@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
package/lib/faces/wire.js CHANGED
@@ -3,14 +3,78 @@ import { TRANSCRIPT_DEFAULT_LIMIT, TRANSCRIPT_MAX_LIMIT } from "../session-trans
3
3
  import { refuseIfBudgetExhausted } from "../recursion-guard.js";
4
4
  import { peerPayloadFingerprint, WIRE_ERROR_IDEMPOTENCY_CONFLICT, WIRE_ERROR_REPLAY_REJECTED } from "../idempotency-store.js";
5
5
  import { briefOf } from "../task-ledger.js";
6
+ import { parseReceipt, taskIdRefusal } from "../receipt.js";
7
+ import { buildAuthorization, claimCovers, refusalOf } from "../task-auth.js";
8
+ import { isRootOriginUrl } from "../origin-claim.js";
6
9
  import { WIRE_ERROR_PAYLOAD_TOO_LARGE, withinRouteBodyCap } from "../transport-caps.js";
10
+ /**
11
+ * #139 (design §4.4): add `settlement: {status:'refused', reason, task_id}`
12
+ * to whatever JSON object this response ends with. The delivery face has
13
+ * many exits (mailbox, no-wait, waiting, refusals); deferring the head until
14
+ * the body is known keeps one rule for all of them. A non-JSON or non-object
15
+ * body is sent unchanged.
16
+ */
17
+ export function withSettlementField(res, settlement) {
18
+ const writeHead = res.writeHead.bind(res);
19
+ const end = res.end.bind(res);
20
+ let head;
21
+ res.writeHead = (status, headers) => {
22
+ head = { status, headers: { ...(headers ?? {}) } };
23
+ return res;
24
+ };
25
+ res.end = (body) => {
26
+ let out = body;
27
+ if (typeof body === 'string') {
28
+ try {
29
+ const parsed = JSON.parse(body);
30
+ if (parsed !== null && typeof parsed === 'object' && !Array.isArray(parsed))
31
+ out = JSON.stringify({ ...parsed, settlement });
32
+ }
33
+ catch { /* not JSON: unchanged */ }
34
+ }
35
+ if (head !== undefined) {
36
+ if (out !== undefined && 'Content-Length' in head.headers)
37
+ head.headers['Content-Length'] = Buffer.byteLength(out);
38
+ writeHead(head.status, head.headers);
39
+ }
40
+ return end(out);
41
+ };
42
+ }
43
+ /** The origin of a card URL for the authorization record; the raw text, bounded, when it does not parse. */
44
+ const originOf = (url) => {
45
+ try {
46
+ return new URL(url).origin;
47
+ }
48
+ catch {
49
+ return url.slice(0, 256);
50
+ }
51
+ };
52
+ /** Same scheme/host/port; false when either side is empty or malformed. */
53
+ function sameOrigin(a, b) {
54
+ try {
55
+ return a !== '' && b !== '' && new URL(a).origin === new URL(b).origin;
56
+ }
57
+ catch {
58
+ return false;
59
+ }
60
+ }
61
+ /**
62
+ * #187: what a verified claim may vouch for at settlement. The signing key always (the card was
63
+ * fetched and its key verified); the ORIGIN only when the claim URL is that origin's root — a card
64
+ * fetched from a path under an origin proves the path's publisher, not the origin's host.
65
+ */
66
+ export function settlerClaimFields(claim, originOf) {
67
+ if (!claim.ok || claim.cli === true || claim.card === undefined || claim.callerUrl === undefined)
68
+ return {};
69
+ return { ...(isRootOriginUrl(claim.callerUrl) ? { claimOrigin: originOf(claim.callerUrl) } : {}), claimKey: claim.card.publicKey };
70
+ }
7
71
  /**
8
72
  * Register the small wire faces on a settled web server.
9
73
  * @param webServer - the settled host web server.
10
74
  * @param d - the captures listed on {@link WireDeps}.
11
75
  */
