orchestrata_ultimate.governance.prolog_gate

Reference › orchestrata_ultimate › governance › prolog_gate

The small, synchronous Prolog hook that the pipeline calls at runtime. PrologGate answers two questions: may an agent execute an action (may_execute), and may a volk complete a task (may_complete). The kernel pipeline creates one, loads its rules at boot, asks may_execute before accepting an intent, and pipeline_escalation asks may_complete when a human approves. It is separate from the richer async PolicyEngine. It serves SPEC-007 (REQ-007-US-001-001) and SPEC-000 story US-010 (REQ-000-087 to -092).

load_rules creates a PrologEngine and consults the core rule files that exist in the rules directory: governance.pl, constitutional.pl, agents.pl, ethics.pl, pam_rules.pl and completion.pl. It returns False and logs when loading fails. may_execute(agent, action) escapes both names as Prolog atoms and queries may_execute/2, returning a flag and a reason: approved on a solution; blocked when there is no rule (with an explanation from governance.verdict); blocked with the error text when the query fails. When rules were never loaded it returns allowed with the reason "Prolog not loaded — permissive mode". Each verdict is also logged through make_verdict.

may_complete(volk, task_id) queries three completion gates, all_acceptance_criteria_met, all_outputs_verified and no_open_questions, and returns the flag with a tick or cross line per gate; a failing gate query counts as a failure. If the whole check errors, strict mode (default) denies and non-strict mode allows. The gate also holds the facts the pipeline asserts: assert_task_output, assert_open_question, retract_open_question, assert_fact, and the volk state helpers assert_volk_operational, retract_volk_operational and set_volk_active.

At a glance

Source src/orchestrata_ultimate/governance/prolog_gate.py
Lines of code 324
Classes 1
Functions 1
Imports (internal) orchestrata_ultimate.core.base.traces, orchestrata_ultimate.core.infra_config, orchestrata_ultimate.core.prolog_adapter, orchestrata_ultimate.governance._strict, orchestrata_ultimate.governance.verdict, orchestrata_ultimate.governance.volk_facts
Imported by orchestrata_ultimate.kernel.pipeline, orchestrata_ultimate.kernel.pipeline_escalation, orchestrata_ultimate.orchestrator.pipeline_runner
Tests that import it tests/conftest.py, tests/generated_recovery/test_governance_prolog_gate.py, tests/governance/test_prolog_gate.py, tests/governance/test_verdict_provenance.py, tests/governance/test_volk_facts.py, tests/kernel/test_pipeline_escalation.py

Class diagram

Class diagram orchestrata_ultimate.governance.prolog_gate

Dependency diagram

Dependency diagram orchestrata_ultimate.governance.prolog_gate

API

Classes

PrologGate

Decorators: traces(spec='000', story='US-007', reqs=['REQ-000-066']) · Line 41

Wraps the Prolog engine (janus-swi via core.prolog_adapter) for governance checks.

Method Signature Summary
__init__ __init__()
load_rules load_rules() -> bool Load Prolog governance rules.
may_execute may_execute(agent: str, action: str) -> tuple[bool, str] Check if an agent may execute an action.
_query_bool _query_bool(goal: str) -> bool Adapt PrologEngine.query (returns a list, can raise) to the Callable[[str], bool] shape explain_may_execute_deny expects -- mirrors PrologBridge.query_bool's swallow-on-error…
may_complete may_complete(volk: str, task_id: str, env: Mapping[str, str] | None = None) -> tuple[bool, list[str]] Check if a volk may complete a task.
_run_volk_goals _run_volk_goals(goals: tuple[str, ...], what: str, alias: str) -> bool Run goals, best-effort.
assert_volk_operational assert_volk_operational(alias: str) -> bool Make alias satisfy governance:volk_operational/1.
retract_volk_operational retract_volk_operational(alias: str) -> bool Remove every volk_* fact for alias.
set_volk_active set_volk_active(alias: str, active: bool) -> bool Track a live status transition on governance:volk_active/1.
assert_fact assert_fact(fact: str) Assert a Prolog fact into the session.
assert_task_output assert_task_output(volk: str, task_id: str) Mark that a volk has produced output for a task.
assert_open_question assert_open_question(volk: str, task_id: str) Mark that a volk has open questions for a task.
retract_open_question retract_open_question(volk: str, task_id: str) Remove open question marker.

