Skip to main content

Chapter 19: Building the Orchestrator UI flawed to be re written

Chapter 19: Building the Orchestrator UI

The Sovereign Orchestrator UI is a Single Page Application served by the Chapter 16 Go worker pool that operates across twothree simultaneous verification regimes:layers: the WASM WAM executing insidein the browser tab provides sub-millisecond logic feedback as the operator types,types; whilethe Go server-sent events pipeline pushes authoritative KB state changes to every connected browser the moment a topology mutation completes; and the Go CGO worker pool re-validates every state-mutating actionrequest isfrom independently re-parsed, re-validated, and re-executed by the server-side CGO poolscratch before any cluster mutationchange is permitted — because the browser is not a trust boundary, the edge KB can be stale, and neverneither hascondition been.should require a page reload to resolve. This chapter wires orchestrator_server.go to the Chapter 16 Pool, writesimplements thea dashboard.jslive coordinationSSE layer,channel definesthat thedrives COOP/COEPautomatic andWASM Cache-ControlVFS middleware directly in Go,resynchronisation, and closes with the double-verification security contract that makes the WASMedge layer a performance optimisation ratherand thanan availability convenience, never a security primitive.


19.1 The ArchitectureDouble-Verification ofContract the Sovereignand SPA Architecture

19.1.1 Why Vanilla JS

A React application has a build-time dependency graph of 800–1,200 npm packages. Any one of those packages can introduce a supply-chain compromise, a breaking API change, or an incompatible peer dependency on a timeline with no relation to the operator's schedule. The compiled bundle is opaque JavaScript that requires a build toolchain to modify, a CI pipeline to deploy, and a browser devtools session to debug in production. Three years from now, node_modules will fail to compile against a Node.js version that ships with the host OS in 2029. The framework becomes a maintenance liability on a cadence controlled by the npm ecosystem, not by the operator.

Vanilla JavaScript with direct DOM manipulation has no build step, no dependency graph, no package.json, and no version drift. The source file that serves the UI today serves it in 2035. It loads in any browser released after 2018, it is readable without tooling, and it is debuggable with the browser's built-in inspector.2036. Every API used in this chapter — fetch, addEventListener, querySelector, classList, EventSource, template literals, async/await — is in the HTML Living Standard and has been stable since 2020. The WASM runtime (swipl.wasm) is the single external binary, versioned explicitly in build.sh and served as an immutable cached asset.

TheAn trade-offoperator ison real:an air-gapped network with no componentnpm model,registry and no reactiveCDN statecan graph,modify, nodeploy, JSX.and Fordebug the entire UI with a sovereigntext infrastructureeditor dashboard used byand a smallbrowser operations team, not a consumer product, these are not trade-offs — they are constraints that were never requirements.inspector.

19.1.2 The Verification Loop

%%{init: {"themeVariables": {"fontSize": "14px"}}}%%
flowchart TD
    INPUT["Operator Input\nIP address, port, protocol\nFree-text DOM fields\nNo server contact yet"]

    WASM["WASM WAM — Browser Tab\nfirewall_verdict/4 executes locally\nmust_be(ground, Src) guard fires\nparse_ipv4/2 validates semantically\nLatency: 15-30μs\30us\nResult: instant green/red UI feedback\nZero network round trips"]

    DECISION{"Deploy to Cluster\nclicked?"}

    FEEDBACK["Visual Feedback Only\nDOM class: allowed / denied\nNo state mutation\nNo server contact\nOperator continues editing"]

    FETCH["fetch() POST to Go API\n/api/v1/firewall/check\nJSON: {source_ip, dest_port, protocol}\protocol\nAuthorization: Bearer token\nRaw DOM values only\nWASM verdict NOT forwarded"]

    GOPOOL["Go CGO Worker Pool — Server\Pool\nChapter 16: locked OS threads\nEach: independent WAM engine\nRe-parses JSON payload from scratch\nRe-runs firewall_verdict/4\nRe-fires must_be(ground, X) guards\nNo trust in any client assertion"]

    AUTHCHECK["Authorization + Re-Validation\nBearer token verified server-side\nSourceIP: re-parsed by parse_ipv4/2\nPort: bounds check 1-65535\nProtocol: closed vocabulary atom check\nAll fields treated as hostile input"]

    MUTATE["Cluster State Mutation\nretract_link / assert_link on shared DB\assert_link\nControlFlushTables broadcastbroadcast\nSSE Ch16\nabolish_table_subgoalsevent Ch17\pushed to all clients\nAudit log entry written\nReachable only after server re-validation"written"]

    REJECT["Reject 400 / 403\nStructured JSON error to client\nWASM UI shows server rejection reason\error\nNo cluster mutationmutation"]

    occurred\nAttemptSSE["SSE recordedPipeline\nGo inpushes auditkb_updated log"event\nAll connected browsers receive\nJS fetches updated .pl from server\nWrites to Emscripten MEMFS\nconsult() reloads edge KB\nNo page reload required"]

    INPUT --->|"on input event"| WASM
    WASM --->|"<5ms result"| DECISION
    DECISION --->|"still editing"| FEEDBACK
    FEEDBACK --->|"back to typing"| INPUT
    DECISION --->|"Deploy clicked"| FETCH
    FETCH --->|"HTTP POST"| GOPOOL
    GOPOOL --->|"token + payload"| AUTHCHECK
    AUTHCHECK --->|"valid"| MUTATE
    AUTHCHECKGOPOOL --->|"invalid"| REJECT
    MUTATE --->|"server event"| SSE
    SSE --->|"edge KB current"| WASM

    style INPUT fill:#1A2B4A,color:#FFFFFF
    style WASM fill:#7A1A1A,color:#FFFFFF
    style DECISION fill:#8B6914,color:#FFFFFF
    style FEEDBACK fill:#2A4A2A,color:#FFFFFF
    style FETCH fill:#1A4070,color:#FFFFFF
    style GOPOOL fill:#1A4070,color:#FFFFFF
    style AUTHCHECK fill:#5A1A6A,color:#FFFFFF
    style MUTATE fill:#1A6B3A,color:#FFFFFF
    style REJECT fill:#7A1A1A,color:#FFFFFF
    style SSE fill:#5A1A6A,color:#FFFFFF

The amber decisionSSE node iscloses the architecturalloop. hinge.The Anedge inputWAM's eventvalue onis theits IP field triggers the WASM branchlatencythe15–30μs WAM executes, the DOM is painted, no network contact occurs. A submit event on the Deploy button triggers the fetch branch —versus a fresh5–50ms JSON payload constructed from the raw DOM values (not from the WASM result) is postedround-trip to the Go API,API. whichThat re-validatesvalue every byte asdisappears if the clientedge sentKB garbage.is stale. The SSE pipeline restores it: every topology mutation on the server triggers a kb_updated event that drives an automatic MEMFS write and consult() in every connected browser, without a page reload and without the operator taking any action.


19.2 Serving the Edge:The Go net/httpEvent Horizon: SSE and Dynamic Topology

19.2.1 MiddlewareArchitecture

The Go server maintains two structures that did not exist in the prototype: an SSE broker that fans out kb_updated events to all connected browser clients, and a dynamically updatable node vocabulary that allows the operator to approve new hypervisor nodes without restarting the server. The Chapter 16 worker pool, the LRU denial cache from the previous iteration, and the secureHeaders/requireJSON/requestSizeLimit middleware chain are unchanged.

The topology node vocabulary problem is architectural. A frozen map built once at startup means adding pve15 to the cluster requires a server restart, which drops all SSE connections, which causes every connected browser to fall back to server-only validation mode for COOP/COEP and Cache-Control

Chapter 18 defined the nginxreconnection configurationwindow. The correct design is an RWMutex-protected map with a dedicated HTTP handler for COOP/COEPnode headers and Cache-Control. When the Go binary serves static files directlyapproval — the correctoperator posture forPOSTs a sovereignnew single-binarynode deploymentname with no nginx dependency — these headers are injected by a Go middleware wrapper. The wrapper inspectsto r.URL.Path/api/v1/topology/approve, onthe eachserver responsevalidates it against the live WAM clause database, and appliesif known_node/1 succeeds, the policyname appropriateis added to that file type:

// File: /opt/logic-node/go/orchestrator/middleware.go
package main

import (
	"net/http"
	"strings"
)

// secureHeaders injects headers required for:
//
//   - COOP/COEP on all responses: Chrome and Firefox require these on every
//     response from the originlive vocabulary. notNo justrestart. theNo HTMLSSE entry point — when
//     SharedArrayBuffer is in use. Applying them at the mux root is correct.
//
//   - Immutable caching on .wasm and .data: content-hash suffixes from
//     build.sh (Chapter 18, §18.2.4.1) make these assets safe to cache
//     permanently. One year max-age is the conventional signal to CDNs and
//     browser caches that the resource never changes at this URL.
//
//   - application/wasm MIME type: a server returning application/octet-stream
//     for .wasm files forces the browser to buffer the full 8.3MB binary before
//     compilation begins, eliminating the streaming compile optimisation in V8
//     and SpiderMonkey that halves the effective load time.
//
//   - no-cache on index.html, swipl.js, dashboard.js: these files embed or
//     locate the current hashed asset filenames. A stale index.html referencing
//     swipl.a1b2c3d4.wasm that was replaced in the last build produces a 404
//     on the most expensive fetch in the page lifecycle.
func secureHeaders(next http.Handler) http.Handler {
	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		w.Header().Set("Cross-Origin-Opener-Policy", "same-origin")
		w.Header().Set("Cross-Origin-Embedder-Policy", "require-corp")

		p := r.URL.Path
		switch {
		case strings.HasSuffix(p, ".wasm"):
			w.Header().Set("Content-Type", "application/wasm")
			w.Header().Set("Cache-Control", "public, max-age=31536000, immutable")

		case strings.HasSuffix(p, ".data"):
			w.Header().Set("Content-Type", "application/octet-stream")
			w.Header().Set("Cache-Control", "public, max-age=31536000, immutable")

		case strings.HasSuffix(p, ".js") &&
			!strings.HasSuffix(p, "dashboard.js") &&
			!strings.HasSuffix(p, "swipl.js"):
			// Hashed app JS assets not otherwise matched.
			w.Header().Set("Cache-Control", "public, max-age=3600")

		case p == "/" ||
			strings.HasSuffix(p, "/index.html") ||
			strings.HasSuffix(p, "dashboard.js") ||
			strings.HasSuffix(p, "swipl.js"):
			// Entry point and Emscripten glue: must never be served stale.
			w.Header().Set("Cache-Control", "no-cache, no-store, must-revalidate")

		default:
			w.Header().Set("Cache-Control", "no-store")
		}

		next.ServeHTTP(w, r)
	})
}

