diff --git a/CHANGELOG.md b/CHANGELOG.md
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,5 +1,20 @@
 # Changelog
 
+## 0.2.0.0 — 2026-02-22
+
+- New module: `NovaCache.Validate` — pure protocol validation layer
+- `ValidationError` sum type with 10 constructors covering sizes, store paths,
+  hash formats, content hashes, and Ed25519 signatures
+- `validateNarInfo` — field semantic validation (non-negative sizes, parseable
+  store paths/hashes/references), collects all errors instead of short-circuiting
+- `validateNarHash` / `validateFileHash` — SHA-256 content hash verification
+  against declared narinfo values
+- `validateSignature` — Ed25519 signature verification against a trusted public
+  key (at-least-one semantics, matching Nix behaviour)
+- `validateFull` — composes all four stages, collecting errors across all
+- 17 new tests (74 total across 9 groups)
+- No new dependencies
+
 ## 0.1.0.0 — 2026-02-21
 
 - Initial release (renamed from gb-nix-cache)
@@ -12,3 +27,6 @@
 - xz compression/decompression
 - Filesystem storage backend
 - Optional WAI cache server (behind `server` flag)
+- Path traversal protection on all store operations
+- Total port parsing (no partial `read`)
+- 54 tests across 8 modules
diff --git a/README.md b/README.md
--- a/README.md
+++ b/README.md
@@ -27,6 +27,7 @@
 - **Signing** — Ed25519 fingerprint signing and verification for binary cache trust
 - **Compression** — xz compress/decompress for NAR transport
 - **Store** — Filesystem storage backend for narinfo and NAR files
+- **Validate** — Pure protocol validation: field semantics, content hashes, Ed25519 signatures — all errors collected, not short-circuited
 - **Server** — Optional WAI/Warp HTTP server implementing the cache protocol (behind `server` cabal flag)
 
 Every module is pure by default. IO lives at the boundaries only.
@@ -91,6 +92,26 @@
     Right sig -> putStrLn ("Verified: " <> show (verify pk narinfo sig))
 ```
 
+### Validate an Upload
+
+```haskell
+import NovaCache.Validate (validateFull, ValidationError)
+import NovaCache.Signing (parsePublicKey)
+
+-- Pure validation — no IO needed
+validateUpload :: PublicKey -> NarInfo -> ByteString -> ByteString -> IO ()
+validateUpload pk narinfo narBytes fileBytes =
+  case validateFull pk narinfo narBytes fileBytes of
+    Right ()   -> putStrLn "Upload valid"
+    Left errs  -> mapM_ (putStrLn . ("  " ++) . show) errs
+
+-- Individual checks compose too
+import NovaCache.Validate (validateNarInfo, validateNarHash)
+
+checkNarInfo :: NarInfo -> Either [ValidationError] NarInfo
+checkNarInfo = validateNarInfo  -- collects ALL errors, not just the first
+```
+
 ---
 
 ## Modules
@@ -163,6 +184,27 @@
 -- "1;/nix/store/abc...;sha256:...;5678;"
 ```
 
+### NovaCache.Validate
+
+Pure protocol validation — field semantics, content hashes, and signatures. All validators collect every error instead of short-circuiting:
+
+```haskell
+import NovaCache.Validate
+
+-- Validate narinfo fields (sizes, store path, hash formats, references)
+case validateNarInfo narinfo of
+  Right ni   -> processUpload ni
+  Left errs  -> rejectWith errs  -- [InvalidStorePath ..., NegativeFileSize ...]
+
+-- Verify content hashes match declared values
+validateNarHash narinfo rawNarBytes       -- Either ValidationError ()
+validateFileHash narinfo compressedBytes  -- Either ValidationError ()
+
+-- Full pipeline: fields + nar hash + file hash + signatures
+validateFull publicKey narinfo narBytes fileBytes
+-- Either [ValidationError] ()
+```
+
 ---
 
 ## Server
@@ -211,8 +253,8 @@
   │  Base32 ──→ Hash ──→ StorePath           │
   │                         │                │
   │                      NarInfo ──→ Signing │
-  │                         │                │
-  │                        NAR               │
+  │                         │         │      │
+  │                        NAR    Validate   │
   │                                          │
   └──────────────────────────────────────────┘
                        │
@@ -222,8 +264,8 @@
   └──────────────────────────────────────────┘
 ```
 
-- **8 modules**, 6 pure + 2 at the IO boundary
-- **48 tests**, hand-rolled harness, no framework dependencies
+- **9 modules**, 7 pure + 2 at the IO boundary
+- **74 tests**, hand-rolled harness, no framework dependencies
 - **Zero partial functions** — total by construction
 - **Strict by default** — bang patterns on all data fields
 
@@ -282,6 +324,16 @@
 verify         :: PublicKey -> NarInfo -> Text -> Bool
 ```
 
