instar 1.3.518 → 1.3.520

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 (66) hide show
  1. package/dashboard/index.html +40 -10
  2. package/dashboard/mandates.js +115 -34
  3. package/dist/commands/server.d.ts.map +1 -1
  4. package/dist/commands/server.js +26 -2
  5. package/dist/commands/server.js.map +1 -1
  6. package/dist/config/ConfigDefaults.d.ts.map +1 -1
  7. package/dist/config/ConfigDefaults.js +29 -0
  8. package/dist/config/ConfigDefaults.js.map +1 -1
  9. package/dist/core/BackupManager.d.ts.map +1 -1
  10. package/dist/core/BackupManager.js +4 -0
  11. package/dist/core/BackupManager.js.map +1 -1
  12. package/dist/core/PostUpdateMigrator.d.ts.map +1 -1
  13. package/dist/core/PostUpdateMigrator.js +29 -0
  14. package/dist/core/PostUpdateMigrator.js.map +1 -1
  15. package/dist/core/devGatedFeatures.d.ts.map +1 -1
  16. package/dist/core/devGatedFeatures.js +6 -0
  17. package/dist/core/devGatedFeatures.js.map +1 -1
  18. package/dist/scaffold/templates.d.ts.map +1 -1
  19. package/dist/scaffold/templates.js +7 -0
  20. package/dist/scaffold/templates.js.map +1 -1
  21. package/dist/server/AgentServer.d.ts +3 -0
  22. package/dist/server/AgentServer.d.ts.map +1 -1
  23. package/dist/server/AgentServer.js +2 -0
  24. package/dist/server/AgentServer.js.map +1 -1
  25. package/dist/server/routes.d.ts +7 -0
  26. package/dist/server/routes.d.ts.map +1 -1
  27. package/dist/server/routes.js +310 -7
  28. package/dist/server/routes.js.map +1 -1
  29. package/dist/threadline/ConversationStore.d.ts +90 -0
  30. package/dist/threadline/ConversationStore.d.ts.map +1 -1
  31. package/dist/threadline/ConversationStore.js +133 -0
  32. package/dist/threadline/ConversationStore.js.map +1 -1
  33. package/dist/threadline/ThreadLog.d.ts +200 -0
  34. package/dist/threadline/ThreadLog.d.ts.map +1 -0
  35. package/dist/threadline/ThreadLog.js +434 -0
  36. package/dist/threadline/ThreadLog.js.map +1 -0
  37. package/dist/threadline/ThreadlineEndpoints.d.ts +10 -1
  38. package/dist/threadline/ThreadlineEndpoints.d.ts.map +1 -1
  39. package/dist/threadline/ThreadlineEndpoints.js +63 -12
  40. package/dist/threadline/ThreadlineEndpoints.js.map +1 -1
  41. package/dist/threadline/canonicalHistoryRead.d.ts +63 -0
  42. package/dist/threadline/canonicalHistoryRead.d.ts.map +1 -0
  43. package/dist/threadline/canonicalHistoryRead.js +123 -0
  44. package/dist/threadline/canonicalHistoryRead.js.map +1 -0
  45. package/dist/threadline/recordThreadMessage.d.ts +143 -0
  46. package/dist/threadline/recordThreadMessage.d.ts.map +1 -0
  47. package/dist/threadline/recordThreadMessage.js +222 -0
  48. package/dist/threadline/recordThreadMessage.js.map +1 -0
  49. package/dist/threadline/threadDigest.d.ts +90 -0
  50. package/dist/threadline/threadDigest.d.ts.map +1 -0
  51. package/dist/threadline/threadDigest.js +130 -0
  52. package/dist/threadline/threadDigest.js.map +1 -0
  53. package/dist/threadline/threadSymmetry.d.ts +108 -0
  54. package/dist/threadline/threadSymmetry.d.ts.map +1 -0
  55. package/dist/threadline/threadSymmetry.js +225 -0
  56. package/dist/threadline/threadSymmetry.js.map +1 -0
  57. package/package.json +1 -1
  58. package/scripts/instar-dev-precommit.js +46 -0
  59. package/scripts/lib/operator-surface.mjs +54 -0
  60. package/skills/instar-dev/templates/side-effects-artifact.md +15 -0
  61. package/src/data/builtin-manifest.json +65 -65
  62. package/src/scaffold/templates.ts +7 -0
  63. package/upgrades/1.3.519.md +92 -0
  64. package/upgrades/1.3.520.md +50 -0
  65. package/upgrades/side-effects/operator-surface-quality-and-mandates-redesign.md +200 -0
  66. package/upgrades/side-effects/threadline-canonical-history.md +122 -0
