ai-foreman 1.7.28 → 2.0.0

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 (144) hide show
  1. package/README.md +1 -1
  2. package/dist/adapters/claude.d.ts +23 -12
  3. package/dist/adapters/claude.js +228 -24
  4. package/dist/adapters/codex.d.ts +17 -7
  5. package/dist/adapters/codex.js +195 -17
  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 +86 -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/planner.js +1 -4
  18. package/dist/branch/runner.d.ts +2 -1
  19. package/dist/branch/runner.js +10 -7
  20. package/dist/buildAdmission.js +4 -0
  21. package/dist/buildApproval.d.ts +30 -0
  22. package/dist/buildApproval.js +68 -0
  23. package/dist/buildAssignment.d.ts +1 -0
  24. package/dist/buildAssignment.js +82 -23
  25. package/dist/buildInterventions.d.ts +2 -2
  26. package/dist/buildInterventions.js +11 -4
  27. package/dist/buildLaunchGate.js +3 -2
  28. package/dist/builderGuidanceFollowup.js +17 -6
  29. package/dist/cli/manager.js +22 -20
  30. package/dist/cli/start.js +32 -24
  31. package/dist/cli/tickets.js +40 -24
  32. package/dist/continuity.d.ts +10 -14
  33. package/dist/continuity.js +198 -179
  34. package/dist/continuityOrigin.d.ts +13 -0
  35. package/dist/continuityOrigin.js +91 -0
  36. package/dist/foreman.d.ts +20 -5
  37. package/dist/foreman.js +57 -187
  38. package/dist/graph/boundary.d.ts +1 -1
  39. package/dist/graph/boundary.js +1 -1
  40. package/dist/graph/host.js +8 -19
  41. package/dist/graph/manager.d.ts +1 -1
  42. package/dist/graph/manager.js +1 -1
  43. package/dist/graph/preparation.d.ts +38 -0
  44. package/dist/graph/preparation.js +7 -6
  45. package/dist/graph/semantic.d.ts +1 -0
  46. package/dist/graph/semantic.js +4 -1
  47. package/dist/graph/session.js +7 -2
  48. package/dist/graph/turn.d.ts +2 -3
  49. package/dist/graph/turn.js +28 -40
  50. package/dist/handoffProtocol.js +2 -2
  51. package/dist/handoffs.d.ts +3 -1
  52. package/dist/handoffs.js +90 -59
  53. package/dist/hostGraphApplicability.d.ts +46 -0
  54. package/dist/hostGraphApplicability.js +97 -0
  55. package/dist/hostQaAugmentation.d.ts +18 -0
  56. package/dist/hostQaAugmentation.js +81 -0
  57. package/dist/hostQaComparison.d.ts +21 -2
  58. package/dist/hostQaComparison.js +43 -28
  59. package/dist/hostSourcePreservation.d.ts +38 -0
  60. package/dist/hostSourcePreservation.js +72 -0
  61. package/dist/interviews.d.ts +7 -0
  62. package/dist/interviews.js +18 -1
  63. package/dist/legacyCommunicationDisplay.d.ts +2 -0
  64. package/dist/legacyCommunicationDisplay.js +2 -0
  65. package/dist/managerPacket.js +3 -3
  66. package/dist/markers.d.ts +4 -3
  67. package/dist/markers.js +4 -20
  68. package/dist/probeIpc.d.ts +14 -0
  69. package/dist/probeIpc.js +29 -0
  70. package/dist/qaBuildGate.d.ts +21 -0
  71. package/dist/qaBuildGate.js +48 -12
  72. package/dist/qaBuilderCoverage.js +22 -13
  73. package/dist/qaBuilderEvidence.d.ts +16 -1
  74. package/dist/qaBuilderEvidence.js +48 -8
  75. package/dist/qaContractCoverage.d.ts +2 -2
  76. package/dist/qaContractCoverage.js +11 -7
  77. package/dist/qaContractDelivery.js +34 -20
  78. package/dist/qaCoverageAssembly.d.ts +22 -0
  79. package/dist/qaCoverageAssembly.js +16 -0
  80. package/dist/qaDeliveryJournal.d.ts +2 -0
  81. package/dist/qaDeliveryJournal.js +19 -0
  82. package/dist/qaEffectiveConfig.d.ts +1 -0
  83. package/dist/qaEffectiveConfig.js +6 -1
  84. package/dist/qaFailureDelivery.js +97 -85
  85. package/dist/qaHostTransformation.d.ts +26 -0
  86. package/dist/qaHostTransformation.js +100 -0
  87. package/dist/qaPreparation.d.ts +8 -20
  88. package/dist/qaPreparation.js +133 -229
  89. package/dist/qaPreparationInputs.js +19 -0
  90. package/dist/qaPreparationStore.js +34 -6
  91. package/dist/qaPrerequisites.d.ts +2 -1
  92. package/dist/qaPrerequisites.js +21 -1
  93. package/dist/qaProtocolV2.d.ts +15 -0
  94. package/dist/qaRecovery.js +21 -4
  95. package/dist/qaReview.js +232 -394
  96. package/dist/qaReviewEvidence.d.ts +7 -3
  97. package/dist/qaReviewEvidence.js +15 -4
  98. package/dist/qaRuntime.js +1 -1
  99. package/dist/qaSnapshot.js +71 -21
  100. package/dist/qaStructuredResponse.d.ts +10 -0
  101. package/dist/qaStructuredResponse.js +57 -0
  102. package/dist/qaTimeline.js +5 -3
  103. package/dist/qaToolRecovery.d.ts +12 -0
  104. package/dist/qaToolRecovery.js +44 -0
  105. package/dist/qaVerificationContract.d.ts +3 -2
  106. package/dist/qaVerificationContract.js +58 -34
  107. package/dist/readinessGate.js +5 -4
  108. package/dist/runtimeReadiness.js +23 -15
  109. package/dist/statusReporter.d.ts +2 -0
  110. package/dist/statusReporter.js +2 -0
  111. package/dist/structuredCutover.d.ts +31 -0
  112. package/dist/structuredCutover.js +510 -0
  113. package/dist/structuredCutoverFiles.d.ts +10 -0
  114. package/dist/structuredCutoverFiles.js +181 -0
  115. package/dist/structuredDispatchAuthority.d.ts +22 -0
  116. package/dist/structuredDispatchAuthority.js +110 -0
  117. package/dist/structuredDispatchRecord.d.ts +29 -0
  118. package/dist/structuredDispatchRecord.js +36 -0
  119. package/dist/structuredLiveBudget.d.ts +2 -0
  120. package/dist/structuredLiveBudget.js +23 -0
  121. package/dist/structuredOperation.d.ts +18 -0
  122. package/dist/structuredOperation.js +257 -0
  123. package/dist/supervisedStart.d.ts +1 -1
  124. package/dist/supervisedStart.js +78 -31
  125. package/dist/supervisor.d.ts +18 -2
  126. package/dist/supervisor.js +74 -17
  127. package/dist/ticketPlanning.d.ts +1 -373
  128. package/dist/ticketPlanning.js +2 -26
  129. package/dist/ticketPopulation.d.ts +0 -3
  130. package/dist/ticketPopulation.js +4 -21
  131. package/dist/ticketPopulationPageAuthority.d.ts +13 -0
  132. package/dist/ticketPopulationPageAuthority.js +30 -0
  133. package/dist/ticketPopulationPaging.d.ts +27 -0
  134. package/dist/ticketPopulationPaging.js +105 -0
  135. package/dist/tickets/renderMarkdown.d.ts +14 -0
  136. package/dist/tickets/renderMarkdown.js +39 -5
  137. package/dist/tickets/ticketLoader.js +10 -0
  138. package/dist/tickets/ticketSchema.d.ts +107 -0
  139. package/dist/tickets/ticketSchema.js +4 -1
  140. package/dist/workflowDb.d.ts +100 -2
  141. package/dist/workflowDb.js +781 -35
  142. package/dist/workflowReader.d.ts +1 -0
  143. package/dist/workflowReader.js +9 -0
  144. package/package.json +15 -7
@@ -1,9 +1,18 @@
1
+ import { persistedProviderWireContract } from "rafi-spec";
2
+ import { verifyCheckpointOrigin, verifyContinuityHeads } from "./continuityOrigin.js";
1
3
  import { registerQaGraphJournalWriter, ensureQaGraphJournalGuards } from "./qaDeliveryJournal.js";
2
4
  import { canonicalJson } from "./qaProtocolV2.js";
5
+ import { hydrateStructuredReceipt, persistedDispatchTuple, sealedDispatchRecord, verifyPersistedDispatch } from "./structuredDispatchRecord.js";
6
+ import { assertStructuredCutoverComplete, cutoverOwnershipMatrix, invalidStructuredRun, purgeStructuredRun, STRUCTURED_CUTOVER_MATRIX_VERSION, STRUCTURED_CUTOVER_REASON, STRUCTURED_AUDIT_CLOSURE } from "./structuredCutover.js";
7
+ import { communicationFileEvidenceReferences, quarantineCommunicationFiles } from "./structuredCutoverFiles.js";
3
8
  import { resolveEffectiveQaConfiguration } from "./qaEffectiveConfig.js";
4
9
  import { registerGraphDerived, graphDerivedAllowed, currentGraphDerivedAccess } from "./graph/derived.js";
5
- import { bindCoverageHostFields, parseContractCoverage, validateContractCoverage } from "./qaContractCoverage.js";
6
- import { applyHostQaComparison, findHostQaCheck, hostComparisonDigestFromCoverage, hostQaComparisonV1Schema } from "./hostQaComparison.js";
10
+ import { bindCoverageHostFields, validateContractCoverage } from "./qaContractCoverage.js";
11
+ import { findHostQaCheck, hostComparisonDigestFromCoverage, hostQaComparisonV1Schema } from "./hostQaComparison.js";
12
+ import { findGraphQaChecks, hostGraphApplicabilityDigestFromCoverage, hostGraphApplicabilityV1Schema } from "./hostGraphApplicability.js";
13
+ import { assembleFinalQaCoverage } from "./qaCoverageAssembly.js";
14
+ import { retainedQaCommandObservations } from "./qaReviewEvidence.js";
15
+ import { retainedHostBuilderObservations } from "./qaBuilderEvidence.js";
7
16
  import { QaPreparationStore, migrateQaPreparation, migrateQaPreparationGuards } from "./qaPreparationStore.js";
8
17
  import { GraphStore, migrateGraphStore } from "./graph/storage.js";
9
18
  import { reconcileWork } from "./buildWorkReconciliation.js";
@@ -18,9 +27,9 @@ import { hostname } from "node:os";
18
27
  import { dirname, join, resolve } from "node:path";
19
28
  import { execFileSync } from "node:child_process";
20
29
  import Database from "better-sqlite3";
21
- import { validateQaFailureReport } from "rafi-spec";
30
+ import { validateQaFailureReport, qaVerdictOutput, remediationOutput } from "rafi-spec";
22
31
  import { migrateQaHandback, registerHandbackWriter, reportOccurrenceId } from "./qaHandbackMigration.js";
23
- import { providerSessionKey } from "./sessionIdentity.js";
32
+ import { providerSessionKey, canonicalSessionPath } from "./sessionIdentity.js";
24
33
  import { captureFrozenQaSource } from "./qaSnapshot.js";
25
34
  import { loadTicketsConfig, resolveTicketPaths } from "./tickets/config.js";
26
35
  import { loadTickets } from "./tickets/ticketLoader.js";
