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,6 +1,8 @@
1
1
  import fs from "node:fs/promises";
2
2
  import path from "node:path";
3
- import { parse as parseYaml, stringify as stringifyYaml } from "yaml";
3
+ import { parse as parseYaml } from "yaml";
4
+ import { atomicWriteYamlDocument as writeYamlDocument } from "../artifact-io.js";
5
+ import { assertObligation } from "./obligation-assertion.js";
4
6
  const ONTOLOGY_DOMAIN_REQUIRED_CATEGORIES = [
5
7
  {
6
8
  category_id: "terminology_glossary",
@@ -70,10 +72,6 @@ async function readYamlDocumentIfPresent(filePath) {
70
72
  throw error;
71
73
  }
72
74
  }
73
- async function writeYamlDocument(filePath, value) {
74
- await fs.mkdir(path.dirname(filePath), { recursive: true });
75
- await fs.writeFile(filePath, stringifyYaml(value), "utf8");
76
- }
77
75
  function selectedPurposeCandidate(args) {
78
76
  const selectedId = args.sourcePurposeCandidatesValidation.selected_purpose_candidate_id;
79
77
  return args.sourcePurposeCandidates.purpose_candidates.find((candidate) => candidate.purpose_candidate_id === selectedId) ?? null;
@@ -139,8 +137,19 @@ function limitationRefsForElement(args) {
139
137
  return [];
140
138
  if (args.row.limitation_refs.length > 0)
141
139
  return args.row.limitation_refs;
142
- if (purposeElementProjectsHandoffLimitation(args.element) &&
143
- elementHasSourceEvidence(args)) {
140
+ if (elementHasSourceEvidence(args) && (purposeElementProjectsHandoffLimitation(args.element) ||
141
+ // Defect-2: a frontier_required element that has source evidence but no
142
+ // available frontier (no accepted frontier refs) is structurally a handoff
143
+ // limitation — frontier deepening cannot happen, so its closure converts to a
144
+ // recorded limitation rather than collapsing to `missing` (which would force
145
+ // frontier_required + no_concrete_frontier = a permanent gate-throw deadlock on
146
+ // single-source input). Routing through the existing handoff-limitation path
147
+ // makes it limitation_backed -> limited_seed_possible, exactly like an element
148
+ // the author explicitly declared as projecting handoff_limitations. Evidence
149
+ // presence is the safety boundary: a frontier_required element WITHOUT evidence
150
+ // stays `missing` (a genuine hole the gate still refuses).
151
+ (args.element.closure_expectation === "frontier_required" &&
152
+ args.frontierRefs.length === 0))) {
144
153
  return [`purpose_handoff_limitation:${slug(args.element.element_id)}`];
145
154
  }
146
155
  return [];
@@ -364,7 +373,7 @@ export function buildSeedAuthoringReadinessFromArtifacts(args) {
364
373
  const frontierRefs = acceptedFrontierRefIds(args.sourceFrontierValidations ?? []);
365
374
  const closureRows = purposeElements.map((element) => {
366
375
  const row = materialRowsByElement.get(element.element_id) ?? null;
367
- const limitationRefs = limitationRefsForElement({ element, row });
376
+ const limitationRefs = limitationRefsForElement({ element, row, frontierRefs });
368
377
  return {
369
378
  closure_row_id: `seed-authoring-closure:${slug(element.element_id)}`,
370
379
  required_element_ref: element.element_id,
@@ -502,6 +511,44 @@ function normalizedRef(ref) {
502
511
  export function validateSeedAuthoringReadiness(args) {
503
512
  const violations = [];
504
513
  const artifact = args.seedAuthoringReadiness;
514
+ // G(a) deferred-7 slice 4: record only the obligation this validator FULLY + UNCONDITIONALLY enforces.
515
+ // Stamped at the top so it fires on any input. This validator receives already-parsed objects and runs a
516
+ // recompute-and-compare against buildSeedAuthoringReadinessFromArtifacts(args), so the slice-3 null-read
517
+ // false-pass does NOT apply here. asserted_obligation_ids is in-memory-only telemetry (Stage 0 #145) on a
518
+ // reuse-hashed artifact (run.ts seed_authoring_readiness_validation_sha256), so stamping does not rotate reuse.
519
+ //
520
+ // RECORD 1:
521
+ // - validate_blocked_validation_gap_is_projection_not_semantic_decision → readiness_classification_mismatch
522
+ // (+ closure_row_invalid_state): blocked_validation_gap is recomputed deterministically from input
523
+ // validation status and compared UNCONDITIONALLY (single scalar), so it cannot be overridden by a
524
+ // hand-authored semantic decision (tests: "rejects tampered readiness classification" + "projects
525
+ // blocked_validation_gap before semantic readiness states"). Immune to both codex #150 edges below.
526
+ //
527
+ // PARKED 7 (ledger notes carry the codex-referenced detail):
528
+ // - validate_readiness_consumes_pre_seed_source_scout_validation_snapshot: codex #150 P2 — the three
529
+ // source_scout_pre_seed_identity_mismatch checks are GATED on sourceScoutPackValidationRef/Validation being
530
+ // present; an absent/null snapshot is handled by the validation-gap mechanism, not the identity check, so an
531
+ // unconditional top stamp would over-claim pre-seed consumption for gap-only executions.
532
+ // - validate_selected_purpose_required_elements_have_closure_rows: codex #150 P2 — expected and actual
533
+ // closure rows are keyed by required_element_ref in a Map, so duplicate element_ids (not rejected upstream)
534
+ // collapse and one closure row can satisfy multiple required elements → a missing duplicate is not caught.
535
+ // - validate_readiness_projection_uses_validated_input_refs_only: the closure-row validated_upstream_refs /
536
+ // llm_authority_refs projection fields are never compared; the only input-validation gate is the gap
537
+ // mechanism, whose binding is readiness_classification_mismatch (recorded above) — no distinct binding.
538
+ // - validate_actor_action_state_scout_rows_do_not_replace_purpose_required_elements: the boundary note is
539
+ // presence-only, and closure_row_dangling_required_element rejects foreign-ref rows but never verifies a
540
+ // row's material is purpose-derived rather than scout-derived (a scout row under a valid ref passes).
541
+ // - validate_frontier_required_preserves_exploration_budget_state_without_declaring_source_insufficiency:
542
+ // compound — source_sufficiency_state is recompute-compared, but exploration_budget_state is COPIED from
543
+ // the artifact (not compared), so the "preserves exploration_budget_state" half is unenforced.
544
+ // - validate_ontology_domain_required_category_rows_only_when_selected_purpose_closure_rows_resolve_to_
545
+ // domain_categories: asymmetric — only expected→actual presence (ontology_domain_category_missing) is
546
+ // checked; spurious/orphan category rows are never rejected, so the "only when" direction is unenforced.
547
+ // - validate_only_seed_ready_or_limited_seed_possible_allows_seed_authoring: DELEGATED to the sibling
548
+ // assertSeedAuthoringReadinessAllowsSeed gate; validateSeedAuthoringReadiness returns valid for
549
+ // frontier_required, so the allow-gate is not enforced by this function.
550
+ const assertedObligationIds = [];
551
+ assertObligation(assertedObligationIds, "validate_blocked_validation_gap_is_projection_not_semantic_decision");
505
552
  if (artifact.session_id !== args.sourcePurposeCandidates.session_id) {
506
553
  violations.push(violation({
507
554
  code: "session_id_mismatch",
@@ -655,6 +702,7 @@ export function validateSeedAuthoringReadiness(args) {
655
702
  validation_results: violations.length === 0
656
703
  ? ["seed_authoring_readiness_valid"]
657
704
  : ["seed_authoring_readiness_invalid"],
705
+ asserted_obligation_ids: assertedObligationIds,
658
706
  violations,
659
707
  };
660
708
  }
@@ -47,8 +47,14 @@ const GOLDEN_FIXTURES = {
47
47
  name_alternates: ["explainfixture", "fixtureexplanation"],
48
48
  },
49
49
  {
50
+ // Q1 presence is credited by a source binding (with a source_ref) whose
51
+ // seed_ref resolves to the fixture-service object — not by binding-id
52
+ // spelling (the old name_alternates were fit to the mock's
53
+ // `binding-fixture-source` id and missed descriptive live ids).
54
+ // name_alternates remain for the Q2 CQ linkage.
50
55
  concept_key: "fixture-source-binding",
51
56
  name_alternates: ["fixturesource", "bindingfixturesource", "sourcebinding"],
57
+ binding_target_alternates: ["fixtureservice"],
52
58
  },
53
59
  ],
54
60
  expected_cq: [
@@ -132,8 +138,14 @@ const GOLDEN_FIXTURES = {
132
138
  name_alternates: ["checkoutbook", "checkout", "borrowbook", "loanbook"],
133
139
  },
134
140
  {
141
+ // Q1 presence is credited by a source binding (with a source_ref) whose
142
+ // seed_ref resolves to the loan-record object. The `loanrecord`
143
+ // name_alternate also matches the LoanRecord object NAME, so using it for
144
+ // Q1 would wrongly credit a seed that has the object but no binding; Q1
145
+ // here is binding-targeted only, and name_alternates remain for Q2.
135
146
  concept_key: "loan-record-binding",
136
147
  name_alternates: ["loanrecord", "loanbinding"],
148
+ binding_target_alternates: ["loanrecord"],
137
149
  },
138
150
  ],
139
151
  expected_cq: [
@@ -219,7 +231,66 @@ function seedNameRows(seed) {
219
231
  }
220
232
  return rows;
221
233
  }
222
- function conceptMatch(expected, rows) {
234
+ /**
235
+ * The object_type ids whose object NAME matches a binding-target alternate. The
236
+ * target object is identified by its NAME (model-agnostic on the id), and the
237
+ * binding is then tied to it by EXACT seed_ref == object_type_id (below), so no
238
+ * substring matching ever runs over model-chosen ids. Only `semantic_layer.
239
+ * object_types` are scanned — a source binding must back an object type, not a
240
+ * concept/action/actor (which would let the fixture pass without the backed
241
+ * object it measures).
242
+ */
243
+ function targetObjectTypeIds(objectTypeRows, targetAlternates) {
244
+ const ids = new Set();
245
+ for (const row of objectTypeRows) {
246
+ const name = normalizeName(stringField(row, "name"));
247
+ if (name.length === 0)
248
+ continue;
249
+ if (targetAlternates.some((alternate) => name.includes(normalizeName(alternate)))) {
250
+ const id = stringField(row, "object_type_id").trim();
251
+ if (id.length > 0)
252
+ ids.add(id);
253
+ }
254
+ }
255
+ return ids;
256
+ }
257
+ /**
258
+ * Whether a source binding satisfies a binding-target concept: it must name a
259
+ * backing source (`source_ref` present) and target the right object — its
260
+ * (trimmed) `seed_ref` is EXACTLY one of the target object type ids. Id equality
261
+ * (not substring) means a descriptive binding id, an opaque object id, incidental
262
+ * whitespace, or a mis-named object cannot leak a false match.
263
+ */
264
+ function bindingTargetsConcept(binding, targetObjectIds) {
265
+ if (stringField(binding, "source_ref").trim().length === 0)
266
+ return false;
267
+ return targetObjectIds.has(stringField(binding, "seed_ref").trim());
268
+ }
269
+ function conceptMatch(expected, rows, sourceBindings, objectTypeRows) {
270
+ // A binding-target concept's PRESENCE (Q1) is credited only by a source
271
+ // binding (with a source_ref) whose seed_ref is exactly the id of an object
272
+ // type the fixture names. `name_alternates` are NOT used for Q1 presence here —
273
+ // they would also match the target object's own NAME and credit the binding
274
+ // concept for a seed that has the object but no binding; they remain for the Q2
275
+ // CQ↔concept linkage.
276
+ if (expected.binding_target_alternates) {
277
+ const targetIds = targetObjectTypeIds(objectTypeRows, expected.binding_target_alternates);
278
+ for (const binding of sourceBindings) {
279
+ if (bindingTargetsConcept(binding, targetIds)) {
280
+ return {
281
+ concept_key: expected.concept_key,
282
+ matched_name: [
283
+ stringField(binding, "binding_id"),
284
+ stringField(binding, "seed_ref"),
285
+ ]
286
+ .filter((value) => value.length > 0)
287
+ .join(" "),
288
+ seed_family: "data_binding_layer.source_bindings",
289
+ };
290
+ }
291
+ }
292
+ return null;
293
+ }
223
294
  for (const row of rows) {
224
295
  const normalized = normalizeName(row.name);
225
296
  for (const alternate of expected.name_alternates) {
@@ -236,18 +307,29 @@ function conceptMatch(expected, rows) {
236
307
  }
237
308
  /**
238
309
  * Explicit no-call exemptions: the only LLM-owned units with a runtime path
239
- * that completes without any LLM call (a not-required purpose confirmation,
240
- * or maturation units the runtime authors as empty projections when the
241
- * question frontier is empty). Every other completed non-runtime unit must
242
- * carry telemetry reject-by-default, so a newly added authored unit cannot
243
- * silently skip measurement provenance.
310
+ * that completes without any LLM call (a not-required purpose confirmation, or
311
+ * maturation units the runtime authors as empty projections when the question
312
+ * frontier is empty, or, for the answer-support judge, when the ledger has no
313
+ * convergent_source_evidence cluster). Every other completed non-runtime unit
314
+ * must carry telemetry — reject-by-default, so a newly added authored unit
315
+ * cannot silently skip measurement provenance.
244
316
  */
245
317
  const NO_CALL_EXEMPT_UNIT_IDS = new Set([
246
318
  "purpose_confirmation",
247
319
  "maturation_question_frontier",
248
320
  "maturation_closure_frontier",
321
+ "answer_support_judgment",
249
322
  "maturation_answer_claims",
250
323
  "ontology_expansion",
324
+ // leaf-read is a conditional LLM-touch: when no spreadsheet region triggers a read (no spreadsheet
325
+ // source, or structure already complete), the stage completes with no LLM call but still writes its
326
+ // census artifact (the R9 honest-signal). A leaf-read that DID call records leaf_read telemetry; the
327
+ // census itself records attempts/failures, so an exempt no-call completion still carries provenance.
328
+ "leaf_read",
329
+ // Layer-2 semantic_map (W3 review onto issue-003): same conditional-LLM-touch shape as leaf_read —
330
+ // a capability-present run with no evaluatable observation (or a fully preflight-capped one)
331
+ // completes with ZERO calls but still writes its census/sidecar (the provenance surface).
332
+ "semantic_map",
251
333
  ]);
252
334
  /**
253
335
  * Source-field rejection: completed LLM units must carry the telemetry
@@ -327,10 +409,12 @@ export function evaluateReconstructGoldenQualityGate(args) {
327
409
  }
328
410
  // Q1 — expected-concept recall over the authored seed.
329
411
  const nameRows = seedNameRows(args.ontologySeed);
412
+ const sourceBindings = recordRows(args.ontologySeed.data_binding_layer, "source_bindings");
413
+ const objectTypeRows = recordRows(args.ontologySeed.semantic_layer, "object_types");
330
414
  const matches = [];
331
415
  const missing = [];
332
416
  for (const expected of spec.expected_concepts) {
333
- const match = conceptMatch(expected, nameRows);
417
+ const match = conceptMatch(expected, nameRows, sourceBindings, objectTypeRows);
334
418
  if (match)
335
419
  matches.push(match);
336
420
  else
@@ -357,9 +441,30 @@ export function evaluateReconstructGoldenQualityGate(args) {
357
441
  if (!concept) {
358
442
  throw new Error(`Fixture ${spec.fixture_id} expected_cq ${expected.cq_key} links unknown concept ${expected.linked_concept_key}`);
359
443
  }
444
+ // A binding-target concept's CQ is supported only when a question explicitly
445
+ // references one of the run's TARGETING bindings BY ID (exact equality on a
446
+ // claim/seed ref). The object name or seed_ref must NOT credit it — a question
447
+ // merely about the object is not a question about its backing source — and an
448
+ // id SUBSTRING must not (a short binding_id like `b1` would collide with an
449
+ // unrelated ref `object-b10`). With no targeting binding the set is empty, so
450
+ // the binding CQ cannot be supported — the missing-binding case Q1 catches.
451
+ // Non-binding concepts link via name_alternates over the question text/refs.
360
452
  const question = args.competencyQuestions.questions.find((candidate) => {
361
453
  if (usedQuestionIds.has(candidate.question_id))
362
454
  return false;
455
+ if (concept.binding_target_alternates) {
456
+ const targetIds = targetObjectTypeIds(objectTypeRows, concept.binding_target_alternates);
457
+ const bindingIds = new Set(sourceBindings
458
+ .filter((binding) => bindingTargetsConcept(binding, targetIds))
459
+ .map((binding) => stringField(binding, "binding_id").trim())
460
+ .filter((id) => id.length > 0));
461
+ if (bindingIds.size === 0)
462
+ return false;
463
+ return [
464
+ ...(candidate.linked_claim_ids ?? []),
465
+ ...(candidate.seed_ref_refs ?? []),
466
+ ].some((ref) => bindingIds.has(ref.trim()));
467
+ }
363
468
  const haystack = normalizeName([
364
469
  candidate.question_id,
365
470
  candidate.question,
@@ -1,18 +1,16 @@
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 { assertArrayField, atomicWriteYamlDocument as writeYamlDocument } from "../artifact-io.js";
5
6
  import { sourceSafetyRowIdForObservation } from "./source-safety-validation.js";
7
+ import { assertObligation } from "./obligation-assertion.js";
6
8
  function isoNow() {
7
9
  return new Date().toISOString();
8
10
  }
9
11
  async function readYamlDocument(filePath) {
10
12
  return parseYaml(await fs.readFile(filePath, "utf8"));
11
13
  }
12
- async function writeYamlDocument(filePath, value) {
13
- await fs.mkdir(path.dirname(filePath), { recursive: true });
14
- await fs.writeFile(filePath, stringifyYaml(value), "utf8");
15
- }
16
14
  function stableJson(value) {
17
15
  if (Array.isArray(value)) {
18
16
  return `[${value.map(stableJson).join(",")}]`;
@@ -70,6 +68,7 @@ function samePathRef(left, right) {
70
68
  return path.resolve(left) === path.resolve(right);
71
69
  }
72
70
  function observationsBySourceRef(observations) {
71
+ assertArrayField(observations.observations, "source-observations", "observations");
73
72
  return new Map(observations.observations.map((observation) => [
74
73
  path.resolve(observation.source_ref),
75
74
  observation,
@@ -191,7 +190,20 @@ export function buildSourceObservationDeltaArtifact(args) {
191
190
  };
192
191
  }
193
192
  export function validateSourceObservationDelta(args) {
193
+ assertArrayField(args.sourceObservations.observations, "source-observations", "observations");
194
+ assertArrayField(args.delta.delta_rows, "source-observation-delta", "delta_rows");
195
+ assertArrayField(args.delta.accepted_frontier_ref_ids, "source-observation-delta", "accepted_frontier_ref_ids");
196
+ assertArrayField(args.delta.added_observation_ids, "source-observation-delta", "added_observation_ids");
194
197
  const violations = [];
198
+ // G(a) slice 5: record the four delta obligations with a distinct enforcement region, before any
199
+ // per-row loop so they are proven wired on a zero-row delta. validate_delta_frontier_kind_is_supported
200
+ // stays parked (ledger audit note): normalizeFrontierForDelta checks kind-vs-artifact CONSISTENCY,
201
+ // not that the kind value itself is in a supported set, so an unsupported kind can fall through.
202
+ const assertedObligationIds = [];
203
+ assertObligation(assertedObligationIds, "validate_delta_observation_refs_exist_in_source_observations");
204
+ assertObligation(assertedObligationIds, "validate_delta_rows_match_accepted_frontier_refs");
205
+ assertObligation(assertedObligationIds, "validate_delta_rows_preserve_observation_batch_id_and_triggering_frontier_validation_ref");
206
+ assertObligation(assertedObligationIds, "validate_delta_source_ref_material_kind_and_observation_hash_match_observed_content");
195
207
  if (args.delta.schema_version !== "1") {
196
208
  violations.push(violation({
197
209
  code: "schema_shape_invalid",
@@ -378,11 +390,23 @@ export function validateSourceObservationDelta(args) {
378
390
  validation_results: violations.length === 0
379
391
  ? ["source_observation_delta_valid"]
380
392
  : ["source_observation_delta_invalid"],
393
+ asserted_obligation_ids: assertedObligationIds,
381
394
  violations,
382
395
  };
383
396
  }
384
397
  export function validateSourceObservationReentry(args) {
398
+ assertArrayField(args.sourceObservations.observations, "source-observations", "observations");
399
+ assertArrayField(args.sourceSafetyLedger.safety_rows, "source-safety-ledger", "safety_rows");
400
+ assertArrayField(args.delta.added_observation_ids, "source-observation-delta", "added_observation_ids");
385
401
  const violations = [];
402
+ // G(a) slice 5: record the four re-entry obligations. R1/R4 are top-level gate checks; R2/R3 are
403
+ // per-observation, so all four are stamped before the loop to be proven wired on a zero-observation
404
+ // delta. Each was audited to a distinct enforcement region (no laundering).
405
+ const assertedObligationIds = [];
406
+ assertObligation(assertedObligationIds, "validate_delta_validation_passed_before_prompt_reentry");
407
+ assertObligation(assertedObligationIds, "validate_each_delta_observation_exists_in_source_observations");
408
+ assertObligation(assertedObligationIds, "validate_each_delta_observation_has_exact_prompt_context_source_safety_row");
409
+ assertObligation(assertedObligationIds, "validate_source_safety_validation_passed_before_prompt_reentry");
386
410
  if (args.delta.schema_version !== "1") {
387
411
  violations.push(reentryViolation({
388
412
  code: "schema_shape_invalid",
@@ -458,11 +482,34 @@ export function validateSourceObservationReentry(args) {
458
482
  validation_results: violations.length === 0
459
483
  ? ["source_observation_reentry_valid"]
460
484
  : ["source_observation_reentry_invalid"],
485
+ asserted_obligation_ids: assertedObligationIds,
461
486
  violations,
462
487
  };
463
488
  }
464
489
  export async function validateSourceObservationLineageIndex(args) {
490
+ assertArrayField(args.sourceObservations.observations, "source-observations", "observations");
491
+ assertArrayField(args.lineageIndex.lineage_rows, "source-observation-lineage-index", "lineage_rows");
465
492
  const violations = [];
493
+ // G(a) deferred-7 slice 3: record only the uniqueness obligation this validator fully enforces. Stamped
494
+ // before the per-row loop so it fires on zero rows. asserted_obligation_ids is in-memory-only telemetry
495
+ // (Stage 0 #145) on a reuse-hashed + scout-captured artifact, so stamping does not rotate reuse.
496
+ // validate_unique_session_level_lineage_row_ids → duplicate_id (the `seen` set below rejects any repeated
497
+ // lineage_row_id, including a repeated blank/absent id).
498
+ //
499
+ // PARKED (codex #149 R1, all false-pass edges of this gap-prone lineage validator):
500
+ // - require_each_lineage_row_{delta,reentry}_validation_to_be_valid, validate_each_lineage_row_delta_ref_
501
+ // is_readable_and_session_matching, validate_lineage_added_observation_ids_match_delta_added_observation_
502
+ // ids: a ref pointing at an EXISTING EMPTY/null YAML makes readYamlDocument return null WITHOUT throwing,
503
+ // so the catch (..._missing) never fires AND the `if (delta)` / `if (deltaValidation)` / `if (reentry
504
+ // Validation)` guards skip every check — the row passes valid. Not fully enforced until non-object reads
505
+ // are rejected.
506
+ // - validate_each_lineage_added_observation_exists_in_source_observations: checks membership in the PASSED
507
+ // sourceObservations object only; it is never bound to the delta/re-entry chain's source_observations_ref
508
+ // or session, so a wrong/stale source-observations file with the same ids passes (caller-supplied authority).
509
+ // - validate_each_lineage_added_observation_was_reentered_by_its_validation: shares the
510
+ // lineage_added_observation_mismatch code with the added-ids-match obligation (no distinct binding; slice-21).
511
+ const assertedObligationIds = [];
512
+ assertObligation(assertedObligationIds, "validate_unique_session_level_lineage_row_ids");
466
513
  const seen = new Set();
467
514
  const observationsById = new Map(args.sourceObservations.observations.map((observation) => [observation.observation_id, observation]));
468
515
  const addedObservationIds = new Set();
@@ -670,6 +717,7 @@ export async function validateSourceObservationLineageIndex(args) {
670
717
  validation_results: violations.length === 0
671
718
  ? ["source_observation_lineage_index_valid"]
672
719
  : ["source_observation_lineage_index_invalid"],
720
+ asserted_obligation_ids: assertedObligationIds,
673
721
  violations,
674
722
  };
675
723
  }
@@ -1,3 +1,5 @@
1
+ import { inventoryHasInspectedStructure, SPREADSHEET_CAPTURE_TRUNCATED_PHRASE, SPREADSHEET_MACRO_PRESENT_PHRASE, SPREADSHEET_OBSERVER_ADAPTER_ID, VALIDATION_MEMBER_CHAR_CAP, VALIDATION_MEMBER_COUNT_CAP, } from "../spreadsheet-structure-observer.js";
2
+ import { validateComprehensionArtifact, } from "./comprehension-artifact.js";
1
3
  const PROHIBITED_STRUCTURAL_KEYS = new Set([
2
4
  "aggregate_root",
3
5
  "business_entity",
@@ -31,6 +33,142 @@ function collectObjectKeys(value, keys = []) {
31
33
  }
32
34
  return keys;
33
35
  }
36
+ /** Lowercase 64-char hex (a raw-byte sha256). */
37
+ const CONTENT_SHA256_PATTERN = /^[0-9a-f]{64}$/;
38
+ /**
39
+ * P6 spreadsheet honesty/provenance assertions — the positive complement of the
40
+ * prohibition checks above: what a spreadsheet observation MUST honestly disclose.
41
+ * Runs only for spreadsheet observations; CRUCIALLY it exempts the legitimate
42
+ * UNSUPPORTED states the observer deliberately emits (empty-csv placeholder,
43
+ * oversized/unreadable with an empty hash) so the gate never converts an honest
44
+ * "nothing to inspect / could not read" inventory into a hard crash (the validator
45
+ * throws inside the builder, before the materialize loop's graceful skip-demotion).
46
+ */
47
+ function validateSpreadsheetObservationHonesty(observation, violations) {
48
+ const inventory = observation.structural_data.workbook_inventory;
49
+ // An array is `typeof "object"` too; reject it like the prompt-projection recompute
50
+ // does — this validator is also the boundary for persisted/replayed observations,
51
+ // so a malformed `workbook_inventory: []` must not pass as a valid inventory.
52
+ if (!inventory || typeof inventory !== "object" || Array.isArray(inventory)) {
53
+ violations.push("spreadsheet observation must carry a workbook_inventory in structural_data");
54
+ return;
55
+ }
56
+ // `unsupported_reason` is three-valued: null (supported), a non-empty string
57
+ // (genuinely unsupported), or a present-but-BLANK string (incoherent). A blank
58
+ // reason would skip the supported hash check yet not demote downstream
59
+ // (spreadsheetUnsupportedReason ignores blanks), admitting a no-evidence workbook —
60
+ // reject it explicitly so the honest disclosure is never empty.
61
+ const reason = inventory.unsupported_reason;
62
+ if (typeof reason === "string" && reason.trim() === "") {
63
+ violations.push("unsupported_reason must not be blank");
64
+ return;
65
+ }
66
+ const supported = reason == null;
67
+ if (supported) {
68
+ // B (provenance anchor): a SUPPORTED workbook (bytes actually read) must carry a
69
+ // well-formed raw-byte content hash at the TOP level — the field the source-scout-
70
+ // pack provenance consumer binds to (materialize-preparation surfaces it there).
71
+ // It is a presence/format check on the raw-byte content hash. An UNSUPPORTED
72
+ // inventory may legitimately carry an empty hash (oversized/unreadable: bytes
73
+ // never read) — its unsupported_reason is the honest disclosure.
74
+ const sha = observation.structural_data.content_sha256;
75
+ if (typeof sha !== "string" || !CONTENT_SHA256_PATTERN.test(sha)) {
76
+ violations.push("content_sha256_missing");
77
+ }
78
+ else if (sha !== inventory.content_sha256) {
79
+ // The top-level hash anchors source-scout provenance; the nested inventory hash
80
+ // is projected into prompts. A corrupted/replayed envelope where they disagree
81
+ // would let the two name different raw bytes — assert they match.
82
+ violations.push("content_sha256 disagrees with workbook_inventory hash");
83
+ }
84
+ }
85
+ else if (inventoryHasInspectedStructure(inventory)) {
86
+ // C (unsupported<->empty coherence): an unsupported inventory must not claim any
87
+ // inspected structure across the full inventory surface, not just `sheets`.
88
+ violations.push("unsupported spreadsheet inventory must not claim inspected structure");
89
+ }
90
+ // D (truncation/macro honesty): when the inventory flags partial capture or macro
91
+ // presence, the prompt-visible summary MUST disclose it with the fixed phrase the
92
+ // producer emits (assert + emit bound to the same literal so they cannot drift).
93
+ if (inventory.capture_truncated &&
94
+ !observation.summary.includes(SPREADSHEET_CAPTURE_TRUNCATED_PHRASE)) {
95
+ violations.push("capture_truncated not disclosed in observation summary");
96
+ }
97
+ if (inventory.macro_present &&
98
+ !observation.summary.includes(SPREADSHEET_MACRO_PRESENT_PHRASE)) {
99
+ violations.push("macro_present not disclosed in observation summary");
100
+ }
101
+ // E (design-C value-aware bound): the ONLY value-bearing field is
102
+ // data_validations[].members — the declared type=list enum labels. This enforces BOUNDS +
103
+ // INTERNAL CONSISTENCY only: any entry carrying members must be validation_type === "list"
104
+ // with count <= VALIDATION_MEMBER_COUNT_CAP and each member <= VALIDATION_MEMBER_CHAR_CAP.
105
+ // formula1 PROVENANCE (that members came from an inline literal, not observed cells) is an
106
+ // emission-time guarantee in the observer, NOT replay-verifiable here — a forged
107
+ // members+type=list pair is outside this honesty model (documented limitation).
108
+ // Codex round3 #5: a replayed / host-supplied artifact may carry a non-array (or missing)
109
+ // data_validations; guard before iterating so a malformed observation degrades to valid:false
110
+ // rather than throwing "not iterable" out of the boundary check.
111
+ if (!Array.isArray(inventory.data_validations)) {
112
+ violations.push("data_validations is missing or not an array");
113
+ }
114
+ for (const dv of Array.isArray(inventory.data_validations)
115
+ ? inventory.data_validations
116
+ : []) {
117
+ if (dv.members === undefined)
118
+ continue;
119
+ // Replayed / host-supplied artifacts are untyped despite the cast: a non-string member would
120
+ // make the length bounds silently pass (members:[123] → m.length undefined) or throw
121
+ // (members:"abc" → no .some). Reject non-string-array members before the bounds (Codex #3).
122
+ if (!Array.isArray(dv.members) ||
123
+ dv.members.some((m) => typeof m !== "string")) {
124
+ violations.push("data_validation members must be an array of strings");
125
+ continue;
126
+ }
127
+ if (dv.validation_type !== "list") {
128
+ violations.push("data_validation members present but validation_type is not 'list'");
129
+ }
130
+ if (dv.members.length > VALIDATION_MEMBER_COUNT_CAP) {
131
+ violations.push("data_validation members exceed VALIDATION_MEMBER_COUNT_CAP");
132
+ }
133
+ if (dv.members.some((m) => m.length > VALIDATION_MEMBER_CHAR_CAP)) {
134
+ violations.push("data_validation member exceeds VALIDATION_MEMBER_CHAR_CAP");
135
+ }
136
+ }
137
+ // E2 (P1-C1 value-tile safety): segmented_value_tiles is aggregate-only — type/shape/format
138
+ // counts, capped distinct COUNTS (never values), lower-bound flags, row anchors. Its only
139
+ // string-bearing fields are format-identities (the SANITIZED display-format grammar). A quoted
140
+ // literal there would mean a domain literal (e.g. "USD", "Customer:") leaked past
141
+ // normalizeFormatCode — reject it so a forged/replayed artifact cannot smuggle source text into
142
+ // a prompt-visible tile. (Bounds are structural: caps live in value_tile_config.)
143
+ const valueTiles = inventory.segmented_value_tiles;
144
+ if (valueTiles !== undefined) {
145
+ if (!Array.isArray(valueTiles)) {
146
+ violations.push("segmented_value_tiles must be an array when present");
147
+ }
148
+ else {
149
+ let unsanitized = false;
150
+ for (const sheet of valueTiles) {
151
+ for (const col of sheet.columns ?? []) {
152
+ for (const seg of col.segments ?? []) {
153
+ if (Object.keys(seg.format_counts ?? {}).some((fid) => fid.includes('"'))) {
154
+ unsanitized = true;
155
+ }
156
+ }
157
+ for (const note of col.intra_tile_notes ?? []) {
158
+ if (note.boundary_kind === "display_format" &&
159
+ ((typeof note.prev_shape === "string" && note.prev_shape.includes('"')) ||
160
+ (typeof note.new_shape === "string" && note.new_shape.includes('"')))) {
161
+ unsanitized = true;
162
+ }
163
+ }
164
+ }
165
+ }
166
+ if (unsanitized) {
167
+ violations.push("value-tile format-identity must be sanitized (no quoted literal)");
168
+ }
169
+ }
170
+ }
171
+ }
34
172
  export function validateSourceObservationBoundary(observation) {
35
173
  const violations = [];
36
174
  if (!observation.observation_id.trim()) {
@@ -46,6 +184,24 @@ export function validateSourceObservationBoundary(observation) {
46
184
  !observation.observation_batch_id.trim()) {
47
185
  violations.push("observation_batch_id must not be blank when present");
48
186
  }
187
+ // Defect-3 basis A is forgery-resistant at the artifact boundary (covers
188
+ // persisted/replayed/host-supplied observations, not just freshly produced ones):
189
+ if ("is_runtime_target_source" in observation) {
190
+ // (a) fail-closed type: downstream source-safety treats ONLY literal boolean
191
+ // true as Basis A, so a non-boolean must not pass the boundary silently.
192
+ if (observation.is_runtime_target_source !== undefined &&
193
+ typeof observation.is_runtime_target_source !== "boolean") {
194
+ violations.push("is_runtime_target_source must be a boolean when present");
195
+ }
196
+ // (b) mutual exclusion: a runtime-target source is the user-provided initial
197
+ // target and is never a frontier re-entry. Rejecting target+trigger blocks a
198
+ // replayed/forged frontier observation from claiming runtime-target provenance.
199
+ if (observation.is_runtime_target_source === true &&
200
+ typeof observation.triggering_frontier_validation_ref === "string" &&
201
+ observation.triggering_frontier_validation_ref.trim()) {
202
+ violations.push("is_runtime_target_source must not be true on a frontier re-entry observation (triggering_frontier_validation_ref present)");
203
+ }
204
+ }
49
205
  if (!observation.adapter_id.trim()) {
50
206
  violations.push("adapter_id is required");
51
207
  }
@@ -65,6 +221,16 @@ export function validateSourceObservationBoundary(observation) {
65
221
  violations.push(`summary contains prohibited ontology interpretation: ${label}`);
66
222
  }
67
223
  }
224
+ if (observation.adapter_id === SPREADSHEET_OBSERVER_ADAPTER_ID) {
225
+ validateSpreadsheetObservationHonesty(observation, violations);
226
+ }
227
+ // P1-C1 §5.7: validate the companion ComprehensionArtifact when present (construction + replay).
228
+ // Completeness is fail-closed (a silently-missing baseline field is a violation), so an invalid
229
+ // contract throws at construction (the materialize loop) and is rejected on replay.
230
+ const comprehensionArtifact = observation.structural_data.comprehension_artifact;
231
+ if (comprehensionArtifact !== undefined) {
232
+ validateComprehensionArtifact(comprehensionArtifact, violations);
233
+ }
68
234
  return {
69
235
  valid: violations.length === 0,
70
236
  violations,