Skip to main content

Chapter 1: The Sovereign Paradigm

Before a single line of Prolog is written, the engineer must understand why this technology is being chosen. Not because it is fashionable — it is decidedly not — but because it is correct for a specific class of infrastructure problem that imperative tools have been failing to solve, quietly and expensively, for decades.

The five philosophical pillars of the Sovereign paradigm are as follows.

1. Truth is not a procedure. It is a declaration. In imperative programming, truth is the residue of a completed execution. A Bash script is "correct" when it finishes without a non-zero exit code. The system's actual state is assumed to match intent. This assumption is, in practice, a security liability. Prolog inverts this: you declare what is true about your domain, and the engine derives whether a query is satisfiable. The runtime cannot be in a "partially correct" state. A proof either closes or it does not.

2. Sovereignty means freedom from runtime dependency on external authority. Cloud-based automation — Terraform Cloud, AWS Systems Manager, Azure Automation — introduces a class of availability and security dependency that infrastructure engineers rarely account for fully. Your control plane is now a remote API endpoint owned by a third party, subject to their outages, their pricing changes, their data-retention policies, and their threat model. A sovereign logic node holds all inference locally. It answers queries at microsecond latency without an internet connection, an API token, or a subscription.

3. The attack surface of a logic engine is fundamentally smaller than a script interpreter. A running Python interpreter with a requests library, a paramiko SSH client, and a boto3 AWS SDK loaded into memory is a sprawling attack surface. SWI-Prolog's core engine is a tightly scoped Warren Abstract Machine (WAM) implementation with no implicit network stack. What is not present cannot be exploited.

4. Relational logic is the natural language of infrastructure policy. Access control, firewall rules, dependency graphs, configuration invariants — these are all relations. service(nginx, depends_on, openssl). user(deploy_agent, has_role, restricted). port(443, bound_to, nginx). Imperative scripts simulate these relations badly, with nested conditionals and ad-hoc data structures that collapse under operational complexity. Prolog encodes them directly.

5. Failure must be explicit, not silent. The single most dangerous property of imperative infrastructure automation is the silent partial failure. A Bash script that runs useradd and then fails on chmod leaves the system in an indeterminate state with no automatic recovery path. Prolog's computation model is fail-closed by design: an unresolvable query fails loudly and immediately, leaving system state unmodified. No side effects are applied on the way to a failed proof.

1.1 The Imperative Trap & Security Debt

1.1.1 A Brief History of Automation Optimism

The infrastructure automation story of the 2010s was one of relentless, largely uncritical enthusiasm. Puppet. Chef. SaltStack. Ansible. Each tool arrived with the same promise: describe your infrastructure as code and it will converge to the desired state automatically. The community embraced this with near-religious conviction. "Infrastructure as Code" became not just a methodology but an identity.

By the mid-2020s, the forensic record on this experiment is available for inspection, and it is not flattering. The SolarWinds breach, Codecov supply-chain compromise, the CircleCI credential exposure, and dozens of less-publicised incidents share a common architectural root: imperative automation scripts operating with elevated privileges on systems whose actual state was not continuously verified against declared intent. The tools that were supposed to eliminate configuration drift became, in many environments, the primary vector for it.

This is not an accident. It is a direct consequence of the computational model.

1.1.2 Anatomy of the Imperative Model

An imperative program is a sequence of commands addressed to a machine. Each command mutates state. The program is considered "correct" when the sequence of mutations produces an intended final state. This model has two deeply embedded pathologies.

Pathology One: Assumed Pre-conditions. Every imperative script carries an invisible set of assumptions about the state of the system before execution begins. apt-get install nginx assumes that APT's package database is populated, that the network is reachable, that /var/cache/apt is writable, that the nginx package name resolves to the expected binary in the configured repositories, and that no conflicting package holds a lock file. None of these assumptions are stated. None are checked at the logical level. They are checked, implicitly, at execution time, and when they fail, they fail with errors that are frequently misinterpreted or silently swallowed by || true patterns in production scripts.

Pathology Two: No Rollback Semantics. A Prolog proof is transactional at the logical level: if the proof fails, no side effects have been committed. An Ansible playbook has no equivalent guarantee. A play that creates a user, installs a package, and then fails when writing a configuration file leaves the system with a new user and a new package and no configuration. The system is now in a state that was never intended, never documented, and may not be detected by the next playbook run if the early tasks are idempotent.

1.1.3 State Drift as a Security Vulnerability

The term "configuration drift" is typically used in an operational context — systems gradually diverge from their desired state due to manual intervention, failed playbook runs, or OS updates. The security dimension of this problem is underappreciated.

Consider a hardening playbook that performs the following sequence:

# Typical Ansible hardening role — simplified
- name: Disable root SSH login
  lineinfile:
    path: /etc/ssh/sshd_config
    regexp: '^PermitRootLogin'
    line: 'PermitRootLogin no'
  notify: restart sshd

- name: Set restrictive umask
  lineinfile:
    path: /etc/profile
    line: 'umask 027'

- name: Install auditd
  apt:
    name: auditd
    state: present

- name: Deploy audit rules
  copy:
    src: audit.rules
    dest: /etc/audit/rules.d/hardening.rules
  notify: restart auditd

A network interruption after task three and before task four leaves the system in the following state: PermitRootLogin no is set (good), umask 027 is set (good), auditd is installed (good), but the hardening audit rules are absent. The system appears hardened. A compliance scan that checks package presence will mark it as passing. The audit subsystem is running with default rules, logging nothing of operational value.

This is state drift as a security vulnerability. The system's actual security posture diverges from its declared security posture, and the divergence is invisible to the tooling that created it.

Definition — State Drift (Security Context): A condition in which a system's actual configuration has diverged from its declared desired configuration due to partial execution of an imperative automation sequence, resulting in a security posture that is neither the intended hardened state nor the explicit failure state, but an indeterminate intermediate state that may satisfy automated compliance checks while providing substantially reduced security guarantees.

The insidious property of security-relevant state drift is that it often satisfies syntactic compliance checks while violating semantic security requirements. The auditd daemon is present and running. The binary exists. The service unit is enabled. Every tool that checks "is auditd installed" returns true. The question "are the correct rules loaded" requires a semantic check against the expected rule set, and that check is rarely performed continuously.

