switchroom 0.19.5 → 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.
@@ -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. */
@@ -79,6 +80,20 @@ export interface PendingInboundBufferOptions {
79
80
  * never breaks the push hot path.
80
81
  */
81
82
  onEvict?: (agent: string, evicted: InboundMessage) => void
83
+ /**
84
+ * fix/backstop-duplicate-reply MUST-FIX 2 — called on every push of a
85
+ * `subagent_handback` envelope (live synthesis AND boot-replay re-push),
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.
95
+ */
96
+ onHandbackEnqueue?: (chatId: string, threadId: number | undefined, ts: number) => void
82
97
  }
83
98
 
84
99
  /**
@@ -342,6 +357,31 @@ export function createPendingInboundBuffer(
342
357
  }
343
358
  }
344
359
  q.push(msg)
360
+ // fix/backstop-duplicate-reply MUST-FIX 2 — stamp the subagent-handback
361
+ // marker at THIS chokepoint, not at the live onFinish enqueue site alone.
362
+ // Every handback enqueue funnels through here — the live synthesis push
363
+ // AND the boot-replay re-push of un-acked spooled inbounds — so stamping
364
+ // here (rather than only at the live site) means a handback replayed after
365
+ // a restart still populates the marker. Otherwise the Map is empty
366
+ // post-boot and a replayed handback's late reply bypasses the #3429
367
+ // content gate → silent edit-over-answer. Uses the envelope's own `ts`
368
+ // (ms, `Date.now()`-derived at synthesis) so the marker reflects when the
369
+ // handback actually happened, not the replay moment. Best-effort.
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) {
377
+ try {
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)
381
+ } catch {
382
+ /* marker stamp is best-effort; never break the push hot path */
383
+ }
384
+ }
345
385
  // Durable record FIRST-class to the in-memory queue: spool BEFORE
346
386
  // returning, regardless of the cap eviction above — an entry the
347
387
  // in-memory cap drops still survives in the spool (boot-replayed /
@@ -0,0 +1,221 @@
1
+ /**
2
+ * Per-chat-and-thread marker of the most recent gateway-synthesized
3
+ * `subagent_handback` enqueue (fix/backstop-duplicate-reply).
4
+ *
5
+ * A BACKGROUND sub-agent completion is a GATEWAY-SYNTHESIZED event, not model
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/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
+ * live/origin/quoted attribution, so both land on latest-ended):
12
+ *
13
+ * - CASE A — the flushed turn's OWN reworded reply landing late. NO
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
+ * provisional flush REGARDLESS of the model's rewording (closes the #3429
17
+ * reworded-duplicate regression: agent:marko 2026-07-20, turns
18
+ * #1177/#1182/#1201 double-sent).
19
+ * - CASE B — a background handback attributed to that ended turn. A
20
+ * `subagent_handback` WAS enqueued after the turn ended and within the
21
+ * supersede TTL, so the late reply might BE it → keep the #3429 content gate
22
+ * and send fresh (two messages), never silently edit/delete the flushed
23
+ * answer.
24
+ *
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.
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
+
174
+ export class SubagentHandbackMarker {
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
+ }
193
+
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
198
+ }
199
+
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
220
+ }
221
+ }
@@ -84,6 +84,37 @@ function latestEndedAccepted(candidates: ReplyOwnerCandidates): boolean {
84
84
  return age <= ttl
85
85
  }
86
86
 
87
+ /**
88
+ * Which precedence tier resolved a reply's owner turn.
89
+ *
90
+ * - `'live'` / `'origin'` / `'quoted'` are POSITIVE attributions: the reply
91
+ * is tied to a specific turn by a live atom, the model's own echo, or the
92
+ * framework-owned quoted message id — it IS that turn's answer.
93
+ * - `'latest-ended'` is the ambiguous FALLBACK: no positive link exists, so
94
+ * the reply is merely attributed to the chat's most-recently-ended turn.
95
+ * This tier cannot tell the turn's OWN late reply from an async sub-agent
96
+ * handback that has no live turn and no quote linkage — both land here with
97
+ * the same resolved turnId (#3429). The supersede content gate
98
+ * (`flushedAnswerMatchesReply`) is therefore applied ONLY on this tier.
99
+ * - `'none'` — every lookup missed (a genuinely unattributable reply).
100
+ */
101
+ export type ReplyOwnerTier = 'live' | 'origin' | 'quoted' | 'latest-ended' | 'none'
102
+
103
+ /**
104
+ * The tier that WINS owner resolution for these candidates — the single source
105
+ * of the precedence order, consumed by both `resolveReplyOwnerTurnId` (which id)
106
+ * and the gateway (which discrimination the supersede applies). Same precedence,
107
+ * first non-null wins; the destructive latest-ended tier is honoured only when
108
+ * fresh (`latestEndedAccepted`).
109
+ */
110
+ export function resolveReplyOwnerTier(candidates: ReplyOwnerCandidates): ReplyOwnerTier {
111
+ if (candidates.liveTurnId != null) return 'live'
112
+ if (candidates.originTurnId != null) return 'origin'
113
+ if (candidates.quotedTurnId != null) return 'quoted'
114
+ if (latestEndedAccepted(candidates)) return 'latest-ended'
115
+ return 'none'
116
+ }
117
+
87
118
  /**
88
119
  * Resolve the turnId that OWNS a landing reply, using the SAME full chain the
89
120
  * thread-router uses. Precedence, first non-null wins:
@@ -98,13 +129,18 @@ function latestEndedAccepted(candidates: ReplyOwnerCandidates): boolean {
98
129
  * to delete any turnId-bearing flush record.
99
130
  */