12
76
  export function registerWireFaces(webServer, d) {
13
- const { ctx, logger, config, cliDeposits, canvasStore, sessionNodes, joinedSessions, idempotencyStore, verifyOriginClaim, callerLinkedToThisHost, recordActivity, secretEqual, a2aCallerRefusal, scanSessionLogList, readSessionLogTail, taskLedger, refuseIfFlooded, settleAndAnnounce, session, resolveAgentForTeam, parseCanvasTeamName, canvasLiveAgent, wakeColdTeam, probeConsumption, steerRelay, fromClause, armReceiptAutosend, receiptExpectationOf, armAsyncNudge, liveAgent, callerIsAgent, selfRouteText, noWakeFaceText, archivedColdMatchText, noColdMatchText, nativeTeamsPrepare, nativeTeamsRound, routeIntoAgent, } = d;
77
+ const { ctx, logger, config, cliDeposits, canvasStore, sessionNodes, joinedSessions, idempotencyStore, verifyOriginClaim, callerLinkedToThisHost, recordActivity, secretEqual, a2aCallerRefusal, a2aCallerAdmission, allowlistVersionNow, observeAuthorization, scanSessionLogList, readSessionLogTail, taskLedger, refuseIfFlooded, settleAndAnnounce, session, resolveAgentForTeam, parseCanvasTeamName, canvasLiveAgent, wakeColdTeam, probeConsumption, sessionTeamOf, localSessionOfHandle, steerRelay, fromClause, sanitizeFromClause, armReceiptAutosend, eventCountOf, receiptExpectationOf, armAsyncNudge, liveAgent, callerIsAgent, selfRouteText, noWakeFaceText, archivedColdMatchText, noColdMatchText, nativeTeamsPrepare, nativeTeamsRound, routeIntoAgent, } = d;
14
78
  // gh#45 CLI 户口卡面 (peer face, unsigned card serve): the deposited
15
79
  // CLI's SIGNED card is served verbatim under its own base URL — an
16
80
  // OriginClaim verification of a CLI envelope fetches exactly here
@@ -41,7 +105,7 @@ export function registerWireFaces(webServer, d) {
41
105
  },
42
106
  }), 'a2a: cli deposit card face');
43
107
  // gh#41 fleet planning, peer-write face: a LINKED peer may regulate this
44
- // host's canvas teams (create / add-member / remove-member) with a
108
+ // host's canvas teams (create / add-member / remove-member / remove) with a
45
109
  // VERIFIED OriginClaim envelope. The owner's ruling: any peer composes
46
110
  // and regulates the whole fleet — the bilateral/operator link IS the
47
111
  // authorization, the envelope proves the key, and every op lands in the
@@ -75,6 +139,16 @@ export function registerWireFaces(webServer, d) {
75
139
  send(200, JSON.stringify({ ok: false, error: `untrusted-origin: OriginClaim verification failed (${claim.reason}) — canvas regulation requires a signed envelope`, code: -32000 }));
76
140
  return;
77
141
  }
