Chapter 6: The SWI-Go Interface — Golog 2026
Overview
Part II left us with a capable, self-contained Prolog system. The infrastructure knowledge base models the homelab in structured Dicts. The DCG parser ingests /var/log/auth.log and turns raw syslog text into queryable data. The security analysis pipeline cross-references dynamic log events against static infrastructure facts. All of this runs cleanly inside the SWI-Prolog REPL on the Mint VM.
The REPL is not a production system. A production system accepts log lines as they arrive at hundreds or thousands per second, evaluates them against live rules, and emits alerts in milliseconds. It needs to be addressable over a network, manageable as a service, and integrated with the rest of the infrastructure toolchain. It needs Go.
This chapter builds the bridge. By the end of it, a Go binary will embed the SWI-Prolog engine as a shared library inside the same OS process, pass log lines from the Go side of memory into the Prolog engine for DCG parsing and rule evaluation, and receive structured alert Dicts back — all without spawning a subprocess, opening a socket, or serialising through JSON. The boundary between Go and Prolog will be a function call.
6.1 The Architecture of Embedding
Before writing a line of code, it is worth being deliberate about the architecture decision being made here, because there are three plausible approaches to connecting Go and Prolog and only one of them is appropriate for the workload we are building toward.
Option 1: HTTP/JSON microservice. Run SWI-Prolog as a separate process exposing an HTTP API. Go sends a JSON-encoded log line, Prolog parses it, responds with a JSON-encoded alert. This is the default architecture for polyglot systems in 2026, and it is the wrong choice here. A round-trip through HTTP, JSON serialisation on the Go side, JSON deserialisation on the Prolog side, query evaluation, JSON serialisation on the Prolog side, and JSON deserialisation on the Go side adds three to eight milliseconds of overhead per query in a well-tuned deployment. At five thousand log lines per second — a modest rate for a busy homelab gateway — that is a seventeen-millisecond processing deficit per second, compounding. The network stack becomes the bottleneck before the logic engine has done any meaningful work.
Option 2: Subprocess via stdin/stdout. Launch swipl as a child process, pipe log lines into its stdin as terms, read results from stdout. This eliminates network overhead but introduces a different set of problems: the protocol between Go and Prolog is ad-hoc text, subprocess crashes require restart logic, and the throughput ceiling is determined by pipe buffer limits and OS context switching between two processes. In practice, this approach also produces brittle code: any change to the Prolog output format silently breaks the Go parser, and debugging failures requires reasoning about what is happening in two separately executing processes simultaneously.
The memory layout of the resulting process looks like this:
SINGLE OS PROCESS MEMORY LAYOUT
─────────────────────────────────────────────────────────────────
┌─────────────────────────────────────────────────────────┐
│ OS Process │
│ │
│ ┌──────────────────────┐ ┌──────────────────────────┐ │
│ │ Go Runtime │ │ SWI-Prolog Engine │ │
│ │ ───────────────── │ │ ────────────────────── │ │
│ │ Goroutines │ │ WAM (Wasm Instr. Set) │ │
│ │ GC-managed heap │ │ Global Stack │ │
│ │ Channel scheduler │ │ Local Stack │ │
│ │ log tailing loop │ │ Trail │ │
│ │ HTTP handlers │ │ Atom Table │ │
│ │ alert dispatcher │ │ Knowledge Base │ │
│ └──────────┬───────────┘ └─────────────────────────┘ │
│ │ │ │
│ │ CGO Boundary │ │
│ │ ┌────────────────────┐ │ │
│ └─▶ │ C Bridge Layer │ ◀─┘ │
│ │ (libswipl C API) │ │
│ │ PL_put_string │ │
│ │ PL_call │ │
│ │ PL_get_dict_key │ │
│ └────────────────────┘ │
│ │
│ Shared: libc, libswipl.so, OS file descriptors │
└─────────────────────────────────────────────────────────┘
One process. One address space. Query overhead: ~50–200μs.
No serialisation. No sockets. No subprocess management.
─────────────────────────────────────────────────────────────────
The CGO boundary is the critical interface to understand. CGO is Go's mechanism for calling C functions from Go code. It compiles a thin shim layer that handles the Go-to-C calling convention transition, manages the interaction between Go's garbage collector and C's manually managed memory, and exposes C types to Go code under the C. namespace. The SWI-Prolog C API — documented as the Foreign Language Interface (FLI) — is the set of C functions that create Prolog terms, call predicates, and extract results. Our Go code will call these C functions through CGO, and the bridge module we build in this chapter will wrap that C API into idiomatic Go types.
One constraint worth stating explicitly: the Go garbage collector and the Prolog engine each have their own memory management systems, and the two must not interfere with each other. Prolog terms created by the C API live on the Prolog stacks, not the Go heap, and their lifetimes are governed by Prolog's backtracking and garbage collection rules, not Go's. This means we must be careful about how long we hold references to Prolog term references from Go — they are valid only within a specific Prolog query context, and using them after the query completes is undefined behaviour. The bridge layer we build encapsulates this lifecycle correctly so that the application code does not need to reason about it directly.
6.2 Provisioning the Bridge
The SWI-Prolog C development headers must be present on the Mint VM before CGO can link against libswipl. Install them now:
sudo apt update
sudo apt install -y libswipl-dev
# Verify the headers and library are in place
dpkg -L libswipl-dev | grep -E "\.h$|\.so$"
# Expected output includes:
# /usr/lib/swi-prolog/include/SWI-Prolog.h
# /usr/lib/swi-prolog/include/SWI-Stream.h
# /usr/lib/x86_64-linux-gnu/libswipl.so
# Verify the version matches the runtime
swipl --version
# SWI-Prolog version 10.x.y
pkg-config --modversion swipl
# 10.x.y
If pkg-config --modversion swipl fails, the libswipl-dev package may not have installed a .pc file, which is common in some Debian packaging versions. In that case, set the compiler flags manually in the Go source, as shown in section 6.3.
Now create the Go module. The orchestrator module lives alongside the Prolog knowledge base in the logic-lab directory structure:
mkdir -p ~/logic-lab/go/orchestrator
cd ~/logic-lab/go/orchestrator
go mod init homelab/orchestrator
# The primary FFI library for embedding SWI-Prolog in Go
go get github.com/guregu/null@latest # for nullable types at the boundary
go get github.com/dop251/goja@latest # if JS scripting is needed later; skip for now
For the SWI-Prolog FFI itself, the situation in 2026 is that the most mature, maintained binding is a direct CGO wrapper rather than a higher-level abstraction library. Several community libraries exist — go-swipl, golog, and others — but each covers a different subset of the FLI and some lag behind SWI-Prolog 10.x's Dict support. For this book, we write a thin, well-understood bridge layer directly against the C API. This has the significant advantage that every line of the bridge is visible, auditable, and debuggable, and the reader knows exactly what is happening at the boundary. Third-party wrappers are black boxes at the most critical interface in the system.
Create the bridge package directory:
mkdir -p ~/logic-lab/go/orchestrator/plbridge
The directory structure for Part III is:
~/logic-lab/go/orchestrator/
├── go.mod
├── go.sum
├── main.go ← application entry point
├── plbridge/
│ ├── engine.go ← engine lifecycle (init, halt)
│ ├── query.go ← query construction and execution
│ ├── terms.go ← type translation layer
│ └── errors.go ← Prolog exception handling
└── logtail/
└── tailer.go ← log file tail loop
6.3 The "Hello Reason" Query
We begin with the minimum viable bridge: initialise the embedded engine, ask it a question, get an answer, shut it down. This establishes that the CGO link is working and the knowledge base loads correctly before we attempt anything more complex.
Create plbridge/engine.go:
// plbridge/engine.go
// Lifecycle management for the embedded SWI-Prolog engine.
// Part III, Chapter 6 - Modern SWI-Prolog (2026 Edition)
package plbridge
/*
#cgo pkg-config: swipl
#include <SWI-Prolog.h>
#include <stdlib.h>
// Helper: initialise the engine with a set of string arguments.
// PL_initialise expects the same argc/argv convention as main().
static int init_engine(int argc, char **argv) {
return PL_initialise(argc, argv);
}
*/
import "C"
import (
"fmt"
"os"
"unsafe"
)
// Engine represents the embedded SWI-Prolog runtime.
// Only one Engine may exist per OS process — the underlying libswipl
// is a singleton. Attempting to create a second Engine will panic.
type Engine struct {
initialised bool
}
var globalEngine *Engine
// NewEngine initialises the SWI-Prolog engine with the given knowledge
// base file. The knowledge base path should be the full absolute path
// to the top-level Prolog module file that loads all dependencies.
func NewEngine(knowledgeBasePath string) (*Engine, error) {
if globalEngine != nil {
return nil, fmt.Errorf("plbridge: only one Engine may exist per process")
}
// Build the argv for PL_initialise.
// -g true: run the goal 'true' on startup (no interactive prompt)
// -t halt: halt the engine when the toplevel goal completes
// -f <path>: load the named file as the initialisation file
// --: separator between Prolog args and application args
args := []string{
os.Args[0], // argv[0] must be the program name
"-g", "true",
"-t", "halt",
"-f", knowledgeBasePath,
"--",
}
cArgs := make([]*C.char, len(args))
for i, a := range args {
cArgs[i] = C.CString(a)
}
defer func() {
for _, ca := range cArgs {
C.free(unsafe.Pointer(ca))
}
}()
ret := C.init_engine(C.int(len(cArgs)), &cArgs[0])
if ret == 0 {
return nil, fmt.Errorf("plbridge: PL_initialise failed — check knowledge base path and SWI-Prolog installation")
}
globalEngine = &Engine{initialised: true}
return globalEngine, nil
}
// Halt shuts down the SWI-Prolog engine cleanly.
// This should be called via defer in main() immediately after NewEngine.
func (e *Engine) Halt() {
if e.initialised {
C.PL_halt(0)
e.initialised = false
globalEngine = nil
}
}
Create plbridge/query.go:
// plbridge/query.go
// Query construction and execution against the embedded engine.
// Part III, Chapter 6 - Modern SWI-Prolog (2026 Edition)
package plbridge
/*
#cgo pkg-config: swipl
#include <SWI-Prolog.h>
#include <stdlib.h>
*/
import "C"
import (
"fmt"
"unsafe"
)
// CallBool calls a zero-argument Prolog goal and returns whether it
// succeeds. This is the simplest form of query — a yes/no question.
func CallBool(module, functor string) (bool, error) {
cModule := C.CString(module)
cFunctor := C.CString(functor)
defer C.free(unsafe.Pointer(cModule))
defer C.free(unsafe.Pointer(cFunctor))
atom := C.PL_new_atom(cFunctor)
pred := C.PL_pred(C.PL_new_functor(atom, 0),
C.PL_new_atom(cModule))
qid := C.PL_open_query(nil, C.PL_Q_NORMAL, pred, nil)
result := C.PL_next_solution(qid)
C.PL_close_query(qid)
if result == C.FALSE {
return false, nil
}
return true, nil
}
Now the application entry point. Create main.go:
// main.go
// Entry point for the homelab orchestrator.
// Part III, Chapter 6 - Modern SWI-Prolog (2026 Edition)
package main
import (
"fmt"
"log"
"homelab/orchestrator/plbridge"
)
const knowledgeBase = "/home/logicdev/logic-lab/prolog/health_monitor.pl"
func main() {
engine, err := plbridge.NewEngine(knowledgeBase)
if err != nil {
log.Fatalf("engine init: %v", err)
}
defer engine.Halt()
// The "Hello Reason" query: ask the embedded engine if the
// health_monitor module loaded successfully by calling true/0.
ok, err := plbridge.CallBool("system", "true")
if err != nil {
log.Fatalf("query error: %v", err)
}
fmt.Printf("Engine online: %v\n", ok)
}
Build and run:
cd ~/logic-lab/go/orchestrator
go build -o orchestrator .
./orchestrator
# Engine online: true
If the build fails with a linker error along the lines of cannot find -lswipl, the pkg-config entry is missing. In that case, replace the #cgo pkg-config: swipl directive with explicit flags:
// #cgo CFLAGS: -I/usr/lib/swi-prolog/include
// #cgo LDFLAGS: -L/usr/lib/x86_64-linux-gnu -lswipl -Wl,-rpath,/usr/lib/x86_64-linux-gnu
The rpath flag embeds the library search path into the binary itself so the dynamic linker can find libswipl.so at runtime without requiring LD_LIBRARY_PATH to be set — an important detail for running the orchestrator as a systemd service.
6.4 Data Translation: The Type Map
This is the most important section of the chapter. The CGO boundary is not a data-copy operation — it is a type translation. A Go string and a Prolog string are different objects stored in different memory regions managed by different runtimes, and the bridge layer must convert between them correctly on every crossing. Getting this wrong produces memory corruption, incorrect query results, or Atom Table Exhaustion — the vulnerability we secured against in Chapter 5.
The following table is the complete type map for this system:
GO → PROLOG TYPE MAP
─────────────────────────────────────────────────────────────────
Go Type Prolog Term FLI Function
───────────────── ────────────────── ──────────────────────
int, int64 integer PL_put_integer
float64 float (IEEE 754) PL_put_float
string string ← ALWAYS PL_put_string_nchars
(NEVER atom)
bool atom: true/false PL_put_atom_chars
[]interface{} list PL_put_list (iterative)
map[string]any Dict PL_put_dict
nil atom: nil PL_put_atom_chars
struct (tagged) compound term PL_put_functor
─────────────────────────────────────────────────────────────────
The string → string rule is NON-NEGOTIABLE.
External data (log lines, hostnames, messages) must NEVER be
converted to Prolog atoms. Use PL_put_string_nchars, which
creates a garbage-collected string object on the Prolog heap.
PL_put_atom_chars creates an interned atom — avoid for variable
external data. Atom Table Exhaustion is a production DoS risk.
─────────────────────────────────────────────────────────────────
The most complex translation is map[string]any → Prolog Dict, because it requires constructing a Dict term dynamically from a list of key-value pairs. Create plbridge/terms.go:
// plbridge/terms.go
// Type translation between Go values and Prolog terms.
// Part III, Chapter 6 - Modern SWI-Prolog (2026 Edition)
package plbridge
/*
#cgo pkg-config: swipl
#include <SWI-Prolog.h>
#include <stdlib.h>
#include <string.h>
// Helper: build a Prolog Dict from parallel arrays of key atoms and value terms.
// Returns TRUE on success.
static int build_dict(term_t dict_term, atom_t tag,
int n, atom_t *keys, term_t *values) {
return PL_put_dict(dict_term, tag, n, keys, values);
}
*/
import "C"
import (
"fmt"
"sort"
"unsafe"
)
// PutString places a Go string into a Prolog term as a Prolog string
// (garbage-collected, NOT an atom). This is the mandatory conversion
// for all external/untrusted data. See Chapter 5 §5.3 for the
// Atom Table Exhaustion rationale.
func PutString(t C.term_t, s string) error {
cStr := C.CString(s)
defer C.free(unsafe.Pointer(cStr))
if C.PL_put_string_nchars(t, C.size_t(len(s)), cStr) == 0 {
return fmt.Errorf("PutString: PL_put_string_nchars failed for %q", s)
}
return nil
}
// PutAtom places a Go string into a Prolog term as an atom.
// Use ONLY for trusted, bounded, static identifiers (module names,
// predicate names, enum-like values). Never use for log data.
func PutAtom(t C.term_t, s string) error {
cStr := C.CString(s)
defer C.free(unsafe.Pointer(cStr))
if C.PL_put_atom_chars(t, cStr) == 0 {
return fmt.Errorf("PutAtom: PL_put_atom_chars failed for %q", s)
}
return nil
}
// PutInteger places a Go int64 into a Prolog term as an integer.
func PutInteger(t C.term_t, n int64) error {
if C.PL_put_integer(t, C.intptr_t(n)) == 0 {
return fmt.Errorf("PutInteger: PL_put_integer failed for %d", n)
}
return nil
}
// PutFloat places a Go float64 into a Prolog term as a float.
func PutFloat(t C.term_t, f float64) error {
if C.PL_put_float(t, C.double(f)) == 0 {
return fmt.Errorf("PutFloat: PL_put_float failed for %f", f)
}
return nil
}
// PutDict constructs a Prolog Dict term from a Go map and a tag string.
// The tag becomes the Dict's type identifier (e.g., "vm", "alert").
// Map keys become Dict field names (atoms); values are translated
// recursively using PutValue.
//
// The resulting Dict is structurally identical to the Dicts built in
// Chapter 4: tag{key1: val1, key2: val2, ...}
func PutDict(t C.term_t, tag string, data map[string]any) error {
// Keys must be sorted — SWI-Prolog's Dict implementation requires
// keys in a canonical order. dict_pairs/3 in Prolog also returns
// them sorted. We sort here to match that expectation.
keys := make([]string, 0, len(data))
for k := range data {
keys = append(keys, k)
}
sort.Strings(keys)
n := len(keys)
cKeys := make([]C.atom_t, n)
cValues := make([]C.term_t, n)
// Allocate term references for the values.
// PL_new_term_refs allocates n consecutive term slots.
if n > 0 {
base := C.PL_new_term_refs(C.int(n))
for i, k := range keys {
ck := C.CString(k)
cKeys[i] = C.PL_new_atom(ck)
C.free(unsafe.Pointer(ck))
cValues[i] = base + C.term_t(i)
if err := PutValue(cValues[i], data[k]); err != nil {
return fmt.Errorf("PutDict: value for key %q: %w", k, err)
}
}
}
cTag := C.CString(tag)
tagAtom := C.PL_new_atom(cTag)
C.free(unsafe.Pointer(cTag))
var keysPtr *C.atom_t
var valsPtr *C.term_t
if n > 0 {
keysPtr = &cKeys[0]
valsPtr = &cValues[0]
}
if C.build_dict(t, tagAtom, C.int(n), keysPtr, valsPtr) == 0 {
return fmt.Errorf("PutDict: PL_put_dict failed for tag %q", tag)
}
return nil
}
// PutValue dispatches on the Go type and places the appropriate Prolog term.
// This is the recursive heart of the type translation layer.
func PutValue(t C.term_t, v any) error {
switch val := v.(type) {
case string:
return PutString(t, val) // strings → Prolog strings (never atoms)
case int:
return PutInteger(t, int64(val))
case int64:
return PutInteger(t, val)
case float64:
return PutFloat(t, val)
case bool:
atom := "false"
if val { atom = "true" }
return PutAtom(t, atom)
case map[string]any:
// Untagged map — use generic tag "data"
return PutDict(t, "data", val)
case nil:
return PutAtom(t, "nil")
default:
return fmt.Errorf("PutValue: unsupported Go type %T", v)
}
}
The diagram below shows exactly how a Go struct carrying a parsed log line morphs into a Prolog Dict at the boundary:
GO → PROLOG DICT TRANSLATION AT THE CGO BOUNDARY
─────────────────────────────────────────────────────────────────
Go side (GC-managed heap) Prolog side (WAM stacks)
type LogEntry struct {
Month int ──── integer(6)
Day int ──── integer(15)
Hour int ──── integer(14)
Minute int ──── integer(32)
Second int ──── integer(1)
Host string ──── string("mint-logic-lab") ← NOT atom
Process string ──── string("sshd") ← NOT atom
PID int ──── integer(1234)
Message string ──── string("Accepted pub...") ← NOT atom
}
PutDict(t, "log_entry", map[string]any{...})
│
▼
CGO boundary (C API calls)
PL_new_atom("log_entry") → tag atom
PL_put_string_nchars(...) → string terms
PL_put_integer(...) → integer terms
PL_put_dict(t, tag, n, keys, values)
│
▼
log_entry{ ← identical structure to
day: 15, Chapter 4 Dicts
host: "mint-logic-lab", built in Prolog source
hour: 14,
message: "Accepted pub...",
minute: 32,
month: 6,
pid: 1234,
process: "sshd",
second: 1
}
─────────────────────────────────────────────────────────────────
The Dict the Go side constructs is structurally indistinguishable
from one written directly in Prolog source. The Prolog rules in
log_analysis.pl do not know or care which side created the Dict.
The key insight in this diagram is the last sentence. The security_events/2 predicate in log_analysis.pl queries E.process and E.message without any knowledge of whether those fields were set by a Prolog fact or by a Go PutDict call. The boundary is transparent to the logic layer, which is exactly what we want. The Prolog code we wrote in Part II does not need to change at all to work with data coming from Go.
Extracting results back across the boundary is the inverse operation. Create the reader side in plbridge/terms.go:
// GetString extracts a Prolog string or atom term into a Go string.
func GetString(t C.term_t) (string, error) {
var cStr *C.char
var cLen C.size_t
// Try string first (the secure type for external data)
if C.PL_get_string_chars(t, &cStr, &cLen) != 0 {
return C.GoStringN(cStr, C.int(cLen)), nil
}
// Fall back to atom (for Prolog-originated atoms like 'online', 'sshd')
if C.PL_get_atom_chars(t, &cStr) != 0 {
return C.GoString(cStr), nil
}
return "", fmt.Errorf("GetString: term is neither string nor atom")
}
// GetInteger extracts a Prolog integer term into a Go int64.
func GetInteger(t C.term_t) (int64, error) {
var n C.intptr_t
if C.PL_get_intptr(t, &n) == 0 {
return 0, fmt.Errorf("GetInteger: term is not an integer")
}
return int64(n), nil
}
// GetDictKey retrieves the value of a named key from a Prolog Dict term
// and returns it as a Go value. The tag argument is used to verify the
// Dict's type — pass "" to skip tag verification.
func GetDictKey(dict C.term_t, key string) (C.term_t, error) {
cKey := C.CString(key)
defer C.free(unsafe.Pointer(cKey))
keyAtom := C.PL_new_atom(cKey)
result := C.PL_new_term_ref()
if C.PL_get_dict_key(keyAtom, dict, result) == 0 {
return 0, fmt.Errorf("GetDictKey: key %q not found in Dict", key)
}
return result, nil
}
6.5 Tutorial: The Live Log Tailer
With the type translation layer in place, we can build the system that motivated the entire architecture: a Go routine that tails /var/log/auth.log in real time, passes each new line to the Prolog engine for DCG parsing and security rule evaluation, and emits high-priority alerts when Prolog detects a brute-force pattern.
First, add the query execution predicate to plbridge/query.go — the function that takes a Prolog term as input and returns a result term:
// CallWithTerm calls a Prolog predicate of arity 2: pred(+Input, -Output).
// It passes inputTerm as the first argument and returns the Output term
// on success. Returns (0, nil) if the predicate fails (no solution).
// Returns (0, err) if a Prolog exception is thrown.
func CallWithTerm(module, functor string, inputTerm C.term_t) (C.term_t, error) {
cModule := C.CString(module)
cFunctor := C.CString(functor)
defer C.free(unsafe.Pointer(cModule))
defer C.free(unsafe.Pointer(cFunctor))
// Allocate two term slots: one for input, one for output.
args := C.PL_new_term_refs(2)
inputSlot := args
outputSlot := args + 1
// Unify the input slot with the provided term.
if C.PL_put_term(inputSlot, inputTerm) == 0 {
return 0, fmt.Errorf("CallWithTerm: PL_put_term failed")
}
atom := C.PL_new_atom(cFunctor)
funPtr := C.PL_new_functor(atom, 2)
pred := C.PL_pred(funPtr, C.PL_new_atom(cModule))
qid := C.PL_open_query(nil, C.PL_Q_CATCH_EXCEPTION, pred, args)
result := C.PL_next_solution(qid)
if result == C.FALSE {
// Check for a thrown exception before closing the query.
exTerm := C.PL_exception(qid)
C.PL_close_query(qid)
if exTerm != 0 {
return 0, extractPrologException(exTerm)
}
return 0, nil // Predicate simply failed — no solution
}
C.PL_close_query(qid)
return outputSlot, nil
}
Now add the Prolog predicate that Go will call. This is the entry point from Go into the logic layer — a thin wrapper in log_analysis.pl that accepts a raw log line string and returns either an alert Dict or the atom none:
% go_process_line(+LineString, -Result)
% Entry point called from the Go bridge.
% Returns an alert Dict if the line triggers a security event,
% or the atom 'none' if the line is benign or unparseable.
go_process_line(LineStr, Result) :-
( parse_log_line(LineStr, Entry),
is_security_event(Entry)
-> Result = alert{
process: Entry.process,
message: Entry.message,
host: Entry.host,
hour: Entry.hour,
minute: Entry.minute,
second: Entry.second
}
; Result = none
).
Add this predicate to log_analysis.pl and add go_process_line/2 to the module's export list.
Now the log tailer. Create logtail/tailer.go:
// logtail/tailer.go
// Tails a log file and passes each new line to the Prolog engine.
// Part III, Chapter 6 - Modern SWI-Prolog (2026 Edition)
package logtail
import (
"bufio"
"fmt"
"io"
"log"
"os"
"time"
"homelab/orchestrator/plbridge"
)
// TailAndAnalyse opens the given log file, seeks to the end, and
// continuously reads new lines as they are appended. Each line is
// passed to the Prolog engine for security analysis. Alerts are
// written to the provided alert channel.
//
// This function blocks until ctx is cancelled or a fatal error occurs.
func TailAndAnalyse(logPath string, alerts chan<- Alert) error {
f, err := os.Open(logPath)
if err != nil {
return fmt.Errorf("tailer: open %s: %w", logPath, err)
}
defer f.Close()
// Seek to end — we only care about new lines arriving after startup.
if _, err := f.Seek(0, io.SeekEnd); err != nil {
return fmt.Errorf("tailer: seek: %w", err)
}
scanner := bufio.NewScanner(f)
ticker := time.NewTicker(100 * time.Millisecond)
defer ticker.Stop()
for {
for scanner.Scan() {
line := scanner.Text()
if line == "" {
continue
}
alert, err := analyseLine(line)
if err != nil {
// Log the error but do not stop the tail loop —
// a single bad line must not halt the analyser.
log.Printf("tailer: analyse error: %v", err)
continue
}
if alert != nil {
alerts <- *alert
}
}
// scanner.Scan() returns false at EOF. We poll for new content.
<-ticker.C
// Re-establish the scanner on the same file handle to pick up
// new content that has been appended since the last read.
scanner = bufio.NewScanner(f)
}
}
// Alert carries the fields extracted from a Prolog alert Dict.
type Alert struct {
Process string
Host string
Message string
Hour int
Minute int
Second int
}
// analyseLine passes a single log line to the Prolog engine and
// returns an Alert if the engine raises one, or nil for benign lines.
func analyseLine(line string) (*Alert, error) {
// Step 1: Create a Prolog string term from the Go string.
// SECURITY: PutString uses PL_put_string_nchars — the line
// becomes a Prolog string, never an atom. This enforces the
// Chapter 5 Atom Table Exhaustion prevention standard.
inputTerm := C.PL_new_term_ref() // Note: C imported via plbridge
if err := plbridge.PutString(inputTerm, line); err != nil {
return nil, fmt.Errorf("analyseLine: PutString: %w", err)
}
// Step 2: Call go_process_line/2 in the log_analysis module.
resultTerm, err := plbridge.CallWithTerm("log_analysis", "go_process_line", inputTerm)
if err != nil {
return nil, fmt.Errorf("analyseLine: Prolog exception: %w", err)
}
if resultTerm == 0 {
// Predicate failed — treat as benign.
return nil, nil
}
// Step 3: Check the result. If it's the atom 'none', the line is benign.
var cStr *C.char
if C.PL_get_atom_chars(resultTerm, &cStr) != 0 {
if C.GoString(cStr) == "none" {
return nil, nil
}
}
// Step 4: Extract fields from the alert Dict.
process, _ := plbridge.GetDictStringKey(resultTerm, "process")
host, _ := plbridge.GetDictStringKey(resultTerm, "host")
message, _ := plbridge.GetDictStringKey(resultTerm, "message")
hour, _ := plbridge.GetDictIntKey(resultTerm, "hour")
minute, _ := plbridge.GetDictIntKey(resultTerm, "minute")
second, _ := plbridge.GetDictIntKey(resultTerm, "second")
return &Alert{
Process: process,
Host: host,
Message: message,
Hour: int(hour),
Minute: int(minute),
Second: int(second),
}, nil
}
Wire the tailer into main.go:
// main.go (updated for Chapter 6 tutorial)
package main
import (
"fmt"
"log"
"homelab/orchestrator/plbridge"
"homelab/orchestrator/logtail"
)
const (
knowledgeBase = "/home/logicdev/logic-lab/prolog/log_analysis.pl"
authLog = "/var/log/auth.log"
)
func main() {
engine, err := plbridge.NewEngine(knowledgeBase)
if err != nil {
log.Fatalf("engine init: %v", err)
}
defer engine.Halt()
fmt.Println("Homelab Orchestrator online. Tailing", authLog)
fmt.Println("Waiting for security events...\n")
alerts := make(chan logtail.Alert, 64)
// Start the log tailer in a goroutine.
go func() {
if err := logtail.TailAndAnalyse(authLog, alerts); err != nil {
log.Fatalf("tailer: %v", err)
}
}()
// Main loop: consume and display alerts.
for alert := range alerts {
fmt.Printf(
"\n⚠ SECURITY ALERT ⚠\n"+
" Time: %02d:%02d:%02d\n"+
" Host: %s\n"+
" Process: %s\n"+
" Event: %s\n",
alert.Hour, alert.Minute, alert.Second,
alert.Host, alert.Process, alert.Message,
)
}
}
Build and run the orchestrator. From a second terminal on the Mint VM, generate a test event:
# Terminal 1: start the orchestrator
cd ~/logic-lab/go/orchestrator
go build -o orchestrator . && ./orchestrator
# Terminal 2: generate a failed SSH login attempt
ssh invalid_user@localhost
The orchestrator detects the failed authentication entry written to /var/log/auth.log by the SSH daemon, passes it through the Go-Prolog bridge, evaluates it against the is_security_event/1 rule, and emits the alert:
⚠ SECURITY ALERT ⚠
Time: 15:47:22
Host: mint-logic-lab
Process: sshd
Event: Failed password for invalid user invalid_user from 127.0.0.1 port 44123 ssh2
The complete data flow from log line to printed alert is:
LIVE LOG ANALYSIS DATA FLOW
─────────────────────────────────────────────────────────────────
/var/log/auth.log (filesystem)
│ new line appended by sshd
▼
logtail.TailAndAnalyse (Go goroutine)
│ line string: "Jun 15 15:47:22 mint-logic-lab sshd[...]"
▼
plbridge.PutString (CGO boundary)
│ Go string → Prolog string term (NOT atom)
▼
go_process_line/2 (Prolog, log_analysis module)
│
├─▶ parse_log_line/2 (DCG grammar — Chapter 5)
│ codes → log_entry{month,day,hour,...,message}
│
└─▶ is_security_event/1 (security rules — Chapter 5)
sub_string match on message field
▼
alert{process, host, message, hour, minute, second}
│
▼
plbridge.GetDictStringKey / GetDictIntKey (CGO boundary)
│ Prolog Dict fields → Go string / int64
▼
logtail.Alert struct (Go)
│ sent on buffered channel (cap 64)
▼
main() alert loop → terminal output
─────────────────────────────────────────────────────────────────
Total boundary crossings per log line: 2
1. Go string → Prolog term (PL_put_string_nchars)
2. Prolog Dict → Go struct (PL_get_dict_key × 6)
Latency per line: ~80–200μs on the Mint VM.
At 5,000 lines/sec: 0.4–1.0 seconds of processing per second.
Headroom for concurrent queries: Chapter 9.
─────────────────────────────────────────────────────────────────
6.6 Handling Engine Failures: Defensive CGO
The Prolog engine can throw exceptions. A query against a malformed term, a call to an undefined predicate, a resource limit exceeded — all of these produce a Prolog exception term rather than simply failing. If a Go program ignores these exceptions, the consequences range from silent data loss (if the query appears to simply fail) to a C-level signal that kills the process (if a stack overflow or memory exhaustion is not handled).
The defensive pattern is established in plbridge/errors.go:
// plbridge/errors.go
// Prolog exception extraction and Go error conversion.
// Part III, Chapter 6 - Modern SWI-Prolog (2026 Edition)
package plbridge
/*
#cgo pkg-config: swipl
#include <SWI-Prolog.h>
#include <stdlib.h>
*/
import "C"
import (
"fmt"
)
// PrologError wraps a Prolog exception as a Go error.
type PrologError struct {
Term string // String representation of the Prolog exception term
Message string // Human-readable summary
}
func (e *PrologError) Error() string {
return fmt.Sprintf("Prolog exception: %s (%s)", e.Message, e.Term)
}
// extractPrologException converts a Prolog exception term into a Go error.
// The exception term is converted to its string representation using
// term_to_atom/2, which gives us a human-readable form for logging.
func extractPrologException(exTerm C.term_t) error {
// Use PL_write_term with a string stream to get the term representation.
// Simpler alternative: use the exception's message if it's an error/2 term.
var cStr *C.char
// Most Prolog exceptions are error(Type, Context) terms.
// We extract a string representation for the Go error.
atomTerm := C.PL_new_term_ref()
if C.PL_get_atom_chars(exTerm, &cStr) != 0 {
return &PrologError{
Term: C.GoString(cStr),
Message: C.GoString(cStr),
}
}
// For compound exception terms, use atom_string conversion.
// Construct the call: term_to_atom(Exception, Atom)
args := C.PL_new_term_refs(2)
if C.PL_put_term(args, exTerm) != 0 {
pred := C.PL_predicate(C.CString("term_to_atom"), 2, C.CString("system"))
qid := C.PL_open_query(nil, C.PL_Q_NORMAL, pred, args)
if C.PL_next_solution(qid) != 0 {
C.PL_get_atom_chars(args+1, &cStr)
}
C.PL_close_query(qid)
}
_ = atomTerm
termStr := "unknown exception"
if cStr != nil {
termStr = C.GoString(cStr)
}
return &PrologError{
Term: termStr,
Message: classifyPrologException(termStr),
}
}
// classifyPrologException provides a human-readable category for common
// Prolog exception types, aiding log triage without requiring the operator
// to understand Prolog exception syntax.
func classifyPrologException(termStr string) string {
switch {
case contains(termStr, "existence_error"):
return "undefined predicate or file"
case contains(termStr, "type_error"):
return "wrong argument type"
case contains(termStr, "instantiation_error"):
return "unbound variable where value required"
case contains(termStr, "resource_error"):
return "resource exhausted (stack or memory)"
case contains(termStr, "permission_error"):
return "operation not permitted"
default:
return "Prolog exception (see Term for details)"
}
}
func contains(s, substr string) bool {
return len(s) >= len(substr) &&
(s == substr ||
len(s) > 0 && containsStr(s, substr))
}
func containsStr(s, sub string) bool {
for i := 0; i <= len(s)-len(sub); i++ {
if s[i:i+len(sub)] == sub {
return true
}
}
return false
}
The critical flag in PL_open_query is PL_Q_CATCH_EXCEPTION. Without it, a Prolog exception propagates as a C-level signal and can crash the process. With it, the exception is captured as an exception term that PL_exception/1 can retrieve after the query returns FALSE. The pattern in CallWithTerm — checking PL_exception before closing the query — is therefore mandatory, not optional.
The Go bridge treats Prolog exceptions as Go errors, which means they propagate normally through the Go call stack, can be logged with full context, and do not affect other goroutines. If the log tailer encounters a Prolog exception on one line — perhaps because an attacker has injected an unusual character sequence that confuses the DCG parser — the error is logged, that line is skipped, and analysis continues with the next line. The process does not crash. The analysis does not stop. This is the defensive posture that distinguishes a production system from a prototype.
One additional defensive measure: the Prolog engine itself should be configured with explicit resource limits to prevent a pathological query from exhausting the Go process's memory. Add these flags to the NewEngine argument list:
args := []string{
os.Args[0],
"-g", "true",
"-t", "halt",
"-f", knowledgeBasePath,
"--stack-limit=256m", // cap combined stack at 256 MB
"--table-space=128m", // cap tabling space at 128 MB (Chapter 7)
"--",
}
These limits mean that even if the Prolog engine receives a query that would cause a runaway computation — a pathological backtracking case on a malformed log line — it will throw a resource_error(stack) exception that the Go bridge catches and converts to a Go error, rather than consuming all available RAM and triggering the OOM killer.
6.7 Chapter Summary and the Concurrency Teaser
The system built in this chapter has three noteworthy properties that are worth making explicit before we move on.
First, the Prolog code from Part II did not change. The DCG grammar in log_parser.pl, the security rules in log_analysis.pl, and the infrastructure knowledge base in infrastructure.pl are the same files we tested in the REPL. The only addition was go_process_line/2 — a thin entry point predicate in log_analysis.pl that the Go bridge can call by name. Everything else was Go-side plumbing. This is the payoff of the architectural discipline established in section 6.1: keeping the logic in Prolog and the I/O in Go means the two halves can be developed, tested, and reasoned about independently.
Second, the security invariants from Part II are enforced at the boundary. The PutString function in terms.go calls PL_put_string_nchars, which creates a garbage-collected Prolog string, never a PL_put_atom_chars call. Every log line that enters the Prolog engine from Go enters as a string. The Atom Table Exhaustion vulnerability that Gemini identified in Chapter 5's initial draft cannot occur through this bridge, because the bridge code does not provide a path for unbounded external text to become an atom.
Third, the current architecture has a throughput ceiling. The Go bridge as written is single-threaded at the Prolog level. While Go can run many goroutines, the embedded SWI-Prolog engine is a single WAM instance, and calling it from multiple goroutines simultaneously without synchronisation will corrupt the engine's internal state. For the log tailer with a single tail loop feeding a single analysis path, this is not a problem. For a web server handling concurrent requests from multiple clients — each potentially needing a Prolog query — it becomes the primary bottleneck.
Chapter 7 introduces SWI-Prolog's tabling mechanism, which dramatically improves the efficiency of the reasoning engine for recursive queries over the infrastructure knowledge base. Chapter 9 returns to the concurrency question and builds a thread-pool architecture: multiple SWI-Prolog engine instances, each isolated in its own thread, managed by a Go sync.Pool-like mechanism that assigns incoming queries to idle engines and returns them to the pool when the query completes.
The bridge works. The data flows. The alerts fire. Part III has more to build.
Appendix 6A: The Complete Bridge Package Structure
plbridge/
├── engine.go — NewEngine, Halt, resource limit flags
├── query.go — CallBool, CallWithTerm, PL_Q_CATCH_EXCEPTION usage
├── terms.go — PutString, PutAtom, PutInteger, PutFloat, PutDict,
│ PutValue, GetString, GetInteger, GetDictKey,
│ GetDictStringKey, GetDictIntKey
└── errors.go — PrologError, extractPrologException,
classifyPrologException
Appendix 6B: CGO Build Reference
# Verify CGO is enabled (required for libswipl linking)
go env CGO_ENABLED
# 1
# Check the pkg-config entry for swipl
pkg-config --cflags --libs swipl
# -I/usr/lib/swi-prolog/include -L/usr/lib/x86_64-linux-gnu -lswipl
# If pkg-config is absent, use explicit flags in engine.go:
# #cgo CFLAGS: -I/usr/lib/swi-prolog/include
# #cgo LDFLAGS: -L/usr/lib/x86_64-linux-gnu -lswipl
# -Wl,-rpath,/usr/lib/x86_64-linux-gnu
# Build with verbose CGO output for debugging link errors
CGO_VERBOSE=1 go build ./...
# Run with SWI-Prolog's home directory explicitly set if init fails
SWI_HOME_DIR=/usr/lib/swi-prolog ./orchestrator
Appendix 6C: go_process_line/2 in log_analysis.pl
Add the following to ~/logic-lab/prolog/log_analysis.pl and add
go_process_line/2 to the module export list:
% go_process_line(+LineString, -Result)
% Entry point called from the Go bridge (Chapter 6).
% Input: a Prolog string (not atom) containing a raw syslog line.
% Output: an alert Dict if the line is a security event, else atom 'none'.
go_process_line(LineStr, Result) :-
( parse_log_line(LineStr, Entry),
is_security_event(Entry)
-> Result = alert{
process: Entry.process,
message: Entry.message,
host: Entry.host,
hour: Entry.hour,
minute: Entry.minute,
second: Entry.second
}
; Result = none
).
Appendix 6D: Snapshot Checkpoint
Snapshot name: 07-chapter-6-complete
Description: Go-Prolog bridge operational. plbridge package
(engine.go, query.go, terms.go, errors.go) complete.
Log tailer wired to embedded engine. go_process_line/2
added to log_analysis.pl. Alert pipeline tested.
Files added: go/orchestrator/ (full module tree)
Files modified: prolog/log_analysis.pl
No comments to display
No comments to display