Skip to main content

Chapter 18: Prolog at the Edge (WASM)

Textbook: Modern SWI-Prolog (2026 Edition): Sovereign Infrastructure & Industrial Logic Volume: III — Scaling & Concurrency (Volume Closing Chapter) Chapter: 18 of 24 Audience: Senior Engineers, Systems Architects, Infrastructure Security Practitioners Prerequisites: Chapters 1–17 complete. firewall_policy.pl and network_parser.pl from Chapter 15 operational at /opt/logic-node/kb/. swipl-wasm distribution available at https://npm.im/swipl-wasm (npm package swipl-wasm ≥ 0.3.0 or the swipl-wasm GitHub releases). Node.js 20+ for packaging toolchain. A browser with SharedArrayBuffer enabled (requires COOP/COEP headers — see Section 18.2.4).


Core Concepts

Every firewall policy check executed in Chapter 15 and scaled in Chapter 16 required a round trip: HTTP request from the browser or API client, dispatch through the Go worker pool, WAM query execution, response marshaling, HTTP response. The minimum latency floor for that round trip, established in Chapter 15's latency table, is 0.5ms on a lightly-loaded local host. Under load, 3–12ms. For a network operations UI where an engineer submits an IP address and immediately sees whether it would be permitted through the current policy, 12ms is perceptible lag. For a validation layer embedded in a CI pipeline that checks configuration diffs against firewall rules before deployment, round-trip latency multiplied by hundreds of checks per pipeline run is a meaningful throughput constraint.

WebAssembly is a binary instruction format that executes inside the browser's JavaScript engine at near-native speed. The SWI-Prolog project maintains swipl-wasm: a distribution of the entire SWI-Prolog runtime — the WAM, the unification engine, the clause database, the module system, library(tabling), the FLI — compiled to a WASM module via Emscripten. A browser tab loading swipl.wasm contains a complete, functional WAM. The firewall_policy.pl and network_parser.pl files from Chapter 15, packaged into the Emscripten Virtual File System and bundled with the WASM module, execute in the browser with zero network round trips. The proof is computed locally in 15–30μs. The DOM is updated before the user has processed the visual confirmation of their form submission.

Five properties define the WASM bridge as an operationally correct deployment target for sovereign infrastructure logic.

1. WASM Linear Memory is a contiguous byte array managed by the compiled C runtime, not by V8's GC. The V8 JavaScript engine manages the JS heap with a generational garbage collector. JS objects are allocated on the heap, traced from GC roots, and compacted or collected as the GC determines. WASM Linear Memory is a separate, flat, contiguous byte array — WebAssembly.Memory — that the compiled C code (the WAM, the Emscripten C runtime) manages directly via pointer arithmetic. V8 does not trace into WASM Linear Memory. The WAM's local stack, global stack, trail, Atom Table, and clause database all live inside WASM Linear Memory. They are opaque to the JS GC. When the WAM allocates a new atom, it calls malloc inside WASM Linear Memory — a C-level allocator operating on bytes within the flat array. V8 is unaware this occurred. When the WAM frees a term, V8 is unaware. The WASM Memory object itself is a JS object on the V8 heap; its backing byte array is not. This split ownership model is the source of every memory safety requirement in this chapter.

2. JS strings crossing into the WAM must be written into WASM Linear Memory explicitly. A JavaScript string — a V8 heap object with a type tag, a length field, and either a Latin-1 or UTF-16 backing array — cannot be passed directly to the WAM. The Emscripten binding layer allocates a C string in WASM Linear Memory (_malloc), copies the JS string bytes into it using stringToUTF8, passes the pointer to the WAM FLI function (PL_put_string_chars), and frees the C string allocation after the FLI call returns. This copy is not optional — the WAM's string functions expect a C pointer into WASM Linear Memory, not a V8 string reference. Any Emscripten binding that skips this copy and passes a V8 heap address as a C pointer produces a WASM trap (illegal memory access) or a WAM corruption, identical to the cross-thread term_t migration failure from Chapter 16.

3. WASM Linear Memory has a practical browser-side ceiling far below the server-side limit. A 64-bit server process with --stack-limit=128MB has 128MB of WAM stack backed by physical RAM and swap. A browser WASM module operates within the memory limits imposed by the V8 engine, the browser's per-tab memory budget, and the operating system. Chrome enforces a 4GB ceiling on WASM Linear Memory on 64-bit platforms; in practice, a tab exceeding 500–800MB RSS is a candidate for the browser's tab-killer on a 16GB laptop under load. WAM stack and table limits set in the WASM init arguments must be sized for the browser's constraint, not the server's. For the firewall validation use case — deterministic depth-1 to depth-5 queries against a few hundred rules — 8MB local stack and 16MB global stack are sufficient with significant headroom.

4. The Emscripten Virtual File System provides a POSIX-compatible in-memory filesystem inside WASM Linear Memory. SWI-Prolog's consult/1 predicate calls open/3, which calls the POSIX open() system call. In a server context, this reaches the OS. In the WASM context, Emscripten intercepts POSIX calls and routes them to a virtual filesystem (the MEMFS or NODEFS backend). Files packaged into the VFS at build time via --preload-file are embedded in the WASM binary's data section and extracted into the in-memory filesystem at module initialisation. consult('/prolog/firewall_policy.pl') inside the WASM WAM reads from the VFS, which reads from WASM Linear Memory. No browser disk access. No fetch(). No async. The file is available synchronously before the first query executes.

5. Atom Table exhaustion in the WASM context crashes the browser tab, not a recoverable server process. Chapter 15's PL_put_atom_chars DoS analysis applies without modification to the WASM context, with one critical difference: the victim is the browser tab, not a server process with a panic recovery mechanism. When the WASM WAM's Atom Table exhausts WASM Linear Memory, the Emscripten malloc inside WASM fails. The WASM module throws a RuntimeError: memory access out of bounds or an OOM abort. The JavaScript catch block receives this as an untyped Error. There is no PL_thread_destroy_engine to call. There is no pool manager to respawn a replacement. The WASM instance is dead. The client-side validation layer is gone. Every subsequent firewall check falls back to the server round trip — exactly the latency profile the WASM deployment was designed to eliminate. The remediation is identical to Chapter 15: user-controlled DOM input crosses the WASM bridge as Prolog strings, never atoms.


Chapter Roadmap

Section Title Focus
18.1 Physics of the WASM Bridge V8 heap vs. WASM Linear Memory, Emscripten bindings, memory ownership
18.2 The Virtual File System .pl packaging, --preload-file, VFS init, COOP/COEP headers
18.3 The Build: Zero-Latency Firewall UI wasm_firewall.js, engine init, Prolog.query().once(), DOM update
18.4 Resource Boundaries and Error Trapping Stack/table limits, try/catch, graceful degradation
18.5 Security: Client-Side Atom Exhaustion Atom vs. string in query strings, keystroke DoS, enforcement pattern
Outcome Write Once, Reason Anywhere Verification checklist, latency comparison

18.1 The Physics of the WASM Bridge

18.1.1 What swipl-wasm Actually Is

swipl-wasm is the SWI-Prolog runtime compiled to WebAssembly via the Emscripten toolchain. The Emscripten compiler (emcc) takes the SWI-Prolog C source — the WAM interpreter, the clause database, the unification engine, the arithmetic evaluator, the module system, all core libraries — and produces two files:

  • swipl.wasm — the WASM binary: the compiled WAM and all C runtime code as WASM instructions
  • swipl.js — the Emscripten glue module: JavaScript that handles WASM module loading, memory initialisation, POSIX syscall emulation, and the JavaScript API surface

A third file, swipl.data, contains the preloaded VFS image: the SWI-Prolog standard library .pl files and the application-specific KB files packaged at build time.

The JavaScript API exposed by swipl.js is the entry point for all queries. The SWIPL() factory function initialises the WASM module and returns a Promise resolving to a Module object. The Module.Prolog interface provides query(), call(), and term construction utilities. These are JavaScript functions that operate on the V8 heap on their surface but immediately cross into WASM Linear Memory to interact with the WAM.

18.1.2 Memory Ownership at the Crossing

The crossing from JavaScript into the WAM:

JavaScript call:
  Module.Prolog.query("firewall_verdict(\"10.0.1.5\", 443, \"tcp\", Verdict, Reason)")

V8 heap at this point:
  - The query string is a V8 String object on the JS heap
  - Module.Prolog is a JS object wrapping Emscripten exports
  - No Prolog terms exist yet

Emscripten binding (swipl.js internals):
  1. stringToUTF8(queryStr, wasmPtr, maxLen)
     — Copies V8 string bytes into WASM Linear Memory at wasmPtr
     — wasmPtr was allocated via _malloc inside WASM Linear Memory
  2. PL_chars_to_term(wasmPtr, termRef)
     — WAM parses the C string at wasmPtr into a term_t
     — Term allocated on WAM global stack (inside WASM Linear Memory)
  3. _free(wasmPtr)
     — C string buffer released; term_t on WAM stack survives
  4. PL_call(termRef, NULL)
     — WAM executes the query
     — All execution state: WAM local/global stacks, trail (WASM Linear Memory)
     — V8 heap: untouched during WAM execution

