@nelsonlongxiang/dsh-open-a2a-net 0.6.30 → 0.6.32

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.
Files changed (89) hide show
  1. package/README.md +4 -1
  2. package/README_zh.md +3 -0
  3. package/assets/nexusDist/assets/index-D5H8R8de.js +9 -0
  4. package/assets/nexusDist/assets/index-D9GYZyGZ.css +1 -0
  5. package/assets/nexusDist/index.html +2 -2
  6. package/cli/a2a.mjs +445 -57
  7. package/lib/a2a-client.js +9 -4
  8. package/lib/auth-observer.js +254 -0
  9. package/lib/blob-store.js +210 -0
  10. package/lib/card.js +4 -2
  11. package/lib/cli-deposit.js +122 -7
  12. package/lib/client.js +64 -64
  13. package/lib/client.js.map +1 -1
  14. package/lib/faces/admin.js +133 -43
  15. package/lib/faces/canvas-notify.js +12 -3
  16. package/lib/faces/files.js +155 -0
  17. package/lib/faces/nodes.js +171 -0
  18. package/lib/faces/session-log.js +162 -0
  19. package/lib/faces/wire.js +206 -39
  20. package/lib/index.js +809 -1720
  21. package/lib/joined-store.js +23 -0
  22. package/lib/ledger-conservation.js +97 -0
  23. package/lib/legacy/teams-bridge-inbound.js +156 -0
  24. package/lib/node-bind.js +534 -0
  25. package/lib/node-github.js +72 -0
  26. package/lib/node-self.js +453 -0
  27. package/lib/node-store.js +484 -0
  28. package/lib/node-tasks.js +342 -0
  29. package/lib/origin-claim.js +246 -0
  30. package/lib/peer-directory.js +134 -0
  31. package/lib/receipt.js +26 -1
  32. package/lib/skill/SKILL.md +3 -1
  33. package/lib/skill/references/cli.md +82 -3
  34. package/lib/skill/references/collaboration.md +146 -73
  35. package/lib/skill/references/discovery.md +29 -0
  36. package/lib/skill/references/intake.md +107 -0
  37. package/lib/skill/references/router.md +96 -0
  38. package/lib/skill/references/setup.md +2 -1
  39. package/lib/task-auth.js +483 -0
  40. package/lib/task-ledger.js +324 -12
  41. package/lib/team-store.js +24 -1
  42. package/lib/tools/act.js +383 -0
  43. package/lib/tools/files.js +201 -0
  44. package/lib/tools/info.js +684 -0
  45. package/lib/types/a2a-client.d.ts +10 -4
  46. package/lib/types/auth-observer.d.ts +110 -0
  47. package/lib/types/blob-store.d.ts +83 -0
  48. package/lib/types/cli-deposit.d.ts +38 -0
  49. package/lib/types/faces/admin.d.ts +14 -2
  50. package/lib/types/faces/canvas-notify.d.ts +4 -1
  51. package/lib/types/faces/files.d.ts +53 -0
  52. package/lib/types/faces/nodes.d.ts +69 -0
  53. package/lib/types/faces/session-log.d.ts +44 -0
  54. package/lib/types/faces/wire.d.ts +50 -5
  55. package/lib/types/index.d.ts +27 -0
  56. package/lib/types/joined-store.d.ts +9 -0
  57. package/lib/types/ledger-conservation.d.ts +77 -0
  58. package/lib/types/legacy/teams-bridge-inbound.d.ts +94 -0
  59. package/lib/types/node-bind.d.ts +220 -0
  60. package/lib/types/node-github.d.ts +35 -0
  61. package/lib/types/node-self.d.ts +178 -0
  62. package/lib/types/node-store.d.ts +180 -0
  63. package/lib/types/node-tasks.d.ts +121 -0
  64. package/lib/types/origin-claim.d.ts +119 -0
  65. package/lib/types/peer-directory.d.ts +39 -0
  66. package/lib/types/receipt.d.ts +12 -0
  67. package/lib/types/task-auth.d.ts +271 -0
  68. package/lib/types/task-ledger.d.ts +143 -6
  69. package/lib/types/team-store.d.ts +9 -0
  70. package/lib/types/tools/act.d.ts +52 -0
  71. package/lib/types/tools/files.d.ts +38 -0
  72. package/lib/types/tools/info.d.ts +102 -0
  73. package/lib/types/types.d.ts +6 -0
  74. package/nexus-stage/src/canvas-ops.ts +17 -2
  75. package/nexus-stage/src/canvas-wire.ts +27 -19
  76. package/nexus-stage/src/federation.ts +1 -0
  77. package/nexus-stage/src/main.ts +41 -18
  78. package/nexus-stage/src/planning-member-handles.ts +34 -0
  79. package/nexus-stage/src/planning-mirror.ts +47 -3
  80. package/nexus-stage/src/planning-reachability.ts +28 -0
  81. package/nexus-stage/src/planning-types.ts +1 -1
  82. package/nexus-stage/src/planning-view.ts +30 -27
  83. package/nexus-stage/src/planning.css +4 -0
  84. package/nexus-stage/src/wb-directory.ts +13 -0
  85. package/nexus-stage/src/workbench.css +3 -0
  86. package/nexus-stage/src/workbench.ts +5 -1
  87. package/package.json +1 -1
  88. package/assets/nexusDist/assets/index-C_x3iAth.js +0 -9
  89. package/assets/nexusDist/assets/index-Dz8N80tI.css +0 -1
