instar 1.3.889 → 1.3.890

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 (109) hide show
  1. package/dashboard/glance.js +1 -0
  2. package/dashboard/index.html +60 -0
  3. package/dist/config/ConfigDefaults.d.ts.map +1 -1
  4. package/dist/config/ConfigDefaults.js +9 -0
  5. package/dist/config/ConfigDefaults.js.map +1 -1
  6. package/dist/core/BackupManager.d.ts.map +1 -1
  7. package/dist/core/BackupManager.js +22 -15
  8. package/dist/core/BackupManager.js.map +1 -1
  9. package/dist/core/InitiativeTracker.d.ts +7 -0
  10. package/dist/core/InitiativeTracker.d.ts.map +1 -1
  11. package/dist/core/InitiativeTracker.js +17 -0
  12. package/dist/core/InitiativeTracker.js.map +1 -1
  13. package/dist/core/WriteDomainRegistry.d.ts.map +1 -1
  14. package/dist/core/WriteDomainRegistry.js +12 -0
  15. package/dist/core/WriteDomainRegistry.js.map +1 -1
  16. package/dist/core/canonicalPipelineRegistry.d.ts +73 -0
  17. package/dist/core/canonicalPipelineRegistry.d.ts.map +1 -0
  18. package/dist/core/canonicalPipelineRegistry.js +56 -0
  19. package/dist/core/canonicalPipelineRegistry.js.map +1 -0
  20. package/dist/core/canonicalPipelineRuntimeEvidence.d.ts +13 -0
  21. package/dist/core/canonicalPipelineRuntimeEvidence.d.ts.map +1 -0
  22. package/dist/core/canonicalPipelineRuntimeEvidence.js +15 -0
  23. package/dist/core/canonicalPipelineRuntimeEvidence.js.map +1 -0
  24. package/dist/core/componentCategories.d.ts.map +1 -1
  25. package/dist/core/componentCategories.js +1 -0
  26. package/dist/core/componentCategories.js.map +1 -1
  27. package/dist/core/devGatedFeatures.d.ts.map +1 -1
  28. package/dist/core/devGatedFeatures.js +18 -0
  29. package/dist/core/devGatedFeatures.js.map +1 -1
  30. package/dist/core/types.d.ts +19 -1
  31. package/dist/core/types.d.ts.map +1 -1
  32. package/dist/core/types.js.map +1 -1
  33. package/dist/core/version-skew.d.ts +1 -1
  34. package/dist/core/version-skew.d.ts.map +1 -1
  35. package/dist/data/llmBenchCoverage.d.ts.map +1 -1
  36. package/dist/data/llmBenchCoverage.js +6 -0
  37. package/dist/data/llmBenchCoverage.js.map +1 -1
  38. package/dist/data/provenanceCoverage.d.ts +2 -0
  39. package/dist/data/provenanceCoverage.d.ts.map +1 -1
  40. package/dist/data/provenanceCoverage.js +10 -0
  41. package/dist/data/provenanceCoverage.js.map +1 -1
  42. package/dist/feedback-factory/drain/FeedbackConsumerPromotionStore.d.ts +18 -0
  43. package/dist/feedback-factory/drain/FeedbackConsumerPromotionStore.d.ts.map +1 -0
  44. package/dist/feedback-factory/drain/FeedbackConsumerPromotionStore.js +51 -0
  45. package/dist/feedback-factory/drain/FeedbackConsumerPromotionStore.js.map +1 -0
  46. package/dist/feedback-factory/drain/FeedbackDrainBackupCadence.d.ts +20 -0
  47. package/dist/feedback-factory/drain/FeedbackDrainBackupCadence.d.ts.map +1 -0
  48. package/dist/feedback-factory/drain/FeedbackDrainBackupCadence.js +28 -0
  49. package/dist/feedback-factory/drain/FeedbackDrainBackupCadence.js.map +1 -0
  50. package/dist/feedback-factory/drain/FeedbackDrainPosture.d.ts +14 -0
  51. package/dist/feedback-factory/drain/FeedbackDrainPosture.d.ts.map +1 -0
  52. package/dist/feedback-factory/drain/FeedbackDrainPosture.js +15 -0
  53. package/dist/feedback-factory/drain/FeedbackDrainPosture.js.map +1 -0
  54. package/dist/feedback-factory/drain/FeedbackDrainService.d.ts +121 -0
  55. package/dist/feedback-factory/drain/FeedbackDrainService.d.ts.map +1 -0
  56. package/dist/feedback-factory/drain/FeedbackDrainService.js +370 -0
  57. package/dist/feedback-factory/drain/FeedbackDrainService.js.map +1 -0
  58. package/dist/feedback-factory/drain/FeedbackDrainStore.d.ts +369 -0
  59. package/dist/feedback-factory/drain/FeedbackDrainStore.d.ts.map +1 -0
  60. package/dist/feedback-factory/drain/FeedbackDrainStore.js +1125 -0
  61. package/dist/feedback-factory/drain/FeedbackDrainStore.js.map +1 -0
  62. package/dist/feedback-factory/drain/FeedbackDrainTickProxy.d.ts +67 -0
  63. package/dist/feedback-factory/drain/FeedbackDrainTickProxy.d.ts.map +1 -0
  64. package/dist/feedback-factory/drain/FeedbackDrainTickProxy.js +151 -0
  65. package/dist/feedback-factory/drain/FeedbackDrainTickProxy.js.map +1 -0
  66. package/dist/feedback-factory/drain/FeedbackFactoryGeneratedDefaults.d.ts +32 -0
  67. package/dist/feedback-factory/drain/FeedbackFactoryGeneratedDefaults.d.ts.map +1 -0
  68. package/dist/feedback-factory/drain/FeedbackFactoryGeneratedDefaults.js +47 -0
  69. package/dist/feedback-factory/drain/FeedbackFactoryGeneratedDefaults.js.map +1 -0
  70. package/dist/feedback-factory/drain/FeedbackInitiativeConsumer.d.ts +33 -0
  71. package/dist/feedback-factory/drain/FeedbackInitiativeConsumer.d.ts.map +1 -0
  72. package/dist/feedback-factory/drain/FeedbackInitiativeConsumer.js +71 -0
  73. package/dist/feedback-factory/drain/FeedbackInitiativeConsumer.js.map +1 -0
  74. package/dist/feedback-factory/drain/FeedbackReadinessArbiter.d.ts +36 -0
  75. package/dist/feedback-factory/drain/FeedbackReadinessArbiter.d.ts.map +1 -0
  76. package/dist/feedback-factory/drain/FeedbackReadinessArbiter.js +153 -0
  77. package/dist/feedback-factory/drain/FeedbackReadinessArbiter.js.map +1 -0
  78. package/dist/feedback-factory/drain/index.d.ts +2 -0
  79. package/dist/feedback-factory/drain/index.d.ts.map +1 -0
  80. package/dist/feedback-factory/drain/index.js +2 -0
  81. package/dist/feedback-factory/drain/index.js.map +1 -0
  82. package/dist/feedback-factory/processing/FeedbackProcessingService.d.ts +14 -0
  83. package/dist/feedback-factory/processing/FeedbackProcessingService.d.ts.map +1 -1
  84. package/dist/feedback-factory/processing/FeedbackProcessingService.js +25 -4
  85. package/dist/feedback-factory/processing/FeedbackProcessingService.js.map +1 -1
  86. package/dist/feedback-factory/store/FeedbackSourceGenerations.d.ts +39 -0
  87. package/dist/feedback-factory/store/FeedbackSourceGenerations.d.ts.map +1 -0
  88. package/dist/feedback-factory/store/FeedbackSourceGenerations.js +288 -0
  89. package/dist/feedback-factory/store/FeedbackSourceGenerations.js.map +1 -0
  90. package/dist/feedback-factory/store/JsonlFeedbackStore.d.ts +26 -6
  91. package/dist/feedback-factory/store/JsonlFeedbackStore.d.ts.map +1 -1
  92. package/dist/feedback-factory/store/JsonlFeedbackStore.js +139 -25
  93. package/dist/feedback-factory/store/JsonlFeedbackStore.js.map +1 -1
  94. package/dist/server/AgentServer.d.ts +3 -0
  95. package/dist/server/AgentServer.d.ts.map +1 -1
  96. package/dist/server/AgentServer.js +188 -1
  97. package/dist/server/AgentServer.js.map +1 -1
  98. package/dist/server/routes.d.ts +31 -0
  99. package/dist/server/routes.d.ts.map +1 -1
  100. package/dist/server/routes.js +281 -0
  101. package/dist/server/routes.js.map +1 -1
  102. package/package.json +5 -2
  103. package/scripts/lint-canonical-pipeline-completeness.mjs +265 -0
  104. package/src/data/builtin-manifest.json +48 -48
  105. package/src/data/llmBenchCoverage.ts +6 -0
  106. package/src/data/provenanceCoverage.ts +12 -0
  107. package/src/scaffold/templates/jobs/instar/feedback-factory-process.md +13 -12
  108. package/upgrades/1.3.890.md +19 -0
  109. package/upgrades/side-effects/feedback-factory-operating-drain.md +110 -0
