instar 1.3.1212 → 1.3.1213

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.
@@ -14,6 +14,16 @@
14
14
  * 1. meshTransport gate — a no-op when `multiMachine.meshTransport` is off.
15
15
  * 2. Absence is a no-op, NEVER a wipe — undefined/null/`[]`/fully-invalid leaves the
16
16
  * peer's prior ropes intact (a silent or un-upgraded sender must not erase them).
17
+ * 2b. Degradation does not discard a PROVEN rope — a non-empty advertisement that
18
+ * OMITS a kind whose local health record says it is currently alive RETAINS that
19
+ * kind instead of replacing it away. Invariant 2 guarded the empty case only, so a
20
+ * peer that can no longer SEE its own best address (an agent inside a NAT'd VM —
21
+ * the 2026-08-29 WSL machine, whose only visible NIC was an unreachable virtual
22
+ * one) replaced a demonstrably-working tailscale rope with a dead-on-arrival lan
23
+ * one, and the mesh lost its only route to that machine. Evidence beats assertion:
24
+ * a rope carrying traffic is not dropped because an announcement forgot it. A rope
25
+ * the health record calls dead (or has never dialed) is still dropped, so a
26
+ * genuinely-retired endpoint does not linger forever.
17
27
  * 3. Synchronous per-kind validation BEFORE storage (defense-in-depth, not authority).
18
28
  * 4. Idempotent — skip the write (and its `lastSeen` bump + registry-dirty mark) when
19
29
  * the normalized set is unchanged, preventing ~720 no-op rewrites/day on a stable
@@ -29,6 +39,13 @@ export interface PeerEndpointRecorderDeps {
29
39
  updateMachineEndpoints: (machineId: string, endpoints: MeshEndpoint[]) => void;
30
40
  /** Live read: false ⇒ recording is a strict no-op (the lease handling is unchanged). */
31
41
  meshTransportEnabled: () => boolean;
42
+ /**
43
+ * Invariant 2b evidence source: is (peer, kind) CURRENTLY alive in this machine's own
44
+ * rope-health record? Only a `true` retains an omitted kind — `false` (dead) and
45
+ * `undefined` (never dialed, no evidence) both fall through to replace semantics.
46
+ * Optional so a caller that wires no health source keeps the pre-2b behaviour exactly.
47
+ */
48
+ isEndpointAlive?: (machineId: string, kind: MeshEndpoint['kind']) => boolean | undefined;
32
49
  logger?: (msg: string) => void;
33
50
  }
34
51
  export declare class PeerEndpointRecorder {
@@ -45,5 +62,18 @@ export declare class PeerEndpointRecorder {
45
62
  * responder). Never pass a self-asserted body field — that is the load-bearing binding.
46
63
  */
47
64
  record(peerMachineId: string, raw: unknown): boolean;
65
+ /**
66
+ * Invariant 2b — merge an omitted-but-PROVEN kind back into the advertised set.
67
+ *
68
+ * `advertised` wins for every kind it names (a peer re-advertising a kind with a NEW
69
+ * url is still an upgrade, and must land). A kind present in `current` but absent from
70
+ * `advertised` is retained ONLY when `isEndpointAlive` positively says it is alive; a
71
+ * dead rope, an unknown rope, and a caller with no health source all fall through to
72
+ * plain replace semantics.
73
+ *
74
+ * Pure over its inputs (no I/O, no throw) so the retain decision is unit-testable
75
+ * without a registry or a resolver.
76
+ */
77
+ private retainProvenOmitted;
48
78
  }
