diff --git a/CHANGELOG.md b/CHANGELOG.md
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,5 +1,9 @@
 # Changelog
 
+## 0.11.1.1 - 2026-08-26
+
+- **A symlink target with a separator serialises to its POSIX spelling on every platform.** Windows stores a symlink's reparse point with backslashes, so a target written `bin/tool` reads back `bin\tool`, and `getSymbolicLinkTarget` returned that verbatim into the NAR. NAR targets are host-independent byte strings in the POSIX spelling, so the archive of the same logical tree diverged between Windows and POSIX, and an unpack-then-recheck round-trip failed its on-disk hash comparison on Windows for any symlink whose target had more than one component. Both producers now normalise the separator to `/` when reading a target on Windows, where a backslash is only ever a separator and encodes as the single byte `0x5C` that no multi-byte UTF-8 sequence contains, so the byte-level replacement is exact. POSIX is untouched, where a backslash is a legitimate filename byte.
+
 ## 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.
diff --git a/README.md b/README.md
--- a/README.md
+++ b/README.md
@@ -118,7 +118,7 @@
 cabal test
 ```
 
-Optional extras: `--flag server` builds the cache server, and the public `nova-cache:xz`, `nova-cache:bzip2`, and `nova-cache:zstandard` sublibraries carry the bounded codecs (liblzma, libbz2, and libzstd are bundled - no system libraries needed) - consumers depend on them with `build-depends: nova-cache:xz` and the like. Requires GHC 9.14+ and cabal-install 3.10+.
+Optional extras: `--flag server` builds the cache server, and the public `nova-cache:xz`, `nova-cache:bzip2`, and `nova-cache:zstandard` sublibraries carry the bounded codecs - consumers depend on them with `build-depends: nova-cache:xz` and the like. libbz2 and libzstd are bundled; liblzma comes from the `xz` package, which prefers a pkg-config system liblzma and falls back to its bundled `xz-clib` (pin `constraints: xz -system-xz` to force the bundled copy, as this repo's cabal.project does). Requires GHC 9.14+ and cabal-install 3.10+.
 
 ---
 
diff --git a/nova-cache.cabal b/nova-cache.cabal
--- a/nova-cache.cabal
+++ b/nova-cache.cabal
@@ -4,7 +4,7 @@
 -- 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.1.0
+version:            0.11.1.1
 synopsis:           Pure-first Nix binary cache protocol library
 description:
   A pure-first library implementing the Nix binary cache protocol -
diff --git a/src/NovaCache/NAR.hs b/src/NovaCache/NAR.hs
--- a/src/NovaCache/NAR.hs
+++ b/src/NovaCache/NAR.hs
@@ -364,7 +364,7 @@
 walkPath opts path = do
   isSym <- pathIsSymbolicLink path
   if isSym
-    then NarSymlink <$> (osPathBytes =<< getSymbolicLinkTarget path)
+    then NarSymlink <$> (symlinkTargetBytes =<< getSymbolicLinkTarget path)
     else do
       isDir <- doesDirectoryExist path
       if isDir
@@ -494,6 +494,26 @@
     fail ("serialiseFromPath: undecodable name: " ++ show err)
 #endif
 
+-- | A symlink target's NAR bytes.  Identical to 'osPathBytes' for a name,
+-- except that on Windows the separator is normalised to @\/@: Windows
+-- stores a reparse point with backslashes, so a target written @bin\/tool@
+-- reads back @bin\\tool@, and a NAR target is the POSIX spelling on every
+-- platform so the archive stays host-independent.  A backslash is only
+-- ever a separator on Windows, never a filename byte, and it encodes as
+-- the single byte @0x5C@ that no multi-byte UTF-8 sequence contains, so
+-- the byte-level replacement is exact.  On POSIX a backslash is a
+-- legitimate filename byte and is left untouched.
+symlinkTargetBytes :: OsPath -> IO ByteString
+#ifdef mingw32_HOST_OS
+symlinkTargetBytes path = BS.map normalizeSeparator <$> osPathBytes path
+  where
+    normalizeSeparator b = if b == backslashByte then forwardSlashByte else b
+    backslashByte = 0x5C
+    forwardSlashByte = 0x2F
+#else
+symlinkTargetBytes = osPathBytes
+#endif
+
 -- ---------------------------------------------------------------------------
 -- Streaming filesystem serialisation (IO boundary)
 -- ---------------------------------------------------------------------------
@@ -633,7 +653,7 @@
   isSym <- pathIsSymbolicLink path
   if isSym
     then do
-      target <- osPathBytes =<< getSymbolicLinkTarget path
+      target <- symlinkTargetBytes =<< getSymbolicLinkTarget path
       pure [PieceBytes (buildNode (NarSymlink target))]
     else do
       isDir <- doesDirectoryExist path
diff --git a/test/Main.hs b/test/Main.hs
--- a/test/Main.hs
+++ b/test/Main.hs
@@ -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, getPermissions, getTemporaryDirectory, listDirectory, removeDirectoryRecursive, setOwnerExecutable, setPermissions)
+import System.Directory (createDirectory, createFileLink, getPermissions, getTemporaryDirectory, listDirectory, removeDirectoryRecursive, setOwnerExecutable, setPermissions)
 import System.Exit (exitFailure, exitSuccess)
 import System.IO (hFlush, stdout)
 import qualified System.Info
@@ -573,6 +573,27 @@
             expected
             (sort (map baseName planned))
         pure (walkOk && planOk),
+      -- A symlink target with a separator serialises to the POSIX
+      -- spelling on every platform (nova-nix #112): Windows stores a
+      -- reparse point with backslashes, so a target written bin/tool
+      -- reads back bin\tool, and an unpack-then-recheck round-trip
+      -- diverges from the archive unless the NAR boundary normalises
+      -- it.  Both producers must agree with the forward-slash form.
+      -- Guarded on symlink privilege, which a Windows runner without
+      -- developer mode lacks.
+      test "a separator-bearing symlink target serialises with forward slashes" $ do
+        dir <- caseHackFixture "nova-cache-test-symlinksep"
+        created <- try (createFileLink "bin/tool" (dir <> "/link")) :: IO (Either SomeException ())
+        case created of
+          Left _ -> removeDirectoryRecursive dir >> pure True
+          Right () -> do
+            let expectedEntry = NAR.NarDirectory [("link", NAR.NarSymlink "bin/tool")]
+            eager <- NAR.serialiseFromPathWith NAR.CaseHackDisabled dir
+            streamed <- NAR.withNarSource NAR.CaseHackDisabled dir drainSource
+            removeDirectoryRecursive dir
+            eagerOk <- assertEqual "eager target spelling" expectedEntry eager
+            streamOk <- assertEqual "streamed target spelling" (NAR.serialise expectedEntry) streamed
+            pure (eagerOk && streamOk),
       -- 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