@@ -18,9 +18,11 @@
18
18
  * illusion: settlements vanished while debts piled up).
19
19
  * @module @nelsonlongxiang/dsh-open-a2a-net/task-ledger
20
20
  */
21
+ import { parseAuthorization } from "./task-auth.js";
21
22
  import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
22
23
  import { dirname } from 'node:path';
23
24
  import { parseReceipt, RECEIPT_OUTCOMES } from "./receipt.js";
25
+ import { classifyConservationBlock, ConservationCounters } from "./ledger-conservation.js";
24
26
  /**
25
27
  * Caller-ended summaries (abandon / cancel) share one isolation law: late
26
28
  * receipts never rewrite the verdict — they only stamp lateReceiptAt, and
@@ -63,6 +65,96 @@ export const TASK_STALE_TTL_MS = 24 * 60 * 60_000;
63
65
  const ARCHIVE_ENDS = ['receipt', 'echo', 'abandoned', 'cancelled', 'evicted'];
64
66
  /** Summary marker {@link TaskLedger.resolveEcho} writes on echo-settled rows. */
65
67
  const ECHO_SUMMARY = '(settled by content echo)';
68
+ const SENDER_KINDS = ['session', 'host', 'bridge'];
69
+ /** Longest sender id kept; anything longer is malformed, not truncated. */
70
+ export const SENDER_ID_CAP = 256;
71
+ /**
72
+ * Restore-side allowlist for one persisted sender: a well-formed value comes
73
+ * back as a fresh object holding only kind, id and (when present) face; anything else is dropped whole (the row
74
+ * itself survives — a bad sender never costs the debt record).
75
+ * @param raw - the persisted value.
76
+ * @returns the sender, or undefined when absent or malformed.
77
+ */
78
+ export function parseSender(raw) {
79
+ if (raw === null || typeof raw !== 'object' || Array.isArray(raw))
80
+ return undefined;
81
+ const { kind, id, face } = raw;
82
+ if (typeof kind !== 'string' || !SENDER_KINDS.includes(kind))
83
+ return undefined;
84
+ if (typeof id !== 'string' || id === '' || id.length > SENDER_ID_CAP)
85
+ return undefined;
86
+ if (face !== undefined && (typeof face !== 'string' || face === '' || face.length > SENDER_ID_CAP))
87
+ return undefined;
88
+ // `face` belongs to host senders only; on any other kind it is dropped, the sender kept.
89
+ return { kind: kind, id, ...(face !== undefined && kind === 'host' ? { face } : {}) };
90
+ }
91
+ /**
92
+ * Spread helper for restore: `{ sender }` when the persisted value parses and
93
+ * the row is not inbound (an inbound row's originator is its caller, never a
94
+ * sender this host recorded), else nothing.
95
+ */
96
+ function withSender(raw, direction) {
97
+ if (direction === 'inbound')
98
+ return {};
99
+ const sender = parseSender(raw);
100
+ return sender === undefined ? {} : { sender };
101
+ }
102
+ /**
103
+ * #114 S2: `{ auth }` when a persisted INBOUND row carries a record that
104
+ * parses whole (parseAuthorization) and belongs to THIS row — its target is
105
+ * the row's team and its receipt target the row's peer — else nothing: the
106
+ * row survives as a pre-S2 row, never with a partially adopted or borrowed
107
+ * record.
108
+ */
109
+ function withAuth(raw, entry, now) {
110
+ if (entry.direction !== 'inbound' && entry.direction !== 'outbound')
111
+ return {};
112
+ const auth = parseAuthorization(raw, now);
113
+ if (auth === undefined || auth.target !== entry.team)
114
+ return {};
115
+ if (entry.direction === 'inbound')
116
+ return auth.ingress === 'wire' && `local:${auth.receipt_target}` === entry.peer ? { auth } : {};
117
+ // #139: an outbound row carries only its local-candidate admission.
118
+ return auth.ingress === 'local' && entry.peer === 'local' ? { auth } : {};
119
+ }
120
+ /** #139: the routing facts settlement reads, copied between a row and its archive entry. */
121
+ function routingOf(row) {
122
+ return {
123
+ ...(row.peerKey !== undefined ? { peerKey: row.peerKey } : {}),
124
+ ...(row.peerHost !== undefined ? { peerHost: row.peerHost } : {}),
125
+ ...(row.deliveredTo !== undefined ? { deliveredTo: row.deliveredTo } : {}),
126
+ };
127
+ }
128
+ /** The facts of one outbound birth that fit the row (see {@link withRouting} for the same shape on restore). */
129
+ function birthFacts(peer, birth) {
130
+ if (birth === undefined)
131
+ return {};
132
+ const fact = (v) => v !== undefined && v !== '' && v.length <= ROUTING_FACT_CAP;
133
+ const remote = peer !== 'local' && !peer.startsWith('local:');
134
+ return {
135
+ ...(!remote && birth.auth !== undefined && birth.auth.ingress === 'local' ? { auth: birth.auth } : {}),
136
+ ...(remote && fact(birth.peerKey) ? { peerKey: birth.peerKey } : {}),
137
+ ...(remote && fact(birth.peerHost) ? { peerHost: birth.peerHost } : {}),
138
+ ...(fact(birth.deliveredTo) ? { deliveredTo: birth.deliveredTo } : {}),
139
+ };
140
+ }
141
+ /** Bound on a persisted routing fact; anything longer (or empty, or not a string) is dropped on restore. */
142
+ const ROUTING_FACT_CAP = 256;
143
+ /**
144
+ * #139: restore-side allowlist for the routing facts. Each is kept only in
145
+ * its shape — a non-empty string within the cap — and the peer facts only on
146
+ * a remote outbound row; anything else is dropped (the row survives as one
147
+ * born before the fields, judged by current membership and origin).
148
+ */
149
+ function withRouting(entry) {
150
+ const fact = (v) => typeof v === 'string' && v !== '' && v.length <= ROUTING_FACT_CAP;
151
+ const remoteOutbound = entry.direction === 'outbound' && typeof entry.peer === 'string' && entry.peer !== 'local' && !entry.peer.startsWith('local:');
152
+ return {
153
+ ...(remoteOutbound && fact(entry.peerKey) ? { peerKey: entry.peerKey } : {}),
154
+ ...(remoteOutbound && fact(entry.peerHost) ? { peerHost: entry.peerHost } : {}),
155
+ ...(fact(entry.deliveredTo) ? { deliveredTo: entry.deliveredTo } : {}),
156
+ };
157
+ }
66
158
  /**
67
159
  * How one archived row ended (gh#31). The recorded field wins; legacy rows
68
160
  * fall back to the summary markers this ledger writes itself. The fallback
@@ -83,6 +175,39 @@ export function archivedEndedBy(row) {
83
175
  return 'echo';
84
176
  return 'receipt';
85
177
  }
178
+ /** Bound on ids and callers kept in settlement records; longer values are cut on write and refused on restore. */
179
+ const SETTLEMENT_ID_CAP = 256;
180
+ /** Bound of the refusal ring (design §4.4). */
181
+ export const SETTLEMENT_REFUSAL_CAP = 64;
182
+ const SETTLEMENT_REASONS = ['settler-off-chain', 'settler-unverified', 'on-behalf-mismatch', 'echo-off-chain'];
183
+ const CHAIN_ROLES = ['target', 'target-member', 'host-face', 'compat', 'legacy', 'off-chain'];
184
+ const PROOF_VALUES = ['origin-claim', 'claim-mismatch', 'legacy-window', 'unbound', 'cli-identity', 'in-process', 'none'];
185
+ /** Restore-side allowlist for settledBy: whole or nothing. */
186
+ function withSettledBy(raw) {
187
+ if (raw === null || typeof raw !== 'object')
188
+ return {};
189
+ const r = raw;
190
+ if (typeof r.caller !== 'string' || r.caller.length > SETTLEMENT_ID_CAP || typeof r.chain_role !== 'string' || !CHAIN_ROLES.includes(r.chain_role))
191
+ return {};
192
+ return { settledBy: { caller: r.caller, chain_role: r.chain_role } };
193
+ }
194
+ /** Restore-side allowlist for one refusal record: whole or nothing. */
195
+ function parseRefusal(raw) {
196
+ if (raw === null || typeof raw !== 'object')
197
+ return undefined;
198
+ const r = raw;
199
+ const st = r.settler;
200
+ if (typeof r.taskId !== 'string' || r.taskId === '' || r.taskId.length > SETTLEMENT_ID_CAP || typeof r.at !== 'number' || !Number.isFinite(r.at))
201
+ return undefined;
202
+ if (typeof r.reason !== 'string' || !SETTLEMENT_REASONS.includes(r.reason) || typeof r.head !== 'string' || typeof r.enforced !== 'boolean')
203
+ return undefined;
204
+ if (st === null || typeof st !== 'object' || typeof st.caller !== 'string' || st.caller.length > SETTLEMENT_ID_CAP || typeof st.proof !== 'string' || !PROOF_VALUES.includes(st.proof) || (st.ingress !== 'wire' && st.ingress !== 'local'))
205
+ return undefined;
206
+ const count = r.count === undefined ? 1 : r.count;
207
+ if (typeof count !== 'number' || !Number.isInteger(count) || count < 1)
208
+ return undefined;
209
+ return { taskId: r.taskId, at: r.at, settler: { caller: st.caller, proof: st.proof, ingress: st.ingress }, reason: r.reason, head: r.head.slice(0, SUMMARY_CAP), enforced: r.enforced, count };
210
+ }
86
211
  /**
87
212
  * Bounded outbound-task ledger with whole-file persistence. Pure of any
88
213
  * routing knowledge: callers {@link track} a task when a route leaves it
@@ -95,6 +220,12 @@ export class TaskLedger {
95
220
  tasks = [];
96
221
  /** Settled rows, most recently resolved first; bounded by the archive cap. */
