Skip to main content

Chapter 9: Meta-Programming & State Management

Prolog has one data type: the term. Rules, facts, queries, and data values are all terms. A clause physical_host('pve-node-01', 'AA:BB:CC:DD:EE:01', 256) is a term with functor physical_host and arity 3. A rule can_migrate(VM, Host) :- ... is a term with functor :- and arity 2, whose first argument is the head term and whose second is the body term. This equivalence of code and data — homoiconicity — is not an academic curiosity. It is the mechanism by which the Logic Node can inspect its own rules, traverse proof trees, validate oracle predicates against security policies, and enforce compliance without an external toolchain.

The same property that enables meta-programming enables the most dangerous class of Prolog vulnerability: goal injection. If code and data are the same thing, and a predicate constructs a goal from external input and executes it via call/1, then external input is code. The mitigation is not to avoid meta-programming — the Logic Node's compliance interpreter depends on it — but to enforce a strict type boundary between terms-as-data (which can be inspected but not executed) and terms-as-goals (which have been verified against a whitelist before execution is permitted).

Five properties define the meta-logical boundary.

1. Terms are the universal currency of code and data. Every Prolog entity — atom, integer, compound term, list, Dict, clause — is a term representable as Functor/Arity plus arguments. call/1 executes any term as a goal. clause/2 retrieves the body of any rule as a term. assert/1 adds any term to the database as a clause. The WAM makes no distinction between a term that happens to be "data" and one that happens to be "code" — the distinction exists only in the programmer's intent and the execution context.

2. Dynamic predicates carry a JIT compilation cost on every structural change. A predicate declared :- dynamic is not compiled once at load time. Each assertz or retract call can invalidate the JIT-compiled index for that predicate, requiring re-indexing on the next call. For the Logic Node's static KB predicates (physical_host/3, inventory_entry/1), this cost is zero — they are compiled once and never modified. For genuinely mutable state predicates (vm_power_state/2, host_health/2), the cost is proportional to update frequency and clause count. The design implication: mutable state predicates must be kept small (bounded clause count, bounded key space) to make re-indexing cheap.

3. assertz/retract are not the source of memory leaks in modern SWI-Prolog. SWI-Prolog's clause database uses garbage-collected storage. A retracted clause that has no active references (no thread is currently executing it, no clause/2 handle points to it) is eligible for collection at the next GC cycle. The "retract leaks" concern from older WAM implementations does not apply to SWI-Prolog 9+. The actual memory concern is different: asserting unique atoms as clause arguments (e.g., asserting a unique timestamp string as a fact key on every state update) interns those atoms permanently. The memory pressure is Atom Table growth, not retained clause storage.

4. call/N is the execution boundary between data and code. A term inspected with clause/2, functor/3, or arg/3 remains data — no execution occurs. A term passed to call/1 crosses the execution boundary and becomes a goal. The security implication: any code path in which a term derived from external input reaches call/1 without being validated against a whitelist is a goal injection vector. The safe_call/1 wrapper in Section 9.5 enforces the whitelist check at the execution boundary.

5. The compliance meta-interpreter traverses proof trees as data. A meta-interpreter re-implements Prolog's resolution procedure in Prolog itself, gaining the ability to intercept and inspect every resolution step. audit_compliance(Goal) in Section 9.4 does not execute Goal — it traverses the proof tree that Goal would produce, checking each sub-goal against compliance rules before any sub-goal is actually resolved. The proof tree is data. The compliance check is logic. No external auditor, no instrumentation framework, and no runtime modification of oracle predicates is required.

9.1 Code as Data: The Homoiconic Nature of Prolog

9.1.1 Term Anatomy: functor/3, arg/3, and =..

Every Prolog term has three components: a functor (principal function symbol), an arity (argument count), and zero or more arguments. The built-ins for decomposing and constructing terms from these components:

% functor/3 — decompose or construct a term
functor(Term, Functor, Arity)
% functor(foo(a,b,c), F, A)  → F = foo, A = 3
% functor(T, foo, 3)         → T = foo(_,_,_)

% arg/3 — access a specific argument by position
arg(N, Term, Arg)
% arg(2, foo(a,b,c), X)      → X = b

% =.. (univ) — decompose term to list or construct from list
Term =.. List
% foo(a,b,c) =.. [foo, a, b, c]
% T =.. [bar, 1, 2]          → T = bar(1,2)

For infrastructure code these are not theoretical tools. They are the mechanism by which:

  • A compliance checker inspects the functor of every sub-goal in a proof tree
  • A meta-interpreter reconstructs goals with modified arguments
  • A shell-safety validator verifies that every command-generating predicate passes its arguments through shell_quote/2
% Inspecting a KB rule term:
?- functor(physical_host('pve-node-01', 'AA:BB:CC:DD:EE:01', 256), F, A).
F = physical_host, A = 3.

% Inspecting an operator-notation term:
?- functor((head :- body), F, A).
F = (:-), A = 2.

% Constructing a goal dynamically (data → code path):
?- Goal =.. [physical_host, 'pve-node-01', _, _], call(Goal).
Goal = physical_host('pve-node-01', 'AA:BB:CC:DD:EE:01', 256).
% NOTE: call(Goal) crossed the execution boundary.
% This is safe here because the functor 'physical_host' came from
% trusted source code, not external input.

9.1.2 clause/2: Inspecting Rules at Runtime

clause(Head, Body) unifies Head with the head of a known clause and Body with its body term. For a fact f(a)., the body is the atom true. For a rule f(X) :- g(X), h(X)., the body is the conjunction ','(g(X), h(X)) — a compound term with functor , and arity 2.

% clause/2 retrieves rule bodies as terms:
:- dynamic demo_rule/1.
demo_rule(X) :- integer(X), X > 0, format("Positive integer: ~w~n", [X]).

?- clause(demo_rule(X), Body).
Body = (integer(X), X > 0, format("Positive integer: ~w~n", [X])).

% The body is a nested conjunction term:
?- clause(demo_rule(X), Body),
   Body =.. [',', First, Rest].
First = integer(X),
Rest  = (X > 0, format("Positive integer: ~w~n", [X])).

clause/2 only works on predicates declared :- dynamic or compiled with the discontiguous or module_transparent flags. Static predicates (compiled at load time, no :- dynamic directive) are not inspectable via clause/2 by default — their clauses are compiled to WAM byte code, not retained as Prolog terms. Oracle predicates that generate shell commands should not be inspectable by arbitrary code. The compliance interpreter in Section 9.4 uses a separate, explicit assert-based representation for rules that must be auditable.

% Static predicate — clause/2 fails:
?- clause(physical_host(_, _, _), _).
false.   % physical_host/3 is not dynamic — WAM bytecode only

