Resolve security audit findings

ober

ba9a96fd29e7ba944f02010da2ad1fe022a1e4ef

diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 791c1a7..72d67ac 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -8,7 +8,7 @@ jobs:
   test:
     runs-on: ubuntu-latest
     steps:
-      - uses: actions/checkout@v4
+      - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1
       - name: Install packages
         run: |
           sudo apt-get update
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/.gitignore b/.gitignore
index ebb2e92..074d7fa 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,5 +1,6 @@
 *.so
 *.dylib
+*.o
 *.wpo
 .jerbuild-hashes
 .jerboa/*
diff --git a/Makefile b/Makefile
index 589aa8d..6f8de80 100644
--- a/Makefile
+++ b/Makefile
@@ -1,6 +1,7 @@
 JERBOA_VERSION ?= v0.2.3
 JERBOA_TOOL_DIR ?= $(CURDIR)/.jerboa/bin
 JERBUILD ?= $(shell if [ -x "$(CURDIR)/../jerboa/dist/jerbuild" ]; then printf '%s\n' "$(CURDIR)/../jerboa/dist/jerbuild"; elif command -v jerbuild >/dev/null 2>&1; then command -v jerbuild; else printf '%s\n' "$(JERBOA_TOOL_DIR)/jerbuild"; fi)
+JERBUILD_ABS = $(abspath $(JERBUILD))
 JH = $(shell "$(JERBUILD)" --jerboa-home 2>/dev/null)
 
 LIBDIRS      = lib:$(JH)/lib
@@ -10,13 +11,14 @@ RELEASE_EVIDENCE_DIR ?= $(DIST_DIR)/release-evidence
 
 QT_VENDOR_DIR ?= $(CURDIR)/../qt-vendor
 QT_FALLBACK_DIR ?= $(CURDIR)/../jerboa-emacs
-QT_SHIM_INCLUDE_DIR ?= $(firstword $(wildcard $(QT_VENDOR_DIR)/vendor) $(wildcard $(QT_FALLBACK_DIR)/support/vendor-overrides) $(wildcard $(QT_FALLBACK_DIR)/vendor))
-QT_SHIM_LIB_DIR     ?= $(firstword $(wildcard $(QT_VENDOR_DIR)/vendor) $(wildcard $(QT_FALLBACK_DIR)))
+QT_SHIM_INCLUDE_DIR ?= $(realpath $(firstword $(wildcard $(QT_VENDOR_DIR)/vendor) $(wildcard $(QT_FALLBACK_DIR)/support/vendor-overrides) $(wildcard $(QT_FALLBACK_DIR)/vendor)))
+QT_SHIM_LIB_DIR     ?= $(realpath $(firstword $(wildcard $(QT_VENDOR_DIR)/vendor) $(wildcard $(QT_FALLBACK_DIR))))
 
 QT_CFLAGS  := $(shell pkg-config --cflags Qt6Widgets 2>/dev/null)
 QT_LIBS    := $(shell pkg-config --libs Qt6Widgets 2>/dev/null || echo "-lQt6Widgets")
 
 CC ?= cc
+CXX ?= c++
 WARN_CFLAGS ?= -Wall -Wextra -Wformat=2 -Wshadow -Wpointer-arith -Wcast-align -Wwrite-strings
 HARDEN_CFLAGS ?= -O2 -fPIC -fstack-protector-strong -D_FORTIFY_SOURCE=2
 CFLAGS ?= $(HARDEN_CFLAGS) $(WARN_CFLAGS)
@@ -25,20 +27,28 @@ UNAME_S := $(shell uname -s)
 ifeq ($(UNAME_S),Darwin)
   SHARED_FLAG = -dynamiclib
   SHLIB_EXT   = .dylib
-  LD_VAR      = DYLD_LIBRARY_PATH
-  HARDEN_LDFLAGS ?= -Wl,-no_uuid
+  # Modern dyld rejects dlopen() of a dylib without LC_UUID.  Deterministic
+  # evidence hashes the rebuilt artifact instead of stripping this required
+  # load command.
+  HARDEN_LDFLAGS ?= -Wl,-undefined,dynamic_lookup
+  ASAN_RUN_OPTIONS ?= halt_on_error=1
 else
   SHARED_FLAG = -shared
   SHLIB_EXT   = .so
-  LD_VAR      = LD_LIBRARY_PATH
   HARDEN_LDFLAGS ?= -Wl,-z,relro,-z,now
+  ASAN_RUN_OPTIONS ?= detect_leaks=1:halt_on_error=1
 endif
 
 SHIM := jerboa_qt_shim$(SHLIB_EXT)
+SHIM_OBJS := jerboa_qt_shim.o callback_queue.o jerboa_qt_lifecycle.o
+CALLBACK_QUEUE_TEST := .jerboa/callback-queue-test
+CALLBACK_QUEUE_ASAN_TEST := .jerboa/callback-queue-asan-test
+CALLBACK_QUEUE_TSAN_TEST := .jerboa/callback-queue-tsan-test
 
 .PHONY: all build native transpile security test test-pure test-native example-smoke clean shim vendor-check \
 	ensure-jerboa-tools audit qt-shim-provenance-check qt-shim-provenance-enforce \
-	qt-advisory-check qt-advisory-enforce native-boundary-corpus sbom reproducibility-report target-evidence verify release-evidence
+	qt-advisory-check qt-advisory-enforce native-boundary-corpus sbom reproducibility-report target-evidence verify release-evidence \
+	test-callback-queue test-callback-queue-asan test-callback-queue-tsan test-loader-policy
 
 all: build
 
@@ -64,8 +74,17 @@ native: shim
 security:
 	@sh scripts/security-check.sh
 
-$(SHIM): jerboa_qt_shim.c Makefile
-	$(CC) $(CFLAGS) $(SHARED_FLAG) -I$(QT_SHIM_INCLUDE_DIR) -o $@ $< \
+jerboa_qt_shim.o: jerboa_qt_shim.c callback_queue.h Makefile
+	$(CC) $(CFLAGS) -pthread -I$(QT_SHIM_INCLUDE_DIR) -I. -c -o $@ $<
+
+callback_queue.o: callback_queue.c callback_queue.h Makefile
+	$(CC) $(CFLAGS) -pthread -I. -c -o $@ $<
+
+jerboa_qt_lifecycle.o: jerboa_qt_lifecycle.cpp callback_queue.h Makefile
+	$(CXX) -std=c++17 $(CFLAGS) $(QT_CFLAGS) -pthread -I. -c -o $@ $<
+
+$(SHIM): $(SHIM_OBJS) Makefile
+	$(CXX) $(CFLAGS) $(SHARED_FLAG) -o $@ $(SHIM_OBJS) \
 		-L$(QT_SHIM_LIB_DIR) -lqt_shim -Wl,-rpath,$(QT_SHIM_LIB_DIR) $(QT_LIBS) $(HARDEN_LDFLAGS)
 
 transpile: ensure-jerboa-tools
@@ -78,24 +97,53 @@ test: test-pure
 test-pure: transpile
 	$(JERBUILD) exec --libdirs "$(LIBDIRS)" tests/pure.ss
 
+test-loader-policy: transpile shim
+	@REPO_ROOT="$(CURDIR)" \
+	JERBUILD="$(JERBUILD_ABS)" \
+	LIBDIRS="$(CURDIR)/lib:$(JH)/lib" \
+	QT_SHIM_LIB_DIR="$(QT_SHIM_LIB_DIR)" \
+	SHLIB_EXT="$(SHLIB_EXT)" \
+	sh scripts/loader-policy-test.sh
+
+test-callback-queue: $(CALLBACK_QUEUE_TEST)
+	$(CALLBACK_QUEUE_TEST)
+
+$(CALLBACK_QUEUE_TEST): callback_queue.c callback_queue.h tests/callback_queue_test.c
+	@mkdir -p .jerboa
+	$(CC) -std=c11 -O2 $(WARN_CFLAGS) -pthread -I. \
+		callback_queue.c tests/callback_queue_test.c -o $@
+
+test-callback-queue-asan: callback_queue.c callback_queue.h tests/callback_queue_test.c
+	@mkdir -p .jerboa
+	$(CC) -std=c11 -O1 $(WARN_CFLAGS) -pthread -I. \
+		-fsanitize=address,undefined -fno-omit-frame-pointer \
+		callback_queue.c tests/callback_queue_test.c -o $(CALLBACK_QUEUE_ASAN_TEST)
+	ASAN_OPTIONS=$(ASAN_RUN_OPTIONS) UBSAN_OPTIONS=halt_on_error=1 \
+		$(CALLBACK_QUEUE_ASAN_TEST)
+
+test-callback-queue-tsan: callback_queue.c callback_queue.h tests/callback_queue_test.c
+	@mkdir -p .jerboa
+	$(CC) -std=c11 -O1 $(WARN_CFLAGS) -pthread -I. \
+		-fsanitize=thread -fno-omit-frame-pointer \
+		callback_queue.c tests/callback_queue_test.c -o $(CALLBACK_QUEUE_TSAN_TEST)
+	TSAN_OPTIONS=halt_on_error=1 $(CALLBACK_QUEUE_TSAN_TEST)
+
 test-native: transpile shim
-	JERBOA_QT_LIB=$(CURDIR) \
+	JERBOA_QT_DEV_NATIVE=1 JERBOA_QT_LIB=$(CURDIR) \
 	JERBOA_QT_SHIM_DIR=$(QT_SHIM_LIB_DIR) \
-	$(LD_VAR)=$(CURDIR):$(QT_SHIM_LIB_DIR) \
 	QT_QPA_PLATFORM=offscreen \
 	$(JERBUILD) exec --libdirs "$(LIBDIRS)" qt-test.ss
 
 native-boundary-corpus: transpile shim support/native-boundary-corpus.ss
-	JERBOA_QT_LIB=$(CURDIR) \
+	JERBOA_QT_DEV_NATIVE=1 JERBOA_QT_LIB=$(CURDIR) \
 	JERBOA_QT_SHIM_DIR=$(QT_SHIM_LIB_DIR) \
-	$(LD_VAR)=$(CURDIR):$(QT_SHIM_LIB_DIR) \
 	QT_QPA_PLATFORM=offscreen \
 	QT_LOGGING_RULES=qt.qpa.fonts=false \
 	$(JERBUILD) exec --libdirs "$(LIBDIRS)" support/native-boundary-corpus.ss
 
 example-smoke: transpile shim
 	@rm -f /tmp/jerboa-qt-example-hello.out /tmp/jerboa-qt-example-hello.err
-	@QT_QPA_PLATFORM=offscreen JERBOA_QT_SHIM_DIR="$(QT_SHIM_LIB_DIR)" \
+	@QT_QPA_PLATFORM=offscreen JERBOA_QT_DEV_NATIVE=1 JERBOA_QT_SHIM_DIR="$(QT_SHIM_LIB_DIR)" \
 		examples/hello.ss >/tmp/jerboa-qt-example-hello.out 2>/tmp/jerboa-qt-example-hello.err & \
 	pid=$$!; \
 	sleep 3; \
@@ -218,5 +266,6 @@ release-evidence: security
 
 clean:
 	rm -f jerboa_qt_shim.so jerboa_qt_shim.dylib qt_chez_shim.so qt_chez_shim.dylib
+	rm -f $(SHIM_OBJS) $(CALLBACK_QUEUE_TEST) $(CALLBACK_QUEUE_ASAN_TEST) $(CALLBACK_QUEUE_TSAN_TEST)
 	rm -rf lib dist .jerboa/bin
 	-rmdir .jerboa 2>/dev/null || true
diff --git a/README.md b/README.md
index 00c2615..881d507 100644
--- a/README.md
+++ b/README.md
@@ -83,7 +83,9 @@ Native tests run headless using Qt's offscreen platform plugin (`QT_QPA_PLATFORM
 `docs/ffi-boundary.md`, `docs/shim-provenance.md`,
 `docs/qt-advisory-review.md`, and `docs/threat-model.md` are complete.
 
-The Scheme modules are designed to import without loading native Qt libraries. Native libraries are resolved lazily on first FFI use, Scheme strings with embedded NUL bytes are rejected before native calls, and callback trampolines are registered when `qt-app-create` is called.
+The Scheme modules are designed to import without loading native Qt libraries. Native libraries are resolved lazily on first FFI use, but dynamic loading requires canonical absolute `JERBOA_QT_SHIM_DIR` and `JERBOA_QT_LIB` directories whose final library files are regular non-symlinks. Static builds are detected from pre-registered representative symbols. Scheme strings with embedded NUL bytes are rejected before native calls.
+
+Qt's native thread never enters Chez. Signal values are copied into a bounded native queue and `qt-app-exec!` or `qt-pump-events!` drains that queue on the thread that created the application. Callback registration, removal, and execution from any other Jerboa thread are rejected.
 
 `make release-evidence` records pure/native tests, native-boundary corpus
 evidence, native linkage, Qt advisory status, external shim provenance,
@@ -151,9 +153,9 @@ Or invoke manually without the shebang:
 
 ```sh
 JH="$(jerbuild --jerboa-home)"
+JERBOA_QT_DEV_NATIVE=1 \
 JERBOA_QT_LIB="$PWD" \
 JERBOA_QT_SHIM_DIR="${JERBOA_QT_SHIM_DIR:-$HOME/mine/jerboa-emacs}" \
-DYLD_LIBRARY_PATH="$PWD:${JERBOA_QT_SHIM_DIR:-$HOME/mine/jerboa-emacs}:${DYLD_LIBRARY_PATH:-}" \
 QT_QPA_PLATFORM=offscreen \
 jerbuild exec --libdirs "lib:$JH/lib" examples/hello.ss
 ```
@@ -1594,28 +1596,30 @@ RAII-style macros that guarantee cleanup even on exceptions.
 
 ## Architecture
 
-The binding uses a three-layer architecture:
+The binding uses a four-layer architecture:
 
 1. **C++ shim** (`vendor/qt_shim.h`, `vendor/qt_shim.cpp` in qt-vendor) -- thin `extern "C"` wrappers around Qt6 C++ classes. All Qt objects are opaque `void*` handles. Compiled as `libqt_shim.so`.
 
-2. **Chez callback bridge** (`jerboa_qt_shim.c`) -- stores Chez-provided `foreign-callable` function pointers and provides C wrapper functions that pass them to the C++ shim's signal connection APIs. Unlike Gambit's compile-time `c-define` trampolines, Chez creates callback pointers at runtime.
+2. **Native callback bridge** (`jerboa_qt_shim.c`, `callback_queue.c`, and `jerboa_qt_lifecycle.cpp`) -- Qt callbacks copy bounded scalar/string events into a pthread-safe queue. It never stores or calls a Scheme function pointer. QObject destruction queues priority cleanup events, including parent-driven child teardown.
 
 3. **FFI layer** (`jerboa-qt/ffi.ss`) -- Jerboa FFI bindings for each C function. Imports are pure; `libqt_shim.so` and `jerboa_qt_shim.so` are loaded lazily on first native use, and C entries are resolved lazily with `c-lambda`.
 
-4. **High-level API** (`jerboa-qt/qt.ss`) -- Idiomatic Jerboa wrappers with boolean conversions, the `with-qt-app` macro, and all the `qt-on-*!` signal registration functions. Callback trampolines are registered when `qt-app-create` is called.
+4. **High-level API** (`jerboa-qt/qt.ss`) -- Idiomatic Jerboa wrappers with boolean conversions, the `with-qt-app` macro, and all the `qt-on-*!` signal registration functions. It owns the generation-tagged callback registry on the `qt-app-create` thread.
 
 ### Callback Pattern
 
-Qt signals are connected to Scheme handlers through a callback trampoline system:
+Qt signals are transferred to Scheme handlers through an owner-thread queue:
 
 ```
-Qt signal -> C++ lambda -> chez_qt_*() wrapper -> foreign-callable trampoline
-  -> callback table lookup -> user's Scheme closure
+Qt signal -> C++ lambda -> bounded native queue -> owner-thread drain
+  -> synchronized callback table lookup -> user's Scheme closure
 ```
 
 Four callback types cover all signals: `void`, `string(text)`, `int(value)`, `bool(checked)`.
 
-All `qt-on-*!` signal registration functions return a callback ID (integer). Use `unregister-qt-handler!` to remove a specific callback, or `qt-disconnect-all!` to disconnect all signals from a QObject.
+All `qt-on-*!` signal registration functions return a generation-tagged callback ID (integer). Use `unregister-qt-handler!` to remove a specific callback, or `qt-disconnect-all!` to disconnect all signals from a QObject. Both must run on the application owner thread. `qt-timer-single-shot!` removes its closure before invocation. Explicit destructors and native `QObject::destroyed` events remove registrations exactly once; queued events for removed IDs are ignored.
+
+`qt-app-exec!` drains automatically. Code that pumps Qt manually must call `(qt-pump-events! app)`, which processes Qt events and drains at most one bounded queue batch. `qt-callback-registry-size`, `qt-callback-pending-count`, and `qt-callback-dropped-count` are available for lifecycle/overload monitoring.
 
 ### Memory Management
 
diff --git a/SECURITY.md b/SECURITY.md
index f3921d0..ecd24c5 100644
--- a/SECURITY.md
+++ b/SECURITY.md
@@ -6,10 +6,11 @@ and production-readiness tracker gates are complete for this repository.
 ## Current Posture
 
 - Importing `(jerboa-qt ffi)` and `(jerboa-qt qt)` must not load native Qt libraries.
-- Native libraries and C entries are resolved lazily through `(jerboa ffi)`.
+- Native libraries and C entries are resolved lazily through `(jerboa ffi)`. Dynamic loading accepts only configured absolute directories and regular non-symlink library files; no CWD or bare-name search is used.
 - Scheme string arguments are rejected if they contain embedded NUL bytes.
-- Callback trampolines are registered lazily when `qt-app-create` is called.
-- Public high-level APIs do not expose direct event-queue pumping; prefer `qt-app-exec!` with an optional tick callback.
+- Native Qt callbacks never enter Chez. They copy into a bounded pthread-safe queue that is drained only by the Scheme thread that called `qt-app-create`.
+- Callback registry mutation is synchronized and owner-thread restricted. Generation-tagged IDs, QObject destruction watches, explicit subtree invalidation, and one-shot removal prevent stale closures from outliving native objects.
+- Prefer `qt-app-exec!`; manual loops must use `qt-pump-events!` so both Qt and callback queues are drained.
 - `make qt-shim-provenance-check` records the external `libqt_shim` source,
   provider Git commit, local callback shim, and native binary hashes.
 - `make qt-advisory-check` records whether the local Qt line satisfies the
@@ -36,7 +37,7 @@ Applications built on this library can handle GUI text, editor buffers, clipboar
   `make qt-shim-provenance-enforce`.
 - Enforce the reviewed Qt advisory floor with `make qt-advisory-enforce`.
 - Run hosted native tests on Linux and macOS with Qt offscreen.
-- Run sanitizer builds for `jerboa_qt_shim.c` and the external Qt shim.
+- Retain passing ASan/UBSan queue evidence and run whole-shim/external Qt ThreadSanitizer builds on a supported hosted target.
 - Add regression coverage for null returned strings, NUL inputs, QObject ownership, callback disconnects, QProcess, file dialogs, clipboard, and event-loop reentrancy.
 - Attach marker-complete target proof through `JQT_TARGET_PROOF_FILE` covering
   hosted Linux/macOS native CI, Qt advisory status, external shim provenance,
diff --git a/callback_queue.c b/callback_queue.c
new file mode 100644
index 0000000..f5510f7
--- /dev/null
+++ b/callback_queue.c
@@ -0,0 +1,188 @@
+#include "callback_queue.h"
+
+#include <pthread.h>
+#include <stdlib.h>
+#include <string.h>
+
+/* Callback traffic is deliberately bounded.  A stalled Scheme owner must not
+ * let an untrusted/high-rate GUI event source grow process memory without
+ * limit. */
+#define JERBOA_QT_CALLBACK_QUEUE_CAPACITY 8192U
+#define JERBOA_QT_CALLBACK_STRING_MAX (1024U * 1024U)
+
+struct callback_event {
+    int type;
+    long callback_id;
+    int value;
+    char *string_value;
+};
+
+static pthread_mutex_t callback_mutex = PTHREAD_MUTEX_INITIALIZER;
+static struct callback_event callback_queue[JERBOA_QT_CALLBACK_QUEUE_CAPACITY];
+static size_t callback_head;
+static size_t callback_count;
+static unsigned long callback_dropped;
+
+/* Only the registered Scheme owner thread accesses dispatch_event. */
+static struct callback_event dispatch_event;
+
+static size_t bounded_string_length(const char *value) {
+    size_t length = 0;
+
+    if (value == NULL) {
+        return 0;
+    }
+    while (length <= JERBOA_QT_CALLBACK_STRING_MAX && value[length] != '\0') {
+        ++length;
+    }
+    return length;
+}
+
+static void enqueue_event(int type, long callback_id, int value,
+                          const char *string_value) {
+    char *copy = NULL;
+
+    if (type == JERBOA_QT_CALLBACK_STRING) {
+        const size_t length = bounded_string_length(string_value);
+        if (string_value == NULL || length > JERBOA_QT_CALLBACK_STRING_MAX) {
+            pthread_mutex_lock(&callback_mutex);
+            ++callback_dropped;
+            pthread_mutex_unlock(&callback_mutex);
+            return;
+        }
+        copy = (char *)malloc(length + 1U);
+        if (copy == NULL) {
+            pthread_mutex_lock(&callback_mutex);
+            ++callback_dropped;
+            pthread_mutex_unlock(&callback_mutex);
+            return;
+        }
+        memcpy(copy, string_value, length + 1U);
+    }
+
+    pthread_mutex_lock(&callback_mutex);
+    if (callback_count == JERBOA_QT_CALLBACK_QUEUE_CAPACITY) {
+        ++callback_dropped;
+        pthread_mutex_unlock(&callback_mutex);
+        free(copy);
+        return;
+    }
+
+    {
+        const size_t tail =
+            (callback_head + callback_count) % JERBOA_QT_CALLBACK_QUEUE_CAPACITY;
+        callback_queue[tail].type = type;
+        callback_queue[tail].callback_id = callback_id;
+        callback_queue[tail].value = value;
+        callback_queue[tail].string_value = copy;
+        ++callback_count;
+    }
+    pthread_mutex_unlock(&callback_mutex);
+}
+
+void jerboa_qt_enqueue_void_event(long callback_id) {
+    enqueue_event(JERBOA_QT_CALLBACK_VOID, callback_id, 0, NULL);
+}
+
+void jerboa_qt_enqueue_string_event(long callback_id, const char *value) {
+    enqueue_event(JERBOA_QT_CALLBACK_STRING, callback_id, 0, value);
+}
+
+void jerboa_qt_enqueue_int_event(long callback_id, int value) {
+    enqueue_event(JERBOA_QT_CALLBACK_INT, callback_id, value, NULL);
+}
+
+void jerboa_qt_enqueue_bool_event(long callback_id, int value) {
+    enqueue_event(JERBOA_QT_CALLBACK_BOOL, callback_id, value, NULL);
+}
+
+void jerboa_qt_enqueue_destroyed_event(long callback_id) {
+    pthread_mutex_lock(&callback_mutex);
+    if (callback_count == JERBOA_QT_CALLBACK_QUEUE_CAPACITY) {
+        const size_t tail =
+            (callback_head + callback_count - 1U) %
+            JERBOA_QT_CALLBACK_QUEUE_CAPACITY;
+        free(callback_queue[tail].string_value);
+        memset(&callback_queue[tail], 0, sizeof(callback_queue[tail]));
+        --callback_count;
+        ++callback_dropped;
+    }
+
+    /* Destruction invalidates callbacks that may already be queued for the
+     * same ID, so cleanup events have priority over ordinary FIFO traffic. */
+    callback_head =
+        (callback_head + JERBOA_QT_CALLBACK_QUEUE_CAPACITY - 1U) %
+        JERBOA_QT_CALLBACK_QUEUE_CAPACITY;
+    callback_queue[callback_head].type = JERBOA_QT_CALLBACK_DESTROYED;
+    callback_queue[callback_head].callback_id = callback_id;
+    callback_queue[callback_head].value = 0;
+    callback_queue[callback_head].string_value = NULL;
+    ++callback_count;
+    pthread_mutex_unlock(&callback_mutex);
+}
+
+int jerboa_qt_next_callback_event(void) {
+    free(dispatch_event.string_value);
+    memset(&dispatch_event, 0, sizeof(dispatch_event));
+
+    pthread_mutex_lock(&callback_mutex);
+    if (callback_count == 0U) {
+        pthread_mutex_unlock(&callback_mutex);
+        return JERBOA_QT_CALLBACK_NONE;
+    }
+
+    dispatch_event = callback_queue[callback_head];
+    memset(&callback_queue[callback_head], 0,
+           sizeof(callback_queue[callback_head]));
+    callback_head = (callback_head + 1U) % JERBOA_QT_CALLBACK_QUEUE_CAPACITY;
+    --callback_count;
+    pthread_mutex_unlock(&callback_mutex);
+    return dispatch_event.type;
+}
+
+long jerboa_qt_callback_event_id(void) {
+    return dispatch_event.callback_id;
+}
+
+int jerboa_qt_callback_event_value(void) {
+    return dispatch_event.value;
+}
+
+const char *jerboa_qt_callback_event_string(void) {
+    return dispatch_event.string_value == NULL ? "" : dispatch_event.string_value;
+}
+
+unsigned long jerboa_qt_callback_pending_count(void) {
+    unsigned long result;
+
+    pthread_mutex_lock(&callback_mutex);
+    result = (unsigned long)callback_count;
+    pthread_mutex_unlock(&callback_mutex);
+    return result;
+}
+
+unsigned long jerboa_qt_callback_dropped_count(void) {
+    unsigned long result;
+
+    pthread_mutex_lock(&callback_mutex);
+    result = callback_dropped;
+    pthread_mutex_unlock(&callback_mutex);
+    return result;
+}
+
+void jerboa_qt_clear_callback_events(void) {
+    size_t i;
+
+    pthread_mutex_lock(&callback_mutex);
+    for (i = 0; i < JERBOA_QT_CALLBACK_QUEUE_CAPACITY; ++i) {
+        free(callback_queue[i].string_value);
+        memset(&callback_queue[i], 0, sizeof(callback_queue[i]));
+    }
+    callback_head = 0U;
+    callback_count = 0U;
+    callback_dropped = 0UL;
+    pthread_mutex_unlock(&callback_mutex);
+
+    free(dispatch_event.string_value);
+    memset(&dispatch_event, 0, sizeof(dispatch_event));
+}
diff --git a/callback_queue.h b/callback_queue.h
new file mode 100644
index 0000000..57b4296
--- /dev/null
+++ b/callback_queue.h
@@ -0,0 +1,42 @@
+#ifndef JERBOA_QT_CALLBACK_QUEUE_H
+#define JERBOA_QT_CALLBACK_QUEUE_H
+
+#include <stddef.h>
+
+#ifdef __cplusplus
+extern "C" {
+#endif
+
+enum jerboa_qt_callback_event_type {
+    JERBOA_QT_CALLBACK_NONE = 0,
+    JERBOA_QT_CALLBACK_VOID = 1,
+    JERBOA_QT_CALLBACK_STRING = 2,
+    JERBOA_QT_CALLBACK_INT = 3,
+    JERBOA_QT_CALLBACK_BOOL = 4,
+    JERBOA_QT_CALLBACK_DESTROYED = 5
+};
+
+/* These functions are safe to call from the native Qt thread.  They never
+ * enter Chez or retain a pointer supplied by Qt. */
+void jerboa_qt_enqueue_void_event(long callback_id);
+void jerboa_qt_enqueue_string_event(long callback_id, const char *value);
+void jerboa_qt_enqueue_int_event(long callback_id, int value);
+void jerboa_qt_enqueue_bool_event(long callback_id, int value);
+void jerboa_qt_enqueue_destroyed_event(long callback_id);
+
+/* Exactly one Scheme-owned thread may consume events.  Producers may run on
+ * any pthread.  Accessors describe the event returned by the most recent
+ * successful jerboa_qt_next_callback_event call. */
+int jerboa_qt_next_callback_event(void);
+long jerboa_qt_callback_event_id(void);
+int jerboa_qt_callback_event_value(void);
+const char *jerboa_qt_callback_event_string(void);
+unsigned long jerboa_qt_callback_pending_count(void);
+unsigned long jerboa_qt_callback_dropped_count(void);
+void jerboa_qt_clear_callback_events(void);
+
+#ifdef __cplusplus
+}
+#endif
+
+#endif
diff --git a/docs/ffi-boundary.md b/docs/ffi-boundary.md
index a20ebcf..d51a098 100644
--- a/docs/ffi-boundary.md
+++ b/docs/ffi-boundary.md
@@ -5,14 +5,16 @@
 - `libqt_shim`: the external C++ Qt shim that owns the Qt wrappers.
 - `jerboa_qt_shim`: the local Chez callback bridge for Qt signal callbacks.
 
