Join item 1: the catalogue's modules take the router's ask from operator-channel's contract and client

mesh-sdk holds neither asks nor asker, nor the vault's answer types, and mesh-delivery's
sdk/views is mesh-sdk's go/views (a1-progress.md, the rewrite map for the join): thirteen
modules switch to the operatorchannel contract and client, mesh-sdk's channel package and
meshvault, with go.mod requiring each at the group pin (nats: meshbroker too). The gate
holds the checks mesh-sdk dropped (an ask's id is its key's asking, at least one option),
with a row that fails without them; claude-code refuses a router record whose times do
not parse, as its JSON decode did, with a row.

Claude-Session: https://claude.ai/code/session_01JG73KJNrH5EgFXMKKYSrig
This commit is contained in:
2026-11-04 15:33:27 +01:00
parent 87a30055a6
commit 9d83987815
103 changed files with 1380 additions and 1105 deletions
+7 -6
View File
@@ -25,7 +25,8 @@ import (
"path/filepath"
"strings"
"git.novox.be/novox/mesh-sdk/go/asks"
occ "git.novox.be/novox/mesh-contracts/go/clients/operatorchannelclient"
oc "git.novox.be/novox/mesh-contracts/go/seats/operatorchannel"
)
// ForgeProvision is the provision whose credential this is, and the file this module's manifest has it delivered to.
@@ -55,16 +56,16 @@ func (f *ForgeHealth) key() string { return "claude-code.machine." + f.Node + ".
// Look is the condition to say after one placing of the credential, nil when nothing changed: raised on a failure, again
// when its reason changes, cleared when it places again; on the first look a success clears what a run before raised.
func (f *ForgeHealth) Look(err error) *asks.Condition {
func (f *ForgeHealth) Look(err error) *oc.Condition {
first := !f.looked
f.looked = true
subject := asks.Scope{Scope: "machine", ID: f.Node, Machine: f.Node}
subject := oc.ConditionSubject{Scope: "machine", ID: f.Node, Machine: f.Node}
if err == nil {
if f.raised == "" && !first {
return nil
}
f.raised = ""
return &asks.Condition{Key: f.key(), Subject: subject, State: asks.StateCleared}
return &oc.Condition{Key: f.key(), Subject: subject, State: occ.StateCleared}
}
why := err.Error()
if why == f.raised {
@@ -72,10 +73,10 @@ func (f *ForgeHealth) Look(err error) *asks.Condition {
}
f.raised = why
needs := "read claude-code's journal on " + f.Node + " and the owner of its forge credential file"
return &asks.Condition{Key: f.key(), Severity: asks.SeverityWarning, State: asks.StateRaised, Subject: subject,
return &oc.Condition{Key: f.key(), Severity: occ.SeverityWarning, State: occ.StateRaised, Subject: subject,
Headline: "Agents on " + f.Node + " cannot use the forge credential",
Summary: why, Needs: needs,
Explanation: asks.NeedsYou + needs + ". " + sentence(why) + " Until it is placed, an agent there cannot " +
Explanation: occ.NeedsYou + needs + ". " + sentence(why) + " Until it is placed, an agent there cannot " +
"clone or fetch the mesh's private repositories.",
Resolved: "The forge's read credential is placed again on " + f.Node + "."}
}
@@ -12,7 +12,7 @@ import (
"strings"
"testing"
"git.novox.be/novox/mesh-sdk/go/asks"
occ "git.novox.be/novox/mesh-contracts/go/clients/operatorchannelclient"
)
const agentToken = "0123456789abcdef0123456789abcdef01234567"
@@ -316,7 +316,7 @@ func TestTheForgeCredentialIsTheRuntimesAndItsFailureIsAConditionOfTheModule(t *
got := ""
if c != nil {
got = c.State
if _, err := asks.EncodeCondition(*c); err != nil {
if _, err := occ.EncodeCondition(*c); err != nil {
t.Errorf("look %d: the condition is refused: %v", i, err)
}
if c.Key != "claude-code.machine.anchor.forge-credential" {
+5 -4
View File
@@ -22,9 +22,10 @@ import (
"strings"
"time"
occ "git.novox.be/novox/mesh-contracts/go/clients/operatorchannelclient"
"git.novox.be/novox/mesh-contracts/go/seats"
oc "git.novox.be/novox/mesh-contracts/go/seats/operatorchannel"
stdio "git.novox.be/novox/mesh-sdk/go"
"git.novox.be/novox/mesh-sdk/go/asks"
"git.novox.be/novox/mesh-sdk/go/delivery"
"git.novox.be/novox/mesh-sdk/go/handacts"
"git.novox.be/novox/mesh-sdk/go/views"
@@ -184,12 +185,12 @@ func (s memoryStateOf) Delete(key string) error { return s.s.Delete(key) }
var memoryCarrier *MemoryCarrier
// sayCondition says a condition on the operator-channel seat under this module's name (novox/hq ADR 0437 §9).
func sayCondition(c asks.Condition) error {
subject, err := asks.ConditionSubject("claude-code")
func sayCondition(c oc.Condition) error {
subject, err := occ.ConditionSubject("claude-code")
if err != nil {
return err
}
raw, err := asks.EncodeCondition(c)
raw, err := occ.EncodeCondition(c)
if err != nil {
return err
}
@@ -9,8 +9,8 @@ import (
"sync"
"time"
oc "git.novox.be/novox/mesh-contracts/go/seats/operatorchannel"
stdio "git.novox.be/novox/mesh-sdk/go"
"git.novox.be/novox/mesh-sdk/go/asks"
)
// Proposing is what the proposal tools reach: the `proposals` state, the operator channel, the controller's
@@ -71,9 +71,13 @@ func (d *Proposing) watch(p Paths) {
}
}
go persist("hearing the operator's answers", func() error {
return stdio.Subscribe(asks.Seat+".decided."+Module, func(e stdio.Envelope) error {
var w asks.Warrant
if json.Unmarshal(e.Body, &w) == nil {
return stdio.Subscribe(oc.Seat+".decided."+Module, func(e stdio.Envelope) error {
var w oc.Warrant
if err := json.Unmarshal(e.Body, &w); err == nil {
if _, err := warrantAt(w); err != nil {
say("not taking the router's word on %s: %v", w.Ask, err)
return nil
}
if strings.HasPrefix(w.Ask, QuestionPrefix) {
HeardQuestionEnd(d.questions(), w)
return nil
@@ -15,7 +15,7 @@ package main
// 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
// (oc.Warrant.For), and the option's bound act is this proposal's (oc.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
@@ -42,8 +42,9 @@ import (
"unicode"
"unicode/utf8"
occ "git.novox.be/novox/mesh-contracts/go/clients/operatorchannelclient"
oc "git.novox.be/novox/mesh-contracts/go/seats/operatorchannel"
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.
@@ -68,7 +69,7 @@ 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
const ProposalLasts = occ.ApproveLasts - 10*time.Minute
// What a proposal does.
const (
@@ -164,8 +165,8 @@ func joinAnd(list []string) string {
// 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,
func (p Proposal) Act(option string) occ.Act {
return occ.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)}
}
@@ -175,7 +176,7 @@ const AskGlance = 1
// 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 — in the form it was asked in.
func (p Proposal) Ask() asks.Ask {
func (p Proposal) Ask() oc.Ask {
if p.Form == AskGlance {
return p.glanceAsk()
}
@@ -185,7 +186,7 @@ func (p Proposal) Ask() asks.Ask {
// glanceAsk is the ask read at a glance on the phone (novox/hq issue 639): its headline, and as its explanation
// the summary of what it does; the text, where it came from, its fingerprint and where to read all of it are its
// Details, which the router notes after the answers, collapsed where the channel can.
func (p Proposal) glanceAsk() asks.Ask {
func (p Proposal) glanceAsk() oc.Ask {
verb := map[string]string{ActionAdd: "Add", ActionReplace: "Replace", ActionRemove: "Remove"}[p.Action]
shown, whole := excerpt(p.Text)
part := ""
@@ -193,7 +194,7 @@ func (p Proposal) glanceAsk() asks.Ask {
part = " Part shown"
}
headline := fmt.Sprintf("%s Claude instructions '%s'?%s", verb, p.Name, part)
if len([]rune(headline)) > asks.HeadlineLength {
if len([]rune(headline)) > occ.HeadlineLength {
headline = verb + " Claude instructions?" + part
}
var d strings.Builder
@@ -214,7 +215,7 @@ func (p Proposal) glanceAsk() asks.Ask {
if p.Action == ActionRemove {
does = "the instructions are removed from those sessions"
}
return p.withOptions(asks.Ask{ID: p.ID, Headline: headline, Explanation: p.summary(), Details: d.String()}, does)
return p.withOptions(oc.Ask{ID: p.ID, Headline: headline, Explanation: p.summary(), Details: d.String()}, does)
}
// summary is what the proposal does, at a glance: its proposer's words, or the module's.
@@ -244,8 +245,8 @@ func SummaryProblem(summary string) string {
return ""
case strings.Contains(s, "\n"):
return "the summary is more than one line"
case len([]rune(s)) > asks.SummaryLength:
return fmt.Sprintf("the summary is %d characters, more than the %d a phone shows at a glance", len([]rune(s)), asks.SummaryLength)
case len([]rune(s)) > occ.SummaryLength:
return fmt.Sprintf("the summary is %d characters, more than the %d a phone shows at a glance", len([]rune(s)), occ.SummaryLength)
case !shownLine(s):
return "the summary names a path, an address, a host name or a long word, which a phone is not shown"
}
@@ -254,8 +255,8 @@ func SummaryProblem(summary string) string {
// withOptions is an ask with what every form of it carries: who answers, its expiry, what it is about, and its two
// options, each binding its act.
func (p Proposal) withOptions(a asks.Ask, does string) asks.Ask {
a.Who, a.Expires, a.OnExpiry = asks.Operator, p.At.Add(ProposalLasts), "the proposal is discarded; nothing changes"
func (p Proposal) withOptions(a oc.Ask, does string) oc.Ask {
a.Who, a.Expires, a.OnExpiry = occ.Operator, occ.FormatTime(p.At.Add(ProposalLasts)), "the proposal is discarded; nothing changes"
a.About = "instructions." + p.Scope + "." + strings.Join(p.Nodes, "+") + "." + p.Name
if len(p.Nodes) == 0 {
a.About = "instructions." + p.Scope + "." + p.Name
@@ -264,15 +265,15 @@ func (p Proposal) withOptions(a asks.Ask, does string) asks.Ask {
{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})
binds, _ := occ.ActDigest(p.Act(o.id))
a.Options = append(a.Options, oc.Option{ID: o.id, Label: o.label, Does: o.does, Level: occ.Approve, Binds: binds})
}
return a
}
// listedAsk is the ask as it was composed before issue 639, the text in its explanation: composed only to hold the
// warrant of a proposal made then.
func (p Proposal) listedAsk() asks.Ask {
func (p Proposal) listedAsk() oc.Ask {
verb := map[string]string{ActionAdd: "Add", ActionReplace: "Replace", ActionRemove: "Remove"}[p.Action]
shown, whole := excerpt(p.Text)
part := ""
@@ -280,7 +281,7 @@ func (p Proposal) listedAsk() asks.Ask {
part = " Part shown"
}
headline := fmt.Sprintf("%s Claude instructions '%s'?%s", verb, p.Name, part)
if len([]rune(headline)) > asks.HeadlineLength {
if len([]rune(headline)) > occ.HeadlineLength {
headline = verb + " Claude instructions?" + part
}
var b strings.Builder
@@ -311,8 +312,8 @@ func (p Proposal) listedAsk() asks.Ask {
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",
a := oc.Ask{ID: p.ID, Headline: headline, Explanation: strings.TrimRight(b.String(), "\n"), Who: occ.Operator,
Expires: occ.FormatTime(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
@@ -321,8 +322,8 @@ func (p Proposal) listedAsk() asks.Ask {
{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})
binds, _ := occ.ActDigest(p.Act(o.id))
a.Options = append(a.Options, oc.Option{ID: o.id, Label: o.label, Does: o.does, Level: occ.Approve, Binds: binds})
}
return a
}
@@ -433,7 +434,7 @@ func invisible(text string) string {
// write the record and ask for it under this module's name.
func ProposalProblem(p Proposal) string {
switch {
case !asks.UsableID(p.ID):
case !occ.UsableID(p.ID):
return "its id is not one"
case !itemName.MatchString(p.Name):
return "its name is not one"
@@ -838,21 +839,21 @@ func Propose(p Paths, in ProposeInput, store ProposalStore, bus AskBus, now time
if why := approverReady(bus); why != "" {
return refuse(why + "; " + AtTheTerminalInstead)
}
if waiting := waitingProposals(store, approved, now); waiting >= asks.MostOpen {
if waiting := waitingProposals(store, approved, now); waiting >= occ.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))
"questions at once; claude_code_proposals lists them", waiting, occ.MostOpen))
}
if problem := ProposalProblem(prop); problem != "" {
return refuse(problem)
}
ask := prop.Ask()
if err := ask.Check(now); err != nil {
if err := occ.Check(ask, 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 {
if err := bus.Publish(oc.AcceptSubjectAsk(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)
@@ -874,11 +875,15 @@ func Propose(p Paths, in ProposeInput, store ProposalStore, bus AskBus, now time
}
time.Sleep(RouterAnswersEvery)
}
expires, err := askExpires(ask)
if err != nil {
return nil, err
}
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),
"expires": 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
@@ -927,10 +932,10 @@ var (
// read.
var (
endedMu sync.Mutex
ended = map[string]asks.Warrant{}
ended = map[string]oc.Warrant{}
)
func endedAsk(id string) (asks.Warrant, bool) {
func endedAsk(id string) (oc.Warrant, bool) {
endedMu.Lock()
defer endedMu.Unlock()
w, ok := ended[id]
@@ -940,8 +945,8 @@ func endedAsk(id string) (asks.Warrant, bool) {
// 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) {
func HeardEnd(p Paths, store ProposalStore, w oc.Warrant, now time.Time) (string, error) {
if w.Asker != Module || w.Outcome == occ.OutcomeChosen || !occ.UsableID(w.Ask) {
return "", nil
}
reconcileMu.Lock()
@@ -968,7 +973,7 @@ func HeardEnd(p Paths, store ProposalStore, w asks.Warrant, now time.Time) (stri
}
// 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}
d := ProposalDecision{At: now.UTC(), Says: occ.Says(w), Outcome: "discarded: the ask " + string(w.Outcome), Heard: true}
if w.Words != "" {
d.Outcome += " (" + w.Words + ")"
}
@@ -986,9 +991,9 @@ func HeardEnd(p Paths, store ProposalStore, w asks.Warrant, now time.Time) (stri
// 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"`
State string `json:"state"`
Ask *oc.Ask `json:"ask"`
Warrant *oc.Warrant `json:"warrant"`
}
var reconcileMu sync.Mutex
@@ -1010,7 +1015,7 @@ func Reconcile(p Paths, store ProposalStore, bus AskBus, record Recorder, now ti
}
type closed struct {
prop Proposal
w asks.Warrant
w oc.Warrant
}
var ready []closed
discarded := false
@@ -1027,8 +1032,8 @@ func Reconcile(p Paths, store ProposalStore, bus AskBus, record Recorder, now ti
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 {
if err == nil && entry != nil && json.Unmarshal(entry.Value, &rec) == nil && rec.check() == nil && rec.Warrant != nil &&
rec.Warrant.Outcome == occ.OutcomeChosen {
ready = append(ready, closed{prop, *rec.Warrant})
}
continue
@@ -1046,12 +1051,16 @@ func Reconcile(p Paths, store ProposalStore, bus AskBus, record Recorder, now ti
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 {
if json.Unmarshal(entry.Value, &rec) != nil || rec.check() != 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) })
sort.SliceStable(ready, func(i, j int) bool {
a, _ := warrantAt(ready[i].w) // checked when read
b, _ := warrantAt(ready[j].w)
return a.Before(b)
})
changed, wrote := false, discarded
var said []string
for _, c := range ready {
@@ -1108,17 +1117,21 @@ func Reconcile(p Paths, store ProposalStore, bus AskBus, record Recorder, now ti
// 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()}
func decide(p Paths, approved *Approved, prop Proposal, w oc.Warrant, now time.Time) (ProposalDecision, bool, string, bool) {
d := ProposalDecision{At: now.UTC(), Says: occ.Says(w)}
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
}
at, err := warrantAt(w)
if err != nil {
return refused(err.Error())
}
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 {
if w.Outcome != occ.OutcomeChosen {
d.Outcome = "discarded: the ask " + string(w.Outcome)
if w.Words != "" {
d.Outcome += " (" + w.Words + ")"
@@ -1130,11 +1143,11 @@ func decide(p Paths, approved *Approved, prop Proposal, w asks.Warrant, now time
if problem := ProposalProblem(prop); problem != "" {
return refused("the proposal is not one this module makes: " + problem)
}
option, err := w.For(Module, prop.Ask())
option, err := occ.For(w, Module, prop.Ask())
if err != nil {
return refused(err.Error())
}
if err := option.Performs(prop.Act(option.ID)); err != nil {
if err := occ.Performs(option, prop.Act(option.ID)); err != nil {
return refused(err.Error())
}
d.Warranted = true
@@ -1146,7 +1159,7 @@ func decide(p Paths, approved *Approved, prop Proposal, w asks.Warrant, now time
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) {
if last, ok := approved.Changed[key]; ok && !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
}
@@ -1157,16 +1170,16 @@ func decide(p Paths, approved *Approved, prop Proposal, w asks.Warrant, now time
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()
approved.Changed[key] = 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()}
Digest: prop.Digest, Proposal: prop.ID, From: prop.From, Approved: at.UTC(), By: occ.Says(w)}
}
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())
approved.Changed[key] = at.UTC()
say("proposal %s (%s %s %s, fingerprint %s): %s", prop.ID, prop.Action, prop.Scope, prop.Name, prop.Fingerprint(), occ.Says(w))
return d, reaches, "proposal " + prop.ID + ": approved, " + prop.Action + " " + prop.Name, true
}
@@ -1222,12 +1235,12 @@ func ListProposals(p Paths, store ProposalStore, bus AskBus, id string, now time
// 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 {
if json.Unmarshal(entry.Value, &rec) == nil && rec.check() == 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():
case occ.Digest(*rec.Ask) == occ.Digest(prop.Ask()):
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"
@@ -13,8 +13,9 @@ import (
"testing"
"time"
occ "git.novox.be/novox/mesh-contracts/go/clients/operatorchannelclient"
oc "git.novox.be/novox/mesh-contracts/go/seats/operatorchannel"
stdio "git.novox.be/novox/mesh-sdk/go"
"git.novox.be/novox/mesh-sdk/go/asks"
)
// memProposals is the `proposals` state, shared by every node of a test as the bus shares it.
@@ -46,7 +47,7 @@ func (s *memProposals) Get(key string) (json.RawMessage, bool, error) {
// router is the operator channel's holder as the module reaches it: what was asked, its record of each ask, and
// the channel kinds.
type router struct {
published map[string]asks.Ask
published map[string]oc.Ask
subjects []string
records map[string]json.RawMessage
kinds []stdio.KindHeld
@@ -54,7 +55,7 @@ type router struct {
}
func newRouter() *router {
return &router{published: map[string]asks.Ask{}, records: map[string]json.RawMessage{},
return &router{published: map[string]oc.Ask{}, records: map[string]json.RawMessage{},
kinds: []stdio.KindHeld{{Seat: "channel", Kind: "telegram", Module: "telegram", Node: "novox",
Capabilities: []string{"choice", "exact-render", "verified-sender"}}}}
}
@@ -65,7 +66,7 @@ func (r *router) Publish(subject string, body any, id string) error {
}
r.subjects = append(r.subjects, subject)
raw, _ := json.Marshal(body)
var a asks.Ask
var a oc.Ask
_ = json.Unmarshal(raw, &a)
r.published[a.ID] = a
r.records[a.ID], _ = json.Marshal(map[string]any{"state": "open"})
@@ -81,19 +82,19 @@ func (r *router) Record(id string) (*stdio.StateEntry, error) {
func (r *router) Kinds() ([]stdio.KindHeld, error) { return r.kinds, nil }
// close ends an ask with a warrant, as the router records it.
func (r *router) close(w asks.Warrant) {
func (r *router) close(w oc.Warrant) {
w.Asker = Module
state := string(w.Outcome)
r.records[w.Ask], _ = json.Marshal(map[string]any{"state": state, "warrant": w})
}
// chose is the warrant for a person's choice of an option of the ask as the router took it.
func (r *router) chose(id, option string, at time.Time) asks.Warrant {
func (r *router) chose(id, option string, at time.Time) oc.Warrant {
a := r.published[id]
o, _ := a.Option(option)
return asks.Warrant{Ask: id, Asker: Module, Outcome: asks.OutcomeChosen, Option: o.ID, Label: o.Label, Level: o.Level,
By: &asks.Person{Who: asks.Operator, Kind: "telegram", Identity: "42", Verified: "user id verified"},
Channel: "telegram", Proofs: []string{"P1"}, At: at, AskDigest: a.Digest()}
o, _ := occ.OptionOf(a, option)
return oc.Warrant{Ask: id, Asker: Module, Outcome: occ.OutcomeChosen, Option: o.ID, Label: o.Label, Level: o.Level,
By: &oc.Person{Who: occ.Operator, Kind: "telegram", Identity: "42", Verified: "user id verified"},
Channel: "telegram", Proofs: []string{"P1"}, At: occ.FormatTime(at), AskDigest: occ.Digest(a)}
}
type recorded struct{ asks []string }
@@ -144,13 +145,13 @@ func TestAProposalAsksTheOperatorAtTheLevelApproveAndChangesNothing(t *testing.T
t.Fatalf("the ask was published on %v, not under this module's own name", r.subjects)
}
a := r.published[id]
if err := a.Check(t0); err != nil {
if err := occ.Check(a, t0); err != nil {
t.Fatalf("the router would refuse the ask: %v", err)
}
// At a glance (issue 639): what it does is the explanation, one line; the text is the Details, which the
// router notes collapsed after the answers.
if a.Explanation != "Every Claude session on the mesh reads these 4 lines as the mesh's own word, from its next start until they are removed the same way." ||
asks.Glance(a.Explanation) != nil {
occ.Glance(a.Explanation) != nil {
t.Errorf("the phone does not say what is asked at a glance: %q", a.Explanation)
}
if a.Headline != "Add Claude instructions 'handoff-from-g14'?" {
@@ -163,11 +164,11 @@ func TestAProposalAsksTheOperatorAtTheLevelApproveAndChangesNothing(t *testing.T
t.Fatalf("options: %+v", a.Options)
}
for _, o := range a.Options {
if o.Level != asks.Approve || !strings.HasPrefix(o.Binds, "sha256:") {
if o.Level != occ.Approve || !strings.HasPrefix(o.Binds, "sha256:") {
t.Errorf("the option %s is %s binding %q: both answers need a proven sender and bind the act", o.ID, o.Level, o.Binds)
}
}
if !a.Expires.Equal(t0.Add(ProposalLasts)) {
if a.Expires != occ.FormatTime(t0.Add(ProposalLasts)) {
t.Errorf("expires %s", a.Expires)
}
// Nothing is active, here or anywhere, while the ask is open.
@@ -212,10 +213,10 @@ func TestNothingBecomesActiveWithoutAValidWarrant(t *testing.T) {
}{
{"declined", func(r *router, _ *memProposals, id string) { r.close(r.chose(id, OptionDecline, t0.Add(time.Hour))) }, "declined"},
{"expired", func(r *router, _ *memProposals, id string) {
r.close(asks.Warrant{Ask: id, Outcome: asks.OutcomeExpired, At: t0.Add(24 * time.Hour)})
r.close(oc.Warrant{Ask: id, Outcome: occ.OutcomeExpired, At: occ.FormatTime(t0.Add(24 * time.Hour))})
}, "discarded"},
{"refused by the router", func(r *router, _ *memProposals, id string) {
r.close(asks.Warrant{Ask: id, Outcome: asks.OutcomeRefused, Words: "no channel can carry it", At: t0})
r.close(oc.Warrant{Ask: id, Outcome: occ.OutcomeRefused, Words: "no channel can carry it", At: occ.FormatTime(t0)})
}, "discarded"},
{"the text changed after the ask, with its digest", func(r *router, store *memProposals, id string) {
var prop Proposal
@@ -243,7 +244,7 @@ func TestNothingBecomesActiveWithoutAValidWarrant(t *testing.T) {
}, "refused"},
{"answered at the level acknowledge", func(r *router, _ *memProposals, id string) {
w := r.chose(id, OptionApprove, t0.Add(time.Hour))
w.Level = asks.Acknowledge
w.Level = occ.Acknowledge
r.close(w)
}, "refused"},
{"no person chose", func(r *router, _ *memProposals, id string) {
@@ -428,7 +429,7 @@ func TestALongTextIsShownInPartAndReadWholeAtTheTerminal(t *testing.T) {
!strings.Contains(a.Details, "fingerprint "+out["fingerprint"].(string)) {
t.Errorf("the phone does not say where to read all of it: %q", a.Details)
}
if len(a.Details) > 3000 || asks.Glance(a.Explanation) != nil {
if len(a.Details) > 3000 || occ.Glance(a.Explanation) != nil {
t.Errorf("the details are %d bytes; the explanation %q", len(a.Details), a.Explanation)
}
list, err := ListProposals(p, store, r, id, t0)
@@ -496,7 +497,7 @@ func TestATextChangedPastWhatThePhoneShowsIsRefused(t *testing.T) {
_ = json.Unmarshal(store.m[id], &prop)
prop.Text = strings.Replace(prop.Text, "Ask before you push.", "Push without asking.", 1)
_ = store.Put(id, prop)
if prop.Ask().Digest() != r.published[id].Digest() {
if occ.Digest(prop.Ask()) != occ.Digest(r.published[id]) {
t.Fatal("the test means a change the ask's words do not show")
}
r.close(r.chose(id, OptionApprove, t0.Add(time.Hour)))
@@ -559,7 +560,7 @@ func TestAForgedRecordIsNotAppliedEvenWhenApproved(t *testing.T) {
} {
prop.Digest = textDigest(prop.Text)
_ = store.Put(prop.ID, prop)
if err := r.Publish(asks.AskSubject(Module), prop.Ask(), prop.ID); err != nil {
if err := r.Publish(oc.AcceptSubjectAsk(Module), prop.Ask(), prop.ID); err != nil {
t.Fatal(err)
}
r.close(r.chose(prop.ID, OptionApprove, t0.Add(time.Hour)))
@@ -718,8 +719,8 @@ func (r *refusingRouter) Publish(subject string, body any, id string) error {
r.id = id
go func() {
time.Sleep(50 * time.Millisecond)
_, _ = HeardEnd(r.p, r.store, asks.Warrant{Ask: id, Asker: Module, Outcome: asks.OutcomeRefused,
Words: "no channel can carry any of its answers now: telegram is not linked", At: t0}, t0)
_, _ = HeardEnd(r.p, r.store, oc.Warrant{Ask: id, Asker: Module, Outcome: occ.OutcomeRefused,
Words: "no channel can carry any of its answers now: telegram is not linked", At: occ.FormatTime(t0)}, t0)
}()
return nil
}
@@ -729,12 +730,23 @@ func TestAnAskTakenAfterItsRefusalIsStillAnswered(t *testing.T) {
p, _ := node(t, "novox")
store, r := &memProposals{m: map[string]json.RawMessage{}}, newRouter()
id := proposeHandover(t, p, store, r)
if _, err := HeardEnd(p, store, asks.Warrant{Ask: id, Asker: Module, Outcome: asks.OutcomeRefused, Words: "no channel", At: t0}, t0); err != nil {
if _, err := HeardEnd(p, store, oc.Warrant{Ask: id, Asker: Module, Outcome: occ.OutcomeRefused, Words: "no channel", At: occ.FormatTime(t0)}, t0); err != nil {
t.Fatal(err)
}
if got := outcomeHere(t, p, id); !strings.HasPrefix(got, "discarded") {
t.Fatalf("decided %q", got)
}
// A warrant whose time does not parse is refused, naming the field, as the decode of it was.
bad := r.chose(id, OptionApprove, t0.Add(time.Hour))
bad.At = "yesterday"
prop, _ := readProposal(store, id)
appr := Approved{Decided: map[string]ProposalDecision{}, Changed: map[string]time.Time{}, Refused: map[string]string{}}
if d, _, _, _ := decide(p, &appr, prop, bad, t0.Add(time.Hour)); !strings.HasPrefix(d.Outcome, "refused: the warrant's at") {
t.Errorf("a warrant with a malformed at was decided %q", d.Outcome)
}
if (routerRecord{Warrant: &bad}).check() == nil {
t.Error("a router record with a malformed at passed its check")
}
r.close(r.chose(id, OptionApprove, t0.Add(time.Hour)))
if changed, _, _ := Reconcile(p, store, r, nil, t0.Add(time.Hour)); !changed {
t.Fatal("the approval of an ask taken after its refusal was lost")
@@ -769,7 +781,7 @@ func TestAProposalIsAskedAtAGlanceAndAnOlderOneAsItWasAsked(t *testing.T) {
older := prop
older.Form = 0
listed := older.Ask()
if !strings.Contains(listed.Explanation, "The whole text:") || listed.Details != "" || listed.Digest() == prop.Ask().Digest() {
if !strings.Contains(listed.Explanation, "The whole text:") || listed.Details != "" || occ.Digest(listed) == occ.Digest(prop.Ask()) {
t.Errorf("a proposal made before is not composed as it was asked: %q", listed.Explanation)
}
}
@@ -11,7 +11,7 @@ package main
//
// The session reads the answer by the question's key with claude_code_answer: from the router's own record of the
// ask, which only the router writes and only this module reads, held to the question as the `questions` state keeps
// it (asks.Warrant.For and asks.Option.Performs). A record that does not hold is said as not proven, never as a
// it (oc.Warrant.For and oc.Option.Performs). A record that does not hold is said as not proven, never as a
// choice. An answer informs the session; it authorises nothing (ADR 0286 stays the way to an approved call).
import (
@@ -27,8 +27,9 @@ import (
"sync"
"time"
occ "git.novox.be/novox/mesh-contracts/go/clients/operatorchannelclient"
oc "git.novox.be/novox/mesh-contracts/go/seats/operatorchannel"
stdio "git.novox.be/novox/mesh-sdk/go"
"git.novox.be/novox/mesh-sdk/go/asks"
)
// QuestionsState is the module's state the questions are kept in.
@@ -38,16 +39,16 @@ const QuestionsState = "questions"
const QuestionPrefix = "q-"
// MostQuestions is how many questions may wait at once, mesh-wide: the router allows an asker three open asks
// (asks.MostOpen), and questions alone never fill them, so one is left for a proposed instruction section (ADR 0274)
// (occ.MostOpen), and questions alone never fill them, so one is left for a proposed instruction section (ADR 0274)
// while two questions are open. Each open question holds one slot key in the `questions` state (slotKey), taken by
// create or by compare-and-set, so two nodes asking at once cannot both take the last one.
const MostQuestions = asks.MostOpen - 1
const MostQuestions = occ.MostOpen - 1
// A question the router holds no record of, and whose refusal was not heard here, keeps its slot until it expires:
// the router may still have it in its queue (down, or backlogged), and neither the queue's order nor a cancel can
// take it back — the queue offers a piece of work again after an error, so a cancel may be taken before the ask it
// cancels, and a cancel of an ask not yet recorded is dropped. Once the question has expired the router refuses it
// when it is taken (asks.Ask.Check), so the slot is free and the question has ended.
// when it is taken (oc.Ask.Check), so the slot is free and the question has ended.
// slotKey is the key of one of the MostQuestions slots: not a question's key, so no question is ever read from it.
func slotKey(n int) string { return fmt.Sprintf("slot-%d", n) }
@@ -69,7 +70,7 @@ const (
// The bounds of what a question says.
const (
FewestQuestionOptions = 2
MostContextBytes = asks.SummaryLength
MostContextBytes = occ.SummaryLength
MostMeansBytes = 200
MostWhyBytes = 120
MostOnExpiryBytes = 200
@@ -151,8 +152,8 @@ func (q Question) recommended() (QuestionOption, bool) {
}
// Act is what an answer binds: this question, whole, and the option chosen.
func (q Question) Act(option string) asks.Act {
act := asks.Act{"verb": Module + ".question", "option": option, "question": q.ID, "headline": q.Headline,
func (q Question) Act(option string) occ.Act {
act := occ.Act{"verb": Module + ".question", "option": option, "question": q.ID, "headline": q.Headline,
"context": q.Context, "why": q.Why, "on-expiry": q.OnExpiry, "by": q.By, "from": q.From,
"at": q.At.UTC().Format(time.RFC3339Nano), "expires": q.Expires.UTC().Format(time.RFC3339Nano),
"options": strconv.Itoa(len(q.Options))}
@@ -163,7 +164,7 @@ func (q Question) Act(option string) asks.Act {
}
// Ask is the ask for a question, composed from it alone, in the form it was asked in.
func (q Question) Ask() asks.Ask {
func (q Question) Ask() oc.Ask {
if q.Form == AskGlance {
return q.glanceAsk()
}
@@ -172,31 +173,31 @@ func (q Question) Ask() asks.Ask {
// glanceAsk is the question read at a glance on the phone (novox/hq issue 639): its context, then the recommended
// answer and why as its one point; who asked and what the answer does are its Details.
func (q Question) glanceAsk() asks.Ask {
func (q Question) glanceAsk() oc.Ask {
explanation := strings.TrimSpace(q.Context)
if rec, ok := q.recommended(); ok {
explanation += fmt.Sprintf("\n• Recommended: %s, because %s.", rec.Label, strings.TrimRight(strings.TrimSpace(q.Why), "."))
}
details := fmt.Sprintf("Asked by the agent session %s on %s through the claude-code module, at %s UTC. Your answer is told "+
"to that session; it performs nothing by itself.", q.By, q.From, q.At.UTC().Format("15:04 on 2 Jan"))
return q.withOptions(asks.Ask{ID: q.ID, Headline: q.Headline, Explanation: explanation, Details: details})
return q.withOptions(oc.Ask{ID: q.ID, Headline: q.Headline, Explanation: explanation, Details: details})
}
func (q Question) withOptions(a asks.Ask) asks.Ask {
a.Who, a.Expires, a.OnExpiry = asks.Operator, q.Expires, q.OnExpiry
func (q Question) withOptions(a oc.Ask) oc.Ask {
a.Who, a.Expires, a.OnExpiry = occ.Operator, occ.FormatTime(q.Expires), q.OnExpiry
for _, o := range q.Options {
does := o.Means
if o.Recommended {
does = "Recommended. " + does
}
binds, _ := asks.ActDigest(q.Act(o.ID))
a.Options = append(a.Options, asks.Option{ID: o.ID, Label: o.Label, Does: does, Level: asks.Approve, Binds: binds})
binds, _ := occ.ActDigest(q.Act(o.ID))
a.Options = append(a.Options, oc.Option{ID: o.ID, Label: o.Label, Does: does, Level: occ.Approve, Binds: binds})
}
return a
}
// listedAsk is the ask as it was composed before issue 639: composed only to hold the answer to a question asked then.
func (q Question) listedAsk() asks.Ask {
func (q Question) listedAsk() oc.Ask {
var b strings.Builder
b.WriteString(strings.TrimSpace(q.Context))
if rec, ok := q.recommended(); ok {
@@ -205,15 +206,15 @@ func (q Question) listedAsk() asks.Ask {
}
fmt.Fprintf(&b, "\n\nAsked by the agent session %s on %s through the claude-code module, at %s UTC. "+
"Your answer is told to that session; it performs nothing by itself.", q.By, q.From, q.At.UTC().Format("15:04 on 2 Jan"))
a := asks.Ask{ID: q.ID, Headline: q.Headline, Explanation: b.String(), Who: asks.Operator, Expires: q.Expires,
a := oc.Ask{ID: q.ID, Headline: q.Headline, Explanation: b.String(), Who: occ.Operator, Expires: occ.FormatTime(q.Expires),
OnExpiry: q.OnExpiry}
for _, o := range q.Options {
does := o.Means
if o.Recommended {
does = "Recommended. " + does
}
binds, _ := asks.ActDigest(q.Act(o.ID))
a.Options = append(a.Options, asks.Option{ID: o.ID, Label: o.Label, Does: does, Level: asks.Approve, Binds: binds})
binds, _ := occ.ActDigest(q.Act(o.ID))
a.Options = append(a.Options, oc.Option{ID: o.ID, Label: o.Label, Does: does, Level: occ.Approve, Binds: binds})
}
return a
}
@@ -228,8 +229,8 @@ func QuestionProblem(q Question) string {
switch {
case strings.TrimSpace(q.Headline) == "":
add("it has no headline")
case len([]rune(q.Headline)) > asks.HeadlineLength:
add("its headline is %d characters, more than %d", len([]rune(q.Headline)), asks.HeadlineLength)
case len([]rune(q.Headline)) > occ.HeadlineLength:
add("its headline is %d characters, more than %d", len([]rune(q.Headline)), occ.HeadlineLength)
}
switch {
case strings.TrimSpace(q.Context) == "":
@@ -239,16 +240,16 @@ func QuestionProblem(q Question) string {
case strings.Contains(strings.TrimSpace(q.Context), "\n"):
add("its context is more than one line: a phone shows it at a glance as one short paragraph")
}
if n := len(q.Options); n < FewestQuestionOptions || n > asks.MostOptions {
add("it offers %d options; a question offers %d to %d", n, FewestQuestionOptions, asks.MostOptions)
if n := len(q.Options); n < FewestQuestionOptions || n > occ.MostOptions {
add("it offers %d options; a question offers %d to %d", n, FewestQuestionOptions, occ.MostOptions)
}
recommended, labels := 0, map[string]bool{}
for i, o := range q.Options {
switch {
case strings.TrimSpace(o.Label) == "":
add("option %d has no label", i+1)
case len([]rune(o.Label)) > asks.LabelLength:
add("option %d's label is longer than %d characters", i+1, asks.LabelLength)
case len([]rune(o.Label)) > occ.LabelLength:
add("option %d's label is longer than %d characters", i+1, occ.LabelLength)
case labels[strings.ToLower(o.Label)]:
add("two options are labelled %q", o.Label)
}
@@ -258,7 +259,7 @@ func QuestionProblem(q Question) string {
} else if len(o.Means) > MostMeansBytes {
add("what option %d means is longer than %d bytes", i+1, MostMeansBytes)
}
if !asks.UsableID(o.ID) {
if !occ.UsableID(o.ID) {
add("option %d's id is not one", i+1)
}
if o.Recommended {
@@ -368,7 +369,7 @@ func AskQuestion(p Paths, in QuestionInput, store QuestionStore, bus AskBus, now
return refuse(problem)
}
ask := q.Ask()
if err := ask.Check(now); err != nil {
if err := occ.Check(ask, now); err != nil {
return refuse(err.Error())
}
// The question is kept before its slot is taken, so a slot always names a question another node can judge.
@@ -384,7 +385,7 @@ func AskQuestion(p Paths, in QuestionInput, store QuestionStore, bus AskBus, now
"at once, so one of the router's three open asks is left for a proposed instruction section; read them with "+
"claude_code_answer, or wait for one to end", len(open), strings.Join(open, ", "), MostQuestions))
}
if err := bus.Publish(asks.AskSubject(Module), ask, ask.ID); err != nil {
if err := bus.Publish(oc.AcceptSubjectAsk(Module), ask, ask.ID); err != nil {
_ = store.Put(q.ID, tombstone(q, "not asked: "+err.Error()))
return nil, fmt.Errorf("the operator could not be asked: %v", err)
}
@@ -517,10 +518,10 @@ func readQuestionErr(store QuestionStore, key string) (Question, bool, error) {
// The router's word that a question ended without a choice, as heard on the decided subject.
var (
questionEndsMu sync.Mutex
questionEnds = map[string]asks.Warrant{}
questionEnds = map[string]oc.Warrant{}
)
func questionEnd(id string) (asks.Warrant, bool) {
func questionEnd(id string) (oc.Warrant, bool) {
questionEndsMu.Lock()
defer questionEndsMu.Unlock()
w, ok := questionEnds[id]
@@ -537,8 +538,8 @@ func forgetQuestionEnd(id string) {
// replaced — and keeps it on the question, because a refused ask leaves no record of the router's. A refused question
// keeps none of its words (tombstone). A choice is never taken from this word: only from the router's record, held to
// the question (ReadAnswer).
func HeardQuestionEnd(store QuestionStore, w asks.Warrant) {
if w.Asker != Module || w.Outcome == asks.OutcomeChosen || !strings.HasPrefix(w.Ask, QuestionPrefix) {
func HeardQuestionEnd(store QuestionStore, w oc.Warrant) {
if w.Asker != Module || w.Outcome == occ.OutcomeChosen || !strings.HasPrefix(w.Ask, QuestionPrefix) {
return
}
q, known := readQuestion(store, w.Ask)
@@ -550,7 +551,7 @@ func HeardQuestionEnd(store QuestionStore, w asks.Warrant) {
if w.Words != "" {
ended += ": " + w.Words
}
if w.Outcome == asks.OutcomeRefused {
if w.Outcome == occ.OutcomeRefused {
q = tombstone(q, ended)
} else {
q.Ended = ended
@@ -589,7 +590,10 @@ func ReadAnswer(store QuestionStore, bus AskBus, key string, now time.Time) (map
}
var rec routerRecord
if entry != nil {
if err := json.Unmarshal(entry.Value, &rec); err != nil {
if err := json.Unmarshal(entry.Value, &rec); err != nil || rec.check() != nil {
if err == nil {
err = rec.check()
}
out["state"], out["words"] = AnswerNotProven, "the router's record of it cannot be read: "+err.Error()
return out, nil
}
@@ -613,12 +617,12 @@ func ReadAnswer(store QuestionStore, bus AskBus, key string, now time.Time) (map
}
default:
w := *rec.Warrant
if w.Outcome != asks.OutcomeChosen {
if w.Outcome != occ.OutcomeChosen {
out["state"], out["words"] = AnswerEnded, string(w.Outcome)
if w.Words != "" {
out["words"] = string(w.Outcome) + ": " + w.Words
}
if w.Outcome == asks.OutcomeExpired {
if w.Outcome == occ.OutcomeExpired {
out["now"] = "do what you said: " + q.OnExpiry
}
return out, nil
@@ -627,9 +631,9 @@ func ReadAnswer(store QuestionStore, bus AskBus, key string, now time.Time) (map
out["state"], out["words"] = AnswerNotProven, "the question kept is not one this module asks: "+problem
return out, nil
}
option, err := w.For(Module, q.Ask())
option, err := occ.For(w, Module, q.Ask())
if err == nil {
err = option.Performs(q.Act(option.ID))
err = occ.Performs(option, q.Act(option.ID))
}
if err != nil {
say("REFUSED the answer to question %s: %v", q.ID, err)
@@ -642,8 +646,9 @@ func ReadAnswer(store QuestionStore, bus AskBus, key string, now time.Time) (map
chosen["means"], chosen["recommended"] = o.Means, o.Recommended
}
}
out["state"], out["chosen"], out["by"], out["channel"], out["at"] = AnswerChosen, chosen, w.Says(), w.Channel,
w.At.Format(time.RFC3339)
at, _ := warrantAt(w) // checked with the record above
out["state"], out["chosen"], out["by"], out["channel"], out["at"] = AnswerChosen, chosen, occ.Says(w), w.Channel,
at.Format(time.RFC3339)
out["note"] = "the operator's word to this session; it authorises no call that needs the operator's approval"
}
return out, nil
@@ -663,3 +668,37 @@ func WaitForAnswer(store QuestionStore, bus AskBus, key string, wait time.Durati
time.Sleep(AnswerLooksEvery)
}
}
// warrantAt is when a warrant was made; the contract carries it as an RFC 3339 string, and one that does not parse is
// refused, as the decode of the router's word was before the contract carried it as a string.
func warrantAt(w oc.Warrant) (time.Time, error) {
t, err := occ.Time(w.At)
if err != nil {
return time.Time{}, fmt.Errorf("the warrant's at %q is not a time: %w", w.At, err)
}
return t, nil
}
// askExpires is when an ask ends, read from the contract's RFC 3339 string; one that does not parse is refused.
func askExpires(a oc.Ask) (time.Time, error) {
t, err := occ.Time(a.Expires)
if err != nil {
return time.Time{}, fmt.Errorf("the ask's expires %q is not a time: %w", a.Expires, err)
}
return t, nil
}
// check refuses a router record whose warrant or ask carries a time that does not parse.
func (r routerRecord) check() error {
if r.Warrant != nil {
if _, err := warrantAt(*r.Warrant); err != nil {
return err
}
}
if r.Ask != nil {
if _, err := askExpires(*r.Ask); err != nil {
return err
}
}
return nil
}
@@ -12,8 +12,9 @@ import (
"testing"
"time"
occ "git.novox.be/novox/mesh-contracts/go/clients/operatorchannelclient"
oc "git.novox.be/novox/mesh-contracts/go/seats/operatorchannel"
stdio "git.novox.be/novox/mesh-sdk/go"
"git.novox.be/novox/mesh-sdk/go/asks"
)
func mergeTonight() QuestionInput {
@@ -50,18 +51,18 @@ func TestAQuestionIsAskedUnderTheModulesNameAtTheLevelApprove(t *testing.T) {
if !strings.HasPrefix(key, QuestionPrefix) || !strings.Contains(out["router"].(string), "took the question") {
t.Fatalf("answered %v", out)
}
if len(r.subjects) != 1 || r.subjects[0] != asks.AskSubject(Module) {
if len(r.subjects) != 1 || r.subjects[0] != oc.AcceptSubjectAsk(Module) {
t.Fatalf("published on %v", r.subjects)
}
a := r.published[key]
if a.Who != asks.Operator || a.OnExpiry != "I leave the pull request open" || !a.Expires.Equal(t0.Add(QuestionDefault)) {
if a.Who != occ.Operator || a.OnExpiry != "I leave the pull request open" || a.Expires != occ.FormatTime(t0.Add(QuestionDefault)) {
t.Errorf("ask %+v", a)
}
if len(a.Options) != 2 {
t.Fatalf("options %+v", a.Options)
}
for _, o := range a.Options {
if o.Level != asks.Approve || o.Binds == "" {
if o.Level != occ.Approve || o.Binds == "" {
t.Errorf("option %s is %s binding %q: every option is an approval bound to the question", o.ID, o.Level, o.Binds)
}
}
@@ -69,7 +70,7 @@ func TestAQuestionIsAskedUnderTheModulesNameAtTheLevelApprove(t *testing.T) {
t.Errorf("the recommendation is not on the recommended option alone: %+v", a.Options)
}
// At a glance (issue 639): the context and the recommendation are the explanation; who asks is the Details.
if !strings.HasSuffix(a.Explanation, "\n• Recommended: Merge now, because it is reviewed and the checks are green.") || asks.Glance(a.Explanation) != nil {
if !strings.HasSuffix(a.Explanation, "\n• Recommended: Merge now, because it is reviewed and the checks are green.") || occ.Glance(a.Explanation) != nil {
t.Errorf("the explanation does not read at a glance:\n%s", a.Explanation)
}
for _, want := range []string{"the agent session fix-589 on g14", "performs nothing by itself"} {
@@ -77,7 +78,7 @@ func TestAQuestionIsAskedUnderTheModulesNameAtTheLevelApprove(t *testing.T) {
t.Errorf("the details do not say %q:\n%s", want, a.Details)
}
}
if err := a.Check(t0); err != nil {
if err := occ.Check(a, t0); err != nil {
t.Errorf("the ask does not pass the asks contract: %v", err)
}
}
@@ -212,8 +213,8 @@ func (r *questionRefusingRouter) Publish(subject string, body any, id string) er
r.id = id
go func() {
time.Sleep(50 * time.Millisecond)
HeardQuestionEnd(r.store, asks.Warrant{Ask: id, Asker: Module, Outcome: asks.OutcomeRefused,
Words: "its words carry path (a file path), which may not leave the mesh", At: t0})
HeardQuestionEnd(r.store, oc.Warrant{Ask: id, Asker: Module, Outcome: occ.OutcomeRefused,
Words: "its words carry path (a file path), which may not leave the mesh", At: occ.FormatTime(t0)})
}()
return nil
}
@@ -247,7 +248,7 @@ func TestAnExpiredQuestionSaysWhatTheSessionDoes(t *testing.T) {
p, _ := node(t, "g14")
store, r := newMemQuestions(), newRouter()
key := askMergeTonight(t, p, store, r, t0)
r.close(asks.Warrant{Ask: key, Outcome: asks.OutcomeExpired, At: t0.Add(QuestionDefault)})
r.close(oc.Warrant{Ask: key, Outcome: occ.OutcomeExpired, At: occ.FormatTime(t0.Add(QuestionDefault))})
out, err := ReadAnswer(store, r, key, t0.Add(QuestionDefault+time.Minute))
if err != nil || out["state"] != AnswerEnded || out["now"] != "do what you said: I leave the pull request open" {
t.Errorf("expired: %v %v", out, err)
@@ -276,7 +277,7 @@ func TestTheQuestionToolsAreServed(t *testing.T) {
var asked map[string]any
_ = json.Unmarshal([]byte(text), &asked)
key, _ := asked["key"].(string)
if a := r.published[key]; !a.Expires.Equal(t0.Add(30 * time.Minute)) {
if a := r.published[key]; a.Expires != occ.FormatTime(t0.Add(30*time.Minute)) {
t.Errorf("expires %v", a.Expires)
}
r.close(r.chose(key, "o1", t0.Add(time.Minute)))
@@ -381,10 +382,10 @@ func TestTheEdgesOfAQuestionAreAsked(t *testing.T) {
// A warrant naming another asker or another ask, or an option the question did not offer, is no answer to it.
func TestAWarrantForAnotherAskOrOptionIsNotProven(t *testing.T) {
p, _ := node(t, "g14")
for name, change := range map[string]func(*asks.Warrant){
"another asker": func(w *asks.Warrant) { w.Asker = "mesh-controller" },
"another ask": func(w *asks.Warrant) { w.Ask = "q-0000000000000000" },
"an option unoffered": func(w *asks.Warrant) { w.Option, w.Label = "o9", "Delete everything" },
for name, change := range map[string]func(*oc.Warrant){
"another asker": func(w *oc.Warrant) { w.Asker = "mesh-controller" },
"another ask": func(w *oc.Warrant) { w.Ask = "q-0000000000000000" },
"an option unoffered": func(w *oc.Warrant) { w.Option, w.Label = "o9", "Delete everything" },
} {
store, r := newMemQuestions(), newRouter()
key := askMergeTonight(t, p, store, r, t0)
@@ -474,7 +475,7 @@ func TestASlotIsTakenOnlyAtTheRevisionRead(t *testing.T) {
store, r := newMemQuestions(), newRouter()
// Two questions that ended: their slots are free to take again.
for _, id := range []string{askMergeTonight(t, p, store, r, t0), askMergeTonight(t, p, store, r, t0)} {
r.close(asks.Warrant{Ask: id, Outcome: asks.OutcomeExpired, At: t0.Add(time.Minute)})
r.close(oc.Warrant{Ask: id, Outcome: occ.OutcomeExpired, At: occ.FormatTime(t0.Add(time.Minute))})
}
shanks, _ := node(t, "shanks")
other := newRouter()
@@ -487,7 +488,7 @@ func TestASlotIsTakenOnlyAtTheRevisionRead(t *testing.T) {
From: shanks.Node, At: t0, Expires: t0.Add(time.Hour),
Options: []QuestionOption{{ID: "o1", Label: "A", Means: "a", Recommended: true}, {ID: "o2", Label: "B", Means: "b"}}}
_ = store.Put(q.ID, q)
_ = other.Publish(asks.AskSubject(Module), q.Ask(), q.ID)
_ = other.Publish(oc.AcceptSubjectAsk(Module), q.Ask(), q.ID)
r.records[q.ID] = other.records[q.ID]
_ = store.Put(key, slot{Question: q.ID, At: t0})
}
+21 -21
View File
@@ -35,7 +35,7 @@ import (
"sync"
"time"
"git.novox.be/novox/mesh-sdk/go/asks"
"git.novox.be/novox/mesh-sdk/go/channel"
)
// Kind is this channel's kind on both benches.
@@ -59,19 +59,19 @@ const StandingEvery = 5 * time.Minute
// Standing is what the desk says of itself: the bench's word, and when a named machine's notifier last lost
// the answers its notifications waited for (its bundle started again). The router then says again what is
// still open and unanswered (issue 384). It belongs in asks.Standing once the SDK carries it; until then the
// still open and unanswered (issue 384). It belongs in channel.Standing once the SDK carries it; until then the
// field travels as JSON the router reads under the same name. Zero (as "0001-01-01T00:00:00Z") until then.
type Standing struct {
asks.Standing
channel.Standing
AnswersLostAt time.Time `json:"answers-lost-at"`
}
// Shown is one of the router's messages as the desk shows it: the message, and its notification on each
// machine.
type Shown struct {
Message asks.Message `json:"message"`
On map[string]int `json:"on"`
Sent time.Time `json:"sent"`
Message channel.Message `json:"message"`
On map[string]int `json:"on"`
Sent time.Time `json:"sent"`
}
// Store keeps what is shown, by the router's handle.
@@ -146,7 +146,7 @@ func (d *Desk) Take(subject string, body json.RawMessage) error {
d.Logf("[desk-channel] work on %s is not this kind's; taken and dropped", subject)
return nil
}
var m asks.Message
var m channel.Message
if err := json.Unmarshal(body, &m); err != nil || m.Handle == "" {
d.Logf("[desk-channel] work on %s could not be read; taken and dropped", subject)
return nil
@@ -158,8 +158,8 @@ func (d *Desk) Take(subject string, body json.RawMessage) error {
// would appear later, to whoever is at the screen then.
seeing, why := d.seeing()
if len(seeing) == 0 {
f := asks.Failed{Handle: m.Handle, Why: "not shown: " + why, At: d.Now()}
return d.Publish(asks.IntakeSubject(asks.FailedWhat, Kind), f, "failed."+m.Handle+"."+strconv.FormatInt(d.Now().UnixNano(), 10))
f := channel.Failed{Handle: m.Handle, Why: "not shown: " + why, At: d.Now()}
return d.Publish(channel.IntakeSubject(channel.FailedWhat, Kind), f, "failed."+m.Handle+"."+strconv.FormatInt(d.Now().UnixNano(), 10))
}
machines = seeing
}
@@ -176,7 +176,7 @@ func (d *Desk) Take(subject string, body json.RawMessage) error {
// shown (a reminder, an escalation), replace the notification it had on each machine — dunst's replace id —
// so a problem is one notification updated in place, never one more beside it. An edit stays quiet where
// it was shown; a message said again is shown anew where it was not.
edit := parts[4] == asks.Edit
edit := parts[4] == channel.Edit
var before *Shown
switch {
case edit:
@@ -226,8 +226,8 @@ func (d *Desk) Take(subject string, body json.RawMessage) error {
if len(machines) == 0 {
why = "no machine to show it on: the setting desktop-machines names none"
}
f := asks.Failed{Handle: m.Handle, Why: why, At: d.Now()}
if err := d.Publish(asks.IntakeSubject(asks.FailedWhat, Kind), f, "failed."+m.Handle+"."+strconv.FormatInt(d.Now().UnixNano(), 10)); err != nil {
f := channel.Failed{Handle: m.Handle, Why: why, At: d.Now()}
if err := d.Publish(channel.IntakeSubject(channel.FailedWhat, Kind), f, "failed."+m.Handle+"."+strconv.FormatInt(d.Now().UnixNano(), 10)); err != nil {
return err
}
return nil
@@ -322,7 +322,7 @@ func sameShown(a, b Shown) bool {
}
// show puts one message on one machine's notifier, in place of `replace` when it names one.
func (d *Desk) show(machine string, m asks.Message, replace int) (int, error) {
func (d *Desk) show(machine string, m channel.Message, replace int) (int, error) {
urgency := "normal"
switch {
case m.Urgent:
@@ -376,12 +376,12 @@ func (d *Desk) Chosen(event, machine, ticket string) {
}
handle, shown := d.find(ticket)
if shown == nil {
_, _ = d.show(machine, asks.Message{Title: "That answer was not taken",
_, _ = d.show(machine, channel.Message{Title: "That answer was not taken",
Body: "It belongs to no message shown here any more. Nothing was done.", Silent: true}, 0)
return
}
if _, here := shown.On[machine]; !here {
_, _ = d.show(machine, asks.Message{Title: "That answer was not taken",
_, _ = d.show(machine, channel.Message{Title: "That answer was not taken",
Body: "It was not shown on " + machine + ". Nothing was done.", Silent: true}, 0)
return
}
@@ -395,11 +395,11 @@ func (d *Desk) Chosen(event, machine, ticket string) {
}
d.clickedOn[id] = machine
d.mu.Unlock()
c := asks.Choice{ID: id, Ticket: ticket, Handle: handle, At: d.Now(),
Sender: asks.Sender{Identity: machine, Display: machine, Authenticated: false}}
if err := d.Publish(asks.IntakeSubject(asks.Chosen, Kind), c, id); err != nil {
c := channel.Choice{ID: id, Ticket: ticket, Handle: handle, At: d.Now(),
Sender: channel.Sender{Identity: machine, Display: machine, Authenticated: false}}
if err := d.Publish(channel.IntakeSubject(channel.Chosen, Kind), c, id); err != nil {
d.Logf("[desk-channel] a choice could not be said to the router: %v", err)
_, _ = d.show(machine, asks.Message{Title: "That answer could not be taken now",
_, _ = d.show(machine, channel.Message{Title: "That answer could not be taken now",
Body: "The mesh did not take it. Try again in a minute.", Silent: true}, 0)
}
}
@@ -508,7 +508,7 @@ func (d *Desk) seeing() ([]string, string) {
// (the review of 2026-10-09). The router shows no link's code while the desk is not ready.
func (d *Desk) Standing() Standing {
d.init()
s := Standing{Standing: asks.Standing{At: d.Now()}, AnswersLostAt: d.answersLostAt()}
s := Standing{Standing: channel.Standing{At: d.Now()}, AnswersLostAt: d.answersLostAt()}
if _, why := d.seeing(); why != "" {
s.Why = why
return s
@@ -542,7 +542,7 @@ func (d *Desk) SayStanding() {
if same {
return
}
if err := d.Publish(asks.IntakeSubject(asks.StandingWhat, Kind), s, "standing."+strconv.FormatInt(s.At.UnixNano(), 10)); err != nil {
if err := d.Publish(channel.IntakeSubject(channel.StandingWhat, Kind), s, "standing."+strconv.FormatInt(s.At.UnixNano(), 10)); err != nil {
d.Logf("[desk-channel] could not say whether it can send: %v", err)
return
}
@@ -10,7 +10,7 @@ import (
"testing"
"time"
"git.novox.be/novox/mesh-sdk/go/asks"
"git.novox.be/novox/mesh-sdk/go/channel"
)
type memShown map[string]Shown
@@ -62,10 +62,10 @@ func newDesk(t *testing.T, machines ...string) (*Desk, *[]notified, map[string][
return d, &shown, said, store
}
func take(t *testing.T, d *Desk, verb string, m asks.Message) {
func take(t *testing.T, d *Desk, verb string, m channel.Message) {
t.Helper()
raw, _ := json.Marshal(m)
if err := d.Take(asks.ChannelSubject(verb, Kind), raw); err != nil {
if err := d.Take(channel.ChannelSubject(verb, Kind), raw); err != nil {
t.Fatal(err)
}
}
@@ -74,16 +74,16 @@ func take(t *testing.T, d *Desk, verb string, m asks.Message) {
// says a click as a choice the router judges — only on a machine the message was shown on.
func TestAClickOnAMachineTheMessageWasShownOnIsSaidToTheRouter(t *testing.T) {
d, shown, said, _ := newDesk(t, "laptop", "away-machine")
take(t, d, asks.Show, asks.Message{Handle: "m1", Title: "3 failed services", Body: "Needs you: <mend> them.",
Buttons: []asks.Button{{Label: "Silence for a week", Ticket: "t1"}}})
take(t, d, channel.Show, channel.Message{Handle: "m1", Title: "3 failed services", Body: "Needs you: <mend> them.",
Buttons: []channel.Button{{Label: "Silence for a week", Ticket: "t1"}}})
if len(*shown) != 1 || (*shown)[0].machine != "laptop" || (*shown)[0].args["expire_ms"] != 0 ||
(*shown)[0].args["body"] != "Needs you: &lt;mend&gt; them." {
t.Fatalf("shown %+v", *shown)
}
d.Chosen("e1", "laptop", "t1")
d.Chosen("e1", "laptop", "t1") // a redelivery
got := said[asks.IntakeSubject(asks.Chosen, Kind)]
var c asks.Choice
got := said[channel.IntakeSubject(channel.Chosen, Kind)]
var c channel.Choice
if len(got) != 1 || json.Unmarshal(got[0], &c) != nil || c.Ticket != "t1" || c.Handle != "m1" ||
c.Sender.Identity != "laptop" || c.Sender.Authenticated {
t.Fatalf("said %s", got)
@@ -91,14 +91,14 @@ func TestAClickOnAMachineTheMessageWasShownOnIsSaidToTheRouter(t *testing.T) {
// Elsewhere it is refused, there, and nothing is said to the router.
d.Chosen("e2", "workstation", "t1")
d.Chosen("e3", "laptop", "forged")
if len(said[asks.IntakeSubject(asks.Chosen, Kind)]) != 1 {
if len(said[channel.IntakeSubject(channel.Chosen, Kind)]) != 1 {
t.Error("a click not shown there was said")
}
if last := (*shown)[len(*shown)-1]; last.machine != "laptop" || last.args["summary"] != "That answer was not taken" {
t.Errorf("the refusal was not shown where it was chosen: %+v", last)
}
// The router's reply to the click is shown on the machine it was made on.
take(t, d, asks.Send, asks.Message{Handle: "r1", Title: "Details: 3 failed services", Reply: c.ID})
take(t, d, channel.Send, channel.Message{Handle: "r1", Title: "Details: 3 failed services", Reply: c.ID})
if last := (*shown)[len(*shown)-1]; last.machine != "laptop" || last.args["summary"] != "Details: 3 failed services" {
t.Errorf("%+v", last)
}
@@ -107,8 +107,8 @@ func TestAClickOnAMachineTheMessageWasShownOnIsSaidToTheRouter(t *testing.T) {
// The note follows the body after a blank line: the desk has no smaller text, and leaves nothing out (issue 639).
func TestTheNoteFollowsTheBody(t *testing.T) {
d, shown, _, _ := newDesk(t, "laptop")
take(t, d, asks.Show, asks.Message{Handle: "m1", Title: "q", Body: "The summary.", Note: "Asked by mesh-knowledge at 16:05.",
Buttons: []asks.Button{{Label: "Silence for a week", Ticket: "t1"}}})
take(t, d, channel.Show, channel.Message{Handle: "m1", Title: "q", Body: "The summary.", Note: "Asked by mesh-knowledge at 16:05.",
Buttons: []channel.Button{{Label: "Silence for a week", Ticket: "t1"}}})
if last := (*shown)[len(*shown)-1]; last.args["body"] != "The summary.\n\nAsked by mesh-knowledge at 16:05." {
t.Errorf("the body: %q", last.args["body"])
}
@@ -116,8 +116,8 @@ func TestTheNoteFollowsTheBody(t *testing.T) {
func TestAnEditReplacesTheNotification(t *testing.T) {
d, shown, _, _ := newDesk(t, "laptop")
take(t, d, asks.Show, asks.Message{Handle: "m1", Title: "q", Buttons: []asks.Button{{Label: "Silence for a week", Ticket: "t1"}}})
take(t, d, asks.Edit, asks.Message{Handle: "m1", Title: "Silence for a week: chosen"})
take(t, d, channel.Show, channel.Message{Handle: "m1", Title: "q", Buttons: []channel.Button{{Label: "Silence for a week", Ticket: "t1"}}})
take(t, d, channel.Edit, channel.Message{Handle: "m1", Title: "Silence for a week: chosen"})
if last := (*shown)[len(*shown)-1]; last.args["replace_id"] != 1 || last.args["actions"] != nil {
t.Errorf("the edit: %+v", last.args)
}
@@ -134,17 +134,17 @@ func TestANotifierThatStartedAgainIsShownNothingFromMemory(t *testing.T) {
d, shown, said, store := newDesk(t, "laptop")
d.Paused("laptop", false)
d.SayStanding()
take(t, d, asks.Show, asks.Message{Handle: "open", Title: "2 failed services on shanks",
Buttons: []asks.Button{{Label: "Silence for a week", Ticket: "t-open"}}})
take(t, d, asks.Show, asks.Message{Handle: "silenced", Title: "Agent-home shrank",
Buttons: []asks.Button{{Label: "Silence for a week", Ticket: "t-silenced"}}})
take(t, d, asks.Edit, asks.Message{Handle: "silenced", Title: "Silenced until Saturday: Agent-home shrank", Silent: true})
take(t, d, asks.Show, asks.Message{Handle: "resolved", Title: "Questions for you not delivered",
Buttons: []asks.Button{{Label: "Details", Ticket: "t-resolved"}}})
take(t, d, asks.Edit, asks.Message{Handle: "resolved", Title: "Resolved: questions for you are delivered", Silent: true})
take(t, d, channel.Show, channel.Message{Handle: "open", Title: "2 failed services on shanks",
Buttons: []channel.Button{{Label: "Silence for a week", Ticket: "t-open"}}})
take(t, d, channel.Show, channel.Message{Handle: "silenced", Title: "Agent-home shrank",
Buttons: []channel.Button{{Label: "Silence for a week", Ticket: "t-silenced"}}})
take(t, d, channel.Edit, channel.Message{Handle: "silenced", Title: "Silenced until Saturday: Agent-home shrank", Silent: true})
take(t, d, channel.Show, channel.Message{Handle: "resolved", Title: "Questions for you not delivered",
Buttons: []channel.Button{{Label: "Details", Ticket: "t-resolved"}}})
take(t, d, channel.Edit, channel.Message{Handle: "resolved", Title: "Resolved: questions for you are delivered", Silent: true})
openID := store["open"].On["laptop"]
before := len(*shown)
standings := len(said[asks.IntakeSubject(asks.StandingWhat, Kind)])
standings := len(said[channel.IntakeSubject(channel.StandingWhat, Kind)])
later := d.Now().Add(time.Hour)
d.Now = func() time.Time { return later }
d.Paused("laptop", false)
@@ -157,25 +157,25 @@ func TestANotifierThatStartedAgainIsShownNothingFromMemory(t *testing.T) {
if store["open"].On["laptop"] != openID {
t.Fatalf("the open message's notification was forgotten: %+v", store["open"].On)
}
got := said[asks.IntakeSubject(asks.StandingWhat, Kind)]
got := said[channel.IntakeSubject(channel.StandingWhat, Kind)]
var s Standing
if len(got) != standings+1 || json.Unmarshal(got[len(got)-1], &s) != nil || !s.Ready || !s.AnswersLostAt.Equal(later) {
t.Fatalf("the standing after the start: %d said, last %s", len(got)-standings, got[len(got)-1])
}
// The router says the open one again under its handle: in place of its notification, with its answers.
take(t, d, asks.Show, asks.Message{Handle: "open", Title: "2 failed services on shanks",
Buttons: []asks.Button{{Label: "Silence for a week", Ticket: "t-open"}}})
take(t, d, channel.Show, channel.Message{Handle: "open", Title: "2 failed services on shanks",
Buttons: []channel.Button{{Label: "Silence for a week", Ticket: "t-open"}}})
last := (*shown)[len(*shown)-1]
if len(*shown) != before+1 || last.args["replace_id"] != openID || last.args["actions"] == nil {
t.Fatalf("the open message said again: %+v", last.args)
}
d.Chosen("e2", "laptop", "t-open")
if len(said[asks.IntakeSubject(asks.Chosen, Kind)]) != 1 {
if len(said[channel.IntakeSubject(channel.Chosen, Kind)]) != 1 {
t.Error("a click on the message said again was not said to the router")
}
// The same start event again (a redelivery) does nothing more.
standings = len(said[asks.IntakeSubject(asks.StandingWhat, Kind)])
if n := d.Started("e-start", "laptop"); n != 0 || len(said[asks.IntakeSubject(asks.StandingWhat, Kind)]) != standings {
standings = len(said[channel.IntakeSubject(channel.StandingWhat, Kind)])
if n := d.Started("e-start", "laptop"); n != 0 || len(said[channel.IntakeSubject(channel.StandingWhat, Kind)]) != standings {
t.Errorf("a redelivered start was acted on again: %d", n)
}
// A desk none of whose notifiers started again says a zero time, which the router reads as none.
@@ -187,8 +187,8 @@ func TestANotifierThatStartedAgainIsShownNothingFromMemory(t *testing.T) {
func TestNoMachineThatShowsItIsAFailureSaidToTheRouter(t *testing.T) {
d, _, said, _ := newDesk(t, "away-machine")
take(t, d, asks.Send, asks.Message{Handle: "m1", Title: "x"})
if f := said[asks.IntakeSubject(asks.FailedWhat, Kind)]; len(f) != 1 || !strings.Contains(string(f[0]), "no session answers") ||
take(t, d, channel.Send, channel.Message{Handle: "m1", Title: "x"})
if f := said[channel.IntakeSubject(channel.FailedWhat, Kind)]; len(f) != 1 || !strings.Contains(string(f[0]), "no session answers") ||
strings.Contains(string(f[0]), `"permanent":true`) {
t.Errorf("%s", f)
}
@@ -229,7 +229,7 @@ func TestItPromisesNoVerifiedSender(t *testing.T) {
func TestASecretMessageIsShownAndNotKept(t *testing.T) {
d, shown, _, store := newDesk(t, "laptop")
d.Paused("laptop", false)
take(t, d, asks.Send, asks.Message{Handle: "code", Title: "Link a Telegram account as you", Body: "123 456", Secret: true})
take(t, d, channel.Send, channel.Message{Handle: "code", Title: "Link a Telegram account as you", Body: "123 456", Secret: true})
if len(*shown) != 1 {
t.Fatal("not shown")
}
@@ -252,8 +252,8 @@ func TestASecretIsShownOnlyWhereTheNotifierIsHeardUnpausedAndNeverKept(t *testin
if s := d.Standing(); !s.Ready {
t.Fatalf("not ready with laptop heard unpaused: %+v", s)
}
code := asks.Message{Handle: "code", Title: "Link a Telegram account as you", Body: "123 456", Secret: true}
take(t, d, asks.Send, code)
code := channel.Message{Handle: "code", Title: "Link a Telegram account as you", Body: "123 456", Secret: true}
take(t, d, channel.Send, code)
if len(*shown) != 1 || (*shown)[0].machine != "laptop" || (*shown)[0].args["secret"] != true {
t.Fatalf("the code was shown as %+v", *shown)
}
@@ -262,11 +262,11 @@ func TestASecretIsShownOnlyWhereTheNotifierIsHeardUnpausedAndNeverKept(t *testin
}
// Paused: not shown anywhere, said as failed, and the desk is not ready.
d.Paused("laptop", true)
take(t, d, asks.Send, code)
take(t, d, channel.Send, code)
if len(*shown) != 1 {
t.Fatalf("the code was shown on a paused notifier: %+v", *shown)
}
if f := said[asks.IntakeSubject(asks.FailedWhat, Kind)]; len(f) != 1 || !strings.Contains(string(f[0]), "paused") {
if f := said[channel.IntakeSubject(channel.FailedWhat, Kind)]; len(f) != 1 || !strings.Contains(string(f[0]), "paused") {
t.Errorf("not said as failed: %s", f)
}
if s := d.Standing(); s.Ready || !strings.Contains(s.Why, "paused") {
@@ -284,7 +284,7 @@ func TestASecretIsShownOnlyWhereTheNotifierIsHeardUnpausedAndNeverKept(t *testin
t.Error("a machine not named made the desk ready")
}
// A message that is not secret still goes to every named machine, paused or not.
take(t, d, asks.Send, asks.Message{Handle: "m9", Title: "x"})
take(t, d, channel.Send, channel.Message{Handle: "m9", Title: "x"})
if len(*shown) != 3 {
t.Errorf("an ordinary message was held back: %+v", *shown)
}
@@ -330,8 +330,8 @@ func TestItRunsAsItsOwnAccount(t *testing.T) {
// was not shown; a message under a new handle replaces nothing.
func TestAMessageSaidAgainUnderItsHandleReplacesItsNotification(t *testing.T) {
d, shown, _, store := newDesk(t, "laptop", "desktop")
take(t, d, asks.Send, asks.Message{Handle: "m1", Title: "2 failed services on shanks"})
take(t, d, asks.Send, asks.Message{Handle: "m2", Title: "another problem"})
take(t, d, channel.Send, channel.Message{Handle: "m1", Title: "2 failed services on shanks"})
take(t, d, channel.Send, channel.Message{Handle: "m2", Title: "another problem"})
if len(*shown) != 4 {
t.Fatalf("shown %d", len(*shown))
}
@@ -343,8 +343,8 @@ func TestAMessageSaidAgainUnderItsHandleReplacesItsNotification(t *testing.T) {
first := store["m1"].On
// The desk now names a third machine as well.
d.Machines = func() []string { return []string{"laptop", "desktop", "third"} }
take(t, d, asks.Show, asks.Message{Handle: "m1", Title: "Still open: 2 failed services on shanks", Urgent: true,
Buttons: []asks.Button{{Label: "Silence for a week", Ticket: "t1"}}})
take(t, d, channel.Show, channel.Message{Handle: "m1", Title: "Still open: 2 failed services on shanks", Urgent: true,
Buttons: []channel.Button{{Label: "Silence for a week", Ticket: "t1"}}})
again := (*shown)[4:]
if len(again) != 3 {
t.Fatalf("said again on %d machines", len(again))
@@ -397,18 +397,18 @@ func TestAFailedReplaceKeepsTheNotificationItWasToReplace(t *testing.T) {
d, shown, _, store := newDesk(t, "laptop")
down := true
d.Store = downStore{store, &down}
raised, _ := json.Marshal(asks.Message{Handle: "m9", Title: "A new build cannot be delivered", Urgent: true})
if err := d.Take(asks.ChannelSubject(asks.Send, Kind), raised); err != nil {
raised, _ := json.Marshal(channel.Message{Handle: "m9", Title: "A new build cannot be delivered", Urgent: true})
if err := d.Take(channel.ChannelSubject(channel.Send, Kind), raised); err != nil {
t.Fatalf("the raise was not taken: %v", err)
}
cleared, _ := json.Marshal(asks.Message{Handle: "m9", Title: "Cleared: a new build can be delivered"})
if err := d.Take(asks.ChannelSubject(asks.Edit, Kind), cleared); err != nil {
cleared, _ := json.Marshal(channel.Message{Handle: "m9", Title: "Cleared: a new build can be delivered"})
if err := d.Take(channel.ChannelSubject(channel.Edit, Kind), cleared); err != nil {
t.Fatalf("the clearing was not taken: %v", err)
}
d.KeepUnkept() // the minute's standing, store still down
down = !back
d.KeepUnkept()
take(t, d, asks.Send, asks.Message{Handle: "m10", Title: "another"})
take(t, d, channel.Send, channel.Message{Handle: "m10", Title: "another"})
if len(*shown) != 3 || (*shown)[1].args["replace_id"] != 1 || (*shown)[1].args["summary"] != "Cleared: a new build can be delivered" {
t.Fatalf("the clearing did not replace the urgent notification: %+v", *shown)
}
@@ -423,7 +423,7 @@ func TestAFailedReplaceKeepsTheNotificationItWasToReplace(t *testing.T) {
}
d, shown, said, store := newDesk(t, "laptop", "desktop")
take(t, d, asks.Send, asks.Message{Handle: "m1", Title: "2 failed services on shanks"})
take(t, d, channel.Send, channel.Message{Handle: "m1", Title: "2 failed services on shanks"})
first := store["m1"].On
notify := d.Notify
d.Notify = func(machine string, args map[string]any) (json.RawMessage, error) {
@@ -432,13 +432,13 @@ func TestAFailedReplaceKeepsTheNotificationItWasToReplace(t *testing.T) {
}
return notify(machine, args)
}
take(t, d, asks.Send, asks.Message{Handle: "m1", Title: "Still open: 2 failed services on shanks"})
take(t, d, channel.Send, channel.Message{Handle: "m1", Title: "Still open: 2 failed services on shanks"})
if got := store["m1"].On["desktop"]; got != first["desktop"] {
t.Fatalf("the desktop's notification %d was forgotten: %v", first["desktop"], store["m1"].On)
}
d.Notify = func(string, map[string]any) (json.RawMessage, error) { return nil, errors.New("away") }
take(t, d, asks.Send, asks.Message{Handle: "m1", Title: "Now urgent: 2 failed services on shanks"})
if len(said[asks.IntakeSubject(asks.FailedWhat, Kind)]) != 1 {
take(t, d, channel.Send, channel.Message{Handle: "m1", Title: "Now urgent: 2 failed services on shanks"})
if len(said[channel.IntakeSubject(channel.FailedWhat, Kind)]) != 1 {
t.Fatalf("shown nowhere, and not said as failed")
}
_ = shown
+3 -3
View File
@@ -18,8 +18,8 @@ import (
"strings"
"time"
oc "git.novox.be/novox/mesh-contracts/go/seats/operatorchannel"
stdio "git.novox.be/novox/mesh-sdk/go"
"git.novox.be/novox/mesh-sdk/go/asks"
"git.novox.be/novox/mesh-sdk/go/handacts"
)
@@ -186,8 +186,8 @@ func hearing(c *Config) {
go func() {
<-stdio.Served() // subscribed as soon as the runtime serves, never after a guessed wait: an event sent meanwhile is heard
for wait := 2 * time.Second; ; wait = min(wait*2, time.Minute) {
err := stdio.Subscribe(asks.Seat+".decided."+Module, func(e stdio.Envelope) error {
var w asks.Warrant
err := stdio.Subscribe(oc.Seat+".decided."+Module, func(e stdio.Envelope) error {
var w oc.Warrant
if json.Unmarshal(e.Body, &w) == nil {
for _, s := range c.HeardEnd(w) {
logf("[mesh-config] %s", s)
@@ -22,7 +22,7 @@ import (
stdio "git.novox.be/novox/mesh-sdk/go"
"git.novox.be/novox/mesh-sdk/go/delivery"
"git.novox.be/novox/mesh-delivery/sdk/views"
"git.novox.be/novox/mesh-sdk/go/views"
)
// The tools of the owners that mesh-config asks, by the state they read.
@@ -9,7 +9,7 @@ import (
"testing"
"time"
"git.novox.be/novox/mesh-delivery/sdk/views"
"git.novox.be/novox/mesh-sdk/go/views"
)
// CAT-C11 (ADR 0615 §1): a followed state of the delivery owners is read from the picture of its owner's tool and events:
@@ -16,8 +16,8 @@ package main
//
// **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
// the same ask digest, an option it offered, at its level, in time (oc.Warrant.For), and the act the option
// bound is this proposal's (oc.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.
@@ -34,8 +34,10 @@ import (
"strings"
"time"
occ "git.novox.be/novox/mesh-contracts/go/clients/operatorchannelclient"
oc "git.novox.be/novox/mesh-contracts/go/seats/operatorchannel"
stdio "git.novox.be/novox/mesh-sdk/go"
"git.novox.be/novox/mesh-sdk/go/asks"
ch "git.novox.be/novox/mesh-sdk/go/channel"
"git.novox.be/novox/mesh-sdk/go/outward"
)
@@ -47,7 +49,7 @@ 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
const ProposalLasts = occ.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 (
@@ -182,8 +184,8 @@ func (p Proposal) about() string {
// 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 {
act := asks.Act{"verb": ProposalVerb, "option": option, "proposal": p.ID, "action": p.Action,
func (p Proposal) Act(option string) occ.Act {
act := occ.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)}
@@ -273,7 +275,7 @@ func (p Proposal) summary() string {
s := fmt.Sprintf("Holds %s for %s on %s for %s; approved, it takes effect when that pull request merges, "+
"and is dropped if it closes unmerged. Nothing reaches a machine before the merge.", p.Key, p.Level.Module,
p.Level.Node, p.Pull)
if len([]rune(s)) > asks.SummaryLength {
if len([]rune(s)) > occ.SummaryLength {
s = fmt.Sprintf("Holds %s for %s on %s for %s until it merges; dropped if it closes unmerged.", p.Key,
p.Level.Module, p.Level.Node, p.Pull)
}
@@ -282,7 +284,7 @@ func (p Proposal) summary() string {
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 {
if len([]rune(s)) > occ.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))
}
@@ -292,8 +294,8 @@ func (p Proposal) summary() string {
// 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
if r := []rune(line); len(r) > occ.PointLength {
return string(r[:occ.PointLength-len([]rune(cut))]) + cut
}
return line
}
@@ -316,7 +318,7 @@ func (p Proposal) headline() string {
}
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 len([]rune(h)) <= occ.HeadlineLength {
if s, ok := sayable(h); ok {
return s
}
@@ -327,7 +329,7 @@ func (p Proposal) headline() string {
// 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 {
func (p Proposal) Ask() oc.Ask {
why, _ := sayable("Why: " + p.Why)
if why != "Why: "+p.Why {
why = "Why: given, and read in full with the proposal"
@@ -348,8 +350,8 @@ func (p Proposal) Ask() asks.Ask {
"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(),
a := oc.Ask{ID: p.ID, Headline: p.headline(), Explanation: compose(false), Who: occ.Operator,
Expires: occ.FormatTime(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
@@ -368,8 +370,8 @@ func (p Proposal) Ask() asks.Ask {
{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, _ := occ.ActDigest(p.Act(o.id))
a.Options = append(a.Options, oc.Option{ID: o.id, Label: o.label, Does: o.does, Level: occ.Approve,
Binds: binds})
}
return a
@@ -379,7 +381,7 @@ func (p Proposal) Ask() asks.Ask {
// 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):
case !occ.UsableID(p.ID):
return "its id is not one"
case p.Action != "set" && p.Action != "clear":
return "its action is neither set nor clear"
@@ -426,7 +428,7 @@ func approverReady(bus AskBus) string {
}
channels := 0
for _, k := range kinds {
if k.Seat != asks.ChannelSeat {
if k.Seat != ch.ChannelSeat {
continue
}
channels++
@@ -446,9 +448,15 @@ func newProposalID() string {
return "cfg-" + hex.EncodeToString(b[:])
}
// warrantAt is the time a warrant carries; the zero time when it carries none.
func warrantAt(w oc.Warrant) time.Time {
t, _ := occ.Time(w.At)
return t
}
// endedWord is the router's word that an ask ended without a choice, heard while its proposer waits.
type endedWord struct {
Outcome asks.Outcome
Outcome string
Words string
}
@@ -496,8 +504,9 @@ func (c *Config) Propose(call Call) (*ProposeAnswer, error) {
if err != nil {
return nil, err
}
askExpires, _ := occ.Time(ask.Expires)
answer := &ProposeAnswer{Proposal: prop.ID, Asked: ask.Headline, Fingerprint: prop.Fingerprint(),
Expires: ask.Expires, Reaches: prop.Reaches, Replaced: replaced,
Expires: askExpires, 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
@@ -520,14 +529,14 @@ func (c *Config) Propose(call Call) (*ProposeAnswer, error) {
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"))
askExpires.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")
func (c *Config) propose(call Call, action string) (Proposal, oc.Ask, []string, error) {
fail := func(format string, a ...any) (Proposal, oc.Ask, []string, error) {
return Proposal{}, oc.Ask{}, nil, errors.New(fmt.Sprintf(format, a...) + ". Nothing was proposed")
}
level, err := call.level()
if err != nil {
@@ -546,7 +555,7 @@ func (c *Config) propose(call Call, action string) (Proposal, asks.Ask, []string
}
s, err := c.sources()
if err != nil {
return Proposal{}, asks.Ask{}, nil, err
return Proposal{}, oc.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.
var r rule
@@ -637,7 +646,7 @@ func (c *Config) propose(call Call, action string) (Proposal, asks.Ask, []string
// replaces an older one still open (§10).
all, err := c.proposals()
if err != nil {
return Proposal{}, asks.Ask{}, nil, err
return Proposal{}, oc.Ask{}, nil, err
}
var replace []Proposal
open := 0
@@ -654,15 +663,15 @@ func (c *Config) propose(call Call, action string) (Proposal, asks.Ask, []string
}
open++
}
if open >= asks.MostOpen {
if open >= occ.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)
"config's proposals lists them", open, occ.MostOpen)
}
if why := approverReady(c.Asks); why != "" {
return fail("%s; %s", why, AtTheTerminalInstead)
}
ask := prop.Ask()
if err := ask.Check(now); err != nil {
if err := occ.Check(ask, now); err != nil {
return fail("%v", err)
}
if err := c.Store.Put(ProposalsState, prop.ID, prop); err != nil {
@@ -675,11 +684,11 @@ func (c *Config) propose(call Call, action string) (Proposal, asks.Ask, []string
replaced = append(replaced, o.ID)
}
}
if err := c.Asks.Publish(asks.AskSubject(Module), ask, ask.ID); err != nil {
if err := c.Asks.Publish(oc.AcceptSubjectAsk(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 "+
return Proposal{}, oc.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,
@@ -709,9 +718,9 @@ func (c *Config) proposals() ([]Proposal, error) {
// 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"`
State string `json:"state"`
Ask *oc.Ask `json:"ask"`
Warrant *oc.Warrant `json:"warrant"`
}
// Decide decides every waiting proposal whose ask the router has closed, oldest warrant first, each held to the
@@ -730,7 +739,7 @@ func (c *Config) Decide() ([]string, error) {
now := c.now()
type closed struct {
p Proposal
w asks.Warrant
w oc.Warrant
}
var ready []closed
var said []string
@@ -754,7 +763,7 @@ func (c *Config) Decide() ([]string, error) {
}
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) })
sort.SliceStable(ready, func(i, j int) bool { return warrantAt(ready[i].w).Before(warrantAt(ready[j].w)) })
for _, r := range ready {
said = append(said, c.decide(r.p, r.w))
}
@@ -775,7 +784,7 @@ func (c *Config) end(p Proposal, why string) string {
// 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 {
func (c *Config) decide(p Proposal, w oc.Warrant) string {
now := c.now()
keep := func(state, outcome string) string {
p.State, p.Outcome, p.Decided = state, outcome, now
@@ -791,7 +800,7 @@ func (c *Config) decide(p Proposal, w asks.Warrant) string {
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 {
if w.Outcome != occ.OutcomeChosen {
why := "the ask " + string(w.Outcome)
if w.Words != "" {
why += " (" + w.Words + ")"
@@ -804,19 +813,19 @@ func (c *Config) decide(p Proposal, w asks.Warrant) string {
if problem := p.problem(); problem != "" {
return refused("the proposal is not the one asked: " + problem)
}
option, err := w.For(Module, p.Ask())
option, err := occ.For(w, Module, p.Ask())
if err == nil {
err = option.Performs(p.Act(option.ID))
err = occ.Performs(option, 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")
p.Answered = occ.Says(w)
return keep(ProposalDeclined, "declined by the operator ("+occ.Says(w)+"); nothing was set")
case OptionApprove:
p.Answered, p.Approved = w.Says(), approvedWords(p, w)
p.Answered, p.Approved = occ.Says(w), approvedWords(p, w)
default:
return refused("the option " + option.ID + " is not one a proposal offers")
}
@@ -853,7 +862,7 @@ func (c *Config) decide(p Proposal, w asks.Warrant) string {
}
// approvedWords is who approved it, as kept beside the value (ADR 0277 §15).
func approvedWords(p Proposal, w asks.Warrant) string {
func approvedWords(p Proposal, w oc.Warrant) string {
who := "the operator"
if w.By != nil {
who = "the " + w.By.Who
@@ -866,7 +875,7 @@ func approvedWords(p Proposal, w asks.Warrant) string {
}
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)
return fmt.Sprintf("approved by %s at %s, ask %s, proposed by %s", who, warrantAt(w).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
@@ -892,11 +901,11 @@ func (c *Config) recordWarrants(now time.Time) {
// 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) {
func (c *Config) HeardEnd(w oc.Warrant) []string {
if w.Asker != Module || !occ.UsableID(w.Ask) {
return nil
}
if w.Outcome == asks.OutcomeChosen {
if w.Outcome == occ.OutcomeChosen {
said, err := c.Decide()
if err != nil {
said = append(said, "reading the operator's answers: "+err.Error())
@@ -984,7 +993,7 @@ func (c *Config) Proposals(call Call) ([]ProposalRow, error) {
switch {
case rec.Ask == nil:
row.Phone = "cannot be compared: the router's record keeps no ask"
case rec.Ask.Digest() == p.Ask().Digest():
case occ.Digest(*rec.Ask) == occ.Digest(p.Ask()):
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"
@@ -10,8 +10,10 @@ import (
"testing"
"time"
occ "git.novox.be/novox/mesh-contracts/go/clients/operatorchannelclient"
oc "git.novox.be/novox/mesh-contracts/go/seats/operatorchannel"
stdio "git.novox.be/novox/mesh-sdk/go"
"git.novox.be/novox/mesh-sdk/go/asks"
ch "git.novox.be/novox/mesh-sdk/go/channel"
)
// standIn is the operator channel's router and a channel that proves who answered, in memory: it takes the asks
@@ -20,7 +22,7 @@ import (
type standIn struct {
mu sync.Mutex
subjects []string
asks map[string]asks.Ask
asks map[string]oc.Ask
records map[string][]byte
kinds []stdio.KindHeld
refuse error
@@ -28,14 +30,14 @@ type standIn struct {
// refuseWith makes the router refuse every ask it is published, as the router does for an ask no channel can
// carry: no record, and its word on the decided subject (heard through hear).
refuseWith string
hear func(asks.Warrant)
hear func(oc.Warrant)
// noRecord keeps the router from recording an ask it took (an ask it has not taken yet).
noRecord bool
}
func newStandIn() *standIn {
return &standIn{asks: map[string]asks.Ask{}, records: map[string][]byte{}, kinds: []stdio.KindHeld{
{Seat: asks.ChannelSeat, Kind: "telegram", Module: "telegram", Node: "novox",
return &standIn{asks: map[string]oc.Ask{}, records: map[string][]byte{}, kinds: []stdio.KindHeld{
{Seat: ch.ChannelSeat, Kind: "telegram", Module: "telegram", Node: "novox",
Capabilities: []string{"choice", "exact-render", "verified-sender"}}}}
}
@@ -45,15 +47,15 @@ func (r *standIn) Publish(subject string, body any, id string) error {
if r.refuse != nil {
return r.refuse
}
a, ok := body.(asks.Ask)
a, ok := body.(oc.Ask)
if !ok || a.ID != id {
return errors.New("not an ask")
}
r.subjects = append(r.subjects, subject)
r.asks[id] = a
if r.refuseWith != "" {
w := asks.Warrant{Ask: id, Asker: Module, About: a.About, Outcome: asks.OutcomeRefused, Words: r.refuseWith,
At: time.Now()}
w := oc.Warrant{Ask: id, Asker: Module, About: a.About, Outcome: occ.OutcomeRefused, Words: r.refuseWith,
At: occ.FormatTime(time.Now())}
go r.hear(w)
return nil
}
@@ -85,7 +87,7 @@ func (r *standIn) Warranted(ask string) error {
// choose is the operator choosing an option on a channel that proves who answered: the router's record of the ask
// it took, closed with its warrant.
func (r *standIn) choose(t *testing.T, id, option string, tweak func(*asks.Warrant)) asks.Warrant {
func (r *standIn) choose(t *testing.T, id, option string, tweak func(*oc.Warrant)) oc.Warrant {
t.Helper()
r.mu.Lock()
defer r.mu.Unlock()
@@ -93,14 +95,14 @@ func (r *standIn) choose(t *testing.T, id, option string, tweak func(*asks.Warra
if !ok {
t.Fatalf("the router took no ask %s", id)
}
o, ok := a.Option(option)
o, ok := occ.OptionOf(a, option)
if !ok {
t.Fatalf("the ask %s offers no %s", id, option)
}
w := asks.Warrant{Ask: id, Asker: Module, About: a.About, Outcome: asks.OutcomeChosen, Option: o.ID, Label: o.Label,
Level: o.Level, By: &asks.Person{Who: asks.Operator, Kind: "telegram", Identity: "4242", Verified: "user id verified"},
Channel: "telegram", Proofs: []string{"P1"}, At: time.Date(2026, 10, 14, 19, 30, 0, 0, time.UTC),
AskDigest: a.Digest()}
w := oc.Warrant{Ask: id, Asker: Module, About: a.About, Outcome: occ.OutcomeChosen, Option: o.ID, Label: o.Label,
Level: o.Level, By: &oc.Person{Who: occ.Operator, Kind: "telegram", Identity: "4242", Verified: "user id verified"},
Channel: "telegram", Proofs: []string{"P1"}, At: occ.FormatTime(time.Date(2026, 10, 14, 19, 30, 0, 0, time.UTC)),
AskDigest: occ.Digest(a)}
if tweak != nil {
tweak(&w)
}
@@ -114,7 +116,7 @@ func aProposingMesh(t *testing.T) (*Config, *memStore, *standIn) {
t.Helper()
c, m := aMesh(t)
r := newStandIn()
r.hear = func(w asks.Warrant) { c.HeardEnd(w) }
r.hear = func(w oc.Warrant) { c.HeardEnd(w) }
c.Asks, c.RouterWithin, c.RouterEvery = r, 300*time.Millisecond, 5*time.Millisecond
return c, m, r
}
@@ -180,14 +182,14 @@ func TestATrustedValueIsProposedAskedUnderItsOwnNameAndSetOnTheOperatorsApprove(
t.Errorf("the proposal answered %+v", a)
}
// Asked under mesh-config's own name, on the operator channel.
if len(r.subjects) != 1 || r.subjects[0] != asks.AskSubject("mesh-config") {
if len(r.subjects) != 1 || r.subjects[0] != oc.AcceptSubjectAsk("mesh-config") {
t.Fatalf("asked on %v", r.subjects)
}
ask := r.asks[a.Proposal]
if err := ask.Check(c.now()); err != nil {
if err := occ.Check(ask, c.now()); err != nil {
t.Fatalf("the ask: %v", err)
}
if err := asks.Glance(ask.Explanation); err != nil {
if err := occ.Glance(ask.Explanation); err != nil {
t.Errorf("the ask does not read at a glance: %v\n%s", err, ask.Explanation)
}
for _, want := range []string{"agent-managed-settings", "mesh config", "takes effect at once", "claude-code",
@@ -211,11 +213,11 @@ func TestATrustedValueIsProposedAskedUnderItsOwnNameAndSetOnTheOperatorsApprove(
// Each option binds the level, the key and the value.
p := storedProposal(t, m, a.Proposal)
for _, o := range ask.Options {
if o.Level != asks.Approve {
if o.Level != occ.Approve {
t.Errorf("the option %s is at %s", o.ID, o.Level)
}
act := p.Act(o.ID)
want, _ := asks.ActDigest(act)
want, _ := occ.ActDigest(act)
if o.Binds != want || act["level"] != LevelMesh || act["key"] != "agent-managed-settings" ||
act["value"] != valueDigest(json.RawMessage(`{"permissions":{"deny":["Bash(ssh:*)"]}}`), true) ||
act["was"] != valueDigest(nil, false) {
@@ -338,13 +340,13 @@ func TestAnApproveIsRefusedWhenTheProposalChangedAfterItWasAsked(t *testing.T) {
func TestAWarrantThatDoesNotAnswerTheAskIsRefused(t *testing.T) {
for _, tc := range []struct {
name string
tweak func(w *asks.Warrant)
tweak func(w *oc.Warrant)
}{
{"another ask's digest", func(w *asks.Warrant) { w.AskDigest = "sha256:00" }},
{"another level", func(w *asks.Warrant) { w.Level = asks.Acknowledge }},
{"after its expiry", func(w *asks.Warrant) { w.At = time.Date(2026, 10, 16, 0, 0, 0, 0, time.UTC) }},
{"an option it did not offer", func(w *asks.Warrant) { w.Option = "force" }},
{"no person", func(w *asks.Warrant) { w.By = nil }},
{"another ask's digest", func(w *oc.Warrant) { w.AskDigest = "sha256:00" }},
{"another level", func(w *oc.Warrant) { w.Level = occ.Acknowledge }},
{"after its expiry", func(w *oc.Warrant) { w.At = occ.FormatTime(time.Date(2026, 10, 16, 0, 0, 0, 0, time.UTC)) }},
{"an option it did not offer", func(w *oc.Warrant) { w.Option = "force" }},
{"no person", func(w *oc.Warrant) { w.By = nil }},
} {
t.Run(tc.name, func(t *testing.T) {
c, m, r := aProposingMesh(t)
@@ -379,7 +381,7 @@ func TestDeclineExpiryAndTheRoutersRefusalSetNothing(t *testing.T) {
// Ended without a choice: the router's word.
a = propose(t, c, Call{Key: "font-size", Value: 12.0, Node: "g14"})
c.HeardEnd(asks.Warrant{Ask: a.Proposal, Asker: Module, Outcome: asks.OutcomeExpired, At: time.Now()})
c.HeardEnd(oc.Warrant{Ask: a.Proposal, Asker: Module, Outcome: occ.OutcomeExpired, At: occ.FormatTime(time.Now())})
if p := storedProposal(t, m, a.Proposal); p.State != ProposalEnded || !strings.Contains(p.Outcome, "expired") {
t.Errorf("the expired proposal is %+v", p)
}
@@ -457,7 +459,7 @@ func TestAProposalIsJudgedAndRefusedBeforeTheOperatorIsAsked(t *testing.T) {
t.Fatalf("a refused proposal was asked: %v", r.subjects)
}
r.kinds = []stdio.KindHeld{{Seat: asks.ChannelSeat, Kind: "desktop", Capabilities: []string{"choice"}}}
r.kinds = []stdio.KindHeld{{Seat: ch.ChannelSeat, Kind: "desktop", Capabilities: []string{"choice"}}}
if _, err := c.Propose(Call{Action: "propose", Key: "font-size", Value: 13.0, Caller: "g", Why: "w"}); err == nil ||
!strings.Contains(err.Error(), "no channel that proves who answered") {
t.Errorf("no approving channel: %v", err)
@@ -6,6 +6,8 @@ import (
"strings"
"testing"
"time"
occ "git.novox.be/novox/mesh-contracts/go/clients/operatorchannelclient"
)
// Issue 1826 (novox/hq ADR 0502): a value held for the open pull request that introduces its key.
@@ -174,7 +176,7 @@ func TestAHeldValueIsAskedAndKeptApartOnApprove(t *testing.T) {
t.Errorf("a held value reaches %v", a.Reaches)
}
ask := r.asks[a.Proposal]
if err := ask.Check(c.now()); err != nil {
if err := occ.Check(ask, c.now()); err != nil {
t.Fatalf("the ask: %v", err)
}
for _, want := range []string{thePull, "merges", "dropped if it closes unmerged", "media-gid", "n8n", "ace"} {
@@ -9,7 +9,8 @@ import (
"testing"
"time"
"git.novox.be/novox/mesh-sdk/go/asks"
occ "git.novox.be/novox/mesh-contracts/go/clients/operatorchannelclient"
oc "git.novox.be/novox/mesh-contracts/go/seats/operatorchannel"
"git.novox.be/novox/mesh-sdk/go/health"
)
@@ -375,7 +376,7 @@ func TestARetirementDeclinedUnansweredCancelledOrASecret(t *testing.T) {
ask := retiringOf(t, c)[0].Ask
// Unanswered: the ask expires, a week passes, it is asked once more, expires again: a finding.
r.choose(t, ask, OptionApprove, func(w *asks.Warrant) { w.Outcome = asks.OutcomeExpired })
r.choose(t, ask, OptionApprove, func(w *oc.Warrant) { w.Outcome = occ.OutcomeExpired })
c.Decide()
if rows := retiringOf(t, c); rows[0].State != RetireUnanswered || len(findingsOf(t, m, FindingRetireUnanswered)) != 0 {
t.Fatalf("after the first expiry: %+v", rows)
@@ -386,7 +387,7 @@ func TestARetirementDeclinedUnansweredCancelledOrASecret(t *testing.T) {
if rows[0].State != RetireAsked || len(r.asks) != 2 || rows[0].Ask == ask {
t.Fatalf("after a week: %+v, %d asks", rows, len(r.asks))
}
r.choose(t, rows[0].Ask, OptionApprove, func(w *asks.Warrant) { w.Outcome = asks.OutcomeExpired })
r.choose(t, rows[0].Ask, OptionApprove, func(w *oc.Warrant) { w.Outcome = occ.OutcomeExpired })
c.Decide()
if fs := findingsOf(t, m, FindingRetireUnanswered); len(fs) != 1 {
t.Fatalf("findings %+v after two asks unanswered", fs)
+16 -15
View File
@@ -20,7 +20,8 @@ import (
"strings"
"time"
"git.novox.be/novox/mesh-sdk/go/asks"
occ "git.novox.be/novox/mesh-contracts/go/clients/operatorchannelclient"
oc "git.novox.be/novox/mesh-contracts/go/seats/operatorchannel"
)
// The state, and where a retirement stands.
@@ -300,13 +301,13 @@ func retireAskID(e Retiring, again bool) string {
}
// act is what an answer binds: the retirement, the nodes it reaches, the ask and the answer.
func (e Retiring) act(ask, option string) asks.Act {
return asks.Act{"verb": RetireVerb, "option": option, "module": e.Module, "key": e.Key, "kind": e.Kind,
func (e Retiring) act(ask, option string) occ.Act {
return occ.Act{"verb": RetireVerb, "option": option, "module": e.Module, "key": e.Key, "kind": e.Kind,
"nodes": strings.Join(e.Nodes, " "), "ask": ask}
}
// retireAsk is the ask composed from the retirement alone.
func (e Retiring) retireAsk(id string, at time.Time) asks.Ask {
func (e Retiring) retireAsk(id string, at time.Time) oc.Ask {
nodes := strings.Join(e.Nodes, ", ")
if nodes == "" {
nodes = "no node"
@@ -315,17 +316,17 @@ func (e Retiring) retireAsk(id string, at time.Time) asks.Ask {
if e.Kind == KindSecret {
what = "secret"
}
a := asks.Ask{ID: id, Headline: fmt.Sprintf("Remove %s's %s %s?", e.Module, what, e.Key),
a := oc.Ask{ID: id, Headline: fmt.Sprintf("Remove %s's %s %s?", e.Module, what, e.Key),
Explanation: fmt.Sprintf("No manifest reads %s's %s %s any more, and no node runs a build that reads it. Approved, it is removed on %s.", e.Module, what, e.Key, nodes),
Who: asks.Operator, Expires: at.Add(ProposalLasts), OnExpiry: "nothing is removed; mesh-config asks once more in a week",
Who: occ.Operator, Expires: occ.FormatTime(at.Add(ProposalLasts)), OnExpiry: "nothing is removed; mesh-config asks once more in a week",
About: "config.retire." + e.Module + "." + e.Key,
Details: fmt.Sprintf("Retiring since %s UTC. Read it with mesh-config's config_retiring.", e.Since.UTC().Format("15:04 on 2 Jan"))}
for _, o := range []struct{ id, label, does string }{
{OptionApprove, "Approve", "the " + what + " is removed on " + nodes},
{OptionDecline, "Decline", "nothing is removed; mesh-config asks no more until it is read again"},
} {
binds, _ := asks.ActDigest(e.act(id, o.id))
a.Options = append(a.Options, asks.Option{ID: o.id, Label: o.label, Does: o.does, Level: asks.Approve, Binds: binds})
binds, _ := occ.ActDigest(e.act(id, o.id))
a.Options = append(a.Options, oc.Option{ID: o.id, Label: o.label, Does: o.does, Level: occ.Approve, Binds: binds})
}
return a
}
@@ -338,10 +339,10 @@ func (c *Config) ask(e *Retiring, again bool) error {
now := c.now()
id := retireAskID(*e, again)
a := e.retireAsk(id, now)
if err := a.Check(now); err != nil {
if err := occ.Check(a, now); err != nil {
return err
}
if err := c.Asks.Publish(asks.AskSubject(Module), a, a.ID); err != nil {
if err := c.Asks.Publish(oc.AcceptSubjectAsk(Module), a, a.ID); err != nil {
return err
}
e.Ask, e.State, e.AskedAt = id, RetireAsked, now
@@ -406,26 +407,26 @@ func (c *Config) decideRetirement(e *Retiring, now time.Time) []string {
if w.Asker != Module || w.Ask != e.Ask {
return keep(RetireUnanswered, "the router's record is for another ask; nothing is removed")
}
if w.Outcome != asks.OutcomeChosen {
if w.Outcome != occ.OutcomeChosen {
out := keep(RetireUnanswered, "the ask "+string(w.Outcome)+"; nothing is removed")
if e.AskedAgain {
c.setFinding(retireUnanswered(*e, now))
}
return out
}
option, err := w.For(Module, e.retireAsk(e.Ask, e.AskedAt))
option, err := occ.For(w, Module, e.retireAsk(e.Ask, e.AskedAt))
if err == nil {
err = option.Performs(e.act(e.Ask, option.ID))
err = occ.Performs(option, e.act(e.Ask, option.ID))
}
if err != nil {
c.logf("[mesh-config] REFUSED the answer to the retirement ask %s: %v", e.Ask, err)
return keep(RetireUnanswered, "the answer is refused and nothing is removed: "+err.Error())
}
c.clearFinding(FindingRetireUnanswered, e.Module, e.Key)
e.Answered = w.Says()
e.Answered = occ.Says(w)
switch option.ID {
case OptionDecline:
return keep(RetireDeclined, "declined by the operator ("+w.Says()+"); nothing is removed")
return keep(RetireDeclined, "declined by the operator ("+occ.Says(w)+"); nothing is removed")
case OptionApprove:
out := keep(RetireApproved, "approved by the operator; being removed")
return append(out, c.finishRetirement(e)...)
@@ -31,7 +31,7 @@ import (
"github.com/nats-io/nats.go/jetstream"
"git.novox.be/novox/mesh-contracts/go/seats/meshconfig"
"git.novox.be/novox/mesh-sdk/go/asks"
oc "git.novox.be/novox/mesh-contracts/go/seats/operatorchannel"
"git.novox.be/novox/mesh-sdk/go/delivery"
"git.novox.be/novox/mesh-sdk/go/health"
"git.novox.be/novox/mesh-sdk/go/testbus"
@@ -319,7 +319,7 @@ func (p *player) handle(m wire) {
p.mu.Unlock()
p.answer(m.ID, map[string]any{}, nil)
case "mesh/seat.publish":
var ask asks.Ask
var ask oc.Ask
if err := json.Unmarshal(a.Body, &ask); err != nil {
p.answer(m.ID, nil, err)
return
@@ -779,7 +779,7 @@ func TestConfigWholeRenamesAndRetiresAgainstTheBus(t *testing.T) {
}
router.mu.Unlock()
w := router.choose(t, id, OptionApprove, nil)
p2.event(asks.Seat+".decided."+Module, w)
p2.event(oc.Seat+".decided."+Module, w)
eventually(t, "the approved value is removed and retired is said", func() bool {
_, still := p2.get(ValuesState, "module.g14.radio.band")
return !still && p2.publishedCount(EventRetired) == 1
+3 -1
View File
@@ -3,8 +3,10 @@ module mesh-config
go 1.26.0
require (
git.novox.be/novox/mesh-delivery/sdk v0.0.0-00010101000000-000000000000
git.novox.be/novox/mesh-contracts/go v0.0.0-00010101000000-000000000000
git.novox.be/novox/mesh-contracts/go/clients/operatorchannelclient v0.0.0-00010101000000-000000000000
git.novox.be/novox/mesh-contracts/go/seats/meshconfig v0.0.0-00010101000000-000000000000
git.novox.be/novox/mesh-contracts/go/seats/operatorchannel v0.0.0-00010101000000-000000000000
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261102191058-f02f142af128
github.com/nats-io/nats.go v1.54.0
)
@@ -9,8 +9,9 @@ import (
"strings"
"time"
occ "git.novox.be/novox/mesh-contracts/go/clients/operatorchannelclient"
oc "git.novox.be/novox/mesh-contracts/go/seats/operatorchannel"
stdio "git.novox.be/novox/mesh-sdk/go"
"git.novox.be/novox/mesh-sdk/go/asks"
sdk "git.novox.be/novox/mesh-sdk/go/delivery"
)
@@ -45,7 +46,7 @@ const (
)
// AskLasts is how long the operator has to answer: a day less a margin, under the router's bound for an approval.
const AskLasts = asks.ApproveLasts - 10*time.Minute
const AskLasts = occ.ApproveLasts - 10*time.Minute
// Operator is how this owner asks the operator: through the keys of its bucket asks and the teller's answers. kvOperator in
// the running module.
@@ -81,8 +82,8 @@ func headsString(heads map[string]string) string {
}
// mergeAct is what an answer binds: this ask, the answer, and the heads it covers.
func mergeAct(ask, option string, heads map[string]string) asks.Act {
return asks.Act{"verb": Seat + ".merge", "ask": ask, "option": option, "heads": headsString(heads)}
func mergeAct(ask, option string, heads map[string]string) occ.Act {
return occ.Act{"verb": Seat + ".merge", "ask": ask, "option": option, "heads": headsString(heads)}
}
var unshownPath = regexp.MustCompile(`[/\\@~$]|\d+\.\d+\.\d+|[A-Za-z0-9+/=_]{24,}|(?i)\b[a-z0-9-]+\.[a-z]{2,12}\b`)
@@ -183,9 +184,9 @@ func mergeHeadline(c checkSaid) string {
// check is the state of the check at the heads it covers (checkAt): the ask says "passed its check" only when it did, and
// otherwise that Approve merges before the check ends, or although it failed (issue 1733).
func composeMergeAsk(id string, d *Delivery, group string, members []*Delivery, heads map[string]string, check checkSaid,
now time.Time) asks.Ask {
var a asks.Ask
a.ID, a.Who, a.Expires = id, asks.Operator, now.Add(AskLasts)
now time.Time) oc.Ask {
var a oc.Ask
a.ID, a.Who, a.Expires = id, occ.Operator, occ.FormatTime(now.Add(AskLasts))
a.OnExpiry = "nothing is merged; the ask is made again"
a.About = "delivery." + phone(repoName(d.Repository), 40) + "." + strconv.Itoa(d.Number)
a.About = strings.ReplaceAll(a.About, " ", "-")
@@ -203,24 +204,24 @@ func composeMergeAsk(id string, d *Delivery, group string, members []*Delivery,
pullSaid(m), m.Title, shortOf(m.Commit, 12), issuesSaid([]*Delivery{m}), words, touches))
}
if group == "" {
a.Headline = phone(mergeHeadline(check)+pullSaid(d)+" "+d.Title, asks.HeadlineLength)
a.Headline = phone(mergeHeadline(check)+pullSaid(d)+" "+d.Title, occ.HeadlineLength)
summary := fmt.Sprintf("%s %s%s. Approve merges it into main at %s%s; Decline leaves it unmerged.",
pullSaid(d), d.Title, checkClause(check, " "), shortOf(d.Commit, 12), approveCaveat(check))
if unreviewed != "" {
summary = strings.ToUpper(unreviewed[:1]) + unreviewed[1:] + ". " + summary
}
a.Explanation = strings.Join([]string{
phone(summary, asks.SummaryLength),
phone("Issues: "+issuesSaid(members), asks.PointLength),
phone("Review: "+reviewers[0], asks.PointLength),
phone("Touches: "+reaches[0], asks.PointLength),
phone(summary, occ.SummaryLength),
phone("Issues: "+issuesSaid(members), occ.PointLength),
phone("Review: "+reviewers[0], occ.PointLength),
phone("Touches: "+reaches[0], occ.PointLength),
}, "\n")
} else {
var names []string
for _, m := range members {
names = append(names, pullSaid(m))
}
a.Headline = phone(mergeHeadline(check)+"group "+group+" ("+strings.Join(names, ", ")+")", asks.HeadlineLength)
a.Headline = phone(mergeHeadline(check)+"group "+group+" ("+strings.Join(names, ", ")+")", occ.HeadlineLength)
caveat := approveCaveat(check)
if caveat != "" {
caveat = "," + caveat
@@ -231,10 +232,10 @@ func composeMergeAsk(id string, d *Delivery, group string, members []*Delivery,
summary = strings.ToUpper(unreviewed[:1]) + unreviewed[1:] + ". " + summary
}
a.Explanation = strings.Join([]string{
phone(summary, asks.SummaryLength),
phone("Issues: "+issuesSaid(members), asks.PointLength),
phone("Review: "+strings.Join(reviewers, "; "), asks.PointLength),
phone("Touches: "+strings.Join(reaches, "; "), asks.PointLength),
phone(summary, occ.SummaryLength),
phone("Issues: "+issuesSaid(members), occ.PointLength),
phone("Review: "+strings.Join(reviewers, "; "), occ.PointLength),
phone("Touches: "+strings.Join(reaches, "; "), occ.PointLength),
}, "\n")
}
a.Details = phone(strings.Join(details, "\n"), 1500)
@@ -251,8 +252,8 @@ func composeMergeAsk(id string, d *Delivery, group string, members []*Delivery,
{OptionApprove, "Approve", "the pull request is merged into main at the head named (a group: all its members, in one pass)"},
{OptionDecline, "Decline", "nothing is merged"},
} {
binds, _ := asks.ActDigest(mergeAct(id, o.id, heads))
a.Options = append(a.Options, asks.Option{ID: o.id, Label: o.label, Does: o.does, Level: asks.Approve, Binds: binds})
binds, _ := occ.ActDigest(mergeAct(id, o.id, heads))
a.Options = append(a.Options, oc.Option{ID: o.id, Label: o.label, Does: o.does, Level: occ.Approve, Binds: binds})
}
return a
}
@@ -573,7 +574,11 @@ func (h *Holder) approval(d *Delivery, now time.Time) (string, func()) {
h.keepGroup(g)
h.mu.Unlock()
}
if err := h.Operator.Ask(id, sdk.Ask{Group: name, Heads: covered, Asking: asking, Ask: ask}); err == nil {
toAsk, err := gateAsk(name, covered, asking, ask)
if err == nil {
err = h.Operator.Ask(id, toAsk)
}
if err == nil {
h.mu.Lock()
h.markWritten(id, true)
h.mu.Unlock()
@@ -787,11 +792,49 @@ func (h *Holder) answered(key string, ans sdk.Answer) []func() {
return said
}
// gateAsk is the gate's ask for a group at heads: the router's ask as its JSON in the value's ask, after the checks
// mesh-sdk no longer makes for it (novox/hq ADR 0627 §4): the ask's id is the one its group, heads and asking give, and
// it offers at least one option.
func gateAsk(group string, heads []sdk.Head, asking int, ask oc.Ask) (sdk.Ask, error) {
raw, err := json.Marshal(ask)
if err != nil {
return sdk.Ask{}, err
}
a := sdk.Ask{Group: group, Heads: heads, Asking: asking, Ask: raw}
if _, err := askOf(a); err != nil {
return sdk.Ask{}, err
}
return a, nil
}
// askOf reads the router's ask a gate ask carries and holds it to what the gate owes: the id its group, heads and asking
// give, and at least one option. kvOperator.Ask holds every ask it writes to it, too.
func askOf(a sdk.Ask) (oc.Ask, error) {
var ask oc.Ask
if err := json.Unmarshal(a.Ask, &ask); err != nil {
return oc.Ask{}, fmt.Errorf("the ask is not a router's ask: %w", err)
}
key, err := sdk.MergeAskKey(a.Group, a.Heads)
if err != nil {
return oc.Ask{}, fmt.Errorf("an ask needs its group and at least one pull request's head: %w", err)
}
if want := sdk.AskingID(key, a.Asking); ask.ID != want {
return oc.Ask{}, fmt.Errorf("the ask's id %q is not %q, the id its group, heads and asking %d give", ask.ID, want, a.Asking)
}
if len(ask.Options) == 0 {
return oc.Ask{}, fmt.Errorf("the ask has no option")
}
return ask, nil
}
// kvOperator is the operator over the runtime: the ask is a key of the gate's bucket asks, the answer a key of the teller's
// bucket answers.
type kvOperator struct{}
func (kvOperator) Ask(key string, a sdk.Ask) error {
if _, err := askOf(a); err != nil {
return err
}
raw, err := sdk.EncodeAsk(a)
if err != nil {
return err
@@ -1,11 +1,13 @@
package main
import (
"encoding/json"
"strings"
"testing"
"time"
"git.novox.be/novox/mesh-sdk/go/asks"
occ "git.novox.be/novox/mesh-contracts/go/clients/operatorchannelclient"
oc "git.novox.be/novox/mesh-contracts/go/seats/operatorchannel"
sdk "git.novox.be/novox/mesh-sdk/go/delivery"
)
@@ -39,7 +41,7 @@ func TestAMarkedGreenDeliveryIntoMainIsAskedAndNotMergedUntilApproved(t *testing
t.Fatalf("the operator is asked %v; wanted exactly one ask", open)
}
a := w.op.ask(open[0])
if !strings.HasPrefix(a.Headline, "Merge into main: app#7") || a.Who != asks.Operator ||
if !strings.HasPrefix(a.Headline, "Merge into main: app#7") || a.Who != occ.Operator ||
len(a.Options) != 2 || a.Options[0].ID != OptionApprove || a.Options[1].ID != OptionDecline {
t.Fatalf("the ask is not the merge ask: %+v", a)
}
@@ -48,7 +50,7 @@ func TestAMarkedGreenDeliveryIntoMainIsAskedAndNotMergedUntilApproved(t *testing
t.Fatalf("the ask does not say %q: %s", want, a.Explanation)
}
}
if err := asks.Glance(a.Explanation); err != nil {
if err := occ.Glance(a.Explanation); err != nil {
t.Fatalf("the ask does not read at a glance: %v", err)
}
// Ticks and minutes pass without an answer: not merged, not asked again.
@@ -195,8 +197,9 @@ func TestAnUnansweredAskIsWrittenAgainOnceWhenItLapses(t *testing.T) {
}
// Review round 1, G2: the router keeps an ask by its id and drops a second under it, so the second asking has an id of its own.
again := w.op.byKey[first]
if again.Asking != 2 || again.Ask.ID != sdk.AskingID(first, 2) || again.Ask.ID == sdk.AskID(first) {
t.Fatalf("the second asking is %d under the id %s; wanted asking 2 under %s", again.Asking, again.Ask.ID, sdk.AskingID(first, 2))
againAsk := w.op.ask(first)
if again.Asking != 2 || againAsk.ID != sdk.AskingID(first, 2) || againAsk.ID == sdk.AskID(first) {
t.Fatalf("the second asking is %d under the id %s; wanted asking 2 under %s", again.Asking, againAsk.ID, sdk.AskingID(first, 2))
}
for range 3 {
w.later(2 * time.Minute)
@@ -474,3 +477,42 @@ func TestADeclineOfAnEarlierAskDoesNotHoldTheApproveOfTheNext(t *testing.T) {
})
}
}
// mesh-sdk no longer refuses an ask whose id is another's or that offers no option (novox/hq ADR 0627 §4, ruling 1): the gate
// holds both, when it composes its ask and when kvOperator writes it.
func TestTheGateRefusesAnAskWithAnotherIdOrNoOption(t *testing.T) {
heads := []sdk.Head{{Repository: "novox/app", Number: 7, Head: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"}}
key, err := sdk.MergeAskKey("feat/g", heads)
if err != nil {
t.Fatal(err)
}
option := oc.Option{ID: OptionApprove, Label: "Approve", Level: occ.Approve}
for _, c := range []struct {
name string
asking int
ask oc.Ask
refuse string
}{
{"the first asking's id", 1, oc.Ask{ID: sdk.AskingID(key, 1), Options: []oc.Option{option}}, ""},
{"the second asking's id", 2, oc.Ask{ID: sdk.AskingID(key, 2), Options: []oc.Option{option}}, ""},
{"another id", 1, oc.Ask{ID: "another-id", Options: []oc.Option{option}}, "is not"},
{"the first asking's id at the second asking", 2, oc.Ask{ID: sdk.AskingID(key, 1), Options: []oc.Option{option}}, "is not"},
{"no option", 1, oc.Ask{ID: sdk.AskingID(key, 1)}, "no option"},
} {
a, err := gateAsk("feat/g", heads, c.asking, c.ask)
if c.refuse == "" {
if err != nil {
t.Errorf("%s: refused: %v", c.name, err)
}
continue
}
if err == nil || !strings.Contains(err.Error(), c.refuse) {
t.Errorf("%s: composed (%v), wanted a refusal saying %q", c.name, err, c.refuse)
}
// The same ask written by hand, past gateAsk, is refused where kvOperator writes it.
raw, _ := json.Marshal(c.ask)
if err := (kvOperator{}).Ask(key, sdk.Ask{Group: "feat/g", Heads: heads, Asking: c.asking, Ask: raw}); err == nil || !strings.Contains(err.Error(), c.refuse) {
t.Errorf("%s: kvOperator wrote it (%v, %v)", c.name, err, a)
}
}
}
@@ -16,8 +16,8 @@ import (
"testing"
"time"
oc "git.novox.be/novox/mesh-contracts/go/seats/operatorchannel"
stdio "git.novox.be/novox/mesh-sdk/go"
"git.novox.be/novox/mesh-sdk/go/asks"
sdk "git.novox.be/novox/mesh-sdk/go/delivery"
"github.com/nats-io/nats-server/v2/server"
"github.com/nats-io/nats.go"
@@ -94,7 +94,7 @@ func newTestBus(t *testing.T) *testBus {
}
// The streams the events are on (mesh.mod.<module>.event.<name>, mesh.seat.<seat>.event.<name>).
if _, err := js.CreateStream(context.Background(), jetstream.StreamConfig{Name: "EVENTS",
Subjects: []string{"mesh.mod.gitea.event.>", "mesh.seat." + asks.Seat + ".event.>",
Subjects: []string{"mesh.mod.gitea.event.>", "mesh.seat." + oc.Seat + ".event.>",
"mesh.mod." + sdk.ForgeBridgeModule + ".event.>"}}); err != nil {
t.Fatal(err)
}
@@ -117,7 +117,7 @@ type runtime struct {
notify chan struct{}
// the asks the operator was asked, and the controller's walks and the forge's open pull requests as the test plays
// them.
operatorAsks []asks.Ask
operatorAsks []oc.Ask
// operatorCancels are the ids of the asks taken back from the router, in order (issue 1079).
operatorCancels []string
openPulls []OpenPull
@@ -296,7 +296,7 @@ func (r *runtime) answer(m rpcMessage) {
case "mesh/seat.publish":
// The ask for the operator, published under the module's name.
if strings.Contains(p.Subject, ".accept.ask.") {
var a asks.Ask
var a oc.Ask
if err := json.Unmarshal(p.Body, &a); err != nil {
return nil, err
}
@@ -543,10 +543,13 @@ func TestAPassingVerdictAsksTheApproveAndItsApproveMergesWithinSecondsOverARealB
"title": announced.Title, "head_sha": head, "base": "main", "body": "Reviewed-by: lead@shanks at " + head})
var key string
rt.until("the ask to be written", eventWithin, func() bool { key = onlyAsk(b); return key != "" })
ask := askAt(t, b, key)
ask, err := askOf(askAt(t, b, key))
if err != nil {
t.Fatal(err)
}
t.Logf("the ask was written %s after the edit's event", time.Since(edit))
if !strings.Contains(ask.Ask.Explanation, "reviewed by lead on shanks") || ask.Ask.ID != sdk.AskID(key) {
t.Fatalf("the ask does not say the record: %s", ask.Ask.Explanation)
if !strings.Contains(ask.Explanation, "reviewed by lead on shanks") || ask.ID != sdk.AskID(key) {
t.Fatalf("the ask does not say the record: %s", ask.Explanation)
}
if len(rt.asksOf("gitea.gitea_merge_pull_request")) != 0 {
t.Fatalf("merged before the Approve")
@@ -666,7 +669,11 @@ func mergeAt(b *testBus, group string) sdk.Merge {
func (b *testBus) answer(t *testing.T, key, option string) time.Time {
t.Helper()
// The warrant's id is the router id of the asking answered and the time (the teller's warrantID).
raw, err := sdk.EncodeAnswer(sdk.Answer{Option: option, Warrant: askNowOrFatal(t, b, key).Ask.ID + "@2026-10-20T03:00:00Z", Words: "the operator, via telegram, chose " + option})
asked, err := askOf(askNowOrFatal(t, b, key))
if err != nil {
t.Fatal(err)
}
raw, err := sdk.EncodeAnswer(sdk.Answer{Option: option, Warrant: asked.ID + "@2026-10-20T03:00:00Z", Words: "the operator, via telegram, chose " + option})
if err != nil {
t.Fatal(err)
}
@@ -7,8 +7,9 @@ import (
"strings"
"time"
occ "git.novox.be/novox/mesh-contracts/go/clients/operatorchannelclient"
oc "git.novox.be/novox/mesh-contracts/go/seats/operatorchannel"
stdio "git.novox.be/novox/mesh-sdk/go"
"git.novox.be/novox/mesh-sdk/go/asks"
)
// The gate says its own stalled lines to the operator as conditions (novox/hq issue 1275, met again 2026-10-25; ADR 0437
@@ -49,12 +50,12 @@ func (kvConditions) PutSaid(said map[string]string) error {
}
// sayOnSeat says a condition on the operator-channel seat under the gate's own name.
func sayOnSeat(c asks.Condition) error {
subject, err := asks.ConditionSubject("mesh-delivery-gate")
func sayOnSeat(c oc.Condition) error {
subject, err := occ.ConditionSubject("mesh-delivery-gate")
if err != nil {
return err
}
raw, err := asks.EncodeCondition(c)
raw, err := occ.EncodeCondition(c)
if err != nil {
return err
}
@@ -69,26 +70,26 @@ func conditionKeyOf(id string) string { return "mesh-delivery-gate.delivery." +
func conditionSaid(l StalledLine) string { return severityOfLine(l) + "|" + l.H2 + "|" + l.Needs }
func severityOfLine(l StalledLine) string {
if l.Severity == asks.SeverityUrgent {
return asks.SeverityUrgent
if l.Severity == occ.SeverityUrgent {
return occ.SeverityUrgent
}
return asks.SeverityWarning
return occ.SeverityWarning
}
// conditionOf is a stalled line as the condition the operator is told.
func conditionOf(l StalledLine) asks.Condition {
func conditionOf(l StalledLine) oc.Condition {
headline := "stalled: " + l.ID
if strings.HasPrefix(l.Says, "group ") {
group, _, _ := strings.Cut(strings.TrimPrefix(l.Says, "group "), " ")
headline = "group " + group + " is not merged whole"
}
if r := []rune(headline); len(r) > asks.HeadlineLength {
headline = string(r[:asks.HeadlineLength-1]) + "…"
if r := []rune(headline); len(r) > occ.HeadlineLength {
headline = string(r[:occ.HeadlineLength-1]) + "…"
}
needs := "read mesh-delivery.show " + l.ID + " and decide"
return asks.Condition{Key: conditionKeyOf(l.ID), Severity: severityOfLine(l), State: asks.StateRaised,
Subject: asks.Scope{Scope: "delivery", ID: l.ID}, Summary: l.Says, Headline: headline, Needs: needs,
Explanation: asks.NeedsYou + needs + ". " + l.Says + " What ends it: " + l.H2 + ".",
return oc.Condition{Key: conditionKeyOf(l.ID), Severity: severityOfLine(l), State: occ.StateRaised,
Subject: oc.ConditionSubject{Scope: "delivery", ID: l.ID}, Summary: l.Says, Headline: headline, Needs: needs,
Explanation: occ.NeedsYou + needs + ". " + l.Says + " What ends it: " + l.H2 + ".",
Resolved: "The gate lists " + l.ID + " as stalled no more."}
}
@@ -121,7 +122,7 @@ func (h *Holder) sayConditions() {
lines := map[string]StalledLine{}
for _, l := range h.Stalled() {
// One condition for an id: the most severe of its lines.
if o, ok := lines[l.ID]; !ok || (severityOfLine(o) != asks.SeverityUrgent && severityOfLine(l) == asks.SeverityUrgent) {
if o, ok := lines[l.ID]; !ok || (severityOfLine(o) != occ.SeverityUrgent && severityOfLine(l) == occ.SeverityUrgent) {
lines[l.ID] = l
}
}
@@ -158,7 +159,7 @@ func (h *Holder) sayConditions() {
sort.Strings(gone)
for _, key := range gone {
id := strings.TrimSuffix(strings.TrimPrefix(key, "mesh-delivery-gate.delivery."), ".stalled")
c := asks.Condition{Key: key, Subject: asks.Scope{Scope: "delivery", ID: id}, State: asks.StateCleared,
c := oc.Condition{Key: key, Subject: oc.ConditionSubject{Scope: "delivery", ID: id}, State: occ.StateCleared,
Resolved: "The gate lists " + id + " as stalled no more."}
if err := h.Say(c); err != nil {
h.Logf("[mesh-delivery] the condition of %s could not be cleared: %v", id, err)
@@ -11,7 +11,7 @@ import (
"sync"
"time"
"git.novox.be/novox/mesh-sdk/go/asks"
oc "git.novox.be/novox/mesh-contracts/go/seats/operatorchannel"
sdk "git.novox.be/novox/mesh-sdk/go/delivery"
)
@@ -41,7 +41,7 @@ type Holder struct {
// Pulls keeps the pull requests' keys on the bus (pullkey.go); nil keeps none.
Pulls PullKeys
// Say says a condition to the operator, and Conditions keeps which the gate raised (conditions.go); nil says none.
Say func(c asks.Condition) error
Say func(c oc.Condition) error
Conditions ConditionStore
// said is what Conditions keeps, read at the first look; sayFailures the looks in a row a condition could not be
// said. Touched by React alone.
@@ -8,7 +8,8 @@ import (
"testing"
"time"
"git.novox.be/novox/mesh-sdk/go/asks"
occ "git.novox.be/novox/mesh-contracts/go/clients/operatorchannelclient"
oc "git.novox.be/novox/mesh-contracts/go/seats/operatorchannel"
sdk "git.novox.be/novox/mesh-sdk/go/delivery"
)
@@ -337,7 +338,7 @@ func TestASeverityOnceUrgentNeverStepsDownToAWarning(t *testing.T) {
type fakeConditions struct {
mu sync.Mutex
kept map[string]string
told []asks.Condition
told []oc.Condition
}
func (f *fakeConditions) Said() (map[string]string, error) {
@@ -360,8 +361,8 @@ func (f *fakeConditions) PutSaid(said map[string]string) error {
return nil
}
func (f *fakeConditions) say(c asks.Condition) error {
if _, err := asks.EncodeCondition(c); err != nil {
func (f *fakeConditions) say(c oc.Condition) error {
if _, err := occ.EncodeCondition(c); err != nil {
return err // what the router would refuse
}
f.mu.Lock()
@@ -371,7 +372,7 @@ func (f *fakeConditions) say(c asks.Condition) error {
}
// last is the newest condition said for a delivery id, or nil.
func (f *fakeConditions) last(id string) *asks.Condition {
func (f *fakeConditions) last(id string) *oc.Condition {
f.mu.Lock()
defer f.mu.Unlock()
for i := len(f.told) - 1; i >= 0; i-- {
@@ -487,7 +488,7 @@ func TestAnApprovedGroupIsHandedWholeInItsOrderAndAStopIsSaid(t *testing.T) {
!strings.Contains(lines[0].Says, "mesh/repo-check (missing)") || !strings.Contains(lines[0].Says, "Needed") {
t.Fatalf("not said as one urgent line: %+v", lines)
}
if c := told.last(IDOf(gCat, gHk)); c == nil || c.State != asks.StateRaised || c.Severity != asks.SeverityUrgent ||
if c := told.last(IDOf(gCat, gHk)); c == nil || c.State != occ.StateRaised || c.Severity != occ.SeverityUrgent ||
!strings.Contains(c.Summary, "feat/g") {
t.Fatalf("the operator was not told, urgent: %+v", c)
}
@@ -512,11 +513,11 @@ func TestAnApprovedGroupIsHandedWholeInItsOrderAndAStopIsSaid(t *testing.T) {
id := IDOf(gCtl, gHc)
raised := 0
for _, c := range told.told {
if c.Subject.ID == id && c.State == asks.StateRaised {
if c.Subject.ID == id && c.State == occ.StateRaised {
raised++
}
}
if c := told.last(id); raised != 1 || c == nil || c.Severity != asks.SeverityUrgent || !strings.Contains(c.Summary, "feat/g") ||
if c := told.last(id); raised != 1 || c == nil || c.Severity != occ.SeverityUrgent || !strings.Contains(c.Summary, "feat/g") ||
!strings.Contains(c.Summary, "half merged (merged 1 of 2)") {
t.Fatalf("raised %d times, last %+v; wanted once, urgent, naming the group half merged", raised, c)
}
@@ -533,7 +534,7 @@ func TestAnApprovedGroupIsHandedWholeInItsOrderAndAStopIsSaid(t *testing.T) {
w.h.PullMerged(merged(gCtl, 1, gHc, gT1))
bridgeMerged(w, gCtl, 1, gHc, gT1)
w.settleAll()
if c := told.last(id); c == nil || c.State != asks.StateCleared {
if c := told.last(id); c == nil || c.State != occ.StateCleared {
t.Fatalf("the group is whole and its condition is not cleared: %+v", c)
}
}},
@@ -548,7 +549,7 @@ func TestAnApprovedGroupIsHandedWholeInItsOrderAndAStopIsSaid(t *testing.T) {
}
w.h.wantForge()
w.settleAll()
if c := told.last("novox/app@0123456789ab"); c == nil || c.State != asks.StateCleared || len(told.kept) != 0 {
if c := told.last("novox/app@0123456789ab"); c == nil || c.State != occ.StateCleared || len(told.kept) != 0 {
t.Fatalf("not cleared: %+v, kept %v", c, told.kept)
}
}},
@@ -702,7 +703,7 @@ func TestAnApprovedGroupIsHandedWholeInItsOrderAndAStopIsSaid(t *testing.T) {
w.h.React()
}
c := told.last(IDOf(gCat, gHk))
if c == nil || c.State != asks.StateRaised || c.Severity != asks.SeverityUrgent {
if c == nil || c.State != occ.StateRaised || c.Severity != occ.SeverityUrgent {
t.Fatalf("at t0+%s with no event: %+v", w.h.Now().Sub(t0), c)
}
}},
@@ -718,7 +719,7 @@ func TestAnApprovedGroupIsHandedWholeInItsOrderAndAStopIsSaid(t *testing.T) {
w.settleAll()
w.later(UncheckedAfter + time.Minute)
w.settleAll()
if c := told.last(id); c == nil || c.State != asks.StateRaised {
if c := told.last(id); c == nil || c.State != occ.StateRaised {
t.Fatalf("the unchecked head is not raised: %+v", c)
}
before := len(told.told)
@@ -734,7 +735,7 @@ func TestAnApprovedGroupIsHandedWholeInItsOrderAndAStopIsSaid(t *testing.T) {
w.forge.open = nil // it closes
again.h.wantForge()
again.settleAll()
if c := told.last(id); c == nil || c.State != asks.StateCleared {
if c := told.last(id); c == nil || c.State != occ.StateCleared {
t.Fatalf("closed, and its condition is not cleared: %+v", c)
}
}},
@@ -5,7 +5,7 @@ import (
"testing"
"time"
"git.novox.be/novox/mesh-sdk/go/asks"
occ "git.novox.be/novox/mesh-contracts/go/clients/operatorchannelclient"
sdk "git.novox.be/novox/mesh-sdk/go/delivery"
)
@@ -29,19 +29,20 @@ func TestAReadyGroupWritesOneAskKeyWithTodaysWordsAndCallsNoRouter(t *testing.T)
t.Fatalf("the key %q is not merge.<group>.<heads hash>: %q %q %v", key, kind, group, err)
}
ask := w.op.byKey[key]
routed := w.op.ask(key)
if ask.Group != "feat/g" || len(ask.Heads) != 2 {
t.Fatalf("the ask value %+v does not name the group and both heads", ask)
}
if want, err := sdk.MergeAskKey("feat/g", ask.Heads); err != nil || want != key || !strings.HasSuffix(key, hash) {
t.Fatalf("the heads %v give the key %q, the ask is under %q (%v)", ask.Heads, want, key, err)
}
if ask.Ask.ID != sdk.AskID(key) {
t.Fatalf("the router's id of the ask is %q, wanted %q", ask.Ask.ID, sdk.AskID(key))
if routed.ID != sdk.AskID(key) {
t.Fatalf("the router's id of the ask is %q, wanted %q", routed.ID, sdk.AskID(key))
}
// Today's words (approval.go composeMergeAsk): the headline names the group, the details its members.
if !strings.Contains(ask.Ask.Headline, "group feat") || !strings.Contains(ask.Ask.Details, "mesh-controller#1") ||
!strings.Contains(ask.Ask.Details, "mesh-catalog#2") || len(ask.Ask.Options) != 2 || ask.Ask.Who != asks.Operator {
t.Fatalf("the ask is not today's merge ask: %+v", ask.Ask)
if !strings.Contains(routed.Headline, "group feat") || !strings.Contains(routed.Details, "mesh-controller#1") ||
!strings.Contains(routed.Details, "mesh-catalog#2") || len(routed.Options) != 2 || routed.Who != occ.Operator {
t.Fatalf("the ask is not today's merge ask: %+v", routed)
}
// A group's headline says a failed check first (issue 1166); a passed or a running one keeps the plain words.
members := []*Delivery{{Repository: "novox/mesh-controller", Number: 1}, {Repository: "novox/mesh-catalog", Number: 2}}
@@ -56,9 +57,9 @@ func TestAReadyGroupWritesOneAskKeyWithTodaysWordsAndCallsNoRouter(t *testing.T)
}
// The act the Approve binds is the one the teller checks and the controller verifies: this ask's router id, the option, and
// the heads as `owner/repo@<first 12 characters>` to the whole commit.
approve, _ := ask.Ask.Option(OptionApprove)
approve, _ := occ.OptionOf(routed, OptionApprove)
heads := map[string]string{IDOf(gCtl, gHc): gHc, IDOf(gCat, gHk): gHk}
want, _ := asks.ActDigest(asks.Act{"verb": "mesh-delivery.merge", "ask": sdk.AskID(key), "option": "approve",
want, _ := occ.ActDigest(occ.Act{"verb": "mesh-delivery.merge", "ask": sdk.AskID(key), "option": "approve",
"heads": "novox/mesh-catalog@d2d2d2d2d2d2=" + gHk + ",novox/mesh-controller@c1c1c1c1c1c1=" + gHc})
if approve.Binds != want {
t.Fatalf("the Approve binds %s, wanted the act %v of %v", approve.Binds, mergeAct(sdk.AskID(key), "approve", heads), heads)
@@ -5,7 +5,8 @@ import (
"sync"
"time"
"git.novox.be/novox/mesh-sdk/go/asks"
occ "git.novox.be/novox/mesh-contracts/go/clients/operatorchannelclient"
oc "git.novox.be/novox/mesh-contracts/go/seats/operatorchannel"
sdk "git.novox.be/novox/mesh-sdk/go/delivery"
)
@@ -17,7 +18,7 @@ type fakeOperator struct {
mu sync.Mutex
now func() time.Time
auto bool
asked []asks.Ask
asked []oc.Ask
byKey map[string]sdk.Ask
order []string
seen map[string]bool
@@ -37,7 +38,11 @@ func (o *fakeOperator) Ask(key string, a sdk.Ask) error {
if o.refuse != nil {
return o.refuse
}
if err := a.Ask.Check(o.now()); err != nil {
ask, err := askOf(a)
if err != nil {
return err
}
if err := occ.Check(ask, o.now()); err != nil {
return err
}
if _, err := sdk.EncodeAsk(a); err != nil {
@@ -46,7 +51,7 @@ func (o *fakeOperator) Ask(key string, a sdk.Ask) error {
if o.state[key] == "open" && sdk.SameAsk(o.byKey[key], a) {
return nil // the same ask is there, as kvOperator leaves it
}
o.asked = append(o.asked, a.Ask)
o.asked = append(o.asked, ask)
o.byKey[key] = a
if _, known := o.seen[key]; !known {
o.order = append(o.order, key)
@@ -75,16 +80,20 @@ func (o *fakeOperator) Where(key string) (*sdk.Answer, error) {
}
// ask is the router's ask at a key, as the gate composed it.
func (o *fakeOperator) ask(key string) asks.Ask {
func (o *fakeOperator) ask(key string) oc.Ask {
if a, ok := o.byKey[key]; ok {
return a.Ask
ask, err := askOf(a)
if err != nil {
panic(err)
}
return ask
}
panic("no ask " + key)
}
func (o *fakeOperator) answerLocked(key, option string) {
a := o.ask(key)
opt, _ := a.Option(option)
opt, _ := occ.OptionOf(a, option)
o.state[key] = "answered"
o.answers[key] = &sdk.Answer{Option: option, Warrant: fmt.Sprintf("%s@%s", a.ID, o.now().UTC().Format(time.RFC3339)),
Words: "the operator, via telegram, chose " + opt.Label}
@@ -7,7 +7,7 @@ import (
"strings"
"time"
"git.novox.be/novox/mesh-sdk/go/asks"
oc "git.novox.be/novox/mesh-contracts/go/seats/operatorchannel"
sdk "git.novox.be/novox/mesh-sdk/go/delivery"
)
@@ -111,7 +111,7 @@ type ReadyRun struct {
// waiting for an answer, Ask the ask as made (a warrant is held to it), Heads the heads it covers by delivery id (a
// group's members together), AskedAt when and Asks how many were made for this head.
AskID string `json:"ask_id,omitempty"`
Ask *asks.Ask `json:"ask,omitempty"`
Ask *oc.Ask `json:"ask,omitempty"`
Heads map[string]string `json:"heads,omitempty"`
AskedAt time.Time `json:"asked_at"`
Asks int `json:"asks,omitempty"`
@@ -7,7 +7,7 @@ import (
"testing"
"time"
"git.novox.be/novox/mesh-sdk/go/asks"
occ "git.novox.be/novox/mesh-contracts/go/clients/operatorchannelclient"
sdk "git.novox.be/novox/mesh-sdk/go/delivery"
)
@@ -159,7 +159,7 @@ func TestAnApprovedPullRequestIsMergedThroughTheMergeTool(t *testing.T) {
t.Fatalf("the ask says %q:\n%s", w, e)
}
}
if err := asks.Glance(e); err != nil {
if err := occ.Glance(e); err != nil {
t.Fatal(err)
}
})
@@ -6,7 +6,8 @@ import (
"strings"
"time"
"git.novox.be/novox/mesh-sdk/go/asks"
occ "git.novox.be/novox/mesh-contracts/go/clients/operatorchannelclient"
oc "git.novox.be/novox/mesh-contracts/go/seats/operatorchannel"
sdk "git.novox.be/novox/mesh-sdk/go/delivery"
)
@@ -97,7 +98,11 @@ func (h *Holder) Settle(ids []string, why, by string) (map[string]any, error) {
asking = max(d.Settle.Asking, 1) + 1
}
}
ask := sdk.Ask{Group: group, Heads: heads, Asking: asking, Ask: composeSettleAsk(sdk.AskingID(key, asking), covered, why, now)}
ask, err := gateAsk(group, heads, asking, composeSettleAsk(sdk.AskingID(key, asking), covered, why, now))
if err != nil {
h.mu.Unlock()
return nil, fmt.Errorf("the settle ask is not one the operator can be asked: %w", err)
}
earlier := map[string]bool{}
for _, d := range covered {
if d.Settle != nil && d.Settle.Answered == nil && d.Settle.Key != key {
@@ -132,14 +137,14 @@ func (h *Holder) Settle(ids []string, why, by string) (map[string]any, error) {
}
// settleAct is what a settle ask's answer binds: its verb (mesh-delivery.settle), this ask, the answer and the heads.
func settleAct(group, ask, option string, heads map[string]string) asks.Act {
return asks.Act{"verb": sdk.AskVerb(group), "ask": ask, "option": option, "heads": headsString(heads)}
func settleAct(group, ask, option string, heads map[string]string) occ.Act {
return occ.Act{"verb": sdk.AskVerb(group), "ask": ask, "option": option, "heads": headsString(heads)}
}
// composeSettleAsk is the operator's ask to settle these held deliveries.
func composeSettleAsk(id string, ds []*Delivery, why string, now time.Time) asks.Ask {
var a asks.Ask
a.ID, a.Who, a.Expires = id, asks.Operator, now.Add(AskLasts)
func composeSettleAsk(id string, ds []*Delivery, why string, now time.Time) oc.Ask {
var a oc.Ask
a.ID, a.Who, a.Expires = id, occ.Operator, occ.FormatTime(now.Add(AskLasts))
a.OnExpiry = "they stay held; settle asks again"
a.About = "delivery.settle"
var pulls, lines []string
@@ -150,11 +155,11 @@ func composeSettleAsk(id string, ds []*Delivery, why string, now time.Time) asks
lines = append(lines, phone(fmt.Sprintf("%s %q merged at %s without a passing check", pullSaid(d), d.Title,
shortOf(d.MergedAs, 12)), 400))
}
a.Headline = phone(fmt.Sprintf("Settle %d merges made without a passing check", len(ds)), asks.HeadlineLength)
a.Headline = phone(fmt.Sprintf("Settle %d merges made without a passing check", len(ds)), occ.HeadlineLength)
a.Explanation = strings.Join([]string{
phone(fmt.Sprintf("%s reached main without a passing check and are held. Settle ends the hold: each reads merged, and on the machines once every machine runs it. Leave keeps them held.",
strings.Join(pulls, ", ")), asks.SummaryLength),
phone("Why: "+why, asks.PointLength),
strings.Join(pulls, ", ")), occ.SummaryLength),
phone("Why: "+why, occ.PointLength),
}, "\n")
a.Details = strings.Join(lines, "\n")
group := sdk.SettleGroup(func() []string {
@@ -168,8 +173,8 @@ func composeSettleAsk(id string, ds []*Delivery, why string, now time.Time) asks
{OptionSettle, "Settle", "each delivery named ends its hold and reads merged"},
{OptionLeave, "Leave", "they stay held"},
} {
binds, _ := asks.ActDigest(settleAct(group, id, o.id, heads))
a.Options = append(a.Options, asks.Option{ID: o.id, Label: o.label, Does: o.does, Level: asks.Approve, Binds: binds})
binds, _ := occ.ActDigest(settleAct(group, id, o.id, heads))
a.Options = append(a.Options, oc.Option{ID: o.id, Label: o.label, Does: o.does, Level: occ.Approve, Binds: binds})
}
return a
}
@@ -6,7 +6,7 @@ import (
"testing"
"time"
"git.novox.be/novox/mesh-sdk/go/asks"
occ "git.novox.be/novox/mesh-contracts/go/clients/operatorchannelclient"
sdk "git.novox.be/novox/mesh-sdk/go/delivery"
)
@@ -57,7 +57,7 @@ func TestSettleAsksTheOperatorOnceAndMovesOnlyOnTheAnswer(t *testing.T) {
heads := map[string]string{a.ID: a.Commit, b.ID: b.Commit}
group := sdk.SettleGroup([]string{a.ID, b.ID})
for _, o := range ask.Options {
if want, _ := asks.ActDigest(settleAct(group, ask.ID, o.ID, heads)); o.Binds != want {
if want, _ := occ.ActDigest(settleAct(group, ask.ID, o.ID, heads)); o.Binds != want {
t.Errorf("the option %s binds %s, not mesh-delivery.settle of these heads", o.ID, o.Binds)
}
}
+2
View File
@@ -3,6 +3,8 @@ module mesh-delivery-gate
go 1.26.0
require (
git.novox.be/novox/mesh-contracts/go/clients/operatorchannelclient v0.0.0-00010101000000-000000000000
git.novox.be/novox/mesh-contracts/go/seats/operatorchannel v0.0.0-00010101000000-000000000000
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261103174208-b7abe203a929
github.com/nats-io/nats-server/v2 v2.11.17
github.com/nats-io/nats.go v1.54.0
@@ -24,7 +24,8 @@ import (
"sync/atomic"
"time"
"git.novox.be/novox/mesh-sdk/go/asks"
occ "git.novox.be/novox/mesh-contracts/go/clients/operatorchannelclient"
oc "git.novox.be/novox/mesh-contracts/go/seats/operatorchannel"
"git.novox.be/novox/mesh-sdk/go/handacts"
)
@@ -154,7 +155,7 @@ func (l *Log) record(act Act) error {
// routerRecord is what warranted reads of the router's record of an ask.
type routerRecord struct {
State string `json:"state"`
Warrant *asks.Warrant `json:"warrant"`
Warrant *oc.Warrant `json:"warrant"`
Ask map[string]any `json:"ask"`
}
@@ -175,7 +176,7 @@ func (l *Log) Warranted(caller, asker, ask string) (Recorded, error) {
return Recorded{}, fmt.Errorf("who asks is the caller the runtime proves, and it proved none (%q): nothing was recorded", caller)
case !askerModule.MatchString(asker):
return Recorded{}, fmt.Errorf("%q is not a module's name", asker)
case !asks.UsableID(ask):
case !occ.UsableID(ask):
return Recorded{}, fmt.Errorf("%q is not an ask's id", ask)
}
key := asker + "." + ask
@@ -206,16 +207,20 @@ func (l *Log) Warranted(caller, asker, ask string) (Recorded, error) {
return Recorded{}, fmt.Errorf("the router holds no closed record of %s's ask %s. Nothing was recorded", asker, ask)
case w.Asker != asker || w.Ask != ask:
return Recorded{}, fmt.Errorf("the router's record is for %s's ask %s. Nothing was recorded", w.Asker, w.Ask)
case w.Outcome != asks.OutcomeChosen || w.By == nil:
case w.Outcome != occ.OutcomeChosen || w.By == nil:
return Recorded{}, fmt.Errorf("%s's ask %s ended %s: no person chose, so there is no warrant to record. Nothing was "+
"recorded", asker, ask, w.Outcome)
case w.AskDigest == "":
return Recorded{}, fmt.Errorf("the router's warrant for %s's ask %s names no ask digest. Nothing was recorded", asker, ask)
}
warranted, err := occ.Time(w.At)
if err != nil {
return Recorded{}, fmt.Errorf("the router's record of %s's ask %s is not readable: %w", asker, ask, err)
}
at := l.Now().UTC()
act := Act{ID: "warrant-" + asker + "-" + ask, At: w.At.UTC(), Verb: VerbWarrant,
act := Act{ID: "warrant-" + asker + "-" + ask, At: warranted.UTC(), Verb: VerbWarrant,
Args: []string{fmt.Sprintf("the operator chose %s on %s's ask %s", w.Label, asker, ask)},
Why: fmt.Sprintf("%s (ask %s of %s)", w.Says(), ask, asker),
Why: fmt.Sprintf("%s (ask %s of %s)", occ.Says(*w), ask, asker),
By: fmt.Sprintf("the %s, as %s identity %s", w.By.Who, w.By.Kind, w.By.Identity),
Cause: CauseOperatorAnswer, Via: via(*w), Ask: ask, Proofs: w.Proofs,
RequestedBy: asker + ", recorded at the word of " + caller,
@@ -250,7 +255,7 @@ func (l *Log) already(key string, raw []byte) (Recorded, bool, error) {
return Recorded{Act: act, Said: "already recorded as " + act.ID + ": " + act.Why}, true, nil
}
func via(w asks.Warrant) string {
func via(w oc.Warrant) string {
if w.By == nil {
return w.Channel
}
+2
View File
@@ -3,6 +3,8 @@ module mesh-hand-acts
go 1.26.0
require (
git.novox.be/novox/mesh-contracts/go/clients/operatorchannelclient v0.0.0-00010101000000-000000000000
git.novox.be/novox/mesh-contracts/go/seats/operatorchannel v0.0.0-00010101000000-000000000000
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261102191058-f02f142af128
github.com/nats-io/nats.go v1.54.0
)
@@ -6,7 +6,7 @@ import (
"testing"
"time"
"git.novox.be/novox/mesh-sdk/go/asks"
occ "git.novox.be/novox/mesh-contracts/go/clients/operatorchannelclient"
)
// novox/hq issue 639: the operator would not read the ask for ADR 0343 — thirty plain lines, every clause one per
@@ -44,13 +44,13 @@ func TestADecisionAskReadsAtAGlance(t *testing.T) {
r := adr0343()
at := time.Date(2026, 10, 13, 14, 5, 0, 0, time.UTC)
a := composeAsk(r, &OperatorAsk{ID: "kb-decision-343-0d1e2f3a", At: at, Version: 12, Form: AskGlance})
if err := a.Check(at); err != nil {
if err := occ.Check(a, at); err != nil {
t.Fatal(err)
}
if a.Headline != "Your agents' memory follows you to every machine" {
t.Errorf("the headline is not what it changes for the operator: %q", a.Headline)
}
if err := asks.Glance(a.Explanation); err != nil {
if err := occ.Glance(a.Explanation); err != nil {
t.Errorf("%v:\n%s", err, a.Explanation)
}
if n := strings.Count(a.Explanation, "\n• "); n != 3 || !strings.HasPrefix(a.Explanation, "What an agent learns") {
@@ -69,13 +69,13 @@ func TestADecisionAskReadsAtAGlance(t *testing.T) {
}
// The ask made before the brief is composed again as it was, so its warrant still holds after the change.
old := composeAsk(r, &OperatorAsk{ID: "kb-decision-343-0d1e2f3a", At: at, Version: 12})
if !strings.Contains(old.Explanation, "Its 13 clause(s):") || old.Headline != "Accept ADR 0343?" || old.Details != "" || old.Digest() == a.Digest() {
if !strings.Contains(old.Explanation, "Its 13 clause(s):") || old.Headline != "Accept ADR 0343?" || old.Details != "" || occ.Digest(old) == occ.Digest(a) {
t.Errorf("an ask made before the brief is not composed as it was asked:\n%s", old.Explanation)
}
// Without a brief (a record proposed before the rule), the glance is the title, and no clause.
r.Brief = nil
a = composeAsk(r, &OperatorAsk{ID: "kb-decision-343-0d1e2f3a", At: at, Version: 12, Form: AskGlance})
if a.Headline != "Approve ADR 0343?" || asks.Glance(a.Explanation) != nil || strings.Contains(a.Explanation, "Only the memory folders") {
if a.Headline != "Approve ADR 0343?" || occ.Glance(a.Explanation) != nil || strings.Contains(a.Explanation, "Only the memory folders") {
t.Errorf("without a brief: %q\n%s", a.Headline, a.Explanation)
}
}
@@ -17,8 +17,8 @@ import (
"sync"
"time"
oc "git.novox.be/novox/mesh-contracts/go/seats/operatorchannel"
stdio "git.novox.be/novox/mesh-sdk/go"
"git.novox.be/novox/mesh-sdk/go/asks"
)
// Unproven is who acted, as recorded, where the runtime proves no caller (hq issue 365): the SDK's word. The caller
@@ -816,7 +816,7 @@ func (k *KB) WithdrawRecord(caller string, key Key, why string) (*Withdrawn, err
k.emit(EventChanged, r, caller, full, e.Versions+1)
if waiting && k.Asks != nil {
id := cur.Ask.ID
if err := k.Asks.Publish(asks.CancelSubject(Module), map[string]string{"id": id}, "cancel."+id); err != nil {
if err := k.Asks.Publish(oc.AcceptSubjectCancel(Module), map[string]string{"id": id}, "cancel."+id); err != nil {
k.logf("[mesh-knowledge] the ask %s for %s is taken back here, and the router could not be told (%v): it expires there, "+
"and no answer to it is acted on", id, key, err)
}
@@ -22,8 +22,9 @@ import (
"testing"
"time"
occ "git.novox.be/novox/mesh-contracts/go/clients/operatorchannelclient"
oc "git.novox.be/novox/mesh-contracts/go/seats/operatorchannel"
stdio "git.novox.be/novox/mesh-sdk/go"
"git.novox.be/novox/mesh-sdk/go/asks"
)
const bundleVar = "MESH_KNOWLEDGE_TEST_BUNDLE"
@@ -326,7 +327,7 @@ func (j *judge) judge(question string, schema map[string]any, model string) (jso
// the cancels taken back on its subject.
type router struct {
mu sync.Mutex
asked []asks.Ask
asked []oc.Ask
records map[string]routerRecord
cancelled []string
}
@@ -337,13 +338,13 @@ func (r *router) Publish(subject string, body any, id string) error {
r.mu.Lock()
defer r.mu.Unlock()
switch subject {
case asks.AskSubject(Module):
a := body.(asks.Ask)
case oc.AcceptSubjectAsk(Module):
a := body.(oc.Ask)
r.asked = append(r.asked, a)
cp := a
r.records[a.ID] = routerRecord{State: "open", Ask: &cp}
return nil
case asks.CancelSubject(Module):
case oc.AcceptSubjectCancel(Module):
m := body.(map[string]string)
r.cancelled = append(r.cancelled, m["id"])
return nil
@@ -363,14 +364,14 @@ func (r *router) Record(id string) (*stdio.StateEntry, error) {
}
// answer closes an ask as the router does when the operator taps an option on a channel that proves who answered.
func (r *router) answer(id, option string, at time.Time) asks.Warrant {
func (r *router) answer(id, option string, at time.Time) oc.Warrant {
r.mu.Lock()
defer r.mu.Unlock()
rec := r.records[id]
o, _ := rec.Ask.Option(option)
w := asks.Warrant{Ask: id, Asker: Module, About: rec.Ask.About, Outcome: asks.OutcomeChosen, Option: o.ID, Label: o.Label,
Level: o.Level, By: &asks.Person{Who: asks.Operator, Kind: "telegram", Identity: "1", Verified: "user id verified"},
Channel: "telegram", At: at, AskDigest: rec.Ask.Digest()}
o, _ := occ.OptionOf(*rec.Ask, option)
w := oc.Warrant{Ask: id, Asker: Module, About: rec.Ask.About, Outcome: occ.OutcomeChosen, Option: o.ID, Label: o.Label,
Level: o.Level, By: &oc.Person{Who: occ.Operator, Kind: "telegram", Identity: "1", Verified: "user id verified"},
Channel: "telegram", At: occ.FormatTime(at), AskDigest: occ.Digest(*rec.Ask)}
rec.State, rec.Warrant = "closed", &w
r.records[id] = rec
return w
@@ -29,8 +29,8 @@ import (
"sync"
"time"
oc "git.novox.be/novox/mesh-contracts/go/seats/operatorchannel"
stdio "git.novox.be/novox/mesh-sdk/go"
"git.novox.be/novox/mesh-sdk/go/asks"
)
// Seat is the role this module holds, once for the mesh.
@@ -122,8 +122,8 @@ func callerOf(args map[string]any) string { return stdio.CallerOf(args) }
// hear subscribes to the router's word on this module's asks, asked again until the runtime takes it.
func hear(k *KB) {
for wait := 2 * time.Second; ; wait = min(wait*2, time.Minute) {
err := stdio.Subscribe(asks.Seat+".decided."+Module, func(e stdio.Envelope) error {
var w asks.Warrant
err := stdio.Subscribe(oc.Seat+".decided."+Module, func(e stdio.Envelope) error {
var w oc.Warrant
if json.Unmarshal(e.Body, &w) == nil {
k.HeardEnd(w)
}
@@ -20,8 +20,9 @@ import (
"strings"
"time"
occ "git.novox.be/novox/mesh-contracts/go/clients/operatorchannelclient"
oc "git.novox.be/novox/mesh-contracts/go/seats/operatorchannel"
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.
@@ -34,7 +35,7 @@ const (
)
// AskLasts is how long the operator has to answer: a day less a margin, under the router's bound for an approval.
const AskLasts = asks.ApproveLasts - 10*time.Minute
const AskLasts = occ.ApproveLasts - 10*time.Minute
// The states of an operator's ask, as the record keeps them.
const (
@@ -58,8 +59,8 @@ func (sdkAskBus) Publish(subject string, body any, id string) error {
func (sdkAskBus) Record(id string) (*stdio.StateEntry, error) { return stdio.SeatRecord("", id) }
// act is what an answer binds: this record's version, by its digest, and the answer.
func act(r *Record, a *OperatorAsk, option string) asks.Act {
return asks.Act{"verb": "knowledge-base.accept", "option": option, "ask": a.ID, "kind": r.Kind,
func act(r *Record, a *OperatorAsk, option string) occ.Act {
return occ.Act{"verb": "knowledge-base.accept", "option": option, "ask": a.ID, "kind": r.Kind,
"number": strconv.Itoa(r.Number), "version": strconv.Itoa(a.Version), "digest": r.Digest()}
}
@@ -90,7 +91,7 @@ const AskGlance = 1
// composeAsk is the ask for a record, composed from the record and its ask alone: asked once, and composed
// again to hold the warrant to it — in the form it was asked in, so an ask made before a form changed is still
// held to the words the operator was shown.
func composeAsk(r *Record, a *OperatorAsk) asks.Ask {
func composeAsk(r *Record, a *OperatorAsk) oc.Ask {
if a.Form == AskGlance {
return glanceAsk(r, a)
}
@@ -101,10 +102,10 @@ func composeAsk(r *Record, a *OperatorAsk) asks.Ask {
// points; who proposed it, its review, its fingerprint and how to read every clause are its Details, which the
// router notes after the answers. A record without a brief (one proposed before the rule) is asked with its
// title as its summary. The clauses are never listed.
func glanceAsk(r *Record, a *OperatorAsk) asks.Ask {
func glanceAsk(r *Record, a *OperatorAsk) oc.Ask {
name := r.Key().String()
headline, explanation := "Approve "+name+"?", name+" needs your approval before it holds."
if t := strings.TrimSuffix(strings.TrimSpace(r.Title), "."); shown(t) && len([]rune(t)) < asks.SummaryLength {
if t := strings.TrimSuffix(strings.TrimSpace(r.Title), "."); shown(t) && len([]rune(t)) < occ.SummaryLength {
explanation = t + "."
}
if r.Brief != nil && len(briefProblems(r.Brief)) == 0 {
@@ -120,12 +121,12 @@ func glanceAsk(r *Record, a *OperatorAsk) asks.Ask {
}
details := fmt.Sprintf("%s, proposed by %s; the knowledge base's own review approved it. Fingerprint %s.\n%s",
name, author, fingerprint(r.Digest()), clauses)
return withOptions(r, a, asks.Ask{ID: a.ID, Headline: headline, Explanation: explanation, Details: details})
return withOptions(r, a, oc.Ask{ID: a.ID, Headline: headline, Explanation: explanation, Details: details})
}
// listedAsk is the ask as it was composed before the brief: every clause's lead listed. Composed only to hold
// the warrant of an ask made then.
func listedAsk(r *Record, a *OperatorAsk) asks.Ask {
func listedAsk(r *Record, a *OperatorAsk) oc.Ask {
name := r.Key().String()
headline := "Accept " + name + "?"
var b strings.Builder
@@ -150,21 +151,21 @@ func listedAsk(r *Record, a *OperatorAsk) asks.Ask {
}
}
fmt.Fprintf(&b, "\n\nRead it whole with the knowledge base's get, number %d; its fingerprint is %s.", r.Number, fingerprint(r.Digest()))
return withOptions(r, a, asks.Ask{ID: a.ID, Headline: headline, Explanation: b.String()})
return withOptions(r, a, oc.Ask{ID: a.ID, Headline: headline, Explanation: b.String()})
}
// withOptions is an ask with what every form of it carries: who answers, its expiry, what it is about, and its
// two options, each binding its act.
func withOptions(r *Record, a *OperatorAsk, out asks.Ask) asks.Ask {
out.Who, out.Expires = asks.Operator, a.At.Add(AskLasts)
func withOptions(r *Record, a *OperatorAsk, out oc.Ask) oc.Ask {
out.Who, out.Expires = occ.Operator, occ.FormatTime(a.At.Add(AskLasts))
out.OnExpiry = "the record stays proposed; nothing of it is in force"
out.About = "knowledge." + r.Kind + "." + strconv.Itoa(r.Number)
for _, o := range []struct{ id, label, does string }{
{OptionApprove, "Approve", "the record is accepted and its clauses are in force"},
{OptionDecline, "Decline", "the record stays proposed; nothing of it is in force"},
} {
binds, _ := asks.ActDigest(act(r, a, o.id))
out.Options = append(out.Options, asks.Option{ID: o.id, Label: o.label, Does: o.does, Level: asks.Approve, Binds: binds})
binds, _ := occ.ActDigest(act(r, a, o.id))
out.Options = append(out.Options, oc.Option{ID: o.id, Label: o.label, Does: o.does, Level: occ.Approve, Binds: binds})
}
return out
}
@@ -204,7 +205,7 @@ func (k *KB) askOperator(ctx context.Context, held *Entry, w *Record, by string,
}
oa := &OperatorAsk{ID: newAskID(w), At: k.now(), Version: version, Said: AskWaiting, Form: AskGlance}
ask := composeAsk(w, oa)
if err := ask.Check(k.now()); err != nil {
if err := occ.Check(ask, k.now()); err != nil {
return unasked(fmt.Sprintf("the ask could not be made (%v)", err))
}
w.Ask = oa
@@ -213,7 +214,7 @@ func (k *KB) askOperator(ctx context.Context, held *Entry, w *Record, by string,
return "", fmt.Errorf("%s: the review could not be kept (%v); the operator was not asked", w.Key(), err)
}
k.emit(EventReviewed, w, by, "approved; the operator is asked", held.Versions+1)
if err := k.Asks.Publish(asks.AskSubject(Module), ask, ask.ID); err != nil {
if err := k.Asks.Publish(oc.AcceptSubjectAsk(Module), ask, ask.ID); err != nil {
k.logf("[mesh-knowledge] the ask for %s could not be published: %v", w.Key(), err)
return unasked(fmt.Sprintf("the operator could not be asked (%v)", err))
}
@@ -224,9 +225,9 @@ func (k *KB) askOperator(ctx context.Context, held *Entry, w *Record, by string,
type routerRecord struct {
State string `json:"state"`
// Held is why no channel can carry the ask yet, while its state is held (ADR 0397).
Held string `json:"held"`
Ask *asks.Ask `json:"ask"`
Warrant *asks.Warrant `json:"warrant"`
Held string `json:"held"`
Ask *oc.Ask `json:"ask"`
Warrant *oc.Warrant `json:"warrant"`
}
// Reconcile decides every record waiting for the operator whose ask the router has closed: accepted on a warrant
@@ -319,10 +320,10 @@ func (k *KB) endAsk(ctx context.Context, e *Entry, w *Record, why string) string
}
// decide holds one closed ask's warrant to the record, and accepts the record when it is the operator's Approve.
func (k *KB) decide(ctx context.Context, e *Entry, w *Record, warrant asks.Warrant) string {
func (k *KB) decide(ctx context.Context, e *Entry, w *Record, warrant oc.Warrant) string {
w = w.Clone()
w.Ask.Held = "" // an ask that ended is held no longer
if warrant.Outcome != asks.OutcomeChosen {
if warrant.Outcome != occ.OutcomeChosen {
why := "ended " + string(warrant.Outcome)
if warrant.Words != "" {
why += " (" + warrant.Words + ")"
@@ -330,9 +331,9 @@ func (k *KB) decide(ctx context.Context, e *Entry, w *Record, warrant asks.Warra
return k.endAsk(ctx, e, w, why)
}
ask := composeAsk(w, w.Ask)
option, err := warrant.For(Module, ask)
option, err := occ.For(warrant, Module, ask)
if err == nil {
err = option.Performs(act(w, w.Ask, option.ID))
err = occ.Performs(option, act(w, w.Ask, option.ID))
}
if err != nil {
k.logf("[mesh-knowledge] REFUSED the answer to the ask for %s: %v", w.Key(), err)
@@ -341,15 +342,15 @@ func (k *KB) decide(ctx context.Context, e *Entry, w *Record, warrant asks.Warra
switch option.ID {
case OptionApprove:
w = w.Clone()
w.Ask.Said, w.Ask.Warrant = AskApproved, warrant.Says()
r, err := k.accept(ctx, e, w, "the operator", "accepted on the operator's Approve after the knowledge base's review", "warrant: "+warrant.Says())
w.Ask.Said, w.Ask.Warrant = AskApproved, occ.Says(warrant)
r, err := k.accept(ctx, e, w, "the operator", "accepted on the operator's Approve after the knowledge base's review", "warrant: "+occ.Says(warrant))
if err != nil {
return err.Error()
}
return fmt.Sprintf("%s: %s on the operator's warrant (%s)", r.Key(), r.Status, warrant.Says())
return fmt.Sprintf("%s: %s on the operator's warrant (%s)", r.Key(), r.Status, occ.Says(warrant))
case OptionDecline:
w = w.Clone()
w.Ask.Warrant = warrant.Says()
w.Ask.Warrant = occ.Says(warrant)
return k.endAsk(ctx, e, w, "declined by the operator")
}
return k.endAsk(ctx, e, w, "was answered with "+option.ID+", which it does not offer")
@@ -357,8 +358,8 @@ func (k *KB) decide(ctx context.Context, e *Entry, w *Record, warrant asks.Warra
// HeardEnd takes the router's word that one of this module's asks ended without a choice: nothing is accepted on
// such a word, so it needs no proof beyond its subject. A choice is only ever taken from the router's record.
func (k *KB) HeardEnd(w asks.Warrant) {
if w.Asker != Module || w.Outcome == asks.OutcomeChosen {
func (k *KB) HeardEnd(w oc.Warrant) {
if w.Asker != Module || w.Outcome == occ.OutcomeChosen {
if _, err := k.Reconcile(); err != nil {
k.logf("[mesh-knowledge] reading the router's answers: %v", err)
}
@@ -20,7 +20,8 @@ import (
"strings"
"time"
"git.novox.be/novox/mesh-sdk/go/asks"
occ "git.novox.be/novox/mesh-contracts/go/clients/operatorchannelclient"
oc "git.novox.be/novox/mesh-contracts/go/seats/operatorchannel"
)
// RetractAsked is the status of a retraction asked and not yet answered.
@@ -127,7 +128,7 @@ func (k *KB) Retract(caller string, key Key, clauses []int, why string) (*Retrac
x := &Retraction{Clauses: sorted, Why: why, By: caller, At: at, Title: cur.Title, Leads: leads,
Ask: &OperatorAsk{ID: newRetractAskID(cur), At: at, Version: e.CurrentVersion, Said: AskWaiting, Form: AskGlance}}
ask := composeRetractAsk(cur, x)
if err := ask.Check(at); err != nil {
if err := occ.Check(ask, at); err != nil {
return nil, fmt.Errorf("the ask could not be made (%v); nothing was asked", err)
}
r := cur.Clone()
@@ -136,7 +137,7 @@ func (k *KB) Retract(caller string, key Key, clauses []int, why string) (*Retrac
if err := k.Store.Save(ctx, Version{By: caller, At: at, Why: what, Record: r}, true, false, e.Revision); err != nil {
return nil, fmt.Errorf("%s could not be written (%v); nothing was asked", key, err)
}
if err := k.Asks.Publish(asks.AskSubject(Module), ask, ask.ID); err != nil {
if err := k.Asks.Publish(oc.AcceptSubjectAsk(Module), ask, ask.ID); err != nil {
// Taken back: the record says it was not asked, and nothing waits on it.
k.logf("[mesh-knowledge] the retraction of %s could not be asked: %v", key, err)
if e2, gerr := k.Store.Get(ctx, key); gerr == nil && e2 != nil {
@@ -176,7 +177,7 @@ func (k *KB) WithdrawRetraction(caller string, key Key, why string) (*Withdrawn,
return nil, err
}
if k.Asks != nil {
if err := k.Asks.Publish(asks.CancelSubject(Module), map[string]string{"id": id}, "cancel."+id); err != nil {
if err := k.Asks.Publish(oc.AcceptSubjectCancel(Module), map[string]string{"id": id}, "cancel."+id); err != nil {
k.logf("[mesh-knowledge] the ask %s for %s is taken back here, and the router could not be told (%v): it expires there, "+
"and no answer to it is acted on", id, key, err)
}
@@ -201,18 +202,18 @@ func newRetractAskID(r *Record) string {
}
// retractAct is what an answer to a retraction binds: this record, the clauses, why, who asked, and the answer.
func retractAct(r *Record, x *Retraction, option string) asks.Act {
return asks.Act{"verb": "knowledge-base.retract", "option": option, "ask": x.Ask.ID, "kind": r.Kind,
func retractAct(r *Record, x *Retraction, option string) occ.Act {
return occ.Act{"verb": "knowledge-base.retract", "option": option, "ask": x.Ask.ID, "kind": r.Kind,
"number": strconv.Itoa(r.Number), "clauses": clauseList(x.Clauses), "why": x.Why, "by": x.By}
}
// composeRetractAsk is the ask for a retraction, composed from the retraction alone, so it is the same when it is
// composed again to hold the warrant to it.
func composeRetractAsk(r *Record, x *Retraction) asks.Ask {
func composeRetractAsk(r *Record, x *Retraction) oc.Ask {
name := r.Key().String()
headline := "Retract " + name + "?"
if !x.Whole() {
if h := "Retract " + name + " " + clauseList(x.Clauses) + "?"; len([]rune(h)) <= asks.HeadlineLength {
if h := "Retract " + name + " " + clauseList(x.Clauses) + "?"; len([]rune(h)) <= occ.HeadlineLength {
headline = h
} else {
headline = fmt.Sprintf("Retract %d clauses of %s?", len(x.Clauses), name)
@@ -225,12 +226,12 @@ func composeRetractAsk(r *Record, x *Retraction) asks.Ask {
summary := what + " would no longer hold; nothing is deleted, its versions stay in history."
var points []string
if shown(x.Why) {
points = append(points, "Why: "+clip(x.Why, asks.PointLength-6))
points = append(points, "Why: "+clip(x.Why, occ.PointLength-6))
} else {
points = append(points, "Why: read it with the knowledge base's get.")
}
if t := strings.TrimSuffix(strings.TrimSpace(x.Title), "."); shown(t) {
points = append(points, "Record: "+clip(t, asks.PointLength-9))
points = append(points, "Record: "+clip(t, occ.PointLength-9))
}
if !x.Whole() {
var leads []string
@@ -240,7 +241,7 @@ func composeRetractAsk(r *Record, x *Retraction) asks.Ask {
}
}
if len(leads) > 0 {
points = append(points, clip("Clauses: "+strings.Join(leads, "; "), asks.PointLength))
points = append(points, clip("Clauses: "+strings.Join(leads, "; "), occ.PointLength))
}
}
explanation := summary
@@ -249,15 +250,15 @@ func composeRetractAsk(r *Record, x *Retraction) asks.Ask {
}
details := fmt.Sprintf("%s, retraction of %s asked by %s: %s\nRead it with the knowledge base's get, number %d.",
name, x.What(), x.By, x.Why, r.Number)
out := asks.Ask{ID: x.Ask.ID, Headline: headline, Explanation: explanation, Details: details,
Who: asks.Operator, Expires: x.Ask.At.Add(AskLasts), OnExpiry: "nothing is retracted; the record holds as it did",
out := oc.Ask{ID: x.Ask.ID, Headline: headline, Explanation: explanation, Details: details,
Who: occ.Operator, Expires: occ.FormatTime(x.Ask.At.Add(AskLasts)), OnExpiry: "nothing is retracted; the record holds as it did",
About: "knowledge." + r.Kind + "." + strconv.Itoa(r.Number)}
for _, o := range []struct{ id, label, does string }{
{OptionApprove, "Approve", "it is retracted and no longer holds"},
{OptionDecline, "Decline", "nothing is retracted; it holds as it did"},
} {
binds, _ := asks.ActDigest(retractAct(r, x, o.id))
out.Options = append(out.Options, asks.Option{ID: o.id, Label: o.label, Does: o.does, Level: asks.Approve, Binds: binds})
binds, _ := occ.ActDigest(retractAct(r, x, o.id))
out.Options = append(out.Options, oc.Option{ID: o.id, Label: o.label, Does: o.does, Level: occ.Approve, Binds: binds})
}
return out
}
@@ -310,30 +311,30 @@ func (k *KB) endRetractionSaid(ctx context.Context, e *Entry, by, why string) st
}
// decideRetraction holds one closed ask's warrant to the retraction, and retracts on the operator's Approve.
func (k *KB) decideRetraction(ctx context.Context, e *Entry, warrant asks.Warrant) string {
func (k *KB) decideRetraction(ctx context.Context, e *Entry, warrant oc.Warrant) string {
cur := e.Current
x := cur.Retracting
if warrant.Outcome != asks.OutcomeChosen {
if warrant.Outcome != occ.OutcomeChosen {
why := "retraction not made: the operator's ask ended " + string(warrant.Outcome)
if warrant.Words != "" {
why += " (" + warrant.Words + ")"
}
return k.endRetractionSaid(ctx, e, "the knowledge base", why)
}
option, err := warrant.For(Module, composeRetractAsk(cur, x))
option, err := occ.For(warrant, Module, composeRetractAsk(cur, x))
if err == nil {
err = option.Performs(retractAct(cur, x, option.ID))
err = occ.Performs(option, retractAct(cur, x, option.ID))
}
if err != nil {
k.logf("[mesh-knowledge] REFUSED the answer to the ask to retract %s: %v", cur.Key(), err)
return k.endRetractionSaid(ctx, e, "the knowledge base", "retraction not made: the answer does not hold here: "+err.Error())
}
if option.ID != OptionApprove {
return k.endRetractionSaid(ctx, e, "the operator", "retraction of "+x.What()+" declined by the operator ("+warrant.Says()+")")
return k.endRetractionSaid(ctx, e, "the operator", "retraction of "+x.What()+" declined by the operator ("+occ.Says(warrant)+")")
}
r := cur.Clone()
done := *r.Retracting
done.Ask.Said, done.Ask.Warrant, done.Ask.Held, done.Applied = AskApproved, warrant.Says(), "", k.now()
done.Ask.Said, done.Ask.Warrant, done.Ask.Held, done.Applied = AskApproved, occ.Says(warrant), "", k.now()
r.Retracting = nil
if done.Whole() {
done.Before, r.Status = r.Status, StatusRetracted
@@ -345,7 +346,7 @@ func (k *KB) decideRetraction(ctx context.Context, e *Entry, warrant asks.Warran
}
}
r.Retractions = append(r.Retractions, done)
why := fmt.Sprintf("retracted %s on the operator's Approve, asked by %s: %s; warrant: %s", done.What(), done.By, done.Why, warrant.Says())
why := fmt.Sprintf("retracted %s on the operator's Approve, asked by %s: %s; warrant: %s", done.What(), done.By, done.Why, occ.Says(warrant))
if err := k.Store.Save(ctx, Version{By: "the operator", At: k.now(), Why: why, Record: r}, true, false, e.Revision); err != nil {
return fmt.Sprintf("%s could not be retracted (%v); it holds as it did", r.Key(), err)
}
@@ -6,7 +6,7 @@ import (
"testing"
"time"
"git.novox.be/novox/mesh-sdk/go/asks"
occ "git.novox.be/novox/mesh-contracts/go/clients/operatorchannelclient"
)
// to-be 55 §10: "A record is accepted only after the knowledge base's own judgement approves it, and, when it needs
@@ -248,9 +248,9 @@ func TestARecordThatNeedsTheOperatorIsAcceptedOnlyOnTheWarrant(t *testing.T) {
t.Fatalf("after the review: %s, asks %d", e.Current.Status, len(w.router.asked))
}
ask := w.router.asked[0]
if err := ask.Check(w.clock); err != nil || ask.Options[0].Level != asks.Approve || ask.Options[1].Level != asks.Approve ||
if err := occ.Check(ask, w.clock); err != nil || ask.Options[0].Level != occ.Approve || ask.Options[1].Level != occ.Approve ||
!strings.Contains(ask.Details, "the knowledge base's own review") || ask.Headline != "Merges land after ninety seconds of quiet" ||
asks.Glance(ask.Explanation) != nil || strings.Contains(ask.Explanation, "The window closes") {
occ.Glance(ask.Explanation) != nil || strings.Contains(ask.Explanation, "The window closes") {
t.Fatalf("the ask: %v %+v", err, ask)
}
// Without the warrant: nothing is accepted, however often the router's record is read or the reviews run.
@@ -275,7 +275,7 @@ func TestARecordThatNeedsTheOperatorIsAcceptedOnlyOnTheWarrant(t *testing.T) {
t.Fatal(err)
}
e := mustGet(t, s, Key{KindDecision, n})
if e.Current.Status != StatusAccepted || e.Current.Ask == nil || e.Current.Ask.Warrant != warrant.Says() ||
if e.Current.Status != StatusAccepted || e.Current.Ask == nil || e.Current.Ask.Warrant != occ.Says(warrant) ||
e.Current.Ask.Said != AskApproved || len(said) != 1 {
t.Fatalf("on the warrant: %+v, ask %+v, said %v", e.Current.Status, e.Current.Ask, said)
}
@@ -17,7 +17,7 @@ import (
"regexp"
"strings"
"git.novox.be/novox/mesh-sdk/go/asks"
occ "git.novox.be/novox/mesh-contracts/go/clients/operatorchannelclient"
)
// Rule is one rule that judges one record: its id and what it holds.
@@ -654,7 +654,7 @@ func Holds(rule, kind, date string) bool {
// briefProblems are the ways a brief would not read at a glance on the operator's phone (novox/hq issue 639):
// missing; a headline empty or longer than an ask's; a summary or a point the router could refuse, or that
// names a path, an address or a long word (shown); more than three points, or one too long (asks.Glance).
// names a path, an address or a long word (shown); more than three points, or one too long (occ.Glance).
func briefProblems(b *Brief) []string {
if b == nil {
return []string{"a record that needs the operator carries a brief (headline, summary, points): what it changes for the operator, " +
@@ -662,8 +662,8 @@ func briefProblems(b *Brief) []string {
}
var out []string
h := strings.TrimSpace(b.Headline)
if h == "" || strings.Contains(h, "\n") || len([]rune(h)) > asks.HeadlineLength {
out = append(out, fmt.Sprintf("the brief's headline is empty, more than one line, or longer than %d characters", asks.HeadlineLength))
if h == "" || strings.Contains(h, "\n") || len([]rune(h)) > occ.HeadlineLength {
out = append(out, fmt.Sprintf("the brief's headline is empty, more than one line, or longer than %d characters", occ.HeadlineLength))
}
if s := strings.TrimSpace(b.Summary); s == "" || strings.Contains(s, "\n") {
out = append(out, "the brief's summary is empty or more than one line")
@@ -673,7 +673,7 @@ func briefProblems(b *Brief) []string {
out = append(out, fmt.Sprintf("the brief's point %d is empty or more than one line", i+1))
}
}
if err := asks.Glance(b.Explanation()); err != nil {
if err := occ.Glance(b.Explanation()); err != nil {
out = append(out, "the brief "+strings.TrimPrefix(err.Error(), "the explanation "))
}
for _, line := range append([]string{h}, strings.Split(b.Explanation(), "\n")...) {
@@ -8,7 +8,7 @@ import (
"strings"
"testing"
"git.novox.be/novox/mesh-sdk/go/asks"
occ "git.novox.be/novox/mesh-contracts/go/clients/operatorchannelclient"
)
func TestAReservedRecordIsWithdrawnAtOnce(t *testing.T) {
@@ -181,7 +181,7 @@ func TestARetractionTakesEffectOnlyOnTheOperatorsApprove(t *testing.T) {
t.Fatalf("asked %d times", len(w.router.asked)-asked)
}
a := w.router.asked[len(w.router.asked)-1]
if err := a.Check(w.clock); err != nil || asks.Glance(a.Explanation) != nil || len([]rune(a.Headline)) > 60 ||
if err := occ.Check(a, w.clock); err != nil || occ.Glance(a.Explanation) != nil || len([]rune(a.Headline)) > 60 ||
!strings.Contains(a.Headline, key.String()) || !strings.Contains(a.Explanation+a.Details, why) {
t.Fatalf("the ask %+v: %v", a, err)
}
+2
View File
@@ -3,6 +3,8 @@ module mesh-knowledge
go 1.26.0
require (
git.novox.be/novox/mesh-contracts/go/clients/operatorchannelclient v0.0.0-00010101000000-000000000000
git.novox.be/novox/mesh-contracts/go/seats/operatorchannel v0.0.0-00010101000000-000000000000
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261102191058-f02f142af128
github.com/jackc/pgx/v5 v5.11.0
github.com/nats-io/nats-server/v2 v2.11.17
+3 -3
View File
@@ -11,7 +11,7 @@ import (
"strings"
"testing"
"git.novox.be/novox/mesh-sdk/go/asks"
oc "git.novox.be/novox/mesh-contracts/go/seats/operatorchannel"
)
// wholeAsk is every word the operator can ever see for one ask: its Headline, Explanation and Details
@@ -26,7 +26,7 @@ func wholeAsk(r *sendRouter, id string) string {
// judgedWords is exactly the text messenger refuses (or lets through) an ask as a whole on (asks.go's
// TakeAsk): its headline, its explanation, what happens at expiry, and each option's label and effect.
// Details is never part of it.
func judgedWords(a asks.Ask) string {
func judgedWords(a oc.Ask) string {
words := []string{a.Headline, a.Explanation, a.OnExpiry}
for _, o := range a.Options {
words = append(words, o.Label, o.Does)
@@ -36,7 +36,7 @@ func judgedWords(a asks.Ask) string {
// mustNotBeRefused fails the test naming what the content rule refused, and why — judged the way messenger
// itself judges an ask as a whole, on judgedWords alone.
func mustNotBeRefused(t *testing.T, a asks.Ask) {
func mustNotBeRefused(t *testing.T, a oc.Ask) {
t.Helper()
if refusal, ok := Check(judgedWords(a)); !ok {
t.Fatalf("the content rule refuses the ask: %s\n%s", refusal, judgedWords(a))
@@ -13,7 +13,8 @@ import (
"testing"
"time"
"git.novox.be/novox/mesh-sdk/go/asks"
occ "git.novox.be/novox/mesh-contracts/go/clients/operatorchannelclient"
oc "git.novox.be/novox/mesh-contracts/go/seats/operatorchannel"
)
// claimTestSend asks and approves one send (through the tools, as issue 1194 built it), leaving it queued,
@@ -62,13 +63,13 @@ func rawQueuedSend(t *testing.T, sends *memSends) SendRequest {
// warrantFor is a well-formed warrant answering req's ask with Send, at, and the ask's own digest — the
// same shape sendRouter.chose builds, for a test that never published the ask to a router at all.
func warrantFor(req SendRequest, at time.Time) asks.Warrant {
func warrantFor(req SendRequest, at time.Time) oc.Warrant {
ask := req.Ask()
opt, _ := ask.Option(OptionSend)
return asks.Warrant{Ask: req.ID, Asker: Module, Outcome: asks.OutcomeChosen, Option: opt.ID, Label: opt.Label,
opt, _ := occ.OptionOf(ask, OptionSend)
return oc.Warrant{Ask: req.ID, Asker: Module, Outcome: occ.OutcomeChosen, Option: opt.ID, Label: opt.Label,
Level: opt.Level,
By: &asks.Person{Who: asks.Operator, Kind: "telegram", Identity: "42", Verified: "user id verified"},
Channel: "telegram", Proofs: []string{"P1"}, At: at, AskDigest: ask.Digest()}
By: &oc.Person{Who: occ.Operator, Kind: "telegram", Identity: "42", Verified: "user id verified"},
Channel: "telegram", Proofs: []string{"P1"}, At: occ.FormatTime(at), AskDigest: occ.Digest(ask)}
}
// 1196/AC1: given a queued send whose router record is closed by the operator's Approve at level approve
@@ -134,7 +135,7 @@ func TestClaimQueuedSendRefusesAMismatchedOrUnclosedWarrant(t *testing.T) {
t.Run("approved after the ask's expiry", func(t *testing.T) {
sends, router := newMemSends(), newSendRouter()
req := rawQueuedSend(t, sends)
w := warrantFor(req, req.Ask().Expires.Add(time.Hour))
w := warrantFor(req, expiresAt(req.Ask()).Add(time.Hour))
router.close(w)
emit := &memEmit{}
_, d, _, outcome, err := claimQueuedSend(sends, router.Record, emit.emit, req.ID, judgedAt.Add(3*time.Minute))
@@ -90,7 +90,7 @@ func Check(text string) (Refusal, bool) {
}
// CheckSecret says whether a text carries a secret's shape — the one class that leaves the mesh nowhere. It is
// what an ask's whole words (asks.Ask.Whole, novox/hq issue 383) are held to: on a kind that proves who answers,
// what an ask's whole words (oc.Ask.Whole, novox/hq issue 383) are held to: on a kind that proves who answers,
// a path or an address is what the operator approves and may be shown, a secret never. A path is read as one:
// a run with a slash in it is judged piece by piece between the slashes, so "/mnt/Photos_2024/Jochen" is a
// path and not a random string (the review of 2026-10-10); the named shapes — a key block, a signed token, a
+3 -3
View File
@@ -31,8 +31,8 @@ import (
"syscall"
"time"
oc "git.novox.be/novox/mesh-contracts/go/seats/operatorchannel"
stdio "git.novox.be/novox/mesh-sdk/go"
"git.novox.be/novox/mesh-sdk/go/asks"
)
// Module is this module's name.
@@ -252,8 +252,8 @@ func waitsTellTicker(b *Bundle) {
func hear(b *Bundle) {
<-stdio.Served() // subscribed as soon as the runtime serves, never after a guessed wait: an event sent meanwhile is heard
for wait := 2 * time.Second; ; wait = min(wait*2, time.Minute) {
err := stdio.Subscribe(asks.Seat+".decided."+Module, func(e stdio.Envelope) error {
var w asks.Warrant
err := stdio.Subscribe(oc.Seat+".decided."+Module, func(e stdio.Envelope) error {
var w oc.Warrant
if json.Unmarshal(e.Body, &w) == nil {
b.Heard(w)
}
+39 -31
View File
@@ -5,8 +5,8 @@ package main
// this raises an ask on the operator channel (novox/hq ADR 0259) showing the account, the recipients, the subject
// and the first lines of the body, and the tool returns at once with the ask's id. The mail reaches the account's
// outgoing server only on the operator's Approve, on a channel that proves who answered, and once: the id is kept
// with the decision it was given, so a warrant delivered twice is acted on once (asks.Warrant.For and
// asks.Option.Performs hold the decision to exactly the request that was asked). A Decline, or no answer before it
// with the decision it was given, so a warrant delivered twice is acted on once (oc.Warrant.For and
// oc.Option.Performs hold the decision to exactly the request that was asked). A Decline, or no answer before it
// expires, sends nothing, which mail_send_status says. The password is read only to sign in, and the body only to
// send; neither is ever logged.
//
@@ -27,8 +27,9 @@ import (
"strings"
"time"
occ "git.novox.be/novox/mesh-contracts/go/clients/operatorchannelclient"
oc "git.novox.be/novox/mesh-contracts/go/seats/operatorchannel"
stdio "git.novox.be/novox/mesh-sdk/go"
"git.novox.be/novox/mesh-sdk/go/asks"
)
// SendsState is the module's state the sends are kept in.
@@ -41,7 +42,7 @@ const (
)
// SendAskLasts is how long the operator has to answer: a day less a margin, under the router's bound for an approval.
const SendAskLasts = asks.ApproveLasts - 10*time.Minute
const SendAskLasts = occ.ApproveLasts - 10*time.Minute
// StaleSendAfter is how long a send may read "sending" before mail_send_status stops calling it "waiting": past
// this, the claim before the SMTP call is old enough that the send may or may not have gone out, and is never
@@ -210,8 +211,8 @@ func bodyDigest(body string) string {
}
// Act is what an answer binds: this request, whole, and the option chosen.
func (s SendRequest) Act(option string) asks.Act {
return asks.Act{"verb": Module + ".send", "option": option, "send": s.ID, "account": s.Account,
func (s SendRequest) Act(option string) occ.Act {
return occ.Act{"verb": Module + ".send", "option": option, "send": s.ID, "account": s.Account,
"to": strings.Join(s.To, ","), "subject": s.Subject, "body-digest": bodyDigest(s.Body),
"caller": s.Caller, "at": s.At.UTC().Format(time.RFC3339Nano)}
}
@@ -236,12 +237,12 @@ func firstChars(s string, n int) string {
// address, a path-shaped reference, a hex hash, … novox/hq issue 868, review round 1), the subject and first
// lines are left out of the Explanation and pointed at Details instead, so the ask always reaches the operator.
// The mail itself is sent unchanged: neither masking touches s.To, s.Subject or s.Body.
func (s SendRequest) Ask() asks.Ask {
func (s SendRequest) Ask() oc.Ask {
to := s.AskTo
subject := maskText(s.Subject)
body := maskText(firstChars(s.Body, ShownBodyChars))
headline := fmt.Sprintf("Send mail from %s?", s.Account)
if len([]rune(headline)) > asks.HeadlineLength {
if len([]rune(headline)) > occ.HeadlineLength {
headline = "Send mail?"
}
onExpiry := "nothing is sent"
@@ -258,22 +259,22 @@ func (s SendRequest) Ask() asks.Ask {
return strings.Join(words, "\n")
}
explanation := fmt.Sprintf("From %s to %s: %q.", s.Account, to, subject)
if len([]rune(explanation)) > asks.SummaryLength {
if len([]rune(explanation)) > occ.SummaryLength {
explanation = fmt.Sprintf("From %s: %q.", s.Account, subject)
}
if _, ok := Check(judged(explanation)); !ok {
explanation = fmt.Sprintf("From %s to %s. The subject and first lines are in Details.", s.Account, to)
if len([]rune(explanation)) > asks.SummaryLength {
if len([]rune(explanation)) > occ.SummaryLength {
explanation = "Send mail. The subject and first lines are in Details."
}
}
details := fmt.Sprintf("Asked by %s through the mesh-mail module, at %s UTC.\nAccount: %s\nTo: %s\nSubject: %s\n\n%s",
s.Caller, s.At.UTC().Format("15:04 on 2 Jan"), s.Account, to, subject, body)
a := asks.Ask{ID: s.ID, Headline: headline, Explanation: explanation, Details: details,
Who: asks.Operator, Expires: s.At.Add(SendAskLasts), OnExpiry: onExpiry}
a := oc.Ask{ID: s.ID, Headline: headline, Explanation: explanation, Details: details,
Who: occ.Operator, Expires: occ.FormatTime(s.At.Add(SendAskLasts)), OnExpiry: onExpiry}
for _, o := range options {
binds, _ := asks.ActDigest(s.Act(o.id))
a.Options = append(a.Options, asks.Option{ID: o.id, Label: o.label, Does: o.does, Level: asks.Approve, Binds: binds})
binds, _ := occ.ActDigest(s.Act(o.id))
a.Options = append(a.Options, oc.Option{ID: o.id, Label: o.label, Does: o.does, Level: occ.Approve, Binds: binds})
}
return a
}
@@ -339,8 +340,8 @@ func getSend(store SendStore, id string) (SendRequest, uint64, bool, error) {
// routerRecord is the router's record of one ask, as far as this module reads it.
type routerRecord struct {
State string `json:"state"`
Warrant *asks.Warrant `json:"warrant"`
State string `json:"state"`
Warrant *oc.Warrant `json:"warrant"`
}
// ---- asking ----------------------------------------------------------------------------------------------------
@@ -421,7 +422,7 @@ func AskMailSend(in SendInput, store SendStore, bus AskBus, now time.Time) (map[
req := SendRequest{ID: newSendID(), Account: in.Account.Name, To: to, Subject: subject, Body: in.Body,
Caller: in.Caller, At: now.UTC(), AskTo: composeAskTo(to, in.Accounts)}
ask := req.Ask()
if err := ask.Check(now); err != nil {
if err := occ.Check(ask, now); err != nil {
return refuse(err.Error())
}
if _, err := store.Create(req.ID, req); err != nil {
@@ -432,7 +433,7 @@ func AskMailSend(in SendInput, store SendStore, bus AskBus, now time.Time) (map[
// sent (novox/hq issue 811, review round 3).
return nil, fmt.Errorf("the send could not be made visible to the sweep, so the operator was not asked: %w. Nothing was sent", err)
}
if err := bus.Publish(asks.AskSubject(Module), ask, ask.ID); err != nil {
if err := bus.Publish(oc.AcceptSubjectAsk(Module), ask, ask.ID); err != nil {
return nil, fmt.Errorf("the operator could not be asked (%v). Nothing was sent; mail_send_status %s says so", err, req.ID)
}
return map[string]any{
@@ -440,7 +441,7 @@ func AskMailSend(in SendInput, store SendStore, bus AskBus, now time.Time) (map[
"status": "waiting",
"asked": "\"" + ask.Headline + "\", to " + to[0] + onwards(to),
"from": in.Account.Name,
"expires": ask.Expires.Format(time.RFC3339),
"expires": expiresAt(ask).Format(time.RFC3339),
"next": "mail_send_status with this ask id says where it stands; nothing is sent until the operator's Approve, " +
"and nothing on Decline or at expiry",
}, nil
@@ -622,12 +623,19 @@ func settleSend(store SendStore, req SendRequest, rev uint64, d SendDecision) (S
return final, newRev, nil
}
// Decide holds one closed ask's warrant to the request it answers (asks.Warrant.For, asks.Option.Performs), and —
// expiresAt is when an ask ends, read from the contract's RFC 3339 text; the ask was checked (occ.Check) before
// it was published, so the text is a time, and a text that is not reads as the zero time.
func expiresAt(a oc.Ask) time.Time {
t, _ := occ.Time(a.Expires)
return t
}
// Decide holds one closed ask's warrant to the request it answers (oc.Warrant.For, oc.Option.Performs), and —
// on Approve, exactly once — records the send approved and queues it for the sender (novox/hq ADR 0436 clause 8);
// it never sends. A request already decided is never decided again: decide answers its kept decision at once, and
// two warrants for the same request (a redelivery, or two readers racing) have only the first claim the request
// by a compare-and-set write; the second finds it already claimed or decided and queues nothing twice.
func Decide(store SendStore, publish QueuePublish, w asks.Warrant, now time.Time) (SendDecision, error) {
func Decide(store SendStore, publish QueuePublish, w oc.Warrant, now time.Time) (SendDecision, error) {
req, rev, ok, err := getSend(store, w.Ask)
if err != nil {
return SendDecision{}, err
@@ -645,7 +653,7 @@ func Decide(store SendStore, publish QueuePublish, w asks.Warrant, now time.Time
final, _, err := settleSend(store, req, rev, d)
return final, err
}
if w.Outcome != asks.OutcomeChosen {
if w.Outcome != occ.OutcomeChosen {
outcome := "ended: " + string(w.Outcome)
if w.Words != "" {
outcome += " (" + w.Words + ")"
@@ -653,16 +661,16 @@ func Decide(store SendStore, publish QueuePublish, w asks.Warrant, now time.Time
return settle(SendDecision{At: now, Outcome: outcome})
}
ask := req.Ask()
option, err := w.For(Module, ask)
option, err := occ.For(w, Module, ask)
if err == nil {
err = option.Performs(req.Act(option.ID))
err = occ.Performs(option, req.Act(option.ID))
}
if err != nil {
return settle(SendDecision{At: now, Outcome: "refused: " + err.Error()})
}
switch option.ID {
case OptionDecline:
return settle(SendDecision{At: now, Outcome: "declined", Warrant: w.Says()})
return settle(SendDecision{At: now, Outcome: "declined", Warrant: occ.Says(w)})
case OptionSend:
return decideSend(store, publish, req, rev, w, now)
default:
@@ -681,12 +689,12 @@ func Decide(store SendStore, publish QueuePublish, w asks.Warrant, now time.Time
// either way, and the sender's own claim (clause 9) keeps it one send. It never sends: the sender (clause 7)
// takes the queued message, verifies the warrant again itself (clause 9) and performs the one handover to
// the server (clause 10).
func decideSend(store SendStore, publish QueuePublish, req SendRequest, rev uint64, w asks.Warrant, now time.Time) (SendDecision, error) {
func decideSend(store SendStore, publish QueuePublish, req SendRequest, rev uint64, w oc.Warrant, now time.Time) (SendDecision, error) {
// Indexed before the record carries "queued" (novox/hq review round 1 finding 3): a crash between this
// and the write below must still leave it findable by mail_health's overdue check and the sweep's own
// stale-queued retry once "queued" does land.
indexErr := addSenderPending(store, req.ID)
queued := SendDecision{At: now, Outcome: "queued", Warrant: w.Says()}
queued := SendDecision{At: now, Outcome: "queued", Warrant: occ.Says(w)}
claimed, newRev, err := settleSend(store, req, rev, queued)
if err != nil {
return SendDecision{}, errOr(indexErr, err)
@@ -832,9 +840,9 @@ func claimQueuedSend(store SendStore, record RouterRecord, emit Emit, id string,
return refuse(fmt.Sprintf("the warrant is for %s's ask, not %s's", w.Asker, Module))
}
ask := req.Ask()
option, err := w.For(Module, ask)
option, err := occ.For(w, Module, ask)
if err == nil {
err = option.Performs(req.Act(option.ID))
err = occ.Performs(option, req.Act(option.ID))
}
if err != nil {
return refuse(err.Error())
@@ -842,7 +850,7 @@ func claimQueuedSend(store SendStore, record RouterRecord, emit Emit, id string,
if option.ID != OptionSend {
return refuse("the option " + option.ID + " does not authorise a send")
}
claim := SendDecision{At: now, Outcome: "sending", Warrant: w.Says(), Attempt: req.Decided.Attempt + 1}
claim := SendDecision{At: now, Outcome: "sending", Warrant: occ.Says(w), Attempt: req.Decided.Attempt + 1}
claimed, newRev, err := updateDecision(store, req, rev, claim)
if err != nil {
return SendRequest{}, SendDecision{}, 0, claimLostRace, err
@@ -1428,7 +1436,7 @@ func reportDecision(out map[string]any, d SendDecision, now time.Time) {
// Heard acts on one warrant the router's own subject delivered to this module directly (main.go's hear), through
// the same Decide that mail_send_status uses: an approved send goes out as soon as the operator answers, never
// only when something later happens to call mail_send_status (novox/hq ADR 0361 §2, review round 1 of issue 790).
func (b *Bundle) Heard(w asks.Warrant) {
func (b *Bundle) Heard(w oc.Warrant) {
if _, err := Decide(b.sends(), b.queuePublish(), w, b.now()); err != nil {
if b.Logf != nil {
b.Logf("[mesh-mail] the warrant for the send %s could not be acted on: %v", w.Ask, err)
+13 -12
View File
@@ -17,8 +17,9 @@ import (
"testing"
"time"
occ "git.novox.be/novox/mesh-contracts/go/clients/operatorchannelclient"
oc "git.novox.be/novox/mesh-contracts/go/seats/operatorchannel"
stdio "git.novox.be/novox/mesh-sdk/go"
"git.novox.be/novox/mesh-sdk/go/asks"
)
// ---- a fake `sends` state and a fake operator channel, as a test keeps them --------------------------------------
@@ -93,14 +94,14 @@ func (m *memSends) Keys() ([]string, error) {
// router's own record of each ask.
type sendRouter struct {
mu sync.Mutex
published map[string]asks.Ask
published map[string]oc.Ask
records map[string]json.RawMessage
publishErr error
recordCalls map[string]int
}
func newSendRouter() *sendRouter {
return &sendRouter{published: map[string]asks.Ask{}, records: map[string]json.RawMessage{}, recordCalls: map[string]int{}}
return &sendRouter{published: map[string]oc.Ask{}, records: map[string]json.RawMessage{}, recordCalls: map[string]int{}}
}
func (r *sendRouter) Publish(subject string, body any, id string) error {
@@ -110,7 +111,7 @@ func (r *sendRouter) Publish(subject string, body any, id string) error {
return r.publishErr
}
raw, _ := json.Marshal(body)
var a asks.Ask
var a oc.Ask
_ = json.Unmarshal(raw, &a)
r.published[a.ID] = a
r.records[a.ID], _ = json.Marshal(map[string]any{"state": "open"})
@@ -143,21 +144,21 @@ func (r *sendRouter) Record(id string) (*stdio.StateEntry, error) {
}
// close ends an ask with a warrant, as the router records it.
func (r *sendRouter) close(w asks.Warrant) {
func (r *sendRouter) close(w oc.Warrant) {
r.mu.Lock()
defer r.mu.Unlock()
r.records[w.Ask], _ = json.Marshal(map[string]any{"state": string(w.Outcome), "warrant": w})
}
// chose is the warrant for a person's choice of an option of the ask as the router took it.
func (r *sendRouter) chose(id, option string, at time.Time) asks.Warrant {
func (r *sendRouter) chose(id, option string, at time.Time) oc.Warrant {
r.mu.Lock()
a := r.published[id]
r.mu.Unlock()
o, _ := a.Option(option)
return asks.Warrant{Ask: id, Asker: Module, Outcome: asks.OutcomeChosen, Option: o.ID, Label: o.Label, Level: o.Level,
By: &asks.Person{Who: asks.Operator, Kind: "telegram", Identity: "42", Verified: "user id verified"},
Channel: "telegram", Proofs: []string{"P1"}, At: at, AskDigest: a.Digest()}
o, _ := occ.OptionOf(a, option)
return oc.Warrant{Ask: id, Asker: Module, Outcome: occ.OutcomeChosen, Option: o.ID, Label: o.Label, Level: o.Level,
By: &oc.Person{Who: occ.Operator, Kind: "telegram", Identity: "42", Verified: "user id verified"},
Channel: "telegram", Proofs: []string{"P1"}, At: occ.FormatTime(at), AskDigest: occ.Digest(a)}
}
func (r *sendRouter) published1() int {
@@ -397,7 +398,7 @@ func TestMailSendAsksWithTheAccountRecipientsSubjectAndFirstLines(t *testing.T)
t.Fatalf("published %d asks, not 1", n)
}
a := router.published[id]
if err := a.Check(judgedAt); err != nil {
if err := occ.Check(a, judgedAt); err != nil {
t.Errorf("the ask does not pass the asks contract: %v", err)
}
whole := a.Headline + "\n" + a.Explanation + "\n" + a.Details
@@ -410,7 +411,7 @@ func TestMailSendAsksWithTheAccountRecipientsSubjectAndFirstLines(t *testing.T)
t.Errorf("the ask carries a recipient's mail address, which the operator channel's content rule refuses:\n%s", whole)
}
for _, o := range a.Options {
if o.Level != asks.Approve || o.Binds == "" {
if o.Level != occ.Approve || o.Binds == "" {
t.Errorf("the option %s is %s binding %q: both answers are an approval bound to this send", o.ID, o.Level, o.Binds)
}
}
+2
View File
@@ -3,6 +3,8 @@ module mesh-mail
go 1.26.0
require (
git.novox.be/novox/mesh-contracts/go/clients/operatorchannelclient v0.0.0-00010101000000-000000000000
git.novox.be/novox/mesh-contracts/go/seats/operatorchannel v0.0.0-00010101000000-000000000000
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261102191058-f02f142af128
github.com/emersion/go-imap/v2 v2.0.0-beta.8
github.com/emersion/go-message v0.18.2
@@ -37,8 +37,9 @@ import (
"sync"
"time"
occ "git.novox.be/novox/mesh-contracts/go/clients/operatorchannelclient"
oc "git.novox.be/novox/mesh-contracts/go/seats/operatorchannel"
stdio "git.novox.be/novox/mesh-sdk/go"
"git.novox.be/novox/mesh-sdk/go/asks"
"git.novox.be/novox/mesh-sdk/go/catalogue"
"git.novox.be/novox/mesh-sdk/go/delivery"
"git.novox.be/novox/mesh-sdk/go/provisions"
@@ -999,30 +1000,30 @@ const (
)
// askLasts is how long a retiring ask stays open.
const askLasts = asks.ApproveLasts - 10*time.Minute
const askLasts = occ.ApproveLasts - 10*time.Minute
// retiringAsk is the ask of one retiring provision, composed from the key alone.
func retiringAsk(key string, p provisions.Provision, at time.Time, id string) asks.Ask {
func retiringAsk(key string, p provisions.Provision, at time.Time, id string) oc.Ask {
what := fmt.Sprintf("%s's access to %s on %s, for %s on %s", p.Consumer.Module, p.Provision, p.Provider.Node,
p.Consumer.Module, p.Consumer.Node)
a := asks.Ask{ID: id, Headline: "Remove " + p.Consumer.Module + "'s " + p.Provision + " access?",
a := oc.Ask{ID: id, Headline: "Remove " + p.Consumer.Module + "'s " + p.Provision + " access?",
Explanation: fmt.Sprintf("Nothing wanted or placed on %s needs %s any more. On Approve, %s removes %s: what it owns "+
"is handed to the resource's owner and its credential goes; the resource and its data stay.", p.Consumer.Node,
p.Provision, p.Provider.Module, what),
Who: asks.Operator, Expires: at.Add(askLasts), OnExpiry: "nothing is removed; it is asked again when it changes",
Who: occ.Operator, Expires: occ.FormatTime(at.Add(askLasts)), OnExpiry: "nothing is removed; it is asked again when it changes",
About: "provision." + key, Details: "The provision " + key + " of mesh-provisioner's state provisions."}
for _, o := range []struct{ id, label, does string }{
{OptionApprove, "Remove", "the provider removes the credential; the resource and its data stay"},
{OptionDecline, "Keep", "nothing is removed, and it is not asked again while nothing changes"},
} {
binds, _ := asks.ActDigest(retireAct(key, id, o.id))
a.Options = append(a.Options, asks.Option{ID: o.id, Label: o.label, Does: o.does, Level: asks.Approve, Binds: binds})
binds, _ := occ.ActDigest(retireAct(key, id, o.id))
a.Options = append(a.Options, oc.Option{ID: o.id, Label: o.label, Does: o.does, Level: occ.Approve, Binds: binds})
}
return a
}
func retireAct(key, id, option string) asks.Act {
return asks.Act{"verb": "mesh-provisioner.retire", "provision": key, "ask": id, "option": option}
func retireAct(key, id, option string) occ.Act {
return occ.Act{"verb": "mesh-provisioner.retire", "provision": key, "ask": id, "option": option}
}
// askOpenAtMost is how many of its asks mesh-provisioner keeps open at once: the router shows at most three per asker
@@ -1068,10 +1069,10 @@ func (h *Holder) ask(p *provisions.Provision, key string) error {
now := h.Now().UTC()
id := "retire-" + strings.ReplaceAll(key, ".", "-") + "-" + fmt.Sprint(now.Unix())
a := retiringAsk(key, *p, now, id)
if err := a.Check(now); err != nil {
if err := occ.Check(a, now); err != nil {
return err
}
if err := h.Asks.Publish(asks.AskSubject(Module), a, id); err != nil {
if err := h.Asks.Publish(oc.AcceptSubjectAsk(Module), a, id); err != nil {
return fmt.Errorf("the retiring ask of %s could not be published: %w", key, err)
}
p.Ask = id
@@ -1141,9 +1142,9 @@ func (h *Holder) Decide() []string {
continue
}
var rec struct {
State string `json:"state"`
Ask *asks.Ask `json:"ask"`
Warrant *asks.Warrant `json:"warrant"`
State string `json:"state"`
Ask *oc.Ask `json:"ask"`
Warrant *oc.Warrant `json:"warrant"`
}
if json.Unmarshal(raw, &rec) != nil || rec.State == "open" || rec.Warrant == nil || rec.Ask == nil {
continue
@@ -1153,10 +1154,10 @@ func (h *Holder) Decide() []string {
continue
}
option := ""
if w.Outcome == asks.OutcomeChosen {
o, err := w.For(Module, *rec.Ask)
if w.Outcome == occ.OutcomeChosen {
o, err := occ.For(w, Module, *rec.Ask)
if err == nil {
err = o.Performs(retireAct(k, p.Ask, o.ID))
err = occ.Performs(o, retireAct(k, p.Ask, o.ID))
}
if err != nil {
said = append(said, k+": the answer is refused: "+err.Error())
@@ -1174,7 +1175,12 @@ func (h *Holder) Decide() []string {
if w.By != nil {
by = fmt.Sprintf("the %s, as %s identity %s", w.By.Who, w.By.Kind, w.By.Identity)
}
p.Warrant = &provisions.Warrant{Ask: p.Ask, Option: option, By: by, At: w.At.UTC()}
answeredAt, err := occ.Time(w.At)
if err != nil {
said = append(said, k+": the answer is refused: its time is not RFC 3339 ("+err.Error()+")")
continue
}
p.Warrant = &provisions.Warrant{Ask: p.Ask, Option: option, By: by, At: answeredAt.UTC()}
h.put(k, p)
said = append(said, k+": "+option+" by "+by)
}
@@ -21,8 +21,8 @@ import (
"github.com/nats-io/nats.go"
"github.com/nats-io/nats.go/jetstream"
oc "git.novox.be/novox/mesh-contracts/go/seats/operatorchannel"
stdio "git.novox.be/novox/mesh-sdk/go"
"git.novox.be/novox/mesh-sdk/go/asks"
"git.novox.be/novox/mesh-sdk/go/delivery"
"git.novox.be/novox/mesh-sdk/go/provisions"
"git.novox.be/novox/mesh-sdk/go/testbus"
@@ -104,7 +104,7 @@ type asked struct{ ids []string }
func (a *asked) Publish(_ string, body any, id string) error {
a.ids = append(a.ids, id)
_ = body.(asks.Ask)
_ = body.(oc.Ask)
return nil
}
func (a *asked) Record(string) ([]byte, bool, error) { return nil, false, nil }
@@ -13,8 +13,9 @@ import (
"testing"
"time"
occ "git.novox.be/novox/mesh-contracts/go/clients/operatorchannelclient"
oc "git.novox.be/novox/mesh-contracts/go/seats/operatorchannel"
stdio "git.novox.be/novox/mesh-sdk/go"
"git.novox.be/novox/mesh-sdk/go/asks"
"git.novox.be/novox/mesh-sdk/go/catalogue"
"git.novox.be/novox/mesh-sdk/go/delivery"
"git.novox.be/novox/mesh-sdk/go/provisions"
@@ -23,13 +24,13 @@ import (
// router is the operator's channel as the holder asks on it: it holds an ask open, or, like the router past its open
// asks per asker, holds no record of it (drop).
type router struct {
asked map[string]asks.Ask
asked map[string]oc.Ask
records map[string][]byte
drop bool
}
func (r *router) Publish(_ string, body any, id string) error {
r.asked[id] = body.(asks.Ask)
r.asked[id] = body.(oc.Ask)
if !r.drop {
r.records[id], _ = json.Marshal(map[string]any{"state": "open", "ask": body})
}
@@ -45,13 +46,13 @@ func (r *router) Record(id string) ([]byte, bool, error) {
func (r *router) answer(t *testing.T, id, option string) {
t.Helper()
a := r.asked[id]
o, ok := a.Option(option)
o, ok := occ.OptionOf(a, option)
if !ok {
t.Fatalf("the ask %s offers no %s", id, option)
}
w := asks.Warrant{Ask: id, Asker: Module, About: a.About, Outcome: asks.OutcomeChosen, Option: o.ID, Label: o.Label,
Level: o.Level, By: &asks.Person{Who: asks.Operator, Kind: "telegram", Identity: "42"}, Channel: "telegram",
At: time.Date(2026, 10, 29, 23, 0, 0, 0, time.UTC), AskDigest: a.Digest()}
w := oc.Warrant{Ask: id, Asker: Module, About: a.About, Outcome: occ.OutcomeChosen, Option: o.ID, Label: o.Label,
Level: o.Level, By: &oc.Person{Who: occ.Operator, Kind: "telegram", Identity: "42"}, Channel: "telegram",
At: occ.FormatTime(time.Date(2026, 10, 29, 23, 0, 0, 0, time.UTC)), AskDigest: occ.Digest(a)}
r.records[id], _ = json.Marshal(map[string]any{"state": "closed", "ask": a, "warrant": w})
}
@@ -70,7 +71,7 @@ type mesh struct {
}
func newMesh() *mesh {
m := &mesh{s: newStore(), r: &router{asked: map[string]asks.Ask{}, records: map[string][]byte{}},
m := &mesh{s: newStore(), r: &router{asked: map[string]oc.Ask{}, records: map[string][]byte{}},
atWant: map[string]map[string]catalogue.Plan{}, atNewest: map[string]map[string]catalogue.Plan{},
heldAt: map[string]map[string]string{}, unpublished: map[string][]string{}}
now := time.Date(2026, 10, 29, 22, 0, 0, 0, time.UTC)
@@ -15,8 +15,8 @@ import (
"sync/atomic"
"time"
oc "git.novox.be/novox/mesh-contracts/go/seats/operatorchannel"
stdio "git.novox.be/novox/mesh-sdk/go"
"git.novox.be/novox/mesh-sdk/go/asks"
"git.novox.be/novox/mesh-sdk/go/delivery"
"git.novox.be/novox/mesh-sdk/go/handacts"
@@ -279,7 +279,7 @@ func main() {
go func() {
<-stdio.Served() // subscribed as soon as the runtime serves, never after a guessed wait: an event sent meanwhile is heard
for wait := 2 * time.Second; ; wait = min(wait*2, time.Minute) {
err := stdio.Subscribe(asks.Seat+".decided."+Module, func(stdio.Envelope) error {
err := stdio.Subscribe(oc.Seat+".decided."+Module, func(stdio.Envelope) error {
for _, said := range h.Decide() {
logf("[%s] %s", Module, said)
}
+2
View File
@@ -3,6 +3,8 @@ module mesh-provisioner
go 1.26.0
require (
git.novox.be/novox/mesh-contracts/go/clients/operatorchannelclient v0.0.0-00010101000000-000000000000
git.novox.be/novox/mesh-contracts/go/seats/operatorchannel v0.0.0-00010101000000-000000000000
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261103221822-85ba0a9a70d8
github.com/nats-io/nats.go v1.54.0
)
@@ -26,6 +26,7 @@ import (
"strings"
"time"
"git.novox.be/novox/mesh-contracts/go/seats/meshvault"
stdio "git.novox.be/novox/mesh-sdk/go"
"git.novox.be/novox/mesh-sdk/go/delivery"
"golang.org/x/crypto/bcrypt"
@@ -148,10 +149,10 @@ type EnrolmentTokenArgs struct {
Drop bool
}
// EnrolmentTokenAnswer is mesh-sdk's delivery.EnrolmentTokenAnswer, the wire shape mesh-enrolment decodes: at issue the
// EnrolmentTokenAnswer is mesh-sdk's meshvault.EnrolmentTokenAnswer, the wire shape mesh-enrolment decodes: at issue the
// bus's address, its certificate's fingerprint and the vault's public signing key; when sealed, the whole token sealed to
// the operator key; at drop, whether it was dropped. Never a token in the clear.
type EnrolmentTokenAnswer = delivery.EnrolmentTokenAnswer
type EnrolmentTokenAnswer = meshvault.EnrolmentTokenAnswer
// enrolTunnel is the vault's name of the first tunnel a node's token carries, kept beside the token.
func enrolTunnel(node string) string { return "enrol/" + node + "/tunnel" }
@@ -364,10 +365,10 @@ type NodeAccountArgs struct {
Retire bool
}
// NodeAccountAnswer is mesh-sdk's delivery.NodeAccountAnswer, the wire shape mesh-enrolment decodes and relays into the
// NodeAccountAnswer is mesh-sdk's meshvault.NodeAccountAnswer, the wire shape mesh-enrolment decodes and relays into the
// redeem answer: the login, and the account sealed to the presented key, as the node-engine opens it (the password,
// sealed anonymously to its raw X25519 key, in base64); or that it was retired.
type NodeAccountAnswer = delivery.NodeAccountAnswer
type NodeAccountAnswer = meshvault.NodeAccountAnswer
// NodeAccount answers caller's `node-account`: the joining node's account, minted once for a node and a set of keys and
// answered sealed to the key presented; or, with retire, taken away again at a revoke.
@@ -16,6 +16,7 @@ import (
"testing"
"time"
"git.novox.be/novox/mesh-contracts/go/seats/meshvault"
"git.novox.be/novox/mesh-sdk/go/delivery"
"golang.org/x/crypto/bcrypt"
"golang.org/x/crypto/nacl/box"
@@ -187,9 +188,9 @@ func TestTheVaultsEnrolmentAnswersAreMeshSDKsWireShapes(t *testing.T) {
}
reply := delivery.RedeemAnswer{Node: "new1", Login: "node.new1", Account: "sealed", Certificate: "pem", Signer: signerB64}
var issued, sealed, dropped delivery.EnrolmentTokenAnswer
var account, accountAgain, retired delivery.NodeAccountAnswer
var cert, replySig delivery.SignAnswer
var issued, sealed, dropped meshvault.EnrolmentTokenAnswer
var account, accountAgain, retired meshvault.NodeAccountAnswer
var cert, replySig meshvault.SignAnswer
var secret, accountHash string
for _, c := range []struct {
name, verb, caller string
@@ -282,7 +283,7 @@ func TestTheVaultsEnrolmentAnswersAreMeshSDKsWireShapes(t *testing.T) {
t.Errorf("the sealed token is %+v, %v", tok, err)
}
// The account opens with the sealing key presented, as the node-engine opens it, and is the same both times.
open := func(a delivery.NodeAccountAnswer) string {
open := func(a meshvault.NodeAccountAnswer) string {
blob, _ := base64.StdEncoding.DecodeString(a.Account)
var pub, priv [32]byte
copy(pub[:], sealingKey.PublicKey().Bytes())
+1 -1
View File
@@ -20,7 +20,7 @@ import (
stdio "git.novox.be/novox/mesh-sdk/go"
"git.novox.be/novox/mesh-delivery/sdk/views"
"git.novox.be/novox/mesh-sdk/go/views"
)
func say(format string, args ...any) {
@@ -17,7 +17,7 @@ import (
stdio "git.novox.be/novox/mesh-sdk/go"
"git.novox.be/novox/mesh-sdk/go/delivery"
"git.novox.be/novox/mesh-delivery/sdk/views"
"git.novox.be/novox/mesh-sdk/go/views"
)
// PlacementTool is the assigner's ordinary read tool of its placement; PlacementViewsEvent the event of its views family.
@@ -8,7 +8,7 @@ import (
"testing"
"time"
"git.novox.be/novox/mesh-delivery/sdk/views"
"git.novox.be/novox/mesh-sdk/go/views"
)
// ADR 0615 §6: the vault started while the assigner is down keeps asking, never reads its bucket, and past the bound
+37 -36
View File
@@ -19,6 +19,7 @@ import (
"strings"
"time"
"git.novox.be/novox/mesh-contracts/go/seats/meshvault"
stdio "git.novox.be/novox/mesh-sdk/go"
"git.novox.be/novox/mesh-sdk/go/delivery"
"git.novox.be/novox/mesh-sdk/go/overlay"
@@ -254,9 +255,9 @@ type PresentedKeys struct {
// Sign answers caller's `sign`. The caller is the one the runtime proved; who may ask which kind is the vault's rule,
// never an argument: a node certificate for a machine's node-engine alone, about its own machine; an enrolment for no
// caller until adoption's record names one (ADR 0561 §5); the authority for the vault itself, never asked.
func (v *Vault) Sign(caller string, a SignArgs) (delivery.SignAnswer, error) {
func (v *Vault) Sign(caller string, a SignArgs) (meshvault.SignAnswer, error) {
if caller == "" || caller == stdio.Unproven || !busUser.MatchString(caller) {
return delivery.SignAnswer{}, errors.New("the vault signs only for a caller the runtime proved")
return meshvault.SignAnswer{}, errors.New("the vault signs only for a caller the runtime proved")
}
switch a.Kind {
case delivery.KindNodeCertificate:
@@ -266,14 +267,14 @@ func (v *Vault) Sign(caller string, a SignArgs) (delivery.SignAnswer, error) {
return v.signNodeCertificate(caller, a.Node)
case delivery.KindEnrolment:
if err := v.fromEnrolment(caller, "sign an enrolment"); err != nil {
return delivery.SignAnswer{}, fmt.Errorf("%s may not ask for an enrolment's signature: only the seat %s, which "+
return meshvault.SignAnswer{}, fmt.Errorf("%s may not ask for an enrolment's signature: only the seat %s, which "+
"decides who joins, is named for it (novox/hq ADR 0561 §5, ADR 0587 §6)", caller, EnrolmentModule)
}
return v.signEnrolment(caller, a.Node, a.Reply)
case delivery.KindAuthority:
return delivery.SignAnswer{}, fmt.Errorf("the authority is the vault's own, signed for itself; %s may not ask it", caller)
return meshvault.SignAnswer{}, fmt.Errorf("the authority is the vault's own, signed for itself; %s may not ask it", caller)
}
return delivery.SignAnswer{}, fmt.Errorf("%q is not a kind the vault signs (%s)", a.Kind, strings.Join(delivery.SignKinds, ", "))
return meshvault.SignAnswer{}, fmt.Errorf("%q is not a kind the vault signs (%s)", a.Kind, strings.Join(delivery.SignKinds, ", "))
}
// redeeming says node has an enrolment token held: a machine joining, which the seat mesh-enrolment redeems.
@@ -293,107 +294,107 @@ func (v *Vault) redeeming(node string) error {
// (ADR 0587 §6). The machine has written no record yet, so the serving key is the one it presented at the redeem, which
// counts only when the four keys presented have the digest node-account recorded for its account; the name is its
// placement's, which mesh-enrolment writes before it asks.
func (v *Vault) signRedeemingCertificate(caller, node string, keys PresentedKeys) (delivery.SignAnswer, error) {
func (v *Vault) signRedeemingCertificate(caller, node string, keys PresentedKeys) (meshvault.SignAnswer, error) {
if err := v.redeeming(node); err != nil {
return delivery.SignAnswer{}, err
return meshvault.SignAnswer{}, err
}
if v.Accounts == nil {
return delivery.SignAnswer{}, errors.New("the vault has no state of bus accounts, so it cannot read the keys a joining node presented")
return meshvault.SignAnswer{}, errors.New("the vault has no state of bus accounts, so it cannot read the keys a joining node presented")
}
acct, err := v.Accounts.Get(NodeEnginePrefix + node)
if err != nil {
return delivery.SignAnswer{}, fmt.Errorf("%s %s could not be read: %w", BusAccountsState, NodeEnginePrefix+node, err)
return meshvault.SignAnswer{}, fmt.Errorf("%s %s could not be read: %w", BusAccountsState, NodeEnginePrefix+node, err)
}
if acct == nil || acct.Kept != KeptNode || acct.KeysDigest == "" {
return delivery.SignAnswer{}, fmt.Errorf("%s has no account made at its redeem (node-account), so no keys are recorded to certify", node)
return meshvault.SignAnswer{}, fmt.Errorf("%s has no account made at its redeem (node-account), so no keys are recorded to certify", node)
}
if keys.Serving == "" || keys.Public == "" || keys.Sealing == "" {
return delivery.SignAnswer{}, errors.New("a joining node's certificate is asked with the keys it presented at the redeem " +
return meshvault.SignAnswer{}, errors.New("a joining node's certificate is asked with the keys it presented at the redeem " +
"(`public_key`, `overlay_key`, `sealing_key`, `serving_key`), which are checked against node-account's digest")
}
if got := delivery.KeysDigest(keys.Public, keys.Overlay, keys.Sealing, keys.Serving); got != acct.KeysDigest {
return delivery.SignAnswer{}, fmt.Errorf("the keys presented for %s are not the ones its account was made for: nothing is signed", node)
return meshvault.SignAnswer{}, fmt.Errorf("the keys presented for %s are not the ones its account was made for: nothing is signed", node)
}
pub, err := base64.StdEncoding.DecodeString(keys.Serving)
if err != nil || len(pub) != ed25519.PublicKeySize {
return delivery.SignAnswer{}, fmt.Errorf("%s's serving key is not an ed25519 public key in base64", node)
return meshvault.SignAnswer{}, fmt.Errorf("%s's serving key is not an ed25519 public key in base64", node)
}
name, err := v.internalName(node)
if err != nil {
return delivery.SignAnswer{}, err
return meshvault.SignAnswer{}, err
}
return v.signCertificateWith(caller, node, name, ed25519.PublicKey(pub))
}
// signEnrolment signs the bytes of an enrolment reply under the enrolment context, for a node that is joining; the
// signature is the vault's key's, and the answer carries it with the signer's fingerprint.
func (v *Vault) signEnrolment(caller, node, reply string) (delivery.SignAnswer, error) {
func (v *Vault) signEnrolment(caller, node, reply string) (meshvault.SignAnswer, error) {
if err := v.redeeming(node); err != nil {
return delivery.SignAnswer{}, err
return meshvault.SignAnswer{}, err
}
msg, err := base64.StdEncoding.DecodeString(reply)
if err != nil || len(msg) == 0 {
return delivery.SignAnswer{}, errors.New("an enrolment's signature is asked for the reply's bytes in base64 (`reply`)")
return meshvault.SignAnswer{}, errors.New("an enrolment's signature is asked for the reply's bytes in base64 (`reply`)")
}
context, err := delivery.SignContext(delivery.KindEnrolment)
if err != nil {
return delivery.SignAnswer{}, err
return meshvault.SignAnswer{}, err
}
k, err := v.keys()
if err != nil {
return delivery.SignAnswer{}, err
return meshvault.SignAnswer{}, err
}
answer := delivery.SignAnswer{Kind: delivery.KindEnrolment, Node: node,
answer := meshvault.SignAnswer{Kind: delivery.KindEnrolment, Node: node,
Signature: base64.StdEncoding.EncodeToString(delivery.SignMessage(k.key, context, msg)),
Signer: delivery.SignerFingerprint(k.key.Public().(ed25519.PublicKey)), Digest: delivery.DigestOf(msg)}
if err := v.saySigned(caller, SigningKeySecret, answer); err != nil {
return delivery.SignAnswer{}, err
return meshvault.SignAnswer{}, err
}
return answer, nil
}
// signNodeCertificate signs the certificate of the calling machine's internal name for the serving key its record states.
func (v *Vault) signNodeCertificate(caller, asked string) (delivery.SignAnswer, error) {
func (v *Vault) signNodeCertificate(caller, asked string) (meshvault.SignAnswer, error) {
node, ok := nodeEngineOf(caller)
if !ok {
return delivery.SignAnswer{}, fmt.Errorf("a node certificate is signed for a machine's node-engine alone, about its own "+
return meshvault.SignAnswer{}, fmt.Errorf("a node certificate is signed for a machine's node-engine alone, about its own "+
"machine; %s is not a node-engine", caller)
}
if asked != "" && asked != node {
return delivery.SignAnswer{}, fmt.Errorf("%s asks for the certificate of %s, and is %s's node-engine: a machine asks for its own alone", caller, asked, node)
return meshvault.SignAnswer{}, fmt.Errorf("%s asks for the certificate of %s, and is %s's node-engine: a machine asks for its own alone", caller, asked, node)
}
return v.signCertificateFor(caller, node)
}
// signCertificateFor signs node's certificate from its placement's name and its record's serving key.
func (v *Vault) signCertificateFor(caller, node string) (delivery.SignAnswer, error) {
func (v *Vault) signCertificateFor(caller, node string) (meshvault.SignAnswer, error) {
name, err := v.internalName(node)
if err != nil {
return delivery.SignAnswer{}, err
return meshvault.SignAnswer{}, err
}
serving, err := v.servingKey(node)
if err != nil {
return delivery.SignAnswer{}, err
return meshvault.SignAnswer{}, err
}
return v.signCertificateWith(caller, node, name, serving)
}
// signCertificateWith signs the certificate of name for serving, for node.
func (v *Vault) signCertificateWith(caller, node, name string, serving ed25519.PublicKey) (delivery.SignAnswer, error) {
func (v *Vault) signCertificateWith(caller, node, name string, serving ed25519.PublicKey) (meshvault.SignAnswer, error) {
k, err := v.keys()
if err != nil {
return delivery.SignAnswer{}, err
return meshvault.SignAnswer{}, err
}
der, err := x509.CreateCertificate(rand.Reader, delivery.NodeCertificateTemplate(name, serving, k.authorityMade),
k.parsed, serving, k.authority)
if err != nil {
return delivery.SignAnswer{}, fmt.Errorf("the certificate of %s could not be signed: %w", name, err)
return meshvault.SignAnswer{}, fmt.Errorf("the certificate of %s could not be signed: %w", name, err)
}
fp, _ := delivery.AuthorityFingerprint(k.certificate)
answer := delivery.SignAnswer{Kind: delivery.KindNodeCertificate, Node: node, Authority: k.certificate,
answer := meshvault.SignAnswer{Kind: delivery.KindNodeCertificate, Node: node, Authority: k.certificate,
Certificate: string(pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: der})), Signer: fp, Digest: delivery.DigestOf(der)}
if err := v.saySigned(caller, AuthorityKeySecret, answer); err != nil {
return delivery.SignAnswer{}, err
return meshvault.SignAnswer{}, err
}
return answer, nil
}
@@ -449,7 +450,7 @@ func (v *Vault) servingKey(node string) (ed25519.PublicKey, error) {
// saySigned keeps the signature in the record of hand-outs and says `signed`: who, which kind, which node, the digest and
// the signer's fingerprint. Never a key. A signature whose record cannot be written is not answered.
func (v *Vault) saySigned(caller, with string, a delivery.SignAnswer) error {
func (v *Vault) saySigned(caller, with string, a meshvault.SignAnswer) error {
at := v.Now().UTC()
if err := v.recordHanding(Handing{To: caller, Name: with, Outcome: signedOutcome,
Why: a.Kind + " " + a.Node + " " + a.Digest}); err != nil {
@@ -458,11 +459,11 @@ func (v *Vault) saySigned(caller, with string, a delivery.SignAnswer) error {
if v.Emit == nil {
return nil
}
raw, _ := json.Marshal(delivery.Signed{Caller: caller, Kind: a.Kind, Node: a.Node, Digest: a.Digest, Signer: a.Signer, At: at})
raw, _ := json.Marshal(meshvault.SignedHolderEvent{Caller: caller, Kind: a.Kind, Node: a.Node, Digest: a.Digest, Signer: a.Signer, At: at.Format(time.RFC3339Nano)})
var body map[string]any
_ = json.Unmarshal(raw, &body)
if err := v.Emit(delivery.SignedEvent, body); err != nil {
v.log("signed %s for %s, but %s could not be said: %v", a.Kind, caller, delivery.SignedEvent, err)
if err := v.Emit(meshvault.HolderEventSigned, body); err != nil {
v.log("signed %s for %s, but %s could not be said: %v", a.Kind, caller, meshvault.HolderEventSigned, err)
}
return nil
}
+1 -2
View File
@@ -3,8 +3,7 @@ module mesh-vault
go 1.26.0
require (
git.novox.be/novox/mesh-delivery/sdk v0.0.0-00010101000000-000000000000
git.novox.be/novox/mesh-contracts/go v0.0.0-00010101000000-000000000000
git.novox.be/novox/mesh-contracts/go/seats/meshvault v0.0.0-00010101000000-000000000000
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261103174208-b7abe203a929
github.com/nats-io/nats-server/v2 v2.11.17
github.com/nats-io/nats.go v1.54.0
+3 -3
View File
@@ -26,7 +26,7 @@ import (
"strings"
"time"
"git.novox.be/novox/mesh-sdk/go/asks"
ch "git.novox.be/novox/mesh-sdk/go/channel"
)
// Action is one answer a condition names: a label, and the seat verb its producer calls when it is chosen.
@@ -132,7 +132,7 @@ func pruneGrants(grants map[string]Grant, kind string, now time.Time, keep int)
// Chosen takes an answer a channel of a kind said it was given: an ask's option is judged and, when it
// stands, closes the ask with a warrant for its asker (asks.go); Details are shown where they were asked.
// Anything else is refused, and said there. The same answer is acted on once.
func (h *Holder) Chosen(kind string, c asks.Choice) (string, error) {
func (h *Holder) Chosen(kind string, c ch.Choice) (string, error) {
// The root-free word is asked before the work is taken (the confirmation review of 2026-10-09):
// up to RootFreeWithin, which no ask, answer or link should wait behind. Asked fresh here, for this answer,
// and judged with in it.
@@ -255,7 +255,7 @@ func firstLineOf(s string) string {
// Failed takes a channel's word that a message did not arrive: the channel is said to be failing, and the
// record it carried is tried again — unless the failure is permanent, which is said and not repeated.
func (h *Holder) Failed(kind string, f asks.Failed) {
func (h *Holder) Failed(kind string, f ch.Failed) {
h.work.Lock()
defer h.work.Unlock()
h.noteChannel(kind, errorf("%s", f.Why))
+53 -51
View File
@@ -10,7 +10,9 @@ import (
"testing"
"time"
"git.novox.be/novox/mesh-sdk/go/asks"
occ "git.novox.be/novox/mesh-contracts/go/clients/operatorchannelclient"
oc "git.novox.be/novox/mesh-contracts/go/seats/operatorchannel"
ch "git.novox.be/novox/mesh-sdk/go/channel"
)
// The router of novox/hq ADR 0259: it holds every ask, judges each answer and issues a warrant; it
@@ -161,9 +163,9 @@ func (m *memAsks) PutLinksAsked(key string, times []time.Time) error {
var rootFreeOf = map[*Holder]map[string]string{}
// told is what the router published to askers, by subject.
type told struct{ by map[string][]asks.Warrant }
type told struct{ by map[string][]oc.Warrant }
func (w *told) of(asker string) []asks.Warrant { return w.by[asks.DecidedSubject(asker)] }
func (w *told) of(asker string) []oc.Warrant { return w.by[oc.SubjectDecided(asker)] }
// newRouter is a router with two kinds: telegram, which reaches the operator away and verifies its sender,
// and the desk, which does neither — as the controller would issue them from the claims.
@@ -171,7 +173,7 @@ func newRouter(t *testing.T) (*Holder, *fakeChannel, *fakeChannel, *clock, *memA
t.Helper()
h, tg, dt, c, _ := newHolder(t)
store := newMemAsks()
w := &told{by: map[string][]asks.Warrant{}}
w := &told{by: map[string][]oc.Warrant{}}
bench := NewBench(c.now)
bench.SetKinds([]KindInfo{
{Kind: "telegram", Module: "telegram", Node: "anchor", Capabilities: []string{"deliver", "reaches-away", "choice", "edit",
@@ -201,7 +203,7 @@ func newRouter(t *testing.T) (*Holder, *fakeChannel, *fakeChannel, *clock, *memA
}
h.Zone = func() *time.Location { return time.UTC }
h.Publish = func(subject string, body any, id string) error {
if wt, ok := body.(asks.Warrant); ok {
if wt, ok := body.(oc.Warrant); ok {
w.by[subject] = append(w.by[subject], wt)
}
return nil
@@ -219,25 +221,25 @@ func stand(h *Holder, c *clock) {
// bound is what an option of the held delivery's ask binds: the asker's act, digested.
func bound(verb string) string {
d, err := asks.ActDigest(asks.Act{"verb": "mesh-delivery." + verb, "id": "novox/hq@0555"})
d, err := occ.ActDigest(occ.Act{"verb": "mesh-delivery." + verb, "id": "novox/hq@0555"})
if err != nil {
panic(err)
}
return d
}
func heldAsk(c *clock, id string) asks.Ask {
return asks.Ask{ID: id, Headline: "Delivery of hq held for 36 hours",
func heldAsk(c *clock, id string) oc.Ask {
return oc.Ask{ID: id, Headline: "Delivery of hq held for 36 hours",
Explanation: "Needs you: release it, or stop it. A delivery of hq has been held for 36 hours.",
Who: asks.Operator, Expires: c.now().Add(2 * time.Hour), OnExpiry: "the delivery stays held",
Options: []asks.Option{
{ID: "release", Label: "Release", Does: "the delivery goes on", Level: asks.Approve, Binds: bound("release")},
{ID: "stop", Label: "Stop", Does: "the delivery ends", Level: asks.Approve, Binds: bound("stop")},
{ID: "silence", Label: "Silence for a week", Does: "nothing more is said of it for a week", Level: asks.Acknowledge},
Who: occ.Operator, Expires: occ.FormatTime(c.now().Add(2 * time.Hour)), OnExpiry: "the delivery stays held",
Options: []oc.Option{
{ID: "release", Label: "Release", Does: "the delivery goes on", Level: occ.Approve, Binds: bound("release")},
{ID: "stop", Label: "Stop", Does: "the delivery ends", Level: occ.Approve, Binds: bound("stop")},
{ID: "silence", Label: "Silence for a week", Does: "nothing more is said of it for a week", Level: occ.Acknowledge},
}}
}
func submit(t *testing.T, h *Holder, asker string, a asks.Ask) {
func submit(t *testing.T, h *Holder, asker string, a oc.Ask) {
t.Helper()
raw, _ := json.Marshal(a)
if err := h.TakeAsk("ask", asker, raw); err != nil {
@@ -250,9 +252,9 @@ func link(h *Holder, store *memAsks, c *clock, identity string, at time.Time) {
Linked: at, ApproveFrom: at.Add(LinkedApproveAfter)}
}
func choiceOf(ticket, identity string, at time.Time) asks.Choice {
return asks.Choice{ID: "c-" + ticket + "-" + identity, Ticket: ticket, At: at,
Sender: asks.Sender{Identity: identity, Display: "Jo", Authenticated: true}}
func choiceOf(ticket, identity string, at time.Time) ch.Choice {
return ch.Choice{ID: "c-" + ticket + "-" + identity, Ticket: ticket, At: at,
Sender: ch.Sender{Identity: identity, Display: "Jo", Authenticated: true}}
}
func ticketFor(m Message, label string) string {
@@ -273,7 +275,7 @@ func labelsOf(m Message) string {
}
// ackAsk is an ask of acknowledgements alone: silencing the held delivery.
func ackAsk(c *clock, id string) asks.Ask {
func ackAsk(c *clock, id string) oc.Ask {
a := heldAsk(c, id)
a.Options = a.Options[2:]
a.Explanation = "Needs you: silence it, or leave it. A delivery of hq has been held for 36 hours."
@@ -342,21 +344,21 @@ func TestAnApproveFromTheLinkedAccountIsAWarrantForTheAskerAlone(t *testing.T) {
t.Fatalf("%s %v", out, err)
}
got := w.of("mesh-delivery")
if len(got) != 1 || got[0].Outcome != asks.OutcomeChosen || got[0].Option != "release" || got[0].Channel != "telegram" ||
got[0].Says() != "the operator, via telegram (user id verified), chose Release" || strings.Join(got[0].Proofs, ",") != "P1" {
if len(got) != 1 || got[0].Outcome != occ.OutcomeChosen || got[0].Option != "release" || got[0].Channel != "telegram" ||
occ.Says(got[0]) != "the operator, via telegram (user id verified), chose Release" || strings.Join(got[0].Proofs, ",") != "P1" {
t.Fatalf("the warrant: %+v", got)
}
if _, err := got[0].For("mesh-delivery", asked); err != nil {
if _, err := occ.For(got[0], "mesh-delivery", asked); err != nil {
t.Errorf("the asker would not act on it: %v", err)
}
// It answers the ask as it was taken, and no other ask under that id (ADR 0259 §6).
if got[0].AskDigest != asked.Digest() {
t.Errorf("the warrant names the digest %s, not the ask's %s", got[0].AskDigest, asked.Digest())
if got[0].AskDigest != occ.Digest(asked) {
t.Errorf("the warrant names the digest %s, not the ask's %s", got[0].AskDigest, occ.Digest(asked))
}
if _, err := got[0].For("mesh-delivery", heldAsk(c, "d-1")); err == nil {
if _, err := occ.For(got[0], "mesh-delivery", heldAsk(c, "d-1")); err == nil {
t.Error("a warrant was taken for an ask under the same id with another expiry")
}
if rec := store.asks["mesh-delivery.d-1"]; rec.State != string(asks.OutcomeChosen) || !rec.Told || !rec.Tickets[release].Used {
if rec := store.asks["mesh-delivery.d-1"]; rec.State != string(occ.OutcomeChosen) || !rec.Told || !rec.Tickets[release].Used {
t.Errorf("the record: %+v", rec)
}
// Every copy says how it ended, its buttons gone.
@@ -490,7 +492,7 @@ func TestALinkApprovesAfterAnHourAndAcknowledgesAtOnce(t *testing.T) {
if out, err := h2.Chosen("telegram", choiceOf(silence, "42", c2.now())); err != nil || out != "chosen" {
t.Fatalf("an acknowledgement from a fresh link was refused: %s %v", out, err)
}
if got := w2.of("mesh-delivery"); len(got) != 1 || got[0].Level != asks.Acknowledge {
if got := w2.of("mesh-delivery"); len(got) != 1 || got[0].Level != occ.Acknowledge {
t.Errorf("%+v", got)
}
_ = w
@@ -573,7 +575,7 @@ func TestTheDeskAcknowledgesAndNeverApproves(t *testing.T) {
func TestAnAskIsRefusedInWordsToItsAsker(t *testing.T) {
h, _, _, c, store, w := newRouter(t)
bad := heldAsk(c, "d-1")
bad.Options[0].Level = asks.Destroy
bad.Options[0].Level = occ.Destroy
submit(t, h, "mesh-delivery", bad)
carrying := heldAsk(c, "d-2")
carrying.Explanation = "Needs you: see /etc/passwd on the machine."
@@ -586,7 +588,7 @@ func TestAnAskIsRefusedInWordsToItsAsker(t *testing.T) {
got := w.of("mesh-delivery")
var refused []string
for _, g := range got {
if g.Outcome == asks.OutcomeRefused {
if g.Outcome == occ.OutcomeRefused {
refused = append(refused, g.Ask+": "+g.Words)
}
}
@@ -629,7 +631,7 @@ func TestAnAskExpiresAndNeverDefaults(t *testing.T) {
stand(h, c)
h.Tick()
got := w.of("mesh-delivery")
if len(got) != 1 || got[0].Outcome != asks.OutcomeExpired || got[0].Option != "" || !strings.Contains(got[0].Words, "the delivery stays held") {
if len(got) != 1 || got[0].Outcome != occ.OutcomeExpired || got[0].Option != "" || !strings.Contains(got[0].Words, "the delivery stays held") {
t.Fatalf("%+v", got)
}
var edited Message
@@ -650,14 +652,14 @@ func TestANewerAskAboutTheSameThingReplacesTheOlderAndACancelEndsIt(t *testing.T
b := ackAsk(c, "d-2")
b.About = "delivery.hq"
submit(t, h, "mesh-delivery", b)
if store.asks["mesh-delivery.d-1"].State != string(asks.OutcomeReplaced) {
if store.asks["mesh-delivery.d-1"].State != string(occ.OutcomeReplaced) {
t.Errorf("the older ask was not replaced: %s", store.asks["mesh-delivery.d-1"].State)
}
if err := h.TakeAsk("cancel", "mesh-delivery", json.RawMessage(`{"id":"d-2"}`)); err != nil {
t.Fatal(err)
}
got := w.of("mesh-delivery")
if len(got) != 2 || got[0].Outcome != asks.OutcomeReplaced || got[1].Outcome != asks.OutcomeCancelled {
if len(got) != 2 || got[0].Outcome != occ.OutcomeReplaced || got[1].Outcome != occ.OutcomeCancelled {
t.Errorf("%+v", got)
}
// Another asker cannot cancel it: its cancel names its own ask, under its own name.
@@ -735,7 +737,7 @@ func TestAnAnswerReplayedLongAfterIsNotActedOn(t *testing.T) {
func TestLinkingShowsACodeOnTheDeskAndLinksOnlyWhoAsked(t *testing.T) {
h, tg, dt, c, store, _ := newRouter(t)
asked := asks.LinkAsked{ID: "l1", At: c.now(), Sender: asks.Sender{Identity: "42", Display: "Jo", Authenticated: true}}
asked := ch.LinkAsked{ID: "l1", At: c.now(), Sender: ch.Sender{Identity: "42", Display: "Jo", Authenticated: true}}
h.Link("telegram", asked)
if len(dt.sends) != 1 || !strings.HasPrefix(dt.sends[0].Title, "Link a Telegram account as you") {
t.Fatalf("no code on the desk: %+v", dt.sends)
@@ -748,13 +750,13 @@ func TestLinkingShowsACodeOnTheDeskAndLinksOnlyWhoAsked(t *testing.T) {
t.Errorf("the code is kept in clear: %+v", p)
}
// Another account sending the code is refused; a wrong code is refused; the right one links.
if a := h.Prove("telegram", asks.Code{Sender: asks.Sender{Identity: "666", Authenticated: true}, Code: code}); a.Accepted {
if a := h.Prove("telegram", ch.Code{Sender: ch.Sender{Identity: "666", Authenticated: true}, Code: code}); a.Accepted {
t.Error("another account linked with the code")
}
if a := h.Prove("telegram", asks.Code{Sender: asks.Sender{Identity: "42", Authenticated: true}, Code: "000000x"}); a.Accepted {
if a := h.Prove("telegram", ch.Code{Sender: ch.Sender{Identity: "42", Authenticated: true}, Code: "000000x"}); a.Accepted {
t.Error("a wrong code linked")
}
a := h.Prove("telegram", asks.Code{Sender: asks.Sender{Identity: "42", Authenticated: true}, Code: code, Purpose: "link"})
a := h.Prove("telegram", ch.Code{Sender: ch.Sender{Identity: "42", Authenticated: true}, Code: code, Purpose: "link"})
if !a.Accepted || !strings.Contains(a.Words, "approve from 13:00") {
t.Fatalf("the right code: %+v (code %q)", a, code)
}
@@ -769,7 +771,7 @@ func TestLinkingShowsACodeOnTheDeskAndLinksOnlyWhoAsked(t *testing.T) {
}
}
// A second account cannot link while one is.
h.Link("telegram", asks.LinkAsked{ID: "l2", At: c.now(), Sender: asks.Sender{Identity: "666", Authenticated: true}})
h.Link("telegram", ch.LinkAsked{ID: "l2", At: c.now(), Sender: ch.Sender{Identity: "666", Authenticated: true}})
if _, pending := store.links["telegram"]; pending {
t.Error("a second link was begun while one is linked")
}
@@ -777,21 +779,21 @@ func TestLinkingShowsACodeOnTheDeskAndLinksOnlyWhoAsked(t *testing.T) {
func TestALinkCodeExpiresAndFiveWrongTriesEndIt(t *testing.T) {
h, _, _, c, store, _ := newRouter(t)
jo := asks.Sender{Identity: "42", Authenticated: true}
h.Link("telegram", asks.LinkAsked{ID: "l1", At: c.now(), Sender: jo})
jo := ch.Sender{Identity: "42", Authenticated: true}
h.Link("telegram", ch.LinkAsked{ID: "l1", At: c.now(), Sender: jo})
for i := 0; i < LinkTries; i++ {
h.Prove("telegram", asks.Code{Sender: jo, Code: "nope"})
h.Prove("telegram", ch.Code{Sender: jo, Code: "nope"})
}
if _, pending := store.links["telegram"]; pending {
t.Error("five wrong codes did not end the link")
}
h.Link("telegram", asks.LinkAsked{ID: "l2", At: c.now(), Sender: jo})
h.Link("telegram", ch.LinkAsked{ID: "l2", At: c.now(), Sender: jo})
c.pass(LinkCodeFor)
if a := h.Prove("telegram", asks.Code{Sender: jo, Code: "123456"}); a.Accepted || !strings.Contains(a.Words, "expired") {
if a := h.Prove("telegram", ch.Code{Sender: jo, Code: "123456"}); a.Accepted || !strings.Contains(a.Words, "expired") {
t.Errorf("%+v", a)
}
// The desk cannot be linked: it proves nothing about who clicked.
h.Link("desktop", asks.LinkAsked{ID: "l3", At: c.now(), Sender: asks.Sender{Identity: "g14"}})
h.Link("desktop", ch.LinkAsked{ID: "l3", At: c.now(), Sender: ch.Sender{Identity: "g14"}})
if _, pending := store.links["desktop"]; pending {
t.Error("the desk began a link")
}
@@ -847,9 +849,9 @@ func TestARelinkWithinADayWaitsForTheReplacedAccount(t *testing.T) {
link(h, store, c, "42", c.now().Add(-2*time.Hour))
_, _ = h.Unlink("telegram")
_, _ = h.Chosen("telegram", choiceOf(ticketFor(tg.sends[len(tg.sends)-1], "Unlink"), "42", c.now()))
h.Link("telegram", asks.LinkAsked{ID: "l9", At: c.now(), Sender: asks.Sender{Identity: "666", Display: "Eve", Authenticated: true}})
h.Link("telegram", ch.LinkAsked{ID: "l9", At: c.now(), Sender: ch.Sender{Identity: "666", Display: "Eve", Authenticated: true}})
code := codeOf(t, dt.sends[len(dt.sends)-1])
a := h.Prove("telegram", asks.Code{Sender: asks.Sender{Identity: "666", Authenticated: true}, Code: code})
a := h.Prove("telegram", ch.Code{Sender: ch.Sender{Identity: "666", Authenticated: true}, Code: code})
if !a.Accepted || !strings.Contains(a.Words, "waits for that account's approval") || len(store.ids) != 0 {
t.Fatalf("linked at once within a day of an unlink: %+v %v", a, store.ids)
}
@@ -945,7 +947,7 @@ func TestNoApprovalWhileAnAgentCanBecomeRootThere(t *testing.T) {
if len(w.of("mesh-delivery")) != 0 {
t.Error("a warrant was issued")
}
if got := h.carriers(asks.Approve); len(got) != 0 {
if got := h.carriers(occ.Approve); len(got) != 0 {
t.Errorf("an approval is still offered on %v", got)
}
}
@@ -969,7 +971,7 @@ func TestAnAnswerNeedsAChannelThatRendersExactly(t *testing.T) {
h, _, _, c, _, _ := newRouter(t)
h.Bench.SetKinds([]KindInfo{{Kind: "desktop", Module: "desk-channel", Capabilities: []string{"deliver", "choice", "private"}}})
h.Bench.Stand("desktop", Standing{Ready: true, At: c.now()})
if got := h.carriers(asks.Acknowledge); len(got) != 0 {
if got := h.carriers(occ.Acknowledge); len(got) != 0 {
t.Errorf("a channel without exact-render carries %v", got)
}
}
@@ -977,12 +979,12 @@ func TestAnAnswerNeedsAChannelThatRendersExactly(t *testing.T) {
// At most three links an hour per kind, and the code is shown only where the words stay private.
func TestLinksAreBoundedAndTheCodeShownOnlyInPrivate(t *testing.T) {
h, tg, dt, c, store, _ := newRouter(t)
jo := asks.Sender{Identity: "42", Display: "Jo", Authenticated: true}
jo := ch.Sender{Identity: "42", Display: "Jo", Authenticated: true}
for i := 0; i < LinksAnHour; i++ {
h.Link("telegram", asks.LinkAsked{ID: fmt.Sprint("l", i), At: c.now(), Sender: jo})
h.Link("telegram", ch.LinkAsked{ID: fmt.Sprint("l", i), At: c.now(), Sender: jo})
}
shown := len(dt.sends)
h.Link("telegram", asks.LinkAsked{ID: "l9", At: c.now(), Sender: jo})
h.Link("telegram", ch.LinkAsked{ID: "l9", At: c.now(), Sender: jo})
if len(dt.sends) != shown || !strings.Contains(tg.sends[len(tg.sends)-1].Body, "Try again in an hour") {
t.Error("a fourth link this hour showed a code")
}
@@ -993,7 +995,7 @@ func TestLinksAreBoundedAndTheCodeShownOnlyInPrivate(t *testing.T) {
h2.Bench.SetKinds([]KindInfo{
{Kind: "telegram", Module: "telegram", Capabilities: []string{"choice", "verified-sender", "exact-render", "reaches-away"}},
{Kind: "desktop", Module: "desk-channel", Capabilities: []string{"choice", "exact-render"}}})
h2.Link("telegram", asks.LinkAsked{ID: "l1", At: c2.now(), Sender: jo})
h2.Link("telegram", ch.LinkAsked{ID: "l1", At: c2.now(), Sender: jo})
if _, pending := store2.links["telegram"]; pending || !strings.Contains(tg2.sends[len(tg2.sends)-1].Body, "No private place") {
t.Error("a code was shown where the words are not private")
}
@@ -1011,7 +1013,7 @@ func TestAWarningTheDeskCouldNotShowGoesAway(t *testing.T) {
if len(dt.sends) != 1 {
t.Fatalf("the desk was sent %d", len(dt.sends))
}
h.Failed("desktop", asks.Failed{Handle: "g14=1", Why: "no session answers", At: c.now()})
h.Failed("desktop", ch.Failed{Handle: "g14=1", Why: "no session answers", At: c.now()})
c.pass(BurstWindow)
h.Flush()
if len(tg.sends) == 0 {
+87 -78
View File
@@ -28,7 +28,9 @@ import (
"strings"
"time"
"git.novox.be/novox/mesh-sdk/go/asks"
occ "git.novox.be/novox/mesh-contracts/go/clients/operatorchannelclient"
oc "git.novox.be/novox/mesh-contracts/go/seats/operatorchannel"
ch "git.novox.be/novox/mesh-sdk/go/channel"
)
// ErrChanged is a compare-and-set that lost: another writer was first.
@@ -38,7 +40,7 @@ var ErrChanged = errors.New("written by another writer first")
// operator on 2026-10-08, until an authenticator-app code exists).
const LinkedApproveAfter = time.Hour
// StateOpen is an ask not yet ended; every other state is the asks.Outcome it ended with, but one.
// StateOpen is an ask not yet ended; every other state is the string it ended with, but one.
const StateOpen = "open"
// StateHeld is an ask that broke no rule and that no ready channel can carry now: kept as an open ask, up to its
@@ -56,12 +58,12 @@ type Ticket struct {
// it there under its own name.
type AskRecord struct {
Asker string `json:"asker"`
Ask asks.Ask `json:"ask"`
Ask oc.Ask `json:"ask"`
State string `json:"state"`
Opened time.Time `json:"opened"`
Closed time.Time `json:"closed,omitempty"`
Tickets map[string]Ticket `json:"tickets,omitempty"`
Warrant *asks.Warrant `json:"warrant,omitempty"`
Warrant *oc.Warrant `json:"warrant,omitempty"`
// Told: the warrant, or the end, was published to the asker.
Told bool `json:"told,omitempty"`
// HeldSince and HeldWhy say, while the ask is held, since when and why no channel can carry it: per kind, the
@@ -172,7 +174,7 @@ func (h *Holder) openAsk(r *Record) *AskRecord {
return &rec
}
// wholeOf is an ask's explanation with its values whole (asks.Ask.Whole), where an ask's own message on one
// wholeOf is an ask's explanation with its values whole (oc.Ask.Whole), where an ask's own message on one
// kind shows it in place of the explanation (novox/hq issue 383): on a kind that proves who answers by the
// controller's fresh word (Promises, as every other verified-sender judgement here), and that the router gave
// the ask's answers (a ticket there, so the kind was linked and root-free when the ask was taken). Empty where
@@ -187,7 +189,7 @@ func (h *Holder) wholeOf(r *Record, kind string) string {
return ""
}
// wholeDetailsOf is an ask's Details with their values whole (asks.Ask.WholeDetails), shown in the note in place of
// wholeDetailsOf is an ask's Details with their values whole (oc.Ask.WholeDetails), shown in the note in place of
// its Details where wholeOf would show its whole words: every value an approval sets, so the operator reads exactly
// what they approve (the review of mesh-controller #304). Empty elsewhere.
func (h *Holder) wholeDetailsOf(r *Record, kind string) string {
@@ -199,7 +201,7 @@ func (h *Holder) wholeDetailsOf(r *Record, kind string) string {
// provenAsk is the ask of an ask's own record when the kind proves who answers and was given the ask's answers;
// nil otherwise.
func (h *Holder) provenAsk(r *Record, kind string) *asks.Ask {
func (h *Holder) provenAsk(r *Record, kind string) *oc.Ask {
if r == nil || r.Origin != "ask" || r.Ask == "" || kind == "" || h.Bench == nil {
return nil
}
@@ -219,7 +221,7 @@ func (h *Holder) provenAsk(r *Record, kind string) *asks.Ask {
// carriers are the kinds that can carry an answer of a level now: ready, offering a choice, and for
// approve verifying their sender with an account of the operator's linked, where the controller says now
// that no agent can become root (rootFree, asked live).
func (h *Holder) carriers(level asks.Level) []string {
func (h *Holder) carriers(level string) []string {
if h.Bench == nil {
return nil
}
@@ -230,9 +232,9 @@ func (h *Holder) carriers(level asks.Level) []string {
continue
}
switch level {
case asks.Acknowledge:
case occ.Acknowledge:
out = append(out, k.Kind)
case asks.Approve:
case occ.Approve:
if h.Bench.Promises(k.Kind, "verified-sender") && h.linked(k.Kind) && h.rootFree(k, true) == "" {
out = append(out, k.Kind)
}
@@ -472,11 +474,18 @@ func (h *Holder) approveHolds(kind string, cached bool) string {
// offers an approval is answered only where it could be approved, whichever of its options is chosen, so an
// acknowledgement from where approval is not proven never ends a question that waits for one (the review of
// 2026-10-09).
func answerLevel(a asks.Ask) asks.Level {
if a.Highest().Rank() > asks.Acknowledge.Rank() {
return a.Highest()
// askExpires is when an ask ends: its expiry as the contract carries it (RFC 3339), or the zero time when it
// carries none or an unreadable one (an ask that passed occ.Check has a readable one).
func askExpires(a oc.Ask) time.Time {
t, _ := occ.Time(a.Expires)
return t
}
func answerLevel(a oc.Ask) string {
if occ.Rank(occ.Highest(a)) > occ.Rank(occ.Acknowledge) {
return occ.Highest(a)
}
return asks.Acknowledge
return occ.Acknowledge
}
// ConditionOwner is the module whose asks may be shown on a condition's message: the condition keeper, which raises
@@ -489,7 +498,7 @@ const ConditionOwner = ConditionSeat
func (h *Holder) TakeAsk(verb, asker string, body json.RawMessage) error {
if verb == ConditionVerb {
// Before the work lock: Condition takes it, and decides as for the controller's conditions.
if !asks.UsableID(strings.ReplaceAll(asker, "-", "_")) {
if !occ.UsableID(strings.ReplaceAll(asker, "-", "_")) {
h.Logf("[messenger] a condition from %q was taken and dropped: not an asker's name (a module's, or a node's engine's)", asker)
return nil
}
@@ -502,7 +511,7 @@ func (h *Holder) TakeAsk(verb, asker string, body json.RawMessage) error {
h.mu.Lock()
h.init()
h.mu.Unlock()
if !asks.UsableID(strings.ReplaceAll(asker, "-", "_")) {
if !occ.UsableID(strings.ReplaceAll(asker, "-", "_")) {
h.Logf("[messenger] an ask from %q was taken and dropped: not an asker's name (a module's, or a node's engine's)", asker)
return nil
}
@@ -510,15 +519,15 @@ func (h *Holder) TakeAsk(verb, asker string, body json.RawMessage) error {
var c struct {
ID string `json:"id"`
}
if json.Unmarshal(body, &c) != nil || !asks.UsableID(c.ID) {
if json.Unmarshal(body, &c) != nil || !occ.UsableID(c.ID) {
h.Logf("[messenger] a cancel from %s names no ask", asker)
return nil
}
return h.closeAsk(askKey(asker, c.ID), asks.OutcomeCancelled, "its asker took it back", nil, "", nil)
return h.closeAsk(askKey(asker, c.ID), occ.OutcomeCancelled, "its asker took it back", nil, "", nil)
}
var a asks.Ask
var a oc.Ask
if err := json.Unmarshal(body, &a); err != nil {
h.refuseAsk(asker, asks.Ask{ID: "unreadable"}, "it could not be read: "+err.Error())
h.refuseAsk(asker, oc.Ask{ID: "unreadable"}, "it could not be read: "+err.Error())
return nil
}
key := askKey(asker, a.ID)
@@ -526,7 +535,7 @@ func (h *Holder) TakeAsk(verb, asker string, body json.RawMessage) error {
return nil // a redelivery: already recorded
}
now := h.Now()
if err := a.Check(now); err != nil {
if err := occ.Check(a, now); err != nil {
h.refuseAsk(asker, a, err.Error())
return nil
}
@@ -539,8 +548,8 @@ func (h *Holder) TakeAsk(verb, asker string, body json.RawMessage) error {
return nil
}
// An ask that does not read at a glance is said, and shown all the same: the bound is its asker's to keep
// (asks.Glance, issue 639), and refusing it would leave the operator unasked.
if err := asks.Glance(a.Explanation); err != nil {
// (occ.Glance, issue 639), and refusing it would leave the operator unasked.
if err := occ.Glance(a.Explanation); err != nil {
h.Logf("[messenger] %s's ask %q is a wall of text on the phone: %v", asker, a.Headline, err)
}
// The whole words are shown only where the sender is proven (wholeOf), so a path or an address may stand
@@ -563,7 +572,7 @@ func (h *Holder) TakeAsk(verb, asker string, body json.RawMessage) error {
open++
}
h.mu.Unlock()
if open >= asks.MostOpen {
if open >= occ.MostOpen {
h.refuseAsk(asker, a, fmt.Sprintf("its asker already has %d open asks", open))
return nil
}
@@ -573,7 +582,7 @@ func (h *Holder) TakeAsk(verb, asker string, body json.RawMessage) error {
rec.State, rec.HeldSince, rec.HeldWhy = StateHeld, now, h.holdWhy(a)
}
if replaced != "" {
if err := h.closeAsk(replaced, asks.OutcomeReplaced, "a newer ask about the same thing replaced it", nil, "", nil); err != nil && !errors.Is(err, ErrChanged) {
if err := h.closeAsk(replaced, occ.OutcomeReplaced, "a newer ask about the same thing replaced it", nil, "", nil); err != nil && !errors.Is(err, ErrChanged) {
return err
}
}
@@ -607,7 +616,7 @@ func (h *Holder) TakeAsk(verb, asker string, body json.RawMessage) error {
// ticketsFor is every option's ticket on every kind that can carry the ask's highest level now: an ask with an
// approval is answered only where it could be approved (answerLevel). None when no ready channel can.
func (h *Holder) ticketsFor(a asks.Ask) map[string]Ticket {
func (h *Holder) ticketsFor(a oc.Ask) map[string]Ticket {
out := map[string]Ticket{}
for _, kind := range h.carriers(answerLevel(a)) {
for _, o := range a.Options {
@@ -619,7 +628,7 @@ func (h *Holder) ticketsFor(a asks.Ask) map[string]Ticket {
// holdWhy is why no kind can carry an ask now, per kind and in the words of the judgement that judges an answer
// (Bench.Ready, the capabilities carriers asks for, approveHolds): never only "no channel" (ADR 0397 §3, issue 926).
func (h *Holder) holdWhy(a asks.Ask) string {
func (h *Holder) holdWhy(a oc.Ask) string {
if h.Bench == nil || len(h.Bench.Kinds()) == 0 {
return "the mesh has not said which channels exist for answers"
}
@@ -630,7 +639,7 @@ func (h *Holder) holdWhy(a asks.Ask) string {
parts = append(parts, Display(k.Kind)+": "+h.Bench.Ready(k.Kind).Error())
case !h.Bench.Promises(k.Kind, "choice") || !h.Bench.Promises(k.Kind, "exact-render"):
parts = append(parts, Display(k.Kind)+" cannot offer a choice shown exactly as the router rendered it")
case answerLevel(a) == asks.Approve:
case answerLevel(a) == occ.Approve:
if why := h.approveHolds(k.Kind, true); why != "" {
if !strings.HasPrefix(why, Display(k.Kind)) {
why = Display(k.Kind) + ": " + why
@@ -646,7 +655,7 @@ func (h *Holder) holdWhy(a asks.Ask) string {
}
// recordFor is the message an ask is shown on: the condition it is about when that is open here, else its own.
func (h *Holder) recordFor(asker, key string, a asks.Ask) string {
func (h *Holder) recordFor(asker, key string, a oc.Ask) string {
h.mu.Lock()
defer h.mu.Unlock()
// Shown on a condition's message only when asked by the condition's owner: another asker naming a
@@ -720,7 +729,7 @@ func (h *Holder) releaseHeld() {
rec, rev = *got, r
}
}
if rec.State != StateHeld || !now.Before(rec.Ask.Expires) {
if rec.State != StateHeld || !now.Before(askExpires(rec.Ask)) {
continue // ended meanwhile; expiry is the sweep's
}
rec.Tickets = h.ticketsFor(rec.Ask)
@@ -759,7 +768,7 @@ func (h *Holder) offerUnsent() {
now := h.Now()
for _, key := range keys {
e := h.askOf(key)
if e == nil || e.rec.State != StateOpen || !now.Before(e.rec.Ask.Expires) {
if e == nil || e.rec.State != StateOpen || !now.Before(askExpires(e.rec.Ask)) {
continue
}
rec := e.rec
@@ -850,20 +859,20 @@ func (h *Holder) offerAsk(r *Record) {
}
// refuseAsk says to its asker that an ask was not taken, and why.
func (h *Holder) refuseAsk(asker string, a asks.Ask, why string) {
func (h *Holder) refuseAsk(asker string, a oc.Ask, why string) {
h.Logf("[messenger] refused an ask from %s (%s): %s", asker, a.ID, why)
h.tell(asks.Warrant{Ask: a.ID, Asker: asker, About: a.About, Outcome: asks.OutcomeRefused, Words: why, At: h.Now()})
h.tell(oc.Warrant{Ask: a.ID, Asker: asker, About: a.About, Outcome: occ.OutcomeRefused, Words: why, At: occ.FormatTime(h.Now())})
}
// tell publishes a warrant, or an ask's end, to its asker alone, deduplicated by the ask.
func (h *Holder) tell(w asks.Warrant) error {
func (h *Holder) tell(w oc.Warrant) error {
if h.Publish == nil {
return errors.New("this router cannot reach the bus")
}
// A new message id each time (the review of 2026-10-09): an id anyone could foresee lets a publisher on
// the same stream take it first, and the bus would drop the warrant as a duplicate. The asker acts on its
// own record of the ask once, however many times it hears a warrant.
err := h.Publish(asks.DecidedSubject(w.Asker), w, "warrant."+ticket())
err := h.Publish(oc.SubjectDecided(w.Asker), w, "warrant."+ticket())
if err != nil {
h.Logf("[messenger] could not tell %s how its ask %s ended (%v); told again in a minute", w.Asker, w.Ask, err)
}
@@ -874,7 +883,7 @@ func (h *Holder) tell(w asks.Warrant) error {
// loses: ErrChanged when another outcome was first. confirm, when given, is called once the end is recorded
// and the asker told or not (told), before any copy is edited: the answer's confirmation where it was given
// comes first, and never before the record stands (issue 372).
func (h *Holder) closeAsk(key string, outcome asks.Outcome, words string, w *asks.Warrant, used string, confirm func(told bool)) error {
func (h *Holder) closeAsk(key string, outcome string, words string, w *oc.Warrant, used string, confirm func(told bool)) error {
e := h.askOf(key)
if e == nil {
return nil
@@ -902,7 +911,7 @@ func (h *Holder) closeAsk(key string, outcome asks.Outcome, words string, w *ask
rec.Tickets[used] = t
}
if w == nil {
w = &asks.Warrant{Ask: rec.Ask.ID, Asker: rec.Asker, About: rec.Ask.About, Outcome: outcome, Words: words, At: now}
w = &oc.Warrant{Ask: rec.Ask.ID, Asker: rec.Asker, About: rec.Ask.About, Outcome: outcome, Words: words, At: occ.FormatTime(now)}
}
rec.Warrant = w
if h.Asks != nil {
@@ -971,14 +980,14 @@ func (h *Holder) showClosed(rec AskRecord) {
ownMessage := r.Origin == "ask"
// The first line is how it ended, which an ask's own message shows as its title (sayOn); the rest follows.
switch {
case rec.Warrant != nil && rec.Warrant.Outcome == asks.OutcomeChosen:
case rec.Warrant != nil && rec.Warrant.Outcome == occ.OutcomeChosen:
r.Answered = chosenLine(rec.Warrant, rec.Closed.In(h.zone())) + "\n" + told
case rec.State == string(asks.OutcomeExpired):
case rec.State == string(occ.OutcomeExpired):
r.Answered = "No longer answerable: nobody answered in time.\n" + strings.TrimSuffix(capital(rec.Ask.OnExpiry), ".") + "."
case rec.State == string(asks.OutcomeReplaced) && !ownMessage:
case rec.State == string(occ.OutcomeReplaced) && !ownMessage:
// On a condition's message, which the newer ask takes over and shows in place.
r.Answered = ""
case rec.State == string(asks.OutcomeReplaced):
case rec.State == string(occ.OutcomeReplaced):
// Its own message: the newer ask looks for its message under its own key and opens one of its own, so
// this one ends here, on every channel that showed it (issue 548).
r.Answered = "Replaced by a newer question.\n" + rec.Asker + " asked again about the same thing; that question is the one to answer."
@@ -1021,10 +1030,10 @@ func (h *Holder) endLeftovers() {
}
// chosenLine is how a chosen ask ended, in the operator's words: "You chose Approve at 13:30 on Telegram."
func chosenLine(w *asks.Warrant, at time.Time) string {
func chosenLine(w *oc.Warrant, at time.Time) string {
who, where := "Somebody", ""
if w.By != nil {
if w.By.Who == asks.Operator {
if w.By.Who == occ.Operator {
who = "You"
}
where = " on " + Display(w.By.Kind)
@@ -1046,7 +1055,7 @@ func (h *Holder) toldLine(rec AskRecord) string {
// confirmChoice says, where an answer was given and in reply to it, that it was recorded: on Telegram the
// tap's own acknowledgement (issue 372). Called only once the ask's end is recorded.
func (h *Holder) confirmChoice(kind string, c asks.Choice, label, asker string, told bool) {
func (h *Holder) confirmChoice(kind string, c ch.Choice, label, asker string, told bool) {
words := "You chose " + label + ": recorded"
switch {
case asker == SelfAsker:
@@ -1061,7 +1070,7 @@ func (h *Holder) confirmChoice(kind string, c asks.Choice, label, asker string,
}
// confirmAgain says, in reply to the same answer given again, that it was already recorded.
func (h *Holder) confirmAgain(kind string, c asks.Choice, label string, at time.Time) {
func (h *Holder) confirmAgain(kind string, c ch.Choice, label string, at time.Time) {
err := h.showOn(kind, Message{Title: "You already chose " + label + " at " + at.In(h.zone()).Format("15:04") + ": recorded.",
Quiet: true, Reply: c.ID})
h.record(Sent{At: h.Now(), Channel: kind, Key: "(answer)", What: "confirmed again " + label, Outcome: outcomeOf(err)})
@@ -1073,7 +1082,7 @@ func (h *Holder) sweepAsks(now time.Time) {
var expired, untold []string
for k, e := range h.asks {
switch {
case (e.rec.State == StateOpen || e.rec.State == StateHeld) && !now.Before(e.rec.Ask.Expires):
case (e.rec.State == StateOpen || e.rec.State == StateHeld) && !now.Before(askExpires(e.rec.Ask)):
expired = append(expired, k)
case e.rec.State != StateOpen && e.rec.State != StateHeld && !e.rec.Told && e.rec.Warrant != nil:
untold = append(untold, k)
@@ -1084,7 +1093,7 @@ func (h *Holder) sweepAsks(now time.Time) {
sort.Strings(expired)
for _, k := range expired {
e := h.askOf(k)
if err := h.closeAsk(k, asks.OutcomeExpired, "nobody answered in time: "+e.rec.Ask.OnExpiry, nil, "", nil); err != nil && !errors.Is(err, ErrChanged) {
if err := h.closeAsk(k, occ.OutcomeExpired, "nobody answered in time: "+e.rec.Ask.OnExpiry, nil, "", nil); err != nil && !errors.Is(err, ErrChanged) {
h.Logf("[messenger] could not end the expired ask %s: %v", k, err)
}
}
@@ -1099,12 +1108,12 @@ func (h *Holder) sweepAsks(now time.Time) {
// offers are an open ask's buttons on one kind: each option offered there, by its ticket.
func (h *Holder) offers(rec *AskRecord, kind string) []Choice {
var out []Choice
if answerLevel(rec.Ask) != asks.Acknowledge && rec.Asker != SelfAsker {
if answerLevel(rec.Ask) != occ.Acknowledge && rec.Asker != SelfAsker {
// Rendered only while approval holds there now, by the controller's recent word.
if h.approveHolds(kind, true) != "" {
return nil
}
} else if rec.Asker == SelfAsker && answerLevel(rec.Ask) != asks.Acknowledge {
} else if rec.Asker == SelfAsker && answerLevel(rec.Ask) != occ.Acknowledge {
if info, held := h.Bench.Kind(kind); !held || h.rootFree(info, true) != "" {
return nil
}
@@ -1165,7 +1174,7 @@ func joinOr(labels []string) string {
// judge is an answer to an ask's option, chosen on a kind: refused at the first failure, in words said on
// the kind; otherwise the ask closed and the warrant published (ADR 0259 §5).
func (h *Holder) judge(kind string, c asks.Choice, key string, root func(KindInfo) string) (string, error) {
func (h *Holder) judge(kind string, c ch.Choice, key string, root func(KindInfo) string) (string, error) {
e := h.askOf(key)
if e == nil {
return h.refuseChoice(kind, c, "it belongs to no ask this router holds")
@@ -1178,7 +1187,7 @@ func (h *Holder) judge(kind string, c asks.Choice, key string, root func(KindInf
}
now := h.Now()
t, issued := rec.Tickets[c.Ticket]
if w := rec.Warrant; issued && rec.State == string(asks.OutcomeChosen) && w != nil && w.Option == t.Option &&
if w := rec.Warrant; issued && rec.State == string(occ.OutcomeChosen) && w != nil && w.Option == t.Option &&
w.By != nil && w.By.Kind == kind && w.By.Identity == c.Sender.Identity && (c.Sender.Authenticated || !h.Bench.Promises(kind, "verified-sender")) {
// The same answer again from the same account — a second tap while the first was being confirmed, or one
// redelivered after a restart: it is what was recorded, so it is said so, not refused as if it failed
@@ -1195,10 +1204,10 @@ func (h *Holder) judge(kind string, c asks.Choice, key string, root func(KindInf
return h.refuseChoice(kind, c, "the question was already answered or has ended")
case t.Used:
return h.refuseChoice(kind, c, "it was already chosen once")
case !now.Before(rec.Ask.Expires):
return h.refuseChoice(kind, c, "the question expired at "+rec.Ask.Expires.In(h.zone()).Format("15:04"))
case !now.Before(askExpires(rec.Ask)):
return h.refuseChoice(kind, c, "the question expired at "+askExpires(rec.Ask).In(h.zone()).Format("15:04"))
}
o, offered := rec.Ask.Option(t.Option)
o, offered := occ.OptionOf(rec.Ask, t.Option)
if !offered {
return h.refuseChoice(kind, c, "it names an answer the question does not offer")
}
@@ -1206,7 +1215,7 @@ func (h *Holder) judge(kind string, c asks.Choice, key string, root func(KindInf
if !held || !h.Bench.Promises(kind, "choice") || !h.Bench.Promises(kind, "exact-render") {
return h.refuseChoice(kind, c, "this channel is not one the mesh holds for answers")
}
person := asks.Person{Who: asks.Operator, Kind: kind, Identity: c.Sender.Identity, Display: c.Sender.Display}
person := oc.Person{Who: occ.Operator, Kind: kind, Identity: c.Sender.Identity, Display: c.Sender.Display}
verifies := h.Bench.Promises(kind, "verified-sender")
var id *Identity
if verifies {
@@ -1236,13 +1245,13 @@ func (h *Holder) judge(kind string, c asks.Choice, key string, root func(KindInf
// ask that offers an approval needs what the approval needs, so it never ends that ask from where an
// approval is not proven.
level := o.Level
if answerLevel(rec.Ask).Rank() > level.Rank() {
if occ.Rank(answerLevel(rec.Ask)) > occ.Rank(level) {
level = answerLevel(rec.Ask)
}
proofs := []string{}
switch level {
case asks.Acknowledge:
case asks.Approve:
case occ.Acknowledge:
case occ.Approve:
if !verifies {
return h.refuseChoice(kind, c, Display(kind)+" cannot prove who answered, so it cannot answer a question "+
"that asks for an approval")
@@ -1254,18 +1263,18 @@ func (h *Holder) judge(kind string, c asks.Choice, key string, root func(KindInf
if why := root(info); why != "" {
return h.refuseChoice(kind, c, why)
}
if o.Level == asks.Approve {
if o.Level == occ.Approve {
proofs = append(proofs, "P1")
}
default:
return h.refuseChoice(kind, c, "it needs a code from the authenticator app, which the mesh does not take yet")
}
w := &asks.Warrant{Ask: rec.Ask.ID, Asker: rec.Asker, About: rec.Ask.About, Outcome: asks.OutcomeChosen,
Option: o.ID, Label: o.Label, Level: o.Level, By: &person, Channel: info.Module, Proofs: proofs, At: now,
w := &oc.Warrant{Ask: rec.Ask.ID, Asker: rec.Asker, About: rec.Ask.About, Outcome: occ.OutcomeChosen,
Option: o.ID, Label: o.Label, Level: o.Level, By: &person, Channel: info.Module, Proofs: proofs, At: occ.FormatTime(now),
// The warrant answers this ask as it was taken and shown, with what each option binds (ADR 0259 §6).
AskDigest: rec.Ask.Digest()}
AskDigest: occ.Digest(rec.Ask)}
confirm := func(told bool) { h.confirmChoice(kind, c, o.Label, rec.Asker, told) }
if err := h.closeAsk(key, asks.OutcomeChosen, "", w, c.Ticket, confirm); err != nil {
if err := h.closeAsk(key, occ.OutcomeChosen, "", w, c.Ticket, confirm); err != nil {
if errors.Is(err, ErrChanged) {
return h.refuseChoice(kind, c, "the question was already answered")
}
@@ -1274,14 +1283,14 @@ func (h *Holder) judge(kind string, c asks.Choice, key string, root func(KindInf
return h.refuseChoice(kind, c, "it could not be recorded")
}
h.record(Sent{At: now, Channel: kind, Key: rec.Record, What: "answer " + o.Label, Outcome: "warrant to " + rec.Asker})
h.Logf("[messenger] %s: a warrant for %s's ask %s", w.Says(), rec.Asker, rec.Ask.ID)
h.Logf("[messenger] %s: a warrant for %s's ask %s", occ.Says(*w), rec.Asker, rec.Ask.ID)
return "chosen", nil
}
// refuseStranger refuses an answer from an account that is not the operator's, and tells the operator, by name
// and number, on the account that is linked: a question's buttons reached somebody else (a forwarded message,
// or a phone in other hands). Nothing is said to the other account.
func (h *Holder) refuseStranger(kind string, c asks.Choice) (string, error) {
func (h *Holder) refuseStranger(kind string, c ch.Choice) (string, error) {
why := "this account is not linked as the operator"
h.Logf("[messenger] an answer on %s from %s was refused: %s", kind, c.Sender.Identity, why)
h.record(Sent{At: h.Now(), Channel: kind, Key: "(answer)", What: "answer", Outcome: "refused: " + why})
@@ -1297,7 +1306,7 @@ func (h *Holder) refuseStranger(kind string, c asks.Choice) (string, error) {
}
// refuseChoice says on the kind that an answer was not taken, and why.
func (h *Holder) refuseChoice(kind string, c asks.Choice, why string) (string, error) {
func (h *Holder) refuseChoice(kind string, c ch.Choice, why string) (string, error) {
h.Logf("[messenger] an answer on %s from %s was refused: %s", kind, c.Sender.Identity, why)
h.record(Sent{At: h.Now(), Channel: kind, Key: "(answer)", What: "answer", Outcome: "refused: " + why})
h.showOn(kind, Message{Title: "That answer was not taken", Body: capital(why) + ". Nothing was done.",
@@ -1317,15 +1326,15 @@ func (h *Holder) showOnHandle(kind string, m Message) (string, error) {
if h.Publish == nil {
return "", errors.New("this router cannot reach the bus")
}
out := asks.Message{Handle: newHandle(), Title: m.Title, Body: m.Body, Note: m.Note, Urgent: m.Urgent, Silent: m.Quiet, Reply: m.Reply, To: m.To,
out := ch.Message{Handle: newHandle(), Title: m.Title, Body: m.Body, Note: m.Note, Urgent: m.Urgent, Silent: m.Quiet, Reply: m.Reply, To: m.To,
Secret: m.Secret}
if out.To == "" && h.Bench != nil && h.Bench.To != nil {
out.To = h.Bench.To(kind)
}
for _, a := range m.Actions {
out.Buttons = append(out.Buttons, asks.Button{Label: a.Label, Ticket: a.Token})
out.Buttons = append(out.Buttons, ch.Button{Label: a.Label, Ticket: a.Token})
}
err := h.Publish(asks.ChannelSubject(asks.Show, kind), out, out.Handle)
err := h.Publish(ch.ChannelSubject(ch.Show, kind), out, out.Handle)
h.noteChannel(kind, err)
return out.Handle, err
}
@@ -1342,7 +1351,7 @@ func (h *Holder) showOn(kind string, m Message) error {
if h.Publish == nil {
return errors.New("this router cannot reach the bus")
}
out := asks.Message{Handle: newHandle(), Title: m.Title, Body: m.Body, Note: m.Note, Urgent: m.Urgent, Silent: m.Quiet, Reply: m.Reply}
out := ch.Message{Handle: newHandle(), Title: m.Title, Body: m.Body, Note: m.Note, Urgent: m.Urgent, Silent: m.Quiet, Reply: m.Reply}
if h.Bench != nil && h.Bench.To != nil {
out.To = h.Bench.To(kind)
}
@@ -1351,9 +1360,9 @@ func (h *Holder) showOn(kind string, m Message) error {
}
out.Secret = m.Secret
for _, a := range m.Actions {
out.Buttons = append(out.Buttons, asks.Button{Label: a.Label, Ticket: a.Token})
out.Buttons = append(out.Buttons, ch.Button{Label: a.Label, Ticket: a.Token})
}
err := h.Publish(asks.ChannelSubject(asks.Send, kind), out, out.Handle)
err := h.Publish(ch.ChannelSubject(ch.Send, kind), out, out.Handle)
h.noteChannel(kind, err)
return err
}
@@ -1387,7 +1396,7 @@ func (h *Holder) AsksNow() []AskView {
var out []AskView
for k, e := range h.asks {
v := AskView{Key: k, Asker: e.rec.Asker, Headline: e.rec.Ask.Headline, State: e.rec.State,
Opened: e.rec.Opened.UTC().Format(time.RFC3339), Expires: e.rec.Ask.Expires.UTC().Format(time.RFC3339),
Opened: e.rec.Opened.UTC().Format(time.RFC3339), Expires: askExpires(e.rec.Ask).UTC().Format(time.RFC3339),
Told: e.rec.Told}
if e.rec.State == StateHeld {
v.HeldSince, v.HeldWhy = e.rec.HeldSince.UTC().Format(time.RFC3339), e.rec.HeldWhy
@@ -1401,8 +1410,8 @@ func (h *Holder) AsksNow() []AskView {
}
sort.Strings(v.Offered)
if w := e.rec.Warrant; w != nil {
if w.Outcome == asks.OutcomeChosen {
v.Answer = w.Says()
if w.Outcome == occ.OutcomeChosen {
v.Answer = occ.Says(*w)
} else {
v.Answer = string(w.Outcome) + ": " + w.Words
}
@@ -1452,15 +1461,15 @@ func (h *Holder) sayOffered(key string, rec AskRecord) {
// sayClosed says how an ask ended, once per ask (its end is recorded by compare-and-set, so a second close never gets here): answered,
// expired, cancelled by its asker, replaced by a newer ask, or ended by the router itself on an unlink or allow-link.
func (h *Holder) sayClosed(key string, rec AskRecord, outcome asks.Outcome, words string) {
func (h *Holder) sayClosed(key string, rec AskRecord, outcome string, words string) {
if h.Emit == nil {
return
}
how := string(outcome)
switch {
case outcome == asks.OutcomeChosen:
case outcome == occ.OutcomeChosen:
how = "answered"
case outcome == asks.OutcomeCancelled && words != "its asker took it back":
case outcome == occ.OutcomeCancelled && words != "its asker took it back":
how = "ended by the router"
}
h.mu.Lock()
@@ -6,7 +6,7 @@ import (
"testing"
"time"
"git.novox.be/novox/mesh-sdk/go/asks"
occ "git.novox.be/novox/mesh-contracts/go/clients/operatorchannelclient"
)
// The router says when an ask starts and ends (ADR 0616 §7): `offered` once when an ask is offered, `closed` once for each of the five ends
@@ -67,7 +67,7 @@ func TestTheRouterSaysWhenAnAskIsOfferedAndWhenItEnds(t *testing.T) {
f := ackAsk(c, "d-5")
f.About = "third"
submit(t, h, "mesh-delivery", f)
if err := h.closeAsk("mesh-delivery.d-5", asks.OutcomeCancelled, "root decided to allow a link", nil, "", nil); err != nil {
if err := h.closeAsk("mesh-delivery.d-5", occ.OutcomeCancelled, "root decided to allow a link", nil, "", nil); err != nil {
t.Fatal(err)
}
want := "mesh-delivery.d-1=answered mesh-delivery.d-2=replaced mesh-delivery.d-3=cancelled mesh-delivery.d-4=expired mesh-delivery.d-5=ended by the router"
@@ -75,7 +75,7 @@ func TestTheRouterSaysWhenAnAskIsOfferedAndWhenItEnds(t *testing.T) {
t.Fatalf("the ends %q, want %q", gotEnds, want)
}
// Once each: a second close loses and says nothing; five asks were offered, once each; no option, ticket or proof is in a body.
_ = h.closeAsk("mesh-delivery.d-1", asks.OutcomeCancelled, "again", nil, "", nil)
_ = h.closeAsk("mesh-delivery.d-1", occ.OutcomeCancelled, "again", nil, "", nil)
offered := 0
for _, s := range got {
if s.event == "offered" {
+10 -9
View File
@@ -16,7 +16,8 @@ import (
"testing"
"time"
"git.novox.be/novox/mesh-sdk/go/asks"
oc "git.novox.be/novox/mesh-contracts/go/seats/operatorchannel"
ch "git.novox.be/novox/mesh-sdk/go/channel"
"github.com/nats-io/nats-server/v2/server"
"github.com/nats-io/nats.go"
"github.com/nats-io/nats.go/jetstream"
@@ -97,7 +98,7 @@ func (r *busRuntime) bucket(name string) jetstream.KeyValue {
func (r *busRuntime) setKinds(caps map[string][]string) {
var out []map[string]any
for kind, c := range caps {
out = append(out, map[string]any{"seat": asks.ChannelSeat, "kind": kind, "module": kind, "node": "test", "capabilities": c})
out = append(out, map[string]any{"seat": ch.ChannelSeat, "kind": kind, "module": kind, "node": "test", "capabilities": c})
}
r.mu.Lock()
r.kinds = out
@@ -235,7 +236,7 @@ func (r *busRuntime) runtime(id json.RawMessage, method string, params json.RawM
}
return keys, err
case "mesh/subscribe":
if strings.HasPrefix(p.Pattern, asks.IntakeSeat) {
if strings.HasPrefix(p.Pattern, ch.IntakeSeat) {
r.mu.Lock()
r.subscribed = true
r.mu.Unlock()
@@ -273,12 +274,12 @@ func (r *busRuntime) runtime(id json.RawMessage, method string, params json.RawM
// forward hands the bundle what is published on the bus that it takes: an ask submitted to the seat as work,
// a channel's word as an event.
func (r *busRuntime) forward() {
if _, err := r.nc.Subscribe("mesh.seat."+asks.Seat+".accept.ask.*", func(m *nats.Msg) {
if _, err := r.nc.Subscribe("mesh.seat."+oc.Seat+".accept.ask.*", func(m *nats.Msg) {
_, _ = r.request("mesh/work", map[string]any{"work": map[string]any{"worker": AsksWorker, "subject": m.Subject, "body": json.RawMessage(m.Data)}})
}); err != nil {
r.t.Fatal(err)
}
if _, err := r.nc.Subscribe("mesh.seat."+asks.IntakeSeat+".event.>", func(m *nats.Msg) {
if _, err := r.nc.Subscribe("mesh.seat."+ch.IntakeSeat+".event.>", func(m *nats.Msg) {
key := strings.Replace(strings.TrimPrefix(m.Subject, "mesh.seat."), ".event", "", 1)
_, _ = r.request("mesh/event", map[string]any{"envelope": map[string]any{"key": key, "node": "test", "body": json.RawMessage(m.Data)}})
}); err != nil {
@@ -325,10 +326,10 @@ func TestAnApprovalAskIsHeldThenOfferedWhenAChannelCanCarryItOverTheBus(t *testi
collect := func(into *[]json.RawMessage) nats.MsgHandler {
return func(m *nats.Msg) { seen.Lock(); *into = append(*into, append([]byte(nil), m.Data...)); seen.Unlock() }
}
if _, err := r.nc.Subscribe(asks.DecidedSubject("mesh-delivery"), collect(&told)); err != nil {
if _, err := r.nc.Subscribe(oc.SubjectDecided("mesh-delivery"), collect(&told)); err != nil {
t.Fatal(err)
}
if _, err := r.nc.Subscribe(asks.ChannelSubject(asks.Show, "telegram"), collect(&shown)); err != nil {
if _, err := r.nc.Subscribe(ch.ChannelSubject(ch.Show, "telegram"), collect(&shown)); err != nil {
t.Fatal(err)
}
r.forward()
@@ -339,13 +340,13 @@ func TestAnApprovalAskIsHeldThenOfferedWhenAChannelCanCarryItOverTheBus(t *testi
return r.subscribed && r.taking && r.kindsRead > 0
})
standing, _ := json.Marshal(Standing{Ready: true, At: time.Now().UTC()})
if err := r.nc.Publish(asks.IntakeSubject(asks.StandingWhat, "telegram"), standing); err != nil {
if err := r.nc.Publish(ch.IntakeSubject(ch.StandingWhat, "telegram"), standing); err != nil {
t.Fatal(err)
}
time.Sleep(time.Second)
c := &clock{t: time.Now().UTC()}
submitted, _ := json.Marshal(heldAsk(c, "d-1"))
if err := r.nc.Publish(asks.AskSubject("mesh-delivery"), submitted); err != nil {
if err := r.nc.Publish(oc.AcceptSubjectAsk("mesh-delivery"), submitted); err != nil {
t.Fatal(err)
}
r.until("the ask is recorded held with the reason", 20*time.Second, func() bool {
+3 -3
View File
@@ -39,7 +39,7 @@ import (
"strings"
"time"
"git.novox.be/novox/mesh-sdk/go/asks"
occ "git.novox.be/novox/mesh-contracts/go/clients/operatorchannelclient"
"git.novox.be/novox/mesh-sdk/go/health"
)
@@ -357,7 +357,7 @@ const ConditionVerb = "condition"
// token, which the bus grants that module alone, so it is not compared with the condition's key. A body that is
// not a condition in the SDK's layout is logged by publisher, taken and dropped.
func (h *Holder) takeCondition(publisher string, body []byte) {
sc, err := asks.DecodeCondition(body)
sc, err := occ.DecodeCondition(body)
if err != nil {
h.Logf("[messenger] a condition from %s was taken and dropped: %v", publisher, err)
return
@@ -371,7 +371,7 @@ func (h *Holder) takeCondition(publisher string, body []byte) {
for _, a := range sc.Actions {
c.Actions = append(c.Actions, Action{Label: a.Label, Verb: a.Verb, Machine: a.Machine, Arguments: a.Arguments})
}
if sc.State == asks.StateCleared {
if sc.State == occ.StateCleared {
h.Condition(EventCleared, c)
return
}
@@ -10,7 +10,9 @@ import (
"testing"
"time"
"git.novox.be/novox/mesh-sdk/go/asks"
occ "git.novox.be/novox/mesh-contracts/go/clients/operatorchannelclient"
oc "git.novox.be/novox/mesh-contracts/go/seats/operatorchannel"
ch "git.novox.be/novox/mesh-sdk/go/channel"
"github.com/nats-io/nats.go"
)
@@ -23,7 +25,7 @@ import (
type conditionBed struct {
r *busRuntime
mu sync.Mutex
desk []asks.Message
desk []ch.Message
acks []string // the answer of the router to each work item: "" when acknowledged
}
@@ -38,8 +40,8 @@ func newConditionBedWith(t *testing.T, settings string) *conditionBed {
b := &conditionBed{r: newBusRuntime(t)}
b.r.settings = settings
b.r.setKinds(map[string][]string{"desktop": {"choice", "edit"}})
if _, err := b.r.nc.Subscribe("mesh.seat."+asks.ChannelSeat+".accept.*.desktop", func(m *nats.Msg) {
var msg asks.Message
if _, err := b.r.nc.Subscribe("mesh.seat."+ch.ChannelSeat+".accept.*.desktop", func(m *nats.Msg) {
var msg ch.Message
if json.Unmarshal(m.Data, &msg) == nil {
b.mu.Lock()
b.desk = append(b.desk, msg)
@@ -48,7 +50,7 @@ func newConditionBedWith(t *testing.T, settings string) *conditionBed {
}); err != nil {
t.Fatal(err)
}
if _, err := b.r.nc.Subscribe("mesh.seat."+asks.Seat+".accept.condition.*", func(m *nats.Msg) {
if _, err := b.r.nc.Subscribe("mesh.seat."+oc.Seat+".accept.condition.*", func(m *nats.Msg) {
answer, err := b.r.request("mesh/work", map[string]any{"work": map[string]any{"worker": AsksWorker, "subject": m.Subject, "body": json.RawMessage(m.Data)}})
var a struct {
Error *struct {
@@ -81,7 +83,7 @@ func newConditionBedWith(t *testing.T, settings string) *conditionBed {
return b.r.subscribed
})
standing, _ := json.Marshal(Standing{Ready: true, At: time.Now().UTC()})
if err := b.r.nc.Publish(asks.IntakeSubject(asks.StandingWhat, "desktop"), standing); err != nil {
if err := b.r.nc.Publish(ch.IntakeSubject(ch.StandingWhat, "desktop"), standing); err != nil {
t.Fatal(err)
}
time.Sleep(time.Second)
@@ -91,7 +93,7 @@ func newConditionBedWith(t *testing.T, settings string) *conditionBed {
// forwardEvents hands the bundle the channels' word, as bus_test.go's forward does for it.
func (b *conditionBed) forwardEvents(t *testing.T) {
t.Helper()
if _, err := b.r.nc.Subscribe("mesh.seat."+asks.IntakeSeat+".event.>", func(m *nats.Msg) {
if _, err := b.r.nc.Subscribe("mesh.seat."+ch.IntakeSeat+".event.>", func(m *nats.Msg) {
key := strings.Replace(strings.TrimPrefix(m.Subject, "mesh.seat."), ".event", "", 1)
_, _ = b.r.request("mesh/event", map[string]any{"envelope": map[string]any{"key": key, "node": "test", "body": json.RawMessage(m.Data)}})
}); err != nil {
@@ -99,13 +101,13 @@ func (b *conditionBed) forwardEvents(t *testing.T) {
}
}
func (b *conditionBed) say(t *testing.T, publisher string, c asks.Condition) {
func (b *conditionBed) say(t *testing.T, publisher string, c oc.Condition) {
t.Helper()
body, err := asks.EncodeCondition(c)
body, err := occ.EncodeCondition(c)
if err != nil {
t.Fatal(err)
}
subject, err := asks.ConditionSubject(publisher)
subject, err := occ.ConditionSubject(publisher)
if err != nil {
t.Fatal(err)
}
@@ -126,10 +128,10 @@ func (b *conditionBed) record(key string) *Record {
return &rec
}
func (b *conditionBed) told() []asks.Message {
func (b *conditionBed) told() []ch.Message {
b.mu.Lock()
defer b.mu.Unlock()
return append([]asks.Message(nil), b.desk...)
return append([]ch.Message(nil), b.desk...)
}
func (b *conditionBed) acknowledged(n int) bool {
@@ -146,13 +148,13 @@ func (b *conditionBed) acknowledged(n int) bool {
return true
}
func busCondition(key, id, headline, needs string) asks.Condition {
verdict := asks.NothingToDo
func busCondition(key, id, headline, needs string) oc.Condition {
verdict := occ.NothingToDo
if needs != "" {
verdict = asks.NeedsYou + needs
verdict = occ.NeedsYou + needs
}
return asks.Condition{Key: key, Severity: asks.SeverityWarning, Subject: asks.Scope{Scope: "merge", ID: id},
State: asks.StateRaised, Summary: "the merge waits on a check", Headline: headline, Needs: needs,
return oc.Condition{Key: key, Severity: occ.SeverityWarning, Subject: oc.ConditionSubject{Scope: "merge", ID: id},
State: occ.StateRaised, Summary: "the merge waits on a check", Headline: headline, Needs: needs,
Explanation: verdict + " It waits on a check."}
}
@@ -180,7 +182,7 @@ func TestAConditionSaidOnTheOneSubjectReachesTheDeskAndIsClearedOverTheBus(t *te
t.Errorf("the work item was offered %d times, want once: it was not acknowledged", n)
}
// 1218/AC2: the cleared form, on the same subject, ends it.
b.say(t, "mesh-delivery-teller", asks.Condition{Key: "merge.d-7.stalled", Subject: asks.Scope{Scope: "merge", ID: "d-7"}, State: asks.StateCleared})
b.say(t, "mesh-delivery-teller", oc.Condition{Key: "merge.d-7.stalled", Subject: oc.ConditionSubject{Scope: "merge", ID: "d-7"}, State: occ.StateCleared})
b.r.until("the condition is recorded cleared", 30*time.Second, func() bool {
rec := b.record("merge.d-7.stalled")
return rec == nil || !rec.Cleared.IsZero()
@@ -8,23 +8,24 @@ import (
"testing"
"time"
"git.novox.be/novox/mesh-sdk/go/asks"
occ "git.novox.be/novox/mesh-contracts/go/clients/operatorchannelclient"
oc "git.novox.be/novox/mesh-contracts/go/seats/operatorchannel"
)
// sdkCondition is a raised warning in mesh-sdk's layout, as a module says it on the operator channel's one subject.
func sdkCondition(needs string) asks.Condition {
verdict := asks.NothingToDo
func sdkCondition(needs string) oc.Condition {
verdict := occ.NothingToDo
if needs != "" {
verdict = asks.NeedsYou + needs
verdict = occ.NeedsYou + needs
}
return asks.Condition{Key: "merge.d-7.stalled", Severity: asks.SeverityWarning,
Subject: asks.Scope{Scope: "merge", ID: "d-7"}, State: asks.StateRaised, Summary: "the merge waits on a check",
return oc.Condition{Key: "merge.d-7.stalled", Severity: occ.SeverityWarning,
Subject: oc.ConditionSubject{Scope: "merge", ID: "d-7"}, State: occ.StateRaised, Summary: "the merge waits on a check",
Headline: "A merge waits on a check", Needs: needs, Explanation: verdict + " It waits on a check."}
}
func conditionBody(t *testing.T, c asks.Condition) json.RawMessage {
func conditionBody(t *testing.T, c oc.Condition) json.RawMessage {
t.Helper()
b, err := asks.EncodeCondition(c)
b, err := occ.EncodeCondition(c)
if err != nil {
t.Fatal(err)
}
@@ -81,7 +82,7 @@ func TestAClearedConditionIsClearedAsTheControllersIs(t *testing.T) { // 1217/AC
t.Fatal(err)
}
settle(h, c)
cleared := asks.Condition{Key: "merge.d-7.stalled", Subject: asks.Scope{Scope: "merge", ID: "d-7"}, State: asks.StateCleared}
cleared := oc.Condition{Key: "merge.d-7.stalled", Subject: oc.ConditionSubject{Scope: "merge", ID: "d-7"}, State: occ.StateCleared}
if err := h.TakeAsk("condition", "mesh-delivery-teller", conditionBody(t, cleared)); err != nil {
t.Fatal(err)
}
+1 -1
View File
@@ -90,7 +90,7 @@ func Check(text string) (Refusal, bool) {
}
// CheckSecret says whether a text carries a secret's shape — the one class that leaves the mesh nowhere. It is
// what an ask's whole words (asks.Ask.Whole, novox/hq issue 383) are held to: on a kind that proves who answers,
// what an ask's whole words (oc.Ask.Whole, novox/hq issue 383) are held to: on a kind that proves who answers,
// a path or an address is what the operator approves and may be shown, a secret never. A path is read as one:
// a run with a slash in it is judged piece by piece between the slashes, so "/mnt/Photos_2024/Jochen" is a
// path and not a random string (the review of 2026-10-10); the named shapes — a key block, a signed token, a
@@ -5,7 +5,8 @@ import (
"testing"
"time"
"git.novox.be/novox/mesh-sdk/go/asks"
occ "git.novox.be/novox/mesh-contracts/go/clients/operatorchannelclient"
oc "git.novox.be/novox/mesh-contracts/go/seats/operatorchannel"
)
// novox/hq issue 639: an ask's message reads at a glance on the phone. Its body is the asker's summary and points
@@ -13,21 +14,21 @@ import (
// it keeps the question whole, so the operator can read back what they answered (issue 714).
// decisionAsk is the ask for ADR 0343 as the knowledge base composes it now (its glance form).
func decisionAsk(c *clock) asks.Ask {
return asks.Ask{ID: "kb-decision-343-0d1e2f3a", Headline: "Your agents' memory follows you to every machine",
func decisionAsk(c *clock) oc.Ask {
return oc.Ask{ID: "kb-decision-343-0d1e2f3a", Headline: "Your agents' memory follows you to every machine",
Explanation: "What an agent learns in a project's memory is copied to every machine that runs the agent, so a session " +
"anywhere starts with it. A file changed on two machines keeps both versions and warns you; secrets in memory files " +
"are screened, though not perfectly.\n" +
"• Only memory folders travel: links and files over 128 KiB stay where they are\n" +
"• A conflict reaches you as a warning that needs you\n" +
"• A deleted file is marked, and a newer change elsewhere outlives the deletion",
Who: asks.Operator, Expires: c.now().Add(23*time.Hour + 50*time.Minute), OnExpiry: "the record stays proposed; nothing of it is in force",
Who: occ.Operator, Expires: occ.FormatTime(c.now().Add(23*time.Hour + 50*time.Minute)), OnExpiry: "the record stays proposed; nothing of it is in force",
About: "knowledge.decision.343",
Details: "ADR 0343, proposed by fix-367@g14; the knowledge base's own review approved it. Fingerprint a59f 361b f60e cb5e d237 a837.\n" +
"Read its 13 clauses with the knowledge base's get, number 343.",
Options: []asks.Option{
{ID: "approve", Label: "Approve", Does: "the record is accepted and its clauses are in force", Level: asks.Approve, Binds: bound("approve")},
{ID: "decline", Label: "Decline", Does: "the record stays proposed; nothing of it is in force", Level: asks.Approve, Binds: bound("decline")},
Options: []oc.Option{
{ID: "approve", Label: "Approve", Does: "the record is accepted and its clauses are in force", Level: occ.Approve, Binds: bound("approve")},
{ID: "decline", Label: "Decline", Does: "the record stays proposed; nothing of it is in force", Level: occ.Approve, Binds: bound("decline")},
}}
}
@@ -47,7 +48,7 @@ func TestAnAsksMessageIsItsSummaryWithTheRestInItsNote(t *testing.T) {
note := strings.Split(m.Note, "\n")
asked := c.now().In(time.UTC).Format("15:04")
want := []string{
"Asked by mesh-knowledge at " + asked + ". Unanswered by tomorrow " + a.Expires.In(time.UTC).Format("15:04") +
"Asked by mesh-knowledge at " + asked + ". Unanswered by tomorrow " + askExpires(a).In(time.UTC).Format("15:04") +
": the record stays proposed; nothing of it is in force.",
"Approve: the record is accepted and its clauses are in force. Decline: the record stays proposed; nothing of it is in force.",
"ADR 0343, proposed by fix-367@g14; the knowledge base's own review approved it. Fingerprint a59f 361b f60e cb5e d237 a837.",
+3 -3
View File
@@ -6,7 +6,7 @@ import (
"testing"
"time"
"git.novox.be/novox/mesh-sdk/go/asks"
occ "git.novox.be/novox/mesh-contracts/go/clients/operatorchannelclient"
)
// A held ask (novox/hq ADR 0397, issue 926): an ask that breaks no rule but that no ready channel can carry now
@@ -99,7 +99,7 @@ func TestAHeldAskExpiresAsAnyOpenAskDoes(t *testing.T) {
stand(h, c)
h.Tick()
got := w.of("mesh-delivery")
if len(got) != 1 || got[0].Outcome != asks.OutcomeExpired || store.asks["mesh-delivery.d-1"].State != string(asks.OutcomeExpired) {
if len(got) != 1 || got[0].Outcome != occ.OutcomeExpired || store.asks["mesh-delivery.d-1"].State != string(occ.OutcomeExpired) {
t.Fatalf("%+v %+v", got, store.asks["mesh-delivery.d-1"])
}
}
@@ -113,7 +113,7 @@ func TestAHeldAskCountsTowardTheOpenAsksOfItsAsker(t *testing.T) {
submit(t, h, "mesh-delivery", a)
}
got := w.of("mesh-delivery")
if len(got) != 1 || got[0].Outcome != asks.OutcomeRefused || !strings.Contains(got[0].Words, "already has 3 open asks") {
if len(got) != 1 || got[0].Outcome != occ.OutcomeRefused || !strings.Contains(got[0].Words, "already has 3 open asks") {
t.Fatalf("%+v", got)
}
}
+3 -3
View File
@@ -98,7 +98,7 @@ type Message struct {
Title string
Body string
// Note is the secondary words, after the body and the answers: who asked, what each answer does, the
// expiry, the asker's Details (asks.Message.Note, issue 639).
// expiry, the asker's Details (ch.Message.Note, issue 639).
Note string
Urgent bool
Quiet bool // a clearing: shown without urgency
@@ -1007,8 +1007,8 @@ func (h *Holder) askLines(rec *AskRecord, r *Record) []string {
asker = "the router itself"
}
first := "Asked by " + asker + " at " + when(r.Raised, h.Now(), h.zone()) + "."
if !rec.Ask.Expires.IsZero() && rec.Ask.OnExpiry != "" {
first += " Unanswered by " + by(rec.Ask.Expires, h.Now(), h.zone()) + ": " + strings.TrimSuffix(rec.Ask.OnExpiry, ".") + "."
if !askExpires(rec.Ask).IsZero() && rec.Ask.OnExpiry != "" {
first += " Unanswered by " + by(askExpires(rec.Ask), h.Now(), h.zone()) + ": " + strings.TrimSuffix(rec.Ask.OnExpiry, ".") + "."
}
lines := []string{first}
var does []string
+16 -14
View File
@@ -30,7 +30,9 @@ import (
"strings"
"time"
"git.novox.be/novox/mesh-sdk/go/asks"
occ "git.novox.be/novox/mesh-contracts/go/clients/operatorchannelclient"
oc "git.novox.be/novox/mesh-contracts/go/seats/operatorchannel"
ch "git.novox.be/novox/mesh-sdk/go/channel"
)
const (
@@ -169,13 +171,13 @@ func (h *Holder) offerDeskUnlink(id Identity) bool {
h.mu.Unlock()
now := h.Now()
until := id.ApproveFrom
a := asks.Ask{ID: "desk-unlink-" + id.Kind + "-" + fmt.Sprint(now.Unix()), Headline: "Unlink the account linked just now?",
a := oc.Ask{ID: "desk-unlink-" + id.Kind + "-" + fmt.Sprint(now.Unix()), Headline: "Unlink the account linked just now?",
Explanation: fmt.Sprintf("The %s account %s (number %s) was linked as you at %s. Another account just asked "+
"to be linked. If the linked one is not yours, unlink it here before %s; after that only that account, "+
"or root at the router's machine, can.", Display(id.Kind), id.Display, id.Identity,
id.Linked.In(h.zone()).Format("15:04"), until.In(h.zone()).Format("15:04")),
Who: asks.Operator, Expires: until, OnExpiry: "the account stays linked",
Options: []asks.Option{{ID: "unlink", Label: "Unlink it", Does: "that account stops answering as you", Level: asks.Acknowledge}}}
Who: occ.Operator, Expires: occ.FormatTime(until), OnExpiry: "the account stays linked",
Options: []oc.Option{{ID: "unlink", Label: "Unlink it", Does: "that account stops answering as you", Level: occ.Acknowledge}}}
if err := h.askSelf(a, Identity{Kind: desk}, SelfAct{Do: "unlink", Kind: id.Kind, Identity: id.Identity}); err != nil {
h.Logf("[messenger] the desk could not be asked to unlink %s: %v", id.Display, err)
return false
@@ -185,7 +187,7 @@ func (h *Holder) offerDeskUnlink(id Identity) bool {
// Link takes a link asked for on a kind: a code shown where only the operator sees it, or a refusal said on
// the kind.
func (h *Holder) Link(kind string, l asks.LinkAsked) string {
func (h *Holder) Link(kind string, l ch.LinkAsked) string {
h.warmRootFree(false) // outside the work: a question it asks reads a recent word
h.work.Lock()
defer h.work.Unlock()
@@ -276,11 +278,11 @@ func (h *Holder) Link(kind string, l asks.LinkAsked) string {
}
// Prove answers a code sent on a kind: the link it confirms, or why not. Never said in an event.
func (h *Holder) Prove(kind string, c asks.Code) asks.ProofAnswer {
func (h *Holder) Prove(kind string, c ch.Code) ch.ProofAnswer {
h.warmRootFree(false) // outside the work: a question it asks reads a recent word
h.work.Lock()
defer h.work.Unlock()
no := func(words string) asks.ProofAnswer { return asks.ProofAnswer{Accepted: false, Words: words} }
no := func(words string) ch.ProofAnswer { return ch.ProofAnswer{Accepted: false, Words: words} }
if c.Purpose != "" && c.Purpose != "link" {
return no("The mesh takes no code for that yet.")
}
@@ -325,12 +327,12 @@ func (h *Holder) Prove(kind string, c asks.Code) asks.ProofAnswer {
p.Confirmed = true
p.Expires = now.Add(RelinkWaits)
_ = h.Asks.PutLink(kind, *p)
a := asks.Ask{ID: "relink-" + kind + "-" + fmt.Sprint(now.Unix()),
a := oc.Ask{ID: "relink-" + kind + "-" + fmt.Sprint(now.Unix()),
Headline: "Allow another account in your place?",
Explanation: fmt.Sprintf("This account was unlinked from being you less than a day ago. The %s account %s "+
"(number %s) now asks to be linked as you. If you choose Allow, it is.", Display(kind), p.Display, p.Identity),
Who: asks.Operator, Expires: now.Add(RelinkWaits - time.Minute), OnExpiry: "nothing is linked",
Options: []asks.Option{{ID: "allow", Label: "Allow", Does: "the other account is linked as you", Level: asks.Approve}}}
Who: occ.Operator, Expires: occ.FormatTime(now.Add(RelinkWaits - time.Minute)), OnExpiry: "nothing is linked",
Options: []oc.Option{{ID: "allow", Label: "Allow", Does: "the other account is linked as you", Level: occ.Approve}}}
prior := u.Identity
prior.ApproveFrom = prior.Linked // an account linked before: its hour passed while it was linked
if err := h.askSelf(a, prior, SelfAct{Do: "link", Kind: kind, Identity: id.Identity, Link: &id}); err != nil {
@@ -339,12 +341,12 @@ func (h *Holder) Prove(kind string, c asks.Code) asks.ProofAnswer {
h.announce(Message{Title: "A new " + Display(kind) + " account asks to be linked as you",
Body: fmt.Sprintf("%s (number %s), less than a day after an unlink. It waits for the account it replaces, "+
"or for root at the router's machine.", p.Display, p.Identity), Urgent: true})
return asks.ProofAnswer{Accepted: true, Words: "The code is right. Because an account was unlinked here less " +
return ch.ProofAnswer{Accepted: true, Words: "The code is right. Because an account was unlinked here less " +
"than a day ago, the link waits for that account's approval, or for you at the router's machine."}
}
h.linkNow(id, "with the code shown on the desk")
from := now.Add(LinkedApproveAfter).In(h.zone()).Format("15:04")
return asks.ProofAnswer{Accepted: true, Words: "Linked. You can answer acknowledgements now, and approve from " + from + "."}
return ch.ProofAnswer{Accepted: true, Words: "Linked. You can answer acknowledgements now, and approve from " + from + "."}
}
// Identities is every linked account, by kind.
@@ -384,7 +386,7 @@ func sixDigits() (string, error) {
}
// decodeLink reads a link asked for.
func decodeLink(body json.RawMessage) (asks.LinkAsked, error) {
var l asks.LinkAsked
func decodeLink(body json.RawMessage) (ch.LinkAsked, error) {
var l ch.LinkAsked
return l, json.Unmarshal(body, &l)
}
+15 -14
View File
@@ -16,8 +16,9 @@ import (
_ "time/tzdata" // the operator's zone is known whatever this machine has installed
oc "git.novox.be/novox/mesh-contracts/go/seats/operatorchannel"
stdio "git.novox.be/novox/mesh-sdk/go"
"git.novox.be/novox/mesh-sdk/go/asks"
ch "git.novox.be/novox/mesh-sdk/go/channel"
)
func errorf(format string, a ...any) error { return fmt.Errorf(format, a...) }
@@ -492,7 +493,7 @@ func run(h *Holder, l *listening) {
// which the bus grants that kind's holder alone.
func kindOf(key string) (what, kind string, ok bool) {
parts := strings.Split(key, ".")
if len(parts) != 3 || parts[0] != asks.IntakeSeat {
if len(parts) != 3 || parts[0] != ch.IntakeSeat {
return "", "", false
}
return parts[1], parts[2], true
@@ -507,8 +508,8 @@ func listenForChannels(h *Holder) {
return nil
}
switch what {
case asks.Chosen:
var c asks.Choice
case ch.Chosen:
var c ch.Choice
if err := json.Unmarshal(e.Body, &c); err != nil || c.Ticket == "" {
logf("[messenger] an answer from %s could not be read: %s", kind, firstLineOf(string(e.Body)))
return nil
@@ -517,19 +518,19 @@ func listenForChannels(h *Holder) {
c.ID = e.Headers["x-event-id"]
}
_, _ = h.Chosen(kind, c)
case asks.Link:
case ch.Link:
l, err := decodeLink(e.Body)
if err != nil {
logf("[messenger] a link asked on %s could not be read", kind)
return nil
}
h.Link(kind, l)
case asks.FailedWhat:
var f asks.Failed
case ch.FailedWhat:
var f ch.Failed
if json.Unmarshal(e.Body, &f) == nil {
h.Failed(kind, f)
}
case asks.StandingWhat:
case ch.StandingWhat:
// Read as this module's Standing: the bench's word, and answers-lost-at beside it (issue 384).
var s Standing
if json.Unmarshal(e.Body, &s) == nil {
@@ -540,7 +541,7 @@ func listenForChannels(h *Holder) {
return nil
}
for wait := 2 * time.Second; ; wait = min(wait*2, time.Minute) {
if err := stdio.Subscribe(asks.IntakeSeat+".#", handle); err == nil {
if err := stdio.Subscribe(ch.IntakeSeat+".#", handle); err == nil {
logf("[messenger] listening for what the channels say")
break
} else {
@@ -562,7 +563,7 @@ func takeAsks(h *Holder) {
}
for wait := 2 * time.Second; ; wait = min(wait*2, time.Minute) {
if err := stdio.SeatTake(AsksWorker, handle); err == nil {
logf("[messenger] taking the asks submitted to %s", asks.Seat)
logf("[messenger] taking the asks submitted to %s", oc.Seat)
break
} else {
logf("[messenger] not taking asks yet (%v); asking again in %s", err, wait)
@@ -579,14 +580,14 @@ func answerProofs(h *Holder) {
handle := func(subject string, body json.RawMessage) (any, error) {
parts := strings.Split(subject, ".")
kind := parts[len(parts)-1]
var c asks.Code
var c ch.Code
if err := json.Unmarshal(body, &c); err != nil {
return asks.ProofAnswer{Words: "The code could not be read."}, nil
return ch.ProofAnswer{Words: "The code could not be read."}, nil
}
return h.Prove(kind, c), nil
}
for wait := 2 * time.Second; ; wait = min(wait*2, time.Minute) {
if err := stdio.SeatAnswer(asks.ProofSubject(asks.CodeProof, "*"), handle); err == nil {
if err := stdio.SeatAnswer(ch.ProofSubject(ch.CodeProof, "*"), handle); err == nil {
logf("[messenger] answering the codes the channels are sent")
break
} else {
@@ -613,7 +614,7 @@ func followKinds(h *Holder) {
} else {
var out []KindInfo
for _, k := range kinds {
if k.Seat == asks.ChannelSeat {
if k.Seat == ch.ChannelSeat {
out = append(out, KindInfo{Kind: k.Kind, Module: k.Module, Node: k.Node, Capabilities: k.Capabilities})
}
}
@@ -9,7 +9,8 @@ import (
"strings"
"testing"
"git.novox.be/novox/mesh-sdk/go/asks"
oc "git.novox.be/novox/mesh-contracts/go/seats/operatorchannel"
ch "git.novox.be/novox/mesh-sdk/go/channel"
)
// The manifest says what the code does (novox/hq ADR 0259): the seats it declares — the operator's, and
@@ -64,19 +65,19 @@ func TestItDeclaresTheSeatsOfAnAskAndHoldsTheOperatorChannel(t *testing.T) { //
m, _ := readManifest(t)
verbs := []string{"open", "history", "notify", "tell", "asks"}
want := []seat{
{Name: asks.Seat, Scope: "mesh", Serves: verbs, Accepts: []string{"ask", "cancel", "condition"}, Emits: []string{"decided"},
{Name: oc.Seat, Scope: "mesh", Serves: verbs, Accepts: []string{"ask", "cancel", "condition"}, Emits: []string{"decided"},
ByCaller: []string{"ask", "cancel", "condition", "decided"}, Records: []string{"asks"}},
{Name: asks.ChannelSeat, Scope: "mesh", Kinded: true, Accepts: []string{asks.Show, asks.Edit, asks.Send}},
{Name: asks.IntakeSeat, Scope: "mesh", Kinded: true,
Emits: []string{asks.Chosen, asks.Link, asks.FailedWhat, asks.StandingWhat}, Proofs: []string{asks.CodeProof}},
{Name: ch.ChannelSeat, Scope: "mesh", Kinded: true, Accepts: []string{ch.Show, ch.Edit, ch.Send}},
{Name: ch.IntakeSeat, Scope: "mesh", Kinded: true,
Emits: []string{ch.Chosen, ch.Link, ch.FailedWhat, ch.StandingWhat}, Proofs: []string{ch.CodeProof}},
}
if !reflect.DeepEqual(m.Seats, want) {
t.Fatalf("seats %+v", m.Seats)
}
if len(m.Claims) != 1 || m.Claims[0].Name != asks.Seat || !reflect.DeepEqual(m.Claims[0].Serves, verbs) {
if len(m.Claims) != 1 || m.Claims[0].Name != oc.Seat || !reflect.DeepEqual(m.Claims[0].Serves, verbs) {
t.Fatalf("claims %+v", m.Claims)
}
if !reflect.DeepEqual(m.Uses, []string{asks.ChannelSeat}) {
if !reflect.DeepEqual(m.Uses, []string{ch.ChannelSeat}) {
t.Fatalf("uses %v", m.Uses)
}
if !reflect.DeepEqual(m.Consumes, []string{
@@ -5,6 +5,7 @@ package main
// problem at any time, updated in place.
import (
occ "git.novox.be/novox/mesh-contracts/go/clients/operatorchannelclient"
"strings"
"testing"
"time"
@@ -156,7 +157,7 @@ func TestAnAsksExpiryIsSaidWithItsDay(t *testing.T) {
h.Zone = func() *time.Location { return time.UTC }
c.t = now
rec := &AskRecord{Asker: ConditionOwner}
rec.Ask.Expires = now.Add(7 * 24 * time.Hour)
rec.Ask.Expires = occ.FormatTime(now.Add(7 * 24 * time.Hour))
rec.Ask.OnExpiry = "nothing is done"
lines := strings.Join(h.askLines(rec, &Record{Raised: now}), "\n")
if !strings.Contains(lines, "Unanswered by Fri 16 Oct 21:33: nothing is done.") {
@@ -175,7 +176,7 @@ func TestAnEndedQuestionLeavesNoMessageOfferingItsAnswers(t *testing.T) {
settle(h, c)
a := ackAsk(c, "q-1")
a.About = k.Key
a.Expires = c.now().Add(3 * time.Hour)
a.Expires = occ.FormatTime(c.now().Add(3 * time.Hour))
submit(t, h, ConditionOwner, a)
settle(h, c)
for i := 0; i < 4*60; i++ {
@@ -5,7 +5,8 @@ import (
"testing"
"time"
"git.novox.be/novox/mesh-sdk/go/asks"
occ "git.novox.be/novox/mesh-contracts/go/clients/operatorchannelclient"
oc "git.novox.be/novox/mesh-contracts/go/seats/operatorchannel"
)
// openAskRecords are the records still open that show an ask as their own message.
@@ -54,7 +55,7 @@ func TestAReplacedAsksMessageEndsWithIt(t *testing.T) {
t.Errorf("%s: the replaced ask's message still stands: edited %v, %q %v", ch.name, edited, m.Text(), labelsOf(m))
}
}
if store.asks["mesh-delivery.d-1"].State != string(asks.OutcomeReplaced) {
if store.asks["mesh-delivery.d-1"].State != string(occ.OutcomeReplaced) {
t.Errorf("the older ask was not replaced: %s", store.asks["mesh-delivery.d-1"].State)
}
}
@@ -71,9 +72,9 @@ func TestAnAsksMessageLeftOpenAfterItsAskEndedIsEndedOnTheNextTick(t *testing.T)
// Ended as replaced the way the router did before the fix: the ask's record only, its message untouched.
key := "mesh-delivery.d-1"
rec := store.asks[key]
rec.State, rec.Closed, rec.Told = string(asks.OutcomeReplaced), c.now(), true
rec.Warrant = &asks.Warrant{Ask: "d-1", Asker: "mesh-delivery", About: a.About, Outcome: asks.OutcomeReplaced,
Words: "a newer ask about the same thing replaced it", At: c.now()}
rec.State, rec.Closed, rec.Told = string(occ.OutcomeReplaced), c.now(), true
rec.Warrant = &oc.Warrant{Ask: "d-1", Asker: "mesh-delivery", About: a.About, Outcome: occ.OutcomeReplaced,
Words: "a newer ask about the same thing replaced it", At: occ.FormatTime(c.now())}
store.asks[key] = rec
h.keepAsk(key, rec, 99)
if got := openAskRecords(h); len(got) != 1 {
@@ -5,7 +5,7 @@ import (
"testing"
"time"
"git.novox.be/novox/mesh-sdk/go/asks"
occ "git.novox.be/novox/mesh-contracts/go/clients/operatorchannelclient"
)
// Issue 384: the desk's notifier started again and the desk showed, from its own memory, nine old popups —
@@ -129,7 +129,7 @@ func TestLostAnswersGetOnlyWhatIsOpenAndUnansweredSaidAgain(t *testing.T) {
}
chosen := 0
for _, wt := range w.of(ConditionOwner) {
if wt.Outcome == asks.OutcomeChosen {
if wt.Outcome == occ.OutcomeChosen {
chosen++
}
}
+24 -22
View File
@@ -15,7 +15,9 @@ import (
"testing"
"time"
"git.novox.be/novox/mesh-sdk/go/asks"
occ "git.novox.be/novox/mesh-contracts/go/clients/operatorchannelclient"
oc "git.novox.be/novox/mesh-contracts/go/seats/operatorchannel"
chn "git.novox.be/novox/mesh-sdk/go/channel"
)
// H3: approval rests on the controller's positive, recent word that no agent can become root where the router
@@ -67,7 +69,7 @@ func TestApprovalNeedsTheControllersWordThatRootIsFree(t *testing.T) {
c.pass(RootFreeKept)
stand(h, c)
}
if got := h.carriers(asks.Approve); len(got) != 0 {
if got := h.carriers(occ.Approve); len(got) != 0 {
t.Errorf("approval still carried on %v", got)
}
})
@@ -184,7 +186,7 @@ func TestAStaleWordOnTheKindsPromisesNoVerifiedSender(t *testing.T) {
}
c.pass(KindsFresh + time.Second)
h.Bench.Stand("telegram", Standing{Ready: true, At: c.now()})
if h.Bench.Promises("telegram", "verified-sender") || len(h.carriers(asks.Approve)) != 0 {
if h.Bench.Promises("telegram", "verified-sender") || len(h.carriers(occ.Approve)) != 0 {
t.Error("a stale word still promises a verified sender")
}
if !h.Bench.Promises("telegram", "choice") {
@@ -204,11 +206,11 @@ func TestALinksCodeIsSecretOnEveryPath(t *testing.T) {
bench.Stand("telegram", Standing{Ready: true, At: c.now()})
bench.Stand("desktop", Standing{Ready: true, At: c.now()})
var mu sync.Mutex
put := map[string][]asks.Message{}
put := map[string][]chn.Message{}
publish := func(subject string, body any, id string) error {
mu.Lock()
defer mu.Unlock()
if m, ok := body.(asks.Message); ok {
if m, ok := body.(chn.Message); ok {
put[subject] = append(put[subject], m)
}
return nil
@@ -217,8 +219,8 @@ func TestALinksCodeIsSecretOnEveryPath(t *testing.T) {
Desk: &SeatChannel{Bench: bench, Role: RoleDesk, Publish: publish}, Bench: bench, Publish: publish,
Asks: newMemAsks(), Node: "anchor", Now: c.now, Logf: t.Logf, Zone: func() *time.Location { return time.UTC }}
h.init()
h.Link("telegram", asks.LinkAsked{ID: "l1", At: c.now(), Sender: asks.Sender{Identity: "42", Display: "Jo", Authenticated: true}})
shown := put[asks.ChannelSubject(asks.Send, "desktop")]
h.Link("telegram", chn.LinkAsked{ID: "l1", At: c.now(), Sender: chn.Sender{Identity: "42", Display: "Jo", Authenticated: true}})
shown := put[chn.ChannelSubject(chn.Send, "desktop")]
if len(shown) != 1 || !strings.Contains(shown[0].Body, "send this code") {
t.Fatalf("the code was not shown on the desk: %+v", put)
}
@@ -234,7 +236,7 @@ func TestAWarrantsMessageIdCannotBeForeseen(t *testing.T) {
link(h, store, c, "42", c.now().Add(-2*time.Hour))
var ids []string
h.Publish = func(subject string, body any, id string) error {
if _, ok := body.(asks.Warrant); ok {
if _, ok := body.(oc.Warrant); ok {
ids = append(ids, id)
}
return nil
@@ -305,9 +307,9 @@ func TestALinkIsNotMadeWhileAnotherAccountIsLinked(t *testing.T) {
link(h, store, c, "42", c.now().Add(-2*time.Hour))
_, _ = h.Unlink("telegram")
_, _ = h.Chosen("telegram", choiceOf(ticketFor(tg.sends[len(tg.sends)-1], "Unlink"), "42", c.now()))
h.Link("telegram", asks.LinkAsked{ID: "l9", At: c.now(), Sender: asks.Sender{Identity: "666", Display: "Eve", Authenticated: true}})
h.Link("telegram", chn.LinkAsked{ID: "l9", At: c.now(), Sender: chn.Sender{Identity: "666", Display: "Eve", Authenticated: true}})
code := codeOf(t, dt.sends[len(dt.sends)-1])
_ = h.Prove("telegram", asks.Code{Sender: asks.Sender{Identity: "666", Authenticated: true}, Code: code})
_ = h.Prove("telegram", chn.Code{Sender: chn.Sender{Identity: "666", Authenticated: true}, Code: code})
asked := tg.sends[len(tg.sends)-1]
// Meanwhile root linked another account (allowed at the machine).
store.ids[identityKey("telegram", "77")] = Identity{Kind: "telegram", Identity: "77", Linked: c.now(), ApproveFrom: c.now().Add(time.Hour)}
@@ -324,7 +326,7 @@ func TestAnUnlinkedAccountDoesNotComeBackByACodeWithinADay(t *testing.T) {
_, _ = h.Unlink("telegram")
_, _ = h.Chosen("telegram", choiceOf(ticketFor(tg.sends[len(tg.sends)-1], "Unlink"), "42", c.now()))
shown := len(dt.sends)
h.Link("telegram", asks.LinkAsked{ID: "l1", At: c.now(), Sender: asks.Sender{Identity: "42", Display: "Jo", Authenticated: true}})
h.Link("telegram", chn.LinkAsked{ID: "l1", At: c.now(), Sender: chn.Sender{Identity: "42", Display: "Jo", Authenticated: true}})
if _, pending := store.links["telegram"]; pending {
t.Error("a link was begun for the account unlinked an hour ago")
}
@@ -343,7 +345,7 @@ func TestAnUnlinkedAccountDoesNotComeBackByACodeWithinADay(t *testing.T) {
func TestAnAccountInItsFirstHourIsUnlinkedFromTheDesk(t *testing.T) {
h, tg, dt, c, store, w := newRouter(t)
link(h, store, c, "666", c.now().Add(-10*time.Minute)) // an account the operator did not link
h.Link("telegram", asks.LinkAsked{ID: "l1", At: c.now(), Sender: asks.Sender{Identity: "42", Display: "Jo", Authenticated: true}})
h.Link("telegram", chn.LinkAsked{ID: "l1", At: c.now(), Sender: chn.Sender{Identity: "42", Display: "Jo", Authenticated: true}})
if reply := tg.sends[len(tg.sends)-1]; !strings.Contains(reply.Body, "unlink it at your desk") {
t.Fatalf("the sender was not told how: %q", reply.Body)
}
@@ -365,7 +367,7 @@ func TestAnAccountInItsFirstHourIsUnlinkedFromTheDesk(t *testing.T) {
// Past its hour, no question goes to the desk.
h2, tg2, dt2, c2, store2, _ := newRouter(t)
link(h2, store2, c2, "666", c2.now().Add(-2*time.Hour))
h2.Link("telegram", asks.LinkAsked{ID: "l1", At: c2.now(), Sender: asks.Sender{Identity: "42", Authenticated: true}})
h2.Link("telegram", chn.LinkAsked{ID: "l1", At: c2.now(), Sender: chn.Sender{Identity: "42", Authenticated: true}})
for _, m := range dt2.sends {
if labelsOf(m) == "Unlink it" {
t.Error("the desk was asked to unlink an account past its first hour")
@@ -381,7 +383,7 @@ func TestAnAccountInItsFirstHourIsUnlinkedFromTheDesk(t *testing.T) {
func TestNoLinkIsBegunWhileTheDeskCannotShowTheCode(t *testing.T) {
h, tg, dt, c, store, _ := newRouter(t)
h.Bench.Stand("desktop", Standing{Ready: false, Why: "notifications are paused on the desk", At: c.now()})
h.Link("telegram", asks.LinkAsked{ID: "l1", At: c.now(), Sender: asks.Sender{Identity: "42", Display: "Jo", Authenticated: true}})
h.Link("telegram", chn.LinkAsked{ID: "l1", At: c.now(), Sender: chn.Sender{Identity: "42", Display: "Jo", Authenticated: true}})
if _, pending := store.links["telegram"]; pending {
t.Error("a link was begun")
}
@@ -397,12 +399,12 @@ func TestNoLinkIsBegunWhileTheDeskCannotShowTheCode(t *testing.T) {
// operator's link, and a restart forgets no count.
func TestLinksAreBoundedPerAccountAndTheCountIsKept(t *testing.T) {
h, tg, dt, c, store, _ := newRouter(t)
eve := asks.Sender{Identity: "666", Display: "Eve", Authenticated: true}
eve := chn.Sender{Identity: "666", Display: "Eve", Authenticated: true}
for i := 0; i < LinksAnHour; i++ {
h.Link("telegram", asks.LinkAsked{ID: "e" + string(rune('0'+i)), At: c.now(), Sender: eve})
h.Link("telegram", chn.LinkAsked{ID: "e" + string(rune('0'+i)), At: c.now(), Sender: eve})
}
shown := len(dt.sends)
h.Link("telegram", asks.LinkAsked{ID: "jo", At: c.now(), Sender: asks.Sender{Identity: "42", Display: "Jo", Authenticated: true}})
h.Link("telegram", chn.LinkAsked{ID: "jo", At: c.now(), Sender: chn.Sender{Identity: "42", Display: "Jo", Authenticated: true}})
if len(dt.sends) != shown+1 || !strings.Contains(dt.sends[len(dt.sends)-1].Body, "(number 42)") {
t.Fatalf("a stranger's tries stopped the operator's link: %q", tg.sends[len(tg.sends)-1].Body)
}
@@ -412,7 +414,7 @@ func TestLinksAreBoundedPerAccountAndTheCountIsKept(t *testing.T) {
c2.pass(c.now().Sub(c2.now()))
stand(h2, c2)
before := len(dt2.sends)
h2.Link("telegram", asks.LinkAsked{ID: "e9", At: c2.now(), Sender: eve})
h2.Link("telegram", chn.LinkAsked{ID: "e9", At: c2.now(), Sender: eve})
if len(dt2.sends) != before || !strings.Contains(tg2.sends[len(tg2.sends)-1].Body, "Try again in an hour") {
t.Error("a restart forgot the count")
}
@@ -461,7 +463,7 @@ func TestTwoAnswersAtOnceMakeOneWarrant(t *testing.T) {
func TestAnUnlinkFromTheDeskInTheFirstHourBarsNothing(t *testing.T) {
h, tg, dt, c, store, _ := newRouter(t)
link(h, store, c, "42", c.now().Add(-10*time.Minute)) // the operator's own, linked just now
h.Link("telegram", asks.LinkAsked{ID: "l1", At: c.now(), Sender: asks.Sender{Identity: "666", Display: "X", Authenticated: true}})
h.Link("telegram", chn.LinkAsked{ID: "l1", At: c.now(), Sender: chn.Sender{Identity: "666", Display: "X", Authenticated: true}})
asked := dt.sends[len(dt.sends)-1]
ch := choiceOf(ticketFor(asked, "Unlink it"), "g14", c.now()) // a click nobody can prove was the operator's
ch.Sender.Authenticated = false
@@ -477,7 +479,7 @@ func TestAnUnlinkFromTheDeskInTheFirstHourBarsNothing(t *testing.T) {
// The operator sends /start again: a code is shown at once, and the code links without waiting for anybody.
c.pass(time.Minute)
stand(h, c)
h.Link("telegram", asks.LinkAsked{ID: "l2", At: c.now(), Sender: asks.Sender{Identity: "42", Display: "Jo", Authenticated: true}})
h.Link("telegram", chn.LinkAsked{ID: "l2", At: c.now(), Sender: chn.Sender{Identity: "42", Display: "Jo", Authenticated: true}})
code := ""
for _, m := range dt.sends {
if strings.Contains(m.Body, "number 42") && strings.Contains(m.Body, "send this code") {
@@ -487,7 +489,7 @@ func TestAnUnlinkFromTheDeskInTheFirstHourBarsNothing(t *testing.T) {
if code == "" {
t.Fatalf("no code was shown to relink the operator's account; said %q", tg.sends[len(tg.sends)-1].Body)
}
if a := h.Prove("telegram", asks.Code{Sender: asks.Sender{Identity: "42", Authenticated: true}, Code: code, Purpose: "link"}); !a.Accepted ||
if a := h.Prove("telegram", chn.Code{Sender: chn.Sender{Identity: "42", Authenticated: true}, Code: code, Purpose: "link"}); !a.Accepted ||
!strings.Contains(a.Words, "Linked") {
t.Fatalf("the operator's code was not taken at once: %+v", a)
}
@@ -547,7 +549,7 @@ func TestTheRootFreeWordIsNotAskedWhileTheWorkIsHeld(t *testing.T) {
<-asking
linked := make(chan struct{})
go func() {
h.Link("telegram", asks.LinkAsked{ID: "l9", At: c.now(), Sender: asks.Sender{Identity: "77", Authenticated: true}})
h.Link("telegram", chn.LinkAsked{ID: "l9", At: c.now(), Sender: chn.Sender{Identity: "77", Authenticated: true}})
close(linked)
}()
select {
@@ -24,7 +24,7 @@ import (
"sync"
"time"
"git.novox.be/novox/mesh-sdk/go/asks"
ch "git.novox.be/novox/mesh-sdk/go/channel"
)
// StandingFresh is how long a channel's word on whether it can send is believed: a channel says it at
@@ -277,16 +277,16 @@ func (c *SeatChannel) putOn(kind, handle string, m Message, edit bool) error {
if c.Publish == nil {
return errors.New("this router cannot reach the bus")
}
verb := asks.Send
verb := ch.Send
switch {
case edit:
verb = asks.Edit
verb = ch.Edit
case len(m.Actions) > 0:
verb = asks.Show
verb = ch.Show
}
// Secret travels whatever the path: a link's code is kept by no channel (the review of 2026-10-09 found it
// dropped here, where the desk's own role sends).
out := asks.Message{Handle: handle, Title: m.Title, Body: m.Body, Note: m.Note, Urgent: m.Urgent, Silent: m.Quiet, Reply: m.Reply,
out := ch.Message{Handle: handle, Title: m.Title, Body: m.Body, Note: m.Note, Urgent: m.Urgent, Silent: m.Quiet, Reply: m.Reply,
Secret: m.Secret}
if c.Bench.To != nil {
out.To = c.Bench.To(kind)
@@ -295,9 +295,9 @@ func (c *SeatChannel) putOn(kind, handle string, m Message, edit bool) error {
out.To = m.To
}
for _, a := range m.Actions {
out.Buttons = append(out.Buttons, asks.Button{Label: a.Label, Ticket: a.Token})
out.Buttons = append(out.Buttons, ch.Button{Label: a.Label, Ticket: a.Token})
}
return c.Publish(asks.ChannelSubject(verb, kind), out, handle+"."+fmt.Sprint(time.Now().UnixNano()))
return c.Publish(ch.ChannelSubject(verb, kind), out, handle+"."+fmt.Sprint(time.Now().UnixNano()))
}
func newHandle() string {
+16 -15
View File
@@ -21,7 +21,8 @@ import (
"syscall"
"time"
"git.novox.be/novox/mesh-sdk/go/asks"
occ "git.novox.be/novox/mesh-contracts/go/clients/operatorchannelclient"
oc "git.novox.be/novox/mesh-contracts/go/seats/operatorchannel"
)
// SelfAsker is the name the router asks its own questions under.
@@ -40,9 +41,9 @@ type SelfAct struct {
Link *Identity `json:"link,omitempty"` // the account a link makes the operator's
}
// act is what an option of the router's own ask binds: its named fields (asks.ActDigest).
func (a SelfAct) act() asks.Act {
return asks.Act{"do": a.Do, "kind": a.Kind, "identity": a.Identity}
// act is what an option of the router's own ask binds: its named fields (occ.ActDigest).
func (a SelfAct) act() occ.Act {
return occ.Act{"do": a.Do, "kind": a.Kind, "identity": a.Identity}
}
// Unlinked is the last account unlinked on a kind, kept a day so a relink is asked of it.
@@ -57,9 +58,9 @@ type Unlinked struct {
}
// askSelf puts a question of the router's own to one account, on its kind, and keeps it like any ask.
func (h *Holder) askSelf(a asks.Ask, only Identity, act SelfAct) error {
func (h *Holder) askSelf(a oc.Ask, only Identity, act SelfAct) error {
// Its options bind the act, as any asker's do (ADR 0259 §6): the warrant then performs this act alone.
binds, err := asks.ActDigest(act.act())
binds, err := occ.ActDigest(act.act())
if err != nil {
return err
}
@@ -103,13 +104,13 @@ func (h *Holder) askSelf(a asks.Ask, only Identity, act SelfAct) error {
}
// selfAct does what the router's own ask decided.
func (h *Holder) selfAct(rec AskRecord, w asks.Warrant) {
if rec.Act == nil || w.Outcome != asks.OutcomeChosen {
func (h *Holder) selfAct(rec AskRecord, w oc.Warrant) {
if rec.Act == nil || w.Outcome != occ.OutcomeChosen {
return
}
o, err := w.For(SelfAsker, rec.Ask)
o, err := occ.For(w, SelfAsker, rec.Ask)
if err == nil {
err = o.Performs(rec.Act.act())
err = occ.Performs(o, rec.Act.act())
}
if err != nil {
h.Logf("[messenger] REFUSED a warrant for its own ask %s: %v", rec.Ask.ID, err)
@@ -117,7 +118,7 @@ func (h *Holder) selfAct(rec AskRecord, w asks.Warrant) {
}
switch rec.Act.Do {
case "unlink":
if o.Level == asks.Acknowledge {
if o.Level == occ.Acknowledge {
// Asked at the desk: only an account still in its first hour, which cannot approve yet, is unlinked
// this way (ADR 0259 §7, the review of 2026-10-09). Past its hour it decides for itself.
id := h.identity(rec.Act.Kind, rec.Act.Identity)
@@ -150,11 +151,11 @@ func (h *Holder) Unlink(kind string) (map[string]any, error) {
}
id := ids[0]
now := h.Now()
a := asks.Ask{ID: "unlink-" + kind + "-" + fmt.Sprint(now.Unix()), Headline: "Unlink this account?",
a := oc.Ask{ID: "unlink-" + kind + "-" + fmt.Sprint(now.Unix()), Headline: "Unlink this account?",
Explanation: fmt.Sprintf("Unlinking this %s account (%s, number %s) was asked. If you choose Unlink, answers "+
"from here stop counting as yours until an account is linked again.", Display(kind), id.Display, id.Identity),
Who: asks.Operator, Expires: now.Add(time.Hour), OnExpiry: "the account stays linked",
Options: []asks.Option{{ID: "unlink", Label: "Unlink", Does: "this account stops answering as you", Level: asks.Approve}}}
Who: occ.Operator, Expires: occ.FormatTime(now.Add(time.Hour)), OnExpiry: "the account stays linked",
Options: []oc.Option{{ID: "unlink", Label: "Unlink", Does: "this account stops answering as you", Level: occ.Approve}}}
if err := h.askSelf(a, id, SelfAct{Do: "unlink", Kind: kind, Identity: id.Identity}); err != nil {
return nil, err
}
@@ -302,7 +303,7 @@ func (h *Holder) endSelfAsks(why string) int {
sort.Strings(keys)
n := 0
for _, k := range keys {
if err := h.closeAsk(k, asks.OutcomeCancelled, why, nil, "", nil); err == nil {
if err := h.closeAsk(k, occ.OutcomeCancelled, why, nil, "", nil); err == nil {
n++
}
}
@@ -6,7 +6,8 @@ import (
"testing"
"time"
"git.novox.be/novox/mesh-sdk/go/asks"
occ "git.novox.be/novox/mesh-contracts/go/clients/operatorchannelclient"
oc "git.novox.be/novox/mesh-contracts/go/seats/operatorchannel"
)
// novox/hq issue 383: a settings proposal's values are what the operator approves, so where the sender is proven
@@ -14,7 +15,7 @@ import (
// proposalAsk is a proposal as the controller composes it: the explanation with its values masked, the whole
// with them whole, and Details with the fingerprint and where to read it, which the note shows.
func proposalAsk(c *clock, id string) asks.Ask {
func proposalAsk(c *clock, id string) oc.Ask {
a := heldAsk(c, id)
a.Headline = "Set mounts on shanks?"
a.Options = a.Options[:2]
@@ -145,7 +146,7 @@ func TestAnAskWhoseWholeWordsCarryASecretIsRefused(t *testing.T) {
submit(t, h, "mesh-controller", a)
settle(h, c)
got := w.of("mesh-controller")
if len(tg.sends) != 0 || len(got) != 1 || got[0].Outcome != asks.OutcomeRefused || !strings.Contains(got[0].Words, "whole words carry secret") {
if len(tg.sends) != 0 || len(got) != 1 || got[0].Outcome != occ.OutcomeRefused || !strings.Contains(got[0].Words, "whole words carry secret") {
t.Errorf("%q: sent %d, told %+v", secret, len(tg.sends), got)
}
if _, kept := store.asks["mesh-controller.s-4"]; kept {
+2
View File
@@ -3,6 +3,8 @@ module messenger
go 1.26.0
require (
git.novox.be/novox/mesh-contracts/go/clients/operatorchannelclient v0.0.0-00010101000000-000000000000
git.novox.be/novox/mesh-contracts/go/seats/operatorchannel v0.0.0-00010101000000-000000000000
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261102191058-f02f142af128
github.com/nats-io/nats-server/v2 v2.11.17
github.com/nats-io/nats.go v1.54.0
+9 -8
View File
@@ -18,8 +18,9 @@ import (
"github.com/nats-io/nats.go"
"github.com/nats-io/nats.go/jetstream"
occ "git.novox.be/novox/mesh-contracts/go/clients/operatorchannelclient"
oc "git.novox.be/novox/mesh-contracts/go/seats/operatorchannel"
stdio "git.novox.be/novox/mesh-sdk/go"
"git.novox.be/novox/mesh-sdk/go/asks"
"git.novox.be/novox/mesh-sdk/go/catalogue"
"git.novox.be/novox/mesh-sdk/go/delivery"
"git.novox.be/novox/mesh-sdk/go/handacts"
@@ -121,13 +122,13 @@ func eventID() string {
}
// sayer raises and clears the keeper's conditions through the condition keeper, as every module says them.
func sayer(js jetstream.JetStream) func(asks.Condition) error {
return func(c asks.Condition) error {
subject, err := asks.ConditionSubject(keeper.Module)
func sayer(js jetstream.JetStream) func(oc.Condition) error {
return func(c oc.Condition) error {
subject, err := occ.ConditionSubject(keeper.Module)
if err != nil {
return err
}
data, err := asks.EncodeCondition(c)
data, err := occ.EncodeCondition(c)
if err != nil {
return err
}
@@ -200,14 +201,14 @@ func runKeeper(ctx context.Context, getenv func(string) string, logOut io.Writer
_, err := recorder.Record(handacts.Request{What: what, Why: why, Cause: cause})
return err
},
Warrant: func(id string) (*asks.Ask, *asks.Warrant, error) {
Warrant: func(id string) (*oc.Ask, *oc.Warrant, error) {
e, err := conn.Ask(asksBucket, id)
if err != nil || e == nil {
return nil, nil, err
}
var rec struct {
Ask *asks.Ask `json:"ask"`
Warrant *asks.Warrant `json:"warrant"`
Ask *oc.Ask `json:"ask"`
Warrant *oc.Warrant `json:"warrant"`
}
if err := json.Unmarshal(e.Value, &rec); err != nil {
return nil, nil, err
@@ -18,7 +18,8 @@ import (
"github.com/nats-io/nats.go"
"github.com/nats-io/nats.go/jetstream"
"git.novox.be/novox/mesh-sdk/go/asks"
occ "git.novox.be/novox/mesh-contracts/go/clients/operatorchannelclient"
oc "git.novox.be/novox/mesh-contracts/go/seats/operatorchannel"
"git.novox.be/novox/mesh-sdk/go/catalogue"
"git.novox.be/novox/mesh-sdk/go/process"
@@ -137,8 +138,8 @@ func TestTheBusModuleRunsWhole(t *testing.T) {
var mu sync.Mutex
view := keeper.FirstView{Revision: 1}
var acts []string
var ask *asks.Ask
var warrant *asks.Warrant
var ask *oc.Ask
var warrant *oc.Warrant
newKeeper := func() *keeper.Keeper {
return &keeper.Keeper{Bus: keeper.JetStream{JS: js, Snap: bussnap.Into(nc, snapshots)}, Store: store,
Emit: streamEmitter(js, account), Say: sayer(js),
@@ -149,7 +150,7 @@ func TestTheBusModuleRunsWhole(t *testing.T) {
acts = append(acts, cause+": "+what)
return nil
},
Warrant: func(string) (*asks.Ask, *asks.Warrant, error) { mu.Lock(); defer mu.Unlock(); return ask, warrant, nil },
Warrant: func(string) (*oc.Ask, *oc.Warrant, error) { mu.Lock(); defer mu.Unlock(); return ask, warrant, nil },
Logf: t.Logf}
}
run := func(k *keeper.Keeper) func() {
@@ -328,8 +329,8 @@ func TestTheBusModuleRunsWhole(t *testing.T) {
t.Fatal("dropped before the operator chose")
}
mu.Lock()
warrant = &asks.Warrant{Ask: a.ID, Asker: keeper.Module, Outcome: asks.OutcomeChosen, By: &asks.Person{Who: asks.Operator},
Option: "go", Label: "Remove", Level: asks.Destroy, At: time.Now(), AskDigest: a.Digest()}
warrant = &oc.Warrant{Ask: a.ID, Asker: keeper.Module, Outcome: occ.OutcomeChosen, By: &oc.Person{Who: occ.Operator},
Option: "go", Label: "Remove", Level: occ.Destroy, At: occ.FormatTime(time.Now()), AskDigest: occ.Digest(a)}
mu.Unlock()
k.ActOnRequests()
eventually(t, "dropped m drafts", saidHas("dropped m drafts"))
+11 -10
View File
@@ -8,8 +8,9 @@ import (
"strings"
"time"
occ "git.novox.be/novox/mesh-contracts/go/clients/operatorchannelclient"
oc "git.novox.be/novox/mesh-contracts/go/seats/operatorchannel"
stdio "git.novox.be/novox/mesh-sdk/go"
"git.novox.be/novox/mesh-sdk/go/asks"
"nats-tools/internal/keeper"
)
@@ -24,7 +25,7 @@ import (
type requestStore struct {
Put func(key string, r keeper.Request) error
Get func(key string) (*keeper.Request, error)
Ask func(a asks.Ask) error
Ask func(a oc.Ask) error
Now func() time.Time
Wait func(d time.Duration)
}
@@ -42,7 +43,7 @@ func RequestsInState() requestStore {
var r keeper.Request
return &r, json.Unmarshal(e.Value, &r)
},
Ask: func(a asks.Ask) error { return stdio.SeatPublish(asks.AskSubject(keeper.Module), a, a.ID) },
Ask: func(a oc.Ask) error { return stdio.SeatPublish(oc.AcceptSubjectAsk(keeper.Module), a, a.ID) },
Now: func() time.Time { return time.Now().UTC() },
Wait: time.Sleep,
}
@@ -55,22 +56,22 @@ func requestID(now time.Time) string {
}
// askFor is the operator's ask for a recreate or a drop of one entry, its approve option binding exactly that act.
func askFor(r keeper.Request, now time.Time) (asks.Ask, error) {
binds, err := asks.ActDigest(r.Act())
func askFor(r keeper.Request, now time.Time) (oc.Ask, error) {
binds, err := occ.ActDigest(r.Act())
if err != nil {
return asks.Ask{}, err
return oc.Ask{}, err
}
what := map[string]string{keeper.VerbDrop: "remove", keeper.VerbRecreate: "rebuild"}[r.Verb]
name := keeper.BusName(r.Module, r.Local)
return asks.Ask{ID: "bus-" + r.ID, Who: asks.Operator, Expires: now.Add(24 * time.Hour),
return oc.Ask{ID: "bus-" + r.ID, Who: occ.Operator, Expires: occ.FormatTime(now.Add(24 * time.Hour)),
OnExpiry: "nothing is removed and the entry stays as it is",
About: keeper.ConditionKey(map[string]string{keeper.VerbDrop: "orphaned", keeper.VerbRecreate: "drifted"}[r.Verb], r.Module, r.Local),
Headline: fmt.Sprintf("%s's kept data %s: %s it?", r.Module, r.Local, what),
Explanation: fmt.Sprintf("%s asks to %s %s on the bus, %s. A snapshot of it is taken first; its data is lost from the bus.",
r.Caller, what, name, r.Why),
Options: []asks.Option{
{ID: "go", Label: strings.ToUpper(what[:1]) + what[1:], Does: fmt.Sprintf("%s %s after a snapshot", what, name), Level: asks.Destroy, Binds: binds},
{ID: "keep", Label: "Keep it", Does: "nothing is removed", Level: asks.Acknowledge},
Options: []oc.Option{
{ID: "go", Label: strings.ToUpper(what[:1]) + what[1:], Does: fmt.Sprintf("%s %s after a snapshot", what, name), Level: occ.Destroy, Binds: binds},
{ID: "keep", Label: "Keep it", Does: "nothing is removed", Level: occ.Acknowledge},
}}, nil
}
+6 -5
View File
@@ -5,8 +5,9 @@ import (
"testing"
"time"
occ "git.novox.be/novox/mesh-contracts/go/clients/operatorchannelclient"
oc "git.novox.be/novox/mesh-contracts/go/seats/operatorchannel"
stdio "git.novox.be/novox/mesh-sdk/go"
"git.novox.be/novox/mesh-sdk/go/asks"
"nats-tools/internal/keeper"
)
@@ -16,7 +17,7 @@ import (
// first, binding exactly that act, and is not acted on by the tool.
func TestAPersonsRequestIsHandedToTheKeeperAsTheProvedCaller(t *testing.T) {
held := map[string]keeper.Request{}
var raised []asks.Ask
var raised []oc.Ask
rs := requestStore{
Put: func(key string, r keeper.Request) error {
if r.Verb == keeper.VerbCreate {
@@ -33,7 +34,7 @@ func TestAPersonsRequestIsHandedToTheKeeperAsTheProvedCaller(t *testing.T) {
}
return &r, nil
},
Ask: func(a asks.Ask) error { raised = append(raised, a); return nil },
Ask: func(a oc.Ask) error { raised = append(raised, a); return nil },
Now: func() time.Time { return time.Date(2026, 11, 4, 12, 0, 0, 0, time.UTC) },
Wait: func(time.Duration) {},
}
@@ -57,8 +58,8 @@ func TestAPersonsRequestIsHandedToTheKeeperAsTheProvedCaller(t *testing.T) {
if len(raised) != 1 || r.Ask != raised[0].ID || !r.Done.IsZero() {
t.Fatalf("raised %+v, held %+v: the ask raised, the request waiting for it", raised, r)
}
binds, _ := asks.ActDigest(asks.Act{"verb": "drop", "module": "m", "local": "old"})
if o, _ := raised[0].Option("go"); o.Level != asks.Destroy || o.Binds != binds || !strings.Contains(raised[0].Explanation, "snapshot") {
binds, _ := occ.ActDigest(occ.Act{"verb": "drop", "module": "m", "local": "old"})
if o, _ := occ.OptionOf(raised[0], "go"); o.Level != occ.Destroy || o.Binds != binds || !strings.Contains(raised[0].Explanation, "snapshot") {
t.Errorf("the ask is %+v: its option destroys exactly this drop, after a snapshot", raised[0])
}
if _, err := tools["nats_drop"].Run(map[string]any{"module": "m", "local": "old"}); err == nil || !strings.Contains(err.Error(), "says why") {
+3
View File
@@ -3,6 +3,9 @@ module nats-tools
go 1.26.0
require (
git.novox.be/novox/mesh-contracts/go/clients/operatorchannelclient v0.0.0-00010101000000-000000000000
git.novox.be/novox/mesh-contracts/go/seats/meshbroker v0.0.0-00010101000000-000000000000
git.novox.be/novox/mesh-contracts/go/seats/operatorchannel v0.0.0-00010101000000-000000000000
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261103221822-85ba0a9a70d8
github.com/klauspost/compress v1.20.0
github.com/nats-io/nats-server/v2 v2.11.17
+2 -2
View File
@@ -5,7 +5,7 @@ import (
"fmt"
"time"
"git.novox.be/novox/mesh-sdk/go/asks"
occ "git.novox.be/novox/mesh-contracts/go/clients/operatorchannelclient"
)
// FillPass reads the fill of every entry that refuses when full (ADR 0595 §6), raising `full` at the raise mark and
@@ -52,7 +52,7 @@ func (k *Keeper) FillPass() {
case !m.Full && most >= raise:
m.Full = true
k.emit(EventFull, m.Plan, held.Revision, fmt.Sprintf("%d%% of its cap, past the raise mark %d%%", most, raise), nil, nil)
k.raise(conditionFull, module, local, asks.SeverityWarning, "Kept data is filling up",
k.raise(conditionFull, module, local, occ.SeverityWarning, "Kept data is filling up",
"a larger cap in the module's manifest, or data removed",
fmt.Sprintf("%s of %s holds %d%% of its cap and refuses new data when full; it is cleared below %d%%", local, module, most, clear))
case m.Full && most < clear:
+12 -11
View File
@@ -10,7 +10,8 @@ import (
"sync"
"time"
"git.novox.be/novox/mesh-sdk/go/asks"
occ "git.novox.be/novox/mesh-contracts/go/clients/operatorchannelclient"
oc "git.novox.be/novox/mesh-contracts/go/seats/operatorchannel"
"git.novox.be/novox/mesh-sdk/go/catalogue"
"git.novox.be/novox/mesh-sdk/go/membership"
)
@@ -184,12 +185,12 @@ type Keeper struct {
Store Store
// Emit says one of the bus module's events; Say raises or clears a condition through the condition keeper.
Emit func(event string, body Event) error
Say func(asks.Condition) error
Say func(oc.Condition) error
// View asks the indexer's indexer_bus_state.
View func() (FirstView, error)
// Act records a hand act through the seat mesh-hand-acts; Warrant reads the router's record of an ask.
Act func(what, why, cause string) error
Warrant func(askID string) (*asks.Ask, *asks.Warrant, error)
Warrant func(askID string) (*oc.Ask, *oc.Warrant, error)
Now func() time.Time
Logf func(string, ...any)
// Raise and Clear are the fill marks in percent (ADR 0595 §6): settings of the bus module, 75 and 70 by default.
@@ -338,7 +339,7 @@ func (k *Keeper) Heard(a Accepted) {
m.Orphaned = true
held.Made[local] = m
k.emit(EventOrphaned, m.Plan, held.Revision, "no accepted manifest of "+a.Module+" asks for it now", nil, nil)
k.raise(conditionOrphaned, a.Module, local, asks.SeverityWarning, "Kept data no manifest asks for",
k.raise(conditionOrphaned, a.Module, local, occ.SeverityWarning, "Kept data no manifest asks for",
"the operator's drop of it, or a manifest asking for it again",
fmt.Sprintf("%s of %s is on the bus and no accepted manifest asks for it: it goes only by the bus module's drop, on your warrant (nats_drop), or a manifest asking for it again keeps it", BusName(a.Module, local), a.Module))
}
@@ -353,7 +354,7 @@ func (k *Keeper) makeEntry(held *Asked, e catalogue.BusStateEntry) {
if err != nil {
if m.Refused != err.Error() {
k.emit(EventRefused, Plan{Module: module, Local: e.Name, Storage: e.Storage, Template: e.Template}, held.Revision, err.Error(), nil, nil)
k.raise(conditionRefused, module, e.Name, asks.SeverityWarning, "The bus module refused to create kept data",
k.raise(conditionRefused, module, e.Name, occ.SeverityWarning, "The bus module refused to create kept data",
"a manifest that asks for it in a form the bus module makes",
fmt.Sprintf("%s of %s was not created: %v. The module gets no binding for it and fails at its start, naming it", e.Name, module, err))
}
@@ -394,7 +395,7 @@ func (k *Keeper) ensure(held *Asked, m *Made) {
}
m.Refused, m.Waiting = fmt.Sprintf("%s: %v", o.Stream(), err), ""
k.emit(EventRefused, m.Plan, held.Revision, m.Refused, asked, nil)
k.raise(conditionRefused, held.Module, m.Plan.Local, asks.SeverityWarning, "The bus module refused to create kept data",
k.raise(conditionRefused, held.Module, m.Plan.Local, occ.SeverityWarning, "The bus module refused to create kept data",
"a look at why the bus refused it",
fmt.Sprintf("%s of %s was not created: %s. The module gets no binding for it and fails at its start, naming it", m.Plan.Local, held.Module, m.Refused))
return
@@ -430,7 +431,7 @@ func (k *Keeper) ensure(held *Asked, m *Made) {
case drift != "" && drift != m.Drift:
m.Drift = drift
k.emit(EventDrifted, m.Plan, held.Revision, drift, asked, holds)
k.raise(conditionDrifted, held.Module, m.Plan.Local, asks.SeverityWarning, "Kept data differs from what was asked",
k.raise(conditionDrifted, held.Module, m.Plan.Local, occ.SeverityWarning, "Kept data differs from what was asked",
"a decision: change the manifest back, or the bus module's recreate on your warrant",
fmt.Sprintf("%s of %s differs from what its manifest asks, in a way that does not only grow: %s", m.Plan.Local, held.Module, drift))
case drift == "" && m.Drift != "":
@@ -529,10 +530,10 @@ func (k *Keeper) raise(kind, module, local, severity, headline, needs, explanati
if k.Say == nil {
return
}
c := asks.Condition{Key: ConditionKey(kind, module, local), Subject: asks.Scope{Scope: "module", ID: module},
State: asks.StateRaised, Severity: severity, Headline: headline, Needs: needs,
c := oc.Condition{Key: ConditionKey(kind, module, local), Subject: oc.ConditionSubject{Scope: "module", ID: module},
State: occ.StateRaised, Severity: severity, Headline: headline, Needs: needs,
Summary: explanation,
Explanation: asks.NeedsYou + needs + ". " + explanation,
Explanation: occ.NeedsYou + needs + ". " + explanation,
Resolved: "cleared by the bus module once it no longer holds"}
if err := k.Say(c); err != nil {
k.logf("the condition %s cannot be raised: %v", c.Key, err)
@@ -543,7 +544,7 @@ func (k *Keeper) clear(kind, module, local string) {
if k.Say == nil {
return
}
c := asks.Condition{Key: ConditionKey(kind, module, local), Subject: asks.Scope{Scope: "module", ID: module}, State: asks.StateCleared}
c := oc.Condition{Key: ConditionKey(kind, module, local), Subject: oc.ConditionSubject{Scope: "module", ID: module}, State: occ.StateCleared}
if err := k.Say(c); err != nil {
k.logf("the condition %s cannot be cleared: %v", c.Key, err)
}
+22 -21
View File
@@ -11,7 +11,8 @@ import (
"testing"
"time"
"git.novox.be/novox/mesh-sdk/go/asks"
occ "git.novox.be/novox/mesh-contracts/go/clients/operatorchannelclient"
oc "git.novox.be/novox/mesh-contracts/go/seats/operatorchannel"
"git.novox.be/novox/mesh-sdk/go/catalogue"
)
@@ -136,7 +137,7 @@ func (s *said) emit(event string, body Event) error {
return nil
}
func (s *said) say(c asks.Condition) error {
func (s *said) say(c oc.Condition) error {
s.mu.Lock()
defer s.mu.Unlock()
s.conds = append(s.conds, c.State+" "+c.Key)
@@ -462,9 +463,9 @@ func TestTheKeeperMakesWhatAVerdictAsksForAndSaysIt(t *testing.T) {
k.Heard(Accepted{Module: "m", Revision: 2, BusState: []catalogue.BusStateEntry{log}})
var acts []string
k.Act = func(what, why, cause string) error { acts = append(acts, cause+": "+what); return nil }
ask := asks.Ask{ID: "bus-1", Who: asks.Operator, Expires: k.now().Add(time.Hour)}
var w *asks.Warrant
k.Warrant = func(string) (*asks.Ask, *asks.Warrant, error) { return &ask, w, nil }
ask := oc.Ask{ID: "bus-1", Who: occ.Operator, Expires: occ.FormatTime(k.now().Add(time.Hour))}
var w *oc.Warrant
k.Warrant = func(string) (*oc.Ask, *oc.Warrant, error) { return &ask, w, nil }
put := func(r Request) Request {
raw, _ := json.Marshal(r)
_, _ = store.Put(StateRequests, r.ID, raw)
@@ -480,13 +481,13 @@ func TestTheKeeperMakesWhatAVerdictAsksForAndSaysIt(t *testing.T) {
if got := put(Request{ID: "r1", Verb: VerbDrop, Module: "m", Local: "drafts", Ask: "bus-1", Caller: "person.jo"}); !got.Done.IsZero() || len(bus.deleted) != 0 {
t.Fatalf("acted with no warrant: %+v, deleted %v", got, bus.deleted)
}
binds := func(act asks.Act) string { d, _ := asks.ActDigest(act); return d }
choose := func(act asks.Act) {
ask.Options = []asks.Option{{ID: "go", Label: "Remove", Level: asks.Destroy, Binds: binds(act)}}
w = &asks.Warrant{Ask: "bus-1", Asker: Module, Outcome: asks.OutcomeChosen, By: &asks.Person{Who: asks.Operator},
Option: "go", Label: "Remove", Level: asks.Destroy, At: k.now(), AskDigest: ask.Digest()}
binds := func(act occ.Act) string { d, _ := occ.ActDigest(act); return d }
choose := func(act occ.Act) {
ask.Options = []oc.Option{{ID: "go", Label: "Remove", Level: occ.Destroy, Binds: binds(act)}}
w = &oc.Warrant{Ask: "bus-1", Asker: Module, Outcome: occ.OutcomeChosen, By: &oc.Person{Who: occ.Operator},
Option: "go", Label: "Remove", Level: occ.Destroy, At: occ.FormatTime(k.now()), AskDigest: occ.Digest(ask)}
}
choose(asks.Act{"verb": "drop", "module": "m", "local": "log"})
choose(occ.Act{"verb": "drop", "module": "m", "local": "log"})
if got := put(Request{ID: "r1", Verb: VerbDrop, Module: "m", Local: "drafts", Ask: "bus-1", Caller: "person.jo"}); !strings.Contains(got.Error, "bound") || len(bus.deleted) != 0 {
t.Fatalf("acted on a warrant for another act: %+v, deleted %v", got, bus.deleted)
}
@@ -498,15 +499,15 @@ func TestTheKeeperMakesWhatAVerdictAsksForAndSaysIt(t *testing.T) {
}
}
refused(Request{ID: "r1a", Verb: VerbDrop, Module: "m", Local: "drafts", Caller: "person.jo"}, "names no ask")
choose(asks.Act{"verb": "drop", "module": "m", "local": "drafts"})
w.Level, w.Label, ask.Options[0].Level = asks.Approve, "Keep", asks.Approve
w.AskDigest = ask.Digest()
choose(occ.Act{"verb": "drop", "module": "m", "local": "drafts"})
w.Level, w.Label, ask.Options[0].Level = occ.Approve, "Keep", occ.Approve
w.AskDigest = occ.Digest(ask)
refused(Request{ID: "r1b", Verb: VerbDrop, Module: "m", Local: "drafts", Ask: "bus-1", Caller: "person.jo"}, "nothing is removed")
choose(asks.Act{"verb": "drop", "module": "m", "local": "drafts"})
choose(occ.Act{"verb": "drop", "module": "m", "local": "drafts"})
w.Asker = "mesh-grants"
refused(Request{ID: "r1c", Verb: VerbDrop, Module: "m", Local: "drafts", Ask: "bus-1", Caller: "person.jo"}, "no warrant")
refused(Request{ID: "r1d", Verb: VerbCreate, Module: "m", Local: "drafts", Caller: "person.jo"}, "no accepted manifest of m asks for drafts")
choose(asks.Act{"verb": "drop", "module": "m", "local": "drafts"})
choose(occ.Act{"verb": "drop", "module": "m", "local": "drafts"})
bus.refuse["snapshot"] = errors.New("no space")
refused(Request{ID: "r1e", Verb: VerbDrop, Module: "m", Local: "drafts", Ask: "bus-1", Caller: "person.jo"}, "the snapshot before drop failed, so nothing was removed")
delete(bus.refuse, "snapshot")
@@ -526,11 +527,11 @@ func TestTheKeeperMakesWhatAVerdictAsksForAndSaysIt(t *testing.T) {
store := k.Store.(*memStore)
k.Heard(Accepted{Module: "m", Revision: 1, BusState: []catalogue.BusStateEntry{log}})
k.Heard(Accepted{Module: "m", Revision: 2, BusState: []catalogue.BusStateEntry{entry("log", catalogue.StorageStream, catalogue.TemplateLog, map[string]string{"max-mib": "16"})}})
binds, _ := asks.ActDigest(asks.Act{"verb": "recreate", "module": "m", "local": "log"})
ask := asks.Ask{ID: "bus-2", Who: asks.Operator, Expires: k.now().Add(time.Hour), Options: []asks.Option{{ID: "go", Label: "Rebuild", Level: asks.Destroy, Binds: binds}}}
w := &asks.Warrant{Ask: "bus-2", Asker: Module, Outcome: asks.OutcomeChosen, By: &asks.Person{Who: asks.Operator},
Option: "go", Label: "Rebuild", Level: asks.Destroy, At: k.now(), AskDigest: ask.Digest()}
k.Warrant = func(string) (*asks.Ask, *asks.Warrant, error) { return &ask, w, nil }
binds, _ := occ.ActDigest(occ.Act{"verb": "recreate", "module": "m", "local": "log"})
ask := oc.Ask{ID: "bus-2", Who: occ.Operator, Expires: occ.FormatTime(k.now().Add(time.Hour)), Options: []oc.Option{{ID: "go", Label: "Rebuild", Level: occ.Destroy, Binds: binds}}}
w := &oc.Warrant{Ask: "bus-2", Asker: Module, Outcome: occ.OutcomeChosen, By: &oc.Person{Who: occ.Operator},
Option: "go", Label: "Rebuild", Level: occ.Destroy, At: occ.FormatTime(k.now()), AskDigest: occ.Digest(ask)}
k.Warrant = func(string) (*oc.Ask, *oc.Warrant, error) { return &ask, w, nil }
out.reset()
raw, _ := json.Marshal(Request{ID: "r3", Verb: VerbRecreate, Module: "m", Local: "log", Ask: "bus-2"})
_, _ = store.Put(StateRequests, "r3", raw)
+7 -7
View File
@@ -8,7 +8,7 @@ import (
"strings"
"time"
"git.novox.be/novox/mesh-sdk/go/asks"
occ "git.novox.be/novox/mesh-contracts/go/clients/operatorchannelclient"
)
// A person's request (design 25 § 12.3; CAT-N8): the bus module's tools hold no grant of the seat's acting verbs, so
@@ -46,9 +46,9 @@ type Request struct {
Snapshot string `json:"snapshot,omitempty"`
}
// Act is what an option of the operator's ask binds for a recreate or a drop: the verb and the entry (asks.ActDigest).
func (r Request) Act() asks.Act {
return asks.Act{"verb": r.Verb, "module": r.Module, "local": r.Local}
// Act is what an option of the operator's ask binds for a recreate or a drop: the verb and the entry (occ.ActDigest).
func (r Request) Act() occ.Act {
return occ.Act{"verb": r.Verb, "module": r.Module, "local": r.Local}
}
// ErrNoWarrant is a request that waits for the operator's word: kept, acted on once the warrant is held.
@@ -197,14 +197,14 @@ func (k *Keeper) warranted(r *Request) error {
if ask == nil || w == nil {
return ErrNoWarrant
}
o, err := w.For(Module, *ask)
o, err := occ.For(*w, Module, *ask)
if err != nil {
return fmt.Errorf("no warrant: %w", err)
}
if o.Level != asks.Destroy {
if o.Level != occ.Destroy {
return fmt.Errorf("the operator chose %s, so nothing is removed", o.Label)
}
return o.Performs(r.Act())
return occ.Performs(o, r.Act())
}
func (k *Keeper) record(r *Request, what string) {
+21 -20
View File
@@ -17,8 +17,9 @@ import (
"strings"
"time"
occ "git.novox.be/novox/mesh-contracts/go/clients/operatorchannelclient"
oc "git.novox.be/novox/mesh-contracts/go/seats/operatorchannel"
stdio "git.novox.be/novox/mesh-sdk/go"
"git.novox.be/novox/mesh-sdk/go/asks"
)
// Module is this module's name: the asker of its asks on the operator's channel.
@@ -51,29 +52,29 @@ func RoundOpening(at time.Time) time.Time {
// PassOperatorAsk is the ask on the operator's channel for a pass: composed from the pass alone, so the tool that
// publishes it and the keeper that reads its answer compose the same ask, with the same digest.
func PassOperatorAsk(round, node, next, reason, id string, at time.Time) asks.Ask {
func PassOperatorAsk(round, node, next, reason, id string, at time.Time) oc.Ask {
headline := "Upgrade round: pass " + node
if len([]rune(headline)) > asks.HeadlineLength {
if len([]rune(headline)) > occ.HeadlineLength {
headline = "Upgrade round: pass a node"
}
binds, _ := asks.ActDigest(passAct(round, node, reason, id))
return asks.Ask{
binds, _ := occ.ActDigest(passAct(round, node, reason, id))
return oc.Ask{
ID: id,
Headline: headline,
Explanation: fmt.Sprintf("The week's upgrade round %s stands at %s. Passing it lets %s and the nodes after it upgrade; "+
"%s is not upgraded in this round.\nThe reason given: %s", round, node, next, node, reason),
Options: []asks.Option{
{ID: "pass", Label: "Pass it", Does: "the round goes on past " + node, Level: asks.Approve, Binds: binds},
{ID: "keep", Label: "Keep it waiting", Does: "the round stays at " + node, Level: asks.Acknowledge},
Options: []oc.Option{
{ID: "pass", Label: "Pass it", Does: "the round goes on past " + node, Level: occ.Approve, Binds: binds},
{ID: "keep", Label: "Keep it waiting", Does: "the round stays at " + node, Level: occ.Acknowledge},
},
Who: asks.Operator,
Expires: at.UTC().Add(passLasts),
Who: occ.Operator,
Expires: occ.FormatTime(at.UTC().Add(passLasts)),
OnExpiry: "the round stays at " + node,
}
}
func passAct(round, node, reason, id string) asks.Act {
return asks.Act{"verb": Module + ".pass", "round": round, "node": node, "reason": reason, "ask": id}
func passAct(round, node, reason, id string) occ.Act {
return occ.Act{"verb": Module + ".pass", "round": round, "node": node, "reason": reason, "ask": id}
}
// roundView is one round as the bus holds it: the round, each node's place, and each node's run in it.
@@ -343,8 +344,8 @@ func (u *Upgrades) readPass(v *roundView, node string) (bool, error) {
return false, nil
}
var rec struct {
State string `json:"state"`
Warrant *asks.Warrant `json:"warrant"`
State string `json:"state"`
Warrant *oc.Warrant `json:"warrant"`
}
if err := json.Unmarshal(e.Value, &rec); err != nil {
return false, fmt.Errorf("the channel's record of the pass of %s: %w", node, err)
@@ -355,13 +356,13 @@ func (u *Upgrades) readPass(v *roundView, node string) (bool, error) {
w := *rec.Warrant
answer := PassDeclined
switch {
case w.Outcome == asks.OutcomeExpired:
case w.Outcome == occ.OutcomeExpired:
answer = PassExpired
case w.Outcome == asks.OutcomeChosen:
case w.Outcome == occ.OutcomeChosen:
ask := PassOperatorAsk(v.Round.Round, node, v.next(node), p.Reason, p.Ask, p.Asked)
option, err := w.For(Module, ask)
option, err := occ.For(w, Module, ask)
if err == nil {
err = option.Performs(passAct(v.Round.Round, node, p.Reason, p.Ask))
err = occ.Performs(option, passAct(v.Round.Round, node, p.Reason, p.Ask))
}
if err != nil {
return false, fmt.Errorf("the answer to the pass of %s is not the operator's to this pass: %w", node, err)
@@ -547,10 +548,10 @@ func AskPass(rounds, runs, asksState Bucket, round, node, reason string, now tim
}
a := Ask{ID: AskID(now), Node: next, Act: ActPass, At: now.UTC(), Round: round, Pass: node, Reason: reason}
opAsk := PassOperatorAsk(round, node, next, reason, a.ID, a.At)
if err := opAsk.Check(now); err != nil {
if err := occ.Check(opAsk, now); err != nil {
return nil, err
}
if err := seatPublish(asks.AskSubject(Module), opAsk, opAsk.ID); err != nil {
if err := seatPublish(oc.AcceptSubjectAsk(Module), opAsk, opAsk.ID); err != nil {
return nil, fmt.Errorf("the operator could not be asked: %w; nothing was passed", err)
}
if _, err := asksState.Create(NodeKey(next, a.ID), a); err != nil {
+13 -12
View File
@@ -9,8 +9,9 @@ import (
"testing"
"time"
occ "git.novox.be/novox/mesh-contracts/go/clients/operatorchannelclient"
oc "git.novox.be/novox/mesh-contracts/go/seats/operatorchannel"
stdio "git.novox.be/novox/mesh-sdk/go"
"git.novox.be/novox/mesh-sdk/go/asks"
)
// roundBed is three keepers over one bed's states, each with the order node-a, node-b, node-c.
@@ -175,9 +176,9 @@ func TestANodeOffAtItsTurnRunsAtItsNextStartAndAStalledTurnIsSaid(t *testing.T)
}
// warrant is the operator channel's record of an ask answered with option.
func warrant(a asks.Ask, option string, level asks.Level, at time.Time) json.RawMessage {
w := asks.Warrant{Ask: a.ID, Asker: Module, Outcome: asks.OutcomeChosen, Option: option, Level: level,
By: &asks.Person{Who: asks.Operator, Kind: "made-up-channel", Identity: "1", Verified: "user id"}, At: at, AskDigest: a.Digest()}
func warrant(a oc.Ask, option string, level string, at time.Time) json.RawMessage {
w := oc.Warrant{Ask: a.ID, Asker: Module, Outcome: occ.OutcomeChosen, Option: option, Level: level,
By: &oc.Person{Who: occ.Operator, Kind: "made-up-channel", Identity: "1", Verified: "user id"}, At: occ.FormatTime(at), AskDigest: occ.Digest(a)}
raw, _ := json.Marshal(map[string]any{"state": "chosen", "warrant": w})
return raw
}
@@ -190,11 +191,11 @@ func TestOnlyTheOperatorsApprovePassesANodeAndAPassNeedsAReason(t *testing.T) {
b.step(t, "node-b")
b.end(t, "node-b", "LoadState=loaded\nActiveState=failed\nResult=exit-code\nExecMainStatus=1\n")
var published []asks.Ask
var published []oc.Ask
var subjects []string
saved := seatPublish
seatPublish = func(subject string, body any, id string) error {
subjects, published = append(subjects, subject), append(published, body.(asks.Ask))
subjects, published = append(subjects, subject), append(published, body.(oc.Ask))
return nil
}
defer func() { seatPublish = saved }()
@@ -209,7 +210,7 @@ func TestOnlyTheOperatorsApprovePassesANodeAndAPassNeedsAReason(t *testing.T) {
t.Fatalf("the operator is asked on their channel: %v %v", subjects, out)
}
opAsk := published[0]
if err := opAsk.Check(b.now); err != nil || !strings.Contains(opAsk.Explanation, "its disk is being replaced") {
if err := occ.Check(opAsk, b.now); err != nil || !strings.Contains(opAsk.Explanation, "its disk is being replaced") {
t.Errorf("the ask: %v %+v", err, opAsk)
}
keys, _ := b.asks.Keys()
@@ -239,11 +240,11 @@ func TestOnlyTheOperatorsApprovePassesANodeAndAPassNeedsAReason(t *testing.T) {
// An answer to another ask, or to this one as it was not shown, passes nothing.
other := opAsk
other.Explanation = "something else"
record[opAsk.ID] = warrant(other, "pass", asks.Approve, b.now)
record[opAsk.ID] = warrant(other, "pass", occ.Approve, b.now)
if _, err := b.nodes["node-c"].RoundStep(); err == nil || len(b.runsOf("node-c")) != 0 {
t.Fatalf("a warrant for another ask: %v", err)
}
record[opAsk.ID] = warrant(opAsk, "pass", asks.Approve, b.now)
record[opAsk.ID] = warrant(opAsk, "pass", occ.Approve, b.now)
b.step(t, "node-c")
if st := b.state(t)["node-b"]; st.State != InRoundPassed || st.Pass.Answer != PassApproved {
t.Errorf("the operator's Approve passes node-b: %+v", st)
@@ -258,8 +259,8 @@ func TestAPassTheOperatorKeepsWaitingPassesNothing(t *testing.T) {
b.now = time.Date(2026, 10, 24, 4, 0, 0, 0, time.UTC)
b.step(t, "node-a") // node-a runs and never ends
saved := seatPublish
var opAsk asks.Ask
seatPublish = func(_ string, body any, _ string) error { opAsk = body.(asks.Ask); return nil }
var opAsk oc.Ask
seatPublish = func(_ string, body any, _ string) error { opAsk = body.(oc.Ask); return nil }
defer func() { seatPublish = saved }()
if _, err := AskPass(b.rounds, b.runs, b.asks, "2026-W43", "node-a", "it hangs", b.now); err != nil {
t.Fatal(err)
@@ -272,7 +273,7 @@ func TestAPassTheOperatorKeepsWaitingPassesNothing(t *testing.T) {
t.Fatal(err)
}
b.nodes["node-b"].AskRecord = func(string) (*stdio.StateEntry, error) {
return &stdio.StateEntry{Value: warrant(opAsk, "keep", asks.Acknowledge, b.now)}, nil
return &stdio.StateEntry{Value: warrant(opAsk, "keep", occ.Acknowledge, b.now)}, nil
}
b.step(t, "node-b")
if st := b.state(t); st["node-a"].Pass.Answer != PassDeclined || len(b.runsOf("node-b")) != 0 {
+2
View File
@@ -3,6 +3,8 @@ module pacman
go 1.26.0
require (
git.novox.be/novox/mesh-contracts/go/clients/operatorchannelclient v0.0.0-00010101000000-000000000000
git.novox.be/novox/mesh-contracts/go/seats/operatorchannel v0.0.0-00010101000000-000000000000
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261102191058-f02f142af128
github.com/nats-io/nats-server/v2 v2.11.17
)
+21 -21
View File
@@ -39,7 +39,7 @@ import (
"time"
"unicode/utf8"
"git.novox.be/novox/mesh-sdk/go/asks"
"git.novox.be/novox/mesh-sdk/go/channel"
)
// Kind is this channel's kind on both benches.
@@ -134,7 +134,7 @@ type Channel struct {
meAt time.Time
me string
meErr string
said asks.Standing
said channel.Standing
saidAt time.Time
lastSent time.Time
lastErr string
@@ -167,7 +167,7 @@ func (c *Channel) init() {
// buttons are a message's buttons as the API takes them: a label and the ticket, which must fit the 64
// bytes Telegram allows; one that does not is left out and said.
func (c *Channel) buttons(m asks.Message) [][2]string {
func (c *Channel) buttons(m channel.Message) [][2]string {
var out [][2]string
for _, b := range m.Buttons {
if len(b.Ticket) == 0 || len(b.Ticket) > 64 {
@@ -181,7 +181,7 @@ func (c *Channel) buttons(m asks.Message) [][2]string {
// chatFor is where a message goes: the linked account the router names, else the chat of the arrival it
// replies to. Neither is a permanent failure: nobody is linked, and nobody asked.
func (c *Channel) chatFor(m asks.Message) (int64, error) {
func (c *Channel) chatFor(m channel.Message) (int64, error) {
if m.To != "" {
id, err := strconv.ParseInt(m.To, 10, 64)
if err != nil {
@@ -208,14 +208,14 @@ func (c *Channel) Take(subject string, body json.RawMessage) error {
return nil
}
verb := parts[4]
var m asks.Message
var m channel.Message
if err := json.Unmarshal(body, &m); err != nil || m.Handle == "" {
c.Logf("[telegram] work on %s could not be read; taken and dropped", subject)
return nil
}
// The router's word on a tap, said where the tap was made: the tap's own acknowledgement (issue 372). A
// word of one line is that alone; one with more to it is also said in the chat, where it stays.
if verb == asks.Send && m.Reply != "" && !m.Secret && c.settleTap(m.Reply, toast(m)) && strings.TrimSpace(m.Body) == "" {
if verb == channel.Send && m.Reply != "" && !m.Secret && c.settleTap(m.Reply, toast(m)) && strings.TrimSpace(m.Body) == "" {
c.mu.Lock()
c.lastSent, c.lastErr = c.Now(), ""
c.delivered++
@@ -235,7 +235,7 @@ func (c *Channel) Take(subject string, body json.RawMessage) error {
// delete loses its buttons and says a newer message follows. A send that fails leaves the earlier one.
var place, earlier *Placed
switch {
case verb == asks.Edit:
case verb == channel.Edit:
if place, err = c.Store.Placed(m.Handle); err != nil {
return err // the state is away: offered again
}
@@ -320,8 +320,8 @@ func (c *Channel) fail(handle string, err error, forGood bool) {
c.failed++
c.mu.Unlock()
c.Logf("[telegram] a message was not delivered: %v", err)
f := asks.Failed{Handle: handle, Why: err.Error(), Permanent: forGood, At: c.Now()}
if perr := c.Publish(asks.IntakeSubject(asks.FailedWhat, Kind), f, "failed."+handle+"."+strconv.FormatInt(c.Now().UnixNano(), 10)); perr != nil {
f := channel.Failed{Handle: handle, Why: err.Error(), Permanent: forGood, At: c.Now()}
if perr := c.Publish(channel.IntakeSubject(channel.FailedWhat, Kind), f, "failed."+handle+"."+strconv.FormatInt(c.Now().UnixNano(), 10)); perr != nil {
c.Logf("[telegram] and the router could not be told: %v", perr)
}
}
@@ -332,8 +332,8 @@ func private(from *User, chat Chat) bool {
return from != nil && !from.IsBot && chat.Type == "private" && chat.ID == from.ID
}
func senderOf(u User) asks.Sender {
return asks.Sender{Identity: strconv.FormatInt(u.ID, 10), Display: u.Display(), Authenticated: true}
func senderOf(u User) channel.Sender {
return channel.Sender{Identity: strconv.FormatInt(u.ID, 10), Display: u.Display(), Authenticated: true}
}
// Handle takes one update.
@@ -368,8 +368,8 @@ func (c *Channel) Handle(u Update) {
c.mu.Lock()
c.taps[id] = cb.ID
c.mu.Unlock()
ch := asks.Choice{ID: id, Ticket: cb.Data, Sender: senderOf(cb.From), At: now}
if err := c.Publish(asks.IntakeSubject(asks.Chosen, Kind), ch, id); err != nil {
ch := channel.Choice{ID: id, Ticket: cb.Data, Sender: senderOf(cb.From), At: now}
if err := c.Publish(channel.IntakeSubject(channel.Chosen, Kind), ch, id); err != nil {
c.Logf("[telegram] a tap could not be said to the router: %v", err)
words := "The mesh could not take this answer now. Nothing was done. Try again in a minute."
c.settleTap(id, words)
@@ -386,8 +386,8 @@ func (c *Channel) Handle(u Update) {
c.remember(id, m.Chat.ID)
switch t := strings.TrimSpace(m.Text); {
case t == "/start" || strings.HasPrefix(t, "/start "):
l := asks.LinkAsked{ID: id, Sender: senderOf(*m.From), At: now}
if err := c.Publish(asks.IntakeSubject(asks.Link, Kind), l, id); err != nil {
l := channel.LinkAsked{ID: id, Sender: senderOf(*m.From), At: now}
if err := c.Publish(channel.IntakeSubject(channel.Link, Kind), l, id); err != nil {
c.reply(m.Chat.ID, "The mesh could not take this now. Try again in a minute.")
}
case codeShape.MatchString(t):
@@ -396,12 +396,12 @@ func (c *Channel) Handle(u Update) {
if err := c.Bot.Delete(m.Chat.ID, m.MessageID); err != nil {
c.Logf("[telegram] a code could not be deleted from the chat: %v", err)
}
raw, err := c.Prove(asks.ProofSubject(asks.CodeProof, Kind), asks.Code{Sender: senderOf(*m.From), Code: code, Purpose: "link"})
raw, err := c.Prove(channel.ProofSubject(channel.CodeProof, Kind), channel.Code{Sender: senderOf(*m.From), Code: code, Purpose: "link"})
if err != nil {
c.reply(m.Chat.ID, "The mesh could not check the code now. Try again in a minute.")
return
}
var a asks.ProofAnswer
var a channel.ProofAnswer
if json.Unmarshal(raw, &a) != nil || a.Words == "" {
a.Words = "The mesh's answer could not be read."
}
@@ -432,7 +432,7 @@ func (c *Channel) settleTap(arrival, words string) bool {
// toast is a message as a tap's acknowledgement shows it: its title and the first line of its body, cut at
// ToastLength.
func toast(m asks.Message) string {
func toast(m channel.Message) string {
t := strings.TrimSpace(m.Title)
if first, _, _ := strings.Cut(strings.TrimSpace(m.Body), "\n"); first != "" {
t += ": " + first
@@ -502,10 +502,10 @@ func (c *Channel) Poll(wait time.Duration) error {
}
// Standing is whether the channel can send now: the token works and the updates arrive.
func (c *Channel) Standing() asks.Standing {
func (c *Channel) Standing() channel.Standing {
c.init()
now := c.Now()
s := asks.Standing{EditsSilently: true, At: now}
s := channel.Standing{EditsSilently: true, At: now}
if _, err := c.Bot.token(); err != nil {
s.Why = err.Error()
return s
@@ -545,7 +545,7 @@ func (c *Channel) SayStanding() {
if same {
return
}
if err := c.Publish(asks.IntakeSubject(asks.StandingWhat, Kind), s, "standing."+strconv.FormatInt(s.At.UnixNano(), 10)); err != nil {
if err := c.Publish(channel.IntakeSubject(channel.StandingWhat, Kind), s, "standing."+strconv.FormatInt(s.At.UnixNano(), 10)); err != nil {
c.Logf("[telegram] could not say whether it can send: %v", err)
return
}

Some files were not shown because too many files have changed in this diff Show More