// requireJSON returns 415 for POST/PUT requests without application/json
// Content-Type. The check precedes body reading.
func requireJSON(next http.Handler) http.Handler {
	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		if r.Method == http.MethodPost || r.Method == http.MethodPut {
			if !strings.HasPrefix(r.Header.Get("Content-Type"), "application/json") {
				writeJSON(w, http.StatusUnsupportedMediaType, APIResponse{
					Error: "Content-Type must be application/json",
				})
				return
			}
		}
		next.ServeHTTP(w, r)
	})
}

// requestSizeLimit wraps r.Body in an http.MaxBytesReader.
// 64KB rejects any body structurally too large to be a legitimate payload;
// the largest realistic payload in this system is under 200 bytes.
func requestSizeLimit(maxBytes int64) func(http.Handler) http.Handler {
	return func(next http.Handler) http.Handler {
		return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
			r.Body = http.MaxBytesReader(w, r.Body, maxBytes)
			next.ServeHTTP(w, r)
		})
	}
}

// chain applies middleware in left-to-right declaration order.
func chain(h http.Handler, mw ...func(http.Handler) http.Handler) http.Handler {
	for i := len(mw) - 1; i >= 0; i-- {
		h = mw[i](h)
	}
	return h
}

disruption.

19.2.2 StaticSSE File Handler with SPA FallbackBroker

// File: /opt/logic-node/go/orchestrator/static.sse.go
package main

import (
	"fmt"
	"log"
	"net/http"
	"os"
	"path/filepath"
	"strings"sync"
)

// staticHandlerSSEBroker servesmanages filesa fromset staticDirof connected browser clients and fallsfans back to index.htmlout
// forserver-sent any path that does not mapevents to anall existingof file.them. TheEach SPA fallbackclient is represented by
// requireda becausechannel deepof linksstrings; the broker goroutine receives events on the
// broadcast channel and copies them to every registered client channel.
//
// Design constraints:
//   - A slow or disconnected client must not block event delivery to
//     other clients. Each client channel is buffered (32 events); if
/dashboard/topology)/     shoulda loadclient's index.htmlbuffer is full when a broadcast arrives, that client
//     receives a disconnect signal rather than blocking the broker.
//   - The broker runs in its own goroutine for its full lifetime and
//     is the only writer to client channels, eliminating the need for
//     per-client locks.
type SSEBroker struct {
	broadcast  chan string
	register   chan chan string
	unregister chan chan string

	mu      sync.RWMutex
	clients map[chan string]struct{}
}

func NewSSEBroker() *SSEBroker {
	b := &SSEBroker{
		broadcast:  make(chan string, 64),
		register:   make(chan chan string),
		unregister: make(chan chan string),
		clients:    make(map[chan string]struct{}),
	}
	go b.run()
	return b
}

func (b *SSEBroker) run() {
	for {
		select {
		case ch := <-b.register:
			b.mu.Lock()
			b.clients[ch] = struct{}{}
			b.mu.Unlock()
			log.Printf("[SSE] client connected — total: %d", len(b.clients))

		case ch := <-b.unregister:
			b.mu.Lock()
			delete(b.clients, ch)
			b.mu.Unlock()
			close(ch)
			log.Printf("[SSE] client disconnected — total: %d", len(b.clients))

		case msg := <-b.broadcast:
			b.mu.RLock()
			for ch := range b.clients {
				select {
				case ch <- msg:
				default:
					// Client buffer full — drop this client.
					// The browser will reconnect automatically via
					// EventSource's built-in reconnection logic and
					// will receive the next event after reconnection.
					log.Printf("[SSE] client buffer full — dropping")
					b.mu.RUnlock()
					b.unregister <- ch
					b.mu.RLock()
				}
			}
			b.mu.RUnlock()
		}
	}
}

// Publish sends an SSE event string to all connected clients.
// The event string must be a complete SSE-formatted payload including
// the trailing double newline.
func (b *SSEBroker) Publish(event string) {
	b.broadcast <- event
}

