instar 1.3.730 → 1.3.732

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 (83) hide show
  1. package/dist/commands/server.d.ts.map +1 -1
  2. package/dist/commands/server.js +161 -1
  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/CoherenceGate.d.ts +119 -0
  8. package/dist/core/CoherenceGate.d.ts.map +1 -1
  9. package/dist/core/CoherenceGate.js +342 -19
  10. package/dist/core/CoherenceGate.js.map +1 -1
  11. package/dist/core/CoherenceReviewer.d.ts +30 -0
  12. package/dist/core/CoherenceReviewer.d.ts.map +1 -1
  13. package/dist/core/CoherenceReviewer.js +14 -0
  14. package/dist/core/CoherenceReviewer.js.map +1 -1
  15. package/dist/core/ConversationRegistry.d.ts +260 -0
  16. package/dist/core/ConversationRegistry.d.ts.map +1 -0
  17. package/dist/core/ConversationRegistry.js +1151 -0
  18. package/dist/core/ConversationRegistry.js.map +1 -0
  19. package/dist/core/FileClassifier.d.ts.map +1 -1
  20. package/dist/core/FileClassifier.js +7 -0
  21. package/dist/core/FileClassifier.js.map +1 -1
  22. package/dist/core/PostUpdateMigrator.d.ts +13 -0
  23. package/dist/core/PostUpdateMigrator.d.ts.map +1 -1
  24. package/dist/core/PostUpdateMigrator.js +105 -0
  25. package/dist/core/PostUpdateMigrator.js.map +1 -1
  26. package/dist/core/ResponseReviewDecisionLog.d.ts +45 -0
  27. package/dist/core/ResponseReviewDecisionLog.d.ts.map +1 -0
  28. package/dist/core/ResponseReviewDecisionLog.js +85 -0
  29. package/dist/core/ResponseReviewDecisionLog.js.map +1 -0
  30. package/dist/core/WriteDomainRegistry.d.ts.map +1 -1
  31. package/dist/core/WriteDomainRegistry.js +21 -0
  32. package/dist/core/WriteDomainRegistry.js.map +1 -1
  33. package/dist/core/conversationContextWiring.d.ts +51 -0
  34. package/dist/core/conversationContextWiring.d.ts.map +1 -0
  35. package/dist/core/conversationContextWiring.js +78 -0
  36. package/dist/core/conversationContextWiring.js.map +1 -0
  37. package/dist/core/conversationIdentity.d.ts +112 -0
  38. package/dist/core/conversationIdentity.d.ts.map +1 -0
  39. package/dist/core/conversationIdentity.js +141 -0
  40. package/dist/core/conversationIdentity.js.map +1 -0
  41. package/dist/core/deliverToConversation.d.ts +62 -0
  42. package/dist/core/deliverToConversation.d.ts.map +1 -0
  43. package/dist/core/deliverToConversation.js +85 -0
  44. package/dist/core/deliverToConversation.js.map +1 -0
  45. package/dist/core/devGatedFeatures.d.ts.map +1 -1
  46. package/dist/core/devGatedFeatures.js +6 -0
  47. package/dist/core/devGatedFeatures.js.map +1 -1
  48. package/dist/core/reviewers/conversational-tone.d.ts.map +1 -1
  49. package/dist/core/reviewers/conversational-tone.js +15 -1
  50. package/dist/core/reviewers/conversational-tone.js.map +1 -1
  51. package/dist/core/types.d.ts +76 -0
  52. package/dist/core/types.d.ts.map +1 -1
  53. package/dist/core/types.js.map +1 -1
  54. package/dist/core/untrustedConversationContext.d.ts +77 -0
  55. package/dist/core/untrustedConversationContext.d.ts.map +1 -0
  56. package/dist/core/untrustedConversationContext.js +134 -0
  57. package/dist/core/untrustedConversationContext.js.map +1 -0
  58. package/dist/monitoring/ReviewCanaryBattery.d.ts +160 -0
  59. package/dist/monitoring/ReviewCanaryBattery.d.ts.map +1 -0
  60. package/dist/monitoring/ReviewCanaryBattery.js +416 -0
  61. package/dist/monitoring/ReviewCanaryBattery.js.map +1 -0
  62. package/dist/scaffold/templates.d.ts.map +1 -1
  63. package/dist/scaffold/templates.js +15 -0
  64. package/dist/scaffold/templates.js.map +1 -1
  65. package/dist/server/AgentServer.d.ts +7 -0
  66. package/dist/server/AgentServer.d.ts.map +1 -1
  67. package/dist/server/AgentServer.js +13 -0
  68. package/dist/server/AgentServer.js.map +1 -1
  69. package/dist/server/CapabilityIndex.d.ts.map +1 -1
  70. package/dist/server/CapabilityIndex.js +15 -0
  71. package/dist/server/CapabilityIndex.js.map +1 -1
  72. package/dist/server/routes.d.ts +10 -0
  73. package/dist/server/routes.d.ts.map +1 -1
  74. package/dist/server/routes.js +114 -1
  75. package/dist/server/routes.js.map +1 -1
  76. package/package.json +1 -1
  77. package/src/data/builtin-manifest.json +64 -64
  78. package/src/scaffold/templates/jobs/instar/review-canary-battery.md +31 -0
  79. package/src/scaffold/templates.ts +15 -0
  80. package/upgrades/1.3.731.md +97 -0
  81. package/upgrades/1.3.732.md +107 -0
  82. package/upgrades/side-effects/context-aware-outbound-review.md +62 -0
  83. package/upgrades/side-effects/durable-conversation-identity-increment1.md +89 -0