@@ -129,6 +129,10 @@ import { evaluateAndRecordInbound } from '../threadline/WarrantsReplyGate.js';
129
129
  import { createThreadlineRoutes } from '../threadline/ThreadlineEndpoints.js';
130
130
  import { evaluateSendGate, negotiatorLogDir } from '../threadline/NegotiatorGate.js';
131
131
  import { recordInboundAck } from '../threadline/recordInboundAck.js';
132
+ import { recordThreadMessage } from '../threadline/recordThreadMessage.js';
133
+ import { THREAD_ID_RE } from '../threadline/ThreadLog.js';
134
+ import { readThreadHistoryUnion } from '../threadline/canonicalHistoryRead.js';
135
+ import { computeSymmetryState, localThreadSync, honorPeerThreadSync } from '../threadline/threadSymmetry.js';
132
136
  import { resolveSingleNegotiatorConfig, readNegotiatorCounts } from '../threadline/NegotiatorLease.js';
133
137
  import { detectCommitmentClass, commitmentNudge } from '../threadline/ContentClassifier.js';
134
138
  import { DEFAULT_RELAY_URL } from '../threadline/constants.js';
@@ -8681,7 +8685,29 @@ export function createRoutes(ctx) {
8681
8685
  res.status(404).json({ error: 'Thread not found' });
8682
8686
  return;
8683
8687
  }
8684
- res.json(detail);
8688
+ // Robustness Phase 2 (E6): surface the CANONICAL log head + advisory symmetry
8689
+ // state onto the dashboard thread view, so corrected history + divergence are
8690
+ // visible to the human (the canonical log is the authoritative count, not the
8691
+ // lossy aggregate). Additive — absent when threadline/canonical-history is off.
8692
+ let canonicalHistory;
8693
+ try {
8694
+ const tid = req.params.threadId;
8695
+ if (ctx.threadLog && THREAD_ID_RE.test(tid) && ctx.threadLog.isPathConfined(tid)) {
8696
+ const head = ctx.threadLog.head(tid);
8697
+ const verify = ctx.threadLog.verify(tid);
8698
+ const conv = ctx.conversationStore?.get(tid);
8699
+ canonicalHistory = {
8700
+ count: head.count,
8701
+ headHash: head.headHash,
8702
+ setAccum: head.setAccum,
8703
+ chainOk: verify.ok,
8704
+ symmetryState: conv?.symmetryState ?? 'unknown',
8705
+ collisionCount: conv?.collisionCount ?? 0,
8706
+ };
8707
+ }
8708
+ }
8709
+ catch { /* @silent-fallback-ok: observability augmentation is best-effort; the base detail still returns */ }
8710
+ res.json(canonicalHistory ? { ...detail, canonicalHistory } : detail);
8685
8711
  });
8686
8712
  // ── A2A peer-health (A2A-DURABLE-DELIVERY-SPEC.md) ──────────────────────
8687
8713
  // "Is my channel to <peer> alive?" as a lookup, not a guess. Read-only
@@ -17507,6 +17533,33 @@ export function createRoutes(ctx) {
17507
17533
  senderFingerprint: senderAgent ?? undefined,
17508
17534
  senderName: senderAgent ?? null,
17509
17535
  });
