audit-tools 0.40.1 → 0.41.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 (114) hide show
  1. package/dist/audit/cli/auditStep.d.ts +1 -1
  2. package/dist/audit/cli/conceptualDispatch.d.ts +3 -0
  3. package/dist/audit/cli/conceptualDispatch.d.ts.map +1 -1
  4. package/dist/audit/cli/conceptualDispatch.js +82 -24
  5. package/dist/audit/cli/conceptualDispatch.js.map +1 -1
  6. package/dist/audit/cli/dispatch/hostHandoff.d.ts +12 -0
  7. package/dist/audit/cli/dispatch/hostHandoff.d.ts.map +1 -1
  8. package/dist/audit/cli/dispatch/hostHandoff.js +84 -56
  9. package/dist/audit/cli/dispatch/hostHandoff.js.map +1 -1
  10. package/dist/audit/cli/fanoutLanes.d.ts +47 -11
  11. package/dist/audit/cli/fanoutLanes.d.ts.map +1 -1
  12. package/dist/audit/cli/fanoutLanes.js +15 -7
  13. package/dist/audit/cli/fanoutLanes.js.map +1 -1
  14. package/dist/audit/cli/laneSubmissions.d.ts +241 -0
  15. package/dist/audit/cli/laneSubmissions.d.ts.map +1 -0
  16. package/dist/audit/cli/laneSubmissions.js +438 -0
  17. package/dist/audit/cli/laneSubmissions.js.map +1 -0
  18. package/dist/audit/cli/laneValidators.d.ts +65 -0
  19. package/dist/audit/cli/laneValidators.d.ts.map +1 -0
  20. package/dist/audit/cli/laneValidators.js +145 -0
  21. package/dist/audit/cli/laneValidators.js.map +1 -0
  22. package/dist/audit/cli/nextStepCommand.d.ts +1 -1
  23. package/dist/audit/cli/nextStepCommand.d.ts.map +1 -1
  24. package/dist/audit/cli/nextStepCommand.js +118 -69
  25. package/dist/audit/cli/nextStepCommand.js.map +1 -1
  26. package/dist/audit/cli/nextStepHelpers.d.ts +73 -42
  27. package/dist/audit/cli/nextStepHelpers.d.ts.map +1 -1
  28. package/dist/audit/cli/nextStepHelpers.js +389 -253
  29. package/dist/audit/cli/nextStepHelpers.js.map +1 -1
  30. package/dist/audit/cli/recoverSubmissionCommand.d.ts +16 -0
  31. package/dist/audit/cli/recoverSubmissionCommand.d.ts.map +1 -0
  32. package/dist/audit/cli/recoverSubmissionCommand.js +73 -0
  33. package/dist/audit/cli/recoverSubmissionCommand.js.map +1 -0
  34. package/dist/audit/cli/semanticReviewStep.d.ts +3 -0
  35. package/dist/audit/cli/semanticReviewStep.d.ts.map +1 -1
  36. package/dist/audit/cli/semanticReviewStep.js +26 -1
  37. package/dist/audit/cli/semanticReviewStep.js.map +1 -1
  38. package/dist/audit/cli/steps.d.ts +75 -2
  39. package/dist/audit/cli/steps.d.ts.map +1 -1
  40. package/dist/audit/cli/steps.js +14 -0
  41. package/dist/audit/cli/steps.js.map +1 -1
  42. package/dist/audit/cli.d.ts.map +1 -1
  43. package/dist/audit/cli.js +5 -1
  44. package/dist/audit/cli.js.map +1 -1
  45. package/dist/audit/contracts/wrapperResponse.d.ts +7 -7
  46. package/dist/audit/contracts/wrapperResponse.js +1 -1
  47. package/dist/audit/contracts/wrapperResponse.js.map +1 -1
  48. package/dist/audit/io/artifacts.d.ts +12 -1
  49. package/dist/audit/io/artifacts.d.ts.map +1 -1
  50. package/dist/audit/io/artifacts.js +28 -1
  51. package/dist/audit/io/artifacts.js.map +1 -1
  52. package/dist/audit/orchestrator/synthesisExecutors.d.ts.map +1 -1
  53. package/dist/audit/orchestrator/synthesisExecutors.js +2 -0
  54. package/dist/audit/orchestrator/synthesisExecutors.js.map +1 -1
  55. package/dist/audit/reporting/synthesis.d.ts +9 -1
  56. package/dist/audit/reporting/synthesis.d.ts.map +1 -1
  57. package/dist/audit/reporting/synthesis.js +69 -1
  58. package/dist/audit/reporting/synthesis.js.map +1 -1
  59. package/dist/audit/supervisor/operatorHandoff.d.ts +9 -2
  60. package/dist/audit/supervisor/operatorHandoff.d.ts.map +1 -1
  61. package/dist/audit/supervisor/operatorHandoff.js +17 -10
  62. package/dist/audit/supervisor/operatorHandoff.js.map +1 -1
  63. package/dist/audit/types/designAssessment.d.ts +9 -5
  64. package/dist/audit/types/designAssessment.d.ts.map +1 -1
  65. package/dist/remediate/index.d.ts.map +1 -1
  66. package/dist/remediate/index.js +48 -1
  67. package/dist/remediate/index.js.map +1 -1
  68. package/dist/remediate/steps/dispatch/hostHandoff.d.ts +38 -6
  69. package/dist/remediate/steps/dispatch/hostHandoff.d.ts.map +1 -1
  70. package/dist/remediate/steps/dispatch/hostHandoff.js +87 -75
  71. package/dist/remediate/steps/dispatch/hostHandoff.js.map +1 -1
  72. package/dist/shared/decompose/charterClarification.d.ts +1 -1
  73. package/dist/shared/decompose/charterClarification.js +1 -1
  74. package/dist/shared/decompose/charterExtraction.js +1 -1
  75. package/dist/shared/decompose/charterExtraction.js.map +1 -1
  76. package/dist/shared/decompose/systemicChallenge.js +1 -1
  77. package/dist/shared/decompose/systemicChallenge.js.map +1 -1
  78. package/dist/shared/index.d.ts +11 -1
  79. package/dist/shared/index.d.ts.map +1 -1
  80. package/dist/shared/index.js +6 -1
  81. package/dist/shared/index.js.map +1 -1
  82. package/dist/shared/io/auditToolsPaths.d.ts +24 -4
  83. package/dist/shared/io/auditToolsPaths.d.ts.map +1 -1
  84. package/dist/shared/io/auditToolsPaths.js +29 -5
  85. package/dist/shared/io/auditToolsPaths.js.map +1 -1
  86. package/dist/shared/io/schemaVersion.d.ts +5 -4
  87. package/dist/shared/io/schemaVersion.d.ts.map +1 -1
  88. package/dist/shared/io/schemaVersion.js +17 -5
  89. package/dist/shared/io/schemaVersion.js.map +1 -1
  90. package/dist/shared/loopCorePaths.d.ts.map +1 -1
  91. package/dist/shared/loopCorePaths.js +8 -2
  92. package/dist/shared/loopCorePaths.js.map +1 -1
  93. package/dist/shared/submission/expectedSubmissions.d.ts +88 -0
  94. package/dist/shared/submission/expectedSubmissions.d.ts.map +1 -0
  95. package/dist/shared/submission/expectedSubmissions.js +84 -0
  96. package/dist/shared/submission/expectedSubmissions.js.map +1 -0
  97. package/dist/shared/submission/handRecovery.d.ts +37 -0
  98. package/dist/shared/submission/handRecovery.d.ts.map +1 -0
  99. package/dist/shared/submission/handRecovery.js +123 -0
  100. package/dist/shared/submission/handRecovery.js.map +1 -0
  101. package/dist/shared/submission/submissionClassifier.d.ts +48 -0
  102. package/dist/shared/submission/submissionClassifier.d.ts.map +1 -0
  103. package/dist/shared/submission/submissionClassifier.js +83 -0
  104. package/dist/shared/submission/submissionClassifier.js.map +1 -0
  105. package/dist/shared/submission/submissionIdentity.d.ts +49 -0
  106. package/dist/shared/submission/submissionIdentity.d.ts.map +1 -0
  107. package/dist/shared/submission/submissionIdentity.js +90 -0
  108. package/dist/shared/submission/submissionIdentity.js.map +1 -0
  109. package/dist/shared/submission/submissionLedger.d.ts +37 -0
  110. package/dist/shared/submission/submissionLedger.d.ts.map +1 -0
  111. package/dist/shared/submission/submissionLedger.js +81 -0
  112. package/dist/shared/submission/submissionLedger.js.map +1 -0
  113. package/docs/audit-pkg/product.md +0 -2
  114. package/package.json +1 -1
@@ -9,9 +9,9 @@ import { mkdir, readFile, rename, unlink, writeFile } from "node:fs/promises";
9
9
  import { join } from "node:path";
10
10
  import { advance, DEFAULT_MAX_TRANSITIONS, isFileMissingError, isJsonParseError, readJsonFile, persistAnalyzerConsent, persistAnalyzerSettings, writeJsonFile, } from "audit-tools/shared";
11
11
  import { loadArtifactBundle, promoteFinalAuditReport, writeCoreArtifacts, } from "../io/artifacts.js";
12
- import { artifactTreeLockPath, auditReportPath, groundDesignFindings, promotedAuditReportPath, withFileLock, } from "audit-tools/shared";
13
- import { CharterSubmissionSchema, CharterDeltaSubmissionSchema, ClarificationAnswersSubmissionSchema, CriticalFlowFallbackResultSchema, SynthesisNarrativeSchema, SystemicChallengeSubmissionSchema, } from "audit-tools/shared";
12
+ import { artifactTreeLockPath, auditReportPath, groundDesignFindings, laneAssetsDir, promotedAuditReportPath, withFileLock, } from "audit-tools/shared";
14
13
  import { charterExtractionKindsForCeiling } from "./charterExtractionPrompt.js";
14
+ import { LANE_SUBMISSION_SCHEMAS, charterLaneSchema, describeSubmissionShapeMismatch, isSubmissionObjectMap, unwrapSubmissionArray, } from "./laneValidators.js";
15
15
  import { advanceAudit } from "../orchestrator/advance.js";
