instar 1.3.731 → 1.3.733

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 (69) hide show
  1. package/dist/commands/server.d.ts.map +1 -1
  2. package/dist/commands/server.js +208 -2
  3. package/dist/commands/server.js.map +1 -1
  4. package/dist/config/ConfigDefaults.d.ts.map +1 -1
  5. package/dist/config/ConfigDefaults.js +26 -0
  6. package/dist/config/ConfigDefaults.js.map +1 -1
  7. package/dist/core/ConversationRegistry.d.ts +355 -0
  8. package/dist/core/ConversationRegistry.d.ts.map +1 -0
  9. package/dist/core/ConversationRegistry.js +1438 -0
  10. package/dist/core/ConversationRegistry.js.map +1 -0
  11. package/dist/core/PostUpdateMigrator.d.ts +12 -0
  12. package/dist/core/PostUpdateMigrator.d.ts.map +1 -1
  13. package/dist/core/PostUpdateMigrator.js +80 -0
  14. package/dist/core/PostUpdateMigrator.js.map +1 -1
  15. package/dist/core/SessionManager.d.ts +15 -0
  16. package/dist/core/SessionManager.d.ts.map +1 -1
  17. package/dist/core/SessionManager.js +28 -0
  18. package/dist/core/SessionManager.js.map +1 -1
  19. package/dist/core/conversationBindToken.d.ts +36 -0
  20. package/dist/core/conversationBindToken.d.ts.map +1 -0
  21. package/dist/core/conversationBindToken.js +144 -0
  22. package/dist/core/conversationBindToken.js.map +1 -0
  23. package/dist/core/conversationIdentity.d.ts +126 -0
  24. package/dist/core/conversationIdentity.d.ts.map +1 -0
  25. package/dist/core/conversationIdentity.js +160 -0
  26. package/dist/core/conversationIdentity.js.map +1 -0
  27. package/dist/core/deliverToConversation.d.ts +105 -0
  28. package/dist/core/deliverToConversation.d.ts.map +1 -0
  29. package/dist/core/deliverToConversation.js +374 -0
  30. package/dist/core/deliverToConversation.js.map +1 -0
  31. package/dist/core/devGatedFeatures.d.ts.map +1 -1
  32. package/dist/core/devGatedFeatures.js +6 -0
  33. package/dist/core/devGatedFeatures.js.map +1 -1
  34. package/dist/core/slackRefreshBinding.d.ts +8 -10
  35. package/dist/core/slackRefreshBinding.d.ts.map +1 -1
  36. package/dist/core/slackRefreshBinding.js +8 -16
  37. package/dist/core/slackRefreshBinding.js.map +1 -1
  38. package/dist/core/types.d.ts +46 -0
  39. package/dist/core/types.d.ts.map +1 -1
  40. package/dist/core/types.js.map +1 -1
  41. package/dist/monitoring/CommitmentTracker.d.ts +64 -0
  42. package/dist/monitoring/CommitmentTracker.d.ts.map +1 -1
  43. package/dist/monitoring/CommitmentTracker.js +58 -0
  44. package/dist/monitoring/CommitmentTracker.js.map +1 -1
  45. package/dist/monitoring/PromiseBeacon.d.ts +70 -0
  46. package/dist/monitoring/PromiseBeacon.d.ts.map +1 -1
  47. package/dist/monitoring/PromiseBeacon.js +221 -24
  48. package/dist/monitoring/PromiseBeacon.js.map +1 -1
  49. package/dist/scaffold/templates.d.ts.map +1 -1
  50. package/dist/scaffold/templates.js +10 -0
  51. package/dist/scaffold/templates.js.map +1 -1
  52. package/dist/server/AgentServer.d.ts +12 -0
  53. package/dist/server/AgentServer.d.ts.map +1 -1
  54. package/dist/server/AgentServer.js +20 -0
  55. package/dist/server/AgentServer.js.map +1 -1
  56. package/dist/server/CapabilityIndex.d.ts.map +1 -1
  57. package/dist/server/CapabilityIndex.js +14 -0
  58. package/dist/server/CapabilityIndex.js.map +1 -1
  59. package/dist/server/routes.d.ts +17 -0
  60. package/dist/server/routes.d.ts.map +1 -1
  61. package/dist/server/routes.js +206 -10
  62. package/dist/server/routes.js.map +1 -1
  63. package/package.json +1 -1
  64. package/src/data/builtin-manifest.json +65 -65
  65. package/src/scaffold/templates.ts +10 -0
  66. package/upgrades/1.3.732.md +107 -0
  67. package/upgrades/1.3.733.md +108 -0
  68. package/upgrades/side-effects/durable-conversation-identity-increment1.md +89 -0
  69. package/upgrades/side-effects/durable-conversation-identity-increment2.md +88 -0