17536
+ // Robustness Phase 2 (D-B): append THIS inbound leg to the canonical log
17537
+ // through the single funnel (Structure > Willpower — the wiring-integrity
17538
+ // test asserts every message-persisting route goes through it). createdAt is
17539
+ // hashed AS RECEIVED, so the digest matches the sender's on the co-located
17540
+ // path. The peer fingerprint is the thread owner (NOT the asserted name).
17541
+ if (ctx.threadMessageRecorder && envelope.message?.threadId && envelope.message?.id) {
17542
+ const rtmBody = typeof envelope.message?.body === 'string'
17543
+ ? envelope.message.body
17544
+ : String(envelope.message?.body?.content ?? envelope.message?.body?.text ?? '');
17545
+ const ownerFp = ctx.threadResumeMap?.get(envelope.message.threadId)?.remoteAgent;
17546
+ recordThreadMessage(ctx.threadMessageRecorder, {
17547
+ threadId: envelope.message.threadId,
17548
+ messageId: envelope.message.id,
17549
+ direction: 'inbound',
17550
+ body: rtmBody,
17551
+ createdAt: typeof envelope.message?.createdAt === 'string' ? envelope.message.createdAt : new Date().toISOString(),
17552
+ peerFingerprint: ownerFp || (senderAgent ?? undefined),
17553
+ author: { agentFingerprint: ownerFp || (senderAgent ?? undefined) },
17554
+ subject: typeof envelope.message?.subject === 'string' ? envelope.message.subject : undefined,
17555
+ });
17556
+ // Honor a piggybacked peer threadSync on the co-located path (D-D). The
17557
+ // verified participant key is the thread OWNER fingerprint (captured by
17558
+ // captureOrigin), never the asserted sender name. Advisory-only.
17559
+ if (ownerFp && envelope.threadSync && ctx.threadLog && ctx.conversationStore) {
17560
+ void honorPeerThreadSync({ threadLog: ctx.threadLog, conversationStore: ctx.conversationStore, attention: ctx.telegram ?? null }, envelope.message.threadId, ownerFp, envelope.threadSync).catch(() => { });
17561
+ }
17562
+ }
17510
17563
  // Check if this message resolves a pending waitForReply request.
17511
17564
  // Local delivery bypasses the relay client's gate-passed event, so we
17512
17565
  // must check reply waiters here directly.
@@ -17739,17 +17792,76 @@ export function createRoutes(ctx) {
17739
17792
  res.status(500).json({ error: err instanceof Error ? err.message : 'Thread list failed' });
17740
17793
  }
17741
17794
  });