16
16
  import { captureDesignReviewSnapshot, isDesignReviewStale, } from "../orchestrator/designReviewSnapshot.js";
17
17
  import { computeArtifactStateSignature } from "../orchestrator/artifactMetadata.js";
@@ -20,22 +20,23 @@ import { isHostDelegationExecutor } from "../orchestrator/executors.js";
20
20
  import { resolveCharterCeiling, ceilingRequestsCharters, } from "../orchestrator/charterExtractionExecutor.js";
21
21
  import { resolveClarificationAttention } from "../orchestrator/charterClarificationExecutor.js";
22
22
  import { deriveAuditState } from "../orchestrator/state.js";
23
- import { deriveIntentEquivalenceStatus, IntentEquivalenceVerdictSchema, } from "../orchestrator/intentEquivalenceExecutor.js";
23
+ import { deriveIntentEquivalenceStatus } from "../orchestrator/intentEquivalenceExecutor.js";
24
24
  import { checkFileIntegrity } from "../orchestrator/fileIntegrity.js";
25
25
  import { graphEnrichmentUnresolvedAnalyzers, graphEnrichmentLowConfidenceEdges, pendingAnalyzerConsent, } from "../orchestrator/hostInputPause.js";
26
26
  import { runAuditStep } from "./auditStep.js";
27
27
  import { writeHandoffOnly, ensureSemanticReviewRun, loadCurrentActiveReviewRun, } from "./reviewRun.js";
28
28
  import { buildPendingAuditTasks } from "./dispatch.js";
29
29
  import { ingestAuditHostResults } from "./dispatch/hostHandoff.js";
30
+ import { CHARTER_EXTRACTION_MERGED_FILENAME, GATE_LANES, charterExtractionLane, charterExtractionPacketFilename, laneSubmissionPath, recordHostResultOutcomes, recordLaneOutcome, } from "./laneSubmissions.js";
30
31
  /**
31
- * Read a JSON file from the `incoming/` subdirectory of `artifactsDir`.
32
- * `ok` when the file exists and parses; `absent` on ENOENT-family errors;
33
- * `malformed` when the file exists but is not JSON — submitted content is the
34
- * CALLER's to quarantine, never an infrastructure failure. All other IO errors
35
- * re-throw unchanged.
32
+ * Read a lane's submission from the TOOL-COMPUTED path its emission bound —
33
+ * never a name a host could type. `ok` when the file exists and parses;
34
+ * `absent` on ENOENT-family errors; `malformed` when the file exists but is not
35
+ * JSON — submitted content is the CALLER's to quarantine, never an
36
+ * infrastructure failure. All other IO errors re-throw unchanged.
36
37
  */