142
+ // This host edits its own canvas through the local control, never
143
+ // through this peer face. A claim naming OUR origin is an envelope
144
+ // we stamped on some outbound request (a card fetch to any tracked
145
+ // peer, linked or not) being replayed at us: the claim is stateless
146
+ // and binds no target, so the same-host basis must not admit it here.
147
+ if (sameOrigin(claim.callerUrl, d.ownCardUrl)) {
148
+ recordActivity('in', 'fleet-canvas', claim.caller, false);
149
+ send(200, JSON.stringify({ ok: false, error: 'a host does not regulate its own canvas through the peer face (replayed self envelope refused)' }));
150
+ return;
151
+ }
78
152
  if (!callerLinkedToThisHost(claim.caller, claim.callerUrl, claim.card)) {
79
153
  recordActivity('in', 'fleet-canvas', claim.caller, false);
80
154
  send(200, JSON.stringify({ ok: false, error: `caller "${claim.caller}" is not linked to this host — canvas regulation requires the operator link (bilateral seed, canvas ref, or same-host membership)` }));
@@ -82,23 +156,35 @@ export function registerWireFaces(webServer, d) {
82
156
  }
83
157
  const op = body.op;
84
158
  const name = typeof body.name === 'string' ? body.name : '';
85
- const id = typeof body.id === 'string' ? body.id : '';
159
+ const rawId = typeof body.id === 'string' ? body.id : '';
160
+ // gh#131: a peer names our sessions by handle — the session it names, for removal.
161
+ const id = (isRemoteMemberRef(rawId) ? localSessionOfHandle(rawId) : undefined) ?? rawId;
86
162
  let payload;
87
163
  if (op === 'create') {
88
164
  const stored = canvasStore.create(name);
89
165
  payload = stored === undefined ? { ok: false, error: 'invalid name or team cap reached' } : { ok: true, members: canvasStore.membersOf(stored) };
90
166
  }
91
167
  else if (op === 'add-member') {
92
- const memberValid = isRemoteMemberRef(id) ? validRemoteMemberRef(id) : id !== '' && (sessionNodes.has(id) || joinedSessions.has(id));
168
+ // gh#131: a handle is stored as sent, even one of this host's own — the
169
+ // local tiers read it as the session it names at route time.
170
+ const memberValid = isRemoteMemberRef(rawId) ? validRemoteMemberRef(rawId) : rawId !== '' && (sessionNodes.has(rawId) || joinedSessions.has(rawId));
93
171
  payload = name === '' || !memberValid
94
172
  ? { ok: false, error: 'name and a member (joined session id, or a zone-qualified remote team handle) are required' }
95
- : (canvasStore.create(name), { ok: canvasStore.addMember(name, id), members: canvasStore.membersOf(name) });
173
+ : (canvasStore.create(name), { ok: canvasStore.addMember(name, rawId), members: canvasStore.membersOf(name) });
96
174
  }
97
175
  else if (op === 'remove-member') {
98
- payload = { ok: name !== '' && canvasStore.removeMember(name, id), members: name !== '' ? canvasStore.membersOf(name) : undefined };
176
+ // Either shape may be stored (an older edit kept the handle): remove both (review of PR #151 P2-1).
177
+ const removed = name !== '' && [...new Set([id, rawId])].map(each => canvasStore.removeMember(name, each)).some(Boolean);
178
+ payload = { ok: removed, members: name !== '' ? canvasStore.membersOf(name) : undefined };
179
+ }
180
+ else if (op === 'remove') {
181
+ // Dissolve. Idempotent: a team that is not here (another peer got
182
+ // there first) is the outcome the caller asked for — `removed`
183
+ // says which it was.
184
+ payload = name === '' ? { ok: false, error: 'name is required' } : { ok: true, removed: canvasStore.remove(name) };
99
185
  }
100
186
  else {
101
- payload = { ok: false, error: 'op must be create | add-member | remove-member' };
187
+ payload = { ok: false, error: 'op must be create | add-member | remove-member | remove' };
102
188
  }
103
189
  recordActivity('in', 'fleet-canvas', `${claim.caller}:${String(op)}`, payload.ok);
104
190
  logger.info(`a2a: fleet canvas op from ${claim.caller} (${String(op)} ${name}${id !== '' ? ` ${id}` : ''}) -> ${payload.ok ? 'applied' : `refused: ${payload.error ?? '?'}`}`);
@@ -242,6 +328,8 @@ export function registerWireFaces(webServer, d) {
242
328
  let body;
243
329
  try {
244
330
  body = JSON.parse(Buffer.concat(chunks).toString('utf8'));
331
+ if (body === null || typeof body !== 'object' || Array.isArray(body))
332
+ throw new TypeError('query body must be a JSON object'); // #168: `null` read body.task_id in this 'end' callback → uncaught → host exit, no auth needed
245
333
  }
246
334
  catch {
247
335
  const payload = JSON.stringify({ error: 'malformed body', code: -32000 });
@@ -328,6 +416,8 @@ export function registerWireFaces(webServer, d) {
328
416
  let body;
329
417
  try {
330
418
  body = JSON.parse(Buffer.concat(chunks).toString('utf8'));
419
+ if (body === null || typeof body !== 'object' || Array.isArray(body))
420
+ throw new TypeError('direct body must be a JSON object'); // #168: `null` read body.team in this async 'end' callback → unhandled rejection → host exit
331
421
  }
332
422
  catch {
333
423
  const payload = JSON.stringify({ error: 'malformed body', code: -32000 });
@@ -370,6 +460,15 @@ export function registerWireFaces(webServer, d) {
370
460
  res.end(payload);
371
461
  return;
372
462
  }
463
+ // #114 S5: a caller-born id must be header-safe — refused before
464
+ // admission, claim or steer, so nothing is booked under it.
465
+ const idRefusal = taskIdRefusal(taskId);
466
+ if (idRefusal !== undefined) {
467
+ const payload = JSON.stringify({ error: idRefusal, code: -32000, team, task_status: 'TASK_STATE_FAILED' });
468
+ res.writeHead(200, { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(payload) });
469
+ res.end(payload);
470
+ return;
471
+ }
373
472
  // Phase-3 admission (owner ruling 2026-09-01, decision-seat
374
473
  // filed): an inbound direct delivery must show a SHARED team.
375
474
  // Local-zone callers are judged synchronously and exactly — the
@@ -383,21 +482,27 @@ export function registerWireFaces(webServer, d) {
383
482
  // (0.6.15: the rule now lives in a2aCallerRefusal so the
384
483
  // read-only /a2a/log face admits callers by exactly the same
385
484
  // standard as message delivery.)
386
- const callerRefusal = a2aCallerRefusal(caller, team);
387
- if (callerRefusal !== undefined) {
485
+ const admission = a2aCallerAdmission(caller, team);
486
+ const callerRefusal = refusalOf(admission);
487
+ if (callerRefusal !== undefined || !admission.ok) {
388
488
  recordActivity('in', team, caller === '' ? 'anonymous' : caller, false);
389
489
  const payload = JSON.stringify({ error: callerRefusal, code: -32000, team, task_status: 'TASK_STATE_FAILED' });
390
490
  res.writeHead(200, { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(payload) });
391
491
  res.end(payload);
392
492
  return;
393
493
  }
494
+ // #114 S6: a version an inbound record is born under is always in the history.
495
+ observeAuthorization('lazy');
496
+ // #114 S2: the version is read at admission time, beside the decision it explains.
497
+ const admittedAt = { version: allowlistVersionNow(), at: Date.now() };
394
498
  // gh#9+gh#10 OriginClaim on the delivery face (M1): an envelope
395
499
  // that is present but unverifiable rejects untrusted-origin; an
396
500
  // ABSENT envelope during the window walks on with a counted
397
501
  // legacyInbound warn (the operator watches the tail drain), and
398
502
  // after the window closes it rejects like a failed signature —
399
503
  // N3 proves a NAME's linkability, never key possession.
400
- const claim = await verifyOriginClaim(req);
504
+ // gh#133: the delivery face is where a deposited CLI identity signs its routes.
505
+ const claim = await verifyOriginClaim(req, { cli: true });
401
506
  if (!claim.ok) {
402
507
  if (claim.reason !== 'absent' || config.originClaimWindow === false) {
403
508
  recordActivity('in', team, caller, false);
@@ -408,6 +513,17 @@ export function registerWireFaces(webServer, d) {
408
513
  }
409
514
  d.bumpLegacyInbound();
410
515
  }
516
+ // A verified claim proves the caller only when its signed zone covers it (review of PR #134 P1),
517
+ // and the host face only when it was signed from this very host (round 2: a zone name is not an origin).
518
+ const callerProof = !claim.ok ? 'legacy-window'
519
+ : claim.cli === true ? (claimCovers(caller, claim.caller) ? 'cli-identity' : 'claim-mismatch')
520
+ : claim.bound !== true ? 'unbound'
521
+ : !isRootOriginUrl(claim.callerUrl) ? 'claim-mismatch'
522
+ : admission.rule === 'host-face' && originOf(claim.callerUrl) !== originOf(d.ownCardUrl) ? 'claim-mismatch'
523
+ : claimCovers(caller, claim.caller) ? 'origin-claim' : 'claim-mismatch';
524
+ const proofClause = callerProof === 'origin-claim' && claim.ok ? `已验证来源 ${originOf(claim.callerUrl)}`
525
+ : callerProof === 'cli-identity' ? '未验证(cli-identity)' : `未验证(${callerProof})`;
526
+ const directHeader = (from) => `[A2A direct] (task ${taskId}) ${proofClause} ${sanitizeFromClause(from)} (routed to ${team}) sent:`;
411
527
  if (refuseIfBudgetExhausted(req, res, team, caller, taskId, taskLedger))
412
528
  return;
413
529
  // Server-side idempotency (P3/B3): one key, one execution inside
@@ -437,17 +553,29 @@ export function registerWireFaces(webServer, d) {
437
553
  // receipt resolves the task it echoes, whichever wait semantics
438
554
  // carried it here. Correlation is bookkeeping only — the message
439
555
  // steers on exactly as before.
440
- settleAndAnnounce(message);
556
+ // #114 S4: the settler is this delivery's caller, as proven here. A CLI identity's claim URL
557
+ // is not a host's origin (its origin is unconstrained), so it never vouches for one (review of PR #140 P1-2).
558
+ // #139: the claim's card key names the host whatever address spelling it signed with.
559
+ const refusedSettlement = settleAndAnnounce(message, { caller, proof: callerProof, ...settlerClaimFields(claim, originOf), ingress: 'wire' });
560
+ // #139 (design §4.4): every answer this delivery gets carries the refusal.
561
+ if (refusedSettlement !== undefined)
562
+ withSettlementField(res, refusedSettlement);
441
563
  // gh#45 CLI mailbox (wire face): a delivery targeting a DEPOSITED
442
564
  // CLI handle never resolves a session and never wakes — it lands
443
565
  // in the deposit's mailbox and answers delivered. Placement here
444
566
  // keeps the idempotency claim (above) exactly as for sessions.
445
567
  const cliRow = cliDeposits.get(team);
446
568
  if (cliRow !== undefined) {
447
- cliDeposits.mail(team, caller, message);
569
+ cliDeposits.mail(team, caller, `${directHeader(caller)}\n\n${message}`);
448
570
  recordActivity('in', team, caller, true);
449
571
  logger.info(`a2a: cli mailbox delivery for ${team} (from ${caller})`);
450
- const payload = JSON.stringify({ routed: true, delivered: true, receipt: 'mailbox', team, task_id: taskId, task_status: 'TASK_STATE_COMPLETED', note: `delivered to the mailbox of ${team} — the CLI polls while online` });
572
+ const note = `delivered to the mailbox of ${team} — the CLI polls while online`;
573
+ // gh#112 review: a mailbox delivery is final from this host's
574
+ // side, so a resend of this id reads `completed`. A receipt the
575
+ // CLI routes back later still reaches its caller, but does not
576
+ // replace this outcome (recordOutcome is first-writer-wins).
577
+ idempotencyStore.recordOutcome(taskId, { status: 'completed', reply: note });
578
+ const payload = JSON.stringify({ routed: true, delivered: true, receipt: 'mailbox', team, task_id: taskId, task_status: 'TASK_STATE_COMPLETED', note });
451
579
  res.writeHead(200, { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(payload) });
452
580
  res.end(payload);
453
581
  return;
@@ -460,6 +588,18 @@ export function registerWireFaces(webServer, d) {
460
588
  // lookup settles the message is already in — answer delivered and
461
589
  // let the receipt contract carry the reply.
462
590
  if (noWait) {
591
+ // gh#112 review P1: a no-wait refusal AFTER the idempotency
592
+ // claim records its failed outcome — otherwise a resend of the
593
+ // same id reads `pending` from /a2a/query forever and the
594
+ // caller is told an undelivered message already landed.
595
+ const refuseNoWait = (error, activity = true) => {
596
+ if (activity)
597
+ recordActivity('in', team, caller, false);
598
+ idempotencyStore.recordOutcome(taskId, { status: 'failed', error });
599
+ const payload = JSON.stringify({ error, code: -32000, team, task_id: taskId, task_status: 'TASK_STATE_FAILED' });
600
+ res.writeHead(200, { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(payload) });
601
+ res.end(payload);
602
+ };
463
603
  // Issue #41 companion fix: the no-wait inbound branch resolved
464
604
  // only bare session teams — a canvas team delivery (wait:false)
465
605
  // fell through to the cold tier and answered a bogus
@@ -475,6 +615,11 @@ export function registerWireFaces(webServer, d) {
475
615
  // supplied one (a session node team — wake-on-route covers a
476
616
  // cold one), else the caller label as before.
477
617
  const receiptTarget = callback !== '' ? callback : from;
618
+ // gh#112 review: only a failed steer is a refusal. Once the
619
+ // message is in the session, a failure in the bookkeeping that
620
+ // follows must not record `failed` (a resend would read
621
+ // "already ran and failed" and invite a second execution).
622
+ let steered = false;
478
623
  try {
479
624
  // The receipt header carries the task id: the target echoes
480
625
  // it verbatim in "[A2A receipt] task <id> ...", closing the
@@ -486,9 +631,16 @@ export function registerWireFaces(webServer, d) {
486
631
  // latched, so the post-steer read alone would answer a false
487
632
  // consumed:true and strand the message.
488
633
  const runningBeforeSteer = probeConsumption(target, taskId);
489
- steerRelay(target, `[A2A direct] (task ${taskId}) ${fromClause(from)} (routed to ${team}) sent:\n\n${message}\n\n(When done, route your outcome back with one call — a2a_route { team: "${receiptTarget}", message: "[A2A receipt] task ${taskId} <one-line outcome>" }.)`, from === 'an unknown node' ? undefined : from);
634
+ // #114 S5: the receipt's consumption search starts here, so an
635
+ // older steer of a reused id in the history never anchors it.
636
+ const steerFloor = eventCountOf(target);
637
+ const deliveredReceipt = parseReceipt(message) !== null;
638
+ // gh#141: a receipt is answered by nobody — its steer asks for no receipt back.
639
+ const receiptRequest = deliveredReceipt ? '' : `\n\n(When done, route your outcome back with one call — a2a_route { team: "${receiptTarget}", message: "[A2A receipt] task ${taskId} <one-line outcome>" }.)`;
640
+ steerRelay(target, `${directHeader(from)}\n\n${message}${receiptRequest}`, from === 'an unknown node' ? undefined : from);
641
+ steered = true;
490
642
  recordActivity('in', team, caller, true);
491
- armReceiptAutosend(target, taskId, receiptTarget, message);
643
+ armReceiptAutosend(target, taskId, receiptTarget, message, steerFloor);
492
644
  // v0.5.23 (async-stall): "delivered" only proves the steer call
493
645
  // returned — not that a turn started. An idle-phase steer wakes
494
646
  // the driver, but a maintenance/abort window latches the wake for
@@ -516,8 +668,28 @@ export function registerWireFaces(webServer, d) {
516
668
  // settle. The row itself stays: stall visibility and the
517
669
  // nudge still apply to the TARGET side.
518
670
  const receiptExpectation = receiptExpectationOf(receiptTarget);
519
- taskLedger.trackInbound(taskId, team, receiptTarget, contextId === '' ? undefined : contextId, receiptExpectation !== 'process-label', briefOf(message));
520
- armAsyncNudge(target, team, taskId, config.asyncNudgeDelayMs);
671
+ // #114 S2: the row is born with its immutable authorization record.
672
+ const auth = buildAuthorization(admission, {
673
+ caller,
674
+ proof: callerProof,
675
+ ...(claim.ok ? { claim: { caller: claim.caller, origin: originOf(claim.callerUrl) } } : {}),
676
+ ingress: 'wire',
677
+ allowlistVersion: admittedAt.version,
678
+ target: team,
679
+ receiptTarget,
680
+ receivedAt: admittedAt.at,
681
+ // Audit only: a cold session's activation window also reads as running.
682
+ targetBusy: runningBeforeSteer,
683
+ });
684
+ // #139: the member that actually received it answers for a canvas or bare-zone team.
685
+ const deliveredTo = sessionTeamOf(target);
686
+ // gh#141: a receipt owes no receipt — it is settlement traffic,
687
+ // recorded on the row it settled. Booking it would leave a debt
688
+ // nothing can pay, and a nudge chasing an answer nobody owes.
689
+ if (!deliveredReceipt) {
690
+ taskLedger.trackInbound(taskId, team, receiptTarget, contextId === '' ? undefined : contextId, receiptExpectation !== 'process-label', briefOf(message), auth, deliveredTo === team ? undefined : deliveredTo);
691
+ armAsyncNudge(target, team, taskId, config.asyncNudgeDelayMs);
692
+ }
521
693
  const payload = JSON.stringify({
522
694
  routed: true,
523
695
  delivered: true,
@@ -528,16 +700,22 @@ export function registerWireFaces(webServer, d) {
528
700
  task_status: 'TASK_STATE_DELIVERED',
529
701
  artifacts: [],
530
702
  consumed: consumedProbe,
531
- receipt: receiptExpectation === 'process-label' ? 'none' : 'routed',
703
+ receipt: receiptExpectation === 'process-label' || deliveredReceipt ? 'none' : 'routed',
532
704
  });
533
705
  res.writeHead(200, { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(payload) });
534
706
  res.end(payload);
535
707
  }
536
708
  catch (error) {
537
- recordActivity('in', team, caller, false);
538
- const payload = JSON.stringify({ error: `The DSH session rejected the message: ${String(error)}`, code: -32000, team, task_id: taskId, task_status: 'TASK_STATE_FAILED' });
539
- res.writeHead(200, { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(payload) });
540
- res.end(payload);
709
+ if (!steered) {
710
+ refuseNoWait(`The DSH session rejected the message: ${String(error)}`);
711
+ return;
712
+ }
713
+ logger.warn(`a2a: task ${taskId.slice(0, 80)} was delivered to ${team}, but its bookkeeping failed: ${String(error)}`);
714
+ if (!res.headersSent) {
715
+ const payload = JSON.stringify({ routed: true, delivered: true, team, task_id: taskId, context_id: contextId, task_status: 'TASK_STATE_DELIVERED', artifacts: [], consumed: false, receipt: 'routed' });
716
+ res.writeHead(200, { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(payload) });
717
+ res.end(payload);
718
+ }
541
719
  }
542
720
  };
543
721
  if (agent !== undefined) {
@@ -546,10 +724,7 @@ export function registerWireFaces(webServer, d) {
546
724
  }
547
725
  if (wake?.kind === 'flight') {
548
726
  void wake.flight.then(deliver, (error) => {
549
- recordActivity('in', team, caller, false);
550
- const payload = JSON.stringify({ error: `waking the session for team "${team}" failed: ${String(error)}`, code: -32000, team, task_status: 'TASK_STATE_FAILED' });
551
- res.writeHead(200, { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(payload) });
552
- res.end(payload);
727
+ refuseNoWait(`waking the session for team "${team}" failed: ${String(error)}`);
553
728
  });
554
729
  return;
555
730
  }
@@ -571,34 +746,26 @@ export function registerWireFaces(webServer, d) {
571
746
  // get an honest failure, never its own dispatch echoed
572
747
  // home plus an auto-receipt for work nobody did.
573
748
  if (callerIsAgent(live, caller)) {
574
- recordActivity('in', team, caller, false);
575
- const payload = JSON.stringify({ error: selfRouteText(team), code: -32000, team, task_status: 'TASK_STATE_FAILED' });
576
- res.writeHead(200, { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(payload) });
577
- res.end(payload);
749
+ refuseNoWait(selfRouteText(team));
578
750
  return;
579
751
  }
580
752
  deliver(live);
581
753
  return;
582
754
  }
583
755
  }
584
- const payload = JSON.stringify({ error: wake?.kind === 'no-face' ? noWakeFaceText(team) : wake?.kind === 'archived' ? archivedColdMatchText(team) : noColdMatchText(team), code: -32000, team, task_status: 'TASK_STATE_FAILED' });
585
- res.writeHead(200, { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(payload) });
586
- res.end(payload);
756
+ refuseNoWait(wake?.kind === 'no-face' ? noWakeFaceText(team) : wake?.kind === 'archived' ? archivedColdMatchText(team) : noColdMatchText(team), false);
587
757
  };
588
758
  const prepared = await nativeTeamsPrepare(team);
589
759
  if (!prepared.ok) {
590
760
  if (prepared.reason === 'error') {
591
- recordActivity('in', team, caller, false);
592
- const payload = JSON.stringify({ error: prepared.error, code: -32000, team, task_status: 'TASK_STATE_FAILED' });
593
- res.writeHead(200, { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(payload) });
594
- res.end(payload);
761
+ refuseNoWait(prepared.error);
595
762
  return;
596
763
  }
597
764
  standardMiss();
598
765
  return;
599
766
  }
600
767
  recordActivity('in', team, caller, true);
601
- void nativeTeamsRound(prepared, team, caller, message, taskId)
768
+ void nativeTeamsRound(prepared, team, caller, message, taskId, proofClause, undefined, (_team, _taskId, from) => directHeader(from))
602
769
  .then(outcome => {
603
770
  if (!outcome.ok) {
604
771
  recordActivity('in', team, caller, false);
@@ -622,7 +789,7 @@ export function registerWireFaces(webServer, d) {
622
789
  })();
623
790
  return;
624
791
  }
625
- void routeIntoAgent(team, message, caller, taskId).then((outcome) => {
792
+ void routeIntoAgent(team, message, caller, taskId, proofClause, directHeader).then((outcome) => {
626
793
  recordActivity('in', team, caller, outcome.ok);
627
794
  // W7 slice-2 hook 1: record the settled outcome on the claim row.
628
795
  // Ruling: a non-COMPLETED status (TASK_STATE_DELIVERED — the