switchroom 0.19.32 → 0.19.33

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.
@@ -73,6 +73,15 @@ interface FakeOpts {
73
73
  pinErrors?: (unknown | null)[]
74
74
  /** Errors to throw from `unpinAllForumTopicMessages`, consumed in order. */
75
75
  unpinAllErrors?: (unknown | null)[]
76
+ /**
77
+ * Ids `unpinAllForumTopicMessages` removes. Undefined = the whole stack.
78
+ *
79
+ * The verb is TOPIC-scoped while `getChat` exposes the CHAT-wide top, so a
80
+ * successful topic drain routinely leaves pins belonging to other topics —
81
+ * including the one on top — untouched. That is exactly the case where a
82
+ * call-counted `popped` and an observation-counted `popped` disagree.
83
+ */
84
+ unpinAllTopicRemoves?: number[]
76
85
  canPin?: boolean
77
86
  /** Throws from `getChat`, consumed in order (null = normal read). */
78
87
  getChatErrors?: (unknown | null)[]
@@ -114,7 +123,14 @@ function fakeChat(o: FakeOpts) {
114
123
  calls.push(`unpinAllTopic:${threadId}`)
115
124
  const err = unpinAllErrors.shift()
116
125
  if (err != null) throw err
117
- stack.length = 0
126
+ if (o.unpinAllTopicRemoves == null) {
127
+ stack.length = 0
128
+ } else {
129
+ for (const id of o.unpinAllTopicRemoves) {
130
+ const at = stack.indexOf(id)
131
+ if (at >= 0) stack.splice(at, 1)
132
+ }
133
+ }
118
134
  return { ok: true }
119
135
  },
120
136
  canPinInChat: async () => {
@@ -270,6 +286,32 @@ describe('stale-pin sweep — draining a stacked chat', () => {
270
286
  expect(h.fake.stack).toEqual([41])
271
287
  })
272
288
 
289
+ it('does not lose an unpin that landed after the verifying read went dark (#3956)', async () => {
290
+ // Sequence: read/read agree on 61 → repin 61 → unpin 61 (it really pops) →
291
+ // the read that WOULD have verified it throws, so the sweep exits early.
292
+ //
293
+ // `popped` must stay 0 — the module never credits an unobserved pop — but
294
+ // the removal must not vanish from the accounting either: it is reported as
295
+ // one `issued`, which is what makes the ledger's "went out, unverified"
296
+ // figure honest instead of silently short by one.
297
+ const h = harness({ stack: [61, 62], getChatErrors: [null, null, new Error('ETIMEDOUT')] })
298
+ const res = await createStalePinSweeper(h.deps).sweepTarget({ chatId: DM })
299
+
300
+ expect(res.status).toBe('incomplete')
301
+ // The pop DID happen in the chat — this is not a hypothetical loss.
302
+ expect(h.fake.stack).toEqual([62])
303
+ expect(res.popped).toBe(0)
304
+ expect(res.issued).toBe(1)
305
+ })
306
+
307
+ it('reports issued alongside popped on a clean DM drain', async () => {
308
+ const h = harness({ stack: [71, 72] })
309
+ const res = await createStalePinSweeper(h.deps).sweepTarget({ chatId: DM })
310
+ expect(res.status).toBe('drained')
311
+ expect(res.popped).toBe(2)
312
+ expect(res.issued).toBe(2)
313
+ })
314
+
273
315
  it('accepts Telegram\'s honest empty-stack signal from unpin', async () => {
274
316
  const h = harness({ stack: [51] })
275
317
  h.deps.unpin = async () => {
@@ -448,6 +490,38 @@ describe('stale-pin sweep — forum topics', () => {
448
490
  expect(h.sleeps).not.toContain(3000) // it did not even back off — it gave up
449
491
  })
450
492
 
493
+ it('credits the wholesale drain a POP only when the observed top moved (#3955)', async () => {
494
+ // A real, ordinary shape: the topic drain clears THIS topic's pins (202)
495
+ // while the chat-wide top (201) belongs to another topic and survives. The
496
+ // obligation IS discharged, but nothing observable moved — so this is one
497
+ // `issued`, zero `popped`. Counting the call as a pop would give the forum
498
+ // branch a different meaning for `popped` than every other branch has.
499
+ const h = harness({
500
+ stack: [201, 202],
501
+ allowUnpinAllForumTopic: true,
502
+ unpinAllTopicRemoves: [202],
503
+ })
504
+ const res = await createStalePinSweeper(h.deps).sweepTarget(topic)
505
+
506
+ expect(h.fake.calls.filter((c) => c === 'unpinAllTopic:77')).toHaveLength(1)
507
+ expect(res.status).toBe('drained')
508
+ expect(res.issued).toBe(1)
509
+ expect(res.popped).toBe(0)
510
+ // …and the durable counter did not inherit the call count either.
511
+ expect(h.cursors()[0].popped).toBe(0)
512
+ })
513
+
514
+ it('credits the wholesale drain a pop when the top DID move', async () => {
515
+ const h = harness({ stack: [211, 212], allowUnpinAllForumTopic: true })
516
+ const res = await createStalePinSweeper(h.deps).sweepTarget(topic)
517
+
518
+ expect(h.fake.stack).toEqual([])
519
+ expect(res.status).toBe('drained')
520
+ expect(res.issued).toBe(1)
521
+ expect(res.popped).toBe(1)
522
+ expect(h.cursors()[0].popped).toBe(1)
523
+ })
524
+
451
525
  it('routes the wholesale-drain policy through ONE predicate', () => {
452
526
  // Deliberately pinned: the destructive remedy is opt-in in exactly one
453
527
  // place, and no chat class reaches it implicitly.
@@ -704,6 +778,37 @@ describe('stale-pin sweep — eligibility and durable resume', () => {
704
778
  expect(res.status).toBe('drained')
705
779
  })
706
780
 
781
+ it('still honours an obligation written by a NEWER build (#3957)', async () => {
782
+ // Downgrade case. A ledger the running build does not recognise must not be
783
+ // silently thrown away: for an obligation ledger, "discard" means "forget
784
+ // an obligation", which re-creates the orphan the sweep exists to clear.
785
+ // Here the newer build already discharged this target — a version-
786
+ // intolerant reader would re-drain a chat it had no business touching.
787
+ const h = harness({ stack: [241] })
788
+ h.fs.files.set(
789
+ h.path,
790
+ JSON.stringify({
791
+ v: 99,
792
+ cursors: [
793
+ {
794
+ chatId: DM,
795
+ kind: 'dm',
796
+ popped: 3,
797
+ done: true,
798
+ attempts: 1,
799
+ updatedAt: 1,
800
+ futureFieldFromV99: 'x',
801
+ },
802
+ ],
803
+ }),
804
+ )
805
+ const res = await createStalePinSweeper(h.deps).sweepTarget({ chatId: DM })
806
+
807
+ expect(res.status).toBe('already-drained')
808
+ expect(h.fake.calls.filter((c) => c.startsWith('unpin:'))).toHaveLength(0)
809
+ expect(h.fake.stack).toEqual([241])
810
+ })
811
+
707
812
  it('never lets a failing ledger write break the sweep', async () => {
708
813
  const h = harness({ stack: [231, 232] })
709
814
  h.fs.writeFileSync = () => {
@@ -20,6 +20,22 @@
20
20
  * Drain progress is only ever concluded from OBSERVED STATE, never from an
21
21
  * API result.
22
22
  *
23
+ * ── Two counters, one meaning each, on EVERY branch ──────────────────────────
24
+ *
25
+ * That invariant is only enforceable if the accounting keeps the two apart, so
26
+ * `SweepResult` carries both and every drain primitive populates both:
27
+ *
28
+ * • `issued` — removal calls that went out unrejected. A claim. Credited
29
+ * immediately, before any observation.
30
+ * • `popped` — a `getChat` top that DEMONSTRABLY MOVED. Evidence. A LOWER
31
+ * BOUND, credited only from an observation, never from a call.
32
+ *
33
+ * Neither branch is allowed its own dialect: a call-counted `popped` on one
34
+ * path and an observation-counted `popped` on another is how the next reader
35
+ * ends up trusting a number that was never evidence (#3955, #3956). `issued -
36
+ * popped` is the honest "went out, could not be verified" figure everywhere,
37
+ * and undercounting `popped` can only make the sweeper do less work.
38
+ *
23
39
  * ── The drain primitives, per chat class ─────────────────────────────────────
24
40
  *
25
41
  * ONE sweep path (`sweepTarget`) that owns eligibility, the rights precheck,
@@ -497,15 +513,38 @@ export type SweepStatus =
497
513
 
498
514
  export interface SweepResult {
499
515
  status: SweepStatus
500
- /** Pops OBSERVED to have landed in THIS sweep (never counted from an API
501
- * resultsee the module docblock). */
516
+ /**
517
+ * Pops OBSERVED to have landed in THIS sweep never counted from an API
518
+ * result (see the module docblock).
519
+ *
520
+ * It is a LOWER BOUND, deliberately, and it means the same thing on EVERY
521
+ * drain branch: "a `getChat` top that demonstrably moved". Two structural
522
+ * reasons it can undercount, both safe:
523
+ *
524
+ * • a removal aimed at a MID-STACK id changes nothing a bot can read, so it
525
+ * is never creditable however well it worked (#3959);
526
+ * • a sweep that exits between a removal and the read that would have
527
+ * verified it (an unverifiable `getChat`) leaves that removal uncredited
528
+ * rather than guessing (#3956).
529
+ *
530
+ * Undercounting can only make the sweeper do LESS work — `popped` drives the
531
+ * per-chat pop cap and telemetry, nothing else — whereas overcounting would
532
+ * launder the `ok:true` silent no-op into a success. Use {@link issued} for
533
+ * "how many removals went out"; the two are never the same number by design.
534
+ */
502
535
  popped: number
503
536
  /**
504
- * Targeted unpins ISSUED in this sweep. Deliberately separate from `popped`:
505
- * an unpin aimed at a mid-stack id has no observable effect on the only thing
506
- * a bot can read (`getChat().pinned_message`, the top), so issuing one is not
507
- * evidence it landed. Keeping the two apart is what stops the `ok:true`
508
- * silent no-op from being laundered back into a success count.
537
+ * Pin-removal calls that WENT OUT and were not rejected — the DM repin+unpin
538
+ * loop's unpins, the group drain's targeted unpins, and the opt-in wholesale
539
+ * topic drain's single call. (Telegram's honest `message to unpin not found`
540
+ * counts too: it settles the id, it just does not move the top.)
541
+ * Deliberately separate from `popped`: a resolved call is a claim, not
542
+ * evidence — `unpinChatMessage` answers `ok:true` on a silent no-op, and a
543
+ * mid-stack removal has no read-back at all.
544
+ *
545
+ * Every branch populates it, so `issued - popped` is the honest "removals we
546
+ * could not verify" figure on any branch, and the two counters can never be
547
+ * conflated by a future reader.
509
548
  */
510
549
  issued?: number
511
550
  /** Detail for the log line. */
@@ -693,7 +732,10 @@ export function createStalePinSweeper(deps: StalePinSweepDeps): StalePinSweeper
693
732
  /**
694
733
  * The DM / opted-in-supergroup drain: RE-PIN the top then UNPIN it, which is
695
734
  * the only sequence that actually pops an entry, and re-read the observed top
696
- * to decide whether anything moved. A resolved unpin is never counted.
735
+ * to decide whether anything moved. A resolved unpin is never counted as a
736
+ * pop — but it IS counted as `issued` the instant it resolves, so an exit
737
+ * taken before the verifying read reports "one removal, none verified"
738
+ * instead of silently reporting nothing at all (#3956).
697
739
  */
698
740
  const repinUnpinLoop = async (
699
741
  target: SweepTarget,
@@ -702,6 +744,7 @@ export function createStalePinSweeper(deps: StalePinSweepDeps): StalePinSweeper
702
744
  ): Promise<SweepResult> => {
703
745
  const gate = gateFor(kind)
704
746
  let popped = 0
747
+ let issued = 0
705
748
  let consecutiveFloods = 0
706
749
  let lastRetryAfter: number | null = null
707
750
  let noProgressAtSameRetryAfter = 0
@@ -711,6 +754,16 @@ export function createStalePinSweeper(deps: StalePinSweepDeps): StalePinSweeper
711
754
  // never a pop.
712
755
  let unverifiedPop = false
713
756
 
757
+ /**
758
+ * EVERY exit from this loop goes through here, so the issued-vs-observed
759
+ * accounting cannot be dropped on any of its many early-return paths (#3956).
760
+ * A resolved unpin is credited to `issued` the moment it resolves; whether
761
+ * it also becomes a `popped` depends on a later observation that an
762
+ * interrupted sweep may never get to make. Reporting both is what keeps the
763
+ * unverified one visible instead of vanishing.
764
+ */
765
+ const out = (r: Omit<SweepResult, 'issued'>): SweepResult => ({ ...r, issued })
766
+
714
767
  /** Count a pop ONLY once the observed top has moved. */
715
768
  const creditPop = (): void => {
716
769
  popped++
@@ -721,35 +774,35 @@ export function createStalePinSweeper(deps: StalePinSweepDeps): StalePinSweeper
721
774
 
722
775
  for (let i = 0; i < gate.maxPopsPerChatPerSweep; i++) {
723
776
  if (circuitOpen) {
724
- return { status: 'aborted-circuit-breaker', popped, detail: 'breaker open' }
777
+ return out({ status: 'aborted-circuit-breaker', popped, detail: 'breaker open' })
725
778
  }
726
779
  const observed = await readTopVerified(target.chatId)
727
780
  if (observed == null) {
728
781
  // Ambiguous read (disagreeing reads or a throw). Not a drain, and NOT
729
782
  // an empty stack. Defer rather than assert either way.
730
- return { status: 'incomplete', popped, detail: 'pin-stack read unverifiable' }
783
+ return out({ status: 'incomplete', popped, detail: 'pin-stack read unverifiable' })
731
784
  }
732
785
  if (unverifiedPop) {
733
786
  // THE bug this module exists for: `unpinChatMessage` resolves ok:true on
734
787
  // a stale entry and pops NOTHING. The same id still on top after a full
735
788
  // repin+unpin is proof the pop did not land, whatever the API answered.
736
789
  if (observed.top != null && observed.top === lastObservedTop) {
737
- return {
790
+ return out({
738
791
  status: 'incomplete',
739
792
  popped,
740
793
  detail: `no progress: message ${observed.top} still on top after a repin+unpin`,
741
- }
794
+ })
742
795
  }
743
796
  creditPop()
744
797
  unverifiedPop = false
745
798
  }
746
799
  if (observed.top == null) {
747
- return { status: 'drained', popped }
800
+ return out({ status: 'drained', popped })
748
801
  }
749
802
  lastObservedTop = observed.top
750
803
 
751
804
  if (!(await awaitWriteSlot(kind))) {
752
- return { status: 'deferred-budget', popped, detail: 'per-minute pin-op budget spent' }
805
+ return out({ status: 'deferred-budget', popped, detail: 'per-minute pin-op budget spent' })
753
806
  }
754
807
  try {
755
808
  await deps.pinSilent(target.chatId, observed.top)
@@ -757,9 +810,9 @@ export function createStalePinSweeper(deps: StalePinSweepDeps): StalePinSweeper
757
810
  const d = classify(err)
758
811
  if (d.kind === 'breaker') {
759
812
  circuitOpen = true
760
- return { status: 'aborted-circuit-breaker', popped, detail: d.detail }
813
+ return out({ status: 'aborted-circuit-breaker', popped, detail: d.detail })
761
814
  }
762
- if (d.kind === 'rights') return { status: 'skipped-no-rights', popped }
815
+ if (d.kind === 'rights') return out({ status: 'skipped-no-rights', popped })
763
816
  if (d.kind === 'flood') {
764
817
  consecutiveFloods++
765
818
  if (lastRetryAfter === d.seconds) noProgressAtSameRetryAfter++
@@ -769,22 +822,22 @@ export function createStalePinSweeper(deps: StalePinSweepDeps): StalePinSweeper
769
822
  consecutiveFloods >= MAX_CONSECUTIVE_FLOOD_WAITS ||
770
823
  noProgressAtSameRetryAfter >= MAX_IDENTICAL_RETRY_AFTER_NO_PROGRESS
771
824
  ) {
772
- return {
825
+ return out({
773
826
  status: 'deferred-flood',
774
827
  popped,
775
828
  detail: `flood: ${consecutiveFloods} consecutive 429s, retry_after=${d.seconds}s`,
776
- }
829
+ })
777
830
  }
778
831
  // Honour retry_after, then double it for the next one (3→6→12→24).
779
832
  await deps.sleep(d.seconds * 1000 * Math.pow(FLOOD_BACKOFF_FACTOR, consecutiveFloods - 1))
780
833
  continue
781
834
  }
782
- return { status: 'error', popped, detail: d.detail }
835
+ return out({ status: 'error', popped, detail: d.detail })
783
836
  }
784
837
  // The unpin's RESULT is deliberately ignored — it resolves ok:true on a
785
838
  // silent no-op. Only the next observed top decides whether this popped.
786
839
  if (!(await awaitWriteSlot(kind))) {
787
- return { status: 'deferred-budget', popped, detail: 'per-minute pin-op budget spent' }
840
+ return out({ status: 'deferred-budget', popped, detail: 'per-minute pin-op budget spent' })
788
841
  }
789
842
  try {
790
843
  await deps.unpin(target.chatId, observed.top)
@@ -792,10 +845,10 @@ export function createStalePinSweeper(deps: StalePinSweepDeps): StalePinSweeper
792
845
  const d = classify(err)
793
846
  if (d.kind === 'breaker') {
794
847
  circuitOpen = true
795
- return { status: 'aborted-circuit-breaker', popped, detail: d.detail }
848
+ return out({ status: 'aborted-circuit-breaker', popped, detail: d.detail })
796
849
  }
797
- if (d.kind === 'rights') return { status: 'skipped-no-rights', popped }
798
- if (isNothingToUnpinError(err)) return { status: 'drained', popped }
850
+ if (d.kind === 'rights') return out({ status: 'skipped-no-rights', popped })
851
+ if (isNothingToUnpinError(err)) return out({ status: 'drained', popped })
799
852
  if (d.kind === 'flood') {
800
853
  consecutiveFloods++
801
854
  if (lastRetryAfter === d.seconds) noProgressAtSameRetryAfter++
@@ -805,18 +858,23 @@ export function createStalePinSweeper(deps: StalePinSweepDeps): StalePinSweeper
805
858
  consecutiveFloods >= MAX_CONSECUTIVE_FLOOD_WAITS ||
806
859
  noProgressAtSameRetryAfter >= MAX_IDENTICAL_RETRY_AFTER_NO_PROGRESS
807
860
  ) {
808
- return {
861
+ return out({
809
862
  status: 'deferred-flood',
810
863
  popped,
811
864
  detail: `flood: ${consecutiveFloods} consecutive 429s, retry_after=${d.seconds}s`,
812
- }
865
+ })
813
866
  }
814
867
  await deps.sleep(d.seconds * 1000 * Math.pow(FLOOD_BACKOFF_FACTOR, consecutiveFloods - 1))
815
868
  continue
816
869
  }
817
- return { status: 'error', popped, detail: d.detail }
870
+ return out({ status: 'error', popped, detail: d.detail })
818
871
  }
819
872
  consecutiveFloods = 0
873
+ // The removal went out. It is `issued` NOW — unconditionally, before the
874
+ // observation that may or may not turn it into a `popped`. That ordering
875
+ // is the fix for #3956: an exit taken before the next verified read can
876
+ // no longer make this call disappear from the accounting.
877
+ issued++
820
878
  unverifiedPop = true
821
879
  }
822
880
 
@@ -824,12 +882,12 @@ export function createStalePinSweeper(deps: StalePinSweepDeps): StalePinSweeper
824
882
  // is the ONLY thing allowed to report `drained`.
825
883
  const finalRead = await readTopVerified(target.chatId)
826
884
  if (unverifiedPop && finalRead != null && finalRead.top !== lastObservedTop) creditPop()
827
- if (finalRead != null && finalRead.top == null) return { status: 'drained', popped }
828
- return {
885
+ if (finalRead != null && finalRead.top == null) return out({ status: 'drained', popped })
886
+ return out({
829
887
  status: 'deferred-budget',
830
888
  popped,
831
889
  detail: `hit the ${gate.maxPopsPerChatPerSweep}-pop cap for this chat; yielding to next boot`,
832
- }
890
+ })
833
891
  }
834
892
 
835
893
  /**
@@ -962,6 +1020,16 @@ export function createStalePinSweeper(deps: StalePinSweepDeps): StalePinSweeper
962
1020
  * place, which is why it is gated on {@link mayUnpinAllForumTopic}. A 429 here
963
1021
  * is the pegged TDLib re-issue loop, so it is a give-up, not a backoff — see
964
1022
  * the module docblock.
1023
+ *
1024
+ * Counting is the SAME contract as every other branch (#3955): the resolved
1025
+ * call is one `issued`, and `popped` is credited only from a `getChat` top
1026
+ * that demonstrably moved between the before- and after-reads. It is not a
1027
+ * distinction without a difference here — the verb is topic-scoped while the
1028
+ * only readable state is the CHAT-wide top, so a drained topic in a chat whose
1029
+ * top belongs to another topic legitimately moves nothing. That is still a
1030
+ * discharged obligation (`drained`), it is just not an observed pop, and
1031
+ * crediting one would make this branch's `popped` a call counter wearing a
1032
+ * pop counter's name.
965
1033
  */
966
1034
  const forumTopicDrain = async (
967
1035
  target: SweepTarget,
@@ -969,10 +1037,22 @@ export function createStalePinSweeper(deps: StalePinSweepDeps): StalePinSweeper
969
1037
  ): Promise<SweepResult> => {
970
1038
  const threadId = target.threadId
971
1039
  if (threadId == null) {
972
- return { status: 'skipped-nothing-recorded', popped: 0, detail: 'no message_thread_id' }
1040
+ return {
1041
+ status: 'skipped-nothing-recorded',
1042
+ popped: 0,
1043
+ issued: 0,
1044
+ detail: 'no message_thread_id',
1045
+ }
973
1046
  }
1047
+ // Read BEFORE the write, so "did the top move?" is answerable afterwards.
1048
+ const before = await readTopVerified(target.chatId)
974
1049
  if (!(await awaitWriteSlot('forum-topic'))) {
975
- return { status: 'deferred-budget', popped: 0, detail: 'per-minute pin-op budget spent' }
1050
+ return {
1051
+ status: 'deferred-budget',
1052
+ popped: 0,
1053
+ issued: 0,
1054
+ detail: 'per-minute pin-op budget spent',
1055
+ }
976
1056
  }
977
1057
  try {
978
1058
  await deps.unpinAllForumTopicMessages(target.chatId, threadId)
@@ -980,9 +1060,9 @@ export function createStalePinSweeper(deps: StalePinSweepDeps): StalePinSweeper
980
1060
  const d = classify(err)
981
1061
  if (d.kind === 'breaker') {
982
1062
  circuitOpen = true
983
- return { status: 'aborted-circuit-breaker', popped: 0, detail: d.detail }
1063
+ return { status: 'aborted-circuit-breaker', popped: 0, issued: 0, detail: d.detail }
984
1064
  }
985
- if (d.kind === 'rights') return { status: 'skipped-no-rights', popped: 0 }
1065
+ if (d.kind === 'rights') return { status: 'skipped-no-rights', popped: 0, issued: 0 }
986
1066
  if (d.kind === 'flood') {
987
1067
  // DO NOT retry. `run_affected_history_query_until_complete` re-issues
988
1068
  // the identical query while `!is_final_`, re-tripping the same server
@@ -990,35 +1070,60 @@ export function createStalePinSweeper(deps: StalePinSweepDeps): StalePinSweeper
990
1070
  return {
991
1071
  status: 'deferred-flood',
992
1072
  popped: 0,
1073
+ issued: 0,
993
1074
  detail: `unpinAllForumTopicMessages pegged at retry_after=${d.seconds}s; not looping`,
994
1075
  }
995
1076
  }
996
- return { status: 'error', popped: 0, detail: d.detail }
1077
+ return { status: 'error', popped: 0, issued: 0, detail: d.detail }
997
1078
  }
998
- cursor.popped++
999
- cursor.updatedAt = deps.now()
1000
- commit(cursor)
1079
+ // The call resolved: that is exactly ONE issued removal, and — per the
1080
+ // module invariant — no evidence of anything at all. Only the read below
1081
+ // may credit a pop, and only the durable counter it credits is `popped`.
1001
1082
  const observed = await readTopVerified(target.chatId)
1002
- if (observed != null && observed.top == null) return { status: 'drained', popped: 1 }
1083
+ const topMoved =
1084
+ before != null && observed != null && observed.top !== before.top ? 1 : 0
1085
+ if (topMoved > 0) {
1086
+ cursor.popped += topMoved
1087
+ cursor.updatedAt = deps.now()
1088
+ commit(cursor)
1089
+ }
1090
+ if (observed != null && observed.top == null) {
1091
+ return { status: 'drained', popped: topMoved, issued: 1 }
1092
+ }
1003
1093
  // The chat-wide stack may legitimately still hold pins belonging to OTHER
1004
1094
  // topics — the topic-scoped verb only drains this one. That is a success
1005
1095
  // for this obligation, not an incomplete drain.
1006
- return { status: 'drained', popped: 1, detail: 'topic drained; chat-wide stack may hold other topics' }
1096
+ return {
1097
+ status: 'drained',
1098
+ popped: topMoved,
1099
+ issued: 1,
1100
+ detail: 'topic drained; chat-wide stack may hold other topics',
1101
+ }
1007
1102
  }
1008
1103
 
1009
1104
  const sweepOnce = async (target: SweepTarget): Promise<SweepResult> => {
1010
- if (!deps.eligible()) return { status: 'skipped-not-eligible', popped: 0 }
1105
+ if (!deps.eligible()) return { status: 'skipped-not-eligible', popped: 0, issued: 0 }
1011
1106
  if (circuitOpen) {
1012
- return { status: 'aborted-circuit-breaker', popped: 0, detail: 'breaker already open' }
1107
+ return {
1108
+ status: 'aborted-circuit-breaker',
1109
+ popped: 0,
1110
+ issued: 0,
1111
+ detail: 'breaker already open',
1112
+ }
1013
1113
  }
1014
1114
  const kind = classifyChatForSweep(target)
1015
1115
  const cursor = loadCursor(target, kind)
1016
- if (cursor.done) return { status: 'already-drained', popped: 0 }
1116
+ if (cursor.done) return { status: 'already-drained', popped: 0, issued: 0 }
1017
1117
  // Attempt budget exhausted: a permanently-undrainable chat (bot kicked,
1018
1118
  // rights revoked, a chat that flood-waits forever) must not re-burn the pop
1019
1119
  // budget on every boot for the rest of time.
1020
1120
  if (cursor.attempts >= SWEEP_MAX_ATTEMPTS) {
1021
- return { status: 'forfeited', popped: 0, detail: `${cursor.attempts} attempts exhausted` }
1121
+ return {
1122
+ status: 'forfeited',
1123
+ popped: 0,
1124
+ issued: 0,
1125
+ detail: `${cursor.attempts} attempts exhausted`,
1126
+ }
1022
1127
  }
1023
1128
 
1024
1129
  // RIGHTS PRECHECK, before ANY write, in every group. A bot without
@@ -1037,7 +1142,7 @@ export function createStalePinSweeper(deps: StalePinSweepDeps): StalePinSweeper
1037
1142
  cursor.lastStatus = 'skipped-no-rights'
1038
1143
  cursor.updatedAt = deps.now()
1039
1144
  commit(cursor)
1040
- return { status: 'skipped-no-rights', popped: 0 }
1145
+ return { status: 'skipped-no-rights', popped: 0, issued: 0 }
1041
1146
  }
1042
1147
  }
1043
1148
 
@@ -1078,7 +1183,7 @@ export function createStalePinSweeper(deps: StalePinSweepDeps): StalePinSweeper
1078
1183
  : await targetedUnpinDrain(target, kind, cursor, ids)
1079
1184
  }
1080
1185
  } catch (err) {
1081
- result = { status: 'error', popped: 0, detail: (err as Error).message }
1186
+ result = { status: 'error', popped: 0, issued: 0, detail: (err as Error).message }
1082
1187
  }
1083
1188
 
1084
1189
  // `skipped-nothing-recorded` discharges the obligation too: there is no id
@@ -1134,8 +1239,12 @@ export function createStalePinSweeper(deps: StalePinSweepDeps): StalePinSweeper
1134
1239
  // Not eligible yet ⇒ nothing was attempted, so do NOT consume the one
1135
1240
  // allowed attempt: the boot sweep must still get its turn once the mutex
1136
1241
  // is won.
1137
- if (!deps.eligible()) return Promise.resolve({ status: 'skipped-not-eligible', popped: 0 })
1138
- if (attempted.has(key)) return Promise.resolve({ status: 'already-attempted', popped: 0 })
1242
+ if (!deps.eligible()) {
1243
+ return Promise.resolve({ status: 'skipped-not-eligible', popped: 0, issued: 0 })
1244
+ }
1245
+ if (attempted.has(key)) {
1246
+ return Promise.resolve({ status: 'already-attempted', popped: 0, issued: 0 })
1247
+ }
1139
1248
  attempted.add(key)
1140
1249
  const p = sweepOnce(target).finally(() => inFlight.delete(key))
1141
1250
  inFlight.set(key, p)
@@ -117,19 +117,43 @@ export interface PersistedStatusPin {
117
117
  * re-fail on every boot forever. */
118
118
  export const BOOT_UNPIN_MAX_ATTEMPTS = 5
119
119
 
120
- /** Envelope version. v1 had no `pending` field; a v1 row loads as a confirmed
121
- * pin (pending undefined). v2 adds the optional `pending` flag. v3 adds the
122
- * optional `pinnedAt` claim timestamp (#3810). v4 adds the optional `threadId`
123
- * so the claim is keyed by (chat, thread) and a forum topic can be drained
124
- * after a crash. All load fail-open an unknown/newer version yields [].
125
- * Every field added since v1 is optional, so the versions are mutually
126
- * readable and a downgrade degrades rather than breaks. */
120
+ /**
121
+ * Envelope version. v1 had no `pending` field; a v1 row loads as a confirmed
122
+ * pin (pending undefined). v2 adds the optional `pending` flag. v3 adds the
123
+ * optional `pinnedAt` claim timestamp (#3810). v4 adds the optional `threadId`
124
+ * so the claim is keyed by (chat, thread) and a forum topic can be drained
125
+ * after a crash.
126
+ *
127
+ * ── The bump rule, which the reader depends on (#3957) ───────────────────────
128
+ *
129
+ * EVERY field added since v1 is OPTIONAL, and every future bump must keep that
130
+ * property. That is what makes the versions mutually readable in both
131
+ * directions, and {@link loadStatusPins} now cashes it in: an envelope whose
132
+ * version this build does not know is still READ, row by row, through the same
133
+ * structural validator. Rows it cannot validate are dropped; rows it can are
134
+ * kept.
135
+ *
136
+ * Why that matters more than it looks: this file is the boot cleanup's only
137
+ * record of which messages the gateway pinned. A reader that discarded the
138
+ * whole file on an unfamiliar version turned every pin taken by the newer build
139
+ * into a permanent orphan the moment an operator rolled back — manufacturing
140
+ * exactly the bug this subsystem exists to prevent. Degrading (keep what
141
+ * validates) is strictly safer than failing open to `[]` here, because the
142
+ * fail-open outcome is not "do nothing", it is "forget an obligation".
143
+ *
144
+ * A bump that ever needs to be NON-additive — a field whose absence changes the
145
+ * meaning of a row — must therefore change the FILENAME, not just `v`, so old
146
+ * readers see "no file" rather than misreading rows. Do not quietly break the
147
+ * additive rule and leave `v` to carry it.
148
+ */
127
149
  interface SnapshotEnvelope {
128
- v: 1 | 2 | 3 | 4
150
+ v: number
129
151
  pins: PersistedStatusPin[]
130
152
  }
131
153
 
132
- const SNAPSHOT_VERSIONS = new Set([1, 2, 3, 4])
154
+ /** The version THIS build writes. Reading is deliberately version-tolerant
155
+ * (see above); only the writer is pinned. */
156
+ const SNAPSHOT_VERSION = 4
133
157
 
134
158
  function isPinRow(x: unknown): x is PersistedStatusPin {
135
159
  if (x == null || typeof x !== 'object') return false
@@ -153,6 +177,13 @@ function isPinRow(x: unknown): x is PersistedStatusPin {
153
177
  * file (fail-open to empty: a corrupt snapshot must never crash boot — worst
154
178
  * case an orphaned pin isn't cleaned up this boot, strictly no worse than the
155
179
  * pre-persistence behaviour).
180
+ *
181
+ * An UNRECOGNISED envelope version is NOT malformed and does not fail open
182
+ * (#3957). Any positive-integer `v` with an array of `pins` is read, and each
183
+ * row is kept iff {@link isPinRow} can structurally validate it. A row written
184
+ * by a newer build therefore survives a downgrade instead of being discarded
185
+ * into a permanent orphan — see the {@link SnapshotEnvelope} bump rule for the
186
+ * additive-fields property this relies on.
156
187
  */
157
188
  export function loadStatusPins(
158
189
  path: string,
@@ -173,7 +204,9 @@ export function loadStatusPins(
173
204
  }
174
205
  if (parsed == null || typeof parsed !== 'object') return []
175
206
  const env = parsed as Record<string, unknown>
176
- if (typeof env.v !== 'number' || !SNAPSHOT_VERSIONS.has(env.v) || !Array.isArray(env.pins)) return []
207
+ // Version-TOLERANT, structurally strict: an unknown `v` still yields the rows
208
+ // that validate. Only a shape that is not an envelope at all fails open.
209
+ if (!Number.isInteger(env.v) || (env.v as number) < 1 || !Array.isArray(env.pins)) return []
177
210
  return env.pins.filter(isPinRow)
178
211
  }
179
212
 
@@ -189,7 +222,7 @@ export function persistStatusPins(
189
222
  snapshot: readonly PersistedStatusPin[],
190
223
  log: (line: string) => void = (l) => process.stderr.write(l),
191
224
  ): void {
192
- const env: SnapshotEnvelope = { v: 4, pins: [...snapshot] }
225
+ const env: SnapshotEnvelope = { v: SNAPSHOT_VERSION, pins: [...snapshot] }
193
226
  const tmp = path + '.tmp'
194
227
  try {
195
228
  fs.writeFileSync(tmp, JSON.stringify(env))