100
131
  export function resolveReplyOwnerTurnId(candidates: ReplyOwnerCandidates): string | null {
101
- return (
102
- candidates.liveTurnId ??
103
- candidates.originTurnId ??
104
- candidates.quotedTurnId ??
105
- (latestEndedAccepted(candidates) ? candidates.latestEndedTurnId : null) ??
106
- null
107
- )
132
+ switch (resolveReplyOwnerTier(candidates)) {
133
+ case 'live':
134
+ return candidates.liveTurnId
135
+ case 'origin':
136
+ return candidates.originTurnId
137
+ case 'quoted':
138
+ return candidates.quotedTurnId
139
+ case 'latest-ended':
140
+ return candidates.latestEndedTurnId
141
+ case 'none':
142
+ return null
143
+ }
108
144
  }
109
145
 
110
146
  /**
@@ -381,3 +381,92 @@ describe('#3429 — decideSupersede new-content gate', () => {
381
381
  expect(reg.peek('chat9', undefined, { liveTurnId: 'turn-F', now: now + 21_000 }).reason).toBe('no-record')
382
382
  })
383
383
  })
384
+
385
+ /**
386
+ * The tier discriminator (fix/backstop-duplicate-reply). `positiveAttribution`
387
+ * lets the gateway bypass the #3429 content gate when the reply's owner turn was
388
+ * resolved by POSITIVE attribution (live / origin echo / quoted) rather than the
389
+ * ambiguous latest-ended fallback. On a positive tier a genuinely-DIFFERENT
390
+ * (reworded) reply for the same turn still supersedes — closing the reworded
391
+ * same-turn duplicate — while the latest-ended path keeps the content gate so a
392
+ * true async handback still sends fresh.
393
+ */
394
+ describe('positiveAttribution — tier-gated content check', () => {
395
+ const FLUSHED = 'Narration first.\n\nHere is the finished summary of the incident you asked about.'
396
+ const REWORDED =
397
+ 'So, to wrap up: the incident boiled down to a stale cache entry, and it is fully resolved now.'
398
+
399
+ it('positiveAttribution=true: same turn + DIFFERENT (reworded) content STILL supersedes', () => {
400
+ // Guard: the reworded text is genuinely new vs the flushed blob (would be
401
+ // declined new-content without the tier bypass).
402
+ expect(flushedAnswerMatchesReply(FLUSHED, REWORDED)).toBe(false)
403
+
404
+ const d = decideSupersede(rec({ text: FLUSHED }), {
405
+ liveTurnId: 'turn-A',
406
+ replyText: REWORDED,
407
+ positiveAttribution: true,
408
+ now: 1_000_010,
409
+ })
410
+ expect(d.supersede).toBe(true)
411
+ expect(d.reason).toBe('supersede')
412
+ expect(d.deleteMessageIds).toEqual([101, 102])
413
+ })
414
+
415
+ it('positiveAttribution=false (latest-ended): the SAME reworded content declines as new-content', () => {
416
+ const d = decideSupersede(rec({ text: FLUSHED }), {
417
+ liveTurnId: 'turn-A',
418
+ replyText: REWORDED,
419
+ positiveAttribution: false,
420
+ now: 1_000_010,
421
+ })
422
+ expect(d.supersede).toBe(false)
423
+ expect(d.reason).toBe('new-content')
424
+ })
425
+
426
+ it('positiveAttribution=true still requires turn IDENTITY (different turn never supersedes)', () => {
427
+ const d = decideSupersede(rec({ turnId: 'turn-A', text: FLUSHED }), {
428
+ liveTurnId: 'turn-B',
429
+ replyText: REWORDED,
430
+ positiveAttribution: true,
431
+ now: 1_000_010,
432
+ })
433
+ expect(d.supersede).toBe(false)
434
+ expect(d.reason).toBe('different-turn')
435
+ })
436
+
437
+ it('positiveAttribution=true still respects the TTL (expired record never supersedes)', () => {
438
+ const d = decideSupersede(rec({ text: FLUSHED }), {
439
+ liveTurnId: 'turn-A',
440
+ replyText: REWORDED,
441
+ positiveAttribution: true,
442
+ now: 1_000_000 + DEFAULT_SUPERSEDE_TTL_MS + 1,
443
+ })
444
+ expect(d.supersede).toBe(false)
445
+ expect(d.reason).toBe('expired')
446
+ })
447
+
448
+ it('registry take() threads positiveAttribution through to the decision', () => {
449
+ const reg = new FlushedTurnSupersedeRegistry()
450
+ const now = 1_000_000
451
+ reg.record('chatT', undefined, { turnId: 'turn-Q', messageIds: [8001], text: FLUSHED }, now)
452
+
453
+ // latest-ended (positiveAttribution falsey): reworded → new-content, kept.
454
+ const ambiguous = reg.peek('chatT', undefined, {
455
+ liveTurnId: 'turn-Q',
456
+ replyText: REWORDED,
457
+ now: now + 5_000,
458
+ })
459
+ expect(ambiguous.reason).toBe('new-content')
460
+
461
+ // positive tier: same reworded reply supersedes and consumes the record.
462
+ const positive = reg.take('chatT', undefined, {
463
+ liveTurnId: 'turn-Q',
464
+ replyText: REWORDED,
465
+ positiveAttribution: true,
466
+ now: now + 6_000,
467
+ })
468
+ expect(positive.supersede).toBe(true)
469
+ expect(positive.deleteMessageIds).toEqual([8001])
470
+ expect(reg.peek('chatT', undefined, { liveTurnId: 'turn-Q', now: now + 7_000 }).reason).toBe('no-record')
471
+ })
472
+ })
@@ -371,7 +371,8 @@ function makeSendReplyDeps(dedup: OutboundDedupCache) {
371
371
  assertSendable: () => {},
372
372
  statusKey: key,
373
373
  streamKey: key,
374
- resolveReplyOwnerTurn: () => null,
374
+ resolveReplyOwnerTurn: () => ({ turn: null, tier: 'none' as const }),
375
+ getLastSubagentHandbackAt: () => null,
375
376
  findTurnByOriginId: () => null,
376
377
  findTurnByQuotedMessageId: () => null,
377
378
  resolveAnswerThreadWithLog: (_c: string, explicit: number | undefined) => explicit,
@@ -32,6 +32,7 @@
32
32
  import { describe, it, expect } from 'vitest'
33
33
  import {
34
34
  resolveReplyOwnerTurnId,
35
+ resolveReplyOwnerTier,
35
36
  decideAnswerLatchSuppression,
36
37
  type ReplyOwnerCandidates,
37
38
  type AnswerDeliveredLatch,
@@ -611,3 +612,76 @@ describe('#3429 — flush-armed latch with content evidence', () => {
611
612
  ).toBe(false)
612
613
  })
613
614
  })
