Harden Scintilla native boundary and loader

ober

450287f52fa78a06232179804e4f86361aaedf37

diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 6f8b545..cf1753a 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -17,7 +17,7 @@ jobs:
   build-test-audit:
     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 af68698..295855a 100644
--- a/Makefile
+++ b/Makefile
@@ -2,8 +2,9 @@ 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)
 JH = $(shell "$(JERBUILD)" --jerboa-home 2>/dev/null)
+JERBOA_CORE ?= $(CURDIR)/../jerboa
 
-LIBDIRS      = lib:$(JH)/lib
+LIBDIRS      = lib:$(JH)/lib:$(JERBOA_CORE)/lib
 DIST_DIR     ?= $(CURDIR)/dist
 TARGET_EVIDENCE_DIR ?= $(DIST_DIR)/target-evidence
 RELEASE_EVIDENCE_DIR ?= $(DIST_DIR)/release-evidence
@@ -23,6 +24,10 @@ CC ?= cc
 UNAME_S := $(shell uname -s)
 WARN_CFLAGS ?= -Wall -Wextra -Wformat=2 -Wshadow -Wpointer-arith -Wcast-align -Wwrite-strings
 HARDEN_CFLAGS ?= -fPIC -O2 -fstack-protector-strong -D_FORTIFY_SOURCE=2
+SANITIZER_CFLAGS ?= -O1 -g -fno-omit-frame-pointer -fsanitize=address,undefined
+THREAD_SANITIZER_CFLAGS ?= -O1 -g -fno-omit-frame-pointer -fsanitize=thread
+SANITIZER_CC ?= $(CC)
+SANITIZER_TOOLCHAIN_CFLAGS ?=
 TERMBOX_COMPAT_CFLAGS ?= -Dutf8_char_to_unicode=tb_utf8_char_to_unicode
 ifeq ($(UNAME_S),Darwin)
   SHARED_FLAG  = -dynamiclib
@@ -31,6 +36,16 @@ ifeq ($(UNAME_S),Darwin)
   WHOLE_ARCHIVE_OPEN  = -Wl,-force_load,
   WHOLE_ARCHIVE_CLOSE =
   HARDEN_LDFLAGS ?=
+  ASAN_OPTIONS_VALUE ?= detect_leaks=0:halt_on_error=1
+  # Apple clang 17's ASan runtime deadlocks during initialization on current
+  # Darwin 25 hosts.  Prefer a Homebrew LLVM runtime when it is installed.
+  ifneq ($(wildcard /opt/homebrew/opt/llvm/bin/clang),)
+    SANITIZER_CC := /opt/homebrew/opt/llvm/bin/clang
+    SANITIZER_TOOLCHAIN_CFLAGS := --no-default-config --sysroot=$(shell xcrun --show-sdk-path)
+  else ifneq ($(wildcard /usr/local/opt/llvm/bin/clang),)
+    SANITIZER_CC := /usr/local/opt/llvm/bin/clang
+    SANITIZER_TOOLCHAIN_CFLAGS := --no-default-config --sysroot=$(shell xcrun --show-sdk-path)
+  endif
 else
   SHARED_FLAG  = -shared
   SO_EXT       = .so
@@ -38,9 +53,12 @@ else
   WHOLE_ARCHIVE_OPEN  = -Wl,--whole-archive
   WHOLE_ARCHIVE_CLOSE = -Wl,--no-whole-archive
   HARDEN_LDFLAGS ?= -Wl,-z,relro,-z,now
+  ASAN_OPTIONS_VALUE ?= detect_leaks=1:halt_on_error=1
 endif
 
 SHIM := jerboa_scintilla_shim$(SO_EXT)
+NATIVE_SECURITY_TEST := $(DIST_DIR)/tests/native-shim-security-test
+NATIVE_THREAD_TEST := $(DIST_DIR)/tests/native-shim-thread-test
 
 CC_INCLUDES = \
 	-I$(SCI_DIR)/include \
@@ -49,7 +67,7 @@ CC_INCLUDES = \
 	-I$(TERMBOX_DIR)/src \
 	-I$(LEXILLA_DIR)/include
 
-.PHONY: all build native transpile security test test-native native-boundary-corpus clean shim vendor-deps vendor-check \
+.PHONY: all build native transpile security test test-native loader-policy-tests native-boundary-corpus native-security-tests native-thread-tests clean shim vendor-deps vendor-check \
 	ensure-jerboa-tools audit vendor-provenance-check vendor-provenance-enforce \
 	sbom reproducibility-report target-evidence verify release-evidence
 
@@ -95,20 +113,49 @@ transpile: ensure-jerboa-tools
 	$(JERBUILD) transpile src lib --force
 
 test: transpile
+	JERBOA_SCINTILLA_DEV_NATIVE=1 \
 	JERBOA_SCINTILLA_LIB=$(CURDIR) \
 	$(LD_VAR)=$(CURDIR) \
 	$(JERBUILD) exec --libdirs "$(LIBDIRS)" tests/run-tests.ss
 
 test-native: transpile shim
+	JERBOA_SCINTILLA_DEV_NATIVE=1 \
 	JERBOA_SCINTILLA_LIB=$(CURDIR) \
 	$(LD_VAR)=$(CURDIR) \
 	$(JERBUILD) exec --libdirs "$(LIBDIRS)" tests/run-tests.ss
 
+loader-policy-tests: transpile shim tests/loader-policy.ss
+	@REPO_ROOT="$(CURDIR)" JERBUILD="$(JERBUILD)" \
+	LIBDIRS="$(CURDIR)/lib:$(JH)/lib:$(JERBOA_CORE)/lib" SHIM="$(CURDIR)/$(SHIM)" \
+	$(LD_VAR)=$(CURDIR) \
+	sh scripts/loader-policy-test.sh
+
 native-boundary-corpus: transpile shim support/native-boundary-corpus.ss