Result extraction:
  5. PL_get_chars(outputTerm, &cstr, CVT_ALL | BUF_MALLOC)
     — cstr points to WASM Linear Memory
  6. UTF8ToString(cstr)
     — Emscripten copies WASM bytes into a new V8 String object
  7. _free(cstr)
     — WASM Linear Memory allocation released

V8 heap at return:
  - Result is a new V8 String
  - No WAM state on V8 heap
  - No WASM pointers on V8 heap
  - Full ownership separation restored

The Emscripten binding layer maintains this ownership separation automatically for the Prolog.query() API. It breaks down when application code bypasses the API and constructs query strings by interpolating raw JS values directly — skipping the copy step and potentially passing V8 heap addresses or malformed strings. See Section 18.5.

18.1.3 Diagram: WASM Execution Boundary

%%{init: {"themeVariables": {"fontSize": "14px"}}}%%
flowchart TD
    FORM["HTML Form\nIP input, Port input, Protocol select\nV8 Event Handler: submit listener\nAll values: V8 String objects on JS heap"]

    VALIDATE["JS Input Validation\nRegex: IP format check\nPort: parseInt bounds check\nProtocol: closed enum check\nRejects malformed input before WASM boundary\nNo WAM involvement"]

    QUERYSTR["Query String Construction\nTemplate literal — Prolog strings only:\nfirewall_verdict("IP", Port, "proto", V, R)\nDouble-quoted values = Prolog strings\nNever single-quoted or bare atoms"]

    V8["V8 JavaScript Heap\nQuery string: V8 String object\nModule.Prolog: JS wrapper object\nResult promise: JS Promise\nNo Prolog data — V8 cannot see WASM memory"]

    EMSCRIPTEN["Emscripten Binding Layer\nswipl.js glue code\nstringToUTF8: copies V8 string → WASM Linear Memory\n_malloc / _free: WASM heap allocator\nUTF8ToString: copies WASM bytes → V8 string\nOwnership boundary enforced here"]

    WASMLINEAR["WASM Linear Memory\nWebAssembly.Memory — flat byte array\nManaged by compiled C malloc/free\nNOT traced by V8 GC\nContains ALL WAM state"]

    WAM["WAM Engine\nLocal Stack: activation frames\nGlobal Stack: compound terms, strings\nTrail: variable binding undo log\nAtom Table: PERMANENT — never GC'd\nClause Database: firewall_policy.pl rules\nAll allocated inside WASM Linear Memory"]

    VFS["Emscripten VFS\nMEMFS: in-memory POSIX filesystem\nfirewall_policy.pl — preloaded at init\nnetwork_parser.pl — preloaded at init\nPaths: /prolog/firewall_policy.pl\nRead by consult/1 synchronously"]

    RESULT["DOM Update\nVerdict: allowed / denied\nReason: whitelist_match / blocklist_match\nLatency: 15-30μs WAM execution\n+ ~0μs network (zero round-trip)\nV8 String — safe to render"]

    FORM --->|"submit event"| VALIDATE
    VALIDATE --->|"validated values"| QUERYSTR
    QUERYSTR --->|"V8 String"| V8
    V8 --->|"Prolog.query() call"| EMSCRIPTEN
    EMSCRIPTEN --->|"stringToUTF8 copy"| WASMLINEAR
    WASMLINEAR --->|"WAM FLI calls"| WAM
    WAM --->|"consult reads"| VFS
    WAM --->|"proof result"| WASMLINEAR
    WASMLINEAR --->|"UTF8ToString copy"| EMSCRIPTEN
    EMSCRIPTEN --->|"V8 String result"| V8
    V8 --->|"DOM manipulation"| RESULT

    style FORM fill:#1A2B4A,color:#FFFFFF
    style VALIDATE fill:#2A4A2A,color:#FFFFFF
    style QUERYSTR fill:#2A4A2A,color:#FFFFFF
    style V8 fill:#1A4070,color:#FFFFFF
    style EMSCRIPTEN fill:#8B6914,color:#FFFFFF
    style WASMLINEAR fill:#5A1A6A,color:#FFFFFF
    style WAM fill:#7A1A1A,color:#FFFFFF
    style VFS fill:#1A6B3A,color:#FFFFFF
    style RESULT fill:#1A4070,color:#FFFFFF

Reading the diagram: Dark blue nodes operate entirely on the V8 JS heap — the browser's normal world. The amber Emscripten layer is the crossing point where ownership changes and explicit copies occur. The purple WASM Linear Memory node is the WAM's entire world — V8 cannot see it, GC cannot collect it. The red WAM engine node is the execution environment for all Prolog queries. The green VFS provides synchronous file access to the preloaded .pl files without any browser disk or network operation.


18.2 The Virtual File System

18.2.1 The Problem: No /opt/logic-node/ in the Browser

SWI-Prolog's consult/1 predicate uses the POSIX open() system call to read source files. In the server context, this reaches the Linux VFS, which reaches the ext4 filesystem, which reads /opt/logic-node/kb/firewall_policy.pl from disk. In the WASM context, there is no Linux VFS, no disk, and no /opt/logic-node/. Emscripten intercepts all POSIX syscalls and routes them to the MEMFS — an in-memory filesystem implemented in JavaScript and backed by WASM Linear Memory. Files must be installed into the MEMFS before they can be consult'd.

Three mechanisms exist for installing files into the WASM VFS:

  1. --preload-file at build time (used in this chapter): Files are embedded in the .data bundle at Emscripten compile time and extracted into the MEMFS automatically when the WASM module initialises. This is a zero-latency, synchronous approach — the files are available before the first Prolog.call(). The .data file is served alongside swipl.wasm and swipl.js by the web server.

  2. FS.writeFile() at runtime: Emscripten exposes Module.FS.writeFile(path, content) allowing JavaScript to write files into the MEMFS after module initialisation. The content can be fetched via fetch() and written into the VFS before consult is called. This approach allows dynamic KB updates — the operator uploads a new firewall_policy.pl, the JS fetches it and writes it into the VFS, and the WAM reloads it via consult. The cost is an async fetch before the first query.

  3. Prolog string literals in the query: For small rule sets, the entire KB can be embedded as a string literal in the JavaScript and loaded via Prolog.call("assert(link(a,b,1))") calls. This bypasses the VFS entirely. For the firewall rules built in Chapter 15 (~50 clauses), this is viable but degrades maintainability — the rules are no longer in a separate .pl file that can be edited and version-controlled independently.

This chapter uses approach 1: --preload-file at build time. The KB is version-controlled in /opt/logic-node/kb/, the swipl-wasm packaging script embeds it, and the resulting .data bundle is served as a static asset.

18.2.1.1 Quick Load Files (.qlf): Skipping the Browser-Side Compiler

consult('/prolog/firewall_policy.pl') in initWasmEngine() does three things inside the WASM WAM: reads the source bytes from the VFS, runs the Prolog tokeniser and parser to produce clause terms, and compiles those terms to WAM bytecode via the clause compiler. For firewall_policy.pl at ~2,800 bytes (~50 clauses), this takes approximately 15–40ms in the browser. For a production KB with 10,000 clauses — proxmox_topology.pl plus archive_ingestor.pl plus the full auth_parser.pl rule set from Volume II — the browser-side consult can take 400–800ms, a visible pause between page load and the engine-ready status message.

SWI-Prolog's qcompile/2 predicate compiles a .pl source file into a Quick Load File (.qlf) on the server. A .qlf file contains the pre-compiled WAM bytecode directly — the tokeniser, parser, and clause compiler have already run. Loading a .qlf via consult/1 or load_files/2 bypasses all three compilation stages and reads the bytecode directly into the clause database. For a 10,000-clause KB, this cuts browser initialisation time by more than 60%.

# Server-side: pre-compile the KB to .qlf before bundling into the VFS
logicadmin@logic-node-01:~$ swipl -g \
    "qcompile('/opt/logic-node/kb/firewall_policy'),halt" \
    -t halt
# Produces: /opt/logic-node/kb/firewall_policy.qlf
# The .qlf contains WAM bytecode for all clauses in firewall_policy.pl
# and all transitively loaded modules (network_parser.pl, etc.).
# build.sh update: bundle the .qlf instead of (or alongside) the .pl
node "$(npm root -g)/swipl-wasm/tools/pack_files.js" \
    --output "$DIST_DIR/swipl.data" \
    --preload-file "$KB_DIR/firewall_policy.qlf@/prolog/firewall_policy.qlf" \
    --preload-file "$KB_DIR/parsers/network_parser.qlf@/prolog/network_parser.qlf"
// wasm_firewall.js initWasmEngine update:
// Load the .qlf instead of the .pl source.
// load_files/2 with [if(not_loaded)] is safe: it loads the file only once,
// even if initWasmEngine is called multiple times.
const consultResult = wasmModule.Prolog.call(
    "load_files('/prolog/firewall_policy.qlf', [if(not_loaded)])"
);

