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:linereference. Authoritative sources:specs/034-governed-action-classification/spec.md, ADR-0034/0035/0036,prolog/constitutional.pl, thegovernance/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).
2. Concept dictionary
Each term below is defined once and reused across the page. New terms are also added to the project Glossary.
- Action atom — the single Prolog term the constitution reasons about, e.g.
the atom
delete_data. Today it is just whatever string the caller passed asaction, formatted byprolog_client._format_atom(governance/prolog_client.py:186). - Reduction function — the (missing) function that would turn a real
operation — a tool call, a CXP packet, an intent — into the Action atom /
descriptor the gate reads. Its absence is the core defect (§4). SPEC-034 calls
the target
reduce_to_action(REQ-034-020). - Classification — deciding what kind of thing an action is: is this a
security_threat? does itinvolve_pii? is it adestructive_change? The article checks are only as good as this step. - Blocklist (deny-list) vs allowlist (permit-list) — a blocklist names
the bad things and allows everything else; its defining weakness is that an
unlisted item is permitted. An allowlist names the good things and denies
everything else. The constitution's risk categories are blocklists of atom
names (
security_threat(bypass_auth).), so a threat under a new name escapes. - Property-derived classification — classifying by the attributes an
action/resource has (
env=prod,reversibility=irreversible) rather than by its name being on a list. A new operation with a threat-bearing property is caught regardless of what it is called (ADR-0034). - Lexical — "of words / surface text." A lexical detector decides by
matching words or string patterns in the input (keywords, regexes) rather
than by understanding structure or meaning.
intent_security.py's injection regexes andDANGEROUS_ACTIONS = {"delete","execute","modify"}are lexical: fast, cheap, high-recall, easily fooled by rewording. Contrast structural (reasoning over the parsed shape of the thing) and semantic (its meaning). SPEC-033 tiers detectors by measured precision precisely because lexical ones are noisy (ADR-0029). - Toothless / decorative — a rule that cannot fail any input, so it looks
like a control but enforces nothing. Articles VIII–X are literally
true(constitutional.pl), and the four assessment articles queried their evidence wrongly until 2026-07-24. A decorative invariant is worse than none: it creates false confidence. - PEP / PDP (Policy Enforcement Point / Policy Decision Point) — the standard policy-as-code split. The PDP is the single place that decides (here: the Prolog gate). A PEP is a site that intercepts an operation, asks the PDP, and enforces the answer — it contains no policy of its own. Today the system has many PEPs that each embed their own policy (§7); SPEC-034 makes them thin.
- ABAC (Attribute-Based Access Control) — authorization decided by matching attributes of the actor, action, resource, and context against a rule, rather than by a role (RBAC) or a global flag. The prod-vs-test distinction is an attribute of the resource, so ABAC fits and RBAC does not (ADR-0035).
- Standing policy grant vs per-instance consent grant — the two authorization sources. A standing grant is a human-ratified pattern rule that auto-authorizes a whole scoped class forever (e.g. "delete any test fixture in env=test"). A consent grant is a human authorization bound to one specific resource, single-use, with expiry and provenance (e.g. "delete THIS prod row, once").
- Tightening vs loosening — a governance change that adds denials is a tightening (quorum-eligible); one that adds acceptances is a loosening (human-only, ADR-0009). Widening a standing grant's selector is a loosening.
- Fail-open vs fail-closed — what a gate does when it errors or is
unconfigured. Fail-closed denies (safe); fail-open allows (dangerous).
governance_strict(default true) makes the main engine fail-closed, but several sites fail-open (§7). - Audit-only / shadow mode — the gate runs and records a verdict but does
not act on it. Useful to measure impact before enforcing; dangerous if
mistaken for enforcement. The kernel dispatcher's PolicyEvaluator is audit-only
(
kernel/dispatcher.py:310, default off). - Atom supply — the act of asserting the ground Prolog facts the rules
read (
involves_pii(X),human_consent_given(...)). Rules never invent facts; facts come from outside the rulebase (ADR-0005). Today almost nothing supplies them (§4). - Constitution-tier — artifacts so load-bearing that changes need human
ratification, never autonomous quorum (ADR-0013).
constitutional.pland the SPEC-034 authorization model are constitution-tier.
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.
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.
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).
%% 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
deleteatest_fixture/testresource →auto_authorized→ allowed, no human.deleteauser_data/prodresource → no grant → denied; proceeds only with a matching single-use consent grant, then consumed.
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).
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?
- One PDP, many thin PEPs. Every allow/deny site becomes a PEP that reduces
its operation and calls the one PDP. It holds no policy — no local
allowlist, no hardcoded
if. - One reduction function supplies the atoms.
reduce_to_action(op)+assert_action_facts(desc)is the only place ground facts are minted for the gate. The agent payload contributes only the verb and target refs; the classification attributes come from the trusted resource store, never the payload.
# 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
- Spec:
specs/034-governed-action-classification/spec.md - ADRs:
docs/adr/0034-*(property-derived classification),0035-*(two-source scoped authorization),0036-*(classification provenance under the trust boundary) - Constitution:
prolog/constitutional.pl; governance package:governance/ - Related: governance module, SPEC-033 (build factory, ADR-0009 amendment authority, ADR-0002 liveness), SPEC-106 (canonical ontology), SPEC-060 (enhanced PAM).