Functions

Function Signature Summary
_escape_prolog_atom _escape_prolog_atom(value: str) -> str Escape a string for safe use inside a single-quoted Prolog atom.

Source

Permalink: https://gitlab.com/MSamy/orchestrata-ultimate-architecture/-/blob/a4cbc2bdf/src/orchestrata_ultimate/governance/prolog_gate.py

# mypy: ignore-errors
"""Prolog governance gate — may_execute and may_complete predicates."""

import logging
import os
from collections.abc import Mapping

from orchestrata_ultimate.core.base.traces import traces
from orchestrata_ultimate.core.infra_config import PROLOG_RULES_DIR
from orchestrata_ultimate.core.prolog_adapter import PrologEngine
from orchestrata_ultimate.governance._strict import governance_strict
from orchestrata_ultimate.governance.verdict import (
    explain_may_execute_deny,
    make_verdict,
)
from orchestrata_ultimate.governance.volk_facts import (
    active_goals,
    assert_goals,
    retract_goals,
)

_logger = logging.getLogger(__name__)


def _escape_prolog_atom(value: str) -> str:
    """Escape a string for safe use inside a single-quoted Prolog atom.

    Order matters: backslash MUST be escaped first, otherwise the
    backslashes introduced by the later replacements would themselves
    get re-escaped.
    """
    return (
        value.replace("\\", "\\\\")
        .replace("'", "\\'")
        .replace('"', '\\"')
        .replace("\n", "\\n")
    )