The .qlf file is larger than the .pl source (bytecode is less compact than Prolog text), but the size increase is typically under 30% and is irrelevant to initialisation time — the bottleneck is compilation, not bytes read. The .qlf approach is mandatory for any KB larger than approximately 500 clauses targeting the browser WASM deployment. For the firewall-only KB in this chapter, consult of the .pl source is fast enough that pre-compilation is optional but costs nothing to add to the build pipeline.

18.2.2 Packaging the KB into the VFS

# Install swipl-wasm toolchain
logicadmin@logic-node-01:~$ npm install -g swipl-wasm

# Project structure for the WASM firewall UI:
/opt/logic-node/wasm-ui/
├── package.json
├── build.sh              ← VFS packaging script
├── src/
│   ├── wasm_firewall.js  ← main application JS (Section 18.3)
│   └── index.html        ← firewall policy UI
└── dist/                 ← build output (served as static files)
    ├── swipl.wasm
    ├── swipl.js
    ├── swipl.data        ← VFS bundle: .pl files + SWI stdlib
    └── index.html
# build.sh — packages the KB into the Emscripten VFS

#!/usr/bin/env bash
set -euo pipefail

KB_DIR="/opt/logic-node/kb"
DIST_DIR="./dist"

mkdir -p "$DIST_DIR"

# Copy the swipl-wasm runtime files into dist/
cp "$(npm root -g)/swipl-wasm/dist/swipl.wasm" "$DIST_DIR/"
cp "$(npm root -g)/swipl-wasm/dist/swipl.js"   "$DIST_DIR/"

# Use swipl-wasm's packaging tool to create a .data bundle.
# --preload-file src@dst: embeds the file at VFS path /prolog/dst.
# The WAM will consult from /prolog/ — matching the paths in wasm_firewall.js.

node "$(npm root -g)/swipl-wasm/tools/pack_files.js" \
    --output "$DIST_DIR/swipl.data" \
    --preload-file "$KB_DIR/firewall_policy.pl@/prolog/firewall_policy.pl" \
    --preload-file "$KB_DIR/parsers/network_parser.pl@/prolog/network_parser.pl"

echo "[Build] VFS bundle written: dist/swipl.data"
echo "[Build] VFS contents:"
echo "  /prolog/firewall_policy.pl  ($(wc -c < "$KB_DIR/firewall_policy.pl") bytes)"
echo "  /prolog/network_parser.pl   ($(wc -c < "$KB_DIR/parsers/network_parser.pl") bytes)"
logicadmin@logic-node-01:~$ cd /opt/logic-node/wasm-ui && bash build.sh
[Build] VFS bundle written: dist/swipl.data
[Build] VFS contents:
  /prolog/firewall_policy.pl  (2847 bytes)
  /prolog/network_parser.pl   (4103 bytes)

18.2.3 VFS Path Conventions

The VFS paths used in consult/1 must exactly match the @dst paths used in --preload-file. A mismatch produces ERROR: source_sink '/prolog/firewall_policy.pl' does not exist inside the WASM WAM — a Prolog exception caught by the JavaScript try/catch in Section 18.4, with no fallback until the VFS path is corrected.

The use_module directive in firewall_policy.pl that loads network_parser.pl must be updated for the VFS path:

%% firewall_policy.pl — WASM variant (VFS paths)
%% The server variant uses the OS path:
%%   :- use_module('/opt/logic-node/kb/parsers/network_parser', [...]).
%% The WASM variant uses the VFS path:
:- use_module('/prolog/network_parser', [parse_ipv4/2, ip_in_cidr/3]).

To maintain a single source file for both server and WASM deployment, use a conditional:

%% Portable path selection — works on both server and WASM:
:- ( exists_file('/opt/logic-node/kb/parsers/network_parser.pl')
   -> use_module('/opt/logic-node/kb/parsers/network_parser', [parse_ipv4/2, ip_in_cidr/3])
   ;  use_module('/prolog/network_parser', [parse_ipv4/2, ip_in_cidr/3])
   ).

18.2.4 COOP/COEP Headers: The SharedArrayBuffer Requirement

swipl-wasm uses SharedArrayBuffer for the WASM Memory object on multi-threaded builds. Chrome and Firefox require the page to be served with Cross-Origin Opener Policy and Cross-Origin Embedder Policy headers to enable SharedArrayBuffer:

# Web server configuration (nginx example):
# /etc/nginx/sites-available/wasm-ui.conf

server {
    listen 443 ssl;
    server_name logic-node-01.infra.internal;

    root /opt/logic-node/wasm-ui/dist;

    # COOP/COEP headers — required for SharedArrayBuffer (WASM threading)
    add_header Cross-Origin-Opener-Policy  "same-origin"          always;
    add_header Cross-Origin-Embedder-Policy "require-corp"        always;

    # Static WASM files — correct MIME type required for WASM streaming compile
    location ~ \.wasm$ {
        add_header Content-Type "application/wasm";
        add_header Cross-Origin-Opener-Policy  "same-origin"   always;
        add_header Cross-Origin-Embedder-Policy "require-corp" always;
    }

    # .data bundle — served as binary
    location ~ \.data$ {
        add_header Content-Type "application/octet-stream";
    }
}

Without the COOP/COEP headers, the browser refuses to allocate SharedArrayBuffer, and swipl-wasm falls back to a single-threaded WASM build that does not require it. The single-threaded build is fully functional for the firewall validation use case — firewall_verdict/4 is deterministic and does not use Prolog threads. Both builds load the VFS identically.

18.2.4.1 Cache-Control Headers: Making "Zero-Latency" Survive the Second Page Load

swipl.wasm is 8.3MB. Over a 10Mbps connection, the first load takes 8 seconds. Serving the WASM binary without aggressive Cache-Control means that every subsequent page load — a tab refresh, a CI pipeline re-run, an operator navigating back to the policy UI — fetches 8.3MB again. The "zero-latency" claim is accurate for the query execution; it is negated by an uncached 8-second binary fetch on every session.

The correct caching strategy pairs a long-lived immutable Cache-Control header with content-hashed filenames for cache busting. The hash is computed at build time and embedded in the filename; when the WASM binary or .data bundle changes, the filename changes, and the browser treats it as a new resource:

# build.sh: add content-hash suffix to WASM and .data filenames
WASM_HASH=$(sha256sum "$DIST_DIR/swipl.wasm" | cut -c1-8)
DATA_HASH=$(sha256sum "$DIST_DIR/swipl.data" | cut -c1-8)

mv "$DIST_DIR/swipl.wasm" "$DIST_DIR/swipl.${WASM_HASH}.wasm"
mv "$DIST_DIR/swipl.data" "$DIST_DIR/swipl.${DATA_HASH}.data"

# Inject the hashed filenames into index.html via sed:
sed -i "s/swipl\.wasm/swipl.${WASM_HASH}.wasm/g" "$DIST_DIR/index.html"
sed -i "s/swipl\.js/swipl.${WASM_HASH}.js/g"   "$DIST_DIR/index.html"
# locateFile in wasm_firewall.js picks up the hashed name from the script tag.
# nginx: long-lived cache for immutable hashed WASM/data assets
server {
    # Hashed WASM binary — immutable for this exact hash
    location ~ \.wasm$ {
        add_header Content-Type "application/wasm";
        add_header Cross-Origin-Opener-Policy  "same-origin"   always;
        add_header Cross-Origin-Embedder-Policy "require-corp" always;
        # immutable: browser MUST NOT revalidate, even on hard refresh
        # max-age=31536000: 1 year (effectively permanent for hashed files)
        add_header Cache-Control "public, max-age=31536000, immutable";
    }

    # Hashed .data VFS bundle — same immutable policy
    location ~ \.data$ {
        add_header Content-Type "application/octet-stream";
        add_header Cache-Control "public, max-age=31536000, immutable";
    }

    # index.html — must NOT be cached (it embeds the hashed filenames)
    # Short-lived or no-cache ensures the browser always fetches the latest
    # index.html, which references the current hash-named WASM and .data files.
    location = /index.html {
        add_header Cache-Control "no-cache, no-store, must-revalidate";
    }
}

With this configuration, the 8.3MB swipl.wasm is fetched once per content hash (i.e., once per swipl-wasm version update). Every subsequent page load — regardless of hard refresh, navigation, or incognito tab — serves the binary from the browser's disk cache in under 10ms. The index.html is never cached: it is tiny (under 5KB) and its uncached fetch costs negligible time while ensuring the browser always loads the correct hashed asset references. The Service Worker approach from Exercise 18.5 builds on top of this: the Service Worker caches the hashed WASM binary in the Cache API for offline-first operation, using the same hash-based invalidation logic.


18.3 The Build: Zero-Latency Firewall UI

18.3.1 index.html

