@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
@@ -0,0 +1,43 @@
1
+ /** Session-member provenance (#334). fp8 selects an owner; only a full key anchors it. */
2
+ import { createPublicKey } from 'node:crypto';
3
+ import { canvasOwnerFingerprint } from "./canvas-id.js";
4
+ import { CLI_HANDLE_RE } from "./cli-deposit.js";
5
+ /** CLI handles keep their existing home/deposit rules and are never parsed as session members. */
6
+ export function splitQualifiedMember(ref) {
7
+ if (CLI_HANDLE_RE.test(ref))
8
+ return undefined;
9
+ const match = /^([^/\s@]+\/[0-9a-f]{8})@([0-9a-f]{8})$/.exec(ref);
10
+ return match ? { handle: match[1], fingerprint: match[2] } : undefined;
11
+ }
12
+ export function validMemberOwner(ref, key) {
13
+ const member = splitQualifiedMember(ref);
14
+ if (!member || !key || canvasOwnerFingerprint(key) !== member.fingerprint)
15
+ return false;
16
+ try {
17
+ const bytes = Buffer.from(key, 'base64'), publicKey = createPublicKey({ key: bytes, format: 'der', type: 'spki' });
18
+ return publicKey.asymmetricKeyType === 'ed25519' && publicKey.export({ format: 'der', type: 'spki' }).equals(bytes) && bytes.toString('base64') === key;
19
+ }
20
+ catch {
21
+ return false;
22
+ }
23
+ }
24
+ export function qualifiedSessionMember(handle, key) {
25
+ const ref = handle + '@' + canvasOwnerFingerprint(key);
26
+ return validMemberOwner(ref, key) ? ref : undefined;
27
+ }
28
+ export function memberProvenance(ref, ownerKey, ownKey) {
29
+ if (CLI_HANDLE_RE.test(ref))
30
+ return 'cli';
31
+ if (!ref.includes('/'))
32
+ return 'local-id';
33
+ const qualified = splitQualifiedMember(ref);
34
+ if (!qualified)
35
+ return ref.includes('@') ? 'conflict' : 'unknown';
36
+ if (ownerKey === undefined)
37
+ return qualified.fingerprint === canvasOwnerFingerprint(ownKey) ? 'confirmed-local' : 'unknown';
38
+ if (!validMemberOwner(ref, ownerKey))
39
+ return 'conflict';
40
+ if (qualified.fingerprint === canvasOwnerFingerprint(ownKey) && ownerKey !== ownKey)
41
+ return 'conflict';
42
+ return ownerKey === ownKey ? 'confirmed-local' : 'confirmed-remote';
43
+ }
@@ -21,6 +21,7 @@ import { randomUUID } from 'node:crypto';
21
21
  import { hostname } from 'node:os';
22
22
  import { dirname } from 'node:path';
23
23
  import { CLI_HANDLE_RE } from "./cli-deposit.js";
24
+ import { splitQualifiedMember, validMemberOwner, memberProvenance } from "./canvas-member.js";
24
25
  /** A bare session handle is not a CLI identity or a qualified owner selector. */
25
26
  export function legacyBareMember(id) {
26
27
  return isRemoteMemberRef(id) && !CLI_HANDLE_RE.test(id) && !/@[0-9a-f]{8}$/.test(id);
@@ -135,8 +136,15 @@ export class CanvasStore {
135
136
  members.add(member);
136
137
  }
137
138
  }
138
- if (snapshot.memberOwners !== undefined && (snapshot.memberOwners === null || typeof snapshot.memberOwners !== 'object' || Array.isArray(snapshot.memberOwners) || Object.values(snapshot.memberOwners).some(key => typeof key !== 'string')))
139
+ if (snapshot.memberOwners !== undefined && (snapshot.memberOwners === null || typeof snapshot.memberOwners !== 'object' || Array.isArray(snapshot.memberOwners) || Object.entries(snapshot.memberOwners).some(([ref, key]) => typeof key !== 'string' || !validMemberOwner(ref, key))))
139
140
  throw Error('invalid canvas v2 owners');
