ai-foreman 1.7.20 → 1.7.21

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 (189) hide show
  1. package/README.md +12 -2
  2. package/dist/activity.d.ts +3 -0
  3. package/dist/activity.js +18 -6
  4. package/dist/adapters/claude.d.ts +16 -0
  5. package/dist/adapters/claude.js +28 -3
  6. package/dist/adapters/codex.d.ts +20 -0
  7. package/dist/adapters/codex.js +99 -5
  8. package/dist/adapters/recovering.d.ts +9 -0
  9. package/dist/adapters/recovering.js +10 -0
  10. package/dist/adapters/types.d.ts +15 -0
  11. package/dist/agentRun.d.ts +7 -0
  12. package/dist/agentRun.js +24 -13
  13. package/dist/branch/currentGuard.d.ts +1 -0
  14. package/dist/branch/currentGuard.js +1 -0
  15. package/dist/branch/finalization.js +6 -1
  16. package/dist/branch/git.d.ts +1 -0
  17. package/dist/branch/git.js +15 -1
  18. package/dist/branch/runner.d.ts +2 -0
  19. package/dist/branch/runner.js +90 -4
  20. package/dist/buildAdmission.js +2 -0
  21. package/dist/buildAssignment.d.ts +33 -0
  22. package/dist/buildAssignment.js +215 -0
  23. package/dist/buildInterventions.d.ts +90 -0
  24. package/dist/buildInterventions.js +334 -0
  25. package/dist/buildOwnershipReconciliation.d.ts +19 -0
  26. package/dist/buildOwnershipReconciliation.js +41 -0
  27. package/dist/buildOwnershipRepair.d.ts +33 -0
  28. package/dist/buildOwnershipRepair.js +111 -0
  29. package/dist/buildRuns.d.ts +6 -0
  30. package/dist/buildRuns.js +23 -17
  31. package/dist/buildSyntheticWork.d.ts +2 -0
  32. package/dist/buildSyntheticWork.js +30 -0
  33. package/dist/buildWorkAdmission.d.ts +17 -0
  34. package/dist/buildWorkAdmission.js +57 -0
  35. package/dist/buildWorkAuthorization.d.ts +2 -0
  36. package/dist/buildWorkAuthorization.js +23 -0
  37. package/dist/buildWorkContext.d.ts +3 -0
  38. package/dist/buildWorkContext.js +6 -0
  39. package/dist/buildWorkMigration.d.ts +9 -0
  40. package/dist/buildWorkMigration.js +206 -0
  41. package/dist/buildWorkReconciliation.d.ts +12 -0
  42. package/dist/buildWorkReconciliation.js +70 -0
  43. package/dist/builderGuidanceFollowup.d.ts +15 -0
  44. package/dist/builderGuidanceFollowup.js +34 -0
  45. package/dist/cli/doctor.js +3 -2
  46. package/dist/cli/graph.d.ts +2 -0
  47. package/dist/cli/graph.js +75 -0
  48. package/dist/cli/manager.d.ts +8 -0
  49. package/dist/cli/manager.js +234 -12
  50. package/dist/cli/recovery.js +3 -2
  51. package/dist/cli/runtimeAuthPrompt.js +28 -8
  52. package/dist/cli/start.js +152 -48
  53. package/dist/cli/status.js +20 -5
  54. package/dist/cli/tickets.js +9 -7
  55. package/dist/continuity.d.ts +11 -0
  56. package/dist/continuity.js +22 -6
  57. package/dist/foreman.d.ts +10 -2
  58. package/dist/foreman.js +330 -39
  59. package/dist/graph/boundary.d.ts +15 -0
  60. package/dist/graph/boundary.js +50 -0
  61. package/dist/graph/bridge.d.ts +9 -0
  62. package/dist/graph/bridge.js +146 -0
  63. package/dist/graph/bridge.py +205 -0
  64. package/dist/graph/config.d.ts +11 -0
  65. package/dist/graph/config.js +40 -0
  66. package/dist/graph/corpus.d.ts +16 -0
  67. package/dist/graph/corpus.js +188 -0
  68. package/dist/graph/derived.d.ts +17 -0
  69. package/dist/graph/derived.js +73 -0
  70. package/dist/graph/host.d.ts +4 -0
  71. package/dist/graph/host.js +160 -0
  72. package/dist/graph/install.d.ts +3 -0
  73. package/dist/graph/install.js +50 -0
  74. package/dist/graph/lifecycle.d.ts +11 -0
  75. package/dist/graph/lifecycle.js +124 -0
  76. package/dist/graph/maintenance.d.ts +43 -0
  77. package/dist/graph/maintenance.js +301 -0
  78. package/dist/graph/manager.d.ts +9 -0
  79. package/dist/graph/manager.js +92 -0
  80. package/dist/graph/preparation.d.ts +41 -0
  81. package/dist/graph/preparation.js +43 -0
  82. package/dist/graph/read.d.ts +27 -0
  83. package/dist/graph/read.js +152 -0
  84. package/dist/graph/semantic.d.ts +17 -0
  85. package/dist/graph/semantic.js +27 -0
  86. package/dist/graph/session.d.ts +8 -0
  87. package/dist/graph/session.js +92 -0
  88. package/dist/graph/sources.d.ts +4 -0
  89. package/dist/graph/sources.js +11 -0
  90. package/dist/graph/storage.d.ts +38 -0
  91. package/dist/graph/storage.js +159 -0
  92. package/dist/graph/turn.d.ts +17 -0
  93. package/dist/graph/turn.js +360 -0
  94. package/dist/graph/util.d.ts +5 -0
  95. package/dist/graph/util.js +51 -0
  96. package/dist/handoffs.d.ts +1 -0
  97. package/dist/handoffs.js +5 -1
  98. package/dist/humanDecision.d.ts +21 -2
  99. package/dist/humanDecision.js +169 -11
  100. package/dist/index.js +2 -0
  101. package/dist/installOwnership.d.ts +46 -0
  102. package/dist/installOwnership.js +440 -0
  103. package/dist/linuxProbeInventory.d.ts +12 -0
  104. package/dist/linuxProbeInventory.js +48 -0
  105. package/dist/log.d.ts +1 -1
  106. package/dist/log.js +7 -1
  107. package/dist/managerEvidence.d.ts +25 -0
  108. package/dist/managerEvidence.js +326 -0
  109. package/dist/managerEvidenceArtifacts.d.ts +38 -0
  110. package/dist/managerEvidenceArtifacts.js +118 -0
  111. package/dist/managerPacket.d.ts +2 -0
  112. package/dist/managerPacket.js +23 -0
  113. package/dist/markers.d.ts +1 -1
  114. package/dist/markers.js +2 -2
  115. package/dist/observability.js +1 -1
  116. package/dist/processIdentity.js +7 -0
  117. package/dist/providerPhase.d.ts +13 -0
  118. package/dist/providerPhase.js +26 -0
  119. package/dist/qaAuthorizedDisposition.d.ts +24 -0
  120. package/dist/qaAuthorizedDisposition.js +26 -0
  121. package/dist/qaBuildGate.d.ts +4 -0
  122. package/dist/qaBuildGate.js +216 -0
  123. package/dist/qaBuilderCoverage.d.ts +5 -0
  124. package/dist/qaBuilderCoverage.js +78 -0
  125. package/dist/qaContractCoverage.d.ts +10 -0
  126. package/dist/qaContractCoverage.js +55 -0
  127. package/dist/qaContractDelivery.d.ts +23 -0
  128. package/dist/qaContractDelivery.js +145 -0
  129. package/dist/qaContractFreshness.d.ts +15 -0
  130. package/dist/qaContractFreshness.js +36 -0
  131. package/dist/qaDeliveryJournal.d.ts +10 -0
  132. package/dist/qaDeliveryJournal.js +16 -1
  133. package/dist/qaEffectiveConfig.d.ts +20 -0
  134. package/dist/qaEffectiveConfig.js +40 -0
  135. package/dist/qaEvidenceReader.d.ts +14 -0
  136. package/dist/qaEvidenceReader.js +176 -0
  137. package/dist/qaFailureDelivery.d.ts +2 -0
  138. package/dist/qaFailureDelivery.js +107 -10
  139. package/dist/qaHandbackMigration.d.ts +1 -1
  140. package/dist/qaHandbackMigration.js +3 -3
  141. package/dist/qaPreparation.d.ts +26 -0
  142. package/dist/qaPreparation.js +150 -0
  143. package/dist/qaPreparationInputs.d.ts +8 -0
  144. package/dist/qaPreparationInputs.js +47 -0
  145. package/dist/qaPreparationMetrics.d.ts +145 -0
  146. package/dist/qaPreparationMetrics.js +120 -0
  147. package/dist/qaPreparationPolicy.d.ts +5 -0
  148. package/dist/qaPreparationPolicy.js +51 -0
  149. package/dist/qaPreparationStore.d.ts +157 -0
  150. package/dist/qaPreparationStore.js +412 -0
  151. package/dist/qaProtocolV2.d.ts +28 -0
  152. package/dist/qaProtocolV2.js +12 -0
  153. package/dist/qaReview.d.ts +6 -0
  154. package/dist/qaReview.js +234 -24
  155. package/dist/qaTimeline.d.ts +18 -0
  156. package/dist/qaTimeline.js +123 -0
  157. package/dist/qaVerificationContract.d.ts +17 -0
  158. package/dist/qaVerificationContract.js +236 -0
  159. package/dist/readinessCleanup.js +12 -5
  160. package/dist/recoveryGuidance.d.ts +9 -0
  161. package/dist/recoveryGuidance.js +13 -0
  162. package/dist/runtimeAuth.d.ts +1 -0
  163. package/dist/runtimeAuth.js +5 -1
  164. package/dist/runtimeReadiness.d.ts +1 -0
  165. package/dist/runtimeReadiness.js +13 -1
  166. package/dist/sessionIdentity.d.ts +2 -0
  167. package/dist/sessionIdentity.js +14 -0
  168. package/dist/sessionLifecycle.js +3 -3
  169. package/dist/sources/sourceRegistry.js +7 -0
  170. package/dist/stateTransfer.js +50 -1
  171. package/dist/statusReporter.d.ts +1 -0
  172. package/dist/statusReporter.js +1 -0
  173. package/dist/supervisedStart.js +18 -8
  174. package/dist/terminalStyle.d.ts +21 -0
  175. package/dist/terminalStyle.js +50 -0
  176. package/dist/ticketPopulation.d.ts +1 -0
  177. package/dist/ticketPopulation.js +5 -1
  178. package/dist/tickets/commands.js +2 -2
  179. package/dist/tickets/queue.js +1 -1
  180. package/dist/tickets/ticketSchema.d.ts +198 -0
  181. package/dist/tickets/ticketSchema.js +2 -0
  182. package/dist/workflowDb.d.ts +51 -5
  183. package/dist/workflowDb.js +338 -7
  184. package/dist/workflowReader.d.ts +109 -2
  185. package/dist/workflowReader.js +48 -4
  186. package/package.json +84 -7
  187. package/scripts/copy-graph-assets.mjs +3 -0
  188. package/scripts/graph-benchmark.mjs +20 -0
  189. package/scripts/graph-evaluation.mjs +85 -0
