The operator would not read the Telegram ask for ADR 0343: thirty plain lines, every clause on one, no bold, no summary. A settings proposal was the same wall of text, its values masked so the approval was blind. - messenger: an ask's message is the asker's headline and explanation alone; who asked and when, what each answer does, the expiry, where else it is answered and the asker's Details travel as the message's note (asks.Message.Note). Answered, the message is one line, "You chose Approve at 16:06 on Telegram · <headline>". An explanation that does not read at a glance (asks.Glance) is shown and logged. On a kind that proves who answers, an ask's values are shown whole (asks.Ask.Whole), held to the secret rule alone (from the earlier work on issue 383, #178). - telegram: every message in HTML, never reworded: the title bold, a key line monospace, the note apart in a collapsed blockquote; markup escaped. A tap is compared by its words, whitespace aside, and a message shown before is still compared by the digest it was kept under. - desk-channel: the note follows the body. - mesh-knowledge: a record that needs the operator carries a brief (headline, summary, at most three points), the 30th per-record rule; its ask shows the brief and never the clauses. An ask made before is composed again as it was asked (form), so its warrant still holds. - claude-code: an instructions proposal's explanation is one summary line (the proposer's, or the module's), its text in the Details; a question's context is at most 300 characters and its recommendation one point. Older proposals and questions keep their form. The SDK is pinned at mesh-sdk 9588871 (fix/639-an-ask-reads-at-a-glance), which adds Message.Note and asks.Glance.
1063 lines
40 KiB
Go
1063 lines
40 KiB
Go
// mesh-knowledge: the knowledge base, holder of the knowledge-base seat (novox/hq ADR 0290, to-be 55). A Go
|
|
// bundle the control-node's runtime launches. It holds every decision record and research effort whole — its
|
|
// fields, its clauses each with a status and a source, its typed links read from both ends, its reasoning — with
|
|
// every version kept, in a database in the store; answers what holds in one bounded call; takes a record proposed,
|
|
// checked when it is written, reviewed by its own judgement and accepted, on the operator's proven word when it
|
|
// needs it; and imports hq's records at a commit. stdout is the MCP channel; what this module says,
|
|
// it says on stderr.
|
|
//
|
|
// mesh-knowledge the bundle: the seat's verbs and the health tool
|
|
// mesh-knowledge prepare the preparation step: the database's schema brought to this version's
|
|
// mesh-knowledge keep acts: judges the whole record set every few minutes and keeps the findings
|
|
// mesh-knowledge watch judges: reads whether the database serves and what the findings say, every
|
|
// minute, and leaves its verdict in the status file the health tool reads
|
|
// mesh-knowledge dry-run <hq checkout> the import's dry run over a checkout on disk, against no database
|
|
//
|
|
// The two long-running processes are the act and the judge, as the mounts module's are: keep is judged by its
|
|
// unit, watch by the health tool, so the module never judges itself by its own tool alone (ADR 0240 rule 2).
|
|
package main
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"math"
|
|
"os"
|
|
"sort"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
|
|
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.
|
|
const Seat = "knowledge-base"
|
|
|
|
// Verbs are the seat's verbs, as the manifest declares and claims them (ADR 0290 rule 6).
|
|
var Verbs = []string{"ask", "get", "holds", "links", "history", "reserve", "propose", "import", "return"}
|
|
|
|
// HealthTool is the module's own tool the node-engine asks how it is.
|
|
const HealthTool = "knowledge_health"
|
|
|
|
func logf(format string, a ...any) { fmt.Fprintf(os.Stderr, format+"\n", a...) }
|
|
|
|
func readSettings(path string) (Settings, error) {
|
|
var s Settings
|
|
if path == "" {
|
|
return s, errors.New("the module was started without a settings file")
|
|
}
|
|
raw, err := os.ReadFile(path)
|
|
if err != nil {
|
|
return s, err
|
|
}
|
|
if err := json.Unmarshal(raw, &s); err != nil {
|
|
return s, fmt.Errorf("the settings file is not the module's settings: %v", err)
|
|
}
|
|
return s, nil
|
|
}
|
|
|
|
func main() {
|
|
if len(os.Args) > 1 {
|
|
switch os.Args[1] {
|
|
case "prepare":
|
|
os.Exit(prepare())
|
|
case "keep":
|
|
os.Exit(keep())
|
|
case "watch":
|
|
os.Exit(watch(os.Getenv("MESH_KNOWLEDGE_STATUS")))
|
|
case "dry-run":
|
|
os.Exit(dryRunAtTheTerminal(os.Args[2:]))
|
|
}
|
|
}
|
|
st := &lazyStore{open: func(ctx context.Context) (*PgStore, error) { return PgStoreFromEnv(ctx) }}
|
|
k := newKB(st)
|
|
serve(k, os.Getenv("MESH_KNOWLEDGE_STATUS"))
|
|
}
|
|
|
|
// newKB is the knowledge base as the runtime launches it: everything it reaches, over the bus.
|
|
func newKB(store Store) *KB {
|
|
file := os.Getenv("MESH_KNOWLEDGE_SETTINGS")
|
|
return &KB{
|
|
Store: store,
|
|
Records: busRecords{},
|
|
Reserve: busReserver,
|
|
Judge: busJudgement(os.Getenv("MESH_NODE")),
|
|
Asks: sdkAskBus{},
|
|
Notify: busNotifier,
|
|
Clear: busClearer,
|
|
Emit: func(event string, body any) error { return stdio.Emit(event, body) },
|
|
Settings: func() (Settings, error) { return readSettings(file) },
|
|
Logf: logf,
|
|
}
|
|
}
|
|
|
|
// serve runs the bundle: the verbs, the operator's answers heard, and the background work.
|
|
func serve(k *KB, status string) {
|
|
k.kick = make(chan struct{}, 1)
|
|
go hear(k)
|
|
go background(k)
|
|
if err := stdio.Serve("", append(Tools(k, callerOf), healthTool(status, k.now), highestTool(k))); err != nil {
|
|
logf("%v", err)
|
|
os.Exit(1)
|
|
}
|
|
}
|
|
|
|
// callerOf is who called, as the runtime proves it: the one place the caller is read. The tool runner takes the
|
|
// caller's bus user from the subject the call arrived on and hands it through the SDK (hq issue 365); Unproven where
|
|
// it proves none. It is never read from the call's arguments, so no caller can name another.
|
|
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) {
|
|
time.Sleep(wait)
|
|
err := stdio.Subscribe(asks.Seat+".decided."+Module, func(e stdio.Envelope) error {
|
|
var w asks.Warrant
|
|
if json.Unmarshal(e.Body, &w) == nil {
|
|
k.HeardEnd(w)
|
|
}
|
|
return nil
|
|
})
|
|
if err == nil {
|
|
logf("[mesh-knowledge] hears the operator's answers")
|
|
return
|
|
}
|
|
logf("[mesh-knowledge] does not hear the operator's answers yet (%v); asking again in %s", err, wait)
|
|
}
|
|
}
|
|
|
|
// background reads the router's record of every waiting ask once a minute, withdraws reservations never
|
|
// proposed, reviews every proposal waiting for its review — at once when one is proposed — and drafts and
|
|
// confirms clauses one step at a time, a review always before the next drafting step: the agent judges one
|
|
// question at a time, and a proposal does not wait behind the import's drafting.
|
|
func background(k *KB) {
|
|
tick := time.NewTicker(time.Minute)
|
|
defer tick.Stop()
|
|
time.Sleep(5 * time.Second)
|
|
review := func() {
|
|
for {
|
|
said, err := k.ReviewStep()
|
|
if err != nil {
|
|
logf("[mesh-knowledge] %v", err)
|
|
return
|
|
}
|
|
if said == "" {
|
|
return
|
|
}
|
|
logf("[mesh-knowledge] %s", said)
|
|
}
|
|
}
|
|
for {
|
|
if said, err := k.Reconcile(); err != nil {
|
|
logf("[mesh-knowledge] reading the operator's answers: %v", err)
|
|
} else {
|
|
for _, s := range said {
|
|
logf("[mesh-knowledge] %s", s)
|
|
}
|
|
}
|
|
if n, err := k.Withdraw(); err != nil {
|
|
logf("[mesh-knowledge] withdrawing reservations: %v", err)
|
|
} else if n > 0 {
|
|
logf("[mesh-knowledge] withdrew %d reservation(s) never proposed", n)
|
|
}
|
|
review()
|
|
// The keeper judges the record set in a process of its own, which tells nobody: its findings are told here.
|
|
k.TellFindings()
|
|
for {
|
|
said, err := k.DraftStep()
|
|
if err != nil {
|
|
logf("[mesh-knowledge] %v", err)
|
|
break
|
|
}
|
|
if said == "" {
|
|
break
|
|
}
|
|
logf("[mesh-knowledge] %s", said)
|
|
review()
|
|
}
|
|
select {
|
|
case <-tick.C:
|
|
case <-k.kick:
|
|
}
|
|
}
|
|
}
|
|
|
|
// ---- the bus -------------------------------------------------------------------------------------------------
|
|
|
|
// busReserver asks the issue tracker for a number (issue-tracker.reserve, hq issue 424).
|
|
func busReserver(kind, title string) (int, error) {
|
|
raw, err := stdio.Ask("seat:issue-tracker.reserve", map[string]any{"kind": kind, "for": title})
|
|
if err != nil {
|
|
return 0, err
|
|
}
|
|
var out struct {
|
|
Kind string `json:"kind"`
|
|
Number int `json:"number"`
|
|
}
|
|
if err := json.Unmarshal(raw, &out); err != nil || out.Number < 1 || out.Kind != kind {
|
|
return 0, fmt.Errorf("the issue tracker's answer is not a %s number: %.200s", kind, raw)
|
|
}
|
|
return out.Number, nil
|
|
}
|
|
|
|
// busJudgement asks the agent module on this node for one judgement (ADR 0278).
|
|
func busJudgement(node string) Judgement { return judgementOver(stdio.Ask, node) }
|
|
|
|
// The bound a judgement asks scales with its question (novox/hq issue 508): ADR 0297, eleven clauses and eleven
|
|
// links, was never reviewed within the agent module's 25 seconds, while short records took 14 to 25. Each KiB of
|
|
// question adds JudgePerKiB to JudgeFloor, up to MostJudgeWithin, which the agent module takes at most. A sized
|
|
// bound was chosen over asking clause by clause: every judgement pays the agent's start, which alone is most of
|
|
// those 14 to 25 seconds, so a dozen clause-sized judgements would each risk the same 25 seconds, take minutes
|
|
// together, and still need a judgement of the whole record for what binds its clauses together.
|
|
const (
|
|
JudgeFloor = 25 * time.Second
|
|
JudgePerKiB = 2 * time.Second
|
|
MostJudgeWithin = 120 * time.Second
|
|
)
|
|
|
|
// JudgeWithin is the bound a judgement of this question asks.
|
|
func JudgeWithin(question string) time.Duration {
|
|
return min(JudgeFloor+time.Duration(len(question)>>10)*JudgePerKiB, MostJudgeWithin)
|
|
}
|
|
|
|
// judgementOver asks the agent module through ask. A bound past one bus call's 30 seconds is answered pending with
|
|
// a ticket when the judgement is not done in the call, and collected from the node that holds it with
|
|
// claude_code_judged (novox/hq issue 508).
|
|
func judgementOver(ask func(key string, body any) (json.RawMessage, error), node string) Judgement {
|
|
return func(question string, schema map[string]any, model string) (json.RawMessage, string, error) {
|
|
key := "claude-code.claude_code_judge"
|
|
if node != "" {
|
|
key += "@" + node
|
|
}
|
|
within := JudgeWithin(question)
|
|
body := map[string]any{"question": question, "schema": schema, "model": model}
|
|
if within > JudgeFloor {
|
|
body["within"] = int(within / time.Second)
|
|
}
|
|
raw, err := ask(key, body)
|
|
if err != nil && body["within"] != nil && strings.Contains(err.Error(), "not within") {
|
|
// An agent module from before issue 508 takes no within: asked again without it, under its own bound.
|
|
delete(body, "within")
|
|
raw, err = ask(key, body)
|
|
}
|
|
// Past the judgement's bound the agent module ends it and says so; the deadline only guards against a
|
|
// module that answers pending for ever.
|
|
deadline := time.Now().Add(within + time.Minute)
|
|
for {
|
|
if err != nil {
|
|
return nil, "", err
|
|
}
|
|
var out struct {
|
|
Answer json.RawMessage `json:"answer"`
|
|
Model string `json:"model"`
|
|
Node string `json:"node"`
|
|
Ticket string `json:"ticket"`
|
|
Pending bool `json:"pending"`
|
|
}
|
|
if err := json.Unmarshal(raw, &out); err != nil {
|
|
return nil, "", fmt.Errorf("the agent module's answer is not a judgement: %.200s", raw)
|
|
}
|
|
if !out.Pending {
|
|
if len(out.Answer) == 0 {
|
|
return nil, "", fmt.Errorf("the agent module's answer is not a judgement: %.200s", raw)
|
|
}
|
|
return out.Answer, fmt.Sprintf("the agent on %s (%s)", out.Node, out.Model), nil
|
|
}
|
|
if out.Ticket == "" || out.Node == "" {
|
|
return nil, "", fmt.Errorf("the agent module answered pending with no ticket or node: %.200s", raw)
|
|
}
|
|
if time.Now().After(deadline) {
|
|
return nil, "", fmt.Errorf("the agent on %s still had not answered %s after its bound of %s", out.Node, out.Ticket, within)
|
|
}
|
|
raw, err = ask("claude-code.claude_code_judged@"+out.Node, map[string]any{"ticket": out.Ticket})
|
|
}
|
|
}
|
|
}
|
|
|
|
// busNotifier tells the operator's channel, as a warning: it is the one severity below urgent.
|
|
func busNotifier(key, summary, headline, explanation, resolved string) error {
|
|
body := map[string]any{"key": key, "subject": "the knowledge base",
|
|
"severity": "warning", "summary": summary, "headline": headline, "explanation": explanation}
|
|
if resolved != "" {
|
|
body["resolved"] = resolved
|
|
}
|
|
_, err := stdio.Ask("seat:operator-channel.notify", body)
|
|
return err
|
|
}
|
|
|
|
// busClearer tells the operator's channel that what it was told under key is over: the message is edited to say
|
|
// resolved (novox/hq issue 497).
|
|
func busClearer(key, resolved string) error {
|
|
_, err := stdio.Ask("seat:operator-channel.notify", map[string]any{"key": key, "subject": "the knowledge base",
|
|
"clear": true, "resolved": resolved})
|
|
return err
|
|
}
|
|
|
|
// ---- the store, opened when it is first needed ------------------------------------------------------------
|
|
|
|
// lazyStore opens the database on the first call, and again after a failure, so a database that arrives late is
|
|
// not fatal; and refuses to serve a schema behind this version.
|
|
type lazyStore struct {
|
|
open func(context.Context) (*PgStore, error)
|
|
mu sync.Mutex
|
|
pg *PgStore
|
|
}
|
|
|
|
func (l *lazyStore) store(ctx context.Context) (*PgStore, error) {
|
|
l.mu.Lock()
|
|
defer l.mu.Unlock()
|
|
if l.pg == nil {
|
|
pg, err := l.open(ctx)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("the knowledge base's database cannot be opened: %w", err)
|
|
}
|
|
l.pg = pg
|
|
}
|
|
if err := l.pg.Ready(ctx); err != nil {
|
|
return nil, err
|
|
}
|
|
return l.pg, nil
|
|
}
|
|
|
|
// Ready says why the store cannot serve, or nil.
|
|
func (l *lazyStore) Ready(ctx context.Context) error { _, err := l.store(ctx); return err }
|
|
|
|
func (l *lazyStore) Get(ctx context.Context, k Key) (*Entry, error) {
|
|
s, err := l.store(ctx)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return s.Get(ctx, k)
|
|
}
|
|
func (l *lazyStore) All(ctx context.Context) ([]*Entry, error) {
|
|
s, err := l.store(ctx)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return s.All(ctx)
|
|
}
|
|
func (l *lazyStore) Create(ctx context.Context, v Version) error {
|
|
s, err := l.store(ctx)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
return s.Create(ctx, v)
|
|
}
|
|
func (l *lazyStore) Save(ctx context.Context, v Version, current, clearPending bool, revision int64) error {
|
|
s, err := l.store(ctx)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
return s.Save(ctx, v, current, clearPending, revision)
|
|
}
|
|
func (l *lazyStore) Versions(ctx context.Context, k Key) ([]Version, error) {
|
|
s, err := l.store(ctx)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return s.Versions(ctx, k)
|
|
}
|
|
func (l *lazyStore) Search(ctx context.Context, q string, limit int) ([]Hit, error) {
|
|
s, err := l.store(ctx)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return s.Search(ctx, q, limit)
|
|
}
|
|
func (l *lazyStore) Inbound(ctx context.Context, k Key) ([]Inbound, error) {
|
|
s, err := l.store(ctx)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return s.Inbound(ctx, k)
|
|
}
|
|
func (l *lazyStore) SetFindings(ctx context.Context, f []Finding) error {
|
|
s, err := l.store(ctx)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
return s.SetFindings(ctx, f)
|
|
}
|
|
func (l *lazyStore) Findings(ctx context.Context) ([]Finding, error) {
|
|
s, err := l.store(ctx)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return s.Findings(ctx)
|
|
}
|
|
func (l *lazyStore) Told(ctx context.Context, topic string) (bool, error) {
|
|
s, err := l.store(ctx)
|
|
if err != nil {
|
|
return false, err
|
|
}
|
|
return s.Told(ctx, topic)
|
|
}
|
|
func (l *lazyStore) ToldOf(ctx context.Context, prefix string) ([]string, error) {
|
|
s, err := l.store(ctx)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return s.ToldOf(ctx, prefix)
|
|
}
|
|
func (l *lazyStore) Untold(ctx context.Context, key string) error {
|
|
s, err := l.store(ctx)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
return s.Untold(ctx, key)
|
|
}
|
|
|
|
// ---- prepare, check, dry-run --------------------------------------------------------------------------------
|
|
|
|
func prepare() int {
|
|
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
|
|
defer cancel()
|
|
s, err := PgStoreFromEnv(ctx)
|
|
if err != nil {
|
|
logf("[mesh-knowledge] preparing the database: %v", err)
|
|
return 1
|
|
}
|
|
defer s.Close()
|
|
said, err := s.Prepare(ctx)
|
|
for _, line := range said {
|
|
logf("[mesh-knowledge] %s", line)
|
|
}
|
|
if err != nil {
|
|
logf("[mesh-knowledge] preparing the database failed: %v", err)
|
|
return 1
|
|
}
|
|
return 0
|
|
}
|
|
|
|
// KeepEvery is how often keep judges the whole record set; WatchEvery how often watch looks.
|
|
const (
|
|
KeepEvery = 5 * time.Minute
|
|
WatchEvery = time.Minute
|
|
)
|
|
|
|
// keep judges the whole record set every few minutes and keeps the findings in the database: a change that
|
|
// reached the database by any way is judged within minutes, not only one an acceptance made.
|
|
func keep() int {
|
|
for {
|
|
ctx, cancel := context.WithTimeout(context.Background(), time.Minute)
|
|
s, err := PgStoreFromEnv(ctx)
|
|
if err == nil {
|
|
if err = s.Ready(ctx); err == nil {
|
|
k := &KB{Store: s, Logf: logf}
|
|
f := k.judgeSet(ctx)
|
|
logf("[mesh-knowledge] the whole record set judged: %d finding(s)", len(f))
|
|
}
|
|
s.Close()
|
|
}
|
|
if err != nil {
|
|
logf("[mesh-knowledge] the whole record set cannot be judged now: %v", err)
|
|
}
|
|
cancel()
|
|
time.Sleep(KeepEvery)
|
|
}
|
|
}
|
|
|
|
// watch reads, every minute, whether the database serves this version and what the whole-set findings say,
|
|
// and leaves its verdict in the status file.
|
|
func watch(status string) int {
|
|
if status == "" {
|
|
logf("[mesh-knowledge] watch was started without MESH_KNOWLEDGE_STATUS")
|
|
return 2
|
|
}
|
|
for {
|
|
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
|
var answer HealthAnswer
|
|
s, err := PgStoreFromEnv(ctx)
|
|
if err != nil {
|
|
answer = HealthAnswer{State: "unhealthy", Why: "the knowledge base's database cannot be opened: " + err.Error()}
|
|
} else {
|
|
answer = (&KB{Store: s}).Health(s)
|
|
s.Close()
|
|
}
|
|
cancel()
|
|
answer.At = time.Now().UTC()
|
|
if err := writeStatus(status, answer); err != nil {
|
|
logf("[mesh-knowledge] the status could not be written: %v", err)
|
|
}
|
|
time.Sleep(WatchEvery)
|
|
}
|
|
}
|
|
|
|
func writeStatus(path string, a HealthAnswer) error {
|
|
raw, err := json.Marshal(a)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
tmp := path + ".next"
|
|
if err := os.WriteFile(tmp, raw, 0o600); err != nil {
|
|
return err
|
|
}
|
|
return os.Rename(tmp, path)
|
|
}
|
|
|
|
// StatusStale is how old the watch's verdict may be before the health tool says the watch stopped looking.
|
|
const StatusStale = 3 * WatchEvery
|
|
|
|
// ReadStatus is the watch's last verdict, as the health tool answers it: unhealthy when there is none, or when it
|
|
// is older than StatusStale.
|
|
func ReadStatus(path string, now time.Time) HealthAnswer {
|
|
raw, err := os.ReadFile(path)
|
|
if err != nil {
|
|
return HealthAnswer{State: "unhealthy", Why: "the watch has left no verdict yet: " + err.Error()}
|
|
}
|
|
var a HealthAnswer
|
|
if err := json.Unmarshal(raw, &a); err != nil || (a.State != "healthy" && a.State != "unhealthy") {
|
|
return HealthAnswer{State: "unhealthy", Why: "the watch's verdict cannot be read"}
|
|
}
|
|
if age := now.Sub(a.At); age > StatusStale {
|
|
return HealthAnswer{State: "unhealthy", Why: fmt.Sprintf("the watch has not looked for %s (its last verdict: %s)", age.Round(time.Second), a.Why)}
|
|
}
|
|
a.Healthy = a.State == "healthy"
|
|
return a
|
|
}
|
|
|
|
// dryRunAtTheTerminal is the import's dry run over a checkout on disk, against an empty knowledge base: what the
|
|
// import would create, before the module is rolled out. Only the verb writes.
|
|
func dryRunAtTheTerminal(args []string) int {
|
|
if len(args) != 1 {
|
|
logf("usage: mesh-knowledge dry-run <hq checkout>")
|
|
return 2
|
|
}
|
|
k := &KB{Store: newMemStore(), Logf: logf}
|
|
out, err := k.Import(Unproven, ImportAsk{DryRun: true}, dirRecords{dir: args[0]})
|
|
if err != nil {
|
|
logf("%v", err)
|
|
return 1
|
|
}
|
|
enc := json.NewEncoder(os.Stdout)
|
|
enc.SetIndent("", " ")
|
|
_ = enc.Encode(out)
|
|
return 0
|
|
}
|
|
|
|
// ---- health ---------------------------------------------------------------------------------------------------
|
|
|
|
// Healther is a store that can say why it cannot serve.
|
|
type Healther interface {
|
|
Ready(ctx context.Context) error
|
|
}
|
|
|
|
// HealthAnswer is the health tool's answer (novox/hq ADR 0240, ADR 0283): healthy or unhealthy, and why; At is
|
|
// when the watch looked.
|
|
type HealthAnswer struct {
|
|
State string `json:"state"`
|
|
Healthy bool `json:"healthy"`
|
|
Why string `json:"why"`
|
|
At time.Time `json:"at,omitempty"`
|
|
}
|
|
|
|
// Health is whether the knowledge base works: unhealthy when its database cannot serve. A rule that judges the whole
|
|
// record set and fails is a fault of the records, which their authors repair, not of the module: it is told to the
|
|
// operator as a warning naming the rule and the records (TellFindings), and named here, but leaves the module
|
|
// healthy (the operator's decision of 2026-10-12 on novox/hq issue 504). So does a proposal that ran out of review
|
|
// tries (novox/hq issue 508). Neither holds the module's own rollouts.
|
|
func (k *KB) Health(ready Healther) HealthAnswer {
|
|
ctx, cancel := context.WithTimeout(context.Background(), 8*time.Second)
|
|
defer cancel()
|
|
if ready != nil {
|
|
if err := ready.Ready(ctx); err != nil {
|
|
return HealthAnswer{State: "unhealthy", Why: err.Error()}
|
|
}
|
|
}
|
|
findings, err := k.Store.Findings(ctx)
|
|
if err != nil {
|
|
return HealthAnswer{State: "unhealthy", Why: "the whole-set findings cannot be read: " + err.Error()}
|
|
}
|
|
var says []string
|
|
for _, f := range findings {
|
|
says = append(says, "the rule "+f.Rule+": "+f.Says)
|
|
}
|
|
sort.Strings(says)
|
|
all, err := k.Store.All(ctx)
|
|
if err != nil {
|
|
return HealthAnswer{State: "unhealthy", Why: "the records cannot be read: " + err.Error()}
|
|
}
|
|
waiting := ""
|
|
if unreviewed := Unreviewed(all); len(unreviewed) > 0 {
|
|
n := len(unreviewed)
|
|
if n > 8 {
|
|
unreviewed = append(unreviewed[:8], fmt.Sprintf("and %d more", n-8))
|
|
}
|
|
waiting = fmt.Sprintf("%d proposed record(s) could not be reviewed in %d tries and wait for their authors to propose them again: %s",
|
|
n, MostReviewTries, strings.Join(unreviewed, "; "))
|
|
}
|
|
why := "the database serves and every rule that judges the whole record set holds"
|
|
if len(says) > 0 {
|
|
if len(says) > 8 {
|
|
says = append(says[:8], fmt.Sprintf("and %d more", len(says)-8))
|
|
}
|
|
why = fmt.Sprintf("the database serves; %d rule(s) that judge the whole record set fail, told to the operator as a warning "+
|
|
"for the records' authors to repair: %s", len(findings), strings.Join(says, "; "))
|
|
}
|
|
if waiting != "" {
|
|
why += "; " + waiting
|
|
}
|
|
return HealthAnswer{State: "healthy", Healthy: true, Why: why}
|
|
}
|
|
|
|
func healthTool(status string, now func() time.Time) stdio.Tool {
|
|
return stdio.Tool{Name: HealthTool,
|
|
Description: "How the knowledge base is, as its watch last saw it: healthy when its database serves this version, unhealthy " +
|
|
"otherwise. A rule that judges the whole record set and fails — a decision accepted that nothing cites, a supersession " +
|
|
"or a change said on one end only, a record extending a superseded one — is named but leaves it healthy: it is told " +
|
|
"to the operator as a warning for the records' authors (novox/hq issue 504). So is a proposed record that ran out of " +
|
|
"review tries: it waits on its author (novox/hq issue 508).",
|
|
Run: func(map[string]any) (any, error) { return ReadStatus(status, now()), nil }}
|
|
}
|
|
|
|
// ---- the verbs --------------------------------------------------------------------------------------------------
|
|
|
|
// args reads one call's arguments strictly: an argument the verb does not take is refused, never ignored — and no
|
|
// argument can say who the caller is.
|
|
type args struct {
|
|
verb string
|
|
m map[string]any
|
|
}
|
|
|
|
func readArgs(verb string, m map[string]any, takes ...string) (args, error) {
|
|
allowed := map[string]bool{}
|
|
for _, k := range takes {
|
|
allowed[k] = true
|
|
}
|
|
var unknown []string
|
|
for k := range m {
|
|
if !allowed[k] {
|
|
unknown = append(unknown, k)
|
|
}
|
|
}
|
|
if len(unknown) > 0 {
|
|
sort.Strings(unknown)
|
|
return args{}, fmt.Errorf("%s takes %s; it does not take %s", verb, strings.Join(takes, ", "), strings.Join(unknown, ", "))
|
|
}
|
|
return args{verb: verb, m: m}, nil
|
|
}
|
|
|
|
func (a args) str(k string) (string, error) {
|
|
v, ok := a.m[k]
|
|
if !ok || v == nil {
|
|
return "", nil
|
|
}
|
|
s, ok := v.(string)
|
|
if !ok {
|
|
return "", fmt.Errorf("%s is text", k)
|
|
}
|
|
return s, nil
|
|
}
|
|
|
|
func (a args) strs(k string) ([]string, error) {
|
|
v, ok := a.m[k]
|
|
if !ok || v == nil {
|
|
return nil, nil
|
|
}
|
|
switch t := v.(type) {
|
|
case string:
|
|
return []string{t}, nil
|
|
case []any:
|
|
out := make([]string, 0, len(t))
|
|
for _, e := range t {
|
|
s, ok := e.(string)
|
|
if !ok {
|
|
return nil, fmt.Errorf("every entry of %s is text", k)
|
|
}
|
|
out = append(out, s)
|
|
}
|
|
return out, nil
|
|
}
|
|
return nil, fmt.Errorf("%s is a list of text", k)
|
|
}
|
|
|
|
func (a args) num(k string, required bool) (int, error) {
|
|
v, ok := a.m[k]
|
|
if !ok || v == nil {
|
|
if required {
|
|
return 0, fmt.Errorf("%s needs %s", a.verb, k)
|
|
}
|
|
return 0, nil
|
|
}
|
|
switch t := v.(type) {
|
|
case float64:
|
|
if t >= 0 && t <= 1e6 && t == math.Trunc(t) {
|
|
return int(t), nil
|
|
}
|
|
case string:
|
|
if kindNumber.MatchString(strings.TrimSpace(t)) {
|
|
return atoi(strings.TrimSpace(t)), nil
|
|
}
|
|
}
|
|
return 0, fmt.Errorf("%s is a whole number, such as 276", k)
|
|
}
|
|
|
|
func (a args) flag(k string) (bool, error) {
|
|
v, ok := a.m[k]
|
|
if !ok || v == nil {
|
|
return false, nil
|
|
}
|
|
b, ok := v.(bool)
|
|
if !ok {
|
|
return false, fmt.Errorf("%s is true or false", k)
|
|
}
|
|
return b, nil
|
|
}
|
|
|
|
// key reads kind (decision when absent) and number.
|
|
func (a args) key() (Key, error) {
|
|
kind, err := a.str("kind")
|
|
if err != nil {
|
|
return Key{}, err
|
|
}
|
|
if kind == "" {
|
|
kind = KindDecision
|
|
}
|
|
if !contains(Kinds, kind) {
|
|
return Key{}, fmt.Errorf("kind is decision or research, not %q", kind)
|
|
}
|
|
n, err := a.num("number", true)
|
|
if err != nil {
|
|
return Key{}, err
|
|
}
|
|
if n < 1 {
|
|
return Key{}, errors.New("number is a record's number, from 1")
|
|
}
|
|
return Key{kind, n}, nil
|
|
}
|
|
|
|
func text(d string) map[string]any { return map[string]any{"type": "string", "description": d} }
|
|
func texts(d string) map[string]any {
|
|
return map[string]any{"type": "array", "items": map[string]any{"type": "string"}, "description": d}
|
|
}
|
|
func integer(d string) map[string]any {
|
|
return map[string]any{"type": "integer", "minimum": 1, "description": d}
|
|
}
|
|
func flag(d string) map[string]any { return map[string]any{"type": "boolean", "description": d} }
|
|
func kindArg() map[string]any {
|
|
return map[string]any{"type": "string", "enum": Kinds, "description": "decision (the default) or research"}
|
|
}
|
|
func boundArg() map[string]any {
|
|
return map[string]any{"type": "integer", "minimum": LeastBound, "maximum": MostBound,
|
|
"description": "the most bytes the answer may hold (default 4096)"}
|
|
}
|
|
|
|
func schema(required []string, props map[string]any) map[string]any {
|
|
s := map[string]any{"type": "object", "properties": props, "additionalProperties": false}
|
|
if len(required) > 0 {
|
|
s["required"] = required
|
|
}
|
|
return s
|
|
}
|
|
|
|
// Tools are the seat's verbs, each said in plain words: what it does and what it refuses. caller is who called,
|
|
// as the runtime proves it.
|
|
func Tools(k *KB, caller func(map[string]any) string) []stdio.Tool {
|
|
return []stdio.Tool{
|
|
{Name: Seat + ".ask",
|
|
Description: "Ask what holds about something, in plain words — \"what holds about the merge window, and why\" — and get the " +
|
|
"decision records and research efforts that bear on it, ranked, with their matching clauses: each clause says whether " +
|
|
"it is in force, what changed or superseded it, or that it is only drafted and not the record's word; each record says " +
|
|
"what changed it since and how to read its reasoning. One call, within a bound it states (4 KiB unless asked for more), " +
|
|
"saying what it cut.",
|
|
Input: schema([]string{"question"}, map[string]any{"question": map[string]any{"type": "string", "maxLength": MostQuestion,
|
|
"description": "the question, in plain words, at most 500 characters"}, "bound": boundArg()}),
|
|
Run: func(m map[string]any) (any, error) {
|
|
a, err := readArgs("ask", m, "question", "bound")
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
q, err := a.str("question")
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
b, err := a.num("bound", false)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return k.Ask(q, b)
|
|
}},
|
|
{Name: Seat + ".get",
|
|
Description: "One record by its number: its title, status, topic, date and deciders; its clauses, each with whether it holds; " +
|
|
"its links both ways; what changed it since; its versions; its reviews, and the operator's ask when it waits for one. " +
|
|
"Its reasoning (context, options, consequences, how it is checked; a research effort's documents) only with reasoning, " +
|
|
"or one section by name; an earlier version with version. Within a bound it states, 4 KiB unless asked for more, " +
|
|
"saying what it cut.",
|
|
Input: schema([]string{"number"}, map[string]any{"kind": kindArg(), "number": integer("the record's number, such as 276"),
|
|
"reasoning": flag("include its reasoning whole"), "section": text("only this section of its reasoning, by name"),
|
|
"version": integer("an earlier version"), "bound": boundArg()}),
|
|
Run: func(m map[string]any) (any, error) {
|
|
a, err := readArgs("get", m, "kind", "number", "reasoning", "section", "version", "bound")
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
key, err := a.key()
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
g := GetAsk{Kind: key.Kind, Number: key.Number}
|
|
if g.Reasoning, err = a.flag("reasoning"); err != nil {
|
|
return nil, err
|
|
}
|
|
if g.Section, err = a.str("section"); err != nil {
|
|
return nil, err
|
|
}
|
|
if g.Version, err = a.num("version", false); err != nil {
|
|
return nil, err
|
|
}
|
|
if g.Bound, err = a.num("bound", false); err != nil {
|
|
return nil, err
|
|
}
|
|
return k.Get(g)
|
|
}},
|
|
{Name: Seat + ".holds",
|
|
Description: "The clauses in force now, for a topic of the reading order or for one record, each with the record it comes " +
|
|
"from; a clause changed in part says by what. Never a clause only drafted from prose: those are counted and not served. " +
|
|
"Within a bound it states, 4 KiB unless asked for more, saying what it cut.",
|
|
Input: schema(nil, map[string]any{"topic": text("a topic, such as the mesh"), "kind": kindArg(),
|
|
"number": integer("one record's number"), "bound": boundArg()}),
|
|
Run: func(m map[string]any) (any, error) {
|
|
a, err := readArgs("holds", m, "topic", "kind", "number", "bound")
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
topic, err := a.str("topic")
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
b, err := a.num("bound", false)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
var key *Key
|
|
if _, given := m["number"]; given {
|
|
kk, err := a.key()
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
key = &kk
|
|
}
|
|
if key != nil && topic != "" {
|
|
return nil, errors.New("holds takes a topic or a record's number, not both")
|
|
}
|
|
return k.Holds(topic, key, b)
|
|
}},
|
|
{Name: Seat + ".links",
|
|
Description: "A record's typed links, both ways: what it supersedes, changes (clause by clause), extends, became or cites, " +
|
|
"and every record that points at it, each with how it was found.",
|
|
Input: schema([]string{"number"}, map[string]any{"kind": kindArg(), "number": integer("the record's number")}),
|
|
Run: func(m map[string]any) (any, error) {
|
|
a, err := readArgs("links", m, "kind", "number")
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
key, err := a.key()
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return k.Links(key)
|
|
}},
|
|
{Name: Seat + ".history",
|
|
Description: "A record's versions, every one kept: who made it, when, why, and whether it was a progressive insight; with " +
|
|
"from and to, the difference between those two versions, line by line. Within a bound it states, 4 KiB unless asked for " +
|
|
"more, saying what it cut.",
|
|
Input: schema([]string{"number"}, map[string]any{"kind": kindArg(), "number": integer("the record's number"),
|
|
"from": integer("the earlier version to compare"), "to": integer("the later version to compare"), "bound": boundArg()}),
|
|
Run: func(m map[string]any) (any, error) {
|
|
a, err := readArgs("history", m, "kind", "number", "from", "to", "bound")
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
key, err := a.key()
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
from, err := a.num("from", false)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
to, err := a.num("to", false)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
b, err := a.num("bound", false)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return k.History(key, from, to, b)
|
|
}},
|
|
{Name: Seat + ".reserve",
|
|
Description: "Reserve the next decision or research number for a record you are about to propose, from the issue " +
|
|
"tracker's counter: never one another caller got. The number is spent whether or not its record is ever accepted; " +
|
|
"one never proposed is withdrawn after a week. Refused: a kind other than decision or research, an empty title.",
|
|
Input: schema([]string{"kind", "for"}, map[string]any{"kind": map[string]any{"type": "string", "enum": Kinds},
|
|
"for": text("the title of the record the number is for")}),
|
|
Run: func(m map[string]any) (any, error) {
|
|
a, err := readArgs("reserve", m, "kind", "for")
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
kind, err := a.str("kind")
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
title, err := a.str("for")
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return k.ReserveNumber(caller(m), kind, title)
|
|
}},
|
|
{Name: Seat + ".propose",
|
|
Description: "Propose a record whole under a reserved number: its title, topic, date and deciders; for a decision its " +
|
|
"clauses, each opening with a bold lead (\"**What is decided.** …\"), and its reasoning in sections (Context, " +
|
|
"Considered Options, Consequences, How it is checked, References); for a research effort its status and documents; its " +
|
|
"links (supersedes, changes \"0276 §4\", extends, became, cites), and whether it needs the operator, with the brief its ask shows. Refused, naming " +
|
|
"every reason at once and writing nothing, when it breaks one of the 30 rules that judge one record or its prose — a " +
|
|
"link to nothing, a clause without a bold lead, a superseded record cited alone, an unknown topic, a retired word. A " +
|
|
"proposal is revised by proposing it again; an accepted record changes only by a progressive insight (insight: what " +
|
|
"stood before). The knowledge base then reviews each version itself, in a judgement that sees only the record, the " +
|
|
"records it links and the rules. A decision that names no home and that no accepted record links is not judged: " +
|
|
"its review asks changes for the whole-set rule cited, which its acceptance would break. On approve it is accepted (a record that needs the operator waits for the operator's " +
|
|
"Approve), on changes it stays proposed with the findings for its author to revise, and when no judgement can be had " +
|
|
"it stays proposed and says so. Nothing proposed is in force.",
|
|
Input: schema([]string{"number", "title"}, map[string]any{
|
|
"kind": kindArg(), "number": integer("the number reserve gave"), "title": text("one line"),
|
|
"topic": text("for a decision: a topic of the reading order, such as the mesh"),
|
|
"date": text("the day, YYYY-MM-DD (today when absent)"),
|
|
"deciders": texts("who decided"),
|
|
"clauses": texts("a decision's clauses in order, each \"**Lead.** the statement\""),
|
|
"sections": map[string]any{"type": "array", "description": "the reasoning, in order: each a name and its markdown",
|
|
"items": map[string]any{"type": "object", "properties": map[string]any{"name": text("such as Context"), "text": text("markdown")},
|
|
"required": []string{"name", "text"}}},
|
|
"links": map[string]any{"type": "array", "description": "its links: kind, to (\"0276\", \"0276 §4\", \"research 045\", \"issue 365\", a design's path), from (the clause it goes out from)",
|
|
"items": map[string]any{"type": "object", "properties": map[string]any{"kind": map[string]any{"type": "string", "enum": LinkKinds},
|
|
"to": text("the target"), "from": integer("the clause of this record it goes out from")}, "required": []string{"kind", "to"}}},
|
|
"homes": texts("hq documents that cite it, such as a design's path; a decision needs one unless an accepted record links it"),
|
|
"needs-operator": flag("it holds only once the operator approves it"),
|
|
"brief": map[string]any{"type": "object", "description": "required with needs-operator: what the operator's phone shows " +
|
|
"in place of the clauses (issue 639) — a headline of at most 60 characters saying what it changes for the operator, " +
|
|
"not the title; a summary of two or three short sentences, at most 300 characters, on what changes, why and what it " +
|
|
"costs or risks; at most three points of at most 160 characters. No path, address, dotted name or long word",
|
|
"properties": map[string]any{"headline": text("what it changes for the operator"), "summary": text("two or three short sentences"),
|
|
"points": texts("at most three, only where they add something")}, "required": []string{"headline", "summary"}},
|
|
"status": map[string]any{"type": "string", "enum": ResearchStatuses, "description": "a research effort's status"},
|
|
"insight": text("for an accepted record: a progressive insight, saying what stood before"),
|
|
"why": text("one line: why this version"),
|
|
}),
|
|
Run: func(m map[string]any) (any, error) {
|
|
a, err := readArgs("propose", m, "kind", "number", "title", "topic", "date", "deciders", "clauses", "sections",
|
|
"links", "homes", "needs-operator", "brief", "status", "insight", "why")
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
key, err := a.key()
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
p := ProposeAsk{Kind: key.Kind, Number: key.Number}
|
|
for _, f := range []struct {
|
|
k string
|
|
p *string
|
|
}{{"title", &p.Title}, {"topic", &p.Topic}, {"date", &p.Date}, {"status", &p.Status}, {"insight", &p.Insight}, {"why", &p.Why}} {
|
|
if *f.p, err = a.str(f.k); err != nil {
|
|
return nil, err
|
|
}
|
|
}
|
|
for _, f := range []struct {
|
|
k string
|
|
p *[]string
|
|
}{{"deciders", &p.Deciders}, {"clauses", &p.Clauses}, {"homes", &p.Homes}} {
|
|
if *f.p, err = a.strs(f.k); err != nil {
|
|
return nil, err
|
|
}
|
|
}
|
|
if p.NeedsOperator, err = a.flag("needs-operator"); err != nil {
|
|
return nil, err
|
|
}
|
|
if err := decode(m["sections"], &p.Sections, "sections is a list of {name, text}"); err != nil {
|
|
return nil, err
|
|
}
|
|
if err := decode(m["links"], &p.Links, "links is a list of {kind, to, from}"); err != nil {
|
|
return nil, err
|
|
}
|
|
if m["brief"] != nil {
|
|
p.Brief = &Brief{}
|
|
if err := decode(m["brief"], p.Brief, "brief is {headline, summary, points}"); err != nil {
|
|
return nil, err
|
|
}
|
|
}
|
|
return k.Propose(caller(m), p)
|
|
}},
|
|
{Name: Seat + ".import",
|
|
Description: "Import hq's decision records and research efforts, read through the records module at the commit its checkout " +
|
|
"stands at, each whole at its own number as its first version naming that commit. It only creates a record that is not " +
|
|
"held, never overwrites one, never moves a number counter, and run again changes nothing and says so. A decision written " +
|
|
"as prose, or with no Decision section, waits for its clauses to be drafted and confirmed. With refresh it also reads " +
|
|
"again every record it holds that nobody changed since its import, and writes what it lost or misread as a new version " +
|
|
"naming why; a record changed since is never touched, and a second refresh changes nothing. With dry-run it writes " +
|
|
"nothing and says what it would do, record by record; with commit it refuses a checkout standing elsewhere.",
|
|
Input: schema(nil, map[string]any{"dry-run": flag("read everything, write nothing, say what would be done"),
|
|
"commit": text("the commit of hq the record must stand at (a prefix is enough)"),
|
|
"refresh": flag("also repair what it holds that only the import ever wrote, from hq at this commit")}),
|
|
Run: func(m map[string]any) (any, error) {
|
|
a, err := readArgs("import", m, "dry-run", "commit", "refresh")
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
var ask ImportAsk
|
|
if ask.DryRun, err = a.flag("dry-run"); err != nil {
|
|
return nil, err
|
|
}
|
|
if ask.Refresh, err = a.flag("refresh"); err != nil {
|
|
return nil, err
|
|
}
|
|
if ask.Commit, err = a.str("commit"); err != nil {
|
|
return nil, err
|
|
}
|
|
return k.Import(caller(m), ask, k.Records)
|
|
}},
|
|
{Name: Seat + ".return",
|
|
Description: "Send the clauses drafted from a prose decision back to be drafted again, with your reason, which the next " +
|
|
"drafting reads. Until they are drafted and confirmed again the record serves none. Refused: a record whose clauses its " +
|
|
"author wrote, an empty reason, and a record whose clauses another record changed or links by number (a redraft " +
|
|
"numbers them anew; a record that changes the drafted clause says it is wrong instead).",
|
|
Input: schema([]string{"number", "reason"}, map[string]any{"kind": kindArg(), "number": integer("the record's number"),
|
|
"reason": text("what is wrong with the drafted clauses")}),
|
|
Run: func(m map[string]any) (any, error) {
|
|
a, err := readArgs("return", m, "kind", "number", "reason")
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
key, err := a.key()
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
reason, err := a.str("reason")
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return k.Return(caller(m), key, reason)
|
|
}},
|
|
}
|
|
}
|
|
|
|
// decode reads a structured argument strictly.
|
|
func decode(v any, into any, says string) error {
|
|
if v == nil {
|
|
return nil
|
|
}
|
|
raw, err := json.Marshal(v)
|
|
if err != nil {
|
|
return errors.New(says)
|
|
}
|
|
dec := json.NewDecoder(strings.NewReader(string(raw)))
|
|
dec.DisallowUnknownFields()
|
|
if err := dec.Decode(into); err != nil {
|
|
return fmt.Errorf("%s (%v)", says, err)
|
|
}
|
|
return nil
|
|
}
|