@traces(spec="000", story="US-007", reqs=["REQ-000-066"])
class PrologGate:
    """Wraps the Prolog engine (janus-swi via ``core.prolog_adapter``) for
    governance checks."""

    def __init__(self):
        self.prolog = None
        self.loaded = False
        self.session_facts: list[str] = []

    def load_rules(self) -> bool:
        """Load Prolog governance rules."""
        try:
            self.prolog = PrologEngine()

            # Core governance rules + the may_complete/2 completion rules.
            # completion.pl is CONSULTED (not runtime-asserted) so its
            # variable-bearing clauses load on the janus-swi backend too --
            # janus's query-based assertz cannot assert clauses with Prolog
            # variables, but consult parses them natively. Runtime facts
            # (task_has_output/2, has_open_question/2) are ground and asserted
            # at runtime via assert_fact, which works on both backends.
            rules_dir = PROLOG_RULES_DIR
            core_files = [
                "governance.pl",
                "constitutional.pl",
                "agents.pl",
                "ethics.pl",
                "pam_rules.pl",
                "completion.pl",
            ]
            loaded = 0
            for f in core_files:
                path = os.path.join(rules_dir, f)
                if os.path.exists(path):
                    self.prolog.consult(path)
                    loaded += 1

            self.loaded = True
            _logger.info("Loaded %d Prolog rule files", loaded)
            return True
        except Exception as e:
            _logger.error("Failed to load Prolog rules: %s", e)
            self.loaded = False
            return False

    @traces(spec="007", story="US-PGOV-001", reqs=["REQ-007-US-001-001"])
    def may_execute(self, agent: str, action: str) -> tuple[bool, str]:
        """Check if an agent may execute an action.

        W1-P6: this method's return VALUE is unchanged byte-for-byte (some
        callers assert exact reason-string equality) -- provenance is
        recorded as an ADDITIONAL side-effect log line via
        ``governance.verdict.make_verdict``, never by altering what is
        returned.
        """
        if not self.loaded:
            # No rules consulted at all: an ALLOW that carries no rule
            # authority whatsoever -- the textbook ERROR_ALLOW case W1-P6
            # exists to make visible (governance_boot's "0 files loaded"
            # signal is this same failure mode at boot time).
            make_verdict(
                surface="PrologGate.may_execute",
                decision="allow",
                is_deny=False,
                is_error=True,
                rule_source="error:prolog_not_loaded",
                reason="Prolog not loaded — permissive mode",
                logger_=_logger,
            )
            return True, "Prolog not loaded — permissive mode"
        try:
            # self.loaded is only set True after self.prolog is constructed
            # in load_rules() — this documents that invariant for pyright.
            assert self.prolog is not None
            agent_escaped = _escape_prolog_atom(agent)
            action_escaped = _escape_prolog_atom(action)
            query = f"may_execute('{agent_escaped}', '{action_escaped}')"
            results = list(self.prolog.query(query))
            if results:
                make_verdict(
                    surface="PrologGate.may_execute",
                    decision="allow",
                    is_deny=False,
                    is_error=False,
                    rule_source="may_execute/2",
                    reason="approved",
                    logger_=_logger,
                )
                return True, "approved"
            reason = f"blocked — no may_execute rule for ({agent}, {action})"
            explanation = explain_may_execute_deny(self._query_bool, agent, action)
            make_verdict(
                surface="PrologGate.may_execute",
                decision="deny",
                is_deny=True,
                is_error=False,
                rule_source="may_execute/2",
                reason=f"{reason} — {explanation}",
                logger_=_logger,
            )
            return False, reason
        except Exception as e:
            # If the predicate does not exist we treat it as "no rule" and
            # block by default to preserve the loaded-but-empty behavior
            # expected by governance tests.
            _logger.error("may_execute Prolog query failed: %s", e)
            message = str(e).lower()
            if "existence_error" in message or "unknown procedure" in message:
                reason = f"blocked — no may_execute rule for ({agent}, {action})"
            else:
                reason = f"blocked — Prolog query error: {e}"
            make_verdict(
                surface="PrologGate.may_execute",
                decision="deny",
                is_deny=True,
                is_error=True,
                rule_source="error:may_execute/2",
                reason=reason,
                logger_=_logger,
            )
            return False, reason

    def _query_bool(self, goal: str) -> bool:
        """Adapt ``PrologEngine.query`` (returns a list, can raise) to the
        ``Callable[[str], bool]`` shape ``explain_may_execute_deny`` expects
        -- mirrors ``PrologBridge.query_bool``'s swallow-on-error contract
        so a leg query failing never breaks the explanation itself."""
        if self.prolog is None:
            return False
        try:
            return bool(list(self.prolog.query(goal)))
        except Exception:
            return False

    @traces(
        spec="000",
        story="US-010",
        reqs=[
            "REQ-000-087",
            "REQ-000-088",
            "REQ-000-089",
            "REQ-000-090",
            "REQ-000-092",
        ],
    )
    def may_complete(
        self, volk: str, task_id: str, env: Mapping[str, str] | None = None
    ) -> tuple[bool, list[str]]:
        """Check if a volk may complete a task. Returns (allowed, [gate_results]).

        On a Prolog error, fails CLOSED (denies) when governance strict mode
        is on (the default — see ``governance._strict.governance_strict``);
        set ``ORCHESTRATA_GOVERNANCE_STRICT=false`` (or pass ``env=``) to
        restore the permissive legacy behavior.
        """
        gates = []
        if not self.loaded:
            gates.append("✓ Prolog not loaded — permissive mode")
            return True, gates

        try:
            # self.loaded is only set True after self.prolog is constructed
            # in load_rules() — this documents that invariant for pyright.
            assert self.prolog is not None
            volk_escaped = _escape_prolog_atom(volk)
            task_id_escaped = _escape_prolog_atom(task_id)
            # Check each gate individually for reporting
            gate_checks = [
                (
                    "all_acceptance_criteria_met",
                    f"all_acceptance_criteria_met('{volk_escaped}', '{task_id_escaped}')",
                ),
                (
                    "all_outputs_verified",
                    f"all_outputs_verified('{volk_escaped}', '{task_id_escaped}')",
                ),
                (
                    "no_open_questions",
                    f"no_open_questions('{volk_escaped}', '{task_id_escaped}')",
                ),
            ]

            all_passed = True
            for gate_name, query in gate_checks:
                try:
                    results = list(self.prolog.query(query))
                    if results:
                        gates.append(f"✓ {gate_name}")
                    else:
                        gates.append(f"✗ {gate_name}")
                        all_passed = False
                except Exception as e:
                    gates.append(f"✗ {gate_name} (error: {e} — default FAIL)")
                    all_passed = False

            return all_passed, gates
        except Exception as e:
            _logger.error("may_complete Prolog query failed: %s", e)
            if governance_strict(env):
                gates.append(f"✗ Prolog error: {e} — fail-closed")
                return False, gates
            gates.append(f"✓ Prolog error: {e} — permissive fallback")
            return True, gates

    @traces(spec="006", story="US-004", reqs=["REQ-006-US-004-002"])
    # ------------------------------------------------------------------
    # Volk fact sync -- the orchestrator half
    # ------------------------------------------------------------------
    #
    # This gate and GovernancePrologClient run the SAME goals against two
    # different engines (a PrologEngine here, a PrologBridge there), which is
    # why the goals are built in governance.volk_facts rather than written out
    # twice. Without these, `may_execute(alias, capability)` at
    # orchestrator/pipeline_runner.py denies every stage: the volk is a
    # stranger to the rule base, and a stranger is denied.

    def _run_volk_goals(self, goals: tuple[str, ...], what: str, alias: str) -> bool:
        """Run *goals*, best-effort. False if the rule base could not take them."""
        if not self.loaded or self.prolog is None:
            return False
        try:
            for goal in goals:
                list(self.prolog.query(goal))
        except Exception as exc:
            _logger.warning("governance %s failed for %s: %s", what, alias, exc)
            return False
        return True

    def assert_volk_operational(self, alias: str) -> bool:
        """Make *alias* satisfy ``governance:volk_operational/1``."""
        return self._run_volk_goals(assert_goals(alias), "volk-fact assert", alias)

    def retract_volk_operational(self, alias: str) -> bool:
        """Remove every ``volk_*`` fact for *alias*."""
        return self._run_volk_goals(retract_goals(alias), "volk-fact retract", alias)

    def set_volk_active(self, alias: str, active: bool) -> bool:
        """Track a live status transition on ``governance:volk_active/1``."""
        return self._run_volk_goals(
            active_goals(alias, active), "volk_active update", alias
        )

    @traces(spec="000", story="US-007", reqs=["REQ-000-066"])
    def assert_fact(self, fact: str):
        """Assert a Prolog fact into the session."""
        if self.loaded:
            try:
                # self.loaded is only set True after self.prolog is
                # constructed in load_rules() — documents that invariant.
                assert self.prolog is not None
                self.prolog.assertz(fact)
                self.session_facts.append(fact)
            except Exception as e:
                _logger.error("Failed to assert fact: %s — %s", fact, e)

    @traces(spec="000", story="US-010", reqs=["REQ-000-089"])
    def assert_task_output(self, volk: str, task_id: str):
        """Mark that a volk has produced output for a task."""
        volk_escaped = _escape_prolog_atom(volk)
        task_id_escaped = _escape_prolog_atom(task_id)
        self.assert_fact(f"task_has_output('{volk_escaped}', '{task_id_escaped}')")

    @traces(spec="000", story="US-010", reqs=["REQ-000-090"])
    def assert_open_question(self, volk: str, task_id: str):
        """Mark that a volk has open questions for a task."""
        volk_escaped = _escape_prolog_atom(volk)
        task_id_escaped = _escape_prolog_atom(task_id)
        self.assert_fact(f"has_open_question('{volk_escaped}', '{task_id_escaped}')")

    @traces(spec="000", story="US-010", reqs=["REQ-000-090"])
    def retract_open_question(self, volk: str, task_id: str):
        """Remove open question marker."""
        if self.loaded:
            try:
                # self.loaded is only set True after self.prolog is
                # constructed in load_rules() — documents that invariant.
                assert self.prolog is not None
                volk_escaped = _escape_prolog_atom(volk)
                task_id_escaped = _escape_prolog_atom(task_id)
                self.prolog.retract(
                    f"has_open_question('{volk_escaped}', '{task_id_escaped}')"
                )
            except Exception as exc:
                _logger.debug("retract open_question failed: %s", exc)