@@ -0,0 +1,1438 @@
1
+ /**
2
+ * ConversationRegistry — durable, channel-agnostic conversation identity
3
+ * (docs/specs/durable-conversation-identity.md §3, increment 1: registry +
4
+ * crash-proof journal + eager mint foundation).
5
+ *
6
+ * The registry is the JOIN TABLE between canonical key (string), structured
7
+ * tuple, and minted NEGATIVE numeric id (§2). It is SPARSE: Telegram positive
8
+ * ids are NEVER registered (a positive id IS its own identity, forever);
9
+ * only minted (non-Telegram) conversations live here.
10
+ *
11
+ * Durability model (§3.3/§3.4 — the WAL rule):
12
+ * - The id is assigned SYNCHRONOUSLY in-memory (probe included) against the
13
+ * authoritative cache + reverse index, so the id RETURNED always equals
14
+ * the id that will PERSIST — no misdelivery window.
15
+ * - A PROBED or durable-binding-forced mint append+fsyncs ONE journal line
16
+ * to `<stateDir>/conversation-registry.jsonl` (the §3.4 journal-path PIN:
17
+ * stateDir ROOT, beside shared-state.jsonl — the one shape the deployed
18
+ * BackupManager.expandGlob actually expands, R3-C4) BEFORE the id is
19
+ * handed to the caller.
20
+ * - A pure speculative non-probed mint appends its audit line WITHOUT fsync
21
+ * (§3.4 fsync discipline; §8 audit completeness) — its candidate re-mints
22
+ * deterministically for free after a crash.
23
+ * - The O(N) full-store JSON snapshot (`<stateDir>/state/conversation-registry.json`)
24
+ * is BATCHED off the hot path with a SIZE-ADAPTIVE interval (§3.4 —
25
+ * the CommitmentTracker 2026-06-21 freeze precedent), and moves to an
26
+ * off-loop write past the pinned trigger (>20k entries / >2MB).
27
+ *
28
+ * Increment scope note: the §3.5/§3.5.1 replication merge, the §3.5.2 bind-pin
29
+ * overlay WRITERS, and the §5.0(a) E1 dedup WRITERS land in their own §6.1
30
+ * increments. The journal op ENUM, replay application, and snapshot
31
+ * completeness for those ops ship NOW (§3.4 record framing is frozen), so a
32
+ * later increment's records — and a rollback across one — replay correctly.
33
+ */
34
+ import fs from 'node:fs';
35
+ import path from 'node:path';
36
+ import { SafeFsExecutor } from './SafeFsExecutor.js';
37
+ import { candidateIdForRoutingKey, canonicalKeyFor, parseCanonicalKey, routingKeyForTuple, SLACK_WORKSPACE_ID_RE, tupleForRoutingKey, tupleKeyFor, walkDisplacement, WORKSPACE_PLACEHOLDER, } from './conversationIdentity.js';
38
+ /** §3.4 record framing — ONE self-contained JSON object per line. FROZEN op enum. */
39
+ export const JOURNAL_OPS = [
40
+ 'mint',
41
+ 'alias',
42
+ 'reachability',
43
+ 'bind-pin',
44
+ 'bind-release',
45
+ 'ambiguous-send',
46
+ 'send-retire',
47
+ 'send-intent',
48
+ 'send-intent-resolved',
49
+ ];
50
+ /** §5.0(a) pinned E1 constants (R3-M1/R7-M2). Exported for the §10 tests. */
51
+ export const AMBIGUOUS_DEDUP_TTL_MS = 604800000; // 7 days — safety bound ONLY, never the suppression mechanism (logical lane)
52
+ export const CONTENT_HASH_DEDUP_WINDOW_MS = 900000; // 15 min — the fallback lane's WINDOW (mirrors the Telegram exact-duplicate window)
53
+ /** §5.0(a) pathological TRIPWIRE, not a correctness bound (R4-M2/R5-minor-1):
54
+ * reached-with-all-live raises ONE aggregated attention item while the new
55
+ * entry is STILL journaled + retained — nothing is ever dropped. */
56
+ export const AMBIGUOUS_SENDS_HARD_CAP = 1000;
57
+ /** §5.1 reachability flap dampening (R3-minor). */
58
+ export const REACHABILITY_FLAP_THRESHOLD = 3;
59
+ export const REACHABILITY_FLAP_WINDOW_MS = 86400000; // 24h
60
+ const ENTRY_CEILING = 50000; // §3.4 JSON-store design ceiling
61
+ const ENTRY_THRESHOLD = 40000; // 80% of ceiling — the pinned tripwire (R3-minor)
62
+ const FILE_BYTES_THRESHOLD = 8 * 1024 * 1024; // 80% of ~10MB
63
+ const OFFLOOP_ENTRY_TRIGGER = 20000; // §3.4 pinned off-loop flush trigger
64
+ const OFFLOOP_BYTES_TRIGGER = 2 * 1024 * 1024;
65
+ const PENDING_MINT_MAX = 1000; // §3.6 pinned
66
+ const BREAKER_MAX_TRACKED_CHANNELS = 512; // bounded budget-state map (§3.3, R3-minor)
67
+ export class ConversationRegistry {
68
+ d;
69
+ snapshotPath;
70
+ journalPath;
71
+ // ── Authoritative in-memory cache + synchronous indexes (§3.4 1–2) ──
72
+ conversations = new Map(); // canonical key → entry
73
+ byId = new Map(); // id→entry reverse index
74
+ byTuple = new Map(); // tupleKey → entry
75
+ aliases = new Map(); // loserId → winnerId (one hop)
76
+ // ── Derived indexes (§3.4 3–5) — rebuilt at boot, maintained at assign time ──
77
+ reservedCanonicals = new Map(); // cand → owning tupleKey
78
+ displacedAssignments = new Map(); // offset → owning tupleKey (GLOBAL — R4-C1)
79
+ candClaimants = new Map(); // cand → claimant tupleKeys
80
+ // ── §5.0(a)/§3.5.2 journal-applied state (writers: §6.1 increment 2) ──
81
+ bindPins = new Map();
82
+ ambiguousSends = new Map();
83
+ sendIntents = new Map();
84
+ /** R9-minor-1: parseable send-intent lines with the lane field STRIPPED —
85
+ * resolved toward RETRY at replay, surfaced once (never a suppressor). */
86
+ missingLaneIntents = 0;
87
+ /** §5.1 reachability flap tracking (in-memory, advisory — R3-minor). */
88
+ reachabilityFlips = new Map();
89
+ workspacePin = null;
90
+ // ── Journal state ──
91
+ seqCounter = 0;
92
+ snapshotHighWaterSeq = 0;
93
+ journalFd = null;
94
+ journalBytes = 0;
95
+ journalLines = 0;
96
+ /** Unknown-op skip-and-preserve set (R8-minor-2) → snapshot-flush SUSPENSION (R9-M1/R10-M1). */
97
+ unappliedUnknownOps = [];
98
+ // ── Snapshot batching ──
99
+ snapshotTimer = null;
100
+ snapshotDirty = false;
101
+ lastSnapshotBytes = 0;
102
+ batchDepth = 0; // adoption-pass batched-save window (§6.2)
103
+ flushInFlight = false;
104
+ // ── Breaker + degradation state ──
105
+ breakerWindows = new Map();
106
+ breakerEpisodes = 0;
107
+ pendingMints = new Map();
108
+ pendingMintDrops = 0;
109
+ // ── Observability ──
110
+ lastMintAt = null;
111
+ durabilityIncidents = 0;
112
+ snapshotQuarantinedAt = null;
113
+ journalQuarantinedAt = null;
114
+ adoptionState = null;
115
+ loaded = false;
116
+ constructor(deps) {
117
+ this.d = deps;
118
+ this.snapshotPath = path.join(deps.stateDir, 'state', 'conversation-registry.json');
119
+ this.journalPath = path.join(deps.stateDir, 'conversation-registry.jsonl');
120
+ }
121
+ now() {
122
+ return (this.d.now ?? (() => new Date()))();
123
+ }
124
+ log(line) {
125
+ try {
126
+ this.d.log?.(line);
127
+ }
128
+ catch {
129
+ /* observability never gates */
130
+ }
131
+ }
132
+ attention(dedupeKey, title, body) {
133
+ try {
134
+ this.d.onAttention?.(dedupeKey, title, body);
135
+ }
136
+ catch {
137
+ /* attention is observability — never gates identity or delivery */
138
+ }
139
+ }
140
+ recordingEnabled() {
141
+ try {
142
+ return this.d.isRecordingEnabled ? this.d.isRecordingEnabled() !== false : true;
143
+ }
144
+ catch {
145
+ return true;
146
+ }
147
+ }
148
+ // ─────────────────────────────────────────────────────────────────────────
149
+ // Boot: snapshot load + journal replay (§3.4 WAL crash-consistency contract)
150
+ // ─────────────────────────────────────────────────────────────────────────
151
+ /** Idempotent boot-time load: snapshot, then journal tail replay in global
152
+ * `seq` order across rotated files (§6.2 recovery order 1–2). */
153
+ load() {
154
+ if (this.loaded)
155
+ return;
156
+ this.loaded = true;
157
+ try {
158
+ fs.mkdirSync(path.join(this.d.stateDir, 'state'), { recursive: true });
159
+ }
160
+ catch {
161
+ /* exists */
162
+ }
163
+ this.loadSnapshot();
164
+ this.replayJournal();
165
+ this.rebuildDerivedIndexes();
166
+ this.applyAliasAssignmentFilter(); // R6-M3 pin 2: the disjointness invariant holds at every BOOT fixpoint
167
+ this.convertOrphanedSendIntents(); // R8-M1 lane-scoped boot conversion (staged post-compose, fsynced before serving — R9-low-3/R10-minor-1)
168
+ this.pruneSendGuardState();
169
+ if (this.missingLaneIntents > 0) {
170
+ this.attention('conversation-registry:missing-lane-intent', 'Conversation registry: send-intent record(s) with a stripped lane field', `${this.missingLaneIntents} parseable send-intent journal record(s) carried no lane discriminator (R9-minor-1). Each was resolved toward RETRY (content-hash treatment — loss is never silent, never a suppressor). This indicates a serialization bug worth a look.`);
171
+ }
172
+ if (this.unappliedUnknownOps.length > 0) {
173
+ const first = this.unappliedUnknownOps[0];
174
+ const kinds = [...new Set(this.unappliedUnknownOps.map((u) => u.op))].join(', ');
175
+ this.attention('conversation-registry:unknown-op', 'Conversation registry journal holds records from a newer version', `${this.unappliedUnknownOps.length} journal record(s) with unrecognized op kind(s) [${kinds}] were skipped-and-preserved (version skew, not corruption — R8-minor-2). SNAPSHOT FLUSHING IS SUSPENDED (R9-M1/R10-M1): the on-disk snapshot stays the pre-skew one (high-water ${this.snapshotHighWaterSeq}, first unapplied seq ${first.seq}) until a recognizing version re-upgrades and replays them in position. Journal retention grows for the suspension's duration.`);
176
+ }
177
+ // Resolve the config workspace pin (source 1 — authoritative when present).
178
+ const configPin = this.readConfigPin();
179
+ if (configPin) {
180
+ this.workspacePin = { value: configPin, source: 'config', confirmedLocally: true };
181
+ }
182
+ }
183
+ readConfigPin() {
184
+ try {
185
+ const v = this.d.getConfigWorkspacePin?.();
186
+ return v && SLACK_WORKSPACE_ID_RE.test(v) ? v : undefined;
187
+ }
188
+ catch {
189
+ return undefined;
190
+ }
191
+ }
192
+ loadSnapshot() {
193
+ if (!fs.existsSync(this.snapshotPath))
194
+ return;
195
+ try {
196
+ const raw = JSON.parse(fs.readFileSync(this.snapshotPath, 'utf-8'));
197
+ if (!raw || typeof raw !== 'object' || raw.version !== 1)
198
+ throw new Error('unrecognized snapshot shape');
199
+ this.snapshotHighWaterSeq = typeof raw.snapshotHighWaterSeq === 'number' ? raw.snapshotHighWaterSeq : 0;
200
+ this.seqCounter = this.snapshotHighWaterSeq;
201
+ if (raw.workspacePin && typeof raw.workspacePin.value === 'string')
202
+ this.workspacePin = raw.workspacePin;
203
+ for (const [key, entry] of Object.entries(raw.conversations ?? {})) {
204
+ if (!entry || typeof entry.id !== 'number' || !(entry.id < 0) || !Number.isSafeInteger(entry.id))
205
+ continue; // id<0 clamp on every write (§3.3)
206
+ this.indexEntry(key, entry);
207
+ }
208
+ for (const [loser, winner] of Object.entries(raw.aliases ?? {})) {
209
+ const l = Number(loser);
210
+ if (Number.isSafeInteger(l) && l < 0 && typeof winner === 'number' && winner < 0)
211
+ this.aliases.set(l, winner);
212
+ }
213
+ for (const [id, pin] of Object.entries(raw.bindPins ?? {})) {
214
+ const n = Number(id);
215
+ if (Number.isSafeInteger(n) && pin && Array.isArray(pin.tuple) && typeof pin.refcount === 'number') {
216
+ this.bindPins.set(n, { tuple: pin.tuple, refcount: pin.refcount });
217
+ }
218
+ }
219
+ for (const [k, v] of Object.entries(raw.ambiguousSends ?? {})) {
220
+ if (v && typeof v.recordedAt === 'string') {
221
+ this.ambiguousSends.set(k, {
222
+ recordedAt: v.recordedAt,
223
+ ...(v.lane === 'logical' || v.lane === 'content-hash' ? { lane: v.lane } : {}),
224
+ });
225
+ }
226
+ }
227
+ for (const [k, v] of Object.entries(raw.sendIntents ?? {})) {
228
+ if (v && (v.lane === 'logical' || v.lane === 'content-hash') && typeof v.seq === 'number')
229
+ this.sendIntents.set(k, v);
230
+ }
231
+ this.lastSnapshotBytes = fs.statSync(this.snapshotPath).size;
232
+ }
233
+ catch (err) {
234
+ // Corrupt-file quarantine-aside (§3.6 — the TopicPlacementPinStore
235
+ // pattern): preserve aside, ONE deduped attention item, rebuild from the
236
+ // journal (a journal-only rebuild replays every retained file from empty
237
+ // state in global seq order — §3.4 rotation note).
238
+ const aside = `${this.snapshotPath}.corrupt-${Date.now()}`;
239
+ try {
240
+ fs.renameSync(this.snapshotPath, aside);
241
+ }
242
+ catch {
243
+ /* best-effort — the report below still fires */
244
+ }
245
+ this.snapshotQuarantinedAt = this.now().toISOString();
246
+ this.snapshotHighWaterSeq = 0;
247
+ this.seqCounter = 0;
248
+ this.attention('conversation-registry:snapshot-corrupt', 'Conversation registry snapshot was corrupt — quarantined aside', `state/conversation-registry.json failed to parse (${err instanceof Error ? err.message : String(err)}) and was preserved at ${aside}. Rebuilding from the journal (§6.2 recovery order: backup restore is the PRIMARY path if the journal is also damaged).`);
249
+ }
250
+ }
251
+ /** Retained journal files in replay order: rotated (ascending epoch) then live. */
252
+ journalFiles() {
253
+ const dir = this.d.stateDir;
254
+ const base = path.basename(this.journalPath);
255
+ let names = [];
256
+ try {
257
+ names = fs.readdirSync(dir).filter((n) => n.startsWith(`${base}.`) && /\.\d+$/.test(n));
258
+ }
259
+ catch {
260
+ names = [];
261
+ }
262
+ const rotated = names
263
+ .map((n) => ({ n, epoch: Number(n.slice(base.length + 1)) }))
264
+ .sort((a, b) => a.epoch - b.epoch)
265
+ .map((x) => path.join(dir, x.n));
266
+ return fs.existsSync(this.journalPath) ? [...rotated, this.journalPath] : rotated;
267
+ }
268
+ replayJournal() {
269
+ const files = this.journalFiles();
270
+ if (files.length === 0)
271
+ return;
272
+ // Torn-tail handling (§3.4): a crash mid-append leaves the LIVE file's last
273
+ // line unterminated — only a fully-written, newline-terminated line is a
274
+ // committed record. The torn tail is DISCARDED (truncated) so later appends
275
+ // can never fuse onto an uncommitted fragment.
276
+ if (fs.existsSync(this.journalPath)) {
277
+ try {
278
+ const buf = fs.readFileSync(this.journalPath);
279
+ if (buf.length > 0 && buf[buf.length - 1] !== 0x0a) {
280
+ const lastNl = buf.lastIndexOf(0x0a);
281
+ fs.truncateSync(this.journalPath, lastNl === -1 ? 0 : lastNl + 1);
282
+ this.log('[conversation-registry] discarded torn journal tail (uncommitted record)');
283
+ }
284
+ }
285
+ catch {
286
+ /* read failure falls through to the per-line handling below */
287
+ }
288
+ }
289
+ const records = [];
290
+ let halted = false;
291
+ for (const file of files) {
292
+ if (halted)
293
+ break;
294
+ let content;
295
+ try {
296
+ content = fs.readFileSync(file, 'utf-8');
297
+ }
298
+ catch {
299
+ continue;
300
+ }
301
+ const lines = content.split('\n');
302
+ for (let i = 0; i < lines.length; i++) {
303
+ const line = lines[i];
304
+ if (line.length === 0)
305
+ continue;
306
+ let rec;
307
+ try {
308
+ rec = JSON.parse(line);
309
+ if (!rec || typeof rec.seq !== 'number' || typeof rec.op !== 'string')
310
+ throw new Error('malformed record');
311
+ }
312
+ catch {
313
+ // NON-tail corruption fails CLOSED (R7-minor-3): a newline-TERMINATED
314
+ // line failing parse is a storage lie, never skipped — HALT the replay
315
+ // into the quarantine-aside path + attention + durability incident.
316
+ const aside = `${file}.corrupt-${Date.now()}`;
317
+ try {
318
+ fs.renameSync(file, aside);
319
+ }
320
+ catch {
321
+ /* preserve best-effort */
322
+ }
323
+ this.journalQuarantinedAt = this.now().toISOString();
324
+ this.durabilityIncidents++; // §3.7 broadened SQLite-migration trigger input
325
+ this.attention('conversation-registry:journal-corrupt', 'Conversation registry journal corruption — replay halted', `A committed (newline-terminated) journal record in ${path.basename(file)} failed to parse at line ${i + 1}. The file was preserved aside at ${aside}; replay halted at that point (records before it are applied). This counts as a DURABILITY INCIDENT (§3.7 — evaluate the SQLite migration). Backup restore is the primary recovery path (§6.2).`);
326
+ halted = true;
327
+ break;
328
+ }
329
+ records.push({ rec, file });
330
+ }
331
+ }
332
+ records.sort((a, b) => a.rec.seq - b.rec.seq); // single global seq order (R3-M14)
333
+ let maxSeq = this.snapshotHighWaterSeq;
334
+ for (const { rec } of records) {
335
+ if (rec.seq > maxSeq)
336
+ maxSeq = rec.seq;
337
+ if (rec.seq <= this.snapshotHighWaterSeq)
338
+ continue; // snapshot already incorporates it
339
+ this.applyJournalRecord(rec);
340
+ }
341
+ // Boot counter resumes from the max seen — never 0/1 (R3-M14).
342
+ this.seqCounter = Math.max(this.seqCounter, maxSeq);
343
+ this.journalBytes = fs.existsSync(this.journalPath) ? fs.statSync(this.journalPath).size : 0;
344
+ this.journalLines = 0;
345
+ if (fs.existsSync(this.journalPath)) {
346
+ try {
347
+ const content = fs.readFileSync(this.journalPath, 'utf-8');
348
+ this.journalLines = content.length === 0 ? 0 : content.split('\n').filter((l) => l.length > 0).length;
349
+ }
350
+ catch {
351
+ /* count is rotation bookkeeping only */
352
+ }
353
+ }
354
+ }
355
+ /** Idempotent, seq-ordered application (§3.4) — safe to re-run any number of times. */
356
+ applyJournalRecord(rec) {
357
+ if (!JOURNAL_OPS.includes(rec.op)) {
358
+ // UNKNOWN-op tolerance (R8-minor-2): version skew, not corruption — skip
359
+ // the application, PRESERVE the line (append-only; never rewritten), and
360
+ // suspend snapshot flushing (R9-M1/R10-M1) until a recognizing version
361
+ // applies it. The deduped attention item is raised once after replay.
362
+ this.unappliedUnknownOps.push({ seq: rec.seq, op: rec.op });
363
+ return;
364
+ }
365
+ switch (rec.op) {
366
+ case 'mint': {
367
+ if (typeof rec.id !== 'number' || !(rec.id < 0) || !Array.isArray(rec.tuple))
368
+ return;
369
+ const tuple = { platform: 'slack', channelId: rec.tuple[1], threadTs: rec.tuple[2] ?? null };
370
+ const tKey = tupleKeyFor(tuple);
371
+ const existing = this.byTuple.get(tKey);
372
+ if (existing) {
373
+ // Re-apply / metadata upgrade (the `_`→teamId upgrade is journaled as
374
+ // op:"mint" with the rewritten key — §3.1): same tuple, refreshed key.
375
+ if (rec.key && rec.key !== canonicalKeyFor(tuple, existing.workspaceId)) {
376
+ const parsed = parseCanonicalKey(rec.key);
377
+ if (parsed && parsed.workspaceId !== existing.workspaceId) {
378
+ this.conversations.delete(canonicalKeyFor(tuple, existing.workspaceId));
379
+ existing.workspaceId = parsed.workspaceId;
380
+ this.conversations.set(rec.key, existing);
381
+ }
382
+ }
383
+ return; // idempotent
384
+ }
385
+ const parsed = rec.key ? parseCanonicalKey(rec.key) : null;
386
+ const entry = {
387
+ id: rec.id,
388
+ platform: 'slack',
389
+ workspaceId: parsed?.workspaceId ?? WORKSPACE_PLACEHOLDER,
390
+ channelId: tuple.channelId,
391
+ threadTs: tuple.threadTs,
392
+ mintedAt: rec.ts,
393
+ mintedBy: rec.hlc?.node ?? 'unknown',
394
+ origin: rec.origin ?? 'adopted-legacy-hash',
395
+ reachability: 'ok',
396
+ hlc: rec.hlc ?? { physical: Date.parse(rec.ts) || 0, logical: 0, node: 'unknown' },
397
+ };
398
+ this.indexEntry(canonicalKeyFor(tuple, entry.workspaceId), entry);
399
+ return;
400
+ }
401
+ case 'alias': {
402
+ if (typeof rec.id === 'number' && typeof rec.target === 'number')
403
+ this.aliases.set(rec.id, rec.target);
404
+ return;
405
+ }
406
+ case 'reachability': {
407
+ if (typeof rec.id !== 'number' || !rec.reachability)
408
+ return;
409
+ const entry = this.byId.get(rec.id);
410
+ if (entry)
411
+ entry.reachability = rec.reachability;
412
+ return;
413
+ }
414
+ case 'bind-pin': {
415
+ if (typeof rec.id !== 'number' || !Array.isArray(rec.tuple))
416
+ return;
417
+ const existing = this.bindPins.get(rec.id);
418
+ if (existing)
419
+ existing.refcount = rec.refcount ?? existing.refcount + 1;
420
+ else
421
+ this.bindPins.set(rec.id, { tuple: rec.tuple, refcount: rec.refcount ?? 1 });
422
+ return;
423
+ }
424
+ case 'bind-release': {
425
+ if (typeof rec.id !== 'number')
426
+ return;
427
+ const pin = this.bindPins.get(rec.id);
428
+ if (!pin)
429
+ return;
430
+ pin.refcount = rec.refcount ?? pin.refcount - 1;
431
+ if (pin.refcount <= 0)
432
+ this.bindPins.delete(rec.id);
433
+ return;
434
+ }
435
+ case 'ambiguous-send': {
436
+ if (typeof rec.conversationId !== 'number' || !rec.logicalSendId)
437
+ return;
438
+ this.ambiguousSends.set(`${rec.conversationId}|${rec.logicalSendId}`, {
439
+ recordedAt: rec.ts,
440
+ ...(rec.lane === 'logical' || rec.lane === 'content-hash' ? { lane: rec.lane } : {}),
441
+ });
442
+ this.sendIntents.delete(`${rec.conversationId}|${rec.logicalSendId}`);
443
+ return;
444
+ }
445
+ case 'send-retire': {
446
+ if (typeof rec.conversationId !== 'number' || !rec.logicalSendId)
447
+ return;
448
+ this.ambiguousSends.delete(`${rec.conversationId}|${rec.logicalSendId}`);
449
+ this.sendIntents.delete(`${rec.conversationId}|${rec.logicalSendId}`);
450
+ return;
451
+ }
452
+ case 'send-intent': {
453
+ if (typeof rec.conversationId !== 'number' || !rec.logicalSendId)
454
+ return;
455
+ // Malformed/unknown lane resolves toward RETRY (content-hash treatment)
456
+ // — R9-minor-1/R10-low-2 (loss-is-never-silent picks retry). Counted so
457
+ // the post-replay boot pass can raise ONE deduped attention item.
458
+ if (rec.lane !== 'logical' && rec.lane !== 'content-hash')
459
+ this.missingLaneIntents++;
460
+ const lane = rec.lane === 'logical' ? 'logical' : 'content-hash';
461
+ this.sendIntents.set(`${rec.conversationId}|${rec.logicalSendId}`, { lane, seq: rec.seq });
462
+ return;
463
+ }
464
+ case 'send-intent-resolved': {
465
+ if (typeof rec.conversationId !== 'number' || !rec.logicalSendId)
466
+ return;
467
+ this.sendIntents.delete(`${rec.conversationId}|${rec.logicalSendId}`);
468
+ return;
469
+ }
470
+ }
471
+ }
472
+ indexEntry(key, entry) {
473
+ this.conversations.set(key, entry);
474
+ this.byId.set(entry.id, entry);
475
+ this.byTuple.set(tupleKeyFor({ platform: 'slack', channelId: entry.channelId, threadTs: entry.threadTs }), entry);
476
+ }
477
+ /** Derived indexes 3–5 (§3.4) — pure functions of the entry set, rebuilt at boot. */
478
+ rebuildDerivedIndexes() {
479
+ this.reservedCanonicals.clear();
480
+ this.displacedAssignments.clear();
481
+ this.candClaimants.clear();
482
+ for (const entry of this.conversations.values()) {
483
+ const tuple = { platform: 'slack', channelId: entry.channelId, threadTs: entry.threadTs };
484
+ const cand = candidateIdForRoutingKey(routingKeyForTuple(tuple));
485
+ const tKey = tupleKeyFor(tuple);
486
+ let claimants = this.candClaimants.get(cand);
487
+ if (!claimants) {
488
+ claimants = new Set();
489
+ this.candClaimants.set(cand, claimants);
490
+ }
491
+ claimants.add(tKey);
492
+ if (entry.id === cand)
493
+ this.reservedCanonicals.set(cand, tKey);
494
+ else
495
+ this.displacedAssignments.set(entry.id, tKey);
496
+ }
497
+ }
498
+ /** R5-C1/R6-M3: the assignment-beats-alias filter re-run over composed state —
499
+ * an alias shadowing a reserved canonical or an assigned displacement offset
500
+ * is dropped exactly as it would be at ingest. */
501
+ applyAliasAssignmentFilter() {
502
+ for (const aliasId of [...this.aliases.keys()]) {
503
+ if (this.reservedCanonicals.has(aliasId) || this.displacedAssignments.has(aliasId)) {
504
+ this.aliases.delete(aliasId);
505
+ }
506
+ }
507
+ }
508
+ // ─────────────────────────────────────────────────────────────────────────
509
+ // Journal append (§3.4 — dedicated single-writer discipline, G3)
510
+ // ─────────────────────────────────────────────────────────────────────────
511
+ /**
512
+ * Append ONE newline-terminated record. `seq` assignment and the byte-write
513
+ * happen atomically per record under a synchronous append (Node's
514
+ * single-threaded sync I/O IS the append mutex — no interleaving is
515
+ * possible), satisfying the §3.4 G3 single-writer contract.
516
+ */
517
+ appendJournal(rec, opts) {
518
+ const seq = ++this.seqCounter;
519
+ const line = `${JSON.stringify({ seq, ...rec, ts: this.now().toISOString() })}\n`;
520
+ this.rotateJournalIfNeeded(Buffer.byteLength(line));
521
+ if (this.journalFd === null) {
522
+ const created = !fs.existsSync(this.journalPath);
523
+ this.journalFd = fs.openSync(this.journalPath, 'a');
524
+ if (created)
525
+ this.fsyncDir(); // directory entry durable on file creation (§3.4)
526
+ }
527
+ fs.writeSync(this.journalFd, line);
528
+ this.journalBytes += Buffer.byteLength(line);
529
+ this.journalLines += 1;
530
+ if (opts.fsync && !(this.d.isJournalFsyncDisabled?.() ?? false)) {
531
+ try {
532
+ fs.fsyncSync(this.journalFd);
533
+ }
534
+ catch {
535
+ /* macOS platform footnote (§3.4): cheap-fsync is the deliberate choice */
536
+ }
537
+ }
538
+ return seq;
539
+ }
540
+ fsyncDir() {
541
+ try {
542
+ const dirFd = fs.openSync(this.d.stateDir, 'r');
543
+ try {
544
+ fs.fsyncSync(dirFd);
545
+ }
546
+ finally {
547
+ fs.closeSync(dirFd);
548
+ }
549
+ }
550
+ catch {
551
+ /* @silent-fallback-ok — directory fsync is best-effort, platform-dependent (§3.4 macOS footnote); the record append itself is the durability authority */
552
+ }
553
+ }
554
+ rotateJournalIfNeeded(incomingBytes) {
555
+ const rotateBytes = this.d.journalRotateBytes ?? 8388608;
556
+ const rotateLines = this.d.journalRotateLines ?? 50000;
557
+ if (this.journalBytes + incomingBytes <= rotateBytes && this.journalLines < rotateLines)
558
+ return;
559
+ if (!fs.existsSync(this.journalPath))
560
+ return;
561
+ if (this.journalFd !== null) {
562
+ try {
563
+ fs.fsyncSync(this.journalFd);
564
+ }
565
+ catch {
566
+ /* @silent-fallback-ok — pre-rotation fsync is best-effort; every durable record already fsynced at append time (§3.3 WAL rule) */
567
+ }
568
+ fs.closeSync(this.journalFd);
569
+ this.journalFd = null;
570
+ }
571
+ // Unique all-digit suffix (two rotations can land in one millisecond; the
572
+ // suffix must keep matching the retained-file scan + the backup glob).
573
+ let suffix = Date.now();
574
+ while (fs.existsSync(`${this.journalPath}.${suffix}`))
575
+ suffix++;
576
+ const rotatedPath = `${this.journalPath}.${suffix}`;
577
+ fs.renameSync(this.journalPath, rotatedPath);
578
+ this.fsyncDir();
579
+ this.journalBytes = 0;
580
+ this.journalLines = 0;
581
+ this.pruneRotatedJournals();
582
+ }
583
+ /**
584
+ * Prune ONLY fully-superseded rotated files: every record ≤ the PERSISTED
585
+ * snapshot high-water, older than the 7-day retention floor (§8 — a recovery
586
+ * requirement), and containing no unapplied unknown-op record (R8-minor-2).
587
+ * Under snapshot suspension the persisted high-water stays pre-skew, so every
588
+ * file the eventual re-upgrade needs is retained MECHANICALLY (R10-M1).
589
+ */
590
+ pruneRotatedJournals() {
591
+ const persistedHighWater = this.readPersistedHighWater();
592
+ const retentionFloorMs = 7 * 24 * 60 * 60 * 1000;
593
+ const unknownSeqs = new Set(this.unappliedUnknownOps.map((u) => u.seq));
594
+ for (const file of this.journalFiles()) {
595
+ if (file === this.journalPath)
596
+ continue;
597
+ try {
598
+ const stat = fs.statSync(file);
599
+ if (this.now().getTime() - stat.mtimeMs < retentionFloorMs)
600
+ continue;
601
+ const content = fs.readFileSync(file, 'utf-8');
602
+ let maxSeq = 0;
603
+ let hasUnknown = false;
604
+ for (const line of content.split('\n')) {
605
+ if (!line)
606
+ continue;
607
+ try {
608
+ const rec = JSON.parse(line);
609
+ if (typeof rec.seq === 'number' && rec.seq > maxSeq)
610
+ maxSeq = rec.seq;
611
+ if (!JOURNAL_OPS.includes(rec.op) || unknownSeqs.has(rec.seq))
612
+ hasUnknown = true;
613
+ }
614
+ catch {
615
+ hasUnknown = true; // @silent-fallback-ok — fails toward RETENTION: a file we cannot fully read is never pruned (§3.4)
616
+ }
617
+ }
618
+ if (!hasUnknown && maxSeq > 0 && maxSeq <= persistedHighWater) {
619
+ SafeFsExecutor.safeUnlinkSync(file, { operation: 'conversation-registry journal rotation prune (fully-superseded rotated file — §3.4)' });
620
+ }
621
+ }
622
+ catch {
623
+ /* @silent-fallback-ok — prune is hygiene; a failed prune retains the file (the safe direction), never gates identity */
624
+ }
625
+ }
626
+ }
627
+ readPersistedHighWater() {
628
+ try {
629
+ const raw = JSON.parse(fs.readFileSync(this.snapshotPath, 'utf-8'));
630
+ return typeof raw?.snapshotHighWaterSeq === 'number' ? raw.snapshotHighWaterSeq : 0;
631
+ }
632
+ catch {
633
+ // @silent-fallback-ok — an unreadable snapshot reads as high-water 0, so the
634
+ // prune rule supersedes NOTHING (fails toward retaining every journal file).
635
+ return 0;
636
+ }
637
+ }
638
+ // ─────────────────────────────────────────────────────────────────────────
639
+ // Snapshot (batched, size-adaptive — §3.4)
640
+ // ─────────────────────────────────────────────────────────────────────────
641
+ scheduleSnapshot() {
642
+ this.snapshotDirty = true;
643
+ if (this.batchDepth > 0)
644
+ return; // batched-save window (§6.2 adoption pass)
645
+ if (this.unappliedUnknownOps.length > 0)
646
+ return; // SUSPENSION (R9-M1/R10-M1)
647
+ if (this.snapshotTimer)
648
+ return;
649
+ const base = this.d.snapshotBaseIntervalMs ?? 2000;
650
+ const step = this.d.snapshotAdaptiveStep ?? 5000;
651
+ const max = this.d.snapshotMaxIntervalMs ?? 60000;
652
+ const interval = Math.min(Math.max(base * Math.ceil(Math.max(this.conversations.size, 1) / step), base), max);
653
+ this.snapshotTimer = setTimeout(() => {
654
+ this.snapshotTimer = null;
655
+ void this.flushSnapshot();
656
+ }, interval);
657
+ this.snapshotTimer.unref?.();
658
+ }
659
+ /** Force a flush now (shutdown/tests). Honors the unknown-op suspension. */
660
+ async flushSnapshot() {
661
+ if (this.flushInFlight)
662
+ return;
663
+ if (this.unappliedUnknownOps.length > 0)
664
+ return; // pre-skew snapshot stays put (R10-M1)
665
+ this.flushInFlight = true;
666
+ try {
667
+ const shape = {
668
+ version: 1,
669
+ snapshotHighWaterSeq: this.seqCounter,
670
+ ...(this.workspacePin ? { workspacePin: this.workspacePin } : {}),
671
+ conversations: Object.fromEntries(this.conversations),
672
+ aliases: Object.fromEntries([...this.aliases].map(([k, v]) => [String(k), v])),
673
+ bindPins: Object.fromEntries([...this.bindPins].map(([k, v]) => [String(k), v])),
674
+ ambiguousSends: Object.fromEntries(this.ambiguousSends),
675
+ sendIntents: Object.fromEntries(this.sendIntents),
676
+ };
677
+ const serialized = JSON.stringify(shape, null, 2);
678
+ const tmp = `${this.snapshotPath}.tmp`;
679
+ if (this.conversations.size > OFFLOOP_ENTRY_TRIGGER || this.lastSnapshotBytes > OFFLOOP_BYTES_TRIGGER) {
680
+ // §3.4 pinned off-loop trigger: async write of the pre-serialized buffer.
681
+ await fs.promises.writeFile(tmp, serialized);
682
+ await fs.promises.rename(tmp, this.snapshotPath);
683
+ }
684
+ else {
685
+ fs.writeFileSync(tmp, serialized);
686
+ fs.renameSync(tmp, this.snapshotPath);
687
+ }
688
+ this.snapshotHighWaterSeq = shape.snapshotHighWaterSeq;
689
+ this.lastSnapshotBytes = Buffer.byteLength(serialized);
690
+ this.snapshotDirty = false;
691
+ this.maybeRaiseGrowthTripwire();
692
+ }
693
+ catch (err) {
694
+ this.log(`[conversation-registry] snapshot flush failed: ${err instanceof Error ? err.message : String(err)}`);
695
+ }
696
+ finally {
697
+ this.flushInFlight = false;
698
+ }
699
+ }
700
+ maybeRaiseGrowthTripwire() {
701
+ if (this.conversations.size >= ENTRY_THRESHOLD || this.lastSnapshotBytes >= FILE_BYTES_THRESHOLD) {
702
+ this.attention('conversation-registry:growth-threshold', 'Conversation registry approaching its JSON-store ceiling', `entryCount=${this.conversations.size} (ceiling ~${ENTRY_CEILING}) / fileSizeBytes=${this.lastSnapshotBytes}. The §11.10 append-journal-as-primary / SQLite migration must land BEFORE the ceiling (scalability-G2).`);
703
+ }
704
+ }
705
+ /** Batched-save window for the adoption pass / bursts (§3.4/§6.2): one flush. */
706
+ beginBatch() {
707
+ this.batchDepth++;
708
+ }
709
+ endBatch() {
710
+ this.batchDepth = Math.max(0, this.batchDepth - 1);
711
+ if (this.batchDepth === 0 && this.snapshotDirty)
712
+ this.scheduleSnapshot();
713
+ }
714
+ // ─────────────────────────────────────────────────────────────────────────
715
+ // Mint (§3.3)
716
+ // ─────────────────────────────────────────────────────────────────────────
717
+ /** §3.3 `candidateCollides` — pure O(1) lookups, never a live-tuple scan:
718
+ * (a) reserved canonical of a DIFFERENT tuple; (b) alias table; (c) the
719
+ * GLOBAL displaced-assignment set (R4-C1). */
720
+ candidateCollides(id, tKey) {
721
+ const reserved = this.reservedCanonicals.get(id);
722
+ if (reserved !== undefined && reserved !== tKey)
723
+ return true;
724
+ if (this.aliases.has(id))
725
+ return true;
726
+ const displaced = this.displacedAssignments.get(id);
727
+ if (displaced !== undefined && displaced !== tKey)
728
+ return true;
729
+ return false;
730
+ }
731
+ /** Resolve the fleet workspace pin (§3.1 order: config → stored candidate). */
732
+ resolvedWorkspacePin() {
733
+ const configPin = this.readConfigPin();
734
+ if (configPin)
735
+ return { value: configPin, confirmedLocally: true };
736
+ return this.workspacePin;
737
+ }
738
+ /**
739
+ * Workspace admission for a mint (§3.1). Returns the workspaceId to record
740
+ * (concrete or `_`), or 'refused' on the per-machine multi-workspace gate.
741
+ */
742
+ admitWorkspace() {
743
+ let observed;
744
+ try {
745
+ observed = this.d.getLocalWorkspaceId?.();
746
+ }
747
+ catch {
748
+ // @silent-fallback-ok — a throwing workspace source degrades to the `_`
749
+ // placeholder (upgrades in place later, §3.1) — never a blocked mint.
750
+ observed = undefined;
751
+ }
752
+ if (!observed || !SLACK_WORKSPACE_ID_RE.test(observed))
753
+ return WORKSPACE_PLACEHOLDER;
754
+ const pin = this.resolvedWorkspacePin();
755
+ if (!pin) {
756
+ // First concrete LOCAL observation writes the candidate and is immediately
757
+ // confirmed for it — self-corroboration is the designed single-machine /
758
+ // first-machine path (R6-minor-5).
759
+ this.workspacePin = { value: observed, source: 'local-observed', confirmedLocally: true };
760
+ this.scheduleSnapshot();
761
+ return observed;
762
+ }
763
+ if (pin.value !== observed && pin.confirmedLocally) {
764
+ this.attention('conversation-registry:multi-workspace', 'Second Slack workspace refused (multi-workspace-unsupported)', `A mint arrived authenticated to workspace ${observed}, but this fleet's pin is ${pin.value}. Phase 1 supports exactly ONE Slack workspace (§3.1); Slack Connect / multi-workspace identity is Phase 7.1. Check conversationIdentity.workspacePin.`);
765
+ return 'refused';
766
+ }
767
+ return observed;
768
+ }
769
+ /** Breaker window state for a channel — bounded map with stale-window eviction (§3.3, R3-minor). */
770
+ breakerWindow(channelId) {
771
+ const windowMs = this.d.breaker?.windowMs ?? 600000;
772
+ const nowMs = this.now().getTime();
773
+ let w = this.breakerWindows.get(channelId);
774
+ if (!w || nowMs - w.windowStartMs >= windowMs) {
775
+ w = { windowStartMs: nowMs, speculative: 0, durable: 0, episodeNotified: false };
776
+ if (!this.breakerWindows.has(channelId) && this.breakerWindows.size >= BREAKER_MAX_TRACKED_CHANNELS) {
777
+ // Evict the stalest window — never a monotonic map (Bounded Blast Radius).
778
+ let oldest = null;
779
+ let oldestStart = Infinity;
780
+ for (const [k, v] of this.breakerWindows) {
781
+ if (v.windowStartMs < oldestStart) {
782
+ oldestStart = v.windowStartMs;
783
+ oldest = k;
784
+ }
785
+ }
786
+ if (oldest)
787
+ this.breakerWindows.delete(oldest);
788
+ }
789
+ this.breakerWindows.set(channelId, w);
790
+ }
791
+ return w;
792
+ }
793
+ /**
794
+ * Speculative (inbound-triggered) get-or-create mint (§6.3 eager mint).
795
+ * NEVER throws and never blocks delivery — every failure degrades toward
796
+ * "no durable id" or a collision-checked read (§3.6).
797
+ */
798
+ mintForInbound(routingKey, opts) {
799
+ this.load();
800
+ const tuple = tupleForRoutingKey(routingKey);
801
+ if (!tuple)
802
+ return { id: null, created: false, registered: false, degraded: 'unparseable-key' };
803
+ return this.mintTuple(tuple, { durableBinding: false, label: opts?.label, origin: undefined });
804
+ }
805
+ /**
806
+ * Durable-binding-forced mint (§3.3 breaker carve-out): registered REGARDLESS
807
+ * of the speculative budget, journal line fsynced BEFORE the id returns (the
808
+ * WAL rule). Its OWN higher budget yields a typed capacity refusal at the
809
+ * cap — never a silent drop (adversarial-B).
810
+ */
811
+ mintForDurableBinding(routingKey, opts) {
812
+ this.load();
813
+ const tuple = tupleForRoutingKey(routingKey);
814
+ if (!tuple)
815
+ return { ok: false, error: 'unparseable-key' };
816
+ if (!this.recordingEnabled()) {
817
+ // R2-integration-§9: an unjournaled bind would be unresolvable after a
818
+ // restart — refuse, typed + loud. Positive Telegram binds are unaffected
819
+ // (they never reach the registry).
820
+ this.attention('conversation-registry:recording-disabled-bind', 'Durable bind on a minted conversation refused — recording is disabled', 'conversationIdentity.recording.enabled is false (the emergency kill-switch). A durable-state open on a MINTED id is refused while recording is off (typed conversation-recording-disabled): an unjournaled bind would silently die on restart. Re-enable recording to restore minted binds.');
821
+ return { ok: false, error: 'conversation-recording-disabled' };
822
+ }
823
+ const existing = this.byTuple.get(tupleKeyFor(tuple));
824
+ if (existing) {
825
+ this.maybeUpgradeWorkspace(existing);
826
+ // WAL rule at durable-bind time (§3.3): a pre-existing SPECULATIVE entry
827
+ // rode the batched snapshot only — its journal line may not exist yet. A
828
+ // durable binding is not re-derivable after a crash, so ensure the entry
829
+ // is durably journaled NOW (idempotent op:"mint" re-apply; fsynced)
830
+ // BEFORE the id is handed to the binding.
831
+ this.appendJournal({
832
+ op: 'mint',
833
+ key: canonicalKeyFor(tuple, existing.workspaceId),
834
+ tuple: [tuple.platform, tuple.channelId, tuple.threadTs],
835
+ id: existing.id,
836
+ origin: existing.origin,
837
+ hlc: existing.hlc,
838
+ }, { fsync: true });
839
+ return { ok: true, id: existing.id, created: false };
840
+ }
841
+ const w = this.breakerWindow(tuple.channelId);
842
+ const durableCap = this.d.breaker?.durableBindingPerWindow ?? 50;
843
+ if (w.durable >= durableCap) {
844
+ this.attention('conversation-registry:durable-capacity', 'Durable-binding mint budget exhausted for a channel', `Channel ${tuple.channelId} exceeded ${durableCap} durable-binding registrations in the window. The binding-open is refused with a typed conversation-registration-capacity error — never a silent drop (§3.3 adversarial-B).`);
845
+ return { ok: false, error: 'conversation-registration-capacity' };
846
+ }
847
+ const res = this.mintTuple(tuple, { durableBinding: true, label: opts?.label });
848
+ if (res.id === null || !res.registered) {
849
+ if (res.degraded === 'multi-workspace-unsupported')
850
+ return { ok: false, error: 'multi-workspace-unsupported' };
851
+ return { ok: false, error: 'mint-failure' };
852
+ }
853
+ w.durable++;
854
+ return { ok: true, id: res.id, created: res.created };
855
+ }
856
+ /** §3.1 in-place `_`→teamId upgrade — LOCAL authenticated source ONLY. */
857
+ maybeUpgradeWorkspace(entry) {
858
+ if (entry.workspaceId !== WORKSPACE_PLACEHOLDER)
859
+ return;
860
+ const admitted = this.admitWorkspace();
861
+ if (admitted === 'refused' || admitted === WORKSPACE_PLACEHOLDER)
862
+ return;
863
+ const tuple = { platform: 'slack', channelId: entry.channelId, threadTs: entry.threadTs };
864
+ const oldKey = canonicalKeyFor(tuple, entry.workspaceId);
865
+ this.conversations.delete(oldKey);
866
+ entry.workspaceId = admitted;
867
+ const newKey = canonicalKeyFor(tuple, admitted);
868
+ this.conversations.set(newKey, entry);
869
+ // Journaled (§3.1) as an idempotent op:"mint" re-apply with the rewritten
870
+ // key (the op enum has no distinct upgrade op — §3.4/R6-low-2 precedent).
871
+ this.appendJournal({
872
+ op: 'mint',
873
+ key: newKey,
874
+ tuple: [tuple.platform, tuple.channelId, tuple.threadTs],
875
+ id: entry.id,
876
+ origin: entry.origin,
877
+ hlc: entry.hlc,
878
+ }, { fsync: false });
879
+ this.scheduleSnapshot();
880
+ this.log(`[conversation-registry] workspace upgraded in place: ${oldKey} → ${newKey} (id ${entry.id})`);
881
+ }
882
+ mintTuple(tuple, opts) {
883
+ const tKey = tupleKeyFor(tuple);
884
+ const existing = this.byTuple.get(tKey);
885
+ if (existing) {
886
+ this.maybeUpgradeWorkspace(existing);
887
+ if (opts.label && opts.label !== existing.label) {
888
+ existing.label = opts.label; // write-on-change (§3.4 G4)
889
+ this.scheduleSnapshot();
890
+ }
891
+ // §5.1 reachability auto-clear: an authenticated inbound on the tuple is
892
+ // positive evidence the conversation is reachable again (re-invited /
893
+ // un-archived channel is never stuck).
894
+ if (existing.reachability === 'unreachable')
895
+ this.setReachability(existing.id, 'ok');
896
+ return { id: existing.id, created: false, registered: true };
897
+ }
898
+ const routingKey = routingKeyForTuple(tuple);
899
+ const candidate = candidateIdForRoutingKey(routingKey);
900
+ const workspace = this.admitWorkspace();
901
+ if (workspace === 'refused') {
902
+ return { id: null, created: false, registered: false, degraded: 'multi-workspace-unsupported' };
903
+ }
904
+ if (!this.recordingEnabled()) {
905
+ // D1 degradation (§3.6): behavior-identical to legacy hashing — candidate
906
+ // + collision-checked read (B6), NO durable write, NO journal fsync.
907
+ return { id: this.collisionCheckedRead(candidate, tKey), created: false, registered: false, degraded: 'recording-disabled' };
908
+ }
909
+ if (!opts.durableBinding) {
910
+ const w = this.breakerWindow(tuple.channelId);
911
+ const cap = this.d.breaker?.speculativePerWindow ?? 200;
912
+ if (w.speculative >= cap) {
913
+ // The breaker DROPS speculative registrations to NOWHERE (zero pending
914
+ // state) — the candidate re-mints for free on a later inbound. Delivery
915
+ // still proceeds on a collision-checked read (B6).
916
+ this.breakerEpisodes++;
917
+ if (!w.episodeNotified) {
918
+ w.episodeNotified = true;
919
+ this.attention('conversation-registry:mint-breaker', 'Mint-rate breaker engaged for a channel', `Channel ${tuple.channelId} exceeded ${cap} new speculative conversation registrations in the window. Further inbound registrations this window are dropped (they re-mint later); delivery is unaffected (§3.3 Bounded Blast Radius).`);
920
+ }
921
+ return { id: this.collisionCheckedRead(candidate, tKey), created: false, registered: false, degraded: 'breaker-dropped' };
922
+ }
923
+ w.speculative++;
924
+ }
925
+ const walk = walkDisplacement(candidate, (id) => this.candidateCollides(id, tKey));
926
+ if (!walk.ok) {
927
+ // Probe overflow (astronomically unlikely) degrades to the §3.6
928
+ // pending-mint path — never a silently-un-ingestable id.
929
+ const key = canonicalKeyFor(tuple, workspace);
930
+ if (!this.pendingMints.has(key)) {
931
+ if (this.pendingMints.size >= PENDING_MINT_MAX)
932
+ this.pendingMintDrops++;
933
+ else
934
+ this.pendingMints.set(key, { firstAt: this.now().toISOString() });
935
+ }
936
+ return { id: null, created: false, registered: false, degraded: 'probe-overflow' };
937
+ }
938
+ const probed = walk.probes > 0;
939
+ const nowIso = this.now().toISOString();
940
+ const machineId = this.safeMachineId();
941
+ const entry = {
942
+ id: walk.id,
943
+ platform: 'slack',
944
+ workspaceId: workspace,
945
+ channelId: tuple.channelId,
946
+ threadTs: tuple.threadTs,
947
+ mintedAt: nowIso,
948
+ mintedBy: machineId,
949
+ // A fresh non-probed local mint's id IS the legacy-hash id — the honest
950
+ // origin within the frozen §3.4 enum; probed mints are 'minted-probed'.
951
+ origin: opts.origin ?? (probed ? 'minted-probed' : 'adopted-legacy-hash'),
952
+ reachability: 'ok',
953
+ hlc: { physical: this.now().getTime(), logical: 0, node: machineId },
954
+ ...(opts.label ? { label: opts.label } : {}),
955
+ };
956
+ const key = canonicalKeyFor(tuple, workspace);
957
+ // Synchronous assignment: authoritative cache + reverse + tuple index +
958
+ // derived occupancy — the id RETURNED equals the id that will PERSIST.
959
+ this.indexEntry(key, entry);
960
+ let claimants = this.candClaimants.get(candidate);
961
+ if (!claimants) {
962
+ claimants = new Set();
963
+ this.candClaimants.set(candidate, claimants);
964
+ }
965
+ claimants.add(tKey);
966
+ if (!probed)
967
+ this.reservedCanonicals.set(candidate, tKey);
968
+ else
969
+ this.displacedAssignments.set(walk.id, tKey);
970
+ // WAL rule (§3.3): a PROBED or durable-binding-forced mint append+fsyncs
971
+ // ONE journal line BEFORE the id is returned (not re-derivable after a
972
+ // crash). A pure SPECULATIVE non-probed mint performs NO synchronous
973
+ // journal write — it rides the batched snapshot only (§3.4 fsync
974
+ // discipline; its candidate re-mints deterministically for free).
975
+ if (probed || opts.durableBinding) {
976
+ this.appendJournal({
977
+ op: 'mint',
978
+ key,
979
+ tuple: [tuple.platform, tuple.channelId, tuple.threadTs],
980
+ id: entry.id,
981
+ origin: entry.origin,
982
+ hlc: entry.hlc,
983
+ }, { fsync: true });
984
+ }
985
+ this.pendingMints.delete(key);
986
+ this.lastMintAt = nowIso;
987
+ this.scheduleSnapshot();
988
+ return { id: entry.id, created: true, registered: true };
989
+ }
990
+ safeMachineId() {
991
+ try {
992
+ return this.d.machineId() || 'unknown';
993
+ }
994
+ catch {
995
+ return 'unknown';
996
+ }
997
+ }
998
+ /**
999
+ * B6 collision-checked read for degraded paths (breaker-drop / recording-off):
1000
+ * never delivers on a raw candidate occupied by a DIFFERENT tuple — resolves
1001
+ * via the same key-derived probe for the READ ONLY (no registration).
1002
+ */
1003
+ collisionCheckedRead(candidate, tKey) {
1004
+ const walk = walkDisplacement(candidate, (id) => this.candidateCollides(id, tKey));
1005
+ return walk.ok ? walk.id : candidate;
1006
+ }
1007
+ // ─────────────────────────────────────────────────────────────────────────
1008
+ // Resolution (§2/§8)
1009
+ // ─────────────────────────────────────────────────────────────────────────
1010
+ /** `resolve(id)`: positive → Telegram pass-through; negative → registry
1011
+ * lookup, aliases followed exactly ONE hop; unknown → null. */
1012
+ resolve(id) {
1013
+ this.load();
1014
+ if (!Number.isSafeInteger(id) || id === 0)
1015
+ return null;
1016
+ if (id > 0)
1017
+ return { platform: 'telegram', topicId: id, passThrough: true };
1018
+ const aliasTarget = this.aliases.get(id);
1019
+ const entry = this.byId.get(aliasTarget ?? id);
1020
+ if (!entry)
1021
+ return null;
1022
+ return {
1023
+ platform: 'slack',
1024
+ id: entry.id,
1025
+ key: canonicalKeyFor({ platform: 'slack', channelId: entry.channelId, threadTs: entry.threadTs }, entry.workspaceId),
1026
+ channelId: entry.channelId,
1027
+ threadTs: entry.threadTs,
1028
+ workspaceId: entry.workspaceId,
1029
+ origin: entry.origin,
1030
+ reachability: entry.reachability,
1031
+ ...(entry.label !== undefined ? { label: entry.label } : {}),
1032
+ ...(aliasTarget !== undefined ? { aliasOf: aliasTarget } : {}),
1033
+ };
1034
+ }
1035
+ /** Forward lookup by canonical key OR transport sessionKey — mints NOTHING (§8). */
1036
+ resolveByKey(keyOrSessionKey) {
1037
+ this.load();
1038
+ if (/^\d+$/.test(keyOrSessionKey)) {
1039
+ return { platform: 'telegram', topicId: Number(keyOrSessionKey), passThrough: true };
1040
+ }
1041
+ const parsed = parseCanonicalKey(keyOrSessionKey);
1042
+ const tuple = parsed?.tuple ?? tupleForRoutingKey(keyOrSessionKey);
1043
+ if (!tuple)
1044
+ return null;
1045
+ const entry = this.byTuple.get(tupleKeyFor(tuple));
1046
+ return entry ? this.resolve(entry.id) : null;
1047
+ }
1048
+ /**
1049
+ * §4 READ-ONLY id for a routing key (integration-nit): the existing entry's
1050
+ * id, else the B6 collision-checked candidate — NEVER a registration. A pure
1051
+ * comparison callsite must not acquire a get-or-create WRITE side-effect.
1052
+ */
1053
+ readIdForRoutingKey(routingKey) {
1054
+ this.load();
1055
+ const tuple = tupleForRoutingKey(routingKey);
1056
+ if (!tuple)
1057
+ return null;
1058
+ const tKey = tupleKeyFor(tuple);
1059
+ const existing = this.byTuple.get(tKey);
1060
+ if (existing)
1061
+ return existing.id;
1062
+ return this.collisionCheckedRead(candidateIdForRoutingKey(routingKey), tKey);
1063
+ }
1064
+ /**
1065
+ * `idForSessionKey` — GET-OR-CREATE (§6.0 #12, a named mint chokepoint).
1066
+ * Positive numeric session keys pass through; Slack routing keys mint.
1067
+ */
1068
+ idForSessionKey(sessionKey) {
1069
+ this.load();
1070
+ if (/^\d+$/.test(sessionKey))
1071
+ return Number(sessionKey);
1072
+ return this.mintForInbound(sessionKey).id;
1073
+ }
1074
+ /** Read-only inventory for GET /conversations (§8). */
1075
+ list(opts) {
1076
+ this.load();
1077
+ const limit = Math.max(1, Math.min(opts?.limit ?? 500, 5000));
1078
+ const out = [];
1079
+ for (const [key, entry] of this.conversations) {
1080
+ if (opts?.platform && entry.platform !== opts.platform)
1081
+ continue;
1082
+ out.push({ key, entry });
1083
+ if (out.length >= limit)
1084
+ break;
1085
+ }
1086
+ return out;
1087
+ }
1088
+ aliasTable() {
1089
+ this.load();
1090
+ return Object.fromEntries([...this.aliases].map(([k, v]) => [String(k), v]));
1091
+ }
1092
+ entryCount() {
1093
+ this.load();
1094
+ return this.conversations.size;
1095
+ }
1096
+ // ─────────────────────────────────────────────────────────────────────────
1097
+ // Bind-pin overlay (§3.5.2 — increment 2 writers)
1098
+ // ─────────────────────────────────────────────────────────────────────────
1099
+ /**
1100
+ * Record a durable bind on a minted id (§3.5.2 property 2/4): the pin binds
1101
+ * the id to the TUPLE it resolves to AT BIND TIME, refcounted per live
1102
+ * binding, journaled (`op:"bind-pin"`, fsynced — the bind's own WAL line).
1103
+ * Returns the bind-time tuple (the caller denormalizes it as `boundTuple`
1104
+ * onto the binding record — property 5), or null when the id is unresolvable.
1105
+ */
1106
+ bindPin(id) {
1107
+ this.load();
1108
+ const entry = this.byId.get(this.aliases.get(id) ?? id);
1109
+ if (!entry)
1110
+ return null;
1111
+ const tuple = ['slack', entry.channelId, entry.threadTs];
1112
+ const pin = this.bindPins.get(id);
1113
+ const refcount = pin ? pin.refcount + 1 : 1;
1114
+ if (pin)
1115
+ pin.refcount = refcount;
1116
+ else
1117
+ this.bindPins.set(id, { tuple, refcount });
1118
+ this.appendJournal({ op: 'bind-pin', id, tuple, refcount }, { fsync: true });
1119
+ this.scheduleSnapshot();
1120
+ return { platform: 'slack', channelId: entry.channelId, threadTs: entry.threadTs };
1121
+ }
1122
+ /** Release one refcount on a bind-pin (`op:"bind-release"`, §3.5.2 property 4).
1123
+ * The pin is released only at ZERO — a commitment closing never strands a
1124
+ * still-live sibling binding on the same id. No-op on a missing pin. */
1125
+ bindRelease(id) {
1126
+ this.load();
1127
+ const pin = this.bindPins.get(id);
1128
+ if (!pin)
1129
+ return;
1130
+ const refcount = pin.refcount - 1;
1131
+ this.appendJournal({ op: 'bind-release', id, refcount }, { fsync: true });
1132
+ if (refcount <= 0)
1133
+ this.bindPins.delete(id);
1134
+ else
1135
+ pin.refcount = refcount;
1136
+ this.scheduleSnapshot();
1137
+ }
1138
+ /** The live bind-pin tuple for an id (delivery-time overlay read — §3.5.2 property 3). */
1139
+ getBindPinTuple(id) {
1140
+ this.load();
1141
+ const pin = this.bindPins.get(id);
1142
+ if (!pin)
1143
+ return null;
1144
+ return { platform: 'slack', channelId: pin.tuple[1], threadTs: pin.tuple[2] ?? null };
1145
+ }
1146
+ // ─────────────────────────────────────────────────────────────────────────
1147
+ // E1 ambiguous-outcome idempotency guard state (§5.0(a) — increment 2 writers)
1148
+ // ─────────────────────────────────────────────────────────────────────────
1149
+ sendGuardKey(conversationId, logicalSendId) {
1150
+ return `${conversationId}|${logicalSendId}`;
1151
+ }
1152
+ /**
1153
+ * Durable SEND-INTENT (R6-M1): appended+fsynced BEFORE the transport send,
1154
+ * stamped with the caller's lane (R8-M1 — the boot-conversion discriminator,
1155
+ * never inferred from the id's shape at boot).
1156
+ */
1157
+ recordSendIntent(conversationId, logicalSendId, lane) {
1158
+ this.load();
1159
+ const seq = this.appendJournal({ op: 'send-intent', conversationId, logicalSendId, lane }, { fsync: true });
1160
+ this.sendIntents.set(this.sendGuardKey(conversationId, logicalSendId), { lane, seq });
1161
+ this.scheduleSnapshot();
1162
+ }
1163
+ /**
1164
+ * Record the E1 suppressor entry — ONLY on a likely-posted outcome (success
1165
+ * or ambiguous/ack-lost; NEVER a clean transient failure — R2-security-NEW-3).
1166
+ * Durable (`op:"ambiguous-send"`, fsynced — R4-M2); resolves the pair's
1167
+ * send-intent. The in-memory map is the loaded image of this durable state.
1168
+ */
1169
+ recordLikelyPosted(conversationId, logicalSendId, lane) {
1170
+ this.load();
1171
+ this.pruneSendGuardState();
1172
+ const key = this.sendGuardKey(conversationId, logicalSendId);
1173
+ this.appendJournal({ op: 'ambiguous-send', conversationId, logicalSendId, lane }, { fsync: true });
1174
+ this.ambiguousSends.set(key, { recordedAt: this.now().toISOString(), lane });
1175
+ this.sendIntents.delete(key);
1176
+ if (this.ambiguousSends.size > AMBIGUOUS_SENDS_HARD_CAP) {
1177
+ // Pathological tripwire (R4-M2 scoped per R5-minor-1): loud, aggregated,
1178
+ // and the new entry is STILL journaled + retained — nothing dropped, so a
1179
+ // silent eviction can never re-open the double-post.
1180
+ this.attention('conversation-registry:send-guard-cap', 'E1 send-guard entry population exceeded its pathological tripwire', `${this.ambiguousSends.size} unretired/unexpired ambiguous-send entries exceed the ${AMBIGUOUS_SENDS_HARD_CAP} cap (§5.0(a)). Live entries are bounded by open beacon commitments (~maxActiveBeacons) by construction — this population means an upstream cap was raised or a retirement path is broken. No entry was evicted or dropped.`);
1181
+ }
1182
+ this.scheduleSnapshot();
1183
+ }
1184
+ /** Retire a logical send (`op:"send-retire"` — delivered outcome advanced the
1185
+ * seq, or the commitment closed). Idempotent; also resolves the intent. */
1186
+ retireSend(conversationId, logicalSendId) {
1187
+ this.load();
1188
+ const key = this.sendGuardKey(conversationId, logicalSendId);
1189
+ if (!this.ambiguousSends.has(key) && !this.sendIntents.has(key))
1190
+ return;
1191
+ this.appendJournal({ op: 'send-retire', conversationId, logicalSendId }, { fsync: true });
1192
+ this.ambiguousSends.delete(key);
1193
+ this.sendIntents.delete(key);
1194
+ this.scheduleSnapshot();
1195
+ }
1196
+ /** Resolve a send-intent WITHOUT a suppressor (`op:"send-intent-resolved"`) —
1197
+ * the clean-transient-failure path (positive evidence the message never
1198
+ * posted), so the retry is NOT suppressed (R2-security-NEW-3/R6-M1). */
1199
+ resolveSendIntent(conversationId, logicalSendId) {
1200
+ this.load();
1201
+ const key = this.sendGuardKey(conversationId, logicalSendId);
1202
+ if (!this.sendIntents.has(key))
1203
+ return;
1204
+ this.appendJournal({ op: 'send-intent-resolved', conversationId, logicalSendId }, { fsync: true });
1205
+ this.sendIntents.delete(key);
1206
+ this.scheduleSnapshot();
1207
+ }
1208
+ /**
1209
+ * The E1 suppression read (§5.0(a) R3-M1/R7-M2): the LOGICAL lane is
1210
+ * retirement-based (an unretired entry suppresses whenever the re-fire
1211
+ * arrives, bounded only by the 7-day TTL backstop); the CONTENT-HASH lane is
1212
+ * WINDOW-based (a windowless caller has nothing to retire — outside the
1213
+ * 15-min window the same text delivers again).
1214
+ */
1215
+ isSendSuppressed(conversationId, logicalSendId, lane) {
1216
+ this.load();
1217
+ const entry = this.ambiguousSends.get(this.sendGuardKey(conversationId, logicalSendId));
1218
+ if (!entry)
1219
+ return false;
1220
+ const age = this.now().getTime() - Date.parse(entry.recordedAt);
1221
+ if (!Number.isFinite(age))
1222
+ return false;
1223
+ return lane === 'content-hash' ? age < CONTENT_HASH_DEDUP_WINDOW_MS : age < AMBIGUOUS_DEDUP_TTL_MS;
1224
+ }
1225
+ /** Prune RETIRED/EXPIRED stragglers only (never a live unretired logical
1226
+ * entry below its TTL — R4-M2 second arm). An expired content-hash entry
1227
+ * prunes exactly like a retired one (R7-M2). */
1228
+ pruneSendGuardState() {
1229
+ const nowMs = this.now().getTime();
1230
+ let pruned = false;
1231
+ for (const [key, entry] of this.ambiguousSends) {
1232
+ const age = nowMs - Date.parse(entry.recordedAt);
1233
+ if (!Number.isFinite(age))
1234
+ continue;
1235
+ const horizon = entry.lane === 'content-hash' ? CONTENT_HASH_DEDUP_WINDOW_MS : AMBIGUOUS_DEDUP_TTL_MS;
1236
+ if (age > horizon) {
1237
+ this.ambiguousSends.delete(key);
1238
+ pruned = true;
1239
+ }
1240
+ }
1241
+ if (pruned)
1242
+ this.scheduleSnapshot();
1243
+ }
1244
+ /**
1245
+ * R8-M1 lane-scoped boot conversion of crash-orphaned send-intents: an intent
1246
+ * that is the LAST word for its (conversationId, logicalSendId) PAIR (R7-M3
1247
+ * composite keying — replay already resolved every superseded pair) resolves
1248
+ * BY ITS RECORDED LANE: `logical` (beacon) converts into an ambiguous-send
1249
+ * suppressor (the outcome is genuinely unknown and the beacon has a next
1250
+ * tick); `content-hash` resolves toward RETRY (the suppressed send IS the
1251
+ * message — replay appends the missing send-intent-resolved durably, so the
1252
+ * verdict is decided once, not re-decided every boot). Staged appends land
1253
+ * POST-compose, fsynced before the registry begins serving (R9-low-3 /
1254
+ * R10-minor-1).
1255
+ */
1256
+ convertOrphanedSendIntents() {
1257
+ if (this.sendIntents.size === 0)
1258
+ return;
1259
+ for (const [key, intent] of [...this.sendIntents]) {
1260
+ const sep = key.indexOf('|'); // conversationId prefix is numeric — the FIRST '|' always delimits (§3.4)
1261
+ if (sep <= 0) {
1262
+ this.sendIntents.delete(key);
1263
+ continue;
1264
+ }
1265
+ const conversationId = Number(key.slice(0, sep));
1266
+ const logicalSendId = key.slice(sep + 1);
1267
+ if (!Number.isSafeInteger(conversationId) || !logicalSendId) {
1268
+ this.sendIntents.delete(key);
1269
+ continue;
1270
+ }
1271
+ if (intent.lane === 'logical') {
1272
+ this.appendJournal({ op: 'ambiguous-send', conversationId, logicalSendId, lane: 'logical' }, { fsync: true });
1273
+ this.ambiguousSends.set(key, { recordedAt: this.now().toISOString(), lane: 'logical' });
1274
+ }
1275
+ else {
1276
+ this.appendJournal({ op: 'send-intent-resolved', conversationId, logicalSendId }, { fsync: true });
1277
+ }
1278
+ this.sendIntents.delete(key);
1279
+ }
1280
+ this.scheduleSnapshot();
1281
+ }
1282
+ // ─────────────────────────────────────────────────────────────────────────
1283
+ // Reachability (§5.1 — increment 2 writer)
1284
+ // ─────────────────────────────────────────────────────────────────────────
1285
+ /**
1286
+ * Flip an entry's LOCAL-authoritative reachability (§5.1). IDEMPOTENT
1287
+ * (already-in-state → no write) and journaled WITHOUT fsync — it rides the
1288
+ * same batched flush, so a mass event coalesces into one write, never an
1289
+ * O(N) write storm on the failure path (scalability-F2). Returns whether the
1290
+ * state changed and whether the conversation is in the R3-minor FLAP-DAMPENED
1291
+ * state (>3 flips in 24h — the caller coalesces its dead-letter attention
1292
+ * into one per-window flap item instead of one per episode).
1293
+ */
1294
+ setReachability(id, state) {
1295
+ this.load();
1296
+ const entry = this.byId.get(this.aliases.get(id) ?? id);
1297
+ if (!entry)
1298
+ return { changed: false, dampened: false };
1299
+ const nowMs = this.now().getTime();
1300
+ const cutoff = nowMs - REACHABILITY_FLAP_WINDOW_MS;
1301
+ const flips = (this.reachabilityFlips.get(entry.id) ?? []).filter((t) => t > cutoff);
1302
+ if (entry.reachability === state) {
1303
+ this.reachabilityFlips.set(entry.id, flips);
1304
+ return { changed: false, dampened: flips.length > REACHABILITY_FLAP_THRESHOLD };
1305
+ }
1306
+ flips.push(nowMs);
1307
+ this.reachabilityFlips.set(entry.id, flips);
1308
+ entry.reachability = state;
1309
+ this.appendJournal({ op: 'reachability', id: entry.id, reachability: state }, { fsync: false });
1310
+ this.scheduleSnapshot();
1311
+ return { changed: true, dampened: flips.length > REACHABILITY_FLAP_THRESHOLD };
1312
+ }
1313
+ // ─────────────────────────────────────────────────────────────────────────
1314
+ // Adoption pass (§6.2)
1315
+ // ─────────────────────────────────────────────────────────────────────────
1316
+ /**
1317
+ * Idempotent boot-time ensure: pre-register channel-level conversations from
1318
+ * the channel registry with their legacy-hash ids, inside ONE batched-save
1319
+ * window. GATED to channels with ≥1 authorized-sender message on record
1320
+ * (security-B8) — an auto-join channel mints lazily on its first authorized
1321
+ * inbound instead. A pre-population CONVENIENCE, not a recovery requirement.
1322
+ */
1323
+ runAdoptionPass(channels, hasAuthorizedTraffic) {
1324
+ this.load();
1325
+ let adopted = 0;
1326
+ let skippedUnauthorized = 0;
1327
+ this.beginBatch();
1328
+ try {
1329
+ for (const ch of channels) {
1330
+ let authorized = false;
1331
+ try {
1332
+ authorized = hasAuthorizedTraffic(ch.channelId);
1333
+ }
1334
+ catch {
1335
+ authorized = false; // fail toward not-pre-minting (it mints lazily later)
1336
+ }
1337
+ if (!authorized) {
1338
+ skippedUnauthorized++;
1339
+ continue;
1340
+ }
1341
+ const res = this.mintForInbound(ch.channelId, ch.name ? { label: ch.name } : undefined);
1342
+ if (res.created)
1343
+ adopted++;
1344
+ }
1345
+ }
1346
+ finally {
1347
+ this.endBatch();
1348
+ }
1349
+ this.adoptionState = { ranAt: this.now().toISOString(), adopted, skippedUnauthorized };
1350
+ this.log(`[conversation-registry] adoption pass: ${adopted} adopted, ${skippedUnauthorized} skipped (no authorized traffic)`);
1351
+ return { adopted, skippedUnauthorized };
1352
+ }
1353
+ // ─────────────────────────────────────────────────────────────────────────
1354
+ // Health (§8 — the e2e "feature is alive" target)
1355
+ // ─────────────────────────────────────────────────────────────────────────
1356
+ health() {
1357
+ this.load();
1358
+ const byOrigin = {};
1359
+ const byPlatform = {};
1360
+ for (const entry of this.conversations.values()) {
1361
+ byOrigin[entry.origin] = (byOrigin[entry.origin] ?? 0) + 1;
1362
+ byPlatform[entry.platform] = (byPlatform[entry.platform] ?? 0) + 1;
1363
+ }
1364
+ let fileSizeBytes = 0;
1365
+ try {
1366
+ fileSizeBytes = fs.existsSync(this.snapshotPath) ? fs.statSync(this.snapshotPath).size : 0;
1367
+ }
1368
+ catch {
1369
+ /* observability */
1370
+ }
1371
+ let retainedJournalBytes = 0;
1372
+ try {
1373
+ for (const f of this.journalFiles())
1374
+ retainedJournalBytes += fs.statSync(f).size;
1375
+ }
1376
+ catch {
1377
+ /* observability */
1378
+ }
1379
+ return {
1380
+ // Resident-heap honesty (§3.4): entryCount is the heap axis — resident
1381
+ // heap is plausibly 5–10× fileSizeBytes at the 100k envelope.
1382
+ entryCount: this.conversations.size,
1383
+ fileSizeBytes,
1384
+ byPlatform,
1385
+ byOrigin,
1386
+ aliasCount: this.aliases.size,
1387
+ lastMintAt: this.lastMintAt,
1388
+ adoptionPass: this.adoptionState,
1389
+ recordingEnabled: this.recordingEnabled(),
1390
+ workspacePin: this.workspacePin ? { value: this.workspacePin.value, source: this.workspacePin.source } : null,
1391
+ mintBudget: {
1392
+ channelsTracked: this.breakerWindows.size,
1393
+ breakerEpisodes: this.breakerEpisodes,
1394
+ },
1395
+ pendingMints: { count: this.pendingMints.size, dropped: this.pendingMintDrops },
1396
+ quarantine: {
1397
+ snapshotQuarantinedAt: this.snapshotQuarantinedAt,
1398
+ journalQuarantinedAt: this.journalQuarantinedAt,
1399
+ durabilityIncidents: this.durabilityIncidents,
1400
+ },
1401
+ // §5.0(a)/§3.5.2 increment-2 observability.
1402
+ sendGuard: {
1403
+ unretiredEntries: this.ambiguousSends.size,
1404
+ unresolvedIntents: this.sendIntents.size,
1405
+ bindPins: this.bindPins.size,
1406
+ },
1407
+ // Snapshot-suspension observability (R11-low-1).
1408
+ snapshotSuspended: this.unappliedUnknownOps.length > 0,
1409
+ firstUnappliedUnknownSeq: this.unappliedUnknownOps[0]?.seq ?? null,
1410
+ unappliedUnknownCount: this.unappliedUnknownOps.length,
1411
+ retainedJournalBytes,
1412
+ ceiling: {
1413
+ entryCeiling: ENTRY_CEILING,
1414
+ thresholdReached: this.conversations.size >= ENTRY_THRESHOLD || fileSizeBytes >= FILE_BYTES_THRESHOLD,
1415
+ },
1416
+ seq: { counter: this.seqCounter, snapshotHighWaterSeq: this.snapshotHighWaterSeq },
1417
+ };
1418
+ }
1419
+ /** Close file handles + flush (shutdown/tests). */
1420
+ async close() {
1421
+ if (this.snapshotTimer) {
1422
+ clearTimeout(this.snapshotTimer);
1423
+ this.snapshotTimer = null;
1424
+ }
1425
+ await this.flushSnapshot();
1426
+ if (this.journalFd !== null) {
1427
+ try {
1428
+ fs.fsyncSync(this.journalFd);
1429
+ fs.closeSync(this.journalFd);
1430
+ }
1431
+ catch {
1432
+ /* @silent-fallback-ok — shutdown teardown; durable records were fsynced at append time */
1433
+ }
1434
+ this.journalFd = null;
1435
+ }
1436
+ }
1437
+ }
1438
+ //# sourceMappingURL=ConversationRegistry.js.map