1.1.4 Race Conditions in Privileged Automation

Beyond state drift, imperative automation operating with root or elevated privileges is routinely vulnerable to a class of race condition that received intense academic scrutiny in the late 1990s but has been largely forgotten in the DevOps era.

The TOCTOU (Time-Of-Check-To-Time-Of-Use) race condition is structurally embedded in any imperative automation that follows the check-then-act pattern:

# Unsafe pattern — endemic in production Bash
if [ ! -f /etc/myapp/config.conf ]; then
    # An attacker with local access can create a symlink
    # pointing to /etc/passwd in this window
    cp /opt/myapp/default.conf /etc/myapp/config.conf
    chmod 600 /etc/myapp/config.conf
fi

The temporal window between the [ ! -f ... ] check and the cp execution is a race window. On a multi-user system, on a system with a compromised service account, or in a containerised environment with shared namespaces, this window is exploitable. Python's os.path.exists() followed by open() has identical semantics. Ansible's stat module followed by copy has identical semantics. The model itself is the vulnerability.

Prolog does not have this problem because Prolog does not act on the world during unification. It reasons about the world. Side effects in Prolog are explicit, isolated, and applied only after a proof closes. The check and the use are not separated by time; they are unified by logic.

1.1.5 The Ansible Fragility Profile

Ansible deserves specific attention because it is the most widely deployed configuration management tool in enterprise Linux environments as of this writing, and because its failure modes are instructive.

Ansible's model is "desired state via idempotent tasks." In theory, running a playbook multiple times should converge the system to the same state each time. In practice, this guarantee is substantially weaker than advertised.

Idempotency is per-task, not per-playbook. Each Ansible module guarantees that its operation is idempotent. The playbook as a whole has no such guarantee. Task interaction effects, handler ordering, and variable precedence rules create emergent non-idempotency that manifests under conditions not present in development environments.

YAML is not a logic language. Ansible playbooks are YAML documents interpreted by Python. The "logic" in a playbook — when: conditions, block/rescue structures, register variables — is implemented via Python string evaluation and dictionary manipulation. There is no formal semantics. The behaviour of complex conditional chains in Ansible is not derivable from first principles; it must be empirically tested.

The control node is a single point of compromise. An Ansible control node with SSH access to 500 managed nodes is not a control plane. It is a master key. A credential breach on the Ansible control node is a simultaneous breach of every managed system. This architectural decision — centralising SSH credentials for automation purposes — is structurally equivalent to the security anti-patterns that Ansible was supposed to replace.

Fact gathering is an unauthenticated trust relationship. gather_facts: true causes Ansible to execute a Python interpreter on each managed node and trust the output. A compromised node can return fabricated facts that alter playbook execution on other nodes via shared variable state.

Ansible Component Security Risk Severity
Control node SSH credentials Single point of compromise for all managed nodes Critical
gather_facts Unauthenticated fact injection from managed nodes High
YAML when: conditions No formal semantics; empirical behaviour only Medium
Vault encryption Decryption key must be available at runtime High
Callback plugins Arbitrary Python execution on control node Critical
command/shell modules Raw shell injection if variables are not sanitised High

The Sovereign Argument: A logic node that reasons about infrastructure state does not require SSH access to managed systems to determine their configuration. It reasons from a fact database that is populated by verifiable, cryptographically signed state reports. The reasoning engine and the state collection mechanism are decoupled. Compromise of the reasoning engine does not yield access to managed systems.


1.2 The Relational Shift: Logic as a Firewall

1.2.1 Mathematics in Motion

Logic programming is frequently described as "declarative," a term so overloaded as to be nearly meaningless in contemporary software discourse. React is "declarative." SQL is "declarative." Kubernetes manifests are "declarative." The word has been applied to anything that is not literally a for loop.

A more precise description: Prolog is mathematics in motion. It is the mechanisation of first-order predicate logic. When you write a Prolog fact or rule, you are writing a logical formula. When you submit a query, you are asking the engine to determine whether a logical consequence of your formula set is derivable. The engine's behaviour is not the result of design decisions by the implementer; it is the result of the formal semantics of Horn clause logic.

This matters for security because formal semantics are auditable. You can determine, with mathematical precision, what conclusions a Prolog knowledge base can and cannot derive. You cannot make this determination about a Bash script or an Ansible playbook without executing it.

1.2.2 Imperative: "How To Do It" — The Procedural Burden

Consider the problem of determining whether a user should have access to a production database. An imperative approach:

def can_access_prod_db(username):
    # Step 1: Look up user record
    user = db.query("SELECT * FROM users WHERE username = ?", username)
    if not user:
        return False
    if user['suspended']:
        return False
    
    # Step 2: Look up group memberships
    groups = db.query("SELECT group_name FROM memberships WHERE user_id = ?", user['id'])
    group_names = [g['group_name'] for g in groups]
    
    # Step 3: Check if any group has prod access
    for group in group_names:
        perms = db.query("SELECT * FROM group_permissions WHERE group_name = ? AND resource = 'prod_db'", group)
        if perms:
            # Step 4: Check if access is time-restricted
            now = datetime.now()
            for perm in perms:
                if perm['time_restrict']:
                    if perm['start_hour'] <= now.hour <= perm['end_hour']:
                        return True
                else:
                    return True
    return False

This is 25 lines of procedural logic that encodes a policy decision. It has multiple database round-trips. It has implicit assumptions about the database schema. It has a subtle bug: the inner loop returns True on the first matching time-restricted permission found, without checking if there are other permissions that might be explicitly denied. It has no way to explain its decision; it returns a boolean. And it is not the policy itself — it is an implementation of the policy, which may or may not correctly reflect the policy's intent.

1.2.3 Relational: "What is True" — The Declarative Solution

The same policy in Prolog:

% Facts — populated from your infrastructure state
user(alice).
user(bob).
user(charlie).

suspended(charlie).

member(alice, dev_team).
member(alice, oncall).
member(bob, dev_team).

group_permission(oncall, prod_db).
group_permission(dba_team, prod_db).