49
79
  //# sourceMappingURL=PeerEndpointRecorder.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"PeerEndpointRecorder.d.ts","sourceRoot":"","sources":["../../src/core/PeerEndpointRecorder.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;GAsBG;AAEH,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,YAAY,CAAC;AAG/C,MAAM,WAAW,wBAAwB;IACvC,qFAAqF;IACrF,gBAAgB,EAAE,CAAC,SAAS,EAAE,MAAM,KAAK,YAAY,EAAE,GAAG,SAAS,CAAC;IACpE,4FAA4F;IAC5F,sBAAsB,EAAE,CAAC,SAAS,EAAE,MAAM,EAAE,SAAS,EAAE,YAAY,EAAE,KAAK,IAAI,CAAC;IAC/E,wFAAwF;IACxF,oBAAoB,EAAE,MAAM,OAAO,CAAC;IACpC,MAAM,CAAC,EAAE,CAAC,GAAG,EAAE,MAAM,KAAK,IAAI,CAAC;CAChC;AAED,qBAAa,oBAAoB;IAC/B,OAAO,CAAC,QAAQ,CAAC,CAAC,CAA2B;gBAEjC,IAAI,EAAE,wBAAwB;IAI1C,OAAO,CAAC,GAAG;IAIX;;;;;;;;OAQG;IACH,MAAM,CAAC,aAAa,EAAE,MAAM,EAAE,GAAG,EAAE,OAAO,GAAG,OAAO;CAoBrD"}
