instar 1.3.1004 → 1.3.1006

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,235 @@
1
+ # Upgrade Guide — vNEXT
2
+
3
+ <!-- assembled-by: assemble-next-md -->
4
+ <!-- bump: patch -->
5
+
6
+ ## What Changed
7
+
8
+ `GET /channels` covers WhatsApp and iMessage. Both adapters existed but had no registry row — a gap
9
+ recorded explicitly when the direct user channels shipped, and a hole in the registry's own
10
+ "absence is impossible" property, since a channel with no row cannot report that it is missing.
11
+
12
+ WhatsApp is read from `getStatus().state`, a real state machine rather than a boolean, so
13
+ `qr-pending` maps to `reachable-no-credential` — the link is alive and waiting for a human to scan a
14
+ pairing code, and a restart will not help. `connecting`/`reconnecting` report `unknown` with the phase
15
+ named rather than being guessed; `disconnected`/`closed` are `broken`; an unrecognised state is
16
+ `unknown`, never `working`.
17
+
18
+ iMessage is read from `getConnectionInfo().state`, deliberately NOT the sibling `connectedAt` — that
19
+ field is computed as `started ? new Date().toISOString() : undefined`, so it reports the moment you
20
+ asked rather than the moment it connected. A source ratchet fails if anyone wires it in.
21
+
22
+ `IMessageAdapter.getConnectionInfo().connectedAt` was computed inline as
23
+ `started ? new Date().toISOString() : undefined`, so it reported the moment of the READ rather than
24
+ the moment of connection. Three reads produced three different "connection times", all wrong.
25
+
26
+ The instant is now captured once, immediately after `started` flips true in `start()`, and cleared in
27
+ `stop()`. An adapter that never connected reports no time.
28
+
29
+ Adds `scripts/recheck-parked-tests.mjs` — the missing EXIT PATH for `vitest.push.config.ts`'s
30
+ `FLAKY_TESTS`. Entry to that list costs nothing (the file is classified as ordinary config, so
31
+ removing a guard from CI needs no artifact), and nothing ever re-checked whether a parked test could
32
+ return. Entry free, exit impossible, ungated surface monotonically growing.
33
+
34
+ The script reports which parked entries now pass deterministically and which point at files that no
35
+ longer exist. It never re-arms and never edits config.
36
+
37
+ Skipping the host-wide test-runner bound via `INSTAR_HOST_TEST_SEMAPHORE=off` printed a deterministic
38
+ warning — identical on every run. The shared detector that counts sustained self-disables was consumed
39
+ only by `dev:preflight`, hours downstream.
40
+
41
+ Measured on 2026-07-27: an agent self-disabled the bound 37 times in about three hours and read the
42
+ same warning 37 times as routine output. The detector's threshold is 3.
43
+
44
+ The skip warning now also reports the current count, that this is no longer an exception, and that
45
+ `dev:preflight` fails on the signature. It reuses the shared detector, so the number in the warning
46
+ and the number that fails preflight cannot drift.
47
+
48
+ `scripts/lib/classify-tier.mjs` no longer treats a `.config` FILENAME reference as evidence of a
49
+ config surface. The `\.config\b` alternative in `CONFIG_SURFACE_HINT` matched any text containing
50
+ `.config`, including filenames — so a script that merely READ `vitest.push.config.ts`, combined with
51
+ an ordinary object literal, fired "new capability: new config key added" and raised that change's risk
52
+ floor to 2.
53
+
54
+ ## What to Tell Your User
55
+
56
+ Your agent can now tell you whether WhatsApp and iMessage are actually working, alongside Telegram and
57
+ Slack.
58
+
59
+ The useful part is what it says when WhatsApp is waiting for you to scan a pairing code. That is not
60
+ broken — the connection is fine, it just needs you for a moment — and restarting things would not fix
61
+ it. It now says exactly that, instead of lumping it in with a dropped connection.
62
+
63
+ It is also careful about what it does not know. If a link is mid-connection it says so rather than
64
+ guessing, and a working link means the connection was up when it checked, not a promise that a message
65
+ to one particular person will arrive.
66
+
67
+ If your agent uses iMessage, the "connected since" time it reported was never real — it was recomputed
68
+ as the current time every time anything asked, so it always said "just now".
69
+
70
+ That is a small thing that could have caused a confident wrong answer, because a precise-looking
71
+ timestamp invites questions like how long has this been up or did it drop recently. Those would all
72
+ have been answered with a number that meant nothing.
73
+
74
+ It now records the moment it actually connects, and reports nothing at all when it is not connected —
75
+ which is the honest answer rather than a placeholder.
76
+
77
+ None — internal change (no user-facing surface).
78
+
79
+ None — internal change (no user-facing surface).
80
+
81
+ None — internal change (no user-facing surface).
82
+
83
+ ## Summary of New Capabilities
84
+
85
+ No new endpoint, command, or config key. `GET /channels` gains `user-whatsapp` and `user-imessage`
86
+ rows with live-state verdicts.
87
+
88
+ No new endpoint, command, or config key. The iMessage connection-info object now reports a real
89
+ connect instant instead of the current time.
90
+
91
+ None — internal change (no user-facing surface).
92
+
93
+ None — internal change (no user-facing surface).
94
+
95
+ None — internal change (no user-facing surface).
96
+
97
+ ## Evidence
98
+
99
+ Falsified by flattening `qr-pending` to `broken` in production:
100
+
101
+ ```
102
+ × THE STATE A BOOLEAN LOSES: qr-pending is reachable-no-credential, not broken
103
+ → expected 'broken' to be 'reachable-no-credential'
104
+ Tests 1 failed | 15 passed (16)
105
+ ```
106
+
107
+ Restored byte-identical. Green across every suite touching the change:
108
+ `Test Files 5 passed (5) · Tests 59 passed (59)` (`user-channel-wa-imessage`,
109
+ `user-channel-liveness`, `channel-registry`, `channel-registry-claims`, `channels-route`);
110
+ `tsc --noEmit` exit 0. The integration test pinning the exact channel set and the audience partition
111
+ was updated in this change rather than left for CI to catch.
112
+
113
+ Falsified by restoring the inline expression:
114
+
115
+ ```
116
+ × THE FIX: getConnectionInfo no longer manufactures a timestamp on read
117
+ → expected 'getConnectionInfo(): ConnectionInfo {…' not to match /new Date\(\)/
118
+ Tests 1 failed | 3 passed (4)
119
+ ```
120
+
121
+ Restored byte-identical. Green across every iMessage suite:
122
+ `Test Files 4 passed (4) · Tests 64 passed (64)`; `tsc --noEmit` exit 0. No production consumer reads
123
+ the field — verified by grep; the only references are comments explaining why the channel-registry row
124
+ deliberately does not use it.
125
+
126
+ Measured on current main, three consecutive runs each: `notification-spam-prevention.test.ts`
127
+ 15/15/15, `message-formatter.test.ts` 17/17/17, `ReflectionConsolidator.test.ts` 16/16/16 — all parked
128
+ as broken or non-deterministic, all repaired at some point and never re-armed. Note the inverse of the
129
+ 2026-06-05 note in that same file: those tests rotted while parked; these were fixed while parked.
130
+ Nobody noticed either way. Plus one dangling entry: `tests/unit/slack-stall-active-gate.test.ts` is
131
+ excluded and does not exist.
132
+
133
+ Falsified by restoring the naive parser:
134
+
135
+ ```
136
+ × THE PARSER: prose in the array does not become an entry → s own
137
+ × finds the dangling entry ... → expected 42 to be less than 5
138
+ × REGRESSION: an apostrophe in a comment cannot corrupt the parse
139
+ Tests 3 failed | 2 passed (5)
140
+ ```
141
+
142
+ Restored byte-identical; 5 passed.
143
+
144
+ Live, against the real ledger:
145
+
146
+ ```
147
+ WARN: SKIPPING the host-wide test-runner bound (reason: off) — posture: off. …
148
+ WARN: this is self-disable #39 on this host within the detector's window (threshold 3).
149
+ WARN: that is no longer an exception — it is the pattern the detector exists to find.
150
+ WARN: `instar dev:preflight` FAILS on this signature …
151
+ ```
152
+
153
+ Falsified by reverting the mapping to the originally-guessed field name:
154
+
155
+ ```
156
+ × THE FIX: fires on the detector's real output shape → expected null not to be null
157
+ × fires exactly AT the threshold
158
+ Tests 2 failed | 3 passed (5)
159
+ ```
160
+
161
+ Restored byte-identical. Green: `Test Files 3 passed (3) · Tests 121 passed (121)`; `tsc --noEmit`
162
+ exit 0.
163
+
164
+ Falsified by restoring the alternative:
165
+
166
+ ```
167
+ × does NOT fire on a mere .config FILENAME reference — the reproduction of the real case
168
+ → reading a .config filename is not adding a config key: expected 2 to be 1
169
+ Tests 1 failed | 48 passed (49)
170
+ ```
171
+
172
+ Restored byte-identical; 49 passed, including all 47 pre-existing classifier tests unchanged. A second
173
+ new test iterates every remaining anchor (`ConfigDefaults`, `defaultConfig`, `InstarConfig`,
174
+ `configSchema`) and asserts the floor still rises — narrowing a check must not blind it.
175
+
176
+ ## Also in this change: the Telegram row now names its subject
177
+
178
+ Found by checking the shipped feature on a running agent rather than only in tests. `/capabilities`
179
+ reported `telegram: { configured: true, adapter: true, bidirectional: true }` while `/channels`
180
+ reported `user-telegram: unknown — the Telegram poll loop is not running` — the config surface and the
181
+ live surface disagreeing, which is exactly what this registry exists to expose.
182
+
183
+ But the row was measuring the SERVER process's adapter and presenting it as the channel's state. On a
184
+ lifeline deployment inbound never goes through that adapter: a separate lifeline process polls and
185
+ forwards, and the server logs "Telegram relay wired (via lifeline callback forwarding)". Inbound was
186
+ healthy; a stopped server poller is normal there.
187
+
188
+ That is the same scope error the `threadline-relay` row was fixed for in #1667 — a true statement
189
+ whose subject is unstated, so the reader concludes something about a path that was never measured.
190
+ The verdict stays `unknown` (what it can see is genuinely undetermined); it now names the server
191
+ adapter and says plainly that this alone does not mean messages are not arriving.
192
+
193
+ ## Known limits
194
+
195
+ `working` means the link was up when probed — not that a message to a particular chat or contact
196
+ would land. Neither row probes a round-trip, because a status read must not send. iMessage is bound to
197
+ the host running its backend, recorded as a real cost of choosing it.
198
+
199
+ `lastError` and `reconnectAttempts` in the same object are still hardcoded `undefined` / `0`. They are
200
+ the same class of defect and are deliberately not fixed here — inventing values for them would repeat
201
+ the error this corrects. The field also says nothing about connection quality.
202
+
203
+ It never re-arms — a local deterministic-pass does not establish a CI pass, and a third of the list is
204
+ parked for a native-binding reason whose stated scope is "on this machine". Glob entries are reported
205
+ rather than resolved. Only a rotating slice runs per invocation, so a single run does not survey the
206
+ whole list; the output says how many it checked.
207
+
208
+ Louder text only — it cannot refuse a run, and making the kill switch refuse was considered and
209
+ rejected (a kill switch that stops working after three uses is not a kill switch). It fires only for
210
+ `reason: 'off'`; the spoofed-CI skip is not escalated, since that path has a different population and
211
+ no measurement behind it.
212
+
213
+ If a genuine config-key addition mentions only a `*.config.ts` filename and none of the remaining
214
+ anchors, its floor will no longer rise. The remaining anchors cover this repo's actual config
215
+ surfaces, and the both-sides test guards against a future narrowing that blinds the check — but this
216
+ is a heuristic over diff text and always was.
217
+
218
+ ## Correction
219
+
220
+ The first (naive) parser reported 92 parked entries; the true count is 91. That wrong number was
221
+ published in the release note of the earlier re-arm change. Recorded rather than silently fixed.
222
+
223
+ ## Built-in caution
224
+
225
+ Two implementations of this produced no output and no error — one un-awaited promise, one guessed data
226
+ shape swallowed by the best-effort catch. Both were found by running it and reading the output. The
227
+ fallible mapping is now a pure exported function pinned against the detector's real shape, so a future
228
+ shape change fails a test rather than vanishing into the catch.
229
+
230
+ ## Why it mattered
231
+
232
+ The floor is what makes a tier declaration meaningful: declaring under it is permitted but audited as
233
+ a deliberate override. A floor that fires on a filename teaches authors that below-floor declarations
234
+ are routine, which is how a real floor gets argued past later. A noisy guard degrades the guard it
235
+ belongs to.
@@ -0,0 +1,69 @@
1
+ # Upgrade Guide — vNEXT
2
+
3
+ <!-- assembled-by: assemble-next-md -->
4
+ <!-- bump: patch -->
5
+
6
+ ## What Changed
7
+
8
+ Fixed an addressing bug that could prevent two healthy agents from ever exchanging a
9
+ Threadline message.
10
+
11
+ `threadline_discover` returns each agent's fingerprint truncated to 8 hex characters, and
12
+ `threadline_send`'s own parameter documentation advertises that short form
13
+ (`"fd9268c2..."`). But `ThreadlineClient.resolveAgent` had no branch for a bare prefix: it
14
+ fell through to name matching, found no agent *named* `7970149e`, and returned null. The
15
+ send then failed with `Agent not found: "7970149e"`. The documented discover-then-send
16
+ workflow could not complete.
17
+
18
+ `resolveAgent` now accepts a bare fingerprint prefix, and **requires a unique match**. This
19
+ matters: `resolveAgent` decides which peer receives a message, and discovery can legitimately
20
+ return two rows for the same agent (a live registration and a stale one). Resolving a prefix
21
+ to "whichever matched first" would have silently delivered to the wrong agent while reporting
22
+ success — worse than not delivering at all. When several agents share a prefix and exactly
23
+ one is online, the live one wins (the same allowance name resolution already makes for
24
+ live-vs-stale twins). Otherwise the send is refused with every candidate and its full
25
+ fingerprint listed, so the caller can say precisely which they meant.
26
+
27
+ Non-hex input, and any prefix matching no known fingerprint, fall through untouched — name
28
+ resolution, including for agents with hex-like names, behaves exactly as before.
29
+
30
+ ## What to Tell Your User
31
+
32
+ If you have ever tried to connect two of your agents over Threadline and been told the other
33
+ agent could not be found — while both were plainly running and correctly configured — this is
34
+ very likely why, and it is fixed.
35
+
36
+ The short agent ID your agent sees when it looks up its peers is now an address it can
37
+ actually send to. Nothing changes about how you ask for it; "message my other agent" simply
38
+ works where it may previously have failed with a confusing not-found error.
39
+
40
+ One deliberate behaviour worth knowing: if a short ID is ambiguous because two agents share
41
+ those opening characters, your agent will now stop and list them rather than pick one. That
42
+ is intentional. A message delivered to the wrong agent looks like success and is much harder
43
+ to notice than a message that refuses to send.
44
+
45
+ ## Summary of New Capabilities
46
+
47
+ - Agent-to-agent messaging accepts the short (8-character) agent fingerprint that agent
48
+ discovery returns, closing a gap where the discover-then-send workflow could not complete.
49
+ - Ambiguous short IDs are reported with the full candidate list instead of resolving to an
50
+ arbitrary agent, so a message can never be silently delivered to the wrong peer.
51
+ - A stale duplicate registration no longer makes a working address ambiguous when exactly one
52
+ matching agent is online.
53
+
54
+ ## Evidence
55
+
56
+ The tests were run against unmodified source *before* the fix was applied, to prove they
57
+ exercise the bug rather than merely accompany it:
58
+
59
+ - Fix reverted: **5 failed / 21 passed** — the 5 failures are precisely the new behaviours
60
+ (8-char prefix resolves, case-insensitivity, ambiguity throws, candidates named in the
61
+ error, live-row preference). The 21 that pass are the deliberate regression guards.
62
+ - Fix applied: **26 passed**.
63
+ - Regression surface: `tests/unit/threadline/` — **65 files, 1581 tests, all passing**.
64
+ - `tsc --noEmit` clean.
65
+
66
+ Observed live on 2026-07-27 between two running, relay-connected agents on the same machine.
67
+ Both ends failed symmetrically — each reported the other as "not found" while holding the
68
+ other's address — which is what identified the seam between the two tools, rather than either
69
+ agent, as the defect.
@@ -0,0 +1,62 @@
1
+ # Side-effects review — iMessage connectedAt is recorded, not synthesised
2
+
3
+ **Change:** `IMessageAdapter.getConnectionInfo().connectedAt` returned
4
+ `started ? new Date().toISOString() : undefined` — the time of the READ, never the time of the
5
+ connection. It now returns a value captured once at connect and cleared on stop.
6
+
7
+ **Decision point touched?** No. Status reporting only; nothing gates on this field.
8
+
9
+ ---
10
+
11
+ ## 1. Over-block
12
+
13
+ None — nothing is rejected or blocked. The only behavioural change is that a value which was always
14
+ populated-while-running can now be `undefined` in one new case: an adapter whose `started` flag is
15
+ true but which has not passed through `start()` in this process (not reachable through the public
16
+ API, since `start()` is what sets `started`).
17
+
18
+ ## 2. Under-block
19
+
20
+ The field is now accurate about connect time but still says nothing about connection QUALITY — a
21
+ long-lived connection that has been failing to deliver reports the same timestamp as a healthy one.
22
+ Unchanged by this and not claimed.
23
+
24
+ Also unchanged: `lastError` and `reconnectAttempts` in the same object are still hardcoded
25
+ `undefined` / `0`. They are the same class of defect — fields that look informative and carry no
26
+ information — and are deliberately NOT fixed here, because inventing values for them would repeat
27
+ the error this change corrects. Named rather than silently left.
28
+
29
+ ## 3. Level-of-abstraction fit
30
+
31
+ Correct: the adapter is the only thing that knows when it connected, so it is the only place the
32
+ instant can be recorded honestly. Nothing downstream can reconstruct it.
33
+
34
+ ## 4. Signal vs authority compliance
35
+
36
+ Pure signal, unchanged in authority. It makes an existing signal truthful rather than adding one.
37
+
38
+ ## 5. Interactions
39
+
40
+ No production consumer reads `connectedAt` — verified by grep across `src/`; the only references are
41
+ comments in `userChannels.ts` and `routes.ts` explaining why the channel-registry row deliberately
42
+ does NOT use it. Those comments remain accurate: the row is built on `state`, which is the right
43
+ liveness signal regardless of this fix.
44
+
45
+ `ConnectionInfo.connectedAt` is already optional in the type, so returning `undefined` when not
46
+ connected requires no type change.
47
+
48
+ ## 6. External surfaces
49
+
50
+ None directly. The value appears in whatever surfaces render `getConnectionInfo()`. Anything that was
51
+ displaying it was displaying fiction; it will now show a real instant or nothing.
52
+
53
+ ## 7. Multi-machine posture
54
+
55
+ Machine-local by design and inherently so: the adapter and its backend run on one host, and a
56
+ connection instant is a fact about THIS process. There is no state to replicate and a cross-machine
57
+ view would be meaningless — another machine's connect time says nothing about this one's.
58
+
59
+ ## 8. Rollback cost
60
+
61
+ Trivial: restore the inline expression and drop the field. No persisted state, no schema, no
62
+ migration. Rolling back restores a fabricated timestamp, so it should carry a reason.
@@ -0,0 +1,71 @@
1
+ # Side-effects review — parked-test re-check (the exit path for FLAKY_TESTS)
2
+
3
+ **Change:** adds `scripts/recheck-parked-tests.mjs`, which reports which `FLAKY_TESTS` entries now
4
+ pass deterministically and which point at files that no longer exist. Read-only; never edits config.
5
+
6
+ **Decision point touched?** No gate is added or changed. It informs a human judgement (should this
7
+ test be re-armed?) that currently has no prompt at all.
8
+
9
+ ---
10
+
11
+ ## 1. Over-block
12
+
13
+ None possible: the script always exits 0 and modifies nothing. Making it a gate was considered and
14
+ rejected — a red build for a test somebody parked deliberately would recreate the original problem
15
+ from the other side.
16
+
17
+ ## 2. Under-block
18
+
19
+ Deliberate and central: it does NOT re-arm. A local deterministic-pass does not establish a CI pass,
20
+ and roughly a third of the list is parked for a native-binding failure whose stated scope is literally
21
+ "on this machine" while CI is a different environment. Auto-re-arming on local green would be the
22
+ confident-wrong-answer this area keeps producing.
23
+
24
+ It also cannot evaluate glob entries (`tests/unit/threadline/**`) — those are reported separately
25
+ rather than resolved, because a glob is not a file and pretending otherwise would put fake entries in
26
+ the missing list.
27
+
28
+ Bounded by default: only a rotating slice is executed per run. So a single run does NOT survey the
29
+ whole list, and the output says how many were checked. Under-coverage that announces itself.
30
+
31
+ ## 3. Level-of-abstraction fit
32
+
33
+ Correct. The gap is that the exclusion list has an entry path and no exit path; this is the exit
34
+ path's reporting half. The complementary half — requiring evidence on ADDITIONS — is tracked
35
+ separately (ACT-1341) and is a gate change, not a report.
36
+
37
+ ## 4. Signal vs authority compliance
38
+
39
+ Pure signal, and deliberately so. Per `docs/signal-vs-authority.md` it produces information for an
40
+ authority (a human) that already exists; it holds none itself.
41
+
42
+ ## 5. Interactions
43
+
44
+ Reads `vitest.push.config.ts` by literal parse rather than importing it — importing would execute the
45
+ config, and this must remain safe to run against a config that does not load. It executes vitest as a
46
+ subprocess for the sampled files; those runs go through the normal host test-runner bound like any
47
+ other.
48
+
49
+ No production code touched. Nothing imports the script.
50
+
51
+ ## 6. External surfaces
52
+
53
+ None. A developer-facing script; no endpoint, no config key, no agent-visible behaviour.
54
+
55
+ ## 7. Multi-machine posture
56
+
57
+ Not applicable in the replication sense, and that is itself the point: the answer it produces is
58
+ about THIS machine's environment, and the script says so in its own output note. A cross-machine
59
+ merged view would be actively misleading, since the whole question is whether a local environment
60
+ reason still holds.
61
+
62
+ ## 8. Rollback cost
63
+
64
+ Trivial — delete the script and its test. No state, no schema, no consumers.
65
+
66
+ ## A correction this change surfaced
67
+
68
+ The first implementation's naive parser produced a count of 92 parked entries. The true count is 91.
69
+ That wrong number was published in the release note of the earlier re-arm change (`#1668`). It is
70
+ recorded here rather than silently corrected, because a plausible-but-wrong number that reached an
71
+ artifact is exactly the failure class this repository keeps finding.
@@ -0,0 +1,78 @@
1
+ # Side-effects review — escalate the self-disable warning at the point of use
2
+
3
+ **Change:** when the test-runner bound is skipped via `INSTAR_HOST_TEST_SEMAPHORE=off`, the existing
4
+ deterministic warning is followed by an escalation naming how many self-disables the shared detector
5
+ already sees in its window, and that `dev:preflight` fails on the signature.
6
+
7
+ **Decision point touched?** No gate, no blocking, no new authority. It adds output to an existing
8
+ logging path. The bound's behaviour is unchanged in every posture.
9
+
10
+ ---
11
+
12
+ ## 1. Over-block
13
+
14
+ None possible — it prints lines. It cannot refuse, delay, or fail a run. Explicitly considered and
15
+ rejected: making the chokepoint REFUSE on a sustained pattern. The kill switch exists for genuine
16
+ emergencies, and a kill switch that stops working after three uses is not a kill switch. Escalating
17
+ the signal is the correct lever; removing the escape hatch is not.
18
+
19
+ ## 2. Under-block
20
+
21
+ It is only louder text. A reader determined to ignore it still can, and someone scripting `off`
22
+ permanently will see it scroll past forever. This narrows the gap between the moment of the decision
23
+ and the moment of the consequence; it does not close it.
24
+
25
+ It also fires only for `reason === 'off'`. The other loud skip (spoofed CI on a dev host) is graded
26
+ "like off" by the spec and is NOT escalated here — deliberate scope, since that path has a different
27
+ population and I have no measurement for it.
28
+
29
+ ## 3. Level-of-abstraction fit
30
+
31
+ Correct: the chokepoint is where the decision is observable at the moment it is made. The detector
32
+ already existed but was consumed only by `dev:preflight`, hours downstream. This wires the same
33
+ detector to the earliest surface, and reuses it rather than re-counting so threshold and window
34
+ cannot drift between the two consumers.
35
+
36
+ ## 4. Signal vs authority compliance
37
+
38
+ Pure signal, and deliberately kept so. The escalation informs; the human or agent decides; the
39
+ existing preflight check retains whatever authority it had.
40
+
41
+ ## 5. Interactions
42
+
43
+ `skipLine` becomes async and is awaited at all seven call sites in `globalSetup`. That is load-bearing
44
+ rather than cosmetic: the first implementation used fire-and-forget and the escalation never printed,
45
+ because globalSetup returns before the promise settles.
46
+
47
+ The lookup dynamically imports `scripts/lib/test-runner-selfdisable-patterns.mjs` from a test setup
48
+ file. Wrapped so a resolution failure degrades to the plain line.
49
+
50
+ Adds one ledger read (bounded to the detector's own `DEFAULT_MAX_EVENTS`) per SKIPPED run only — never
51
+ on the acquire path.
52
+
53
+ ## 6. External surfaces
54
+
55
+ None. Test-run stderr only; no endpoint, no config key, no user-facing behaviour.
56
+
57
+ ## 7. Multi-machine posture
58
+
59
+ Machine-local by design and inherently so: the ledger records what THIS host did, and the bound is a
60
+ per-host concurrency limit. A cross-machine count would be meaningless — another machine's
61
+ self-disables say nothing about this one's.
62
+
63
+ ## 8. Rollback cost
64
+
65
+ Trivial: drop the escalation block and revert `skipLine` to sync. No state, no schema, no consumers.
66
+
67
+ ## What this change's own construction demonstrated
68
+
69
+ Two implementations produced NO output and NO error. The first attached the lookup to an un-awaited
70
+ promise; the second guessed the detector's shape and had the mistake swallowed by the best-effort
71
+ catch that exists so a louder warning cannot break a run. Both were found by running it and reading
72
+ the output, not by anything failing.
73
+
74
+ That is the same failure class as the change's subject, twice, inside the change. The response was
75
+ structural rather than resolving to be careful: the fallible mapping is now a pure exported function
76
+ pinned against the detector's real output shape, so a future shape change fails a test instead of
77
+ disappearing into the catch. The catch stays — it is correct that this cannot break a run — but it no
78
+ longer has anything unverified to hide.
@@ -0,0 +1,136 @@
1
+ # Side-effects review — bare fingerprint-prefix addressing in `resolveAgent`
2
+
3
+ **Change:** `ThreadlineClient.resolveAgent` gains a bare fingerprint-prefix branch
4
+ (`findAgentByFingerprintPrefix`), so the 8-char fingerprint `threadline_discover`
5
+ emits is an address `threadline_send` will actually accept.
6
+
7
+ **Discovered:** live, 2026-07-27. Echo → instar-codey. Both agents healthy, both
8
+ relay-connected, correct chat, correct topic. Every send answered
9
+ `Agent not found: "7970149e"`. Codey's own reply to Echo failed with the mirror-image
10
+ error `Agent not found: "63b1dbb2"`. Neither agent was misconfigured — the address one
11
+ tool handed out was an address the other tool refused.
12
+
13
+ ## 1. Over-block — what legitimate input does this reject that it shouldn't?
14
+
15
+ None. The new branch only ever *adds* resolutions; it returns `undefined` for any input
16
+ that is not a bare hex string of 4–64 chars, and for any hex string that matches no
17
+ known fingerprint. In both cases control falls through to the pre-existing name path
18
+ unchanged.
19
+
20
+ The one input class that now behaves differently in a *rejecting* direction is an
21
+ **ambiguous** prefix — two known agents sharing it. That previously resolved to `null`
22
+ ("not found"); it now throws a named-candidates error. This is strictly more informative
23
+ than the old outcome, and the alternative (silently picking) is the failure this review
24
+ most wants to prevent. See §4.
25
+
26
+ ## 2. Under-block — what does this still miss?
27
+
28
+ - **Discover still emits 8 chars while its neighbouring comment says 32.**
29
+ `ThreadlineMCPServer.ts` sets `entry.fingerprint = a.publicKey.substring(0, 8)` at two
30
+ sites (lines 510, 870 on main) beside a comment reading "fingerprint = first 32 hex
31
+ chars". This change makes the 8-char form *work* rather than making the two halves
32
+ agree. Deliberate: changing discover's output is a contract change to every existing
33
+ consumer, whereas widening the resolver is backward-compatible and fixes all current
34
+ callers at once. The inconsistency is recorded here rather than silently fixed.
35
+ <!-- tracked: ACT-1380 -->
36
+ - **A 4-char prefix is permitted.** With a large enough agent population, 4 hex chars
37
+ will collide. Collisions surface as the ambiguity error rather than a wrong delivery,
38
+ so the failure mode is safe, but it is a usability cliff at scale rather than a
39
+ correctness one.
40
+ - This does not address unreachability caused by anything other than addressing — a dark
41
+ relay, an unpaired peer, or a trust refusal are all untouched.
42
+
43
+ ## 3. Level-of-abstraction fit
44
+
45
+ Correct layer. `resolveAgent` is the single funnel every send path already goes through
46
+ (`routes.ts:12841` calls `relayClient.resolveAgent(targetAgent)` and 404s on null). Fixing
47
+ it here fixes the MCP tool, the HTTP route, and any future caller at once. Fixing it in
48
+ the MCP tool would have left the HTTP route broken; fixing it in discover would have
49
+ changed a published output shape without helping callers who already hold a short
50
+ fingerprint from an older message or a log line.
51
+
52
+ ## 4. Signal vs authority compliance
53
+
54
+ `resolveAgent` is an *authority* — its return value directly determines which peer
55
+ receives a message. That is precisely why the new branch **requires a unique match**.
56
+
57
+ The tempting implementation is "match the prefix, take the first hit." That would have
58
+ made my own send succeed today, because discover returned **two** `instar-codey` rows
59
+ (`092c1cac…` and `7970149e…`) and only one is live. Taking the first would have delivered
60
+ to whichever the map happened to yield first — a silent wrong-recipient delivery, which is
61
+ categorically worse than a failed send, because the sender believes it succeeded.
62
+
63
+ So the branch reuses the existing convention exactly:
64
+ - exactly one match → resolve
65
+ - several matches, exactly one online → resolve to the live one (`pickSingleOnline`, the
66
+ same live-vs-dead twin allowance `findAgentByName` already makes)
67
+ - otherwise → throw, naming every candidate and its full fingerprint
68
+
69
+ Ambiguity is surfaced to the caller as a decision, never absorbed as a guess.
70
+
71
+ ## 5. Interactions
72
+
73
+ - **Ordering.** The prefix branch runs after exact-match (1) and before name parsing (2).
74
+ It cannot shadow name resolution, because a zero-match prefix returns `undefined` and
75
+ falls through. An agent whose *name* is hex-like resolves as before — covered by an
76
+ explicit regression test.
77
+ - **`name:prefix` syntax.** Untouched. `parseAgentAddress` only engages on a colon, and a
78
+ bare prefix has none. The 4-char floor is deliberately the same one `parseAgentAddress`
79
+ already uses for its suffix, so the two addressing forms agree on what counts as a
80
+ prefix.
81
+ - **Cold cache.** The retry after `autoDiscover()` in step 4 now re-tries the prefix as
82
+ well as the name. Without this the fix would work only when the cache was already warm —
83
+ which is the very condition that is false on a first send after boot.
84
+ - **Rediscovery cooldown.** Unchanged; the prefix branch performs no discovery of its own.
85
+
86
+ ## 6. External surfaces
87
+
88
+ No wire-format, protocol, or persisted-state change. No new config, route, or dependency.
89
+ The observable difference is that an address which previously 404'd now resolves, plus one
90
+ new error message string on the ambiguous path. Nothing depends on timing or conversation
91
+ state.
92
+
93
+ ## 7. Multi-machine posture (Cross-Machine Coherence)
94
+
95
+ **Machine-local by design, and correctly so — no justification marker needed because no
96
+ durable state is introduced.** `knownAgents` is a process-local in-memory cache rebuilt
97
+ from relay discovery on every connect. Two machines running this agent each resolve
98
+ against their own live view of the relay, which is the correct behaviour: reachability is
99
+ a property of *this* process's relay connection, not a fact to replicate. Replicating a
100
+ resolution cache would actively harm — a peer that machine A can reach may be unreachable
101
+ from machine B, and a shared cache would assert otherwise.
102
+
103
+ No user-facing notice, no generated URL, no state that could strand on topic transfer.
104
+
105
+ ## 8. Rollback cost
106
+
107
+ Near zero. Revert the commit; the added method becomes unreferenced and the resolver
108
+ returns to exact-match-plus-name. No migration, no persisted state, no agent-state repair.
109
+ Any address that worked before still works after a revert — the change is purely additive
110
+ to the set of inputs that resolve.
111
+
112
+ ## Evidence
113
+
114
+ Tests were verified to fail against unmodified source before the fix was applied, so they
115
+ demonstrably exercise the bug rather than merely accompanying it:
116
+
117
+ ```
118
+ # fix reverted, tests only
119
+ Tests 5 failed | 21 passed (26)
120
+ ✗ resolves the 8-char prefix that discover emits (the reported bug) → null
121
+ ✗ is case-insensitive on the prefix → null
122
+ ✗ THROWS on an ambiguous prefix rather than delivering to the wrong agent → resolved null
123
+ ✗ names every candidate in the ambiguity error → resolved null
124
+ ✗ prefers the single LIVE row when a stale twin shares the prefix → null
125
+
126
+ # fix applied
127
+ Tests 26 passed (26)
128
+
129
+ # regression surface
130
+ tests/unit/threadline/ — 65 files, 1581 passed
131
+ tsc --noEmit — clean
132
+ ```
133
+
134
+ The 21 that pass in both runs are the deliberate regression guards (full fingerprint,
135
+ ordinary name, hex-like name, unknown prefix → null); they *should* pass either way, and
136
+ their passing on unfixed source is what makes them meaningful as guards.