Files
mesh-catalog/modules/mesh-issues
jochen f067fab9db
mesh/merge-gate pass: builds mesh-issues → novox; no bus step; every machine composes with the change as it did without (4 of 4 compose)
mesh/repo-check pass: its merge-check.sh passed, each part in its toolchain: merge-check.sh (go), merge-check-typescript.sh (typescript)
mesh/delivery delivered
mesh-issues: end a fix-stuck warning when its issue is resolved, and never say a closed issue's fix stuck (issue 547)
A resolved issue's worker proved the acceptance live, so a fix whose own walk failed
and that a later merge carried is not waited for. Only wontfix ended the warning, so
16 warnings stayed open on the operator's channel and the sweep raised new ones for
resolved issues.
2026-10-12 13:31:51 +02:00
..

mesh-issues

The issue tracker: the mesh numbers and tracks its issues in a module of its own (novox/hq ADR 0275, to-be 53). An issue is a question about the mesh that someone must answer; this module holds the mesh's issues and hands out their numbers, and the numbers of decision and research records too (ADR 0275 rule 8, ADR 0290 rule 5). It holds the seat issue-tracker, once for the mesh, on the control-node.

Built on the model of hits, a headless issue tracker on NATS, studied at release v0.7.0: its statuses, fields, append-only notes, typed links and compare-and-set numbering are taken as ideas. It carries none of its code, under a licence that is not an open-source one.

Verbs

Called as issue-tracker.<verb> through the mesh's tools; each says in its description what it does and what it refuses.

verb takes does
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 the issue whole: fields, notes, links
list optionally status, owner, phrase, limit, severity, needs, ready, flag (delivered-not-resolved or abandoned), search (words, ranked), before (a number, the cursor) short rows, newest first, or best first for a search: number, status, owner, title, 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
import optionally dry-run, commit hq's issue folders, read through the records module, each written as an issue under its own number; see below
set number, any of severity, needs, suggested-owner changes them as a person decides
groom optionally pass, confirm the triage over the backlog: without arguments a dry run in the background, answering its pass id; with pass its progress and findings; with confirm and pass exactly that pass's findings applied, each checked again; see below
tracking optionally conditions (condition keys) for every condition key an issue links (link kind condition), the issues that link it, those not closed first: status, severity, needs, worker with since, active and stale, and where the fix is (fix); the operator's channel says it in a condition's message (hq ADR 0285)
reserve kind (decision or research), optionally for (one line: what the record is about) the next number of that kind, spent forever; answers kind and number (and prefix, the number as the record's name starts with it); see below

The cycle's rules, at the write (hq playbook 03, which 00-META/checks/cycle.py enforced on the folders until the import, and hq ADR 0284): a status is one of open, diagnosing, located, fixing, resolved, wontfix; diagnosing and fixing need by; located needs located-in; resolved is reached only from fixing and needs located-in and fixed-by; a core issue (located in the controller, the node-engine, the tool runner, the SDK, or the catalogue's bus, forge or build agent) resolves with replay or replay-none; wontfix needs a reason. A refused change changes nothing and says which rule refused it.

Who works on it (hq ADR 0284). A move to diagnosing or fixing names in by the issue's worker: who works on it and its node, as who@node (such as fix-399@g14 or jochen@shanks, kept in lower case). The issue keeps its worker (worker: by, since, active) while it stays at either status; any other status ends the work. get shows it, with worker-stale, and each list row shows worker, since and stale. A move to either status by someone else while the worker is current is refused, naming the worker; with take-over saying why, the issue is taken over, and the reason is noted on it. A worker with no move, note, link or set from a caller on the issue for worker-stale-after (default 24h) is stale and may be taken over from without a reason, still noted. The tracker's own writes (the triage, the duplicate check, the delivery notes) keep no worker current. The moved event carries by (the worker; who acted, for a move naming none), since, caller (who acted, as the runtime proves it: see Who acted), and took-over-from, take-over or released when they apply. by is what the worker says it is, not who called, so the worker lets agents and people see each other; it is not a lock against a false name.