time_restricted(oncall, prod_db, 8, 20).  % hour range 08:00–20:00

% Rules — the policy itself
active_user(U) :-
    user(U),
    \+ suspended(U).

has_group_access(U, Resource) :-
    active_user(U),
    member(U, Group),
    group_permission(Group, Resource).

has_timed_access(U, Resource, Hour) :-
    has_group_access(U, Resource),
    member(U, Group),
    time_restricted(Group, Resource, Start, End),
    Hour >= Start,
    Hour =< End.

can_access(U, Resource, _Hour) :-
    has_group_access(U, Resource),
    \+ time_restricted(_, Resource, _, _).

can_access(U, Resource, Hour) :-
    has_timed_access(U, Resource, Hour).

This is the policy. Not an implementation of the policy — the policy itself, stated as logical relations. It is executable. It is auditable. It generates explanations via Prolog's trace mechanism. It is modifiable at the fact level (change suspended(charlie) to active(charlie)) without touching the rules. And it is fail-closed by default: any user not explicitly represented in the fact base will fail the user(U) check and be denied access without requiring an explicit deny rule.

The fail-closed property is not a feature that was designed in. It is a consequence of the closed-world assumption: in Prolog, what is not stated to be true is assumed to be false. This is the correct default for access control systems and the opposite of the implicit trust that imperative systems extend to unhandled code paths.

1.2.4 Diagram: Procedural Search vs. Relational Query

The following Mermaid diagram illustrates the structural difference between an imperative access check and its Prolog equivalent, highlighting the fail-closed nature of the relational model.

flowchart TD
    A["START: can_access?(user, resource)"] --> B{"Imperative Path"}
    A --> C{"Relational Path"}

    B --> B1["Step 1: Fetch user record from DB"]
    B1 --> B2{"user record found?"}
    B2 -->|"No"| B_FAIL["RETURN FALSE<br/>(explicit null check)"]
    B2 -->|"Yes"| B3["Step 2: Fetch group memberships"]
    B3 --> B4["Step 3: Loop groups, check permissions"]
    B4 --> B5{"permission row found?"}
    B5 -->|"No"| B_FAIL2["RETURN FALSE<br/>(loop exhausted)"]
    B5 -->|"Yes"| B6{"time restricted?"}
    B6 -->|"No"| B_TRUE["RETURN TRUE"]
    B6 -->|"Yes"| B7{"current hour in window?"}
    B7 -->|"No"| B_FAIL3["RETURN FALSE"]
    B7 -->|"Yes"| B_TRUE2["RETURN TRUE"]
    
    B_FAIL --> RISK1["RISK: unhandled exception = silent PASS"]
    B_FAIL2 --> RISK2["RISK: wrong loop logic = incorrect result"]

    C --> C1["Query: can_access(User, Resource, Hour)"]
    C1 --> C2["Unify: active_user(User)"]
    C2 -->|"FAILS"| C_CLOSED["FAIL - CLOSED<br/>No side effects applied<br/>No ambiguous state"]
    C2 -->|"SUCCEEDS"| C3["Unify: member(User, Group)"]
    C3 -->|"FAILS"| C_CLOSED
    C3 -->|"SUCCEEDS"| C4["Unify: group_permission(Group, Resource)"]
    C4 -->|"FAILS"| C_CLOSED
    C4 -->|"SUCCEEDS"| C5["Check time constraints"]
    C5 -->|"FAILS"| C_CLOSED
    C5 -->|"SUCCEEDS"| C_OPEN["SUCCEED - PROVEN<br/>Result is a logical consequence<br/>Explanation available via trace/2"]

    style C_CLOSED fill:#8B0000,color:#FFFFFF
    style C_OPEN fill:#1A5276,color:#FFFFFF
    style B_FAIL fill:#C0392B,color:#FFFFFF
    style B_FAIL2 fill:#C0392B,color:#FFFFFF
    style B_FAIL3 fill:#C0392B,color:#FFFFFF
    style B_TRUE fill:#1A5276,color:#FFFFFF
    style B_TRUE2 fill:#1A5276,color:#FFFFFF
    style RISK1 fill:#F39C12,color:#000000
    style RISK2 fill:#F39C12,color:#000000

Diagram notes:

  • The imperative path has multiple independent failure points, each requiring explicit handling. An unhandled exception at any step defaults to the Python/Bash caller's exception handling, which frequently returns a permissive result.
  • The relational path has a single failure mode: the proof does not close. This is always explicit, always side-effect-free, and always the correct secure default.
  • The imperative path returns a boolean. The relational path, via trace/2 or why/1, can return the derivation — the exact chain of facts and rules that produced the result.

1.2.5 The Warren Abstract Machine: A Necessary Digression

SWI-Prolog executes Prolog via the Warren Abstract Machine (WAM), a register-based virtual machine designed by David H.D. Warren in 1983. Understanding the WAM's basic operation is not merely academic — it directly informs the memory safety and performance characteristics of the production logic node.

The WAM operates on the following memory regions:

Region Purpose Security Relevance
Code Area Compiled clause instructions Immutable after load; no JIT recompilation
Heap (Global Stack) Term construction during execution Subject to garbage collection; terms are allocated and reclaimed
Local Stack Environment and choice point frames Proportional to recursion depth; bounded by memory limits
Trail Record of variable bindings for backtracking Enables clean backtracking without side-effect leakage
Atom Table Hash table of all interned atom strings Global, unbounded without limits; primary DoS surface
String Heap Storage for SWI-Prolog string objects Garbage collected; safe for external input

The relationship between these regions during execution is frequently a source of confusion for engineers coming from imperative languages. The following diagram clarifies how a single query traverses the WAM's memory architecture:

