switchroom 0.19.23 → 0.19.25

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 (52) hide show
  1. package/dist/agent-scheduler/index.js +18 -7
  2. package/dist/auth-broker/index.js +117 -33
  3. package/dist/cli/autoaccept-poll.js +0 -1
  4. package/dist/cli/drive-write-pretool.mjs +5 -0
  5. package/dist/cli/ms-365-write-pretool.mjs +5 -0
  6. package/dist/cli/notion-write-pretool.mjs +18 -6
  7. package/dist/cli/switchroom.js +2916 -1481
  8. package/dist/host-control/main.js +116 -34
  9. package/dist/vault/approvals/kernel-server.js +115 -33
  10. package/dist/vault/broker/server.js +281 -76
  11. package/examples/switchroom.yaml +1 -1
  12. package/package.json +1 -1
  13. package/profiles/_base/start.sh.hbs +52 -13
  14. package/profiles/_shared/dev-protocol.md.hbs +3 -4
  15. package/skills/dev-protocol/SKILL.md +22 -15
  16. package/skills/switchroom-health/SKILL.md +19 -0
  17. package/skills/switchroom-release/SKILL.md +2 -1
  18. package/skills/switchroom-status/SKILL.md +1 -1
  19. package/telegram-plugin/auth-snapshot-format.ts +9 -2
  20. package/telegram-plugin/dist/gateway/gateway.js +6925 -6734
  21. package/telegram-plugin/gateway/gateway.ts +34 -35
  22. package/telegram-plugin/gateway/latest-turn-lookup.ts +60 -0
  23. package/telegram-plugin/gateway/outbound-send-path.ts +53 -21
  24. package/telegram-plugin/gateway/subagent-handback-marker.ts +1 -1
  25. package/telegram-plugin/gateway/turn-end.ts +1 -1
  26. package/telegram-plugin/quota-bar-format.ts +4 -1
  27. package/telegram-plugin/reply-owner-resolve.ts +110 -9
  28. package/telegram-plugin/send-gate-degraded.test.ts +45 -16
  29. package/telegram-plugin/send-gate.ts +185 -24
  30. package/telegram-plugin/tests/activity-card-send-gate.test.ts +9 -9
  31. package/telegram-plugin/tests/auth-snapshot-format.test.ts +42 -0
  32. package/telegram-plugin/tests/latest-turn-lookup.test.ts +77 -0
  33. package/telegram-plugin/tests/narrative-lane-golden.test.ts +23 -1
  34. package/telegram-plugin/tests/quota-bar-format.test.ts +50 -0
  35. package/telegram-plugin/tests/reply-owner-resolve.test.ts +531 -0
  36. package/telegram-plugin/tests/secret-detect-false-positives.test.ts +1 -1
  37. package/telegram-plugin/tests/send-reply-golden.test.ts +296 -28
  38. package/telegram-plugin/tests/stream-controller-send-gate.test.ts +134 -28
  39. package/telegram-plugin/tests/stream-render-golden.test.ts +25 -3
  40. package/vendor/hindsight-memory/scripts/lib/config.py +61 -19
  41. package/vendor/hindsight-memory/scripts/lib/content.py +376 -1
  42. package/vendor/hindsight-memory/scripts/lib/english_words.txt +10799 -0
  43. package/vendor/hindsight-memory/scripts/recall.py +503 -252
  44. package/vendor/hindsight-memory/scripts/tests/test_recall_bank_slots.py +509 -0
  45. package/vendor/hindsight-memory/scripts/tests/test_recall_envelope_strip_telemetry.py +22 -5
  46. package/vendor/hindsight-memory/scripts/tests/test_recall_error_text.py +147 -0
  47. package/vendor/hindsight-memory/scripts/tests/test_recall_hook_budget.py +266 -0
  48. package/vendor/hindsight-memory/scripts/tests/test_recall_integration.py +0 -401
  49. package/vendor/hindsight-memory/scripts/tests/test_recall_no_lexical_gate.py +261 -0
  50. package/vendor/hindsight-memory/scripts/tests/test_recall_query_shaping.py +473 -0
  51. package/vendor/hindsight-memory/scripts/tests/test_recall_transcript_fallback.py +25 -8
  52. package/vendor/hindsight-memory/tests/test_content.py +218 -0
