ADR 0278, to-be 53 §9: the issue tracker checks every new issue for a duplicate
mesh/merge-gate pass: the change touches no module of the mesh's graph
mesh/repo-check pass: its merge-check.sh passed
mesh/delivery superseded: a newer head of the same pull request

The operator asked for duplicates to be consolidated, and better, when an issue is
opened rather than on a schedule. A judgement needs a model, and the mesh had no
channel for one; the agent module answers one question with no tools, under the
node's licence, and the tracker acts only on a clear duplicate, closing the newer
issue and keeping its report on the older one.
This commit is contained in:
jochen
2026-10-10 17:04:27 +02:00
parent 894be9971f
commit cf3065c660
3 changed files with 245 additions and 4 deletions
+4
View File
@@ -550,6 +550,10 @@ Not a domain of the mesh but of the way it is built, listed because its words me
held by the module `mesh-issues` ([53 — The issue tracker](../03-DESIGN/01-to-be/53-the-issue-tracker.md)).
Not hits's *item*; bare *ticket* is a homonym (see [Homonyms](#homonyms)).
*Not:* ~~ticketing system~~ (hq), ~~ticket system~~ (hq)
- **duplicate check** — the issue tracker's judgement, when an issue is opened, of whether it asks a question an
older issue not resolved already asks; a certain duplicate is closed `wontfix` with its report kept on the older
issue ([ADR 0278](../02-DECISIONS/0278-the-issue-tracker-checks-every-new-issue-for-a-duplicate-through-a-judgement-the-agent-makes-without-tools.md)).
Its pass over the backlog is the verb `groom`.
- **hq check** — one of `00-META/checks/`, run by `merge-check.sh` as this repository's check.
## Homonyms
@@ -0,0 +1,204 @@
---
topic: how we work
status: accepted
date: 2026-10-10
deciders: jochen
reconstructed: false
extends: 02-DECISIONS/0275-the-mesh-numbers-and-tracks-its-issues-in-a-module-of-its-own-and-hq-keeps-only-the-narrative-of-a-design-or-governance-issue.md
---
# 278. The issue tracker checks every new issue for a duplicate, through a judgement the agent makes without tools
## Context
On 2026-10-10 the operator asked for "a 'groom backlog' tool for our issue tracker which goes over the
non-resolved issues and checks for duplicates and tries to consolidate issues together, 'cancelling' the other
ones with a reference in the open one", and then: "or even better, that runs when a new issue is submitted".
Agents open issues through `issue-tracker.open` ([ADR 0275](0275-the-mesh-numbers-and-tracks-its-issues-in-a-module-of-its-own-and-hq-keeps-only-the-narrative-of-a-design-or-governance-issue.md)).
Nothing tells an agent that the question it is about to open is already open, so the same symptom is opened
again by the next session that meets it. [Research 042](../01-RESEARCH/042-the-mesh-keeps-its-own-issues/01-findings.md)
§3 planned a duplicates pass over the imported issues as candidate pairs "whose titles share most words",
each confirmed before it is applied. Shared words find the pairs a reader would find by searching; they do not
find the same question asked in other words, which is the case that costs time, and they cannot tell a
duplicate from two issues in one area. That judgement reads both reports, which needs a language model.
**What the mesh had for a judgement, measured on 2026-10-10.** No seat or tool runs a model for a module:
`mesh_search` for "headless agent", "llm" and "completion" found nothing. The `claude-code` module holds the
agent on every node and the licence the node is bound to ([ADR 0206](0206-a-node-reports-the-anthropic-grant-it-holds-and-the-licence-manager-adopts-a-licence-by-refreshing-it.md));
on the control-node the agent runs as an account of its own ([ADR 0266](0266-on-the-control-node-agents-run-under-an-account-of-their-own-that-cannot-become-root-without-a-person.md)),
and the operator's account, which the tool runner runs bundles as, may become it. `claude` in print mode with
its built-in tools turned off (`--tools ""`) and in safe mode answers a structured question with only its
answer-shaping tool available: measured on the laptop, the session listed `StructuredOutput` as its only tool,
in 2.2 seconds. Ollama provides `model-access` on the home server only, and the control-node runs no model.
**The bounds that shape the design.** A tool call on the bus is answered within 30 seconds or the asker hears a
timeout (the tool runner's request bound). An event a module consumes is redelivered after 30 seconds without
an acknowledgement, five times at most; after that the controller raises its `max-deliveries` condition and
keeps the event in `DEAD_LETTERS` until a person delivers it again. A module that runs only its bundle declares
no health ([ADR 0240](0240-a-module-says-how-it-is-healthy-and-the-node-engine-judges-it.md) rule 8), and a
module's health is the only way it raises a condition of its own
([ADR 0256](0256-a-machines-hardware-condition-is-read-by-one-module-on-every-machine-and-a-fault-is-said-through-its-health.md)).
The record was searched first ("duplicate", "groom", "issue opened", "headless", "claude-agent -p", "agent
account"): only research 042's pass and the grooming passes made by hand on 2026-09-29.
**A word.** The glossary has none for this check. This record names it the **duplicate check** and adds it.
## Considered Options
### What starts the check
1. *A schedule, every two hours, over the backlog.* The operator's first idea, and his own better one replaced
it: a duplicate opened at 10:00 stays open and gathers notes until noon.
2. **The tracker's own `opened` event.** The tracker already says every open on the bus
([to-be 53](../03-DESIGN/01-to-be/53-the-issue-tracker.md) §4). Consuming it is the mesh's ordinary way for one
change to start work, it reaches an issue opened by any caller, and the event's redelivery is the retry.
Chosen, with a verb for the backlog that exists today.
3. *A step inside `open`.* Refused: `open` answers within the bus's 30 seconds, and a judgement can take most of
them; an open must never fail because a model was slow.
### Who judges
1. *The tracker calls the vendor's interface itself, with the node's token.* Refused: the token is the agent
module's, handed to it sealed (ADR 0206); a second holder is a second place it can leak, and a subscription's
token is for the agent.
2. *A local model through `model-access`.* Refused for now: none runs on the control-node, and the one on the
home server is weaker at telling "the same question" from "the same area", the one distinction this check
must get right.
3. **The agent module answers one question, as the node's agent with no tools.** `claude-code` gains a tool that
runs the agent in print mode, as the account agents run as, under the licence the node is bound to, with its
built-in tools off and in safe mode (no tool servers, plugins, hooks or instruction files), and answers the
structured answer. Chosen: the module that holds the licence spends it, and the session can do nothing but
answer.
4. *A seat for judgements, with kinds.* Deferred: one module asks today. When a second module asks, or a
second holder (a local model) is worth offering, the tool becomes a seat's verb; until then it is a module's
tool, called on the tracker's own node.
### What a verdict may do
1. *Close whichever of the two is newer, or merge both into a third.* Refused: a third issue loses both
numbers' history, and an agent holding the older number would find it closed.
2. **Close only the newer issue, the one just opened, and only for a clear duplicate.** The older issue stays as
it is and gains the newer one's report as a note, so nothing reported is lost and the number agents already
hold stays open. Chosen.
## Decision
### 1. A new issue is checked when it is opened
`mesh-issues` consumes its own `mesh-issues.opened` event. For each opened issue still at `open`, it judges it
against every **older** issue that is not resolved: at `open`, `diagnosing` or `located`. A resolved or wontfix
issue is never offered, never linked and never changed by the check.
The judgement has two questions, so each answers within the bus's bound however long the backlog grows:
1. **A shortlist by titles.** The new issue's title and report against every candidate's number and title; the
answer names at most five numbers that could be the same question or closely related.
2. **A verdict on the reports.** The new issue and each shortlisted issue whole, each report cut at 8 KiB; the
answer is one of `duplicate` (the same question: one answer closes both), `related` (the same area or a
cause in common, two questions) or `none`, with the issue it names, whether it is **certain**, and the reason
in one or two sentences.
An empty shortlist is a verdict of `none` without the second question.
### 2. What each verdict does
| Verdict | Done, through the tracker's own `note`, `link` and `move` |
|---|---|
| `duplicate`, certain, naming an offered issue | the new issue is linked `duplicates` to the older one; the older one gains a note holding the new one's title and report, and who found it and why; the new one gains the verdict as a note; the new one moves to `wontfix` with the reason "duplicate of N" |
| `duplicate` not certain, or `related` | the new issue is linked `relates-to` the older one; both gain a note saying so and why; nothing moves |
| `none` | the new issue gains a note: checked against so many issues, none the same |
The older issue is never moved, so the check never closes both sides. A verdict naming an issue that was not
offered is a failed judgement (§4), never acted on. The tracker's own code for the verbs does each step, so each
refuses what the verb refuses and says its event; the store is never written around them. Each note and link
records its author as `duplicate-check`, the way an import records `import` (ADR 0275 §5).
**An issue's report is data in the question, and the session has no tools.** A report written to steer the
verdict can at most close the issue that carries it, or link it: the verdict cannot move an older issue, touch a
resolved one or name an issue it was not offered.
### 3. Once per issue, whatever is delivered twice
The verdict note on the new issue is written **first**, and its first line says the verdict in a fixed form
("Duplicate check: duplicate of 412", "related to 412", "none of 85 issues is the same question"). An event
delivered again, or the backlog pass reaching an issue the event already checked, finds that note and does not
judge again: it does only the steps of that verdict not yet done (a link it holds, a note the older issue
holds, a status already `wontfix` are each done). An issue that is no longer at `open` when its event arrives is
not checked.
### 4. A judgement that fails is loud
A judgement fails when the agent module does not answer, answers late or refuses, or answers outside the shape
asked for, or names an issue it was not offered. Then the new issue gains one note saying the duplicate check
could not be made and why — once, not at every delivery — and the handler does not take the event. The bus
offers it again after five seconds; after the fifth time the controller raises `max-deliveries` on the tracker's
consumer, an open condition, and keeps the event for a person to deliver again. A tracker that cannot judge is
therefore never silent and never closes anything.
### 5. The judgement tool
`claude-code` gains `claude_code_judge`: a question in plain words and the JSON schema of the answer, optionally
a model alias. It runs `claude` in print mode, as the node's agent account, in an empty directory, with built-in
tools off, in safe mode and keeping no session, under the licence the node is bound to; and answers the
structured answer, the model and how long it took. It answers within 25 seconds or not at all (the process is
ended), so its caller hears a refusal rather than the bus's timeout. One judgement runs at a time on a node.
It refuses a question larger than 64 KiB, a schema that is not an object, and a node holding no licence, each in
words. The question is never logged or said in an event; the answer goes only to its caller.
### 6. The backlog: `groom`, a dry run first
The tracker's seat gains the verb `groom`, for the issues that were open before this check existed. It runs in
the background, because a judgement per issue takes longer than one call may:
- **Without `confirm`** it starts a dry run: every issue not resolved, oldest first, judged as in §1 against the
older ones not resolved and not already found a duplicate in this pass. It writes no note, link or move. Its
findings — each duplicate and related pair with the reason, and each judgement that failed — are kept as one
**pass** in the module's new state `grooming`, and `groom` with the pass's id answers its progress and
findings.
- **With `confirm` and a pass's id** it applies exactly that pass's duplicate and related findings, not a fresh
judgement, each checked again against the issues as they stand: a finding whose older issue has since been
resolved, or whose newer one has moved on, is skipped and said. It adds no "checked, none" note to the
backlog's issues.
One pass runs at a time. A pass's findings are kept until the next pass replaces them.
### 7. Settings
`duplicate-check` (`on` by default; `off` stops the check on new issues, said in the log) and `judge-model`
(`sonnet` by default; an alias the judgement tool accepts). Both are the module's ordinary settings.
## Consequences
- An issue opened twice is closed as a duplicate within the judgement's time of being opened, with the reason
on both issues and the duplicate's report kept on the older one; agents keep working on one number.
- Every open spends two short judgements of the control-node's licence. At the dozen or so issues a day the
mesh opens, that is noise beside one agent session.
- The duplicate check depends on the agent module and its licence on the tracker's node. When the licence is
gone, every new issue's event ends in `max-deliveries`: loud, and nothing is closed.
- Research 042's word-sharing candidate list for the duplicates pass is replaced by this judgement; to-be 53
slice 3 says so.
## How it is checked
| Rule | Checked by |
|---|---|
| A clear duplicate: linked, its report kept on the older one, the newer moved to wontfix with "duplicate of N" | the tracker's test with the judgement stubbed: the four steps, the verdict note first, each note naming `duplicate-check` |
| Related, or a duplicate not certain: linked `relates-to`, noted on both, nothing moved | the tracker's test, both cases |
| None: one note, nothing else | the tracker's test |
| A resolved or wontfix issue is never offered or changed; the older issue never moves | the tracker's test: a resolved and a wontfix issue in the backlog are absent from both questions; a verdict naming one, or naming an issue not offered, changes nothing and fails |
| Once per issue | the tracker's test: the same event twice judges once; an event after a partial apply finishes only the missing steps |
| A failed judgement is loud | the tracker's test: the handler returns an error, one note is written however often the event comes |
| The dry run changes nothing; confirm applies the pass's findings, re-checked | the tracker's test: issues byte-for-byte unchanged after a dry run; a finding whose older issue was resolved in between is skipped |
| The judgement session can do nothing but answer | the agent module's test of the command line: tools off, safe mode, no session kept, as the agent account, a 25-second bound; a question over 64 KiB and a node without a licence refused |
| Live | an agent opens an issue that repeats one not resolved; within a few minutes `get` shows it `wontfix`, "duplicate of N", linked `duplicates`, and N holds its report as a note; a second, unrelated issue gains only the note that it was checked |
## References
- [ADR 0275](0275-the-mesh-numbers-and-tracks-its-issues-in-a-module-of-its-own-and-hq-keeps-only-the-narrative-of-a-design-or-governance-issue.md) — the tracker this record extends
- [Research 042](../01-RESEARCH/042-the-mesh-keeps-its-own-issues/01-findings.md) §3 — the duplicates pass
- [ADR 0206](0206-a-node-reports-the-anthropic-grant-it-holds-and-the-licence-manager-adopts-a-licence-by-refreshing-it.md) and
[ADR 0266](0266-on-the-control-node-agents-run-under-an-account-of-their-own-that-cannot-become-root-without-a-person.md) —
whose licence, and which account, a judgement runs under
- [To-be 53](../03-DESIGN/01-to-be/53-the-issue-tracker.md) — the design this record amends
+37 -4
View File
@@ -1,10 +1,11 @@
---
layer: to-be
status: in-progress
code: [mesh-catalog modules/mesh-issues, mesh-sdk]
code: [mesh-catalog modules/mesh-issues, mesh-catalog modules/claude-code, mesh-sdk]
updated: 2026-10-10
decisions:
- 02-DECISIONS/0275-the-mesh-numbers-and-tracks-its-issues-in-a-module-of-its-own-and-hq-keeps-only-the-narrative-of-a-design-or-governance-issue.md
- 02-DECISIONS/0278-the-issue-tracker-checks-every-new-issue-for-a-duplicate-through-a-judgement-the-agent-makes-without-tools.md
---
# 53 — The issue tracker
@@ -35,10 +36,15 @@ each with what the operator sees when it is done.
├ 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,
├ verbs open, get, list, move, note, link, import, groom,
│ served by the control-node's tool runner
└ events opened, moved, noted, linked
├ events opened, moved, noted, linked, imported
└ consumes its own `opened`: the duplicate check (§ 9)
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
@@ -63,6 +69,7 @@ agent finds them with `mesh_search` ("open an issue") and calls them as `issue-t
| `move` | a number, a status, and what that status needs | 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 |
| `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
@@ -157,6 +164,7 @@ in one call, from any session, and the responder on a channel answers from it on
| 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 |
| 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](../../02-DECISIONS/0278-the-issue-tracker-checks-every-new-issue-for-a-duplicate-through-a-judgement-the-agent-makes-without-tools.md)'s "How it is checked" |
| 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
@@ -165,12 +173,37 @@ in one call, from any session, and the responder on a channel answers from it on
|---|---|---|
| 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 | 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 2026-10-10; the status keys stay in the reports as a dated snapshot, and one pull request changes playbook 03 and `cycle.py` and adds dated notes to ADR 0019, 0080 and 0275); then the four consolidation passes of research 042 (fixed, duplicates, owners, grouping), 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 |
| 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 2026-10-10; the status keys stay in the reports as a dated snapshot, and one pull request changes playbook 03 and `cycle.py` and adds dated notes to ADR 0019, 0080 and 0275); 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 the controller shows the link; 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 shows 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 |
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](../../02-DECISIONS/0278-the-issue-tracker-checks-every-new-issue-for-a-duplicate-through-a-judgement-the-agent-makes-without-tools.md)).
- **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` or `located`; 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`).
## What it leaves out
Priorities, claims, notifications, full-text or semantic search, the forge's own issue lists, and an index of any kind: