onto-mcp 0.4.11 → 0.4.13

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 (139) hide show
  1. package/.onto/authority/core-lexicon.yaml +1 -1
  2. package/.onto/authority/supported-models.yaml +153 -0
  3. package/.onto/domains/accounting-kr/competency_qs.md +153 -153
  4. package/.onto/processes/reconstruct/obligation-coverage-ledger.yaml +842 -0
  5. package/.onto/processes/reconstruct/obligation-coverage-recorded.yaml +225 -0
  6. package/.onto/processes/reconstruct/ontology-seeding-and-maturation-design.md +93 -54
  7. package/.onto/processes/reconstruct/reconstruct-contract-registry.yaml +202 -49
  8. package/.onto/processes/reconstruct/reconstruct-execution-ux-contract.md +72 -1
  9. package/.onto/processes/reconstruct/source-profiles/spreadsheet.md +35 -0
  10. package/.onto/processes/review/nesting-batch-worker-contract.md +1 -1
  11. package/.onto/processes/review/productized-live-path.md +9 -5
  12. package/.onto/processes/review/prompt-execution-runner-contract.md +32 -8
  13. package/.onto/processes/review/review-target-profile-contract.md +47 -4
  14. package/.onto/processes/shared/pipeline-execution-ledger-contract.md +11 -0
  15. package/.onto/processes/shared/target-material-kind-contract.md +1 -1
  16. package/README.md +64 -1
  17. package/dist/cli.js +11 -1
  18. package/dist/core-api/reconstruct-api.js +679 -18
  19. package/dist/core-api/review-api.js +70 -5
  20. package/dist/core-api/review-progress.js +7 -0
  21. package/dist/core-api/runtime-observation.js +7 -0
  22. package/dist/core-runtime/artifact-io.js +131 -0
  23. package/dist/core-runtime/cli/claude-code-review-unit-executor.js +21 -9
  24. package/dist/core-runtime/cli/claude-nesting-batch-worker.js +4 -2
  25. package/dist/core-runtime/cli/materialize-review-prompt-packets.js +68 -14
  26. package/dist/core-runtime/cli/prepare-review-session.js +34 -1
  27. package/dist/core-runtime/cli/render-review-final-output.js +2 -1
  28. package/dist/core-runtime/cli/run-review-prompt-execution.js +758 -48
  29. package/dist/core-runtime/cli/run-seats.js +18 -0
  30. package/dist/core-runtime/cli/runtime-submit-context.js +10 -2
  31. package/dist/core-runtime/cli/unit-resubmit.js +269 -0
  32. package/dist/core-runtime/cli/worker-structured-output.js +80 -2
  33. package/dist/core-runtime/discovery/host-detection.js +71 -22
  34. package/dist/core-runtime/discovery/review-cert-assemble.js +208 -0
  35. package/dist/core-runtime/discovery/review-cert-record.js +442 -0
  36. package/dist/core-runtime/discovery/review-execution-units.js +22 -0
  37. package/dist/core-runtime/discovery/seat-inventory.js +167 -0
  38. package/dist/core-runtime/discovery/settings-chain.js +311 -52
  39. package/dist/core-runtime/discovery/supported-models.js +289 -25
  40. package/dist/core-runtime/discovery/synthesize-cert-assemble.js +197 -0
  41. package/dist/core-runtime/discovery/synthesize-cert-capsule.js +399 -0
  42. package/dist/core-runtime/discovery/synthesize-cert-judge.js +340 -0
  43. package/dist/core-runtime/discovery/synthesize-cert-loop.js +234 -0
  44. package/dist/core-runtime/discovery/synthesize-cert-mutation.js +117 -0
  45. package/dist/core-runtime/discovery/synthesize-cert-packet.js +182 -0
  46. package/dist/core-runtime/discovery/synthesize-cert-record.js +831 -0
  47. package/dist/core-runtime/discovery/synthesize-cert-sampler.js +336 -0
  48. package/dist/core-runtime/effort-calibration-ingest.js +326 -0
  49. package/dist/core-runtime/effort-calibration-reconstruct.js +47 -0
  50. package/dist/core-runtime/effort-calibration-review.js +22 -0
  51. package/dist/core-runtime/effort-calibration-sweep.js +110 -0
  52. package/dist/core-runtime/effort-frontier.js +134 -0
  53. package/dist/core-runtime/llm/claude-bin.js +77 -0
  54. package/dist/core-runtime/llm/dispatch-breaker.js +412 -0
  55. package/dist/core-runtime/llm/dispatch-fallback-adapter-capabilities.js +33 -0
  56. package/dist/core-runtime/llm/llm-caller.js +316 -35
  57. package/dist/core-runtime/llm/mock-llm-realization.js +35 -4
  58. package/dist/core-runtime/llm/model-switcher.js +1 -1
  59. package/dist/core-runtime/llm/openai-responses-incomplete-error.js +46 -0
  60. package/dist/core-runtime/llm/sealed-dispatch-capability.js +359 -0
  61. package/dist/core-runtime/llm/structured-dispatch-error.js +15 -0
  62. package/dist/core-runtime/onboard/claude-profile-scan.js +65 -0
  63. package/dist/core-runtime/onboard/cli-host.js +5 -2
  64. package/dist/core-runtime/onboard/configure-provider.js +33 -1
  65. package/dist/core-runtime/onboard/host-target.js +28 -3
  66. package/dist/core-runtime/onboard/register.js +54 -12
  67. package/dist/core-runtime/pipeline-execution-ledger.js +10 -0
  68. package/dist/core-runtime/reconstruct/artifact-types.js +37 -0
  69. package/dist/core-runtime/reconstruct/benchmark-evidence.js +16 -0
  70. package/dist/core-runtime/reconstruct/claim-projection-validation.js +19 -5
  71. package/dist/core-runtime/reconstruct/competency-projection-contract.js +63 -0
  72. package/dist/core-runtime/reconstruct/comprehension-artifact.js +321 -0
  73. package/dist/core-runtime/reconstruct/comprehension-reduce.js +367 -0
  74. package/dist/core-runtime/reconstruct/comprehension-semantic-map.js +771 -0
  75. package/dist/core-runtime/reconstruct/contract-registry.js +20 -0
  76. package/dist/core-runtime/reconstruct/directive-validation.js +4 -3
  77. package/dist/core-runtime/reconstruct/dispatch-fallback-artifacts.js +640 -0
  78. package/dist/core-runtime/reconstruct/execution-telemetry.js +84 -9
  79. package/dist/core-runtime/reconstruct/final-output-sections.js +89 -0
  80. package/dist/core-runtime/reconstruct/governing-snapshot.js +36 -1
  81. package/dist/core-runtime/reconstruct/leaf-reader.js +305 -0
  82. package/dist/core-runtime/reconstruct/llm-dispatch-failure.js +270 -0
  83. package/dist/core-runtime/reconstruct/llm-touch-fingerprint.js +94 -0
  84. package/dist/core-runtime/reconstruct/material-admission-validation.js +20 -5
  85. package/dist/core-runtime/reconstruct/material-profile-validation.js +24 -6
  86. package/dist/core-runtime/reconstruct/materialize-preparation.js +380 -23
  87. package/dist/core-runtime/reconstruct/maturation-validation.js +1522 -67
  88. package/dist/core-runtime/reconstruct/mock-llm-realization.js +120 -1
  89. package/dist/core-runtime/reconstruct/obligation-assertion.js +18 -0
  90. package/dist/core-runtime/reconstruct/ontology-seed-validation.js +39 -7
  91. package/dist/core-runtime/reconstruct/output-budget.js +6 -0
  92. package/dist/core-runtime/reconstruct/pipeline-execution-ledger.js +112 -5
  93. package/dist/core-runtime/reconstruct/post-seed-validation.js +122 -11
  94. package/dist/core-runtime/reconstruct/proof-authority-validation.js +2 -6
  95. package/dist/core-runtime/reconstruct/purpose-authority-validation.js +119 -7
  96. package/dist/core-runtime/reconstruct/record.js +49 -3
  97. package/dist/core-runtime/reconstruct/registry-verification-validation.js +41 -5
  98. package/dist/core-runtime/reconstruct/run-control-validation.js +1019 -37
  99. package/dist/core-runtime/reconstruct/run.js +5409 -517
  100. package/dist/core-runtime/reconstruct/seed-authoring-readiness-validation.js +56 -8
  101. package/dist/core-runtime/reconstruct/semantic-quality-gate.js +112 -7
  102. package/dist/core-runtime/reconstruct/source-observation-delta-validation.js +53 -5
  103. package/dist/core-runtime/reconstruct/source-observations.js +166 -0
  104. package/dist/core-runtime/reconstruct/source-safety-validation.js +77 -188
  105. package/dist/core-runtime/reconstruct/source-scout-pack-validation.js +50 -6
  106. package/dist/core-runtime/reconstruct/terminal-validation.js +145 -13
  107. package/dist/core-runtime/review/continuation-plan.js +12 -5
  108. package/dist/core-runtime/review/issue-artifact-runtime.js +4 -1
  109. package/dist/core-runtime/review/materializers.js +100 -11
  110. package/dist/core-runtime/review/nesting-batch.js +6 -2
  111. package/dist/core-runtime/review/obligation-shardability.js +138 -0
  112. package/dist/core-runtime/review/pipeline-execution-ledger.js +42 -2
  113. package/dist/core-runtime/review/review-artifact-utils.js +219 -7
  114. package/dist/core-runtime/review/review-execution-steps.js +32 -8
  115. package/dist/core-runtime/review/review-progress-contract.js +8 -0
  116. package/dist/core-runtime/review/review-prompt-budget.js +97 -0
  117. package/dist/core-runtime/review/semantic-quality-gate.js +33 -7
  118. package/dist/core-runtime/review/spreadsheet-review-disposition.js +94 -0
  119. package/dist/core-runtime/route-identity.js +182 -0
  120. package/dist/core-runtime/spreadsheet-header-escalation.js +204 -0
  121. package/dist/core-runtime/spreadsheet-structure-observer.js +2689 -0
  122. package/dist/core-runtime/target-material-kind.js +58 -0
  123. package/dist/mcp/server.js +27 -5
  124. package/dist/mcp/tool-schemas.js +8 -0
  125. package/dist/tui/app.js +167 -0
  126. package/dist/tui/data/event-follower.js +116 -0
  127. package/dist/tui/data/node-detail.js +42 -0
  128. package/dist/tui/data/projection-poll.js +26 -0
  129. package/dist/tui/data/session-discovery.js +65 -0
  130. package/dist/tui/index.js +108 -0
  131. package/dist/tui/screens/log.js +25 -0
  132. package/dist/tui/screens/node-detail.js +17 -0
  133. package/dist/tui/screens/session-selector.js +26 -0
  134. package/dist/tui/screens/workflow-tree.js +90 -0
  135. package/dist/tui/view-model/reconstruct-adapter.js +162 -0
  136. package/dist/tui/view-model/review-adapter.js +270 -0
  137. package/dist/tui/view-model/tree-view-model.js +19 -0
  138. package/package.json +20 -3
  139. package/settings.example.json +77 -16
@@ -1,9 +1,12 @@
1
1
  import crypto from "node:crypto";
2
2
  import fs from "node:fs/promises";
3
3
  import path from "node:path";
4
- import { parse as parseYaml, stringify as stringifyYaml } from "yaml";
4
+ import { parse as parseYaml } from "yaml";
5
+ import { atomicWriteYamlDocument as writeYamlDocument } from "../artifact-io.js";
5
6
  import { sourceSafetyRowIdForObservation } from "./source-safety-validation.js";
6
7
  import { materialAdmissionIdForPurposeElement } from "./material-admission-validation.js";
