ai-foreman 1.7.29 → 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 (136) hide show
  1. package/README.md +1 -1
  2. package/dist/adapters/claude.d.ts +23 -12
  3. package/dist/adapters/claude.js +227 -23
  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 +85 -9
  11. package/dist/agentRun.d.ts +2 -0
  12. package/dist/agentRun.js +2 -1
  13. package/dist/boundedTurnEvidence.d.ts +18 -0
  14. package/dist/boundedTurnEvidence.js +76 -0
  15. package/dist/branch/currentGuard.d.ts +2 -0
  16. package/dist/branch/currentGuard.js +2 -0
  17. package/dist/branch/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 +15 -0
  22. package/dist/buildApproval.js +25 -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 +22 -26
  31. package/dist/cli/tickets.d.ts +0 -7
  32. package/dist/cli/tickets.js +41 -40
  33. package/dist/continuity.d.ts +10 -14
  34. package/dist/continuity.js +198 -179
  35. package/dist/continuityOrigin.d.ts +13 -0
  36. package/dist/continuityOrigin.js +91 -0
  37. package/dist/foreman.d.ts +10 -4
  38. package/dist/foreman.js +51 -193
  39. package/dist/graph/boundary.d.ts +1 -1
  40. package/dist/graph/boundary.js +1 -1
  41. package/dist/graph/host.js +8 -19
  42. package/dist/graph/manager.d.ts +1 -1
  43. package/dist/graph/manager.js +1 -1
  44. package/dist/graph/preparation.d.ts +38 -0
  45. package/dist/graph/preparation.js +7 -6
  46. package/dist/graph/semantic.d.ts +1 -0
  47. package/dist/graph/semantic.js +4 -1
  48. package/dist/graph/session.js +7 -2
  49. package/dist/graph/turn.d.ts +2 -3
  50. package/dist/graph/turn.js +28 -40
  51. package/dist/handoffProtocol.js +2 -2
  52. package/dist/handoffs.d.ts +3 -1
  53. package/dist/handoffs.js +90 -59
  54. package/dist/hostGraphApplicability.d.ts +2 -1
  55. package/dist/hostGraphApplicability.js +17 -19
  56. package/dist/hostQaAugmentation.js +21 -4
  57. package/dist/hostQaComparison.d.ts +21 -2
  58. package/dist/hostQaComparison.js +43 -29
  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 +47 -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 +4 -9
  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/qaFailureDelivery.js +97 -85
  83. package/dist/qaHostTransformation.d.ts +26 -0
  84. package/dist/qaHostTransformation.js +100 -0
  85. package/dist/qaPreparation.d.ts +8 -20
  86. package/dist/qaPreparation.js +133 -248
  87. package/dist/qaPreparationInputs.js +19 -0
  88. package/dist/qaPrerequisites.d.ts +2 -1
  89. package/dist/qaPrerequisites.js +21 -1
  90. package/dist/qaProtocolV2.d.ts +15 -0
  91. package/dist/qaRecovery.js +21 -4
  92. package/dist/qaReview.js +220 -395
  93. package/dist/qaReviewEvidence.d.ts +7 -3
  94. package/dist/qaReviewEvidence.js +15 -4
  95. package/dist/qaRuntime.js +1 -1
  96. package/dist/qaSnapshot.js +71 -21
  97. package/dist/qaStructuredResponse.d.ts +10 -0
  98. package/dist/qaStructuredResponse.js +57 -0
  99. package/dist/qaTimeline.js +5 -3
  100. package/dist/qaVerificationContract.d.ts +1 -2
  101. package/dist/qaVerificationContract.js +24 -35
  102. package/dist/readinessGate.js +5 -4
  103. package/dist/runtimeReadiness.js +23 -15
  104. package/dist/statusReporter.d.ts +2 -0
  105. package/dist/statusReporter.js +2 -0
  106. package/dist/structuredCutover.d.ts +31 -0
  107. package/dist/structuredCutover.js +510 -0
  108. package/dist/structuredCutoverFiles.d.ts +10 -0
  109. package/dist/structuredCutoverFiles.js +181 -0
  110. package/dist/structuredDispatchAuthority.d.ts +22 -0
  111. package/dist/structuredDispatchAuthority.js +110 -0
  112. package/dist/structuredDispatchRecord.d.ts +29 -0
  113. package/dist/structuredDispatchRecord.js +36 -0
  114. package/dist/structuredLiveBudget.d.ts +2 -0
  115. package/dist/structuredLiveBudget.js +23 -0
  116. package/dist/structuredOperation.d.ts +18 -0
  117. package/dist/structuredOperation.js +257 -0
  118. package/dist/supervisedStart.js +35 -21
  119. package/dist/ticketPlanning.d.ts +1 -373
  120. package/dist/ticketPlanning.js +2 -26
  121. package/dist/ticketPopulation.d.ts +0 -3
  122. package/dist/ticketPopulation.js +4 -21
  123. package/dist/ticketPopulationPageAuthority.d.ts +13 -0
  124. package/dist/ticketPopulationPageAuthority.js +30 -0
  125. package/dist/ticketPopulationPaging.d.ts +27 -0
  126. package/dist/ticketPopulationPaging.js +105 -0
  127. package/dist/tickets/renderMarkdown.d.ts +14 -0
  128. package/dist/tickets/renderMarkdown.js +39 -5
  129. package/dist/tickets/ticketLoader.js +10 -0
  130. package/dist/tickets/ticketSchema.d.ts +107 -0
  131. package/dist/tickets/ticketSchema.js +4 -1
  132. package/dist/workflowDb.d.ts +100 -2
  133. package/dist/workflowDb.js +763 -39
  134. package/dist/workflowReader.d.ts +1 -0
  135. package/dist/workflowReader.js +9 -0
  136. package/package.json +15 -7
