diff --git a/CHANGELOG.md b/CHANGELOG.md
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,5 +1,11 @@
 # Changelog
 
+## 0.11.1.0 - 2026-08-26
+
+- **The default executable answer on POSIX is the file's own owner-execute bit, matching upstream's dump.** `defaultExecBitResolver` (and so both producers before any custom resolver) read `getPermissions`, which answers from `access(2)`: what the calling process may do. Root read any execute bit as executable, ACLs counted, and a file the caller does not own answered by the caller's groups; upstream reads `st_mode & S_IXUSR`, a property of the file alone, and the flag lands in NAR bytes, so the divergence moved a hash exactly where the caller was unusual. The POSIX default now reads the mode bit directly; Windows keeps the extension answer, which is all the platform has.
+- **The xz codec's binding moves from the deprecated `lzma-static` to its successor, `xz`.** Hackage marks `lzma-static` deprecated in favor of `xz` and `xz-clib`, and it has been frozen at liblzma 5.2.5 since 2022. The successor comes from the same repository with the same `Codec.Compression.Lzma` module (its Haskell sources are unchanged across the rename), so `NovaCache.Xz` is untouched; what changes is the bundled C: a current liblzma via `xz-clib` instead of 2022's. Two consequences are pinned in place rather than left to chance. The successor asks pkg-config for a system liblzma by default and bundles only as a fallback, so which library a build linked would have depended on the host; `cabal.project` pins the flag off and every build links the bundled sources, the behavior `lzma-static` always had (a consumer wanting the same guarantee carries the same one-line constraint, since a dependency's flag cannot be set from a `.cabal` file). And the package description now needs Cabal 3.4: under 3.0 a bare `build-depends` name is shadowed by a like-named in-package component, so the `xz` sublibrary could not have named the `xz` package at all, the hazard the `zstandard` sublibrary's name already dodges.
+- **A per-file executable-bit resolver: `SerialiseOptions`, `serialiseFromPathOpts`, `withNarSourceOpts`.** Both NAR producers answered "is this file executable" only through `getPermissions`, which on Windows answers from the file extension, so a store that models the bit any other way (nova-nix keeps it in an NTFS alternate data stream) could not serialise correct NAR bytes through the streaming source at all, and wrapping the eager serialiser meant a second walk that re-derived on-disk paths from NAR entry names and got them wrong under the case hack. The resolver is called with the on-disk spelling the walk is actually reading - under the case hack, the suffixed name - and its answer lands verbatim in the executable flag, eager and streaming alike. `defaultSerialiseOptions` reproduces the previous behavior exactly, and the existing entry points are unchanged.
+
 ## 0.11.0.1 - 2026-08-21
 
 - **`NovaCache.Bzip2` accepts trailing bytes that do not begin another stream, matching upstream.** The decoder re-initialized at every clean stream end while input remained and fed whatever followed to libbz2's magic check, so a stray NUL, a newline, or any non-stream trailer after the last stream failed the decode. Upstream C++ Nix no longer has a bzip2 sink of its own: `compression.cc` drives every libarchive-supported codec through `ArchiveDecompressionSource`, and libarchive decodes the payload and ignores such a trailer. Measured against libarchive 3.8.2 driven exactly as Nix drives it (`filter_all` + `format_raw` + `format_empty`), four of seven cases diverged, so a historical `.nar.bz2` carrying one stray byte substituted under `nix copy` and failed here, falling through to a source build while the operator was told the cache object was corrupt. Trailing bytes that do begin a stream header are still decoded as a concatenated stream, and a truncated one is still refused: libarchive refuses those too, and silently truncating a real stream is the failure mode a bounded decoder exists to prevent. One divergence is deliberate and documented in place: a trailer that is a well-formed header carrying no block data (`BZh9` alone) is refused here and accepted by libarchive, which buffers past it, and refusing is the safer side of a case that does not arise in practice.
diff --git a/nova-cache.cabal b/nova-cache.cabal
--- a/nova-cache.cabal
+++ b/nova-cache.cabal
@@ -1,6 +1,10 @@
-cabal-version:      3.0
+cabal-version:      3.4
+-- 3.4 so a bare name in build-depends always means the external
+-- package: the xz sublibrary must depend on the xz package, and under
+-- 3.0 an in-package component name shadowed its external namesake
+-- (the hazard the zstandard library's name dodges, documented there).
 name:               nova-cache
-version:            0.11.0.1
+version:            0.11.1.0
 synopsis:           Pure-first Nix binary cache protocol library
 description:
   A pure-first library implementing the Nix binary cache protocol -
@@ -62,6 +66,12 @@
     , vector              >= 0.12 && < 0.14
     , wai                 >= 3.2 && < 3.3
 
+  -- The POSIX default executable answer reads the file mode directly
+  -- (S_IXUSR, as upstream's dump does); Windows has no mode bit and
+  -- no unix package.
+  if !os(windows)
+    build-depends: unix  >= 2.8 && < 2.9
+
   hs-source-dirs:   src
   default-language:  Haskell2010
   default-extensions:
@@ -75,8 +85,8 @@
 
 -- The bounded xz decoder as a solver-visible opt-in: a consumer that
 -- substitutes foreign caches writes build-depends: nova-cache:xz and
--- gets the module; everyone else never builds lzma-static's bundled
--- liblzma.  This replaces the manual xz flag - a dependency's flag
+-- gets the module; everyone else never builds the xz package's
+-- bundled liblzma.  This replaces the manual xz flag - a dependency's flag
 -- cannot be set from a consumer's .cabal file, which forced mirror
 -- flags and matching constraints downstream (nova-nix#27) - while
 -- keeping the 0.5.0.0 lesson: xz stays out of the default install.
@@ -97,7 +107,9 @@
   build-depends:
       base                >= 4.22 && < 5
     , bytestring          >= 0.11 && < 0.13
-    , lzma-static         >= 5.2.5 && < 5.3
+    -- The external xz package, not this very sublibrary: bare names
+    -- are external from cabal-version 3.4 on.
+    , xz                  >= 5.6.3 && < 5.7
 
 -- The bounded bzip2 decoder, the same solver-visible opt-in as xz.
 -- Historical cache.nixos.org narinfos declare Compression: bzip2, and
diff --git a/src/NovaCache/NAR.hs b/src/NovaCache/NAR.hs
--- a/src/NovaCache/NAR.hs
+++ b/src/NovaCache/NAR.hs
@@ -29,7 +29,13 @@
     narHash,
     serialiseFromPath,
     serialiseFromPathWith,
+    serialiseFromPathOpts,
     withNarSource,
+    withNarSourceOpts,
+    SerialiseOptions (..),
+    defaultSerialiseOptions,
+    ExecBitResolver,
+    defaultExecBitResolver,
     CaseHack (..),
     defaultCaseHack,
     caseHackSuffix,
@@ -70,8 +76,6 @@
 import System.Directory.OsPath
   ( doesDirectoryExist,
     doesFileExist,
-    executable,
-    getPermissions,
     getSymbolicLinkTarget,
     listDirectory,
     pathIsSymbolicLink,
@@ -82,10 +86,12 @@
 #ifdef mingw32_HOST_OS
 import qualified Data.Text as T
 import qualified Data.Text.Encoding as TE
+import System.Directory.OsPath (executable, getPermissions)
 import System.IO (Handle, IOMode (ReadMode), hClose, hFileSize, openBinaryFile)
 #else
 import qualified Data.ByteString.Char8 as BS8
 import System.IO (Handle, IOMode (ReadMode), hClose, hFileSize, latin1, openBinaryFile)
+import qualified System.Posix.Files as Posix
 #endif
 
 -- ---------------------------------------------------------------------------
@@ -277,6 +283,61 @@
 caseHackSuffix :: ByteString
 caseHackSuffix = "~nix~case~hack~"
 
+-- | Answers "is this file executable" for one regular file during a
+-- walk.  Called with the platform-native ON-DISK path - under
+-- 'CaseHackEnabled' that is the suffixed spelling the walk is
+-- reading, not the stripped NAR entry name - and the answer lands
+-- verbatim in the entry's (or stream's) executable flag.  Lets a
+-- store that models the bit outside POSIX permissions (an NTFS
+-- alternate data stream, a sidecar, a database) serialise correct
+-- NAR bytes in the same walk, streaming included.
+--
+-- The path is the walk's own 'decodeFS' rendering: pass it to any
+-- 'FilePath'-taking API as-is; 'encodeFS' it first to compare raw
+-- bytes, since a non-UTF-8 name arrives with surrogate escapes.
+type ExecBitResolver = FilePath -> IO Bool
+
+#ifdef mingw32_HOST_OS
+
+-- | The stock resolver on Windows: 'getPermissions', which answers
+-- from the file extension - the platform has no mode bit to read.
+defaultExecBitResolver :: ExecBitResolver
+defaultExecBitResolver path = executable <$> (getPermissions =<< encodeFS path)
+
+#else
+
+-- | The stock resolver on POSIX: the owner-execute bit of the file's
+-- own mode, as upstream's dump reads it (@st_mode & S_IXUSR@).  Not
+-- 'System.Directory.OsPath.getPermissions', which answers from
+-- @access(2)@ - what the CALLING PROCESS may do - and so diverges for
+-- root (any execute bit reads as executable), ACLs, and files the
+-- caller does not own; the flag lands in NAR bytes, so that
+-- divergence moves a hash (#65).
+defaultExecBitResolver :: ExecBitResolver
+defaultExecBitResolver path = do
+  status <- Posix.getSymbolicLinkStatus path
+  pure (Posix.intersectFileModes (Posix.fileMode status) Posix.ownerExecuteMode /= Posix.nullFileMode)
+
+#endif
+
+-- | How a tree is read for serialisation: the case-hack mode and the
+-- executable-bit source.
+data SerialiseOptions = SerialiseOptions
+  { -- | Directory-name case-hack resolution (see 'CaseHack').
+    soCaseHack :: !CaseHack,
+    -- | Where a regular file's executable flag comes from.
+    soExecBit :: !ExecBitResolver
+  }
+
+-- | 'defaultCaseHack' and 'defaultExecBitResolver': with these,
+-- 'serialiseFromPathOpts' is exactly 'serialiseFromPath'.
+defaultSerialiseOptions :: SerialiseOptions
+defaultSerialiseOptions =
+  SerialiseOptions
+    { soCaseHack = defaultCaseHack,
+      soExecBit = defaultExecBitResolver
+    }
+
 -- | Walk a filesystem path and build a 'NarEntry' under
 -- 'defaultCaseHack'.
 --
@@ -284,36 +345,40 @@
 -- classifies each path as symlink, directory, or regular file and
 -- delegates to pure constructors.
 serialiseFromPath :: FilePath -> IO NarEntry
-serialiseFromPath = serialiseFromPathWith defaultCaseHack
+serialiseFromPath = serialiseFromPathOpts defaultSerialiseOptions
 
 -- | 'serialiseFromPath' with the case-hack mode explicit, for callers
 -- and tests that need behavior independent of the host platform.
 serialiseFromPathWith :: CaseHack -> FilePath -> IO NarEntry
-serialiseFromPathWith mode path = walkPath mode =<< encodeFS path
+serialiseFromPathWith mode = serialiseFromPathOpts defaultSerialiseOptions {soCaseHack = mode}
 
+-- | 'serialiseFromPath' with every knob explicit.
+serialiseFromPathOpts :: SerialiseOptions -> FilePath -> IO NarEntry
+serialiseFromPathOpts opts path = walkPath opts =<< encodeFS path
+
 -- | Walk one platform-native path.  The walk runs on 'OsPath' so child
 -- names reach the archive byte-true ('osPathBytes'); only the root
 -- enters as 'FilePath', and the root's own name never appears in a
 -- NAR.
-walkPath :: CaseHack -> OsPath -> IO NarEntry
-walkPath mode path = do
+walkPath :: SerialiseOptions -> OsPath -> IO NarEntry
+walkPath opts path = do
   isSym <- pathIsSymbolicLink path
   if isSym
     then NarSymlink <$> (osPathBytes =<< getSymbolicLinkTarget path)
     else do
       isDir <- doesDirectoryExist path
       if isDir
-        then buildDirectory mode path
-        else buildRegularFile path
+        then buildDirectory opts path
+        else buildRegularFile (soExecBit opts) path
 
 -- | Build a directory entry by recursively walking children.
-buildDirectory :: CaseHack -> OsPath -> IO NarEntry
-buildDirectory mode path = do
-  resolved <- resolvedDirEntries mode path
+buildDirectory :: SerialiseOptions -> OsPath -> IO NarEntry
+buildDirectory opts path = do
+  resolved <- resolvedDirEntries (soCaseHack opts) path
   NarDirectory <$> traverse walkChild resolved
   where
     walkChild (entryName, diskName) = do
-      entry <- walkPath mode (path </> diskName)
+      entry <- walkPath opts (path </> diskName)
       pure (entryName, entry)
 
 -- | A directory's children as (NAR name, on-disk name) pairs under the
@@ -369,13 +434,13 @@
         [] -> Right resolved
 
 -- | Build a regular file entry, checking the executable bit.
-buildRegularFile :: OsPath -> IO NarEntry
-buildRegularFile path = do
+buildRegularFile :: ExecBitResolver -> OsPath -> IO NarEntry
+buildRegularFile resolver path = do
   isFile <- doesFileExist path
   if isFile
     then do
       contents <- readFileBytes path
-      isExec <- checkExecutable path
+      isExec <- resolveExecBit resolver path
       pure (NarRegular isExec contents)
     else specialFileFailure path
 
@@ -389,11 +454,11 @@
   shownPath <- decodeFS path
   fail ("serialiseFromPath: not a regular file (special or vanished): " ++ shownPath)
 
--- | Check whether a file has the executable permission set.
--- Uses 'System.Directory.OsPath.getPermissions' which is cross-platform:
--- checks the user-execute bit on Unix, file extension on Windows.
-checkExecutable :: OsPath -> IO Bool
-checkExecutable path = executable <$> getPermissions path
+-- | Run the resolver on a platform-native path.  The resolver contract
+-- is 'FilePath', so the path bridges through 'decodeFS' - the same
+-- interop seam as 'readFileBytes'.
+resolveExecBit :: ExecBitResolver -> OsPath -> IO Bool
+resolveExecBit resolver path = resolver =<< decodeFS path
 
 -- | Read a file's contents by platform-native path.  The byte-string
 -- file API still takes 'FilePath', so the path bridges through
@@ -459,8 +524,9 @@
 
 -- | Serialise a filesystem tree as a pull source of NAR chunks,
 -- without ever holding a file's contents in memory: the tree's
--- structure is planned up front (names, kinds, symlink targets -
--- never contents), then each pull returns the next chunk, reading
+-- structure is planned up front (names, kinds, symlink targets,
+-- executable flags - never contents), so the resolver's IO runs
+-- before the first pull; then each pull returns the next chunk, reading
 -- regular files 128 KiB at a time.  The empty chunk
 -- means end of input and repeats on further pulls - the convention
 -- 'NovaCache.Store.writeNarStreaming' consumes, so the two ends
@@ -475,9 +541,13 @@
 -- mid-stream fails loudly rather than emitting a torn archive.  Any
 -- file handle still open when the continuation exits is closed.
 withNarSource :: CaseHack -> FilePath -> (IO ByteString -> IO a) -> IO a
-withNarSource mode root consume = do
+withNarSource mode = withNarSourceOpts defaultSerialiseOptions {soCaseHack = mode}
+
+-- | 'withNarSource' with every knob explicit.
+withNarSourceOpts :: SerialiseOptions -> FilePath -> (IO ByteString -> IO a) -> IO a
+withNarSourceOpts opts root consume = do
   rootPath <- encodeFS root
-  segments <- planSegments mode rootPath
+  segments <- planSegments opts rootPath
   stateRef <- newIORef (SourceSegments segments)
   consume (pullChunk stateRef) `finally` closeCurrent stateRef
   where
@@ -532,9 +602,9 @@
 -- | Plan the archive: every structural byte rendered, file contents
 -- deferred as 'SegmentFile's.  Holds structure only - O(entries),
 -- never contents.
-planSegments :: CaseHack -> OsPath -> IO [NarSegment]
-planSegments mode path = do
-  pieces <- planNode mode path
+planSegments :: SerialiseOptions -> OsPath -> IO [NarSegment]
+planSegments opts path = do
+  pieces <- planNode opts path
   pure (coalesce (PieceBytes (narStr tokMagic) : pieces))
 
 -- | Plan pieces before coalescing: structural builders, or a deferred
@@ -558,8 +628,8 @@
        in if BS.null bytes then segments else SegmentBytes bytes : segments
 
 -- | Plan one node, mirroring 'walkPath'.
-planNode :: CaseHack -> OsPath -> IO [PlanPiece]
-planNode mode path = do
+planNode :: SerialiseOptions -> OsPath -> IO [PlanPiece]
+planNode opts path = do
   isSym <- pathIsSymbolicLink path
   if isSym
     then do
@@ -568,15 +638,15 @@
     else do
       isDir <- doesDirectoryExist path
       if isDir
-        then planDirectory mode path
-        else planRegular path
+        then planDirectory opts path
+        else planRegular (soExecBit opts) path
 
 -- | Plan a directory.  Children are ordered by their NAR-name bytes -
 -- the same order 'buildNode' emits - not by on-disk order, which can
 -- differ on Windows where 'OsPath' sorts by UTF-16 units.
-planDirectory :: CaseHack -> OsPath -> IO [PlanPiece]
-planDirectory mode path = do
-  resolved <- resolvedDirEntries mode path
+planDirectory :: SerialiseOptions -> OsPath -> IO [PlanPiece]
+planDirectory opts path = do
+  resolved <- resolvedDirEntries (soCaseHack opts) path
   children <- traverse planChild (sortBy (comparing fst) resolved)
   pure
     ( PieceBytes (narStr tokLParen <> narStr tokType <> narStr tokDirectory)
@@ -585,7 +655,7 @@
     )
   where
     planChild (entryName, diskName) = do
-      node <- planNode mode (path </> diskName)
+      node <- planNode opts (path </> diskName)
       pure
         ( PieceBytes
             ( narStr tokEntry
@@ -601,12 +671,12 @@
 -- | Plan a regular file: the node's structure now, its contents at
 -- pull time.  The pieces mirror 'buildNode' on 'NarRegular' exactly,
 -- with the contents wire string (length, bytes, padding) deferred.
-planRegular :: OsPath -> IO [PlanPiece]
-planRegular path = do
+planRegular :: ExecBitResolver -> OsPath -> IO [PlanPiece]
+planRegular resolver path = do
   isFile <- doesFileExist path
   if isFile
     then do
-      isExec <- checkExecutable path
+      isExec <- resolveExecBit resolver path
       pure
         [ PieceBytes
             ( narStr tokLParen
diff --git a/src/NovaCache/NAR/Stream.hs b/src/NovaCache/NAR/Stream.hs
--- a/src/NovaCache/NAR/Stream.hs
+++ b/src/NovaCache/NAR/Stream.hs
@@ -169,8 +169,8 @@
 narStream = narStreamBounded maxWireStringBytes
 
 -- | The largest structural-string bound 'narStreamBounded' honors:
--- 'Int''s ceiling less alignment headroom, so a payload at the bound
--- still fits 'Int' together with its padding.
+-- the ceiling of 'Int' less alignment headroom, so a payload at the
+-- bound still fits 'Int' together with its padding.
 structuralBoundCeiling :: Word64
 structuralBoundCeiling =
   fromIntegral (maxBound :: Int) - fromIntegral (narAlignment - 1)
diff --git a/src/NovaCache/Xz.hs b/src/NovaCache/Xz.hs
--- a/src/NovaCache/Xz.hs
+++ b/src/NovaCache/Xz.hs
@@ -15,8 +15,8 @@
 -- @LZMA_CONCATENATED@ decoder in libutil's compression sink.
 --
 -- This module lives in the public @nova-cache:xz@ sublibrary.  The
--- @lzma-static@ dependency bundles liblzma's C sources, so no system
--- library is needed on any platform - but it is still an extra C
+-- @xz@ dependency bundles liblzma's C sources (via @xz-clib@), so no
+-- system library is needed on any platform - but it is still an extra C
 -- build that consumers without foreign-cache needs should not pay
 -- for, and a default-on compression dependency broke downstream
 -- installs once already (0.5.0.0).  Consumers that substitute from
@@ -154,16 +154,13 @@
 -- same exception.
 --
 -- Despite the bracket-shaped name there is no bracket to run: the
--- binding ("Codec.Compression.Lzma") exposes no teardown for a live
--- 'Lzma.DecompressStream' - it runs @lzma_end@ itself on the clean
--- end path and otherwise leaves it to the stream's ForeignPtr
--- finalizer.  A pull that throws, or a consumer that exits early,
--- therefore strands the decoder state (up to 'xzMaxDecoderMemoryBytes')
--- until a GC runs the finalizer.  Undo condition: a lzma-static
--- release surfacing live-stream teardown in the high-level API (its
--- internal @LibLzma.endLzmaStream@ is what the fix needs), at which
--- point this becomes a real bracket ending the stream on every exit
--- path.
+-- binding ("Codec.Compression.Lzma") runs @lzma_end@ itself on the
+-- clean end path and otherwise frees the decoder through the
+-- stream's ForeignPtr finalizer - the lifecycle the ecosystem's
+-- zlib and bzlib bindings use for the same job.  A pull that
+-- throws, or a consumer that exits early, holds the decoder state
+-- (bounded by 'xzMaxDecoderMemoryBytes') until a GC runs the
+-- finalizer.
 withXzSource :: XzLimits -> IO ByteString -> (IO ByteString -> IO a) -> IO a
 withXzSource limits compressedSource consume = do
   start <- Lzma.decompressIO (decompressParams limits)
diff --git a/test/Main.hs b/test/Main.hs
--- a/test/Main.hs
+++ b/test/Main.hs
@@ -11,8 +11,8 @@
 import qualified Data.ByteString.Base64 as B64
 import qualified Data.ByteString.Lazy as BL
 import Data.Either (isLeft)
-import Data.IORef (atomicModifyIORef', newIORef)
-import Data.List (sort)
+import Data.IORef (atomicModifyIORef', newIORef, readIORef)
+import Data.List (isSuffixOf, sort)
 import Data.Maybe (isJust)
 import Data.Text (Text)
 import qualified Data.Text as T
@@ -31,7 +31,7 @@
 import qualified NovaCache.Store as Store
 import qualified NovaCache.StorePath as StorePath
 import qualified NovaCache.Validate as Validate
-import System.Directory (createDirectory, getTemporaryDirectory, listDirectory, removeDirectoryRecursive)
+import System.Directory (createDirectory, getPermissions, getTemporaryDirectory, listDirectory, removeDirectoryRecursive, setOwnerExecutable, setPermissions)
 import System.Exit (exitFailure, exitSuccess)
 import System.IO (hFlush, stdout)
 import qualified System.Info
@@ -501,6 +501,78 @@
         pure $ case (outcome :: Either SomeException NAR.NarEntry) of
           Left _ -> True
           Right _ -> False,
+      -- The resolver is the executable-bit source: its answer lands
+      -- verbatim, independent of permissions or extension.
+      test "serialiseFromPathOpts takes the exec bit from the resolver" $ do
+        dir <- caseHackFixture "nova-cache-test-execresolver"
+        BS.writeFile (dir <> "/plain") "p"
+        let opts =
+              NAR.defaultSerialiseOptions
+                { NAR.soCaseHack = NAR.CaseHackDisabled,
+                  NAR.soExecBit = \_ -> pure True
+                }
+        entry <- NAR.serialiseFromPathOpts opts dir
+        removeDirectoryRecursive dir
+        assertEqual
+          "resolver answer"
+          (NAR.NarDirectory [("plain", NAR.NarRegular True "p")])
+          entry,
+      -- A real on-disk exec bit round-trips through the default
+      -- resolver; no earlier fixture ever set one.  POSIX-gated:
+      -- Windows has no mode bit for the default to read.
+      test "a real owner-exec bit round-trips from disk" $
+        if System.Info.os == "mingw32"
+          then pure True
+          else do
+            dir <- caseHackFixture "nova-cache-test-realexec"
+            BS.writeFile (dir <> "/doc") "txt"
+            BS.writeFile (dir <> "/tool") "bin"
+            perms <- getPermissions (dir <> "/tool")
+            setPermissions (dir <> "/tool") (setOwnerExecutable True perms)
+            entry <- NAR.serialiseFromPathWith NAR.CaseHackDisabled dir
+            removeDirectoryRecursive dir
+            assertEqual
+              "owner bit from the file mode"
+              ( NAR.NarDirectory
+                  [ ("doc", NAR.NarRegular False "txt"),
+                    ("tool", NAR.NarRegular True "bin")
+                  ]
+              )
+              entry,
+      -- The resolver contract, pinned as an exact call set for both
+      -- producers: once per regular file, never for the directory,
+      -- and always the on-disk spelling - under the case hack, the
+      -- suffixed name the walk reads, not the stripped entry name.
+      test "the resolver sees the on-disk case-hacked spelling" $ do
+        dir <- caseHackFixture "nova-cache-test-execdisk"
+        BS.writeFile (dir <> "/Foo") "upper"
+        BS.writeFile (dir <> "/foo~nix~case~hack~1") "lower"
+        seenWalk <- newIORef []
+        seenPlan <- newIORef []
+        let recordingOpts ref =
+              NAR.defaultSerialiseOptions
+                { NAR.soCaseHack = NAR.CaseHackEnabled,
+                  NAR.soExecBit = \path ->
+                    atomicModifyIORef' ref (\paths -> (path : paths, ())) >> pure False
+                }
+            baseName = reverse . takeWhile (\c -> c /= '/' && c /= '\\') . reverse
+        _ <- NAR.serialiseFromPathOpts (recordingOpts seenWalk) dir
+        _ <- NAR.withNarSourceOpts (recordingOpts seenPlan) dir drainSource
+        removeDirectoryRecursive dir
+        walked <- readIORef seenWalk
+        planned <- readIORef seenPlan
+        let expected = ["Foo", "foo~nix~case~hack~1"]
+        walkOk <-
+          assertEqual
+            "eager walk call set"
+            expected
+            (sort (map baseName walked))
+        planOk <-
+          assertEqual
+            "streaming plan call set"
+            expected
+            (sort (map baseName planned))
+        pure (walkOk && planOk),
       -- The walk's boundary encoding: a Unicode disk name enters the
       -- archive as its UTF-8 bytes on every platform.
       test "serialiseFromPath encodes a Unicode disk name as UTF-8" $ do
@@ -765,6 +837,31 @@
         streamed <- NAR.withNarSource NAR.CaseHackEnabled dir drainSource
         removeDirectoryRecursive dir
         assertTrue "bytes equal" (NAR.serialise entry == streamed),
+      -- The streaming planner asks the same resolver the eager walk
+      -- does, so a custom executable-bit source streams byte-equal.
+      test "withNarSourceOpts streams the resolver's flags byte-identically" $ do
+        dir <- caseHackFixture "nova-cache-test-narsource-exec"
+        BS.writeFile (dir <> "/doc") "txt"
+        BS.writeFile (dir <> "/tool") "bin"
+        let opts =
+              NAR.defaultSerialiseOptions
+                { NAR.soCaseHack = NAR.CaseHackDisabled,
+                  NAR.soExecBit = \path -> pure ("tool" `isSuffixOf` path)
+                }
+        entry <- NAR.serialiseFromPathOpts opts dir
+        streamed <- NAR.withNarSourceOpts opts dir drainSource
+        removeDirectoryRecursive dir
+        flagsOk <-
+          assertEqual
+            "eager flags"
+            ( NAR.NarDirectory
+                [ ("doc", NAR.NarRegular False "txt"),
+                  ("tool", NAR.NarRegular True "bin")
+                ]
+            )
+            entry
+        bytesOk <- assertTrue "stream equals serialise" (NAR.serialise entry == streamed)
+        pure (flagsOk && bytesOk),
       test "withNarSource keeps returning empty after the end" $ do
         dir <- caseHackFixture "nova-cache-test-narsource-end"
         BS.writeFile (dir <> "/f") "x"
