From 01378d73eadd1e9cd9b00891b4ebdf7c976ffc7a Mon Sep 17 00:00:00 2001 From: Egor Merkushev Date: Sat, 13 Jun 2026 18:47:16 +0300 Subject: [PATCH 1/2] Add ontology owner decision contract --- .../0114_ontology_owner_decision_contract.md | 38 ++ .../0114_ontology_owner_decision_contract.md | 118 +++++ docs/supervisor_manual.md | 4 + tests/test_ontology_import_policy.py | 147 ++++++ tools/README.md | 5 +- tools/ontology_imports.py | 484 +++++++++++++++++- tools/ontology_semantic_control_policy.json | 76 ++- tools/proposal_promotion_registry.json | 13 + tools/proposal_runtime_registry.json | 65 +++ 9 files changed, 945 insertions(+), 5 deletions(-) create mode 100644 docs/archive/proposal_sources/0114_ontology_owner_decision_contract.md create mode 100644 docs/proposals/0114_ontology_owner_decision_contract.md diff --git a/docs/archive/proposal_sources/0114_ontology_owner_decision_contract.md b/docs/archive/proposal_sources/0114_ontology_owner_decision_contract.md new file mode 100644 index 0000000..71837f6 --- /dev/null +++ b/docs/archive/proposal_sources/0114_ontology_owner_decision_contract.md @@ -0,0 +1,38 @@ +# Ontology Owner Decision Contract + +Source artifact class: working draft + +## Motivating concern + +SpecGraph can display closed-loop Ontology evidence, but it does not yet have a +typed report contract for the accepted/rejected decisions that Ontology owners +will return for reviewed delta candidates. + +## Bounded scope + +Add a deterministic `ontology_owner_decision_report` artifact under `runs/` that +models Ontology owner decisions as read-only evidence. The report carries +decision ids, candidate ids, intake ids, decision state, Ontology decision refs, +decision actor/time, accepted/rejected state, and explicit false SpecGraph +import, gate-close, and canonical mutation flags. + +This slice must not 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 owner decision report layout and contract in + `tools/ontology_semantic_control_policy.json`. +- Build `runs/ontology_owner_decision_report.json` from a typed owner-decision + fixture and existing closed-loop evidence source refs. +- Validate accepted/rejected/clarification states and reject import authority. +- Cover artifact shape, write path, authority rejection, and `make + ontology-imports` output in focused tests. +- Register proposal `0114` in promotion and runtime registries. + +## Next gap + +```text +build_ontology_decision_import_preview +``` diff --git a/docs/proposals/0114_ontology_owner_decision_contract.md b/docs/proposals/0114_ontology_owner_decision_contract.md new file mode 100644 index 0000000..7b5d689 --- /dev/null +++ b/docs/proposals/0114_ontology_owner_decision_contract.md @@ -0,0 +1,118 @@ +# Ontology Owner Decision Contract + +RFC: SG-RFC-0114 +Version: 0.1.0 + +## Status + +Implemented + +Decision scope: typed read-only owner decision report for accepted/rejected +Ontology owner decisions. + +This document does not import owner 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 +`0113_ontology_review_dashboard`. + +Source draft: + +- `docs/archive/proposal_sources/0114_ontology_owner_decision_contract.md` + +## Summary + +SpecGraph now emits a deterministic owner decision report artifact: + +```text +runs/ontology_owner_decision_report.json +``` + +The artifact provides a typed contract for Ontology-supplied accepted/rejected +decisions while keeping those decisions as review evidence only. A later import +preview slice decides how those decisions would affect SpecGraph. + +## Goals + +- Add `ontology_owner_decision_report` to the semantic policy layout. +- Define accepted, rejected, and clarification decision states. +- Preserve Ontology decision refs, candidate ids, intake ids, decision actor, + decision time, and accepted-delta status. +- Reject SpecGraph import, semantic gate closure, canonical mutation, prompt + execution, and Ontology package/lockfile write authority. +- Cover report shape, write path, 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 report artifact declares: + +```json +{ + "artifact_kind": "ontology_owner_decision_report", + "schema_version": 1, + "proposal_id": "0114", + "source_artifacts": { + "ontology_closed_loop_evidence": "runs/ontology_closed_loop_evidence.json" + }, + "canonical_mutations_allowed": false, + "tracked_artifacts_written": false, + "decisions": [] +} +``` + +Each decision records: + +- decision id; +- candidate id; +- intake id; +- decision state; +- Ontology decision ref; +- decision actor and timestamp; +- accepted-delta flag; +- explicit false SpecGraph import, gate-close, and canonical mutation flags. + +## Authority Boundary + +The report may be used as owner-decision evidence by later import previews. + +The report may not: + +- 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_owner_decision_report`; +- `tools/ontology_imports.py` builds and validates the owner decision report; +- `make ontology-imports` writes `runs/ontology_owner_decision_report.json`; +- focused tests cover accepted/rejected report shape, write path, and authority + boundary; +- proposal `0114` is tracked in promotion and runtime registries; +- proposal gates, DocC sync, and focused Python tests pass. + +## Next Gap + +```text +build_ontology_decision_import_preview +``` diff --git a/docs/supervisor_manual.md b/docs/supervisor_manual.md index 61832bf..de81ac6 100644 --- a/docs/supervisor_manual.md +++ b/docs/supervisor_manual.md @@ -1646,6 +1646,10 @@ authoritative. - richer read-only SpecGraph/SpecSpace dashboard projection combining the semantic review surface, supervisor semantic gate, delta draft intake, and closed-loop evidence without importing owner decisions or mutating specs +- `runs/ontology_owner_decision_report.json` + - typed read-only Ontology owner decision report carrying accepted/rejected + decision evidence for later import previews without closing gates 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 cb981f5..ba90cac 100644 --- a/tests/test_ontology_import_policy.py +++ b/tests/test_ontology_import_policy.py @@ -142,6 +142,9 @@ def test_ontology_semantic_control_policy_defines_review_only_contract() -> None assert policy["repository_layout"]["ontology_review_dashboard"] == ( "runs/ontology_review_dashboard.json" ) + assert policy["repository_layout"]["ontology_owner_decision_report"] == ( + "runs/ontology_owner_decision_report.json" + ) assert policy["repository_layout"]["ontology_delta_candidate_review_packet"] == ( "runs/ontology_delta_candidate_review_packet.json" ) @@ -355,6 +358,49 @@ def test_ontology_semantic_control_policy_defines_review_only_contract() -> None assert dashboard_contract["consumer_boundary"]["may_mark_candidate_accepted"] is False assert dashboard_contract["consumer_boundary"]["may_import_owner_decision"] is False assert dashboard_contract["consumer_boundary"]["may_close_semantic_gate"] is False + owner_decision_contract = policy["ontology_owner_decision_report_contract"] + assert owner_decision_contract["artifact_kind"] == "ontology_owner_decision_report" + assert owner_decision_contract["source_closed_loop_evidence_artifact_kind"] == ( + "ontology_closed_loop_evidence" + ) + assert owner_decision_contract["target"] == { + "target_kind": "proposal", + "target_ref": "SG-RFC-0114", + } + assert { + "accepted", + "rejected", + "needs_clarification", + }.issubset(set(owner_decision_contract["decision_states"])) + assert { + "decision_id", + "candidate_id", + "intake_id", + "decision_state", + "ontology_decision_ref", + "accepted_ontology_delta", + "imports_into_specgraph", + "closes_semantic_gate", + "mutates_canonical_specs", + }.issubset(set(owner_decision_contract["required_decision_fields"])) + assert ( + owner_decision_contract["consumer_boundary"]["for_specgraph_decision_import_preview"] + is True + ) + assert owner_decision_contract["consumer_boundary"]["for_specspace_review_dashboard"] is True + assert owner_decision_contract["consumer_boundary"]["may_execute_prompt_agent"] is False + assert owner_decision_contract["consumer_boundary"]["may_write_ontology_package"] is False + assert owner_decision_contract["consumer_boundary"]["may_update_ontology_lockfile"] is False + assert owner_decision_contract["consumer_boundary"]["may_mutate_canonical_specs"] is False + 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 + 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"]} == { + "accepted", + "rejected", + } contract = policy["semantic_lint_contract"] assert contract["smoke_artifact_kind"] == "ontology_semantic_lint_smoke" assert { @@ -372,6 +418,7 @@ def test_ontology_semantic_control_policy_defines_review_only_contract() -> None assert boundary["ontology_delta_draft_intake_is_authority"] is False 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["prompt_agent_execution_allowed"] is False assert boundary["automatic_canonical_node_update"] is False @@ -1091,6 +1138,55 @@ def test_ontology_review_dashboard_builds_rich_review_projection() -> None: assert dashboard["authority_boundary"]["ontology_review_dashboard_is_authority"] is False +def test_ontology_owner_decision_report_builds_read_only_decision_contract() -> None: + module = load_ontology_imports_module() + + surfaces = module.build_ontology_import_surfaces(FIXTURE) + + report = surfaces["ontology_owner_decision_report"] + assert report["artifact_kind"] == "ontology_owner_decision_report" + assert report["proposal_id"] == "0114" + assert report["target"] == { + "target_kind": "proposal", + "target_ref": "SG-RFC-0114", + } + assert report["source_artifacts"]["ontology_closed_loop_evidence"] == ( + "runs/ontology_closed_loop_evidence.json" + ) + assert report["canonical_mutations_allowed"] is False + assert report["tracked_artifacts_written"] is False + assert report["summary"] == { + "status": "decisions_available", + "decision_count": 2, + "accepted_count": 1, + "rejected_count": 1, + "clarification_count": 0, + "next_gap": "build_ontology_decision_import_preview", + } + + decisions = {entry["decision_id"]: entry for entry in report["decisions"]} + accepted = decisions["ontology-owner-decision-accept-casfunction"] + assert accepted["candidate_id"] == "ontology-delta-candidate-examcalc-casfunction" + assert accepted["decision_state"] == "accepted" + assert accepted["accepted_ontology_delta"] is True + assert accepted["imports_into_specgraph"] is False + assert accepted["closes_semantic_gate"] is False + assert accepted["mutates_canonical_specs"] is False + rejected = decisions["ontology-owner-decision-reject-legacyterm"] + assert rejected["decision_state"] == "rejected" + assert rejected["accepted_ontology_delta"] is False + assert report["consumer_boundary"]["for_specgraph_decision_import_preview"] is True + assert report["consumer_boundary"]["for_specspace_review_dashboard"] is True + assert report["consumer_boundary"]["may_execute_prompt_agent"] is False + assert report["consumer_boundary"]["may_write_ontology_package"] is False + assert report["consumer_boundary"]["may_update_ontology_lockfile"] is False + assert report["consumer_boundary"]["may_mutate_canonical_specs"] is False + assert report["consumer_boundary"]["may_mark_candidate_accepted"] is False + assert report["consumer_boundary"]["may_import_into_specgraph"] is False + assert report["consumer_boundary"]["may_close_semantic_gate"] is False + assert report["authority_boundary"]["ontology_owner_decision_report_is_authority"] is False + + def test_ontology_semantic_review_surface_rejects_authority_expansion( tmp_path: Path, ) -> None: @@ -1349,6 +1445,50 @@ def test_ontology_review_dashboard_rejects_source_decision_authority() -> None: ) +def test_ontology_owner_decision_report_rejects_policy_import_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_owner_decision_report_contract"]["consumer_boundary"][ + "may_import_into_specgraph" + ] = True + semantic_policy_path = write_temp_semantic_control_policy(tmp_path, semantic_policy) + + with pytest.raises(ValueError, match="may_import_into_specgraph"): + module.build_ontology_import_surfaces( + fixture_path, + policy_path=policy_path, + semantic_policy_path=semantic_policy_path, + ) + + +def test_ontology_owner_decision_report_rejects_inconsistent_accepted_flag( + 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["owner_decision_fixture"]["decisions"][1]["accepted_ontology_delta"] = True + semantic_policy_path = write_temp_semantic_control_policy(tmp_path, semantic_policy) + + with pytest.raises(ValueError, match="accepted_ontology_delta"): + module.build_ontology_import_surfaces( + fixture_path, + policy_path=policy_path, + semantic_policy_path=semantic_policy_path, + ) + + def test_ontology_delta_candidate_review_packet_uses_policy_review_action_order( tmp_path: Path, ) -> None: @@ -1455,6 +1595,9 @@ def test_ontology_semantic_write_uses_surface_output_artifact(tmp_path: Path) -> semantic_policy["repository_layout"]["ontology_review_dashboard"] = ( "runs/custom_ontology_review_dashboard.json" ) + semantic_policy["repository_layout"]["ontology_owner_decision_report"] = ( + "runs/custom_ontology_owner_decision_report.json" + ) semantic_policy["repository_layout"]["semantic_lint_smoke"] = ( "runs/custom_semantic_lint_smoke.json" ) @@ -1491,6 +1634,7 @@ def test_ontology_semantic_write_uses_surface_output_artifact(tmp_path: Path) -> assert "runs/custom_delta_draft_intake.json" in written_paths 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_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() @@ -1500,6 +1644,7 @@ def test_ontology_semantic_write_uses_surface_output_artifact(tmp_path: Path) -> assert not (tmp_path / "runs" / "ontology_delta_draft_intake.json").exists() 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_semantic_lint_smoke.json").exists() @@ -1670,6 +1815,7 @@ def test_make_ontology_imports_writes_declared_surfaces() -> None: "runs/ontology_delta_draft_intake.json": "ontology_delta_draft_intake", "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_semantic_lint_smoke.json": "ontology_semantic_lint_smoke", } for relative_path, artifact_kind in expected.items(): @@ -1684,6 +1830,7 @@ def test_make_ontology_imports_writes_declared_surfaces() -> None: "ontology_delta_draft_intake": "0110", "ontology_closed_loop_evidence": "0111", "ontology_review_dashboard": "0113", + "ontology_owner_decision_report": "0114", "ontology_semantic_lint_smoke": "0103", }.get(artifact_kind, "0060") assert payload["proposal_id"] == expected_proposal_id diff --git a/tools/README.md b/tools/README.md index c630fa3..21c1c99 100644 --- a/tools/README.md +++ b/tools/README.md @@ -137,7 +137,10 @@ Supervisor modes: loop surface for those intake requests, `runs/ontology_review_dashboard.json` as the richer read-only SpecGraph/SpecSpace dashboard projection over semantic review, gate, intake, - and closed-loop evidence, plus + 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, + plus `runs/ontology_semantic_lint_smoke.json`, classifying accepted, alias, unknown, deprecated, and relation-conflict terms against the imported ontology fixture. These surfaces resolve known imported diff --git a/tools/ontology_imports.py b/tools/ontology_imports.py index deccbde..4ff75ff 100755 --- a/tools/ontology_imports.py +++ b/tools/ontology_imports.py @@ -644,6 +644,7 @@ def require_semantic_control_policy(policy: dict[str, Any]) -> dict[str, Any]: require_layout_path(layout, "ontology_delta_draft_intake") 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, "semantic_lint_smoke") boundary = require_object(policy, "authority_boundary", "semantic_control_policy") for field in ( @@ -656,6 +657,7 @@ def require_semantic_control_policy(policy: dict[str, Any]) -> dict[str, Any]: "ontology_delta_draft_intake_is_authority", "ontology_closed_loop_evidence_is_authority", "ontology_review_dashboard_is_authority", + "ontology_owner_decision_report_is_authority", "prompt_agent_execution_allowed", "automatic_import_lock_update", "automatic_canonical_node_update", @@ -1730,9 +1732,205 @@ def require_semantic_control_policy(policy: dict[str, Any]) -> dict[str, Any]: "next_gap", "semantic_control_policy.ontology_review_dashboard_contract", ) - require_layout_path( - require_object(policy, "repository_layout", "semantic_control_policy"), - "ontology_review_dashboard", + owner_decision_contract = require_object( + policy, "ontology_owner_decision_report_contract", "semantic_control_policy" + ) + if ( + require_string( + owner_decision_contract, + "artifact_kind", + "semantic_control_policy.ontology_owner_decision_report_contract", + ) + != "ontology_owner_decision_report" + ): + raise ValueError( + "semantic_control_policy.ontology_owner_decision_report_contract.artifact_kind " + "must be ontology_owner_decision_report" + ) + if ( + require_string( + owner_decision_contract, + "source_closed_loop_evidence_artifact_kind", + "semantic_control_policy.ontology_owner_decision_report_contract", + ) + != "ontology_closed_loop_evidence" + ): + raise ValueError( + "semantic_control_policy.ontology_owner_decision_report_contract." + "source_closed_loop_evidence_artifact_kind must be ontology_closed_loop_evidence" + ) + owner_decision_target = require_object( + owner_decision_contract, + "target", + "semantic_control_policy.ontology_owner_decision_report_contract", + ) + require_string( + owner_decision_target, + "target_kind", + "semantic_control_policy.ontology_owner_decision_report_contract.target", + ) + require_string( + owner_decision_target, + "target_ref", + "semantic_control_policy.ontology_owner_decision_report_contract.target", + ) + decision_states = set( + require_string_list( + owner_decision_contract, + "decision_states", + "semantic_control_policy.ontology_owner_decision_report_contract", + ) + ) + required_decision_states = {"accepted", "rejected", "needs_clarification"} + missing_decision_states = sorted(required_decision_states - decision_states) + if missing_decision_states: + raise ValueError( + "semantic_control_policy.ontology_owner_decision_report_contract.decision_states " + f"missing: {', '.join(missing_decision_states)}" + ) + required_decision_fields = set( + require_string_list( + owner_decision_contract, + "required_decision_fields", + "semantic_control_policy.ontology_owner_decision_report_contract", + ) + ) + expected_decision_fields = { + "decision_id", + "candidate_id", + "intake_id", + "decision_state", + "ontology_decision_ref", + "decided_by", + "decided_at", + "accepted_ontology_delta", + "imports_into_specgraph", + "closes_semantic_gate", + "mutates_canonical_specs", + } + missing_decision_fields = sorted(expected_decision_fields - required_decision_fields) + if missing_decision_fields: + raise ValueError( + "semantic_control_policy.ontology_owner_decision_report_contract." + f"required_decision_fields missing: {', '.join(missing_decision_fields)}" + ) + owner_decision_consumer_boundary = require_object( + owner_decision_contract, + "consumer_boundary", + "semantic_control_policy.ontology_owner_decision_report_contract", + ) + for field in ("for_specgraph_decision_import_preview", "for_specspace_review_dashboard"): + if ( + require_bool( + owner_decision_consumer_boundary, + field, + "semantic_control_policy.ontology_owner_decision_report_contract.consumer_boundary", + ) + is not True + ): + raise ValueError( + "semantic_control_policy.ontology_owner_decision_report_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_import_into_specgraph", + "may_close_semantic_gate", + ): + if ( + require_bool( + owner_decision_consumer_boundary, + field, + "semantic_control_policy.ontology_owner_decision_report_contract.consumer_boundary", + ) + is not False + ): + raise ValueError( + "semantic_control_policy.ontology_owner_decision_report_contract." + f"consumer_boundary.{field} must be false" + ) + owner_decision_fixture = require_object( + policy, "owner_decision_fixture", "semantic_control_policy" + ) + if ( + require_string( + owner_decision_fixture, + "artifact_kind", + "semantic_control_policy.owner_decision_fixture", + ) + != "ontology_owner_decision_fixture" + ): + raise ValueError( + "semantic_control_policy.owner_decision_fixture.artifact_kind must be " + "ontology_owner_decision_fixture" + ) + owner_fixture_decisions = owner_decision_fixture.get("decisions") + if not isinstance(owner_fixture_decisions, list): + raise ValueError("semantic_control_policy.owner_decision_fixture.decisions must be a list") + for index, raw_decision in enumerate(owner_fixture_decisions): + if not isinstance(raw_decision, dict): + raise ValueError( + f"semantic_control_policy.owner_decision_fixture.decisions[{index}] " + "must be an object" + ) + for field in sorted(required_decision_fields): + if field in { + "accepted_ontology_delta", + "imports_into_specgraph", + "closes_semantic_gate", + "mutates_canonical_specs", + }: + require_bool( + raw_decision, + field, + f"semantic_control_policy.owner_decision_fixture.decisions[{index}]", + ) + else: + require_string( + raw_decision, + field, + f"semantic_control_policy.owner_decision_fixture.decisions[{index}]", + ) + decision_state = require_string( + raw_decision, + "decision_state", + f"semantic_control_policy.owner_decision_fixture.decisions[{index}]", + ) + if decision_state not in decision_states: + raise ValueError( + "semantic_control_policy.owner_decision_fixture.decisions" + f"[{index}].decision_state must be declared by decision_states" + ) + if require_bool( + raw_decision, + "accepted_ontology_delta", + f"semantic_control_policy.owner_decision_fixture.decisions[{index}]", + ) != (decision_state == "accepted"): + raise ValueError( + "semantic_control_policy.owner_decision_fixture.decisions" + f"[{index}].accepted_ontology_delta must match accepted decision_state" + ) + for field in ("imports_into_specgraph", "closes_semantic_gate", "mutates_canonical_specs"): + if ( + require_bool( + raw_decision, + field, + f"semantic_control_policy.owner_decision_fixture.decisions[{index}]", + ) + is not False + ): + raise ValueError( + "semantic_control_policy.owner_decision_fixture.decisions" + f"[{index}].{field} must be false" + ) + require_string( + owner_decision_contract, + "next_gap", + "semantic_control_policy.ontology_owner_decision_report_contract", ) return policy @@ -3887,6 +4085,277 @@ def require_ontology_review_dashboard( return dashboard +def build_ontology_owner_decision_report( + semantic_policy: dict[str, Any], + *, + semantic_policy_path: Path, + closed_loop_evidence: dict[str, Any], +) -> dict[str, Any]: + require_semantic_control_policy(semantic_policy) + require_ontology_closed_loop_evidence(closed_loop_evidence) + + owner_decision_contract = require_object( + semantic_policy, "ontology_owner_decision_report_contract", "semantic_control_policy" + ) + semantic_layout = require_object( + semantic_policy, "repository_layout", "semantic_control_policy" + ) + owner_decision_fixture = require_object( + semantic_policy, "owner_decision_fixture", "semantic_control_policy" + ) + raw_decisions = owner_decision_fixture.get("decisions") + if not isinstance(raw_decisions, list): + raise ValueError("semantic_control_policy.owner_decision_fixture.decisions must be a list") + + decision_states = set( + require_string_list( + owner_decision_contract, + "decision_states", + "semantic_control_policy.ontology_owner_decision_report_contract", + ) + ) + decisions: list[dict[str, Any]] = [] + for index, raw_decision in enumerate(raw_decisions): + if not isinstance(raw_decision, dict): + raise ValueError( + f"semantic_control_policy.owner_decision_fixture.decisions[{index}] " + "must be an object" + ) + decision_state = require_string( + raw_decision, + "decision_state", + f"semantic_control_policy.owner_decision_fixture.decisions[{index}]", + ) + if decision_state not in decision_states: + raise ValueError( + "semantic_control_policy.owner_decision_fixture.decisions" + f"[{index}].decision_state must be declared by decision_states" + ) + accepted_ontology_delta = require_bool( + raw_decision, + "accepted_ontology_delta", + f"semantic_control_policy.owner_decision_fixture.decisions[{index}]", + ) + if accepted_ontology_delta != (decision_state == "accepted"): + raise ValueError( + "semantic_control_policy.owner_decision_fixture.decisions" + f"[{index}].accepted_ontology_delta must match accepted decision_state" + ) + decision = { + "decision_id": require_string( + raw_decision, + "decision_id", + f"semantic_control_policy.owner_decision_fixture.decisions[{index}]", + ), + "candidate_id": require_string( + raw_decision, + "candidate_id", + f"semantic_control_policy.owner_decision_fixture.decisions[{index}]", + ), + "intake_id": require_string( + raw_decision, + "intake_id", + f"semantic_control_policy.owner_decision_fixture.decisions[{index}]", + ), + "decision_state": decision_state, + "ontology_decision_ref": require_string( + raw_decision, + "ontology_decision_ref", + f"semantic_control_policy.owner_decision_fixture.decisions[{index}]", + ), + "decided_by": require_string( + raw_decision, + "decided_by", + f"semantic_control_policy.owner_decision_fixture.decisions[{index}]", + ), + "decided_at": require_string( + raw_decision, + "decided_at", + f"semantic_control_policy.owner_decision_fixture.decisions[{index}]", + ), + "reason": optional_string( + raw_decision, + "reason", + f"semantic_control_policy.owner_decision_fixture.decisions[{index}]", + ), + "accepted_ontology_delta": accepted_ontology_delta, + "imports_into_specgraph": require_bool( + raw_decision, + "imports_into_specgraph", + f"semantic_control_policy.owner_decision_fixture.decisions[{index}]", + ), + "closes_semantic_gate": require_bool( + raw_decision, + "closes_semantic_gate", + f"semantic_control_policy.owner_decision_fixture.decisions[{index}]", + ), + "mutates_canonical_specs": require_bool( + raw_decision, + "mutates_canonical_specs", + f"semantic_control_policy.owner_decision_fixture.decisions[{index}]", + ), + } + for field in ("imports_into_specgraph", "closes_semantic_gate", "mutates_canonical_specs"): + if decision[field] is not False: + raise ValueError( + "semantic_control_policy.owner_decision_fixture.decisions" + f"[{index}].{field} must be false" + ) + decisions.append(decision) + + source_artifacts = copy_json_object( + require_object(closed_loop_evidence, "source_artifacts", "closed_loop_evidence") + ) + source_artifacts["ontology_closed_loop_evidence"] = require_surface_output_artifact( + closed_loop_evidence, "ontology_closed_loop_evidence" + ) + accepted_count = sum(1 for decision in decisions if decision["decision_state"] == "accepted") + rejected_count = sum(1 for decision in decisions if decision["decision_state"] == "rejected") + clarification_count = sum( + 1 for decision in decisions if decision["decision_state"] == "needs_clarification" + ) + status = "decisions_available" if decisions else "no_decisions" + return { + "artifact_kind": require_string( + owner_decision_contract, + "artifact_kind", + "semantic_control_policy.ontology_owner_decision_report_contract", + ), + "schema_version": 1, + "proposal_id": "0114", + "policy_basis": semantic_policy["policy_basis"], + "source_policy": relative_path(semantic_policy_path), + "source_artifacts": source_artifacts, + "target": copy_json_object( + require_object( + owner_decision_contract, + "target", + "semantic_control_policy.ontology_owner_decision_report_contract", + ) + ), + "canonical_mutations_allowed": False, + "tracked_artifacts_written": False, + "decisions": decisions, + "consumer_boundary": copy_json_object( + require_object( + owner_decision_contract, + "consumer_boundary", + "semantic_control_policy.ontology_owner_decision_report_contract", + ) + ), + "authority_boundary": copy_json_object( + require_object(semantic_policy, "authority_boundary", "semantic_control_policy") + ), + "summary": { + "status": status, + "decision_count": len(decisions), + "accepted_count": accepted_count, + "rejected_count": rejected_count, + "clarification_count": clarification_count, + "next_gap": require_string( + owner_decision_contract, + "next_gap", + "semantic_control_policy.ontology_owner_decision_report_contract", + ), + }, + "output_artifact": require_layout_path(semantic_layout, "ontology_owner_decision_report"), + } + + +def require_ontology_owner_decision_report( + owner_decision_report: dict[str, Any], +) -> dict[str, Any]: + if owner_decision_report.get("artifact_kind") != "ontology_owner_decision_report": + raise ValueError( + "owner_decision_report.artifact_kind must be ontology_owner_decision_report" + ) + if require_int(owner_decision_report, "schema_version", "owner_decision_report") != 1: + raise ValueError("owner_decision_report.schema_version must be 1") + if require_string(owner_decision_report, "proposal_id", "owner_decision_report") != "0114": + raise ValueError("owner_decision_report.proposal_id must be 0114") + if ( + require_bool(owner_decision_report, "canonical_mutations_allowed", "owner_decision_report") + is not False + ): + raise ValueError("owner_decision_report.canonical_mutations_allowed must be false") + if ( + require_bool(owner_decision_report, "tracked_artifacts_written", "owner_decision_report") + is not False + ): + raise ValueError("owner_decision_report.tracked_artifacts_written must be false") + require_surface_output_artifact(owner_decision_report, "ontology_owner_decision_report") + require_object(owner_decision_report, "source_artifacts", "owner_decision_report") + require_object(owner_decision_report, "summary", "owner_decision_report") + decisions = owner_decision_report.get("decisions") + if not isinstance(decisions, list): + raise ValueError("owner_decision_report.decisions must be a list") + for index, raw_decision in enumerate(decisions): + if not isinstance(raw_decision, dict): + raise ValueError(f"owner_decision_report.decisions[{index}] must be an object") + decision_state = require_string( + raw_decision, "decision_state", f"owner_decision_report.decisions[{index}]" + ) + if decision_state not in {"accepted", "rejected", "needs_clarification"}: + raise ValueError( + f"owner_decision_report.decisions[{index}].decision_state must be supported" + ) + accepted_ontology_delta = require_bool( + raw_decision, + "accepted_ontology_delta", + f"owner_decision_report.decisions[{index}]", + ) + if accepted_ontology_delta != (decision_state == "accepted"): + raise ValueError( + "owner_decision_report.decisions" + f"[{index}].accepted_ontology_delta must match accepted decision_state" + ) + for field in ("imports_into_specgraph", "closes_semantic_gate", "mutates_canonical_specs"): + if ( + require_bool(raw_decision, field, f"owner_decision_report.decisions[{index}]") + is not False + ): + raise ValueError(f"owner_decision_report.decisions[{index}].{field} must be false") + consumer_boundary = require_object( + owner_decision_report, "consumer_boundary", "owner_decision_report" + ) + for field in ("for_specgraph_decision_import_preview", "for_specspace_review_dashboard"): + if ( + require_bool(consumer_boundary, field, "owner_decision_report.consumer_boundary") + is not True + ): + raise ValueError(f"owner_decision_report.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_import_into_specgraph", + "may_close_semantic_gate", + ): + if ( + require_bool(consumer_boundary, field, "owner_decision_report.consumer_boundary") + is not False + ): + raise ValueError(f"owner_decision_report.consumer_boundary.{field} must be false") + authority_boundary = require_object( + owner_decision_report, "authority_boundary", "owner_decision_report" + ) + for field in ( + "ontology_owner_decision_report_is_authority", + "prompt_agent_execution_allowed", + "automatic_import_lock_update", + "automatic_canonical_node_update", + "canonical_mutations_allowed", + ): + if ( + require_bool(authority_boundary, field, "owner_decision_report.authority_boundary") + is not False + ): + raise ValueError(f"owner_decision_report.authority_boundary.{field} must be false") + return owner_decision_report + + def build_ontology_semantic_lint_smoke( semantic_policy: dict[str, Any], *, @@ -4235,6 +4704,11 @@ def build_ontology_import_surfaces( draft_intake=surfaces["ontology_delta_draft_intake"], closed_loop_evidence=surfaces["ontology_closed_loop_evidence"], ) + surfaces["ontology_owner_decision_report"] = build_ontology_owner_decision_report( + semantic_policy, + semantic_policy_path=semantic_policy_path, + closed_loop_evidence=surfaces["ontology_closed_loop_evidence"], + ) surfaces["semantic_lint_smoke"] = build_ontology_semantic_lint_smoke( semantic_policy, semantic_policy_path=semantic_policy_path, @@ -4300,6 +4774,10 @@ def write_ontology_import_surfaces( destinations["ontology_review_dashboard"] = require_surface_output_artifact( surfaces["ontology_review_dashboard"], "ontology_review_dashboard" ) + if "ontology_owner_decision_report" in surfaces: + destinations["ontology_owner_decision_report"] = require_surface_output_artifact( + surfaces["ontology_owner_decision_report"], "ontology_owner_decision_report" + ) 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 1f26d87..533155e 100644 --- a/tools/ontology_semantic_control_policy.json +++ b/tools/ontology_semantic_control_policy.json @@ -11,7 +11,8 @@ "docs/proposals/0109_ontology_supervisor_semantic_gate.md", "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/0113_ontology_review_dashboard.md", + "docs/proposals/0114_ontology_owner_decision_contract.md" ], "policy_basis": "docs/proposals/0100_ontology_grounded_semantic_control.md", "repository_layout": { @@ -23,6 +24,7 @@ "ontology_delta_draft_intake": "runs/ontology_delta_draft_intake.json", "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", "semantic_lint_smoke": "runs/ontology_semantic_lint_smoke.json" }, "derived_output_contract": { @@ -320,6 +322,44 @@ }, "next_gap": "build_specspace_rich_ontology_review_panel" }, + "ontology_owner_decision_report_contract": { + "artifact_kind": "ontology_owner_decision_report", + "source_closed_loop_evidence_artifact_kind": "ontology_closed_loop_evidence", + "target": { + "target_kind": "proposal", + "target_ref": "SG-RFC-0114" + }, + "decision_states": [ + "accepted", + "rejected", + "needs_clarification" + ], + "required_decision_fields": [ + "decision_id", + "candidate_id", + "intake_id", + "decision_state", + "ontology_decision_ref", + "decided_by", + "decided_at", + "accepted_ontology_delta", + "imports_into_specgraph", + "closes_semantic_gate", + "mutates_canonical_specs" + ], + "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_import_into_specgraph": false, + "may_close_semantic_gate": false + }, + "next_gap": "build_ontology_decision_import_preview" + }, "semantic_lint_contract": { "future_artifact_kind": "ontology_semantic_lint_report", "smoke_artifact_kind": "ontology_semantic_lint_smoke", @@ -413,6 +453,39 @@ } ] }, + "owner_decision_fixture": { + "artifact_kind": "ontology_owner_decision_fixture", + "decisions": [ + { + "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 as an owner-reviewed package draft candidate for CASFunction.", + "accepted_ontology_delta": true, + "imports_into_specgraph": false, + "closes_semantic_gate": false, + "mutates_canonical_specs": false + }, + { + "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": "Rejected because the term is outside the accepted package vocabulary.", + "accepted_ontology_delta": false, + "imports_into_specgraph": false, + "closes_semantic_gate": false, + "mutates_canonical_specs": false + } + ] + }, "authority_boundary": { "context_pack_is_authority": false, "lint_report_is_authority": false, @@ -423,6 +496,7 @@ "ontology_delta_draft_intake_is_authority": false, "ontology_closed_loop_evidence_is_authority": false, "ontology_review_dashboard_is_authority": false, + "ontology_owner_decision_report_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 7b7356a..d387ebb 100644 --- a/tools/proposal_promotion_registry.json +++ b/tools/proposal_promotion_registry.json @@ -1467,5 +1467,18 @@ "required_provenance_links": [ "source_draft_ref" ] + }, + { + "proposal_id": "0114", + "source_artifact_class": "working_draft", + "source_refs": [ + "docs/archive/proposal_sources/0114_ontology_owner_decision_contract.md" + ], + "motivating_concern": "SpecGraph can display closed-loop Ontology evidence, but it lacks a typed read-only contract for accepted/rejected owner decisions returned by Ontology before any decision import preview can be built safely.", + "normalized_title": "Ontology Owner Decision Contract", + "bounded_scope": "Add a deterministic ontology_owner_decision_report artifact that carries accepted/rejected/clarification owner decisions, decision refs, candidate ids, intake ids, decision actor/time, and explicit false SpecGraph import, semantic-gate closure, and canonical mutation flags under runs/ without 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 3b2b3e8..495be3f 100644 --- a/tools/proposal_runtime_registry.json +++ b/tools/proposal_runtime_registry.json @@ -6184,5 +6184,70 @@ "pattern": "build_specspace_rich_ontology_review_panel" } ] + }, + { + "proposal_id": "0114", + "posture": "bounded_runtime_followup", + "runtime_surfaces": [ + "docs/proposals/0114_ontology_owner_decision_contract.md", + "docs/archive/proposal_sources/0114_ontology_owner_decision_contract.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_owner_decision_report.json" + ], + "runtime_markers": [ + { + "path": "tools/ontology_semantic_control_policy.json", + "pattern": "ontology_owner_decision_report_contract" + }, + { + "path": "tools/ontology_imports.py", + "pattern": "def build_ontology_owner_decision_report(" + }, + { + "path": "tools/ontology_imports.py", + "pattern": "def require_ontology_owner_decision_report(" + }, + { + "path": "tools/ontology_imports.py", + "pattern": "\"ontology_owner_decision_report\"" + } + ], + "validation_markers": [ + { + "path": "tests/test_ontology_import_policy.py", + "pattern": "def test_ontology_owner_decision_report_builds_read_only_decision_contract(" + }, + { + "path": "tests/test_ontology_import_policy.py", + "pattern": "def test_ontology_owner_decision_report_rejects_policy_import_authority(" + }, + { + "path": "tests/test_ontology_import_policy.py", + "pattern": "runs/ontology_owner_decision_report.json" + } + ], + "observation_markers": [ + { + "path": "Makefile", + "pattern": "ontology-imports:" + }, + { + "path": "tools/README.md", + "pattern": "runs/ontology_owner_decision_report.json" + }, + { + "path": "docs/supervisor_manual.md", + "pattern": "runs/ontology_owner_decision_report.json" + }, + { + "path": "docs/proposals/0114_ontology_owner_decision_contract.md", + "pattern": "build_ontology_decision_import_preview" + } + ] } ] From fc8f5ed648670170218391eda70b581f8062f38b Mon Sep 17 00:00:00 2001 From: Egor Merkushev Date: Sat, 13 Jun 2026 19:20:49 +0300 Subject: [PATCH 2/2] Validate ontology owner decisions against evidence --- .../0114_ontology_owner_decision_contract.md | 9 +- .../0114_ontology_owner_decision_contract.md | 23 +++- tests/test_ontology_import_policy.py | 109 +++++++++++++--- tools/ontology_imports.py | 119 +++++++++++++++--- 4 files changed, 220 insertions(+), 40 deletions(-) diff --git a/docs/archive/proposal_sources/0114_ontology_owner_decision_contract.md b/docs/archive/proposal_sources/0114_ontology_owner_decision_contract.md index 71837f6..69f1c34 100644 --- a/docs/archive/proposal_sources/0114_ontology_owner_decision_contract.md +++ b/docs/archive/proposal_sources/0114_ontology_owner_decision_contract.md @@ -13,8 +13,10 @@ will return for reviewed delta candidates. Add a deterministic `ontology_owner_decision_report` artifact under `runs/` that models Ontology owner decisions as read-only evidence. The report carries decision ids, candidate ids, intake ids, decision state, Ontology decision refs, -decision actor/time, accepted/rejected state, and explicit false SpecGraph -import, gate-close, and canonical mutation flags. +decision actor/time, accepted/rejected state, matched closed-loop evidence +state, and explicit false SpecGraph import, gate-close, and canonical mutation +flags. Decisions that do not match pending Ontology owner closed-loop evidence +are ignored with diagnostics rather than emitted as valid decision evidence. This slice must not import decisions into SpecGraph, mark candidates accepted, close semantic gates, write Ontology packages, update ontology lockfiles, mutate @@ -26,7 +28,8 @@ canonical specs, invoke prompt agents, parse arbitrary text, or run ontologyc. `tools/ontology_semantic_control_policy.json`. - Build `runs/ontology_owner_decision_report.json` from a typed owner-decision fixture and existing closed-loop evidence source refs. -- Validate accepted/rejected/clarification states and reject import authority. +- Validate accepted/rejected/clarification states against pending closed-loop + evidence and reject import authority. - Cover artifact shape, write path, authority rejection, and `make ontology-imports` output in focused tests. - Register proposal `0114` in promotion and runtime registries. diff --git a/docs/proposals/0114_ontology_owner_decision_contract.md b/docs/proposals/0114_ontology_owner_decision_contract.md index 7b5d689..222ba34 100644 --- a/docs/proposals/0114_ontology_owner_decision_contract.md +++ b/docs/proposals/0114_ontology_owner_decision_contract.md @@ -33,15 +33,20 @@ runs/ontology_owner_decision_report.json ``` The artifact provides a typed contract for Ontology-supplied accepted/rejected -decisions while keeping those decisions as review evidence only. A later import -preview slice decides how those decisions would affect SpecGraph. +decisions while keeping those decisions as review evidence only. Decisions must +match closed-loop evidence that is pending Ontology owner review; stale, +blocked, or unmatched fixture decisions are reported as ignored inputs instead +of being emitted as usable decision evidence. A later import preview slice +decides how valid decisions would affect SpecGraph. ## Goals - Add `ontology_owner_decision_report` to the semantic policy layout. - Define accepted, rejected, and clarification decision states. - Preserve Ontology decision refs, candidate ids, intake ids, decision actor, - decision time, and accepted-delta status. + decision time, accepted-delta status, and matched closed-loop evidence state. +- Filter blocked, stale, or unmatched fixture decisions into ignored decision + diagnostics. - Reject SpecGraph import, semantic gate closure, canonical mutation, prompt execution, and Ontology package/lockfile write authority. - Cover report shape, write path, authority boundary, and registry trace in @@ -69,7 +74,8 @@ The report artifact declares: }, "canonical_mutations_allowed": false, "tracked_artifacts_written": false, - "decisions": [] + "decisions": [], + "ignored_decisions": [] } ``` @@ -82,8 +88,13 @@ Each decision records: - Ontology decision ref; - decision actor and timestamp; - accepted-delta flag; +- matched closed-loop evidence id; +- matched evidence and intake states; - explicit false SpecGraph import, gate-close, and canonical mutation flags. +Ignored decisions record decision identity, source state when present, and the +reason they were not emitted as decision evidence. + ## Authority Boundary The report may be used as owner-decision evidence by later import previews. @@ -106,8 +117,8 @@ This slice is complete when: `ontology_owner_decision_report`; - `tools/ontology_imports.py` builds and validates the owner decision report; - `make ontology-imports` writes `runs/ontology_owner_decision_report.json`; -- focused tests cover accepted/rejected report shape, write path, and authority - boundary; +- focused tests cover accepted/rejected report shape, ignored invalid decisions, + write path, and authority boundary; - proposal `0114` is tracked in promotion and runtime registries; - proposal gates, DocC sync, and focused Python tests pass. diff --git a/tests/test_ontology_import_policy.py b/tests/test_ontology_import_policy.py index e2390c9..dbbb2fe 100644 --- a/tests/test_ontology_import_policy.py +++ b/tests/test_ontology_import_policy.py @@ -1156,25 +1156,24 @@ def test_ontology_owner_decision_report_builds_read_only_decision_contract() -> assert report["canonical_mutations_allowed"] is False assert report["tracked_artifacts_written"] is False assert report["summary"] == { - "status": "decisions_available", - "decision_count": 2, - "accepted_count": 1, - "rejected_count": 1, + "status": "no_decisions", + "decision_count": 0, + "accepted_count": 0, + "rejected_count": 0, "clarification_count": 0, + "ignored_decision_count": 2, "next_gap": "build_ontology_decision_import_preview", } - - decisions = {entry["decision_id"]: entry for entry in report["decisions"]} - accepted = decisions["ontology-owner-decision-accept-casfunction"] - assert accepted["candidate_id"] == "ontology-delta-candidate-examcalc-casfunction" - assert accepted["decision_state"] == "accepted" - assert accepted["accepted_ontology_delta"] is True - assert accepted["imports_into_specgraph"] is False - assert accepted["closes_semantic_gate"] is False - assert accepted["mutates_canonical_specs"] is False - rejected = decisions["ontology-owner-decision-reject-legacyterm"] - assert rejected["decision_state"] == "rejected" - assert rejected["accepted_ontology_delta"] is False + assert report["decisions"] == [] + ignored = {entry["decision_id"]: entry for entry in report["ignored_decisions"]} + assert ( + ignored["ontology-owner-decision-accept-casfunction"]["reason"] + == "closed_loop_evidence_not_pending_owner_decision" + ) + assert ( + ignored["ontology-owner-decision-reject-legacyterm"]["reason"] + == "missing_closed_loop_evidence" + ) assert report["consumer_boundary"]["for_specgraph_decision_import_preview"] is True assert report["consumer_boundary"]["for_specspace_review_dashboard"] is True assert report["consumer_boundary"]["may_execute_prompt_agent"] is False @@ -1308,6 +1307,66 @@ def test_ontology_review_dashboard_uses_gate_action_for_review_pending_no_candid ) +def test_ontology_owner_decision_report_builds_matched_pending_decisions() -> 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) + closed_loop_evidence = json.loads(json.dumps(surfaces["ontology_closed_loop_evidence"])) + cas_entry = closed_loop_evidence["evidence_entries"][0] + cas_entry["evidence_state"] = "pending_ontology_owner_decision" + cas_entry["source_intake_state"] = "awaiting_ontology_owner_review" + cas_entry["required_human_action"] = "collect_ontology_owner_delta_decisions" + legacy_entry = json.loads(json.dumps(cas_entry)) + legacy_entry["candidate_id"] = "ontology-delta-candidate-examcalc-legacyterm" + legacy_entry["intake_id"] = ( + "ontology-delta-draft-intake-ontology-delta-candidate-examcalc-legacyterm" + ) + legacy_entry["evidence_id"] = ( + "ontology-closed-loop-evidence-ontology-delta-candidate-examcalc-legacyterm" + ) + legacy_entry["term"] = "LegacyTerm" + closed_loop_evidence["evidence_entries"].append(legacy_entry) + closed_loop_evidence["summary"]["status"] = "pending_ontology_owner_decision" + closed_loop_evidence["summary"]["evidence_entry_count"] = 2 + closed_loop_evidence["summary"]["pending_decision_count"] = 2 + closed_loop_evidence["summary"]["blocked_entry_count"] = 0 + closed_loop_evidence["summary"]["required_human_action"] = ( + "collect_ontology_owner_delta_decisions" + ) + + report = module.build_ontology_owner_decision_report( + semantic_policy, + semantic_policy_path=ROOT / "tools" / "ontology_semantic_control_policy.json", + closed_loop_evidence=closed_loop_evidence, + ) + + assert report["summary"] == { + "status": "decisions_available", + "decision_count": 2, + "accepted_count": 1, + "rejected_count": 1, + "clarification_count": 0, + "ignored_decision_count": 0, + "next_gap": "build_ontology_decision_import_preview", + } + assert report["ignored_decisions"] == [] + decisions = {entry["decision_id"]: entry for entry in report["decisions"]} + accepted = decisions["ontology-owner-decision-accept-casfunction"] + assert accepted["candidate_id"] == "ontology-delta-candidate-examcalc-casfunction" + assert accepted["decision_state"] == "accepted" + assert accepted["accepted_ontology_delta"] is True + assert accepted["source_evidence_state"] == "pending_ontology_owner_decision" + assert accepted["source_intake_state"] == "awaiting_ontology_owner_review" + assert accepted["imports_into_specgraph"] is False + assert accepted["closes_semantic_gate"] is False + assert accepted["mutates_canonical_specs"] is False + rejected = decisions["ontology-owner-decision-reject-legacyterm"] + assert rejected["decision_state"] == "rejected" + assert rejected["accepted_ontology_delta"] is False + + def test_ontology_semantic_review_surface_rejects_authority_expansion( tmp_path: Path, ) -> None: @@ -1610,6 +1669,24 @@ def test_ontology_owner_decision_report_rejects_inconsistent_accepted_flag( ) +def test_ontology_owner_decision_report_requires_decision_identity_fields() -> None: + module = load_ontology_imports_module() + surfaces = module.build_ontology_import_surfaces(FIXTURE) + report = json.loads(json.dumps(surfaces["ontology_owner_decision_report"])) + report["decisions"] = [ + { + "decision_state": "accepted", + "accepted_ontology_delta": True, + "imports_into_specgraph": False, + "closes_semantic_gate": False, + "mutates_canonical_specs": False, + } + ] + + with pytest.raises(ValueError, match=r"decisions\[0\]\.decision_id"): + module.require_ontology_owner_decision_report(report) + + def test_ontology_delta_candidate_review_packet_uses_policy_review_action_order( tmp_path: Path, ) -> None: diff --git a/tools/ontology_imports.py b/tools/ontology_imports.py index b3d5f9b..bf3a091 100755 --- a/tools/ontology_imports.py +++ b/tools/ontology_imports.py @@ -4131,7 +4131,23 @@ def build_ontology_owner_decision_report( "semantic_control_policy.ontology_owner_decision_report_contract", ) ) + evidence_entries = closed_loop_evidence.get("evidence_entries") + if not isinstance(evidence_entries, list): + raise ValueError("closed_loop_evidence.evidence_entries must be a list") + closed_loop_by_decision_key: dict[tuple[str, str], dict[str, Any]] = {} + for index, raw_entry in enumerate(evidence_entries): + if not isinstance(raw_entry, dict): + raise ValueError(f"closed_loop_evidence.evidence_entries[{index}] must be an object") + candidate_id = require_string( + raw_entry, "candidate_id", f"closed_loop_evidence.evidence_entries[{index}]" + ) + intake_id = require_string( + raw_entry, "intake_id", f"closed_loop_evidence.evidence_entries[{index}]" + ) + closed_loop_by_decision_key[(candidate_id, intake_id)] = raw_entry + decisions: list[dict[str, Any]] = [] + ignored_decisions: list[dict[str, Any]] = [] for index, raw_decision in enumerate(raw_decisions): if not isinstance(raw_decision, dict): raise ValueError( @@ -4158,22 +4174,25 @@ def build_ontology_owner_decision_report( "semantic_control_policy.owner_decision_fixture.decisions" f"[{index}].accepted_ontology_delta must match accepted decision_state" ) + decision_id = require_string( + raw_decision, + "decision_id", + f"semantic_control_policy.owner_decision_fixture.decisions[{index}]", + ) + candidate_id = require_string( + raw_decision, + "candidate_id", + f"semantic_control_policy.owner_decision_fixture.decisions[{index}]", + ) + intake_id = require_string( + raw_decision, + "intake_id", + f"semantic_control_policy.owner_decision_fixture.decisions[{index}]", + ) decision = { - "decision_id": require_string( - raw_decision, - "decision_id", - f"semantic_control_policy.owner_decision_fixture.decisions[{index}]", - ), - "candidate_id": require_string( - raw_decision, - "candidate_id", - f"semantic_control_policy.owner_decision_fixture.decisions[{index}]", - ), - "intake_id": require_string( - raw_decision, - "intake_id", - f"semantic_control_policy.owner_decision_fixture.decisions[{index}]", - ), + "decision_id": decision_id, + "candidate_id": candidate_id, + "intake_id": intake_id, "decision_state": decision_state, "ontology_decision_ref": require_string( raw_decision, @@ -4218,6 +4237,51 @@ def build_ontology_owner_decision_report( "semantic_control_policy.owner_decision_fixture.decisions" f"[{index}].{field} must be false" ) + matched_entry = closed_loop_by_decision_key.get((candidate_id, intake_id)) + if matched_entry is None: + ignored_decisions.append( + { + "decision_id": decision_id, + "candidate_id": candidate_id, + "intake_id": intake_id, + "decision_state": decision_state, + "reason": "missing_closed_loop_evidence", + } + ) + continue + source_evidence_state = require_string( + matched_entry, + "evidence_state", + f"closed_loop_evidence.evidence_entries[{candidate_id}]", + ) + source_intake_state = require_string( + matched_entry, + "source_intake_state", + f"closed_loop_evidence.evidence_entries[{candidate_id}]", + ) + if ( + source_evidence_state != "pending_ontology_owner_decision" + or source_intake_state != "awaiting_ontology_owner_review" + ): + ignored_decisions.append( + { + "decision_id": decision_id, + "candidate_id": candidate_id, + "intake_id": intake_id, + "decision_state": decision_state, + "source_evidence_state": source_evidence_state, + "source_intake_state": source_intake_state, + "reason": "closed_loop_evidence_not_pending_owner_decision", + } + ) + continue + decision["source_evidence_id"] = require_string( + matched_entry, + "evidence_id", + f"closed_loop_evidence.evidence_entries[{candidate_id}]", + ) + decision["source_evidence_state"] = source_evidence_state + decision["source_intake_state"] = source_intake_state decisions.append(decision) source_artifacts = copy_json_object( @@ -4253,6 +4317,7 @@ def build_ontology_owner_decision_report( "canonical_mutations_allowed": False, "tracked_artifacts_written": False, "decisions": decisions, + "ignored_decisions": ignored_decisions, "consumer_boundary": copy_json_object( require_object( owner_decision_contract, @@ -4269,6 +4334,7 @@ def build_ontology_owner_decision_report( "accepted_count": accepted_count, "rejected_count": rejected_count, "clarification_count": clarification_count, + "ignored_decision_count": len(ignored_decisions), "next_gap": require_string( owner_decision_contract, "next_gap", @@ -4303,12 +4369,35 @@ def require_ontology_owner_decision_report( require_surface_output_artifact(owner_decision_report, "ontology_owner_decision_report") require_object(owner_decision_report, "source_artifacts", "owner_decision_report") require_object(owner_decision_report, "summary", "owner_decision_report") + ignored_decisions = owner_decision_report.get("ignored_decisions", []) + if not isinstance(ignored_decisions, list): + raise ValueError("owner_decision_report.ignored_decisions must be a list") decisions = owner_decision_report.get("decisions") if not isinstance(decisions, list): raise ValueError("owner_decision_report.decisions must be a list") for index, raw_decision in enumerate(decisions): if not isinstance(raw_decision, dict): raise ValueError(f"owner_decision_report.decisions[{index}] must be an object") + for field in ( + "decision_id", + "candidate_id", + "intake_id", + "ontology_decision_ref", + "decided_by", + "decided_at", + "source_evidence_id", + "source_evidence_state", + "source_intake_state", + ): + require_string(raw_decision, field, f"owner_decision_report.decisions[{index}]") + if ( + raw_decision["source_evidence_state"] != "pending_ontology_owner_decision" + or raw_decision["source_intake_state"] != "awaiting_ontology_owner_review" + ): + raise ValueError( + "owner_decision_report.decisions" + f"[{index}] must reference pending owner-decision evidence" + ) decision_state = require_string( raw_decision, "decision_state", f"owner_decision_report.decisions[{index}]" )