// ServeHTTP handles an SSE connection for one client.
// It registers the client, streams events until the client disconnects,
// and letunregisters theon JS router handle the path rather than returning a 404.return.
func staticHandler(staticDir(b string)*SSEBroker) http.Handler {
	fs := http.FileServer(http.Dir(staticDir))
	return http.HandlerFunc(func(ServeHTTP(w http.ResponseWriter, r *http.Request) {
	//flusher, Prohibitok directory:= listings.w.(http.Flusher)
	if strings.HasSuffix(r.URL.Path, "/") && r.URL.Path != "/"ok {
		http.Error(w, "forbidden"streaming not supported", http.StatusForbidden)StatusInternalServerError)
		return
	}

	targetw.Header().Set("Content-Type", "text/event-stream")
	w.Header().Set("Cache-Control", "no-cache")
	w.Header().Set("Connection", "keep-alive")
	w.Header().Set("X-Accel-Buffering", "no") // disable nginx buffering if proxied

	ch := filepath.Join(staticDir,make(chan filepath.Clean(string, 32)
	b.register <- ch
	defer func() { b.unregister <- ch }()

	// Send an initial connection confirmation event so the browser
	// can distinguish "connected and waiting" from "not connected".
	fmt.Fprintf(w, "event: connected\ndata: {\"status\":\"ok\"}\n\n")
	flusher.Flush()

	ctx := r.URL.Path)Context()
	for {
		select {
		case <-ctx.Done():
			return
		case msg, ok := <-ch:
			if _,!ok {
				return
			}
			fmt.Fprint(w, msg)
			flusher.Flush()
		}
	}
}

19.2.3 TCP Keep-Alive and Ghost Connection Hygiene

The IdleTimeout: 120 * time.Second in NewServer handles idle HTTP/1.1 connections at the Go layer. It does not cover the case of a browser tab that has been frozen by a mobile OS battery manager or a desktop OS sleep cycle: the underlying TCP connection remains half-open on the server's routing fabric with no data flowing, the Go runtime sees no EOF and fires no r.Context().Done(), and the client channel registered in the broker's clients map accumulates indefinitely. On a management station that is suspended overnight with 12 browser tabs open, the server accrues 12 ghost client channels consuming goroutine stack, channel memory, and broker iteration overhead for every subsequent Publish call.

The correct defence is OS-level TCP keep-alive tuning on logic-node-01. With the default Linux tcp_keepalive_time of 7,200 seconds, the kernel waits two hours before sending the first keep-alive probe to a silent TCP connection. At a sovereign infrastructure scale of 20–50 operator sessions, this is a bounded but real nuisance; at 200+ sessions it becomes a memory pressure issue.

# /etc/sysctl.d/99-observability.conf (already established in Chapter 20 —
# append these three values to the existing file):

# SSE ghost connection hygiene.
# Send first TCP keep-alive probe after 60 seconds of silence.
net.ipv4.tcp_keepalive_time = 60
# Retry keep-alive probes every 10 seconds.
net.ipv4.tcp_keepalive_intvl = 10
# Drop connection after 5 unanswered probes (total: 110 seconds to detect a dead tab).
net.ipv4.tcp_keepalive_probes = 5

Apply with sysctl --system. The SSE ServeHTTP handler does not need changes — the TCP keep-alive mechanism operates below the HTTP layer. When the kernel's keep-alive probes receive no answer, the TCP stack closes the connection, the Go HTTP server fires r.Context().Done() in ServeHTTP, and the b.unregister <- ch defer runs, removing the ghost channel from the broker's clients map and closing it. The 110-second detection window (60s idle + 5 × 10s probes) is appropriate for sovereign infrastructure: fast enough to prevent ghost accumulation across a normal overnight cycle, slow enough to survive a mobile network handoff where the TCP connection is momentarily dark.

19.2.4 Dynamic Node Vocabulary

// File: /opt/logic-node/go/orchestrator/topology.go
package main

import (
	"fmt"
	"log"
	"sync"
	"time"
)

// nodeVocabulary is the live set of approved topology node names.
// It is initialised at startup by querying the WAM clause database
// and extended at runtime via handleApproveNode without a server restart.
//
// Read path (knownTopologyNode): called on every topology mutation request.
// Write path (approveNode): called only by handleApproveNode under the
// operator's explicit action. No automatic promotion of runtime assertions.
type nodeVocabulary struct {
	mu    sync.RWMutex
	nodes map[string]struct{}
}

var vocab = &nodeVocabulary{
	nodes: make(map[string]struct{}),
}

// buildKnownNodes populates vocab from the live WAM clause database.
// Called once after pool init at server startup.
func buildKnownNodes(pool *Pool) error {
	result, err := os.Stat(target);pool.Dispatch(WorkItem{
		os.IsNotExist(err)Goal: "findall(N,proxmox_topology:known_node(N),Nodes)",
	}, 2*time.Second)
	if err != nil {
		r.URL.Pathreturn fmt.Errorf("dispatch: %w", err)
	}
	if result.Err != nil {
		return fmt.Errorf("WAM: %w", result.Err)
	}

	vocab.mu.Lock()
	defer vocab.mu.Unlock()
	for _, node := range result.NodeList {
		vocab.nodes[node] = "/"struct{}{}
	}
	fs.ServeHTTP(w,log.Printf("[Topology] r)startup vocabulary: %d nodes", len(vocab.nodes))
	return nil
}

// approveNode adds a node name to the live vocabulary after verifying
// it exists in the WAM clause database. Returns an error if the WAM
// does not recognise the node — preventing arbitrary string injection
// into the vocabulary map.
//
// This is the only write path other than buildKnownNodes. The map is
// never written by topology mutation handlers; mutation handlers only
// read the map to validate their input.
func approveNode(pool *Pool, name string) error {
	// Validate against live WAM — not just the startup snapshot.
	goal := fmt.Sprintf("proxmox_topology:known_node(%s)", name)
	result, err := pool.Dispatch(WorkItem{Goal: goal}, 1*time.Second)
	if err != nil {
		return fmt.Errorf("dispatch: %w", err)
	}
	if result.Err != nil {
		return fmt.Errorf("node %q not in WAM clause database: %w", name, result.Err)
	}

	vocab.mu.Lock()
	vocab.nodes[name] = struct{}{}
	vocab.mu.Unlock()

	log.Printf("[Topology] approved new node: %q (vocabulary size: %d)",
		name, func() int {
			vocab.mu.RLock()
			defer vocab.mu.RUnlock()
			return len(vocab.nodes)
		}())
	return nil
}

// knownTopologyNode reports whether name is in the approved vocabulary.
func knownTopologyNode(name string) bool {
	vocab.mu.RLock()
	defer vocab.mu.RUnlock()
	_, ok := vocab.nodes[name]
	return ok
}

19.3 The Build:2.5 orchestrator_server.go

// File: /opt/logic-node/go/orchestrator/orchestrator_server.go
package main

import (
	"context"
	"encoding/json"
	"errors"
	"fmt"
	"log"
	"net/http"
	"os"
	"os/signal"
	"runtime"
	"strings"
	"syscall"
	"time"
)

// ───────────────────────────────────────────────────────────────────────────── // REQUESTRequest / RESPONSEResponse TYPEStypes // ─────────────────────────────────────────────────────────────────────────────

type FirewallCheckReq struct {
	SourceIP string `json:"source_ip"`
	DestPort int    `json:"dest_port"`
	Protocol string `json:"protocol"`
}

type TopologyMutateReq struct {
	Action string `json:"action"` // "add_link" | "remove_link"
	Node1  string `json:"node1"`
	Node2  string `json:"node2"`
	Cost   int    `json:"cost"`
}

type ApproveNodeReq struct {
	Node string `json:"node"`
}

type APIResponse struct {
	OK      bool        `json:"ok"`
	Data    interface{} `json:"data,omitempty"`
	Error   string      `json:"error,omitempty"`
	Latency int64       `json:"latency_us,omitempty"`
}

func writeJSON(w http.ResponseWriter, status int, body APIResponse) {
	w.Header().Set("Content-Type", "application/json")
	w.WriteHeader(status)
	if err := json.NewEncoder(w).Encode(body); err != nil {
		log.Printf("[API] encode error: %v", err)
	}
}

// ───────────────────────────────────────────────────────────────────────────── //Server SERVER
// ─────────────────────────────────────────────────────────────────────────────

type Server struct {
	pool      *Pool
	broker    *SSEBroker
	http      *http.Server
	staticDir string
	kbPath    string
}

func NewServer(pool *Pool, broker *SSEBroker, staticDir, kbPath, addr string) *Server {
	s := &Server{
		pool:      pool,
		broker:    broker,
		staticDir: staticDir}staticDir,
		kbPath:    kbPath,
	}

	mux := http.NewServeMux()

	apiGuards := func(h http.HandlerFunc) http.Handler {
		return chain(h, requireJSON, requestSizeLimit(64*1024))
	}

	mux.Handle("/api/v1/firewall/check",    apiGuards(s.handleFirewallCheck))
	mux.Handle("/api/v1/topology/mutate",   apiGuards(s.handleTopologyMutate))
	mux.Handle("/api/v1/topology/approve",  apiGuards(s.handleApproveNode))
	mux.HandleFunc("/api/v1/pool/status",   s.handlePoolStatus)
	mux.Handle("/api/v1/events",            broker)
	mux.HandleFunc("/api/v1/kb/current",    s.handleKBCurrent)
	mux.Handle("/", staticHandler(staticDir))

	s.http = &http.Server{
		Addr:              addr,
		Handler:           secureHeaders(mux),
		// ReadHeaderTimeout: protects against Slowloris attacks.
		// An attacker opens a TCP connection and sends HTTP headers one byte
		// at a time, permanently tying up a Go connection goroutine per open
		// connection. With enough connections the server runs out of goroutines.
		// ReadTimeout alone does not reliably close this window across all Go
		// versions — the timeout begins when the connection is accepted, but
		// early Go runtime implementations reset it on first-byte receipt,
		// leaving a gap between TCP accept and the first header byte.
		// ReadHeaderTimeout is specifically the duration allowed to read ALL
		// request headers after the connection is accepted. It is the correct
		// defence for bare-metal Go servers on hostile networks.
		ReadHeaderTimeout: 5 * time.Second,
		ReadTimeout:       10 * time.Second,
		WriteTimeout:      300, *// time.Second,SSE connections are long-lived; no write timeout
		IdleTimeout:       120 * time.Second,
	}
	return s
}

// WriteTimeout is set to 0 on the http.Server to accommodate SSE connections,
// which are indefinitely long-lived. The SSEBroker's per-client channel buffer
// and the client disconnect detection via r.Context().Done() provide the
// equivalent resource bound: a gone client is detected and cleaned up within
// one event cycle, not held open by a write deadline.

func (s *Server) ListenAndServe() error {
	log.Printf("[Server] Listeninglistening on %s — static: %s", s.http.Addr, s.staticDir)Addr)
	if err := s.http.ListenAndServe(); !errors.Is(err, http.ErrServerClosed) {
		return err
	}
	return nil
}

func (s *Server) Shutdown(ctx context.Context) {
	log.Printf("[Server] draining HTTP…")
	if err := s.http.Shutdown(ctx); err != nil {
		log.Printf("[Server] HTTP shutdown: %v", err)
	}
	log.Printf("[Server] draining pool…")
	if err := s.pool.Stop(10 * time.Second); err != nil {
		log.Printf("[Server] pool stop: %v", err)
	}
}

// ─── Handlers ─────────────────────────────────────────────────────────────────────────────
// GRACEFUL SHUTDOWN
// ─────────────────────────────────────────────────────────────────────────────

// Shutdown stops accepting HTTP connections first, then drains the worker pool.
//
// Order is mandatory. Stopping the pool before the HTTP server means in-flight
// HTTP handlers call pool.Dispatch into a stopped pool and receive ErrPoolStopped,
// producing 503 responses for requests that arrived before the signal and
// should have been served. Stopping HTTP first prevents new dispatches from
// arriving while the pool drains.
func (s *Server) Shutdown(ctx context.Context) {
	log.Printf("[Server] Shutdown: draining HTTP…")
	if err := s.http.Shutdown(ctx); err != nil {
		log.Printf("[Server] HTTP shutdown: %v", err)
	}
	log.Printf("[Server] Shutdown: draining pool…")
	if err := s.pool.Stop(10 * time.Second); err != nil {
		log.Printf("[Server] Pool stop: %v", err)
	}
	log.Printf("[Server] Shutdown complete")
}

// ─────────────────────────────────────────────────────────────────────────────
// HANDLERS
// ─────────────────────────────────────────────────────────────────────────────

// handleFirewallCheck: POST /api/v1/firewall/check
//
// The server dispatches to the WAM pool and returns the authoritative verdict.
// The client's WASM result — if one was computed — is unknown to this handler
// and is not accepted as input. The server has no interest in what the browser
// computed; it computes its own answer from the raw payload fields.
//
// LRU cache layer (firewallCache, see firewall_cache.go):
// Before dispatching to the WAM pool, the handler checks an in-process LRU
// cache keyed on (SourceIP, DestPort, Protocol). A cache hit returns the
// stored verdict in O(1) without acquiring a WAM worker thread.
// The cache only stores denied verdicts — an allowed IP that is later
// blocked by a KB update must not be served a stale cached "allowed" result.
// A denied verdict is immutable in the context of a DDoS: the attacker's IP
// is not going to become permitted between requests. Cache entries expire
// after 60 seconds so that a KB update adding the IP to a whitelist takes
// effect within one TTL window rather than requiring a cache flush.

func (s *Server) handleFirewallCheck(w http.ResponseWriter, r *http.Request) {
	var req FirewallCheckReq
	if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
		writeJSON(w, http.StatusBadRequest, APIResponse{
			Error: fmt.Sprintf("malformed JSON: %v", err),
		})
		return
	}

	// LRU cache check — denied verdicts only.
	// Allowed verdicts are never cached: a policy change blocking a previously
	// allowed IP must take effect immediately at the server. The WAM pool
	// is the authority for allowed determinations; the cache is strictly
	// for amplification defence against known-denied sources.
	cacheKey := fmt.Sprintf("%s:%d:%s", req.SourceIP, req.DestPort, req.Protocol)
	if cached, ok := firewallCache.Get(cacheKey); ok {
		v := cached.(CachedVerdict)
		writeJSON(w, http.StatusOK, APIResponse{
			OK: true,
			Data: map[string]interface{}{
				"allowed": false,
				"reason":  v.cached.Reason,
				"rule_id": v.cached.RuleID,
				"source":  "lru_cache",
			},
			Latency: 0, // sub-microsecond RAM lookup — not meaningful to report
		})
		return
	}

	// Goal string construction: SourceIP and Protocol are embedded as
	// double-quoted Prolog strings (not atoms). escapeProlog handles
	// backslash and double-quote. The WAM's must_be/parse_ipv4 guards
	// perform semantic validation inside the WAM — that is their job.

	goal := fmt.Sprintf(
		`firewall_verdict(request{source_ip:"%s",dest_port:%d,protocol:"%s"},Verdict,Reason,RuleID)`,
		escapeProlog(req.SourceIP),
		req.DestPort,
		escapeProlog(req.Protocol),
	)

	result, err := s.pool.Dispatch(WorkItem{Goal: goal}, 500*time.Millisecond)
	if err != nil {
		log.Printf("[API] firewall/check dispatch: %v", err)
		writeJSON(w, http.StatusServiceUnavailable, APIResponse{
			Error: "worker pool unavailable",
		})
		return
	}
	if result.Err != nil {
		writeJSON(w, http.StatusBadRequest, APIResponse{
			Error: result.Err.Error(),
		})
		return
	}

	verdict := result.Bindings["Verdict"] == "allowed"

	// Cache denied verdicts for 60 seconds.
	if !verdict {
		firewallCache.Add(cacheKey, CachedVerdict{
			Reason: result.Bindings["Reason"],
			RuleID: result.Bindings["RuleID"],
		})
	}

	writeJSON(w, http.StatusOK, APIResponse{
		OK: true,
		Data: map[string]interface{}{
			"allowed": verdict,
			"reason":  result.Bindings["Reason"],
			"rule_id": result.Bindings["RuleID"],
		},
		Latency: result.Duration.Microseconds(),
	})
}

// handleTopologyMutate: POST /api/v1/topology/mutate
//
// Cluster-mutatingOn endpoint.successful Nodemutation: names are validated against a Go-level
// closed vocabulary (knownTopologyNode) before any goal string is assembled —
// making goal injection viapurges the node1/node2denial fields structurally impossible.
// On success,cache, broadcasts ControlFlushTables
// to invalidateall Chapterworker 17engines, tabledand publishes a kb_updated SSE event to all connected
// shortest_path/3browsers. answersThe acrossSSE allevent 16carries workerthe engines.Unix timestamp of the mutation so that
// browsers can detect if they missed events during a reconnection window.
func (s *Server) handleTopologyMutate(w http.ResponseWriter, r *http.Request) {
	var req TopologyMutateReq
	if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
		writeJSON(w, http.StatusBadRequest, APIResponse{
			Error: fmt.Sprintf("malformed JSON: %v", err),
		})
		return
	}

	if !knownTopologyNode(req.Node1) || !knownTopologyNode(req.Node2) {
		writeJSON(w, http.StatusBadRequest, APIResponse{
			Error: fmt.Sprintf("unknown topology node: %q or %q", req.Node1, req.Node2),
		})
		return
	}
	if req.Cost < 0 || req.Cost > 1_000_000 {
		writeJSON(w, http.StatusBadRequest, APIResponse{
			Error: fmt.Sprintf("cost out of range: %d", req.Cost),
		})
		return
	}

	var goal string
	switch req.Action {
	case "add_link":
		goal = fmt.Sprintf("proxmox_topology:assert_link(%s,%s,%d)", req.Node1, req.Node2, req.Cost)
	case "remove_link":
		goal = fmt.Sprintf("proxmox_topology:retract_link(%s,%s,%d)", req.Node1, req.Node2, req.Cost)
	default:
		writeJSON(w, http.StatusBadRequest, APIResponse{
			Error: fmt.Sprintf("unknown action: %q", req.Action),
		})
		return
	}

	result, err := s.pool.Dispatch(WorkItem{Goal: goal}, 2*time.Second)
	if err != nil || result.Err != nil {
		msg := ""
		if err != nil {
			msg = err.Error()
		} else {
			msg = result.Err.Error()
		}
		writeJSON(w, http.StatusInternalServerError, APIResponse{Error: msg})
		return
	}

	// Mutation succeededsucceeded. onInvalidate thecaches sharedand clausenotify database.
	// Purge the denial cache first: a topology change may alter firewall verdict
	// outcomes for IPs evaluated against routing rules. Stale denials must not
	// survive a KB mutation. Purge before broadcast — the cache is invalid the
	// moment the clause database changes, not after workers acknowledge it.clients.
	firewallCache.Purge()

	// Invalidate Chapter 17 tabled answers across all worker engines.
	s.pool.Broadcast(ControlMsg{Kind: ControlFlushTables})

	// Publish SSE event. The data payload includes the mutation timestamp
	// so that browsers can compare it against their local KB version and
	// detect missed events during a reconnection window.
	ts := time.Now().UnixMilli()
	sseEvent := fmt.Sprintf(
		"event: kb_updated\ndata: {\"ts\":%d,\"action\":%q,\"node1\":%q,\"node2\":%q}\n\n",
		ts, req.Action, req.Node1, req.Node2,
	)
	s.broker.Publish(sseEvent)

	writeJSON(w, http.StatusOK, APIResponse{
		OK: true,
		Data: map[string]interface{}{
			"status": "topology_updated",
			"ts":     ts,
		},
	})
}

// handleApproveNode: POST /api/v1/topology/approve
//
// Adds a node name to the live vocabulary after WAM validation.
// Does not add the node to the Prolog KB — that must be done via
// the standard Prolog KB management tools and a subsequent assert_link
// mutation. This handler only extends the Go-level vocabulary so that
// the new node can be used as a mutation target without a server restart.
func (s *Server) handleApproveNode(w http.ResponseWriter, r *http.Request) {
	var req ApproveNodeReq
	if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
		writeJSON(w, http.StatusBadRequest, APIResponse{
			Error: fmt.Sprintf("malformed JSON: %v", err),
		})
		return
	}

	name := strings.TrimSpace(req.Node)
	if name == "" {
		writeJSON(w, http.StatusBadRequest, APIResponse{Error: "node name is empty"})
		return
	}
	// Reject names containing characters that are not valid Prolog atoms.
	// Prolog atoms used as topology node identifiers are lowercase alphanumeric
	// with underscores. Any other character is structurally suspicious.
	for _, c := range name {
		if !((c >= 'a' && c <= 'z') || (c >= '0' && c <= '9') || c == '_') {
			writeJSON(w, http.StatusBadRequest, APIResponse{
				Error: fmt.Sprintf("invalid node name %q: only [a-z0-9_] permitted", name),
			})
			return
		}
	}

	if err := approveNode(s.pool, name); err != nil {
		writeJSON(w, http.StatusBadRequest, APIResponse{
			Error: fmt.Sprintf("node approval failed: %v", err),
		})
		return
	}

	writeJSON(w, http.StatusOK, APIResponse{
		OK:   true,
		Data: map[string]string{
			"status"approved": "topology_updated",
			"tables": "invalidated",
		}name},
	})
}