17795
+ // Robustness Phase 2 (D-C): build the UNION read deps (canonical log ∪ bounded
17796
+ // memoized backfill). The outbox supplies outbound legs (tail-bounded); the
17797
+ // derived aggregate supplies inbound legs (O(thread), never a full-store scan).
17798
+ const buildCanonicalReadDeps = () => {
17799
+ if (!ctx.threadLog || !ctx.threadMessageRecorder)
17800
+ return null;
17801
+ return {
17802
+ threadLog: ctx.threadLog,
17803
+ threadMessageRecorder: ctx.threadMessageRecorder,
17804
+ conversationStore: ctx.conversationStore,
17805
+ outboxPath: ctx.listenerManager?.canonicalOutboxPath
17806
+ ?? path.join(ctx.config.stateDir, 'threadline', 'outbox.jsonl.active'),
17807
+ selfName: ctx.config.projectName,
17808
+ backfillOutboxTailLines: ctx.config.threadline?.canonicalHistory?.backfillOutboxTailLines,
17809
+ getAggregateMessages: async (tid) => {
17810
+ const agg = await ctx.messageRouter?.getThread(tid);
17811
+ if (!agg)
17812
+ return [];
17813
+ return agg.messages.map((env) => ({
17814
+ id: env.message.id,
17815
+ body: typeof env.message.body === 'string' ? env.message.body : String(env.message.body?.content ?? env.message.body?.text ?? ''),
17816
+ createdAt: env.message.createdAt,
17817
+ fromAgent: env.message.from?.agent,
17818
+ }));
17819
+ },
17820
+ };
17821
+ };
17742
17822
  router.get('/messages/thread/:threadId', async (req, res) => {
17743
- if (!ctx.messageRouter) {
17744
- res.status(503).json({ error: 'Messaging not available' });
17745
- return;
17746
- }
17747
17823
  try {
17748
17824
  const { threadId } = req.params;
17749
- if (!MSG_ID_RE.test(threadId)) {
17825
+ // Tight anchored allowlist for the REAL minted shapes (FD-7) — not UUID-only,
17826
+ // so a `msg-…`/`thread-…` id no longer 400s (a second F3 surface).
17827
+ if (!THREAD_ID_RE.test(threadId)) {
17750
17828
  res.status(400).json({ error: 'Invalid thread ID format' });
17751
17829
  return;
17752
17830
  }
17831
+ // Robustness Phase 2 (D-C): read the CANONICAL log as the authoritative source
17832
+ // (UNION with a one-time bounded backfill), replacing the lossy derived
17833
+ // aggregate. History can only gain, never regress.
17834
+ const canonDeps = buildCanonicalReadDeps();
17835
+ if (canonDeps) {
17836
+ const entries = await readThreadHistoryUnion(canonDeps, threadId);
17837
+ const messages = entries.map((e) => ({
17838
+ message: {
17839
+ id: e.messageId,
17840
+ from: { agent: e.direction === 'outbound' ? ctx.config.projectName : (e.peerFingerprint ?? e.author?.agentFingerprint ?? 'peer') },
17841
+ body: e.textRef.kind === 'inline' ? e.textRef.text : '',
17842
+ createdAt: e.createdAt,
17843
+ threadId,
17844
+ },
17845
+ }));
17846
+ const last = entries.length ? entries[entries.length - 1] : null;
17847
+ res.json({
17848
+ thread: {
17849
+ id: threadId,
17850
+ messageIds: entries.map((e) => e.messageId),
17851
+ messageCount: entries.length,
17852
+ lastMessageAt: last?.createdAt ?? new Date(0).toISOString(),
17853
+ status: 'active',
17854
+ source: 'canonical-log',
17855
+ },
17856
+ messages,
17857
+ });
17858
+ return;
17859
+ }
17860
+ // Fallback (threadline disabled): the legacy derived aggregate.
17861
+ if (!ctx.messageRouter) {
17862
+ res.status(503).json({ error: 'Messaging not available' });
17863
+ return;
17864
+ }
17753
17865
  const result = await ctx.messageRouter.getThread(threadId);
17754
17866
  if (!result) {
17755
17867
  res.status(404).json({ error: 'Thread not found' });
@@ -17761,6 +17873,116 @@ export function createRoutes(ctx) {
17761
17873
  res.status(500).json({ error: err instanceof Error ? err.message : 'Thread query failed' });
17762
17874
  }
17763
17875
  });
17876
+ // ── Robustness Phase 2 (D-C/D-D): canonical-log read + symmetry health ────────
17877
+ // Bearer-gated (own-data-only), seq-cursor paginated, with a TIGHT anchored id
17878
+ // allowlist + a path-confinement check (traversal defense, FD-7). NOTE: these sit
17879
+ // under /threadline/* (which authMiddleware bypasses for relay-auth), so they
17880
+ // SELF-GATE on the owner bearer token. Bodies are returned tagged as UNTRUSTED
17881
+ // peer-authored data — never instructions.
17882
+ const ownerAuthed = (req) => {
17883
+ if (!ctx.config.authToken)
17884
+ return true; // no token configured → open (dev/local)
17885
+ const header = req.headers.authorization;
17886
+ if (!header?.startsWith('Bearer '))
17887
+ return false;
17888
+ try {
17889
+ const ha = createHash('sha256').update(header.slice(7)).digest();
17890
+ const hb = createHash('sha256').update(ctx.config.authToken).digest();
17891
+ return ha.length === hb.length && timingSafeEqual(ha, hb);
17892
+ }
17893
+ catch {
17894
+ return false;
17895
+ }
17896
+ };
17897
+ router.get('/threadline/threads/:id', async (req, res) => {
17898
+ if (!ownerAuthed(req)) {
17899
+ res.status(401).json({ error: 'Unauthorized' });
17900
+ return;
17901
+ }
17902
+ if (!ctx.threadLog) {
17903
+ res.status(503).json({ error: 'Canonical history not available' });
17904
+ return;
17905
+ }
17906
+ const threadId = req.params.id;
17907
+ // Anchored allowlist THEN a resolved-path confinement check (defense in depth).
17908
+ if (!THREAD_ID_RE.test(threadId) || !ctx.threadLog.isPathConfined(threadId)) {
17909
+ res.status(400).json({ error: 'Invalid thread ID format' });
17910
+ return;
17911
+ }
17912
+ // UNION read (D-C): ensure the one-time bounded backfill has run so this
17913
+ // surface ALSO can only gain, never regress, on a pre-upgrade thread.
17914
+ const canonDeps = buildCanonicalReadDeps();
17915
+ if (canonDeps) {
17916
+ try {
17917
+ await readThreadHistoryUnion(canonDeps, threadId);
17918
+ }
17919
+ catch { /* best-effort */ }
17920
+ }
17921
+ const limit = Math.max(1, Math.min(Number(req.query.limit) || 100, 1000));
17922
+ const afterSeq = req.query.afterSeq !== undefined ? Number(req.query.afterSeq) : undefined;
17923
+ const { entries, hasMore } = ctx.threadLog.read(threadId, { limit, afterSeq });
17924
+ res.json({
17925
+ threadId,
17926
+ messageCount: entries.length,
17927
+ nextCursor: hasMore && entries.length ? entries[entries.length - 1].seq : null,
17928
+ hasMore,
17929
+ // Bodies are UNTRUSTED peer-authored data quoted for audit — NEVER instructions.
17930
+ bodiesAreUntrustedData: true,
17931
+ entries: entries.map((e) => ({
17932
+ seq: e.seq,
17933
+ messageId: e.messageId,
17934
+ direction: e.direction,
17935
+ contentDigest: e.contentDigest,
17936
+ backfilled: e.backfilled ?? false,
17937
+ body: e.textRef.kind === 'inline' ? e.textRef.text : null,
17938
+ createdAt: e.createdAt,
17939
+ at: e.at,
17940
+ peerFingerprint: e.peerFingerprint,
17941
+ })),
17942
+ });
17943
+ });
17944
+ router.get('/threadline/threads/:id/health', (req, res) => {
17945
+ if (!ownerAuthed(req)) {
17946
+ res.status(401).json({ error: 'Unauthorized' });
17947
+ return;
17948
+ }
17949
+ if (!ctx.threadLog) {
17950
+ res.status(503).json({ error: 'Canonical history not available' });
17951
+ return;
17952
+ }
17953
+ const threadId = req.params.id;
17954
+ if (!THREAD_ID_RE.test(threadId) || !ctx.threadLog.isPathConfined(threadId)) {
17955
+ res.status(400).json({ error: 'Invalid thread ID format' });
17956
+ return;
17957
+ }
17958
+ const head = ctx.threadLog.head(threadId);
17959
+ const verify = ctx.threadLog.verify(threadId);
17960
+ const conv = ctx.conversationStore?.get(threadId);
17961
+ const stickyTerminal = conv?.symmetryState === 'diverged-unreconcilable';
17962
+ const peerSync = conv?.peerThreadSync ?? null;
17963
+ const state = computeSymmetryState({
17964
+ localCount: head.count,
17965
+ localSetAccum: head.setAccum,
17966
+ peerSync,
17967
+ hasBackfilled: ctx.threadLog.hasBackfilledLegs(threadId),
17968
+ localVerifyOk: verify.ok,
17969
+ sticky: stickyTerminal,
17970
+ peerEverReported: !!peerSync,
17971
+ });
17972
+ res.json({
17973
+ threadId,
17974
+ symmetryState: state,
17975
+ local: localThreadSync(ctx.threadLog, threadId),
17976
+ peer: peerSync,
17977
+ chainOk: verify.ok,
17978
+ ...(verify.ok ? {} : { brokenAt: verify.brokenAt }),
17979
+ collisionCount: conv?.collisionCount ?? 0,
17980
+ // Operator recovery playbook for a local integrity fault (gemini G1).
17981
+ ...(state === 'local-integrity-fault'
17982
+ ? { recovery: `The canonical chain for ${threadId} is broken and cannot be repaired in place. Delete threadline/threads/${threadId}.log.jsonl and re-converge from the peer via the bounded backfill.` }
17983
+ : {}),
17984
+ });
17985
+ });
17764
17986
  router.post('/messages/thread/:threadId/resolve', async (req, res) => {
17765
17987
  if (!ctx.messageRouter) {
17766
17988
  res.status(503).json({ error: 'Messaging not available' });
@@ -18046,6 +18268,16 @@ export function createRoutes(ctx) {
18046
18268
  {
18047
18269
  a2aDeliveryTracker: ctx.a2aDeliveryTracker,
18048
18270
  threadResumeMap: ctx.threadResumeMap ?? null,
18271
+ },
18272
+ // Robustness Phase 2 (D-B/D-D): the canonical-history funnel + symmetry surfaces
18273
+ // for the verified E2E relay inbound path + participant-authorized backfill.
18274
+ {
18275
+ threadMessageRecorder: ctx.threadMessageRecorder,
18276
+ threadLog: ctx.threadLog,
18277
+ conversationStore: ctx.conversationStore,
18278
+ attention: ctx.telegram ?? null,
18279
+ backfillMaxDigestsPerRequest: ctx.config.threadline?.canonicalHistory?.backfillMaxDigestsPerRequest,
18280
+ backfillMaxRecordsPerResponse: ctx.config.threadline?.canonicalHistory?.backfillMaxRecordsPerResponse,
18049
18281
  });
18050
18282
  router.use(threadlineRoutes);
18051
18283
  }
@@ -18398,7 +18630,38 @@ export function createRoutes(ctx) {
18398
18630
  // Mint a stable UUID threadId when caller didn't provide one so
18399
18631
  // first-contact messages aren't dropped on the recipient side. Both
18400
18632
  // sender and recipient will agree on this id going forward.
18401
- const effectiveThreadId = threadId ?? randomUUID();
18633
+ let effectiveThreadId = threadId ?? randomUUID();
18634
+ // ── Robustness Phase 2 (D-E): conversation-discipline resolver ──────────
18635
+ // Default-join the canonical thread for a verified (peer, workstream) instead
18636
+ // of minting a fork (closes F5). DEV-GATED + DRY-RUN-FIRST: `enabled` rides the
18637
+ // developmentAgent gate (live on dev, dark on the fleet) and `dryRun` (default
18638
+ // true) only LOGS the would-join decision — the threadId is unchanged until an
18639
+ // operator flips dryRun off after telemetry proves the join/fork rate. The
18640
+ // resolver is recoverable routing, NEVER an authority: it never blocks a send.
18641
+ if (ctx.threadMessageRecorder) {
18642
+ const cdCfg = ctx.config.threadline?.canonicalHistory;
18643
+ const resolverEnabled = resolveDevAgentGate(cdCfg?.conversationDiscipline?.enabled, ctx.config);
18644
+ const resolverDryRun = cdCfg?.conversationDiscipline?.dryRun ?? true;
18645
+ const forkRequested = req.body?.fork === true || req.body?.newThread === true;
18646
+ // Best-effort VERIFIED principal available locally (never a name the peer
18647
+ // asserts): a fingerprint-shaped target or a curated nickname mapping.
18648
+ const resolverPrincipal = looksLikeFingerprint ? targetAgent : (nicknameResolvedFp ?? undefined);
18649
+ try {
18650
+ const decided = await ctx.threadMessageRecorder.resolveOutboundThread({
18651
+ explicitThreadId: threadId,
18652
+ mintedThreadId: effectiveThreadId,
18653
+ peerPrincipal: resolverPrincipal,
18654
+ subject: typeof purpose === 'string' ? purpose : undefined,
18655
+ fork: forkRequested,
18656
+ enabled: resolverEnabled,
18657
+ dryRun: resolverDryRun,
18658
+ workstreamKeyMode: cdCfg?.workstreamKeyMode ?? 'subject-slug',
18659
+ isHolder: true, // single-holder: this server is the holder for local conversations
18660
+ });
18661
+ effectiveThreadId = decided.threadId;
18662
+ }
18663
+ catch { /* @silent-fallback-ok: the resolver is recoverable routing; on any error keep the minted threadId */ }
18664
+ }
18402
18665
  // ── Negotiator lease/voice gate (Robustness Phase 1, D-B) ───────
18403
18666
  // Enforce single-voice: only the session that owns this conversation's lease
18404
18667
  // may speak CONTENT in the agent's name (G1, closes F1). Ships dark
@@ -18582,6 +18845,23 @@ export function createRoutes(ctx) {
18582
18845
  }
18583
18846
  })();
18584
18847
  const now = new Date().toISOString();
18848
+ // Robustness Phase 2 (D-B): log THIS outbound leg to the canonical
18849
+ // log BEFORE building the envelope, so the piggybacked `threadSync`
18850
+ // reflects the post-leg head (no transient off-by-one `diverged`).
18851
+ // `now` is the same createdAt stamped on the wire below ⇒ this end's
18852
+ // content digest matches the peer's byte-for-byte (the symmetric path).
18853
+ if (ctx.threadMessageRecorder) {
18854
+ recordThreadMessage(ctx.threadMessageRecorder, {
18855
+ threadId: effectiveThreadId,
18856
+ messageId: msgId,
18857
+ direction: 'outbound',
18858
+ body: message,
18859
+ createdAt: now,
18860
+ peerFingerprint: resolvePeerFingerprint(localTarget) || undefined,
18861
+ author: { machineId: ctx.meshSelfId ?? undefined, sessionName: typeof originSessionName === 'string' ? originSessionName : undefined },
18862
+ subject: typeof purpose === 'string' ? purpose : undefined,
18863
+ });
18864
+ }
18585
18865
  const envelope = {
18586
18866
  schemaVersion: 1,
18587
18867
  message: {
@@ -18595,6 +18875,9 @@ export function createRoutes(ctx) {
18595
18875
  threadId: effectiveThreadId,
18596
18876
  createdAt: now,
18597
18877
  },
18878
+ // Robustness Phase 2 (D-D): piggyback this end's threadSync so the
18879
+ // peer can cross-verify symmetry (additive; a legacy peer ignores it).
18880
+ ...(ctx.threadLog ? { threadSync: localThreadSync(ctx.threadLog, effectiveThreadId) } : {}),
18598
18881
  transport: {
18599
18882
  relayChain: ['local'],
18600
18883
  originServer: `http://localhost:${ctx.config.port ?? 4042}`,
@@ -18670,6 +18953,9 @@ export function createRoutes(ctx) {
18670
18953
  console.warn(`[relay-send] Canonical outbox append failed (non-fatal): ${err instanceof Error ? err.message : err}`);
18671
18954
  }
18672
18955
  }
18956
+ // (The canonical-log append for this outbound leg already ran
18957
+ // BEFORE the envelope was built — see above — so its threadSync is
18958
+ // post-leg. The funnel is idempotent, so no double-append here.)
18673
18959
  // Mirror outbound into Telegram bridge (relay-only — best effort).
18674
18960
  if (ctx.telegramBridge) {
18675
18961
  ctx.telegramBridge.mirrorOutbound({
@@ -18802,6 +19088,23 @@ export function createRoutes(ctx) {
18802
19088
  console.warn(`[relay-send] Canonical outbox append failed (non-fatal): ${err instanceof Error ? err.message : err}`);
18803
19089
  }
18804
19090
  }
19091
+ // Robustness Phase 2 (D-B): append the relay-delivered outbound leg to the
19092
+ // canonical log through the funnel. The wire createdAt is stamped inside the
19093
+ // relay client, so this end's createdAt is best-effort (symmetry on the relay
19094
+ // path degrades to unverified, advisory-only); F3 holds unconditionally — the
19095
+ // sender's own message is now auditable per-thread regardless of symmetry.
19096
+ if (ctx.threadMessageRecorder) {
19097
+ recordThreadMessage(ctx.threadMessageRecorder, {
19098
+ threadId: effectiveRelayThreadId,
19099
+ messageId: relayMsgId,
19100
+ direction: 'outbound',
19101
+ body: message,
19102
+ createdAt: new Date().toISOString(),
19103
+ peerFingerprint: resolvedId ?? undefined,
19104
+ author: { machineId: ctx.meshSelfId ?? undefined, sessionName: typeof originSessionName === 'string' ? originSessionName : undefined },
19105
+ subject: typeof purpose === 'string' ? purpose : undefined,
19106
+ });
19107
+ }
18805
19108
  // Mirror outbound into Telegram bridge (relay-only — best effort).
18806
19109
  if (ctx.telegramBridge) {
18807
19110
  ctx.telegramBridge.mirrorOutbound({