% Dynamic predicate — inspectable:
?- clause(vm_power_state(100, _), Body).
Body = true.   % It's a fact — body is 'true'

9.1.3 The Conjunction Representation

Rule bodies are stored as nested ','(A,B) (comma) terms. Traversing a body requires handling three cases: a conjunction (recurse into both sides), a disjunction ';'(A,B) (recurse into both branches), and a primitive goal (inspect the functor). This tree structure is the data model for the compliance interpreter.

% Body term structure for: (a, b, c, d)
','(a, ','(b, ','(c, d)))
% Right-associative nesting.

% Traversal predicate (data — not executing the goals):
body_goals(true,             []).
body_goals((A, B),           Goals) :-
    !,
    body_goals(A, GoalsA),
    body_goals(B, GoalsB),
    append(GoalsA, GoalsB, Goals).
body_goals(Goal,             [Goal]).

?- clause(demo_rule(X), Body), body_goals(Body, Goals).
Goals = [integer(X), X>0, format("Positive integer: ~w~n",[X])].

9.1.4 Diagram: Homoiconic Flow — Rule as Term into Audit Predicate

%%{init: {"themeVariables": {"fontSize": "18px"}}}%%
flowchart TD
    SOURCE["Source Rule\ncan_migrate(VM, Host) :-\n  preflight_check(VM, Host),\n  generate_command(VM, Host, Cmd)."]

    TERM["Prolog Term\n':-'(\n  can_migrate(VM,Host),\n  ','(preflight_check(VM,Host),\n    generate_command(VM,Host,Cmd))\n)"]

    CLAUSE["clause(can_migrate(VM,Host), Body)\nBody = ','(preflight_check(VM,Host),\n          generate_command(VM,Host,Cmd))"]

    DECOMP["body_goals(Body, Goals)\nGoals = [\n  preflight_check(VM,Host),\n  generate_command(VM,Host,Cmd)\n]"]

    AUDIT["audit_compliance(Goal)\nFor each Goal in Goals:\n  functor(Goal, F, A)\n  compliance_check(F/A)"]

    PASS["PASS\nAll functors in\ncompliance whitelist\nProof tree clean"]

    FAIL["FAIL\nForbidden functor\ndetected in body\nOracle rejected"]

    SOURCE --->|"compiled as"| TERM
    TERM --->|"clause/2 retrieves body"| CLAUSE
    CLAUSE --->|"body_goals/2 flattens"| DECOMP
    DECOMP --->|"audit_compliance/1 inspects"| AUDIT
    AUDIT --->|"all goals compliant"| PASS
    AUDIT --->|"forbidden goal detected"| FAIL

    style SOURCE fill:#1A2B4A,color:#FFFFFF
    style TERM fill:#1A4070,color:#FFFFFF
    style CLAUSE fill:#1A4070,color:#FFFFFF
    style DECOMP fill:#1A4070,color:#FFFFFF
    style AUDIT fill:#8B6914,color:#FFFFFF
    style PASS fill:#1A6B3A,color:#FFFFFF
    style FAIL fill:#7A1A1A,color:#FFFFFF

Reading the diagram: The source rule (top, dark blue) is simultaneously a logical statement and a Prolog term. clause/2 retrieves the body as a term (not executing it). body_goals/2 flattens the conjunction tree into a list. audit_compliance/1 inspects each goal's functor against compliance rules. The entire inspection happens on data — no execution of the original rule's goals occurs during the audit.


9.2 Dynamic State: assertz and retract

9.2.1 The :- dynamic Directive

A predicate must be declared :- dynamic before it can be asserted or retracted at runtime. The directive has two effects:

  1. It tells the compiler not to optimise away the predicate's clause store. Static predicates can be compiled to pure WAM byte code with no retained term representation. Dynamic predicates retain their clause terms in a mutable store that assertz, retract, and clause/2 can access.

  2. It enables JIT indexing to be rebuilt on structural change. When a clause is added or removed, SWI-Prolog's JIT indexer re-analyses the first-argument structure of the predicate and rebuilds the index hash. For small predicates (< 100 clauses), this is microseconds. For large predicates with high update rates, it accumulates.

% Correct declaration pattern for mutable state predicates:

:- dynamic vm_power_state/2.   % vm_power_state(+VMID, +State)
:- dynamic host_health/2.      % host_health(+HostName, +LoadPct)
:- dynamic maintenance_lock/1. % maintenance_lock(+HostName) — asserted when locked

% NEVER declare static oracle predicates as dynamic:
% :- dynamic replace_disk/4.   % WRONG — this would allow runtime clause injection
% :- dynamic shell_quote/2.    % WRONG — this would allow safety predicate hijacking

The rule: :- dynamic belongs on predicates that represent mutable system state. It does not belong on oracle predicates, safety predicates, or KB facts that are updated only through the authorised chattr -i / edit / chattr +i cycle.

9.2.2 Physical Cost: JIT Re-indexing Under Update Load

When assertz(vm_power_state(100, running)) is called on a predicate that already has clauses, the WAM:

  1. Allocates a new clause record on the heap
  2. Links it into the clause chain for vm_power_state/2
  3. Marks the JIT index for vm_power_state/2 as stale
  4. On the next call to vm_power_state/2, rebuilds the first-argument index

Step 3–4 is the cost. For a predicate with 5 VMs, the index rebuild is negligible. For a predicate tracking 10,000 VMs updated 100 times per second, the rebuild fires 100 times per second on a 10,000-clause predicate — with the clause chain scan at O(n) per rebuild. This is the case where Chapter 8's library(rbtrees) variant is correct, not assertz.

For the Logic Node's deployment (5–50 VMs, state updates every few minutes), assertz/retract with the single-clause-per-key pattern is the correct and efficient choice:

%% single-clause-per-key pattern: retract old, assert new
%% Ensures vm_power_state/2 has exactly one clause per VMID at all times.
%% JIT index always has O(VMs) entries — never accumulates stale clauses.

set_vm_power_state(VMID, NewState) :-
    must_be(positive_integer, VMID),
    must_be(atom, NewState),
    memberchk(NewState, [running, stopped, suspended, error]),
    % Retract the old state if present — ignore failure if not present
    retractall(vm_power_state(VMID, _)),
    assertz(vm_power_state(VMID, NewState)).

get_vm_power_state(VMID, State) :-
    must_be(positive_integer, VMID),
    ( vm_power_state(VMID, State) ->
        true
    ;
        throw(error(
            no_power_state(VMID),
            context(get_vm_power_state/2, 'VM not registered in state manager')
        ))
    ).

retractall/1 removes all matching clauses. Unlike retract/1 (which removes one matching clause and backtracks to find the next), retractall/1 is deterministic and never fails even if no clauses match. It is the correct primitive for the single-clause-per-key pattern.

9.2.3 Memory Reality: The Retract Cycle in SWI-Prolog 9+

The "retract leaks" concern originates from early WAM implementations where retracted clauses were not freed until the module was reloaded. SWI-Prolog 9's clause store is reference-counted: a retracted clause is freed when its reference count drops to zero — i.e., when no thread is currently executing it and no clause/2 reference to it exists.

The actual memory concern for the Logic Node is different and more subtle:

% CORRECT: reuse known atoms as state values — no Atom Table growth
set_vm_power_state(100, running).   % 'running' is already interned
set_vm_power_state(100, stopped).   % 'stopped' is already interned

% DANGEROUS: unique atoms as clause arguments — Atom Table grows without bound
% (Hypothetical incorrect pattern — do not implement)
assert_vm_timestamp(VMID, Timestamp) :-
    format(atom(A), "state_at_~w", [Timestamp]),   % unique atom per call
    assertz(vm_state_log(VMID, A)).                 % A interned permanently

The first pattern is safe: running, stopped, suspended, error are four atoms interned once at load time. Asserting and retracting clauses using these atoms generates no new Atom Table entries. The second pattern generates one new permanent atom per call. At 100 state updates per second, the Atom Table grows at 100 atoms/second — unbounded.

The rule: clause arguments in dynamic predicates must come from a closed vocabulary of pre-interned atoms or from must_be(integer, ...) verified integers. No dynamically-constructed atom strings as clause arguments.

9.2.4 abolish/1: The Nuclear Option

abolish(Functor/Arity) removes a predicate entirely from the clause store, destroying both its clauses and its JIT index. It cannot be undone. Unlike retractall/1 which removes clauses one by one (and can be reversed by assertz), abolish/1 destroys the predicate's identity.

For the Logic Node, abolish/1 is restricted to two use cases:

  1. Recovery from a corrupted state predicate. If vm_power_state/2 has accumulated inconsistent or duplicate clauses from a crashed state update, abolish/1 followed by :- dynamic vm_power_state/2 re-establishes a clean empty predicate.

  2. Module unload during session cleanup. When a test session ends and a temporary state predicate is discarded.

%% state_recovery_protocol/1
%% Destroys and re-initialises a named dynamic predicate.
%% Records the recovery event in the audit log.
%% RESTRICTED: only callable from the recovery module.

state_recovery_protocol(Functor/Arity) :-
    must_be(atom,    Functor),
    must_be(integer, Arity),
    % Verify this is a known mutable state predicate — not an oracle
    memberchk(Functor/Arity, [
        vm_power_state/2,
        host_health/2,
        maintenance_lock/1
    ]),
    abolish(Functor/Arity),
    % Re-establish the dynamic declaration
    Goal =.. [dynamic, Functor/Arity],
    call(Goal),
    format(atom(Msg), "RECOVERY: abolished and re-declared ~w/~w", [Functor, Arity]),
    assertz(recovery_log(Msg)).

Any code path that calls abolish/1 on a predicate not in the whitelist is a critical defect. abolish(shell_quote/2) or abolish(replace_disk/4) would silently destroy safety predicates, causing all subsequent calls to fail in ways that may not be immediately detected.


9.3 Higher-Order Logic: call/N, setof/3, and bagof/3

9.3.1 The call/N Family

call(Goal) executes Goal as a Prolog goal. call(Goal, A1) is equivalent to appending A1 to Goal's argument list and calling the result. This is Prolog's currying mechanism — it enables partial application as seen in maplist/3, include/3, and the constraint filter of Chapter 7.

% call/1 — execute a complete goal
call(write('hello')).

% call/2 — append one argument: call(Goal, A1) ≡ call(Goal(A1))
Pred = write,
call(Pred, 'hello').   % ≡ call(write('hello'))

% call/3 — append two arguments
Pred = format("~w on ~w~n"),
call(Pred, 'nginx-prod-01', 'pve-node-01').
% ≡ format("~w on ~w~n", 'nginx-prod-01', 'pve-node-01')

% Partial application in infrastructure code:
:- use_module(library(apply)).

vm_on_vlan_pred(VID, VMDict) :-
    is_dict(VMDict, vm),
    VMDict.vlan =:= VID.

% include/3 with partial application — binds VID = 20
?- findall(D, vm_entry(D), AllVMs),
   include(vm_on_vlan_pred(20), AllVMs, VLAN20VMs).
VLAN20VMs = [vm{...}, vm{...}, vm{...}].   % All VLAN 20 VMs

The security concern: call/1 with a term derived from external input makes that input executable. The argument for call/1 must always have a known, trusted provenance. Section 9.5 formalises this with safe_call/1.

9.3.2 setof/3 vs. findall/3: The Sorted Collection Standard

findall/3 always succeeds (returning [] for no solutions), never binds free variables, and returns results in the order they are found. setof/3 fails on no solutions, groups results by free variables, sorts and deduplicates results, and requires explicit ^ notation for existential quantification.

For the Logic Node, the distinction matters for two reasons:

  1. Security policy reporting must be deterministic. A compliance report listing violated predicates in a non-deterministic order is an audit liability. setof/3 produces a sorted, deduplicated list — the same list every time, regardless of clause store order.

  2. Free variable behaviour. findall(Host, vm(_, _, Host, _), Hosts) returns all host names including duplicates. setof(Host, ID^Name^Status^vm(ID, Name, Host, Status), Hosts) returns sorted, deduplicated hosts. The ^ operator binds existential variables — "there exist ID, Name, Status such that this holds" — preventing setof/3 from grouping by those variables.

% findall/3 — all results, duplicates, unsorted, always succeeds
?- findall(Host, vm(_, _, Host, running), Hosts).
Hosts = ['pve-node-01', 'pve-node-01', 'pve-node-02', 'pve-node-03'].
% pve-node-01 appears twice (two running VMs)

% setof/3 — sorted, deduplicated, fails on empty
?- setof(Host,
         ID^Name^Status^vm(ID, Name, Host, running),
         Hosts).
Hosts = ['pve-node-01', 'pve-node-02', 'pve-node-03'].
% Each host appears once. Sorted. Fails if no running VMs.

% bagof/3 — sorted within groups, grouped by free variables
?- bagof(Name, ID^Host^Status^vm(ID, Name, Host, running), Names).
Names = ['monitoring-01', 'nginx-prod-01', 'nginx-prod-02', 'postgres-prod-01'].
% All running VM names, sorted alphabetically, no grouping (all vars existential)

The ^ notation is mandatory when using setof/3 or bagof/3 to suppress grouping. Without it, setof(Host, vm(_, _, Host, running), Hosts) would group results by the unnamed variables — producing separate solution sets for each combination of VM IDs and names, not a single list of distinct hosts.

%% Infrastructure use: generate sorted compliance report
%% "Which hosts have VMs in error state, sorted?"

error_state_hosts(Hosts) :-
    ( setof(Host,
            ID^Name^vm(ID, Name, Host, error),
            Hosts) ->
        true
    ;
        Hosts = []   % No error-state VMs — setof would fail
    ).

?- error_state_hosts(Hosts).
Hosts = [].   % No error-state VMs in current inventory

9.3.3 Dynamic Goal Construction: Argument-Order Safety

When building goals dynamically using =.. or call/N, argument order is a correctness guarantee, not a convention. The shell_quote/2 predicate takes (Input, Output) — a goal constructed as shell_quote(Output, Input) by mistake silently reverses the transformation, producing unquoted output passed to the serialiser.

The safe pattern for dynamic goal construction:

% SAFE: functor is compile-time constant, arguments are runtime-bound
check_vm_state(VMID, ExpectedState) :-
    must_be(positive_integer, VMID),
    must_be(atom, ExpectedState),
    Goal = vm_power_state(VMID, ExpectedState),   % known functor
    call(Goal).

% UNSAFE: functor derived from external input
check_dynamic_bad(FunctorAtom, Arg) :-
    Goal =.. [FunctorAtom, Arg],
    call(Goal).   % FunctorAtom could be 'abolish', 'assert', 'shell', ...

9.4 The Build: Live State Manager and Compliance Interpreter

9.4.1 live_state.pl: Mutable VM and Host State

logicadmin@logic-node-01:~$ nano /opt/logic-node/kb/state/live_state.pl
%% =============================================================================
%% FILE:    /opt/logic-node/kb/state/live_state.pl
%% PURPOSE: Live mutable state tracking for VM power states and host health.
%%
%% DESIGN:
%%   — All state facts are single-clause-per-key (retractall before assertz).
%%   — State values come from closed vocabularies of pre-interned atoms.
%%   — No dynamically-constructed atom strings as clause arguments.
%%   — All entry points enforce must_be/2 type guards.
%%   — State history is written to an append-only audit log fact.
%%
%% SECURITY:
%%   — vm_power_state/2 and host_health/2 are the ONLY dynamic predicates.
%%   — Recovery protocol via state_recovery_protocol/1 (Section 9.2.4).
%%   — No execution primitives in this module.
%% =============================================================================

:- module(live_state, [
    set_vm_power_state/2,
    get_vm_power_state/2,
    set_host_health/3,
    get_host_health/3,
    assert_maintenance_lock/1,
    release_maintenance_lock/1,
    host_under_maintenance/1,
    state_snapshot/1,
    state_audit_log/3
]).

:- use_module('/opt/logic-node/kb/inventory/proxmox_inventory_v2').
:- use_module(library(error)).

%% ---------------------------------------------------------------------------
%% DYNAMIC DECLARATIONS — mutable state predicates only
%% ---------------------------------------------------------------------------

:- dynamic vm_power_state/2.      % vm_power_state(+VMID, +State)
:- dynamic host_health/3.         % host_health(+HostName, +IOWaitPct, +CPUPct)
:- dynamic maintenance_lock/1.    % maintenance_lock(+HostName)
:- dynamic state_audit_log/3.     % state_audit_log(+Timestamp, +Event, +Detail)

%% ---------------------------------------------------------------------------
%% CLOSED VOCABULARIES — pre-interned state atoms
%% ---------------------------------------------------------------------------

valid_vm_state(running).
valid_vm_state(stopped).
valid_vm_state(suspended).
valid_vm_state(error).
valid_vm_state(migrating).

%% ---------------------------------------------------------------------------
%% VM POWER STATE
%% ---------------------------------------------------------------------------

%% set_vm_power_state(+VMID, +NewState)
%% Transitions VMID to NewState. Records transition in audit log.
%% Single-clause-per-key: retractall before assertz.

set_vm_power_state(VMID, NewState) :-
    must_be(positive_integer, VMID),
    must_be(atom, NewState),
    ( valid_vm_state(NewState) ->
        true
    ;
        throw(error(
            invalid_vm_state(NewState),
            context(set_vm_power_state/2, 'State not in closed vocabulary')
        ))
    ),
    % Record old state for audit log
    ( vm_power_state(VMID, OldState) -> true ; OldState = undefined ),
    retractall(vm_power_state(VMID, _)),
    assertz(vm_power_state(VMID, NewState)),
    % Append to audit log (using get_time/1 for monotonic timestamp)
    get_time(T),
    assertz(state_audit_log(T, vm_state_transition,
                detail{vmid:VMID, from:OldState, to:NewState})).

%% get_vm_power_state(+VMID, -State)
get_vm_power_state(VMID, State) :-
    must_be(positive_integer, VMID),
    ( vm_power_state(VMID, State) ->
        true
    ;
        throw(error(no_power_state(VMID), context(get_vm_power_state/2,
            'VMID not registered in live state')))
    ).

%% initialise_vm_states/0
%% Seeds the live state from the static KB inventory.
%% Called once at session start.

initialise_vm_states :-
    forall(
        ( vm_entry(V), VMID = V.id, Status = V.status ),
        set_vm_power_state(VMID, Status)
    ).

:- initialise_vm_states.

%% ---------------------------------------------------------------------------
%% HOST HEALTH STATE
%% ---------------------------------------------------------------------------

%% set_host_health(+HostName, +IOWaitPct, +CPUPct)

set_host_health(HostName, IOWait, CPUPct) :-
    must_be(atom,    HostName),
    must_be(integer, IOWait),
    must_be(integer, CPUPct),
    IOWait >= 0, IOWait =< 100,
    CPUPct  >= 0, CPUPct  =< 100,
    ( host_entry(H), H.name = HostName -> true
    ; throw(error(unknown_host(HostName), set_host_health/3))
    ),
    retractall(host_health(HostName, _, _)),
    assertz(host_health(HostName, IOWait, CPUPct)),
    get_time(T),
    assertz(state_audit_log(T, host_health_update,
                detail{host:HostName, io_wait:IOWait, cpu_pct:CPUPct})).

%% get_host_health(+HostName, -IOWait, -CPUPct)
get_host_health(HostName, IOWait, CPUPct) :-
    must_be(atom, HostName),
    ( host_health(HostName, IOWait, CPUPct) ->
        true
    ;
        throw(error(no_health_data(HostName), get_host_health/3))
    ).

%% ---------------------------------------------------------------------------
%% MAINTENANCE LOCKS
%% ---------------------------------------------------------------------------

assert_maintenance_lock(HostName) :-
    must_be(atom, HostName),
    ( maintenance_lock(HostName) ->
        true   % Already locked — idempotent
    ;
        assertz(maintenance_lock(HostName)),
        get_time(T),
        assertz(state_audit_log(T, maintenance_lock_asserted,
                    detail{host:HostName}))
    ).