@@ -0,0 +1,1151 @@
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
+ const ENTRY_CEILING = 50000; // §3.4 JSON-store design ceiling
51
+ const ENTRY_THRESHOLD = 40000; // 80% of ceiling — the pinned tripwire (R3-minor)
52
+ const FILE_BYTES_THRESHOLD = 8 * 1024 * 1024; // 80% of ~10MB
53
+ const OFFLOOP_ENTRY_TRIGGER = 20000; // §3.4 pinned off-loop flush trigger
54
+ const OFFLOOP_BYTES_TRIGGER = 2 * 1024 * 1024;
55
+ const PENDING_MINT_MAX = 1000; // §3.6 pinned
56
+ const BREAKER_MAX_TRACKED_CHANNELS = 512; // bounded budget-state map (§3.3, R3-minor)
57
+ export class ConversationRegistry {
58
+ d;
59
+ snapshotPath;
60
+ journalPath;
61
+ // ── Authoritative in-memory cache + synchronous indexes (§3.4 1–2) ──
62
+ conversations = new Map(); // canonical key → entry
63
+ byId = new Map(); // id→entry reverse index
64
+ byTuple = new Map(); // tupleKey → entry
65
+ aliases = new Map(); // loserId → winnerId (one hop)
66
+ // ── Derived indexes (§3.4 3–5) — rebuilt at boot, maintained at assign time ──
67
+ reservedCanonicals = new Map(); // cand → owning tupleKey
68
+ displacedAssignments = new Map(); // offset → owning tupleKey (GLOBAL — R4-C1)
69
+ candClaimants = new Map(); // cand → claimant tupleKeys
70
+ // ── §5.0(a)/§3.5.2 journal-applied state (writers land in later increments) ──
71
+ bindPins = new Map();
72
+ ambiguousSends = new Map();
73
+ sendIntents = new Map();
74
+ workspacePin = null;
75
+ // ── Journal state ──
76
+ seqCounter = 0;
77
+ snapshotHighWaterSeq = 0;
78
+ journalFd = null;
79
+ journalBytes = 0;
80
+ journalLines = 0;
81
+ /** Unknown-op skip-and-preserve set (R8-minor-2) → snapshot-flush SUSPENSION (R9-M1/R10-M1). */
82
+ unappliedUnknownOps = [];
83
+ // ── Snapshot batching ──
84
+ snapshotTimer = null;
85
+ snapshotDirty = false;
86
+ lastSnapshotBytes = 0;
87
+ batchDepth = 0; // adoption-pass batched-save window (§6.2)
88
+ flushInFlight = false;
89
+ // ── Breaker + degradation state ──
90
+ breakerWindows = new Map();
91
+ breakerEpisodes = 0;
92
+ pendingMints = new Map();
93
+ pendingMintDrops = 0;
94
+ // ── Observability ──
95
+ lastMintAt = null;
96
+ durabilityIncidents = 0;
97
+ snapshotQuarantinedAt = null;
98
+ journalQuarantinedAt = null;
99
+ adoptionState = null;
100
+ loaded = false;
101
+ constructor(deps) {
102
+ this.d = deps;
103
+ this.snapshotPath = path.join(deps.stateDir, 'state', 'conversation-registry.json');
104
+ this.journalPath = path.join(deps.stateDir, 'conversation-registry.jsonl');
105
+ }
106
+ now() {
107
+ return (this.d.now ?? (() => new Date()))();
108
+ }
109
+ log(line) {
110
+ try {
111
+ this.d.log?.(line);
112
+ }
113
+ catch {
114
+ /* observability never gates */
115
+ }
116
+ }
117
+ attention(dedupeKey, title, body) {
118
+ try {
119
+ this.d.onAttention?.(dedupeKey, title, body);
120
+ }
121
+ catch {
122
+ /* attention is observability — never gates identity or delivery */
123
+ }
124
+ }
125
+ recordingEnabled() {
126
+ try {
127
+ return this.d.isRecordingEnabled ? this.d.isRecordingEnabled() !== false : true;
128
+ }
129
+ catch {
130
+ return true;
131
+ }
132
+ }
133
+ // ─────────────────────────────────────────────────────────────────────────
134
+ // Boot: snapshot load + journal replay (§3.4 WAL crash-consistency contract)
135
+ // ─────────────────────────────────────────────────────────────────────────
136
+ /** Idempotent boot-time load: snapshot, then journal tail replay in global
137
+ * `seq` order across rotated files (§6.2 recovery order 1–2). */
138
+ load() {
139
+ if (this.loaded)
140
+ return;
141
+ this.loaded = true;
142
+ try {
143
+ fs.mkdirSync(path.join(this.d.stateDir, 'state'), { recursive: true });
144
+ }
145
+ catch {
146
+ /* exists */
147
+ }
148
+ this.loadSnapshot();
149
+ this.replayJournal();
150
+ this.rebuildDerivedIndexes();
151
+ this.applyAliasAssignmentFilter(); // R6-M3 pin 2: the disjointness invariant holds at every BOOT fixpoint
152
+ if (this.unappliedUnknownOps.length > 0) {
153
+ const first = this.unappliedUnknownOps[0];
154
+ const kinds = [...new Set(this.unappliedUnknownOps.map((u) => u.op))].join(', ');
155
+ 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.`);
156
+ }
157
+ // Resolve the config workspace pin (source 1 — authoritative when present).
158
+ const configPin = this.readConfigPin();
159
+ if (configPin) {
160
+ this.workspacePin = { value: configPin, source: 'config', confirmedLocally: true };
161
+ }
162
+ }
163
+ readConfigPin() {
164
+ try {
165
+ const v = this.d.getConfigWorkspacePin?.();
166
+ return v && SLACK_WORKSPACE_ID_RE.test(v) ? v : undefined;
167
+ }
168
+ catch {
169
+ return undefined;
170
+ }
171
+ }
172
+ loadSnapshot() {
173
+ if (!fs.existsSync(this.snapshotPath))
174
+ return;
175
+ try {
176
+ const raw = JSON.parse(fs.readFileSync(this.snapshotPath, 'utf-8'));
177
+ if (!raw || typeof raw !== 'object' || raw.version !== 1)
178
+ throw new Error('unrecognized snapshot shape');
179
+ this.snapshotHighWaterSeq = typeof raw.snapshotHighWaterSeq === 'number' ? raw.snapshotHighWaterSeq : 0;
180
+ this.seqCounter = this.snapshotHighWaterSeq;
181
+ if (raw.workspacePin && typeof raw.workspacePin.value === 'string')
182
+ this.workspacePin = raw.workspacePin;
183
+ for (const [key, entry] of Object.entries(raw.conversations ?? {})) {
184
+ if (!entry || typeof entry.id !== 'number' || !(entry.id < 0) || !Number.isSafeInteger(entry.id))
185
+ continue; // id<0 clamp on every write (§3.3)
186
+ this.indexEntry(key, entry);
187
+ }
188
+ for (const [loser, winner] of Object.entries(raw.aliases ?? {})) {
189
+ const l = Number(loser);
190
+ if (Number.isSafeInteger(l) && l < 0 && typeof winner === 'number' && winner < 0)
191
+ this.aliases.set(l, winner);
192
+ }
193
+ for (const [id, pin] of Object.entries(raw.bindPins ?? {})) {
194
+ const n = Number(id);
195
+ if (Number.isSafeInteger(n) && pin && Array.isArray(pin.tuple) && typeof pin.refcount === 'number') {
196
+ this.bindPins.set(n, { tuple: pin.tuple, refcount: pin.refcount });
197
+ }
198
+ }
199
+ for (const [k, v] of Object.entries(raw.ambiguousSends ?? {})) {
200
+ if (v && typeof v.recordedAt === 'string')
201
+ this.ambiguousSends.set(k, v);
202
+ }
203
+ for (const [k, v] of Object.entries(raw.sendIntents ?? {})) {
204
+ if (v && (v.lane === 'logical' || v.lane === 'content-hash') && typeof v.seq === 'number')
205
+ this.sendIntents.set(k, v);
206
+ }
207
+ this.lastSnapshotBytes = fs.statSync(this.snapshotPath).size;
208
+ }
209
+ catch (err) {
210
+ // Corrupt-file quarantine-aside (§3.6 — the TopicPlacementPinStore
211
+ // pattern): preserve aside, ONE deduped attention item, rebuild from the
212
+ // journal (a journal-only rebuild replays every retained file from empty
213
+ // state in global seq order — §3.4 rotation note).
214
+ const aside = `${this.snapshotPath}.corrupt-${Date.now()}`;
215
+ try {
216
+ fs.renameSync(this.snapshotPath, aside);
217
+ }
218
+ catch {
219
+ /* best-effort — the report below still fires */
220
+ }
221
+ this.snapshotQuarantinedAt = this.now().toISOString();
222
+ this.snapshotHighWaterSeq = 0;
223
+ this.seqCounter = 0;
224
+ 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).`);
225
+ }
226
+ }
227
+ /** Retained journal files in replay order: rotated (ascending epoch) then live. */
228
+ journalFiles() {
229
+ const dir = this.d.stateDir;
230
+ const base = path.basename(this.journalPath);
231
+ let names = [];
232
+ try {
233
+ names = fs.readdirSync(dir).filter((n) => n.startsWith(`${base}.`) && /\.\d+$/.test(n));
234
+ }
235
+ catch {
236
+ names = [];
237
+ }
238
+ const rotated = names
239
+ .map((n) => ({ n, epoch: Number(n.slice(base.length + 1)) }))
240
+ .sort((a, b) => a.epoch - b.epoch)
241
+ .map((x) => path.join(dir, x.n));
242
+ return fs.existsSync(this.journalPath) ? [...rotated, this.journalPath] : rotated;
243
+ }
244
+ replayJournal() {
245
+ const files = this.journalFiles();
246
+ if (files.length === 0)
247
+ return;
248
+ // Torn-tail handling (§3.4): a crash mid-append leaves the LIVE file's last
249
+ // line unterminated — only a fully-written, newline-terminated line is a
250
+ // committed record. The torn tail is DISCARDED (truncated) so later appends
251
+ // can never fuse onto an uncommitted fragment.
252
+ if (fs.existsSync(this.journalPath)) {
253
+ try {
254
+ const buf = fs.readFileSync(this.journalPath);
255
+ if (buf.length > 0 && buf[buf.length - 1] !== 0x0a) {
256
+ const lastNl = buf.lastIndexOf(0x0a);
257
+ fs.truncateSync(this.journalPath, lastNl === -1 ? 0 : lastNl + 1);
258
+ this.log('[conversation-registry] discarded torn journal tail (uncommitted record)');
259
+ }
260
+ }
261
+ catch {
262
+ /* read failure falls through to the per-line handling below */
263
+ }
264
+ }
265
+ const records = [];
266
+ let halted = false;
267
+ for (const file of files) {
268
+ if (halted)
269
+ break;
270
+ let content;
271
+ try {
272
+ content = fs.readFileSync(file, 'utf-8');
273
+ }
274
+ catch {
275
+ continue;
276
+ }
277
+ const lines = content.split('\n');
278
+ for (let i = 0; i < lines.length; i++) {
279
+ const line = lines[i];
280
+ if (line.length === 0)
281
+ continue;
282
+ let rec;
283
+ try {
284
+ rec = JSON.parse(line);
285
+ if (!rec || typeof rec.seq !== 'number' || typeof rec.op !== 'string')
286
+ throw new Error('malformed record');
287
+ }
288
+ catch {
289
+ // NON-tail corruption fails CLOSED (R7-minor-3): a newline-TERMINATED
290
+ // line failing parse is a storage lie, never skipped — HALT the replay
291
+ // into the quarantine-aside path + attention + durability incident.
292
+ const aside = `${file}.corrupt-${Date.now()}`;
293
+ try {
294
+ fs.renameSync(file, aside);
295
+ }
296
+ catch {
297
+ /* preserve best-effort */
298
+ }
299
+ this.journalQuarantinedAt = this.now().toISOString();
300
+ this.durabilityIncidents++; // §3.7 broadened SQLite-migration trigger input
301
+ 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).`);
302
+ halted = true;
303
+ break;
304
+ }
305
+ records.push({ rec, file });
306
+ }
307
+ }
308
+ records.sort((a, b) => a.rec.seq - b.rec.seq); // single global seq order (R3-M14)
309
+ let maxSeq = this.snapshotHighWaterSeq;
310
+ for (const { rec } of records) {
311
+ if (rec.seq > maxSeq)
312
+ maxSeq = rec.seq;
313
+ if (rec.seq <= this.snapshotHighWaterSeq)
314
+ continue; // snapshot already incorporates it
315
+ this.applyJournalRecord(rec);
316
+ }
317
+ // Boot counter resumes from the max seen — never 0/1 (R3-M14).
318
+ this.seqCounter = Math.max(this.seqCounter, maxSeq);
319
+ this.journalBytes = fs.existsSync(this.journalPath) ? fs.statSync(this.journalPath).size : 0;
320
+ this.journalLines = 0;
321
+ if (fs.existsSync(this.journalPath)) {
322
+ try {
323
+ const content = fs.readFileSync(this.journalPath, 'utf-8');
324
+ this.journalLines = content.length === 0 ? 0 : content.split('\n').filter((l) => l.length > 0).length;
325
+ }
326
+ catch {
327
+ /* count is rotation bookkeeping only */
328
+ }
329
+ }
330
+ }
331
+ /** Idempotent, seq-ordered application (§3.4) — safe to re-run any number of times. */
332
+ applyJournalRecord(rec) {
333
+ if (!JOURNAL_OPS.includes(rec.op)) {
334
+ // UNKNOWN-op tolerance (R8-minor-2): version skew, not corruption — skip
335
+ // the application, PRESERVE the line (append-only; never rewritten), and
336
+ // suspend snapshot flushing (R9-M1/R10-M1) until a recognizing version
337
+ // applies it. The deduped attention item is raised once after replay.
338
+ this.unappliedUnknownOps.push({ seq: rec.seq, op: rec.op });
339
+ return;
340
+ }
341
+ switch (rec.op) {
342
+ case 'mint': {
343
+ if (typeof rec.id !== 'number' || !(rec.id < 0) || !Array.isArray(rec.tuple))
344
+ return;
345
+ const tuple = { platform: 'slack', channelId: rec.tuple[1], threadTs: rec.tuple[2] ?? null };
346
+ const tKey = tupleKeyFor(tuple);
347
+ const existing = this.byTuple.get(tKey);
348
+ if (existing) {
349
+ // Re-apply / metadata upgrade (the `_`→teamId upgrade is journaled as
350
+ // op:"mint" with the rewritten key — §3.1): same tuple, refreshed key.
351
+ if (rec.key && rec.key !== canonicalKeyFor(tuple, existing.workspaceId)) {
352
+ const parsed = parseCanonicalKey(rec.key);
353
+ if (parsed && parsed.workspaceId !== existing.workspaceId) {
354
+ this.conversations.delete(canonicalKeyFor(tuple, existing.workspaceId));
355
+ existing.workspaceId = parsed.workspaceId;
356
+ this.conversations.set(rec.key, existing);
357
+ }
358
+ }
359
+ return; // idempotent
360
+ }
361
+ const parsed = rec.key ? parseCanonicalKey(rec.key) : null;
362
+ const entry = {
363
+ id: rec.id,
364
+ platform: 'slack',
365
+ workspaceId: parsed?.workspaceId ?? WORKSPACE_PLACEHOLDER,
366
+ channelId: tuple.channelId,
367
+ threadTs: tuple.threadTs,
368
+ mintedAt: rec.ts,
369
+ mintedBy: rec.hlc?.node ?? 'unknown',
370
+ origin: rec.origin ?? 'adopted-legacy-hash',
371
+ reachability: 'ok',
372
+ hlc: rec.hlc ?? { physical: Date.parse(rec.ts) || 0, logical: 0, node: 'unknown' },
373
+ };
374
+ this.indexEntry(canonicalKeyFor(tuple, entry.workspaceId), entry);
375
+ return;
376
+ }
377
+ case 'alias': {
378
+ if (typeof rec.id === 'number' && typeof rec.target === 'number')
379
+ this.aliases.set(rec.id, rec.target);
380
+ return;
381
+ }
382
+ case 'reachability': {
383
+ if (typeof rec.id !== 'number' || !rec.reachability)
384
+ return;
385
+ const entry = this.byId.get(rec.id);
386
+ if (entry)
387
+ entry.reachability = rec.reachability;
388
+ return;
389
+ }
390
+ case 'bind-pin': {
391
+ if (typeof rec.id !== 'number' || !Array.isArray(rec.tuple))
392
+ return;
393
+ const existing = this.bindPins.get(rec.id);
394
+ if (existing)
395
+ existing.refcount = rec.refcount ?? existing.refcount + 1;
396
+ else
397
+ this.bindPins.set(rec.id, { tuple: rec.tuple, refcount: rec.refcount ?? 1 });
398
+ return;
399
+ }
400
+ case 'bind-release': {
401
+ if (typeof rec.id !== 'number')
402
+ return;
403
+ const pin = this.bindPins.get(rec.id);
404
+ if (!pin)
405
+ return;
406
+ pin.refcount = rec.refcount ?? pin.refcount - 1;
407
+ if (pin.refcount <= 0)
408
+ this.bindPins.delete(rec.id);
409
+ return;
410
+ }
411
+ case 'ambiguous-send': {
412
+ if (typeof rec.conversationId !== 'number' || !rec.logicalSendId)
413
+ return;
414
+ this.ambiguousSends.set(`${rec.conversationId}|${rec.logicalSendId}`, { recordedAt: rec.ts });
415
+ this.sendIntents.delete(`${rec.conversationId}|${rec.logicalSendId}`);
416
+ return;
417
+ }
418
+ case 'send-retire': {
419
+ if (typeof rec.conversationId !== 'number' || !rec.logicalSendId)
420
+ return;
421
+ this.ambiguousSends.delete(`${rec.conversationId}|${rec.logicalSendId}`);
422
+ this.sendIntents.delete(`${rec.conversationId}|${rec.logicalSendId}`);
423
+ return;
424
+ }
425
+ case 'send-intent': {
426
+ if (typeof rec.conversationId !== 'number' || !rec.logicalSendId)
427
+ return;
428
+ // Malformed/unknown lane resolves toward RETRY (content-hash treatment)
429
+ // — R9-minor-1/R10-low-2 (loss-is-never-silent picks retry).
430
+ const lane = rec.lane === 'logical' ? 'logical' : 'content-hash';
431
+ this.sendIntents.set(`${rec.conversationId}|${rec.logicalSendId}`, { lane, seq: rec.seq });
432
+ return;
433
+ }
434
+ case 'send-intent-resolved': {
435
+ if (typeof rec.conversationId !== 'number' || !rec.logicalSendId)
436
+ return;
437
+ this.sendIntents.delete(`${rec.conversationId}|${rec.logicalSendId}`);
438
+ return;
439
+ }
440
+ }
441
+ }
442
+ indexEntry(key, entry) {
443
+ this.conversations.set(key, entry);
444
+ this.byId.set(entry.id, entry);
445
+ this.byTuple.set(tupleKeyFor({ platform: 'slack', channelId: entry.channelId, threadTs: entry.threadTs }), entry);
446
+ }
447
+ /** Derived indexes 3–5 (§3.4) — pure functions of the entry set, rebuilt at boot. */
448
+ rebuildDerivedIndexes() {
449
+ this.reservedCanonicals.clear();
450
+ this.displacedAssignments.clear();
451
+ this.candClaimants.clear();
452
+ for (const entry of this.conversations.values()) {
453
+ const tuple = { platform: 'slack', channelId: entry.channelId, threadTs: entry.threadTs };
454
+ const cand = candidateIdForRoutingKey(routingKeyForTuple(tuple));
455
+ const tKey = tupleKeyFor(tuple);
456
+ let claimants = this.candClaimants.get(cand);
457
+ if (!claimants) {
458
+ claimants = new Set();
459
+ this.candClaimants.set(cand, claimants);
460
+ }
461
+ claimants.add(tKey);
462
+ if (entry.id === cand)
463
+ this.reservedCanonicals.set(cand, tKey);
464
+ else
465
+ this.displacedAssignments.set(entry.id, tKey);
466
+ }
467
+ }
468
+ /** R5-C1/R6-M3: the assignment-beats-alias filter re-run over composed state —
469
+ * an alias shadowing a reserved canonical or an assigned displacement offset
470
+ * is dropped exactly as it would be at ingest. */
471
+ applyAliasAssignmentFilter() {
472
+ for (const aliasId of [...this.aliases.keys()]) {
473
+ if (this.reservedCanonicals.has(aliasId) || this.displacedAssignments.has(aliasId)) {
474
+ this.aliases.delete(aliasId);
475
+ }
476
+ }
477
+ }
478
+ // ─────────────────────────────────────────────────────────────────────────
479
+ // Journal append (§3.4 — dedicated single-writer discipline, G3)
480
+ // ─────────────────────────────────────────────────────────────────────────
481
+ /**
482
+ * Append ONE newline-terminated record. `seq` assignment and the byte-write
483
+ * happen atomically per record under a synchronous append (Node's
484
+ * single-threaded sync I/O IS the append mutex — no interleaving is
485
+ * possible), satisfying the §3.4 G3 single-writer contract.
486
+ */
487
+ appendJournal(rec, opts) {
488
+ const seq = ++this.seqCounter;
489
+ const line = `${JSON.stringify({ seq, ...rec, ts: this.now().toISOString() })}\n`;
490
+ this.rotateJournalIfNeeded(Buffer.byteLength(line));
491
+ if (this.journalFd === null) {
492
+ const created = !fs.existsSync(this.journalPath);
493
+ this.journalFd = fs.openSync(this.journalPath, 'a');
494
+ if (created)
495
+ this.fsyncDir(); // directory entry durable on file creation (§3.4)
496
+ }
497
+ fs.writeSync(this.journalFd, line);
498
+ this.journalBytes += Buffer.byteLength(line);
499
+ this.journalLines += 1;
500
+ if (opts.fsync && !(this.d.isJournalFsyncDisabled?.() ?? false)) {
501
+ try {
502
+ fs.fsyncSync(this.journalFd);
503
+ }
504
+ catch {
505
+ /* macOS platform footnote (§3.4): cheap-fsync is the deliberate choice */
506
+ }
507
+ }
508
+ return seq;
509
+ }
510
+ fsyncDir() {
511
+ try {
512
+ const dirFd = fs.openSync(this.d.stateDir, 'r');
513
+ try {
514
+ fs.fsyncSync(dirFd);
515
+ }
516
+ finally {
517
+ fs.closeSync(dirFd);
518
+ }
519
+ }
520
+ catch {
521
+ /* @silent-fallback-ok — directory fsync is best-effort, platform-dependent (§3.4 macOS footnote); the record append itself is the durability authority */
522
+ }
523
+ }
524
+ rotateJournalIfNeeded(incomingBytes) {
525
+ const rotateBytes = this.d.journalRotateBytes ?? 8388608;
526
+ const rotateLines = this.d.journalRotateLines ?? 50000;
527
+ if (this.journalBytes + incomingBytes <= rotateBytes && this.journalLines < rotateLines)
528
+ return;
529
+ if (!fs.existsSync(this.journalPath))
530
+ return;
531
+ if (this.journalFd !== null) {
532
+ try {
533
+ fs.fsyncSync(this.journalFd);
534
+ }
535
+ catch {
536
+ /* @silent-fallback-ok — pre-rotation fsync is best-effort; every durable record already fsynced at append time (§3.3 WAL rule) */
537
+ }
538
+ fs.closeSync(this.journalFd);
539
+ this.journalFd = null;
540
+ }
541
+ // Unique all-digit suffix (two rotations can land in one millisecond; the
542
+ // suffix must keep matching the retained-file scan + the backup glob).
543
+ let suffix = Date.now();
544
+ while (fs.existsSync(`${this.journalPath}.${suffix}`))
545
+ suffix++;
546
+ const rotatedPath = `${this.journalPath}.${suffix}`;
547
+ fs.renameSync(this.journalPath, rotatedPath);
548
+ this.fsyncDir();
549
+ this.journalBytes = 0;
550
+ this.journalLines = 0;
551
+ this.pruneRotatedJournals();
552
+ }
553
+ /**
554
+ * Prune ONLY fully-superseded rotated files: every record ≤ the PERSISTED
555
+ * snapshot high-water, older than the 7-day retention floor (§8 — a recovery
556
+ * requirement), and containing no unapplied unknown-op record (R8-minor-2).
557
+ * Under snapshot suspension the persisted high-water stays pre-skew, so every
558
+ * file the eventual re-upgrade needs is retained MECHANICALLY (R10-M1).
559
+ */
560
+ pruneRotatedJournals() {
561
+ const persistedHighWater = this.readPersistedHighWater();
562
+ const retentionFloorMs = 7 * 24 * 60 * 60 * 1000;
563
+ const unknownSeqs = new Set(this.unappliedUnknownOps.map((u) => u.seq));
564
+ for (const file of this.journalFiles()) {
565
+ if (file === this.journalPath)
566
+ continue;
567
+ try {
568
+ const stat = fs.statSync(file);
569
+ if (this.now().getTime() - stat.mtimeMs < retentionFloorMs)
570
+ continue;
571
+ const content = fs.readFileSync(file, 'utf-8');
572
+ let maxSeq = 0;
573
+ let hasUnknown = false;
574
+ for (const line of content.split('\n')) {
575
+ if (!line)
576
+ continue;
577
+ try {
578
+ const rec = JSON.parse(line);
579
+ if (typeof rec.seq === 'number' && rec.seq > maxSeq)
580
+ maxSeq = rec.seq;
581
+ if (!JOURNAL_OPS.includes(rec.op) || unknownSeqs.has(rec.seq))
582
+ hasUnknown = true;
583
+ }
584
+ catch {
585
+ hasUnknown = true; // @silent-fallback-ok — fails toward RETENTION: a file we cannot fully read is never pruned (§3.4)
586
+ }
587
+ }
588
+ if (!hasUnknown && maxSeq > 0 && maxSeq <= persistedHighWater) {
589
+ SafeFsExecutor.safeUnlinkSync(file, { operation: 'conversation-registry journal rotation prune (fully-superseded rotated file — §3.4)' });
590
+ }
591
+ }
592
+ catch {
593
+ /* @silent-fallback-ok — prune is hygiene; a failed prune retains the file (the safe direction), never gates identity */
594
+ }
595
+ }
596
+ }
597
+ readPersistedHighWater() {
598
+ try {
599
+ const raw = JSON.parse(fs.readFileSync(this.snapshotPath, 'utf-8'));
600
+ return typeof raw?.snapshotHighWaterSeq === 'number' ? raw.snapshotHighWaterSeq : 0;
601
+ }
602
+ catch {
603
+ // @silent-fallback-ok — an unreadable snapshot reads as high-water 0, so the
604
+ // prune rule supersedes NOTHING (fails toward retaining every journal file).
605
+ return 0;
606
+ }
607
+ }
608
+ // ─────────────────────────────────────────────────────────────────────────
609
+ // Snapshot (batched, size-adaptive — §3.4)
610
+ // ─────────────────────────────────────────────────────────────────────────
611
+ scheduleSnapshot() {
612
+ this.snapshotDirty = true;
613
+ if (this.batchDepth > 0)
614
+ return; // batched-save window (§6.2 adoption pass)
615
+ if (this.unappliedUnknownOps.length > 0)
616
+ return; // SUSPENSION (R9-M1/R10-M1)
617
+ if (this.snapshotTimer)
618
+ return;
619
+ const base = this.d.snapshotBaseIntervalMs ?? 2000;
620
+ const step = this.d.snapshotAdaptiveStep ?? 5000;
621
+ const max = this.d.snapshotMaxIntervalMs ?? 60000;
622
+ const interval = Math.min(Math.max(base * Math.ceil(Math.max(this.conversations.size, 1) / step), base), max);
623
+ this.snapshotTimer = setTimeout(() => {
624
+ this.snapshotTimer = null;
625
+ void this.flushSnapshot();
626
+ }, interval);
627
+ this.snapshotTimer.unref?.();
628
+ }
629
+ /** Force a flush now (shutdown/tests). Honors the unknown-op suspension. */
630
+ async flushSnapshot() {
631
+ if (this.flushInFlight)
632
+ return;
633
+ if (this.unappliedUnknownOps.length > 0)
634
+ return; // pre-skew snapshot stays put (R10-M1)
635
+ this.flushInFlight = true;
636
+ try {
637
+ const shape = {
638
+ version: 1,
639
+ snapshotHighWaterSeq: this.seqCounter,
640
+ ...(this.workspacePin ? { workspacePin: this.workspacePin } : {}),
641
+ conversations: Object.fromEntries(this.conversations),
642
+ aliases: Object.fromEntries([...this.aliases].map(([k, v]) => [String(k), v])),
643
+ bindPins: Object.fromEntries([...this.bindPins].map(([k, v]) => [String(k), v])),
644
+ ambiguousSends: Object.fromEntries(this.ambiguousSends),
645
+ sendIntents: Object.fromEntries(this.sendIntents),
646
+ };
647
+ const serialized = JSON.stringify(shape, null, 2);
648
+ const tmp = `${this.snapshotPath}.tmp`;
649
+ if (this.conversations.size > OFFLOOP_ENTRY_TRIGGER || this.lastSnapshotBytes > OFFLOOP_BYTES_TRIGGER) {
650
+ // §3.4 pinned off-loop trigger: async write of the pre-serialized buffer.
651
+ await fs.promises.writeFile(tmp, serialized);
652
+ await fs.promises.rename(tmp, this.snapshotPath);
653
+ }
654
+ else {
655
+ fs.writeFileSync(tmp, serialized);
656
+ fs.renameSync(tmp, this.snapshotPath);
657
+ }
658
+ this.snapshotHighWaterSeq = shape.snapshotHighWaterSeq;
659
+ this.lastSnapshotBytes = Buffer.byteLength(serialized);
660
+ this.snapshotDirty = false;
661
+ this.maybeRaiseGrowthTripwire();
662
+ }
663
+ catch (err) {
664
+ this.log(`[conversation-registry] snapshot flush failed: ${err instanceof Error ? err.message : String(err)}`);
665
+ }
666
+ finally {
667
+ this.flushInFlight = false;
668
+ }
669
+ }
670
+ maybeRaiseGrowthTripwire() {
671
+ if (this.conversations.size >= ENTRY_THRESHOLD || this.lastSnapshotBytes >= FILE_BYTES_THRESHOLD) {
672
+ 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).`);
673
+ }
674
+ }
675
+ /** Batched-save window for the adoption pass / bursts (§3.4/§6.2): one flush. */
676
+ beginBatch() {
677
+ this.batchDepth++;
678
+ }
679
+ endBatch() {
680
+ this.batchDepth = Math.max(0, this.batchDepth - 1);
681
+ if (this.batchDepth === 0 && this.snapshotDirty)
682
+ this.scheduleSnapshot();
683
+ }
684
+ // ─────────────────────────────────────────────────────────────────────────
685
+ // Mint (§3.3)
686
+ // ─────────────────────────────────────────────────────────────────────────
687
+ /** §3.3 `candidateCollides` — pure O(1) lookups, never a live-tuple scan:
688
+ * (a) reserved canonical of a DIFFERENT tuple; (b) alias table; (c) the
689
+ * GLOBAL displaced-assignment set (R4-C1). */
690
+ candidateCollides(id, tKey) {
691
+ const reserved = this.reservedCanonicals.get(id);
692
+ if (reserved !== undefined && reserved !== tKey)
693
+ return true;
694
+ if (this.aliases.has(id))
695
+ return true;
696
+ const displaced = this.displacedAssignments.get(id);
697
+ if (displaced !== undefined && displaced !== tKey)
698
+ return true;
699
+ return false;
700
+ }
701
+ /** Resolve the fleet workspace pin (§3.1 order: config → stored candidate). */
702
+ resolvedWorkspacePin() {
703
+ const configPin = this.readConfigPin();
704
+ if (configPin)
705
+ return { value: configPin, confirmedLocally: true };
706
+ return this.workspacePin;
707
+ }
708
+ /**
709
+ * Workspace admission for a mint (§3.1). Returns the workspaceId to record
710
+ * (concrete or `_`), or 'refused' on the per-machine multi-workspace gate.
711
+ */
712
+ admitWorkspace() {
713
+ let observed;
714
+ try {
715
+ observed = this.d.getLocalWorkspaceId?.();
716
+ }
717
+ catch {
718
+ // @silent-fallback-ok — a throwing workspace source degrades to the `_`
719
+ // placeholder (upgrades in place later, §3.1) — never a blocked mint.
720
+ observed = undefined;
721
+ }
722
+ if (!observed || !SLACK_WORKSPACE_ID_RE.test(observed))
723
+ return WORKSPACE_PLACEHOLDER;
724
+ const pin = this.resolvedWorkspacePin();
725
+ if (!pin) {
726
+ // First concrete LOCAL observation writes the candidate and is immediately
727
+ // confirmed for it — self-corroboration is the designed single-machine /
728
+ // first-machine path (R6-minor-5).
729
+ this.workspacePin = { value: observed, source: 'local-observed', confirmedLocally: true };
730
+ this.scheduleSnapshot();
731
+ return observed;
732
+ }
733
+ if (pin.value !== observed && pin.confirmedLocally) {
734
+ 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.`);
735
+ return 'refused';
736
+ }
737
+ return observed;
738
+ }
739
+ /** Breaker window state for a channel — bounded map with stale-window eviction (§3.3, R3-minor). */
740
+ breakerWindow(channelId) {
741
+ const windowMs = this.d.breaker?.windowMs ?? 600000;
742
+ const nowMs = this.now().getTime();
743
+ let w = this.breakerWindows.get(channelId);
744
+ if (!w || nowMs - w.windowStartMs >= windowMs) {
745
+ w = { windowStartMs: nowMs, speculative: 0, durable: 0, episodeNotified: false };
746
+ if (!this.breakerWindows.has(channelId) && this.breakerWindows.size >= BREAKER_MAX_TRACKED_CHANNELS) {
747
+ // Evict the stalest window — never a monotonic map (Bounded Blast Radius).
748
+ let oldest = null;
749
+ let oldestStart = Infinity;
750
+ for (const [k, v] of this.breakerWindows) {
751
+ if (v.windowStartMs < oldestStart) {
752
+ oldestStart = v.windowStartMs;
753
+ oldest = k;
754
+ }
755
+ }
756
+ if (oldest)
757
+ this.breakerWindows.delete(oldest);
758
+ }
759
+ this.breakerWindows.set(channelId, w);
760
+ }
761
+ return w;
762
+ }
763
+ /**
764
+ * Speculative (inbound-triggered) get-or-create mint (§6.3 eager mint).
765
+ * NEVER throws and never blocks delivery — every failure degrades toward
766
+ * "no durable id" or a collision-checked read (§3.6).
767
+ */
768
+ mintForInbound(routingKey, opts) {
769
+ this.load();
770
+ const tuple = tupleForRoutingKey(routingKey);
771
+ if (!tuple)
772
+ return { id: null, created: false, registered: false, degraded: 'unparseable-key' };
773
+ return this.mintTuple(tuple, { durableBinding: false, label: opts?.label, origin: undefined });
774
+ }
775
+ /**
776
+ * Durable-binding-forced mint (§3.3 breaker carve-out): registered REGARDLESS
777
+ * of the speculative budget, journal line fsynced BEFORE the id returns (the
778
+ * WAL rule). Its OWN higher budget yields a typed capacity refusal at the
779
+ * cap — never a silent drop (adversarial-B).
780
+ */
781
+ mintForDurableBinding(routingKey, opts) {
782
+ this.load();
783
+ const tuple = tupleForRoutingKey(routingKey);
784
+ if (!tuple)
785
+ return { ok: false, error: 'unparseable-key' };
786
+ if (!this.recordingEnabled()) {
787
+ // R2-integration-§9: an unjournaled bind would be unresolvable after a
788
+ // restart — refuse, typed + loud. Positive Telegram binds are unaffected
789
+ // (they never reach the registry).
790
+ 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.');
791
+ return { ok: false, error: 'conversation-recording-disabled' };
792
+ }
793
+ const existing = this.byTuple.get(tupleKeyFor(tuple));
794
+ if (existing) {
795
+ this.maybeUpgradeWorkspace(existing);
796
+ return { ok: true, id: existing.id, created: false };
797
+ }
798
+ const w = this.breakerWindow(tuple.channelId);
799
+ const durableCap = this.d.breaker?.durableBindingPerWindow ?? 50;
800
+ if (w.durable >= durableCap) {
801
+ 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).`);
802
+ return { ok: false, error: 'conversation-registration-capacity' };
803
+ }
804
+ const res = this.mintTuple(tuple, { durableBinding: true, label: opts?.label });
805
+ if (res.id === null || !res.registered) {
806
+ if (res.degraded === 'multi-workspace-unsupported')
807
+ return { ok: false, error: 'multi-workspace-unsupported' };
808
+ return { ok: false, error: 'mint-failure' };
809
+ }
810
+ w.durable++;
811
+ return { ok: true, id: res.id, created: res.created };
812
+ }
813
+ /** §3.1 in-place `_`→teamId upgrade — LOCAL authenticated source ONLY. */
814
+ maybeUpgradeWorkspace(entry) {
815
+ if (entry.workspaceId !== WORKSPACE_PLACEHOLDER)
816
+ return;
817
+ const admitted = this.admitWorkspace();
818
+ if (admitted === 'refused' || admitted === WORKSPACE_PLACEHOLDER)
819
+ return;
820
+ const tuple = { platform: 'slack', channelId: entry.channelId, threadTs: entry.threadTs };
821
+ const oldKey = canonicalKeyFor(tuple, entry.workspaceId);
822
+ this.conversations.delete(oldKey);
823
+ entry.workspaceId = admitted;
824
+ const newKey = canonicalKeyFor(tuple, admitted);
825
+ this.conversations.set(newKey, entry);
826
+ // Journaled (§3.1) as an idempotent op:"mint" re-apply with the rewritten
827
+ // key (the op enum has no distinct upgrade op — §3.4/R6-low-2 precedent).
828
+ this.appendJournal({
829
+ op: 'mint',
830
+ key: newKey,
831
+ tuple: [tuple.platform, tuple.channelId, tuple.threadTs],
832
+ id: entry.id,
833
+ origin: entry.origin,
834
+ hlc: entry.hlc,
835
+ }, { fsync: false });
836
+ this.scheduleSnapshot();
837
+ this.log(`[conversation-registry] workspace upgraded in place: ${oldKey} → ${newKey} (id ${entry.id})`);
838
+ }
839
+ mintTuple(tuple, opts) {
840
+ const tKey = tupleKeyFor(tuple);
841
+ const existing = this.byTuple.get(tKey);
842
+ if (existing) {
843
+ this.maybeUpgradeWorkspace(existing);
844
+ if (opts.label && opts.label !== existing.label) {
845
+ existing.label = opts.label; // write-on-change (§3.4 G4)
846
+ this.scheduleSnapshot();
847
+ }
848
+ return { id: existing.id, created: false, registered: true };
849
+ }
850
+ const routingKey = routingKeyForTuple(tuple);
851
+ const candidate = candidateIdForRoutingKey(routingKey);
852
+ const workspace = this.admitWorkspace();
853
+ if (workspace === 'refused') {
854
+ return { id: null, created: false, registered: false, degraded: 'multi-workspace-unsupported' };
855
+ }
856
+ if (!this.recordingEnabled()) {
857
+ // D1 degradation (§3.6): behavior-identical to legacy hashing — candidate
858
+ // + collision-checked read (B6), NO durable write, NO journal fsync.
859
+ return { id: this.collisionCheckedRead(candidate, tKey), created: false, registered: false, degraded: 'recording-disabled' };
860
+ }
861
+ if (!opts.durableBinding) {
862
+ const w = this.breakerWindow(tuple.channelId);
863
+ const cap = this.d.breaker?.speculativePerWindow ?? 200;
864
+ if (w.speculative >= cap) {
865
+ // The breaker DROPS speculative registrations to NOWHERE (zero pending
866
+ // state) — the candidate re-mints for free on a later inbound. Delivery
867
+ // still proceeds on a collision-checked read (B6).
868
+ this.breakerEpisodes++;
869
+ if (!w.episodeNotified) {
870
+ w.episodeNotified = true;
871
+ 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).`);
872
+ }
873
+ return { id: this.collisionCheckedRead(candidate, tKey), created: false, registered: false, degraded: 'breaker-dropped' };
874
+ }
875
+ w.speculative++;
876
+ }
877
+ const walk = walkDisplacement(candidate, (id) => this.candidateCollides(id, tKey));
878
+ if (!walk.ok) {
879
+ // Probe overflow (astronomically unlikely) degrades to the §3.6
880
+ // pending-mint path — never a silently-un-ingestable id.
881
+ const key = canonicalKeyFor(tuple, workspace);
882
+ if (!this.pendingMints.has(key)) {
883
+ if (this.pendingMints.size >= PENDING_MINT_MAX)
884
+ this.pendingMintDrops++;
885
+ else
886
+ this.pendingMints.set(key, { firstAt: this.now().toISOString() });
887
+ }
888
+ return { id: null, created: false, registered: false, degraded: 'probe-overflow' };
889
+ }
890
+ const probed = walk.probes > 0;
891
+ const nowIso = this.now().toISOString();
892
+ const machineId = this.safeMachineId();
893
+ const entry = {
894
+ id: walk.id,
895
+ platform: 'slack',
896
+ workspaceId: workspace,
897
+ channelId: tuple.channelId,
898
+ threadTs: tuple.threadTs,
899
+ mintedAt: nowIso,
900
+ mintedBy: machineId,
901
+ // A fresh non-probed local mint's id IS the legacy-hash id — the honest
902
+ // origin within the frozen §3.4 enum; probed mints are 'minted-probed'.
903
+ origin: opts.origin ?? (probed ? 'minted-probed' : 'adopted-legacy-hash'),
904
+ reachability: 'ok',
905
+ hlc: { physical: this.now().getTime(), logical: 0, node: machineId },
906
+ ...(opts.label ? { label: opts.label } : {}),
907
+ };
908
+ const key = canonicalKeyFor(tuple, workspace);
909
+ // Synchronous assignment: authoritative cache + reverse + tuple index +
910
+ // derived occupancy — the id RETURNED equals the id that will PERSIST.
911
+ this.indexEntry(key, entry);
912
+ let claimants = this.candClaimants.get(candidate);
913
+ if (!claimants) {
914
+ claimants = new Set();
915
+ this.candClaimants.set(candidate, claimants);
916
+ }
917
+ claimants.add(tKey);
918
+ if (!probed)
919
+ this.reservedCanonicals.set(candidate, tKey);
920
+ else
921
+ this.displacedAssignments.set(walk.id, tKey);
922
+ // WAL rule (§3.3): a PROBED or durable-binding-forced mint append+fsyncs
923
+ // ONE journal line BEFORE the id is returned (not re-derivable after a
924
+ // crash). A pure SPECULATIVE non-probed mint performs NO synchronous
925
+ // journal write — it rides the batched snapshot only (§3.4 fsync
926
+ // discipline; its candidate re-mints deterministically for free).
927
+ if (probed || opts.durableBinding) {
928
+ this.appendJournal({
929
+ op: 'mint',
930
+ key,
931
+ tuple: [tuple.platform, tuple.channelId, tuple.threadTs],
932
+ id: entry.id,
933
+ origin: entry.origin,
934
+ hlc: entry.hlc,
935
+ }, { fsync: true });
936
+ }
937
+ this.pendingMints.delete(key);
938
+ this.lastMintAt = nowIso;
939
+ this.scheduleSnapshot();
940
+ return { id: entry.id, created: true, registered: true };
941
+ }
942
+ safeMachineId() {
943
+ try {
944
+ return this.d.machineId() || 'unknown';
945
+ }
946
+ catch {
947
+ return 'unknown';
948
+ }
949
+ }
950
+ /**
951
+ * B6 collision-checked read for degraded paths (breaker-drop / recording-off):
952
+ * never delivers on a raw candidate occupied by a DIFFERENT tuple — resolves
953
+ * via the same key-derived probe for the READ ONLY (no registration).
954
+ */
955
+ collisionCheckedRead(candidate, tKey) {
956
+ const walk = walkDisplacement(candidate, (id) => this.candidateCollides(id, tKey));
957
+ return walk.ok ? walk.id : candidate;
958
+ }
959
+ // ─────────────────────────────────────────────────────────────────────────
960
+ // Resolution (§2/§8)
961
+ // ─────────────────────────────────────────────────────────────────────────
962
+ /** `resolve(id)`: positive → Telegram pass-through; negative → registry
963
+ * lookup, aliases followed exactly ONE hop; unknown → null. */
964
+ resolve(id) {
965
+ this.load();
966
+ if (!Number.isSafeInteger(id) || id === 0)
967
+ return null;
968
+ if (id > 0)
969
+ return { platform: 'telegram', topicId: id, passThrough: true };
970
+ const aliasTarget = this.aliases.get(id);
971
+ const entry = this.byId.get(aliasTarget ?? id);
972
+ if (!entry)
973
+ return null;
974
+ return {
975
+ platform: 'slack',
976
+ id: entry.id,
977
+ key: canonicalKeyFor({ platform: 'slack', channelId: entry.channelId, threadTs: entry.threadTs }, entry.workspaceId),
978
+ channelId: entry.channelId,
979
+ threadTs: entry.threadTs,
980
+ workspaceId: entry.workspaceId,
981
+ origin: entry.origin,
982
+ reachability: entry.reachability,
983
+ ...(entry.label !== undefined ? { label: entry.label } : {}),
984
+ ...(aliasTarget !== undefined ? { aliasOf: aliasTarget } : {}),
985
+ };
986
+ }
987
+ /** Forward lookup by canonical key OR transport sessionKey — mints NOTHING (§8). */
988
+ resolveByKey(keyOrSessionKey) {
989
+ this.load();
990
+ if (/^\d+$/.test(keyOrSessionKey)) {
991
+ return { platform: 'telegram', topicId: Number(keyOrSessionKey), passThrough: true };
992
+ }
993
+ const parsed = parseCanonicalKey(keyOrSessionKey);
994
+ const tuple = parsed?.tuple ?? tupleForRoutingKey(keyOrSessionKey);
995
+ if (!tuple)
996
+ return null;
997
+ const entry = this.byTuple.get(tupleKeyFor(tuple));
998
+ return entry ? this.resolve(entry.id) : null;
999
+ }
1000
+ /**
1001
+ * `idForSessionKey` — GET-OR-CREATE (§6.0 #12, a named mint chokepoint).
1002
+ * Positive numeric session keys pass through; Slack routing keys mint.
1003
+ */
1004
+ idForSessionKey(sessionKey) {
1005
+ this.load();
1006
+ if (/^\d+$/.test(sessionKey))
1007
+ return Number(sessionKey);
1008
+ return this.mintForInbound(sessionKey).id;
1009
+ }
1010
+ /** Read-only inventory for GET /conversations (§8). */
1011
+ list(opts) {
1012
+ this.load();
1013
+ const limit = Math.max(1, Math.min(opts?.limit ?? 500, 5000));
1014
+ const out = [];
1015
+ for (const [key, entry] of this.conversations) {
1016
+ if (opts?.platform && entry.platform !== opts.platform)
1017
+ continue;
1018
+ out.push({ key, entry });
1019
+ if (out.length >= limit)
1020
+ break;
1021
+ }
1022
+ return out;
1023
+ }
1024
+ aliasTable() {
1025
+ this.load();
1026
+ return Object.fromEntries([...this.aliases].map(([k, v]) => [String(k), v]));
1027
+ }
1028
+ entryCount() {
1029
+ this.load();
1030
+ return this.conversations.size;
1031
+ }
1032
+ // ─────────────────────────────────────────────────────────────────────────
1033
+ // Adoption pass (§6.2)
1034
+ // ─────────────────────────────────────────────────────────────────────────
1035
+ /**
1036
+ * Idempotent boot-time ensure: pre-register channel-level conversations from
1037
+ * the channel registry with their legacy-hash ids, inside ONE batched-save
1038
+ * window. GATED to channels with ≥1 authorized-sender message on record
1039
+ * (security-B8) — an auto-join channel mints lazily on its first authorized
1040
+ * inbound instead. A pre-population CONVENIENCE, not a recovery requirement.
1041
+ */
1042
+ runAdoptionPass(channels, hasAuthorizedTraffic) {
1043
+ this.load();
1044
+ let adopted = 0;
1045
+ let skippedUnauthorized = 0;
1046
+ this.beginBatch();
1047
+ try {
1048
+ for (const ch of channels) {
1049
+ let authorized = false;
1050
+ try {
1051
+ authorized = hasAuthorizedTraffic(ch.channelId);
1052
+ }
1053
+ catch {
1054
+ authorized = false; // fail toward not-pre-minting (it mints lazily later)
1055
+ }
1056
+ if (!authorized) {
1057
+ skippedUnauthorized++;
1058
+ continue;
1059
+ }
1060
+ const res = this.mintForInbound(ch.channelId, ch.name ? { label: ch.name } : undefined);
1061
+ if (res.created)
1062
+ adopted++;
1063
+ }
1064
+ }
1065
+ finally {
1066
+ this.endBatch();
1067
+ }
1068
+ this.adoptionState = { ranAt: this.now().toISOString(), adopted, skippedUnauthorized };
1069
+ this.log(`[conversation-registry] adoption pass: ${adopted} adopted, ${skippedUnauthorized} skipped (no authorized traffic)`);
1070
+ return { adopted, skippedUnauthorized };
1071
+ }
1072
+ // ─────────────────────────────────────────────────────────────────────────
1073
+ // Health (§8 — the e2e "feature is alive" target)
1074
+ // ─────────────────────────────────────────────────────────────────────────
1075
+ health() {
1076
+ this.load();
1077
+ const byOrigin = {};
1078
+ const byPlatform = {};
1079
+ for (const entry of this.conversations.values()) {
1080
+ byOrigin[entry.origin] = (byOrigin[entry.origin] ?? 0) + 1;
1081
+ byPlatform[entry.platform] = (byPlatform[entry.platform] ?? 0) + 1;
1082
+ }
1083
+ let fileSizeBytes = 0;
1084
+ try {
1085
+ fileSizeBytes = fs.existsSync(this.snapshotPath) ? fs.statSync(this.snapshotPath).size : 0;
1086
+ }
1087
+ catch {
1088
+ /* observability */
1089
+ }
1090
+ let retainedJournalBytes = 0;
1091
+ try {
1092
+ for (const f of this.journalFiles())
1093
+ retainedJournalBytes += fs.statSync(f).size;
1094
+ }
1095
+ catch {
1096
+ /* observability */
1097
+ }
1098
+ return {
1099
+ // Resident-heap honesty (§3.4): entryCount is the heap axis — resident
1100
+ // heap is plausibly 5–10× fileSizeBytes at the 100k envelope.
1101
+ entryCount: this.conversations.size,
1102
+ fileSizeBytes,
1103
+ byPlatform,
1104
+ byOrigin,
1105
+ aliasCount: this.aliases.size,
1106
+ lastMintAt: this.lastMintAt,
1107
+ adoptionPass: this.adoptionState,
1108
+ recordingEnabled: this.recordingEnabled(),
1109
+ workspacePin: this.workspacePin ? { value: this.workspacePin.value, source: this.workspacePin.source } : null,
1110
+ mintBudget: {
1111
+ channelsTracked: this.breakerWindows.size,
1112
+ breakerEpisodes: this.breakerEpisodes,
1113
+ },
1114
+ pendingMints: { count: this.pendingMints.size, dropped: this.pendingMintDrops },
1115
+ quarantine: {
1116
+ snapshotQuarantinedAt: this.snapshotQuarantinedAt,
1117
+ journalQuarantinedAt: this.journalQuarantinedAt,
1118
+ durabilityIncidents: this.durabilityIncidents,
1119
+ },
1120
+ // Snapshot-suspension observability (R11-low-1).
1121
+ snapshotSuspended: this.unappliedUnknownOps.length > 0,
1122
+ firstUnappliedUnknownSeq: this.unappliedUnknownOps[0]?.seq ?? null,
1123
+ unappliedUnknownCount: this.unappliedUnknownOps.length,
1124
+ retainedJournalBytes,
1125
+ ceiling: {
1126
+ entryCeiling: ENTRY_CEILING,
1127
+ thresholdReached: this.conversations.size >= ENTRY_THRESHOLD || fileSizeBytes >= FILE_BYTES_THRESHOLD,
1128
+ },
1129
+ seq: { counter: this.seqCounter, snapshotHighWaterSeq: this.snapshotHighWaterSeq },
1130
+ };
1131
+ }
1132
+ /** Close file handles + flush (shutdown/tests). */
1133
+ async close() {
1134
+ if (this.snapshotTimer) {
1135
+ clearTimeout(this.snapshotTimer);
1136
+ this.snapshotTimer = null;
1137
+ }
1138
+ await this.flushSnapshot();
1139
+ if (this.journalFd !== null) {
1140
+ try {
1141
+ fs.fsyncSync(this.journalFd);
1142
+ fs.closeSync(this.journalFd);
1143
+ }
1144
+ catch {
1145
+ /* @silent-fallback-ok — shutdown teardown; durable records were fsynced at append time */
1146
+ }
1147
+ this.journalFd = null;
1148
+ }
1149
+ }
1150
+ }
1151
+ //# sourceMappingURL=ConversationRegistry.js.map