+	JERBOA_SCINTILLA_DEV_NATIVE=1 \
 	JERBOA_SCINTILLA_LIB=$(CURDIR) \
 	$(LD_VAR)=$(CURDIR) \
 	$(JERBUILD) exec --libdirs "$(LIBDIRS)" support/native-boundary-corpus.ss
 
+native-security-tests: jerboa_scintilla_shim.c tests/fake_scintilla_backend.h tests/native_shim_security_test.c
+	@mkdir -p "$(dir $(NATIVE_SECURITY_TEST))"
+	$(SANITIZER_CC) $(SANITIZER_TOOLCHAIN_CFLAGS) -std=c11 $(WARN_CFLAGS) -Werror \
+		$(SANITIZER_CFLAGS) $(TERMBOX_COMPAT_CFLAGS) \
+		-DJERBOA_SCINTILLA_FAKE_BACKEND=1 -DJERBOA_SCINTILLA_TESTING=1 \
+		-Itests -o "$(NATIVE_SECURITY_TEST)" \
+		jerboa_scintilla_shim.c tests/native_shim_security_test.c -lpthread
+	ASAN_OPTIONS=$(ASAN_OPTIONS_VALUE) \
+	UBSAN_OPTIONS=halt_on_error=1:print_stacktrace=1 \
+	"$(NATIVE_SECURITY_TEST)"
+
+native-thread-tests: jerboa_scintilla_shim.c tests/fake_scintilla_backend.h tests/native_shim_security_test.c
+	@mkdir -p "$(dir $(NATIVE_THREAD_TEST))"
+	$(SANITIZER_CC) $(SANITIZER_TOOLCHAIN_CFLAGS) -std=c11 $(WARN_CFLAGS) -Werror \
+		$(THREAD_SANITIZER_CFLAGS) $(TERMBOX_COMPAT_CFLAGS) \
+		-DJERBOA_SCINTILLA_FAKE_BACKEND=1 -DJERBOA_SCINTILLA_TESTING=1 \
+		-Itests -o "$(NATIVE_THREAD_TEST)" \
+		jerboa_scintilla_shim.c tests/native_shim_security_test.c -lpthread
+	TSAN_OPTIONS=halt_on_error=1 "$(NATIVE_THREAD_TEST)"
+
 audit: transpile
 	@echo "==> Scintilla native dependency roots"
 	@echo "SCINTILLA_VENDOR=$(SCINTILLA_VENDOR)"
@@ -180,7 +227,7 @@ reproducibility-report:
 target-evidence:
 	@TARGET_EVIDENCE_DIR="$(TARGET_EVIDENCE_DIR)" sh scripts/target-evidence.sh
 
-verify: security test audit sbom reproducibility-report target-evidence
+verify: security test-native loader-policy-tests native-boundary-corpus native-security-tests native-thread-tests audit sbom reproducibility-report target-evidence
 
 release-evidence: security
 	@rm -rf "$(RELEASE_EVIDENCE_DIR)"
diff --git a/README.md b/README.md
index 47b89f8..7eb022d 100644
--- a/README.md
+++ b/README.md
@@ -6,9 +6,14 @@ tools.
 
 The native boundary is intentionally narrow:
 
-- The shared object is loaded lazily, not during module import.
+- Statically registered native symbols are preferred. Development-only dynamic
+  loading is lazy and requires an explicit trusted absolute directory.
 - Text arguments cross into C as UTF-8 bytevectors with explicit lengths.
+- Text results cross into caller-owned bytevectors with a checked capacity and
+  an explicit 64 MiB API limit; there is no fixed 64 KiB receive buffer.
 - Embedded NUL bytes are rejected before native calls.
+- Opaque numeric editor IDs are never reused. Native operations, notification
+  delivery, and destruction use synchronized lifecycle references.
 - Vendored Scintilla, Lexilla, and termbox_next archives are required only for
   native shim builds.
 - Pure API tests can run without a terminal or native vendor checkout.
@@ -29,12 +34,26 @@ shim:
 make vendor-deps
 make vendor-provenance-enforce
 make test-native
