instar 1.3.1321 → 1.3.1323

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 (72) hide show
  1. package/dist/commands/server.d.ts.map +1 -1
  2. package/dist/commands/server.js +20 -1
  3. package/dist/commands/server.js.map +1 -1
  4. package/dist/core/PostUpdateMigrator.d.ts.map +1 -1
  5. package/dist/core/PostUpdateMigrator.js +27 -0
  6. package/dist/core/PostUpdateMigrator.js.map +1 -1
  7. package/dist/data/standards-guard-index.json +1 -1
  8. package/dist/data/standards-guard-index.meta.json +2 -2
  9. package/dist/data/standards-registry.meta.json +1 -1
  10. package/dist/scaffold/templates.d.ts.map +1 -1
  11. package/dist/scaffold/templates.js +13 -0
  12. package/dist/scaffold/templates.js.map +1 -1
  13. package/dist/server/AgentServer.d.ts +3 -0
  14. package/dist/server/AgentServer.d.ts.map +1 -1
  15. package/dist/server/AgentServer.js +3 -0
  16. package/dist/server/AgentServer.js.map +1 -1
  17. package/dist/server/middleware.d.ts +13 -0
  18. package/dist/server/middleware.d.ts.map +1 -1
  19. package/dist/server/middleware.js +21 -0
  20. package/dist/server/middleware.js.map +1 -1
  21. package/dist/server/routes.d.ts +2 -0
  22. package/dist/server/routes.d.ts.map +1 -1
  23. package/dist/server/routes.js +233 -71
  24. package/dist/server/routes.js.map +1 -1
  25. package/dist/threadline/A2ADeliveryTracker.d.ts +59 -2
  26. package/dist/threadline/A2ADeliveryTracker.d.ts.map +1 -1
  27. package/dist/threadline/A2ADeliveryTracker.js +193 -21
  28. package/dist/threadline/A2ADeliveryTracker.js.map +1 -1
  29. package/dist/threadline/ListenerSessionManager.d.ts.map +1 -1
  30. package/dist/threadline/ListenerSessionManager.js +7 -0
  31. package/dist/threadline/ListenerSessionManager.js.map +1 -1
  32. package/dist/threadline/ThreadlineBootstrap.d.ts +9 -0
  33. package/dist/threadline/ThreadlineBootstrap.d.ts.map +1 -1
  34. package/dist/threadline/ThreadlineBootstrap.js +33 -3
  35. package/dist/threadline/ThreadlineBootstrap.js.map +1 -1
  36. package/dist/threadline/ThreadlineMCPServer.d.ts +8 -0
  37. package/dist/threadline/ThreadlineMCPServer.d.ts.map +1 -1
  38. package/dist/threadline/ThreadlineMCPServer.js +8 -0
  39. package/dist/threadline/ThreadlineMCPServer.js.map +1 -1
  40. package/dist/threadline/client/RelayClient.d.ts.map +1 -1
  41. package/dist/threadline/client/RelayClient.js +15 -0
  42. package/dist/threadline/client/RelayClient.js.map +1 -1
  43. package/dist/threadline/client/ThreadlineClient.d.ts +49 -0
  44. package/dist/threadline/client/ThreadlineClient.d.ts.map +1 -1
  45. package/dist/threadline/client/ThreadlineClient.js +169 -0
  46. package/dist/threadline/client/ThreadlineClient.js.map +1 -1
  47. package/dist/threadline/honestDeliveryWiring.d.ts +55 -0
  48. package/dist/threadline/honestDeliveryWiring.d.ts.map +1 -0
  49. package/dist/threadline/honestDeliveryWiring.js +142 -0
  50. package/dist/threadline/honestDeliveryWiring.js.map +1 -0
  51. package/dist/threadline/mcp-http-client.d.ts.map +1 -1
  52. package/dist/threadline/mcp-http-client.js +12 -2
  53. package/dist/threadline/mcp-http-client.js.map +1 -1
  54. package/dist/threadline/relayConnectionObserver.d.ts.map +1 -1
  55. package/dist/threadline/relayConnectionObserver.js +10 -3
  56. package/dist/threadline/relayConnectionObserver.js.map +1 -1
  57. package/dist/threadline/relayVerdict.d.ts +49 -0
  58. package/dist/threadline/relayVerdict.d.ts.map +1 -0
  59. package/dist/threadline/relayVerdict.js +70 -0
  60. package/dist/threadline/relayVerdict.js.map +1 -0
  61. package/package.json +1 -1
  62. package/src/data/builtin-manifest.json +64 -64
  63. package/src/data/standards-guard-index.json +1 -1
  64. package/src/data/standards-guard-index.meta.json +2 -2
  65. package/src/data/standards-registry.meta.json +1 -1
  66. package/src/scaffold/templates.ts +13 -0
  67. package/upgrades/1.3.1322.md +29 -0
  68. package/upgrades/1.3.1323.md +31 -0
  69. package/upgrades/a2a-honest-delivery-outcomes.eli16.md +7 -0
  70. package/upgrades/relay-displaced-reclaim.eli16.md +12 -0
  71. package/upgrades/side-effects/a2a-honest-delivery-outcomes.md +46 -0
  72. package/upgrades/side-effects/relay-displaced-reclaim.md +24 -0