+### Validate
+
+```haskell
+validateNarInfo  :: NarInfo -> Either [ValidationError] NarInfo
+validateNarHash  :: NarInfo -> ByteString -> Either ValidationError ()
+validateFileHash :: NarInfo -> ByteString -> Either ValidationError ()
+validateSignature :: PublicKey -> NarInfo -> Either [ValidationError] ()
+validateFull     :: PublicKey -> NarInfo -> ByteString -> ByteString -> Either [ValidationError] ()
+```
+
 ### Compression
 
 ```haskell
@@ -304,11 +356,32 @@
 
 ---
 
+## GitHub Action — CI Cache Seeding
+
+A reusable composite action is included for pushing Nix store paths to a nova-cache server from CI:
+
+```yaml
+- uses: Novavero-AI/nova-cache/.github/actions/seed@main
+  with:
+    cache-url: https://cache.example.com
+    api-key: ${{ secrets.CACHE_API_KEY }}
+```
+
+| Input | Required | Description |
+|-------|----------|-------------|
+| `cache-url` | yes | Base URL of the nova-cache server |
+| `api-key` | yes | Bearer token for authenticating uploads |
+| `paths` | no | Explicit store paths (space-separated). Defaults to all paths from `shell.nix` / `default.nix` |
+
+The action resolves Nix store path closures, exports them to a local binary cache, and uploads all narinfo + NAR files to the server. Works with any CI that has Nix installed.
+
+---
+
 ## Build & Test
 
 ```bash
 cabal build                              # Build library
-cabal test                               # Run all tests (48 tests, 8 groups)
+cabal test                               # Run all tests (74 tests, 9 groups)
 cabal build --ghc-options="-Werror"      # Warnings as errors
 cabal build --flag server                # Build with WAI server
 cabal haddock                            # Generate docs
diff --git a/nova-cache.cabal b/nova-cache.cabal
--- a/nova-cache.cabal
+++ b/nova-cache.cabal
@@ -1,11 +1,12 @@
 cabal-version:      3.0
 name:               nova-cache
-version:            0.1.0.0
+version:            0.2.0.0
 synopsis:           Pure Nix binary cache protocol library
 description:
   A focused, minimal, pure-first library implementing the full Nix binary
   cache protocol — nix-base32, NAR serialization, narinfo parsing, Ed25519
-  signing, store path handling — with an optional WAI server.
+  signing, store path handling, content validation — with an optional WAI
+  server.
 
 license:            MIT
 license-file:       LICENSE
@@ -36,6 +37,7 @@
     NovaCache.Signing
     NovaCache.Store
     NovaCache.StorePath
+    NovaCache.Validate
 
   build-depends:
       base                >= 4.16 && < 5
