Harden DNS cache, privileges, and native loading

ober

7beece56afaa6ff5cfc8329dc2bb0042d4c7cc2f

diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index ab3cb5b..bcedb60 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -17,7 +17,7 @@ jobs:
   verify:
     runs-on: ubuntu-latest
     steps:
-      - uses: actions/checkout@v4
+      - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1
 
       - name: Install system tools
         run: |
diff --git a/.github/workflows/security-baseline.yml b/.github/workflows/security-baseline.yml
index 28a713e..5381c31 100644
--- a/.github/workflows/security-baseline.yml
+++ b/.github/workflows/security-baseline.yml
@@ -13,7 +13,7 @@ jobs:
   baseline:
     runs-on: ubuntu-latest
     steps:
-      - uses: actions/checkout@v4
+      - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1
 
       - name: Required release files
         run: |
diff --git a/Makefile b/Makefile
index 3e55143..c0027e9 100644
--- a/Makefile
+++ b/Makefile
@@ -142,6 +142,17 @@ release-evidence: verify
 	grep -q '^abuse_policy_status=present$$' "$(DIST_DIR)/abuse-policy.txt"
 	grep -q '^any_query_policy_status=minimized-refused$$' "$(DIST_DIR)/abuse-policy.txt"
 	grep -q '^edge_rate_limit_status=external-required-until-native-limit$$' "$(DIST_DIR)/abuse-policy.txt"
+	{ \
+		printf 'cdb_snapshot_reload_status=validated-atomic-swap\n'; \
+		printf 'cdb_request_path_status=in-memory-only\n'; \
+		printf 'cdb_retired_reader_status=lease-drained\n'; \
+		printf 'privilege_root_policy_status=uid-gid-required\n'; \
+		printf 'supplementary_groups_status=clear-and-verify\n'; \
+		printf 'saved_identity_status=verified-or-regain-probed\n'; \
+		printf 'source=docs/runtime-hardening.md\n'; \
+	} > "$(DIST_DIR)/runtime-hardening.txt"
+	grep -q '^cdb_request_path_status=in-memory-only$$' "$(DIST_DIR)/runtime-hardening.txt"
+	grep -q '^supplementary_groups_status=clear-and-verify$$' "$(DIST_DIR)/runtime-hardening.txt"
 	sh scripts/sanitize-evidence.sh "$(DIST_DIR)"
 	sh scripts/sanitize-evidence.sh "$(SBOM_DIR)"
 	sh scripts/sanitize-evidence.sh "$(REPRO_DIR)"
diff --git a/README.md b/README.md
index 1e68974..5173e41 100644
--- a/README.md
+++ b/README.md
@@ -42,9 +42,14 @@ make static-freebsd
 
 ## Runtime Security Defaults
 
-`jdns` binds the UDP and TCP sockets first, then chroots to `ROOT` (default `.`), drops
-supplementary groups with `setgroups(0, NULL)`, applies `setgid`/`setuid` when
-configured, and then applies platform filesystem restrictions.
+`jdns` binds the UDP and TCP sockets first, then chroots to `ROOT` (default
+`.`), drops supplementary groups with `setgroups(0, NULL)`, applies
+`setgid`/`setuid`, verifies the resulting identities, and then applies platform
+filesystem restrictions. A root launch requires both a non-zero `UID` and
+non-zero `GID`. Root retention is denied unless the development-only
+`JDNS_RETAIN_PRIVILEGES=1` / `--retain-privileges` override is explicit; that
+override is audited in the log and is not a production configuration. See
+`docs/runtime-hardening.md`.
 
 TCP handling uses Jerboa fibers over nonblocking sockets when the fiber runtime
 is available before chroot. Embedded static builds can fall back to bounded
@@ -75,8 +80,12 @@ JDNS_REQUIRE_WASM_CDB=1
 ## Zone Updates
 
 `jdns-data` writes a temporary CDB and atomically renames it into place. The
-server validates the CDB at startup, then reopens it per query so an atomic
-`data.cdb` replacement is visible without restarting the server.
+server validates one immutable in-memory snapshot at startup. A low-frequency
+watcher detects device/inode/time/size changes, builds and validates a candidate
+off the request path, and atomically swaps it; in-flight queries finish against
+their old snapshot. Queries never reopen the CDB. The default one-second poll
+can be changed with `JDNS_CDB_RELOAD_MS` (100–60000 ms), and malformed updates
+leave the last valid snapshot serving.
 
 ## Zone Formats
 
diff --git a/SECURITY.md b/SECURITY.md
index 5fd22c8..07d2339 100644
--- a/SECURITY.md
+++ b/SECURITY.md
@@ -35,8 +35,10 @@ not raw binaries, generated libraries, or scratch build logs.
 - `ANY` queries must remain minimized instead of enumerating full zone records.
 - Filesystem sandbox fallback must fail closed unless explicitly enabled for
   local development.
-- Privilege drop, supplementary group clearing, timeouts, and packet-size caps
-  are release gates.
+- The CDB request path, validated atomic snapshot reload, privilege drop,
+  supplementary group clearing/verification, timeouts, and packet-size caps
+  are release gates. Exact runtime invariants are tracked in
+  `docs/runtime-hardening.md`.
 - Do not publish operational zone data, deployment paths, hostnames, or keys.
 - Public production deployment still requires UDP/TCP soak/load evidence,
   coverage-guided fuzzing evidence, SBOM/reproducibility evidence, target edge
diff --git a/docs/release-evidence.md b/docs/release-evidence.md
index 2b9fb44..6baee19 100644
--- a/docs/release-evidence.md
+++ b/docs/release-evidence.md
@@ -42,6 +42,9 @@ artifacts under `dist/release-evidence/`:
 - `abuse-policy.txt`, generated from `docs/rate-limit-amplification.md`, with
   authoritative-only, recursion-disabled, minimized `ANY`, UDP payload, TCP
   resource, and external edge-rate-limit policy markers.
+- `runtime-hardening.txt`, generated from `docs/runtime-hardening.md`, with the
+  CDB snapshot/reload, lease-drain, root-policy, supplementary-group, and saved
+  identity verification markers.
 
 To record a current production-length soak, run:
 