<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>Sovereign Firewall Policy — Edge Validation</title>
  <style>
    body { font-family: monospace; max-width: 600px; margin: 2rem auto; padding: 0 1rem; }
    #result { margin-top: 1rem; padding: 0.75rem; border-left: 4px solid #888; }
    .allowed  { border-color: #2a7a2a; background: #f0fff0; }
    .denied   { border-color: #7a1a1a; background: #fff0f0; }
    .error    { border-color: #8b6914; background: #fffbf0; }
    label     { display: block; margin-top: 0.5rem; font-size: 0.9rem; }
    input, select { font-family: monospace; padding: 0.3rem; width: 100%; }
    button    { margin-top: 1rem; padding: 0.5rem 1.5rem; font-family: monospace; cursor: pointer; }
    #status   { font-size: 0.8rem; color: #666; margin-top: 0.5rem; }
  </style>
</head>
<body>
  <h2>Firewall Policy — Client-Side WAM Validation</h2>
  <p id="status">Initialising WAM engine…</p>

  <form id="fw-form">
    <label>Source IP
      <input id="ip"       type="text"   placeholder="10.0.1.5"  autocomplete="off">
    </label>
    <label>Destination Port
      <input id="port"     type="number" placeholder="443" min="1" max="65535">
    </label>
    <label>Protocol
      <select id="protocol">
        <option value="tcp">TCP</option>
        <option value="udp">UDP</option>
        <option value="icmp">ICMP</option>
      </select>
    </label>
    <button type="submit" id="submit-btn" disabled>Check Policy</button>
  </form>

  <div id="result" hidden></div>
  <div id="latency"></div>

  <!-- WASM runtime (must precede wasm_firewall.js) -->
  <script src="swipl.js"></script>
  <script src="wasm_firewall.js"></script>
</body>
</html>

18.3.2 wasm_firewall.js

// File: /opt/logic-node/wasm-ui/src/wasm_firewall.js
//
// Zero-latency firewall policy validation using the SWI-Prolog WASM engine.
//
// MEMORY CONTRACT:
//   All user-controlled DOM input crosses the WASM boundary as Prolog STRINGS
//   (double-quoted in the query string), never as atoms (unquoted or single-quoted).
//   See Section 18.5 for the exhaustive security analysis.
//
// ENGINE LIFECYCLE:
//   The SWIPL module is initialised once on page load.
//   A single WAM instance serves all queries for the lifetime of the page.
//   The engine is never explicitly destroyed — the browser tab's lifecycle
//   manages WASM Linear Memory (freed on tab close or navigation).
//
// ERROR HANDLING:
//   All Prolog.query() calls are wrapped in try/catch.
//   WAM resource errors produce a degraded-mode UI that warns the user and
//   falls back to the server-side validation endpoint.
//   See Section 18.4 for the full error taxonomy.

'use strict';

// ─────────────────────────────────────────────────────────────────────────────
// CONFIGURATION
// ─────────────────────────────────────────────────────────────────────────────

const WASM_CONFIG = {
    // Stack and table limits — sized for browser tab constraints (not server limits).
    // local_stack: 8MB  — firewall_verdict/4 is shallow (depth 3-5 max)
    // global_stack: 16MB — compound terms, parsed IP integers, rule matches
    // table_space: 4MB  — no tabling in firewall_policy.pl; minimal allocation
    stackLimitMb: 8,
    globalLimitMb: 16,
    tableSpaceMb: 4,

    // VFS paths — must match --preload-file @dst paths in build.sh
    kbPaths: [
        '/prolog/firewall_policy.pl',
        '/prolog/network_parser.pl',   // loaded transitively by firewall_policy.pl
    ],

    // Server fallback endpoint — used when WASM engine is unavailable
    fallbackEndpoint: '/firewall',

    // Input length limits — enforced in JS before any WASM call
    maxIpLength: 45,    // IPv4: 15 chars. IPv6: 39 chars. Overage: reject.
    maxPortValue: 65535,
    allowedProtocols: new Set(['tcp', 'udp', 'icmp']),
};

// ─────────────────────────────────────────────────────────────────────────────
// ENGINE STATE
// ─────────────────────────────────────────────────────────────────────────────

let wasmModule  = null;   // The initialised SWIPL module object
let wasmReady   = false;  // True after KB loaded successfully
let wasmFailed  = false;  // True after unrecoverable engine failure

// ─────────────────────────────────────────────────────────────────────────────
// INITIALISATION
// ─────────────────────────────────────────────────────────────────────────────

async function initWasmEngine() {
    const statusEl = document.getElementById('status');
    statusEl.textContent = 'Loading WAM engine (swipl.wasm)…';

    try {
        // SWIPL() is the Emscripten module factory from swipl.js.
        // The arguments array is passed to PL_initialise as argv, identical to
        // the command-line arguments used in the server's InitEngine function
        // from Chapter 15's cgo_bridge.go — same engine, different host.
        wasmModule = await SWIPL({
            arguments: [
                'swipl',
                '--quiet',
                // Stack limits in bytes — browser-sized, not server-sized.
                // These match WASM_CONFIG but are passed as argv strings to
                // PL_initialise inside the WASM module.
                `--stack-limit=${WASM_CONFIG.stackLimitMb}M`,
                `--table-space=${WASM_CONFIG.tableSpaceMb}M`,
                '-g', 'true',
                '-t', 'halt',
            ],
            // locateFile: tells the Emscripten glue where to find swipl.wasm
            // and swipl.data relative to the current page URL.
            locateFile: (file) => `./${file}`,
        });

        statusEl.textContent = 'Loading knowledge base…';

        // consult/1 reads from the Emscripten VFS (MEMFS).
        // The .pl files were preloaded from swipl.data at module init time.
        // This call is synchronous — no async, no fetch.
        const consultResult = wasmModule.Prolog.call(
            "consult('/prolog/firewall_policy.pl')"
        );

        if (!consultResult) {
            throw new Error(
                "consult('/prolog/firewall_policy.pl') failed — " +
                "VFS path mismatch or syntax error in KB"
            );
        }

        // Verify the predicate loaded correctly before enabling the UI.
        // A failing predicate_property check here is safer than a silent
        // wrong result on the first user query.
        const predicateCheck = wasmModule.Prolog.call(
            "predicate_property(firewall_verdict(_, _, _, _, _), defined)"
        );

        if (!predicateCheck) {
            throw new Error(
                "firewall_verdict/5 not found after consult — " +
                "check firewall_policy.pl for syntax errors"
            );
        }

        wasmReady = true;
        statusEl.textContent =
            `WAM engine ready — KB loaded. Queries execute at client edge (0 network hops).`;
        document.getElementById('submit-btn').disabled = false;

    } catch (err) {
        wasmFailed = true;
        statusEl.textContent =
            `WAM engine failed to initialise: ${err.message}. ` +
            `Validation will fall back to server (latency: ~5ms).`;
        console.error('[WASM] Engine init failed:', err);
        // Enable submit button in fallback mode — server handles the query.
        document.getElementById('submit-btn').disabled = false;
        document.getElementById('submit-btn').textContent = 'Check Policy (Server Mode)';
    }
}

// ─────────────────────────────────────────────────────────────────────────────
// INPUT VALIDATION (JS-LAYER, PRE-WASM)
// ─────────────────────────────────────────────────────────────────────────────

// validateInputs: enforces all JS-layer constraints before building the
// Prolog query string. These checks run entirely on the V8 heap —
// no WASM involvement. They are the first line of defence against
// malformed and malicious inputs.
//
// Returns {valid: true, ip, port, protocol} or {valid: false, error: string}.
function validateInputs() {
    const ip       = document.getElementById('ip').value.trim();
    const portStr  = document.getElementById('port').value.trim();
    const protocol = document.getElementById('protocol').value;

    // ── Length guard — prevents memory pressure from oversized strings ──
    if (ip.length === 0) {
        return { valid: false, error: 'Source IP is required.' };
    }
    if (ip.length > WASM_CONFIG.maxIpLength) {
        return { valid: false, error: `IP address too long (max ${WASM_CONFIG.maxIpLength} chars).` };
    }

    // ── Format guard — JavaScript-level regex, before WAM parse_ipv4/2 ──
    // This is not a security boundary — parse_ipv4/2 in the WAM validates
    // the IP semantically. This is a UX guard that catches obvious typos
    // before spending 20μs on a WAM query that will fail at parse_ipv4/2.
    const ipv4Regex = /^(\d{1,3}\.){3}\d{1,3}$/;
    if (!ipv4Regex.test(ip)) {
        return { valid: false, error: 'Invalid IPv4 format (expected A.B.C.D).' };
    }

    // ── Port bounds ──
    const port = parseInt(portStr, 10);
    if (isNaN(port) || port < 1 || port > WASM_CONFIG.maxPortValue) {
        return { valid: false, error: `Port must be 1–${WASM_CONFIG.maxPortValue}.` };
    }

    // ── Protocol closed vocabulary ──
    if (!WASM_CONFIG.allowedProtocols.has(protocol)) {
        return { valid: false, error: `Protocol must be one of: tcp, udp, icmp.` };
    }

    return { valid: true, ip, port, protocol };
}

// ─────────────────────────────────────────────────────────────────────────────
// QUERY EXECUTION
// ─────────────────────────────────────────────────────────────────────────────

// buildQueryString: constructs the Prolog query string from validated inputs.
//
// CRITICAL SECURITY CONTRACT — STRINGS, NOT ATOMS:
//   ip and protocol are wrapped in double quotes → Prolog strings.
//   Double-quoted values in SWI-Prolog (with double_quotes flag = atom) can be
//   atoms, but with double_quotes = codes or string (the default in SWI 7+),
//   they are Prolog string objects — NOT atoms. They are allocated on the WAM
//   global stack (GC-eligible), NOT in the Atom Table (permanent).
//
//   Wrapping in double quotes AND ensuring double_quotes flag = string:
//     "10.0.1.5"  → Prolog string object on global heap  ← CORRECT
//     '10.0.1.5'  → Prolog atom in Atom Table (permanent) ← NEVER DO THIS
//     10.0.1.5    → Prolog float literal (parse error)    ← NEVER DO THIS
//
//   See Section 18.5 for the full atom exhaustion analysis.
//
// INJECTION GUARD:
//   The ip and protocol values have already been validated by validateInputs().
//   ip: matches /^(\d{1,3}\.){3}\d{1,3}$/ — no Prolog metacharacters possible.
//   protocol: from WASM_CONFIG.allowedProtocols — a closed JS Set of literals.
//   No escaping is required for these specific, validated formats.
//   For an API that accepts arbitrary text (e.g., a node name or comment field),
//   ALL Prolog metacharacters must be escaped before embedding in a query string.
//   In that case, use Module.Prolog term construction APIs instead of string
//   interpolation — see Exercise 18.3.
function buildQueryString(ip, port, protocol) {
    // double_quotes flag must be 'string' (SWI-Prolog 7+ default).
    // The consult in initWasmEngine loaded firewall_policy.pl which uses
    // :- set_prolog_flag(double_quotes, string) — verified at build time.
    return `firewall_verdict(request{` +
           `source_ip:"${ip}", ` +       // Prolog string — NOT atom
           `dest_port:${port}, ` +        // Prolog integer — no quotes needed
           `protocol:"${protocol}"` +    // Prolog string — NOT atom
           `}, Verdict, Reason, _RuleID)`;
}

// executeQuery: runs the Prolog query in the WASM WAM and returns the result.
// Must only be called after wasmReady = true.
// Returns {verdict, reason, latencyUs} or throws on WAM error.
function executeQuery(queryStr) {
    const t0 = performance.now();

    // Prolog.query(goal).once() executes the goal and returns the first solution.
    // Returns an object where each unbound variable in the goal becomes a key:
    //   {Verdict: "allowed", Reason: "whitelist_match", _RuleID: "1"}
    // Returns null if the goal fails (no solutions).
    // Throws a JavaScript Error if the goal throws a Prolog exception.
    const result = wasmModule.Prolog.query(queryStr).once();

    const latencyUs = Math.round((performance.now() - t0) * 1000);

    if (result === null) {
        // Goal failed — firewall_verdict/4's default_deny clause always succeeds,
        // so a null result means the query string itself was malformed or the
        // predicate is not loaded. Treat as an engine error.
        throw new Error('firewall_verdict/4 returned no solutions — KB integrity error');
    }

    return {
        verdict:    result.Verdict,   // 'allowed' or 'denied'
        reason:     result.Reason,    // e.g., 'whitelist_match' or 'default_deny'
        ruleId:     result._RuleID,
        latencyUs,
    };
}

// ─────────────────────────────────────────────────────────────────────────────
// SERVER FALLBACK
// ─────────────────────────────────────────────────────────────────────────────

// queryServerFallback: called when wasmFailed = true or when a WAM runtime
// error occurs mid-session. Sends the validated inputs to the Go worker pool
// endpoint from Chapter 16. Response format is identical to the WASM result
// structure — the DOM update function accepts both.
async function queryServerFallback(ip, port, protocol) {
    const t0 = performance.now();
    const response = await fetch(WASM_CONFIG.fallbackEndpoint, {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({ SourceIP: ip, DestPort: port, Protocol: protocol }),
    });
    if (!response.ok) {
        throw new Error(`Server returned ${response.status}`);
    }
    const data = await response.json();
    const latencyUs = Math.round((performance.now() - t0) * 1000);
    return {
        verdict:    data.allowed ? 'allowed' : 'denied',
        reason:     data.reason,
        ruleId:     data.rule_id,
        latencyUs,
        serverMode: true,
    };
}

// ─────────────────────────────────────────────────────────────────────────────
// DOM UPDATE
// ─────────────────────────────────────────────────────────────────────────────

function displayResult(result, errorMsg) {
    const resultEl  = document.getElementById('result');
    const latencyEl = document.getElementById('latency');

    resultEl.removeAttribute('hidden');
    resultEl.className = '';

    if (errorMsg) {
        resultEl.classList.add('error');
        resultEl.innerHTML = `<strong>Error:</strong> ${escapeHtml(errorMsg)}`;
        latencyEl.textContent = '';
        return;
    }

    resultEl.classList.add(result.verdict === 'allowed' ? 'allowed' : 'denied');
    resultEl.innerHTML =
        `<strong>${result.verdict.toUpperCase()}</strong> — ${escapeHtml(result.reason)}` +
        (result.ruleId ? ` (rule ${escapeHtml(String(result.ruleId))})` : '');

    const modeTag = result.serverMode ? ' [server fallback]' : ' [WAM edge]';
    latencyEl.textContent = `Query latency: ${result.latencyUs}μs${modeTag}`;
}

function escapeHtml(str) {
    return str.replace(/[&<>"']/g, (c) => ({
        '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;'
    }[c]));
}

// ─────────────────────────────────────────────────────────────────────────────
// FORM HANDLER
// ─────────────────────────────────────────────────────────────────────────────

document.getElementById('fw-form').addEventListener('submit', async (event) => {
    event.preventDefault();

    const submitBtn = document.getElementById('submit-btn');
    submitBtn.disabled = true;
    submitBtn.textContent = 'Querying…';

    try {
        // Step 1: JS-layer input validation (V8 heap only — no WASM)
        const validation = validateInputs();
        if (!validation.valid) {
            displayResult(null, validation.error);
            return;
        }
        const { ip, port, protocol } = validation;

        let result;

        if (wasmReady && !wasmFailed) {
            // Step 2a: WASM-local query — zero network hops
            const queryStr = buildQueryString(ip, port, protocol);
            result = executeQuery(queryStr);
            // executeQuery throws on WAM error — caught below
        } else {
            // Step 2b: Server fallback — WASM unavailable
            result = await queryServerFallback(ip, port, protocol);
        }

        // Step 3: DOM update
        displayResult(result, null);

    } catch (err) {
        // WAM runtime errors reach here (resource_error, existence_error, etc.)
        // See Section 18.4 for the full error handling taxonomy.
        console.error('[WASM] Query error:', err);
        displayResult(null, `WAM error: ${err.message}. Retrying via server…`);

        // Attempt server fallback after WAM error
        try {
            const validation = validateInputs();
            if (validation.valid) {
                const { ip, port, protocol } = validation;
                const result = await queryServerFallback(ip, port, protocol);
                displayResult(result, null);
            }
        } catch (fallbackErr) {
            displayResult(null, `Both WAM and server failed: ${fallbackErr.message}`);
        }

    } finally {
        submitBtn.disabled = false;
        submitBtn.textContent = wasmFailed ? 'Check Policy (Server Mode)' : 'Check Policy';
    }
});

// ─────────────────────────────────────────────────────────────────────────────
// STARTUP
// ─────────────────────────────────────────────────────────────────────────────

// SWIPL module is loaded by the <script src="swipl.js"> tag in index.html.
// initWasmEngine() is called after the DOM is ready.
document.addEventListener('DOMContentLoaded', initWasmEngine);

18.3.3 Live Demonstration

Browser loads index.html:
  [WASM] Initialising engine (swipl.wasm, 8.3MB)…
  [WASM] VFS: /prolog/firewall_policy.pl mounted (2847 bytes)
  [WASM] VFS: /prolog/network_parser.pl mounted (4103 bytes)
  [WASM] consult('/prolog/firewall_policy.pl')… OK
  [WASM] firewall_verdict/5 defined. Engine ready.
  Status: "WAM engine ready — KB loaded. Queries execute at client edge (0 network hops)."

User submits: IP=10.0.1.5, Port=443, Protocol=tcp
  Query: firewall_verdict(request{source_ip:"10.0.1.5", dest_port:443, protocol:"tcp"}, Verdict, Reason, _)
  WAM: parse_ipv4("10.0.1.5", 167772677)
  WAM: whitelist_rule(1, "10.0.0.0/8", tcp)
  WAM: ip_in_cidr(167772677, "10.0.0.0/8", true) → true
  Result: {Verdict: "allowed", Reason: "whitelist_match", _RuleID: "1"}
  DOM update: "ALLOWED — whitelist_match (rule 1)"
  Latency: 23μs [WAM edge]

User submits: IP=203.0.113.8, Port=22, Protocol=tcp
  Query: firewall_verdict(request{source_ip:"203.0.113.8", dest_port:22, protocol:"tcp"}, Verdict, Reason, _)
  WAM: parse_ipv4("203.0.113.8", 3406127880)
  WAM: blocklist_rule(100, "203.0.113.0/24", "known_attacker_range")
  WAM: ip_in_cidr(3406127880, "203.0.113.0/24", true) → true
  Result: {Verdict: "denied", Reason: "blocklist_match", _RuleID: "100"}
  DOM update: "DENIED — blocklist_match (rule 100)"
  Latency: 19μs [WAM edge]

Network DevTools: 0 XHR/fetch requests during either query.
Total round-trip: 0ms network + 23μs WAM = 23μs wall-clock from submit to DOM update.

18.4 Resource Boundaries and Error Trapping

18.4.1 WASM Memory Limits in Practice

// WASM_CONFIG stack limits — reasoning for browser-specific values:
//
// Server (Chapter 15 cgo_bridge.go InitEngine argv):
//   --stack-limit=64M   → 64MB per worker engine
//   Justification: server has dedicated RAM, 16 workers × 64MB = 1GB WAM stacks
//
// Browser WASM:
//   --stack-limit=8M    → 8MB per tab
//   Justification: firewall_verdict/4 reaches depth 3-5 at most.
//                  8MB / ~200 bytes per frame = 40,000 frames max.
//                  Actual max depth for any firewall rule: 5 frames.
//                  8MB provides 8,000× headroom — more than sufficient.
//   Risk of going lower: a KB with complex meta-rules (Chapter 19+) may
//                  exceed a 1MB limit. 8MB is the safe conservative floor.
//
// --table-space=4M:
//   firewall_policy.pl has no tabled predicates.
//   The 4MB allocation is for the SWI-Prolog standard library's internal
//   use of tabling (e.g., library(aggregate) uses tabling internally).
//   Setting this to 0 or 1MB risks a resource_error on standard library calls.

18.4.2 JavaScript Error Taxonomy for WAM Exceptions

Prolog.query(goal).once() throws a JavaScript Error when the Prolog goal throws an exception. The error's message property contains the Prolog exception term serialised as a string. The catch block must distinguish between recoverable and unrecoverable conditions:

// Comprehensive error handler for Prolog.query() calls:
function handleWasmError(err, ip, port, protocol) {
    const msg = err.message || String(err);

    // ── Classify the WAM exception ──────────────────────────────────────────

    if (msg.includes('resource_error(max_table_space)')) {
        // Table space exhausted — tabled predicate filled the 4MB limit.
        // Recoverable: flush tables and retry.
        // For firewall_policy.pl (no tabling), this should not occur.
        // If it does: a KB update added tabling without increasing the limit.
        console.warn('[WASM] table_space exhausted — flushing and retrying');
        wasmModule.Prolog.call('abolish_all_tables');
        return 'retry';  // Signal to caller: retry the query
    }

    if (msg.includes('resource_error(max_stack)') ||
        msg.includes('Stack limit') ||
        msg.includes('out_of_stack')) {
        // WAM local stack exhausted — 8MB limit hit.
        // For firewall_policy.pl: not expected. Indicates KB has infinite recursion
        // or a tabling failure (cycle without :- table directive).
        // Unrecoverable for this session — engine state may be corrupt.
        console.error('[WASM] Stack overflow — WAM engine compromised');
        wasmFailed = true;
        return 'fallback';  // Signal: use server fallback from now on
    }

    if (msg.includes('parse_failure(ipv4') ||
        msg.includes('type_error') ||
        msg.includes('instantiation_error')) {
        // Prolog-level validation failure — input rejected by the KB.
        // The WAM engine is healthy. Return the structured error to the UI.
        return {
            userError: true,
            message: `Input rejected by policy engine: ${msg}`,
        };
    }

    if (msg.includes('existence_error(procedure')) {
        // Predicate not found — KB load failed silently, or wrong predicate name.
        // Engine is healthy; KB state is suspect.
        console.error('[WASM] Predicate not found:', msg);
        wasmFailed = true;
        return 'fallback';
    }

    if (msg.includes('RuntimeError') ||
        msg.includes('memory access out of bounds') ||
        msg.includes('unreachable')) {
        // WASM trap — C-level crash inside WASM Linear Memory.
        // This is the atom table exhaustion failure mode from Section 18.5,
        // or a genuine bug in the compiled WAM code.
        // The WASM instance may be unrecoverable — fall back immediately.
        console.error('[WASM] WASM trap — engine terminated:', msg);
        wasmFailed = true;
        wasmReady  = false;
        return 'fallback';
    }

    // Unknown exception — log and fall back.
    console.error('[WASM] Unclassified error:', msg);
    return 'fallback';
}

18.4.3 Graceful Degradation Contract

The UI presents three states to the user without white-screening:

  1. WAM healthy — All queries execute locally in 15–30μs. Status bar shows "WAM edge". No network requests.

  2. WAM degraded (recoverable error) — A table space exhaustion was caught and recovered. The retry succeeded. The user sees the result; the status bar shows "WAM edge". The error is logged to the browser console.

  3. WAM unavailable (unrecoverable) — Stack overflow, WASM trap, or init failure. The submit button relabels to "Check Policy (Server Mode)". All queries are forwarded to the /firewall endpoint from Chapter 16. Latency reverts to the server round-trip profile (~5ms). The status bar explains the degradation. No white screen. The user can continue working.

This degradation contract makes the WASM layer additive — a performance optimisation that improves latency when available — rather than a hard dependency that breaks the UI when unavailable.


18.5 Security: Client-Side Atom Exhaustion

18.5.1 The Attack Vector — Browser-Side

The atom exhaustion DoS from Chapter 15, Section 15.5 operates identically in the browser WASM context, with one difference: the victim is the user's browser tab, not a recoverable server process.

The vulnerable query construction pattern:

// WRONG — DO NOT DO THIS:
function buildQueryStringInsecure(ip, port, protocol) {
    // ip comes from an HTML input field — user-controlled.
    // Wrapping in single quotes makes it a PROLOG ATOM.
    // The atom is interned permanently in the WASM Atom Table
    // inside WASM Linear Memory.
    return `firewall_verdict(request{` +
           `source_ip:'${ip}', ` +      // ← ATOM: permanent Atom Table entry
           `dest_port:${port}, ` +
           `protocol:'${protocol}'` +   // ← ATOM: permanent Atom Table entry
           `}, Verdict, Reason, _RuleID)`;
}

The user holds down the spacebar in the IP input field, which incrementally appends space characters to the value. With each keypress that triggers a query (e.g., if the application queries on input events rather than submit):

Keystroke 1:  ip = "1"         → atom('1') interned → Atom Table: +1 entry
Keystroke 2:  ip = "10"        → atom('10') interned → Atom Table: +1 entry
Keystroke 3:  ip = "10."       → atom('10.') interned → Atom Table: +1 entry
...
Keystroke N:  ip = "10.       " (N-3 spaces appended)
              → atom('10.       ') interned → Atom Table: +1 entry

At N = 1,000 distinct inputs:
  1,000 distinct atoms interned permanently.
  Average atom string length: 10 bytes.
  Atom Table overhead: ~80 bytes/entry (hash bucket + string copy).
  Total: 1,000 × 90 bytes = 90KB. Negligible at this scale.

At N = 100,000 distinct inputs (automated attack, XMLHttpRequest or fetch loop):
  100,000 × 90 bytes = 9MB permanent Atom Table growth inside WASM Linear Memory.
  WASM Linear Memory initial allocation: 16MB (typical swipl-wasm default).
  Atom Table growth consumes 56% of total WASM Linear Memory.
  Next WAM GC: cannot reclaim Atom Table entries — they are permanent.
  Next malloc inside WASM: fails — out of WASM Linear Memory.
  WASM trap: RuntimeError: memory access out of bounds.
  JavaScript catch: catches the RuntimeError.
  WASM instance: DEAD. Cannot be recovered without page reload.
  Client-side validation: GONE. All queries fall back to server.

The input events do not even need to come from a real user. An automated script that constructs and submits the form with incrementally different IP strings achieves the same result. The WASM Atom Table has no rate limiting, no size limit independent of WASM Linear Memory, and no garbage collection.

18.5.2 The Correct Pattern — Prolog Strings in Query Strings

// CORRECT — strings, never atoms:
function buildQueryString(ip, port, protocol) {
    // Double quotes in the Prolog query string → Prolog string objects.
    // In SWI-Prolog with double_quotes = string (the default):
    //   "10.0.1.5"  is a Prolog string — allocated on the WAM global heap.
    //               When the query completes, the string is eligible for WAM GC.
    //               It is NOT entered into the Atom Table.
    //               100,000 distinct IP strings → 0 new Atom Table entries.
    return `firewall_verdict(request{` +
           `source_ip:"${ip}", ` +       // ← STRING: global heap, GC-eligible
           `dest_port:${port}, ` +        // ← INTEGER: no allocation concern
           `protocol:"${protocol}"` +    // ← STRING: global heap, GC-eligible
           `}, Verdict, Reason, _RuleID)`;
}

The double_quotes flag must be string for this to hold. Verify in firewall_policy.pl:

%% At the top of firewall_policy.pl — enforce string semantics:
:- set_prolog_flag(double_quotes, string).
%% This directive ensures that "10.0.1.5" in a query string is a
%% Prolog string object, not a list of character codes and not an atom.
%% It is the WASM equivalent of using PL_put_string_chars (not PL_put_atom_chars)
%% in the Chapter 15 CGO bridge.

Verify the flag is active in the browser console:

// Verification: confirm double_quotes flag before production deployment
const flagCheck = wasmModule.Prolog.query(
    "current_prolog_flag(double_quotes, F)"
).once();
console.assert(flagCheck.F === 'string',
    `double_quotes flag is '${flagCheck.F}', must be 'string' — ATOM TABLE AT RISK`);

18.5.3 The double_quotes Flag Trap

SWI-Prolog's double_quotes flag has three possible values:

Value "text" in query Atom Table impact Use in WASM
atom (pre-SWI-7 default) Interned as atom 'text' Permanent entry per unique string NEVER
codes List of character codes [116,101,120,116] No atom, but large term allocation Avoid for user input
string (SWI 7+ default) Prolog string object on global heap Zero Atom Table entries ALWAYS

The default in SWI-Prolog 7+ is string. The swipl-wasm distribution inherits this default. The :- set_prolog_flag(double_quotes, string) directive in firewall_policy.pl makes the contract explicit and prevents any future KB author from accidentally changing it with a :- set_prolog_flag(double_quotes, atom) directive in a different module.

18.5.4 Validating Length Before WASM Crossing

Even with the correct string semantics, a sufficiently long string pushes a large term onto the WAM global stack for the duration of the query. An IP field accepting a 64KB string input and passing it as a Prolog string causes a 64KB allocation on the WAM global heap. Multiplied by concurrent query rate:

// Length guard in validateInputs() — prevents heap pressure:
if (ip.length > WASM_CONFIG.maxIpLength) {   // maxIpLength = 45
    return { valid: false, error: `IP address too long (max ${WASM_CONFIG.maxIpLength} chars).` };
}

The 45-character limit safely bounds any valid IPv6 string representation. It rejects all inputs that are structurally incapable of being valid IP addresses at the JavaScript layer before any WASM crossing, at zero WAM cost.


Outcome: Write Once, Reason Anywhere

18.6.1 The Conceptual Transition

Volume III opened with a single process: a Go HTTP server backed by a CGO worker pool. The Prolog rules from Volumes I and II — firewall_verdict/4, parse_ipv4/2, ip_in_cidr/3, whitelist_rule/3, blocklist_rule/3 — executed server-side, in WAM engines attached to OS-thread-locked goroutines, at 20μs per query.

Chapter 18 executes the same rules in the user's browser. Not a reimplementation in JavaScript. Not a translation to a different logic system. The same .pl files, the same Prolog clauses, the same WAM unification engine — running in WASM Linear Memory inside a V8 tab, at 15–30μs per query, with zero network round trips. The latency is not lower because the query is simpler; it is lower because the server round trip has been eliminated entirely.

The rules are written once, in Prolog, in the canonical format established in Chapter 15. They are deployed in three contexts without modification:

  1. Server-side (Chapter 15–16): libswipl.so embedded in the Go binary via CGO. WAM executes inside the Go process. Network round trip: 0 hops (in-process).

  2. Worker pool (Chapter 16): 16 locked OS threads, 16 WAM engines, 80,000+ queries/second aggregate throughput. Shared clause database. Same .pl files via consultFile().

  3. Browser edge (Chapter 18): swipl.wasm in the browser tab. WASM Linear Memory. VFS. Same .pl files via --preload-file. Zero server queries for validation.

The security invariants are identical across all three deployments: user-controlled strings never become atoms, input length is bounded before WAM crossing, parse_ipv4/2 validates semantically inside the WAM, unknown inputs produce typed errors that surface cleanly to the caller. The enforcement mechanism differs (CGO FLI vs. Emscripten bindings vs. JS input guards), but the contract is the same.

Deployment Host Round trips Latency Throughput
Server CGO (Ch 15) Go process, server 0 (in-process) 20μs Single query
Worker pool (Ch 16) Go process, server 0 (in-process) 20μs 80,000+ q/s
Browser WASM (Ch 18) V8 tab, client 0 (in-browser) 15–30μs 1 tab
Server HTTP (baseline) Separate process 1 (localhost TCP) 0.5–12ms Limited by scheduler

18.6.2 Verification Checklist

// All checks executable in the browser console after page load:

// 1. Engine initialised and KB loaded
console.assert(wasmReady === true, 'WAM engine not ready');

// 2. double_quotes flag is string (atom table safety)
const fq = wasmModule.Prolog.query("current_prolog_flag(double_quotes, F)").once();
console.assert(fq.F === 'string', `double_quotes = ${fq.F} — MUST be 'string'`);

// 3. Whitelist IP returns allowed
const r1 = wasmModule.Prolog.query(
    'firewall_verdict(request{source_ip:"10.0.1.5",dest_port:443,protocol:"tcp"},V,_,_)'
).once();
console.assert(r1.V === 'allowed', `Expected allowed, got ${r1.V}`);

// 4. Blocklist IP returns denied
const r2 = wasmModule.Prolog.query(
    'firewall_verdict(request{source_ip:"203.0.113.8",dest_port:22,protocol:"tcp"},V,_,_)'
).once();
console.assert(r2.V === 'denied', `Expected denied, got ${r2.V}`);

// 5. Atom table stable across 1000 distinct string queries
const atomsBefore = wasmModule.Prolog.query(
    'aggregate_all(count, current_atom(_), N)'
).once().N;
for (let i = 0; i < 1000; i++) {
    wasmModule.Prolog.query(
        `firewall_verdict(request{source_ip:"10.0.${i % 256}.1",dest_port:443,protocol:"tcp"},_,_,_)`
    ).once();
}
const atomsAfter = wasmModule.Prolog.query(
    'aggregate_all(count, current_atom(_), N)'
).once().N;
console.assert(atomsBefore === atomsAfter,
    `Atom table grew by ${atomsAfter - atomsBefore} — STRING semantics violated`);

// 6. Invalid IP rejected before WASM crossing (JS layer)
const badValidation = validateInputs(); // with ip field containing "not-an-ip"
// (Set document.getElementById('ip').value = 'not-an-ip' first)
// Expected: {valid: false, error: 'Invalid IPv4 format...'}

// 7. Stack limit active
const sl = wasmModule.Prolog.query("current_prolog_flag(stack_limit, N)").once();
console.assert(sl.N <= 8 * 1024 * 1024, `stack_limit ${sl.N} exceeds 8MB browser budget`);

// 8. Graceful degradation: WASM error triggers server fallback
// (Simulate by setting wasmFailed = true and submitting the form)

18.6.3 What Comes Next

Volume III closes here. The Prolog rules are now sovereign across the full deployment stack: compiled into the Go binary as a CGO-embedded WAM, scaled across a 16-worker OS-thread-locked pool, and distributed to the browser edge as a WASM module. The query that validates a firewall policy executes at microsecond latency at every layer — in the server process, in the worker pool, and in the client tab — from the same source file.

The logic engine has no blind spots in its reasoning — but it has one blind spot in its inputs: it knows only what it has been told. firewall_verdict/4 evaluates the rules in firewall_policy.pl correctly, but it cannot know that pve3's CPU has been pinned at 98% for six minutes, that the NVMe write latency on storage1 doubled at 14:32, or that a bonded uplink on leaf_b dropped 0.3% of packets in the last 60-second window. These are not logic facts — they are physical measurements from bare-metal hardware, and they arrive as a continuous stream of time-series samples, not as Prolog clauses.

Volume IV opens with Chapter 19: Bare-Metal Telemetry. The chapter instruments the Proxmox cluster nodes with VictoriaMetrics agents, scrapes CPU steal, disk I/O latency, memory pressure, and network error counters into a time-series database, and builds the Grafana dashboards that surface them to the operator. This is not a detour from the logic engine — it is the instrumentation layer that will feed it. Chapter 20 then closes the loop: the telemetry stream is transformed into assert'd Prolog facts (node_metric(pve3, cpu_steal_pct, 98.1, T)) and injected into the live KB, making the logic engine's routing and policy decisions telemetry-aware. A firewall rule that permits pve3 as a source node will automatically fail the healthy_node/1 guard when the CPU steal metric crosses its threshold fact. The reasoning is still declarative. The inputs are now physical.


Chapter Summary

ConceptOperational DefinitionPerformance / Security Consequence
WASM Linear MemoryFlat contiguous byte array (WebAssembly.Memory); managed by compiled C malloc/free; not traced by V8 GCAll WAM state (stacks, Atom Table, clause DB) lives here; V8 GC never sees or collects it; ownership boundary is absolute
swipl-wasmSWI-Prolog runtime compiled to WASM via Emscripten; full WAM, clause DB, module system, library(tabling)Same .pl files run in browser tab as in CGO server binary; no reimplementation
V8 heap ↔ WASM Linear Memory crossingEmscripten stringToUTF8 copies V8 string to WASM; UTF8ToString copies result back; explicit _malloc/_freeSkipping the copy passes V8 heap addresses to C pointers — WASM trap; always use the Prolog.query() API
Emscripten VFS (MEMFS)In-memory POSIX filesystem inside WASM Linear Memory; --preload-file embeds .pl files in .data bundleconsult('/prolog/firewall_policy.pl') synchronous and zero-network inside WASM; VFS path must match @dst in packaging
--preload-file src@dstEmscripten packaging: embeds file at build time, extracts to VFS at WASM initFiles available before first Prolog.call(); no async fetch required
COOP/COEP headersCross-Origin-Opener-Policy: same-origin + Cross-Origin-Embedder-Policy: require-corpRequired for SharedArrayBuffer (multi-threaded WASM); omission causes silent fallback to single-threaded WASM
Browser WASM stack limits--stack-limit=8M --table-space=4M in SWIPL() argumentsServer limit (64M) is too large for tab memory budget; 8MB provides 8,000× headroom for depth-5 firewall queries
Prolog.query(goal).once()Executes goal, returns first solution as JS object with bound variable values; returns null on failure; throws on exceptionNull return = goal failed (KB integrity error); exception = WAM runtime error; both must be handled
double_quotes = string flagSWI 7+ default: "text" in query string → Prolog string on global heapZero Atom Table growth for user input; equivalent to PL_put_string_chars in CGO
double_quotes = atom (dangerous)"text" → permanent atom in Atom Table100,000 distinct inputs → 9MB permanent Atom Table growth → WASM OOM trap → instance dead
Browser atom exhaustion DoSAttacker/user submits many distinct string values via single-quoted or unquoted query interpolationWASM OOM trap kills instance; client-side validation lost; all queries fall back to server round trip
JS input length guardip.length > 45 rejected before buildQueryString() call64KB IP string → 64KB WAM global heap allocation per query; length guard costs 0 WAM cycles
Graceful degradationwasmFailed = true → all queries route to queryServerFallback()Server fallback (~5ms) activates silently on WASM engine failure; no white screen; user continues working
"Write Once, Reason Anywhere"Same .pl files deployed as CGO library, worker-pool KB, and WASM VFS bundleSingle source of truth for firewall policy; no reimplementation across deployment targets; security invariants identical
.qlf Quick Load FileServer-side qcompile/2 pre-compiles .pl to WAM bytecode; .qlf bundled into VFS via --preload-fileCuts browser consult time >60% for large KBs; tokeniser/parser/compiler skipped at load; mandatory for KBs >500 clauses
Cache-Control: immutable for .wasm/.datamax-age=31536000, immutable on hash-named WASM and VFS bundle files8.3MB binary fetched once per content hash; all subsequent loads served from disk cache in <10ms; index.html must be no-cache
index.html no-cacheCache-Control: no-cache, no-store, must-revalidate on HTML entry pointEnsures browser always loads latest hashed asset references; prevents stale swipl.wasm hash after a WASM version update

Exercises

Exercise 18.1 — Dynamic KB Reload via FS.writeFile Implement a reloadPolicy(policyText) function in wasm_firewall.js that accepts a Prolog source string, writes it to /prolog/firewall_policy_live.pl via wasmModule.FS.writeFile(), calls consult('/prolog/firewall_policy_live.pl') via wasmModule.Prolog.call(), and verifies that firewall_verdict/5 is still defined after the reload. Add a <textarea> to index.html where an operator can paste a new policy and click "Reload". Verify that a reload with a syntax error is caught by the try/catch in the reload function, that the engine continues to serve queries from the prior policy, and that the UI displays the error without white-screening.

Exercise 18.2 — Atom Table Regression Test Write a Node.js test script test_atom_safety.mjs that initialises the swipl-wasm module, records the atom count via aggregate_all(count, current_atom(_), N), submits 10,000 firewall queries with 10,000 distinct IP addresses using buildQueryString() (the correct double-quoted variant), records the atom count again, and asserts that the delta is zero. Then modify buildQueryString to use single-quoted atoms, re-run the test, and assert that the delta is exactly 20,000 (two atoms per query: IP and protocol). This test must be added to the CI pipeline that builds the WASM bundle.

Exercise 18.3 — Term Construction API Instead of String Interpolation Section 18.5.2 notes that string interpolation is safe for validated IP and protocol inputs but unsafe for arbitrary text fields. Implement buildQueryTerm(ip, port, protocol) using the swipl-wasm term construction API (Module.Prolog.term(), Module.Prolog.functor(), Module.Prolog.put_string()) rather than a template literal. This approach does not produce a query string at all — it constructs the term object directly in WASM Linear Memory, bypassing the PL_chars_to_term parsing step entirely. Benchmark the term construction API against the string interpolation API for 10,000 queries and report the latency difference.

Exercise 18.4 — WASM-Compiled proxmox_topology.pl with Tabling Package the proxmox_topology.pl and its tabled shortest_path/3 predicate from Chapter 17 into the VFS bundle alongside firewall_policy.pl. Implement a topology query UI tab in index.html that accepts a source and destination hypervisor name, calls query_path(Src, Dst, Cost, Path).once(), and renders the hop sequence as an SVG network diagram. Verify that: (a) the grounding guards from Section 17.5.2 fire correctly when the Src or Dst fields are empty; (b) the Answer Trie caches the result on second query (verify by timing — second query should be ≤ 1μs vs. ~200μs first query); (c) abolish_all_tables called via wasmModule.Prolog.call() flushes the browser-side trie.

Exercise 18.5 — Service Worker Caching for Offline-First Deployment The swipl.wasm binary is 8.3MB. On a first load over a 10Mbps connection, this takes 8 seconds — unacceptable for an operations UI. Implement a Service Worker (sw.js) that caches swipl.wasm, swipl.js, and swipl.data on first load using the Cache API. On subsequent loads, the Service Worker serves the WASM binary from cache without a network request. Add a cache-busting mechanism: when firewall_policy.pl is updated (detected via a version hash in a /api/kb-version endpoint), the Service Worker invalidates the swipl.data cache entry and fetches the new bundle. Verify that after the Service Worker is installed, the browser tab operates fully offline — including policy checks — with no network connectivity.


Further Reading

  • SWI-Prolog WASM: swipl-wasm npm package — https://npm.im/swipl-wasm — official distribution; source at https://github.com/SWI-Prolog/swipl-wasm
  • SWI-Prolog WASM documentation — https://swi-prolog.github.io/swipl-wasm/SWIPL() factory API, Prolog.query(), Prolog.call(), VFS packaging
  • Emscripten File System API — https://emscripten.org/docs/api_reference/Filesystem-API.htmlFS.writeFile, FS.readFile, MEMFS and NODEFS backends; the --preload-file flag
  • Emscripten: Interacting with Code — https://emscripten.org/docs/porting/connecting_cpp_and_javascript/Interacting-with-code.htmlstringToUTF8, UTF8ToString, _malloc, _free; the complete guide to the V8 ↔ WASM Linear Memory crossing
  • WebAssembly Memory — https://developer.mozilla.org/en-US/docs/WebAssembly/JavaScript_interface/MemoryWebAssembly.Memory specification; linear memory model, growth operations, 4GB ceiling
  • Cross-Origin Opener Policy — https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Cross-Origin-Opener-PolicySharedArrayBuffer enabling headers; security implications of COOP/COEP
  • Haas, A. et al. (2017). "Bringing the Web up to Speed with WebAssembly." PLDI 2017. — the original WebAssembly paper; the memory model and execution semantics described in Section 18.1 are defined here

End of Chapter 18 — End of Volume III: Scaling & Concurrency

Volume IV opens with Chapter 19: Bare-Metal Telemetry — VictoriaMetrics, Grafana, and the Instrumentation Layer for a Logic-Aware Cluster


Revision record: Chapter 18.1 — Architect’s review applied. Fix 1: pedantic IPv6 character-count sentence deleted from Section 18.5.4; replaced with "The 45-character limit safely bounds any valid IPv6 string representation." Fix 2: Section 18.6.3 rewritten to transition to Chapter 19 Bare-Metal Telemetry (VictoriaMetrics/Grafana) rather than Meta-Interpretation; explains the telemetry gap (CPU steal, disk I/O, network error rates are physical measurements, not Prolog facts) and previews Chapter 20’s telemetry-to-KB assert pipeline. Fix 3: footer corrected to Chapter 19: Bare-Metal Telemetry. Improvement 1: Section 18.2.1.1 inserted — qcompile/2 Quick Load File pre-compilation pattern; server-side swipl -g qcompile(...) command; build.sh .qlf packaging; load_files/2 JS init update; >60% init time reduction for large KBs; mandatory threshold (>500 clauses). Improvement 2: Section 18.2.4.1 inserted — Cache-Control: immutable for hash-named WASM and .data files; max-age=31536000; build.sh sha256-based filename hashing; nginx location blocks for .wasm, .data, and index.html; cross-reference to Exercise 18.5 Service Worker. Three new Chapter Summary rows. BookStack tags: swi-prolog, chapter-18, wasm, webassembly, swipl-wasm, emscripten, vfs, browser, edge-logic, atom-table, zero-latency, qlf, cache-control, volume-iii, volume-iii-close