-The Scheme modules can now be imported without loading native Qt libraries. Native libraries are loaded lazily on the first FFI call through `(jerboa ffi)` `load-shared-object*`, then individual C entries are resolved lazily with `c-lambda`.
+The Scheme modules can be imported without loading native Qt libraries. Native libraries are loaded lazily on the first FFI call through `(jerboa ffi)` `load-shared-object*`, then individual C entries are resolved lazily with `c-lambda`.
 
 ## Current Controls
 
 - Importing `(jerboa-qt ffi)` or `(jerboa-qt qt)` is pure and must not require Qt, X11, Wayland, or a display server.
 - Scheme string arguments are rejected if they contain embedded NUL bytes before native resolution or native calls.
-- Callback trampolines are registered lazily when `qt-app-create` is called.
-- `JERBOA_QT_SHIM_DIR` selects the external shim directory. `JERBOA_QT_LIB` selects the local callback shim directory.
+- Dynamic loading requires canonical absolute `JERBOA_QT_SHIM_DIR` and `JERBOA_QT_LIB` directories. The selected final library files and every path component must resolve without symlink or `..` substitution. There is no current-directory or bare-name fallback. Static builds are recognized only when representative external and local symbols are already registered.
+- The Qt pthread never enters Chez. Native callbacks copy into an 8192-event queue with a 1 MiB per-string limit; overflow is counted and drops the newest ordinary event. Destruction cleanup has priority so stale queued callbacks cannot execute after logical teardown.
+- `qt-app-create` establishes one Scheme owner thread. Registry mutation, event draining, and handler execution from other Jerboa threads fail closed.
+- Callback IDs combine an application generation and sequence. Every registration is associated with each owning QObject, native destruction is watched, and explicit destructors invalidate QObject subtrees before disconnect/delete.
 - `make test` runs pure checks. `make test-native` is the gate for hosted native Qt behavior.
 - `make qt-shim-provenance-check` records external shim source, binary, and
   provider Git metadata.