@@ -130,7 +130,8 @@ import { secretKeyPaths } from '../core/SecretSync.js';
130
130
  import { PlaywrightProfileRegistry, PlaywrightRegistryError, PlaywrightRegistryCorruptError, DEFAULT_BLOCK_MAX_BYTES as PLAYWRIGHT_BLOCK_MAX_BYTES, } from '../core/PlaywrightProfileRegistry.js';
131
131
  import { PlaywrightSeatLease } from '../core/PlaywrightSeatLease.js';
132
132
  import { writeConfigAtomic, readSelfKnowledgeFlags } from '../core/BootSelfKnowledge.js';
133
- import { rateLimiter, signViewPath, OUTBOUND_GATE_REVIEW_BUDGET_MS, resolveFollowMeBudgets, FOLLOWME_SIMPLE_RELAY_FETCH_MS } from './middleware.js';
133
+ import { rateLimiter, signViewPath, OUTBOUND_GATE_REVIEW_BUDGET_MS, resolveFollowMeBudgets, FOLLOWME_SIMPLE_RELAY_FETCH_MS, bearerMatches } from './middleware.js';
134
+ import { relayHoldHours } from '../threadline/relayVerdict.js';
134
135
  import { buildTopicProfileOptions, validateDashboardProfileChoice, seedTopicProfileAtCreation, claimDashboardCreatedTopic, settleDashboardCreatedTopic, frameworkLabel } from '../core/dashboardTopicProfile.js';
135
136
  import { reviewWithinBudget } from './outboundGateBudget.js';
136
137
  import { resolveToneRecipientClass } from './toneRecipientClass.js';
@@ -3821,16 +3822,9 @@ export function createRoutes(ctx) {
3821
3822
  };
3822
3823
  // Include detailed info only for authenticated callers.
3823
3824
  // Must actually validate the token here since authMiddleware skips /health.
3824
- let isAuthed = !ctx.config.authToken;
3825
- if (!isAuthed && ctx.config.authToken) {
3826
- const header = req.headers.authorization;
3827
- if (header?.startsWith('Bearer ')) {
3828
- const token = header.slice(7);
3829
- const ha = createHash('sha256').update(token).digest();
3830
- const hb = createHash('sha256').update(ctx.config.authToken).digest();
3831
- isAuthed = timingSafeEqual(ha, hb);
3832
- }
3833
- }
3825
+ // Token-only compare, shared with the pool-scope peer-health read
3826
+ // (`bearerMatches`): byte-identical to the inline check it replaced.
3827
+ const isAuthed = bearerMatches(req, ctx.config.authToken);
3834
3828
  if (isAuthed) {
3835
3829
  const mem = process.memoryUsage();
3836
3830
  // Use ProcessIntegrity for truthful version reporting
@@ -3871,6 +3865,14 @@ export function createRoutes(ctx) {
3871
3865
  base.telegramOriginStorage = { state: 'unavailable' };
3872
3866
  }
3873
3867
  }
