Compare commits

...
@@ -0,0 +1,187 @@
# #1782: the owner sees a thread agent's claims (design r1)
Morgana, 2026-10-22, for Duckworth. Brief: docs/plans/2026-10-22_owner-thread-claims.md (Sage, for
mos-1262). The brief wasn't committed and had no queue row when this was written, so the folder is named
for the brief. It moves to queue-<row> once the row exists. Base: refactor c27207f6e (205 landed at
ce6a96ad6, then 268). No code yet.
## What the code does now
- A thread run's `role.claim` writes `thread_claims` only and returns (broker.mjs:2682 at ce6a96ad6).
- `agents` (broker.mjs:3409) reads `role_claims` only:
- human and reader sessions get `#agentState(c)` per held role;
- role and tester sessions get the raw rows.
- A held thread claim is the thread's latest `thread_claims` row with op `claim`. `#threadHolders`
(:483) and `#threadRows` (:624) already compute it that way, with `c.seq = max(seq)` per thread.
- `threads.name` is the name a user wrote. `threads.instance` is the role instance from the business
file, which the operator writes, not the user.
## The problem with the brief's shape
The brief adds thread rows to the human and reader `agents` result, after the role rows. Every consumer
of that result assumes one row per role:
| Consumer | What it reads | What a thread row would do |
|---|---|---|
| Console Agents view, webui bus.js:474 | `c.role` in a "Held roles" table, `claims.length` as its count | blank role cell, wrong count |
| Console board held table, bus.js:447 and :462 `held-count` | same | same |
| Console composer, bus.js:298 | `c.role`, filtered with `typeof r === 'string'` | dropped safely |
| CLI TUI agents view, screens.mjs:280 and :284, app.mjs:288 and :433 | `c.role` as title and column | blank title |
| CLI `mosaic agents`, format.mjs:54 | `c.role` | `undefined` in the line (brief item 4 covers this one) |
So the shape as briefed also needs the Console (webui) and the TUI changed in this row, or the live
Agents view breaks on the next re-pin. The brief's manifest names only format.mjs.
## Proposed shape: a separate owner verb
The brief allows this ("for example a separate owner verb, it goes in BUILD.md to Duckworth before
code").
**D1. New verb `thread.claims`**, human and reader sessions only. Every other session refuses with
`human-required`, the code `decisions` uses at :3426. No arguments (`keys(a, [], [])`). The result, in
thread creation order:
```js
[{ thread, instance, holder_run, harness, at }]
```
- One row per thread whose latest `thread_claims` op is `claim`.
- `at` is that claim row's time.
- It doesn't return `name`, message content or `address`. `address` matches the brief's four fields;
it can be added if Duckworth wants it next to the role rows.
- `instance` is beyond the brief's four. It names which assistant holds the thread, and the operator
writes it, not the user.
SQL, the `#threadHolders` join without the instance and owner filter:
```sql
SELECT t.id AS thread, t.instance, c.holder_run, c.harness, c.at FROM threads t
JOIN thread_claims c ON c.seq=(SELECT max(seq) FROM thread_claims WHERE thread=t.id)
WHERE t.business=? AND c.op='claim' ORDER BY t.seq
```
**D2. `agents` doesn't change** for any session. That is stronger than brief item 2 (role sessions
only), and it means the Console, TUI and composer need no change in this row.
**D3. Reader allow-list.** `thread.claims` goes in the reader list at :2370. The processing gate (:402)
already exempts human and reader sessions, so a halt doesn't hide claims. No TESTER_VERBS or USER_VERBS
entry is added (see D6).
**D4. CLI `mosaic agents`** (cli.mjs:314) calls `agents` and then `thread.claims`. `formatAgents(list,
threads = [])` prints the role lines unchanged, then for each held thread claim:
```
thread <thread> <instance> <holder_run> <harness> since <at>
```
Each line starts with `thread `, so a reader can tell the two kinds apart (brief item 1). Role output
with no thread claims is byte-equal to today, including "agents: no role is claimed". When there are no
roles but there are thread claims, the empty-roles line is still printed, then the thread lines.
**D5. The governance pin.** message-filter.test.mjs, the "claims" item: `threadsState` becomes
`call(owner, 'thread.claims')`, which must include `{ thread: U, holder_run: 'assistant-run' }`. The
comment that names this row goes away (brief item 5).
**D6. What this row leaves out**, so as not to widen beyond owner and reader sessions (mos-1202):
- tester sessions;
- FED R3 user principals, who own threads under R3, so their own claims are an R6 question;
- a Console section;
- the TUI.
## Questions for Duckworth and Sage
- **Q1.** Do you accept D1 over the briefed mixed list? If you'd rather have the mixed list, the
manifest grows to webui bus.js, screens.mjs and app.mjs, plus their tests (bus-browser,
webui bus.test and the TUI tests).
- **Q2.** Should the Console show thread claims before a user thread goes live? PRD :1083-1084 promises
visibility to the owner, and a Console user won't run `mosaic agents`. My recommendation: a follow-up
row bound like this one (it lands before any user thread goes live), so this row stays small for
10-27. The alternative is a "Thread claims" section in the Console Agents view in this row.
## Tests (under D1)
All new tests go in packages/bus/tests/agents.test.mjs unless named otherwise. Each one runs on a store
with one held role claim and one thread run.
- **T1.** Human `thread.claims` lists the held thread claim (thread, instance, holder_run, harness,
at). Reader gets the same result.
- **T2.** After release, the thread is gone. After a re-claim followed by a revoke, it's gone again.
The thread has an earlier `claim` row, so a first-op reading would still list it.
- **T3.** A role session's `thread.claims` refuses `human-required`.
- **T4.** `agents` is deepEqual to a copy taken before the thread run claimed, for the role, human
and reader sessions on the same store.
- **T5.** `JSON.stringify` of the `thread.claims` result has neither the thread's name nor the body of
a message sent in that thread.
- **T6.** packages/cli/tests/format.test.mjs: a mixed list renders the role lines exactly as today,
then `thread …` lines. The empty-roles case with threads is covered too.
- **T7.** packages/bus/tests/message-filter.test.mjs: the moved pin (D5).
## Mutants (each killed by a named test)
| Mutant | Change | Killed by |
|---|---|---|
| M1 | `thread.claims` returns `[]` | T1, T7 |
| M2 | the held check uses `min(seq)` (first op) | T2 |
| M3 | the human/reader check is dropped, so a role session gets thread rows | T3 |
| M4 | `agents` for human appends the thread rows | T4 |
| M5 | the SELECT adds `t.name` | T5 |
| M6 | formatAgents drops the thread lines | T6 |
## Manifest (expected)
- packages/bus/src/broker.mjs
- packages/bus/README.md (a paragraph for `thread.claims`, next to `agents`)
- packages/bus/tests/agents.test.mjs
- packages/bus/tests/message-filter.test.mjs
- packages/cli/src/cli.mjs
- packages/cli/src/format.mjs
- packages/cli/tests/format.test.mjs
- this BUILD.md
Verb-list pins checked at c27207f6e:
- tester-writes.test.mjs:19 pins `TESTER_VERBS` exactly. D6 adds nothing to it, so the pin holds.
- No test pins the reader list at :2370.
- `thread.messages` appears in tester-threads, threads and webui chat as calls, not as a verb set.
Before the in-review move, I'll rerun the same grep for `thread.claims` and for any new verb-set pin.
Gate (brief): Duckworth approves the manifest. On Sage's gate, the touched test files run whole, the
bus and cli suites are green, every scripts/test-*.sh is green, and M1-M6 are killed.
## r2 (duckworth-0118: D1 approved with C1-C3)
Q1: Duckworth accepts D1. Q2: Duckworth is fine with a follow-up row under the same bind; Sage rules on
it. Duckworth's note is agents/duckworth/work/row1782/DESIGN-r1.md on duckworth/reviews 13fb9abc5.
**C1. `--tester` makes one call.** callFor binds the tester when `--tester` is given (cli.mjs:192).
D6 keeps `thread.claims` off TESTER_VERBS, so a second call there would refuse tester-refused and exit
3. tester.test.mjs:128 pins exactly one spawn, `agents`. So cli.mjs calls `thread.claims` only when
`--tester` is absent. A tester's `mosaic agents` is unchanged.
- T8 (packages/cli/tests/format.test.mjs or the CLI test that drives main() with a stand-in transport;
the exact file is picked at build): `agents --tester` makes one call, `agents`; human `agents` makes
two, `agents` then `thread.claims`.
- Gate adds tester.test.mjs and other-tree.test.mjs, each run whole.
- M7: thread.claims is called on the tester path too. T8 and tester.test.mjs:128 kill it.
**C2. `--json` shape.** `mosaic agents --json` prints the `agents` array unchanged, as `inbox --json`
prints decisions only (cli.mjs:308). Only the text output adds the `thread …` lines. The machine read of
thread claims is the bus verb, and the bus README paragraph documents `thread.claims` as such. A grep at
c27207f6e found no consumer of `mosaic agents --json` in packages/*/tests or scripts. T6 pins both: the
text output is mixed, and the `--json` output deepEquals the `agents` result alone.
**C3. Where the pin moves.** Under D1 the governance pin moves to the owner's `thread.claims` call, not
to `agents`. That is brief item 5 read under D1: the brief named `agents` because it put thread rows
there.
Notes taken:
- N1: T4's before/after equality covers the tester session too.
- N2: T3 adds a tester: `thread.claims` refuses tester-refused.
- N3: FED R3 user principals must not get other users' thread claims. This row gives them none
(no USER_VERBS entry). The rule carries with 205 N1/N3 to 205b/R3.
- N4: the :403 processing-gate list is in the verb-set grep. Human and reader sessions return before
that list (:402), so `thread.claims` needs no entry there, and no test pins the list (grep at
c27207f6e). The :2370 reader list is the one list that gains the verb.
Mutants are now M1-M7. Building starts once Sage creates the row.