@@ -111,7 +114,8 @@ Edge rate-limit evidence is claimed only when the release packet contains
 reviewed target configuration/evidence and `target_rate_limit_proof_status=present`.
 
 The external review packet must include this file, `SECURITY.md`,
-`docs/threat-model.md`, `docs/rate-limit-amplification.md`, `docs/fuzzing.md`,
+`docs/threat-model.md`, `docs/runtime-hardening.md`,
+`docs/rate-limit-amplification.md`, `docs/fuzzing.md`,
 the corpus under `fuzz/corpus/`, the latest UDP/TCP soak and load-test logs,
 `dist/release-evidence/soak/`, `dist/release-evidence/fuzz-evidence/`,
 `dist/release-evidence/abuse-policy.txt`, and any accepted RustSec or native
diff --git a/docs/runtime-hardening.md b/docs/runtime-hardening.md
new file mode 100644
index 0000000..cd2d786
--- /dev/null
+++ b/docs/runtime-hardening.md
@@ -0,0 +1,59 @@
+# Runtime CDB And Privilege Hardening
+
+## Release Checklist Markers
+
+- cdb_snapshot_reload_status: validated-atomic-swap
+- cdb_request_path_status: in-memory-only
+- cdb_retired_reader_status: lease-drained
+- privilege_root_policy_status: uid-gid-required
+- supplementary_groups_status: clear-and-verify
+- saved_identity_status: verified-or-regain-probed
+
+These markers are checked by `make security` and copied into release evidence.
+Changing one requires matching implementation, regression tests, and renewed
+target soak evidence.
+
+## CDB Snapshot And Reload Policy
+
+The daemon reads and validates `data.cdb` once at startup. UDP and TCP query
+handlers acquire a short-lived lease on the current immutable in-memory reader;
+they do not `stat`, open, read, allocate, or instantiate a CDB from disk.
+
+A control-plane thread checks the CDB pathname once per second by default. Its
+version includes device, inode, size, modification time, and change time. When
+the version changes, the thread reads and validates the candidate away from the
+request path, confirms that its identity stayed stable during the read, and
+atomically publishes it. A failed, malformed, truncated, oversized, or unstable
+candidate is logged and the last valid snapshot continues serving. A retired
+reader is closed only after its last in-flight query releases its lease.
+
+`JDNS_CDB_RELOAD_MS` may set the polling interval from 100 through 60000 ms.
+The supported CDB size is capped at 16 MiB, matching the WASM CDB sandbox
+capacity. Zone updates must continue to use same-directory atomic rename; the
+checked-in zone compiler already follows that rule.
+
+`tests/cdb-cache-test.ss` gates the request-path load/allocation behavior,
+concurrent old-or-new answers during atomic replacement, exactly-once reload,
+lease draining, and rejection of malformed replacements.
+
+## Privilege Policy
+
+When the daemon starts with effective UID 0, both a non-zero `UID` and non-zero
+`GID` are mandatory. Before either identity changes, the daemon calls
+`setgroups(0, NULL)`, then `setgid`, then `setuid`. It verifies that no
+supplementary groups remain, checks real/effective IDs and saved IDs where the
+platform exposes `getresuid`/`getresgid`, and probes that UID 0 and GID 0 cannot
+be regained.
+
+For an explicitly audited local development run only, root retention can be
+enabled with one of:
+
+```sh
+JDNS_RETAIN_PRIVILEGES=1 jdns ...
+jdns --retain-privileges ...
+```
+
+The override conflicts with configured UID/GID values and must never be used
+for a production release. `tests/privilege-policy-test.ss` gates the root
+decision matrix and ensures even a UID-only non-root plan begins by clearing
+supplementary groups.
diff --git a/docs/threat-model.md b/docs/threat-model.md
index 6d0c8f6..1258452 100644
--- a/docs/threat-model.md
+++ b/docs/threat-model.md
@@ -28,7 +28,11 @@ cases must be modeled before release.
 - Filesystem boundary: startup reads config and CDB files before sandboxing;
   runtime should operate inside `ROOT`.
 - Privilege boundary: sockets are bound before chroot, supplementary groups are
-  cleared, and configured `setgid`/`setuid` are applied before serving.
+  cleared, configured `setgid`/`setuid` are applied, and resulting identities
+  plus inability to regain root are verified before serving.
+- Reload boundary: packet handlers lease an immutable validated CDB snapshot;
+  a low-frequency control-plane task builds replacements and atomically swaps
+  them only after stable file identity and complete validation.
 
 ## Threat Actors
 
@@ -68,8 +72,12 @@ cases must be modeled before release.
 - Filesystem sandbox fallback must be denied unless explicitly enabled for
   local development with `JDNS_ALLOW_SANDBOX_FALLBACK=1` or
   `--allow-sandbox-fallback`.
-- CDB updates must remain atomic: write temp, validate, rename, reopen per
-  query, and never serve partially written data.
+- CDB updates must remain atomic: write temp, rename, validate a stable
+  off-path candidate, atomically swap snapshots, drain old leases, and never
+  serve partially written data. Packet handlers must not reopen the zone.
+- Effective-root startup must require non-root UID/GID targets or the explicit
+  development-only retain override. Every identity change must first clear
+  supplementary groups and verify the resulting credentials.
 - Public deployments must follow `docs/rate-limit-amplification.md` for
   authoritative-only behavior, edge rate limits, response-size review, and
   abuse evidence.
