Skip to main content

Chapter 3: The Static Knowledge Base

Core Concepts

Every inference engine requires a foundation of facts it treats as unconditionally true. In Prolog, this foundation is the Knowledge Base (KB): the complete set of facts and rules loaded into the WAM at any given moment. The correctness of every derivation the engine makes is bounded entirely by the correctness of that foundation. This chapter constructs it with the same rigour applied to any critical infrastructure component — with explicit schemas, hardened file permissions, and a formal model for what it means for a fact to be absent.

Five properties define the Knowledge Base as a security primitive.

1. The Closed-World Assumption is not a quirk — it is the only safe default for infrastructure logic. In an open-world system, an absent fact is unknown — it might be true but simply not recorded. SQL's NULL, Python's None, and YAML's absent keys all express this: "we don't know." For infrastructure, this is a dangerous epistemology. A firewall rule that cannot be found in the ruleset is not unknown — it is absent, and absence means the connection is blocked. A VM not listed in the inventory is not unknown — it is unprovisioned, and should be treated as an anomaly. The Closed-World Assumption (CWA) formalises this: what is not stated in the KB is false. Full stop. This is the correct model for any system where the complete ground truth is, in principle, knowable.

2. A fact is an axiom, not a cache. In imperative infrastructure tooling, the "source of truth" is typically a database, an API, or a configuration management system that is queried at runtime. The result is a cache: a snapshot of a remote state that may have changed by the time the decision that depends on it is executed. A Prolog fact file loaded into the Logic Node is an axiom — it is locally authoritative, version-controlled, and cryptographically verifiable. It does not become stale between queries because there is no remote state to diverge from. The KB is the truth, for the scope of the reasoning session it governs.

3. Arity is the schema contract. A Prolog predicate's arity — the number of arguments it takes — is the entirety of its "type signature" in the standard language. physical_host/3 with arguments (Name, MAC, RAM_GB) is a completely different predicate from physical_host/2 with arguments (Name, MAC). They do not share clauses. They do not conflict. Code that calls physical_host(X, Y, Z) and receives no answers has been given an unambiguous error signal: either no hosts exist matching those constraints, or the predicate does not exist at that arity. There is no silent downcast, no partial match, no coercion. The arity contract enforces structural consistency across the entire KB without a schema language, a validator, or a migration system.

4. The KB boundary is the first line of defence against false inference. Any fact that enters the KB is, by definition, taken as true for all subsequent reasoning. A polluted KB — one that contains false facts, injected facts, or facts from an unverified source — produces reasoning that is formally valid but substantively wrong. The engine cannot distinguish a true fact from an injected one; it reasons from whatever it is given. KB integrity is therefore not a post-hoc concern. It is enforced at the file-system level, at the consult/1 interface, and at the assertz/1 interface. These are the three surfaces that must be hardened before the Logic Node handles any fact about production infrastructure.

5. Modularity without encapsulation is coupling in disguise. A KB that grows to thousands of facts in a single file is not modular — it is monolithic with a .pl extension. True KB modularity means separating facts by domain of authority: physical inventory, network topology, security policy, access control. Each domain has an owner, a validation procedure, and a version history. Splitting these into separate files under a controlled directory structure is the KB equivalent of least-privilege: each file contains only what it is authorised to assert, and the logic node loads only what the current reasoning task requires.


Chapter Roadmap

SectionTitleFocus
3.1The Closed-World AssumptionCWA as security primitive, contrast with open-world systems
3.2Taxonomy of the Sovereign FactSchema design, arity contract, logical schema diagram
3.3The Build: proxmox_inventory.plPhysical hosts, VMs, storage, relational rules
3.4Knowledge Base Modularityinclude/1 vs consult/1, directory structure, permissions
3.5The Sovereign Oracle: First InferenceOrphaned VM detection, semantic cluster verification
OutcomeThe Datacenter as AxiomVerification checklist, conceptual transition

3.1 The Closed-World Assumption

3.1.1 Formal Definition

The Closed-World Assumption states: a ground atomic formula is false if and only if it is not derivable from the knowledge base. Derivability includes both direct facts and logical consequences of rules applied to facts. If neither path produces a proof, the formula is false — not unknown, not absent, not null. False.

This is in direct contrast to the Open-World Assumption (OWA) that underlies most database and ontology systems: in an OWA system, a formula that is not derivable is simply unestablished — it might be true in the world even if not recorded in the system. OWA is appropriate for encyclopaedic knowledge systems where the complete truth of the domain cannot be known. It is catastrophically inappropriate for infrastructure management, where the complete truth is both knowable and required to be known.

% KB contains exactly these physical host facts
physical_host(pve-node-01, "AA:BB:CC:DD:EE:01", 256).
physical_host(pve-node-02, "AA:BB:CC:DD:EE:02", 128).
physical_host(pve-node-03, "AA:BB:CC:DD:EE:03", 256).

% CWA in action:
?- physical_host('pve-node-04', _, _).
false.
% Under CWA: pve-node-04 does not exist.
% This is not "I don't know if pve-node-04 exists."
% This is "pve-node-04 is not in this datacenter."

?- physical_host('pve-node-01', MAC, _).
MAC = "AA:BB:CC:DD:EE:01".
% Under CWA: this is the complete and authoritative answer.
% There is no other MAC for pve-node-01. The KB says so.

The security implication is direct. A system that enforces CWA cannot be socially engineered into believing that a non-existent host is "probably there but not yet recorded." Either the fact is in the KB, or the query fails. There is no soft failure mode, no "best-effort" match, no fallback to a default value.

3.1.2 CWA vs. Null: The Imperative Contrast

Consider how an imperative infrastructure tool handles a host lookup for a node not in its database:

# Python/Ansible equivalent
def get_host_ram(hostname):
    result = inventory_db.query(
        "SELECT ram_gb FROM hosts WHERE name = ?", hostname
    )
    if not result:
        return None   # Open-world: "we don't have data for this host"
    return result[0]['ram_gb']

ram = get_host_ram("pve-node-04")
# ram is None
# What does the caller do with None?
# — Treats it as 0? (Silent data corruption)
# — Raises an exception? (If the caller checks — many don't)
# — Uses a default value? (Silent policy violation)
# — Logs a warning and continues? (The operation proceeds on bad data)

The None return is an open-world response: "I have no information about this host." Every caller of this function must independently decide what "no information" means for their context. Most don't. The None propagates until it causes a downstream failure — a NullPointerException in Java, a TypeError in Python, a blank field in a report — at a point arbitrarily distant from where the original data was absent.

The Prolog equivalent:

get_host_ram(Hostname, RAM) :-
    physical_host(Hostname, _, RAM).
?- get_host_ram('pve-node-04', RAM).
false.

The query fails. There is no RAM binding. No caller can accidentally use an unbound variable as if it contained data — Prolog's single-assignment semantics prevent it. The failure is immediate, localised, and unambiguous. The datacenter's authoritative inventory does not contain pve-node-04, therefore no RAM value exists, therefore any proof that requires a RAM value for pve-node-04 is false. The reasoning chain terminates cleanly.

3.1.3 CWA as the Air-Gap Model

An air-gapped datacenter has a defining property: its authoritative state is not queryable from the outside. You cannot call an API to ask whether a host exists. You cannot query a cloud provider's metadata service. The KB is the definitive record of what exists in that environment, populated by human operators with physical access, and version-controlled in a secure repository.

