forge-workflow 0.1.0-beta.4 → 0.1.0-beta.6

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 (196) hide show
  1. package/AGENTS.md +18 -7
  2. package/CHANGELOG.md +79 -1
  3. package/CLAUDE.md +0 -12
  4. package/CODING_STANDARDS.md +72 -0
  5. package/README.md +6 -2
  6. package/bin/forge-cmd.js +20 -0
  7. package/bin/forge.js +28 -375
  8. package/docs/INDEX.md +1 -1
  9. package/docs/guides/BEADS_GITHUB_SYNC.md +2 -31
  10. package/docs/guides/MIGRATION.md +4 -4
  11. package/docs/guides/SETUP.md +16 -16
  12. package/docs/reference/COMMANDS.md +8 -5
  13. package/docs/reference/FORGE_KERNEL_STORAGE_MODEL.md +4 -0
  14. package/docs/reference/INSIGHTS_RECAP.md +9 -20
  15. package/docs/reference/INSTALL.md +4 -0
  16. package/docs/reference/LEGACY_CLAIM_REPAIR.md +112 -0
  17. package/docs/reference/RELEASE.md +5 -3
  18. package/docs/reference/TOOLCHAIN.md +8 -0
  19. package/docs/reference/github-accounts.md +134 -0
  20. package/docs/reference/protected-state-surfaces.md +4 -4
  21. package/docs/reference/shepherd.md +114 -35
  22. package/lefthook.yml +12 -0
  23. package/lib/activation/ensure-forge-home.js +33 -15
  24. package/lib/adapters/pr-state-adapter.js +359 -144
  25. package/lib/audit-evidence.js +71 -110
  26. package/lib/base-remote.js +138 -0
  27. package/lib/beta5-compatibility-evidence.js +1093 -0
  28. package/lib/bun-lockfile-proof.js +413 -0
  29. package/lib/bun-workflow-pins.js +461 -0
  30. package/lib/capabilities/index.js +9 -0
  31. package/lib/capabilities/model.js +141 -0
  32. package/lib/capabilities/probes.js +347 -0
  33. package/lib/capped-jsonl-log.js +236 -0
  34. package/lib/codex-skills.js +2 -2
  35. package/lib/commands/_manifest.js +1 -0
  36. package/lib/commands/_registry.js +50 -20
  37. package/lib/commands/clean.js +252 -32
  38. package/lib/commands/dev.js +4 -33
  39. package/lib/commands/doctor.js +37 -6
  40. package/lib/commands/gate.js +197 -27
  41. package/lib/commands/github.js +215 -0
  42. package/lib/commands/hooks.js +276 -30
  43. package/lib/commands/insights.js +8 -3
  44. package/lib/commands/memory.js +66 -2
  45. package/lib/commands/merge.js +1265 -58
  46. package/lib/commands/plan.js +33 -2
  47. package/lib/commands/pr.js +3 -1
  48. package/lib/commands/preflight.js +21 -4
  49. package/lib/commands/prime.js +21 -8
  50. package/lib/commands/push.js +146 -54
  51. package/lib/commands/recall.js +127 -49
  52. package/lib/commands/recap.js +6 -1
  53. package/lib/commands/release.js +39 -3
  54. package/lib/commands/remember.js +28 -4
  55. package/lib/commands/serve.js +26 -9
  56. package/lib/commands/setup.js +323 -98
  57. package/lib/commands/shepherd.js +591 -73
  58. package/lib/commands/ship.js +36 -91
  59. package/lib/commands/skill.js +127 -11
  60. package/lib/commands/status.js +17 -1
  61. package/lib/commands/team.js +47 -8
  62. package/lib/commands/test.js +187 -38
  63. package/lib/commands/validate.js +65 -21
  64. package/lib/commands/worktree.js +359 -45
  65. package/lib/core/runtime-graph.js +1 -1
  66. package/lib/doc-assertions.js +297 -0
  67. package/lib/existing-tdd-gate.js +253 -0
  68. package/lib/fixtures/beta5-corpus/v1/README.md +9 -0
  69. package/lib/fixtures/beta5-corpus/v1/contract/command-contract.json +26 -0
  70. package/lib/fixtures/beta5-corpus/v1/contract/package-contract.json +13 -0
  71. package/lib/fixtures/beta5-corpus/v1/contract/workflow-stage-matrix.json +8 -0
  72. package/lib/fixtures/beta5-corpus/v1/manifest.json +25 -0
  73. package/lib/fixtures/beta5-corpus/v1/state/comments.jsonl +1 -0
  74. package/lib/fixtures/beta5-corpus/v1/state/config.yaml +6 -0
  75. package/lib/fixtures/beta5-corpus/v1/state/dependencies.jsonl +1 -0
  76. package/lib/fixtures/beta5-corpus/v1/state/issues.jsonl +2 -0
  77. package/lib/fixtures/beta5-corpus/v1/state/kernel.sql +20 -0
  78. package/lib/forge-context.js +1 -4
  79. package/lib/forge-issues.js +134 -32
  80. package/lib/gate-events.js +98 -10
  81. package/lib/git-defaults.js +56 -0
  82. package/lib/github-context.js +308 -0
  83. package/lib/global-flags.js +1 -0
  84. package/lib/harness-capability-matrix.js +3 -3
  85. package/lib/hook-renderer.js +122 -5
  86. package/lib/insights.js +96 -80
  87. package/lib/issue-render.js +19 -0
  88. package/lib/kernel/backing-issue.js +14 -2
  89. package/lib/kernel/broker.js +739 -31
  90. package/lib/kernel/claim-reconciler.js +238 -0
  91. package/lib/kernel/cli-broker-factory.js +12 -1
  92. package/lib/kernel/close-on-merge.js +154 -0
  93. package/lib/kernel/fs-class.js +42 -25
  94. package/lib/kernel/lease-enforcer.js +9 -4
  95. package/lib/kernel/legacy-claim-repair.js +442 -0
  96. package/lib/kernel/live-claim-projection.js +26 -0
  97. package/lib/kernel/migrations.js +118 -3
  98. package/lib/kernel/readiness-model.js +184 -12
  99. package/lib/kernel/schema.js +49 -1
  100. package/lib/kernel/sqlite-driver.js +3435 -172
  101. package/lib/kernel/taxonomy-validator.js +4 -1
  102. package/lib/kernel/windows-private-acl.js +239 -0
  103. package/lib/lefthook-wiring.js +21 -1
  104. package/lib/memory/hygiene.js +191 -0
  105. package/lib/memory/router.js +110 -28
  106. package/lib/memory/usage-evidence.js +4 -0
  107. package/lib/memory-digest.js +106 -15
  108. package/lib/memory-recall-events.js +145 -0
  109. package/lib/memory-recall.js +71 -10
  110. package/lib/merge-rules.js +143 -21
  111. package/lib/npm-publish-workflow.js +465 -0
  112. package/lib/orientation.js +68 -43
  113. package/lib/package-root.js +2 -0
  114. package/lib/plugin-catalog.js +14 -4
  115. package/lib/pr-bundle.js +5 -6
  116. package/lib/pr-monitor/auto-actions.js +169 -28
  117. package/lib/pr-monitor/differ.js +110 -4
  118. package/lib/pr-monitor/events.js +0 -0
  119. package/lib/pr-monitor/flow-monitor.js +1424 -0
  120. package/lib/pr-monitor/gather.js +251 -44
  121. package/lib/pr-monitor/journal.js +18 -39
  122. package/lib/pr-monitor/monitor.js +117 -10
  123. package/lib/pr-monitor/process-identity.js +117 -0
  124. package/lib/pr-monitor/reconcile-executor.js +1129 -470
  125. package/lib/pr-monitor/reconcile.js +0 -0
  126. package/lib/pr-monitor/render-summary.js +293 -0
  127. package/lib/pr-monitor/review-preflight.js +269 -0
  128. package/lib/pr-monitor/shepherd-lease.js +38 -20
  129. package/lib/pr-monitor/verdict.js +438 -0
  130. package/lib/pr-monitor/watch-lifecycle.js +145 -27
  131. package/lib/pr-monitor/watch-owner.js +1414 -0
  132. package/lib/pr-monitor/watch.js +129 -58
  133. package/lib/pr-pull.js +33 -14
  134. package/lib/pr-shepherd.js +51 -11
  135. package/lib/preflight/gates.js +65 -18
  136. package/lib/preflight/runner.js +5 -0
  137. package/lib/project-memory.js +178 -4
  138. package/lib/protected-state-authority.js +1100 -0
  139. package/lib/protected-state-surfaces.js +243 -45
  140. package/lib/release-readiness.js +53 -7
  141. package/lib/review-adapter.js +65 -0
  142. package/lib/shell-utils.js +1 -1
  143. package/lib/skills-sync.js +71 -35
  144. package/lib/smart-merge.js +28 -4
  145. package/lib/symlink-utils.js +74 -26
  146. package/lib/upgrade-safety.js +39 -0
  147. package/lib/using-forge.js +19 -6
  148. package/lib/validation/risk-manifest.js +339 -0
  149. package/lib/workflow/enforce-stage.js +44 -0
  150. package/lib/workflow/plan-authority.js +225 -0
  151. package/package.json +12 -9
  152. package/scripts/commitlint.js +13 -15
  153. package/scripts/doc-asserting-tests.js +158 -0
  154. package/scripts/generate-risk-manifest.js +91 -0
  155. package/scripts/github-context-bridge.sh +10 -0
  156. package/scripts/legacy-claim-repair.js +145 -0
  157. package/scripts/lib/behavioral-eval-runner.js +310 -0
  158. package/scripts/lib/behavioral-eval-runtime.js +457 -0
  159. package/scripts/lib/eval-evidence.js +328 -0
  160. package/scripts/lib/eval-runner.js +81 -41
  161. package/scripts/lib/immutable-eval-corpus.js +309 -0
  162. package/scripts/lib/promotion-evidence-loader.js +94 -0
  163. package/scripts/lib/promotion-scorecard.js +314 -0
  164. package/scripts/npm-release-receipt.js +134 -0
  165. package/scripts/process-tree.js +773 -0
  166. package/scripts/protected-state-check.js +479 -31
  167. package/scripts/run-command-eval.js +29 -1
  168. package/scripts/sync-agent-skills.js +333 -34
  169. package/scripts/sync-d20-audit.js +172 -0
  170. package/scripts/test-full-suite.js +935 -37
  171. package/scripts/test-profile.js +13 -3
  172. package/scripts/test.js +271 -57
  173. package/skills/coverage.json +1 -0
  174. package/skills/review/SKILL.md +6 -11
  175. package/skills/review/evals/scorecard.json +4 -4
  176. package/skills/rollback/SKILL.md +4 -11
  177. package/skills/rollback/evals/scorecard.json +3 -3
  178. package/skills/setup/SKILL.md +18 -0
  179. package/skills/setup/evals/scorecard.json +3 -3
  180. package/skills/shepherd/SKILL.md +39 -16
  181. package/skills/shepherd/evals/scorecard.json +4 -4
  182. package/skills/ship/SKILL.md +4 -12
  183. package/skills/ship/evals/scorecard.json +3 -3
  184. package/skills/validate/SKILL.md +3 -0
  185. package/skills/validate/evals/scorecard.json +1 -1
  186. package/skills/worktree/SKILL.md +6 -1
  187. package/skills/worktree/evals/scorecard.json +2 -2
  188. package/lib/beads-setup.js +0 -538
  189. package/lib/beads-sync-scaffold.js +0 -189
  190. package/lib/pat-setup.js +0 -207
  191. package/lib/pr-monitor/render-sticky.js +0 -206
  192. package/lib/pr-monitor/upsert-sticky.js +0 -169
  193. package/scripts/beads-context.sh +0 -577
  194. package/scripts/beads-migrate-to-dolt.sh +0 -7
  195. package/scripts/beads-upgrade-smoke.sh +0 -284
  196. package/scripts/lib/beads-migrate-to-dolt.mjs +0 -503
@@ -3,7 +3,10 @@
3
3
  const fs = require('node:fs');
4
4
  const os = require('node:os');
5
5
  const path = require('node:path');
6
- const { randomUUID } = require('node:crypto');
6
+ const { spawn: nodeSpawn } = require('node:child_process');
7
+ const { pathToFileURL } = require('node:url');
8
+ const { createHash, randomUUID } = require('node:crypto');
9
+ const { types: { isProxy } } = require('node:util');
7
10
 