diff --git a/src/NovaCache/Validate.hs b/src/NovaCache/Validate.hs
new file mode 100644
--- /dev/null
+++ b/src/NovaCache/Validate.hs
@@ -0,0 +1,156 @@
+-- | Pure protocol validation for narinfo and NAR content.
+--
+-- Validates narinfo field semantics (sizes, store paths, hash formats),
+-- NAR/file content hashes against declared values, and Ed25519 signatures.
+-- All functions are pure — no IO, no side effects.
+module NovaCache.Validate
+  ( ValidationError (..),
+    validateNarInfo,
+    validateNarHash,
+    validateFileHash,
+    validateSignature,
+    validateFull,
+  )
+where
+
+import Data.ByteString (ByteString)
+import Data.Text (Text)
+import NovaCache.Hash (formatNixHash, hashBytes, parseNixHash)
+import NovaCache.NarInfo (NarInfo (..))
+import NovaCache.Signing (PublicKey, verify)
+import NovaCache.StorePath (defaultStoreDir, parseStorePath)
+
+-- ---------------------------------------------------------------------------
+-- Types
+-- ---------------------------------------------------------------------------
+
+-- | Validation errors for narinfo and NAR content.
+data ValidationError
+  = -- | FileSize field is negative.
+    NegativeFileSize !Integer
+  | -- | NarSize field is negative.
+    NegativeNarSize !Integer
+  | -- | StorePath field does not parse. (raw value, parse error)
+    InvalidStorePath !Text !String
+  | -- | FileHash field does not parse. (raw value, parse error)
+    InvalidFileHash !Text !String
+  | -- | NarHash field does not parse. (raw value, parse error)
+    InvalidNarHash !Text !String
+  | -- | A reference does not parse as a store path basename. (raw value, parse error)
+    InvalidReference !Text !String
+  | -- | Computed NAR hash differs from declared. (expected, actual)
+    NarHashMismatch !Text !Text
+  | -- | Computed file hash differs from declared. (expected, actual)
+    FileHashMismatch !Text !Text
+  | -- | A signature line failed verification.
+    SignatureInvalid !Text
+  | -- | The narinfo has zero signatures.
+    NoSignatures
+  deriving (Eq, Show)
+
+-- ---------------------------------------------------------------------------
+-- NarInfo field validation
+-- ---------------------------------------------------------------------------
+
+-- | Validate narinfo field semantics: sizes non-negative, store path parses,
+-- hash fields parse, references parse. Collects all errors (not short-circuit).
+-- Returns the 'NarInfo' unchanged on success for composition.
+validateNarInfo :: NarInfo -> Either [ValidationError] NarInfo
+validateNarInfo ni =
+  case concat [sizeErrors, storePathErrors, fileHashErrors, narHashErrors, refErrors] of
+    [] -> Right ni
+    errs -> Left errs
+  where
+    sizeErrors =
+      [NegativeFileSize (niFileSize ni) | niFileSize ni < 0]
+        ++ [NegativeNarSize (niNarSize ni) | niNarSize ni < 0]
+
+    storePathErrors = case parseStorePath defaultStoreDir (niStorePath ni) of
+      Left err -> [InvalidStorePath (niStorePath ni) err]
+      Right _ -> []
+
+    fileHashErrors = case parseNixHash (niFileHash ni) of
+      Left err -> [InvalidFileHash (niFileHash ni) err]
+      Right _ -> []
+
+    narHashErrors = case parseNixHash (niNarHash ni) of
+      Left err -> [InvalidNarHash (niNarHash ni) err]
+      Right _ -> []
+
+    refErrors = concatMap checkRef (niReferences ni)
+
+    checkRef ref = case parseStorePath defaultStoreDir ref of
+      Left err -> [InvalidReference ref err]
+      Right _ -> []
+
+-- ---------------------------------------------------------------------------
+-- Content hash validation
+-- ---------------------------------------------------------------------------
+
+-- | Validate that the SHA-256 hash of raw uncompressed NAR bytes matches
+-- the declared 'niNarHash'.
+validateNarHash :: NarInfo -> ByteString -> Either ValidationError ()
+validateNarHash ni narBytes =
+  let actual = formatNixHash (hashBytes narBytes)
+      expected = niNarHash ni
+   in if expected == actual
+        then Right ()
+        else Left (NarHashMismatch expected actual)
+
+-- | Validate that the SHA-256 hash of compressed file bytes matches
+-- the declared 'niFileHash'.
+validateFileHash :: NarInfo -> ByteString -> Either ValidationError ()
+validateFileHash ni fileBytes =
+  let actual = formatNixHash (hashBytes fileBytes)
+      expected = niFileHash ni
+   in if expected == actual
+        then Right ()
+        else Left (FileHashMismatch expected actual)
+
+-- ---------------------------------------------------------------------------
+-- Signature validation
+-- ---------------------------------------------------------------------------
+
+-- | Validate signatures against a trusted public key. At least one signature
+-- must verify (matches Nix behaviour — any trusted key suffices).
+-- Returns 'Left [NoSignatures]' when there are no signatures at all.
+validateSignature :: PublicKey -> NarInfo -> Either [ValidationError] ()
+validateSignature pk ni
+  | null sigs = Left [NoSignatures]
+  | any (verify pk ni) sigs = Right ()
+  | otherwise = Left (map SignatureInvalid sigs)
+  where
+    sigs = niSigs ni
+
+-- ---------------------------------------------------------------------------
+-- Full validation
+-- ---------------------------------------------------------------------------
+
+-- | Compose all validation stages: narinfo fields, NAR hash, file hash,
+-- and signatures. Collects all errors from all stages.
+validateFull ::
+  PublicKey ->
+  NarInfo ->
+  ByteString ->
+  ByteString ->
+  Either [ValidationError] ()
+validateFull pk ni narBytes fileBytes =
+  case concat [narInfoErrors, narHashErrors, fileHashErrors, sigErrors] of
+    [] -> Right ()
+    errs -> Left errs
+  where
+    narInfoErrors = case validateNarInfo ni of
+      Left errs -> errs
+      Right _ -> []
+
+    narHashErrors = case validateNarHash ni narBytes of
+      Left err -> [err]
+      Right _ -> []
+
+    fileHashErrors = case validateFileHash ni fileBytes of
+      Left err -> [err]
+      Right _ -> []
+
+    sigErrors = case validateSignature pk ni of
+      Left errs -> errs
+      Right _ -> []
diff --git a/test/Main.hs b/test/Main.hs
--- a/test/Main.hs
+++ b/test/Main.hs
@@ -16,6 +16,7 @@
 import qualified NovaCache.Signing as Signing
 import qualified NovaCache.Store as Store
 import qualified NovaCache.StorePath as StorePath