@@ -1,3 +1,14 @@
1
+ import { registerQaGraphJournalWriter, ensureQaGraphJournalGuards } from "./qaDeliveryJournal.js";
2
+ import { canonicalJson } from "./qaProtocolV2.js";
3
+ import { resolveEffectiveQaConfiguration } from "./qaEffectiveConfig.js";
4
+ import { registerGraphDerived, graphDerivedAllowed, currentGraphDerivedAccess } from "./graph/derived.js";
5
+ import { parseContractCoverage, validateContractCoverage } from "./qaContractCoverage.js";
6
+ import { QaPreparationStore, migrateQaPreparation, migrateQaPreparationGuards } from "./qaPreparationStore.js";
7
+ import { GraphStore, migrateGraphStore } from "./graph/storage.js";
8
+ import { reconcileWork } from "./buildWorkReconciliation.js";
9
+ import { migrateBuildInterventions, hasQueuedBuilderGuidance, reconcileGuidance, decisionWorkId, queuedControls, completeControl, verifyGuidance, builderVerificationContext, verifyBuilderGuidance, assertFinalizationControls, reserveGuidance, finishGuidance, instruction } from "./buildInterventions.js";
10
+ import { migrateBuildWork } from "./buildWorkMigration.js";
11
+ import { admitWork, admittedWork, assertAdmittedWork } from "./buildWorkAdmission.js";
1
12
  import { cleanupReadiness, inspectReadiness, readinessMetadata } from "./readinessCleanup.js";
2
13
  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";
3
14
  import { createHash, randomUUID } from "node:crypto";
