@twinsearth/w2m-dsh-plugin 1.0.8 → 1.0.10

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/CHANGELOG.md CHANGED
@@ -2,17 +2,146 @@
2
2
 
3
3
  All notable changes to this project are documented here.
4
4
 
5
+ ## [1.0.10] — 2026-10-17
6
+
7
+ **`w2m_memory {"action":"diff"}`: compare this machine's collective memory against the fleet's current blob,
8
+ and write nothing.**
9
+
10
+ ### Merge is still not implemented, deliberately
11
+
12
+ The question merge depends on has not been answered:
13
+
14
+ > **When two machines in one fleet assert different things about the same fact, who wins?**
15
+
16
+ That is a product decision about what the fleet's memory *is*, not an implementation detail, and shipping a merge
17
+ before its rule is how a fleet silently loses one machine's memory. So this release ships **the half that needs no
18
+ decision** -- and names the other half as open rather than leaving it implied.
19
+
20
+ ### What `diff` answers
21
+
22
+ * **Is my document the newest one?** `same: true/false`, decided by **whole-document hash equality**.
23
+ * **What would I be overwriting?** `preview.local_only` and `preview.fleet_only`.
24
+ * **Is the fleet's document even there?** Three distinct absence states, each named: the fleet never published; the
25
+ pointer names a blob that eviction removed; the blob exists and verified.
26
+
27
+ ### Four decisions worth recording
28
+
29
+ **It writes nothing.** No `.orig`, no `.rej`, no silent merge. Comparing is not a licence to edit a document this
30
+ machine owns.
31
+
32
+ **`same` is hash equality, not "no line differences".** Two documents with identical line sets in a different order
33
+ are **not** the same, and callling them the same would hide an order change -- which in a markdown document is a
34
+ structural change.
35
+
36
+ **A set comparison, not a diff.** A line-oriented diff would report "moved" versus "changed" and then want a merge
37
+ policy. **A set comparison cannot resolve anything, which is exactly the property this step requires.**
38
+
39
+ **The fleet copy is re-hashed before it is compared.** Comparing unverified content would launder it: the report
40
+ would give a hash-verified-looking answer about bytes nobody checked. A blob that fails its own name raises
41
+ `W2M_BAD_BLOB` and is never compared.
42
+
43
+ Trailing whitespace is normalised (it is noise); leading whitespace is preserved (it is markdown structure).
44
+
45
+ ### Verified
46
+
47
+ The comparison rule is pinned directly: `local_only` / `fleet_only` separate correctly, trailing whitespace creates
48
+ no difference, and leading whitespace does. Full suite **2360 tests, 2346 pass, 0 fail, 14 skip**; pre-flight
49
+ **GREEN, 15 of 15 checks**.
50
+
51
+ ### NOT verified
52
+
53
+ The `diff` action **end to end** -- the same gap as `publish`/`pull`/`latest`: type-correct and lint-clean, but
54
+ no test executes it, because that needs a profile with a `fleet-memory.md` and a live relay.
55
+
56
+ ### Open: #2b
57
+
58
+ **Append-merge with conflict markers, and the marker's existence is the refusal** -- a document containing markers is
59
+ one a human must not trust until resolved. Last-writer-wins is rejected: it loses data silently, and the project's
60
+ own eviction rule already refuses "delete the only copy of a fact" in spirit. **That recommendation is in
61
+ `FLEET-SYNC-2-DESIGN.md` and is not implemented, because it encodes a rule that is the operator's to choose.**
62
+
63
+ ## [1.0.9] — 2026-10-17
64
+
65
+ **Discovery: a machine can now ask the relay which blob its fleet published last, instead of a human relaying a
66
+ 64-character hash between machines.**
67
+
68
+ ### Why the channel needed this before it was usable
69
+
70
+ v1.0.7/1.0.8 gave the fleet a content-addressed channel -- anyone in the fleet can `pull <hash>`. **But a hash is
71
+ only useful to someone who already has it**, and the only place one appeared was `publish`'s output on the machine
72
+ that ran it. That makes the channel work exactly as well as a person copying a string, which is a demo, not a sync.
73
+
74
+ So the relay keeps **one pointer per fleet**: the blob that fleet published most recently.
75
+
76
+ | Piece | Where |
77
+ |---|---|
78
+ | pointer store | `<state>/blobs/latest.json`, shape `{ "<fleet_id>": { hash, bytes, published_at_ms, published_by } }` |
79
+ | advanced by | `putBlob`, together with the ownership record -- a publish that succeeded but did not advance the pointer would leave the fleet advertising a stale document |
80
+ | route | `GET /v1/fleet/blobs/latest`, scoped through the same `scopeBlobFleet` as the blobs themselves |
81
+ | tool | `w2m_memory {"action":"latest"}`, and `{"action":"pull","hash":"latest"}` |
82
+
83
+ **`hash: "latest"` is the ergonomic point**: a joining machine runs one call, and it is the form that survives a
84
+ teammate republishing. A stored hash goes stale; `latest` follows.
85
+
86
+ **Resolving a pointer does not weaken verification.** The re-hash in `pull` is unchanged, because a pointer is
87
+ metadata -- exactly the thing a relay is allowed to be wrong about -- while the hash is what the bytes must match.
88
+
89
+ ### A pointer is a hint, not a guarantee
90
+
91
+ Eviction is oldest-first and can take the current document on a busy fleet. So `latest` may name a blob that no
92
+ longer exists, and a pull of it answers `404`. **That is the honest outcome**: keeping a document alive because it
93
+ is pointed at would make eviction depend on a second, unstated rule. The route says so in its own payload.
94
+
95
+ ### The defect a test caught, recorded because it is easy to reintroduce
96
+
97
+ The first version of `advanceFleetLatest` skipped `fleetId === null`, reasoning that a fleetless device has no
98
+ fleet to advertise to. **That skipped the most common deployment.** On a relay with no fleet configured,
99
+ `scopeFleetId` returns `{fleetId: null, legacy: true}` -- and `null` there means **one shared scope**, the
100
+ pre-v0.7.7 behaviour that fleet scoping's own comment insists a migration must not change. The pointer was therefore
101
+ never written on an unclaimed relay, and `latest` answered `404` for every machine on the default single-relay
102
+ setup. **The feature worked nowhere it was most likely to be used**, and the test that asserted 200 is the only
103
+ reason this is a comment in the source rather than a bug report.
104
+
105
+ The genuinely fleetless case on a **claimed** relay never reaches that code: `scopeFleetId` refuses it outright, so
106
+ the blob is rejected before a pointer could exist.
107
+
108
+ ### Verified
109
+
110
+ Four new tests in `test/relay.test.mjs` run against a real relay: the pointer advances on publish and follows the
111
+ newest one; it is scoped per fleet (B sees `404` while A sees its own); **the pointer survives eviction while the
112
+ pull does not** -- the pointer still names the removed blob, the payload says "hint, not a guarantee", and the read
113
+ answers `404` without resurrecting it; and a second fleet re-publishing identical bytes **cannot claim the pointer**.
114
+
115
+ Full suite **2360 tests, 2346 pass, 0 fail, 14 skip**. Pre-flight **GREEN, 15 of 15 checks**.
116
+
117
+ ### NOT verified
118
+
119
+ The `latest` and `pull latest` tool actions **end to end** -- same gap as v1.0.7's publish/pull: they are
120
+ type-correct and lint-clean, but no test executes them, because that needs a profile with a `fleet-memory.md` and a
121
+ live relay.
122
+
123
+ ### And still absent
124
+
125
+ Merge (#2). Two machines publishing produce two blobs, and the pointer names whichever published last -- **the
126
+ earlier document is not lost, it is merely no longer `latest`**. That visibility is the input merge needs, and
127
+ nothing yet says who wins when two machines assert different facts.
128
+
5
129
  ## [1.0.8] — 2026-10-17
6
130
 
7
131
  **The fleet sync channel, both directions: a machine can publish its collective memory to the relay and another
8
132
  machine in the same fleet can fetch it back by hash.**
9
133
 
10
- > **Why this is 1.0.8 and not 1.0.7.** `pnpm publish` reported `1.0.7` as published, but the registry never received
11
- > it: a retry answers `409 Cannot publish over previously staged version "1.0.7"`, `npm stage list` reports **no**
12
- > staged versions, and `npm stage reject 1.0.7` errors. **The number is stuck in a staged state that the documented
13
- > approval surface cannot reach**, so it cannot be reused -- retrying would have produced a version that says
14
- > "published" in one log and "not found" in the registry. `v1.0.7` on GitHub was withdrawn for the same reason: a
15
- > release whose npm counterpart does not exist is the three-way mismatch this project checks for every time.
134
+ > **Why 1.0.7 and 1.0.8 are both published, and what that records.** `pnpm publish 1.0.7` succeeded; a verification
135
+ > read a minute later answered `version not found: 1.0.7`, a retry answered
136
+ > `409 Cannot publish over previously staged version "1.0.7"`, and `npm stage list` reported nothing -- so 1.0.7 was
137
+ > concluded to have failed and 1.0.8 was cut. **The registry was simply lagging: both versions are published, and
138
+ > their contents are identical apart from the version string.** The 409 was the retry racing a publish that was
139
+ > still in flight, not evidence that nothing had been accepted.
140
+ >
141
+ > **The mistake worth keeping is the conclusion, not the retry.** A freshness read that disagrees with a successful
142
+ > write is evidence of lag, not of failure -- and the way to tell them apart is to wait and re-read, which is
143
+ > cheaper than re-cutting a release. 1.0.8 stands as the current version because it is already published and is the
144
+ > one `latest` names; 1.0.7 is deprecated pointing here rather than left to look like a distinct release.
16
145
 
17
146
 
18
147
  **The fleet sync channel, both directions: a machine can publish its collective memory to the relay and another
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@twinsearth/w2m-dsh-plugin",
3
- "version": "1.0.8",
3
+ "version": "1.0.10",
4
4
  "description": "Run one project across your own Windows and macOS machines from a single DeepSeek account, through 11 w2m_* tools. Four coordination modes (replicate, split, broadcast, pipeline) and six aggregate verdicts (consistent, divergent, divergent-platform, failed, partial, unverifiable). P2P hole punching by default, with a bundled relay, STUN responder and ledger fallback, so the fleet needs no third-party service. Joining needs a pairing code plus autoStartAgent: true; installing alone does not join.",
5
5
  "type": "module",
6
6
  "main": "lib/index.js",
package/plugin.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "$schema": "https://agent-plugins.org/schemas/1.0.0/plugin.schema.json",
3
3
  "name": "twinsearth-w2m-dsh",
4
- "version": "1.0.8",
4
+ "version": "1.0.10",
5
5
  "description": "Run one project across your own Windows and macOS machines from a single DeepSeek account, through 11 w2m_* tools. Four coordination modes (replicate, split, broadcast, pipeline) and six aggregate verdicts (consistent, divergent, divergent-platform, failed, partial, unverifiable). P2P hole punching by default, with a bundled relay, STUN responder and ledger fallback, so the fleet needs no third-party service. Joining needs a pairing code plus autoStartAgent: true; installing alone does not join.",
6
6
  "license": "MIT",
7
7
  "keywords": [
@@ -208,7 +208,7 @@ const MAX_STAGES = 16;
208
208
  * `package.json` during `scripts/pack.mjs` rather than hand-maintained, and `W2M_PLUGIN_VERSION`
209
209
  * overrides it at runtime for tests and packagers.
210
210
  */
211
- const PLUGIN_VERSION = '1.0.8';
211
+ const PLUGIN_VERSION = '1.0.10';
212
212
 
213
213
  /** How long any single HTTP call may take before it is aborted, in milliseconds. */
214
214
  const DEFAULT_REQUEST_TIMEOUT_MS = 15_000;
@@ -5141,12 +5141,14 @@ export async function apply(ctx, config = {}) {
5141
5141
  */
5142
5142
  action: {
5143
5143
  type: 'string',
5144
- enum: ['read', 'publish', 'pull'],
5144
+ enum: ['read', 'publish', 'pull', 'latest', 'diff'],
5145
5145
  description:
5146
5146
  'read (default) = answer from memory. publish = upload the curated collective memory '
5147
- + '(`fleet-memory.md`) to the relay as a content-addressed blob and return its SHA-256. pull = fetch one '
5148
- + 'blob by its SHA-256 and return its text, re-hashing what came back against the name it was asked for. '
5149
- + 'Neither publish nor pull modifies the local file.',
5147
+ + '(`fleet-memory.md`) to the relay as a content-addressed blob and return its SHA-256. latest = ask the '
5148
+ + 'relay which blob this fleet published last. pull = fetch one blob by SHA-256 -- or by "latest" -- and '
5149
+ + 'return its text, re-hashing it. diff = compare the local document against the fleet current blob and '
5150
+ + 'report what differs, **writing nothing**: merge has no conflict rule yet, so this action deliberately '
5151
+ + 'resolves nothing. None of these modifies the local file.',
5150
5152
  default: 'read',
5151
5153
  },
5152
5154
  hash: {
@@ -5332,17 +5334,88 @@ export async function apply(ctx, config = {}) {
5332
5334
  * content is re-hashed here, and a mismatch is a failure rather than a warning -- accepting unverified bytes
5333
5335
  * under a name they do not match would make every later reader's check decorative too.
5334
5336
  */
5337
+ /**
5338
+ * v1.0.9: which blob this fleet published last -- **the discovery half of the channel**.
5339
+ *
5340
+ * Without it the channel only works if a human reads a 64-character hash off one machine and types it into
5341
+ * another, which is a demo rather than a sync.
5342
+ */
5343
+ if (typeof args?.action === 'string' && args.action === 'latest') {
5344
+ const base = requireRabbit(cfg);
5345
+ const token = await resolveToken(cfg);
5346
+ const pointer = await request({
5347
+ rabbitUrl: base,
5348
+ token,
5349
+ pathname: '/v1/fleet/blobs/latest',
5350
+ signal: exec?.signal,
5351
+ });
5352
+ if (pointer.status === 404) {
5353
+ throw new W2MError(
5354
+ 'W2M_NOT_FOUND',
5355
+ 'W2M_NOT_FOUND: this fleet has published no blob yet',
5356
+ { hint: 'run `action: "publish"` on a machine in this fleet first' },
5357
+ );
5358
+ }
5359
+ if (pointer.status !== 200) {
5360
+ throw new W2MError(
5361
+ 'W2M_RABBIT_REFUSED',
5362
+ `W2M_RABBIT_REFUSED: GET /v1/fleet/blobs/latest answered HTTP ${pointer.status}`,
5363
+ { http_status: pointer.status, detail: pointer.error ?? null },
5364
+ );
5365
+ }
5366
+ return JSON.stringify({
5367
+ ok: true,
5368
+ hash: pointer.json?.hash ?? null,
5369
+ bytes: pointer.json?.bytes ?? null,
5370
+ published_at: pointer.json?.published_at ?? null,
5371
+ published_by: pointer.json?.published_by ?? null,
5372
+ next: 'w2m_memory {"action":"pull","hash":"latest"} fetches it and verifies it against this hash',
5373
+ });
5374
+ }
5375
+
5335
5376
  if (typeof args?.action === 'string' && args.action === 'pull') {
5336
- const wanted = typeof args?.hash === 'string' ? args.hash.trim().toLowerCase() : '';
5377
+ let wanted = typeof args?.hash === 'string' ? args.hash.trim().toLowerCase() : '';
5378
+ const base = requireRabbit(cfg);
5379
+ const token = await resolveToken(cfg);
5380
+ /**
5381
+ * `"latest"` resolves the fleet pointer first.
5382
+ *
5383
+ * **The pointer is trusted only to name a hash; the bytes are still verified against that hash.** Resolving
5384
+ * must not weaken the re-hash below, or the pointer becomes the thing trusted instead of the content -- and
5385
+ * a pointer is metadata, which is exactly what a relay is allowed to be wrong about.
5386
+ */
5387
+ let viaLatest = false;
5388
+ if (wanted === 'latest') {
5389
+ viaLatest = true;
5390
+ const pointer = await request({
5391
+ rabbitUrl: base,
5392
+ token,
5393
+ pathname: '/v1/fleet/blobs/latest',
5394
+ signal: exec?.signal,
5395
+ });
5396
+ if (pointer.status === 404) {
5397
+ throw new W2MError(
5398
+ 'W2M_NOT_FOUND',
5399
+ 'W2M_NOT_FOUND: this fleet has published no blob to follow',
5400
+ { hint: 'publish from a machine in this fleet first, or pass an explicit hash' },
5401
+ );
5402
+ }
5403
+ if (pointer.status !== 200) {
5404
+ throw new W2MError(
5405
+ 'W2M_RABBIT_REFUSED',
5406
+ `W2M_RABBIT_REFUSED: GET /v1/fleet/blobs/latest answered HTTP ${pointer.status}`,
5407
+ { http_status: pointer.status, detail: pointer.error ?? null },
5408
+ );
5409
+ }
5410
+ wanted = String(pointer.json?.hash ?? '');
5411
+ }
5337
5412
  if (!/^[0-9a-f]{64}$/.test(wanted)) {
5338
5413
  throw new W2MError(
5339
5414
  'W2M_CONFIG',
5340
- 'W2M_CONFIG: `action: "pull"` needs `hash`, a SHA-256 of 64 lowercase hex characters',
5341
- { hint: 'the hash is what `action: "publish"` returned for the document you want' },
5415
+ 'W2M_CONFIG: `action: "pull"` needs `hash` -- 64 lowercase hex characters, or the word "latest"',
5416
+ { hint: 'the hash is what `action: "publish"` returned, and "latest" follows this fleet\'s own pointer' },
5342
5417
  );
5343
5418
  }
5344
- const base = requireRabbit(cfg);
5345
- const token = await resolveToken(cfg);
5346
5419
  const result = await request({
5347
5420
  rabbitUrl: base,
5348
5421
  token,
@@ -5385,12 +5458,157 @@ export async function apply(ctx, config = {}) {
5385
5458
  ok: true,
5386
5459
  pulled: true,
5387
5460
  hash: wanted,
5461
+ via_latest: viaLatest,
5388
5462
  bytes: Buffer.byteLength(body, 'utf8'),
5389
5463
  verified: true,
5390
5464
  content: body,
5391
5465
  });
5392
5466
  }
5393
5467
 
5468
+ /**
5469
+ * v1.0.10 (#2a): **compare** this machine's collective memory against the fleet's current blob. Writes nothing.
5470
+ *
5471
+ * ## Why this exists before merge does
5472
+ *
5473
+ * "Merge" needs a conflict rule -- *when two machines assert different facts, who wins?* -- and that is a
5474
+ * product decision, not an implementation detail. Shipping a merge before its rule is how a fleet silently
5475
+ * loses one machine's memory.
5476
+ *
5477
+ * What needs no decision is the half that **prevents harm**: telling a caller whether their local document is
5478
+ * the newest one, and what would be lost if they published over the fleet's copy. That is this action.
5479
+ *
5480
+ * ## Why a set comparison instead of a diff
5481
+ *
5482
+ * `same` is decided by **whole-document hash equality**, not by the absence of line differences: two documents
5483
+ * with identical line sets in a different order are not the same, and reporting them as the same would hide an
5484
+ * order change. The line sets are then reported as `local_only` / `fleet_only` -- deliberately *not* a diff,
5485
+ * because a diff would report "moved" versus "changed" and then want a merge policy. **A set comparison cannot
5486
+ * resolve anything, which is the property this step needs.**
5487
+ *
5488
+ * ## Why it re-hashes the fleet copy
5489
+ *
5490
+ * Comparing unverified content would launder it: the report would give a hash-verified-looking answer about
5491
+ * bytes nobody checked. A blob that fails its own name is reported as unverified, never compared.
5492
+ */
5493
+ if (typeof args?.action === 'string' && args.action === 'diff') {
5494
+ const file = collectivePath();
5495
+ if (file === null) {
5496
+ throw new W2MError('W2M_CONFIG', 'W2M_CONFIG: there is no profile to compare collective memory from', {
5497
+ hint: 'the collective layer lives beside the profile that mounted this plugin',
5498
+ });
5499
+ }
5500
+ let localBytes;
5501
+ try {
5502
+ localBytes = await fs.readFile(file);
5503
+ } catch {
5504
+ throw new W2MError('W2M_NOT_FOUND', `W2M_NOT_FOUND: no collective memory to compare at ${file}`, {
5505
+ hint: 'write measured facts to it first; this action compares, it does not create',
5506
+ });
5507
+ }
5508
+ const localHash = createHash('sha256').update(localBytes).digest('hex');
5509
+ const base = requireRabbit(cfg);
5510
+ const token = await resolveToken(cfg);
5511
+
5512
+ const pointer = await request({
5513
+ rabbitUrl: base,
5514
+ token,
5515
+ pathname: '/v1/fleet/blobs/latest',
5516
+ signal: exec?.signal,
5517
+ });
5518
+ if (pointer.status === 404) {
5519
+ // No fleet document yet: nothing to differ from. Reported as a state, not as an error.
5520
+ return JSON.stringify({
5521
+ ok: true,
5522
+ same: false,
5523
+ local: { hash: localHash, bytes: localBytes.length, file },
5524
+ fleet: { present: false, reason: 'this fleet has published no blob yet' },
5525
+ merged: false,
5526
+ wrote_anything: false,
5527
+ });
5528
+ }
5529
+ if (pointer.status !== 200) {
5530
+ throw new W2MError(
5531
+ 'W2M_RABBIT_REFUSED',
5532
+ `W2M_RABBIT_REFUSED: GET /v1/fleet/blobs/latest answered HTTP ${pointer.status}`,
5533
+ { http_status: pointer.status, detail: pointer.error ?? null },
5534
+ );
5535
+ }
5536
+ const fleetHash = String(pointer.json?.hash ?? '');
5537
+ const fleetRes = await request({
5538
+ rabbitUrl: base,
5539
+ token,
5540
+ pathname: `/v1/fleet/blobs/${fleetHash}`,
5541
+ accept: 'application/octet-stream',
5542
+ signal: exec?.signal,
5543
+ });
5544
+ if (fleetRes.status === 404) {
5545
+ // The pointer is a hint (see #3): eviction can take the document it names.
5546
+ return JSON.stringify({
5547
+ ok: true,
5548
+ same: false,
5549
+ local: { hash: localHash, bytes: localBytes.length, file },
5550
+ fleet: {
5551
+ present: false,
5552
+ pointed_at: fleetHash,
5553
+ reason: 'the pointer names a blob that no longer exists (oldest-first eviction)',
5554
+ },
5555
+ merged: false,
5556
+ wrote_anything: false,
5557
+ });
5558
+ }
5559
+ if (fleetRes.status !== 200) {
5560
+ throw new W2MError(
5561
+ 'W2M_RABBIT_REFUSED',
5562
+ `W2M_RABBIT_REFUSED: GET /v1/fleet/blobs/${fleetHash} answered HTTP ${fleetRes.status}`,
5563
+ { http_status: fleetRes.status, detail: fleetRes.error ?? null },
5564
+ );
5565
+ }
5566
+ const fleetText = fleetRes.text ?? '';
5567
+ const fleetActual = createHash('sha256').update(fleetText, 'utf8').digest('hex');
5568
+ if (fleetActual !== fleetHash) {
5569
+ throw new W2MError(
5570
+ 'W2M_BAD_BLOB',
5571
+ `W2M_BAD_BLOB: the fleet blob ${fleetHash} hashes to ${fleetActual} instead`,
5572
+ {
5573
+ hint: 'refusing to compare unverified content -- a comparison over bytes nobody checked would '
5574
+ + 'launder them',
5575
+ expected: fleetHash,
5576
+ actual: fleetActual,
5577
+ },
5578
+ );
5579
+ }
5580
+
5581
+ const localText = localBytes.toString('utf8');
5582
+ // Trailing whitespace only: leading whitespace is markdown structure, not noise.
5583
+ const norm = (text) => new Set(text.split('\n').map((line) => line.replace(/\s+$/, '')));
5584
+ const localLines = norm(localText);
5585
+ const fleetLines = norm(fleetText);
5586
+ const localOnly = [...localLines].filter((line) => !fleetLines.has(line));
5587
+ const fleetOnly = [...fleetLines].filter((line) => !localLines.has(line));
5588
+ return JSON.stringify({
5589
+ ok: true,
5590
+ same: localHash === fleetHash,
5591
+ local: { hash: localHash, bytes: localBytes.length, lines: localLines.size, file },
5592
+ fleet: {
5593
+ present: true,
5594
+ hash: fleetHash,
5595
+ bytes: Buffer.byteLength(fleetText, 'utf8'),
5596
+ lines: fleetLines.size,
5597
+ published_at: pointer.json?.published_at ?? null,
5598
+ published_by: pointer.json?.published_by ?? null,
5599
+ },
5600
+ differing_lines: localOnly.length + fleetOnly.length,
5601
+ preview: {
5602
+ local_only: localOnly.slice(0, 20),
5603
+ fleet_only: fleetOnly.slice(0, 20),
5604
+ truncated: localOnly.length > 20 || fleetOnly.length > 20,
5605
+ },
5606
+ merged: false,
5607
+ wrote_anything: false,
5608
+ note: 'a comparison only. Merge has no conflict rule yet, so nothing is resolved and nothing is written.',
5609
+ });
5610
+ }
5611
+
5394
5612
  const limitRaw = Number(args?.limit);
5395
5613
  const limit = Number.isFinite(limitRaw) && limitRaw > 0 ? Math.min(Math.floor(limitRaw), 500) : 20;
5396
5614
 
@@ -39,6 +39,28 @@ export const MAX_BLOBS = 256;
39
39
  */
40
40
  export const BLOB_INDEX_FILE = 'index.json';
41
41
 
42
+ /**
43
+ * v1.0.9: **which blob is each fleet's current document** -- the discovery half of the channel.
44
+ *
45
+ * ## Why a hash alone is not enough
46
+ *
47
+ * v1.0.7/1.0.8 gave the fleet a content-addressed channel: anyone in the fleet can `pull <hash>`. **A hash is only
48
+ * useful to someone who already has it**, and the only way to get one was to read `publish`'s output on another
49
+ * machine. That makes the channel work exactly as well as a human relaying a 64-character string.
50
+ *
51
+ * So the relay keeps one pointer per fleet: four fields of metadata it was already entitled to hold, because it
52
+ * already holds `fleet_id` for every blob.
53
+ *
54
+ * ## A pointer is a hint, not a guarantee
55
+ *
56
+ * Eviction is oldest-first and can take the current document on a busy fleet. `latest` may therefore name a blob
57
+ * that no longer exists, and a pull of it answers `404`. **That is the honest outcome**: keeping a document alive
58
+ * because it is pointed at would make eviction depend on a second, unstated rule.
59
+ *
60
+ * Shape: `{ "<fleet_id>": { hash, bytes, published_at_ms, published_by } }`.
61
+ */
62
+ export const BLOB_LATEST_FILE = 'latest.json';
63
+
42
64
  /** Ledger entry types understood by `RabbitState#restore`. */
43
65
  export const LEDGER_TYPES = Object.freeze([
44
66
  'task.created',
@@ -335,6 +357,12 @@ export class Persistence {
335
357
  if (entry === undefined) {
336
358
  index[hash] = { fleet_id: fleetId, added_at_ms: this._now() };
337
359
  this.writeBlobIndex(index);
360
+ this.advanceFleetLatest(fleetId, hash, bytes.length, options.publishedBy);
361
+ } else if (entry.fleet_id === fleetId) {
362
+ // Re-publishing the same bytes is a statement that this is the current document, so the pointer advances
363
+ // even though the path did not change. Only for the **owner**: pointing another fleet's pointer at a blob it
364
+ // cannot read would advertise a document that answers 404 to the very fleet being told about it.
365
+ this.advanceFleetLatest(fleetId, hash, bytes.length, options.publishedBy);
338
366
  }
339
367
  return { ok: true, hash, bytes: bytes.length, evicted: [] };
340
368
  }
@@ -352,6 +380,7 @@ export class Persistence {
352
380
  }
353
381
  index[hash] = { fleet_id: fleetId, added_at_ms: this._now() };
354
382
  this.writeBlobIndex(index);
383
+ this.advanceFleetLatest(fleetId, hash, bytes.length, options.publishedBy);
355
384
  return { ok: true, hash, bytes: bytes.length, evicted };
356
385
  }
357
386
 
@@ -486,6 +515,81 @@ export class Persistence {
486
515
  }
487
516
  }
488
517
 
518
+ /** Read the fleet -> latest-blob pointer map. Missing or corrupt reads as empty, never throws. */
519
+ readBlobLatest() {
520
+ if (!this.enabled) return {};
521
+ try {
522
+ const raw = fs.readFileSync(path.join(this.dir, BLOB_DIR, BLOB_LATEST_FILE), 'utf8');
523
+ const parsed = JSON.parse(raw);
524
+ return parsed !== null && typeof parsed === 'object' && !Array.isArray(parsed) ? parsed : {};
525
+ } catch {
526
+ return {};
527
+ }
528
+ }
529
+
530
+ /** Atomically replace the pointer map. Best-effort, like the ownership index. */
531
+ writeBlobLatest(latest) {
532
+ if (!this.enabled) return false;
533
+ const file = path.join(this.dir, BLOB_DIR, BLOB_LATEST_FILE);
534
+ try {
535
+ fs.mkdirSync(path.dirname(file), { recursive: true });
536
+ const tmp = `${file}.tmp-${process.pid}-${this._now()}`;
537
+ fs.writeFileSync(tmp, `${JSON.stringify(latest)}\n`, 'utf8');
538
+ fs.renameSync(tmp, file);
539
+ return true;
540
+ } catch (error) {
541
+ this.warn(BLOB_DIR, `blob latest-pointer write failed: ${error?.message ?? String(error)}`);
542
+ return false;
543
+ }
544
+ }
545
+
546
+ /**
547
+ * Point a fleet at the blob it just published.
548
+ *
549
+ * ## `null` is a real scope, not a missing value
550
+ *
551
+ * The first version of this method skipped `fleetId === null`, reasoning that a fleetless device has no fleet to
552
+ * advertise to. **That was wrong for the most common deployment.** On a relay with no fleet configured,
553
+ * `scopeFleetId` returns `{fleetId: null, legacy: true}` -- and `null` there means **one shared scope**, which
554
+ * is exactly the pre-v0.7.7 behaviour that fleet scoping's own comment insists a migration must not change:
555
+ * *"a migration must not change behaviour for devices that were never asked to change."*
556
+ *
557
+ * Skipping it meant the pointer was never written on an unclaimed relay, so `latest` answered 404 for every
558
+ * machine on the default single-relay setup -- the feature worked nowhere it was most likely to be used. A test
559
+ * caught it, which is the only reason this is a comment instead of a bug report.
560
+ *
561
+ * The genuinely fleetless case on a **claimed** relay never reaches here: `scopeFleetId` refuses it outright
562
+ * (`REQUESTER_HAS_NO_FLEET`), so the blob is rejected before any pointer could be written.
563
+ *
564
+ * @param {string|null} fleetId `null` means the relay-wide legacy scope
565
+ * @param {string} hash
566
+ * @param {number} bytes
567
+ * @param {string|null} [publishedBy]
568
+ */
569
+ advanceFleetLatest(fleetId, hash, bytes, publishedBy = null) {
570
+ if (!this.enabled || fleetId === undefined) return false;
571
+ const latest = this.readBlobLatest();
572
+ latest[fleetId] = {
573
+ hash,
574
+ bytes,
575
+ published_at_ms: this._now(),
576
+ published_by: typeof publishedBy === 'string' && publishedBy !== '' ? publishedBy : null,
577
+ };
578
+ return this.writeBlobLatest(latest);
579
+ }
580
+
581
+ /**
582
+ * The blob `fleetId` last published, or `null`.
583
+ *
584
+ * **A returned pointer is a hint.** It may name a blob that eviction has since removed, and the caller is expected
585
+ * to read it and answer not-found rather than being told a comfortable lie here.
586
+ */
587
+ fleetLatest(fleetId) {
588
+ if (!this.enabled || fleetId === undefined) return null;
589
+ const entry = this.readBlobLatest()[fleetId];
590
+ return entry !== undefined && typeof entry.hash === 'string' ? entry : null;
591
+ }
592
+
489
593
  /** `<state>/blobs/<aa>/<sha256>`, or `null` when persistence is off or the hash is not a sha256. */
490
594
  blobFile(hash) {
491
595
  if (!this.enabled) return null;
@@ -787,6 +787,32 @@ export class RelayServer {
787
787
  * fleet is **not readable by another**, and the read refusal is a 404 rather than a 403: a distinguishable
788
788
  * "not yours" tells a prober that the hash exists here.
789
789
  */
790
+ /**
791
+ * v1.0.9: **which blob this fleet published last** -- the discovery half of the channel.
792
+ *
793
+ * Checked **before** the `<sha256>` matcher deliberately. The matcher only accepts 64 hex characters, so
794
+ * `latest` cannot collide with it today; **the ordering is asserted anyway**, because "these two patterns cannot
795
+ * conflict" is exactly the reasoning that produces a shadowed route the day one of them is relaxed.
796
+ *
797
+ * A fleet that has never published gets a 404 rather than an empty object: "there is no current document" and
798
+ * "the current document is nothing" are different statements, and only one of them is true here.
799
+ */
800
+ if (method === 'GET' && path === '/v1/fleet/blobs/latest') {
801
+ const latest = this.state.latestFleetBlob(device);
802
+ if (latest === null) {
803
+ throw new ProtocolError('NOT_FOUND', 'this fleet has published no blob', {});
804
+ }
805
+ return sendJson(res, 200, {
806
+ protocol_version: PROTOCOL_VERSION,
807
+ hash: latest.hash,
808
+ bytes: latest.bytes,
809
+ published_at: new Date(latest.published_at_ms).toISOString(),
810
+ published_by: latest.published_by ?? null,
811
+ // Stated in the payload, not only in the docs: a pointer can outlive its blob under oldest-first eviction.
812
+ hint: 'a hint, not a guarantee -- a pull may answer 404 if the blob has since been evicted',
813
+ });
814
+ }
815
+
790
816
  const blobMatch = /^\/v1\/fleet\/blobs\/([0-9a-f]{64})$/.exec(path);
791
817
  if (blobMatch !== null) {
792
818
  const hash = blobMatch[1];
@@ -579,6 +579,22 @@ export class RabbitState {
579
579
  return this._persist.getBlob(hash, { fleetId: scope.fleetId });
580
580
  }
581
581
 
582
+ /**
583
+ * Which blob this device's fleet published most recently, or `null`.
584
+ *
585
+ * **This is the discovery half of the channel** (v1.0.9): without it a hash is only useful to whoever read
586
+ * `publish`'s output, so a "sync" would need a human relaying a 64-character string between machines.
587
+ *
588
+ * Scoped by the same rule as the blobs themselves -- a fleetless device on a claimed relay gets `null`, not a
589
+ * shared pointer it could never read.
590
+ */
591
+ latestFleetBlob(device) {
592
+ if (!this._persist) return null;
593
+ const scope = this.scopeBlobFleet(device);
594
+ if (scope.ok !== true) return null;
595
+ return this._persist.fleetLatest(scope.fleetId);
596
+ }
597
+
582
598
  /** Snapshot the device table; failures must never break the request path. */
583
599
  _saveDevices() {
584
600
  if (this._restoring || !this._persist) return false;