release_maintenance_lock(HostName) :-
    must_be(atom, HostName),
    retractall(maintenance_lock(HostName)),
    get_time(T),
    assertz(state_audit_log(T, maintenance_lock_released,
                detail{host:HostName})).

host_under_maintenance(HostName) :-
    maintenance_lock(HostName).

%% ---------------------------------------------------------------------------
%% STATE SNAPSHOT
%% ---------------------------------------------------------------------------

%% state_snapshot(-Snapshot)
%% Produces a Dict summarising all live state at the moment of call.

state_snapshot(Snapshot) :-
    findall(vmid-VMID/state-State,
            vm_power_state(VMID, State), VMStates),
    findall(host-Host/io_wait-IO/cpu-CPU,
            host_health(Host, IO, CPU),   HostHealths),
    findall(Host, maintenance_lock(Host), LockedHosts),
    get_time(T),
    Snapshot = state_snapshot{
        timestamp:    T,
        vm_states:    VMStates,
        host_healths: HostHealths,
        locked_hosts: LockedHosts
    }.
% REPL demonstration:

?- set_vm_power_state(100, stopped).
true.

?- get_vm_power_state(100, State).
State = stopped.

?- set_vm_power_state(100, invalid_state).
ERROR: invalid_vm_state(invalid_state)
% Closed vocabulary enforcement — 'invalid_state' not in valid_vm_state/1.

?- assert_maintenance_lock('pve-node-02'),
   host_under_maintenance('pve-node-02').
true.

?- state_snapshot(S).
S = state_snapshot{
    timestamp: 1741171454.32,
    vm_states: [vmid-100/state-stopped, vmid-101/state-running, ...],
    host_healths: [],
    locked_hosts: ['pve-node-02']
}.

9.4.2 audit_compliance.pl: The Compliance Meta-Interpreter

The compliance meta-interpreter traverses the proof tree of a goal as data, applying compliance rules to each sub-goal before any sub-goal is executed. It does not re-implement full SLD resolution — it inspects the static rule bodies of oracle predicates and validates that their structure conforms to the Logic Node's security invariants.

logicadmin@logic-node-01:~$ nano /opt/logic-node/kb/compliance/audit_compliance.pl
%% =============================================================================
%% FILE:    /opt/logic-node/kb/compliance/audit_compliance.pl
%% PURPOSE: Compliance meta-interpreter for oracle predicate auditing.
%%
%% DESIGN:
%%   audit_compliance(Module:Functor/Arity) inspects the clauses of the
%%   named predicate, traverses each clause body as a conjunction tree,
%%   and verifies that every sub-goal satisfies the compliance rules.
%%
%% COMPLIANCE RULES (enforced):
%%   RULE-1: No direct shell execution primitives (shell/1, process_create/3)
%%   RULE-2: String serialisation must use shell_quote/2, not format/2 alone
%%   RULE-3: No assertz/retract inside oracle predicates
%%   RULE-4: No abolish/1 anywhere (except recovery module)
%%   RULE-5: Dynamic goal construction must use safe_call/1, not call/1 directly
%%           with an argument derived from a non-literal functor
%%
%% OUTPUT:
%%   compliance_result(Predicate, violations, [Violation|...]) — failed
%%   compliance_result(Predicate, clean, [])                   — passed
%% =============================================================================

:- module(audit_compliance, [
    audit_compliance/2,
    audit_module_compliance/2,
    compliance_report/1
]).

:- use_module(library(error)).

%% ---------------------------------------------------------------------------
%% FORBIDDEN PATTERNS
%% Each clause defines one forbidden predicate / structural pattern.
%% ---------------------------------------------------------------------------

%% forbidden_goal(+GoalTerm, -ViolationCode, -Description)
%% Succeeds if GoalTerm matches a forbidden pattern.

forbidden_goal(shell(_),              shell_exec,
    'Direct shell/1 execution — never permitted in oracle').
forbidden_goal(shell(_, _),           shell_exec,
    'Direct shell/2 execution').
forbidden_goal(process_create(_, _, _), shell_exec,
    'process_create/3 — direct process execution').
forbidden_goal(abolish(_),            abolish_call,
    'abolish/1 outside recovery module').
forbidden_goal(assertz(_),            state_mutation,
    'assertz/1 in oracle — oracle predicates must be pure').
forbidden_goal(retract(_),            state_mutation,
    'retract/1 in oracle — oracle predicates must be pure').
forbidden_goal(retractall(_),         state_mutation,
    'retractall/1 in oracle').
forbidden_goal(call(X),               unsafe_call,
    'call/1 with non-literal argument — use safe_call/1') :-
    \+ callable_literal(X).   % call/1 with known literal is OK

%% callable_literal(+Term): True if Term is a known-safe literal goal
callable_literal(true).
callable_literal(fail).
callable_literal(Term) :-
    compound(Term),
    functor(Term, F, _),
    whitelisted_functor(F).

%% ---------------------------------------------------------------------------
%% FUNCTOR COMPLIANCE WHITELIST
%% Functors that may appear as goals in oracle predicate bodies.
%% ---------------------------------------------------------------------------

whitelisted_functor(must_be).
whitelisted_functor(is_dict).
whitelisted_functor(get_dict).
whitelisted_functor(memberchk).
whitelisted_functor(shell_quote).
whitelisted_functor(shell_quote_integer).
whitelisted_functor(shell_safe_atom).
whitelisted_functor(with_output_to).
whitelisted_functor(format).
whitelisted_functor(atom_string).
whitelisted_functor(string_concat).
whitelisted_functor(atomic_list_concat).
whitelisted_functor(throw).
whitelisted_functor(catch).
whitelisted_functor((=)).
whitelisted_functor((\=)).
whitelisted_functor((==)).
whitelisted_functor((\==)).
whitelisted_functor((is)).
whitelisted_functor((!)).
whitelisted_functor((->)).
whitelisted_functor((;)).
whitelisted_functor((,)).
whitelisted_functor(true).
whitelisted_functor(fail).
whitelisted_functor(false).
whitelisted_functor(once).
whitelisted_functor(findall).
whitelisted_functor(vm_entry).
whitelisted_functor(host_entry).
whitelisted_functor(disk_entry).
whitelisted_functor(vm_by_id).
whitelisted_functor(host_by_name).

%% ---------------------------------------------------------------------------
%% BODY TRAVERSAL
%% ---------------------------------------------------------------------------

%% collect_body_goals(+Body, -Goals)
%% Flattens a conjunction/disjunction body term into a flat list of goals.

collect_body_goals(true,    []) :- !.
collect_body_goals((A, B),  Goals) :-
    !,
    collect_body_goals(A, GA),
    collect_body_goals(B, GB),
    append(GA, GB, Goals).