The CWA is the logical model for this physical reality. The KB contains what has been deliberately, explicitly, physically verified to exist. What is not in the KB has not been verified. What has not been verified does not exist, for the purposes of automated decision-making. A Logic Node operating under the CWA on an air-gapped network makes no implicit assumptions about unrecorded state. It reasons only from what it has been told. This is not a constraint — it is a security property.

Model Absent fact means Infrastructure implication
Open World (SQL NULL, Python None) Unknown — might be true Caller must handle "unknown" case; most don't
Closed World (Prolog CWA) False — definitively absent Query fails; no partial result; no propagation
Default-open (many REST APIs) Assume permissive default Unregistered entities get default access
Closed World + explicit unknown False unless explicitly marked uncertain Correct for hybrid environments (Chapter 11)

3.2 Taxonomy of the Sovereign Fact

3.2.1 Ground Facts vs. Rules: The Ontological Distinction

A ground fact is a clause with no variables. It is an unconditional assertion about the world:

physical_host('pve-node-01', "AA:BB:CC:DD:EE:01", 256).

This states: the entity pve-node-01 has MAC address "AA:BB:CC:DD:EE:01" and 256 GB of RAM. There are no conditions. It is true in this KB.

A rule is a clause with a head and a body, connected by :-. The head is true if and only if the body is provable:

well_provisioned_host(Name) :-
    physical_host(Name, _, RAM),
    RAM >= 128.

This states: well_provisioned_host(Name) is true if Name is a physical host with at least 128 GB RAM. The truth of the rule-derived fact depends entirely on the ground facts in the KB. Change the ground facts, and the rule-derived truth changes automatically.

The architecture of a Sovereign KB separates these cleanly: ground facts are in static, read-only files. Rules are in separate files that import the facts. This separation is enforced at the file-system level (Section 3.4) and at the module/2 level (Volume II, Chapter 4).

3.2.2 Schema Design: The Three Primary Relations

The Proxmox datacenter model requires three primary ground-fact schemas. Each is designed with the minimum arity that captures the required ground truth without redundancy.

physical_host/3

% physical_host(+Name, +MAC, +RAM_GB)
% Name:   atom — unique Proxmox node identifier (e.g., pve-node-01)
% MAC:    string — primary NIC MAC address (management interface)
% RAM_GB: integer — total installed RAM in gigabytes
%
% Design note: IP address is deliberately absent from this schema.
% IP addresses belong to the network topology KB, not physical inventory.
% A host's MAC is hardware-permanent; its IP may change.
% Separating them prevents the physical_host/3 schema from coupling
% to network configuration decisions.

physical_host('pve-node-01', "AA:BB:CC:DD:EE:01", 256).
physical_host('pve-node-02', "AA:BB:CC:DD:EE:02", 128).
physical_host('pve-node-03', "AA:BB:CC:DD:EE:03", 256).

vm/4

% vm(+ID, +Name, +HostName, +Status)
% ID:       integer — Proxmox VM ID (unique within cluster)
% Name:     atom — VM display name
% HostName: atom — physical_host Name where this VM runs
% Status:   atom — one of: running | stopped | suspended | error
%
% Design note: HostName is a foreign key into physical_host/3.
% The relationship is enforced by rules (Section 3.5), not by schema.
% Prolog has no declarative foreign key constraint; enforcement is logical.

vm(100, 'nginx-prod-01',    'pve-node-01', running).
vm(101, 'postgres-prod-01', 'pve-node-01', running).
vm(102, 'nginx-prod-02',    'pve-node-02', running).
vm(103, 'worker-01',        'pve-node-02', stopped).
vm(104, 'monitoring-01',    'pve-node-03', running).
vm(105, 'orphan-vm-01',     'pve-node-99', running).  % Intentional: node doesn't exist

storage/3

% storage(+HostName, +Serial, +Capacity_GB)
% HostName:    atom — physical_host Name that owns this disk
% Serial:      string — manufacturer disk serial number
% Capacity_GB: integer — raw disk capacity in gigabytes
%
% Design note: Serial is a string, not an atom.
% Disk serials are external data of unbounded variety.
% Treating them as atoms would pollute the Atom Table (Chapter 1, Section 1.5.4).
% They are compared structurally (== / unification), never used as functor names.

storage('pve-node-01', "WD-WX11A2K3P801", 4096).
storage('pve-node-01', "WD-WX11A2K3P802", 4096).
storage('pve-node-02', "ST-ZA1234BCDE001", 8192).
storage('pve-node-03', "WD-WX11A2K3P901", 4096).
storage('pve-node-03', "WD-WX11A2K3P902", 4096).

vlan/2

% vlan(+VLAN_ID, +Description)
% VLAN_ID:     integer — 802.1Q VLAN identifier (1–4094)
% Description: atom — human-readable purpose label
%
% Design note: IP ranges belong to a separate networking KB.
% This schema records only the existence and purpose of each VLAN.
% Association between IPs and VLANs is handled by network_host/3 (Section 3.3.3).

vlan(10,  management).
vlan(20,  production).
vlan(30,  storage_backend).
vlan(100, dmz).

host_firmware/3

% host_firmware(+HostName, +Component, +Hash)
% HostName:  atom   — physical_host Name (foreign key)
% Component: atom   — firmware component identifier
%                     Values: bios | bmc | nic_firmware | storage_controller
% Hash:      string — BLAKE3 or SHA256 hex digest of the verified firmware image
%
% Design note: This schema records the cryptographically verified "Gold Master"
% firmware state for each hardware component on each physical host.
% It enables Firmware Drift detection (Section 3.5.2): if a live host reports
% a firmware hash that does not match this KB entry, the discrepancy is a
% logical violation — either unauthorised firmware was flashed, or the KB
% has not been updated to reflect a legitimate, reviewed upgrade.
%
% Hash is a string (not atom) — SHA256/BLAKE3 digests are 64-character hex
% strings of external origin. They must never enter the Atom Table.
% They are compared with ==/2 or unification — structural identity only.
%
% Collecting hashes (run on each physical node, record the output):
%   BIOS:             sudo dmidecode -s bios-version | sha256sum
%   BMC (IPMI):       ipmitool mc info | grep "Firmware Revision" | sha256sum
%   NIC firmware:     ethtool -i eth0 | grep "firmware-version" | sha256sum
%   Storage ctrlr:    sudo storcli /c0 show | grep "FW Package" | sha256sum

host_firmware('pve-node-01', bios,
    "a3f5d2e1b8c7f9042a6d3e5f1b8c2d4e6f8a0b2c4d6e8f0a2b4c6d8e0f2a4b6").
host_firmware('pve-node-01', bmc,
    "1b3d5f7a9c2e4f6a8b0c2d4e6f8a0b2c4d6e8f0a2b4c6d8e0f2a4b6c8d0e2f4").
host_firmware('pve-node-02', bios,
    "a3f5d2e1b8c7f9042a6d3e5f1b8c2d4e6f8a0b2c4d6e8f0a2b4c6d8e0f2a4b6").
host_firmware('pve-node-03', bios,
    "a3f5d2e1b8c7f9042a6d3e5f1b8c2d4e6f8a0b2c4d6e8f0a2b4c6d8e0f2a4b6").

3.2.3 The Arity Contract

The arity of a predicate is its API. This is not a metaphor — it is the precise mechanism by which SWI-Prolog distinguishes predicates. physical_host/2 and physical_host/3 are different predicates, stored separately in the WAM's predicate index, with no relationship to each other.

The arity contract has three engineering consequences:

Adding a field is a breaking change. If physical_host/3 is expanded to physical_host/4 to include a CPU model, every rule that calls physical_host(Name, MAC, RAM) silently fails — not because of a type error, but because physical_host/4 has no clauses that match physical_host/3 call patterns. The failure is immediate and total. A schema change that is not propagated consistently produces failures at every call site, rather than silently succeeding with stale data.

Arity variants should be avoided. Some engineers define both physical_host/2 and physical_host/3 for "optional" fields. This is a schema antipattern. It forces every rule to contain disjunctive logic (physical_host(N, M, R) ; physical_host(N, M)) or wrapper predicates that smooth over the inconsistency. The correct design: use the maximum arity and supply canonical default values for fields that are genuinely optional. Or, better, separate the optional data into a dedicated predicate: host_cpu_model(Name, CPUModel).

Arity is the documentation. When a new engineer reads vm_on_host(VMName, PhysicalName), the arity and argument names completely specify the query interface. No docstring. No type annotation. No schema file. The predicate head is the specification.

3.2.4 Named Field Access with library(record)

The arity contract's breaking-change behaviour is the correct failure mode, but it creates friction during schema evolution. library(record) addresses this by generating named accessor predicates from a struct-like declaration, providing the benefits of named fields while maintaining the speed and security of fixed-arity compound terms.

% In /opt/logic-node/kb/inventory/proxmox_inventory.pl
:- use_module(library(record)).

% Declare the physical_host record type
% This generates accessor predicates automatically at load time:
%   physical_host_name/2, physical_host_mac/2, physical_host_ram_gb/2
%   make_physical_host/4, set_physical_host_name/3, etc.

:- record physical_host_record(
    name:atom    = unknown,
    mac:string   = "",
    ram_gb:integer = 0
).

library(record) generates the following predicates automatically from the above declaration:

Generated predicate Purpose
make_physical_host_record(+Options, -Record) Construct a record term with named field assignment
physical_host_record_name(?Record, ?Name) Access or match the name field
physical_host_record_mac(?Record, ?MAC) Access or match the mac field
physical_host_record_ram_gb(?Record, ?RAM) Access or match the ram_gb field
set_physical_host_record_ram_gb(+New, +OldRecord, -NewRecord) Functional update — produces a new record
% Constructing a record term via named fields
?- make_physical_host_record([name('pve-node-01'), mac("AA:BB:CC:DD:EE:01"),
                               ram_gb(256)], R).
R = physical_host_record('pve-node-01', "AA:BB:CC:DD:EE:01", 256).

% Accessing a field by name — no position dependency
?- R = physical_host_record('pve-node-01', "AA:BB:CC:DD:EE:01", 256),
   physical_host_record_ram_gb(R, RAM).
RAM = 256.

% Schema evolution: adding a field
% Change the :- record declaration to add cpu_model:
%   :- record physical_host_record(name:atom, mac:string, ram_gb:integer,
%                                  cpu_model:atom = unknown).
%
% All existing code using physical_host_record_name/2, _mac/2, _ram_gb/2
% continues to work unchanged. The new field has a default value.
% Only code that explicitly needs cpu_model calls physical_host_record_cpu_model/2.
% The breaking-change problem is eliminated for additive schema extensions.

The critical distinction from standard physical_host/3: the library(record) accessors are generated predicates that operate on a fixed-arity compound term — physical_host_record/3 (or /4 after adding cpu_model). The underlying representation remains a WAM compound term with all the performance and structural properties of Section 3.2.3. The named accessors are a convenience layer compiled away at load time, not a runtime abstraction. There is no overhead for a field access compared to direct unification.

When to use library(record) vs. bare predicates: use bare predicates (physical_host/3) for stable, permanent schemas unlikely to grow. Use library(record) for schemas in active development where additive field extension is expected — hardware inventory schemas are typical candidates, since new hardware attributes emerge as the infrastructure evolves.

3.2.5 Diagram: Logical Schema Map

The following diagram shows the relationships between the four primary schemas as a logical entity map. Arrows represent foreign-key relationships enforced by rules, not by schema declarations.

flowchart TD
    subgraph Physical["Physical Layer — physical_host/3"]
        PH["physical_host(\n  Name,\n  MAC,\n  RAM_GB\n)"]
    end

    subgraph Storage["Storage Layer — storage/3"]
        ST["storage(\n  HostName,\n  Serial,\n  Capacity_GB\n)"]
    end

    subgraph Virtual["Virtual Layer — vm/4"]
        VM["vm(\n  ID,\n  Name,\n  HostName,\n  Status\n)"]
    end

    subgraph Network["Network Layer — vlan/2 + network_host/3"]
        VL["vlan(\n  VLAN_ID,\n  Description\n)"]
        NH["network_host(\n  HostName,\n  IP,\n  VLAN_ID\n)"]
    end

    subgraph Rules["Derived Facts — Rules"]
        R1["vm_on_host/2\nVM.HostName →\nphysical_host.Name"]
        R2["host_storage/2\nstorage.HostName →\nphysical_host.Name"]
        R3["vlan_member/2\nnetwork_host.VLAN_ID →\nvlan.VLAN_ID"]
        R4["orphaned_vm/1\nVM.HostName ∉\nphysical_host.Name"]
    end

    ST -->|"HostName ref"| PH
    VM -->|"HostName ref"| PH
    NH -->|"HostName ref"| PH
    NH -->|"VLAN_ID ref"| VL

    PH --> R1
    PH --> R2
    PH --> R3
    VM --> R1
    ST --> R2
    NH --> R3
    VM --> R4
    PH --> R4

    style PH fill:#1A2B4A,color:#FFFFFF
    style ST fill:#2E6B3E,color:#FFFFFF
    style VM fill:#2E6DA4,color:#FFFFFF
    style VL fill:#4A235A,color:#FFFFFF
    style NH fill:#4A235A,color:#FFFFFF
    style R1 fill:#8B6914,color:#FFFFFF
    style R2 fill:#8B6914,color:#FFFFFF
    style R3 fill:#8B6914,color:#FFFFFF
    style R4 fill:#8B0000,color:#FFFFFF

Reading the diagram: The Physical layer is the root anchor — every other schema references it via HostName. Rules (gold/red nodes) are derived facts computed from combinations of ground facts. The orphaned_vm/1 rule (red) is the first security-relevant derivation: it identifies VMs whose HostName has no corresponding physical_host/3 fact — a cluster inconsistency that represents either a decommissioned node whose VMs were not migrated, or an injected VM record referencing a non-existent host.


3.3 The Build: proxmox_inventory.pl

3.3.1 Directory Structure and File Creation

Create the KB directory structure on the Logic Node:

logicadmin@logic-node-01:~$ sudo mkdir -p /opt/logic-node/kb/{inventory,security,networking}
logicadmin@logic-node-01:~$ sudo chown -R logicadmin:logicadmin /opt/logic-node/
logicadmin@logic-node-01:~$ ls -la /opt/logic-node/kb/
drwxr-xr-x  5 logicadmin logicadmin 4096 kb/
drwxr-xr-x  2 logicadmin logicadmin 4096 kb/inventory/
drwxr-xr-x  2 logicadmin logicadmin 4096 kb/security/
drwxr-xr-x  2 logicadmin logicadmin 4096 kb/networking/

Create the primary inventory file:

logicadmin@logic-node-01:~$ nano /opt/logic-node/kb/inventory/proxmox_inventory.pl

3.3.2 The Complete proxmox_inventory.pl