flowchart LR
    subgraph WAM["WAM Memory Architecture"]
        direction TB
        CA["Code Area\n─────────────\nCompiled clauses\nImmutable after load\nNo JIT surface"]
        AT["Atom Table\n─────────────\nGlobal intern hash\nNever GC'd\n⚠ DoS surface"]
        subgraph Dynamic["Dynamic — per query"]
            direction LR
            LS["Local Stack\n───────────\nEnvironments\nChoice points\nRecursion depth"]
            H["Heap\n(Global Stack)\n───────────\nTerm construction\nGarbage collected\nUnification output"]
            TR["Trail\n───────────\nVariable bindings\nBacktrack record\nRollback engine"]
            SH["String Heap\n───────────\nString objects\nGarbage collected\n✓ Safe for input"]
        end
    end

    Q["?- query submitted"] --> CA
    CA -->|"clause selected"| H
    H -->|"variable bound"| TR
    TR -->|"branch fails → unwind"| H
    AT -.->|"atom lookup (O1)"| H
    SH -.->|"string data\n(validated input)"| H

    style AT fill:#8B0000,color:#FFFFFF
    style TR fill:#1A5276,color:#FFFFFF
    style SH fill:#1A5276,color:#FFFFFF
    style CA fill:#2C3E50,color:#FFFFFF
    style Q fill:#F39C12,color:#000000

Reading the diagram: A submitted query enters via the Code Area, where the WAM selects matching clauses. Term construction happens on the Heap. Every variable binding is recorded on the Trail — this is the rollback register. When a proof branch fails, the WAM walks the Trail in reverse, unbinding variables and restoring the Heap to its pre-branch state. The Atom Table is accessed on every atom reference but contributes no mutable state to the proof. External input arrives on the String Heap, isolated from the Atom Table until explicitly validated and converted. When the WAM binds a variable during unification, it records that binding on the trail. If a proof branch fails, the WAM unwinds the trail, restoring all variables to their unbound state. The world, from the perspective of subsequent computation, is exactly as it was before the failed branch was attempted.

This is rollback semantics built into the hardware model of the language. Not a library. Not a design pattern. The machine does it.


1.3 Hardening the Logic Node Architecture

1.3.1 The Prefrontal Cortex Concept

Throughout this textbook, the production SWI-Prolog installation is referred to as a Logic Node or, in architectural diagrams, as the Prefrontal Cortex of the infrastructure. The metaphor is deliberate and precise.

The prefrontal cortex does not move muscles. It does not directly perceive the environment. It reasons about information collected from other brain regions and makes decisions — specifically, it is responsible for what neuroscience calls "executive function": goal-directed behaviour, rule application, and the inhibition of inappropriate responses. It is, in the human brain, the system that says "no" when sensory input suggests an action that violates a known constraint.