collect_body_goals((A ; B), Goals) :-
    !,
    collect_body_goals(A, GA),
    collect_body_goals(B, GB),
    append(GA, GB, Goals).
collect_body_goals((A -> B), Goals) :-
    !,
    collect_body_goals(A, GA),
    collect_body_goals(B, GB),
    append(GA, GB, Goals).
collect_body_goals(Goal, [Goal]).

%% ---------------------------------------------------------------------------
%% COMPLIANCE CHECK — SINGLE GOAL
%% ---------------------------------------------------------------------------

%% check_goal_compliance(+Goal, +PredicateContext, -Violations)

check_goal_compliance(Goal, Context, Violations) :-
    findall(
        violation(Context, Code, Goal, Desc),
        forbidden_goal(Goal, Code, Desc),
        Violations
    ).

%% ---------------------------------------------------------------------------
%% MAIN AUDIT PREDICATE
%% ---------------------------------------------------------------------------

%% audit_compliance(+Module:Functor/Arity, -Result)
%% Audits all clauses of Module:Functor/Arity.
%% Result: compliance_result(clean, []) or compliance_result(violations, [V|...])

audit_compliance(Module:Functor/Arity, Result) :-
    must_be(atom, Module),
    must_be(atom, Functor),
    must_be(integer, Arity),

    % Build a template head for clause/2 lookup
    length(Args, Arity),
    Head =.. [Functor | Args],

    % Collect all violations across all clauses
    findall(
        Violations,
        (
            Module:clause(Head, Body),
            collect_body_goals(Body, Goals),
            include(
                [G, Vs]>>(check_goal_compliance(G, Functor/Arity, Vs), Vs \= []),
                Goals,
                ViolationLists
            ),
            flatten(ViolationLists, Violations),
            Violations \= []
        ),
        AllViolationLists
    ),
    flatten(AllViolationLists, AllViolations),
    ( AllViolations = [] ->
        Result = compliance_result(Functor/Arity, clean, [])
    ;
        Result = compliance_result(Functor/Arity, violations, AllViolations)
    ).

%% audit_module_compliance(+Module, -Results)
%% Audits all exported predicates of Module.

audit_module_compliance(Module, Results) :-
    must_be(atom, Module),
    predicate_property(Module:_, dynamic),   % get dynamic predicates
    module_property(Module, exports(Exports)),
    maplist([F/A, R]>>(audit_compliance(Module:F/A, R)), Exports, Results).

%% compliance_report/1
%% Print a human-readable compliance report for all oracle modules.

compliance_report(Module) :-
    audit_module_compliance(Module, Results),
    forall(member(R, Results), (
        R = compliance_result(FA, Status, Violations),
        ( Status = clean ->
            format("[PASS] ~w:~w~n", [Module, FA])
        ;
            format("[FAIL] ~w:~w — ~w violation(s)~n",
                   [Module, FA, length(Violations)]),
            forall(member(violation(_, Code, Goal, Desc), Violations), (
                format("       ~w: ~w → ~w~n", [Code, Goal, Desc])
            ))
        )
    )).
% REPL demonstration:

% Audit zfs_oracle_v2 — should pass all compliance rules
?- audit_compliance(zfs_oracle_v2:replace_disk/4, R).
R = compliance_result(replace_disk/4, clean, []).

% Introduce a violation for demonstration — add a test predicate
:- module(test_violation, [bad_oracle/2]).
:- dynamic bad_oracle/2.
bad_oracle(Input, Output) :-
    shell(Input),       % FORBIDDEN: direct shell execution
    Output = done.

?- audit_compliance(test_violation:bad_oracle/2, R).
R = compliance_result(bad_oracle/2, violations,
    [violation(bad_oracle/2, shell_exec,
               shell(Input),
               'Direct shell/1 execution — never permitted in oracle')]).

% Full oracle module audit:
?- compliance_report(zfs_oracle_v2).
[PASS] zfs_oracle_v2:replace_disk/4
[PASS] zfs_oracle_v2:pool_scrub/3
[PASS] zfs_oracle_v2:disk_status/3

Security Note — Safe Term Reading for External Audits The audit_compliance/2 predicate above uses clause/2 to inspect modules that are already loaded into the WAM. This is safe for internal, trusted oracle modules. However, if you extend this auditor to scan untrusted .pl files (e.g., as part of a CI/CD pipeline before loading them), you must never use consult/1 or load_files/1 to read them.

As established in Section 3.3.5, consult/1 executes :- initialization directives immediately. Scanning a malicious file via consult/1 compromises the auditing node. To safely audit an unverified file from disk, you must parse it as inert data using read_term/2 or read_term_from_stream/2 with the safe(true) option enforced:

% SAFE file inspection — parses terms without executing directives
audit_untrusted_file(FilePath) :-
    setup_call_cleanup(
        open(FilePath, read, Stream),
        read_safe_terms(Stream),
        close(Stream)
    ).

read_safe_terms(Stream) :-
    read_term(Stream, Term, [safe(true)]),
    ( Term == end_of_file -> true
    ; analyze_term_for_violations(Term), % Your custom inspection logic
      read_safe_terms(Stream)
    ).

The safe(true) flag ensures that no arbitrary code execution or dangerous term expansion occurs during the parsing phase.

9.5 Security Context: The Dynamic Injection Vector

9.5.1 Goal Injection: The Attack Surface

Every code path of the form call(UserInput) or Goal =.. [UserAtom | Args], call(Goal) where UserAtom is not verified is a goal injection vector. In Prolog, code and data are the same type — a term that looks like data becomes a goal the moment it is passed to call/1.

If any predicate in the Logic Node accepts an atom from an external source (HTTP request, JSON ingestion, file read) and uses it as a functor in a dynamic goal construction:

% Vulnerable pattern — do not implement
run_user_check(FunctorAtom, VMID) :-
    Goal =.. [FunctorAtom, VMID],
    call(Goal).

An attacker supplying FunctorAtom = 'abolish' and VMID = physical_host/3 calls abolish(physical_host/3) — silently destroying the entire host inventory. Supplying FunctorAtom = 'shell' and a command atom calls shell(command) if shell/1 is accessible in scope. Supplying FunctorAtom = 'assert' injects arbitrary facts.

The mitigation has two layers: predicate-level whitelisting before call/1, and module visibility restrictions that prevent execution primitives from being callable in the Logic Node's module scope.

9.5.2 safe_call/1: The Execution Boundary Whitelist

%% =============================================================================
%% FILE:    /opt/logic-node/kb/security/safe_call.pl
%% PURPOSE: Whitelisted call/1 wrapper — prevents goal injection.
%%
%% DESIGN:
%%   safe_call/1 checks the functor of its argument against a whitelist
%%   of permitted callable goals before executing it.
%%   If the functor is not whitelisted, throws injection_attempt/2.
%%   No exceptions for "well-known safe" functors — whitelist is exhaustive.
%% =============================================================================

