diff --git a/modules/claude-code/cmd/claude-code/forge.go b/modules/claude-code/cmd/claude-code/forge.go index fda20225f..9fda30875 100644 --- a/modules/claude-code/cmd/claude-code/forge.go +++ b/modules/claude-code/cmd/claude-code/forge.go @@ -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 + "."} } diff --git a/modules/claude-code/cmd/claude-code/forge_test.go b/modules/claude-code/cmd/claude-code/forge_test.go index 551ad774d..8b990770a 100644 --- a/modules/claude-code/cmd/claude-code/forge_test.go +++ b/modules/claude-code/cmd/claude-code/forge_test.go @@ -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" { diff --git a/modules/claude-code/cmd/claude-code/main.go b/modules/claude-code/cmd/claude-code/main.go index 61e23ad57..aa3fd7858 100644 --- a/modules/claude-code/cmd/claude-code/main.go +++ b/modules/claude-code/cmd/claude-code/main.go @@ -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 } diff --git a/modules/claude-code/cmd/claude-code/proposal_tools.go b/modules/claude-code/cmd/claude-code/proposal_tools.go index 84af45589..47758bf29 100644 --- a/modules/claude-code/cmd/claude-code/proposal_tools.go +++ b/modules/claude-code/cmd/claude-code/proposal_tools.go @@ -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 diff --git a/modules/claude-code/cmd/claude-code/proposals.go b/modules/claude-code/cmd/claude-code/proposals.go index 897c0a771..0d34884fb 100644 --- a/modules/claude-code/cmd/claude-code/proposals.go +++ b/modules/claude-code/cmd/claude-code/proposals.go @@ -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" diff --git a/modules/claude-code/cmd/claude-code/proposals_test.go b/modules/claude-code/cmd/claude-code/proposals_test.go index e1e199f16..eb376b91c 100644 --- a/modules/claude-code/cmd/claude-code/proposals_test.go +++ b/modules/claude-code/cmd/claude-code/proposals_test.go @@ -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) } } diff --git a/modules/claude-code/cmd/claude-code/questions.go b/modules/claude-code/cmd/claude-code/questions.go index 7247d3527..72ca7c17b 100644 --- a/modules/claude-code/cmd/claude-code/questions.go +++ b/modules/claude-code/cmd/claude-code/questions.go @@ -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 +} diff --git a/modules/claude-code/cmd/claude-code/questions_test.go b/modules/claude-code/cmd/claude-code/questions_test.go index 64d7dc34e..33094575d 100644 --- a/modules/claude-code/cmd/claude-code/questions_test.go +++ b/modules/claude-code/cmd/claude-code/questions_test.go @@ -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}) } diff --git a/modules/desk-channel/cmd/desk-channel/desk.go b/modules/desk-channel/cmd/desk-channel/desk.go index e1fc3eb19..1033ebe41 100644 --- a/modules/desk-channel/cmd/desk-channel/desk.go +++ b/modules/desk-channel/cmd/desk-channel/desk.go @@ -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 } diff --git a/modules/desk-channel/cmd/desk-channel/desk_test.go b/modules/desk-channel/cmd/desk-channel/desk_test.go index 336f4d037..27ecd0b67 100644 --- a/modules/desk-channel/cmd/desk-channel/desk_test.go +++ b/modules/desk-channel/cmd/desk-channel/desk_test.go @@ -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: 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: 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: <mend> 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 diff --git a/modules/mesh-config/cmd/mesh-config/main.go b/modules/mesh-config/cmd/mesh-config/main.go index 454126b51..43aa99c07 100644 --- a/modules/mesh-config/cmd/mesh-config/main.go +++ b/modules/mesh-config/cmd/mesh-config/main.go @@ -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) diff --git a/modules/mesh-config/cmd/mesh-config/ownerviews.go b/modules/mesh-config/cmd/mesh-config/ownerviews.go index 17900b6cb..5f91fcfc5 100644 --- a/modules/mesh-config/cmd/mesh-config/ownerviews.go +++ b/modules/mesh-config/cmd/mesh-config/ownerviews.go @@ -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. diff --git a/modules/mesh-config/cmd/mesh-config/ownerviews_test.go b/modules/mesh-config/cmd/mesh-config/ownerviews_test.go index 8f04825fd..73ae06fce 100644 --- a/modules/mesh-config/cmd/mesh-config/ownerviews_test.go +++ b/modules/mesh-config/cmd/mesh-config/ownerviews_test.go @@ -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: diff --git a/modules/mesh-config/cmd/mesh-config/proposals.go b/modules/mesh-config/cmd/mesh-config/proposals.go index d92247985..1435c6b73 100644 --- a/modules/mesh-config/cmd/mesh-config/proposals.go +++ b/modules/mesh-config/cmd/mesh-config/proposals.go @@ -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" diff --git a/modules/mesh-config/cmd/mesh-config/proposals_test.go b/modules/mesh-config/cmd/mesh-config/proposals_test.go index 65c668bbf..80a5d65cb 100644 --- a/modules/mesh-config/cmd/mesh-config/proposals_test.go +++ b/modules/mesh-config/cmd/mesh-config/proposals_test.go @@ -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) diff --git a/modules/mesh-config/cmd/mesh-config/pulls_test.go b/modules/mesh-config/cmd/mesh-config/pulls_test.go index 341b0bfb1..ffa94ccf7 100644 --- a/modules/mesh-config/cmd/mesh-config/pulls_test.go +++ b/modules/mesh-config/cmd/mesh-config/pulls_test.go @@ -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"} { diff --git a/modules/mesh-config/cmd/mesh-config/renames_test.go b/modules/mesh-config/cmd/mesh-config/renames_test.go index d3e705b55..f43618720 100644 --- a/modules/mesh-config/cmd/mesh-config/renames_test.go +++ b/modules/mesh-config/cmd/mesh-config/renames_test.go @@ -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) diff --git a/modules/mesh-config/cmd/mesh-config/retiring.go b/modules/mesh-config/cmd/mesh-config/retiring.go index e854737c1..fe08c9e47 100644 --- a/modules/mesh-config/cmd/mesh-config/retiring.go +++ b/modules/mesh-config/cmd/mesh-config/retiring.go @@ -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)...) diff --git a/modules/mesh-config/cmd/mesh-config/whole_integration_test.go b/modules/mesh-config/cmd/mesh-config/whole_integration_test.go index 4aeb182b9..75380dd72 100644 --- a/modules/mesh-config/cmd/mesh-config/whole_integration_test.go +++ b/modules/mesh-config/cmd/mesh-config/whole_integration_test.go @@ -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 diff --git a/modules/mesh-config/go.mod b/modules/mesh-config/go.mod index d781033f9..18f4fba22 100644 --- a/modules/mesh-config/go.mod +++ b/modules/mesh-config/go.mod @@ -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 ) diff --git a/modules/mesh-delivery-gate/cmd/mesh-delivery-gate/approval.go b/modules/mesh-delivery-gate/cmd/mesh-delivery-gate/approval.go index 77d9805f2..76b2cc0aa 100644 --- a/modules/mesh-delivery-gate/cmd/mesh-delivery-gate/approval.go +++ b/modules/mesh-delivery-gate/cmd/mesh-delivery-gate/approval.go @@ -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 diff --git a/modules/mesh-delivery-gate/cmd/mesh-delivery-gate/approval_test.go b/modules/mesh-delivery-gate/cmd/mesh-delivery-gate/approval_test.go index 9019225d0..20ca5ff68 100644 --- a/modules/mesh-delivery-gate/cmd/mesh-delivery-gate/approval_test.go +++ b/modules/mesh-delivery-gate/cmd/mesh-delivery-gate/approval_test.go @@ -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) + } + } +} diff --git a/modules/mesh-delivery-gate/cmd/mesh-delivery-gate/bus_test.go b/modules/mesh-delivery-gate/cmd/mesh-delivery-gate/bus_test.go index d4f8777a9..feba260b5 100644 --- a/modules/mesh-delivery-gate/cmd/mesh-delivery-gate/bus_test.go +++ b/modules/mesh-delivery-gate/cmd/mesh-delivery-gate/bus_test.go @@ -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..event., mesh.seat..event.). 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) } diff --git a/modules/mesh-delivery-gate/cmd/mesh-delivery-gate/conditions.go b/modules/mesh-delivery-gate/cmd/mesh-delivery-gate/conditions.go index d25132d99..30afaa813 100644 --- a/modules/mesh-delivery-gate/cmd/mesh-delivery-gate/conditions.go +++ b/modules/mesh-delivery-gate/cmd/mesh-delivery-gate/conditions.go @@ -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) diff --git a/modules/mesh-delivery-gate/cmd/mesh-delivery-gate/holder.go b/modules/mesh-delivery-gate/cmd/mesh-delivery-gate/holder.go index 74de79164..112b25ac9 100644 --- a/modules/mesh-delivery-gate/cmd/mesh-delivery-gate/holder.go +++ b/modules/mesh-delivery-gate/cmd/mesh-delivery-gate/holder.go @@ -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. diff --git a/modules/mesh-delivery-gate/cmd/mesh-delivery-gate/issue1275_test.go b/modules/mesh-delivery-gate/cmd/mesh-delivery-gate/issue1275_test.go index 73d88678f..29695fd80 100644 --- a/modules/mesh-delivery-gate/cmd/mesh-delivery-gate/issue1275_test.go +++ b/modules/mesh-delivery-gate/cmd/mesh-delivery-gate/issue1275_test.go @@ -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) } }}, diff --git a/modules/mesh-delivery-gate/cmd/mesh-delivery-gate/issue1348_test.go b/modules/mesh-delivery-gate/cmd/mesh-delivery-gate/issue1348_test.go index 98a9cac5b..dc2d0c809 100644 --- a/modules/mesh-delivery-gate/cmd/mesh-delivery-gate/issue1348_test.go +++ b/modules/mesh-delivery-gate/cmd/mesh-delivery-gate/issue1348_test.go @@ -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..: %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@` 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) diff --git a/modules/mesh-delivery-gate/cmd/mesh-delivery-gate/operator_test.go b/modules/mesh-delivery-gate/cmd/mesh-delivery-gate/operator_test.go index 6bc6d4821..4a76223e0 100644 --- a/modules/mesh-delivery-gate/cmd/mesh-delivery-gate/operator_test.go +++ b/modules/mesh-delivery-gate/cmd/mesh-delivery-gate/operator_test.go @@ -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} diff --git a/modules/mesh-delivery-gate/cmd/mesh-delivery-gate/ready.go b/modules/mesh-delivery-gate/cmd/mesh-delivery-gate/ready.go index 3f16589d8..4a9eb0f2d 100644 --- a/modules/mesh-delivery-gate/cmd/mesh-delivery-gate/ready.go +++ b/modules/mesh-delivery-gate/cmd/mesh-delivery-gate/ready.go @@ -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"` diff --git a/modules/mesh-delivery-gate/cmd/mesh-delivery-gate/ready_test.go b/modules/mesh-delivery-gate/cmd/mesh-delivery-gate/ready_test.go index bfd839a2d..e82ad027f 100644 --- a/modules/mesh-delivery-gate/cmd/mesh-delivery-gate/ready_test.go +++ b/modules/mesh-delivery-gate/cmd/mesh-delivery-gate/ready_test.go @@ -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) } }) diff --git a/modules/mesh-delivery-gate/cmd/mesh-delivery-gate/settle.go b/modules/mesh-delivery-gate/cmd/mesh-delivery-gate/settle.go index d8d977a6a..a2b334300 100644 --- a/modules/mesh-delivery-gate/cmd/mesh-delivery-gate/settle.go +++ b/modules/mesh-delivery-gate/cmd/mesh-delivery-gate/settle.go @@ -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 } diff --git a/modules/mesh-delivery-gate/cmd/mesh-delivery-gate/settle_test.go b/modules/mesh-delivery-gate/cmd/mesh-delivery-gate/settle_test.go index f4b31dd69..ea7563673 100644 --- a/modules/mesh-delivery-gate/cmd/mesh-delivery-gate/settle_test.go +++ b/modules/mesh-delivery-gate/cmd/mesh-delivery-gate/settle_test.go @@ -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) } } diff --git a/modules/mesh-delivery-gate/go.mod b/modules/mesh-delivery-gate/go.mod index c0544f393..a2cfa3061 100644 --- a/modules/mesh-delivery-gate/go.mod +++ b/modules/mesh-delivery-gate/go.mod @@ -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 diff --git a/modules/mesh-hand-acts/cmd/mesh-hand-acts/acts.go b/modules/mesh-hand-acts/cmd/mesh-hand-acts/acts.go index 83306052d..14a12a6dd 100644 --- a/modules/mesh-hand-acts/cmd/mesh-hand-acts/acts.go +++ b/modules/mesh-hand-acts/cmd/mesh-hand-acts/acts.go @@ -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 } diff --git a/modules/mesh-hand-acts/go.mod b/modules/mesh-hand-acts/go.mod index 897c9249c..b8b375ad2 100644 --- a/modules/mesh-hand-acts/go.mod +++ b/modules/mesh-hand-acts/go.mod @@ -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 ) diff --git a/modules/mesh-knowledge/cmd/mesh-knowledge/glance_test.go b/modules/mesh-knowledge/cmd/mesh-knowledge/glance_test.go index 9b2d57793..fb62c47c9 100644 --- a/modules/mesh-knowledge/cmd/mesh-knowledge/glance_test.go +++ b/modules/mesh-knowledge/cmd/mesh-knowledge/glance_test.go @@ -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) } } diff --git a/modules/mesh-knowledge/cmd/mesh-knowledge/kb.go b/modules/mesh-knowledge/cmd/mesh-knowledge/kb.go index dc2c82115..6bc28dd4e 100644 --- a/modules/mesh-knowledge/cmd/mesh-knowledge/kb.go +++ b/modules/mesh-knowledge/cmd/mesh-knowledge/kb.go @@ -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) } diff --git a/modules/mesh-knowledge/cmd/mesh-knowledge/kit_test.go b/modules/mesh-knowledge/cmd/mesh-knowledge/kit_test.go index eed0e18c6..781f0426c 100644 --- a/modules/mesh-knowledge/cmd/mesh-knowledge/kit_test.go +++ b/modules/mesh-knowledge/cmd/mesh-knowledge/kit_test.go @@ -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 diff --git a/modules/mesh-knowledge/cmd/mesh-knowledge/main.go b/modules/mesh-knowledge/cmd/mesh-knowledge/main.go index 80572703b..44da0d37d 100644 --- a/modules/mesh-knowledge/cmd/mesh-knowledge/main.go +++ b/modules/mesh-knowledge/cmd/mesh-knowledge/main.go @@ -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) } diff --git a/modules/mesh-knowledge/cmd/mesh-knowledge/operator.go b/modules/mesh-knowledge/cmd/mesh-knowledge/operator.go index 1fa0b8d0f..4c832c94f 100644 --- a/modules/mesh-knowledge/cmd/mesh-knowledge/operator.go +++ b/modules/mesh-knowledge/cmd/mesh-knowledge/operator.go @@ -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) } diff --git a/modules/mesh-knowledge/cmd/mesh-knowledge/retract.go b/modules/mesh-knowledge/cmd/mesh-knowledge/retract.go index e4537b84b..b1c035eeb 100644 --- a/modules/mesh-knowledge/cmd/mesh-knowledge/retract.go +++ b/modules/mesh-knowledge/cmd/mesh-knowledge/retract.go @@ -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) } diff --git a/modules/mesh-knowledge/cmd/mesh-knowledge/review_test.go b/modules/mesh-knowledge/cmd/mesh-knowledge/review_test.go index a32fe56da..572fcf2e2 100644 --- a/modules/mesh-knowledge/cmd/mesh-knowledge/review_test.go +++ b/modules/mesh-knowledge/cmd/mesh-knowledge/review_test.go @@ -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) } diff --git a/modules/mesh-knowledge/cmd/mesh-knowledge/rules.go b/modules/mesh-knowledge/cmd/mesh-knowledge/rules.go index 36e010fd4..df02183a9 100644 --- a/modules/mesh-knowledge/cmd/mesh-knowledge/rules.go +++ b/modules/mesh-knowledge/cmd/mesh-knowledge/rules.go @@ -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")...) { diff --git a/modules/mesh-knowledge/cmd/mesh-knowledge/withdraw_test.go b/modules/mesh-knowledge/cmd/mesh-knowledge/withdraw_test.go index ce847dbd9..2c43d9c2e 100644 --- a/modules/mesh-knowledge/cmd/mesh-knowledge/withdraw_test.go +++ b/modules/mesh-knowledge/cmd/mesh-knowledge/withdraw_test.go @@ -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) } diff --git a/modules/mesh-knowledge/go.mod b/modules/mesh-knowledge/go.mod index 1046d142d..dd54ff741 100644 --- a/modules/mesh-knowledge/go.mod +++ b/modules/mesh-knowledge/go.mod @@ -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 diff --git a/modules/mesh-mail/cmd/mesh-mail/ask_test.go b/modules/mesh-mail/cmd/mesh-mail/ask_test.go index 41f207598..a76140e18 100644 --- a/modules/mesh-mail/cmd/mesh-mail/ask_test.go +++ b/modules/mesh-mail/cmd/mesh-mail/ask_test.go @@ -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)) diff --git a/modules/mesh-mail/cmd/mesh-mail/claim_test.go b/modules/mesh-mail/cmd/mesh-mail/claim_test.go index 8b2e95352..7fe797f87 100644 --- a/modules/mesh-mail/cmd/mesh-mail/claim_test.go +++ b/modules/mesh-mail/cmd/mesh-mail/claim_test.go @@ -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)) diff --git a/modules/mesh-mail/cmd/mesh-mail/content_rule.go b/modules/mesh-mail/cmd/mesh-mail/content_rule.go index 2c0b5552d..70140b5c2 100644 --- a/modules/mesh-mail/cmd/mesh-mail/content_rule.go +++ b/modules/mesh-mail/cmd/mesh-mail/content_rule.go @@ -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 diff --git a/modules/mesh-mail/cmd/mesh-mail/main.go b/modules/mesh-mail/cmd/mesh-mail/main.go index 499bb8fcc..b66f928f0 100644 --- a/modules/mesh-mail/cmd/mesh-mail/main.go +++ b/modules/mesh-mail/cmd/mesh-mail/main.go @@ -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) } diff --git a/modules/mesh-mail/cmd/mesh-mail/send.go b/modules/mesh-mail/cmd/mesh-mail/send.go index 6dbbb3fe4..5ba502f5c 100644 --- a/modules/mesh-mail/cmd/mesh-mail/send.go +++ b/modules/mesh-mail/cmd/mesh-mail/send.go @@ -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) diff --git a/modules/mesh-mail/cmd/mesh-mail/send_test.go b/modules/mesh-mail/cmd/mesh-mail/send_test.go index ea27bbc43..256ad099c 100644 --- a/modules/mesh-mail/cmd/mesh-mail/send_test.go +++ b/modules/mesh-mail/cmd/mesh-mail/send_test.go @@ -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) } } diff --git a/modules/mesh-mail/go.mod b/modules/mesh-mail/go.mod index 5fdf72725..ac8c8c22e 100644 --- a/modules/mesh-mail/go.mod +++ b/modules/mesh-mail/go.mod @@ -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 diff --git a/modules/mesh-provisioner/cmd/mesh-provisioner/holder.go b/modules/mesh-provisioner/cmd/mesh-provisioner/holder.go index 5d8fa5dc9..29adacdd0 100644 --- a/modules/mesh-provisioner/cmd/mesh-provisioner/holder.go +++ b/modules/mesh-provisioner/cmd/mesh-provisioner/holder.go @@ -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) } diff --git a/modules/mesh-provisioner/cmd/mesh-provisioner/holder_integration_test.go b/modules/mesh-provisioner/cmd/mesh-provisioner/holder_integration_test.go index 91e53cc2f..ea65433ad 100644 --- a/modules/mesh-provisioner/cmd/mesh-provisioner/holder_integration_test.go +++ b/modules/mesh-provisioner/cmd/mesh-provisioner/holder_integration_test.go @@ -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 } diff --git a/modules/mesh-provisioner/cmd/mesh-provisioner/holder_test.go b/modules/mesh-provisioner/cmd/mesh-provisioner/holder_test.go index f99f945a1..a7134b70b 100644 --- a/modules/mesh-provisioner/cmd/mesh-provisioner/holder_test.go +++ b/modules/mesh-provisioner/cmd/mesh-provisioner/holder_test.go @@ -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) diff --git a/modules/mesh-provisioner/cmd/mesh-provisioner/main.go b/modules/mesh-provisioner/cmd/mesh-provisioner/main.go index 9220412d2..32ed6e6b4 100644 --- a/modules/mesh-provisioner/cmd/mesh-provisioner/main.go +++ b/modules/mesh-provisioner/cmd/mesh-provisioner/main.go @@ -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) } diff --git a/modules/mesh-provisioner/go.mod b/modules/mesh-provisioner/go.mod index e16ce0017..fcb0f487f 100644 --- a/modules/mesh-provisioner/go.mod +++ b/modules/mesh-provisioner/go.mod @@ -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 ) diff --git a/modules/mesh-vault/cmd/mesh-vault/enrolment.go b/modules/mesh-vault/cmd/mesh-vault/enrolment.go index 17f47978a..87f694711 100644 --- a/modules/mesh-vault/cmd/mesh-vault/enrolment.go +++ b/modules/mesh-vault/cmd/mesh-vault/enrolment.go @@ -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. diff --git a/modules/mesh-vault/cmd/mesh-vault/enrolment_test.go b/modules/mesh-vault/cmd/mesh-vault/enrolment_test.go index 420a4ac78..031b46299 100644 --- a/modules/mesh-vault/cmd/mesh-vault/enrolment_test.go +++ b/modules/mesh-vault/cmd/mesh-vault/enrolment_test.go @@ -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()) diff --git a/modules/mesh-vault/cmd/mesh-vault/main.go b/modules/mesh-vault/cmd/mesh-vault/main.go index 1a1345589..713b7956b 100644 --- a/modules/mesh-vault/cmd/mesh-vault/main.go +++ b/modules/mesh-vault/cmd/mesh-vault/main.go @@ -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) { diff --git a/modules/mesh-vault/cmd/mesh-vault/placementview.go b/modules/mesh-vault/cmd/mesh-vault/placementview.go index e14e766a8..d69e6bf1b 100644 --- a/modules/mesh-vault/cmd/mesh-vault/placementview.go +++ b/modules/mesh-vault/cmd/mesh-vault/placementview.go @@ -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. diff --git a/modules/mesh-vault/cmd/mesh-vault/placementview_test.go b/modules/mesh-vault/cmd/mesh-vault/placementview_test.go index 2195d623a..99d26a8ca 100644 --- a/modules/mesh-vault/cmd/mesh-vault/placementview_test.go +++ b/modules/mesh-vault/cmd/mesh-vault/placementview_test.go @@ -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 diff --git a/modules/mesh-vault/cmd/mesh-vault/signing.go b/modules/mesh-vault/cmd/mesh-vault/signing.go index 705e91b5a..80faad1f9 100644 --- a/modules/mesh-vault/cmd/mesh-vault/signing.go +++ b/modules/mesh-vault/cmd/mesh-vault/signing.go @@ -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 } diff --git a/modules/mesh-vault/go.mod b/modules/mesh-vault/go.mod index 85849672e..0cd46cff1 100644 --- a/modules/mesh-vault/go.mod +++ b/modules/mesh-vault/go.mod @@ -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 diff --git a/modules/messenger/cmd/messenger/answers.go b/modules/messenger/cmd/messenger/answers.go index ab0159f2c..5856d935c 100644 --- a/modules/messenger/cmd/messenger/answers.go +++ b/modules/messenger/cmd/messenger/answers.go @@ -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)) diff --git a/modules/messenger/cmd/messenger/answers_test.go b/modules/messenger/cmd/messenger/answers_test.go index 1bb942bf8..411409ae6 100644 --- a/modules/messenger/cmd/messenger/answers_test.go +++ b/modules/messenger/cmd/messenger/answers_test.go @@ -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 { diff --git a/modules/messenger/cmd/messenger/asks.go b/modules/messenger/cmd/messenger/asks.go index 2e9e61800..7d5a5618e 100644 --- a/modules/messenger/cmd/messenger/asks.go +++ b/modules/messenger/cmd/messenger/asks.go @@ -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() diff --git a/modules/messenger/cmd/messenger/asks_events_test.go b/modules/messenger/cmd/messenger/asks_events_test.go index 1f0f96a73..afb3d3e60 100644 --- a/modules/messenger/cmd/messenger/asks_events_test.go +++ b/modules/messenger/cmd/messenger/asks_events_test.go @@ -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" { diff --git a/modules/messenger/cmd/messenger/bus_test.go b/modules/messenger/cmd/messenger/bus_test.go index c42fb2788..ba37872ea 100644 --- a/modules/messenger/cmd/messenger/bus_test.go +++ b/modules/messenger/cmd/messenger/bus_test.go @@ -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 { diff --git a/modules/messenger/cmd/messenger/condition.go b/modules/messenger/cmd/messenger/condition.go index 60233d1bf..1fb6292e1 100644 --- a/modules/messenger/cmd/messenger/condition.go +++ b/modules/messenger/cmd/messenger/condition.go @@ -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 } diff --git a/modules/messenger/cmd/messenger/condition_bus_test.go b/modules/messenger/cmd/messenger/condition_bus_test.go index 9057315aa..849fee136 100644 --- a/modules/messenger/cmd/messenger/condition_bus_test.go +++ b/modules/messenger/cmd/messenger/condition_bus_test.go @@ -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() diff --git a/modules/messenger/cmd/messenger/condition_intake_test.go b/modules/messenger/cmd/messenger/condition_intake_test.go index e3a1899d2..84ff9d8ea 100644 --- a/modules/messenger/cmd/messenger/condition_intake_test.go +++ b/modules/messenger/cmd/messenger/condition_intake_test.go @@ -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) } diff --git a/modules/messenger/cmd/messenger/content.go b/modules/messenger/cmd/messenger/content.go index 2c0b5552d..70140b5c2 100644 --- a/modules/messenger/cmd/messenger/content.go +++ b/modules/messenger/cmd/messenger/content.go @@ -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 diff --git a/modules/messenger/cmd/messenger/glance_test.go b/modules/messenger/cmd/messenger/glance_test.go index 223caeb97..609c8789f 100644 --- a/modules/messenger/cmd/messenger/glance_test.go +++ b/modules/messenger/cmd/messenger/glance_test.go @@ -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.", diff --git a/modules/messenger/cmd/messenger/held_test.go b/modules/messenger/cmd/messenger/held_test.go index 1efae214f..20384f0f4 100644 --- a/modules/messenger/cmd/messenger/held_test.go +++ b/modules/messenger/cmd/messenger/held_test.go @@ -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) } } diff --git a/modules/messenger/cmd/messenger/holder.go b/modules/messenger/cmd/messenger/holder.go index 39226e865..30fe6dbdc 100644 --- a/modules/messenger/cmd/messenger/holder.go +++ b/modules/messenger/cmd/messenger/holder.go @@ -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 diff --git a/modules/messenger/cmd/messenger/link.go b/modules/messenger/cmd/messenger/link.go index 00beb17b4..2f47cc16e 100644 --- a/modules/messenger/cmd/messenger/link.go +++ b/modules/messenger/cmd/messenger/link.go @@ -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) } diff --git a/modules/messenger/cmd/messenger/main.go b/modules/messenger/cmd/messenger/main.go index f237289a1..62ee30dc3 100644 --- a/modules/messenger/cmd/messenger/main.go +++ b/modules/messenger/cmd/messenger/main.go @@ -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}) } } diff --git a/modules/messenger/cmd/messenger/manifest_test.go b/modules/messenger/cmd/messenger/manifest_test.go index 1a631605d..b5272447a 100644 --- a/modules/messenger/cmd/messenger/manifest_test.go +++ b/modules/messenger/cmd/messenger/manifest_test.go @@ -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{ diff --git a/modules/messenger/cmd/messenger/onemessage_test.go b/modules/messenger/cmd/messenger/onemessage_test.go index 56d16d1c3..2cb1c9f5f 100644 --- a/modules/messenger/cmd/messenger/onemessage_test.go +++ b/modules/messenger/cmd/messenger/onemessage_test.go @@ -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++ { diff --git a/modules/messenger/cmd/messenger/replaced_test.go b/modules/messenger/cmd/messenger/replaced_test.go index 467a9c0c7..4ffd5c617 100644 --- a/modules/messenger/cmd/messenger/replaced_test.go +++ b/modules/messenger/cmd/messenger/replaced_test.go @@ -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 { diff --git a/modules/messenger/cmd/messenger/restart_test.go b/modules/messenger/cmd/messenger/restart_test.go index cb8855567..3cf2d3a48 100644 --- a/modules/messenger/cmd/messenger/restart_test.go +++ b/modules/messenger/cmd/messenger/restart_test.go @@ -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++ } } diff --git a/modules/messenger/cmd/messenger/review_test.go b/modules/messenger/cmd/messenger/review_test.go index d60ff535e..73e5b85ce 100644 --- a/modules/messenger/cmd/messenger/review_test.go +++ b/modules/messenger/cmd/messenger/review_test.go @@ -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 { diff --git a/modules/messenger/cmd/messenger/seatchannel.go b/modules/messenger/cmd/messenger/seatchannel.go index 671b892a2..afbfceab5 100644 --- a/modules/messenger/cmd/messenger/seatchannel.go +++ b/modules/messenger/cmd/messenger/seatchannel.go @@ -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 { diff --git a/modules/messenger/cmd/messenger/selfact.go b/modules/messenger/cmd/messenger/selfact.go index b35024207..0f2966e5c 100644 --- a/modules/messenger/cmd/messenger/selfact.go +++ b/modules/messenger/cmd/messenger/selfact.go @@ -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++ } } diff --git a/modules/messenger/cmd/messenger/whole_test.go b/modules/messenger/cmd/messenger/whole_test.go index 1eaef2e64..4a94e9212 100644 --- a/modules/messenger/cmd/messenger/whole_test.go +++ b/modules/messenger/cmd/messenger/whole_test.go @@ -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 { diff --git a/modules/messenger/go.mod b/modules/messenger/go.mod index 4ad8ac16a..1304af16f 100644 --- a/modules/messenger/go.mod +++ b/modules/messenger/go.mod @@ -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 diff --git a/modules/nats/cmd/nats-tools/keep.go b/modules/nats/cmd/nats-tools/keep.go index 9288bb08c..c9bd2e39c 100644 --- a/modules/nats/cmd/nats-tools/keep.go +++ b/modules/nats/cmd/nats-tools/keep.go @@ -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 diff --git a/modules/nats/cmd/nats-tools/keeper_integration_test.go b/modules/nats/cmd/nats-tools/keeper_integration_test.go index da61f2e54..b60cde404 100644 --- a/modules/nats/cmd/nats-tools/keeper_integration_test.go +++ b/modules/nats/cmd/nats-tools/keeper_integration_test.go @@ -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")) diff --git a/modules/nats/cmd/nats-tools/requests.go b/modules/nats/cmd/nats-tools/requests.go index 3b4a879db..2f7084810 100644 --- a/modules/nats/cmd/nats-tools/requests.go +++ b/modules/nats/cmd/nats-tools/requests.go @@ -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 } diff --git a/modules/nats/cmd/nats-tools/requests_test.go b/modules/nats/cmd/nats-tools/requests_test.go index fdda6566f..5c4802173 100644 --- a/modules/nats/cmd/nats-tools/requests_test.go +++ b/modules/nats/cmd/nats-tools/requests_test.go @@ -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") { diff --git a/modules/nats/go.mod b/modules/nats/go.mod index 609dc37e5..654c62910 100644 --- a/modules/nats/go.mod +++ b/modules/nats/go.mod @@ -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 diff --git a/modules/nats/internal/keeper/fill.go b/modules/nats/internal/keeper/fill.go index b28806b89..9a8ee9345 100644 --- a/modules/nats/internal/keeper/fill.go +++ b/modules/nats/internal/keeper/fill.go @@ -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: diff --git a/modules/nats/internal/keeper/keeper.go b/modules/nats/internal/keeper/keeper.go index 5a69c76a5..1eb665c5a 100644 --- a/modules/nats/internal/keeper/keeper.go +++ b/modules/nats/internal/keeper/keeper.go @@ -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) } diff --git a/modules/nats/internal/keeper/keeper_test.go b/modules/nats/internal/keeper/keeper_test.go index 6c273871c..001e3ba43 100644 --- a/modules/nats/internal/keeper/keeper_test.go +++ b/modules/nats/internal/keeper/keeper_test.go @@ -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) diff --git a/modules/nats/internal/keeper/requests.go b/modules/nats/internal/keeper/requests.go index b36900026..e27757a38 100644 --- a/modules/nats/internal/keeper/requests.go +++ b/modules/nats/internal/keeper/requests.go @@ -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) { diff --git a/modules/pacman/cmd/pacman-tools/rounds.go b/modules/pacman/cmd/pacman-tools/rounds.go index a606531b2..01d2d5884 100644 --- a/modules/pacman/cmd/pacman-tools/rounds.go +++ b/modules/pacman/cmd/pacman-tools/rounds.go @@ -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 { diff --git a/modules/pacman/cmd/pacman-tools/rounds_test.go b/modules/pacman/cmd/pacman-tools/rounds_test.go index 26a54748b..1aa1f6cd1 100644 --- a/modules/pacman/cmd/pacman-tools/rounds_test.go +++ b/modules/pacman/cmd/pacman-tools/rounds_test.go @@ -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 { diff --git a/modules/pacman/go.mod b/modules/pacman/go.mod index b242c8547..b2622389b 100644 --- a/modules/pacman/go.mod +++ b/modules/pacman/go.mod @@ -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 ) diff --git a/modules/telegram/cmd/telegram/channel.go b/modules/telegram/cmd/telegram/channel.go index b890185cd..847d74c87 100644 --- a/modules/telegram/cmd/telegram/channel.go +++ b/modules/telegram/cmd/telegram/channel.go @@ -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 } diff --git a/modules/telegram/cmd/telegram/channel_test.go b/modules/telegram/cmd/telegram/channel_test.go index 06c961218..b08c0d45d 100644 --- a/modules/telegram/cmd/telegram/channel_test.go +++ b/modules/telegram/cmd/telegram/channel_test.go @@ -15,7 +15,7 @@ import ( "time" "unicode/utf8" - "git.novox.be/novox/mesh-sdk/go/asks" + "git.novox.be/novox/mesh-sdk/go/channel" ) const goodToken = "123456789:AAEhBP0av28P4XFQnIuR-o-7Xnz1kkUzW3g" @@ -184,15 +184,15 @@ func (s *said) waited() { } } -func work(verb string, m asks.Message) (string, json.RawMessage) { +func work(verb string, m channel.Message) (string, json.RawMessage) { raw, _ := json.Marshal(m) - return asks.ChannelSubject(verb, Kind), raw + return channel.ChannelSubject(verb, Kind), raw } func TestAQuestionIsShownWithItsTicketsAndRingsAndAWarningIsSilent(t *testing.T) { c, f, _, st := newChannel(t, goodToken) - subject, body := work(asks.Show, asks.Message{Handle: "m1", Title: "Delivery of hq held", Body: "Needs you: release it.", - To: "42", Buttons: []asks.Button{{Label: "Release", Ticket: "0123456789abcdef0123456789abcdef"}}}) + subject, body := work(channel.Show, channel.Message{Handle: "m1", Title: "Delivery of hq held", Body: "Needs you: release it.", + To: "42", Buttons: []channel.Button{{Label: "Release", Ticket: "0123456789abcdef0123456789abcdef"}}}) if err := c.Take(subject, body); err != nil { t.Fatal(err) } @@ -212,12 +212,12 @@ func TestAQuestionIsShownWithItsTicketsAndRingsAndAWarningIsSilent(t *testing.T) if st.placed["m1"].ID == 0 { t.Error("where it is was not kept") } - subject, body = work(asks.Send, asks.Message{Handle: "m2", Title: "Resolved: hq works again", To: "42"}) + subject, body = work(channel.Send, channel.Message{Handle: "m2", Title: "Resolved: hq works again", To: "42"}) _ = c.Take(subject, body) if f.of("sendMessage")[1].Body["disable_notification"] != true { t.Error("a clearing rang (D4)") } - subject, body = work(asks.Send, asks.Message{Handle: "m3", Title: "Urgent: ace silent", Urgent: true, To: "42"}) + subject, body = work(channel.Send, channel.Message{Handle: "m3", Title: "Urgent: ace silent", Urgent: true, To: "42"}) _ = c.Take(subject, body) if f.of("sendMessage")[2].Body["disable_notification"] != false { t.Error("an urgent message was silent (D4)") @@ -226,7 +226,7 @@ func TestAQuestionIsShownWithItsTicketsAndRingsAndAWarningIsSilent(t *testing.T) func TestALongMessageIsCutAndTheCutSaid(t *testing.T) { c, f, _, _ := newChannel(t, goodToken) - subject, body := work(asks.Send, asks.Message{Handle: "m1", Title: "t", Body: strings.Repeat("x", 5000), To: "42"}) + subject, body := work(channel.Send, channel.Message{Handle: "m1", Title: "t", Body: strings.Repeat("x", 5000), To: "42"}) if err := c.Take(subject, body); err != nil { t.Fatal(err) } @@ -243,10 +243,10 @@ func TestALongMessageIsCutAndTheCutSaid(t *testing.T) { // nor injects. The tap on it is still compared by the plain words, which is what Telegram gives back. func TestAMessageIsRenderedInHTMLWithItsMarkupEscaped(t *testing.T) { c, f, s, _ := newChannel(t, goodToken) - subject, body := work(asks.Show, asks.Message{Handle: "q1", Title: "Set mounts on & co?", + subject, body := work(channel.Show, channel.Message{Handle: "q1", Title: "Set mounts on & co?", Body: "Changes two settings.\n+ shares: media=/storage/media\n~ sources: a=b (was: c)", Note: "Asked by mesh-controller at 15:11.\nFingerprint 1234 5678 9abc.", - To: "42", Buttons: []asks.Button{{Label: "Approve", Ticket: "tkt"}}}) + To: "42", Buttons: []channel.Button{{Label: "Approve", Ticket: "tkt"}}}) if err := c.Take(subject, body); err != nil { t.Fatal(err) } @@ -264,7 +264,7 @@ func TestAMessageIsRenderedInHTMLWithItsMarkupEscaped(t *testing.T) { Message: &Msg{MessageID: shown, Chat: Chat{ID: 42, Type: "private"}, Text: "Set mounts on & co?\nChanges two settings.\n+ shares: media=/storage/media\n~ sources: a=b (was: c)\n" + "Asked by mesh-controller at 15:11.\nFingerprint 1234 5678 9abc."}}}) - if got := s.of(asks.IntakeSubject(asks.Chosen, Kind)); len(got) != 1 { + if got := s.of(channel.IntakeSubject(channel.Chosen, Kind)); len(got) != 1 { t.Fatalf("the tap on the rendered message was not taken: %s", got) } // Words changed by another holder of the token are still told apart. @@ -272,11 +272,11 @@ func TestAMessageIsRenderedInHTMLWithItsMarkupEscaped(t *testing.T) { Message: &Msg{MessageID: shown, Chat: Chat{ID: 42, Type: "private"}, Text: "Set mounts on & co?\nChanges three settings.\n+ shares: media=/storage/media\n~ sources: a=b (was: c)\n" + "Asked by mesh-controller at 15:11.\nFingerprint 1234 5678 9abc."}}}) - if got := s.of(asks.IntakeSubject(asks.Chosen, Kind)); len(got) != 1 { + if got := s.of(channel.IntakeSubject(channel.Chosen, Kind)); len(got) != 1 { t.Fatalf("a tap on changed words was taken: %s", got) } // The edit says what was chosen, one line with the headline, and is in the same mode. - subject, body = work(asks.Edit, asks.Message{Handle: "q1", Title: "You chose Approve at 15:06 on Telegram · Set mounts on & co?", + subject, body = work(channel.Edit, channel.Message{Handle: "q1", Title: "You chose Approve at 15:06 on Telegram · Set mounts on & co?", Note: "Told to mesh-controller, which acts on it itself.", To: "42"}) if err := c.Take(subject, body); err != nil { t.Fatal(err) @@ -302,7 +302,7 @@ func TestATapOnAMessageShownBeforeIsStillTaken(t *testing.T) { } c.Handle(Update{UpdateID: 1, Callback: &Callback{ID: "cb1", Data: "tkt", From: User{ID: 42, FirstName: "Jo"}, Message: &Msg{MessageID: 9, Chat: Chat{ID: 42, Type: "private"}, Text: words}}}) - if got := s.of(asks.IntakeSubject(asks.Chosen, Kind)); len(got) != 1 { + if got := s.of(channel.IntakeSubject(channel.Chosen, Kind)); len(got) != 1 { t.Fatalf("the tap on a message shown before was not taken: %s", got) } } @@ -313,8 +313,8 @@ func TestAnEditToTheSameTextIsASuccessAndAGoneMessageIsSentAgain(t *testing.T) { f.answer["editMessageText"] = func(map[string]any) (int, string) { return 400, `{"ok":false,"error_code":400,"description":"Bad Request: message is not modified"}` } - subject, body := work(asks.Edit, asks.Message{Handle: "m1", Title: "same", To: "42"}) - if err := c.Take(subject, body); err != nil || len(f.of("sendMessage")) != 0 || len(s.of(asks.IntakeSubject(asks.FailedWhat, Kind))) != 0 { + subject, body := work(channel.Edit, channel.Message{Handle: "m1", Title: "same", To: "42"}) + if err := c.Take(subject, body); err != nil || len(f.of("sendMessage")) != 0 || len(s.of(channel.IntakeSubject(channel.FailedWhat, Kind))) != 0 { t.Fatalf("an identical edit was not a success (D6): %v", err) } f.answer["editMessageText"] = func(map[string]any) (int, string) { @@ -341,21 +341,21 @@ func TestARefusalIsPermanentARateLimitIsWaitedOutAndNeitherCarriesTheToken(t *te } return 403, `{"ok":false,"error_code":403,"description":"Forbidden: bot was blocked by the user ` + goodToken + `"}` } - subject, body := work(asks.Send, asks.Message{Handle: "m1", Title: "x", To: "42"}) + subject, body := work(channel.Send, channel.Message{Handle: "m1", Title: "x", To: "42"}) if err := c.Take(subject, body); err != nil { t.Fatal(err) } if len(slept) != 1 || slept[0] != 7*time.Second || tries != 2 { t.Errorf("the rate limit was not waited out as told (D5): slept %v, tries %d", slept, tries) } - failed := s.of(asks.IntakeSubject(asks.FailedWhat, Kind)) + failed := s.of(channel.IntakeSubject(channel.FailedWhat, Kind)) if len(failed) != 1 || !strings.Contains(string(failed[0]), `"permanent":true`) || strings.Contains(string(failed[0]), goodToken) { t.Fatalf("the refusal: %s (D6)", failed) } // No linked account and nothing to reply to: said as failed for good, never retried. - subject, body = work(asks.Send, asks.Message{Handle: "m2", Title: "x"}) + subject, body = work(channel.Send, channel.Message{Handle: "m2", Title: "x"}) _ = c.Take(subject, body) - if f := s.of(asks.IntakeSubject(asks.FailedWhat, Kind)); len(f) != 2 || !strings.Contains(string(f[1]), "no account is linked") { + if f := s.of(channel.IntakeSubject(channel.FailedWhat, Kind)); len(f) != 2 || !strings.Contains(string(f[1]), "no account is linked") { t.Errorf("%s", f) } } @@ -365,10 +365,10 @@ func TestATapIsAcknowledgedAtOnceAndSaidWithItsAuthenticatedSender(t *testing.T) shown := showQuestion(t, c, f) c.Handle(Update{UpdateID: 1, Callback: &Callback{ID: "cb1", Data: "tkt", From: User{ID: 42, FirstName: "Jo"}, Message: &Msg{MessageID: shown, Chat: Chat{ID: 42, Type: "private"}, Text: "Release it?\nThe delivery is held."}}}) - got := s.of(asks.IntakeSubject(asks.Chosen, Kind)) - var ch asks.Choice + got := s.of(channel.IntakeSubject(channel.Chosen, Kind)) + var ch channel.Choice if len(got) != 1 || json.Unmarshal(got[0], &ch) != nil || ch.Ticket != "tkt" || - !reflect.DeepEqual(ch.Sender, asks.Sender{Identity: "42", Display: "Jo", Authenticated: true}) { + !reflect.DeepEqual(ch.Sender, channel.Sender{Identity: "42", Display: "Jo", Authenticated: true}) { t.Fatalf("said %s", got) } // In a group, or from another bot, nothing is said: it is nobody's own chat with the bot. @@ -376,7 +376,7 @@ func TestATapIsAcknowledgedAtOnceAndSaidWithItsAuthenticatedSender(t *testing.T) Message: &Msg{Chat: Chat{ID: -100, Type: "group"}}}}) c.Handle(Update{UpdateID: 3, Callback: &Callback{ID: "cb3", Data: "tkt", From: User{ID: 9, IsBot: true}, Message: &Msg{Chat: Chat{ID: 9, Type: "private"}}}}) - if len(s.of(asks.IntakeSubject(asks.Chosen, Kind))) != 1 { + if len(s.of(channel.IntakeSubject(channel.Chosen, Kind))) != 1 { t.Error("a tap outside the account's own chat was said") } } @@ -385,13 +385,13 @@ func TestStartAsksALinkAndACodeIsAProofDeletedFromTheChat(t *testing.T) { c, f, s, _ := newChannel(t, goodToken) jo := &User{ID: 42, FirstName: "Jo", Username: "jo"} c.Handle(Update{UpdateID: 10, Message: &Msg{MessageID: 5, From: jo, Chat: Chat{ID: 42, Type: "private"}, Text: "/start"}}) - var l asks.LinkAsked - got := s.of(asks.IntakeSubject(asks.Link, Kind)) + var l channel.LinkAsked + got := s.of(channel.IntakeSubject(channel.Link, Kind)) if len(got) != 1 || json.Unmarshal(got[0], &l) != nil || l.Sender.Identity != "42" || l.Sender.Display != "Jo (@jo)" { t.Fatalf("link %s", got) } // The router's answer to the link goes to the chat it came from, though nobody is linked yet. - subject, body := work(asks.Send, asks.Message{Handle: "r1", Title: "Send the code shown on your desk", Reply: l.ID}) + subject, body := work(channel.Send, channel.Message{Handle: "r1", Title: "Send the code shown on your desk", Reply: l.ID}) if err := c.Take(subject, body); err != nil || len(f.of("sendMessage")) != 1 || f.of("sendMessage")[0].Body["chat_id"] != float64(42) { t.Fatalf("the reply did not reach the chat: %v %v", err, f.of("sendMessage")) } @@ -439,7 +439,7 @@ func TestStandingSaysWhatIsMissingAndNeverTheToken(t *testing.T) { } c.SayStanding() c.SayStanding() - if n := len(said.of(asks.IntakeSubject(asks.StandingWhat, Kind))); n != 1 { + if n := len(said.of(channel.IntakeSubject(channel.StandingWhat, Kind))); n != 1 { t.Errorf("standing said %d times without a change", n) } } @@ -449,7 +449,7 @@ func TestPollTakesTheUpdatesAndKeepsTheOffsetAndSaysAConflict(t *testing.T) { f.answer["getUpdates"] = func(map[string]any) (int, string) { return 200, `{"ok":true,"result":[{"update_id":41,"message":{"message_id":1,"from":{"id":42},"chat":{"id":42,"type":"private"},"text":"/start"}}]}` } - if err := c.Poll(time.Second); err != nil || st.offset != 42 || len(s.of(asks.IntakeSubject(asks.Link, Kind))) != 1 { + if err := c.Poll(time.Second); err != nil || st.offset != 42 || len(s.of(channel.IntakeSubject(channel.Link, Kind))) != 1 { t.Fatalf("%v offset %d", err, st.offset) } f.answer["getUpdates"] = func(map[string]any) (int, string) { @@ -466,8 +466,8 @@ func TestPollTakesTheUpdatesAndKeepsTheOffsetAndSaysAConflict(t *testing.T) { // showQuestion shows one question with a button to the linked account, and answers its message's id. func showQuestion(t *testing.T, c *Channel, f *fakeAPI) int64 { t.Helper() - subject, body := work(asks.Show, asks.Message{Handle: "q1", Title: "Release it?", Body: "The delivery is held.", - To: "42", Buttons: []asks.Button{{Label: "Release", Ticket: "tkt"}}}) + subject, body := work(channel.Show, channel.Message{Handle: "q1", Title: "Release it?", Body: "The delivery is held.", + To: "42", Buttons: []channel.Button{{Label: "Release", Ticket: "tkt"}}}) if err := c.Take(subject, body); err != nil { t.Fatal(err) } @@ -493,7 +493,7 @@ func TestATapOnAMessageShowingOtherWordsIsRefused(t *testing.T) { } tap("cb-other", shown, "Silence it for a week?\nNothing changes.") tap("cb-unknown", shown+100, "Release it?\nThe delivery is held.") - if got := s.of(asks.IntakeSubject(asks.Chosen, Kind)); len(got) != 0 { + if got := s.of(channel.IntakeSubject(channel.Chosen, Kind)); len(got) != 0 { t.Fatalf("a tap on other words was said: %s", got) } answers := f.of("answerCallbackQuery") @@ -502,16 +502,16 @@ func TestATapOnAMessageShowingOtherWordsIsRefused(t *testing.T) { } // The same words, as Telegram gives them back (its line endings, its outer spaces), are taken. tap("cb-same", shown, " Release it?\r\nThe delivery is held. ") - if got := s.of(asks.IntakeSubject(asks.Chosen, Kind)); len(got) != 1 { + if got := s.of(channel.IntakeSubject(channel.Chosen, Kind)); len(got) != 1 { t.Fatalf("the tap on the words shown was not said: %s", got) } // An edit moves what was shown: the old words are then refused. - subject, body := work(asks.Edit, asks.Message{Handle: "q1", Title: "Released", Body: "by you", To: "42"}) + subject, body := work(channel.Edit, channel.Message{Handle: "q1", Title: "Released", Body: "by you", To: "42"}) if err := c.Take(subject, body); err != nil { t.Fatal(err) } tap("cb-old", shown, "Release it?\nThe delivery is held.") - if got := s.of(asks.IntakeSubject(asks.Chosen, Kind)); len(got) != 1 { + if got := s.of(channel.IntakeSubject(channel.Chosen, Kind)); len(got) != 1 { t.Errorf("a tap on words since replaced was said") } if d, _ := st.Shown(42, shown); d == "" { @@ -536,7 +536,7 @@ func TestATapIsAcknowledgedWithTheRoutersWordOnIt(t *testing.T) { t.Fatalf("the tap was acknowledged before the router said anything: %v", got) } sentBefore := len(f.of("sendMessage")) - subject, body := work(asks.Send, asks.Message{Handle: "r1", Title: "You chose Release: recorded, and mesh-delivery was told.", + subject, body := work(channel.Send, channel.Message{Handle: "r1", Title: "You chose Release: recorded, and mesh-delivery was told.", Silent: true, Reply: "tg-tap-cb1", To: "42"}) if err := c.Take(subject, body); err != nil { t.Fatal(err) @@ -569,7 +569,7 @@ func TestARefusedTapSaysWhyOnThePhoneAndInTheChat(t *testing.T) { shown := showQuestion(t, c, f) tapOn(c, shown, "cb1") sentBefore := len(f.of("sendMessage")) - subject, body := work(asks.Send, asks.Message{Handle: "r1", Title: "That answer was not taken", + subject, body := work(channel.Send, channel.Message{Handle: "r1", Title: "That answer was not taken", Body: "The question was already answered or has ended. Nothing was done.", Silent: true, Reply: "tg-tap-cb1", To: "42"}) if err := c.Take(subject, body); err != nil { t.Fatal(err) @@ -595,7 +595,7 @@ func TestATapTheRouterIsSlowOnClaimsNothingAndItsWordComesInTheChat(t *testing.T t.Fatalf("a slow router's tap was acknowledged with %v", got) } sentBefore := len(f.of("sendMessage")) - subject, body := work(asks.Send, asks.Message{Handle: "r1", Title: "You chose Release: recorded.", Reply: "tg-tap-cb1", To: "42"}) + subject, body := work(channel.Send, channel.Message{Handle: "r1", Title: "You chose Release: recorded.", Reply: "tg-tap-cb1", To: "42"}) if err := c.Take(subject, body); err != nil { t.Fatal(err) } @@ -607,7 +607,7 @@ func TestATapTheRouterIsSlowOnClaimsNothingAndItsWordComesInTheChat(t *testing.T return 400, `{"ok":false,"error_code":400,"description":"Bad Request: query is too old and response timeout expired or query ID is invalid"}` } tapOn(c, shown, "cb2") - subject, body = work(asks.Send, asks.Message{Handle: "r2", Title: "You chose Release: recorded.", Reply: "tg-tap-cb2", To: "42"}) + subject, body = work(channel.Send, channel.Message{Handle: "r2", Title: "You chose Release: recorded.", Reply: "tg-tap-cb2", To: "42"}) if err := c.Take(subject, body); err != nil { t.Fatal(err) } @@ -629,7 +629,7 @@ func TestATapNotSaidToTheRouterIsSaidSoAndASecretIsNeverAToast(t *testing.T) { } c.Publish = func(string, any, string) error { return nil } tapOn(c, shown, "cb2") - subject, body := work(asks.Send, asks.Message{Handle: "r1", Title: "Your code", Body: "123456", Secret: true, + subject, body := work(channel.Send, channel.Message{Handle: "r1", Title: "Your code", Body: "123456", Secret: true, Reply: "tg-tap-cb2", To: "42"}) if err := c.Take(subject, body); err != nil { t.Fatal(err) @@ -643,11 +643,11 @@ func TestATapNotSaidToTheRouterIsSaidSoAndASecretIsNeverAToast(t *testing.T) { // The toast is the title and the first line of the body, cut at Telegram's bound. func TestAToastIsCutAtTelegramsBound(t *testing.T) { - long := toast(asks.Message{Title: strings.Repeat("a", 300)}) + long := toast(channel.Message{Title: strings.Repeat("a", 300)}) if utf8.RuneCountInString(long) != ToastLength || !strings.HasSuffix(long, "…") { t.Errorf("a long toast is %d runes", utf8.RuneCountInString(long)) } - if got := toast(asks.Message{Title: "Details: x", Body: "first\nsecond"}); got != "Details: x: first" { + if got := toast(channel.Message{Title: "Details: x", Body: "first\nsecond"}); got != "Details: x: first" { t.Errorf("toast %q", got) } } @@ -657,19 +657,19 @@ func TestAToastIsCutAtTelegramsBound(t *testing.T) { // loses its buttons. A new handle is a new message, and nothing is deleted. func TestAMessageSaidAgainUnderItsHandleReplacesTheEarlierOne(t *testing.T) { c, f, _, st := newChannel(t, goodToken) - subject, body := work(asks.Show, asks.Message{Handle: "m1", Title: "Urgent: ace silent", Urgent: true, To: "42", - Buttons: []asks.Button{{Label: "Silence for a week", Ticket: "0123456789abcdef0123456789abcdef"}}}) + subject, body := work(channel.Show, channel.Message{Handle: "m1", Title: "Urgent: ace silent", Urgent: true, To: "42", + Buttons: []channel.Button{{Label: "Silence for a week", Ticket: "0123456789abcdef0123456789abcdef"}}}) if err := c.Take(subject, body); err != nil { t.Fatal(err) } first := st.placed["m1"].ID - subject, body = work(asks.Send, asks.Message{Handle: "m2", Title: "another problem", To: "42"}) + subject, body = work(channel.Send, channel.Message{Handle: "m2", Title: "another problem", To: "42"}) _ = c.Take(subject, body) if len(f.of("deleteMessage")) != 0 { t.Fatal("a new handle deleted a message") } - subject, body = work(asks.Show, asks.Message{Handle: "m1", Title: "Still open: ace silent", Urgent: true, To: "42", - Buttons: []asks.Button{{Label: "Silence for a week", Ticket: "fedcba9876543210fedcba9876543210"}}}) + subject, body = work(channel.Show, channel.Message{Handle: "m1", Title: "Still open: ace silent", Urgent: true, To: "42", + Buttons: []channel.Button{{Label: "Silence for a week", Ticket: "fedcba9876543210fedcba9876543210"}}}) if err := c.Take(subject, body); err != nil { t.Fatal(err) } @@ -686,7 +686,7 @@ func TestAMessageSaidAgainUnderItsHandleReplacesTheEarlierOne(t *testing.T) { return 400, `{"ok":false,"error_code":400,"description":"Bad Request: message can't be deleted"}` } second := st.placed["m1"].ID - subject, body = work(asks.Send, asks.Message{Handle: "m1", Title: "Now urgent: ace silent", Urgent: true, To: "42"}) + subject, body = work(channel.Send, channel.Message{Handle: "m1", Title: "Now urgent: ace silent", Urgent: true, To: "42"}) _ = c.Take(subject, body) ed := f.of("editMessageText") if len(ed) != 1 || ed[0].Body["message_id"] != float64(second) || !strings.Contains(ed[0].Body["text"].(string), "Replaced") { @@ -705,7 +705,7 @@ func TestAReplacementThatCannotBeSentLeavesTheEarlierMessage(t *testing.T) { f.answer["sendMessage"] = func(map[string]any) (int, string) { return 400, `{"ok":false,"error_code":400,"description":"Bad Request: chat not found"}` } - subject, body := work(asks.Send, asks.Message{Handle: "m1", Title: "Still open: ace silent", Urgent: true, To: "42"}) + subject, body := work(channel.Send, channel.Message{Handle: "m1", Title: "Still open: ace silent", Urgent: true, To: "42"}) _ = c.Take(subject, body) if len(f.of("deleteMessage")) != 0 || st.placed["m1"].ID != 7 { t.Fatalf("the earlier message was taken away though its replacement was not sent: %+v", f.of("deleteMessage")) diff --git a/modules/telegram/cmd/telegram/render.go b/modules/telegram/cmd/telegram/render.go index 81c227821..e978f6d6a 100644 --- a/modules/telegram/cmd/telegram/render.go +++ b/modules/telegram/cmd/telegram/render.go @@ -20,7 +20,7 @@ import ( "strings" "unicode/utf8" - "git.novox.be/novox/mesh-sdk/go/asks" + "git.novox.be/novox/mesh-sdk/go/channel" ) // ParseMode is the formatting the Bot API is told the text is in. @@ -40,7 +40,7 @@ type shown struct{ title, body, note string } // first: the note carries what each answer does and every value an approval sets, so it is kept whole as long as // the title and the note fit; only then is the note cut, and that said too. The bound is on the words, as // Telegram's is on the text once its markup is read. -func partsOf(m asks.Message) shown { +func partsOf(m channel.Message) shown { s := shown{title: strings.TrimSpace(m.Title), body: strings.TrimSpace(m.Body), note: strings.TrimSpace(m.Note)} over := utf8.RuneCountInString(s.plain()) - MaxLength if over <= 0 { diff --git a/modules/telegram/cmd/telegram/render_test.go b/modules/telegram/cmd/telegram/render_test.go index 963136d8c..8ef471705 100644 --- a/modules/telegram/cmd/telegram/render_test.go +++ b/modules/telegram/cmd/telegram/render_test.go @@ -4,13 +4,13 @@ import ( "strings" "testing" - "git.novox.be/novox/mesh-sdk/go/asks" + "git.novox.be/novox/mesh-sdk/go/channel" ) // adr0343 is the ask for ADR 0343 as the router now sends it to this channel (novox/hq issue 639): the knowledge // base's brief as its title and body, the rest in its note. On 2026-10-13 the same ask arrived as about thirty plain // lines — every one of its thirteen clauses on a line of its own — and the operator would not read it. -var adr0343 = asks.Message{Handle: "h-343", To: "42", +var adr0343 = channel.Message{Handle: "h-343", To: "42", Title: "Your agents' memory follows you to every machine", Body: "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 " + @@ -22,13 +22,13 @@ var adr0343 = asks.Message{Handle: "h-343", To: "42", "Approve: the record is accepted and its clauses are in force. Decline: the record stays proposed; nothing of it is in force.\n" + "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.", - Buttons: []asks.Button{{Label: "Approve", Ticket: "t-approve"}, {Label: "Decline", Ticket: "t-decline"}}} + Buttons: []channel.Button{{Label: "Approve", Ticket: "t-approve"}, {Label: "Decline", Ticket: "t-decline"}}} // The ADR 0343 ask fits a phone's glance: a bold headline, the summary and its points, then the rest apart and // collapsed — at most twelve lines in all, and never a clause. func TestTheADR0343AskFitsAGlance(t *testing.T) { c, f, _, _ := newChannel(t, goodToken) - subject, body := work(asks.Show, adr0343) + subject, body := work(channel.Show, adr0343) if err := c.Take(subject, body); err != nil { t.Fatal(err) } @@ -64,7 +64,7 @@ func TestTheADR0343AskFitsAGlance(t *testing.T) { // The note is kept whole when the message is too long: the body is cut first and the cut said; only a note that // cannot fit beside the title is cut, and that said too (the review of mesh-catalog #278). func TestALongBodyIsCutBeforeTheNote(t *testing.T) { - m := asks.Message{Title: "t", Body: strings.Repeat("b", 5000), Note: "+ shares: media=/storage/media\nFingerprint 1234."} + m := channel.Message{Title: "t", Body: strings.Repeat("b", 5000), Note: "+ shares: media=/storage/media\nFingerprint 1234."} s := partsOf(m) if s.note != m.Note || !strings.HasSuffix(s.body, cutNote) || len([]rune(s.plain())) != MaxLength { t.Errorf("note %q, body ends %q, %d characters", s.note, s.body[len(s.body)-20:], len([]rune(s.plain()))) @@ -78,7 +78,7 @@ func TestALongBodyIsCutBeforeTheNote(t *testing.T) { // A '<', '>' or '&' in the note is escaped like any other part: it neither breaks the blockquote nor injects. func TestTheNoteIsEscaped(t *testing.T) { - got := partsOf(asks.Message{Title: "t", Note: "Asked by x & y "}).html() + got := partsOf(channel.Message{Title: "t", Note: "Asked by x & y "}).html() want := "t\n\n
Asked by <b>x</b> & y </blockquote><i>
" if got != want { t.Errorf("%q", got) @@ -89,7 +89,7 @@ func TestTheNoteIsEscaped(t *testing.T) { // the end of the body (the asker's Details) is cut; the headline and the answer are never. func TestAnAnsweredAskOverTheBoundKeepsItsHeadlineAndItsAnswer(t *testing.T) { answer := "You chose Approve at 20:03 on Telegram.\nTold to mesh-knowledge, which acts on it itself." - m := asks.Message{Handle: "h-714", To: "42", Title: "Fixes reach every machine at once, unchecked", + m := channel.Message{Handle: "h-714", To: "42", Title: "Fixes reach every machine at once, unchecked", Body: "A fix goes to every machine in one step.\n• No machine goes first\n" + strings.Repeat("Details line. ", 400), Note: answer} s := partsOf(m)