The Logic Node has this role in a Sovereign Infrastructure:

  • It does not provision servers (the provisioning agent does that, on instruction)
  • It does not collect metrics (the monitoring agent does that, and reports facts)
  • It does not manage secrets (the vault does that, and the logic node queries the vault's published fact interface)
  • It reasons about the facts reported by other systems and derives authoritative decisions about infrastructure state, access policy, and change safety

This separation is an architectural security property, not just a design aesthetic. A reasoning system that cannot directly mutate infrastructure state cannot be exploited to directly mutate infrastructure state, even if fully compromised. An attacker who gains code execution on the Logic Node gains the ability to corrupt reasoning outputs — which is serious and must be detected — but does not gain immediate lateral access to production systems.

1.3.2 Linux Mint 22.x: The Deliberate Choice

This textbook prescribes Linux Mint 22.x (Wilma) as the operating system for the Logic Node. This choice will be questioned by readers who default to Ubuntu Server, Debian, or minimal Alpine-based containers.

Linux Mint 22.x is based on Ubuntu 24.04 LTS, which is itself based on Debian. The LTS cycle provides five years of security maintenance. The package ecosystem is the full Ubuntu universe. The choice costs nothing in terms of package availability or support lifetime.

Linux Mint ships a GUI by default, which will strike some engineers as bloat. In the Logic Node architecture, the local graphical environment is a security feature, not overhead. A VM with a local display that an engineer can attach to via Proxmox console (zero network attack surface) is safer than a headless server where all administration is performed via SSH. This is a philosophy: the administration path that requires physical-or-equivalent access is always preferable to the administration path that requires network access.

Linux Mint does not ship snapd enabled by default. Ubuntu Server's aggressive push of snap packages introduces an additional package manager, an additional daemon (snapd), additional mount namespaces, and a dependency on Canonical's snap store — an external network service — for package integrity verification. On a Sovereign Node, snap packages are disabled and remain disabled. All software is installed from APT repositories with GPG-verified package signatures and optionally pinned hash verification.

Against Docker: The most common pushback to this architecture is the suggestion that a Docker container would provide equivalent isolation with less overhead. This argument fails on several grounds.

Property Linux Mint 22.x VM Docker Container
Kernel isolation Full — dedicated kernel Shared — host kernel namespace
Attack surface Defined by VM hardware interface Shared kernel call surface + container runtime
OOM behaviour VM-scoped; host unaffected OOM killer operates on host scope
Storage isolation Dedicated disk image Shared host filesystem with overlay FS
Boot-time verification Full UEFI/BIOS secure boot chain Runtime image integrity only
Snapshot/rollback Full VM snapshot at hypervisor level Layer cache only; no live state rollback
Credential exposure VM credentials isolated from host Container environment variables in host /proc
Container runtime CVEs Not applicable Runc, containerd, buildkit attack surface

The container runtime is not zero-cost in terms of attack surface. Runc, containerd, and the Docker daemon each have CVE histories that include privilege escalation to host root. A dedicated VM with no container runtime eliminates this attack surface class entirely.

For development and testing, container-based Prolog environments are entirely reasonable and are covered in Volume III. For production Sovereign Logic Nodes, the VM model is mandated.

1.3.3 Proxmox VE as the Hypervisor

The Logic Node VM runs on Proxmox VE (Virtual Environment). Proxmox is an open-source Type-1 hypervisor based on KVM/QEMU with a mature web management interface. It is not VMware. It is not Hyper-V. It requires no licence fee. It has no external control plane. Its management API is entirely local to the host. This is consistent with the Sovereign philosophy.

The Proxmox installation itself is out of scope for this chapter (see Volume II, Chapter 8: Sovereign Compute Infrastructure). What matters here is that the Logic Node VM is created with specific resource constraints that are themselves a security measure.


1.4 Provisioning & Hardening the Node

1.4.1 Proxmox VM Specification

Create the Logic Node VM with the following specification. These are not arbitrary — each parameter has a justification.

Parameter Value Justification
VM ID 200 (recommended) Reserved range for Logic Node tier
Name logic-node-01 Naming convention: role-index
OS Linux Mint 22.x (Wilma) x86_64 LTS base, no snapd, GUI for local admin
CPU 4 vCPU (host type) host passes the physical CPU's feature flags directly into the VM. This exposes AES-NI (aes flag) to the guest kernel. SWI-Prolog's library(crypto) — used for fact-signing in Volume IV — performs significantly better when OpenSSL can delegate to AES-NI rather than software AES. Verify with: grep -m1 aes /proc/cpuinfo inside the running VM.
RAM 8 GB fixed (no ballooning) Fixed allocation prevents memory pressure under proof load; ballooning disabled
Storage 40 GB virtio-scsi, thin provisioned virtio for performance; 40 GB provides room for Prolog fact databases
Network Single virtio NIC, VLAN-isolated Single interface; place on management VLAN, not production VLAN
BIOS OVMF (UEFI) Enables Secure Boot; required for full chain of trust
Machine q35 Modern chipset; better PCIe topology
Discard Enabled TRIM passthrough for SSD-backed storage
Balloon Disabled See RAM note above

Security Note — Memory Ballooning: Proxmox's memory ballooning feature (balloon) allows the hypervisor to reclaim unused VM memory dynamically. For general-purpose VMs this is efficient. For a Logic Node running active proof searches, ballooning can cause the WAM's heap and local stack to be partially evicted, triggering SIGSEGV or heap exhaustion under load. Disable it. The 8 GB reservation is the operating cost of a Sovereign Node.

1.4.2 Linux Mint 22.x Base Installation

Install Linux Mint 22.x (Wilma) from the official ISO. During installation:

  • Select "Minimal installation" if offered, or deselect office applications post-install
  • Create a dedicated non-root user: logicadmin (or your site naming convention)
  • Enable full-disk encryption (LUKS) — the UEFI boot chain supports this with OVMF
  • Do not enable automatic login

Post-installation, before installing SWI-Prolog, apply the following hardening baseline:

# Update all packages to current security state
sudo apt update && sudo apt full-upgrade -y

# Remove snapd entirely — not compatible with Sovereign model
sudo systemctl stop snapd
sudo apt purge -y snapd
sudo apt-mark hold snapd
sudo rm -rf ~/snap /snap /var/snap /var/lib/snapd /var/cache/snapd

# Verify snapd is gone
snap version 2>/dev/null && echo "WARNING: snapd still present" || echo "OK: snapd removed"

# Disable Avahi (mDNS) — not required on a logic node
sudo systemctl disable --now avahi-daemon

# Disable cups (printing) — not required
sudo systemctl disable --now cups cups-browsed 2>/dev/null

# Enable UFW with default-deny
sudo ufw default deny incoming
sudo ufw default allow outgoing
sudo ufw allow from 192.168.10.0/24 to any port 22 proto tcp  # Adjust VLAN range
sudo ufw enable

# Verify UFW status
sudo ufw status verbose

1.4.3 SWI-Prolog 10.x Installation: Hardened PPA Setup

SWI-Prolog's official PPA for Ubuntu/Mint is maintained by the SWI-Prolog development team. The following procedure installs from the stable PPA with GPG signature verification — do not shortcut this with an unsigned or third-party source.

# Step 1: Install prerequisites
sudo apt install -y software-properties-common curl gnupg lsb-release

# Step 2: Add the official SWI-Prolog stable PPA
# The PPA is signed; APT will verify all packages against this key
sudo add-apt-repository ppa:swi-prolog/stable
# When prompted, press ENTER to confirm

# Step 3: Update and install
sudo apt update
sudo apt install -y swi-prolog

# Step 4: Verify installation and version
swipl --version
# Expected output: SWI-Prolog version 10.x.x for x86_64-linux

# Step 5: Verify binary integrity
which swipl
ls -la $(which swipl)
# Confirm it links to /usr/bin/swipl, not a snap or flatpak path

# Step 6: Verify the PPA GPG key is correctly installed
apt-key list 2>/dev/null | grep -A1 "SWI-Prolog"
# Or with newer APT key management:
ls /etc/apt/trusted.gpg.d/ | grep swi

On PPA Trust: Adding a PPA extends APT's trust to the PPA maintainer. The SWI-Prolog PPA is maintained by the upstream project (swi-prolog.org). For environments that prohibit external PPAs on policy grounds, SWI-Prolog can be compiled from source — see Appendix A. The compilation path is longer but results in a binary you have personally verified from source.

1.4.4 Post-Installation Hardening: Memory Limits and Network Listeners

After installation, two immediate hardening steps are required before the Logic Node is considered operational.

Step 1: Configure SWI-Prolog memory limits

SWI-Prolog's WAM will consume as much memory as the OS permits for large proof searches. Without explicit limits, a malformed or adversarially crafted query can exhaust system memory. Set limits in the global SWI-Prolog initialisation file:

# Create the system-wide SWI-Prolog initialisation file
sudo tee /etc/swipl.rc > /dev/null << 'EOF'
% /etc/swipl.rc — System-wide SWI-Prolog hardening configuration
% Applied to all swipl invocations on this node

% Stack memory limits
% Local stack: environments and choice points
:- set_prolog_flag(stack_limit, 2_000_000_000).     % 2 GB hard limit

% Global heap limit (term construction)
:- set_prolog_flag(max_tagged_integer, 2147483647).  % 32-bit tag limit

% Atom table: CRITICAL — mitigated via JSON ingestion whitelists (Chapter 8)
% Table space for tabling (memoisation) — bounded
:- set_prolog_flag(table_space, 500_000_000).        % 500 MB

% Disable history file in REPL (no command history written to disk)
:- set_prolog_flag(history, 0).

% Encoding: always UTF-8
:- set_prolog_flag(encoding, utf8).

EOF

# Verify the file was written correctly
cat /etc/swipl.rc

Step 2: Verify and disable unnecessary network listeners

SWI-Prolog ships with several optional components that open network sockets. These are disabled on the Logic Node:

# Check what is listening before SWI-Prolog starts
sudo ss -tlnp

# Start swipl and immediately check for unexpected listeners
swipl &
SWIPL_PID=$!
sleep 2
sudo ss -tlnp | grep "$SWIPL_PID"
# Expected: no output — the swipl REPL does not open any network sockets by default

# Kill the background swipl
kill $SWIPL_PID

# The HTTP server library (library(http/http_server)) would open port 80/8080
# The pengine library would open a web interface
# Neither should be loaded on the Logic Node without explicit, reviewed justification
# Verify no pengine or HTTP autoload is occurring:
grep -r "http_server\|pengine" /etc/swipl.rc /usr/lib/swipl/library/MANIFEST 2>/dev/null | head -20
# Verify your own knowledge bases don't accidentally load these:
# grep -r ":- use_module(library(http" /path/to/your/prolog/
# Optional: Create a systemd hardening wrapper for production Logic Node services
# This is used when SWI-Prolog runs as a persistent service (covered in Chapter 6)
sudo tee /etc/systemd/system/logic-node.service > /dev/null << 'EOF'
[Unit]
Description=Sovereign Logic Node — SWI-Prolog Inference Engine
After=network.target
Documentation=https://www.swi-prolog.org/

[Service]
Type=simple
User=logicadmin
Group=logicadmin
WorkingDirectory=/opt/logic-node
ExecStart=/usr/bin/swipl -g "halt" /opt/logic-node/main.pl
Restart=on-failure
RestartSec=5

# Hardening directives
NoNewPrivileges=yes
ProtectSystem=strict
ProtectHome=read-only
PrivateTmp=yes
PrivateDevices=yes
ProtectKernelTunables=yes
ProtectKernelModules=yes
ProtectControlGroups=yes
RestrictAddressFamilies=AF_UNIX AF_INET AF_INET6
RestrictRealtime=yes
LimitNOFILE=1024
LimitNPROC=64
MemoryMax=4G
CPUQuota=200%

[Install]
WantedBy=multi-user.target
EOF

sudo systemctl daemon-reload
# Do not enable this service yet — it requires a main.pl entrypoint (Chapter 3)

1.5 The REPL & Memory Safety

1.5.1 Starting the REPL

With SWI-Prolog installed and the system hardened, start the interactive REPL:

logicadmin@logic-node-01:~$ swipl

You will see output similar to the following:

Welcome to SWI-Prolog (threaded, 64 bits, version 10.0.2)
SWI-Prolog comes with ABSOLUTELY NO WARRANTY. This is free software.
Please run ?- license. for legal details.

For online help and many examples, see http://www.swi-prolog.org
For built-in help, use ?- help(Topic). or ?- apropos(Word).

?-

The ?- prompt is the query interface. You are interacting directly with the WAM. Nothing between this prompt and the engine is mediated by a Python interpreter, a YAML parser, or an HTTP stack.

1.5.2 The Period as Proof Completion

The most fundamental syntactic rule in SWI-Prolog: every query must be terminated with a period (.) followed by Enter. This is not arbitrary syntax. It is the signal to the inference engine that the query is complete and proof search should begin.

?- write('Hello, Sovereign Node').
Hello, Sovereign Node
true.

?- X is 2 + 2.
X = 4.

?- member(X, [alpha, beta, gamma]).
X = alpha ;    % Press ; to request next solution
X = beta ;
X = gamma.

The period terminates the statement of the query. The engine then performs proof search and reports results. If you omit the period and press Enter, SWI-Prolog continues waiting for more input — it assumes the query is not yet complete. This catches a surprising number of new users who expect newline to submit.

The ; key at a result prompt requests the next solution via backtracking. Enter or . accepts the current result and returns to the prompt.

1.5.3 Verifying the Environment with current_prolog_flag

Before using any Logic Node in a production capacity, verify the runtime flags match the hardening configuration applied in Section 1.4.4:

?- current_prolog_flag(bounded, X).
X = true.

?- current_prolog_flag(max_integer, X).
X = 9223372036854775807.

?- current_prolog_flag(integer_rounding_function, X).
X = toward_zero.

?- current_prolog_flag(stack_limit, X).
X = 2000000000.  % Confirms /etc/swipl.rc was loaded

?- current_prolog_flag(encoding, X).
X = utf8.  % Confirms UTF-8 encoding

?- current_prolog_flag(verbose, X).
X = normal.

?- current_prolog_flag(dialect, X).
X = swi.

Run the following compound verification query to assert multiple flags simultaneously. This version uses library(error) to raise formal, typed exceptions rather than printing strings — this matters in Volume III, where the Go orchestrator parses SWI-Prolog's exit channel and must distinguish a misconfigured node from a query failure:

?- use_module(library(error)).
true.

?- verify_flag(encoding, utf8).
true.

% The predicate definition for the verification harness:
% Load this into a file (e.g., /opt/logic-node/preflight.pl)

verify_flag(Flag, Expected) :-
    current_prolog_flag(Flag, Got),
    ( Got = Expected
    -> true
    ;  domain_error(Expected, Got)   % Raises: error(domain_error(Expected, Got), _)
    ).                               % The Go orchestrator catches this on stderr

verify_node_flags :-
    Checks = [ encoding-utf8,
               bounded-true,
               dialect-swi ],
    maplist([Flag-Expected]>>(
        ( verify_flag(Flag, Expected)
        -> format("  [PASS] ~w = ~w~n", [Flag, Expected])
        ;  must_be(Flag, Expected)   % Secondary: type-safe assertion for structured errors
        )
    ), Checks).
?- verify_node_flags.
  [PASS] encoding = utf8
  [PASS] bounded = true
  [PASS] dialect = swi
true.

When a flag mismatches, domain_error/2 raises error(domain_error(expected_value, actual_value), context) — a structured Prolog exception term that the Go orchestrator in Volume III reads from the process's exception channel via swipl -g "verify_node_flags" -t "halt(1)" and maps to a typed NodePrefightError struct. A bare format/2 print to stdout would require fragile string parsing. The exception term requires none.

If any flag reports FAIL, revisit Section 1.4.4 and verify /etc/swipl.rc is being loaded. You can explicitly verify the init file is being read:

?- absolute_file_name('/etc/swipl.rc', Path, [access(read)]).
Path = '/etc/swipl.rc'.
% If this fails, the file is not accessible — check permissions

1.5.4 Memory Safety Deep-Dive: The Atom Table vs. The String Heap

This section is not optional reading. It describes a real denial-of-service vector that has been exploited in production SWI-Prolog deployments where external input was handled carelessly.

Understanding Atoms

In Prolog, an atom is an unstructured symbol. hello, nginx, prod_db, alice are all atoms. Atoms are the basic vocabulary of a Prolog program. The critical implementation detail: atoms are interned in a global hash table called the Atom Table.

When you write X = hello, SWI-Prolog looks up hello in the Atom Table. If found, it returns the existing pointer. If not found, it allocates a new entry and stores the string "hello" permanently in the Atom Table. Atom Table entries are never garbage collected. They are global and permanent for the lifetime of the Prolog process.

This is by design. Atoms are compared by pointer equality — an O(1) operation. This is the foundation of Prolog's efficient unification. The trade-off is that every unique atom ever created lives in memory forever.

The Atom Table Exhaustion Attack

Now consider what happens when external input — from a network socket, a file, a REST endpoint — is converted to atoms:

% DANGEROUS — do not use this pattern with external input
process_request(RequestString) :-
    atom_string(RequestAtom, RequestString),   % Converts to atom!
    dispatch(RequestAtom).

If an attacker sends 10,000 requests, each with a unique user-supplied value — "user_5a3f2b1c", "user_7d8e9f0a", etc. — each unique string is converted to a permanent Atom Table entry. At 10,000 requests, the table holds 10,000 permanent entries that will never be freed, even if the requests are rejected. At 1,000,000 requests, the table holds 1,000,000 entries. Memory consumption grows without bound until the process is killed by OOM or the engine raises ERROR: Out of memory: Could not allocate atom table entry.

This is Atom Table Exhaustion, and it is a denial-of-service vulnerability that is unique to Prolog systems that treat external input as atoms.

% Demonstration — run this in a test environment, not production
?- current_prolog_flag(atom_count, Before),
   forall(between(1, 1000, N),
          (atom_concat(test_atom_, N, _))),
   current_prolog_flag(atom_count, After),
   Diff is After - Before,
   format("Created ~w new permanent atoms~n", [Diff]).
Created 1000 new permanent atoms
true.
% Those 1000 atoms are now permanent. Run it again and they stay.

The String Heap: The Correct Solution

SWI-Prolog strings (distinct from atoms, written with double-quotes by default in SWI-Prolog: "hello" is a string, hello is an atom) are allocated on the garbage-collected heap. They are subject to normal GC and are freed when no longer referenced.

% SAFE — strings are heap-allocated and garbage collected
process_request(RequestString) :-
    string_codes(RequestString, Codes),           % Works on string; no atom created
    validate_input(Codes),                        % Validate before any conversion
    ( safe_to_process(Codes)
    -> convert_to_internal(Codes, InternalTerm)   % Convert to internal structure
    ;  log_rejection(RequestString)               % Log using string, not atom
    ).

The Sovereign rule is absolute:

Rule: External input enters the Logic Node as strings. It is converted to atoms only after explicit validation, sanitisation, and with bounded uniqueness guarantees. The Atom Table is a trusted, controlled vocabulary. It is not an input sink.

The practical implementation of this rule:

% Trusted vocabulary — these atoms are created at load time, bounded in count
:- dynamic known_user/1.
:- dynamic known_resource/1.

% Load-time fact assertion (safe — bounded set)
:- maplist(assert_known_user, [alice, bob, charlie, deploy_agent]).
assert_known_user(U) :- assertz(known_user(U)).

% Safe external input handler
handle_access_query(UserString, ResourceString, HourInt) :-
    % Validate format before any atom conversion
    string_length(UserString, ULen),
    string_length(ResourceString, RLen),
    ULen > 0, ULen < 64,        % Reasonable bounds
    RLen > 0, RLen < 128,
    % Convert to atoms ONLY if they are in the known vocabulary
    atom_string(UserAtom, UserString),
    atom_string(ResourceAtom, ResourceString),
    ( known_user(UserAtom)
    -> true
    ;  throw(error(unknown_user(UserString), handle_access_query/3))
    ),
    ( known_resource(ResourceAtom)
    -> true
    ;  throw(error(unknown_resource(ResourceString), handle_access_query/3))
    ),
    % Now safe to use atoms in the proof
    can_access(UserAtom, ResourceAtom, HourInt).

Security Note — atom_string/2 still creates the atom: Even calling atom_string(Atom, String) to check a string against a known atom creates the atom first, then checks it. The correct pattern is to check string membership against a string set before conversion, or to use atom_to_term/3 with controlled input.

1.5.5 Checking the Atom Table in Production

Monitor Atom Table growth on a production Logic Node:

?- current_prolog_flag(atom_count, N),
   format("Current atom count: ~w~n", [N]).
Current atom count: 12847.
# Shell-level monitoring: log atom count every 60 seconds
watch -n 60 'swipl -g "current_prolog_flag(atom_count, N), format(\"atom_count: ~w~n\", [N]), halt" 2>/dev/null'

A steadily growing atom count in a running Logic Node service is a security indicator. It means external input is reaching the Atom Table. This is either a programming error (external strings being atom-converted without validation) or an active probing attempt. Either requires immediate investigation.


Outcome: The Secure Logic Node State

At the conclusion of this chapter's build steps, the Logic Node must satisfy all items in the following checklist. This checklist is also the acceptance criterion for the Logic Node in any automated deployment pipeline.

1.6.1 Environment Verification Checklist

Operating System

# Verify OS version
lsb_release -a
# Expected: Ubuntu 24.04 (Linux Mint 22.x base) or Mint 22.x directly

# Verify snapd is absent
systemctl is-active snapd 2>/dev/null && echo "FAIL: snapd active" || echo "OK: snapd inactive"

# Verify UFW is active
sudo ufw status | grep "Status: active" && echo "OK: UFW active" || echo "FAIL: UFW inactive"

# Verify LUKS encryption (on physical/VM disk)
lsblk -o NAME,FSTYPE,MOUNTPOINT | grep -i crypt && echo "OK: LUKS present" || echo "WARN: LUKS not detected"

SWI-Prolog

# Version check
swipl --version | grep -E "version 1[0-9]\." && echo "OK: SWI-Prolog 10.x+" || echo "FAIL: wrong version"

# No unexpected network listeners after startup
swipl -g true -t halt &
sleep 1
UNEXPECTED=$(ss -tlnp | grep swipl)
[ -z "$UNEXPECTED" ] && echo "OK: no network listeners" || echo "FAIL: unexpected listener: $UNEXPECTED"

REPL Verification

?- current_prolog_flag(stack_limit, L), L >= 1_000_000_000.
true.  % Stack limit >= 1GB: OK

?- current_prolog_flag(encoding, utf8).
true.  % UTF-8 encoding: OK

?- catch(
     (atom_string(test_external_input_validation, _),
      throw(must_not_reach_here)),
     _,
     true
   ).
% This test intentionally explores atom creation behaviour
% Review your input handling if this causes concern

1.6.2 Architectural State Summary

The Logic Node, upon completing this chapter's build, occupies the following state:

What is present:

  • Linux Mint 22.x with full Ubuntu 24.04 LTS package support
  • SWI-Prolog 10.x installed from GPG-verified official PPA
  • UFW with default-deny inbound policy
  • /etc/swipl.rc with memory limits and hardening flags
  • A systemd service unit template for future production deployment

What is explicitly absent:

  • snapd and its associated trust chain
  • Unnecessary daemons (Avahi, CUPS)
  • Container runtimes (no Docker, no containerd, no runc)
  • HTTP listener (SWI-Prolog's HTTP server library not loaded or configured)
  • Pengine web interface
  • External cloud automation agents (no AWS SSM agent, no Ansible pull client)

The security posture:

  • Attack surface is limited to: SSH (VLAN-restricted), and the VM's virtio hardware interface (hypervisor-level only)
  • SWI-Prolog process has no network bindings
  • Memory limits prevent OOM-based DoS on the host
  • Atom Table policy is defined: external input handled as strings until validated

1.6.3 The Sovereign Security Model: Final Statement

The question this chapter implicitly answers is: why is a local logic engine safer than cloud-based automation?

The answer has several components, and they compound.

Availability independence. Cloud automation depends on API endpoint availability. SWI-Prolog's inference engine is a local binary. It answers queries when the VM is running, regardless of network state, vendor outage, or API deprecation. The 2021 AWS us-east-1 outage took down Terraform Cloud, GitHub Actions, and dozens of CI/CD platforms simultaneously. A Sovereign Logic Node was unaffected.

No credential externalisation. Cloud automation requires that your infrastructure credentials — IAM keys, service account tokens, SSH certificates — be transmitted to and stored on a remote platform. These credentials are then subject to that platform's security controls, which you do not audit and cannot mandate. Sovereign automation keeps credentials local, referenced by the logic engine and never transmitted to external systems for evaluation.

No vendor-defined trust model. Terraform Cloud's execution environment, GitHub Actions runners, and AWS Systems Manager are all governed by trust models defined unilaterally by their vendors, subject to change at any time. The Sovereign Node's trust model is defined entirely in Prolog facts and rules that you author, review, and control.

Audit trail ownership. Prolog's trace/2 and listing/1 mechanisms produce a complete, deterministic audit trail of every reasoning step. This trail is local, cannot be altered by a vendor, and does not require a paid plan to access at full resolution.

The fundamental argument is this: intelligence — reasoning, policy evaluation, access control decisions — is not a service to be rented from a cloud provider. It is a capability to be owned, understood, and defended locally. The Logic Node is the architectural expression of that principle.


Exercises

Exercise 1.1 — State Drift Analysis Review a Bash or Ansible script from your current infrastructure (or use the example in Section 1.1.3). Identify every point at which a failure would leave the system in an intermediate state. For each point, describe what the actual vs. intended security posture would be if execution stopped at that point.

Exercise 1.2 — REPL Exploration Start the SWI-Prolog REPL and run the following queries. For each, describe in plain English what the engine is computing:

?- succ_or_zero(0, X) :- (0 > 0 -> X = 0 ; X = 1).
?- between(1, 10, X), 0 is X mod 3.
?- length(L, 3), maplist(=(a), L).

Exercise 1.3 — Atom Table Safety Write a predicate safe_to_atom/2 that takes a string and a list of known valid atoms, and succeeds (returning the atom) only if the string matches a known atom exactly. It should fail, not throw, for unknown input. Verify your solution does not add new entries to the Atom Table for rejected inputs.

Exercise 1.4 — Flag Verification Script Write a shell script that starts SWI-Prolog in batch mode, queries the three flags (stack_limit, encoding, dialect), and exits with code 0 if all match expected values, or code 1 with a descriptive error message if any do not. This script will be used as a pre-flight check in the Logic Node deployment pipeline (Chapter 6).

Exercise 1.5 — The Closed-World Assumption Create a Prolog knowledge base with five users and three resources. Write access rules equivalent to those in Section 1.2.3. Then deliberately query for a user who is not in the knowledge base. Observe the result. Write a paragraph explaining why this behaviour is the correct security default, and contrast it with what an imperative access control function would return for an unknown user if the developer forgot to handle the "not found" case.


Further Reading

  • Warren, D.H.D. (1983). An Abstract Prolog Instruction Set. Technical Note 309, SRI International. — The foundational WAM paper.
  • Covington, M.A. et al. (1997). Coding Guidelines for Prolog. — Style and safety conventions, most still applicable.
  • SWI-Prolog Reference Manual, Chapter 2: "Overview of SWI-Prolog" — https://www.swi-prolog.org/pldoc/man?section=overview
  • SWI-Prolog Security Considerations — https://www.swi-prolog.org/pldoc/man?section=safe-examples
  • CIS Benchmarks for Ubuntu Linux 24.04 LTS — Applicable directly to Linux Mint 22.x base