instar 1.3.993 → 1.3.995

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,137 @@
1
+ # Side-effects review — round completion verifies merged state
2
+
3
+ **Change:** `runRound`'s `verifyMergedItems` seam no longer defaults to a no-op that reports nothing
4
+ verified. It defaults to the real git-backed `verifyMergedItemsViaGit`, the seam carries the
5
+ three-state `MergedVerificationResult`, and a new `unverifiable` outcome records **no** round status.
6
+
7
+ **Decision point touched:** yes — two of them. (a) whether a round is complete; (b) whether to spawn
8
+ an autonomous child. Both previously ran on a verifier that could only ever say "nothing verified".
9
+
10
+ ## 1. Over-block — what legitimate inputs does this reject that it shouldn't?
11
+
12
+ The one real risk is stalling a round that should proceed. `verifyMergedItemsViaGit` reports
13
+ `unverifiable` for an item with **no `mergeCommitOid`**, which is the ordinary state of a round nobody
14
+ has worked yet. A naive "any unverifiable ⇒ don't spawn" would deadlock **every fresh round** — a far
15
+ worse defect than the one being fixed.
16
+
17
+ Handled by splitting on **evidence**: only an item that *records* a merge commit and cannot be checked
18
+ counts as genuinely uncheckable. An item with no recorded commit is not-done, and the child spawns.
19
+ Pinned by `an item with NO merge commit recorded is NOT-DONE, not unknown — the child still spawns`.
20
+
21
+ This test earned its place: the first draft applied the evidence rule at the pre-spawn check only,
22
+ and the post-exit check kept the old conflation. The test failed, and the predicate is now defined
23
+ once and used at both sites.
24
+
25
+ ## 2. Under-block — what does it still miss?
26
+
27
+ - **CI-green is still not checked.** Verification remains merge-base reachability of a recorded
28
+ commit. An item merged with red CI verifies. Unchanged by this PR; `StageTransitionValidator`
29
+ performs the stronger check on the `/advance` path.
30
+ - **No scheduling.** Nothing here makes rounds run; it makes a run able to conclude.
31
+ - **`resolveCanonicalMainRef` is best-effort.** If `gh` is absent it falls back to `origin/main`,
32
+ which on a fork-origin home under-verifies (items read as regressed → a spawn, i.e. redoing work).
33
+ That is the pre-existing conservative default, now applied to this path too rather than left to a
34
+ caller to remember.
35
+
36
+ ## 3. Level-of-abstraction fit
37
+
38
+ `resolveCanonicalMainRef` moved from `src/server/routes.ts` into `src/core/ProjectRoundExecution.ts`
39
+ and is imported back. Both consumers — the lazy reconciler and this runner — need identical
40
+ resolution, and a core module must not import from `server/`. Net: one definition, two callers,
41
+ correct direction. The two source-grep tests in `merged-record-carries-its-evidence.test.ts` assert
42
+ the *call* in `routes.ts`, not the definition, and still pass.
43
+
44
+ ## 4. Signal vs authority
45
+
46
+ The verifier stays a **signal** — it reports three states and holds no blocking authority. `runRound`
47
+ is the authority and now consumes all three rather than flattening them. The specific improvement:
48
+ the authority can no longer be handed a value ("empty set") that is indistinguishable from a real
49
+ negative reading. Per `docs/signal-vs-authority.md`, the failure was not a brittle check holding
50
+ authority; it was an authority whose input could not express uncertainty.
51
+
52
+ ## 5. Interactions
53
+
54
+ - **`/projects/:id/advance`** — untouched. Item-level stage transitions still go through
55
+ `StageTransitionValidator`; this only affects round-level outcome.
56
+ - **`ProjectAutoAdvancePoller`** — unchanged; it clears `autoAdvanceAt` and counts unacked advances.
57
+ It now sees rounds that can actually reach `complete`.
58
+ - **`ProjectDigestCache`** — unchanged, and this is the visible effect: the session-start
59
+ `N of M done` line can move off zero for the first time via the poller path.
60
+ - **Double-fire / races** — none added. `unverifiable` performs strictly *fewer* writes than any
61
+ other outcome (it writes nothing), so it cannot race the tracker's OCC.
62
+
63
+ ## 6. External surfaces
64
+
65
+ `RoundOutcome` gains `'unverifiable'` — a TypeScript-level addition on an exported union. In-repo
66
+ consumers are `ProjectRoundExecution` itself and the tests; `recordOutcome` maps
67
+ `Exclude<RoundOutcome,'unverifiable'>` so the compiler enforces the exhaustiveness rather than a
68
+ runtime default swallowing it. No route, no config key, no user-visible string.
69
+
70
+ ## 7. Multi-machine posture
71
+
72
+ **Machine-local BY DESIGN**, `machine-local-justification: hardware-bound-resource` — the check runs
73
+ `git merge-base` against a working checkout on local disk, and the round runner already holds a
74
+ host-local `ProjectRoundLock` (`.instar/local/round-runner.lock`). Round execution is bound to the
75
+ machine holding the checkout; nothing here introduces state another machine would need to read. No
76
+ notice, no durable cross-machine record, no generated URL.
77
+
78
+ ## 8. Rollback cost
79
+
80
+ Low and local. One file's behavior plus a moved helper; no data migration, no config, no persisted
81
+ schema. Reverting restores the prior behavior exactly — including the defect. A round left in
82
+ `pending` by an `unverifiable` outcome is a normal pending round; nothing to repair.
83
+
84
+ ## Refusals demonstrated (command + output)
85
+
86
+ Falsification 1 — neutralise the evidence predicate (`ids.filter(() => false && …)`):
87
+
88
+ ```
89
+ × an item that RECORDS a merge commit but cannot be checked → no respawn, and NO round verdict
90
+ × child exits 0 but the shortfall is entirely uncheckable → unverifiable, NOT partially-complete
91
+ Tests 2 failed | 8 passed (10)
92
+ ```
93
+
94
+ Falsification 2 — re-introduce a default returning an unconditional empty verdict:
95
+
96
+ ```
97
+ × the seam cannot default to silence again > has no default verifier that returns an unconditional empty set
98
+ Tests 1 failed | 9 passed (10)
99
+ ```
100
+
101
+ Restored: `Tests 36 passed (36)` across the four affected files, `tsc --noEmit` exit 0.
102
+
103
+ Falsification 3 — **a guard I did not anticipate, which changed the code.** The full suite failed on
104
+ `lint-sync-subprocess-chokepoint`:
105
+
106
+ ```
107
+ src/core/ProjectRoundExecution.ts:569 — raw sync spawn (execFileSync('gh', …)).
108
+ A sync blocking op must funnel through withSyncOp() so the in-flight marker sees it.
109
+ lint-sync-subprocess-chokepoint: 1 new violation(s).
110
+ ```
111
+
112
+ In `routes.ts` that callsite sat on the lint's **frozen baseline** — grandfathered, not blessed.
113
+ Moving it into core made it a NEW violation. The refusal is the useful part: `runRound` is called
114
+ from the server's auto-advance poller, so a blocking `gh repo view` there stalls the event loop.
115
+ Now funnelled through `withSyncOp` so the in-flight marker classes the stall instead of it
116
+ presenting as an unexplained freeze. Lint after: `clean — 97 raw sync spawn(s), all grandfathered
117
+ (142 baselined)`.
118
+
119
+ Worth noting the lint is a deliberately **lexical, same-line** matcher (`/\bwithSyncOp\s*\(/`) and
120
+ says so in its own header — it cannot prove runtime wrapping, which is the marker's unit test's job.
121
+ My first wrap was semantically correct across three lines and still flagged. That is signal-vs-
122
+ authority working as designed: a cheap check that names a hazard, not a proof.
123
+
124
+ ## Known-failing locally, verified NOT caused by this change
125
+
126
+ `tests/e2e/dev-preflight-cli.test.ts` fails in this worktree. The cause is inside preflight's lint
127
+ step, which shells out to `pnpm install`; reproduced standalone:
128
+
129
+ ```
130
+ pnpm install → PNPM_EXIT=1
131
+ [ERR_PNPM_IGNORED_BUILDS] Ignored build scripts: baileys, better-sqlite3, esbuild, sharp, …
132
+ ```
133
+
134
+ That is a dependency build-script approval issue in a worktree installed with `npm ci`, naming only
135
+ third-party packages and reading none of the changed files. Not run to completion deliberately —
136
+ `pnpm install` would restructure the `node_modules` the 36 passing tests were validated against. CI
137
+ installs the project its own way and is the arbiter; flagged here rather than quietly omitted.
@@ -0,0 +1,116 @@
1
+ # Side-effects review — user-channel liveness in the channel registry
2
+
3
+ **Change:** the two direct USER channels (Telegram, Slack) join the peer channel registry, with
4
+ liveness probes that read live adapter state instead of configuration. `ChannelDefinition` and
5
+ `ChannelReport` gain an `audience: 'peer' | 'user'` field.
6
+
7
+ **Decision point touched:** none that blocks. This is a read surface — it informs a routing choice a
8
+ caller makes, and holds no authority over it. `advisory: true` is already on the response.
9
+
10
+ ## 1. Over-block — what legitimate inputs does this reject that it shouldn't?
11
+
12
+ Nothing is rejected; nothing is gated. The nearest analogue is reporting a *usable* channel as
13
+ unusable, which would cause a caller to avoid a working path.
14
+
15
+ One case deliberately risks that and should be named: a Telegram that is **not polling with no
16
+ recorded reason** reports `unknown`, not `working`. If the adapter was stopped deliberately and could
17
+ still send, this understates it. That direction is chosen on purpose — over-reporting liveness is the
18
+ defect being fixed, and `unknown` carries its reason rather than pretending to a verdict.
19
+
20
+ Transient errors do NOT downgrade a live channel: still-polling with a non-zero consecutive error
21
+ count stays `working`, with the count named in the detail. Marking that broken would be the opposite
22
+ over-block.
23
+
24
+ ## 2. Under-block — what does it still miss?
25
+
26
+ - **A live reading is not a promise.** `working` means the loop was polling when asked. It can die a
27
+ second later. No probe can fix that; the response carries `generatedAt`.
28
+ - **No round-trip is attempted.** Telegram `working` means the poll loop is up, not that a message to
29
+ a specific topic would land (a topic could be deleted, a user could have blocked the bot). The
30
+ peer registry's `mutual-ssh` entry already makes the same distinction in its own detail text, and
31
+ this follows that precedent rather than overstating.
32
+ - **Slack is one workspace.** `isConnected()` is per-adapter; a multi-workspace setup is not modelled.
33
+ - **Other user surfaces are absent** — WhatsApp and iMessage adapters exist in the tree. They are not
34
+ in this change, and the registry will therefore not claim anything about them. Under the registry's
35
+ own "absence is impossible" property this is a real limit: a channel with no row cannot report that
36
+ it is missing. Called out rather than quietly scoped away.
37
+
38
+ ## 3. Level-of-abstraction fit
39
+
40
+ The user definitions live in a NEW file (`src/core/userChannels.ts`) rather than being added to
41
+ `src/core/instarChannels.ts`, whose header declares an explicit "PEER-TO-PEER only" scope discipline
42
+ and justifies two prior exclusions. Widening that file would have silently discarded a deliberate
43
+ decision by its author.
44
+
45
+ The two lists are composed at the route and resolved by the same `resolveChannels`, so the registry's
46
+ invariants (one row per definition, bounded probes, `unknown` on failure) apply identically to both
47
+ without being reimplemented.
48
+
49
+ `audience` is data on the channel, not two registries, because the peer-vs-user choice is itself a
50
+ routing decision a caller must be able to weigh. Two surfaces would require the caller to already
51
+ know which to consult — the arbitrariness the registry exists to remove.
52
+
53
+ ## 4. Signal vs authority
54
+
55
+ Pure signal. The registry reports; it never routes, blocks, or sends. The response is already flagged
56
+ `advisory: true`. The mapping functions (`telegramStateFrom`, `slackStateFrom`) are exported and pure
57
+ precisely so the verdict logic can be pinned by tests without constructing an adapter — the mapping
58
+ is where a wrong verdict would originate.
59
+
60
+ ## 5. Interactions
61
+
62
+ - **`/capabilities`** — unchanged. It keeps reporting `telegram: { configured: true }`, which remains
63
+ correct for what it measures (configuration). This adds the missing state reading; it does not
64
+ correct or replace the config reading, and the two answer different questions.
65
+ - **Peer channels** — behaviour unchanged; they gain an `audience: 'peer'` tag. Because `audience` is
66
+ required on `ChannelDefinition`, a future channel cannot be added untagged: it is a compile error,
67
+ not a silent default.
68
+ - **No double-fire / no races** — read-only, no writes, no timers of its own. Probes are bounded by
69
+ the registry's existing 3s timeout.
70
+
71
+ ## 6. External surfaces
72
+
73
+ `GET /channels` gains two rows and every row gains an `audience` field. Additive: existing consumers
74
+ reading `id`/`state`/`detail` are unaffected. No new route, no config key, no user-visible string.
75
+
76
+ ## 7. Multi-machine posture
77
+
78
+ **Machine-local BY DESIGN**, `machine-local-justification: physical-credential-locality` — a Telegram
79
+ bot token and its long-poll loop, and a Slack Socket Mode connection, live in one process on one
80
+ machine. "Is my Telegram polling?" is only meaningful about the machine asked; replicating another
81
+ machine's answer would assert liveness this process cannot observe. This matches the peer registry,
82
+ which is machine-local for the same reason (its relay/SSH probes read local runtime state).
83
+
84
+ ## 8. Rollback cost
85
+
86
+ Low. One new file, one required field on two interfaces, one route composing two lists. No data
87
+ migration, no config, no persisted state. Reverting restores the prior behaviour exactly — including
88
+ the gap.
89
+
90
+ ## Refusals demonstrated (command + output)
91
+
92
+ Falsification 1 — make the Telegram probe read EXISTENCE instead of liveness (`if (status !== null)`
93
+ in place of `if (status.started)`), i.e. exactly what `/capabilities` does:
94
+
95
+ ```
96
+ × THE FIX: a configured-but-DEAD Telegram is never reported as working
97
+ × a missing bot token is a credential verdict, not a network one
98
+ × a network death is broken — distinct from a credential problem
99
+ × stopped for NO recorded reason is unknown — never working, never a confident broken
100
+ Tests 4 failed | 12 passed (16)
101
+ ```
102
+
103
+ Falsification 2 — give Slack "ever connected" semantics (`if (enabled)` in place of
104
+ `if (connected)`), the exact trap its own source warns about:
105
+
106
+ ```
107
+ × THE TRAP: enabled with the socket DOWN is broken, not working
108
+ Tests 1 failed | 15 passed (16)
109
+ ```
110
+
111
+ Restored: `Tests 35 passed (35)` across `channel-registry`, `channel-registry-claims` and
112
+ `user-channel-liveness`; `npx tsc --noEmit` exit 0.
113
+
114
+ Two source ratchets are included so the probes cannot drift back: one asserts `userChannels.ts` never
115
+ reads a `.configured` property or a `config.*` value, the other asserts the route wiring uses
116
+ `isConnected()` and never `slackAdapter.started`.