@@ -5,6 +16,7 @@ import { appendFileSync, existsSync, mkdirSync, readFileSync, readdirSync, statS
5
16
  import { hostname } from "node:os";
6
17
  import { dirname, join, resolve } from "node:path";
7
18
  import Database from "better-sqlite3";
19
+ import { validateQaFailureReport } from "rafi-spec";
8
20
  import { migrateQaHandback, registerHandbackWriter, reportOccurrenceId } from "./qaHandbackMigration.js";
9
21
  import { providerSessionKey } from "./sessionIdentity.js";
10
22
  import { taggedProcesses, classifyProcess, processStartIdentity } from "./processIdentity.js";
@@ -60,6 +72,7 @@ export class WorkflowDb {
60
72
  writerLease;
61
73
  coordinatorTransition = 0;
62
74
  lineageTransition = 0;
75
+ workAuthority = 0;
63
76
  constructor(projectDir, path = join(resolve(projectDir), WORKFLOW_DB_FILE), readinessAccess) {
64
77
  this.projectDir = projectDir;
65
78
  this.readinessAccess = readinessAccess;
@@ -85,7 +98,9 @@ export class WorkflowDb {
85
98
  this.db.function("rafi_build_lease_generation", () => this.writerLease?.generation ?? -1);
86
99
  this.db.function("rafi_build_writer_token", () => this.writerAuthority?.token ?? "");
87
100
  this.db.function("rafi_build_writer_run", () => this.writerAuthority?.runId ?? "");
101
+ this.db.function("rafi_work_authority", () => this.workAuthority);
88
102
  registerHandbackWriter(this.db);
103
+ registerQaGraphJournalWriter(this.db);
89
104
  if (!readinessAccess)
90
105
  this.db.pragma("journal_mode = WAL");
91
106
  this.db.pragma("synchronous = FULL");
@@ -95,8 +110,12 @@ export class WorkflowDb {
95
110
  this.restrictReadinessConnection(readinessAccess);
96
111
  else {
97
112
  this.migrate();
113
+ migrateQaPreparation(this.db);
98
114
  migrateBuildAdmission(this.db);
99
115
  this.importLegacyOnce();
116
+ migrateBuildWork(this.db, projectDir);
117
+ migrateBuildInterventions(this.db);
118
+ migrateQaPreparationGuards(this.db);
100
119
  }
101
120
  }
102
121
  catch (error) {
@@ -119,6 +138,155 @@ export class WorkflowDb {
119
138
  }
120
139
  }
121
140
  close() { this.db.close(); }
141
+ qaPreparationStore() { return new QaPreparationStore(this.db, runId => this.assertInstructionOwner(runId), this.projectDir); }
142
+ graphStore() { migrateGraphStore(this.db); return new GraphStore(this.db); }
143
+ admitWork(input) {
144
+ this.assertInstructionOwner(input.runId);
145
+ this.workAuthority++;
146
+ try {
147
+ const result = admitWork(this.db, input);
148
+ this.qaPreparationStore().metric(input.runId, result.workId, result.requirementsDigest, `metric:approved:${input.runId}:${result.workId}:${result.requirementsDigest}`, "approved");
149
+ return result;
150
+ }
151
+ finally {
152
+ this.workAuthority--;
153
+ }
154
+ }
155
+ assertInstructionOwner(runId) {
156
+ if (this.writerAuthority) {
157
+ assertAdmission(this.db, this.writerAuthority);
158
+ if (this.writerAuthority.runId !== runId && !this.db.prepare("SELECT 1 FROM build_child_runs WHERE child=? AND parent=?").get(runId, this.writerAuthority.runId))
159
+ throw new Error("Instruction execution authority belongs to another run");
160
+ return;
161
+ }
162
+ if (this.buildAdmission())
163
+ throw new Error("Instruction consumption requires the original build owner");
164
+ if (this.db.prepare("SELECT 1 FROM build_runtime_runs WHERE run_id=?").get(runId)) {
165
+ const held = this.currentLease();
166
+ if (!held || !this.writerLease || held.owner !== this.writerLease.owner || held.generation !== this.writerLease.generation || held.runId !== runId)
167
+ throw new Error("Instruction execution requires the original workflow lease");
168
+ }
169
+ }
170
+ reserveGuidance(runId, workId, recipient, operationId, sourceDigest, text, reserve = true) {
171
+ this.assertInstructionOwner(runId);
172
+ return reserveGuidance(this.db, runId, workId, recipient, operationId, sourceDigest, text, reserve);
173
+ }
174
+ reconcileInstructionDeliveries(runId, workId) { this.assertInstructionOwner(runId); this.atomic(() => reconcileGuidance(this.db, runId, workId)); }
175
+ decisionWorkId(decision) { return decisionWorkId(this.db, decision); }
176
+ finishGuidance(ids, recipient, result) { for (const id of ids)
177
+ this.assertInstructionOwner(instruction(this.db, id).request.runId); finishGuidance(this.db, ids, recipient, result); }
178
+ builderVerificationContext(runId, workId, sourceDigest) { return builderVerificationContext(this.db, runId, workId, sourceDigest); }
179
+ verifyBuilderGuidance(ids, certificate, response, receiptDigest) { for (const id of ids)
180
+ this.assertInstructionOwner(instruction(this.db, id).request.runId); return this.atomic(() => verifyBuilderGuidance(this.db, ids, certificate, response, receiptDigest)); }
181
+ verifyGuidance(ids, certificate, response) { for (const id of ids)
182
+ this.assertInstructionOwner(instruction(this.db, id).request.runId); return this.atomic(() => verifyGuidance(this.db, ids, certificate, response)); }
183
+ hasQueuedBuilderGuidance(runId, workId) { return hasQueuedBuilderGuidance(this.db, runId, workId); }
184
+ instruction(id) { return instruction(this.db, id); }
185
+ assertFinalizationControls(runId, workId) { assertFinalizationControls(this.db, runId, workId); }
186
+ consumeInstructionControls(runId, workId, kind = "questions") {
187
+ return this.atomic(() => {
188
+ this.assertInstructionOwner(runId);
189
+ const admission = this.assertAdmittedWork(runId, workId);
190
+ const latestReview = this.qaReviewAttempts(runId, workId).at(-1);
191
+ let authorization = kind === "attempts" && latestReview && ["failed", "passed"].includes(latestReview.status) ? this.db.prepare("SELECT a.authorization_id FROM qa_remediation_authorizations a JOIN build_instruction_deliveries d ON json_extract(d.record_json,'$.receipt.authorizationId')=a.authorization_id JOIN build_instructions i USING(instruction_id) WHERE a.run_id=? AND a.ticket_id=? AND a.review_attempt_id=? AND a.consumed_by IS NULL AND d.state='applied' AND json_extract(i.record_json,'$.requirementsDigest')=? AND json_extract(i.record_json,'$.basis.assignmentId')=? AND json_extract(i.record_json,'$.basis.scopeRevision')=? ORDER BY i.sequence LIMIT 1").get(runId, workId, latestReview.attemptId, admission.requirementsDigest, admission.assignmentId, admission.scopeRevision)?.authorization_id : undefined;
192
+ if (authorization) {
193
+ const head = this.qaTicketHead(runId, workId);
194
+ const eligible = latestReview?.status === "passed"
195
+ ? head.state === "passed" && this.hasQueuedBuilderGuidance(runId, workId)
196
+ : head.state === "review-failed" && this.unresolvedQaReports(runId, workId).some(report => report.reviewNumber === latestReview.reviewNumber && report.reportDigest === latestReview.reportDigest);
197
+ if (!eligible)
198
+ authorization = undefined;
199
+ }
200
+ for (const record of queuedControls(this.db, runId, workId).filter(record => kind === "questions" ? record.request.action === "answer_question" : record.request.action === "request_attempt")) {
201
+ const priorAuthorization = authorization;
202
+ try {
203
+ this.atomic(() => {
204
+ if (record.requirementsDigest !== admission.requirementsDigest)
205
+ throw new Error("Instruction requirements are stale");
206
+ if (record.request.action === "answer_question") {
207
+ const decision = this.humanDecision(record.request.decisionId);
208
+ if (!decision || decision.runId !== runId || this.decisionWorkId(decision) !== workId || createHash("sha256").update(json(decision)).digest("hex") !== record.request.decisionRevision)
209
+ throw new Error("Pending question identity or revision changed");
210
+ const choice = decision.choices.find(choice => choice.id === record.request.text || choice.label === record.request.text) ?? decision.choices.find(choice => choice.id === "custom" || choice.id === "answer");
211
+ if (!choice)
212
+ throw new Error("Answer must name an offered choice or use a question accepting custom text");
213
+ const answer = this.answerHumanDecision(runId, decision.decisionId, choice.id, new Date(), choice.id === "custom" || choice.id === "answer" ? record.request.text : undefined);
214
+ completeControl(this.db, record.instructionId, { decisionId: answer.decisionId, status: answer.status });
215
+ }
216
+ else {
217
+ const latest = this.qaReviewAttempts(runId, workId).at(-1);
218
+ if (!latest || !["failed", "passed"].includes(latest.status) || latest.attemptId !== record.basis.reviewAttemptId || authorization)
219
+ throw new Error("One extra attempt requires the current eligible review; another authorization is already available");
220
+ if (record.basis.assignmentId !== admission.assignmentId || record.basis.scopeRevision !== admission.scopeRevision)
221
+ throw new Error("Attempt authorization scope changed");
222
+ const head = this.qaTicketHead(runId, workId);
223
+ if (latest.status === "passed") {
224
+ if (head.state !== "passed" || !this.hasQueuedBuilderGuidance(runId, workId))
225
+ throw new Error("Follow-up authorization requires waiting Builder guidance on the current unfinalized pass");
226
+ authorization = this.authorizeQaRemediation(runId, workId, latest.attemptId, record.request.text);
227
+ completeControl(this.db, record.instructionId, { authorizationId: authorization, reviewAttemptId: latest.attemptId, consumed: false, kind: "builder-guidance-followup" });
228
+ return;
229
+ }
230
+ const report = this.unresolvedQaReports(runId, workId).find(report => report.reviewNumber === latest.reviewNumber && report.reportDigest === latest.reportDigest && report.sourceStateDigest === latest.sourceDigest && report.reviewBasisDigest === head.reviewBasisDigest);
231
+ if (!report || !validateQaFailureReport(report.report).valid || head.reviewNumber !== latest.reviewNumber || head.sourceStateDigest !== latest.sourceDigest || !["review-failed", "operator-menu"].includes(head.state) || this.qaRemediationAttempts(runId, workId).some(attempt => attempt.reviewAttemptId === latest.attemptId))
232
+ throw new Error("Extra attempt requires an unconsumed validated failed-review basis; reconcile uncertain execution separately");
233
+ const decisions = this.db.prepare("SELECT decision_id,status,decision_json FROM human_decisions WHERE run_id=? AND decision_key LIKE ?").all(runId, `${runId}:qa-nonconvergence:%`).filter(row => this.decisionWorkId(parseJson(row.decision_json)) === workId && parseJson(row.decision_json).createdAt >= latest.createdAt);
234
+ if (decisions.some(row => row.status === "answered"))
235
+ throw new Error("The nonconvergence decision was already answered; resume that decision before requesting another attempt");
236
+ authorization = this.authorizeQaRemediation(runId, workId, latest.attemptId, record.request.text);
237
+ for (const row of decisions.filter(row => row.status === "pending")) {
238
+ const decision = parseJson(row.decision_json);
239
+ const at = new Date().toISOString();
240
+ this.db.prepare("UPDATE human_decisions SET status='cancelled',decision_json=?,updated_at=? WHERE decision_id=? AND status='pending'").run(json({ ...decision, status: "cancelled", cancellationReason: `Superseded by scoped attempt instruction ${record.instructionId}`, cancelledAt: at }), at, row.decision_id);
241
+ this.insertEvent(runId, "human_decision_cancelled", "attempt-authorized", { decisionId: row.decision_id, instructionId: record.instructionId }, at);
242
+ }
243
+ if (head.state === "operator-menu")
244
+ this.transitionQa(runId, workId, head.revision, { type: "failed-review-restored", reviewNumber: latest.reviewNumber, sourceStateDigest: latest.sourceDigest, reportDigest: report.reportDigest });
245
+ completeControl(this.db, record.instructionId, { authorizationId: authorization, reviewAttemptId: latest.attemptId, consumed: false });
246
+ }
247
+ });
248
+ }
249
+ catch (error) {
250
+ authorization = priorAuthorization;
251
+ completeControl(this.db, record.instructionId, undefined, String(error));
252
+ }
253
+ }
254
+ return authorization;
255
+ });
256
+ }
257
+ reconcileWork(request, input) {
258
+ this.assertInstructionOwner(request.runId);
259
+ if (this.writerAuthority)
260
+ assertAdmission(this.db, this.writerAuthority);
261
+ this.workAuthority++;
262
+ try {
263
+ return this.atomic(() => reconcileWork(this.db, request, input));
264
+ }
265
+ finally {
266
+ this.workAuthority--;
267
+ }
268
+ }
269
+ reconciliation(id) {
270
+ const row = this.db.prepare("SELECT record_json FROM build_reconciliations WHERE reconciliation_id=?").get(id);
271
+ return row ? JSON.parse(row.record_json) : undefined;
272
+ }
273
+ workDefinitions(runId) {
274
+ return this.db.prepare("SELECT work_id,kind,definition_json FROM build_work_scope WHERE run_id=? AND state='admitted' ORDER BY rowid").all(runId).map(row => ({ workId: row.work_id, kind: row.kind, definition: JSON.parse(row.definition_json) }));
275
+ }
276
+ recordWorkAssignment(runId, workId, operationId, record) {
277
+ this.assertInstructionOwner(runId);
278
+ const admission = this.assertAdmittedWork(runId, workId);
279
+ this.qaPreparationStore().metric(runId, workId, admission.requirementsDigest, `metric:started:${runId}:${workId}:${admission.requirementsDigest}`, "implementation-started");
280
+ this.workAuthority++;
281
+ try {
282
+ this.db.prepare("INSERT INTO build_assignments VALUES(?,?,?,?,?)").run(operationId, runId, workId, operationId, json(record));
283
+ }
284
+ finally {
285
+ this.workAuthority--;
286
+ }
287
+ }
288
+ admittedWork(runId, workId) { return admittedWork(this.db, runId, workId); }
289
+ assertAdmittedWork(runId, workId) { return assertAdmittedWork(this.db, runId, workId); }
122
290
  /** Atomic local changes only; never await provider or filesystem work here. */
123
291
  atomic(work) { return this.db.transaction(work).immediate(); }
124
292
  buildAdmission() { return readAdmission(this.db); }
@@ -685,6 +853,9 @@ export class WorkflowDb {
685
853
  this.ensureRun(input.runId, "build", now);
686
854
  const at = now.toISOString();
687
855
  const safePayload = sanitizeContinuityValue(input.payload);
856
+ const graphAccess = currentGraphDerivedAccess();
857
+ if (graphAccess)
858
+ registerGraphDerived(this.db, json(safePayload), graphAccess);
688
859
  const session = sessionParts(input.sessionRef, undefined, input.sessionKey);
689
860
  const digest = digestJson({ runId: input.runId, role: input.role, kind: input.kind, payload: safePayload, authoritativeStateRevision: input.authoritativeStateRevision, sessionKey: session.key, at });
690
861
  const result = this.db.prepare(`INSERT INTO continuity_events(run_id,role,kind,payload_json,digest,authoritative_state_revision,session_key,session_ref_json,created_at)
@@ -693,6 +864,8 @@ export class WorkflowDb {
693
864
  }
694
865
  continuityEvents(runId, afterSequence = 0) {
695
866
  const rows = this.db.prepare("SELECT * FROM continuity_events WHERE run_id=? AND sequence>? ORDER BY sequence").all(runId, afterSequence);
867
+ for (const row of rows)
868
+ this.assertGraphContinuityAccess(row.payload_json);
696
869
  return rows.map(continuityEventFromRow);
697
870
  }
698
871
  publishContinuityCheckpoint(input, now = new Date()) {
@@ -702,6 +875,9 @@ export class WorkflowDb {
702
875
  const previous = this.continuityHead(input.runId, input.role);
703
876
  const latestEvent = this.db.prepare("SELECT COALESCE(MAX(sequence),0) AS sequence FROM continuity_events WHERE run_id=?").get(input.runId);
704
877
  const safeDelta = sanitizeContinuityValue(input.delta);
878
+ const graphAccess = currentGraphDerivedAccess();
879
+ if (graphAccess)
880
+ registerGraphDerived(this.db, json(safeDelta), graphAccess);
705
881
  const session = sessionParts(input.sessionRef, undefined, input.sessionKey);
706
882
  const digest = digestJson({ runId: input.runId, role: input.role, sequence: latestEvent.sequence, predecessorDigest: previous?.digest, delta: safeDelta, authoritativeStateRevision: input.authoritativeStateRevision, sessionKey: session.key });
707
883
  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)
@@ -723,12 +899,21 @@ export class WorkflowDb {
723
899
  const rows = role
724
900
  ? this.db.prepare("SELECT * FROM continuity_checkpoints WHERE run_id=? AND role=? ORDER BY checkpoint_id").all(runId, role)
725
901
  : this.db.prepare("SELECT * FROM continuity_checkpoints WHERE run_id=? ORDER BY checkpoint_id").all(runId);
902
+ for (const row of rows)
903
+ this.assertGraphContinuityAccess(row.delta_json);
726
904
  return rows.map(continuityCheckpointFromRow);
727
905
  }
728
906
  latestContinuityCheckpoint(runId, role) {
729
907
  const row = this.db.prepare("SELECT * FROM continuity_checkpoints WHERE run_id=? AND role=? ORDER BY checkpoint_id DESC LIMIT 1").get(runId, role);
908
+ if (row)
909
+ this.assertGraphContinuityAccess(row.delta_json);
730
910
  return row ? continuityCheckpointFromRow(row) : undefined;
731
911
  }
912
+ assertGraphContinuityAccess(value) {
913
+ const digest = createHash("sha256").update(value).digest("hex");
914
+ if (!graphDerivedAllowed(this.db, this.projectDir, digest))
915
+ throw new Error("Continuity contains revoked graph-derived evidence; explicit source-based recovery is required");
916
+ }
732
917
  continuityHead(runId, role) {
733
918
  const row = this.db.prepare("SELECT * FROM continuity_heads WHERE run_id=? AND role=?").get(runId, role);
734
919
  return row ? continuityHeadFromRow(row) : undefined;
@@ -858,6 +1043,18 @@ export class WorkflowDb {
858
1043
  })();
859
1044
  return decision;
860
1045
  }
1046
+ cancelPendingHumanDecisions(runId, reason, now = new Date()) {
1047
+ this.atomic(() => {
1048
+ for (const decision of this.pendingHumanDecisions(runId)) {
1049
+ const next = { ...decision, status: "cancelled", cancellationReason: reason, cancelledAt: now.toISOString() };
1050
+ this.db.prepare("UPDATE human_decisions SET status='cancelled',decision_json=?,updated_at=? WHERE decision_id=? AND status='pending'").run(json(next), now.toISOString(), decision.decisionId);
1051
+ this.insertEvent(runId, "human_decision_cancelled", "cancelled", { decisionId: decision.decisionId, reason }, now.toISOString());
1052
+ }
1053
+ });
1054
+ }
1055
+ humanDecisionKey(decisionId) {
1056
+ return this.db.prepare("SELECT decision_key FROM human_decisions WHERE decision_id=?").get(decisionId)?.decision_key;
1057
+ }
861
1058
  humanDecision(decisionId) {
862
1059
  const row = this.db.prepare("SELECT decision_json FROM human_decisions WHERE decision_id=?").get(decisionId);
863
1060
  return row ? parseJson(row.decision_json) : undefined;
@@ -866,21 +1063,39 @@ export class WorkflowDb {
866
1063
  return this.db.prepare("SELECT decision_json FROM human_decisions WHERE run_id=? AND status='pending' ORDER BY created_at").all(runId)
867
1064
  .map((row) => parseJson(row.decision_json));
868
1065
  }
1066
+ decisionContinuationAvailable(runId, decisionId) {
1067
+ const prior = this.operations(runId).filter(op => op.kind === "decision-continuation" && op.intent.decisionId === decisionId);
1068
+ return prior.every(op => op.status === "failed" && op.result?.dispatchState === "not-sent");
1069
+ }
1070
+ nextDecisionContinuationKey(runId, decisionId) {
1071
+ if (!this.decisionContinuationAvailable(runId, decisionId))
1072
+ throw new Error("Decision continuation requires reconciliation before replay");
1073
+ const count = this.operations(runId).filter(op => op.kind === "decision-continuation" && op.intent.decisionId === decisionId).length;
1074
+ return `decision-continuation:${decisionId}${count ? `:retry-${count}` : ""}`;
1075
+ }
869
1076
  answeredTicketDecisions(runId, scopeRevision) {
870
1077
  return this.db.prepare("SELECT decision_key,decision_json FROM human_decisions WHERE run_id=? AND status='answered'").all(runId)
871
1078
  .filter(row => row.decision_key.includes(`:${scopeRevision}:`))
872
1079
  .map(row => parseJson(row.decision_json))
873
- .filter(decision => decision.interruptionId.startsWith("ticket:") && !this.operation(`decision-continuation:${decision.decisionId}`) && !this.operation(`decision-supersession:${decision.decisionId}`));
1080
+ .filter(decision => decision.interruptionId.startsWith("ticket:") && this.decisionContinuationAvailable(runId, decision.decisionId) && !this.operation(`decision-supersession:${decision.decisionId}`));
874
1081
  }
875
1082
  /** Unconsumed answers to an older definition must not silently reopen a ticket. */
876
1083
  staleTicketDecisions(runId, scopeRevision) {
877
1084
  return this.db.prepare("SELECT decision_key,decision_json FROM human_decisions WHERE run_id=? AND status='answered'").all(runId)
878
1085
  .filter(row => !row.decision_key.includes(`:${scopeRevision}:`))
879
1086
  .map(row => parseJson(row.decision_json))
880
- .filter(decision => decision.interruptionId.startsWith("ticket:") && !this.operation(`decision-continuation:${decision.decisionId}`) && !this.operation(`decision-supersession:${decision.decisionId}`));
1087
+ .filter(decision => decision.interruptionId.startsWith("ticket:") && this.decisionContinuationAvailable(runId, decision.decisionId) && !this.operation(`decision-supersession:${decision.decisionId}`));
881
1088
  }
882
1089
  refreshStaleTicketDecisions(runId, scopeRevision, tickets) {
883
1090
  this.atomic(() => {
1091
+ for (const prior of this.pendingHumanDecisions(runId)) {
1092
+ const key = this.humanDecisionKey(prior.decisionId) ?? "";
1093
+ if (!prior.interruptionId.startsWith("ticket:") || tickets && !tickets.includes(prior.interruptionId.slice(7)) || key.includes(`:${scopeRevision}:`) || !(key.startsWith(`${runId}:builder:`) || key.startsWith(`${runId}:ticket-question:`)))
1094
+ continue;
1095
+ this.ensureHumanDecision({ runId, decisionKey: `${runId}:ticket-question:${scopeRevision}:supersedes:${prior.decisionId}`, interruptionId: prior.interruptionId, prompt: prior.prompt, choices: prior.choices, evidence: prior.evidence });
1096
+ this.db.prepare("UPDATE human_decisions SET status='cancelled',decision_json=?,updated_at=? WHERE decision_id=? AND status='pending'").run(json({ ...prior, status: "cancelled", cancellationReason: "ticket scope changed before the question was answered" }), new Date().toISOString(), prior.decisionId);
1097
+ this.insertEvent(runId, "human_decision_superseded", "question-revised", { decisionId: prior.decisionId, scopeRevision }, new Date().toISOString());
1098
+ }
884
1099
  for (const prior of this.staleTicketDecisions(runId, scopeRevision)) {
885
1100
  if (tickets && !tickets.includes(prior.interruptionId.slice(7)))
886
1101
  continue;
@@ -962,6 +1177,12 @@ export class WorkflowDb {
962
1177
  this.db.transaction(() => {
963
1178
  this.db.prepare("UPDATE operation_journal SET status=?,result_json=?,external_id=?,error=?,updated_at=? WHERE idempotency_key=?")
964
1179
  .run(status, details.result === undefined ? null : json(details.result), details.externalId ?? null, details.error ?? null, at, idempotencyKey);
1180
+ if (status === "confirmed" && ["ticket-complete", "synthetic-completion"].includes(prior.kind)) {
1181
+ const workId = prior.intent.workId ?? prior.intent.ticket ?? prior.intent.ticketId;
1182
+ const admission = workId && this.admittedWork(prior.runId, workId);
1183
+ if (admission)
1184
+ this.qaPreparationStore().metric(prior.runId, workId, admission.requirementsDigest, `metric:completed:${prior.runId}:${workId}`, "completed", { status: this.getRun(prior.runId)?.state?.qaEnabled === false ? "disabled" : undefined, at });
1185
+ }
965
1186
  this.appendContinuityEvent({ runId: prior.runId, role: "host", kind: "operation_receipt", payload: { idempotencyKey, kind: prior.kind, status, externalId: details.externalId, error: details.error }, authoritativeStateRevision: this.continuityHead(prior.runId, "run")?.authoritativeStateRevision ?? 0 }, now);
966
1187
  })();
967
1188
  return this.operation(idempotencyKey);
@@ -995,9 +1216,15 @@ export class WorkflowDb {
995
1216
  throw new Error(`${kind} evidence exceeds the durable ${maximum}-byte item limit`);
996
1217
  const digest = createHash("sha256").update(bytes).digest("hex");
997
1218
  this.db.prepare("INSERT OR IGNORE INTO content_refs(digest,kind,content,created_at) VALUES(?,?,?,?)").run(digest, kind, bytes, now.toISOString());
1219
+ const graphAccess = currentGraphDerivedAccess();
1220
+ if (graphAccess && (kind === "qa" || kind === "handoff"))
1221
+ registerGraphDerived(this.db, bytes, graphAccess);
998
1222
  return digest;
999
1223
  }
1224
+ registerGraphEvidence(value, access) { registerGraphDerived(this.db, value, access); }
1000
1225
  getEvidence(digest) {
1226
+ if (!graphDerivedAllowed(this.db, this.projectDir, digest))
1227
+ return undefined;
1001
1228
  const row = this.db.prepare("SELECT content FROM content_refs WHERE digest=?").get(digest);
1002
1229
  return row?.content;
1003
1230
  }
@@ -1052,6 +1279,11 @@ export class WorkflowDb {
1052
1279
  const next = { ...prior, ...patch, updatedAt: now.toISOString() };
1053
1280
  this.db.prepare("UPDATE qa_review_attempts SET status=?,report_digest=?,record_json=?,updated_at=? WHERE attempt_id=?")
1054
1281
  .run(next.status, next.reportDigest ?? null, json({ findingIds: next.findingIds, namespacedFindingIds: next.namespacedFindingIds, detail: next.detail }), next.updatedAt, attemptId);
1282
+ const admission = this.admittedWork(next.runId, next.ticketId);
1283
+ 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);
1284
+ 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()); });
1285
+ if (admission && (next.status === "passed" || next.status === "failed" || terminalBlock))
1286
+ 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] : [] });
1055
1287
  return next;
1056
1288
  }
1057
1289
  qaReviewAttempts(runId, ticketId) {
@@ -1107,6 +1339,8 @@ export class WorkflowDb {
1107
1339
  .run(record.invocationId, record.runId, record.ticketId, qaDigest("handback-invocation", intent), json(intent), record.status, json(record), record.startedAt, record.completedAt ?? new Date().toISOString());
1108
1340
  }
1109
1341
  recordQaDeliveryTurn(turn) {
1342
+ if (turn.version === 4)
1343
+ ensureQaGraphJournalGuards(this.db);
1110
1344
  const prior = this.qaDeliveryTurns(turn.operationId).find(t => t.turnRecordId === turn.turnRecordId);
1111
1345
  if (prior) {
1112
1346
  if (prior.status !== "intended") {
@@ -1146,7 +1380,8 @@ export class WorkflowDb {
1146
1380
  if (linked.size !== recoveries.length)
1147
1381
  throw new Error("QA remediation budget requires legacy attempt reconciliation");
1148
1382
  const authorized = new Set(this.db.prepare("SELECT consumed_by FROM qa_remediation_authorizations WHERE run_id=? AND ticket_id=? AND consumed_by IS NOT NULL").all(runId, ticketId).map(r => r.consumed_by));
1149
- return attempts.filter(a => !authorized.has(a.attemptId)).length;
1383
+ const followups = this.operations(runId).filter(operation => operation.kind === "build-assignment" && operation.intent.ticketId === ticketId && operation.intent.managerFollowup);
1384
+ return attempts.filter(a => !authorized.has(a.attemptId)).length + followups.filter(operation => !authorized.has(operation.idempotencyKey)).length;
1150
1385
  }
1151
1386
  authorizeQaRemediation(runId, ticketId, reviewAttemptId, reason) {
1152
1387
  const authorizationId = randomUUID();
@@ -1397,6 +1632,12 @@ export class WorkflowDb {
1397
1632
  return this.db.transaction(() => {
1398
1633
  const predecessors = this.unresolvedQaReports(input.runId, input.ticketId);
1399
1634
  const current = this.recordQaReport(input, findingIds, now);
1635
+ const admission = this.admittedWork(input.runId, input.ticketId), preparation = this.qaPreparationStore();
1636
+ if (admission && preparation.policy(input.runId)) {
1637
+ const evidence = preparation.putArtifact("finding-classification-input", { report: input, contractHead: preparation.head(input.runId, input.ticketId, admission.requirementsDigest), evidenceDisposition: "Cause remains unknown until supported assessment" });
1638
+ for (const findingId of findingIds)
1639
+ preparation.metric(input.runId, input.ticketId, admission.requirementsDigest, `metric:finding-unknown:${current.reportOccurrenceId}:${findingId}`, "finding-classification", { findingId, classifier: "host-evidence-boundary", cause: "unknown", confidence: "unknown", evidenceRefs: [evidence], at: now.toISOString() });
1640
+ }
1400
1641
  for (const predecessor of predecessors) {
1401
1642
  if (predecessor.reportOccurrenceId === current.reportOccurrenceId)
1402
1643
  continue;
@@ -1468,13 +1709,38 @@ export class WorkflowDb {
1468
1709
  for (const report of this.unresolvedQaReports(runId, ticketId))
1469
1710
  this.setQaReportDisposition(report.reportOccurrenceId, "verified-fixed", "subsequent bound QA review passed", now);
1470
1711
  }
1471
- commitQaWaiver(runId, ticketId, expectedRevision, reason, now = new Date()) {
1712
+ authorizeContractQaWaiver(runId, workId, decisionId) {
1713
+ this.assertInstructionOwner(runId);
1714
+ const decision = this.humanDecision(decisionId);
1715
+ if (!decision || decision.runId !== runId || decision.status !== "answered" || decision.selectedChoiceId !== "yes" || decision.interruptionId !== `ticket:${workId}` || !this.humanDecisionKey(decisionId)?.startsWith(`${runId}:qa-nonconvergence:`) || !decision.prompt.includes("QA waiver confirmation"))
1716
+ throw new Error("Whole-work waiver requires the actual answered operator confirmation");
1717
+ const admission = this.assertAdmittedWork(runId, workId), store = this.qaPreparationStore(), head = store.head(runId, workId, admission.requirementsDigest), qa = this.qaTicketHead(runId, workId);
1718
+ if (head.state !== "ready" || !head.digest)
1719
+ throw new Error("Whole-work waiver requires a current ready contract");
1720
+ return store.putArtifact("waiver-authorization", { decisionId, runId, workId, admissionDigest: admission.requirementsDigest, contractDigest: head.digest, qaRevision: qa.revision, sourceDigest: qa.sourceStateDigest, reviewBasisDigest: qa.reviewBasisDigest, authorizedAt: decision.answeredAt });
1721
+ }
1722
+ commitQaWaiver(runId, ticketId, expectedRevision, reason, now = new Date(), authorizationRef) {
1472
1723
  return this.db.transaction(() => {
1473
1724
  const head = this.qaTicketHead(runId, ticketId);
1474
1725
  if (head.revision !== expectedRevision)
1475
1726
  throw new Error(`QA waiver raced for ${runId}/${ticketId}`);
1727
+ const store = this.qaPreparationStore();
1728
+ if (store.policy(runId)?.mode === "enforce") {
1729
+ const admission = this.assertAdmittedWork(runId, ticketId), contractHead = store.head(runId, ticketId, admission.requirementsDigest);
1730
+ if (contractHead.state !== "ready" || !contractHead.digest || !reason.trim())
1731
+ throw new Error("Whole-work waiver requires exact contract scope and operator reason");
1732
+ const authority = authorizationRef ? store.artifact(authorizationRef, "waiver-authorization") : undefined;
1733
+ const decision = authority && this.humanDecision(authority.decisionId);
1734
+ if (!authority || authority.runId !== runId || authority.workId !== ticketId || authority.admissionDigest !== admission.requirementsDigest || authority.contractDigest !== contractHead.digest || authority.qaRevision !== expectedRevision || authority.sourceDigest !== head.sourceStateDigest || authority.reviewBasisDigest !== head.reviewBasisDigest || decision?.status !== "answered" || decision.selectedChoiceId !== "yes")
1735
+ throw new Error("Missing or stale whole-work waiver authorization");
1736
+ const contract = store.contract(contractHead.digest), review = this.qaReviewAttempts(runId, ticketId).at(-1);
1737
+ store.event(runId, ticketId, `whole-work-waiver:${runId}:${ticketId}:${head.revision}`, "whole-work-waiver", { version: 1, runId, workId: ticketId, admissionDigest: admission.requirementsDigest, contractDigest: contract.contentDigest, revision: contract.revision, sourceDigest: review?.sourceDigest ?? null, reviewBasisDigest: head.reviewBasisDigest ?? null, mandatoryCheckIds: contract.checks.filter(check => check.obligation === "mandatory").map(check => check.id), unresolvedReports: this.unresolvedQaReports(runId, ticketId).map(report => report.reportOccurrenceId), reason, actor: "operator", authorizationRef, decisionId: authority.decisionId, mechanism: "existing-qa-nonconvergence-decision", at: now.toISOString(), completion: "waived" });
1738
+ }
1476
1739
  for (const report of this.unresolvedQaReports(runId, ticketId))
1477
1740
  this.setQaReportDisposition(report.reportOccurrenceId, "waived", reason, now);
1741
+ const admission = this.admittedWork(runId, ticketId);
1742
+ if (admission)
1743
+ store.metric(runId, ticketId, admission.requirementsDigest, `metric:waiver:${runId}:${ticketId}:${head.revision}`, "waived", { at: now.toISOString() });
1478
1744
  return this.transitionQa(runId, ticketId, head.revision, { type: "waived" }, now);
1479
1745
  })();
1480
1746
  }
@@ -1499,6 +1765,11 @@ export class WorkflowDb {
1499
1765
  return this.db.transaction(() => {
1500
1766
  this.recordBuilderRemediationReceipt(receipt);
1501
1767
  this.markQaReportsRecheckRequired(receipt.runId, receipt.ticketId, "Builder remediation received; QA recheck required", now);
1768
+ const admission = this.admittedWork(receipt.runId, receipt.ticketId);
1769
+ const remediation = this.qaRemediationAttempt(receipt.operationId);
1770
+ const review = remediation ? this.qaReviewAttempt(remediation.reviewAttemptId) : undefined;
1771
+ if (admission)
1772
+ this.qaPreparationStore().metric(receipt.runId, receipt.ticketId, admission.requirementsDigest, `metric:remediation:${receipt.operationId}`, "remediation", { phase: "remediation", findingIds: review?.namespacedFindingIds ?? [], at: now.toISOString() });
1502
1773
  return this.transitionQa(receipt.runId, receipt.ticketId, expectedRevision, { type: "remediation-received" }, now);
1503
1774
  })();
1504
1775
  }
@@ -1520,7 +1791,60 @@ export class WorkflowDb {
1520
1791
  return this.transitionQa(remediation.runId, remediation.ticketId, head.revision, { type: input.outcome === "uncertain" ? "remediation-uncertain" : "remediation-failed" }, now);
1521
1792
  })();
1522
1793
  }
1794
+ assertContractCertificate(input) {
1795
+ const store = this.qaPreparationStore();
1796
+ if (store.policy(input.runId)?.mode !== "enforce")
1797
+ return;
1798
+ const binding = input.contractCoverage;
1799
+ if (!binding || binding.version !== 1)
1800
+ throw new Error("Enforcing pass requires tagged contract coverage authority");
1801
+ const admission = this.assertAdmittedWork(input.runId, input.ticketId);
1802
+ const head = store.head(input.runId, input.ticketId, admission.requirementsDigest);
1803
+ if (head.state !== "ready" || head.digest !== binding.contractDigest)
1804
+ throw new Error("Pass contract is stale or incomplete");
1805
+ const contract = store.contract(binding.contractDigest);
1806
+ if (contract.revision !== binding.revision)
1807
+ throw new Error("Pass revision mismatch");
1808
+ const basisRow = this.db.prepare("SELECT basis_json FROM qa_review_bases WHERE digest=? AND run_id=? AND ticket_id=?").get(input.reviewBasisDigest, input.runId, input.ticketId);
1809
+ if (!basisRow)
1810
+ throw new Error("Pass lacks immutable input basis");
1811
+ const basis = parseJson(basisRow.basis_json);
1812
+ const definition = this.workDefinitions(input.runId).find(work => work.workId === input.ticketId)?.definition;
1813
+ if (!definition || basis.ticketDigest !== qaDigest("ticket", definition))
1814
+ throw new Error("Pass ticket basis differs from frozen admitted scope");
1815
+ const { digest, ...fields } = basis;
1816
+ if (digest !== qaDigest("review-basis-fields", fields) || basis.contractBinding?.digest !== contract.contentDigest || basis.contractBinding.admissionDigest !== admission.requirementsDigest)
1817
+ throw new Error("Pass basis/contract binding mismatch");
1818
+ const configuredMake = this.getRun(input.runId)?.state?.qa?.settings?.make;
1819
+ if (configuredMake && resolveEffectiveQaConfiguration(this.projectDir, { make: configuredMake }).digest !== basis.contractBinding.commonConfigDigest)
1820
+ throw new Error("Effective QA configuration changed before pass authority; reconcile contract");
1821
+ const attempt = this.qaReviewAttempt(binding.attemptId);
1822
+ if (!attempt || attempt.runId !== input.runId || attempt.ticketId !== input.ticketId || attempt.sourceDigest !== input.sourceStateDigest || attempt.reviewNumber !== this.qaTicketHead(input.runId, input.ticketId).reviewNumber)
1823
+ throw new Error("Coverage review attempt is stale or foreign");
1824
+ const coverage = store.artifact(binding.coverageDigest, "final-coverage");
1825
+ for (const check of contract.checks)
1826
+ for (const method of check.verification)
1827
+ if (method.equivalentAuthorityId && !store.equivalentResolver()(method.equivalentAuthorityId, contract, check, method, input.sourceStateDigest))
1828
+ throw new Error("Equivalent verification authority is stale or foreign");
1829
+ const errors = validateContractCoverage(contract, coverage, { phase: "qa", sourceDigest: input.sourceStateDigest, inputBasisDigest: input.reviewBasisDigest, attemptId: binding.attemptId, sessionId: binding.sessionId });
1830
+ if (errors.length)
1831
+ throw new Error(errors.join("; "));
1832
+ 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);
1833
+ const initial = initialTurns.map(row => parseJson(row.intent_json)).find(turn => turn.slot === "initial");
1834
+ if (!initial || initial.providerSession.sessionId !== binding.sessionId)
1835
+ throw new Error("Coverage lacks the actual fresh review dispatch identity");
1836
+ const dispatchedInstruction = this.getEvidence(initial.instructionDigest)?.toString();
1837
+ const envelope = `\nImmutable input-basis digest: ${basis.digest}. Coverage must bind this inputBasisDigest.`;
1838
+ if (basis.contractBinding.transportVersion !== 1 || !dispatchedInstruction?.endsWith(envelope) || qaDigest("instruction", dispatchedInstruction.slice(0, -envelope.length)) !== basis.instructionDigest)
1839
+ throw new Error("Actual review dispatch differs from frozen base instruction and tagged input-basis envelope");
1840
+ 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);
1841
+ const receipt = receipts.map(row => parseJson(row.receipt_json)).find(row => qaDigest("turn-receipt", row) === input.turnReceiptDigest && row.terminalEventObserved);
1842
+ const response = receipt?.cleanedResponseDigest ? this.getEvidence(receipt.cleanedResponseDigest) : undefined;
1843
+ if (!receipt || !response || canonicalJson(parseContractCoverage(response.toString())) !== canonicalJson(coverage))
1844
+ throw new Error("Coverage is not bound to the retained terminal review receipt");
1845
+ }
1523
1846
  issueQaPassCertificate(input, now = new Date()) {
1847
+ this.assertContractCertificate(input);
1524
1848
  if (this.unresolvedQaReports(input.runId, input.ticketId).length)
1525
1849
  throw new Error("cannot issue QA pass certificate while reports remain unresolved");
1526
1850
  const certificate = { version: 2, certificateId: qaDigest("pass-certificate", { ...input, issuedAt: now.toISOString() }), ...input, unresolvedReportCount: 0, issuedAt: now.toISOString() };
@@ -1545,6 +1869,7 @@ export class WorkflowDb {
1545
1869
  || attempt.sourceDigest !== input.sourceStateDigest || attempt.status !== "started") {
1546
1870
  throw new Error(`QA pass attempt binding mismatch: ${attemptId}`);
1547
1871
  }
1872
+ this.assertContractCertificate(input);
1548
1873
  this.finishQaReviewAttempt(attemptId, { status: "passed", detail }, now);
1549
1874
  return this.commitQaPass(input, expectedRevision, now);
1550
1875
  })();
@@ -1556,6 +1881,7 @@ export class WorkflowDb {
1556
1881
  throw new Error("QA pass certificate is missing or scoped to another run/ticket");
1557
1882
  if (row.consumed_at)
1558
1883
  throw new Error("QA pass certificate has already been consumed");
1884
+ this.assertContractCertificate(parseJson(row.certificate_json));
1559
1885
  if (this.unresolvedQaReports(runId, ticketId).length)
1560
1886
  throw new Error("QA pass certificate cannot be consumed with unresolved reports");
1561
1887
  const at = now.toISOString();
@@ -1566,9 +1892,9 @@ export class WorkflowDb {
1566
1892
  return certificate;
1567
1893
  })();
1568
1894
  }
1569
- invalidateUnconsumedQaPassCertificates(runId, ticketId, reason, now = new Date()) {
1895
+ invalidateUnconsumedQaPassCertificates(runId, ticketId, reason, now = new Date(), issuedBefore) {
1570
1896
  const at = now.toISOString();
1571
- this.db.prepare("UPDATE qa_pass_certificates SET consumed_at=?,consumed_by=? WHERE run_id=? AND ticket_id=? AND consumed_at IS NULL").run(at, `invalidated:${reason}`, runId, ticketId);
1897
+ this.db.prepare("UPDATE qa_pass_certificates SET consumed_at=?,consumed_by=? WHERE run_id=? AND ticket_id=? AND consumed_at IS NULL AND (? IS NULL OR issued_at<=?)").run(at, `invalidated:${reason}`, runId, ticketId, issuedBefore ?? null, issuedBefore ?? null);
1572
1898
  }
1573
1899
  invalidateQaPassBeforeFinalization(input, now = new Date()) {
1574
1900
  return this.db.transaction(() => {
@@ -1584,6 +1910,7 @@ export class WorkflowDb {
1584
1910
  }
1585
1911
  beginQaFinalization(input, now = new Date()) {
1586
1912
  return this.db.transaction(() => {
1913
+ this.assertFinalizationControls(input.runId, input.ticketId);
1587
1914
  const head = this.qaTicketHead(input.runId, input.ticketId);
1588
1915
  if (head.revision !== input.expectedRevision || head.state !== "passed")
1589
1916
  throw new Error(`QA finalization start raced for ${input.runId}/${input.ticketId}`);
@@ -1660,10 +1987,14 @@ export class WorkflowDb {
1660
1987
  }
1661
1988
  completeQaFinalization(runId, ticketId, expectedRevision, receipt, now = new Date()) {
1662
1989
  return this.db.transaction(() => {
1990
+ this.assertFinalizationControls(runId, ticketId);
1663
1991
  const head = this.qaTicketHead(runId, ticketId);
1664
1992
  if (head.revision !== expectedRevision || head.state !== "finalizing")
1665
1993
  throw new Error(`QA finalization completion raced for ${runId}/${ticketId}`);
1666
1994
  this.finishPendingQaFinalizationSteps(runId, ticketId, receipt, now);
1995
+ const admission = this.admittedWork(runId, ticketId);
1996
+ if (admission)
1997
+ this.qaPreparationStore().metric(runId, ticketId, admission.requirementsDigest, `metric:completed:${runId}:${ticketId}`, "completed", { at: now.toISOString() });
1667
1998
  return this.transitionQa(runId, ticketId, head.revision, { type: "completed" }, now);
1668
1999
  })();
1669
2000
  }
@@ -2272,7 +2603,7 @@ function leaseVerifiedLive(lease, now, staleMs) {
2272
2603
  function ensureRecoveryGitignore(projectDir) {
2273
2604
  const localExclude = join(projectDir, ".git", "info", "exclude");
2274
2605
  const path = existsSync(localExclude) ? localExclude : join(projectDir, ".gitignore");
2275
- const entries = [WORKFLOW_DB_FILE, `${WORKFLOW_DB_FILE}-wal`, `${WORKFLOW_DB_FILE}-shm`, ".rafi/cache/handoffs/"];
2606
+ const entries = [WORKFLOW_DB_FILE, `${WORKFLOW_DB_FILE}-wal`, `${WORKFLOW_DB_FILE}-shm`, ".rafi/cache/handoffs/", ".rafi/backups/work-admission-v4/"];
2276
2607
  const existing = existsSync(path) ? readFileSync(path, "utf8") : "";
2277
2608
  const missing = entries.filter((entry) => !existing.split(/\r?\n/).includes(entry));
2278
2609
  if (missing.length)