An issue with a folder in hq (record at open: the folder's name, without the number) is whole in the tracker like any other: the folder holds its narrative, the tracker its status. Until the import of slice 3 ran, such an issue kept its status in the folder and move refused it; the import filled it.

The triage (hq ADR 0278, ADR 0279)

Every issue opened is triaged by the module itself: it consumes its own mesh-issues.opened event, and for an issue still at open asks the agent on its node — claude-code.claude_code_judge@<node>, which runs the agent with no tools under the node's licence — two questions: a shortlist of at most five older issues by their titles, then, always, a judgement on the reports: whether it repeats one (duplicate, related or none, whether it is certain, and why), its severity (critical, major, minor), what it needs (none, decision, operator), a suggested owner when it names none, and the shortlisted issues it depends on or relates to, each with a one-line reason. Only issues at open, diagnosing, located or fixing that are older than the new one are offered.

The judgement is kept on the issue (triage) and said as the event triaged before anything else, then applied through the module's own note, set, link and move, each recorded as by triage and each only when not done yet:

  1. one note saying the whole judgement, a line per value with its reason; where the issuer gave a severity or needs the triage disagrees with, the line says so and the given value stands;
  2. each empty value filled — never one a person gave;
  3. each depends-on and relates-to link added, unless it is there, would close a cycle, or its target is resolved or wontfix; the older issue gains a note;
  4. a certain duplicate: linked duplicates, its title and report noted on the older, moved to wontfix "duplicate of N" last. The older issue never moves.

A second delivery of the event finds the kept judgement and finishes what is missing without judging again. A judgement that fails leaves one note, and the event is not taken: after its fifth delivery the controller raises max-deliveries on the module's consumer. Notes the duplicate check of ADR 0278 wrote as duplicate-check are still read as that issue's verdict.

Ready. An issue is ready when it is not resolved or wontfix and every issue it depends on is wontfix, or resolved with every pull request its fixed-by names (<repository>#<number>) delivered. The module consumes mesh-delivery.transition and marks a pull request delivered on every issue naming it; at its own moved to resolved it asks seat:mesh-delivery.checks once for what it has not heard. An issue made ready gains a note and the event ready. get answers waits-for, needed-by and ready; list filters on severity, needs and ready — an agent looking for work asks severity=minor, needs=none, ready=true. set changes severity, needs and suggested owner as a person decides. link refuses a depends-on that would close a cycle.

groom triages every issue not resolved and not yet triaged, oldest first, against the older ones not resolved and not found a duplicate in the same pass. It writes only the pass, in the state grooming (the last pass, one key), each finding saying what applying it would do, and applies exactly that pass on confirm, each issue checked again. One judgement runs at a time; the pass waits while a new issue's triage waits. A triage may take up to two minutes, which the tool runner allows a handler while keeping its event alive (hq issue 387).

The new values are optional keys of an issue's one JSON value: an issue kept before reads as having none; the key-value state has no schema to migrate.

Whether the fix is delivered (hq ADR 0281)

For each pull request an issue's fixed-by names, the module keeps what mesh-delivery.transition last said — state, why, merged and when — or asks seat:mesh-delivery.checks once (at its own moved) for one it has not heard of. Nothing sets it by hand. get and every list row carry delivery, one line derived from it, worst first: "fix not delivered: …", "held: …", "fix merged, not delivered: …", "fix not merged: …", "nothing heard from the delivery of …", "delivered to every machine it should reach" (covered by a later merge says so).

Delivered is not resolved. When every fix of an issue not yet resolved is delivered, the issue gains a note and the event ready-to-verify, once per set of fixes; it is resolved once its acceptance is proven live. A fix is named while the issue stays fixing: move to the status it has changes what it holds and is refused when it changes nothing.

The fixes of an issue are its fixed-by and the pull requests linked to it (link kind pull-request; hq issue 483): agents link a pull request while the issue is in fixing and give fixed-by at the resolve, so a delivery read on fixed-by alone missed every fix being verified. Readiness still reads fixed-by alone, so a pull request linked and abandoned holds no dependent back. A pull request closed without merging (stopped, and never merged) is withdrawn: not a fix awaiting delivery, not delivered, not open; the line says "fix withdrawn: …" when it is the only one. Merged is read from the delivery's merged_as or the pull request's own merged, never from the state, since stopped is also what a closed pull request becomes. A pull request linked after its delivery moved hears no transition again: each sweep asks mesh-delivery for at most five fixes never heard of, of issues not resolved.

The ready-to-verify note names each fix with when it was delivered, and the issue's worker; the event carries worker and delivered-at. No channel of the tracker reaches a worker, so the note and the event are how it hears.

Delivered, not resolved (hq issue 483). An issue in fixing whose every fix is delivered carries delivered-not-resolved on get and its list row. Past delivered-not-resolved-after (default 1h) from the last delivery, the sweep says it once as a warning on the operator's channel, key issue-tracker.delivered-not-resolved.<number>, "issue N: fixed and delivered, not resolved; works on it", cleared there when the issue leaves fixing or names a fix not delivered yet (at its moved, or the next sweep).

Abandoned (hq issue 483). An issue in diagnosing or fixing whose worker did nothing on it for worker-stale-after (default 24h) and that names no pull request still open (not merged, or never heard of) carries abandoned on get and its list row, beside its worker. A move by someone else takes it over without take-over's reason, as any stale worker; the answer, the note and the moved event say it was abandoned (abandoned).

Every five minutes the module looks for a fix merged and not delivered after stuck-after (default 2h) of an issue not closed: it is said once — a note on the issue and a warning on the operator's channel (seat:operator-channel.notify, key issue-tracker.fix-stuck.<number>), cleared there when every fix of the issue is delivered or the issue is closed. Resolved is the end of the warning as wontfix is: its worker proved the acceptance live, so a fix whose own walk failed and that a later merge carried is not waited for (hq issue 547). An issue resolved or wontfix is never said stuck. A module that runs only its bundle cannot raise a controller condition (hq ADR 0240 rule 8, ADR 0256), so the warning is the channel's, not the controller's.

The import of hq's issues (slice 3)

issue-tracker.import reads every 04-ISSUES/NNN-slug/00-report.md of hq through the records module over the bus (records_status, records_list, records_read, called as this module through the SDK's Ask), at the commit the records module's checkout stands at, and writes each as an issue at its own number: record is the folder, the title is the report's heading, the report is the text under it, and status, opened, located-in, fixed-by, amended-design, replay and replay-none are the frontmatter's, as written — the status one to one, no cycle rule applied, because the folder's status is the snapshot hq kept. The issues and decision records the report names (issue 155, ADR 0275, 04-ISSUES/203-…, 02-DECISIONS/0019-…) become relates-to and decision links.

It is a verb on the seat, callable by any agent, because it can do only this:

  • It only creates an issue that is not there (create on the key, refused when the key exists) or fills the status of one the tracker holds without a status — an issue opened with a folder before this slice, whose folder kept its status; one whose folder hq does not hold at the commit becomes open.
  • It never overwrites. An issue the tracker holds differently is reported as differs, naming the fields, and left alone: the tracker is the truth. An issue it holds the same is same. So a second run changes nothing and says so.
  • It never touches the counter, and refuses the whole run when a folder is numbered past the counter. A folder numbered at or past the setting first-number without an issue in the tracker is refused on its own: a folder from the tracker's time carries a number the tracker gave.
  • It cannot forge an issue's history. An issue it writes is opened-by: import, its opened the date the report gave, and it carries imported: {at, by, commit, from} — when, who ran the import (the caller, as the runtime proves it), the commit of hq and the document. Each is an imported event. What it writes comes from hq's main as the records module holds it, which is reviewed in pull requests.
  • What it keeps is bounded as every verb's text is: the title 200 bytes, the report 64 KiB, one issue 252 KiB; a report that trips a credential's shape is refused. An entry of located-in or fixed-by hq wrote is prose at times, so it is taken within a reason's bound (2000 bytes), not a line's.
  • An owner hq named that the registered list does not know is kept as written and marked, in located-in-unverified on the issue and unverified-owners in the answer, never refused: the list is the setting's problem (hq issue 366), and a move that gives located-in anew checks every entry and clears the mark.
  • With dry-run it reads everything, writes nothing, and answers the same rows with would- on each action. With commit it refuses a checkout standing elsewhere; it refuses a run during which the checkout moved.

The answer is a row per folder — number, action (create, fill, same, differs, refused, with why), status, title — the counts, the counts by status, what was odd (a title numbered otherwise than its folder, a frontmatter key not imported, an amended-design that is no design document) and the unverified owners. A call that outlives the tool runner's wait is still running: the import goes on, and the answer is read back through the controller's calls verb by the call's id.

Before the module is rolled out, mesh-issues dry-run <hq checkout> <settings.json> at the terminal runs the same dry run over a checkout on disk against an empty tracker, and prints the answer. It is not a way to import: only the verb writes, and it reads the record over the bus.

Every argument is read strictly. One a verb does not take is refused, never dropped. Every field has a size limit, a one-line field takes no control character, a folder's name is no path, and text shaped like a credential (a private key, an API key, a forge or bus token, password=…) is refused: an issue is read by every agent on the mesh. The shapes catch the ordinary mistake, not every secret.

Numbers

One counter, the key issue of the state counters, advanced only by compare-and-set: the SDK's State(...).Increment reads it and writes the next number only at the revision it read, so two openers — in one instance or two — never take one number. A write another opener won is read and tried again, a bounded number of times with a short random wait; past the bound the open is refused and takes no number.

  • A refused open takes no number: an issue is checked before its number is taken.
  • A number is never handed out twice, and may be skipped. A number taken and then not taken by the log — the bus failing between the counter and the issue's first operation, or a full log — is spent, and the open answers the failure (hq ADR 0300 §2). An append whose answer was lost is recognised as its own by the open's id when tried again or read back, so it leaves no gap; when it cannot be confirmed either way, the answer says the issue may have been written and to get that number before opening it again, never that nothing was opened.
  • An issue is never overwritten. Its first operation is appended at expect 0, refused when the log already holds one for that number; if the counter ever hands out a number already kept, the open is refused loudly, the number stays unused, and nothing is overwritten.
  • The counter starts once, the first time a number is taken: at the setting first-number, or after the highest issue already kept, whichever is higher, made only if it does not exist. The setting is never read again, so a number never moves.

Decision and research numbers

issue-tracker.reserve {"kind": "decision"|"research", "for": "…"} answers {"kind": …, "number": N} (hq issue 424; the knowledge base asks it before it writes a record, ADR 0290 rule 5). Each kind has its own counter beside issue in counters — keys decision and research — advanced the same way, so two sessions writing a record at once never take one number.

  • A reserved number is spent forever, whether or not its record ever lands; it is never handed out again. Each is kept in the state reservations under decision-0292 or research-046 with who reserved it (by, the caller as for every verb), when (at) and for, and said as the event mesh-issues.reserved. A reservation whose write cannot be confirmed is refused, and its number stays spent.
  • A refused reserve takes no number: a kind other than decision or research, a for that is not one line of plain text or looks like a secret, an unknown argument.
  • A counter starts once, at the first reserve of its kind, at the highest of: the highest number of that kind the knowledge base holds (mesh-knowledge.knowledge_highest, which counts every number spent there: accepted, proposed, reserved or withdrawn; hq issue 488), the setting decision-floor or research-floor, and the reservations kept. The first number handed out is one above. hq's folders are not read: the records live in the knowledge base, and those folders empty when they leave hq (hq issue 426). When the knowledge base does not answer (or holds none of the kind), the first reserve of a kind is refused and nothing is spent, whatever the floor says: a floor raises the start, and never stands in for the knowledge base's answer.
  • Never at or below a number held: each reserve asks the knowledge base again, and a counter that stands below the highest number there is raised to it first, written only at the revision read. When the knowledge base does not answer once the counter has started, the counter is used as it stands, and the answer's warning says the number was not checked against the records held.
  • Open branches are the floor's job while records are still written in hq: a number taken on an open branch and not yet imported is covered by the floor. Before the first reserve of a kind, the operator raises decision-floor or research-floor (the controller's settings verb) to the highest number on any open branch of hq; once the counter has started, the floor is not read again.

The log, the bucket and the events (hq ADR 0300)

The record is the log changes (declared in the manifest's logs, made by the controller, hq ADR 0297), one key per issue by number. Every write — open, move, note, link, set, the triage, a delivery's word on a fix, a warning said or cleared, the import — is one operation appended under the issue's number with what changed, who acted and when, at the sequence of the last operation the issue was folded at (AppendAt). Another operation in between refuses it: nothing is written, the log is folded again and the change applied to that, a bounded number of times. A write the log refused writes nothing; only an open's number, once taken, stays spent. An operation that begins an issue (opened, imported, carried) holds it whole; every other one names the fields given a new value (set), the lists added to at their end (add: a note is kept as itself, not as every note again) and the fields taken away (unset). The fold applies them as written, so a new kind of operation needs nothing new in the fold.

The bucket issues is the fold of the log, each issue carrying folded, the sequence of its last operation. It is written after the append, at the revision read; one whose write fails after the log took the change is behind, and caught up at its next change (the stale append is refused and the log folded) or at the next start. Agents and views watch it as before. It still keeps 8 values per key, so the bus cannot drop the older values before the carry has read them; it goes to one value per key (ADR 0300 §3) in a follow-up, once the carry has run with a read of those values.

On start, the tracker watches its own bucket, carries what the bucket holds and the log does not (below), reads the whole log page by page, folds every issue whose last operation is newer than its folded — an emptied bucket is rebuilt whole — and raises the issue counter to the highest number in the log where it is below. Until then a list reads the bucket and a write carries the issue it changes first.

The switch (ADR 0300 §6) is the carry, run on every start and before any write of an issue not carried: for every issue the bucket holds with no folded, each value the bucket still keeps of it, read through the runtime's mesh/state.history (hq ADR 0307), is appended oldest first as a carried operation holding the whole issue and the bucket's revision of that value, and the fold written. It resumes rather than restarts: a kept value at or below the highest revision a carried operation holds, or one the tracker wrote itself (it has folded), is known to the log, so an interrupted carry, or two instances carrying one issue at once, carry each value once. Matched by revision, not content, because the same content may be kept twice. A value written beside the log after the carry, by an instance from before the switch, is carried as one more operation and is never written over by a fold. A deletion among the kept values is passed over and counted, since the tracker never deletes an issue and every value after one holds the issue whole.

An issue that cannot be carried — the log refusing it, or a value this module cannot read — is a fault, named with its reason in the start line; the others are carried and the start finishes. That issue stays in the bucket as it was, read and listed as before, and every write to it is refused with the same reason until it can be carried, so no operation reaches the log before its carry. One issue is kept in at most 252 KiB, so that the operation holding it whole fits one entry of the log (256 KiB). An entry of the log that cannot be folded faults its issue on start: nothing folded past it is written, and its bucket value is left as it is.

In memory the tracker holds every folded issue, kept current by the watch and its own writes, and a full-text index of title, report and notes, ranked by BM25 (title words weigh three), rebuilt on every start. list answers from memory, with search ranked best first, and before paging the newest-first list past the 500 rows one answer holds. The index is the module's own: a few thousand issues need a hundred lines, not a library of dozens of modules.

The other states: counters, one value per key (issue, decision, research); grooming, the backlog's pass; reservations, one key per reserved number. None expires. One issue is kept in at most 252 KiB; a note that would pass it is refused.

The events announce; the log is the audit trail. Every change is still an event: mesh-issues.opened, .moved, .noted, .linked, .imported, .reserved, each with the number, what changed, who changed it and when. A change whose event the bus did not take is kept, and its answer says the event was not said (announced: false).

Who acted

ADR 0275 §5 records the caller from the bus credential, never from an argument. Who acted is the bus user that called (hq issue 365): a call on a tool's caller-named subject carries the caller's bus user as its last token, the bus lets each user publish there with its own name alone, and the tool runner hands the module that user from the subject the call arrived on (mesh-sdk stdio.CallerOf) — shanks.node-tools for an agent on that machine's console, person.<name> for a person's credential. A call on the subject that names no caller (served beside it for one release, hq issue 464), or through a runtime from before issue 365, is recorded as by unproven. No verb takes an argument naming a caller, and none can be given one. The caller is only as sound as the credential, which ADR 0266 keeps agents from reading (hq issue 333).

Settings

duplicate-check (on; off stops the triage of new issues), judge-model (sonnet; haiku or opus) and forge-owner (the forge's owner of the repositories fixed-by names, for asking a delivery) steer the triage; stuck-after (2h) is how long a merged fix may take to be delivered before it is said; delivered-not-resolved-after (1h) how long an issue may stay in fixing once every fix is delivered before it is said (hq issue 483).

{
  "first-number": 365,
  "owners": ["hq", "mesh-catalog", "mesh-catalog modules/nats", "mesh-controller", …]
}
  • first-number — the number the first issue gets: the one after the highest issue number on hq's main and every open branch at the moment the module is switched on. Read once for the counter; the import reads it as the boundary between the folders hq numbered before the tracker and the numbers the tracker gave.
  • decision-floor, research-floor — the highest decision and research numbers taken where the tracker cannot read, such as an open branch of hq (293 and 45 when this was written: 0293 on an open branch, 045 on main). Read once, when that kind's counter starts; see Decision and research numbers.
  • worker-stale-after — how long an issue's worker stays current with nothing done on the issue (hq ADR 0284), as a duration; 24h when empty.
  • owners — the registered list located-in may name: repositories, and modules as mesh-catalog modules/<module>. An entry of located-in is an owner, or an owner followed by a space or a slash and where in it (mesh-controller internal/broker, mesh-catalog modules/nats/module.json). A module must itself be registered: mesh-catalog modules/<module> is never taken as a place in mesh-catalog. It starts with the mesh's repositories and the modules the catalogue held when this module was written; a module added since is added here through the controller's settings verb.

Tests

go test ./... in this directory. The acceptance — an agent opens an issue and gets the next number; two agents opening at the same moment never get the same one — is TestFiftyOpensAtOnceFromTwoInstancesGetFiftyDistinctConsecutiveNumbers: a JetStream server linked into the test, two instances of this bundle (the test binary started again) speaking to a runtime played by the test against the buckets the manifest declares, fifty opens at once, fifty distinct numbers from the first in a row. It counts the writes lost to another opener, so it fails when the openers never collided. With the counter written without its revision check, it fails.

reserve's acceptance — sessions reserving at the same moment never get one number, and the first is above what the knowledge base holds — is TestFortyReservationsAtOnceFromTwoInstancesGetFortyDistinctConsecutiveNumbers on the same bus, with the knowledge base played by the test, and the tests in reserve_test.go: the start above the knowledge base, the floor and the reservations kept, a counter behind the knowledge base raised, a refused reserve spending nothing, the first reserve refused while the knowledge base does not answer, even with a floor set, a kind held none of being no answer, hq's folders never read, and a reservation that cannot be kept leaving its number spent.

The import is tested over testdata/hq, copies of seven real reports of hq chosen for their shapes (a bracketed list with commas inside parentheses, quoted block items, an apostrophe in prose, a resolved: date, a replay, an empty list, a wontfix): the frontmatter read as written, each issue created at its own number with the counter untouched, a second run changing nothing, an issue the tracker holds differently left alone, a folder-backed issue filled once, a folder past the counter or past the first number refused, a dry run writing nothing, and a report that cannot be read refused alone with why.

The log (hq ADR 0300) is tested in log_test.go over a log kept in the test with the runtime's semantics (expect, ErrChanged): a write whose append is refused changing nothing, an operation in between not lost, an open refused at validation taking no number, an append failing after the number spending it and answering the failure, an emptied bucket restored exactly by the fold on start, a bucket behind its log catching up, the counter raised to the log, each write saying its event beside its one operation, before paging 530 issues without a gap or a repeat, a ranked search with the index rebuilt on start and kept current by the watch, and the carry carrying each value kept, oldest first, once, passing over a deletion among them. Over the bus, a write to an issue the bucket kept before the switch carries its three kept values first, read through the runtime's history. Over the bus, the stream the controller makes holds one operation per change of TestAnIssueIsOpenedNotedLinkedMovedAndListedOverTheBus.