instar 1.3.489 → 1.3.490

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 (106) hide show
  1. package/dist/commands/server.d.ts.map +1 -1
  2. package/dist/commands/server.js +979 -28
  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 +23 -0
  6. package/dist/config/ConfigDefaults.js.map +1 -1
  7. package/dist/core/CodexResumeMap.d.ts +95 -0
  8. package/dist/core/CodexResumeMap.d.ts.map +1 -0
  9. package/dist/core/CodexResumeMap.js +283 -0
  10. package/dist/core/CodexResumeMap.js.map +1 -0
  11. package/dist/core/MeshRpc.d.ts +3 -0
  12. package/dist/core/MeshRpc.d.ts.map +1 -1
  13. package/dist/core/MeshRpc.js +5 -0
  14. package/dist/core/MeshRpc.js.map +1 -1
  15. package/dist/core/ModelSwapService.d.ts +26 -11
  16. package/dist/core/ModelSwapService.d.ts.map +1 -1
  17. package/dist/core/ModelSwapService.js +59 -21
  18. package/dist/core/ModelSwapService.js.map +1 -1
  19. package/dist/core/PostUpdateMigrator.d.ts +16 -0
  20. package/dist/core/PostUpdateMigrator.d.ts.map +1 -1
  21. package/dist/core/PostUpdateMigrator.js +62 -2
  22. package/dist/core/PostUpdateMigrator.js.map +1 -1
  23. package/dist/core/SessionManager.d.ts +4 -0
  24. package/dist/core/SessionManager.d.ts.map +1 -1
  25. package/dist/core/SessionManager.js +3 -0
  26. package/dist/core/SessionManager.js.map +1 -1
  27. package/dist/core/SessionRefresh.d.ts +36 -7
  28. package/dist/core/SessionRefresh.d.ts.map +1 -1
  29. package/dist/core/SessionRefresh.js +90 -29
  30. package/dist/core/SessionRefresh.js.map +1 -1
  31. package/dist/core/TopicProfileOrchestrator.d.ts +480 -0
  32. package/dist/core/TopicProfileOrchestrator.d.ts.map +1 -0
  33. package/dist/core/TopicProfileOrchestrator.js +1404 -0
  34. package/dist/core/TopicProfileOrchestrator.js.map +1 -0
  35. package/dist/core/TopicProfileResolver.d.ts +104 -0
  36. package/dist/core/TopicProfileResolver.d.ts.map +1 -0
  37. package/dist/core/TopicProfileResolver.js +231 -0
  38. package/dist/core/TopicProfileResolver.js.map +1 -0
  39. package/dist/core/TopicProfileStore.d.ts +308 -0
  40. package/dist/core/TopicProfileStore.d.ts.map +1 -0
  41. package/dist/core/TopicProfileStore.js +781 -0
  42. package/dist/core/TopicProfileStore.js.map +1 -0
  43. package/dist/core/TopicProfileTransferCarrier.d.ts +227 -0
  44. package/dist/core/TopicProfileTransferCarrier.d.ts.map +1 -0
  45. package/dist/core/TopicProfileTransferCarrier.js +533 -0
  46. package/dist/core/TopicProfileTransferCarrier.js.map +1 -0
  47. package/dist/core/TopicResumeMap.d.ts +67 -1
  48. package/dist/core/TopicResumeMap.d.ts.map +1 -1
  49. package/dist/core/TopicResumeMap.js +114 -1
  50. package/dist/core/TopicResumeMap.js.map +1 -1
  51. package/dist/core/classifyProfileChange.d.ts +83 -0
  52. package/dist/core/classifyProfileChange.d.ts.map +1 -0
  53. package/dist/core/classifyProfileChange.js +274 -0
  54. package/dist/core/classifyProfileChange.js.map +1 -0
  55. package/dist/core/devGatedFeatures.d.ts.map +1 -1
  56. package/dist/core/devGatedFeatures.js +6 -0
  57. package/dist/core/devGatedFeatures.js.map +1 -1
  58. package/dist/core/frameworkSessionLaunch.d.ts +26 -0
  59. package/dist/core/frameworkSessionLaunch.d.ts.map +1 -1
  60. package/dist/core/frameworkSessionLaunch.js +58 -0
  61. package/dist/core/frameworkSessionLaunch.js.map +1 -1
  62. package/dist/core/slackRefreshBinding.d.ts +83 -0
  63. package/dist/core/slackRefreshBinding.d.ts.map +1 -0
  64. package/dist/core/slackRefreshBinding.js +54 -0
  65. package/dist/core/slackRefreshBinding.js.map +1 -0
  66. package/dist/core/topicProfileIngress.d.ts +128 -0
  67. package/dist/core/topicProfileIngress.d.ts.map +1 -0
  68. package/dist/core/topicProfileIngress.js +249 -0
  69. package/dist/core/topicProfileIngress.js.map +1 -0
  70. package/dist/core/topicProfileValidation.d.ts +109 -0
  71. package/dist/core/topicProfileValidation.d.ts.map +1 -0
  72. package/dist/core/topicProfileValidation.js +247 -0
  73. package/dist/core/topicProfileValidation.js.map +1 -0
  74. package/dist/core/topicProfileWriteSurface.d.ts +222 -0
  75. package/dist/core/topicProfileWriteSurface.d.ts.map +1 -0
  76. package/dist/core/topicProfileWriteSurface.js +631 -0
  77. package/dist/core/topicProfileWriteSurface.js.map +1 -0
  78. package/dist/core/types.d.ts +37 -0
  79. package/dist/core/types.d.ts.map +1 -1
  80. package/dist/core/types.js.map +1 -1
  81. package/dist/lifeline/TelegramLifeline.d.ts.map +1 -1
  82. package/dist/lifeline/TelegramLifeline.js +6 -0
  83. package/dist/lifeline/TelegramLifeline.js.map +1 -1
  84. package/dist/messaging/TelegramAdapter.d.ts +10 -1
  85. package/dist/messaging/TelegramAdapter.d.ts.map +1 -1
  86. package/dist/messaging/TelegramAdapter.js +41 -1
  87. package/dist/messaging/TelegramAdapter.js.map +1 -1
  88. package/dist/scaffold/templates.d.ts.map +1 -1
  89. package/dist/scaffold/templates.js +9 -0
  90. package/dist/scaffold/templates.js.map +1 -1
  91. package/dist/server/AgentServer.d.ts +32 -0
  92. package/dist/server/AgentServer.d.ts.map +1 -1
  93. package/dist/server/AgentServer.js +52 -3
  94. package/dist/server/AgentServer.js.map +1 -1
  95. package/dist/server/CapabilityIndex.d.ts.map +1 -1
  96. package/dist/server/CapabilityIndex.js +1 -0
  97. package/dist/server/CapabilityIndex.js.map +1 -1
  98. package/dist/server/routes.d.ts +17 -0
  99. package/dist/server/routes.d.ts.map +1 -1
  100. package/dist/server/routes.js +271 -1
  101. package/dist/server/routes.js.map +1 -1
  102. package/package.json +1 -1
  103. package/src/data/builtin-manifest.json +66 -66
  104. package/src/scaffold/templates.ts +9 -0
  105. package/upgrades/1.3.490.md +34 -0
  106. package/upgrades/side-effects/topic-profile.md +142 -0