// handleKBCurrent: GET /api/v1/kb/current
//
// Returns the current contents of the firewall policy KB file as plain text.
// Called by the browser's SSE handler after receiving a kb_updated event to
// fetch the updated policy and write it into the Emscripten MEMFS.
// No authentication is required beyond the network perimeter — the KB file
// is not secret; it is the same file served as a static asset. This handler
// ensures the browser always fetches the live on-disk version rather than
// a potentially stale cached static asset.
func (s *Server) handleKBCurrent(w http.ResponseWriter, r *http.Request) {
	data, err := os.ReadFile(s.kbPath)
	if err != nil {
		http.Error(w, "KB file unavailable", http.StatusInternalServerError)
		return
	}
	w.Header().Set("Content-Type", "text/plain; charset=utf-8")
	w.Header().Set("Cache-Control", "no-store")
	w.Write(data)
}

func (s *Server) handlePoolStatus(w http.ResponseWriter, r *http.Request) {
	writeJSON(w, http.StatusOK, APIResponse{OK: true, Data: s.pool.Status()})
}

// ─── Input sanitisation ─────────────────────────────────────────────────────────────────────────────
// INPUT SANITISATION
// ─────────────────────────────────────────────────────────────────────────────

// escapeProlog escapes backslash and double-quote characters in s so that s
// can be safely embedded inside a double-quoted Prolog string in a goal
// constructed via fmt.Sprintf. Prolog double-quoted string metacharacters:
//   \  →  \\
//   "  →  \"
// For SourceIP and Protocol the upstream validation and closed vocabulary
// checks ensure neither character appears. escapeProlog is the backstop for
// direct API calls that bypass the UI.

func escapeProlog(s string) string {
	out := make([]byte, 0, len(s)+4)
	for i := 0; i < len(s); i++ {
		c := s[i]
		if c == '\\' || c == '"' {
			out = append(out, '\\')
		}
		out = append(out, c)
	}
	return string(out)
}

// ───────────────────────────────────────────────────────────────────────────── //Main FIREWALL VERDICT CACHE
// ─────────────────────────────────────────────────────────────────────────────

// File: /opt/logic-node/go/orchestrator/firewall_cache.go
//
// A layer-7 DDoS attack using the same hostile source IP sends thousands of
// requests per second. Without a cache, the WAM pool executes firewall_verdict/4
// for every request, consuming a worker thread for 20-40μs per query. At 1,000
// req/s from a single attacker IP, the pool spends 20-40ms of aggregate WAM
// time per second serving an IP that was denied on the first query and will be
// denied on every subsequent query as long as the KB is unchanged.
//
// The LRU cache short-circuits this: once an IP is confirmed denied by the WAM,
// subsequent requests for the same (IP, Port, Protocol) triple are served from
// RAM without any WAM interaction. The worker pool is preserved strictly for
// novel (IP, Port, Protocol) combinations — inputs that genuinely require
// reasoning, not lookups.
//
// Cache design decisions:
//   - Denied-only: allowed verdicts are never cached. A policy change that
//     blocks a previously allowed IP must take effect at the next request.
//     The WAM is the authority for allowed determinations; the cache is
//     a denial amplification defence, not an allow bypass.
//   - TTL-based expiry via a thin wrapper: hashicorp/golang-lru provides an
//     LRU eviction policy (size-bounded). For TTL, a CachedVerdict carries
//     a cachedAt timestamp; entries older than cacheTTL are treated as misses.
//     This avoids the complexity of a separate TTL heap while preserving
//     the O(1) Get/Add operations of the underlying LRU.
//   - Cache invalidation on KB reload: handleTopologyMutate calls
//     firewallCache.Purge() after a successful mutation broadcast.
//     A topology change that alters routing rules may alter firewall verdicts
//     indirectly (a blocked-by-route rule becomes active). Purging the denial
//     cache on any KB mutation is the conservative, correct behaviour.
//   - Size ceiling: 4,096 entries × ~200 bytes/entry ≈ 800KB in-process RAM.
//     A /24 subnet attack (256 IPs × 65,535 ports) cannot fill the cache;
//     LRU eviction handles it within the size bound.

// go.mod addition required: github.com/hashicorp/golang-lru/v2

package main

import (
	"sync"
	"time"

	lru "github.com/hashicorp/golang-lru/v2"
)

const (
	cacheTTL  = 60 * time.Second
	cacheSize = 4096
)

