From 9573ea4ab0bdf4183c21b2d91a1b699cb5804927 Mon Sep 17 00:00:00 2001 From: Egor Merkushev Date: Sat, 13 Jun 2026 19:01:15 +0300 Subject: [PATCH 1/2] Add ontology decision import preview --- .../0115_ontology_decision_import_preview.md | 43 ++ .../0115_ontology_decision_import_preview.md | 125 +++++ docs/supervisor_manual.md | 4 + tests/test_ontology_import_policy.py | 190 ++++++++ tools/README.md | 3 + tools/ontology_imports.py | 452 ++++++++++++++++++ tools/ontology_semantic_control_policy.json | 34 +- tools/proposal_promotion_registry.json | 13 + tools/proposal_runtime_registry.json | 65 +++ 9 files changed, 928 insertions(+), 1 deletion(-) create mode 100644 docs/archive/proposal_sources/0115_ontology_decision_import_preview.md create mode 100644 docs/proposals/0115_ontology_decision_import_preview.md diff --git a/docs/archive/proposal_sources/0115_ontology_decision_import_preview.md b/docs/archive/proposal_sources/0115_ontology_decision_import_preview.md new file mode 100644 index 0000000..49484e7 --- /dev/null +++ b/docs/archive/proposal_sources/0115_ontology_decision_import_preview.md @@ -0,0 +1,43 @@ +# Ontology Decision Import Preview + +Source artifact class: working draft + +## Motivating concern + +SpecGraph now has a typed Ontology owner decision report, but it still needs a +deterministic preview that shows how accepted/rejected decisions line up with +closed-loop evidence before any operator considers importing them into +SpecGraph. + +## Bounded scope + +Add a deterministic `ontology_decision_import_preview` artifact under `runs/` +that joins `ontology_review_dashboard` and `ontology_owner_decision_report` by +candidate id and intake id. The preview records matched evidence ids, source +intake state, owner decision state, preview state, required human action, and +explicit false apply/import/gate-close/canonical-mutation flags. + +This slice must not apply owner decisions, import decisions into SpecGraph, mark +candidates accepted, close semantic gates, write Ontology packages, update +ontology lockfiles, mutate canonical specs, invoke prompt agents, parse +arbitrary text, or run ontologyc. + +## Acceptance sketch + +- Declare the decision import preview layout and contract in + `tools/ontology_semantic_control_policy.json`. +- Build `runs/ontology_decision_import_preview.json` from the review dashboard + and owner decision report. +- Classify preview rows as blocked, ready for operator review, rejected, + clarification-needed, or unmatched. +- Validate the read-only boundary and reject policy or source authority + expansion. +- Cover artifact shape, write path, generated output, and authority rejection in + focused tests. +- Register proposal `0115` in promotion and runtime registries. + +## Next gap + +```text +build_specspace_owner_decision_review_surface +``` diff --git a/docs/proposals/0115_ontology_decision_import_preview.md b/docs/proposals/0115_ontology_decision_import_preview.md new file mode 100644 index 0000000..bc5906d --- /dev/null +++ b/docs/proposals/0115_ontology_decision_import_preview.md @@ -0,0 +1,125 @@ +# Ontology Decision Import Preview + +RFC: SG-RFC-0115 +Version: 0.1.0 + +## Status + +Implemented + +Decision scope: read-only import preview for Ontology owner decisions. + +This document does not apply owner decisions, import decisions into SpecGraph, +write Ontology packages, update ontology lockfiles, mutate canonical SpecGraph +specs, mark candidate terms accepted, close semantic gates, invoke prompt +agents, parse arbitrary text, or run ontologyc. + +## Source Material + +This proposal implements the next bounded runtime slice after +`0114_ontology_owner_decision_contract`. + +Source draft: + +- `docs/archive/proposal_sources/0115_ontology_decision_import_preview.md` + +## Summary + +SpecGraph now emits a deterministic decision import preview artifact: + +```text +runs/ontology_decision_import_preview.json +``` + +The artifact joins the rich ontology review dashboard with the owner decision +report. It shows whether each owner decision is blocked by the semantic gate, +ready for operator review, rejected, clarification-needed, or unmatched, while +keeping the result as review evidence only. + +## Goals + +- Add `ontology_decision_import_preview` to the semantic policy layout. +- Define preview states for blocked, ready, rejected, clarification, and + unmatched owner decisions. +- Preserve owner decision refs, candidate ids, intake ids, matched closed-loop + evidence ids, source intake state, and required human action. +- Keep apply/import, semantic gate closure, canonical mutation, Ontology package + writes, lockfile writes, and prompt execution authority disabled. +- Cover preview shape, write path, generated output, authority boundary, and + registry trace in tests. + +## Non-Goals + +- Applying owner decisions to SpecGraph specs. +- Marking candidates accepted in canonical SpecGraph state. +- Closing semantic gates. +- Writing Ontology packages or lockfiles. +- Adding SpecSpace mutation UI. + +## Runtime Contract + +The preview artifact declares: + +```json +{ + "artifact_kind": "ontology_decision_import_preview", + "schema_version": 1, + "proposal_id": "0115", + "source_artifacts": { + "ontology_review_dashboard": "runs/ontology_review_dashboard.json", + "ontology_owner_decision_report": "runs/ontology_owner_decision_report.json" + }, + "canonical_mutations_allowed": false, + "tracked_artifacts_written": false, + "decision_import_previews": [] +} +``` + +Each preview row records: + +- preview id; +- decision id; +- candidate id; +- intake id; +- owner decision state; +- Ontology decision ref; +- matched closed-loop evidence id when present; +- matched source intake state when present; +- preview state; +- required human action; +- import recommendation; +- explicit false import, gate-close, canonical mutation, Ontology package write, + and lockfile update flags. + +## Authority Boundary + +The preview may be used by SpecGraph and SpecSpace as a read-only review surface. + +The preview may not: + +- apply itself; +- import decisions into SpecGraph; +- mark candidates accepted; +- close semantic gates; +- mutate canonical specs; +- write Ontology packages; +- update ontology lockfiles; +- execute prompt agents. + +## Acceptance + +This slice is complete when: + +- `tools/ontology_semantic_control_policy.json` declares + `ontology_decision_import_preview`; +- `tools/ontology_imports.py` builds and validates the decision import preview; +- `make ontology-imports` writes `runs/ontology_decision_import_preview.json`; +- focused tests cover blocked, unmatched, and read-only preview behavior; +- proposal `0115` is tracked in promotion and runtime registries; +- proposal gates, DocC sync, and focused Python tests pass. + +## Next Gap + +```text +build_specspace_owner_decision_review_surface +``` diff --git a/docs/supervisor_manual.md b/docs/supervisor_manual.md index de81ac6..2404622 100644 --- a/docs/supervisor_manual.md +++ b/docs/supervisor_manual.md @@ -1650,6 +1650,10 @@ authoritative. - typed read-only Ontology owner decision report carrying accepted/rejected decision evidence for later import previews without closing gates or mutating canonical specs +- `runs/ontology_decision_import_preview.json` + - read-only preview that matches Ontology owner decisions to closed-loop + evidence and shows operator-review/import readiness without applying those + decisions or mutating canonical specs ### Queue and proposal surfaces diff --git a/tests/test_ontology_import_policy.py b/tests/test_ontology_import_policy.py index ba90cac..d8301a2 100644 --- a/tests/test_ontology_import_policy.py +++ b/tests/test_ontology_import_policy.py @@ -145,6 +145,9 @@ def test_ontology_semantic_control_policy_defines_review_only_contract() -> None assert policy["repository_layout"]["ontology_owner_decision_report"] == ( "runs/ontology_owner_decision_report.json" ) + assert policy["repository_layout"]["ontology_decision_import_preview"] == ( + "runs/ontology_decision_import_preview.json" + ) assert policy["repository_layout"]["ontology_delta_candidate_review_packet"] == ( "runs/ontology_delta_candidate_review_packet.json" ) @@ -395,6 +398,38 @@ def test_ontology_semantic_control_policy_defines_review_only_contract() -> None assert owner_decision_contract["consumer_boundary"]["may_mark_candidate_accepted"] is False assert owner_decision_contract["consumer_boundary"]["may_import_into_specgraph"] is False assert owner_decision_contract["consumer_boundary"]["may_close_semantic_gate"] is False + decision_import_contract = policy["ontology_decision_import_preview_contract"] + assert decision_import_contract["artifact_kind"] == "ontology_decision_import_preview" + assert decision_import_contract["source_review_dashboard_artifact_kind"] == ( + "ontology_review_dashboard" + ) + assert decision_import_contract["source_owner_decision_report_artifact_kind"] == ( + "ontology_owner_decision_report" + ) + assert decision_import_contract["target"] == { + "target_kind": "proposal", + "target_ref": "SG-RFC-0115", + } + assert { + "blocked_by_semantic_gate", + "ready_for_operator_review", + "rejected_by_owner", + "needs_clarification", + "unmatched_decision", + }.issubset(set(decision_import_contract["preview_states"])) + assert ( + decision_import_contract["consumer_boundary"]["for_specgraph_decision_import_preview"] + is True + ) + assert decision_import_contract["consumer_boundary"]["for_specspace_review_dashboard"] is True + assert decision_import_contract["consumer_boundary"]["may_execute_prompt_agent"] is False + assert decision_import_contract["consumer_boundary"]["may_write_ontology_package"] is False + assert decision_import_contract["consumer_boundary"]["may_update_ontology_lockfile"] is False + assert decision_import_contract["consumer_boundary"]["may_mutate_canonical_specs"] is False + assert decision_import_contract["consumer_boundary"]["may_mark_candidate_accepted"] is False + assert decision_import_contract["consumer_boundary"]["may_apply_preview"] is False + assert decision_import_contract["consumer_boundary"]["may_import_into_specgraph"] is False + assert decision_import_contract["consumer_boundary"]["may_close_semantic_gate"] is False owner_decision_fixture = policy["owner_decision_fixture"] assert owner_decision_fixture["artifact_kind"] == "ontology_owner_decision_fixture" assert {decision["decision_state"] for decision in owner_decision_fixture["decisions"]} == { @@ -419,6 +454,7 @@ def test_ontology_semantic_control_policy_defines_review_only_contract() -> None assert boundary["ontology_closed_loop_evidence_is_authority"] is False assert boundary["ontology_review_dashboard_is_authority"] is False assert boundary["ontology_owner_decision_report_is_authority"] is False + assert boundary["ontology_decision_import_preview_is_authority"] is False assert boundary["prompt_agent_execution_allowed"] is False assert boundary["automatic_canonical_node_update"] is False @@ -1187,6 +1223,73 @@ def test_ontology_owner_decision_report_builds_read_only_decision_contract() -> assert report["authority_boundary"]["ontology_owner_decision_report_is_authority"] is False +def test_ontology_decision_import_preview_builds_read_only_preview() -> None: + module = load_ontology_imports_module() + + surfaces = module.build_ontology_import_surfaces(FIXTURE) + + preview = surfaces["ontology_decision_import_preview"] + assert preview["artifact_kind"] == "ontology_decision_import_preview" + assert preview["proposal_id"] == "0115" + assert preview["target"] == { + "target_kind": "proposal", + "target_ref": "SG-RFC-0115", + } + assert preview["source_artifacts"]["ontology_review_dashboard"] == ( + "runs/ontology_review_dashboard.json" + ) + assert preview["source_artifacts"]["ontology_owner_decision_report"] == ( + "runs/ontology_owner_decision_report.json" + ) + assert preview["canonical_mutations_allowed"] is False + assert preview["tracked_artifacts_written"] is False + assert preview["summary"] == { + "status": "blocked_by_semantic_gate", + "preview_count": 2, + "accepted_count": 1, + "rejected_count": 1, + "clarification_count": 0, + "importable_count": 0, + "blocked_count": 1, + "unmatched_count": 1, + "next_gap": "build_specspace_owner_decision_review_surface", + } + + previews = {entry["decision_id"]: entry for entry in preview["decision_import_previews"]} + accepted = previews["ontology-owner-decision-accept-casfunction"] + assert accepted["candidate_id"] == "ontology-delta-candidate-examcalc-casfunction" + assert accepted["decision_state"] == "accepted" + assert accepted["preview_state"] == "blocked_by_semantic_gate" + assert accepted["matched_closed_loop_evidence_id"] == ( + "ontology-closed-loop-evidence-ontology-delta-candidate-examcalc-casfunction" + ) + assert accepted["matched_source_intake_state"] == "blocked_by_semantic_gate" + assert accepted["required_human_action"] == "resolve_blocking_ontology_semantic_findings" + assert accepted["import_recommended"] is False + assert accepted["imports_into_specgraph"] is False + assert accepted["closes_semantic_gate"] is False + assert accepted["mutates_canonical_specs"] is False + assert accepted["writes_ontology_package"] is False + assert accepted["updates_ontology_lockfile"] is False + rejected = previews["ontology-owner-decision-reject-legacyterm"] + assert rejected["decision_state"] == "rejected" + assert rejected["preview_state"] == "unmatched_decision" + assert rejected["matched_closed_loop_evidence_id"] == "" + assert rejected["required_human_action"] == "review_unmatched_ontology_owner_decision" + assert rejected["import_recommended"] is False + assert preview["consumer_boundary"]["for_specgraph_decision_import_preview"] is True + assert preview["consumer_boundary"]["for_specspace_review_dashboard"] is True + assert preview["consumer_boundary"]["may_execute_prompt_agent"] is False + assert preview["consumer_boundary"]["may_write_ontology_package"] is False + assert preview["consumer_boundary"]["may_update_ontology_lockfile"] is False + assert preview["consumer_boundary"]["may_mutate_canonical_specs"] is False + assert preview["consumer_boundary"]["may_mark_candidate_accepted"] is False + assert preview["consumer_boundary"]["may_apply_preview"] is False + assert preview["consumer_boundary"]["may_import_into_specgraph"] is False + assert preview["consumer_boundary"]["may_close_semantic_gate"] is False + assert preview["authority_boundary"]["ontology_decision_import_preview_is_authority"] is False + + def test_ontology_semantic_review_surface_rejects_authority_expansion( tmp_path: Path, ) -> None: @@ -1489,6 +1592,47 @@ def test_ontology_owner_decision_report_rejects_inconsistent_accepted_flag( ) +def test_ontology_decision_import_preview_rejects_policy_apply_authority( + tmp_path: Path, +) -> None: + module = load_ontology_imports_module() + module.ROOT = tmp_path + fixture_path = write_temp_fixture(tmp_path, load_fixture_payload()) + policy_path = write_temp_policy(tmp_path) + semantic_policy = json.loads( + (ROOT / "tools" / "ontology_semantic_control_policy.json").read_text() + ) + semantic_policy["ontology_decision_import_preview_contract"]["consumer_boundary"][ + "may_apply_preview" + ] = True + semantic_policy_path = write_temp_semantic_control_policy(tmp_path, semantic_policy) + + with pytest.raises(ValueError, match="may_apply_preview"): + module.build_ontology_import_surfaces( + fixture_path, + policy_path=policy_path, + semantic_policy_path=semantic_policy_path, + ) + + +def test_ontology_decision_import_preview_rejects_source_import_authority() -> None: + module = load_ontology_imports_module() + semantic_policy = json.loads( + (ROOT / "tools" / "ontology_semantic_control_policy.json").read_text() + ) + surfaces = module.build_ontology_import_surfaces(FIXTURE) + owner_decision_report = json.loads(json.dumps(surfaces["ontology_owner_decision_report"])) + owner_decision_report["decisions"][0]["imports_into_specgraph"] = True + + with pytest.raises(ValueError, match=r"decisions\[0\]\.imports_into_specgraph"): + module.build_ontology_decision_import_preview( + semantic_policy, + semantic_policy_path=ROOT / "tools" / "ontology_semantic_control_policy.json", + dashboard=surfaces["ontology_review_dashboard"], + owner_decision_report=owner_decision_report, + ) + + def test_ontology_delta_candidate_review_packet_uses_policy_review_action_order( tmp_path: Path, ) -> None: @@ -1598,6 +1742,9 @@ def test_ontology_semantic_write_uses_surface_output_artifact(tmp_path: Path) -> semantic_policy["repository_layout"]["ontology_owner_decision_report"] = ( "runs/custom_ontology_owner_decision_report.json" ) + semantic_policy["repository_layout"]["ontology_decision_import_preview"] = ( + "runs/custom_ontology_decision_import_preview.json" + ) semantic_policy["repository_layout"]["semantic_lint_smoke"] = ( "runs/custom_semantic_lint_smoke.json" ) @@ -1635,6 +1782,7 @@ def test_ontology_semantic_write_uses_surface_output_artifact(tmp_path: Path) -> assert "runs/custom_closed_loop_evidence.json" in written_paths assert "runs/custom_ontology_review_dashboard.json" in written_paths assert "runs/custom_ontology_owner_decision_report.json" in written_paths + assert "runs/custom_ontology_decision_import_preview.json" in written_paths assert "runs/custom_semantic_lint_smoke.json" in written_paths assert not (tmp_path / "runs" / "ontology_delta_candidate_review_packet.json").exists() assert not (tmp_path / "runs" / "ontology_semantic_context_pack.json").exists() @@ -1645,6 +1793,7 @@ def test_ontology_semantic_write_uses_surface_output_artifact(tmp_path: Path) -> assert not (tmp_path / "runs" / "ontology_closed_loop_evidence.json").exists() assert not (tmp_path / "runs" / "ontology_review_dashboard.json").exists() assert not (tmp_path / "runs" / "ontology_owner_decision_report.json").exists() + assert not (tmp_path / "runs" / "ontology_decision_import_preview.json").exists() assert not (tmp_path / "runs" / "ontology_semantic_lint_smoke.json").exists() @@ -1816,6 +1965,7 @@ def test_make_ontology_imports_writes_declared_surfaces() -> None: "runs/ontology_closed_loop_evidence.json": "ontology_closed_loop_evidence", "runs/ontology_review_dashboard.json": "ontology_review_dashboard", "runs/ontology_owner_decision_report.json": "ontology_owner_decision_report", + "runs/ontology_decision_import_preview.json": "ontology_decision_import_preview", "runs/ontology_semantic_lint_smoke.json": "ontology_semantic_lint_smoke", } for relative_path, artifact_kind in expected.items(): @@ -1831,6 +1981,7 @@ def test_make_ontology_imports_writes_declared_surfaces() -> None: "ontology_closed_loop_evidence": "0111", "ontology_review_dashboard": "0113", "ontology_owner_decision_report": "0114", + "ontology_decision_import_preview": "0115", "ontology_semantic_lint_smoke": "0103", }.get(artifact_kind, "0060") assert payload["proposal_id"] == expected_proposal_id @@ -2523,3 +2674,42 @@ def test_proposal_0111_runtime_registry_tracks_closed_loop_evidence() -> None: "docs/supervisor_manual.md", "runs/ontology_closed_loop_evidence.json", ) in observation_markers + + +def test_proposal_0115_runtime_registry_tracks_decision_import_preview() -> None: + registry = json.loads((ROOT / "tools" / "proposal_runtime_registry.json").read_text()) + entries = {entry["proposal_id"]: entry for entry in registry if isinstance(entry, dict)} + proposal = entries["0115"] + + runtime_markers = {(item["path"], item["pattern"]) for item in proposal["runtime_markers"]} + validation_markers = { + (item["path"], item["pattern"]) for item in proposal["validation_markers"] + } + observation_markers = { + (item["path"], item["pattern"]) for item in proposal["observation_markers"] + } + + assert ( + "tools/ontology_semantic_control_policy.json", + "ontology_decision_import_preview_contract", + ) in runtime_markers + assert ( + "tools/ontology_imports.py", + "def build_ontology_decision_import_preview(", + ) in runtime_markers + assert ( + "tools/ontology_imports.py", + "def require_ontology_decision_import_preview(", + ) in runtime_markers + assert ( + "tests/test_ontology_import_policy.py", + "def test_ontology_decision_import_preview_builds_read_only_preview(", + ) in validation_markers + assert ( + "tools/README.md", + "runs/ontology_decision_import_preview.json", + ) in observation_markers + assert ( + "docs/supervisor_manual.md", + "runs/ontology_decision_import_preview.json", + ) in observation_markers diff --git a/tools/README.md b/tools/README.md index 21c1c99..36d26ad 100644 --- a/tools/README.md +++ b/tools/README.md @@ -140,6 +140,9 @@ Supervisor modes: and closed-loop evidence, `runs/ontology_owner_decision_report.json` as the typed read-only accepted/rejected Ontology owner decision report for later import previews, + `runs/ontology_decision_import_preview.json` as the read-only preview that + matches owner decisions back to closed-loop evidence and recommends operator + review without applying imports, plus `runs/ontology_semantic_lint_smoke.json`, classifying accepted, alias, unknown, deprecated, and relation-conflict terms diff --git a/tools/ontology_imports.py b/tools/ontology_imports.py index 4ff75ff..6300ba4 100755 --- a/tools/ontology_imports.py +++ b/tools/ontology_imports.py @@ -645,6 +645,7 @@ def require_semantic_control_policy(policy: dict[str, Any]) -> dict[str, Any]: require_layout_path(layout, "ontology_closed_loop_evidence") require_layout_path(layout, "ontology_review_dashboard") require_layout_path(layout, "ontology_owner_decision_report") + require_layout_path(layout, "ontology_decision_import_preview") require_layout_path(layout, "semantic_lint_smoke") boundary = require_object(policy, "authority_boundary", "semantic_control_policy") for field in ( @@ -658,6 +659,7 @@ def require_semantic_control_policy(policy: dict[str, Any]) -> dict[str, Any]: "ontology_closed_loop_evidence_is_authority", "ontology_review_dashboard_is_authority", "ontology_owner_decision_report_is_authority", + "ontology_decision_import_preview_is_authority", "prompt_agent_execution_allowed", "automatic_import_lock_update", "automatic_canonical_node_update", @@ -1932,6 +1934,127 @@ def require_semantic_control_policy(policy: dict[str, Any]) -> dict[str, Any]: "next_gap", "semantic_control_policy.ontology_owner_decision_report_contract", ) + decision_import_contract = require_object( + policy, "ontology_decision_import_preview_contract", "semantic_control_policy" + ) + if ( + require_string( + decision_import_contract, + "artifact_kind", + "semantic_control_policy.ontology_decision_import_preview_contract", + ) + != "ontology_decision_import_preview" + ): + raise ValueError( + "semantic_control_policy.ontology_decision_import_preview_contract.artifact_kind " + "must be ontology_decision_import_preview" + ) + if ( + require_string( + decision_import_contract, + "source_review_dashboard_artifact_kind", + "semantic_control_policy.ontology_decision_import_preview_contract", + ) + != "ontology_review_dashboard" + ): + raise ValueError( + "semantic_control_policy.ontology_decision_import_preview_contract." + "source_review_dashboard_artifact_kind must be ontology_review_dashboard" + ) + if ( + require_string( + decision_import_contract, + "source_owner_decision_report_artifact_kind", + "semantic_control_policy.ontology_decision_import_preview_contract", + ) + != "ontology_owner_decision_report" + ): + raise ValueError( + "semantic_control_policy.ontology_decision_import_preview_contract." + "source_owner_decision_report_artifact_kind must be ontology_owner_decision_report" + ) + decision_import_target = require_object( + decision_import_contract, + "target", + "semantic_control_policy.ontology_decision_import_preview_contract", + ) + require_string( + decision_import_target, + "target_kind", + "semantic_control_policy.ontology_decision_import_preview_contract.target", + ) + require_string( + decision_import_target, + "target_ref", + "semantic_control_policy.ontology_decision_import_preview_contract.target", + ) + preview_states = set( + require_string_list( + decision_import_contract, + "preview_states", + "semantic_control_policy.ontology_decision_import_preview_contract", + ) + ) + required_preview_states = { + "blocked_by_semantic_gate", + "ready_for_operator_review", + "rejected_by_owner", + "needs_clarification", + "unmatched_decision", + } + missing_preview_states = sorted(required_preview_states - preview_states) + if missing_preview_states: + raise ValueError( + "semantic_control_policy.ontology_decision_import_preview_contract.preview_states " + f"missing: {', '.join(missing_preview_states)}" + ) + decision_import_consumer_boundary = require_object( + decision_import_contract, + "consumer_boundary", + "semantic_control_policy.ontology_decision_import_preview_contract", + ) + for field in ("for_specgraph_decision_import_preview", "for_specspace_review_dashboard"): + if ( + require_bool( + decision_import_consumer_boundary, + field, + "semantic_control_policy.ontology_decision_import_preview_contract." + "consumer_boundary", + ) + is not True + ): + raise ValueError( + "semantic_control_policy.ontology_decision_import_preview_contract." + f"consumer_boundary.{field} must be true" + ) + for field in ( + "may_execute_prompt_agent", + "may_write_ontology_package", + "may_update_ontology_lockfile", + "may_mutate_canonical_specs", + "may_mark_candidate_accepted", + "may_apply_preview", + "may_import_into_specgraph", + "may_close_semantic_gate", + ): + if ( + require_bool( + decision_import_consumer_boundary, + field, + "semantic_control_policy.ontology_decision_import_preview_contract." + "consumer_boundary", + ) + is not False + ): + raise ValueError( + "semantic_control_policy.ontology_decision_import_preview_contract." + f"consumer_boundary.{field} must be false" + ) + require_string( + decision_import_contract, + "next_gap", + "semantic_control_policy.ontology_decision_import_preview_contract", + ) return policy @@ -4356,6 +4479,325 @@ def require_ontology_owner_decision_report( return owner_decision_report +def build_ontology_decision_import_preview( + semantic_policy: dict[str, Any], + *, + semantic_policy_path: Path, + dashboard: dict[str, Any], + owner_decision_report: dict[str, Any], +) -> dict[str, Any]: + require_semantic_control_policy(semantic_policy) + require_ontology_review_dashboard(dashboard) + require_ontology_owner_decision_report(owner_decision_report) + + decision_import_contract = require_object( + semantic_policy, "ontology_decision_import_preview_contract", "semantic_control_policy" + ) + semantic_layout = require_object( + semantic_policy, "repository_layout", "semantic_control_policy" + ) + dashboard_artifact = require_surface_output_artifact(dashboard, "ontology_review_dashboard") + owner_decision_report_artifact = require_surface_output_artifact( + owner_decision_report, "ontology_owner_decision_report" + ) + dashboard_summary = require_object(dashboard, "status_summary", "dashboard") + dashboard_status = require_string(dashboard_summary, "status", "dashboard.status_summary") + preview_states = set( + require_string_list( + decision_import_contract, + "preview_states", + "semantic_control_policy.ontology_decision_import_preview_contract", + ) + ) + + closed_loop_entries = dashboard.get("closed_loop_entries") + if not isinstance(closed_loop_entries, list): + raise ValueError("dashboard.closed_loop_entries must be a list") + closed_loop_by_decision_key: dict[tuple[str, str], dict[str, Any]] = {} + for index, raw_entry in enumerate(closed_loop_entries): + if not isinstance(raw_entry, dict): + raise ValueError(f"dashboard.closed_loop_entries[{index}] must be an object") + candidate_id = require_string( + raw_entry, "candidate_id", f"dashboard.closed_loop_entries[{index}]" + ) + intake_id = require_string( + raw_entry, "intake_id", f"dashboard.closed_loop_entries[{index}]" + ) + closed_loop_by_decision_key[(candidate_id, intake_id)] = raw_entry + + raw_decisions = owner_decision_report.get("decisions") + if not isinstance(raw_decisions, list): + raise ValueError("owner_decision_report.decisions must be a list") + previews: list[dict[str, Any]] = [] + for index, raw_decision in enumerate(raw_decisions): + if not isinstance(raw_decision, dict): + raise ValueError(f"owner_decision_report.decisions[{index}] must be an object") + context = f"owner_decision_report.decisions[{index}]" + decision_id = require_string(raw_decision, "decision_id", context) + candidate_id = require_string(raw_decision, "candidate_id", context) + intake_id = require_string(raw_decision, "intake_id", context) + decision_state = require_string(raw_decision, "decision_state", context) + if decision_state not in {"accepted", "rejected", "needs_clarification"}: + raise ValueError(f"{context}.decision_state must be supported") + matched_entry = closed_loop_by_decision_key.get((candidate_id, intake_id)) + matched_evidence_id = "" + matched_source_intake_state = "" + matched_evidence_state = "" + required_human_action = "review_unmatched_ontology_owner_decision" + if matched_entry is None: + preview_state = "unmatched_decision" + else: + matched_evidence_id = require_string( + matched_entry, + "evidence_id", + f"dashboard.closed_loop_entries[{candidate_id}]", + ) + matched_source_intake_state = require_string( + matched_entry, + "source_intake_state", + f"dashboard.closed_loop_entries[{candidate_id}]", + ) + matched_evidence_state = require_string( + matched_entry, + "evidence_state", + f"dashboard.closed_loop_entries[{candidate_id}]", + ) + if decision_state == "rejected": + preview_state = "rejected_by_owner" + required_human_action = "record_owner_rejection_without_import" + elif decision_state == "needs_clarification": + preview_state = "needs_clarification" + required_human_action = "request_owner_clarification" + elif ( + dashboard_status == "blocked_by_semantic_gate" + or matched_evidence_state == "blocked_by_semantic_gate" + ): + preview_state = "blocked_by_semantic_gate" + required_human_action = require_string( + matched_entry, + "required_human_action", + f"dashboard.closed_loop_entries[{candidate_id}]", + ) + else: + preview_state = "ready_for_operator_review" + required_human_action = "operator_review_ontology_owner_decision" + if preview_state not in preview_states: + raise ValueError( + "semantic_control_policy.ontology_decision_import_preview_contract." + f"preview_states does not declare computed state {preview_state}" + ) + import_recommended = ( + preview_state == "ready_for_operator_review" and decision_state == "accepted" + ) + previews.append( + { + "preview_id": f"ontology-decision-import-preview-{symbol_slug(decision_id)}", + "decision_id": decision_id, + "candidate_id": candidate_id, + "intake_id": intake_id, + "decision_state": decision_state, + "ontology_decision_ref": require_string( + raw_decision, "ontology_decision_ref", context + ), + "decided_by": require_string(raw_decision, "decided_by", context), + "decided_at": require_string(raw_decision, "decided_at", context), + "reason": optional_string(raw_decision, "reason", context), + "accepted_ontology_delta": require_bool( + raw_decision, "accepted_ontology_delta", context + ), + "matched_closed_loop_evidence_id": matched_evidence_id, + "matched_source_intake_state": matched_source_intake_state, + "matched_evidence_state": matched_evidence_state, + "preview_state": preview_state, + "required_human_action": required_human_action, + "import_recommended": import_recommended, + "imports_into_specgraph": False, + "closes_semantic_gate": False, + "mutates_canonical_specs": False, + "writes_ontology_package": False, + "updates_ontology_lockfile": False, + } + ) + + accepted_count = sum(1 for preview in previews if preview["decision_state"] == "accepted") + rejected_count = sum(1 for preview in previews if preview["decision_state"] == "rejected") + clarification_count = sum( + 1 for preview in previews if preview["decision_state"] == "needs_clarification" + ) + importable_count = sum(1 for preview in previews if preview["import_recommended"]) + blocked_count = sum( + 1 for preview in previews if preview["preview_state"] == "blocked_by_semantic_gate" + ) + unmatched_count = sum( + 1 for preview in previews if preview["preview_state"] == "unmatched_decision" + ) + if not previews: + status = "no_decisions" + elif blocked_count: + status = "blocked_by_semantic_gate" + elif importable_count: + status = "ready_for_operator_review" + elif unmatched_count: + status = "unmatched_decision" + elif clarification_count: + status = "needs_clarification" + else: + status = "rejected_by_owner" + + source_artifacts = copy_json_object(require_object(dashboard, "source_artifacts", "dashboard")) + source_artifacts["ontology_review_dashboard"] = dashboard_artifact + source_artifacts["ontology_owner_decision_report"] = owner_decision_report_artifact + + return { + "artifact_kind": require_string( + decision_import_contract, + "artifact_kind", + "semantic_control_policy.ontology_decision_import_preview_contract", + ), + "schema_version": 1, + "proposal_id": "0115", + "policy_basis": semantic_policy["policy_basis"], + "source_policy": relative_path(semantic_policy_path), + "source_artifacts": source_artifacts, + "target": copy_json_object( + require_object( + decision_import_contract, + "target", + "semantic_control_policy.ontology_decision_import_preview_contract", + ) + ), + "canonical_mutations_allowed": False, + "tracked_artifacts_written": False, + "decision_import_previews": previews, + "consumer_boundary": copy_json_object( + require_object( + decision_import_contract, + "consumer_boundary", + "semantic_control_policy.ontology_decision_import_preview_contract", + ) + ), + "authority_boundary": copy_json_object( + require_object(semantic_policy, "authority_boundary", "semantic_control_policy") + ), + "summary": { + "status": status, + "preview_count": len(previews), + "accepted_count": accepted_count, + "rejected_count": rejected_count, + "clarification_count": clarification_count, + "importable_count": importable_count, + "blocked_count": blocked_count, + "unmatched_count": unmatched_count, + "next_gap": require_string( + decision_import_contract, + "next_gap", + "semantic_control_policy.ontology_decision_import_preview_contract", + ), + }, + "output_artifact": require_layout_path(semantic_layout, "ontology_decision_import_preview"), + } + + +def require_ontology_decision_import_preview( + preview: dict[str, Any], +) -> dict[str, Any]: + if preview.get("artifact_kind") != "ontology_decision_import_preview": + raise ValueError( + "decision_import_preview.artifact_kind must be ontology_decision_import_preview" + ) + if require_int(preview, "schema_version", "decision_import_preview") != 1: + raise ValueError("decision_import_preview.schema_version must be 1") + if require_string(preview, "proposal_id", "decision_import_preview") != "0115": + raise ValueError("decision_import_preview.proposal_id must be 0115") + if require_bool(preview, "canonical_mutations_allowed", "decision_import_preview") is not False: + raise ValueError("decision_import_preview.canonical_mutations_allowed must be false") + if require_bool(preview, "tracked_artifacts_written", "decision_import_preview") is not False: + raise ValueError("decision_import_preview.tracked_artifacts_written must be false") + require_surface_output_artifact(preview, "ontology_decision_import_preview") + require_object(preview, "source_artifacts", "decision_import_preview") + summary = require_object(preview, "summary", "decision_import_preview") + status = require_string(summary, "status", "decision_import_preview.summary") + supported_states = { + "blocked_by_semantic_gate", + "ready_for_operator_review", + "rejected_by_owner", + "needs_clarification", + "unmatched_decision", + "no_decisions", + } + if status not in supported_states: + raise ValueError("decision_import_preview.summary.status is not supported") + decision_import_previews = preview.get("decision_import_previews") + if not isinstance(decision_import_previews, list): + raise ValueError("decision_import_preview.decision_import_previews must be a list") + for index, raw_entry in enumerate(decision_import_previews): + if not isinstance(raw_entry, dict): + raise ValueError( + f"decision_import_preview.decision_import_previews[{index}] must be an object" + ) + context = f"decision_import_preview.decision_import_previews[{index}]" + require_string(raw_entry, "preview_id", context) + require_string(raw_entry, "decision_id", context) + require_string(raw_entry, "candidate_id", context) + require_string(raw_entry, "intake_id", context) + decision_state = require_string(raw_entry, "decision_state", context) + if decision_state not in {"accepted", "rejected", "needs_clarification"}: + raise ValueError(f"{context}.decision_state must be supported") + preview_state = require_string(raw_entry, "preview_state", context) + if preview_state not in supported_states - {"no_decisions"}: + raise ValueError(f"{context}.preview_state must be supported") + import_recommended = require_bool(raw_entry, "import_recommended", context) + if import_recommended is not (preview_state == "ready_for_operator_review"): + raise ValueError( + f"{context}.import_recommended must match ready_for_operator_review state" + ) + for field in ( + "imports_into_specgraph", + "closes_semantic_gate", + "mutates_canonical_specs", + "writes_ontology_package", + "updates_ontology_lockfile", + ): + if require_bool(raw_entry, field, context) is not False: + raise ValueError(f"{context}.{field} must be false") + consumer_boundary = require_object(preview, "consumer_boundary", "decision_import_preview") + for field in ("for_specgraph_decision_import_preview", "for_specspace_review_dashboard"): + if ( + require_bool(consumer_boundary, field, "decision_import_preview.consumer_boundary") + is not True + ): + raise ValueError(f"decision_import_preview.consumer_boundary.{field} must be true") + for field in ( + "may_execute_prompt_agent", + "may_write_ontology_package", + "may_update_ontology_lockfile", + "may_mutate_canonical_specs", + "may_mark_candidate_accepted", + "may_apply_preview", + "may_import_into_specgraph", + "may_close_semantic_gate", + ): + if ( + require_bool(consumer_boundary, field, "decision_import_preview.consumer_boundary") + is not False + ): + raise ValueError(f"decision_import_preview.consumer_boundary.{field} must be false") + authority_boundary = require_object(preview, "authority_boundary", "decision_import_preview") + for field in ( + "ontology_decision_import_preview_is_authority", + "prompt_agent_execution_allowed", + "automatic_import_lock_update", + "automatic_canonical_node_update", + "canonical_mutations_allowed", + ): + if ( + require_bool(authority_boundary, field, "decision_import_preview.authority_boundary") + is not False + ): + raise ValueError(f"decision_import_preview.authority_boundary.{field} must be false") + return preview + + def build_ontology_semantic_lint_smoke( semantic_policy: dict[str, Any], *, @@ -4709,6 +5151,12 @@ def build_ontology_import_surfaces( semantic_policy_path=semantic_policy_path, closed_loop_evidence=surfaces["ontology_closed_loop_evidence"], ) + surfaces["ontology_decision_import_preview"] = build_ontology_decision_import_preview( + semantic_policy, + semantic_policy_path=semantic_policy_path, + dashboard=surfaces["ontology_review_dashboard"], + owner_decision_report=surfaces["ontology_owner_decision_report"], + ) surfaces["semantic_lint_smoke"] = build_ontology_semantic_lint_smoke( semantic_policy, semantic_policy_path=semantic_policy_path, @@ -4778,6 +5226,10 @@ def write_ontology_import_surfaces( destinations["ontology_owner_decision_report"] = require_surface_output_artifact( surfaces["ontology_owner_decision_report"], "ontology_owner_decision_report" ) + if "ontology_decision_import_preview" in surfaces: + destinations["ontology_decision_import_preview"] = require_surface_output_artifact( + surfaces["ontology_decision_import_preview"], "ontology_decision_import_preview" + ) if "semantic_lint_smoke" in surfaces: destinations["semantic_lint_smoke"] = require_surface_output_artifact( surfaces["semantic_lint_smoke"], "semantic_lint_smoke" diff --git a/tools/ontology_semantic_control_policy.json b/tools/ontology_semantic_control_policy.json index 533155e..8f84781 100644 --- a/tools/ontology_semantic_control_policy.json +++ b/tools/ontology_semantic_control_policy.json @@ -12,7 +12,8 @@ "docs/proposals/0110_ontology_delta_draft_intake.md", "docs/proposals/0111_ontology_closed_loop_evidence.md", "docs/proposals/0113_ontology_review_dashboard.md", - "docs/proposals/0114_ontology_owner_decision_contract.md" + "docs/proposals/0114_ontology_owner_decision_contract.md", + "docs/proposals/0115_ontology_decision_import_preview.md" ], "policy_basis": "docs/proposals/0100_ontology_grounded_semantic_control.md", "repository_layout": { @@ -25,6 +26,7 @@ "ontology_closed_loop_evidence": "runs/ontology_closed_loop_evidence.json", "ontology_review_dashboard": "runs/ontology_review_dashboard.json", "ontology_owner_decision_report": "runs/ontology_owner_decision_report.json", + "ontology_decision_import_preview": "runs/ontology_decision_import_preview.json", "semantic_lint_smoke": "runs/ontology_semantic_lint_smoke.json" }, "derived_output_contract": { @@ -360,6 +362,35 @@ }, "next_gap": "build_ontology_decision_import_preview" }, + "ontology_decision_import_preview_contract": { + "artifact_kind": "ontology_decision_import_preview", + "source_review_dashboard_artifact_kind": "ontology_review_dashboard", + "source_owner_decision_report_artifact_kind": "ontology_owner_decision_report", + "target": { + "target_kind": "proposal", + "target_ref": "SG-RFC-0115" + }, + "preview_states": [ + "blocked_by_semantic_gate", + "ready_for_operator_review", + "rejected_by_owner", + "needs_clarification", + "unmatched_decision" + ], + "consumer_boundary": { + "for_specgraph_decision_import_preview": true, + "for_specspace_review_dashboard": true, + "may_execute_prompt_agent": false, + "may_write_ontology_package": false, + "may_update_ontology_lockfile": false, + "may_mutate_canonical_specs": false, + "may_mark_candidate_accepted": false, + "may_apply_preview": false, + "may_import_into_specgraph": false, + "may_close_semantic_gate": false + }, + "next_gap": "build_specspace_owner_decision_review_surface" + }, "semantic_lint_contract": { "future_artifact_kind": "ontology_semantic_lint_report", "smoke_artifact_kind": "ontology_semantic_lint_smoke", @@ -497,6 +528,7 @@ "ontology_closed_loop_evidence_is_authority": false, "ontology_review_dashboard_is_authority": false, "ontology_owner_decision_report_is_authority": false, + "ontology_decision_import_preview_is_authority": false, "ontology_governance_required": true, "specgraph_proposal_review_required": true, "prompt_agent_execution_allowed": false, diff --git a/tools/proposal_promotion_registry.json b/tools/proposal_promotion_registry.json index d387ebb..f74228e 100644 --- a/tools/proposal_promotion_registry.json +++ b/tools/proposal_promotion_registry.json @@ -1480,5 +1480,18 @@ "required_provenance_links": [ "source_draft_ref" ] + }, + { + "proposal_id": "0115", + "source_artifact_class": "working_draft", + "source_refs": [ + "docs/archive/proposal_sources/0115_ontology_decision_import_preview.md" + ], + "motivating_concern": "SpecGraph has a typed Ontology owner decision report, but operators and SpecSpace need a deterministic read-only preview that shows how accepted/rejected decisions match closed-loop evidence before any later import workflow is considered.", + "normalized_title": "Ontology Decision Import Preview", + "bounded_scope": "Add a deterministic ontology_decision_import_preview artifact that joins the ontology review dashboard and owner decision report by candidate id and intake id, carrying matched evidence refs, source intake state, owner decision state, preview state, required human action, and explicit false apply/import/gate-close/canonical-mutation/package-write/lockfile-update flags under runs/ without applying owner decisions, importing decisions into SpecGraph, marking candidates accepted, closing gates, writing Ontology packages or lockfiles, mutating canonical specs, invoking prompt agents, parsing arbitrary text, or running ontologyc.", + "required_provenance_links": [ + "source_draft_ref" + ] } ] diff --git a/tools/proposal_runtime_registry.json b/tools/proposal_runtime_registry.json index 495be3f..9f89582 100644 --- a/tools/proposal_runtime_registry.json +++ b/tools/proposal_runtime_registry.json @@ -6249,5 +6249,70 @@ "pattern": "build_ontology_decision_import_preview" } ] + }, + { + "proposal_id": "0115", + "posture": "bounded_runtime_followup", + "runtime_surfaces": [ + "docs/proposals/0115_ontology_decision_import_preview.md", + "docs/archive/proposal_sources/0115_ontology_decision_import_preview.md", + "tools/ontology_semantic_control_policy.json", + "tools/ontology_imports.py", + "tools/README.md", + "docs/supervisor_manual.md", + "Makefile", + "tests/test_ontology_import_policy.py", + "runs/ontology_decision_import_preview.json" + ], + "runtime_markers": [ + { + "path": "tools/ontology_semantic_control_policy.json", + "pattern": "ontology_decision_import_preview_contract" + }, + { + "path": "tools/ontology_imports.py", + "pattern": "def build_ontology_decision_import_preview(" + }, + { + "path": "tools/ontology_imports.py", + "pattern": "def require_ontology_decision_import_preview(" + }, + { + "path": "tools/ontology_imports.py", + "pattern": "\"ontology_decision_import_preview\"" + } + ], + "validation_markers": [ + { + "path": "tests/test_ontology_import_policy.py", + "pattern": "def test_ontology_decision_import_preview_builds_read_only_preview(" + }, + { + "path": "tests/test_ontology_import_policy.py", + "pattern": "def test_ontology_decision_import_preview_rejects_policy_apply_authority(" + }, + { + "path": "tests/test_ontology_import_policy.py", + "pattern": "runs/ontology_decision_import_preview.json" + } + ], + "observation_markers": [ + { + "path": "Makefile", + "pattern": "ontology-imports:" + }, + { + "path": "tools/README.md", + "pattern": "runs/ontology_decision_import_preview.json" + }, + { + "path": "docs/supervisor_manual.md", + "pattern": "runs/ontology_decision_import_preview.json" + }, + { + "path": "docs/proposals/0115_ontology_decision_import_preview.md", + "pattern": "build_specspace_owner_decision_review_surface" + } + ] } ] From 1f5570d5ca51b9f28289b0f03b9170fe9895462e Mon Sep 17 00:00:00 2001 From: Egor Merkushev Date: Sat, 13 Jun 2026 19:41:52 +0300 Subject: [PATCH 2/2] Harden ontology decision import preview validation --- .../OntologyCAdapterReport.md | 17 +++ tests/test_ontology_import_policy.py | 106 ++++++++++++++++++ tools/ontology_imports.py | 24 +++- 3 files changed, 146 insertions(+), 1 deletion(-) diff --git a/Sources/SpecGraph/Documentation.docc/OntologyCAdapterReport.md b/Sources/SpecGraph/Documentation.docc/OntologyCAdapterReport.md index bfabc9c..c8dbfff 100644 --- a/Sources/SpecGraph/Documentation.docc/OntologyCAdapterReport.md +++ b/Sources/SpecGraph/Documentation.docc/OntologyCAdapterReport.md @@ -59,6 +59,9 @@ runs/ontology_semantic_review_surface.json runs/ontology_supervisor_semantic_gate.json runs/ontology_delta_draft_intake.json runs/ontology_closed_loop_evidence.json +runs/ontology_review_dashboard.json +runs/ontology_owner_decision_report.json +runs/ontology_decision_import_preview.json runs/ontology_semantic_lint_smoke.json ``` @@ -88,6 +91,20 @@ surface for those intake requests. It reports blocked or pending owner-decision states and preserves empty Ontology decision refs until real owner evidence is available; it does not close semantic gates or mutate canonical specs. +`runs/ontology_review_dashboard.json` is the richer SpecGraph/SpecSpace +projection over semantic review, gate, intake, and closed-loop evidence. It is +read-only review material and does not import owner decisions. + +`runs/ontology_owner_decision_report.json` carries typed Ontology owner decision +evidence only when those decisions match pending closed-loop owner-review +evidence. Blocked, stale, or unmatched owner-decision inputs remain ignored +diagnostics instead of becoming importable evidence. + +`runs/ontology_decision_import_preview.json` joins the review dashboard with the +owner decision report. It shows no-decision, blocked, ready, rejected, +clarification, or unmatched preview states, preserves ignored owner-decision +diagnostics, and does not apply imports or mutate canonical specs. + ## Boundary The adapter report must not: diff --git a/tests/test_ontology_import_policy.py b/tests/test_ontology_import_policy.py index 7a284dd..c6b4ea9 100644 --- a/tests/test_ontology_import_policy.py +++ b/tests/test_ontology_import_policy.py @@ -1902,6 +1902,112 @@ def test_ontology_decision_import_preview_rejects_source_import_authority() -> N ) +def test_ontology_decision_import_preview_rejects_source_artifact_mismatch() -> None: + module = load_ontology_imports_module() + semantic_policy = json.loads( + (ROOT / "tools" / "ontology_semantic_control_policy.json").read_text() + ) + surfaces = module.build_ontology_import_surfaces(FIXTURE) + owner_decision_report = json.loads(json.dumps(surfaces["ontology_owner_decision_report"])) + owner_decision_report["source_artifacts"]["ontology_closed_loop_evidence"] = ( + "runs/other_closed_loop_evidence.json" + ) + + with pytest.raises(ValueError, match="ontology_closed_loop_evidence"): + module.build_ontology_decision_import_preview( + semantic_policy, + semantic_policy_path=ROOT / "tools" / "ontology_semantic_control_policy.json", + dashboard=surfaces["ontology_review_dashboard"], + owner_decision_report=owner_decision_report, + ) + + +def test_ontology_decision_import_preview_rejects_ready_non_accepted_decision() -> None: + module = load_ontology_imports_module() + surfaces = module.build_ontology_import_surfaces(FIXTURE) + preview = json.loads(json.dumps(surfaces["ontology_decision_import_preview"])) + preview["summary"]["status"] = "ready_for_operator_review" + preview["summary"]["preview_count"] = 1 + preview["summary"]["rejected_count"] = 1 + preview["summary"]["importable_count"] = 1 + preview["decision_import_previews"] = [ + { + "preview_id": "ontology-decision-import-preview-rejected-ready", + "decision_id": "ontology-owner-decision-reject-legacyterm", + "candidate_id": "ontology-delta-candidate-examcalc-legacyterm", + "intake_id": ( + "ontology-delta-draft-intake-ontology-delta-candidate-examcalc-legacyterm" + ), + "decision_state": "rejected", + "ontology_decision_ref": ( + "ontology-decision://edu.university.examcalc/0.1.0/legacyterm/rejected" + ), + "decided_by": "ontology-owner", + "decided_at": "2026-06-13T00:00:00Z", + "reason": "", + "accepted_ontology_delta": False, + "matched_closed_loop_evidence_id": ( + "ontology-closed-loop-evidence-ontology-delta-candidate-examcalc-legacyterm" + ), + "matched_source_intake_state": "awaiting_ontology_owner_review", + "matched_evidence_state": "pending_ontology_owner_decision", + "preview_state": "ready_for_operator_review", + "required_human_action": "operator_review_ontology_owner_decision", + "import_recommended": True, + "imports_into_specgraph": False, + "closes_semantic_gate": False, + "mutates_canonical_specs": False, + "writes_ontology_package": False, + "updates_ontology_lockfile": False, + } + ] + + with pytest.raises(ValueError, match="accepted decision"): + module.require_ontology_decision_import_preview(preview) + + +def test_ontology_decision_import_preview_rejects_ready_without_evidence_match() -> None: + module = load_ontology_imports_module() + surfaces = module.build_ontology_import_surfaces(FIXTURE) + preview = json.loads(json.dumps(surfaces["ontology_decision_import_preview"])) + preview["summary"]["status"] = "ready_for_operator_review" + preview["summary"]["preview_count"] = 1 + preview["summary"]["accepted_count"] = 1 + preview["summary"]["importable_count"] = 1 + preview["decision_import_previews"] = [ + { + "preview_id": "ontology-decision-import-preview-accepted-ready", + "decision_id": "ontology-owner-decision-accept-casfunction", + "candidate_id": "ontology-delta-candidate-examcalc-casfunction", + "intake_id": ( + "ontology-delta-draft-intake-ontology-delta-candidate-examcalc-casfunction" + ), + "decision_state": "accepted", + "ontology_decision_ref": ( + "ontology-decision://edu.university.examcalc/0.1.0/casfunction/accepted" + ), + "decided_by": "ontology-owner", + "decided_at": "2026-06-13T00:00:00Z", + "reason": "", + "accepted_ontology_delta": True, + "matched_closed_loop_evidence_id": "", + "matched_source_intake_state": "awaiting_ontology_owner_review", + "matched_evidence_state": "pending_ontology_owner_decision", + "preview_state": "ready_for_operator_review", + "required_human_action": "operator_review_ontology_owner_decision", + "import_recommended": True, + "imports_into_specgraph": False, + "closes_semantic_gate": False, + "mutates_canonical_specs": False, + "writes_ontology_package": False, + "updates_ontology_lockfile": False, + } + ] + + with pytest.raises(ValueError, match="matched_closed_loop_evidence_id"): + module.require_ontology_decision_import_preview(preview) + + def test_ontology_owner_decision_report_requires_decision_identity_fields() -> None: module = load_ontology_imports_module() surfaces = module.build_ontology_import_surfaces(FIXTURE) diff --git a/tools/ontology_imports.py b/tools/ontology_imports.py index a62608c..dae0a15 100755 --- a/tools/ontology_imports.py +++ b/tools/ontology_imports.py @@ -4607,6 +4607,16 @@ def build_ontology_decision_import_preview( owner_decision_report_artifact = require_surface_output_artifact( owner_decision_report, "ontology_owner_decision_report" ) + dashboard_sources = require_object(dashboard, "source_artifacts", "dashboard") + owner_decision_sources = require_object( + owner_decision_report, "source_artifacts", "owner_decision_report" + ) + for key in ("ontology_closed_loop_evidence",): + if dashboard_sources.get(key) != owner_decision_sources.get(key): + raise ValueError( + "owner_decision_report.source_artifacts." + f"{key} must match dashboard.source_artifacts.{key}" + ) dashboard_summary = require_object(dashboard, "status_summary", "dashboard") dashboard_status = require_string(dashboard_summary, "status", "dashboard.status_summary") preview_states = set( @@ -4759,7 +4769,7 @@ def build_ontology_decision_import_preview( else: status = "rejected_by_owner" - source_artifacts = copy_json_object(require_object(dashboard, "source_artifacts", "dashboard")) + source_artifacts = copy_json_object(dashboard_sources) source_artifacts["ontology_review_dashboard"] = dashboard_artifact source_artifacts["ontology_owner_decision_report"] = owner_decision_report_artifact @@ -4863,6 +4873,7 @@ def require_ontology_decision_import_preview( decision_state = require_string(raw_entry, "decision_state", context) if decision_state not in {"accepted", "rejected", "needs_clarification"}: raise ValueError(f"{context}.decision_state must be supported") + accepted_ontology_delta = require_bool(raw_entry, "accepted_ontology_delta", context) preview_state = require_string(raw_entry, "preview_state", context) if preview_state not in supported_states - {"no_decisions"}: raise ValueError(f"{context}.preview_state must be supported") @@ -4871,6 +4882,17 @@ def require_ontology_decision_import_preview( raise ValueError( f"{context}.import_recommended must match ready_for_operator_review state" ) + if preview_state == "ready_for_operator_review": + if decision_state != "accepted" or accepted_ontology_delta is not True: + raise ValueError( + f"{context}.ready_for_operator_review requires an accepted decision" + ) + for field in ( + "matched_closed_loop_evidence_id", + "matched_source_intake_state", + "matched_evidence_state", + ): + require_string(raw_entry, field, context) for field in ( "imports_into_specgraph", "closes_semantic_gate",