@@ -0,0 +1,781 @@
1
+ /**
2
+ * TopicProfileStore — sticky per-topic execution profile (framework / model /
3
+ * thinking-mode), TOPIC-PROFILE-SPEC §4/§5.1.
4
+ *
5
+ * Modeled on TopicFrameworksStore for the file/atomic-write shape, with the
6
+ * deliberate departures the spec's review required:
7
+ *
8
+ * - Single-writer serialized `mutate(topicKey, patch)` (the CommitmentTracker
9
+ * CAS pattern), NOT a load→overwrite-whole-object `set`. The profile has
10
+ * three independently-writable axes — a whole-object rewrite would clobber
11
+ * a concurrently-set field. `mutate` does a read-modify-write under a
12
+ * per-topic process-local lock, merging only the changed fields.
13
+ * - In-memory cache is AUTHORITATIVE for reads. The file is read once at
14
+ * boot and on explicit external-change invalidation only — per-spawn
15
+ * resolution is O(1) (§5.1).
16
+ * - Durability precedes acknowledgment: `mutate` resolves only after a flush
17
+ * containing its write has durably landed (tmp+rename completed). A failed
18
+ * flush REFUSES the write out loud (throws FlushRefusedError) AND ROLLS
19
+ * BACK the in-memory mutation to the last durably-flushed snapshot — on a
20
+ * failed COALESCED flush, every waiter whose write is not yet durable is
21
+ * refused and rolled back together (§5.1, rounds 5-7).
22
+ * - Queued flush waiters COALESCE: a mutate arriving while a flush is in
23
+ * flight is satisfied by a single trailing flush of the latest cache
24
+ * snapshot — a batch of N writes costs O(1-2) flushes (§5.1, round 6).
25
+ * - The undo snapshot ({ current, previous }) is durable in the same file;
26
+ * `previous` shifts once per delta-carrying disclosure — the ORCHESTRATION
27
+ * layer signals the shift via opts.shiftPrevious (it owns the §8 burst /
28
+ * coalescing-window accounting); the store never guesses the cadence.
29
+ * - Legacy `state/topic-frameworks.json` is a ONE-DIRECTIONAL read-only seed
30
+ * (never overwrites a profile entry) + a profile-store-written mirror so a
31
+ * binary rollback still reads current framework data (§5.1).
32
+ * - The dry-run shadow (`intendedProfile`, §14) is a sibling field that
33
+ * resolution NEVER reads; it has its own lifecycle (flip-clear, skew fates).
34
+ * - §10.4 parked intended-but-unhealthy profiles + breaker counter live on
35
+ * the entry so a revert can re-offer the pin and a new operator pin can
36
+ * atomically supersede the parked state in the same mutate.
37
+ */
38
+ import fs from 'node:fs';
39
+ import path from 'node:path';
40
+ import { SUPPORTED_FRAMEWORKS } from './TopicFrameworksStore.js';
41
+ import { DegradationReporter } from '../monitoring/DegradationReporter.js';
42
+ import { modelTierMutualExclusionError, validateProfileFields, } from './topicProfileValidation.js';
43
+ export class FlushRefusedError extends Error {
44
+ constructor(cause) {
45
+ super(`topic-profile flush failed — the write is REFUSED and rolled back (durability precedes acknowledgment): ${cause instanceof Error ? cause.message : String(cause)}`);
46
+ this.name = 'FlushRefusedError';
47
+ }
48
+ }
49
+ export class ProfileLockTimeoutError extends Error {
50
+ constructor(topicKey) {
51
+ super(`could not acquire the profile lock for topic ${topicKey} — a respawn is in progress`);
52
+ this.name = 'ProfileLockTimeoutError';
53
+ }
54
+ }
55
+ export class ProfileValidationRefusal extends Error {
56
+ validation;
57
+ constructor(validation) {
58
+ super(validation.reason);
59
+ this.validation = validation;
60
+ this.name = 'ProfileValidationRefusal';
61
+ }
62
+ }
63
+ const WRITE_LOCK_TIMEOUT_MS = 5_000;
64
+ const PROFILE_FIELDS = ['framework', 'model', 'modelTier', 'escalationOverride', 'thinkingMode'];
65
+ export class TopicProfileStore {
66
+ stateFilePath;
67
+ legacyFrameworksPath;
68
+ isDryRun;
69
+ now;
70
+ /** AUTHORITATIVE in-memory cache (§5.1). */
71
+ topics = {};
72
+ /** Deep snapshot of the last durably-flushed topics map (rollback target). */
73
+ lastFlushed = {};
74
+ mirrorGeneratedAt;
75
+ /** Per-topic process-local locks (promise chains). */
76
+ locks = new Map();
77
+ /** Store-wide serialized flush queue (§5.1 round-4/6). */
78
+ flushInFlight = null;
79
+ trailingFlush = null;
80
+ constructor(options) {
81
+ this.stateFilePath = options.stateFilePath;
82
+ this.legacyFrameworksPath = options.legacyFrameworksPath ?? null;
83
+ this.isDryRun = options.isDryRun ?? (() => false);
84
+ this.now = options.now ?? (() => new Date());
85
+ this.audit = options.audit ?? (() => { });
86
+ this.loadFromDisk();
87
+ }
88
+ audit;
89
+ // ── reads (cache-only, O(1)) ─────────────────────────────────────────────
90
+ /** Entry for a topic, or null. Cache read — never touches disk. */
91
+ get(topicKey) {
92
+ return this.topics[String(topicKey)] ?? null;
93
+ }
94
+ /** The live profile for a topic (resolution input). Never the shadow. */
95
+ resolve(topicKey) {
96
+ return this.topics[String(topicKey)]?.current ?? null;
97
+ }
98
+ /** Undo target (§10.3). Null when nothing to undo yet. */
99
+ previousFor(topicKey) {
100
+ return this.topics[String(topicKey)]?.previous ?? null;
101
+ }
102
+ /** Admin/migration only (§5.1) — read surfaces use get(). */
103
+ all() {
104
+ return { ...this.topics };
105
+ }
106
+ // ── per-topic lock (shared ordering primitive, §5.1/§8) ──────────────────
107
+ /**
108
+ * Run `fn` holding the topic's process-local lock. The §8 RESPAWN phase and
109
+ * escalation swaps serialize through this same lock; `mutate` uses it for
110
+ * the WRITE phase. `timeoutMs` bounds ACQUISITION (a wedged holder causes a
111
+ * ProfileLockTimeoutError, not an unbounded queue); the holder itself is
112
+ * bounded by the caller (§8 RESPAWN-phase TTL).
113
+ */
114
+ async withTopicLock(topicKey, fn, timeoutMs = WRITE_LOCK_TIMEOUT_MS) {
115
+ const key = String(topicKey);
116
+ const prior = this.locks.get(key) ?? Promise.resolve();
117
+ let release;
118
+ const gate = new Promise((res) => (release = res));
119
+ // Chain our slot regardless of the prior holder's outcome.
120
+ const slot = prior.then(() => gate, () => gate);
121
+ this.locks.set(key, slot);
122
+ // Bounded acquisition: wait for the prior holder or time out.
123
+ let acquired = false;
124
+ await Promise.race([
125
+ prior.then(() => (acquired = true), () => (acquired = true)),
126
+ new Promise((res) => setTimeout(res, timeoutMs).unref?.()),
127
+ ]);
128
+ if (!acquired) {
129
+ // Preserve chain integrity: our slot releases only when the still-running
130
+ // prior holder finishes — otherwise the next waiter would run
131
+ // concurrently with it.
132
+ prior.then(() => release(), () => release());
133
+ throw new ProfileLockTimeoutError(key);
134
+ }
135
+ try {
136
+ return await fn();
137
+ }
138
+ finally {
139
+ release();
140
+ if (this.locks.get(key) === slot)
141
+ this.locks.delete(key);
142
+ }
143
+ }
144
+ // ── mutate (§5.1 WRITE phase) ────────────────────────────────────────────
145
+ /**
146
+ * Field-merged single-writer write. `null` field values CLEAR the field;
147
+ * `undefined` fields are untouched. Enforces the §4 model/modelTier hard
148
+ * mutual exclusion against the MERGE RESULT. Resolves only after the write
149
+ * is durable; throws FlushRefusedError (with rollback) when it is not.
150
+ */
151
+ async mutate(topicKey, patch, opts = {}) {
152
+ const key = String(topicKey);
153
+ return this.withTopicLock(key, async () => {
154
+ const entry = this.entryFor(key);
155
+ const base = entry.current ?? {
156
+ updatedAt: this.now().toISOString(),
157
+ updatedBy: patch.updatedBy,
158
+ };
159
+ // Merge only the supplied fields.
160
+ const merged = { ...base };
161
+ let changed = false;
162
+ for (const field of PROFILE_FIELDS) {
163
+ if (patch[field] === undefined)
164
+ continue;
165
+ const next = patch[field];
166
+ if (merged[field] !== next)
167
+ changed = true;
168
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
169
+ merged[field] = next;
170
+ }
171
+ // §4 hard refusal against the merge RESULT (a patch setting model on
172
+ // a tier-pinned topic without clearing the tier is refused too).
173
+ const exclusion = modelTierMutualExclusionError(merged);
174
+ if (exclusion)
175
+ throw new ProfileValidationRefusal(exclusion);
176
+ if (!changed) {
177
+ return { changed: false, entry, supersededParked: false };
178
+ }
179
+ merged.updatedAt = this.now().toISOString();
180
+ merged.updatedBy = patch.updatedBy;
181
+ // §10.4 — a deliberate operator pin atomically supersedes a parked
182
+ // intended-but-unhealthy profile + the breaker counter in the SAME
183
+ // mutate. System writes (breaker revert itself) manage parked state
184
+ // through park()/unparkProfile(), not here.
185
+ const isOperatorWrite = !patch.updatedBy.startsWith('system:');
186
+ let supersededParked = false;
187
+ if (isOperatorWrite && entry.parked) {
188
+ entry.parked = null;
189
+ entry.breakerCount = 0;
190
+ supersededParked = true;
191
+ }
192
+ if (opts.shiftPrevious) {
193
+ entry.previous = entry.current ? { ...entry.current } : null;
194
+ }
195
+ entry.current = merged;
196
+ // An accepted LIVE write clears that topic's stale dry-run shadow
197
+ // (§14 supersession discipline).
198
+ if (entry.intendedProfile)
199
+ entry.intendedProfile = null;
200
+ await this.flushDurably();
201
+ return { changed: true, entry, supersededParked };
202
+ }, opts.lockTimeoutMs ?? WRITE_LOCK_TIMEOUT_MS);
203
+ }
204
+ // ── REPLACE (transfer-apply / restore-apply, §5.3/§12) ───────────────────
205
+ /**
206
+ * Wholesale per-topic REPLACE — NOT a field-merge (§5.3 round-3: a merge
207
+ * would skip nulls and resurrect a pin the operator cleared remotely).
208
+ * `previous` is pinned to the receiving machine's pre-replace `current`
209
+ * (undo means "back to what this machine had") — EXCEPT when the REPLACE
210
+ * produces no effective delta (A→B→A round-trip, duplicate retried pull):
211
+ * then `previous` must NOT shift (§5.1 round-10).
212
+ *
213
+ * Arriving-shadow fate under regime/timing skew (§14 round-13):
214
+ * (i) a NON-dry-run receiver discards the arriving shadow;
215
+ * (ii) an arriving entry with NO shadow never destroys a populated local
216
+ * shadow on a still-dry-run receiver — it is retained.
217
+ */
218
+ async replaceEntry(topicKey, arriving, opts = {}) {
219
+ const key = String(topicKey);
220
+ // §5.3/§10.2 — receiving-machine revalidation of the ARRIVING entry
221
+ // (closed-enum + framework-compat) BEFORE it can persist or drive a
222
+ // launch. Failing fields fall to null; provenance travels verbatim.
223
+ let arrivingCurrent = arriving.current;
224
+ let arrivingShadow = arriving.intendedProfile;
225
+ const droppedFields = [];
226
+ if (opts.revalidate) {
227
+ const rv = revalidateArrivingProfile(arrivingCurrent, opts.revalidate.fallbackFramework);
228
+ arrivingCurrent = rv.profile;
229
+ droppedFields.push(...rv.dropped);
230
+ if (arrivingShadow != null) {
231
+ const sv = revalidateArrivingShadow(arrivingShadow, opts.revalidate.fallbackFramework);
232
+ arrivingShadow = sv.shadow;
233
+ droppedFields.push(...sv.dropped);
234
+ }
235
+ }
236
+ return this.withTopicLock(key, async () => {
237
+ const entry = this.entryFor(key);
238
+ const delta = !profilesEqual(entry.current, arrivingCurrent);
239
+ let discardedArrivingShadow = false;
240
+ let retainedLocalShadow = false;
241
+ const dryRun = this.isDryRun();
242
+ let nextShadow;
243
+ if (arrivingShadow != null) {
244
+ if (dryRun) {
245
+ nextShadow = arrivingShadow;
246
+ }
247
+ else {
248
+ nextShadow = null;
249
+ discardedArrivingShadow = true;
250
+ }
251
+ }
252
+ else if (entry.intendedProfile != null && dryRun) {
253
+ nextShadow = entry.intendedProfile;
254
+ retainedLocalShadow = true;
255
+ }
256
+ else {
257
+ nextShadow = null;
258
+ }
259
+ if (delta) {
260
+ entry.previous = entry.current ? { ...entry.current } : null;
261
+ entry.current = arrivingCurrent ? { ...arrivingCurrent } : null;
262
+ }
263
+ entry.intendedProfile = nextShadow;
264
+ await this.flushDurably();
265
+ return { delta, entry, retainedLocalShadow, discardedArrivingShadow, droppedFields };
266
+ });
267
+ }
268
+ // ── dry-run shadow lifecycle (§14) ───────────────────────────────────────
269
+ /** Record a dry-run intent. Never read by resolution. */
270
+ async setShadow(topicKey, fields, recordedBy) {
271
+ const key = String(topicKey);
272
+ await this.withTopicLock(key, async () => {
273
+ const entry = this.entryFor(key);
274
+ entry.intendedProfile = {
275
+ fields,
276
+ recordedAt: this.now().toISOString(),
277
+ recordedBy,
278
+ };
279
+ await this.flushDurably();
280
+ });
281
+ }
282
+ /**
283
+ * The dryRun true→false flip clears EVERY topic's shadow — intents are
284
+ * NEVER promoted, at the flip or ever (§14 round-12). Returns the cleared
285
+ * intents so the caller can surface the ONE coalesced expired-intents
286
+ * notice.
287
+ */
288
+ async clearAllShadows() {
289
+ const cleared = [];
290
+ for (const [key, entry] of Object.entries(this.topics)) {
291
+ if (entry.intendedProfile) {
292
+ cleared.push({ topicKey: key, shadow: entry.intendedProfile });
293
+ entry.intendedProfile = null;
294
+ }
295
+ }
296
+ if (cleared.length > 0)
297
+ await this.flushDurably();
298
+ return cleared;
299
+ }
300
+ // ── §10.4 parked-profile + breaker counter ───────────────────────────────
301
+ /** Park the current profile as intended-but-unhealthy and revert. */
302
+ async parkAndRevert(topicKey, reason, revertTo) {
303
+ const key = String(topicKey);
304
+ await this.withTopicLock(key, async () => {
305
+ const entry = this.entryFor(key);
306
+ if (entry.current) {
307
+ entry.parked = {
308
+ profile: { ...entry.current },
309
+ reason,
310
+ parkedAt: this.now().toISOString(),
311
+ };
312
+ }
313
+ entry.previous = entry.current ? { ...entry.current } : null;
314
+ entry.current = revertTo
315
+ ? { ...revertTo, updatedAt: this.now().toISOString(), updatedBy: 'system:circuit-breaker' }
316
+ : null;
317
+ entry.breakerCount = 0;
318
+ await this.flushDurably();
319
+ });
320
+ }
321
+ /** The parked pin, for the §5.2(b) re-apply recovery write. */
322
+ parkedFor(topicKey) {
323
+ return this.topics[String(topicKey)]?.parked ?? null;
324
+ }
325
+ /** Clear the parked state (after a successful re-apply). */
326
+ async clearParked(topicKey) {
327
+ const key = String(topicKey);
328
+ await this.withTopicLock(key, async () => {
329
+ const entry = this.entryFor(key);
330
+ if (entry.parked) {
331
+ entry.parked = null;
332
+ await this.flushDurably();
333
+ }
334
+ });
335
+ }
336
+ /** Increment the attributable spawn-failure counter; returns the new count. */
337
+ async incrementBreaker(topicKey) {
338
+ const key = String(topicKey);
339
+ return this.withTopicLock(key, async () => {
340
+ const entry = this.entryFor(key);
341
+ entry.breakerCount += 1;
342
+ await this.flushDurably();
343
+ return entry.breakerCount;
344
+ });
345
+ }
346
+ /** §10.4 — the counter resets on any successful spawn. */
347
+ async resetBreaker(topicKey) {
348
+ const key = String(topicKey);
349
+ await this.withTopicLock(key, async () => {
350
+ const entry = this.entryFor(key);
351
+ if (entry.breakerCount !== 0) {
352
+ entry.breakerCount = 0;
353
+ await this.flushDurably();
354
+ }
355
+ });
356
+ }
357
+ // ── external-change invalidation (§5.1 / §12 restore carrier) ────────────
358
+ /**
359
+ * Explicit cache invalidation — the restore route fires this after a
360
+ * snapshot restore lands on disk (the file is otherwise server-owned
361
+ * single-writer; external edits reconcile only here or at boot).
362
+ */
363
+ invalidateAndReload() {
364
+ this.topics = {};
365
+ this.loadFromDisk();
366
+ }
367
+ // ── boot load + legacy seed (§5.1) ───────────────────────────────────────
368
+ loadFromDisk() {
369
+ try {
370
+ if (fs.existsSync(this.stateFilePath)) {
371
+ const raw = fs.readFileSync(this.stateFilePath, 'utf-8');
372
+ const parsed = JSON.parse(raw);
373
+ if (parsed && typeof parsed === 'object' && parsed.topics && typeof parsed.topics === 'object') {
374
+ for (const [key, value] of Object.entries(parsed.topics)) {
375
+ const entry = sanitizeEntry(value);
376
+ if (entry)
377
+ this.topics[key] = entry;
378
+ }
379
+ this.mirrorGeneratedAt = typeof parsed.mirrorGeneratedAt === 'string' ? parsed.mirrorGeneratedAt : undefined;
380
+ }
381
+ }
382
+ }
383
+ catch (err) {
384
+ // Corrupt/unreadable state — boot must succeed; fall back to empty +
385
+ // legacy seed below. Loud in logs, never a throw.
386
+ console.warn(`[TopicProfileStore] Failed to load ${this.stateFilePath}: ${err}`);
387
+ this.topics = {};
388
+ }
389
+ const seeded = this.seedFromLegacy();
390
+ this.lastFlushed = deepCloneTopics(this.topics);
391
+ this.bootFinalize(seeded);
392
+ }
393
+ /**
394
+ * Boot persistence + mirror self-heal (§12). Seeded/reconciled entries are
395
+ * flushed so `topic-profiles.json` exists from first boot, and a mirror
396
+ * that diverges from the profile store is regenerated (the flush path does
397
+ * both). Best-effort and async — boot itself never refuses; the cache is
398
+ * authoritative and the seed is reconstructible at every boot.
399
+ */
400
+ bootFinalize(seededOrReconciled) {
401
+ let needsFlush = seededOrReconciled;
402
+ if (!needsFlush && Object.keys(this.topics).length > 0 && !fs.existsSync(this.stateFilePath)) {
403
+ needsFlush = true;
404
+ }
405
+ if (!needsFlush && this.legacyFrameworksPath) {
406
+ // Mirror divergence check: compare the legacy file's framework view
407
+ // against the profile store's. Divergent → regenerate via flush.
408
+ try {
409
+ const view = {};
410
+ for (const [key, entry] of Object.entries(this.topics)) {
411
+ const fw = entry.current?.framework;
412
+ if (fw)
413
+ view[key] = fw;
414
+ }
415
+ let legacyView = {};
416
+ if (fs.existsSync(this.legacyFrameworksPath)) {
417
+ const parsed = JSON.parse(fs.readFileSync(this.legacyFrameworksPath, 'utf-8'));
418
+ if (parsed?.topics && typeof parsed.topics === 'object')
419
+ legacyView = parsed.topics;
420
+ }
421
+ if (JSON.stringify(view) !== JSON.stringify(legacyView))
422
+ needsFlush = true;
423
+ }
424
+ catch {
425
+ // Unreadable mirror — the flush below rewrites it.
426
+ needsFlush = true;
427
+ }
428
+ }
429
+ if (needsFlush) {
430
+ void this.flushDurably().catch((err) => {
431
+ console.warn(`[TopicProfileStore] Boot flush failed (cache stays authoritative, retried at next write): ${err}`);
432
+ DegradationReporter.getInstance().report({
433
+ feature: 'TopicProfileStore.bootFlush',
434
+ primary: 'Persist topic-profile state and regenerate the legacy mirror at boot',
435
+ fallback: 'In-memory cache stays authoritative; the durable file and legacy mirror stay stale until the next successful write',
436
+ reason: `Boot flush failed: ${err instanceof Error ? err.message : String(err)}`,
437
+ impact: 'A crash before the next successful write loses boot-time seeds/reconciles; a rolled-back binary would read a divergent legacy mirror',
438
+ });
439
+ });
440
+ }
441
+ }
442
+ /**
443
+ * One-directional read-only seed from the legacy framework store: seed the
444
+ * `framework` field for any topic ABSENT from the profile store. Never
445
+ * overwrites — EXCEPT the §12 rollback-window reconcile: when the legacy
446
+ * file's `updatedAt` differs from the last mirror-write stamp, the legacy
447
+ * file was written by something other than this store's mirror (a rolled-
448
+ * back binary's live `/route` writes), so topics whose framework VALUE
449
+ * differs re-seed the framework arm only, audited. `previous: null` on
450
+ * seeds (§5.1 — undo with no snapshot is refused plainly upstream).
451
+ */
452
+ seedFromLegacy() {
453
+ if (!this.legacyFrameworksPath)
454
+ return false;
455
+ try {
456
+ if (!fs.existsSync(this.legacyFrameworksPath))
457
+ return false;
458
+ const raw = fs.readFileSync(this.legacyFrameworksPath, 'utf-8');
459
+ const parsed = JSON.parse(raw);
460
+ if (!parsed?.topics || typeof parsed.topics !== 'object')
461
+ return false;
462
+ // §12 rollback-window predicate: file-level legacy updatedAt vs the
463
+ // mirror-generation stamp. Equal ⇒ the legacy file is our own mirror
464
+ // write — never reconcile values from it (it is read-only output).
465
+ const legacyExternallyWritten = typeof parsed.updatedAt === 'string' && parsed.updatedAt !== this.mirrorGeneratedAt;
466
+ // Retain a one-time migration-time snapshot as the rollback artifact.
467
+ const snapshotPath = `${this.legacyFrameworksPath}.pre-profile-seed`;
468
+ let seededAny = false;
469
+ for (const [key, fw] of Object.entries(parsed.topics)) {
470
+ if (typeof fw !== 'string' || !SUPPORTED_FRAMEWORKS.includes(fw))
471
+ continue;
472
+ const existing = this.topics[key]?.current;
473
+ if (existing) {
474
+ // §12: BOTH predicates — externally written AND file-level legacy
475
+ // updatedAt newer than this entry's profile updatedAt. The second
476
+ // guard keeps the crash-window case (profile flushed, mirror write
477
+ // lost — profile entries are NEWER) from pulling stale legacy
478
+ // values backwards; there the mirror self-heal regenerates instead.
479
+ const legacyNewerThanEntry = typeof parsed.updatedAt === 'string' && parsed.updatedAt > existing.updatedAt;
480
+ if (legacyExternallyWritten && legacyNewerThanEntry && existing.framework !== fw) {
481
+ // Rollback-window reconcile: framework arm only, value-differs only.
482
+ existing.framework = fw;
483
+ existing.updatedAt = this.now().toISOString();
484
+ existing.updatedBy = 'system:rollback-window-reconcile';
485
+ seededAny = true;
486
+ this.audit({
487
+ kind: 'rollback-window-reconcile',
488
+ topicKey: key,
489
+ framework: fw,
490
+ legacyUpdatedAt: parsed.updatedAt,
491
+ mirrorGeneratedAt: this.mirrorGeneratedAt ?? null,
492
+ });
493
+ }
494
+ continue;
495
+ }
496
+ const entry = this.entryFor(key);
497
+ entry.current = {
498
+ framework: fw,
499
+ updatedAt: this.now().toISOString(),
500
+ updatedBy: 'system:legacy-seed',
501
+ };
502
+ entry.previous = null;
503
+ seededAny = true;
504
+ }
505
+ if (seededAny && !fs.existsSync(snapshotPath)) {
506
+ try {
507
+ fs.copyFileSync(this.legacyFrameworksPath, snapshotPath);
508
+ }
509
+ catch {
510
+ // @silent-fallback-ok: the §12 rollback snapshot is a best-effort
511
+ // forensic artifact — the seed itself already succeeded (and is
512
+ // audited via the entry writes); a missing snapshot only disables
513
+ // the optional rollback-window reconcile, never live routing.
514
+ }
515
+ }
516
+ return seededAny;
517
+ }
518
+ catch (err) {
519
+ console.warn(`[TopicProfileStore] Legacy seed from ${this.legacyFrameworksPath} failed: ${err}`);
520
+ DegradationReporter.getInstance().report({
521
+ feature: 'TopicProfileStore.seedFromLegacy',
522
+ primary: 'Seed framework pins from the legacy topic-frameworks file at boot',
523
+ fallback: 'Continue with whatever the profile store already holds — legacy framework routing is not imported',
524
+ reason: `Legacy seed read/parse failed: ${err instanceof Error ? err.message : String(err)}`,
525
+ impact: 'Topics routed by the legacy file may silently resolve to the default framework until re-pinned',
526
+ });
527
+ return false;
528
+ }
529
+ }
530
+ // ── durable flush (serialized + coalescing, §5.1) ────────────────────────
531
+ /**
532
+ * Serialize flushes store-wide. A flush snapshots the authoritative cache
533
+ * AT FLUSH TIME, so a later-completing flush can never persist an older
534
+ * snapshot. Writes arriving mid-flight coalesce onto ONE trailing flush.
535
+ * Failure: every undurable waiter is refused + the cache rolls back to the
536
+ * last durably-flushed snapshot BEFORE the refusals fire.
537
+ */
538
+ flushDurably() {
539
+ if (this.flushInFlight) {
540
+ // Coalesce: all waiters during an in-flight flush share one trailing
541
+ // flush of the latest snapshot (their writes are already in the cache).
542
+ if (!this.trailingFlush) {
543
+ this.trailingFlush = this.flushInFlight.then(() => {
544
+ this.trailingFlush = null;
545
+ return this.flushDurably();
546
+ }, (err) => {
547
+ // A failed leader already ROLLED BACK the cache — wiping the
548
+ // trailing waiters' writes too. Refusing them together (rather
549
+ // than flushing the rolled-back state and ACKing silently-dropped
550
+ // writes) is the §5.1 coalesced-failure rule.
551
+ this.trailingFlush = null;
552
+ throw err;
553
+ });
554
+ }
555
+ return this.trailingFlush;
556
+ }
557
+ this.flushInFlight = (async () => {
558
+ const snapshot = deepCloneTopics(this.topics);
559
+ const mirrorStamp = this.mirrorGeneratedAt;
560
+ try {
561
+ await this.writeFileAtomic(snapshot, mirrorStamp);
562
+ this.lastFlushed = snapshot;
563
+ }
564
+ catch (err) {
565
+ // Rollback to the last durably-flushed snapshot, then refuse loudly.
566
+ this.topics = deepCloneTopics(this.lastFlushed);
567
+ throw new FlushRefusedError(err);
568
+ }
569
+ finally {
570
+ this.flushInFlight = null;
571
+ }
572
+ this.regenerateMirror(snapshot);
573
+ })();
574
+ return this.flushInFlight;
575
+ }
576
+ async writeFileAtomic(topics, mirrorStamp) {
577
+ const dir = path.dirname(this.stateFilePath);
578
+ if (!fs.existsSync(dir))
579
+ fs.mkdirSync(dir, { recursive: true });
580
+ const state = {
581
+ updatedAt: this.now().toISOString(),
582
+ ...(mirrorStamp ? { mirrorGeneratedAt: mirrorStamp } : {}),
583
+ topics,
584
+ };
585
+ const tmp = `${this.stateFilePath}.${process.pid}.tmp`;
586
+ await fs.promises.writeFile(tmp, JSON.stringify(state, null, 2), 'utf-8');
587
+ await fs.promises.rename(tmp, this.stateFilePath);
588
+ }
589
+ /**
590
+ * §5.1/§12 — the legacy file becomes a read-only mirror regenerated from
591
+ * the profile store (sole writer = this store), so a binary rollback to
592
+ * the prior release still reads current framework data. Best-effort,
593
+ * SECOND (profile file is the source of truth and flushes first), and only
594
+ * rewritten when the framework view actually changed.
595
+ */
596
+ lastMirrorView = null;
597
+ regenerateMirror(topics) {
598
+ if (!this.legacyFrameworksPath)
599
+ return;
600
+ try {
601
+ const view = {};
602
+ for (const [key, entry] of Object.entries(topics)) {
603
+ const fw = entry.current?.framework;
604
+ if (fw)
605
+ view[key] = fw;
606
+ }
607
+ const serialized = JSON.stringify(view);
608
+ if (serialized === this.lastMirrorView)
609
+ return;
610
+ this.lastMirrorView = serialized;
611
+ const stamp = this.now().toISOString();
612
+ this.mirrorGeneratedAt = stamp;
613
+ const mirror = { updatedAt: stamp, topics: view };
614
+ const tmp = `${this.legacyFrameworksPath}.${process.pid}.tmp`;
615
+ fs.writeFileSync(tmp, JSON.stringify(mirror, null, 2), 'utf-8');
616
+ fs.renameSync(tmp, this.legacyFrameworksPath);
617
+ }
618
+ catch (err) {
619
+ // Mirror divergence self-heals at boot (§12); never fail the write path.
620
+ console.warn(`[TopicProfileStore] Mirror regeneration failed (self-heals at boot): ${err}`);
621
+ }
622
+ }
623
+ /** The mirror-generation stamp (§12 rollback-window reconcile predicate). */
624
+ getMirrorGeneratedAt() {
625
+ return this.mirrorGeneratedAt;
626
+ }
627
+ // ── internals ────────────────────────────────────────────────────────────
628
+ entryFor(key) {
629
+ let entry = this.topics[key];
630
+ if (!entry) {
631
+ entry = { current: null, previous: null, intendedProfile: null, parked: null, breakerCount: 0 };
632
+ this.topics[key] = entry;
633
+ }
634
+ return entry;
635
+ }
636
+ }
637
+ /**
638
+ * §5.3/§10.2 — clamp an ARRIVING (peer-asserted, untrusted) profile through
639
+ * the closed enums, field by field. Unlike the write-surface refusal
640
+ * (first-error, profile unchanged), transfer-apply degrades PER FIELD: a
641
+ * failing field falls to null (the default for that field at resolution)
642
+ * and is reported for the one-line disclosure; valid sibling fields are
643
+ * kept. Provenance (updatedAt/updatedBy) travels verbatim — a malformed
644
+ * provenance pair means the entry is not a well-formed profile at all and
645
+ * is treated as absent (current: null), reported.
646
+ *
647
+ * The arriving model id validates against the arriving entry's OWN
648
+ * framework when that framework is enum-valid, else `fallbackFramework`
649
+ * (§10.2 framework-compat — a model incompatible with the framework falls).
650
+ * A clamped result holding BOTH model and modelTier drops both (§4 hard
651
+ * mutual exclusion — picking a winner silently is the named anti-pattern).
652
+ */
653
+ export function revalidateArrivingProfile(arriving, fallbackFramework) {
654
+ if (arriving === null)
655
+ return { profile: null, dropped: [] };
656
+ const dropped = [];
657
+ if (typeof arriving.updatedAt !== 'string' || typeof arriving.updatedBy !== 'string') {
658
+ return {
659
+ profile: null,
660
+ dropped: [
661
+ {
662
+ field: 'updatedAt/updatedBy',
663
+ failure: 'unknown-field',
664
+ reason: 'arriving entry carries malformed provenance — treated as absent',
665
+ },
666
+ ],
667
+ };
668
+ }
669
+ const { fields, fieldDropped } = clampArrivingFields(arriving, fallbackFramework);
670
+ dropped.push(...fieldDropped);
671
+ return {
672
+ profile: {
673
+ ...fields,
674
+ updatedAt: arriving.updatedAt,
675
+ updatedBy: arriving.updatedBy,
676
+ },
677
+ dropped,
678
+ };
679
+ }
680
+ /** §5.3 — the dry-run shadow travels on the pull and is revalidated the same way. */
681
+ export function revalidateArrivingShadow(arriving, fallbackFramework) {
682
+ if (!arriving ||
683
+ typeof arriving !== 'object' ||
684
+ typeof arriving.recordedAt !== 'string' ||
685
+ typeof arriving.recordedBy !== 'string' ||
686
+ !arriving.fields ||
687
+ typeof arriving.fields !== 'object') {
688
+ return {
689
+ shadow: null,
690
+ dropped: [
691
+ {
692
+ field: 'intendedProfile',
693
+ failure: 'unknown-field',
694
+ reason: 'arriving dry-run shadow is malformed — discarded',
695
+ },
696
+ ],
697
+ };
698
+ }
699
+ const { fields, fieldDropped } = clampArrivingFields(arriving.fields, fallbackFramework);
700
+ return {
701
+ shadow: { fields, recordedAt: arriving.recordedAt, recordedBy: arriving.recordedBy },
702
+ dropped: fieldDropped,
703
+ };
704
+ }
705
+ /** Per-field §10.2 clamp shared by the live-profile and shadow revalidation. */
706
+ function clampArrivingFields(source, fallbackFramework) {
707
+ const fieldDropped = [];
708
+ const fields = {};
709
+ // Framework first — the model arm validates against it (§10.2).
710
+ let effectiveFramework = fallbackFramework;
711
+ if (source.framework != null) {
712
+ const v = validateProfileFields({ framework: String(source.framework) }, fallbackFramework);
713
+ if (v.ok && v.patch.framework) {
714
+ fields.framework = v.patch.framework;
715
+ effectiveFramework = v.patch.framework;
716
+ }
717
+ else if (!v.ok) {
718
+ fieldDropped.push(v.error);
719
+ }
720
+ }
721
+ for (const field of ['model', 'modelTier', 'thinkingMode', 'escalationOverride']) {
722
+ const raw = source[field];
723
+ if (raw == null)
724
+ continue;
725
+ const v = validateProfileFields({ [field]: String(raw) }, effectiveFramework);
726
+ if (v.ok) {
727
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
728
+ fields[field] = v.patch[field];
729
+ }
730
+ else {
731
+ fieldDropped.push(v.error);
732
+ }
733
+ }
734
+ // §4 hard mutual exclusion against the clamped result — both set means a
735
+ // divergent/forged peer entry; dropping BOTH is the only non-silent fate.
736
+ const exclusion = modelTierMutualExclusionError(fields);
737
+ if (exclusion) {
738
+ fields.model = null;
739
+ fields.modelTier = null;
740
+ fieldDropped.push(exclusion);
741
+ }
742
+ return { fields, fieldDropped };
743
+ }
744
+ /** Field-level profile equality over the five profile axes (delta detection). */
745
+ export function profilesEqual(a, b) {
746
+ if (a === null && b === null)
747
+ return true;
748
+ if (a === null || b === null)
749
+ return false;
750
+ for (const field of PROFILE_FIELDS) {
751
+ if ((a[field] ?? null) !== (b[field] ?? null))
752
+ return false;
753
+ }
754
+ return true;
755
+ }
756
+ function deepCloneTopics(topics) {
757
+ return JSON.parse(JSON.stringify(topics));
758
+ }
759
+ function sanitizeEntry(value) {
760
+ if (!value || typeof value !== 'object')
761
+ return null;
762
+ const v = value;
763
+ return {
764
+ current: sanitizeProfile(v.current),
765
+ previous: sanitizeProfile(v.previous),
766
+ intendedProfile: v.intendedProfile && typeof v.intendedProfile === 'object'
767
+ ? v.intendedProfile
768
+ : null,
769
+ parked: v.parked && typeof v.parked === 'object' ? v.parked : null,
770
+ breakerCount: typeof v.breakerCount === 'number' && v.breakerCount >= 0 ? v.breakerCount : 0,
771
+ };
772
+ }
773
+ function sanitizeProfile(value) {
774
+ if (!value || typeof value !== 'object')
775
+ return null;
776
+ const v = value;
777
+ if (typeof v.updatedAt !== 'string' || typeof v.updatedBy !== 'string')
778
+ return null;
779
+ return v;
780
+ }
781
+ //# sourceMappingURL=TopicProfileStore.js.map