3868
+ // Honest delivery (spec §1): verdict-dispatcher counters ride the AUTHED
3869
+ // branch only — every /threadline/* path bypasses Bearer auth by design.
3870
+ if (ctx.relayVerdictCounters) {
3871
+ try {
3872
+ base.threadline = { ...(base.threadline ?? {}), relayVerdicts: ctx.relayVerdictCounters() };
3873
+ }
3874
+ catch { /* counters are best-effort observability */ }
3875
+ }
3874
3876
  base.project = ctx.config.projectName;
3875
3877
  base.node = process.version;
3876
3878
  base.memory = {
@@ -16430,26 +16432,113 @@ document.getElementById('mcpForm').addEventListener('submit', async function (e)
16430
16432
  // ── A2A peer-health (A2A-DURABLE-DELIVERY-SPEC.md) ──────────────────────
16431
16433
  // "Is my channel to <peer> alive?" as a lookup, not a guess. Read-only
16432
16434
  // observability over the durable delivery tracker — never gates a send.
16433
- router.get('/threadline/peers/health', (req, res) => {
16434
- if (!ctx.a2aDeliveryTracker) {
16435
+ // Security posture (spec §6): the DEFAULT scope is reachable without a token
16436
+ // (pre-existing `/threadline/*` middleware exemption), per peer, this machine
16437
+ // only. `?scope=pool` fans out to every machine and therefore checks the token
16438
+ // inline (token-only compare + the agent-id rule) — it guards AMPLIFICATION,
16439
+ // not the per-peer data. Peers are queried with the default scope only.
16440
+ const peerHealthInstarVersion = () => {
16441
+ const integrity = ProcessIntegrity.getInstance();
16442
+ return integrity?.runningVersion ?? (ctx.config.version || '0.0.0');
16443
+ };
16444
+ const poolScopeAuthorized = (req, res) => {
16445
+ if (!bearerMatches(req, ctx.config.authToken)) {
16446
+ res.status(403).json({ error: 'forbidden', scope: 'pool' });
16447
+ return false;
16448
+ }
16449
+ const provided = req.headers['x-instar-agentid'];
16450
+ if (typeof provided === 'string' && ctx.config.projectName && provided !== ctx.config.projectName) {
16451
+ res.status(403).json({ error: 'agent_id_mismatch', expected: ctx.config.projectName });
16452
+ return false;
16453
+ }
16454
+ return true;
16455
+ };
16456
+ const isPeerHealthBody = (b) => Array.isArray(b.peers) && typeof b.count === 'number';
16457
+ const isPeerHealthOne = (b) => typeof b.peerFp === 'string' && typeof b.stale === 'boolean';
16458
+ const poolPeerRows = (peers, pick) => {
16459
+ const local = peerHealthInstarVersion();
16460
+ let mixedVersion = false;
16461
+ const rows = [];
16462
+ for (const p of peers) {
16463
+ const v = typeof p.body.instarVersion === 'string' ? p.body.instarVersion : null;
16464
+ if (v !== local)
16465
+ mixedVersion = true; // absent IS the mixed case
16466
+ for (const item of pick(p.body)) {
16467
+ rows.push({ ...item, machineId: p.machineId, machineNickname: p.nickname, instarVersion: v });
16468
+ }
16469
+ }
16470
+ return { rows, mixedVersion };
16471
+ };
16472
+ router.get('/threadline/peers/health', async (req, res) => {
16473
+ if (!ctx.a2aDeliveryTracker && req.query.scope !== 'pool') {
16435
16474
  res.status(503).json({ error: 'A2A delivery tracker not initialized' });
16436
16475
  return;
16437
16476
  }
16438
16477
  const staleAfterMs = typeof req.query.staleAfterMs === 'string' && Number.isFinite(Number(req.query.staleAfterMs))
16439
16478
  ? Number(req.query.staleAfterMs)
16440
16479
  : undefined;
16441
- const peers = ctx.a2aDeliveryTracker.allPeerHealth({ staleAfterMs });
16442
- res.json({ peers, count: peers.length, staleCount: peers.filter((p) => p.stale).length });
16480
+ const peers = ctx.a2aDeliveryTracker?.allPeerHealth({ staleAfterMs }) ?? null;
16481
+ const localBody = peers
16482
+ ? { peers, count: peers.length, staleCount: peers.filter((p) => p.stale).length, instarVersion: peerHealthInstarVersion() }
16483
+ : null;
16484
+ if (req.query.scope !== 'pool') {
16485
+ if (!localBody) {
16486
+ res.status(503).json({ error: 'A2A delivery tracker not initialized' });
16487
+ return;
16488
+ }
16489
+ res.json(localBody);
16490
+ return;
16491
+ }
16492
+ if (!poolScopeAuthorized(req, res))
16493
+ return;
16494
+ const qs = staleAfterMs !== undefined ? `?staleAfterMs=${staleAfterMs}` : '';
16495
+ const { peers: pool, failed, peersQueried } = await readReaperPoolPeers(`/threadline/peers/health${qs}`, isPeerHealthBody);
16496
+ const selfId = ctx.meshSelfId ?? 'local';
16497
+ const { rows, mixedVersion } = poolPeerRows(pool, (b) => b.peers);
16498
+ const localRows = (localBody?.peers ?? []).map((p) => ({ ...p, machineId: selfId, machineNickname: null, instarVersion: localBody?.instarVersion ?? null }));
16499
+ const all = [...localRows, ...rows];
16500
+ res.json({
16501
+ scope: 'pool',
16502
+ local: localBody,
16503
+ peers: all,
16504
+ count: all.length,
16505
+ staleCount: all.filter((p) => p.stale).length,
16506
+ mixedVersion,
16507
+ pool: { selfMachineId: selfId, peersQueried, peersOk: pool.length, failed },
16508
+ });
16443
16509
  });
16444
- router.get('/threadline/peers/:fp/health', (req, res) => {
16445
- if (!ctx.a2aDeliveryTracker) {
16446
- res.status(503).json({ error: 'A2A delivery tracker not initialized' });
16510
+ router.get('/threadline/peers/:fp/health', async (req, res) => {
16511
+ const fp = req.params.fp;
16512
+ if (!/^[0-9a-f]{6,64}$/i.test(fp)) {
16513
+ res.status(400).json({ error: 'invalid fingerprint' });
16447
16514
  return;
16448
16515
  }
16449
16516
  const staleAfterMs = typeof req.query.staleAfterMs === 'string' && Number.isFinite(Number(req.query.staleAfterMs))
16450
16517
  ? Number(req.query.staleAfterMs)
16451
16518
  : undefined;
16452
- res.json(ctx.a2aDeliveryTracker.peerHealth(req.params.fp, { staleAfterMs }));
16519
+ const local = ctx.a2aDeliveryTracker
16520
+ ? { ...ctx.a2aDeliveryTracker.peerHealth(fp, { staleAfterMs }), instarVersion: peerHealthInstarVersion() }
16521
+ : null;
16522
+ if (req.query.scope !== 'pool') {
16523
+ if (!local) {
16524
+ res.status(503).json({ error: 'A2A delivery tracker not initialized' });
16525
+ return;
16526
+ }
16527
+ res.json(local);
16528
+ return;
16529
+ }
16530
+ if (!poolScopeAuthorized(req, res))
16531
+ return;
16532
+ const qs = staleAfterMs !== undefined ? `?staleAfterMs=${staleAfterMs}` : '';
16533
+ const { peers: pool, failed, peersQueried } = await readReaperPoolPeers(`/threadline/peers/${encodeURIComponent(fp)}/health${qs}`, isPeerHealthOne);
16534
+ const selfId = ctx.meshSelfId ?? 'local';
16535
+ const { rows, mixedVersion } = poolPeerRows(pool, (b) => [b]);
16536
+ const all = [...(local ? [{ ...local, machineId: selfId, machineNickname: null }] : []), ...rows];
16537
+ res.json({
16538
+ scope: 'pool', peerFp: fp, local, machines: all,
16539
+ staleCount: all.filter((p) => p.stale).length,
16540
+ mixedVersion, pool: { selfMachineId: selfId, peersQueried, peersOk: pool.length, failed },
16541
+ });
16453
16542
  });
16454
16543
  // ── Secure A2A Verified Pairing surfaces (§3.6) ────────────────────────
16455
16544
  // List + detail + the PIN-gated operator verify. Ships DARK behind
@@ -34100,7 +34189,7 @@ document.getElementById('mcpForm').addEventListener('submit', async function (e)
34100
34189
  // ── Threadline Reply Waiter ─────────────────────────────────────────
34101
34190
  // Waits for an incoming reply from a specific agent on a specific thread.
34102
34191
  // Used by the relay-send endpoint when waitForReply is true.
34103
- function waitForThreadlineReply(routeCtx, senderAgent, threadId, timeoutSec) {
34192
+ function waitForThreadlineReply(routeCtx, senderAgent, threadId, timeoutSec, onRegistered) {
34104
34193
  const timeout = Math.min(Math.max(timeoutSec ?? 120, 5), 300) * 1000; // 5s–300s, default 120s
34105
34194
  // PR-3: Waiters are keyed by threadId (unique per conversation) rather
34106
34195
  // than sender agent name (which can collide when multiple agents share
@@ -34110,7 +34199,7 @@ document.getElementById('mcpForm').addEventListener('submit', async function (e)
34110
34199
  routeCtx.threadlineReplyWaiters.delete(threadId);
34111
34200
  resolve(null);
34112
34201
  }, timeout);
34113
- routeCtx.threadlineReplyWaiters.set(threadId, {
34202
+ const entry = {
34114
34203
  resolve: (reply) => {
34115
34204
  clearTimeout(timer);
34116
34205
  routeCtx.threadlineReplyWaiters.delete(threadId);
@@ -34119,6 +34208,16 @@ document.getElementById('mcpForm').addEventListener('submit', async function (e)
34119
34208
  threadId,
34120
34209
  senderAgent,
34121
34210
  timer,
34211
+ };
34212
+ routeCtx.threadlineReplyWaiters.set(threadId, entry);
34213
+ // Honest delivery (spec §4): a refused send cancels ITS OWN waiter only —
34214
+ // an orphaned waiter's timeout would otherwise delete a retry's waiter on
34215
+ // the same thread.
34216
+ onRegistered?.(() => {
34217
+ clearTimeout(timer);
34218
+ if (routeCtx.threadlineReplyWaiters.get(threadId) === entry)
34219
+ routeCtx.threadlineReplyWaiters.delete(threadId);
34220
+ resolve(null);
34122
34221
  });
34123
34222
  });
34124
34223
  }
@@ -34858,8 +34957,73 @@ document.getElementById('mcpForm').addEventListener('submit', async function (e)
34858
34957
  // The peer replies on the thread that went on the wire, so record, return
34859
34958
  // and wait on THAT id — never the messageId (ACT-1304 fault 3).
34860
34959
  const { messageId: relayMsgId, threadId: effectiveRelayThreadId } = relayClient.sendAutoWithThread(resolvedId, message, threadId);
34861
- // Canonical outbox write for the relay-delivery path — same shape as the
34862
- // local-delivery path above, so the observability tab sees both paths.
34960
+ // waitForReply: the reply listener is registered NOW, before any await, so
34961
+ // it runs concurrently with the verdict wait (no time comes off the reply
34962
+ // budget, and a fast reply can never land in a gap with no listener).
34963
+ let cancelReplyWait;
34964
+ const replyPromise = waitForReply
34965
+ ? waitForThreadlineReply(ctx, resolvedId, effectiveRelayThreadId, timeoutSeconds, (c) => { cancelReplyWait = c; })
34966
+ : null;
34967
+ // Honest delivery (docs/specs/a2a-honest-delivery-outcomes.md §4). Order:
34968
+ // 1. send (above) 2. recordSent + canonical thread leg + captureOrigin,
34969
+ // all BEFORE the verdict wait so a fast reply never precedes them —
34970
+ // 3. wait ≤3 s for the relay's verdict (cache covers a verdict that
34971
+ // landed during captureOrigin) 4. outbox ONCE with the final outcome
34972
+ // 5. bridge mirror + reply-claim release on 2xx; a refusal 502s and the
34973
+ // finish handler releases the claim (retry allowed).
34974
+ try {
34975
+ ctx.a2aDeliveryTracker?.recordSent({
34976
+ messageId: relayMsgId,
34977
+ peerFp: resolvedId,
34978
+ peerName: targetAgent,
34979
+ threadId: effectiveRelayThreadId,
34980
+ transport: 'relay',
34981
+ });
34982
+ }
34983
+ catch (err) {
34984
+ // @silent-fallback-ok: recording-only — A2A delivery tracking must never break
34985
+ // the send (the message was already submitted above). Logged.
34986
+ console.warn(`[relay-send] A2A delivery record failed (non-fatal): ${err instanceof Error ? err.message : err}`);
34987
+ }
34988
+ // Robustness Phase 2 (D-B): append the relay-submitted outbound leg to the
34989
+ // canonical log through the funnel. The wire createdAt is stamped inside the
34990
+ // relay client, so this end's createdAt is best-effort (symmetry on the relay
34991
+ // path degrades to unverified, advisory-only); F3 holds unconditionally — the
34992
+ // sender's own message is now auditable per-thread regardless of symmetry.
34993
+ // Known limit: a leg the relay then REFUSES stays in the log (the verdict is
34994
+ // not known yet), so that thread reads `diverged` until the next exchange.
34995
+ if (ctx.threadMessageRecorder) {
34996
+ recordThreadMessage(ctx.threadMessageRecorder, {
34997
+ threadId: effectiveRelayThreadId,
34998
+ messageId: relayMsgId,
34999
+ direction: 'outbound',
35000
+ body: message,
35001
+ createdAt: new Date().toISOString(),
35002
+ peerFingerprint: resolvedId ?? undefined,
35003
+ author: { machineId: ctx.meshSelfId ?? undefined, sessionName: typeof originSessionName === 'string' ? originSessionName : undefined },
35004
+ subject: typeof purpose === 'string' ? purpose : undefined,
35005
+ });
35006
+ }
35007
+ // `resolvedId` is the peer's full routing fingerprint; store IT as the
35008
+ // canonical thread owner (display = the raw target the caller typed) so a
35009
+ // reply matches the anti-hijack guard instead of being false-isolated.
35010
+ await captureOrigin(effectiveRelayThreadId, resolvedId, targetAgent);
35011
+ const RELAY_ACK_WAIT_MS = 3000;
35012
+ const verdict = await relayClient.awaitRelayAck(relayMsgId, RELAY_ACK_WAIT_MS);
35013
+ if (verdict?.status === 'rejected')
35014
+ cancelReplyWait?.();
35015
+ const relayStatus = verdict?.status ?? 'unconfirmed';
35016
+ const relayReasonCode = verdict?.reasonCode ?? (relayStatus === 'unconfirmed' && relayClient.banSuspected ? 'banned' : undefined);
35017
+ const banSuspected = relayStatus === 'unconfirmed' && relayClient.banSuspected;
35018
+ const outboxOutcome = relayStatus === 'delivered' ? 'relay-sent'
35019
+ : relayStatus === 'queued' ? 'relay-queued'
35020
+ : relayStatus === 'rejected' ? 'relay-rejected'
35021
+ : 'relay-unconfirmed';
35022
+ if (outboxOutcome === 'relay-unconfirmed')
35023
+ relayClient.noteUnconfirmedSettled();
35024
+ // Canonical outbox write for the relay-delivery path — ONCE, with the final
35025
+ // outcome (the outbox is append-only + HMAC-per-line; no placeholder, no
35026
+ // update-by-id). Same shape as the local-delivery path above.
34863
35027
  if (ctx.listenerManager) {
34864
35028
  try {
34865
35029
  ctx.listenerManager.appendCanonicalOutboxEntry({
@@ -34871,35 +35035,40 @@ document.getElementById('mcpForm').addEventListener('submit', async function (e)
34871
35035
  text: message,
34872
35036
  messageId: relayMsgId,
34873
35037
  inReplyTo: typeof inReplyTo === 'string' ? inReplyTo : undefined,
34874
- outcome: 'relay-sent',
35038
+ outcome: outboxOutcome,
34875
35039
  });
34876
- if (typeof inReplyTo === 'string')
34877
- ctx.listenerManager.releaseReplyClaim(inReplyTo, replyClaimOwner);
34878
35040
  }
34879
35041
  catch (err) {
34880
35042
  console.warn(`[relay-send] Canonical outbox append failed (non-fatal): ${err instanceof Error ? err.message : err}`);
34881
- if (typeof inReplyTo === 'string')
35043
+ if (typeof inReplyTo === 'string' && relayStatus !== 'rejected')
34882
35044
  ctx.listenerManager.retainReplyClaimFailure(inReplyTo, replyClaimOwner);
34883
35045
  }
34884
35046
  }
34885
- // Robustness Phase 2 (D-B): append the relay-submitted outbound leg to the
34886
- // canonical log through the funnel. The wire createdAt is stamped inside the
34887
- // relay client, so this end's createdAt is best-effort (symmetry on the relay
34888
- // path degrades to unverified, advisory-only); F3 holds unconditionally — the
34889
- // sender's own message is now auditable per-thread regardless of symmetry.
34890
- if (ctx.threadMessageRecorder) {
34891
- recordThreadMessage(ctx.threadMessageRecorder, {
34892
- threadId: effectiveRelayThreadId,
35047
+ if (relayStatus === 'rejected') {
35048
+ // A refused send is a failed send: 502 (the relay is the upstream), the
35049
+ // body carries the contract (`retryLater`, never the relay's prose), and
35050
+ // the >=400 finish handler releases the reply claim so a retry is allowed.
35051
+ // NOT retainReplyClaimFailure — that marks "delivered but settlement
35052
+ // failed" and would block the retry forever.
35053
+ res.status(502).json({
35054
+ success: false,
35055
+ error: `not delivered: relay refused (${relayReasonCode ?? 'unmapped'}). Tracked; do not resend now.`,
35056
+ accepted: false,
35057
+ delivered: false,
34893
35058
  messageId: relayMsgId,
34894
- direction: 'outbound',
34895
- body: message,
34896
- createdAt: new Date().toISOString(),
34897
- peerFingerprint: resolvedId ?? undefined,
34898
- author: { machineId: ctx.meshSelfId ?? undefined, sessionName: typeof originSessionName === 'string' ? originSessionName : undefined },
34899
- subject: typeof purpose === 'string' ? purpose : undefined,
35059
+ threadId: effectiveRelayThreadId,
35060
+ resolvedAgent: resolvedId,
35061
+ deliveryPath: 'relay',
35062
+ relayStatus: 'rejected',
35063
+ relayReasonCode: relayReasonCode ?? 'unmapped',
35064
+ retryLater: verdict?.retryLater ?? null,
35065
+ deliveryOutcome: `not delivered: relay refused (${relayReasonCode ?? 'unmapped'}). Tracked; do not resend now.`,
35066
+ topicLinkageStamped: resolvedOriginTopicId !== undefined,
34900
35067
  });
35068
+ return;
34901
35069
  }
34902
- // Mirror outbound into Telegram bridge (relay-only — best effort).
35070
+ // Mirror outbound into Telegram bridge (relay-only — best effort), then
35071
+ // release the reply claim: this is the 2xx path.
34903
35072
  if (ctx.telegramBridge) {
34904
35073
  ctx.telegramBridge.mirrorOutbound({
34905
35074
  threadId: effectiveRelayThreadId,
@@ -34907,43 +35076,35 @@ document.getElementById('mcpForm').addEventListener('submit', async function (e)
34907
35076
  remoteAgentName: targetAgent,
34908
35077
  text: message,
34909
35078
  messageId: relayMsgId,
34910
- outcome: 'relay-sent',
35079
+ outcome: outboxOutcome,
34911
35080
  }).catch(() => { });
34912
35081
  }
34913
- // `resolvedId` is the peer's full routing fingerprint; store IT as the
34914
- // canonical thread owner (display = the raw target the caller typed) so a
34915
- // reply matches the anti-hijack guard instead of being false-isolated.
34916
- // This is the fix for the composite "name:fpPrefix" address being stored
34917
- // un-resolved and causing a known peer's replies to cold-spawn.
34918
- await captureOrigin(effectiveRelayThreadId, resolvedId, targetAgent);
34919
- // Durable delivery record (A2A-DURABLE-DELIVERY-SPEC.md): start the
34920
- // awaiting-ack lifecycle so silence becomes a visible, escalatable signal.
34921
- // Recording-only — never gates the send (already happened above).
34922
- try {
34923
- ctx.a2aDeliveryTracker?.recordSent({
34924
- messageId: relayMsgId,
34925
- peerFp: resolvedId,
34926
- peerName: targetAgent,
34927
- threadId: effectiveRelayThreadId,
34928
- transport: 'relay',
34929
- });
34930
- }
34931
- catch (err) {
34932
- // @silent-fallback-ok: recording-only — A2A delivery tracking must never break
34933
- // the send (the message was already submitted above). Logged.
34934
- console.warn(`[relay-send] A2A delivery record failed (non-fatal): ${err instanceof Error ? err.message : err}`);
34935
- }
35082
+ if (ctx.listenerManager && typeof inReplyTo === 'string')
35083
+ ctx.listenerManager.releaseReplyClaim(inReplyTo, replyClaimOwner);
35084
+ const deliveryOutcome = relayStatus === 'delivered'
35085
+ ? "handed to the peer's relay connection; not yet confirmed read"
35086
+ : relayStatus === 'queued'
35087
+ ? `peer offline; the relay holds it for up to ${relayHoldHours(verdict?.ttlSec)} h`
35088
+ : banSuspected
35089
+ ? 'submitted to relay; the relay reports this sender banned'
35090
+ : `submitted to relay; no relay acknowledgement within ${RELAY_ACK_WAIT_MS / 1000}s`;
35091
+ const honest = {
35092
+ relayStatus,
35093
+ ...(relayReasonCode ? { relayReasonCode } : {}),
35094
+ ...(banSuspected ? { banSuspected: true } : {}),
35095
+ };
34936
35096
  if (waitForReply) {
34937
- const reply = await waitForThreadlineReply(ctx, resolvedId, effectiveRelayThreadId, timeoutSeconds);
35097
+ const reply = await replyPromise;
34938
35098
  res.json({
34939
35099
  success: true,
34940
- accepted: reply !== null,
35100
+ accepted: reply !== null || relayStatus === 'delivered' || relayStatus === 'queued',
34941
35101
  delivered: reply !== null,
34942
35102
  messageId: relayMsgId,
34943
35103
  threadId: effectiveRelayThreadId,
34944
35104
  resolvedAgent: resolvedId,
34945
35105
  deliveryPath: 'relay',
34946
- deliveryOutcome: reply !== null ? 'reply received' : 'submitted to relay; acceptance unconfirmed',
35106
+ deliveryOutcome: reply !== null ? 'reply received' : deliveryOutcome,
35107
+ ...honest,
34947
35108
  reply,
34948
35109
  topicLinkageStamped: resolvedOriginTopicId !== undefined,
34949
35110
  });
@@ -34951,13 +35112,14 @@ document.getElementById('mcpForm').addEventListener('submit', async function (e)
34951
35112
  else {
34952
35113
  res.json({
34953
35114
  success: true,
34954
- accepted: false,
35115
+ accepted: relayStatus === 'delivered' || relayStatus === 'queued',
34955
35116
  delivered: false,
34956
35117
  messageId: relayMsgId,
34957
35118
  threadId: effectiveRelayThreadId,
34958
35119
  resolvedAgent: resolvedId,
34959
35120
  deliveryPath: 'relay',
34960
- deliveryOutcome: 'submitted to relay; acceptance unconfirmed',
35121
+ deliveryOutcome,
35122
+ ...honest,
34961
35123
  topicLinkageStamped: resolvedOriginTopicId !== undefined,
34962
35124
  });
34963
35125
  }