+import qualified NovaCache.Validate as Validate
 import System.Directory (createDirectoryIfMissing, removeDirectoryRecursive)
 import System.Exit (exitFailure, exitSuccess)
 import System.IO (hFlush, stdout)
@@ -122,7 +123,8 @@
       testNarInfo,
       testSigning,
       testCompression,
-      testFileStore
+      testFileStore,
+      testValidate
     ]
 
 -- ---------------------------------------------------------------------------
@@ -569,6 +571,175 @@
         result <- Store.readNarInfo store "../../etc/passwd"
         removeDirectoryRecursive tmpDir
         assertEqual "blocked" Nothing result
+    ]
+
+-- ---------------------------------------------------------------------------
+-- Validate tests
+-- ---------------------------------------------------------------------------
+
+-- | Bytes whose SHA-256 is used as the NarHash in 'mkValidNarInfo'.
+validNarBytes :: ByteString
+validNarBytes = BS.pack [1, 2, 3, 4, 5]
+
+-- | Bytes whose SHA-256 is used as the FileHash in 'mkValidNarInfo'.
+validFileBytes :: ByteString
+validFileBytes = BS.pack [10, 20, 30, 40, 50]
+
+-- | A narinfo that passes all field validation.
+mkValidNarInfo :: NarInfo.NarInfo
+mkValidNarInfo =
+  NarInfo.NarInfo
+    { NarInfo.niStorePath = "/nix/store/aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa-hello-1.0",
+      NarInfo.niUrl = "nar/test.nar.xz",
+      NarInfo.niCompression = "xz",
+      NarInfo.niFileHash = Hash.formatNixHash (Hash.hashBytes validFileBytes),
+      NarInfo.niFileSize = 5,
+      NarInfo.niNarHash = Hash.formatNixHash (Hash.hashBytes validNarBytes),
+      NarInfo.niNarSize = 5,
+      NarInfo.niReferences = ["aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa-hello-1.0"],
+      NarInfo.niDeriver = Nothing,
+      NarInfo.niSystem = Nothing,
+      NarInfo.niSigs = [],
+      NarInfo.niCA = Nothing
+    }
+
+testValidate :: IO Bool
+testValidate =
+  runGroup
+    "Validate"
+    [ test "validateNarInfo valid" $
+        assertEqual
+          "valid narinfo"
+          (Right mkValidNarInfo)
+          (Validate.validateNarInfo mkValidNarInfo),
+      test "validateNarInfo negative FileSize" $
+        let ni = mkValidNarInfo {NarInfo.niFileSize = -1}
+         in assertEqual
+              "negative filesize"
+              (Left [Validate.NegativeFileSize (-1)])
+              (Validate.validateNarInfo ni),
+      test "validateNarInfo negative NarSize" $
+        let ni = mkValidNarInfo {NarInfo.niNarSize = -1}
+         in assertEqual
+              "negative narsize"
+              (Left [Validate.NegativeNarSize (-1)])
+              (Validate.validateNarInfo ni),
+      test "validateNarInfo bad StorePath" $
+        let ni = mkValidNarInfo {NarInfo.niStorePath = "not-a-store-path"}
+         in case Validate.validateNarInfo ni of
+              Left [Validate.InvalidStorePath raw _] ->
+                assertEqual "raw value" "not-a-store-path" raw
+              other -> do
+                putStrLn ("    expected Left [InvalidStorePath ..], got: " ++ show other)
+                pure False,
+      test "validateNarInfo bad FileHash" $
+        let ni = mkValidNarInfo {NarInfo.niFileHash = "md5:bogus"}
+         in case Validate.validateNarInfo ni of
+              Left [Validate.InvalidFileHash raw _] ->
+                assertEqual "raw value" "md5:bogus" raw
+              other -> do
+                putStrLn ("    expected Left [InvalidFileHash ..], got: " ++ show other)
+                pure False,
+      test "validateNarInfo bad NarHash" $
+        let ni = mkValidNarInfo {NarInfo.niNarHash = "md5:bogus"}
+         in case Validate.validateNarInfo ni of
+              Left [Validate.InvalidNarHash raw _] ->
+                assertEqual "raw value" "md5:bogus" raw
+              other -> do
+                putStrLn ("    expected Left [InvalidNarHash ..], got: " ++ show other)
+                pure False,
+      test "validateNarInfo bad reference" $
+        let ni = mkValidNarInfo {NarInfo.niReferences = ["bad"]}
+         in case Validate.validateNarInfo ni of
+              Left [Validate.InvalidReference raw _] ->
+                assertEqual "raw value" "bad" raw
+              other -> do
+                putStrLn ("    expected Left [InvalidReference ..], got: " ++ show other)
+                pure False,
+      test "validateNarInfo multiple errors collected" $
+        let ni =
+              mkValidNarInfo
+                { NarInfo.niFileSize = -1,
+                  NarInfo.niNarSize = -1,
+                  NarInfo.niStorePath = "bad"
+                }
+         in case Validate.validateNarInfo ni of
+              Left errs -> assertTrue "at least 3 errors" (length errs >= 3)
+              Right _ -> do
+                putStrLn "    expected Left, got Right"
+                pure False,
+      test "validateNarHash correct" $
+        assertEqual
+          "correct nar hash"
+          (Right ())
+          (Validate.validateNarHash mkValidNarInfo validNarBytes),
+      test "validateNarHash wrong" $
+        case Validate.validateNarHash mkValidNarInfo (BS.pack [99]) of
+          Left (Validate.NarHashMismatch _ _) -> pure True
+          other -> do
+            putStrLn ("    expected Left NarHashMismatch, got: " ++ show other)
+            pure False,
+      test "validateFileHash correct" $
+        assertEqual
+          "correct file hash"
+          (Right ())
+          (Validate.validateFileHash mkValidNarInfo validFileBytes),
+      test "validateFileHash wrong" $
+        case Validate.validateFileHash mkValidNarInfo (BS.pack [99]) of
+          Left (Validate.FileHashMismatch _ _) -> pure True
+          other -> do
+            putStrLn ("    expected Left FileHashMismatch, got: " ++ show other)
+            pure False,
+      test "validateSignature valid" $ do
+        sk <- generateTestSecretKey
+        let pk = deriveTestPublicKey sk
+            ni = mkValidNarInfo
+        case Signing.sign sk ni of
+          Left err -> do
+            putStrLn ("  sign failed: " ++ err)
+            pure False
+          Right sig ->
+            let niSigned = ni {NarInfo.niSigs = [sig]}
+             in assertEqual "valid sig" (Right ()) (Validate.validateSignature pk niSigned),
+      test "validateSignature invalid" $ do
+        sk <- generateTestSecretKey
+        let pk = deriveTestPublicKey sk
+            bogusSig = "bogus-key:aW52YWxpZA=="
+            ni = mkValidNarInfo {NarInfo.niSigs = [bogusSig]}
+        assertEqual
+          "invalid sig"
+          (Left [Validate.SignatureInvalid bogusSig])
+          (Validate.validateSignature pk ni),
+      test "validateSignature no sigs" $ do
+        sk <- generateTestSecretKey
+        let pk = deriveTestPublicKey sk
+        assertEqual
+          "no sigs"
+          (Left [Validate.NoSignatures])
+          (Validate.validateSignature pk mkValidNarInfo),
+      test "validateFull all good" $ do
+        sk <- generateTestSecretKey
+        let pk = deriveTestPublicKey sk
+            ni = mkValidNarInfo
+        case Signing.sign sk ni of
+          Left err -> do
+            putStrLn ("  sign failed: " ++ err)
+            pure False
+          Right sig ->
+            let niSigned = ni {NarInfo.niSigs = [sig]}
+             in assertEqual
+                  "full valid"
+                  (Right ())
+                  (Validate.validateFull pk niSigned validNarBytes validFileBytes),
+      test "validateFull multiple failures" $ do
+        sk <- generateTestSecretKey
+        let pk = deriveTestPublicKey sk
+            ni = mkValidNarInfo {NarInfo.niFileSize = -1, NarInfo.niNarSize = -1}
+        case Validate.validateFull pk ni (BS.pack [99]) (BS.pack [99]) of
+          Left errs -> assertTrue "at least 4 errors" (length errs >= 4)
+          Right _ -> do
+            putStrLn "    expected Left, got Right"
+            pure False
     ]
 
 -- ---------------------------------------------------------------------------
