@nelsonlongxiang/dsh-open-a2a-net 0.6.43 → 0.7.0

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 (73) hide show
  1. package/assets/nexusDist/assets/index-CA_YZTWG.js +9 -0
  2. package/assets/nexusDist/index.html +1 -1
  3. package/cli/a2a.mjs +19 -3
  4. package/lib/a2a-client.js +10 -2
  5. package/lib/admitted-bookkeeping.js +7 -0
  6. package/lib/canvas-discovery.js +11 -1
  7. package/lib/canvas-member.js +43 -0
  8. package/lib/canvas-store.js +70 -4
  9. package/lib/direct-forward.js +9 -7
  10. package/lib/faces/admin.js +50 -7
  11. package/lib/faces/files.js +2 -1
  12. package/lib/faces/wire.js +566 -468
  13. package/lib/followup-delivery.js +89 -0
  14. package/lib/idempotency-store.js +8 -0
  15. package/lib/inbound-verification.js +23 -0
  16. package/lib/index.js +478 -156
  17. package/lib/nudge-observability.js +34 -0
  18. package/lib/origin-claim.js +36 -9
  19. package/lib/outbound-scope.js +4 -2
  20. package/lib/peer-directory.js +15 -5
  21. package/lib/peer-store.js +31 -17
  22. package/lib/queue-guard.js +4 -3
  23. package/lib/receipt-ladder.js +1 -1
  24. package/lib/receipt-retry.js +58 -0
  25. package/lib/reconciliation-reminders.js +148 -0
  26. package/lib/reconciliation-state.js +27 -0
  27. package/lib/session-transcript.js +46 -18
  28. package/lib/skill/SKILL.md +5 -0
  29. package/lib/skill/references/cli.md +4 -1
  30. package/lib/skill/references/routing.md +31 -1
  31. package/lib/task-auth.js +3 -3
  32. package/lib/task-delivery.js +23 -0
  33. package/lib/task-ledger-persistence.js +51 -0
  34. package/lib/task-ledger-rows.js +5 -0
  35. package/lib/task-ledger.js +219 -20
  36. package/lib/tools/info.js +37 -7
  37. package/lib/types/a2a-client.d.ts +1 -1
  38. package/lib/types/admitted-bookkeeping.d.ts +5 -0
  39. package/lib/types/canvas-member.d.ts +10 -0
  40. package/lib/types/canvas-store.d.ts +8 -0
  41. package/lib/types/faces/admin.d.ts +19 -0
  42. package/lib/types/faces/files.d.ts +1 -1
  43. package/lib/types/faces/wire.d.ts +21 -14
  44. package/lib/types/followup-delivery.d.ts +25 -0
  45. package/lib/types/idempotency-store.d.ts +2 -0
  46. package/lib/types/inbound-verification.d.ts +14 -0
  47. package/lib/types/index.d.ts +5 -0
  48. package/lib/types/nudge-observability.d.ts +17 -0
  49. package/lib/types/origin-claim.d.ts +7 -1
  50. package/lib/types/outbound-scope.d.ts +1 -0
  51. package/lib/types/peer-store.d.ts +4 -0
  52. package/lib/types/queue-guard.d.ts +9 -2
  53. package/lib/types/receipt-ladder.d.ts +2 -0
  54. package/lib/types/receipt-retry.d.ts +22 -0
  55. package/lib/types/reconciliation-reminders.d.ts +41 -0
  56. package/lib/types/reconciliation-state.d.ts +32 -0
  57. package/lib/types/session-transcript.d.ts +6 -1
  58. package/lib/types/task-auth.d.ts +7 -0
  59. package/lib/types/task-delivery.d.ts +17 -0
  60. package/lib/types/task-ledger-persistence.d.ts +3 -0
  61. package/lib/types/task-ledger-rows.d.ts +19 -2
  62. package/lib/types/task-ledger.d.ts +48 -1
  63. package/lib/types/tools/info.d.ts +3 -0
  64. package/lib/types/types.d.ts +3 -0
  65. package/nexus-stage/src/federation.ts +1 -0
  66. package/nexus-stage/src/main.ts +10 -2
  67. package/nexus-stage/src/member-provenance-picker.ts +13 -0
  68. package/nexus-stage/src/planning-member-handles.ts +4 -1
  69. package/nexus-stage/src/planning-view.ts +3 -3
  70. package/nexus-stage/src/workbench.ts +28 -6
  71. package/nexus-stage/src/world.ts +1 -0
  72. package/package.json +3 -2
  73. package/assets/nexusDist/assets/index-CaA76j0F.js +0 -9