+make native-boundary-corpus
+make native-security-tests
+make native-thread-tests
 make audit
 ```
 
 By default, the Makefile uses the ignored `./scintilla-vendor` directory. Set
 `SCINTILLA_VENDOR=/path/to/scintilla-vendor` to use another checkout.
 
+The Makefile supplies the development loader controls for its native tests.
+Other dynamic development hosts must set both
+`JERBOA_SCINTILLA_DEV_NATIVE=1` and `JERBOA_SCINTILLA_LIB` to a trusted,
+canonical absolute directory containing the shim. Production hosts should
+statically register the shim symbols instead. Current-directory, relative,
+bare-name, symlinked, unexpected-owner, and world-writable native paths are
+rejected by the shared `(std native-loader)` policy, which validates every
+ancestor. Group-write is allowed only on same-user components in explicit
+unprivileged development mode. Privileged processes ignore the development
+environment entirely.
+
 ## Security
 
 Review [SECURITY.md](SECURITY.md), [docs/ffi-boundary.md](docs/ffi-boundary.md),
@@ -45,5 +64,7 @@ provenance status, SBOM manifests, native linkage, sanitized target evidence,
 and hash-only reproducibility output under `dist/release-evidence/`. Target
 proof files are rejected before copying when they contain overlarge,
 host-private, or high-confidence secret material. Public production release
-still requires sanitizer builds, terminal smoke tests, hostile-input coverage,
-and marker-complete target-platform evidence.
+still requires sanitizer coverage of the complete vendored C++ graph, terminal
+smoke tests, and marker-complete target-platform evidence. The checked-in shim
+harness already runs ASan/UBSan boundary cases and ThreadSanitizer over the
+deterministic lifecycle/concurrency suite.
diff --git a/SECURITY.md b/SECURITY.md
index c587564..a315286 100644
--- a/SECURITY.md
+++ b/SECURITY.md
@@ -6,12 +6,21 @@ tracker gates are complete.
 
 ## Security Posture
 
-- Native bindings load lazily through `(jerboa ffi)` and do not call native
-  loaders during module import.
+- Native bindings prefer statically registered symbols. Lazy dynamic loading
+  requires an explicit development opt-in and a trusted canonical absolute
+  directory. The shared `(std native-loader)` policy validates every path
+  component's type, owner, and write mode; current-directory, bare-name,
+  symlinked, unexpected-owner, and world-writable paths are forbidden.
+  Group-write is accepted only on same-user components in explicit
+  unprivileged development mode. Privileged processes ignore overrides.
 - Scheme strings that cross into C are converted to UTF-8 bytevectors with
   explicit lengths.
 - Embedded NUL bytes in text, property keys, lexer names, and terminal output
   are rejected before native calls.
+- Native text and clipboard results use capacity-checked caller-owned
+  bytevectors with a 64 MiB public limit instead of fixed receive storage.
+- Never-reused numeric handles and synchronized operation, callback,
+  notification, and destruction state prevent stale-handle aliasing and UAF.
 - Blocking termbox calls are declared collect-safe.
 - CI runs pure build/tests and records whether native Scintilla vendor archives
   are available for linkage audit.
@@ -40,10 +49,14 @@ have been scrubbed.
 - Validate the pinned Scintilla, Lexilla, and termbox_next checkout with
   `make vendor-provenance-enforce` on the native release machine.
 - Add native terminal smoke tests and deterministic teardown checks.
-- Add hostile-input tests for malformed UTF-8, very large buffers, long lexer
-  names, notification queue pressure, and clipboard edge cases.
-- Add sanitizer builds for the C/C++ shim and vendored native code.
-- Document thread ownership and lifecycle guarantees for native handles.
+- Extend hostile-input tests for malformed UTF-8, long lexer names,
+  notification queue pressure, and clipboard edge cases. Boundary sizes through
+  1 MiB, embedded NUL, and multibyte data are covered now.
+- Run ASan/UBSan and ThreadSanitizer over the complete vendored C++ build on
+  every target. The shim and deterministic fake backend are already tested by
+  all three sanitizers.
+- Validate upstream UI-thread requirements; the shim itself serializes native
+  operations and provides callback-aware destruction.
 - Generate passing native shim reproducibility evidence for Jerboa, compilers,
   libc/libc++, Scintilla, Lexilla, and termbox_next.
 - Attach a marker-complete target native proof with no buffers, clipboard
diff --git a/docs/ffi-boundary.md b/docs/ffi-boundary.md
index e22670f..38e31a2 100644
--- a/docs/ffi-boundary.md
+++ b/docs/ffi-boundary.md
@@ -8,14 +8,28 @@
 - Own Scintilla instance allocation and destruction.
 - Keep a per-instance notification queue and expose one drained notification at a time.
 - Copy Scheme-provided bytevectors into short-lived NUL-terminated buffers before calling Scintilla or termbox text APIs.
-- Reject null handles and embedded NUL bytes at native entry points.
-- Return empty strings instead of null C strings for optional clipboard, notification, and text results.
+- Resolve never-reused numeric handle IDs under a synchronized registry and
+  hold a lifecycle reference for every operation and callback.
+- Serialize operations per instance, synchronize notification queues, and
+  remove an ID before destruction waits for in-flight references.
+- Recheck text-result length and actual bytevector capacity while holding the
+  same operation lock as the Scintilla write. A too-small buffer is rejected
+  before Scintilla receives its pointer.
 
 ## Scheme Responsibilities
 
 - Load `jerboa_scintilla_shim` lazily through `(jerboa ffi)`.
+- Prefer statically registered symbols. Permit dynamic development loading only
+  with `JERBOA_SCINTILLA_DEV_NATIVE=1` and an absolute `JERBOA_SCINTILLA_LIB`
+  directory accepted by `(std native-loader)`. The shared policy rejects
+  non-canonical or symlinked paths, unexpected owners, and world-writable
+  libraries or ancestors. Group-write is allowed only on same-user components
+  in explicit unprivileged development mode; privileged processes ignore the
+  environment.
 - Pass text, property keys, lexer names, and terminal output as UTF-8 bytevectors with explicit lengths.
 - Reject strings containing NUL bytes before any native call.
+- Decode checked, exact-length result bytevectors only after the native call;
+  reject results larger than 64 MiB rather than truncating them.
 - Treat editor handles as single-owner resources and call `editor-destroy` when done.
 - Avoid terminal event calls from threads that cannot tolerate blocking.
 
@@ -23,14 +37,22 @@
 
 - `create-scintilla-editor` returns an editor that owns one native Scintilla handle.
 - `editor-destroy` releases the native handle and clears the Scheme record field.
-- Notification text returned by accessors is copied from a fixed native buffer and should be treated as transient.
+- Notification text returned by accessors is copied from a bounded per-entry
+  native buffer and should be treated as transient.
+- Clipboard and editor text use caller-owned, length-aware bytevectors and are
+  never routed through process- or thread-global receive storage.
 - Lexilla lexer ownership is delegated to Scintilla after `SCI_SETILEXER`; this assumption needs upstream verification before public production release.
 
 ## Error Mapping
 
-- Missing native libraries are reported when a native FFI function is first called, not during module import.
+- Missing or untrusted dynamic native libraries are reported when a native FFI
+  function is first called, not during module import.
 - Null native handles return neutral values or no-op in the C shim.
 - Embedded NUL strings raise Scheme errors before the native call.
+- Invalid, stale, and already-destroyed numeric IDs never dereference caller
+  memory and cannot alias a later instance.
+- A native result that changes between the sizing and receive calls is retried
+  up to three times; oversized or unstable results raise an error.
 - Native allocation failures currently return null/zero and require caller-level checks for production workflows.
 - `make native-boundary-corpus` exercises deterministic NUL rejection,
   non-ASCII UTF-8 strings, large editor text, notification accessors/draining,
@@ -43,6 +65,9 @@
   ignored local checkout before native release evidence.
 - Hosted CI still needs a terminal integration test job with the native vendor
   checkout populated.
-- The notification queue is not thread-safe.
 - There are no production memory or render-time quotas around hostile editor
-  content beyond the bounded local corpus.
+  content beyond the 64 MiB result boundary and bounded local corpus.
+- The sanitizer harness instruments the shim and a deterministic fake backend
+  with ASan/UBSan and ThreadSanitizer;
+  release evidence still needs ASan coverage of the complete vendored
+  Scintilla/Lexilla/termbox C++ build on each supported target.
diff --git a/docs/threat-model.md b/docs/threat-model.md
index 277c424..9b7af68 100644
--- a/docs/threat-model.md
+++ b/docs/threat-model.md
@@ -15,22 +15,31 @@ process FFI boundary, not as a sandbox.
 
 - Scheme strings cross into C and then into Scintilla or termbox_next.
 - Terminal input events cross from termbox_next into Scheme event records.
-- Clipboard data crosses from Scintilla-owned memory into a native static buffer and then into Scheme strings.
+- Clipboard data crosses from Scintilla-owned memory into a capacity-checked,
+  Scheme-owned bytevector and then into Scheme strings.
 - Lexer names cross into Lexilla dynamic lexer creation.
 
 ## Primary Risks
 
 - Embedded NUL bytes causing text truncation or property confusion across C APIs.
 - Blocking terminal calls stalling the Jerboa runtime.
-- Missing native library loading at import time breaking pure callers or CI.
+- Loader-path substitution in development dynamic-library configurations.
 - Native handle misuse after `editor-destroy`.
 - Untrusted content triggering parser, lexer, rendering, or notification queue bugs in vendored native code.
 - Clipboard or notification data being logged accidentally by callers.
 
 ## Current Controls
 
-- Native loading is lazy and uses `(jerboa ffi)` `load-shared-object*`.
+- Statically registered native symbols take precedence. Lazy dynamic loading is
+  development-only and requires an explicit canonical absolute directory. The
+  shared `(std native-loader)` policy validates the library and every ancestor's
+  type, owner, and write mode, and privileged processes ignore the environment.
 - Text arguments cross as bytevectors with explicit lengths and are checked for NUL bytes.
+- Text and clipboard results use caller-owned bytevectors whose capacity is
+  checked under the same per-instance lock as the native write.
+- Never-reused numeric handles, synchronized registry references, per-instance
+  operation/ring locks, and close-before-wait destruction protect lifecycle
+  and notification state.
 - Blocking termbox poll/peek/present and text-print calls are collect-safe.
 - Pure tests run without vendored native archives and still validate the public API surface.
 - Native shim builds fail closed until expected Scintilla, Lexilla, and termbox_next archives exist.
@@ -47,9 +56,13 @@ process FFI boundary, not as a sandbox.
 - Run `make vendor-provenance-enforce` with a local checkout matching
   `vendor-lock.env`.
 - Add native terminal smoke tests with deterministic teardown on Linux and macOS.
-- Add malformed UTF-8, very long text, property, lexer-name, notification, and clipboard tests.
-- Add sanitizer builds for the C/C++ shim and vendored native code.
-- Document thread ownership and whether Scintilla handles can cross threads.
+- Extend malformed UTF-8 and terminal integration coverage. Boundary-sized
+  text, multibyte content, property strings, notifications, and clipboard
+  access are in the native corpus.
+- Run sanitizer builds for the complete vendored native code; the shim and
+  deterministic fake backend already run under ASan/UBSan and ThreadSanitizer.
+- Validate upstream UI-thread requirements even though shim calls and
+  destruction are serialized and lifecycle-safe.
 - Generate passing native shim reproducibility evidence for Jerboa, C/C++
   compilers, libc/libc++, Scintilla, Lexilla, and termbox_next.
 - Require marker-complete `SCINTILLA_TARGET_PROOF_FILE` evidence before making a
diff --git a/jerboa_scintilla_shim.c b/jerboa_scintilla_shim.c
index 22253a3..c0aa0b3 100644
--- a/jerboa_scintilla_shim.c
+++ b/jerboa_scintilla_shim.c
@@ -14,11 +14,17 @@
 #include <string.h>
 #include <stdint.h>
 #include <stdbool.h>
+#include <limits.h>
+#include <pthread.h>
 
+#ifdef JERBOA_SCINTILLA_FAKE_BACKEND
+#include "fake_scintilla_backend.h"
+#else
 #include "Scintilla.h"
 #include "ScintillaTermbox.h"
 #include "Lexilla.h"
 #include "termbox.h"
+#endif
 
 /* ================================================================
    Types
@@ -26,11 +32,14 @@
 typedef intptr_t sptr_t;
 typedef uintptr_t uptr_t;
 typedef void Scintilla;
+typedef uint64_t jerboa_scintilla_handle_t;
+
+#define JERBOA_SCINTILLA_INVALID_HANDLE UINT64_C(0)
+#define JERBOA_SCINTILLA_BUFFER_TOO_SMALL (-2L)
 
 static char *copy_ffi_bytes(const unsigned char *bytes, size_t len) {
     if (len > 0 && !bytes) return NULL;
     if (len > SIZE_MAX - 1) return NULL;
-    if (len > 0 && memchr(bytes, '\0', len)) return NULL;
 
     char *out = (char *)malloc(len + 1);
     if (!out) return NULL;
@@ -66,36 +75,123 @@ typedef struct {
 } notify_entry_t;
 
 typedef struct instance_node {
+    jerboa_scintilla_handle_t id;
     Scintilla           *view;
     notify_entry_t       ring[NOTIFY_RING_SIZE];
     int                  ring_head;
     int                  ring_tail;
+    bool                 closing;
+    size_t               active_calls;
+    pthread_mutex_t      operation_mutex;
+    pthread_mutex_t      ring_mutex;
+    pthread_cond_t       idle;
     struct instance_node *next;
 } instance_node_t;
 
 static instance_node_t *instance_list = NULL;
+static pthread_mutex_t instance_list_mutex = PTHREAD_MUTEX_INITIALIZER;
+static jerboa_scintilla_handle_t next_instance_id = UINT64_C(1);
+
+#ifdef JERBOA_SCINTILLA_TESTING
+size_t jerboa_scintilla_test_live_instances(void) {
+    size_t count = 0;
+    pthread_mutex_lock(&instance_list_mutex);
+    for (instance_node_t *node = instance_list; node; node = node->next) count++;
+    pthread_mutex_unlock(&instance_list_mutex);
+    return count;
+}
+
+int jerboa_scintilla_test_handle_registered(jerboa_scintilla_handle_t id) {
+    int registered = 0;
+    pthread_mutex_lock(&instance_list_mutex);
+    for (instance_node_t *node = instance_list; node; node = node->next) {
+        if (node->id == id) {
+            registered = 1;
+            break;
+        }
+    }
+    pthread_mutex_unlock(&instance_list_mutex);
+    return registered;
+}
+#endif
 
 /* Current notification for drain_one -> accessor pattern. */
 static __thread notify_entry_t current_scn;
 
-/* Find instance node by view pointer */
-static instance_node_t *find_instance(Scintilla *view) {
+/* The numeric handle is never reused.  Callers never dereference it, so a
+ * stale handle cannot become an alias for a later allocation at the same
+ * address.  The registry lock turns lookup into a synchronized reference
+ * acquisition; deletion removes the ID before waiting for acquired calls. */
+static instance_node_t *find_instance_locked(jerboa_scintilla_handle_t id) {
     instance_node_t *n = instance_list;
     while (n) {
-        if (n->view == view) return n;
+        if (n->id == id) return n;
         n = n->next;
     }
     return NULL;
 }
 
+static instance_node_t *acquire_instance(jerboa_scintilla_handle_t id) {
+    if (id == JERBOA_SCINTILLA_INVALID_HANDLE) return NULL;
+
+    pthread_mutex_lock(&instance_list_mutex);
+    instance_node_t *inst = find_instance_locked(id);
+    if (inst && !inst->closing) {
+        inst->active_calls++;
+    } else {
+        inst = NULL;
+    }
+    pthread_mutex_unlock(&instance_list_mutex);
+
+    if (inst) pthread_mutex_lock(&inst->operation_mutex);
+    return inst;
+}
+
+static void release_instance(instance_node_t *inst) {
+    if (!inst) return;
+    pthread_mutex_unlock(&inst->operation_mutex);
+
+    pthread_mutex_lock(&instance_list_mutex);
+    if (inst->active_calls > 0) inst->active_calls--;
+    if (inst->closing && inst->active_calls == 0) {
+        pthread_cond_signal(&inst->idle);
+    }
+    pthread_mutex_unlock(&instance_list_mutex);
+}
+
+static bool acquire_callback(instance_node_t *inst) {
+    bool acquired = false;
+    pthread_mutex_lock(&instance_list_mutex);
+    if (inst && !inst->closing) {
+        inst->active_calls++;
+        acquired = true;
+    }
+    pthread_mutex_unlock(&instance_list_mutex);
+    return acquired;
+}
+
+static void release_callback(instance_node_t *inst) {
+    pthread_mutex_lock(&instance_list_mutex);
+    if (inst->active_calls > 0) inst->active_calls--;
+    if (inst->closing && inst->active_calls == 0) {
+        pthread_cond_signal(&inst->idle);
+    }
+    pthread_mutex_unlock(&instance_list_mutex);
+}
+
 /* Notification callback — called synchronously from C++ */
 static void notification_callback(Scintilla *view, int msg,
                                   SCNotification *scn, void *userdata) {
     (void)msg;
-    (void)userdata;
+    instance_node_t *inst = (instance_node_t *)userdata;
 
-    instance_node_t *inst = find_instance(view);
-    if (!inst) return;
+    if (!scn || !acquire_callback(inst)) return;
+    if (inst->view != view) {
+        release_callback(inst);
+        return;
+    }
+
+    pthread_mutex_lock(&inst->ring_mutex);
 
     int next_head = (inst->ring_head + 1) % NOTIFY_RING_SIZE;
     if (next_head == inst->ring_tail) {
@@ -132,112 +228,224 @@ static void notification_callback(Scintilla *view, int msg,
     }
 
     inst->ring_head = next_head;
+    pthread_mutex_unlock(&inst->ring_mutex);
+    release_callback(inst);
 }
 
 /* ================================================================
    Instance lifecycle
    ================================================================ */
-void *jerboa_scintilla_new(void) {
-    Scintilla *sci = scintilla_new(notification_callback, NULL);
-    if (!sci) return NULL;
-
+jerboa_scintilla_handle_t jerboa_scintilla_new(void) {
     instance_node_t *node = (instance_node_t *)calloc(1, sizeof(instance_node_t));
-    if (!node) {
+    if (!node) return JERBOA_SCINTILLA_INVALID_HANDLE;
+
+    if (pthread_mutex_init(&node->operation_mutex, NULL) != 0) {
+        free(node);
+        return JERBOA_SCINTILLA_INVALID_HANDLE;
+    }
+    if (pthread_mutex_init(&node->ring_mutex, NULL) != 0) {
+        pthread_mutex_destroy(&node->operation_mutex);
+        free(node);
+        return JERBOA_SCINTILLA_INVALID_HANDLE;
+    }
+    if (pthread_cond_init(&node->idle, NULL) != 0) {
+        pthread_mutex_destroy(&node->ring_mutex);
+        pthread_mutex_destroy(&node->operation_mutex);
+        free(node);
+        return JERBOA_SCINTILLA_INVALID_HANDLE;
+    }
+
+    Scintilla *sci = scintilla_new(notification_callback, node);
+    if (!sci) {
+        pthread_cond_destroy(&node->idle);
+        pthread_mutex_destroy(&node->ring_mutex);
+        pthread_mutex_destroy(&node->operation_mutex);
+        free(node);
+        return JERBOA_SCINTILLA_INVALID_HANDLE;
+    }
+    node->view = sci;
+
+    pthread_mutex_lock(&instance_list_mutex);
+    if (next_instance_id == JERBOA_SCINTILLA_INVALID_HANDLE) {
+        pthread_mutex_unlock(&instance_list_mutex);
         scintilla_delete(sci);
-        return NULL;
+        pthread_cond_destroy(&node->idle);
+        pthread_mutex_destroy(&node->ring_mutex);
+        pthread_mutex_destroy(&node->operation_mutex);
+        free(node);
+        return JERBOA_SCINTILLA_INVALID_HANDLE;
     }
-    node->view      = sci;
+    node->id = next_instance_id++;
     node->next      = instance_list;
     instance_list   = node;
+    pthread_mutex_unlock(&instance_list_mutex);
 
-    return (void *)sci;
+    return node->id;
 }
 
-void jerboa_scintilla_delete(void *handle) {
-    if (!handle) return;
-    Scintilla *sci = (Scintilla *)handle;
-    /* Remove from instance list */
+void jerboa_scintilla_delete(jerboa_scintilla_handle_t handle) {
+    if (handle == JERBOA_SCINTILLA_INVALID_HANDLE) return;
+
+    pthread_mutex_lock(&instance_list_mutex);
     instance_node_t **pp = &instance_list;
     while (*pp) {
-        if ((*pp)->view == sci) {
-            instance_node_t *rm = *pp;
-            *pp = rm->next;
-            free(rm);
+        if ((*pp)->id == handle) {
             break;
         }
         pp = &(*pp)->next;
     }
-    scintilla_delete(sci);
+    instance_node_t *node = *pp;
+    if (!node) {
+        pthread_mutex_unlock(&instance_list_mutex);
+        return;
+    }
+
+    *pp = node->next;
+    node->next = NULL;
+    node->closing = true;
+    while (node->active_calls != 0) {
+        pthread_cond_wait(&node->idle, &instance_list_mutex);
+    }
+    pthread_mutex_unlock(&instance_list_mutex);
+
+    /* No new call can acquire the removed ID, and every prior call and
+     * callback has released its reference.  Callbacks made by the upstream
+     * destructor observe closing=true and are discarded before queue access. */
+    pthread_mutex_lock(&node->operation_mutex);
+    scintilla_delete(node->view);
+    node->view = NULL;
+    pthread_mutex_lock(&node->ring_mutex);
+    memset(node->ring, 0, sizeof(node->ring));
+    node->ring_head = 0;
+    node->ring_tail = 0;
+    pthread_mutex_unlock(&node->ring_mutex);
+    pthread_mutex_unlock(&node->operation_mutex);
+
+    pthread_cond_destroy(&node->idle);
+    pthread_mutex_destroy(&node->ring_mutex);
+    pthread_mutex_destroy(&node->operation_mutex);
+    free(node);
 }
 
 /* ================================================================
    Message passing
    ================================================================ */
-long jerboa_scintilla_send_message(void *handle, unsigned int msg,
+long jerboa_scintilla_send_message(jerboa_scintilla_handle_t handle, unsigned int msg,
                                  unsigned long wparam, long lparam) {
-    if (!handle) return 0;
-    return (long)scintilla_send_message((Scintilla *)handle, msg,
-                                        (uptr_t)wparam, (sptr_t)lparam);
+    instance_node_t *inst = acquire_instance(handle);
+    if (!inst) return 0;
+    long result = (long)scintilla_send_message(inst->view, msg,
+                                               (uptr_t)wparam, (sptr_t)lparam);
+    release_instance(inst);
+    return result;
 }
 
 /* Send message with string as lparam (cast char* to sptr_t) */
-long jerboa_scintilla_send_message_string(void *handle, unsigned int msg,
+long jerboa_scintilla_send_message_string(jerboa_scintilla_handle_t handle, unsigned int msg,
                                         unsigned long wparam, const char *str) {
-    if (!handle || !str) return 0;
-    return (long)scintilla_send_message((Scintilla *)handle, msg,
-                                        (uptr_t)wparam, (sptr_t)str);
+    if (!str) return 0;
+    instance_node_t *inst = acquire_instance(handle);
+    if (!inst) return 0;
+    long result = (long)scintilla_send_message(inst->view, msg,
+                                               (uptr_t)wparam, (sptr_t)str);
+    release_instance(inst);
+    return result;
 }
 
-long jerboa_scintilla_send_message_bytes(void *handle, unsigned int msg,
+long jerboa_scintilla_send_message_bytes(jerboa_scintilla_handle_t handle, unsigned int msg,
                                        unsigned long wparam,
                                        const unsigned char *bytes,
                                        size_t len) {
-    if (!handle) return 0;
     char *str = copy_ffi_bytes(bytes, len);
     if (!str) return 0;
-    long result = (long)scintilla_send_message((Scintilla *)handle, msg,
+    instance_node_t *inst = acquire_instance(handle);
+    if (!inst) {
+        free(str);
+        return 0;
+    }
+    long result = (long)scintilla_send_message(inst->view, msg,
                                                (uptr_t)wparam, (sptr_t)str);
+    release_instance(inst);
     free(str);
     return result;
 }
 
-/* Receive string: call msg twice (get length, then fill buffer) */
-#define RECV_BUF_SIZE 65536
-static __thread char recv_buf[RECV_BUF_SIZE];
+/* Query the message-specific result length without issuing a write.  The
+ * caller uses this only to size a Scheme-owned bytevector; receive_bytes
+ * repeats the query while holding the same per-instance operation lock as the
+ * write, so a stale size can never become an overflow. */
+long jerboa_scintilla_receive_length(jerboa_scintilla_handle_t handle,
+                                    unsigned int msg,
+                                    unsigned long wparam) {
+    instance_node_t *inst = acquire_instance(handle);
+    if (!inst) return -1;
+    long len = (long)scintilla_send_message(inst->view, msg,
+                                            (uptr_t)wparam, (sptr_t)NULL);
+    release_instance(inst);
+    return len;
+}
+
+long jerboa_scintilla_receive_bytes(jerboa_scintilla_handle_t handle,
+                                   unsigned int msg,
+                                   unsigned long wparam,
+                                   unsigned char *out,
+                                   size_t capacity) {
+    if (!out || capacity == 0) return -1;
+    instance_node_t *inst = acquire_instance(handle);
+    if (!inst) return -1;
+
+    long queried = (long)scintilla_send_message(inst->view, msg,
+                                                (uptr_t)wparam, (sptr_t)NULL);
+    if (queried < 0 || (uintmax_t)queried >= (uintmax_t)capacity) {
+        release_instance(inst);
+        return queried < 0 ? -1 : JERBOA_SCINTILLA_BUFFER_TOO_SMALL;
+    }
 
-const char *jerboa_scintilla_receive_string(void *handle, unsigned int msg,
-                                          unsigned long wparam) {
-    if (!handle) {
-        recv_buf[0] = '\0';
-        return recv_buf;
+    /* SCI_GETTEXT's wParam is the destination capacity.  Never pass a value
+     * larger than the actual bytevector even if a caller supplied one.  Other
+     * string-result messages use wParam for their own message-specific input;
+     * the checked query above bounds their write while this lock excludes
+     * concurrent edits through the shim. */
+    uptr_t call_wparam = (uptr_t)wparam;
+    if (msg == SCI_GETTEXT && call_wparam > (uptr_t)capacity) {
+        call_wparam = (uptr_t)capacity;
     }
-    long len = (long)scintilla_send_message((Scintilla *)handle, msg,
-                                            (uptr_t)wparam, (sptr_t)NULL);
-    if (len <= 0) {
-        recv_buf[0] = '\0';
-        return recv_buf;
+
+    long written = (long)scintilla_send_message(inst->view, msg,
+                                                call_wparam, (sptr_t)out);
+    if (written < 0 || (uintmax_t)written >= (uintmax_t)capacity) {
+        /* SCI_GETTEXT is capacity-clamped above.  For other messages this is
+         * a defensive contract check; the pre-write query and serialized
+         * operation should make it unreachable for conforming Scintilla. */
+        out[capacity - 1] = '\0';
+        release_instance(inst);
+        return -1;
     }
-    if (len >= RECV_BUF_SIZE) len = RECV_BUF_SIZE - 1;
-    scintilla_send_message((Scintilla *)handle, msg,
-                           (uptr_t)wparam, (sptr_t)recv_buf);
-    recv_buf[len] = '\0';
-    return recv_buf;
+
+    size_t text_len = (size_t)written;
+    if (text_len > 0 && out[text_len - 1] == '\0') text_len--;
+    out[text_len] = '\0';
+    release_instance(inst);
+    return (long)text_len;
 }
 
 /* Set property: wparam = key string, lparam = value string */
-long jerboa_scintilla_set_property(void *handle, unsigned int msg,
+long jerboa_scintilla_set_property(jerboa_scintilla_handle_t handle, unsigned int msg,
                                  const char *key, const char *value) {
-    if (!handle || !key || !value) return 0;
-    return (long)scintilla_send_message((Scintilla *)handle, msg,
-                                        (uptr_t)key, (sptr_t)value);
+    if (!key || !value) return 0;
+    instance_node_t *inst = acquire_instance(handle);
+    if (!inst) return 0;
+    long result = (long)scintilla_send_message(inst->view, msg,
+                                               (uptr_t)key, (sptr_t)value);
+    release_instance(inst);
+    return result;
 }
 
-long jerboa_scintilla_set_property_bytes(void *handle, unsigned int msg,
+long jerboa_scintilla_set_property_bytes(jerboa_scintilla_handle_t handle, unsigned int msg,
                                        const unsigned char *key,
                                        size_t key_len,
                                        const unsigned char *value,
                                        size_t value_len) {
-    if (!handle) return 0;
     char *key_str = copy_ffi_bytes(key, key_len);
     if (!key_str) return 0;
     char *value_str = copy_ffi_bytes(value, value_len);
@@ -246,84 +454,223 @@ long jerboa_scintilla_set_property_bytes(void *handle, unsigned int msg,
         return 0;
     }
 
-    long result = (long)scintilla_send_message((Scintilla *)handle, msg,
+    if (memchr(key_str, '\0', key_len) || memchr(value_str, '\0', value_len)) {
+        free(value_str);
+        free(key_str);
+        return 0;
+    }
+    instance_node_t *inst = acquire_instance(handle);
+    if (!inst) {
+        free(value_str);
+        free(key_str);
+        return 0;
+    }
+    long result = (long)scintilla_send_message(inst->view, msg,
                                                (uptr_t)key_str,
                                                (sptr_t)value_str);
+    release_instance(inst);
     free(value_str);
     free(key_str);
     return result;
 }
 
+long jerboa_scintilla_property_length(jerboa_scintilla_handle_t handle,
+                                     unsigned int msg,
+                                     const unsigned char *key,
+                                     size_t key_len) {
+    char *key_str = copy_ffi_bytes(key, key_len);
+    if (!key_str || memchr(key_str, '\0', key_len)) {
+        free(key_str);
+        return -1;
+    }
+    instance_node_t *inst = acquire_instance(handle);
+    if (!inst) {
+        free(key_str);
+        return -1;
+    }
+    long len = (long)scintilla_send_message(inst->view, msg,
+                                            (uptr_t)key_str, (sptr_t)NULL);
+    release_instance(inst);
+    free(key_str);
+    return len;
+}
+
+long jerboa_scintilla_receive_property_bytes(
+    jerboa_scintilla_handle_t handle, unsigned int msg,
+    const unsigned char *key, size_t key_len,
+    unsigned char *out, size_t capacity) {
+    if (!out || capacity == 0) return -1;
+    char *key_str = copy_ffi_bytes(key, key_len);
+    if (!key_str || memchr(key_str, '\0', key_len)) {
+        free(key_str);
+        return -1;
+    }
+    instance_node_t *inst = acquire_instance(handle);
+    if (!inst) {
+        free(key_str);
+        return -1;
+    }
+    long queried = (long)scintilla_send_message(inst->view, msg,
+                                                (uptr_t)key_str,
+                                                (sptr_t)NULL);
+    if (queried < 0 || (uintmax_t)queried >= (uintmax_t)capacity) {
+        release_instance(inst);
+        free(key_str);
+        return queried < 0 ? -1 : JERBOA_SCINTILLA_BUFFER_TOO_SMALL;
+    }
+    long written = (long)scintilla_send_message(inst->view, msg,
+                                                (uptr_t)key_str,
+                                                (sptr_t)out);
+    if (written < 0 || (uintmax_t)written >= (uintmax_t)capacity) {
+        out[capacity - 1] = '\0';
+        release_instance(inst);
+        free(key_str);
+        return -1;
+    }
+    size_t text_len = (size_t)written;
+    if (text_len > 0 && out[text_len - 1] == '\0') text_len--;
+    out[text_len] = '\0';
+    release_instance(inst);
+    free(key_str);
+    return (long)text_len;
+}
+
+long jerboa_scintilla_property_int(jerboa_scintilla_handle_t handle,
+                                  unsigned int msg,
+                                  const unsigned char *key,
+                                  size_t key_len,
+                                  long default_value) {
+    char *key_str = copy_ffi_bytes(key, key_len);
+    if (!key_str || memchr(key_str, '\0', key_len)) {
+        free(key_str);
+        return default_value;
+    }
+    instance_node_t *inst = acquire_instance(handle);
+    if (!inst) {
+        free(key_str);
+        return default_value;
+    }
+    long result = (long)scintilla_send_message(inst->view, msg,
+                                               (uptr_t)key_str,
+                                               (sptr_t)default_value);
+    release_instance(inst);
+    free(key_str);
+    return result;
+}
+
 /* ================================================================
    Input
    ================================================================ */
-void jerboa_scintilla_send_key(void *handle, int key,
+void jerboa_scintilla_send_key(jerboa_scintilla_handle_t handle, int key,
                               int shift, int ctrl, int alt) {
-    if (!handle) return;
-    scintilla_send_key((Scintilla *)handle, key, shift, ctrl, alt);
+    instance_node_t *inst = acquire_instance(handle);
+    if (!inst) return;
+    scintilla_send_key(inst->view, key, shift, ctrl, alt);
+    release_instance(inst);
 }
 
-int jerboa_scintilla_send_mouse(void *handle, int event, int button,
+int jerboa_scintilla_send_mouse(jerboa_scintilla_handle_t handle, int event, int button,
                                int y, int x,
                                int shift, int ctrl, int alt) {
-    if (!handle) return 0;
-    return scintilla_send_mouse((Scintilla *)handle, event, button,
-                                y, x, shift, ctrl, alt);
+    instance_node_t *inst = acquire_instance(handle);
+    if (!inst) return 0;
+    int result = scintilla_send_mouse(inst->view, event, button,
+                                      y, x, shift, ctrl, alt);
+    release_instance(inst);
+    return result;
 }
 
 /* ================================================================
    Display
    ================================================================ */
-void jerboa_scintilla_refresh(void *handle) {
-    if (!handle) return;
-    scintilla_refresh((Scintilla *)handle);
+void jerboa_scintilla_refresh(jerboa_scintilla_handle_t handle) {
+    instance_node_t *inst = acquire_instance(handle);
+    if (!inst) return;
+    scintilla_refresh(inst->view);
+    release_instance(inst);
 }
 
-void jerboa_scintilla_resize(void *handle, int width, int height) {
-    if (!handle) return;
-    scintilla_resize((Scintilla *)handle, width, height);
+void jerboa_scintilla_resize(jerboa_scintilla_handle_t handle, int width, int height) {
+    instance_node_t *inst = acquire_instance(handle);
+    if (!inst) return;
+    scintilla_resize(inst->view, width, height);
+    release_instance(inst);
 }
 
-void jerboa_scintilla_move(void *handle, int x, int y) {
-    if (!handle) return;
-    scintilla_move((Scintilla *)handle, x, y);
+void jerboa_scintilla_move(jerboa_scintilla_handle_t handle, int x, int y) {
+    instance_node_t *inst = acquire_instance(handle);
+    if (!inst) return;
+    scintilla_move(inst->view, x, y);
+    release_instance(inst);
 }
 
 /* ================================================================
    Clipboard
    ================================================================ */
-const char *jerboa_scintilla_get_clipboard(void *handle) {
-    if (!handle) return "";
+long jerboa_scintilla_clipboard_length(jerboa_scintilla_handle_t handle) {
+    instance_node_t *inst = acquire_instance(handle);
+    if (!inst) return -1;
     int len = 0;
-    char *buf = scintilla_get_clipboard((Scintilla *)handle, &len);
-    if (!buf || len < 0) return "";
-    /* Copy into static buffer so Chez can read it, then free */
-    if (len >= RECV_BUF_SIZE) len = RECV_BUF_SIZE - 1;
-    memcpy(recv_buf, buf, len);
-    recv_buf[len] = '\0';
+    char *buf = scintilla_get_clipboard(inst->view, &len);
+    free(buf);
+    release_instance(inst);
+    return len < 0 ? -1 : (long)len;
+}
+
+long jerboa_scintilla_receive_clipboard_bytes(
+    jerboa_scintilla_handle_t handle, unsigned char *out, size_t capacity) {
+    if (!out || capacity == 0) return -1;
+    instance_node_t *inst = acquire_instance(handle);