141
+ const fpKeys = new Map();
142
+ for (const [ref, key] of Object.entries(snapshot.memberOwners ?? {})) {
143
+ const fp = splitQualifiedMember(ref).fingerprint;
144
+ if (fpKeys.has(fp) && fpKeys.get(fp) !== key)
145
+ throw Error('conflicting canvas member fingerprints');
146
+ fpKeys.set(fp, key);
147
+ }
140
148
  if (snapshot.cliHome !== undefined && (snapshot.cliHome === null || typeof snapshot.cliHome !== 'object' || Array.isArray(snapshot.cliHome) || Object.entries(snapshot.cliHome).some(([handle, home]) => !CLI_HANDLE_RE.test(handle) || typeof home !== 'string' || !validHomeOrigin(home) || !snapshot.teams.some(t => t.members.includes(handle)))))
141
149
  throw Error('invalid canvas v2 CLI homes');
142
150
  if (snapshot.cliHomeCleared !== undefined && (!Array.isArray(snapshot.cliHomeCleared) || new Set(snapshot.cliHomeCleared).size !== snapshot.cliHomeCleared.length || snapshot.cliHomeCleared.some(handle => typeof handle !== 'string' || !CLI_HANDLE_RE.test(handle) || !snapshot.teams.some(t => t.members.includes(handle)))))
@@ -207,6 +215,7 @@ export class CanvasStore {
207
215
  const previous = JSON.parse(this.committed);
208
216
  this.teams = previous.teams.map(t => ({ name: t.name, members: [...t.members] }));
209
217
  this.suspended = [...previous.suspendedMembers ?? []];
218
+ this.memberOwners = { ...previous.memberOwners };
210
219
  this.cliHome = { ...previous.cliHome };
211
220
  this.cliHomeCleared = new Set(previous.cliHomeCleared);
212
221
  this.revision = previous.revision ?? 0;
@@ -220,7 +229,7 @@ export class CanvasStore {
220
229
  }
221
230
  }
222
231
  serialize() {
223
- return JSON.stringify({ teams: this.teams, suspendedMembers: this.suspended, cliHome: this.cliHome, cliHomeCleared: [...this.cliHomeCleared], revision: this.revision });
232
+ return JSON.stringify({ teams: this.teams, memberOwners: this.memberOwners, suspendedMembers: this.suspended, cliHome: this.cliHome, cliHomeCleared: [...this.cliHomeCleared], revision: this.revision });
224
233
  }
225
234
  write() {
226
235
  if (this.readOnlyReason)
@@ -328,7 +337,15 @@ export class CanvasStore {
328
337
  const entry = this.teams.find(t => t.name === name);
329
338
  if (this.readOnlyReason || this.failedSuspension)
330
339
  return [];
331
- return entry === undefined ? [] : entry.members.filter(id => !this.isSuspended(name, id) && (this.options.legacyBareMembers !== false || !legacyBareMember(id)));
340
+ return entry === undefined ? [] : entry.members.filter(id => {
341
+ if (this.isSuspended(name, id) || (this.options.legacyBareMembers === false && legacyBareMember(id)))
342
+ return false;
343
+ if (this.options.ownPublicKey && id.includes('@') && !CLI_HANDLE_RE.test(id)) {
344
+ const provenance = memberProvenance(id, this.memberOwners[id], this.options.ownPublicKey);
345
+ return provenance === 'confirmed-local' || provenance === 'confirmed-remote';
346
+ }
347
+ return true;
348
+ });
332
349
  }
333
350
  /** Inspection only: never use raw rows for routing, authorization or counts. */
334
351
  rawMembersOf(name) { return [...(this.teams.find(t => t.name === name)?.members ?? [])]; }
@@ -341,6 +358,48 @@ export class CanvasStore {
341
358
  ...(this.readOnlyReason || this.failedSuspension || this.lastWriteError ? { error: this.readOnlyReason || (this.failedSuspension ? 'canvas suspension persistence failed; restart after repair' : this.lastWriteError) } : {}) };
342
359
  }
343
360
  ownerKeyOf(member) { return this.memberOwners[member]; }