@@ -54,6 +63,7 @@ export function heartbeatCurrentWorkflowLease(projectDir, lease, now = new Date(
54
63
  throw new Error("workflow recovery database not found");
55
64
  const db = new Database(path, { fileMustExist: true });
56
65
  db.function("rafi_protocol_v3", () => 1);
66
+ db.pragma("busy_timeout = 5000");
57
67
  registerHandbackWriter(db);
58
68
  try {
59
69
  const at = now.toISOString();
@@ -121,6 +131,8 @@ export class WorkflowDb {
121
131
  if (existsSync(path)) {
122
132
  const preview = new Database(path, { readonly: true, fileMustExist: true });
123
133
  try {
134
+ if (readinessAccess)
135
+ assertStructuredCutoverComplete(preview);
124
136
  checkBuildOwnershipSchema(preview);
125
137
  }
126
138
  finally {
@@ -144,13 +156,20 @@ export class WorkflowDb {
144
156
  registerQaGraphJournalWriter(this.db);
145
157
  if (!readinessAccess)
146
158
  this.db.pragma("journal_mode = WAL");
159
+ // Short-lived coordinator/worker writes can overlap with a heartbeat or
160
+ // another durable transition. Let SQLite wait for the owning transaction
161
+ // instead of immediately surfacing SQLITE_BUSY to the supervisor.
162
+ this.db.pragma("busy_timeout = 5000");
147
163
  this.db.pragma("synchronous = FULL");
148
164
  this.db.pragma("foreign_keys = ON");
149
165
  try {
150
- if (readinessAccess)
166
+ if (readinessAccess) {
167
+ assertStructuredCutoverComplete(this.db);
151
168
  this.restrictReadinessConnection(readinessAccess);
169
+ }
152
170
  else {
153
171
  this.migrate();
172
+ this.applyStructuredCommunicationV2Cutover();
154
173
  migrateQaPreparation(this.db);
155
174
  migrateBuildAdmission(this.db);
156
175
  this.importLegacyOnce();
@@ -194,6 +213,7 @@ export class WorkflowDb {
194
213
  }
195
214
  }
196
215
  assertInstructionOwner(runId) {
216
+ this.assertNativeRunAdmission(runId);
197
217
  if (this.writerAuthority) {
198
218
  assertAdmission(this.db, this.writerAuthority);
199
219
  if (this.writerAuthority.runId !== runId && !this.db.prepare("SELECT 1 FROM build_child_runs WHERE child=? AND parent=?").get(runId, this.writerAuthority.runId))
@@ -327,7 +347,7 @@ export class WorkflowDb {
327
347
  }
328
348
  }
329
349
  admittedWork(runId, workId) { return admittedWork(this.db, runId, workId); }
330
- assertAdmittedWork(runId, workId) { return assertAdmittedWork(this.db, runId, workId); }
350
+ assertAdmittedWork(runId, workId) { this.assertNativeRunAdmission(runId); return assertAdmittedWork(this.db, runId, workId); }
331
351
  /** Atomic local changes only; never await provider or filesystem work here. */
332
352
  atomic(work) { return this.db.transaction(work).immediate(); }
333
353
  buildAdmission() { return readAdmission(this.db); }
@@ -410,6 +430,7 @@ export class WorkflowDb {
410
430
  throw new Error("Ownership migration is pending legacy execution cleanup; use rafi resume before starting work");
411
431
  }
412
432
  acquireBuildAdmission(runId, phase) {
433
+ this.assertNativeRunAdmission(runId);
413
434
  return this.atomic(() => {
414
435
  this.assertNoLegacyBuildOwner();
415
436
  const lease = this.currentLease();
@@ -440,6 +461,7 @@ export class WorkflowDb {
440
461
  });
441
462
  }
442
463
  acquireBuildRecoveryAdmission(runId) {
464
+ this.assertNativeRunAdmission(runId);
443
465
  return this.atomic(() => {
444
466
  this.assertExecutionProtocol();
445
467
  this.assertNoLegacyBuildOwner();
@@ -831,6 +853,11 @@ export class WorkflowDb {
831
853
  originalWork: input.originalWork ?? {}, remainingWork: input.remainingWork ?? input.originalWork ?? {}, state: input.state ?? {},
832
854
  legacy: Boolean(input.legacy), createdAt: at, updatedAt: at,
833
855
  };
856
+ if (run.legacy) {
857
+ run.status = "superseded";
858
+ run.checkpoint = STRUCTURED_CUTOVER_REASON;
859
+ run.state = { ...run.state, structuredCommunicationCutover: { version: 2, reason: STRUCTURED_CUTOVER_REASON, at } };
860
+ }
834
861
  this.db.transaction(() => {
835
862
  if (this.writerAuthority)
836
863
  assertAdmission(this.db, this.writerAuthority);
@@ -902,6 +929,7 @@ export class WorkflowDb {
902
929
  });
903
930
  }
904
931
  transition(runId, update, now = new Date()) {
932
+ this.assertNativeRunAdmission(runId);
905
933
  const at = now.toISOString();
906
934
  return this.db.transaction(() => {
907
935
  if (!this.coordinatorTransition && (this.writerAuthority || this.buildAdmission())) {
@@ -1029,11 +1057,33 @@ export class WorkflowDb {
1029
1057
  this.assertGraphContinuityAccess(row.payload_json);
1030
1058
  return rows.map(continuityEventFromRow);
1031
1059
  }
1060
+ providerCheckpoint(dispatchId, publicationKind) {
1061
+ 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);
1062
+ if (row)
1063
+ verifyCheckpointOrigin(this.db, this.projectDir, row.checkpoint_id);
1064
+ return row ? continuityCheckpointFromRow(row) : undefined;
1065
+ }
1032
1066
  publishContinuityCheckpoint(input, now = new Date()) {
1033
1067
  this.ensureRun(input.runId, "build", now);
1034
1068
  return this.db.transaction(() => {
1035
1069
  const at = now.toISOString();
1070
+ if (input.origin?.kind === "provider") {
1071
+ 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=?")
1072
+ .get(input.runId, input.role, input.origin.dispatchId, input.origin.publicationKind);
1073
+ if (prior) {
1074
+ verifyCheckpointOrigin(this.db, this.projectDir, prior.checkpoint_id);
1075
+ if (canonicalJson(parseJson(prior.delta_json)) !== canonicalJson(input.delta))
1076
+ throw new Error("Conflicting repeated checkpoint publication");
1077
+ return continuityCheckpointFromRow(prior);
1078
+ }
1079
+ }
1036
1080
  const previous = this.continuityHead(input.runId, input.role);
1081
+ let origin = input.origin;
1082
+ if (!origin) {
1083
+ const event = this.appendContinuityEvent({ runId: input.runId, role: "host", kind: input.hostEventKind ?? "host_checkpoint", authoritativeStateRevision: input.authoritativeStateRevision,
1084
+ payload: { checkpointDeltaDigest: createHash("sha256").update(json(sanitizeContinuityValue(input.delta))).digest("hex"), role: input.role } });
1085
+ origin = { kind: "host", eventSequence: event.sequence, inputCheckpointDigests: input.inputCheckpointDigests ?? (previous ? [previous.digest] : []) };
1086
+ }
1037
1087
  const latestEvent = this.db.prepare("SELECT COALESCE(MAX(sequence),0) AS sequence FROM continuity_events WHERE run_id=?").get(input.runId);
1038
1088
  const safeDelta = sanitizeContinuityValue(input.delta);
1039
1089
  const graphAccess = currentGraphDerivedAccess();
@@ -1043,6 +1093,10 @@ export class WorkflowDb {
1043
1093
  const digest = digestJson({ runId: input.runId, role: input.role, sequence: latestEvent.sequence, predecessorDigest: previous?.digest, delta: safeDelta, authoritativeStateRevision: input.authoritativeStateRevision, sessionKey: session.key });
1044
1094
  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)
1045
1095
  VALUES(?,?,?,?,?,?,?,?,?,?,?)`).run(input.runId, input.role, latestEvent.sequence, input.state ?? "current", json(safeDelta), digest, previous?.digest ?? null, input.authoritativeStateRevision, session.key, session.refJson, at);
1096
+ const checkpointId = Number(result.lastInsertRowid);
1097
+ this.db.prepare("INSERT INTO continuity_checkpoint_origins(checkpoint_id,run_id,origin_json,delta_digest,dispatch_id,publication_kind) VALUES(?,?,?,?,?,?)")
1098
+ .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);
1099
+ verifyCheckpointOrigin(this.db, this.projectDir, checkpointId);
1046
1100
  const checkpoint = {
1047
1101
  checkpointId: Number(result.lastInsertRowid), runId: input.runId, role: input.role,
1048
1102
  sequence: latestEvent.sequence, state: input.state ?? "current", delta: safeDelta, digest,
@@ -1060,14 +1114,18 @@ export class WorkflowDb {
1060
1114
  const rows = role
1061
1115
  ? this.db.prepare("SELECT * FROM continuity_checkpoints WHERE run_id=? AND role=? ORDER BY checkpoint_id").all(runId, role)
1062
1116
  : this.db.prepare("SELECT * FROM continuity_checkpoints WHERE run_id=? ORDER BY checkpoint_id").all(runId);
1063
- for (const row of rows)
1117
+ for (const row of rows) {
1064
1118
  this.assertGraphContinuityAccess(row.delta_json);
1119
+ verifyCheckpointOrigin(this.db, this.projectDir, row.checkpoint_id);
1120
+ }
1065
1121
  return rows.map(continuityCheckpointFromRow);
1066
1122
  }
1067
1123
  latestContinuityCheckpoint(runId, role) {
1068
1124
  const row = this.db.prepare("SELECT * FROM continuity_checkpoints WHERE run_id=? AND role=? ORDER BY checkpoint_id DESC LIMIT 1").get(runId, role);
1069
- if (row)
1125
+ if (row) {
1070
1126
  this.assertGraphContinuityAccess(row.delta_json);
1127
+ verifyCheckpointOrigin(this.db, this.projectDir, row.checkpoint_id);
1128
+ }
1071
1129
  return row ? continuityCheckpointFromRow(row) : undefined;
1072
1130
  }
1073
1131
  assertGraphContinuityAccess(value) {
@@ -1077,6 +1135,8 @@ export class WorkflowDb {
1077
1135
  }
1078
1136
  continuityHead(runId, role) {
1079
1137
  const row = this.db.prepare("SELECT * FROM continuity_heads WHERE run_id=? AND role=?").get(runId, role);
1138
+ if (row)
1139
+ verifyContinuityHeads(this.db, this.projectDir, runId);
1080
1140
  return row ? continuityHeadFromRow(row) : undefined;
1081
1141
  }
1082
1142
  setContinuityHeadState(runId, role, state, now = new Date()) {
@@ -1306,7 +1366,12 @@ export class WorkflowDb {
1306
1366
  return row ? parseJson(row.state_json) : undefined;
1307
1367
  }
1308
1368
  planOperation(input, now = new Date()) {
1369
+ this.assertNativeRunAdmission(input.runId);
1309
1370
  const at = now.toISOString();
1371
+ const run = this.getRun(input.runId);
1372
+ if (input.kind === "provider-dispatch" && run?.checkpoint === "structured-communication-v2-cutover") {
1373
+ throw new Error("This run was fenced by the structured-communication v2 cutover; start a new native dispatch");
1374
+ }
1310
1375
  if (input.kind === "provider-dispatch" && this.unresolvedPreparationProcesses(input.runId).length)
1311
1376
  throw new Error("Readiness cleanup must be reconciled before provider dispatch");
1312
1377
  this.db.prepare(`INSERT INTO operation_journal(idempotency_key,run_id,kind,status,intent_json,created_at,updated_at)
@@ -1317,12 +1382,294 @@ export class WorkflowDb {
1317
1382
  }
1318
1383
  return operation;
1319
1384
  }
1385
+ /** Seal a previously persisted non-authoritative preparation intent. This
1386
+ * is intentionally legal only before provider content dispatch begins. */
1387
+ sealPlannedOperationIntent(idempotencyKey, intent, now = new Date()) {
1388
+ const prior = this.operation(idempotencyKey);
1389
+ if (!prior || prior.status !== "planned" || prior.kind !== "provider-dispatch")
1390
+ throw new Error("Only a planned provider dispatch may be sealed");
1391
+ const preparing = prior.intent;
1392
+ if (preparing.protocolVersion !== 2 || preparing.state !== "preparing")
1393
+ throw new Error("Provider dispatch did not begin as a v2 preparation intent");
1394
+ this.db.prepare("UPDATE operation_journal SET intent_json=?,updated_at=? WHERE idempotency_key=? AND status='planned'")
1395
+ .run(json(intent), now.toISOString(), idempotencyKey);
1396
+ return this.operation(idempotencyKey);
1397
+ }
1398
+ /** Called in the gateway's sealing transaction, before the send capability exists. */
1399
+ sealStructuredOwnerDispatch(key, tuple, renderedInstruction) {
1400
+ const sealedDispatch = sealedDispatchRecord(tuple);
1401
+ const transportSession = this.operation(key)?.intent?.sessionRef;
1402
+ const matchesSession = (session) => Boolean(transportSession
1403
+ && ["provider", "sessionId", "role", "stream", "generation", "cwd", "configRoot"].every(field => session[field] === transportSession[field]));
1404
+ const review = this.db.prepare("SELECT intent_json,status FROM qa_turns WHERE operation_id=?").get(key);
1405
+ if (review) {
1406
+ const intent = parseJson(review.intent_json);
1407
+ if (review.status !== "intended" || intent.sealedDispatch || !matchesSession(intent.providerSession) || intent.providerSession.sessionId !== tuple.sessionId || intent.providerSession.generation !== tuple.generation)
1408
+ throw new Error("QA review owner cannot seal a foreign or already dispatched session");
1409
+ this.db.prepare("UPDATE qa_turns SET intent_json=? WHERE operation_id=?").run(json({ ...intent, sealedDispatch }), key);
1410
+ }
1411
+ const delivery = this.db.prepare("SELECT record_json,status FROM qa_delivery_turns WHERE turn_record_id=?").get(key);
1412
+ if (delivery) {
1413
+ const turn = parseJson(delivery.record_json);
1414
+ if (delivery.status !== "intended" || turn.sealedDispatch || !matchesSession(turn.intendedSession) || turn.intendedSession.sessionId !== tuple.sessionId || turn.intendedSession.generation !== tuple.generation)
1415
+ throw new Error("QA remediation owner cannot seal a foreign or already dispatched session");
1416
+ this.db.prepare("UPDATE qa_delivery_turns SET record_json=? WHERE turn_record_id=?").run(json({ ...turn, sealedDispatch }), key);
1417
+ const attempt = this.db.prepare("SELECT record_json FROM qa_remediation_attempts WHERE attempt_id=?").get(turn.operationId);
1418
+ if (!attempt)
1419
+ throw new Error("Remediation dispatch has no durable owner attempt");
1420
+ const fields = parseJson(attempt.record_json);
1421
+ const dispatches = (fields.sealedDispatches ?? {});
1422
+ if (dispatches[key])
1423
+ throw new Error("Remediation dispatch already sealed");
1424
+ this.db.prepare("UPDATE qa_remediation_attempts SET record_json=? WHERE attempt_id=?").run(json({ ...fields, sealedDispatches: { ...dispatches, [key]: sealedDispatch } }), turn.operationId);
1425
+ }
1426
+ this.putEvidence("handoff", Buffer.from(renderedInstruction));
1427
+ }
1428
+ structuredOwnerDispatch(key) {
1429
+ const transport = this.operation(key)?.intent;
1430
+ const verifyOwner = (record) => {
1431
+ persistedDispatchTuple(record);
1432
+ if (json(transport?.sealedDispatch) !== json(record))
1433
+ throw new Error("Domain owner tuple differs from its transport intent");
1434
+ };
1435
+ const review = this.db.prepare("SELECT intent_json FROM qa_turns WHERE operation_id=?").get(key);
1436
+ if (review) {
1437
+ const record = parseJson(review.intent_json).sealedDispatch;
1438
+ if (!record)
1439
+ throw new Error("QA owner has no sealed dispatch");
1440
+ verifyOwner(record);
1441
+ return record;
1442
+ }
1443
+ const delivery = this.db.prepare("SELECT record_json FROM qa_delivery_turns WHERE turn_record_id=?").get(key);
1444
+ if (delivery) {
1445
+ const turn = parseJson(delivery.record_json);
1446
+ const record = turn.sealedDispatch;
1447
+ if (!record)
1448
+ throw new Error("Remediation owner has no sealed dispatch");
1449
+ verifyOwner(record);
1450
+ const attempt = this.db.prepare("SELECT record_json FROM qa_remediation_attempts WHERE attempt_id=?").get(turn.operationId);
1451
+ const sealed = attempt && parseJson(attempt.record_json).sealedDispatches?.[key];
1452
+ if (json(sealed) !== json(record))
1453
+ throw new Error("Remediation attempt tuple differs from its delivery intent");
1454
+ return record;
1455
+ }
1456
+ const intent = this.operation(key)?.intent;
1457
+ if (!intent?.sealedDispatch)
1458
+ throw new Error("Generic owner has no sealed dispatch");
1459
+ persistedDispatchTuple(intent.sealedDispatch);
1460
+ if (canonicalJson(intent.tuple) !== intent.sealedDispatch.tupleJson)
1461
+ throw new Error("Generic intent tuple differs from its sealed canonical bytes");
1462
+ return intent.sealedDispatch;
1463
+ }
1464
+ assertStructuredDispatchAdmission(runId, dispatchId, parentId, operation) {
1465
+ const processing = this.pendingStructuredPostprocessing(runId);
1466
+ const transfers = this.db.prepare("SELECT * FROM structured_handoff_lineage WHERE run_id=? AND state<>'completed'").all(runId);
1467
+ const linked = transfers.length === 1 && transfers[0].predecessor_id === parentId &&
1468
+ (operation === "handoff-acceptance" && transfers[0].state === "acceptance-pending" && !transfers[0].acceptance_id ||
1469
+ transfers[0].state === "continuation-planned" && transfers[0].continuation_id === dispatchId);
1470
+ if (processing.some(id => id !== parentId) || (processing.length || transfers.length) && !linked)
1471
+ throw new Error("Confirmed dispatch postprocessing or handoff is pending; reconcile before unrelated work");
1472
+ }
1473
+ beginStructuredHandoff(predecessorId) {
1474
+ const operation = this.operation(predecessorId);
1475
+ if (!operation || operation.status !== "confirmed")
1476
+ throw new Error("Handoff predecessor must be confirmed");
1477
+ const changed = this.db.prepare("INSERT OR IGNORE INTO structured_handoff_lineage(predecessor_id,run_id,state) VALUES(?,?,'acceptance-pending')").run(predecessorId, operation.runId);
1478
+ if (changed.changes !== 1)
1479
+ throw new Error("Handoff acceptance already started; reconcile persisted lineage before recovery");
1480
+ }
1481
+ linkStructuredHandoffDispatch(dispatchId, parentId, operation) {
1482
+ if (!parentId)
1483
+ return;
1484
+ const row = this.db.prepare("SELECT * FROM structured_handoff_lineage WHERE predecessor_id=?").get(parentId);
1485
+ if (!row)
1486
+ return;
1487
+ if (operation === "handoff-acceptance") {
1488
+ if (row.state !== "acceptance-pending" || row.acceptance_id)
1489
+ throw new Error("Handoff acceptance already dispatched");
1490
+ this.db.prepare("UPDATE structured_handoff_lineage SET acceptance_id=? WHERE predecessor_id=?").run(dispatchId, parentId);
1491
+ }
1492
+ else if (row.state !== "continuation-planned" || row.continuation_id !== dispatchId)
1493
+ throw new Error("Foreign handoff continuation");
1494
+ }
1495
+ planStructuredContinuation(predecessorId, successor) {
1496
+ return this.atomic(() => {
1497
+ const row = this.db.prepare("SELECT * FROM structured_handoff_lineage WHERE predecessor_id=?").get(predecessorId);
1498
+ if (!row || row.state !== "acceptance-pending" || !row.acceptance_id || this.operation(row.acceptance_id)?.status !== "confirmed")
1499
+ throw new Error("Successor lacks confirmed native handoff acceptance lineage");
1500
+ const origin = this.db.prepare("SELECT checkpoint_id FROM continuity_checkpoint_origins WHERE dispatch_id=? AND publication_kind='handoff_acceptance'").get(row.acceptance_id);
1501
+ if (!origin)
1502
+ throw new Error("Successor acceptance checkpoint is missing");
1503
+ verifyCheckpointOrigin(this.db, this.projectDir, origin.checkpoint_id);
1504
+ this.assertAcceptedStructuredSuccessor(row.run_id, origin.checkpoint_id, successor);
1505
+ const accepted = this.retainedStructuredTurn(row.acceptance_id).selectedDispatch;
1506
+ if (accepted.sessionId !== successor.sessionId || accepted.provider !== successor.provider)
1507
+ throw new Error("Successor differs from the accepted provider session");
1508
+ const continuationId = randomUUID();
1509
+ this.db.prepare("UPDATE structured_handoff_lineage SET state='continuation-planned',successor_json=?,continuation_id=? WHERE predecessor_id=?")
1510
+ .run(json(successor), continuationId, predecessorId);
1511
+ return continuationId;
1512
+ });
1513
+ }
1514
+ structuredContinuation(runId, role) {
1515
+ 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);
1516
+ if (!row)
1517
+ return;
1518
+ const origin = this.db.prepare("SELECT checkpoint_id FROM continuity_checkpoint_origins WHERE dispatch_id=? AND publication_kind='handoff_acceptance'").get(row.acceptance_id);
1519
+ if (!origin)
1520
+ throw new Error("Handoff continuation lacks its accepted checkpoint");
1521
+ verifyCheckpointOrigin(this.db, this.projectDir, origin.checkpoint_id);
1522
+ this.assertAcceptedStructuredSuccessor(runId, origin.checkpoint_id, parseJson(row.successor_json));
1523
+ return { predecessorId: row.predecessor_id, continuationId: row.continuation_id, state: row.state, successor: parseJson(row.successor_json) };
1524
+ }
1525
+ finishStructuredContinuation(dispatchId) {
1526
+ if (this.operation(dispatchId)?.status !== "confirmed")
1527
+ throw new Error("Unconfirmed continuation cannot complete");
1528
+ this.db.prepare("UPDATE structured_handoff_lineage SET state='completed' WHERE continuation_id=?").run(dispatchId);
1529
+ }
1530
+ assertAcceptedStructuredSuccessor(runId, checkpointId, successor) {
1531
+ const handoff = this.db.prepare("SELECT acceptance_receipt_digest FROM handoffs WHERE run_id=? AND successor_session_key=? AND state='accepted'")
1532
+ .get(runId, providerSessionKey(successor));
1533
+ const checkpoint = this.db.prepare("SELECT digest FROM continuity_checkpoints WHERE checkpoint_id=? AND run_id=?").get(checkpointId, runId);
1534
+ const bytes = handoff?.acceptance_receipt_digest && this.getEvidence(handoff.acceptance_receipt_digest);
1535
+ if (!bytes || createHash("sha256").update(bytes).digest("hex") !== handoff.acceptance_receipt_digest)
1536
+ throw new Error("Successor lacks retained accepted ownership evidence");
1537
+ const receipt = parseJson(bytes.toString());
1538
+ if (receipt.runId !== runId || receipt.acceptanceCheckpointDigest !== checkpoint?.digest
1539
+ || providerSessionKey(receipt.successorSessionRef) !== providerSessionKey(successor))
1540
+ throw new Error("Successor ownership differs from accepted checkpoint lineage");
1541
+ }
1542
+ requireStructuredPostprocessing(dispatchId, context) {
1543
+ const operation = this.operation(dispatchId);
1544
+ this.db.prepare("INSERT INTO structured_dispatch_processing(dispatch_id,run_id,state,context_json) VALUES(?,?,'pending',?)")
1545
+ .run(dispatchId, operation.runId, json(context));
1546
+ }
1547
+ structuredProcessingContext(dispatchId) {
1548
+ const row = this.db.prepare("SELECT context_json FROM structured_dispatch_processing WHERE dispatch_id=?").get(dispatchId);
1549
+ if (!row)
1550
+ throw new Error("Missing persisted post-confirmation intent");
1551
+ return parseJson(row.context_json);
1552
+ }
1553
+ failStructuredPostprocessing(dispatchId, error) {
1554
+ this.db.prepare("UPDATE structured_dispatch_processing SET error=? WHERE dispatch_id=?").run(error.slice(0, 4000), dispatchId);
1555
+ }
1556
+ completeStructuredPostprocessing(dispatchId) {
1557
+ if (this.operation(dispatchId)?.status !== "confirmed")
1558
+ throw new Error("Cannot process an unconfirmed dispatch");
1559
+ this.db.prepare("UPDATE structured_dispatch_processing SET state='completed',error=NULL WHERE dispatch_id=?").run(dispatchId);
1560
+ }
1561
+ pendingStructuredPostprocessing(runId) {
1562
+ return this.db.prepare("SELECT dispatch_id FROM structured_dispatch_processing WHERE run_id=? AND state='pending'").all(runId).map(row => row.dispatch_id);
1563
+ }
1564
+ retainedStructuredTurn(dispatchId) {
1565
+ const owner = this.structuredOwnerDispatch(dispatchId), tuple = persistedDispatchTuple(owner);
1566
+ const descriptor = persistedProviderWireContract(tuple);
1567
+ if (!descriptor)
1568
+ throw new Error("Missing historic dispatch descriptor");
1569
+ this.replayStructuredDispatch(dispatchId, descriptor.canonical);
1570
+ const result = this.operation(dispatchId).result;
1571
+ const retained = JSON.parse(this.getEvidence(result.responseDigest).toString());
1572
+ return { text: result.rawDigest ? this.getEvidence(result.rawDigest)?.toString() ?? "" : "", isError: false, numTurns: 1, costUsd: 0,
1573
+ turnId: retained.hostTurnId, selectedDispatch: tuple, structured: hydrateStructuredReceipt(retained, digest => this.getEvidence(digest)) ?? undefined };
1574
+ }
1575
+ /** A claim is never recycled, even if transport fails before writing bytes. */
1576
+ claimStructuredSubmission(tuple, session) {
1577
+ return this.atomic(() => {
1578
+ const operation = this.operation(tuple.dispatchId);
1579
+ if (!operation || operation.status !== "in_progress" || this.structuredOwnerDispatch(tuple.dispatchId).tupleJson !== canonicalJson(tuple))
1580
+ throw new Error("Cannot mint a send capability without a committed sealed owner intent");
1581
+ this.assertNativeRunAdmission(operation.runId);
1582
+ const frozen = operation.intent.sessionRef;
1583
+ if (!session || canonicalJson(frozen) !== canonicalJson(session))
1584
+ throw new Error("Submission claim requires the frozen provider session");
1585
+ const continuation = this.db.prepare("SELECT successor_json FROM structured_handoff_lineage WHERE continuation_id=? AND state='continuation-planned'").get(tuple.dispatchId);
1586
+ if (continuation && canonicalJson(parseJson(continuation.successor_json)) !== canonicalJson(session))
1587
+ throw new Error("Continuation session differs from accepted lineage");
1588
+ const claimId = randomUUID();
1589
+ 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)
1590
+ VALUES(?,?,?,?,?,'claimed',?)`).run(tuple.dispatchId, operation.runId, tuple.digest, claimId, canonicalJson(session), new Date().toISOString());
1591
+ if (changed.changes !== 1)
1592
+ throw new Error("Structured dispatch already has a submission claim; reconcile before a new dispatch");
1593
+ this.db.prepare("UPDATE structured_handoff_lineage SET state='continuation-claimed' WHERE continuation_id=?").run(tuple.dispatchId);
1594
+ return claimId;
1595
+ });
1596
+ }
1597
+ consumeStructuredSubmission(tuple, claimId, session) {
1598
+ this.atomic(() => {
1599
+ const operation = this.operation(tuple.dispatchId);
1600
+ if (!operation || operation.status !== "in_progress" || this.structuredOwnerDispatch(tuple.dispatchId).tupleJson !== canonicalJson(tuple))
1601
+ throw new Error("Submission owner is no longer in progress or sealed");
1602
+ this.assertNativeRunAdmission(operation.runId);
1603
+ const frozen = operation.intent.sessionRef;
1604
+ if (!session || canonicalJson(frozen) !== canonicalJson(session))
1605
+ throw new Error("Submission session changed after sealing");
1606
+ const owner = this.roleMutationLease(operation.runId, session.role);
1607
+ if (!(tuple.operation === "handoff-acceptance" && operation.intent.purpose === "contract-acceptance") && owner && (owner.providerSessionId !== session.sessionId || owner.sessionKey && owner.sessionKey !== providerSessionKey(session))) {
1608
+ this.assertFreshRecoveryValidation(tuple.dispatchId, session);
1609
+ }
1610
+ const changed = this.db.prepare(`UPDATE structured_submission_claims SET state='consumed',consumed_at=?
1611
+ WHERE dispatch_id=? AND run_id=? AND tuple_digest=? AND claim_id=? AND session_json=? AND state='claimed'`)
1612
+ .run(new Date().toISOString(), tuple.dispatchId, operation.runId, tuple.digest, claimId, canonicalJson(session));
1613
+ if (changed.changes !== 1)
1614
+ throw new Error("Structured submission claim already consumed or revoked");
1615
+ });
1616
+ }
1617
+ assertNativeRunAdmission(runId) {
1618
+ assertStructuredCutoverComplete(this.db);
1619
+ const run = this.getRun(runId);
1620
+ if (run?.legacy || run?.checkpoint === STRUCTURED_CUTOVER_REASON || run?.state.structuredCommunicationCutover)
1621
+ throw new Error("structured-communication-v2-cutover: start a new native dispatch; this workflow cannot resume");
1622
+ }
1623
+ recordCommunicationEvidenceRefs(ownerId, digests) {
1624
+ for (const digest of new Set(digests))
1625
+ 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());
1626
+ }
1627
+ replayStructuredDispatch(key, contract) {
1628
+ const operation = this.operation(key);
1629
+ const result = operation?.result;
1630
+ if (operation?.status !== "confirmed" || result?.validation !== "accepted" || !result.responseDigest)
1631
+ throw new Error("Structured dispatch has no confirmed persisted receipt");
1632
+ return this.verifyRetainedStructuredDispatch(key, contract, result.responseDigest);
1633
+ }
1634
+ assertStructuredProviderTurn(key, providerTurnId) {
1635
+ const result = this.operation(key)?.result;
1636
+ const bytes = result?.responseDigest ? this.getEvidence(result.responseDigest) : undefined;
1637
+ if (!bytes)
1638
+ throw new Error("Structured dispatch receipt evidence is missing");
1639
+ const retained = JSON.parse(bytes.toString());
1640
+ if (!providerTurnId || retained?.receipt?.state !== "candidate" || retained.hostTurnId !== providerTurnId)
1641
+ throw new Error("Domain receipt has a foreign native provider turn identity");
1642
+ }
1643
+ verifyRetainedStructuredDispatch(key, contract, responseDigest) {
1644
+ const readVerifiedEvidence = (digest) => {
1645
+ const bytes = this.getEvidence(digest);
1646
+ if (bytes && (!graphDerivedAllowed(this.db, this.projectDir, digest) || createHash("sha256").update(bytes).digest("hex") !== digest))
1647
+ throw new Error("Structured receipt evidence digest mismatch");
1648
+ return bytes;
1649
+ };
1650
+ const bytes = readVerifiedEvidence(responseDigest);
1651
+ if (!bytes)
1652
+ throw new Error("Structured dispatch receipt evidence is missing");
1653
+ const retained = JSON.parse(bytes.toString());
1654
+ const owner = this.structuredOwnerDispatch(key);
1655
+ if (retained.version !== 2 || retained.tupleDigest !== owner.tupleDigest)
1656
+ throw new Error("Stored receipt does not match persisted owner tuple");
1657
+ return verifyPersistedDispatch(contract, owner, hydrateStructuredReceipt(retained, readVerifiedEvidence));
1658
+ }
1320
1659
  updateOperation(idempotencyKey, status, details = {}, now = new Date()) {
1321
1660
  const prior = this.operation(idempotencyKey);
1322
1661
  if (prior?.kind === "provider-dispatch" && status === "in_progress" && this.unresolvedPreparationProcesses(prior.runId).length)
1323
1662
  throw new Error("Readiness cleanup must be reconciled before provider dispatch");
1324
1663
  if (!prior)
1325
1664
  throw new Error(`operation not found: ${idempotencyKey}`);
1665
+ if (prior.kind === "provider-dispatch" && status === "confirmed") {
1666
+ const owner = this.structuredOwnerDispatch(idempotencyKey), tuple = persistedDispatchTuple(owner);
1667
+ const descriptor = persistedProviderWireContract(tuple);
1668
+ const result = details.result;
1669
+ if (!descriptor || result?.validation !== "accepted" || !result.responseDigest)
1670
+ throw new Error("Provider confirmation requires verified persisted receipt evidence");
1671
+ this.verifyRetainedStructuredDispatch(idempotencyKey, descriptor.canonical, result.responseDigest);
1672
+ }
1326
1673
  const allowed = {
1327
1674
  planned: ["planned", "in_progress", "failed"],
1328
1675
  in_progress: ["in_progress", "confirmed", "failed", "uncertain"],
@@ -1382,6 +1729,84 @@ export class WorkflowDb {
1382
1729
  registerGraphDerived(this.db, bytes, graphAccess);
1383
1730
  return digest;
1384
1731
  }
1732
+ /** Create an append-only provider page artifact before its first dispatch. */
1733
+ populationPageRecords(artifactId) {
1734
+ return this.db.prepare("SELECT content FROM page_ledger_pages WHERE artifact_id=? ORDER BY ordinal").all(artifactId)
1735
+ .map(row => row.content.toString("utf8"));
1736
+ }
1737
+ populationPageLedger(artifactId) {
1738
+ const row = this.db.prepare("SELECT tuple_digest,state,page_count FROM page_ledgers WHERE artifact_id=?").get(artifactId);
1739
+ return row && { tupleDigest: row.tuple_digest, state: row.state, pageCount: row.page_count };
1740
+ }
1741
+ beginPageLedger(input, now = new Date()) {
1742
+ 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))
1743
+ throw new Error("Invalid page ledger identity");
1744
+ this.ensureRun(input.runId);
1745
+ 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)
1746
+ 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());
1747
+ const row = this.db.prepare("SELECT run_id,lineage_id,tuple_digest,lease_id FROM page_ledgers WHERE artifact_id=?").get(input.artifactId);
1748
+ if (row.run_id !== input.runId || row.lineage_id !== input.lineageId || row.tuple_digest !== input.tupleDigest || row.lease_id !== input.leaseId)
1749
+ throw new Error("Page ledger identity collision");
1750
+ }
1751
+ /**
1752
+ * Atomically appends a page. Duplicate delivery is accepted only when every
1753
+ * immutable byte and linkage field agrees; a provider cannot fill gaps,
1754
+ * rewrite history, or append after the terminal page.
1755
+ */
1756
+ appendPageLedger(input, now = new Date()) {
1757
+ if (!Number.isInteger(input.ordinal) || input.ordinal < 0 || input.ordinal >= 60)
1758
+ throw new Error("Page ordinal exceeds the 60-page limit");
1759
+ let canonical;
1760
+ let ticketCount;
1761
+ try {
1762
+ const parsed = JSON.parse(input.content);
1763
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed))
1764
+ throw new Error("page must be a JSON object");
1765
+ const tickets = parsed.tickets;
1766
+ if (!Array.isArray(tickets))
1767
+ throw new Error("page must contain tickets");
1768
+ ticketCount = tickets.length;
1769
+ canonical = canonicalJson(parsed);
1770
+ }
1771
+ catch (error) {
1772
+ throw new Error(`Page ledger content must be canonical ticket-page JSON: ${error instanceof Error ? error.message : String(error)}`);
1773
+ }
1774
+ if (canonical !== input.content)
1775
+ throw new Error("Page ledger content must use canonical JSON bytes");
1776
+ if (ticketCount > 150)
1777
+ throw new Error("Page exceeds the 150-ticket limit");
1778
+ const bytes = Buffer.byteLength(input.content);
1779
+ if (bytes > 750 * 1024)
1780
+ throw new Error("Page exceeds the 750 KiB limit");
1781
+ const digest = createHash("sha256").update(input.content).digest("hex");
1782
+ return this.atomic(() => {
1783
+ 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);
1784
+ if (!header || header.lineage_id !== input.lineageId || header.tuple_digest !== input.tupleDigest || header.lease_id !== input.leaseId)
1785
+ throw new Error("Unknown or foreign page ledger");
1786
+ 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);
1787
+ if (existing) {
1788
+ if (existing.terminal)
1789
+ throw new Error("Duplicate terminal page");
1790
+ if (existing.predecessor_digest === input.predecessorDigest && existing.content_digest === digest && existing.content.equals(Buffer.from(input.content)) && Boolean(existing.terminal) === input.terminal)
1791
+ return { digest, duplicate: true };
1792
+ throw new Error("Conflicting duplicate page");
1793
+ }
1794
+ if (header.state !== "open")
1795
+ throw new Error("Cannot append after terminal page");
1796
+ if (input.ordinal !== header.page_count)
1797
+ throw new Error("Page ordinal is out of order or has a gap");
1798
+ 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);
1799
+ if ((prior?.content_digest ?? null) !== input.predecessorDigest)
1800
+ throw new Error("Page predecessor digest does not match");
1801
+ if (header.total_bytes + bytes > 6 * 1024 * 1024)
1802
+ throw new Error("Page ledger exceeds the 6 MiB aggregate limit");
1803
+ this.db.prepare("INSERT INTO page_ledger_pages(artifact_id,ordinal,predecessor_digest,content_digest,content,terminal,created_at) VALUES(?,?,?,?,?,?,?)")
1804
+ .run(input.artifactId, input.ordinal, input.predecessorDigest, digest, Buffer.from(input.content), input.terminal ? 1 : 0, now.toISOString());
1805
+ this.db.prepare("UPDATE page_ledgers SET page_count=?,total_bytes=?,state=?,updated_at=? WHERE artifact_id=?")
1806
+ .run(header.page_count + 1, header.total_bytes + bytes, input.terminal ? "terminal" : "open", now.toISOString(), input.artifactId);
1807
+ return { digest, duplicate: false };
1808
+ });
1809
+ }
1385
1810
  registerGraphEvidence(value, access) { registerGraphDerived(this.db, value, access); }
1386
1811
  getEvidence(digest) {
1387
1812
  if (!graphDerivedAllowed(this.db, this.projectDir, digest))
@@ -1442,7 +1867,16 @@ export class WorkflowDb {
1442
1867
  .run(next.status, next.reportDigest ?? null, json({ findingIds: next.findingIds, namespacedFindingIds: next.namespacedFindingIds, detail: next.detail }), next.updatedAt, attemptId);
1443
1868
  const admission = this.admittedWork(next.runId, next.ticketId);
1444
1869
  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);
1445
- 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()); });
1870
+ const terminalBlock = turns.some(row => {
1871
+ const receipt = parseJson(row.receipt_json);
1872
+ const response = receipt.structuredResponseDigest && this.getEvidence(receipt.structuredResponseDigest);
1873
+ if (!receipt.terminalEventObserved || !response)
1874
+ return false;
1875
+ // A retained response alone is not authority in v2: this legacy receipt
1876
+ // has no sealed tuple/descriptor record to verify. It may be inspected,
1877
+ // but cannot advance a review state.
1878
+ return false;
1879
+ });
1446
1880
  if (admission && (next.status === "passed" || next.status === "failed" || terminalBlock))
1447
1881
  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] : [] });
1448
1882
  return next;
@@ -1482,8 +1916,9 @@ export class WorkflowDb {
1482
1916
  if (!prior)
1483
1917
  throw new Error(`QA remediation attempt not found: ${attemptId}`);
1484
1918
  const next = { ...prior, ...patch, status, updatedAt: now.toISOString() };
1919
+ const raw = this.db.prepare("SELECT record_json FROM qa_remediation_attempts WHERE attempt_id=?").get(attemptId);
1485
1920
  this.db.prepare("UPDATE qa_remediation_attempts SET status=?,response_digest=?,summary_digest=?,record_json=?,updated_at=? WHERE attempt_id=?")
1486
- .run(status, next.responseDigest ?? null, next.summaryDigest ?? null, json({ detail: next.detail, recoveryAttemptId: next.recoveryAttemptId }), next.updatedAt, attemptId);
1921
+ .run(status, next.responseDigest ?? null, next.summaryDigest ?? null, json({ ...parseJson(raw.record_json), detail: next.detail, recoveryAttemptId: next.recoveryAttemptId }), next.updatedAt, attemptId);
1487
1922
  return next;
1488
1923
  }
1489
1924
  qaRemediationAttempts(runId, ticketId) {
@@ -1504,6 +1939,11 @@ export class WorkflowDb {
1504
1939
  ensureQaGraphJournalGuards(this.db);
1505
1940
  const prior = this.qaDeliveryTurns(turn.operationId).find(t => t.turnRecordId === turn.turnRecordId);
1506
1941
  if (prior) {
1942
+ if (prior.sealedDispatch) {
1943
+ if (turn.sealedDispatch && json(turn.sealedDispatch) !== json(prior.sealedDispatch))
1944
+ throw new Error("Delivery owner tuple is immutable");
1945
+ turn.sealedDispatch = prior.sealedDispatch;
1946
+ }
1507
1947
  if (prior.status !== "intended") {
1508
1948
  if (json(prior) !== json(turn))
1509
1949
  throw new Error(`conflicting completed delivery turn ${turn.turnRecordId}`);
@@ -1514,6 +1954,14 @@ export class WorkflowDb {
1514
1954
  throw new Error(`delivery turn intent mismatch: ${key}`);
1515
1955
  }
1516
1956
  }
1957
+ if (turn.status === "completed") {
1958
+ const transport = this.operation(turn.turnRecordId);
1959
+ if (!prior?.sealedDispatch || json(turn.sealedDispatch) !== json(prior.sealedDispatch)
1960
+ || turn.structuredResponseDigest !== transport?.result?.responseDigest)
1961
+ throw new Error("Completed delivery requires its persisted sealed native receipt");
1962
+ this.replayStructuredDispatch(turn.turnRecordId, remediationOutput);
1963
+ this.assertStructuredProviderTurn(turn.turnRecordId, turn.providerTurnId);
1964
+ }
1517
1965
  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)
1518
1966
  VALUES(?,?,?,?,?,?,?,?,?) ON CONFLICT(turn_record_id) DO UPDATE SET status=excluded.status,record_json=excluded.record_json,updated_at=excluded.updated_at`)
1519
1967
  .run(turn.turnRecordId, turn.operationId, turn.reportOccurrenceId, turn.turnIndex, turn.kind, turn.status, json(turn), turn.startedAt, turn.completedAt ?? turn.startedAt);
@@ -1639,6 +2087,7 @@ export class WorkflowDb {
1639
2087
  }
1640
2088
  /** Compare-and-swap the authoritative QA reducer head and append its event atomically. */
1641
2089
  transitionQa(runId, ticketId, expectedRevision, event, now = new Date()) {
2090
+ this.assertNativeRunAdmission(runId);
1642
2091
  this.ensureRun(runId);
1643
2092
  return this.db.transaction(() => {
1644
2093
  const current = this.qaTicketHead(runId, ticketId);
@@ -1727,7 +2176,18 @@ export class WorkflowDb {
1727
2176
  const row = this.db.prepare("SELECT receipt_json FROM qa_handoffs WHERE operation_id=? AND status='accepted'").get(operationId);
1728
2177
  return row?.receipt_json ? parseJson(row.receipt_json) : undefined;
1729
2178
  }
2179
+ /**
2180
+ * Structured authority is admitted only after the one-way v2 cutover has
2181
+ * committed. Keeping this at the common admission boundary prevents a
2182
+ * caller that opens WorkflowDb directly from bypassing the migration.
2183
+ */
2184
+ enableStructuredCommunication() {
2185
+ this.applyStructuredCommunicationV2Cutover();
2186
+ ensureQaGraphJournalGuards(this.db);
2187
+ }
1730
2188
  beginQaTurn(intent) {
2189
+ if (intent.outputContract)
2190
+ this.enableStructuredCommunication();
1731
2191
  const at = intent.intendedAt;
1732
2192
  this.db.transaction(() => {
1733
2193
  const existing = this.db.prepare("SELECT intent_json FROM qa_turns WHERE operation_id=?").get(intent.operationId);
@@ -1832,9 +2292,24 @@ export class WorkflowDb {
1832
2292
  }
1833
2293
  finishQaTurn(receipt) {
1834
2294
  this.db.transaction(() => {
1835
- const prior = this.db.prepare("SELECT source_state_digest,review_basis_digest,status FROM qa_turns WHERE operation_id=?").get(receipt.operationId);
2295
+ const prior = this.db.prepare("SELECT source_state_digest,review_basis_digest,status,intent_json FROM qa_turns WHERE operation_id=?").get(receipt.operationId);
1836
2296
  if (!prior)
1837
2297
  throw new Error(`QA turn intent not found: ${receipt.operationId}`);
2298
+ const intent = parseJson(prior.intent_json);
2299
+ if (receipt.dispatch === "completed") {
2300
+ if (!intent.outputContract)
2301
+ throw new Error("Completed QA receipt requires its sealed native contract");
2302
+ const owner = this.structuredOwnerDispatch(receipt.operationId);
2303
+ if (json(receipt.sealedDispatch) !== json(owner))
2304
+ throw new Error("QA receipt differs from sealed owner intent");
2305
+ if (receipt.structuredResponseDigest !== this.operation(receipt.operationId)?.result?.responseDigest)
2306
+ throw new Error("QA receipt references foreign native evidence");
2307
+ const descriptor = persistedProviderWireContract(persistedDispatchTuple(owner));
2308
+ if (!descriptor)
2309
+ throw new Error("QA receipt contract is unavailable");
2310
+ this.replayStructuredDispatch(receipt.operationId, descriptor.canonical);
2311
+ this.assertStructuredProviderTurn(receipt.operationId, receipt.providerTurnId);
2312
+ }
1838
2313
  if (prior.source_state_digest !== receipt.sourceStateDigest || prior.review_basis_digest !== receipt.reviewBasisDigest)
1839
2314
  throw new Error("QA turn receipt binding mismatch");
1840
2315
  if (prior.status === "completed") {
@@ -1993,6 +2468,13 @@ export class WorkflowDb {
1993
2468
  const row = this.db.prepare("SELECT receipt_json FROM qa_remediation_receipts WHERE operation_id=?").get(operationId);
1994
2469
  return row ? parseJson(row.receipt_json) : undefined;
1995
2470
  }
2471
+ verifiedBuilderRemediationReceipt(operationId) {
2472
+ const receipt = this.builderRemediationReceipt(operationId);
2473
+ if (!receipt)
2474
+ return undefined;
2475
+ this.assertBuilderRemediationReceipt(receipt);
2476
+ return receipt;
2477
+ }
1996
2478
  hasConfirmedQaRemediationForRecheck(runId, ticketId) {
1997
2479
  const head = this.qaTicketHead(runId, ticketId);
1998
2480
  const attempt = this.qaRemediationAttempts(runId, ticketId).at(-1);
@@ -2000,6 +2482,8 @@ export class WorkflowDb {
2000
2482
  || attempt.generation !== head.remediationGeneration || this.unresolvedRoleDispatches(runId, "builder").length)
2001
2483
  return false;
2002
2484
  const receipt = this.builderRemediationReceipt(attempt.attemptId);
2485
+ if (receipt)
2486
+ this.assertBuilderRemediationReceipt(receipt);
2003
2487
  const review = this.qaReviewAttempt(attempt.reviewAttemptId);
2004
2488
  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);
2005
2489
  return Boolean(receipt && review && review.status === "failed" && receipt.runId === runId && receipt.ticketId === ticketId
@@ -2009,6 +2493,7 @@ export class WorkflowDb {
2009
2493
  && transition?.to_revision === head.revision && parseJson(transition.event_json).type === "remediation-received");
2010
2494
  }
2011
2495
  recordBuilderRemediationReceipt(receipt) {
2496
+ this.assertBuilderRemediationReceipt(receipt);
2012
2497
  const text = json(receipt);
2013
2498
  const existing = this.db.prepare("SELECT receipt_json FROM qa_remediation_receipts WHERE operation_id=?").get(receipt.operationId);
2014
2499
  if (existing && json(parseJson(existing.receipt_json)) !== text)
@@ -2021,6 +2506,30 @@ export class WorkflowDb {
2021
2506
  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(?,?,?,?,?,?,?)")
2022
2507
  .run(receipt.operationId, receipt.runId, receipt.ticketId, receipt.reportDigest, occurrence.reportOccurrenceId, text, receipt.completedAt);
2023
2508
  }
2509
+ assertBuilderRemediationReceipt(receipt) {
2510
+ if (receipt.version !== 3)
2511
+ throw new Error("Legacy Builder receipt cannot authorize native remediation");
2512
+ this.assertNativeRunAdmission(receipt.runId);
2513
+ const attempt = this.qaRemediationAttempt(receipt.operationId);
2514
+ const review = attempt && this.qaReviewAttempt(attempt.reviewAttemptId);
2515
+ const turn = this.qaDeliveryTurns(receipt.operationId).find(value => value.turnRecordId === receipt.turnRecordId);
2516
+ if (!attempt || !review || !turn || turn.status !== "completed" || attempt.status !== "succeeded"
2517
+ || attempt.runId !== receipt.runId || attempt.ticketId !== receipt.ticketId
2518
+ || review.sourceDigest !== receipt.sourceStateDigest || review.reportDigest !== receipt.reportDigest
2519
+ || attempt.requestDigest !== receipt.requestDigest || attempt.responseDigest !== receipt.responseDigest
2520
+ || attempt.summaryDigest !== receipt.summaryDigest || turn.rawResponseDigest !== receipt.responseDigest
2521
+ || turn.providerTurnId !== receipt.providerTurnId || turn.reportOccurrenceId !== receipt.reportOccurrenceId)
2522
+ throw new Error("Builder receipt differs from its persisted remediation owner");
2523
+ const transport = this.operation(turn.turnRecordId);
2524
+ if (turn.structuredResponseDigest !== transport?.result?.responseDigest)
2525
+ throw new Error("Builder receipt has a foreign native evidence reference");
2526
+ const result = this.replayStructuredDispatch(turn.turnRecordId, remediationOutput);
2527
+ this.assertStructuredProviderTurn(turn.turnRecordId, receipt.providerTurnId);
2528
+ const handoff = this.db.prepare("SELECT handoff_id FROM qa_failure_handoffs WHERE operation_id=?").get(receipt.operationId);
2529
+ if (result.result.kind !== "completed" || result.result.payload.status !== "done" || result.result.payload.report.handoff_id !== handoff?.handoff_id
2530
+ || createHash("sha256").update(result.result.payload.report.summary).digest("hex") !== receipt.summaryDigest)
2531
+ throw new Error("Builder receipt requires its completed native remediation result and summary");
2532
+ }
2024
2533
  commitBuilderRemediationSuccess(receipt, expectedRevision, now = new Date()) {
2025
2534
  return this.db.transaction(() => {
2026
2535
  this.recordBuilderRemediationReceipt(receipt);
@@ -2051,6 +2560,47 @@ export class WorkflowDb {
2051
2560
  return this.transitionQa(remediation.runId, remediation.ticketId, head.revision, { type: input.outcome === "uncertain" ? "remediation-uncertain" : "remediation-failed" }, now);
2052
2561
  })();
2053
2562
  }
2563
+ assertNativeQaCertificate(input, phase, certificateId) {
2564
+ this.assertNativeRunAdmission(input.runId);
2565
+ const head = this.qaTicketHead(input.runId, input.ticketId);
2566
+ 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'")
2567
+ .all(input.runId, input.ticketId, input.sourceStateDigest, input.reviewBasisDigest);
2568
+ const row = rows.find(row => {
2569
+ try {
2570
+ return qaDigest("turn-receipt", parseJson(row.receipt_json)) === input.turnReceiptDigest;
2571
+ }
2572
+ catch {
2573
+ return false;
2574
+ }
2575
+ });
2576
+ if (!row)
2577
+ throw new Error("Pass requires the persisted sealed native QA owner receipt");
2578
+ const receipt = parseJson(row.receipt_json);
2579
+ const intent = parseJson(row.intent_json);
2580
+ if (!receipt.terminalEventObserved || receipt.dispatch !== "completed" || receipt.operationId !== row.operation_id || intent.operationId !== row.operation_id ||
2581
+ intent.runId !== input.runId || intent.ticketId !== input.ticketId || intent.reviewNumber !== head.reviewNumber || intent.reviewNumber !== row.review_number || intent.sessionGeneration !== head.sessionGeneration ||
2582
+ intent.sourceStateDigest !== input.sourceStateDigest || intent.reviewBasisDigest !== input.reviewBasisDigest ||
2583
+ receipt.sourceStateDigest !== input.sourceStateDigest || receipt.reviewBasisDigest !== input.reviewBasisDigest ||
2584
+ !receipt.sealedDispatch || canonicalJson(receipt.sealedDispatch) !== canonicalJson(this.structuredOwnerDispatch(receipt.operationId)))
2585
+ throw new Error("Native pass owner/source/review binding mismatch");
2586
+ this.assertStructuredProviderTurn(receipt.operationId, receipt.providerTurnId);
2587
+ const verdict = this.replayStructuredDispatch(receipt.operationId, qaVerdictOutput);
2588
+ if (verdict.result.kind !== "completed" || verdict.result.payload.kind !== "passed")
2589
+ throw new Error("Pass requires a verified native passed verdict");
2590
+ const operation = this.operation(receipt.operationId);
2591
+ const tuple = persistedDispatchTuple(this.structuredOwnerDispatch(receipt.operationId));
2592
+ if (operation.runId !== input.runId || intent.providerSession.sessionId !== tuple.sessionId || intent.providerSession.generation !== tuple.generation || intent.providerSession.provider !== tuple.provider)
2593
+ throw new Error("Native pass session/tuple binding mismatch");
2594
+ const attempt = this.qaReviewAttempt(operation.intent.logicalActionId ?? "");
2595
+ if (operation.intent.logicalActionId && !attempt)
2596
+ throw new Error("Native pass review attempt is missing");
2597
+ if (attempt && (attempt.runId !== input.runId || attempt.ticketId !== input.ticketId || attempt.reviewNumber !== head.reviewNumber || attempt.sourceDigest !== input.sourceStateDigest || attempt.status !== "passed"))
2598
+ throw new Error("Native pass review attempt is not completed and bound");
2599
+ if (head.sourceStateDigest !== input.sourceStateDigest || head.reviewBasisDigest !== input.reviewBasisDigest ||
2600
+ (phase === "issue" ? head.state !== "turn-intended" || input.qaRevision !== head.revision + 1 :
2601
+ head.passCertificateId !== certificateId || (phase === "consume" ? head.state !== "passed" || input.qaRevision !== head.revision : head.state !== "finalizing" || input.qaRevision + 1 !== head.revision)))
2602
+ throw new Error("Native pass certificate does not match the reducer phase/revision");
2603
+ }
2054
2604
  assertContractCertificate(input) {
2055
2605
  const store = this.qaPreparationStore();
2056
2606
  if (store.policy(input.runId)?.mode !== "enforce")
@@ -2058,6 +2608,7 @@ export class WorkflowDb {
2058
2608
  const binding = input.contractCoverage;
2059
2609
  if (!binding || binding.version !== 1)
2060
2610
  throw new Error("Enforcing pass requires tagged contract coverage authority");
2611
+ const verifiedBinding = binding;
2061
2612
  const admission = this.assertAdmittedWork(input.runId, input.ticketId);
2062
2613
  const head = store.head(input.runId, input.ticketId, admission.requirementsDigest);
2063
2614
  if (head.state !== "ready" || head.digest !== binding.contractDigest)
@@ -2078,20 +2629,17 @@ export class WorkflowDb {
2078
2629
  const configuredMake = this.getRun(input.runId)?.state?.qa?.settings?.make;
2079
2630
  if (configuredMake && resolveEffectiveQaConfiguration(this.projectDir, { make: configuredMake }).digest !== basis.contractBinding.commonConfigDigest)
2080
2631
  throw new Error("Effective QA configuration changed before pass authority; reconcile contract");
2081
- const attempt = this.qaReviewAttempt(binding.attemptId);
2632
+ const attempt = this.qaReviewAttempt(verifiedBinding.attemptId);
2082
2633
  if (!attempt || attempt.runId !== input.runId || attempt.ticketId !== input.ticketId || attempt.sourceDigest !== input.sourceStateDigest || attempt.reviewNumber !== this.qaTicketHead(input.runId, input.ticketId).reviewNumber)
2083
2634
  throw new Error("Coverage review attempt is stale or foreign");
2084
- const coverage = store.artifact(binding.coverageDigest, "final-coverage");
2635
+ const coverage = store.artifact(verifiedBinding.coverageDigest, "final-coverage");
2085
2636
  for (const check of contract.checks)
2086
2637
  for (const method of check.verification)
2087
2638
  if (method.equivalentAuthorityId && !store.equivalentResolver()(method.equivalentAuthorityId, contract, check, method, input.sourceStateDigest))
2088
2639
  throw new Error("Equivalent verification authority is stale or foreign");
2089
- const errors = validateContractCoverage(contract, coverage, { phase: "qa", sourceDigest: input.sourceStateDigest, inputBasisDigest: input.reviewBasisDigest, attemptId: binding.attemptId, sessionId: binding.sessionId });
2090
- if (errors.length)
2091
- throw new Error(errors.join("; "));
2092
2640
  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);
2093
2641
  const initial = initialTurns.map(row => parseJson(row.intent_json)).find(turn => turn.slot === "initial");
2094
- if (!initial || initial.providerSession.sessionId !== binding.sessionId)
2642
+ if (!initial || initial.providerSession.sessionId !== verifiedBinding.sessionId)
2095
2643
  throw new Error("Coverage lacks the actual fresh review dispatch identity");
2096
2644
  const dispatchedInstruction = this.getEvidence(initial.instructionDigest)?.toString();
2097
2645
  const envelope = `\nImmutable input-basis digest: ${basis.digest}. Coverage must bind this inputBasisDigest.`;
@@ -2099,31 +2647,65 @@ export class WorkflowDb {
2099
2647
  throw new Error("Actual review dispatch differs from frozen base instruction and tagged input-basis envelope");
2100
2648
  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);
2101
2649
  const receipt = receipts.map(row => parseJson(row.receipt_json)).find(row => qaDigest("turn-receipt", row) === input.turnReceiptDigest && row.terminalEventObserved);
2102
- const response = receipt?.cleanedResponseDigest ? this.getEvidence(receipt.cleanedResponseDigest) : undefined;
2103
- const retainedProviderCoverage = receipt && response
2104
- ? bindCoverageHostFields(parseContractCoverage(response.toString()), contract, {
2105
- phase: "qa",
2106
- sourceDigest: input.sourceStateDigest,
2107
- inputBasisDigest: input.reviewBasisDigest,
2108
- attemptId: binding.attemptId,
2109
- sessionId: binding.sessionId,
2110
- })
2111
- : undefined;
2650
+ const response = receipt?.structuredResponseDigest ? this.getEvidence(receipt.structuredResponseDigest) : undefined;
2651
+ if (!response || !receipt)
2652
+ throw new Error("Pass requires a retained native QA verdict receipt");
2653
+ const owner = this.structuredOwnerDispatch(receipt.operationId);
2654
+ if (json(receipt.sealedDispatch) !== json(owner))
2655
+ throw new Error("Pass receipt lacks its persisted v2 owner tuple");
2656
+ if (persistedDispatchTuple(owner).sessionId !== verifiedBinding.sessionId)
2657
+ throw new Error("Pass native receipt has a foreign session");
2658
+ const verdict = this.replayStructuredDispatch(receipt.operationId, qaVerdictOutput);
2659
+ if (verdict.result.kind !== "completed" || verdict.result.payload.kind !== "passed" || !verdict.result.payload.coverage)
2660
+ throw new Error("Pass requires complete coverage in the retained native passed verdict");
2661
+ const retainedProviderCoverage = bindCoverageHostFields(verdict.result.payload.coverage, contract, {
2662
+ phase: "qa", sourceDigest: input.sourceStateDigest, inputBasisDigest: input.reviewBasisDigest,
2663
+ attemptId: verifiedBinding.attemptId, sessionId: verifiedBinding.sessionId,
2664
+ });
2112
2665
  let retainedCoverage = retainedProviderCoverage;
2666
+ let retainedHostComparison;
2113
2667
  const hostCheck = findHostQaCheck(contract);
2114
- if (hostCheck) {
2668
+ if (hostCheck !== undefined) {
2115
2669
  const hostDigest = hostComparisonDigestFromCoverage(coverage, hostCheck.id);
2116
2670
  if (!hostDigest)
2117
2671
  throw new Error("Coverage is missing the host-owned comparison artifact reference");
2118
- const hostResult = hostQaComparisonV1Schema.parse(store.artifact(hostDigest, "host-qa-comparison"));
2119
- retainedCoverage = retainedProviderCoverage
2120
- ? applyHostQaComparison(retainedProviderCoverage, contract, hostResult, hostDigest, this.projectDir)
2121
- : undefined;
2672
+ const verifiedHostDigest = hostDigest;
2673
+ const hostResult = hostQaComparisonV1Schema.parse(store.artifact(verifiedHostDigest, "host-qa-comparison"));
2674
+ retainedHostComparison = { result: hostResult, resultDigest: verifiedHostDigest };
2675
+ }
2676
+ const commandObservations = [
2677
+ ...retainedQaCommandObservations(this.projectDir, input.runId, input.ticketId, false),
2678
+ ...retainedHostBuilderObservations(this, input.runId, input.ticketId),
2679
+ ];
2680
+ const graphCheck = findGraphQaChecks(contract)[0];
2681
+ let retainedHostGraph;
2682
+ if (graphCheck) {
2683
+ const graphDigest = hostGraphApplicabilityDigestFromCoverage(coverage, graphCheck.id);
2684
+ if (!graphDigest)
2685
+ throw new Error("Coverage is missing the host-owned Graphify applicability artifact reference");
2686
+ const verifiedGraphDigest = graphDigest;
2687
+ const graphResult = hostGraphApplicabilityV1Schema.parse(store.artifact(verifiedGraphDigest, "host-graph-applicability"));
2688
+ retainedHostGraph = { result: graphResult, resultDigest: verifiedGraphDigest };
2122
2689
  }
2690
+ if (retainedCoverage)
2691
+ retainedCoverage = assembleFinalQaCoverage({
2692
+ providerCoverage: retainedCoverage,
2693
+ contract,
2694
+ projectDir: this.projectDir,
2695
+ commandObservations,
2696
+ hostComparison: retainedHostComparison,
2697
+ hostGraph: retainedHostGraph,
2698
+ });
2699
+ if (!retainedCoverage)
2700
+ throw new Error("Coverage lacks the retained terminal review receipt and authorized host augmentation");
2701
+ const errors = validateContractCoverage(contract, retainedCoverage, { phase: "qa", sourceDigest: input.sourceStateDigest, inputBasisDigest: input.reviewBasisDigest, attemptId: verifiedBinding.attemptId, sessionId: verifiedBinding.sessionId });
2702
+ if (errors.length)
2703
+ throw new Error(errors.join("; "));
2123
2704
  if (!receipt || !retainedCoverage || canonicalJson(retainedCoverage) !== canonicalJson(coverage))
2124
2705
  throw new Error("Coverage is not bound to the retained terminal review receipt and authorized host augmentation");
2125
2706
  }
2126
2707
  issueQaPassCertificate(input, now = new Date()) {
2708
+ this.assertNativeQaCertificate(input, "issue");
2127
2709
  this.assertContractCertificate(input);
2128
2710
  if (this.unresolvedQaReports(input.runId, input.ticketId).length)
2129
2711
  throw new Error("cannot issue QA pass certificate while reports remain unresolved");
@@ -2156,11 +2738,16 @@ export class WorkflowDb {
2156
2738
  }
2157
2739
  consumeQaPassCertificate(runId, ticketId, certificateId, consumer, now = new Date()) {
2158
2740
  return this.db.transaction(() => {
2159
- 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);
2741
+ const row = this.db.prepare("SELECT * FROM qa_pass_certificates WHERE certificate_id=? AND run_id=? AND ticket_id=?").get(certificateId, runId, ticketId);
2160
2742
  if (!row)
2161
2743
  throw new Error("QA pass certificate is missing or scoped to another run/ticket");
2162
2744
  if (row.consumed_at)
2163
2745
  throw new Error("QA pass certificate has already been consumed");
2746
+ const value = parseJson(row.certificate_json);
2747
+ if (value.certificateId !== row.certificate_id || value.runId !== runId || value.ticketId !== ticketId || value.qaRevision !== row.qa_revision ||
2748
+ value.sourceStateDigest !== row.source_state_digest || value.reviewBasisDigest !== row.review_basis_digest || value.turnReceiptDigest !== row.turn_receipt_digest || value.issuedAt !== row.issued_at)
2749
+ throw new Error("Pass certificate SQL identity mismatch");
2750
+ this.assertNativeQaCertificate(value, "consume", certificateId);
2164
2751
  this.assertContractCertificate(parseJson(row.certificate_json));
2165
2752
  if (this.unresolvedQaReports(runId, ticketId).length)
2166
2753
  throw new Error("QA pass certificate cannot be consumed with unresolved reports");
@@ -2209,6 +2796,26 @@ export class WorkflowDb {
2209
2796
  return next;
2210
2797
  })();
2211
2798
  }
2799
+ assertQaFinalizationAuthority(runId, ticketId) {
2800
+ const steps = this.qaFinalizationSteps(runId, ticketId).filter(step => step.status !== "invalidated");
2801
+ if (!steps.length)
2802
+ throw new Error("QA finalization intent is missing");
2803
+ for (const step of steps) {
2804
+ const row = this.db.prepare("SELECT * FROM qa_pass_certificates WHERE certificate_id=? AND run_id=? AND ticket_id=?")
2805
+ .get(step.certificateId, runId, ticketId);
2806
+ if (!row || !row.consumed_at || row.consumed_by !== step.kind)
2807
+ throw new Error("Finalization certificate consumer binding mismatch");
2808
+ const certificate = parseJson(row.certificate_json);
2809
+ if (certificate.consumedAt !== row.consumed_at || certificate.consumedBy !== row.consumed_by
2810
+ || certificate.certificateId !== row.certificate_id || certificate.runId !== row.run_id || certificate.ticketId !== row.ticket_id
2811
+ || certificate.qaRevision !== row.qa_revision || certificate.sourceStateDigest !== row.source_state_digest
2812
+ || certificate.reviewBasisDigest !== row.review_basis_digest || certificate.turnReceiptDigest !== row.turn_receipt_digest
2813
+ || certificate.issuedAt !== row.issued_at)
2814
+ throw new Error("Finalization certificate SQL identity mismatch");
2815
+ this.assertNativeQaCertificate(certificate, "resume", step.certificateId);
2816
+ this.assertContractCertificate(certificate);
2817
+ }
2818
+ }
2212
2819
  planQaFinalizationStep(input, now = new Date()) {
2213
2820
  const at = now.toISOString();
2214
2821
  const intentText = json(input.intent);
@@ -2268,6 +2875,7 @@ export class WorkflowDb {
2268
2875
  completeQaFinalization(runId, ticketId, expectedRevision, receipt, now = new Date()) {
2269
2876
  return this.db.transaction(() => {
2270
2877
  this.assertFinalizationControls(runId, ticketId);
2878
+ this.assertQaFinalizationAuthority(runId, ticketId);
2271
2879
  const head = this.qaTicketHead(runId, ticketId);
2272
2880
  if (head.revision !== expectedRevision || head.state !== "finalizing")
2273
2881
  throw new Error(`QA finalization completion raced for ${runId}/${ticketId}`);
@@ -2528,6 +3136,31 @@ export class WorkflowDb {
2528
3136
  : 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);
2529
3137
  return row.count;
2530
3138
  }
3139
+ freshRecoveryValidation(runId, role) {
3140
+ const decision = this.recoveryDecisions(runId).at(-1);
3141
+ const predecessor = this.roleMutationLease(runId, role);
3142
+ if (!decision || decision.mode !== "fresh-recovery-only" || decision.role !== role || !predecessor)
3143
+ throw new Error("Fresh recovery validation requires an explicit current recovery decision and predecessor lease");
3144
+ return { decisionDigest: digestJson(decision), predecessor };
3145
+ }
3146
+ /** This grants one response-only initialization, never ownership or business work. */
3147
+ assertFreshRecoveryValidation(dispatchId, session) {
3148
+ const operation = this.operation(dispatchId);
3149
+ const intent = operation?.intent;
3150
+ const authorization = intent?.freshRecoveryValidation;
3151
+ const decision = operation && this.recoveryDecisions(operation.runId).at(-1);
3152
+ if (!operation || !authorization || !decision || intent?.operation !== "session-initialization" || intent.responseOnly !== true ||
3153
+ canonicalJson(intent.sessionRef) !== canonicalJson(session) ||
3154
+ (session.role !== "builder" && session.role !== "qa") || decision.mode !== "fresh-recovery-only" || decision.role !== session.role ||
3155
+ authorization.decisionDigest !== digestJson(decision) || dispatchId !== `fresh-recovery:${authorization.decisionDigest}` ||
3156
+ canonicalJson(this.roleMutationLease(operation.runId, session.role)) !== canonicalJson(authorization.predecessor) ||
3157
+ decision.runId !== operation.runId || canonicalSessionPath(session.cwd) !== canonicalSessionPath(decision.worktree) ||
3158
+ session.provider !== (decision.requestedSuccessor?.agent ?? decision.settings.make) ||
3159
+ session.sessionId === authorization.predecessor.providerSessionId ||
3160
+ decision.predecessorSessionId && decision.predecessorSessionId !== authorization.predecessor.providerSessionId ||
3161
+ decision.predecessorSessionRef && providerSessionKey(decision.predecessorSessionRef) !== authorization.predecessor.sessionKey)
3162
+ throw new Error("Submission session no longer owns its role: fresh recovery decision or predecessor changed");
3163
+ }
2531
3164
  recordRecoveryDecision(receipt) {
2532
3165
  this.ensureRun(receipt.runId);
2533
3166
  const digest = digestJson(receipt);
@@ -2706,12 +3339,12 @@ export class WorkflowDb {
2706
3339
  parsed = { unreadable: true };
2707
3340
  }
2708
3341
  const record = parsed && typeof parsed === "object" ? parsed : undefined;
3342
+ if (record?.communicationProtocolVersion === 2)
3343
+ continue;
2709
3344
  const isBuild = (record?.version === 1 || record?.version === 2) && typeof record.runId === "string";
2710
3345
  const runId = isBuild ? String(record.runId) : `legacy_${digest.slice(0, 24)}`;
2711
3346
  if (!this.getRun(runId)) {
2712
3347
  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);
2713
- if (!isBuild)
2714
- this.transition(runId, { status: "superseded", checkpoint: "legacy-imported", remainingWork: {}, event: "legacy_record_preserved" }, now);
2715
3348
  }
2716
3349
  this.db.prepare("INSERT INTO legacy_imports(source_path,digest,run_id,imported_at) VALUES(?,?,?,?)").run(path, digest, runId, now.toISOString());
2717
3350
  count += 1;
@@ -2839,6 +3472,119 @@ export class WorkflowDb {
2839
3472
  this.db.prepare("INSERT INTO recovery_schema_migrations(migration,completed_at) VALUES(?,?)").run(`${migration}:${migrationDigest}`, new Date().toISOString());
2840
3473
  migrateQaHandback(this.db);
2841
3474
  }
3475
+ /**
3476
+ * One-way cutover fence. We deliberately do not attempt to convert an old
3477
+ * receipt into authority: any pre-existing run with legacy state or a
3478
+ * provider dispatch that lacks a sealed tuple is terminally superseded.
3479
+ * User/project artifacts remain in their own tables; only workflow authority
3480
+ * is fenced. The marker is committed atomically with the classification.
3481
+ */
3482
+ applyStructuredCommunicationV2Cutover() {
3483
+ const migration = STRUCTURED_AUDIT_CLOSURE;
3484
+ this.db.transaction(() => {
3485
+ 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);
3486
+ CREATE TABLE IF NOT EXISTS structured_cutover_quarantine(run_id TEXT NOT NULL,table_name TEXT NOT NULL,row_json TEXT NOT NULL);
3487
+ 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);
3488
+ 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));
3489
+ 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);
3490
+ 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);
3491
+ 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));
3492
+ 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);
3493
+ 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));`);
3494
+ const prior = this.db.prepare("SELECT details_json FROM structured_communication_cutover WHERE migration=?").get(migration);
3495
+ 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)
3496
+ return;
3497
+ // Upgrade only an unambiguous host baseline. Historical provider deltas
3498
+ // without an exact dispatch origin remain fenced; timestamps are not proof.
3499
+ for (const checkpoint of this.db.prepare(`SELECT c.* FROM continuity_checkpoints c JOIN workflow_runs r USING(run_id)
3500
+ WHERE json_valid(r.state_json) AND c.session_ref_json IS NULL
3501
+ AND NOT EXISTS(SELECT 1 FROM continuity_checkpoint_origins o WHERE o.checkpoint_id=c.checkpoint_id)`).all()) {
3502
+ let delta;
3503
+ try {
3504
+ delta = parseJson(checkpoint.delta_json);
3505
+ }
3506
+ catch {
3507
+ continue;
3508
+ }
3509
+ if (!delta || delta.version !== 1 || delta.nextAction !== "Await the first role action." ||
3510
+ [delta.decisions, delta.constraints, delta.discoveries, delta.completedActions, delta.evidence, delta.failures, delta.blockers, delta.openWork].some(items => !Array.isArray(items) || items.length))
3511
+ continue;
3512
+ 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")
3513
+ .get(checkpoint.run_id, checkpoint.event_sequence);
3514
+ if (!baseline || JSON.parse(baseline.payload_json).role !== checkpoint.role)
3515
+ continue;
3516
+ const deltaDigest = createHash("sha256").update(checkpoint.delta_json).digest("hex");
3517
+ const event = this.appendContinuityEvent({ runId: checkpoint.run_id, role: "host", kind: "host_checkpoint", authoritativeStateRevision: checkpoint.authoritative_state_revision,
3518
+ payload: { checkpointDeltaDigest: deltaDigest, sourceBaselineSequence: baseline.sequence, role: checkpoint.role } });
3519
+ this.db.prepare("INSERT INTO continuity_checkpoint_origins(checkpoint_id,run_id,origin_json,delta_digest) VALUES(?,?,?,?)")
3520
+ .run(checkpoint.checkpoint_id, checkpoint.run_id, json({ kind: "host", eventSequence: event.sequence, inputCheckpointDigests: [] }), deltaDigest);
3521
+ }
3522
+ const matrix = cutoverOwnershipMatrix(this.db);
3523
+ const fileReferences = communicationFileEvidenceReferences(this.projectDir);
3524
+ const affected = this.db.prepare("SELECT * FROM workflow_runs").all().filter(run => invalidStructuredRun(this.db, run, this.projectDir));
3525
+ const lease = this.db.prepare("SELECT run_id,pid,process_start,host FROM project_lease WHERE singleton=1").get();
3526
+ if (lease && affected.some(run => run.run_id === lease.run_id) && classifyProcess(lease.pid, lease.process_start, lease.host).state !== "dead")
3527
+ throw new Error("v2-cutover-pending: stop the existing workflow owner before structured communication cutover");
3528
+ if (this.db.prepare("SELECT 1 FROM sqlite_master WHERE type='table' AND name='build_owned_processes'").get()) {
3529
+ const processes = this.db.prepare("SELECT run_id,pid,process_start,host FROM build_owned_processes WHERE state<>'quiescent'").all();
3530
+ if (processes.some(process => affected.some(run => run.run_id === process.run_id)
3531
+ && (!process.pid || !process.process_start || classifyProcess(process.pid, process.process_start, process.host).state !== "dead")))
3532
+ throw new Error("v2-cutover-pending: reconcile owned build processes before structured communication cutover");
3533
+ }
3534
+ if (this.db.prepare("SELECT 1 FROM sqlite_master WHERE type='table' AND name='build_admission'").get()) {
3535
+ const row = this.db.prepare("SELECT record_json FROM build_admission WHERE singleton=1").get();
3536
+ if (row) {
3537
+ let admission;
3538
+ try {
3539
+ admission = JSON.parse(row.record_json);
3540
+ }
3541
+ catch {
3542
+ throw new Error("v2-cutover-pending: malformed build admission requires reconciliation");
3543
+ }
3544
+ if (affected.some(run => run.run_id === admission.runId) && classifyProcess(admission.pid, admission.processStart, admission.host).state !== "dead")
3545
+ throw new Error("v2-cutover-pending: stop the existing build owner before structured communication cutover");
3546
+ }
3547
+ }
3548
+ // Existing domain guards must not block the authorized cutover. Restore
3549
+ // them in this same transaction, omitting only superseded v1 comms guards.
3550
+ const triggers = this.db.prepare("SELECT name,sql FROM sqlite_master WHERE type='trigger' AND sql IS NOT NULL").all();
3551
+ for (const trigger of triggers) {
3552
+ if (!/^[a-zA-Z_][a-zA-Z_0-9]*$/.test(trigger.name))
3553
+ throw new Error("Unexpected cutover trigger identity");
3554
+ this.db.exec(`DROP TRIGGER "${trigger.name}"`);
3555
+ }
3556
+ const now = new Date().toISOString();
3557
+ for (const { run_id } of affected) {
3558
+ const row = this.db.prepare("SELECT state_json FROM workflow_runs WHERE run_id=?").get(run_id);
3559
+ const originalBytesDigest = createHash("sha256").update(row.state_json).digest("hex");
3560
+ 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 }));
3561
+ let original = {};
3562
+ try {
3563
+ const parsed = parseJson(row.state_json);
3564
+ if (parsed && typeof parsed === "object" && !Array.isArray(parsed))
3565
+ original = parsed;
3566
+ }
3567
+ catch { /* quarantined verbatim above */ }
3568
+ const state = { ...original, structuredCommunicationCutover: { version: 2, reason: "structured-communication-v2-cutover", originalBytesDigest, at: now } };
3569
+ purgeStructuredRun(this.db, run_id, matrix, fileReferences);
3570
+ this.db.prepare("UPDATE workflow_runs SET status='superseded',checkpoint='structured-communication-v2-cutover',state_json=?,updated_at=? WHERE run_id=?")
3571
+ .run(json(state), now, run_id);
3572
+ }
3573
+ quarantineCommunicationFiles(this.projectDir, this.db);
3574
+ for (const trigger of triggers)
3575
+ if (!trigger.name.startsWith("structured_comms_v1_"))
3576
+ this.db.exec(trigger.sql);
3577
+ 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")
3578
+ .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" }));
3579
+ this.db.prepare("INSERT OR IGNORE INTO structured_communication_cutover(migration,completed_at,details_json) VALUES(?,?,?)")
3580
+ .run("003_structured_communication_v2_cutover", now, json({ ownershipMatrixVersion: STRUCTURED_CUTOVER_MATRIX_VERSION }));
3581
+ ensureQaGraphJournalGuards(this.db);
3582
+ this.db.exec(`CREATE TRIGGER IF NOT EXISTS structured_cutover_terminal_run BEFORE UPDATE ON workflow_runs
3583
+ WHEN OLD.checkpoint='structured-communication-v2-cutover'
3584
+ BEGIN SELECT CASE WHEN NEW.status<>'superseded' OR NEW.checkpoint<>OLD.checkpoint OR NEW.state_json<>OLD.state_json
3585
+ THEN RAISE(ABORT,'structured-communication-v2-cutover: workflow permanently non-resumable') END; END;`);
3586
+ })();
3587
+ }
2842
3588
  ensureColumn(table, column, definition) {
2843
3589
  const columns = this.db.prepare(`PRAGMA table_info(${table})`).all();
2844
3590
  if (!columns.some((existing) => existing.name === column)) {