37
- export async function tryConsumeIncoming(artifactsDir, filename) {
38
- const filePath = join(artifactsDir, "incoming", filename);
38
+ export async function tryConsumeSubmission(artifactsDir, lane) {
39
+ const filePath = laneSubmissionPath(artifactsDir, lane);
39
40
  try {
40
41
  const value = await readJsonFile(filePath);
41
42
  return { status: "ok", value, path: filePath };
@@ -149,8 +150,8 @@ export async function buildTerminalStep(params, bundle, state, blockedReason) {
149
150
  * mirroring the analyzer-install consent fold exactly:
150
151
  * - nothing pending (acquisition off / token present / all decided) → run the
151
152
  * deterministic acquisition executor (`fallthrough`);
152
- * - a decisions file arrived (`incoming/analyzer-consent-decisions.json`,
153
- * `{ "<id>": "granted" | "declined" }`) → persist the decisions into
153
+ * - a decisions submission arrived on the `analyzer_consent` lane
154
+ * (`{ "<id>": "granted" | "declined" }`) → persist the decisions into
154
155
  * session config (decisions durable, tokens never), fold them into the
155
156
  * in-flight acquisition options, and re-scan (`continue`);
156
157
  * - otherwise → emit the ONE batched operator-interactive offer step
@@ -167,43 +168,36 @@ export async function handleAnalyzerConsentBranch(params, bundle, state, analyze
167
168
  });
168
169
  if (pending.length === 0)
169
170
  return { action: "fallthrough" };
170
- const incoming = await consumeObjectIncoming(params.artifactsDir, "analyzer-consent-decisions.json");
171
+ const incoming = await consumeEnumMapSubmission(params.artifactsDir, GATE_LANES.analyzer_consent, ANALYZER_CONSENT_VALUES);
171
172
  if (incoming.status === "quarantined") {
172
173
  return { action: "continue" };
173
174
  }
174
175
  if (incoming.status === "ok") {
175
- const decisions = {};
176
- for (const [id, value] of Object.entries(incoming.value)) {
177
- if (value === "granted" || value === "declined") {
178
- decisions[id] = value;
179
- }
180
- }
181
- if (Object.keys(decisions).length > 0) {
182
- await persistAnalyzerConsent(params.root, decisions);
183
- if (params.externalAcquisition) {
184
- params.externalAcquisition.analyzerConsent = {
185
- ...(params.externalAcquisition.analyzerConsent ?? {}),
186
- ...decisions,
187
- };
188
- }
189
- }
190
- else {
191
- const invalidEntries = Object.keys(incoming.value).join(", ") || "(none)";
192
- process.stderr.write(`[audit-code] analyzer-consent-decisions.json ignored: no recognized values (got: ${invalidEntries}). ` +
193
- `Valid values are: granted, declined.\n`);
176
+ await persistAnalyzerConsent(params.root, incoming.values);
177
+ if (params.externalAcquisition) {
178
+ params.externalAcquisition.analyzerConsent = {
179
+ ...(params.externalAcquisition.analyzerConsent ?? {}),
180
+ ...incoming.values,
181
+ };
194
182
  }
195
183
  await unlink(incoming.path).catch(() => { });
184
+ await recordLaneOutcome(params.artifactsDir, GATE_LANES.analyzer_consent, {
185
+ kind: "accepted",
186
+ ...(incoming.ignored.length > 0
187
+ ? { message: describeIgnoredKeys(incoming.ignored, ANALYZER_CONSENT_VALUES) }
188
+ : {}),
189
+ });
196
190
  return { action: "continue" };
197
191
  }
198
192
  return { action: "return", result: { kind: "analyzer_consent", state, bundle, pending } };
199
193
  }
200
194
  /**
201
- * Handle the `graph_enrichment_executor` incoming-artifact polling block.
195
+ * Handle the `graph_enrichment_executor` submission-polling block.
202
196
  * Checks for pending analyzer install decisions and edge-reasoning results.
203
197
  * Returns an action object:
204
- * - `continue` → caller should keep folding (already consumed an artifact).
198
+ * - `continue` → caller should keep folding (already consumed a submission).
205
199
  * - `return` → caller should emit the embedded result to cmdNextStep.
206
- * - `fallthrough` → no incoming artifacts; run the deterministic executor.
200
+ * - `fallthrough` → nothing submitted; run the deterministic executor.
207
201
  */
208
202
  export async function handleGraphEnrichmentBranch(params, bundle, state, analyzersRef, deps = {}) {
209
203
  const runStep = deps.runStep ?? runAuditStep;
@@ -217,37 +211,23 @@ export async function handleGraphEnrichmentBranch(params, bundle, state, analyze
217
211
  };
218
212
  const unresolved = graphEnrichmentUnresolvedAnalyzers(bundle, pauseInputs);
219
213
  if (unresolved.length > 0) {
220
- const incoming = await consumeObjectIncoming(params.artifactsDir, "analyzer-decisions.json");
214
+ const incoming = await consumeEnumMapSubmission(params.artifactsDir, GATE_LANES.analyzer_decisions, ANALYZER_SETTING_VALUES);
221
215
  if (incoming.status === "quarantined") {
222
216
  // A non-object top-level value used to be neither merged, deleted, nor
223
- // diagnosed — the file lingered in incoming/ and the analyzer_install
217
+ // diagnosed — the file lingered at its bound path and the analyzer_install
224
218
  // step re-emitted silently forever. Quarantined + diagnosed instead.
225
219
  return { action: "continue" };
226
220
  }
227
221
  if (incoming.status === "ok") {
228
- const settings = {};
229
- for (const [id, value] of Object.entries(incoming.value)) {
230
- if (value === "ephemeral" ||
231
- value === "permanent" ||
232
- value === "skip" ||
233
- value === "repo" ||
234
- value === "auto") {
235
- settings[id] = value;
236
- }
237
- }
238
- if (Object.keys(settings).length > 0) {
239
- const merged = await persistAnalyzerSettings(params.root, settings);
240
- analyzersRef.value = merged.analyzers;
241
- }
242
- else {
243
- // All entries in analyzer-decisions.json failed the recognized-value
244
- // check (ephemeral|permanent|skip|repo|auto). Emit a diagnostic so the
245
- // operator knows why no settings were applied (COR-03418a9f fix).
246
- const invalidEntries = Object.keys(incoming.value).join(", ") || "(none)";
247
- process.stderr.write(`[audit-code] analyzer-decisions.json ignored: no recognized values (got: ${invalidEntries}). ` +
248
- `Valid values are: ephemeral, permanent, skip, repo, auto.\n`);
249
- }
222
+ const merged = await persistAnalyzerSettings(params.root, incoming.values);
223
+ analyzersRef.value = merged.analyzers;
250
224
  await unlink(incoming.path).catch(() => { });
225
+ await recordLaneOutcome(params.artifactsDir, GATE_LANES.analyzer_decisions, {
226
+ kind: "accepted",
227
+ ...(incoming.ignored.length > 0
228
+ ? { message: describeIgnoredKeys(incoming.ignored, ANALYZER_SETTING_VALUES) }
229
+ : {}),
230
+ });
251
231
  return { action: "continue" };
252
232
  }
253
233
  return { action: "return", result: { kind: "analyzer_install", state, bundle, unresolved } };
@@ -263,15 +243,20 @@ export async function handleGraphEnrichmentBranch(params, bundle, state, analyze
263
243
  {
264
244
  const candidates = graphEnrichmentLowConfidenceEdges(bundle, pauseInputs);
265
245
  if (candidates.length > 0) {
266
- const edgeReasoningIncoming = await tryConsumeIncoming(params.artifactsDir, "edge-reasoning.json");
246
+ const edgeReasoningIncoming = await tryConsumeSubmission(params.artifactsDir, GATE_LANES.edge_reasoning);
267
247
  if (edgeReasoningIncoming.status === "malformed") {
268
- const quarantinePath = await quarantineIncomingFile(params.artifactsDir, edgeReasoningIncoming.path, "edge-reasoning.json");
248
+ const quarantinePath = await quarantineSubmissionFile(params.artifactsDir, edgeReasoningIncoming.path, GATE_LANES.edge_reasoning);
269
249
  await recordEdgeReasoningRejection(params.artifactsDir, {
270
- filename: "edge-reasoning.json",
250
+ lane: GATE_LANES.edge_reasoning,
271
251
  quarantine_path: quarantinePath,
272
252
  reason: edgeReasoningIncoming.reason,
273
253
  rejected_at: new Date().toISOString(),
274
254
  });
255
+ await recordLaneOutcome(params.artifactsDir, GATE_LANES.edge_reasoning, {
256
+ kind: "rejected",
257
+ issueCode: "submission_malformed",
258
+ message: edgeReasoningIncoming.reason,
259
+ });
275
260
  return { action: "continue" };
276
261
  }
277
262
  if (edgeReasoningIncoming.status === "ok") {
@@ -283,18 +268,23 @@ export async function handleGraphEnrichmentBranch(params, bundle, state, analyze
283
268
  // so the same "exactly one array-valued top-level property" rule
284
269
  // applies, and a bare rewrites array is accepted too); anything else is
285
270
  // quarantined and named in the re-emitted step's prompt.
286
- const unwrapped = unwrapIncomingArray(edgeReasoningIncoming.value);
271
+ const unwrapped = unwrapSubmissionArray(edgeReasoningIncoming.value);
287
272
  if (!unwrapped.ok) {
288
- const quarantinePath = await quarantineIncomingFile(params.artifactsDir, edgeReasoningIncoming.path, "edge-reasoning.json");
273
+ const quarantinePath = await quarantineSubmissionFile(params.artifactsDir, edgeReasoningIncoming.path, GATE_LANES.edge_reasoning);
289
274
  await recordEdgeReasoningRejection(params.artifactsDir, {
290
- filename: "edge-reasoning.json",
275
+ lane: GATE_LANES.edge_reasoning,
291
276
  quarantine_path: quarantinePath,
292
277
  reason: unwrapped.reason,
293
278
  rejected_at: new Date().toISOString(),
294
279
  });
280
+ await recordLaneOutcome(params.artifactsDir, GATE_LANES.edge_reasoning, {
281
+ kind: "rejected",
282
+ issueCode: "submission_contract_invalid",
283
+ message: unwrapped.reason,
284
+ });
295
285
  return { action: "continue" };
296
286
  }
297
- // Apply BEFORE deleting the incoming file: if runStep throws (locks,
287
+ // Apply BEFORE deleting the submission: if runStep throws (locks,
298
288
  // crash), the submission survives for the retry instead of being lost.
299
289
  await runStep({
300
290
  root: params.root,
@@ -306,6 +296,9 @@ export async function handleGraphEnrichmentBranch(params, bundle, state, analyze
306
296
  });
307
297
  await unlink(edgeReasoningIncoming.path).catch(() => { });
308
298
  await clearEdgeReasoningRejection(params.artifactsDir);
299
+ await recordLaneOutcome(params.artifactsDir, GATE_LANES.edge_reasoning, {
300
+ kind: "accepted",
301
+ });
309
302
  return { action: "continue" };
310
303
  }
311
304
  return { action: "return", result: { kind: "edge_reasoning", state, bundle, candidates } };
@@ -317,11 +310,11 @@ export async function handleGraphEnrichmentBranch(params, bundle, state, analyze
317
310
  return { action: "fallthrough" };
318
311
  }
319
312
  /**
320
- * Handle the `design_review_contract` or `design_review_conceptual` incoming-artifact
321
- * polling blocks. Checks for contract and/or conceptual findings files independently.
313
+ * Handle the `design_review_contract` or `design_review_conceptual` submission
314
+ * polling blocks. Checks the contract and conceptual lanes independently.
322
315
  *
323
316
  * Returns:
324
- * - `continue` → one or both incoming files were consumed; keep folding.
317
+ * - `continue` → one or both lane submissions were consumed; keep folding.
325
318
  * - `design_review_parallel` → both passes still needed; dispatch two subagents.
326
319
  * - `design_review_contract` → only contract pass still needed.
327
320
  * - `design_review_conceptual` → only conceptual pass still needed.
@@ -333,37 +326,17 @@ function passIsStale(bundle, pass) {
333
326
  const snapshot = bundle.design_review_snapshots?.[pass];
334
327
  return snapshot ? isDesignReviewStale(snapshot, bundle) : false;
335
328
  }
336
- /** Human-readable description of why an incoming value is neither an array nor a single-array-wrapped object. */
337
- function describeIncomingShapeMismatch(value) {
338
- if (value === null)
339
- return "null";
340
- if (Array.isArray(value))
341
- return "an array"; // reachable from consumeObjectIncoming (an array is not a key→value map)
342
- const t = typeof value;
343
- if (t !== "object")
344
- return `a bare ${t}`;
345
- const entries = Object.entries(value);
346
- if (entries.length === 0)
347
- return "an empty object";
348
- const arrayKeys = entries.filter(([, v]) => Array.isArray(v)).map(([k]) => k);
349
- const allKeys = entries.map(([k]) => k).join(", ");
350
- if (arrayKeys.length === 0) {
351
- return `an object with no array-valued properties (keys: ${allKeys})`;
352
- }
353
- return (`an object with ${arrayKeys.length} array-valued propert${arrayKeys.length === 1 ? "y" : "ies"} ` +
354
- `out of ${entries.length} total key(s) (${allKeys}) — exactly one top-level array property is ` +
355
- `required for the tolerant unwrap`);
356
- }
357
329
  /**
358
- * Move a malformed incoming submission to `<artifactsDir>/quarantine/` rather
359
- * than deleting it. Falls back to copy+unlink if `rename` fails (e.g. a
360
- * cross-device incoming/ mount) so the content is never lost.
330
+ * Move a refused submission to `<artifactsDir>/quarantine/` rather than
331
+ * deleting it. Falls back to copy+unlink if `rename` fails (e.g. a cross-device
332
+ * artifacts mount) so the content is never lost. The quarantined file is named
333
+ * for its LANE — the bound path is a digest, which tells an operator nothing.
361
334
  */
362
- async function quarantineIncomingFile(artifactsDir, filePath, originalFilename) {
335
+ async function quarantineSubmissionFile(artifactsDir, filePath, lane) {
363
336
  const quarantineDir = join(artifactsDir, "quarantine");
364
337
  await mkdir(quarantineDir, { recursive: true });
365
338
  const timestamp = new Date().toISOString().replace(/[:.]/g, "-");
366
- const quarantinePath = join(quarantineDir, `${originalFilename}.${timestamp}.json`);
339
+ const quarantinePath = join(quarantineDir, `${lane}.${timestamp}.json`);
367
340
  try {
368
341
  await rename(filePath, quarantinePath);
369
342
  }
@@ -381,94 +354,165 @@ async function quarantineIncomingFile(artifactsDir, filePath, originalFilename)
381
354
  }
382
355
  /**
383
356
  * Quarantine a submission that failed zod validation — or failed to parse as
384
- * JSON at all (a plain string reason): move it out of `incoming/` (never
385
- * unlink-and-discard) and write a stderr diagnostic naming the quarantined
386
- * file + the error. The single loud-quarantine path shared by every
387
- * schema-validated incoming gate (`runOmittableGate` + `handleIntentEquivalenceBranch`
388
- * + the charter lane loop) so the "quarantine loudly" property cannot drift
389
- * between them. Returns the quarantine path.
357
+ * JSON at all (a plain string reason): move it off its bound path (never
358
+ * unlink-and-discard), write a stderr diagnostic naming the quarantined file +
359
+ * the error, and record the refusal on the submission ledger so a repaired run
360
+ * stays distinguishable from a clean one. The single loud-quarantine path
361
+ * shared by every schema-validated gate (`runOmittableGate` +
362
+ * `handleIntentEquivalenceBranch` + the charter lane loop) so the "quarantine
363
+ * loudly" property cannot drift between them. Returns the quarantine path.
390
364
  */
391
- async function quarantineMisshapedIncoming(artifactsDir, filePath, filename, error) {
365
+ async function quarantineMisshapedSubmission(artifactsDir, filePath, lane, error) {
392
366
  const reason = typeof error === "string"
393
367
  ? error
394
368
  : error.issues
395
369
  .map((issue) => `${issue.path.join(".") || "(root)"}: ${issue.message}`)
396
370
  .join("; ");
397
- const quarantinePath = await quarantineIncomingFile(artifactsDir, filePath, filename);
398
- process.stderr.write(`[audit-code] ${filename} quarantined to ${quarantinePath}: ${reason}. ` +
371
+ const quarantinePath = await quarantineSubmissionFile(artifactsDir, filePath, lane);
372
+ process.stderr.write(`[audit-code] ${lane} submission quarantined to ${quarantinePath}: ${reason}. ` +
399
373
  `Fix the shape and resubmit.\n`);
374
+ await recordLaneOutcome(artifactsDir, lane, {
375
+ kind: "rejected",
376
+ issueCode: typeof error === "string" ? "submission_malformed" : "submission_contract_invalid",
377
+ message: reason,
378
+ });
400
379
  return quarantinePath;
401
380
  }
402
381
  /**
403
- * The single tolerant-unwrap rule: a bare array is accepted as-is; a top-level
404
- * object wrapping exactly one array-valued property is unambiguous and is
405
- * accepted as that array. Anything else fails with a shape description.
406
- * Single-sourced so `consumeArrayIncoming` and the edge-reasoning gate cannot
407
- * drift on what shapes are accepted.
408
- */
409
- function unwrapIncomingArray(value) {
410
- if (Array.isArray(value))
411
- return { ok: true, array: value };
412
- if (value !== null && typeof value === "object") {
413
- const entries = Object.entries(value);
414
- if (entries.length === 1 && Array.isArray(entries[0][1])) {
415
- return { ok: true, array: entries[0][1] };
416
- }
417
- }
418
- return { ok: false, reason: describeIncomingShapeMismatch(value) };
419
- }
420
- /**
421
- * Read a JSON `incoming/` file expected to be an array (or a top-level object
382
+ * Read a lane submission expected to be an array (or a top-level object
422
383
  * wrapping exactly one array-valued property, the tolerant unwrap). Accepts
423
- * either shape and deletes the source file; any other shape is quarantined
424
- * (never unlinked-and-discarded) and reported with a reason.
384
+ * either shape; any other shape is quarantined (never unlinked-and-discarded)
385
+ * and reported with a reason.
386
+ *
387
+ * An accepted submission is NOT deleted here (P25-f) — the caller unlinks after
388
+ * it has applied the value, so a submission is never destroyed before its
389
+ * content has landed somewhere else.
425
390
  */
426
- export async function consumeArrayIncoming(artifactsDir, filename) {
427
- const incoming = await tryConsumeIncoming(artifactsDir, filename);
391
+ export async function consumeArraySubmission(artifactsDir, lane) {
392
+ const incoming = await tryConsumeSubmission(artifactsDir, lane);
428
393
  if (incoming.status === "absent")
429
394
  return { status: "absent" };
430
395
  if (incoming.status === "malformed") {
431
- const quarantinePath = await quarantineIncomingFile(artifactsDir, incoming.path, filename);
432
- return { status: "quarantined", quarantinePath, originalFilename: filename, reason: incoming.reason };
396
+ const quarantinePath = await quarantineSubmissionFile(artifactsDir, incoming.path, lane);
397
+ await recordLaneOutcome(artifactsDir, lane, {
398
+ kind: "rejected",
399
+ issueCode: "submission_malformed",
400
+ message: incoming.reason,
401
+ });
402
+ return { status: "quarantined", quarantinePath, lane, reason: incoming.reason };
433
403
  }
434
404
  const { value, path } = incoming;
435
- const unwrapped = unwrapIncomingArray(value);
405
+ const unwrapped = unwrapSubmissionArray(value);
436
406
  if (unwrapped.ok) {
437
- await unlink(path).catch(() => { });
438
407
  return { status: "ok", value: unwrapped.array, path };
439
408
  }
440
- const quarantinePath = await quarantineIncomingFile(artifactsDir, path, filename);
441
- return { status: "quarantined", quarantinePath, originalFilename: filename, reason: unwrapped.reason };
409
+ const quarantinePath = await quarantineSubmissionFile(artifactsDir, path, lane);
410
+ await recordLaneOutcome(artifactsDir, lane, {
411
+ kind: "rejected",
412
+ issueCode: "submission_contract_invalid",
413
+ message: unwrapped.reason,
414
+ });
415
+ return { status: "quarantined", quarantinePath, lane, reason: unwrapped.reason };
442
416
  }
443
417
  /**
444
- * Read a JSON `incoming/` file expected to be a plain top-level object (a
445
- * key → value map, e.g. analyzer-decisions.json). A non-object value — null,
418
+ * Read a lane submission expected to be a plain top-level object (a
419
+ * key → value map, e.g. the analyzer decisions). A non-object value — null,
446
420
  * an array, a bare primitive — is quarantined with a stderr diagnostic rather
447
- * than left lingering in `incoming/` (where it used to make the emitting step
448
- * re-ask silently forever). Unlike `consumeArrayIncoming`, an accepted file is
449
- * NOT deleted here — the caller unlinks after applying, so a crash mid-apply
450
- * retains the submission for the retry.
421
+ * than left lingering at the bound path (where it used to make the emitting
422
+ * step re-ask silently forever). An accepted file is NOT deleted here — the
423
+ * caller unlinks after applying, so a crash mid-apply retains the submission
424
+ * for the retry.
451
425
  */
452
- export async function consumeObjectIncoming(artifactsDir, filename) {
453
- const incoming = await tryConsumeIncoming(artifactsDir, filename);
426
+ export async function consumeObjectSubmission(artifactsDir, lane) {
427
+ const incoming = await tryConsumeSubmission(artifactsDir, lane);
454
428
  if (incoming.status === "absent")
455
429
  return { status: "absent" };
456
430
  if (incoming.status === "malformed") {
457
- const quarantinePath = await quarantineIncomingFile(artifactsDir, incoming.path, filename);
458
- process.stderr.write(`[audit-code] ${filename} quarantined to ${quarantinePath}: ${incoming.reason}. ` +
431
+ const quarantinePath = await quarantineSubmissionFile(artifactsDir, incoming.path, lane);
432
+ process.stderr.write(`[audit-code] ${lane} submission quarantined to ${quarantinePath}: ${incoming.reason}. ` +
459
433
  `Fix the JSON and resubmit.\n`);
434
+ await recordLaneOutcome(artifactsDir, lane, {
435
+ kind: "rejected",
436
+ issueCode: "submission_malformed",
437
+ message: incoming.reason,
438
+ });
460
439
  return { status: "quarantined", quarantinePath, reason: incoming.reason };
461
440
  }
462
441
  const { value, path } = incoming;
463
- if (value !== null && typeof value === "object" && !Array.isArray(value)) {
464
- return { status: "ok", value: value, path };
442
+ if (isSubmissionObjectMap(value)) {
443
+ return { status: "ok", value, path };
465
444
  }
466
- const reason = describeIncomingShapeMismatch(value);
467
- const quarantinePath = await quarantineIncomingFile(artifactsDir, path, filename);
468
- process.stderr.write(`[audit-code] ${filename} quarantined to ${quarantinePath}: expected a JSON object, got ${reason}. ` +
445
+ const reason = describeSubmissionShapeMismatch(value);
446
+ const quarantinePath = await quarantineSubmissionFile(artifactsDir, path, lane);
447
+ process.stderr.write(`[audit-code] ${lane} submission quarantined to ${quarantinePath}: expected a JSON object, got ${reason}. ` +
469
448
  `Fix the shape and resubmit.\n`);
449
+ await recordLaneOutcome(artifactsDir, lane, {
450
+ kind: "rejected",
451
+ issueCode: "submission_contract_invalid",
452
+ message: reason,
453
+ });
470
454
  return { status: "quarantined", quarantinePath, reason };
471
455
  }
456
+ /** The two decision vocabularies the operator-facing analyzer gates accept. */
457
+ const ANALYZER_CONSENT_VALUES = ["granted", "declined"];
458
+ const ANALYZER_SETTING_VALUES = [
459
+ "ephemeral",
460
+ "permanent",
461
+ "skip",
462
+ "repo",
463
+ "auto",
464
+ ];
465
+ /** Name the keys that carried no recognized value, for the ledger event. */
466
+ function describeIgnoredKeys(ignored, allowed) {
467
+ return (`ignored ${ignored.length} unrecognized entr${ignored.length === 1 ? "y" : "ies"} ` +
468
+ `(${ignored.join(", ")}); recognized values are: ${allowed.join(", ")}`);
469
+ }
470
+ /**
471
+ * Read a decisions submission — a `{ "<id>": "<one of a fixed vocabulary>" }`
472
+ * map — for the two operator-facing analyzer gates.
473
+ *
474
+ * The value-enum filter used to be per-gate, and both copies treated a
475
+ * submission with ZERO recognized values as a SUCCESS: nothing was applied, the
476
+ * file was deleted, and an `accepted` event went on the ledger, so a host that
477
+ * answered in the wrong vocabulary had its answer destroyed, the run recorded
478
+ * as clean, and the identical step re-emitted. A submission that says nothing
479
+ * the gate understands is a refusal: it is quarantined (bytes kept), recorded
480
+ * `rejected`, and re-asked. PARTIAL recognition still applies — the operator's
481
+ * real decisions are not held hostage to one typo — with the ignored keys named
482
+ * on the accepting event so the omission is on the record rather than in a
483
+ * stderr line nobody kept.
484
+ */
485
+ async function consumeEnumMapSubmission(artifactsDir, lane, allowed) {
486
+ const incoming = await consumeObjectSubmission(artifactsDir, lane);
487
+ if (incoming.status === "absent")
488
+ return { status: "absent" };
489
+ if (incoming.status === "quarantined")
490
+ return { status: "quarantined" };
491
+ const values = {};
492
+ const ignored = [];
493
+ for (const [id, value] of Object.entries(incoming.value)) {
494
+ if (allowed.includes(value)) {
495
+ values[id] = value;
496
+ }
497
+ else {
498
+ ignored.push(id);
499
+ }
500
+ }
501
+ if (Object.keys(values).length > 0) {
502
+ return { status: "ok", values, ignored, path: incoming.path };
503
+ }
504
+ const reason = `no recognized values (got: ${Object.keys(incoming.value).join(", ") || "(none)"}). ` +
505
+ `Valid values are: ${allowed.join(", ")}.`;
506
+ const quarantinePath = await quarantineSubmissionFile(artifactsDir, incoming.path, lane);
507
+ process.stderr.write(`[audit-code] ${lane} submission quarantined to ${quarantinePath}: ${reason} ` +
508
+ `Fix the values and resubmit.\n`);
509
+ await recordLaneOutcome(artifactsDir, lane, {
510
+ kind: "rejected",
511
+ issueCode: "submission_contract_invalid",
512
+ message: reason,
513
+ });
514
+ return { status: "quarantined" };
515
+ }
472
516
  function edgeReasoningRejectionPath(artifactsDir) {
473
517
  return join(artifactsDir, "quarantine", "edge-reasoning.rejection.json");
474
518
  }
@@ -502,27 +546,38 @@ export async function renderEdgeReasoningRejectionNotice(artifactsDir) {
502
546
  "Your last edge-reasoning submission did not match the expected shape and was " +
503
547
  "quarantined (not applied, not silently discarded). Fix the shape and resubmit:",
504
548
  "",
505
- `- \`${rejection.filename}\` quarantined to \`${rejection.quarantine_path}\` (${rejection.rejected_at}): ${rejection.reason}`,
549
+ `- lane \`${rejection.lane}\` quarantined to \`${rejection.quarantine_path}\` (${rejection.rejected_at}): ${rejection.reason}`,
506
550
  "",
507
551
  'Expected shape: {"rewrites":[{"from":"...","to":"...","kind":"...","reason":"..."}]} — ' +
508
552
  "a bare JSON array of rewrites is also accepted.",
509
553
  ].join("\n");
510
554
  }
511
555
  /**
512
- * Record a quarantined design-review submission on `design_assessment` (and
513
- * persist immediately) so the note survives the same-call `continue`
514
- * re-derivation. A no-op when `design_assessment` doesn't exist yet in
515
- * practice unreachable, since `design_assessment_current` is a
516
- * higher-priority obligation than either design-review pass (PRIORITY in
517
- * nextStep.ts), so `design_assessment` always exists by the time this branch
518
- * runs.
556
+ * Record a quarantined design-review submission.
557
+ *
558
+ * TWO homes, because they answer different questions and one of them can be
559
+ * absent. `design_assessment.rejected_submissions` is what the re-emitted step
560
+ * reads back to tell the host WHY its last submission was refused — so it is
561
+ * written whenever there is an assessment to write it on. The submission ledger
562
+ * is the durable record that a refusal happened at all, and it is written
563
+ * unconditionally.
564
+ *
565
+ * This function used to open `if (!existing) return;`, which meant the one case
566
+ * where a submission had no merge target — exactly the case where the host most
567
+ * needs to be told something went wrong — recorded nothing anywhere. The
568
+ * ledger closes that hole without inventing a partial `design_assessment.json`
569
+ * for an assessment that does not exist yet: every quarantine reaches it, but
570
+ * through the ONE site that performs the quarantine (`consumeArraySubmission`,
571
+ * or `holdWithoutTarget` for the no-merge-target case), never a second append
572
+ * here. Two appends per refusal made a single rejection read as two on a record
573
+ * whose whole value is counting them.
519
574
  */
520
575
  async function recordRejectedDesignReviewSubmission(artifactsDir, existing, pass, quarantined) {
521
576
  if (!existing)
522
577
  return;
523
578
  const entry = {
524
579
  pass,
525
- filename: quarantined.originalFilename,
580
+ lane: quarantined.lane,
526
581
  quarantine_path: quarantined.quarantinePath,
527
582
  reason: quarantined.reason,
528
583
  rejected_at: new Date().toISOString(),
@@ -552,7 +607,7 @@ export function renderDesignReviewRejectionNotice(bundle, passes) {
552
607
  "",
553
608
  ];
554
609
  for (const r of rejected) {
555
- lines.push(`- **${r.pass}** — \`${r.filename}\` quarantined to \`${r.quarantine_path}\` (${r.rejected_at}): ${r.reason}`);
610
+ lines.push(`- **${r.pass}** — lane \`${r.lane}\` quarantined to \`${r.quarantine_path}\` (${r.rejected_at}): ${r.reason}`);
556
611
  }
557
612
  lines.push("", "Expected shape: a JSON array of findings, or a top-level object wrapping exactly " +
558
613
  'one array-valued property (e.g. `{"findings": [...]}`).');
@@ -560,9 +615,42 @@ export function renderDesignReviewRejectionNotice(bundle, passes) {
560
615
  }
561
616
  export async function handleDesignReviewBranch(params, bundle, state) {
562
617
  const existing = bundle.design_assessment;
563
- // Legacy: consume old combined findings file. Tolerant-unwrap or quarantine
564
- // (never a bare unconditional delete) see the quarantine block comment above.
565
- const legacyResult = await consumeArrayIncoming(params.artifactsDir, "design-review-findings.json");
618
+ /**
619
+ * P25-f: a valid submission with nowhere to merge it is HELD and RECORDED,
620
+ * never consumed-and-dropped. `design_assessment` is normally present by the
621
+ * time this branch runs (`design_assessment_current` outranks both review
622
+ * passes in PRIORITY) — but that is a reasoning argument, and the old code
623
+ * relied on it: it deleted the file at unwrap time and then skipped the merge
624
+ * on `&& existing`, destroying valid work and re-emitting the identical step
625
+ * with zero signal to the host. Quarantining keeps the bytes and puts the
626
+ * refusal on the record, so the next pass can still use them.
627
+ */
628
+ const holdWithoutTarget = async (pass, lane, path) => {
629
+ const reason = "no design assessment exists yet to merge this submission into";
630
+ const quarantinePath = await quarantineSubmissionFile(params.artifactsDir, path, lane);
631
+ process.stderr.write(`[audit-code] ${lane} submission quarantined to ${quarantinePath}: no design ` +
632
+ `assessment exists yet to merge it into. Re-run next-step; the assessment is ` +
633
+ `built by a higher-priority obligation.\n`);
634
+ // The ledger append belongs to the site that performs the quarantine — this
635
+ // is the one quarantine `consumeArraySubmission` does not do — so a refusal
636
+ // is recorded exactly once no matter which path refused it. With no
637
+ // assessment on disk there is no `rejected_submissions` note to write, and
638
+ // this event is the only durable record that the submission was received.
639
+ await recordLaneOutcome(params.artifactsDir, lane, {
640
+ kind: "rejected",
641
+ issueCode: "submission_rejected",
642
+ message: `${pass} pass: ${reason}`,
643
+ });
644
+ await recordRejectedDesignReviewSubmission(params.artifactsDir, existing, pass, {
645
+ status: "quarantined",
646
+ quarantinePath,
647
+ lane,
648
+ reason,
649
+ });
650
+ };
651
+ // Legacy: consume the old combined findings submission. Tolerant-unwrap or
652
+ // quarantine (never a bare unconditional delete) — see the block comment above.
653
+ const legacyResult = await consumeArraySubmission(params.artifactsDir, GATE_LANES.design_review_legacy);
566
654
  if (legacyResult.status === "quarantined") {
567
655
  await recordRejectedDesignReviewSubmission(params.artifactsDir, existing, "legacy", legacyResult);
568
656
  return { action: "continue" };
@@ -573,15 +661,19 @@ export async function handleDesignReviewBranch(params, bundle, state) {
573
661
  existing.reviewed = true;
574
662
  existing.rejected_submissions = (existing.rejected_submissions ?? []).filter((r) => r.pass !== "legacy");
575
663
  await writeJsonFile(join(params.artifactsDir, "design_assessment.json"), existing);
664
+ await unlink(legacyResult.path).catch(() => { });
665
+ await recordLaneOutcome(params.artifactsDir, GATE_LANES.design_review_legacy, {
666
+ kind: "accepted",
667
+ });
576
668
  return { action: "continue" };
577
669
  }
578
- // File consumed but no target to merge into — keep folding.
670
+ await holdWithoutTarget("legacy", GATE_LANES.design_review_legacy, legacyResult.path);
579
671
  return { action: "continue" };
580
672
  }
581
673
  // absent: fall through to the contract/conceptual check.
582
674
  // New: consume contract-findings and/or conceptual-findings independently.
583
- const contractResult = await consumeArrayIncoming(params.artifactsDir, "design-review-contract-findings.json");
584
- const conceptualResult = await consumeArrayIncoming(params.artifactsDir, "design-review-conceptual-findings.json");
675
+ const contractResult = await consumeArraySubmission(params.artifactsDir, GATE_LANES.design_review_contract);
676
+ const conceptualResult = await consumeArraySubmission(params.artifactsDir, GATE_LANES.design_review_conceptual);
585
677
  let consumed = false;
586
678
  if (contractResult.status === "quarantined") {
587
679
  await recordRejectedDesignReviewSubmission(params.artifactsDir, existing, "contract", contractResult);
@@ -592,6 +684,9 @@ export async function handleDesignReviewBranch(params, bundle, state) {
592
684
  existing.rejected_submissions = (existing.rejected_submissions ?? []).filter((r) => r.pass !== "contract");
593
685
  consumed = true;
594
686
  }
687
+ else if (contractResult.status === "ok") {
688
+ await holdWithoutTarget("contract", GATE_LANES.design_review_contract, contractResult.path);
689
+ }
595
690
  if (conceptualResult.status === "quarantined") {
596
691
  await recordRejectedDesignReviewSubmission(params.artifactsDir, existing, "conceptual", conceptualResult);
597
692
  }
@@ -601,6 +696,9 @@ export async function handleDesignReviewBranch(params, bundle, state) {
601
696
  existing.rejected_submissions = (existing.rejected_submissions ?? []).filter((r) => r.pass !== "conceptual");
602
697
  consumed = true;
603
698
  }
699
+ else if (conceptualResult.status === "ok") {
700
+ await holdWithoutTarget("conceptual", GATE_LANES.design_review_conceptual, conceptualResult.path);
701
+ }
604
702
  if (consumed && existing) {
605
703
  await writeJsonFile(join(params.artifactsDir, "design_assessment.json"), existing);
606
704
  // Snapshot each just-completed pass (B2 parity port): record the verdict +
@@ -611,9 +709,19 @@ export async function handleDesignReviewBranch(params, bundle, state) {
611
709
  const reviewedAt = new Date().toISOString();
612
710
  if (contractResult.status === "ok") {
613
711
  await captureDesignReviewSnapshot(params.artifactsDir, "contract", existing.contract_findings ?? [], bundle, reviewedAt);
712
+ // Unlink only now: the findings are persisted and snapshotted, so the
713
+ // submission's content survives its own deletion.
714
+ await unlink(contractResult.path).catch(() => { });
715
+ await recordLaneOutcome(params.artifactsDir, GATE_LANES.design_review_contract, {
716
+ kind: "accepted",
717
+ });
614
718
  }
615
719
  if (conceptualResult.status === "ok") {
616
720
  await captureDesignReviewSnapshot(params.artifactsDir, "conceptual", existing.conceptual_findings ?? [], bundle, reviewedAt);
721
+ await unlink(conceptualResult.path).catch(() => { });
722
+ await recordLaneOutcome(params.artifactsDir, GATE_LANES.design_review_conceptual, {
723
+ kind: "accepted",
724
+ });
617
725
  }
618
726
  return { action: "continue" };
619
727
  }
@@ -636,28 +744,29 @@ export async function handleDesignReviewBranch(params, bundle, state) {
636
744
  return { action: "continue" };
637
745
  }
638
746
  /**
639
- * Drive one "poll incoming → apply+continue, else omit-or-return" gate — the
747
+ * Drive one "poll the lane → apply+continue, else omit-or-return" gate — the
640
748
  * shape common to synthesis_narrative, charter_extraction,
641
749
  * charter_clarification, and systemic_challenge. See the section comment
642
750
  * above for the two gates that deviate and are not run through this engine.
643
751
  */
644
752
  async function runOmittableGate(descriptor, params, bundle, state) {
645
- const incoming = await tryConsumeIncoming(params.artifactsDir, descriptor.filename);
753
+ const incoming = await tryConsumeSubmission(params.artifactsDir, descriptor.lane);
646
754
  if (incoming.status === "malformed") {
647
755
  // Not-JSON submission: same quarantine-loudly lifecycle as a mis-shaped one.
648
- await quarantineMisshapedIncoming(params.artifactsDir, incoming.path, descriptor.filename, incoming.reason);
756
+ await quarantineMisshapedSubmission(params.artifactsDir, incoming.path, descriptor.lane, incoming.reason);
649
757
  }
650
758
  else if (incoming.status === "ok") {
651
759
  const parsed = descriptor.schema.safeParse(incoming.value);
652
760
  if (parsed.success) {
653
761
  await descriptor.apply(parsed.data, incoming.path, params);
654
762
  await unlink(incoming.path).catch(() => { });
763
+ await recordLaneOutcome(params.artifactsDir, descriptor.lane, { kind: "accepted" });
655
764
  return { action: "continue" };
656
765
  }
657
766
  // Mis-shaped submission: quarantine loudly and fall through to
658
767
  // shouldOmit/return — never hand it to the executor to crash on or silently
659
768
  // treat as an empty "reviewed, found nothing" result.
660
- await quarantineMisshapedIncoming(params.artifactsDir, incoming.path, descriptor.filename, parsed.error);
769
+ await quarantineMisshapedSubmission(params.artifactsDir, incoming.path, descriptor.lane, parsed.error);
661
770
  }
662
771
  if (descriptor.shouldOmit(bundle)) {
663
772
  return { action: "run_omit" };
@@ -665,9 +774,9 @@ async function runOmittableGate(descriptor, params, bundle, state) {
665
774
  return { action: "return", result: { kind: descriptor.kind, state, bundle } };
666
775
  }
667
776
  /**
668
- * Handle the `synthesis_narrative_executor` incoming-artifact polling block.
777
+ * Handle the `synthesis_narrative_executor` submission-polling block.
669
778
  * Returns:
670
- * - `continue` → an incoming narrative file was consumed + applied (progress
779
+ * - `continue` → a narrative submission was consumed + applied (progress
671
780
  * made); re-scan on the reloaded bundle.
672
781
  * - `return` → a host turn is still needed (narrative enabled, none supplied
673
782
  * yet); emit the synthesis_narrative step.
@@ -679,8 +788,8 @@ async function runOmittableGate(descriptor, params, bundle, state) {
679
788
  export async function handleSynthesisNarrativeBranch(params, bundle, state) {
680
789
  return runOmittableGate({
681
790
  kind: "synthesis_narrative",
682
- filename: "synthesis-narrative.json",
683
- schema: SynthesisNarrativeSchema,
791
+ lane: GATE_LANES.synthesis_narrative,
792
+ schema: LANE_SUBMISSION_SCHEMAS[GATE_LANES.synthesis_narrative],
684
793
  apply: async (_value, path, p) => {
685
794
  await runAuditStep({
686
795
  root: p.root,
@@ -705,14 +814,14 @@ export async function handleSynthesisNarrativeBranch(params, bundle, state) {
705
814
  * - `return` → a prose-only delta awaits the host judge; emit the step.
706
815
  */
707
816
  export async function handleIntentEquivalenceBranch(params, bundle, state) {
708
- const filename = "intent-equivalence-verdict.json";
709
- const incoming = await tryConsumeIncoming(params.artifactsDir, filename);
817
+ const lane = GATE_LANES.intent_equivalence;
818
+ const incoming = await tryConsumeSubmission(params.artifactsDir, lane);
710
819
  if (incoming.status === "malformed") {
711
- await quarantineMisshapedIncoming(params.artifactsDir, incoming.path, filename, incoming.reason);
820
+ await quarantineMisshapedSubmission(params.artifactsDir, incoming.path, lane, incoming.reason);
712
821
  // Fall through: no valid submission — re-emit or deterministically resolve.
713
822
  }
714
823
  else if (incoming.status === "ok") {
715
- const parsed = IntentEquivalenceVerdictSchema.safeParse(incoming.value);
824
+ const parsed = LANE_SUBMISSION_SCHEMAS[lane].safeParse(incoming.value);
716
825
  if (parsed.success) {
717
826
  await runAuditStep({
718
827
  root: params.root,
@@ -721,9 +830,10 @@ export async function handleIntentEquivalenceBranch(params, bundle, state) {
721
830
  intentEquivalenceVerdictPath: incoming.path,
722
831
  });
723
832
  await unlink(incoming.path).catch(() => { });
833
+ await recordLaneOutcome(params.artifactsDir, lane, { kind: "accepted" });
724
834
  return { action: "continue" };
725
835
  }
726
- await quarantineMisshapedIncoming(params.artifactsDir, incoming.path, filename, parsed.error);
836
+ await quarantineMisshapedSubmission(params.artifactsDir, incoming.path, lane, parsed.error);
727
837
  // Fall through: no valid submission — re-emit or deterministically resolve.
728
838
  }
729
839
  if (deriveIntentEquivalenceStatus(bundle).kind !== "prose_judgment_pending") {
@@ -732,7 +842,7 @@ export async function handleIntentEquivalenceBranch(params, bundle, state) {
732
842
  return { action: "return", result: { kind: "intent_equivalence", state, bundle } };
733
843
  }
734
844
  /**
735
- * Handle the `critical_flow_fallback_executor` incoming-artifact polling block.
845
+ * Handle the `critical_flow_fallback_executor` submission-polling block.
736
846
  * The obligation is only ever selected when the deterministic flow inference
737
847
  * marked itself below the confidence bar (`critical_flows.fallback_required`),
738
848
  * so — unlike the synthesis-narrative / charter gates — there is NO autonomous
@@ -746,8 +856,8 @@ export async function handleIntentEquivalenceBranch(params, bundle, state) {
746
856
  export async function handleCriticalFlowFallbackBranch(params, bundle, state) {
747
857
  return runOmittableGate({
748
858
  kind: "critical_flow_fallback",
749
- filename: "critical-flow-fallback.json",
750
- schema: CriticalFlowFallbackResultSchema,
859
+ lane: GATE_LANES.critical_flow_fallback,
860
+ schema: LANE_SUBMISSION_SCHEMAS[GATE_LANES.critical_flow_fallback],
751
861
  apply: async (_value, path, p) => {
752
862
  await runAuditStep({
753
863
  root: p.root,
@@ -762,9 +872,9 @@ export async function handleCriticalFlowFallbackBranch(params, bundle, state) {
762
872
  }, params, bundle, state);
763
873
  }
764
874
  /**
765
- * Handle the `charter_extraction_executor` incoming-artifact polling block
875
+ * Handle the `charter_extraction_executor` submission-polling block
766
876
  * (Phase C). Mirrors the synthesis-narrative branch:
767
- * - a pending `incoming/charter-extraction.json` → assemble+gate it via the
877
+ * - every per-kind lane present and valid → assemble+gate the merge via the
768
878
  * preferred executor (ingest), then `continue`;
769
879
  * - otherwise a `shallow` ceiling → `run_omit` (the deterministic executor
770
880
  * writes an empty `status:omitted` register — the conversation-first default,
@@ -793,42 +903,22 @@ export async function handleCharterExtractionBranch(params, bundle, state) {
793
903
  const laneValues = new Map();
794
904
  let quarantinedAny = false;
795
905
  for (const kind of kinds) {
796
- const filename = `charter-extraction-${kind}.json`;
797
- const incoming = await tryConsumeIncoming(params.artifactsDir, filename);
906
+ const lane = charterExtractionLane(kind);
907
+ const incoming = await tryConsumeSubmission(params.artifactsDir, lane);
798
908
  if (incoming.status === "absent")
799
909
  continue;
800
910
  if (incoming.status === "malformed") {
801
911
  quarantinedAny = true;
802
- await quarantineMisshapedIncoming(params.artifactsDir, incoming.path, filename, incoming.reason);
912
+ await quarantineMisshapedSubmission(params.artifactsDir, incoming.path, lane, incoming.reason);
803
913
  continue;
804
914
  }
805
- const laneSchema = CharterSubmissionSchema.superRefine((submission, ctx) => {
806
- submission.nodes.forEach((node, ni) => {
807
- if (node.kind !== kind) {
808
- ctx.addIssue({
809
- code: "custom",
810
- path: ["nodes", ni, "kind"],
811
- message: `lane '${kind}' may only carry kind '${kind}', got '${node.kind}'`,
812
- });
813
- }
814
- const unknown = node.files.filter((f) => !universe.has(f));
815
- if (unknown.length > 0) {
816
- ctx.addIssue({
817
- code: "custom",
818
- path: ["nodes", ni, "files"],
819
- message: `teleology node cites file(s) outside the repo: ${unknown.sort().join(", ")} — ` +
820
- "scopes must be repo-relative paths exactly as the evidence packet names them",
821
- });
822
- }
823
- });
824
- });
825
- const parsed = laneSchema.safeParse(incoming.value);
915
+ const parsed = charterLaneSchema(kind, universe).safeParse(incoming.value);
826
916
  if (parsed.success) {
827
917
  laneValues.set(kind, { value: parsed.data, path: incoming.path });
828
918
  }
829
919
  else {
830
920
  quarantinedAny = true;
831
- await quarantineMisshapedIncoming(params.artifactsDir, incoming.path, filename, parsed.error);
921
+ await quarantineMisshapedSubmission(params.artifactsDir, incoming.path, lane, parsed.error);
832
922
  }
833
923
  }
834
924
  if (!quarantinedAny && laneValues.size === kinds.length) {
@@ -842,7 +932,10 @@ export async function handleCharterExtractionBranch(params, bundle, state) {
842
932
  const merged = {
843
933
  nodes: kinds.flatMap((kind) => laneValues.get(kind).value.nodes),
844
934
  };
845
- const mergedPath = join(params.artifactsDir, "incoming", "charter-extraction.json");
935
+ // The merged submission is TOOL-written, so it lives with the other lane
936
+ // assets rather than under `submissions/` (which holds only what a host
937
+ // wrote). It is handed to the executor by path and deleted after ingest.
938
+ const mergedPath = join(laneAssetsDir(params.artifactsDir), CHARTER_EXTRACTION_MERGED_FILENAME);
846
939
  await writeJsonFile(mergedPath, merged);
847
940
  await runAuditStep({
848
941
  root: params.root,
@@ -851,13 +944,16 @@ export async function handleCharterExtractionBranch(params, bundle, state) {
851
944
  charterSubmissionPath: mergedPath,
852
945
  });
853
946
  await unlink(mergedPath).catch(() => { });
854
- for (const lane of laneValues.values()) {
947
+ for (const [kind, lane] of laneValues.entries()) {
855
948
  await unlink(lane.path).catch(() => { });
949
+ await recordLaneOutcome(params.artifactsDir, charterExtractionLane(kind), {
950
+ kind: "accepted",
951
+ });
856
952
  }
857
953
  // Evidence packets are consumed inputs like the lane submissions — a stale
858
954
  // packet left behind would feed a later re-extraction yesterday's evidence.
859
955
  for (const kind of kinds) {
860
- await unlink(join(params.artifactsDir, "incoming", `charter-extraction-${kind}-packet.md`)).catch(() => { });
956
+ await unlink(join(laneAssetsDir(params.artifactsDir), charterExtractionPacketFilename(kind))).catch(() => { });
861
957
  }
862
958
  return { action: "continue" };
863
959
  }
@@ -866,9 +962,9 @@ export async function handleCharterExtractionBranch(params, bundle, state) {
866
962
  return { action: "return", result: { kind: "charter_extraction", state, bundle } };
867
963
  }
868
964
  /**
869
- * Handle the `charter_delta_executor` incoming-artifact polling block (Phase C.2 —
965
+ * Handle the `charter_delta_executor` submission-polling block (Phase C.2 —
870
966
  * the INDEPENDENT delta-miner). Mirrors the charter-extraction branch:
871
- * - a pending `incoming/charter-delta.json` → route+gate it via the preferred
967
+ * - a pending `charter_delta` lane submission → route+gate it via the preferred
872
968
  * executor (ingest), then `continue`;
873
969
  * - otherwise, when the register is NOT `deltas_pending` (extraction omitted, or
874
970
  * found no subsystems to mine) → `run_omit` (the deterministic executor settles
@@ -879,8 +975,8 @@ export async function handleCharterExtractionBranch(params, bundle, state) {
879
975
  export async function handleCharterDeltaBranch(params, bundle, state) {
880
976
  return runOmittableGate({
881
977
  kind: "charter_delta",
882
- filename: "charter-delta.json",
883
- schema: CharterDeltaSubmissionSchema,
978
+ lane: GATE_LANES.charter_delta,
979
+ schema: LANE_SUBMISSION_SCHEMAS[GATE_LANES.charter_delta],
884
980
  apply: async (_value, path, p) => {
885
981
  await runAuditStep({
886
982
  root: p.root,
@@ -899,7 +995,7 @@ export async function handleCharterDeltaBranch(params, bundle, state) {
899
995
  * loop). Mirrors the charter-extraction branch, but the loop is DETERMINISTIC — the
900
996
  * executor assembles asked/banked from the Phase-C `charter_register` deltas, so the
901
997
  * host turn only surfaces the VOI-ranked interactive queue for relay:
902
- * - a pending `incoming/charter-clarification.json` (host answers) → assemble via
998
+ * - a pending `charter_clarification` lane submission (host answers) → assemble via
903
999
  * the deterministic runner, then `continue`;
904
1000
  * - a `shallow` ceiling OR zero attention → `run_omit` (the runner writes the
905
1001
  * register autonomously — every question banks as a finding, no host turn);
@@ -911,8 +1007,8 @@ export async function handleCharterDeltaBranch(params, bundle, state) {
911
1007
  export async function handleCharterClarificationBranch(params, bundle, state) {
912
1008
  return runOmittableGate({
913
1009
  kind: "charter_clarification",
914
- filename: "charter-clarification.json",
915
- schema: ClarificationAnswersSubmissionSchema,
1010
+ lane: GATE_LANES.charter_clarification,
1011
+ schema: LANE_SUBMISSION_SCHEMAS[GATE_LANES.charter_clarification],
916
1012
  apply: async (_value, path, p) => {
917
1013
  await runAuditStep({
918
1014
  root: p.root,
@@ -945,7 +1041,7 @@ export async function handleCharterClarificationBranch(params, bundle, state) {
945
1041
  /**
946
1042
  * Handle the `systemic_challenge_executor` obligation (Phase E — the second-order
947
1043
  * adversary loop-until-dry pass). Mirrors the charter-clarification branch:
948
- * - a pending `incoming/systemic-challenge.json` (an adversary round's findings) →
1044
+ * - a pending `systemic_challenge` lane submission (an adversary round's findings) →
949
1045
  * fold it via the deterministic runner, then `continue`;
950
1046
  * - a `shallow` ceiling → `run_omit` (the runner writes an omitted register
951
1047
  * autonomously, no host turn);
@@ -959,8 +1055,8 @@ export async function handleCharterClarificationBranch(params, bundle, state) {
959
1055
  export async function handleSystemicChallengeBranch(params, bundle, state) {
960
1056
  return runOmittableGate({
961
1057
  kind: "systemic_challenge",
962
- filename: "systemic-challenge.json",
963
- schema: SystemicChallengeSubmissionSchema,
1058
+ lane: GATE_LANES.systemic_challenge,
1059
+ schema: LANE_SUBMISSION_SCHEMAS[GATE_LANES.systemic_challenge],
964
1060
  apply: async (_value, path, p) => {
965
1061
  await runAuditStep({
966
1062
  root: p.root,
@@ -987,49 +1083,72 @@ export async function handleSystemicChallengeBranch(params, bundle, state) {
987
1083
  },
988
1084
  }, params, bundle, state);
989
1085
  }
1086
+ /**
1087
+ * The single enumeration of every gate that can be owed a host submission, and
1088
+ * of every lane within it.
1089
+ *
1090
+ * It under-counted before P25 in three ways, each of which meant the "complete"
1091
+ * registry could not be used to derive what a run expects: the analyzer-consent
1092
+ * gate had no entry at all, the conceptual perspective lanes were minted
1093
+ * privately by `conceptualDispatch`, and the charter lane set was hard-coded
1094
+ * three deep instead of derived from `charterExtractionKindsForCeiling`. All
1095
+ * three are fixed here; tool-WRITTEN inputs (lane prompts, evidence packets,
1096
+ * the merged charter submission) are deliberately absent — they are not
1097
+ * submissions, so nothing is ever owed on them.
1098
+ */
990
1099
  export const HOST_GATE_DESCRIPTORS = {
1100
+ analyzer_consent: {
1101
+ driven: "custom",
1102
+ lanes: [GATE_LANES.analyzer_consent],
1103
+ },
991
1104
  graph_enrichment: {
992
1105
  driven: "custom",
993
- incomingFiles: ["analyzer-decisions.json", "edge-reasoning.json"],
1106
+ lanes: [GATE_LANES.analyzer_decisions, GATE_LANES.edge_reasoning],
994
1107
  },
995
1108
  design_review: {
996
1109
  driven: "custom",
997
- incomingFiles: [
998
- "design-review-findings.json",
999
- "design-review-contract-findings.json",
1000
- "design-review-conceptual-findings.json",
1110
+ // The deep conceptual pass's independent JUDGE produces the conceptual
1111
+ // submission, so it is the `design_review_conceptual` lane rather than one
1112
+ // of its own. The pass's N perspective lanes are deliberately ABSENT: the
1113
+ // tool never reads a perspective's findings (the judge does), so it is owed
1114
+ // nothing on them — listing them here would state an expectation the run
1115
+ // could never satisfy and could never drop.
1116
+ lanes: [
1117
+ GATE_LANES.design_review_legacy,
1118
+ GATE_LANES.design_review_contract,
1119
+ GATE_LANES.design_review_conceptual,
1001
1120
  ],
1002
1121
  },
1003
1122
  critical_flow_fallback: {
1004
1123
  driven: "generic",
1005
- incomingFiles: ["critical-flow-fallback.json"],
1124
+ lanes: [GATE_LANES.critical_flow_fallback],
1006
1125
  },
1007
1126
  // Custom: runOmittableGate minus the plain-consume — the verdict is
1008
1127
  // schema-validated + quarantined-loudly in the handler itself.
1009
1128
  intent_equivalence: {
1010
1129
  driven: "custom",
1011
- incomingFiles: ["intent-equivalence-verdict.json"],
1130
+ lanes: [GATE_LANES.intent_equivalence],
1012
1131
  },
1013
- synthesis_narrative: { driven: "generic", incomingFiles: ["synthesis-narrative.json"] },
1132
+ synthesis_narrative: { driven: "generic", lanes: [GATE_LANES.synthesis_narrative] },
1014
1133
  // Custom: the per-kind blind-lane gate (design resolution 2) — one submission
1015
- // file per charter kind, each validated (shape + kind purity + scope
1016
- // grounding) and quarantined loudly per lane, tool-side merge only when every
1017
- // lane is present + valid. The three estimator channels are the lane set at
1018
- // every charter-authorizing ceiling; `true` is nominated by the delta miner
1019
- // at deepest, never a lane (design resolution 4).
1134
+ // per charter kind, each validated (shape + kind purity + scope grounding) and
1135
+ // quarantined loudly per lane, tool-side merge only when every lane is present
1136
+ // + valid. The lane set is DERIVED from the same function the gate loop walks,
1137
+ // so the registry and the loop cannot disagree at any ceiling; `true` is
1138
+ // nominated by the delta miner at deepest, never a lane (design resolution 4).
1020
1139
  charter_extraction: {
1021
1140
  driven: "custom",
1022
- incomingFiles: [
1023
- "charter-extraction-stated.json",
1024
- "charter-extraction-structural.json",
1025
- "charter-extraction-revealed.json",
1026
- ],
1141
+ lanes: charterExtractionKindsForCeiling({
1142
+ rung: "deepest",
1143
+ explicit_opt_in: true,
1144
+ }).map(charterExtractionLane),
1027
1145
  },
1028
- charter_delta: { driven: "generic", incomingFiles: ["charter-delta.json"] },
1029
- charter_clarification: { driven: "generic", incomingFiles: ["charter-clarification.json"] },
1030
- systemic_challenge: { driven: "generic", incomingFiles: ["systemic-challenge.json"] },
1146
+ charter_delta: { driven: "generic", lanes: [GATE_LANES.charter_delta] },
1147
+ charter_clarification: { driven: "generic", lanes: [GATE_LANES.charter_clarification] },
1148
+ systemic_challenge: { driven: "generic", lanes: [GATE_LANES.systemic_challenge] },
1031
1149
  };
1032
1150
  export const HOST_GATE_KINDS = [
1151
+ "analyzer_consent",
1033
1152
  "graph_enrichment",
1034
1153
  "critical_flow_fallback",
1035
1154
  "intent_equivalence",
@@ -1336,7 +1455,7 @@ export function buildAuditObligations() {
1336
1455
  },
1337
1456
  },
1338
1457
  {
1339
- // Graph enrichment: poll the analyzer-decision / edge-reasoning incoming
1458
+ // Graph enrichment: poll the analyzer-decision / edge-reasoning lane
1340
1459
  // artifacts first (emit a host step when one is needed), otherwise run the
1341
1460
  // deterministic enrichment executor.
1342
1461
  id: "graph_enrichment_current",
@@ -1361,7 +1480,7 @@ export function buildAuditObligations() {
1361
1480
  deterministic("docs_digest_current"),
1362
1481
  {
1363
1482
  // Confirm-intent host step: the host writes intent_checkpoint.json (read by
1364
- // deriveAuditState on re-invocation), so there is no incoming artifact to
1483
+ // deriveAuditState on re-invocation), so there is no submission to
1365
1484
  // consume — emit the step directly.
1366
1485
  id: "intent_checkpoint_current",
1367
1486
  derive: deriveObligationState("intent_checkpoint_current"),
@@ -1392,7 +1511,7 @@ export function buildAuditObligations() {
1392
1511
  },
1393
1512
  },
1394
1513
  {
1395
- // Charter extraction (Phase C): poll the incoming submission (ingest+gate),
1514
+ // Charter extraction (Phase C): poll the lane submissions (ingest+gate),
1396
1515
  // omit at a shallow ceiling, or emit the host charter-extraction step at a
1397
1516
  // deep+ ceiling. Mirrors the synthesis-narrative branch.
1398
1517
  id: "charter_extraction_current",
@@ -1410,7 +1529,7 @@ export function buildAuditObligations() {
1410
1529
  },
1411
1530
  },
1412
1531
  {
1413
- // Charter delta-mining (Phase C.2): poll the incoming delta submission
1532
+ // Charter delta-mining (Phase C.2): poll the delta lane submission
1414
1533
  // (route+gate it), settle deterministically when the register is not
1415
1534
  // deltas_pending (extraction omitted / no subsystems), or emit the host step
1416
1535
  // for the INDEPENDENT delta-miner when a deltas_pending register has no
@@ -1430,21 +1549,21 @@ export function buildAuditObligations() {
1430
1549
  },
1431
1550
  },
1432
1551
  {
1433
- // Contract design-review pass: poll incoming contract/conceptual findings;
1552
+ // Contract design-review pass: poll the contract/conceptual lanes;
1434
1553
  // emit the dispatch step when a pass still needs to run.
1435
1554
  id: "design_review_contract_completed",
1436
1555
  derive: deriveObligationState("design_review_contract_completed"),
1437
1556
  execute: (bundle, ctx) => runDesignReviewObligation(bundle, ctx),
1438
1557
  },
1439
1558
  {
1440
- // Conceptual design-review pass: same incoming-poll handler (it resolves
1559
+ // Conceptual design-review pass: same submission-poll handler (it resolves
1441
1560
  // which pass remains).
1442
1561
  id: "design_review_conceptual_completed",
1443
1562
  derive: deriveObligationState("design_review_conceptual_completed"),
1444
1563
  execute: (bundle, ctx) => runDesignReviewObligation(bundle, ctx),
1445
1564
  },
1446
1565
  {
1447
- // Charter clarification (Phase D triangulation loop): poll incoming answers
1566
+ // Charter clarification (Phase D triangulation loop): poll the answers lane
1448
1567
  // (apply + re-split), assemble the loop deterministically at a shallow ceiling
1449
1568
  // / zero attention (autonomous), or emit the host step relaying the VOI-ranked
1450
1569
  // interactive queue at a deep+ ceiling with attention > 0. Non-drainable
@@ -1464,7 +1583,7 @@ export function buildAuditObligations() {
1464
1583
  },
1465
1584
  },
1466
1585
  {
1467
- // Systemic challenge (Phase E loop-until-dry): poll the incoming adversary
1586
+ // Systemic challenge (Phase E loop-until-dry): poll the adversary lane's
1468
1587
  // round (fold it), omit at a shallow ceiling, or emit the second-order-adversary
1469
1588
  // host step when the loop is open at a deep+ ceiling. Non-drainable
1470
1589
  // (host_delegation), so the drain stops here.
@@ -1494,7 +1613,7 @@ export function buildAuditObligations() {
1494
1613
  deterministic("runtime_validation_current"),
1495
1614
  deterministic("synthesis_current"),
1496
1615
  {
1497
- // Synthesis narrative: poll the incoming narrative; emit the host step when
1616
+ // Synthesis narrative: poll the narrative lane; emit the host step when
1498
1617
  // narrative is enabled and not yet supplied, otherwise the deterministic
1499
1618
  // omit runs (fold on).
1500
1619
  id: "synthesis_narrative_current",
@@ -1511,7 +1630,7 @@ export function buildAuditObligations() {
1511
1630
  // (A bare reload here would leave it actionable and spin the fold.)
1512
1631
  return runDeterministicExecutor(bundle, ctx);
1513
1632
  }
1514
- // continue: an incoming narrative was consumed + applied — re-scan.
1633
+ // continue: a narrative submission was consumed + applied — re-scan.
1515
1634
  return { kind: "transition", state: await loadArtifactBundle(ctx.params.artifactsDir) };
1516
1635
  },
1517
1636
  },
@@ -1547,14 +1666,19 @@ async function runHostDelegationObligation(bundle, ctx) {
1547
1666
  // accepted ledger written before core ingestion is retried, while results
1548
1667
  // already reflected in coverage are not replayed.
1549
1668
  const currentRun = await loadCurrentActiveReviewRun(ctx.params.artifactsDir);
1669
+ let ingestIssues = [];
1550
1670
  if (currentRun) {
1551
1671
  let acceptedResults = [];
1672
+ let completedIds = [];
1552
1673
  try {
1553
- acceptedResults = (await ingestAuditHostResults({
1674
+ const ingested = await ingestAuditHostResults({
1554
1675
  root: ctx.params.root,
1555
1676
  artifactsDir: ctx.params.artifactsDir,
1556
1677
  runId: currentRun.run_id,
1557
- })).accepted_results;
1678
+ });
1679
+ acceptedResults = ingested.accepted_results;
1680
+ ingestIssues = ingested.issues;
1681
+ completedIds = ingested.completed_work_item_ids;
1558
1682
  }
1559
1683
  catch (error) {
1560
1684
  // No handoff exists on the first visit, or prepare was interrupted before
@@ -1562,6 +1686,17 @@ async function runHostDelegationObligation(bundle, ctx) {
1562
1686
  if (!isFileMissingError(error))
1563
1687
  throw error;
1564
1688
  }
1689
+ // The ingest CLASSIFIES every failed read — a submission that never
1690
+ // arrived, unreadable bytes (an EACCES or a directory at the bound path
1691
+ // reads as malformed WITH the OS detail in its message), a body that fails
1692
+ // the contract, a replayed result id. Those classifications used to die in
1693
+ // the caller that computed them; they now land on the ledger and, below,
1694
+ // in the re-emitted step the host actually reads. The completed set rides
1695
+ // along so a repaired item closes its own record.
1696
+ await recordHostResultOutcomes(ctx.params.artifactsDir, currentRun.run_id, {
1697
+ issues: ingestIssues,
1698
+ acceptedIds: completedIds,
1699
+ });
1565
1700
  const pendingIds = new Set(buildPendingAuditTasks(bundle).map((task) => task.task_id));
1566
1701
  const pendingAccepted = acceptedResults.filter((result) => pendingIds.has(result.task_id));
1567
1702
  if (pendingAccepted.length > 0) {
@@ -1589,6 +1724,7 @@ async function runHostDelegationObligation(bundle, ctx) {
1589
1724
  kind: "semantic_review",
1590
1725
  selectedExecutor: decision.selected_executor,
1591
1726
  ...review,
1727
+ ...(ingestIssues.length > 0 ? { ingestIssues } : {}),
1592
1728
  },
1593
1729
  };
1594
1730
  }