361
+ ownerConflict(member, key) {
362
+ const fp = splitQualifiedMember(member)?.fingerprint;
363
+ return Object.entries(this.memberOwners).some(([ref, pinned]) => (ref === member || splitQualifiedMember(ref)?.fingerprint === fp) && pinned !== key);
364
+ }
365
+ /** Owner control only: CAS the complete slot and preserve suspension plus routing priority. */
366
+ confirmMember(team, member, replacement, ownerKey, revision, expectedOwner) {
367
+ if (this.readOnlyReason || this.failedSuspension)
368
+ return { ok: false, error: this.state().error };
369
+ const entry = this.teams.find(t => t.name === team), index = entry?.members.indexOf(member) ?? -1;
370
+ if (!entry || index < 0 || revision !== this.revision || expectedOwner !== (this.memberOwners[member] ?? null))
371
+ return { ok: false, error: 'canvas version, member slot or owner changed; refresh first' };
372
+ const sameId8 = (value) => value.replace(/^session-/, '').slice(0, 8);
373
+ const originalHandle = splitQualifiedMember(member)?.handle ?? member;
374
+ const replacementHandle = splitQualifiedMember(replacement)?.handle ?? replacement;
375
+ if (member.includes('/') && (replacement.includes('/') ? originalHandle !== replacementHandle : !originalHandle.endsWith('/' + sameId8(replacement))))
376
+ return { ok: false, error: 'confirmation must identify the same original session handle' };
377
+ if (!member.includes('/') || !replacement || CLI_HANDLE_RE.test(member) || CLI_HANDLE_RE.test(replacement))
378
+ return { ok: false, error: 'session provenance confirmation only; CLI homes use their own controls' };
379
+ const qualified = splitQualifiedMember(replacement);
380
+ if ((replacement.includes('/') && !qualified) || (qualified && (!ownerKey || !validMemberOwner(replacement, ownerKey) || this.ownerConflict(replacement, ownerKey))))
381
+ return { ok: false, error: 'owner key/fingerprint conflict or unproved member' };
382
+ if (this.memberOwners[member] !== undefined && (replacement !== member || ownerKey !== this.memberOwners[member]))
383
+ return { ok: false, error: 'confirmed owner cannot be silently replaced' };
384
+ const equivalent = entry.members.indexOf(replacement);
385
+ if (equivalent >= 0 && equivalent !== index && this.suspensionOf(team, member).some(row => this.suspensionOf(team, replacement).some(other => other.reason === row.reason && other.localSessionId === row.localSessionId)))
386
+ return { ok: false, error: 'both slots have independent matching suspension reasons; handle them explicitly before merging' };
387
+ if (equivalent >= 0 && equivalent !== index)
388
+ entry.members.splice(index, 1);
389
+ else
390
+ entry.members[index] = replacement;
391
+ this.suspended = this.suspended.map(row => row.team === team && row.member === member ? { ...row, member: replacement } : row)
392
+ .filter((row, i, all) => all.findIndex(other => other.team === row.team && other.member === row.member && other.reason === row.reason && other.localSessionId === row.localSessionId) === i);
393
+ if (qualified)
394
+ this.memberOwners[replacement] = ownerKey;
395
+ try {
396
+ this.persist();
397
+ return { ok: true };
398
+ }
399
+ catch (error) {
400
+ return { ok: false, error: String(error) };
401
+ }
402
+ }
344
403
  /** Durably revoke canvas consent BEFORE join/agent/roster state is removed. */