8
+ import { isRevisionBlocker } from "./post-seed-validation.js";
9
+ import { assertObligation } from "./obligation-assertion.js";
7
10
  const MATERIALITY_VALUES = [
8
11
  "blocker",
9
12
  "high",
@@ -52,10 +55,6 @@ function isoNow() {
52
55
  async function readYamlDocument(filePath) {
53
56
  return parseYaml(await fs.readFile(filePath, "utf8"));
54
57
  }
55
- async function writeYamlDocument(filePath, value) {
56
- await fs.mkdir(path.dirname(filePath), { recursive: true });
57
- await fs.writeFile(filePath, stringifyYaml(value), "utf8");
58
- }
59
58
  async function sha256FileIfPresent(filePath) {
60
59
  if (!filePath)
61
60
  return null;
@@ -135,6 +134,59 @@ function duplicateIds(values) {
135
134
  function sameResolvedRef(left, right) {
136
135
  return path.resolve(left) === path.resolve(right);
137
136
  }
137
+ /**
138
+ * Index actionability matrix rows by the dimensions used to compute a delta's
139
+ * affected_matrix_row_refs, so the build/validate intersection is O(rows + refs)
140
+ * per delta instead of a per-delta linear scan over the whole matrix.
141
+ *
142
+ * Row indices (not matrix_row_ids) are stored so that the downstream projection
143
+ * reproduces the exact multiplicity and ordering of
144
+ * `matrixRows.filter(...).map((row) => row.matrix_row_id)`.
145
+ */
146
+ function indexActionabilityRowsByDelta(matrixRows) {
147
+ const bySourceRef = new Map();
148
+ const byObservationId = new Map();
149
+ const add = (map, key, index) => {
150
+ const bucket = map.get(key);
151
+ if (bucket)
152
+ bucket.add(index);
153
+ else
154
+ map.set(key, new Set([index]));
155
+ };
156
+ matrixRows.forEach((row, index) => {
157
+ for (const ref of row.member_source_refs) {
158
+ add(bySourceRef, path.resolve(ref), index);
159
+ }
160
+ for (const ref of row.cross_material_ref_refs) {
161
+ add(bySourceRef, path.resolve(ref), index);
162
+ }
163
+ for (const ref of row.supporting_refs) {
164
+ add(byObservationId, ref, index);
165
+ }
166
+ });
167
+ return { bySourceRef, byObservationId };
168
+ }
169
+ /**
170
+ * Resolve the sorted matrix_row_ids affected by a delta row, equivalent to
171
+ * `matrixRows.filter((row) => member/cross resolves to source_ref OR
172
+ * supporting_refs includes observation_id).map((row) => row.matrix_row_id).sort()`.
173
+ */
174
+ function affectedMatrixRowRefsForDelta(matrixRows, index, deltaRow) {
175
+ const matched = new Set();
176
+ for (const rowIndex of index.bySourceRef.get(path.resolve(deltaRow.source_ref)) ??
177
+ []) {
178
+ matched.add(rowIndex);
179
+ }
180
+ for (const rowIndex of index.byObservationId.get(deltaRow.observation_id) ?? []) {
181
+ matched.add(rowIndex);
182
+ }
183
+ const refs = [];
184
+ matrixRows.forEach((row, rowIndex) => {
185
+ if (matched.has(rowIndex))
186
+ refs.push(row.matrix_row_id);
187
+ });
188
+ return refs.sort();
189
+ }
138
190
  function violation(args) {
139
191
  return {
140
192
  code: args.code,
@@ -205,12 +257,83 @@ function matrixRowNeedsFrontier(row) {
205
257
  row.maturity_level !== "L4_validated_for_purpose" &&
206
258
  row.limitation_refs.length === 0;
207
259
  }
260
+ // Maturation value-read cut (design §13.3). Shared discharge→residual+readiness derivation
261
+ // used by BOTH the matrix builder and its validator (derive-and-assert). Pure/deterministic,
262
+ // no I/O.
263
+ // Baseline limitations cleared by VALIDATED, SATISFIED value-discharges, keyed by
264
+ // baseline_row_id. Only a discharge artifact whose validation is `valid` contributes (global
265
+ // gate, mirrors the answer-claims pattern), and only entries with satisfaction_status ===
266
+ // "satisfied" subtract — refuted/inconclusive never discharge (§4.3 four conditions). The
267
+ // validator builds this independently and never trusts the matrix's stamped residual.
268
+ function buildValidatedDischargeIndex(discharge, dischargeValidation) {
269
+ const byBaselineRowId = new Map();
270
+ if (dischargeValidation?.validation_status !== "valid")
271
+ return byBaselineRowId;
272
+ for (const entry of discharge?.discharges ?? []) {
273
+ if (entry.satisfaction_status !== "satisfied")
274
+ continue;
275
+ for (const baselineRowRef of entry.target_baseline_row_refs) {
276
+ const bucket = byBaselineRowId.get(baselineRowRef) ?? new Set();
277
+ for (const lim of entry.target_limitation_refs)
278
+ bucket.add(lim);
279
+ byBaselineRowId.set(baselineRowRef, bucket);
280
+ }
281
+ }
282
+ return byBaselineRowId;
283
+ }
284
+ // The terminal readiness for a material row whose value-dependent limitations were cleared to
285
+ // zero residual by value-read discharge. `dischargedForRow > 0` distinguishes a residual that
286
+ // reached zero BY discharge (→ value_resolved) from one that was always zero (→ the natural
287
+ // frontier_required / closed case). A forged value_resolved with no real discharge therefore
288
+ // falls through to frontier_required/closed and is rejected by the validator's readiness
289
+ // assert. matrixRowNeedsFrontier is unchanged (called with the residual).
290
+ function deriveMemberReadiness(args) {
291
+ if (args.residualLimitationRefs.length > 0)
292
+ return "limitation_backed";
293
+ if (isMaterialMaturationRow({ materiality: args.materiality }) &&
294
+ args.maturityLevel !== "L4_validated_for_purpose" &&
295
+ args.dischargedForRow > 0) {
296
+ return "value_resolved";
297
+ }
298
+ if (matrixRowNeedsFrontier({
299
+ materiality: args.materiality,
300
+ maturity_level: args.maturityLevel,
301
+ limitation_refs: args.residualLimitationRefs,
302
+ })) {
303
+ return "frontier_required";
304
+ }
305
+ return "closed";
306
+ }
208
307
  function maturityLevelRank(level) {
209
308
  return MATURITY_LEVELS.indexOf(level);
210
309
  }
211
310
  function higherMaturityLevel(current, next) {
212
311
  return maturityLevelRank(next) > maturityLevelRank(current) ? next : current;
213
312
  }
313
+ // Site-7 proportional terminal (design 20260706 §5): the SINGLE certification choke point.
314
+ // A judge-support-shortfall (degraded) claim must never certify — it is excluded from the
315
+ // positive sets that raise maturity (L3/L4), and every baseline row it matches carries a
316
+ // deterministic limitation token so member_readiness degrades to limitation_backed and the
317
+ // continuation decision weighs it by materiality. The matrix BUILDER and VALIDATOR both
318
+ // derive through these helpers (derive-and-assert lockstep); an expansion citing a degraded
319
+ // claim is likewise non-positive so it cannot co-lift a row to L4.
320
+ const JUDGE_SUPPORT_SHORTFALL_TOKEN_PREFIX = "judge_support_shortfall:";
321
+ function judgeSupportShortfallIds(validation) {
322
+ return new Set(validation?.judge_support_shortfall_claim_ids);
323
+ }
324
+ export function judgeSupportShortfallToken(claimId) {
325
+ return `${JUDGE_SUPPORT_SHORTFALL_TOKEN_PREFIX}${claimId}`;
326
+ }
327
+ function positiveAnswerClaim(claim, shortfallIds) {
328
+ return claim.answer_status === "answered" &&
329
+ claim.limitation_refs.length === 0 &&
330
+ !shortfallIds.has(claim.answer_claim_id);
331
+ }
332
+ function positiveExpansion(expansion, shortfallIds) {
333
+ return (expansion.operation === "add" || expansion.operation === "refine") &&
334
+ expansion.limitation_refs.length === 0 &&
335
+ !expansion.answer_claim_refs.some((ref) => shortfallIds.has(ref));
336
+ }
214
337
  function answerClaimMatchesBaselineRow(claim, row) {
215
338
  return claim.purpose_element_refs.includes(row.purpose_element_ref) &&
216
339
  claim.target_surface_refs.includes(row.actionability_surface_ref) &&
@@ -256,6 +379,7 @@ export function buildMaturationBaselineArtifact(args) {
256
379
  purpose_confirmation_validation_ref: args.purposeConfirmationValidationRef,
257
380
  source_material_admission_ledger_ref: args.sourceMaterialAdmissionLedgerRef,
258
381
  source_material_admission_validation_ref: args.sourceMaterialAdmissionValidationRef,
382
+ candidate_limitation_refs: [],
259
383
  baseline_rows: [],
260
384
  };
261
385
  }
@@ -266,10 +390,12 @@ export function buildMaturationBaselineArtifact(args) {
266
390
  const resolvedEvidence = rowEvidence.length > 0
267
391
  ? rowEvidence
268
392
  : element.supporting_evidence_refs;
269
- const limitationRefs = [
270
- ...stringArray(seedElement?.limitation_refs),
271
- ...candidate.limitation_refs,
272
- ];
393
+ // Row-level limitations are the row's own seed-element limitations only.
394
+ // Candidate-level limitations are surfaced once at the baseline top-level
395
+ // (candidate_limitation_refs) so they constrain the actionable claim without
396
+ // forcing every surface×dimension row to limitation_backed (which would dead
397
+ // the maturation answer machine — frontier/closure/ledger/judge all skipped).
398
+ const limitationRefs = stringArray(seedElement?.limitation_refs);
273
399
  const coverage = questionCoverage({
274
400
  seedRefs,
275
401
  competencyQuestions: args.competencyQuestions,
@@ -338,6 +464,7 @@ export function buildMaturationBaselineArtifact(args) {
338
464
  purpose_confirmation_validation_ref: args.purposeConfirmationValidationRef,
339
465
  source_material_admission_ledger_ref: args.sourceMaterialAdmissionLedgerRef,
340
466
  source_material_admission_validation_ref: args.sourceMaterialAdmissionValidationRef,
467
+ candidate_limitation_refs: candidate.limitation_refs,
341
468
  baseline_rows: rows.map((row) => ({
342
469
  ...row,
343
470
  blocking_reason: needsFrontier(row)
@@ -346,8 +473,55 @@ export function buildMaturationBaselineArtifact(args) {
346
473
  })),
347
474
  };
348
475
  }
476
+ // M1 conservation — derive-from-authority. The closed set of required maturation
477
+ // tuples is enumerated once from the selected purpose candidate's required_elements
478
+ // (the same element x surface x dimension enumeration the baseline builder performs),
479
+ // so the validator can prove every required tuple is present exactly once instead of
480
+ // only checking that the rows that ARE present resolve. Coverage authority is the
481
+ // TUPLE, not the (slug-derived) baseline_row_id, which is verified separately.
482
+ function baselineTupleKey(elementId, surfaceRef, dimensionRef) {
483
+ return JSON.stringify([elementId, surfaceRef, dimensionRef]);
484
+ }
485
+ function sameRefSet(a, b) {
486
+ if (a.length !== b.length)
487
+ return false;
488
+ const seen = new Set(a);
489
+ return b.every((ref) => seen.has(ref));
490
+ }
491
+ // Exact array equality (order- and multiplicity-sensitive). Used for matrix fields
492
+ // the builder copies VERBATIM from the baseline row, where set-equality would miss a
493
+ // reorder or a duplicate-occurrence swap (e.g. [a,a,b] -> [a,b,b]: same length+set).
494
+ function sameRefArray(a, b) {
495
+ return a.length === b.length && a.every((ref, index) => ref === b[index]);
496
+ }
497
+ // Cross-artifact lineage binding: a consumed validation must record the upstream ref that is
498
+ // actually supplied here, so a valid validation produced against a DIFFERENT upstream cannot
499
+ // bless this artifact. Returns a violation when the recorded and supplied refs disagree.
500
+ function validationLineageViolation(args) {
501
+ const recorded = args.recordedUpstreamRef ?? null;
502
+ const supplied = args.suppliedUpstreamRef ?? null;
503
+ if (recorded === supplied)
504
+ return null;
505
+ return violation({
506
+ code: "conflicting_state",
507
+ message: args.message,
508
+ subjectId: recorded ?? supplied,
509
+ });
510
+ }
511
+ function deriveExpectedBaselineTuples(selected) {
512
+ const tuples = new Set();
513
+ for (const element of selected?.adequacy_frame.required_elements ?? []) {
514
+ for (const surfaceRef of element.actionability_surface_refs) {
515
+ for (const dimensionRef of element.maturity_dimension_refs) {
516
+ tuples.add(baselineTupleKey(element.element_id, surfaceRef, dimensionRef));
517
+ }
518
+ }
519
+ }
520
+ return tuples;
521
+ }
349
522
  export function validateMaturationBaseline(args) {
350
523
  const violations = [];
524
+ const assertedObligationIds = [];
351
525
  const baseline = args.maturationBaseline;
352
526
  const selected = selectedPurposeCandidate({
353
527
  sourcePurposeCandidates: args.sourcePurposeCandidates,
@@ -365,6 +539,25 @@ export function validateMaturationBaseline(args) {
365
539
  subjectId: baseline.session_id,
366
540
  }));
367
541
  }
542
+ // candidate_limitation_refs are the source-level authority that holds an otherwise
543
+ // closed run at actionable_limited; a stale/edited baseline that drops them (then
544
+ // copied faithfully into the matrix) would pass the matrix check yet let
545
+ // continuation project actionable_ready. Anchor them to the selected candidate.
546
+ const expectedCandidateLimitations = new Set(selected?.limitation_refs ?? []);
547
+ const actualCandidateLimitations = new Set(baseline.candidate_limitation_refs);
548
+ const candidateLimitationsMatch = expectedCandidateLimitations.size === actualCandidateLimitations.size &&
549
+ [...expectedCandidateLimitations].every((ref) => actualCandidateLimitations.has(ref));
550
+ if (!candidateLimitationsMatch) {
551
+ violations.push(violation({
552
+ code: "conflicting_state",
553
+ message: "maturation baseline candidate_limitation_refs must match the selected purpose candidate's limitation_refs",
554
+ subjectId: "candidate_limitation_refs",
555
+ }));
556
+ }
557
+ // G(a) slice 2: record reaching the source-reconstruct-record enforcement UNCONDITIONALLY (this
558
+ // check has no data-dependent guard) so the obligation is proven wired on every call. Audited to a
559
+ // distinct enforcement site (source_reconstruct_record_missing) before recording — no laundering.
560
+ assertObligation(assertedObligationIds, "require_source_reconstruct_record_ref_and_sha256_before_maturation_baseline_consumption");
368
561
  if (!baseline.source_reconstruct_record_ref || !args.sourceReconstructRecordSha256) {
369
562
  violations.push(violation({
370
563
  code: "source_reconstruct_record_missing",
@@ -394,6 +587,10 @@ export function validateMaturationBaseline(args) {
394
587
  subjectId: "baseline_rows",
395
588
  }));
396
589
  }
590
+ // G(a) slice 2: record reaching the per-row mixed-lineage enforcement region UNCONDITIONALLY
591
+ // (before the row loop) so the obligation is proven wired even for a baseline with zero rows.
592
+ // Audited to a distinct enforcement site (mixed_lineage_missing, below) before recording.
593
+ assertObligation(assertedObligationIds, "validate_mixed_baseline_rows_preserve_member_material_and_cross_material_lineage");
397
594
  for (const row of baseline.baseline_rows) {
398
595
  if (seen.has(row.baseline_row_id)) {
399
596
  violations.push(violation({
@@ -453,7 +650,19 @@ export function validateMaturationBaseline(args) {
453
650
  row.member_target_material_kind !== null &&
454
651
  row.member_source_refs.length > 0 &&
455
652
  row.cross_material_ref_refs.length > 0;
456
- if (mixedTarget && !hasLineage && row.limitation_refs.length === 0) {
653
+ // Mixed-target lineage is row-scoped by default: each row grounds itself by its
654
+ // own member lineage or its own limitation, because a stray candidate limitation
655
+ // does not name a row's member/source. The one exception is a candidate that is
656
+ // ITSELF limitation-backed — the upstream purpose validator already accepted its
657
+ // limitation_refs as justifying gaps (e.g. unrecoverable mixed-member lineage), so
658
+ // its rows may lean on that acknowledgment rather than failing baseline validation.
659
+ // (Candidate limitations still never mark a row limitation_backed, so frontier
660
+ // gating is unaffected.)
661
+ const candidateLimitationBacked = selected?.purpose_source_status === "limitation_backed" ||
662
+ selected?.adequacy_frame.frame_status === "limitation_backed";
663
+ const mixedLineageExempt = row.limitation_refs.length > 0 ||
664
+ (candidateLimitationBacked && baseline.candidate_limitation_refs.length > 0);
665
+ if (mixedTarget && !hasLineage && !mixedLineageExempt) {
457
666
  violations.push(violation({
458
667
  code: "mixed_lineage_missing",
459
668
  message: "mixed-material baseline row must preserve member lineage or cite a limitation",
@@ -461,6 +670,39 @@ export function validateMaturationBaseline(args) {
461
670
  }));
462
671
  }
463
672
  }
673
+ // M1 coverage conservation: derive the closed required-tuple set from the selected
674
+ // candidate and prove every required tuple is present exactly once. The per-row loop
675
+ // above only checks that PRESENT rows resolve, so a deleted required row (erasing
676
+ // blocker/high scope) or a duplicated tuple would otherwise pass silently.
677
+ // G(a): record reaching the coverage block UNCONDITIONALLY (before the `if (selected)`
678
+ // guard) so the obligation is proven wired even for inputs with no selected candidate.
679
+ assertObligation(assertedObligationIds, "validate_baseline_rows_cover_selected_purpose_frame_required_elements");
680
+ if (selected) {
681
+ const expectedTuples = deriveExpectedBaselineTuples(selected);
682
+ const presentTupleCounts = new Map();
683
+ for (const row of baseline.baseline_rows) {
684
+ const key = baselineTupleKey(row.purpose_element_ref, row.actionability_surface_ref, row.maturity_dimension_ref);
685
+ presentTupleCounts.set(key, (presentTupleCounts.get(key) ?? 0) + 1);
686
+ }
687
+ for (const tuple of expectedTuples) {
688
+ if (!presentTupleCounts.has(tuple)) {
689
+ violations.push(violation({
690
+ code: "missing_required_coverage",
691
+ message: `required maturation tuple has no baseline row: ${tuple}`,
692
+ subjectId: tuple,
693
+ }));
694
+ }
695
+ }
696
+ for (const [tuple, count] of presentTupleCounts) {
697
+ if (count > 1 && expectedTuples.has(tuple)) {
698
+ violations.push(violation({
699
+ code: "conflicting_state",
700
+ message: `multiple baseline rows cover one required maturation tuple: ${tuple}`,
701
+ subjectId: tuple,
702
+ }));
703
+ }
704
+ }
705
+ }
464
706
  return {
465
707
  schema_version: "1",
466
708
  session_id: baseline.session_id,
@@ -477,6 +719,7 @@ export function validateMaturationBaseline(args) {
477
719
  validation_results: violations.length === 0
478
720
  ? ["maturation_baseline_valid"]
479
721
  : ["maturation_baseline_invalid"],
722
+ asserted_obligation_ids: assertedObligationIds,
480
723
  violations,
481
724
  };
482
725
  }
@@ -484,29 +727,60 @@ export function buildActionabilityMatrixArtifact(args) {
484
727
  const answerClaims = args.maturationAnswerClaimsValidation?.validation_status === "valid"
485
728
  ? args.maturationAnswerClaims?.answer_claims ?? []
486
729
  : [];
730
+ const shortfallIds = judgeSupportShortfallIds(args.maturationAnswerClaimsValidation);
487
731
  const expansions = args.ontologyExpansionValidation?.validation_status === "valid"
488
732
  ? args.ontologyExpansion?.expansions ?? []
489
733
  : [];
734
+ // Maturation value-read cut: baseline limitations cleared by validated satisfied
735
+ // discharges, keyed by baseline_row_id (design §13.3). Empty when no discharge is threaded
736
+ // or its validation is not valid → the subtract below is a no-op (default-off).
737
+ const dischargedLimitationsByBaselineRow = buildValidatedDischargeIndex(args.maturationValueDischarge, args.maturationValueDischargeValidation);
738
+ // Reverse link: a row's blocking_question_refs name the maturation frontier
739
+ // question(s) authored for it. Questions only exist once the frontier is authored
740
+ // from the (pre-frontier) baseline matrix, so this is populated on the current-matrix
741
+ // recompute when a VALIDATED frontier is threaded in and left empty otherwise (the
742
+ // baseline matrix passes no frontier → []). Indexed by baseline_row_id, which the
743
+ // frontier question references via baseline_row_refs.
744
+ const frontierQuestions = args.maturationQuestionFrontierValidation?.validation_status === "valid"
745
+ ? args.maturationQuestionFrontier?.questions ?? []
746
+ : [];
747
+ const blockingQuestionsByBaselineRow = new Map();
748
+ for (const question of frontierQuestions) {
749
+ for (const baselineRowRef of question.baseline_row_refs) {
750
+ const bucket = blockingQuestionsByBaselineRow.get(baselineRowRef);
751
+ if (bucket)
752
+ bucket.push(question.question_id);
753
+ else {
754
+ blockingQuestionsByBaselineRow.set(baselineRowRef, [question.question_id]);
755
+ }
756
+ }
757
+ }
490
758
  return {
491
759
  schema_version: "1",
492
760
  session_id: args.sessionId,
493
761
  created_at: isoNow(),
494
762
  maturation_baseline_ref: args.maturationBaselineRef,
495
763
  maturation_baseline_validation_ref: args.maturationBaselineValidationRef,
764
+ candidate_limitation_refs: args.maturationBaseline.candidate_limitation_refs,
496
765
  rows: args.maturationBaseline.baseline_rows.map((row) => {
497
766
  const matchingAnswerClaims = answerClaims.filter((claim) => answerClaimMatchesBaselineRow(claim, row));
498
767
  const matchingExpansions = expansions.filter((expansion) => expansionMatchesBaselineRow(expansion, row));
499
- const positiveAnswerClaims = matchingAnswerClaims.filter((claim) => claim.answer_status === "answered" &&
500
- claim.limitation_refs.length === 0);
501
- const positiveExpansions = matchingExpansions.filter((expansion) => (expansion.operation === "add" || expansion.operation === "refine") &&
502
- expansion.limitation_refs.length === 0);
768
+ const positiveAnswerClaims = matchingAnswerClaims.filter((claim) => positiveAnswerClaim(claim, shortfallIds));
769
+ const positiveExpansions = matchingExpansions.filter((expansion) => positiveExpansion(expansion, shortfallIds));
503
770
  let maturityLevel = row.maturity_level;
504
771
  const supportingRefs = [
505
772
  ...row.supporting_seed_refs,
506
773
  ...row.supporting_validation_refs,
507
774
  ...row.supporting_evidence_refs.map((ref) => ref.observation_id),
508
775
  ];
509
- const limitationRefs = [...row.limitation_refs];
776
+ // Value-read discharge subtracts ONLY this baseline row's value-dependent limitations
777
+ // that a validated satisfied discharge targeted; claim/expansion limitations below are
778
+ // never discharged (they are separate evidence caveats). dischargedForRow counts the
779
+ // baseline limitations actually cleared — the value_resolved gate (design §13.3).
780
+ const dischargedLimsForRow = dischargedLimitationsByBaselineRow.get(row.baseline_row_id) ??
781
+ new Set();
782
+ const dischargedForRow = row.limitation_refs.filter((ref) => dischargedLimsForRow.has(ref)).length;
783
+ const limitationRefs = row.limitation_refs.filter((ref) => !dischargedLimsForRow.has(ref));
510
784
  if (matchingAnswerClaims.length > 0 && args.maturationAnswerClaimsValidationRef) {
511
785
  supportingRefs.push(args.maturationAnswerClaimsValidationRef);
512
786
  }
@@ -519,6 +793,12 @@ export function buildActionabilityMatrixArtifact(args) {
519
793
  supportingRefs.push(ref.observation_id);
520
794
  }
521
795
  limitationRefs.push(...claim.limitation_refs);
796
+ // Site-7 degrade token: a matching judge-support-shortfall claim leaves a named
797
+ // residual limitation, so the row reads limitation_backed (not closed) and the
798
+ // shortfall reaches decision.limitation_refs — the honest-disclosure channel.
799
+ if (shortfallIds.has(claim.answer_claim_id)) {
800
+ limitationRefs.push(judgeSupportShortfallToken(claim.answer_claim_id));
801
+ }
522
802
  }
523
803
  for (const expansion of matchingExpansions) {
524
804
  supportingRefs.push(expansion.expansion_id, ...expansion.answer_claim_refs);
@@ -533,16 +813,17 @@ export function buildActionabilityMatrixArtifact(args) {
533
813
  if (positiveAnswerClaims.length > 0 && positiveExpansions.length > 0) {
534
814
  maturityLevel = higherMaturityLevel(maturityLevel, "L4_validated_for_purpose");
535
815
  }
536
- const frontierRequired = matrixRowNeedsFrontier({
816
+ // Shared derivation (builder ↔ validator). A value_resolved row has residual 0 and
817
+ // dischargedForRow > 0, so matrixRowNeedsFrontier would return true for it — therefore
818
+ // blocking_question_refs / next_action below gate on memberReadiness, NOT on a raw
819
+ // frontierRequired boolean, or the validator's reverse-link check would reject it
820
+ // (design §13.1 latent-defect fix).
821
+ const memberReadiness = deriveMemberReadiness({
537
822
  materiality: row.materiality,
538
- maturity_level: maturityLevel,
539
- limitation_refs: limitationRefs,
823
+ maturityLevel,
824
+ residualLimitationRefs: limitationRefs,
825
+ dischargedForRow,
540
826
  });
541
- const memberReadiness = limitationRefs.length > 0
542
- ? "limitation_backed"
543
- : frontierRequired
544
- ? "frontier_required"
545
- : "closed";
546
827
  return {
547
828
  matrix_row_id: `matrix-${slug(row.baseline_row_id)}`,
548
829
  baseline_row_refs: [row.baseline_row_id],
@@ -560,13 +841,19 @@ export function buildActionabilityMatrixArtifact(args) {
560
841
  competency_assessment_refs: row.competency_assessment_refs,
561
842
  maturity_level: maturityLevel,
562
843
  supporting_refs: [...new Set(supportingRefs)],
563
- blocking_question_refs: [],
844
+ // Only an open (frontier_required) row is blocked; a closed, value_resolved, or
845
+ // limitation-backed row carries no open blocking questions.
846
+ blocking_question_refs: memberReadiness === "frontier_required"
847
+ ? [...new Set(blockingQuestionsByBaselineRow.get(row.baseline_row_id) ?? [])]
848
+ : [],
564
849
  limitation_refs: [...new Set(limitationRefs)],
565
- next_action: frontierRequired
850
+ next_action: memberReadiness === "frontier_required"
566
851
  ? "Create a maturation frontier question for this row."
567
852
  : memberReadiness === "limitation_backed"
568
853
  ? "Keep the limitation visible in continuation decisions."
569
- : "Preserve the closed row as current actionability support.",
854
+ : memberReadiness === "value_resolved"
855
+ ? "Preserve the value-read discharge as current actionability support (value-grounded, not L4-validated)."
856
+ : "Preserve the closed row as current actionability support.",
570
857
  };
571
858
  }),
572
859
  };
@@ -574,13 +861,43 @@ export function buildActionabilityMatrixArtifact(args) {
574
861
  export function validateActionabilityMatrix(args) {
575
862
  const matrix = args.actionabilityMatrix;
576
863
  const violations = [];
864
+ const assertedObligationIds = [];
577
865
  const baselineRows = new Map(args.maturationBaseline.baseline_rows.map((row) => [row.baseline_row_id, row]));
578
866
  const answerClaims = args.maturationAnswerClaimsValidation?.validation_status === "valid"
579
867
  ? args.maturationAnswerClaims?.answer_claims ?? []
580
868
  : [];
869
+ const shortfallIds = judgeSupportShortfallIds(args.maturationAnswerClaimsValidation);
581
870
  const expansions = args.ontologyExpansionValidation?.validation_status === "valid"
582
871
  ? args.ontologyExpansion?.expansions ?? []
583
872
  : [];
873
+ // Maturation value-read cut: rebuild the validated discharge index from the consumed
874
+ // discharge artifact INDEPENDENTLY (derive-and-assert). The per-row readiness recompute and
875
+ // the dropped-baseline-limitation check below use this, never the matrix's stamped residual.
876
+ const dischargedLimitationsByBaselineRow = buildValidatedDischargeIndex(args.maturationValueDischarge, args.maturationValueDischargeValidation);
877
+ // Reverse-link conservation for blocking_question_refs: when a validated question
878
+ // frontier is threaded in (the current-matrix recompute), index each question by the
879
+ // baseline rows it names so the matrix's reverse link can be proven to mirror the
880
+ // frontier's forward link. Absent/invalid frontier (the baseline matrix) → the rows
881
+ // must carry no blocking questions yet.
882
+ const frontierAvailable = args.maturationQuestionFrontierValidation?.validation_status === "valid";
883
+ // qid -> the baseline rows it names (forward link), and the inverse baseline-row -> qids
884
+ // so the matrix's reverse link can be proven to be the EXACT set (not merely a subset)
885
+ // of the frontier questions naming each row.
886
+ const frontierQuestionBaselineRows = new Map();
887
+ const frontierQuestionsByBaselineRow = new Map();
888
+ if (frontierAvailable) {
889
+ for (const question of args.maturationQuestionFrontier?.questions ?? []) {
890
+ frontierQuestionBaselineRows.set(question.question_id, new Set(question.baseline_row_refs));
891
+ for (const baselineRowRef of question.baseline_row_refs) {
892
+ const bucket = frontierQuestionsByBaselineRow.get(baselineRowRef);
893
+ if (bucket)
894
+ bucket.add(question.question_id);
895
+ else {
896
+ frontierQuestionsByBaselineRow.set(baselineRowRef, new Set([question.question_id]));
897
+ }
898
+ }
899
+ }
900
+ }
584
901
  const seen = new Set();
585
902
  if (matrix.session_id !== args.maturationBaseline.session_id) {
586
903
  violations.push(violation({
@@ -589,6 +906,21 @@ export function validateActionabilityMatrix(args) {
589
906
  subjectId: matrix.session_id,
590
907
  }));
591
908
  }
909
+ // Continuation trusts matrix.candidate_limitation_refs to keep an otherwise closed
910
+ // run at actionable_limited, so the matrix must faithfully carry the validated
911
+ // baseline's candidate limitations — a stale/edited matrix that drops them would
912
+ // otherwise let continuation project actionable_ready despite the source limitation.
913
+ const baselineCandidateLimitations = new Set(args.maturationBaseline.candidate_limitation_refs);
914
+ const matrixCandidateLimitations = new Set(matrix.candidate_limitation_refs);
915
+ const candidateLimitationsMatch = baselineCandidateLimitations.size === matrixCandidateLimitations.size &&
916
+ [...baselineCandidateLimitations].every((ref) => matrixCandidateLimitations.has(ref));
917
+ if (!candidateLimitationsMatch) {
918
+ violations.push(violation({
919
+ code: "conflicting_state",
920
+ message: "actionability matrix candidate_limitation_refs must match the validated maturation baseline",
921
+ subjectId: "candidate_limitation_refs",
922
+ }));
923
+ }
592
924
  if (args.maturationBaselineValidation.validation_status !== "valid") {
593
925
  violations.push(violation({
594
926
  code: "prior_validation_invalid",
@@ -596,6 +928,132 @@ export function validateActionabilityMatrix(args) {
596
928
  subjectId: args.maturationBaselineValidationRef ?? null,
597
929
  }));
598
930
  }
931
+ // The question frontier is a PAIR of declared inputs (the artifact and its validation);
932
+ // they must be supplied together. A half-threaded call (one side only) would silently
933
+ // fall back to the pre-frontier rules and accept an unlinked current matrix, so fail loud.
934
+ const frontierProvided = args.maturationQuestionFrontier != null;
935
+ const frontierValidationProvided = args.maturationQuestionFrontierValidation != null;
936
+ if (frontierProvided !== frontierValidationProvided) {
937
+ violations.push(violation({
938
+ code: "missing_required_ref",
939
+ message: "actionability matrix question frontier and its validation must be supplied together",
940
+ subjectId: args.maturationQuestionFrontierValidationRef ??
941
+ args.actionabilityMatrixRef ?? null,
942
+ }));
943
+ }
944
+ // Current-matrix mode is signalled by post-frontier authority inputs (answer claims and
945
+ // ontology expansion are authored AFTER the frontier). Whenever any are present, the
946
+ // frontier pair is required — otherwise a current matrix with frontier_required rows and
947
+ // empty blocking_question_refs would validate under the pre-frontier (baseline) rules.
948
+ // Only the true baseline matrix (no post-frontier inputs AND no frontier) may omit it.
949
+ const postFrontierInputsPresent = args.maturationAnswerClaims != null ||
950
+ args.maturationAnswerClaimsValidation != null ||
951
+ args.ontologyExpansion != null ||
952
+ args.ontologyExpansionValidation != null;
953
+ if (postFrontierInputsPresent &&
954
+ !(frontierProvided && frontierValidationProvided)) {
955
+ violations.push(violation({
956
+ code: "missing_required_ref",
957
+ message: "current-matrix actionability validation (carrying answer-claim or expansion inputs) requires the question frontier and its validation",
958
+ subjectId: args.maturationQuestionFrontierValidationRef ??
959
+ args.actionabilityMatrixRef ?? null,
960
+ }));
961
+ }
962
+ // A supplied question-frontier validation is a declared input authority: distinguish
963
+ // "no frontier supplied" (the pre-frontier baseline matrix, legitimately empty) from
964
+ // "supplied but invalid". The latter must fail rather than silently fall back to the
965
+ // pre-frontier rules (which would let an empty blocking_question_refs pass even though
966
+ // the required frontier authority failed validation).
967
+ if (args.maturationQuestionFrontierValidation &&
968
+ args.maturationQuestionFrontierValidation.validation_status !== "valid") {
969
+ violations.push(violation({
970
+ code: "prior_validation_invalid",
971
+ message: "actionability matrix requires valid maturation question frontier validation when one is supplied",
972
+ subjectId: args.maturationQuestionFrontierValidationRef ?? null,
973
+ }));
974
+ }
975
+ // Bind the frontier validation to the frontier artifact it is consumed with: a valid
976
+ // validation of a DIFFERENT frontier must not bless the supplied (possibly stale/edited)
977
+ // frontier, whose questions would otherwise satisfy blocking_question_refs unvalidated.
978
+ if (frontierProvided && frontierAvailable) {
979
+ const recordedFrontierRef = args.maturationQuestionFrontierValidation?.maturation_question_frontier_ref ?? null;
980
+ if (recordedFrontierRef !== (args.maturationQuestionFrontierRef ?? null)) {
981
+ violations.push(violation({
982
+ code: "conflicting_state",
983
+ message: "actionability matrix question frontier validation must validate the supplied question frontier",
984
+ subjectId: args.maturationQuestionFrontierValidationRef ??
985
+ args.maturationQuestionFrontierRef ?? null,
986
+ }));
987
+ }
988
+ }
989
+ // Cross-artifact lineage chain: the consumed validations must form a consistent lineage —
990
+ // each must have been produced against the upstream validation that is actually supplied
991
+ // here (baseline-validation <- frontier-validation <- answer-claims-validation <-
992
+ // ontology-expansion-validation). A valid validation produced against a different upstream
993
+ // cannot bless this matrix. (Each check is gated on the consumed validation being present &
994
+ // valid, so the pre-frontier baseline matrix trips none of them. Binding each validation to
995
+ // its OWN source artifact — beyond the frontier above — is a smaller residual; see the
996
+ // follow-up note.)
997
+ for (const lineage of [
998
+ frontierAvailable
999
+ ? validationLineageViolation({
1000
+ recordedUpstreamRef: args.maturationQuestionFrontierValidation?.maturation_baseline_validation_ref,
1001
+ suppliedUpstreamRef: args.maturationBaselineValidationRef,
1002
+ message: "actionability matrix question frontier validation must be produced against the supplied maturation baseline validation",
1003
+ })
1004
+ : null,
1005
+ args.maturationAnswerClaimsValidation?.validation_status === "valid"
1006
+ ? validationLineageViolation({
1007
+ recordedUpstreamRef: args.maturationAnswerClaimsValidation.maturation_question_frontier_validation_ref,
1008
+ suppliedUpstreamRef: args.maturationQuestionFrontierValidationRef,
1009
+ message: "actionability matrix answer-claims validation must be produced against the supplied question frontier validation",
1010
+ })
1011
+ : null,
1012
+ args.ontologyExpansionValidation?.validation_status === "valid"
1013
+ ? validationLineageViolation({
1014
+ recordedUpstreamRef: args.ontologyExpansionValidation.maturation_answer_claims_validation_ref,
1015
+ suppliedUpstreamRef: args.maturationAnswerClaimsValidationRef,
1016
+ message: "actionability matrix ontology expansion validation must be produced against the supplied answer-claims validation",
1017
+ })
1018
+ : null,
1019
+ ]) {
1020
+ if (lineage)
1021
+ violations.push(lineage);
1022
+ }
1023
+ // G(a): record reaching the matrix row-id/baseline-ref-close block UNCONDITIONALLY (before
1024
+ // the per-row loop) so the obligation is proven wired even for an empty matrix. One fn serves
1025
+ // two validators; the recorded id attributes to whichever validator this mode is (below).
1026
+ assertObligation(assertedObligationIds, "validate_matrix_row_ids_are_stable_and_baseline_row_refs_close");
1027
+ // G(a) slice 3: mode-ALIGNED recording. This fn serves two validators by mode
1028
+ // (postFrontierInputsPresent → validator_id) and a shared enforcement region satisfies a
1029
+ // DIFFERENTLY-NAMED obligation per mode, so each stamp's obligation_id is mode-conditional and is
1030
+ // emitted ONLY when the mode (and, for the frontier-gated pair, the frontier branch) matches the
1031
+ // registry's validator attribution — never minting a (validator_id, obligation_id) pair the
1032
+ // registry lacks. Each recorded pair was audited to a distinct enforcement region (no laundering);
1033
+ // obligations with no distinct enforcement stay parked with ledger audit notes (the current-mode
1034
+ // expansion-alt is absent, the blocker/high-L4 rule is missing, "support" rules push no violation,
1035
+ // the maturity-level rule is distributed across other regions, the preserve-seed rule is defensive).
1036
+ if (postFrontierInputsPresent) {
1037
+ // CURRENT mode → actionability-matrix-validator.
1038
+ // identity preservation + maturity-upgrade citation region (conflicting_state identity /
1039
+ // missing_required_ref upgrade, below).
1040
+ assertObligation(assertedObligationIds, "validate_matrix_rows_derive_from_validated_baseline_and_any_applicable_validated_deltas");
1041
+ if (frontierAvailable) {
1042
+ // the frontier reverse-link validation (the frontierAvailable `else` branch below).
1043
+ assertObligation(assertedObligationIds, "validate_blocking_question_refs_against_validated_question_frontier");
1044
+ }
1045
+ }
1046
+ else {
1047
+ // BASELINE mode → baseline-actionability-matrix-validator.
1048
+ // strict baseline-row-ref resolution (unknown_id / conflicting_state on baseline_row_refs).
1049
+ assertObligation(assertedObligationIds, "reject_matrix_rows_without_baseline_row_ref");
1050
+ // baseline payload conservation (conflicting_state on identity / maturity-no-reduce).
1051
+ assertObligation(assertedObligationIds, "validate_matrix_rows_derive_from_validated_baseline_without_maturation_deltas");
1052
+ if (!frontierAvailable) {
1053
+ // reject blocking refs before the frontier exists (the `!frontierAvailable` branch below).
1054
+ assertObligation(assertedObligationIds, "reject_blocking_question_refs_before_question_frontier_authoring");
1055
+ }
1056
+ }
599
1057
  for (const row of matrix.rows) {
600
1058
  if (seen.has(row.matrix_row_id)) {
601
1059
  violations.push(violation({
@@ -605,6 +1063,13 @@ export function validateActionabilityMatrix(args) {
605
1063
  }));
606
1064
  }
607
1065
  seen.add(row.matrix_row_id);
1066
+ if (row.baseline_row_refs.length !== 1) {
1067
+ violations.push(violation({
1068
+ code: "conflicting_state",
1069
+ message: "matrix row must reference exactly one baseline row",
1070
+ subjectId: row.matrix_row_id,
1071
+ }));
1072
+ }
608
1073
  const baselineRef = row.baseline_row_refs[0] ?? null;
609
1074
  const baselineRow = baselineRef ? baselineRows.get(baselineRef) : null;
610
1075
  if (!baselineRow) {
@@ -622,6 +1087,31 @@ export function validateActionabilityMatrix(args) {
622
1087
  subjectId: row.matrix_row_id,
623
1088
  }));
624
1089
  }
1090
+ // M1 payload conservation: a matrix row can cite baseline A while mutating the
1091
+ // identity/lineage fields it must inherit. Assert the baseline-immutable set is
1092
+ // preserved (materiality is checked above; maturity/support/limitation/readiness
1093
+ // legitimately change by validated rules, so are NOT asserted). The builder copies
1094
+ // member-lineage AND competency refs VERBATIM from the baseline row, so they are
1095
+ // compared with exact array equality (multiplicity- and order-sensitive): a
1096
+ // set-only check would let a duplicate-occurrence swap or a competency-ref tamper
1097
+ // pass undetected.
1098
+ const identityPreserved = row.purpose_element_ref === baselineRow.purpose_element_ref &&
1099
+ row.actionability_surface_ref === baselineRow.actionability_surface_ref &&
1100
+ row.maturity_dimension_ref === baselineRow.maturity_dimension_ref &&
1101
+ row.materiality_ref === baselineRow.materiality_ref &&
1102
+ row.member_target_material_kind === baselineRow.member_target_material_kind &&
1103
+ sameRefArray(row.member_scope_refs, baselineRow.member_scope_refs) &&
1104
+ sameRefArray(row.member_source_refs, baselineRow.member_source_refs) &&
1105
+ sameRefArray(row.cross_material_ref_refs, baselineRow.cross_material_ref_refs) &&
1106
+ sameRefArray(row.competency_question_refs, baselineRow.competency_question_refs) &&
1107
+ sameRefArray(row.competency_assessment_refs, baselineRow.competency_assessment_refs);
1108
+ if (!identityPreserved) {
1109
+ violations.push(violation({
1110
+ code: "conflicting_state",
1111
+ message: "matrix row must preserve the baseline row's identity and member-lineage fields",
1112
+ subjectId: row.matrix_row_id,
1113
+ }));
1114
+ }
625
1115
  if (maturityLevelRank(row.maturity_level) <
626
1116
  maturityLevelRank(baselineRow.maturity_level)) {
627
1117
  violations.push(violation({
@@ -631,11 +1121,9 @@ export function validateActionabilityMatrix(args) {
631
1121
  }));
632
1122
  }
633
1123
  const matchingAnswerClaims = answerClaims.filter((claim) => answerClaimMatchesBaselineRow(claim, baselineRow) &&
634
- claim.answer_status === "answered" &&
635
- claim.limitation_refs.length === 0);
1124
+ positiveAnswerClaim(claim, shortfallIds));
636
1125
  const matchingExpansions = expansions.filter((expansion) => expansionMatchesBaselineRow(expansion, baselineRow) &&
637
- (expansion.operation === "add" || expansion.operation === "refine") &&
638
- expansion.limitation_refs.length === 0);
1126
+ positiveExpansion(expansion, shortfallIds));
639
1127
  if (row.maturity_level !== baselineRow.maturity_level) {
640
1128
  const claimsCanRaiseToL3 = matchingAnswerClaims.length > 0;
641
1129
  const claimsCanRaiseToL4 = matchingAnswerClaims.length > 0 && matchingExpansions.length > 0;
@@ -675,18 +1163,157 @@ export function validateActionabilityMatrix(args) {
675
1163
  }));
676
1164
  }
677
1165
  }
678
- const expectedReadiness = row.limitation_refs.length > 0
679
- ? "limitation_backed"
680
- : matrixRowNeedsFrontier(row)
681
- ? "frontier_required"
682
- : "closed";
1166
+ // Derive-and-assert (design §13.3 F2): recompute the discharge effect from the
1167
+ // INDEPENDENTLY rebuilt validated discharge index — never trust the matrix's stamped
1168
+ // residual. (a) every baseline limitation the matrix dropped from this row must have
1169
+ // been cleared by a validated satisfied discharge; (b) member_readiness must equal the
1170
+ // shared derivation using the recomputed dischargedForRow, so a forged value_resolved
1171
+ // with no real discharge is rejected (it falls through to frontier_required/closed). The
1172
+ // residual LENGTH still reads the stamped row.limitation_refs (claim/expansion caveats are
1173
+ // trusted as before), but a forged shrink is caught by (a).
1174
+ const dischargedLimsForRow = dischargedLimitationsByBaselineRow.get(baselineRow.baseline_row_id) ??
1175
+ new Set();
1176
+ const stampedLimitationRefs = new Set(row.limitation_refs);
1177
+ for (const baselineLim of baselineRow.limitation_refs) {
1178
+ if (!stampedLimitationRefs.has(baselineLim) &&
1179
+ !dischargedLimsForRow.has(baselineLim)) {
1180
+ violations.push(violation({
1181
+ code: "conflicting_state",
1182
+ message: "matrix row dropped a baseline limitation without a validated satisfied value-discharge",
1183
+ subjectId: row.matrix_row_id,
1184
+ }));
1185
+ }
1186
+ }
1187
+ // Site-7 token conservation (design 20260706 §5): stamped claim/expansion caveats are
1188
+ // otherwise trusted, so a stale/edited matrix could silently DROP the judge-support-
1189
+ // shortfall token and un-exclude the row. Re-derive the expected tokens from the claims
1190
+ // + validation authority and require each on the stamped row (mirror of the dropped-
1191
+ // baseline-limitation check above).
1192
+ for (const claim of answerClaims) {
1193
+ if (!shortfallIds.has(claim.answer_claim_id))
1194
+ continue;
1195
+ if (!answerClaimMatchesBaselineRow(claim, baselineRow))
1196
+ continue;
1197
+ if (!stampedLimitationRefs.has(judgeSupportShortfallToken(claim.answer_claim_id))) {
1198
+ violations.push(violation({
1199
+ code: "conflicting_state",
1200
+ message: "matrix row dropped the judge-support-shortfall limitation token for a matching degraded claim",
1201
+ subjectId: row.matrix_row_id,
1202
+ }));
1203
+ }
1204
+ }
1205
+ const dischargedForRow = baselineRow.limitation_refs.filter((ref) => dischargedLimsForRow.has(ref)).length;
1206
+ const expectedReadiness = deriveMemberReadiness({
1207
+ materiality: row.materiality,
1208
+ maturityLevel: row.maturity_level,
1209
+ residualLimitationRefs: row.limitation_refs,
1210
+ dischargedForRow,
1211
+ });
683
1212
  if (row.member_readiness !== expectedReadiness) {
684
1213
  violations.push(violation({
685
1214
  code: "conflicting_state",
686
- message: "matrix member_readiness must follow material L4, frontier, or limitation state",
1215
+ message: "matrix member_readiness must follow material L4, frontier, value-discharge, or limitation state",
687
1216
  subjectId: row.matrix_row_id,
688
1217
  }));
689
1218
  }
1219
+ // blocking_question_refs reverse-link conservation (G track):
1220
+ // before the frontier exists (baseline matrix) the row must cite no questions;
1221
+ // once the validated frontier exists (current matrix) an open frontier_required row
1222
+ // must cite its blocking question(s), a closed/limitation-backed row must cite none,
1223
+ // and every cited ref must resolve to a frontier question that names this row.
1224
+ if (!frontierAvailable) {
1225
+ if (row.blocking_question_refs.length > 0) {
1226
+ violations.push(violation({
1227
+ code: "conflicting_state",
1228
+ message: "matrix row cannot cite blocking questions before the question frontier exists",
1229
+ subjectId: row.matrix_row_id,
1230
+ }));
1231
+ }
1232
+ }
1233
+ else {
1234
+ const rowIsFrontier = row.member_readiness === "frontier_required";
1235
+ const citedQuestions = new Set(row.blocking_question_refs);
1236
+ if (rowIsFrontier) {
1237
+ // Exact reverse link: an open row must cite EVERY validated frontier question that
1238
+ // names it, not merely one of them — otherwise a stale/edited matrix could drop one
1239
+ // of several questions for a row and still hide an open blocking question.
1240
+ const expectedQuestions = new Set();
1241
+ for (const baselineRowRef of row.baseline_row_refs) {
1242
+ for (const questionId of frontierQuestionsByBaselineRow.get(baselineRowRef) ??
1243
+ []) {
1244
+ expectedQuestions.add(questionId);
1245
+ }
1246
+ }
1247
+ // The supplied frontier must actually name this open material row. An empty
1248
+ // expected set means a stale/edited/mismatched frontier (e.g. a valid validation
1249
+ // paired with a different frontier artifact) — without this guard the coverage
1250
+ // loop below would be vacuous and an unresolved row would validate with empty refs.
1251
+ if (expectedQuestions.size === 0) {
1252
+ violations.push(violation({
1253
+ code: "missing_required_coverage",
1254
+ message: "frontier-required matrix row has no matching question in the supplied frontier",
1255
+ subjectId: row.matrix_row_id,
1256
+ }));
1257
+ }
1258
+ for (const expectedQuestionId of expectedQuestions) {
1259
+ if (!citedQuestions.has(expectedQuestionId)) {
1260
+ violations.push(violation({
1261
+ code: "missing_required_coverage",
1262
+ message: "frontier-required matrix row must cite every blocking maturation question that names it",
1263
+ subjectId: row.matrix_row_id,
1264
+ }));
1265
+ }
1266
+ }
1267
+ }
1268
+ if (!rowIsFrontier && row.blocking_question_refs.length > 0) {
1269
+ violations.push(violation({
1270
+ code: "conflicting_state",
1271
+ message: "closed or limitation-backed matrix row cannot cite open blocking questions",
1272
+ subjectId: row.matrix_row_id,
1273
+ }));
1274
+ }
1275
+ for (const questionRef of row.blocking_question_refs) {
1276
+ const questionBaselineRows = frontierQuestionBaselineRows.get(questionRef);
1277
+ if (!questionBaselineRows) {
1278
+ violations.push(violation({
1279
+ code: "unknown_id",
1280
+ message: "matrix blocking_question_refs must resolve to the validated question frontier",
1281
+ subjectId: questionRef,
1282
+ }));
1283
+ continue;
1284
+ }
1285
+ const namesRow = row.baseline_row_refs.some((ref) => questionBaselineRows.has(ref));
1286
+ if (!namesRow) {
1287
+ violations.push(violation({
1288
+ code: "conflicting_state",
1289
+ message: "matrix blocking question must name this row's baseline ref (reverse link must mirror the frontier)",
1290
+ subjectId: row.matrix_row_id,
1291
+ }));
1292
+ }
1293
+ }
1294
+ }
1295
+ }
1296
+ // M1 coverage conservation: every baseline row must map to exactly one matrix row.
1297
+ // The loop above only checks that PRESENT matrix rows resolve to a baseline row, so a
1298
+ // matrix that DROPS a baseline row (erasing its scope from the downstream claim) would
1299
+ // otherwise pass silently.
1300
+ const matrixCoverageCounts = new Map();
1301
+ for (const row of matrix.rows) {
1302
+ for (const ref of row.baseline_row_refs) {
1303
+ matrixCoverageCounts.set(ref, (matrixCoverageCounts.get(ref) ?? 0) + 1);
1304
+ }
1305
+ }
1306
+ for (const baselineRowId of baselineRows.keys()) {
1307
+ const count = matrixCoverageCounts.get(baselineRowId) ?? 0;
1308
+ if (count !== 1) {
1309
+ violations.push(violation({
1310
+ code: count === 0 ? "missing_required_coverage" : "conflicting_state",
1311
+ message: count === 0
1312
+ ? `baseline row has no actionability matrix row: ${baselineRowId}`
1313
+ : `baseline row is covered by multiple matrix rows: ${baselineRowId}`,
1314
+ subjectId: baselineRowId,
1315
+ }));
1316
+ }
690
1317
  }
691
1318
  return {
692
1319
  schema_version: "1",
@@ -696,18 +1323,26 @@ export function validateActionabilityMatrix(args) {
696
1323
  maturation_baseline_validation_ref: args.maturationBaselineValidationRef ?? null,
697
1324
  maturation_answer_claims_validation_ref: args.maturationAnswerClaimsValidationRef ?? null,
698
1325
  ontology_expansion_validation_ref: args.ontologyExpansionValidationRef ?? null,
1326
+ maturation_question_frontier_validation_ref: args.maturationQuestionFrontierValidationRef ?? null,
699
1327
  validation_status: violations.length === 0 ? "valid" : "invalid",
700
1328
  matrix_row_count: matrix.rows.length,
701
1329
  frontier_required_row_count: matrix.rows.filter((row) => row.member_readiness === "frontier_required").length,
702
1330
  validation_results: violations.length === 0
703
1331
  ? ["actionability_matrix_valid"]
704
1332
  : ["actionability_matrix_invalid"],
1333
+ // G(a) attribution: the mode (computed above as postFrontierInputsPresent) selects which of
1334
+ // the two validators served by this fn owns the recorded obligation pair.
1335
+ validator_id: postFrontierInputsPresent
1336
+ ? "actionability-matrix-validator"
1337
+ : "baseline-actionability-matrix-validator",
1338
+ asserted_obligation_ids: assertedObligationIds,
705
1339
  violations,
706
1340
  };
707
1341
  }
708
1342
  export function validateMaturationQuestionFrontier(args) {
709
1343
  const frontier = args.maturationQuestionFrontier;
710
1344
  const violations = [];
1345
+ const assertedObligationIds = [];
711
1346
  const baselineRows = new Map(args.maturationBaseline.baseline_rows.map((row) => [row.baseline_row_id, row]));
712
1347
  const materialFrontierBaselineRows = new Set(args.actionabilityMatrix.rows
713
1348
  .filter((row) => row.member_readiness === "frontier_required")
@@ -735,6 +1370,13 @@ export function validateMaturationQuestionFrontier(args) {
735
1370
  subjectId: args.actionabilityMatrixValidationRef ?? null,
736
1371
  }));
737
1372
  }
1373
+ // G(a) slice 6: record the two question-frontier obligations with a distinct, audited enforcement
1374
+ // region, before the per-question loop so they fire on a zero-question frontier. The other four
1375
+ // obligations stay parked with ledger audit notes — authority-need first-class scoping and the
1376
+ // competency/domain-trace refs are NOT validated here (NOT_FOUND), and the answer_status enum and
1377
+ // the seed+limitation authority clauses are unenforced (the obligation names are broader than code).
1378
+ assertObligation(assertedObligationIds, "require_blocker_and_high_questions_to_have_closure_frontier_limitation_or_authority_need");
1379
+ assertObligation(assertedObligationIds, "require_unique_question_id");
738
1380
  for (const question of frontier.questions) {
739
1381
  if (seen.has(question.question_id)) {
740
1382
  violations.push(violation({
@@ -812,6 +1454,7 @@ export function validateMaturationQuestionFrontier(args) {
812
1454
  validation_results: violations.length === 0
813
1455
  ? ["maturation_question_frontier_valid"]
814
1456
  : ["maturation_question_frontier_invalid"],
1457
+ asserted_obligation_ids: assertedObligationIds,
815
1458
  violations,
816
1459
  };
817
1460
  }
@@ -849,6 +1492,19 @@ function questionMap(frontier) {
849
1492
  export function validateMaturationClosureFrontier(args) {
850
1493
  const frontier = args.maturationClosureFrontier;
851
1494
  const violations = [];
1495
+ // G(a) slice 25 — RECORD only the 3 obligations that fully enforce their named scope with no
1496
+ // runtime/registry/edge gaps (codex R1 found this validator is NOT cleanly structural — 7 obligations
1497
+ // have deeper gaps, see obligation-coverage-ledger.yaml notes): #2 dup-source only de-dupes the id, not
1498
+ // same-target-ref duplicates (runtime re-entry throws); #4 accepts exists:false/skipped inventory rows
1499
+ // (runtime buildReconstructSourceObservation returns null); #10 mixed-lineage never fires (source
1500
+ // requests carry concrete per-ref kinds, never "mixed"); #1 authority-dedup keys on the whole
1501
+ // question_refs set, missing per-question overlap; #8/#9 don't reject empty question_refs; and #6/#8/#9
1502
+ // read the maturation-question-frontier ARTIFACT which the registry does not declare as an input
1503
+ // authority (declared≠wired, slice-14 pattern). Stamped before any per-request guard (zero-row input).
1504
+ const assertedObligationIds = [];
1505
+ assertObligation(assertedObligationIds, "reject_semantic_only_locations");
1506
+ assertObligation(assertedObligationIds, "require_unique_authority_request_id");
1507
+ assertObligation(assertedObligationIds, "validate_authority_request_kind_expected_response_kind_and_scope");
852
1508
  const materialQuestions = materialQuestionIds(args.maturationQuestionFrontier);
853
1509
  const questions = questionMap(args.maturationQuestionFrontier);
854
1510
  const sourceRequestsSeen = new Set();
@@ -1053,6 +1709,7 @@ export function validateMaturationClosureFrontier(args) {
1053
1709
  validation_results: violations.length === 0
1054
1710
  ? ["maturation_closure_frontier_valid"]
1055
1711
  : ["maturation_closure_frontier_invalid"],
1712
+ asserted_obligation_ids: assertedObligationIds,
1056
1713
  violations,
1057
1714
  };
1058
1715
  }
@@ -1171,12 +1828,239 @@ export function validateMaturationAuthorityResponse(args) {
1171
1828
  violations,
1172
1829
  };
1173
1830
  }
1831
+ // Maturation value-read cut (design §13.5 F4/F5). Governance + structural validator for the
1832
+ // value-discharge artifact. Mirrors the answer-support source-safety precondition gate and the
1833
+ // per-evidence consumption_allowed check, and adds the F4 read-path constraint: value evidence
1834
+ // may come ONLY from an already-observed, runtime-target (basis A) source — reading a non-target
1835
+ // source would leak its raw values into the discharge prompt. Structural: each discharge may only
1836
+ // target a limitation that actually exists on its baseline row. The matrix derive-and-assert
1837
+ // consumes ONLY discharges whose validation here is `valid`.
1838
+ export function validateMaturationValueDischarge(args) {
1839
+ const discharge = args.maturationValueDischarge;
1840
+ const violations = [];
1841
+ const baselineRows = new Map(args.maturationBaseline.baseline_rows.map((row) => [row.baseline_row_id, row]));
1842
+ const sourceObservationsById = new Map(args.sourceObservations.observations.map((o) => [o.observation_id, o]));
1843
+ const safetyRowsById = new Map((args.sourceSafetyLedger?.safety_rows ?? []).map((r) => [r.safety_row_id, r]));
1844
+ if (discharge.session_id !== args.maturationBaseline.session_id) {
1845
+ violations.push(violation({
1846
+ code: "session_id_mismatch",
1847
+ message: "value discharge session_id must match maturation baseline",
1848
+ subjectId: discharge.session_id,
1849
+ }));
1850
+ }
1851
+ if (args.maturationBaselineValidation.validation_status !== "valid") {
1852
+ violations.push(violation({
1853
+ code: "prior_validation_invalid",
1854
+ message: "value discharge requires valid maturation baseline validation",
1855
+ subjectId: args.maturationBaselineValidationRef ?? null,
1856
+ }));
1857
+ }
1858
+ const hasDischarges = discharge.discharges.length > 0;
1859
+ // Source-safety precondition (replicates the answer-support gate, F5): discharges make
1860
+ // material claims, so the safety ledger and its valid validation must be supplied and
1861
+ // certify the consumed ledger before the per-evidence consumption checks run.
1862
+ if (hasDischarges && !args.sourceSafetyLedger) {
1863
+ violations.push(violation({
1864
+ code: "missing_required_ref",
1865
+ message: "value discharge requires the source safety ledger",
1866
+ subjectId: args.sourceSafetyLedgerRef ?? null,
1867
+ }));
1868
+ }
1869
+ if (hasDischarges && !args.sourceSafetyLedgerValidation) {
1870
+ violations.push(violation({
1871
+ code: "prior_validation_invalid",
1872
+ message: "value discharge requires source safety ledger validation",
1873
+ subjectId: args.sourceSafetyLedgerValidationRef ?? null,
1874
+ }));
1875
+ }
1876
+ if (args.sourceSafetyLedgerValidation &&
1877
+ args.sourceSafetyLedgerValidation.validation_status !== "valid") {
1878
+ violations.push(violation({
1879
+ code: "prior_validation_invalid",
1880
+ message: "value discharge requires valid source safety ledger validation",
1881
+ subjectId: args.sourceSafetyLedgerValidationRef ?? null,
1882
+ }));
1883
+ }
1884
+ if (args.sourceSafetyLedger &&
1885
+ args.sourceSafetyLedgerValidation &&
1886
+ args.sourceSafetyLedgerValidationRef &&
1887
+ args.sourceSafetyLedgerValidation.source_safety_ledger_ref &&
1888
+ path.resolve(args.sourceSafetyLedgerValidation.source_safety_ledger_ref) !==
1889
+ path.resolve(args.sourceSafetyLedgerRef ?? "")) {
1890
+ violations.push(violation({
1891
+ code: "prior_validation_invalid",
1892
+ message: "value discharge source safety ledger validation must certify the consumed source safety ledger",
1893
+ subjectId: args.sourceSafetyLedgerValidationRef ?? null,
1894
+ }));
1895
+ }
1896
+ const seen = new Set();
1897
+ let satisfiedCount = 0;
1898
+ for (const entry of discharge.discharges) {
1899
+ if (seen.has(entry.discharge_id)) {
1900
+ violations.push(violation({
1901
+ code: "duplicate_id",
1902
+ message: "value discharge entries require a unique discharge_id",
1903
+ subjectId: entry.discharge_id,
1904
+ }));
1905
+ }
1906
+ seen.add(entry.discharge_id);
1907
+ if (entry.satisfaction_status === "satisfied")
1908
+ satisfiedCount += 1;
1909
+ // Structural: each targeted limitation must actually exist on the targeted baseline row —
1910
+ // a discharge cannot subtract a phantom limitation (matrix derive-and-assert depends on this).
1911
+ for (const baselineRowRef of entry.target_baseline_row_refs) {
1912
+ const baselineRow = baselineRows.get(baselineRowRef);
1913
+ if (!baselineRow) {
1914
+ violations.push(violation({
1915
+ code: "unknown_id",
1916
+ message: "value discharge target_baseline_row_refs must resolve to maturation baseline rows",
1917
+ subjectId: baselineRowRef,
1918
+ }));
1919
+ continue;
1920
+ }
1921
+ const baselineLimitations = new Set(baselineRow.limitation_refs);
1922
+ for (const limitationRef of entry.target_limitation_refs) {
1923
+ if (!baselineLimitations.has(limitationRef)) {
1924
+ violations.push(violation({
1925
+ code: "missing_required_ref",
1926
+ message: "value discharge target_limitation_refs must exist on the targeted baseline row's limitation_refs",
1927
+ subjectId: `${baselineRowRef}:${limitationRef}`,
1928
+ }));
1929
+ }
1930
+ }
1931
+ }
1932
+ // Governance (F4/F5): the value evidence must come from an ALREADY-OBSERVED, runtime-target
1933
+ // (basis A) source whose material_claim safety row is consumption_allowed. A non-target
1934
+ // source is rejected outright — reading it would leak its raw values into the prompt.
1935
+ const observationId = entry.value_evidence_ref.observation_id;
1936
+ const observation = sourceObservationsById.get(observationId);
1937
+ if (!observation) {
1938
+ violations.push(violation({
1939
+ code: "unknown_id",
1940
+ message: "value discharge value_evidence_ref.observation_id must resolve to an already-observed source",
1941
+ subjectId: observationId,
1942
+ }));
1943
+ continue;
1944
+ }
1945
+ if (observation.is_runtime_target_source !== true) {
1946
+ violations.push(violation({
1947
+ code: "conflicting_state",
1948
+ message: "value discharge may read only runtime-target sources (basis A); a non-target source cannot be read",
1949
+ subjectId: observationId,
1950
+ }));
1951
+ }
1952
+ const expectedAuthorizationRef = `${observationId}:material_claim`;
1953
+ if (entry.value_evidence_authorization_ref !== expectedAuthorizationRef) {
1954
+ violations.push(violation({
1955
+ code: "conflicting_state",
1956
+ message: "value discharge value_evidence_authorization_ref must be the canonical <observation_id>:material_claim ref",
1957
+ subjectId: entry.value_evidence_authorization_ref,
1958
+ }));
1959
+ }
1960
+ if (args.sourceSafetyLedger) {
1961
+ const materialClaimRowId = sourceSafetyRowIdForObservation(observation, "material_claim");
1962
+ const materialClaimRow = safetyRowsById.get(materialClaimRowId);
1963
+ if (!materialClaimRow ||
1964
+ materialClaimRow.proof_sufficiency_state !== "sufficient_for_claim" ||
1965
+ materialClaimRow.visibility_tier !== "consumption_allowed") {
1966
+ violations.push(violation({
1967
+ code: "missing_required_ref",
1968
+ message: "value discharge evidence must have an observation-specific material_claim source-safety row that is consumption_allowed and sufficient for claim",
1969
+ subjectId: observationId,
1970
+ }));
1971
+ }
1972
+ }
1973
+ // Provenance floor (design §15.4): a `satisfied` discharge must rest on a REAL, COMPLETE,
1974
+ // content-bound read — otherwise it cannot drive value_resolved. A 0-cell read (dead/empty), a
1975
+ // truncated read (partial view), or a content-hash mismatch vs the authorized observation's
1976
+ // observed bytes (file changed between observation and the re-read) → reject. This makes
1977
+ // cells_read / read_truncated / read_content_sha256 REAL consumers, not inert provenance
1978
+ // (FRP-1 / issue-008 / GL-1). refuted/inconclusive discharges never subtract, so they are exempt.
1979
+ if (entry.satisfaction_status === "satisfied") {
1980
+ const evidence = entry.value_evidence_ref;
1981
+ if (evidence.cells_read <= 0) {
1982
+ violations.push(violation({
1983
+ code: "conflicting_state",
1984
+ message: "a satisfied value discharge must be backed by a non-empty cell read (cells_read > 0)",
1985
+ subjectId: entry.discharge_id,
1986
+ }));
1987
+ }
1988
+ if (evidence.read_truncated) {
1989
+ violations.push(violation({
1990
+ code: "conflicting_state",
1991
+ message: "a satisfied value discharge cannot rest on a truncated (incomplete) cell read",
1992
+ subjectId: entry.discharge_id,
1993
+ }));
1994
+ }
1995
+ const observedContentSha256 = observation.structural_data
1996
+ ?.workbook_inventory?.content_sha256;
1997
+ // Fail-closed (design §16.2, onto issue-013): a satisfied discharge must be content-bound to the
1998
+ // authorized observation's observed bytes. If the observation carries NO observed content hash to
1999
+ // bind against, the binding cannot be proven → reject rather than silently skip the check.
2000
+ if (typeof observedContentSha256 !== "string" || observedContentSha256.length === 0) {
2001
+ violations.push(violation({
2002
+ code: "conflicting_state",
2003
+ message: "a satisfied value discharge requires the authorized observation to carry an observed content_sha256 to content-bind against",
2004
+ subjectId: entry.discharge_id,
2005
+ }));
2006
+ }
2007
+ else if (evidence.read_content_sha256 !== observedContentSha256) {
2008
+ violations.push(violation({
2009
+ code: "conflicting_state",
2010
+ message: "value discharge read_content_sha256 must match the authorized observation's observed content_sha256 (the source changed between observation and the maturation re-read)",
2011
+ subjectId: entry.discharge_id,
2012
+ }));
2013
+ }
2014
+ }
2015
+ }
2016
+ return {
2017
+ schema_version: "1",
2018
+ session_id: discharge.session_id,
2019
+ created_at: isoNow(),
2020
+ maturation_value_discharge_ref: args.maturationValueDischargeRef ?? null,
2021
+ source_safety_ledger_validation_ref: args.sourceSafetyLedgerValidationRef ?? null,
2022
+ source_observation_reentry_validation_ref: null,
2023
+ maturation_baseline_validation_ref: args.maturationBaselineValidationRef ?? null,
2024
+ validation_status: violations.length === 0 ? "valid" : "invalid",
2025
+ discharge_count: discharge.discharges.length,
2026
+ satisfied_discharge_count: satisfiedCount,
2027
+ validation_results: violations.length === 0
2028
+ ? ["maturation_value_discharge_valid"]
2029
+ : ["maturation_value_discharge_invalid"],
2030
+ asserted_obligation_ids: [],
2031
+ violations,
2032
+ };
2033
+ }
1174
2034
  export function validateAnswerSupportLedger(args) {
1175
2035
  const ledger = args.answerSupportLedger;
1176
2036
  const violations = [];
2037
+ // G(a) slice 21 — record the obligations this validator genuinely enforces (RECORD 4/9). The
2038
+ // other 5 stay parked (see obligation-coverage-ledger.yaml notes): contradictions-bounded is
2039
+ // enforced by validateMaturationAnswerClaims (DELEGATED); the frontier→valid-lineage-index-
2040
+ // validation check is an unscoped global precondition (PARTIAL); the external/runtime + generic
2041
+ // support-mode-required-refs obligations have no distinct, non-overlapping enforcement here; and
2042
+ // user_confirmation is presence-only (it never resolves user_confirmation_refs to the confirmation
2043
+ // authority — an arbitrary ref passes whenever a valid purpose-confirmation exists; PARTIAL, codex
2044
+ // R1). Stamped before any per-cluster guard so the recorder fires on zero-row input.
2045
+ const assertedObligationIds = [];
2046
+ assertObligation(assertedObligationIds, "validate_evidence_cluster_question_refs");
2047
+ assertObligation(assertedObligationIds, "require_two_independent_evidence_refs_for_convergent_source_evidence_unless_direct_authority");
2048
+ assertObligation(assertedObligationIds, "require_frontier_triggered_evidence_to_resolve_to_valid_reentry_validation");
2049
+ assertObligation(assertedObligationIds, "require_observation_specific_evidence_support_source_safety_row_with_claim_sufficiency_and_replay");
1177
2050
  const questions = questionMap(args.maturationQuestionFrontier);
1178
2051
  const evidenceIndex = evidenceRefIndex(args.sourceObservations);
1179
2052
  const lineageObservationIds = new Set(args.sourceObservationLineageIndex?.lineage_rows.flatMap((row) => row.added_observation_ids) ?? []);
2053
+ // Index added_observation_id -> first lineage row, mirroring the `.find`
2054
+ // (first-match) semantics so per-ref lookups are O(1) instead of a nested
2055
+ // linear scan over every lineage row.
2056
+ const lineageRowByObservationId = new Map();
2057
+ for (const row of args.sourceObservationLineageIndex?.lineage_rows ?? []) {
2058
+ for (const observationId of row.added_observation_ids) {
2059
+ if (!lineageRowByObservationId.has(observationId)) {
2060
+ lineageRowByObservationId.set(observationId, row);
2061
+ }
2062
+ }
2063
+ }
1180
2064
  const reentryValidationsByRef = new Map((args.sourceObservationReentryValidations ?? []).map((item) => [
1181
2065
  item.ref,
1182
2066
  item.validation,
@@ -1414,10 +2298,19 @@ export function validateAnswerSupportLedger(args) {
1414
2298
  }));
1415
2299
  }
1416
2300
  const observation = sourceObservationsById.get(ref.observation_id);
1417
- const lineageRow = args.sourceObservationLineageIndex?.lineage_rows.find((row) => row.added_observation_ids.includes(ref.observation_id)) ?? null;
1418
- const refCarriesLineage = Boolean(observation?.round_id ||
1419
- observation?.observation_batch_id ||
1420
- observation?.triggering_frontier_validation_ref);
2301
+ const lineageRow = lineageRowByObservationId.get(ref.observation_id) ??
2302
+ null;
2303
+ // Defect-3 G1: a per-ref lineage-index row only exists for genuine frontier
2304
+ // RE-ENTRY observations, which the producer stamps with a
2305
+ // triggering_frontier_validation_ref. The INITIAL observation carries the
2306
+ // sentinel round_id "initial_source_frontier" and an "...:initial" batch id
2307
+ // but is NOT a re-entry and never appears in the lineage index, so keying
2308
+ // this per-ref resolution on round_id/observation_batch_id over-fired on
2309
+ // every single-source run. triggering_frontier_validation_ref is the
2310
+ // canonical lineage-bearing marker (set at run.ts re-observation callers).
2311
+ // The broader index-PRESENCE predicate sourceBackedEvidenceCarriesLineage
2312
+ // (above) is intentionally LEFT BROAD — do not narrow it here.
2313
+ const refCarriesLineage = Boolean(observation?.triggering_frontier_validation_ref);
1421
2314
  if (refCarriesLineage &&
1422
2315
  args.sourceObservationLineageIndex &&
1423
2316
  !lineageRow) {
@@ -1484,8 +2377,7 @@ export function validateAnswerSupportLedger(args) {
1484
2377
  }
1485
2378
  if (!observation ||
1486
2379
  !publicOutputRow ||
1487
- (publicOutputRow.visibility_tier !== "consumption_allowed" &&
1488
- publicOutputRow.visibility_tier !== "redacted_output_only")) {
2380
+ publicOutputRow.visibility_tier !== "consumption_allowed") {
1489
2381
  violations.push(violation({
1490
2382
  code: "missing_required_ref",
1491
2383
  message: "answer support evidence used in public output must have an observation-specific public_output source-safety row",
@@ -1572,17 +2464,37 @@ export function validateAnswerSupportLedger(args) {
1572
2464
  validation_results: violations.length === 0
1573
2465
  ? ["answer_support_ledger_valid"]
1574
2466
  : ["answer_support_ledger_invalid"],
2467
+ asserted_obligation_ids: assertedObligationIds,
1575
2468
  violations,
1576
2469
  };
1577
2470
  }
1578
2471
  export function validateMaturationAnswerClaims(args) {
1579
2472
  const artifact = args.maturationAnswerClaims;
1580
2473
  const violations = [];
2474
+ const assertedObligationIds = [];
2475
+ const judgeSupportShortfallClaimIds = new Set();
1581
2476
  const questions = questionMap(args.maturationQuestionFrontier);
1582
2477
  const clusters = new Map(args.answerSupportLedger.evidence_clusters.map((cluster) => [
1583
2478
  cluster.evidence_cluster_id,
1584
2479
  cluster,
1585
2480
  ]));
2481
+ // B-6 judge gate. judgeActive = orchestrator supplied a non-null judgment whose
2482
+ // validation is valid. judgeSupported keys a confirmed support by IDENTITY
2483
+ // (`${evidence_cluster_ref}#${evidenceRefKey}`) so the per-claim sufficiency
2484
+ // count below can re-key it by INDEPENDENCE. With the judge gate active (R4) a
2485
+ // convergent_source_evidence claim is FAIL-CLOSED on judgeActive: an
2486
+ // absent/invalid judgment makes such a claim invalid (see the per-claim branch).
2487
+ // Non-convergent claims are unaffected.
2488
+ const judgeActive = Boolean(args.answerSupportJudgment) &&
2489
+ args.answerSupportJudgmentValidation?.validation_status === "valid";
2490
+ const judgeSupported = new Set();
2491
+ if (judgeActive && args.answerSupportJudgment) {
2492
+ for (const judgment of args.answerSupportJudgment.judgments) {
2493
+ if (judgment.supports === "supported") {
2494
+ judgeSupported.add(`${judgment.evidence_cluster_ref}#${evidenceRefKey(judgment.evidence_ref)}`);
2495
+ }
2496
+ }
2497
+ }
1586
2498
  const seen = new Set();
1587
2499
  const answeredQuestions = new Set();
1588
2500
  if (artifact.session_id !== args.maturationQuestionFrontier.session_id) {
@@ -1606,6 +2518,15 @@ export function validateMaturationAnswerClaims(args) {
1606
2518
  subjectId: args.maturationQuestionFrontierValidationRef ?? null,
1607
2519
  }));
1608
2520
  }
2521
+ // G(a) slice 4: record the two answer-claims obligations with a distinct, audited enforcement region,
2522
+ // UNCONDITIONALLY before the per-claim loop so they are proven wired even on a zero-claim input. The
2523
+ // judge pair is the live-enforced #57/#58 gate (insufficient_independent_evidence / fail-closed
2524
+ // prior_validation_invalid in the convergent-source block below) — recording it here closes its
2525
+ // enforced_pending_instrumentation tier. The other three obligations stay parked with ledger audit
2526
+ // notes (the "OR frontier"/"OR authority" alternatives are unimplemented; dimension/purpose refs are
2527
+ // presence-checked, not resolved against the question), rather than being laundered into recorded.
2528
+ assertObligation(assertedObligationIds, "require_convergent_source_evidence_claims_to_have_two_independent_judge_confirmed_supports");
2529
+ assertObligation(assertedObligationIds, "validate_answer_claim_question_refs");
1609
2530
  for (const claim of artifact.answer_claims) {
1610
2531
  if (seen.has(claim.answer_claim_id)) {
1611
2532
  violations.push(violation({
@@ -1677,6 +2598,73 @@ export function validateMaturationAnswerClaims(args) {
1677
2598
  }));
1678
2599
  }
1679
2600
  }
2601
+ // B-6 sufficiency (composes with the contradiction-bounded check above; a
2602
+ // convergent claim must pass BOTH). Count INDEPENDENT judge-confirmed
2603
+ // supports across all cited clusters: IDENTITY key joins to the judge
2604
+ // verdict, INDEPENDENCE key (source_ref:location, byte-identical to the
2605
+ // ledger envelope) is counted so same-source refs collapse to one.
2606
+ if (claim.support_mode === "convergent_source_evidence") {
2607
+ if (!judgeActive) {
2608
+ // Fail-closed: the answer-support judge gate is active (R4), so a
2609
+ // convergent-source claim REQUIRES a valid judgment. judgeActive is
2610
+ // false exactly when the judgment artifact is absent or its validation
2611
+ // is not valid — either way the convergent claim cannot be trusted.
2612
+ violations.push(violation({
2613
+ code: "prior_validation_invalid",
2614
+ message: "convergent source evidence claim requires a valid answer-support judgment",
2615
+ subjectId: claim.answer_claim_id,
2616
+ }));
2617
+ }
2618
+ else {
2619
+ // Count the claim's OWN supporting_evidence_refs that are judge-confirmed
2620
+ // in one of its cited clusters, not every ref in those clusters. Downstream
2621
+ // (claim projection, ontology expansion) consumes supporting_evidence_refs,
2622
+ // so sufficiency must be carried by the evidence the claim actually cites.
2623
+ const independentConfirmed = new Set();
2624
+ for (const ref of claim.supporting_evidence_refs) {
2625
+ const judgeConfirmed = claim.evidence_cluster_refs.some((clusterRef) => judgeSupported.has(`${clusterRef}#${evidenceRefKey(ref)}`));
2626
+ if (judgeConfirmed) {
2627
+ independentConfirmed.add(`${normalizedPathRef(ref.source_ref)}:${normalizedPathRef(ref.location)}`);
2628
+ }
2629
+ }
2630
+ if (independentConfirmed.size < 2) {
2631
+ // Site-7 proportional terminal (design 20260706 §4.1): split the shortfall
2632
+ // disposition by a QUESTION-scoped pool computed directly from the judgment
2633
+ // artifact's supported verdicts (never joined through the claim's own refs, so
2634
+ // a future ref-serialization divergence cannot mass-degrade):
2635
+ // - pool >= 2 → the question COULD be certified; the author under-cited refs
2636
+ // or clusters → violation stays (bug catcher, crash).
2637
+ // - pool < 2 AND the judge supported >= 1 ref somewhere in the run (functioned
2638
+ // contrast control) → the source cannot certify this question → degrade:
2639
+ // recorded in judge_support_shortfall_claim_ids, the artifact stays valid,
2640
+ // and the actionability matrix blocks certification downstream.
2641
+ // - pool < 2 AND the judge supported NOTHING run-wide → indistinguishable from
2642
+ // judge dysfunction (an all-not_supported judgment is schema-valid), so the
2643
+ // loud violation is kept.
2644
+ const questionClusterIds = new Set(args.answerSupportLedger.evidence_clusters
2645
+ .filter((cluster) => cluster.support_mode === "convergent_source_evidence" &&
2646
+ cluster.question_refs.includes(claim.question_id))
2647
+ .map((cluster) => cluster.evidence_cluster_id));
2648
+ const questionPoolIndependent = new Set();
2649
+ for (const judgment of args.answerSupportJudgment?.judgments ?? []) {
2650
+ if (judgment.supports === "supported" &&
2651
+ questionClusterIds.has(judgment.evidence_cluster_ref)) {
2652
+ questionPoolIndependent.add(`${normalizedPathRef(judgment.evidence_ref.source_ref)}:${normalizedPathRef(judgment.evidence_ref.location)}`);
2653
+ }
2654
+ }
2655
+ if (questionPoolIndependent.size < 2 && judgeSupported.size > 0) {
2656
+ judgeSupportShortfallClaimIds.add(claim.answer_claim_id);
2657
+ }
2658
+ else {
2659
+ violations.push(violation({
2660
+ code: "insufficient_independent_evidence",
2661
+ message: "convergent answer claim requires at least two independent judge-confirmed supports",
2662
+ subjectId: claim.answer_claim_id,
2663
+ }));
2664
+ }
2665
+ }
2666
+ }
2667
+ }
1680
2668
  if (claim.evidence_cluster_refs.length === 0) {
1681
2669
  violations.push(violation({
1682
2670
  code: "support_mode_missing_authority",
@@ -1698,13 +2686,16 @@ export function validateMaturationAnswerClaims(args) {
1698
2686
  created_at: isoNow(),
1699
2687
  maturation_answer_claims_ref: args.maturationAnswerClaimsRef ?? null,
1700
2688
  answer_support_ledger_validation_ref: args.answerSupportLedgerValidationRef ?? null,
2689
+ answer_support_judgment_validation_ref: args.answerSupportJudgmentValidationRef ?? null,
1701
2690
  maturation_question_frontier_validation_ref: args.maturationQuestionFrontierValidationRef ?? null,
1702
2691
  validation_status: violations.length === 0 ? "valid" : "invalid",
1703
2692
  answer_claim_count: artifact.answer_claims.length,
1704
2693
  answered_question_count: answeredQuestions.size,
2694
+ judge_support_shortfall_claim_ids: [...judgeSupportShortfallClaimIds].sort(),
1705
2695
  validation_results: violations.length === 0
1706
2696
  ? ["maturation_answer_claims_valid"]
1707
2697
  : ["maturation_answer_claims_invalid"],
2698
+ asserted_obligation_ids: assertedObligationIds,
1708
2699
  violations,
1709
2700
  };
1710
2701
  }
@@ -1722,6 +2713,25 @@ export function validateOntologyExpansion(args) {
1722
2713
  defer: 0,
1723
2714
  reject: 0,
1724
2715
  };
2716
+ // G(a) slice 9: record the two obligations whose enforcement matches the authoritative contract
2717
+ // (ontology-seeding-and-maturation-design.md §"ontology-expansion-validation.yaml must enforce"),
2718
+ // before the per-expansion loop so they fire on a zero-expansion artifact:
2719
+ // - validate_expansion_answer_claim_refs — every answer_claim_refs[] item resolves to a valid
2720
+ // answer claim (unknown_id) and add/refine cites ≥1 (missing_required_ref).
2721
+ // - require_concept_economy_rationale_when_surface_increases — the contract scopes this to
2722
+ // `operation: add` with `increases_surface`, which the per-expansion check enforces exactly
2723
+ // (missing_required_ref when rationale.trim().length < 24). A refine row that sets
2724
+ // increases_surface is OUT of the contract clause, not an enforcement gap.
2725
+ // PARKED (not recorded): prevent_in_place_seed_authority_rewrite — the contract clause is unscoped
2726
+ // ("no expansion rewrites seed authority in place") but the check only rejects refs whose
2727
+ // path.basename(ref) === "ontology-seed.yaml", so an LLM-authored anchored ref (e.g.
2728
+ // "ontology-seed.yaml#semantic_layer/object-new") bypasses it; narrower than the contract.
2729
+ // Also parked: the evidence-refs obligation — evidence_refs are resolved against the cited answer
2730
+ // claims' carried supporting_evidence_refs (a proxy), not the answer-support-ledger/seed authority
2731
+ // the name names. See obligation-coverage-ledger.yaml notes. No laundering.
2732
+ const assertedObligationIds = [];
2733
+ assertObligation(assertedObligationIds, "validate_expansion_answer_claim_refs");
2734
+ assertObligation(assertedObligationIds, "require_concept_economy_rationale_when_surface_increases");
1725
2735
  if (artifact.session_id !== args.maturationAnswerClaims.session_id) {
1726
2736
  violations.push(violation({
1727
2737
  code: "session_id_mismatch",
@@ -1826,6 +2836,7 @@ export function validateOntologyExpansion(args) {
1826
2836
  validation_results: violations.length === 0
1827
2837
  ? ["ontology_expansion_valid"]
1828
2838
  : ["ontology_expansion_invalid"],
2839
+ asserted_obligation_ids: assertedObligationIds,
1829
2840
  violations,
1830
2841
  };
1831
2842
  }
@@ -2035,6 +3046,14 @@ export function validateMaturationConvergenceLedger(args) {
2035
3046
  const answerClaimIds = new Set(args.maturationAnswerClaims.answer_claims.map((claim) => claim.answer_claim_id));
2036
3047
  const answerSupportIds = new Set(args.answerSupportLedger.evidence_clusters.map((cluster) => cluster.evidence_cluster_id));
2037
3048
  const expansionIds = new Set(args.ontologyExpansion.expansions.map((expansion) => expansion.expansion_id));
3049
+ // G(a) obligation recorder (INV-OBLIGATION-COVERAGE-1). Only the closure-row delta-ref match is a
3050
+ // clean, fully-proving structural enforcer; it is stamped before the round loop so it records on
3051
+ // zero-round input too (vacuously true when no closure rows exist, like the slice-2/15 row checks).
3052
+ // The other six convergence obligations are parked with audit notes — see obligation-coverage-ledger.yaml
3053
+ // (ready-projection gate deferred per contract h; positive-support exclusion / disposition-value /
3054
+ // carried-forward-or-blocked-with-refs / source-delta validation-status all under-enforced).
3055
+ const assertedObligationIds = [];
3056
+ assertObligation(assertedObligationIds, "validate_closure_source_observation_delta_refs_match_source_observation_delta_validation");
2038
3057
  if (ledger.session_id !== args.maturationQuestionFrontier.session_id) {
2039
3058
  violations.push(violation({
2040
3059
  code: "session_id_mismatch",
@@ -2376,18 +3395,15 @@ export function validateMaturationConvergenceLedger(args) {
2376
3395
  validation_results: violations.length === 0
2377
3396
  ? ["maturation_convergence_ledger_valid"]
2378
3397
  : ["maturation_convergence_ledger_invalid"],
3398
+ asserted_obligation_ids: assertedObligationIds,
2379
3399
  violations,
2380
3400
  };
2381
3401
  }
2382
3402
  export function buildMaturationSourceDeltaArtifact(args) {
2383
3403
  const matrixRows = args.actionabilityMatrix.rows;
3404
+ const actionabilityIndex = indexActionabilityRowsByDelta(matrixRows);
2384
3405
  const impactRows = (args.sourceObservationDelta?.delta_rows ?? []).map((deltaRow) => {
2385
- const affectedMatrixRowRefs = matrixRows
2386
- .filter((row) => row.member_source_refs.some((ref) => sameResolvedRef(ref, deltaRow.source_ref)) ||
2387
- row.cross_material_ref_refs.some((ref) => sameResolvedRef(ref, deltaRow.source_ref)) ||
2388
- row.supporting_refs.includes(deltaRow.observation_id))
2389
- .map((row) => row.matrix_row_id)
2390
- .sort();
3406
+ const affectedMatrixRowRefs = affectedMatrixRowRefsForDelta(matrixRows, actionabilityIndex, deltaRow);
2391
3407
  return {
2392
3408
  impact_row_id: `maturation-source-delta:${slug(deltaRow.delta_row_id)}`,
2393
3409
  delta_row_id: deltaRow.delta_row_id,
@@ -2426,7 +3442,9 @@ export function buildMaturationSourceDeltaArtifact(args) {
2426
3442
  export function validateMaturationSourceDelta(args) {
2427
3443
  const artifact = args.maturationSourceDelta;
2428
3444
  const violations = [];
2429
- const matrixRowIds = new Set(args.actionabilityMatrix.rows.map((row) => row.matrix_row_id));
3445
+ const matrixRows = args.actionabilityMatrix.rows;
3446
+ const matrixRowIds = new Set(matrixRows.map((row) => row.matrix_row_id));
3447
+ const actionabilityIndex = indexActionabilityRowsByDelta(matrixRows);
2430
3448
  const deltaRowsById = new Map((args.sourceObservationDelta?.delta_rows ?? []).map((row) => [
2431
3449
  row.delta_row_id,
2432
3450
  row,
@@ -2504,12 +3522,7 @@ export function validateMaturationSourceDelta(args) {
2504
3522
  }));
2505
3523
  }
2506
3524
  }
2507
- const expectedAffectedMatrixRowRefs = args.actionabilityMatrix.rows
2508
- .filter((matrixRow) => matrixRow.member_source_refs.some((ref) => sameResolvedRef(ref, deltaRow.source_ref)) ||
2509
- matrixRow.cross_material_ref_refs.some((ref) => sameResolvedRef(ref, deltaRow.source_ref)) ||
2510
- matrixRow.supporting_refs.includes(deltaRow.observation_id))
2511
- .map((matrixRow) => matrixRow.matrix_row_id)
2512
- .sort();
3525
+ const expectedAffectedMatrixRowRefs = affectedMatrixRowRefsForDelta(matrixRows, actionabilityIndex, deltaRow);
2513
3526
  if (row.affected_matrix_row_refs.join("\0") !==
2514
3527
  expectedAffectedMatrixRowRefs.join("\0")) {
2515
3528
  violations.push(violation({
@@ -2608,15 +3621,50 @@ export async function writeMaturationSourceDeltaValidationArtifact(args) {
2608
3621
  await writeYamlDocument(args.outputPath, validation);
2609
3622
  return validation;
2610
3623
  }
3624
+ // M4b: revision blockers (reject/defer) carried to the next maturation round, derived from
3625
+ // revision-proposal.yaml and GATED on a valid revision-proposal-validation. Shared by the
3626
+ // continuation builder and validator so the two derive the identical set from the same
3627
+ // validated authority (no asymmetry → no spurious conservation mismatch on the invalid path).
3628
+ function revisionBlockerLimitationRefs(revisionProposal, revisionProposalValidation) {
3629
+ if (revisionProposalValidation.validation_status !== "valid")
3630
+ return [];
3631
+ return revisionProposal.proposals
3632
+ .filter(isRevisionBlocker)
3633
+ .map((proposal) => `revision-blocker:${proposal.proposal_id}`);
3634
+ }
2611
3635
  export function buildMaturationContinuationDecisionArtifact(args) {
2612
3636
  const materialRows = args.actionabilityMatrix.rows.filter((row) => row.materiality === "blocker" || row.materiality === "high");
2613
3637
  const frontierRows = materialRows.filter((row) => row.member_readiness === "frontier_required");
2614
3638
  const limitationRows = materialRows.filter((row) => row.member_readiness === "limitation_backed");
3639
+ // Maturation value-read cut (design §13.2). value_resolved rows had their value-dependent
3640
+ // limitations discharged to zero residual — they are a non-blocking anchor: like a closed
3641
+ // row they can support a bounded actionable claim, so the two `closedRows === 0` blocked
3642
+ // arms below also gate on `valueResolvedRows.length === 0`, and a value_resolved-only run
3643
+ // routes to actionable_limited (not actionable_ready — these rows are value-grounded, not
3644
+ // L4-validated). deriveMemberReadiness gates value_resolved on `material`, so every
3645
+ // value_resolved row is material → this equals the all-rows value_resolved set.
3646
+ const valueResolvedRows = materialRows.filter((row) => row.member_readiness === "value_resolved");
2615
3647
  const blockingRowRefs = frontierRows.map((row) => row.matrix_row_id);
2616
3648
  const authorityRequestRefs = args.maturationClosureFrontier.authority_requests.map((request) => request.authority_request_id);
2617
3649
  const unresolvedAuthorityResponses = args.maturationAuthorityResponse.responses.filter((response) => response.response_status !== "provided");
2618
3650
  const finalRequestionStatus = args.maturationConvergenceLedgerValidation.final_requestion_pass_status;
2619
3651
  const closedRows = args.actionabilityMatrix.rows.filter((row) => row.member_readiness === "closed");
3652
+ // Purpose-candidate-level limitations bound the overall claim but do not gate any
3653
+ // single row's frontier; when present they keep the claim at actionable_limited
3654
+ // even if every row is closed (the source itself is acknowledged as partial).
3655
+ const candidateLimitationRefs = args.actionabilityMatrix.candidate_limitation_refs;
3656
+ const hasCandidateLimitations = candidateLimitationRefs.length > 0;
3657
+ // An unproven final re-question convergence is its own limitation on the claim,
3658
+ // independent of candidate or row limitations — so it must be recorded whenever it
3659
+ // holds, not only when it is the sole reason for actionable_limited. Otherwise the
3660
+ // candidate-limitation branch below would preempt it and the public claim (which
3661
+ // projects only decision.limitation_refs) would silently drop it.
3662
+ const convergenceUnproven = finalRequestionStatus !== "no_new_material_question";
3663
+ // M4b: unresolved reject/defer proposals are carried-forward scope that must keep the
3664
+ // continuation below actionable_ready, independent of row readiness. Computed once here;
3665
+ // the field/fold below is unconditional (a higher-priority branch can win while blockers
3666
+ // exist), only the decision_state downgrade is branch-gated.
3667
+ const revisionBlockerRefs = revisionBlockerLimitationRefs(args.revisionProposal, args.revisionProposalValidation);
2620
3668
  let decisionState;
2621
3669
  let rationale;
2622
3670
  const convergenceLimitationRefs = [];
@@ -2628,23 +3676,52 @@ export function buildMaturationContinuationDecisionArtifact(args) {
2628
3676
  decisionState = "blocked";
2629
3677
  rationale = "Material rows remain frontier-required, but no validated next source or authority response can advance them.";
2630
3678
  }
2631
- else if (limitationRows.length > 0 && closedRows.length === 0) {
3679
+ else if (limitationRows.length > 0 &&
3680
+ closedRows.length === 0 &&
3681
+ valueResolvedRows.length === 0) {
2632
3682
  decisionState = "blocked";
2633
- rationale = "Material rows remain limitation-backed and no closed row can support a bounded actionable claim.";
3683
+ rationale = "Material rows remain limitation-backed and no closed or value-resolved row can support a bounded actionable claim.";
2634
3684
  }
2635
3685
  else if (limitationRows.length > 0) {
2636
3686
  decisionState = "actionable_limited";
2637
3687
  rationale = "No material frontier remains, but named limitations constrain the actionability claim.";
2638
3688
  }
2639
- else if (finalRequestionStatus !== "no_new_material_question") {
3689
+ else if (revisionBlockerRefs.length > 0 &&
3690
+ closedRows.length === 0 &&
3691
+ valueResolvedRows.length === 0) {
3692
+ // Unresolved revision blockers with no closed or value-resolved row cannot support a
3693
+ // bounded actionable claim, and actionable_limited with zero included rows is itself
3694
+ // invalid → blocked.
3695
+ decisionState = "blocked";
3696
+ rationale = "Unresolved reject/defer revision proposals remain and no closed or value-resolved row can support a bounded actionable claim.";
3697
+ }
3698
+ else if (revisionBlockerRefs.length > 0) {
3699
+ decisionState = "actionable_limited";
3700
+ rationale = "All material rows are closed or value-resolved, but unresolved reject/defer revision proposals carry scope to the next maturation round and constrain the actionability claim.";
3701
+ }
3702
+ else if (hasCandidateLimitations) {
3703
+ decisionState = "actionable_limited";
3704
+ rationale = "All material rows are closed or value-resolved, but purpose-candidate-level limitations constrain the actionability claim and signal next-round source frontier.";
3705
+ }
3706
+ else if (convergenceUnproven) {
2640
3707
  decisionState = "actionable_limited";
2641
- convergenceLimitationRefs.push(`maturation-final-requestion:${finalRequestionStatus}`);
2642
3708
  rationale = "No material frontier remains, but final re-question convergence has not proven actionable readiness.";
2643
3709
  }
3710
+ else if (valueResolvedRows.length > 0) {
3711
+ // Value-read discharge cleared the value-dependent limitations on these material rows,
3712
+ // but they are value-grounded (not L4-validated) — a bounded actionable claim, not full
3713
+ // readiness. Reached only when no blocker/limitation/candidate/convergence constraint
3714
+ // already routed to actionable_limited above (design §13.2 branch 8.5).
3715
+ decisionState = "actionable_limited";
3716
+ rationale = "No material frontier remains; value-read discharge resolved the value-dependent limitations, supporting a bounded actionable claim on a value-grounded (not L4-validated) basis.";
3717
+ }
2644
3718
  else {
2645
3719
  decisionState = "actionable_ready";
2646
3720
  rationale = "All material rows are closed for the declared purpose.";
2647
3721
  }
3722
+ if (convergenceUnproven) {
3723
+ convergenceLimitationRefs.push(`maturation-final-requestion:${finalRequestionStatus}`);
3724
+ }
2648
3725
  const nextFrontierRefs = [
2649
3726
  ...args.maturationQuestionFrontier.questions
2650
3727
  .filter((question) => question.current_answer_status !== "answerable" &&
@@ -2666,19 +3743,38 @@ export function buildMaturationContinuationDecisionArtifact(args) {
2666
3743
  authority_request_refs: authorityRequestRefs,
2667
3744
  authority_response_refs: args.maturationAuthorityResponse.responses.map((response) => response.authority_response_id),
2668
3745
  claim_scope: {
2669
- included_row_refs: closedRows.map((row) => row.matrix_row_id),
3746
+ // Value-read cut (design §13.4 F6③): the claimable set is closed ∪ value_resolved —
3747
+ // a value_resolved row is value-grounded but claimable. The continuation validator and
3748
+ // the actionable-ontology validator mirror this exact partition (disjoint by
3749
+ // construction: included ∩ excluded = ∅).
3750
+ included_row_refs: args.actionabilityMatrix.rows
3751
+ .filter((row) => row.member_readiness === "closed" ||
3752
+ row.member_readiness === "value_resolved")
3753
+ .map((row) => row.matrix_row_id),
2670
3754
  excluded_row_refs: args.actionabilityMatrix.rows
2671
- .filter((row) => row.member_readiness !== "closed")
3755
+ .filter((row) => row.member_readiness !== "closed" &&
3756
+ row.member_readiness !== "value_resolved")
2672
3757
  .map((row) => row.matrix_row_id),
2673
3758
  exclusion_rationale: limitationRows.length > 0 || frontierRows.length > 0
2674
3759
  ? "Rows outside the trusted claim remain limitation-backed or frontier-required."
2675
3760
  : null,
2676
3761
  },
3762
+ // M4b: unconditional — recorded regardless of which decision_state branch won, so the
3763
+ // validator's superset+conservation hold even when an earlier branch (ask_user/blocked)
3764
+ // is chosen while blockers exist (mirrors convergenceLimitationRefs).
3765
+ revision_blocker_limitation_refs: revisionBlockerRefs,
2677
3766
  limitation_refs: [
2678
3767
  ...new Set([
2679
3768
  ...args.actionabilityMatrix.rows.flatMap((row) => row.limitation_refs),
3769
+ ...candidateLimitationRefs,
2680
3770
  ...args.ontologyExpansionValidation.violations.map((item) => item.subject_id ?? "ontology_expansion_validation"),
2681
3771
  ...convergenceLimitationRefs,
3772
+ ...revisionBlockerRefs,
3773
+ // Value-read cut (design §13.4 F6④): each value_resolved row contributes an explicit
3774
+ // value-read basis ref so the public claim honestly records that its actionability
3775
+ // rests on value-read discharge (not L4 validation), and a pure value_resolved-only
3776
+ // run satisfies the "actionable_limited needs excluded refs or limitation refs" gate.
3777
+ ...valueResolvedRows.map((row) => `maturation-value-read-basis:${row.matrix_row_id}`),
2682
3778
  ]),
2683
3779
  ],
2684
3780
  };
@@ -2686,6 +3782,24 @@ export function buildMaturationContinuationDecisionArtifact(args) {
2686
3782
  export function validateMaturationContinuationDecision(args) {
2687
3783
  const decision = args.maturationContinuationDecision;
2688
3784
  const violations = [];
3785
+ // G(a) slice 22 — record the obligations this validator genuinely enforces (RECORD 4/9, all clean
3786
+ // derive-and-assert / single-facet gates from the M1/M4b conservation work). The other 5 stay parked
3787
+ // (see obligation-coverage-ledger.yaml notes): "continuation_state against validated matrix and
3788
+ // frontier_state" is AMBIGUOUS (intent split across the blanket prior-validation loop / claim_scope
3789
+ // partition / material-rows gate); "continue/ask_user/blocked against available next authority" is
3790
+ // PARTIAL (the named `blocked` state has no enforcement); the revision-proposal binding is gated on the
3791
+ // caller-supplied optional `revisionProposalRef` (no internal guarantee — codex R1, slice-18); the
3792
+ // "material blocker/high row remains unclosed" gate only catches `frontier_required`, not the
3793
+ // `limitation_backed` half of "unclosed" (codex R1, subset-of-scope); and "limitation_refs and
3794
+ // row_scope for actionable_limited" only firmly enforces row_scope — the limitation-ref facet is a weak
3795
+ // excluded-OR-limitation presence check that a limitation_backed excluded row with dropped
3796
+ // limitation_refs satisfies (codex R2, name-broader-than-code). Stamped before any per-row/per-state
3797
+ // guard so the recorder fires on zero-row input.
3798
+ const assertedObligationIds = [];
3799
+ assertObligation(assertedObligationIds, "reject_actionable_ready_until_final_requestion_convergence_is_proven");
3800
+ assertObligation(assertedObligationIds, "reject_actionable_ready_when_unresolved_revision_blockers_remain");
3801
+ assertObligation(assertedObligationIds, "require_revision_blocker_refs_in_continuation_limitation_refs");
3802
+ assertObligation(assertedObligationIds, "validate_revision_blocker_limitation_refs_against_validated_revision_proposal");
2689
3803
  const matrixRows = new Map(args.actionabilityMatrix.rows.map((row) => [
2690
3804
  row.matrix_row_id,
2691
3805
  row,
@@ -2716,6 +3830,7 @@ export function validateMaturationContinuationDecision(args) {
2716
3830
  maturation_authority_response: args.maturationAuthorityResponseValidation.validation_status,
2717
3831
  ontology_expansion: args.ontologyExpansionValidation.validation_status,
2718
3832
  maturation_convergence_ledger: args.maturationConvergenceLedgerValidation.validation_status,
3833
+ revision_proposal: args.revisionProposalValidation.validation_status,
2719
3834
  })) {
2720
3835
  if (status !== "valid") {
2721
3836
  violations.push(violation({
@@ -2745,6 +3860,33 @@ export function validateMaturationContinuationDecision(args) {
2745
3860
  }));
2746
3861
  }
2747
3862
  }
3863
+ // M1 partition conservation: recompute the claim_scope partition from the matrix and
3864
+ // require the decision to match it. The resolve check above only verifies the listed refs
3865
+ // exist, so a claim_scope that OMITS rows would pass and the public claim would preserve
3866
+ // the omission. Value-read cut (design §13.4 F6③): the claimable (included) set is
3867
+ // closed ∪ value_resolved; everything else is excluded (mirrors the builder exactly).
3868
+ const expectedIncluded = args.actionabilityMatrix.rows
3869
+ .filter((row) => row.member_readiness === "closed" ||
3870
+ row.member_readiness === "value_resolved")
3871
+ .map((row) => row.matrix_row_id);
3872
+ const expectedExcluded = args.actionabilityMatrix.rows
3873
+ .filter((row) => row.member_readiness !== "closed" &&
3874
+ row.member_readiness !== "value_resolved")
3875
+ .map((row) => row.matrix_row_id);
3876
+ if (!sameRefSet(decision.claim_scope.included_row_refs, expectedIncluded)) {
3877
+ violations.push(violation({
3878
+ code: "conflicting_state",
3879
+ message: "continuation claim_scope included_row_refs must equal the matrix's closed and value-resolved rows",
3880
+ subjectId: "claim_scope.included_row_refs",
3881
+ }));
3882
+ }
3883
+ if (!sameRefSet(decision.claim_scope.excluded_row_refs, expectedExcluded)) {
3884
+ violations.push(violation({
3885
+ code: "conflicting_state",
3886
+ message: "continuation claim_scope excluded_row_refs must equal the matrix's non-closed, non-value-resolved rows",
3887
+ subjectId: "claim_scope.excluded_row_refs",
3888
+ }));
3889
+ }
2748
3890
  const hasAuthorityNeed = decision.authority_request_refs.length > 0;
2749
3891
  if (decision.decision_state === "actionable_ready" && materialOpenRows.length > 0) {
2750
3892
  violations.push(violation({
@@ -2753,6 +3895,85 @@ export function validateMaturationContinuationDecision(args) {
2753
3895
  subjectId: "actionable_ready",
2754
3896
  }));
2755
3897
  }
3898
+ // Value-read cut (design §13.4): value_resolved rows are value-grounded, not L4-validated,
3899
+ // so a saved/edited decision cannot project actionable_ready while any remain — the bounded
3900
+ // claim is at most actionable_limited (mirrors the builder's branch ordering).
3901
+ if (decision.decision_state === "actionable_ready" &&
3902
+ args.actionabilityMatrix.rows.some((row) => row.member_readiness === "value_resolved")) {
3903
+ violations.push(violation({
3904
+ code: "conflicting_state",
3905
+ message: "actionable_ready cannot be projected while value-resolved (value-grounded, not L4) rows remain",
3906
+ subjectId: "actionable_ready",
3907
+ }));
3908
+ }
3909
+ // Mirror the builder: purpose-candidate-level limitations constrain the overall
3910
+ // claim, so a saved/edited decision cannot project actionable_ready while the
3911
+ // matrix carries them (the bounded claim is at most actionable_limited).
3912
+ if (decision.decision_state === "actionable_ready" &&
3913
+ args.actionabilityMatrix.candidate_limitation_refs.length > 0) {
3914
+ violations.push(violation({
3915
+ code: "conflicting_state",
3916
+ message: "actionable_ready cannot be projected while purpose-candidate-level limitations remain",
3917
+ subjectId: "actionable_ready",
3918
+ }));
3919
+ }
3920
+ // The public claim projects decision.limitation_refs, so every matrix candidate
3921
+ // limitation must survive into it — a saved/edited limited decision that drops them
3922
+ // (keeping only excluded rows or other limitation refs) would erase the source-level
3923
+ // limitation from the downstream claim even though the validated matrix still carries it.
3924
+ const decisionLimitationRefs = new Set(decision.limitation_refs);
3925
+ if (args.actionabilityMatrix.candidate_limitation_refs.some((ref) => !decisionLimitationRefs.has(ref))) {
3926
+ violations.push(violation({
3927
+ code: "missing_required_ref",
3928
+ message: "continuation decision limitation_refs must include every matrix candidate limitation",
3929
+ subjectId: "candidate_limitation_refs",
3930
+ }));
3931
+ }
3932
+ // M4b — revision-blocker conservation gate (derive-and-assert, not trust the field).
3933
+ // onto finding-002 + codex: bind the consumed revision-proposal-validation to the consumed
3934
+ // revision-proposal so a stale/mismatched/unbound valid validation cannot certify a
3935
+ // different proposal set (resume/manual substitution). When the consumed proposal ref is
3936
+ // known and the validation is being trusted (valid), require a matching NON-NULL ref —
3937
+ // a null revision_proposal_ref (pure/manual validations) is not acceptable certification.
3938
+ if (args.revisionProposalRef &&
3939
+ args.revisionProposalValidation.validation_status === "valid" &&
3940
+ (!args.revisionProposalValidation.revision_proposal_ref ||
3941
+ path.resolve(args.revisionProposalValidation.revision_proposal_ref) !==
3942
+ path.resolve(args.revisionProposalRef))) {
3943
+ violations.push(violation({
3944
+ code: "conflicting_state",
3945
+ message: "continuation decision must consume the revision-proposal validation that certifies the consumed revision-proposal",
3946
+ subjectId: "revision_proposal_ref",
3947
+ }));
3948
+ }
3949
+ // Recompute the blocker set from the validated authority (gated on valid, symmetric with
3950
+ // the builder) and assert the decision field equals it — the field is not trusted.
3951
+ // Normalize a missing field ([] for pre-M4b persisted decisions) so revalidation produces
3952
+ // an invalid result rather than throwing.
3953
+ const decisionRevisionBlockerRefs = decision.revision_blocker_limitation_refs ?? [];
3954
+ const expectedRevisionBlockerRefs = revisionBlockerLimitationRefs(args.revisionProposal, args.revisionProposalValidation);
3955
+ if (!sameRefSet(decisionRevisionBlockerRefs, expectedRevisionBlockerRefs)) {
3956
+ violations.push(violation({
3957
+ code: "conflicting_state",
3958
+ message: "continuation decision revision_blocker_limitation_refs must equal the reject/defer proposals derived from the validated revision-proposal",
3959
+ subjectId: "revision_blocker_limitation_refs",
3960
+ }));
3961
+ }
3962
+ if (expectedRevisionBlockerRefs.some((ref) => !decisionLimitationRefs.has(ref))) {
3963
+ violations.push(violation({
3964
+ code: "missing_required_ref",
3965
+ message: "continuation decision limitation_refs must include every revision blocker ref",
3966
+ subjectId: "revision_blocker_limitation_refs",
3967
+ }));
3968
+ }
3969
+ if (decision.decision_state === "actionable_ready" &&
3970
+ expectedRevisionBlockerRefs.length > 0) {
3971
+ violations.push(violation({
3972
+ code: "conflicting_state",
3973
+ message: "actionable_ready cannot be projected while unresolved revision blockers remain",
3974
+ subjectId: "actionable_ready",
3975
+ }));
3976
+ }
2756
3977
  if (decision.decision_state === "actionable_ready" &&
2757
3978
  args.maturationConvergenceLedgerValidation.final_requestion_pass_status !==
2758
3979
  "no_new_material_question") {
@@ -2813,6 +4034,7 @@ export function validateMaturationContinuationDecision(args) {
2813
4034
  validation_results: violations.length === 0
2814
4035
  ? ["maturation_continuation_decision_valid"]
2815
4036
  : ["maturation_continuation_decision_invalid"],
4037
+ asserted_obligation_ids: assertedObligationIds,
2816
4038
  violations,
2817
4039
  };
2818
4040
  }
@@ -2911,6 +4133,17 @@ export function validateActionableOntology(args) {
2911
4133
  const expansionIds = new Set(args.ontologyExpansion.expansions.map((expansion) => expansion.expansion_id));
2912
4134
  const projectedMatrixRefs = new Set();
2913
4135
  const projectionIds = new Set();
4136
+ // G(a) obligation recorder (INV-OBLIGATION-COVERAGE-1). The four obligations below have a distinct,
4137
+ // name-matching enforcer reached unconditionally (the per-row trace check fires per projected row, so
4138
+ // its stamp also records on zero-row input). The other three convergence/projection obligations are
4139
+ // parked with audit notes — see obligation-coverage-ledger.yaml (material blocker/high closure is
4140
+ // delegated to the continuation-decision validator; per-surface static/kinetic/dynamic closure is not
4141
+ // checked here; proof-authority recheck is a blanket downstream-claim reject, not a validation).
4142
+ const assertedObligationIds = [];
4143
+ assertObligation(assertedObligationIds, "reject_actionable_ready_until_final_requestion_convergence_is_proven");
4144
+ assertObligation(assertedObligationIds, "validate_actionability_claim_against_maturation_continuation_decision");
4145
+ assertObligation(assertedObligationIds, "validate_actionable_limited_claim_scope_rows");
4146
+ assertObligation(assertedObligationIds, "require_every_projected_row_to_trace_to_seed_expansion_or_limitation");
2914
4147
  if (artifact.session_id !== args.actionabilityMatrix.session_id) {
2915
4148
  violations.push(violation({
2916
4149
  code: "session_id_mismatch",
@@ -3016,10 +4249,12 @@ export function validateActionableOntology(args) {
3016
4249
  subjectId: row.projection_row_id,
3017
4250
  }));
3018
4251
  }
3019
- if (row.claim_scope === "included" && matrixRow.member_readiness !== "closed") {
4252
+ if (row.claim_scope === "included" &&
4253
+ matrixRow.member_readiness !== "closed" &&
4254
+ matrixRow.member_readiness !== "value_resolved") {
3020
4255
  violations.push(violation({
3021
4256
  code: "conflicting_state",
3022
- message: "included actionable ontology rows must be closed in the actionability matrix",
4257
+ message: "included actionable ontology rows must be closed or value-resolved in the actionability matrix",
3023
4258
  subjectId: row.matrix_row_ref,
3024
4259
  }));
3025
4260
  }
@@ -3069,6 +4304,7 @@ export function validateActionableOntology(args) {
3069
4304
  validation_results: violations.length === 0
3070
4305
  ? ["actionable_ontology_valid"]
3071
4306
  : ["actionable_ontology_invalid"],
4307
+ asserted_obligation_ids: assertedObligationIds,
3072
4308
  violations,
3073
4309
  };
3074
4310
  }
@@ -3196,7 +4432,7 @@ export async function writeMaturationBaselineValidationArtifact(args) {
3196
4432
  }
3197
4433
  export async function writeActionabilityMatrixArtifact(args) {
3198
4434
  const maturationBaseline = await readYamlDocument(args.maturationBaselinePath);
3199
- const [maturationAnswerClaims, maturationAnswerClaimsValidation, ontologyExpansion, ontologyExpansionValidation,] = await Promise.all([
4435
+ const [maturationAnswerClaims, maturationAnswerClaimsValidation, ontologyExpansion, ontologyExpansionValidation, maturationQuestionFrontier, maturationQuestionFrontierValidation, maturationValueDischarge, maturationValueDischargeValidation,] = await Promise.all([
3200
4436
  args.maturationAnswerClaimsPath
3201
4437
  ? readYamlDocument(args.maturationAnswerClaimsPath)
3202
4438
  : Promise.resolve(null),
@@ -3209,6 +4445,18 @@ export async function writeActionabilityMatrixArtifact(args) {
3209
4445
  args.ontologyExpansionValidationPath
3210
4446
  ? readYamlDocument(args.ontologyExpansionValidationPath)
3211
4447
  : Promise.resolve(null),
4448
+ args.maturationQuestionFrontierPath
4449
+ ? readYamlDocument(args.maturationQuestionFrontierPath)
4450
+ : Promise.resolve(null),
4451
+ args.maturationQuestionFrontierValidationPath
4452
+ ? readYamlDocument(args.maturationQuestionFrontierValidationPath)
4453
+ : Promise.resolve(null),
4454
+ args.maturationValueDischargePath
4455
+ ? readYamlDocument(args.maturationValueDischargePath)
4456
+ : Promise.resolve(null),
4457
+ args.maturationValueDischargeValidationPath
4458
+ ? readYamlDocument(args.maturationValueDischargeValidationPath)
4459
+ : Promise.resolve(null),
3212
4460
  ]);
3213
4461
  const artifact = buildActionabilityMatrixArtifact({
3214
4462
  sessionId: args.sessionId,
@@ -3218,15 +4466,19 @@ export async function writeActionabilityMatrixArtifact(args) {
3218
4466
  maturationAnswerClaims,
3219
4467
  maturationAnswerClaimsValidation,
3220
4468
  maturationAnswerClaimsValidationRef: args.maturationAnswerClaimsValidationPath ?? null,
4469
+ maturationValueDischarge,
4470
+ maturationValueDischargeValidation,
3221
4471
  ontologyExpansion,
3222
4472
  ontologyExpansionValidation,
3223
4473
  ontologyExpansionValidationRef: args.ontologyExpansionValidationPath ?? null,
4474
+ maturationQuestionFrontier,
4475
+ maturationQuestionFrontierValidation,
3224
4476
  });
3225
4477
  await writeYamlDocument(args.outputPath, artifact);
3226
4478
  return artifact;
3227
4479
  }
3228
4480
  export async function writeActionabilityMatrixValidationArtifact(args) {
3229
- const [actionabilityMatrix, maturationBaseline, maturationBaselineValidation, maturationAnswerClaims, maturationAnswerClaimsValidation, ontologyExpansion, ontologyExpansionValidation,] = await Promise.all([
4481
+ const [actionabilityMatrix, maturationBaseline, maturationBaselineValidation, maturationAnswerClaims, maturationAnswerClaimsValidation, ontologyExpansion, ontologyExpansionValidation, maturationQuestionFrontier, maturationQuestionFrontierValidation, maturationValueDischarge, maturationValueDischargeValidation,] = await Promise.all([
3230
4482
  readYamlDocument(args.actionabilityMatrixPath),
3231
4483
  readYamlDocument(args.maturationBaselinePath),
3232
4484
  readYamlDocument(args.maturationBaselineValidationPath),
@@ -3242,6 +4494,18 @@ export async function writeActionabilityMatrixValidationArtifact(args) {
3242
4494
  args.ontologyExpansionValidationPath
3243
4495
  ? readYamlDocument(args.ontologyExpansionValidationPath)
3244
4496
  : Promise.resolve(null),
4497
+ args.maturationQuestionFrontierPath
4498
+ ? readYamlDocument(args.maturationQuestionFrontierPath)
4499
+ : Promise.resolve(null),
4500
+ args.maturationQuestionFrontierValidationPath
4501
+ ? readYamlDocument(args.maturationQuestionFrontierValidationPath)
4502
+ : Promise.resolve(null),
4503
+ args.maturationValueDischargePath
4504
+ ? readYamlDocument(args.maturationValueDischargePath)
4505
+ : Promise.resolve(null),
4506
+ args.maturationValueDischargeValidationPath
4507
+ ? readYamlDocument(args.maturationValueDischargeValidationPath)
4508
+ : Promise.resolve(null),
3245
4509
  ]);
3246
4510
  const validation = validateActionabilityMatrix({
3247
4511
  actionabilityMatrix,
@@ -3252,9 +4516,15 @@ export async function writeActionabilityMatrixValidationArtifact(args) {
3252
4516
  maturationAnswerClaims,
3253
4517
  maturationAnswerClaimsValidation,
3254
4518
  maturationAnswerClaimsValidationRef: args.maturationAnswerClaimsValidationPath ?? null,
4519
+ maturationValueDischarge,
4520
+ maturationValueDischargeValidation,
3255
4521
  ontologyExpansion,
3256
4522
  ontologyExpansionValidation,
3257
4523
  ontologyExpansionValidationRef: args.ontologyExpansionValidationPath ?? null,
4524
+ maturationQuestionFrontier,
4525
+ maturationQuestionFrontierValidation,
4526
+ maturationQuestionFrontierValidationRef: args.maturationQuestionFrontierValidationPath ?? null,
4527
+ maturationQuestionFrontierRef: args.maturationQuestionFrontierPath ?? null,
3258
4528
  });
3259
4529
  await writeYamlDocument(args.outputPath, validation);
3260
4530
  return validation;
@@ -3404,6 +4674,170 @@ export async function writeAnswerSupportLedgerValidationArtifact(args) {
3404
4674
  await writeYamlDocument(args.outputPath, validation);
3405
4675
  return validation;
3406
4676
  }
4677
+ // B-5 answer-support judge validation. Structural author!=judge attribution is
4678
+ // enforced upstream (separate authored artifact + 1:1 telemetry mapping); this
4679
+ // validator therefore NEVER compares directive_author.author_id (spoofable).
4680
+ // Obligations: refs resolve (unknown_id) / supports enum (invalid_enum) /
4681
+ // rationale present (missing_required_ref) / one verdict per (cluster, evidence)
4682
+ // pair (duplicate_id) / convergent coverage — every cited evidence_ref of a
4683
+ // convergent_source_evidence cluster must be judged (reuses missing_required_coverage).
4684
+ export function validateAnswerSupportJudgment(args) {
4685
+ const artifact = args.answerSupportJudgment;
4686
+ const ledger = args.answerSupportLedger;
4687
+ const violations = [];
4688
+ const SUPPORTS_VALUES = ["supported", "not_supported"];
4689
+ if (artifact.session_id !== ledger.session_id) {
4690
+ violations.push(violation({
4691
+ code: "session_id_mismatch",
4692
+ message: "answer support judgment session_id must match the support ledger",
4693
+ subjectId: artifact.session_id,
4694
+ }));
4695
+ }
4696
+ if (args.answerSupportLedgerValidation.validation_status !== "valid") {
4697
+ violations.push(violation({
4698
+ code: "prior_validation_invalid",
4699
+ message: "answer support judgment requires a valid support-ledger validation",
4700
+ subjectId: args.answerSupportLedgerValidationRef ?? "answer-support-ledger-validation",
4701
+ }));
4702
+ }
4703
+ const clusters = new Map(ledger.evidence_clusters.map((cluster) => [cluster.evidence_cluster_id, cluster]));
4704
+ const evidenceKeysByCluster = new Map(ledger.evidence_clusters.map((cluster) => [
4705
+ cluster.evidence_cluster_id,
4706
+ new Set(cluster.evidence_refs.map(evidenceRefKey)),
4707
+ ]));
4708
+ // IDENTITY keys a judgment actually covered, per cluster (drives coverage D).
4709
+ const judgedKeysByCluster = new Map();
4710
+ const seen = new Set();
4711
+ let supportedJudgmentCount = 0;
4712
+ // G(a) slice 8: record the four judgment obligations before the per-judgment loop (and the D
4713
+ // convergent-coverage loop) so they fire on a zero-judgment artifact. Each maps to a distinct
4714
+ // audited block — A ref resolution (unknown_id) / B supports enum (invalid_enum) / C rationale_ref
4715
+ // (missing_required_ref) / D convergent coverage (missing_required_coverage). No laundering.
4716
+ const assertedObligationIds = [];
4717
+ assertObligation(assertedObligationIds, "validate_judgment_refs_resolve_to_answer_support_ledger_clusters_and_evidence");
4718
+ assertObligation(assertedObligationIds, "require_supports_enum_for_each_judgment");
4719
+ assertObligation(assertedObligationIds, "require_rationale_ref_for_each_judgment");
4720
+ assertObligation(assertedObligationIds, "require_convergent_clusters_to_judge_every_cited_evidence_ref");
4721
+ for (const judgment of artifact.judgments) {
4722
+ if (seen.has(judgment.judgment_id)) {
4723
+ violations.push(violation({
4724
+ code: "duplicate_id",
4725
+ message: "answer support judgment ids must be unique",
4726
+ subjectId: judgment.judgment_id,
4727
+ }));
4728
+ }
4729
+ else {
4730
+ seen.add(judgment.judgment_id);
4731
+ }
4732
+ // A: cluster ref + evidence ref resolve to a cited evidence in the ledger.
4733
+ const evidenceKey = evidenceRefKey(judgment.evidence_ref);
4734
+ const clusterKeys = evidenceKeysByCluster.get(judgment.evidence_cluster_ref);
4735
+ if (!clusters.has(judgment.evidence_cluster_ref) || !clusterKeys) {
4736
+ violations.push(violation({
4737
+ code: "unknown_id",
4738
+ message: "judgment evidence_cluster_ref must resolve to a support-ledger cluster",
4739
+ subjectId: judgment.evidence_cluster_ref,
4740
+ }));
4741
+ }
4742
+ else if (!clusterKeys.has(evidenceKey)) {
4743
+ violations.push(violation({
4744
+ code: "unknown_id",
4745
+ message: "judgment evidence_ref must resolve to a cited evidence ref in its cluster",
4746
+ subjectId: judgment.judgment_id,
4747
+ }));
4748
+ }
4749
+ else {
4750
+ const covered = judgedKeysByCluster.get(judgment.evidence_cluster_ref) ??
4751
+ new Set();
4752
+ // One verdict per (cluster, evidence) IDENTITY pair: a second judgment for
4753
+ // the same pair (e.g. a contradictory supported + not_supported) must be
4754
+ // invalid. Otherwise B-6 would launder it — judgeSupported only ADDS
4755
+ // 'supported' keys, so a conflict silently resolves to "supported wins" and
4756
+ // an ambiguous ref counts toward convergent sufficiency. Reuses duplicate_id.
4757
+ if (covered.has(evidenceKey)) {
4758
+ violations.push(violation({
4759
+ code: "duplicate_id",
4760
+ message: "answer support judgment must record at most one verdict per (cluster, evidence) pair",
4761
+ subjectId: judgment.judgment_id,
4762
+ }));
4763
+ }
4764
+ else {
4765
+ covered.add(evidenceKey);
4766
+ judgedKeysByCluster.set(judgment.evidence_cluster_ref, covered);
4767
+ }
4768
+ }
4769
+ // B: supports enum (raw projection of supported count, NOT sufficiency).
4770
+ if (!SUPPORTS_VALUES.includes(judgment.supports)) {
4771
+ violations.push(violation({
4772
+ code: "invalid_enum",
4773
+ message: `invalid supports verdict ${judgment.supports}`,
4774
+ subjectId: judgment.judgment_id,
4775
+ }));
4776
+ }
4777
+ else if (judgment.supports === "supported") {
4778
+ supportedJudgmentCount += 1;
4779
+ }
4780
+ // C: rationale ref present (existence only, content not read).
4781
+ if (!judgment.rationale_ref || judgment.rationale_ref.trim().length === 0) {
4782
+ violations.push(violation({
4783
+ code: "missing_required_ref",
4784
+ message: "answer support judgment must cite a rationale_ref",
4785
+ subjectId: judgment.judgment_id,
4786
+ }));
4787
+ }
4788
+ }
4789
+ // D: convergent coverage (Codex #3) — every cited evidence_ref of a
4790
+ // convergent_source_evidence cluster must have a judgment row, so an
4791
+ // unfavorable/ambiguous ref cannot be silently omitted. Reuses the existing
4792
+ // missing_required_coverage failure kind (same "required coverage missing"
4793
+ // family). Non-convergent clusters do not trigger coverage.
4794
+ for (const cluster of ledger.evidence_clusters) {
4795
+ if (cluster.support_mode !== "convergent_source_evidence")
4796
+ continue;
4797
+ const covered = judgedKeysByCluster.get(cluster.evidence_cluster_id) ??
4798
+ new Set();
4799
+ for (const ref of cluster.evidence_refs) {
4800
+ if (!covered.has(evidenceRefKey(ref))) {
4801
+ violations.push(violation({
4802
+ code: "missing_required_coverage",
4803
+ message: "convergent cluster requires a judgment for every cited evidence ref",
4804
+ subjectId: cluster.evidence_cluster_id,
4805
+ }));
4806
+ }
4807
+ }
4808
+ }
4809
+ return {
4810
+ schema_version: "1",
4811
+ session_id: artifact.session_id,
4812
+ created_at: isoNow(),
4813
+ answer_support_judgment_ref: args.answerSupportJudgmentRef ?? null,
4814
+ answer_support_ledger_validation_ref: args.answerSupportLedgerValidationRef ?? null,
4815
+ validation_status: violations.length === 0 ? "valid" : "invalid",
4816
+ judgment_count: artifact.judgments.length,
4817
+ supported_judgment_count: supportedJudgmentCount,
4818
+ validation_results: violations.length === 0
4819
+ ? ["answer_support_judgment_valid"]
4820
+ : ["answer_support_judgment_invalid"],
4821
+ asserted_obligation_ids: assertedObligationIds,
4822
+ violations,
4823
+ };
4824
+ }
4825
+ export async function writeAnswerSupportJudgmentValidationArtifact(args) {
4826
+ const [answerSupportJudgment, answerSupportLedger, answerSupportLedgerValidation,] = await Promise.all([
4827
+ readYamlDocument(args.answerSupportJudgmentPath),
4828
+ readYamlDocument(args.answerSupportLedgerPath),
4829
+ readYamlDocument(args.answerSupportLedgerValidationPath),
4830
+ ]);
4831
+ const validation = validateAnswerSupportJudgment({
4832
+ answerSupportJudgment,
4833
+ answerSupportJudgmentRef: args.answerSupportJudgmentPath,
4834
+ answerSupportLedger,
4835
+ answerSupportLedgerValidation,
4836
+ answerSupportLedgerValidationRef: args.answerSupportLedgerValidationPath,
4837
+ });
4838
+ await writeYamlDocument(args.outputPath, validation);
4839
+ return validation;
4840
+ }
3407
4841
  export async function writeMaturationAnswerClaimsValidationArtifact(args) {
3408
4842
  const [maturationAnswerClaims, answerSupportLedger, answerSupportLedgerValidation, maturationQuestionFrontier, maturationQuestionFrontierValidation,] = await Promise.all([
3409
4843
  readYamlDocument(args.maturationAnswerClaimsPath),
@@ -3412,6 +4846,14 @@ export async function writeMaturationAnswerClaimsValidationArtifact(args) {
3412
4846
  readYamlDocument(args.maturationQuestionFrontierPath),
3413
4847
  readYamlDocument(args.maturationQuestionFrontierValidationPath),
3414
4848
  ]);
4849
+ const [answerSupportJudgment, answerSupportJudgmentValidation] = await Promise.all([
4850
+ args.answerSupportJudgmentPath
4851
+ ? readYamlDocument(args.answerSupportJudgmentPath)
4852
+ : Promise.resolve(null),
4853
+ args.answerSupportJudgmentValidationPath
4854
+ ? readYamlDocument(args.answerSupportJudgmentValidationPath)
4855
+ : Promise.resolve(null),
4856
+ ]);
3415
4857
  const validation = validateMaturationAnswerClaims({
3416
4858
  maturationAnswerClaims,
3417
4859
  maturationAnswerClaimsRef: args.maturationAnswerClaimsPath,
@@ -3421,6 +4863,10 @@ export async function writeMaturationAnswerClaimsValidationArtifact(args) {
3421
4863
  maturationQuestionFrontier,
3422
4864
  maturationQuestionFrontierValidation,
3423
4865
  maturationQuestionFrontierValidationRef: args.maturationQuestionFrontierValidationPath,
4866
+ answerSupportJudgment,
4867
+ answerSupportJudgmentRef: args.answerSupportJudgmentPath ?? null,
4868
+ answerSupportJudgmentValidation,
4869
+ answerSupportJudgmentValidationRef: args.answerSupportJudgmentValidationPath ?? null,
3424
4870
  });
3425
4871
  await writeYamlDocument(args.outputPath, validation);
3426
4872
  return validation;
@@ -3515,7 +4961,7 @@ export async function writeMaturationConvergenceLedgerValidationArtifact(args) {
3515
4961
  return validation;
3516
4962
  }
3517
4963
  export async function writeMaturationContinuationDecisionArtifact(args) {
3518
- const [actionabilityMatrix, maturationQuestionFrontier, maturationClosureFrontier, maturationClosureFrontierValidation, maturationAuthorityResponse, ontologyExpansionValidation, maturationConvergenceLedgerValidation,] = await Promise.all([
4964
+ const [actionabilityMatrix, maturationQuestionFrontier, maturationClosureFrontier, maturationClosureFrontierValidation, maturationAuthorityResponse, ontologyExpansionValidation, maturationConvergenceLedgerValidation, revisionProposal, revisionProposalValidation,] = await Promise.all([
3519
4965
  readYamlDocument(args.actionabilityMatrixPath),
3520
4966
  readYamlDocument(args.maturationQuestionFrontierPath),
3521
4967
  readYamlDocument(args.maturationClosureFrontierPath),
@@ -3523,6 +4969,8 @@ export async function writeMaturationContinuationDecisionArtifact(args) {
3523
4969
  readYamlDocument(args.maturationAuthorityResponsePath),
3524
4970
  readYamlDocument(args.ontologyExpansionValidationPath),
3525
4971
  readYamlDocument(args.maturationConvergenceLedgerValidationPath),
4972
+ readYamlDocument(args.revisionProposalPath),
4973
+ readYamlDocument(args.revisionProposalValidationPath),
3526
4974
  ]);
3527
4975
  const artifact = buildMaturationContinuationDecisionArtifact({
3528
4976
  sessionId: args.sessionId,
@@ -3535,12 +4983,14 @@ export async function writeMaturationContinuationDecisionArtifact(args) {
3535
4983
  maturationClosureFrontierValidation,
3536
4984
  maturationAuthorityResponse,
3537
4985
  ontologyExpansionValidation,
4986
+ revisionProposal,
4987
+ revisionProposalValidation,
3538
4988
  });
3539
4989
  await writeYamlDocument(args.outputPath, artifact);
3540
4990
  return artifact;
3541
4991
  }
3542
4992
  export async function writeMaturationContinuationDecisionValidationArtifact(args) {
3543
- const [maturationContinuationDecision, actionabilityMatrix, actionabilityMatrixValidation, maturationQuestionFrontierValidation, maturationClosureFrontierValidation, answerSupportLedgerValidation, maturationAuthorityResponseValidation, ontologyExpansionValidation, maturationConvergenceLedgerValidation,] = await Promise.all([
4993
+ const [maturationContinuationDecision, actionabilityMatrix, actionabilityMatrixValidation, maturationQuestionFrontierValidation, maturationClosureFrontierValidation, answerSupportLedgerValidation, maturationAuthorityResponseValidation, ontologyExpansionValidation, maturationConvergenceLedgerValidation, revisionProposal, revisionProposalValidation,] = await Promise.all([
3544
4994
  readYamlDocument(args.maturationContinuationDecisionPath),
3545
4995
  readYamlDocument(args.actionabilityMatrixPath),
3546
4996
  readYamlDocument(args.actionabilityMatrixValidationPath),
@@ -3550,6 +5000,8 @@ export async function writeMaturationContinuationDecisionValidationArtifact(args
3550
5000
  readYamlDocument(args.maturationAuthorityResponseValidationPath),
3551
5001
  readYamlDocument(args.ontologyExpansionValidationPath),
3552
5002
  readYamlDocument(args.maturationConvergenceLedgerValidationPath),
5003
+ readYamlDocument(args.revisionProposalPath),
5004
+ readYamlDocument(args.revisionProposalValidationPath),
3553
5005
  ]);
3554
5006
  const validation = validateMaturationContinuationDecision({
3555
5007
  maturationContinuationDecision,
@@ -3569,6 +5021,9 @@ export async function writeMaturationContinuationDecisionValidationArtifact(args
3569
5021
  ontologyExpansionValidationRef: args.ontologyExpansionValidationPath,
3570
5022
  maturationConvergenceLedgerValidation,
3571
5023
  maturationConvergenceLedgerValidationRef: args.maturationConvergenceLedgerValidationPath,
5024
+ revisionProposal,
5025
+ revisionProposalValidation,
5026
+ revisionProposalRef: args.revisionProposalPath,
3572
5027
  });
3573
5028
  await writeYamlDocument(args.outputPath, validation);
3574
5029
  return validation;