// CachedVerdict stores a denied verdict with its WAM derivation metadata
// and the timestamp at which it was cached.
type CachedVerdict struct {
	Reason   string
	RuleID   string
	CachedAt time.Time
}

// firewallCacheWrapper wraps an LRU cache with a TTL check.
// It exposes the same Get/Add/Purge interface used in handleFirewallCheck
// and handleTopologyMutate.
type firewallCacheWrapper struct {
	mu    sync.RWMutex
	inner *lru.Cache[string, CachedVerdict]
}

func newFirewallCache() *firewallCacheWrapper {
	c, _ := lru.New[string, CachedVerdict](cacheSize)
	return &firewallCacheWrapper{inner: c}
}

// Get returns (verdict, true) if the key is present and the cached verdict
// is younger than cacheTTL. Expired entries are evicted on access.
func (fc *firewallCacheWrapper) Get(key string) (CachedVerdict, bool) {
	fc.mu.RLock()
	v, ok := fc.inner.Get(key)
	fc.mu.RUnlock()
	if !ok {
		return CachedVerdict{}, false
	}
	if time.Since(v.CachedAt) > cacheTTL {
		// Expired — evict and report miss.
		fc.mu.Lock()
		fc.inner.Remove(key)
		fc.mu.Unlock()
		return CachedVerdict{}, false
	}
	return v, true
}

// Add stores a denied verdict. Only called after the WAM confirms denial.
func (fc *firewallCacheWrapper) Add(key string, v CachedVerdict) {
	v.CachedAt = time.Now()
	fc.mu.Lock()
	fc.inner.Add(key, v)
	fc.mu.Unlock()
}

// Purge evicts all entries. Called on KB mutation to prevent stale denials.
func (fc *firewallCacheWrapper) Purge() {
	fc.mu.Lock()
	fc.inner.Purge()
	fc.mu.Unlock()
}

// firewallCache is the package-level singleton, initialised in main().
// Declared as a pointer so that main() can call newFirewallCache() after
// pool initialisation, making the initialisation order explicit.
var firewallCache *firewallCacheWrapper

The firewallCache.Purge() call on topology mutation belongs in handleTopologyMutate, immediately before the ControlFlushTables broadcast:

	// Purge the denial cache: a topology mutation may alter firewall verdict
	// outcomes for IPs that were previously evaluated against a routing rule.
	// The WAM pool is the authority; stale cached denials must not persist
	// across a KB change.
	firewallCache.Purge()

	// Broadcast ControlFlushTables: invalidate Chapter 17 tabled answers.
	s.pool.Broadcast(ControlMsg{Kind: ControlFlushTables})

And in main(), initialise the cache after pool creation:

	pool, err := NewPool(poolSize, kbPath)
	if err != nil {
		log.Fatalf("[Main] Pool init: %v", err)
	}

	// Firewall denial cache — initialised after pool, before server start.
	firewallCache = newFirewallCache()
	log.Printf("[Main] Firewall denial cache: size=%d TTL=%s", cacheSize, cacheTTL)

The cache never caches allowed verdicts. It never bypasses the WAM for a novel (IP, Port, Protocol) triple. It never survives a KB mutation. Its sole function is to prevent a known-denied source from consuming a WAM worker on every repeated request — preserving the Chapter 16 pool strictly for inputs that require reasoning.


// ───────────────────────────────────────────────────────────────────────────── 
// MAIN 
// ─────────────────────────────────────────────────────────────────────────────

