ai-foreman 1.7.29 → 2.0.1

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 (156) hide show
  1. package/README.md +1 -1
  2. package/dist/adapters/claude.d.ts +23 -12
  3. package/dist/adapters/claude.js +231 -26
  4. package/dist/adapters/codex.d.ts +19 -7
  5. package/dist/adapters/codex.js +202 -19
  6. package/dist/adapters/recovering.d.ts +2 -0
  7. package/dist/adapters/recovering.js +5 -1
  8. package/dist/adapters/structured.d.ts +71 -0
  9. package/dist/adapters/structured.js +213 -0
  10. package/dist/adapters/types.d.ts +85 -9
  11. package/dist/agentRun.d.ts +2 -0
  12. package/dist/agentRun.js +2 -1
  13. package/dist/boundedTurnEvidence.d.ts +18 -0
  14. package/dist/boundedTurnEvidence.js +76 -0
  15. package/dist/branch/currentGuard.d.ts +2 -0
  16. package/dist/branch/currentGuard.js +2 -0
  17. package/dist/branch/git.js +3 -8
  18. package/dist/branch/planner.js +1 -4
  19. package/dist/branch/runner.d.ts +2 -1
  20. package/dist/branch/runner.js +10 -7
  21. package/dist/buildAdmission.js +4 -0
  22. package/dist/buildApproval.d.ts +15 -0
  23. package/dist/buildApproval.js +28 -9
  24. package/dist/buildAssignment.d.ts +1 -0
  25. package/dist/buildAssignment.js +82 -23
  26. package/dist/buildInterventions.d.ts +2 -2
  27. package/dist/buildInterventions.js +11 -4
  28. package/dist/buildLaunchGate.js +3 -2
  29. package/dist/buildRuns.d.ts +1 -0
  30. package/dist/buildRuns.js +6 -1
  31. package/dist/builderGuidanceFollowup.js +17 -6
  32. package/dist/cli/doctor.js +3 -1
  33. package/dist/cli/manager.js +22 -20
  34. package/dist/cli/start.js +85 -39
  35. package/dist/cli/tickets.d.ts +0 -8
  36. package/dist/cli/tickets.js +50 -60
  37. package/dist/continuity.d.ts +10 -16
  38. package/dist/continuity.js +223 -208
  39. package/dist/continuityOrigin.d.ts +13 -0
  40. package/dist/continuityOrigin.js +91 -0
  41. package/dist/dispatchNotSent.d.ts +4 -0
  42. package/dist/dispatchNotSent.js +10 -0
  43. package/dist/foreman.d.ts +15 -16
  44. package/dist/foreman.js +70 -278
  45. package/dist/gitExclude.d.ts +16 -0
  46. package/dist/gitExclude.js +46 -0
  47. package/dist/graph/boundary.d.ts +1 -1
  48. package/dist/graph/boundary.js +1 -1
  49. package/dist/graph/host.js +8 -19
  50. package/dist/graph/manager.d.ts +1 -1
  51. package/dist/graph/manager.js +1 -1
  52. package/dist/graph/preparation.d.ts +38 -0
  53. package/dist/graph/preparation.js +7 -6
  54. package/dist/graph/semantic.d.ts +1 -0
  55. package/dist/graph/semantic.js +4 -1
  56. package/dist/graph/session.js +7 -2
  57. package/dist/graph/turn.d.ts +3 -4
  58. package/dist/graph/turn.js +29 -41
  59. package/dist/handoffProtocol.js +2 -2
  60. package/dist/handoffs.d.ts +5 -3
  61. package/dist/handoffs.js +92 -88
  62. package/dist/hostGraphApplicability.d.ts +2 -1
  63. package/dist/hostGraphApplicability.js +17 -19
  64. package/dist/hostQaAugmentation.js +21 -4
  65. package/dist/hostQaComparison.d.ts +21 -2
  66. package/dist/hostQaComparison.js +43 -29
  67. package/dist/hostSourcePreservation.d.ts +38 -0
  68. package/dist/hostSourcePreservation.js +72 -0
  69. package/dist/humanDecision.d.ts +7 -0
  70. package/dist/humanDecision.js +11 -0
  71. package/dist/interviews.d.ts +7 -0
  72. package/dist/interviews.js +18 -1
  73. package/dist/legacyCommunicationDisplay.d.ts +2 -0
  74. package/dist/legacyCommunicationDisplay.js +2 -0
  75. package/dist/managerPacket.js +3 -3
  76. package/dist/markers.d.ts +4 -3
  77. package/dist/markers.js +4 -20
  78. package/dist/observability.js +3 -8
  79. package/dist/probeIpc.d.ts +14 -0
  80. package/dist/probeIpc.js +29 -0
  81. package/dist/qaBuildGate.d.ts +21 -0
  82. package/dist/qaBuildGate.js +60 -13
  83. package/dist/qaBuilderCoverage.js +22 -13
  84. package/dist/qaBuilderEvidence.d.ts +16 -1
  85. package/dist/qaBuilderEvidence.js +48 -8
  86. package/dist/qaContractCoverage.d.ts +2 -2
  87. package/dist/qaContractCoverage.js +4 -9
  88. package/dist/qaContractDelivery.js +34 -20
  89. package/dist/qaCoverageAssembly.d.ts +22 -0
  90. package/dist/qaCoverageAssembly.js +16 -0
  91. package/dist/qaDeliveryJournal.d.ts +2 -0
  92. package/dist/qaDeliveryJournal.js +19 -0
  93. package/dist/qaFailureDelivery.js +97 -85
  94. package/dist/qaHostTransformation.d.ts +26 -0
  95. package/dist/qaHostTransformation.js +100 -0
  96. package/dist/qaPreparation.d.ts +8 -20
  97. package/dist/qaPreparation.js +153 -254
  98. package/dist/qaPreparationInputs.js +19 -0
  99. package/dist/qaPreparationStore.d.ts +11 -0
  100. package/dist/qaPreparationStore.js +44 -4
  101. package/dist/qaPrerequisites.d.ts +2 -1
  102. package/dist/qaPrerequisites.js +21 -1
  103. package/dist/qaProtocolV2.d.ts +15 -0
  104. package/dist/qaRecovery.d.ts +0 -3
  105. package/dist/qaRecovery.js +21 -27
  106. package/dist/qaReview.js +241 -406
  107. package/dist/qaReviewEvidence.d.ts +7 -3
  108. package/dist/qaReviewEvidence.js +15 -4
  109. package/dist/qaRuntime.js +1 -1
  110. package/dist/qaSnapshot.js +76 -21
  111. package/dist/qaStructuredResponse.d.ts +18 -0
  112. package/dist/qaStructuredResponse.js +81 -0
  113. package/dist/qaTimeline.js +2 -2
  114. package/dist/qaVerificationContract.d.ts +1 -2
  115. package/dist/qaVerificationContract.js +24 -35
  116. package/dist/readinessGate.js +5 -4
  117. package/dist/recoveryOwners.d.ts +32 -0
  118. package/dist/recoveryOwners.js +87 -0
  119. package/dist/runtimePreflight.d.ts +11 -0
  120. package/dist/runtimePreflight.js +77 -0
  121. package/dist/runtimeReadiness.js +23 -15
  122. package/dist/sources/sourceRegistry.d.ts +0 -3
  123. package/dist/sources/sourceRegistry.js +0 -42
  124. package/dist/statusReporter.d.ts +2 -0
  125. package/dist/statusReporter.js +2 -0
  126. package/dist/structuredCutover.d.ts +31 -0
  127. package/dist/structuredCutover.js +510 -0
  128. package/dist/structuredCutoverFiles.d.ts +10 -0
  129. package/dist/structuredCutoverFiles.js +181 -0
  130. package/dist/structuredDispatchAuthority.d.ts +22 -0
  131. package/dist/structuredDispatchAuthority.js +110 -0
  132. package/dist/structuredDispatchRecord.d.ts +29 -0
  133. package/dist/structuredDispatchRecord.js +36 -0
  134. package/dist/structuredLiveBudget.d.ts +2 -0
  135. package/dist/structuredLiveBudget.js +23 -0
  136. package/dist/structuredOperation.d.ts +18 -0
  137. package/dist/structuredOperation.js +279 -0
  138. package/dist/supervisedStart.js +35 -21
  139. package/dist/ticketPlanning.d.ts +1 -376
  140. package/dist/ticketPlanning.js +2 -49
  141. package/dist/ticketPopulation.d.ts +0 -3
  142. package/dist/ticketPopulation.js +4 -21
  143. package/dist/ticketPopulationPageAuthority.d.ts +13 -0
  144. package/dist/ticketPopulationPageAuthority.js +30 -0
  145. package/dist/ticketPopulationPaging.d.ts +27 -0
  146. package/dist/ticketPopulationPaging.js +127 -0
  147. package/dist/tickets/renderMarkdown.d.ts +14 -0
  148. package/dist/tickets/renderMarkdown.js +39 -5
  149. package/dist/tickets/ticketLoader.js +10 -0
  150. package/dist/tickets/ticketSchema.d.ts +107 -0
  151. package/dist/tickets/ticketSchema.js +4 -1
  152. package/dist/workflowDb.d.ts +116 -3
  153. package/dist/workflowDb.js +852 -61
  154. package/dist/workflowReader.d.ts +1 -0
  155. package/dist/workflowReader.js +9 -0
  156. package/package.json +23 -7
@@ -1,11 +1,19 @@
1
+ import { ensureLocalGitExcludes } from "./gitExclude.js";
2
+ import { persistedProviderWireContract } from "rafi-spec";
3
+ import { verifyCheckpointOrigin, verifyContinuityHeads } from "./continuityOrigin.js";
1
4
  import { registerQaGraphJournalWriter, ensureQaGraphJournalGuards } from "./qaDeliveryJournal.js";
2
5
  import { canonicalJson } from "./qaProtocolV2.js";
6
+ import { hydrateStructuredReceipt, persistedDispatchTuple, sealedDispatchRecord, verifyPersistedDispatch } from "./structuredDispatchRecord.js";
7
+ import { assertStructuredCutoverComplete, cutoverOwnershipMatrix, invalidStructuredRun, purgeStructuredRun, STRUCTURED_CUTOVER_MATRIX_VERSION, STRUCTURED_CUTOVER_REASON, STRUCTURED_AUDIT_CLOSURE } from "./structuredCutover.js";
8
+ import { communicationFileEvidenceReferences, quarantineCommunicationFiles } from "./structuredCutoverFiles.js";
3
9
  import { resolveEffectiveQaConfiguration } from "./qaEffectiveConfig.js";
4
10
  import { registerGraphDerived, graphDerivedAllowed, currentGraphDerivedAccess } from "./graph/derived.js";
5
- import { bindCoverageHostFields, parseContractCoverage, validateContractCoverage } from "./qaContractCoverage.js";
11
+ import { bindCoverageHostFields, validateContractCoverage } from "./qaContractCoverage.js";
6
12
  import { findHostQaCheck, hostComparisonDigestFromCoverage, hostQaComparisonV1Schema } from "./hostQaComparison.js";
7
13
  import { findGraphQaChecks, hostGraphApplicabilityDigestFromCoverage, hostGraphApplicabilityV1Schema } from "./hostGraphApplicability.js";
8
- import { applyHostQaAugmentations } from "./hostQaAugmentation.js";
14
+ import { assembleFinalQaCoverage } from "./qaCoverageAssembly.js";
15
+ import { retainedQaCommandObservations } from "./qaReviewEvidence.js";
16
+ import { retainedHostBuilderObservations } from "./qaBuilderEvidence.js";
9
17
  import { QaPreparationStore, migrateQaPreparation, migrateQaPreparationGuards } from "./qaPreparationStore.js";
10
18
  import { GraphStore, migrateGraphStore } from "./graph/storage.js";
11
19
  import { reconcileWork } from "./buildWorkReconciliation.js";
@@ -15,20 +23,36 @@ import { admitWork, admittedWork, assertAdmittedWork } from "./buildWorkAdmissio
15
23
  import { cleanupReadiness, inspectReadiness, readinessMetadata } from "./readinessCleanup.js";
16
24
  import { originalBuildLease, rememberOriginalLease, forgetOriginalLease, registerLaunchChild, acknowledgeLaunchChild, reconcileLaunch, checkBuildOwnershipSchema, canonicalProject, launchDigest, localBuildAuthority, rememberBuildAuthority, forgetBuildAuthority, migrateBuildAdmission, acquireAdmission, readAdmission, assertAdmission, releaseAdmission, reserveLaunch, setLaunchState, claimLaunch, readLaunch } from "./buildAdmission.js";
17
25
  import { createHash, randomUUID } from "node:crypto";
18
- import { appendFileSync, existsSync, mkdirSync, readFileSync, readdirSync, statSync } from "node:fs";
26
+ import { existsSync, mkdirSync, readFileSync, readdirSync, statSync } from "node:fs";
19
27
  import { hostname } from "node:os";
20
28
  import { dirname, join, resolve } from "node:path";
21
29
  import { execFileSync } from "node:child_process";
22
30
  import Database from "better-sqlite3";
23
- import { validateQaFailureReport } from "rafi-spec";
31
+ import { validateQaFailureReport, qaVerdictOutput, remediationOutput } from "rafi-spec";
24
32
  import { migrateQaHandback, registerHandbackWriter, reportOccurrenceId } from "./qaHandbackMigration.js";
25
- import { providerSessionKey } from "./sessionIdentity.js";
33
+ import { providerSessionKey, canonicalSessionPath } from "./sessionIdentity.js";
26
34
  import { captureFrozenQaSource } from "./qaSnapshot.js";
27
35
  import { loadTicketsConfig, resolveTicketPaths } from "./tickets/config.js";
28
36
  import { loadTickets } from "./tickets/ticketLoader.js";
29
37
  import { processGroupQuiescent, taggedProcesses, classifyProcess, processStartIdentity } from "./processIdentity.js";
30
38
  import { initialQaReducerState, qaDigest, reduceQaState, } from "./qaProtocolV2.js";
31
39
  export const WORKFLOW_DB_FILE = ".rafi/recovery.sqlite3";
40
+ /** Role steps that change no project files, so an interrupted one can run
41
+ * again from its beginning with its original inputs. */
42
+ // QA preparation runs in a disposable snapshot, so its steps are restartable too.
43
+ const QA_PREPARATION_STEPS = ["qa-preparation-depth", "qa-preparation-draft", "qa-preparation-assessment", "qa-preparation-challenge"];
44
+ const RESTARTABLE_STEPS = {
45
+ builder: new Set(["build-preflight", "session-initialization", "handoff-acceptance", "builder-coverage", "branch-dependency-audit", "qa-contract-acceptance", "qa-contract-segment", "status", "escalation", ...QA_PREPARATION_STEPS]),
46
+ qa: new Set(["session-initialization", "handoff-acceptance", "qa-recovery-acceptance", "qa-contract-acceptance", "qa-contract-segment", ...QA_PREPARATION_STEPS]),
47
+ };
48
+ /** Journal runs for agent calls outside a build use these run-id prefixes. */
49
+ export const NON_BUILD_RUN_PREFIXES = ["operation:", "ticket-population:", "qa-"];
50
+ export function isNonBuildRunId(runId) {
51
+ return NON_BUILD_RUN_PREFIXES.some(prefix => runId.startsWith(prefix));
52
+ }
53
+ function defaultRunKind(runId) {
54
+ return isNonBuildRunId(runId) ? "operation" : "build";
55
+ }
32
56
  /** Read the current project lease without creating or migrating recovery state. */
