Governed Action Classification (SPEC-034)

What this page is. A concept-by-concept explanation of how the governance layer decides whether an action is allowed, why it currently cannot enforce most of what it claims to, and the SPEC-034 design that fixes it. Every claim about the current code carries a file:line reference. Authoritative sources: specs/034-governed-action-classification/spec.md, ADR-0034/0035/0036, prolog/constitutional.pl, the governance/ package. Status: design under review — not built. See also the Glossary (new terms below are folded into it) and governance module.

1. The one-sentence problem

The constitution is written as if it enforces eleven articles, but nothing translates a real operation into the vocabulary the articles read, so in practice almost every real action matches a permissive default clause and is allowed. The rules have no bite — they are toothless (§4). Separately, many allow/deny decisions never reach the constitution at all — they are made by hand-written Python if-statements and hardcoded lists (§7).

Diagram projects-orchestrata-ultimate-governed-action-classification-1

2. Concept dictionary

Each term below is defined once and reused across the page. New terms are also added to the project Glossary.

3. What "governs an action" today — the enforcement topology

There are three parallel, weakly-connected rulebases plus a large surface of Python-only decisions. Understanding this map is the whole point.

Diagram projects-orchestrata-ultimate-governed-action-classification-2

Every decision site falls into one of three buckets:

Bucket Meaning Examples (file:line)
Routes through Prolog the PDP decides, fail-closed PolicyEngine.evaluate (governance/policy_engine.py:85); ConstitutionalEnforcer.check_article (governance/constitutional.py:139); gateway PolicyGate.check (gateway/handlers/_policy_gate.py:47) — only when the factory engine is wired
Audit-only Prolog runs, verdict never blocks dispatcher._audit_evaluate_routing (kernel/dispatcher.py:310, default off); PolicyEvaluator (kernel/policy_evaluator.py, whole module labelled AUDIT-ONLY); VerificationGate (orchestrator/verification_gate.py, enforce=False)
Pure-Python bypass never consults governance ToolInterceptor with no engine (tool_interceptor.py:191); PROTECTED_PATHS deny (orchestrator/engine.py:501); intent_security regex/frozenset denylists; postman/governance.py AGENT_PERMISSIONS dict; _scope_guard flat read_write; gateway/handlers/cxp.py:109 (no authority check)

4. Why the constitution is "toothless" — the evidence

Three compounding facts, each verifiable in the source:

(a) No reduction function. constitutional_check/3 reads a hardcoded atom vocabulary — requires_human_consent/1 (constitutional.pl:73-79), involves_pii/1 (:169), security_threat/1 (:232), destructive_change/1 (:299). But the "action" it receives is the raw string a caller passed, formatted by _format_atom (prolog_client.py:186). Nothing maps a real Bash("rm -rf ...") tool call to the atom delete_data. Unless a caller literally hand-passes action="delete_data", no category matches.

(b) Permissive defaults. When no category matches, the article clauses fall through to defaults that always succeed — Articles VIII–X are bare true, accountable_operation is always true. So a non-matching action is permitted.

(c) The evidence base is never populated. The :- dynamic predicates the articles depend on — pii_data/2, bias_report/3, audit_trail_exists/1, detect_bias/3 — are declared but no Python assert_fact ever writes them (zero non-test call sites). The only runtime fact asserted into the constitution is human_override → human_consent_given/2 (governance/constitutional.py:264).

Put together: the classifier can't classify, unclassified means allow, and the evidence the rules need never arrives. That is what "toothless" means here — the control exists on paper and passes everything in practice.

Diagram projects-orchestrata-ultimate-governed-action-classification-3

5. The target design (SPEC-034)

5.1 Typed descriptors + property-derived categories (ADR-0034)

Replace name lists with rules over properties of a typed Action/Resource descriptor (modelled in canonical_schema.py, SPEC-106 first).

Diagram projects-orchestrata-ultimate-governed-action-classification-4

%% BEFORE (rejected) — a blocklist of names:
security_threat(execute_untrusted).
security_threat(bypass_auth).
involves_pii(access_pii).

%% AFTER (ADR-0034) — rules over properties of the descriptor:
security_threat(A)    :- action_property(A, untrusted_execution).
security_threat(A)    :- action_property(A, auth_bypass).
destructive_change(A) :- action_target(A, R),
                         resource(R, reversibility, irreversible),
                         \+ authorized(A).
involves_pii(A)       :- action_target(A, R), resource(R, sensitivity, pii).

5.2 Scoped authorization — the prod-vs-test example (ADR-0035)

The category-global human_consent_given(access_pii, _) (one flag authorizes everything forever) is removed. Authorization becomes a grant whose selector matches this instance:

%% Source 1 — standing policy grant (human-ratified pattern; auto-applies):
auto_authorized(A) :-
    action(A, verb, delete),
    action_target(A, R),
    resource(R, class, test_fixture),
    resource(R, env, test).