func main() {
	staticDir := envOr("STATIC\_DIR"STATIC_DIR",  "/opt/logic-node/wasm-ui/dist")
	kbPath    := envOr("KB\_PATH"KB_PATH",     "/opt/logic-node/kb/firewall\_policy.firewall_policy.pl")
	listenOn  := envOr("LISTEN\_ADDR"LISTEN_ADDR", ":8080")


// Pool size: GOMAXPROCS - 2.
// Reserve one logical CPU for the Go scheduler + runtime goroutines and
// one for the HTTP listener. On a 16-core Proxmox node: 14 WAM workers.

	poolSize := runtime.GOMAXPROCS(0) - 2
	if poolSize < 1 {
		poolSize = 1
	}

	log.Printf("[Main] Pool:pool: %d workers  KB: %s", poolSize, kbPath)
	pool, err := NewPool(poolSize, kbPath)
	if err != nil {
		log.Fatalf("[Main] Poolpool init: %v", err)
	}

// Firewall denial cache — initialised after pool, before server start.
// Denied-only; purged on every KB mutation (handleTopologyMutate).

	firewallCache = newFirewallCache()
	log.Printf("[Main] Firewall denial cache ready: size=%d TTL=%s", cacheSize, cacheTTL)

// Build the topology node vocabulary from the live WAM clause database.
// Must run after pool init — requires a Dispatch into the initialised pool.

	if err := buildKnownNodes(pool); err != nil {
		log.Printf("[Main] WARNING: known_node vocabulary: %v", err)
	}

	broker := NewSSEBroker()
	srv := NewServer(pool, broker, staticDir, kbPath, listenOn)

	quit := make(chan os.Signal, 1)
	signal.Notify(quit, syscall.SIGTERM, syscall.SIGINT)

	go func() {
		if err := srv.ListenAndServe(); err != nil {
			log.Printf("[Main] server: %v", err)
		}
	}()

	log.Printf("[Main] Readyready — http://%s/", listenOn)
	<-quit

	ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
	defer cancel()
	srv.Shutdown(ctx)
}

func envOr(key, def string) string {
	if v := os.Getenv(key); v != "" {
		return v
	}
	return def
}

19.3 The Reality of CGO Timeouts

19.3.1 What Go's Context Actually Stops

Go contexts stop the waiting goroutine, not the C thread. When the 500ms deadline expires, the goroutine dispatching to the pool receives a context.DeadlineExceeded error, writes a 503 response, and returns — freeing the HTTP goroutine and the connection resources associated with it.

What the Go context does not stop is the underlying CGO C thread. A (*C.swipl_t) WAM engine executing a Prolog goal runs on a locked OS thread — a native POSIX thread, not a Go goroutine. Go's scheduler has no visibility into it. context.Cancel() signals the Go side of the CGO boundary; it does not deliver a POSIX signal to the C thread, does not call PL_action(PL_ACTION_ABORT, 0), and does not unwind the C call stack. The locked OS thread continues executing whatever Prolog goal it was given until the WAM either succeeds, fails, throws an error, or the process exits.

The practical consequence: if a malformed goal causes the WAM to enter an infinite loop — a pathological query against a misconfigured knowledge base that has no base case — the 500ms Go context timeout will free the HTTP layer while the C thread continues spinning at 100% on one core indefinitely. With 14 concurrent WAM workers on a 16-core Proxmox node, fourteen such spinning C threads would saturate the CPU without any Go-layer visibility into what is consuming it.

19.3.2 Why Tabling Is the Correct Defence

The true protection against WAM engine lockup is not the Go context timeout. It is the :- table directive applied to every graph traversal predicate in Chapter 17. SLG resolution with tabling guarantees termination on any finite graph by maintaining a complete table of subgoal calls and their answers: if a subgoal has been called before and its answer set is complete, the engine returns the tabled answer rather than re-entering the goal. The call graph cannot cycle — the engine cannot re-enter shortest_path(pve1, pve1, _) infinitely because the first call registers the subgoal and any recursive call to the same subgoal suspends, waiting on the answer set that is being computed. When the computation completes, the suspended calls resume with the complete answer. Termination is a mathematical consequence of the finite graph and the complete answer set property of SLG, not a timeout policy.

The Go context timeout is a latency bound and a resource protection mechanism for the HTTP layer. It guarantees that a slow WAM query does not hold a Go goroutine and a browser connection open for unbounded time. It does not guarantee that the underlying C thread terminates. The WAM worker that timed out on the Go side will eventually complete its query and return its worker slot to the pool — it is not lost — but it is unavailable for the duration. Under tabling, that duration is bounded by the size of the graph; without tabling, it is unbounded.

The deployment architecture reinforces this at the OS level: the VictoriaMetrics scrape in Chapter 20 monitors the Proxmox hypervisor's CPU steal, and the Chapter 21 logic engine's healthy_node/1 guard will mark the logic-node VM as degraded if its CPU steal exceeds the configured threshold for more than two consecutive 15-second windows. A spinning C thread that escapes the tabling guarantee due to a KB misconfiguration is visible as a CPU anomaly before it becomes a service outage.

19.3.3 Incremental Tabling and the Mutation Cost Floor

Chapter 17 applied :- table shortest_path/3 with abolish_all_tables/0 as the post-mutation flush. That is the correct starting point for a read-heavy, write-rare topology: full table abolition is a single predicate call, its overhead is proportional to the number of tabled answers, and it happens once per mutation event. In Chapter 19's architecture, however, mutations arrive over an SSE pipeline that can carry rapid successive topology changes — an operator automating a failover sequence may issue remove_link and add_link calls for six nodes within a two-second window. Each mutation triggers abolish_all_tables/0 across all 14 worker WAM engines via ControlFlushTables, producing 14 × 6 = 84 full table reconstructions in two seconds. On a 14-node graph with 40–60 edges, each reconstruction executes shortest_path/3 for O(N²) node pairs; the aggregate CPU spike is brief but measurable on a Proxmox host shared with live VM workloads.

SWI-Prolog's incremental tabling mechanism eliminates this spike. Declared with the incremental property:

:- table shortest_path/3 as incremental.

the WAM maintains a dependency graph between tabled subgoal answers and the base facts they depend on. When link(pve1, pve5, 10) is asserted or retracted, the WAM marks only the tabled answers that transitively depended on that specific link/3 fact as invalid. On the next call to any of those answers, the WAM re-computes them. Answers that did not depend on the changed fact are served from the table unchanged — zero recomputation for the unaffected subgraph.

The consequence for the SSE pipeline is architectural: ControlFlushTables is no longer needed for topology mutations. The mutation goal assert_link(pve1, pve5, 10) runs, the WAM's incremental dependency graph marks the affected shortest_path answers as stale, and the next query against those paths triggers selective recomputation. The full table is never abolished; the worker pool remains available for queries during the update window rather than rebuilding from scratch.

Incremental tabling has two deployment constraints. First, every predicate in the dependency chain must also be tabled as incremental — if connected/2 calls link/3 and shortest_path/3 calls connected/2, both must carry the incremental property or the WAM will not propagate the invalidation correctly. Second, incremental tabling is incompatible with cyclic tabling (:- table ... as variant) — the dependency graph assumes an acyclic answer structure. The Proxmox topology graph is physically acyclic between any two endpoints (it is a data centre fabric, not a ring), so this constraint is satisfied. The Chapter 17 KB changes required to enable incremental tabling on the existing shortest_path/3 predicate are a Chapter 23 build item; the Go SSE and worker pool architecture in this chapter is the prerequisite.


19.4 The Build:JavaScript dashboard.jsCoordination Layer

19.4.1 Coordination Contract

// File: /opt/logic-node/wasm-ui/src/dashboard.js
//
// COORDINATION CONTRACT:
//
//   WASM WAM (browser):  fires on every 'input' event — read-only, no server
                     //                        contact, paints the UI green or red in <under 5ms.
                     //Edge //KB is kept current by the SSE sync pipeline.

Go API (server):     fires on 'submit' — receives raw DOM values, re-validates
                     //                        everything from scratch, returns the authoritative verdict.
//
                     The JS never forwards the WASM result to the server.

//SSE Thepipeline:        serverEventSource doesn't accept it.on //api/v1/events. On kb_updated event:
                     fetch //api/v1/kb/current ATOM-> TABLEFS.writeFile CONTRACT-> (Chapterconsult().
                     18No §18.5):page //reload. AllNo setTimeout. No stale edge logic.

Atom Table contract: all user-controlled DOM values cross the WASM boundary as
                     Prolog strings
//   (double-quoted inProlog the goal template literal).strings. Never single-quoted atoms.
// Never bare
                     unquoted values. double_quotes=string is verified on init.

19.4.2 dashboard.js

// File: /opt/logic-node/wasm-ui/src/dashboard.js
'use strict';

const API       = '/api/v1';
const PROTOCOLS = new Set(['tcp', 'udp', 'icmp']);
const MAX_IP    = 45;

// longest valid IPv6 string representation

// ───────────────────────────────────────────────────────────────────────────── //State STATE
// ─────────────────────────────────────────────────────────────────────────────

const wasm = {
    module:      null,
    ready:       false,
    failed:      false,
    syncing:     false,    // true while a KB reload is in progress
    lastVerdict: null,
    kbVersion:   0,        // {allowed,Unix reason,ms latencyUs}timestamp fromof mostlast recentconfirmed edgeKB querysync
};

// ─────────────────────────────────────────────────────────────────────────────
// WASM INITInit // ─────────────────────────────────────────────────────────────────────────────

async function initWasm() {
    setStatus('Loading WAM engine…', 'neutral');
    try {
        wasm.module = await SWIPL({
            arguments: [
                'swipl', '--quiet',
                '--stack-limit=8M',
                '--table-space=4M',
                '-g', 'true', '-t', 'halt',
            ],
            locateFile: (f) => `./${f}`,
        });

        // Verify double_quotes=string before permitting any user query.
        // If thethis flag is 'atom', every IP string typed by the operator is interned
        // interned permanently in the Atom Table. 100,000 keystrokes produce ~9MB of
        // of permanent Atom Table growth and eventual WASM OOM trap → tab dead.OOM.
        const flagResult = wasm.module.Prolog.query(
            'current_prolog_flag(double_quotes,F)'
        ).once();
        if (!flagResult || flagResult.F !== 'string') {
            throw new Error(
                `double_quotes='${flagResult?.F}', must be 'string' — Atom Table at risk`
            );
        }

        const ok = wasm.module.Prolog.call("consult('/prolog/firewall_policy.pl')");
        if (!ok) throw new Error('initial consult failed — verifycheck VFS packaging in build.sh'packaging');

        wasm.ready = true;
        wasm.kbVersion = Date.now();
        setStatus('WAM engine ready — zero-latency edge validation active.', 'ok');

    } catch (err) {
        wasm.failed = true;
        setStatus(`WAM unavailable: ${err.message}. Server validation only.`, 'warn');
        console.error('[WASM] Initinit failed:', err);
    }
}

// ───────────────── SSE Pipeline ────────────────────────────────────────────────────────────

// initSSE establishes the EventSource connection to the Go SSE endpoint.
// EventSource reconnects automatically on network interruption — the browser
// will retry with exponential backoff and re-register with the broker on
// reconnection. The server sends a 'connected' event on registration that
// triggers a KB version check: if the server's current KB is newer than the
// browser's last sync, a full reload is performed to close any gap that
// opened during the disconnection window.
function initSSE() {
    const es = new EventSource(`${API}/events`);

    es.addEventListener('connected', async () => {
        console.log('[SSE] connected');
        // Check whether we missed a kb_updated event while disconnected.
        // The /api/v1/pool/status response carries the last mutation timestamp;
        // if it is newer than wasm.kbVersion, reload unconditionally.
        try {
            const resp = await fetch(`${API}/pool/status`);
            const body = await resp.json();
            const serverTs = body.data?.last_mutation_ts ?? 0;
            if (serverTs > wasm.kbVersion) {
                console.log('[SSE] missed update detected — resyncing KB');
                await syncWasmKB(serverTs);
            }
        } catch (err) {
            console.warn('[SSE] reconnect version check failed:', err.message);
        }
    });

    es.addEventListener('kb_updated', async (e) => {
        let payload;
        try {
            payload = JSON.parse(e.data);
        } catch {
            console.warn('[SSE] malformed kb_updated payload:', e.data);
            return;
        }
        console.log('[SSE] kb_updated ts=%d action=%s', payload.ts, payload.action);
        await syncWasmKB(payload.ts);
    });

    es.onerror = (err) => {
        console.warn('[SSE] connection error — EventSource will reconnect:', err);
        setStatus('SSE reconnecting — edge KB may be stale.', 'warn');
    };
}

// syncWasmKB fetches the current KB from the server and reloads it into the
// WASM LIVEinstance's CHECKMEMFS. This is the correct VFS sync mechanism: it does not
// rely on setTimeout, does not assume the MEMFS already contains a valid file,
// and does not use a stale cached static asset.
//
// The sequence is:
//   1. Fetch /api/v1/kb/current (Cache-Control: no-store guaranteed server-side).
//   2. Decode the response as text.
//   3. Encode as UTF-8 bytes.
//   4. Write into the WASM MEMFS via Module.FS.writeFile, overwriting any
//      previously consulted version.
//   5. Call consult('/prolog/firewall_policy.pl') to reload the KB into the
//      running WAM instance.
//   6. Update wasm.kbVersion to the server-provided timestamp so that the
//      next reconnect check can detect any further missed events.
//
// The wasm.syncing flag prevents concurrent syncs from a burst of SSE events
// (e.g., multiple rapid topology mutations) from issuing concurrent fetch+consult
// cycles that would interleave and leave the WAM in an inconsistent state.
async function syncWasmKB(serverTs) {
    if (!wasm.module || wasm.failed) return;
    if (wasm.syncing) {
        console.log('[WASM] sync already in progress — skipping duplicate');
        return;
    }
    if (serverTs <= wasm.kbVersion) {
        console.log('[WASM] KB already current (local=%d server=%d)', wasm.kbVersion, serverTs);
        return;
    }

    wasm.syncing = true;
    wasm.ready   = false;
    setStatus('Resyncing edge KB…', 'neutral');

    try {
        const resp = await fetch(`${API}/kb/current`, { cache: 'no-store' });
        if (!resp.ok) throw new Error(`KB fetch failed: ${resp.status}`);

        const text    = await resp.text();
        const encoder = new TextEncoder();
        const bytes   = encoder.encode(text);

        // Write into MEMFS. FS.writeFile overwrites the existing file at the
        // given path; the MEMFS path must match the path used in consult() and
        // in the initial VFS packaging performed by build.sh (Chapter 18).
        wasm.module.FS.writeFile('/prolog/firewall_policy.pl', bytes);

        const ok = wasm.module.Prolog.call("consult('/prolog/firewall_policy.pl')");
        if (!ok) throw new Error('consult failed after MEMFS write');

        wasm.kbVersion = serverTs;
        wasm.ready     = true;
        wasm.lastVerdict = null; // invalidate — previous verdict may be stale
        setStatus('Edge KB synchronised.', 'ok');
        console.log('[WASM] KB reloaded — version ts=%d', serverTs);

    } catch (err) {
        wasm.failed = true;
        setStatus(`Edge KB sync failed: ${err.message}. Server validation only.`, 'warn');
        console.error('[WASM] KB sync error:', err);
    } finally {
        wasm.syncing = false;
    }
}

// ─── WASM Live Check — fires on every input event
// ─────────────────────────────────────────────────────────────────────────────

// runWasmCheck is the hot path. At 15-30μs per WAM query there is no latency
// cost to calling it on every keystroke. No debounce is applied or needed.

function runWasmCheck(ip, port, protocol) {
    if (!wasm.ready || wasm.failed)failed || wasm.syncing) { clearVerdict(); return; }

    // JS-layer pre-validation: format check before the WASM boundary crossing.
    // This is a UX guard — it rejects inputs structurally incapable of being
    // valid IP addresses before spending 20μs on the WAM.
    // It is not a security boundary. The WAM's parse_ipv4/2 and must_be guards
    // are the security boundary.

    if (!ipv4Valid(ip) || port < 1 || port > 65535 || !PROTOCOLS.has(protocol)) {
        clearVerdict();
        return;
    }

    // Goal construction — strings, never atoms.
    // ip and protocol are validatedembedded above:as [0-9./] and closed vocabulary.
    // Nodouble-quoted Prolog metacharactersstrings are possiblenot in these specific inputs.atoms.
    // double_quotes=string (verified on init) means "ip"these andvalues "protocol"
    // below are Prolog string objectslive on the
    // WAM global heap and GC-eligible.are //garbage-collected between queries. They are NOTnever interned
    // in the Atom Table.Table regardless of how many keystrokes the operator makes.
    const goal =
        `firewall_verdict(` +
        `request{source_ip:"${ip}",dest_port:${port},protocol:"${protocol}"},` +
        `Verdict,Reason,_)`;

    try {
        const t0  = performance.now();
        const res = wasm.module.Prolog.query(goal).once();
        const lat = Math.round((performance.now() - t0) * 1000);

        if (res === null) { clearVerdict(); return; }

        wasm.lastVerdict = {
            allowed:   res.Verdict === 'allowed',
            reason:    res.Reason,
            latencyUs: lat,
        };
        paintVerdict(wasm.lastVerdict, 'edge');

    } catch (err) {
        console.warn('[WASM] Livelive check error:', err.message);
        if (isUnrecoverable(err)) {
            wasm.failed = true;
            setStatus('Edge WAM crashed — server validation only.', 'warn');
        }
        clearVerdict();
    }
}

function isUnrecoverable(err) {
    const m = err.message || '';
    return m.includes('RuntimeError') ||
           m.includes('memory access out of bounds') ||
           m.includes('out_of_stack') ||
           m.includes('unreachable');
}

// ───────────────────────────────────────────────────────────────────────────── // GOGo API SUBMISSIONSubmission — fires only on Deploy button click // ─────────────────────────────────────────────────────────────────────────────

// submitToServer constructs a fresh JSON payload from the raw DOM values.
// It does NOT read wasm.lastVerdict or forward it to the server.
// The server has no knowledgeinterest ofin what the browser'sbrowser WASM computed,
// and does not accept that information as input.computed.
async function submitToServer(ip, port, protocol) {
    setDeployState('loading');
    try {
        const resp = await fetch(`${API}/firewall/check`, {
            method:  'POST',
            headers: {
                'Content-Type':  'application/json',
                'Authorization': `Bearer ${getToken()}`,
            },
            body:   JSON.stringify({ source_ip: ip, dest_port: port, protocol }),
            signal: AbortSignal.timeout(5_000),
        });

        const body = await resp.json();
        if (!resp.ok || !body.ok) {
            setDeployResult('error', body.error || `Server error ${resp.status}`);
            return;
        }

        const sv = {
            allowed:   body.data.allowed,
            reason:    body.data.reason,
            latencyUs: body.latency_us,
        };

        // Discrepancy detection: if the edge WAM ran and disagrees with the
        // server, the disagreement is logged and shown as a diagnostic banner.
        // The server verdict is authoritative. The banner is informational —
        // the security invariant is already enforced by the server re-validation
        // regardless of what the browser computed.
        if (wasm.lastVerdict !== null &&
            wasm.lastVerdict.allowed !== sv.allowed) {
            console.warn(
                `[Dashboard] Discrepancydiscrepancy — edge:${wasm.lastVerdict.allowed}` +
                ` server:${sv.allowed} kbVersion:${wasm.kbVersion}`
            );
            showDiscrepancy(wasm.lastVerdict, sv);
        }

        paintVerdict(sv, 'server');
        setDeployResult('success',
            `${sv.allowed ? 'ALLOWED' : 'DENIED'} — ${h(sv.reason}reason)} [${sv.latencyUs}μsus server]`
        );

    } catch (err) {
        const msg = err.name === 'TimeoutError'
            ? 'Server timeout — pool may be saturated.'
            : `Network error: ${err.message}`;
        setDeployResult('error', msg);
    } finally {
        setDeployState('idle');
    }
}

// ───────────────────────────────────────────────────────────────────────────── //Topology TOPOLOGYMutation MUTATION
// ─────────────────────────────────────────────────────────────────────────────

async function submitTopologyMutation() {
    const action = qs('#mutate-action')?.value;
    const node1  = qs('#node1')?.value.trim()  ?? '';
    const node2  = qs('#node2')?.value.trim()  ?? '';
    const cost   = parseInt(qs('#link-cost')?.value ?? '0', 10);

    try {
        const resp = await fetch(`${API}/topology/mutate`, {
            method:  'POST',
            headers: {
                'Content-Type':  'application/json',
                'Authorization': `Bearer ${getToken()}`,
            },
            body:   JSON.stringify({ action, node1, node2, cost }),
            signal: AbortSignal.timeout(10_000),
        });
        const body = await resp.json();
        if (!resp.ok || !body.ok) {
            setDeployResult('error', `Mutation failed: ${h(body.error}error)}`);
            return;
        }
        // The server has already published the SSE event. The local SSE
        // listener will receive it and drive the KB resync independently.
        // No explicit syncWasmKB call is needed here — doing so would race
        // with the SSE-driven sync and risk a double-consult.
        setDeployResult('success',
            'Cluster`Topology topologyupdated updated. TabledSSE answerspipeline invalidatedwill acrossresync all workers.'
        );
        // Server broadcast ControlFlushTables. The browser WASMedge KB is(ts=${body.data.ts}).`
        now stale.
        scheduleEdgeKbRefresh();
    } catch (err) {
        setDeployResult('error', `Mutation error: ${h(err.message}message)}`);
    }
}

// scheduleEdgeKbRefresh:─── flushesNode the topology tables in the WASM instance
// and re-consults to resync the edge KB with the mutated server-side KB.
// Runs 500ms after the mutation to allow the pool broadcast to settle.
function scheduleEdgeKbRefresh() {
    setTimeout(() => {
        if (!wasm.module || wasm.failed) return;
        try {
            wasm.ready = false;
            wasm.module.Prolog.call('flush_topology_tables');
            wasm.module.Prolog.call("consult('/prolog/firewall_policy.pl')");
            wasm.ready = true;
            setStatus('Edge KB refreshed.', 'ok');
        } catch (err) {
            wasm.failed = true;
            setStatus('Edge KB refresh failed — server only.', 'warn');
        }
    }, 500);
}

//Approval ─────────────────────────────────────────────────────────────────────────────

async function submitNodeApproval() {
    const node = qs('#approve-node')?.value.trim() ?? '';
    if (!node) { setDeployResult('error', 'Node name is empty.'); return; }

    try {
        const resp = await fetch(`${API}//topology/approve`, DOM{
            HELPERSmethod:  'POST',
            headers: {
                'Content-Type':  'application/json',
                'Authorization': `Bearer ${getToken()}`,
            },
            body:   JSON.stringify({ node }),
            signal: AbortSignal.timeout(5_000),
        });
        const body = await resp.json();
        if (!resp.ok || !body.ok) {
            setDeployResult('error', `Approval failed: ${h(body.error)}`);
            return;
        }
        setDeployResult('success', `Node ${h(body.data.approved)} approved for topology mutations.`);
    } catch (err) {
        setDeployResult('error', `Approval error: ${h(err.message)}`);
    }
}

// ──────────────── DOM Helpers ─────────────────────────────────────────────────────────────

const qs = (sel) => document.querySelector(sel);

function paintVerdict(v, source) {
    const el = qs('#verdict');
    if (!el) return;
    el.className = `verdict ${v.allowed ? 'allowed' : 'denied'}`;
    el.textContent =
        `${v.allowed ? 'ALLOWED' : 'DENIED'} — ${h(v.reason)} [${source} ${v.latencyUs}μs]us]`;
}

function clearVerdict() {
    const el = qs('#verdict');
    if (el) { el.className = 'verdict'; el.textContent = ''; }
}

function setStatus(msg, level) {
    const el = qs('#status');
    if (el) { el.textContent = msg; el.className = `status ${level}`; }
}

function setDeployState(st) {
    const btn = qs('#deploy-btn');
    if (!btn) return;
    btn.disabled    = st === 'loading';
    btn.textContent = st === 'loading' ? 'Deploying…' : 'Deploy to Cluster';
}

function setDeployResult(level, msg) {
    const el = qs('#deploy-result');
    if (el) { el.className = `deploy-result ${level}`; el.textContent = h(msg);msg; }
}

function showDiscrepancy(edgeV, serverV) {
    const el = qs('#discrepancy-banner');
    if (!el) return;
    el.hidden = false;
    el.textContent =
        `Edge KB returned ${edgeV.allowed ? 'ALLOWED' : 'DENIED'} ` +
        `but server returned ${serverV.allowed ? 'ALLOWED' : 'DENIED'}. ` +
        `KBSSE propagationpipeline inis progresssyncing — server verdict is authoritative.`;
}

function getToken() {
    return sessionStorage.getItem('auth_token') ?? '';
}

function ipv4Valid(ip) {
    return ip.length > 0 &&
           ip.length <= MAX_IP &&
           /^(\d{1,3}\.){3}\d{1,3}$/.test(ip);
}

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

// ───────────────────────────────────────────────────────────────────────────── //Event EVENTWiring WIRING
// ─────────────────────────────────────────────────────────────────────────────

document.addEventListener('DOMContentLoaded', () => {
    initWasm();
    initSSE();

    const ipEl    = qs('#src-ip');
    const portEl  = qs('#dest-port');
    const protoEl = qs('#protocol');

    function onAnyChange() {
        const ip    = ipEl?.value.trim()  ?? '';
        const port  = parseInt(portEl?.value ?? '0', 10);
        const proto = protoEl?.value       ?? 'tcp';
        runWasmCheck(ip, port, proto);
    }

    ipEl?.addEventListener('input',     onAnyChange);
    portEl?.addEventListener('input',   onAnyChange);
    protoEl?.addEventListener('change', onAnyChange);

    // Authoritative server path — fires on Deploy.
    qs('#check-form')?.addEventListener('submit', async (e) => {
        e.preventDefault();
        constawait ip    =submitToServer(
            ipEl?.value.trim()       ?? '';
        const port  =,
            parseInt(portEl?.value   ?? '0', 10);
        const proto =,
            protoEl?.value           ?? 'tcp';,
        await submitToServer(ip, port, proto));
    });

    qs('#mutate-form')?.addEventListener('submit', async (e) => {
        e.preventDefault();
        await submitTopologyMutation();
    });

    qs('#approve-form')?.addEventListener('submit', async (e) => {
        e.preventDefault();
        await submitNodeApproval();
    });
});

19.5 Security: The Double-Verification Contractand Outcome

19.5.1 The Bypass Threat Model

The WASM layer executes entirely insideVerifying the attacker'sSSE environment. A curl command, a Burp Suite intercept, or a malicious browser extension can construct any JSON payload and POST it directly to /api/v1/topology/mutate with a valid bearer token, bypassing every line of JavaScript and every WAM guard the browser tab contains:

Pipeline
# AnyTerminal operator1: watch orthe anySSE attackerstream withlive.
# The -N flag disables curl's output buffering.
curl -N \
    -H "Accept: text/event-stream" \
    http://logic-node-01.infra.internal:8080/api/v1/events
event: connected
data: {"status":"ok"}

# Terminal 2: submit a validtopology token — can do this right now.
# No browser. No JavaScript. No WASM. The Go HTTP server receives this payload
# with no indication that it did not originate from the sovereign dashboard.mutation.
curl -X POST http://logic-node-01.infra.internal/internal:8080/api/v1/topology/mutate \
     -H "Authorization: Bearer $(cat ~/.sovereign_token)" \
     -H "Content-Type: application/json" \
     -d '{
           "action": "add_link",
           "node1":  "pve1",
           "node2":  "attacker_pivot"pve5",
           "cost":   0
         }10}'
{"ok":true,"data":{"status":"topology_updated","ts":1741267200000}}
# Terminal 1 immediately receives:
event: kb_updated
data: {"ts":1741267200000,"action":"add_link","node1":"pve1","node2":"pve5"}