345
404
  revokeLocal(id, ownQualified, inferredBare, reason, team) {
346
405
  if (this.readOnlyReason || this.failedSuspension)
@@ -353,7 +412,7 @@ export class CanvasStore {
353
412
  this.suspended.push({ team: entry.name, member: inferredBare, localSessionId: id, reason, at: new Date().toISOString(), token: randomUUID() });
354
413
  changed = true;
355
414
  }
356
- const kept = entry.members.filter(m => m !== id && m !== ownQualified);
415
+ const kept = entry.members.filter(m => (m !== id && m !== ownQualified) || this.isSuspended(entry.name, m));
357
416
  if (kept.length !== entry.members.length) {
358
417
  entry.members = kept;
359
418
  changed = true;
@@ -462,10 +521,15 @@ export class CanvasStore {
462
521
  return false;
463
522
  if (home !== undefined && this.cliHome[id] !== undefined && this.cliHome[id] !== home && options.replaceHome !== true)
464
523
  return false;
524
+ const qualified = splitQualifiedMember(id);
525
+ if (qualified && (!options.ownerKey || !validMemberOwner(id, options.ownerKey) || this.ownerConflict(id, options.ownerKey)))
526
+ return false;
465
527
  const entry = this.teams.find(t => t.name === name);
466
528
  if (entry === undefined)
467
529
  return false;
468
530
  if (entry.members.includes(id)) {
531
+ if (qualified && this.memberOwners[id] === undefined)
532
+ return false; // Existing unknown slots require explicit confirmation, not re-add.
469
533
  if (home !== undefined && this.cliHome[id] !== home) {
470
534
  this.cliHome[id] = home;
471
535
  this.cliHomeCleared.delete(id);
@@ -476,6 +540,8 @@ export class CanvasStore {
476
540
  if (entry.members.length >= CANVAS_MEMBER_CAP)
477
541
  return false;
478
542
  entry.members.push(id);
543
+ if (qualified)
544
+ this.memberOwners[id] = options.ownerKey;
479
545
  if (home !== undefined) {
480
546
  this.cliHome[id] = home;
481
547
  this.cliHomeCleared.delete(id);
@@ -3,6 +3,7 @@ import { DEFAULT_HOP_BUDGET, guardHeader, mintGuard } from "./recursion-guard.js
3
3
  import { briefOf } from "./task-ledger.js";
4
4
  import { parseReceipt } from "./receipt.js";
5
5
  import { splitCanvasOwner } from "./canvas-id.js";
6
+ import { splitQualifiedMember } from "./canvas-member.js";
6
7
  import { claimCovers } from "./task-auth.js";
7
8
  /** An early receipt waits at most this long for its forward row to be born (#298). */
8
9
  export const BIRTH_WAIT_CAP_MS = 60_000;
@@ -46,8 +47,9 @@ export function createDirectForward(d) {
46
47
  if (!request.localCliProof || !d.deposited(request.caller))
47
48
  return refuse('caller is not a proven online local CLI deposit', publish);
48
49
  // #282 S2: candidates honour an owner-qualified id; scope, the peer, the receivers and the ledger see the plain team.
49
- const plainTeam = splitCanvasOwner(request.team).team;
50
- const scope = await d.scope(request.caller, plainTeam);
50
+ const qualifiedMember = splitQualifiedMember(request.team);
51
+ const plainTeam = qualifiedMember?.handle ?? splitCanvasOwner(request.team).team;
52
+ const scope = await d.scope(request.caller, qualifiedMember ? request.team : plainTeam);
51
53
  if (!scope.ok)
52
54
  return refuse('target is outside sender scope: ' + scope.error, `ask this host's operator to link ${request.team} here: drag it (or one of its members) into a canvas team on this host`);
53
55
  const failures = [];
@@ -83,9 +85,9 @@ export function createDirectForward(d) {
83
85
  if (result.ok && result.bridge !== undefined)
84
86
  return { ...result, receipt: 'none' };
85
87
  const deliveredTo = result.ok ? result.deliveredTo : undefined;
86
- const birth = { forwarded: true, ...(request.auth !== undefined ? { auth: request.auth } : {}), ...(peerCard?.publicKey ? { peerKey: peerCard.publicKey } : {}), ...(peerCard?.session ? { peerHost: peerCard.session } : {}), ...(deliveredTo !== undefined ? { deliveredTo } : {}), ...(receivers !== undefined ? { receiverMembers: receivers } : {}) };
88
+ const birth = { ...(result.ok && typeof result.consumed === 'boolean' ? { consumed: result.consumed } : {}), forwarded: true, ...(request.auth !== undefined ? { auth: request.auth } : {}), ...(peerCard?.publicKey ? { peerKey: peerCard.publicKey } : {}), ...(peerCard?.session ? { peerHost: peerCard.session } : {}), ...(deliveredTo !== undefined ? { deliveredTo } : {}), ...(receivers !== undefined ? { receiverMembers: receivers } : {}) };
87
89
  // Exactly one outbound row. A completed sync response is receiptless bookkeeping.
88
- if (result.ok && (result.task_status === 'TASK_STATE_COMPLETED' || result.receipt === 'none'))
90
+ if (result.ok && result.receiverBookkeeping !== 'failed' && (result.task_status === 'TASK_STATE_COMPLETED' || result.receipt === 'none'))
89
91
  d.ledger.trackReceiptless(request.taskId, plainTeam, url, request.contextId, briefOf(request.message), { kind: 'cli', id: request.caller }, birth);
90
92
  else
91
93
  d.ledger.track(request.taskId, plainTeam, url, request.contextId, briefOf(request.message), { kind: 'cli', id: request.caller }, birth);
@@ -117,13 +119,13 @@ export function forwardReceiptBasis(row, caller, cliHandle, claim) {
117
119
  // The signer's zone must cover the caller label: B never signs for another zone's member (review of #287 M4).
118
120
  if (!claimCovers(caller, claim.caller))
119
121
  return undefined;
120
- if (row === undefined || row.status !== 'pending' || row.forwarded !== true || row.sender?.kind !== 'cli' || row.sender.id !== cliHandle)
122
+ if (row === undefined || row.status !== 'pending' || row.forwarded !== true || row.sender?.kind !== 'cli' || row.sender.id !== cliHandle || (row.auth !== undefined && row.auth.receipt_target !== cliHandle) || row.receiptExpected === false)
121
123
  return undefined;
122
124
  // Body-bound (Sig3) only: a stripped Sig3 would let a captured envelope carry another body (review of #287 P3-1).
123
125
  if (!claim.bound || claim.bodyBound !== true || row.peerKey === undefined || claim.card.publicKey !== row.peerKey)
124
126
  return undefined;
125
- // A legacy row's deliveredTo may be the canvas handle itself (#272/#275): never a receiver.
126
- if (row.deliveredTo !== undefined && (row.deliveredTo === row.team || row.deliveredTo.includes('/canvas/')))
127
+ // A legacy canvas deliveredTo is never a receiver; a direct session target equal to row.team is valid.
128
+ if (row.deliveredTo !== undefined && row.deliveredTo.includes('/canvas/'))
127
129
  return undefined;
128
130
  return (row.deliveredTo !== undefined ? [row.deliveredTo] : row.receiverMembers ?? []).includes(caller) ? `peer-org:${row.team}` : undefined;
129
131
  }
@@ -18,6 +18,7 @@
18
18
  import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
19
19
  import { join } from 'node:path';
20
20
  import { fileURLToPath } from 'node:url';
21
+ import { reminderView } from "../reconciliation-state.js";
21
22
  import { canvasNameReserved as reservedCanvasName, isRemoteMemberRef, validRemoteMemberRef, legacyBareMember } from "../canvas-store.js";
22
23
  import { qualifiedCanvasId } from "../canvas-id.js";
23
24
  import { refreshPeerCard, registerCanvasNotify } from "./canvas-notify.js";
@@ -76,6 +77,7 @@ export function registerAdminFaces(webServer, d) {
76
77
  id: String(agent.id),
77
78
  label: sessionLabelOf(agent),
78
79
  team: sessionTeamOf(agent),
80
+ memberRef: `${sessionTeamOf(agent)}@${d.ownCanvasFingerprint}`,
79
81
  ...nodeMetadataOf(agent),
80
82
  joined: sessionNodes.has(String(agent.id)),
81
83
  live: true,
@@ -115,6 +117,7 @@ export function registerAdminFaces(webServer, d) {
115
117
  label: `${session}-${id8(id)}`,
116
118
  ...(d.ownerSeatNames[id] !== undefined ? { name: d.ownerSeatNames[id], seatName: d.ownerSeatNames[id] } : {}),
117
119
  team: `${config.team}/${id8(id)}`,
120
+ memberRef: `${config.team}/${id8(id)}@${d.ownCanvasFingerprint}`,
118
121
  joined: true,
119
122
  live: false,
120
123
  teams: [...teamMemberships.teamsOf(id)],
@@ -194,7 +197,7 @@ export function registerAdminFaces(webServer, d) {
194
197
  };
195
198
  }),
196
199
  remote: (refreshRemoteRows(), (d.remoteRowsCache?.rows ?? []).map(row => {
197
- const canvasTeams = canvasStore.teamsOf(row.team);
200
+ const canvasTeams = [...new Set([...canvasStore.teamsOf(row.team), ...(row.memberRef ? canvasStore.teamsOf(row.memberRef) : [])])];
198
201
  return canvasTeams.length > 0 ? { ...row, canvasTeams: [...canvasTeams] } : row;
199
202
  })),
200
203
  activity: recentActivity.slice(),
@@ -207,12 +210,19 @@ export function registerAdminFaces(webServer, d) {
207
210
  // in-flight ring cannot show. Dead-lettered rows ride their own
208
211
  // list and settled ones just a count, all additive so the panel
209
212
  // keeps rendering plain owed rows until it learns the tiers.
213
+ ledgerPersistence: taskLedger.persistenceState(),
214
+ deliveryBookkeeping: taskLedger.deliveryBookkeepingState(),
215
+ nudgeEscalations: d.nudgeEscalations?.() ?? { queued: 0, unavailable: 0, failed: 0 },
216
+ reconciliationReminders: d.reminderDiagnostics?.(),
210
217
  tasks: taskLedger.list()
211
218
  .filter(task => task.status === 'pending')
212
219
  .map(task => ({
220
+ ...reminderView(taskLedger.reminderState(task.taskId)),
213
221
  taskId: task.taskId, team: task.team, peer: task.peer, startedAt: task.startedAt, status: task.status,
214
222
  direction: task.direction ?? 'outbound', receiptExpected: task.receiptExpected !== false,
215
223
  ...(task.answeredAt !== undefined ? { answeredAt: task.answeredAt } : {}),
224
+ ...(task.receiptDelivery ? { receiptDelivery: task.receiptDelivery } : {}),
225
+ ...(task.consumed !== undefined ? { consumed: task.consumed, deliveryObservation: task.consumed ? 'consumed' : 'delivered-unconsumed' } : {}),
216
226
  ...(task.forwarded === true ? { forwarded: true } : {}),
217
227
  ...(task.deliveredTo !== undefined ? { deliveredTo: task.deliveredTo } : {}),
218
228
  ...(task.receiverMembers !== undefined ? { receiverMembers: task.receiverMembers } : {}),
@@ -234,7 +244,8 @@ export function registerAdminFaces(webServer, d) {
234
244
  tasksStaleCount: taskLedger.list().filter(task => task.status === 'pending' && (task.receiptExpected === false || Date.now() - task.startedAt >= 60 * 60_000)).length,
235
245
  tasksDead: taskLedger.list()
236
246
  .filter(task => task.status === 'dead')
237
- .map(task => ({ taskId: task.taskId, team: task.team, peer: task.peer, startedAt: task.startedAt, deadAt: task.deadAt ?? task.startedAt, reason: task.reason ?? 'unspecified', ...(task.answeredAt !== undefined ? { answeredAt: task.answeredAt } : {}), ...(task.brief !== undefined ? { brief: task.brief } : {}), ...(task.direction !== undefined ? { direction: task.direction } : {}), ...(task.sender !== undefined ? { sender: task.sender } : {}), ...(task.auth !== undefined ? { auth: task.auth } : task.direction === 'inbound' ? { authLegacy: true } : {}) })),
247
+ .map(task => ({ taskId: task.taskId, team: task.team, peer: task.peer, startedAt: task.startedAt, deadAt: task.deadAt ?? task.startedAt, reason: task.reason ?? 'unspecified', ...(task.answeredAt !== undefined ? { answeredAt: task.answeredAt } : {}),
248
+ ...(task.receiptDelivery ? { receiptDelivery: task.receiptDelivery } : {}), ...(task.brief !== undefined ? { brief: task.brief } : {}), ...(task.direction !== undefined ? { direction: task.direction } : {}), ...(task.sender !== undefined ? { sender: task.sender } : {}), ...(task.auth !== undefined ? { auth: task.auth } : task.direction === 'inbound' ? { authLegacy: true } : {}) })),
238
249
  // gh#31: per-task outcomes for rows that LEFT the ledger — the
239
250
  // chat drawer used to guess (settled, abandoned or cancelled).
240
251
  // Bounded to the archive (≤64), newest first, with the
@@ -253,6 +264,7 @@ export function registerAdminFaces(webServer, d) {
253
264
  // its direction when known.
254
265
  archived: taskLedger.archive().slice(0, ARCHIVE_CAP).map(row => ({
255
266
  taskId: row.taskId,
267
+ ...(row.withdrawalResult ? { withdrawalResult: row.withdrawalResult } : {}),
256
268
  team: row.team,
257
269
  peer: row.peer,
258
270
  startedAt: row.startedAt,
@@ -345,6 +357,9 @@ export function registerAdminFaces(webServer, d) {
345
357
  boundMissInbound: d.boundMissInboundCount,
346
358
  // Who missed, by verified caller and the origin it dialled (newest first, ≤16).
347
359
  boundMissRecent: d.boundMissRecent,
360
+ // Present-but-unverifiable envelopes by claimed caller and reason (bad-ts = sender clock skew > ±5 min).
361
+ claimRejectsInbound: d.claimRejectsInboundCount,
362
+ claimRejectsRecent: d.claimRejectsRecent,
348
363
  // gh#113: CLI mailbox loss, all handles — mail the 64-row cap
349
364
  // pushed out, and mail cut at 4000 chars. The team verifier
350
365
  // holds `dropped` to zero.
@@ -528,7 +543,7 @@ export function registerAdminFaces(webServer, d) {
528
543
  path: '/__dsh_a2a/fleet-canvas-op',
529
544
  handler: controlRoute((req, res) => {
530
545
  readJsonBody(req, res, async (body) => {
531
- const { peer, op, name, id } = body;
546
+ const { peer, op, name, id, memberPeer } = body;
532
547
  const peerUrl = typeof peer === 'string' ? peer : '';
533
548
  const opV = typeof op === 'string' ? op : '';
534
549
  const nameV = typeof name === 'string' ? name : '';
@@ -547,7 +562,13 @@ export function registerAdminFaces(webServer, d) {
547
562
  }
548
563
  try {
549
564
  const target = peerUrl.replace(/\/$/, '') + '/a2a/canvas-op';
550
- const body = JSON.stringify({ op: opV, name: nameV, ...(opV === 'add-member' || opV === 'remove-member' ? { id: idV !== '' && !isRemoteMemberRef(idV) && (sessionNodes.has(idV) || joinedSessions.has(idV)) ? `${config.team}/${id8(idV)}` : idV } : {}) });
565
+ const proof = opV === 'add-member' ? await d.canvasMemberForWrite(idV, memberPeer) : undefined;
566
+ if (proof && !proof.ok) {
567
+ send(JSON.stringify(proof));
568
+ return;
569
+ }
570
+ const selectedId = proof?.ok ? (proof.member.includes('/') ? proof.member : `${config.team}/${id8(proof.member)}@${d.ownCanvasFingerprint}`) : idV !== '' && !isRemoteMemberRef(idV) && (sessionNodes.has(idV) || joinedSessions.has(idV)) ? `${config.team}/${id8(idV)}@${d.ownCanvasFingerprint}` : idV;
571
+ const body = JSON.stringify({ op: opV, name: nameV, ...(opV === 'add-member' || opV === 'remove-member' ? { id: selectedId } : {}) });
551
572
  const reply = await d.peerFetch(target, {
552
573
  method: 'POST',
553
574
  headers: { 'Content-Type': 'application/json', ...originClaimHeaders({ url: target, method: 'POST', body }) },
@@ -756,7 +777,18 @@ export function registerAdminFaces(webServer, d) {
756
777
  const action = body.action;
757
778
  let payload;
758
779
  try {
759
- if (action === 'restore-suspended' || action === 'delete-suspended') {
780
+ if (action === 'confirm-member') {
781
+ const name = typeof body.name === 'string' ? body.name : '', id = typeof body.id === 'string' ? body.id : '';
782
+ const request = body;
783
+ const replacement = typeof request.replacement === 'string' ? request.replacement : '';
784
+ const originalHandle = id.replace(/@[0-9a-f]{8}$/, '');
785
+ const localChoice = replacement !== '' && !replacement.includes('/');
786
+ const proof = localChoice && originalHandle !== `${config.team}/${id8(replacement)}` ? { ok: false, error: 'local confirmation must match this host zone and the original handle' } : await d.canvasMemberForWrite(replacement, request.peer);
787
+ const out = proof.ok ? canvasStore.confirmMember(name, id, proof.member, proof.ownerKey, request.revision, request.expectedOwner) : proof;
788
+ logger.info('canvas provenance owner confirmation actor=owner-control at=%s team=%s old=%s new=%s ownerKey=%s ok=%s', new Date().toISOString(), name, id, proof.ok ? proof.member : replacement, proof.ok ? proof.ownerKey ?? 'local-id' : 'unconfirmed', out.ok);
789
+ payload = JSON.stringify({ ...out, ...canvasStore.state() });
790
+ }
791
+ else if (action === 'restore-suspended' || action === 'delete-suspended') {
760
792
  const request = body;
761
793
  const name = typeof request.name === 'string' ? request.name : '', id = typeof request.id === 'string' ? request.id : '';
762
794
  const out = canvasStore.regulateSuspended(action === 'restore-suspended' ? 'restore' : 'delete', name, id, request.revision, request.expected);
@@ -802,15 +834,26 @@ export function registerAdminFaces(webServer, d) {
802
834
  else if (name === '' || !memberValid) {
803
835
  payload = JSON.stringify({ ok: false, error: 'name and a member (joined session id, or a zone-qualified remote team handle like zone/a1b2c3d4) are required' });
804
836
  }
837
+ else if (legacyBareMember(id) && canvasStore.rawMembersOf(name).includes(id)) {
838
+ // Re-adding a retained legacy slot is a no-op, never provenance confirmation or restore.
839
+ payload = JSON.stringify({ ok: true, members: canvasStore.membersOf(name), provenance: 'unknown' });
840
+ }
805
841
  else {
806
- const pin = await d.cliHomeForAdd(id, body.home).catch(() => ({ ok: false, error: 'linked home card lookup failed' }));
842
+ const proof = await d.canvasMemberForWrite(id, body.peer);
843
+ if (!proof.ok) {
844
+ payload = JSON.stringify(proof);
845
+ res.writeHead(200, { 'Content-Type': 'application/json' });
846
+ res.end(payload);
847
+ return;
848
+ }
849
+ const pin = await d.cliHomeForAdd(proof.member, body.home).catch(() => ({ ok: false, error: 'linked home card lookup failed' }));
807
850
  if (!pin.ok)
808
851
  payload = JSON.stringify({ ok: false, error: pin.error });
809
852
  else {
810
853
  canvasStore.create(name);
811
854
  const explicitHome = body.home !== undefined;
812
855
  const home = explicitHome ? pin.home : canvasStore.cliHomeOf(id) ?? pin.home;
813
- payload = JSON.stringify({ ok: canvasStore.addMember(name, id, home, { replaceHome: explicitHome }), teams: canvasStore.list(), members: canvasStore.membersOf(name) });
856
+ payload = JSON.stringify({ ok: canvasStore.addMember(name, proof.member, home, { replaceHome: explicitHome, ownerKey: proof.ownerKey }), teams: canvasStore.list(), members: canvasStore.membersOf(name) });
814
857
  }
815
858
  }
816
859
  }
@@ -1,6 +1,7 @@
1
1
  import { BLOB_FILE_MAX } from "../blob-store.js";
2
2
  import { isRootOriginUrl } from "../origin-claim.js";
3
3
  import { claimCovers } from "../task-auth.js";
4
+ import { splitQualifiedMember } from "../canvas-member.js";
4
5
  /** Pulls per proven signer (host zone or CLI handle) in a sliding minute (design §8; the FloodGuard figure). */
5
6
  export const BLOB_PULLS_PER_MINUTE = 16;
6
7
  /** Distinct signers the pull limiter remembers; a full table refuses new signers rather than forgetting live ones. */
@@ -91,7 +92,7 @@ export function registerFileFaces(webServer, d) {
91
92
  refuse(403, 'untrusted-origin', 'the envelope does not prove this caller (it must be the audience-bound form, signed from a root card URL whose zone covers caller_session)');
92
93
  return;
93
94
  }
94
- const grant = blobStore.list().find(g => g.sha256 === sha256 && grantAdmits(g.to, caller, claim.caller, config.team, name => canvasStore.membersOf(name), claim.cli === true));
95
+ const grant = blobStore.list().find(g => g.sha256 === sha256 && grantAdmits(g.to, caller, claim.caller, config.team, name => canvasStore.membersOf(name).flatMap(ref => { const member = splitQualifiedMember(ref); return member ? (canvasStore.ownerKeyOf?.(ref) === claim.card.publicKey ? [member.handle] : []) : [ref]; }), claim.cli === true));
95
96
  // One answer for "no such blob", "expired" and "not yours": a refusal is no existence oracle.
96
97
  if (grant === undefined) {
97
98
  refuse(403, 'not-granted', 'no live grant lets this caller pull that file');