97
222
  archived = [];
223
+ /** #114 S4: refused / would-be-refused settlements, newest first. */
224
+ refusals = [];
225
+ /** #114 S4: every refusal ever noted (monotonic; survives the ring's bound). */
226
+ refusalsTotal = 0;
227
+ /** gh#154: lifetime conservation counters (persisted; never reset by cap trims or restarts) and the durable damage marker — the machinery lives in src/ledger-conservation.ts (R-mod); the ledger's seams delegate. */
228
+ counters = new ConservationCounters();
98
229
  staleTtlMs;
99
230
  archiveCap;
100
231
  /** Injectable clock (option `now`). */
@@ -147,7 +278,7 @@ export class TaskLedger {
147
278
  * via context_id follow-ups (the dispatch reply already says so).
148
279
  * Idempotent by task id like {@link track}.
149
280
  */
150
- trackReceiptless(taskId, team, peer, contextId, brief) {
281
+ trackReceiptless(taskId, team, peer, contextId, brief, sender, birth) {
151
282
  if (taskId === '')
152
283
  return;
153
284
  this.sweep(this.now());
@@ -155,6 +286,7 @@ export class TaskLedger {
155
286
  return;
156
287
  if (this.archived.some(entry => entry.taskId === taskId))
157
288
  return;
289
+ this.counters.trackedTotal += 1;
158
290
  this.tasks = [{
159
291
  taskId,
160
292
  team,
@@ -164,12 +296,14 @@ export class TaskLedger {
164
296
  receiptExpected: false,
165
297
  ...(contextId !== undefined && contextId !== '' ? { contextId } : {}),
166
298
  ...(brief !== undefined && brief !== '' ? { brief } : {}),
299
+ ...(sender !== undefined ? { sender } : {}),
300
+ ...birthFacts(peer, birth),
167
301
  status: 'pending',
168
302
  }, ...this.tasks];
169
303
  this.trimOwed();
170
304
  this.persist();
171
305
  }
172
- track(taskId, team, peer, contextId, brief) {
306
+ track(taskId, team, peer, contextId, brief, sender, birth) {
173
307
  if (taskId === '')
174
308
  return;
175
309
  this.sweep(this.now());
@@ -177,6 +311,7 @@ export class TaskLedger {
177
311
  return;
178
312
  if (this.archived.some(entry => entry.taskId === taskId))
179
313
  return;
314
+ this.counters.trackedTotal += 1;
180
315
  this.tasks = [{
181
316
  taskId,
182
317
  team,
@@ -185,6 +320,8 @@ export class TaskLedger {
185
320
  direction: 'outbound',
186
321
  ...(contextId !== undefined && contextId !== '' ? { contextId } : {}),
187
322
  ...(brief !== undefined && brief !== '' ? { brief } : {}),
323
+ ...(sender !== undefined ? { sender } : {}),
324
+ ...birthFacts(peer, birth),
188
325
  status: 'pending',
189
326
  }, ...this.tasks];
190
327
  this.trimOwed();
@@ -203,7 +340,7 @@ export class TaskLedger {
203
340
  * @param from - the caller's receipt target (label or callback address).
204
341
  * @param contextId - the delivery's conversation id, kept for follow-up routes (empty omits it).
205
342
  */
206
- trackInbound(taskId, team, from, contextId, receiptExpected, brief) {
343
+ trackInbound(taskId, team, from, contextId, receiptExpected, brief, auth, deliveredTo) {
207
344
  if (taskId === '')
208
345
  return;
209
346
  this.sweep(this.now());
@@ -211,6 +348,7 @@ export class TaskLedger {
211
348
  return;
212
349
  if (this.archived.some(entry => entry.taskId === taskId))
213
350
  return;
351
+ this.counters.trackedTotal += 1;
214
352
  this.tasks = [{
215
353
  taskId,
216
354
  team,
@@ -220,6 +358,9 @@ export class TaskLedger {
220
358
  ...(receiptExpected === false ? { receiptExpected: false } : {}),
221
359
  ...(contextId !== undefined && contextId !== '' ? { contextId } : {}),
222
360
  ...(brief !== undefined && brief !== '' ? { brief } : {}),
361
+ // #114 S2: frozen at birth; a replayed id never reaches here (first record wins above).
362
+ ...(auth !== undefined ? { auth } : {}),
363
+ ...(deliveredTo !== undefined && deliveredTo !== '' ? { deliveredTo: deliveredTo.slice(0, ROUTING_FACT_CAP) } : {}),
223
364
  status: 'pending',
224
365
  }, ...this.tasks];
225
366
  this.trimOwed();
@@ -276,6 +417,9 @@ export class TaskLedger {
276
417
  endedBy: 'abandoned',
277
418
  ...(record.direction !== undefined ? { direction: record.direction } : {}),
278
419
  ...(record.receiptExpected === false ? { receiptExpected: false } : {}),
420
+ ...(record.sender !== undefined ? { sender: record.sender } : {}),
421
+ ...(record.auth !== undefined ? { auth: record.auth } : {}),
422
+ ...routingOf(record),
279
423
  });
280
424
  this.persist();
281
425
  return { outcome: 'cleared', taskId, archivedAt: resolvedAt };
@@ -318,6 +462,9 @@ export class TaskLedger {
318
462
  endedBy: 'cancelled',
319
463
  ...(record.direction !== undefined ? { direction: record.direction } : {}),
320
464
  ...(record.receiptExpected === false ? { receiptExpected: false } : {}),
465
+ ...(record.sender !== undefined ? { sender: record.sender } : {}),
466
+ ...(record.auth !== undefined ? { auth: record.auth } : {}),
467
+ ...routingOf(record),
321
468
  });
322
469
  this.persist();
323
470
  return {
@@ -340,10 +487,12 @@ export class TaskLedger {
340
487
  * record with the latest outcome. A dead-lettered row revives through this
341
488
  * same path when a revived target finally answers.
342
489
  * @param message - the inbound or relayed message text.
490
+ * @param only - gh#141: settle the named row only when it passes this test;
491
+ * with a test, a row that fails it or is already archived correlates nothing.
343
492
  * @returns what the receipt settled, or `undefined` when the message
344
493
  * correlated nothing.
345
494
  */
346
- resolveFromMessage(message) {
495
+ resolveFromMessage(message, judge, only) {
347
496
  const parsed = parseReceipt(message);
348
497
  if (parsed === null)
349
498
  return undefined;
@@ -353,7 +502,18 @@ export class TaskLedger {
353
502
  const elapsedMs = parsed.envelope?.elapsedMs;
354
503
  this.sweep(this.now());
355
504
  const record = this.tasks.find(entry => entry.taskId === taskId);
505
+ if (only !== undefined && (record === undefined || !only(record)))
506
+ return undefined;
356
507
  if (record !== undefined) {
508
+ // #114 S4: judge the settler before the row moves; enforce keeps a refused row owed.
509
+ const verdict = judge?.check(record, false);
510
+ if (verdict !== undefined && !verdict.ok) {
511
+ this.noteRefusal(taskId, judge, verdict.reason, message);
512
+ if (judge.enforce) {
513
+ this.persist();
514
+ return undefined;
515
+ }
516
+ }
357
517
  this.tasks = this.tasks.filter(entry => entry.taskId !== taskId);
358
518
  this.archiveSettled({
359
519
  taskId: record.taskId,
@@ -369,6 +529,10 @@ export class TaskLedger {
369
529
  endedBy: 'receipt',
370
530
  ...(record.direction !== undefined ? { direction: record.direction } : {}),
371
531
  ...(record.receiptExpected === false ? { receiptExpected: false } : {}),
532
+ ...(record.sender !== undefined ? { sender: record.sender } : {}),
533
+ ...(record.auth !== undefined ? { auth: record.auth } : {}),
534
+ ...routingOf(record),
535
+ ...(verdict !== undefined ? { settledBy: { caller: judge.settler.caller.slice(0, SETTLEMENT_ID_CAP), chain_role: verdict.ok ? verdict.role : 'off-chain' } } : {}),
372
536
  });
373
537
  this.persist();
374
538
  return {
@@ -385,6 +549,18 @@ export class TaskLedger {
385
549
  const settled = this.archived.find(entry => entry.taskId === taskId);
386
550
  if (settled === undefined)
387
551
  return undefined;
552
+ // #114 S4 (review of PR #140 P2-3): a late receipt is judged too. Under
553
+ // enforce, an off-chain one only marks that an answer came late — it
554
+ // never rewrites the settled summary or outcome.
555
+ const lateVerdict = judge?.check(settled, false);
556
+ if (lateVerdict !== undefined && !lateVerdict.ok) {
557
+ this.noteRefusal(taskId, judge, lateVerdict.reason, message);
558
+ if (judge.enforce) {
559
+ this.archived = [{ ...settled, lateReceiptAt: this.now() }, ...this.archived.filter(entry => entry.taskId !== taskId)];
560
+ this.persist();
561
+ return undefined;
562
+ }
563
+ }
388
564
  // Orphan isolation: a receipt for an explicitly abandoned row must not
389
565
  // rewrite the abandonment outcome — the caller stopped waiting by choice.
390
566
  // Record that the target did answer later; keep the decision verbatim and
@@ -432,14 +608,23 @@ export class TaskLedger {
432
608
  * @param message - the inbound message text.
433
609
  * @returns the task ids this message settled (usually none or one).
434
610
  */
435
- resolveEcho(message) {
611
+ resolveEcho(message, judge) {
436
612
  const settled = [];
613
+ let refused = false;
437
614
  this.sweep(this.now());
438
615
  for (const record of [...this.tasks]) {
439
616
  if (record.status !== 'pending')
440
617
  continue;
441
618
  if (!message.includes(`task ${record.taskId}`))
442
619
  continue;
620
+ // #114 S4: an echo is weak evidence — only an on-chain settler's echo counts under enforce.
621
+ const verdict = judge?.check(record, true);
622
+ if (verdict !== undefined && !verdict.ok) {
623
+ this.noteRefusal(record.taskId, judge, verdict.reason, message);
624
+ refused = true;
625
+ if (judge.enforce)
626
+ continue;
627
+ }
443
628
  this.tasks = this.tasks.filter(entry => entry.taskId !== record.taskId);
444
629
  this.archiveSettled({
445
630
  taskId: record.taskId,
@@ -452,16 +637,72 @@ export class TaskLedger {
452
637
  endedBy: 'echo',
453
638
  ...(record.direction !== undefined ? { direction: record.direction } : {}),
454
639
  ...(record.receiptExpected === false ? { receiptExpected: false } : {}),
640
+ ...(record.sender !== undefined ? { sender: record.sender } : {}),
641
+ ...(record.auth !== undefined ? { auth: record.auth } : {}),
642
+ ...routingOf(record),
643
+ ...(verdict !== undefined ? { settledBy: { caller: judge.settler.caller.slice(0, SETTLEMENT_ID_CAP), chain_role: verdict.ok ? verdict.role : 'off-chain' } } : {}),
455
644
  });
456
645
  settled.push(record.taskId);
457
646
  }
458
- if (settled.length > 0)
647
+ if (settled.length > 0 || refused)
459
648
  this.persist();
460
649
  return settled;
461
650
  }
462
- /** Move one settled record to the archive head, trimming at the cap. */
651
+ /** #114 S4: the owed row for a task id, if any (read-only; for the settlement gate). */
652
+ find(taskId) {
653
+ return this.tasks.find(entry => entry.taskId === taskId);
654
+ }
655
+ /** #114 S4: refused / would-be-refused settlements, newest first. */
656
+ settlementRefusals() {
657
+ return [...this.refusals];
658
+ }
659
+ /** #114 S4: every refusal ever noted, including those folded or pushed out of the ring. */
660
+ settlementRefusalsTotal() {
661
+ return this.refusalsTotal;
662
+ }
663
+ /**
664
+ * Record one refused / would-be-refused settlement in the bounded ring
665
+ * (persisted by the caller). A repeat of the same (task, settler, reason)
666
+ * folds into its entry and moves it to the head, so one row's flood cannot
667
+ * push other evidence out.
668
+ */
669
+ noteRefusal(taskId, judge, reason, message) {
670
+ const head = (message.split('\n')[0] ?? '').slice(0, SUMMARY_CAP);
671
+ const id = taskId.slice(0, SETTLEMENT_ID_CAP);
672
+ const settler = { ...judge.settler, caller: judge.settler.caller.slice(0, SETTLEMENT_ID_CAP) };
673
+ const same = (r) => r.taskId === id && r.settler.caller === settler.caller && r.reason === reason && r.enforced === judge.enforce;
674
+ const prior = this.refusals.find(same);
675
+ this.refusalsTotal += 1;
676
+ this.refusals = [{ taskId: id, at: this.now(), settler, reason, head, enforced: judge.enforce, count: (prior?.count ?? 0) + 1 }, ...this.refusals.filter(r => !same(r))].slice(0, SETTLEMENT_REFUSAL_CAP);
677
+ }
678
+ /**
679
+ * Move one settled record to the archive head, trimming at the cap.
680
+ * gh#154: every arrival and every cap-trimmed row is counted — the trim
681
+ * itself stays exactly as it was, only visible now.
682
+ */
463
683
  archiveSettled(record) {
464
- this.archived = [record, ...this.archived].slice(0, this.archiveCap);
684
+ const next = [record, ...this.archived];
685
+ const kept = next.slice(0, this.archiveCap);
686
+ this.counters.archiveTrimmed += next.length - kept.length;
687
+ if (record.endedBy !== undefined)
688
+ this.counters.archivedByEnd[record.endedBy] += 1;
689
+ this.archived = kept;
690
+ }
691
+ /**
692
+ * gh#154 conservation verdict — duplicates first, then the count identity
693
+ * (a duplicated row can still make the counts balance; the Harness²
694
+ * assert_identity lesson). The counters module answers the count half.
695
+ */
696
+ conservation() {
697
+ const seen = new Set();
698
+ const duplicateTaskIds = [];
699
+ for (const row of [...this.tasks, ...this.archived]) {
700
+ if (seen.has(row.taskId))
701
+ duplicateTaskIds.push(row.taskId);
702
+ else
703
+ seen.add(row.taskId);
704
+ }
705
+ return this.counters.verdict(this.tasks.length, this.archived.length, duplicateTaskIds);
465
706
  }
466
707
  /**
467
708
  * gh#86 (chief order direct-4f596524): when the owed book exceeds
@@ -487,6 +728,9 @@ export class TaskLedger {
487
728
  ...(row.brief !== undefined ? { brief: row.brief } : {}),
488
729
  endedBy: 'evicted',
489
730
  ...(row.direction !== undefined ? { direction: row.direction } : {}),
731
+ ...(row.sender !== undefined ? { sender: row.sender } : {}),
732
+ ...(row.auth !== undefined ? { auth: row.auth } : {}),
733
+ ...routingOf(row),
490
734
  });
491
735
  }
492
736
  }
@@ -514,15 +758,33 @@ export class TaskLedger {
514
758
  return;
515
759
  try {
516
760
  const snapshot = JSON.parse(readFileSync(this.path, 'utf8'));
517
- const entries = snapshot?.tasks;
761
+ // gh#154 review r3: a non-object OR ARRAY top level, and a missing or
762
+ // non-array required `tasks` member, are malformed snapshots — never a
763
+ // healthy empty ledger (an array is typeof 'object'; absence of the
764
+ // one required member is damage, not an empty legacy snapshot).
765
+ if (snapshot === null || typeof snapshot !== 'object' || Array.isArray(snapshot) || !Array.isArray(snapshot.tasks)) {
766
+ this.counters.damaged = 'snapshot-shape-invalid';
767
+ return;
768
+ }
769
+ // The optional archive array: present-but-not-an-array stamps damage —
770
+ // but the REQUIRED tasks rows still load (review r4 P1: an early return
771
+ // here would drop parseable owed rows wholesale on the next persist).
772
+ if (snapshot.archived !== undefined && !Array.isArray(snapshot.archived)) {
773
+ this.counters.damaged = 'snapshot-shape-invalid';
774
+ }
775
+ const entries = snapshot.tasks;
518
776
  const restored = [];
519
777
  if (Array.isArray(entries)) {
520
778
  for (const raw of entries) {
521
779
  // Unparsed on purpose: the legacy shape carries a 'resolved' status
522
780
  // and inline outcome fields that TaskRecord no longer declares.
523
781
  const entry = raw;
524
- if (typeof entry?.taskId !== 'string' || entry.taskId === '')
782
+ if (typeof entry?.taskId !== 'string' || entry.taskId === '') {
783
+ // gh#154 review r3: a malformed row never vanishes into a balanced
784
+ // baseline — the parseable rows still load, the verdict refuses.
785
+ this.counters.damaged = this.counters.damaged ?? 'row-shape-invalid';
525
786
  continue;
787
+ }
526
788
  // Legacy two-state files carried resolved rows inline; they migrate
527
789
  // into the archive so pre-upgrade outcomes survive the upgrade.
528
790
  if (entry.status === 'resolved') {
@@ -540,6 +802,10 @@ export class TaskLedger {
540
802
  ...(typeof entry.endedBy === 'string' && ARCHIVE_ENDS.includes(entry.endedBy) ? { endedBy: entry.endedBy } : {}),
541
803
  ...(entry.direction === 'inbound' || entry.direction === 'outbound' ? { direction: entry.direction } : {}),
542
804
  ...(entry.receiptExpected === false ? { receiptExpected: false } : {}),
805
+ ...withSender(entry.sender, entry.direction),
806
+ ...withAuth(entry.auth, entry, this.now()),
807
+ ...withRouting(entry),
808
+ ...withSettledBy(entry.settledBy),
543
809
  });
544
810
  continue;
545
811
  }
@@ -554,6 +820,10 @@ export class TaskLedger {
554
820
  // it is unknown, so a restored outbound row must not lose it.
555
821
  ...(entry.direction === 'inbound' || entry.direction === 'outbound' ? { direction: entry.direction } : {}),
556
822
  ...(entry.receiptExpected === false ? { receiptExpected: false } : {}),
823
+ // #114 S3: a malformed sender is dropped whole; the row survives.
824
+ ...withSender(entry.sender, entry.direction),
825
+ ...withAuth(entry.auth, entry, this.now()),
826
+ ...withRouting(entry),
557
827
  status: entry.status === 'dead' ? 'dead' : 'pending',
558
828
  ...(entry.status === 'dead'
559
829
  ? {
@@ -568,8 +838,11 @@ export class TaskLedger {
568
838
  if (Array.isArray(legacyArchive)) {
569
839
  for (const raw of legacyArchive) {
570
840
  const entry = raw;
571
- if (typeof entry?.taskId !== 'string' || entry.taskId === '')
841
+ if (typeof entry?.taskId !== 'string' || entry.taskId === '') {
842
+ // gh#154 review r3: same refusal for a malformed archived row.
843
+ this.counters.damaged = this.counters.damaged ?? 'row-shape-invalid';
572
844
  continue;
845
+ }
573
846
  if (this.archived.some(existing => existing.taskId === entry.taskId))
574
847
  continue;
575
848
  this.archived.push({
@@ -586,15 +859,48 @@ export class TaskLedger {
586
859
  ...(typeof entry.endedBy === 'string' && ARCHIVE_ENDS.includes(entry.endedBy) ? { endedBy: entry.endedBy } : {}),
587
860
  ...(entry.direction === 'inbound' || entry.direction === 'outbound' ? { direction: entry.direction } : {}),
588
861
  ...(entry.receiptExpected === false ? { receiptExpected: false } : {}),
862
+ ...withSender(entry.sender, entry.direction),
863
+ ...withAuth(entry.auth, entry, this.now()),
864
+ ...withRouting(entry),
865
+ ...withSettledBy(entry.settledBy),
589
866
  });
590
867
  }
591
868
  }
869
+ // gh#154: the load path's cap cuts are COUNTED, never silent. Rows this
870
+ // snapshot carried beyond the caps leave the live view but remain in the
871
+ // identity through restoreTrimmed.
592
872
  this.tasks = restored.slice(0, TASK_CAP);
873
+ this.counters.restoreTrimmed += Math.max(0, restored.length - this.tasks.length);
874
+ this.counters.restoreTrimmed += Math.max(0, this.archived.length - this.archiveCap);
593
875
  this.archived = this.archived.slice(0, this.archiveCap);
876
+ // gh#154: restore the lifetime counters through the conservation
877
+ // module's classifier - absent block = pre-#154 legacy bootstrap
878
+ // (kept rows + this load's cuts, endedBy via the legacy classifier);
879
+ // present-but-invalid (partial/negative/fractional/null body/
880
+ // unbounded trim) = damage: counters zero, verdict refuses conserved;
881
+ // valid = counters as persisted, a persisted damage marker riding
882
+ // along (review r2: it outlives the restart).
883
+ const classified = classifyConservationBlock(snapshot.conservation);
884
+ if (classified.kind === 'legacy') {
885
+ this.counters.legacyBootstrap(this.tasks.length, this.archived, this.counters.restoreTrimmed, archivedEndedBy);
886
+ }
887
+ else if (classified.kind === 'valid') {
888
+ this.counters.loadFrom(classified.snapshot, this.tasks.length, this.archived.length);
889
+ }
890
+ else {
891
+ this.counters.damaged = classified.reason;
892
+ }
893
+ const refusals = snapshot?.settlementRefusals;
894
+ if (Array.isArray(refusals))
895
+ this.refusals = refusals.map(parseRefusal).filter((r) => r !== undefined).slice(0, SETTLEMENT_REFUSAL_CAP);
896
+ const total = snapshot?.settlementRefusalsTotal;
897
+ this.refusalsTotal = Math.max(typeof total === 'number' && Number.isInteger(total) && total >= 0 ? total : 0, this.refusals.reduce((n, r) => n + r.count, 0));
594
898
  }
595
899
  catch {
596
900
  // A corrupt ledger is not fatal: routing never depends on it; the
597
- // reconciliation simply starts fresh.
901
+ // reconciliation simply starts fresh. gh#154 review P1: a fresh start
902
+ // is NOT a conserved state — the unreadable history may have held work.
903
+ this.counters.damaged = 'snapshot-unreadable';
598
904
  }
599
905
  }
600
906
  /** Persist the ledger (no-op when no path was configured). */
@@ -606,6 +912,12 @@ export class TaskLedger {
606
912
  const snapshot = {
607
913
  tasks: this.tasks.map(task => ({ ...task })),
608
914
  ...(this.archived.length > 0 ? { archived: this.archived.map(entry => ({ ...entry })) } : {}),
915
+ ...(this.refusals.length > 0 ? { settlementRefusals: this.refusals.map(entry => ({ ...entry, settler: { ...entry.settler } })) } : {}),
916
+ ...(this.refusalsTotal > 0 ? { settlementRefusalsTotal: this.refusalsTotal } : {}),
917
+ // gh#154: the conservation counters ride every snapshot once any work
918
+ // has been tracked (zero-work ledgers stay byte-identical to before).
919
+ // A damage marker rides too — review r2: it must survive restarts.
920
+ ...(this.counters.trackedTotal > 0 || this.counters.damaged !== undefined ? { conservation: this.counters.toSnapshot() } : {}),
609
921
  };
610
922
  writeFileSync(this.path, JSON.stringify(snapshot), { mode: 0o600 });
611
923
  }
package/lib/team-store.js CHANGED
@@ -31,6 +31,7 @@ export class TeamMembershipStore {
31
31
  /** Entries in first-declaration order; teams in join order. */
32
32
  entries = [];
33
33
  path;
34
+ listeners = new Set();
34
35
  /**
35
36
  * @param path - the snapshot file; an empty path keeps the store in memory.
36
37
  */
@@ -98,6 +99,7 @@ export class TeamMembershipStore {
98
99
  return;
99
100
  entry.teams.push(clean);
100
101
  this.persist();
102
+ this.notify();
101
103
  }
102
104
  /** Retract one membership; a no-op when absent. */
103
105
  remove(session, team) {
@@ -110,13 +112,34 @@ export class TeamMembershipStore {
110
112
  if (entry.teams.length === 0)
111
113
  this.entries = this.entries.filter(candidate => candidate.session !== session);
112
114
  this.persist();
115
+ this.notify();
113
116
  }
114
117
  }
115
118
  /** Drop every declaration of one session (a left or archived node). */
116
119
  dropSession(session) {
117
120
  const before = this.entries.length;
118
121
  this.entries = this.entries.filter(entry => entry.session !== session);
119
- if (this.entries.length !== before)
122
+ if (this.entries.length !== before) {
120
123
  this.persist();
124
+ this.notify();
125
+ }
126
+ }
127
+ /**
128
+ * #114 S6: observe changes — the listener runs after every write that
129
+ * changed the store (never on restore or reload). A throwing listener
130
+ * cannot break the write.
131
+ * @returns the unsubscribe function.
132
+ */
133
+ onChange(listener) {
134
+ this.listeners.add(listener);
135
+ return () => { this.listeners.delete(listener); };
136
+ }
137
+ notify() {
138
+ for (const listener of this.listeners) {
139
+ try {
140
+ listener();
141
+ }
142
+ catch { /* a listener's failure is its own */ }
143
+ }
121
144
  }
122
145
  }