%% =============================================================================
%% FILE:    /opt/logic-node/kb/inventory/proxmox_inventory.pl
%% PURPOSE: Sovereign ground truth for Proxmox cluster physical inventory.
%% SCHEMA VERSION: 1.0
%% MODIFIED: 2026-01-15
%% AUTHORITY: Infrastructure Team
%% CLASSIFICATION: INTERNAL — do not expose via external API without sanitisation
%%
%% This file is the axiomatic foundation for all physical infrastructure
%% reasoning. Every fact here is treated as unconditionally true under the
%% Closed-World Assumption. Facts are loaded read-only via consult/1.
%% Direct assertz/1 into these predicates is prohibited — see Section 3.4.3.
%% =============================================================================

:- module(proxmox_inventory, [
    physical_host/3,
    vm/4,
    storage/3,
    vlan/2,
    network_host/3,
    host_firmware/3
]).

%% -----------------------------------------------------------------------------
%% PHYSICAL HOSTS
%% physical_host(+Name, +MAC, +RAM_GB)
%% Source of truth: physical inspection + Proxmox node list
%% MAC: management interface (bond0 or eth0 depending on node config)
%% -----------------------------------------------------------------------------

physical_host('pve-node-01', "AA:BB:CC:DD:EE:01", 256).
physical_host('pve-node-02', "AA:BB:CC:DD:EE:02", 128).
physical_host('pve-node-03', "AA:BB:CC:DD:EE:03", 256).

%% -----------------------------------------------------------------------------
%% VIRTUAL MACHINES
%% vm(+ID, +Name, +HostName, +Status)
%% Source of truth: `pvesh get /cluster/resources --type vm` output, sanitised
%% Status values: running | stopped | suspended | error
%% HostName MUST correspond to an entry in physical_host/3.
%% Orphan detection (Section 3.5) enforces this relationship.
%% -----------------------------------------------------------------------------

vm(100, 'nginx-prod-01',    'pve-node-01', running).
vm(101, 'postgres-prod-01', 'pve-node-01', running).
vm(102, 'nginx-prod-02',    'pve-node-02', running).
vm(103, 'worker-01',        'pve-node-02', stopped).
vm(104, 'monitoring-01',    'pve-node-03', running).
vm(105, 'orphan-vm-01',     'pve-node-99', running).

%% -----------------------------------------------------------------------------
%% STORAGE DEVICES
%% storage(+HostName, +Serial, +Capacity_GB)
%% Source of truth: `smartctl -a /dev/sdX` serial field, per-node
%% Serial is a string (not atom) — external data, Atom Table protection applies
%% -----------------------------------------------------------------------------

storage('pve-node-01', "WD-WX11A2K3P801", 4096).
storage('pve-node-01', "WD-WX11A2K3P802", 4096).
storage('pve-node-02', "ST-ZA1234BCDE001", 8192).
storage('pve-node-03', "WD-WX11A2K3P901", 4096).
storage('pve-node-03', "WD-WX11A2K3P902", 4096).

%% -----------------------------------------------------------------------------
%% VLANs
%% vlan(+VLAN_ID, +Description)
%% Source of truth: network team VLAN register
%% VLAN_IDs 1–4094 (802.1Q spec). ID 1 is native — never use as sovereign VLAN.
%% -----------------------------------------------------------------------------

vlan(10,  management).
vlan(20,  production).
vlan(30,  storage_backend).
vlan(100, dmz).

%% -----------------------------------------------------------------------------
%% NETWORK HOST ASSIGNMENTS
%% network_host(+HostName, +IP, +VLAN_ID)
%% Maps physical or virtual host names to their management IP and VLAN
%% IP is a string for display; integer-octet ip/4 form used in networking KB
%% HostName MUST correspond to physical_host/3 or vm/4 Name field
%% -----------------------------------------------------------------------------

network_host('pve-node-01', "10.10.1.1",  10).
network_host('pve-node-02', "10.10.1.2",  10).
network_host('pve-node-03', "10.10.1.3",  10).
network_host('nginx-prod-01',    "10.20.1.10", 20).
network_host('postgres-prod-01', "10.20.1.11", 20).
network_host('nginx-prod-02',    "10.20.1.12", 20).
network_host('worker-01',        "10.20.1.13", 20).
network_host('monitoring-01',    "10.10.1.50", 10).

%% -----------------------------------------------------------------------------
%% FIRMWARE GOLD MASTER HASHES
%% host_firmware(+HostName, +Component, +Hash)
%% Source of truth: physical inspection — run collection commands per-node,
%% record output in this file, set chattr +i after each verified update.
%% Hash algorithm: SHA256 (upgrade to BLAKE3 when library(crypto) supports it natively)
%% -----------------------------------------------------------------------------

host_firmware('pve-node-01', bios,
    "a3f5d2e1b8c7f9042a6d3e5f1b8c2d4e6f8a0b2c4d6e8f0a2b4c6d8e0f2a4b6").
host_firmware('pve-node-01', bmc,
    "1b3d5f7a9c2e4f6a8b0c2d4e6f8a0b2c4d6e8f0a2b4c6d8e0f2a4b6c8d0e2f4").
host_firmware('pve-node-02', bios,
    "a3f5d2e1b8c7f9042a6d3e5f1b8c2d4e6f8a0b2c4d6e8f0a2b4c6d8e0f2a4b6").
host_firmware('pve-node-03', bios,
    "a3f5d2e1b8c7f9042a6d3e5f1b8c2d4e6f8a0b2c4d6e8f0a2b4c6d8e0f2a4b6").

3.3.3 Relational Rules: The First Layer of Inference

Create a separate rules file that imports the inventory:

logicadmin@logic-node-01:~$ nano /opt/logic-node/kb/inventory/proxmox_rules.pl
%% =============================================================================
%% FILE:    /opt/logic-node/kb/inventory/proxmox_rules.pl
%% PURPOSE: First-order relational rules derived from proxmox_inventory.pl
%% These rules produce no side effects. They are pure derivations from
%% ground facts. Loading this file without its dependency will produce
%% existence_error warnings — load proxmox_inventory.pl first.
%% =============================================================================

:- use_module('/opt/logic-node/kb/inventory/proxmox_inventory').

%% vm_on_host(+VMName, -PhysicalHostName)
%% True if the named VM runs on a physical host that exists in the KB.
%% Fails (under CWA) if the VM does not exist, or its host does not exist.

vm_on_host(VMName, PhysicalHostName) :-
    vm(_, VMName, PhysicalHostName, _),
    physical_host(PhysicalHostName, _, _).

%% host_total_storage(+HostName, -TotalCapacity_GB)
%% Computes total raw storage across all disks attached to a physical host.
%% Uses aggregate_all/3 — requires library(aggregate).

:- use_module(library(aggregate)).

host_total_storage(HostName, TotalGB) :-
    physical_host(HostName, _, _),
    aggregate_all(sum(Cap), storage(HostName, _, Cap), TotalGB).

%% vlan_member(+HostName, -VLAN_ID)
%% True if HostName has a network assignment on the given VLAN,
%% AND that VLAN exists in the vlan/2 registry.

vlan_member(HostName, VLAN_ID) :-
    network_host(HostName, _, VLAN_ID),
    vlan(VLAN_ID, _).

%% host_vm_count(+HostName, -Count)
%% Number of VMs assigned to this physical host, regardless of status.

host_vm_count(HostName, Count) :-
    physical_host(HostName, _, _),
    aggregate_all(count, vm(_, _, HostName, _), Count).