@@ -0,0 +1,1125 @@
1
+ import { createHash, createHmac, randomBytes, randomUUID, timingSafeEqual } from 'node:crypto';
2
+ import fs from 'node:fs';
3
+ import path from 'node:path';
4
+ import Database from 'better-sqlite3';
5
+ import { registerSqliteHandle } from '../../core/SqliteRegistry.js';
6
+ import { NativeModuleHealer } from '../../memory/NativeModuleHealer.js';
7
+ export const FEEDBACK_DRAIN_STORE_STAGE = {
8
+ canonicalPipelineId: 'feedback-factory',
9
+ stage: 'durable-work',
10
+ };
11
+ export class DrainConflictError extends Error {
12
+ statusCode = 409;
13
+ constructor(message) { super(message); this.name = 'DrainConflictError'; }
14
+ }
15
+ function sqliteFileIdentity(filePath) {
16
+ const stat = fs.statSync(filePath);
17
+ // dev+ino alone is not a durable replacement detector: Linux may recycle an
18
+ // inode immediately after the operated directory is destroyed. Birth time
19
+ // stays stable across routine close/reopen but changes for restored bytes.
20
+ return `${stat.dev}:${stat.ino}:${stat.birthtimeMs}`;
21
+ }
22
+ const SCHEMA = `
23
+ CREATE TABLE IF NOT EXISTS drain_meta (key TEXT PRIMARY KEY, value TEXT NOT NULL);
24
+ CREATE TABLE IF NOT EXISTS readiness (
25
+ cluster_id TEXT PRIMARY KEY, state TEXT NOT NULL CHECK(state IN ('collecting','ready','queued','held')),
26
+ epoch INTEGER NOT NULL DEFAULT 0 CHECK(epoch >= 0), entered_at INTEGER NOT NULL,
27
+ last_evaluated_at INTEGER, next_review_at INTEGER, reason_code TEXT NOT NULL DEFAULT ''
28
+ );
29
+ CREATE INDEX IF NOT EXISTS idx_readiness_due ON readiness(next_review_at, cluster_id) WHERE state='collecting';
30
+ CREATE INDEX IF NOT EXISTS idx_readiness_state ON readiness(state, entered_at);
31
+ CREATE TABLE IF NOT EXISTS readiness_approvals (
32
+ approval_key TEXT PRIMARY KEY, cluster_id TEXT NOT NULL, evidence_hash TEXT NOT NULL,
33
+ authority_id TEXT NOT NULL, authority_generation INTEGER NOT NULL, decision_nonce TEXT NOT NULL,
34
+ proposal_set_hash TEXT NOT NULL, epoch INTEGER NOT NULL, created_at INTEGER NOT NULL
35
+ );
36
+ CREATE TABLE IF NOT EXISTS work (
37
+ work_id TEXT PRIMARY KEY, idempotency_key TEXT NOT NULL UNIQUE, cluster_id TEXT NOT NULL,
38
+ readiness_epoch INTEGER NOT NULL, state TEXT NOT NULL CHECK(state IN ('queued','claimed','completed','retryable','dead-lettered','held')),
39
+ title TEXT NOT NULL, summary TEXT NOT NULL, priority TEXT NOT NULL, report_count INTEGER NOT NULL,
40
+ first_seen_at INTEGER NOT NULL, last_seen_at INTEGER NOT NULL, authority_ref TEXT NOT NULL, evidence_ref TEXT NOT NULL,
41
+ lease_epoch INTEGER NOT NULL DEFAULT 0, lease_expires_at INTEGER, consumer_id TEXT, token_hash TEXT,
42
+ owner_authority_epoch INTEGER, attempts INTEGER NOT NULL DEFAULT 0, next_attempt_at INTEGER,
43
+ created_at INTEGER NOT NULL, updated_at INTEGER NOT NULL, claimed_at INTEGER, completed_at INTEGER
44
+ );
45
+ CREATE INDEX IF NOT EXISTS idx_work_claim ON work(state, next_attempt_at, created_at);
46
+ CREATE TABLE IF NOT EXISTS work_tombstones (
47
+ idempotency_key TEXT PRIMARY KEY, work_id TEXT NOT NULL UNIQUE, terminal_state TEXT NOT NULL,
48
+ retired_at INTEGER NOT NULL
49
+ );
50
+ CREATE TABLE IF NOT EXISTS artifact_links (
51
+ work_id TEXT PRIMARY KEY, state TEXT NOT NULL CHECK(state IN ('pending','readable','degraded','held')),
52
+ external_key TEXT NOT NULL UNIQUE, artifact_id TEXT, artifact_kind TEXT, verified_at INTEGER, updated_at INTEGER NOT NULL,
53
+ FOREIGN KEY(work_id) REFERENCES work(work_id)
54
+ );
55
+ CREATE TABLE IF NOT EXISTS drain_runs (
56
+ run_id TEXT PRIMARY KEY, state TEXT NOT NULL CHECK(state IN ('accepted','running','succeeded','no-op','degraded','failed','abandoned')),
57
+ owner_host TEXT NOT NULL, owner_epoch INTEGER NOT NULL, lease_expires_at INTEGER,
58
+ reason TEXT NOT NULL DEFAULT '', cancellation_requested_at INTEGER, created_at INTEGER NOT NULL, updated_at INTEGER NOT NULL
59
+ );
60
+ CREATE UNIQUE INDEX IF NOT EXISTS idx_one_active_run ON drain_runs((1)) WHERE state IN ('accepted','running');
61
+ CREATE TABLE IF NOT EXISTS authority_records (
62
+ authority_id TEXT NOT NULL, generation INTEGER NOT NULL, agent_id TEXT NOT NULL, owner_machine_id TEXT NOT NULL,
63
+ owner_epoch INTEGER NOT NULL, provider TEXT NOT NULL, model_family TEXT NOT NULL, prompt_version TEXT NOT NULL,
64
+ schema_version TEXT NOT NULL, decision_point_id TEXT NOT NULL, max_batch INTEGER NOT NULL, max_tokens INTEGER NOT NULL,
65
+ max_daily_spend_usd REAL NOT NULL, revoked INTEGER NOT NULL DEFAULT 0, created_at INTEGER NOT NULL,
66
+ PRIMARY KEY(authority_id, generation)
67
+ );
68
+ CREATE TABLE IF NOT EXISTS authority_audit (
69
+ sequence INTEGER PRIMARY KEY AUTOINCREMENT, generation INTEGER NOT NULL, action TEXT NOT NULL,
70
+ authority_id TEXT NOT NULL, operator_decision_ref TEXT NOT NULL, payload_hash TEXT NOT NULL,
71
+ previous_checksum TEXT NOT NULL, checksum TEXT NOT NULL UNIQUE, created_at INTEGER NOT NULL
72
+ );
73
+ CREATE TABLE IF NOT EXISTS authority_daily_usage (
74
+ authority_id TEXT NOT NULL, generation INTEGER NOT NULL, utc_day TEXT NOT NULL,
75
+ committed_usd REAL NOT NULL DEFAULT 0 CHECK(committed_usd >= 0), decisions INTEGER NOT NULL DEFAULT 0,
76
+ updated_at INTEGER NOT NULL, PRIMARY KEY(authority_id,generation,utc_day)
77
+ );
78
+ CREATE TABLE IF NOT EXISTS authority_posture (
79
+ authority_id TEXT NOT NULL, generation INTEGER NOT NULL, mode TEXT NOT NULL CHECK(mode IN ('active','proposal-only')),
80
+ reason TEXT NOT NULL, updated_at INTEGER NOT NULL, PRIMARY KEY(authority_id,generation)
81
+ );
82
+ CREATE TABLE IF NOT EXISTS drain_audit (
83
+ sequence INTEGER PRIMARY KEY AUTOINCREMENT, kind TEXT NOT NULL, entity_id TEXT NOT NULL,
84
+ from_state TEXT, to_state TEXT, reason TEXT NOT NULL, created_at INTEGER NOT NULL
85
+ );
86
+ CREATE TABLE IF NOT EXISTS source_projection (
87
+ ingest_sequence INTEGER PRIMARY KEY AUTOINCREMENT, source_record_id TEXT NOT NULL UNIQUE,
88
+ generation_id TEXT NOT NULL, byte_offset INTEGER NOT NULL, byte_length INTEGER NOT NULL,
89
+ record_checksum TEXT NOT NULL, entity_id TEXT NOT NULL, record_json TEXT NOT NULL, checked_at INTEGER, created_at INTEGER NOT NULL
90
+ );
91
+ CREATE INDEX IF NOT EXISTS idx_source_projection_reconcile ON source_projection(checked_at,ingest_sequence);
92
+ CREATE TABLE IF NOT EXISTS source_cursors (
93
+ cursor_id TEXT PRIMARY KEY, generation_id TEXT NOT NULL, byte_offset INTEGER NOT NULL,
94
+ last_record_checksum TEXT NOT NULL, updated_at INTEGER NOT NULL
95
+ );
96
+ CREATE TABLE IF NOT EXISTS source_conflicts (
97
+ source_record_id TEXT PRIMARY KEY, expected_checksum TEXT NOT NULL, observed_checksum TEXT NOT NULL,
98
+ reason TEXT NOT NULL, created_at INTEGER NOT NULL
99
+ );
100
+ CREATE TABLE IF NOT EXISTS request_replay (
101
+ authority_id TEXT NOT NULL, nonce_hash TEXT NOT NULL, expires_at INTEGER NOT NULL, created_at INTEGER NOT NULL,
102
+ PRIMARY KEY(authority_id,nonce_hash)
103
+ );
104
+ CREATE INDEX IF NOT EXISTS idx_request_replay_expiry ON request_replay(expires_at);
105
+ `;
106
+ const clamp = (value, max) => value.replace(/[\r\n\u0000-\u001f\u007f]/g, ' ').trim().slice(0, max);
107
+ const canonical = (value) => JSON.stringify(value, Object.keys(value).sort());
108
+ export class FeedbackDrainStore {
109
+ db;
110
+ hmacKey;
111
+ now;
112
+ idFactory;
113
+ tokenFactory;
114
+ authorityBackupPath;
115
+ crashInjector;
116
+ dbPath;
117
+ unregisterSqlite;
118
+ constructor(opts) {
119
+ this.dbPath = opts.dbPath;
120
+ this.hmacKey = opts.tokenHmacKey;
121
+ if ((typeof this.hmacKey === 'string' && this.hmacKey.length < 32) || (Buffer.isBuffer(this.hmacKey) && this.hmacKey.length < 32)) {
122
+ throw new Error('tokenHmacKey must contain at least 32 bytes');
123
+ }
124
+ this.now = opts.clock ?? Date.now;
125
+ this.idFactory = opts.idFactory ?? randomUUID;
126
+ this.tokenFactory = opts.tokenFactory ?? (() => randomBytes(32).toString('base64url'));
127
+ this.authorityBackupPath = opts.authorityBackupPath ?? (opts.dbPath === ':memory:' ? null : path.join(path.dirname(opts.dbPath), 'feedback-readiness-authorities.json'));
128
+ this.crashInjector = opts.crashInjector ?? null;
129
+ if (opts.db)
130
+ this.db = opts.db;
131
+ else {
132
+ if (opts.dbPath !== ':memory:')
133
+ fs.mkdirSync(path.dirname(opts.dbPath), { recursive: true });
134
+ this.db = NativeModuleHealer.openWithHealSync('FeedbackDrainStore', () => new Database(opts.dbPath));
135
+ }
136
+ this.db.pragma('journal_mode = WAL');
137
+ this.db.pragma('busy_timeout = 5000');
138
+ this.db.pragma('synchronous = FULL');
139
+ this.db.pragma('foreign_keys = ON');
140
+ this.db.exec(SCHEMA);
141
+ this.ensureSchemaUpgrades();
142
+ this.restoreAuthorityBackupIfEmpty();
143
+ this.unregisterSqlite = registerSqliteHandle(() => { try {
144
+ this.db.close();
145
+ }
146
+ catch { /* closed */ } });
147
+ }
148
+ close() {
149
+ if (this.db.open)
150
+ this.db.close();
151
+ this.unregisterSqlite?.();
152
+ this.unregisterSqlite = undefined;
153
+ }
154
+ integrityCheck() {
155
+ const rows = this.db.pragma('integrity_check');
156
+ return rows.length === 1 && rows[0]?.integrity_check === 'ok';
157
+ }
158
+ /** Quiescent, checksummed SQLite checkpoint metadata consumed by BackupManager. */
159
+ checkpointForBackup(ownerAuthorityEpoch) {
160
+ if (this.dbPath === ':memory:')
161
+ throw new Error('in-memory feedback drain cannot be checkpointed for backup');
162
+ const recordedEpoch = this.ownerAuthorityEpoch();
163
+ if (recordedEpoch === null)
164
+ this.setMeta('owner_authority_epoch', String(ownerAuthorityEpoch));
165
+ else if (recordedEpoch !== ownerAuthorityEpoch)
166
+ throw new DrainConflictError('backup owner authority epoch is stale');
167
+ const checkpointRows = this.db.pragma('wal_checkpoint(TRUNCATE)');
168
+ const checkpoint = checkpointRows[0];
169
+ if (!checkpoint || checkpoint.busy !== 0 || checkpoint.checkpointed !== checkpoint.log) {
170
+ throw new DrainConflictError('feedback drain WAL checkpoint is busy or incomplete');
171
+ }
172
+ if (!this.integrityCheck())
173
+ throw new Error('feedback drain integrity check failed before backup');
174
+ const bytes = fs.readFileSync(this.dbPath);
175
+ const payload = { schemaVersion: 1, snapshotId: `feedback-drain:${randomUUID()}`, checksum: createHash('sha256').update(bytes).digest('hex'),
176
+ dbFileIdentity: sqliteFileIdentity(this.dbPath), ownerAuthorityEpoch, createdAt: new Date(this.now()).toISOString() };
177
+ const record = { ...payload, manifestChecksum: createHash('sha256').update(JSON.stringify(payload)).digest('hex') };
178
+ const target = path.join(path.dirname(this.dbPath), 'feedback-drain-checkpoint.json');
179
+ const tmp = `${target}.${process.pid}.tmp`;
180
+ const fd = fs.openSync(tmp, 'w', 0o600);
181
+ try {
182
+ fs.writeFileSync(fd, `${JSON.stringify(record)}\n`);
183
+ fs.fsyncSync(fd);
184
+ }
185
+ finally {
186
+ fs.closeSync(fd);
187
+ }
188
+ fs.renameSync(tmp, target);
189
+ const dir = fs.openSync(path.dirname(target), 'r');
190
+ try {
191
+ fs.fsyncSync(dir);
192
+ }
193
+ finally {
194
+ fs.closeSync(dir);
195
+ }
196
+ return record;
197
+ }
198
+ /** Distinguish a routine restart from a DB file restored out of a snapshot. */
199
+ restorePending() {
200
+ if (this.dbPath === ':memory:')
201
+ return false;
202
+ const checkpointPath = path.join(path.dirname(this.dbPath), 'feedback-drain-checkpoint.json');
203
+ if (!fs.existsSync(checkpointPath))
204
+ return false;
205
+ let checkpoint;
206
+ try {
207
+ checkpoint = JSON.parse(fs.readFileSync(checkpointPath, 'utf8'));
208
+ }
209
+ catch {
210
+ throw new DrainConflictError('feedback drain checkpoint manifest is malformed');
211
+ }
212
+ const payload = { schemaVersion: checkpoint.schemaVersion, snapshotId: checkpoint.snapshotId, checksum: checkpoint.checksum,
213
+ dbFileIdentity: checkpoint.dbFileIdentity, ownerAuthorityEpoch: checkpoint.ownerAuthorityEpoch, createdAt: checkpoint.createdAt };
214
+ if (checkpoint.schemaVersion !== 1 || createHash('sha256').update(JSON.stringify(payload)).digest('hex') !== checkpoint.manifestChecksum) {
215
+ throw new DrainConflictError('feedback drain checkpoint manifest checksum is invalid');
216
+ }
217
+ const currentIdentity = sqliteFileIdentity(this.dbPath);
218
+ if (currentIdentity === checkpoint.dbFileIdentity)
219
+ return false;
220
+ const currentChecksum = createHash('sha256').update(fs.readFileSync(this.dbPath)).digest('hex');
221
+ if (currentChecksum !== checkpoint.checksum || this.ownerAuthorityEpoch() !== checkpoint.ownerAuthorityEpoch) {
222
+ throw new DrainConflictError('restored feedback drain does not match its checkpoint');
223
+ }
224
+ return true;
225
+ }
226
+ admitRequestNonce(authorityId, nonce, input = {}) {
227
+ const now = input.now ?? this.now();
228
+ const ttlMs = Math.max(1_000, Math.min(60 * 60_000, input.ttlMs ?? 10 * 60_000));
229
+ const nonceHash = createHmac('sha256', this.hmacKey).update(nonce).digest('hex');
230
+ return this.db.transaction(() => {
231
+ this.db.prepare(`DELETE FROM request_replay WHERE rowid IN (SELECT rowid FROM request_replay WHERE expires_at<=? ORDER BY expires_at LIMIT 100)`).run(now);
232
+ const result = this.db.prepare(`INSERT OR IGNORE INTO request_replay(authority_id,nonce_hash,expires_at,created_at) VALUES (?,?,?,?)`)
233
+ .run(clamp(authorityId, 200), nonceHash, now + ttlMs, now);
234
+ return result.changes === 1;
235
+ }).immediate();
236
+ }
237
+ projectSourceGeneration(input) {
238
+ const limit = Math.max(0, Math.min(500, Math.trunc(input.limit ?? 500)));
239
+ const generationId = clamp(input.generationId, 200);
240
+ const cursor = this.db.prepare(`SELECT generation_id,byte_offset,last_record_checksum FROM source_cursors WHERE cursor_id='canonical-feedback'`).get();
241
+ if (!generationId)
242
+ return { projected: 0, replayed: 0, byteOffset: 0, lagBytes: 0 };
243
+ if (!fs.existsSync(input.filePath)) {
244
+ if (!cursor)
245
+ return { projected: 0, replayed: 0, byteOffset: 0, lagBytes: 0 };
246
+ this.setMeta('source_integrity_hold', 'source-generation-missing');
247
+ throw new DrainConflictError('durable source generation is missing');
248
+ }
249
+ if (cursor && cursor.generation_id !== generationId)
250
+ throw new DrainConflictError('source generation changed without a validated handoff');
251
+ const offset = cursor?.byte_offset ?? 0;
252
+ const size = fs.statSync(input.filePath).size;
253
+ if (size < offset)
254
+ throw new DrainConflictError('source generation truncated before durable cursor');
255
+ if (size === offset || limit === 0)
256
+ return { projected: 0, replayed: 0, byteOffset: offset, lagBytes: size - offset };
257
+ const buffer = Buffer.allocUnsafe(Math.min(size - offset, 4 * 1024 * 1024));
258
+ const fd = fs.openSync(input.filePath, 'r');
259
+ let bytesRead = 0;
260
+ try {
261
+ bytesRead = fs.readSync(fd, buffer, 0, buffer.length, offset);
262
+ }
263
+ finally {
264
+ fs.closeSync(fd);
265
+ }
266
+ const records = [];
267
+ let consumed = 0;
268
+ for (let start = 0; start < bytesRead && records.length < limit;) {
269
+ const newline = buffer.indexOf(0x0a, start);
270
+ if (newline < 0)
271
+ break;
272
+ const raw = buffer.subarray(start, newline).toString('utf8').trim();
273
+ const recordOffset = offset + start;
274
+ consumed = newline + 1;
275
+ start = newline + 1;
276
+ if (!raw)
277
+ continue;
278
+ let parsed;
279
+ try {
280
+ parsed = JSON.parse(raw);
281
+ }
282
+ catch {
283
+ throw new DrainConflictError('invalid complete source record');
284
+ }
285
+ const entityId = String(parsed.feedbackId ?? parsed.feedback_id ?? parsed.id ?? '');
286
+ if (!entityId)
287
+ throw new DrainConflictError('source record has no stable feedback id');
288
+ const sourceId = typeof parsed.sourceRecordId === 'string' && parsed.sourceRecordId
289
+ ? parsed.sourceRecordId
290
+ : `legacy:${entityId}:${createHash('sha256').update(raw).digest('hex')}`;
291
+ records.push({ sourceId: clamp(sourceId, 300), entityId: clamp(entityId, 300), checksum: createHash('sha256').update(raw).digest('hex'), raw, offset: recordOffset, length: newline - (recordOffset - offset) + 1 });
292
+ }
293
+ if (input.crashPoint === 'after-read')
294
+ throw new Error('injected crash after source read');
295
+ let projected = 0;
296
+ let replayed = 0;
297
+ const sourceConflict = this.db.transaction(() => {
298
+ for (const record of records) {
299
+ const prior = this.db.prepare('SELECT record_checksum FROM source_projection WHERE source_record_id=?').get(record.sourceId);
300
+ if (prior && prior.record_checksum !== record.checksum) {
301
+ this.db.prepare(`INSERT INTO source_conflicts VALUES (?,?,?,?,?) ON CONFLICT(source_record_id) DO UPDATE SET observed_checksum=excluded.observed_checksum,reason=excluded.reason,created_at=excluded.created_at`)
302
+ .run(record.sourceId, prior.record_checksum, record.checksum, 'source-record-checksum-conflict', this.now());
303
+ this.setMeta('source_integrity_hold', 'source-record-checksum-conflict');
304
+ return true;
305
+ }
306
+ if (prior)
307
+ replayed++;
308
+ else {
309
+ this.db.prepare(`INSERT INTO source_projection(source_record_id,generation_id,byte_offset,byte_length,record_checksum,entity_id,record_json,created_at) VALUES (?,?,?,?,?,?,?,?)`)
310
+ .run(record.sourceId, generationId, record.offset, record.length, record.checksum, record.entityId, record.raw, this.now());
311
+ projected++;
312
+ }
313
+ }
314
+ if (input.crashPoint === 'after-insert')
315
+ throw new Error('injected crash after projection insert');
316
+ const nextOffset = offset + consumed;
317
+ const checksum = records.at(-1)?.checksum ?? cursor?.last_record_checksum ?? '';
318
+ this.db.prepare(`INSERT INTO source_cursors(cursor_id,generation_id,byte_offset,last_record_checksum,updated_at) VALUES ('canonical-feedback',?,?,?,?)
319
+ ON CONFLICT(cursor_id) DO UPDATE SET generation_id=excluded.generation_id,byte_offset=excluded.byte_offset,last_record_checksum=excluded.last_record_checksum,updated_at=excluded.updated_at`)
320
+ .run(generationId, nextOffset, checksum, this.now());
321
+ return false;
322
+ }).immediate();
323
+ if (sourceConflict)
324
+ throw new DrainConflictError('source record checksum conflicts with its projection');
325
+ const byteOffset = offset + consumed;
326
+ this.db.prepare(`INSERT INTO drain_meta(key,value) VALUES ('source_lag_bytes',?) ON CONFLICT(key) DO UPDATE SET value=excluded.value`).run(String(Math.max(0, size - byteOffset)));
327
+ if (input.crashPoint === 'after-commit')
328
+ throw new Error('injected crash after projection commit');
329
+ return { projected, replayed, byteOffset, lagBytes: Math.max(0, size - byteOffset) };
330
+ }
331
+ sourceCursor() {
332
+ const row = this.db.prepare(`SELECT generation_id,byte_offset,last_record_checksum FROM source_cursors WHERE cursor_id='canonical-feedback'`).get();
333
+ return row ? { generationId: row.generation_id, byteOffset: row.byte_offset, lastRecordChecksum: row.last_record_checksum } : null;
334
+ }
335
+ pendingProjectedFeedback(limit = 500) {
336
+ const cursor = Number(this.db.prepare(`SELECT value FROM drain_meta WHERE key='processing_ingest_sequence'`).get()?.value ?? 0);
337
+ const rows = this.db.prepare(`SELECT ingest_sequence,record_json FROM source_projection WHERE ingest_sequence>? ORDER BY ingest_sequence LIMIT ?`)
338
+ .all(cursor, Math.max(0, Math.min(500, Math.trunc(limit))));
339
+ return rows.map((row) => ({ ingestSequence: row.ingest_sequence, record: JSON.parse(row.record_json) }));
340
+ }
341
+ acknowledgeProcessedProjection(ingestSequence) {
342
+ const current = Number(this.db.prepare(`SELECT value FROM drain_meta WHERE key='processing_ingest_sequence'`).get()?.value ?? 0);
343
+ if (!Number.isSafeInteger(ingestSequence) || ingestSequence < current)
344
+ throw new DrainConflictError('processing projection cursor cannot rewind');
345
+ const max = Number(this.db.prepare(`SELECT MAX(ingest_sequence) n FROM source_projection`).get().n ?? 0);
346
+ if (ingestSequence > max)
347
+ throw new DrainConflictError('processing projection cursor exceeds durable projection');
348
+ this.setMeta('processing_ingest_sequence', String(ingestSequence));
349
+ }
350
+ acceptSourceHandoff(input) {
351
+ this.db.transaction(() => {
352
+ const cursor = this.sourceCursor();
353
+ if (!cursor || cursor.generationId !== input.fromGenerationId || cursor.byteOffset !== input.finalOffset) {
354
+ throw new DrainConflictError('source cursor has not acknowledged the immutable generation boundary');
355
+ }
356
+ const existing = this.db.prepare(`SELECT COUNT(*) n FROM source_projection WHERE generation_id=?`).get(input.toGenerationId);
357
+ if (existing.n > 0)
358
+ throw new DrainConflictError('target source generation was projected before handoff acceptance');
359
+ this.db.prepare(`UPDATE source_cursors SET generation_id=?,byte_offset=0,last_record_checksum='',updated_at=? WHERE cursor_id='canonical-feedback' AND generation_id=? AND byte_offset=?`)
360
+ .run(input.toGenerationId, this.now(), input.fromGenerationId, input.finalOffset);
361
+ this.audit('source-generation', input.toGenerationId, input.fromGenerationId, input.toGenerationId, 'checksummed-handoff-accepted');
362
+ }).immediate();
363
+ }
364
+ reconcileSourceProjection(input) {
365
+ if (!fs.existsSync(input.filePath)) {
366
+ const sourceId = `generation:${clamp(input.generationId, 200)}`;
367
+ this.db.prepare(`INSERT INTO source_conflicts VALUES (?,?,?,?,?) ON CONFLICT(source_record_id) DO UPDATE SET observed_checksum=excluded.observed_checksum,reason=excluded.reason,created_at=excluded.created_at`)
368
+ .run(sourceId, 'generation-present', 'generation-missing', 'reconciliation-generation-missing', this.now());
369
+ this.setMeta('source_integrity_hold', 'reconciliation-generation-missing');
370
+ this.setMeta('last_source_reconciliation_at', String(this.now()));
371
+ throw new DrainConflictError('source generation is missing during reconciliation');
372
+ }
373
+ const rows = this.db.prepare(`SELECT * FROM source_projection WHERE generation_id=? ORDER BY checked_at IS NOT NULL,checked_at,ingest_sequence LIMIT ?`)
374
+ .all(input.generationId, Math.max(0, Math.min(500, Math.trunc(input.limit ?? 500))));
375
+ let conflicts = 0;
376
+ const fd = fs.openSync(input.filePath, 'r');
377
+ try {
378
+ for (const row of rows) {
379
+ const bytes = Buffer.alloc(Number(row.byte_length));
380
+ const read = fs.readSync(fd, bytes, 0, bytes.length, Number(row.byte_offset));
381
+ const raw = bytes.subarray(0, read).toString('utf8').trim();
382
+ const observed = createHash('sha256').update(raw).digest('hex');
383
+ if (observed !== row.record_checksum) {
384
+ conflicts++;
385
+ this.db.prepare(`INSERT INTO source_conflicts VALUES (?,?,?,?,?) ON CONFLICT(source_record_id) DO UPDATE SET observed_checksum=excluded.observed_checksum,reason=excluded.reason,created_at=excluded.created_at`)
386
+ .run(row.source_record_id, row.record_checksum, observed, 'reconciliation-checksum-conflict', this.now());
387
+ }
388
+ this.db.prepare('UPDATE source_projection SET checked_at=? WHERE ingest_sequence=?').run(this.now(), row.ingest_sequence);
389
+ }
390
+ }
391
+ finally {
392
+ fs.closeSync(fd);
393
+ }
394
+ this.setMeta('last_source_reconciliation_at', String(this.now()));
395
+ return { checked: rows.length, conflicts };
396
+ }
397
+ markClusteringSucceeded(at = this.now()) { this.setMeta('last_successful_clustering_at', String(at)); }
398
+ sourceCompactionDue(intervalMs, now = this.now()) {
399
+ const row = this.db.prepare(`SELECT value FROM drain_meta WHERE key='last_source_compaction_at'`).get();
400
+ if (!row) {
401
+ this.setMeta('last_source_compaction_at', String(now));
402
+ return false;
403
+ }
404
+ const last = Number(row?.value ?? 0);
405
+ return !Number.isFinite(last) || last <= 0 || now - last >= Math.max(1, intervalMs);
406
+ }
407
+ recordSourceCompaction(at = this.now()) { this.setMeta('last_source_compaction_at', String(at)); }
408
+ ensureReadiness(clusterId, nextReviewAt = null) {
409
+ const now = this.now();
410
+ this.db.prepare(`INSERT OR IGNORE INTO readiness(cluster_id,state,epoch,entered_at,next_review_at)
411
+ VALUES (?,'collecting',0,?,?)`).run(clamp(clusterId, 200), now, nextReviewAt);
412
+ return this.getReadiness(clusterId);
413
+ }
414
+ getReadiness(clusterId) {
415
+ const row = this.db.prepare('SELECT * FROM readiness WHERE cluster_id=?').get(clusterId);
416
+ return row ? this.readinessFromRow(row) : null;
417
+ }
418
+ approveReady(input) {
419
+ if (!/^[A-Za-z0-9._:-]{16,128}$/.test(input.decisionNonce) || !/^[a-f0-9]{64}$/.test(input.proposalSetHash)) {
420
+ throw new DrainConflictError('approval decision nonce or proposal-set hash is invalid');
421
+ }
422
+ const outcome = this.db.transaction(() => {
423
+ const duplicate = this.db.prepare('SELECT * FROM readiness_approvals WHERE approval_key=?').get(input.approvalKey);
424
+ if (duplicate) {
425
+ if (duplicate.cluster_id !== input.clusterId || duplicate.evidence_hash !== input.evidenceHash ||
426
+ duplicate.authority_id !== input.authorityId || Number(duplicate.authority_generation) !== input.authorityGeneration ||
427
+ duplicate.decision_nonce !== input.decisionNonce || duplicate.proposal_set_hash !== input.proposalSetHash) {
428
+ const current = this.getReadiness(input.clusterId) ?? this.ensureReadiness(input.clusterId, input.nextReviewAt ?? null);
429
+ const row = current.state === 'held' ? current : this.holdReadinessInternal(input.clusterId, 'approval-key-conflict');
430
+ return { row, conflict: true };
431
+ }
432
+ return { row: this.getReadiness(input.clusterId), conflict: false };
433
+ }
434
+ const authority = this.getAuthority(input.authorityId, input.authorityGeneration);
435
+ if (!authority || authority.revoked || authority.generation !== this.latestAuthority(input.authorityId)?.generation)
436
+ throw new DrainConflictError('readiness authority is not active');
437
+ const current = this.getReadiness(input.clusterId) ?? this.ensureReadiness(input.clusterId, input.nextReviewAt ?? null);
438
+ if (current.state !== 'collecting')
439
+ throw new DrainConflictError(`cannot approve readiness from ${current.state}`);
440
+ const now = this.now();
441
+ const epoch = current.epoch + 1;
442
+ this.db.prepare(`UPDATE readiness SET state='ready',epoch=?,entered_at=?,last_evaluated_at=?,next_review_at=NULL,reason_code='authority-approved' WHERE cluster_id=? AND state='collecting'`)
443
+ .run(epoch, now, now, input.clusterId);
444
+ this.inject('readiness-after-state');
445
+ this.db.prepare('INSERT INTO readiness_approvals(approval_key,cluster_id,evidence_hash,authority_id,authority_generation,decision_nonce,proposal_set_hash,epoch,created_at) VALUES (?,?,?,?,?,?,?,?,?)')
446
+ .run(input.approvalKey, input.clusterId, input.evidenceHash, input.authorityId, input.authorityGeneration, input.decisionNonce, input.proposalSetHash, epoch, now);
447
+ this.inject('readiness-after-approval');
448
+ this.audit('readiness', input.clusterId, 'collecting', 'ready', 'authority-approved');
449
+ return { row: this.getReadiness(input.clusterId), conflict: false };
450
+ }).immediate();
451
+ this.inject('readiness-after-commit');
452
+ if (outcome.conflict)
453
+ throw new DrainConflictError('approval key conflicts with its recorded evidence');
454
+ return outcome.row;
455
+ }
456
+ holdReadiness(clusterId, reason) {
457
+ return this.db.transaction(() => this.holdReadinessInternal(clusterId, reason)).immediate();
458
+ }
459
+ releaseHeld(clusterId, input) {
460
+ if (!input.revalidated)
461
+ throw new DrainConflictError('integrity revalidation is required');
462
+ const now = this.now();
463
+ const result = this.db.prepare(`UPDATE readiness SET state='collecting',epoch=epoch+1,entered_at=?,last_evaluated_at=?,reason_code=? WHERE cluster_id=? AND state='held'`)
464
+ .run(now, now, clamp(input.reason, 200), clusterId);
465
+ if (result.changes !== 1)
466
+ throw new DrainConflictError('readiness is not held');
467
+ this.audit('readiness', clusterId, 'held', 'collecting', input.reason);
468
+ return this.getReadiness(clusterId);
469
+ }
470
+ dueReadiness(limit = 100, now = this.now()) {
471
+ const bounded = Math.max(0, Math.min(100, Math.trunc(limit)));
472
+ return this.db.prepare(`SELECT * FROM readiness WHERE state='collecting' AND next_review_at IS NOT NULL AND next_review_at<=? ORDER BY next_review_at,cluster_id LIMIT ?`)
473
+ .all(now, bounded).map(row => this.readinessFromRow(row));
474
+ }
475
+ recordCollectingEvaluation(clusterId, input) {
476
+ const now = this.now();
477
+ const result = this.db.prepare(`UPDATE readiness SET last_evaluated_at=?,next_review_at=?,reason_code=? WHERE cluster_id=? AND state='collecting'`)
478
+ .run(now, input.nextReviewAt, clamp(input.reason, 200), clusterId);
479
+ if (result.changes !== 1)
480
+ throw new DrainConflictError('readiness is not collecting');
481
+ return this.getReadiness(clusterId);
482
+ }
483
+ readyReadiness(limit = 250) {
484
+ const bounded = Math.max(0, Math.min(250, Math.trunc(limit)));
485
+ return this.db.prepare(`SELECT * FROM readiness WHERE state='ready' ORDER BY entered_at,cluster_id LIMIT ?`)
486
+ .all(bounded).map((row) => this.readinessFromRow(row));
487
+ }
488
+ enqueue(input) {
489
+ const result = this.db.transaction(() => {
490
+ const readiness = this.getReadiness(input.clusterId);
491
+ if (!readiness || !['ready', 'queued'].includes(readiness.state))
492
+ throw new DrainConflictError('cluster is not ready');
493
+ const key = `feedback-work:${input.clusterId}:${readiness.epoch}`;
494
+ const existing = this.workByKey(key);
495
+ if (existing)
496
+ return existing;
497
+ if (readiness.state !== 'ready')
498
+ throw new DrainConflictError('queued readiness has no work row');
499
+ const now = this.now();
500
+ const workId = this.idFactory();
501
+ this.db.prepare(`INSERT INTO work(work_id,idempotency_key,cluster_id,readiness_epoch,state,title,summary,priority,report_count,first_seen_at,last_seen_at,authority_ref,evidence_ref,created_at,updated_at)
502
+ VALUES (?,?,?,?,'queued',?,?,?,?,?,?,?,?,?,?)`).run(workId, key, input.clusterId, readiness.epoch, clamp(input.title, 240), clamp(input.summary, 2000), clamp(input.priority, 40), Math.max(0, Math.trunc(input.reportCount)), input.firstSeenAt, input.lastSeenAt, clamp(input.authorityRef, 300), clamp(input.evidenceRef, 300), now, now);
503
+ this.inject('enqueue-after-work');
504
+ this.db.prepare(`UPDATE readiness SET state='queued',entered_at=?,reason_code='work-enqueued' WHERE cluster_id=? AND state='ready' AND epoch=?`).run(now, input.clusterId, readiness.epoch);
505
+ this.inject('enqueue-after-readiness');
506
+ this.db.prepare(`INSERT INTO artifact_links(work_id,state,external_key,updated_at) VALUES (?,'pending',?,?)`).run(workId, key, now);
507
+ this.inject('enqueue-after-link');
508
+ this.audit('work', workId, null, 'queued', 'readiness-enqueued');
509
+ return this.workById(workId);
510
+ }).immediate();
511
+ this.inject('enqueue-after-commit');
512
+ return result;
513
+ }
514
+ workById(workId) {
515
+ const row = this.db.prepare('SELECT * FROM work WHERE work_id=?').get(workId);
516
+ return row ? this.workFromRow(row) : null;
517
+ }
518
+ workByKey(key) {
519
+ const row = this.db.prepare('SELECT * FROM work WHERE idempotency_key=?').get(key);
520
+ return row ? this.workFromRow(row) : null;
521
+ }
522
+ isRetiredWorkKey(key) {
523
+ return Boolean(this.db.prepare('SELECT 1 found FROM work_tombstones WHERE idempotency_key=?').get(key));
524
+ }
525
+ claimNext(input) {
526
+ const claim = this.db.transaction(() => {
527
+ this.assertOrInitializeOwnerAuthorityEpoch(input.ownerAuthorityEpoch);
528
+ const now = input.now ?? this.now();
529
+ const row = this.db.prepare(`SELECT work_id FROM work WHERE state IN ('queued','retryable') AND (next_attempt_at IS NULL OR next_attempt_at<=?) ORDER BY created_at,work_id LIMIT 1`).get(now);
530
+ if (!row)
531
+ return null;
532
+ const token = this.tokenFactory();
533
+ const tokenHash = this.hashToken(token);
534
+ const result = this.db.prepare(`UPDATE work SET state='claimed',lease_epoch=lease_epoch+1,lease_expires_at=?,consumer_id=?,token_hash=?,owner_authority_epoch=?,attempts=attempts+1,claimed_at=?,updated_at=?
535
+ WHERE work_id=? AND state IN ('queued','retryable') AND (next_attempt_at IS NULL OR next_attempt_at<=?)`)
536
+ .run(now + Math.max(1, input.leaseMs), clamp(input.consumerId, 200), tokenHash, input.ownerAuthorityEpoch, now, now, row.work_id, now);
537
+ if (result.changes !== 1)
538
+ throw new DrainConflictError('claim lost');
539
+ this.inject('claim-after-update');
540
+ this.audit('work', row.work_id, null, 'claimed', 'consumer-claim');
541
+ return { ...this.workById(row.work_id), claimToken: token };
542
+ }).immediate();
543
+ this.inject('claim-after-commit');
544
+ return claim;
545
+ }
546
+ /** Exercise the real claim/link/ack FSM in an isolated ephemeral database. */
547
+ simulateClaims(input) {
548
+ const limit = Math.max(0, Math.min(50, Math.trunc(input.limit)));
549
+ const rows = this.db.prepare(`SELECT * FROM work WHERE state IN ('queued','retryable') AND (next_attempt_at IS NULL OR next_attempt_at<=?) ORDER BY created_at,work_id LIMIT ?`)
550
+ .all(input.now ?? this.now(), limit);
551
+ const simulation = new FeedbackDrainStore({ dbPath: ':memory:', tokenHmacKey: this.hmacKey, clock: this.now });
552
+ let simulated = 0;
553
+ try {
554
+ const insertWork = simulation.db.prepare(`INSERT INTO work(work_id,idempotency_key,cluster_id,readiness_epoch,state,title,summary,priority,report_count,first_seen_at,last_seen_at,authority_ref,evidence_ref,lease_epoch,attempts,next_attempt_at,created_at,updated_at)
555
+ VALUES (?,?,?,?,'queued',?,?,?,?,?,?,?,?,?,?,NULL,?,?)`);
556
+ const insertLink = simulation.db.prepare(`INSERT INTO artifact_links(work_id,state,external_key,updated_at) VALUES (?,'pending',?,?)`);
557
+ for (const row of rows) {
558
+ insertWork.run(row.work_id, row.idempotency_key, row.cluster_id, row.readiness_epoch, row.title, row.summary, row.priority, row.report_count, row.first_seen_at, row.last_seen_at, row.authority_ref, row.evidence_ref, row.lease_epoch, row.attempts, row.created_at, row.updated_at);
559
+ insertLink.run(row.work_id, row.idempotency_key, row.updated_at);
560
+ }
561
+ for (let i = 0; i < limit; i++) {
562
+ const claim = simulation.claimNext({ consumerId: 'initiative-simulation', ownerAuthorityEpoch: input.ownerAuthorityEpoch, leaseMs: 30_000, now: input.now });
563
+ if (!claim)
564
+ break;
565
+ simulation.markArtifactReadable({
566
+ workId: claim.workId, leaseEpoch: claim.leaseEpoch, claimToken: claim.claimToken,
567
+ ownerAuthorityEpoch: input.ownerAuthorityEpoch, artifactId: `simulation:${claim.workId}`, artifactKind: 'simulation',
568
+ });
569
+ simulation.complete({ workId: claim.workId, leaseEpoch: claim.leaseEpoch, claimToken: claim.claimToken, ownerAuthorityEpoch: input.ownerAuthorityEpoch });
570
+ simulated++;
571
+ }
572
+ }
573
+ finally {
574
+ simulation.close();
575
+ }
576
+ return simulated;
577
+ }
578
+ markArtifactReadable(input) {
579
+ this.db.transaction(() => {
580
+ this.assertCurrentClaim(input);
581
+ const now = this.now();
582
+ const result = this.db.prepare(`UPDATE artifact_links SET state='readable',artifact_id=?,artifact_kind=?,verified_at=?,updated_at=? WHERE work_id=? AND state IN ('pending','degraded','readable')`)
583
+ .run(clamp(input.artifactId, 300), clamp(input.artifactKind, 80), now, now, input.workId);
584
+ if (result.changes !== 1)
585
+ throw new DrainConflictError('artifact link is held or missing');
586
+ this.inject('artifact-link-after-update');
587
+ }).immediate();
588
+ this.inject('artifact-link-after-commit');
589
+ }
590
+ complete(input) {
591
+ const completed = this.db.transaction(() => {
592
+ this.assertCurrentClaim(input);
593
+ const link = this.db.prepare(`SELECT state,artifact_id FROM artifact_links WHERE work_id=?`).get(input.workId);
594
+ if (!link || link.state !== 'readable' || !link.artifact_id)
595
+ throw new DrainConflictError('readable artifact linkage is required');
596
+ const now = this.now();
597
+ const result = this.db.prepare(`UPDATE work SET state='completed',token_hash=NULL,lease_expires_at=NULL,completed_at=?,updated_at=? WHERE work_id=? AND state='claimed' AND lease_epoch=? AND owner_authority_epoch=?`)
598
+ .run(now, now, input.workId, input.leaseEpoch, input.ownerAuthorityEpoch);
599
+ if (result.changes !== 1)
600
+ throw new DrainConflictError('claim is stale');
601
+ this.inject('completion-after-update');
602
+ this.audit('work', input.workId, 'claimed', 'completed', 'artifact-readable');
603
+ return this.workById(input.workId);
604
+ }).immediate();
605
+ this.inject('completion-after-commit');
606
+ return completed;
607
+ }
608
+ retry(input) {
609
+ const retried = this.db.transaction(() => {
610
+ this.assertCurrentClaim(input);
611
+ const work = this.workById(input.workId);
612
+ const next = work.attempts >= input.maxAttempts ? 'dead-lettered' : 'retryable';
613
+ const now = this.now();
614
+ this.db.prepare(`UPDATE work SET state=?,token_hash=NULL,lease_expires_at=NULL,next_attempt_at=?,updated_at=? WHERE work_id=? AND state='claimed' AND lease_epoch=? AND owner_authority_epoch=?`)
615
+ .run(next, next === 'retryable' ? input.retryAt : null, now, input.workId, input.leaseEpoch, input.ownerAuthorityEpoch);
616
+ this.inject('retry-after-update');
617
+ this.audit('work', input.workId, 'claimed', next, input.reason);
618
+ return this.workById(input.workId);
619
+ }).immediate();
620
+ this.inject('retry-after-commit');
621
+ return retried;
622
+ }
623
+ holdWork(workId, reason) {
624
+ const current = this.workById(workId);
625
+ if (!current || ['completed', 'dead-lettered', 'held'].includes(current.state))
626
+ throw new DrainConflictError('work is terminal or missing');
627
+ const now = this.now();
628
+ this.db.prepare(`UPDATE work SET state='held',lease_epoch=lease_epoch+1,token_hash=NULL,lease_expires_at=NULL,next_attempt_at=NULL,updated_at=? WHERE work_id=?`).run(now, workId);
629
+ this.db.prepare(`UPDATE artifact_links SET state='held',updated_at=? WHERE work_id=?`).run(now, workId);
630
+ this.audit('work', workId, current.state, 'held', reason);
631
+ return this.workById(workId);
632
+ }
633
+ reconcileExpiredLeases(input) {
634
+ return this.db.transaction(() => {
635
+ const now = input.now ?? this.now();
636
+ const limit = Math.max(0, Math.min(100, Math.trunc(input.limit ?? 100)));
637
+ const rows = this.db.prepare(`SELECT work_id,attempts FROM work WHERE state='claimed' AND lease_expires_at<=? ORDER BY lease_expires_at,work_id LIMIT ?`).all(now, limit);
638
+ let retryable = 0;
639
+ let deadLettered = 0;
640
+ for (const row of rows) {
641
+ const next = row.attempts >= input.maxAttempts ? 'dead-lettered' : 'retryable';
642
+ this.db.prepare(`UPDATE work SET state=?,lease_epoch=lease_epoch+1,token_hash=NULL,lease_expires_at=NULL,next_attempt_at=?,updated_at=? WHERE work_id=? AND state='claimed' AND lease_expires_at<=?`)
643
+ .run(next, next === 'retryable' ? now + input.retryDelayMs : null, now, row.work_id, now);
644
+ this.audit('work', row.work_id, 'claimed', next, 'lease-expired');
645
+ if (next === 'retryable')
646
+ retryable++;
647
+ else
648
+ deadLettered++;
649
+ }
650
+ return { reconciled: rows.length, retryable, deadLettered };
651
+ }).immediate();
652
+ }
653
+ /**
654
+ * Complete the safety-critical part of a restored DB admission. A restore
655
+ * never reuses pre-restore writer or claim fences: integrity is checked,
656
+ * the owner epoch advances exactly once, active runs are abandoned, and
657
+ * every outstanding claim token is invalidated before another claim opens.
658
+ */
659
+ finalizeRestore(input) {
660
+ if (!this.integrityCheck())
661
+ throw new DrainConflictError('restored database failed integrity_check');
662
+ if (!Number.isSafeInteger(input.restoredOwnerAuthorityEpoch) || input.restoredOwnerAuthorityEpoch < 1) {
663
+ throw new Error('restoredOwnerAuthorityEpoch must be a positive safe integer');
664
+ }
665
+ if (!clamp(input.operatorDecisionRef, 300))
666
+ throw new Error('operatorDecisionRef is required');
667
+ if (this.dbPath !== ':memory:') {
668
+ const checkpointPath = path.join(path.dirname(this.dbPath), 'feedback-drain-checkpoint.json');
669
+ let checkpoint;
670
+ try {
671
+ checkpoint = JSON.parse(fs.readFileSync(checkpointPath, 'utf8'));
672
+ }
673
+ catch {
674
+ throw new DrainConflictError('restored checkpoint manifest is missing or malformed');
675
+ }
676
+ const payload = { schemaVersion: checkpoint.schemaVersion, snapshotId: checkpoint.snapshotId, checksum: checkpoint.checksum, dbFileIdentity: checkpoint.dbFileIdentity,
677
+ ownerAuthorityEpoch: checkpoint.ownerAuthorityEpoch, createdAt: checkpoint.createdAt };
678
+ const expectedManifest = createHash('sha256').update(JSON.stringify(payload)).digest('hex');
679
+ const restoredDbChecksum = createHash('sha256').update(fs.readFileSync(this.dbPath)).digest('hex');
680
+ if (checkpoint.schemaVersion !== 1 || checkpoint.manifestChecksum !== expectedManifest || input.manifestChecksum !== checkpoint.manifestChecksum ||
681
+ input.snapshotId !== checkpoint.snapshotId || checkpoint.checksum !== restoredDbChecksum || checkpoint.ownerAuthorityEpoch !== input.restoredOwnerAuthorityEpoch ||
682
+ checkpoint.dbFileIdentity === sqliteFileIdentity(this.dbPath)) {
683
+ throw new DrainConflictError('restored checkpoint identity or checksum verification failed');
684
+ }
685
+ }
686
+ const recovery = input.splitBrainRecoveryPacket;
687
+ if (!input.oldOwnerQuiesced && (!recovery || recovery.oldOwnerStatus !== 'unreachable-or-fenced' ||
688
+ recovery.operatorDecisionRef !== input.operatorDecisionRef || !/^[-A-Za-z0-9._:]{8,200}$/.test(recovery.incidentId))) {
689
+ throw new DrainConflictError('old-owner quiescence or an explicit split-brain recovery packet is required');
690
+ }
691
+ const restored = this.db.transaction(() => {
692
+ const recorded = this.ownerAuthorityEpoch();
693
+ if (recorded !== null && recorded !== input.restoredOwnerAuthorityEpoch) {
694
+ throw new DrainConflictError('restored owner authority epoch does not match the durable snapshot');
695
+ }
696
+ const now = this.now();
697
+ const nextEpoch = input.restoredOwnerAuthorityEpoch + 1;
698
+ const claims = this.db.prepare(`SELECT work_id FROM work WHERE state='claimed' ORDER BY work_id`).all();
699
+ for (const claim of claims) {
700
+ this.db.prepare(`UPDATE work SET state='retryable',lease_epoch=lease_epoch+1,token_hash=NULL,
701
+ lease_expires_at=NULL,next_attempt_at=?,updated_at=? WHERE work_id=? AND state='claimed'`)
702
+ .run(now, now, claim.work_id);
703
+ this.audit('work', claim.work_id, 'claimed', 'retryable', 'restore-invalidated-claim');
704
+ }
705
+ this.inject('restore-after-claims');
706
+ const activeRuns = this.db.prepare(`SELECT run_id,state FROM drain_runs WHERE state IN ('accepted','running') ORDER BY run_id`).all();
707
+ for (const run of activeRuns) {
708
+ this.db.prepare(`UPDATE drain_runs SET state='abandoned',reason='restore-epoch-bump',updated_at=? WHERE run_id=? AND state=?`).run(now, run.run_id, run.state);
709
+ }
710
+ this.inject('restore-after-runs');
711
+ this.setMeta('owner_authority_epoch', String(nextEpoch));
712
+ this.inject('restore-after-epoch');
713
+ this.audit('restore', input.operatorDecisionRef, String(input.restoredOwnerAuthorityEpoch), String(nextEpoch), 'restore-owner-epoch-bump');
714
+ return { ownerAuthorityEpoch: nextEpoch, invalidatedClaims: claims.length, abandonedRuns: activeRuns.length };
715
+ }).immediate();
716
+ this.inject('restore-after-commit');
717
+ return restored;
718
+ }
719
+ ownerAuthorityEpoch() {
720
+ const row = this.db.prepare(`SELECT value FROM drain_meta WHERE key='owner_authority_epoch'`).get();
721
+ if (!row)
722
+ return null;
723
+ const value = Number(row.value);
724
+ if (!Number.isSafeInteger(value) || value < 1)
725
+ throw new DrainConflictError('durable owner authority epoch is corrupt');
726
+ return value;
727
+ }
728
+ /**
729
+ * Reconcile the SQLite saga side with InitiativeTracker's immutable exact
730
+ * key. Ambiguous, missing, unreadable, or conflicting links are held (or
731
+ * marked degraded for immutable completed history); they are never treated
732
+ * as permission to create another Initiative.
733
+ */
734
+ reconcileInitiativeLinks(input) {
735
+ const limit = Math.max(0, Math.min(500, Math.trunc(input.limit ?? 500)));
736
+ const rows = this.db.prepare(`SELECT w.work_id,w.idempotency_key,w.state,l.state link_state,l.artifact_id
737
+ FROM work w JOIN artifact_links l ON l.work_id=w.work_id ORDER BY w.work_id LIMIT ?`).all(limit);
738
+ const result = { checked: 0, linked: 0, held: 0, degraded: 0 };
739
+ for (const row of rows) {
740
+ result.checked++;
741
+ let observations;
742
+ try {
743
+ observations = input.lookupByFeedbackWorkKey(row.idempotency_key);
744
+ }
745
+ catch {
746
+ observations = [];
747
+ }
748
+ const exact = observations.filter((item) => item.feedbackWorkKey === row.idempotency_key && item.readable);
749
+ const resolved = exact.length === 1 ? exact[0] : null;
750
+ const conflicts = !resolved || (row.artifact_id !== null && row.artifact_id !== resolved.artifactId);
751
+ if (conflicts) {
752
+ if (row.state === 'completed') {
753
+ this.db.prepare(`UPDATE artifact_links SET state='degraded',updated_at=? WHERE work_id=?`).run(this.now(), row.work_id);
754
+ this.audit('artifact-link', row.work_id, row.link_state, 'degraded', exact.length > 1 ? 'duplicate-feedback-work-key' : 'initiative-link-unresolved');
755
+ result.degraded++;
756
+ }
757
+ else if (!['held', 'dead-lettered'].includes(row.state)) {
758
+ this.holdWork(row.work_id, exact.length > 1 ? 'duplicate-feedback-work-key' : 'initiative-link-unresolved');
759
+ result.held++;
760
+ }
761
+ else {
762
+ this.db.prepare(`UPDATE artifact_links SET state='held',updated_at=? WHERE work_id=?`).run(this.now(), row.work_id);
763
+ result.held++;
764
+ }
765
+ continue;
766
+ }
767
+ this.db.prepare(`UPDATE artifact_links SET state='readable',artifact_id=?,artifact_kind=?,verified_at=?,updated_at=? WHERE work_id=? AND state!='held'`)
768
+ .run(clamp(resolved.artifactId, 300), clamp(resolved.artifactKind, 80), this.now(), this.now(), row.work_id);
769
+ result.linked++;
770
+ }
771
+ return result;
772
+ }
773
+ startRun(input) {
774
+ return this.db.transaction(() => {
775
+ const active = this.db.prepare(`SELECT run_id,state FROM drain_runs WHERE state IN ('accepted','running') LIMIT 1`).get();
776
+ if (active)
777
+ return { runId: active.run_id, state: active.state, acquired: false };
778
+ const now = this.now();
779
+ const runId = `run:${randomUUID()}`;
780
+ this.db.prepare(`INSERT INTO drain_runs(run_id,state,owner_host,owner_epoch,lease_expires_at,reason,created_at,updated_at) VALUES (?,'accepted',?,?,?,?,?,?)`).run(runId, clamp(input.ownerHost, 200), input.ownerEpoch, now + input.leaseMs, '', now, now);
781
+ return { runId, state: 'accepted', acquired: true };
782
+ }).immediate();
783
+ }
784
+ heartbeatRun(runId, ownerHost, ownerEpoch, leaseMs) {
785
+ const now = this.now();
786
+ const result = this.db.prepare(`UPDATE drain_runs SET lease_expires_at=?,updated_at=?
787
+ WHERE run_id=? AND state IN ('accepted','running') AND owner_host=? AND owner_epoch=?`)
788
+ .run(now + Math.max(1, leaseMs), now, runId, clamp(ownerHost, 200), ownerEpoch);
789
+ if (result.changes !== 1)
790
+ throw new DrainConflictError('run fence is stale');
791
+ }
792
+ transitionRun(runId, from, to, reason = '', fence) {
793
+ const allowed = from === 'accepted' ? new Set(['running', 'failed', 'abandoned']) : new Set(['succeeded', 'no-op', 'degraded', 'failed', 'abandoned']);
794
+ if (!allowed.has(to))
795
+ throw new DrainConflictError(`invalid run transition ${from} -> ${to}`);
796
+ this.db.transaction(() => {
797
+ const result = fence
798
+ ? this.db.prepare('UPDATE drain_runs SET state=?,reason=?,updated_at=? WHERE run_id=? AND state=? AND owner_host=? AND owner_epoch=?')
799
+ .run(to, clamp(reason, 500), this.now(), runId, from, clamp(fence.ownerHost, 200), fence.ownerEpoch)
800
+ : this.db.prepare('UPDATE drain_runs SET state=?,reason=?,updated_at=? WHERE run_id=? AND state=?').run(to, clamp(reason, 500), this.now(), runId, from);
801
+ if (result.changes !== 1)
802
+ throw new DrainConflictError('run state is stale');
803
+ this.inject('run-after-transition');
804
+ }).immediate();
805
+ this.inject('run-after-commit');
806
+ }
807
+ requestRunCancellation(runId, fence) {
808
+ const now = this.now();
809
+ const result = this.db.prepare(`UPDATE drain_runs SET cancellation_requested_at=COALESCE(cancellation_requested_at,?),updated_at=?
810
+ WHERE run_id=? AND state IN ('accepted','running') AND owner_host=? AND owner_epoch=?`)
811
+ .run(now, now, runId, clamp(fence.ownerHost, 200), fence.ownerEpoch);
812
+ if (result.changes !== 1)
813
+ throw new DrainConflictError('run cancellation fence is stale');
814
+ return true;
815
+ }
816
+ isRunCancellationRequested(runId, fence) {
817
+ const row = this.db.prepare(`SELECT cancellation_requested_at FROM drain_runs WHERE run_id=? AND state IN ('accepted','running') AND owner_host=? AND owner_epoch=?`)
818
+ .get(runId, clamp(fence.ownerHost, 200), fence.ownerEpoch);
819
+ if (!row)
820
+ throw new DrainConflictError('run cancellation fence is stale');
821
+ return row.cancellation_requested_at !== null;
822
+ }
823
+ stopCancelledRunAtBoundary(runId, fence) {
824
+ if (!this.isRunCancellationRequested(runId, fence))
825
+ return false;
826
+ const row = this.db.prepare(`SELECT state FROM drain_runs WHERE run_id=?`).get(runId);
827
+ this.transitionRun(runId, row.state, 'abandoned', 'cancelled-at-stage-boundary', fence);
828
+ return true;
829
+ }
830
+ pruneOperationalHistory(input) {
831
+ const now = input.now ?? this.now();
832
+ const limit = Math.max(0, Math.min(1_000, Math.trunc(input.limit ?? 500)));
833
+ this.assertOrInitializeOwnerAuthorityEpoch(input.ownerAuthorityEpoch);
834
+ const foreignActive = this.db.prepare(`SELECT 1 found FROM drain_runs WHERE state IN ('accepted','running') AND (owner_host!=? OR owner_epoch!=?) LIMIT 1`)
835
+ .get(clamp(input.ownerHost, 200), input.ownerAuthorityEpoch);
836
+ if (foreignActive)
837
+ throw new DrainConflictError('retention writer fence is not owned');
838
+ const queueCutoff = now - 400 * 24 * 60 * 60 * 1000;
839
+ const runCutoff = now - 30 * 24 * 60 * 60 * 1000;
840
+ const counts = this.db.transaction(() => {
841
+ let remaining = limit;
842
+ const terminal = this.db.prepare(`SELECT work_id,idempotency_key,state FROM work WHERE state IN ('completed','dead-lettered','held') AND updated_at<? ORDER BY updated_at,work_id LIMIT ?`)
843
+ .all(queueCutoff, remaining);
844
+ for (const row of terminal) {
845
+ this.db.prepare(`INSERT OR IGNORE INTO work_tombstones VALUES (?,?,?,?)`).run(row.idempotency_key, row.work_id, row.state, now);
846
+ this.db.prepare(`DELETE FROM artifact_links WHERE work_id=?`).run(row.work_id);
847
+ this.db.prepare(`DELETE FROM work WHERE work_id=?`).run(row.work_id);
848
+ }
849
+ remaining -= terminal.length;
850
+ const audits = this.db.prepare(`SELECT sequence FROM drain_audit WHERE created_at<? ORDER BY sequence LIMIT ?`).all(queueCutoff, remaining);
851
+ for (const row of audits)
852
+ this.db.prepare(`DELETE FROM drain_audit WHERE sequence=?`).run(row.sequence);
853
+ remaining -= audits.length;
854
+ const runs = this.db.prepare(`SELECT run_id FROM drain_runs WHERE state NOT IN ('accepted','running') AND updated_at<? ORDER BY updated_at,run_id LIMIT ?`).all(runCutoff, remaining);
855
+ for (const row of runs)
856
+ this.db.prepare(`DELETE FROM drain_runs WHERE run_id=?`).run(row.run_id);
857
+ return { retiredWork: terminal.length, prunedAudit: audits.length, prunedRuns: runs.length };
858
+ }).immediate();
859
+ const checkpoint = this.db.pragma('wal_checkpoint(PASSIVE)');
860
+ return { ...counts, checkpointed: Number(checkpoint[0]?.busy ?? 1) === 0 };
861
+ }
862
+ abandonExpiredRuns(now = this.now(), limit = 10) {
863
+ const rows = this.db.prepare(`SELECT run_id,state FROM drain_runs WHERE state IN ('accepted','running') AND lease_expires_at<=? ORDER BY lease_expires_at LIMIT ?`).all(now, Math.max(0, Math.min(100, Math.trunc(limit))));
864
+ for (const row of rows)
865
+ this.transitionRun(row.run_id, row.state, 'abandoned', 'lease-expired');
866
+ return rows.length;
867
+ }
868
+ metrics(now = this.now()) {
869
+ const readiness = { collecting: 0, ready: 0, queued: 0, held: 0 };
870
+ const work = { queued: 0, claimed: 0, completed: 0, retryable: 0, 'dead-lettered': 0, held: 0 };
871
+ for (const row of this.db.prepare('SELECT state,COUNT(*) n FROM readiness GROUP BY state').all())
872
+ readiness[row.state] = row.n;
873
+ for (const row of this.db.prepare('SELECT state,COUNT(*) n FROM work GROUP BY state').all())
874
+ work[row.state] = row.n;
875
+ const scalar = (sql) => this.db.prepare(sql).get().value;
876
+ const age = (value) => value === null ? null : Math.max(0, now - value);
877
+ return {
878
+ readiness, work,
879
+ oldestReadyAgeMs: age(scalar(`SELECT MIN(entered_at) value FROM readiness WHERE state='ready'`)),
880
+ oldestQueuedAgeMs: age(scalar(`SELECT MIN(created_at) value FROM work WHERE state IN ('queued','retryable')`)),
881
+ oldestClaimedAgeMs: age(scalar(`SELECT MIN(updated_at) value FROM work WHERE state='claimed'`)),
882
+ oldestFeedbackAgeMs: age(scalar(`SELECT MIN(created_at) value FROM source_projection`)),
883
+ oldestHeldAgeMs: age(scalar(`SELECT MIN(at) value FROM (SELECT entered_at at FROM readiness WHERE state='held' UNION ALL SELECT updated_at at FROM work WHERE state='held')`)),
884
+ oldestDeadLetterAgeMs: age(scalar(`SELECT MIN(updated_at) value FROM work WHERE state='dead-lettered'`)),
885
+ lastEnqueuedAt: scalar(`SELECT MAX(created_at) value FROM work`),
886
+ lastClaimedAt: scalar(`SELECT MAX(claimed_at) value FROM work WHERE attempts>0`),
887
+ lastCompletedAt: scalar(`SELECT MAX(completed_at) value FROM work WHERE state='completed'`),
888
+ lastSuccessfulClusteringAt: Number(this.db.prepare(`SELECT value FROM drain_meta WHERE key='last_successful_clustering_at'`).get()?.value ?? 0) || null,
889
+ lastSuccessfulEvaluationAt: scalar(`SELECT MAX(last_evaluated_at) value FROM readiness`),
890
+ lastSourceReconciliationAt: Number(this.db.prepare(`SELECT value FROM drain_meta WHERE key='last_source_reconciliation_at'`).get()?.value ?? 0) || null,
891
+ overdueCollecting: Number(this.db.prepare(`SELECT COUNT(*) n FROM readiness WHERE state='collecting' AND next_review_at IS NOT NULL AND next_review_at<=?`).get(now).n),
892
+ oldestOverdueAgeMs: age(scalar(`SELECT MIN(next_review_at) value FROM readiness WHERE state='collecting' AND next_review_at IS NOT NULL AND next_review_at<=${Math.trunc(now)}`)),
893
+ sourceProjectionLagBytes: Number(this.db.prepare(`SELECT value FROM drain_meta WHERE key='source_lag_bytes'`).get()?.value ?? 0),
894
+ sourceChecksumConflicts: Number(this.db.prepare(`SELECT COUNT(*) n FROM source_conflicts`).get().n),
895
+ oldestSourceConflictAgeMs: age(scalar(`SELECT MIN(created_at) value FROM source_conflicts`)),
896
+ };
897
+ }
898
+ lastRun() {
899
+ const row = this.db.prepare(`SELECT * FROM drain_runs ORDER BY created_at DESC,run_id DESC LIMIT 1`).get();
900
+ if (!row)
901
+ return null;
902
+ return {
903
+ runId: String(row.run_id), state: row.state, ownerHost: String(row.owner_host), ownerEpoch: Number(row.owner_epoch),
904
+ reason: String(row.reason), createdAt: Number(row.created_at), updatedAt: Number(row.updated_at),
905
+ durationMs: Math.max(0, Number(row.updated_at) - Number(row.created_at)),
906
+ };
907
+ }
908
+ mutateAuthority(input) {
909
+ if (!input.operatorDecisionRef.trim())
910
+ throw new Error('operatorDecisionRef is required');
911
+ for (const [name, value, max] of [
912
+ ['authorityId', input.authorityId, 200], ['agentId', input.agentId, 200],
913
+ ['ownerMachineId', input.ownerMachineId, 200], ['provider', input.provider, 80],
914
+ ['modelFamily', input.modelFamily, 120], ['promptVersion', input.promptVersion, 120],
915
+ ['schemaVersion', input.schemaVersion, 120], ['decisionPointId', input.decisionPointId, 160],
916
+ ]) {
917
+ if (!value.trim() || clamp(value, max) !== value.trim())
918
+ throw new Error(`${name} is invalid`);
919
+ }
920
+ if (!Number.isSafeInteger(input.ownerEpoch) || input.ownerEpoch < 1)
921
+ throw new Error('ownerEpoch must be a positive integer');
922
+ if (!Number.isSafeInteger(input.maxBatch) || input.maxBatch < 1 || input.maxBatch > 50)
923
+ throw new Error('maxBatch must be 1..50');
924
+ if (!Number.isSafeInteger(input.maxTokens) || input.maxTokens < 128 || input.maxTokens > 100_000)
925
+ throw new Error('maxTokens must be 128..100000');
926
+ if (!Number.isFinite(input.maxDailySpendUsd) || input.maxDailySpendUsd <= 0 || input.maxDailySpendUsd > 1000)
927
+ throw new Error('maxDailySpendUsd must be >0 and <=1000');
928
+ const record = this.db.transaction(() => {
929
+ const generation = this.authorityGeneration() + 1;
930
+ const previous = this.latestAuthority(input.authorityId);
931
+ if (input.action === 'create' && previous)
932
+ throw new DrainConflictError('authority already exists');
933
+ if (input.action !== 'create' && !previous)
934
+ throw new DrainConflictError('authority does not exist');
935
+ if (input.action === 'revoke' && previous?.revoked)
936
+ throw new DrainConflictError('authority is already revoked');
937
+ if (input.action === 'restore' && !previous?.revoked)
938
+ throw new DrainConflictError('only a revoked authority can be restored');
939
+ if (input.action === 'replace' && previous?.revoked)
940
+ throw new DrainConflictError('restore the authority before replacing it');
941
+ const revoked = input.action === 'revoke';
942
+ const now = this.now();
943
+ this.db.prepare(`INSERT INTO authority_records VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)`).run(input.authorityId, generation, input.agentId, input.ownerMachineId, input.ownerEpoch, input.provider, input.modelFamily, input.promptVersion, input.schemaVersion, input.decisionPointId, input.maxBatch, input.maxTokens, input.maxDailySpendUsd, revoked ? 1 : 0, now);
944
+ this.db.prepare(`INSERT INTO drain_meta(key,value) VALUES ('authority_generation',?) ON CONFLICT(key) DO UPDATE SET value=excluded.value`).run(String(generation));
945
+ const payloadHash = createHash('sha256').update(canonical({ ...input, operatorDecisionRef: undefined, generation, revoked })).digest('hex');
946
+ const prior = this.db.prepare('SELECT checksum FROM authority_audit ORDER BY sequence DESC LIMIT 1').get();
947
+ const previousChecksum = prior?.checksum ?? '0'.repeat(64);
948
+ const checksum = createHash('sha256').update(`${previousChecksum}|${generation}|${input.action}|${input.authorityId}|${input.operatorDecisionRef}|${payloadHash}|${now}`).digest('hex');
949
+ this.db.prepare(`INSERT INTO authority_audit(generation,action,authority_id,operator_decision_ref,payload_hash,previous_checksum,checksum,created_at) VALUES (?,?,?,?,?,?,?,?)`)
950
+ .run(generation, input.action, input.authorityId, input.operatorDecisionRef, payloadHash, previousChecksum, checksum, now);
951
+ return this.getAuthority(input.authorityId, generation);
952
+ }).immediate();
953
+ this.writeAuthorityBackup();
954
+ return record;
955
+ }
956
+ authorityGeneration() {
957
+ const row = this.db.prepare(`SELECT value FROM drain_meta WHERE key='authority_generation'`).get();
958
+ return row ? Number(row.value) : 0;
959
+ }
960
+ authorityPosture(authorityId, generation) {
961
+ const row = this.db.prepare(`SELECT mode,reason,updated_at FROM authority_posture WHERE authority_id=? AND generation=?`).get(authorityId, generation);
962
+ return row ? { mode: row.mode, reason: row.reason, updatedAt: row.updated_at } : { mode: 'active', reason: '', updatedAt: 0 };
963
+ }
964
+ demoteAuthority(authorityId, generation, reason) {
965
+ const authority = this.getAuthority(authorityId, generation);
966
+ if (!authority || authority.revoked || this.latestAuthority(authorityId)?.generation !== generation)
967
+ throw new DrainConflictError('cannot demote inactive authority');
968
+ this.db.prepare(`INSERT INTO authority_posture(authority_id,generation,mode,reason,updated_at) VALUES (?,?,'proposal-only',?,?)
969
+ ON CONFLICT(authority_id,generation) DO UPDATE SET mode='proposal-only',reason=excluded.reason,updated_at=excluded.updated_at`)
970
+ .run(authorityId, generation, clamp(reason, 200), this.now());
971
+ this.audit('authority', `${authorityId}:${generation}`, 'active', 'proposal-only', reason);
972
+ }
973
+ getAuthority(authorityId, generation) {
974
+ const row = generation === undefined
975
+ ? this.db.prepare('SELECT * FROM authority_records WHERE authority_id=? ORDER BY generation DESC LIMIT 1').get(authorityId)
976
+ : this.db.prepare('SELECT * FROM authority_records WHERE authority_id=? AND generation=?').get(authorityId, generation);
977
+ return row ? this.authorityFromRow(row) : null;
978
+ }
979
+ verifyAuthorityAudit() {
980
+ const rows = this.db.prepare('SELECT * FROM authority_audit ORDER BY sequence').all();
981
+ let previous = '0'.repeat(64);
982
+ for (const row of rows) {
983
+ if (row.previous_checksum !== previous)
984
+ return false;
985
+ const expected = createHash('sha256').update(`${previous}|${row.generation}|${row.action}|${row.authority_id}|${row.operator_decision_ref}|${row.payload_hash}|${row.created_at}`).digest('hex');
986
+ if (expected !== row.checksum)
987
+ return false;
988
+ previous = String(row.checksum);
989
+ }
990
+ return true;
991
+ }
992
+ /** Fail-closed reservation against the authority's independent UTC-day cap. */
993
+ reserveAuthoritySpend(authority, estimatedUsd, decisions, now = this.now()) {
994
+ if (!Number.isFinite(estimatedUsd) || estimatedUsd < 0 || !Number.isSafeInteger(decisions) || decisions < 1)
995
+ return false;
996
+ const day = new Date(now).toISOString().slice(0, 10);
997
+ return this.db.transaction(() => {
998
+ const current = this.getAuthority(authority.authorityId, authority.generation);
999
+ if (!current || current.revoked || current.generation !== this.latestAuthority(authority.authorityId)?.generation)
1000
+ return false;
1001
+ const row = this.db.prepare(`SELECT committed_usd FROM authority_daily_usage WHERE authority_id=? AND generation=? AND utc_day=?`)
1002
+ .get(authority.authorityId, authority.generation, day);
1003
+ const committed = Number(row?.committed_usd ?? 0);
1004
+ if (committed + estimatedUsd > authority.maxDailySpendUsd)
1005
+ return false;
1006
+ this.db.prepare(`INSERT INTO authority_daily_usage(authority_id,generation,utc_day,committed_usd,decisions,updated_at)
1007
+ VALUES (?,?,?,?,?,?) ON CONFLICT(authority_id,generation,utc_day) DO UPDATE SET
1008
+ committed_usd=authority_daily_usage.committed_usd+excluded.committed_usd,
1009
+ decisions=authority_daily_usage.decisions+excluded.decisions,updated_at=excluded.updated_at`)
1010
+ .run(authority.authorityId, authority.generation, day, estimatedUsd, decisions, now);
1011
+ return true;
1012
+ }).immediate();
1013
+ }
1014
+ latestAuthority(authorityId) {
1015
+ const row = this.db.prepare('SELECT * FROM authority_records WHERE authority_id=? ORDER BY generation DESC LIMIT 1').get(authorityId);
1016
+ return row ? this.authorityFromRow(row) : null;
1017
+ }
1018
+ writeAuthorityBackup() {
1019
+ if (!this.authorityBackupPath)
1020
+ return;
1021
+ const payload = {
1022
+ schemaVersion: 1,
1023
+ generation: this.authorityGeneration(),
1024
+ records: this.db.prepare('SELECT * FROM authority_records ORDER BY generation,authority_id').all(),
1025
+ audit: this.db.prepare('SELECT * FROM authority_audit ORDER BY sequence').all(),
1026
+ };
1027
+ const envelope = { ...payload, checksum: createHash('sha256').update(JSON.stringify(payload)).digest('hex') };
1028
+ fs.mkdirSync(path.dirname(this.authorityBackupPath), { recursive: true });
1029
+ const tmp = `${this.authorityBackupPath}.${process.pid}.tmp`;
1030
+ fs.writeFileSync(tmp, `${JSON.stringify(envelope, null, 2)}\n`, { mode: 0o600 });
1031
+ fs.renameSync(tmp, this.authorityBackupPath);
1032
+ }
1033
+ restoreAuthorityBackupIfEmpty() {
1034
+ if (!this.authorityBackupPath || !fs.existsSync(this.authorityBackupPath) || this.authorityGeneration() !== 0)
1035
+ return;
1036
+ let envelope;
1037
+ try {
1038
+ envelope = JSON.parse(fs.readFileSync(this.authorityBackupPath, 'utf8'));
1039
+ }
1040
+ catch {
1041
+ throw new Error('readiness authority backup is invalid JSON');
1042
+ }
1043
+ const payload = { schemaVersion: envelope.schemaVersion, generation: envelope.generation, records: envelope.records, audit: envelope.audit };
1044
+ const expected = createHash('sha256').update(JSON.stringify(payload)).digest('hex');
1045
+ if (envelope.schemaVersion !== 1 || envelope.checksum !== expected || !Array.isArray(envelope.records) || !Array.isArray(envelope.audit)) {
1046
+ throw new Error('readiness authority backup checksum or schema is invalid');
1047
+ }
1048
+ this.db.transaction(() => {
1049
+ const recordInsert = this.db.prepare(`INSERT INTO authority_records(authority_id,generation,agent_id,owner_machine_id,owner_epoch,provider,model_family,prompt_version,schema_version,decision_point_id,max_batch,max_tokens,max_daily_spend_usd,revoked,created_at) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)`);
1050
+ for (const raw of envelope.records)
1051
+ recordInsert.run(raw.authority_id, raw.generation, raw.agent_id, raw.owner_machine_id, raw.owner_epoch, raw.provider, raw.model_family, raw.prompt_version, raw.schema_version, raw.decision_point_id, raw.max_batch, raw.max_tokens, raw.max_daily_spend_usd, raw.revoked, raw.created_at);
1052
+ const auditInsert = this.db.prepare(`INSERT INTO authority_audit(sequence,generation,action,authority_id,operator_decision_ref,payload_hash,previous_checksum,checksum,created_at) VALUES (?,?,?,?,?,?,?,?,?)`);
1053
+ for (const raw of envelope.audit)
1054
+ auditInsert.run(raw.sequence, raw.generation, raw.action, raw.authority_id, raw.operator_decision_ref, raw.payload_hash, raw.previous_checksum, raw.checksum, raw.created_at);
1055
+ this.db.prepare(`INSERT INTO drain_meta(key,value) VALUES ('authority_generation',?)`).run(String(envelope.generation));
1056
+ if (!this.verifyAuthorityAudit())
1057
+ throw new Error('readiness authority backup audit chain is invalid');
1058
+ }).immediate();
1059
+ }
1060
+ holdReadinessInternal(clusterId, reason) {
1061
+ const current = this.getReadiness(clusterId);
1062
+ if (!current || !['collecting', 'ready', 'queued'].includes(current.state))
1063
+ throw new DrainConflictError('readiness cannot be held');
1064
+ const now = this.now();
1065
+ this.db.prepare(`UPDATE readiness SET state='held',entered_at=?,reason_code=? WHERE cluster_id=?`).run(now, clamp(reason, 200), clusterId);
1066
+ if (current.state === 'queued') {
1067
+ const work = this.db.prepare(`SELECT work_id FROM work WHERE cluster_id=? AND readiness_epoch=?`).get(clusterId, current.epoch);
1068
+ if (work)
1069
+ this.holdWork(work.work_id, reason);
1070
+ }
1071
+ this.audit('readiness', clusterId, current.state, 'held', reason);
1072
+ return this.getReadiness(clusterId);
1073
+ }
1074
+ assertCurrentClaim(input) {
1075
+ const currentOwnerEpoch = this.ownerAuthorityEpoch();
1076
+ const row = this.db.prepare(`SELECT state,lease_epoch,token_hash,owner_authority_epoch FROM work WHERE work_id=?`).get(input.workId);
1077
+ const candidate = this.hashToken(input.claimToken);
1078
+ const persisted = row?.token_hash ?? '0'.repeat(64);
1079
+ const sameToken = timingSafeEqual(Buffer.from(candidate, 'hex'), Buffer.from(persisted, 'hex'));
1080
+ if (currentOwnerEpoch !== input.ownerAuthorityEpoch || !row || row.state !== 'claimed' || row.lease_epoch !== input.leaseEpoch || row.owner_authority_epoch !== input.ownerAuthorityEpoch || !sameToken) {
1081
+ throw new DrainConflictError('claim is stale or invalid');
1082
+ }
1083
+ }
1084
+ assertOrInitializeOwnerAuthorityEpoch(epoch) {
1085
+ if (!Number.isSafeInteger(epoch) || epoch < 1)
1086
+ throw new DrainConflictError('owner authority epoch is invalid');
1087
+ const current = this.ownerAuthorityEpoch();
1088
+ if (current === null)
1089
+ this.setMeta('owner_authority_epoch', String(epoch));
1090
+ else if (current !== epoch)
1091
+ throw new DrainConflictError('owner authority epoch is stale');
1092
+ }
1093
+ hashToken(token) { return createHmac('sha256', this.hmacKey).update(token).digest('hex'); }
1094
+ inject(point) { this.crashInjector?.(point); }
1095
+ ensureSchemaUpgrades() {
1096
+ const columns = new Set(this.db.pragma('table_info(readiness_approvals)').map((row) => row.name));
1097
+ if (!columns.has('decision_nonce'))
1098
+ this.db.exec(`ALTER TABLE readiness_approvals ADD COLUMN decision_nonce TEXT NOT NULL DEFAULT ''`);
1099
+ if (!columns.has('proposal_set_hash'))
1100
+ this.db.exec(`ALTER TABLE readiness_approvals ADD COLUMN proposal_set_hash TEXT NOT NULL DEFAULT ''`);
1101
+ const projectionColumns = new Set(this.db.pragma('table_info(source_projection)').map((row) => row.name));
1102
+ if (!projectionColumns.has('record_json'))
1103
+ this.db.exec(`ALTER TABLE source_projection ADD COLUMN record_json TEXT NOT NULL DEFAULT '{}'`);
1104
+ const runColumns = new Set(this.db.pragma('table_info(drain_runs)').map((row) => row.name));
1105
+ if (!runColumns.has('cancellation_requested_at'))
1106
+ this.db.exec(`ALTER TABLE drain_runs ADD COLUMN cancellation_requested_at INTEGER`);
1107
+ }
1108
+ setMeta(key, value) {
1109
+ this.db.prepare(`INSERT INTO drain_meta(key,value) VALUES (?,?) ON CONFLICT(key) DO UPDATE SET value=excluded.value`).run(key, value);
1110
+ }
1111
+ audit(kind, entityId, from, to, reason) {
1112
+ this.db.prepare('INSERT INTO drain_audit(kind,entity_id,from_state,to_state,reason,created_at) VALUES (?,?,?,?,?,?)')
1113
+ .run(kind, entityId, from, to, clamp(reason, 500), this.now());
1114
+ }
1115
+ readinessFromRow(row) {
1116
+ return { clusterId: String(row.cluster_id), state: row.state, epoch: Number(row.epoch), enteredAt: Number(row.entered_at), lastEvaluatedAt: row.last_evaluated_at === null ? null : Number(row.last_evaluated_at), nextReviewAt: row.next_review_at === null ? null : Number(row.next_review_at), reasonCode: String(row.reason_code) };
1117
+ }
1118
+ workFromRow(row) {
1119
+ return { workId: String(row.work_id), idempotencyKey: String(row.idempotency_key), clusterId: String(row.cluster_id), readinessEpoch: Number(row.readiness_epoch), state: row.state, title: String(row.title), summary: String(row.summary), priority: String(row.priority), reportCount: Number(row.report_count), firstSeenAt: Number(row.first_seen_at), lastSeenAt: Number(row.last_seen_at), authorityRef: String(row.authority_ref), evidenceRef: String(row.evidence_ref), leaseEpoch: Number(row.lease_epoch), leaseExpiresAt: row.lease_expires_at === null ? null : Number(row.lease_expires_at), consumerId: row.consumer_id === null ? null : String(row.consumer_id), attempts: Number(row.attempts), nextAttemptAt: row.next_attempt_at === null ? null : Number(row.next_attempt_at), createdAt: Number(row.created_at), updatedAt: Number(row.updated_at) };
1120
+ }
1121
+ authorityFromRow(row) {
1122
+ return { authorityId: String(row.authority_id), agentId: String(row.agent_id), ownerMachineId: String(row.owner_machine_id), ownerEpoch: Number(row.owner_epoch), provider: String(row.provider), modelFamily: String(row.model_family), promptVersion: String(row.prompt_version), schemaVersion: String(row.schema_version), decisionPointId: String(row.decision_point_id), maxBatch: Number(row.max_batch), maxTokens: Number(row.max_tokens), maxDailySpendUsd: Number(row.max_daily_spend_usd), generation: Number(row.generation), revoked: Boolean(row.revoked) };
1123
+ }
1124
+ }
1125
+ //# sourceMappingURL=FeedbackDrainStore.js.map