diff --git a/CHANGELOG.md b/CHANGELOG.md
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -5,6 +5,16 @@
 
 The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
 
+## 0.3.6.0 - 2023-06-28
+
+### Added
+
+ - `allowThreads`, `runInGcSafeThread`
+
+ - Nix 2.15 support
+
+ - Nix 2.16 support
+
 ## 0.3.5.1 - 2023-03-06
 
 ### Added
diff --git a/hercules-ci-cnix-expr.cabal b/hercules-ci-cnix-expr.cabal
--- a/hercules-ci-cnix-expr.cabal
+++ b/hercules-ci-cnix-expr.cabal
@@ -1,7 +1,7 @@
 cabal-version: 2.4
 
 name:           hercules-ci-cnix-expr
-version:        0.3.5.1
+version:        0.3.6.0
 synopsis:       Bindings for the Nix evaluator
 category:       Nix, CI, Testing, DevOps
 homepage:       https://docs.hercules-ci.com
@@ -34,32 +34,42 @@
   description: Build for Nix >=2.4pre*
   default: True
 
-flag nix-2_5
+flag nix-2_15
   description: Build for Nix >=2.5pre*
   default: False
 
+flag ide
+  description: Whether to enable IDE workarounds. You shouldn't need this.
+  default: False
+
 -- match the C++ language standard Nix is using
 common cxx-opts
+  if flag(nix-2_15)
+    cxx-options:
+      -std=c++2a
+  else
+    cxx-options:
+      -std=c++17
+
   cxx-options:
-    -std=c++17
     -Wall
   extra-libraries: stdc++
 
-  if flag(nix-2_5)
-    cxx-options:
-      -DNIX_2_5
-    cpp-options:
-      -DNIX_2_5
-
   if os(darwin)
     -- avoid https://gitlab.haskell.org/ghc/ghc/issues/11829
     ld-options:  -Wl,-keep_dwarf_unwind
 
   if impl(ghc >= 8.10)
     ghc-options:
-      -optcxx-std=c++17
       -optcxx-Wall
+    if flag(nix-2_15)
+      ghc-options:
+        -optcxx-std=c++2a
+    else
+      ghc-options:
+        -optcxx-std=c++17
   else
+    -- Remove soon
     ghc-options:
       -optc-std=c++17
       -optc-Wall
@@ -113,8 +123,9 @@
       include
   install-includes:
       hercules-ci-cnix/expr.hxx
-  extra-libraries:
-      boost_context
+  if ! flag(ide)
+    extra-libraries:
+        boost_context
   pkgconfig-depends:
       nix-store >= 2.4
     , nix-expr >= 2.4
@@ -125,7 +136,7 @@
       nix-cmd >= 2.8
 
 
-test-suite hercules-ci-cnix-expr-tests
+test-suite hercules-ci-cnix-expr-unit-tests
   type: exitcode-stdio-1.0
   main-is: TestMain.hs
   other-modules:
diff --git a/src/Hercules/CNix/Expr.hs b/src/Hercules/CNix/Expr.hs
--- a/src/Hercules/CNix/Expr.hs
+++ b/src/Hercules/CNix/Expr.hs
@@ -8,6 +8,10 @@
 {-# LANGUAGE QuasiQuotes #-}
 {-# LANGUAGE TemplateHaskell #-}
 {-# LANGUAGE TypeFamilies #-}
+
+-- redundant-constraints: False positive in default signature for `toRawValue`
+{-# OPTIONS_GHC -Wno-redundant-constraints #-}
+
 #ifdef __GHCIDE__
 # define NIX_IS_AT_LEAST(mm,m,p) 1
 #endif
@@ -20,6 +24,8 @@
     setOption,
     setExtraStackOverflowHandlerToSleep,
     initThread,
+    allowThreads,
+    runGcRegisteredThread,
     logInfo,
     withEvalState,
     withEvalStateConduit,
@@ -91,6 +97,8 @@
 
 C.context (Hercules.CNix.Store.Context.context <> Hercules.CNix.Expr.Context.evalContext)
 
+C.verbatim "#define GC_THREADS 1"
+
 C.include "<stdio.h>"
 
 C.include "<cstring>"
@@ -137,7 +145,10 @@
     [C.throwBlock| void {
       nix::initNix();
       nix::initGC();
-#ifdef NIX_2_5
+#if NIX_IS_AT_LEAST(2,15,0)
+      globalConfig.set("extra-experimental-features", "flakes");
+#else
+#if NIX_IS_AT_LEAST(2,5,0)
       std::set<nix::ExperimentalFeature> features(nix::settings.experimentalFeatures.get());
       features.insert(nix::ExperimentalFeature::Flakes);
 #else
@@ -145,8 +156,10 @@
       features.push_back("flakes");
 #endif
       nix::settings.experimentalFeatures.assign(features);
+#endif
     } |]
 
+-- | Initialize the current (main) thread for stack overflow detection.
 initThread :: IO ()
 initThread =
   void
@@ -154,6 +167,47 @@
       nix::detectStackOverflow();
     }|]
 
+-- | Configure the garbage collector to support threads.
+--
+-- This is not needed when all evaluation happens on the main thread.
+allowThreads :: IO ()
+allowThreads =
+  void
+    [C.block| void {
+      GC_allow_register_threads();
+    }|]
+
+-- | Run in a thread from which GC may be triggered safely.
+--
+-- This also installs the stack overflow handler.
+--
+-- NOTE: Before using this, you must call 'allowThreads' once.
+runGcRegisteredThread :: IO a -> IO a
+runGcRegisteredThread io =
+  runInBoundThread do
+    bracket
+      start
+      (const end)
+      (const io)
+  where
+    start =
+      do
+        initThread
+        [C.block| void {
+          struct GC_stack_base sb;
+          int r = GC_get_stack_base(&sb);
+          assert(r == GC_SUCCESS);
+          GC_register_my_thread(&sb);
+        }|]
+        pass
+    end =
+      do
+        [C.block| void {
+          GC_unregister_my_thread();
+        }|]
+        pass
+
+
 {- | Configure the stack overflow handler to sleep before returning, allowing
   other threads to continue for a bit.
 
@@ -265,7 +319,13 @@
   mkRawValue
     =<< [C.throwBlock| Value* {
       Value value;
-      $(EvalState *evalState)->evalFile($(const char *filename'), value);
+      auto cstr = $(const char *filename');
+#if NIX_IS_AT_LEAST(2,16,0)
+      SourcePath path = CanonPath(cstr);
+#else
+      std::string path = cstr;
+#endif
+      $(EvalState *evalState)->evalFile(path, value);
       return new (NoGC) Value(value);
     }|]
 
@@ -462,7 +522,13 @@
   mkRawValue
     =<< [C.throwBlock| Value *{
       EvalState &evalState = *$(EvalState *evalState);
-      Expr *expr = evalState.parseExprFromString(std::string($bs-ptr:s, $bs-len:s), std::string($bs-ptr:basePath, $bs-len:basePath));
+      std::string basePathStr = std::string($bs-ptr:basePath, $bs-len:basePath);
+#if NIX_IS_AT_LEAST(2,16,0)
+      SourcePath basePath = CanonPath(basePathStr);
+#else
+      auto & basePath = basePathStr;
+#endif
+      Expr *expr = evalState.parseExprFromString(std::string($bs-ptr:s, $bs-len:s), basePath);
       Value *r = new (NoGC) Value();
       evalState.eval(expr, *r);
       return r;
@@ -628,10 +694,10 @@
   toRawValue _ = pure
 
 -- | Upcast
-instance ToRawValue (Value a)
+instance forall (a :: Type). ToRawValue (Value a)
 
 -- | Identity
-instance ToValue (Value a) where
+instance forall (a :: Type). ToValue (Value a) where
   type NixTypeFor (Value a) = a
   toValue _ = pure
 
diff --git a/src/Hercules/CNix/Expr/Schema.hs b/src/Hercules/CNix/Expr/Schema.hs
--- a/src/Hercules/CNix/Expr/Schema.hs
+++ b/src/Hercules/CNix/Expr/Schema.hs
@@ -270,11 +270,11 @@
 type MonadEval m = (MonadIO m, MonadReader (Ptr EvalState) m)
 
 -- | A combination of '>>=' and '#.'.
-(>>.) :: (KnownSymbol s, as . s ~ b, MonadEval m) => m (PSObject (Attrs' as w)) -> AttrLabel s -> m (PSObject b)
+(>>.) :: (KnownSymbol s, MonadEval m) => m (PSObject (Attrs' as w)) -> AttrLabel s -> m (PSObject (as . s))
 mas >>. p = mas >>= \as -> as #. p
 
 -- | Attribute selector. @a #. #b@ is @a.b@ in Nix. Operates on attributes that are required (@_.@) in the schema, throwing an error if necessary.
-(#.) :: (KnownSymbol s, as . s ~ b, MonadEval m) => PSObject (Attrs' as w) -> AttrLabel s -> m (PSObject b)
+(#.) :: (KnownSymbol s, MonadEval m) => PSObject (Attrs' as w) -> AttrLabel s -> m (PSObject (as . s))
 as #. p = do
   evalState <- ask
   let name = T.pack (symbolVal p)
@@ -284,11 +284,11 @@
     Just b -> pure PSObject {value = b, provenance = Attribute (provenance as) name}
 
 -- | A combination of '>>=' and '#?'.
-(>>?) :: (KnownSymbol s, as ? s ~ b, MonadEval m) => m (PSObject (Attrs' as w)) -> AttrLabel s -> m (Maybe (PSObject b))
+(>>?) :: (KnownSymbol s, MonadEval m) => m (PSObject (Attrs' as w)) -> AttrLabel s -> m (Maybe (PSObject (as ? s)))
 mas >>? p = mas >>= \as -> as #? p
 
 -- | Attribute selector. @a #? #b@ is @a.b@ in Nix, but handles the missing case without exception. Operates on attributes that are optional (@_?@) in the schema, throwing an error if necessary.
-(#?) :: (KnownSymbol s, as ? s ~ b, MonadEval m) => PSObject (Attrs' as w) -> AttrLabel s -> m (Maybe (PSObject b))
+(#?) :: (KnownSymbol s, MonadEval m) => PSObject (Attrs' as w) -> AttrLabel s -> m (Maybe (PSObject (as ? s)))
 as #? p = do
   evalState <- ask
   let name = T.pack (symbolVal p)
@@ -306,7 +306,7 @@
 --
 -- It provides a decent error message with attrset provenance, but can't provide
 -- extra context like you can when manually handling the @a '#?' b@ 'Nothing' case.
-(#?!) :: (KnownSymbol s, as ? s ~ b, MonadEval m) => PSObject (Attrs' as w) -> AttrLabel s -> m (PSObject b)
+(#?!) :: (KnownSymbol s, MonadEval m) => PSObject (Attrs' as w) -> AttrLabel s -> m (PSObject (as ? s))
 as #?! p = do
   as #? p >>= \case
     Nothing -> throwIO $ MissingAttribute (provenance as) (T.pack (symbolVal p))
@@ -392,7 +392,6 @@
 (|!) ::
   forall a b c m.
   ( CheckType (NixTypeForSchema a),
-    MonadIO m,
     MonadEval m,
     PossibleTypesForSchema a,
     PossibleTypesForSchema b
@@ -421,12 +420,12 @@
 englishOr (a : as) = a <> ", " <> englishOr as
 
 -- | Optional application.
-($?) :: (MonadEval m, PossibleTypesForSchema a, PossibleTypesForSchema b) => PSObject (a ->? b) -> PSObject a -> m (PSObject b)
+($?) :: (MonadEval m, PossibleTypesForSchema b) => PSObject (a ->? b) -> PSObject a -> m (PSObject b)
 x $? a =
   pure x >>$? pure a
 
 -- | Optional application. Like '$?' but takes care of monadic binding as a convenience.
-(>>$?) :: (MonadEval m, PossibleTypesForSchema a, PossibleTypesForSchema b) => m (PSObject (a ->? b)) -> m (PSObject a) -> m (PSObject b)
+(>>$?) :: (MonadEval m, PossibleTypesForSchema b) => m (PSObject (a ->? b)) -> m (PSObject a) -> m (PSObject b)
 x >>$? a =
   ( (\f -> a >>= (f .$))
       |! pure
@@ -434,7 +433,7 @@
     =<< x
 
 -- | Application. Like '$.' but takes care of monadic binding as a convenience.
-(>>$.) :: (MonadEval m, PossibleTypesForSchema a, PossibleTypesForSchema b) => m (PSObject (a ->. b)) -> m (PSObject a) -> m (PSObject b)
+(>>$.) :: (MonadEval m) => m (PSObject (a ->. b)) -> m (PSObject a) -> m (PSObject b)
 f >>$. a = do
   f' <- f
   a' <- a
diff --git a/test/Hercules/CNix/Expr/SchemaSpec.hs b/test/Hercules/CNix/Expr/SchemaSpec.hs
--- a/test/Hercules/CNix/Expr/SchemaSpec.hs
+++ b/test/Hercules/CNix/Expr/SchemaSpec.hs
@@ -8,7 +8,6 @@
 import qualified Hercules.CNix.Expr as Expr
 import Hercules.CNix.Expr.Raw (RawValueType (Attrs, Bool, Lambda, Null, String))
 import Hercules.CNix.Expr.Schema
-import Hercules.CNix.Expr.Schema (FromPSObject (fromPSObject))
 import Protolude hiding (TypeError, check, evalState)
 import SingleState (evalState)
 import Test.Hspec
diff --git a/test/Hercules/CNix/ExprSpec.hs b/test/Hercules/CNix/ExprSpec.hs
--- a/test/Hercules/CNix/ExprSpec.hs
+++ b/test/Hercules/CNix/ExprSpec.hs
@@ -136,7 +136,8 @@
                 ExitSuccess -> pass
             readProc exe args = do
               -- putErrText $ "Reading " <> show exe <> " " <> show args
-              (t, r) <- System.Process.withCreateProcess ((System.Process.proc exe args) {System.Process.cwd = Just dir, System.Process.std_out = System.Process.CreatePipe}) \_ (Just stdo) _ p -> do
+              (t, r) <- System.Process.withCreateProcess ((System.Process.proc exe args) {System.Process.cwd = Just dir, System.Process.std_out = System.Process.CreatePipe}) \_ stdom _ p -> do
+                stdo <- maybe (panic "no stdout") pure stdom
                 t <- T.hGetContents stdo
                 (t,) <$> waitForProcess p
               case r of