%% running_vms_on_host(+HostName, -VMName)
%% Enumerate running VMs on a specific host (backtrackable).

running_vms_on_host(HostName, VMName) :-
    vm(_, VMName, HostName, running),
    physical_host(HostName, _, _).

3.3.4 Loading the KB with consult/1

logicadmin@logic-node-01:~$ swipl
% Load the inventory — consult/1 reads the file, asserts all clauses into
% the current module's database, and compiles them for WAM execution.
?- consult('/opt/logic-node/kb/inventory/proxmox_inventory.pl').
true.

% Verify the loaded predicates
?- listing(physical_host/3).
physical_host('pve-node-01', "AA:BB:CC:DD:EE:01", 256).
physical_host('pve-node-02', "AA:BB:CC:DD:EE:02", 128).
physical_host('pve-node-03', "AA:BB:CC:DD:EE:03", 256).

% Basic inventory queries
?- physical_host(Name, _, RAM), RAM >= 200.
Name = 'pve-node-01',
RAM = 256 ;
Name = 'pve-node-03',
RAM = 256.

% All running VMs and their hosts
?- vm(ID, Name, Host, running).
ID = 100, Name = 'nginx-prod-01',    Host = 'pve-node-01' ;
ID = 101, Name = 'postgres-prod-01', Host = 'pve-node-01' ;
ID = 102, Name = 'nginx-prod-02',    Host = 'pve-node-02' ;
ID = 104, Name = 'monitoring-01',    Host = 'pve-node-03' ;
ID = 105, Name = 'orphan-vm-01',     Host = 'pve-node-99'.
% Note: orphan-vm-01 appears here — its Host is an unverified atom.
% The orphan detection rule in Section 3.5 surfaces this as a violation.

% Load rules
?- consult('/opt/logic-node/kb/inventory/proxmox_rules.pl').
true.

% Query using derived rules
?- vm_on_host(VMName, Host).
VMName = 'nginx-prod-01',    Host = 'pve-node-01' ;
VMName = 'postgres-prod-01', Host = 'pve-node-01' ;
VMName = 'nginx-prod-02',    Host = 'pve-node-02' ;
VMName = 'worker-01',        Host = 'pve-node-02' ;
VMName = 'monitoring-01',    Host = 'pve-node-03'.
% orphan-vm-01 does NOT appear — its host 'pve-node-99' fails physical_host/3.
% CWA: the join silently excludes it because no physical_host fact exists for it.

?- host_total_storage('pve-node-01', Total).
Total = 8192.

?- host_vm_count('pve-node-02', Count).
Count = 2.

3.3.5 The consult/1 Security Surface

consult/1 is the mechanism by which the Logic Node reads its KB from disk. It is also the primary file-system attack surface. Understanding what consult/1 does internally is required before hardening it.

consult(File) performs the following operations in sequence:

  1. Opens File for reading (file-system access — subject to OS permissions)
  2. Reads and parses each clause as a Prolog term
  3. For each fact clause Head., calls assertz(Head) into the current module
  4. For each rule clause Head :- Body., calls assertz((Head :- Body))
  5. For each directive :- Goal., calls call(Goal) immediately

Step 5 is the critical one. A directive in a .pl file is executed at load time, in the context of the loading process, with the permissions of the logicadmin user. A crafted KB file that contains:

:- shell("rm -rf /opt/logic-node/kb/").
:- assertz(physical_host('attacker-node', "00:00:00:00:00:00", 0)).
:- consult('/etc/passwd').  % read arbitrary files into WAM

...would execute all three directives when loaded. The first deletes the KB directory. The second injects a false fact into the physical inventory. The third loads /etc/passwd into the WAM as Prolog terms (it will fail to parse, but the attempt is made).

The defence is layered and is covered in full in Section 3.4.3. The immediate takeaway: consult/1 is not a safe mechanism for loading KB files from untrusted sources. It is the correct and efficient mechanism for loading KB files from the hardened, version-controlled, permission-restricted KB directory — and only that.


3.4 Knowledge Base Modularity

3.4.1 include/1 vs. consult/1

SWI-Prolog provides two mechanisms for loading additional Prolog source at load time. They have different semantics with different security implications.

consult/1 (and its alias [filename]) loads a file into the WAM at query time or at startup. The loaded file is processed in its own module context (if it declares one) or in the calling module. The file can be re-consulted to reload updated facts. It is the standard mechanism for loading KB files into a running logic session.

include/1 is a textual inclusion directive, processed at compile/load time. include(file) behaves as if the contents of file were literally pasted at the point of the directive in the including file. There is no separate module context. All clauses in the included file are added to the including module. It cannot be selectively reloaded.

Property consult/1 include/1
Processing time Runtime (query or startup) Load time (compile phase)
Module context Separate (if module declared) Same as including file
Reloadable Yes — consult/1 again to update No — requires full reload
Execution of directives Yes — :- goal is executed Yes — :- goal is executed
Use case Runtime KB loading, hot reload Splitting a single large module file
Security boundary File-system permission check at call time File-system permission check at compile time

For the Sovereign KB architecture, consult/1 is the correct mechanism for loading inventory, security policy, and networking fact files. include/1 is appropriate for splitting a large rule file into logically distinct sections that are always loaded together. Never use include/1 for files that contain ground facts from external sources — the lack of module isolation means injected facts land directly in the including module's predicate database.

3.4.2 KB Directory Structure

The full KB directory layout for the Logic Node:

/opt/logic-node/
├── main.pl                    ← Entry point; loads all modules in order
├── kb/
│   ├── inventory/
│   │   ├── proxmox_inventory.pl   ← Ground facts: hosts, VMs, storage, VLANs
│   │   └── proxmox_rules.pl       ← Derived rules over inventory facts
│   ├── networking/
│   │   ├── topology.pl            ← IP/subnet assignments, routing facts
│   │   └── firewall_policy.pl     ← Port/protocol allow/deny facts
│   └── security/
│       ├── access_control.pl      ← User/role/resource access facts
│       └── audit_rules.pl         ← Rules that derive audit events
└── preflight.pl               ← Environment verification (Chapter 1, Section 1.5.3)

The main.pl entry point:

%% =============================================================================
%% FILE:    /opt/logic-node/main.pl
%% PURPOSE: Logic Node entry point. Loads KB modules in dependency order.
%% Load with: swipl -l /opt/logic-node/main.pl
%% =============================================================================

:- consult('/opt/logic-node/preflight').   % Verify environment flags first

:- consult('/opt/logic-node/kb/inventory/proxmox_inventory').
:- consult('/opt/logic-node/kb/inventory/proxmox_rules').
:- consult('/opt/logic-node/kb/networking/topology').
:- consult('/opt/logic-node/kb/security/access_control').

:- format("Logic Node KB loaded. ~w physical hosts. ~w VMs.~n",
          [PhysHosts, VMCount]) :-
    aggregate_all(count, physical_host(_, _, _), PhysHosts),
    aggregate_all(count, vm(_, _, _, _), VMCount).

3.4.3 Hardening KB Directory Permissions

The KB files contain the axiomatic ground truth of the datacenter. They must be:

  • Readable by the logicadmin user (for consult/1 to function)
  • Not writable by the logicadmin user at runtime (to prevent consult/1-triggered directives from modifying them)
  • Not writable by any process that the Logic Node spawns
  • Writable only by a dedicated kb-admin group, via controlled update procedures