615
+
616
+ /**
617
+ * `resolveReplyOwnerTier` (fix/backstop-duplicate-reply) exposes WHICH precedence
618
+ * tier won, so the gateway can apply the #3429 content gate ONLY on the ambiguous
619
+ * `latest-ended` fallback. It shares one precedence with `resolveReplyOwnerTurnId`
620
+ * (asserted equivalent below), so the winning id and the winning tier can never
621
+ * disagree.
622
+ */
623
+ describe('resolveReplyOwnerTier — precedence + latest-ended TTL bound', () => {
624
+ const base: ReplyOwnerCandidates = {
625
+ liveTurnId: null,
626
+ originTurnId: null,
627
+ quotedTurnId: null,
628
+ latestEndedTurnId: null,
629
+ }
630
+
631
+ it('live wins over every lower tier', () => {
632
+ expect(
633
+ resolveReplyOwnerTier({ ...base, liveTurnId: 'L', originTurnId: 'O', quotedTurnId: 'Q', latestEndedTurnId: 'E' }),
634
+ ).toBe('live')
635
+ })
636
+
637
+ it('origin wins when no live turn', () => {
638
+ expect(resolveReplyOwnerTier({ ...base, originTurnId: 'O', quotedTurnId: 'Q', latestEndedTurnId: 'E' })).toBe('origin')
639
+ })
640
+
641
+ it('quoted wins when no live/origin turn (the positive DM recovery tier)', () => {
642
+ expect(resolveReplyOwnerTier({ ...base, quotedTurnId: 'Q', latestEndedTurnId: 'E' })).toBe('quoted')
643
+ })
644
+
645
+ it('latest-ended is the ambiguous FALLBACK when only it resolves', () => {
646
+ expect(resolveReplyOwnerTier({ ...base, latestEndedTurnId: 'E' })).toBe('latest-ended')
647
+ })
648
+
649
+ it('none when every lookup missed', () => {
650
+ expect(resolveReplyOwnerTier(base)).toBe('none')
651
+ })
652
+
653
+ it('a STALE latest-ended (age > TTL) is NOT accepted → none, never latest-ended', () => {
654
+ const stale: ReplyOwnerCandidates = {
655
+ ...base,
656
+ latestEndedTurnId: 'E',
657
+ latestEndedAgeMs: DEFAULT_SUPERSEDE_TTL_MS + 1,
658
+ latestEndedTtlMs: DEFAULT_SUPERSEDE_TTL_MS,
659
+ }
660
+ expect(resolveReplyOwnerTier(stale)).toBe('none')
661
+ // And the id resolver agrees (no destructive authority granted to a stale turn).
662
+ expect(resolveReplyOwnerTurnId(stale)).toBe(null)
663
+ })
664
+
665
+ it('tier and id resolver share one precedence for every candidate shape', () => {
666
+ const shapes: ReplyOwnerCandidates[] = [
667
+ base,
668
+ { ...base, liveTurnId: 'L', quotedTurnId: 'Q', latestEndedTurnId: 'E' },
669
+ { ...base, originTurnId: 'O', latestEndedTurnId: 'E' },
670
+ { ...base, quotedTurnId: 'Q' },
671
+ { ...base, latestEndedTurnId: 'E' },
672
+ ]
673
+ const idForTier = (s: ReplyOwnerCandidates): string | null => {
674
+ switch (resolveReplyOwnerTier(s)) {
675
+ case 'live': return s.liveTurnId
676
+ case 'origin': return s.originTurnId
677
+ case 'quoted': return s.quotedTurnId
678
+ case 'latest-ended': return s.latestEndedTurnId
679
+ case 'none': return null
680
+ }
681
+ }
682
+ for (const s of shapes) {
683
+ // The id the winning tier points at equals what the id resolver returns.
684
+ expect(resolveReplyOwnerTurnId(s)).toBe(idForTier(s))
685
+ }
686
+ })
687
+ })