@@ -0,0 +1,22 @@
1
+ import type { QaContractCoverageV1, QaVerificationContractV1 } from "rafi-spec";
2
+ import type { HostQaComparisonV1 } from "./hostQaComparison.js";
3
+ import type { HostGraphApplicabilityV1 } from "./hostGraphApplicability.js";
4
+ /**
5
+ * Assemble the durable final QA coverage in one order for both finalization
6
+ * and certificate validation. The provider-owned rows remain the input; all
7
+ * host-owned authority is applied here and is never reconstructed implicitly.
8
+ */
9
+ export declare function assembleFinalQaCoverage(input: {
10
+ providerCoverage: QaContractCoverageV1;
11
+ contract: QaVerificationContractV1;
12
+ projectDir: string;
13
+ commandObservations?: unknown[];
14
+ hostComparison?: {
15
+ result: HostQaComparisonV1;
16
+ resultDigest: string;
17
+ };
18
+ hostGraph?: {
19
+ result: HostGraphApplicabilityV1;
20
+ resultDigest: string;
21
+ };
22
+ }): QaContractCoverageV1;
@@ -0,0 +1,16 @@
1
+ import { applyHostQaAugmentations } from "./hostQaAugmentation.js";
2
+ /**
3
+ * Assemble the durable final QA coverage in one order for both finalization
4
+ * and certificate validation. The provider-owned rows remain the input; all
5
+ * host-owned authority is applied here and is never reconstructed implicitly.
6
+ */
7
+ export function assembleFinalQaCoverage(input) {
8
+ return applyHostQaAugmentations({
9
+ coverage: input.providerCoverage,
10
+ contract: input.contract,
11
+ projectDir: input.projectDir,
12
+ commandObservations: input.commandObservations,
13
+ hostComparison: input.hostComparison,
14
+ hostGraph: input.hostGraph,
15
+ });
16
+ }
@@ -24,6 +24,8 @@ export interface QaDeliveryInvocationV3 {
24
24
  }>;
25
25
  }