1
+ {"version":3,"file":"PeerEndpointRecorder.d.ts","sourceRoot":"","sources":["../../src/core/PeerEndpointRecorder.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAgCG;AAEH,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,YAAY,CAAC;AAG/C,MAAM,WAAW,wBAAwB;IACvC,qFAAqF;IACrF,gBAAgB,EAAE,CAAC,SAAS,EAAE,MAAM,KAAK,YAAY,EAAE,GAAG,SAAS,CAAC;IACpE,4FAA4F;IAC5F,sBAAsB,EAAE,CAAC,SAAS,EAAE,MAAM,EAAE,SAAS,EAAE,YAAY,EAAE,KAAK,IAAI,CAAC;IAC/E,wFAAwF;IACxF,oBAAoB,EAAE,MAAM,OAAO,CAAC;IACpC;;;;;OAKG;IACH,eAAe,CAAC,EAAE,CAAC,SAAS,EAAE,MAAM,EAAE,IAAI,EAAE,YAAY,CAAC,MAAM,CAAC,KAAK,OAAO,GAAG,SAAS,CAAC;IACzF,MAAM,CAAC,EAAE,CAAC,GAAG,EAAE,MAAM,KAAK,IAAI,CAAC;CAChC;AAED,qBAAa,oBAAoB;IAC/B,OAAO,CAAC,QAAQ,CAAC,CAAC,CAA2B;gBAEjC,IAAI,EAAE,wBAAwB;IAI1C,OAAO,CAAC,GAAG;IAIX;;;;;;;;OAQG;IACH,MAAM,CAAC,aAAa,EAAE,MAAM,EAAE,GAAG,EAAE,OAAO,GAAG,OAAO;IAsBpD;;;;;;;;;;;OAWG;IACH,OAAO,CAAC,mBAAmB;CA8B5B"}
@@ -14,6 +14,16 @@
14
14
  * 1. meshTransport gate — a no-op when `multiMachine.meshTransport` is off.
15
15
  * 2. Absence is a no-op, NEVER a wipe — undefined/null/`[]`/fully-invalid leaves the
16
16
  * peer's prior ropes intact (a silent or un-upgraded sender must not erase them).
17
+ * 2b. Degradation does not discard a PROVEN rope — a non-empty advertisement that
18
+ * OMITS a kind whose local health record says it is currently alive RETAINS that
19
+ * kind instead of replacing it away. Invariant 2 guarded the empty case only, so a
20
+ * peer that can no longer SEE its own best address (an agent inside a NAT'd VM —
21
+ * the 2026-08-29 WSL machine, whose only visible NIC was an unreachable virtual
22
+ * one) replaced a demonstrably-working tailscale rope with a dead-on-arrival lan
23
+ * one, and the mesh lost its only route to that machine. Evidence beats assertion:
24
+ * a rope carrying traffic is not dropped because an announcement forgot it. A rope
25
+ * the health record calls dead (or has never dialed) is still dropped, so a
26
+ * genuinely-retired endpoint does not linger forever.
17
27
  * 3. Synchronous per-kind validation BEFORE storage (defense-in-depth, not authority).
18
28
  * 4. Idempotent — skip the write (and its `lastSeen` bump + registry-dirty mark) when
19
29
  * the normalized set is unchanged, preventing ~720 no-op rewrites/day on a stable
@@ -51,10 +61,11 @@ export class PeerEndpointRecorder {
51
61
  return false; // empty/fully-invalid → no-op, never a wipe
52
62
  try {
53
63
  const current = this.d.getPeerEndpoints(peerMachineId);
54
- if (meshEndpointsEqual(current, validated))
64
+ const next = this.retainProvenOmitted(peerMachineId, current, validated);
65
+ if (meshEndpointsEqual(current, next))
55
66
  return false; // idempotent — skip the write
56
- this.d.updateMachineEndpoints(peerMachineId, validated);
57
- this.log(`recorded ${validated.length} endpoint(s) for ${peerMachineId} [${validated.map((e) => e.kind).join(',')}]`);
67
+ this.d.updateMachineEndpoints(peerMachineId, next);
68
+ this.log(`recorded ${next.length} endpoint(s) for ${peerMachineId} [${next.map((e) => e.kind).join(',')}]`);
58
69
  return true;
59
70
  }
60
71
  catch (err) {
@@ -65,5 +76,44 @@ export class PeerEndpointRecorder {
65
76
  return false;
66
77
  }
67
78
  }
79
+ /**
80
+ * Invariant 2b — merge an omitted-but-PROVEN kind back into the advertised set.
81
+ *
82
+ * `advertised` wins for every kind it names (a peer re-advertising a kind with a NEW
83
+ * url is still an upgrade, and must land). A kind present in `current` but absent from
84
+ * `advertised` is retained ONLY when `isEndpointAlive` positively says it is alive; a
85
+ * dead rope, an unknown rope, and a caller with no health source all fall through to
86
+ * plain replace semantics.
87
+ *
88
+ * Pure over its inputs (no I/O, no throw) so the retain decision is unit-testable
89
+ * without a registry or a resolver.
90
+ */
91
+ retainProvenOmitted(peerMachineId, current, advertised) {
92
+ const alive = this.d.isEndpointAlive;
93
+ if (!alive || !current || current.length === 0)
94
+ return advertised;
95
+ const advertisedKinds = new Set(advertised.map((e) => e.kind));
96
+ const retained = [];
97
+ for (const ep of current) {
98
+ if (advertisedKinds.has(ep.kind))
99
+ continue;
100
+ let verdict;
101
+ try {
102
+ verdict = alive(peerMachineId, ep.kind);
103
+ }
104
+ catch {
105
+ // @silent-fallback-ok: an unreadable health source is NO evidence, which is the
106
+ // same as "never dialed" — fall through to replace. Never retains on an error.
107
+ verdict = undefined;
108
+ }
109
+ if (verdict === true)
110
+ retained.push(ep);
111
+ }
112
+ if (retained.length > 0) {
113
+ this.log(`retained ${retained.length} proven endpoint(s) for ${peerMachineId} omitted by its advertisement `
114
+ + `[${retained.map((e) => e.kind).join(',')}] — health says alive`);
115
+ }
116
+ return [...advertised, ...retained];
117
+ }
68
118
  }
69
119
  //# sourceMappingURL=PeerEndpointRecorder.js.map
@@ -1 +1 @@
1
- {"version":3,"file":"PeerEndpointRecorder.js","sourceRoot":"","sources":["../../src/core/PeerEndpointRecorder.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;GAsBG;AAGH,OAAO,EAAE,qBAAqB,EAAE,kBAAkB,EAAE,MAAM,4BAA4B,CAAC;AAYvF,MAAM,OAAO,oBAAoB;IACd,CAAC,CAA2B;IAE7C,YAAY,IAA8B;QACxC,IAAI,CAAC,CAAC,GAAG,IAAI,CAAC;IAChB,CAAC;IAEO,GAAG,CAAC,CAAS;QACnB,IAAI,CAAC,CAAC,CAAC,MAAM,EAAE,CAAC,oBAAoB,CAAC,EAAE,CAAC,CAAC;IAC3C,CAAC;IAED;;;;;;;;OAQG;IACH,MAAM,CAAC,aAAqB,EAAE,GAAY;QACxC,IAAI,CAAC,IAAI,CAAC,CAAC,CAAC,oBAAoB,EAAE;YAAE,OAAO,KAAK,CAAC;QACjD,IAAI,CAAC,aAAa;YAAE,OAAO,KAAK,CAAC;QACjC,IAAI,GAAG,KAAK,SAAS,IAAI,GAAG,KAAK,IAAI;YAAE,OAAO,KAAK,CAAC,CAAC,kBAAkB;QACvE,MAAM,SAAS,GAAG,qBAAqB,CAAC,GAAG,CAAC,CAAC;QAC7C,IAAI,SAAS,CAAC,MAAM,KAAK,CAAC;YAAE,OAAO,KAAK,CAAC,CAAC,4CAA4C;QACtF,IAAI,CAAC;YACH,MAAM,OAAO,GAAG,IAAI,CAAC,CAAC,CAAC,gBAAgB,CAAC,aAAa,CAAC,CAAC;YACvD,IAAI,kBAAkB,CAAC,OAAO,EAAE,SAAS,CAAC;gBAAE,OAAO,KAAK,CAAC,CAAC,8BAA8B;YACxF,IAAI,CAAC,CAAC,CAAC,sBAAsB,CAAC,aAAa,EAAE,SAAS,CAAC,CAAC;YACxD,IAAI,CAAC,GAAG,CAAC,YAAY,SAAS,CAAC,MAAM,oBAAoB,aAAa,KAAK,SAAS,CAAC,GAAG,CAAC,CAAC,CAAC,EAAE,EAAE,CAAC,CAAC,CAAC,IAAI,CAAC,CAAC,IAAI,CAAC,GAAG,CAAC,GAAG,CAAC,CAAC;YACtH,OAAO,IAAI,CAAC;QACd,CAAC;QAAC,OAAO,GAAG,EAAE,CAAC;YACb,qFAAqF;YACrF,yFAAyF;YACzF,wFAAwF;YACxF,IAAI,CAAC,GAAG,CAAC,mBAAmB,aAAa,KAAK,GAAG,YAAY,KAAK,CAAC,CAAC,CAAC,GAAG,CAAC,OAAO,CAAC,CAAC,CAAC,MAAM,CAAC,GAAG,CAAC,EAAE,CAAC,CAAC;YAClG,OAAO,KAAK,CAAC;QACf,CAAC;IACH,CAAC;CACF"}
1
+ {"version":3,"file":"PeerEndpointRecorder.js","sourceRoot":"","sources":["../../src/core/PeerEndpointRecorder.ts"],"names":[],"mappings":"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"}
@@ -2,7 +2,7 @@
2
2
  "schemaVersion": 1,
3
3
  "generatedFrom": "source-tree",
4
4
  "registrySha256": "8e6606a6722433c5ffc15b3870402283c5406083a14cdbd0e18e735731e2aae2",
5
- "packageVersion": "1.3.1212",
5
+ "packageVersion": "1.3.1213",
6
6
  "guards": [
7
7
  {
8
8
  "ref": "docs/audits/phase-b/f10-triage.md",
@@ -1,5 +1,5 @@
1
1
  {
2
- "sha256": "e5674d7f14d889d929e239fecc78aa34e4fafebd8e07f9e7b598339109b7b084",
2
+ "sha256": "0511bc378e28f623a7f8670ca57f2563ae0c20396c88c436fd87507377ca87b9",
3
3
  "registrySha256": "8e6606a6722433c5ffc15b3870402283c5406083a14cdbd0e18e735731e2aae2",
4
- "packageVersion": "1.3.1212"
4
+ "packageVersion": "1.3.1213"
5
5
  }
@@ -2,5 +2,5 @@
2
2
  "sha256": "8e6606a6722433c5ffc15b3870402283c5406083a14cdbd0e18e735731e2aae2",
3
3
  "articleCount": 90,
4
4
  "generatedFrom": "docs/STANDARDS-REGISTRY.md",
5
- "packageVersion": "1.3.1212"
5
+ "packageVersion": "1.3.1213"
6
6
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "instar",
3
- "version": "1.3.1212",
3
+ "version": "1.3.1213",
4
4
  "description": "Coherence infrastructure for self-evolving AI agents — on the Claude Code or Codex subscription you already have.",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",
@@ -1,8 +1,8 @@
1
1
  {
2
2
  "$schema": "./builtin-manifest.schema.json",
3
3
  "schemaVersion": 1,
4
- "generatedAt": "2026-08-29T19:31:15.184Z",
5
- "instarVersion": "1.3.1212",
4
+ "generatedAt": "2026-08-29T20:28:30.776Z",
5
+ "instarVersion": "1.3.1213",
6
6
  "entryCount": 202,
7
7
  "entries": {
8
8
  "hook:session-start": {
@@ -2,7 +2,7 @@
2
2
  "schemaVersion": 1,
3
3
  "generatedFrom": "source-tree",
4
4
  "registrySha256": "8e6606a6722433c5ffc15b3870402283c5406083a14cdbd0e18e735731e2aae2",
5
- "packageVersion": "1.3.1212",
5
+ "packageVersion": "1.3.1213",
6
6
  "guards": [
7
7
  {
8
8
  "ref": "docs/audits/phase-b/f10-triage.md",
@@ -1,5 +1,5 @@
1
1
  {
2
- "sha256": "e5674d7f14d889d929e239fecc78aa34e4fafebd8e07f9e7b598339109b7b084",
2
+ "sha256": "0511bc378e28f623a7f8670ca57f2563ae0c20396c88c436fd87507377ca87b9",
3
3
  "registrySha256": "8e6606a6722433c5ffc15b3870402283c5406083a14cdbd0e18e735731e2aae2",
4
- "packageVersion": "1.3.1212"
4
+ "packageVersion": "1.3.1213"
5
5
  }
@@ -2,5 +2,5 @@
2
2
  "sha256": "8e6606a6722433c5ffc15b3870402283c5406083a14cdbd0e18e735731e2aae2",
3
3
  "articleCount": 90,
4
4
  "generatedFrom": "docs/STANDARDS-REGISTRY.md",
5
- "packageVersion": "1.3.1212"
5
+ "packageVersion": "1.3.1213"
6
6
  }
@@ -0,0 +1,45 @@
1
+ # Upgrade Guide — vNEXT
2
+
3
+ <!-- assembled-by: assemble-next-md -->
4
+ <!-- bump: patch -->
5
+
6
+ ## What Changed
7
+
8
+ `PeerEndpointRecorder` gained invariant 2b: a non-empty peer advertisement that OMITS a
9
+ rope kind no longer discards that kind when this machine's own rope-health record shows
10
+ it currently alive. Retention requires positive evidence — a dead rope, a never-dialled
11
+ rope, and an unreadable health source all keep the previous replace semantics, and an
12
+ advertised kind always wins for its own kind. Wired from `meshResolver.snapshot()` in
13
+ `server.ts`.
14
+
15
+ Found live on 2026-08-29: a peer whose agent runs inside WSL can only see an unreachable
16
+ virtual NIC, so it advertised `[lan]` alone, and the mesh discarded the working tailscale
17
+ rope that was carrying its traffic at that moment.
18
+
19
+ ## What to Tell Your User
20
+
21
+ If one of your machines runs its agent inside a nested environment (WSL, a container, a
22
+ VM), it may not be able to see the address the other machines actually reach it on. When
23
+ it announced the only address it *could* see, your other machines used to believe it and
24
+ throw away the route that was working — leaving that machine unreachable with no way for
25
+ it to correct the record.
26
+
27
+ Now a route your machine can see carrying traffic is kept even when an announcement
28
+ forgets to mention it. A route that has genuinely gone dead is still dropped, so nothing
29
+ lingers forever.
30
+
31
+ ## Summary of New Capabilities
32
+
33
+ - A peer's shorter address list can no longer delete a route this machine can see working.
34
+ - Retention is evidence-based: only a rope the local health record reports alive is kept;
35
+ dead and never-dialled ropes are dropped exactly as before.
36
+ - Fully reversible — with no health source wired the behaviour is byte-identical to the
37
+ previous replace semantics.
38
+
39
+ ## Evidence
40
+
41
+ - Unit: `tests/unit/PeerEndpointRecorder.test.ts` (+8 cases), verified red-then-green
42
+ against pre-2b behaviour.
43
+ - Integration: `tests/integration/mesh-endpoint-propagation.test.ts` (+2 cases) driving
44
+ the real `/api/lease` route with a real registry.
45
+ - Side-effects review: `upgrades/side-effects/mesh-endpoint-degradation-retention.md`.
@@ -0,0 +1,106 @@
1
+ # Side-effects review — mesh endpoint degradation retention (invariant 2b)
2
+
3
+ **Change:** `PeerEndpointRecorder.record()` no longer lets a non-empty but DEGRADED peer
4
+ advertisement discard a rope this machine's own health record currently shows alive.
5
+ Wired in `server.ts` from `meshResolver.snapshot()`.
6
+
7
+ **Origin (live incident, 2026-08-29, topic 62395):** a peer whose agent runs inside WSL
8
+ can only see an unreachable virtual NIC (`eth0 172.22.96.135`); Tailscale lives on the
9
+ Windows host, outside that VM. It advertised `[lan]` alone; the recorder replaced
10
+ `[tailscale, lan]`, and the mesh lost its only working route to that machine — while the
11
+ tailscale rope was verifiably carrying traffic at that moment (peer answered
12
+ `/health` 200 and a signed mesh probe returned the typed `not-router` refusal).
13
+
14
+ ## 1. Over-block — what legitimate input does this now reject?
15
+
16
+ None: the change never rejects an advertisement. Every advertised kind is still recorded
17
+ verbatim and still wins for its own kind (covered by the "re-advertising a kind with a new
18
+ url still wins" and "advertised kind actually changed" tests). The only behavioural delta
19
+ is ADDITIVE retention of an omitted kind.
20
+
21
+ The nearest thing to an over-block is over-RETENTION: keeping a rope the peer meant to
22
+ retire. Bounded three ways — retention requires a positive `alive === true`; `false` and
23
+ `undefined` (never dialled) both drop; and the resolver remains the dial-time authority,
24
+ so a retained rope that stops working is marked dead and then dropped on the next
25
+ degraded advertisement.
26
+
27
+ ## 2. Under-block — what does this still miss?
28
+
29
+ It does NOT help when the proven rope is ALSO not currently alive in the health record at
30
+ the moment the degraded advertisement lands (a machine that reboots into WSL-only
31
+ visibility while its tailscale rope happens to be marked dead). That case needs the
32
+ peer to be able to advertise an address it cannot locally see, which is the separate
33
+ NAT'd-machine work — tracked, not deferred silently:
34
+ <!-- tracked: CMT-211 -->
35
+
36
+ It also does not fix the root cause for that machine: a WSL-hosted agent still cannot
37
+ discover its own reachable address. This change stops the DATA LOSS, not the blindness.
38
+
39
+ ## 3. Level-of-abstraction fit
40
+
41
+ Correct layer. `PeerEndpointRecorder` is the documented single chokepoint for "I just
42
+ learned a peer's ropes" and already owns invariants 1–5, including the sibling
43
+ absence-is-never-a-wipe rule. Invariant 2b is the same class of protection (an
44
+ advertisement must not destroy knowledge) and belongs beside it, not in the two
45
+ callsites. Nothing lower (the validator) knows current state; nothing higher (the routes)
46
+ should own merge policy.
47
+
48
+ ## 4. Signal vs authority compliance
49
+
50
+ Compliant. The recorder produces/persists a SIGNAL (the known endpoint set); it holds no
51
+ blocking authority. `PeerEndpointResolver` remains the dial-time authority and already
52
+ deprioritises-but-probes dead ropes, so a retained-but-stale endpoint cannot block or
53
+ misroute anything — it can only be tried last. The new dep is a read-only evidence
54
+ source; an exception from it is caught and degrades to the previous behaviour.
55
+
56
+ ## 5. Interactions
57
+
58
+ - **Invariant 4 (idempotency):** preserved and explicitly tested — when the merged set
59
+ equals what is stored (the common steady-state case), the write is still skipped, so
60
+ this does not reintroduce the ~720 no-op rewrites/day the idempotency guard removed.
61
+ - **Invariant 2 (absence):** untouched; the empty/invalid path returns before the merge.
62
+ - **Invariant 5 (advisory):** untouched; only the peer's own entry is written.
63
+ - **`MeshEndpointValidator` cap:** the merge appends retained entries AFTER validation of
64
+ the advertised set. A peer at the kind cap cannot exceed it, because retention only
65
+ adds kinds the advertisement did NOT name and there are three kinds total.
66
+ - **No double-fire / no race added:** the merge is pure and synchronous inside the
67
+ existing single write path.
68
+
69
+ ## 6. External surfaces
70
+
71
+ No route, config key, schema, or user-visible surface changes. `GET /health →
72
+ multiMachine.syncStatus` and `GET /pool` may now show a peer retaining a rope kind they
73
+ would previously have lost — which is the intended correction. Timing dependence is
74
+ limited to reading a live snapshot, and the failure mode of that read is explicitly
75
+ "no evidence ⇒ previous behaviour".
76
+
77
+ ## 7. Multi-machine posture (Cross-Machine Coherence)
78
+
79
+ **Machine-local BY DESIGN.** The retention decision is made from the RECEIVING machine's
80
+ own rope-health record and written only into its own registry copy. This is deliberate
81
+ and load-bearing: rope health is inherently per-observer (machine A's tailscale rope to C
82
+ can be alive while B's is dead), so replicating the decision would let one machine's view
83
+ overwrite another's ground truth. Each machine independently reaches the correct answer
84
+ from its own evidence. No replication path is added and none is wanted. A single-machine
85
+ agent has no peers and is a strict no-op.
86
+
87
+ ## 8. Rollback cost
88
+
89
+ Trivial and total. Remove the `isEndpointAlive` dep from the `server.ts` construction and
90
+ the recorder falls back — by an explicitly-tested path ("with NO health dep wired,
91
+ behaviour is byte-identical to plain replace") — to the exact pre-change semantics. No
92
+ data migration: the registry format is unchanged, and a retained endpoint is an ordinary
93
+ entry the next advertisement can overwrite. No agent state repair.
94
+
95
+ ## Tests
96
+
97
+ - Unit (`tests/unit/PeerEndpointRecorder.test.ts`, +8): retain-on-alive, drop-on-dead,
98
+ drop-on-no-evidence, drop-on-throw, advertised-kind-upgrade-wins, idempotent-merge,
99
+ no-dep-is-identical. Verified RED against pre-2b behaviour (3 failures for the right
100
+ reason: the retention cases) and GREEN after.
101
+ - Integration (`tests/integration/mesh-endpoint-propagation.test.ts`, +2): the real
102
+ `/api/lease` route with a real registry — a degraded advertisement keeps a live rope,
103
+ and still drops a dead one.
104
+ - E2E: not applicable — no new API route or feature-liveness surface; the change is a
105
+ merge rule inside an existing chokepoint already covered end-to-end by the integration
106
+ route test.