33
57
  export function readCurrentWorkflowLease(projectDir) {
34
58
  const path = join(resolve(projectDir), WORKFLOW_DB_FILE);
@@ -124,6 +148,8 @@ export class WorkflowDb {
124
148
  if (existsSync(path)) {
125
149
  const preview = new Database(path, { readonly: true, fileMustExist: true });
126
150
  try {
151
+ if (readinessAccess)
152
+ assertStructuredCutoverComplete(preview);
127
153
  checkBuildOwnershipSchema(preview);
128
154
  }
129
155
  finally {
@@ -154,10 +180,13 @@ export class WorkflowDb {
154
180
  this.db.pragma("synchronous = FULL");
155
181
  this.db.pragma("foreign_keys = ON");
156
182
  try {
157
- if (readinessAccess)
183
+ if (readinessAccess) {
184
+ assertStructuredCutoverComplete(this.db);
158
185
  this.restrictReadinessConnection(readinessAccess);
186
+ }
159
187
  else {
160
188
  this.migrate();
189
+ this.applyStructuredCommunicationV2Cutover();
161
190
  migrateQaPreparation(this.db);
162
191
  migrateBuildAdmission(this.db);
163
192
  this.importLegacyOnce();
@@ -201,6 +230,7 @@ export class WorkflowDb {
201
230
  }
202
231
  }
203
232
  assertInstructionOwner(runId) {
233
+ this.assertNativeRunAdmission(runId);
204
234
  if (this.writerAuthority) {
205
235
  assertAdmission(this.db, this.writerAuthority);
206
236
  if (this.writerAuthority.runId !== runId && !this.db.prepare("SELECT 1 FROM build_child_runs WHERE child=? AND parent=?").get(runId, this.writerAuthority.runId))
@@ -334,7 +364,7 @@ export class WorkflowDb {
334
364
  }
335
365
  }
336
366
  admittedWork(runId, workId) { return admittedWork(this.db, runId, workId); }
337
- assertAdmittedWork(runId, workId) { return assertAdmittedWork(this.db, runId, workId); }
367
+ assertAdmittedWork(runId, workId) { this.assertNativeRunAdmission(runId); return assertAdmittedWork(this.db, runId, workId); }
338
368
  /** Atomic local changes only; never await provider or filesystem work here. */
339
369
  atomic(work) { return this.db.transaction(work).immediate(); }
340
370
  buildAdmission() { return readAdmission(this.db); }
@@ -417,6 +447,7 @@ export class WorkflowDb {
417
447
  throw new Error("Ownership migration is pending legacy execution cleanup; use rafi resume before starting work");
418
448
  }
419
449
  acquireBuildAdmission(runId, phase) {
450
+ this.assertNativeRunAdmission(runId);
420
451
  return this.atomic(() => {
421
452
  this.assertNoLegacyBuildOwner();
422
453
  const lease = this.currentLease();
@@ -426,7 +457,10 @@ export class WorkflowDb {
426
457
  if (owner.state.pid && classifyProcess(owner.state.pid, owner.state.processStart).state !== "dead")
427
458
  throw new Error(`supervisor already active for project (run ${owner.runId})`);
428
459
  }
429
- const uncertain = this.db.prepare("SELECT run_id FROM operation_journal WHERE kind='provider-dispatch' AND status IN ('in_progress','uncertain') LIMIT 1").get();
460
+ // Single agent calls outside builds ("operation:<id>" runs: planning,
461
+ // ticket population, uninstall proposals) are read-only proposals; an
462
+ // unknown outcome there cannot conflict with a build.
463
+ const uncertain = this.db.prepare("SELECT run_id FROM operation_journal WHERE kind='provider-dispatch' AND status IN ('in_progress','uncertain') AND run_id NOT LIKE 'operation:%' AND run_id NOT LIKE 'ticket-population:%' AND run_id NOT LIKE 'qa-%' LIMIT 1").get();
430
464
  if (uncertain)
431
465
  throw new Error(`Build ${uncertain.run_id} has unresolved provider dispatch; reconcile before another launch`);
432
466
  for (const row of this.db.prepare("SELECT DISTINCT run_id FROM build_owned_processes WHERE state<>'quiescent'").all()) {
@@ -447,10 +481,11 @@ export class WorkflowDb {
447
481
  });
448
482
  }
449
483
  acquireBuildRecoveryAdmission(runId) {
484
+ this.assertNativeRunAdmission(runId);
450
485
  return this.atomic(() => {
451
486
  this.assertExecutionProtocol();
452
487
  this.assertNoLegacyBuildOwner();
453
- const otherDispatch = this.db.prepare("SELECT run_id FROM operation_journal WHERE run_id<>? AND kind='provider-dispatch' AND status IN ('in_progress','uncertain') LIMIT 1").get(runId);
488
+ const otherDispatch = this.db.prepare("SELECT run_id FROM operation_journal WHERE run_id<>? AND kind='provider-dispatch' AND status IN ('in_progress','uncertain') AND run_id NOT LIKE 'operation:%' AND run_id NOT LIKE 'ticket-population:%' AND run_id NOT LIKE 'qa-%' LIMIT 1").get(runId);
454
489
  if (otherDispatch)
455
490
  throw new Error(`Build ${otherDispatch.run_id} has unresolved provider dispatch; reconcile before starting another recovery workflow`);
456
491
  const current = localBuildAuthority(this.projectDir);
@@ -812,7 +847,7 @@ export class WorkflowDb {
812
847
  return { eligible: reasons.length === 0, reasons };
813
848
  }
814
849
  /** Ensure non-workflow build records can use the same durable event store. */
815
- ensureRun(runId, kind = "build", now = new Date()) {
850
+ ensureRun(runId, kind = defaultRunKind(runId), now = new Date()) {
816
851
  const existing = this.getRun(runId);
817
852
  if (existing)
818
853
  return existing;
@@ -838,6 +873,11 @@ export class WorkflowDb {
838
873
  originalWork: input.originalWork ?? {}, remainingWork: input.remainingWork ?? input.originalWork ?? {}, state: input.state ?? {},
839
874
  legacy: Boolean(input.legacy), createdAt: at, updatedAt: at,
840
875
  };
876
+ if (run.legacy) {
877
+ run.status = "superseded";
878
+ run.checkpoint = STRUCTURED_CUTOVER_REASON;
879
+ run.state = { ...run.state, structuredCommunicationCutover: { version: 2, reason: STRUCTURED_CUTOVER_REASON, at } };
880
+ }
841
881
  this.db.transaction(() => {
842
882
  if (this.writerAuthority)
843
883
  assertAdmission(this.db, this.writerAuthority);
@@ -909,6 +949,7 @@ export class WorkflowDb {
909
949
  });
910
950
  }
911
951
  transition(runId, update, now = new Date()) {
952
+ this.assertNativeRunAdmission(runId);
912
953
  const at = now.toISOString();
913
954
  return this.db.transaction(() => {
914
955
  if (!this.coordinatorTransition && (this.writerAuthority || this.buildAdmission())) {
@@ -1036,11 +1077,33 @@ export class WorkflowDb {
1036
1077
  this.assertGraphContinuityAccess(row.payload_json);
1037
1078
  return rows.map(continuityEventFromRow);
1038
1079
  }
1080
+ providerCheckpoint(dispatchId, publicationKind) {
1081
+ const row = this.db.prepare("SELECT c.* FROM continuity_checkpoints c JOIN continuity_checkpoint_origins o USING(checkpoint_id) WHERE o.dispatch_id=? AND o.publication_kind=?").get(dispatchId, publicationKind);
1082
+ if (row)
1083
+ verifyCheckpointOrigin(this.db, this.projectDir, row.checkpoint_id);
1084
+ return row ? continuityCheckpointFromRow(row) : undefined;
1085
+ }
1039
1086
  publishContinuityCheckpoint(input, now = new Date()) {
1040
1087
  this.ensureRun(input.runId, "build", now);
1041
1088
  return this.db.transaction(() => {
1042
1089
  const at = now.toISOString();
1090
+ if (input.origin?.kind === "provider") {
1091
+ const prior = this.db.prepare("SELECT c.* FROM continuity_checkpoints c JOIN continuity_checkpoint_origins o USING(checkpoint_id) WHERE c.run_id=? AND c.role=? AND o.dispatch_id=? AND o.publication_kind=?")
1092
+ .get(input.runId, input.role, input.origin.dispatchId, input.origin.publicationKind);
1093
+ if (prior) {
1094
+ verifyCheckpointOrigin(this.db, this.projectDir, prior.checkpoint_id);
1095
+ if (canonicalJson(parseJson(prior.delta_json)) !== canonicalJson(input.delta))
1096
+ throw new Error("Conflicting repeated checkpoint publication");
1097
+ return continuityCheckpointFromRow(prior);
1098
+ }
1099
+ }
1043
1100
  const previous = this.continuityHead(input.runId, input.role);
1101
+ let origin = input.origin;
1102
+ if (!origin) {
1103
+ const event = this.appendContinuityEvent({ runId: input.runId, role: "host", kind: input.hostEventKind ?? "host_checkpoint", authoritativeStateRevision: input.authoritativeStateRevision,
1104
+ payload: { checkpointDeltaDigest: createHash("sha256").update(json(sanitizeContinuityValue(input.delta))).digest("hex"), role: input.role } });
1105
+ origin = { kind: "host", eventSequence: event.sequence, inputCheckpointDigests: input.inputCheckpointDigests ?? (previous ? [previous.digest] : []) };
1106
+ }
1044
1107
  const latestEvent = this.db.prepare("SELECT COALESCE(MAX(sequence),0) AS sequence FROM continuity_events WHERE run_id=?").get(input.runId);
1045
1108
  const safeDelta = sanitizeContinuityValue(input.delta);
1046
1109
  const graphAccess = currentGraphDerivedAccess();
@@ -1050,6 +1113,10 @@ export class WorkflowDb {
1050
1113
  const digest = digestJson({ runId: input.runId, role: input.role, sequence: latestEvent.sequence, predecessorDigest: previous?.digest, delta: safeDelta, authoritativeStateRevision: input.authoritativeStateRevision, sessionKey: session.key });
1051
1114
  const result = this.db.prepare(`INSERT INTO continuity_checkpoints(run_id,role,event_sequence,state,delta_json,digest,predecessor_digest,authoritative_state_revision,session_key,session_ref_json,created_at)
1052
1115
  VALUES(?,?,?,?,?,?,?,?,?,?,?)`).run(input.runId, input.role, latestEvent.sequence, input.state ?? "current", json(safeDelta), digest, previous?.digest ?? null, input.authoritativeStateRevision, session.key, session.refJson, at);
1116
+ const checkpointId = Number(result.lastInsertRowid);
1117
+ this.db.prepare("INSERT INTO continuity_checkpoint_origins(checkpoint_id,run_id,origin_json,delta_digest,dispatch_id,publication_kind) VALUES(?,?,?,?,?,?)")
1118
+ .run(checkpointId, input.runId, json(origin), createHash("sha256").update(json(safeDelta)).digest("hex"), origin.kind === "provider" ? origin.dispatchId : null, origin.kind === "provider" ? origin.publicationKind : null);
1119
+ verifyCheckpointOrigin(this.db, this.projectDir, checkpointId);
1053
1120
  const checkpoint = {
1054
1121
  checkpointId: Number(result.lastInsertRowid), runId: input.runId, role: input.role,
1055
1122
  sequence: latestEvent.sequence, state: input.state ?? "current", delta: safeDelta, digest,
@@ -1067,14 +1134,18 @@ export class WorkflowDb {
1067
1134
  const rows = role
1068
1135
  ? this.db.prepare("SELECT * FROM continuity_checkpoints WHERE run_id=? AND role=? ORDER BY checkpoint_id").all(runId, role)
1069
1136
  : this.db.prepare("SELECT * FROM continuity_checkpoints WHERE run_id=? ORDER BY checkpoint_id").all(runId);
1070
- for (const row of rows)
1137
+ for (const row of rows) {
1071
1138
  this.assertGraphContinuityAccess(row.delta_json);
1139
+ verifyCheckpointOrigin(this.db, this.projectDir, row.checkpoint_id);
1140
+ }
1072
1141
  return rows.map(continuityCheckpointFromRow);
1073
1142
  }
1074
1143
  latestContinuityCheckpoint(runId, role) {
1075
1144
  const row = this.db.prepare("SELECT * FROM continuity_checkpoints WHERE run_id=? AND role=? ORDER BY checkpoint_id DESC LIMIT 1").get(runId, role);
1076
- if (row)
1145
+ if (row) {
1077
1146
  this.assertGraphContinuityAccess(row.delta_json);
1147
+ verifyCheckpointOrigin(this.db, this.projectDir, row.checkpoint_id);
1148
+ }
1078
1149
  return row ? continuityCheckpointFromRow(row) : undefined;
1079
1150
  }
1080
1151
  assertGraphContinuityAccess(value) {
@@ -1084,6 +1155,8 @@ export class WorkflowDb {
1084
1155
  }
1085
1156
  continuityHead(runId, role) {
1086
1157
  const row = this.db.prepare("SELECT * FROM continuity_heads WHERE run_id=? AND role=?").get(runId, role);
1158
+ if (row)
1159
+ verifyContinuityHeads(this.db, this.projectDir, runId);
1087
1160
  return row ? continuityHeadFromRow(row) : undefined;
1088
1161
  }
1089
1162
  setContinuityHeadState(runId, role, state, now = new Date()) {
@@ -1231,6 +1304,11 @@ export class WorkflowDb {
1231
1304
  return this.db.prepare("SELECT decision_json FROM human_decisions WHERE run_id=? AND status='pending' ORDER BY created_at").all(runId)
1232
1305
  .map((row) => parseJson(row.decision_json));
1233
1306
  }
1307
+ /** Answered decisions for a run, oldest first (e.g. answered with build:decide while no worker waited). */
1308
+ answeredHumanDecisions(runId) {
1309
+ return this.db.prepare("SELECT decision_json FROM human_decisions WHERE run_id=? AND status='answered' ORDER BY created_at").all(runId)
1310
+ .map((row) => parseJson(row.decision_json));
1311
+ }
1234
1312
  decisionContinuationAvailable(runId, decisionId) {
1235
1313
  const prior = this.operations(runId).filter(op => op.kind === "decision-continuation" && op.intent.decisionId === decisionId);
1236
1314
  return prior.every(op => op.status === "failed" && op.result?.dispatchState === "not-sent");
@@ -1313,7 +1391,12 @@ export class WorkflowDb {
1313
1391
  return row ? parseJson(row.state_json) : undefined;
1314
1392
  }
1315
1393
  planOperation(input, now = new Date()) {
1394
+ this.assertNativeRunAdmission(input.runId);
1316
1395
  const at = now.toISOString();
1396
+ const run = this.getRun(input.runId);
1397
+ if (input.kind === "provider-dispatch" && run?.checkpoint === "structured-communication-v2-cutover") {
1398
+ throw new Error("This run was fenced by the structured-communication v2 cutover; start a new native dispatch");
1399
+ }
1317
1400
  if (input.kind === "provider-dispatch" && this.unresolvedPreparationProcesses(input.runId).length)
1318
1401
  throw new Error("Readiness cleanup must be reconciled before provider dispatch");
1319
1402
  this.db.prepare(`INSERT INTO operation_journal(idempotency_key,run_id,kind,status,intent_json,created_at,updated_at)
@@ -1324,12 +1407,294 @@ export class WorkflowDb {
1324
1407
  }
1325
1408
  return operation;
1326
1409
  }
1410
+ /** Seal a previously persisted non-authoritative preparation intent. This
1411
+ * is intentionally legal only before provider content dispatch begins. */
1412
+ sealPlannedOperationIntent(idempotencyKey, intent, now = new Date()) {
1413
+ const prior = this.operation(idempotencyKey);
1414
+ if (!prior || prior.status !== "planned" || prior.kind !== "provider-dispatch")
1415
+ throw new Error("Only a planned provider dispatch may be sealed");
1416
+ const preparing = prior.intent;
1417
+ if (preparing.protocolVersion !== 2 || preparing.state !== "preparing")
1418
+ throw new Error("Provider dispatch did not begin as a v2 preparation intent");
1419
+ this.db.prepare("UPDATE operation_journal SET intent_json=?,updated_at=? WHERE idempotency_key=? AND status='planned'")
1420
+ .run(json(intent), now.toISOString(), idempotencyKey);
1421
+ return this.operation(idempotencyKey);
1422
+ }
1423
+ /** Called in the gateway's sealing transaction, before the send capability exists. */
1424
+ sealStructuredOwnerDispatch(key, tuple, renderedInstruction) {
1425
+ const sealedDispatch = sealedDispatchRecord(tuple);
1426
+ const transportSession = this.operation(key)?.intent?.sessionRef;
1427
+ const matchesSession = (session) => Boolean(transportSession
1428
+ && ["provider", "sessionId", "role", "stream", "generation", "cwd", "configRoot"].every(field => session[field] === transportSession[field]));
1429
+ const review = this.db.prepare("SELECT intent_json,status FROM qa_turns WHERE operation_id=?").get(key);
1430
+ if (review) {
1431
+ const intent = parseJson(review.intent_json);
1432
+ if (review.status !== "intended" || intent.sealedDispatch || !matchesSession(intent.providerSession) || intent.providerSession.sessionId !== tuple.sessionId || intent.providerSession.generation !== tuple.generation)
1433
+ throw new Error("QA review owner cannot seal a foreign or already dispatched session");
1434
+ this.db.prepare("UPDATE qa_turns SET intent_json=? WHERE operation_id=?").run(json({ ...intent, sealedDispatch }), key);
1435
+ }
1436
+ const delivery = this.db.prepare("SELECT record_json,status FROM qa_delivery_turns WHERE turn_record_id=?").get(key);
1437
+ if (delivery) {
1438
+ const turn = parseJson(delivery.record_json);
1439
+ if (delivery.status !== "intended" || turn.sealedDispatch || !matchesSession(turn.intendedSession) || turn.intendedSession.sessionId !== tuple.sessionId || turn.intendedSession.generation !== tuple.generation)
1440
+ throw new Error("QA remediation owner cannot seal a foreign or already dispatched session");
1441
+ this.db.prepare("UPDATE qa_delivery_turns SET record_json=? WHERE turn_record_id=?").run(json({ ...turn, sealedDispatch }), key);
1442
+ const attempt = this.db.prepare("SELECT record_json FROM qa_remediation_attempts WHERE attempt_id=?").get(turn.operationId);
1443
+ if (!attempt)
1444
+ throw new Error("Remediation dispatch has no durable owner attempt");
1445
+ const fields = parseJson(attempt.record_json);
1446
+ const dispatches = (fields.sealedDispatches ?? {});
1447
+ if (dispatches[key])
1448
+ throw new Error("Remediation dispatch already sealed");
1449
+ this.db.prepare("UPDATE qa_remediation_attempts SET record_json=? WHERE attempt_id=?").run(json({ ...fields, sealedDispatches: { ...dispatches, [key]: sealedDispatch } }), turn.operationId);
1450
+ }
1451
+ this.putEvidence("handoff", Buffer.from(renderedInstruction));
1452
+ }
1453
+ structuredOwnerDispatch(key) {
1454
+ const transport = this.operation(key)?.intent;
1455
+ const verifyOwner = (record) => {
1456
+ persistedDispatchTuple(record);
1457
+ if (json(transport?.sealedDispatch) !== json(record))
1458
+ throw new Error("Domain owner tuple differs from its transport intent");
1459
+ };
1460
+ const review = this.db.prepare("SELECT intent_json FROM qa_turns WHERE operation_id=?").get(key);
1461
+ if (review) {
1462
+ const record = parseJson(review.intent_json).sealedDispatch;
1463
+ if (!record)
1464
+ throw new Error("QA owner has no sealed dispatch");
1465
+ verifyOwner(record);
1466
+ return record;
1467
+ }
1468
+ const delivery = this.db.prepare("SELECT record_json FROM qa_delivery_turns WHERE turn_record_id=?").get(key);
1469
+ if (delivery) {
1470
+ const turn = parseJson(delivery.record_json);
1471
+ const record = turn.sealedDispatch;
1472
+ if (!record)
1473
+ throw new Error("Remediation owner has no sealed dispatch");
1474
+ verifyOwner(record);
1475
+ const attempt = this.db.prepare("SELECT record_json FROM qa_remediation_attempts WHERE attempt_id=?").get(turn.operationId);
1476
+ const sealed = attempt && parseJson(attempt.record_json).sealedDispatches?.[key];
1477
+ if (json(sealed) !== json(record))
1478
+ throw new Error("Remediation attempt tuple differs from its delivery intent");
1479
+ return record;
1480
+ }
1481
+ const intent = this.operation(key)?.intent;
1482
+ if (!intent?.sealedDispatch)
1483
+ throw new Error("Generic owner has no sealed dispatch");
1484
+ persistedDispatchTuple(intent.sealedDispatch);
1485
+ if (canonicalJson(intent.tuple) !== intent.sealedDispatch.tupleJson)
1486
+ throw new Error("Generic intent tuple differs from its sealed canonical bytes");
1487
+ return intent.sealedDispatch;
1488
+ }
1489
+ assertStructuredDispatchAdmission(runId, dispatchId, parentId, operation) {
1490
+ const processing = this.pendingStructuredPostprocessing(runId);
1491
+ const transfers = this.db.prepare("SELECT * FROM structured_handoff_lineage WHERE run_id=? AND state<>'completed'").all(runId);
1492
+ const linked = transfers.length === 1 && transfers[0].predecessor_id === parentId &&
1493
+ (operation === "handoff-acceptance" && transfers[0].state === "acceptance-pending" && !transfers[0].acceptance_id ||
1494
+ transfers[0].state === "continuation-planned" && transfers[0].continuation_id === dispatchId);
1495
+ if (processing.some(id => id !== parentId) || (processing.length || transfers.length) && !linked)
1496
+ throw new Error("Confirmed dispatch postprocessing or handoff is pending; reconcile before unrelated work");
1497
+ }
1498
+ beginStructuredHandoff(predecessorId) {
1499
+ const operation = this.operation(predecessorId);
1500
+ if (!operation || operation.status !== "confirmed")
1501
+ throw new Error("Handoff predecessor must be confirmed");
1502
+ const changed = this.db.prepare("INSERT OR IGNORE INTO structured_handoff_lineage(predecessor_id,run_id,state) VALUES(?,?,'acceptance-pending')").run(predecessorId, operation.runId);
1503
+ if (changed.changes !== 1)
1504
+ throw new Error("Handoff acceptance already started; reconcile persisted lineage before recovery");
1505
+ }
1506
+ linkStructuredHandoffDispatch(dispatchId, parentId, operation) {
1507
+ if (!parentId)
1508
+ return;
1509
+ const row = this.db.prepare("SELECT * FROM structured_handoff_lineage WHERE predecessor_id=?").get(parentId);
1510
+ if (!row)
1511
+ return;
1512
+ if (operation === "handoff-acceptance") {
1513
+ if (row.state !== "acceptance-pending" || row.acceptance_id)
1514
+ throw new Error("Handoff acceptance already dispatched");
1515
+ this.db.prepare("UPDATE structured_handoff_lineage SET acceptance_id=? WHERE predecessor_id=?").run(dispatchId, parentId);
1516
+ }
1517
+ else if (row.state !== "continuation-planned" || row.continuation_id !== dispatchId)
1518
+ throw new Error("Foreign handoff continuation");
1519
+ }
1520
+ planStructuredContinuation(predecessorId, successor) {
1521
+ return this.atomic(() => {
1522
+ const row = this.db.prepare("SELECT * FROM structured_handoff_lineage WHERE predecessor_id=?").get(predecessorId);
1523
+ if (!row || row.state !== "acceptance-pending" || !row.acceptance_id || this.operation(row.acceptance_id)?.status !== "confirmed")
1524
+ throw new Error("Successor lacks confirmed native handoff acceptance lineage");
1525
+ const origin = this.db.prepare("SELECT checkpoint_id FROM continuity_checkpoint_origins WHERE dispatch_id=? AND publication_kind='handoff_acceptance'").get(row.acceptance_id);
1526
+ if (!origin)
1527
+ throw new Error("Successor acceptance checkpoint is missing");
1528
+ verifyCheckpointOrigin(this.db, this.projectDir, origin.checkpoint_id);
1529
+ this.assertAcceptedStructuredSuccessor(row.run_id, origin.checkpoint_id, successor);
1530
+ const accepted = this.retainedStructuredTurn(row.acceptance_id).selectedDispatch;
1531
+ if (accepted.sessionId !== successor.sessionId || accepted.provider !== successor.provider)
1532
+ throw new Error("Successor differs from the accepted provider session");
1533
+ const continuationId = randomUUID();
1534
+ this.db.prepare("UPDATE structured_handoff_lineage SET state='continuation-planned',successor_json=?,continuation_id=? WHERE predecessor_id=?")
1535
+ .run(json(successor), continuationId, predecessorId);
1536
+ return continuationId;
1537
+ });
1538
+ }
1539
+ structuredContinuation(runId, role) {
1540
+ const row = this.db.prepare("SELECT * FROM structured_handoff_lineage WHERE run_id=? AND successor_json IS NOT NULL AND json_extract(successor_json,'$.role')=? ORDER BY rowid DESC LIMIT 1").get(runId, role);
1541
+ if (!row)
1542
+ return;
1543
+ const origin = this.db.prepare("SELECT checkpoint_id FROM continuity_checkpoint_origins WHERE dispatch_id=? AND publication_kind='handoff_acceptance'").get(row.acceptance_id);
1544
+ if (!origin)
1545
+ throw new Error("Handoff continuation lacks its accepted checkpoint");
1546
+ verifyCheckpointOrigin(this.db, this.projectDir, origin.checkpoint_id);
1547
+ this.assertAcceptedStructuredSuccessor(runId, origin.checkpoint_id, parseJson(row.successor_json));
1548
+ return { predecessorId: row.predecessor_id, continuationId: row.continuation_id, state: row.state, successor: parseJson(row.successor_json) };
1549
+ }
1550
+ finishStructuredContinuation(dispatchId) {
1551
+ if (this.operation(dispatchId)?.status !== "confirmed")
1552
+ throw new Error("Unconfirmed continuation cannot complete");
1553
+ this.db.prepare("UPDATE structured_handoff_lineage SET state='completed' WHERE continuation_id=?").run(dispatchId);
1554
+ }
1555
+ assertAcceptedStructuredSuccessor(runId, checkpointId, successor) {
1556
+ const handoff = this.db.prepare("SELECT acceptance_receipt_digest FROM handoffs WHERE run_id=? AND successor_session_key=? AND state='accepted'")
1557
+ .get(runId, providerSessionKey(successor));
1558
+ const checkpoint = this.db.prepare("SELECT digest FROM continuity_checkpoints WHERE checkpoint_id=? AND run_id=?").get(checkpointId, runId);
1559
+ const bytes = handoff?.acceptance_receipt_digest && this.getEvidence(handoff.acceptance_receipt_digest);
1560
+ if (!bytes || createHash("sha256").update(bytes).digest("hex") !== handoff.acceptance_receipt_digest)
1561
+ throw new Error("Successor lacks retained accepted ownership evidence");
1562
+ const receipt = parseJson(bytes.toString());
1563
+ if (receipt.runId !== runId || receipt.acceptanceCheckpointDigest !== checkpoint?.digest
1564
+ || providerSessionKey(receipt.successorSessionRef) !== providerSessionKey(successor))
1565
+ throw new Error("Successor ownership differs from accepted checkpoint lineage");
1566
+ }
1567
+ requireStructuredPostprocessing(dispatchId, context) {
1568
+ const operation = this.operation(dispatchId);
1569
+ this.db.prepare("INSERT INTO structured_dispatch_processing(dispatch_id,run_id,state,context_json) VALUES(?,?,'pending',?)")
1570
+ .run(dispatchId, operation.runId, json(context));
1571
+ }
1572
+ structuredProcessingContext(dispatchId) {
1573
+ const row = this.db.prepare("SELECT context_json FROM structured_dispatch_processing WHERE dispatch_id=?").get(dispatchId);
1574
+ if (!row)
1575
+ throw new Error("Missing persisted post-confirmation intent");
1576
+ return parseJson(row.context_json);
1577
+ }
1578
+ failStructuredPostprocessing(dispatchId, error) {
1579
+ this.db.prepare("UPDATE structured_dispatch_processing SET error=? WHERE dispatch_id=?").run(error.slice(0, 4000), dispatchId);
1580
+ }
1581
+ completeStructuredPostprocessing(dispatchId) {
1582
+ if (this.operation(dispatchId)?.status !== "confirmed")
1583
+ throw new Error("Cannot process an unconfirmed dispatch");
1584
+ this.db.prepare("UPDATE structured_dispatch_processing SET state='completed',error=NULL WHERE dispatch_id=?").run(dispatchId);
1585
+ }
1586
+ pendingStructuredPostprocessing(runId) {
1587
+ return this.db.prepare("SELECT dispatch_id FROM structured_dispatch_processing WHERE run_id=? AND state='pending'").all(runId).map(row => row.dispatch_id);
1588
+ }
1589
+ retainedStructuredTurn(dispatchId) {
1590
+ const owner = this.structuredOwnerDispatch(dispatchId), tuple = persistedDispatchTuple(owner);
1591
+ const descriptor = persistedProviderWireContract(tuple);
1592
+ if (!descriptor)
1593
+ throw new Error("Missing historic dispatch descriptor");
1594
+ this.replayStructuredDispatch(dispatchId, descriptor.canonical);
1595
+ const result = this.operation(dispatchId).result;
1596
+ const retained = JSON.parse(this.getEvidence(result.responseDigest).toString());
1597
+ return { text: result.rawDigest ? this.getEvidence(result.rawDigest)?.toString() ?? "" : "", isError: false, numTurns: 1, costUsd: 0,
1598
+ turnId: retained.hostTurnId, selectedDispatch: tuple, structured: hydrateStructuredReceipt(retained, digest => this.getEvidence(digest)) ?? undefined };
1599
+ }
1600
+ /** A claim is never recycled, even if transport fails before writing bytes. */
1601
+ claimStructuredSubmission(tuple, session) {
1602
+ return this.atomic(() => {
1603
+ const operation = this.operation(tuple.dispatchId);
1604
+ if (!operation || operation.status !== "in_progress" || this.structuredOwnerDispatch(tuple.dispatchId).tupleJson !== canonicalJson(tuple))
1605
+ throw new Error("Cannot mint a send capability without a committed sealed owner intent");
1606
+ this.assertNativeRunAdmission(operation.runId);
1607
+ const frozen = operation.intent.sessionRef;
1608
+ if (!session || canonicalJson(frozen) !== canonicalJson(session))
1609
+ throw new Error("Submission claim requires the frozen provider session");
1610
+ const continuation = this.db.prepare("SELECT successor_json FROM structured_handoff_lineage WHERE continuation_id=? AND state='continuation-planned'").get(tuple.dispatchId);
1611
+ if (continuation && canonicalJson(parseJson(continuation.successor_json)) !== canonicalJson(session))
1612
+ throw new Error("Continuation session differs from accepted lineage");
1613
+ const claimId = randomUUID();
1614
+ const changed = this.db.prepare(`INSERT OR IGNORE INTO structured_submission_claims(dispatch_id,run_id,tuple_digest,claim_id,session_json,state,created_at)
1615
+ VALUES(?,?,?,?,?,'claimed',?)`).run(tuple.dispatchId, operation.runId, tuple.digest, claimId, canonicalJson(session), new Date().toISOString());
1616
+ if (changed.changes !== 1)
1617
+ throw new Error("Structured dispatch already has a submission claim; reconcile before a new dispatch");
1618
+ this.db.prepare("UPDATE structured_handoff_lineage SET state='continuation-claimed' WHERE continuation_id=?").run(tuple.dispatchId);
1619
+ return claimId;
1620
+ });
1621
+ }
1622
+ consumeStructuredSubmission(tuple, claimId, session) {
1623
+ this.atomic(() => {
1624
+ const operation = this.operation(tuple.dispatchId);
1625
+ if (!operation || operation.status !== "in_progress" || this.structuredOwnerDispatch(tuple.dispatchId).tupleJson !== canonicalJson(tuple))
1626
+ throw new Error("Submission owner is no longer in progress or sealed");
1627
+ this.assertNativeRunAdmission(operation.runId);
1628
+ const frozen = operation.intent.sessionRef;
1629
+ if (!session || canonicalJson(frozen) !== canonicalJson(session))
1630
+ throw new Error("Submission session changed after sealing");
1631
+ const owner = this.roleMutationLease(operation.runId, session.role);
1632
+ if (!(tuple.operation === "handoff-acceptance" && operation.intent.purpose === "contract-acceptance") && owner && (owner.providerSessionId !== session.sessionId || owner.sessionKey && owner.sessionKey !== providerSessionKey(session))) {
1633
+ this.assertFreshRecoveryValidation(tuple.dispatchId, session);
1634
+ }
1635
+ const changed = this.db.prepare(`UPDATE structured_submission_claims SET state='consumed',consumed_at=?
1636
+ WHERE dispatch_id=? AND run_id=? AND tuple_digest=? AND claim_id=? AND session_json=? AND state='claimed'`)
1637
+ .run(new Date().toISOString(), tuple.dispatchId, operation.runId, tuple.digest, claimId, canonicalJson(session));
1638
+ if (changed.changes !== 1)
1639
+ throw new Error("Structured submission claim already consumed or revoked");
1640
+ });
1641
+ }
1642
+ assertNativeRunAdmission(runId) {
1643
+ assertStructuredCutoverComplete(this.db);
1644
+ const run = this.getRun(runId);
1645
+ if (run?.legacy || run?.checkpoint === STRUCTURED_CUTOVER_REASON || run?.state.structuredCommunicationCutover)
1646
+ throw new Error("structured-communication-v2-cutover: start a new native dispatch; this workflow cannot resume");
1647
+ }
1648
+ recordCommunicationEvidenceRefs(ownerId, digests) {
1649
+ for (const digest of new Set(digests))
1650
+ this.db.prepare("INSERT OR IGNORE INTO communication_evidence_refs(owner_kind,owner_id,evidence_digest,created_at) VALUES('provider-dispatch',?,?,?)").run(ownerId, digest, new Date().toISOString());
1651
+ }
1652
+ replayStructuredDispatch(key, contract) {
1653
+ const operation = this.operation(key);
1654
+ const result = operation?.result;
1655
+ if (operation?.status !== "confirmed" || result?.validation !== "accepted" || !result.responseDigest)
1656
+ throw new Error("Structured dispatch has no confirmed persisted receipt");
1657
+ return this.verifyRetainedStructuredDispatch(key, contract, result.responseDigest);
1658
+ }
1659
+ assertStructuredProviderTurn(key, providerTurnId) {
1660
+ const result = this.operation(key)?.result;
1661
+ const bytes = result?.responseDigest ? this.getEvidence(result.responseDigest) : undefined;
1662
+ if (!bytes)
1663
+ throw new Error("Structured dispatch receipt evidence is missing");
1664
+ const retained = JSON.parse(bytes.toString());
1665
+ if (!providerTurnId || retained?.receipt?.state !== "candidate" || retained.hostTurnId !== providerTurnId)
1666
+ throw new Error("Domain receipt has a foreign native provider turn identity");
1667
+ }
1668
+ verifyRetainedStructuredDispatch(key, contract, responseDigest) {
1669
+ const readVerifiedEvidence = (digest) => {
1670
+ const bytes = this.getEvidence(digest);
1671
+ if (bytes && (!graphDerivedAllowed(this.db, this.projectDir, digest) || createHash("sha256").update(bytes).digest("hex") !== digest))
1672
+ throw new Error("Structured receipt evidence digest mismatch");
1673
+ return bytes;
1674
+ };
1675
+ const bytes = readVerifiedEvidence(responseDigest);
1676
+ if (!bytes)
1677
+ throw new Error("Structured dispatch receipt evidence is missing");
1678
+ const retained = JSON.parse(bytes.toString());
1679
+ const owner = this.structuredOwnerDispatch(key);
1680
+ if (retained.version !== 2 || retained.tupleDigest !== owner.tupleDigest)
1681
+ throw new Error("Stored receipt does not match persisted owner tuple");
1682
+ return verifyPersistedDispatch(contract, owner, hydrateStructuredReceipt(retained, readVerifiedEvidence));
1683
+ }
1327
1684
  updateOperation(idempotencyKey, status, details = {}, now = new Date()) {
1328
1685
  const prior = this.operation(idempotencyKey);
1329
1686
  if (prior?.kind === "provider-dispatch" && status === "in_progress" && this.unresolvedPreparationProcesses(prior.runId).length)
1330
1687
  throw new Error("Readiness cleanup must be reconciled before provider dispatch");
1331
1688
  if (!prior)
1332
1689
  throw new Error(`operation not found: ${idempotencyKey}`);
1690
+ if (prior.kind === "provider-dispatch" && status === "confirmed") {
1691
+ const owner = this.structuredOwnerDispatch(idempotencyKey), tuple = persistedDispatchTuple(owner);
1692
+ const descriptor = persistedProviderWireContract(tuple);
1693
+ const result = details.result;
1694
+ if (!descriptor || result?.validation !== "accepted" || !result.responseDigest)
1695
+ throw new Error("Provider confirmation requires verified persisted receipt evidence");
1696
+ this.verifyRetainedStructuredDispatch(idempotencyKey, descriptor.canonical, result.responseDigest);
1697
+ }
1333
1698
  const allowed = {
1334
1699
  planned: ["planned", "in_progress", "failed"],
1335
1700
  in_progress: ["in_progress", "confirmed", "failed", "uncertain"],
@@ -1389,6 +1754,84 @@ export class WorkflowDb {
1389
1754
  registerGraphDerived(this.db, bytes, graphAccess);
1390
1755
  return digest;
1391
1756
  }
1757
+ /** Create an append-only provider page artifact before its first dispatch. */
1758
+ populationPageRecords(artifactId) {
1759
+ return this.db.prepare("SELECT content FROM page_ledger_pages WHERE artifact_id=? ORDER BY ordinal").all(artifactId)
1760
+ .map(row => row.content.toString("utf8"));
1761
+ }
1762
+ populationPageLedger(artifactId) {
1763
+ const row = this.db.prepare("SELECT tuple_digest,state,page_count FROM page_ledgers WHERE artifact_id=?").get(artifactId);
1764
+ return row && { tupleDigest: row.tuple_digest, state: row.state, pageCount: row.page_count };
1765
+ }
1766
+ beginPageLedger(input, now = new Date()) {
1767
+ if (!/^[A-Za-z0-9_.:-]{1,256}$/.test(input.artifactId) || !/^[A-Za-z0-9_.:-]{1,256}$/.test(input.lineageId) || !/^[A-Za-z0-9_.:-]{1,256}$/.test(input.leaseId) || !/^[a-f0-9]{64}$/.test(input.tupleDigest))
1768
+ throw new Error("Invalid page ledger identity");
1769
+ this.ensureRun(input.runId);
1770
+ this.db.prepare(`INSERT INTO page_ledgers(artifact_id,run_id,lineage_id,tuple_digest,lease_id,state,page_count,total_bytes,created_at,updated_at)
1771
+ VALUES(?,?,?,?,?,'open',0,0,?,?) ON CONFLICT(artifact_id) DO NOTHING`).run(input.artifactId, input.runId, input.lineageId, input.tupleDigest, input.leaseId, now.toISOString(), now.toISOString());
1772
+ const row = this.db.prepare("SELECT run_id,lineage_id,tuple_digest,lease_id FROM page_ledgers WHERE artifact_id=?").get(input.artifactId);
1773
+ if (row.run_id !== input.runId || row.lineage_id !== input.lineageId || row.tuple_digest !== input.tupleDigest || row.lease_id !== input.leaseId)
1774
+ throw new Error("Page ledger identity collision");
1775
+ }
1776
+ /**
1777
+ * Atomically appends a page. Duplicate delivery is accepted only when every
1778
+ * immutable byte and linkage field agrees; a provider cannot fill gaps,
1779
+ * rewrite history, or append after the terminal page.
1780
+ */
1781
+ appendPageLedger(input, now = new Date()) {
1782
+ if (!Number.isInteger(input.ordinal) || input.ordinal < 0 || input.ordinal >= 60)
1783
+ throw new Error("Page ordinal exceeds the 60-page limit");
1784
+ let canonical;
1785
+ let ticketCount;
1786
+ try {
1787
+ const parsed = JSON.parse(input.content);
1788
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed))
1789
+ throw new Error("page must be a JSON object");
1790
+ const tickets = parsed.tickets;
1791
+ if (!Array.isArray(tickets))
1792
+ throw new Error("page must contain tickets");
1793
+ ticketCount = tickets.length;
1794
+ canonical = canonicalJson(parsed);
1795
+ }
1796
+ catch (error) {
1797
+ throw new Error(`Page ledger content must be canonical ticket-page JSON: ${error instanceof Error ? error.message : String(error)}`);
1798
+ }
1799
+ if (canonical !== input.content)
1800
+ throw new Error("Page ledger content must use canonical JSON bytes");
1801
+ if (ticketCount > 150)
1802
+ throw new Error("Page exceeds the 150-ticket limit");
1803
+ const bytes = Buffer.byteLength(input.content);
1804
+ if (bytes > 750 * 1024)
1805
+ throw new Error("Page exceeds the 750 KiB limit");
1806
+ const digest = createHash("sha256").update(input.content).digest("hex");
1807
+ return this.atomic(() => {
1808
+ const header = this.db.prepare("SELECT lineage_id,tuple_digest,lease_id,state,page_count,total_bytes FROM page_ledgers WHERE artifact_id=?").get(input.artifactId);
1809
+ if (!header || header.lineage_id !== input.lineageId || header.tuple_digest !== input.tupleDigest || header.lease_id !== input.leaseId)
1810
+ throw new Error("Unknown or foreign page ledger");
1811
+ const existing = this.db.prepare("SELECT predecessor_digest,content_digest,content,terminal FROM page_ledger_pages WHERE artifact_id=? AND ordinal=?").get(input.artifactId, input.ordinal);
1812
+ if (existing) {
1813
+ if (existing.terminal)
1814
+ throw new Error("Duplicate terminal page");
1815
+ if (existing.predecessor_digest === input.predecessorDigest && existing.content_digest === digest && existing.content.equals(Buffer.from(input.content)) && Boolean(existing.terminal) === input.terminal)
1816
+ return { digest, duplicate: true };
1817
+ throw new Error("Conflicting duplicate page");
1818
+ }
1819
+ if (header.state !== "open")
1820
+ throw new Error("Cannot append after terminal page");
1821
+ if (input.ordinal !== header.page_count)
1822
+ throw new Error("Page ordinal is out of order or has a gap");
1823
+ const prior = input.ordinal === 0 ? null : this.db.prepare("SELECT content_digest FROM page_ledger_pages WHERE artifact_id=? AND ordinal=?").get(input.artifactId, input.ordinal - 1);
1824
+ if ((prior?.content_digest ?? null) !== input.predecessorDigest)
1825
+ throw new Error("Page predecessor digest does not match");
1826
+ if (header.total_bytes + bytes > 6 * 1024 * 1024)
1827
+ throw new Error("Page ledger exceeds the 6 MiB aggregate limit");
1828
+ this.db.prepare("INSERT INTO page_ledger_pages(artifact_id,ordinal,predecessor_digest,content_digest,content,terminal,created_at) VALUES(?,?,?,?,?,?,?)")
1829
+ .run(input.artifactId, input.ordinal, input.predecessorDigest, digest, Buffer.from(input.content), input.terminal ? 1 : 0, now.toISOString());
1830
+ this.db.prepare("UPDATE page_ledgers SET page_count=?,total_bytes=?,state=?,updated_at=? WHERE artifact_id=?")
1831
+ .run(header.page_count + 1, header.total_bytes + bytes, input.terminal ? "terminal" : "open", now.toISOString(), input.artifactId);
1832
+ return { digest, duplicate: false };
1833
+ });
1834
+ }
1392
1835
  registerGraphEvidence(value, access) { registerGraphDerived(this.db, value, access); }
1393
1836
  getEvidence(digest) {
1394
1837
  if (!graphDerivedAllowed(this.db, this.projectDir, digest))
@@ -1449,7 +1892,16 @@ export class WorkflowDb {
1449
1892
  .run(next.status, next.reportDigest ?? null, json({ findingIds: next.findingIds, namespacedFindingIds: next.namespacedFindingIds, detail: next.detail }), next.updatedAt, attemptId);
1450
1893
  const admission = this.admittedWork(next.runId, next.ticketId);
1451
1894
  const turns = this.db.prepare("SELECT receipt_json FROM qa_turns WHERE run_id=? AND ticket_id=? AND review_number=? AND status='completed'").all(next.runId, next.ticketId, next.reviewNumber);
1452
- const terminalBlock = turns.some(row => { const receipt = parseJson(row.receipt_json); const response = receipt.cleanedResponseDigest && this.getEvidence(receipt.cleanedResponseDigest); return receipt.terminalEventObserved && response && /STEP_STATUS:\s*(blocked|needs_input)\b/.test(response.toString()); });
1895
+ const terminalBlock = turns.some(row => {
1896
+ const receipt = parseJson(row.receipt_json);
1897
+ const response = receipt.structuredResponseDigest && this.getEvidence(receipt.structuredResponseDigest);
1898
+ if (!receipt.terminalEventObserved || !response)
1899
+ return false;
1900
+ // A retained response alone is not authority in v2: this legacy receipt
1901
+ // has no sealed tuple/descriptor record to verify. It may be inspected,
1902
+ // but cannot advance a review state.
1903
+ return false;
1904
+ });
1453
1905
  if (admission && (next.status === "passed" || next.status === "failed" || terminalBlock))
1454
1906
  this.qaPreparationStore().metric(next.runId, next.ticketId, admission.requirementsDigest, `metric:review:${attemptId}`, "substantive-review", { status: next.status === "passed" ? "passed" : next.status === "failed" ? "failed" : "blocked", at: now.toISOString(), evidenceRefs: next.reportDigest ? [next.reportDigest] : [] });
1455
1907
  return next;
@@ -1489,8 +1941,9 @@ export class WorkflowDb {
1489
1941
  if (!prior)
1490
1942
  throw new Error(`QA remediation attempt not found: ${attemptId}`);
1491
1943
  const next = { ...prior, ...patch, status, updatedAt: now.toISOString() };
1944
+ const raw = this.db.prepare("SELECT record_json FROM qa_remediation_attempts WHERE attempt_id=?").get(attemptId);
1492
1945
  this.db.prepare("UPDATE qa_remediation_attempts SET status=?,response_digest=?,summary_digest=?,record_json=?,updated_at=? WHERE attempt_id=?")
1493
- .run(status, next.responseDigest ?? null, next.summaryDigest ?? null, json({ detail: next.detail, recoveryAttemptId: next.recoveryAttemptId }), next.updatedAt, attemptId);
1946
+ .run(status, next.responseDigest ?? null, next.summaryDigest ?? null, json({ ...parseJson(raw.record_json), detail: next.detail, recoveryAttemptId: next.recoveryAttemptId }), next.updatedAt, attemptId);
1494
1947
  return next;
1495
1948
  }
1496
1949
  qaRemediationAttempts(runId, ticketId) {
@@ -1511,6 +1964,11 @@ export class WorkflowDb {
1511
1964
  ensureQaGraphJournalGuards(this.db);
1512
1965
  const prior = this.qaDeliveryTurns(turn.operationId).find(t => t.turnRecordId === turn.turnRecordId);
1513
1966
  if (prior) {
1967
+ if (prior.sealedDispatch) {
1968
+ if (turn.sealedDispatch && json(turn.sealedDispatch) !== json(prior.sealedDispatch))
1969
+ throw new Error("Delivery owner tuple is immutable");
1970
+ turn.sealedDispatch = prior.sealedDispatch;
1971
+ }
1514
1972
  if (prior.status !== "intended") {
1515
1973
  if (json(prior) !== json(turn))
1516
1974
  throw new Error(`conflicting completed delivery turn ${turn.turnRecordId}`);
@@ -1521,6 +1979,14 @@ export class WorkflowDb {
1521
1979
  throw new Error(`delivery turn intent mismatch: ${key}`);
1522
1980
  }
1523
1981
  }
1982
+ if (turn.status === "completed") {
1983
+ const transport = this.operation(turn.turnRecordId);
1984
+ if (!prior?.sealedDispatch || json(turn.sealedDispatch) !== json(prior.sealedDispatch)
1985
+ || turn.structuredResponseDigest !== transport?.result?.responseDigest)
1986
+ throw new Error("Completed delivery requires its persisted sealed native receipt");
1987
+ this.replayStructuredDispatch(turn.turnRecordId, remediationOutput);
1988
+ this.assertStructuredProviderTurn(turn.turnRecordId, turn.providerTurnId);
1989
+ }
1524
1990
  this.db.prepare(`INSERT INTO qa_delivery_turns(turn_record_id,operation_id,report_occurrence_id,turn_index,kind,status,record_json,created_at,updated_at)
1525
1991
  VALUES(?,?,?,?,?,?,?,?,?) ON CONFLICT(turn_record_id) DO UPDATE SET status=excluded.status,record_json=excluded.record_json,updated_at=excluded.updated_at`)
1526
1992
  .run(turn.turnRecordId, turn.operationId, turn.reportOccurrenceId, turn.turnIndex, turn.kind, turn.status, json(turn), turn.startedAt, turn.completedAt ?? turn.startedAt);
@@ -1646,6 +2112,7 @@ export class WorkflowDb {
1646
2112
  }
1647
2113
  /** Compare-and-swap the authoritative QA reducer head and append its event atomically. */
1648
2114
  transitionQa(runId, ticketId, expectedRevision, event, now = new Date()) {
2115
+ this.assertNativeRunAdmission(runId);
1649
2116
  this.ensureRun(runId);
1650
2117
  return this.db.transaction(() => {
1651
2118
  const current = this.qaTicketHead(runId, ticketId);
@@ -1734,7 +2201,18 @@ export class WorkflowDb {
1734
2201
  const row = this.db.prepare("SELECT receipt_json FROM qa_handoffs WHERE operation_id=? AND status='accepted'").get(operationId);
1735
2202
  return row?.receipt_json ? parseJson(row.receipt_json) : undefined;
1736
2203
  }
2204
+ /**
2205
+ * Structured authority is admitted only after the one-way v2 cutover has
2206
+ * committed. Keeping this at the common admission boundary prevents a
2207
+ * caller that opens WorkflowDb directly from bypassing the migration.
2208
+ */
2209
+ enableStructuredCommunication() {
2210
+ this.applyStructuredCommunicationV2Cutover();
2211
+ ensureQaGraphJournalGuards(this.db);
2212
+ }
1737
2213
  beginQaTurn(intent) {
2214
+ if (intent.outputContract)
2215
+ this.enableStructuredCommunication();
1738
2216
  const at = intent.intendedAt;
1739
2217
  this.db.transaction(() => {
1740
2218
  const existing = this.db.prepare("SELECT intent_json FROM qa_turns WHERE operation_id=?").get(intent.operationId);
@@ -1746,6 +2224,50 @@ export class WorkflowDb {
1746
2224
  .run(intent.runId, intent.ticketId, intent.reviewNumber, intent.sessionGeneration, intent.slot, intent.operationId, at);
1747
2225
  })();
1748
2226
  }
2227
+ /** Retire this run's unresolved dispatches on a provider session the host
2228
+ * has just closed after a failed call. The closed provider can no longer
2229
+ * act, so the call's outcome is final: unknown, never replayed. */
2230
+ retireClosedSessionDispatches(runId, sessionId, reason) {
2231
+ if (!sessionId)
2232
+ return [];
2233
+ const dispatches = [...this.unresolvedRoleDispatches(runId, "builder"), ...this.unresolvedRoleDispatches(runId, "qa")]
2234
+ .filter(dispatch => dispatch.intent.sessionRef?.sessionId === sessionId);
2235
+ this.atomic(() => {
2236
+ for (const dispatch of dispatches)
2237
+ this.updateOperation(dispatch.idempotencyKey, "failed", { result: { recovery: "provider-closed", outcomeUnknown: true, providerReplayAuthorized: false, priorResult: dispatch.result }, error: reason });
2238
+ });
2239
+ return dispatches.map(dispatch => dispatch.idempotencyKey);
2240
+ }
2241
+ /** Retire interrupted role turns that are not implementation or bound QA
2242
+ * review work (for example build preflight, coverage, or handoff
2243
+ * acceptance) so the step restarts from its beginning. Implementation and
2244
+ * review turns keep their own recovery. The interrupted outcome stays unknown and is
2245
+ * never replayed or treated as a result. Returns the retired dispatch IDs. */
2246
+ reconcileInterruptedSteps(runId, role) {
2247
+ // Only steps that are safe to run again from their beginning. Implementation
2248
+ // turns, bound QA review turns and turns of unknown kind keep their own recovery.
2249
+ const reviewTurn = this.db.prepare("SELECT 1 FROM qa_turns WHERE operation_id=?");
2250
+ const dispatches = this.unresolvedRoleDispatches(runId, role).filter(dispatch => {
2251
+ const operation = String(dispatch.intent.operation ?? "");
2252
+ return RESTARTABLE_STEPS[role].has(operation) && (role === "builder" || !reviewTurn.get(dispatch.idempotencyKey));
2253
+ });
2254
+ if (!dispatches.length)
2255
+ return [];
2256
+ const lease = this.currentLease();
2257
+ if (lease?.runId !== runId || lease.pid !== process.pid)
2258
+ throw new Error("Step recovery requires the original build recovery lease");
2259
+ const worker = this.events(runId).filter(event => event.type === "worker_started").at(-1)?.payload;
2260
+ if (!worker?.workerPid || classifyProcess(worker.workerPid, worker.processStart, worker.host).state !== "dead" || !processGroupQuiescent(worker.workerPid)
2261
+ || this.readinessProcesses().length)
2262
+ throw new Error("The interrupted step and all owned provider processes must be verified stopped before it restarts");
2263
+ const reconciliationId = `${role}-step-restart:${runId}:${randomUUID()}`;
2264
+ this.atomic(() => {
2265
+ for (const dispatch of dispatches)
2266
+ this.updateOperation(dispatch.idempotencyKey, "failed", { result: { recovery: "step-restart", reconciliationId, outcomeUnknown: true, providerReplayAuthorized: false, operation: dispatch.intent.operation ?? null, priorResult: dispatch.result }, error: "Interrupted step outcome remains unknown; the step restarts from its beginning with its original inputs" });
2267
+ this.appendContinuityEvent({ runId, role: "host", kind: "interrupted_step_restarted", payload: { role, reconciliationId, dispatchIds: dispatches.map(dispatch => dispatch.idempotencyKey), outcomeUnknown: true, providerReplayAuthorized: false }, authoritativeStateRevision: this.continuityHead(runId, "run")?.authoritativeStateRevision ?? 0 });
2268
+ });
2269
+ return dispatches.map(dispatch => dispatch.idempotencyKey);
2270
+ }
1749
2271
  /** Retire an interrupted, proven read-only review transport, never a QA pass
1750
2272
  * or Builder remediation. A fresh immutable review must still run afterward. */
1751
2273
  reconcileInterruptedQaDispatch(runId, ticketId) {
@@ -1772,17 +2294,20 @@ export class WorkflowDb {
1772
2294
  // A cancellation can have a durable host terminal receipt while the
1773
2295
  // provider dispatch is still unknown. Exact frozen-turn bindings remain
1774
2296
  // required; the old receipt is never promoted to an accepted QA result.
2297
+ // A QA turn and its provider dispatch share one ID: the turn's operation ID
2298
+ // is the dispatch's idempotency key. Instruction digests are not comparable
2299
+ // across the two records (the dispatch hashes the full rendered provider
2300
+ // instruction), so binding is by that shared ID.
2301
+ // A host-completed turn is recoverable only when its dispatch recorded a
2302
+ // typed unknown provider outcome.
1775
2303
  const rows = this.db.prepare("SELECT status,intent_json FROM qa_turns WHERE run_id=? AND ticket_id=? AND status IN ('intended','uncertain','unknown','completed')").all(runId, ticketId);
1776
- const turns = rows.flatMap(row => {
2304
+ const turns = new Map(rows.flatMap(row => {
1777
2305
  const turn = parseJson(row.intent_json);
1778
- if (row.status === "completed" && !dispatches.some(dispatch => {
1779
- const result = dispatch.result;
1780
- return result?.isError === true && result.failure?.dispatchState === "unknown"
1781
- && dispatch.intent.instructionDigest === turn.instructionDigest;
1782
- }))
2306
+ const result = dispatches.find(dispatch => dispatch.idempotencyKey === turn.operationId)?.result;
2307
+ if (row.status === "completed" && !(result?.isError === true && result.failure?.dispatchState === "unknown"))
1783
2308
  return [];
1784
- return [turn];
1785
- });
2309
+ return [[turn.operationId, turn]];
2310
+ }));
1786
2311
  const bindings = dispatches.map(dispatch => {
1787
2312
  const intent = dispatch.intent;
1788
2313
  const ref = intent.sessionRef;
@@ -1790,9 +2315,10 @@ export class WorkflowDb {
1790
2315
  || !ref.configRoot || canonicalProject(ref.configRoot) !== canonicalProject(this.projectDir) || !ref.validatedAt || Number.isNaN(Date.parse(ref.validatedAt))
1791
2316
  || ref.source !== "observed" || providerSessionKey(ref) !== owner.sessionKey)
1792
2317
  throw new Error("QA dispatch has no validated owner binding");
1793
- const turn = turns.find(turn => turn.reviewNumber === head.reviewNumber && turn.instructionDigest === intent.instructionDigest
1794
- && turn.providerSession.sessionId === ref.sessionId && turn.providerSession.provider === ref.provider && turn.providerSession.generation === ref.generation
1795
- && turn.providerSession.cwd === ref.cwd && turn.providerSession.configRoot === ref.configRoot);
2318
+ const candidate = turns.get(dispatch.idempotencyKey);
2319
+ const turn = candidate && candidate.reviewNumber === head.reviewNumber
2320
+ && candidate.providerSession.sessionId === ref.sessionId && candidate.providerSession.provider === ref.provider && candidate.providerSession.generation === ref.generation
2321
+ && candidate.providerSession.cwd === ref.cwd && candidate.providerSession.configRoot === ref.configRoot ? candidate : undefined;
1796
2322
  if (!turn || turn.sourceStateDigest !== head.sourceStateDigest || turn.reviewBasisDigest !== head.reviewBasisDigest)
1797
2323
  throw new Error("QA dispatch does not match the frozen review turn");
1798
2324
  const sessions = this.db.prepare("SELECT session_json FROM qa_sessions WHERE run_id=? AND ticket_id=? AND generation=? AND source_state_digest=? AND review_basis_digest=?").all(runId, ticketId, ref.generation, turn.sourceStateDigest, turn.reviewBasisDigest);
@@ -1839,9 +2365,24 @@ export class WorkflowDb {
1839
2365
  }
1840
2366
  finishQaTurn(receipt) {
1841
2367
  this.db.transaction(() => {
1842
- const prior = this.db.prepare("SELECT source_state_digest,review_basis_digest,status FROM qa_turns WHERE operation_id=?").get(receipt.operationId);
2368
+ const prior = this.db.prepare("SELECT source_state_digest,review_basis_digest,status,intent_json FROM qa_turns WHERE operation_id=?").get(receipt.operationId);
1843
2369
  if (!prior)
1844
2370
  throw new Error(`QA turn intent not found: ${receipt.operationId}`);
2371
+ const intent = parseJson(prior.intent_json);
2372
+ if (receipt.dispatch === "completed") {
2373
+ if (!intent.outputContract)
2374
+ throw new Error("Completed QA receipt requires its sealed native contract");
2375
+ const owner = this.structuredOwnerDispatch(receipt.operationId);
2376
+ if (json(receipt.sealedDispatch) !== json(owner))
2377
+ throw new Error("QA receipt differs from sealed owner intent");
2378
+ if (receipt.structuredResponseDigest !== this.operation(receipt.operationId)?.result?.responseDigest)
2379
+ throw new Error("QA receipt references foreign native evidence");
2380
+ const descriptor = persistedProviderWireContract(persistedDispatchTuple(owner));
2381
+ if (!descriptor)
2382
+ throw new Error("QA receipt contract is unavailable");
2383
+ this.replayStructuredDispatch(receipt.operationId, descriptor.canonical);
2384
+ this.assertStructuredProviderTurn(receipt.operationId, receipt.providerTurnId);
2385
+ }
1845
2386
  if (prior.source_state_digest !== receipt.sourceStateDigest || prior.review_basis_digest !== receipt.reviewBasisDigest)
1846
2387
  throw new Error("QA turn receipt binding mismatch");
1847
2388
  if (prior.status === "completed") {
@@ -2000,6 +2541,13 @@ export class WorkflowDb {
2000
2541
  const row = this.db.prepare("SELECT receipt_json FROM qa_remediation_receipts WHERE operation_id=?").get(operationId);
2001
2542
  return row ? parseJson(row.receipt_json) : undefined;
2002
2543
  }
2544
+ verifiedBuilderRemediationReceipt(operationId) {
2545
+ const receipt = this.builderRemediationReceipt(operationId);
2546
+ if (!receipt)
2547
+ return undefined;
2548
+ this.assertBuilderRemediationReceipt(receipt);
2549
+ return receipt;
2550
+ }
2003
2551
  hasConfirmedQaRemediationForRecheck(runId, ticketId) {
2004
2552
  const head = this.qaTicketHead(runId, ticketId);
2005
2553
  const attempt = this.qaRemediationAttempts(runId, ticketId).at(-1);
@@ -2007,6 +2555,8 @@ export class WorkflowDb {
2007
2555
  || attempt.generation !== head.remediationGeneration || this.unresolvedRoleDispatches(runId, "builder").length)
2008
2556
  return false;
2009
2557
  const receipt = this.builderRemediationReceipt(attempt.attemptId);
2558
+ if (receipt)
2559
+ this.assertBuilderRemediationReceipt(receipt);
2010
2560
  const review = this.qaReviewAttempt(attempt.reviewAttemptId);
2011
2561
  const transition = this.db.prepare("SELECT to_revision,event_json FROM qa_transitions WHERE run_id=? AND ticket_id=? ORDER BY to_revision DESC LIMIT 1").get(runId, ticketId);
2012
2562
  return Boolean(receipt && review && review.status === "failed" && receipt.runId === runId && receipt.ticketId === ticketId
@@ -2016,6 +2566,7 @@ export class WorkflowDb {
2016
2566
  && transition?.to_revision === head.revision && parseJson(transition.event_json).type === "remediation-received");
2017
2567
  }
2018
2568
  recordBuilderRemediationReceipt(receipt) {
2569
+ this.assertBuilderRemediationReceipt(receipt);
2019
2570
  const text = json(receipt);
2020
2571
  const existing = this.db.prepare("SELECT receipt_json FROM qa_remediation_receipts WHERE operation_id=?").get(receipt.operationId);
2021
2572
  if (existing && json(parseJson(existing.receipt_json)) !== text)
@@ -2028,6 +2579,30 @@ export class WorkflowDb {
2028
2579
  this.db.prepare("INSERT OR IGNORE INTO qa_remediation_receipts(operation_id,run_id,ticket_id,report_digest,report_occurrence_id,receipt_json,created_at) VALUES(?,?,?,?,?,?,?)")
2029
2580
  .run(receipt.operationId, receipt.runId, receipt.ticketId, receipt.reportDigest, occurrence.reportOccurrenceId, text, receipt.completedAt);
2030
2581
  }
2582
+ assertBuilderRemediationReceipt(receipt) {
2583
+ if (receipt.version !== 3)
2584
+ throw new Error("Legacy Builder receipt cannot authorize native remediation");
2585
+ this.assertNativeRunAdmission(receipt.runId);
2586
+ const attempt = this.qaRemediationAttempt(receipt.operationId);
2587
+ const review = attempt && this.qaReviewAttempt(attempt.reviewAttemptId);
2588
+ const turn = this.qaDeliveryTurns(receipt.operationId).find(value => value.turnRecordId === receipt.turnRecordId);
2589
+ if (!attempt || !review || !turn || turn.status !== "completed" || attempt.status !== "succeeded"
2590
+ || attempt.runId !== receipt.runId || attempt.ticketId !== receipt.ticketId
2591
+ || review.sourceDigest !== receipt.sourceStateDigest || review.reportDigest !== receipt.reportDigest
2592
+ || attempt.requestDigest !== receipt.requestDigest || attempt.responseDigest !== receipt.responseDigest
2593
+ || attempt.summaryDigest !== receipt.summaryDigest || turn.rawResponseDigest !== receipt.responseDigest
2594
+ || turn.providerTurnId !== receipt.providerTurnId || turn.reportOccurrenceId !== receipt.reportOccurrenceId)
2595
+ throw new Error("Builder receipt differs from its persisted remediation owner");
2596
+ const transport = this.operation(turn.turnRecordId);
2597
+ if (turn.structuredResponseDigest !== transport?.result?.responseDigest)
2598
+ throw new Error("Builder receipt has a foreign native evidence reference");
2599
+ const result = this.replayStructuredDispatch(turn.turnRecordId, remediationOutput);
2600
+ this.assertStructuredProviderTurn(turn.turnRecordId, receipt.providerTurnId);
2601
+ const handoff = this.db.prepare("SELECT handoff_id FROM qa_failure_handoffs WHERE operation_id=?").get(receipt.operationId);
2602
+ if (result.result.kind !== "completed" || result.result.payload.status !== "done" || result.result.payload.report.handoff_id !== handoff?.handoff_id
2603
+ || createHash("sha256").update(result.result.payload.report.summary).digest("hex") !== receipt.summaryDigest)
2604
+ throw new Error("Builder receipt requires its completed native remediation result and summary");
2605
+ }
2031
2606
  commitBuilderRemediationSuccess(receipt, expectedRevision, now = new Date()) {
2032
2607
  return this.db.transaction(() => {
2033
2608
  this.recordBuilderRemediationReceipt(receipt);
@@ -2058,6 +2633,47 @@ export class WorkflowDb {
2058
2633
  return this.transitionQa(remediation.runId, remediation.ticketId, head.revision, { type: input.outcome === "uncertain" ? "remediation-uncertain" : "remediation-failed" }, now);
2059
2634
  })();
2060
2635
  }
2636
+ assertNativeQaCertificate(input, phase, certificateId) {
2637
+ this.assertNativeRunAdmission(input.runId);
2638
+ const head = this.qaTicketHead(input.runId, input.ticketId);
2639
+ const rows = this.db.prepare("SELECT * FROM qa_turns WHERE run_id=? AND ticket_id=? AND source_state_digest=? AND review_basis_digest=? AND status='completed'")
2640
+ .all(input.runId, input.ticketId, input.sourceStateDigest, input.reviewBasisDigest);
2641
+ const row = rows.find(row => {
2642
+ try {
2643
+ return qaDigest("turn-receipt", parseJson(row.receipt_json)) === input.turnReceiptDigest;
2644
+ }
2645
+ catch {
2646
+ return false;
2647
+ }
2648
+ });
2649
+ if (!row)
2650
+ throw new Error("Pass requires the persisted sealed native QA owner receipt");
2651
+ const receipt = parseJson(row.receipt_json);
2652
+ const intent = parseJson(row.intent_json);
2653
+ if (!receipt.terminalEventObserved || receipt.dispatch !== "completed" || receipt.operationId !== row.operation_id || intent.operationId !== row.operation_id ||
2654
+ intent.runId !== input.runId || intent.ticketId !== input.ticketId || intent.reviewNumber !== head.reviewNumber || intent.reviewNumber !== row.review_number || intent.sessionGeneration !== head.sessionGeneration ||
2655
+ intent.sourceStateDigest !== input.sourceStateDigest || intent.reviewBasisDigest !== input.reviewBasisDigest ||
2656
+ receipt.sourceStateDigest !== input.sourceStateDigest || receipt.reviewBasisDigest !== input.reviewBasisDigest ||
2657
+ !receipt.sealedDispatch || canonicalJson(receipt.sealedDispatch) !== canonicalJson(this.structuredOwnerDispatch(receipt.operationId)))
2658
+ throw new Error("Native pass owner/source/review binding mismatch");
2659
+ this.assertStructuredProviderTurn(receipt.operationId, receipt.providerTurnId);
2660
+ const verdict = this.replayStructuredDispatch(receipt.operationId, qaVerdictOutput);
2661
+ if (verdict.result.kind !== "completed" || verdict.result.payload.kind !== "passed")
2662
+ throw new Error("Pass requires a verified native passed verdict");
2663
+ const operation = this.operation(receipt.operationId);
2664
+ const tuple = persistedDispatchTuple(this.structuredOwnerDispatch(receipt.operationId));
2665
+ if (operation.runId !== input.runId || intent.providerSession.sessionId !== tuple.sessionId || intent.providerSession.generation !== tuple.generation || intent.providerSession.provider !== tuple.provider)
2666
+ throw new Error("Native pass session/tuple binding mismatch");
2667
+ const attempt = this.qaReviewAttempt(operation.intent.logicalActionId ?? "");
2668
+ if (operation.intent.logicalActionId && !attempt)
2669
+ throw new Error("Native pass review attempt is missing");
2670
+ if (attempt && (attempt.runId !== input.runId || attempt.ticketId !== input.ticketId || attempt.reviewNumber !== head.reviewNumber || attempt.sourceDigest !== input.sourceStateDigest || attempt.status !== "passed"))
2671
+ throw new Error("Native pass review attempt is not completed and bound");
2672
+ if (head.sourceStateDigest !== input.sourceStateDigest || head.reviewBasisDigest !== input.reviewBasisDigest ||
2673
+ (phase === "issue" ? head.state !== "turn-intended" || input.qaRevision !== head.revision + 1 :
2674
+ head.passCertificateId !== certificateId || (phase === "consume" ? head.state !== "passed" || input.qaRevision !== head.revision : head.state !== "finalizing" || input.qaRevision + 1 !== head.revision)))
2675
+ throw new Error("Native pass certificate does not match the reducer phase/revision");
2676
+ }
2061
2677
  assertContractCertificate(input) {
2062
2678
  const store = this.qaPreparationStore();
2063
2679
  if (store.policy(input.runId)?.mode !== "enforce")
@@ -2065,6 +2681,7 @@ export class WorkflowDb {
2065
2681
  const binding = input.contractCoverage;
2066
2682
  if (!binding || binding.version !== 1)
2067
2683
  throw new Error("Enforcing pass requires tagged contract coverage authority");
2684
+ const verifiedBinding = binding;
2068
2685
  const admission = this.assertAdmittedWork(input.runId, input.ticketId);
2069
2686
  const head = store.head(input.runId, input.ticketId, admission.requirementsDigest);
2070
2687
  if (head.state !== "ready" || head.digest !== binding.contractDigest)
@@ -2085,17 +2702,17 @@ export class WorkflowDb {
2085
2702
  const configuredMake = this.getRun(input.runId)?.state?.qa?.settings?.make;
2086
2703
  if (configuredMake && resolveEffectiveQaConfiguration(this.projectDir, { make: configuredMake }).digest !== basis.contractBinding.commonConfigDigest)
2087
2704
  throw new Error("Effective QA configuration changed before pass authority; reconcile contract");
2088
- const attempt = this.qaReviewAttempt(binding.attemptId);
2705
+ const attempt = this.qaReviewAttempt(verifiedBinding.attemptId);
2089
2706
  if (!attempt || attempt.runId !== input.runId || attempt.ticketId !== input.ticketId || attempt.sourceDigest !== input.sourceStateDigest || attempt.reviewNumber !== this.qaTicketHead(input.runId, input.ticketId).reviewNumber)
2090
2707
  throw new Error("Coverage review attempt is stale or foreign");
2091
- const coverage = store.artifact(binding.coverageDigest, "final-coverage");
2708
+ const coverage = store.artifact(verifiedBinding.coverageDigest, "final-coverage");
2092
2709
  for (const check of contract.checks)
2093
2710
  for (const method of check.verification)
2094
2711
  if (method.equivalentAuthorityId && !store.equivalentResolver()(method.equivalentAuthorityId, contract, check, method, input.sourceStateDigest))
2095
2712
  throw new Error("Equivalent verification authority is stale or foreign");
2096
2713
  const initialTurns = this.db.prepare("SELECT intent_json FROM qa_turns WHERE run_id=? AND ticket_id=? AND review_number=? AND source_state_digest=? AND review_basis_digest=?").all(input.runId, input.ticketId, attempt.reviewNumber, input.sourceStateDigest, input.reviewBasisDigest);
2097
2714
  const initial = initialTurns.map(row => parseJson(row.intent_json)).find(turn => turn.slot === "initial");
2098
- if (!initial || initial.providerSession.sessionId !== binding.sessionId)
2715
+ if (!initial || initial.providerSession.sessionId !== verifiedBinding.sessionId)
2099
2716
  throw new Error("Coverage lacks the actual fresh review dispatch identity");
2100
2717
  const dispatchedInstruction = this.getEvidence(initial.instructionDigest)?.toString();
2101
2718
  const envelope = `\nImmutable input-basis digest: ${basis.digest}. Coverage must bind this inputBasisDigest.`;
@@ -2103,49 +2720,65 @@ export class WorkflowDb {
2103
2720
  throw new Error("Actual review dispatch differs from frozen base instruction and tagged input-basis envelope");
2104
2721
  const receipts = this.db.prepare("SELECT receipt_json FROM qa_turns WHERE run_id=? AND ticket_id=? AND source_state_digest=? AND review_basis_digest=? AND status='completed'").all(input.runId, input.ticketId, input.sourceStateDigest, input.reviewBasisDigest);
2105
2722
  const receipt = receipts.map(row => parseJson(row.receipt_json)).find(row => qaDigest("turn-receipt", row) === input.turnReceiptDigest && row.terminalEventObserved);
2106
- const response = receipt?.cleanedResponseDigest ? this.getEvidence(receipt.cleanedResponseDigest) : undefined;
2107
- const retainedProviderCoverage = receipt && response
2108
- ? bindCoverageHostFields(parseContractCoverage(response.toString()), contract, {
2109
- phase: "qa",
2110
- sourceDigest: input.sourceStateDigest,
2111
- inputBasisDigest: input.reviewBasisDigest,
2112
- attemptId: binding.attemptId,
2113
- sessionId: binding.sessionId,
2114
- })
2115
- : undefined;
2723
+ const response = receipt?.structuredResponseDigest ? this.getEvidence(receipt.structuredResponseDigest) : undefined;
2724
+ if (!response || !receipt)
2725
+ throw new Error("Pass requires a retained native QA verdict receipt");
2726
+ const owner = this.structuredOwnerDispatch(receipt.operationId);
2727
+ if (json(receipt.sealedDispatch) !== json(owner))
2728
+ throw new Error("Pass receipt lacks its persisted v2 owner tuple");
2729
+ if (persistedDispatchTuple(owner).sessionId !== verifiedBinding.sessionId)
2730
+ throw new Error("Pass native receipt has a foreign session");
2731
+ const verdict = this.replayStructuredDispatch(receipt.operationId, qaVerdictOutput);
2732
+ if (verdict.result.kind !== "completed" || verdict.result.payload.kind !== "passed" || !verdict.result.payload.coverage)
2733
+ throw new Error("Pass requires complete coverage in the retained native passed verdict");
2734
+ const retainedProviderCoverage = bindCoverageHostFields(verdict.result.payload.coverage, contract, {
2735
+ phase: "qa", sourceDigest: input.sourceStateDigest, inputBasisDigest: input.reviewBasisDigest,
2736
+ attemptId: verifiedBinding.attemptId, sessionId: verifiedBinding.sessionId,
2737
+ });
2116
2738
  let retainedCoverage = retainedProviderCoverage;
2117
2739
  let retainedHostComparison;
2118
2740
  const hostCheck = findHostQaCheck(contract);
2119
- if (hostCheck) {
2741
+ if (hostCheck !== undefined) {
2120
2742
  const hostDigest = hostComparisonDigestFromCoverage(coverage, hostCheck.id);
2121
2743
  if (!hostDigest)
2122
2744
  throw new Error("Coverage is missing the host-owned comparison artifact reference");
2123
- const hostResult = hostQaComparisonV1Schema.parse(store.artifact(hostDigest, "host-qa-comparison"));
2124
- retainedHostComparison = { result: hostResult, resultDigest: hostDigest };
2745
+ const verifiedHostDigest = hostDigest;
2746
+ const hostResult = hostQaComparisonV1Schema.parse(store.artifact(verifiedHostDigest, "host-qa-comparison"));
2747
+ retainedHostComparison = { result: hostResult, resultDigest: verifiedHostDigest };
2125
2748
  }
2749
+ const commandObservations = [
2750
+ ...retainedQaCommandObservations(this.projectDir, input.runId, input.ticketId, false),
2751
+ ...retainedHostBuilderObservations(this, input.runId, input.ticketId),
2752
+ ];
2126
2753
  const graphCheck = findGraphQaChecks(contract)[0];
2754
+ let retainedHostGraph;
2127
2755
  if (graphCheck) {
2128
2756
  const graphDigest = hostGraphApplicabilityDigestFromCoverage(coverage, graphCheck.id);
2129
2757
  if (!graphDigest)
2130
2758
  throw new Error("Coverage is missing the host-owned Graphify applicability artifact reference");
2131
- const graphResult = hostGraphApplicabilityV1Schema.parse(store.artifact(graphDigest, "host-graph-applicability"));
2132
- const retainedHostGraph = { result: graphResult, resultDigest: graphDigest };
2133
- retainedCoverage = retainedCoverage
2134
- ? applyHostQaAugmentations({ coverage: retainedCoverage, contract, projectDir: this.projectDir, hostComparison: retainedHostComparison, hostGraph: retainedHostGraph })
2135
- : undefined;
2136
- }
2137
- else if (retainedCoverage && retainedHostComparison) {
2138
- retainedCoverage = applyHostQaAugmentations({ coverage: retainedCoverage, contract, projectDir: this.projectDir, hostComparison: retainedHostComparison });
2759
+ const verifiedGraphDigest = graphDigest;
2760
+ const graphResult = hostGraphApplicabilityV1Schema.parse(store.artifact(verifiedGraphDigest, "host-graph-applicability"));
2761
+ retainedHostGraph = { result: graphResult, resultDigest: verifiedGraphDigest };
2139
2762
  }
2763
+ if (retainedCoverage)
2764
+ retainedCoverage = assembleFinalQaCoverage({
2765
+ providerCoverage: retainedCoverage,
2766
+ contract,
2767
+ projectDir: this.projectDir,
2768
+ commandObservations,
2769
+ hostComparison: retainedHostComparison,
2770
+ hostGraph: retainedHostGraph,
2771
+ });
2140
2772
  if (!retainedCoverage)
2141
2773
  throw new Error("Coverage lacks the retained terminal review receipt and authorized host augmentation");
2142
- const errors = validateContractCoverage(contract, coverage, { phase: "qa", sourceDigest: input.sourceStateDigest, inputBasisDigest: input.reviewBasisDigest, attemptId: binding.attemptId, sessionId: binding.sessionId });
2774
+ const errors = validateContractCoverage(contract, retainedCoverage, { phase: "qa", sourceDigest: input.sourceStateDigest, inputBasisDigest: input.reviewBasisDigest, attemptId: verifiedBinding.attemptId, sessionId: verifiedBinding.sessionId });
2143
2775
  if (errors.length)
2144
2776
  throw new Error(errors.join("; "));
2145
2777
  if (!receipt || !retainedCoverage || canonicalJson(retainedCoverage) !== canonicalJson(coverage))
2146
2778
  throw new Error("Coverage is not bound to the retained terminal review receipt and authorized host augmentation");
2147
2779
  }
2148
2780
  issueQaPassCertificate(input, now = new Date()) {
2781
+ this.assertNativeQaCertificate(input, "issue");
2149
2782
  this.assertContractCertificate(input);
2150
2783
  if (this.unresolvedQaReports(input.runId, input.ticketId).length)
2151
2784
  throw new Error("cannot issue QA pass certificate while reports remain unresolved");
@@ -2178,11 +2811,16 @@ export class WorkflowDb {
2178
2811
  }
2179
2812
  consumeQaPassCertificate(runId, ticketId, certificateId, consumer, now = new Date()) {
2180
2813
  return this.db.transaction(() => {
2181
- const row = this.db.prepare("SELECT certificate_json,consumed_at FROM qa_pass_certificates WHERE certificate_id=? AND run_id=? AND ticket_id=?").get(certificateId, runId, ticketId);
2814
+ const row = this.db.prepare("SELECT * FROM qa_pass_certificates WHERE certificate_id=? AND run_id=? AND ticket_id=?").get(certificateId, runId, ticketId);
2182
2815
  if (!row)
2183
2816
  throw new Error("QA pass certificate is missing or scoped to another run/ticket");
2184
2817
  if (row.consumed_at)
2185
2818
  throw new Error("QA pass certificate has already been consumed");
2819
+ const value = parseJson(row.certificate_json);
2820
+ if (value.certificateId !== row.certificate_id || value.runId !== runId || value.ticketId !== ticketId || value.qaRevision !== row.qa_revision ||
2821
+ value.sourceStateDigest !== row.source_state_digest || value.reviewBasisDigest !== row.review_basis_digest || value.turnReceiptDigest !== row.turn_receipt_digest || value.issuedAt !== row.issued_at)
2822
+ throw new Error("Pass certificate SQL identity mismatch");
2823
+ this.assertNativeQaCertificate(value, "consume", certificateId);
2186
2824
  this.assertContractCertificate(parseJson(row.certificate_json));
2187
2825
  if (this.unresolvedQaReports(runId, ticketId).length)
2188
2826
  throw new Error("QA pass certificate cannot be consumed with unresolved reports");
@@ -2231,6 +2869,26 @@ export class WorkflowDb {
2231
2869
  return next;
2232
2870
  })();
2233
2871
  }
2872
+ assertQaFinalizationAuthority(runId, ticketId) {
2873
+ const steps = this.qaFinalizationSteps(runId, ticketId).filter(step => step.status !== "invalidated");
2874
+ if (!steps.length)
2875
+ throw new Error("QA finalization intent is missing");
2876
+ for (const step of steps) {
2877
+ const row = this.db.prepare("SELECT * FROM qa_pass_certificates WHERE certificate_id=? AND run_id=? AND ticket_id=?")
2878
+ .get(step.certificateId, runId, ticketId);
2879
+ if (!row || !row.consumed_at || row.consumed_by !== step.kind)
2880
+ throw new Error("Finalization certificate consumer binding mismatch");
2881
+ const certificate = parseJson(row.certificate_json);
2882
+ if (certificate.consumedAt !== row.consumed_at || certificate.consumedBy !== row.consumed_by
2883
+ || certificate.certificateId !== row.certificate_id || certificate.runId !== row.run_id || certificate.ticketId !== row.ticket_id
2884
+ || certificate.qaRevision !== row.qa_revision || certificate.sourceStateDigest !== row.source_state_digest
2885
+ || certificate.reviewBasisDigest !== row.review_basis_digest || certificate.turnReceiptDigest !== row.turn_receipt_digest
2886
+ || certificate.issuedAt !== row.issued_at)
2887
+ throw new Error("Finalization certificate SQL identity mismatch");
2888
+ this.assertNativeQaCertificate(certificate, "resume", step.certificateId);
2889
+ this.assertContractCertificate(certificate);
2890
+ }
2891
+ }
2234
2892
  planQaFinalizationStep(input, now = new Date()) {
2235
2893
  const at = now.toISOString();
2236
2894
  const intentText = json(input.intent);
@@ -2290,6 +2948,7 @@ export class WorkflowDb {
2290
2948
  completeQaFinalization(runId, ticketId, expectedRevision, receipt, now = new Date()) {
2291
2949
  return this.db.transaction(() => {
2292
2950
  this.assertFinalizationControls(runId, ticketId);
2951
+ this.assertQaFinalizationAuthority(runId, ticketId);
2293
2952
  const head = this.qaTicketHead(runId, ticketId);
2294
2953
  if (head.revision !== expectedRevision || head.state !== "finalizing")
2295
2954
  throw new Error(`QA finalization completion raced for ${runId}/${ticketId}`);
@@ -2550,6 +3209,31 @@ export class WorkflowDb {
2550
3209
  : this.db.prepare(`SELECT COUNT(*) AS count FROM compaction_attempts WHERE run_id=? AND role=? AND provider_session_id=? AND session_key IS NULL AND status='succeeded'`).get(runId, role, session.id);
2551
3210
  return row.count;
2552
3211
  }
3212
+ freshRecoveryValidation(runId, role) {
3213
+ const decision = this.recoveryDecisions(runId).at(-1);
3214
+ const predecessor = this.roleMutationLease(runId, role);
3215
+ if (!decision || decision.mode !== "fresh-recovery-only" || decision.role !== role || !predecessor)
3216
+ throw new Error("Fresh recovery validation requires an explicit current recovery decision and predecessor lease");
3217
+ return { decisionDigest: digestJson(decision), predecessor };
3218
+ }
3219
+ /** This grants one response-only initialization, never ownership or business work. */
3220
+ assertFreshRecoveryValidation(dispatchId, session) {
3221
+ const operation = this.operation(dispatchId);
3222
+ const intent = operation?.intent;
3223
+ const authorization = intent?.freshRecoveryValidation;
3224
+ const decision = operation && this.recoveryDecisions(operation.runId).at(-1);
3225
+ if (!operation || !authorization || !decision || intent?.operation !== "session-initialization" || intent.responseOnly !== true ||
3226
+ canonicalJson(intent.sessionRef) !== canonicalJson(session) ||
3227
+ (session.role !== "builder" && session.role !== "qa") || decision.mode !== "fresh-recovery-only" || decision.role !== session.role ||
3228
+ authorization.decisionDigest !== digestJson(decision) || dispatchId !== `fresh-recovery:${authorization.decisionDigest}` ||
3229
+ canonicalJson(this.roleMutationLease(operation.runId, session.role)) !== canonicalJson(authorization.predecessor) ||
3230
+ decision.runId !== operation.runId || canonicalSessionPath(session.cwd) !== canonicalSessionPath(decision.worktree) ||
3231
+ session.provider !== (decision.requestedSuccessor?.agent ?? decision.settings.make) ||
3232
+ session.sessionId === authorization.predecessor.providerSessionId ||
3233
+ decision.predecessorSessionId && decision.predecessorSessionId !== authorization.predecessor.providerSessionId ||
3234
+ decision.predecessorSessionRef && providerSessionKey(decision.predecessorSessionRef) !== authorization.predecessor.sessionKey)
3235
+ throw new Error("Submission session no longer owns its role: fresh recovery decision or predecessor changed");
3236
+ }
2553
3237
  recordRecoveryDecision(receipt) {
2554
3238
  this.ensureRun(receipt.runId);
2555
3239
  const digest = digestJson(receipt);
@@ -2728,12 +3412,12 @@ export class WorkflowDb {
2728
3412
  parsed = { unreadable: true };
2729
3413
  }
2730
3414
  const record = parsed && typeof parsed === "object" ? parsed : undefined;
3415
+ if (record?.communicationProtocolVersion === 2)
3416
+ continue;
2731
3417
  const isBuild = (record?.version === 1 || record?.version === 2) && typeof record.runId === "string";
2732
3418
  const runId = isBuild ? String(record.runId) : `legacy_${digest.slice(0, 24)}`;
2733
3419
  if (!this.getRun(runId)) {
2734
3420
  this.createRun({ runId, kind: isBuild ? "build" : "legacy", checkpoint: isBuild && typeof record?.checkpoint === "string" ? record.checkpoint : "legacy-imported", originalWork: isBuild ? { tickets: record?.tickets ?? [] } : { source: path }, remainingWork: isBuild ? { tickets: record?.tickets ?? [] } : {}, state: isBuild ? record : { source: path, record: parsed }, legacy: true }, now);
2735
- if (!isBuild)
2736
- this.transition(runId, { status: "superseded", checkpoint: "legacy-imported", remainingWork: {}, event: "legacy_record_preserved" }, now);
2737
3421
  }
2738
3422
  this.db.prepare("INSERT INTO legacy_imports(source_path,digest,run_id,imported_at) VALUES(?,?,?,?)").run(path, digest, runId, now.toISOString());
2739
3423
  count += 1;
@@ -2861,6 +3545,119 @@ export class WorkflowDb {
2861
3545
  this.db.prepare("INSERT INTO recovery_schema_migrations(migration,completed_at) VALUES(?,?)").run(`${migration}:${migrationDigest}`, new Date().toISOString());
2862
3546
  migrateQaHandback(this.db);
2863
3547
  }
3548
+ /**
3549
+ * One-way cutover fence. We deliberately do not attempt to convert an old
3550
+ * receipt into authority: any pre-existing run with legacy state or a
3551
+ * provider dispatch that lacks a sealed tuple is terminally superseded.
3552
+ * User/project artifacts remain in their own tables; only workflow authority
3553
+ * is fenced. The marker is committed atomically with the classification.
3554
+ */
3555
+ applyStructuredCommunicationV2Cutover() {
3556
+ const migration = STRUCTURED_AUDIT_CLOSURE;
3557
+ this.db.transaction(() => {
3558
+ this.db.exec(`CREATE TABLE IF NOT EXISTS structured_communication_cutover(migration TEXT PRIMARY KEY,completed_at TEXT NOT NULL,details_json TEXT NOT NULL);
3559
+ CREATE TABLE IF NOT EXISTS structured_cutover_quarantine(run_id TEXT NOT NULL,table_name TEXT NOT NULL,row_json TEXT NOT NULL);
3560
+ CREATE TABLE IF NOT EXISTS structured_handoff_lineage(predecessor_id TEXT PRIMARY KEY REFERENCES operation_journal(idempotency_key),run_id TEXT NOT NULL REFERENCES workflow_runs(run_id),state TEXT NOT NULL,acceptance_id TEXT UNIQUE,successor_json TEXT,continuation_id TEXT UNIQUE);
3561
+ CREATE TABLE IF NOT EXISTS continuity_checkpoint_origins(checkpoint_id INTEGER PRIMARY KEY REFERENCES continuity_checkpoints(checkpoint_id),run_id TEXT NOT NULL REFERENCES workflow_runs(run_id),origin_json TEXT NOT NULL,delta_digest TEXT NOT NULL,dispatch_id TEXT,publication_kind TEXT,UNIQUE(run_id,dispatch_id,publication_kind));
3562
+ CREATE TABLE IF NOT EXISTS structured_dispatch_processing(dispatch_id TEXT PRIMARY KEY REFERENCES operation_journal(idempotency_key),run_id TEXT NOT NULL REFERENCES workflow_runs(run_id),state TEXT NOT NULL CHECK(state IN ('pending','completed')),context_json TEXT NOT NULL,error TEXT);
3563
+ CREATE TABLE IF NOT EXISTS structured_submission_claims(dispatch_id TEXT PRIMARY KEY REFERENCES operation_journal(idempotency_key),run_id TEXT NOT NULL REFERENCES workflow_runs(run_id),tuple_digest TEXT NOT NULL,claim_id TEXT NOT NULL UNIQUE,session_json TEXT NOT NULL,state TEXT NOT NULL CHECK(state IN ('claimed','consumed')),created_at TEXT NOT NULL,consumed_at TEXT);
3564
+ CREATE TABLE IF NOT EXISTS communication_evidence_refs(owner_kind TEXT NOT NULL,owner_id TEXT NOT NULL,evidence_digest TEXT NOT NULL,created_at TEXT NOT NULL,PRIMARY KEY(owner_kind,owner_id,evidence_digest));
3565
+ CREATE TABLE IF NOT EXISTS page_ledgers(artifact_id TEXT PRIMARY KEY,run_id TEXT NOT NULL REFERENCES workflow_runs(run_id),lineage_id TEXT NOT NULL,tuple_digest TEXT NOT NULL,lease_id TEXT NOT NULL,state TEXT NOT NULL CHECK(state IN ('open','terminal','cancelled')),page_count INTEGER NOT NULL,total_bytes INTEGER NOT NULL,created_at TEXT NOT NULL,updated_at TEXT NOT NULL);
3566
+ CREATE TABLE IF NOT EXISTS page_ledger_pages(artifact_id TEXT NOT NULL REFERENCES page_ledgers(artifact_id),ordinal INTEGER NOT NULL,predecessor_digest TEXT,content_digest TEXT NOT NULL,content BLOB NOT NULL,terminal INTEGER NOT NULL CHECK(terminal IN (0,1)),created_at TEXT NOT NULL,PRIMARY KEY(artifact_id,ordinal));`);
3567
+ const prior = this.db.prepare("SELECT details_json FROM structured_communication_cutover WHERE migration=?").get(migration);
3568
+ if (this.db.prepare("SELECT 1 FROM structured_communication_cutover WHERE migration='003_structured_communication_v2_cutover'").get() && prior && JSON.parse(prior.details_json).ownershipMatrixVersion === STRUCTURED_CUTOVER_MATRIX_VERSION)
3569
+ return;
3570
+ // Upgrade only an unambiguous host baseline. Historical provider deltas
3571
+ // without an exact dispatch origin remain fenced; timestamps are not proof.
3572
+ for (const checkpoint of this.db.prepare(`SELECT c.* FROM continuity_checkpoints c JOIN workflow_runs r USING(run_id)
3573
+ WHERE json_valid(r.state_json) AND c.session_ref_json IS NULL
3574
+ AND NOT EXISTS(SELECT 1 FROM continuity_checkpoint_origins o WHERE o.checkpoint_id=c.checkpoint_id)`).all()) {
3575
+ let delta;
3576
+ try {
3577
+ delta = parseJson(checkpoint.delta_json);
3578
+ }
3579
+ catch {
3580
+ continue;
3581
+ }
3582
+ if (!delta || delta.version !== 1 || delta.nextAction !== "Await the first role action." ||
3583
+ [delta.decisions, delta.constraints, delta.discoveries, delta.completedActions, delta.evidence, delta.failures, delta.blockers, delta.openWork].some(items => !Array.isArray(items) || items.length))
3584
+ continue;
3585
+ const baseline = this.db.prepare("SELECT sequence,payload_json FROM continuity_events WHERE run_id=? AND role='host' AND kind='role_baseline' AND sequence<=? ORDER BY sequence DESC LIMIT 1")
3586
+ .get(checkpoint.run_id, checkpoint.event_sequence);
3587
+ if (!baseline || JSON.parse(baseline.payload_json).role !== checkpoint.role)
3588
+ continue;
3589
+ const deltaDigest = createHash("sha256").update(checkpoint.delta_json).digest("hex");
3590
+ const event = this.appendContinuityEvent({ runId: checkpoint.run_id, role: "host", kind: "host_checkpoint", authoritativeStateRevision: checkpoint.authoritative_state_revision,
3591
+ payload: { checkpointDeltaDigest: deltaDigest, sourceBaselineSequence: baseline.sequence, role: checkpoint.role } });
3592
+ this.db.prepare("INSERT INTO continuity_checkpoint_origins(checkpoint_id,run_id,origin_json,delta_digest) VALUES(?,?,?,?)")
3593
+ .run(checkpoint.checkpoint_id, checkpoint.run_id, json({ kind: "host", eventSequence: event.sequence, inputCheckpointDigests: [] }), deltaDigest);
3594
+ }
3595
+ const matrix = cutoverOwnershipMatrix(this.db);
3596
+ const fileReferences = communicationFileEvidenceReferences(this.projectDir);
3597
+ const affected = this.db.prepare("SELECT * FROM workflow_runs").all().filter(run => invalidStructuredRun(this.db, run, this.projectDir));
3598
+ const lease = this.db.prepare("SELECT run_id,pid,process_start,host FROM project_lease WHERE singleton=1").get();
3599
+ if (lease && affected.some(run => run.run_id === lease.run_id) && classifyProcess(lease.pid, lease.process_start, lease.host).state !== "dead")
3600
+ throw new Error("v2-cutover-pending: stop the existing workflow owner before structured communication cutover");
3601
+ if (this.db.prepare("SELECT 1 FROM sqlite_master WHERE type='table' AND name='build_owned_processes'").get()) {
3602
+ const processes = this.db.prepare("SELECT run_id,pid,process_start,host FROM build_owned_processes WHERE state<>'quiescent'").all();
3603
+ if (processes.some(process => affected.some(run => run.run_id === process.run_id)
3604
+ && (!process.pid || !process.process_start || classifyProcess(process.pid, process.process_start, process.host).state !== "dead")))
3605
+ throw new Error("v2-cutover-pending: reconcile owned build processes before structured communication cutover");
3606
+ }
3607
+ if (this.db.prepare("SELECT 1 FROM sqlite_master WHERE type='table' AND name='build_admission'").get()) {
3608
+ const row = this.db.prepare("SELECT record_json FROM build_admission WHERE singleton=1").get();
3609
+ if (row) {
3610
+ let admission;
3611
+ try {
3612
+ admission = JSON.parse(row.record_json);
3613
+ }
3614
+ catch {
3615
+ throw new Error("v2-cutover-pending: malformed build admission requires reconciliation");
3616
+ }
3617
+ if (affected.some(run => run.run_id === admission.runId) && classifyProcess(admission.pid, admission.processStart, admission.host).state !== "dead")
3618
+ throw new Error("v2-cutover-pending: stop the existing build owner before structured communication cutover");
3619
+ }
3620
+ }
3621
+ // Existing domain guards must not block the authorized cutover. Restore
3622
+ // them in this same transaction, omitting only superseded v1 comms guards.
3623
+ const triggers = this.db.prepare("SELECT name,sql FROM sqlite_master WHERE type='trigger' AND sql IS NOT NULL").all();
3624
+ for (const trigger of triggers) {
3625
+ if (!/^[a-zA-Z_][a-zA-Z_0-9]*$/.test(trigger.name))
3626
+ throw new Error("Unexpected cutover trigger identity");
3627
+ this.db.exec(`DROP TRIGGER "${trigger.name}"`);
3628
+ }
3629
+ const now = new Date().toISOString();
3630
+ for (const { run_id } of affected) {
3631
+ const row = this.db.prepare("SELECT state_json FROM workflow_runs WHERE run_id=?").get(run_id);
3632
+ const originalBytesDigest = createHash("sha256").update(row.state_json).digest("hex");
3633
+ this.db.prepare("INSERT INTO structured_cutover_quarantine(run_id,table_name,row_json) VALUES(?,?,?)").run(run_id, "workflow_runs.state_json", json({ originalBytes: row.state_json, originalBytesDigest }));
3634
+ let original = {};
3635
+ try {
3636
+ const parsed = parseJson(row.state_json);
3637
+ if (parsed && typeof parsed === "object" && !Array.isArray(parsed))
3638
+ original = parsed;
3639
+ }
3640
+ catch { /* quarantined verbatim above */ }
3641
+ const state = { ...original, structuredCommunicationCutover: { version: 2, reason: "structured-communication-v2-cutover", originalBytesDigest, at: now } };
3642
+ purgeStructuredRun(this.db, run_id, matrix, fileReferences);
3643
+ this.db.prepare("UPDATE workflow_runs SET status='superseded',checkpoint='structured-communication-v2-cutover',state_json=?,updated_at=? WHERE run_id=?")
3644
+ .run(json(state), now, run_id);
3645
+ }
3646
+ quarantineCommunicationFiles(this.projectDir, this.db);
3647
+ for (const trigger of triggers)
3648
+ if (!trigger.name.startsWith("structured_comms_v1_"))
3649
+ this.db.exec(trigger.sql);
3650
+ this.db.prepare("INSERT INTO structured_communication_cutover(migration,completed_at,details_json) VALUES(?,?,?) ON CONFLICT(migration) DO UPDATE SET completed_at=excluded.completed_at,details_json=excluded.details_json")
3651
+ .run(migration, now, json({ ownershipMatrixVersion: STRUCTURED_CUTOVER_MATRIX_VERSION, ownershipMatrix: matrix, fencedRuns: affected.map(row => row.run_id), policy: "legacy-or-unsealed-provider-authority-is-non-resumable" }));
3652
+ this.db.prepare("INSERT OR IGNORE INTO structured_communication_cutover(migration,completed_at,details_json) VALUES(?,?,?)")
3653
+ .run("003_structured_communication_v2_cutover", now, json({ ownershipMatrixVersion: STRUCTURED_CUTOVER_MATRIX_VERSION }));
3654
+ ensureQaGraphJournalGuards(this.db);
3655
+ this.db.exec(`CREATE TRIGGER IF NOT EXISTS structured_cutover_terminal_run BEFORE UPDATE ON workflow_runs
3656
+ WHEN OLD.checkpoint='structured-communication-v2-cutover'
3657
+ BEGIN SELECT CASE WHEN NEW.status<>'superseded' OR NEW.checkpoint<>OLD.checkpoint OR NEW.state_json<>OLD.state_json
3658
+ THEN RAISE(ABORT,'structured-communication-v2-cutover: workflow permanently non-resumable') END; END;`);
3659
+ })();
3660
+ }
2864
3661
  ensureColumn(table, column, definition) {
2865
3662
  const columns = this.db.prepare(`PRAGMA table_info(${table})`).all();
2866
3663
  if (!columns.some((existing) => existing.name === column)) {
@@ -2912,11 +3709,5 @@ function leaseVerifiedLive(lease, now, staleMs) {
2912
3709
  return classifyProcess(lease.pid, lease.processStart, lease.host).state !== "dead";
2913
3710
  }
2914
3711
  function ensureRecoveryGitignore(projectDir) {
2915
- const localExclude = join(projectDir, ".git", "info", "exclude");
2916
- const path = existsSync(localExclude) ? localExclude : join(projectDir, ".gitignore");
2917
- const entries = [WORKFLOW_DB_FILE, `${WORKFLOW_DB_FILE}-wal`, `${WORKFLOW_DB_FILE}-shm`, ".rafi/cache/handoffs/", ".rafi/backups/work-admission-v4/"];
2918
- const existing = existsSync(path) ? readFileSync(path, "utf8") : "";
2919
- const missing = entries.filter((entry) => !existing.split(/\r?\n/).includes(entry));
2920
- if (missing.length)
2921
- appendFileSync(path, `${existing && !existing.endsWith("\n") ? "\n" : ""}${missing.join("\n")}\n`, "utf8");
3712
+ ensureLocalGitExcludes(projectDir, [WORKFLOW_DB_FILE, `${WORKFLOW_DB_FILE}-wal`, `${WORKFLOW_DB_FILE}-shm`, ".rafi/cache/handoffs/", ".rafi/backups/work-admission-v4/"], { fallbackToGitignore: true });
2922
3713
  }