@@ -489,7 +489,7 @@ import {
489
489
  import {
490
490
  resolveReplyOwnerTurnId,
491
491
  resolveReplyOwnerTier,
492
- type ReplyOwnerTier,
492
+ type ReplyOwnerTier, type ReplyOwnerCandidates,
493
493
  type AnswerDeliveredLatch,
494
494
  } from '../reply-owner-resolve.js'
495
495
  import { SubagentHandbackMarker } from './subagent-handback-marker.js'
@@ -717,6 +717,7 @@ import {
717
717
  } from './emission-authority.js'
718
718
  import { CurrentTurnMap } from './current-turn-map.js'
719
719
  import { resolveAnswerThreadId } from './answer-thread-resolve.js'
720
+ import { latestTurnForChat } from './latest-turn-lookup.js'
720
721
  import { decideObligationTurnEnd } from './obligation-turn-end.js'
721
722
  import { maybeRotate, resolveAgentStateDir, resolveTurnsJsonlPath } from './turns-jsonl-rotate.js'
722
723
  import {
@@ -3484,12 +3485,14 @@ export type CurrentTurn = {
3484
3485
  flushedAnswerText: string | null
3485
3486
  // 2026-07 double-reply-on-DM fix (F2 — recency bound). Wall-clock ms the turn
3486
3487
  // ENDED (stamped once by `endCurrentTurnAtomic`), or null while still live.
3487
- // The `findLatestEndedTurnForChat` supersede tier carries DESTRUCTIVE
3488
- // authority (it drives message deletion), so `resolveReplyOwnerTurn` only
3489
- // honours a latest-ended turn whose `endedAt` is within the supersede TTL —
3488
+ // The `latest-ended` supersede tier carries DESTRUCTIVE authority (it drives
3489
+ // message deletion), so `resolveReplyOwnerTurn` only honours a latest-ended
3490
+ // turn whose `endedAt` is non-null (#3725 — the registry is populated at turn
3491
+ // START, so the tail entry may still be RUNNING) AND within the supersede TTL —
3490
3492
  // otherwise a late reply belonging to an OLDER turn could resolve its owner to
3491
- // a NEWER turn sitting at the registry tail and delete that newer turn's legit
3492
- // answer. Unbounded routing use of `findLatestEndedTurnForChat` is unaffected.
3493
+ // a NEWER turn at the registry tail and delete that turn's legit answer. The
3494
+ // unbounded ROUTING use (`endedOnly: false`) is unaffected: it only picks a
3495
+ // topic to deliver into and deletes nothing.
3493
3496
  endedAt: number | null
3494
3497
  // #1675 (over-ping safety net): wall-clock ms of the first reply
3495
3498
  // this turn that landed with `disable_notification: false` (a real
@@ -3999,21 +4002,18 @@ const LATE_REPLY_TOPIC_RECOVERY_ENABLED =
3999
4002
  process.env.SWITCHROOM_LATE_REPLY_TOPIC_RECOVERY !== '0'
4000
4003
 
4001
4004
  /**
4002
- * The most-recently-started turn for a chat from the bounded recently-ended
4003
- * registry — the deterministic fallback for a LATE answer reply when the model
4004
- * echoed no `origin_turn_id` and `currentTurn` has already cleared. Iterates in
4005
- * insertion order so the last match is the most recent turn for that chat.
4006
- * Returns null when the chat has no remembered turn (so the caller keeps the
4007
- * legacy result). NB: this is the chat's own most-recent TURN, not the
4008
- * `chatThreadMap` last-seen-any-message heuristic that caused the wrong-topic
4009
- * bug — a late reply almost always belongs to the turn that just ended.
4005
+ * The most recent turn for a chat from the bounded recent-turn registry — the
4006
+ * deterministic fallback for a LATE answer reply when the model echoed no
4007
+ * `origin_turn_id` and `currentTurn` has already cleared. Returns null when the
4008
+ * chat has no matching turn (so the caller keeps the legacy result). NB: this is
4009
+ * the chat's own most-recent TURN, not the `chatThreadMap` last-seen-any-message
4010
+ * heuristic that caused the wrong-topic bug. `endedOnly` selects the consumer's
4011
+ * semantics — routing takes the tail entry even when that turn is still RUNNING;
4012
+ * the destructive `latest-ended` owner tier takes only a genuinely ENDED turn,
4013
+ * because the registry is populated at turn START (#3725; see the module).
4010
4014
  */
4011
- function findLatestEndedTurnForChat(chatId: string): CurrentTurn | null {
4012
- let latest: CurrentTurn | null = null
4013
- for (const t of recentTurnsById.values()) {
4014
- if (t.sessionChatId === chatId) latest = t
4015
- }
4016
- return latest
4015
+ function findLatestTurnForChat(chatId: string, opts: { endedOnly: boolean }): CurrentTurn | null {
4016
+ return latestTurnForChat(recentTurnsById.values(), chatId, opts)
4017
4017
  }
4018
4018
 
4019
4019
  /**
@@ -4029,7 +4029,7 @@ function findLatestEndedTurnForChat(chatId: string): CurrentTurn | null {
4029
4029
  * 1. the live `currentTurn` passed in (null once the flush nulled the atom);
4030
4030
  * 2. `findTurnByOriginId(origin_turn_id)` — the model echo;
4031
4031
  * 3. `findTurnByQuotedMessageId(chat_id, reply_to)` — framework-owned quote;
4032
- * 4. `findLatestEndedTurnForChat(chat_id)` — the chat's last-ended turn.
4032
+ * 4. `findLatestTurnForChat(chat_id, {endedOnly:true})` — last ENDED turn.
4033
4033
  * Returns the CurrentTurn for the winning id (so callers can read its
4034
4034
  * `answerDelivered` latch), or null when every lookup missed.
4035
4035
  */
@@ -4037,10 +4037,10 @@ function resolveReplyOwnerTurn(
4037
4037
  liveTurn: CurrentTurn | null,
4038
4038
  chatId: string,
4039
4039
  args: Record<string, unknown>,
4040
- ): { turn: CurrentTurn | null; tier: ReplyOwnerTier } {
4040
+ ): { turn: CurrentTurn | null; tier: ReplyOwnerTier; candidates: ReplyOwnerCandidates } {
4041
4041
  const origin = findTurnByOriginId(args.origin_turn_id as string | undefined)
4042
4042
  const quoted = findTurnByQuotedMessageId(chatId, args.reply_to)
4043
- const latestEnded = findLatestEndedTurnForChat(chatId)
4043
+ const latestEnded = findLatestTurnForChat(chatId, { endedOnly: true })
4044
4044
  const byId = new Map<string, CurrentTurn>()
4045
4045
  // Populate lowest-precedence first so a higher tier's turn wins the id slot
4046
4046
  // when two lookups resolve the same turn (they carry the same turnId anyway).
@@ -4049,12 +4049,12 @@ function resolveReplyOwnerTurn(
4049
4049
  }
4050
4050
  // F2 — bound the DESTRUCTIVE latest-ended tier to the supersede TTL so a stale
4051
4051
  // latest-ended turn can't inherit deletion authority over a newer turn's flush
4052
- // record. `endedAt` is null only for a turn still resolvable but not yet ended
4053
- // (not a supersede risk); leave the age unset then (unbounded) rather than
4054
- // fabricate one.
4052
+ // record. #3725: the lookup above is `endedOnly`, so `endedAt` is non-null here
4053
+ // and the age is ALWAYS a real number — a not-yet-ended turn is no longer a
4054
+ // candidate at all, and an explicit null age now fails CLOSED downstream.
4055
4055
  const latestEndedAgeMs =
4056
4056
  latestEnded?.endedAt != null ? Date.now() - latestEnded.endedAt : null
4057
- const candidates = {
4057
+ const candidates: ReplyOwnerCandidates = {
4058
4058
  liveTurnId: liveTurn?.turnId ?? null,
4059
4059
  originTurnId: origin?.turnId ?? null,
4060
4060
  quotedTurnId: quoted?.turnId ?? null,
@@ -4062,15 +4062,14 @@ function resolveReplyOwnerTurn(
4062
4062
  latestEndedAgeMs,
4063
4063
  latestEndedTtlMs: DEFAULT_SUPERSEDE_TTL_MS,
4064
4064
  }
4065
- // #3429 — the WINNING tier travels with the turn. A positive tier
4066
- // (live/origin/quoted) means the reply is this turn's own answer and the
4067
- // supersede fires regardless of text; the ambiguous `latest-ended` fallback
4068
- // keeps the content gate (it cannot tell a late own-reply from an async
4069
- // sub-agent handback). Both derive from the SAME candidates, so the id and the
4070
- // tier can never disagree.
4065
+ // #3429 — the winning tier AND the candidate set it came from travel with the
4066
+ // turn. Tier alone no longer decides the content-gate bypass: the
4067
+ // model-steerable `origin`/`quoted` tiers must be CORROBORATED against the
4068
+ // framework-derived `latestEndedTurnId` (`decideContentGateBypass`). All three
4069
+ // derive from these SAME candidates, so they can never disagree.
4071
4070
  const tier = resolveReplyOwnerTier(candidates)
4072
4071
  const winnerId = resolveReplyOwnerTurnId(candidates)
4073
- return { turn: winnerId != null ? (byId.get(winnerId) ?? null) : null, tier }
4072
+ return { turn: winnerId != null ? (byId.get(winnerId) ?? null) : null, tier, candidates }
4074
4073
  }
4075
4074
 
4076
4075
  /**
@@ -4115,7 +4114,7 @@ function resolveAnswerThreadWithLog(
4115
4114
  explicitThreadId == null &&
4116
4115
  originTurn == null &&
4117
4116
  liveTurn == null
4118
- ? findLatestEndedTurnForChat(chatId)
4117
+ ? findLatestTurnForChat(chatId, { endedOnly: false })
4119
4118
  : null
4120
4119
  const threadId = resolveAnswerThreadId({
4121
4120
  explicitThreadId,
@@ -0,0 +1,60 @@
1
+ /**
2
+ * The recently-seen-turn registry scan, extracted from `gateway.ts` as a pure
3
+ * function so its ENDED-ness contract is unit-testable (#3725; gateway.ts is not
4
+ * importable in tests — the repo's `decideTurnFlush` / `resolveReplyOwnerTurnId`
5
+ * pattern).
6
+ *
7
+ * ## Why `endedOnly` exists (#3725)
8
+ *
9
+ * `recentTurnsById` is populated at turn **start** (`rememberRecentTurn` fires
10
+ * from the `enqueue` lifecycle event in `stream-render.ts`), and the atom is
11
+ * built with `endedAt: null`; `endedAt` is stamped later, in `turn-end.ts`. So
12
+ * the registry's tail entry for a chat is the most-recently-STARTED turn, which
13
+ * may still be RUNNING. The registry is also chat-wide and thread-agnostic, so
14
+ * on a forum a turn running in ANOTHER topic sits at the tail.
15
+ *
16
+ * That distinction is load-bearing because the two consumers want different
17
+ * things:
18
+ *
19
+ * - **Routing** (`resolveAnswerThreadWithLog`) wants the chat's most recent
20
+ * turn whether or not it has ended — it only picks a topic to deliver into,
21
+ * and a still-running turn's topic is a perfectly good (indeed better)
22
+ * answer than falling back to General. `endedOnly: false`.
23
+ * - **Owner resolution** (`resolveReplyOwnerTurn` → the `latest-ended`
24
+ * supersede tier) wants a genuinely ENDED turn: that tier carries
25
+ * DESTRUCTIVE authority (it drives message deletion) and is bounded by the
26
+ * supersede TTL measured from `endedAt`. A turn with `endedAt == null` has
27
+ * no age, so it could not be TTL-bounded at all — it was an unbounded
28
+ * anchor for the corroborated content-gate bypass (#3725). A still-running
29
+ * turn must be resolved by the `live` tier, never by this fallback.
30
+ * `endedOnly: true`.
31
+ */
32
+
33
+ /** The registry-atom shape this scan needs (structural — `CurrentTurn` in the
34
+ * gateway satisfies it without importing the gateway's type). */
35
+ export interface LatestTurnLookupAtom {
36
+ /** The chat the turn belongs to. */
37
+ sessionChatId: string
38
+ /** Wall-clock ms the turn ENDED, or null while it is still running. */
39
+ endedAt: number | null
40
+ }
41
+
42
+ /**
43
+ * The last turn for `chatId` in registry insertion order — i.e. the most recent
44
+ * one. With `endedOnly: true` the scan skips turns that have not ended yet, so
45
+ * the result is the most-recently-ended turn (which may NOT be the tail entry).
46
+ * Returns null when the chat has no matching turn.
47
+ */
48
+ export function latestTurnForChat<T extends LatestTurnLookupAtom>(
49
+ turns: Iterable<T>,
50
+ chatId: string,
51
+ opts: { endedOnly: boolean },
52
+ ): T | null {
53
+ let latest: T | null = null
54
+ for (const t of turns) {
55
+ if (t.sessionChatId !== chatId) continue
56
+ if (opts.endedOnly && t.endedAt == null) continue
57
+ latest = t
58
+ }
59
+ return latest
60
+ }
@@ -62,7 +62,12 @@ import {
62
62
  DEFAULT_SUPERSEDE_TTL_MS,
63
63
  type FlushedTurnSupersedeRegistry,
64
64
  } from '../flushed-turn-supersede.js'
65
- import { decideAnswerLatchSuppression, type ReplyOwnerTier } from '../reply-owner-resolve.js'
65
+ import {
66
+ decideAnswerLatchSuppression,
67
+ decideContentGateBypass,
68
+ type ReplyOwnerTier,
69
+ type ReplyOwnerCandidates,
70
+ } from '../reply-owner-resolve.js'
66
71
  import { deriveTelegraphTitle } from '../telegraph.js'
67
72
  import {
68
73
  mayInjectListenButton,
@@ -726,7 +731,13 @@ export interface SendReplyGatewayDeps {
726
731
  assertSendable(f: string): void
727
732
  statusKey(chatId: string, threadId?: number | null): string
728
733
  streamKey(chatId: string, threadId?: number | null): string
729
- resolveReplyOwnerTurn(liveTurn: CurrentTurn | null, chatId: string, args: Record<string, unknown>): { turn: CurrentTurn | null; tier: ReplyOwnerTier }
734
+ /** Resolves the owner turn AND returns the CANDIDATE SET it was derived from.
735
+ * The candidates are load-bearing, not diagnostics: `decideContentGateBypass`
736
+ * corroborates a model-steerable `origin`/`quoted` attribution against the
737
+ * framework-derived `latestEndedTurnId` inside them before allowing a
738
+ * content-gate bypass — an anchor that is an ENDED turn within the supersede
739
+ * TTL, never one still running (#3725). */
740
+ resolveReplyOwnerTurn(liveTurn: CurrentTurn | null, chatId: string, args: Record<string, unknown>): { turn: CurrentTurn | null; tier: ReplyOwnerTier; candidates: ReplyOwnerCandidates }
730
741
  findTurnByOriginId(originTurnId: string | null | undefined): CurrentTurn | null
731
742
  findTurnByQuotedMessageId(chatId: string, replyTo: unknown): CurrentTurn | null
732
743
  resolveAnswerThreadWithLog(
@@ -946,7 +957,11 @@ export async function sendReply(
946
957
  // double-send). The quoted / latest-ended recoveries are precisely what the
947
958
  // router already did for the same reply, so unifying here makes the two
948
959
  // resolvers agree and the late-reply supersede fires by identity.
949
- const { turn: ownerTurn, tier: ownerTier } = resolveReplyOwnerTurn(turn, chat_id, args)
960
+ const {
961
+ turn: ownerTurn,
962
+ tier: ownerTier,
963
+ candidates: ownerCandidates,
964
+ } = resolveReplyOwnerTurn(turn, chat_id, args)
950
965
  const resolvedTurnId = ownerTurn?.turnId ?? null
951
966
  // #3429 — pass the (normalized) reply text so the registry CAN apply the
952
967
  // new-content gate: identity match + TTL alone also fits a background
@@ -998,7 +1013,7 @@ export async function sendReply(
998
1013
  // owner turn resolved (no record to clobber on the collapse path).
999
1014
  const gateThreadId = ownerTurn?.sessionThreadId ?? replyThreadId
1000
1015
  // MUST-FIX 2 (dup-audit / Fable) — the content-gate READ is CHAT-WIDE, not
1001
- // lane-specific: `findLatestEndedTurnForChat` resolves owners chat-wide, so a
1016
+ // lane-specific: `findLatestTurnForChat` resolves owners chat-wide, so a
1002
1017
  // handback in topic A can supersede topic B's ended turn; a thread-keyed gate
1003
1018
  // read (the F2 regression) let a reply dodge that handback by carrying a
1004
1019
  // different `message_thread_id`. Chat-wide makes the gate un-steerable — any
@@ -1014,23 +1029,35 @@ export async function sendReply(
1014
1029
  ownerEndedAt != null &&
1015
1030
  handbackAt > ownerEndedAt &&
1016
1031
  now - handbackAt <= DEFAULT_SUPERSEDE_TTL_MS
1017
- // MUST-FIX 1 (silent-data-loss, PROVEN by Fable 2026-07-21) — restrict the
1018
- // content-gate BYPASS to the tiers whose attribution is NOT model-steerable:
1019
- // - `live` — the framework-owned live `currentTurn` (not model-derived,
1020
- // and `decideSupersede`'s same-turnId check bars it from an
1021
- // ended turn's record). Bypasses even a handback window.
1032
+ // MUST-FIX 1 (silent-data-loss, PROVEN by Fable 2026-07-21) + the 2026-07-27
1033
+ // corroboration widening — decided by the pure `decideContentGateBypass` so
1034
+ // the gateway runs the exact code the unit tests exercise. The rule, in
1035
+ // brief (full rationale on that function):
1036
+ // - `live` — framework-owned live `currentTurn`; bypasses unconditionally
1037
+ // (`decideSupersede`'s same-turnId check already bars it from
1038
+ // a DIFFERENT ended turn's record).
1022
1039
  // - `latest-ended` — the ambiguous DM/late-reply fallback the marko fix
1023
- // actually needs; bypass ONLY when no decoupled completion is
1024
- // in the window (marker-absence ⇒ own answer).
1025
- // The `quoted` / `origin` tiers resolve from MODEL-SUPPLIED args
1026
- // (`args.reply_to` / `args.origin_turn_id`), so a reply can steer ITSELF onto
1027
- // a DIFFERENT ended turn's record — with marker-absence they used to bypass
1028
- // the content gate and silently edit-over that turn's delivered answer (the
1029
- // #3429 double-loss, executed by Fable). Those tiers therefore NEVER bypass:
1030
- // they always go through the content gate, so foreign content sends fresh and
1031
- // only a genuine same-answer reply collapses.
1032
- const replyIsOwnAnswer =
1033
- ownerTier === 'live' || (ownerTier === 'latest-ended' && !handbackCouldOwnReply)
1040
+ // needs; bypass ONLY when no decoupled completion is in the
1041
+ // window (marker-absence ⇒ own answer).
1042
+ // - `origin` / `quoted` — MODEL-SUPPLIED attributions, so they bypass ONLY
1043
+ // when CORROBORATED: the turn they resolve must be the same
1044
+ // turn the framework-derived, TTL-bounded `latestEndedTurnId`
1045
+ // resolves (#3725 — that anchor is a genuinely ENDED turn
1046
+ // within the TTL; a turn still RUNNING in this chat is not a
1047
+ // candidate and corroborates nothing), and no handback may be
1048
+ // in the window. A reply that
1049
+ // steers itself onto a DIFFERENT ended turn fails
1050
+ // corroboration and keeps the content gate, so the Fable
1051
+ // silent-edit-over stays closed; a reply that merely echoes
1052
+ // its OWN turn no longer loses the collapse it would have got
1053
+ // by omitting the echo entirely (the observed 2026-07-27
1054
+ // `via=origin` duplicate).
1055
+ const replyIsOwnAnswer = decideContentGateBypass({
1056
+ tier: ownerTier,
1057
+ resolvedTurnId,
1058
+ candidates: ownerCandidates,
1059
+ handbackCouldOwnReply,
1060
+ })
1034
1061
  const decision = flushedTurnSupersede.take(
1035
1062
  chat_id,
1036
1063
  gateThreadId,
@@ -1114,7 +1141,12 @@ export async function sendReply(
1114
1141
  if (decision.reason === 'new-content') {
1115
1142
  process.stderr.write(
1116
1143
  `telegram gateway: reply: flush supersede declined — new content (#3429) ` +
1117
- `chatId=${chat_id} ownerTurnId=${JSON.stringify(resolvedTurnId)}; sending fresh\n`,
1144
+ `chatId=${chat_id} ownerTurnId=${JSON.stringify(resolvedTurnId)} ` +
1145
+ // WHY the bypass didn't apply — without these two fields the
1146
+ // 2026-07-27 duplicate looked like a pure text-matcher failure and
1147
+ // took a log-archive dig to attribute to the tier restriction.
1148
+ `tier=${ownerTier} latestEnded=${JSON.stringify(ownerCandidates.latestEndedTurnId)} ` +
1149
+ `handbackInWindow=${handbackCouldOwnReply}; sending fresh\n`,
1118
1150
  )
1119
1151
  }
1120
1152
  if (suppressByLatch) {
@@ -201,7 +201,7 @@ export class SubagentHandbackMarker {
201
201
  * Wall-clock ms of the most recent handback enqueue ANYWHERE in `chatId`
202
202
  * (across every topic lane), or null. This is what the content-gate read uses
203
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
204
+ * tier is CHAT-WIDE (`findLatestTurnForChat` ignores thread), so a
205
205
  * background handback in topic A can resolve — and supersede — topic B's
206
206
  * ended turn. A thread-SPECIFIC gate read (the F2 regression) let a reply
207
207
  * dodge that handback by carrying a different `message_thread_id`, silently
@@ -436,7 +436,7 @@ function endCurrentTurnAtomic(
436
436
  // live feed never opened because its sends failed (the resume-400 signature).
437
437
  const turnEndedAt = Date.now()
438
438
  // 2026-07 double-reply-on-DM fix (F2) — stamp the turn's end time so the
439
- // `findLatestEndedTurnForChat` supersede tier can be recency-bounded to the
439
+ // `latest-ended` supersede tier can be recency-bounded to the
440
440
  // supersede TTL (a stale latest-ended turn must not inherit deletion
441
441
  // authority over a newer turn's flush record). Set once; idempotent on the
442
442
  // deferRecord flush path (which calls this synchronously before its send).
@@ -39,6 +39,7 @@
39
39
  import type { QuotaUtilization } from './quota-check.js';
40
40
  import { refillNormalizedUtils, isProbeThin } from '../src/auth/quota.js';
41
41
  import type { AccountState, ListStateData } from '../src/auth/broker/client.js';
42
+ import { effectiveServingLabel } from '../src/auth/broker/client.js';
42
43
  import { reviveLastQuota, recommendation, type AccountSnapshot } from './auth-snapshot-format.js';
43
44
  import { escapeMarkdown } from './card-format.js';
44
45
  import { maskEmail } from './demo-mask.js';
@@ -358,9 +359,11 @@ export function renderQuotaBarBlockFromListState(
358
359
  const exhaustedByLabel = new Map<string, boolean>(
359
360
  state.accounts.map((a: AccountState) => [a.label, a.exhausted]),
360
361
  );
362
+ // SERVING, not pinned — see effectiveServingLabel.
363
+ const serving = effectiveServingLabel(state);
361
364
  const snapshots: AccountSnapshot[] = state.accounts.map((acc: AccountState) => ({
362
365
  label: acc.label,
363
- isActive: acc.label === state.active,
366
+ isActive: acc.label === serving,
364
367
  quota: reviveLastQuota(acc.last_quota ?? null),
365
368
  quotaError: acc.last_quota ? undefined : 'no cached quota (no probe since broker start)',
366
369
  expiresAtMs: acc.expiresAt,
@@ -25,8 +25,8 @@
25
25
  *
26
26
  * The DECISIVE divergence: the gateway's *thread-routing* path DID recover the
27
27
  * owner turn for the same late reply — via `findTurnByQuotedMessageId` (the
28
- * framework-owned default quote target) and `findLatestEndedTurnForChat` (the
29
- * chat's most-recently-ended turn). The supersede resolver chain omitted BOTH
28
+ * framework-owned default quote target) and `findLatestTurnForChat` (the
29
+ * chat's most recent turn). The supersede resolver chain omitted BOTH
30
30
  * recoveries, so the two resolvers disagreed on who owned the reply. This module
31
31
  * unifies them onto ONE precedence so they can never diverge again.
32
32
  *
@@ -37,7 +37,7 @@
37
37
  * `decideCapturedProseDelivery` — is to extract the decision core into a pure,
38
38
  * unit-testable function and have the gateway run the EXACT code the regression
39
39
  * tests exercise. The gateway performs the four turn lookups (currentTurn,
40
- * findTurnByOriginId, findTurnByQuotedMessageId, findLatestEndedTurnForChat) and
40
+ * findTurnByOriginId, findTurnByQuotedMessageId, findLatestTurnForChat) and
41
41
  * feeds their resolved turnIds here; the precedence lives in one place.
42
42
  */
43
43
 
@@ -56,16 +56,25 @@ export interface ReplyOwnerCandidates {
56
56
  /** `findTurnByQuotedMessageId(chat_id, reply_to)` — the framework-owned
57
57
  * quoted message id, resolved with NO model thread assertion. */
58
58
  quotedTurnId: string | null
59
- /** `findLatestEndedTurnForChat(chat_id)` — the chat's most-recently-ended
60
- * turn. The deterministic late-reply fallback (the DM path's recovery). */
59
+ /** `findLatestTurnForChat(chat_id, {endedOnly:true})` — the chat's
60
+ * most-recently-ENDED turn. The deterministic late-reply fallback (the DM
61
+ * path's recovery). #3725: the gateway lookup skips turns that have not
62
+ * ended, so this is never a still-running turn. */
61
63
  latestEndedTurnId: string | null
62
64
  /** Age (ms) of the latest-ended turn — `now - turn.endedAt`. The latest-ended
63
65
  * tier carries DESTRUCTIVE authority (it drives supersede deletion), so it is
64
66
  * honoured ONLY when the turn ended within `latestEndedTtlMs` (the supersede
65
67
  * TTL). Without the bound, a late reply belonging to an OLDER turn could
66
68
  * resolve its owner to a NEWER turn now sitting at the registry tail and
67
- * delete THAT turn's legit answer. Undefined/null ⇒ unbounded (back-compat:
68
- * callers that don't supply an age keep the pre-F2 behaviour). */
69
+ * delete THAT turn's legit answer.
70
+ *
71
+ * Two distinct absences (#3725):
72
+ * - `undefined` (property omitted) ⇒ unbounded, the pre-F2 back-compat
73
+ * escape for callers that don't supply an age at all;
74
+ * - explicit `null` ⇒ the caller COMPUTED no age, i.e. its candidate turn
75
+ * has no `endedAt` and has NOT ended. That cannot be TTL-bounded, so it
76
+ * fails CLOSED (not accepted) rather than granting unbounded authority
77
+ * to a turn that is still running. */
69
78
  latestEndedAgeMs?: number | null
70
79
  /** The supersede TTL bound applied to `latestEndedAgeMs`. Undefined ⇒
71
80
  * unbounded. */
@@ -74,13 +83,17 @@ export interface ReplyOwnerCandidates {
74
83
 
75
84
  /**
76
85
  * Whether the latest-ended candidate is fresh enough to carry supersede
77
- * (deletion) authority. A missing age or TTL means unbounded (back-compat).
86
+ * (deletion) authority. An OMITTED age or TTL means unbounded (the pre-F2
87
+ * back-compat escape); an EXPLICIT null age fails closed (#3725 — the caller
88
+ * computed no age because its candidate turn has not ended, and an un-ended turn
89
+ * must be resolved by the `live` tier, never by this destructive fallback).
78
90
  */
79
91
  function latestEndedAccepted(candidates: ReplyOwnerCandidates): boolean {
80
92
  if (candidates.latestEndedTurnId == null) return false
81
93
  const age = candidates.latestEndedAgeMs
82
94
  const ttl = candidates.latestEndedTtlMs
83
- if (age == null || ttl == null) return true
95
+ if (age === null) return false
96
+ if (age === undefined || ttl == null) return true
84
97
  return age <= ttl
85
98
  }
86
99
 
@@ -143,6 +156,94 @@ export function resolveReplyOwnerTurnId(candidates: ReplyOwnerCandidates): strin
143
156
  }
144
157
  }
145
158
 
159
+ /**
160
+ * Whether the supersede path may BYPASS the #3429 content gate — i.e. treat the
161
+ * landing reply as this flushed turn's OWN answer and collapse the provisional
162
+ * flush REGARDLESS of the model having reworded it.
163
+ *
164
+ * ## Why this is not simply "the tier is positive"
165
+ *
166
+ * The pre-existing rule was `tier === 'live' || (tier === 'latest-ended' &&
167
+ * !handbackCouldOwnReply)`. `origin` and `quoted` were excluded WHOLESALE
168
+ * because both derive from MODEL-SUPPLIED args (`args.origin_turn_id` /
169
+ * `args.reply_to`): a reply can STEER its own attribution onto a DIFFERENT
170
+ * ended turn and, with the gate bypassed, silently edit over that turn's
171
+ * delivered answer (the #3429 double-loss, executed by Fable 2026-07-21).
172
+ *
173
+ * That wholesale exclusion over-fires. Observed 2026-07-27 on a DM agent (two
174
+ * answers delivered twice): the answer-ready quiescence flush posted the turn's
175
+ * composed prose as message A, the model then fired `reply` with a REWORDED
176
+ * version of the SAME answer, and — because it had echoed `origin_turn_id` back
177
+ * pointing at its OWN turn — the tier resolved `origin` rather than
178
+ * `latest-ended`, so no bypass applied and the content gate declined on the
179
+ * rewording (`reply: flush supersede declined — new content (#3429)`). Message B
180
+ * shipped as a visible duplicate. Had the model simply OMITTED the echo, the
181
+ * identical reply would have resolved `latest-ended` and collapsed to ONE
182
+ * message. The exclusion punished the model for supplying MORE information.
183
+ *
184
+ * ## The rule: corroborate the steerable tier, don't blanket-ban it
185
+ *
186
+ * A model-supplied attribution is dangerous only when it points somewhere the
187
+ * FRAMEWORK would not have gone on its own. So `origin`/`quoted` bypass the
188
+ * content gate IFF the turn they resolve is the SAME turn the framework-derived,
189
+ * TTL-bounded `latest-ended` candidate resolves — a candidate computed from
190
+ * `findLatestTurnForChat(chat_id, {endedOnly:true})` with no model input at all.
191
+ * "TTL-bounded" is enforced, not assumed (#3725): `latestEndedAccepted` demands
192
+ * an age within `latestEndedTtlMs`, and an anchor turn that has NOT ended (age
193
+ * explicitly null) is rejected outright rather than treated as unbounded — so a
194
+ * turn still running in another topic of the same chat can corroborate nothing.
195
+ *
196
+ * This grants ZERO new capability, which is the safety argument: any reply that
197
+ * reaches the bypass via a corroborated `origin`/`quoted` attribution could
198
+ * already have reached it by omitting `origin_turn_id`/`reply_to` entirely and
199
+ * landing on `latest-ended` with the same turn and the same outcome. Steering to
200
+ * a DIFFERENT ended turn breaks corroboration (`origin` id ≠ latest-ended id),
201
+ * so the gate holds and the #3429/Fable silent-edit-over defence is untouched.
202
+ *
203
+ * `latest-ended` corroborates itself trivially (its resolved id IS the
204
+ * latest-ended candidate), so the rule below SUBSUMES the previous behaviour on
205
+ * that tier rather than changing it.
206
+ *
207
+ * `live` keeps its unconditional bypass: `currentTurn` is framework-owned, and
208
+ * `decideSupersede`'s same-turnId requirement already bars it from reaching a
209
+ * DIFFERENT ended turn's record. `none` never bypasses (nothing to attribute).
210
+ */
211
+ export function decideContentGateBypass(input: {
212
+ /** The winning owner tier (`resolveReplyOwnerTier`). */
213
+ tier: ReplyOwnerTier
214
+ /** The owner turnId the supersede will act on (`resolveReplyOwnerTurnId`). */
215
+ resolvedTurnId: string | null
216
+ /** The SAME candidate set both of the above were derived from — supplies the
217
+ * framework-derived `latestEndedTurnId` plus its freshness bound, so the
218
+ * corroboration reuses the EXACT rule `resolveReplyOwnerTier` applies
219
+ * (`latestEndedAccepted`) instead of duplicating it: within the TTL, and —
220
+ * since #3725 — not a turn that is still running. */
221
+ candidates: ReplyOwnerCandidates
222
+ /** True when a decoupled-completion inbound (`subagent_handback`) was enqueued
223
+ * in this chat AFTER the owner turn ended and within the supersede TTL — the
224
+ * ambiguous window where the late reply might BE that handback rather than
225
+ * the turn's own answer. Keeps the content gate on every non-`live` tier. */
226
+ handbackCouldOwnReply: boolean
227
+ }): boolean {
228
+ if (input.tier === 'live') return true
229
+ if (input.tier === 'none') return false
230
+ if (input.handbackCouldOwnReply) return false
231
+ // Total over degenerate input (#3726). TypeScript makes `candidates` mandatory
232
+ // and the one production caller (`resolveReplyOwnerTurn`) always builds it, so
233
+ // this cannot fire today — but this module is exported precisely so the
234
+ // decision can be exercised OUTSIDE the gateway's construction discipline.
235
+ // Every other defensive branch here fails CLOSED (keep the #3429 content
236
+ // gate); without this guard the missing-input case instead fails by THROWING
237
+ // out of the supersede path — a fail-open-by-crash. Matches the module's own
238
+ // precedent: `latestEndedAccepted` is total over null age/ttl/turnId.
239
+ if (input.candidates == null) return false
240
+ if (!latestEndedAccepted(input.candidates)) return false
241
+ return (
242
+ input.resolvedTurnId != null &&
243
+ input.resolvedTurnId === input.candidates.latestEndedTurnId
244
+ )
245
+ }
246
+
146
247
  /**
147
248
  * The answer-delivered latch value — SOURCE-TAGGED (#3426).
148
249
  *
@@ -113,7 +113,7 @@ describe('send-gate PR2: cosmetic shedding', () => {
113
113
  expect(gate.stats().global.sent).toBe(1)
114
114
  })
115
115
 
116
- it('sheds a cosmetic EDIT while the message-edit window is open', async () => {
116
+ it('COALESCES a cosmetic EDIT through an open message-edit window instead of shedding it (#3716)', async () => {
117
117
  const clock = new FakeClock()
118
118
  const { calls, fn } = recorder(clock)
119
119
  const gate = createSendGate({
@@ -122,17 +122,35 @@ describe('send-gate PR2: cosmetic shedding', () => {
122
122
  // H1: msg-edit scope is keyed `${chat_id}:${messageId}`.
123
123
  initialWindows: [{ scopeKey: 'msg-edit:5:42', untilTs: HOUR }],
124
124
  })
125
+ const opts = { chat_id: '5', messageId: 42, priorityClass: 'cosmetic' as const }
125
126
 
126
- const res = await gate.gate(fn('edit'), {
127
- chat_id: '5',
128
- messageId: 42,
129
- editPayload: 'v1',
130
- priorityClass: 'cosmetic',
131
- })
127
+ // A burst of cosmetic edits arrives while the window is wide open. The old
128
+ // behaviour dropped every one of them (SEND_GATE_SHED), which stranded the
129
+ // card on whatever body happened to be on screen when the window opened.
130
+ const p1 = gate.gate(fn('v1'), { ...opts, editPayload: 'v1' })
131
+ await flush()
132
+ const p2 = gate.gate(fn('v2'), { ...opts, editPayload: 'v2' })
133
+ await flush()
134
+ const p3 = gate.gate(fn('v3'), { ...opts, editPayload: 'v3' })
135
+ await flush()
132
136
 
133
- expect(res).toBe(SEND_GATE_SHED)
137
+ // Nothing has hit the API yet — the window still suppresses the send, which
138
+ // is the flood protection doing its job.
134
139
  expect(calls).toHaveLength(0)
135
- expect(gate.stats().global.shed).toBe(1)
140
+ // ...but nothing was DISCARDED either.
141
+ expect(gate.stats().global.shed).toBe(0)
142
+
143
+ // Walk past the window. Exactly ONE send lands, carrying the NEWEST payload:
144
+ // the burst was aggregated, not dropped, and not replayed edit-by-edit.
145
+ await clock.advance(HOUR + 1000)
146
+ await flush()
147
+ await Promise.allSettled([p1, p2, p3])
148
+
149
+ expect(calls.map((c) => c.label)).toEqual(['v3'])
150
+ expect(gate.stats().global.sent).toBe(1)
151
+ expect(gate.stats().global.shed).toBe(0)
152
+ // v1 and v2 were superseded in the pending slot (last-write-wins).
153
+ expect(gate.stats().global.coalesced).toBeGreaterThan(0)
136
154
  })
137
155
  })
138
156
 
@@ -343,7 +361,7 @@ describe('send-gate PR2: H1 cross-chat message_id isolation', () => {
343
361
  expect(calls.every((c) => c.at === 0)).toBe(true)
344
362
  })
345
363
 
346
- it('a 429 on chat A message 100 does NOT shed chat B message 100 cosmetic edits', async () => {
364
+ it('a 429 on chat A message 100 does NOT delay chat B message 100 cosmetic edits', async () => {
347
365
  const clock = new FakeClock()
348
366
  const { calls, fn } = recorder(clock)
349
367
  const gate = createSendGate({
@@ -353,14 +371,17 @@ describe('send-gate PR2: H1 cross-chat message_id isolation', () => {
353
371
  initialWindows: [{ scopeKey: 'msg-edit:A:100', untilTs: HOUR }],
354
372
  })
355
373
 
356
- // Chat A cosmetic edit → shed (its scope window is open).
357
- const rA = await gate.gate(fn('A-edit'), {
374
+ // Chat A cosmetic edit → deferred, its scope window is open (#3716: deferred,
375
+ // NOT shed — the edit is held and lands with its state once the window ends).
376
+ const pA = gate.gate(fn('A-edit'), {
358
377
  chat_id: 'A',
359
378
  messageId: 100,
360
379
  editPayload: 'A-edit',
361
380
  priorityClass: 'cosmetic',
362
381
  })
363
- // Chat B cosmetic edit to the SAME message_id → must NOT shed (different scope).
382
+ await flush()
383
+ // Chat B cosmetic edit to the SAME message_id → must be unaffected (different
384
+ // scope). This is the H1 isolation the test exists to guard.
364
385
  const rB = await gate.gate(fn('B-edit'), {
365
386
  chat_id: 'B',
366
387
  messageId: 100,
@@ -368,10 +389,18 @@ describe('send-gate PR2: H1 cross-chat message_id isolation', () => {
368
389
  priorityClass: 'cosmetic',
369
390
  })
370
391
 
371
- expect(rA).toBe(SEND_GATE_SHED) // A shed
372
- expect(rB).toBe('B-edit') // B sent
392
+ expect(rB).toBe('B-edit') // B sent immediately, unblocked by A's window
373
393
  expect(calls.map((c) => c.label)).toEqual(['B-edit'])
374
- expect(gate.stats().global.shed).toBe(1)
394
+ // A is still pending, not discarded.
395
+ expect(gate.stats().global.shed).toBe(0)
396
+
397
+ // Once A's window closes, A's edit lands too — nothing was lost.
398
+ await clock.advance(HOUR + 1000)
399
+ await flush()
400
+ await Promise.allSettled([pA])
401
+
402
+ expect(calls.map((c) => c.label).sort()).toEqual(['A-edit', 'B-edit'])
403
+ expect(gate.stats().global.shed).toBe(0)
375
404
  })
376
405
  })
377
406