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.
- package/dist/commands/server.d.ts.map +1 -1
- package/dist/commands/server.js +12 -0
- package/dist/commands/server.js.map +1 -1
- package/dist/core/PeerEndpointRecorder.d.ts +30 -0
- package/dist/core/PeerEndpointRecorder.d.ts.map +1 -1
- package/dist/core/PeerEndpointRecorder.js +53 -3
- package/dist/core/PeerEndpointRecorder.js.map +1 -1
- package/dist/data/standards-guard-index.json +1 -1
- package/dist/data/standards-guard-index.meta.json +2 -2
- package/dist/data/standards-registry.meta.json +1 -1
- package/package.json +1 -1
- package/src/data/builtin-manifest.json +2 -2
- package/src/data/standards-guard-index.json +1 -1
- package/src/data/standards-guard-index.meta.json +2 -2
- package/src/data/standards-registry.meta.json +1 -1
- package/upgrades/1.3.1213.md +45 -0
- package/upgrades/side-effects/mesh-endpoint-degradation-retention.md +106 -0
|
@@ -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
|
|
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
|
-
|
|
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,
|
|
57
|
-
this.log(`recorded ${
|
|
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
|
|
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.
|
|
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": "
|
|
2
|
+
"sha256": "0511bc378e28f623a7f8670ca57f2563ae0c20396c88c436fd87507377ca87b9",
|
|
3
3
|
"registrySha256": "8e6606a6722433c5ffc15b3870402283c5406083a14cdbd0e18e735731e2aae2",
|
|
4
|
-
"packageVersion": "1.3.
|
|
4
|
+
"packageVersion": "1.3.1213"
|
|
5
5
|
}
|
package/package.json
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
{
|
|
2
2
|
"$schema": "./builtin-manifest.schema.json",
|
|
3
3
|
"schemaVersion": 1,
|
|
4
|
-
"generatedAt": "2026-08-
|
|
5
|
-
"instarVersion": "1.3.
|
|
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.
|
|
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": "
|
|
2
|
+
"sha256": "0511bc378e28f623a7f8670ca57f2563ae0c20396c88c436fd87507377ca87b9",
|
|
3
3
|
"registrySha256": "8e6606a6722433c5ffc15b3870402283c5406083a14cdbd0e18e735731e2aae2",
|
|
4
|
-
"packageVersion": "1.3.
|
|
4
|
+
"packageVersion": "1.3.1213"
|
|
5
5
|
}
|
|
@@ -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.
|