switchroom 0.19.6 → 0.19.7

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.
@@ -2120,7 +2120,7 @@ var init_esm = __esm(() => {
2120
2120
  });
2121
2121
 
2122
2122
  // src/build-info.ts
2123
- var VERSION = "0.19.6", COMMIT_SHA = "ae57dd83";
2123
+ var VERSION = "0.19.7", COMMIT_SHA = "2ddec4ae";
2124
2124
 
2125
2125
  // src/cli/resolve-version.ts
2126
2126
  import { existsSync, readFileSync } from "node:fs";
@@ -26663,7 +26663,7 @@ import { existsSync as existsSync9, readFileSync as readFileSync7 } from "node:f
26663
26663
  import { dirname as dirname4, join as join7 } from "node:path";
26664
26664
 
26665
26665
  // src/build-info.ts
26666
- var VERSION = "0.19.6";
26666
+ var VERSION = "0.19.7";
26667
26667
 
26668
26668
  // src/cli/resolve-version.ts
26669
26669
  function readPackageVersion() {
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "switchroom",
3
3
  "//version": "NOT the release version — source of truth is the git tag, resolved by scripts/build.mjs:resolveVersion() (see CLAUDE.md > Standard release process). This field is stale by design and only the Layer-4 dev/non-tag fallback for build.mjs + src/cli/resolve-version.ts; do NOT bump it expecting a release to pick it up. npm-pack tarball naming needs a real version — do that as an UNCOMMITTED pack-time bump (see release step 6), never a committed one.",
4
- "version": "0.19.6",
4
+ "version": "0.19.7",
5
5
  "description": "Run Claude Code 24/7 on your Claude Pro/Max subscription over Telegram. Open-source alternative to OpenClaw and NanoClaw — no API keys.",
6
6
  "type": "module",
7
7
  "bin": {
@@ -74662,11 +74662,12 @@ async function sendReply(deps, req) {
74662
74662
  const { turn: ownerTurn, tier: ownerTier } = resolveReplyOwnerTurn(turn, chat_id, args);
74663
74663
  const resolvedTurnId = ownerTurn?.turnId ?? null;
74664
74664
  const ownerEndedAt = ownerTurn?.endedAt ?? null;
74665
+ const gateThreadId = ownerTurn?.sessionThreadId ?? replyThreadId;
74665
74666
  const handbackAt = getLastSubagentHandbackAt(chat_id);
74666
74667
  const now = Date.now();
74667
74668
  const handbackCouldOwnReply = handbackAt != null && ownerEndedAt != null && handbackAt > ownerEndedAt && now - handbackAt <= DEFAULT_SUPERSEDE_TTL_MS2;
74668
- const replyIsOwnAnswer = ownerTier === "live" || !handbackCouldOwnReply;
74669
- const decision = flushedTurnSupersede.take(chat_id, replyThreadId, { liveTurnId: resolvedTurnId, replyText: text4, positiveAttribution: replyIsOwnAnswer, now });
74669
+ const replyIsOwnAnswer = ownerTier === "live" || ownerTier === "latest-ended" && !handbackCouldOwnReply;
74670
+ const decision = flushedTurnSupersede.take(chat_id, gateThreadId, { liveTurnId: resolvedTurnId, replyText: text4, positiveAttribution: replyIsOwnAnswer, now });
74670
74671
  if (decision.supersede) {
74671
74672
  process.stderr.write(`telegram gateway: reply: superseding flushed turn message(s) ` + `chatId=${chat_id} ids=${JSON.stringify(decision.deleteMessageIds)}
74672
74673
  `);
@@ -78177,13 +78178,33 @@ function resolveReplyOwnerTurnId(candidates) {
78177
78178
  }
78178
78179
 
78179
78180
  // gateway/subagent-handback-marker.ts
78181
+ var MAIN_THREAD_KEY = "<main>";
78182
+
78180
78183
  class SubagentHandbackMarker {
78181
- lastAtByChat = new Map;
78182
- record(chatId, now) {
78183
- this.lastAtByChat.set(chatId, now);
78184
+ byChat = new Map;
78185
+ threadKey(threadId) {
78186
+ return threadId == null ? MAIN_THREAD_KEY : String(threadId);
78187
+ }
78188
+ record(chatId, threadId, now) {
78189
+ let inner = this.byChat.get(chatId);
78190
+ if (inner == null) {
78191
+ inner = new Map;
78192
+ this.byChat.set(chatId, inner);
78193
+ }
78194
+ inner.set(this.threadKey(threadId), now);
78184
78195
  }
78185
- lastAt(chatId) {
78186
- return this.lastAtByChat.get(chatId) ?? null;
78196
+ lastAt(chatId, threadId) {
78197
+ return this.byChat.get(chatId)?.get(this.threadKey(threadId)) ?? null;
78198
+ }
78199
+ lastAtInChat(chatId) {
78200
+ const inner = this.byChat.get(chatId);
78201
+ if (inner == null || inner.size === 0)
78202
+ return null;
78203
+ let max = -Infinity;
78204
+ for (const ts of inner.values())
78205
+ if (ts > max)
78206
+ max = ts;
78207
+ return max === -Infinity ? null : max;
78187
78208
  }
78188
78209
  }
78189
78210
 
@@ -84663,6 +84684,76 @@ function buildDiffPreviewCard(input) {
84663
84684
  return { text: text4, reply_markup: kb };
84664
84685
  }
84665
84686
 
84687
+ // gateway/subagent-handback-marker.ts
84688
+ var INBOUND_SOURCE_CLASSIFICATION = {
84689
+ subagent_handback: { decoupledCompletion: true },
84690
+ cron: { decoupledCompletion: false },
84691
+ reaction: { decoupledCompletion: false },
84692
+ subagent_progress: { decoupledCompletion: false },
84693
+ resume_interrupted: { decoupledCompletion: false },
84694
+ resume_deferred: { decoupledCompletion: false },
84695
+ resume_watchdog_timeout: { decoupledCompletion: false },
84696
+ vault_grant_approved: { decoupledCompletion: false },
84697
+ vault_grant_denied: { decoupledCompletion: false },
84698
+ vault_grant_timeout: { decoupledCompletion: false },
84699
+ vault_save_completed: { decoupledCompletion: false },
84700
+ vault_save_discarded: { decoupledCompletion: false },
84701
+ vault_save_failed: { decoupledCompletion: false },
84702
+ vault_save_timeout: { decoupledCompletion: false },
84703
+ secret_provided: { decoupledCompletion: false },
84704
+ secret_declined: { decoupledCompletion: false },
84705
+ secret_provide_failed: { decoupledCompletion: false },
84706
+ secret_request_timeout: { decoupledCompletion: false },
84707
+ mental_model_propose_timeout: { decoupledCompletion: false },
84708
+ bridge_dead_restart: { decoupledCompletion: false },
84709
+ obligation_represent: { decoupledCompletion: false },
84710
+ missed_approval_retry: { decoupledCompletion: false },
84711
+ skill_proposal_apply: { decoupledCompletion: false },
84712
+ warmup: { decoupledCompletion: false },
84713
+ mental_model_proposal_applied: { decoupledCompletion: false },
84714
+ mental_model_proposal_denied: { decoupledCompletion: false },
84715
+ mental_model_proposal_failed: { decoupledCompletion: false },
84716
+ webhook: { decoupledCompletion: false },
84717
+ linear: { decoupledCompletion: false }
84718
+ };
84719
+ function stampsHandbackMarker(source) {
84720
+ if (source == null)
84721
+ return false;
84722
+ const known = INBOUND_SOURCE_CLASSIFICATION[source];
84723
+ if (known == null)
84724
+ return true;
84725
+ return known.decoupledCompletion;
84726
+ }
84727
+ var MAIN_THREAD_KEY2 = "<main>";
84728
+
84729
+ class SubagentHandbackMarker2 {
84730
+ byChat = new Map;
84731
+ threadKey(threadId) {
84732
+ return threadId == null ? MAIN_THREAD_KEY2 : String(threadId);
84733
+ }
84734
+ record(chatId, threadId, now) {
84735
+ let inner = this.byChat.get(chatId);
84736
+ if (inner == null) {
84737
+ inner = new Map;
84738
+ this.byChat.set(chatId, inner);
84739
+ }
84740
+ inner.set(this.threadKey(threadId), now);
84741
+ }
84742
+ lastAt(chatId, threadId) {
84743
+ return this.byChat.get(chatId)?.get(this.threadKey(threadId)) ?? null;
84744
+ }
84745
+ lastAtInChat(chatId) {
84746
+ const inner = this.byChat.get(chatId);
84747
+ if (inner == null || inner.size === 0)
84748
+ return null;
84749
+ let max = -Infinity;
84750
+ for (const ts of inner.values())
84751
+ if (ts > max)
84752
+ max = ts;
84753
+ return max === -Infinity ? null : max;
84754
+ }
84755
+ }
84756
+
84666
84757
  // gateway/pending-inbound-buffer.ts
84667
84758
  var DEFAULT_PENDING_INBOUND_CAP = 32;
84668
84759
  function redeliverBufferedInbound(buffer, agent, send, spool, onDelivered) {
@@ -84777,9 +84868,9 @@ function createPendingInboundBuffer(opts = {}) {
84777
84868
  }
84778
84869
  }
84779
84870
  q.push(msg);
84780
- if (msg.meta?.source === "subagent_handback" && opts.onHandbackEnqueue != null) {
84871
+ if (stampsHandbackMarker(msg.meta?.source) && opts.onHandbackEnqueue != null) {
84781
84872
  try {
84782
- opts.onHandbackEnqueue(msg.chatId, msg.ts);
84873
+ opts.onHandbackEnqueue(msg.chatId, msg.threadId, msg.ts);
84783
84874
  } catch {}
84784
84875
  }
84785
84876
  spool?.put(agent, msg);
@@ -93460,10 +93551,10 @@ function startGatewayHeartbeat(stateDir, intervalMs = GATEWAY_HEARTBEAT_INTERVAL
93460
93551
  }
93461
93552
 
93462
93553
  // ../src/build-info.ts
93463
- var VERSION = "0.19.6";
93464
- var COMMIT_SHA = "ae57dd83";
93465
- var COMMIT_DATE = "2026-07-20T16:11:50Z";
93466
- var LATEST_PR = 3476;
93554
+ var VERSION = "0.19.7";
93555
+ var COMMIT_SHA = "2ddec4ae";
93556
+ var COMMIT_DATE = "2026-07-20T19:59:43Z";
93557
+ var LATEST_PR = 3478;
93467
93558
  var COMMITS_AHEAD_OF_TAG = 0;
93468
93559
 
93469
93560
  // gateway/boot-version.ts
@@ -96382,7 +96473,7 @@ var RECENT_TURNS_MAX = 32;
96382
96473
  var recentTurnsById = new Map;
96383
96474
  var recentTurnIdBySourceMessageId = new Map;
96384
96475
  var subagentHandbackMarker = new SubagentHandbackMarker;
96385
- var getLastSubagentHandbackAt = (chatId) => subagentHandbackMarker.lastAt(chatId);
96476
+ var getLastSubagentHandbackAt = (chatId) => subagentHandbackMarker.lastAtInChat(chatId);
96386
96477
  function rememberRecentTurn(turn) {
96387
96478
  recentTurnsById.set(turn.turnId, turn);
96388
96479
  if (turn.sourceMessageId != null) {
@@ -99172,7 +99263,7 @@ var pendingInboundBuffer = createPendingInboundBuffer({
99172
99263
  const threadOpts = evThread != null ? { message_thread_id: evThread } : {};
99173
99264
  swallowingApiCall(() => bot.api.sendMessage(chat, "\u23F3 Messages are arriving faster than I can process them. Your messages are saved and will be handled once I finish the current turn \u2014 if any can't be picked up, I'll ask you to resend it.", { ...threadOpts }), { chat_id: chat, verb: "inbound-buffer-eviction" });
99174
99265
  },
99175
- onHandbackEnqueue: (chatId, ts) => subagentHandbackMarker.record(chatId, ts)
99266
+ onHandbackEnqueue: (chatId, threadId, ts) => subagentHandbackMarker.record(chatId, threadId, ts)
99176
99267
  });
99177
99268
  function agentHasInFlightBackgroundWork(now) {
99178
99269
  if (countRunningWorkers() > 0)
@@ -3889,12 +3889,17 @@ const recentTurnsById = new Map<string, CurrentTurn>()
3889
3889
  // Evicted in lock-step with recentTurnsById so it can't outgrow it.
3890
3890
  const recentTurnIdBySourceMessageId = new Map<number, string>()
3891
3891
 
3892
- // fix/backstop-duplicate-reply — per-chat marker of the most recent
3892
+ // fix/backstop-duplicate-reply — per-chat/thread marker of the most recent
3893
3893
  // gateway-synthesized `subagent_handback` enqueue (logic in
3894
3894
  // subagent-handback-marker.ts; extracted per the gateway anti-inflation ratchet).
3895
+ // The record is thread-resolved (retains the originating topic), but the
3896
+ // content-gate READ is CHAT-WIDE (dup-audit MUST-FIX 2): the latest-ended owner
3897
+ // tier is chat-wide, so a thread-specific gate read was steerable by the reply's
3898
+ // own `message_thread_id` → silent edit-over. `lastAtInChat` makes the gate
3899
+ // un-steerable (any in-window handback in the chat keeps the content gate).
3895
3900
  const subagentHandbackMarker = new SubagentHandbackMarker()
3896
3901
  const getLastSubagentHandbackAt = (chatId: string): number | null =>
3897
- subagentHandbackMarker.lastAt(chatId)
3902
+ subagentHandbackMarker.lastAtInChat(chatId)
3898
3903
 
3899
3904
  function rememberRecentTurn(turn: CurrentTurn): void {
3900
3905
  recentTurnsById.set(turn.turnId, turn)
@@ -9984,7 +9989,7 @@ const pendingInboundBuffer = createPendingInboundBuffer({
9984
9989
  // fix/backstop-duplicate-reply MUST-FIX 2 — stamp the handback marker at the
9985
9990
  // enqueue chokepoint so a boot-replayed handback (not just the live synthesis
9986
9991
  // push) populates it. Every `subagent_handback` push funnels through here.
9987
- onHandbackEnqueue: (chatId, ts) => subagentHandbackMarker.record(chatId, ts),
9992
+ onHandbackEnqueue: (chatId, threadId, ts) => subagentHandbackMarker.record(chatId, threadId, ts),
9988
9993
  })
9989
9994
 
9990
9995
  // PR2 obligation-ledger idle sweep. Re-present an OPEN obligation only at a
@@ -898,22 +898,45 @@ export async function sendReply(
898
898
  //
899
899
  // MUST-FIX 1 (silent-data-loss): the owner-resolution `quoted` / `origin`
900
900
  // tiers are derived from MODEL-SUPPLIED args (`args.reply_to` /
901
- // `args.origin_turn_id`), so a background handback turn can STEER them
902
- // pass `reply_to = <the user's original msg id>` and it resolves the PRIOR
903
- // flushed turn via `quoted`, which used to force the bypass and silently
904
- // edit-over that turn's delivered answer (#3429-class, model-steerable). So
905
- // a positive tier NEVER overrides an in-window handback; only the
906
- // FRAMEWORK-owned `live` tier (the live `currentTurn`, not model-derived,
907
- // and structurally unable to collide with an ended turn's flush record —
908
- // `decideSupersede` requires same turnId) may bypass a handback window.
901
+ // `args.origin_turn_id`), so a reply can STEER itself pass
902
+ // `reply_to = <another ended turn's source msg>` and it resolves THAT turn via
903
+ // `quoted`. Marker-absence proves "own answer" ONLY for the framework-derived
904
+ // `latest-ended` tier; a model-steered `quoted`/`origin` attribution with no
905
+ // marker is NOT evidence of ownership, so bypassing the content gate there
906
+ // silently edits over a different ended turn's delivered answer (the #3429
907
+ // double-loss, executed by Fable 2026-07-21). The `replyIsOwnAnswer`
908
+ // computation below therefore restricts the bypass to `live` + `latest-ended`;
909
+ // `quoted`/`origin` ALWAYS traverse the content gate. See that comment.
909
910
  //
910
- // The content gate is therefore kept whenever a handback WAS enqueued in the
911
- // window (any non-live tier): the late reply might BE that handback, so
912
- // genuinely-new content sends fresh (two messages, #3429 preserved).
913
- // Concurrency note: a case-A own reply coinciding with an unrelated
914
- // background handback in the same ≤TTL window degrades to today's behaviour
915
- // (two messages) — safe (never a silent drop/edit), just not collapsed.
911
+ // The content gate is therefore kept whenever a decoupled completion WAS
912
+ // enqueued in the window (on the latest-ended tier) OR the reply resolved via
913
+ // a model-steerable tier: the late reply might carry foreign content, so it
914
+ // sends fresh (two messages, #3429 preserved). Concurrency note: a case-A own
915
+ // reply coinciding with an unrelated background handback in the same ≤TTL
916
+ // window degrades to two messages — safe (never a silent drop/edit).
916
917
  const ownerEndedAt = ownerTurn?.endedAt ?? null
918
+ // MUST-FIX 2 (dup-audit / Fable 2026-07-21) — key BOTH the supersede lane and
919
+ // the marker-gate read on the FRAMEWORK-resolved thread (the owner turn's
920
+ // `sessionThreadId` — the lane the flush recorded on), NOT the raw model arg
921
+ // `args.message_thread_id` (`replyThreadId`). The flush records on
922
+ // `turn.sessionThreadId`; the owner turn resolved here IS that turn, so its
923
+ // thread is where its record and its handback marker live. Reading the gate on
924
+ // the raw arg let a reply carry `message_thread_id=<other topic>` to dodge a
925
+ // handback marker stamped on the real topic → silent edit-over (the regression
926
+ // F2's raw-arg keying introduced). The owner turn's thread is not model-
927
+ // derived, so it cannot be steered. Falls back to the raw arg only when no
928
+ // owner turn resolved (no record to clobber on the collapse path).
929
+ const gateThreadId = ownerTurn?.sessionThreadId ?? replyThreadId
930
+ // MUST-FIX 2 (dup-audit / Fable) — the content-gate READ is CHAT-WIDE, not
931
+ // lane-specific: `findLatestEndedTurnForChat` resolves owners chat-wide, so a
932
+ // handback in topic A can supersede topic B's ended turn; a thread-keyed gate
933
+ // read (the F2 regression) let a reply dodge that handback by carrying a
934
+ // different `message_thread_id`. Chat-wide makes the gate un-steerable — any
935
+ // in-window handback in the chat keeps the content gate (accepting the F2
936
+ // visible-dup in the overlap window; a self-healing dup beats a silent loss).
937
+ // The supersede `take()` LANE below stays thread-resolved (`gateThreadId` =
938
+ // the owner turn's framework thread, not the raw arg), so a handback's
939
+ // correction only ever touches ITS OWN topic's record.
917
940
  const handbackAt = getLastSubagentHandbackAt(chat_id)
918
941
  const now = Date.now()
919
942
  const handbackCouldOwnReply =
@@ -921,10 +944,26 @@ export async function sendReply(
921
944
  ownerEndedAt != null &&
922
945
  handbackAt > ownerEndedAt &&
923
946
  now - handbackAt <= DEFAULT_SUPERSEDE_TTL_MS
924
- const replyIsOwnAnswer = ownerTier === 'live' || !handbackCouldOwnReply
947
+ // MUST-FIX 1 (silent-data-loss, PROVEN by Fable 2026-07-21) — restrict the
948
+ // content-gate BYPASS to the tiers whose attribution is NOT model-steerable:
949
+ // - `live` — the framework-owned live `currentTurn` (not model-derived,
950
+ // and `decideSupersede`'s same-turnId check bars it from an
951
+ // ended turn's record). Bypasses even a handback window.
952
+ // - `latest-ended` — the ambiguous DM/late-reply fallback the marko fix
953
+ // actually needs; bypass ONLY when no decoupled completion is
954
+ // in the window (marker-absence ⇒ own answer).
955
+ // The `quoted` / `origin` tiers resolve from MODEL-SUPPLIED args
956
+ // (`args.reply_to` / `args.origin_turn_id`), so a reply can steer ITSELF onto
957
+ // a DIFFERENT ended turn's record — with marker-absence they used to bypass
958
+ // the content gate and silently edit-over that turn's delivered answer (the
959
+ // #3429 double-loss, executed by Fable). Those tiers therefore NEVER bypass:
960
+ // they always go through the content gate, so foreign content sends fresh and
961
+ // only a genuine same-answer reply collapses.
962
+ const replyIsOwnAnswer =
963
+ ownerTier === 'live' || (ownerTier === 'latest-ended' && !handbackCouldOwnReply)
925
964
  const decision = flushedTurnSupersede.take(
926
965
  chat_id,
927
- replyThreadId,
966
+ gateThreadId,
928
967
  { liveTurnId: resolvedTurnId, replyText: text, positiveAttribution: replyIsOwnAnswer, now },
929
968
  )
930
969
  if (decision.supersede) {
@@ -31,6 +31,7 @@
31
31
 
32
32
  import type { InboundMessage } from './ipc-protocol.js'
33
33
  import type { InboundSpool } from './inbound-spool.js'
34
+ import { stampsHandbackMarker } from './subagent-handback-marker.js'
34
35
 
35
36
  /** Default cap per agent. Tuned for `should fit a reasonable backlog of
36
37
  * approval cards stacked while bridge is offline` but no more. */
@@ -82,13 +83,17 @@ export interface PendingInboundBufferOptions {
82
83
  /**
83
84
  * fix/backstop-duplicate-reply MUST-FIX 2 — called on every push of a
84
85
  * `subagent_handback` envelope (live synthesis AND boot-replay re-push),
85
- * carrying the envelope's `chatId` and its own `ts` (ms). The gateway wires
86
- * this to the per-chat subagent-handback marker so the supersede path can tell
87
- * a flushed turn's own late reply from a background handback attributed to it
88
- * INCLUDING after a restart, where the only handback push is the replay.
89
- * Best-effort: a throw here never breaks the push hot path.
86
+ * carrying the envelope's `chatId`, its `threadId` (the originating forum
87
+ * topic, or undefined for a DM), and its own `ts` (ms). The gateway wires this
88
+ * to the per-chat/thread subagent-handback marker so the supersede path can
89
+ * tell a flushed turn's own late reply from a background handback attributed to
90
+ * it INCLUDING after a restart, where the only handback push is the replay.
91
+ * The `threadId` is passed so the marker keys on the SAME `chatId|threadId`
92
+ * lane the supersede registry uses (dup-audit F2): a handback in one topic must
93
+ * not hold the content gate open in another. Best-effort: a throw here never
94
+ * breaks the push hot path.
90
95
  */
91
- onHandbackEnqueue?: (chatId: string, ts: number) => void
96
+ onHandbackEnqueue?: (chatId: string, threadId: number | undefined, ts: number) => void
92
97
  }
93
98
 
94
99
  /**
@@ -362,9 +367,17 @@ export function createPendingInboundBuffer(
362
367
  // content gate → silent edit-over-answer. Uses the envelope's own `ts`
363
368
  // (ms, `Date.now()`-derived at synthesis) so the marker reflects when the
364
369
  // handback actually happened, not the replay moment. Best-effort.
365
- if (msg.meta?.source === 'subagent_handback' && opts.onHandbackEnqueue != null) {
370
+ // F1 (dup-audit) the ONE chokepoint that decides which sources stamp the
371
+ // decoupled-completion marker, delegated to the single `stampsHandbackMarker`
372
+ // predicate (its membership is the invariant's only extension point). Every
373
+ // inbound — live synthesis AND boot-replay — funnels through this push(), so
374
+ // routing the decision here makes "a decoupled late-reply source stamps the
375
+ // marker" true BY CONSTRUCTION rather than by per-feature discipline.
376
+ if (stampsHandbackMarker(msg.meta?.source) && opts.onHandbackEnqueue != null) {
366
377
  try {
367
- opts.onHandbackEnqueue(msg.chatId, msg.ts)
378
+ // F2 (dup-audit): pass the envelope's originating topic so the marker
379
+ // keys on the same `chatId|threadId` lane as the supersede registry.
380
+ opts.onHandbackEnqueue(msg.chatId, msg.threadId, msg.ts)
368
381
  } catch {
369
382
  /* marker stamp is best-effort; never break the push hot path */
370
383
  }
@@ -1,18 +1,18 @@
1
1
  /**
2
- * Per-chat marker of the most recent gateway-synthesized `subagent_handback`
3
- * enqueue (fix/backstop-duplicate-reply).
2
+ * Per-chat-and-thread marker of the most recent gateway-synthesized
3
+ * `subagent_handback` enqueue (fix/backstop-duplicate-reply).
4
4
  *
5
5
  * A BACKGROUND sub-agent completion is a GATEWAY-SYNTHESIZED event, not model
6
6
  * output: when a background worker terminates the gateway wakes the agent with a
7
- * `subagent_handback` inbound. Recording WHEN one was enqueued, per chat, is the
8
- * ONE deterministic signal that distinguishes the two late-reply cases that both
9
- * resolve a flush-delivered ENDED turn via the latest-ended tier — the case the
10
- * owner-resolution tier alone cannot separate (a DM late reply has no
7
+ * `subagent_handback` inbound. Recording WHEN one was enqueued, per chat/thread,
8
+ * is the ONE deterministic signal that distinguishes the two late-reply cases
9
+ * that both resolve a flush-delivered ENDED turn via the latest-ended tier — the
10
+ * case the owner-resolution tier alone cannot separate (a DM late reply has no
11
11
  * live/origin/quoted attribution, so both land on latest-ended):
12
12
  *
13
13
  * - CASE A — the flushed turn's OWN reworded reply landing late. NO
14
- * `subagent_handback` was enqueued for this chat after the turn ended, so the
15
- * reply is that turn's own answer → the supersede path collapses the
14
+ * `subagent_handback` was enqueued for this chat/thread after the turn ended,
15
+ * so the reply is that turn's own answer → the supersede path collapses the
16
16
  * provisional flush REGARDLESS of the model's rewording (closes the #3429
17
17
  * reworded-duplicate regression: agent:marko 2026-07-20, turns
18
18
  * #1177/#1182/#1201 double-sent).
@@ -22,21 +22,200 @@
22
22
  * and send fresh (two messages), never silently edit/delete the flushed
23
23
  * answer.
24
24
  *
25
- * One entry per chat (overwritten on each enqueue), so bounded by chat count. No
26
- * clock reads beyond the caller-supplied `now`; the gateway wires the actual
27
- * enqueue site and the supersede-path read. Deterministic keyed on a
28
- * gateway-emitted event, never on model discipline (Ken's controls-in-code rule).
25
+ * ## Why thread-keyed (F2, dup-audit 2026-07-21)
26
+ *
27
+ * The supersede registry this marker gates is keyed on `chatId|threadId`
28
+ * (`flushed-turn-supersede.ts` `makeKey`). Keying the marker on `chatId` ALONE
29
+ * was inconsistent: in a forum supergroup a background handback in topic A
30
+ * stamped the chat-wide marker, and a genuine CASE-A reworded own-reply in ANY
31
+ * other topic within the 60 s TTL then computed `handbackCouldOwnReply = true` →
32
+ * kept the content gate → shipped a second visible bubble instead of collapsing.
33
+ * The blast radius was every topic in the chat for 60 s per handback. Keying on
34
+ * `chatId + threadId` — the SAME lane the supersede registry uses — confines a
35
+ * handback's gate-hold to the topic it actually landed in, so an unrelated
36
+ * topic's CASE-A collapse is untouched. A DM (no thread) collapses to the
37
+ * chat-only key, so single-lane behaviour is unchanged.
38
+ *
39
+ * One entry per chat/thread (overwritten on each enqueue), so bounded by
40
+ * chat×topic count. No clock reads beyond the caller-supplied `now`; the gateway
41
+ * wires the actual enqueue site and the supersede-path read. Deterministic —
42
+ * keyed on a gateway-emitted event, never on model discipline (Ken's
43
+ * controls-in-code rule).
44
+ */
45
+ /**
46
+ * ## The enforced invariant (F1, dup-audit 2026-07-21)
47
+ *
48
+ * The whole content-gate bypass rests on this premise: *a decoupled late reply
49
+ * (turn == null) carrying content that is NOT the flushed turn's own answer,
50
+ * resolving an ENDED flushed turn as its owner, can ONLY be a gateway-synthesized
51
+ * completion — and every such completion stamps this marker.* If that premise
52
+ * holds, then marker-ABSENCE in the window proves the late reply IS the flushed
53
+ * turn's own (possibly reworded) answer, so bypassing the content gate is safe
54
+ * (closes the marko duplicate). If a NEW synthesized-inbound source were ever
55
+ * able to land as a decoupled late reply with foreign content WITHOUT stamping,
56
+ * it would silently edit over a delivered answer (the #3429 failure).
57
+ *
58
+ * We make the premise an ENFORCED invariant rather than an architectural
59
+ * coincidence by routing the stamp decision through this ONE predicate at the ONE
60
+ * chokepoint every inbound funnels through (`pendingInboundBuffer.push` — live
61
+ * synthesis, boot-replay, and every future source alike). The membership of the
62
+ * decoupled-completion class is defined HERE and nowhere else:
63
+ *
64
+ * - `subagent_handback` — the only source today that wakes the agent to emit a
65
+ * reply with NO live gateway turn of its own (a background worker completion),
66
+ * so its reply resolves a DIFFERENT, already-ended turn via the latest-ended
67
+ * tier. It is the F1 vector.
68
+ * - Every OTHER synthesized source (cron, resume_*, reaction, vault_*, …) lands
69
+ * as its OWN live inbound turn, so its reply resolves the `live` tier for its
70
+ * own turnId and structurally cannot supersede a different ended turn's flush
71
+ * record (`decideSupersede` requires `record.turnId === liveTurnId`). Those
72
+ * must NOT stamp — stamping them would needlessly hold the content gate open
73
+ * for an unrelated own-reply in the window (a safe but avoidable visible dup).
74
+ *
75
+ * This predicate is therefore the SINGLE point of extension: any future feature
76
+ * that synthesizes an inbound which can land as a DECOUPLED late reply (no live
77
+ * turn) MUST add its `meta.source` here — and because the chokepoint consults
78
+ * this predicate, doing so is the whole wiring. A source that forgets to opt in
79
+ * cannot reach the bypass unnoticed: the negative outcome guard
80
+ * (`send-reply-golden.test.ts`) pins that a decoupled foreign-content reply on a
81
+ * non-live tier never silently edits over the flushed answer.
82
+ *
83
+ * ## Inbound meta.source classification registry (F1 durability — dup-audit
84
+ * MUST-FIX 3, Fable 2026-07-21)
85
+ *
86
+ * The old predicate was a bare `=== 'subagent_handback'` with NO tripwire and an
87
+ * UNSAFE default: adding a new decoupled-completion source tripped nothing — no
88
+ * stamp → content-gate bypass eligible → silent edit-over of a delivered answer.
89
+ * This registry makes the classification EXPLICIT, EXHAUSTIVE and FAIL-SAFE:
90
+ *
91
+ * - Every known `meta.source` is listed with `decoupledCompletion`. Today ONLY
92
+ * `subagent_handback` is true; every other synthesized source lands as its
93
+ * OWN live inbound turn (live tier → cannot supersede a different ended
94
+ * turn's record), so it must NOT stamp.
95
+ * - An UNKNOWN / unclassified source FAILS SAFE: `stampsHandbackMarker` returns
96
+ * `true`, so it STAMPS → the content gate is KEPT → the worst case is a
97
+ * visible duplicate, NEVER a silent edit-over (inverted from the old
98
+ * deny-default, which failed toward silent loss).
99
+ * - The exhaustiveness test (`subagent-handback-marker.test.ts`) scans the
100
+ * gateway for `source:` / `meta.source ===` literals and FAILS when a new one
101
+ * is added without a registry entry — forcing a conscious classification.
102
+ *
103
+ * Defense-in-depth with the tier restriction (`outbound-send-path.ts`): the
104
+ * content-gate bypass is now limited to the `live` + `latest-ended` tiers, so
105
+ * model-steerable `quoted`/`origin` attributions can never bypass regardless of
106
+ * the marker. This registry closes the residual `latest-ended` vector for a
107
+ * FUTURE decoupled source, fail-safe.
29
108
  */
109
+ export const INBOUND_SOURCE_CLASSIFICATION: Record<string, { decoupledCompletion: boolean }> = {
110
+ // The ONE decoupled-completion source today: a background worker termination
111
+ // wakes the agent with no live turn of its own → its reply resolves a
112
+ // different, already-ended turn via the latest-ended tier. THE F1 vector.
113
+ subagent_handback: { decoupledCompletion: true },
114
+ // Everything below lands as its OWN live inbound turn (live tier), so its reply
115
+ // resolves the live tier for its own turnId and structurally cannot supersede a
116
+ // different ended turn's record → not a decoupled-completion vector, must not stamp.
117
+ cron: { decoupledCompletion: false },
118
+ reaction: { decoupledCompletion: false },
119
+ subagent_progress: { decoupledCompletion: false },
120
+ resume_interrupted: { decoupledCompletion: false },
121
+ resume_deferred: { decoupledCompletion: false },
122
+ resume_watchdog_timeout: { decoupledCompletion: false },
123
+ vault_grant_approved: { decoupledCompletion: false },
124
+ vault_grant_denied: { decoupledCompletion: false },
125
+ vault_grant_timeout: { decoupledCompletion: false },
126
+ vault_save_completed: { decoupledCompletion: false },
127
+ vault_save_discarded: { decoupledCompletion: false },
128
+ vault_save_failed: { decoupledCompletion: false },
129
+ vault_save_timeout: { decoupledCompletion: false },
130
+ secret_provided: { decoupledCompletion: false },
131
+ secret_declined: { decoupledCompletion: false },
132
+ secret_provide_failed: { decoupledCompletion: false },
133
+ secret_request_timeout: { decoupledCompletion: false },
134
+ mental_model_propose_timeout: { decoupledCompletion: false },
135
+ bridge_dead_restart: { decoupledCompletion: false },
136
+ obligation_represent: { decoupledCompletion: false },
137
+ missed_approval_retry: { decoupledCompletion: false },
138
+ skill_proposal_apply: { decoupledCompletion: false },
139
+ warmup: { decoupledCompletion: false },
140
+ // dup-audit pass-2 (Fable) — sources the widened exhaustiveness scanner now
141
+ // sees. Each lands as its OWN live inbound turn (not a decoupled completion
142
+ // resolving a DIFFERENT ended turn), so it must NOT stamp — else its fail-safe
143
+ // stamp would hold the content gate chat-wide for 60 s and re-open the
144
+ // reworded-own-answer visible dup in that window.
145
+ // - mental_model_proposal_{applied,denied,failed}: resume-synthetic inbounds
146
+ // injected via `deliverResumeSyntheticOrBuffer` as their own live turn.
147
+ // - webhook / linear: built in `src/web/webhook-dispatch.ts`, delivered via
148
+ // the gateway's `webhookInject` (`sendToAgent`, buffer on miss) as their
149
+ // own live turn.
150
+ mental_model_proposal_applied: { decoupledCompletion: false },
151
+ mental_model_proposal_denied: { decoupledCompletion: false },
152
+ mental_model_proposal_failed: { decoupledCompletion: false },
153
+ webhook: { decoupledCompletion: false },
154
+ linear: { decoupledCompletion: false },
155
+ }
156
+
157
+ /**
158
+ * Whether an inbound of this `meta.source` must stamp the decoupled-completion
159
+ * marker. Null/undefined (a normal user inbound — not synthesized) → false.
160
+ * A known source → its registry classification. An UNKNOWN source → true
161
+ * (fail-safe: stamp, so an unclassified future decoupled source can only cause a
162
+ * visible dup, never a silent edit-over).
163
+ */
164
+ export function stampsHandbackMarker(source: string | null | undefined): boolean {
165
+ if (source == null) return false
166
+ const known = INBOUND_SOURCE_CLASSIFICATION[source]
167
+ if (known == null) return true // fail-safe: unknown synthesized source stamps
168
+ return known.decoupledCompletion
169
+ }
170
+
171
+ /** Sentinel thread key for the no-thread (DM / bare-chat) lane. */
172
+ const MAIN_THREAD_KEY = '<main>'
173
+
30
174
  export class SubagentHandbackMarker {
31
- private readonly lastAtByChat = new Map<string, number>()
175
+ // chatId (threadKey last enqueue ms). Nested so the CONTENT-GATE read can
176
+ // query chat-wide (`lastAtInChat`) while the record stays thread-resolved.
177
+ private readonly byChat = new Map<string, Map<string, number>>()
178
+
179
+ private threadKey(threadId: number | undefined): string {
180
+ return threadId == null ? MAIN_THREAD_KEY : String(threadId)
181
+ }
182
+
183
+ /** Record that a `subagent_handback` was enqueued for `chatId`/`threadId` at
184
+ * `now` (ms). Thread-resolved so the record retains the originating topic. */
185
+ record(chatId: string, threadId: number | undefined, now: number): void {
186
+ let inner = this.byChat.get(chatId)
187
+ if (inner == null) {
188
+ inner = new Map<string, number>()
189
+ this.byChat.set(chatId, inner)
190
+ }
191
+ inner.set(this.threadKey(threadId), now)
192
+ }
32
193
 
33
- /** Record that a `subagent_handback` was enqueued for `chatId` at `now` (ms). */
34
- record(chatId: string, now: number): void {
35
- this.lastAtByChat.set(chatId, now)
194
+ /** Wall-clock ms of the most recent handback enqueue for a SPECIFIC
195
+ * `chatId`/`threadId` lane, or null. (Diagnostics / unit tests.) */
196
+ lastAt(chatId: string, threadId: number | undefined): number | null {
197
+ return this.byChat.get(chatId)?.get(this.threadKey(threadId)) ?? null
36
198
  }
37
199
 
38
- /** Wall-clock ms of the most recent handback enqueue for `chatId`, or null. */
39
- lastAt(chatId: string): number | null {
40
- return this.lastAtByChat.get(chatId) ?? null
200
+ /**
201
+ * Wall-clock ms of the most recent handback enqueue ANYWHERE in `chatId`
202
+ * (across every topic lane), or null. This is what the content-gate read uses
203
+ * (dup-audit MUST-FIX 2, Fable 2026-07-21): the owner-resolution latest-ended
204
+ * tier is CHAT-WIDE (`findLatestEndedTurnForChat` ignores thread), so a
205
+ * background handback in topic A can resolve — and supersede — topic B's
206
+ * ended turn. A thread-SPECIFIC gate read (the F2 regression) let a reply
207
+ * dodge that handback by carrying a different `message_thread_id`, silently
208
+ * editing over the answer. Querying chat-wide makes the gate un-steerable by
209
+ * the reply's own thread arg: any in-window handback in the chat keeps the
210
+ * content gate. The cost is the F2 visible-dup (a handback in one topic keeps
211
+ * the gate for a coinciding own-reply in another for ≤TTL) — a self-healing
212
+ * visible duplicate, which is strictly better than a silent edit-over.
213
+ */
214
+ lastAtInChat(chatId: string): number | null {
215
+ const inner = this.byChat.get(chatId)
216
+ if (inner == null || inner.size === 0) return null
217
+ let max = -Infinity
218
+ for (const ts of inner.values()) if (ts > max) max = ts
219
+ return max === -Infinity ? null : max
41
220
  }
42
221
  }
@@ -46,7 +46,7 @@ import {
46
46
  } from '../gateway/outbound-send-path.js'
47
47
  import { OutboundDedupCache } from '../recent-outbound-dedup.js'
48
48
  import { FlushedTurnSupersedeRegistry } from '../flushed-turn-supersede.js'
49
- import { SubagentHandbackMarker } from '../gateway/subagent-handback-marker.js'
49
+ import { SubagentHandbackMarker, stampsHandbackMarker } from '../gateway/subagent-handback-marker.js'
50
50
  import { createPendingInboundBuffer } from '../gateway/pending-inbound-buffer.js'
51
51
  import { redact } from '../secret-detect/redact.js'
52
52
  import type { CurrentTurn, Access } from '../gateway/gateway.js'
@@ -887,7 +887,7 @@ describe('#3429 — post-turn-end handback vs flush-delivered supersede (real se
887
887
  const marker = new SubagentHandbackMarker()
888
888
  const buffer = createPendingInboundBuffer({
889
889
  log: () => {},
890
- onHandbackEnqueue: (chatId, ts) => marker.record(chatId, ts),
890
+ onHandbackEnqueue: (chatId, threadId, ts) => marker.record(chatId, threadId, ts),
891
891
  })
892
892
 
893
893
  // Simulate the boot-replay re-push of an un-acked spooled handback envelope.
@@ -905,14 +905,14 @@ describe('#3429 — post-turn-end handback vs flush-delivered supersede (real se
905
905
  meta: { source: 'subagent_handback' },
906
906
  })
907
907
  // The chokepoint stamped the marker from the replay push (pre-fix: empty).
908
- expect(marker.lastAt(CHAT)).toBe(handbackTs)
908
+ expect(marker.lastAt(CHAT, undefined)).toBe(handbackTs)
909
909
 
910
910
  const h = makeHarness()
911
911
  const owner = makeFlushDeliveredEndedTurn()
912
912
  seedRecord(h, owner)
913
913
  h.deps.resolveReplyOwnerTurn = () => ({ turn: owner, tier: 'latest-ended' })
914
- // The gateway reads the marker the boot-replay stamped.
915
- h.deps.getLastSubagentHandbackAt = (chatId) => marker.lastAt(chatId)
914
+ // The gateway reads the marker the boot-replay stamped (chat-wide gate).
915
+ h.deps.getLastSubagentHandbackAt = (chatId) => marker.lastAtInChat(chatId)
916
916
 
917
917
  const res = await sendReply(h.deps, req(HANDBACK))
918
918
 
@@ -929,12 +929,226 @@ describe('#3429 — post-turn-end handback vs flush-delivered supersede (real se
929
929
  const marker = new SubagentHandbackMarker()
930
930
  const buffer = createPendingInboundBuffer({
931
931
  log: () => {},
932
- onHandbackEnqueue: (chatId, ts) => marker.record(chatId, ts),
932
+ onHandbackEnqueue: (chatId, threadId, ts) => marker.record(chatId, threadId, ts),
933
933
  })
934
934
  buffer.push('marko', {
935
935
  type: 'inbound', chatId: CHAT, messageId: 5, user: 'ken', userId: 1,
936
936
  ts: Date.now(), text: 'hi', meta: {},
937
937
  })
938
- expect(marker.lastAt(CHAT)).toBe(null)
938
+ expect(marker.lastAt(CHAT, undefined)).toBe(null)
939
+ })
940
+
941
+ // ─── MUST-FIX 2 (dup-audit / Fable): the gate is un-steerable by a model
942
+ // thread arg — a cross-topic handback still keeps the content gate ───
943
+ //
944
+ // Fable's PROVEN regression: F2's thread-keyed gate read let a foreign-content
945
+ // reply carrying `message_thread_id=<other topic>` dodge a handback marker
946
+ // stamped on the real topic → silent edit-over (the #3429 double-loss). Because
947
+ // `findLatestEndedTurnForChat` resolves owners CHAT-WIDE, a topic-A handback can
948
+ // resolve — and supersede — topic B's ended turn. The gate read is therefore
949
+ // chat-wide (`lastAtInChat`): any in-window handback in the chat keeps the gate,
950
+ // so the reply's own thread arg cannot bypass it. Drives the REAL buffer
951
+ // chokepoint + REAL send path.
952
+ it('MUST-FIX 2: a handback stamped in topic A keeps the content gate for a ' +
953
+ 'foreign reply steered to topic B (message_thread_id) — FRESH send, no silent edit-over', async () => {
954
+ const TOPIC_A = 111
955
+ const TOPIC_B = 222
956
+ const marker = new SubagentHandbackMarker()
957
+ const buffer = createPendingInboundBuffer({
958
+ log: () => {},
959
+ onHandbackEnqueue: (chatId, threadId, ts) => marker.record(chatId, threadId, ts),
960
+ })
961
+ // A background handback lands in TOPIC A (envelope threadId=A) via the real
962
+ // chokepoint — after topic B's turn ended (now-30s), within the 60s TTL.
963
+ const handbackTs = Date.now() - 15_000
964
+ buffer.push('marko', {
965
+ type: 'inbound', chatId: CHAT, threadId: TOPIC_A, messageId: handbackTs,
966
+ user: 'subagent-watcher', userId: 0, ts: handbackTs, text: 'handback',
967
+ meta: { source: 'subagent_handback' },
968
+ })
969
+
970
+ // Topic B has its OWN flush-delivered ended turn with a real answer.
971
+ const h = makeHarness()
972
+ const owner = { ...makeFlushDeliveredEndedTurn(), sessionThreadId: TOPIC_B } as unknown as CurrentTurn
973
+ h.deps.flushedTurnSupersede.record(
974
+ CHAT, TOPIC_B,
975
+ { turnId: (owner as CurrentTurn).turnId, messageIds: [FLUSH_MSG_ID], text: FLUSHED_TEXT },
976
+ Date.now(),
977
+ )
978
+ // The topic-A handback's reply is STEERED to topic B (message_thread_id=222)
979
+ // and resolves topic B's ended turn chat-wide (latest-ended), carrying FOREIGN
980
+ // content. The gate read is chat-wide, so the topic-A stamp is seen.
981
+ h.deps.resolveReplyOwnerTurn = () => ({ turn: owner as CurrentTurn, tier: 'latest-ended' })
982
+ h.deps.getLastSubagentHandbackAt = (chatId) => marker.lastAtInChat(chatId)
983
+
984
+ const res = await sendReply(
985
+ h.deps,
986
+ req(HANDBACK, { message_thread_id: TOPIC_B }),
987
+ )
988
+
989
+ // Gate kept → FRESH notifying send; topic B's flushed answer is NEITHER
990
+ // edited nor deleted. (Under the F2 thread-keyed gate this was editMessageText
991
+ // ×1 over msg 4242 — the silent-loss regression this closes.)
992
+ const fresh = h.calls.filter((c) => c.method === 'sendRichMessage')
993
+ expect(fresh).toHaveLength(1)
994
+ expect(h.calls.filter((c) => c.method === 'editMessageText')).toHaveLength(0)
995
+ expect(h.calls.filter((c) => c.method === 'deleteMessage')).toHaveLength(0)
996
+ expect(res.content[0]!.text).toMatch(/^sent \(id: \d+\)$/)
997
+ // Topic B's flush record NOT consumed — the delivered answer stands.
998
+ expect(
999
+ h.deps.flushedTurnSupersede.peek(CHAT, TOPIC_B, {
1000
+ liveTurnId: (owner as CurrentTurn).turnId,
1001
+ replyText: CANONICAL_REPLY,
1002
+ now: Date.now(),
1003
+ }).supersede,
1004
+ ).toBe(true)
1005
+ })
1006
+
1007
+ // Thread-keyed COLLAPSE still routes per-topic: a topic reply supersedes its
1008
+ // OWN topic's flush record (via the framework-resolved owner thread, not the
1009
+ // raw arg), so a genuine own-reply in a forum topic collapses to one message.
1010
+ it('F2 positive: a topic own-reply (no handback) supersedes its own topic\'s ' +
1011
+ 'flush record — one message, on the framework-resolved thread lane', async () => {
1012
+ const TOPIC = 222
1013
+ const h = makeHarness()
1014
+ const owner = { ...makeFlushDeliveredEndedTurn(), sessionThreadId: TOPIC } as unknown as CurrentTurn
1015
+ // Flush recorded on the owner turn's OWN topic lane (what the flush does).
1016
+ h.deps.flushedTurnSupersede.record(
1017
+ CHAT, TOPIC,
1018
+ { turnId: (owner as CurrentTurn).turnId, messageIds: [FLUSH_MSG_ID], text: FLUSHED_TEXT },
1019
+ Date.now(),
1020
+ )
1021
+ h.deps.resolveReplyOwnerTurn = () => ({ turn: owner as CurrentTurn, tier: 'latest-ended' })
1022
+ h.deps.getLastSubagentHandbackAt = () => null // no handback anywhere
1023
+
1024
+ const res = await sendReply(h.deps, req(REWORDED_SAME_TURN_ANSWER, { message_thread_id: TOPIC }))
1025
+
1026
+ // Collapses to ONE message: the topic's flushed message edited in place.
1027
+ const edits = h.calls.filter((c) => c.method === 'editMessageText')
1028
+ expect(edits).toHaveLength(1)
1029
+ expect(edits[0]!.message_id).toBe(FLUSH_MSG_ID)
1030
+ expect(h.calls.filter((c) => c.method === 'sendRichMessage')).toHaveLength(0)
1031
+ expect(res.content[0]!.text).toMatch(/^sent/)
1032
+ })
1033
+
1034
+ // ─── F1 (dup-audit): negative outcome guard — no silent edit-over ───
1035
+ //
1036
+ // The content-gate bypass must NEVER silently edit over a flush-delivered
1037
+ // answer with FOREIGN content on a non-live tier. A decoupled completion (the
1038
+ // handback class the marker covers) landing with genuinely different content,
1039
+ // resolving an ended flushed turn via the latest-ended tier, must SEND FRESH —
1040
+ // the flushed answer left untouched (Telegram edits do not re-notify, so an
1041
+ // edit-over is a silent double-loss: the handback never surfaces AND the answer
1042
+ // is destroyed — the #3429 incident). This pins the invariant's OUTCOME: the
1043
+ // stamp (driven by the single chokepoint predicate) keeps the gate, and the
1044
+ // foreign reply never touches the delivered answer.
1045
+ it('F1: a decoupled foreign-content reply on a non-live tier resolving a ' +
1046
+ 'DIFFERENT ended turn sends FRESH — the flushed answer is never edited/deleted', async () => {
1047
+ const h = makeHarness()
1048
+ const owner = makeFlushDeliveredEndedTurn()
1049
+ seedRecord(h, owner)
1050
+ // The reply resolves the flush-delivered ENDED turn via the ambiguous
1051
+ // latest-ended tier (no live turn, no positive attribution) and carries
1052
+ // FOREIGN content (a worker report, not this turn's answer nor a rewording).
1053
+ h.deps.resolveReplyOwnerTurn = () => ({ turn: owner, tier: 'latest-ended' })
1054
+ // A decoupled completion IS in the window (the marker stamped it) — so this
1055
+ // late reply might BE it. The invariant keeps the content gate.
1056
+ h.deps.getLastSubagentHandbackAt = () => Date.now() - 15_000
1057
+
1058
+ const res = await sendReply(h.deps, req(HANDBACK))
1059
+
1060
+ // FRESH notifying send; the flushed answer is NEITHER edited nor deleted.
1061
+ const fresh = h.calls.filter((c) => c.method === 'sendRichMessage')
1062
+ expect(fresh).toHaveLength(1)
1063
+ expect(fresh[0]!.text).toContain('migration audit')
1064
+ expect(fresh[0]!.message_id).not.toBe(FLUSH_MSG_ID)
1065
+ expect(h.calls.filter((c) => c.method === 'editMessageText')).toHaveLength(0)
1066
+ expect(h.calls.filter((c) => c.method === 'deleteMessage')).toHaveLength(0)
1067
+ expect(res.content[0]!.text).toMatch(/^sent \(id: \d+\)$/)
1068
+ // The flush record is NOT consumed — the flushed answer stands, and the
1069
+ // turn's OWN canonical replay can still correct it.
1070
+ expect(
1071
+ h.deps.flushedTurnSupersede.peek(CHAT, undefined, {
1072
+ liveTurnId: OWNER_TURN_ID,
1073
+ replyText: CANONICAL_REPLY,
1074
+ now: Date.now(),
1075
+ }).supersede,
1076
+ ).toBe(true)
1077
+ })
1078
+
1079
+ // Structural: the stamp membership lives in exactly ONE predicate, consulted
1080
+ // at the ONE buffer chokepoint. If a future refactor inlines a bare
1081
+ // `=== 'subagent_handback'` check at the chokepoint (bypassing the predicate),
1082
+ // the invariant's single-extension-point guarantee is lost — fail loudly.
1083
+ it('F1: the buffer chokepoint stamps via the stampsHandbackMarker predicate, ' +
1084
+ 'not an inlined source literal', () => {
1085
+ const bufferSrc = readFileSync(new URL('../gateway/pending-inbound-buffer.ts', import.meta.url), 'utf8')
1086
+ expect(bufferSrc).toContain('stampsHandbackMarker(msg.meta?.source)')
1087
+ // The predicate itself is the single membership definition.
1088
+ expect(stampsHandbackMarker('subagent_handback')).toBe(true)
1089
+ expect(stampsHandbackMarker('cron')).toBe(false)
1090
+ expect(stampsHandbackMarker('resume_interrupted')).toBe(false)
1091
+ expect(stampsHandbackMarker(undefined)).toBe(false)
1092
+ })
1093
+
1094
+ // ─── MUST-FIX 1 (dup-audit / Fable): model-steerable tiers NEVER bypass ───
1095
+ //
1096
+ // The `quoted`/`origin` tiers resolve from MODEL-supplied args (reply_to /
1097
+ // origin_turn_id), so a reply can steer ITSELF onto a different ended turn's
1098
+ // record. Marker-absence proves "own answer" only for the framework-derived
1099
+ // latest-ended tier — a quoted/origin resolution with no marker is NOT
1100
+ // ownership evidence. Fable executed this exact path (quoted tier, no marker,
1101
+ // foreign content → editMessageText ×1 over the flushed answer). With the tier
1102
+ // restriction, quoted/origin ALWAYS traverse the content gate → foreign content
1103
+ // sends FRESH, the flushed answer untouched. RED on pre-fix code
1104
+ // (replyIsOwnAnswer = tier==='live' || !handbackCouldOwnReply → quoted bypassed).
1105
+ it('MUST-FIX 1: a quoted-tier reply with NO marker and FOREIGN content does NOT ' +
1106
+ 'bypass the content gate — FRESH send, flushed answer never edited/deleted', async () => {
1107
+ const h = makeHarness()
1108
+ const owner = makeFlushDeliveredEndedTurn()
1109
+ seedRecord(h, owner)
1110
+ // Model-steered `quoted` attribution to a DIFFERENT ended flushed turn, with
1111
+ // NO decoupled completion anywhere in the chat (the residual F1 vector).
1112
+ h.deps.resolveReplyOwnerTurn = () => ({ turn: owner, tier: 'quoted' })
1113
+ h.deps.getLastSubagentHandbackAt = () => null
1114
+
1115
+ const res = await sendReply(h.deps, req(HANDBACK))
1116
+
1117
+ // FRESH notifying send; the flushed answer is NEITHER edited nor deleted.
1118
+ const fresh = h.calls.filter((c) => c.method === 'sendRichMessage')
1119
+ expect(fresh).toHaveLength(1)
1120
+ expect(fresh[0]!.text).toContain('migration audit')
1121
+ expect(fresh[0]!.message_id).not.toBe(FLUSH_MSG_ID)
1122
+ expect(h.calls.filter((c) => c.method === 'editMessageText')).toHaveLength(0)
1123
+ expect(h.calls.filter((c) => c.method === 'deleteMessage')).toHaveLength(0)
1124
+ expect(res.content[0]!.text).toMatch(/^sent \(id: \d+\)$/)
1125
+ // Flush record NOT consumed — the delivered answer stands.
1126
+ expect(
1127
+ h.deps.flushedTurnSupersede.peek(CHAT, undefined, {
1128
+ liveTurnId: OWNER_TURN_ID,
1129
+ replyText: CANONICAL_REPLY,
1130
+ now: Date.now(),
1131
+ }).supersede,
1132
+ ).toBe(true)
1133
+ })
1134
+
1135
+ // Sibling positive: a quoted-tier reply carrying the turn's OWN answer (same
1136
+ // content, contained in the flushed blob) still collapses through the content
1137
+ // gate — the tier restriction only blocks FOREIGN content, not genuine replays.
1138
+ it('MUST-FIX 1 sibling: a quoted-tier reply with the SAME answer still collapses ' +
1139
+ '(content gate passes) — one message', async () => {
1140
+ const h = makeHarness()
1141
+ const owner = makeFlushDeliveredEndedTurn()
1142
+ seedRecord(h, owner)
1143
+ h.deps.resolveReplyOwnerTurn = () => ({ turn: owner, tier: 'quoted' })
1144
+ h.deps.getLastSubagentHandbackAt = () => null
1145
+
1146
+ const res = await sendReply(h.deps, req(CANONICAL_REPLY))
1147
+
1148
+ const edits = h.calls.filter((c) => c.method === 'editMessageText')
1149
+ expect(edits).toHaveLength(1)
1150
+ expect(edits[0]!.message_id).toBe(FLUSH_MSG_ID)
1151
+ expect(h.calls.filter((c) => c.method === 'sendRichMessage')).toHaveLength(0)
1152
+ expect(res.content[0]!.text).toMatch(/^sent/)
939
1153
  })
940
1154
  })
@@ -56,7 +56,12 @@ function makeFakeBot() {
56
56
  const api = {
57
57
  sendRichMessage: async (c: string, b: { markdown: string }, o: Record<string, unknown> = {}) => rec('sendRichMessage', c, b.markdown, o),
58
58
  sendMessage: async (c: string, t: string, o: Record<string, unknown> = {}) => rec('sendMessage', c, t, o),
59
- editMessageText: async (c: string, m: number, b: unknown, o: Record<string, unknown> = {}) => { rec('editMessageText', c, typeof b === 'string' ? b : (b as { markdown: string }).markdown, o); return {} },
59
+ editMessageText: async (c: string, m: number, b: unknown, o: Record<string, unknown> = {}) => {
60
+ // Preserve the REAL edit target id (m) so tests can assert an edit-in-place
61
+ // hit the flushed message, not a synthetic fresh id.
62
+ calls.push({ method: 'editMessageText', chat_id: c, text: typeof b === 'string' ? b : (b as { markdown: string }).markdown, opts: o, reply_markup: o.reply_markup ?? null, message_id: m })
63
+ return {}
64
+ },
60
65
  deleteMessage: async (c: string, m: number) => { rec('deleteMessage', c, null); return true },
61
66
  }
62
67
  return { api, calls }
@@ -100,6 +105,7 @@ function makeStreamDeps(opts?: {
100
105
  dedup?: OutboundDedupCache
101
106
  turn?: CurrentTurn | null
102
107
  deliverResult?: { sentIds: number[]; chunkCount: number; delivered: boolean; exhausted: boolean }
108
+ flushedTurnSupersede?: FlushedTurnSupersedeRegistry
103
109
  }): StreamHarness {
104
110
  const { api, calls } = makeFakeBot()
105
111
  const dedup = opts?.dedup ?? new OutboundDedupCache()
@@ -135,7 +141,7 @@ function makeStreamDeps(opts?: {
135
141
  activeTurnStartedAt: new Map(),
136
142
  backstopDeliveryLedger: ledger,
137
143
  bot: { api },
138
- flushedTurnSupersede: new FlushedTurnSupersedeRegistry(),
144
+ flushedTurnSupersede: opts?.flushedTurnSupersede ?? new FlushedTurnSupersedeRegistry(),
139
145
  idleTracker: { noteEvent: noop },
140
146
  lastPtyPreviewByChat: new Map(),
141
147
  obligationLedger: { close: noop, noteTurnEnded: noop },
@@ -206,12 +212,12 @@ function makeStreamDeps(opts?: {
206
212
  }
207
213
 
208
214
  // ── the P2 sendReply harness (same content, sharing the ONE cache) ─────────
209
- function makeSendReplyDeps(dedup: OutboundDedupCache) {
215
+ function makeSendReplyDeps(dedup: OutboundDedupCache, sharedSupersede?: FlushedTurnSupersedeRegistry) {
210
216
  const { api, calls } = makeFakeBot()
211
217
  const key = (c: string, t?: number | null) => `${c}:${t ?? 'main'}`
212
218
  const deps = {
213
219
  outboundDedup: dedup,
214
- flushedTurnSupersede: new FlushedTurnSupersedeRegistry(),
220
+ flushedTurnSupersede: sharedSupersede ?? new FlushedTurnSupersedeRegistry(),
215
221
  firstTextReplyLogged: new Set<string>(),
216
222
  suppressPtyPreview: new Set<string>(),
217
223
  activeDraftStreams: new Map(),
@@ -386,6 +392,136 @@ describe('cross-surface dedup — ONE OutboundDedupCache across P4 stream + P2 r
386
392
  })
387
393
  })
388
394
 
395
+ // ── F3 (dup-audit 2026-07-21): the flush RECORD wiring is load-bearing ──────
396
+ //
397
+ // The entire flush→reply dedup depends on stream-render.ts recording the
398
+ // flush's delivered ids into the shared FlushedTurnSupersedeRegistry
399
+ // (`flushedTurnSupersede.record(...)`). Every OTHER outcome test seeds that
400
+ // record by hand (send-reply-golden's seedRecord/seedFlushRecord), so DELETING
401
+ // the record call would reintroduce the dominant flush→reply duplicate with all
402
+ // those tests still green. This drives the REAL flush record() end-to-end (no
403
+ // pre-seed) across ONE shared registry and asserts the reworded same-turn reply
404
+ // collapses to EXACTLY ONE message — so it goes RED if the record() call is
405
+ // removed. This is the guard the audit flagged as missing.
406
+ describe('F3 — flush record() → same-turn reworded reply collapse (end-to-end, no pre-seed)', () => {
407
+ const settleFlush = () => new Promise((r) => setTimeout(r, 650))
408
+ const FLUSHED_ANSWER =
409
+ 'All twelve agents are healthy right now. The gateway, the vault broker and the ' +
410
+ 'approval kernel all report green health checks, and no container has restarted in ' +
411
+ 'the last twenty four hours, so there is nothing that needs your attention at the moment.'
412
+ const REWORDED =
413
+ 'Good news on the fleet. Every one of the twelve agents is running fine at the moment. ' +
414
+ 'Gateway, vault broker and approval kernel are all green, and nothing has restarted in ' +
415
+ 'the past day, so you do not need to do anything right now.'
416
+
417
+ it('flush → record → late reworded reply collapses to ONE message ' +
418
+ '(RED if stream-render flushedTurnSupersede.record is removed)', async () => {
419
+ // ONE shared supersede registry across BOTH surfaces — exactly the gateway
420
+ // singleton wiring. The flush RECORDS (the real stream-render.ts:1828 call);
421
+ // the reply CONSUMES. Nothing is pre-seeded.
422
+ const supersede = new FlushedTurnSupersedeRegistry()
423
+ const turn = makeTurn({ capturedText: [FLUSHED_ANSWER], capturedBlockMeta: [true] })
424
+ const sh = makeStreamDeps({ turn, flushedTurnSupersede: supersede })
425
+
426
+ // Drive the REAL turn-flush path: it delivers message A (deliverAnswer → id
427
+ // 4242) AND records {turnId, [4242]} into the shared registry.
428
+ handleSessionEvent(sh.deps, { kind: 'turn_end', durationMs: 1200 })
429
+ await settleFlush()
430
+ expect(sh.delivered).toContain(FLUSHED_ANSWER)
431
+ // The record actually landed (this is what a removal breaks first).
432
+ expect(
433
+ supersede.peek(CHAT, undefined, { liveTurnId: turn.turnId, now: Date.now() }).reason,
434
+ ).toBe('supersede')
435
+
436
+ // The model's REAL reply lands late with a REWORDED version of the same
437
+ // answer: no live turn, latest-ended tier, NO handback in flight (CASE A).
438
+ const s = makeSendReplyDeps(new OutboundDedupCache(), supersede)
439
+ s.deps.resolveReplyOwnerTurn = () => ({ turn, tier: 'latest-ended' as const })
440
+ // (getLastSubagentHandbackAt returns null in the base deps → own answer.)
441
+
442
+ const res = await sendReply(s.deps, req(REWORDED))
443
+
444
+ // EXACTLY ONE client-visible message: the flushed message (4242) edited in
445
+ // place into the reworded reply — NO fresh second bubble. Without the
446
+ // record(), the reply finds no record, falls to the latch branch, sees
447
+ // reworded ≠ flushed content, does NOT suppress, and ships a DUPLICATE
448
+ // sendRichMessage (edits=0, sends=1) → these assertions fail.
449
+ const edits = s.calls.filter((c) => c.method === 'editMessageText')
450
+ expect(edits).toHaveLength(1)
451
+ expect(edits[0]!.message_id).toBe(4242)
452
+ expect(s.calls.filter((c) => c.method === 'sendRichMessage')).toHaveLength(0)
453
+ expect(res.content[0]!.text).toMatch(/^sent/)
454
+ })
455
+ })
456
+
457
+ // ── F5 (dup-audit): true take()-before-record() interleaving ────────────────
458
+ //
459
+ // The residual race the supersede registry cannot reach: a reply whose
460
+ // supersede take() runs in the window AFTER the flush FIRED but BEFORE it
461
+ // recorded its message ids. The flush arms `turn.answerDelivered='flush'` (+
462
+ // flushedAnswerText) SYNCHRONOUSLY at fire time — before its async deliver and
463
+ // before record — so a same-answer reply landing in that window is suppressed by
464
+ // the latch, not shipped as a duplicate. This drives a genuine two-emitter
465
+ // interleave (inverted ordering: reply take() strictly before flush record())
466
+ // and asserts exactly one delivered message.
467
+ describe('F5 — take()-before-record() interleaving delivers exactly one message', () => {
468
+ const settleFlush = () => new Promise((r) => setTimeout(r, 650))
469
+ // ≥200 chars so the late reply is a substantive final answer (the floor the
470
+ // flush latch is scoped to) — else it would never trip the suppression.
471
+ const ANSWER =
472
+ 'Yes, that is all done and confirmed. The migration ran cleanly against the staging ' +
473
+ 'database, every integration check passed on the first attempt, the rollback plan is ' +
474
+ 'staged in case it is ever needed, and I have written the full run log to the shared ' +
475
+ 'drive so the team can review exactly what changed and when it happened.'
476
+
477
+ it('a reply whose take() runs BEFORE the flush record() is suppressed by the ' +
478
+ 'flush-armed latch — one delivered message, not two', async () => {
479
+ const supersede = new FlushedTurnSupersedeRegistry()
480
+ const turn = makeTurn({ capturedText: [ANSWER], capturedBlockMeta: [true] })
481
+ const sh = makeStreamDeps({ turn, flushedTurnSupersede: supersede })
482
+
483
+ // Fire the flush. Its latch is set SYNCHRONOUSLY here; deliverAnswer + record
484
+ // run in the async IIFE that has NOT completed — the pre-record window.
485
+ handleSessionEvent(sh.deps, { kind: 'turn_end', durationMs: 1200 })
486
+ // INVERTED ORDERING: the reply's take() runs now, before the flush record().
487
+ expect(
488
+ supersede.peek(CHAT, undefined, { liveTurnId: turn.turnId, now: Date.now() }).reason,
489
+ ).toBe('no-record') // record genuinely not written yet
490
+
491
+ const s = makeSendReplyDeps(new OutboundDedupCache(), supersede)
492
+ s.deps.resolveReplyOwnerTurn = () => ({ turn, tier: 'latest-ended' as const })
493
+ // The same answer landing again in the race window → latch backstop suppresses.
494
+ const res = await sendReply(s.deps, req(ANSWER))
495
+
496
+ expect(s.calls).toHaveLength(0) // the reply shipped nothing
497
+ expect(res.content[0]!.text).toContain('deduped')
498
+
499
+ await settleFlush() // let the flush's async deliver + record complete
500
+ // Exactly one message reached the user: the flush's message A.
501
+ expect(sh.delivered).toHaveLength(1)
502
+ expect(sh.delivered[0]).toContain('done and confirmed')
503
+ })
504
+ })
505
+
506
+ // ── Double-flush idempotency at the delivery primitive (audit §3.5) ─────────
507
+ //
508
+ // The E2-vs-E3 double flush (answer-ready quiescence THEN turn-end backstop for
509
+ // the SAME turn) is guarded by backstopDeliveryLedger.claim. The ledger is
510
+ // unit-tested, but the audit wanted it pinned at the SEND-COUNT level in the
511
+ // flush integration. Two turn_end dispatches for one turn must deliver once.
512
+ describe('double-flush idempotency — deliverAnswer fires once (send-count level)', () => {
513
+ const settleFlush = () => new Promise((r) => setTimeout(r, 650))
514
+ it('two turn_end dispatches for the same turn deliver the answer EXACTLY once', async () => {
515
+ const turn = makeTurn()
516
+ const answer = turn.capturedText.join('')
517
+ const sh = makeStreamDeps({ turn })
518
+ handleSessionEvent(sh.deps, { kind: 'turn_end', durationMs: 1200 }) // claims the latch
519
+ handleSessionEvent(sh.deps, { kind: 'turn_end', durationMs: 1200 }) // claim fails → no-op
520
+ await settleFlush()
521
+ expect(sh.delivered).toEqual([answer])
522
+ })
523
+ })
524
+
389
525
  describe('structural — the singleton lives once in gateway, never in the modules (Amendment 1)', () => {
390
526
  const gatewaySrc = readFileSync(new URL('../gateway/gateway.ts', import.meta.url), 'utf8')
391
527
  const streamSrc = readFileSync(new URL('../gateway/stream-render.ts', import.meta.url), 'utf8')
@@ -1,36 +1,165 @@
1
1
  /**
2
- * Unit coverage for the per-chat subagent-handback marker
3
- * (fix/backstop-duplicate-reply). The marker is the deterministic signal the
4
- * supersede path uses to tell a flushed turn's OWN reworded late reply (no
5
- * handback in flight → supersede) from a background handback attributed to that
6
- * ended turn (handback in flight → keep the #3429 content gate).
2
+ * Unit coverage for the per-chat/thread subagent-handback marker
3
+ * (fix/backstop-duplicate-reply; thread-keying dup-audit F2 2026-07-21). The
4
+ * marker is the deterministic signal the supersede path uses to tell a flushed
5
+ * turn's OWN reworded late reply (no handback in flight → supersede) from a
6
+ * background handback attributed to that ended turn (handback in flight → keep
7
+ * the #3429 content gate).
7
8
  */
8
9
 
9
10
  import { describe, it, expect } from 'vitest'
10
- import { SubagentHandbackMarker } from '../gateway/subagent-handback-marker.js'
11
+ import { readFileSync, readdirSync } from 'node:fs'
12
+ import { fileURLToPath } from 'node:url'
13
+ import { dirname, join } from 'node:path'
14
+ import {
15
+ SubagentHandbackMarker,
16
+ stampsHandbackMarker,
17
+ INBOUND_SOURCE_CLASSIFICATION,
18
+ } from '../gateway/subagent-handback-marker.js'
11
19
 
12
20
  describe('SubagentHandbackMarker', () => {
13
21
  it('returns null for a chat with no recorded handback', () => {
14
22
  const m = new SubagentHandbackMarker()
15
- expect(m.lastAt('chatA')).toBe(null)
23
+ expect(m.lastAt('chatA', undefined)).toBe(null)
16
24
  })
17
25
 
18
26
  it('returns the recorded enqueue ts for the chat', () => {
19
27
  const m = new SubagentHandbackMarker()
20
- m.record('chatA', 1_000_000)
21
- expect(m.lastAt('chatA')).toBe(1_000_000)
28
+ m.record('chatA', undefined, 1_000_000)
29
+ expect(m.lastAt('chatA', undefined)).toBe(1_000_000)
22
30
  })
23
31
 
24
32
  it('is per-chat — one chat never leaks into another', () => {
25
33
  const m = new SubagentHandbackMarker()
26
- m.record('chatA', 1_000_000)
27
- expect(m.lastAt('chatB')).toBe(null)
34
+ m.record('chatA', undefined, 1_000_000)
35
+ expect(m.lastAt('chatB', undefined)).toBe(null)
28
36
  })
29
37
 
30
38
  it('keeps only the MOST RECENT enqueue (overwrites)', () => {
31
39
  const m = new SubagentHandbackMarker()
32
- m.record('chatA', 1_000_000)
33
- m.record('chatA', 1_050_000)
34
- expect(m.lastAt('chatA')).toBe(1_050_000)
40
+ m.record('chatA', undefined, 1_000_000)
41
+ m.record('chatA', undefined, 1_050_000)
42
+ expect(m.lastAt('chatA', undefined)).toBe(1_050_000)
43
+ })
44
+
45
+ // ── F2: thread-keying (dup-audit 2026-07-21) ────────────────────────────
46
+ it('is per-THREAD — a handback in topic A does not leak into topic B', () => {
47
+ const m = new SubagentHandbackMarker()
48
+ m.record('chatA', 111, 1_000_000)
49
+ // Same chat, different topic → no marker: topic B's CASE-A collapse is
50
+ // untouched by topic A's handback (the visible-dup gap F2 closed).
51
+ expect(m.lastAt('chatA', 222)).toBe(null)
52
+ expect(m.lastAt('chatA', 111)).toBe(1_000_000)
53
+ })
54
+
55
+ it('a thread handback does not leak into the DM (no-thread) lane of the same chat', () => {
56
+ const m = new SubagentHandbackMarker()
57
+ m.record('chatA', 111, 1_000_000)
58
+ expect(m.lastAt('chatA', undefined)).toBe(null)
59
+ })
60
+
61
+ it('the no-thread lane keys identically to the supersede registry (chat only)', () => {
62
+ const m = new SubagentHandbackMarker()
63
+ m.record('chatA', undefined, 1_000_000)
64
+ // A later thread read must NOT see the DM stamp, and vice-versa.
65
+ expect(m.lastAt('chatA', undefined)).toBe(1_000_000)
66
+ expect(m.lastAt('chatA', 111)).toBe(null)
67
+ })
68
+
69
+ // ── MUST-FIX 2 (dup-audit / Fable): chat-wide gate read ─────────────────
70
+ it('lastAtInChat returns the MOST RECENT handback across ALL topics of a chat', () => {
71
+ const m = new SubagentHandbackMarker()
72
+ m.record('chatA', 111, 1_000)
73
+ m.record('chatA', 222, 3_000)
74
+ m.record('chatA', undefined, 2_000)
75
+ expect(m.lastAtInChat('chatA')).toBe(3_000)
76
+ expect(m.lastAtInChat('chatB')).toBe(null)
77
+ })
78
+
79
+ it('lastAtInChat makes the gate un-steerable: a topic-A stamp is seen chat-wide ' +
80
+ 'even when a thread-specific read of topic B would miss it', () => {
81
+ const m = new SubagentHandbackMarker()
82
+ m.record('chatA', 111, 5_000)
83
+ // The content gate reads chat-wide → sees topic A's handback…
84
+ expect(m.lastAtInChat('chatA')).toBe(5_000)
85
+ // …whereas a thread-specific read of topic 222 (the F2 regression) missed it,
86
+ // which is exactly how a steered `message_thread_id` bypassed the gate.
87
+ expect(m.lastAt('chatA', 222)).toBe(null)
88
+ })
89
+ })
90
+
91
+ // ── MUST-FIX 3 (dup-audit / Fable): fail-safe classification + exhaustiveness ──
92
+ describe('inbound source classification (F1 durability)', () => {
93
+ it('stampsHandbackMarker: subagent_handback stamps; other known sources do not', () => {
94
+ expect(stampsHandbackMarker('subagent_handback')).toBe(true)
95
+ expect(stampsHandbackMarker('cron')).toBe(false)
96
+ expect(stampsHandbackMarker('reaction')).toBe(false)
97
+ expect(stampsHandbackMarker('resume_interrupted')).toBe(false)
98
+ expect(stampsHandbackMarker('vault_grant_approved')).toBe(false)
99
+ })
100
+
101
+ it('a normal user inbound (no meta.source) never stamps', () => {
102
+ expect(stampsHandbackMarker(null)).toBe(false)
103
+ expect(stampsHandbackMarker(undefined)).toBe(false)
104
+ })
105
+
106
+ it('FAIL-SAFE default: an UNKNOWN synthesized source STAMPS (visible dup, never ' +
107
+ 'silent edit-over)', () => {
108
+ // The unsafe old default (deny → no stamp → bypass eligible → silent loss)
109
+ // is inverted: an unclassified future decoupled source keeps the content gate.
110
+ expect(stampsHandbackMarker('some_future_decoupled_source')).toBe(true)
111
+ expect(stampsHandbackMarker('another_unclassified_source')).toBe(true)
112
+ })
113
+
114
+ // The real tripwire: scan the gateway for meta.source literals and FAIL when a
115
+ // new one is added without a registry classification. The grep-the-predicate
116
+ // structural test could not catch rot; this does.
117
+ it('exhaustiveness: every gateway-inbound meta.source literal is classified in the registry', () => {
118
+ const gatewayDir = join(dirname(fileURLToPath(import.meta.url)), '..', 'gateway')
119
+ const repoRoot = join(dirname(fileURLToPath(import.meta.url)), '..', '..')
120
+ // `source:` fields that are NOT inbound meta.source tags (config cascade, the
121
+ // model-source selector, doc comments, typeof guards). Allowlisted so a
122
+ // genuinely new INBOUND source still trips this guard.
123
+ const NON_INBOUND_SOURCE_LITERALS = new Set([
124
+ 'env', 'config', 'default', 'transcript', 'override', 'gateway', 'cli', 'string', 'github',
125
+ ])
126
+ // The scan surface = every gateway module PLUS the out-of-gateway inbound
127
+ // builders whose synthesized inbounds are delivered THROUGH the gateway
128
+ // (dup-audit pass-2 / Fable): `src/web/webhook-dispatch.ts` builds the
129
+ // `webhook` / `linear` inbounds injected via `webhookInject` → the same
130
+ // buffer chokepoint. Grep for other `meta:`+`source:` inbound builders if new
131
+ // dirs appear.
132
+ const files: string[] = [
133
+ ...readdirSync(gatewayDir)
134
+ .filter((f) => f.endsWith('.ts') && !f.endsWith('.test.ts'))
135
+ .map((f) => join(gatewayDir, f)),
136
+ join(repoRoot, 'src', 'web', 'webhook-dispatch.ts'),
137
+ ]
138
+ const found = new Set<string>()
139
+ // Catch BOTH quote styles and full source spellings (digits / underscores /
140
+ // any case) — a single-quote-only, `[a-z_]+`-only regex silently missed the
141
+ // double-quoted `mental_model_proposal_*` and the out-of-gateway webhook
142
+ // sources (pass-2 porosity). Variable-valued `source: expr` stays structurally
143
+ // invisible; the fail-safe stamp default is the safety boundary, not this scan.
144
+ const patterns = [
145
+ /source:\s*['"]([A-Za-z0-9_]+)['"]/g,
146
+ /meta\??\.source\s*===\s*['"]([A-Za-z0-9_]+)['"]/g,
147
+ ]
148
+ for (const f of files) {
149
+ const src = readFileSync(f, 'utf8')
150
+ for (const re of patterns) {
151
+ let m: RegExpExecArray | null
152
+ while ((m = re.exec(src)) != null) found.add(m[1]!)
153
+ }
154
+ }
155
+ const unclassified = [...found]
156
+ .filter((s) => !NON_INBOUND_SOURCE_LITERALS.has(s))
157
+ .filter((s) => INBOUND_SOURCE_CLASSIFICATION[s] == null)
158
+ .sort()
159
+ // If this fails: a new meta.source literal was added. Classify it in
160
+ // INBOUND_SOURCE_CLASSIFICATION (decoupledCompletion true ONLY if its reply
161
+ // can land as a late reply with no live turn of its own), or — if it is a
162
+ // non-inbound `source:` field — add it to NON_INBOUND_SOURCE_LITERALS.
163
+ expect(unclassified).toEqual([])
35
164
  })
36
165
  })