@@ -28,7 +30,7 @@ The Scheme modules can now be imported without loading native Qt libraries. Nati
 
 Qt owns parented child widgets. Do not manually destroy a child after its parent has been destroyed. Prefer one explicit destroy call at the root widget or use `with-qt-app` for application lifetime.
 
-Callback IDs returned by `qt-on-*!` are Scheme-side references. Use `unregister-qt-handler!` or `qt-disconnect-all!` when disconnecting long-lived objects.
+Callback IDs returned by `qt-on-*!` are Scheme-side references. Use `unregister-qt-handler!` or `qt-disconnect-all!` when disconnecting long-lived objects. Both are idempotent at the logical registry boundary, and native late events for absent IDs are discarded. One-shot timers unregister before invoking user code.
 
 ## Known Gaps
 
@@ -37,8 +39,8 @@ Callback IDs returned by `qt-on-*!` are Scheme-side references. Use `unregister-
 - The Qt advisory floor must stay current. The local release evidence currently
   records Qt 6.11.1, which passes the CVE-2026-6210 floor for the 6.11 line.
 - QScintilla support is optional and must be tested against the exact native shim used by the release.
-- Direct event-queue pumping remains restricted to the low-level FFI boundary. High-level callers should use `qt-app-exec!` with an optional tick callback instead.
+- High-level manual pumping must use `qt-pump-events!`; direct low-level `ffi-qt-app-process-events` does not drain the callback queue.
+- The queue is intentionally lossy at its fixed capacity. Applications should monitor `qt-callback-dropped-count` and treat growth as overload rather than assuming every high-rate UI event is delivered.
 - QProcess, clipboard, file dialog, and filesystem model APIs cross process,
   desktop, and filesystem boundaries and require application-level policy.
-- Production memory/render-time quotas and sanitizer evidence are still
-  required beyond the bounded local native-boundary corpus.
+- The local queue has deterministic concurrency, ASan/UBSan, and lifecycle coverage. Full Qt/Chez ThreadSanitizer proof still requires a supported Linux/macOS Qt runtime built with TSan; external GUI review remains a production gate.
diff --git a/examples/autocomplete.ss b/examples/autocomplete.ss
index 9be6d39..5dcd954 100755
--- a/examples/autocomplete.ss
+++ b/examples/autocomplete.ss
@@ -9,8 +9,7 @@ fi
 QT_SHIM_DIR="${JERBOA_QT_SHIM_DIR:-$HOME/mine/jerboa-emacs}"
 export JERBOA_QT_LIB="$JERBOA_QT_DIR"
 export JERBOA_QT_SHIM_DIR="$QT_SHIM_DIR"
-export DYLD_LIBRARY_PATH="$JERBOA_QT_DIR:$QT_SHIM_DIR:${DYLD_LIBRARY_PATH:-}"
-export LD_LIBRARY_PATH="$JERBOA_QT_DIR:$QT_SHIM_DIR:${LD_LIBRARY_PATH:-}"
+export JERBOA_QT_DEV_NATIVE=1
 exec jerbuild exec --libdirs "$JERBOA_QT_DIR/lib:$JH/lib" "$0" "$@"
 |#
 ;;; autocomplete.ss — QSettings, QCompleter, QToolTip demo
diff --git a/examples/counter.ss b/examples/counter.ss
index 4b530b3..9dc4572 100755
--- a/examples/counter.ss
+++ b/examples/counter.ss
@@ -9,8 +9,7 @@ fi
 QT_SHIM_DIR="${JERBOA_QT_SHIM_DIR:-$HOME/mine/jerboa-emacs}"
 export JERBOA_QT_LIB="$JERBOA_QT_DIR"
 export JERBOA_QT_SHIM_DIR="$QT_SHIM_DIR"
-export DYLD_LIBRARY_PATH="$JERBOA_QT_DIR:$QT_SHIM_DIR:${DYLD_LIBRARY_PATH:-}"
-export LD_LIBRARY_PATH="$JERBOA_QT_DIR:$QT_SHIM_DIR:${LD_LIBRARY_PATH:-}"
+export JERBOA_QT_DEV_NATIVE=1
 exec jerbuild exec --libdirs "$JERBOA_QT_DIR/lib:$JH/lib" "$0" "$@"
 |#
 ;;; Counter example: button click increments a label
diff --git a/examples/dashboard.ss b/examples/dashboard.ss
index 7a83eb7..09c054a 100755
--- a/examples/dashboard.ss
+++ b/examples/dashboard.ss
@@ -9,8 +9,7 @@ fi
 QT_SHIM_DIR="${JERBOA_QT_SHIM_DIR:-$HOME/mine/jerboa-emacs}"
 export JERBOA_QT_LIB="$JERBOA_QT_DIR"
 export JERBOA_QT_SHIM_DIR="$QT_SHIM_DIR"
-export DYLD_LIBRARY_PATH="$JERBOA_QT_DIR:$QT_SHIM_DIR:${DYLD_LIBRARY_PATH:-}"
-export LD_LIBRARY_PATH="$JERBOA_QT_DIR:$QT_SHIM_DIR:${LD_LIBRARY_PATH:-}"
+export JERBOA_QT_DEV_NATIVE=1
 exec jerbuild exec --libdirs "$JERBOA_QT_DIR/lib:$JH/lib" "$0" "$@"
 |#
 ;;; dashboard.ss — Dashboard demo showcasing tabs, lists, tables, sliders
diff --git a/examples/datainput.ss b/examples/datainput.ss
index dbf06e8..5244df5 100755
--- a/examples/datainput.ss
+++ b/examples/datainput.ss
@@ -9,8 +9,7 @@ fi
 QT_SHIM_DIR="${JERBOA_QT_SHIM_DIR:-$HOME/mine/jerboa-emacs}"
 export JERBOA_QT_LIB="$JERBOA_QT_DIR"
 export JERBOA_QT_SHIM_DIR="$QT_SHIM_DIR"
-export DYLD_LIBRARY_PATH="$JERBOA_QT_DIR:$QT_SHIM_DIR:${DYLD_LIBRARY_PATH:-}"
-export LD_LIBRARY_PATH="$JERBOA_QT_DIR:$QT_SHIM_DIR:${LD_LIBRARY_PATH:-}"
+export JERBOA_QT_DEV_NATIVE=1
 exec jerbuild exec --libdirs "$JERBOA_QT_DIR/lib:$JH/lib" "$0" "$@"
 |#
 ;;; datainput.ss — Data input widgets demo
diff --git a/examples/diagram.ss b/examples/diagram.ss
index 4848f87..22f1607 100755
--- a/examples/diagram.ss
+++ b/examples/diagram.ss
@@ -9,8 +9,7 @@ fi
 QT_SHIM_DIR="${JERBOA_QT_SHIM_DIR:-$HOME/mine/jerboa-emacs}"
 export JERBOA_QT_LIB="$JERBOA_QT_DIR"
 export JERBOA_QT_SHIM_DIR="$QT_SHIM_DIR"
-export DYLD_LIBRARY_PATH="$JERBOA_QT_DIR:$QT_SHIM_DIR:${DYLD_LIBRARY_PATH:-}"
-export LD_LIBRARY_PATH="$JERBOA_QT_DIR:$QT_SHIM_DIR:${LD_LIBRARY_PATH:-}"
+export JERBOA_QT_DEV_NATIVE=1
 exec jerbuild exec --libdirs "$JERBOA_QT_DIR/lib:$JH/lib" "$0" "$@"
 |#
 (import (jerboa-qt qt))
diff --git a/examples/dialogs.ss b/examples/dialogs.ss
index 286cf4b..4d70edc 100755
--- a/examples/dialogs.ss
+++ b/examples/dialogs.ss
@@ -9,8 +9,7 @@ fi
 QT_SHIM_DIR="${JERBOA_QT_SHIM_DIR:-$HOME/mine/jerboa-emacs}"
 export JERBOA_QT_LIB="$JERBOA_QT_DIR"
 export JERBOA_QT_SHIM_DIR="$QT_SHIM_DIR"
-export DYLD_LIBRARY_PATH="$JERBOA_QT_DIR:$QT_SHIM_DIR:${DYLD_LIBRARY_PATH:-}"
-export LD_LIBRARY_PATH="$JERBOA_QT_DIR:$QT_SHIM_DIR:${LD_LIBRARY_PATH:-}"
+export JERBOA_QT_DEV_NATIVE=1
 exec jerbuild exec --libdirs "$JERBOA_QT_DIR/lib:$JH/lib" "$0" "$@"
 |#
 ;;; dialogs.ss — Showcase of all dialog types.
diff --git a/examples/dockable.ss b/examples/dockable.ss
index a4bd894..47f34ea 100755
--- a/examples/dockable.ss
+++ b/examples/dockable.ss
@@ -9,8 +9,7 @@ fi
 QT_SHIM_DIR="${JERBOA_QT_SHIM_DIR:-$HOME/mine/jerboa-emacs}"
 export JERBOA_QT_LIB="$JERBOA_QT_DIR"
 export JERBOA_QT_SHIM_DIR="$QT_SHIM_DIR"
-export DYLD_LIBRARY_PATH="$JERBOA_QT_DIR:$QT_SHIM_DIR:${DYLD_LIBRARY_PATH:-}"
-export LD_LIBRARY_PATH="$JERBOA_QT_DIR:$QT_SHIM_DIR:${LD_LIBRARY_PATH:-}"
+export JERBOA_QT_DEV_NATIVE=1
 exec jerbuild exec --libdirs "$JERBOA_QT_DIR/lib:$JH/lib" "$0" "$@"
 |#
 (import (jerboa-qt qt))
diff --git a/examples/dragdrop.ss b/examples/dragdrop.ss
index 73678b1..7194368 100755
--- a/examples/dragdrop.ss
+++ b/examples/dragdrop.ss
@@ -9,8 +9,7 @@ fi
 QT_SHIM_DIR="${JERBOA_QT_SHIM_DIR:-$HOME/mine/jerboa-emacs}"
 export JERBOA_QT_LIB="$JERBOA_QT_DIR"
 export JERBOA_QT_SHIM_DIR="$QT_SHIM_DIR"
-export DYLD_LIBRARY_PATH="$JERBOA_QT_DIR:$QT_SHIM_DIR:${DYLD_LIBRARY_PATH:-}"
-export LD_LIBRARY_PATH="$JERBOA_QT_DIR:$QT_SHIM_DIR:${LD_LIBRARY_PATH:-}"
+export JERBOA_QT_DEV_NATIVE=1
 exec jerbuild exec --libdirs "$JERBOA_QT_DIR/lib:$JH/lib" "$0" "$@"
 |#
 (import (jerboa-qt qt))
diff --git a/examples/editor.ss b/examples/editor.ss
index 7289611..548f223 100755
--- a/examples/editor.ss
+++ b/examples/editor.ss
@@ -9,8 +9,7 @@ fi
 QT_SHIM_DIR="${JERBOA_QT_SHIM_DIR:-$HOME/mine/jerboa-emacs}"
 export JERBOA_QT_LIB="$JERBOA_QT_DIR"
 export JERBOA_QT_SHIM_DIR="$QT_SHIM_DIR"
-export DYLD_LIBRARY_PATH="$JERBOA_QT_DIR:$QT_SHIM_DIR:${DYLD_LIBRARY_PATH:-}"
-export LD_LIBRARY_PATH="$JERBOA_QT_DIR:$QT_SHIM_DIR:${LD_LIBRARY_PATH:-}"
+export JERBOA_QT_DEV_NATIVE=1
 exec jerbuild exec --libdirs "$JERBOA_QT_DIR/lib:$JH/lib" "$0" "$@"
 |#
 ;;; editor.ss — Simple text editor with menus, toolbar, keyboard shortcuts
diff --git a/examples/filebrowser.ss b/examples/filebrowser.ss
index afde40e..21dc9c3 100755
--- a/examples/filebrowser.ss
+++ b/examples/filebrowser.ss
@@ -9,8 +9,7 @@ fi
 QT_SHIM_DIR="${JERBOA_QT_SHIM_DIR:-$HOME/mine/jerboa-emacs}"
 export JERBOA_QT_LIB="$JERBOA_QT_DIR"
 export JERBOA_QT_SHIM_DIR="$QT_SHIM_DIR"
-export DYLD_LIBRARY_PATH="$JERBOA_QT_DIR:$QT_SHIM_DIR:${DYLD_LIBRARY_PATH:-}"
-export LD_LIBRARY_PATH="$JERBOA_QT_DIR:$QT_SHIM_DIR:${LD_LIBRARY_PATH:-}"
+export JERBOA_QT_DEV_NATIVE=1
 exec jerbuild exec --libdirs "$JERBOA_QT_DIR/lib:$JH/lib" "$0" "$@"
 |#
 ;;; File Browser — demonstrates QTreeWidget, QGridLayout, QTimer, clipboard
diff --git a/examples/filemanager.ss b/examples/filemanager.ss
index ff7d47f..9505043 100755
--- a/examples/filemanager.ss
+++ b/examples/filemanager.ss
@@ -9,8 +9,7 @@ fi
 QT_SHIM_DIR="${JERBOA_QT_SHIM_DIR:-$HOME/mine/jerboa-emacs}"
 export JERBOA_QT_LIB="$JERBOA_QT_DIR"
 export JERBOA_QT_SHIM_DIR="$QT_SHIM_DIR"
-export DYLD_LIBRARY_PATH="$JERBOA_QT_DIR:$QT_SHIM_DIR:${DYLD_LIBRARY_PATH:-}"
-export LD_LIBRARY_PATH="$JERBOA_QT_DIR:$QT_SHIM_DIR:${LD_LIBRARY_PATH:-}"
+export JERBOA_QT_DEV_NATIVE=1
 exec jerbuild exec --libdirs "$JERBOA_QT_DIR/lib:$JH/lib" "$0" "$@"
 |#
 ;;; filemanager.ss — File system browser using QFileSystemModel + QTreeView
diff --git a/examples/form.ss b/examples/form.ss
index 29c6715..2414c60 100755
--- a/examples/form.ss
+++ b/examples/form.ss
@@ -9,8 +9,7 @@ fi
 QT_SHIM_DIR="${JERBOA_QT_SHIM_DIR:-$HOME/mine/jerboa-emacs}"
 export JERBOA_QT_LIB="$JERBOA_QT_DIR"
 export JERBOA_QT_SHIM_DIR="$QT_SHIM_DIR"
-export DYLD_LIBRARY_PATH="$JERBOA_QT_DIR:$QT_SHIM_DIR:${DYLD_LIBRARY_PATH:-}"
-export LD_LIBRARY_PATH="$JERBOA_QT_DIR:$QT_SHIM_DIR:${LD_LIBRARY_PATH:-}"
+export JERBOA_QT_DEV_NATIVE=1
 exec jerbuild exec --libdirs "$JERBOA_QT_DIR/lib:$JH/lib" "$0" "$@"
 |#
 ;;; Form example: exercises Phase 2 widgets
diff --git a/examples/hello.ss b/examples/hello.ss
index c16bc39..5bdcf14 100755
--- a/examples/hello.ss
+++ b/examples/hello.ss
@@ -9,8 +9,7 @@ fi
 QT_SHIM_DIR="${JERBOA_QT_SHIM_DIR:-$HOME/mine/jerboa-emacs}"
 export JERBOA_QT_LIB="$JERBOA_QT_DIR"
 export JERBOA_QT_SHIM_DIR="$QT_SHIM_DIR"
-export DYLD_LIBRARY_PATH="$JERBOA_QT_DIR:$QT_SHIM_DIR:${DYLD_LIBRARY_PATH:-}"
-export LD_LIBRARY_PATH="$JERBOA_QT_DIR:$QT_SHIM_DIR:${LD_LIBRARY_PATH:-}"
+export JERBOA_QT_DEV_NATIVE=1
 exec jerbuild exec --libdirs "$JERBOA_QT_DIR/lib:$JH/lib" "$0" "$@"
 |#
 ;;; Minimal jerboa-qt example: window with a label
diff --git a/examples/keyboard.ss b/examples/keyboard.ss
index 2adce75..f28148c 100755
--- a/examples/keyboard.ss
+++ b/examples/keyboard.ss
@@ -9,8 +9,7 @@ fi
 QT_SHIM_DIR="${JERBOA_QT_SHIM_DIR:-$HOME/mine/jerboa-emacs}"
 export JERBOA_QT_LIB="$JERBOA_QT_DIR"
 export JERBOA_QT_SHIM_DIR="$QT_SHIM_DIR"
-export DYLD_LIBRARY_PATH="$JERBOA_QT_DIR:$QT_SHIM_DIR:${DYLD_LIBRARY_PATH:-}"
-export LD_LIBRARY_PATH="$JERBOA_QT_DIR:$QT_SHIM_DIR:${LD_LIBRARY_PATH:-}"
+export JERBOA_QT_DEV_NATIVE=1
 exec jerbuild exec --libdirs "$JERBOA_QT_DIR/lib:$JH/lib" "$0" "$@"
 |#
 ;;; keyboard.ss — Key event viewer and shortcut demo.
diff --git a/examples/mdi.ss b/examples/mdi.ss
index 132c503..5cec801 100755
--- a/examples/mdi.ss
+++ b/examples/mdi.ss
@@ -9,8 +9,7 @@ fi
 QT_SHIM_DIR="${JERBOA_QT_SHIM_DIR:-$HOME/mine/jerboa-emacs}"
 export JERBOA_QT_LIB="$JERBOA_QT_DIR"
 export JERBOA_QT_SHIM_DIR="$QT_SHIM_DIR"
-export DYLD_LIBRARY_PATH="$JERBOA_QT_DIR:$QT_SHIM_DIR:${DYLD_LIBRARY_PATH:-}"
-export LD_LIBRARY_PATH="$JERBOA_QT_DIR:$QT_SHIM_DIR:${LD_LIBRARY_PATH:-}"
+export JERBOA_QT_DEV_NATIVE=1
 exec jerbuild exec --libdirs "$JERBOA_QT_DIR/lib:$JH/lib" "$0" "$@"
 |#
 (import (jerboa-qt qt))
diff --git a/examples/modelviewer.ss b/examples/modelviewer.ss
index 0a17c27..342b071 100755
--- a/examples/modelviewer.ss
+++ b/examples/modelviewer.ss
@@ -9,8 +9,7 @@ fi
 QT_SHIM_DIR="${JERBOA_QT_SHIM_DIR:-$HOME/mine/jerboa-emacs}"
 export JERBOA_QT_LIB="$JERBOA_QT_DIR"
 export JERBOA_QT_SHIM_DIR="$QT_SHIM_DIR"
-export DYLD_LIBRARY_PATH="$JERBOA_QT_DIR:$QT_SHIM_DIR:${DYLD_LIBRARY_PATH:-}"
-export LD_LIBRARY_PATH="$JERBOA_QT_DIR:$QT_SHIM_DIR:${LD_LIBRARY_PATH:-}"
+export JERBOA_QT_DEV_NATIVE=1
 exec jerbuild exec --libdirs "$JERBOA_QT_DIR/lib:$JH/lib" "$0" "$@"
 |#
 ;;; modelviewer.ss — Model/View demo
diff --git a/examples/painter.ss b/examples/painter.ss
index 4e6f055..f744a6e 100755
--- a/examples/painter.ss
+++ b/examples/painter.ss
@@ -9,8 +9,7 @@ fi
 QT_SHIM_DIR="${JERBOA_QT_SHIM_DIR:-$HOME/mine/jerboa-emacs}"
 export JERBOA_QT_LIB="$JERBOA_QT_DIR"
 export JERBOA_QT_SHIM_DIR="$QT_SHIM_DIR"
-export DYLD_LIBRARY_PATH="$JERBOA_QT_DIR:$QT_SHIM_DIR:${DYLD_LIBRARY_PATH:-}"
-export LD_LIBRARY_PATH="$JERBOA_QT_DIR:$QT_SHIM_DIR:${LD_LIBRARY_PATH:-}"
+export JERBOA_QT_DEV_NATIVE=1
 exec jerbuild exec --libdirs "$JERBOA_QT_DIR/lib:$JH/lib" "$0" "$@"
 |#
 ;;; painter.ss — QPainter demo: draw shapes, text, and composited images
diff --git a/examples/planner.ss b/examples/planner.ss
index 5b9aeae..8395395 100755
--- a/examples/planner.ss
+++ b/examples/planner.ss
@@ -9,8 +9,7 @@ fi
 QT_SHIM_DIR="${JERBOA_QT_SHIM_DIR:-$HOME/mine/jerboa-emacs}"
 export JERBOA_QT_LIB="$JERBOA_QT_DIR"
 export JERBOA_QT_SHIM_DIR="$QT_SHIM_DIR"
-export DYLD_LIBRARY_PATH="$JERBOA_QT_DIR:$QT_SHIM_DIR:${DYLD_LIBRARY_PATH:-}"
-export LD_LIBRARY_PATH="$JERBOA_QT_DIR:$QT_SHIM_DIR:${LD_LIBRARY_PATH:-}"
+export JERBOA_QT_DEV_NATIVE=1
 exec jerbuild exec --libdirs "$JERBOA_QT_DIR/lib:$JH/lib" "$0" "$@"
 |#
 ;;; planner.ss — QFormLayout, QCalendarWidget, QTextBrowser,
diff --git a/examples/polished.ss b/examples/polished.ss
index 29a778b..5832df6 100755
--- a/examples/polished.ss
+++ b/examples/polished.ss
@@ -9,8 +9,7 @@ fi
 QT_SHIM_DIR="${JERBOA_QT_SHIM_DIR:-$HOME/mine/jerboa-emacs}"
 export JERBOA_QT_LIB="$JERBOA_QT_DIR"
 export JERBOA_QT_SHIM_DIR="$QT_SHIM_DIR"
-export DYLD_LIBRARY_PATH="$JERBOA_QT_DIR:$QT_SHIM_DIR:${DYLD_LIBRARY_PATH:-}"
-export LD_LIBRARY_PATH="$JERBOA_QT_DIR:$QT_SHIM_DIR:${LD_LIBRARY_PATH:-}"
+export JERBOA_QT_DEV_NATIVE=1
 exec jerbuild exec --libdirs "$JERBOA_QT_DIR/lib:$JH/lib" "$0" "$@"
 |#
 ;;; polished.ss — Practical Polish demo
diff --git a/examples/richtext.ss b/examples/richtext.ss
index 6d05669..c7ae538 100755
--- a/examples/richtext.ss
+++ b/examples/richtext.ss
@@ -9,8 +9,7 @@ fi
 QT_SHIM_DIR="${JERBOA_QT_SHIM_DIR:-$HOME/mine/jerboa-emacs}"
 export JERBOA_QT_LIB="$JERBOA_QT_DIR"
 export JERBOA_QT_SHIM_DIR="$QT_SHIM_DIR"
-export DYLD_LIBRARY_PATH="$JERBOA_QT_DIR:$QT_SHIM_DIR:${DYLD_LIBRARY_PATH:-}"
-export LD_LIBRARY_PATH="$JERBOA_QT_DIR:$QT_SHIM_DIR:${LD_LIBRARY_PATH:-}"
+export JERBOA_QT_DEV_NATIVE=1
 exec jerbuild exec --libdirs "$JERBOA_QT_DIR/lib:$JH/lib" "$0" "$@"
 |#
 ;;; richtext.ss — HTML viewer with font/color customization
diff --git a/examples/settings.ss b/examples/settings.ss
index 4e515aa..f600291 100755
--- a/examples/settings.ss
+++ b/examples/settings.ss
@@ -9,8 +9,7 @@ fi
 QT_SHIM_DIR="${JERBOA_QT_SHIM_DIR:-$HOME/mine/jerboa-emacs}"
 export JERBOA_QT_LIB="$JERBOA_QT_DIR"
 export JERBOA_QT_SHIM_DIR="$QT_SHIM_DIR"
-export DYLD_LIBRARY_PATH="$JERBOA_QT_DIR:$QT_SHIM_DIR:${DYLD_LIBRARY_PATH:-}"
-export LD_LIBRARY_PATH="$JERBOA_QT_DIR:$QT_SHIM_DIR:${LD_LIBRARY_PATH:-}"
+export JERBOA_QT_DEV_NATIVE=1
 exec jerbuild exec --libdirs "$JERBOA_QT_DIR/lib:$JH/lib" "$0" "$@"
 |#
 ;;; settings.ss — Radio buttons, button groups, group boxes
diff --git a/examples/styled.ss b/examples/styled.ss
index c740690..54ff380 100755
--- a/examples/styled.ss
+++ b/examples/styled.ss
@@ -9,8 +9,7 @@ fi
 QT_SHIM_DIR="${JERBOA_QT_SHIM_DIR:-$HOME/mine/jerboa-emacs}"
 export JERBOA_QT_LIB="$JERBOA_QT_DIR"
 export JERBOA_QT_SHIM_DIR="$QT_SHIM_DIR"
-export DYLD_LIBRARY_PATH="$JERBOA_QT_DIR:$QT_SHIM_DIR:${DYLD_LIBRARY_PATH:-}"
-export LD_LIBRARY_PATH="$JERBOA_QT_DIR:$QT_SHIM_DIR:${LD_LIBRARY_PATH:-}"
+export JERBOA_QT_DEV_NATIVE=1
 exec jerbuild exec --libdirs "$JERBOA_QT_DIR/lib:$JH/lib" "$0" "$@"
 |#
 ;;; styled.ss — Dark-themed split-pane app with scroll area, key events
diff --git a/examples/terminal.ss b/examples/terminal.ss
index d82a1e2..968c79e 100755
--- a/examples/terminal.ss
+++ b/examples/terminal.ss
@@ -9,8 +9,7 @@ fi
 QT_SHIM_DIR="${JERBOA_QT_SHIM_DIR:-$HOME/mine/jerboa-emacs}"
 export JERBOA_QT_LIB="$JERBOA_QT_DIR"
 export JERBOA_QT_SHIM_DIR="$QT_SHIM_DIR"
-export DYLD_LIBRARY_PATH="$JERBOA_QT_DIR:$QT_SHIM_DIR:${DYLD_LIBRARY_PATH:-}"
-export LD_LIBRARY_PATH="$JERBOA_QT_DIR:$QT_SHIM_DIR:${LD_LIBRARY_PATH:-}"
+export JERBOA_QT_DEV_NATIVE=1
 exec jerbuild exec --libdirs "$JERBOA_QT_DIR/lib:$JH/lib" "$0" "$@"
 |#
 ;;; terminal.ss — Simple terminal demonstrating QProcess with QPlainTextEdit
diff --git a/examples/trayapp.ss b/examples/trayapp.ss
index f253a67..eaf93c5 100755
--- a/examples/trayapp.ss
+++ b/examples/trayapp.ss
@@ -9,8 +9,7 @@ fi
 QT_SHIM_DIR="${JERBOA_QT_SHIM_DIR:-$HOME/mine/jerboa-emacs}"
 export JERBOA_QT_LIB="$JERBOA_QT_DIR"
 export JERBOA_QT_SHIM_DIR="$QT_SHIM_DIR"
-export DYLD_LIBRARY_PATH="$JERBOA_QT_DIR:$QT_SHIM_DIR:${DYLD_LIBRARY_PATH:-}"
-export LD_LIBRARY_PATH="$JERBOA_QT_DIR:$QT_SHIM_DIR:${LD_LIBRARY_PATH:-}"
+export JERBOA_QT_DEV_NATIVE=1
 exec jerbuild exec --libdirs "$JERBOA_QT_DIR/lib:$JH/lib" "$0" "$@"
 |#
 (import (jerboa-qt qt))
diff --git a/examples/widgets.ss b/examples/widgets.ss
index a07fa14..85925fe 100755
--- a/examples/widgets.ss
+++ b/examples/widgets.ss
@@ -9,8 +9,7 @@ fi
 QT_SHIM_DIR="${JERBOA_QT_SHIM_DIR:-$HOME/mine/jerboa-emacs}"
 export JERBOA_QT_LIB="$JERBOA_QT_DIR"
 export JERBOA_QT_SHIM_DIR="$QT_SHIM_DIR"
-export DYLD_LIBRARY_PATH="$JERBOA_QT_DIR:$QT_SHIM_DIR:${DYLD_LIBRARY_PATH:-}"
-export LD_LIBRARY_PATH="$JERBOA_QT_DIR:$QT_SHIM_DIR:${LD_LIBRARY_PATH:-}"
+export JERBOA_QT_DEV_NATIVE=1
 exec jerbuild exec --libdirs "$JERBOA_QT_DIR/lib:$JH/lib" "$0" "$@"
 |#
 ;;; widgets.ss — Niche widgets demo: QDial, QLCDNumber, QToolBox, QUndoStack
diff --git a/examples/wizard.ss b/examples/wizard.ss
index 99ea700..52c2a89 100755
--- a/examples/wizard.ss
+++ b/examples/wizard.ss
@@ -9,8 +9,7 @@ fi
 QT_SHIM_DIR="${JERBOA_QT_SHIM_DIR:-$HOME/mine/jerboa-emacs}"
 export JERBOA_QT_LIB="$JERBOA_QT_DIR"
 export JERBOA_QT_SHIM_DIR="$QT_SHIM_DIR"
-export DYLD_LIBRARY_PATH="$JERBOA_QT_DIR:$QT_SHIM_DIR:${DYLD_LIBRARY_PATH:-}"
-export LD_LIBRARY_PATH="$JERBOA_QT_DIR:$QT_SHIM_DIR:${LD_LIBRARY_PATH:-}"
+export JERBOA_QT_DEV_NATIVE=1
 exec jerbuild exec --libdirs "$JERBOA_QT_DIR/lib:$JH/lib" "$0" "$@"
 |#
 (import (jerboa-qt qt))
diff --git a/jerboa_qt_lifecycle.cpp b/jerboa_qt_lifecycle.cpp
new file mode 100644
index 0000000..f4f9b6d
--- /dev/null
+++ b/jerboa_qt_lifecycle.cpp
@@ -0,0 +1,142 @@
+#include "callback_queue.h"
+
+#include <QMetaObject>
+#include <QObject>
+#include <QThread>
+
+#include <algorithm>
+#include <mutex>
+#include <unordered_map>
+#include <vector>
+
+extern "C" void Sdeactivate_thread(void);
+extern "C" int Sactivate_thread(void);
+
+static std::mutex watch_mutex;
+struct watch_state {
+    std::vector<long> ids;
+    QMetaObject::Connection connection;
+};
+static std::unordered_map<QObject *, watch_state> watched_objects;
+
+static std::vector<long> take_watched_ids(QObject *object) {
+    std::lock_guard<std::mutex> guard(watch_mutex);
+    auto found = watched_objects.find(object);
+    if (found == watched_objects.end()) {
+        return {};
+    }
+    std::vector<long> result = std::move(found->second.ids);
+    watched_objects.erase(found);
+    return result;
+}
+
+static void enqueue_destroyed_ids(QObject *object) {
+    for (long id : take_watched_ids(object)) {
+        jerboa_qt_enqueue_destroyed_event(id);
+    }
+}
+
+/* QObject::connect is thread-safe.  The destroyed handler runs on the
+ * object's Qt thread and only enqueues an integer; Scheme state is never
+ * touched from that native thread. */
+extern "C" void jerboa_qt_watch_destroyed(void *object, long callback_id) {
+    if (object == nullptr) {
+        return;
+    }
+
+    auto *qobject = static_cast<QObject *>(object);
+    bool install_connection = false;
+    {
+        std::lock_guard<std::mutex> guard(watch_mutex);
+        auto &state = watched_objects[qobject];
+        auto &ids = state.ids;
+        install_connection = ids.empty();
+        if (std::find(ids.begin(), ids.end(), callback_id) == ids.end()) {
+            ids.push_back(callback_id);
+        }
+    }
+    if (install_connection) {
+        auto connection = QObject::connect(
+            qobject, &QObject::destroyed,
+            [qobject]() { enqueue_destroyed_ids(qobject); });
+        std::lock_guard<std::mutex> guard(watch_mutex);
+        auto found = watched_objects.find(qobject);
+        if (found != watched_objects.end()) {
+            found->second.connection = connection;
+        }
+    }
+}
+
+extern "C" void jerboa_qt_unwatch_destroyed(void *object, long callback_id) {
+    if (object == nullptr) {
+        return;
+    }
+
+    QMetaObject::Connection connection;
+    bool disconnect = false;
+    {
+        std::lock_guard<std::mutex> guard(watch_mutex);
+        auto found = watched_objects.find(static_cast<QObject *>(object));
+        if (found == watched_objects.end()) {
+            return;
+        }
+        auto &ids = found->second.ids;
+        ids.erase(std::remove(ids.begin(), ids.end(), callback_id), ids.end());
+        if (ids.empty()) {
+            connection = found->second.connection;
+            watched_objects.erase(found);
+            disconnect = true;
+        }
+    }
+    if (disconnect) {
+        QObject::disconnect(connection);
+    }
+}
+
+extern "C" void jerboa_qt_clear_destroyed_watches(void) {
+    std::vector<QMetaObject::Connection> connections;
+    {
+        std::lock_guard<std::mutex> guard(watch_mutex);
+        for (const auto &entry : watched_objects) {
+            connections.push_back(entry.second.connection);
+        }
+        watched_objects.clear();
+    }
+    for (const auto &connection : connections) {
+        QObject::disconnect(connection);
+    }
+}
+
+/* Explicit parent deletion must invalidate child registrations immediately,
+ * before qt_disconnect_all can remove destroyed-signal watchers.  Traverse
+ * QObject ownership on the object's Qt thread, but only enqueue integer IDs;
+ * no Scheme object or callback crosses this boundary. */
+extern "C" void jerboa_qt_notify_destroying_tree(void *object) {
+    if (object == nullptr) {
+        return;
+    }
+
+    auto *root = static_cast<QObject *>(object);
+    std::vector<QObject *> objects;
+    auto collect = [&objects, root]() {
+        objects.push_back(root);
+        const auto children = root->findChildren<QObject *>();
+        objects.insert(objects.end(), children.begin(), children.end());
+    };
+
+    if (root->thread() == QThread::currentThread()) {
+        collect();
+    } else {
+        Sdeactivate_thread();
+        const bool invoked = QMetaObject::invokeMethod(
+            root, collect, Qt::BlockingQueuedConnection);
+        (void)Sactivate_thread();
+        if (!invoked) {
+            return;
+        }
+    }
+
+    for (QObject *current : objects) {
+        enqueue_destroyed_ids(current);
+    }
+}
diff --git a/jerboa_qt_shim.c b/jerboa_qt_shim.c
index 9d71f6c..e9513c1 100644
--- a/jerboa_qt_shim.c
+++ b/jerboa_qt_shim.c
@@ -1,31 +1,32 @@
-/* jerboa_qt_shim.c — Callback bridge for Chez Scheme FFI
+/* jerboa_qt_shim.c — Native callback queue bridge for Chez Scheme FFI
  *
- * Chez Scheme's foreign-callable creates function pointers at runtime,
- * unlike Gambit's c-define which creates static trampolines at compile time.
- *
- * This shim stores the Chez-provided callback function pointers and
- * provides C functions that qt_shim.cpp can call through them.
+ * This shim never calls Chez from Qt's native thread.  Signal wrappers pass
+ * bounded native-queue producers to qt_shim; Jerboa drains those immutable
+ * events later on its registered owner thread.
  *
  * Pattern:
- *   1. Chez creates foreign-callable → gets a C function pointer
- *   2. Chez calls jerboa_qt_set_*_callback() to register that pointer
- *   3. Signal connection wrappers use the stored pointer as the trampoline
+ * The jerboa_qt_set_* entry points remain as ABI-compatible no-ops for older
+ * callers.  No caller-supplied function pointer is retained or invoked.
  */
 
+#include "callback_queue.h"
 #include "qt_shim.h"
 #include <stddef.h>
 
-/* ---- Stored callback function pointers (set from Chez at init time) ---- */
-static qt_callback_void   chez_void_callback   = NULL;
-static qt_callback_string chez_string_callback  = NULL;
-static qt_callback_int    chez_int_callback     = NULL;
-static qt_callback_bool   chez_bool_callback    = NULL;
+/* Loader ABI canary: "JQT1". */
+unsigned int jerboa_qt_abi_version(void) { return 0x4a515431u; }
+