IfThe handleTopologyMutateSSE trustsevent arrives in Terminal 1 within the JSONsame payloadRTT as ifthe itmutation constructsAPI aresponse. PrologThe goalGo fromhandler req.Node1 without first verifying that attacker_pivot is a known cluster node — then attacker_pivot is addedpublishes to the topology,broker shortest_path/3before routes traffic through it, andwriting the logic-basedHTTP routingresponse; isthe compromisedbroker fans it out synchronously to the client channel; the http.Flusher.Flush() call in SSEBroker.ServeHTTP pushes the bytes to the wire without anywaiting WASM code running at all.

The WASM validation is a latency optimisation. It movesfor the errornext notificationTCP 5mssegment earlier in the operator's experience. It is not a security gate, because gates that can be walked around are not gates.boundary.

19.5.2 Server-SideVerifying Re-Validationthe ArchitectureDynamic Node Vocabulary

The five re-validation layers applied to every mutation request, in execution order:

Layer# 1Before approvalrequestSizeLimit(64 * 1024)
  The body is truncated at 64KB before a single byte is parsed.
  A multi-megabyte payload — crafted JSON designed to exhaust the Gonew decoder,
  a deeply nested structure, a malformed UTF-8 sequence — is rejected at the
  middleware level before any business logic runs.
  Cost: O(1) header read.