# Create a dedicated group for KB authorship
sudo groupadd kb-admin

# Add the infrastructure team members who maintain KB files
sudo usermod -aG kb-admin engineer01
sudo usermod -aG kb-admin engineer02
# logicadmin is NOT added to kb-admin

# Set ownership: root owns, kb-admin group can write, logicadmin can only read
sudo chown -R root:kb-admin /opt/logic-node/kb/

# Permissions:
# Directories: 750 — root:kb-admin can traverse; others cannot
# Files:       640 — root:kb-admin can write; logicadmin (group: kb-admin? No.) reads via world-read? No.
# Correct approach: logicadmin needs read access but not write.
# Use ACLs for precise control:

# Install ACL tools if not present
sudo apt install -y acl

# Set directory ACLs: logicadmin can read and execute (traverse) KB directories
sudo find /opt/logic-node/kb/ -type d -exec setfacl -m u:logicadmin:rx {} \;

# Set file ACLs: logicadmin can read KB files but not write
sudo find /opt/logic-node/kb/ -type f -name "*.pl" -exec setfacl -m u:logicadmin:r {} \;

# Set base permissions: owner (root) full, group (kb-admin) read+write, other: none
sudo find /opt/logic-node/kb/ -type f -name "*.pl" -exec chmod 640 {} \;
sudo find /opt/logic-node/kb/ -type d -exec chmod 750 {} \;

# Verify the ACL on a KB file
getfacl /opt/logic-node/kb/inventory/proxmox_inventory.pl
# # file: opt/logic-node/kb/inventory/proxmox_inventory.pl
# # owner: root
# # group: kb-admin
# user::rw-
# user:logicadmin:r--        ← logicadmin can read, cannot write
# group::rw-
# mask::rw-
# other::---

# Verify logicadmin cannot write
sudo -u logicadmin bash -c 'echo "test" >> /opt/logic-node/kb/inventory/proxmox_inventory.pl'
# bash: /opt/logic-node/kb/inventory/proxmox_inventory.pl: Permission denied
# CORRECT — write attempt is blocked at OS level

# Verify logicadmin can read (consult/1 will work)
sudo -u logicadmin bash -c 'head -5 /opt/logic-node/kb/inventory/proxmox_inventory.pl'
# %% FILE: /opt/logic-node/kb/inventory/proxmox_inventory.pl
# CORRECT — read access confirmed

Security Note — Immutable Flag: For maximum hardening, set the immutable flag on KB files after their initial write. This prevents even root from modifying them without explicitly removing the flag — a step that would be logged by auditd:

sudo chattr +i /opt/logic-node/kb/inventory/proxmox_inventory.pl
lsattr /opt/logic-node/kb/inventory/proxmox_inventory.pl
# ----i--------e-- /opt/logic-node/kb/inventory/proxmox_inventory.pl
# The 'i' flag: immutable. Even root cannot write without 'chattr -i' first.

Removing the immutable flag is itself an auditable event under auditd with appropriate rules (Volume II, Chapter 3). This creates a two-step, logged procedure for any KB modification: chattr -i (logged) → edit → chattr +i (logged). Silent KB modification by a compromised process becomes structurally impossible.

Infrastructure Refinement — ZFS Read-Only Snapshot: The chattr +i flag operates at the Linux VFS layer and is enforced by the guest kernel. A compromised guest kernel — via a privilege escalation exploit or a kernel module backdoor — can bypass it. For block-level integrity that survives a compromised guest, take a ZFS snapshot of the KB dataset on the Proxmox host after each verified Gold Master update, then set readonly=on on the dataset:

# Run on the PROXMOX HOST — not inside the Logic Node VM

# After a verified KB update is complete and chattr +i is set inside the VM,
# take a named snapshot from the host side:
SNAP_DATE=$(date +%Y%m%d-%H%M%S)
zfs snapshot rpool/data/vm-200-disk-0@kb-goldmaster-${SNAP_DATE}

# Set the dataset to read-only at the ZFS layer
# This is enforced by the host kernel — the guest OS cannot override it
zfs set readonly=on rpool/data/vm-200-disk-0

# Verify
zfs get readonly rpool/data/vm-200-disk-0
# NAME                        PROPERTY  VALUE   SOURCE
# rpool/data/vm-200-disk-0    readonly  on      local

# To perform a legitimate KB update:
# 1. Set readonly=off on the host (logged by host auditd)
zfs set readonly=off rpool/data/vm-200-disk-0
# 2. Update KB files inside the VM (guest auditd logs chattr -i / edit / chattr +i)
# 3. Re-snapshot and re-set readonly=on on the host
zfs snapshot rpool/data/vm-200-disk-0@kb-goldmaster-${NEW_DATE}
zfs set readonly=on rpool/data/vm-200-disk-0

The defence-in-depth stack is now: guest-kernel ACL (blocks logicadmin) → chattr +i (blocks root inside VM) → ZFS readonly=on (blocks any guest write at the block device layer) → ZFS snapshot (provides point-in-time rollback if corruption is detected). An attacker who compromises the guest VM entirely cannot silently modify the KB without also compromising the Proxmox host, which is a separate security domain.

3.4.4 Preventing Runtime KB Pollution via assertz/1

The file-system permissions in Section 3.4.3 protect static KB files from direct modification. They do not prevent the running Prolog process from calling assertz(physical_host(attacker_node, "00:00:00:00:00:00", 0)) at runtime. This is the second KB pollution surface.

The defence is a module-level permission wrapper:

%% =============================================================================
%% FILE:    /opt/logic-node/kb/inventory/proxmox_inventory.pl
%% Add the following to the module declaration:
%% =============================================================================

:- module(proxmox_inventory, [
    physical_host/3,
    vm/4,
    storage/3,
    vlan/2,
    network_host/3
]).

%% Seal the module against runtime assertion after initial load.
%% This hook fires before any assertz/retract on predicates in this module.

:- meta_predicate seal_against_runtime_mutation(0).

%% After the module is loaded, lock all dynamic predicates to read-only.
%% Use module-level hooks to intercept assert attempts.

:- initialization(seal_inventory_module, main).

seal_inventory_module :-
    % Mark all exported predicates as static after loading
    % static predicates cannot be modified by assertz/retract
    forall(
        member(Pred/Arity, [physical_host/3, vm/4, storage/3, vlan/2, network_host/3]),
        ( functor(Head, Pred, Arity),
          predicate_property(proxmox_inventory:Head, defined)
          -> set_predicate_attribute(proxmox_inventory:Pred/Arity, static, true)
          ;  true
        )
    ).

A simpler and more portable approach: do not declare KB predicates as dynamic. In SWI-Prolog, a predicate that is not declared :- dynamic cannot be modified by assertz/1 or retract/1 at runtime — attempts raise a permission_error:

% In proxmox_inventory.pl — NO :- dynamic declarations for ground facts.
% The absence of :- dynamic is intentional and security-relevant.

% physical_host/3 is NOT declared dynamic.
% Attempting to assert into it at runtime:
?- assertz(physical_host('injected-node', "FF:FF:FF:FF:FF:FF", 0)).
ERROR: permission_error(modify,static_procedure,physical_host/3)
%
% This is the correct behaviour. The KB is read-only after load.

The rule: ground fact predicates in the Sovereign KB are never declared :- dynamic. Only predicates that are designed to accumulate facts at runtime — audit logs, session state, query results — carry the :- dynamic declaration, and those predicates are in separate files with separate permission controls.


3.5 The Sovereign Oracle: First Inference