%% Source 2 — per-instance consent grant (single-use, version-bound):
matching_consent_grant(A) :-
    action(A, verb, V), action_target(A, R),
    consent_grant(_Id, _Who, V, R, once, Ver, _Reason, _At),
    resource(R, version, Ver),
    \+ consumed(_Id).

authorized(A) :- auto_authorized(A).
authorized(A) :- matching_consent_grant(A).

%% Article I consumes it:
%%   requires_human_consent(A), \+ authorized(A)  ->  DENY

5.3 Provenance — the label can't be forged (ADR-0036)

The whole model rests on who says a resource is a test_fixture. If the acting agent could assert that, it would relabel its own prod delete. So classification is stamped by the creator at creation time under the trusted identity and is immutable to the agent uid (ADR-0018 boundary).

Diagram projects-orchestrata-ultimate-governed-action-classification-5

6. The single choke point — how Python allow/deny routes through the constitution

This answers the direct question: how must Python code that allows and denies go through the constitutional layer, and supply its atoms?

Diagram projects-orchestrata-ultimate-governed-action-classification-6

# TARGET pattern (SPEC-034 REQ-034-018..020). Illustrative, not yet built.
class ToolCallPEP:
    """A Policy Enforcement Point holds NO policy. It reduces + asks + enforces."""

    def __init__(self, pdp: PolicyDecisionPoint, reducer: ActionReducer) -> None:
        self._pdp = pdp
        self._reducer = reducer

    def guard(self, op: ToolOperation) -> None:
        # 1. reduce the real op to a typed, trusted descriptor (the ONLY atom source)
        action = self._reducer.reduce_to_action(op)      # verb+targets from op...
        #    ...classification attributes come from the trusted resource store,
        #    never from `op` (ADR-0036), so the agent cannot self-classify.
        self._reducer.assert_action_facts(action)         # supply ground facts to Prolog

        # 2. ask the ONE decision point
        verdict = self._pdp.decide(action)                # -> constitutional_check/3 etc.

        # 3. enforce (fail-closed in a factory context; ADR-0002 liveness elsewhere)
        if not verdict.allowed:
            raise GovernanceDenied(action, verdict.reason, verdict.proof)

Contrast the current default posture, which is why nothing is enforced:

# CURRENT — core/adapters/claude/tool_interceptor.py (shipped wiring).
# hooks.py:89 builds `interceptor or ToolInterceptor()` with NO engine, so:
def _check_governance(self, ...):
    if self._engine is None:        # tool_interceptor.py:191
        return None                 # -> falls through to ALLOW (tool_interceptor.py:141)
    # ...only if an engine was attached does anything reach a rulebase.

7. Is it referenced everywhere, or only in some modules?

Only in some — and not the most important one. The main orchestration engine (PolicyEngine.evaluate) is genuinely Prolog-backed and fail-closed, and the gateway RPC path routes through it when the factory engine is wired. But the agent tool-call path — the highest-volume, highest-risk surface — is engineless by default and allows everything. And eight+ sites decide in pure Python with no governance call at all:

Site (file:line) Decides by Reaches constitution?
core/adapters/claude/tool_interceptor.py:150,191 Python set lists; no-op without engine No (default)
core/adapters/claude/gate_policy.py:111,154 hardcoded SELF_EXCLUSION_SET, checked first No
orchestrator/engine.py:175,501 hardcoded PROTECTED_PATHS deny before Prolog Partial (deny pre-empts)
orchestrator/intent_security.py:73,119 injection regexes + DANGEROUS_ACTIONS frozenset Only if bridge injected
gateway/handlers/cxp.py:109 nothing — builds + broadcasts No
postman/governance.py:86,204 Python AGENT_PERMISSIONS dict; U:Ox universal allow No (rulebase exists, unused)
gateway/handlers/_scope_guard.py:64 flat hardcoded read_write No
governance/resource_access.py:110,147 Python whitelist / "GET always allowed" fallbacks Only when atom valid

Plus two fail-open holes: PrologGate.may_execute returns True when rules aren't loaded (governance/prolog_gate.py:96), and EthicalBoundary returns "no harm" on any Prolog error (governance/ethical.py:112).

Conclusion: governance is available centrally but not uniformly enforced. SPEC-034's single-PDP rule (§6) exists to make "every allow/deny goes through the constitution" true, instead of true-in-one-module-and-bypassed-in-eight.

8. Glossary additions

The following terms are added to the project Glossary: action atom, reduction function, classification, blocklist/allowlist, property-derived classification, lexical (vs structural/semantic), toothless / decorative, PEP / PDP, ABAC, standing policy grant, consent grant, tightening/loosening, fail-open/fail-closed, audit-only / shadow, atom supply, constitution-tier.

9. References