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
@@ -34,11 +34,14 @@ import {
34
34
  resolveReplyOwnerTurnId,
35
35
  resolveReplyOwnerTier,
36
36
  decideAnswerLatchSuppression,
37
+ decideContentGateBypass,
37
38
  type ReplyOwnerCandidates,
39
+ type ReplyOwnerTier,
38
40
  type AnswerDeliveredLatch,
39
41
  } from '../reply-owner-resolve.js'
40
42
  import { FlushedTurnSupersedeRegistry, DEFAULT_SUPERSEDE_TTL_MS } from '../flushed-turn-supersede.js'
41
43
  import { OutboundDedupCache } from '../recent-outbound-dedup.js'
44
+ import { latestTurnForChat } from '../gateway/latest-turn-lookup.js'
42
45
 
43
46
  const NONE: ReplyOwnerCandidates = {
44
47
  liveTurnId: null,
@@ -685,3 +688,531 @@ describe('resolveReplyOwnerTier — precedence + latest-ended TTL bound', () =>
685
688
  }
686
689
  })
687
690
  })
691
+
692
+ /**
693
+ * `decideContentGateBypass` — WHEN the supersede path may treat a landing reply
694
+ * as this flushed turn's own answer and collapse the provisional flush even
695
+ * though the model REWORDED it.
696
+ *
697
+ * The rule replaced a blanket ban on the model-supplied `origin`/`quoted` tiers
698
+ * with CORROBORATION against the framework-derived `latestEndedTurnId`. The two
699
+ * properties that matter, both asserted below as outcomes:
700
+ *
701
+ * - it closes the 2026-07-27 duplicate: a reply that echoes `origin_turn_id`
702
+ * for its OWN turn now bypasses, exactly as it would have by omitting the
703
+ * echo (the blanket ban punished the extra information);
704
+ * - it grants ZERO new capability: for every candidate shape, a corroborated
705
+ * `origin`/`quoted` bypass is reachable by dropping the model-supplied ids
706
+ * and landing on `latest-ended` — so nothing bypasses that could not
707
+ * already, and a STEERED attribution (pointing at a different turn than the
708
+ * framework resolves) still cannot.
709
+ */
710
+ describe('decideContentGateBypass — corroborated bypass of the #3429 content gate', () => {
711
+ const OWNER = 'turn-OWNER'
712
+ const OTHER = 'turn-OTHER'
713
+
714
+ /** Candidates whose framework-derived latest-ended IS `latestEnded`, fresh. */
715
+ const cands = (over: Partial<ReplyOwnerCandidates> = {}): ReplyOwnerCandidates => ({
716
+ liveTurnId: null,
717
+ originTurnId: null,
718
+ quotedTurnId: null,
719
+ latestEndedTurnId: OWNER,
720
+ latestEndedAgeMs: 30_000,
721
+ latestEndedTtlMs: DEFAULT_SUPERSEDE_TTL_MS,
722
+ ...over,
723
+ })
724
+
725
+ it('THE 2026-07-27 INCIDENT: an `origin` echo pointing at the SAME turn the ' +
726
+ 'framework resolves bypasses the gate (pre-fix: banned → visible duplicate)', () => {
727
+ expect(
728
+ decideContentGateBypass({
729
+ tier: 'origin',
730
+ resolvedTurnId: OWNER,
731
+ candidates: cands({ originTurnId: OWNER }),
732
+ handbackCouldOwnReply: false,
733
+ }),
734
+ ).toBe(true)
735
+ })
736
+
737
+ it('a `quoted` attribution corroborated the same way also bypasses', () => {
738
+ expect(
739
+ decideContentGateBypass({
740
+ tier: 'quoted',
741
+ resolvedTurnId: OWNER,
742
+ candidates: cands({ quotedTurnId: OWNER }),
743
+ handbackCouldOwnReply: false,
744
+ }),
745
+ ).toBe(true)
746
+ })
747
+
748
+ it('THE FABLE VECTOR stays closed: an `origin`/`quoted` attribution STEERED onto ' +
749
+ 'a different turn than the framework resolves never bypasses', () => {
750
+ for (const tier of ['origin', 'quoted'] as const) {
751
+ expect(
752
+ decideContentGateBypass({
753
+ tier,
754
+ resolvedTurnId: OTHER,
755
+ candidates: cands({
756
+ originTurnId: tier === 'origin' ? OTHER : null,
757
+ quotedTurnId: tier === 'quoted' ? OTHER : null,
758
+ latestEndedTurnId: OWNER,
759
+ }),
760
+ handbackCouldOwnReply: false,
761
+ }),
762
+ ).toBe(false)
763
+ }
764
+ })
765
+
766
+ it('a decoupled completion in the window keeps the gate on EVERY non-live tier', () => {
767
+ for (const tier of ['origin', 'quoted', 'latest-ended'] as const) {
768
+ expect(
769
+ decideContentGateBypass({
770
+ tier,
771
+ resolvedTurnId: OWNER,
772
+ candidates: cands({
773
+ originTurnId: tier === 'origin' ? OWNER : null,
774
+ quotedTurnId: tier === 'quoted' ? OWNER : null,
775
+ }),
776
+ handbackCouldOwnReply: true,
777
+ }),
778
+ ).toBe(false)
779
+ }
780
+ })
781
+
782
+ it('`live` bypasses unconditionally — even inside a handback window ' +
783
+ '(framework-owned, and decideSupersede bars it from another turn record)', () => {
784
+ expect(
785
+ decideContentGateBypass({
786
+ tier: 'live',
787
+ resolvedTurnId: OWNER,
788
+ candidates: cands({ liveTurnId: OWNER }),
789
+ handbackCouldOwnReply: true,
790
+ }),
791
+ ).toBe(true)
792
+ })
793
+
794
+ it('`none` never bypasses (nothing to attribute the reply to)', () => {
795
+ expect(
796
+ decideContentGateBypass({
797
+ tier: 'none',
798
+ resolvedTurnId: null,
799
+ candidates: cands({ latestEndedTurnId: null }),
800
+ handbackCouldOwnReply: false,
801
+ }),
802
+ ).toBe(false)
803
+ })
804
+
805
+ it('a STALE framework candidate corroborates NOTHING — the F2 freshness bound ' +
806
+ 'still denies a past-TTL turn any bypass authority', () => {
807
+ const stale = cands({
808
+ originTurnId: OWNER,
809
+ latestEndedAgeMs: DEFAULT_SUPERSEDE_TTL_MS + 1,
810
+ })
811
+ expect(
812
+ decideContentGateBypass({
813
+ tier: 'origin',
814
+ resolvedTurnId: OWNER,
815
+ candidates: stale,
816
+ handbackCouldOwnReply: false,
817
+ }),
818
+ ).toBe(false)
819
+ })
820
+
821
+ it('`latest-ended` behaviour is UNCHANGED by the widening (it corroborates itself)', () => {
822
+ expect(
823
+ decideContentGateBypass({
824
+ tier: 'latest-ended',
825
+ resolvedTurnId: OWNER,
826
+ candidates: cands(),
827
+ handbackCouldOwnReply: false,
828
+ }),
829
+ ).toBe(true)
830
+ })
831
+
832
+ // ─── #3726: total over degenerate input, and it fails CLOSED ───
833
+ //
834
+ // TypeScript makes `candidates` mandatory and the gateway always constructs
835
+ // it, so this is production-unreachable — but the function is EXPORTED, and a
836
+ // caller without the gateway's construction discipline used to get a
837
+ // `TypeError` out of the supersede decision path (fail-open-by-crash) instead
838
+ // of the safe answer. The safe answer is `false`: keep the #3429 content gate.
839
+ it('#3726: an absent candidate set returns false (fail CLOSED), never throws', () => {
840
+ for (const tier of ['origin', 'quoted', 'latest-ended'] as const) {
841
+ for (const absent of [undefined, null]) {
842
+ expect(
843
+ decideContentGateBypass({
844
+ tier,
845
+ resolvedTurnId: OWNER,
846
+ candidates: absent as unknown as ReplyOwnerCandidates,
847
+ handbackCouldOwnReply: false,
848
+ }),
849
+ ).toBe(false)
850
+ }
851
+ }
852
+ })
853
+
854
+ // ─── ZERO new capability, as an EXHAUSTIVE property (#3724) ───
855
+ //
856
+ // The safety argument for widening `origin`/`quoted` from a blanket ban to
857
+ // corroboration: every shape that bypasses via a model-supplied tier ALSO
858
+ // bypasses with those model-supplied ids stripped (i.e. by the model simply
859
+ // omitting `origin_turn_id` / `reply_to`). So the widening hands a reply no
860
+ // reach it did not already have.
861
+ //
862
+ // #3724 — the previous form of this test hand-picked four candidate shapes and
863
+ // guarded its assertion behind `if (withModelIds)`, so one of the four
864
+ // asserted nothing at all, `handbackCouldOwnReply` was pinned `false`, and
865
+ // `liveTurnId` was never varied. It could only go red if the corroboration
866
+ // equality were deleted outright. It is replaced here by an exhaustive sweep
867
+ // of the whole (small) candidate space that derives `tier`/`resolvedTurnId`
868
+ // from the REAL resolvers rather than passing them in by hand — and, so the
869
+ // sweep is evidence rather than decoration, by a companion test that runs the
870
+ // SAME sweep over deliberately broken decisions and asserts it catches them.
871
+ const IDS = [null, OWNER, OTHER] as const
872
+ /** fresh / stale / unbounded (no age+ttl — the documented back-compat path). */
873
+ const AGES = [
874
+ { label: 'fresh', ageMs: 30_000 as number | null, ttlMs: DEFAULT_SUPERSEDE_TTL_MS as number | undefined },
875
+ { label: 'stale', ageMs: DEFAULT_SUPERSEDE_TTL_MS + 1, ttlMs: DEFAULT_SUPERSEDE_TTL_MS },
876
+ { label: 'unbounded', ageMs: null, ttlMs: undefined },
877
+ ]
878
+
879
+ type Decide = typeof decideContentGateBypass
880
+
881
+ /** The module's freshness rule, restated locally so the mutants below can
882
+ * differ from the real decision in EXACTLY one respect each. */
883
+ const freshEnough = (c: ReplyOwnerCandidates): boolean =>
884
+ c.latestEndedTurnId != null &&
885
+ (c.latestEndedAgeMs == null || c.latestEndedTtlMs == null
886
+ ? true
887
+ : c.latestEndedAgeMs <= c.latestEndedTtlMs)
888
+
889
+ /**
890
+ * Sweep the full candidate space against `decide`, reporting every case that
891
+ * VIOLATES zero-new-capability (bypasses with the model-supplied ids present
892
+ * but not with them stripped) plus coverage counters that stop the sweep
893
+ * silently degrading to all-vacuous.
894
+ */
895
+ function sweepZeroNewCapability(decide: Decide): {
896
+ violations: string[]
897
+ cases: number
898
+ bypasses: number
899
+ modelTierBypasses: number
900
+ } {
901
+ const violations: string[] = []
902
+ let cases = 0
903
+ let bypasses = 0
904
+ let modelTierBypasses = 0
905
+ for (const liveTurnId of IDS)
906
+ for (const originTurnId of IDS)
907
+ for (const quotedTurnId of IDS)
908
+ for (const latestEndedTurnId of IDS)
909
+ for (const age of AGES)
910
+ for (const handbackCouldOwnReply of [false, true]) {
911
+ cases++
912
+ const c: ReplyOwnerCandidates = {
913
+ liveTurnId,
914
+ originTurnId,
915
+ quotedTurnId,
916
+ latestEndedTurnId,
917
+ latestEndedAgeMs: age.ageMs,
918
+ latestEndedTtlMs: age.ttlMs,
919
+ }
920
+ const tier = resolveReplyOwnerTier(c)
921
+ const withModelIds = decide({
922
+ tier,
923
+ resolvedTurnId: resolveReplyOwnerTurnId(c),
924
+ candidates: c,
925
+ handbackCouldOwnReply,
926
+ })
927
+ // Strip the model-supplied ids: the FRAMEWORK alone resolves the
928
+ // owner — the reach the model already had by staying silent.
929
+ const stripped: ReplyOwnerCandidates = {
930
+ ...c,
931
+ originTurnId: null,
932
+ quotedTurnId: null,
933
+ }
934
+ const withoutModelIds = decide({
935
+ tier: resolveReplyOwnerTier(stripped),
936
+ resolvedTurnId: resolveReplyOwnerTurnId(stripped),
937
+ candidates: stripped,
938
+ handbackCouldOwnReply,
939
+ })
940
+ if (withModelIds) {
941
+ bypasses++
942
+ if (tier === 'origin' || tier === 'quoted') modelTierBypasses++
943
+ }
944
+ if (withModelIds && !withoutModelIds) {
945
+ violations.push(
946
+ `tier=${tier} live=${liveTurnId} origin=${originTurnId} ` +
947
+ `quoted=${quotedTurnId} latestEnded=${latestEndedTurnId} ` +
948
+ `age=${age.label} handback=${handbackCouldOwnReply}`,
949
+ )
950
+ }
951
+ }
952
+ return { violations, cases, bypasses, modelTierBypasses }
953
+ }
954
+
955
+ it('ZERO new capability over the EXHAUSTIVE candidate space: no shape bypasses ' +
956
+ 'with model-supplied ids that would not bypass without them', () => {
957
+ const r = sweepZeroNewCapability(decideContentGateBypass)
958
+ // The property itself — every violating shape is NAMED, not just counted.
959
+ expect(r.violations).toEqual([])
960
+ // Non-degeneracy: the sweep must actually cover the space and must actually
961
+ // reach the branch under test, so it can never quietly become a no-op.
962
+ expect(r.cases).toBe(IDS.length ** 4 * AGES.length * 2)
963
+ expect(r.bypasses).toBeGreaterThan(0)
964
+ // …and specifically, many cases must win their bypass on the WIDENED,
965
+ // model-steerable `origin`/`quoted` tiers — the thing being vouched for.
966
+ // Exactly 8 of the 486 cases do: no live turn, handback clear, a FRESH
967
+ // latest-ended id, and the steered id equal to it.
968
+ //
969
+ // This floor was 16 before #3725. The other 8 were the `latestEndedAgeMs
970
+ // == null` (unbounded) shapes, which corroborated a bypass off an anchor
971
+ // that had never ended. #3725 makes the latest-ended lookup `endedOnly`, so
972
+ // the age is always a real number and an explicit null age fails CLOSED —
973
+ // those 8 shapes are now correctly unreachable. Lowering the floor here is
974
+ // recording that narrowing, not conceding it: the property assertion above
975
+ // (`violations` empty) is unchanged and still passes.
976
+ expect(r.modelTierBypasses).toBeGreaterThanOrEqual(8)
977
+ })
978
+
979
+ // NON-VACUITY, asserted deterministically rather than demonstrated by hand:
980
+ // the same sweep is run over mutants that break zero-new-capability in the two
981
+ // realistic ways #3724 called out. If it could not go red on these, it would
982
+ // not be evidence for the real function.
983
+ it('the sweep is NOT vacuous: it catches a bypass that ignores corroboration, ' +
984
+ 'and one that ignores the handback window on the steerable tiers', () => {
985
+ // Mutant A — the widening WITHOUT the corroboration equality: `origin`/
986
+ // `quoted` bypass on any resolved turn. This is the Fable silent-edit-over.
987
+ const ignoresCorroboration: Decide = (input) => {
988
+ if (input.tier === 'live') return true
989
+ if (input.tier === 'none') return false
990
+ if (input.handbackCouldOwnReply) return false
991
+ if (input.candidates == null) return false
992
+ if (input.tier === 'origin' || input.tier === 'quoted') return true
993
+ if (!freshEnough(input.candidates)) return false
994
+ return input.resolvedTurnId === input.candidates.latestEndedTurnId
995
+ }
996
+ const a = sweepZeroNewCapability(ignoresCorroboration)
997
+ expect(a.violations.length).toBeGreaterThan(0)
998
+
999
+ // Mutant B — corroboration kept, handback guard dropped on the steerable
1000
+ // tiers only. The OLD hand-picked test stayed GREEN under exactly this,
1001
+ // because it pinned `handbackCouldOwnReply: false` in every shape.
1002
+ const ignoresHandback: Decide = (input) => {
1003
+ if (input.tier === 'live') return true
1004
+ if (input.tier === 'none') return false
1005
+ const steerable = input.tier === 'origin' || input.tier === 'quoted'
1006
+ if (input.handbackCouldOwnReply && !steerable) return false
1007
+ if (input.candidates == null) return false
1008
+ if (!freshEnough(input.candidates)) return false
1009
+ return (
1010
+ input.resolvedTurnId != null &&
1011
+ input.resolvedTurnId === input.candidates.latestEndedTurnId
1012
+ )
1013
+ }
1014
+ const b = sweepZeroNewCapability(ignoresHandback)
1015
+ expect(b.violations.length).toBeGreaterThan(0)
1016
+ })
1017
+ })
1018
+
1019
+ /**
1020
+ * #3725 — the "framework-derived, TTL-bounded" supersede anchor was neither
1021
+ * ended nor bounded.
1022
+ *
1023
+ * The gateway's `recentTurnsById` registry is populated at turn START, so its
1024
+ * tail entry for a chat can be a turn that is STILL RUNNING (`endedAt: null`) —
1025
+ * and because the registry is chat-wide and thread-agnostic, a turn running in
1026
+ * another topic of the same chat lands there. The gateway then computed
1027
+ * `latestEndedAgeMs = endedAt != null ? now - endedAt : null`, and
1028
+ * `latestEndedAccepted` treated a null age as UNBOUNDED (its back-compat escape
1029
+ * for callers that supply no age at all). Net effect: a still-running turn
1030
+ * became a corroborating anchor with NO freshness bound, for the widened
1031
+ * `origin`/`quoted` content-gate bypass #3719 leans on.
1032
+ *
1033
+ * The fix is two-layer and both layers fail CLOSED:
1034
+ * 1. the gateway lookup skips turns with no `endedAt` (`latestTurnForChat`
1035
+ * with `endedOnly: true`), so the anchor is a genuinely ended turn — and
1036
+ * when the chat has no ended turn, there is NO anchor rather than a
1037
+ * running one;
1038
+ * 2. `latestEndedAccepted` distinguishes an OMITTED age (undefined ⇒
1039
+ * back-compat unbounded) from a COMPUTED-null age (⇒ rejected).
1040
+ */
1041
+ describe('#3725 — a still-running turn is not a supersede anchor', () => {
1042
+ const CHAT = '12345'
1043
+ const ENDED = 'turn-ENDED'
1044
+ const RUNNING = 'turn-RUNNING'
1045
+
1046
+ interface RegistryTurn { turnId: string; sessionChatId: string; endedAt: number | null }
1047
+
1048
+ const NOW = 1_000_000
1049
+
1050
+ /**
1051
+ * The gateway's candidate construction (`resolveReplyOwnerTurn`), mirrored
1052
+ * here because `gateway.ts` is not importable in tests — the same convention
1053
+ * the rest of this file uses. `scan` selects the FIXED lookup (`endedOnly`)
1054
+ * or the PRE-FIX one (tail entry regardless of `endedAt`), giving the
1055
+ * red-on-main contrast as an assertion rather than prose.
1056
+ */
1057
+ function gatewayCandidates(
1058
+ turns: RegistryTurn[],
1059
+ scan: 'fixed' | 'prefix',
1060
+ over: Partial<ReplyOwnerCandidates> = {},
1061
+ ): ReplyOwnerCandidates {
1062
+ const latestEnded =
1063
+ scan === 'fixed'
1064
+ ? latestTurnForChat(turns, CHAT, { endedOnly: true })
1065
+ : (turns.filter((t) => t.sessionChatId === CHAT).at(-1) ?? null)
1066
+ return {
1067
+ liveTurnId: null,
1068
+ originTurnId: null,
1069
+ quotedTurnId: null,
1070
+ latestEndedTurnId: latestEnded?.turnId ?? null,
1071
+ latestEndedAgeMs: latestEnded?.endedAt != null ? NOW - latestEnded.endedAt : null,
1072
+ latestEndedTtlMs: DEFAULT_SUPERSEDE_TTL_MS,
1073
+ ...over,
1074
+ }
1075
+ }
1076
+
1077
+ /**
1078
+ * The PRE-FIX resolver semantics, reproduced verbatim for the red-on-main
1079
+ * contrast. BOTH layers of the defect are here: `latestEndedAccepted` treated
1080
+ * a null age as unbounded (`age == null ⇒ true`), and the gateway's scan (the
1081
+ * `'prefix'` mode above) handed it a turn that had not ended, whose age it
1082
+ * therefore computed as null.
1083
+ */
1084
+ function preFixAccepted(c: ReplyOwnerCandidates): boolean {
1085
+ if (c.latestEndedTurnId == null) return false
1086
+ const age = c.latestEndedAgeMs
1087
+ const ttl = c.latestEndedTtlMs
1088
+ if (age == null || ttl == null) return true
1089
+ return age <= ttl
1090
+ }
1091
+ function preFixTier(c: ReplyOwnerCandidates): ReplyOwnerTier {
1092
+ if (c.liveTurnId != null) return 'live'
1093
+ if (c.originTurnId != null) return 'origin'
1094
+ if (c.quotedTurnId != null) return 'quoted'
1095
+ if (preFixAccepted(c)) return 'latest-ended'
1096
+ return 'none'
1097
+ }
1098
+ function preFixOwnerId(c: ReplyOwnerCandidates): string | null {
1099
+ switch (preFixTier(c)) {
1100
+ case 'live': return c.liveTurnId
1101
+ case 'origin': return c.originTurnId
1102
+ case 'quoted': return c.quotedTurnId
1103
+ case 'latest-ended': return c.latestEndedTurnId
1104
+ case 'none': return null
1105
+ }
1106
+ }
1107
+ /** `decideContentGateBypass` as it stood before this fix (same rule, pre-fix
1108
+ * acceptance). */
1109
+ function preFixBypass(c: ReplyOwnerCandidates): boolean {
1110
+ const tier = preFixTier(c)
1111
+ if (tier === 'live') return true
1112
+ if (tier === 'none') return false
1113
+ if (!preFixAccepted(c)) return false
1114
+ const id = preFixOwnerId(c)
1115
+ return id != null && id === c.latestEndedTurnId
1116
+ }
1117
+
1118
+ /** A chat whose only ended turn is followed by a turn still running in
1119
+ * another topic — the registry tail is the RUNNING turn. */
1120
+ const registry: RegistryTurn[] = [
1121
+ { turnId: ENDED, sessionChatId: CHAT, endedAt: NOW - 10_000 },
1122
+ { turnId: RUNNING, sessionChatId: CHAT, endedAt: null },
1123
+ ]
1124
+
1125
+ it('the anchor is the ENDED turn, not the running tail entry (pre-fix scan ' +
1126
+ 'picked the running turn with a null — unbounded — age)', () => {
1127
+ expect(gatewayCandidates(registry, 'fixed')).toMatchObject({
1128
+ latestEndedTurnId: ENDED,
1129
+ latestEndedAgeMs: 10_000,
1130
+ })
1131
+ expect(gatewayCandidates(registry, 'prefix')).toMatchObject({
1132
+ latestEndedTurnId: RUNNING,
1133
+ latestEndedAgeMs: null,
1134
+ })
1135
+ })
1136
+
1137
+ it('a model-steered `origin` echo of the RUNNING turn gets NO corroborated ' +
1138
+ 'bypass — pre-fix it did', () => {
1139
+ const steered = { originTurnId: RUNNING }
1140
+ const fixed = gatewayCandidates(registry, 'fixed', steered)
1141
+ expect(
1142
+ decideContentGateBypass({
1143
+ tier: resolveReplyOwnerTier(fixed),
1144
+ resolvedTurnId: resolveReplyOwnerTurnId(fixed),
1145
+ candidates: fixed,
1146
+ handbackCouldOwnReply: false,
1147
+ }),
1148
+ ).toBe(false)
1149
+ // Red-on-main contrast: the same reply DID bypass the #3429 content gate
1150
+ // before the fix, corroborated by a turn that had not even ended.
1151
+ expect(preFixBypass(gatewayCandidates(registry, 'prefix', steered))).toBe(true)
1152
+ })
1153
+
1154
+ it('a chat with ONLY a running turn yields NO owner and NO bypass (fail ' +
1155
+ 'closed), where pre-fix it yielded an unbounded latest-ended owner', () => {
1156
+ const onlyRunning: RegistryTurn[] = [{ turnId: RUNNING, sessionChatId: CHAT, endedAt: null }]
1157
+ const fixed = gatewayCandidates(onlyRunning, 'fixed')
1158
+ expect(fixed.latestEndedTurnId).toBeNull()
1159
+ expect(resolveReplyOwnerTier(fixed)).toBe('none')
1160
+ expect(resolveReplyOwnerTurnId(fixed)).toBeNull()
1161
+ expect(
1162
+ decideContentGateBypass({
1163
+ tier: 'none',
1164
+ resolvedTurnId: null,
1165
+ candidates: fixed,
1166
+ handbackCouldOwnReply: false,
1167
+ }),
1168
+ ).toBe(false)
1169
+
1170
+ const prefix = gatewayCandidates(onlyRunning, 'prefix')
1171
+ expect(preFixTier(prefix)).toBe('latest-ended')
1172
+ expect(preFixOwnerId(prefix)).toBe(RUNNING)
1173
+ })
1174
+
1175
+ it('layer 2: an EXPLICIT null age is rejected outright, while an OMITTED age ' +
1176
+ 'keeps the pre-F2 back-compat escape', () => {
1177
+ const base: ReplyOwnerCandidates = {
1178
+ liveTurnId: null,
1179
+ originTurnId: null,
1180
+ quotedTurnId: null,
1181
+ latestEndedTurnId: ENDED,
1182
+ latestEndedTtlMs: DEFAULT_SUPERSEDE_TTL_MS,
1183
+ }
1184
+ // Computed-null (the caller's candidate turn has not ended) ⇒ closed.
1185
+ expect(resolveReplyOwnerTier({ ...base, latestEndedAgeMs: null })).toBe('none')
1186
+ expect(resolveReplyOwnerTurnId({ ...base, latestEndedAgeMs: null })).toBeNull()
1187
+ expect(
1188
+ decideContentGateBypass({
1189
+ tier: 'origin',
1190
+ resolvedTurnId: ENDED,
1191
+ candidates: { ...base, originTurnId: ENDED, latestEndedAgeMs: null },
1192
+ handbackCouldOwnReply: false,
1193
+ }),
1194
+ ).toBe(false)
1195
+ // Property omitted entirely ⇒ unchanged pre-F2 behaviour.
1196
+ expect(resolveReplyOwnerTier(base)).toBe('latest-ended')
1197
+ expect(resolveReplyOwnerTurnId(base)).toBe(ENDED)
1198
+ })
1199
+
1200
+ it('end-to-end: a late reply while another topic is still running cannot ' +
1201
+ "delete a DIFFERENT turn's flush record", () => {
1202
+ const reg = new FlushedTurnSupersedeRegistry()
1203
+ // The still-running turn holds a fresh flush record of its own.
1204
+ reg.record(CHAT, undefined, { turnId: RUNNING, messageIds: [9001], text: 'A' }, NOW)
1205
+ // A late reply lands with no live turn, no echo, no quote. Pre-fix its owner
1206
+ // resolved to the RUNNING turn (registry tail, unbounded) and the take()
1207
+ // consumed that turn's record; with the fix the owner is the ENDED turn, so
1208
+ // the running turn's record is untouched.
1209
+ const onlyRunning: RegistryTurn[] = [
1210
+ { turnId: ENDED, sessionChatId: CHAT, endedAt: NOW - 10_000 },
1211
+ { turnId: RUNNING, sessionChatId: CHAT, endedAt: null },
1212
+ ]
1213
+ const ownerId = resolveReplyOwnerTurnId(gatewayCandidates(onlyRunning, 'fixed'))
1214
+ expect(ownerId).toBe(ENDED)
1215
+ expect(reg.take(CHAT, undefined, { liveTurnId: ownerId, now: NOW + 10 }).supersede).toBe(false)
1216
+ expect(reg.peek(CHAT, undefined, { liveTurnId: RUNNING, now: NOW + 10 }).supersede).toBe(true)
1217
+ })
1218
+ })
@@ -82,7 +82,7 @@ describe("secret-detect — DOES still fire on actually-shaped secrets (regressi
82
82
  // Locking in so a future regex-tightening doesn't over-shoot.
83
83
  // Values constructed at runtime so the source file doesn't trip
84
84
  // GitHub Push Protection — same pattern as
85
- // secret-detect-secretlint.test.ts:1.
85
+ // secret-detect-sanctum.test.ts:18 (CLAUDE.md § Secrets in tests).
86
86
  const fakeApiKey = `sk-ant-${"a1b2c3d4".repeat(4)}XYZ987`; // sk-ant- + 32 chars
87
87
  const fakeBearer = `${"abc123".repeat(8)}.${"def456".repeat(4)}`;
88
88
  const fakeRandom = `${"x9zM4kP3qR7sT2vW".repeat(2)}`; // 32 chars, high entropy