instar 1.3.498 → 1.3.499

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,125 @@
1
+ # Side-Effects Review — WS3.1/WS3.2 one-voice gates (SpeakerElection)
2
+
3
+ **Spec:** docs/specs/MULTI-MACHINE-SEAMLESSNESS-SPEC.md (converged 2026-06-12, approved; merged to main in PR #1083)
4
+ **Change:** new `src/monitoring/SpeakerElection.ts` (deterministic speaker election with
5
+ lease-stability dwell); PresenceProxy gate at its single send chokepoint (closes F18 —
6
+ it had NO machine gate); PromiseBeacon gate upgraded to live owner re-resolution with
7
+ the commitment stamp as fallback (closes F19); CommitmentTracker stamps
8
+ `ownerMachineId` at creation (defaulting to originMachineId); PostUpdateMigrator
9
+ backfills existing open commitments; dark config flag
10
+ `multiMachine.seamlessness.ws3OneVoice` (default false) + `ws3DwellMs`.
11
+
12
+ ## 1. Over-block — what legitimate emissions could this wrongly silence?
13
+
14
+ The known hazard (round-1 adversarial finding, addressed in the converged spec): a
15
+ gate keyed on "am I the owner?" can conclude "no" on EVERY machine and silence the
16
+ pool. The election is built against that direction of error:
17
+ - Unknown ownership NEVER yields pool-wide silence: lease-holder speaks; no/offline
18
+ lease-holder → deterministic lowest-online-id tiebreak; flapping lease → bounded
19
+ defer then tiebreak. The exactly-one-speaks invariant test asserts ≥1 as well as ≤1,
20
+ including under a flapping lease.
21
+ - Every guard failure (flag off, no machine id, pool < 2, deps unbound during the
22
+ boot window) returns "speak" — fail toward speech.
23
+ - PromiseBeacon's silent verdicts RE-ARM the commitment (schedule), never drop it; the
24
+ owning machine's beacon carries the heartbeat.
25
+ Residual over-block: a STALE local placement view could say "owner is the other
26
+ machine" while that machine is dead — its election (not running) can't speak, ours
27
+ stays silent for that topic until placement converges. Mitigation: ownership records
28
+ key the election only when status is 'active'; a dead machine's records converge via
29
+ the existing failover path, and PresenceProxy's cadence re-evaluates each tick.
30
+ Accepted as bounded-by-failover; noted for the WS1.3 reconcile build which shortens
31
+ exactly this window. Second residual (independent reviewer finding): a held silent
32
+ verdict can outlive the chosen speaker's departure for up to one dwell window
33
+ (default 60s) plus one cadence tick on a doubly-degraded path (speaker offline
34
+ mid-dwell + unknown ownership + no online lease-holder) — bounded, self-healing
35
+ (re-election after dwell expiry), and no emission is dropped (both consumers re-arm).
36
+
37
+ ## 2. Under-block — what double-voice does this still miss?
38
+
39
+ - Both gates only cover PresenceProxy + PromiseBeacon (the F18/F19 surfaces named in
40
+ the spec). Other notice paths (rate-limit sentinel, recovery notices — F23/WS3.3)
41
+ are explicitly a separate spec item with episode-key dedup; not silently claimed.
42
+ - The ≥1/≤1 invariant holds when machines agree on the replicated inputs. During a
43
+ partition with divergent placement views, two machines can transiently both believe
44
+ they own a topic and both speak — the same exposure as today (no regression), fully
45
+ closed only by the WS1 epoch-fenced reconcile family.
46
+
47
+ ## 3. Level-of-abstraction fit
48
+
49
+ One shared deterministic module consumed by both sentinels at their existing
50
+ chokepoints — not two bespoke gates (the F18 finding showed exactly how per-sentinel
51
+ divergence happens). The election reads only local replicated state (ownership
52
+ registry, capacity registry, lease status) — never a mesh call on the emission path
53
+ (spec hot-path rule). The dwell lives in the module so every consumer inherits it.
54
+
55
+ ## 4. Signal vs authority compliance
56
+
57
+ The election holds NARROW authority: it gates WHO SPEAKS a duplicate-prone sentinel
58
+ notice — never recovery actions, never message content, never anything user-initiated
59
+ (spec "Decision points touched": WS3 gates sentinel SPEECH only). Its logic is
60
+ deterministic over replicated state (not brittle pattern-matching), it fails open to
61
+ "speak" on every uncertainty, ships dark, and every verdict is observable (onVerdict
62
+ hook → server log; P7). This is the blocking-authority shape the converged spec
63
+ explicitly reviewed and approved across three rounds.
64
+
65
+ ## 5. Interactions
66
+
67
+ - The per-topic proxy mutex (PromiseBeacon/PresenceProxy double-post guard, spec A10)
68
+ is untouched and still applies AFTER the election — the two compose (election picks
69
+ the machine, mutex serializes emitters within it).
70
+ - PromiseBeacon's legacy static gate remains for election-absent construction
71
+ (back-compat for tests/embedders); the election branch supersedes it when wired.
72
+ - The CommitmentTracker stamp default cannot collide with cross-machine commitment
73
+ sync (P1.5): originMachineId was already recorded; ownerMachineId now mirrors it at
74
+ creation and remains mutable via the existing CAS mutate path.
75
+ - The migration touches state/commitments.json directly at update time (server down/
76
+ pre-boot — same pattern as every other PostUpdateMigrator state migration); it
77
+ stamps only missing fields on 'pending' records.
78
+
79
+ ## 6. External surfaces
80
+
81
+ None new. No routes, no message formats, no cross-machine wire changes. The flag is
82
+ config-local. Single-machine agents: structurally inert (no SpeakerElection
83
+ engagement below 2 online machines; CommitmentTracker stamp is absent without an
84
+ originMachineId — both tested).
85
+
86
+ ## 7. Rollback cost
87
+
88
+ Flag-flip: `multiMachine.seamlessness.ws3OneVoice: false` (the default) restores
89
+ legacy verdicts instantly at the next decision — no restart-ordering hazard since the
90
+ flag is read live per decision. The backfilled ownerMachineId stamps are inert data
91
+ when the flag is off (the legacy gate only acts when currentMachineId is ALSO set,
92
+ i.e. multi-machine pools) and are correct-by-construction on single-machine agents;
93
+ no data rollback needed.
94
+
95
+ ## Second-pass review
96
+
97
+ REQUIRED (sentinel surface). Reviewer response appended below.
98
+
99
+ <!-- second-pass reviewer response appended below by the independent reviewer -->
100
+
101
+ ---
102
+
103
+ ## Second-pass reviewer response (independent subagent, 2026-06-12)
104
+
105
+ Concur with the review
106
+
107
+ 1. **Fail-toward-speech verified on every uncertain path.** Both consumer gates skip the send ONLY when `!verdict.speak` (PromiseBeacon.ts:360, PresenceProxy.ts:1847), and `SpeakerElection.decideInner` returns `speak:true` on every legacy/no-op/ambiguous branch: flag-off (`legacy-disabled`), no machine id, pool<2 (`single-machine`), owner-self, stamp-self, lease-holder-self, and the deterministic `tiebreak-lowest-id` whenever the lease holder is null or offline. The guards are ordered legacy/no-op FIRST, so none of the election machinery runs when disabled. The boot-window no-op is real: `poolMachineIds()` returns `[]` until `ws3PoolDeps` binds, yielding `single-machine`/speak. The flag is read live (`() => ws3Cfg().ws3OneVoice === true`) so the rollback flip takes effect at the next decision with no restart — artifact §7 is accurate.
108
+
109
+ 2. **Silent verdicts re-arm, never drop.** PromiseBeacon calls `this.schedule(c)` on a non-speak verdict, which re-arms at the commitment cadence (default 10min, 1s floor — no busy-loop); PresenceProxy logs and returns, and its own cadence re-evaluates. No emission is lost on a silent/defer verdict. The legacy static gate is preserved for election-absent construction as the artifact §5 claims.
110
+
111
+ 3. **Migration direction-of-error is safe and idempotent.** `migrateCommitmentOwnerBackfill` stamps ONLY `status==='pending'` records lacking `ownerMachineId`, with THIS machine's own identity.json machineId — and crucially, when no identity exists it skips WITHOUT writing the marker, so it retries on a later update rather than permanently no-op'ing. The stamp is only a fallback; live placement re-resolution at speak time means a stale backfilled stamp racing a transfer cannot wedge the gate (live owner beats stamp; tested). The single-machine no-op is genuinely byte-identical: no `originMachineId` → `ownerMachineId` stays absent (tested).
112
+
113
+ 4. **No authority beyond sentinel speech; cannot gate recovery/delivery/user flows.** The election is consumed at exactly two chokepoints — `PresenceProxy.sendProxyMessage` and `PromiseBeacon.fire` — both of which emit duplicate-prone 🔭/heartbeat notices. It is injected as an optional dep; it touches no recovery action, no message content, no user-initiated path, and `resolveTopicOwner` reads only local replicated state with no mesh/fetch call on the hot path (asserted by test). The `onVerdict` hook is try/catch-swallowed so observability can never gate. I agree with the artifact's "no issue identified" answers in §3–§6.
114
+
115
+ 5. **Bounded memory; ≤1 invariant holds.** The `held` map is opportunistically swept past the dwell window at size>512 and `unstableSince` entries are deleted on every decisive path — no unbounded growth. Two machines cannot BOTH speak for a topic under agreed replicated inputs: live owner is single-valued, the stamp comparison is to one id, and the tiebreak picks one `sort()[0]`. (Divergent-placement double-voice during a partition is correctly scoped to WS1 and disclosed as no-regression in §2.)
116
+
117
+ **One verification note for the record (bounded, not a blocker):** the speaker-identity dwell can HOLD a silent verdict, and the held branch returns it irrespective of current pool state. If the chosen speaker machine drops offline mid-dwell while ownership is unknown AND there is no online lease holder AND the remaining pool is still ≥2, the surviving machines can each return a fresh held-silent verdict, producing a transient ALL-silent window for that topic. This does NOT violate the spec's ≥1 framing ("never pool-wide / never unbounded silence"): it is bounded by `dwellMs` (default 60s) plus one cadence tick, it self-heals (after dwell expiry the new lowest-id re-elects to `tiebreak-lowest-id` and speaks), no emission is dropped (both consumers re-arm), and the `single-machine` guard correctly rescues the pool-of-1 case by firing before the dwell check. It is strictly no worse in outcome than the partition exposure already disclosed in §2, and the gate holds no authority beyond speech — an acceptable residual on a doubly-degraded path, not a defect. Recorded in §1's residual list.
118
+
119
+ ## Addendum — no-silent-fallbacks ratchet (CI round 1)
120
+
121
+ The three new fail-open guards in server.ts (identity read, pool-ids read, ownership
122
+ read) tripped the repo-wide silent-fallback ratchet (466 > 463). Each now carries the
123
+ sanctioned `@silent-fallback-ok` exemption with its reason inline: every one degrades
124
+ to the election's designed fail-toward-speech/no-op verdict — the documented safe
125
+ direction, not a silent loss. Ratchet count restored to baseline.