8
11
  const {
9
12
  ISSUE_COMMAND_SCHEMA_VERSION,
@@ -13,10 +16,21 @@ const {
13
16
  resolveNextCommands,
14
17
  } = require('./issue-command-contract');
15
18
  const { buildReadinessIndex } = require('./readiness-model');
16
- const { buildMemoryProjectionMigration, memoryFtsDdl } = require('./migrations');
17
- const { rankForPriorityLabel } = require('./taxonomy-validator');
19
+ const { buildMemoryProjectionMigration, buildUsageEvidenceMigration, memoryFtsDdl } = require('./migrations');
20
+ const { assertFilesystemSafeForKernel } = require('./fs-class');
21
+ const { isTerminalStatus, rankForPriorityLabel } = require('./taxonomy-validator');
18
22
  const { isLeaseExpired } = require('./lease-enforcer');
23
+ const { isLiveClaim, projectLiveClaims } = require('./live-claim-projection');
24
+ const {
25
+ ClaimRepairError,
26
+ buildClaimRepairPlan,
27
+ publicClaimRepairPreflight,
28
+ verifyClaimRepairBackup,
29
+ } = require('./legacy-claim-repair');
19
30
  const { CONFLICT_SIGNAL, classifyConflictSignal } = require('./conflict-signal');
31
+ const { normalizeRecallHit } = require('../memory-recall');
32
+ const { getPackageRoot } = require('../package-root');
33
+ const { appendUsageEvidence, rebuildUsageProjection } = require('../../packages/memory');
20
34
 
21
35
  const BUILTIN_SQLITE_RUNTIME_ORDER = Object.freeze(['bun:sqlite', 'node:sqlite']);
22
36
  let probeCounter = 0;
@@ -132,6 +146,18 @@ function createDatabase(runtime, databasePath) {
132
146
  throw new Error(`Unsupported builtin SQLite runtime: ${runtime.id}`);
133
147
  }
134
148
 
149
+ function createExistingWatchOwnerDatabase(runtime, databasePath) {
150
+ if (runtime.id === 'bun:sqlite') {
151
+ return new runtime.module.Database(databasePath, { readwrite: true });
152
+ }
153
+ if (runtime.id === 'node:sqlite') {
154
+ const databaseUrl = pathToFileURL(databasePath);
155
+ databaseUrl.searchParams.set('mode', 'rw');
156
+ return new runtime.module.DatabaseSync(databaseUrl);
157
+ }
158
+ throw new Error(`Unsupported builtin SQLite runtime: ${runtime.id}`);
159
+ }
160
+
135
161
  function execSql(_runtime, db, sql) {
136
162
  db.exec(sql);
137
163
  }
@@ -170,6 +196,35 @@ function runParams(runtime, db, sql, params = []) {
170
196
  return db.prepare(sql).run(...params);
171
197
  }
172
198
 
199
+ function createUsageEvidenceAdapter(runtime, db) {
200
+ return {
201
+ exec(sql) { execSql(runtime, db, sql); },
202
+ run(sql, params = []) { return runParams(runtime, db, sql, params); },
203
+ one(sql, params = []) { return allParams(runtime, db, sql, params)[0] || null; },
204
+ transaction(callback) {
205
+ let active = false;
206
+ try {
207
+ execSql(runtime, db, 'BEGIN IMMEDIATE;');
208
+ active = true;
209
+ const result = callback();
210
+ execSql(runtime, db, 'COMMIT;');
211
+ active = false;
212
+ return result;
213
+ } catch (error) {
214
+ if (active) {
215
+ try { execSql(runtime, db, 'ROLLBACK;'); } catch { /* preserve the write failure */ }
216
+ }
217
+ throw error;
218
+ }
219
+ },
220
+ assertUsageWriterEnabled() {
221
+ const row = allParams(runtime, db,
222
+ 'SELECT enabled FROM memory_usage_writer_state WHERE singleton = 1')[0];
223
+ if (!row || Number(row.enabled) !== 1) throw new Error('memory usage evidence writer is disabled');
224
+ },
225
+ };
226
+ }
227
+
173
228
  // A table may not exist on a partially-migrated DB; readiness inputs degrade to empty.
174
229
  // ONLY a missing-table error is tolerated — a locked/corrupt DB or a real SQL
175
230
  // regression must surface, not silently produce wrong readiness/stats/projection.
@@ -205,7 +260,7 @@ function parseLabels(raw) {
205
260
  }
206
261
 
207
262
  function rowToIssueSummary(row, readinessEntry, claimedBy = null, dependencyIds = [], dependentIds = []) {
208
- return {
263
+ const summary = {
209
264
  id: row.id,
210
265
  title: row.title,
211
266
  body: row.body ?? null,
@@ -249,6 +304,11 @@ function rowToIssueSummary(row, readinessEntry, claimedBy = null, dependencyIds
249
304
  close_reason: row.close_reason ?? null,
250
305
  metadata: row.metadata ?? null,
251
306
  };
307
+ if (readinessEntry?.contract_applicable) {
308
+ summary.readiness_state = readinessEntry.state;
309
+ summary.readiness_reasons = readinessEntry.reasons;
310
+ }
311
+ return summary;
252
312
  }
253
313
 
254
314
  function okIssueResponse(command, data, nextCommands) {
@@ -264,30 +324,48 @@ function okIssueResponse(command, data, nextCommands) {
264
324
  };
265
325
  }
266
326
 
327
+ function loadLiveKernelClaimRows(runtime, db, context = {}) {
328
+ const rows = safeAll(
329
+ runtime,
330
+ db,
331
+ `SELECT claim.*, issue.status AS issue_status
332
+ FROM kernel_claims AS claim
333
+ JOIN kernel_issues AS issue ON issue.id = claim.issue_id
334
+ WHERE claim.state = 'active'`,
335
+ );
336
+ const issues = rows.map(row => ({ id: row.issue_id, status: row.issue_status }));
337
+ return projectLiveClaims(rows, issues, context.now || new Date().toISOString())
338
+ .map(row => {
339
+ const claim = { ...row };
340
+ delete claim.issue_status;
341
+ return claim;
342
+ });
343
+ }
344
+
267
345
  // Derive the whole-board readiness read model (D18) from the authority tables.
268
346
  function loadBoardReadiness(runtime, db, context = {}) {
269
347
  const issues = allParams(runtime, db, 'SELECT * FROM kernel_issues');
270
348
  const dependencies = safeAll(runtime, db, 'SELECT * FROM kernel_dependencies');
271
349
  const conflicts = safeAll(runtime, db, 'SELECT * FROM kernel_conflicts');
272
- const claims = safeAll(runtime, db, 'SELECT * FROM kernel_claims');
350
+ const now = context.now || new Date().toISOString();
351
+ const liveClaims = loadLiveKernelClaimRows(runtime, db, { ...context, now });
273
352
  const index = buildReadinessIndex({
274
353
  issues,
275
354
  dependencies,
276
355
  conflicts,
277
- claims,
278
- now: context.now,
356
+ claims: liveClaims,
357
+ now,
279
358
  actor: context.actor,
359
+ contractPolicy: context.contractPolicy,
360
+ isTrustedAdoption: context.isTrustedAdoption,
280
361
  });
281
- // Surface the active lease holder per issue for issue summaries. Filter on state
282
- // only (matches loadActiveKernelClaimRow); the partial-UNIQUE active-lease index
283
- // guarantees at most one active row per issue, so the map is unambiguous.
362
+ // Surface only the shared live-authority projection as claimed_by. The partial
363
+ // UNIQUE active-lease index guarantees at most one live row per issue.
284
364
  // Null-prototype map: issue ids are unconstrained external strings, so a literal `{}`
285
365
  // keyed by them would be a prototype-pollution vector (matches buildReadinessIndex).
286
366
  const claimedById = Object.create(null);
287
- for (const claim of claims) {
288
- if ((claim.state || 'active') === 'active' && claim.issue_id) {
289
- claimedById[claim.issue_id] = claim.actor ?? null;
290
- }
367
+ for (const claim of liveClaims) {
368
+ if (claim.issue_id) claimedById[claim.issue_id] = claim.actor ?? null;
291
369
  }
292
370
  // Per-issue declared dependency edges (the ids each issue depends on, i.e.
293
371
  // blocks_issue_id where issue_id === the dependent). Distinct from readiness'
@@ -317,7 +395,7 @@ function loadBoardReadiness(runtime, db, context = {}) {
317
395
  dependentsById[issueId] = [...new Set(dependentsById[issueId])]
318
396
  .sort((a, b) => String(a).localeCompare(String(b)));
319
397
  }
320
- return { issues, index, claimedById, dependenciesById, dependentsById };
398
+ return { issues, index, liveClaims, claimedById, dependenciesById, dependentsById };
321
399
  }
322
400
 
323
401
  function firstPositional(args = []) {
@@ -434,6 +512,57 @@ function buildRollup(children) {
434
512
  };
435
513
  }
436
514
 
515
+ function runIssueOwnsRead(runtime, db, args, context) {
516
+ // Lease-ownership verification (kernel d71a824b). A claim returning ok:true does
517
+ // not prove the caller won the lease: a duplicate replay also returns ok:true, so
518
+ // a worker must CONFIRM it holds the live lease before mutating a claimed issue.
519
+ // `owned` is true iff the resolving actor holds the shared live-authority claim.
520
+ // loadActiveKernelClaimRow remains state-only for acquisition/reclaim planning;
521
+ // isLiveClaim applies expiry and issue-terminal fencing here. Actor resolution mirrors the mutation
522
+ // path's `context.actor || 'forge'` default so a bare CLI invocation matches its
523
+ // own claims; `now` falls back to the wall clock like the mutation route.
524
+ const id = firstPositional(args);
525
+ const rows = allParams(runtime, db, 'SELECT * FROM kernel_issues WHERE id = ?', [id]);
526
+ if (!rows[0]) {
527
+ return formatIssueCommandError({
528
+ command: 'issue.owns',
529
+ code: 'FORGE_ISSUE_NOT_FOUND',
530
+ message: `Issue ${id ?? '<missing id>'} not found`,
531
+ exitCode: ISSUE_COMMAND_EXIT_CODES.notFound,
532
+ });
533
+ }
534
+ const now = context.now || new Date().toISOString();
535
+ const actor = context.actor || 'forge';
536
+ const claim = loadActiveKernelClaimRow(runtime, db, id);
537
+ const liveClaim = isLiveClaim(claim, rows[0], now) ? claim : null;
538
+ const claimedBy = liveClaim ? (liveClaim.actor ?? null) : null;
539
+ const expired = claim ? isLeaseExpired(claim, now) : false;
540
+ // Ownership is per-SESSION, not just per-actor (kernel d71a824b): two agents
541
+ // sharing one human actor but running as DIFFERENT sessions must not both read
542
+ // OWNED for a lease only one of them holds. When BOTH the caller and the live
543
+ // lease carry a session-id they must match; if either side is session-less (a
544
+ // no-env caller, or a pre-session claim) we fall back to actor-only ownership so
545
+ // historical behavior is preserved byte-for-byte. An empty/whitespace-only
546
+ // session-id counts as session-LESS on BOTH sides — the SAME trim-truthy test the
547
+ // claim-key write uses (buildClaimMutationEvent in lib/kernel/broker.js) — so ''
548
+ // can never count as "present" here and "absent" there.
549
+ const normalizeSession = value => (typeof value === 'string' && value.trim() !== '' ? value : null);
550
+ const contextSession = normalizeSession(context.sessionId);
551
+ const claimSession = claim ? normalizeSession(claim.session_id) : null;
552
+ const sessionMismatch = contextSession !== null
553
+ && claimSession !== null
554
+ && contextSession !== claimSession;
555
+ const owned = Boolean(liveClaim) && claimedBy === actor && !sessionMismatch;
556
+ return okIssueResponse('issue.owns', {
557
+ id,
558
+ actor,
559
+ claimed_by: claimedBy,
560
+ owned,
561
+ expired,
562
+ expires_at: claim ? (claim.expires_at ?? null) : null,
563
+ });
564
+ }
565
+
437
566
  // Read-side of driver.issueOperation: ready/list/show/search/stats as parameterized
438
567
  // SELECTs returning issue-command-contract shapes. Mutations are handled separately
439
568
  // through the broker's guarded-event path (later wave).
@@ -505,55 +634,7 @@ function runIssueReadOperation(runtime, db, operation, args, context) {
505
634
  });
506
635
  }
507
636
  if (operation === 'owns') {
508
- // Lease-ownership verification (kernel d71a824b). A claim returning ok:true does
509
- // not prove the caller won the lease: a duplicate replay also returns ok:true, so
510
- // a worker must CONFIRM it holds the live lease before mutating a claimed issue.
511
- // `owned` is true iff the resolving actor holds the SINGLE active claim AND that
512
- // lease has not expired. read summaries derive `claimed_by` from state='active'
513
- // only (never expiry — see loadActiveKernelClaimRow), so owns re-applies the
514
- // expiry check here: an expired-but-not-yet-reclaimed lease is NOT ownership
515
- // (planClaimAcquisition would supersede it). Actor resolution mirrors the mutation
516
- // path's `context.actor || 'forge'` default so a bare CLI invocation matches its
517
- // own claims; `now` falls back to the wall clock like the mutation route.
518
- const id = firstPositional(args);
519
- const rows = allParams(runtime, db, 'SELECT * FROM kernel_issues WHERE id = ?', [id]);
520
- if (!rows[0]) {
521
- return formatIssueCommandError({
522
- command: 'issue.owns',
523
- code: 'FORGE_ISSUE_NOT_FOUND',
524
- message: `Issue ${id ?? '<missing id>'} not found`,
525
- exitCode: ISSUE_COMMAND_EXIT_CODES.notFound,
526
- });
527
- }
528
- const now = context.now || new Date().toISOString();
529
- const actor = context.actor || 'forge';
530
- const claim = loadActiveKernelClaimRow(runtime, db, id);
531
- const claimedBy = claim ? (claim.actor ?? null) : null;
532
- const expired = claim ? isLeaseExpired(claim, now) : false;
533
- // Ownership is per-SESSION, not just per-actor (kernel d71a824b): two agents
534
- // sharing one human actor but running as DIFFERENT sessions must not both read
535
- // OWNED for a lease only one of them holds. When BOTH the caller and the live
536
- // lease carry a session-id they must match; if either side is session-less (a
537
- // no-env caller, or a pre-session claim) we fall back to actor-only ownership so
538
- // historical behavior is preserved byte-for-byte. An empty/whitespace-only
539
- // session-id counts as session-LESS on BOTH sides — the SAME trim-truthy test the
540
- // claim-key write uses (buildClaimMutationEvent in lib/kernel/broker.js) — so ''
541
- // can never count as "present" here and "absent" there.
542
- const normalizeSession = value => (typeof value === 'string' && value.trim() !== '' ? value : null);
543
- const contextSession = normalizeSession(context.sessionId);
544
- const claimSession = claim ? normalizeSession(claim.session_id) : null;
545
- const sessionMismatch = contextSession !== null
546
- && claimSession !== null
547
- && contextSession !== claimSession;
548
- const owned = Boolean(claim) && !expired && claimedBy === actor && !sessionMismatch;
549
- return okIssueResponse('issue.owns', {
550
- id,
551
- actor,
552
- claimed_by: claimedBy,
553
- owned,
554
- expired,
555
- expires_at: claim ? (claim.expires_at ?? null) : null,
556
- });
637
+ return runIssueOwnsRead(runtime, db, args, context);
557
638
  }
558
639
  if (operation === 'search') {
559
640
  const term = `%${firstPositional(args) || ''}%`;
@@ -567,20 +648,17 @@ function runIssueReadOperation(runtime, db, operation, args, context) {
567
648
  return okIssueResponse('issue.search', { issues: summaries, count: summaries.length });
568
649
  }
569
650
  if (operation === 'stats') {
570
- const { index } = loadBoardReadiness(runtime, db, context);
651
+ const { index, liveClaims } = loadBoardReadiness(runtime, db, context);
571
652
  const statusRows = allParams(runtime, db, 'SELECT status, COUNT(*) AS n FROM kernel_issues GROUP BY status');
572
653
  const counts = {};
573
654
  for (const row of statusRows) {
574
655
  counts[row.status] = Number(row.n);
575
656
  }
576
- const activeClaims = Number(
577
- safeAll(runtime, db, "SELECT COUNT(*) AS n FROM kernel_claims WHERE state = 'active'")[0]?.n || 0,
578
- );
579
657
  return okIssueResponse('issue.stats', {
580
658
  counts,
581
659
  ready_count: index.readyQueue.length,
582
660
  blocked_count: index.blocked.length,
583
- active_claims: activeClaims,
661
+ active_claims: liveClaims.length,
584
662
  });
585
663
  }
586
664
  // KAP-7: derived read query — every issue whose readiness is blocked
@@ -695,24 +773,11 @@ function runIssueReadOperation(runtime, db, operation, args, context) {
695
773
  count: children.length,
696
774
  });
697
775
  }
698
- // Issue 7dc229d4: active lease/claims read for the dashboard live layer. The
699
- // kernel_claims lease row carries the full who/what/where set (actor,
700
- // session_id, worktree_id, expires_at, issue_id) — the CLI previously exposed
701
- // only `claimed_by` (the actor). A lease is LIVE iff state='active' AND it has
702
- // NOT expired at the read `now` (isLeaseExpired; a null expires_at never
703
- // expires). An expired-but-not-yet-reclaimed lease is deliberately excluded:
704
- // planClaimAcquisition would supersede it, so it is not a live presence signal
705
- // (this mirrors the expiry check the `owns` verdict re-applies). Reads that
706
- // derive claimed_by (loadBoardReadiness/loadActiveKernelClaimRow) filter on
707
- // state only; THIS read additionally honors the lease TTL. Sorted by
708
- // claimed_at (then issue_id) for deterministic output. The kernel_claims
709
- // schema has NO `agent` column — the lease's who-dimension is actor +
710
- // session_id + worktree_id — so no agent field is surfaced.
776
+ // Dashboard lease rows consume the same live-authority projection as stats,
777
+ // claimed_by, owns, and readiness. Sort by claimed_at then issue_id for stable output.
711
778
  if (operation === 'claims') {
712
- const nowIso = context.now || new Date().toISOString();
713
- const rows = safeAll(runtime, db, "SELECT * FROM kernel_claims WHERE state = 'active'");
714
- const claims = rows
715
- .filter(row => !isLeaseExpired(row, nowIso))
779
+ const liveClaims = loadLiveKernelClaimRows(runtime, db, context);
780
+ const claims = liveClaims
716
781
  .map(row => ({
717
782
  id: row.id,
718
783
  issue_id: row.issue_id,
@@ -748,11 +813,20 @@ const KERNEL_EVENT_COLUMNS = Object.freeze([
748
813
  'created_at',
749
814
  ]);
750
815
 
816
+ function assertGenericEventNamespace(event) {
817
+ if (!String(event.idempotency_key || '').startsWith('claim.repair:')) return;
818
+ throw new ClaimRepairError(
819
+ 'CLAIM_REPAIR_RECEIPT_RESERVED',
820
+ 'Generic event insertion cannot write the reserved claim-repair receipt namespace',
821
+ );
822
+ }
823
+
751
824
  // Persist one event. The id is supplied by the caller or minted here (event ids are
752
825
  // TEXT, not autoincrement). The event's payload is stored as payload_json: a
753
826
  // pre-serialized payload_json wins, else the payload object is JSON-stringified. The
754
827
  // native UNIQUE(idempotency_key) error is intentionally NOT caught here.
755
828
  function insertKernelEventRow(runtime, db, event) {
829
+ assertGenericEventNamespace(event);
756
830
  const id = event.id || randomUUID();
757
831
  const payloadJson = event.payload_json ?? JSON.stringify(event.payload ?? {});
758
832
  const row = {
@@ -789,17 +863,40 @@ function loadKernelEntityRow(runtime, db, entityType, entityId) {
789
863
  }
790
864
 
791
865
  // Read the full event stream for one entity, oldest first (matches
792
- // idx_kernel_events_entity_created; there is no seq column, so created_at is the
793
- // ordering key).
866
+ // idx_kernel_events_entity_created. SQLite rowid is the authoritative local
867
+ // insertion sequence when timestamps tie; event ids are random and cannot order
868
+ // same-clock approval/rejection decisions.
794
869
  function listKernelEventRows(runtime, db, entityType, entityId) {
795
870
  return allParams(
796
871
  runtime,
797
872
  db,
798
- 'SELECT * FROM kernel_events WHERE entity_type = ? AND entity_id = ? ORDER BY created_at ASC',
873
+ 'SELECT * FROM kernel_events WHERE entity_type = ? AND entity_id = ? ORDER BY created_at ASC, rowid ASC',
799
874
  [entityType, entityId],
800
875
  );
801
876
  }
802
877
 
878
+ // Bulk activity read (Slice C2, additive + read-only) across ALL entities, newest first,
879
+ // for `forge insights`. `since` is an optional ISO cutoff (created_at >= since); `limit`
880
+ // bounds the row count (default 1000). Imported beads interactions live here as
881
+ // `beads.interaction.<kind>` events, so insights derives interaction patterns from this
882
+ // instead of the retired legacy interactions log. Creates/migrates nothing.
883
+ function listRecentKernelEventRows(runtime, db, since, limit) {
884
+ const params = [];
885
+ let where = '';
886
+ if (since) {
887
+ where = 'WHERE created_at >= ?';
888
+ params.push(since);
889
+ }
890
+ const cap = Number.isFinite(Number(limit)) && Number(limit) > 0 ? Math.floor(Number(limit)) : 1000;
891
+ params.push(cap);
892
+ return allParams(
893
+ runtime,
894
+ db,
895
+ `SELECT * FROM kernel_events ${where} ORDER BY created_at DESC LIMIT ?`,
896
+ params,
897
+ );
898
+ }
899
+
803
900
  // Look up the committed event for an idempotency key (the duplicate-replay probe).
804
901
  // The broker calls this unconditionally inside a Promise.all even for keyless
805
902
  // events, so guard a falsy key up front rather than binding undefined.
@@ -1084,6 +1181,67 @@ function updateKernelClaimStateRow(runtime, db, claimId, state) {
1084
1181
  return { id: claimId, state };
1085
1182
  }
1086
1183
 
1184
+ function listActiveKernelClaimRows(runtime, db) {
1185
+ return safeAll(runtime, db, "SELECT * FROM kernel_claims WHERE state = 'active' ORDER BY claimed_at ASC, issue_id ASC");
1186
+ }
1187
+
1188
+ // Reconciliation must never release whichever lease happens to occupy an issue's
1189
+ // active slot after its evidence was gathered. Bind every ownership discriminator
1190
+ // from the observed row so a concurrent replacement makes this a zero-row no-op.
1191
+ function releaseExactKernelClaimRow(runtime, db, claim) {
1192
+ const result = runParams(
1193
+ runtime,
1194
+ db,
1195
+ `UPDATE kernel_claims SET state = 'released'
1196
+ WHERE id = ? AND issue_id = ? AND actor IS ? AND session_id IS ?
1197
+ AND worktree_id IS ? AND state = 'active'`,
1198
+ [claim.id, claim.issue_id, claim.actor ?? null, claim.session_id ?? null, claim.worktree_id ?? null],
1199
+ );
1200
+ return Number(result?.changes || 0) === 1;
1201
+ }
1202
+
1203
+ function releaseExactKernelClaimIfWorktreeMissing(runtime, db, claim, expectedWorktree, isMissing) {
1204
+ if (!expectedWorktree || typeof isMissing !== 'function') return false;
1205
+ execSql(runtime, db, 'BEGIN IMMEDIATE;');
1206
+ try {
1207
+ const rows = safeAll(
1208
+ runtime,
1209
+ db,
1210
+ `SELECT * FROM kernel_worktrees
1211
+ WHERE id = ? AND git_common_dir IS ? AND path = ? AND branch IS ?
1212
+ AND actor IS ? AND issue_id IS ? AND work_folder IS ?
1213
+ AND registered_at IS ? AND state = 'active'`,
1214
+ [
1215
+ expectedWorktree.id,
1216
+ expectedWorktree.git_common_dir ?? null,
1217
+ expectedWorktree.path,
1218
+ expectedWorktree.branch ?? null,
1219
+ expectedWorktree.actor ?? null,
1220
+ expectedWorktree.issue_id ?? null,
1221
+ expectedWorktree.work_folder ?? null,
1222
+ expectedWorktree.registered_at ?? null,
1223
+ ],
1224
+ );
1225
+ const exact = rows.length === 1 && rows[0];
1226
+ // This check deliberately runs under BEGIN IMMEDIATE to close the
1227
+ // worktree-state TOCTOU window. Keep callbacks synchronous, local, and fast.
1228
+ if (!exact || isMissing(exact.path, exact) !== true) {
1229
+ execSql(runtime, db, 'ROLLBACK;');
1230
+ return false;
1231
+ }
1232
+ const released = releaseExactKernelClaimRow(runtime, db, claim);
1233
+ if (!released) {
1234
+ execSql(runtime, db, 'ROLLBACK;');
1235
+ return false;
1236
+ }
1237
+ execSql(runtime, db, 'COMMIT;');
1238
+ return true;
1239
+ } catch (error) {
1240
+ try { execSql(runtime, db, 'ROLLBACK;'); } catch { /* preserve original failure */ }
1241
+ throw error;
1242
+ }
1243
+ }
1244
+
1087
1245
  // --- Worktree-linkage primitives (P0 kernel linkage backbone). The kernel_worktrees
1088
1246
  // table is a plain authority registry (NOT event-sourced): `forge worktree create`
1089
1247
  // writes a row here so the kernel records issue → worktree → work-folder, and
@@ -1193,6 +1351,194 @@ function listWorktreeRows(runtime, db, filter = {}) {
1193
1351
  return safeAll(runtime, db, 'SELECT * FROM kernel_worktrees ORDER BY registered_at DESC');
1194
1352
  }
1195
1353
 
1354
+ const TRACE_MAX_PULL_REQUESTS = 128;
1355
+ const TRACE_MAX_ITERATIONS = 128;
1356
+
1357
+ function resolvePrLinkageRow(runtime, db, input = {}) {
1358
+ let worktree = null;
1359
+ if (input.worktree_id) {
1360
+ worktree = safeAll(
1361
+ runtime,
1362
+ db,
1363
+ "SELECT * FROM kernel_worktrees WHERE id = ? AND state = 'active' LIMIT 1",
1364
+ [input.worktree_id],
1365
+ )[0] || null;
1366
+ } else if (input.branch && input.git_common_dir) {
1367
+ worktree = safeAll(
1368
+ runtime,
1369
+ db,
1370
+ `SELECT * FROM kernel_worktrees
1371
+ WHERE git_common_dir = ? AND branch = ? AND state = 'active'
1372
+ ORDER BY registered_at DESC LIMIT 1`,
1373
+ [input.git_common_dir, input.branch],
1374
+ )[0] || null;
1375
+ }
1376
+ const inferredIssueId = worktree?.issue_id ?? null;
1377
+ const issueId = input.issue_id ?? inferredIssueId;
1378
+ const issue = issueId
1379
+ ? safeAll(runtime, db, 'SELECT id, entity_revision FROM kernel_issues WHERE id = ? LIMIT 1', [issueId])[0] || null
1380
+ : null;
1381
+ return {
1382
+ issue_id: issueId,
1383
+ inferred_issue_id: inferredIssueId,
1384
+ issue_revision: issue?.entity_revision ?? null,
1385
+ worktree_id: worktree?.id ?? null,
1386
+ worktree_matches: Boolean(worktree
1387
+ && worktree.git_common_dir === input.git_common_dir
1388
+ && worktree.branch === input.branch
1389
+ && worktree.issue_id === issueId),
1390
+ };
1391
+ }
1392
+
1393
+ function parseTraceEvent(row) {
1394
+ let payload;
1395
+ try {
1396
+ payload = JSON.parse(row.payload_json || '{}');
1397
+ } catch {
1398
+ payload = {};
1399
+ }
1400
+ return {
1401
+ id: row.id,
1402
+ type: row.event_type,
1403
+ at: row.created_at,
1404
+ issue_id: payload.issue_id ?? null,
1405
+ worktree_id: payload.worktree_id ?? null,
1406
+ issue_revision: payload.issue_revision ?? null,
1407
+ head_sha: payload.head_sha ?? null,
1408
+ work_packet_hash: payload.work_packet_hash ?? null,
1409
+ work_packet_identity: payload.work_packet_identity ?? null,
1410
+ run_receipt_hash: payload.run_receipt_hash ?? null,
1411
+ run_id: payload.run_id ?? null,
1412
+ attempt_id: payload.attempt_id ?? null,
1413
+ risk_manifest_digest: payload.risk_manifest_digest ?? null,
1414
+ gate_receipts: payload.gate_receipts ?? null,
1415
+ url: payload.url ?? null,
1416
+ };
1417
+ }
1418
+
1419
+ function isCompleteTraceIteration(event) {
1420
+ if (!event.type?.startsWith('pr.')) return true;
1421
+ return typeof event.issue_id === 'string' && event.issue_id.length > 0
1422
+ && typeof event.worktree_id === 'string' && event.worktree_id.length > 0
1423
+ && Number.isInteger(event.issue_revision) && event.issue_revision >= 0
1424
+ && /^[0-9a-f]{40}$/.test(event.head_sha)
1425
+ && /^[0-9a-f]{64}$/.test(event.work_packet_hash)
1426
+ && typeof event.work_packet_identity === 'string' && event.work_packet_identity.length > 0
1427
+ && /^[0-9a-f]{64}$/.test(event.run_receipt_hash)
1428
+ && /^[0-9a-f]{64}$/.test(event.risk_manifest_digest)
1429
+ && Array.isArray(event.gate_receipts) && event.gate_receipts.length > 0
1430
+ && event.gate_receipts.every(receipt => typeof receipt === 'string' && receipt.length > 0);
1431
+ }
1432
+
1433
+ function loadPrTraceRow(runtime, db, row, gaps) {
1434
+ let iterations = safeAll(
1435
+ runtime,
1436
+ db,
1437
+ "SELECT * FROM kernel_events WHERE entity_type = 'pr' AND entity_id = ? ORDER BY created_at ASC, rowid ASC LIMIT ?",
1438
+ [row.id, TRACE_MAX_ITERATIONS + 1],
1439
+ );
1440
+ if (iterations.length > TRACE_MAX_ITERATIONS) {
1441
+ iterations = iterations.slice(0, TRACE_MAX_ITERATIONS);
1442
+ gaps.push(`iterations:${row.id}:overflow`);
1443
+ }
1444
+ const parsedIterations = iterations.map(parseTraceEvent);
1445
+ const prIterations = parsedIterations.filter(event => event.type?.startsWith('pr.'));
1446
+ if (prIterations.length === 0) {
1447
+ gaps.push(`iterations:${row.id}:missing`);
1448
+ } else if (prIterations.some(event => !isCompleteTraceIteration(event))) {
1449
+ gaps.push(`iterations:${row.id}:incomplete`);
1450
+ }
1451
+ return {
1452
+ ...row,
1453
+ url: parsedIterations.find(event => event.url)?.url ?? null,
1454
+ iterations: parsedIterations,
1455
+ };
1456
+ }
1457
+
1458
+ function loadSelectedTracePr(runtime, db, target) {
1459
+ if (target.pr_number === undefined || target.pr_number === null) return null;
1460
+ const clauses = ['number = ?'];
1461
+ const params = [Number(target.pr_number)];
1462
+ if (target.repo) {
1463
+ clauses.push('repo = ?');
1464
+ params.push(target.repo);
1465
+ }
1466
+ if (target.git_common_dir) {
1467
+ clauses.push('git_common_dir = ?');
1468
+ params.push(target.git_common_dir);
1469
+ }
1470
+ const matches = safeAll(
1471
+ runtime,
1472
+ db,
1473
+ `SELECT * FROM kernel_pr WHERE ${clauses.join(' AND ')} ORDER BY registered_at DESC LIMIT 2`,
1474
+ params,
1475
+ );
1476
+ if (matches.length > 1) throw new Error('Kernel trace PR target is ambiguous; supply repo and git_common_dir');
1477
+ return matches[0] || null;
1478
+ }
1479
+
1480
+ function appendSelectedTracePr(pullRequests, selectedPr, issueId, gaps) {
1481
+ if (!selectedPr || pullRequests.some(row => row.id === selectedPr.id)) return pullRequests;
1482
+ if (issueId && selectedPr.issue_id !== issueId) {
1483
+ gaps.push(`pull_requests:${selectedPr.id}:unlinked_issue`);
1484
+ }
1485
+ const retained = pullRequests.length >= TRACE_MAX_PULL_REQUESTS
1486
+ ? pullRequests.slice(0, TRACE_MAX_PULL_REQUESTS - 1)
1487
+ : pullRequests;
1488
+ if (retained.length < pullRequests.length && !gaps.includes('pull_requests:overflow')) {
1489
+ gaps.push('pull_requests:overflow');
1490
+ }
1491
+ return [...retained, selectedPr];
1492
+ }
1493
+
1494
+ function loadTraceRows(runtime, db, target = {}) {
1495
+ const gaps = [];
1496
+ const selectedPr = loadSelectedTracePr(runtime, db, target);
1497
+
1498
+ const issueId = target.issue_id ?? selectedPr?.issue_id ?? null;
1499
+ const issue = issueId
1500
+ ? safeAll(runtime, db, 'SELECT * FROM kernel_issues WHERE id = ? LIMIT 1', [issueId])[0] || null
1501
+ : null;
1502
+ let pullRequests = issue
1503
+ ? safeAll(
1504
+ runtime,
1505
+ db,
1506
+ ' SELECT * FROM kernel_pr WHERE issue_id = ? ORDER BY registered_at ASC, number ASC LIMIT ?',
1507
+ [issue.id, TRACE_MAX_PULL_REQUESTS + 1],
1508
+ )
1509
+ : [];
1510
+ if (pullRequests.length > TRACE_MAX_PULL_REQUESTS) {
1511
+ pullRequests = pullRequests.slice(0, TRACE_MAX_PULL_REQUESTS);
1512
+ gaps.push('pull_requests:overflow');
1513
+ }
1514
+ pullRequests = appendSelectedTracePr(pullRequests, selectedPr, issueId, gaps);
1515
+
1516
+ let worktree = null;
1517
+ const issueWorktreeId = pullRequests.find(row => row.issue_id === issueId && row.worktree_id)?.worktree_id;
1518
+ const selectedWorktreeId = !issueId || selectedPr?.issue_id === issueId ? selectedPr?.worktree_id : null;
1519
+ const preferredWorktreeId = selectedWorktreeId || issueWorktreeId;
1520
+ if (preferredWorktreeId) {
1521
+ worktree = safeAll(runtime, db, 'SELECT * FROM kernel_worktrees WHERE id = ? LIMIT 1', [preferredWorktreeId])[0] || null;
1522
+ }
1523
+ if (!worktree && issue) {
1524
+ worktree = safeAll(
1525
+ runtime,
1526
+ db,
1527
+ `SELECT * FROM kernel_worktrees WHERE issue_id = ?
1528
+ ORDER BY CASE state WHEN 'active' THEN 0 ELSE 1 END, registered_at DESC LIMIT 1`,
1529
+ [issue.id],
1530
+ )[0] || null;
1531
+ }
1532
+
1533
+ return {
1534
+ issue,
1535
+ worktree,
1536
+ work_folder: worktree?.work_folder ?? null,
1537
+ gaps,
1538
+ pull_requests: pullRequests.map(row => loadPrTraceRow(runtime, db, row, gaps)),
1539
+ };
1540
+ }
1541
+
1196
1542
  // --- Stage-run registry (f61601ab). kernel_stage_runs records the REAL workflow
1197
1543
  // phase per issue so the dashboard/`show` read the phase instead of guessing it
1198
1544
  // from status+claim (a claimed-open issue with a merged PR would otherwise still
@@ -1351,6 +1697,78 @@ function recordStageTransitionRow(runtime, db, input) {
1351
1697
  }
1352
1698
  }
1353
1699
 
1700
+ const PLAN_SNAPSHOT_METADATA_KEY = 'forge.plan.v1';
1701
+
1702
+ function parseIssueMetadataObject(metadata) {
1703
+ if (metadata == null || metadata === '') return {};
1704
+ const parsed = typeof metadata === 'string' ? JSON.parse(metadata) : metadata;
1705
+ if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
1706
+ throw new Error('issue metadata must be a JSON object');
1707
+ }
1708
+ return parsed;
1709
+ }
1710
+
1711
+ function loadPlanSnapshotRow(runtime, db, issueId) {
1712
+ const issue = loadKernelEntityRow(runtime, db, 'issue', issueId);
1713
+ if (!issue) throw new Error(`Issue ${issueId} not found in the kernel`);
1714
+ return parseIssueMetadataObject(issue.metadata)[PLAN_SNAPSHOT_METADATA_KEY] || null;
1715
+ }
1716
+
1717
+ // Persist the plan snapshot and the plan->dev transition as one authority write.
1718
+ // The issue metadata column already exists, so this adds no schema or migration.
1719
+ // A normal issue.update event is appended in the same transaction so revision/CAS
1720
+ // history remains truthful rather than mutating the issue projection out-of-band.
1721
+ function recordPlanSnapshotTransitionRow(runtime, db, input) {
1722
+ const issueId = input && input.issue_id;
1723
+ const snapshot = input && input.snapshot;
1724
+ if (!issueId || !snapshot) {
1725
+ throw new Error('recordPlanSnapshotTransition requires issue_id and snapshot');
1726
+ }
1727
+ const now = input.now || new Date().toISOString();
1728
+ execSql(runtime, db, 'BEGIN IMMEDIATE;');
1729
+ try {
1730
+ const issue = loadKernelEntityRow(runtime, db, 'issue', issueId);
1731
+ if (!issue) throw new Error(`Issue ${issueId} not found in the kernel`);
1732
+ const metadata = parseIssueMetadataObject(issue.metadata);
1733
+ const established = metadata[PLAN_SNAPSHOT_METADATA_KEY];
1734
+ if (established) {
1735
+ if (established.digest === snapshot.digest) {
1736
+ execSql(runtime, db, 'COMMIT;');
1737
+ return { idempotent: true };
1738
+ }
1739
+ throw new Error('Kernel plan snapshot is immutable; reconcile repository drift before retrying');
1740
+ }
1741
+ metadata[PLAN_SNAPSHOT_METADATA_KEY] = snapshot;
1742
+ const event = {
1743
+ id: randomUUID(),
1744
+ entity_type: 'issue',
1745
+ entity_id: issueId,
1746
+ event_type: 'issue.update',
1747
+ idempotency_key: `plan.snapshot:${issueId}:${snapshot.digest}`,
1748
+ expected_revision: Number(issue.entity_revision ?? 0),
1749
+ actor: input.actor || 'forge-plan',
1750
+ origin: 'local',
1751
+ payload: { metadata: JSON.stringify(metadata) },
1752
+ created_at: now,
1753
+ };
1754
+ insertKernelEventRow(runtime, db, event);
1755
+ applyAcceptedIssueEvent(runtime, db, event, {});
1756
+ const transition = {
1757
+ from: recordStageRunRow(runtime, db, { issue_id: issueId, stage: 'plan', action: 'complete', now }),
1758
+ to: recordStageRunRow(runtime, db, { issue_id: issueId, stage: 'dev', action: 'start', now }),
1759
+ };
1760
+ execSql(runtime, db, 'COMMIT;');
1761
+ return transition;
1762
+ } catch (error) {
1763
+ try {
1764
+ execSql(runtime, db, 'ROLLBACK;');
1765
+ } catch {
1766
+ // Preserve the original failure.
1767
+ }
1768
+ throw error;
1769
+ }
1770
+ }
1771
+
1354
1772
  function listStageRunRows(runtime, db, issueId) {
1355
1773
  if (!issueId) return [];
1356
1774
  // Deterministic order: started_at first, then rowid (the implicit INSERT sequence)
@@ -1562,6 +1980,15 @@ function applyAcceptedIssueEvent(runtime, db, event, context = {}) {
1562
1980
  // on this connection), so computeNewlyUnblocked sees the post-close readiness.
1563
1981
  function finalizeIssueMutation(runtime, db, event, issueId, revision, context) {
1564
1982
  const summary = { id: issueId, revision };
1983
+ const issue = loadKernelEntityRow(runtime, db, 'issue', issueId);
1984
+ if (isTerminalStatus(issue?.status)) {
1985
+ const claim = loadActiveKernelClaimRow(runtime, db, issueId);
1986
+ if (claim && !releaseExactKernelClaimRow(runtime, db, claim)) {
1987
+ const error = new Error(`failed to release current claim for terminal issue ${issueId}`);
1988
+ error.code = 'FORGE_CLAIM_TERMINAL_RELEASE_CONFLICT';
1989
+ throw error;
1990
+ }
1991
+ }
1565
1992
  if (event.event_type === 'issue.close') {
1566
1993
  summary.newly_unblocked = computeNewlyUnblocked(runtime, db, issueId, context);
1567
1994
  }
@@ -1865,6 +2292,7 @@ function importIssueRecords(runtime, db, records = {}, options = {}) {
1865
2292
  for (const event of activityEvents) {
1866
2293
  if (!event || event.id == null) { summary.events.skipped += 1; continue; }
1867
2294
  const row = buildImportEventRow(event, now);
2295
+ assertGenericEventNamespace(row);
1868
2296
  const result = runParams(runtime, db, eventSql, IMPORT_EVENT_COLUMNS.map(column => row[column]));
1869
2297
  summary.events[wasInserted(result) ? 'inserted' : 'skipped'] += 1;
1870
2298
  }
@@ -2006,23 +2434,43 @@ function searchMemoryRows(runtime, db, query) {
2006
2434
  ).map(memoryRowToEntry);
2007
2435
  }
2008
2436
 
2009
- // Optional source_agent allow-list → a parameterized `WHERE source_agent IN (...)` clause.
2010
- // Lets the default `recall` view show only human `remember` notes without loading and
2011
- // filtering the whole table in JS. Returns { clause, params }; empty when no filter.
2012
- function memoryAgentFilter(agents) {
2013
- if (!Array.isArray(agents) || agents.length === 0) {
2014
- return { clause: '', params: [] };
2437
+ // Optional source-agent and kind filters become one parameterized WHERE clause. This keeps
2438
+ // typed recall complete even when its oldest match lies outside a broad recent-note window.
2439
+ function memoryReadFilter(options = {}, table = 'kernel_memories') {
2440
+ const predicates = [];
2441
+ const params = [];
2442
+ if (options.includeSuperseded !== true) {
2443
+ predicates.push(`(COALESCE(${table}.scope, ''), ${table}.key) NOT IN (
2444
+ SELECT COALESCE(superseder.scope, ''), superseded.value
2445
+ FROM kernel_memories superseder,
2446
+ json_each(COALESCE(superseder.supersedes_json, '[]')) superseded
2447
+ WHERE superseded.type = 'text'
2448
+ AND superseder.key != superseded.value
2449
+ )`);
2450
+ }
2451
+ if (Array.isArray(options.agents) && options.agents.length > 0) {
2452
+ predicates.push(`${table}.source_agent IN (${options.agents.map(() => '?').join(', ')})`);
2453
+ params.push(...options.agents);
2015
2454
  }
2016
- const placeholders = agents.map(() => '?').join(', ');
2017
- return { clause: ` WHERE source_agent IN (${placeholders})`, params: [...agents] };
2455
+ if (typeof options.kind === 'string' && options.kind.trim()) {
2456
+ predicates.push(`EXISTS (
2457
+ SELECT 1 FROM json_each(${table}.tags_json)
2458
+ WHERE lower(json_each.value) = ?
2459
+ )`);
2460
+ params.push(`type:${options.kind.trim().toLowerCase()}`);
2461
+ }
2462
+ return {
2463
+ clause: predicates.length > 0 ? ` WHERE ${predicates.join(' AND ')}` : '',
2464
+ params,
2465
+ };
2018
2466
  }
2019
2467
 
2020
2468
  // The newest `limit` entries by logical (as-of) timestamp — the default read model for
2021
2469
  // `recall` with no query. rowid breaks ties so same-timestamp rows are still deterministic.
2022
- // An optional `agents` allow-list scopes the view (e.g. to human `remember` notes).
2023
- function recentMemoryRows(runtime, db, limit, agents) {
2470
+ // Optional `agents` and `kind` filters scope the view before the limit is applied.
2471
+ function recentMemoryRows(runtime, db, limit, options = {}) {
2024
2472
  const capped = Number.isInteger(limit) && limit > 0 ? limit : 20;
2025
- const { clause, params } = memoryAgentFilter(agents);
2473
+ const { clause, params } = memoryReadFilter(options);
2026
2474
  return allParams(
2027
2475
  runtime,
2028
2476
  db,
@@ -2031,10 +2479,10 @@ function recentMemoryRows(runtime, db, limit, agents) {
2031
2479
  ).map(memoryRowToEntry);
2032
2480
  }
2033
2481
 
2034
- // Total number of stored memories (optionally scoped by `agents`) — paired with
2482
+ // Total number of stored memories (optionally scoped by `agents` and `kind`) — paired with
2035
2483
  // recentMemoryRows so `recall` can report "showing N of TOTAL" instead of silently truncating.
2036
- function countMemoryRows(runtime, db, agents) {
2037
- const { clause, params } = memoryAgentFilter(agents);
2484
+ function countMemoryRows(runtime, db, options = {}) {
2485
+ const { clause, params } = memoryReadFilter(options);
2038
2486
  const rows = allParams(runtime, db, `SELECT count(*) AS count FROM kernel_memories${clause}`, params);
2039
2487
  return Number((rows[0] || {}).count) || 0;
2040
2488
  }
@@ -2049,24 +2497,40 @@ function buildMemoryFtsMatch(query) {
2049
2497
  return tokens.map(token => `"${token}"`).join(' AND ');
2050
2498
  }
2051
2499
 
2500
+ // Like buildMemoryFtsMatch but OR-joins the tokens: a natural-language prompt matches a note
2501
+ // containing ANY of its keywords, not EVERY one. Used ONLY by the relevance-only SCORED read
2502
+ // (the per-turn recall hook) — a raw prompt ("why is my forge push taking so long") token-ANDed
2503
+ // required every word in one note and matched nothing (0% recall). Each token is double-quoted
2504
+ // for FTS5 safety (tokens may be non-Latin unicode). Returns '' when the query has no tokens.
2505
+ function buildMemoryFtsMatchOr(query) {
2506
+ const tokens = String(query ?? '').match(/[\p{L}\p{N}]+/gu);
2507
+ if (!tokens || tokens.length === 0) return '';
2508
+ return tokens.map(token => `"${token}"`).join(' OR ');
2509
+ }
2510
+
2052
2511
  // BM25 top-N recall over the kernel_memories_fts index (migration 008). Joins the FTS
2053
2512
  // rowid back to the memory row and orders by bm25 (lower = better match). An empty/tokenless
2054
2513
  // query falls back to recent entries so `recall` never returns a bare full dump.
2055
- function searchMemoryRowsRanked(runtime, db, query, limit) {
2514
+ function searchMemoryRowsRanked(runtime, db, query, limit, options = {}) {
2056
2515
  const capped = Number.isInteger(limit) && limit > 0 ? limit : 20;
2057
2516
  const match = buildMemoryFtsMatch(query);
2058
2517
  if (!match) {
2059
- return recentMemoryRows(runtime, db, capped);
2518
+ return recentMemoryRows(runtime, db, capped, options);
2060
2519
  }
2520
+ const { clause, params } = memoryReadFilter({
2521
+ kind: options.kind,
2522
+ includeSuperseded: options.includeSuperseded,
2523
+ }, 'm');
2524
+ const kindPredicate = clause ? clause.replace(/^ WHERE /, ' AND ') : '';
2061
2525
  return allParams(
2062
2526
  runtime,
2063
2527
  db,
2064
2528
  `SELECT m.* FROM kernel_memories m
2065
2529
  JOIN kernel_memories_fts ON kernel_memories_fts.rowid = m.rowid
2066
- WHERE kernel_memories_fts MATCH ?
2530
+ WHERE kernel_memories_fts MATCH ?${kindPredicate}
2067
2531
  ORDER BY bm25(kernel_memories_fts)
2068
2532
  LIMIT ?`,
2069
- [match, capped],
2533
+ [match, ...params, capped],
2070
2534
  ).map(memoryRowToEntry);
2071
2535
  }
2072
2536
 
@@ -2076,22 +2540,100 @@ function searchMemoryRowsRanked(runtime, db, query, limit) {
2076
2540
  // Unlike searchMemoryRowsRanked, a no-match (or empty) query returns [] with NO recency
2077
2541
  // fallback: the whole point is to avoid surfacing recent-but-irrelevant notes. bm25()
2078
2542
  // returns more-negative for stronger matches, so rows come back best (lowest) first.
2079
- function searchMemoryRowsRankedScored(runtime, db, query, limit) {
2543
+ function confirmedMemorySql(alias) {
2544
+ return `(EXISTS (
2545
+ SELECT 1 FROM json_each(${alias}.tags_json)
2546
+ WHERE lower(json_each.value) = 'trust:confirmed'
2547
+ ) OR (${alias}.source_agent = 'forge remember'
2548
+ AND json_type(${alias}.value_json) = 'text'
2549
+ AND NOT EXISTS (
2550
+ SELECT 1 FROM json_each(${alias}.tags_json)
2551
+ WHERE lower(json_each.value) LIKE 'trust:%'
2552
+ OR lower(json_each.value) = 'forge:auto-capture'
2553
+ )))`;
2554
+ }
2555
+
2556
+ function projectMemoryScopeSql(alias) {
2557
+ return `(${alias}.scope IS NULL OR ${alias}.scope = 'project' OR ${alias}.scope = ?)`;
2558
+ }
2559
+
2560
+ function suggestedFreshnessCutoff(now) {
2561
+ const timestamp = Date.parse(now || new Date().toISOString());
2562
+ return new Date(timestamp - (7 * 24 * 60 * 60 * 1000)).toISOString();
2563
+ }
2564
+
2565
+ function searchMemoryRowsRankedScored(runtime, db, query, limit, options = {}) {
2080
2566
  const capped = Number.isInteger(limit) && limit > 0 ? limit : 20;
2081
- const match = buildMemoryFtsMatch(query);
2567
+ // keyword-OR (NOT the token-AND of searchMemoryRowsRanked): this relevance-only read backs
2568
+ // the per-turn recall hook, where a natural-language prompt must match on ANY keyword.
2569
+ const match = buildMemoryFtsMatchOr(query);
2082
2570
  if (!match) {
2083
2571
  return [];
2084
2572
  }
2085
- return allParams(
2086
- runtime,
2087
- db,
2088
- `SELECT m.*, bm25(kernel_memories_fts) AS __score FROM kernel_memories m
2573
+ const projectId = options.projectId;
2574
+ if (typeof projectId !== 'string' || !projectId) return [];
2575
+ const cutoff = suggestedFreshnessCutoff(options.now);
2576
+ const excludeKeys = Array.isArray(options.excludeKeys)
2577
+ ? [...new Set(options.excludeKeys.filter(key => typeof key === 'string'))].slice(0, 256)
2578
+ : [];
2579
+ const seenSql = excludeKeys.length > 0
2580
+ ? `AND m.key NOT IN (${excludeKeys.map(() => '?').join(', ')})`
2581
+ : '';
2582
+ const confirmed = confirmedMemorySql('m');
2583
+ const supersederConfirmed = confirmedMemorySql('s');
2584
+ const supersessionPredicate = options.includeSuperseded === true
2585
+ ? ''
2586
+ : `AND NOT EXISTS (
2587
+ SELECT 1
2588
+ FROM eligible_superseders superseder
2589
+ WHERE superseder.memory_key = m.key
2590
+ AND (superseder.is_confirmed = 1 OR NOT ${confirmed})
2591
+ )`;
2592
+ // Expand eligible supersession edges once. A correlated json_each scan repeated the
2593
+ // entire memory table for every FTS candidate and dominated the 1,000-row prompt path.
2594
+ // Recall temporarily waits less than the connection default so a real lock cannot outlive
2595
+ // the prompt hook; the finally block restores the caller's normal connection behavior.
2596
+ const previousBusyTimeout = Number(queryOne(runtime, db, 'PRAGMA busy_timeout;').timeout) || 0;
2597
+ const requestedBusyTimeout = Number(options.busyTimeoutMs);
2598
+ const busyTimeout = Number.isFinite(requestedBusyTimeout) && requestedBusyTimeout >= 0
2599
+ ? Math.floor(requestedBusyTimeout)
2600
+ : Math.min(previousBusyTimeout, 2_500);
2601
+ if (busyTimeout !== previousBusyTimeout) {
2602
+ execSql(runtime, db, `PRAGMA busy_timeout=${busyTimeout};`);
2603
+ }
2604
+ try {
2605
+ return allParams(
2606
+ runtime,
2607
+ db,
2608
+ `WITH eligible_superseders AS MATERIALIZED (
2609
+ SELECT superseded.value AS memory_key,
2610
+ CASE WHEN ${supersederConfirmed} THEN 1 ELSE 0 END AS is_confirmed
2611
+ FROM kernel_memories s,
2612
+ json_each(COALESCE(s.supersedes_json, '[]')) superseded
2613
+ WHERE ${projectMemoryScopeSql('s')}
2614
+ AND (${supersederConfirmed} OR s.updated_at >= ?)
2615
+ )
2616
+ SELECT m.*, bm25(kernel_memories_fts) AS __score FROM kernel_memories m
2089
2617
  JOIN kernel_memories_fts ON kernel_memories_fts.rowid = m.rowid
2090
2618
  WHERE kernel_memories_fts MATCH ?
2091
- ORDER BY bm25(kernel_memories_fts)
2619
+ AND ${projectMemoryScopeSql('m')}
2620
+ AND (${confirmed} OR m.updated_at >= ?)
2621
+ ${seenSql}
2622
+ ${supersessionPredicate}
2623
+ ORDER BY bm25(kernel_memories_fts),
2624
+ CASE WHEN ${confirmed} THEN 0 ELSE 1 END,
2625
+ m.source_agent ASC, m.updated_at DESC, m.key ASC
2092
2626
  LIMIT ?`,
2093
- [match, capped],
2094
- ).map(row => ({ ...memoryRowToEntry(row), score: row.__score }));
2627
+ [projectId, cutoff, match, projectId, cutoff, ...excludeKeys, capped],
2628
+ ).map(row => normalizeRecallHit(
2629
+ { ...memoryRowToEntry(row), score: row.__score },
2630
+ projectId,
2631
+ ));
2632
+ } finally {
2633
+ if (busyTimeout !== previousBusyTimeout) {
2634
+ execSql(runtime, db, `PRAGMA busy_timeout=${previousBusyTimeout};`);
2635
+ }
2636
+ }
2095
2637
  }
2096
2638
 
2097
2639
  function closeDatabase(db) {
@@ -2100,58 +2642,2187 @@ function closeDatabase(db) {
2100
2642
  }
2101
2643
  }
2102
2644
 
2103
- function createDriver(runtime, configuredDatabasePath) {
2104
- let db;
2105
- let openedDatabasePath;
2106
- let memorySchemaEnsured = false;
2645
+ const WATCH_OWNER_TABLE = 'kernel_pr_watch_owners';
2646
+ const WATCH_GATE_TABLE = 'kernel_pr_watch_migration_gate';
2647
+ const WATCH_OWNER_SCHEMA_COLUMNS = Object.freeze([
2648
+ { name: 'repo', type: 'TEXT', notnull: 1, pk: 1 },
2649
+ { name: 'pr', type: 'INTEGER', notnull: 1, pk: 2 },
2650
+ { name: 'version', type: 'INTEGER', notnull: 1, pk: 0 },
2651
+ { name: 'generation', type: 'TEXT', notnull: 1, pk: 0 },
2652
+ { name: 'phase', type: 'TEXT', notnull: 1, pk: 0 },
2653
+ { name: 'controller_pid', type: 'INTEGER', notnull: 0, pk: 0 },
2654
+ { name: 'watcher_pid', type: 'INTEGER', notnull: 0, pk: 0 },
2655
+ { name: 'started_at', type: 'TEXT', notnull: 1, pk: 0 },
2656
+ { name: 'updated_at', type: 'TEXT', notnull: 1, pk: 0 },
2657
+ { name: 'heartbeat_at', type: 'TEXT', notnull: 0, pk: 0 },
2658
+ { name: 'terminal_receipt_id', type: 'TEXT', notnull: 0, pk: 0 },
2659
+ { name: 'block_reason', type: 'TEXT', notnull: 0, pk: 0 },
2660
+ { name: 'legacy_evidence_hash', type: 'TEXT', notnull: 0, pk: 0 },
2661
+ ]);
2662
+ const WATCH_GATE_SCHEMA_COLUMNS = Object.freeze([
2663
+ { name: 'singleton', type: 'INTEGER', notnull: 1, pk: 1 },
2664
+ { name: 'state', type: 'TEXT', notnull: 1, pk: 0 },
2665
+ { name: 'snapshot_hash', type: 'TEXT', notnull: 0, pk: 0 },
2666
+ { name: 'conflict_code', type: 'TEXT', notnull: 0, pk: 0 },
2667
+ { name: 'updated_at', type: 'TEXT', notnull: 1, pk: 0 },
2668
+ ]);
2107
2669
 
2108
- // kernel_memories is created by migration 005 through broker.initialize(), but the
2109
- // synchronous project-memory facade writes WITHOUT first running migrations. Lazily
2110
- // ensure the table (idempotent CREATE IF NOT EXISTS, rendered from the same migration)
2111
- // plus a busy_timeout for the second connection the issue backend may hold open.
2112
- function ensureMemorySchema(database) {
2113
- if (memorySchemaEnsured) return;
2114
- execSql(runtime, database, 'PRAGMA busy_timeout=5000;');
2115
- for (const statement of buildMemoryProjectionMigration().apply) {
2116
- execSql(runtime, database, statement);
2670
+ function watchOwnerStoreError(code, message, cause) {
2671
+ const error = new Error(message);
2672
+ error.code = code;
2673
+ if (cause) error.cause = cause;
2674
+ return error;
2675
+ }
2676
+
2677
+ function assertWatchOwnerDatabasePath(databasePath, options = {}) {
2678
+ if (typeof databasePath !== 'string' || !databasePath
2679
+ || databasePath === ':memory:' || databasePath.startsWith('file:')) {
2680
+ throw watchOwnerStoreError('AUTHORITY_UNAVAILABLE', 'Watcher authority requires an existing file-backed Kernel database');
2681
+ }
2682
+ const resolved = path.resolve(databasePath);
2683
+ const filesystemDeps = options.watchOwnerFilesystemDeps || {};
2684
+ const authorityFilesystemDeps = {
2685
+ ...filesystemDeps,
2686
+ env: { ...(filesystemDeps.env || process.env), FORGE_KERNEL_ALLOW_UNSAFE_FS: '' },
2687
+ };
2688
+ if (path.basename(resolved) !== 'kernel.sqlite' || path.basename(path.dirname(resolved)) !== 'forge') {
2689
+ throw watchOwnerStoreError('AUTHORITY_UNAVAILABLE', 'Watcher authority database path is outside the canonical forge/kernel.sqlite location');
2690
+ }
2691
+ try {
2692
+ assertFilesystemSafeForKernel(resolved, authorityFilesystemDeps);
2693
+ } catch (error) {
2694
+ throw watchOwnerStoreError('AUTHORITY_UNAVAILABLE', 'Watcher authority database is on a refused filesystem class', error);
2695
+ }
2696
+ let stat;
2697
+ try {
2698
+ stat = fs.statSync(resolved);
2699
+ } catch (error) {
2700
+ throw watchOwnerStoreError('AUTHORITY_UNAVAILABLE', 'Watcher authority database does not exist', error);
2701
+ }
2702
+ if (!stat.isFile()) {
2703
+ throw watchOwnerStoreError('AUTHORITY_UNAVAILABLE', 'Watcher authority database is not a regular file');
2704
+ }
2705
+ let realPath;
2706
+ try {
2707
+ realPath = fs.realpathSync(resolved);
2708
+ } catch (error) {
2709
+ throw watchOwnerStoreError('AUTHORITY_UNAVAILABLE', 'Watcher authority database path cannot be resolved', error);
2710
+ }
2711
+ if (path.basename(realPath) !== 'kernel.sqlite' || path.basename(path.dirname(realPath)) !== 'forge') {
2712
+ throw watchOwnerStoreError('AUTHORITY_UNAVAILABLE', 'Watcher authority database resolves outside the canonical forge/kernel.sqlite location');
2713
+ }
2714
+ try {
2715
+ assertFilesystemSafeForKernel(realPath, authorityFilesystemDeps);
2716
+ } catch (error) {
2717
+ throw watchOwnerStoreError('AUTHORITY_UNAVAILABLE', 'Watcher authority database resolves through a refused filesystem class', error);
2718
+ }
2719
+ return { realPath, dev: stat.dev, ino: stat.ino };
2720
+ }
2721
+
2722
+ function sameWatchOwnerDatabaseIdentity(expected, actual) {
2723
+ return expected.realPath === actual.realPath
2724
+ && expected.dev === actual.dev
2725
+ && expected.ino === actual.ino;
2726
+ }
2727
+
2728
+ function openWatchOwnerDatabase(runtime, validated) {
2729
+ let database;
2730
+ let guard;
2731
+ try {
2732
+ // SQLite's filename constructor may create a missing file. Hold a must-exist
2733
+ // descriptor across the open so a path swap after validation fails before any
2734
+ // runtime can create or rebind the authority database.
2735
+ guard = fs.openSync(validated.realPath, 'r+');
2736
+ const guardedStat = fs.fstatSync(guard);
2737
+ if (!sameWatchOwnerDatabaseIdentity(validated, {
2738
+ realPath: validated.realPath, dev: guardedStat.dev, ino: guardedStat.ino,
2739
+ })) {
2740
+ throw new Error('authority file identity changed before open');
2117
2741
  }
2118
- // FTS5 recall index (migration 008): create the virtual table + sync triggers
2119
- // idempotently so a synchronous memory write stays indexed without a prior
2120
- // broker.initialize(). When the index is NEWLY created, rebuild once to backfill any
2121
- // rows written before it existed (a DB upgraded from before this feature, or rows the
2122
- // insights engine wrote straight to kernel_memories) — the sync triggers keep it
2123
- // current thereafter, so steady-state process starts skip the reindex.
2124
- //
2125
- // Staleness is detected by TABLE EXISTENCE (sqlite_master), never by count(*): on an
2126
- // external-content FTS5 table `count(*)` returns the CONTENT row count, not the
2127
- // indexed-doc count, so it can never reveal an un-backfilled index.
2128
- const ftsDdl = memoryFtsDdl();
2129
- const ftsExisted = Number(queryOne(
2130
- runtime,
2131
- database,
2132
- "SELECT count(*) AS count FROM sqlite_master WHERE type = 'table' AND name = 'kernel_memories_fts'",
2133
- ).count) > 0;
2134
- execSql(runtime, database, ftsDdl.create);
2135
- for (const trigger of ftsDdl.triggers) {
2136
- execSql(runtime, database, trigger);
2742
+ database = createExistingWatchOwnerDatabase(runtime, validated.realPath);
2743
+ const main = queryAll(runtime, database, 'PRAGMA database_list;')
2744
+ .find(row => row.name === 'main');
2745
+ if (!main || typeof main.file !== 'string' || !main.file) {
2746
+ throw new Error('opened database did not expose a file-backed main database');
2747
+ }
2748
+ const openedPath = path.resolve(main.file);
2749
+ const openedRealPath = fs.realpathSync(openedPath);
2750
+ const openedStat = fs.statSync(openedRealPath);
2751
+ const actual = { realPath: openedRealPath, dev: openedStat.dev, ino: openedStat.ino };
2752
+ if (!sameWatchOwnerDatabaseIdentity(validated, actual)) {
2753
+ throw new Error('opened database identity differs from the validated authority file');
2137
2754
  }
2138
- if (!ftsExisted) {
2139
- execSql(runtime, database, ftsDdl.rebuild);
2755
+ return database;
2756
+ } catch (error) {
2757
+ closeDatabase(database);
2758
+ throw watchOwnerStoreError('AUTHORITY_UNAVAILABLE', 'Watcher authority database changed while opening', error);
2759
+ } finally {
2760
+ if (guard != null) {
2761
+ try { fs.closeSync(guard); } catch { /* preserve the open/identity result */ }
2140
2762
  }
2141
- memorySchemaEnsured = true;
2142
2763
  }
2764
+ }
2143
2765
 
2144
- function resolveDatabasePath(config) {
2145
- const brokerDatabasePath = config && config.databasePath;
2146
- if (configuredDatabasePath && brokerDatabasePath && configuredDatabasePath !== brokerDatabasePath) {
2147
- throw new Error([
2148
- 'Kernel SQLite driver databasePath mismatch:',
2149
- `driver is configured for ${configuredDatabasePath}`,
2150
- `but broker config uses ${brokerDatabasePath}`,
2151
- ].join(' '));
2152
- }
2153
- const databasePath = brokerDatabasePath || configuredDatabasePath;
2154
- if (!databasePath) {
2766
+ function watchOwnerBusyTimeout(options = {}) {
2767
+ const requested = Number(options.watchOwnerBusyTimeoutMs);
2768
+ if (!Number.isFinite(requested)) return 1_000;
2769
+ return Math.max(0, Math.min(5_000, Math.floor(requested)));
2770
+ }
2771
+
2772
+ function hasExactWatchSchemaColumns(runtime, db, tableName, expectedColumns) {
2773
+ const columns = queryAll(runtime, db, `PRAGMA table_info('${tableName}')`);
2774
+ return columns.length === expectedColumns.length
2775
+ && columns.every((column, index) => {
2776
+ const expected = expectedColumns[index];
2777
+ return column.name === expected.name
2778
+ && String(column.type).toUpperCase() === expected.type
2779
+ && Number(column.notnull) === expected.notnull
2780
+ && Number(column.pk) === expected.pk;
2781
+ });
2782
+ }
2783
+
2784
+ // Triggers owned by the authority tables are forbidden outright, and so are
2785
+ // triggers on any other table whose body even references an authority table:
2786
+ // quoted or schema-qualified identifiers would otherwise evade write-shape
2787
+ // matching, so a mere mention fails closed.
2788
+ function hasUnexpectedWatchAuthorityTriggers(runtime, db) {
2789
+ const triggers = allParams(
2790
+ runtime,
2791
+ db,
2792
+ "SELECT tbl_name AS tbl_name, sql FROM sqlite_master WHERE type = 'trigger'",
2793
+ );
2794
+ const authorityMention = new RegExp(
2795
+ `(?:${WATCH_OWNER_TABLE}|${WATCH_GATE_TABLE})`,
2796
+ 'i',
2797
+ );
2798
+ return triggers.some(trigger => {
2799
+ const owned = [WATCH_OWNER_TABLE, WATCH_GATE_TABLE].includes(String(trigger.tbl_name).toLowerCase());
2800
+ return owned || (typeof trigger.sql === 'string' && authorityMention.test(trigger.sql));
2801
+ });
2802
+ }
2803
+
2804
+ // Inbound ON DELETE CASCADE foreign keys would make abort/release delete rows
2805
+ // in unrelated tables (or vice versa), so any referencing table fails closed.
2806
+ // Outbound cascades declared on the authority tables themselves are equally
2807
+ // forbidden: deleting a referenced parent must never erase authority rows.
2808
+ function hasForbiddenWatchOwnerForeignKeys(runtime, db) {
2809
+ const tables = allParams(
2810
+ runtime,
2811
+ db,
2812
+ "SELECT name FROM sqlite_master WHERE type = 'table' AND name NOT IN (?, ?)",
2813
+ [WATCH_OWNER_TABLE, WATCH_GATE_TABLE],
2814
+ );
2815
+ for (const { name } of tables) {
2816
+ const references = allParams(runtime, db, `PRAGMA foreign_key_list("${name.replace(/"/g, '""')}")`);
2817
+ if (references.some(reference => [WATCH_OWNER_TABLE, WATCH_GATE_TABLE]
2818
+ .includes(String(reference.table).toLowerCase()))) return true;
2819
+ }
2820
+ for (const table of [WATCH_OWNER_TABLE, WATCH_GATE_TABLE]) {
2821
+ if (allParams(runtime, db, `PRAGMA foreign_key_list("${table}")`).length > 0) return true;
2822
+ }
2823
+ return false;
2824
+ }
2825
+
2826
+ // Extra uniqueness constraints (e.g. UNIQUE(repo) ON CONFLICT REPLACE) let a
2827
+ // later insert silently delete an earlier authority row, so anything beyond
2828
+ // the migration's primary-key indexes fails closed.
2829
+ function hasUnexpectedWatchAuthorityIndexes(runtime, db) {
2830
+ for (const table of [WATCH_OWNER_TABLE, WATCH_GATE_TABLE]) {
2831
+ const indexes = allParams(runtime, db, `PRAGMA index_list("${table}")`);
2832
+ if (indexes.some(index => index.origin !== 'pk')) return true;
2833
+ }
2834
+ return false;
2835
+ }
2836
+
2837
+ function assertWatchOwnerSchema(runtime, db) {
2838
+ const rows = allParams(
2839
+ runtime,
2840
+ db,
2841
+ "SELECT name FROM sqlite_master WHERE type = 'table' AND name IN (?, ?)",
2842
+ [WATCH_OWNER_TABLE, WATCH_GATE_TABLE],
2843
+ );
2844
+ const tables = new Set(rows.map(row => row.name));
2845
+ if (!tables.has(WATCH_OWNER_TABLE) || !tables.has(WATCH_GATE_TABLE)) {
2846
+ throw watchOwnerStoreError('AUTHORITY_UNAVAILABLE', 'Watcher authority schema is not initialized; run Kernel migrations');
2847
+ }
2848
+ if (!hasExactWatchSchemaColumns(runtime, db, WATCH_OWNER_TABLE, WATCH_OWNER_SCHEMA_COLUMNS)
2849
+ || !hasExactWatchSchemaColumns(runtime, db, WATCH_GATE_TABLE, WATCH_GATE_SCHEMA_COLUMNS)
2850
+ || hasUnexpectedWatchAuthorityTriggers(runtime, db)
2851
+ || hasUnexpectedWatchAuthorityIndexes(runtime, db)) {
2852
+ throw watchOwnerStoreError('AUTHORITY_UNAVAILABLE', 'Watcher authority schema does not match the Kernel migration');
2853
+ }
2854
+ if (hasForbiddenWatchOwnerForeignKeys(runtime, db)) {
2855
+ throw watchOwnerStoreError('AUTHORITY_UNAVAILABLE', 'Watcher authority tables participate in a forbidden foreign key relationship');
2856
+ }
2857
+ }
2858
+
2859
+ const activeWatchOwnerConnections = new WeakSet();
2860
+
2861
+ function runWatchOwnerTransactionOnConnection(runtime, database, options, operation) {
2862
+ if (typeof operation !== 'function'
2863
+ || operation.constructor?.name === 'AsyncFunction') {
2864
+ throw watchOwnerStoreError('INVALID_OPERATION', 'Watcher authority transactions require a synchronous operation');
2865
+ }
2866
+ if (activeWatchOwnerConnections.has(database)) {
2867
+ throw watchOwnerStoreError('INVALID_OPERATION', 'Watcher authority transaction re-entry on the same connection is forbidden');
2868
+ }
2869
+ activeWatchOwnerConnections.add(database);
2870
+ const timeout = watchOwnerBusyTimeout(options);
2871
+ let active = false;
2872
+ let committed = false;
2873
+ try {
2874
+ let previousTimeout;
2875
+ let timeoutInstalled = false;
2876
+ let result;
2877
+ let transactionError;
2878
+ let restorationError;
2879
+ try {
2880
+ previousTimeout = Number(queryOne(runtime, database, 'PRAGMA busy_timeout;').timeout) || 0;
2881
+ execSql(runtime, database, `PRAGMA busy_timeout=${timeout};`);
2882
+ timeoutInstalled = true;
2883
+ execSql(runtime, database, 'PRAGMA foreign_keys=ON;');
2884
+ execSql(runtime, database, 'BEGIN IMMEDIATE;');
2885
+ active = true;
2886
+ assertWatchOwnerSchema(runtime, database);
2887
+ result = operation(database, nestedOperation => runWatchOwnerTransactionOnConnection(
2888
+ runtime, database, options, nestedOperation,
2889
+ ));
2890
+ if (result && typeof result.then === 'function') {
2891
+ throw watchOwnerStoreError('INVALID_OPERATION', 'Watcher authority transactions cannot return a thenable');
2892
+ }
2893
+ execSql(runtime, database, 'COMMIT;');
2894
+ active = false;
2895
+ committed = true;
2896
+ } catch (error) {
2897
+ if (active) rollbackTransaction(runtime, database);
2898
+ transactionError = /database is locked|SQLITE_BUSY/i.test(String(error?.message || ''))
2899
+ ? watchOwnerStoreError('AUTHORITY_UNAVAILABLE', `Watcher authority remained busy after ${timeout}ms`, error)
2900
+ : error;
2901
+ } finally {
2902
+ if (timeoutInstalled) {
2903
+ try {
2904
+ execSql(runtime, database, `PRAGMA busy_timeout=${previousTimeout};`);
2905
+ } catch (error) {
2906
+ restorationError = watchOwnerStoreError('AUTHORITY_UNAVAILABLE', 'Watcher authority timeout restoration failed', error);
2907
+ }
2908
+ }
2909
+ }
2910
+ if (transactionError) throw transactionError;
2911
+ if (restorationError && !committed) throw restorationError;
2912
+ return result;
2913
+ } finally {
2914
+ activeWatchOwnerConnections.delete(database);
2915
+ }
2916
+ }
2917
+
2918
+ function runWatchOwnerTransaction(runtime, databasePath, options, operation) {
2919
+ const validated = assertWatchOwnerDatabasePath(databasePath, options);
2920
+ const database = openWatchOwnerDatabase(runtime, validated);
2921
+ try {
2922
+ return runWatchOwnerTransactionOnConnection(runtime, database, options, operation);
2923
+ } finally {
2924
+ closeDatabase(database);
2925
+ }
2926
+ }
2927
+
2928
+ const WATCH_OWNER_PHASES = new Set(['starting', 'running', 'stop_requested', 'terminal_pending', 'complete', 'blocked']);
2929
+ const WATCH_OWNER_BLOCK_REASONS = new Set([
2930
+ 'legacy_live_pid',
2931
+ 'legacy_conflict',
2932
+ 'legacy_unreadable',
2933
+ 'legacy_lossy',
2934
+ 'legacy_receipt_unverified',
2935
+ ]);
2936
+ const WATCH_OWNER_GENERATION_OPERATIONS = new Set([
2937
+ 'reserveReopened', 'bindRunning', 'heartbeat', 'requestStop', 'recordTerminal',
2938
+ 'completeTerminal', 'abortStarting', 'releaseNonterminal', 'recoverDeadStarting',
2939
+ 'recoverDeadWatcher', 'recheckLegacyBlocked',
2940
+ ]);
2941
+ const WATCH_OWNER_MONOTONIC_OPERATIONS = new Set([
2942
+ 'reserveReopened', 'bindRunning', 'heartbeat', 'requestStop', 'recordTerminal',
2943
+ 'completeTerminal', 'recoverDeadStarting', 'recoverDeadWatcher', 'markLegacyBlocked',
2944
+ 'recheckLegacyBlocked', 'importLegacyStarting', 'importLegacyComplete',
2945
+ ]);
2946
+ const WATCH_OWNER_EVIDENCE_BOUND_OPERATIONS = new Set([
2947
+ 'reserveReopened', 'recordTerminal', 'completeTerminal', 'abortStarting',
2948
+ 'recoverDeadStarting', 'recoverDeadWatcher', 'markLegacyBlocked', 'recheckLegacyBlocked',
2949
+ 'importLegacyStarting', 'importLegacyComplete',
2950
+ ]);
2951
+ const WATCH_OWNER_SNAPSHOT_FIELDS = Object.freeze([
2952
+ 'repo', 'pr', 'version', 'generation', 'phase', 'controller_pid', 'watcher_pid',
2953
+ 'started_at', 'updated_at', 'heartbeat_at', 'terminal_receipt_id', 'block_reason',
2954
+ 'legacy_evidence_hash',
2955
+ ]);
2956
+ const WATCH_GATE_SNAPSHOT_FIELDS = Object.freeze([
2957
+ 'singleton', 'state', 'snapshot_hash', 'conflict_code', 'updated_at',
2958
+ ]);
2959
+ const WATCH_OWNER_MUTATION_FIELDS = Object.freeze([
2960
+ 'repo', 'pr', 'controllerPid', 'watcherPid', 'expectedControllerPid', 'generation',
2961
+ 'terminalReceiptId', 'expectedReceiptId', 'now', 'snapshotHash', 'legacyEvidenceHash',
2962
+ 'blockReason', 'action', 'expectedSnapshot', 'expectedGate',
2963
+ ]);
2964
+ const WATCH_GATE_STATES = new Set(['quarantined', 'conflict', 'complete']);
2965
+ const WATCH_GATE_CONFLICT_CODES = new Set([
2966
+ 'legacy_identity_unmappable',
2967
+ 'legacy_snapshot_changed',
2968
+ 'legacy_owner_conflict',
2969
+ ]);
2970
+ const WATCH_REPOSITORY = /^[a-z0-9_.-]+\/[a-z0-9_.-]+$/;
2971
+ const WATCH_SHA256 = /^[0-9a-f]{64}$/;
2972
+ const WATCH_TIMESTAMP = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/;
2973
+ const WATCH_OWNER_ENUMERATION_LIMIT = 4_096;
2974
+ const WATCH_OWNER_ENUMERATION_BYTES = 4 * 1024 * 1024;
2975
+
2976
+ function watchUtf8Bytes(value) {
2977
+ return typeof value === 'string' ? Buffer.byteLength(value, 'utf8') : -1;
2978
+ }
2979
+
2980
+ function watchTimestamp(value) {
2981
+ if (typeof value !== 'string' || watchUtf8Bytes(value) !== 24 || !WATCH_TIMESTAMP.test(value)) return false;
2982
+ try {
2983
+ return new Date(value).toISOString() === value;
2984
+ } catch {
2985
+ return false;
2986
+ }
2987
+ }
2988
+
2989
+ function positiveWatchPid(value) {
2990
+ return Number.isSafeInteger(value) && value > 0;
2991
+ }
2992
+
2993
+ function boundedWatchString(value, maxBytes) {
2994
+ const bytes = watchUtf8Bytes(value);
2995
+ return typeof value === 'string' && bytes > 0 && bytes <= maxBytes;
2996
+ }
2997
+
2998
+ function optionalWatchString(value, maxBytes) {
2999
+ return value == null || boundedWatchString(value, maxBytes);
3000
+ }
3001
+
3002
+ function validWatchRepository(value) {
3003
+ return boundedWatchString(value, 256) && WATCH_REPOSITORY.test(value);
3004
+ }
3005
+
3006
+ function validWatchHash(value) {
3007
+ return typeof value === 'string' && WATCH_SHA256.test(value);
3008
+ }
3009
+
3010
+ function validWatchOwnerRow(row) {
3011
+ if (!row || row.version !== 1 || !validWatchRepository(row.repo)
3012
+ || !Number.isSafeInteger(row.pr) || row.pr <= 0
3013
+ || !boundedWatchString(row.generation, 128)
3014
+ || !WATCH_OWNER_PHASES.has(row.phase) || !watchTimestamp(row.started_at)
3015
+ || !watchTimestamp(row.updated_at) || row.updated_at < row.started_at
3016
+ || !optionalWatchString(row.terminal_receipt_id, 256)) return false;
3017
+ const controller = row.controller_pid;
3018
+ const watcher = row.watcher_pid;
3019
+ if ((controller != null && !positiveWatchPid(controller)) || (watcher != null && !positiveWatchPid(watcher))) return false;
3020
+ if (row.heartbeat_at != null && (!watchTimestamp(row.heartbeat_at)
3021
+ || row.heartbeat_at < row.started_at || row.heartbeat_at > row.updated_at)) return false;
3022
+ if (row.legacy_evidence_hash != null && !validWatchHash(row.legacy_evidence_hash)) return false;
3023
+ if (row.phase === 'starting') {
3024
+ return controller != null && watcher == null && row.heartbeat_at == null
3025
+ && row.terminal_receipt_id == null && row.block_reason == null;
3026
+ }
3027
+ if (row.phase === 'running' || row.phase === 'stop_requested') {
3028
+ return controller == null && watcher != null && row.heartbeat_at != null
3029
+ && row.terminal_receipt_id == null && row.block_reason == null;
3030
+ }
3031
+ if (row.phase === 'terminal_pending') {
3032
+ return controller == null && watcher != null && row.heartbeat_at != null
3033
+ && row.terminal_receipt_id != null && row.block_reason == null;
3034
+ }
3035
+ if (row.phase === 'complete') {
3036
+ return controller == null && watcher == null && row.heartbeat_at == null
3037
+ && row.terminal_receipt_id != null && row.block_reason == null;
3038
+ }
3039
+ if (!WATCH_OWNER_BLOCK_REASONS.has(row.block_reason) || row.legacy_evidence_hash == null
3040
+ || controller != null || row.heartbeat_at != null) return false;
3041
+ return row.block_reason === 'legacy_live_pid' ? watcher != null : watcher == null;
3042
+ }
3043
+
3044
+ function validWatchGateRow(row) {
3045
+ if (!row || row.singleton !== 1 || !WATCH_GATE_STATES.has(row.state)
3046
+ || !watchTimestamp(row.updated_at)) return false;
3047
+ if (row.state === 'quarantined') {
3048
+ return (row.snapshot_hash == null || validWatchHash(row.snapshot_hash)) && row.conflict_code == null;
3049
+ }
3050
+ if (!validWatchHash(row.snapshot_hash)) return false;
3051
+ return row.state === 'complete'
3052
+ ? row.conflict_code == null
3053
+ : WATCH_GATE_CONFLICT_CODES.has(row.conflict_code);
3054
+ }
3055
+
3056
+ function copyWatchGateSnapshot(gate) {
3057
+ if (gate === null) return null;
3058
+ try {
3059
+ const copy = Object.fromEntries(WATCH_GATE_SNAPSHOT_FIELDS.map(field => [field, gate[field]]));
3060
+ return validWatchGateRow(copy) ? copy : undefined;
3061
+ } catch {
3062
+ return undefined;
3063
+ }
3064
+ }
3065
+
3066
+ function sameWatchGateSnapshot(current, expected) {
3067
+ if (current == null || expected == null) return current == null && expected == null;
3068
+ return WATCH_GATE_SNAPSHOT_FIELDS.every(field => Object.is(current[field], expected[field]));
3069
+ }
3070
+
3071
+ function readWatchOwnerRow(runtime, database, input) {
3072
+ return allParams(
3073
+ runtime,
3074
+ database,
3075
+ `SELECT * FROM ${WATCH_OWNER_TABLE} WHERE repo = ? AND pr = ?`,
3076
+ [input.repo, input.pr],
3077
+ )[0] || null;
3078
+ }
3079
+
3080
+ function saveWatchOwnerRow(runtime, database, row, insert = false) {
3081
+ const columns = [
3082
+ row.repo, row.pr, row.version, row.generation, row.phase, row.controller_pid,
3083
+ row.watcher_pid, row.started_at, row.updated_at, row.heartbeat_at,
3084
+ row.terminal_receipt_id, row.block_reason, row.legacy_evidence_hash,
3085
+ ];
3086
+ if (insert) {
3087
+ runParams(runtime, database, `INSERT INTO ${WATCH_OWNER_TABLE}
3088
+ (repo, pr, version, generation, phase, controller_pid, watcher_pid, started_at,
3089
+ updated_at, heartbeat_at, terminal_receipt_id, block_reason, legacy_evidence_hash)
3090
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, columns);
3091
+ return;
3092
+ }
3093
+ runParams(runtime, database, `UPDATE ${WATCH_OWNER_TABLE} SET
3094
+ version = ?, generation = ?, phase = ?, controller_pid = ?, watcher_pid = ?,
3095
+ started_at = ?, updated_at = ?, heartbeat_at = ?, terminal_receipt_id = ?,
3096
+ block_reason = ?, legacy_evidence_hash = ? WHERE repo = ? AND pr = ?`, [
3097
+ row.version, row.generation, row.phase, row.controller_pid, row.watcher_pid,
3098
+ row.started_at, row.updated_at, row.heartbeat_at, row.terminal_receipt_id,
3099
+ row.block_reason, row.legacy_evidence_hash, row.repo, row.pr,
3100
+ ]);
3101
+ }
3102
+
3103
+ function deleteWatchOwnerRow(runtime, database, input) {
3104
+ runParams(runtime, database, `DELETE FROM ${WATCH_OWNER_TABLE} WHERE repo = ? AND pr = ?`, [input.repo, input.pr]);
3105
+ }
3106
+
3107
+ function watchOwnerResult(ok, changed, reason, row = null) {
3108
+ return { ok, changed, reason, row };
3109
+ }
3110
+
3111
+ function watchOwnerMismatch(current, input, phases) {
3112
+ if (!current) return 'absent';
3113
+ if (!validWatchOwnerRow(current)) return 'corrupt';
3114
+ if (input.generation != null && current.generation !== input.generation) return 'generation_mismatch';
3115
+ if (phases && ![].concat(phases).includes(current.phase)) return 'phase_mismatch';
3116
+ return null;
3117
+ }
3118
+
3119
+ function watchOwnerUpdatedAtRegresses(operation, current, input) {
3120
+ if (!current || !WATCH_OWNER_MONOTONIC_OPERATIONS.has(operation)) return false;
3121
+ if (operation === 'recheckLegacyBlocked' && input.action === 'release') return false;
3122
+ return [current.updated_at, current.heartbeat_at]
3123
+ .filter(Boolean)
3124
+ .some(timestamp => Date.parse(input.now) < Date.parse(timestamp));
3125
+ }
3126
+
3127
+ function sameWatchOwnerSnapshot(current, expected) {
3128
+ if (current == null || expected == null) return current == null && expected == null;
3129
+ return WATCH_OWNER_SNAPSHOT_FIELDS.every(field => Object.is(current[field], expected[field]));
3130
+ }
3131
+
3132
+ function startingWatchOwnerRow(input, generation = randomUUID()) {
3133
+ return {
3134
+ repo: input.repo,
3135
+ pr: input.pr,
3136
+ version: 1,
3137
+ generation,
3138
+ phase: 'starting',
3139
+ controller_pid: input.controllerPid,
3140
+ watcher_pid: null,
3141
+ started_at: input.now,
3142
+ updated_at: input.now,
3143
+ heartbeat_at: null,
3144
+ terminal_receipt_id: null,
3145
+ block_reason: null,
3146
+ legacy_evidence_hash: null,
3147
+ };
3148
+ }
3149
+
3150
+ function validWatchOwnerIdentity(input) {
3151
+ return validWatchRepository(input?.repo) && Number.isSafeInteger(input.pr) && input.pr > 0;
3152
+ }
3153
+
3154
+ function captureWatchOwnerIdentity(input) {
3155
+ try {
3156
+ return { repo: input?.repo, pr: input?.pr };
3157
+ } catch {
3158
+ return null;
3159
+ }
3160
+ }
3161
+
3162
+ function validWatchOwnerMutationInput(operation, input) {
3163
+ if (!validWatchOwnerIdentity(input)) return false;
3164
+ if (WATCH_OWNER_EVIDENCE_BOUND_OPERATIONS.has(operation)
3165
+ && (!Object.prototype.hasOwnProperty.call(input, 'expectedSnapshot')
3166
+ || input.expectedSnapshot === undefined)) return false;
3167
+ if (!['abortStarting', 'releaseNonterminal'].includes(operation) && !watchTimestamp(input.now)) return false;
3168
+ if (!['controllerPid', 'watcherPid', 'expectedControllerPid'].every(field => (
3169
+ input[field] == null || positiveWatchPid(input[field])
3170
+ ))) return false;
3171
+ if (!['generation', 'terminalReceiptId', 'expectedReceiptId'].every(field => (
3172
+ input[field] == null || boundedWatchString(input[field], field === 'generation' ? 128 : 256)
3173
+ ))) return false;
3174
+ if (WATCH_OWNER_GENERATION_OPERATIONS.has(operation)
3175
+ && !boundedWatchString(input.generation, 128)) return false;
3176
+ if (!['snapshotHash', 'legacyEvidenceHash'].every(field => (
3177
+ input[field] == null || validWatchHash(input[field])
3178
+ ))) return false;
3179
+ if (input.blockReason != null && !WATCH_OWNER_BLOCK_REASONS.has(input.blockReason)) return false;
3180
+ if (operation === 'recheckLegacyBlocked') {
3181
+ return ['release', 'complete'].includes(input.action);
3182
+ }
3183
+ return input.action == null || ['release', 'complete'].includes(input.action);
3184
+ }
3185
+
3186
+ function captureWatchOwnerMutationInput(input) {
3187
+ try {
3188
+ const ownFields = new Set(Object.getOwnPropertyNames(input));
3189
+ const prepared = {};
3190
+ for (const field of WATCH_OWNER_MUTATION_FIELDS) {
3191
+ if (ownFields.has(field)) prepared[field] = input[field];
3192
+ }
3193
+ return prepared;
3194
+ } catch {
3195
+ return null;
3196
+ }
3197
+ }
3198
+
3199
+ function copyWatchOwnerSnapshot(snapshot) {
3200
+ if (snapshot === null) return null;
3201
+ if (!snapshot || typeof snapshot !== 'object' || Array.isArray(snapshot)) return undefined;
3202
+ try {
3203
+ Object.keys(snapshot);
3204
+ const copy = {};
3205
+ for (const field of WATCH_OWNER_SNAPSHOT_FIELDS) {
3206
+ const value = snapshot[field];
3207
+ if (value !== null && !['number', 'string'].includes(typeof value)) return undefined;
3208
+ copy[field] = value;
3209
+ }
3210
+ return validWatchOwnerRow(copy) ? Object.freeze(copy) : undefined;
3211
+ } catch {
3212
+ return undefined;
3213
+ }
3214
+ }
3215
+
3216
+ function prepareWatchOwnerMutationInput(operation, input) {
3217
+ if (!input || typeof input !== 'object' || Array.isArray(input)) return null;
3218
+ const prepared = captureWatchOwnerMutationInput(input);
3219
+ if (!prepared) return null;
3220
+ if (!validWatchOwnerMutationInput(operation, prepared)) return null;
3221
+ if (Object.prototype.hasOwnProperty.call(prepared, 'expectedSnapshot')) {
3222
+ const expectedSnapshot = copyWatchOwnerSnapshot(prepared.expectedSnapshot);
3223
+ if (expectedSnapshot === undefined) return null;
3224
+ prepared.expectedSnapshot = expectedSnapshot;
3225
+ }
3226
+ if (Object.prototype.hasOwnProperty.call(prepared, 'expectedGate')) {
3227
+ const expectedGate = copyWatchGateSnapshot(prepared.expectedGate);
3228
+ if (expectedGate === undefined) return null;
3229
+ prepared.expectedGate = expectedGate;
3230
+ }
3231
+ return Object.freeze(prepared);
3232
+ }
3233
+
3234
+ function applyWatchOwnerOperation(runtime, databasePath, options, operation, rawInput) {
3235
+ const input = prepareWatchOwnerMutationInput(operation, rawInput);
3236
+ if (!input) return watchOwnerResult(false, false, 'invalid_input');
3237
+ return runWatchOwnerTransaction(runtime, databasePath, options, database => {
3238
+ const current = readWatchOwnerRow(runtime, database, input);
3239
+ if (current && !validWatchOwnerRow(current)) return watchOwnerResult(false, false, 'corrupt', current);
3240
+ if (Object.prototype.hasOwnProperty.call(input, 'expectedSnapshot')
3241
+ && !sameWatchOwnerSnapshot(current, input.expectedSnapshot)) {
3242
+ return watchOwnerResult(false, false, 'stale_evidence', current);
3243
+ }
3244
+ if (Object.prototype.hasOwnProperty.call(input, 'expectedGate')) {
3245
+ const currentGate = allParams(runtime, database,
3246
+ `SELECT * FROM ${WATCH_GATE_TABLE} WHERE singleton = 1`)[0] || null;
3247
+ if (currentGate && !validWatchGateRow(currentGate)) {
3248
+ return watchOwnerResult(false, false, 'corrupt', current);
3249
+ }
3250
+ if (!sameWatchGateSnapshot(currentGate, input.expectedGate)) {
3251
+ return watchOwnerResult(false, false, 'stale_evidence', current);
3252
+ }
3253
+ }
3254
+ if (watchOwnerUpdatedAtRegresses(operation, current, input)) {
3255
+ return watchOwnerResult(false, false, 'stale_evidence', current);
3256
+ }
3257
+ let row;
3258
+ let mismatch;
3259
+ switch (operation) {
3260
+ case 'reserveStarting':
3261
+ if (current) return watchOwnerResult(false, false, 'busy', current);
3262
+ row = startingWatchOwnerRow(input);
3263
+ if (!validWatchOwnerRow(row)) return watchOwnerResult(false, false, 'invalid_transition');
3264
+ saveWatchOwnerRow(runtime, database, row, true);
3265
+ return watchOwnerResult(true, true, 'acquired', row);
3266
+ case 'reserveReopened':
3267
+ mismatch = watchOwnerMismatch(current, input, 'complete');
3268
+ if (mismatch) return watchOwnerResult(false, false, mismatch, current);
3269
+ if (current.terminal_receipt_id !== input.expectedReceiptId) return watchOwnerResult(false, false, 'receipt_mismatch', current);
3270
+ row = { ...startingWatchOwnerRow(input), legacy_evidence_hash: current.legacy_evidence_hash };
3271
+ if (!validWatchOwnerRow(row)) return watchOwnerResult(false, false, 'invalid_transition', current);
3272
+ saveWatchOwnerRow(runtime, database, row);
3273
+ return watchOwnerResult(true, true, 'reopened', row);
3274
+ case 'bindRunning':
3275
+ if (current?.phase === 'running' && current.generation === input.generation
3276
+ && current.watcher_pid === input.watcherPid) return watchOwnerResult(true, false, 'idempotent', current);
3277
+ mismatch = watchOwnerMismatch(current, input, 'starting');
3278
+ if (mismatch) return watchOwnerResult(false, false, mismatch, current);
3279
+ if (current.controller_pid !== input.controllerPid) return watchOwnerResult(false, false, 'controller_pid_mismatch', current);
3280
+ row = { ...current, phase: 'running', controller_pid: null, watcher_pid: input.watcherPid,
3281
+ updated_at: input.now, heartbeat_at: input.now };
3282
+ break;
3283
+ case 'heartbeat':
3284
+ mismatch = watchOwnerMismatch(current, input, ['running', 'stop_requested']);
3285
+ if (mismatch) return watchOwnerResult(false, false, mismatch, current);
3286
+ if (current.watcher_pid !== input.watcherPid) return watchOwnerResult(false, false, 'pid_mismatch', current);
3287
+ if (Date.parse(input.now) < Math.max(Date.parse(current.updated_at), Date.parse(current.heartbeat_at))) {
3288
+ return watchOwnerResult(false, false, 'stale_evidence', current);
3289
+ }
3290
+ row = { ...current, updated_at: input.now, heartbeat_at: input.now };
3291
+ break;
3292
+ case 'requestStop':
3293
+ if (current?.phase === 'stop_requested' && current.generation === input.generation
3294
+ && current.watcher_pid === input.watcherPid) return watchOwnerResult(true, false, 'idempotent', current);
3295
+ mismatch = watchOwnerMismatch(current, input, 'running');
3296
+ if (mismatch) return watchOwnerResult(false, false, mismatch, current);
3297
+ if (current.watcher_pid !== input.watcherPid) return watchOwnerResult(false, false, 'pid_mismatch', current);
3298
+ row = { ...current, phase: 'stop_requested', updated_at: input.now };
3299
+ break;
3300
+ case 'recordTerminal':
3301
+ if (current?.phase === 'terminal_pending' && current.generation === input.generation
3302
+ && current.watcher_pid === input.watcherPid
3303
+ && current.terminal_receipt_id === input.terminalReceiptId) return watchOwnerResult(true, false, 'idempotent', current);
3304
+ mismatch = watchOwnerMismatch(current, input, ['running', 'stop_requested']);
3305
+ if (mismatch) return watchOwnerResult(false, false, mismatch, current);
3306
+ if (current.watcher_pid !== input.watcherPid) return watchOwnerResult(false, false, 'pid_mismatch', current);
3307
+ row = { ...current, phase: 'terminal_pending', terminal_receipt_id: input.terminalReceiptId, updated_at: input.now };
3308
+ break;
3309
+ case 'completeTerminal':
3310
+ if (current?.phase === 'complete' && current.generation === input.generation
3311
+ && current.terminal_receipt_id === input.terminalReceiptId) return watchOwnerResult(true, false, 'idempotent', current);
3312
+ mismatch = watchOwnerMismatch(current, input, 'terminal_pending');
3313
+ if (mismatch) return watchOwnerResult(false, false, mismatch, current);
3314
+ if (current.watcher_pid !== input.watcherPid) return watchOwnerResult(false, false, 'pid_mismatch', current);
3315
+ if (current.terminal_receipt_id !== input.terminalReceiptId) return watchOwnerResult(false, false, 'receipt_mismatch', current);
3316
+ row = { ...current, phase: 'complete', watcher_pid: null, heartbeat_at: null, updated_at: input.now };
3317
+ break;
3318
+ case 'abortStarting':
3319
+ mismatch = watchOwnerMismatch(current, input, 'starting');
3320
+ if (mismatch) return watchOwnerResult(false, false, mismatch, current);
3321
+ if (current.controller_pid !== input.controllerPid) return watchOwnerResult(false, false, 'controller_pid_mismatch', current);
3322
+ deleteWatchOwnerRow(runtime, database, input);
3323
+ return watchOwnerResult(true, true, 'aborted');
3324
+ case 'releaseNonterminal':
3325
+ mismatch = watchOwnerMismatch(current, input, 'stop_requested');
3326
+ if (mismatch) return watchOwnerResult(false, false, mismatch, current);
3327
+ if (current.watcher_pid !== input.watcherPid) return watchOwnerResult(false, false, 'pid_mismatch', current);
3328
+ deleteWatchOwnerRow(runtime, database, input);
3329
+ return watchOwnerResult(true, true, 'released');
3330
+ case 'recoverDeadStarting':
3331
+ mismatch = watchOwnerMismatch(current, input, 'starting');
3332
+ if (mismatch) return watchOwnerResult(false, false, mismatch, current);
3333
+ if (current.controller_pid !== input.expectedControllerPid) return watchOwnerResult(false, false, 'controller_pid_mismatch', current);
3334
+ row = { ...startingWatchOwnerRow(input), legacy_evidence_hash: current.legacy_evidence_hash };
3335
+ break;
3336
+ case 'recoverDeadWatcher':
3337
+ // `stop_requested` is a nonterminal watcher-owned phase exactly like
3338
+ // `running`; a dead watcher in either state recovers into a fresh
3339
+ // `starting` row.
3340
+ mismatch = watchOwnerMismatch(current, input, ['running', 'stop_requested']);
3341
+ if (mismatch) return watchOwnerResult(false, false, mismatch, current);
3342
+ if (current.watcher_pid !== input.watcherPid) return watchOwnerResult(false, false, 'pid_mismatch', current);
3343
+ row = { ...startingWatchOwnerRow(input), legacy_evidence_hash: current.legacy_evidence_hash };
3344
+ break;
3345
+ case 'importLegacyStarting': {
3346
+ const gate = allParams(runtime, database,
3347
+ `SELECT * FROM ${WATCH_GATE_TABLE} WHERE singleton = 1`)[0] || null;
3348
+ if (!validWatchGateRow(gate)) return watchOwnerResult(false, false, gate ? 'corrupt' : 'gate_mismatch', current);
3349
+ if (gate.state !== 'quarantined' || gate.snapshot_hash !== input.snapshotHash) {
3350
+ return watchOwnerResult(false, false, 'gate_mismatch', current);
3351
+ }
3352
+ if (current) {
3353
+ const same = validWatchOwnerRow(current) && current.phase === 'starting'
3354
+ && current.controller_pid === input.controllerPid
3355
+ && current.legacy_evidence_hash === input.legacyEvidenceHash;
3356
+ return watchOwnerResult(same, false, same ? 'idempotent' : 'owner_conflict', current);
3357
+ }
3358
+ row = { ...startingWatchOwnerRow(input), legacy_evidence_hash: input.legacyEvidenceHash };
3359
+ if (!validWatchOwnerRow(row)) return watchOwnerResult(false, false, 'invalid_transition');
3360
+ saveWatchOwnerRow(runtime, database, row, true);
3361
+ return watchOwnerResult(true, true, 'imported', row);
3362
+ }
3363
+ case 'markLegacyBlocked':
3364
+ {
3365
+ const gate = allParams(runtime, database,
3366
+ `SELECT * FROM ${WATCH_GATE_TABLE} WHERE singleton = 1`)[0] || null;
3367
+ if (!validWatchGateRow(gate)) return watchOwnerResult(false, false, gate ? 'corrupt' : 'gate_mismatch', current);
3368
+ if (gate.state !== 'quarantined' || gate.snapshot_hash !== input.snapshotHash) {
3369
+ return watchOwnerResult(false, false, 'gate_mismatch', current);
3370
+ }
3371
+ }
3372
+ if (current) {
3373
+ const same = validWatchOwnerRow(current) && current.phase === 'blocked'
3374
+ && current.controller_pid == null && current.watcher_pid === input.watcherPid
3375
+ && current.terminal_receipt_id === input.terminalReceiptId
3376
+ && current.block_reason === input.blockReason
3377
+ && current.legacy_evidence_hash === input.legacyEvidenceHash;
3378
+ return watchOwnerResult(same, false, same ? 'idempotent' : 'owner_conflict', current);
3379
+ }
3380
+ row = { ...startingWatchOwnerRow(input), phase: 'blocked', controller_pid: null,
3381
+ watcher_pid: input.watcherPid, terminal_receipt_id: input.terminalReceiptId,
3382
+ block_reason: input.blockReason, legacy_evidence_hash: input.legacyEvidenceHash };
3383
+ if (!validWatchOwnerRow(row)) return watchOwnerResult(false, false, 'invalid_transition');
3384
+ saveWatchOwnerRow(runtime, database, row, true);
3385
+ return watchOwnerResult(true, true, 'blocked', row);
3386
+ case 'recheckLegacyBlocked':
3387
+ mismatch = watchOwnerMismatch(current, input, 'blocked');
3388
+ if (mismatch) return watchOwnerResult(false, false, mismatch, current);
3389
+ if (current.legacy_evidence_hash !== input.legacyEvidenceHash) return watchOwnerResult(false, false, 'evidence_mismatch', current);
3390
+ if ((current.block_reason === 'legacy_live_pid' || input.watcherPid != null)
3391
+ && current.watcher_pid !== input.watcherPid) {
3392
+ return watchOwnerResult(false, false, 'pid_mismatch', current);
3393
+ }
3394
+ if (input.action === 'release') {
3395
+ if (current.block_reason !== 'legacy_live_pid') {
3396
+ return watchOwnerResult(false, false, 'invalid_transition', current);
3397
+ }
3398
+ deleteWatchOwnerRow(runtime, database, input);
3399
+ return watchOwnerResult(true, true, 'released');
3400
+ }
3401
+ row = { ...current, phase: 'complete', watcher_pid: null, heartbeat_at: null,
3402
+ terminal_receipt_id: input.terminalReceiptId, block_reason: null, updated_at: input.now };
3403
+ break;
3404
+ case 'importLegacyComplete': {
3405
+ const gate = allParams(runtime, database,
3406
+ `SELECT * FROM ${WATCH_GATE_TABLE} WHERE singleton = 1`)[0] || null;
3407
+ if (!validWatchGateRow(gate)) return watchOwnerResult(false, false, gate ? 'corrupt' : 'gate_mismatch', current);
3408
+ if (gate.state !== 'quarantined' || gate.snapshot_hash !== input.snapshotHash) {
3409
+ return watchOwnerResult(false, false, 'gate_mismatch', current);
3410
+ }
3411
+ if (current) {
3412
+ const same = validWatchOwnerRow(current) && current.phase === 'complete'
3413
+ && current.legacy_evidence_hash === input.legacyEvidenceHash
3414
+ && current.terminal_receipt_id === input.terminalReceiptId;
3415
+ return watchOwnerResult(same, false, same ? 'idempotent' : 'owner_conflict', current);
3416
+ }
3417
+ row = { ...startingWatchOwnerRow(input), phase: 'complete', controller_pid: null,
3418
+ terminal_receipt_id: input.terminalReceiptId, legacy_evidence_hash: input.legacyEvidenceHash };
3419
+ if (!validWatchOwnerRow(row)) return watchOwnerResult(false, false, 'invalid_transition');
3420
+ saveWatchOwnerRow(runtime, database, row, true);
3421
+ return watchOwnerResult(true, true, 'imported', row);
3422
+ }
3423
+ default:
3424
+ throw watchOwnerStoreError('INVALID_OPERATION', 'Unknown watcher authority operation');
3425
+ }
3426
+ if (!validWatchOwnerRow(row)) return watchOwnerResult(false, false, 'invalid_transition', current);
3427
+ saveWatchOwnerRow(runtime, database, row);
3428
+ return watchOwnerResult(true, true, {
3429
+ bindRunning: 'bound',
3430
+ heartbeat: 'heartbeat',
3431
+ requestStop: 'stop_requested',
3432
+ recordTerminal: 'terminal_pending',
3433
+ completeTerminal: 'complete',
3434
+ recoverDeadStarting: 'recovered',
3435
+ recoverDeadWatcher: 'recovered',
3436
+ recheckLegacyBlocked: 'complete',
3437
+ }[operation], row);
3438
+ });
3439
+ }
3440
+
3441
+ function readWatchOwner(runtime, databasePath, options, input) {
3442
+ const identity = captureWatchOwnerIdentity(input);
3443
+ if (!identity || !validWatchOwnerIdentity(identity)) {
3444
+ return watchOwnerResult(false, false, 'invalid_input');
3445
+ }
3446
+ return runWatchOwnerTransaction(runtime, databasePath, options, database => {
3447
+ const row = readWatchOwnerRow(runtime, database, identity);
3448
+ if (!row) return watchOwnerResult(true, false, 'absent');
3449
+ return validWatchOwnerRow(row)
3450
+ ? watchOwnerResult(true, false, 'read', row)
3451
+ : watchOwnerResult(false, false, 'corrupt', row);
3452
+ });
3453
+ }
3454
+
3455
+ function listWatchOwners(runtime, databasePath, options) {
3456
+ return runWatchOwnerTransaction(runtime, databasePath, options, database => {
3457
+ const rows = allParams(runtime, database,
3458
+ `SELECT * FROM ${WATCH_OWNER_TABLE} ORDER BY repo, pr LIMIT ?`,
3459
+ [WATCH_OWNER_ENUMERATION_LIMIT + 1]);
3460
+ if (rows.length > WATCH_OWNER_ENUMERATION_LIMIT) {
3461
+ return { ok: false, changed: false, reason: 'enumeration_overflow', rows: [] };
3462
+ }
3463
+ let bytes = 0;
3464
+ for (const row of rows) {
3465
+ if (!validWatchOwnerRow(row)) return { ok: false, changed: false, reason: 'corrupt', rows: [] };
3466
+ bytes += Buffer.byteLength(JSON.stringify(row), 'utf8');
3467
+ if (bytes > WATCH_OWNER_ENUMERATION_BYTES) {
3468
+ return { ok: false, changed: false, reason: 'enumeration_overflow', rows: [] };
3469
+ }
3470
+ }
3471
+ return { ok: true, changed: false, reason: 'read', rows };
3472
+ });
3473
+ }
3474
+
3475
+ function readWatchGate(runtime, databasePath, options) {
3476
+ return runWatchOwnerTransaction(runtime, databasePath, options, database => {
3477
+ const gate = allParams(runtime, database,
3478
+ `SELECT * FROM ${WATCH_GATE_TABLE} WHERE singleton = 1`)[0] || null;
3479
+ if (!gate) return { ok: false, changed: false, reason: 'absent', gate: null };
3480
+ return validWatchGateRow(gate)
3481
+ ? { ok: true, changed: false, reason: 'read', gate }
3482
+ : { ok: false, changed: false, reason: 'corrupt', gate };
3483
+ });
3484
+ }
3485
+
3486
+ function validWatchGateMutationInput(operation, input) {
3487
+ if (!input || !watchTimestamp(input.now)) return false;
3488
+ if (operation === 'publishQuarantine') return true;
3489
+ if (operation === 'retryConflict') {
3490
+ return validWatchHash(input.expectedSnapshotHash)
3491
+ && WATCH_GATE_CONFLICT_CODES.has(input.expectedConflictCode)
3492
+ && validWatchHash(input.replacementSnapshotHash)
3493
+ && input.replacementSnapshotHash !== input.expectedSnapshotHash;
3494
+ }
3495
+ if (!validWatchHash(input.snapshotHash)) return false;
3496
+ return operation !== 'publishConflict' || WATCH_GATE_CONFLICT_CODES.has(input.conflictCode);
3497
+ }
3498
+
3499
+ function captureWatchGateMutationInput(operation, input) {
3500
+ if (!input) return null;
3501
+ try {
3502
+ const captured = { now: input.now };
3503
+ if (Object.prototype.hasOwnProperty.call(input, 'expectedGate')) {
3504
+ const expectedGate = copyWatchGateSnapshot(input.expectedGate);
3505
+ if (expectedGate === undefined) return null;
3506
+ captured.expectedGate = expectedGate;
3507
+ }
3508
+ if (operation === 'retryConflict') {
3509
+ captured.expectedSnapshotHash = input.expectedSnapshotHash;
3510
+ captured.expectedConflictCode = input.expectedConflictCode;
3511
+ captured.replacementSnapshotHash = input.replacementSnapshotHash;
3512
+ } else {
3513
+ if (operation !== 'publishQuarantine') captured.snapshotHash = input.snapshotHash;
3514
+ if (operation === 'publishConflict') captured.conflictCode = input.conflictCode;
3515
+ }
3516
+ return Object.freeze(captured);
3517
+ } catch {
3518
+ return null;
3519
+ }
3520
+ }
3521
+
3522
+ function applyWatchGateOperation(runtime, databasePath, options, operation, input) {
3523
+ input = captureWatchGateMutationInput(operation, input);
3524
+ if (!validWatchGateMutationInput(operation, input)) {
3525
+ return { ok: false, changed: false, reason: 'invalid_input', gate: null };
3526
+ }
3527
+ return runWatchOwnerTransaction(runtime, databasePath, options, database => {
3528
+ const current = allParams(runtime, database,
3529
+ `SELECT * FROM ${WATCH_GATE_TABLE} WHERE singleton = 1`)[0] || null;
3530
+ if (current && !validWatchGateRow(current)) return { ok: false, changed: false, reason: 'corrupt', gate: current };
3531
+ if (Object.prototype.hasOwnProperty.call(input, 'expectedGate')
3532
+ && !sameWatchGateSnapshot(current, input.expectedGate)) {
3533
+ return { ok: false, changed: false, reason: 'stale_evidence', gate: current };
3534
+ }
3535
+ if (current && input.now < current.updated_at) {
3536
+ return { ok: false, changed: false, reason: 'stale_evidence', gate: current };
3537
+ }
3538
+ if (operation === 'publishQuarantine') {
3539
+ if (current) return { ok: current.state === 'quarantined', changed: false,
3540
+ reason: current.state === 'quarantined' ? 'idempotent' : 'gate_conflict', gate: current };
3541
+ runParams(runtime, database, `INSERT INTO ${WATCH_GATE_TABLE}
3542
+ (singleton, state, snapshot_hash, conflict_code, updated_at) VALUES (1, 'quarantined', NULL, NULL, ?)`, [input.now]);
3543
+ return { ok: true, changed: true, reason: 'quarantined', gate: {
3544
+ singleton: 1, state: 'quarantined', snapshot_hash: null, conflict_code: null, updated_at: input.now,
3545
+ } };
3546
+ }
3547
+ if (!current) {
3548
+ return { ok: false, changed: false, reason: 'absent', gate: null };
3549
+ }
3550
+ if (operation === 'bindSnapshot') {
3551
+ if (current.state !== 'quarantined') return { ok: false, changed: false, reason: 'phase_mismatch', gate: current };
3552
+ if (current.snapshot_hash === input.snapshotHash) return { ok: true, changed: false, reason: 'idempotent', gate: current };
3553
+ if (current.snapshot_hash != null) return { ok: false, changed: false, reason: 'snapshot_mismatch', gate: current };
3554
+ runParams(runtime, database, `UPDATE ${WATCH_GATE_TABLE} SET snapshot_hash = ?, updated_at = ? WHERE singleton = 1`,
3555
+ [input.snapshotHash, input.now]);
3556
+ return { ok: true, changed: true, reason: 'bound', gate: { ...current, snapshot_hash: input.snapshotHash, updated_at: input.now } };
3557
+ }
3558
+ if (operation === 'publishConflict') {
3559
+ if (!WATCH_SHA256.test(input.snapshotHash) || !WATCH_GATE_CONFLICT_CODES.has(input.conflictCode)) {
3560
+ return { ok: false, changed: false, reason: 'invalid_conflict', gate: current };
3561
+ }
3562
+ if (current.state === 'conflict') {
3563
+ const idempotent = current.snapshot_hash === input.snapshotHash
3564
+ && current.conflict_code === input.conflictCode;
3565
+ return { ok: idempotent, changed: false,
3566
+ reason: idempotent ? 'idempotent' : 'conflict_mismatch', gate: current };
3567
+ }
3568
+ if (current.state !== 'quarantined') return { ok: false, changed: false, reason: 'phase_mismatch', gate: current };
3569
+ if (current.snapshot_hash != null && current.snapshot_hash !== input.snapshotHash) {
3570
+ return { ok: false, changed: false, reason: 'snapshot_mismatch', gate: current };
3571
+ }
3572
+ runParams(runtime, database, `UPDATE ${WATCH_GATE_TABLE} SET state = 'conflict', snapshot_hash = ?, conflict_code = ?, updated_at = ? WHERE singleton = 1`,
3573
+ [input.snapshotHash, input.conflictCode, input.now]);
3574
+ return { ok: true, changed: true, reason: 'conflict', gate: { ...current, state: 'conflict', snapshot_hash: input.snapshotHash,
3575
+ conflict_code: input.conflictCode, updated_at: input.now } };
3576
+ }
3577
+ if (operation === 'retryConflict') {
3578
+ if (current.state !== 'conflict') return { ok: false, changed: false, reason: 'phase_mismatch', gate: current };
3579
+ if (current.snapshot_hash !== input.expectedSnapshotHash
3580
+ || current.conflict_code !== input.expectedConflictCode) {
3581
+ return { ok: false, changed: false, reason: 'conflict_mismatch', gate: current };
3582
+ }
3583
+ runParams(runtime, database, `UPDATE ${WATCH_GATE_TABLE}
3584
+ SET state = 'quarantined', snapshot_hash = ?, conflict_code = NULL, updated_at = ? WHERE singleton = 1`,
3585
+ [input.replacementSnapshotHash, input.now]);
3586
+ return { ok: true, changed: true, reason: 'retry_bound', gate: {
3587
+ ...current, state: 'quarantined', snapshot_hash: input.replacementSnapshotHash,
3588
+ conflict_code: null, updated_at: input.now,
3589
+ } };
3590
+ }
3591
+ if (operation === 'completeMigration') {
3592
+ if (current.state === 'complete' && current.snapshot_hash === input.snapshotHash) {
3593
+ return { ok: true, changed: false, reason: 'idempotent', gate: current };
3594
+ }
3595
+ if (current.state !== 'quarantined' || current.snapshot_hash !== input.snapshotHash) {
3596
+ return { ok: false, changed: false, reason: 'snapshot_mismatch', gate: current };
3597
+ }
3598
+ runParams(runtime, database, `UPDATE ${WATCH_GATE_TABLE} SET state = 'complete', updated_at = ? WHERE singleton = 1`, [input.now]);
3599
+ return { ok: true, changed: true, reason: 'complete', gate: { ...current, state: 'complete', updated_at: input.now } };
3600
+ }
3601
+ throw watchOwnerStoreError('INVALID_OPERATION', 'Unknown watcher migration-gate operation');
3602
+ });
3603
+ }
3604
+
3605
+ function rollbackTransaction(runtime, db) {
3606
+ try {
3607
+ execSql(runtime, db, 'ROLLBACK;');
3608
+ } catch {
3609
+ // Preserve the original failure when SQLite has already closed the transaction.
3610
+ }
3611
+ }
3612
+
3613
+ const MONITOR_HASH = /^[0-9a-f]{64}$/;
3614
+ const MONITOR_TARGET = /^[A-Za-z0-9][A-Za-z0-9._:/-]*$/;
3615
+ const MONITOR_SECRET_PATTERNS = [
3616
+ /gh[pousr]_[a-z0-9]{20,}/i,
3617
+ /github_pat_[a-z0-9_]{20,}/i,
3618
+ /sk_(?:live|test)_[a-z0-9]{16,}/i,
3619
+ /sk-[a-z0-9]{16,}/i,
3620
+ /AKIA[0-9A-Z]{16}/i,
3621
+ /(?:api[_-]?key|token|secret|password)\s*[:=]\s*\S{8,}/i,
3622
+ ];
3623
+ const MONITOR_MAX_TARGETS = 32;
3624
+ const MONITOR_MAX_TARGET_LENGTH = 128;
3625
+ const MONITOR_MAX_ENVELOPE_BYTES = 16_384;
3626
+
3627
+ const MONITOR_MAX_ENVELOPE_DEPTH = 8;
3628
+ const MONITOR_MAX_ENVELOPE_ITEMS = 128;
3629
+ const MONITOR_MAX_ENVELOPE_PROPERTIES = 64;
3630
+ const MONITOR_MAX_ENVELOPE_NODES = 1_024;
3631
+ const MONITOR_DEFAULT_READ_LIMIT = 128;
3632
+ const MONITOR_MAX_READ_LIMIT = 4_096;
3633
+
3634
+ function monitorStoreError(code, message, cause) {
3635
+ const error = new Error(message, cause ? { cause } : undefined);
3636
+ error.code = code;
3637
+ return error;
3638
+ }
3639
+
3640
+ function containsMonitorSecret(value) {
3641
+ return MONITOR_SECRET_PATTERNS.some(pattern => pattern.test(value));
3642
+ }
3643
+
3644
+ const MONITOR_PRIVATE_PATH_ROOTS = ['users', 'home', 'root'];
3645
+ const MONITOR_MAX_PRIVATE_SCAN_LENGTH = MONITOR_MAX_ENVELOPE_BYTES;
3646
+
3647
+ function hasNonWhitespacePathSegment(segment) {
3648
+ return Boolean(segment && segment.trim());
3649
+ }
3650
+
3651
+ function containsMonitorPrivatePath(value) {
3652
+ if (typeof value !== 'string') return false;
3653
+ if (value.length > MONITOR_MAX_PRIVATE_SCAN_LENGTH) return true;
3654
+ const normalized = value.replaceAll('\\', '/').toLowerCase();
3655
+ for (const root of MONITOR_PRIVATE_PATH_ROOTS) {
3656
+ const marker = `/${root}/`;
3657
+ let offset = 0;
3658
+ while (true) {
3659
+ const index = normalized.indexOf(marker, offset);
3660
+ if (index < 0) break;
3661
+ const segment = normalized.slice(index + marker.length).split('/')[0];
3662
+ if (hasNonWhitespacePathSegment(segment)) return true;
3663
+ offset = index + marker.length;
3664
+ }
3665
+ }
3666
+ for (let code = 97; code <= 122; code += 1) {
3667
+ const marker = `${String.fromCharCode(code)}:/users/`;
3668
+ const index = normalized.indexOf(marker);
3669
+ if (index >= 0 && hasNonWhitespacePathSegment(
3670
+ normalized.slice(index + marker.length).split('/')[0],
3671
+ )) return true;
3672
+ }
3673
+ return false;
3674
+ }
3675
+
3676
+ function invalidMonitorPlainData() {
3677
+ throw new Error('monitor envelope must contain only bounded plain JSON data');
3678
+ }
3679
+
3680
+ function cloneMonitorArray(value, descriptors, keys, state, depth) {
3681
+ const length = descriptors.length?.value;
3682
+ if (!Number.isInteger(length) || length < 0 || length > MONITOR_MAX_ENVELOPE_ITEMS
3683
+ || keys.length !== length + 1) {
3684
+ invalidMonitorPlainData();
3685
+ }
3686
+ const clone = [];
3687
+ for (let index = 0; index < length; index += 1) {
3688
+ const descriptor = descriptors[String(index)];
3689
+ if (!descriptor?.enumerable || !Object.hasOwn(descriptor, 'value')) invalidMonitorPlainData();
3690
+ clone.push(cloneMonitorPlainData(descriptor.value, state, depth + 1));
3691
+ }
3692
+ return clone;
3693
+ }
3694
+
3695
+ function cloneMonitorObject(descriptors, keys, state, depth) {
3696
+ const clone = Object.create(null);
3697
+ for (const key of keys) {
3698
+ const descriptor = descriptors[key];
3699
+ if (!descriptor.enumerable || !Object.hasOwn(descriptor, 'value')) invalidMonitorPlainData();
3700
+ clone[key] = cloneMonitorPlainData(descriptor.value, state, depth + 1);
3701
+ }
3702
+ return clone;
3703
+ }
3704
+
3705
+ function cloneMonitorPlainData(value, state, depth = 0) {
3706
+ const traversal = state || { ancestors: new WeakSet(), nodes: 0 };
3707
+ traversal.nodes += 1;
3708
+ if (traversal.nodes > MONITOR_MAX_ENVELOPE_NODES || depth > MONITOR_MAX_ENVELOPE_DEPTH) {
3709
+ invalidMonitorPlainData();
3710
+ }
3711
+ if (value === null || typeof value === 'string' || typeof value === 'boolean') return value;
3712
+ if (typeof value === 'number') {
3713
+ if (!Number.isFinite(value)) invalidMonitorPlainData();
3714
+ return value;
3715
+ }
3716
+ if (typeof value !== 'object' || isProxy(value) || traversal.ancestors.has(value)) {
3717
+ invalidMonitorPlainData();
3718
+ }
3719
+
3720
+ let prototype;
3721
+ let descriptors;
3722
+ try {
3723
+ prototype = Object.getPrototypeOf(value);
3724
+ descriptors = Object.getOwnPropertyDescriptors(value);
3725
+ } catch {
3726
+ invalidMonitorPlainData();
3727
+ }
3728
+ const keys = Reflect.ownKeys(descriptors);
3729
+ if (keys.some(key => typeof key !== 'string' || key === 'toJSON')) invalidMonitorPlainData();
3730
+
3731
+ traversal.ancestors.add(value);
3732
+ try {
3733
+ if (Array.isArray(value)) {
3734
+ if (prototype !== Array.prototype) invalidMonitorPlainData();
3735
+ return cloneMonitorArray(value, descriptors, keys, traversal, depth);
3736
+ }
3737
+
3738
+ if ((prototype !== Object.prototype && prototype !== null)
3739
+ || keys.length > MONITOR_MAX_ENVELOPE_PROPERTIES) {
3740
+ invalidMonitorPlainData();
3741
+ }
3742
+ return cloneMonitorObject(descriptors, keys, traversal, depth);
3743
+ } finally {
3744
+ traversal.ancestors.delete(value);
3745
+ }
3746
+ }
3747
+
3748
+ function containsPrivateMonitorData(value) {
3749
+ if (typeof value === 'string') {
3750
+ return containsMonitorSecret(value) || containsMonitorPrivatePath(value);
3751
+ }
3752
+ if (!value || typeof value !== 'object') return false;
3753
+ return Object.entries(value).some(([key, nestedValue]) => (
3754
+ containsMonitorSecret(key)
3755
+ || containsMonitorPrivatePath(key)
3756
+ || containsPrivateMonitorData(nestedValue)
3757
+ ));
3758
+ }
3759
+
3760
+ function assertMonitorEnvelope(envelope, schemaId) {
3761
+ const plainEnvelope = cloneMonitorPlainData(envelope);
3762
+ const serialized = JSON.stringify(plainEnvelope);
3763
+ const safeEnvelope = JSON.parse(serialized);
3764
+ if (containsPrivateMonitorData(safeEnvelope)) {
3765
+ throw new Error(`private content rejected from ${schemaId}`);
3766
+ }
3767
+ const payload = safeEnvelope?.payload;
3768
+ const validCommon = safeEnvelope?.schema_id === schemaId
3769
+ && MONITOR_HASH.test(safeEnvelope?.content_hash)
3770
+ && typeof safeEnvelope?.created_at === 'string'
3771
+ && payload && typeof payload === 'object' && !Array.isArray(payload);
3772
+ let validPayload = false;
3773
+ if (schemaId === 'forge.memory.monitor-event.v1') {
3774
+ validPayload = typeof payload?.monitor_id === 'string' && payload.monitor_id.length > 0
3775
+ && typeof payload?.event_id === 'string' && payload.event_id.length > 0
3776
+ && Number.isInteger(payload?.sequence) && payload.sequence >= 0;
3777
+ } else if (schemaId === 'forge.memory.delivery-receipt.v1') {
3778
+ validPayload = typeof payload?.event_id === 'string' && payload.event_id.length > 0
3779
+ && Number.isInteger(payload?.attempt) && payload.attempt > 0;
3780
+ } else if (schemaId === 'forge.memory.monitor-receipt.v1') {
3781
+ validPayload = typeof payload?.monitor_id === 'string' && payload.monitor_id.length > 0
3782
+ && Number.isInteger(payload?.last_sequence) && payload.last_sequence >= 0;
3783
+ }
3784
+ if (!validCommon || !validPayload || Buffer.byteLength(serialized, 'utf8') > MONITOR_MAX_ENVELOPE_BYTES) {
3785
+ throw new Error(`invalid ${schemaId.replace('forge.memory.', '').replace('.v1', '').replaceAll('-', ' ')} envelope`);
3786
+ }
3787
+ return safeEnvelope;
3788
+ }
3789
+
3790
+ function assertMonitorTarget(target) {
3791
+ if (typeof target !== 'string' || target.length === 0 || target.length > MONITOR_MAX_TARGET_LENGTH
3792
+ || !MONITOR_TARGET.test(target) || containsMonitorSecret(target) || containsMonitorPrivatePath(target)) {
3793
+ throw new Error('invalid or private monitor delivery target');
3794
+ }
3795
+ return target;
3796
+ }
3797
+
3798
+ function normalizeMonitorTargets(targets) {
3799
+ if (!Array.isArray(targets) || targets.length === 0 || targets.length > MONITOR_MAX_TARGETS) {
3800
+ throw new Error(`monitor delivery targets must contain 1-${MONITOR_MAX_TARGETS} entries`);
3801
+ }
3802
+ return [...new Set(targets.map(assertMonitorTarget))];
3803
+ }
3804
+
3805
+ function compareMonitorTargets(left, right) {
3806
+ if (left < right) return -1;
3807
+ if (left > right) return 1;
3808
+ return 0;
3809
+ }
3810
+
3811
+ function assertMonitorWriterEnabled(runtime, db, config = {}) {
3812
+ if (config.monitorDurabilityEnabled === false) {
3813
+ throw monitorStoreError('MONITOR_UNAVAILABLE', 'Monitor durability writers are disabled; retained evidence remains readable');
3814
+ }
3815
+ let row;
3816
+ try {
3817
+ row = allParams(runtime, db,
3818
+ 'SELECT enabled FROM memory_monitor_writer_state WHERE singleton = 1')[0];
3819
+ } catch (error) {
3820
+ if (/no such table/i.test(String(error?.message || ''))) {
3821
+ throw monitorStoreError('MONITOR_UNAVAILABLE', 'Monitor durability schema is not initialized; run Kernel migrations', error);
3822
+ }
3823
+ throw error;
3824
+ }
3825
+ if (Number(row?.enabled) !== 1) {
3826
+ throw monitorStoreError('MONITOR_UNAVAILABLE', 'Monitor durability writers are disabled; retained evidence remains readable');
3827
+ }
3828
+ }
3829
+
3830
+ function monitorBusyTimeout(config = {}) {
3831
+ const requested = Number(config.monitorBusyTimeoutMs);
3832
+ if (!Number.isFinite(requested)) return 1_000;
3833
+ return Math.max(0, Math.min(5_000, Math.floor(requested)));
3834
+ }
3835
+
3836
+ function runMonitorTransaction(runtime, db, config, operation) {
3837
+ const timeout = monitorBusyTimeout(config);
3838
+ const previousTimeout = Number(queryOne(runtime, db, 'PRAGMA busy_timeout;').timeout) || 0;
3839
+ execSql(runtime, db, `PRAGMA busy_timeout=${timeout};`);
3840
+ let active = false;
3841
+ try {
3842
+ execSql(runtime, db, 'BEGIN IMMEDIATE;');
3843
+ active = true;
3844
+ const result = operation();
3845
+ execSql(runtime, db, 'COMMIT;');
3846
+ active = false;
3847
+ return result;
3848
+ } catch (error) {
3849
+ if (active) rollbackTransaction(runtime, db);
3850
+ if (/database is locked|SQLITE_BUSY/i.test(String(error?.message || ''))) {
3851
+ throw monitorStoreError('MONITOR_UNAVAILABLE', `Monitor durability write remained busy after ${timeout}ms`, error);
3852
+ }
3853
+ throw error;
3854
+ } finally {
3855
+ execSql(runtime, db, `PRAGMA busy_timeout=${previousTimeout};`);
3856
+ }
3857
+ }
3858
+
3859
+ function monitorEventRow(envelope) {
3860
+ const payload = envelope.payload || {};
3861
+ return {
3862
+ event_id: payload.event_id,
3863
+ monitor_id: payload.monitor_id,
3864
+ sequence: payload.sequence,
3865
+ content_hash: envelope.content_hash,
3866
+ envelope_json: JSON.stringify(envelope),
3867
+ artifact_digest: payload.artifact_digest ?? null,
3868
+ created_at: envelope.created_at,
3869
+ };
3870
+ }
3871
+
3872
+ function appendMonitorEventRow(runtime, db, envelope, targets, config) {
3873
+ const row = monitorEventRow(envelope);
3874
+ return runMonitorTransaction(runtime, db, config, () => {
3875
+ assertMonitorWriterEnabled(runtime, db, config);
3876
+ const existingById = allParams(runtime, db,
3877
+ 'SELECT event_id, monitor_id, sequence, content_hash FROM memory_monitor_events WHERE event_id = ?', [row.event_id])[0];
3878
+ const existingBySequence = allParams(runtime, db,
3879
+ 'SELECT event_id, monitor_id, sequence, content_hash FROM memory_monitor_events WHERE monitor_id = ? AND sequence = ?', [row.monitor_id, row.sequence])[0];
3880
+ const existing = existingById || existingBySequence;
3881
+ if (existing) {
3882
+ if (existing.content_hash !== row.content_hash) throw monitorStoreError('MONITOR_EVENT_CONFLICT', 'monitor event conflict: immutable identity has different content_hash');
3883
+ const storedTargets = allParams(runtime, db,
3884
+ 'SELECT target FROM memory_monitor_outbox WHERE event_id = ? ORDER BY target ASC', [existing.event_id])
3885
+ .map(entry => entry.target);
3886
+ const replayTargets = [...targets].sort(compareMonitorTargets);
3887
+ if (storedTargets.length !== replayTargets.length
3888
+ || storedTargets.some((target, index) => target !== replayTargets[index])) {
3889
+ throw monitorStoreError('MONITOR_TARGET_SET_CONFLICT', 'monitor event target set conflict: identical event replay changed delivery targets');
3890
+ }
3891
+ return { idempotent: true, event_id: existing.event_id, monitor_id: existing.monitor_id, sequence: existing.sequence };
3892
+ }
3893
+ const terminalReceipt = allParams(runtime, db,
3894
+ 'SELECT monitor_id FROM memory_monitor_receipts WHERE monitor_id = ?', [row.monitor_id])[0];
3895
+ if (terminalReceipt) throw monitorStoreError('MONITOR_TERMINAL', 'monitor already has a terminal receipt');
3896
+
3897
+ runParams(runtime, db,
3898
+ `INSERT INTO memory_monitor_events
3899
+ (event_id, monitor_id, sequence, content_hash, envelope_json, artifact_digest, created_at)
3900
+ VALUES (?, ?, ?, ?, ?, ?, ?)`,
3901
+ [row.event_id, row.monitor_id, row.sequence, row.content_hash, row.envelope_json, row.artifact_digest, row.created_at]);
3902
+ for (const target of targets) {
3903
+ runParams(runtime, db,
3904
+ `INSERT INTO memory_monitor_outbox
3905
+ (id, event_id, target, status, attempts, next_attempt_at, created_at)
3906
+ VALUES (?, ?, ?, 'pending', 0, NULL, ?)`,
3907
+ [randomUUID(), row.event_id, target, row.created_at]);
3908
+ }
3909
+ return { idempotent: false, event_id: row.event_id, monitor_id: row.monitor_id, sequence: row.sequence };
3910
+ });
3911
+ }
3912
+
3913
+ function recordMonitorDeliveryReceiptRow(runtime, db, envelope, config) {
3914
+ const payload = envelope.payload || {};
3915
+ const row = {
3916
+ event_id: payload.event_id,
3917
+ target: payload.target,
3918
+ attempt: payload.attempt,
3919
+ content_hash: envelope.content_hash,
3920
+ envelope_json: JSON.stringify(envelope),
3921
+ acknowledged: payload.acknowledged ? 1 : 0,
3922
+ delivered_at: payload.delivered_at,
3923
+ outcome: payload.outcome,
3924
+ };
3925
+ return runMonitorTransaction(runtime, db, config, () => {
3926
+ assertMonitorWriterEnabled(runtime, db, config);
3927
+ const existing = allParams(runtime, db,
3928
+ `SELECT content_hash FROM memory_monitor_delivery_receipts
3929
+ WHERE event_id = ? AND target = ? AND attempt = ?`, [row.event_id, row.target, row.attempt])[0];
3930
+ if (existing) {
3931
+ if (existing.content_hash !== row.content_hash) throw monitorStoreError('MONITOR_DELIVERY_CONFLICT', 'monitor delivery receipt conflict: attempt has different content_hash');
3932
+ return { idempotent: true, event_id: row.event_id, target: row.target, attempt: row.attempt };
3933
+ }
3934
+ const event = allParams(runtime, db,
3935
+ 'SELECT monitor_id, sequence FROM memory_monitor_events WHERE event_id = ?', [row.event_id])[0];
3936
+ if (!event) throw monitorStoreError('MONITOR_DELIVERY_CONFLICT', 'monitor delivery receipt references an unknown event');
3937
+ const outbox = allParams(runtime, db,
3938
+ 'SELECT id FROM memory_monitor_outbox WHERE event_id = ? AND target = ?', [row.event_id, row.target])[0];
3939
+ if (!outbox) throw monitorStoreError('MONITOR_DELIVERY_CONFLICT', 'monitor delivery receipt references an unplanned target');
3940
+ if (row.acknowledged) {
3941
+ const cursor = allParams(runtime, db,
3942
+ 'SELECT sequence FROM memory_monitor_cursors WHERE monitor_id = ? AND target = ?',
3943
+ [event.monitor_id, row.target])[0];
3944
+ if (cursor && event.sequence < Number(cursor.sequence)) {
3945
+ throw monitorStoreError('MONITOR_STALE_CURSOR', 'stale monitor delivery cursor: acknowledged sequence is behind the durable cursor');
3946
+ }
3947
+ }
3948
+
3949
+ runParams(runtime, db,
3950
+ `INSERT INTO memory_monitor_delivery_receipts
3951
+ (event_id, target, attempt, content_hash, envelope_json, acknowledged, delivered_at, outcome)
3952
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
3953
+ [row.event_id, row.target, row.attempt, row.content_hash, row.envelope_json, row.acknowledged, row.delivered_at, row.outcome]);
3954
+ if (row.acknowledged) {
3955
+ runParams(runtime, db,
3956
+ `INSERT INTO memory_monitor_cursors (monitor_id, target, sequence, updated_at)
3957
+ VALUES (?, ?, ?, ?)
3958
+ ON CONFLICT(monitor_id, target) DO UPDATE SET
3959
+ sequence = excluded.sequence,
3960
+ updated_at = excluded.updated_at
3961
+ WHERE excluded.sequence > memory_monitor_cursors.sequence`,
3962
+ [event.monitor_id, row.target, event.sequence, row.delivered_at]);
3963
+ runParams(runtime, db,
3964
+ "UPDATE memory_monitor_outbox SET status = 'acknowledged', attempts = attempts + 1, next_attempt_at = NULL WHERE id = ?",
3965
+ [outbox.id]);
3966
+ }
3967
+ return { idempotent: false, event_id: row.event_id, target: row.target, attempt: row.attempt, acknowledged: Boolean(row.acknowledged) };
3968
+ });
3969
+ }
3970
+
3971
+ function recordMonitorTerminalReceiptRow(runtime, db, envelope, config) {
3972
+ const payload = envelope.payload || {};
3973
+ const row = {
3974
+ monitor_id: payload.monitor_id,
3975
+ content_hash: envelope.content_hash,
3976
+ envelope_json: JSON.stringify(envelope),
3977
+ owner_run_id: payload.owner_run_id,
3978
+ terminal_state: payload.terminal_state,
3979
+ last_sequence: payload.last_sequence,
3980
+ evidence_digest: payload.evidence_digest,
3981
+ undelivered_cursor: payload.undelivered_cursor ?? null,
3982
+ created_at: envelope.created_at,
3983
+ };
3984
+ return runMonitorTransaction(runtime, db, config, () => {
3985
+ assertMonitorWriterEnabled(runtime, db, config);
3986
+ const existing = allParams(runtime, db,
3987
+ 'SELECT content_hash FROM memory_monitor_receipts WHERE monitor_id = ?', [row.monitor_id])[0];
3988
+ if (existing) {
3989
+ if (existing.content_hash !== row.content_hash) throw monitorStoreError('MONITOR_RECEIPT_CONFLICT', 'monitor receipt conflict: terminal evidence has different content_hash');
3990
+ return { idempotent: true, monitor_id: row.monitor_id };
3991
+ }
3992
+ const eventState = allParams(runtime, db,
3993
+ 'SELECT COUNT(*) AS event_count, MAX(sequence) AS max_sequence FROM memory_monitor_events WHERE monitor_id = ?',
3994
+ [row.monitor_id])[0];
3995
+ const expectedLastSequence = Number(eventState.event_count) === 0 ? 0 : Number(eventState.max_sequence);
3996
+ if (row.last_sequence !== expectedLastSequence) {
3997
+ throw monitorStoreError('MONITOR_STALE_TERMINAL', 'stale monitor terminal sequence: last_sequence does not match durable events');
3998
+ }
3999
+ runParams(runtime, db,
4000
+ `INSERT INTO memory_monitor_receipts
4001
+ (monitor_id, content_hash, envelope_json, owner_run_id, terminal_state, last_sequence, evidence_digest, undelivered_cursor, created_at)
4002
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`,
4003
+ [row.monitor_id, row.content_hash, row.envelope_json, row.owner_run_id, row.terminal_state, row.last_sequence, row.evidence_digest, row.undelivered_cursor, row.created_at]);
4004
+ return { idempotent: false, monitor_id: row.monitor_id };
4005
+ });
4006
+ }
4007
+
4008
+ function monitorReadLimit(options = {}, fallback = MONITOR_DEFAULT_READ_LIMIT) {
4009
+ const requested = options.limit ?? fallback;
4010
+ if (!Number.isSafeInteger(requested) || requested < 1 || requested > MONITOR_MAX_READ_LIMIT) {
4011
+ throw new TypeError(`monitor read limit must be an integer from 1 to ${MONITOR_MAX_READ_LIMIT}`);
4012
+ }
4013
+ return requested;
4014
+ }
4015
+
4016
+ function getMonitorEventRow(runtime, db, eventId) {
4017
+ return allParams(
4018
+ runtime,
4019
+ db,
4020
+ `SELECT event_id, monitor_id, sequence, content_hash, envelope_json, artifact_digest, created_at
4021
+ FROM memory_monitor_events WHERE event_id = ? LIMIT 1`,
4022
+ [eventId],
4023
+ )[0] || null;
4024
+ }
4025
+
4026
+ function listMonitorEventRows(runtime, db, monitorId) {
4027
+ return allParams(
4028
+ runtime,
4029
+ db,
4030
+ `SELECT event_id, monitor_id, sequence, content_hash, envelope_json, artifact_digest, created_at
4031
+ FROM memory_monitor_events WHERE monitor_id = ? ORDER BY sequence ASC, event_id ASC`,
4032
+ [monitorId],
4033
+ );
4034
+ }
4035
+
4036
+ function readMonitorEventTailRows(runtime, db, monitorId, options = {}) {
4037
+ const limit = monitorReadLimit(options);
4038
+ const selected = allParams(
4039
+ runtime,
4040
+ db,
4041
+ `SELECT event_id, monitor_id, sequence, content_hash, envelope_json, artifact_digest, created_at
4042
+ FROM memory_monitor_events WHERE monitor_id = ?
4043
+ ORDER BY sequence DESC, event_id DESC LIMIT ?`,
4044
+ [monitorId, limit + 1],
4045
+ );
4046
+ const overflow = selected.length > limit;
4047
+ const events = selected.slice(0, limit).reverse();
4048
+ return {
4049
+ events,
4050
+ overflow,
4051
+ truncated_before_sequence: overflow && events.length > 0 ? Number(events[0].sequence) : null,
4052
+ };
4053
+ }
4054
+
4055
+ function readMonitorDeliveryStateRows(runtime, db, monitorId, options = {}) {
4056
+ const limit = monitorReadLimit(options);
4057
+ const cursorRows = allParams(
4058
+ runtime,
4059
+ db,
4060
+ `SELECT monitor_id, target, sequence, updated_at FROM memory_monitor_cursors
4061
+ WHERE monitor_id = ? ORDER BY target ASC LIMIT ?`,
4062
+ [monitorId, limit + 1],
4063
+ );
4064
+ const outboxRows = allParams(
4065
+ runtime,
4066
+ db,
4067
+ `SELECT o.id AS outbox_id, o.event_id, e.monitor_id, e.sequence, o.target,
4068
+ o.status, o.attempts, o.next_attempt_at, o.created_at
4069
+ FROM memory_monitor_outbox o
4070
+ JOIN memory_monitor_events e ON e.event_id = o.event_id
4071
+ WHERE e.monitor_id = ?
4072
+ ORDER BY e.sequence ASC, o.target ASC, o.id ASC LIMIT ?`,
4073
+ [monitorId, limit + 1],
4074
+ );
4075
+ const terminalReceipt = allParams(
4076
+ runtime,
4077
+ db,
4078
+ `SELECT monitor_id, content_hash, envelope_json, owner_run_id, terminal_state,
4079
+ last_sequence, evidence_digest, undelivered_cursor, created_at
4080
+ FROM memory_monitor_receipts WHERE monitor_id = ? LIMIT 1`,
4081
+ [monitorId],
4082
+ )[0] || null;
4083
+ return {
4084
+ cursors: cursorRows.slice(0, limit),
4085
+ outbox: outboxRows.slice(0, limit),
4086
+ terminal_receipt: terminalReceipt,
4087
+ overflow: {
4088
+ cursors: cursorRows.length > limit,
4089
+ outbox: outboxRows.length > limit,
4090
+ },
4091
+ };
4092
+ }
4093
+
4094
+ const CLAIM_REPAIR_INDEX_NAMES = Object.freeze([
4095
+ 'idx_kernel_claims_active_lease',
4096
+ 'idx_kernel_claims_actor_state',
4097
+ 'idx_kernel_claims_issue_state',
4098
+ ]);
4099
+ const CLAIM_REPAIR_AUTHORITY_ROWID_TABLES = new Set([
4100
+ 'kernel_events',
4101
+ 'kernel_memories',
4102
+ 'kernel_stage_runs',
4103
+ 'kernel_worktrees',
4104
+ ]);
4105
+
4106
+ function quoteSqlIdentifier(value) {
4107
+ return `"${String(value).replaceAll('"', '""')}"`;
4108
+ }
4109
+
4110
+ function loadCompleteAuthoritySnapshot(runtime, db, guardedRead) {
4111
+ const schema = guardedRead(
4112
+ 'authority_schema',
4113
+ () => allParams(
4114
+ runtime,
4115
+ db,
4116
+ `SELECT type, name, tbl_name, sql FROM sqlite_master
4117
+ WHERE name NOT LIKE 'sqlite_%' ORDER BY type ASC, name ASC`,
4118
+ ),
4119
+ );
4120
+ const tables = schema
4121
+ .filter(row => row.type === 'table')
4122
+ .map(row => ({
4123
+ name: row.name,
4124
+ rows: guardedRead(
4125
+ `authority_table:${row.name}`,
4126
+ () => allParams(
4127
+ runtime,
4128
+ db,
4129
+ `SELECT ${CLAIM_REPAIR_AUTHORITY_ROWID_TABLES.has(row.name) ? 'rowid AS __forge_rowid, ' : ''}* FROM ${quoteSqlIdentifier(row.name)}`,
4130
+ ),
4131
+ ),
4132
+ }));
4133
+ return { schema, tables };
4134
+ }
4135
+
4136
+ function loadLegacyClaimRepairSnapshot(runtime, db) {
4137
+ const readErrors = [];
4138
+ const guardedRead = (label, read, fallback = []) => {
4139
+ try {
4140
+ return read();
4141
+ } catch {
4142
+ readErrors.push(label);
4143
+ return fallback;
4144
+ }
4145
+ };
4146
+ const integrityRows = guardedRead('integrity_check', () => queryAll(runtime, db, 'PRAGMA integrity_check;'));
4147
+ const foreignKeyState = guardedRead('foreign_keys', () => queryOne(runtime, db, 'PRAGMA foreign_keys;'), {});
4148
+ const foreignKeyFaults = guardedRead('foreign_key_check', () => queryAll(runtime, db, 'PRAGMA foreign_key_check;'));
4149
+ const claimColumns = guardedRead('claim_columns', () => queryAll(runtime, db, "PRAGMA table_info('kernel_claims');"));
4150
+ const issueColumns = guardedRead('issue_columns', () => queryAll(runtime, db, "PRAGMA table_info('kernel_issues');"));
4151
+ const claimForeignKeys = guardedRead('claim_foreign_keys', () => queryAll(runtime, db, "PRAGMA foreign_key_list('kernel_claims');"));
4152
+ const indexList = guardedRead('claim_indexes', () => queryAll(runtime, db, "PRAGMA index_list('kernel_claims');"));
4153
+ const indexSqlRows = guardedRead(
4154
+ 'claim_index_sql',
4155
+ () => allParams(runtime, db, "SELECT name, sql FROM sqlite_master WHERE type = 'index' AND tbl_name = 'kernel_claims'"),
4156
+ );
4157
+ const indexSql = new Map(indexSqlRows.map(row => [row.name, row.sql]));
4158
+ const indexByName = new Map(indexList.map(row => [row.name, row]));
4159
+ const claimIndexes = CLAIM_REPAIR_INDEX_NAMES.map(name => {
4160
+ const index = indexByName.get(name) || {};
4161
+ const columns = guardedRead(
4162
+ `index_columns:${name}`,
4163
+ () => queryAll(runtime, db, `PRAGMA index_info('${name}');`),
4164
+ );
4165
+ return {
4166
+ name,
4167
+ columns: columns.map(row => row.name),
4168
+ unique: Number(index.unique) === 1,
4169
+ partial: Number(index.partial) === 1,
4170
+ sql: indexSql.get(name) || '',
4171
+ };
4172
+ });
4173
+ const issues = guardedRead(
4174
+ 'issues',
4175
+ () => allParams(runtime, db, 'SELECT id, status, type FROM kernel_issues ORDER BY id ASC'),
4176
+ );
4177
+ const claims = guardedRead(
4178
+ 'claims',
4179
+ () => allParams(
4180
+ runtime,
4181
+ db,
4182
+ `SELECT id, issue_id, actor, state, session_id, worktree_id, claimed_at, expires_at
4183
+ FROM kernel_claims ORDER BY id ASC`,
4184
+ ),
4185
+ );
4186
+ const authority = loadCompleteAuthoritySnapshot(runtime, db, guardedRead);
4187
+ return {
4188
+ integrity: integrityRows.length > 0
4189
+ && integrityRows.every(row => String(row.integrity_check || '').toLowerCase() === 'ok')
4190
+ ? 'ok'
4191
+ : 'failed',
4192
+ foreign_keys_enabled: Number(foreignKeyState.foreign_keys) === 1,
4193
+ foreign_key_faults: foreignKeyFaults.length,
4194
+ claim_columns: claimColumns.map(row => row.name),
4195
+ issue_columns: issueColumns.map(row => row.name),
4196
+ claim_foreign_keys: claimForeignKeys.map(row => ({ table: row.table, from: row.from, to: row.to })),
4197
+ claim_indexes: claimIndexes,
4198
+ issues,
4199
+ claims,
4200
+ authority_schema: authority.schema,
4201
+ authority_tables: authority.tables,
4202
+ read_errors: readErrors,
4203
+ };
4204
+ }
4205
+
4206
+ function preflightLegacyClaimRepairRow(runtime, db, input = {}) {
4207
+ const snapshot = loadLegacyClaimRepairSnapshot(runtime, db);
4208
+ return publicClaimRepairPreflight(buildClaimRepairPlan(snapshot, input));
4209
+ }
4210
+
4211
+ function assertClaimRepairApplyInput(input = {}) {
4212
+ const approvedDigest = input.approvedDigest;
4213
+ const proof = input.backupProof;
4214
+ if (!/^[0-9a-f]{64}$/.test(String(approvedDigest || ''))) {
4215
+ throw new ClaimRepairError('CLAIM_REPAIR_APPROVAL_REQUIRED', 'Apply requires the exact approved preflight digest');
4216
+ }
4217
+ if (proof?.schema_version !== 'forge.claim-repair.backup-proof.v1'
4218
+ || proof.integrity !== 'ok'
4219
+ || proof.plan_digest !== approvedDigest
4220
+ || proof.restore_digest !== approvedDigest
4221
+ || !/^[0-9a-f]{64}$/.test(String(proof.backup_sha256 || ''))) {
4222
+ throw new ClaimRepairError(
4223
+ 'CLAIM_REPAIR_BACKUP_PROOF_REQUIRED',
4224
+ 'Apply requires a verified separate backup restored from the exact approved snapshot',
4225
+ );
4226
+ }
4227
+ if (typeof input.actor !== 'string' || input.actor.trim() === '') {
4228
+ throw new ClaimRepairError('CLAIM_REPAIR_ACTOR_REQUIRED', 'Apply requires an explicit operator actor');
4229
+ }
4230
+ }
4231
+
4232
+ function parseStoredClaimRepairReceipt(row, approvedDigest, backupProof = null) {
4233
+ if (!row) return null;
4234
+ let receipt;
4235
+ try {
4236
+ receipt = JSON.parse(row.payload_json);
4237
+ } catch {
4238
+ throw new ClaimRepairError('CLAIM_REPAIR_RECEIPT_INVALID', 'Stored claim repair receipt is malformed');
4239
+ }
4240
+ if (receipt?.schema_version !== 'forge.claim-repair.receipt.v1'
4241
+ || row.id !== receipt.receipt_id
4242
+ || row.entity_type !== 'claim_repair'
4243
+ || row.entity_id !== 'legacy_claims'
4244
+ || row.event_type !== 'claim.repair'
4245
+ || row.origin !== 'forge.claim-repair'
4246
+ || receipt.approved_digest !== approvedDigest
4247
+ || (backupProof && receipt.backup_sha256 !== backupProof.backup_sha256)
4248
+ || !/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test(String(receipt.recovery_ref || ''))
4249
+ || !/^[0-9a-f]{64}$/.test(String(receipt.backup_sha256 || ''))
4250
+ || !/^[0-9a-f]{64}$/.test(String(receipt.after_digest || ''))) {
4251
+ throw new ClaimRepairError('CLAIM_REPAIR_RECEIPT_INVALID', 'Stored claim repair receipt does not match the approved proof');
4252
+ }
4253
+ return receipt;
4254
+ }
4255
+
4256
+ function claimRepairRecoveryPath(backupPath, recoveryReference) {
4257
+ return `${backupPath}.forge-recovery-${recoveryReference}`;
4258
+ }
4259
+
4260
+ function attachClaimRepairRecoveryPath(receipt, backupPath) {
4261
+ if (!receipt || typeof backupPath !== 'string' || !path.isAbsolute(backupPath)) return receipt;
4262
+ return {
4263
+ ...receipt,
4264
+ recovery_path: claimRepairRecoveryPath(backupPath, receipt.recovery_ref),
4265
+ };
4266
+ }
4267
+
4268
+ function replayStoredClaimRepairReceipt(runtime, db, approvedDigest, observedAt, backupPath) {
4269
+ if (!/^[0-9a-f]{64}$/.test(String(approvedDigest || ''))) return null;
4270
+ const existingRow = allParams(
4271
+ runtime,
4272
+ db,
4273
+ 'SELECT * FROM kernel_events WHERE idempotency_key = ? LIMIT 1',
4274
+ [`claim.repair:${approvedDigest}`],
4275
+ )[0];
4276
+ const receipt = parseStoredClaimRepairReceipt(existingRow, approvedDigest);
4277
+ if (receipt && receipt.observed_at !== observedAt) {
4278
+ throw new ClaimRepairError('CLAIM_REPAIR_RECEIPT_INVALID', 'Stored claim repair receipt does not match the fixed observation time');
4279
+ }
4280
+ return receipt
4281
+ ? attachClaimRepairRecoveryPath({ ...receipt, replayed: true }, backupPath)
4282
+ : null;
4283
+ }
4284
+
4285
+ function claimRepairSourceIdentities(databasePath) {
4286
+ return [databasePath, `${databasePath}-wal`, `${databasePath}-shm`, `${databasePath}-journal`]
4287
+ .filter(sourcePath => fs.existsSync(sourcePath))
4288
+ .map(sourcePath => {
4289
+ const stat = fs.statSync(sourcePath, { bigint: true });
4290
+ return { device: stat.dev, inode: stat.ino };
4291
+ });
4292
+ }
4293
+
4294
+ function assertNoClaimRepairBackupSidecars(backupPath) {
4295
+ for (const suffix of ['-wal', '-shm', '-journal']) {
4296
+ if (fs.existsSync(`${backupPath}${suffix}`)) throw new Error('backup sidecar exists');
4297
+ }
4298
+ }
4299
+
4300
+ function hashOpenFileDescriptor(fileDescriptor) {
4301
+ const hash = createHash('sha256');
4302
+ const buffer = Buffer.allocUnsafe(64 * 1024);
4303
+ let offset = 0;
4304
+ for (;;) {
4305
+ const bytesRead = fs.readSync(fileDescriptor, buffer, 0, buffer.length, offset);
4306
+ if (bytesRead === 0) break;
4307
+ hash.update(buffer.subarray(0, bytesRead));
4308
+ offset += bytesRead;
4309
+ }
4310
+ return hash.digest('hex');
4311
+ }
4312
+
4313
+ function copyOpenFileDescriptor(sourceDescriptor, destinationDescriptor) {
4314
+ const buffer = Buffer.allocUnsafe(64 * 1024);
4315
+ let offset = 0;
4316
+ for (;;) {
4317
+ const bytesRead = fs.readSync(sourceDescriptor, buffer, 0, buffer.length, offset);
4318
+ if (bytesRead === 0) break;
4319
+ let written = 0;
4320
+ while (written < bytesRead) {
4321
+ const bytesWritten = fs.writeSync(
4322
+ destinationDescriptor,
4323
+ buffer,
4324
+ written,
4325
+ bytesRead - written,
4326
+ offset + written,
4327
+ );
4328
+ if (bytesWritten <= 0) throw new Error('recovery copy write made no progress');
4329
+ written += bytesWritten;
4330
+ }
4331
+ offset += bytesRead;
4332
+ }
4333
+ fs.fsyncSync(destinationDescriptor);
4334
+ }
4335
+
4336
+ function syncClaimRepairRecoveryDirectory(recoveryPath, options = {}) {
4337
+ const platform = options.platform || process.platform;
4338
+ if (platform === 'win32') return;
4339
+ const fsApi = options.fsApi || fs;
4340
+ let directoryDescriptor;
4341
+ try {
4342
+ directoryDescriptor = fsApi.openSync(path.dirname(recoveryPath), 'r');
4343
+ fsApi.fsyncSync(directoryDescriptor);
4344
+ } finally {
4345
+ if (directoryDescriptor !== undefined) fsApi.closeSync(directoryDescriptor);
4346
+ }
4347
+ }
4348
+
4349
+ async function verifyClaimRepairRecovery(receipt, backupPath, databasePath, options = {}) {
4350
+ const recoveryPath = claimRepairRecoveryPath(backupPath, receipt.recovery_ref);
4351
+ let fileDescriptor;
4352
+ try {
4353
+ await (options.hardenPath || hardenBackupPermissions)(recoveryPath);
4354
+ const sourceIdentities = claimRepairSourceIdentities(databasePath);
4355
+ const backupIdentity = fs.existsSync(backupPath) ? fs.statSync(backupPath, { bigint: true }) : null;
4356
+ fileDescriptor = fs.openSync(recoveryPath, 'r');
4357
+ const before = fs.fstatSync(fileDescriptor, { bigint: true });
4358
+ const digest = hashOpenFileDescriptor(fileDescriptor);
4359
+ const after = fs.fstatSync(fileDescriptor, { bigint: true });
4360
+ const named = fs.statSync(recoveryPath, { bigint: true });
4361
+ if (digest !== receipt.backup_sha256
4362
+ || before.size !== after.size
4363
+ || before.mtimeNs !== after.mtimeNs
4364
+ || named.dev !== before.dev
4365
+ || named.ino !== before.ino
4366
+ || (backupIdentity && backupIdentity.dev === before.dev && backupIdentity.ino === before.ino)
4367
+ || sourceIdentities.some(source => source.device === before.dev && source.inode === before.ino)) {
4368
+ throw new Error('recovery copy changed');
4369
+ }
4370
+ return recoveryPath;
4371
+ } catch {
4372
+ throw new ClaimRepairError(
4373
+ 'CLAIM_REPAIR_RECOVERY_INVALID',
4374
+ 'Retained claim-repair recovery copy is missing, unsafe, or changed',
4375
+ );
4376
+ } finally {
4377
+ if (fileDescriptor !== undefined) fs.closeSync(fileDescriptor);
4378
+ }
4379
+ }
4380
+
4381
+ async function openClaimRepairBackupFence(databasePath, backupPath, backupProof, recoveryReference, options = {}) {
4382
+ let fileDescriptor;
4383
+ let recoveryFileDescriptor;
4384
+ let recoveryPath;
4385
+ try {
4386
+ const hardenPath = options.hardenPath || hardenBackupPermissions;
4387
+ const hardenPaths = options.hardenPaths;
4388
+ await hardenPath(backupPath);
4389
+ assertSafeBackupDestination(databasePath, backupPath);
4390
+ assertNoClaimRepairBackupSidecars(backupPath);
4391
+ const sourceIdentities = claimRepairSourceIdentities(databasePath);
4392
+ fileDescriptor = fs.openSync(backupPath, 'r');
4393
+ const before = fs.fstatSync(fileDescriptor, { bigint: true });
4394
+ if (sourceIdentities.some(source => source.device === before.dev && source.inode === before.ino)) {
4395
+ throw new Error('pinned backup aliases source');
4396
+ }
4397
+ const digest = hashOpenFileDescriptor(fileDescriptor);
4398
+ const after = fs.fstatSync(fileDescriptor, { bigint: true });
4399
+ const named = fs.statSync(backupPath, { bigint: true });
4400
+ if (digest !== backupProof.backup_sha256
4401
+ || before.size !== after.size
4402
+ || before.mtimeNs !== after.mtimeNs
4403
+ || before.dev !== named.dev
4404
+ || before.ino !== named.ino) {
4405
+ throw new Error('backup identity or content changed');
4406
+ }
4407
+ recoveryPath = claimRepairRecoveryPath(backupPath, recoveryReference);
4408
+ recoveryFileDescriptor = fs.openSync(recoveryPath, 'wx+', 0o600);
4409
+ // The backup was hardened before opening its descriptor; harden the retained
4410
+ // copy in the same Windows PowerShell pass before copying any bytes.
4411
+ if (typeof hardenPaths === 'function') await hardenPaths([backupPath, recoveryPath]);
4412
+ else {
4413
+ await hardenPath(backupPath);
4414
+ await hardenPath(recoveryPath);
4415
+ }
4416
+ copyOpenFileDescriptor(fileDescriptor, recoveryFileDescriptor);
4417
+ syncClaimRepairRecoveryDirectory(recoveryPath);
4418
+ const sourceAfterCopy = fs.fstatSync(fileDescriptor, { bigint: true });
4419
+ const sourceDigestAfterCopy = hashOpenFileDescriptor(fileDescriptor);
4420
+ const namedAfterCopy = fs.statSync(backupPath, { bigint: true });
4421
+ const recoveryBefore = fs.fstatSync(recoveryFileDescriptor, { bigint: true });
4422
+ const recoveryDigest = hashOpenFileDescriptor(recoveryFileDescriptor);
4423
+ const recoveryAfter = fs.fstatSync(recoveryFileDescriptor, { bigint: true });
4424
+ const recoveryNamed = fs.statSync(recoveryPath, { bigint: true });
4425
+ if (sourceDigestAfterCopy !== backupProof.backup_sha256
4426
+ || before.size !== sourceAfterCopy.size
4427
+ || before.mtimeNs !== sourceAfterCopy.mtimeNs
4428
+ || namedAfterCopy.dev !== before.dev
4429
+ || namedAfterCopy.ino !== before.ino
4430
+ || recoveryDigest !== backupProof.backup_sha256
4431
+ || recoveryBefore.size !== recoveryAfter.size
4432
+ || recoveryBefore.mtimeNs !== recoveryAfter.mtimeNs
4433
+ || recoveryNamed.dev !== recoveryBefore.dev
4434
+ || recoveryNamed.ino !== recoveryBefore.ino
4435
+ || (recoveryBefore.dev === before.dev && recoveryBefore.ino === before.ino)) {
4436
+ throw new Error('recovery copy does not preserve independent verified bytes');
4437
+ }
4438
+ return {
4439
+ databasePath,
4440
+ backupPath,
4441
+ backupSha256: backupProof.backup_sha256,
4442
+ fileDescriptor,
4443
+ device: before.dev,
4444
+ inode: before.ino,
4445
+ recoveryFileDescriptor,
4446
+ recoveryDevice: recoveryBefore.dev,
4447
+ recoveryInode: recoveryBefore.ino,
4448
+ recoverySize: recoveryBefore.size,
4449
+ recoveryMtimeNs: recoveryBefore.mtimeNs,
4450
+ recoveryPath,
4451
+ };
4452
+ } catch {
4453
+ if (fileDescriptor !== undefined) fs.closeSync(fileDescriptor);
4454
+ if (recoveryFileDescriptor !== undefined) fs.closeSync(recoveryFileDescriptor);
4455
+ if (recoveryPath) {
4456
+ try { fs.rmSync(recoveryPath); } catch { /* cleanup only */ }
4457
+ }
4458
+ throw new ClaimRepairError(
4459
+ 'CLAIM_REPAIR_BACKUP_DRIFT',
4460
+ 'Verified claim-repair backup changed before receipt commit',
4461
+ );
4462
+ }
4463
+ }
4464
+
4465
+ async function assertClaimRepairBackupFenceCurrent(fence, options = {}) {
4466
+ try {
4467
+ await (options.hardenPath || hardenBackupPermissions)(fence.backupPath);
4468
+ assertSafeBackupDestination(fence.databasePath, fence.backupPath);
4469
+ if (!options.sidecarsBlocked) assertNoClaimRepairBackupSidecars(fence.backupPath);
4470
+ const before = fs.fstatSync(fence.fileDescriptor, { bigint: true });
4471
+ const digest = hashOpenFileDescriptor(fence.fileDescriptor);
4472
+ const after = fs.fstatSync(fence.fileDescriptor, { bigint: true });
4473
+ const named = fs.statSync(fence.backupPath, { bigint: true });
4474
+ const recoveryBefore = fs.fstatSync(fence.recoveryFileDescriptor, { bigint: true });
4475
+ const recoveryDigest = hashOpenFileDescriptor(fence.recoveryFileDescriptor);
4476
+ const recoveryAfter = fs.fstatSync(fence.recoveryFileDescriptor, { bigint: true });
4477
+ const recoveryNamed = fs.statSync(fence.recoveryPath, { bigint: true });
4478
+ if (digest !== fence.backupSha256
4479
+ || before.size !== after.size
4480
+ || before.mtimeNs !== after.mtimeNs
4481
+ || before.dev !== fence.device
4482
+ || before.ino !== fence.inode
4483
+ || named.dev !== fence.device
4484
+ || named.ino !== fence.inode
4485
+ || recoveryDigest !== fence.backupSha256
4486
+ || recoveryBefore.size !== recoveryAfter.size
4487
+ || recoveryBefore.size !== fence.recoverySize
4488
+ || recoveryBefore.mtimeNs !== recoveryAfter.mtimeNs
4489
+ || recoveryBefore.mtimeNs !== fence.recoveryMtimeNs
4490
+ || recoveryBefore.dev !== fence.recoveryDevice
4491
+ || recoveryBefore.ino !== fence.recoveryInode
4492
+ || recoveryNamed.dev !== fence.recoveryDevice
4493
+ || recoveryNamed.ino !== fence.recoveryInode
4494
+ || (recoveryNamed.dev === fence.device && recoveryNamed.ino === fence.inode)) {
4495
+ throw new Error('backup fence changed');
4496
+ }
4497
+ } catch {
4498
+ throw new ClaimRepairError(
4499
+ 'CLAIM_REPAIR_BACKUP_DRIFT',
4500
+ 'Verified claim-repair backup changed before receipt commit',
4501
+ );
4502
+ }
4503
+ }
4504
+
4505
+ function assertClaimRepairBackupSidecarsRemainAbsent(backupPath) {
4506
+ assertNoClaimRepairBackupSidecars(backupPath);
4507
+ const blockers = [];
4508
+ try {
4509
+ for (const suffix of ['-wal', '-shm', '-journal']) {
4510
+ const sidecarPath = `${backupPath}${suffix}`;
4511
+ fs.mkdirSync(sidecarPath);
4512
+ const stat = fs.statSync(sidecarPath, { bigint: true });
4513
+ blockers.push({ path: sidecarPath, device: stat.dev, inode: stat.ino });
4514
+ }
4515
+ return blockers;
4516
+ } catch (error) {
4517
+ for (const blocker of blockers) {
4518
+ try { fs.rmdirSync(blocker.path); } catch { /* cleanup only */ }
4519
+ }
4520
+ throw error;
4521
+ }
4522
+ }
4523
+
4524
+ function assertClaimRepairBackupSidecarBlockersCurrent(blockers = []) {
4525
+ for (const blocker of blockers) {
4526
+ const stat = fs.statSync(blocker.path, { bigint: true });
4527
+ if (!stat.isDirectory() || stat.dev !== blocker.device || stat.ino !== blocker.inode) {
4528
+ throw new Error('backup sidecar blocker changed');
4529
+ }
4530
+ }
4531
+ }
4532
+
4533
+ function removeClaimRepairBackupSidecarBlockers(blockers = []) {
4534
+ for (const blocker of blockers) {
4535
+ try {
4536
+ const stat = fs.statSync(blocker.path, { bigint: true });
4537
+ if (stat.isDirectory() && stat.dev === blocker.device && stat.ino === blocker.inode) {
4538
+ fs.rmdirSync(blocker.path);
4539
+ }
4540
+ } catch { /* cleanup cannot change the committed receipt */ }
4541
+ }
4542
+ }
4543
+
4544
+ function closeClaimRepairBackupFence(fence, options = {}) {
4545
+ if (!fence) return;
4546
+ try { fs.closeSync(fence.fileDescriptor); } catch { /* descriptor cleanup cannot change a committed receipt */ }
4547
+ try { fs.closeSync(fence.recoveryFileDescriptor); } catch { /* descriptor cleanup cannot change a committed receipt */ }
4548
+ if (options.removeRecovery) {
4549
+ try {
4550
+ const recovery = fs.statSync(fence.recoveryPath, { bigint: true });
4551
+ if (recovery.dev === fence.recoveryDevice && recovery.ino === fence.recoveryInode) {
4552
+ fs.rmSync(fence.recoveryPath);
4553
+ }
4554
+ } catch { /* rolled-back cleanup cannot change authority */ }
4555
+ }
4556
+ }
4557
+
4558
+ function injectClaimRepairFault(driverOptions, phase) {
4559
+ if (typeof driverOptions.claimRepairFaultInjector === 'function') {
4560
+ driverOptions.claimRepairFaultInjector(phase);
4561
+ }
4562
+ }
4563
+
4564
+ function assertClaimRepairReplayInput(input = {}) {
4565
+ if (!/^[0-9a-f]{64}$/.test(String(input.approvedDigest || ''))) {
4566
+ throw new ClaimRepairError('CLAIM_REPAIR_APPROVAL_REQUIRED', 'Apply requires the exact approved preflight digest');
4567
+ }
4568
+ if (typeof input.actor !== 'string' || input.actor.trim() === '') {
4569
+ throw new ClaimRepairError('CLAIM_REPAIR_ACTOR_REQUIRED', 'Apply requires an explicit operator actor');
4570
+ }
4571
+ if (typeof input.observedAt !== 'string'
4572
+ || !Number.isFinite(Date.parse(input.observedAt))
4573
+ || new Date(Date.parse(input.observedAt)).toISOString() !== input.observedAt) {
4574
+ throw new ClaimRepairError('CLAIM_REPAIR_INVALID_TIME', 'Apply requires a fixed canonical UTC observation time');
4575
+ }
4576
+ }
4577
+
4578
+ function updateExactClaimRepairAction(runtime, db, action) {
4579
+ const claim = action.claim;
4580
+ const result = runParams(
4581
+ runtime,
4582
+ db,
4583
+ `UPDATE kernel_claims SET state = ?
4584
+ WHERE id = ? AND issue_id = ? AND actor = ? AND state = 'active'
4585
+ AND session_id IS ? AND worktree_id IS ? AND claimed_at = ? AND expires_at IS ?
4586
+ AND EXISTS (
4587
+ SELECT 1 FROM kernel_issues WHERE id = ? AND status = ?
4588
+ )`,
4589
+ [
4590
+ action.to_state,
4591
+ claim.id,
4592
+ claim.issue_id,
4593
+ claim.actor,
4594
+ claim.session_id,
4595
+ claim.worktree_id,
4596
+ claim.claimed_at,
4597
+ claim.expires_at,
4598
+ claim.issue_id,
4599
+ action.issue_status,
4600
+ ],
4601
+ );
4602
+ if (Number(result?.changes || 0) !== 1) {
4603
+ throw new ClaimRepairError(
4604
+ 'CLAIM_REPAIR_CAS_CONFLICT',
4605
+ 'Claim repair exact-row compare-and-swap rejected concurrent drift',
4606
+ );
4607
+ }
4608
+ }
4609
+
4610
+ const claimRepairApplyQueues = new Map();
4611
+
4612
+ function claimRepairApplyQueueKey(databasePath) {
4613
+ const canonicalPath = fs.realpathSync.native(databasePath);
4614
+ return process.platform === 'win32' ? canonicalPath.toLowerCase() : canonicalPath;
4615
+ }
4616
+
4617
+ async function withClaimRepairApplyQueue(databasePath, operation, queue = claimRepairApplyQueues) {
4618
+ const key = claimRepairApplyQueueKey(databasePath);
4619
+ const previous = queue.get(key) || Promise.resolve();
4620
+ let release;
4621
+ const gate = new Promise(resolve => { release = resolve; });
4622
+ const tail = previous.then(() => gate);
4623
+ queue.set(key, tail);
4624
+ await previous;
4625
+ try {
4626
+ return await operation();
4627
+ } finally {
4628
+ release();
4629
+ if (queue.get(key) === tail) queue.delete(key);
4630
+ }
4631
+ }
4632
+
4633
+ async function applyLegacyClaimRepairRow(runtime, db, input = {}, driverOptions = {}) {
4634
+ assertClaimRepairApplyInput(input);
4635
+ const idempotencyKey = `claim.repair:${input.approvedDigest}`;
4636
+ const hardenPath = driverOptions.hardenPath || hardenBackupPermissions;
4637
+ const hardenPaths = driverOptions.hardenPaths
4638
+ || (driverOptions.hardenPath ? driverOptions.hardenPath.batch : hardenBackupPermissionsBatch);
4639
+ let backupFence;
4640
+ let sidecarBlockers = [];
4641
+ let committed = false;
4642
+ let rolledBack = false;
4643
+ execSql(runtime, db, 'BEGIN IMMEDIATE;');
4644
+ try {
4645
+ const existingRow = allParams(
4646
+ runtime,
4647
+ db,
4648
+ 'SELECT * FROM kernel_events WHERE idempotency_key = ? LIMIT 1',
4649
+ [idempotencyKey],
4650
+ )[0];
4651
+ const existingReceipt = parseStoredClaimRepairReceipt(existingRow, input.approvedDigest, input.backupProof);
4652
+ if (existingReceipt) {
4653
+ await verifyClaimRepairRecovery(existingReceipt, input.backupPath, input.databasePath, { hardenPath });
4654
+ execSql(runtime, db, 'COMMIT;');
4655
+ return attachClaimRepairRecoveryPath({ ...existingReceipt, replayed: true }, input.backupPath);
4656
+ }
4657
+ const plan = buildClaimRepairPlan(loadLegacyClaimRepairSnapshot(runtime, db), {
4658
+ observedAt: input.observedAt,
4659
+ });
4660
+ if (plan.digest !== input.approvedDigest) {
4661
+ throw new ClaimRepairError(
4662
+ 'CLAIM_REPAIR_DIGEST_DRIFT',
4663
+ 'Live claim authority changed after approval; generate and approve a new fixed-time preflight',
4664
+ );
4665
+ }
4666
+
4667
+ for (const action of plan.actions) updateExactClaimRepairAction(runtime, db, action);
4668
+ injectClaimRepairFault(driverOptions, 'after-mutations');
4669
+ const afterPlan = buildClaimRepairPlan(loadLegacyClaimRepairSnapshot(runtime, db), {
4670
+ observedAt: input.observedAt,
4671
+ });
4672
+ if (afterPlan.digest !== plan.afterDigest) {
4673
+ throw new ClaimRepairError('CLAIM_REPAIR_POSTCONDITION_FAILED', 'Claim repair postcondition digest did not match the approved plan');
4674
+ }
4675
+ injectClaimRepairFault(driverOptions, 'before-backup-commit-check');
4676
+ const recoveryReference = randomUUID();
4677
+ backupFence = await openClaimRepairBackupFence(
4678
+ input.databasePath,
4679
+ input.backupPath,
4680
+ input.backupProof,
4681
+ recoveryReference,
4682
+ { hardenPath, hardenPaths },
4683
+ );
4684
+ injectClaimRepairFault(driverOptions, 'after-backup-fence');
4685
+
4686
+ const receiptId = randomUUID();
4687
+ const receipt = {
4688
+ schema_version: 'forge.claim-repair.receipt.v1',
4689
+ receipt_id: receiptId,
4690
+ observed_at: input.observedAt,
4691
+ approved_digest: input.approvedDigest,
4692
+ after_digest: afterPlan.digest,
4693
+ backup_sha256: input.backupProof.backup_sha256,
4694
+ recovery_ref: recoveryReference,
4695
+ mutations: {
4696
+ released: plan.actions.filter(action => action.to_state === 'released').length,
4697
+ reclaimable: plan.actions.filter(action => action.to_state === 'reclaimable').length,
4698
+ total: plan.actions.length,
4699
+ },
4700
+ replayed: false,
4701
+ };
4702
+ runParams(
4703
+ runtime,
4704
+ db,
4705
+ `INSERT INTO kernel_events (
4706
+ id, entity_type, entity_id, event_type, idempotency_key,
4707
+ expected_revision, actor, origin, payload_json, created_at
4708
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
4709
+ [
4710
+ receiptId,
4711
+ 'claim_repair',
4712
+ 'legacy_claims',
4713
+ 'claim.repair',
4714
+ idempotencyKey,
4715
+ 0,
4716
+ input.actor,
4717
+ 'forge.claim-repair',
4718
+ JSON.stringify(receipt),
4719
+ new Date().toISOString(),
4720
+ ],
4721
+ );
4722
+ injectClaimRepairFault(driverOptions, 'after-receipt-before-commit');
4723
+ await assertClaimRepairBackupFenceCurrent(backupFence, { hardenPath });
4724
+ sidecarBlockers = assertClaimRepairBackupSidecarsRemainAbsent(input.backupPath);
4725
+ await assertClaimRepairBackupFenceCurrent(backupFence, { hardenPath, sidecarsBlocked: true });
4726
+ execSql(runtime, db, 'COMMIT;');
4727
+ committed = true;
4728
+ injectClaimRepairFault(driverOptions, 'after-commit-before-backup-check');
4729
+ try {
4730
+ await assertClaimRepairBackupFenceCurrent(backupFence, { hardenPath, sidecarsBlocked: true });
4731
+ assertClaimRepairBackupSidecarBlockersCurrent(sidecarBlockers);
4732
+ } catch {
4733
+ throw new ClaimRepairError(
4734
+ 'CLAIM_REPAIR_BACKUP_POSTCOMMIT_DRIFT',
4735
+ 'Claim repair committed, but the named backup changed; preserve the verified recovery path',
4736
+ { recovery_path: backupFence.recoveryPath },
4737
+ );
4738
+ }
4739
+ injectClaimRepairFault(driverOptions, 'after-postcommit-backup-check');
4740
+ return attachClaimRepairRecoveryPath(receipt, input.backupPath);
4741
+ } catch (error) {
4742
+ if (!committed) {
4743
+ try {
4744
+ execSql(runtime, db, 'ROLLBACK;');
4745
+ rolledBack = true;
4746
+ } catch { /* preserve the original failure and recovery copy */ }
4747
+ }
4748
+ throw error;
4749
+ } finally {
4750
+ removeClaimRepairBackupSidecarBlockers(sidecarBlockers);
4751
+ closeClaimRepairBackupFence(backupFence, { removeRecovery: rolledBack });
4752
+ }
4753
+ }
4754
+
4755
+ function createDriver(runtime, configuredDatabasePath, driverOptions = {}) {
4756
+ let db;
4757
+ let openedDatabasePath;
4758
+ let memorySchemaEnsured = false;
4759
+ let usageEvidenceSchemaEnsured = false;
4760
+
4761
+ // kernel_memories is created by migration 005 through broker.initialize(), but the
4762
+ // synchronous project-memory facade writes WITHOUT first running migrations. Lazily
4763
+ // ensure the table (idempotent CREATE IF NOT EXISTS, rendered from the same migration)
4764
+ // plus a busy_timeout for the second connection the issue backend may hold open.
4765
+ function ensureMemorySchema(database, busyTimeoutMs) {
4766
+ if (memorySchemaEnsured) return;
4767
+ const requestedBusyTimeout = Number(busyTimeoutMs);
4768
+ const busyTimeout = Number.isFinite(requestedBusyTimeout) && requestedBusyTimeout >= 0
4769
+ ? Math.floor(requestedBusyTimeout)
4770
+ : 5_000;
4771
+ execSql(runtime, database, `PRAGMA busy_timeout=${busyTimeout};`);
4772
+ try {
4773
+ for (const statement of buildMemoryProjectionMigration().apply) {
4774
+ execSql(runtime, database, statement);
4775
+ }
4776
+ // FTS5 recall index (migration 008): create the virtual table + sync triggers
4777
+ // idempotently so a synchronous memory write stays indexed without a prior
4778
+ // broker.initialize(). When the index is NEWLY created, rebuild once to backfill any
4779
+ // rows written before it existed (a DB upgraded from before this feature, or rows the
4780
+ // insights engine wrote straight to kernel_memories) — the sync triggers keep it
4781
+ // current thereafter, so steady-state process starts skip the reindex.
4782
+ //
4783
+ // Staleness is detected by TABLE EXISTENCE (sqlite_master), never by count(*): on an
4784
+ // external-content FTS5 table `count(*)` returns the CONTENT row count, not the
4785
+ // indexed-doc count, so it can never reveal an un-backfilled index.
4786
+ const ftsDdl = memoryFtsDdl();
4787
+ const ftsExisted = Number(queryOne(
4788
+ runtime,
4789
+ database,
4790
+ "SELECT count(*) AS count FROM sqlite_master WHERE type = 'table' AND name = 'kernel_memories_fts'",
4791
+ ).count) > 0;
4792
+ execSql(runtime, database, ftsDdl.create);
4793
+ for (const trigger of ftsDdl.triggers) {
4794
+ execSql(runtime, database, trigger);
4795
+ }
4796
+ if (!ftsExisted) {
4797
+ execSql(runtime, database, ftsDdl.rebuild);
4798
+ }
4799
+ memorySchemaEnsured = true;
4800
+ } finally {
4801
+ if (busyTimeout !== 5_000) {
4802
+ execSql(runtime, database, 'PRAGMA busy_timeout=5000;');
4803
+ }
4804
+ }
4805
+ }
4806
+
4807
+ function ensureUsageEvidenceSchema(database) {
4808
+ if (usageEvidenceSchemaEnsured) return;
4809
+ for (const statement of buildUsageEvidenceMigration().apply) {
4810
+ execSql(runtime, database, statement);
4811
+ }
4812
+ usageEvidenceSchemaEnsured = true;
4813
+ }
4814
+
4815
+ function resolveDatabasePath(config) {
4816
+ const brokerDatabasePath = config && config.databasePath;
4817
+ if (configuredDatabasePath && brokerDatabasePath && configuredDatabasePath !== brokerDatabasePath) {
4818
+ throw new Error([
4819
+ 'Kernel SQLite driver databasePath mismatch:',
4820
+ `driver is configured for ${configuredDatabasePath}`,
4821
+ `but broker config uses ${brokerDatabasePath}`,
4822
+ ].join(' '));
4823
+ }
4824
+ const databasePath = brokerDatabasePath || configuredDatabasePath;
4825
+ if (!databasePath) {
2155
4826
  throw new Error('Kernel SQLite driver requires a databasePath or broker config databasePath');
2156
4827
  }
2157
4828
  return databasePath;
@@ -2161,6 +4832,7 @@ function createDriver(runtime, configuredDatabasePath) {
2161
4832
  const databasePath = resolveDatabasePath(config);
2162
4833
  if (!db) {
2163
4834
  db = createDatabase(runtime, databasePath);
4835
+ execSql(runtime, db, 'PRAGMA foreign_keys=ON;');
2164
4836
  openedDatabasePath = databasePath;
2165
4837
  } else if (openedDatabasePath !== databasePath) {
2166
4838
  throw new Error(`Kernel SQLite driver is already open for ${openedDatabasePath}`);
@@ -2168,7 +4840,7 @@ function createDriver(runtime, configuredDatabasePath) {
2168
4840
  return db;
2169
4841
  }
2170
4842
 
2171
- return {
4843
+ const driver = {
2172
4844
  runtime: {
2173
4845
  id: runtime.id,
2174
4846
  databaseClassName: runtime.databaseClassName,
@@ -2176,12 +4848,91 @@ function createDriver(runtime, configuredDatabasePath) {
2176
4848
  experimental: runtime.experimental,
2177
4849
  },
2178
4850
  databasePath: configuredDatabasePath,
4851
+ forkConnection(config = {}) {
4852
+ const databasePath = resolveDatabasePath(config);
4853
+ if (databasePath === ':memory:') {
4854
+ const fork = Object.create(driver);
4855
+ Object.defineProperty(fork, 'close', { value() {} });
4856
+ Object.defineProperty(fork, 'transactionQueueKey', { value: driver });
4857
+ return fork;
4858
+ }
4859
+ return createDriver(runtime, databasePath, driverOptions);
4860
+ },
2179
4861
  async exec(statement, config) {
2180
4862
  execSql(runtime, getDatabase(config), statement);
2181
4863
  },
2182
4864
  async queryAll(statement, config) {
2183
4865
  return queryAll(runtime, getDatabase(config), statement);
2184
4866
  },
4867
+ watchOwnerRead(input, config = {}) {
4868
+ return readWatchOwner(runtime, resolveDatabasePath(config), { ...driverOptions, ...config }, input);
4869
+ },
4870
+ watchOwnerList(config = {}) {
4871
+ return listWatchOwners(runtime, resolveDatabasePath(config), { ...driverOptions, ...config });
4872
+ },
4873
+ watchOwnerReserveStarting(input, config = {}) {
4874
+ return applyWatchOwnerOperation(runtime, resolveDatabasePath(config), { ...driverOptions, ...config }, 'reserveStarting', input);
4875
+ },
4876
+ watchOwnerReserveReopened(input, config = {}) {
4877
+ return applyWatchOwnerOperation(runtime, resolveDatabasePath(config), { ...driverOptions, ...config }, 'reserveReopened', input);
4878
+ },
4879
+ watchOwnerBindRunning(input, config = {}) {
4880
+ return applyWatchOwnerOperation(runtime, resolveDatabasePath(config), { ...driverOptions, ...config }, 'bindRunning', input);
4881
+ },
4882
+ watchOwnerHeartbeat(input, config = {}) {
4883
+ return applyWatchOwnerOperation(runtime, resolveDatabasePath(config), { ...driverOptions, ...config }, 'heartbeat', input);
4884
+ },
4885
+ watchOwnerRequestStop(input, config = {}) {
4886
+ return applyWatchOwnerOperation(runtime, resolveDatabasePath(config), { ...driverOptions, ...config }, 'requestStop', input);
4887
+ },
4888
+ watchOwnerRecordTerminal(input, config = {}) {
4889
+ return applyWatchOwnerOperation(runtime, resolveDatabasePath(config), { ...driverOptions, ...config }, 'recordTerminal', input);
4890
+ },
4891
+ watchOwnerCompleteTerminal(input, config = {}) {
4892
+ return applyWatchOwnerOperation(runtime, resolveDatabasePath(config), { ...driverOptions, ...config }, 'completeTerminal', input);
4893
+ },
4894
+ watchOwnerAbortStarting(input, config = {}) {
4895
+ return applyWatchOwnerOperation(runtime, resolveDatabasePath(config), { ...driverOptions, ...config }, 'abortStarting', input);
4896
+ },
4897
+ watchOwnerReleaseNonterminal(input, config = {}) {
4898
+ return applyWatchOwnerOperation(runtime, resolveDatabasePath(config), { ...driverOptions, ...config }, 'releaseNonterminal', input);
4899
+ },
4900
+ watchOwnerRecoverDeadStarting(input, config = {}) {
4901
+ return applyWatchOwnerOperation(runtime, resolveDatabasePath(config), { ...driverOptions, ...config }, 'recoverDeadStarting', input);
4902
+ },
4903
+ watchOwnerRecoverDeadWatcher(input, config = {}) {
4904
+ return applyWatchOwnerOperation(runtime, resolveDatabasePath(config), { ...driverOptions, ...config }, 'recoverDeadWatcher', input);
4905
+ },
4906
+ watchOwnerMarkLegacyBlocked(input, config = {}) {
4907
+ return applyWatchOwnerOperation(runtime, resolveDatabasePath(config), { ...driverOptions, ...config }, 'markLegacyBlocked', input);
4908
+ },
4909
+ watchOwnerRecheckLegacyBlocked(input, config = {}) {
4910
+ return applyWatchOwnerOperation(runtime, resolveDatabasePath(config), { ...driverOptions, ...config }, 'recheckLegacyBlocked', input);
4911
+ },
4912
+ watchOwnerImportLegacyStarting(input, config = {}) {
4913
+ return applyWatchOwnerOperation(runtime, resolveDatabasePath(config), { ...driverOptions, ...config }, 'importLegacyStarting', input);
4914
+ },
4915
+ watchOwnerImportLegacyComplete(input, config = {}) {
4916
+ return applyWatchOwnerOperation(runtime, resolveDatabasePath(config), { ...driverOptions, ...config }, 'importLegacyComplete', input);
4917
+ },
4918
+ watchGateRead(_input = {}, config = {}) {
4919
+ return readWatchGate(runtime, resolveDatabasePath(config), { ...driverOptions, ...config });
4920
+ },
4921
+ watchGatePublishQuarantine(input, config = {}) {
4922
+ return applyWatchGateOperation(runtime, resolveDatabasePath(config), { ...driverOptions, ...config }, 'publishQuarantine', input);
4923
+ },
4924
+ watchGateBindSnapshot(input, config = {}) {
4925
+ return applyWatchGateOperation(runtime, resolveDatabasePath(config), { ...driverOptions, ...config }, 'bindSnapshot', input);
4926
+ },
4927
+ watchGatePublishConflict(input, config = {}) {
4928
+ return applyWatchGateOperation(runtime, resolveDatabasePath(config), { ...driverOptions, ...config }, 'publishConflict', input);
4929
+ },
4930
+ watchGateRetryConflict(input, config = {}) {
4931
+ return applyWatchGateOperation(runtime, resolveDatabasePath(config), { ...driverOptions, ...config }, 'retryConflict', input);
4932
+ },
4933
+ watchGateCompleteMigration(input, config = {}) {
4934
+ return applyWatchGateOperation(runtime, resolveDatabasePath(config), { ...driverOptions, ...config }, 'completeMigration', input);
4935
+ },
2185
4936
  async issueOperation(operation, args = [], context = {}, config = {}) {
2186
4937
  const database = getDatabase(config);
2187
4938
  const READ_OPERATIONS = new Set(['ready', 'list', 'show', 'search', 'stats', 'blocked', 'stale', 'orphans', 'lint', 'children', 'owns', 'claims']);
@@ -2277,7 +5028,22 @@ function createDriver(runtime, configuredDatabasePath) {
2277
5028
  registeredAt,
2278
5029
  ],
2279
5030
  );
2280
- return { ok: true, id };
5031
+ const persisted = allParams(
5032
+ runtime,
5033
+ getDatabase(config),
5034
+ 'SELECT * FROM kernel_pr WHERE git_common_dir = ? AND repo = ? AND number = ? LIMIT 1',
5035
+ [row.git_common_dir, row.repo, row.number],
5036
+ )[0];
5037
+ if (!persisted) {
5038
+ throw new Error(`Kernel SQLite driver upsertPr: no kernel_pr row after upsert for ${row.repo}#${row.number}`);
5039
+ }
5040
+ return { ok: true, ...persisted };
5041
+ },
5042
+ async resolvePrLinkage(input, _context = {}, config = {}) {
5043
+ return resolvePrLinkageRow(runtime, getDatabase(config), input);
5044
+ },
5045
+ async readTrace(target, _context = {}, config = {}) {
5046
+ return loadTraceRows(runtime, getDatabase(config), target);
2281
5047
  },
2282
5048
  // The ONE verdict authority WRITE (design §1.2 rule 2) — FRESHEST-HEAD-SHA
2283
5049
  // PRECEDENCE enforced in the WHERE so a verdict computed against a SUPERSEDED head is
@@ -2335,9 +5101,19 @@ function createDriver(runtime, configuredDatabasePath) {
2335
5101
  async listKernelEvents(entityType, entityId, _context = {}, config = {}) {
2336
5102
  return listKernelEventRows(runtime, getDatabase(config), entityType, entityId);
2337
5103
  },
5104
+ // Additive bulk read for `forge insights` (Slice C2). Read-only; NOT part of the
5105
+ // GUARDED_DRIVER_METHODS write-path contract, so existing driver stubs stay valid.
5106
+ async listRecentKernelEvents({ since = null, limit = null } = {}, _context = {}, config = {}) {
5107
+ return listRecentKernelEventRows(runtime, getDatabase(config), since, limit);
5108
+ },
2338
5109
  async loadKernelEventByIdempotencyKey(idempotencyKey, _context = {}, config = {}) {
2339
5110
  return loadKernelEventByIdempotencyKeyRow(runtime, getDatabase(config), idempotencyKey);
2340
5111
  },
5112
+ async loadPrEventsByRunId(runId, _context = {}, config = {}) {
5113
+ return allParams(runtime, getDatabase(config),
5114
+ "SELECT * FROM kernel_events WHERE entity_type = 'pr' AND json_extract(payload_json, '$.run_id') = ? ORDER BY created_at ASC, rowid ASC",
5115
+ [runId]);
5116
+ },
2341
5117
  async insertKernelConflict(conflict, _context = {}, config = {}) {
2342
5118
  return insertKernelConflictRow(runtime, getDatabase(config), conflict);
2343
5119
  },
@@ -2380,6 +5156,69 @@ function createDriver(runtime, configuredDatabasePath) {
2380
5156
  async updateKernelClaimState(claimId, state, _context = {}, config = {}) {
2381
5157
  return updateKernelClaimStateRow(runtime, getDatabase(config), claimId, state);
2382
5158
  },
5159
+ async listActiveClaims(_context = {}, config = {}) {
5160
+ return listActiveKernelClaimRows(runtime, getDatabase(config));
5161
+ },
5162
+ async releaseExactClaim(claim, _evidence = {}, config = {}) {
5163
+ return releaseExactKernelClaimRow(runtime, getDatabase(config), claim);
5164
+ },
5165
+ async releaseExactClaimIfWorktreeMissing(claim, worktree, isMissing, _evidence = {}, config = {}) {
5166
+ return releaseExactKernelClaimIfWorktreeMissing(
5167
+ runtime, getDatabase(config), claim, worktree, isMissing,
5168
+ );
5169
+ },
5170
+ // Explicit operator-only legacy data repair. This surface is never called by
5171
+ // broker initialization or normal claim acquisition: dry-run is immutable,
5172
+ // while apply requires a separately verified backup and approved exact digest.
5173
+ async preflightLegacyClaimRepair(input = {}, config = {}) {
5174
+ return preflightLegacyClaimRepairRow(runtime, getDatabase(config), input);
5175
+ },
5176
+ async applyLegacyClaimRepair(input = {}, config = {}) {
5177
+ assertClaimRepairReplayInput(input);
5178
+ const databasePath = resolveDatabasePath(config);
5179
+ return withClaimRepairApplyQueue(databasePath, async () => {
5180
+ const database = createDatabase(runtime, databasePath);
5181
+ const hardenPath = driverOptions.hardenPath || hardenBackupPermissions;
5182
+ const hardenPaths = driverOptions.hardenPaths
5183
+ || (driverOptions.hardenPath ? driverOptions.hardenPath.batch : hardenBackupPermissionsBatch);
5184
+ try {
5185
+ execSql(runtime, database, 'PRAGMA foreign_keys=ON;');
5186
+ const replayed = replayStoredClaimRepairReceipt(
5187
+ runtime,
5188
+ database,
5189
+ input.approvedDigest,
5190
+ input.observedAt,
5191
+ input.backupPath,
5192
+ );
5193
+ if (replayed) {
5194
+ await verifyClaimRepairRecovery(replayed, input.backupPath, databasePath, { hardenPath });
5195
+ return replayed;
5196
+ }
5197
+ if (!input.backupPath) {
5198
+ throw new ClaimRepairError(
5199
+ 'CLAIM_REPAIR_BACKUP_PROOF_REQUIRED',
5200
+ 'Apply requires the path to a separately verified SQLite backup',
5201
+ );
5202
+ }
5203
+ assertSafeBackupDestination(databasePath, input.backupPath);
5204
+ const backupProof = await verifyClaimRepairBackup({
5205
+ backupPath: input.backupPath,
5206
+ observedAt: input.observedAt,
5207
+ openDriver: restorePath => createDriver(runtime, restorePath),
5208
+ hardenPath,
5209
+ hardenPaths,
5210
+ });
5211
+ return await applyLegacyClaimRepairRow(
5212
+ runtime,
5213
+ database,
5214
+ { ...input, databasePath, backupProof },
5215
+ driverOptions,
5216
+ );
5217
+ } finally {
5218
+ closeDatabase(database);
5219
+ }
5220
+ }, driverOptions.claimRepairApplyQueue);
5221
+ },
2383
5222
  // commitGuardedAccept invokes this (typeof-guarded) INSIDE its BEGIN IMMEDIATE
2384
5223
  // transaction to apply an accepted issue event to the authority tables. The
2385
5224
  // returned summary ({id, revision, comment_id?}) flows back through
@@ -2427,6 +5266,12 @@ function createDriver(runtime, configuredDatabasePath) {
2427
5266
  recordStageTransition(input, config = {}) {
2428
5267
  return recordStageTransitionRow(runtime, getDatabase(config), input);
2429
5268
  },
5269
+ recordPlanSnapshotTransition(input, config = {}) {
5270
+ return recordPlanSnapshotTransitionRow(runtime, getDatabase(config), input);
5271
+ },
5272
+ loadPlanSnapshot(filter = {}, config = {}) {
5273
+ return loadPlanSnapshotRow(runtime, getDatabase(config), filter.issue_id);
5274
+ },
2430
5275
  listStageRuns(filter = {}, config = {}) {
2431
5276
  return listStageRunRows(runtime, getDatabase(config), filter.issue_id);
2432
5277
  },
@@ -2453,42 +5298,111 @@ function createDriver(runtime, configuredDatabasePath) {
2453
5298
  searchMemoriesRanked(query, limit, config = {}) {
2454
5299
  const database = getDatabase(config);
2455
5300
  ensureMemorySchema(database);
2456
- return searchMemoryRowsRanked(runtime, database, query, limit);
5301
+ return searchMemoryRowsRanked(runtime, database, query, limit, config);
2457
5302
  },
2458
5303
  // Relevance-only BM25 recall that also returns the raw bm25 `score` per entry, so a
2459
5304
  // caller can apply a relevance floor. A no-match/empty query returns [] (no recency
2460
5305
  // fallback). Used by the per-turn memory-recall hook.
2461
5306
  searchMemoriesRankedScored(query, limit, config = {}) {
2462
5307
  const database = getDatabase(config);
2463
- ensureMemorySchema(database);
2464
- return searchMemoryRowsRankedScored(runtime, database, query, limit);
5308
+ ensureMemorySchema(database, config.busyTimeoutMs);
5309
+ return searchMemoryRowsRankedScored(runtime, database, query, limit, config);
2465
5310
  },
2466
5311
  // The newest `limit` entries (default recall with no query). `options.agents` scopes
2467
5312
  // the read to a source_agent allow-list (e.g. human `remember` notes only).
2468
5313
  recentMemories(limit, options = {}, config = {}) {
2469
5314
  const database = getDatabase(config);
2470
5315
  ensureMemorySchema(database);
2471
- return recentMemoryRows(runtime, database, limit, options.agents);
5316
+ return recentMemoryRows(runtime, database, limit, options);
2472
5317
  },
2473
- // Total stored memories (optionally scoped by `options.agents`) — lets recall report
5318
+ // Total stored memories (optionally scoped by `options.agents` and `options.kind`) — lets recall report
2474
5319
  // "showing N of TOTAL".
2475
5320
  countMemories(options = {}, config = {}) {
2476
5321
  const database = getDatabase(config);
2477
5322
  ensureMemorySchema(database);
2478
- return countMemoryRows(runtime, database, options.agents);
5323
+ return countMemoryRows(runtime, database, options);
5324
+ },
5325
+ appendUsageEvidence(event, config = {}) {
5326
+ const database = getDatabase(config);
5327
+ ensureUsageEvidenceSchema(database);
5328
+ return appendUsageEvidence(createUsageEvidenceAdapter(runtime, database), event);
5329
+ },
5330
+ rebuildUsageProjection(config = {}) {
5331
+ const database = getDatabase(config);
5332
+ ensureUsageEvidenceSchema(database);
5333
+ return rebuildUsageProjection(createUsageEvidenceAdapter(runtime, database));
5334
+ },
5335
+ loadUsageProjection(memoryId, config = {}) {
5336
+ const database = getDatabase(config);
5337
+ ensureUsageEvidenceSchema(database);
5338
+ return allParams(runtime, database,
5339
+ 'SELECT scope, memory_id, last_used_at, use_count FROM memory_usage_projection WHERE memory_id = ?', [memoryId])[0] || null;
5340
+ },
5341
+ loadUsageProjections(memoryIds = [], config = {}) {
5342
+ const database = getDatabase(config);
5343
+ ensureUsageEvidenceSchema(database);
5344
+ const ids = [...new Set(Array.isArray(memoryIds) ? memoryIds : [])];
5345
+ if (ids.length === 0) return [];
5346
+ return allParams(runtime, database,
5347
+ `SELECT scope, memory_id, last_used_at, use_count FROM memory_usage_projection WHERE memory_id IN (${ids.map(() => '?').join(', ')})`, ids);
2479
5348
  },
2480
5349
  listMemories(config = {}) {
2481
5350
  const database = getDatabase(config);
2482
5351
  ensureMemorySchema(database);
2483
5352
  return listMemoryRows(runtime, database);
2484
5353
  },
5354
+ // --- Memory-owned monitor durability. Public callers use @forge/memory, while
5355
+ // these primitives independently reject malformed/private data as defense in depth.
5356
+ async appendMonitorEvent(envelope, targets = [], config = {}) {
5357
+ const database = getDatabase(config);
5358
+ const safeEnvelope = assertMonitorEnvelope(envelope, 'forge.memory.monitor-event.v1');
5359
+ return appendMonitorEventRow(runtime, database, safeEnvelope, normalizeMonitorTargets(targets), config);
5360
+ },
5361
+ async recordMonitorDeliveryReceipt(envelope, config = {}) {
5362
+ const database = getDatabase(config);
5363
+ const safeEnvelope = assertMonitorEnvelope(envelope, 'forge.memory.delivery-receipt.v1');
5364
+ assertMonitorTarget(safeEnvelope.payload.target);
5365
+ return recordMonitorDeliveryReceiptRow(runtime, database, safeEnvelope, config);
5366
+ },
5367
+ async recordMonitorTerminalReceipt(envelope, config = {}) {
5368
+ const database = getDatabase(config);
5369
+ const safeEnvelope = assertMonitorEnvelope(envelope, 'forge.memory.monitor-receipt.v1');
5370
+ return recordMonitorTerminalReceiptRow(runtime, database, safeEnvelope, config);
5371
+ },
5372
+ async getMonitorEvent(eventId, config = {}) {
5373
+ if (typeof eventId !== 'string' || !eventId || eventId.length > 255) {
5374
+ throw new TypeError('eventId must be a bounded non-empty string');
5375
+ }
5376
+ return getMonitorEventRow(runtime, getDatabase(config), eventId);
5377
+ },
5378
+ async readMonitorEventTail(monitorId, options = {}, config = {}) {
5379
+ return readMonitorEventTailRows(runtime, getDatabase(config), monitorId, options);
5380
+ },
5381
+ async readMonitorDeliveryState(monitorId, options = {}, config = {}) {
5382
+ return readMonitorDeliveryStateRows(runtime, getDatabase(config), monitorId, options);
5383
+ },
5384
+ async listMonitorEvents(monitorId, config = {}) {
5385
+ return listMonitorEventRows(runtime, getDatabase(config), monitorId);
5386
+ },
5387
+ async backup(destinationPath, config = {}, backupOptions = {}) {
5388
+ const databasePath = resolveDatabasePath(config);
5389
+ return createSafeBackup(
5390
+ runtime,
5391
+ getDatabase(config),
5392
+ databasePath,
5393
+ destinationPath,
5394
+ { ...driverOptions, ...backupOptions },
5395
+ );
5396
+ },
2485
5397
  close() {
2486
5398
  closeDatabase(db);
2487
5399
  db = null;
2488
5400
  openedDatabasePath = null;
2489
5401
  memorySchemaEnsured = false;
5402
+ usageEvidenceSchemaEnsured = false;
2490
5403
  },
2491
5404
  };
5405
+ return driver;
2492
5406
  }
2493
5407
 
2494
5408
  function assertCapability(runtime, capability, detail) {
@@ -2595,13 +5509,354 @@ async function createBackup(runtime, db, backupPath) {
2595
5509
  if (typeof db.serialize !== 'function') {
2596
5510
  throw new Error('bun:sqlite Database.serialize() is unavailable');
2597
5511
  }
2598
- fs.writeFileSync(backupPath, db.serialize());
5512
+ fs.writeFileSync(backupPath, db.serialize(), { mode: 0o600 });
2599
5513
  return;
2600
5514
  }
2601
5515
 
2602
5516
  throw new Error(`Unsupported builtin SQLite runtime: ${runtime.id}`);
2603
5517
  }
2604
5518
 
5519
+ function comparableFilePath(filePath) {
5520
+ const resolved = path.resolve(filePath);
5521
+ return process.platform === 'win32' ? resolved.toLowerCase() : resolved;
5522
+ }
5523
+
5524
+ function assertSafeBackupDestination(databasePath, backupPath) {
5525
+ if (!backupPath || databasePath === ':memory:' || String(databasePath).startsWith('file:')) {
5526
+ throw new Error('SQLite backup requires distinct file-backed source and destination paths');
5527
+ }
5528
+ const source = comparableFilePath(databasePath);
5529
+ const destination = comparableFilePath(backupPath);
5530
+ if ([source, `${source}-wal`, `${source}-shm`, `${source}-journal`].includes(destination)) {
5531
+ throw new Error('Backup destination must not alias the live SQLite database or its sidecars');
5532
+ }
5533
+ if (fs.existsSync(backupPath)) {
5534
+ const destinationStat = fs.statSync(backupPath, { bigint: true });
5535
+ for (const sourcePath of [databasePath, `${databasePath}-wal`, `${databasePath}-shm`, `${databasePath}-journal`]) {
5536
+ if (!fs.existsSync(sourcePath)) continue;
5537
+ const sourceStat = fs.statSync(sourcePath, { bigint: true });
5538
+ if (sourceStat.dev === destinationStat.dev && sourceStat.ino === destinationStat.ino) {
5539
+ throw new Error('Backup destination must not alias the live SQLite database or its sidecars');
5540
+ }
5541
+ }
5542
+ }
5543
+ }
5544
+
5545
+ function verifyBackupFile(runtime, backupPath) {
5546
+ const backupDb = createDatabase(runtime, backupPath);
5547
+ try {
5548
+ const row = queryOne(runtime, backupDb, 'PRAGMA integrity_check;');
5549
+ if (String(row.integrity_check || '').toLowerCase() !== 'ok') {
5550
+ throw new Error('SQLite backup integrity verification failed');
5551
+ }
5552
+ } finally {
5553
+ closeDatabase(backupDb);
5554
+ }
5555
+ }
5556
+
5557
+ async function hardenBackupPermissions(filePath, options = {}) {
5558
+ const platform = options.platform || process.platform;
5559
+ const fsApi = options.fsApi || fs;
5560
+ const effectiveUserId = options.effectiveUserId ?? (typeof process.geteuid === 'function' ? process.geteuid() : null);
5561
+ try {
5562
+ if (platform === 'win32') {
5563
+ const aclSecurer = options.aclSecurer || secureWindowsPathAcl;
5564
+ await aclSecurer(filePath);
5565
+ return;
5566
+ }
5567
+ const initial = fsApi.statSync(filePath);
5568
+ if (effectiveUserId !== null && Number(initial.uid) !== Number(effectiveUserId)) {
5569
+ throw new Error('path is not owned by the current effective user');
5570
+ }
5571
+ const ownerMode = typeof initial.isDirectory === 'function' && initial.isDirectory() ? 0o700 : 0o600;
5572
+ fsApi.chmodSync(filePath, ownerMode);
5573
+ if ((Number(fsApi.statSync(filePath).mode) & 0o077) !== 0) {
5574
+ throw new Error(`mode remains broader than ${ownerMode.toString(8)}`);
5575
+ }
5576
+ } catch (error) {
5577
+ throw new ClaimRepairError(
5578
+ 'CLAIM_REPAIR_BACKUP_PERMISSIONS',
5579
+ 'SQLite backup could not be secured with owner-only permissions',
5580
+ { cause: error.message || String(error) },
5581
+ );
5582
+ }
5583
+ }
5584
+
5585
+ async function hardenBackupPermissionsBatch(filePaths, options = {}) {
5586
+ if (!Array.isArray(filePaths) || filePaths.length === 0) return;
5587
+ const paths = [...new Set(filePaths)];
5588
+ const platform = options.platform || process.platform;
5589
+ if (platform === 'win32' && !options.aclSecurer && !options.fsApi) {
5590
+ try {
5591
+ // One owner readback serves the whole batch after each DACL is secured.
5592
+ await secureWindowsPathsAcl(paths);
5593
+ return;
5594
+ } catch (error) {
5595
+ throw new ClaimRepairError(
5596
+ 'CLAIM_REPAIR_BACKUP_PERMISSIONS',
5597
+ 'SQLite backup could not be secured with owner-only permissions',
5598
+ { cause: error.message || String(error) },
5599
+ );
5600
+ }
5601
+ }
5602
+ for (const filePath of paths) await hardenBackupPermissions(filePath, options);
5603
+ }
5604
+
5605
+ const WINDOWS_PRIVATE_ACL_SCRIPT_PATH = path.join(__dirname, 'windows-private-acl.js');
5606
+ const WINDOWS_PRIVATE_ACL_SCRIPT_RELATIVE_PATH = path.join('lib', 'kernel', 'windows-private-acl.js');
5607
+ const WINDOWS_PRIVATE_ACL_SCRIPT_SHA256 = '97f44c740bb843fcca0ea158e21d201b4e34b81eb1636a6a9e9410803a31e349';
5608
+
5609
+ function resolveWindowsSystemExecutable(name, environment = process.env) {
5610
+ const systemRoot = environment.SystemRoot;
5611
+ if (typeof systemRoot !== 'string' || !path.win32.isAbsolute(systemRoot)) {
5612
+ throw new Error('Windows backup hardening requires an absolute SystemRoot');
5613
+ }
5614
+ return path.win32.join(systemRoot, 'System32', name);
5615
+ }
5616
+
5617
+ function resolveWindowsCscriptPath(environment = process.env) {
5618
+ return resolveWindowsSystemExecutable('cscript.exe', environment);
5619
+ }
5620
+
5621
+ function windowsAclScriptPath(fsApi = fs, packageRoot = getPackageRoot()) {
5622
+ const scriptPath = path.join(packageRoot, WINDOWS_PRIVATE_ACL_SCRIPT_RELATIVE_PATH);
5623
+ const script = fsApi.readFileSync(scriptPath);
5624
+ const digest = createHash('sha256').update(script).digest('hex');
5625
+ if (digest !== WINDOWS_PRIVATE_ACL_SCRIPT_SHA256) {
5626
+ throw new Error('Windows ACL hardening script integrity check failed');
5627
+ }
5628
+ return scriptPath;
5629
+ }
5630
+
5631
+ async function runWindowsProcess(command, args, options = {}) {
5632
+ const runtime = options.runtime || (process.versions.bun ? 'bun' : 'node');
5633
+ const timeout = options.timeout;
5634
+ if (!Number.isFinite(timeout) || timeout <= 0) throw new Error('Windows ACL hardening subprocess timed out');
5635
+ const setTimer = options.setTimer || setTimeout;
5636
+ const clearTimer = options.clearTimer || clearTimeout;
5637
+ let timedOut = false;
5638
+ let exitCode;
5639
+ let stdout;
5640
+
5641
+ if (runtime === 'bun') {
5642
+ const spawn = options.bunSpawn || globalThis.Bun.spawn;
5643
+ const child = spawn([command, ...args], {
5644
+ cwd: options.cwd,
5645
+ env: options.env,
5646
+ stderr: 'ignore',
5647
+ stdout: options.captureStdout ? 'pipe' : 'ignore',
5648
+ windowsHide: false,
5649
+ });
5650
+ const timer = setTimer(() => {
5651
+ timedOut = true;
5652
+ child.kill('SIGKILL');
5653
+ }, timeout);
5654
+ try {
5655
+ const output = options.captureStdout ? new Response(child.stdout).text() : Promise.resolve('');
5656
+ [exitCode, stdout] = await Promise.all([child.exited, output]);
5657
+ } finally {
5658
+ clearTimer(timer);
5659
+ }
5660
+ } else {
5661
+ const spawn = options.nodeSpawn || nodeSpawn;
5662
+ const child = spawn(command, args, {
5663
+ cwd: options.cwd,
5664
+ env: options.env,
5665
+ stdio: ['ignore', options.captureStdout ? 'pipe' : 'ignore', 'ignore'],
5666
+ windowsHide: false,
5667
+ });
5668
+ const chunks = [];
5669
+ if (options.captureStdout) child.stdout.on('data', chunk => chunks.push(Buffer.from(chunk)));
5670
+ const timer = setTimer(() => {
5671
+ timedOut = true;
5672
+ child.kill('SIGKILL');
5673
+ }, timeout);
5674
+ try {
5675
+ exitCode = await new Promise((resolve, reject) => {
5676
+ child.once('error', reject);
5677
+ child.once('close', resolve);
5678
+ });
5679
+ stdout = options.captureStdout ? Buffer.concat(chunks).toString('utf8') : '';
5680
+ } finally {
5681
+ clearTimer(timer);
5682
+ }
5683
+ }
5684
+
5685
+ if (timedOut) throw new Error('Windows ACL hardening subprocess timed out');
5686
+ if (exitCode !== 0) throw new Error(`Windows ACL hardening subprocess failed (exit ${exitCode})`);
5687
+ return stdout;
5688
+ }
5689
+
5690
+ const WHOAMI_SID_CSV_ERROR = 'Windows ACL hardening could not resolve the current SID';
5691
+
5692
+ function readQuotedCsvCharacter(input, index, state) {
5693
+ const character = input[index];
5694
+ if (character === '\r' || character === '\n') {
5695
+ throw new Error(WHOAMI_SID_CSV_ERROR);
5696
+ }
5697
+ if (character !== '"') {
5698
+ state.field += character;
5699
+ return index;
5700
+ }
5701
+ if (input[index + 1] === '"') {
5702
+ state.field += '"';
5703
+ return index + 1;
5704
+ }
5705
+ state.inQuotes = false;
5706
+ state.closedQuote = true;
5707
+ return index;
5708
+ }
5709
+
5710
+ function readUnquotedCsvCharacter(character, state) {
5711
+ if (character === '"') {
5712
+ if (state.field !== '') {
5713
+ throw new Error(WHOAMI_SID_CSV_ERROR);
5714
+ }
5715
+ state.inQuotes = true;
5716
+ return;
5717
+ }
5718
+ if (character === ',') {
5719
+ state.fields.push(state.field);
5720
+ state.field = '';
5721
+ return;
5722
+ }
5723
+ if (character === '\r' || character === '\n') {
5724
+ throw new Error(WHOAMI_SID_CSV_ERROR);
5725
+ }
5726
+ state.field += character;
5727
+ }
5728
+
5729
+ function parseWhoamiCsvRecord(stdout) {
5730
+ const input = String(stdout).replace(/\r?\n$/, '');
5731
+ const state = { fields: [], field: '', inQuotes: false, closedQuote: false };
5732
+ for (let index = 0; index < input.length; index += 1) {
5733
+ const character = input[index];
5734
+ if (state.inQuotes) {
5735
+ index = readQuotedCsvCharacter(input, index, state);
5736
+ } else if (state.closedQuote) {
5737
+ if (character !== ',') {
5738
+ throw new Error(WHOAMI_SID_CSV_ERROR);
5739
+ }
5740
+ state.fields.push(state.field);
5741
+ state.field = '';
5742
+ state.closedQuote = false;
5743
+ } else {
5744
+ readUnquotedCsvCharacter(character, state);
5745
+ }
5746
+ }
5747
+ if (state.inQuotes) throw new Error(WHOAMI_SID_CSV_ERROR);
5748
+ state.fields.push(state.field);
5749
+ return state.fields;
5750
+ }
5751
+
5752
+ function parseWhoamiSid(stdout) {
5753
+ const fields = parseWhoamiCsvRecord(stdout);
5754
+ const sid = fields[1];
5755
+ if (fields.length !== 2 || !/^S-1-[0-9]+(?:-[0-9]+)+$/.test(sid || '')) {
5756
+ throw new Error('Windows ACL hardening could not resolve the current SID');
5757
+ }
5758
+ return sid;
5759
+ }
5760
+
5761
+ async function secureWindowsPathsAcl(filePaths, options = {}) {
5762
+ if (!Array.isArray(filePaths) || filePaths.length === 0 || filePaths.length > 128) {
5763
+ throw new Error('Windows ACL hardening requires one to 128 targets');
5764
+ }
5765
+ for (const filePath of filePaths) {
5766
+ if (typeof filePath !== 'string' || !filePath || filePath.includes('\0')) {
5767
+ throw new Error('Windows ACL hardening requires valid target paths');
5768
+ }
5769
+ }
5770
+ const targetPaths = [...new Set(filePaths.map(filePath => path.win32.resolve(filePath)))];
5771
+ const environment = options.environment || process.env;
5772
+ const cscriptPath = resolveWindowsCscriptPath(environment);
5773
+ const whoamiPath = resolveWindowsSystemExecutable('whoami.exe', environment);
5774
+ const systemDirectory = path.win32.dirname(cscriptPath);
5775
+ const scriptPath = windowsAclScriptPath(options.fsApi || fs, options.packageRoot || getPackageRoot());
5776
+ const now = options.now || Date.now;
5777
+ const deadline = now() + (options.timeout || 15_000);
5778
+ const remainingTimeout = () => {
5779
+ const remaining = deadline - now();
5780
+ if (remaining <= 0) throw new Error('Windows ACL hardening subprocess timed out');
5781
+ return remaining;
5782
+ };
5783
+ const processOptions = {
5784
+ bunSpawn: options.bunSpawn,
5785
+ clearTimer: options.clearTimer,
5786
+ cwd: systemDirectory,
5787
+ nodeSpawn: options.nodeSpawn,
5788
+ runtime: options.runtime,
5789
+ setTimer: options.setTimer,
5790
+ };
5791
+ const sid = parseWhoamiSid(await runWindowsProcess(whoamiPath, ['/user', '/fo', 'csv', '/nh'], {
5792
+ ...processOptions,
5793
+ captureStdout: true,
5794
+ env: { SystemRoot: environment.SystemRoot },
5795
+ timeout: remainingTimeout(),
5796
+ }));
5797
+ const childEnvironment = {
5798
+ FORGE_PRIVATE_ACL_COUNT: String(targetPaths.length),
5799
+ FORGE_PRIVATE_ACL_SID: sid,
5800
+ SystemRoot: environment.SystemRoot,
5801
+ };
5802
+ targetPaths.forEach((filePath, index) => {
5803
+ childEnvironment[`FORGE_PRIVATE_ACL_TARGET_${index}`] = filePath;
5804
+ });
5805
+ await runWindowsProcess(cscriptPath, ['//B', '//Nologo', '//E:JScript', '//T:14', scriptPath], {
5806
+ ...processOptions,
5807
+ captureStdout: false,
5808
+ env: childEnvironment,
5809
+ timeout: remainingTimeout(),
5810
+ });
5811
+ }
5812
+
5813
+ async function secureWindowsPathAcl(filePath) {
5814
+ await secureWindowsPathsAcl([filePath]);
5815
+ }
5816
+
5817
+ async function createPrivateBackupDirectory(backupPath, options = {}) {
5818
+ const directory = fs.mkdtempSync(path.join(path.dirname(backupPath), '.forge-sqlite-backup-'));
5819
+ try {
5820
+ if (process.platform === 'win32') await (options.hardenPath || secureWindowsPathAcl)(directory);
5821
+ else {
5822
+ fs.chmodSync(directory, 0o700);
5823
+ if ((Number(fs.statSync(directory).mode) & 0o077) !== 0) throw new Error('mode remains broader than 0700');
5824
+ }
5825
+ } catch (error) {
5826
+ fs.rmSync(directory, { recursive: true, force: true });
5827
+ throw new ClaimRepairError(
5828
+ 'CLAIM_REPAIR_BACKUP_PERMISSIONS',
5829
+ 'SQLite backup workspace could not be secured with owner-only permissions',
5830
+ { cause: error.message || String(error) },
5831
+ );
5832
+ }
5833
+ return directory;
5834
+ }
5835
+
5836
+ async function createSafeBackup(runtime, db, databasePath, backupPath, options = {}) {
5837
+ assertSafeBackupDestination(databasePath, backupPath);
5838
+ ensureFileBackedDatabaseDirectory(backupPath);
5839
+ const hardenPath = options.hardenPath || hardenBackupPermissions;
5840
+ const tempDirectory = await createPrivateBackupDirectory(backupPath, { hardenPath });
5841
+ const tempPath = path.join(tempDirectory, `${path.basename(backupPath)}.${process.pid}.${randomUUID()}.tmp`);
5842
+ const backupWriter = options.backupWriter || createBackup;
5843
+ const backupVerifier = options.backupVerifier || verifyBackupFile;
5844
+ const backupReplacer = options.backupReplacer || (options.noReplace
5845
+ ? ((source, destination) => (process.platform === 'win32'
5846
+ ? fs.linkSync(source, destination)
5847
+ : fs.copyFileSync(source, destination, fs.constants.COPYFILE_EXCL)))
5848
+ : ((source, destination) => fs.renameSync(source, destination)));
5849
+ try {
5850
+ await backupWriter(runtime, db, tempPath);
5851
+ await hardenPath(tempPath);
5852
+ await backupVerifier(runtime, tempPath);
5853
+ await backupReplacer(tempPath, backupPath);
5854
+ await hardenPath(backupPath);
5855
+ } finally {
5856
+ fs.rmSync(tempDirectory, { recursive: true, force: true });
5857
+ }
5858
+ }
5859
+
2605
5860
  async function validateBackup(runtime, db, backupPath) {
2606
5861
  const tableName = createProbeTableName('forge_backup_probe');
2607
5862
  try {
@@ -2676,7 +5931,7 @@ async function validateBuiltinSQLiteRuntimeDriver(options = {}, deps = {}) {
2676
5931
 
2677
5932
  function createBuiltinSQLiteDriver(options = {}, deps = {}) {
2678
5933
  const runtime = options.runtime || selectBuiltinSQLiteRuntime(deps);
2679
- return createDriver(runtime, options.databasePath);
5934
+ return createDriver(runtime, options.databasePath, options);
2680
5935
  }
2681
5936
 
2682
5937
  module.exports = {
@@ -2684,7 +5939,15 @@ module.exports = {
2684
5939
  CONFLICT_SIGNAL,
2685
5940
  classifyConflictSignal,
2686
5941
  createBuiltinSQLiteDriver,
5942
+ hardenBackupPermissions,
5943
+ hardenBackupPermissionsBatch,
2687
5944
  requireSqliteRuntimeModule,
5945
+ resolveWindowsCscriptPath,
5946
+ secureWindowsPathsAcl,
2688
5947
  selectBuiltinSQLiteRuntime,
5948
+ syncClaimRepairRecoveryDirectory,
2689
5949
  validateBuiltinSQLiteRuntimeDriver,
5950
+ WINDOWS_PRIVATE_ACL_SCRIPT_PATH,
5951
+ WINDOWS_PRIVATE_ACL_SCRIPT_SHA256,
5952
+ _runWindowsProcess: runWindowsProcess,
2690
5953
  };