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

Claude-Session: https://claude.ai/code/session_01JG73KJNrH5EgFXMKKYSrig
2026-11-04 15:33:27 +01:00

705 lines
28 KiB
Go

package main
// A question an agent session asks the operator, answered on a channel that proves who answered (novox/hq ADR 0332,
// issue 589).
//
// A session that needs a yes, a no or a choice asks it with claude_code_ask instead of waiting at a terminal the
// operator may not be watching. The ask is published under this module's name on the operator channel (ADR 0259):
// the bus grants that subject to this module alone, so the asker is proven by the bus, and the router stays the judge
// of answers, never an asker for others. Every option is at the level approve, so only a channel that proves who
// answered (Telegram, today) carries it, and each option binds the digest of the question and of itself.
//
// 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 (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 (
"crypto/rand"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"regexp"
"sort"
"strconv"
"strings"
"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"
)
// QuestionsState is the module's state the questions are kept in.
const QuestionsState = "questions"
// QuestionPrefix starts every question's key, so the router's word on one is never taken for a proposal's.
const QuestionPrefix = "q-"
// MostQuestions is how many questions may wait at once, mesh-wide: the router allows an asker three open asks
// (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 = 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 (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) }
// slot is what a slot key holds: the question holding it.
type slot struct {
Question string `json:"question"`
At time.Time `json:"at"`
}
// The bounds of a question's life (ADR 0332 §2): at least five minutes, at most a day less ten minutes (the
// router's bound for an approval, with the margin a proposal keeps), an hour when none is given.
const (
QuestionShortest = 5 * time.Minute
QuestionLongest = ProposalLasts
QuestionDefault = time.Hour
)
// The bounds of what a question says.
const (
FewestQuestionOptions = 2
MostContextBytes = occ.SummaryLength
MostMeansBytes = 200
MostWhyBytes = 120
MostOnExpiryBytes = 200
)
// How long one claude_code_answer call may wait for the answer, and how often it looks meanwhile.
var (
AnswerWaitsAtMost = 60 * time.Second
AnswerLooksEvery = 2 * time.Second
)
// QuestionOption is one answer a session offers.
type QuestionOption struct {
ID string `json:"id"`
Label string `json:"label"`
Means string `json:"means"`
Recommended bool `json:"recommended,omitempty"`
}
// QuestionStore is the `questions` state: a value with its revision, created only when absent, updated only at the
// revision read.
type QuestionStore interface {
Get(key string) (json.RawMessage, uint64, bool, error)
Create(key string, value any) error
Update(key string, value any, revision uint64) error
Put(key string, value any) error
}
// keptQuestions is the `questions` state on the bus.
type keptQuestions struct{ s stdio.KeptState }
func (k keptQuestions) Get(key string) (json.RawMessage, uint64, bool, error) {
e, err := k.s.Get(key)
if err != nil || e == nil {
return nil, 0, false, err
}
return e.Value, e.Revision, true, nil
}
func (k keptQuestions) Create(key string, value any) error {
_, err := k.s.Create(key, value)
return err
}
func (k keptQuestions) Update(key string, value any, revision uint64) error {
_, err := k.s.Update(key, value, revision)
return err
}
func (k keptQuestions) Put(key string, value any) error { _, err := k.s.Put(key, value); return err }
// Question is one question, as the `questions` state keeps it. Every field the ask is composed from is here, so the
// answer is held to exactly the question asked.
type Question struct {
ID string `json:"id"`
Headline string `json:"headline"`
Context string `json:"context"`
Options []QuestionOption `json:"options"`
Why string `json:"why"`
OnExpiry string `json:"on-expiry"`
By string `json:"by"`
From string `json:"from"`
At time.Time `json:"at"`
Expires time.Time `json:"expires"`
// Form is how its ask is composed (AskGlance since novox/hq issue 639), so the answer is held to the ask asked.
Form int `json:"form,omitempty"`
// Asked is empty once the ask was published, or says why it was not; nothing composes from it.
Asked string `json:"asked,omitempty"`
// Ended is the router's word that the ask ended without a choice, as heard on the decided subject: a refused ask
// leaves no record of the router's to read it from. It never makes a choice.
Ended string `json:"ended,omitempty"`
}
// recommended is the option marked as recommended.
func (q Question) recommended() (QuestionOption, bool) {
for _, o := range q.Options {
if o.Recommended {
return o, true
}
}
return QuestionOption{}, false
}
// Act is what an answer binds: this question, whole, and the option chosen.
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))}
for i, o := range q.Options {
act[fmt.Sprintf("option.%d", i)] = o.ID + "\n" + o.Label + "\n" + o.Means + "\n" + strconv.FormatBool(o.Recommended)
}
return act
}
// Ask is the ask for a question, composed from it alone, in the form it was asked in.
func (q Question) Ask() oc.Ask {
if q.Form == AskGlance {
return q.glanceAsk()
}
return q.listedAsk()
}
// 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() 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(oc.Ask{ID: q.ID, Headline: q.Headline, Explanation: explanation, Details: details})
}
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, _ := 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() oc.Ask {
var b strings.Builder
b.WriteString(strings.TrimSpace(q.Context))
if rec, ok := q.recommended(); ok {
fmt.Fprintf(&b, "\n\nRecommended: %s, because %s", rec.Label, strings.TrimRight(strings.TrimSpace(q.Why), "."))
b.WriteString(".")
}
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 := 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, _ := 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
}
var sessionName = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9_-]{0,47}$`)
// QuestionProblem says why a question cannot be asked, or "": every bound of ADR 0332 §2 that this module holds
// itself. The words are held to the content rule by the router, which says its refusal back.
func QuestionProblem(q Question) string {
var problems []string
add := func(format string, args ...any) { problems = append(problems, fmt.Sprintf(format, args...)) }
switch {
case strings.TrimSpace(q.Headline) == "":
add("it has no headline")
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) == "":
add("it gives no context: say in plain words what the operator decides and why it matters")
case len([]rune(q.Context)) > MostContextBytes:
add("its context is %d characters, more than the %d a phone shows in one glance", len([]rune(q.Context)), MostContextBytes)
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 > 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)) > 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)
}
labels[strings.ToLower(o.Label)] = true
if strings.TrimSpace(o.Means) == "" {
add("option %d does not say what choosing it means", i+1)
} else if len(o.Means) > MostMeansBytes {
add("what option %d means is longer than %d bytes", i+1, MostMeansBytes)
}
if !occ.UsableID(o.ID) {
add("option %d's id is not one", i+1)
}
if o.Recommended {
recommended++
}
}
if recommended != 1 {
add("%d options are marked recommended; exactly one is", recommended)
}
switch {
case strings.TrimSpace(q.Why) == "":
add("it does not say why the recommended option is recommended")
case len(q.Why) > MostWhyBytes:
add("why the option is recommended is longer than %d bytes", MostWhyBytes)
}
switch {
case strings.TrimSpace(q.OnExpiry) == "":
add("it does not say what the session does if nobody answers")
case len(q.OnExpiry) > MostOnExpiryBytes:
add("what happens if nobody answers is longer than %d bytes", MostOnExpiryBytes)
}
if !sessionName.MatchString(q.By) {
add("who asks is not a session's name: letters, digits, - and _, such as fix-589")
}
if !nodeName.MatchString(q.From) {
add("where it comes from is not a node's name")
}
if life := q.Expires.Sub(q.At); life < QuestionShortest || life > QuestionLongest {
add("it lasts %s; a question lasts %s to %s", life.Round(time.Minute), QuestionShortest, QuestionLongest)
}
if problem := questionWordsProblem(q); problem != "" {
add("%s", problem)
}
if len(problems) == 0 {
return ""
}
return strings.Join(problems, "; ")
}
// questionWordsProblem says why a question's words may not be kept at all, or "": anything shaped like a secret, or a
// character a phone does not show, in any word the operator is shown — the headline, the context, why, what happens
// on expiry, and each option's label and meaning. Judged before anything is kept: the `questions` state is read by
// every node, and the router judges the content rule only after the question was kept.
func questionWordsProblem(q Question) string {
fields := []struct{ name, text string }{{"the headline", q.Headline}, {"the context", q.Context}, {"why", q.Why},
{"what happens on expiry", q.OnExpiry}}
for i, o := range q.Options {
fields = append(fields, struct{ name, text string }{fmt.Sprintf("option %d's label", i+1), o.Label},
struct{ name, text string }{fmt.Sprintf("what option %d means", i+1), o.Means})
}
for _, f := range fields {
for _, re := range secretShaped {
if re.MatchString(f.text) {
return f.name + " holds something shaped like a secret, which is never kept or shown on a phone"
}
}
if what := invisible(f.text); what != "" {
return f.name + " holds " + what + ", which a phone does not show"
}
}
return ""
}
func newQuestionID() string {
var b [8]byte
_, _ = rand.Read(b[:])
return QuestionPrefix + hex.EncodeToString(b[:])
}
// QuestionInput is what a session asks.
type QuestionInput struct {
Headline string
Context string
Options []QuestionOption
Why string
OnExpiry string
By string
Expires time.Duration
}
// AskQuestion keeps a question and asks the operator. It answers within seconds, once the router has said whether it
// took the question, with the question's key; the answer is read with ReadAnswer.
func AskQuestion(p Paths, in QuestionInput, store QuestionStore, bus AskBus, now time.Time) (map[string]any, error) {
refuse := func(why string) (map[string]any, error) { return nil, errors.New(why + ". Nothing was asked") }
by := strings.TrimSpace(in.By)
if who, node, found := strings.Cut(by, "@"); found {
if node != p.Node {
return refuse(fmt.Sprintf("who asks names the node %q, and this is %s: ask on the node the session runs on", node, p.Node))
}
by = who
}
if by == "" {
return refuse("it does not say who asks: give by, your session's name as who@node, such as fix-589@g14")
}
life := in.Expires
if life == 0 {
life = QuestionDefault
}
q := Question{ID: newQuestionID(), Headline: strings.TrimSpace(in.Headline), Context: strings.TrimSpace(in.Context),
Why: strings.TrimSpace(in.Why), OnExpiry: strings.TrimSpace(in.OnExpiry), By: by, From: p.Node, At: now.UTC(),
Expires: now.UTC().Add(life), Form: AskGlance}
for i, o := range in.Options {
q.Options = append(q.Options, QuestionOption{ID: fmt.Sprintf("o%d", i+1), Label: strings.TrimSpace(o.Label),
Means: strings.TrimSpace(o.Means), Recommended: o.Recommended})
}
if problem := QuestionProblem(q); problem != "" {
return refuse(problem)
}
ask := q.Ask()
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.
if err := store.Create(q.ID, q); err != nil {
return nil, fmt.Errorf("the question could not be kept, so the operator was not asked: %w", err)
}
if open, err := takeSlot(store, bus, q, now); err != nil {
_ = store.Put(q.ID, tombstone(q, "not asked: "+err.Error()))
return refuse(err.Error())
} else if open != nil {
_ = store.Put(q.ID, tombstone(q, "not asked: two questions were open"))
return refuse(fmt.Sprintf("%d questions already wait for the operator's answer (%s), and sessions hold at most %d "+
"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(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)
}
say("question %s from the session %s asked of the operator", q.ID, q.By)
taken := "the router has not said yet whether it took the question; claude_code_answer says"
defer forgetQuestionEnd(q.ID)
for deadline := time.Now().Add(RouterAnswersWithin); time.Now().Before(deadline); {
if w, ended := questionEnd(q.ID); ended {
return nil, fmt.Errorf("the router did not ask the operator: the question %s (%s)", w.Outcome, w.Words)
}
if entry, err := bus.Record(q.ID); err == nil && entry != nil {
taken = "the router took the question and shows it where who answers is proven (Telegram)"
break
}
time.Sleep(RouterAnswersEvery)
}
return map[string]any{
"key": q.ID,
"router": taken,
"asked": "\"" + q.Headline + "\", with the answers " + labelsOf(q),
"expires": q.Expires.Format(time.RFC3339),
"next": "read the answer with claude_code_answer {\"key\": \"" + q.ID + "\"} (it may wait up to a minute); " +
"keep working meanwhile, and if it expires do what you said: " + q.OnExpiry,
}, nil
}
func labelsOf(q Question) string {
var out []string
for _, o := range q.Options {
out = append(out, o.Label)
}
return strings.Join(out, ", ")
}
// questionOpen says whether a question still waits: kept, asked, not ended, not expired, and open in the router's
// record or not recorded yet. An unreadable record is open: a slot is never freed on a guess.
func questionOpen(store QuestionStore, bus AskBus, id string, now time.Time) (bool, error) {
q, ok, err := readQuestionErr(store, id)
if err != nil {
return true, err
}
if !ok || q.Asked != "" || q.Ended != "" || !now.Before(q.Expires) {
return false, nil
}
entry, err := bus.Record(q.ID)
if err != nil {
return true, err
}
if entry == nil {
return true, nil
}
var rec routerRecord
if json.Unmarshal(entry.Value, &rec) != nil {
return true, nil
}
return rec.State == "" || rec.State == "open", nil
}
// takeSlot takes one of the MostQuestions slots for q: a slot no key holds, by create, or one whose question no longer
// waits, by compare-and-set at the revision read. Of two nodes taking the same slot only one wins; the other tries
// the next. It answers the open questions when every slot is held, and an error — refusing, never guessing — when a
// slot or its question cannot be read.
func takeSlot(store QuestionStore, bus AskBus, q Question, now time.Time) ([]string, error) {
mine := slot{Question: q.ID, At: q.At}
var open []string
for n := 1; n <= MostQuestions; n++ {
key := slotKey(n)
raw, rev, found, err := store.Get(key)
if err != nil {
return nil, fmt.Errorf("the open questions could not be counted (%v), so none more is asked", err)
}
if !found {
if err := store.Create(key, mine); err == nil {
return nil, nil
} else if !errors.Is(err, stdio.ErrChanged) {
return nil, fmt.Errorf("the open questions could not be counted (%v), so none more is asked", err)
}
if raw, rev, found, err = store.Get(key); err != nil || !found {
return nil, fmt.Errorf("the open questions could not be counted, so none more is asked")
}
}
var held slot
if json.Unmarshal(raw, &held) == nil && held.Question != "" {
waits, err := questionOpen(store, bus, held.Question, now)
if err != nil {
return nil, fmt.Errorf("the open questions could not be counted (%v), so none more is asked", err)
}
if waits {
open = append(open, held.Question)
continue
}
}
if err := store.Update(key, mine, rev); err == nil {
return nil, nil
} else if !errors.Is(err, stdio.ErrChanged) {
return nil, fmt.Errorf("the open questions could not be counted (%v), so none more is asked", err)
}
open = append(open, "one taken just now")
}
sort.Strings(open)
return open, nil
}
// tombstone is what is kept of a question that was not asked or that the router refused: its key, who asked, when, and
// why it ended — none of its words, which the router judged unfit to leave the mesh or never saw.
func tombstone(q Question, ended string) Question {
return Question{ID: q.ID, By: q.By, From: q.From, At: q.At, Expires: q.Expires, Ended: ended}
}
func readQuestion(store QuestionStore, key string) (Question, bool) {
q, ok, _ := readQuestionErr(store, key)
return q, ok
}
func readQuestionErr(store QuestionStore, key string) (Question, bool, error) {
if !strings.HasPrefix(key, QuestionPrefix) {
return Question{}, false, nil
}
raw, _, found, err := store.Get(key)
if err != nil || !found {
return Question{}, false, err
}
var q Question
if json.Unmarshal(raw, &q) != nil || q.ID != key {
return Question{}, false, nil
}
return q, true, nil
}
// The router's word that a question ended without a choice, as heard on the decided subject.
var (
questionEndsMu sync.Mutex
questionEnds = map[string]oc.Warrant{}
)
func questionEnd(id string) (oc.Warrant, bool) {
questionEndsMu.Lock()
defer questionEndsMu.Unlock()
w, ok := questionEnds[id]
return w, ok
}
func forgetQuestionEnd(id string) {
questionEndsMu.Lock()
delete(questionEnds, id)
questionEndsMu.Unlock()
}
// HeardQuestionEnd takes the router's word that a question ended without a choice — refused, expired, cancelled or
// 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 oc.Warrant) {
if w.Asker != Module || w.Outcome == occ.OutcomeChosen || !strings.HasPrefix(w.Ask, QuestionPrefix) {
return
}
q, known := readQuestion(store, w.Ask)
if !known {
return
}
if q.Ended == "" {
ended := string(w.Outcome)
if w.Words != "" {
ended += ": " + w.Words
}
if w.Outcome == occ.OutcomeRefused {
q = tombstone(q, ended)
} else {
q.Ended = ended
}
_ = store.Put(q.ID, q)
}
// Kept on the question first, then said to a session waiting on it: what it reads next is already there.
questionEndsMu.Lock()
questionEnds[w.Ask] = w
questionEndsMu.Unlock()
}
// The answer's states, as claude_code_answer says them.
const (
AnswerWaiting = "waiting"
AnswerChosen = "chosen"
AnswerEnded = "ended"
AnswerNotProven = "not proven"
)
// ReadAnswer is where a question stands: waiting, chosen (and what), ended without a choice, or not proven.
func ReadAnswer(store QuestionStore, bus AskBus, key string, now time.Time) (map[string]any, error) {
q, ok := readQuestion(store, strings.TrimSpace(key))
if !ok {
return nil, fmt.Errorf("no question %q is kept: the key is the one claude_code_ask gave", key)
}
out := map[string]any{"key": q.ID, "headline": q.Headline, "asked": q.At.Format(time.RFC3339),
"expires": q.Expires.Format(time.RFC3339)}
if q.Asked != "" {
out["state"], out["words"] = AnswerEnded, q.Asked
return out, nil
}
entry, err := bus.Record(q.ID)
if err != nil {
return nil, fmt.Errorf("the router's record of the question could not be read now (%v); ask again", err)
}
var rec routerRecord
if entry != 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
}
}
switch {
case entry == nil && q.Ended != "":
out["state"], out["words"] = AnswerEnded, q.Ended
case entry == nil && !now.Before(q.Expires):
out["state"], out["words"] = AnswerEnded, "expired without the router recording it: refused (its refusal was not "+
"heard here) or never taken; the router refuses it now if it is taken late"
out["now"] = "do what you said: " + q.OnExpiry
case entry == nil:
out["state"] = AnswerWaiting
out["words"] = "not yet recorded by the router: it may still be in the router's queue, or was refused without the " +
"refusal being heard here. It holds its slot, and the operator may still be asked, until it expires at " +
q.Expires.Format(time.RFC3339)
case entry == nil, rec.State == "open", rec.Warrant == nil:
out["state"] = AnswerWaiting
if q.Ended != "" {
out["words"] = "the router said it ended (" + q.Ended + "), and its record says it is open; read again"
}
default:
w := *rec.Warrant
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 == occ.OutcomeExpired {
out["now"] = "do what you said: " + q.OnExpiry
}
return out, nil
}
if problem := QuestionProblem(q); problem != "" {
out["state"], out["words"] = AnswerNotProven, "the question kept is not one this module asks: "+problem
return out, nil
}
option, err := occ.For(w, Module, q.Ask())
if err == nil {
err = occ.Performs(option, q.Act(option.ID))
}
if err != nil {
say("REFUSED the answer to question %s: %v", q.ID, err)
out["state"], out["words"] = AnswerNotProven, "the router's record does not hold to the question asked: "+err.Error()
return out, nil
}
chosen := map[string]any{"id": option.ID, "label": option.Label}
for _, o := range q.Options {
if o.ID == option.ID {
chosen["means"], chosen["recommended"] = o.Means, o.Recommended
}
}
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
}
// WaitForAnswer reads the answer, and while it is waiting reads it again until wait has passed.
func WaitForAnswer(store QuestionStore, bus AskBus, key string, wait time.Duration, now func() time.Time) (map[string]any, error) {
if wait > AnswerWaitsAtMost {
wait = AnswerWaitsAtMost
}
deadline := time.Now().Add(wait)
for {
out, err := ReadAnswer(store, bus, key, now())
if err != nil || out["state"] != AnswerWaiting || !time.Now().Before(deadline) {
return out, err
}
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
}