26
26
  export interface QaDeliveryTurnV3 {
27
+ sealedDispatch?: import("./structuredDispatchRecord.js").SealedDispatchRecordV2;
28
+ structuredResponseDigest?: string;
27
29
  version: 3;
28
30
  turnRecordId: string;
29
31
  operationId: string;
@@ -1,11 +1,30 @@
1
+ import { STRUCTURED_AUTHORITY_TABLES } from "./structuredCutover.js";
1
2
  /** Independent of preparation's schema revision: old writers cannot mutate an
2
3
  * evidence-continuation journal they would interpret as a response-only repair. */
3
4
  export function registerQaGraphJournalWriter(db) {
4
5
  db.function("rafi_graph_delivery_protocol", () => 4);
6
+ // A v1 connection must never be able to mutate a journal after the v2
7
+ // cutover. Keep this connection-local function deliberately separate from
8
+ // the graph-delivery writer version.
9
+ db.function("rafi_structured_communication_protocol", () => 2);
10
+ db.function("rafi_structured_authority_revision", () => 3);
5
11
  }
6
12
  export function ensureQaGraphJournalGuards(db) {
7
13
  const tables = ["qa_delivery_turns", "qa_review_attempts", "qa_remediation_attempts", "qa_turns", "qa_ticket_heads", "qa_operation_journal", "qa_pass_certificates", "build_assignments"];
8
14
  db.transaction(() => {
15
+ for (const table of new Set([...STRUCTURED_AUTHORITY_TABLES, ...tables, "workflow_runs", "build_work_scope", "build_work_admissions"])) {
16
+ if (!db.prepare("SELECT 1 FROM sqlite_master WHERE type='table' AND name=?").get(table))
17
+ continue;
18
+ for (const action of ["INSERT", "UPDATE", "DELETE"]) {
19
+ // The old guard was an authority bypass: an old writer could continue
20
+ // to mutate records that the v2 reader treats as sealed. Dropping it
21
+ // is safe and idempotent because this migration installs its v2
22
+ // replacement in the same SQLite transaction.
23
+ db.exec(`DROP TRIGGER IF EXISTS structured_comms_v1_${table}_${action}`);
24
+ db.exec(`CREATE TRIGGER IF NOT EXISTS structured_authority_v3_${table}_${action} BEFORE ${action} ON ${table} BEGIN SELECT CASE WHEN rafi_structured_authority_revision() <> 3 THEN RAISE(ABORT,'Structured authority revision 3 writer required') END; END;`);
25
+ db.exec(`CREATE TRIGGER IF NOT EXISTS structured_comms_v2_${table}_${action} BEFORE ${action} ON ${table} BEGIN SELECT CASE WHEN rafi_structured_communication_protocol() <> 2 THEN RAISE(ABORT,'Structured communication v2 writer required') END; END;`);
26
+ }
27
+ }
9
28
  for (const table of tables) {
10
29
  if (!db.prepare("SELECT 1 FROM sqlite_master WHERE type='table' AND name=?").get(table))
11
30
  continue;
@@ -1,15 +1,22 @@
1
+ import { BoundedTurnEvents, boundedUtf8 as boundedEvidenceUtf8, receiptFingerprint, rawFingerprint } from "./boundedTurnEvidence.js";
2
+ import { providerWireContract, remediationOutput } from "rafi-spec";
3
+ import { journalStructuredDispatch, operationInstruction, operationResponse, nativePolicy } from "./structuredOperation.js";
4
+ import { durableOwnerDispatch } from "./structuredDispatchAuthority.js";
5
+ import { remediationStructuredResponse } from "./qaStructuredResponse.js";
1
6
  import { dispatchWithGraphAccess } from "./graph/session.js";
2
7
  import { currentGraphDerivedAccess, inheritGraphDerivedAccess, withGraphDerivedAccess } from "./graph/derived.js";
3
8
  import { collectBuilderContractCoverage, assertBuilderContractCoverage } from "./qaBuilderCoverage.js";
9
+ import { observeHostBuilderTests } from "./qaBuilderEvidence.js";
10
+ import { captureBuildSource } from "./buildAssignment.js";
4
11
  import { queueGraphMaintenance, maintainGraphTask } from "./graph/boundary.js";
5
12
  import { ensureBuilderContract } from "./qaBuildGate.js";
6
13
  import { actualContractSession, assertContractReceipt } from "./qaContractDelivery.js";
7
- import { graphContext, parseGraphRequest, prepareGraphInstruction, sendGraphTurn } from "./graph/turn.js";
14
+ import { graphContext, prepareGraphInstruction, sendGraphTurn } from "./graph/turn.js";
8
15
  import { randomUUID } from "node:crypto";
9
16
  import { realpathSync } from "node:fs";
10
17
  import { reportOccurrenceId } from "./qaHandbackMigration.js";
11
18
  import { boundedQaHistory, evidenceDigest, utf8Prefix } from "./qaHandbackHistory.js";
12
- import { BUILDER_QA_REMEDIATION_END, BUILDER_QA_REMEDIATION_START, builderQaRemediationReportV3Schema, parseBuilderQaRemediationContract, } from "rafi-spec";
19
+ import { builderQaRemediationReportV3Schema, } from "rafi-spec";
13
20
  import { withActivityPhase } from "./activity.js";
14
21
  import { captureFrozenQaSourceAsync } from "./qaSnapshot.js";
15
22
  import { canonicalJson, createQaFindingRefs, qaDigest } from "./qaProtocolV2.js";
@@ -92,7 +99,7 @@ export class QaFailureDeliveryService {
92
99
  try {
93
100
  let prior = read.qaFailureHandoff(handoffId);
94
101
  if (prior?.state === "delivery-intended" && read.qaRemediationAttempt(prior.operationId)?.status === "succeeded") {
95
- const historical = read.builderRemediationReceipt(prior.operationId);
102
+ const historical = read.verifiedBuilderRemediationReceipt(prior.operationId);
96
103
  if (!historical || historical.runId !== input.runId || historical.ticketId !== input.ticket.id || historical.reportDigest !== input.reportDigest || !read.getEvidence(historical.responseDigest))
97
104
  throw new Error("Partial historical handback needs receipt reconciliation; no redispatch permitted");
98
105
  const receiptDigest = read.putEvidence("handoff", Buffer.from(canonicalEvidence({ version: 3, outcome: "remediation-reported", legacyEvidenceIncomplete: true, historicalReceipt: historical, reconciledAt: new Date().toISOString(), qaApproved: false })));
@@ -103,6 +110,13 @@ export class QaFailureDeliveryService {
103
110
  const raw = prior.receiptDigest ? read.getEvidence(prior.receiptDigest) : undefined;
104
111
  const receipt = raw ? JSON.parse(raw.toString()) : undefined;
105
112
  const accepted = prior.state === "recheck-required" && read.qaRemediationAttempt(prior.operationId)?.status === "succeeded";
113
+ if (accepted) {
114
+ const verified = read.verifiedBuilderRemediationReceipt(prior.operationId);
115
+ if (!verified || verified.runId !== prior.runId || verified.ticketId !== prior.ticketId
116
+ || verified.reportDigest !== prior.reportDigest || verified.responseDigest !== prior.responseDigest
117
+ || verified.providerTurnId !== prior.providerTurnId)
118
+ throw new Error("Replayed QA remediation handoff lacks its verified native receipt");
119
+ }
106
120
  return { ok: accepted, outcome: receipt?.outcome ?? (accepted ? "remediation-reported" : "delivery-uncertain"), detail: prior.detail ?? "Operation already dispatched; reconcile its durable evidence before further work", handoffId, operationId: prior.operationId,
107
121
  turnRecordId: receipt?.turnRecordId, response: prior.responseDigest ? read.getEvidence(prior.responseDigest)?.toString() : undefined,
108
122
  summary: accepted ? "Previously reported remediation; independent QA required" : undefined, providerTurnId: prior.providerTurnId };
@@ -175,7 +189,7 @@ export class QaFailureDeliveryService {
175
189
  // Byte counts are not token counts. This deliberately conservative bound also
176
190
  // reserves wrapper instructions, response and continuity against known capacity.
177
191
  const graphDeliveryContext = graphContext(input.projectDir, input.builderWorktree, "remediation", instruction, { operationId: `graph-delivery:${operationId}`, logicalTaskId: operationId, sourceRef: preDispatch.digest, runId: input.runId, workId: input.ticket.id, qualifies: input.report.findings.some(f => f.locations.length > 1) });
178
- let graphPrepared = await prepareGraphInstruction(instruction, graphDeliveryContext);
192
+ let graphPrepared = await prepareGraphInstruction(instruction, graphDeliveryContext, undefined, remediationOutput);
179
193
  const usage = await builder.contextUsage?.();
180
194
  if (usage?.maximum && Buffer.byteLength(graphPrepared.instruction) + 16_384 > usage.maximum - usage.used)
181
195
  graphPrepared = { instruction, bytes: 0 };
@@ -212,28 +226,37 @@ export class QaFailureDeliveryService {
212
226
  this.faults.afterIntent?.();
213
227
  let response;
214
228
  let graphSequence = 0;
215
- const exchangeResult = await phase("initial-work-and-validation", () => sendGraphTurn(builder, instruction, graphDeliveryContext, { handback: true, logicalActionId: operationId }, async (prompt) => {
216
- if (graphSequence > 0) {
217
- const nextDb = new WorkflowDb(input.projectDir);
218
- try {
219
- const head = nextDb.qaTicketHead(input.runId, input.ticket.id);
220
- const active = nextDb.qaRemediationAttempt(operationId);
221
- if (head.revision !== intent.expectedRevision || head.state !== "remediation-intended" || active?.status !== "started" || active.reviewAttemptId !== input.reviewAttemptId)
222
- throw new Error("Graph continuation lost its remediation authority");
223
- record = this.turnIntent(nextDb, operationId, occurrence, session, prompt, graphSequence, "graph-continuation");
224
- }
225
- finally {
226
- nextDb.close();
229
+ const hostToolObservations = [];
230
+ const initialSourceDigest = captureBuildSource(input.builderWorktree).digest;
231
+ const stopHostObservation = observeHostBuilderTests(builder, input.builderWorktree, hostToolObservations);
232
+ let exchangeResult;
233
+ try {
234
+ exchangeResult = await phase("initial-work-and-validation", () => sendGraphTurn(builder, instruction, graphDeliveryContext, nativePolicy(remediationOutput, { handback: true, logicalActionId: operationId, journalRunId: input.runId }), durableOwnerDispatch(input.projectDir, remediationOutput, { runId: input.runId, actionId: operationId }, async (prompt) => {
235
+ if (graphSequence > 0) {
236
+ const nextDb = new WorkflowDb(input.projectDir);
237
+ try {
238
+ const head = nextDb.qaTicketHead(input.runId, input.ticket.id);
239
+ const active = nextDb.qaRemediationAttempt(operationId);
240
+ if (head.revision !== intent.expectedRevision || head.state !== "remediation-intended" || active?.status !== "started" || active.reviewAttemptId !== input.reviewAttemptId)
241
+ throw new Error("Graph continuation lost its remediation authority");
242
+ record = this.turnIntent(nextDb, operationId, occurrence, session, prompt, graphSequence, "graph-continuation");
243
+ }
244
+ finally {
245
+ nextDb.close();
246
+ }
227
247
  }
228
- }
229
- graphSequence++;
230
- response = await this.dispatch(input, builder, record, prompt);
231
- if (!response.turn)
232
- return { text: response.detail ?? "Remediation dispatch uncertain", isError: true, costUsd: 0, numTurns: 0 };
233
- if (response.record.validationErrors?.length)
234
- return { ...response.turn, isError: true };
235
- return response.turn;
236
- }, { ...graphPrepared, instruction }));
248
+ graphSequence++;
249
+ response = await this.dispatch(input, builder, record, prompt);
250
+ if (!response.turn)
251
+ return { text: response.detail ?? "Remediation dispatch uncertain", isError: true, costUsd: 0, numTurns: 0 };
252
+ if (response.record.validationErrors?.length)
253
+ return { ...response.turn, isError: true };
254
+ return response.turn;
255
+ }), { ...graphPrepared, instruction }));
256
+ }
257
+ finally {
258
+ stopHostObservation();
259
+ }
237
260
  const guidanceDb = new WorkflowDb(input.projectDir);
238
261
  try {
239
262
  guidanceDb.finishGuidance(guidanceIds, "builder", { submitted: response.record.status === "completed" && response.record.providerTurnId ? true : undefined, receipt: response.record.providerTurnId ? response.record : undefined });
@@ -241,33 +264,16 @@ export class QaFailureDeliveryService {
241
264
  finally {
242
265
  guidanceDb.close();
243
266
  }
244
- let outcome = exchangeResult.isError ? "delivery-uncertain" : this.classify(response);
245
- if (exchangeResult.isError)
267
+ let outcome = this.classify(response);
268
+ if (exchangeResult.isError && outcome !== "response-invalid") {
269
+ outcome = "delivery-uncertain";
246
270
  response.detail = exchangeResult.text;
247
- if (outcome === "response-invalid" && response.record.sourceCapture === "captured") {
248
- const correction = [
249
- "Builder QA remediation response correction only. Do not inspect files, run tools, edit source, or perform additional remediation.",
250
- "Preserve the original substantive result: do not invent changes, evidence, verification, answers, or turn a blocker into done.",
251
- `Actual validation errors: ${JSON.stringify(response.record.parserErrors)}`,
252
- `Original raw response evidence: ${response.record.rawResponseDigest}. The original turn remains in this same conversation.`,
253
- `Post-remediation source digest: ${response.record.postSourceDigest}`,
254
- responseInstructions(handoff),
255
- ].join("\n\n");
256
- enforceMandatoryPromptLimit(Buffer.from(correction));
257
- const repairDb = new WorkflowDb(input.projectDir);
258
- try {
259
- record = this.turnIntent(repairDb, operationId, occurrence, session, correction, graphSequence, "response-repair");
260
- }
261
- finally {
262
- repairDb.close();
263
- }
264
- const before = response.record.postSourceDigest;
265
- response = await phase("response-repair-and-validation", () => this.dispatch(input, builder, record, correction, before));
266
- outcome = this.classify(response);
267
271
  }
272
+ // Native malformed results retain completed side effects and stop at the
273
+ // operation boundary. A second model turn cannot prove content equivalence.
268
274
  if (outcome === "remediation-reported" && response.turn) {
269
275
  try {
270
- await collectBuilderContractCoverage(input.projectDir, input.runId, input.ticket.id, input.builderWorktree, builder, operationId, instruction, response.turn);
276
+ await collectBuilderContractCoverage(input.projectDir, input.runId, input.ticket.id, input.builderWorktree, builder, operationId, instruction, response.turn, hostToolObservations, initialSourceDigest);
271
277
  }
272
278
  catch (error) {
273
279
  outcome = "response-invalid";
@@ -326,7 +332,7 @@ export class QaFailureDeliveryService {
326
332
  }
327
333
  if (outcome === "remediation-reported" && response.turn) {
328
334
  try {
329
- queueGraphMaintenance(input.projectDir, input.builderWorktree, operationId, response.turn.text, response.record.postSourceDigest !== preDispatch.digest);
335
+ queueGraphMaintenance(input.projectDir, input.builderWorktree, operationId, operationResponse(response.turn, remediationOutput).result.payload?.graphMaintenance === "meaningful-indexed-change", response.record.postSourceDigest !== preDispatch.digest);
330
336
  await maintainGraphTask(input.projectDir, input.builderWorktree, operationId, builder);
331
337
  }
332
338
  catch (error) {
@@ -335,23 +341,23 @@ export class QaFailureDeliveryService {
335
341
  }
336
342
  return { ok: outcome === "remediation-reported", outcome, detail, response: response.turn?.text, summary: response.contract?.report?.summary, providerTurnId: response.record.providerTurnId, handoffId, operationId, turnRecordId: response.record.turnRecordId };
337
343
  }
338
- turnIntent(db, operationId, occurrence, session, prompt, index, kind = index ? "response-repair" : "remediation") {
344
+ turnIntent(db, operationId, occurrence, session, prompt, index, kind = "remediation") {
339
345
  const turn = { version: 4, turnRecordId: qaDigest("qa-delivery-turn-v4", { operationId, index }), operationId, reportOccurrenceId: occurrence, turnIndex: index, kind, ...(index ? { parentTurnRecordId: qaDigest("qa-delivery-turn-v4", { operationId, index: 0 }) } : {}), status: "intended", intendedSession: session, hostInstructionDigest: db.putEvidence("handoff", Buffer.from(prompt)), hostInstructionBytes: Buffer.byteLength(prompt), providerInstructionAvailability: "unavailable", sourceCapture: "pending", startedAt: new Date().toISOString() };
340
346
  if (db.qaDeliveryTurns(operationId).some(t => t.turnIndex === index))
341
347
  throw new Error("Delivery turn already intended; reconcile without redispatch");
342
348
  db.recordQaDeliveryTurn(turn);
343
349
  return turn;
344
350
  }
345
- async dispatch(input, builder, record, prompt, responseOnlySourceDigest) {
351
+ async dispatch(input, builder, record, prompt) {
346
352
  let result;
347
353
  await dispatchWithGraphAccess(input.projectDir, builder, prompt, undefined, async () => {
348
- result = await this.dispatchOwned(input, builder, record, prompt, responseOnlySourceDigest);
354
+ result = await this.dispatchOwned(input, builder, record, prompt);
349
355
  return result.turn ?? { text: result.detail ?? "Dispatch failed", isError: true, costUsd: 0, numTurns: 0 };
350
356
  });
351
357
  return result;
352
358
  }
353
- async dispatchOwned(input, builder, record, prompt, responseOnlySourceDigest) {
354
- const events = [];
359
+ async dispatchOwned(input, builder, record, prompt) {
360
+ const events = new BoundedTurnEvents(providerWireContract(remediationOutput, builder.agent).maximumResponseBytes);
355
361
  let unsubscribe;
356
362
  let turn, error;
357
363
  const began = performance.now();
@@ -361,10 +367,14 @@ export class QaFailureDeliveryService {
361
367
  if (stableBuilderSessionIdentity(validateBuilderSession(builder, input)) !== stableBuilderSessionIdentity(record.intendedSession))
362
368
  throw new Error("Builder session identity changed before dispatch");
363
369
  unsubscribe = builder.observeEvents(event => { events.push(event); });
364
- turn = await withActivityPhase(record.kind === "response-repair" ? "correcting Builder QA remediation response" : "delivering source-bound QA failure handoff to Builder", () => builder.sendTurn(prompt, { handback: true, responseOnly: record.kind === "response-repair" }));
370
+ // The QA record is the domain owner, while the common gateway owns the
371
+ // immutable transport tuple. Calling it here (rather than relying on
372
+ // the graph wrapper) gives QA the same prepare/seal-before-send boundary
373
+ // as every other structured dispatch without creating a second tuple.
374
+ turn = await withActivityPhase("delivering source-bound QA failure handoff to Builder", () => journalStructuredDispatch(input.projectDir, builder, `${prompt}\n\n${operationInstruction(remediationOutput)}`, nativePolicy(remediationOutput, { dispatchId: record.turnRecordId, logicalActionId: record.operationId, journalRunId: input.runId, handback: true }), (text, policy) => builder.sendTurn(text, policy)));
365
375
  }
366
376
  catch (caught) {
367
- error = `Builder ${record.kind === "response-repair" ? "correction " : ""}dispatch uncertain: ${String(caught)}`;
377
+ error = `Builder dispatch uncertain: ${String(caught)}`;
368
378
  }
369
379
  finally {
370
380
  unsubscribe?.();
@@ -374,18 +384,29 @@ export class QaFailureDeliveryService {
374
384
  try {
375
385
  // Preserve returned bytes before identity/source inspection can fail.
376
386
  if (turn) {
387
+ record.sealedDispatch = db.structuredOwnerDispatch(record.turnRecordId);
377
388
  const raw = turn.rawResponse ?? turn.text, cleaned = turn.cleanedResponse ?? turn.text;
378
- Object.assign(record, { rawResponseDigest: db.putEvidence("qa", Buffer.from(raw)), rawResponseBytes: Buffer.byteLength(raw), cleanedResponseDigest: db.putEvidence("qa", Buffer.from(cleaned)), cleanedResponseBytes: Buffer.byteLength(cleaned), providerTurnId: turn.turnId, providerReturnedError: turn.isError, providerMetadata: turn.providerMetadata, failure: turn.failure });
389
+ record.structuredResponseDigest = db.operation(record.turnRecordId)?.result?.responseDigest;
390
+ Object.assign(record, { rawResponseDigest: (db.operation(record.turnRecordId)?.result).rawDigest, rawResponseBytes: Buffer.byteLength(raw), cleanedResponseDigest: db.putEvidence("qa", boundedEvidenceUtf8(cleaned, events.maximumBytes)), cleanedResponseBytes: Buffer.byteLength(cleaned), providerTurnId: turn.turnId, providerReturnedError: turn.isError, providerMetadata: turn.providerMetadata, failure: turn.failure && { ...turn.failure, diagnostics: boundedEvidenceUtf8(turn.failure.diagnostics, 4096).toString() } });
379
391
  if (turn.providerInstruction !== undefined)
380
392
  Object.assign(record, { providerInstructionDigest: db.putEvidence("handoff", Buffer.from(turn.providerInstruction)), providerInstructionBytes: Buffer.byteLength(turn.providerInstruction), providerInstructionAvailability: "captured" });
381
393
  }
382
- record.eventEvidenceDigest = db.putEvidence("qa", Buffer.from(canonicalEvidence(events)));
383
- record.toolCount = events.filter(e => e.kind === "tool" || e.kind === "provider-item" && !["text", "agentMessage", "reasoning", "userMessage", "contextCompaction"].includes(e.itemType)).length;
384
- record.terminalCount = events.filter(e => e.kind === "turn-complete").length;
394
+ record.eventEvidenceDigest = db.putEvidence("qa", Buffer.from(canonicalEvidence(events.metadata())));
395
+ db.recordCommunicationEvidenceRefs(record.turnRecordId, [record.rawResponseDigest, record.cleanedResponseDigest, record.eventEvidenceDigest].filter((digest) => Boolean(digest)));
396
+ record.toolCount = events.toolCount;
397
+ record.terminalCount = events.terminalCount;
385
398
  db.recordQaDeliveryTurn(record);
386
399
  this.faults.afterResponseStored?.();
387
400
  const validationStarted = performance.now();
388
401
  const errors = error ? [error] : [];
402
+ if (turn && !turn.isError && !turn.failure) {
403
+ try {
404
+ db.replayStructuredDispatch(record.turnRecordId, remediationOutput);
405
+ }
406
+ catch (caught) {
407
+ errors.push(`Persisted remediation receipt rejected: ${String(caught)}`);
408
+ }
409
+ }
389
410
  try {
390
411
  record.observedSession = validateBuilderSession(builder, input);
391
412
  if (stableBuilderSessionIdentity(record.observedSession) !== stableBuilderSessionIdentity(record.intendedSession))
@@ -395,10 +416,13 @@ export class QaFailureDeliveryService {
395
416
  errors.push(String(caught));
396
417
  }
397
418
  if (turn) {
398
- if (turn.isError || turn.failure)
419
+ if (turn.failure || turn.isError && turn.structured?.state !== "rejected")
399
420
  errors.push("Builder provider returned an error or failure metadata");
400
421
  if (!turn.turnId)
401
422
  errors.push("Builder completed without a provider turn identity");
423
+ const expectedWire = providerWireContract(remediationOutput, record.intendedSession.provider);
424
+ if (turn.structured?.state === "candidate" && (turn.structured.dispatchId !== record.turnRecordId || turn.structured.sessionId !== record.intendedSession.sessionId || turn.structured.profile !== expectedWire.profile || turn.structured.wireContractDigest !== expectedWire.descriptorDigest || turn.structured.wireSchemaDigest !== expectedWire.wireSchemaDigest || turn.structured.wireVariant !== expectedWire.variant))
425
+ errors.push("Native remediation receipt has a foreign dispatch or provider session");
402
426
  const meta = turn.providerMetadata;
403
427
  try {
404
428
  if (!meta?.sessionRef || meta.provider !== record.intendedSession.provider || meta.sessionId !== record.intendedSession.sessionId || stableBuilderSessionIdentity(meta.sessionRef) !== stableBuilderSessionIdentity(record.intendedSession))
@@ -416,7 +440,7 @@ export class QaFailureDeliveryService {
416
440
  catch (caught) {
417
441
  errors.push(`Terminal event scope is inaccessible: ${String(caught)}`);
418
442
  }
419
- if (terminal.length !== 1 || terminal[0]?.turnId !== turn.turnId || terminal[0]?.result.turnId !== turn.turnId || terminal[0]?.result.isError || terminal[0]?.result.failure || (terminal[0]?.result.rawResponse ?? terminal[0]?.result.text) !== (turn.rawResponse ?? turn.text))
443
+ if (events.terminalOverflow || events.terminalCount !== 1 || terminal.length !== 1 || terminal[0]?.turnId !== turn.turnId || terminal[0]?.result.turnId !== turn.turnId || terminal[0]?.result.isError || terminal[0]?.result.failure || (!terminal[0] || receiptFingerprint(terminal[0].result) !== receiptFingerprint(turn)) || (!terminal[0] || rawFingerprint(terminal[0].result) !== rawFingerprint(turn)))
420
444
  errors.push("Builder completion is missing, duplicated, or uncorrelated");
421
445
  }
422
446
  const captureStart = performance.now();
@@ -426,33 +450,28 @@ export class QaFailureDeliveryService {
426
450
  }
427
451
  catch (caught) {
428
452
  record.sourceCapture = "unavailable";
429
- record.sourceCaptureError = String(caught);
453
+ record.sourceCaptureError = boundedEvidenceUtf8(String(caught), 4096).toString();
430
454
  errors.push(`Post-dispatch source capture failed: ${String(caught)}`);
431
455
  }
432
456
  record.captureElapsedMs = performance.now() - captureStart;
433
- if (record.kind === "response-repair") {
434
- record.responseOnlyViolations = record.toolCount ? ["Builder used tools during response correction"] : [];
435
- record.responseOnlySourceChanged = Boolean(record.postSourceDigest && record.postSourceDigest !== responseOnlySourceDigest);
436
- if (record.responseOnlySourceChanged)
437
- record.responseOnlyViolations.push(`source changed during response correction (${responseOnlySourceDigest} -> ${record.postSourceDigest})`);
438
- }
439
457
  let graphRequest = false;
458
+ const parserErrors = [];
440
459
  try {
441
- graphRequest = Boolean(turn && parseGraphRequest(turn.cleanedResponse ?? turn.text));
460
+ graphRequest = Boolean(turn && operationResponse(turn, remediationOutput).result.kind === "graph-request");
442
461
  }
443
462
  catch (caught) {
444
- errors.push(String(caught));
463
+ parserErrors.push(String(caught));
445
464
  }
446
- const contract = turn && !graphRequest ? parseBuilderQaRemediationContract(turn.cleanedResponse ?? turn.text, { handoffId: qaDigest("qa-failure-handoff", { runId: input.runId, ticketId: input.ticket.id, reviewAttemptId: input.reviewAttemptId, reportDigest: input.reportDigest, remediationGeneration: input.remediationGeneration + 1 }), findings: createQaFindingRefs({ runId: input.runId, ticketId: input.ticket.id, reviewAttemptId: input.reviewAttemptId, reportDigest: input.reportDigest, rawFindingIds: selectRemediationChunk(input) }) }) : undefined;
447
- record.parserErrors = [...(contract?.errors ?? []), ...(turn?.continuityErrors ?? [])];
448
- record.validationErrors = errors;
465
+ const contract = turn && !graphRequest ? remediationStructuredResponse(turn, { handoffId: qaDigest("qa-failure-handoff", { runId: input.runId, ticketId: input.ticket.id, reviewAttemptId: input.reviewAttemptId, reportDigest: input.reportDigest, remediationGeneration: input.remediationGeneration + 1 }), findings: createQaFindingRefs({ runId: input.runId, ticketId: input.ticket.id, reviewAttemptId: input.reviewAttemptId, reportDigest: input.reportDigest, rawFindingIds: selectRemediationChunk(input) }) }) : undefined;
466
+ record.parserErrors = [...parserErrors, ...(contract?.errors ?? []), ...(turn?.continuityErrors ?? [])].slice(0, 32).map(detail => boundedEvidenceUtf8(detail, 1024).toString());
467
+ record.validationErrors = errors.slice(0, 32).map(detail => boundedEvidenceUtf8(detail, 1024).toString());
449
468
  if (contract?.report)
450
469
  record.parsedResponseDigest = db.putEvidence("qa", Buffer.from(canonicalEvidence(contract.report)));
451
- record.status = errors.length ? "delivery-uncertain" : "completed";
470
+ record.status = errors.length || !turn || turn.isError || turn.failure ? "delivery-uncertain" : "completed";
452
471
  record.completedAt = new Date().toISOString();
453
472
  record.validationElapsedMs = Math.max(0, performance.now() - validationStarted - record.captureElapsedMs);
454
473
  db.recordQaDeliveryTurn(record);
455
- return { turn, record, contract, detail: errors.length ? errors.join("; ") : record.responseOnlyViolations?.length ? record.responseOnlyViolations.join("; ") : undefined };
474
+ return { turn, record, contract, detail: errors.length ? boundedEvidenceUtf8(errors.join("; "), 4096).toString() : undefined };
456
475
  }
457
476
  finally {
458
477
  db.close();
@@ -461,10 +480,6 @@ export class QaFailureDeliveryService {
461
480
  classify(result) {
462
481
  if (result.record.validationErrors?.length)
463
482
  return "delivery-uncertain";
464
- if (result.record.responseOnlySourceChanged)
465
- return "source-drift";
466
- if (result.record.responseOnlyViolations?.length)
467
- return "response-invalid";
468
483
  if (result.contract?.valid && result.contract.status === "blocked")
469
484
  return "blocked";
470
485
  if (result.contract?.valid && result.contract.status === "needs_input")
@@ -654,11 +669,8 @@ function responseInstructions(handoff) {
654
669
  `Required finding identities for this remediation chunk: ${JSON.stringify(handoff.report.findingRefs.map(f => ({ finding_key: f.findingKey, raw_id: f.rawId })))}`,
655
670
  "Address, dispute with evidence, or mark blocked every finding in this remediation chunk. Do not address findings outside the chunk. A Builder claim never approves QA findings.",
656
671
  "This is only a bounded remediation chunk. It does not approve the ticket. Any source change invalidates this chunk's source binding, and Rafi must perform a fresh full independent QA review afterward.",
657
- "The envelope must be the first non-empty content of the cleaned response. STEP_STATUS must be the final non-empty line. No prologue, trailing prose, duplicate keys, unknown or missing findings.",
658
- "In raw output, put the required RAFI_CONTINUITY_DELTA record between the envelope end and STEP_STATUS. The continuity wrapper removes only that record before contract validation.",
659
- `${BUILDER_QA_REMEDIATION_START}\n${JSON.stringify({ version: 3, handoff_id: handoff.handoffId, summary: "Truthful result", findings: handoff.report.findingRefs.map(f => ({ finding_key: f.findingKey, raw_id: f.rawId, disposition: "disputed", changes: ["Explain actual changes or why none were needed"], evidence: "Actual evidence", verification: [{ check: "Actual verification", outcome: "not_run", evidence: "Actual reason" }] })), observations: [] })}\n${BUILDER_QA_REMEDIATION_END}\nSTEP_STATUS: done | summary="short truthful summary"`,
660
- 'If blocked, use STEP_STATUS: blocked | reason="actual blocker and recovery needed". A V3 envelope with complete partial fixed/disputed/blocked coverage is allowed; blocked findings require category, reason, recovery, capability, evidence. Without partial results, the blocked status alone is valid.',
661
- 'If a decision is required, use STEP_STATUS: needs_input | question="actual unanswered question" without fabricating an envelope or answer.',
672
+ "Return one native builder-remediation response. The completed payload contains status done or blocked, a reason (null for done), the complete V3 report, and available coverage claims. Keep continuity in the same response object.",
673
+ "A report must cover every finding exactly once. Partial fixed/disputed/blocked coverage is valid only with status blocked, a truthful reason, and a structured blocker on each blocked finding. If no partial report is available, use the outer blocked result. Questions use the needs-input alternative.",
662
674
  `Authoritative V3 schema: ${JSON.stringify(builderQaRemediationReportV3Schema)}`,
663
675
  ].join("\n\n");
664
676
  }
@@ -0,0 +1,26 @@
1
+ export type QaHostTransformationRule = "assessment-host-bindings-v1" | "depth-host-bindings-v1" | "draft-host-bindings-v1";
2
+ export interface QaHostTransformationV1 {
3
+ version: 1;
4
+ ruleVersion: QaHostTransformationRule;
5
+ operation: string;
6
+ tupleDigest: string;
7
+ inputDigest: string;
8
+ outputDigest: string;
9
+ authorityClass: "host-binding-and-observation";
10
+ justification: string;
11
+ effects: Array<{
12
+ path: string;
13
+ beforeDigest: string;
14
+ afterDigest: string;
15
+ }>;
16
+ }
17
+ /** Records only approved host effects; provider/user requirements remain immutable. */
18
+ export declare function qaHostTransformation(input: {
19
+ rule: QaHostTransformationRule;
20
+ operation: string;
21
+ tupleDigest: string;
22
+ before: unknown;
23
+ after: unknown;
24
+ workspacePaths?: Record<string, string>;
25
+ }): QaHostTransformationV1;
26
+ export declare function verifyQaHostTransformation(record: QaHostTransformationV1, before: unknown, after: unknown, workspacePaths?: Record<string, string>): void;
@@ -0,0 +1,100 @@
1
+ import { canonicalJson, structuredDigest } from "rafi-spec";
2
+ const hashValue = (value, present) => structuredDigest({ present, ...(present ? { value } : {}) });
3
+ /** Records only approved host effects; provider/user requirements remain immutable. */
4
+ export function qaHostTransformation(input) {
5
+ if (!["assessment-host-bindings-v1", "depth-host-bindings-v1", "draft-host-bindings-v1"].includes(input.rule))
6
+ throw new Error("Unknown QA host transformation rule");
7
+ if (!/^[a-f0-9]{64}$/.test(input.tupleDigest) || !input.operation)
8
+ throw new Error("QA transformation requires native tuple provenance");
9
+ const effects = [];
10
+ const original = input.before, transformed = input.after;
11
+ const ids = new Map();
12
+ if (input.rule === "draft-host-bindings-v1") {
13
+ if (transformed.checks.length < original.checks.length)
14
+ throw new Error("QA transformation cannot remove provider checks");
15
+ original.checks.forEach((check, index) => ids.set(check.id, transformed.checks[index].id));
16
+ }
17
+ const labelIds = [...ids.keys()].filter(id => id.startsWith("check-")).sort((a, b) => b.length - a.length);
18
+ const labelPattern = labelIds.length ? new RegExp(`(^|[^A-Za-z0-9_-])(${labelIds.map(id => id.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")).join("|")})(?![A-Za-z0-9_-])`, "g") : undefined;
19
+ const relabel = (value) => typeof value === "string" && labelPattern
20
+ ? value.replace(labelPattern, (_match, prefix, id) => `${prefix}${ids.get(id)}`) : value;
21
+ const walk = (before, after, path, beforePresent = true, afterPresent = true) => {
22
+ if (beforePresent === afterPresent && canonicalJson(before ?? null) === canonicalJson(after ?? null))
23
+ return;
24
+ if (input.rule === "draft-host-bindings-v1" && path === "baseline" && Array.isArray(before) && Array.isArray(after)) {
25
+ for (const prior of before)
26
+ if (!after.some(row => row.id === prior.id))
27
+ throw new Error("QA transformation cannot remove provider observations");
28
+ for (const [index, row] of after.entries()) {
29
+ const prior = before.find(item => item.id === row.id);
30
+ walk(prior, row, `baseline.${index}`, prior !== undefined, true);
31
+ }
32
+ return;
33
+ }
34
+ if (before && after && typeof before === "object" && typeof after === "object" && Array.isArray(before) === Array.isArray(after)) {
35
+ for (const key of [...new Set([...Object.keys(before), ...Object.keys(after)])].sort())
36
+ walk(before[key], after[key], path ? `${path}.${key}` : key, Object.hasOwn(before, key), Object.hasOwn(after, key));
37
+ return;
38
+ }
39
+ const assessment = /^(draftPayloadDigest|inventoryDigest|policyVersion|authorSessionId|sessionId|operationId)$/.test(path);
40
+ const depth = /^(plannerOperationId|plannerIdentity|minimumReasons(?:\.\d+)?)$/.test(path);
41
+ let draft = false;
42
+ if (input.rule === "draft-host-bindings-v1") {
43
+ const check = /^checks\.(\d+)\.(.+)$/.exec(path);
44
+ const originalCheck = check && original.checks[Number(check[1])];
45
+ const idReference = /^(checks\.\d+\.(id|dependsOnChecks\.\d+)|coverage\.\d+\.checkIds\.\d+|unresolved\.\d+\.checkIds\.\d+)$/.test(path);
46
+ draft = idReference && ids.get(before) === after;
47
+ if (check?.[2] === "obligation" && originalCheck && after === "mandatory") {
48
+ draft = original.requirements.some((requirement) => requirement.obligation === "mandatory" &&
49
+ original.coverage.some((row) => row.requirementId === requirement.id && row.checkIds.includes(originalCheck.id)));
50
+ }
51
+ if (check && /^requirementRefs\.\d+$/.test(check[2]) && !beforePresent && originalCheck) {
52
+ draft = original.coverage.some((row) => row.requirementId === after && row.checkIds.includes(originalCheck.id));
53
+ }
54
+ if (/^checks\.\d+\.hostKind$/.test(path) && !beforePresent)
55
+ draft = after === "source-scope" || after === "graph-maintenance";
56
+ if (/^checks\.\d+$/.test(path) && !beforePresent)
57
+ draft = after && (after.hostKind === "source-scope" || after.hostKind === "graph-maintenance") &&
58
+ after.origin === "invariant" && after.obligation === "mandatory" && after.requirementRefs.every((id) => original.requirements.some((requirement) => requirement.id === id));
59
+ if (/^baseline\.\d+$/.test(path) && !beforePresent)
60
+ draft = after && /^[a-f0-9]{64}$/.test(after.evidenceDigest) && typeof after.observedAt === "string";
61
+ if (/^baseline\.\d+\.(sourceDigest|observedAt|phase|evidenceDigest)$/.test(path))
62
+ draft = true;
63
+ if (/^baseline\.\d+\.checkId$/.test(path)) {
64
+ const row = transformed.baseline[Number(path.split(".")[1])];
65
+ const checks = transformed.checks.filter((item) => item.prerequisiteRefs.includes(row.id));
66
+ draft = ids.get(before) === after || checks.length === 1 && checks[0].id === after;
67
+ }
68
+ if (/^preparationEvidence\.\d+\.(operationId|sessionId|graphReceiptRefs(?:\.\d+)?|evidenceDigest)$/.test(path) ||
69
+ /^preparationEvidence\.\d+\.references\.\d+\.digest$/.test(path))
70
+ draft = true;
71
+ if (/^(checks\.\d+\.verification\.\d+\.cwd|preparationEvidence\.\d+\.references\.\d+\.path)$/.test(path))
72
+ draft = typeof before === "string" && input.workspacePaths?.[before] === after;
73
+ // The only permitted prose edit substitutes exact provider check identifiers.
74
+ if (/^(checks\.\d+\.(expectedBehavior|expectedEvidence\.\d+|expectedBaseline|applicability\.(predicate|requiredEvidence\.\d+)|verification\.\d+\.(expectedOutcome|steps\.\d+))|baseline\.\d+\.(procedure|relevance|expectedTransition|predicateEvidence\.\d+)|preparationEvidence\.\d+\.analysis|unresolved\.\d+\.(reason|nextAction))$/.test(path) &&
75
+ typeof before === "string" && before !== after && relabel(before) === after)
76
+ draft = true;
77
+ }
78
+ if (!(input.rule === "assessment-host-bindings-v1" ? assessment : input.rule === "depth-host-bindings-v1" ? depth : draft))
79
+ throw new Error(`QA host transformation cannot change provider requirements or semantic fields: ${path}`);
80
+ effects.push({ path, beforeDigest: hashValue(before, beforePresent), afterDigest: hashValue(after, afterPresent) });
81
+ if (effects.length > 10000)
82
+ throw new Error("QA host transformation exceeds effect budget");
83
+ };
84
+ walk(input.before, input.after, "");
85
+ if (input.rule === "depth-host-bindings-v1") {
86
+ const before = input.before;
87
+ const after = input.after;
88
+ const expected = [...before.minimumReasons, ...before.riskFactors.map(risk => risk.category).filter(category => !before.minimumReasons.includes(category))];
89
+ if (canonicalJson(expected) !== canonicalJson(after.minimumReasons))
90
+ throw new Error("Depth labels must be exactly derivable from provider risk factors");
91
+ }
92
+ return { version: 1, ruleVersion: input.rule, operation: input.operation, tupleDigest: input.tupleDigest,
93
+ inputDigest: structuredDigest(input.before), outputDigest: structuredDigest(input.after), effects,
94
+ authorityClass: "host-binding-and-observation", justification: "Bind native dispatch identities and retained host observations; preserve provider/user requirements" };
95
+ }
96
+ export function verifyQaHostTransformation(record, before, after, workspacePaths) {
97
+ const expected = qaHostTransformation({ rule: record.ruleVersion, operation: record.operation, tupleDigest: record.tupleDigest, before, after, workspacePaths });
98
+ if (canonicalJson(expected) !== canonicalJson(record))
99
+ throw new Error("QA host transformation replay mismatch");
100
+ }