:- module(safe_call, [
    safe_call/1,
    safe_call/2,
    register_callable/1,
    deregister_callable/1
]).

:- use_module(library(error)).

%% ---------------------------------------------------------------------------
%% CALLABLE WHITELIST
%% ---------------------------------------------------------------------------

:- dynamic callable_goal/1.

%% Seed the whitelist with the Logic Node's permitted callable predicates.
%% This list is the complete set of predicates that may be invoked
%% via safe_call/1. It is NOT the same as the oracle whitelist —
%% this is the runtime execution whitelist.

:- forall(member(F/A, [
    vm_power_state/2,
    host_health/3,
    host_entry/1,
    vm_entry/1,
    disk_entry/1,
    host_by_name/2,
    vm_by_id/2,
    vm_by_name/2,
    shell_quote/2,
    shell_quote_integer/2,
    shell_safe_atom/1,
    can_migrate/3,
    preflight_report/3,
    batch_snapshot/3,
    vm_constraint_filter/3,
    replace_disk/4,
    pool_scrub/3
]), assertz(callable_goal(F/A))).

%% register_callable(+Functor/Arity)
%% Adds a new entry to the callable whitelist.
%% Restricted to the admin module — runtime modification is not public.

register_callable(Functor/Arity) :-
    must_be(atom,    Functor),
    must_be(integer, Arity),
    ( callable_goal(Functor/Arity) ->
        true   % Already registered — idempotent
    ;
        assertz(callable_goal(Functor/Arity))
    ).

deregister_callable(Functor/Arity) :-
    must_be(atom, Functor),
    retractall(callable_goal(Functor/Arity)).

%% ---------------------------------------------------------------------------
%% SAFE CALL
%% ---------------------------------------------------------------------------

%% safe_call(+Goal)
%% Executes Goal only if its functor is in the callable whitelist.
%% Throws injection_attempt/2 otherwise.

safe_call(Goal) :-
    must_be(callable, Goal),
    functor(Goal, F, A),
    ( callable_goal(F/A) ->
        call(Goal)
    ;
        throw(error(
            injection_attempt(F/A, Goal),
            context(safe_call/1,
                'Functor not in callable whitelist — possible goal injection')
        ))
    ).

%% safe_call(+Goal, +ExtraArg)
%% Partial application form: safe_call(Pred, Arg) ≡ safe_call(call(Pred, Arg))

safe_call(Goal, ExtraArg) :-
    must_be(callable, Goal),
    functor(Goal, F, A),
    A1 is A + 1,
    ( callable_goal(F/A1) ->
        call(Goal, ExtraArg)
    ;
        % Try the base functor too (for curried forms)
        ( callable_goal(F/A) ->
            call(Goal, ExtraArg)
        ;
            throw(error(
                injection_attempt(F/A1, Goal/ExtraArg),
                context(safe_call/2, 'Functor not in callable whitelist')
            ))
        )
    ).
% REPL: safe_call/1 demonstration

?- safe_call(vm_power_state(100, State)).
State = running.   % vm_power_state/2 is whitelisted — executes

?- safe_call(abolish(physical_host/3)).
ERROR: injection_attempt(abolish/1, abolish(physical_host/3))
% abolish/1 is not in the whitelist — injection blocked

?- safe_call(shell('rm -rf /')).
ERROR: injection_attempt(shell/1, shell('rm -rf /'))
% shell/1 is not in the whitelist

% Demonstrating the injection vector without safe_call (educational only):
% ?- call(abolish(physical_host/3)).
% — Would succeed, destroying the host inventory.
% safe_call/1 is the guard at this boundary.

9.5.3 Module Visibility as Defence-in-Depth

The whitelist in safe_call/1 is the primary defence. Module visibility is the secondary defence. In SWI-Prolog's module system (Chapter 10), a predicate is only callable from outside its module if it is in the module's export list. abolish/1, assert/1, and shell/1 are built-ins accessible from all modules — they cannot be hidden via exports. But oracle-specific execution predicates can be restricted:

% zfs_oracle_v2.pl — restrict exports to command-generating predicates only
:- module(zfs_oracle_v2, [
    replace_disk/4,
    pool_scrub/3,
    disk_status/3
]).
% pool_create_command/3 is intentionally NOT exported — internal use only
% shell_quote/2 is in shell_safety module, not re-exported from here

A predicate that is not exported from a module is not callable from another module without an explicit module-qualified call (zfs_oracle_v2:pool_create_command(...)) — which is visible in code review. All dynamic goal construction paths that use call/1 directly (bypassing module qualification) only reach predicates in the current module's scope. Module restriction does not prevent call(abolish(...))abolish/1 is a built-in — but it does prevent accidentally calling unexported implementation details.

9.5.4 Atom Table DoS via Frequent Assertz

Section 9.2.3 established the rule: clause arguments in dynamic predicates must come from a closed vocabulary. The third vector is asserting computed string atoms as clause keys under high frequency:

% DANGEROUS: unique session IDs assertz'd as fact keys
% (Hypothetical pattern — illustrative only)
log_state_event(VMID, Event) :-
    format(atom(Key), "evt_~w_~w", [VMID, Event]),
    assertz(event_seen(Key)).
% Each unique combination of VMID and Event produces a new permanent atom.
% Under concurrent state updates: unbounded Atom Table growth.

The correct pattern: use integer IDs as clause keys, and encode event metadata as Dict values (GC-eligible heap terms, not permanent atoms):

% CORRECT: integer key, Dict value — no Atom Table growth from clause keys
log_state_event_safe(VMID, Event) :-
    must_be(positive_integer, VMID),
    must_be(atom, Event),      % Event must be from closed vocabulary
    get_time(T),
    assertz(state_audit_log(T, Event, detail{vmid:VMID})).
    % T is a float (not interned), Event is from valid_vm_state/1 vocabulary
    % detail{...} is a heap Dict — GC-eligible when audit_log clause is retracted

The audit log uses state_audit_log(+Timestamp, +EventAtom, +DetailDict). Timestamp is a floating-point Unix time from get_time/1 — floats are not interned in the Atom Table. EventAtom is one of a small closed set of pre-interned atoms (vm_state_transition, host_health_update, etc.). DetailDict is a heap-allocated Dict that lives as long as the clause lives and is freed when the clause is retracted (assuming no other references to it exist).


Outcome: The Reactive Logic Engine

9.6.1 The Conceptual Transition

Chapters 1–8 built a logic engine that reasons over a static KB. Every query was a proof over facts that did not change during the proof. The oracle predicates were pure functions: given KB state, produce command text. The KB itself changed only through authorised human-initiated procedures.