package/lib/index.js CHANGED
@@ -31,6 +31,7 @@ export function resolveCallerLabel(input) {
31
31
  import { encodePublicKey, signCard, verifyCard } from "./card.js";
32
32
  import { GroupStore } from "./group-store.js";
33
33
  import { CanvasStore, canvasNameReserved, isRemoteMemberRef, legacyBareMember } from "./canvas-store.js";
34
+ import { splitQualifiedMember, memberProvenance, qualifiedSessionMember } from "./canvas-member.js";
34
35
  import { decodeSessionLog, extractTranscript, TRANSCRIPT_MAX_FILE_BYTES } from "./session-transcript.js";
35
36
  import { LayoutStore } from "./layout-store.js";
36
37
  import { peerLinkDisplay } from "./peer-link-display.js";
@@ -47,7 +48,11 @@ import { PeerStore } from "./peer-store.js";
47
48
  import { SelfReferralFilter } from "./self-suppress.js";
48
49
  import { TaskLedger, SUMMARY_CAP, briefOf } from "./task-ledger.js";
49
50
  import { formatReceipt, parseReceipt, taskIdRefusal } from "./receipt.js";
51
+ import { ReconciliationReminders } from "./reconciliation-reminders.js";
52
+ import { REMINDER_SCAN_MS, reminderView } from "./reconciliation-state.js";
53
+ import { AdmittedBookkeepingError, ADMITTED_BOOKKEEPING_NOTE } from "./admitted-bookkeeping.js";
50
54
  import { runReceiptLadder } from "./receipt-ladder.js";
55
+ import { receiptFailure, retryReceiptDelivery, transientReceiptFailure, receiptRetryWait } from "./receipt-retry.js";
51
56
  import { IdempotencyStore, WIRE_ERROR_IDEMPOTENCY_CONFLICT, WIRE_ERROR_REPLAY_REJECTED, peerPayloadFingerprint } from "./idempotency-store.js";
52
57
  import { createFloodGuard } from "./flood-guard.js";
53
58
  import { createQueueGuard, queueFullRefusal, QUEUE_FULL_NEXT, WIRE_ERROR_QUEUE_FULL } from "./queue-guard.js";
@@ -55,8 +60,9 @@ import { resolveZone } from "./zone.js";
55
60
  import { registerA2aNetworkSkill } from "./skill-registration.js";
56
61
  import { isRootOriginUrl, sameMountedUrl, originClaimTeamWarning, signOriginClaim, verifyOriginClaim as verifyOriginClaimOf } from "./origin-claim.js";
57
62
  import { AuthObserver } from "./auth-observer.js";
58
- import { allowlistVersion, authorizationRef, buildAuthorization, callerAdmission, localCandidateAdmission, canonicalAllowlist, judgeSettlement, joinedFaceAdmission, refusalOf, sameZoneAdmission, recheckRemoteCliAuthorization } from "./task-auth.js";
63
+ import { claimCovers, allowlistVersion, authorizationRef, buildAuthorization, callerAdmission, localCandidateAdmission, canonicalAllowlist, judgeSettlement, joinedFaceAdmission, refusalOf, sameZoneAdmission, recheckRemoteCliAuthorization } from "./task-auth.js";
59
64
  import { MessageId } from '@deepseek-ai/dsh-llm';
65
+ import { FollowupDelivery } from "./followup-delivery.js";
60
66
  import { SessionId } from '@deepseek-ai/dsh-session';
61
67
  import { liveRootAgents } from "./live-roots.js";
62
68
  import { A2aClient } from "./a2a-client.js";
@@ -85,6 +91,7 @@ export const inject = ['tools', 'timer', 'systemPrompt'];
85
91
  import { Config } from "./config.js";
86
92
  import { canvasOwnerFingerprint, splitCanvasOwner } from "./canvas-id.js";
87
93
  import { forwardReceiptBasis } from "./direct-forward.js";
94
+ import { NudgeObservability } from "./nudge-observability.js";
88
95
  export { Config };
89
96
  /**
90
97
  * Register the outbound peer tools, the direct-route endpoint, and (when
@@ -481,7 +488,7 @@ export function apply(ctx, config) {
481
488
  };
482
489
  // Graceful disposal lands the debounced peer state on disk before a
483
490
  // restart reads it back (fiber teardown awaits effect disposers).
484
- ctx.effect(() => () => peerStore.flush());
491
+ ctx.effect(() => () => peerStore.close());
485
492
  // Slice 3: an unset session derives from a per-home node id, and the
486
493
  // configured delegations become signed zone records on every card.
487
494
  const session = config.session === '' ? deriveSession(home) : config.session;
@@ -505,8 +512,9 @@ export function apply(ctx, config) {
505
512
  */
506
513
  function originClaimHeaders(target) {
507
514
  // No local web/card anchor yet: keep the preexisting absent-envelope migration posture, not a malformed Sig3.
508
- const signedTarget = ownCardUrl === '' ? { url: target.url, method: target.method } : target;
509
- return signOriginClaim(signedTarget, { caller: config.team, callerUrl: ownCardUrl, privateKey: nodePrivateKey, unbound: config.originClaimUnboundWindow !== false, now: Date.now() });
515
+ if (ownCardUrl === '')
516
+ return {};
517
+ return signOriginClaim(target, { caller: config.team, callerUrl: ownCardUrl, privateKey: nodePrivateKey, unbound: config.originClaimUnboundWindow !== false, now: Date.now() });
510
518
  }
511
519
  const client = new A2aClient({
512
520
  apiKey: config.apiKey,
@@ -597,7 +605,7 @@ export function apply(ctx, config) {
597
605
  // Canvas teams: user-composed multi-member routing groups. A session node
598
606
  // may sit in many teams; routing to <team>/canvas/<name> resolves the
599
607
  // first live member or wakes the first cold one (member order = priority).
600
- const canvasStore = new CanvasStore(join(home, 'a2a', 'canvas.json'), { legacyBareMembers: config.canvasLegacyBareMembers !== false });
608
+ const canvasStore = new CanvasStore(join(home, 'a2a', 'canvas.json'), { legacyBareMembers: config.canvasLegacyBareMembers !== false, ownPublicKey: encodePublicKey(createPublicKey(nodePrivateKey)) });
601
609
  // #91 file channel: the sender host's blob store (docs/design/file-channel.md).
602
610
  const blobStore = new BlobStore(join(home, 'a2a'));
603
611
  // Spatial presentation state for the full-page stage (contract v2).
@@ -606,6 +614,7 @@ export function apply(ctx, config) {
606
614
  // queryable here, and the correlating `[A2A receipt] task <id>` message
607
615
  // resolves it — the caller-side half of the receipt contract, persisted so
608
616
  // a restart does not orphan the reconciliation.
617
+ const nudgeObservations = new NudgeObservability(message => logger.warn(message));
609
618
  const taskLedger = new TaskLedger(join(home, 'a2a', 'tasks.json'), { staleTtlMs: config.taskStaleTtlMs, warn: message => logger.warn(message) });
610
619
  const enforceBlockers = taskLedger.settlementEnforceBlockers();
611
620
  const enforceRefused = config.settlementAuth === 'enforce' && enforceBlockers.length > 0 ? { reason: 'legacy forwarded canvas debts lack receiver facts', blockers: enforceBlockers } : undefined;
@@ -640,7 +649,14 @@ export function apply(ctx, config) {
640
649
  if (name === undefined)
641
650
  return undefined;
642
651
  // Canvas members by session id and by handle, as the faces store them.
643
- return canvasStore.membersOf(name).flatMap(m => (m.includes('/') ? [m] : [m, `${config.team}/${id8(m)}`]));
652
+ return canvasStore.membersOf(name).flatMap(m => {
653
+ const qualified = splitQualifiedMember(m);
654
+ if (qualified) {
655
+ const local = localHandleResolver()(m);
656
+ return local !== undefined || settler.claimKey === canvasStore.ownerKeyOf(m) ? [m, qualified.handle] : [m];
657
+ }
658
+ return m.includes('/') ? [m] : [m, `${config.team}/${id8(m)}`];
659
+ });
644
660
  },
645
661
  ...(parsedForJudge?.envelope?.onBehalfOf !== undefined ? { onBehalfOf: parsedForJudge.envelope.onBehalfOf } : {}),
646
662
  echo,
@@ -676,18 +692,6 @@ export function apply(ctx, config) {
676
692
  // must not be recorded as one. First-write wins inside the store, so a
677
693
  // late receipt never overwrites a precise sync-hook record.
678
694
  const parsed = parsedForJudge;
679
- if (parsed !== null && parsed.taskId !== '') {
680
- const line = parsed.summary.trim();
681
- // #114 S4 (design §4.2.2): under enforce, the receipt hook writes the
682
- // outcome a /a2a/query answers only for a row THIS ledger owes and
683
- // whose settler is on the chain — anyone sending a receipt line could
684
- // otherwise rewrite another caller's answer.
685
- const row = judge?.enforce === true ? taskLedger.find(parsed.taskId) : undefined;
686
- const allowed = judge?.enforce !== true || (row !== undefined && judge.check(row, false).ok);
687
- if (line !== '' && allowed) {
688
- idempotencyStore.recordOutcome(parsed.taskId, { status: 'completed', reply: line.slice(0, SUMMARY_CAP) });
689
- }
690
- }
691
695
  // A header that names an owed row and comes back unresolved was refused
692
696
  // (enforce): that is the verdict — the echo fallback must not judge the
693
697
  // same message a second time.
@@ -696,7 +700,19 @@ export function apply(ctx, config) {
696
700
  // #139 (design §4.4): an enforce refusal of the row the header names is
697
701
  // told to the sender on the wire response, not only in /state.
698
702
  const headerVerdict = judge?.enforce === true && owedRow !== undefined ? judge.check(owedRow, false) : undefined;
703
+ // Capture authority while the owed row is still present; successful resolve archives it.
704
+ const outcomeAllowed = judge?.enforce !== true || (owedRow !== undefined && headerVerdict?.ok === true);
699
705
  const resolved = taskLedger.resolveFromMessage(message, judge);
706
+ if (parsed !== null && parsed.taskId !== '') {
707
+ const line = parsed.summary.trim();
708
+ // #114 S4 (design §4.2.2): under enforce, the receipt hook writes the
709
+ // outcome a /a2a/query answers only for a row THIS ledger owes and
710
+ // whose settler is on the chain — anyone sending a receipt line could
711
+ // otherwise rewrite another caller's answer.
712
+ if (line !== '' && outcomeAllowed) {
713
+ idempotencyStore.recordOutcome(parsed.taskId, { status: 'completed', reply: line.slice(0, SUMMARY_CAP) });
714
+ }
715
+ }
700
716
  if (resolved === undefined && owedByHeader) {
701
717
  return headerVerdict !== undefined && !headerVerdict.ok ? { status: 'refused', reason: headerVerdict.reason, task_id: parsed.taskId } : undefined;
702
718
  }
@@ -741,11 +757,18 @@ export function apply(ctx, config) {
741
757
  function settleOwnInbound(message, creditor, payer) {
742
758
  const parsed = parseReceipt(message);
743
759
  if (parsed === null || parsed.taskId === '')
744
- return;
745
- const settled = taskLedger.resolveFromMessage(message, settlementJudgeFor(payer, parsed, true), row => row.direction === 'inbound' && row.peer === `local:${creditor}`);
746
- const line = parsed.summary.trim();
747
- if (settled !== undefined && line !== '')
748
- idempotencyStore.recordOutcome(parsed.taskId, { status: 'completed', reply: line.slice(0, SUMMARY_CAP) });
760
+ return true;
761
+ try {
762
+ const settled = taskLedger.resolveFromMessage(message, settlementJudgeFor(payer, parsed, true), row => row.direction === 'inbound' && row.peer === `local:${creditor}`);
763
+ const line = parsed.summary.trim();
764
+ if (settled !== undefined && line !== '')
765
+ idempotencyStore.recordOutcome(parsed.taskId, { status: 'completed', reply: line.slice(0, SUMMARY_CAP) });
766
+ return true;
767
+ }
768
+ catch {
769
+ logger.warn('a2a: own inbound settlement commit failed; task remains owed');
770
+ return false;
771
+ }
749
772
  }
750
773
  // Server-side idempotency keys (work-order P3): a caller-born task id
751
774
  // executes at most once inside the 24h window — same-key replays answer
@@ -753,7 +776,7 @@ export function apply(ctx, config) {
753
776
  const idempotencyStore = new IdempotencyStore(join(home, 'a2a', 'idempotency.json'));
754
777
  const refuseIfFlooded = createFloodGuard(idempotencyStore, recordActivity);
755
778
  const ownTask = (taskId) => taskLedger.list().some(r => r.taskId === taskId) || taskLedger.archive().some(r => r.taskId === taskId);
756
- const refuseIfQueueFull = createQueueGuard(() => taskLedger.list(), () => config.targetQueueCap, ownTask, idempotencyStore, recordActivity);
779
+ const refuseIfQueueFull = createQueueGuard(() => taskLedger.list(), () => config.targetQueueCap, ownTask, idempotencyStore, recordActivity, id => reminderView(taskLedger.reminderState(id)));
757
780
  // Unmasked-identity suppression: gossip mirrors peer lists back and forth,
758
781
  // so a URL whose fetch just proved it serves this node — or an already
759
782
  // tracked node (an alias spelling) — keeps coming home as an inbound
@@ -1030,7 +1053,14 @@ export function apply(ctx, config) {
1030
1053
  published.add(row.team);
1031
1054
  }
1032
1055
  return (ref) => {
1033
- // #334 qualified member routing is deliberately deferred; this window is only legacy bare inference.
1056
+ const qualified = splitQualifiedMember(ref);
1057
+ if (qualified !== undefined) {
1058
+ const ownKey = encodePublicKey(createPublicKey(nodePrivateKey));
1059
+ if (memberProvenance(ref, canvasStore.ownerKeyOf(ref), ownKey) !== 'confirmed-local')
1060
+ return undefined;
1061
+ const hits = byHandle.get(qualified.handle);
1062
+ return hits?.length === 1 ? hits[0] : undefined;
1063
+ }
1034
1064
  if (!legacyOverride && config.canvasLegacyBareMembers === false)
1035
1065
  return undefined;
1036
1066
  const hits = byHandle.get(ref);
@@ -1076,6 +1106,10 @@ export function apply(ctx, config) {
1076
1106
  legacyInferredMembers: raw.filter(r => canvasStore.membersOf(r.name).includes(r.member) && resolve(r.member) !== undefined).length,
1077
1107
  legacyInferenceResolutions, legacyCollisionWarnings, legacyCollisionRecent: [...legacyCollisionRecent],
1078
1108
  suspendedMembersCount: new Set(canvasStore.state().suspendedMembers.map(r => JSON.stringify([r.team, r.member]))).size,
1109
+ memberSlots: canvasStore.list().flatMap(name => canvasStore.rawMembersOf(name).map(member => {
1110
+ const provenance = memberProvenance(member, canvasStore.ownerKeyOf(member), encodePublicKey(createPublicKey(nodePrivateKey)));
1111
+ return { name, member, ownerKey: canvasStore.ownerKeyOf(member) ?? null, provenance, suspended: canvasStore.isSuspended(name, member), compatibilityInference: provenance === 'unknown' && canvasStore.membersOf(name).includes(member) && resolve(member) !== undefined };
1112
+ })),
1079
1113
  ...canvasStore.state() };
1080
1114
  }
1081
1115
  function ownCanvasHandleOf(id, includeArchived = false) {
@@ -1091,12 +1125,8 @@ export function apply(ctx, config) {
1091
1125
  * canvas a handle-stored membership puts the session in.
1092
1126
  */
1093
1127
  function canvasTeamsOfSession(id) {
1094
- const teams = new Set(canvasStore.teamsOf(id));
1095
- const own = ownCanvasHandleOf(id);
1096
- if (own !== undefined)
1097
- for (const name of canvasStore.teamsOf(own))
1098
- teams.add(name);
1099
- return [...teams];
1128
+ const resolve = localHandleResolver();
1129
+ return canvasStore.list().filter(name => canvasStore.membersOf(name).some(ref => ref === id || resolve(ref) === id));
1100
1130
  }
1101
1131
  /**
1102
1132
  * gh#131: a canvas team's members as the local routing tiers read them —
@@ -1631,7 +1661,7 @@ export function apply(ctx, config) {
1631
1661
  at: now,
1632
1662
  rows: all
1633
1663
  .filter(row => row.local !== true)
1634
- .map(row => ({ team: row.team, name: row.name, ...(row.origin !== undefined ? { origin: row.origin } : {}), ...(row.via !== undefined ? { via: row.via } : {}), ...(row.workspace !== undefined ? { workspace: row.workspace } : {}), ...(row.legacy === true ? { legacy: true } : {}), ...(row.unreachable === true ? { unreachable: true } : {}), ...(row.teams !== undefined && row.teams.length > 0 ? { teams: [...row.teams] } : {}) })),
1664
+ .map(row => ({ team: row.team, name: row.name, ...(row.memberRef !== undefined ? { memberRef: row.memberRef } : {}), ...(row.origin !== undefined ? { origin: row.origin } : {}), ...(row.via !== undefined ? { via: row.via } : {}), ...(row.workspace !== undefined ? { workspace: row.workspace } : {}), ...(row.legacy === true ? { legacy: true } : {}), ...(row.unreachable === true ? { unreachable: true } : {}), ...(row.teams !== undefined && row.teams.length > 0 ? { teams: [...row.teams] } : {}) })),
1635
1665
  };
1636
1666
  })
1637
1667
  .catch(() => { })
@@ -1642,7 +1672,9 @@ export function apply(ctx, config) {
1642
1672
  whenWebServerSettled((webServer) => {
1643
1673
  // gh#77 B1: the admin faces live in src/faces/admin.ts.
1644
1674
  registerAdminFaces(webServer, {
1645
- ctx, config, logger, home, session, lanIp, PLUGIN_VERSION, controlRoute, controlTrustedWithoutKey, readJsonBody, peerFetch, peerStore, peerLinkState, originClaimHeaders, refreshRemoteRows, joinedSessions, exposureStore, teamMemberships, cliDeposits, groupStore, canvasStore, revokeCanvasMember, canvasMigrationState, cliHomeForAdd, cliHomeOptions, layoutStore, taskLedger, idempotencyStore, inFlightRoutes, recentActivity, teamAuditRing, reportRing, sessionNodes, liveRoots, mountSessionNode, coldJoinedIds, prewarmStatus, reconcileStatus, id8, sessionLabelOf, sessionTeamOf, ownCanvasHandleOf, localSessionOfHandle, canvasTeamsOfSession, nodeMetadataOf, canvasTeamOf, archivedSessionFilter, pruneArchivedJoins, pruneCanvasMemberships, verifyOriginClaim, callerLinkedToThisHost, cachedCardFetch, authorizationView,
1675
+ reminderDiagnostics: () => ({ ...idleReminders.diagnostics }),
1676
+ nudgeEscalations: () => nudgeObservations.snapshot(),
1677
+ ctx, config, logger, home, session, lanIp, PLUGIN_VERSION, controlRoute, controlTrustedWithoutKey, readJsonBody, peerFetch, peerStore, peerLinkState, originClaimHeaders, refreshRemoteRows, joinedSessions, exposureStore, teamMemberships, cliDeposits, groupStore, canvasStore, revokeCanvasMember, canvasMigrationState, canvasMemberForWrite, cliHomeForAdd, cliHomeOptions, layoutStore, taskLedger, idempotencyStore, inFlightRoutes, recentActivity, teamAuditRing, reportRing, sessionNodes, liveRoots, mountSessionNode, coldJoinedIds, prewarmStatus, reconcileStatus, id8, sessionLabelOf, sessionTeamOf, ownCanvasHandleOf, localSessionOfHandle, canvasTeamsOfSession, nodeMetadataOf, canvasTeamOf, archivedSessionFilter, pruneArchivedJoins, pruneCanvasMemberships, verifyOriginClaim, callerLinkedToThisHost, cachedCardFetch, authorizationView,
1646
1678
  indexUrl: import.meta.url, enforceRefused,
1647
1679
  get cardCache() { return cardCache; },
1648
1680
  get remoteRowsCache() { return remoteRowsCache; },
@@ -1650,6 +1682,8 @@ export function apply(ctx, config) {
1650
1682
  get unboundInboundCount() { return unboundInboundCount; },
1651
1683
  get boundMissInboundCount() { return boundMissInboundCount; },
1652
1684
  get boundMissRecent() { return boundMissRecent; },
1685
+ get claimRejectsInboundCount() { return claimRejectsInboundCount; },
1686
+ get claimRejectsRecent() { return claimRejectsRecent; },
1653
1687
  get ownCardUrl() { return ownCardUrl; }, ownCanvasFingerprint,
1654
1688
  get ownerSeatNames() { return ownerSeatNames; },
1655
1689
  set ownerSeatNames(v) { ownerSeatNames = v; },
@@ -1683,7 +1717,7 @@ export function apply(ctx, config) {
1683
1717
  // roster: the team-roster face (a2a_team_join/leave, card
1684
1718
  // teamMemberships, team-scope routing admission) ships — peers read
1685
1719
  // its absence as a pre-roster legacy node (0.5.43 and older).
1686
- capabilities: { route: true, async: true, roster: true },
1720
+ capabilities: { route: true, async: true, roster: true, canvasMemberProvenance: true, sourcedFollowup: true, queuedTaskCancellation: true },
1687
1721
  expiresAt: Date.now() + config.cardTtlMs,
1688
1722
  ...(records.length > 0 ? { records: [...records] } : {}),
1689
1723
  });
@@ -1854,6 +1888,45 @@ export function apply(ctx, config) {
1854
1888
  * so ui-chat renders the relay body ("来自 X" + full text) instead of an
1855
1889
  * opaque row; omitted when the sender is not meaningfully addressable.
1856
1890
  */
1891
+ const followupDelivery = new FollowupDelivery({
1892
+ ledger: taskLedger,
1893
+ taint: (target, localFrom) => { if (localFrom === undefined || nodeTaint.of(localFrom) !== '')
1894
+ markLineage(target, 'relay'); },
1895
+ warn: code => logger.warn('a2a: sourced followup (%s); check ledger persistence and original task', code),
1896
+ });
1897
+ const followupRelay = (agent, text, sender, localFrom, taskId, callerKey, taskExpected = true) => followupDelivery.deliver(agent, text, sender, localFrom, taskId, callerKey, taskExpected);
1898
+ ctx.on('agent/inbox/claimed', ({ agent, message }) => followupDelivery.claimed(agent, String(message.id)));
1899
+ const withdrawQueuedTask = (taskId) => {
1900
+ const controller = ctx.get('sessionController');
1901
+ return followupDelivery.withdraw(taskId, controller, id => ctx.get('agents')?.get(SessionId(id)));
1902
+ };
1903
+ const cancelQueuedTask = async (taskId, text, sender, original) => {
1904
+ let record, withdrawFailed = false, removed = false;
1905
+ try {
1906
+ record = taskLedger.deliveryFor(taskId);
1907
+ removed = await withdrawQueuedTask(taskId) === 'removed';
1908
+ }
1909
+ catch (error) {
1910
+ withdrawFailed = true;
1911
+ logger.warn('a2a: queue withdrawal failed; sending immediate cancellation notice: %s', String(error));
1912
+ }
1913
+ if (removed) {
1914
+ taskLedger.recordWithdrawalResult(taskId, 'removed');
1915
+ return { removed: true, steered: false, withdrawalResult: 'removed' };
1916
+ }
1917
+ const row = original ?? taskLedger.find(taskId);
1918
+ const receiverId = record?.sessionId ?? row?.receiverSessionId;
1919
+ const agent = receiverId ? ctx.get('agents')?.get(SessionId(receiverId)) : row ? (resolveAgentForTeam(row.deliveredTo ?? row.team) ?? canvasLiveAgent(parseCanvasTeamName(row.team))) : undefined;
1920
+ if (!agent) {
1921
+ taskLedger.recordWithdrawalResult(taskId, 'no-agent');
1922
+ return { removed: false, steered: false, withdrawalResult: 'no-agent' };
1923
+ }
1924
+ const notice = cancelNoticeText(taskId);
1925
+ steerRelay(agent, text.includes('If this task reaches you later from the queue, do not execute it.') ? text : notice, sender);
1926
+ const withdrawalResult = withdrawFailed ? 'withdraw-failed-steered' : 'claimed-steered';
1927
+ taskLedger.recordWithdrawalResult(taskId, withdrawalResult);
1928
+ return { removed: false, steered: true, withdrawalResult };
1929
+ };
1857
1930
  function steerRelay(agent, text, sender, localFrom) {
1858
1931
  // N4/gh#198: a relay steer carries content from outside this host — unless it is an in-process delivery from a clean local session (localFrom), whose taint the target then takes.
1859
1932
  if (localFrom === undefined || nodeTaint.of(localFrom) !== '')
@@ -1868,7 +1941,7 @@ export function apply(ctx, config) {
1868
1941
  /** The cooperative stop-notice text, shared by the tasks/cancel control
1869
1942
  * route and the transport face's cancel half (one contract, one wording). */
1870
1943
  function cancelNoticeText(taskId, reason) {
1871
- return `[A2A cancel] (task ${taskId}) the caller cancelled this task${reason !== undefined && reason !== '' ? `: ${reason}` : ''}. Stop any work tied to it and acknowledge via [A2A receipt] task ${taskId} cancelled.`;
1944
+ return `[A2A cancel] (task ${taskId}) the caller cancelled this task${reason !== undefined && reason !== '' ? `: ${reason}` : ''}. This task has been cancelled. If this task reaches you later from the queue, do not execute it. Stop any work tied to it and acknowledge via [A2A receipt] task ${taskId} cancelled.`;
1872
1945
  }
1873
1946
  /**
1874
1947
  * v0.5.23 (async-stall): whether the steered agent actually left idle. A
@@ -1933,20 +2006,23 @@ export function apply(ctx, config) {
1933
2006
  const escalateQueuePrompt = (agent, team, taskId) => {
1934
2007
  // Queue-mode prompt seam (the session controller owns prompt admission); #183: it stamps kind:'user', so nudgeRef stands in for the caller's task id.
1935
2008
  const controller = ctx.get('sessionController');
1936
- if (typeof controller?.prompt !== 'function')
2009
+ if (typeof controller?.prompt !== 'function') {
2010
+ nudgeObservations.note('unavailable', team, nudgeRef(taskId));
1937
2011
  return;
2012
+ }
2013
+ idleReminders.noteNudge(String(agent.id));
1938
2014
  markLineage(agent, 'nudge');
1939
- logger.info(`a2a: async nudge escalates to queue-mode prompt for ${team} (task ${taskId}, ref ${nudgeRef(taskId)})`);
2015
+ logger.info(`a2a: async nudge escalates to queue-mode prompt for ${team} (ref ${nudgeRef(taskId)})`);
1940
2016
  try {
1941
2017
  void controller.prompt({
1942
2018
  requestId: `a2a-nudge-${nudgeRef(taskId)}`,
1943
2019
  sessionId: agent.id,
1944
2020
  mode: 'queue',
1945
2021
  content: [{ type: 'text', text: `[A2A nudge] (ref ${nudgeRef(taskId)}) an earlier A2A delivery reached this session while it could not start a turn — please consume the inbox backlog now (ignore if there is nothing pending).` }],
1946
- }, new AbortController().signal).catch(() => { });
2022
+ }, new AbortController().signal).then(result => { const reply = result; nudgeObservations.note(reply?.accepted === false || reply?.ok === false ? 'failed' : 'queued', team, nudgeRef(taskId)); }, error => nudgeObservations.note('failed', team, nudgeRef(taskId), error));
1947
2023
  }
1948
- catch {
1949
- /* queueing is best-effort: the ledger row keeps the debt visible */
2024
+ catch (error) {
2025
+ nudgeObservations.note('failed', team, nudgeRef(taskId), error);
1950
2026
  }
1951
2027
  };
1952
2028
  function armAsyncNudge(agent, team, taskId, delayMs) {
@@ -1960,6 +2036,10 @@ export function apply(ctx, config) {
1960
2036
  nudgeBudget.delete(key);
1961
2037
  return;
1962
2038
  }
2039
+ if (!idleReminders.nudgeAllowed(key)) {
2040
+ armAsyncNudge(agent, team, taskId, Math.max(delayMs, 120_000));
2041
+ return;
2042
+ }
1963
2043
  if (agent.status !== 'idle') {
1964
2044
  // Busy on its own turn (or wedged): spend the bounded re-arm, then
1965
2045
  // climb to the queue-mode seam. Re-resolve on the steer path below
@@ -1983,6 +2063,7 @@ export function apply(ctx, config) {
1983
2063
  // afresh.
1984
2064
  const fresh = resolveAgentForTeam(team) ?? agent;
1985
2065
  logger.info(`a2a: async nudge re-waking ${team} (task ${taskId}, ref ${nudgeRef(taskId)}, delivered but not consumed)`);
2066
+ idleReminders.noteNudge(String(fresh.id));
1986
2067
  steerRelay(fresh, `[A2A nudge] (ref ${nudgeRef(taskId)}) an earlier A2A delivery reached this session while it could not start a turn — please consume the inbox backlog now.`);
1987
2068
  }, delayMs);
1988
2069
  ctx.effect(() => () => clearTimeout(timer), `a2a: async nudge ${taskId}`);
@@ -2000,6 +2081,34 @@ export function apply(ctx, config) {
2000
2081
  * receipts themselves are never tracked as new debts.
2001
2082
  */
2002
2083
  const receiptWarnAt = new Map();
2084
+ const reminderAgents = () => {
2085
+ const service = ctx.get('agents');
2086
+ return service ? liveRootAgents(service).filter(a => archivedSessionFilter()?.(String(a.id)) !== true) : [];
2087
+ };
2088
+ const exactReminderTarget = (row) => {
2089
+ const live = reminderAgents();
2090
+ if (row.receiverSessionId)
2091
+ return live.find(a => String(a.id) === row.receiverSessionId);
2092
+ // Legacy concrete face only; never reassign a canvas/process slot to its current roster.
2093
+ const face = row.deliveredTo ?? row.team;
2094
+ if (face.includes('/canvas/') || face === config.team)
2095
+ return undefined;
2096
+ const matches = live.filter(a => sessionTeamOf(a) === face);
2097
+ return matches.length === 1 ? matches[0] : undefined;
2098
+ };
2099
+ const idleReminders = new ReconciliationReminders({
2100
+ ledger: taskLedger, agents: reminderAgents, resolve: exactReminderTarget,
2101
+ sender: row => {
2102
+ if (row.peer !== 'local' || row.sender?.kind !== 'session')
2103
+ return undefined;
2104
+ const matches = reminderAgents().filter(a => sessionTeamOf(a) === row.sender.id);
2105
+ return matches.length === 1 ? matches[0] : undefined;
2106
+ },
2107
+ followup: (agent, text) => followupRelay(agent, text, config.team),
2108
+ warn: code => logger.warn('a2a: idle reconciliation reminder failed (%s)', code),
2109
+ });
2110
+ const requestReminderScan = () => { queueMicrotask(() => idleReminders.scan()); };
2111
+ ctx.effect(() => { const timer = ctx.timer.interval(() => idleReminders.scan(), REMINDER_SCAN_MS); requestReminderScan(); return () => { idleReminders.stop(); timer(); }; }, 'a2a: persistent idle reconciliation reminders');
2003
2112
  function armReceiptAutosend(target, taskId, callerTeam, deliveredText, steerFloor) {
2004
2113
  if (!callerTeam || callerTeam === '')
2005
2114
  return;
@@ -2054,19 +2163,17 @@ export function apply(ctx, config) {
2054
2163
  // closure would silently no-op there — runReceiptLadder reads a
2055
2164
  // settled undefined as tier-1 success, so the receipt would vanish
2056
2165
  // without a log.
2057
- void runReceiptLadder({
2058
- deliver: async (team) => {
2059
- // gh#217: across hosts the caller admits a session face it linked, never this host's process label; a
2060
- // task for the bare process team keeps the label, the only host face the settlement judge accepts (N1).
2061
- const peerCaller = row?.auth?.target.includes('/') === true ? sessionTeamOf(target) : undefined;
2062
- const delivered = await dispatchAnywhere(team, receipt, session, undefined, new AbortController().signal, true, undefined, peerCaller);
2063
- // gh#141: the caller lane pays this delivery's inbound row across hosts (a
2064
- // same-host receipt already settled it in-process), unless the caller
2065
- // refused it. The owner tier is no payment here — but an owner mailbox
2066
- // on THIS host settles the row in-process, as it always has.
2067
- if (team === callerTeam && delivered.settlementRefused !== true)
2068
- settleOwnInbound(receipt, callerTeam, { caller: session, proof: 'in-process', ingress: 'local' });
2069
- },
2166
+ const transportId = 'r-' + createHash('sha256').update(taskId + '\0' + callerTeam + '\0' + receipt).digest('base64url').slice(0, 12);
2167
+ const deliverReceipt = async (team) => {
2168
+ const peerCaller = row?.auth?.target.includes('/') === true ? sessionTeamOf(target) : undefined;
2169
+ const delivered = await dispatchAnywhere(team, receipt, session, undefined, new AbortController().signal, true, transportId, peerCaller);
2170
+ if (team === callerTeam && delivered.settlementRefused !== true && !settleOwnInbound(receipt, callerTeam, { caller: session, proof: 'in-process', ingress: 'local' }))
2171
+ throw receiptFailure({ error: 'task ledger write failed after receipt delivery', code: -32011 });
2172
+ };
2173
+ /* Legacy permanent-failure ladder remains the owner-settlement policy. */
2174
+ const ladder = () => runReceiptLadder({
2175
+ deliver: deliverReceipt,
2176
+ transient: transientReceiptFailure,
2070
2177
  ownerTeam: config.team,
2071
2178
  log: (stage, error) => {
2072
2179
  // N2 (observability): throttled per task to avoid ping-pong noise.
@@ -2076,9 +2183,13 @@ export function apply(ctx, config) {
2076
2183
  receiptWarnAt.set(key, Date.now());
2077
2184
  logger.warn(`a2a: receipt autosend ${stage} for task ${taskId} failed: ${String(error).slice(0, 120)}`);
2078
2185
  },
2079
- }, callerTeam).catch(() => {
2080
- // Ladder exhausted: the throttled breadcrumbs above already said why.
2081
- });
2186
+ }, callerTeam);
2187
+ const wait = (ms) => receiptRetryWait(ctx, ms);
2188
+ void retryReceiptDelivery({
2189
+ attempt: () => ladder(),
2190
+ notify: async () => { await dispatchAnywhere(config.team, '[A2A receipt delivery notice] ref ' + nudgeRef(taskId) + '; delivery to the original caller is temporarily unconfirmed; host retry is responsible. This notice is not a receipt and does not settle work.', session, undefined, new AbortController().signal, true); },
2191
+ pending: () => row === undefined || taskLedger.isPending(taskId), wait, log: () => { logger.warn('a2a: receipt delivery temporarily unconfirmed ref=%s', nudgeRef(taskId)); },
2192
+ }).catch(() => { });
2082
2193
  }, { taskId, steerFloor });
2083
2194
  }
2084
2195
  /**
@@ -2111,15 +2222,15 @@ export function apply(ctx, config) {
2111
2222
  * marker instead of a (false) completion.
2112
2223
  * @param taskId - the correlation key the steered receipt header carries.
2113
2224
  */
2114
- async function routeIntoAgent(team, message, caller, taskId, proofClause, buildHeader, localFrom, onSteered) {
2225
+ async function routeIntoAgent(team, message, caller, taskId, proofClause, buildHeader, localFrom, onSteered, callerKey) {
2115
2226
  const agent = resolveAgentForTeam(team) ?? canvasLiveAgent(parseCanvasTeamName(team));
2116
2227
  if (agent !== undefined) {
2117
- return routeIntoAgentFor(agent, team, message, caller, taskId, proofClause, buildHeader, localFrom, onSteered);
2228
+ return routeIntoAgentFor(agent, team, message, caller, taskId, proofClause, buildHeader, localFrom, onSteered, callerKey);
2118
2229
  }
2119
2230
  // Wake-on-route: a cold joined team materializes on demand, then steers.
2120
2231
  const wake = wakeColdTeam(team);
2121
2232
  if (wake.kind === 'flight') {
2122
- return wake.flight.then(agent => routeIntoAgentFor(agent, team, message, caller, taskId, proofClause, buildHeader, localFrom, onSteered), (error) => ({ ok: false, error: `waking the session for team "${team}" failed: ${String(error)}` }));
2233
+ return wake.flight.then(agent => routeIntoAgentFor(agent, team, message, caller, taskId, proofClause, buildHeader, localFrom, onSteered, callerKey), (error) => ({ ok: false, error: `waking the session for team "${team}" failed: ${String(error)}` }));
2123
2234
  }
2124
2235
  // Native-teams inbound bridge (opt-in): a registry team claims the
2125
2236
  // handle and dispatches through its authoritative seam. Checked before
@@ -2148,7 +2259,7 @@ export function apply(ctx, config) {
2148
2259
  if (live !== undefined) {
2149
2260
  if (callerIsAgent(live, caller))
2150
2261
  return { ok: false, error: selfRouteText(team) };
2151
- return routeIntoAgentFor(live, team, message, caller, taskId, proofClause, buildHeader, undefined, onSteered);
2262
+ return routeIntoAgentFor(live, team, message, caller, taskId, proofClause, buildHeader, undefined, onSteered, callerKey);
2152
2263
  }
2153
2264
  return { ok: false, error: 'No live DSH agent is available to accept this message.' };
2154
2265
  }
@@ -2163,11 +2274,13 @@ export function apply(ctx, config) {
2163
2274
  * @param caller - caller label for the steered prefix.
2164
2275
  * @returns the agent's final reply, or the explicit failure.
2165
2276
  */
2166
- function routeIntoAgentFor(agent, team, message, caller, taskId, proofClause, buildHeader, localFrom, onSteered) {
2277
+ function routeIntoAgentFor(agent, team, message, caller, taskId, proofClause, buildHeader, localFrom, onSteered, callerKey) {
2167
2278
  return new Promise((resolve) => {
2168
2279
  // #306: synchronous replies use the same reading-turn attribution as automatic receipts.
2169
2280
  // Registering and steering are synchronous, so this is the exact pre-steer floor.
2170
2281
  const floorAtRegistration = sessionEventsOf(agent).length;
2282
+ let admissionFinished = false;
2283
+ let earlyReply;
2171
2284
  const waiter = registerFinalWaiter(agent, (text, placeholder) => {
2172
2285
  // Preserve #305's reply_final contract: only this task's own completed reading turn qualifies.
2173
2286
  let ownTurn = false;
@@ -2177,7 +2290,11 @@ export function apply(ctx, config) {
2177
2290
  const window = read === undefined ? undefined : readingTurnReply(events, read);
2178
2291
  ownTurn = window !== undefined && window.completed && window.reply !== '' && window.reply === text;
2179
2292
  }
2180
- resolve({ ok: true, reply: text, ...(placeholder === true ? { placeholder: true } : {}), ...(ownTurn ? { ownTurn: true } : {}) });
2293
+ const result = { ok: true, reply: text, ...(placeholder === true ? { placeholder: true } : {}), ...(ownTurn ? { ownTurn: true } : {}) };
2294
+ if (admissionFinished)
2295
+ resolve(result);
2296
+ else
2297
+ earlyReply = result;
2181
2298
  }, taskId !== undefined && taskId !== '' ? { taskId, steerFloor: floorAtRegistration } : undefined);
2182
2299
  // The header names the sender first: `caller` is the routing node's
2183
2300
  // own label (the session that issued the route), while `team` is this
@@ -2192,8 +2309,11 @@ export function apply(ctx, config) {
2192
2309
  const text = `${headerLine}\n\n${message}`;
2193
2310
  try {
2194
2311
  const floor = sessionEventsOf(agent).length;
2195
- steerRelay(agent, text, caller === '' ? undefined : caller, localFrom);
2312
+ followupRelay(agent, text, caller === '' ? undefined : caller, localFrom, taskId, callerKey, onSteered !== undefined);
2196
2313
  onSteered?.(agent, floor);
2314
+ admissionFinished = true;
2315
+ if (earlyReply)
2316
+ resolve(earlyReply);
2197
2317
  if (onSteered !== undefined && sessionEventsOf(agent).slice(floor).some(event => event?.type === 'assistant/message'))
2198
2318
  queueMicrotask(() => flushFinals(String(agent.id))); // synchronous final may already have emitted idle
2199
2319
  }
@@ -2201,13 +2321,17 @@ export function apply(ctx, config) {
2201
2321
  // The waiter was registered above, so the key always holds it; the
2202
2322
  // map-of-arrays stays a plain read.
2203
2323
  const key = String(agent.id);
2204
- const entries = pendingFinals.get(key);
2324
+ const entries = pendingFinals.get(key) ?? [];
2205
2325
  const kept = entries.filter(entry => entry !== waiter);
2206
2326
  if (kept.length === 0)
2207
2327
  pendingFinals.delete(key);
2208
2328
  else
2209
2329
  pendingFinals.set(key, kept);
2210
2330
  waiter.timeoutDisposer?.();
2331
+ if (error instanceof AdmittedBookkeepingError) {
2332
+ resolve({ ok: true, reply: ADMITTED_BOOKKEEPING_NOTE, receiverBookkeeping: 'failed', placeholder: true });
2333
+ return;
2334
+ }
2211
2335
  resolve({ ok: false, error: `The DSH session rejected the message: ${String(error)}` });
2212
2336
  }
2213
2337
  });
@@ -2237,6 +2361,23 @@ export function apply(ctx, config) {
2237
2361
  boundMissRecent.unshift({ ...row, count: row.count + 1, lastAt: Date.now() });
2238
2362
  boundMissRecent.length = Math.min(boundMissRecent.length, 16);
2239
2363
  };
2364
+ /**
2365
+ * Envelopes that were present but failed verification, by claimed caller, claimed URL and reason —
2366
+ * `bad-ts` is a sender clock off by more than ±5 min, `no-card`/`bad-sig` a key or reachability fault.
2367
+ * Observation only: the faces' refusals are unchanged. Caller fields are the sender's unverified
2368
+ * header values (capped), so read them as claims, not identities. Counts verification attempts.
2369
+ */
2370
+ let claimRejectsInboundCount = 0;
2371
+ const claimRejectsRecent = [];
2372
+ const noteClaimReject = (req, reason) => {
2373
+ claimRejectsInboundCount += 1;
2374
+ const header = (name) => String(req.headers[name] ?? '').slice(0, 200);
2375
+ const caller = header('x-a2a-caller'), callerUrl = header('x-a2a-caller-url');
2376
+ const at = claimRejectsRecent.findIndex(row => row.caller === caller && row.callerUrl === callerUrl && row.reason === reason);
2377
+ const row = at >= 0 ? claimRejectsRecent.splice(at, 1)[0] : { caller, callerUrl, reason, count: 0, lastAt: 0 };
2378
+ claimRejectsRecent.unshift({ ...row, count: row.count + 1, lastAt: Date.now() });
2379
+ claimRejectsRecent.length = Math.min(claimRejectsRecent.length, 16);
2380
+ };
2240
2381
  /** Our configured seeds anchor origin claims; mounted claims require exact mount matching. */
2241
2382
  function configuredSeedCaller(callerUrl) {
2242
2383
  return config.peers.some(peer => {
@@ -2254,6 +2395,7 @@ export function apply(ctx, config) {
2254
2395
  const claim = await verifyOriginClaimOf(req, {
2255
2396
  cardCache,
2256
2397
  fetch: fetchImpl,
2398
+ freshCard: true,
2257
2399
  audiences: [ownCardUrl, ...config.originClaimAudiences ?? []],
2258
2400
  unboundOpen: config.originClaimUnboundWindow !== false,
2259
2401
  onUnbound: () => { unboundInboundCount += 1; },
@@ -2263,6 +2405,8 @@ export function apply(ctx, config) {
2263
2405
  ...(options?.cli === true ? { cliDeposit: cliDepositCard } : {}),
2264
2406
  ...(options?.rawBody !== undefined ? { rawBody: options.rawBody } : {}),
2265
2407
  });
2408
+ if (!claim.ok && claim.reason !== 'absent')
2409
+ noteClaimReject(req, claim.reason);
2266
2410
  // #312: recover bilateral seed evidence from a signed, reduction-only card,
2267
2411
  // never from a caller-selected unseeded URL and never recursively.
2268
2412
  const discovery = new URL(req.url ?? '/', 'http://x').searchParams.get('discovery') === '1';
@@ -2382,6 +2526,9 @@ export function apply(ctx, config) {
2382
2526
  for (const ref of canvasStore.remoteMembersOf(name)) {
2383
2527
  if (ref === caller)
2384
2528
  return true;
2529
+ const qualified = splitQualifiedMember(ref);
2530
+ if (qualified?.handle === caller && callerCard?.publicKey === canvasStore.ownerKeyOf(ref))
2531
+ return true;
2385
2532
  }
2386
2533
  }
2387
2534
  // Basis 3, BILATERAL (review M1 fix): OUR config.peers seeds a URL
@@ -2447,14 +2594,14 @@ export function apply(ctx, config) {
2447
2594
  memberships: () => teamMemberships.list(),
2448
2595
  joined: () => joinedSessions.list(),
2449
2596
  canvasNames: () => canvasStore.list(),
2450
- canvasTeamsOf: sessionId => canvasStore.teamsOf(sessionId),
2451
- canvasMembersOf: name => canvasStore.membersOf(name),
2597
+ canvasTeamsOf: sessionId => canvasTeamsOfSession(sessionId),
2598
+ canvasMembersOf: name => canvasLocalMembersOf(name),
2452
2599
  canvasRemoteMembersOf: name => canvasStore.remoteMembersOf(name),
2453
2600
  peerOrgs: function* () {
2454
2601
  for (const cached of cardCache.values()) {
2455
2602
  const org = cached.card?.canvasOrg;
2456
2603
  if (Array.isArray(org))
2457
- yield org;
2604
+ yield org.map(row => ({ ...row, members: Array.isArray(row.members) ? row.members.map((ref) => splitQualifiedMember(ref) && localHandleResolver()(ref) !== undefined ? splitQualifiedMember(ref).handle : ref) : row.members }));
2458
2605
  }
2459
2606
  },
2460
2607
  liveNodeIds: () => [...sessionNodes.values()].map(agent => String(agent.id)),
@@ -2471,8 +2618,43 @@ export function apply(ctx, config) {
2471
2618
  return verdict === undefined ? undefined : refusalOf(verdict);
2472
2619
  }
2473
2620
  /** Inbound admission with the rule and shared teams that admitted the caller (gh#114 S1). */
2474
- function a2aCallerAdmission(caller, targetTeam, cliProof) {
2475
- return callerAdmission(admissionRecords, caller, targetTeam, cliProof);
2621
+ function a2aCallerAdmission(caller, targetTeam, cliProof, signerKey) {
2622
+ const ownKey = encodePublicKey(createPublicKey(nodePrivateKey));
2623
+ const members = (name) => canvasStore.membersOf(name).flatMap(ref => {
2624
+ const qualified = splitQualifiedMember(ref);
2625
+ if (!qualified)
2626
+ return [ref];
2627
+ const provenance = memberProvenance(ref, canvasStore.ownerKeyOf(ref), ownKey);
2628
+ if (provenance === 'confirmed-local') {
2629
+ const local = localHandleResolver()(ref);
2630
+ return local ? [local, qualified.handle] : [];
2631
+ }
2632
+ return provenance === 'confirmed-remote' && signerKey === canvasStore.ownerKeyOf(ref) ? [qualified.handle] : [];
2633
+ });
2634
+ const foreignSigner = signerKey !== undefined && signerKey !== ownKey;
2635
+ const records = { ...admissionRecords,
2636
+ callerJoined: () => admissionRecords.joined().filter(id => !foreignSigner || config.team + '/' + id8(id) !== caller),
2637
+ callerMemberships: () => admissionRecords.memberships().filter(row => !foreignSigner || config.team + '/' + id8(row.session) !== caller),
2638
+ callerCanvasMembersOf: (name) => {
2639
+ const effective = new Set(members(name));
2640
+ return canvasStore.membersOf(name).flatMap(ref => {
2641
+ const qualified = splitQualifiedMember(ref);
2642
+ if (qualified?.handle === caller && foreignSigner && canvasStore.ownerKeyOf(ref) !== signerKey)
2643
+ return [];
2644
+ if (effective.has(ref))
2645
+ return [ref];
2646
+ return qualified && effective.has(qualified.handle) ? [qualified.handle] : [];
2647
+ });
2648
+ },
2649
+ canvasMembersOf: members,
2650
+ canvasRemoteMembersOf: (name) => canvasStore.remoteMembersOf(name).flatMap(ref => {
2651
+ const qualified = splitQualifiedMember(ref);
2652
+ if (!qualified)
2653
+ return [ref];
2654
+ return canvasStore.ownerKeyOf(ref) === signerKey && memberProvenance(ref, canvasStore.ownerKeyOf(ref), ownKey) === 'confirmed-remote' ? [qualified.handle] : [];
2655
+ }),
2656
+ };
2657
+ return callerAdmission(records, caller, targetTeam, cliProof);
2476
2658
  }
2477
2659
  function a2aCallerRefusal(caller, targetTeam) {
2478
2660
  return refusalOf(a2aCallerAdmission(caller, targetTeam));
@@ -2612,7 +2794,7 @@ export function apply(ctx, config) {
2612
2794
  logger,
2613
2795
  config,
2614
2796
  cliDeposits,
2615
- canvasStore,
2797
+ canvasStore, canvasMemberForWrite,
2616
2798
  canvasNameReserved: name => canvasNameReserved(name, config.team, ownerAllowlist, teamMemberships),
2617
2799
  sessionNodes,
2618
2800
  joinedSessions,
@@ -2633,6 +2815,18 @@ export function apply(ctx, config) {
2633
2815
  scanSessionLogList,
2634
2816
  readSessionLogTail,
2635
2817
  taskLedger,
2818
+ receiptReplayAllowed: (message, caller, team, claim) => {
2819
+ const parsed = parseReceipt(message);
2820
+ if (!parsed || !claim.bound || claim.bodyBound !== true || !claimCovers(caller, claim.caller) || !isRootOriginUrl(claim.callerUrl))
2821
+ return false;
2822
+ const row = taskLedger.archive().find(r => r.taskId === parsed.taskId);
2823
+ if (!row || row.endedBy !== 'receipt' || row.peerKey !== claim.card.publicKey)
2824
+ return false;
2825
+ const creditor = row.auth?.receipt_target ?? row.sender?.id;
2826
+ if (creditor !== team)
2827
+ return false;
2828
+ return judgeSettlement(row, { caller, proof: 'origin-claim', claimKey: claim.card.publicKey, claimOrigin: originOf(claim.callerUrl), ingress: 'wire' }, { hostLabel: session, membersOf: () => undefined, ...(parsed.envelope?.onBehalfOf ? { onBehalfOf: parsed.envelope.onBehalfOf } : {}) }).ok;
2829
+ },
2636
2830
  refuseIfFlooded,
2637
2831
  refuseIfQueueFull,
2638
2832
  settleAndAnnounce, waitForForwardBirth,
@@ -2645,7 +2839,7 @@ export function apply(ctx, config) {
2645
2839
  probeConsumption,
2646
2840
  sessionTeamOf,
2647
2841
  localSessionOfHandle,
2648
- steerRelay,
2842
+ steerRelay, followupRelay, cancelQueuedTask,
2649
2843
  fromClause,
2650
2844
  sanitizeFromClause,
2651
2845
  armReceiptAutosend,
@@ -2696,28 +2890,42 @@ export function apply(ctx, config) {
2696
2890
  path: '/__dsh_a2a/tasks/cancel',
2697
2891
  handler: controlRoute((req, res) => {
2698
2892
  readJsonBody(req, res, (body) => {
2699
- const fields = body;
2700
- const taskId = typeof fields.task_id === 'string' ? fields.task_id : '';
2701
- const reason = typeof fields.reason === 'string' ? fields.reason : undefined;
2702
- const result = taskLedger.cancel(taskId, reason);
2703
- let steered = false;
2704
- if (result.outcome === 'cleared' && result.team !== undefined) {
2705
- const live = resolveAgentForTeam(result.team)
2706
- ?? canvasLiveAgent(parseCanvasTeamName(result.team))
2707
- ?? (result.team === config.team ? liveAgent() : undefined);
2708
- if (live !== undefined) {
2709
- try {
2710
- steerRelay(live, cancelNoticeText(taskId, reason));
2711
- steered = true;
2712
- }
2713
- catch {
2714
- // Bookkeeping already settled; steering is best-effort only.
2893
+ void (async () => {
2894
+ const fields = body;
2895
+ const taskId = typeof fields.task_id === 'string' ? fields.task_id : '';
2896
+ const reason = typeof fields.reason === 'string' ? fields.reason : undefined;
2897
+ const row = taskLedger.find(taskId);
2898
+ const result = taskLedger.cancel(taskId, reason);
2899
+ let removed = false, steered = false;
2900
+ try {
2901
+ if (row && result.outcome === 'cleared') {
2902
+ const notice = cancelNoticeText(taskId, reason);
2903
+ if (row.peer === 'local' || row.direction === 'inbound') {
2904
+ const notification = await cancelQueuedTask(taskId, notice, session, row);
2905
+ removed = notification.removed;
2906
+ steered = notification.steered;
2907
+ }
2908
+ else {
2909
+ const card = await cachedCardFetch(row.peer);
2910
+ const supportsQueuedCancel = card?.capabilities?.queuedTaskCancellation === true;
2911
+ const result = await client.routeDirect(row.peer, row.team, notice, undefined, undefined, supportsQueuedCancel ? (row.sender?.kind === 'session' ? row.sender.id : row.auth?.caller ?? session) : session, true, undefined, undefined, undefined, supportsQueuedCancel ? taskId : undefined);
2912
+ if (!result.ok)
2913
+ throw Error('remote queued cancellation was not confirmed');
2914
+ }
2715
2915
  }
2716
2916
  }
2917
+ catch (error) {
2918
+ logger.warn('a2a: cancellation committed; stop notice was best-effort: %s', String(error));
2919
+ }
2920
+ const payload = JSON.stringify({ ...result, steered, removed });
2921
+ res.writeHead(200, { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(payload) });
2922
+ res.end(payload);
2923
+ })().catch(() => { if (!res.headersSent) {
2924
+ res.writeHead(503, { 'Content-Type': 'application/json' });
2925
+ res.end(JSON.stringify({ error: 'queued task cancellation could not be committed' }));
2717
2926
  }
2718
- const payload = JSON.stringify({ ...result, steered });
2719
- res.writeHead(200, { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(payload) });
2720
- res.end(payload);
2927
+ else if (!res.writableEnded)
2928
+ res.end(); });
2721
2929
  });
2722
2930
  }),
2723
2931
  }), 'a2a: task cancel route');
@@ -2955,6 +3163,14 @@ export function apply(ctx, config) {
2955
3163
  if (!linked && handles.size > 0 && zone !== '') {
2956
3164
  outer: for (const name of canvasStore.list()) {
2957
3165
  for (const ref of canvasStore.remoteMembersOf(name)) {
3166
+ const qualified = splitQualifiedMember(ref);
3167
+ if (qualified) {
3168
+ if (cached?.publicKey === canvasStore.ownerKeyOf(ref) && handles.has(qualified.handle)) {
3169
+ linked = true;
3170
+ break outer;
3171
+ }
3172
+ continue;
3173
+ }
2958
3174
  if (handles.has(ref)) {
2959
3175
  linked = true;
2960
3176
  break outer;
@@ -2979,6 +3195,51 @@ export function apply(ctx, config) {
2979
3195
  return { url, card, linked: card === undefined || peerAdmissionLinkState(url).link === 'linked' };
2980
3196
  });
2981
3197
  }
3198
+ async function canvasMemberForWrite(ref, requestedPeer, editorCard) {
3199
+ if (CLI_HANDLE_RE.test(ref))
3200
+ return { ok: true, member: ref };
3201
+ const selected = splitQualifiedMember(ref), handle = selected?.handle ?? ref;
3202
+ if (!ref.includes('/')) {
3203
+ if (editorCard)
3204
+ return { ok: false, error: 'peer writes must use owner-qualified session members; upgrade the editor' };
3205
+ return (joinedSessions.has(ref) || sessionNodes.has(ref)) && archivedSessionFilter()?.(ref) !== true ? { ok: true, member: ref } : { ok: false, error: 'local session must be joined and not archived' };
3206
+ }
3207
+ if (editorCard && !selected)
3208
+ return { ok: false, error: 'new bare session member refused; upgrade or confirm locally' };
3209
+ const ownKey = encodePublicKey(createPublicKey(nodePrivateKey));
3210
+ if (selected && memberProvenance(ref, canvasStore.ownerKeyOf(ref), ownKey) === 'confirmed-local') {
3211
+ const locals = [...new Set([...joinedSessions.list(), ...sessionNodes.keys()])].filter(id => config.team + '/' + id8(id) === handle && archivedSessionFilter()?.(id) !== true);
3212
+ if (locals.length === 1)
3213
+ return { ok: true, member: ref, ownerKey: ownKey };
3214
+ return { ok: false, error: 'local qualified member is not uniquely joined' };
3215
+ }
3216
+ const peers = [...new Set([...peerStore.listSeeded(), ...config.delegates.map(row => row.url)])];
3217
+ if (requestedPeer !== undefined && (typeof requestedPeer !== 'string' || !peers.includes(requestedPeer)))
3218
+ return { ok: false, error: 'select an existing linked seed/delegate owner' };
3219
+ const cards = [];
3220
+ for (const peer of peers) {
3221
+ const card = await fetchPeerCard(peer);
3222
+ if (!card || !verifyCard(card, Date.now()).ok)
3223
+ continue;
3224
+ cardCache.set(peer, { at: Date.now(), card });
3225
+ if (requestedPeer !== undefined && peer !== requestedPeer)
3226
+ continue;
3227
+ if (peerAdmissionLinkState(peer).link === 'linked')
3228
+ cards.push(card);
3229
+ }
3230
+ if (editorCard && verifyCard(editorCard, Date.now()).ok)
3231
+ cards.push(editorCard);
3232
+ if (selected && new Set(cards.filter(card => canvasOwnerFingerprint(card.publicKey) === selected.fingerprint).map(card => card.publicKey)).size > 1)
3233
+ return { ok: false, error: 'owner fingerprint collision; no member written' };
3234
+ const matching = cards.filter(card => handle.startsWith(card.team + '/') && /^[0-9a-f]{8}$/.test(handle.slice(card.team.length + 1)) && card.sessionTeams?.some(row => row.team === handle) && (!selected || canvasOwnerFingerprint(card.publicKey) === selected.fingerprint));
3235
+ const keys = [...new Set(matching.map(card => card.publicKey))];
3236
+ if (keys.length !== 1)
3237
+ return { ok: false, error: 'member owner is unconfirmed, hidden, offline or ambiguous; select a verifiable host' };
3238
+ const member = qualifiedSessionMember(handle, keys[0]);
3239
+ if (!member || (selected && member !== ref))
3240
+ return { ok: false, error: 'member fingerprint/key mismatch' };
3241
+ return { ok: true, member, ownerKey: keys[0] };
3242
+ }
2982
3243
  async function cliHomeForAdd(handle, requested) {
2983
3244
  const localHousehold = () => cliDeposits.directoryRows().some(row => row.team === handle);
2984
3245
  if (localHousehold())
@@ -3112,6 +3373,7 @@ export function apply(ctx, config) {
3112
3373
  };
3113
3374
  const SWEEP_CONCURRENCY = 6;
3114
3375
  const infoTools = createInfoTools({
3376
+ nudgeEscalations: () => nudgeObservations.snapshot(),
3115
3377
  ctx, config, logger, session, lanIp, get ownCardUrl() { return ownCardUrl; }, peerStore, taskLedger,
3116
3378
  inFlightRoutes, recentActivity, joinedSessions, sessionNodes, canvasStore,
3117
3379
  teamMemberships, exposureStore, idempotencyStore, cardCache,
@@ -3123,7 +3385,7 @@ export function apply(ctx, config) {
3123
3385
  get ownerAllowlist() { return ownerAllowlist; },
3124
3386
  get ownerTeamScopeInfo() { return ownerTeamScopeInfo; },
3125
3387
  teamAuditRing, reportRing, a2aJoinGateRefusal, observeAuthorization,
3126
- settlePeerCard, client, listDirectoryTeams, canvasTeamsOfSession, localHandleResolver,
3388
+ settlePeerCard, client, listDirectoryTeams, canvasTeamsOfSession, localHandleResolver, canvasMigrationState,
3127
3389
  SWEEP_CONCURRENCY,
3128
3390
  });
3129
3391
  infoTools.registerWhoami();
@@ -3253,13 +3515,13 @@ export function apply(ctx, config) {
3253
3515
  * by task id and taken (deleted) by whoever dispatched.
3254
3516
  */
3255
3517
  const localBirths = new Map();
3256
- const localBirthOf = (team, callerSession, receiptTarget, deliveredTo) => {
3518
+ const localBirthOf = (team, callerSession, receiptTarget, deliveredTo, receiverSessionId) => {
3257
3519
  const admission = localCandidateAdmission(admissionRecords, callerSession, team);
3258
3520
  const caller = callerSession ?? session;
3259
3521
  const auth = admission.ok && caller !== ''
3260
3522
  ? buildAuthorization(admission, { caller, proof: 'in-process', ingress: 'local', allowlistVersion: allowlistVersionNow(), target: team, receiptTarget: receiptTarget ?? caller, receivedAt: Date.now() })
3261
3523
  : undefined;
3262
- return { ...(auth !== undefined ? { auth } : {}), ...(deliveredTo !== team ? { deliveredTo } : {}) };
3524
+ return { receiverSessionId, ...(auth !== undefined ? { auth } : {}), ...(deliveredTo !== team ? { deliveredTo } : {}) };
3263
3525
  };
3264
3526
  /**
3265
3527
  * #139: the peer facts a remote outbound row is born with — the signed
@@ -3278,12 +3540,13 @@ export function apply(ctx, config) {
3278
3540
  const trackOwedTask = (taskId, team, peer, result, callbackAddress, brief, sender, birth) => {
3279
3541
  if (!result.ok)
3280
3542
  return;
3543
+ birth = { ...birth, ...(typeof result.consumed === 'boolean' ? { consumed: result.consumed } : {}) };
3281
3544
  // Native-teams bridge rounds emit no A2A receipt in this slice: booking
3282
3545
  // them as receipt-owed would fill the owed book with rows no receipt can
3283
3546
  // ever settle (evicting genuinely receivable peer rows past TASK_CAP).
3284
3547
  if (result.bridge !== undefined)
3285
3548
  return;
3286
- if (result.task_status !== 'TASK_STATE_DELIVERED')
3549
+ if (result.task_status !== 'TASK_STATE_DELIVERED' && result.receiverBookkeeping !== 'failed')
3287
3550
  return;
3288
3551
  // Fix ② (ghost-debt stop): a delivered async dispatch with no routable
3289
3552
  // callback address can never be settled — register it receiptless
@@ -3405,9 +3668,10 @@ export function apply(ctx, config) {
3405
3668
  const senderScopeRecords = {
3406
3669
  zone: config.team, id8, teamsOf: id => teamMemberships.teamsOf(id), membersOf: team => teamMemberships.membersOf(team),
3407
3670
  canvasNames: () => canvasStore.list(), canvasMembers: name => canvasStore.membersOf(name), canvasTeam: canvasTeamOf,
3671
+ memberHandle: ref => splitQualifiedMember(ref)?.handle ?? ref,
3408
3672
  peerOrgs: function* () { for (const hit of cardCache.values())
3409
3673
  if (hit.card?.canvasOrg)
3410
- yield hit.card.canvasOrg; },
3674
+ yield hit.card.canvasOrg.map(row => ({ ...row, members: row.members.map(ref => splitQualifiedMember(ref) && localHandleResolver()(ref) !== undefined ? splitQualifiedMember(ref).handle : ref) })); },
3411
3675
  warmPeers: () => mapBounded(resolutionPeers(), SWEEP_CONCURRENCY, cachedCardFetch),
3412
3676
  };
3413
3677
  async function routeTrunk(args, agent, signal, hostCaller) {
@@ -3430,14 +3694,35 @@ export function apply(ctx, config) {
3430
3694
  a2aJoinGateRefusal(agent === undefined ? undefined : { agent });
3431
3695
  // #282 S2: an owner-qualified canvas id. Our own fingerprint is our canvas; a foreign one never
3432
3696
  // lands on a same-named local canvas, and the directory walk keeps only that owner.
3697
+ const requestedMember = splitQualifiedMember(String(args.team ?? '').trim());
3698
+ if (requestedMember && localHandleResolver()(String(args.team).trim()) !== undefined)
3699
+ args = { ...args, team: requestedMember.handle };
3433
3700
  const ownerQualified = splitCanvasOwner(String(args.team ?? '').trim());
3434
3701
  const foreignOwner = ownerQualified.owner !== undefined && ownerQualified.owner !== ownCanvasFingerprint ? ownerQualified.owner : undefined;
3435
3702
  if (ownerQualified.owner !== undefined)
3436
3703
  args = { ...args, team: ownerQualified.team };
3437
- // A target-authored receipt attempt answers its own work even if scope/transport later refuses delivery.
3704
+ // A receipt may use only its own frozen return lane; local answer state is not delivery proof.
3438
3705
  const answer = parseReceipt(args.message);
3439
- if (agent !== undefined && answer !== null)
3440
- taskLedger.markAnswered(answer.taskId, sessionTeamOf(agent));
3706
+ const receiptRow = answer === null ? undefined : taskLedger.find(answer.taskId);
3707
+ if (agent !== undefined && answer !== null) {
3708
+ try {
3709
+ taskLedger.markAnswered(answer.taskId, sessionTeamOf(agent));
3710
+ }
3711
+ catch {
3712
+ return { ok: false, error: 'task ledger write failed; receipt was not sent', code: -32000, peer_content: false };
3713
+ }
3714
+ }
3715
+ const ownReceipt = agent !== undefined && receiptRow?.status === 'pending' && receiptRow.direction === 'inbound' && receiptRow.receiptExpected !== false && receiptRow.auth?.receipt_target === args.team && (receiptRow.deliveredTo ?? receiptRow.team) === sessionTeamOf(agent) && (receiptRow.receiverSessionId === undefined || receiptRow.receiverSessionId === String(agent.id));
3716
+ if (ownReceipt && receiptRow.auth?.proof === 'origin-claim' && receiptRow.auth.claim !== undefined) {
3717
+ const birthHome = receiptRow.auth.carrier_url ?? receiptRow.auth.claim.origin;
3718
+ const home = CLI_HANDLE_RE.test(args.team) ? canvasStore.cliHomeOf(args.team) ?? birthHome : birthHome;
3719
+ if (originOf(home) !== originOf(birthHome))
3720
+ return { ok: false, error: 'receipt return home no longer matches the task birth', code: -32000, receipt: 'none' };
3721
+ const result = await dispatchPeerCandidate(home, args.team, args.message, args.context_id, signal, sessionTeamOf(agent), true);
3722
+ if (result.ok && result.settlementRefused !== true && !settleOwnInbound(args.message, args.team, { caller: sessionTeamOf(agent), proof: 'in-process', ingress: 'local' }))
3723
+ return { ok: false, error: 'receipt reached the peer but local settlement commit failed; task remains owed', code: -32000, receipt: 'none' };
3724
+ return result;
3725
+ }
3441
3726
  // S3 (team-scoped routing, phase-2 default ON per the 2026-09-01 owner
3442
3727
  // ruling; set false to restore the open outbound seam): a session
3443
3728
  // caller must have declared membership in the target team before it
@@ -3529,7 +3814,7 @@ export function apply(ctx, config) {
3529
3814
  // named; the peer request carries the member handle it must resolve.
3530
3815
  const canvasName = foreignOwner !== undefined ? undefined : parseCanvasTeamName(args.team);
3531
3816
  // gh#131: one of this host's own handles is a local member, never a directory target.
3532
- const remoteTargets = canvasName !== undefined ? ((resolve) => canvasStore.remoteMembersOf(canvasName).filter(ref => resolve(ref) === undefined))(localHandleResolver()) : [];
3817
+ const remoteTargets = canvasName !== undefined ? ((resolve) => canvasStore.remoteMembersOf(canvasName).filter(ref => resolve(ref) === undefined && (memberProvenance(ref, canvasStore.ownerKeyOf(ref), encodePublicKey(createPublicKey(nodePrivateKey))) !== 'conflict')))(localHandleResolver()) : [];
3533
3818
  const targets = remoteTargets.length > 0 ? [...remoteTargets] : [args.team];
3534
3819
  if (remoteTargets.length > 0) {
3535
3820
  failures.push(`local: canvas team "${args.team}" has no live or wakeable local member`);
@@ -3544,7 +3829,7 @@ export function apply(ctx, config) {
3544
3829
  const candidatePool = [];
3545
3830
  for (const target of targets) {
3546
3831
  for (const candidate of await directoryPeerCandidates(fetch, ownerQualified.owner !== undefined && target === args.team ? `${target}@${ownerQualified.owner}` : target, failures, scopedCanvasCaller)) {
3547
- candidatePool.push({ candidate, target });
3832
+ candidatePool.push({ candidate, target: splitQualifiedMember(target)?.handle ?? target });
3548
3833
  }
3549
3834
  }
3550
3835
  // Failover: try every candidate in order — an offline path falls back
@@ -3566,8 +3851,8 @@ export function apply(ctx, config) {
3566
3851
  if (result.ok || signal.aborted) {
3567
3852
  // A receipt the creditor refused (its enforce verdict) pays nothing here either (review of PR #149 P2-2).
3568
3853
  if (result.ok && routedReceipt) {
3569
- if (result.settlementRefused !== true)
3570
- settleOwnInbound(args.message, args.team, { caller: callerSession ?? '', proof: 'in-process', ingress: 'local' });
3854
+ if (result.settlementRefused !== true && !settleOwnInbound(args.message, args.team, { caller: callerSession ?? '', proof: 'in-process', ingress: 'local' }))
3855
+ return { ok: false, error: 'receipt reached the peer but local settlement commit failed; task remains owed', code: -32000, receipt: 'none' };
3571
3856
  }
3572
3857
  else if (result.ok)
3573
3858
  trackOwedTask(taskId, args.team, candidate, result, callbackAddress, briefOf(args.message), sender, await peerBirth(fetch, candidate, target === args.team ? undefined : target));
@@ -3750,11 +4035,11 @@ export function apply(ctx, config) {
3750
4035
  const receiptHint = callerTeam === undefined ? '' : `\n\n(When done, route your outcome back with one call — a2a_route { team: "${callerTeam}", message: "[A2A receipt] task ${taskId} <one-line outcome>" }.)`;
3751
4036
  // #114 S5: the consumption search starts at the pre-steer log length.
3752
4037
  const steerFloor = sessionEventsOf(target).length;
3753
- steerRelay(target, `[A2A direct] (task ${taskId}) 未验证(in-process) ${sanitizeFromClause(callerSession ?? session)} (routed to ${team}) sent:\n\n${message}${receiptHint}`, callerTeam, originAgentId);
4038
+ followupRelay(target, `[A2A direct] (task ${taskId}) 未验证(in-process) ${sanitizeFromClause(callerSession ?? session)} (routed to ${team}) sent:\n\n${message}${receiptHint}`, callerTeam, originAgentId, taskId, undefined, parseReceipt(message) === null);
3754
4039
  armReceiptAutosend(target, taskId, callerTeam, message, steerFloor);
3755
4040
  // #139: the caller's outbound row is born with this admission and the member that received it.
3756
4041
  if (taskIdFromCaller !== undefined)
3757
- localBirths.set(taskIdFromCaller, localBirthOf(team, callerSession, callerTeam, sessionTeamOf(target)));
4042
+ localBirths.set(taskIdFromCaller, localBirthOf(team, callerSession, callerTeam, sessionTeamOf(target), String(target.id)));
3758
4043
  endRoute(flight);
3759
4044
  recordActivity('out', team, 'local', true);
3760
4045
  return {
@@ -3764,6 +4049,7 @@ export function apply(ctx, config) {
3764
4049
  task_id: taskId,
3765
4050
  context_id: contextId ?? `ctx-${Math.random().toString(16).slice(2, 10)}`,
3766
4051
  task_status: 'TASK_STATE_DELIVERED',
4052
+ consumed: taskLedger.deliveryFor(taskId)?.state === 'claimed',
3767
4053
  };
3768
4054
  }
3769
4055
  catch (error) {
@@ -3946,7 +4232,7 @@ export function apply(ctx, config) {
3946
4232
  throw new Error(`CLI receipt home is not pinned for ${team}`);
3947
4233
  const delivered = await dispatchPeerCandidate(home, team, message, contextId, signal, peerCaller ?? callerSession, asyncMode, taskId);
3948
4234
  if (!delivered.ok)
3949
- throw new Error(`CLI receipt to pinned home failed: ${delivered.error}`);
4235
+ throw receiptFailure(delivered);
3950
4236
  return delivered;
3951
4237
  }
3952
4238
  const local = await dispatchLocalCandidate(team, message, callerSession, contextId, signal, asyncMode, taskId);
@@ -3955,7 +4241,7 @@ export function apply(ctx, config) {
3955
4241
  localBirths.delete(taskId);
3956
4242
  if (local !== undefined) {
3957
4243
  if (!local.ok)
3958
- throw new Error(local.error);
4244
+ throw receiptFailure(local);
3959
4245
  return local;
3960
4246
  }
3961
4247
  if (cliReceipt)
@@ -3966,15 +4252,19 @@ export function apply(ctx, config) {
3966
4252
  throw new Error(`no route to "${team}": not a local team and not published by any tracked peer`);
3967
4253
  }
3968
4254
  let lastError = 'no candidate delivered';
4255
+ let receiptError;
3969
4256
  for (const candidate of candidates) {
3970
4257
  const result = await dispatchPeerCandidate(candidate, team, message, contextId, signal, peerCaller ?? callerSession, asyncMode, taskId);
3971
4258
  if (result.ok)
3972
4259
  return result;
3973
4260
  lastError = result.error;
4261
+ const failure = receiptFailure(result);
4262
+ if (receiptError === undefined || failure.transient)
4263
+ receiptError = failure;
3974
4264
  if (signal.aborted)
3975
4265
  break;
3976
4266
  }
3977
- throw new Error(`delivery to "${team}" failed on every candidate: ${lastError}`);
4267
+ throw receiptError ?? new Error(`delivery to "${team}" failed on every candidate: ${lastError}`);
3978
4268
  }
3979
4269
  /**
3980
4270
  * The directory's candidate walk for one team: direct publishers first,
@@ -4013,12 +4303,34 @@ export function apply(ctx, config) {
4013
4303
  // itself after the target). referralTeamResolution restores the legacy
4014
4304
  // whole-store basis for fleets mid-migration.
4015
4305
  // #282 S2: `<canvas>@<fp8>` selects only the owner whose signed card key matches; no delegation walk.
4016
- const qualified = splitCanvasOwner(team);
4306
+ if (!splitQualifiedMember(team)) {
4307
+ const qualifiedRefs = [...new Set(canvasStore.list().flatMap(name => canvasStore.membersOf(name)).filter(ref => splitQualifiedMember(ref)?.handle === team))];
4308
+ const keys = new Set(qualifiedRefs.map(ref => canvasStore.ownerKeyOf(ref)).filter(Boolean));
4309
+ if (keys.size > 1) {
4310
+ failures.push('ambiguous member owners; select an owner-qualified handle');
4311
+ return [];
4312
+ }
4313
+ if (qualifiedRefs.length === 1)
4314
+ team = qualifiedRefs[0];
4315
+ }
4316
+ const member = splitQualifiedMember(team);
4317
+ const storedMember = team;
4318
+ const qualified = member ? { team: member.handle, owner: member.fingerprint } : splitCanvasOwner(team);
4319
+ const ownerKey = member ? canvasStore.ownerKeyOf(storedMember) : undefined;
4320
+ if (member && !ownerKey) {
4321
+ failures.push('member provenance is unconfirmed; owner must confirm the full key first');
4322
+ return [];
4323
+ }
4017
4324
  team = qualified.team;
4018
- const ownerOk = (card) => qualified.owner === undefined || canvasOwnerFingerprint(card?.publicKey) === qualified.owner;
4325
+ const ownerOk = (card) => (qualified.owner === undefined || canvasOwnerFingerprint(card?.publicKey) === qualified.owner) && (member === undefined || card?.publicKey === ownerKey);
4019
4326
  const peers = resolutionPeers();
4020
4327
  const candidates = [];
4021
- const cards = (await mapBounded(peers, CANDIDATE_CONCURRENCY, async (peer) => ({ peer, card: await fetch(peer) }))).map(entry => ownerOk(entry.card) ? entry : { peer: entry.peer, card: undefined });
4328
+ const fetched = await mapBounded(peers, CANDIDATE_CONCURRENCY, async (peer) => ({ peer, card: await fetch(peer) }));
4329
+ if (member && fetched.some(entry => entry.card && canvasOwnerFingerprint(entry.card.publicKey) === member.fingerprint && entry.card.publicKey !== ownerKey)) {
4330
+ failures.push('member owner fingerprint collision or key change; no payload sent');
4331
+ return [];
4332
+ }
4333
+ const cards = fetched.map(entry => ownerOk(entry.card) ? entry : { peer: entry.peer, card: undefined });
4022
4334
  // Check after fetching all cards: a seeded peer publishing the same id8
4023
4335
  // makes the caller handle ambiguous, so do not send a canvas payload.
4024
4336
  const sessions = scopedCanvasCaller === undefined ? undefined : localHandleResolver();
@@ -4031,6 +4343,10 @@ export function apply(ctx, config) {
4031
4343
  for (const entry of cards) {
4032
4344
  if (entry.card === undefined)
4033
4345
  continue;
4346
+ if (member && entry.card.capabilities?.canvasMemberProvenance !== true) {
4347
+ failures.push('selected member host needs a provenance-capable upgrade');
4348
+ continue;
4349
+ }
4034
4350
  if (entry.card.team === team || sessionCandidateMatches(entry.card, team) || canvasCandidateMatches(entry.card, team, scopedCanvasCaller, resolveLocal))
4035
4351
  candidates.push(entry.peer);
4036
4352
  }
@@ -4112,7 +4428,7 @@ export function apply(ctx, config) {
4112
4428
  const card = await fetch(candidate);
4113
4429
  peerAsync = card?.capabilities?.async === true;
4114
4430
  }
4115
- const result = await dispatchPeerCandidate(candidate, splitCanvasOwner(request.handle).team, request.message, request.contextId, abort, session, request.delivery === 'async' && peerAsync, taskId, callbackAddress);
4431
+ const result = await dispatchPeerCandidate(candidate, splitQualifiedMember(request.handle)?.handle ?? splitCanvasOwner(request.handle).team, request.message, request.contextId, abort, session, request.delivery === 'async' && peerAsync, taskId, callbackAddress);
4116
4432
  if (!result.ok) {
4117
4433
  // Idempotency verdicts (B3) are terminal, not failover: a replay
4118
4434
  // says the prior attempt at THIS peer stays authoritative —
@@ -4208,45 +4524,31 @@ export function apply(ctx, config) {
4208
4524
  },
4209
4525
  cancel: async (ref, reason) => {
4210
4526
  const taskId = ref.taskId;
4211
- if (taskId === undefined || taskId === '')
4527
+ if (!taskId)
4212
4528
  return false;
4213
4529
  const row = taskLedger.list().find(entry => entry.taskId === taskId);
4214
- if (row === undefined)
4530
+ if (!row)
4215
4531
  return false;
4216
- const notice = cancelNoticeText(taskId, reason);
4217
- if (row.peer === 'local') {
4218
- // Mirror the tasks/cancel control route: the cooperative stop-notice
4219
- // steers the live local target, not just the ledger row. The target
4220
- // resolution spans every kind the dispatcher can book — session
4221
- // node, canvas team, bare process team — a row's team is whatever
4222
- // the dispatch accepted, so the cancel must accept the same set.
4223
- const live = resolveAgentForTeam(row.team)
4224
- ?? canvasLiveAgent(parseCanvasTeamName(row.team))
4225
- ?? (row.team === config.team ? liveAgent() : undefined);
4226
- if (live !== undefined) {
4227
- try {
4228
- steerRelay(live, notice);
4229
- }
4230
- catch {
4231
- // Bookkeeping below settles regardless; steering is best-effort.
4232
- }
4532
+ // Caller bookkeeping has priority. Notification compatibility never gates cancellation.
4533
+ const cleared = taskLedger.cancel(taskId, reason).outcome === 'cleared';
4534
+ if (!cleared)
4535
+ return false;
4536
+ try {
4537
+ const notice = cancelNoticeText(taskId, reason);
4538
+ if (row.peer === 'local' || row.direction === 'inbound')
4539
+ await cancelQueuedTask(taskId, notice, session, row);
4540
+ else {
4541
+ const card = await cachedCardFetch(row.peer);
4542
+ const supportsQueuedCancel = card?.capabilities?.queuedTaskCancellation === true;
4543
+ const result = await client.routeDirect(row.peer, row.team, notice, undefined, undefined, supportsQueuedCancel ? (row.sender?.kind === 'session' ? row.sender.id : row.auth?.caller ?? session) : session, true, undefined, undefined, undefined, supportsQueuedCancel ? taskId : undefined);
4544
+ if (!result.ok)
4545
+ throw Error(result.error);
4233
4546
  }
4234
4547
  }
4235
- else {
4236
- // Peers do NOT track inbound task ids (the owed ledger's writer runs
4237
- // on the dispatcher side), so the peer's ledger-cancel route would
4238
- // deterministically answer 'unknown'. Drive the stop-notice through
4239
- // the wire to the team itself instead. The notice deliberately
4240
- // carries NO task_id: the original id is already claimed at the
4241
- // peer's idempotency ledger with a different payload, so reusing it
4242
- // would 409-conflict before any steer happens (a bodyless key is
4243
- // always fresh there). The original id rides the notice text.
4244
- const result = await client.routeDirect(row.peer, row.team, notice, undefined, undefined, session, true);
4245
- if (!result.ok) {
4246
- logger.warn(`a2a: cancel stop-notice for task ${taskId} failed to deliver to ${row.team}: ${result.error}`);
4247
- }
4548
+ catch (error) {
4549
+ logger.warn('a2a: cancellation committed; stop notice was best-effort: %s', String(error));
4248
4550
  }
4249
- return taskLedger.cancel(taskId, reason).outcome === 'cleared';
4551
+ return true;
4250
4552
  },
4251
4553
  };
4252
4554
  ctx.reflect.provide(NATIVE_TEAMS_A2A_FACE_KEY, bridgeFace);
@@ -4254,6 +4556,16 @@ export function apply(ctx, config) {
4254
4556
  infoTools.registerTasks();
4255
4557
  infoTools.registerProbe();
4256
4558
  const pendingFinals = new Map();
4559
+ function answerFinalSafely(entry, text, placeholder) {
4560
+ try {
4561
+ entry.answer(text, placeholder);
4562
+ return true;
4563
+ }
4564
+ catch (error) {
4565
+ logger.warn('a2a: final callback failed; task remains owed: %s', String(error));
4566
+ return false;
4567
+ }
4568
+ }
4257
4569
  const armFlushTimeout = (agentId, waiter) => {
4258
4570
  return ctx.timer.timeout(() => {
4259
4571
  const entries = pendingFinals.get(agentId);
@@ -4265,7 +4577,7 @@ export function apply(ctx, config) {
4265
4577
  pendingFinals.delete(agentId);
4266
4578
  else
4267
4579
  pendingFinals.set(agentId, kept);
4268
- waiter.answer('The DSH session produced no final reply within the configured window.', true);
4580
+ answerFinalSafely(waiter, 'The DSH session produced no final reply within the configured window.', true);
4269
4581
  }, config.flushTimeoutMs);
4270
4582
  };
4271
4583
  function flushFinals(agentId) {
@@ -4276,7 +4588,7 @@ export function apply(ctx, config) {
4276
4588
  if (agent === undefined) {
4277
4589
  for (const entry of entries) {
4278
4590
  entry.timeoutDisposer?.();
4279
- entry.answer('The target DSH session is no longer live.', true);
4591
+ answerFinalSafely(entry, 'The target DSH session is no longer live.', true);
4280
4592
  }
4281
4593
  pendingFinals.delete(agentId);
4282
4594
  return;
@@ -4315,17 +4627,25 @@ export function apply(ctx, config) {
4315
4627
  }
4316
4628
  if (answered.length === 0)
4317
4629
  return;
4318
- const kept = entries.filter(entry => !answered.some(done => done.entry === entry));
4319
- if (kept.length === 0)
4320
- pendingFinals.delete(agentId);
4321
- else
4322
- pendingFinals.set(agentId, kept);
4323
4630
  for (const { entry, reply, placeholder } of answered) {
4324
- entry.timeoutDisposer?.();
4325
- if (placeholder === true)
4326
- entry.answer('The consuming turn produced no completed reply.', true);
4631
+ // Remove one callback before invoking (it may synchronously re-enter), never the whole batch.
4632
+ const current = pendingFinals.get(agentId) ?? [];
4633
+ if (!current.includes(entry))
4634
+ continue;
4635
+ const kept = current.filter(other => other !== entry);
4636
+ if (kept.length)
4637
+ pendingFinals.set(agentId, kept);
4327
4638
  else
4328
- entry.answer(reply);
4639
+ pendingFinals.delete(agentId);
4640
+ if (answerFinalSafely(entry, placeholder ? 'The consuming turn produced no completed reply.' : reply, placeholder)) {
4641
+ entry.timeoutDisposer?.();
4642
+ }
4643
+ else {
4644
+ // A failed commit is still owed. Keep its original floor/consuming turn for a later idle retry.
4645
+ const now = pendingFinals.get(agentId) ?? [];
4646
+ if (!now.includes(entry))
4647
+ pendingFinals.set(agentId, [entry, ...now]);
4648
+ }
4329
4649
  }
4330
4650
  }
4331
4651
  /**
@@ -4399,8 +4719,10 @@ export function apply(ctx, config) {
4399
4719
  // no-op. Node facts need no refresh here: the card serves them fresh at
4400
4720
  // read time.
4401
4721
  ctx.on('agent/status', (payload) => {
4402
- if (payload.status === 'idle')
4722
+ if (payload.status === 'idle') {
4403
4723
  flushFinals(String(payload.agent.id));
4724
+ requestReminderScan();
4725
+ }
4404
4726
  });
4405
4727
  }
4406
4728
  /**