3.5.1 Orphaned VM Detection

An orphaned VM is a VM record whose HostName field references a physical host that does not exist in the physical_host/3 facts. This condition represents one of three real-world scenarios:

  1. A physical node was decommissioned and removed from the inventory without migrating its VMs
  2. A VM record was injected into the KB with a fabricated or incorrect host reference
  3. A host was renamed and the VM records were not updated consistently

All three are operational or security incidents. The Logic Node detects them automatically from the KB state, without any additional tooling, network access, or API call.

%% Add to proxmox_rules.pl

%% orphaned_vm(+VMName)
%% True if VMName exists in vm/4 but its assigned HostName has no
%% corresponding physical_host/3 fact. Under CWA, the absent physical_host
%% fact means the host does not exist — making the VM an orphan.

orphaned_vm(VMName) :-
    vm(_, VMName, HostName, _),
    \+ physical_host(HostName, _, _).

%% orphaned_vm_detail(+VMName, +VMID, +HostName, +Status)
%% Full detail record for audit/reporting purposes.

orphaned_vm_detail(VMName, VMID, AssignedHost, Status) :-
    vm(VMID, VMName, AssignedHost, Status),
    \+ physical_host(AssignedHost, _, _).
?- consult('/opt/logic-node/kb/inventory/proxmox_rules.pl').
true.

% Query: which VMs are orphaned?
?- orphaned_vm(VMName).
VMName = 'orphan-vm-01'.

% Full detail for the orphan
?- orphaned_vm_detail(VMName, ID, Host, Status).
VMName = 'orphan-vm-01',
ID = 105,
Host = 'pve-node-99',
Status = running.

The result is derived in one step from two ground facts: the vm/4 entry for orphan-vm-01, and the absence of any physical_host/3 entry for pve-node-99. The CWA does the work — no explicit "does host exist?" check is needed. The \+ (negation as failure) operator is the logical expression of the CWA: \+ physical_host('pve-node-99', _, _) succeeds precisely because no such fact exists, which under CWA means the host does not exist.

3.5.2 Semantic Cluster Verification

The orphaned VM rule is the first example of semantic verification: querying the KB for conditions that are logically consistent at the syntactic level but semantically anomalous from an infrastructure perspective. The VM record is syntactically valid — it is a well-formed vm/4 fact. The anomaly is semantic: it references an entity that has no grounding in the physical inventory.

Extend the verification ruleset:

%% =============================================================================
%% Semantic verification rules — add to proxmox_rules.pl
%% Each of these rules identifies a class of KB inconsistency.
%% They produce no output in a consistent KB — they are silent success/failure.
%% =============================================================================

%% vms_on_nonexistent_vlan(+VMName, +VLAN_ID)
%% VM has a network assignment referencing a VLAN not in the vlan/2 registry.

vm_on_nonexistent_vlan(VMName, VLAN_ID) :-
    vm(_, VMName, _, _),
    network_host(VMName, _, VLAN_ID),
    \+ vlan(VLAN_ID, _).

%% storage_on_nonexistent_host(+Serial, +HostName)
%% Disk record references a host not in physical_host/3.

storage_on_nonexistent_host(Serial, HostName) :-
    storage(HostName, Serial, _),
    \+ physical_host(HostName, _, _).

%% host_without_network(+HostName)
%% Physical host has no network_host/3 assignment — cannot be reached.

host_without_network(HostName) :-
    physical_host(HostName, _, _),
    \+ network_host(HostName, _, _).

%% overloaded_host(+HostName, +VMCount)
%% Physical host running more VMs than a configurable threshold.

overloaded_host(HostName, VMCount) :-
    physical_host(HostName, _, _),
    aggregate_all(count, vm(_, _, HostName, running), VMCount),
    VMCount > 5.  % Threshold: adjust to cluster policy

%% firmware_drift(+HostName, +Component, +LiveHash, +KBHash)
%% Detects a mismatch between the firmware hash recorded in host_firmware/3
%% and a hash reported live from the physical node.
%%
%% LiveHash is supplied by the caller — typically a fact asserted by an
%% out-of-band collection agent that runs `dmidecode`/`ipmitool` on each
%% node and writes the results into a transient, session-scoped KB file.
%% The rule does the comparison; the collection mechanism is external.
%%
%% Under CWA: if host_firmware/3 has no entry for (HostName, Component),
%% the predicate fails — the Gold Master has not been recorded.
%% A separate rule (firmware_unregistered/2) surfaces this condition.

firmware_drift(HostName, Component, LiveHash, KBHash) :-
    host_firmware(HostName, Component, KBHash),
    live_firmware(HostName, Component, LiveHash),  % Asserted by collection agent
    LiveHash \== KBHash.

%% firmware_unregistered(+HostName, +Component)
%% A live firmware report exists for a component with no Gold Master in the KB.
%% This is a different violation class from drift: not "changed" but "untracked."

firmware_unregistered(HostName, Component) :-
    live_firmware(HostName, Component, _),
    \+ host_firmware(HostName, Component, _).

%% verify_cluster_consistency/0
%% Run all semantic checks. Reports violations. Succeeds even if violations found.
%% Intended for use in automated health-check pipelines.

verify_cluster_consistency :-
    format("~n=== Sovereign Cluster Consistency Report ===~n"),

    % Orphaned VMs
    format("~n[1] Orphaned VMs (HostName not in physical_host/3):~n"),
    ( \+ orphaned_vm_detail(_, _, _, _)
    -> format("    PASS — no orphaned VMs~n")
    ;  forall(
           orphaned_vm_detail(VMName, ID, Host, Status),
           format("    VIOLATION: VM ~w (ID ~w) assigned to nonexistent host '~w' [~w]~n",
                  [VMName, ID, Host, Status])
       )
    ),

    % Storage on nonexistent hosts
    format("~n[2] Storage on nonexistent hosts:~n"),
    ( \+ storage_on_nonexistent_host(_, _)
    -> format("    PASS — all storage records reference valid hosts~n")
    ;  forall(
           storage_on_nonexistent_host(Serial, Host),
           format("    VIOLATION: Disk '~w' assigned to nonexistent host '~w'~n",
                  [Serial, Host])
       )
    ),

    % VMs on nonexistent VLANs
    format("~n[3] VMs on unregistered VLANs:~n"),
    ( \+ vm_on_nonexistent_vlan(_, _)
    -> format("    PASS — all VM VLANs registered~n")
    ;  forall(
           vm_on_nonexistent_vlan(VMName, VLAN),
           format("    VIOLATION: VM '~w' on unregistered VLAN ~w~n", [VMName, VLAN])
       )
    ),

    % Hosts without network
    format("~n[4] Physical hosts without network assignment:~n"),
    ( \+ host_without_network(_)
    -> format("    PASS — all hosts have network assignments~n")
    ;  forall(
           host_without_network(HostName),
           format("    VIOLATION: Host '~w' has no network_host/3 assignment~n", [HostName])
       )
    ),

    % Firmware drift — only runs if live_firmware/3 facts are loaded
    format("~n[5] Firmware drift (Gold Master vs live state):~n"),
    ( \+ live_firmware(_, _, _)
    -> format("    SKIP — no live_firmware/3 facts loaded (run collection agent first)~n")
    ;  ( \+ firmware_drift(_, _, _, _), \+ firmware_unregistered(_, _)
       -> format("    PASS — all firmware hashes match Gold Master~n")
       ;  forall(
              firmware_drift(HN, Comp, Live, KB),
              format("    DRIFT: ~w [~w] live=~w expected=~w~n", [HN, Comp, Live, KB])
          ),
          forall(
              firmware_unregistered(HN, Comp),
              format("    UNTRACKED: ~w [~w] has no Gold Master hash in KB~n", [HN, Comp])
          )
       )
    ),

    format("~n=== Report complete ===~n").