diff --git a/lib/jerboa-dns/cdb-cache.ss b/lib/jerboa-dns/cdb-cache.ss
new file mode 100644
index 0000000..7b50654
--- /dev/null
+++ b/lib/jerboa-dns/cdb-cache.ss
@@ -0,0 +1,205 @@
+#!chezscheme
+;;; (jerboa-dns cdb-cache) — immutable CDB snapshots with atomic hot reload.
+;;;
+;;; The request path only acquires a reference to the current in-memory
+;;; snapshot. A low-frequency control-plane task calls cdb-cache-refresh!,
+;;; which identifies the pathname by device/inode/size/timestamps, builds and
+;;; validates a replacement off-path, then swaps it under a short mutex.
+;;; Retired readers remain live until their last in-flight query releases them.
+
+(library (jerboa-dns cdb-cache)
+  (export
+    open-cdb-cache cdb-cache? cdb-cache-close!
+    call-with-cdb-snapshot
+    cdb-cache-refresh!
+    cdb-cache-current-using-sandbox?
+    cdb-cache-load-count cdb-cache-reload-count)
+
+  (import
+    (except (chezscheme)
+            make-hash-table hash-table?
+            sort sort!
+            printf fprintf
+            path-extension path-absolute?
+            with-input-from-string with-output-to-string
+            iota 1+ 1-
+            partition
+            make-date make-time)
+    (except (jerboa prelude) meta atom?)
+    (only (std os posix)
+          posix-stat free-stat stat-dev stat-ino)
+    (only (jerboa-dns wasm-cdb)
+          open-sandboxed-cdb
+          sandboxed-cdb-close!
+          sandboxed-cdb-reader-using-sandbox?))
+
+  ;; version is an immutable vector:
+  ;; #(device inode size mtime-sec mtime-ns ctime-sec ctime-ns)
+  (defstruct cdb-generation (reader version active retired closed))
+  (defstruct cdb-cache
+    (path lock refresh-lock current closed load-count reload-count))
+
+  (def (call-with-mutex mutex thunk)
+    (dynamic-wind
+      (lambda () (mutex-acquire mutex))
+      thunk
+      (lambda () (mutex-release mutex))))
+
+  (def (path-device+inode path)
+    (let ([st (posix-stat path)])
+      (dynamic-wind
+        void
+        (lambda () (values (stat-dev st) (stat-ino st)))
+        (lambda () (free-stat st)))))
+
+  (def (cdb-file-version path)
+    (let-values ([(device inode) (path-device+inode path)])
+      (let ([mtime (file-modification-time path)]
+            [ctime (file-change-time path)]
+            [size (call-with-port (open-file-input-port path)
+                    port-length)])
+        (vector device inode size
+                (time-second mtime) (time-nanosecond mtime)
+                (time-second ctime) (time-nanosecond ctime)))))
+
+  (def (load-stable-generation path)
+    ;; Retry a bounded number of times if an operator replaces the pathname
+    ;; while a candidate is being read. No unstable candidate is published.
+    (let loop ([attempt 0])
+      (let* ([before (cdb-file-version path)]
+             [reader (open-sandboxed-cdb path)]
+             [after (guard (e [#t
+                               (sandboxed-cdb-close! reader)
+                               (raise e)])
+                      (cdb-file-version path))])
+        (if (equal? before after)
+          (make-cdb-generation reader after 0 #f #f)
+          (begin
+            (sandboxed-cdb-close! reader)
+            (if (< attempt 2)
+              (loop (+ attempt 1))
+              (error 'open-cdb-cache
+                     "CDB kept changing while a stable snapshot was built"
+                     path)))))))
+
+  (def (open-cdb-cache path)
+    (make-cdb-cache path
+                    (make-mutex)
+                    (make-mutex)
+                    (load-stable-generation path)
+                    #f
+                    1
+                    0))
+
+  (def (acquire-generation cache)
+    (call-with-mutex (cdb-cache-lock cache)
+      (lambda ()
+        (when (cdb-cache-closed cache)
+          (error 'call-with-cdb-snapshot "CDB cache is closed"))
+        (let ([generation (cdb-cache-current cache)])
+          (cdb-generation-active-set!
+            generation (+ (cdb-generation-active generation) 1))
+          generation))))
+
+  (def (release-generation! cache generation)
+    (let ([reader-to-close #f])
+      (call-with-mutex (cdb-cache-lock cache)
+        (lambda ()
+          (let ([active (- (cdb-generation-active generation) 1)])
+            (when (negative? active)
+              (error 'release-generation! "CDB generation reference underflow"))
+            (cdb-generation-active-set! generation active)
+            (when (and (zero? active)
+                       (cdb-generation-retired generation)
+                       (not (cdb-generation-closed generation)))
+              (cdb-generation-closed-set! generation #t)
+              (set! reader-to-close (cdb-generation-reader generation))))))
+      (when reader-to-close
+        (sandboxed-cdb-close! reader-to-close))))
+
+  (def (call-with-cdb-snapshot cache proc)
+    (let ([generation (acquire-generation cache)])
+      (dynamic-wind
+        void
+        (lambda () (proc (cdb-generation-reader generation)))
+        (lambda () (release-generation! cache generation)))))
+
+  (def (cdb-cache-current-version cache)
+    (call-with-mutex (cdb-cache-lock cache)
+      (lambda ()
+        (when (cdb-cache-closed cache)
+          (error 'cdb-cache-refresh! "CDB cache is closed"))
+        (cdb-generation-version (cdb-cache-current cache)))))
+
+  (def (retire-generation/locked! generation)
+    ;; Caller holds the cache lock. Return a reader that is immediately safe
+    ;; to close, or #f when in-flight queries still own the generation.
+    (cdb-generation-retired-set! generation #t)
+    (and (zero? (cdb-generation-active generation))
+         (not (cdb-generation-closed generation))
+         (begin
+           (cdb-generation-closed-set! generation #t)
+           (cdb-generation-reader generation))))
+
+  (def (cdb-cache-refresh! cache)
+    ;; Only refreshers serialize on refresh-lock. Queries never take it.
+    (call-with-mutex (cdb-cache-refresh-lock cache)
+      (lambda ()
+        (let* ([current-version (cdb-cache-current-version cache)]
+               [observed-version (cdb-file-version (cdb-cache-path cache))])
+          (if (equal? current-version observed-version)
+            'unchanged
+            (let ([candidate (load-stable-generation (cdb-cache-path cache))]
+                  [reader-to-close #f]
+                  [candidate-unused? #f]
+                  [candidate-installed? #f]
+                  [result 'unchanged])
+              (guard (e [#t
+                         (unless (or candidate-installed?
+                                     (cdb-generation-closed candidate))
+                           (cdb-generation-closed-set! candidate #t)
+                           (sandboxed-cdb-close!
+                             (cdb-generation-reader candidate)))
+                         (raise e)])
+                (call-with-mutex (cdb-cache-lock cache)
+                  (lambda ()
+                    (when (cdb-cache-closed cache)
+                      (error 'cdb-cache-refresh! "CDB cache is closed"))
+                    (let ([old (cdb-cache-current cache)])
+                      (if (equal? (cdb-generation-version old)
+                                  (cdb-generation-version candidate))
+                        (set! candidate-unused? #t)
+                        (begin
+                          (cdb-cache-current-set! cache candidate)
+                          (set! candidate-installed? #t)
+                          (cdb-cache-load-count-set!
+                            cache (+ (cdb-cache-load-count cache) 1))
+                          (cdb-cache-reload-count-set!
+                            cache (+ (cdb-cache-reload-count cache) 1))
+                          (set! reader-to-close
+                            (retire-generation/locked! old))
+                          (set! result 'reloaded))))))
+                (when candidate-unused?
+                  (cdb-generation-closed-set! candidate #t)
+                  (sandboxed-cdb-close! (cdb-generation-reader candidate)))
+                (when reader-to-close
+                  (sandboxed-cdb-close! reader-to-close))
+                result)))))))
+
+  (def (cdb-cache-current-using-sandbox? cache)
+    (call-with-cdb-snapshot cache sandboxed-cdb-reader-using-sandbox?))
+
+  (def (cdb-cache-close! cache)
+    (let ([reader-to-close #f])
+      (call-with-mutex (cdb-cache-refresh-lock cache)
+        (lambda ()
+          (call-with-mutex (cdb-cache-lock cache)
+            (lambda ()
+              (unless (cdb-cache-closed cache)
+                (cdb-cache-closed-set! cache #t)
+                (set! reader-to-close
+                  (retire-generation/locked! (cdb-cache-current cache))))))))
+      (when reader-to-close
+        (sandboxed-cdb-close! reader-to-close))))
+
+  ) ;; end library
diff --git a/lib/jerboa-dns/main.ss b/lib/jerboa-dns/main.ss
index e82c441..7c59b43 100644
--- a/lib/jerboa-dns/main.ss
+++ b/lib/jerboa-dns/main.ss
@@ -24,6 +24,12 @@
   (def (truthy-env? s)
     (and s (not (member (string-downcase s) '("" "0" "false" "no")))))
 
+  (def (parse-id who value)
+    (let ([n (and value (string->number value))])
+      (unless (and n (integer? n) (<= 0 n #xffffffff))
+        (error who "expected an unsigned numeric identity" value))
+      n))
+
   (def (run-jdns! args)
     ;; Configuration from environment variables (djbdns convention)
     ;; or command-line args: [ip] [port] [root-dir] [uid] [gid]
@@ -31,8 +37,8 @@
           [port (or (and (getenv "PORT") (string->number (getenv "PORT")))
                     53)]
           [root-dir (or (getenv "ROOT") ".")]
-          [uid (and (getenv "UID") (string->number (getenv "UID")))]
-          [gid (and (getenv "GID") (string->number (getenv "GID")))]
+          [uid (and (getenv "UID") (parse-id 'UID (getenv "UID")))]
+          [gid (and (getenv "GID") (parse-id 'GID (getenv "GID")))]
           [data-file (or (getenv "DATA") "data.cdb")])
 
       (when (truthy-env? (getenv "JDNS_LOG_QUERIES"))
@@ -52,9 +58,9 @@
           ((cons "--root" (cons v more))
            (process-args more ip port v uid gid data-file))
           ((cons "--uid" (cons v more))
-           (process-args more ip port root-dir (string->number v) gid data-file))
+           (process-args more ip port root-dir (parse-id '--uid v) gid data-file))
           ((cons "--gid" (cons v more))
-           (process-args more ip port root-dir uid (string->number v) data-file))
+           (process-args more ip port root-dir uid (parse-id '--gid v) data-file))
           ((cons "--data" (cons v more))
            (process-args more ip port root-dir uid gid v))
           ((cons "--log-queries" more)
@@ -63,10 +69,13 @@
           ((cons "--allow-sandbox-fallback" more)
            (putenv "JDNS_ALLOW_SANDBOX_FALLBACK" "1")
            (process-args more ip port root-dir uid gid data-file))
+          ((cons "--retain-privileges" more)
+           (putenv "JDNS_RETAIN_PRIVILEGES" "1")
+           (process-args more ip port root-dir uid gid data-file))
           ((cons opt _)
            (display (format "jdns: unknown option ~a\n" opt)
                     (current-error-port))
-           (display "Usage: jdns [--ip IP] [--port PORT] [--root DIR] [--uid UID] [--gid GID] [--data FILE] [--log-queries] [--allow-sandbox-fallback]\n"
+           (display "Usage: jdns [--ip IP] [--port PORT] [--root DIR] [--uid UID] [--gid GID] [--data FILE] [--log-queries] [--allow-sandbox-fallback] [--retain-privileges]\n"
                     (current-error-port))
            (exit 1))))))
 
diff --git a/lib/jerboa-dns/server.ss b/lib/jerboa-dns/server.ss
index 4b5773e..cc13647 100644
--- a/lib/jerboa-dns/server.ss
+++ b/lib/jerboa-dns/server.ss
@@ -25,6 +25,7 @@
     server-config-ip server-config-port
     server-config-root-dir server-config-uid server-config-gid
     server-config-data-file
+    privilege-drop-plan
     run-server!
     use-wasm-parser?
     set-use-wasm-parser!)
@@ -46,12 +47,8 @@
     (jerboa-dns lookup)
     (jerboa-dns wasm-dns)
     (jerboa-dns log)
-    ;; The wasm-cdb wrapper exposes the same predicate/flag names as
-    ;; wasm-dns; we only need its reader API here, so import that subset.
-    (only (jerboa-dns wasm-cdb)
-          open-sandboxed-cdb
-          sandboxed-cdb-close!
-          sandboxed-cdb-reader-using-sandbox?))
+    (jerboa-dns cdb-cache)
+    (only (std native-loader) native-loader-ensure-libc-symbol!))
 
   ;; ========== Platform Detection ==========
 
@@ -67,15 +64,12 @@
   ;; data-file: CDB filename (default "data.cdb")
   (defstruct server-config (ip port root-dir uid gid data-file))
 
-  ;; ========== Load libc ==========
-  ;; On FreeBSD, socket/network functions require explicit libc loading.
+  ;; ========== Resolve libc ==========
+  ;; Static registrations take precedence. Dynamic builds may load libc only
+  ;; from the core policy's fixed operating-system paths, never by a bare name.
 
   (def _libc
-    (or (guard (e [#t #f]) (load-shared-object "libc.so.7"))
-        (guard (e [#t #f]) (load-shared-object "libc.so.6"))
-        (guard (e [#t #f]) (load-shared-object "libc.so"))
-        (guard (e [#t #f]) (load-shared-object "libSystem.B.dylib"))
-        (guard (e [#t #f]) (load-shared-object ""))))
+    (native-loader-ensure-libc-symbol! 'jerboa-dns/server "socket"))
 
   ;; ========== FFI for socket operations ==========
 
@@ -97,6 +91,17 @@
   (def c-setuid    (foreign-procedure "setuid" (unsigned) int))
   (def c-setgid    (foreign-procedure "setgid" (unsigned) int))
   (def c-setgroups (foreign-procedure "setgroups" (int void*) int))
+  (def c-getuid    (foreign-procedure "getuid" () unsigned))
+  (def c-geteuid   (foreign-procedure "geteuid" () unsigned))
+  (def c-getgid    (foreign-procedure "getgid" () unsigned))
+  (def c-getegid   (foreign-procedure "getegid" () unsigned))
+  (def c-getgroups (foreign-procedure "getgroups" (int void*) int))
+  (def c-getresuid
+    (and (foreign-entry? "getresuid")
+         (foreign-procedure "getresuid" (void* void* void*) int)))
+  (def c-getresgid
+    (and (foreign-entry? "getresgid")
+         (foreign-procedure "getresgid" (void* void* void*) int)))
   (def c-chdir     (foreign-procedure "chdir" (string) int))
   (def c-errno-location
     (cond
@@ -150,6 +155,7 @@
   (def TCP-IDLE-TIMEOUT-NS (* 15 1000000000))
   (def TCP-FIBER-POLL-MS 1)
   (def TCP-IO-TIMEOUT -2)
+  (def DEFAULT-CDB-RELOAD-MS 1000)
 
   ;; Loaded dynamically so static jdns binaries can still run without
   ;; needing external Jerboa library files at startup.
@@ -336,6 +342,149 @@
     (def (allow-sandbox-fallback?)
       (truthy-env? "JDNS_ALLOW_SANDBOX_FALLBACK"))
 
+    (def (retain-privileges?)
+      (truthy-env? "JDNS_RETAIN_PRIVILEGES"))
+
+    (def (configured-cdb-reload-ms)
+      (let ([raw (getenv "JDNS_CDB_RELOAD_MS")])
+        (if raw
+          (let ([n (string->number raw)])
+            (unless (and n (integer? n) (<= 100 n 60000))
+              (error 'run-server!
+                     "JDNS_CDB_RELOAD_MS must be an integer from 100 to 60000"
+                     raw))
+            n)
+          DEFAULT-CDB-RELOAD-MS)))
+
+  ;; ========== Privilege policy and verification ==========
+
+  (def (valid-id? id)
+    (and (integer? id) (<= 0 id #xffffffff)))
+
+  (def (privilege-drop-plan effective-uid uid gid retain?)
+    ;; This pure planner is exported so policy/order can be regression-tested
+    ;; without changing the test runner's process credentials.
+    (unless (valid-id? effective-uid)
+      (error 'privilege-drop-plan "invalid effective UID" effective-uid))
+    (when (and uid (not (valid-id? uid)))
+      (error 'privilege-drop-plan "invalid target UID" uid))
+    (when (and gid (not (valid-id? gid)))
+      (error 'privilege-drop-plan "invalid target GID" gid))
+    (when (and retain? (or uid gid))
+      (error 'privilege-drop-plan
+             "retain-privileges conflicts with a UID/GID drop"))
+    (cond
+      [(zero? effective-uid)
+       (cond
+         [(and (not uid) (not gid))
+          (if retain?
+            'retain
+            (error 'privilege-drop-plan
+                   "root startup requires non-root UID and GID; JDNS_RETAIN_PRIVILEGES=1 is development-only"))]
+         [(or (not uid) (not gid) (zero? uid) (zero? gid))
+          (error 'privilege-drop-plan
+                 "root startup requires both a non-root UID and non-root GID"
+                 uid gid)]
+         [else '(clear-groups setgid setuid verify)])]
+      [(and (not uid) (not gid))
+       ;; On a non-root launch, the retain flag is harmless but still logged.
+       (if retain? 'retain-nonroot 'none)]
+      [else
+       (append '(clear-groups)
+               (if gid '(setgid) '())
+               (if uid '(setuid) '())
+               '(verify))]))
+
+  (def (read-res-ids proc)
+    (and proc
+         (let ([real (foreign-alloc 4)]
+               [effective (foreign-alloc 4)]
+               [saved (foreign-alloc 4)])
+           (dynamic-wind
+             void
+             (lambda ()
+               (unless (zero? (proc real effective saved))
+                 (error 'run-server! "cannot read saved process identities"))
+               (vector (foreign-ref 'unsigned real 0)
+                       (foreign-ref 'unsigned effective 0)
+                       (foreign-ref 'unsigned saved 0)))
+             (lambda ()
+               (foreign-free real)
+               (foreign-free effective)
+               (foreign-free saved))))))
+
+  (def (all-vector-values=? ids expected)
+    (or (not ids)
+        (and (= (vector-ref ids 0) expected)
+             (= (vector-ref ids 1) expected)
+             (= (vector-ref ids 2) expected))))
+
+  (def (verify-privilege-drop! original-euid uid gid)
+    (when (not (zero? (c-getgroups 0 0)))
+      (error 'run-server! "supplementary groups remain after setgroups"))
+    (when uid
+      (unless (and (= (c-getuid) uid)
+                   (= (c-geteuid) uid)
+                   (all-vector-values=? (read-res-ids c-getresuid) uid))
+        (error 'run-server! "UID drop verification failed" uid)))
+    (when gid
+      (unless (and (= (c-getgid) gid)
+                   (= (c-getegid) gid)
+                   (all-vector-values=? (read-res-ids c-getresgid) gid))
+        (error 'run-server! "GID drop verification failed" gid)))
+
+    ;; A root-origin process must be unable to regain either identity. If a
+    ;; platform unexpectedly permits it, immediately drop again and fail.
+    (when (zero? original-euid)
+      (when (zero? (c-setuid 0))
+        (when uid (c-setuid uid))
+        (error 'run-server! "process could regain root UID after drop"))
+      (when (zero? (c-setgid 0))
+        (when gid (c-setgid gid))
+        (error 'run-server! "process could regain root GID after drop"))))
+
+  (def (apply-privilege-plan! plan original-euid uid gid)
+    (case plan
+      [(none) (void)]
+      [(retain)
+       (log-info 'privileges_retained
+         'reason "explicit_development_override"
+         'uid (c-geteuid) 'gid (c-getegid))]
+      [(retain-nonroot)
+       (log-info 'retain_privileges_ignored
+         'reason "process_already_nonroot"
+         'uid (c-geteuid) 'gid (c-getegid))]
+      [else
+       ;; Every configured identity change begins by clearing supplementary
+       ;; groups, including the historically unsafe UID-only case.
+       (when (= (c-setgroups 0 0) -1)
+         (error 'run-server! "cannot clear supplementary groups"))
+       (when gid
+         (when (= (c-setgid gid) -1)
+           (error 'run-server! "cannot setgid" gid)))
+       (when uid
+         (when (= (c-setuid uid) -1)
+           (error 'run-server! "cannot setuid" uid)))
+       (verify-privilege-drop! original-euid uid gid)
+       (log-info 'privdrop_verified 'uid (or uid "unchanged")
+         'gid (or gid "unchanged")
+         'supplementary_groups 0)]))
+
+  ;; ========== CDB reload control plane ==========
+
+  (def (start-cdb-reloader! cache interval-ms)
+    (fork-thread
+      (lambda ()
+        (let loop ()
+          (sleep-ms interval-ms)
+          (guard (e [#t
+                     (log-error 'cdb_reload_failed
+                       'reason (condition-reason e))])
+            (when (eq? (cdb-cache-refresh! cache) 'reloaded)
+              (log-info 'cdb_reloaded
+                'reload_count (cdb-cache-reload-count cache))))
+          (loop)))))
+
   ;; ========== Capsicum fd Restriction (FreeBSD) ==========
 
   (def (pack-cap-rights idx0-bits idx1-bits)
@@ -430,7 +579,7 @@
       [(message-condition? e) (condition-message e)]
       [else "unknown"]))
 
-    (def (process-query! rs pkt-buf pkt-len client-addr data-file send-response! max-response-len)
+    (def (process-query! rs pkt-buf pkt-len client-addr cdb-cache send-response! max-response-len)
       ;; Parse query, look up in CDB, send response.
       (let ([pkt   (make-bytevector pkt-len)]
             [t0    (now-monotonic-ns)]
@@ -497,24 +646,14 @@
              (response-query! rs qname qtype qclass)
              (response-id! rs id)
 
-               ;; Open per query so an atomic data.cdb rename is visible
-               ;; immediately, matching tinydns live-update behavior.
                (let ([client-ip (make-bytevector 4 0)])
                  ;; Extract from sockaddr (sin_addr at offset 4)
                  (do ([i 0 (+ i 1)]) ((= i 4))
                    (bytevector-u8-set! client-ip i
                      (foreign-ref 'unsigned-8 (+ client-addr 4) i)))
-                 (let ([cdb (open-sandboxed-cdb data-file)]
-                       [closed? #f])
-                   (def (close-cdb!)
-                     (unless closed?
-                       (set! closed? #t)
-                       (sandboxed-cdb-close! cdb)))
-                   (guard (e [#t
-                              (close-cdb!)
-                              (raise e)])
-                     (dns-respond rs cdb qname qtype client-ip)
-                     (close-cdb!))))
+                 (call-with-cdb-snapshot cdb-cache
+                   (lambda (cdb)
+                     (dns-respond rs cdb qname qtype client-ip))))
 
              ;; UDP truncates to the question section at 512 bytes; TCP can
              ;; carry the full DNS message up to the protocol maximum.
@@ -610,7 +749,7 @@
       (+ (bitwise-arithmetic-shift-left (foreign-ref 'unsigned-8 len-buf 0) 8)
          (foreign-ref 'unsigned-8 len-buf 1)))
 
-    (def (handle-tcp-client! client-fd client-addr data-file)
+    (def (handle-tcp-client! client-fd client-addr cdb-cache)
       (let ([len-buf (foreign-alloc 2)])
         (let ([cleaned? #f])
           (def (cleanup!)
@@ -649,7 +788,7 @@
                                               'src (client-src-str client-addr)
                                               'reason (condition-reason e))])
                                    (process-query! (new-response-state)
-                                     pkt-buf pkt-len client-addr data-file
+                                     pkt-buf pkt-len client-addr cdb-cache
                                      (lambda (rs)
                                        (set! send-ok?
                                          (send-tcp-response! client-fd rs
@@ -662,7 +801,7 @@
                          (when keep-going? (loop)))])))))
             (cleanup!)))))
 
-    (def (run-tcp-client-task! client-fd client-addr data-file tcp-limiter)
+    (def (run-tcp-client-task! client-fd client-addr cdb-cache tcp-limiter)
       (let ([cleaned? #f])
         (def (cleanup!)
           (unless cleaned?
@@ -674,10 +813,10 @@
                      'src (client-src-str client-addr)
                      'reason (condition-reason e))
                    (cleanup!)])
-          (handle-tcp-client! client-fd client-addr data-file)
+          (handle-tcp-client! client-fd client-addr cdb-cache)
           (cleanup!))))
 
-    (def (accept-tcp-loop! tcp-sock data-file tcp-limiter spawn-client!)
+    (def (accept-tcp-loop! tcp-sock cdb-cache tcp-limiter spawn-client!)
       (let loop ()
         (let ([client-addr (foreign-alloc SOCKADDR_IN_SIZE)]
               [addrlen-buf (foreign-alloc 4)])
@@ -701,7 +840,7 @@
                            (foreign-ref 'unsigned-8 client-addr i)))
                        (spawn-client!
                          (lambda ()
-                           (run-tcp-client-task! client-fd addr-copy data-file tcp-limiter))
+                           (run-tcp-client-task! client-fd addr-copy cdb-cache tcp-limiter))
                          "tcp-client")))
                    (c-close client-fd)))]
               [(= (get-errno) EINTR) (void)]
@@ -716,12 +855,17 @@
   ;; ========== Main Server Loop ==========
 
   (def (run-server! config)
-    (let ([ip (server-config-ip config)]
-          [port (server-config-port config)]
-          [root-dir (server-config-root-dir config)]
-          [uid (server-config-uid config)]
-          [gid (server-config-gid config)]
-          [data-file (server-config-data-file config)])
+    (let* ([ip (server-config-ip config)]
+           [port (server-config-port config)]
+           [root-dir (server-config-root-dir config)]
+           [uid (server-config-uid config)]
+           [gid (server-config-gid config)]
+           [data-file (server-config-data-file config)]
+           [original-euid (c-geteuid)]
+           [retain? (retain-privileges?)]
+           [privilege-plan
+            (privilege-drop-plan original-euid uid gid retain?)]
+           [reload-ms (configured-cdb-reload-ms)])
 
       (install-sigpipe-handler!)
 
@@ -756,21 +900,11 @@
                          "chroot failed; set JDNS_ALLOW_SANDBOX_FALLBACK=1 to allow chdir fallback"
                          root-dir)))))
 
-          ;; 5. Drop privileges (clear groups before gid/uid, and gid
-          ;;    before uid as setuid may remove
-          ;;    the ability to call setgid)
-          (when gid
-            (when (= (c-setgroups 0 0) -1)
-              (error 'run-server! "cannot clear supplementary groups")))
-          (when gid
-            (when (= (c-setgid gid) -1)
-              (error 'run-server! "cannot setgid" gid)))
-        (when uid
-          (when (= (c-setuid uid) -1)
-            (error 'run-server! "cannot setuid" uid)))
-
-        (when (or uid gid)
-          (log-info 'privdrop 'uid (or uid "unchanged") 'gid (or gid "unchanged")))
+          ;; 5. Apply the fail-closed privilege plan. Supplementary groups
+          ;;    are always cleared before any configured UID/GID change;
+          ;;    root startup requires both non-root IDs unless the explicit
+          ;;    development-only retain override was selected.
+          (apply-privilege-plan! privilege-plan original-euid uid gid)
 
         ;; 6. Apply OS-level sandbox (defense in depth)
         (cond
@@ -782,14 +916,16 @@
            ;; Landlock: restrict filesystem access (if available)
            (enter-landlock-sandbox!)])
 
-          ;; 7. Validate the CDB at startup, then reopen per query so
-          ;; atomic data.cdb replacement is observed without restart.
-          (let ([startup-cdb (open-sandboxed-cdb data-file)])
+          ;; 7. Build one validated immutable snapshot. A low-frequency
+          ;; control-plane task detects atomic replacement and swaps a fully
+          ;; built candidate; packet handlers only acquire/release snapshots.
+          (let ([cdb-cache (open-cdb-cache data-file)])
             (log-info 'cdb_opened
               'file data-file
-              'parser (if (sandboxed-cdb-reader-using-sandbox? startup-cdb)
+              'parser (if (cdb-cache-current-using-sandbox? cdb-cache)
                         "sandboxed" "in-process"))
-            (sandboxed-cdb-close! startup-cdb)
+            (start-cdb-reloader! cdb-cache reload-ms)
+            (log-info 'cdb_reload_watcher 'interval_ms reload-ms)
 
             ;; 8. TCP uses Jerboa fibers when available; embedded static
             ;; builds can fall back to bounded nonblocking OS-thread tasks.
@@ -798,7 +934,7 @@
                 (let ([tcp-rt (*make-fiber-runtime* TCP-FIBER-WORKERS)])
                   (*fiber-spawn* tcp-rt
                     (lambda ()
-                      (accept-tcp-loop! tcp-sock data-file tcp-limiter
+                      (accept-tcp-loop! tcp-sock cdb-cache tcp-limiter
                         (lambda (thunk name)
                           (*fiber-spawn* tcp-rt thunk name))))
                     "tcp-accept")
@@ -807,7 +943,7 @@
                 (begin
                   (fork-thread
                     (lambda ()
-                      (accept-tcp-loop! tcp-sock data-file tcp-limiter
+                      (accept-tcp-loop! tcp-sock cdb-cache tcp-limiter
                         (lambda (thunk name) (fork-thread thunk)))))
                   (log-info 'tcp_listening 'ip ip 'port port 'mode "threads"))))
 
@@ -828,7 +964,7 @@
                     (guard (e [#t
                                (log-error 'recv_loop_error
                                  'reason (condition-reason e))])
-                      (process-query! rs recv-buf n client-addr data-file
+                      (process-query! rs recv-buf n client-addr cdb-cache
                         (lambda (out-rs)
                           (send-udp-response! udp-sock out-rs client-addr))
                         MAX-PACKET))))
diff --git a/lib/jerboa-dns/wasm-cdb.ss b/lib/jerboa-dns/wasm-cdb.ss
index 53642e2..9e01a6e 100644
--- a/lib/jerboa-dns/wasm-cdb.ss
+++ b/lib/jerboa-dns/wasm-cdb.ss
@@ -78,6 +78,10 @@
   ;; terminating zero already counted in dns-domain-length.
   (def MAX-KEY 256)
 
+  ;; Keep host allocation bounded before the sandbox gets a chance to
+  ;; inspect the CDB. This matches wasm/cdb/src/lib.rs::CDB_CAP.
+  (def MAX-CDB-BYTES (* 16 1024 1024))
+
   (def (resolve-wasm-path)
     (or (getenv "JDNS_CDB_WASM")
         (find file-exists?
@@ -141,23 +145,46 @@
   ;; bytevector + (jerboa-dns cdb) reader for graceful fallback.
 
   ;; instance: wasm instance handle, or #f for fallback-only; data: raw CDB
-  ;; bytes; cdb-fallback: (jerboa-dns cdb) reader on the same bytes; closed.
-  (defstruct sandboxed-cdb-reader (instance data cdb-fallback closed))
+  ;; bytes; cdb-fallback: (jerboa-dns cdb) reader on the same bytes; closed;
+  ;; lock: serializes the mutable per-query wasm arena and close.
+  (defstruct sandboxed-cdb-reader (instance data cdb-fallback closed lock))
+
+  (def (call-with-mutex mutex thunk)
+    (dynamic-wind
+      (lambda () (mutex-acquire mutex))
+      thunk
+      (lambda () (mutex-release mutex))))
 
   ;; ---------- Construction ----------
 
   (def (open-sandboxed-cdb path)
-    (let* ([size (get-file-size path)]
-           [bv (make-bytevector size)])
-      (call-with-port (open-file-input-port path (file-options) (buffer-mode block))
-        (lambda (p)
-          (let loop ([off 0])
-            (when (< off size)
-              (let ([n (get-bytevector-n! p bv off (- size off))])
-                (loop (+ off n)))))))
-      (open-sandboxed-cdb/bytevector bv)))
+    ;; Size and read the same open file description. Atomic rename can swap
+    ;; the pathname while this runs, but it cannot splice two CDB versions
+    ;; into this bytevector. The cache performs a before/after identity check
+    ;; before publishing the resulting reader.
+    (call-with-port
+      (open-file-input-port path (file-options) (buffer-mode block))
+      (lambda (p)
+        (let ([size (port-length p)])
+          (when (or (< size 2048) (> size MAX-CDB-BYTES))
+            (error 'open-sandboxed-cdb
+                   "CDB size outside supported range"
+                   size MAX-CDB-BYTES))
+          (let ([bv (make-bytevector size)])
+            (let loop ([off 0])
+              (when (< off size)
+                (let ([n (get-bytevector-n! p bv off (- size off))])
+                  (when (or (eof-object? n) (zero? n))
+                    (error 'open-sandboxed-cdb
+                           "CDB changed or truncated while reading" path))
+                  (loop (+ off n)))))
+            (open-sandboxed-cdb/bytevector bv))))))
 
   (def (open-sandboxed-cdb/bytevector bv)
+    ;; Validate the complete CDB envelope even when the optional WASM runtime
+    ;; is unavailable. A malformed hot-reload candidate must never replace a
+    ;; known-good reader merely because fallback mode is active.
+    (validate-cdb-bytevector! bv)
     (let* ([fallback (open-cdb-reader/bytevector bv)]
            [inst (try-load-into-sandbox bv)])
       (when (and (not inst) (require-sandbox?))
@@ -169,7 +196,7 @@
             [(not (wasm-sandbox-available?)) "libjerboa_native not loaded"]
             [(not (file-exists? (resolve-wasm-path))) "wasm module missing on disk"]
             [else "cdb_finalize rejected the file"])))
-      (make-sandboxed-cdb-reader inst bv fallback #f)))
+      (make-sandboxed-cdb-reader inst bv fallback #f (make-mutex))))
 
   (def (try-load-into-sandbox bv)
     (and (sandbox-available?)
@@ -205,11 +232,62 @@
           (catch (e) (wasm-sandbox-free inst) #f)))
       (catch (e) #f)))
 
-  (def (get-file-size path)
-    (let ([p (open-file-input-port path)])
-      (let ([len (port-length p)])
-        (close-port p)
-        len)))
+  ;; ---------- CDB envelope validation ----------
+
+  (def (cdb-le32 bv off)
+    (let ([len (bytevector-length bv)])
+      (when (or (negative? off) (> (+ off 4) len))
+        (error 'validate-cdb-bytevector! "truncated uint32" off len))
+      (bitwise-ior
+        (bytevector-u8-ref bv off)
+        (bitwise-arithmetic-shift-left (bytevector-u8-ref bv (+ off 1)) 8)
+        (bitwise-arithmetic-shift-left (bytevector-u8-ref bv (+ off 2)) 16)
+        (bitwise-arithmetic-shift-left (bytevector-u8-ref bv (+ off 3)) 24))))
+
+  (def (validate-cdb-bytevector! bv)
+    (let ([len (bytevector-length bv)])
+      (when (or (< len 2048) (> len MAX-CDB-BYTES))
+        (error 'validate-cdb-bytevector!
+               "CDB size outside supported range" len MAX-CDB-BYTES))
+
+      ;; The first non-empty hash table marks the end of the record area.
+      ;; Every table and every referenced record must remain within the file.
+      (let ([records-end len])
+        (do ([i 0 (+ i 1)]) ((= i 256))
+          (let* ([header-off (* i 8)]
+                 [table-pos (cdb-le32 bv header-off)]
+                 [table-count (cdb-le32 bv (+ header-off 4))]
+                 [table-bytes (* table-count 8)])
+            (when (or (> table-pos len) (> table-bytes (- len table-pos)))
+              (error 'validate-cdb-bytevector!
+                     "CDB hash table outside file" i table-pos table-count len))
+            (when (> table-count 0)
+              (set! records-end (min records-end table-pos)))))
+
+        (when (< records-end 2048)
+          (error 'validate-cdb-bytevector!
+                 "CDB record area overlaps header" records-end))
+
+        (do ([i 0 (+ i 1)]) ((= i 256) #t)
+          (let* ([header-off (* i 8)]
+                 [table-pos (cdb-le32 bv header-off)]
+                 [table-count (cdb-le32 bv (+ header-off 4))])
+            (do ([slot 0 (+ slot 1)]) ((= slot table-count))
+              (let* ([entry-off (+ table-pos (* slot 8))]
+                     [entry-pos (cdb-le32 bv (+ entry-off 4))])
+                (unless (zero? entry-pos)
+                  (when (or (< entry-pos 2048) (> (+ entry-pos 8) records-end))
+                    (error 'validate-cdb-bytevector!
+                           "CDB record header outside record area"
+                           i slot entry-pos records-end))