Chapter 9 adds two capabilities:

Reactive state: live_state.pl maintains a mutable view of the current infrastructure state — VM power states, host health, maintenance locks — that can be updated programmatically as infrastructure changes are observed. The Oracle can now answer "which VMs are currently running?" not just "which VMs were running when the KB was last updated." The mutable state layer is strictly separated from the static KB: oracle predicates that generate commands read the static KB; state-aware predicates that check live conditions read vm_power_state/2 and host_health/3.

Self-auditing logic: audit_compliance.pl allows the Logic Node to inspect its own oracle predicates for security violations before deployment. A new oracle predicate can be validated against the compliance rules as part of its commit pipeline — no external toolchain, no separate security scanner. The compliance check is itself a Prolog program that runs on the same KB.

Static Logic Node (Ch. 1–8) Reactive Logic Engine (Ch. 9)
KB is read-only between human updates vm_power_state/2 updated per observed event
Oracle answers "what should be done?" State manager answers "what is currently true?"
No self-inspection audit_compliance/2 validates oracle predicates at load time
call/1 with trusted literal only safe_call/1 whitelist enforces execution boundary
Shell safety: shell_quote/2 mandate Compliance interpreter verifies shell_quote/2 usage in all oracle bodies

9.6.2 Verification Checklist

?- consult('/opt/logic-node/kb/state/live_state.pl').
true.
?- consult('/opt/logic-node/kb/compliance/audit_compliance.pl').
true.
?- consult('/opt/logic-node/kb/security/safe_call.pl').
true.

% 1. State initialisation from KB
?- forall(vm_entry(V), vm_power_state(V.id, _)).
true.   % ✓ All VM IDs have live state entries

% 2. State transition with closed vocabulary
?- set_vm_power_state(100, stopped),
   get_vm_power_state(100, stopped).
true.   % ✓ State transition and retrieval

% 3. Invalid state rejected
?- catch(set_vm_power_state(100, flying),
         error(invalid_vm_state(flying), _), true).
true.   % ✓ 'flying' not in valid_vm_state/1

% 4. Single-clause-per-key invariant
?- set_vm_power_state(100, running),
   set_vm_power_state(100, stopped),
   aggregate_all(count, vm_power_state(100, _), N),
   N =:= 1.
true.   % ✓ retractall ensures exactly one clause per VMID

% 5. Audit log records transitions
?- set_vm_power_state(100, migrating),
   state_audit_log(_, vm_state_transition,
                   detail{vmid:100, from:_, to:migrating}).
true.   % ✓ Transition logged

% 6. safe_call/1 blocks injection
?- catch(safe_call(abolish(physical_host/3)),
         error(injection_attempt(abolish/1, _), _), true).
true.   % ✓ abolish blocked

% 7. safe_call/1 permits whitelisted predicates
?- safe_call(vm_entry(D)), is_dict(D, vm).
true.

% 8. Compliance audit passes on oracle predicates
?- audit_compliance(zfs_oracle_v2:replace_disk/4,
                    compliance_result(_, clean, [])).
true.   % ✓ replace_disk/4 is compliance-clean

% 9. setof/3 produces sorted deduplicated host list
?- setof(Host,
         ID^Name^Status^vm(ID, Name, Host, running),
         Hosts),
   Hosts = [H1|_], atom(H1).
true.   % ✓ setof returns sorted atom list

% 10. Maintenance lock affects state snapshot
?- assert_maintenance_lock('pve-node-02'),
   state_snapshot(S),
   memberchk('pve-node-02', S.locked_hosts).
true.   % ✓ Locked host appears in snapshot

Exercises

Exercise 9.1 — Term Anatomy Write a predicate term_depth/2 that computes the maximum nesting depth of a term. An atom or integer has depth 0. A compound term's depth is 1 + the maximum depth of its arguments. Verify: term_depth(foo(a, bar(b, c)), D)D = 2. Then use term_depth/2 to check that no single body goal in any oracle predicate exceeds depth 5 (deeply nested goals in oracle bodies are a readability and auditability concern).

Exercise 9.2 — clause/2 Inspection Write a predicate oracle_goal_count/2 that, given a predicate Functor/Arity, counts the total number of sub-goals across all clauses of that predicate using clause/2 and collect_body_goals/2 from Section 9.4.2. Run it against replace_disk/4 and can_migrate/3. Document what the goal count tells you about predicate complexity.

Exercise 9.3 — State Manager Extension Extend live_state.pl with a vm_migration_state/3 dynamic predicate tracking (VMID, FromHost, ToHost) for VMs currently migrating. Implement start_migration/3 (asserts vm_migration_state and transitions VM state to migrating) and complete_migration/2 (retracts vm_migration_state, updates vm_power_state to running, and updates the KB's vm/4 host field via bridge predicate). Verify the single-clause-per-key invariant for vm_migration_state/3.

Exercise 9.4 — Compliance Rule Extension Add two new rules to forbidden_goal/3 in audit_compliance.pl: (1) nb_setval/2 and nb_getval/2 are forbidden in oracle predicates (they are global mutable state, bypassing the logic layer); (2) any call to open/3 or open/4 with mode write or append is forbidden in oracle predicates (oracles are text-output only via with_output_to/2). Write test predicates that violate each new rule and verify the compliance interpreter catches them.

Exercise 9.5 — setof/3 Compliance Report Using setof/3, write violations_by_code/2 that produces a sorted list of Code-[PredicateList] pairs, grouping all compliance violations found by audit_module_compliance/2 by their violation code (e.g., shell_exec-[bad_pred/2, other/1]). This is the structured output format for a compliance audit report. Verify that running it against a module with known injected violations produces the expected grouping.


Further Reading

  • Sterling, L. & Shapiro, E. (1994). The Art of Prolog. 2nd ed. MIT Press. Chapter 17: Meta-Interpreters — the classical treatment of Prolog self-interpretation.
  • O'Keefe, R.A. (1990). The Craft of Prolog. MIT Press. Chapter 10: The Meta-Interpreter — engineering-focused treatment of clause/2 and compliance checking.
  • SWI-Prolog Manual: clause/2https://www.swi-prolog.org/pldoc/man?predicate=clause/2
  • SWI-Prolog Manual: assertz/1, asserta/1, retract/1, abolish/1https://www.swi-prolog.org/pldoc/man?predicate=assertz/1
  • SWI-Prolog Manual: setof/3, bagof/3https://www.swi-prolog.org/pldoc/man?predicate=setof/3
  • SWI-Prolog Manual: call/Nhttps://www.swi-prolog.org/pldoc/man?predicate=call/1
  • OWASP: Code Injectionhttps://owasp.org/www-community/attacks/Code_Injection — The Prolog goal injection attack maps directly to OWASP's code injection category.