?- verify_cluster_consistency.

=== Sovereign Cluster Consistency Report ===

[1] Orphaned VMs (HostName not in physical_host/3):
    VIOLATION: VM orphan-vm-01 (ID 105) assigned to nonexistent host 'pve-node-99' [running]

[2] Storage on nonexistent hosts:
    PASS — all storage records reference valid hosts

[3] VMs on unregistered VLANs:
    PASS — all VM VLANs registered

[4] Physical hosts without network assignment:
    PASS — all hosts have network assignments

=== Report complete ===
true.

3.5.3 The Inference-as-Audit Model

verify_cluster_consistency/0 is not a monitoring script. It is a logical derivation. It produces its output by querying relations that are defined entirely in terms of the KB's ground facts. It has no external dependencies. It makes no network calls. It consults no external API. It is reproducible: given the same KB, it produces the same report, deterministically, every time.

This is the Inference-as-Audit model: the Logic Node's knowledge base is the authoritative state of the datacenter, and the rules that query it are the authoritative audit procedures. Running the audit requires loading the KB and executing one predicate. The audit result is a logical consequence of the KB state — not an estimate, not a best-effort scan, not a snapshot of a mutable external system.

The correctness guarantee: if verify_cluster_consistency/0 reports no violations, then, under the CWA, the KB is internally consistent. Every VM is on a host that exists. Every disk is attached to a host that exists. Every network assignment references a registered VLAN. This is not a probabilistic claim. It is a logical proof.


Outcome: The Datacenter as Axiom

3.6.1 The Conceptual Transition

Chapter 3 produces a specific shift in how infrastructure state is represented and reasoned about. The engineer arriving from an imperative background models infrastructure state as objects in memory, rows in a database, or YAML documents on disk. The engineer who has completed this chapter models it as axioms — ground facts that are unconditionally true for the duration of the reasoning session, from which all further claims are derived by logic rather than by code.

Imperative model Sovereign KB model
State is in a database, queried at runtime State is in the KB, consulted at load time
Absent record → NULL → caller handles Absent fact → false → proof fails
Schema defined in DDL/ORM/Pydantic Schema defined by predicate arity and argument conventions
Consistency checked by triggers or application logic Consistency checked by logical rules (semantic verification)
Modification is the normal operation Modification is a controlled, auditable exception
Foreign key enforcement by the DB engine Reference integrity enforced by relational rules
"Source of truth" is a live mutable system "Source of truth" is a version-controlled fact file

3.6.2 Verification Checklist

KB Structure:

# Verify directory structure
ls -la /opt/logic-node/kb/inventory/
# proxmox_inventory.pl  proxmox_rules.pl

# Verify permissions — logicadmin cannot write
sudo -u logicadmin bash -c 'echo test >> /opt/logic-node/kb/inventory/proxmox_inventory.pl' \
    && echo "FAIL: write succeeded" \
    || echo "PASS: write blocked"

# Verify immutable flag (if set)
lsattr /opt/logic-node/kb/inventory/proxmox_inventory.pl | grep -q '\-i\-' \
    && echo "PASS: immutable flag set" \
    || echo "WARN: immutable flag not set"

KB Content:

% Load and verify fact counts
?- consult('/opt/logic-node/kb/inventory/proxmox_inventory.pl').
true.

?- aggregate_all(count, physical_host(_, _, _), N), format("Hosts: ~w~n", [N]).
Hosts: 3

?- aggregate_all(count, vm(_, _, _, _), N), format("VMs: ~w~n", [N]).
VMs: 6

% CWA verification — absent facts return false, not null
?- \+ physical_host('nonexistent-host', _, _).
true.  % Correct CWA behaviour

% Static predicate protection — assertz must fail
?- catch(
       assertz(physical_host('injected', "00:00:00:00:00:00", 0)),
       error(permission_error(modify, static_procedure, _), _),
       true
   ).
true.  % Exception was raised and caught — KB is protected

Semantic Verification:

% Load rules and run consistency check
?- consult('/opt/logic-node/kb/inventory/proxmox_rules.pl').
true.

?- verify_cluster_consistency.
% Expected output: 1 violation (orphan-vm-01)
% All other checks: PASS

All checks passing (with the expected intentional orphan violation) confirms the Logic Node's KB substrate is correctly structured, hardened, and producing accurate inference.

Exercises

Exercise 3.1 — CWA Boundary Analysis Add a fourth physical host pve-node-04 to proxmox_inventory.pl with MAC "AA:BB:CC:DD:EE:04" and 64 GB RAM. Without re-consulting the file, query physical_host('pve-node-04', _, _). Observe the result. Then re-consult the file and repeat the query. Explain what changed in the WAM's clause database between the two queries, and why the CWA produced false in the first case.

Exercise 3.2 — Arity Contract Enforcement Attempt to define physical_host/4 with an additional CPU_model argument alongside the existing physical_host/3 facts. Write a rule full_host_info/4 that attempts to query both arities. Observe and explain the behaviour when one arity is present and the other is absent.

Exercise 3.3 — consult/1 Directive Injection Simulation In a test environment (not the production KB directory), create a file /tmp/test_injection.pl containing a harmless directive:

:- format("DIRECTIVE EXECUTED during consult~n").
physical_host('test-node', "FF:FF:FF:FF:FF:FF", 8).

Consult this file and observe that the directive fires immediately. Then explain the defence chain that prevents this from being a production risk: file-system ACLs, immutable flag, and non-dynamic predicate declarations.

Exercise 3.4 — Extended Semantic Verification Add the following to proxmox_rules.pl and verify it against the current inventory:

  • duplicate_mac(MAC, Host1, Host2): finds two different physical hosts claiming the same MAC address
  • vm_id_conflict(ID, Name1, Name2): finds two VMs with the same Proxmox ID but different names
  • unreachable_vm(VMName): a running VM that has no network_host/3 assignment

Exercise 3.5 — The Datacenter as Axiom Using only proxmox_inventory.pl as loaded, write a Prolog query (no additional rules) that answers: "Which physical host has the highest total storage capacity, and what is that capacity?" The query must use aggregate_all/3 and produce a single answer. Verify it against the inventory figures in Section 3.3.2.


Further Reading

  • Reiter, R. (1978). On Closed World Data Bases. In Logic and Data Bases, Gallaire & Minker (eds.). Plenum Press. — The foundational paper on the CWA, predating Prolog's widespread use.
  • Lloyd, J.W. (1987). Foundations of Logic Programming. Springer. Second Edition. Chapter 3 (Negation as Failure) covers the logical basis of \+ and its relationship to CWA.
  • SWI-Prolog Manual: consult/1https://www.swi-prolog.org/pldoc/man?predicate=consult/1
  • SWI-Prolog Manual: Module system — https://www.swi-prolog.org/pldoc/man?section=modules
  • SWI-Prolog Manual: aggregate_all/3https://www.swi-prolog.org/pldoc/man?predicate=aggregate_all/3
  • chattr(1) man page — Linux immutable file flag; relevant to KB hardening
  • Linux ACL documentation: setfacl(1), getfacl(1) — file-level access control beyond standard Unix permissions