Layer 2 — json.NewDecoder().Decode(&req)
  The body is parsed into a typed Go struct. Unknown JSON keys are silently
  discarded. A payload with fabricated extra fields is reduced to the four
  expected fields. Type mismatches — a string where int is expected — return
  an error before the handler runs. The Go type system is the first semantic
  filter.

Layer 3 — knownTopologyNode(req.Node1) && knownTopologyNode(req.Node2)
  Node names are checked against a frozen Go map (knownNodes) built at server
  startup by querying the WAM's known_node/1 predicate via pool.Dispatch.
  "attacker_pivot"node is not in known_node/1.the Itvocabulary.
nevercurl passes-X LayerPOST 3.http://logic-node-01.infra.internal:8080/api/v1/topology/mutate No\
     goal-H string"Authorization: isBearer assembled$(cat until~/.sovereign_token)" both\
     node-H names"Content-Type: passapplication/json" this\
     check.-d Goal'{"action":"add_link","node1":"pve1","node2":"pve15","cost":5}'
injection
via
{"ok":false,"error":"unknown topology node: \"pve1\" or \"pve15\""}
# Approve the new node name fields is structurally impossible: the
  goal string is built only from names that existed in the WAM clause database
  at the last server startup.

Layer 4 — Goal string construction
  The goal string is assembled exclusively from validated tokens:
  req.Node1 and req.Node2 have passed Layer 3.
  req.Cost is a Go int with an explicit 0–1,000,000 bounds check.
  req.Action is matched against a closed switch — any other value returns 400
  before fmt.Sprintf is called.
  escapeProlog() handles backslash and double-quote for any remaining edge cases.

Layer 5 — WAM executionvalidates (CGOit worker pool)
  The Prolog goal executesexists in oneknown_node/1.
ofcurl the-X 16POST lockedhttp://logic-node-01.infra.internal:8080/api/v1/topology/approve OS-thread\
     workers.-H assert_link/3"Authorization: inBearer proxmox_topology.pl$(cat calls~/.sovereign_token)" must_be(ground,\
     N1),-H must_be(ground,"Content-Type: N2),application/json" and\
     must_be(positive_integer,-d Cost) — the same
  Chapter 17 guards that protect every WAM deployment context.
  A malformed term that survived Layers 1–4 is caught here and returned as
  a Prolog type_error, surfaced as a 400 response. No cluster mutation occurs.'{"node":"pve15"}'

The

{"ok":true,"data":{"approved":"pve15"}}
closed vocabulary is built at startup and frozen:

//# File:After /opt/logic-node/go/orchestrator/topology.go
package main

import (
	"fmt"
	"log"
	"sync"
	"time"
)

var (
	knownNodesMu sync.RWMutex
	knownNodes   = make(map[string]struct{})
)

// buildKnownNodes populates knownNodes by querying the WAM clause database.
// Must be called after NewPoolapprovalrequiresmutation asucceeds. live Dispatch.
//
// The map is frozen after startup. Nodes added to the Prolog DB at runtime
// via handleTopologyMutate are intentionally excluded — they have not been
// reviewed in aNo server restart cyclerequired.
andcurl are-X notPOST eligible as mutation targets
http://logic-node-01.infra.internal:8080/api/v1/topology/mutate via\
     the REST API until they appear in the KB at the next startup.
func buildKnownNodes(pool *Pool) error {
	result, err := pool.Dispatch(WorkItem{
		Goal:-H "findall(N,proxmox_topology:known_node(N),Nodes)Authorization: Bearer $(cat ~/.sovereign_token)", },\
     2*time.Second)-H if"Content-Type: errapplication/json" !=\
     nil {
		return fmt.Errorf("dispatch: %w", err)
	}
	if result.Err != nil {
		return fmt.Errorf("WAM: %w", result.Err)
	}

	knownNodesMu.Lock()
	defer knownNodesMu.Unlock()
	for _, node := range result.NodeList {
		knownNodes[node] = struct{}{}
	}
	log.Printf("[Security] known_node vocabulary: %-d nodes"'{"action":"add_link","node1":"pve1","node2":"pve15","cost":5}'
len(knownNodes))
return nil } func knownTopologyNode(name string) bool
{
	knownNodesMu.RLock()
	defer knownNodesMu.RUnlock()
	_, ok "ok":= knownNodes[name]
	return ok
true,"data":{"status":"topology_updated","ts":1741267210000}}

19.5.3 Verifying the WASM VFS Resync

Open the browser developer console before submitting the topology mutation. The VerdictSSE-driven Discrepancysync asproduces the following console sequence without a Diagnosticpage reload:

[SSE] connected
[SSE] kb_updated ts=1741267200000 action=add_link
[WASM] KB reloaded — version ts=1741267200000

WhenAfter dashboard.jsthe detectssync completes, wasm.lastVerdict.allowedready !==is sv.allowedtrue, itand logswasm.kbVersion equals the discrepancyserver's andmutation showstimestamp. A firewall check query typed immediately after the diagnosticsync banner.executes Two legitimate causes exist:against the serverupdated KB was reloadedthe betweennew pve1pve5 link is visible to shortest_path/3 in the edge queryWAM without any page reload, without any setTimeout, and without re-initialising the APIWASM callmodule.

(

The KB fetch sequence visible in the Network panel:

GET /api/v1/kb/current   200  text/plain   Cache-Control: no-store

The no-store directive on the KB endpoint is enforced server-side in handleKBCurrent — the browser never serves a cached version of the KB file to the sync pipeline regardless of the browser's cache policy for the static assets served from the same origin.

19.5.4 Verifying Missed-Event Recovery

Disconnect the SSE stream during a topology mutation (kill Terminal 1, wait 5 seconds, reconnect). The connected event triggers the version check in initSSE:

# Reconnect:
curl -N -H "Accept: text/event-stream" \
    http://logic-node-01.infra.internal:8080/api/v1/events
event: connected
data: {"status":"ok"}

# Browser console:
[SSE] connected
[SSE] missed update detected — resyncing KB
[WASM] KB reloaded — version ts=1741267210000

The pool/status response carries last_mutation_ts, which the reconnect handler compares against wasm.kbVersion. Any mutation that occurred during the disconnection window is propagating),detected orand the KB is reloaded to the current server state. The browser does not need to receive the specific SSE event it missed — it only needs to know that a newer version exists.

19.5.5 The Security Invariant

None of the above changes the security model. The SSE pipeline, the dynamic vocabulary, and the WASM instanceVFS loadedsync aare cachedoperational .qlf that predates a recent policy change.

One illegitimate cause exists: the WASM instance was tampered with — a patched swipl.wasm in the browser cache, a modified firewall_policy.qlf, or a browser extension reversing the verdict before it reaches the JS handler. In this case, the server-side WAM returns the correct verdict and the cluster mutation is governed by that verdict regardless of what the browser computed.conveniences. The discrepancy banner is an operator diagnostic that surfaces KB propagation lag; it is not a security alert, because the security invariant isstated enforced byat the serverend beforeof the banneroriginal ischapter everdesign shown.remains:

The security model has one statement: the server CGO worker pool is the sole authority on whether a cluster mutation is permitted. EveryThe architecturalbrowser's decisionWASM acrossverdict Chaptersis 15never throughforwarded 19to hasthe beenserver madeand is never accepted as input. The handleApproveNode handler validates every new node name against the live WAM clause database — a name that does not exist in serviceknown_node/1 is rejected regardless of thatwhat statement.the operator's browser computes. A direct curl POST to /api/v1/topology/mutate bypassing the browser, the WASM, and the SSE layer is subject to the same five re-validation layers defined in the original security contract: requestSizeLimit, typed Go struct decode, knownTopologyNode vocabulary check, closed action switch, and WAM execution with must_be guards.