Merge pull request 'knowledge-base.progress: how far a design, milestone, issue or record has come, in one call (issue 1947)' (#504) from feat/1947-progress into main

This commit was merged in pull request #504.
This commit is contained in:
2026-10-26 16:42:30 +00:00
23 changed files with 3652 additions and 12 deletions
+1 -1
View File
@@ -19,7 +19,7 @@ what it refuses.
|---|---|---|
| `open` | `title`, `report`, optionally `located-in`, `record` | checks the issue, then takes the next number and keeps it under that number; answers the number |
| `get` | `number`, optionally `brief` | the issue whole: fields, notes, links; with `brief`, a short answer with only its last note |
| `list` | optionally `status`, `owner`, `phrase`, `limit`, `severity`, `needs`, `ready`, `flag` (`delivered-not-resolved` or `abandoned`), `search` (words, ranked), `before` (a number, the cursor), `numbers` (only these issues, in the order given, naming any not an issue in `missing`), `worker` (only that worker's held issues) | short rows, newest first, or best first for a `search`, or in the order given for `numbers`: number, status, owner, title, and each flag that holds; `next`, the `before` that reads on, when more match |
| `list` | optionally `status`, `owner`, `phrase`, `limit`, `severity`, `needs`, `ready`, `flag` (`delivered-not-resolved` or `abandoned`), `search` (words, ranked), `before` (a number, the cursor), `numbers` (only these issues, in the order given, naming any not an issue in `missing`), `worker` (only that worker's held issues), `linked` (only the issues with that link, as `kind:target`, such as `decision:0493`) | short rows, newest first, or best first for a `search`, or in the order given for `numbers`: number, status, owner, title, `moved` (when the issue last changed: opened, moved, noted, linked, worked on, or its fix's delivery said a new state), and each flag that holds; `next`, the `before` that reads on, when more match |
| `move` | `number`, `status`, and what that status needs (`by` for `diagnosing` and `fixing`, optionally `take-over`) | changes the status under the development cycle's rules |
| `note` | `number`, `text` | appends a note; a note is never edited |
| `link` | `number`, `kind`, `target`, optionally `remove` | adds or removes a typed link |
@@ -0,0 +1,76 @@
package main
import (
"reflect"
"testing"
"time"
)
// 1947/AC4: each list row says when its issue last moved, and linked keeps only the issues with that link, so the
// knowledge base's progress reads both in one call.
func TestListSaysWhenEachIssueMovedAndKeepsTheLinkedOnes(t *testing.T) {
h := newHarness(t)
clock := time.Date(2026, 10, 26, 12, 0, 0, 0, time.UTC)
h.Tracker.Now = func() time.Time { return clock }
quiet := h.open(t, "opened and left")
noted := h.open(t, "noted later")
linked := h.open(t, "linked to a decision")
clock = clock.Add(time.Hour)
if _, err := h.Note(noted, "seen again"); err != nil {
t.Fatal(err)
}
clock = clock.Add(time.Hour)
if _, err := h.Link(LinkAsk{Number: linked, Kind: LinkDecision, Target: "0493"}); err != nil {
t.Fatal(err)
}
if _, err := h.Link(LinkAsk{Number: quiet, Kind: LinkDesign, Target: "03-DESIGN/01-to-be/47-delivery-from-commit-to-delivered.md"}); err != nil {
t.Fatal(err)
}
start := time.Date(2026, 10, 26, 12, 0, 0, 0, time.UTC)
for _, c := range []struct {
name string
ask ListAsk
want []uint64
moved []time.Time
refuse bool
}{
{"numbers say moved", ListAsk{Numbers: []uint64{quiet, noted, linked}}, []uint64{quiet, noted, linked},
[]time.Time{start.Add(2 * time.Hour), start.Add(time.Hour), start.Add(2 * time.Hour)}, false},
{"linked to a decision", ListAsk{Linked: "decision:0493"}, []uint64{linked}, nil, false},
{"linked to a design", ListAsk{Linked: " design:03-DESIGN/01-to-be/47-delivery-from-commit-to-delivered.md"}, []uint64{quiet}, nil, false},
{"linked and numbers together", ListAsk{Linked: "decision:0493", Numbers: []uint64{quiet, noted}}, nil, nil, false},
{"a link nobody has", ListAsk{Linked: "decision:0001"}, nil, nil, false},
{"no colon", ListAsk{Linked: "decision"}, nil, nil, true},
{"an unknown kind", ListAsk{Linked: "milestone:R6"}, nil, nil, true},
{"a target of the wrong shape", ListAsk{Linked: "decision:ADR 0493"}, nil, nil, true},
} {
l, err := h.List(c.ask)
if c.refuse {
if err == nil {
t.Errorf("%s: listed", c.name)
}
continue
}
if err != nil {
t.Fatalf("%s: %v", c.name, err)
}
var got []uint64
var moved []time.Time
for _, r := range l.Rows {
got = append(got, r.Number)
if r.Moved == nil {
t.Fatalf("%s: row %d says no moved", c.name, r.Number)
}
moved = append(moved, *r.Moved)
}
if !reflect.DeepEqual(got, c.want) {
t.Errorf("%s: %v, want %v (%s)", c.name, got, c.want, l.Said)
}
if c.moved != nil && !reflect.DeepEqual(moved, c.moved) {
t.Errorf("%s: moved %v, want %v", c.name, moved, c.moved)
}
}
if _, err := h.call(t, "list", map[string]any{"linked": "decision:0493"}); err != nil {
t.Fatalf("the verb does not take linked: %v", err)
}
}
+8 -2
View File
@@ -464,7 +464,9 @@ func Tools(t *Tracker) []stdio.Tool {
"what is open in one call. search ranks the issues whose title, report or notes hold its words, best first; " +
"before pages the newest-first list: give the answer's next to read on below it. numbers answers only " +
"those issues, rows in the order given, naming any that is not an issue in missing; worker (who@node) " +
"keeps only the issues in diagnosing or fixing whose worker is exactly that.",
"keeps only the issues in diagnosing or fixing whose worker is exactly that; linked (kind:target, such as " +
"decision:0493) keeps only the issues with that link. Each row says when the issue last moved (moved): " +
"opened, moved, noted, linked, worked on, or its fix's delivery said a new state.",
Input: schema(nil, map[string]any{
"status": map[string]any{"type": "string", "enum": Statuses, "description": "only issues at this status"},
"owner": text("only issues located in this owner, such as mesh-controller"),
@@ -479,9 +481,10 @@ func Tools(t *Tracker) []stdio.Tool {
"numbers": map[string]any{"type": "array", "items": map[string]any{"type": "integer", "minimum": 1},
"maxItems": MostNumbers, "description": "only these issues, rows in the order given; one not an issue is named in missing"},
"worker": text("only issues in diagnosing or fixing whose worker is exactly this, who@node"),
"linked": text("only issues with this link, as kind:target, such as decision:0493 or design:03-DESIGN/01-to-be/47-delivery-from-commit-to-delivered.md"),
}),
Run: func(m map[string]any) (any, error) {
a, err := readArgs("list", m, "status", "owner", "phrase", "limit", "severity", "needs", "ready", "flag", "search", "before", "numbers", "worker")
a, err := readArgs("list", m, "status", "owner", "phrase", "limit", "severity", "needs", "ready", "flag", "search", "before", "numbers", "worker", "linked")
if err != nil {
return nil, err
}
@@ -510,6 +513,9 @@ func Tools(t *Tracker) []stdio.Tool {
if ask.Worker, err = a.str("worker"); err != nil {
return nil, err
}
if ask.Linked, err = a.str("linked"); err != nil {
return nil, err
}
if ask.Before, err = a.number("before", false); err != nil {
return nil, err
}
@@ -708,6 +708,10 @@ type ListAsk struct {
// Worker keeps only the issues in diagnosing or fixing whose worker is exactly this, who@node (novox/hq
// issue 1070).
Worker string
// Linked keeps only the issues with this link, given as kind:target, such as decision:0493 or
// design:03-DESIGN/01-to-be/47-delivery-from-commit-to-delivered.md (novox/hq issue 1947): what the knowledge
// base's progress reads to name the issues that build a record or a design (ADR 0424 §2).
Linked string
}
// MostNumbers is the most `numbers` may name in one list.
@@ -735,6 +739,61 @@ type Row struct {
// did nothing on it for worker-stale-after and no pull request of it is open (novox/hq issue 483).
DeliveredNotResolved bool `json:"delivered-not-resolved,omitempty"`
Abandoned bool `json:"abandoned,omitempty"`
// Moved is when the issue last changed: opened, moved, noted, linked, its worker active, or its fix's delivery
// said a new state (novox/hq issue 1947), so a progress report says when each thing last moved in one call.
Moved *time.Time `json:"moved,omitempty"`
}
// lastMoved is when an issue last changed, as its own record says: the latest of its opening, its closing, its
// notes, its links, its worker's start and last act, its import and what the delivery last said of each fix.
func lastMoved(is *Issue) time.Time {
at := is.Opened
later := func(t time.Time) {
if t.After(at) {
at = t
}
}
if is.Closed != nil {
later(*is.Closed)
}
for _, n := range is.Notes {
later(n.At)
}
for _, l := range is.Links {
later(l.At)
}
if w := is.Worker; w != nil {
later(w.Since)
later(w.Active)
}
if is.Imported != nil {
later(is.Imported.At)
}
for _, f := range is.Fixes {
later(f.Since)
}
return at
}
// checkLinked is a list's linked filter, kind:target, said the one way a link of that kind is kept.
func checkLinked(s string) (kind, target string, err error) {
k, t, ok := strings.Cut(strings.TrimSpace(s), ":")
if !ok {
return "", "", fmt.Errorf("linked is kind:target, such as decision:0493, not %q", s)
}
k = strings.TrimSpace(k)
t, err = checkTarget(k, t)
return k, t, err
}
// linkedTo says whether an issue has the link of kind to target, as kept.
func linkedTo(is *Issue, kind, target string) bool {
for _, l := range is.Links {
if l.Kind == kind && l.Target == target {
return true
}
}
return false
}
// Listed is the answer to a list.
@@ -815,6 +874,12 @@ func (t *Tracker) List(a ListAsk) (*Listed, error) {
"or more words, instead")
}
}
linkKind, linkTarget := "", ""
if strings.TrimSpace(a.Linked) != "" {
if linkKind, linkTarget, err = checkLinked(a.Linked); err != nil {
return nil, err
}
}
worker := ""
if strings.TrimSpace(a.Worker) != "" {
if worker, err = checkWorkerField("worker", a.Worker); err != nil {
@@ -899,6 +964,9 @@ func (t *Tracker) List(a ListAsk) (*Listed, error) {
if worker != "" && (is.Worker == nil || is.Worker.By != worker) {
continue
}
if linkKind != "" && !linkedTo(is, linkKind, linkTarget) {
continue
}
if lower != "" && !strings.Contains(strings.ToLower(is.Title), lower) && !strings.Contains(strings.ToLower(is.Report), lower) {
continue
}
@@ -912,6 +980,8 @@ func (t *Tracker) List(a ListAsk) (*Listed, error) {
}
row.DeliveredNotResolved = deliveredNotResolved(is)
row.Abandoned = abandoned(is, now, staleAfter)
moved := lastMoved(is)
row.Moved = &moved
out.Rows = append(out.Rows, row)
}
}
+1
View File
@@ -27,6 +27,7 @@ operator, it is accepted only on the operator's Approve, given on a channel that
| `import` | `dry-run`, `commit` | hq's decision records and research efforts at the commit the records module stands at, each whole at its own number |
| `return` | `number`, `reason` | drafted clauses sent back to be drafted again; refused for a record whose clauses another record changed or links by number |
| `withdraw` | `number`, `kind`, `why` | a reserved or proposed record withdrawn, as a new version naming why; its number stays spent. A pending review is cleared and a waiting operator's ask is ended. Refused for a record already accepted, superseded or withdrawn |
| `progress` | `of` (`mesh`, `design <n>`, `milestone <design>/<id>`, `issue <n>`, `decision <n>` or `ADR <n>`, `research <n>`), `detail` (`minimal` or `detailed`), `bound` | how far it has come (hq issue 1947): each child done ✓, moving ▶, waiting ⏸ or stuck ✗ with when it last moved, worked out from the issue tracker's rows and mesh-delivery's deliveries and groups, never written (ADR 0424 §1). `minimal` is one line per milestone, for a report every five minutes (two or three bus calls); `detailed` the report's tree with each issue's pull requests, the groups holding them and what it depends on. Milestones are read from the design's `## Milestones` table through the records module, cached by hq's commit, until the issue tracker holds milestone records (issue 1781); what the table does not say for certain is said in `warnings` |
`ask`, `get`, `holds` and `history` stay within `bound` bytes (4096 unless asked for, at most 65536) and say what they cut
and how to read the rest. The module's own tool `knowledge_health` answers its health (below).
@@ -72,7 +72,7 @@ func newBusWorld(t *testing.T) *busWorld {
t.Fatal(err)
}
// What stands on the other side: the issue tracker, the records module, the agent's judgement.
records := fixture()
records := withDesigns{fixture(), progressRecords()}
answer := func(key string, do func(body json.RawMessage) (any, error)) {
if _, err := nc.Subscribe("ask."+key, func(m *nats.Msg) {
w.mu.Lock()
@@ -0,0 +1,281 @@
package main
// A design as the progress verb reads it (novox/hq issue 1947): its title, its state as its frontmatter says it, the
// decisions it names, and its milestones from its `## Milestones` table. Designs are still documents in hq, read
// through the records module, until ADR 0423 makes them records of the knowledge base (issue 428); then this reads
// those records instead.
//
// Milestones are read from the design's table until the issue tracker holds milestone records (ADR 0493 §1, §4,
// built under issue 1781); when 1781 is delivered, the table reader is replaced by a read of those records and this
// parse is deleted (the lead of design 47, 2026-10-26). The Issues cell is prose, so what the parse cannot read
// for certain it says as a warning, never hides: an issue in two milestones (against ADR 0493 §2), a range written
// in words, a row naming no issue.
import (
"fmt"
"regexp"
"sort"
"strconv"
"strings"
)
// DesignsFolder is where the designs being built are, in hq.
const DesignsFolder = "03-DESIGN/01-to-be"
// Design is one design as read.
type Design struct {
Number int
Path string
Title string
Status string
Decisions []string
Milestones []Milestone
Warnings []string
}
// Milestone is one row of a design's milestone table.
type Milestone struct {
ID string
Outcome string
Issues []int
}
var (
designFile = regexp.MustCompile(`^([0-9]{2,3})-[a-z0-9-]+\.md$`)
titleLine = regexp.MustCompile(`^#\s+[0-9]+\s+[—-]\s+(.+)$`)
decisionItem = regexp.MustCompile(`ADR ([0-9]{4})`)
// notAnIssue is a number the Issues cell names that is not an issue: a record, a clause, a test, a release, a pull
// request, a document.
notAnIssue = regexp.MustCompile(`(?i)(ADR|research|acceptance tests?|tests?|§|clauses?|release|design|to-be|as-is|playbook|version)\s*[0-9][0-9, and]*|[a-z-]*#[0-9]+|[0-9]{4}-[0-9]{2}-[0-9]{2}|[0-9]{1,2}:[0-9]{2}`)
rangeWords = regexp.MustCompile(`\b([0-9]{3,5}) to ([0-9]{3,5})\b`)
butOne = regexp.MustCompile(`\bbut ([0-9]{3,5})\b`)
issueNum = regexp.MustCompile(`\b[0-9]{3,5}\b`)
)
// MostRange is the widest range in words the parse expands; a wider one is said and not expanded.
const MostRange = 60
// parseDesign reads a design document.
func parseDesign(path, content string) *Design {
d := &Design{Path: path}
if m := designFile.FindStringSubmatch(lastPart(path)); m != nil {
d.Number, _ = strconv.Atoi(m[1])
}
lines := strings.Split(content, "\n")
body := 0
if len(lines) > 0 && strings.TrimSpace(lines[0]) == "---" {
for i := 1; i < len(lines); i++ {
l := lines[i]
if strings.TrimSpace(l) == "---" {
body = i + 1
break
}
if v, ok := strings.CutPrefix(l, "status:"); ok {
d.Status = strings.TrimSpace(v)
}
for _, m := range decisionItem.FindAllStringSubmatch(l, -1) {
d.Decisions = append(d.Decisions, "ADR "+m[1])
}
}
}
inMilestones, header := false, -1
issueCol, outcomeCol := -1, -1
seen := map[int]string{}
for i := body; i < len(lines); i++ {
l := strings.TrimSpace(lines[i])
if d.Title == "" {
if m := titleLine.FindStringSubmatch(l); m != nil {
d.Title = strings.TrimSpace(m[1])
}
}
if strings.HasPrefix(l, "## ") {
inMilestones = strings.EqualFold(strings.TrimSpace(strings.TrimPrefix(l, "## ")), "Milestones")
header = -1
continue
}
if !inMilestones || !strings.HasPrefix(l, "|") {
continue
}
cells := cellsOf(l)
if header < 0 {
header = i
issueCol, outcomeCol = -1, -1
for c, h := range cells {
switch strings.ToLower(h) {
case "issues", "issue":
issueCol = c
case "outcome":
outcomeCol = c
}
}
continue
}
if i == header+1 && strings.Trim(l, "|-: ") == "" {
continue
}
if issueCol < 0 || issueCol >= len(cells) || len(cells) == 0 {
continue
}
m := Milestone{ID: cells[0]}
if outcomeCol >= 0 && outcomeCol < len(cells) {
m.Outcome = cells[outcomeCol]
}
var warn []string
m.Issues, warn = issuesOf(cells[issueCol])
for _, w := range warn {
d.Warnings = append(d.Warnings, fmt.Sprintf("milestone %s: %s", m.ID, w))
}
if len(m.Issues) == 0 {
d.Warnings = append(d.Warnings, fmt.Sprintf("milestone %s names no issue", m.ID))
}
for _, n := range m.Issues {
if other, ok := seen[n]; ok && other != m.ID {
d.Warnings = append(d.Warnings, fmt.Sprintf("issue %d is in milestones %s and %s; an issue belongs to at most one (ADR 0493 §2)", n, other, m.ID))
continue
}
seen[n] = m.ID
}
d.Milestones = append(d.Milestones, m)
}
return d
}
// issuesOf is the issue numbers a table's Issues cell names, in order, and what it could not read for certain.
func issuesOf(cell string) ([]int, []string) {
var warn []string
s := notAnIssue.ReplaceAllString(cell, " ")
var nums []int
for _, m := range rangeWords.FindAllStringSubmatch(s, -1) {
lo, _ := strconv.Atoi(m[1])
hi, _ := strconv.Atoi(m[2])
if hi < lo || hi-lo > MostRange {
warn = append(warn, fmt.Sprintf("%q is a range in words the reader does not expand", m[0]))
continue
}
warn = append(warn, fmt.Sprintf("%q is a range in words, read as the %d issues %d to %d", m[0], hi-lo+1, lo, hi))
for n := lo; n <= hi; n++ {
nums = append(nums, n)
}
}
but := map[int]bool{}
for _, m := range butOne.FindAllStringSubmatch(s, -1) {
n, _ := strconv.Atoi(m[1])
but[n] = true
}
s = rangeWords.ReplaceAllString(s, " ")
s = butOne.ReplaceAllString(s, " ")
for _, m := range issueNum.FindAllString(s, -1) {
n, _ := strconv.Atoi(m)
nums = append(nums, n)
}
out, have := []int{}, map[int]bool{}
for _, n := range nums {
if !have[n] && !but[n] {
have[n] = true
out = append(out, n)
}
}
return out, warn
}
func cellsOf(row string) []string {
row = strings.TrimSpace(row)
row = strings.TrimPrefix(row, "|")
row = strings.TrimSuffix(row, "|")
parts := strings.Split(row, "|")
for i := range parts {
parts[i] = strings.TrimSpace(parts[i])
}
return parts
}
func lastPart(p string) string {
if i := strings.LastIndex(p, "/"); i >= 0 {
return p[i+1:]
}
return p
}
// milestone finds a milestone by its id, as the table writes it, without regard to case or spaces.
func (d *Design) milestone(id string) *Milestone {
want := strings.ToLower(strings.Join(strings.Fields(id), " "))
for i := range d.Milestones {
if strings.ToLower(strings.Join(strings.Fields(d.Milestones[i].ID), " ")) == want {
return &d.Milestones[i]
}
}
return nil
}
// issues is every issue its milestones name, each once, in order.
func (d *Design) issues() []int {
var out []int
have := map[int]bool{}
for _, m := range d.Milestones {
for _, n := range m.Issues {
if !have[n] {
have[n] = true
out = append(out, n)
}
}
}
return out
}
// designsCache is the designs read at one commit of hq: read again only when the records module's checkout moves.
type designsCache struct {
commit string
paths []string
byPath map[string]*Design
}
// designs answers the designs being built, read at the commit the records module stands at, from the cache while
// that commit stands (novox/hq issue 1947, 1947/AC6).
func (k *KB) designs(all bool, number int) ([]*Design, string, error) {
if k.Records == nil {
return nil, "", fmt.Errorf("the knowledge base reads no records here")
}
commit, err := k.Records.Standing()
if err != nil {
return nil, "", fmt.Errorf("the records module does not say where hq stands: %v", err)
}
k.progMu.Lock()
defer k.progMu.Unlock()
c := k.progCache
if c == nil || c.commit != commit {
c = &designsCache{commit: commit, byPath: map[string]*Design{}}
_, docs, err := k.Records.List(DesignsFolder)
if err != nil {
return nil, "", fmt.Errorf("the records module does not list %s: %v", DesignsFolder, err)
}
for _, doc := range docs {
if designFile.MatchString(doc) {
c.paths = append(c.paths, DesignsFolder+"/"+doc)
}
}
sort.Strings(c.paths)
k.progCache = c
}
var out []*Design
for _, p := range c.paths {
n, _ := strconv.Atoi(designFile.FindStringSubmatch(lastPart(p))[1])
if !all && n != number {
continue
}
d := c.byPath[p]
if d == nil {
content, _, err := k.Records.Read(p)
if err != nil {
return nil, "", fmt.Errorf("the records module does not read %s: %v", p, err)
}
d = parseDesign(p, content)
c.byPath[p] = d
k.progReads++
}
out = append(out, d)
}
if !all && len(out) == 0 {
return nil, "", fmt.Errorf("no design %d is in %s at hq %s", number, DesignsFolder, short(commit))
}
return out, commit, nil
}
@@ -99,6 +99,13 @@ type KB struct {
refMu sync.Mutex
ref *Reference
refAt time.Time
// Seats reaches another seat's verb or a module's tool over the bus, for progress (novox/hq issue 1947).
Seats SeatAsk
// progCache is the designs read at one commit of hq; progReads counts the documents read, for its test.
progMu sync.Mutex
progCache *designsCache
progReads int
}
func (k *KB) now() time.Time {
@@ -37,7 +37,7 @@ import (
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", "list", "findings", "holds", "links", "history", "reserve", "propose", "import", "return", "withdraw"}
var Verbs = []string{"ask", "get", "list", "findings", "holds", "links", "history", "reserve", "propose", "import", "return", "withdraw", "progress"}
// HealthTool is the module's own tool the node-engine asks how it is.
const HealthTool = "knowledge_health"
@@ -83,6 +83,7 @@ func newKB(store Store) *KB {
return &KB{
Store: store,
Records: busRecords{},
Seats: stdio.Ask,
Reserve: busReserver,
Judge: busJudgement(os.Getenv("MESH_NODE")),
Asks: sdkAskBus{},
@@ -1120,6 +1121,39 @@ func Tools(k *KB, caller func(map[string]any) string) []stdio.Tool {
}
return k.WithdrawRecord(caller(m), key, why)
}},
{Name: Seat + ".progress",
Description: "How far something has come, in one call: of mesh (every design in progress), design <n> (its " +
"milestones), milestone <design>/<id> (its issues, such as milestone 47/R6), issue <n> (its pull requests " +
"and deliveries) or decision <n> / research <n> (the designs naming it and the issues linked to it). Each " +
"thing is done ✓, moving ▶, waiting ⏸ or stuck ✗, worked out from the issue tracker and mesh-delivery " +
"and never written by anyone, with when it last moved. detail minimal (the default) gives one line per " +
"milestone, cheap enough for a report every five minutes; detailed gives the report's tree: each " +
"milestone not done with its issues, their pull requests, the groups holding them and what each waits " +
"for, for about every thirty. Milestones are read from the design's milestone table until the issue " +
"tracker holds milestone records (issue 1781); what the table does not say for certain is said in " +
"warnings. Within a bound it states, 4 KiB unless asked for more, saying what it cut.",
Input: schema([]string{"of"}, map[string]any{
"of": text("what: mesh, design 47, milestone 47/R6, issue 1945, decision 493 (or ADR 0493) or research 72"),
"detail": map[string]any{"type": "string", "enum": []string{DetailMinimal, DetailDetailed},
"description": "minimal (the default): one line per child; detailed: the tree with issues, pull requests and groups"},
"bound": boundArg()}),
Run: func(m map[string]any) (any, error) {
a, err := readArgs("progress", m, "of", "detail", "bound")
if err != nil {
return nil, err
}
var p ProgressAsk
if p.Of, err = a.str("of"); err != nil {
return nil, err
}
if p.Detail, err = a.str("detail"); err != nil {
return nil, err
}
if p.Bound, err = a.num("bound", false); err != nil {
return nil, err
}
return k.Progress(p)
}},
}
}
@@ -62,8 +62,10 @@ func TestItDeclaresAndHoldsTheKnowledgeBaseSeatOnceForTheMesh(t *testing.T) {
if m.Module != Module || !reflect.DeepEqual(m.Seats, want) || !reflect.DeepEqual(m.Claims, want) {
t.Fatalf("module %q, seats %+v, claims %+v", m.Module, m.Seats, m.Claims)
}
if !reflect.DeepEqual(Verbs, []string{"ask", "get", "list", "findings", "holds", "links", "history", "reserve", "propose", "import", "return", "withdraw"}) {
t.Fatalf("the verbs are not ADR 0290 rule 6's: %v", Verbs)
// ADR 0290 rule 6's verbs and those added after it; progress says a design's state, which ADR 0424 §1 makes the
// knowledge base's to work out (novox/hq issue 1947).
if !reflect.DeepEqual(Verbs, []string{"ask", "get", "list", "findings", "holds", "links", "history", "reserve", "propose", "import", "return", "withdraw", "progress"}) {
t.Fatalf("the verbs are not ADR 0290 rule 6's and those added after it: %v", Verbs)
}
if !reflect.DeepEqual(m.Emits, Events) {
t.Fatalf("emits %v, the code says %v", m.Emits, Events)
@@ -77,11 +79,12 @@ func TestItDeclaresAndHoldsTheKnowledgeBaseSeatOnceForTheMesh(t *testing.T) {
t.Fatalf("uses %v, consumes %v", m.Uses, m.Consumes)
}
if !reflect.DeepEqual(m.Invokes, []string{"seat:issue-tracker.reserve", "claude-code.claude_code_judge", "claude-code.claude_code_judged", "records.records_status",
"records.records_list", "records.records_read", "seat:operator-channel.notify"}) {
"records.records_list", "records.records_read", "seat:operator-channel.notify", "seat:issue-tracker.list", "seat:issue-tracker.get",
"seat:mesh-delivery.deliveries", "seat:mesh-delivery.show", "seat:mesh-delivery.groups"}) {
t.Fatalf("invokes %v", m.Invokes)
}
src := ""
for _, f := range []string{"main.go", "hq.go"} {
for _, f := range []string{"main.go", "hq.go", "progress.go"} {
src += readFile(t, f)
}
for _, inv := range m.Invokes {
@@ -0,0 +1,857 @@
package main
// progress: how far a design, a milestone, an issue or a record has come, in one call (novox/hq issue 1947). The
// knowledge base works a design's state out and nobody writes it (ADR 0424 §1, §2); this verb says it at every
// level, in two forms: minimal, one line per child, cheap enough for every five minutes; detailed, the tree of a
// lead's report with the pull requests, the held groups and what waits on whom, for about every thirty.
//
// It keeps no state of its own. It reads, each a request and reply over the bus: the designs through the records
// module (design.go, cached by hq's commit), the issues from the issue tracker (`issue-tracker.list`, whose rows
// say each issue's delivery and when it last moved), and the deliveries and groups from mesh-delivery.
import (
"encoding/json"
"errors"
"fmt"
"regexp"
"sort"
"strconv"
"strings"
"time"
)
// The two forms of an answer.
const (
DetailMinimal = "minimal"
DetailDetailed = "detailed"
)
// The states, each with the mark a report draws.
const (
StateDone = "done"
StateMoving = "moving"
StateWaiting = "waiting"
StateStuck = "stuck"
)
var marks = map[string]string{StateDone: "✓", StateMoving: "▶", StateWaiting: "⏸", StateStuck: "✗"}
// rank orders the states from best to worst, for a parent taking its children's.
var rank = map[string]int{StateDone: 0, StateWaiting: 1, StateMoving: 2, StateStuck: 3}
// The most a detailed answer reads one by one, so one call stays one call's cost.
const (
MostShows = 30
MostGets = 20
// MostIssuesAsked is the most the tracker's list answers by numbers in one call.
MostIssuesAsked = 200
)
// SeatAsk asks another seat's verb or a module's tool over the bus and answers its result (stdio.Ask).
type SeatAsk func(key string, body any) (json.RawMessage, error)
// ProgressAsk is what progress is asked.
type ProgressAsk struct {
Of string
Detail string
Bound int
}
// Item is one thing whose progress is said: a design, a milestone, an issue, a pull request.
type Item struct {
ID string `json:"id"`
State string `json:"state"`
Title string `json:"title,omitempty"`
Done int `json:"done,omitempty"`
Total int `json:"total,omitempty"`
Moved *time.Time `json:"moved,omitempty"`
Why string `json:"why,omitempty"`
Children []Item `json:"children,omitempty"`
}
// Progress is the answer.
type Progress struct {
Of string `json:"of"`
Detail string `json:"detail"`
State string `json:"state"`
Tree string `json:"tree"`
Items []Item `json:"items,omitempty"`
Warnings []string `json:"warnings,omitempty"`
Source string `json:"source"`
ReadAt time.Time `json:"read-at"`
Cut string `json:"cut,omitempty"`
}
// trackerRow is a row of `issue-tracker.list`.
type trackerRow struct {
Number int `json:"number"`
Status string `json:"status"`
Title string `json:"title"`
Needs string `json:"needs"`
Ready bool `json:"ready"`
Delivery string `json:"delivery"`
Worker string `json:"worker"`
Since *time.Time `json:"since"`
Stale bool `json:"stale"`
Abandoned bool `json:"abandoned"`
Moved *time.Time `json:"moved"`
}
// deliveryRow is a row of `mesh-delivery.deliveries`, with the issues `mesh-delivery.show` names for it.
type deliveryRow struct {
ID string `json:"id"`
Number int `json:"number"`
State string `json:"state"`
Since time.Time `json:"since"`
Waits string `json:"waits"`
Group string `json:"group"`
Issues []int `json:"-"`
Title string `json:"-"`
}
// groupRow is a row of `mesh-delivery.groups`.
type groupRow struct {
ID string `json:"id"`
State string `json:"state"`
Why string `json:"why"`
Members []string `json:"members"`
}
var (
designOf = regexp.MustCompile(`^design\s+([0-9]{1,3})$`)
milestoneOf = regexp.MustCompile(`(?i)^milestone\s+([0-9]{1,3})/(.+)$`)
issueOf = regexp.MustCompile(`^issue\s+([0-9]{1,6})$`)
recordOf = regexp.MustCompile(`(?i)^(ADR|decision|research)\s+([0-9]{1,4})$`)
)
// Progress says how far what `of` names has come.
func (k *KB) Progress(a ProgressAsk) (*Progress, error) {
detail := strings.TrimSpace(a.Detail)
if detail == "" {
detail = DetailMinimal
}
if detail != DetailMinimal && detail != DetailDetailed {
return nil, fmt.Errorf("detail is %s or %s, not %q", DetailMinimal, DetailDetailed, a.Detail)
}
if k.Seats == nil {
return nil, errors.New("the knowledge base reaches no other seat here")
}
of := strings.Join(strings.Fields(a.Of), " ")
p := &Progress{Of: of, Detail: detail, ReadAt: k.now()}
var err error
switch {
case strings.EqualFold(of, "mesh"):
err = k.progressMesh(p)
case designOf.MatchString(strings.ToLower(of)):
n, _ := strconv.Atoi(designOf.FindStringSubmatch(strings.ToLower(of))[1])
err = k.progressDesign(p, n, "")
case milestoneOf.MatchString(of):
m := milestoneOf.FindStringSubmatch(of)
n, _ := strconv.Atoi(m[1])
err = k.progressDesign(p, n, m[2])
case issueOf.MatchString(strings.ToLower(of)):
n, _ := strconv.Atoi(issueOf.FindStringSubmatch(strings.ToLower(of))[1])
err = k.progressIssue(p, n)
case recordOf.MatchString(of):
m := recordOf.FindStringSubmatch(of)
n, _ := strconv.Atoi(m[2])
kind := "ADR"
if strings.EqualFold(m[1], "research") {
kind = "research"
}
err = k.progressRecord(p, kind, n)
default:
return nil, fmt.Errorf("of names mesh, design <n>, milestone <design>/<id> (such as milestone 47/R6), issue <n>, "+
"decision <n> (or ADR <n>) or research <n>, not %q", a.Of)
}
if err != nil {
return nil, err
}
fitProgress(p, boundOf(a.Bound))
return p, nil
}
// issueRows asks the tracker for these issues, in chunks it takes, and answers them by number with the numbers it
// does not know.
func (k *KB) issueRows(numbers []int) (map[int]trackerRow, []int, error) {
rows := map[int]trackerRow{}
var missing []int
for from := 0; from < len(numbers); from += MostIssuesAsked {
chunk := numbers[from:min(from+MostIssuesAsked, len(numbers))]
raw, err := k.Seats("seat:issue-tracker.list", map[string]any{"numbers": chunk, "limit": len(chunk)})
if err != nil {
return nil, nil, fmt.Errorf("the issue tracker does not answer: %v", err)
}
var l struct {
Rows []trackerRow `json:"rows"`
Missing []int `json:"missing"`
}
if err := json.Unmarshal(raw, &l); err != nil {
return nil, nil, fmt.Errorf("issue-tracker.list answered something else: %v", err)
}
for _, r := range l.Rows {
rows[r.Number] = r
}
missing = append(missing, l.Missing...)
}
return rows, missing, nil
}
// issueItem is one issue's state, from its tracker row (1947/AC3).
func issueItem(r trackerRow) (Item, bool) {
it := Item{ID: strconv.Itoa(r.Number), Title: r.Title, Moved: r.Moved}
if it.Moved == nil {
it.Moved = r.Since
}
held := strings.HasPrefix(r.Delivery, "held") || strings.Contains(r.Delivery, " failed")
switch {
case r.Status == "wontfix":
return it, false
case r.Status == "resolved":
// Done only when its fix is delivered, or it has nothing to deliver (ADR 0424 §2, §4; 1947/AC3).
switch d := r.Delivery; {
case d == "":
it.State, it.Why = StateDone, "resolved, nothing to deliver"
case strings.HasPrefix(d, "delivered"):
it.State, it.Why = StateDone, "resolved, delivered"
case held || strings.Contains(d, " stopped"):
it.State, it.Why = StateStuck, "resolved · "+d
case strings.HasPrefix(d, "fix merged, not delivered"):
it.State, it.Why = StateMoving, "resolved · "+d
default:
it.State, it.Why = StateWaiting, "resolved · "+d
}
case r.Abandoned || r.Stale:
it.State, it.Why = StateStuck, fmt.Sprintf("%s, worker %s stale since %s", r.Status, r.Worker, when(r.Since))
case held:
it.State, it.Why = StateStuck, r.Status+" · "+r.Delivery
case r.Status == "diagnosing" || r.Status == "fixing":
it.State, it.Why = StateMoving, fmt.Sprintf("%s since %s (%s)", r.Status, when(r.Since), r.Worker)
if r.Delivery != "" {
it.Why += " · " + r.Delivery
}
case r.Needs == "decision" || r.Needs == "operator":
it.State, it.Why = StateWaiting, r.Status+", needs "+map[string]string{"decision": "a decision", "operator": "the operator"}[r.Needs]
case !r.Ready:
it.State, it.Why = StateWaiting, r.Status+", waits for what it depends on"
default:
it.State, it.Why = StateWaiting, r.Status+", ready"
}
return it, true
}
// rollUp gives a parent its children's state: stuck when one is, else moving when one moves, else waiting when one
// waits, else done; done and total counted; moved the latest of theirs (1947/AC3).
func rollUp(parent *Item, children []Item) {
parent.State, parent.Done, parent.Total = StateDone, 0, len(children)
if len(children) == 0 {
parent.State, parent.Why = StateWaiting, "no issues yet"
return
}
worst := StateDone
for _, c := range children {
if c.State == StateDone {
parent.Done++
}
if rank[c.State] > rank[worst] {
worst = c.State
}
if c.Moved != nil && (parent.Moved == nil || c.Moved.After(*parent.Moved)) {
m := *c.Moved
parent.Moved = &m
}
}
parent.State = worst
}
// milestoneItems is each milestone of a design as an item with its issues as children.
func milestoneItems(d *Design, rows map[int]trackerRow, missing map[int]bool) ([]Item, []string) {
var items []Item
var warn []string
var unanswered []string
for _, m := range d.Milestones {
mi := Item{ID: m.ID, Title: m.Outcome}
var kids []Item
for _, n := range m.Issues {
if missing[n] {
warn = append(warn, fmt.Sprintf("milestone %s names %d, which is not an issue", m.ID, n))
continue
}
r, ok := rows[n]
if !ok {
unanswered = append(unanswered, strconv.Itoa(n))
continue
}
if it, counts := issueItem(r); counts {
kids = append(kids, it)
}
}
rollUp(&mi, kids)
mi.Children = kids
items = append(items, mi)
}
if len(unanswered) > 0 {
warn = append(warn, fmt.Sprintf("the tracker neither answered nor named missing %d issues the milestones name, left out of the counts: %s",
len(unanswered), strings.Join(unanswered, ", ")))
}
return items, warn
}
// progressDesign says a design's milestones, or with milestone one milestone's issues.
func (k *KB) progressDesign(p *Progress, number int, milestone string) error {
ds, commit, err := k.designs(false, number)
if err != nil {
return err
}
d := ds[0]
p.Source = fmt.Sprintf("the milestone table of %s at hq %s (until the tracker's milestone records, issue 1781)", d.Path, short(commit))
p.Warnings = append(p.Warnings, d.Warnings...)
ms := d
if milestone != "" {
m := d.milestone(milestone)
if m == nil {
var ids []string
for _, x := range d.Milestones {
ids = append(ids, x.ID)
}
return fmt.Errorf("design %d has no milestone %q; its milestones are %s", number, milestone, strings.Join(ids, ", "))
}
ms = &Design{Number: d.Number, Path: d.Path, Title: d.Title, Milestones: []Milestone{*m}}
}
rows, missingList, err := k.issueRows(ms.issues())
if err != nil {
return err
}
missing := map[int]bool{}
for _, n := range missingList {
missing[n] = true
}
items, warn := milestoneItems(ms, rows, missing)
p.Warnings = append(p.Warnings, warn...)
var extra *extras
if p.Detail == DetailDetailed {
if extra, err = k.readExtras(items); err != nil {
return err
}
p.Warnings = append(p.Warnings, extra.warnings...)
}
top := Item{ID: fmt.Sprintf("design %d", d.Number), Title: d.Title}
rollUp(&top, items)
top.Children = items
if milestone != "" {
top = items[0]
top.ID = fmt.Sprintf("milestone %d/%s", d.Number, items[0].ID)
}
p.State = top.State
p.Items = []Item{top}
p.Tree = renderTree(top, p.Detail, extra, milestone != "")
return nil
}
// progressMesh says every design being built: those with milestones one line each, the rest counted.
func (k *KB) progressMesh(p *Progress) error {
ds, commit, err := k.designs(true, 0)
if err != nil {
return err
}
p.Source = fmt.Sprintf("the designs in %s at hq %s", DesignsFolder, short(commit))
var with []*Design
var without []string
for _, d := range ds {
if d.Status != "in-progress" {
continue
}
if len(d.Milestones) == 0 {
without = append(without, fmt.Sprintf("%02d", d.Number))
continue
}
with = append(with, d)
}
var numbers []int
for _, d := range with {
numbers = append(numbers, d.issues()...)
}
rows, missingList, err := k.issueRows(numbers)
if err != nil {
return err
}
missing := map[int]bool{}
for _, n := range missingList {
missing[n] = true
}
top := Item{ID: "mesh", Title: "the designs being built"}
var kids []Item
for _, d := range with {
items, _ := milestoneItems(d, rows, missing)
di := Item{ID: fmt.Sprintf("design %d", d.Number), Title: d.Title}
rollUp(&di, items)
if p.Detail == DetailDetailed {
di.Children = items
}
kids = append(kids, di)
}
rollUp(&top, kids)
top.Children = kids
p.State = top.State
p.Items = []Item{top}
var b strings.Builder
fmt.Fprintf(&b, "The mesh · %d designs in progress, %d with milestones\n", len(with)+len(without), len(with))
for i, di := range kids {
branch := "├── "
if i == len(kids)-1 && len(without) == 0 {
branch = "└── "
}
fmt.Fprintf(&b, "%s%s %s · %s — %d of %d milestones done%s\n", branch, marks[di.State], di.ID, di.Title, di.Done, di.Total, movedSay(di.Moved))
}
if len(without) > 0 {
fmt.Fprintf(&b, "└── %d designs in progress name no milestones (issue 845): %s\n", len(without), strings.Join(without, ", "))
}
p.Tree = strings.TrimRight(b.String(), "\n")
return nil
}
// progressIssue says one issue: its state, its pull requests and their deliveries, what it waits for.
func (k *KB) progressIssue(p *Progress, n int) error {
rows, missing, err := k.issueRows([]int{n})
if err != nil {
return err
}
r, ok := rows[n]
if len(missing) > 0 || !ok {
return fmt.Errorf("%d is not an issue the tracker holds", n)
}
it, counts := issueItem(r)
if !counts {
it.State, it.Why = StateDone, "wontfix"
}
it.ID = fmt.Sprintf("issue %d", n)
p.Source = "the issue tracker and mesh-delivery"
extra, err := k.readExtras([]Item{{Children: []Item{{ID: strconv.Itoa(n), State: StateMoving}}}})
if err != nil {
return err
}
p.Warnings = append(p.Warnings, extra.warnings...)
p.State = it.State
p.Items = []Item{it}
var b strings.Builder
fmt.Fprintf(&b, "%s issue %d · %s — %s%s\n", marks[it.State], n, it.Title, it.Why, movedSay(it.Moved))
if g := extra.gets[n]; g != nil {
fmt.Fprintf(&b, "├── %d notes; last %s\n", g.notes, g.last)
if len(g.waitsFor) > 0 {
fmt.Fprintf(&b, "├── depends on %s\n", strings.Join(g.waitsFor, ", "))
}
}
lines := extra.pullLines(n)
if len(lines) == 0 {
b.WriteString("└── no pull request open or ended in the last day names it\n")
}
for i, l := range lines {
branch := "├── "
if i == len(lines)-1 {
branch = "└── "
}
b.WriteString(branch + l + "\n")
}
p.Tree = strings.TrimRight(b.String(), "\n")
return nil
}
// progressRecord says what builds a decision record or a research effort: the designs that name it and the issues
// linked to it.
func (k *KB) progressRecord(p *Progress, kind string, n int) error {
key := Key{KindDecision, n}
name := fmt.Sprintf("ADR %04d", n)
if kind == "research" {
key, name = Key{KindResearch, n}, fmt.Sprintf("research %03d", n)
}
g, err := k.Get(GetAsk{Kind: key.Kind, Number: key.Number, Bound: LeastBound})
if err != nil {
return err
}
ds, commit, err := k.designs(true, 0)
if err != nil {
return err
}
p.Source = fmt.Sprintf("the knowledge base, the designs at hq %s and the issue tracker's links", short(commit))
var designs []string
for _, d := range ds {
for _, dn := range d.Decisions {
if dn == name {
designs = append(designs, fmt.Sprintf("design %d (%s)", d.Number, d.Status))
}
}
}
var kids []Item
if kind == "ADR" {
raw, err := k.Seats("seat:issue-tracker.list", map[string]any{"linked": fmt.Sprintf("decision:%04d", n), "limit": MostIssuesAsked})
if err != nil {
return fmt.Errorf("the issue tracker does not answer: %v", err)
}
var l struct {
Rows []trackerRow `json:"rows"`
Matched int `json:"matched"`
}
if err := json.Unmarshal(raw, &l); err != nil {
return fmt.Errorf("issue-tracker.list answered something else: %v", err)
}
if l.Matched > len(l.Rows) {
p.Warnings = append(p.Warnings, fmt.Sprintf("%d issues are linked to %s; the first %d are counted", l.Matched, name, len(l.Rows)))
}
for _, r := range l.Rows {
if it, counts := issueItem(r); counts {
kids = append(kids, it)
}
}
}
top := Item{ID: name, Title: g.Title}
rollUp(&top, kids)
top.Children = kids
if len(kids) == 0 {
top.Why = "no issue is linked to it"
}
p.State = top.State
p.Items = []Item{top}
var b strings.Builder
fmt.Fprintf(&b, "%s · %s — %s\n", name, g.Title, g.Status)
if len(designs) == 0 {
b.WriteString("├── no design names it\n")
} else {
fmt.Fprintf(&b, "├── named by %s\n", strings.Join(designs, ", "))
}
if kind != "ADR" {
b.WriteString("└── issues link decisions, not research efforts: see what it became (knowledge-base.links)\n")
} else if len(kids) == 0 {
b.WriteString("└── no issue is linked to it (issue-tracker.link, kind decision)\n")
} else {
fmt.Fprintf(&b, "└── %s %d of %d linked issues done%s\n", marks[top.State], top.Done, top.Total, movedSay(top.Moved))
if p.Detail == DetailDetailed {
for i, c := range kids {
branch := " ├── "
if i == len(kids)-1 {
branch = " └── "
}
fmt.Fprintf(&b, "%s%s %s %s — %s%s\n", branch, marks[c.State], c.ID, c.Title, c.Why, movedSay(c.Moved))
}
}
}
p.Tree = strings.TrimRight(b.String(), "\n")
return nil
}
// extras is what a detailed answer reads beyond the rows: the open deliveries with their issues, the groups, and
// each issue not done read whole.
type extras struct {
deliveries []deliveryRow
groups map[string]groupRow
gets map[int]*issueGot
warnings []string
}
type issueGot struct {
notes int
last string
waitsFor []string
}
// final says a delivery's state says nothing more will happen to it.
func final(state string) bool {
return state == "merged" || state == "closed" || state == "delivered"
}
// readExtras reads the deliveries not final with the issues each names, the groups, and the issues not done.
func (k *KB) readExtras(items []Item) (*extras, error) {
x := &extras{groups: map[string]groupRow{}, gets: map[int]*issueGot{}}
raw, err := k.Seats("seat:mesh-delivery.deliveries", map[string]any{})
if err != nil {
return nil, fmt.Errorf("mesh-delivery does not answer: %v", err)
}
var dl struct {
Deliveries []deliveryRow `json:"deliveries"`
}
if err := json.Unmarshal(raw, &dl); err != nil {
return nil, fmt.Errorf("mesh-delivery.deliveries answered something else: %v", err)
}
shown := 0
for _, d := range dl.Deliveries {
if final(d.State) {
continue
}
if shown == MostShows {
x.warnings = append(x.warnings, fmt.Sprintf("more than %d deliveries are open; the rest are not read", MostShows))
break
}
shown++
raw, err := k.Seats("seat:mesh-delivery.show", map[string]any{"id": d.ID})
if err != nil {
x.warnings = append(x.warnings, fmt.Sprintf("mesh-delivery.show %s: %v", d.ID, err))
continue
}
var s struct {
Delivery struct {
Issues []int `json:"issues"`
Title string `json:"title"`
} `json:"delivery"`
}
if err := json.Unmarshal(raw, &s); err != nil {
x.warnings = append(x.warnings, fmt.Sprintf("mesh-delivery.show %s answered something else: %v", d.ID, err))
}
d.Issues, d.Title = s.Delivery.Issues, s.Delivery.Title
x.deliveries = append(x.deliveries, d)
}
raw, err = k.Seats("seat:mesh-delivery.groups", map[string]any{})
if err != nil {
return nil, fmt.Errorf("mesh-delivery does not answer: %v", err)
}
var gs []groupRow
if err := json.Unmarshal(raw, &gs); err != nil {
return nil, fmt.Errorf("mesh-delivery.groups answered something else: %v", err)
}
for _, g := range gs {
x.groups[g.ID] = g
}
got := 0
for _, m := range items {
for _, c := range m.Children {
if c.State == StateDone {
continue
}
n, _ := strconv.Atoi(c.ID)
if got == MostGets {
x.warnings = append(x.warnings, fmt.Sprintf("more than %d issues are not done; the rest are not read whole", MostGets))
return x, nil
}
got++
raw, err := k.Seats("seat:issue-tracker.get", map[string]any{"number": n, "brief": true})
if err != nil {
x.warnings = append(x.warnings, fmt.Sprintf("issue-tracker.get %d: %v", n, err))
continue
}
var g struct {
Notes []struct {
At time.Time `json:"at"`
By string `json:"by"`
Text string `json:"text"`
} `json:"notes"`
Left int `json:"notes-left-out"`
Links []struct {
Kind string `json:"kind"`
Target string `json:"target"`
} `json:"links"`
}
if err := json.Unmarshal(raw, &g); err != nil {
x.warnings = append(x.warnings, fmt.Sprintf("issue-tracker.get %d answered something else: %v", n, err))
continue
}
ig := &issueGot{notes: len(g.Notes) + g.Left, last: "none"}
if len(g.Notes) > 0 {
last := g.Notes[len(g.Notes)-1]
ig.last = fmt.Sprintf("%s by %s", when(&last.At), last.By)
}
for _, l := range g.Links {
if l.Kind == "depends-on" {
ig.waitsFor = append(ig.waitsFor, l.Target)
}
}
x.gets[n] = ig
}
}
return x, nil
}
// pullLines is one line per open delivery naming issue n, and per group it is in.
func (x *extras) pullLines(n int) []string {
if x == nil {
return nil
}
var out []string
groups := map[string]bool{}
for _, d := range x.deliveries {
named := false
for _, i := range d.Issues {
if i == n {
named = true
}
}
if !named {
continue
}
state := StateWaiting
if strings.HasPrefix(d.State, "held") || strings.Contains(d.Waits, "a person") {
state = StateStuck
} else if d.State == "checking" || d.State == "merging" || d.State == "delivering" {
state = StateMoving
}
line := fmt.Sprintf("%s %s %s since %s", marks[state], d.ID, d.State, when(&d.Since))
if d.Waits != "" {
line += ", waits for " + d.Waits
}
out = append(out, line)
if d.Group != "" {
groups[d.Group] = true
}
}
var gids []string
for g := range groups {
gids = append(gids, g)
}
sort.Strings(gids)
for _, id := range gids {
g, ok := x.groups[id]
if !ok {
continue
}
mark := marks[StateMoving]
if g.State == "held" {
mark = marks[StateStuck]
}
out = append(out, fmt.Sprintf("%s group %s %s: %s", mark, g.ID, g.State, g.Why))
}
return out
}
// renderTree draws a design's or a milestone's tree: minimal, one line per milestone with the done ones folded into
// one line; detailed, each milestone not done with its issues, their pull requests and groups.
func renderTree(top Item, detail string, x *extras, oneMilestone bool) string {
var b strings.Builder
if oneMilestone {
fmt.Fprintf(&b, "%s %s · %s — %d of %d done%s\n", marks[top.State], top.ID, top.Title, top.Done, top.Total, movedSay(top.Moved))
writeIssues(&b, "", top.Children, detail, x)
return strings.TrimRight(b.String(), "\n")
}
fmt.Fprintf(&b, "%s %s · %s — %d of %d milestones done%s\n", marks[top.State], top.ID, top.Title, top.Done, top.Total, movedSay(top.Moved))
var done []string
issuesDone := 0
var open []Item
for _, m := range top.Children {
if m.State == StateDone {
done = append(done, m.ID)
issuesDone += m.Total
continue
}
open = append(open, m)
}
if len(done) > 0 {
branch := "├── "
if len(open) == 0 {
branch = "└── "
}
fmt.Fprintf(&b, "%s%s %d milestones done (%d issues): %s\n", branch, marks[StateDone], len(done), issuesDone, strings.Join(done, ", "))
}
for i, m := range open {
branch, under := "├── ", "│ "
if i == len(open)-1 {
branch, under = "└── ", " "
}
left := notDone(m.Children)
if m.Total == 0 {
left = m.Why
}
if detail == DetailMinimal {
fmt.Fprintf(&b, "%s%s %s %d/%d %s%s\n", branch, marks[m.State], m.ID, m.Done, m.Total, left, movedSay(m.Moved))
continue
}
fmt.Fprintf(&b, "%s%s %s · %s — %d of %d done%s\n", branch, marks[m.State], m.ID, clip(m.Title, 90), m.Done, m.Total, movedSay(m.Moved))
writeIssues(&b, under, m.Children, detail, x)
}
return strings.TrimRight(b.String(), "\n")
}
// writeIssues draws a milestone's issues: minimal, those not done; detailed, every issue, and under each not done
// its pull requests, groups and what it depends on.
func writeIssues(b *strings.Builder, indent string, kids []Item, detail string, x *extras) {
var show []Item
for _, c := range kids {
if detail == DetailDetailed || c.State != StateDone {
show = append(show, c)
}
}
for i, c := range show {
branch, under := "├── ", "│ "
if i == len(show)-1 {
branch, under = "└── ", " "
}
fmt.Fprintf(b, "%s%s%s %s %s — %s%s\n", indent, branch, marks[c.State], c.ID, clip(c.Title, 70), c.Why, movedSay(c.Moved))
if detail != DetailDetailed || c.State == StateDone || x == nil {
continue
}
n, _ := strconv.Atoi(c.ID)
var lines []string
if g := x.gets[n]; g != nil {
if len(g.waitsFor) > 0 {
lines = append(lines, "depends on "+strings.Join(g.waitsFor, ", "))
}
lines = append(lines, fmt.Sprintf("%d notes; last %s", g.notes, g.last))
}
lines = append(lines, x.pullLines(n)...)
for j, l := range lines {
br := "├── "
if j == len(lines)-1 {
br = "└── "
}
fmt.Fprintf(b, "%s%s%s%s\n", indent, under, br, l)
}
}
}
// notDone is a short say of the issues of a milestone not done, for its one line.
func notDone(kids []Item) string {
var parts []string
for _, c := range kids {
if c.State != StateDone {
parts = append(parts, c.ID+" "+c.Why)
}
}
if len(parts) > 3 {
return fmt.Sprintf("%s; and %d more", strings.Join(parts[:3], "; "), len(parts)-3)
}
return strings.Join(parts, "; ")
}
// when is a time as the operator reads it: local, its day and its hour.
func when(t *time.Time) string {
if t == nil || t.IsZero() {
return "an unknown time"
}
return t.Local().Format("01-02 15:04")
}
func movedSay(t *time.Time) string {
if t == nil {
return ""
}
return " · moved " + when(t)
}
// fitProgress keeps an answer within its bound: the items go first, then the tree's last lines, saying what was cut.
func fitProgress(p *Progress, bound int) {
if size(p) <= bound {
return
}
const raise = ": raise bound (at most 65536) or ask a smaller of, such as one milestone"
p.Cut = "the items, some warnings and the tree's last lines" + raise
var cut []string
if len(p.Items) > 0 {
p.Items = nil
cut = append(cut, "the items (the tree says the same)")
}
warned := len(p.Warnings)
for size(p) > bound && len(p.Warnings) > 0 {
p.Warnings = p.Warnings[:len(p.Warnings)-1]
}
if len(p.Warnings) < warned {
cut = append(cut, fmt.Sprintf("the last %d of %d warnings", warned-len(p.Warnings), warned))
}
lines := strings.Split(p.Tree, "\n")
dropped := 0
for size(p) > bound && len(lines) > 1 {
lines = lines[:len(lines)-1]
dropped++
p.Tree = strings.Join(lines, "\n")
}
if dropped > 0 {
cut = append(cut, fmt.Sprintf("the tree's last %d lines", dropped))
}
if exact := strings.Join(cut, "; ") + raise; len(exact) <= len(p.Cut) {
p.Cut = exact
}
}
@@ -0,0 +1,69 @@
//go:build integration
package main
import (
"encoding/json"
"strconv"
"strings"
"testing"
"github.com/nats-io/nats.go"
)
// 1947/AC1: the knowledge base running whole, as the runtime launches it, answers progress over the bus: it reads
// design 47 through the records module and asks the issue tracker and mesh-delivery on their seats' subjects,
// played here from what the live seats answered (testdata/progress).
func TestProgressOverTheBus(t *testing.T) {
w := newBusWorld(t)
seats := newSeatsWorld(t)
for _, key := range []string{"seat:issue-tracker.list", "seat:issue-tracker.get", "seat:mesh-delivery.deliveries",
"seat:mesh-delivery.groups", "seat:mesh-delivery.show"} {
key := key
if _, err := w.nc.Subscribe("ask."+key, func(m *nats.Msg) {
var body map[string]any
_ = json.Unmarshal(m.Data, &body)
out, err := seats.ask(key, body)
if err != nil {
_ = m.Respond([]byte(`{"error":` + strconv.Quote(err.Error()) + `}`))
return
}
raw, _ := json.Marshal(map[string]any{"result": json.RawMessage(out)})
_ = m.Respond(raw)
}); err != nil {
t.Fatal(err)
}
}
b, _ := w.start(t)
raw, err := b.call(Seat+".progress", map[string]any{"of": "design 47"})
if err != nil {
t.Fatal(err)
}
var p Progress
if err := json.Unmarshal(raw, &p); err != nil {
t.Fatal(err)
}
lines := strings.Split(p.Tree, "\n")
if len(raw) > DefaultBound || p.Detail != DetailMinimal || !strings.HasPrefix(lines[0], "✗ design 47 · Delivery: from a commit to delivered — ") ||
!strings.Contains(p.Tree, "✗ R6 4/7 1945 fixing since") || !strings.Contains(strings.Join(p.Warnings, "\n"), "issue 1209 is in milestones R3b and R3c") {
t.Fatalf("%d bytes:\n%s\n%v", len(raw), p.Tree, p.Warnings)
}
raw, err = b.call(Seat+".progress", map[string]any{"of": "milestone 47/R6", "detail": "detailed"})
if err != nil {
t.Fatal(err)
}
_ = json.Unmarshal(raw, &p)
for _, want := range []string{"✗ milestone 47/R6 · ", "▶ 1945 ", "jschoubben/car-hunter@cc4c8ce12901 ready since", "depends on 1943", "▶ 1946 "} {
if !strings.Contains(p.Tree, want) {
t.Errorf("detailed: no %q in\n%s", want, p.Tree)
}
}
if _, err := b.call(Seat+".progress", map[string]any{"of": "design 47", "by": "someone@else"}); err == nil {
t.Error("an argument naming the caller was taken")
}
seats.mu.Lock()
defer seats.mu.Unlock()
if seats.asked["seat:issue-tracker.list"] != 2 || seats.asked["seat:mesh-delivery.groups"] != 1 {
t.Fatalf("asked %v", seats.asked)
}
}
@@ -0,0 +1,384 @@
package main
import (
"encoding/json"
"os"
"path/filepath"
"reflect"
"strings"
"sync"
"testing"
"time"
)
// The progress verb's tests (novox/hq issue 1947). The designs are hq's, captured at a commit; the tracker's rows and
// the delivery seat's answers were captured from the live mesh on 2026-10-26 (testdata/progress/README.md).
func progressRecords() dirRecords {
return dirRecords{dir: filepath.Join("testdata", "progress", "hq")}
}
// withDesigns is the fixture's records with the captured designs beside them, as the bus test's records module
// serves them: a folder or document the first does not hold is read from the second.
type withDesigns struct{ base, designs dirRecords }
func (w withDesigns) Standing() (string, error) { return w.base.Standing() }
func (w withDesigns) List(folder string) ([]string, []string, error) {
if f, d, err := w.base.List(folder); err == nil {
return f, d, nil
}
return w.designs.List(folder)
}
func (w withDesigns) Read(path string) (string, string, error) {
if c, commit, err := w.base.Read(path); err == nil {
return c, commit, nil
}
c, _, err := w.designs.Read(path)
commit, _ := w.base.Standing()
return c, commit, err
}
// seatsWorld plays the issue tracker and mesh-delivery from the captured answers, and counts what it is asked.
type seatsWorld struct {
mu sync.Mutex
rows map[int]json.RawMessage
linked map[string][]int
asked map[string]int
files map[string]json.RawMessage
}
func newSeatsWorld(t *testing.T) *seatsWorld {
t.Helper()
s := &seatsWorld{rows: map[int]json.RawMessage{}, linked: map[string][]int{}, asked: map[string]int{}, files: map[string]json.RawMessage{}}
var rows []json.RawMessage
if err := json.Unmarshal(readFixture(t, "tracker-rows.json"), &rows); err != nil {
t.Fatal(err)
}
for _, r := range rows {
var n struct{ Number int }
_ = json.Unmarshal(r, &n)
s.rows[n.Number] = r
}
for _, f := range []string{"deliveries.json", "groups.json", "show-car-hunter-2.json"} {
s.files[f] = readFixture(t, f)
}
return s
}
func readFixture(t *testing.T, name string) json.RawMessage {
t.Helper()
raw, err := os.ReadFile(filepath.Join("testdata", "progress", name))
if err != nil {
t.Fatal(err)
}
return raw
}
func (s *seatsWorld) ask(key string, body any) (json.RawMessage, error) {
s.mu.Lock()
defer s.mu.Unlock()
s.asked[key]++
raw, _ := json.Marshal(body)
var a struct {
Numbers []int `json:"numbers"`
Linked string `json:"linked"`
Number int `json:"number"`
ID string `json:"id"`
}
_ = json.Unmarshal(raw, &a)
switch key {
case "seat:issue-tracker.list":
numbers := a.Numbers
if a.Linked != "" {
numbers = s.linked[a.Linked]
}
var rows []json.RawMessage
for _, n := range numbers {
if r, ok := s.rows[n]; ok {
rows = append(rows, r)
}
}
return json.Marshal(map[string]any{"rows": rows, "matched": len(rows)})
case "seat:issue-tracker.get":
return json.Marshal(map[string]any{"number": a.Number, "notes": []map[string]any{{"at": "2026-10-26T15:12:15Z", "by": "shanks.node-tools", "text": "a note"}},
"notes-left-out": 96, "links": []map[string]any{{"kind": "depends-on", "target": "1943"}}})
case "seat:mesh-delivery.deliveries":
return s.files["deliveries.json"], nil
case "seat:mesh-delivery.groups":
return s.files["groups.json"], nil
case "seat:mesh-delivery.show":
if a.ID == "jschoubben/car-hunter@cc4c8ce12901" {
return s.files["show-car-hunter-2.json"], nil
}
return json.RawMessage(`{"delivery": {}}`), nil
}
return nil, os.ErrNotExist
}
// countingRecords is Records that counts the documents read and can be moved to another commit.
type countingRecords struct {
dirRecords
commit string
reads int
}
func (c *countingRecords) Standing() (string, error) { return c.commit, nil }
func (c *countingRecords) Read(p string) (string, string, error) {
c.reads++
content, _, err := c.dirRecords.Read(p)
return content, c.commit, err
}
// 1947/AC2: the milestone table of design 47, as hq holds it, read into milestones with their issues, and what the
// prose does not say for certain said as warnings.
func TestTheDesignReaderReadsTheMilestoneTable(t *testing.T) {
content, _, err := progressRecords().Read(DesignsFolder + "/47-delivery-from-commit-to-delivered.md")
if err != nil {
t.Fatal(err)
}
d := parseDesign(DesignsFolder+"/47-delivery-from-commit-to-delivered.md", content)
if d.Number != 47 || d.Title != "Delivery: from a commit to delivered" || d.Status != "in-progress" || !contains(d.Decisions, "ADR 0276") {
t.Fatalf("design %d %q %q %v", d.Number, d.Title, d.Status, d.Decisions)
}
if len(d.Milestones) != 29 {
t.Fatalf("%d milestones", len(d.Milestones))
}
for _, c := range []struct {
id string
want []int
}{
{"1", []int{973, 525}},
{"2b", []int{1025, 1019}},
{"R6", []int{1942, 1945, 1943, 1944, 1946, 1906, 1130}},
{"r4d", []int{}},
{"3 fix", []int{1082, 1049, 1047, 1083, 1079, 1084}},
} {
m := d.milestone(c.id)
if m == nil {
t.Fatalf("no milestone %s", c.id)
}
if !reflect.DeepEqual(m.Issues, c.want) {
t.Errorf("milestone %s: %v, want %v", c.id, m.Issues, c.want)
}
}
if r4a := d.milestone("R4a"); len(r4a.Issues) != 14 || !containsInt(r4a.Issues, 1360) {
t.Errorf("R4a's range 1353 to 1364 read as %v", r4a.Issues)
}
warned := strings.Join(d.Warnings, "\n")
for _, w := range []string{"issue 1209 is in milestones R3b and R3c", "issue 971 is in milestones 6 and R4c",
`"1353 to 1364" is a range in words, read as the 12 issues`, "milestone R4d names no issue"} {
if !strings.Contains(warned, w) {
t.Errorf("no warning %q in\n%s", w, warned)
}
}
// Design 57's range with an exception, and a design with no milestone table.
content, _, _ = progressRecords().Read(DesignsFolder + "/57-building-config-and-provisioning.md")
d57 := parseDesign(DesignsFolder+"/57-building-config-and-provisioning.md", content)
if m := d57.milestone("1"); m == nil || containsInt(m.Issues, 659) || !containsInt(m.Issues, 678) || !containsInt(m.Issues, 685) {
t.Errorf("design 57 milestone 1: %+v", d57.Milestones)
}
content, _, _ = progressRecords().Read(DesignsFolder + "/53-the-issue-tracker.md")
if d53 := parseDesign(DesignsFolder+"/53-the-issue-tracker.md", content); len(d53.Milestones) != 0 || d53.Status != "in-progress" {
t.Errorf("design 53: %+v", d53.Milestones)
}
}
func containsInt(xs []int, n int) bool {
for _, x := range xs {
if x == n {
return true
}
}
return false
}
// 1947/AC6: the designs are read once per commit of hq, and again when it moves; the mesh level names those with
// milestones one by one and counts the rest.
func TestDesignsAreReadAgainOnlyWhenHqMoves(t *testing.T) {
rec := &countingRecords{dirRecords: progressRecords(), commit: "aaaa"}
seats := newSeatsWorld(t)
k := &KB{Records: rec, Seats: seats.ask, Now: func() time.Time { return time.Date(2026, 10, 26, 15, 30, 0, 0, time.UTC) }}
p, err := k.Progress(ProgressAsk{Of: "mesh"})
if err != nil {
t.Fatal(err)
}
if rec.reads != 3 {
t.Fatalf("read %d documents for the mesh, want 3", rec.reads)
}
if !strings.Contains(p.Tree, "3 designs in progress, 2 with milestones") || !strings.Contains(p.Tree, "design 47 · Delivery: from a commit to delivered") ||
!strings.Contains(p.Tree, "1 designs in progress name no milestones (issue 845): 53") {
t.Fatalf("mesh:\n%s", p.Tree)
}
for range 2 {
if _, err := k.Progress(ProgressAsk{Of: "design 47"}); err != nil {
t.Fatal(err)
}
}
if rec.reads != 3 {
t.Fatalf("read %d documents with hq standing still, want 3", rec.reads)
}
rec.commit = "bbbb"
if _, err := k.Progress(ProgressAsk{Of: "design 47"}); err != nil {
t.Fatal(err)
}
if rec.reads != 4 {
t.Fatalf("read %d documents after hq moved, want 4", rec.reads)
}
if _, err := k.Progress(ProgressAsk{Of: "design 99"}); err == nil || !strings.Contains(err.Error(), "no design 99") {
t.Fatalf("an unknown design: %v", err)
}
}
// 1947/AC3: an issue's state from its tracker row, and a parent's from its children.
func TestIssueStatesAndTheirRollUp(t *testing.T) {
seats := newSeatsWorld(t)
row := func(n int) trackerRow {
var r trackerRow
if err := json.Unmarshal(seats.rows[n], &r); err != nil {
t.Fatal(err)
}
return r
}
for _, c := range []struct {
n int
state string
why string
counts bool
}{
{1942, StateDone, "resolved, delivered", true},
{1943, StateDone, "resolved, nothing to deliver", true},
{1946, StateMoving, "resolved · fix merged, not delivered: mesh-catalog#475", true},
{1906, StateStuck, "resolved · held: mesh-delivery#39", true},
{1945, StateMoving, "fixing since", true},
{1049, StateStuck, "worker fix-chain-cat@shanks stale since", true},
{1126, StateWaiting, "open, needs a decision", true},
{774, StateWaiting, "open, ready", true},
{1021, "", "", false},
} {
it, counts := issueItem(row(c.n))
if counts != c.counts || it.State != c.state || !strings.Contains(it.Why, c.why) {
t.Errorf("issue %d: %s %q (counts %v), want %s %q", c.n, it.State, it.Why, counts, c.state, c.why)
}
}
// The other delivery lines a resolved issue carries on the live tracker (981, 1290 and 1235 on 2026-10-26).
for line, state := range map[string]string{
"fix not delivered: mesh-catalog#357 stopped:": StateStuck,
"fix not delivered: mesh-catalog#389 failed:": StateStuck,
"nothing heard from the delivery of mesh-delivery#5": StateWaiting,
} {
if it, _ := issueItem(trackerRow{Number: 1, Status: "resolved", Delivery: line}); it.State != state {
t.Errorf("resolved with %q: %s, want %s", line, it.State, state)
}
}
at := func(h int) *time.Time { t := time.Date(2026, 10, 26, h, 0, 0, 0, time.UTC); return &t }
for _, c := range []struct {
name string
kids []Item
state string
done int
moved *time.Time
}{
{"all done", []Item{{State: StateDone, Moved: at(9)}, {State: StateDone, Moved: at(11)}}, StateDone, 2, at(11)},
{"one moves", []Item{{State: StateDone}, {State: StateMoving, Moved: at(12)}, {State: StateWaiting}}, StateMoving, 1, at(12)},
{"one stuck outranks moving", []Item{{State: StateMoving}, {State: StateStuck}}, StateStuck, 0, nil},
{"only waiting", []Item{{State: StateDone}, {State: StateWaiting}}, StateWaiting, 1, nil},
{"no issue yet", nil, StateWaiting, 0, nil},
} {
var p Item
rollUp(&p, c.kids)
if p.State != c.state || p.Done != c.done || !reflect.DeepEqual(p.Moved, c.moved) {
t.Errorf("%s: %s %d %v", c.name, p.State, p.Done, p.Moved)
}
}
}
// 1947/AC1 (its unit side): design 47 in its two forms, and one milestone.
func TestProgressOfADesignMinimalAndDetailed(t *testing.T) {
seats := newSeatsWorld(t)
k := &KB{Records: progressRecords(), Seats: seats.ask, Now: func() time.Time { return time.Date(2026, 10, 26, 15, 30, 0, 0, time.UTC) }}
p, err := k.Progress(ProgressAsk{Of: "design 47"})
if err != nil {
t.Fatal(err)
}
raw, _ := json.Marshal(p)
if len(raw) > DefaultBound || p.Detail != DetailMinimal || !strings.Contains(p.Source, "issue 1781") {
t.Fatalf("%d bytes, %s, %s", len(raw), p.Detail, p.Source)
}
if seats.asked["seat:issue-tracker.list"] != 1 || seats.asked["seat:mesh-delivery.deliveries"] != 0 || seats.asked["seat:issue-tracker.get"] != 0 {
t.Fatalf("minimal asked %v", seats.asked)
}
if w := strings.Join(p.Warnings, "\n"); !strings.Contains(w, "the tracker neither answered nor named missing") || !strings.Contains(w, "973, 525") {
t.Errorf("a row the tracker did not answer is not said: %v", p.Warnings)
}
lines := strings.Split(p.Tree, "\n")
if !strings.HasPrefix(lines[0], "✗ design 47 · Delivery: from a commit to delivered — ") {
t.Fatalf("first line %q", lines[0])
}
for _, want := range []string{"✗ R6 4/7 1945 fixing since", "✗ 3 fix 0/1 1049 fixing, worker fix-chain-cat@shanks stale since"} {
if !strings.Contains(p.Tree, want) {
t.Errorf("no %q in\n%s", want, p.Tree)
}
}
d, err := k.Progress(ProgressAsk{Of: "design 47", Detail: DetailDetailed, Bound: MostBound})
if err != nil {
t.Fatal(err)
}
for _, want := range []string{"├── ▶ 1946 ", "resolved · fix merged, not delivered: mesh-catalog#475",
"▶ 1945 The controller still plans", "jschoubben/car-hunter@cc4c8ce12901 ready since 10-26 17:05, waits for its merge",
"depends on 1943", "97 notes; last 10-26 17:12 by shanks.node-tools"} {
if !strings.Contains(d.Tree, want) {
t.Errorf("detailed: no %q in\n%s", want, d.Tree)
}
}
if len(d.Items) != 1 || d.Items[0].ID != "design 47" {
t.Fatalf("items %+v", d.Items)
}
m, err := k.Progress(ProgressAsk{Of: "milestone 47/r6", Detail: DetailDetailed})
if err != nil {
t.Fatal(err)
}
if !strings.HasPrefix(m.Tree, "✗ milestone 47/R6 · ") || !strings.Contains(m.Tree, "✓ 1130") {
t.Fatalf("milestone:\n%s", m.Tree)
}
small, _ := k.Progress(ProgressAsk{Of: "design 47", Detail: DetailDetailed, Bound: LeastBound})
if raw, _ := json.Marshal(small); len(raw) > LeastBound || !strings.Contains(small.Cut, "raise bound") {
t.Fatalf("%d bytes within %d; cut %q", len(raw), LeastBound, small.Cut)
}
for _, bad := range []ProgressAsk{{Of: "design"}, {Of: "milestone 47/R99"}, {Of: "design 47", Detail: "full"}} {
if _, err := k.Progress(bad); err == nil {
t.Errorf("%+v was answered", bad)
}
}
}
// 1947/AC5: an issue's own level, and a record's: the designs that name it and the issues linked to it.
func TestProgressOfAnIssueAndARecord(t *testing.T) {
w := imported(t, newMemStore())
seats := newSeatsWorld(t)
seats.linked["decision:0276"] = []int{1942, 1945}
w.k.Records, w.k.Seats = progressRecords(), seats.ask
p, err := w.k.Progress(ProgressAsk{Of: "issue 1945"})
if err != nil {
t.Fatal(err)
}
for _, want := range []string{"▶ issue 1945 · The controller still plans", "97 notes", "depends on 1943",
"jschoubben/car-hunter@cc4c8ce12901 ready"} {
if !strings.Contains(p.Tree, want) {
t.Errorf("issue: no %q in\n%s", want, p.Tree)
}
}
r, err := w.k.Progress(ProgressAsk{Of: "ADR 0276", Detail: DetailDetailed})
if err != nil {
t.Fatal(err)
}
for _, want := range []string{"ADR 0276 · ", "named by design 47 (in-progress)", "▶ 1 of 2 linked issues done", "✓ 1942"} {
if !strings.Contains(r.Tree, want) {
t.Errorf("record: no %q in\n%s", want, r.Tree)
}
}
if _, err := w.k.Progress(ProgressAsk{Of: "issue 99999"}); err == nil {
t.Error("an issue the tracker does not hold was answered")
}
}
@@ -0,0 +1,7 @@
Captured for the progress verb's tests (novox/hq issue 1947), never written by hand:
- `hq/`: three designs of hq at the commit in `hq/COMMIT`, copied whole.
- `tracker-rows.json`: rows of `issue-tracker.list` with `numbers`, as the live tracker answered them on
2026-10-26 16:2x (before rows said `moved`).
- `deliveries.json`, `groups.json`, `show-car-hunter-2.json`: parts of `mesh-delivery.deliveries`, `.groups` and
`.show` as the live delivery seat answered them on 2026-10-26 16:2x, cut to the members named here.
@@ -0,0 +1,5 @@
{"deliveries": [
{"for": "17m26s", "id": "jschoubben/car-hunter@cc4c8ce12901", "number": 2, "since": "2026-10-26T15:05:01Z", "state": "ready", "waits": "its merge"},
{"for": "34m7s", "id": "novox/mesh-catalog@8c5f27627b67", "number": 502, "since": "2026-10-26T14:48:20Z", "state": "merged"},
{"for": "19h50m33s", "group": "fix/1945-credential-when-row-without-seal", "id": "novox/mesh-delivery@61196c99e6a4", "number": 46, "since": "2026-10-25T19:31:54Z", "state": "held", "waits": "a person: merged without a passing check: only a person decides what follows"}
]}
@@ -0,0 +1,3 @@
[
{"composed": "asked as mesh/check 3fad1982742a", "composed_gate": true, "id": "fix/1945-credential-when-row-without-seal", "members": ["novox/mesh-controller@fbf14b68dbab (held)", "novox/mesh-delivery@61196c99e6a4 (held)"], "state": "held", "why": "a member is held: novox/mesh-controller@fbf14b68dbab (merged without a passing check: only a person decides what follows); novox/mesh-delivery@61196c99e6a4 (merged without a passing check: only a person decides what follows)"}
]
@@ -0,0 +1,319 @@
---
layer: to-be
status: in-progress
code: [mesh-catalog modules/mesh-issues, mesh-catalog modules/claude-code, mesh-catalog modules/messenger, mesh-sdk]
updated: 2026-10-11
decisions:
- "ADR 0275"
- "ADR 0278"
- "ADR 0279"
- "ADR 0281"
- "ADR 0284"
- "ADR 0285"
- "ADR 0291"
- "ADR 0294"
- "ADR 0300"
---
# 53 — The issue tracker
**The mesh keeps its issues in a module of its own, `mesh-issues`, one instance on the control-node, holding the seat
`issue-tracker`. It hands out every
issue's number from one counter that two callers can never both advance, holds each issue's status and links, refuses
a change the development cycle would refuse, and announces every change on the bus. An issue lives whole in the tracker, its report, diagnosis and
resolution included; hq keeps no issue content.**
(ADR 0275,
from research 042;
ADR 0291 for an issue whole in the tracker.)
An **issue** is a question about the mesh that someone must answer. The **issue tracker** is the module that holds
issues, named `mesh-issues`; the seat it holds is `issue-tracker`. The glossary holds both words.
**Acceptance.** An agent opens an issue and gets the next number; two agents opening at the same moment never get the
same one.
This document designs slice 1 in detail. The slices after it are listed in [§ 8](#8-the-slices-after-the-first),
each with what the operator sees when it is done.
## 1. The parts
```
control-node
mesh-issues (seat issue-tracker, held once for the mesh)
├ its process, launched by the node runtime
├ bucket issues one key per issue, by number; a short history per key
├ bucket counters one key per sequence; slice 1 has `issue` only
├ bucket grooming the backlog pass of the duplicate check (§ 9)
├ setting owners the registered list `located-in` may name
├ verbs open, get, list, move, note, link, import, groom, set, tracking,
│ served by the control-node's tool runner
├ events opened, moved, noted, linked, imported, set, triaged, ready, ready-to-verify
└ consumes its own `opened` and `moved`: the triage (§ 9);
mesh-delivery's `transition`: when a fix is delivered (§ 10)
claude-code (on the same node)
└ claude_code_judge one question answered by the agent with no tools (§ 9)
any agent session, on any node
mesh MCP server ── mesh_search, mesh_describe, mesh_call ──▶ the verbs, over the bus
```
The module is written in Go against the SDK. It is built on the model of hits, a headless issue tracker on NATS, and
carries none of its code; its README says so.
## 2. The seat and its verbs
The module declares the seat `issue-tracker` at mesh scope and claims it exclusively
(ADR 0126): there is one tracker per mesh. Its verbs are
the seat's tools, served through the tool runner and announced on the bus
(ADR 0197), so an
agent finds them with `mesh_search` ("open an issue") and calls them as `issue-tracker.<verb>`.
| Verb | Takes | Does |
|---|---|---|
| `open` | a title, the report as text, optionally `located-in` and a folder path | checks the issue, then takes the next number and writes it; returns the number. Refuses an empty title or report, and an owner not on the registered list |
| `get` | a number | the issue whole: fields, notes, links |
| `list` | optionally a status, an owner, a phrase | compact rows, newest first, short enough to read in a chat: number, status, owner, title, and the worker with since and whether it is stale |
| `move` | a number, a status, and what that status needs; `by` for `diagnosing` and `fixing`, and `take-over` with a reason to take an issue over from another worker | changes the status under the rules of § 5 |
| `note` | a number and a text | appends to the issue's notes; a note is never edited |
| `link` | a number, a kind and a target, or `remove` | adds or removes a typed link |
| `set` | a number and any of `severity`, `needs`, `suggested-owner` | changes them as a person decides (§ 10) |
| `tracking` | optionally condition keys | for every condition key an issue links, the issues that link it and where each stands: status, needs, worker, fix (§ 12) |
| `groom` | optionally `confirm` and a pass's id | the duplicate check over the backlog (§ 9): without `confirm`, starts a dry run in the background and answers its id; with the id alone, its progress and findings; with `confirm` and the id, applies exactly that pass's findings, each checked again |
| `import` (slice 3) | optionally `dry-run`, a commit | reads every issue folder of hq through the records module and writes each as an issue at its own number; creates what is absent, fills a status the tracker holds empty, never overwrites, never advances the counter; run again, changes nothing and says so |
Each verb's description says in plain words what it does and what it refuses, without the mesh's internal names, so
an agent picks the right one from `mesh_search` alone. A refusal says what was wrong and what would be accepted.
**The caller is the bus credential, never an argument.** The runtime hands the module the identity of each call's
caller; the module records that identity as who acted. No verb takes an actor. `by` on `move` is not one: it names
the issue's worker, who works on it, as the worker states it
(ADR 0284).
## 3. What one issue holds
- `number` (its key), `title`, `status` (`open`, `diagnosing`, `located`, `fixing`, `resolved`, `wontfix`), `opened`,
`closed`;
- `worker`, while it is `diagnosing` or `fixing`: who works on it as who@node, `since` when, and `active`, the last
time a caller did anything on it (§ 5);
- `located-in`: entries of the registered list of owners (repositories and modules), never free text;
- `fixed-by` (pull requests or commits), `amended-design`, `replay` or `replay-none`, and a reason for `wontfix`;
- `notes`: appended, each with who wrote it, when, and the text;
- `links`, typed: to another issue (`duplicates`, `relates-to`), a pull request, a decision record, a design, a
condition key (the issue **tracks** that condition, § 12) or a delivery;
- `documents`: the report, and a diagnosis and a resolution when written, each with its author and date, revised in
place with its history kept (ADR 0291);
- `record`: the path of the issue's folder in hq, while one exists; none is opened any more, and the folders are
imported into `documents` and removed (§ 8, slice 6);
- `severity` (critical, major, minor), `needs` (none, decision, operator), `suggested-owner`, `delivered` (the
pull requests of `fixed-by` the delivery said are delivered) and `triage` (the triage's judgement as made), and the link
kind `depends-on` (§ 10).
**The registered list of owners** is a setting of the module, changed through the controller's settings verb like any
other module setting. Slice 1 starts it with the repositories in hq's `00-META/repos.md` and the modules the catalogue
registers.
**An issue that has a folder in hq** kept its status in that folder's frontmatter until the import of slice 3 ran;
until then the tracker held its number, title and `record`, took its notes and links, and `move` refused it with the
reason: its status lives in its folder. Since the import, every issue is whole in the tracker, folder or not, and a
report in hq carries no status key. No status is written in two places for one issue.
## 4. State, the counter and events
**Buckets the module declares, the controller creates**
(ADR 0201).
The manifest's `state` names two buckets, `issues` and `counters`, with their options: `issues` keeps a short history
per key, `counters` one value per key, neither expires. The module never creates a bucket. One issue is one value; at
the 256 KiB a value may be, a note that would push an issue past it is refused with that reason (the largest issue
folder in hq is 25 KiB).
**The counter is advanced by compare-and-set.** Beside `get`, `put`, `delete`, `keys` and `watch`, the runtime's state
verbs hold two writes that are refused when another writer was first, in the runtime and the SDK's state interface
together (ADR 0259):
- `create` writes a key only if it does not exist;
- `update` writes a key only if its revision is still the one the caller read, and returns the new revision.
The SDK's `Increment` reads a counter and writes the next number with `create` or `update` at the revision it read,
trying again a bounded number of times when another writer was first. The counter is started once, by `create` at
the number before the first, so it never starts twice. `open` then runs in this order:
1. validate the issue; a refused issue spends no number;
2. read `counters/issue` and its revision;
3. `update` it to the next number at that revision; if another caller moved it first, the write is refused, and
`open` reads again and retries, a bounded number of times;
4. `create` the issue's key; an existing key is a fault the module reports loudly and never overwrites.
So the counter does not rely on there being one instance: two instances, or two calls racing in one, still never hand
out one number twice. **The counter starts** above the highest issue number on `main` and on every open branch of hq
at the moment the module is switched on. Numbers already skipped stay unused, and a number never moves.
**Every change is an event the module declares** under `emits`
(ADR 0041): `opened`, `moved`, `noted`, `linked`. Each carries
the number, what changed, and who changed it, so the events are the audit trail, and anything watching the bucket or
the events sees a change as it happens. `moved` also carries the worker a move names, as `by`, with `since`, and who
acted as `caller`; on a take-over `took-over-from` and the reason, and `released` when a move ends the work. No
event carries a secret, and an issue holds none.
## 5. The cycle's rules, at the write
`move` refuses what `cycle.py` refuses at merge today, and the rules playbook 03 states:
| The issue moves to | Needs |
|---|---|
| any status | one of the six |
| `diagnosing` or `fixing` | `by`, the worker as who@node; refused while another worker is current, naming them, unless `take-over` gives a reason, which is noted; a worker with nothing done on the issue for `worker-stale-after` (24 hours) is stale and may be taken over from without one |
| any other status | no `by` and no `take-over`; the move ends the work |
| `located` | `located-in` names at least one registered owner |
| `resolved` | the issue is at `fixing` (or already `resolved`, to correct what it holds); `located-in`, and `fixed-by` saying what fixed it |
| `resolved`, for a core issue (its `located-in` names the controller, the node-engine, the tool runner, the SDK, or the catalogue's bus, forge or build agent) | `replay` (an id from mesh-lab's replays register) or `replay-none` saying why none is possible (ADR 0237) |
| `wontfix` | a reason |
A refused move changes nothing and says which rule refused it. The rows for `diagnosing`, `fixing` and `resolved` are
ADR 0284's:
resolved only from `fixing` is the check that an agent said it started the fix. `cycle.py` checked the folders that carried status until
slice 3; since then it refuses a status key in any report, and judges no issue's status.
## 6. How the operator and agents use it
Through the mesh's tools first. An agent opening an issue calls `issue-tracker.open`, gets the number, and, for a design
or governance issue, writes `04-ISSUES/NNN-slug/` under it with the number in the report. Playbook 03's first step
changes to that the day slice 1 is live; nothing else in playbook 03 changes in slice 1. `list` answers "what is open"
in one call, from any session, and the responder on a channel answers from it once the seat exists.
**An agent says when it starts.** Before it starts work on an issue, an agent moves it to `diagnosing` (to find where
it lives) or `fixing` (to fix it) with `by`, its own name and node. `list` then shows who works on what, and a second
agent is refused before it starts the same work. An agent that keeps working notes what it finds, which keeps it the
worker; one that stops moves the issue on, or is stale a day later.
## 7. How it is checked
| Rule | Checked by |
|---|---|
| Two opens at once never share a number | the module's test over a real bus: many concurrent `open` calls, from two instances, yield distinct consecutive numbers; the runtime's test that `update` at a stale revision is refused |
| A refused open spends no number | the module's test: an invalid open, then a valid one, gets the next number |
| An issue's key is never overwritten | the module's test: a `create` on an existing key is refused and reported |
| The cycle's rules hold at the write | the module's table test over § 5, one case per row, each refused case changing nothing |
| The module creates no bucket | the runtime refuses a module that tries (ADR 0201 §3); the controller's raise asserts both buckets |
| The caller is who acted | the module's test that the recorded actor is the call's identity, whatever the arguments say |
| A worker is named on a move to diagnosing or fixing, another is refused while it is current, a take-over is noted, a stale one may be taken over from, and resolved is reached only from fixing | the rows of ADR 0284's "How it is checked" |
| An issue's delivery line follows the delivery; ready to verify once; a stuck fix said once and cleared | the rows of ADR 0281's "How it is checked" |
| A linked pull request is a fix; a closed unmerged one is withdrawn; delivered and not resolved is said once; an abandoned issue is flagged | the rows of ADR 0294's "How it is checked" |
| The triage fills only what is empty, links no cycle, and an issue is ready only when its dependencies' fixes are delivered | the rows of ADR 0279's "How it is checked" |
| The duplicate check acts only on a clear duplicate, never on an older or a resolved issue, once per issue, loudly when it cannot judge | the rows of ADR 0278's "How it is checked" |
| An issue's documents are kept whole, with author, date and history, and the import of hq's folders loses nothing (ADR 0291) | the module's tests of `documents` and of the import, one case per document kind; the comparison of every issue with its folder before the folders are removed, written on issue 427. Tracked by issue 427 |
| Live | on the mesh, an agent calls `issue-tracker.open` and gets the next number; two sessions on two nodes open at the same moment and get two different numbers; `list` shows both |
## 8. The slices after the first
| Slice | Delivers | Done when the operator sees |
|---|---|---|
| 1 | The `mesh-issues` module: `open`, `get`, `list`, `move`, `note`, `link`; a counter by compare-and-set in the SDK; playbook 03 takes its numbers from it | an agent opens an issue and gets the next number; two agents opening at the same moment never get the same one |
| 2 | Decision and research numbers from the tracker, each its own key in `counters`; a number is reserved when a branch starts; the knowledge base's `reserve` asks for them ([to-be 55](55-the-knowledge-base.md) § 5). Tracked by issue 424 | two agents writing a decision at once get two numbers, and no hq pull request is renumbered |
| 3 | `import`: every issue folder in hq read through the records module and written as an issue with its own number, idempotent and showing what it would change before it does (built and run 2026-10-10; one pull request changed playbook 03 and `cycle.py` and added dated notes to ADR 0019, 0080 and 0275, and once every folder was checked against the tracker a second removed the status keys from every report, as § 3 says); then the four consolidation passes of research 042 (fixed, duplicates, owners, grouping; the duplicates pass is the duplicate check's `groom`, § 9, judged rather than by shared words), each a list confirmed before it is applied through `move`, `link` and `note`, and an audit of `fixed-by` against the forge | the not-closed list comes from one call and shrinks by what was already fixed or duplicated, each with its reason |
| 4 | Conditions and pull requests linked: an issue links a condition by its key, and every message about the condition says the issue and where it stands (§ 12; the controller does not show the link, ADR 0285); a merged pull request notes every issue it names; after a week of that, the controller opens one issue per `healer-wanted` condition key by itself | a condition's message names its issue, and a merge notes the issues it names |
| 5 | A read-only view onto the `issues` bucket over the bus | the operator reads issues in a browser without an agent |
| 6 | An issue whole (ADR 0291): the `documents` of § 3; an import of the 361 folders of hq into them, repeatable, keeping each issue's number and dates; once every issue is compared with its folder, one pull request rewrites the 765 path mentions in hq to "issue N", removes the folders, makes `records.py` refuse a new path into `04-ISSUES/`, and changes playbook 03; `open` loses `record`. Tracked by issue 427 | `issue-tracker.get` answers an issue's report and diagnosis, and hq has no `04-ISSUES/` |
Once slice 1 runs, each slice after it is opened as an issue in the tracker, so the tracker carries its own work.
## 9. The duplicate check
**When an agent opens an issue that repeats one not resolved, the tracker closes the new one as its duplicate
within a few minutes, keeping its report on the older one; an issue that repeats nothing gains a note that it
was checked** (ADR 0278).
- **Started by the tracker's own `opened` event**, which the module consumes. Only an issue still at `open` is
checked, against every older issue at `open`, `diagnosing`, `located` or `fixing`; resolved and wontfix issues are never
offered or changed.
- **Judged in two questions** to the agent module's `claude_code_judge` on the tracker's node: a shortlist of at
most five by titles, then a verdict on the reports (`duplicate`, `related` or `none`, whether it is certain,
and why). The judging session runs as the node's agent account under its licence, with no tools, in safe
mode, and answers within 25 seconds.
- **A certain duplicate**: the new issue is linked `duplicates`, its title and report become a note on the older
one, its verdict a note on itself, and it moves to `wontfix`, "duplicate of N". **Related, or not certain**: a
`relates-to` link and a note on both. **None**: one note. The older issue never moves. Each step goes through
the tracker's own `note`, `link` and `move`, and is recorded as by `duplicate-check`.
- **Once per issue.** The verdict note is written first, in a fixed first line; an event delivered again does
only the steps not yet done.
- **Loud when it cannot judge.** One note on the new issue, and the event is not taken: after five deliveries
the controller raises `max-deliveries` on the tracker's consumer.
- **The backlog**: `groom` runs the same judgement over every issue not resolved, as a dry run kept in the state
`grooming`, then applies that pass's findings on `confirm`.
- Settings: `duplicate-check` (`on`) and `judge-model` (`sonnet`).
## 10. Triage, severity and readiness
**An agent opening an issue need not know the backlog: the triage estimates its severity and what it needs, links
what it depends on, and the issue is said ready when every fix it waits for is delivered**
(ADR 0279).
- **Triage** is the judgement of § 9 widened: its second question is always asked and also answers severity,
needs, a suggested owner when the issue names none, and the shortlisted issues it depends on or relates to, each
with a one-line reason. The judgement is kept on the issue first, then applied through the tracker's own verbs:
one note saying it all, each empty field filled, each link added (never one that closes a cycle, never to a
resolved or wontfix issue), the duplicate verdict last. It fills only what is empty; a value a person gave
stands, and a disagreement is a line in the note. Recorded as by `triage`.
- **Severity**: critical (something the operator relies on is broken, or data is at risk), major (a rule of the
mesh does not hold, or people work around it), minor (a contained fix; nothing the operator sees changes).
**Needs**: none, decision (a decision record first), operator (the operator's answer is required).
- **`depends-on`**: refused when it would close a cycle; `get` shows what an issue depends on and what it is
needed by.
- **Ready**: not resolved or wontfix, and every dependency wontfix (one closed as a duplicate counts as its
original), or resolved with every pull request its `fixed-by` names delivered; said once per spell. The tracker consumes `mesh-delivery.transition` and, at its own `moved` to resolved, asks
`mesh-delivery.checks` for what it has not heard; an issue made ready gains a note and the event `ready`.
- `open` takes severity and needs; `set` changes severity, needs and suggested owner; `list` filters on severity,
needs and ready. `groom` triages the backlog, dry run first.
- The values are optional keys of an issue's one JSON value: an issue kept before reads as having none, and the
key-value store has no schema to migrate.
## 10a. The record is a log of operations
**The tracker's record is its log of operations, and its bucket is that log's fold** (ADR 0300, resting on ADR 0297):
every write is one operation appended under the issue's number before the bucket is written; the bucket is rebuilt
from the log on start; the events announce and the log is the audit trail; `list` answers from memory with a ranked
search and a `before` cursor. Built under issues 501 and 502.
## 11. Whether the fix is delivered
**An issue knows whether its fix is on the machines, as the delivery says it; delivered is not resolved, and a fix
stuck after its merge is said** (ADR 0281). Since ADR 0294, an issue's fixes are the pull requests `fixed-by`
names and every pull request linked to it; a pull request closed without merging is withdrawn; an issue left in
fixing an hour after its last fix is delivered is said once, "fixed and delivered, not resolved"; and a stale worker
with no pull request open is **abandoned**.
- For each pull request `fixed-by` names, the tracker keeps the state `mesh-delivery.transition` last said — merged,
held and why, covered, delivered — or asks `mesh-delivery.checks` once for one it has not heard of. Nothing sets it
by hand.
- `get` and each row of `list` carry **delivery**, one line: "fix not delivered: …", "held: …", "fix merged, not
delivered", "fix not merged", "nothing heard …", "delivered to every machine it should reach".
- When every fix of an issue not yet resolved is delivered, it gains a note and the event `ready-to-verify`, once;
it is resolved after the acceptance is proven live. A fix is named while the issue stays `fixing`: `move` to the
same status, with `by`, changes what the issue holds.
- A fix merged and not delivered after `stuck-after` (two hours) is said once: a note on the issue and a warning on
the operator's channel, cleared when it is delivered. It is not a controller condition: a module that runs only
its bundle cannot raise one.
## 12. Which issue tracks a condition
**Every message about a condition says which issue tracks it and where that issue stands, or that none does**
(ADR 0285).
- **An issue tracks a condition** through a link of kind `condition` naming the condition's key exactly; set by
whoever opens the issue or works on it, never by the triage or the controller. An agent that opens an issue for a
condition links its key at once, and one that silences a condition because an issue tracks it links that issue
first.
- **`tracking`** answers, for every key an issue links or only the keys asked, the issues that link it, those not
closed first, at most 10 per key: number, status, severity, needs, the worker with since, active and stale, and
**fix** (`not-merged`, `merged`, `held`, `failed`, `unheard`, `delivered`, or empty), from the facts of § 11. It
carries no title, report or note, and changes nothing.
- **The router** (to-be 45 § 5) asks it over the bus, outside its work, at most once a minute, and says from it the
**tracking line** in every condition's message; the words and the verdict it changes are its own (to-be 45 § 5).
The controller learns nothing of issues.
## What it leaves out
Priorities beyond severity, notifications beyond the `ready` and `ready-to-verify` events and the stuck-fix warning, full-text or semantic search, the forge's own issue lists, and an index of any kind:
none of them is in any slice above, and each needs a decision of its own. Who works on an issue, listed here until
2026-10-10, is settled by ADR 0284.
@@ -0,0 +1,363 @@
---
layer: to-be
status: in-progress
code: [mesh-controller, mesh-host, mesh-sdk, mesh-catalog]
updated: 2026-10-17
decisions:
- "ADR 0348"
- "ADR 0349"
- "ADR 0201"
- "ADR 0113"
- "ADR 0158"
- "ADR 0360"
- "ADR 0362"
---
# 57. Building config and provisioning
**The work of ADR 0348 (config is a domain of its own) and ADR 0349 (provisioning), broken into
packages small enough that each ends at something that can be seen to work, in the order their
dependencies allow.** The two decision records are the authority on *what* is built; this document holds
only the packages, their order, their sizes and their proofs, and is wrong the moment it disagrees with
them. It is the shape [design 38](38-building-the-operators-machine.md) gave the operator's machine.
## How this is built
**Each package is one issue in the issue tracker, and one pull request per repository it touches.** The
issue carries the package's number here in its title, links the packages it waits for with `depends-on`,
and links ADR 0348 or ADR 0349. At most five packages are worked at once, each by its own agent in its
own worktree; a package starts once every package it depends on is merged. Every pull request is
reviewed independently at its head and merged only with its checks green.
**It ships as one release.** The operator's decision of 2026-10-14: one delivery, not one per package.
Each of mesh-controller, `mesh-host`, mesh-sdk, mesh-catalog and hq has the branch
`feat/57-config-and-provisioning`, and every package's pull request merges into that branch, never into
main. When the last package is merged, the whole is rehearsed in the lab: the rollout, the moves of data in
their order, and every proof that is run live. Then each repository's branch reaches main in one pull
request, and the five, sharing their branch's name, are one delivery group (ADR 0239 §2), judged as one
future state. The five are merged within one merge window, so they are one batch and one rollout (ADR 0276
§1, §2), in an order declared with `after:` lines in each pull request (ADR 0239 §11): mesh-sdk, then
`mesh-host`, then mesh-controller, then mesh-catalog, then hq. The release holds every package but T6, R1 and R2:
the media catalogue and the applications live in repositories of their own and move after it, and only
then can the old forms be refused (R1) and the record say what runs (R2).
**The moves of data run after the delivery, in this order, each a verb that says what it did:**
placement out of the settings layers (F2), the settings layers into mesh-config (K5), the secrets into
the vault, nothing rotated (V3), then the providers one by one (B2, B3). Each is rehearsed in the lab
first, and each is checked by reading back through the module that owns the data before anything
points at it.
**What a release costs.** A proof that is run live (dunst on a workstation, a secret reaching its file, a
database consumer connecting) is first seen in the lab rehearsal, and a fault found then holds the
release, not one package. A merge's builds go to every machine at once (ADR 0360), so the release has no
first node to hold it: after the pull requests' checks, the lab rehearsal is the only live check before it
reaches every node. A machine that fails to apply it, or whose provisioner reports a module's files failed
(ADR 0362), is named in the rollout's end and raises apply-failing, and is fixed forward with a new pull
request into main; one that says nothing within fifteen minutes is named silent and gets its builds from
the backlog rollout once it is heard from (ADR 0360 §10).
**Nothing breaks on the way.** ADR 0349 clause 17: each file is filled the old way or the new way, never
both, by the form of its manifest, and the old forms stay accepted until the last module has moved.
Inside the release this still holds: the media catalogue and the applications kept in their own
repositories move after it, so the old forms stay accepted until they have (R1).
**Where it stands, 2026-10-17** (the Milestones section below). The packages' branches were merged into
main of mesh-controller, the node-engine (`mesh-host`) and mesh-catalog on 2026-10-16. T6 moved after them, as
planned above, and is resolved. B3 was left out as well, so B3, R1 and R2 are what is left.
## What exists already, measured
Counted 2026-10-14 at mesh-controller `1601cb1`, the node-engine (`mesh-host`) `1a26778`, mesh-sdk `a2d2af0`, mesh-catalog
`ebf7f37`.
| Piece | Today | Becomes |
|---|---|---|
| the controller's composer | `catalogue.Resolution.compose` fills every namespace but `${secret:}` in one loop (`declaration.go` ~1000–1085); the namespaces and their fields are tabled in `fillers` | fills placement only, for a module that has moved |
| settings | tables `settings`, `settings_history`; the `settings` verb (`modules.go` 407–760); proposals and asks (`proposals.go`, 975 lines); trusted files; `takes-settings` merging; about 4 100 lines and 2 500 of tests | the `mesh-config` module and its buckets; the verb `config` |
| placement kept as settings | `places`, `accesses` and the kept networks are read from settings layers | facts of an assignment, the controller's own |
| secrets | the controller mints, seals and accepts every secret (`internal/secrets`, `inventory/secrets.go` 1 125 lines; tables `secret`, `module_secret`, `secret_ask`, `operator_key`); the vault (`mesh-vault`, TypeScript) is read-only and never asked | the vault, in Go, holding and minting; handing out on request and reply |
| bindings | grant files and binding files composed by the controller (`declaration.go` 750–818); `${bound:}` 117 uses | each provider answers its consumers; the vault mints the pair's credential |
| contributions | `${contribution:}` (4 collectors), `${shell:}` (3), `${environment:}` (1); 22 contributors | collections, filled by the provisioner client |
| file text | 225 file resources in 88 modules, all inline (about 193 KB); 12 modules keep a second copy in 34 files with a test that the two agree (ADR 0349's context counted eleven; the twelfth is lemurs) | template files under `files/`, packed by the module's build |
| packing a directory | the builder's `archive` artifact packs a tree and publishes it by digest; the node-engine fetches and unpacks it | the same mechanism carries a module's templates |
| the node-engine's file writing | `applyFile`, `applyBlock`, `applyInto`, create-once, drift written back, `Keep` backups, restart-on and reload-on, container reads | the same code, reached also through a narrow local write verb |
| the node-engine's local verbs | one socket (`cli.sock`), peer-checked by uid, forwarding to the controller; no local write | a second, narrow socket for the provisioner client |
| bus identities | one principal per node for the tool runner, one per module with a process of its own | the provisioner client gets one of its own |
| the SDK | KV, watches, asks, caller proof; no sealing helper; the TypeScript `provisioner` is a database-provider harness | a seal box, provider helpers, and readers for config, bound values and secrets |
**Two measurements decide the order.** The builder can already pack a module's directory, and the
controller already fills every placeholder at registration time from text; so template files can ship
first, the controller reading the text back from the packed `files/`, and nothing on a node changes. And
the node-engine's writing code needs no change in kind, only a second way in; so the provisioner client
reuses it rather than writing as root itself.
## The order the work allows
```
package waits for package waits for
F1 — P1 —
F2 — P2 P1
F3 — P3 F3
F4 — P4 P3
T1 — P5 P2, P4, T1, K3
T2 T1 P6 P5
T3 T4 T5 T2 P7 P6
T6 T1 V1 F1
K1 T1 (same file) V2 V1
K2 K1 V3 V2
K3 F3, K2 V4 V3, P5
K4 K3 V5 V3, V4
K5 F2, K4 B1 P5, V5
K6 K1 B2 B1
K7 K5, P6 B3 B2
L1 P6 L2 L1
S1 P6 R1 T5, T6, K6, K7, P7, V4, B3, L2, S1
R2 R1
```
The issue tracker carries these as `depends-on` links (thirteen issues also link what they wait for
through another package), so the packages ready to start are those whose linked packages are all merged
into the release's branches. An issue's "Waits for: … delivered" means merged into the release's
branches: nothing is delivered before the release. The first wave touches different repositories and different files:
**T1, F1, F3, P1 and F4**. K1 waits for T1 because both change `internal/catalogue/manifest.go`; P3
waits for F3 because both change `internal/broker`; F2 joins as soon as a slot is free.
## 0 — Foundations
**F1 (issue 650) — A seal box in the SDK.** *mesh-sdk. Half a day.* The ADR 0206 box (X25519, HKDF-SHA256,
AES-256-GCM) moves from the licence manager into `mesh-sdk/go/seal`, with a TypeScript twin, and the
licence manager uses it. **Proof:** a byte-for-byte test against the licence manager's existing sealed
values; the licence manager's tests pass unchanged.
**F2 (issue 651) — Placement leaves the settings layers.** *mesh-controller. Two days.* `places`, `accesses` and the
kept networks become facts of an assignment, held by the controller beside the assignment, with a
migration moving each existing layer's keys; `Settle` stops letting a setting replace a provider's
served keys. **Proof:** composition of every node before and after the migration is byte-identical;
the settings layers hold no placement key afterwards.
**F3 (issue 652) — The assignments and the modules' config declarations on the bus.** *mesh-controller. Two days.*
The controller keeps the bucket of assignments design 54 decides (which module on which node), and a
bucket of each registered module's declared config values, written when they change, with one event per
write. mesh-config needs both to resolve. **Proof:** a test against a real bus: an assignment and a
registration each change exactly one key and raise one event.
**F4 (issue 653) — The words.** *hq. Half a day.* The glossary gains *config value*, *predefined key*, *provision*
and *provisioner client*, *collection*, and the ownership words *owned*, *seed* and *managed*. The word "setting" goes on *config
value*'s *Not:* line only in R2, once the 67 documents still using it are reworded; until then the
glossary says it is the older word. **Proof:** `words.py` passes.
## 1 — Template files (ADR 0349 clause 2)
**T1 (issue 654) — A file resource may name a template.** *mesh-controller. Three days; the largest package of the
stage.* A file resource takes `"template": "files/<name>"` in place of `content`; the module's build packs
`files/` into an archive artifact of its own (the existing `archive` packing); registration and `Resolve`
read each template's text back from the packed tree into `content`, so composition and the node-engine
are unchanged; `module check` and the composition check read templates from the repository tree beside the
manifest; a file naming both `template` and `content` is refused. **Proof:** composition tests: a module
with its text in `files/` composes byte-identically to the same module with it inline; the composition check
judges a change to a template alone.
**T2 (issue 655) — The twelve modules with copies.** *mesh-catalog. A day.* adwaita, clipmenu, dunst, feh,
gnome-keyring, i3, i3status-rust, lemurs, picom, rofi, screen-lock and xdg move to their templates; the
34 inline copies and the `sameAsSource` tests go. **Proof:** each module's composed declaration on its
nodes is unchanged (the composition check's plan says no file changes).
**T3 (issue 656) — The desktop modules.** *mesh-catalog. A day.* Every remaining module assigned only to
workstations. **Proof:** as T2.
**T4 (issue 657) — The servers' modules.** *mesh-catalog. Two days.* Every remaining module with a build. **Proof:**
as T2.
**T5 (issue 658) — The ten modules without a build.** *mesh-catalog. A day.* ca-trust, de-spiegel, invoicing,
local-model-consumer, matrix, node-env, only-office, sshd, step-ca and supabase gain an archive build for
their templates. **Proof:** as T2; afterwards no manifest in the catalogue carries file text.
**T6 (issue 659) — The media catalogue and the applications.** *mesh-media-catalog and each application's
repository. Two days.* **Proof:** as T2, per repository.
## 2 — Config (ADR 0348)
**K1 (issue 660) — A module declares its config.** *mesh-controller. Two days.* The manifest takes `config` (type,
description, validation, trusted, `predefined`, `default` or `required`), `takes-config` and
`${config:<key>}`, with `settings`, `takes-settings` and `${setting:}` read as aliases; registration
refuses everything ADR 0348's "How it is checked" lists for registration: a value without type or
description; a value with neither a default nor `required`; a default failing its validation; an
object or list not trusted; a predefined key not on the list or of another type (with K2); an untrusted
value falling back to a trusted key; a value neither in a template nor read at runtime; a placeholder
naming an undeclared value; a `reload-on` or `restart-on` naming an undeclared file; a moved module's
mergeable file not naming its keys; a mesh word or `puid`/`pgid` in `takes-config`. **Proof:** the registration refusal tests, one per
rule; every manifest in the catalogue still registers.
**K2 (issue 661) — The list of predefined keys.** *mesh-controller and mesh-catalog. A day.* `predefined.json` at the
catalogue's root, with `font-size` and `agent-managed-settings` first; the build validates it and every
manifest naming a key; the merge check refuses removing a key still named or holding a value; the newest
trunk build's list is published. **Proof:** the merge check refuses a planted removal; an older build's
list does not replace a newer one.
**K3 (issue 662) — The mesh-config module.** *mesh-catalog. Four days.* A Go module holding the seat `mesh-config`:
the buckets `mesh-config_values`, `_resolved`, `_predefined`, `_proposals`; the four sources resolved from
F3's assignments and declarations; the verb `config` (set, clear, list, history, narrowed by module and
node); validation; the hand-act log; `config-changed`; only its account writes its buckets. Terminal-only
for trusted values until K4. **Proof:** its tests resolve each source in turn; a test against a real bus
refuses a put from another account.
**K4 (issue 663) — Trusted values are proposed and approved.** *mesh-catalog. Two days.* mesh-config asks under its
own name, with its own grant and cap of open asks, binds each option to the level, key and value, records
the approver beside the value, and checks the proposal on Approve (ADR 0277's rules, ADR 0348 clause 8).
**Proof:** a proposal test end to end with a stand-in channel; an Approve against a changed value is
refused.
**K5 (issue 664) — The values move, and `settings` forwards.** *mesh-controller and mesh-catalog. Two days.* Each
existing layer moves into mesh-config (a node layer to `module.<node>.<module>`; a whole-mesh layer to
mesh config, a manifest default or each node, as ADR 0348's consequences say; claude-code's managed
settings to `agent-managed-settings`), recorded with why; for a module not yet handed to the provisioner
the controller reads `mesh-config_resolved` where it read its layers (ADR 0349 clause 17's old way,
an exception to ADR 0348 §9 that R1 removes), and `settings` answers by forwarding to `config`. **Proof:** composition of
every node before and after is byte-identical; `settings` and `config` list the same values.
**K6 (issue 665) — The catalogue says config.** *mesh-catalog. A day.* The 15 modules using `${setting:}`, the 9
declaring settings and the 4 with `takes-settings` move to the new names and the full declaration.
**Proof:** the catalogue's check finds no old name in a moved module.
**K7 (issue 666) — The operator's acceptance on dunst.** *live. An hour.* With every level cleared the first
workstation's dunstrc says 10; a mesh font size, then that node's font size, then dunst's own value on
that node each appear in its dunstrc within the call's answer, and the history shows each change with who and why. Needs P6.
## 3 — The provisioner (ADR 0349 clauses 3–5, 11, 13)
**P1 (issue 667) — The node-engine knows who owns a placed file.** *`mesh-host`. A day.* `declaration.File` gains
`owner-at-runtime` (`owned`, `seed`, `managed`), mapped onto today's paths (drift written back,
create-once, `into`), and the declaration gains the list of files the provisioner fills. **Proof:** the
apply tests: each ownership behaves as its old form did.
**P2 (issue 668) — The narrow local write verb.** *`mesh-host`. Two days.* A second unix socket accepts a write only from
the provisioner client's account (`SO_PEERCRED`) and only for a path the declaration names as filled,
writes through `applyFile`, `applyBlock` or `applyInto` with `Keep`, and fires restart-on, reload-on and
container reads as an apply does. **Proof:** tests: an undeclared path and another uid are refused; a
write under `reload-on` reloads its program once.
**P3 (issue 669) — The provisioner client's own bus account.** *mesh-controller. A day.* A principal of its own per
node for `node-provisioner`, granted to read its node's keys in each domain's bucket, publish
`prov.applied.<node>.>` and ask the vault; nothing else. **Proof:** the grants test, and a test against a
real bus refusing a read of another node's keys.
**P4 (issue 670) — The provisioner's server.** *mesh-catalog. Two days; after P3.* A Go module holding `mesh-provisioner` once:
the registry of which domain answers which kind, the buckets `_registry`, `_needs`, `_applied` and
`_collect`. **Proof:** its tests; the buckets listed live.
**P5 (issue 671) — The provisioner's client.** *mesh-catalog. Four days.* A Go service on every node, outside the tool
runner: fetches a module's templates by digest, records needs, watches each owner's keys for its node,
fills only when every value is there, writes through P2, reports `prov.applied`, writes an `owned` file
back on drift, removes a module's files and needs when it leaves, fills from current values when its node
comes back. Its first owners are placement (from the declaration) and config (from
`mesh-config_resolved`). **Proof:** the client's test list in ADR 0349, against a real bus.
**P6 (issue 672) — The controller hands a module to the provisioner, and the rollout waits for it.** *mesh-controller.
Two days.* For a module marked moved, the declaration carries placement, the templates' digest and the
filled-files list, and the controller runs no other filling pass for it; a rollout counts the module applied
on a machine only after the node-engine's report and that node's `prov.applied` for the new build, and a
failed `prov.applied` is a failed apply (ADR 0362). dunst moves first. **Proof:** composition and rollout
tests; dunst on the first workstation filled by the client, live.
**P7 (issue 673) — The node-engine stops writing placed files from the declaration.** *`mesh-host`. A day.* For a moved
module the engine writes its filled files only through P2, and removes as an orphan only what the verb
placed. **Proof:** the apply tests; no placed file written twice in a day's journal on the
first workstation.
## 4 — Secrets (ADR 0349 clauses 6–11, 16; issue 647)
**V1 (issue 674) — The vault in Go, holding secrets.** *mesh-catalog. Four days.* `mesh-vault` is rewritten in Go:
its own store, a copy of each secret sealed to the operator key, `get` on request and reply sealed to the
asker's key (F1's box; the node-engine's key format is settled here and written down), `secret-changed`
without a value, no secret in any bucket. **Proof:** the vault's test list in ADR 0349.
**V2 (issue 675) — The vault's verbs.** *mesh-catalog and mesh-controller. Three days.* `secret accept`, `secret-ask`
(with ADR 0277 §24–§27's record and bounds in the vault's store), `issued` (ADR 0305, the provider's tool
answering the vault alone) and `rotate` become the vault's; the controller's verbs forward. **Proof:** the
`secret-ask` and issuing provider tests in ADR 0349.
**V3 (issue 676) — The secrets move to the vault.** *mesh-controller and mesh-catalog. Two days.* Every row of
`secret`, `module_secret`, `secret_ask` and the operator key's public half are copied into the vault as they are,
nothing rotated, and read back through the vault before anything points at it. **Proof:** for every
secret, the vault's sealed answer opens to the same value the controller's does.
**V4 (issue 647) — A node's secrets come from the vault.** *mesh-catalog, mesh-controller, `mesh-host`.
Three days.* The client fills `${secret:}` from the vault's `get`; for a moved module the controller seals
nothing; the core's credentials travel sealed by the vault to their node and the node-engine opens them.
**Proof:** a secret rotated in the vault reaches its file without a new declaration, live.
**V5 (issue 677) — The vault mints pair credentials.** *mesh-catalog. Two days.* One value per provider assignment and
provision, remade for all holders when the vault sees a consumer's need recorded or deleted (ADR 0158
§4–§6). A need recorded for a bind the vault already holds from V3 adopts that value and remakes
nothing, so the move itself rotates no credential. **Proof:** the provider test in ADR 0349, with a stand-in provider.
## 5 — Bindings (ADR 0349 clause 12)
**B1 (issue 678) — A provider's helper in the SDK.** *mesh-sdk. Two days.* Go: watch the needs for a kind, write one
entry per consumer, refuse a derived value for a multi-holder consumer, answer from the vault's pair
credential. The TypeScript `provisioner` harness is renamed so the word means one thing. **Proof:** its
tests with a stand-in bus.
**B2 (issue 679) — PostgreSQL answers its consumers.** *mesh-catalog and mesh-controller. Three days.* The first
provider moves; its grant files and binding files are retired. **Proof:** every database consumer on
every node connects after the move with the credential it had, adopted by the vault as V5 says, live.
**B3 (issue 680) — The other providers.** *mesh-catalog. A week, one pull request per provider.* **Proof:** as B2, per
provider; afterwards the controller composes no grant file.
## 6 — Collections (ADR 0349 clause 13)
**L1 (issue 684) — A collection is declared and filled by the client.** *mesh-controller and mesh-catalog. Three days.*
A collector requires a collection; a contributor provides an item from a template; the client fills the
collector's template from `_collect`. `${contribution:}`, `${shell:}` and `${environment:}` read as
collections. **Proof:** zsh's file holds every placed contributor's item and loses one whose contributor
leaves.
**L2 (issue 681) — The collectors and contributors move.** *mesh-catalog. Two days.* zsh first, then the bar, the
display session, the hotkeys, xorg, power and node-env, with their 22 contributors. **Proof:** each
collector's composed file is unchanged at the move.
## 7 — A module's code asks at runtime (ADR 0349 clause 14)
**S1 (issue 685) — The SDK's readers.** *mesh-sdk and mesh-catalog. Two days.* `config`, `bound` and their watches,
answered by the node's client for what the manifest declares. `secrets` refuses until the module runs
under a bus account of its own, which waits on issue 569 and its decision. **Proof:** the SDK's test in
ADR 0349; claude-code reads its managed settings through it.
## 8 — The old forms go (ADR 0349 clause 17)
**R1 (issue 682) — Refused and removed.** *mesh-controller, `mesh-host`, mesh-catalog. Three days.* Once every module of
every catalogue has moved: the catalogue's check refuses inline text, `settings`, `${setting:}` and the
old contribution forms; the controller's filling passes but placement, its settings, secret and binding
code and tables (a new migration) go; the node-engine keeps opening sealed values for the core's files
only; the controller stops reading `mesh-config_resolved`. **Proof:** the controller's test that its
code opens no secret outside genesis and reads no config value; the absence of the
removed code.
**R2 (issue 683) — The record says what runs.** *hq. Two days.* The as-is designs are written, designs 27 and 32 are
amended, the 67 documents that still say "setting" in running prose are reworded so the glossary can
retire the word (F4 left it off the *Not:* line for them), design 49 lists the Config domain, ADR 0348's and 0349's built status is said, and this document's status becomes `implemented`.
## Milestones
The packages above were built as one delivery, not milestone by milestone; the milestones group them by what
each makes true. Whether a milestone's issues are delivered is read from the issue tracker (playbook 07).
| # | Outcome | Packages | Issues |
|---|---|---|---|
| 1 | Config, provisioning, secrets, bindings, collections and runtime asks run the new way; the old forms are still accepted | every package but T6, B3, R1, R2 | 647, 650 to 679 but 659, 681, 684, 685 |
| 2 | The media catalogue and the applications carry their file text as templates | T6 | 659 |
| 3 | The other providers answer their consumers; the old forms are refused; the record says what runs | B3, R1, R2 | 680, 682, 683 |
## What is deliberately not here
- **Secrets in a module's own code** wait on issue 569 (a module's tools run as the tool runner's
account) and its decision; S1 ships without them.
- **Release separated from delivery** stays issue 644's, and **the bus's replicas** issue 645's.
- **Renaming "walk"** is issue 646's.
## How this list is kept true
Each package's proof that runs in its tests is run when it is merged into the release's branch, and its
line here gains the date and the commit; a proof that runs live is run in the lab rehearsal and again
after the release's delivery. A package whose proof fails is not reworded; the failure is recorded under it and the package
stays open. The issue tracker says which packages are worked now; this document says only the order.
@@ -0,0 +1 @@
3a5c4c7c4d9c4143044f9659d4b58cb1d531bcd7
@@ -0,0 +1 @@
{"delivery": {"base": "main", "branch": "feat/current-mesh", "commit": "cc4c8ce12901ba4f4035bef7e660db049a2561f5", "id": "jschoubben/car-hunter@cc4c8ce12901", "issues": [1945], "number": 2, "repository": "jschoubben/car-hunter", "state": "ready", "title": "car-hunter on today's mesh: health check, templates, a network of its own, rebuildable data seeded at start"}, "waits": "its merge"}
@@ -0,0 +1,13 @@
[
{"delivery": "delivered to every machine it should reach", "needs": "none", "number": 1942, "owner": "mesh-controller cmd/mesh-controller/delivery_edges.go", "ready": false, "severity": "major", "status": "resolved", "title": "Today's planner cannot put the controller and the node-engine in one tier, which release 6's switch needs"},
{"needs": "none", "number": 1943, "owner": "mesh-sdk go/delivery, go/catalogue, go/cmd/mesh-hand-compose", "ready": false, "severity": "major", "status": "resolved", "title": "mesh-sdk has no layout for a module's composition on a node, no node want, no composition digest in a want, and still lists the plan verbs"},
{"needs": "none", "number": 1944, "owner": "mesh-host cmd/mesh-host, internal/agent", "ready": false, "severity": "major", "status": "resolved", "title": "The node-engine applies only whole declarations the controller sends, so no node can follow its wants"},
{"needs": "none", "number": 1945, "owner": "mesh-controller cmd/mesh-controller", "ready": true, "severity": "major", "since": "2026-10-24T14:14:53Z", "status": "fixing", "title": "The controller still plans deliveries and pushes whole declarations, so nothing can deliver by wants", "worker": "dev-r6-interface@shanks"},
{"delivery": "fix merged, not delivered: mesh-catalog#475", "needs": "none", "number": 1946, "owner": "mesh-catalog modules/mesh-delivery-gate", "ready": false, "severity": "major", "status": "resolved", "title": "The gate keeps a delivery's states, machine steps and plan calls that no longer exist once plans go"},
{"delivery": "held: mesh-delivery#39: a person: merged without a passing check: only a person decides what follows", "needs": "none", "number": 1906, "owner": "mesh-delivery", "ready": false, "severity": "minor", "status": "resolved", "title": "Release 6 must bring what R5's assigner leaves out: wants mirrored as pulses, the restore ask for a data module and the condition for a held fingerprint"},
{"needs": "none", "number": 1130, "owner": "hq 03-DESIGN/01-to-be/47, 03-DESIGN/00-as-is/04-delivery, 03-DESIGN/00-as-is/13-the-console", "ready": false, "severity": "minor", "status": "resolved", "title": "Design 47's sections still describe delivery plans, batches and the merge window, which ADR 0429 replaces release by release"},
{"abandoned": true, "delivered-not-resolved": true, "delivery": "delivered to every machine it should reach", "needs": "none", "number": 1049, "owner": "mesh-catalog modules/gitea", "ready": true, "severity": "critical", "since": "2026-10-18T02:26:22Z", "stale": true, "status": "fixing", "title": "A group that moves a module delivers its first member before the second merges, so the plan builds the moved module from the old repository and fails", "worker": "fix-chain-cat@shanks"},
{"needs": "decision", "number": 1126, "owner": "mesh-controller", "ready": false, "severity": "minor", "status": "open", "title": "About 49,800 controller code lines stay after the delivery model, against a first limit of 25,000"},
{"needs": "none", "number": 774, "owner": "mesh-controller; mesh-catalog modules/mesh-vault", "ready": true, "severity": "minor", "status": "open", "title": "ADR 0113 clause 6 is not built: the controller, not the vault, mints the bus accounts of modules"},
{"needs": "none", "number": 1021, "owner": "mesh-catalog modules/mesh-delivery", "ready": false, "severity": "minor", "status": "wontfix", "title": "An after: line added by editing a pull request's description is never read, though mesh-delivery's refusal says to add it there"}
]
+10 -3
View File
@@ -32,7 +32,8 @@
"propose",
"import",
"return",
"withdraw"
"withdraw",
"progress"
]
}
],
@@ -52,7 +53,8 @@
"propose",
"import",
"return",
"withdraw"
"withdraw",
"progress"
]
}
],
@@ -82,7 +84,12 @@
"records.records_status",
"records.records_list",
"records.records_read",
"seat:operator-channel.notify"
"seat:operator-channel.notify",
"seat:issue-tracker.list",
"seat:issue-tracker.get",
"seat:mesh-delivery.deliveries",
"seat:mesh-delivery.show",
"seat:mesh-delivery.groups"
],
"config": {
"judge-model": {