diff --git a/ChangeLog.md b/ChangeLog.md
new file mode 100644
--- /dev/null
+++ b/ChangeLog.md
@@ -0,0 +1,9 @@
+# Revision history for icloud-notes
+
+`icloud-notes` uses [PVP Versioning][1].
+
+## 0.1.0.0 -- 2026-07-28
+
+* Initial version.
+
+[1]: https://pvp.haskell.org
diff --git a/LICENSE b/LICENSE
new file mode 100644
--- /dev/null
+++ b/LICENSE
@@ -0,0 +1,30 @@
+Copyright (c) 2023, Tim Emiola
+
+All rights reserved.
+
+Redistribution and use in source and binary forms, with or without
+modification, are permitted provided that the following conditions are met:
+
+    * Redistributions of source code must retain the above copyright
+      notice, this list of conditions and the following disclaimer.
+
+    * Redistributions in binary form must reproduce the above
+      copyright notice, this list of conditions and the following
+      disclaimer in the documentation and/or other materials provided
+      with the distribution.
+
+    * Neither the name of Tim Emiola nor the names of other
+      contributors may be used to endorse or promote products derived
+      from this software without specific prior written permission.
+
+THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
+"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
+LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
+A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT
+OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
+SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT
+LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,
+DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY
+THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
+(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
+OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
diff --git a/README.md b/README.md
new file mode 100644
--- /dev/null
+++ b/README.md
@@ -0,0 +1,98 @@
+# hstratus-notes — access to iCloud Notes
+
+`hstratus-notes` reads notes and folders from iCloud Notes using an authenticated
+session from [`hstratus-auth`](https://github.com/adetokunbo/hstratus/tree/main/hstratus-auth/).
+
+The library provides read-only access to the Notes CloudKit database: listing
+folders, fetching recent notes, and downloading note content.
+
+
+## Disclaimer — use at your own risk
+
+- This library is **unofficial** and not supported by Apple.
+- The iCloud Notes API it uses is undocumented and may change without notice.
+
+
+## Usage
+
+After a successful login with `hstratus-auth`, construct a `NotesApi` value and
+use it to browse notes.
+
+### Listing recent notes
+
+```haskell
+import Network.HStratus.Http (mkApi, login, AuthState (..))
+import Network.HStratus.Http.Endpoints (Realm (..))
+import Network.HStratus.Notes
+
+example :: IO ()
+example = do
+  api <- mkApi Usual
+  result <- login api
+  case result of
+    Authenticated sess ad -> do
+      na    <- mkNotesApi ad sess api
+      notes <- recentNotes na
+      mapM_ print notes
+    _ -> putStrLn "Unexpected result"
+```
+
+### Listing folders
+
+```haskell
+foldersExample :: NotesApi -> IO ()
+foldersExample na = do
+  folders <- noteFolders na
+  mapM_ print folders
+```
+
+### Listing notes in a folder
+
+```haskell
+folderNotesExample :: NotesApi -> FolderId -> IO ()
+folderNotesExample na fid = do
+  notes <- notesInFolder na fid
+  mapM_ print notes
+```
+
+### Fetching and decoding a note body
+
+```haskell
+import qualified Data.Text.IO as TIO
+import Network.HStratus.Notes
+import Network.HStratus.Notes.Markdown (noteToMarkdown)
+
+getNoteExample :: NotesApi -> NoteId -> IO ()
+getNoteExample na nid = do
+  mnote <- getNote na nid
+  case mnote of
+    Nothing   -> putStrLn "Note not found"
+    Just note -> do
+      result <- decodeNoteBody (noteBodyBytes note)
+      case result of
+        Left err -> putStrLn $ "Decode error: " <> err
+        Right nt -> TIO.putStrLn (noteToMarkdown nt)
+```
+
+`getNote` returns `Nothing` when the note has been deleted since the listing
+was fetched.  `decodeNoteBody` returns `Left` when the compressed protobuf
+cannot be decoded.
+
+
+## CLI usage
+
+A command-line interface using this behaviour is provided by the [`hstratus`](https://github.com/adetokunbo/hstratus/tree/main/hstratus/#readme)
+package.
+
+| Command | Description |
+|---------|-------------|
+| [`hstratus notes list-note-folders`](https://github.com/adetokunbo/hstratus/tree/main/hstratus/#hstratus-notes-list-note-folders) | List all Notes folders |
+| [`hstratus notes list-notes`](https://github.com/adetokunbo/hstratus/tree/main/hstratus/#hstratus-notes-list-notes) | List notes, optionally filtered by folder name |
+| [`hstratus notes get`](https://github.com/adetokunbo/hstratus/tree/main/hstratus/#hstratus-notes-get) | Fetch and display a note body |
+| [`hstratus notes export-folder`](https://github.com/adetokunbo/hstratus/tree/main/hstratus/#hstratus-notes-export-folder) | Download all notes in a folder to local files |
+
+
+---
+
+Apple and the Apple logo are trademarks of Apple Inc., registered in the U.S. and other countries and regions.
+iCloud is a service mark of Apple Inc., registered in the U.S. and other countries and regions.
diff --git a/Setup.hs b/Setup.hs
new file mode 100644
--- /dev/null
+++ b/Setup.hs
@@ -0,0 +1,4 @@
+import Distribution.Simple
+
+
+main = defaultMain
diff --git a/hstratus-notes.cabal b/hstratus-notes.cabal
new file mode 100644
--- /dev/null
+++ b/hstratus-notes.cabal
@@ -0,0 +1,117 @@
+cabal-version:      3.0
+name:               hstratus-notes
+version:            0.1.0.0
+synopsis:           Access iCloud Notes
+description:
+  Browse notes and folders from iCloud Notes using an authenticated session
+  from the @hstratus-auth@ library.
+
+  Provides read-only access to the Notes CloudKit database: listing folders,
+  fetching recent notes, and downloading note content.
+
+  This library is unofficial and not supported by Apple. It may break
+  without warning if Apple changes their API.
+
+license:            BSD-3-Clause
+license-file:       LICENSE
+author:             Tim Emiola
+maintainer:         Tim Emiola <adetokunbo@emio.la>
+copyright:          (c) 2026 Tim Emiola
+category:           Network
+build-type:         Simple
+tested-with:
+    GHC == 9.2.8
+  , GHC == 9.4.8
+  , GHC == 9.6.7
+  , GHC == 9.8.4
+  , GHC == 9.10.2
+  , GHC == 9.12.1
+extra-doc-files:
+  ChangeLog.md
+  README.md
+
+source-repository head
+  type:     git
+  location: https://github.com/adetokunbo/hstratus.git
+  subdir:   hstratus-notes
+
+library
+  exposed-modules:
+    Network.HStratus.Notes
+    Network.HStratus.Notes.Markdown
+    Network.HStratus.Notes.Note
+  hs-source-dirs:   src
+  build-depends:
+    , base                         >=4.12 && <5
+    , bytestring                   >=0.10.8 && <0.11 || >=0.11.3 && <0.13
+    , hstratus-auth                >=0.1 && <0.2
+    , hstratus-notes:hstratus-notes-internal
+    , text                         >=1.2.3 && <2.2
+    , time                         >=1.9 && <1.15
+  default-language: Haskell2010
+  ghc-options:      -Wall -Wincomplete-uni-patterns -Wpartial-fields -fwarn-tabs
+
+library hstratus-notes-internal
+  exposed-modules:
+    Network.HStratus.Internal.Notes.CloudKit
+    Network.HStratus.Internal.Notes.Decode
+    Network.HStratus.Internal.Notes.Download
+    Network.HStratus.Internal.Notes.Endpoints
+    Network.HStratus.Internal.Notes.Markdown
+    Network.HStratus.Internal.Notes.Note
+    Network.HStratus.Internal.Notes.NoteData
+    Network.HStratus.Internal.Notes.Proto
+  hs-source-dirs:   src-internal
+  build-depends:
+    , aeson                >=2.0   && <2.3
+    , base                 >=4.12 && <5
+    , base64-bytestring    >=1.0 && <2.1
+    , bytestring           >=0.10.8 && <0.11 || >=0.11.3 && <0.13
+    , containers           >=0.6 && <0.8
+    , http-client          >=0.5 && <0.8
+    , http-types           >=0.12.1 && <0.13
+    , hstratus-auth        >=0.1 && <0.2
+    , proto3-suite         >=0.7 && <0.11
+    , proto3-wire          >=1.0 && <2
+    , text                 >=1.2.3 && <2.2
+    , time                 >=1.9 && <1.15
+    , zlib                 >=0.6 && <0.8
+  default-language: Haskell2010
+  ghc-options:      -Wall -Wincomplete-uni-patterns -Wpartial-fields -fwarn-tabs
+
+test-suite test
+  type:             exitcode-stdio-1.0
+  main-is:          Spec.hs
+  hs-source-dirs:   test
+  other-modules:
+    HStratus.Notes.Arbitraries
+    HStratus.Notes.CloudKitSpec
+    HStratus.Notes.DecodeSpec
+    HStratus.Notes.EndpointsSpec
+    HStratus.Notes.MarkdownSpec
+    HStratus.Notes.NoteDataSpec
+    HStratus.Notes.ProtoSpec
+    HStratus.Notes.TestHelper
+    HStratus.NotesSpec
+  default-language: Haskell2010
+  ghc-options:      -threaded -rtsopts -with-rtsopts=-N -Wall -fwarn-tabs
+  build-depends:
+    , aeson                        >=2.0   && <2.3
+    , base
+    , bytestring                   >=0.10.8 && <0.11 || >=0.11.3 && <0.13
+    , containers                   >=0.6 && <0.8
+    , benri-hspec                  >=0.1 && <0.3
+    , hspec                        >=2.1 && <3
+    , http-client                  >=0.5 && <0.8
+    , http-types                   >=0.12.1 && <0.13
+    , hstratus-auth                >=0.1 && <0.2
+    , hstratus-notes
+    , hstratus-notes:hstratus-notes-internal
+    , proto3-wire                  >=1.0 && <2
+    , QuickCheck                   >=2.14 && <3
+    , temporary                    >=1.2 && <1.4
+    , text                         >=1.2.3 && <2.2
+    , time                         >=1.9 && <1.15
+    , wai                          >=3.2 && <3.3
+    , warp                         >=3.2 && <3.5
+    , zlib                         >=0.6 && <0.8
diff --git a/src-internal/Network/HStratus/Internal/Notes/CloudKit.hs b/src-internal/Network/HStratus/Internal/Notes/CloudKit.hs
new file mode 100644
--- /dev/null
+++ b/src-internal/Network/HStratus/Internal/Notes/CloudKit.hs
@@ -0,0 +1,316 @@
+{-# LANGUAGE DataKinds #-}
+{-# LANGUAGE FlexibleContexts #-}
+{-# LANGUAGE GeneralizedNewtypeDeriving #-}
+{-# LANGUAGE OverloadedStrings #-}
+{-# LANGUAGE ScopedTypeVariables #-}
+{-# LANGUAGE StrictData #-}
+{-# LANGUAGE TypeFamilies #-}
+
+{- |
+Module      : Network.HStratus.Internal.Notes.CloudKit
+Copyright   : (c) 2026 Tim Emiola
+Maintainer  : Tim Emiola <adetokunbo@emio.la>
+SPDX-License-Identifier: BSD-3-Clause
+
+CloudKit JSON response types for the Notes CloudKit endpoints, including
+records, assets, zone-change responses, and query responses.
+-}
+module Network.HStratus.Internal.Notes.CloudKit
+  ( CKZoneId (..)
+  , CKRecordRef (..)
+  , CKAsset (..)
+  , CKTimestamp (..)
+  , CKField (..)
+  , CKRecord (..)
+  , CKQueryResponse (..)
+  , CKLookupResponse (..)
+  , CKZoneChangesZone (..)
+  , CKZoneChangesResponse (..)
+  , parseMillisTimestamp
+  )
+where
+
+import Control.Monad (guard)
+import Data.Aeson
+  ( FromJSON (..)
+  , Object
+  , Value
+  , withObject
+  , (.!=)
+  , (.:)
+  , (.:?)
+  )
+import Data.Aeson.Types (Parser)
+import Data.Foldable (asum)
+import Data.Int (Int64)
+import Data.Map.Strict (Map)
+import Data.Proxy (Proxy (..))
+import Data.Text (Text, pack)
+import Data.Time (UTCTime)
+import Data.Time.Clock.POSIX (posixSecondsToUTCTime)
+import GHC.TypeLits (KnownSymbol, Symbol, symbolVal)
+
+
+-- | Convert a millisecond-precision POSIX timestamp to 'UTCTime'.
+parseMillisTimestamp :: Int64 -> UTCTime
+parseMillisTimestamp ms = posixSecondsToUTCTime (fromIntegral ms / 1000)
+
+
+-- | CloudKit zone identifier.
+data CKZoneId = CKZoneId
+  { czName :: Text
+  -- ^ zone name (e.g. @Notes@)
+  , czType :: Text
+  -- ^ zone type (e.g. @REGULAR_CUSTOM_ZONE@)
+  }
+  deriving (Eq, Show)
+
+
+instance FromJSON CKZoneId where
+  parseJSON = withObject "CKZoneId" $ \o ->
+    CKZoneId
+      <$> o .: "zoneName"
+      <*> o .: "zoneType"
+
+
+-- | A reference to another CloudKit record.
+data CKRecordRef = CKRecordRef
+  { rrRecordName :: Text
+  -- ^ @recordName@ of the referenced record
+  , rrAction :: Text
+  -- ^ referential integrity action (e.g. @NONE@, @DELETE_SELF@)
+  }
+  deriving (Eq, Show)
+
+
+instance FromJSON CKRecordRef where
+  parseJSON = withObject "CKRecordRef" $ \o ->
+    CKRecordRef
+      <$> o .: "recordName"
+      <*> o .: "action"
+
+
+-- | A CloudKit asset: an encrypted file attachment associated with a record.
+data CKAsset = CKAsset
+  { caDownloadUrl :: Text
+  -- ^ pre-signed URL from which the asset content can be downloaded
+  , caFileChecksum :: Text
+  -- ^ SHA-256 checksum of the encrypted asset content
+  , caRefChecksum :: Text
+  -- ^ reference checksum used when committing an upload
+  , caWrappingKey :: Text
+  -- ^ encryption wrapping key for the asset
+  , caSize :: Int64
+  -- ^ byte size of the encrypted asset content
+  }
+  deriving (Eq, Show)
+
+
+instance FromJSON CKAsset where
+  parseJSON = withObject "CKAsset" $ \o ->
+    CKAsset
+      <$> o .: "downloadURL"
+      <*> o .: "fileChecksum"
+      <*> o .: "referenceChecksum"
+      <*> o .: "wrappingKey"
+      <*> o .: "size"
+
+
+-- | A CloudKit creation or modification timestamp with the responsible user.
+data CKTimestamp = CKTimestamp
+  { ctTimestamp :: Int64
+  -- ^ millisecond-precision POSIX timestamp
+  , ctUserRecordName :: Text
+  -- ^ @recordName@ of the user who created or last modified the record
+  }
+  deriving (Eq, Show)
+
+
+instance FromJSON CKTimestamp where
+  parseJSON = withObject "CKTimestamp" $ \o ->
+    CKTimestamp
+      <$> o .: "timestamp"
+      <*> o .: "userRecordName"
+
+
+-- Internal newtypes: give distinct Haskell types to CK tags that share a
+-- primitive (Text covers "STRING"/"ENCRYPTED_BYTES"; Int64 covers
+-- "INT64"/"TIMESTAMP"), making CKFieldTag a total function.
+newtype CKString = CKString Text
+  deriving (FromJSON)
+
+
+newtype CKEncryptedBytes = CKEncryptedBytes Text
+  deriving (FromJSON)
+
+
+newtype CKInt64Value = CKInt64Value Int64
+  deriving (FromJSON)
+
+
+newtype CKTimestampValue = CKTimestampValue Int64
+  deriving (FromJSON)
+
+
+-- Single source of truth mapping each value type to its CloudKit "type" tag.
+type family CKFieldTag a :: Symbol where
+  CKFieldTag CKString = "STRING"
+  CKFieldTag CKInt64Value = "INT64"
+  CKFieldTag CKTimestampValue = "TIMESTAMP"
+  CKFieldTag CKEncryptedBytes = "ENCRYPTED_BYTES"
+  CKFieldTag CKRecordRef = "REFERENCE"
+  CKFieldTag [CKRecordRef] = "REFERENCE_LIST"
+  CKFieldTag CKAsset = "ASSETID"
+
+
+-- Confirms the pre-parsed "type" tag matches CKFieldTag a, then parses "value".
+matchField
+  :: forall a
+   . (KnownSymbol (CKFieldTag a), FromJSON a)
+  => Text
+  -> Object
+  -> Parser a
+matchField typ o = do
+  guard (typ == pack (symbolVal (Proxy :: Proxy (CKFieldTag a))))
+  o .: "value"
+
+
+-- | A single typed field value in a CloudKit record.
+data CKField
+  = -- | a plain text (@STRING@) field value
+    CKStringField Text
+  | -- | a 64-bit integer (@INT64@) field value
+    CKInt64Field Int64
+  | -- | a millisecond POSIX timestamp (@TIMESTAMP@) field value
+    CKTimestampField Int64
+  | -- | an encrypted bytes (@ENCRYPTED_BYTES@) field, base64-encoded
+    CKEncryptedBytesField Text
+  | -- | a single record reference (@REFERENCE@) field value
+    CKReferenceField CKRecordRef
+  | -- | a list of record references (@REFERENCE_LIST@) field value
+    CKReferenceListField [CKRecordRef]
+  | -- | an asset identifier (@ASSETID@) field value
+    CKAssetIdField CKAsset
+  | -- | a field with an unrecognised type tag and its raw JSON value
+    CKUnknownField Text Value
+  deriving (Eq, Show)
+
+
+instance FromJSON CKField where
+  parseJSON = withObject "CKField" $ \o -> do
+    typ <- o .: "type" :: Parser Text
+    asum
+      [ CKStringField . (\(CKString t) -> t) <$> matchField typ o
+      , CKInt64Field . (\(CKInt64Value i) -> i) <$> matchField typ o
+      , CKTimestampField . (\(CKTimestampValue i) -> i) <$> matchField typ o
+      , CKEncryptedBytesField . (\(CKEncryptedBytes t) -> t) <$> matchField typ o
+      , CKReferenceField <$> matchField typ o
+      , CKReferenceListField <$> matchField typ o
+      , CKAssetIdField <$> matchField typ o
+      , CKUnknownField typ <$> o .: "value"
+      ]
+
+
+-- | A CloudKit record with its name, type, fields, and metadata.
+data CKRecord = CKRecord
+  { crName :: Text
+  -- ^ unique identifier for the record within its zone
+  , crType :: Maybe Text
+  -- ^ record type (e.g. @Note@, @Folder@); @Nothing@ in delete tombstones
+  , crChangeTag :: Maybe Text
+  -- ^ opaque version tag used for conflict detection
+  , crZoneId :: Maybe CKZoneId
+  -- ^ zone containing this record; @Nothing@ in some response shapes
+  , crFields :: Map Text CKField
+  -- ^ typed field values keyed by field name; empty when the record is deleted
+  , crCreated :: Maybe CKTimestamp
+  -- ^ creation timestamp and user; @Nothing@ when absent
+  , crModified :: Maybe CKTimestamp
+  -- ^ last-modification timestamp and user; @Nothing@ when absent
+  , crDeleted :: Maybe Bool
+  -- ^ @Just True@ for delete tombstones; @Nothing@ otherwise
+  }
+  deriving (Eq, Show)
+
+
+instance FromJSON CKRecord where
+  parseJSON = withObject "CKRecord" $ \o ->
+    CKRecord
+      <$> o .: "recordName"
+      <*> o .:? "recordType"
+      <*> o .:? "recordChangeTag"
+      <*> o .:? "zoneID"
+      <*> o .:? "fields" .!= mempty
+      <*> o .:? "created"
+      <*> o .:? "modified"
+      <*> o .:? "deleted"
+
+
+-- | Response body for a CloudKit record query.
+data CKQueryResponse = CKQueryResponse
+  { qrRecords :: [CKRecord]
+  -- ^ records returned by the query
+  , qrContinuationMarker :: Maybe Value
+  -- ^ pagination cursor; @Nothing@ when all results fit in one response
+  }
+  deriving (Eq, Show)
+
+
+instance FromJSON CKQueryResponse where
+  parseJSON = withObject "CKQueryResponse" $ \o ->
+    CKQueryResponse
+      <$> o .: "records"
+      <*> o .:? "continuationMarker"
+
+
+-- | Response body for a CloudKit record lookup by name.
+data CKLookupResponse = CKLookupResponse
+  { lrRecords :: [CKRecord]
+  -- ^ records returned by the lookup, in the same order as the request
+  , lrSyncToken :: Maybe Text
+  -- ^ sync token for subsequent zone-changes requests; @Nothing@ when absent
+  }
+  deriving (Eq, Show)
+
+
+instance FromJSON CKLookupResponse where
+  parseJSON = withObject "CKLookupResponse" $ \o ->
+    CKLookupResponse
+      <$> o .: "records"
+      <*> o .:? "syncToken"
+
+
+-- | Per-zone section of a CloudKit zone-changes response.
+data CKZoneChangesZone = CKZoneChangesZone
+  { zczZoneId :: CKZoneId
+  -- ^ identifier of the zone these changes belong to
+  , zczSyncToken :: Maybe Text
+  -- ^ opaque token for the next zone-changes request for this zone
+  , zczMoreComing :: Maybe Bool
+  -- ^ @Just True@ when further pages of changes remain for this zone
+  , zczRecords :: [CKRecord]
+  -- ^ records that changed (or were deleted) in this zone
+  }
+  deriving (Eq, Show)
+
+
+instance FromJSON CKZoneChangesZone where
+  parseJSON = withObject "CKZoneChangesZone" $ \o ->
+    CKZoneChangesZone
+      <$> o .: "zoneID"
+      <*> o .:? "syncToken"
+      <*> o .:? "moreComing"
+      <*> o .:? "records" .!= []
+
+
+-- | Top-level response body for a CloudKit zone-changes request.
+newtype CKZoneChangesResponse = CKZoneChangesResponse
+  { zcrZones :: [CKZoneChangesZone]
+  -- ^ per-zone change sets included in this response
+  }
+  deriving (Eq, Show)
+
+
+instance FromJSON CKZoneChangesResponse where
+  parseJSON = withObject "CKZoneChangesResponse" $ \o ->
+    CKZoneChangesResponse <$> o .: "zones"
diff --git a/src-internal/Network/HStratus/Internal/Notes/Decode.hs b/src-internal/Network/HStratus/Internal/Notes/Decode.hs
new file mode 100644
--- /dev/null
+++ b/src-internal/Network/HStratus/Internal/Notes/Decode.hs
@@ -0,0 +1,122 @@
+{-# LANGUAGE NamedFieldPuns #-}
+
+{- |
+Module      : Network.HStratus.Internal.Notes.Decode
+Copyright   : (c) 2026 Tim Emiola
+Maintainer  : Tim Emiola <adetokunbo@emio.la>
+SPDX-License-Identifier: BSD-3-Clause
+
+Decodes a compressed protobuf note body (gzip or zlib) into the 'NoteText' domain type,
+bridging the wire representation in "Network.HStratus.Internal.Notes.Proto"
+and the public 'NoteText', 'NoteRun', and 'NoteStyle' types.
+-}
+module Network.HStratus.Internal.Notes.Decode
+  ( decodeNoteBody
+  )
+where
+
+import qualified Codec.Compression.GZip as GZip
+import qualified Codec.Compression.Zlib as Zlib
+import Control.Exception (SomeException, evaluate, try)
+import Data.ByteString (ByteString)
+import qualified Data.ByteString as BS
+import qualified Data.ByteString.Lazy as LBS
+import Data.Maybe (fromMaybe)
+import qualified Data.Text as T
+import qualified Data.Text.Lazy as LT
+import Network.HStratus.Internal.Notes.Note
+  ( NoteRun (..)
+  , NoteStyle (..)
+  , NoteText (..)
+  )
+import Network.HStratus.Internal.Notes.Proto
+  ( ProtoAttributeRun (..)
+  , ProtoNote (..)
+  , ProtoParagraphStyle (..)
+  , decodeNoteStoreProto
+  )
+
+
+{- | Decode a note body.  The input is the raw bytes from the CloudKit
+@TextDataEncrypted@ field after base64-decoding (Phase 1 does this in
+'noteRecordToNote').  The encoding is: compress( protobuf( NoteStoreProto ) )
+where the compressor is gzip (magic @\\x1f\\x8b@) or zlib (magic @\\x78@).
+Returns @Left@ if decompression fails or the protobuf cannot be parsed.
+-}
+decodeNoteBody :: ByteString -> IO (Either String NoteText)
+decodeNoteBody bs = do
+  decompressed <- try (evaluate (LBS.toStrict (decomp (LBS.fromStrict bs))))
+  pure $ case (decompressed :: Either SomeException ByteString) of
+    Left e -> Left ("decompression failed: " <> show e)
+    Right strict -> fmap toNoteText (decodeNoteStoreProto strict)
+ where
+  decomp = if isGzip bs then GZip.decompress else Zlib.decompress
+
+
+isGzip :: ByteString -> Bool
+isGzip bs = BS.length bs >= 2 && BS.index bs 0 == 0x1f && BS.index bs 1 == 0x8b
+
+
+-- Map ProtoNote → NoteText.  The proto layer mirrors the wire schema; this
+-- function applies the domain interpretation (e.g. font_weight int → bold/italic
+-- booleans, empty link string → Nothing).
+toNoteText :: ProtoNote -> NoteText
+toNoteText ProtoNote{pnNoteText, pnAttributeRuns} =
+  NoteText
+    { ntText = pnNoteText
+    , ntRuns = map toNoteRun pnAttributeRuns
+    }
+
+
+toNoteRun :: ProtoAttributeRun -> NoteRun
+toNoteRun
+  ProtoAttributeRun
+    { parLength
+    , parParagraphStyle
+    , parFontWeight
+    , parUnderlined
+    , parStrikethrough
+    , parAttachmentId
+    , parLink
+    } =
+    NoteRun
+      { nrLength = parLength
+      , nrStyle = parParagraphStyle >>= toNoteStyle
+      , -- FontWeight enum: 1=bold, 2=italic, 3=bold+italic
+        nrBold = parFontWeight == 1 || parFontWeight == 3
+      , nrItalic = parFontWeight == 2 || parFontWeight == 3
+      , nrUnderline = parUnderlined /= 0
+      , nrStrikethrough = parStrikethrough /= 0
+      , nrAttachmentId = fmap LT.toStrict parAttachmentId
+      , -- proto3-wire yields lazy Text; empty string means no link
+        nrLink = let t = LT.toStrict parLink in if T.null t then Nothing else Just t
+      }
+
+
+-- StyleType enum from notes.proto.  Values not in this list represent future
+-- or unknown styles; if block_quote is set they map to StyleBody True,
+-- otherwise to Nothing (rendered as plain body text).
+-- style_type 0 (title) defers to block_quote: a paragraph_style with only
+-- block_quote set has style_type = 0 by proto3 default, so we give blockquote
+-- priority over title for that case.
+toNoteStyle :: ProtoParagraphStyle -> Maybe NoteStyle
+toNoteStyle
+  ProtoParagraphStyle
+    { ppsStyleType
+    , ppsIndent
+    , ppsChecked
+    , ppsListStart
+    , ppsBlockQuote
+    } =
+    let indent = fromIntegral ppsIndent
+        listStart = fmap fromIntegral ppsListStart
+     in case ppsStyleType of
+          0 -> if ppsBlockQuote then Just (StyleBody True) else Just StyleTitle
+          1 -> Just StyleHeading
+          2 -> Just StyleSubheading
+          4 -> Just StyleMonospaced
+          100 -> Just (StyleBullet indent)
+          101 -> Just (StyleDash indent)
+          102 -> Just (StyleNumbered indent listStart)
+          103 -> Just (StyleChecklist indent (fromMaybe False ppsChecked))
+          _ -> if ppsBlockQuote then Just (StyleBody True) else Nothing
diff --git a/src-internal/Network/HStratus/Internal/Notes/Download.hs b/src-internal/Network/HStratus/Internal/Notes/Download.hs
new file mode 100644
--- /dev/null
+++ b/src-internal/Network/HStratus/Internal/Notes/Download.hs
@@ -0,0 +1,161 @@
+{-# LANGUAGE OverloadedStrings #-}
+
+{- |
+Module      : Network.HStratus.Internal.Notes.Download
+Copyright   : (c) 2026 Tim Emiola
+Maintainer  : Tim Emiola <adetokunbo@emio.la>
+SPDX-License-Identifier: BSD-3-Clause
+
+HTTP fetchers for the iCloud Notes CloudKit endpoints: recent notes, folders,
+notes in a folder, and individual note lookup.
+-}
+module Network.HStratus.Internal.Notes.Download
+  ( NotesError (..)
+  , fetchFolders
+  , fetchRecent
+  , fetchNote
+  , fetchNotesInFolder
+  )
+where
+
+import Control.Exception (Exception, throwIO)
+import Control.Monad (when)
+import Data.Aeson (FromJSON, eitherDecode)
+import qualified Data.ByteString.Lazy as LBS
+import Data.Maybe (listToMaybe, mapMaybe)
+import Network.HStratus.Http (Api, HStratusError, rawRequest)
+import Network.HStratus.Internal.Notes.CloudKit
+  ( CKLookupResponse (..)
+  , CKQueryResponse (..)
+  , CKZoneChangesResponse (..)
+  , CKZoneChangesZone (..)
+  )
+import Network.HStratus.Internal.Notes.Endpoints
+  ( NotesEndpoints
+  , changesBody
+  , changesReq
+  , foldersBody
+  , lookupBody
+  , lookupReq
+  , queryReq
+  , recentsBody
+  )
+import Network.HStratus.Internal.Notes.Note
+  ( FolderId
+  , Note
+  , NoteFolder
+  , NoteId (..)
+  , NoteSummary (..)
+  )
+import Network.HStratus.Internal.Notes.NoteData
+  ( noteRecordToNote
+  , parseFoldersFromQuery
+  , parseSummariesFromChanges
+  , parseSummariesFromQuery
+  )
+import Network.HTTP.Client
+  ( Request
+  , RequestBody (..)
+  , Response (..)
+  , requestBody
+  , requestHeaders
+  )
+import Network.HTTP.Types (hContentType, statusCode)
+
+
+-- | Errors that can occur during iCloud Notes API calls.
+data NotesError
+  = -- | the server returned an unexpected HTTP status code
+    NotesHttpError Int
+  | -- | a JSON response could not be decoded into the expected structure
+    NotesParseError String
+
+
+instance Show NotesError where
+  show (NotesHttpError n) = "iCloud Notes: HTTP error " <> show n
+  show (NotesParseError msg) = "iCloud Notes: parse error: " <> msg
+
+
+instance Exception NotesError
+
+
+instance HStratusError NotesError
+
+
+-- | Fetch all note folders, following continuation markers to retrieve every page.
+fetchFolders :: Api -> NotesEndpoints -> IO [NoteFolder]
+fetchFolders api ep = go Nothing []
+ where
+  go marker acc = do
+    qr <- fetchAs "fetchFolders" api (jsonReq (foldersBody 200 marker) (queryReq ep))
+    let acc' = acc <> parseFoldersFromQuery qr
+    case qrContinuationMarker qr of
+      Nothing -> pure acc'
+      Just m -> go (Just m) acc'
+
+
+-- | Fetch recent note summaries, following continuation markers to retrieve every page.
+fetchRecent :: Api -> NotesEndpoints -> IO [NoteSummary]
+fetchRecent api ep = go Nothing []
+ where
+  go marker acc = do
+    qr <- fetchAs "fetchRecent" api (jsonReq (recentsBody 200 marker) (queryReq ep))
+    let acc' = acc <> parseSummariesFromQuery qr
+    case qrContinuationMarker qr of
+      Nothing -> pure acc'
+      Just m -> go (Just m) acc'
+
+
+-- | Fetch a single note by its 'NoteId'; returns @Nothing@ if the record is absent or cannot be decoded.
+fetchNote :: Api -> NotesEndpoints -> NoteId -> IO (Maybe Note)
+fetchNote api ep nid = do
+  lr <- fetchAs "fetchNote" api (jsonReq (lookupBody [unNoteId nid]) (lookupReq ep))
+  pure $ listToMaybe $ mapMaybe noteRecordToNote (lrRecords lr)
+
+
+-- | Fetch all non-deleted note summaries belonging to the given folder, paging through zone changes.
+fetchNotesInFolder :: Api -> NotesEndpoints -> FolderId -> IO [NoteSummary]
+fetchNotesInFolder api ep fid = go Nothing []
+ where
+  go mToken acc = do
+    cr <- fetchAs "fetchNotesInFolder" api (jsonReq (changesBody mToken) (changesReq ep))
+    let acc' = acc <> filter inFolder (parseSummariesFromChanges cr)
+    case nextToken cr of
+      Nothing -> pure acc'
+      Just tok -> go (Just tok) acc'
+  inFolder s = nsFolderId s == Just fid && not (nsDeleted s)
+  nextToken cr = case zcrZones cr of
+    (z : _) | zczMoreComing z == Just True -> zczSyncToken z
+    _ -> Nothing
+
+
+fetchAs :: (FromJSON a) => String -> Api -> Request -> IO a
+fetchAs ctx api r = rawRequest' api r >>= decodeAs ctx
+
+
+rawRequest' :: Api -> Request -> IO (Response LBS.ByteString)
+rawRequest' api r = do
+  resp <- rawRequest api r
+  checkStatus resp
+  pure resp
+
+
+jsonReq :: LBS.ByteString -> Request -> Request
+jsonReq body r =
+  r
+    { requestBody = RequestBodyLBS body
+    , requestHeaders = (hContentType, "application/json") : requestHeaders r
+    }
+
+
+checkStatus :: Response a -> IO ()
+checkStatus resp =
+  let code = statusCode (responseStatus resp)
+   in when (code >= 400) $ throwIO (NotesHttpError code)
+
+
+decodeAs :: (FromJSON a) => String -> Response LBS.ByteString -> IO a
+decodeAs ctx resp =
+  case eitherDecode (responseBody resp) of
+    Left err -> throwIO (NotesParseError (ctx <> ": " <> err))
+    Right v -> pure v
diff --git a/src-internal/Network/HStratus/Internal/Notes/Endpoints.hs b/src-internal/Network/HStratus/Internal/Notes/Endpoints.hs
new file mode 100644
--- /dev/null
+++ b/src-internal/Network/HStratus/Internal/Notes/Endpoints.hs
@@ -0,0 +1,189 @@
+{-# LANGUAGE NamedFieldPuns #-}
+{-# LANGUAGE OverloadedStrings #-}
+
+{- |
+Module      : Network.HStratus.Internal.Notes.Endpoints
+Copyright   : (c) 2026 Tim Emiola
+Maintainer  : Tim Emiola <adetokunbo@emio.la>
+SPDX-License-Identifier: BSD-3-Clause
+
+Constructs HTTP requests and JSON request bodies for the iCloud Notes
+CloudKit API.
+-}
+module Network.HStratus.Internal.Notes.Endpoints
+  ( NotesEndpoints
+  , mkNotesEndpoints
+  , queryReq
+  , lookupReq
+  , changesReq
+  , foldersBody
+  , recentsBody
+  , lookupBody
+  , changesBody
+  )
+where
+
+import Data.Aeson (Value, encode, object, (.=))
+import qualified Data.ByteString.Char8 as BS8
+import qualified Data.ByteString.Lazy as LBS
+import Data.Text (Text)
+import qualified Data.Text as Text
+import Network.HStratus.Http.Common
+  ( icloudBrowserHeaders
+  , lookupWebservice
+  , stripTrailingSlash
+  , withHeaders
+  )
+import Network.HStratus.Session (AccountData (..), Session (..))
+import Network.HTTP.Client
+  ( Request (..)
+  )
+import Network.HTTP.Types (methodPost)
+
+
+-- | Base request for the iCloud Notes CloudKit database API.
+data NotesEndpoints = NotesEndpoints
+  { neBaseReq :: !Request
+  -- ^ base request targeting the Notes CloudKit database path
+  , neQueryString :: !BS8.ByteString
+  -- ^ shared query string appended to every Notes API request
+  }
+
+
+{- | Construct 'NotesEndpoints' from the account data returned after login.
+
+Fails if the @ckdatabasews@ service URL is absent from the account data.
+-}
+mkNotesEndpoints :: AccountData -> Session -> IO NotesEndpoints
+mkNotesEndpoints ad sess = do
+  svcReq <- lookupWebservice "ckdatabasews" (adWebservices ad)
+  let baseReq =
+        withHeaders icloudBrowserHeaders $
+          svcReq
+            { path =
+                stripTrailingSlash (path svcReq)
+                  <> "/database/1/com.apple.notes/production/private"
+            }
+      qs =
+        "remapEnums=true&getCurrentSyncToken=true&clientId="
+          <> BS8.pack (Text.unpack (sessionClientId sess))
+  pure NotesEndpoints{neBaseReq = baseReq, neQueryString = qs}
+
+
+-- | Build the @POST /records/query@ request.
+queryReq :: NotesEndpoints -> Request
+queryReq = notesReq "/records/query"
+
+
+-- | Build the @POST /records/lookup@ request.
+lookupReq :: NotesEndpoints -> Request
+lookupReq = notesReq "/records/lookup"
+
+
+-- | Build the @POST /changes/zone@ request.
+changesReq :: NotesEndpoints -> Request
+changesReq = notesReq "/changes/zone"
+
+
+notesReq :: BS8.ByteString -> NotesEndpoints -> Request
+notesReq suffix ep =
+  (neBaseReq ep)
+    { path = path (neBaseReq ep) <> suffix
+    , method = methodPost
+    , queryString = neQueryString ep
+    }
+
+
+{- | Build the JSON body for a folders query.  Pass the previous response's
+@continuationMarker@ to page through results.
+-}
+foldersBody :: Int -> Maybe Value -> LBS.ByteString
+foldersBody limit marker = encode $ object $ base <> cont
+ where
+  base =
+    [ "query"
+        .= object
+          [ "recordType" .= ("SearchIndexes" :: Text)
+          , "filterBy" .= [indexFilter "parentless"]
+          ]
+    , "zoneID" .= notesZoneId
+    , "resultsLimit" .= min notesMaxResults limit
+    ]
+  cont = maybe [] (\m -> ["continuationMarker" .= m]) marker
+
+
+{- | Build the JSON body for a recent-notes query.  Pass the previous
+response's @continuationMarker@ to page through results.
+-}
+recentsBody :: Int -> Maybe Value -> LBS.ByteString
+recentsBody limit marker = encode $ object $ base <> cont
+ where
+  base =
+    [ "query"
+        .= object
+          [ "recordType" .= ("SearchIndexes" :: Text)
+          , "filterBy" .= [indexFilter "recents"]
+          , "sortBy"
+              .= [ object
+                     [ "fieldName" .= ("modTime" :: Text)
+                     , "ascending" .= False
+                     ]
+                 ]
+          ]
+    , "zoneID" .= notesZoneId
+    , "resultsLimit" .= min notesMaxResults limit
+    ]
+  cont = maybe [] (\m -> ["continuationMarker" .= m]) marker
+
+
+-- | Build the JSON body for a record lookup by name.
+lookupBody :: [Text] -> LBS.ByteString
+lookupBody names =
+  encode $
+    object
+      [ "records" .= map (\n -> object ["recordName" .= n]) names
+      , "zoneID" .= notesZoneId
+      ]
+
+
+{- | Build the JSON body for a zone-changes request.  Pass the previous
+@syncToken@ to fetch only changes since that token.
+-}
+changesBody :: Maybe Text -> LBS.ByteString
+changesBody syncToken =
+  encode $
+    object
+      ["zones" .= [object $ zoneBase <> syncPart]]
+ where
+  zoneBase =
+    [ "zoneID" .= notesZoneId
+    , "desiredRecordTypes" .= [noteRecordType]
+    ]
+  syncPart = maybe [] (\t -> ["syncToken" .= t]) syncToken
+
+
+-- Helpers
+
+noteRecordType :: Text
+noteRecordType = "Note"
+
+
+notesMaxResults :: Int
+notesMaxResults = 200
+
+
+notesZoneId :: Value
+notesZoneId =
+  object
+    [ "zoneName" .= ("Notes" :: Text)
+    , "zoneType" .= ("REGULAR_CUSTOM_ZONE" :: Text)
+    ]
+
+
+indexFilter :: Text -> Value
+indexFilter val =
+  object
+    [ "comparator" .= ("EQUALS" :: Text)
+    , "fieldName" .= ("indexName" :: Text)
+    , "fieldValue" .= object ["type" .= ("STRING" :: Text), "value" .= val]
+    ]
diff --git a/src-internal/Network/HStratus/Internal/Notes/Markdown.hs b/src-internal/Network/HStratus/Internal/Notes/Markdown.hs
new file mode 100644
--- /dev/null
+++ b/src-internal/Network/HStratus/Internal/Notes/Markdown.hs
@@ -0,0 +1,302 @@
+{-# LANGUAGE NamedFieldPuns #-}
+{-# LANGUAGE OverloadedStrings #-}
+
+{- |
+Module      : Network.HStratus.Internal.Notes.Markdown
+Copyright   : (c) 2026 Tim Emiola
+Maintainer  : Tim Emiola <adetokunbo@emio.la>
+SPDX-License-Identifier: BSD-3-Clause
+
+Two-phase Markdown renderer for decoded note bodies: Phase 1 splits a
+'NoteText' into paragraphs ('splitIntoParagraphs'); Phase 2 renders them
+as Markdown text ('noteToMarkdown').
+-}
+module Network.HStratus.Internal.Notes.Markdown
+  ( RawParagraph (..)
+  , RawSegment (..)
+  , noteToMarkdown
+  , splitIntoParagraphs
+  )
+where
+
+import qualified Data.IntMap.Strict as IM
+import Data.List (foldl')
+import Data.Text (Text)
+import qualified Data.Text as T
+import Network.HStratus.Internal.Notes.Note
+  ( NoteRun (..)
+  , NoteStyle (..)
+  , NoteText (..)
+  )
+
+
+{- | A paragraph extracted from a 'NoteText', with its resolved style and
+ordered inline segments.
+-}
+data RawParagraph = RawParagraph
+  { rpStyle :: Maybe NoteStyle
+  -- ^ Style of the paragraph; 'Nothing' for plain body text.
+  , rpSegments :: [RawSegment]
+  -- ^ Inline segments in order; may be empty for blank paragraphs.
+  }
+  deriving (Eq, Show)
+
+
+-- | One inline span within a 'RawParagraph'.
+data RawSegment = RawSegment
+  { rsText :: Text
+  -- ^ plain text content of the span
+  , rsBold :: Bool
+  -- ^ @True@ when the span should be rendered bold
+  , rsItalic :: Bool
+  -- ^ @True@ when the span should be rendered italic
+  , rsStrikethrough :: Bool
+  -- ^ @True@ when the span should be rendered with strikethrough
+  , rsUnderline :: Bool
+  -- ^ @True@ when the span is underlined (no Markdown equivalent; dropped during rendering)
+  , rsLink :: Maybe Text
+  -- ^ hyperlink URL; @Nothing@ when the span is not a link
+  }
+  deriving (Eq, Show)
+
+
+-- Internal state for the markdown renderer.
+data RenderState = RenderState
+  { rsCounters :: IM.IntMap Int
+  -- ^ Counter per indent level for numbered lists.
+  , rsPrevStyle :: Maybe NoteStyle
+  -- ^ Style of the previous paragraph, for numbered-list group-start detection.
+  }
+
+
+-- Internal fold state.
+data SplitState = SplitState
+  { ssRemaining :: Text
+  , ssCurrentStyle :: Maybe NoteStyle
+  , ssCurrentSegs :: [RawSegment] -- reversed; reversed on paragraph close
+  , ssDone :: [RawParagraph] -- reversed; reversed in finalize
+  }
+
+
+{- | Split a 'NoteText' into paragraphs.
+
+Each @\\n@ in the note text closes the current paragraph and opens a new one.
+Runs with @nrStyle = Nothing@ (neutral/inline-only) are absorbed into the
+current paragraph rather than starting a new one; the paragraph's style is
+taken from the first run in that paragraph that carries a non-'Nothing'
+'nrStyle'.
+
+@\\xFFFC@ (Unicode object replacement character) in each run's text is
+replaced with @[attachment: \<id\>]@ when 'nrAttachmentId' is present, or
+@[attachment]@ otherwise.
+-}
+splitIntoParagraphs :: NoteText -> [RawParagraph]
+splitIntoParagraphs NoteText{ntText, ntRuns} =
+  let finalState = foldl' processRun initialState ntRuns
+   in finalize finalState
+ where
+  initialState =
+    SplitState
+      { ssRemaining = ntText
+      , ssCurrentStyle = Nothing
+      , ssCurrentSegs = []
+      , ssDone = []
+      }
+
+  processRun :: SplitState -> NoteRun -> SplitState
+  processRun st run =
+    let n = max 0 (fromIntegral (nrLength run))
+        (slice, remaining') = T.splitAt n (ssRemaining st)
+        slice' = replaceAttachment (nrAttachmentId run) slice
+        parts = T.splitOn "\n" slice'
+        newStyle = maybe (nrStyle run) Just (ssCurrentStyle st)
+        mkSeg txt =
+          RawSegment
+            { rsText = txt
+            , rsBold = nrBold run
+            , rsItalic = nrItalic run
+            , rsStrikethrough = nrStrikethrough run
+            , rsUnderline = nrUnderline run
+            , rsLink = nrLink run
+            }
+        addSeg txt segs = if T.null txt then segs else mkSeg txt : segs
+        closePara style segs =
+          RawParagraph{rpStyle = style, rpSegments = reverse segs}
+     in case parts of
+          [] ->
+            st{ssRemaining = remaining', ssCurrentStyle = newStyle}
+          [single] ->
+            st
+              { ssRemaining = remaining'
+              , ssCurrentStyle = newStyle
+              , ssCurrentSegs = addSeg single (ssCurrentSegs st)
+              }
+          (firstPart : moreParts) ->
+            let segsWithFirst = addSeg firstPart (ssCurrentSegs st)
+                closedFirst = closePara newStyle segsWithFirst
+                (finalDone, finalSegs) =
+                  foldPartsAfterFirst mkSeg moreParts (closedFirst : ssDone st)
+             in st
+                  { ssRemaining = remaining'
+                  , ssCurrentStyle = Nothing
+                  , ssCurrentSegs = finalSegs
+                  , ssDone = finalDone
+                  }
+
+  finalize :: SplitState -> [RawParagraph]
+  finalize st =
+    let lastPara =
+          RawParagraph
+            { rpStyle = ssCurrentStyle st
+            , rpSegments = reverse (ssCurrentSegs st)
+            }
+     in reverse (lastPara : ssDone st)
+
+
+-- After the first '\n' in a run, fold over the remaining parts: all but the
+-- last are closed as single-segment paragraphs; the last stays open.
+foldPartsAfterFirst
+  :: (Text -> RawSegment)
+  -> [Text]
+  -> [RawParagraph]
+  -> ([RawParagraph], [RawSegment])
+foldPartsAfterFirst _ [] done = (done, [])
+foldPartsAfterFirst mk [p] done =
+  (done, if T.null p then [] else [mk p])
+foldPartsAfterFirst mk (p : ps) done =
+  let segs = if T.null p then [] else [mk p]
+      para = RawParagraph{rpStyle = Nothing, rpSegments = segs}
+   in foldPartsAfterFirst mk ps (para : done)
+
+
+replaceAttachment :: Maybe Text -> Text -> Text
+replaceAttachment mId = T.replace "\xFFFC" placeholder
+ where
+  placeholder =
+    maybe "[attachment]" (\i -> "[attachment: " <> i <> "]") mId
+
+
+{- | Render a 'NoteText' as Markdown.
+
+Consecutive list paragraphs are separated by a single newline; all other
+paragraph boundaries use a double newline.  Empty paragraphs are dropped.
+Supported paragraph styles:
+
+* 'StyleTitle'         → @# …@
+* 'StyleHeading'       → @## …@
+* 'StyleSubheading'    → @### …@
+* 'StyleBody True'     → @> …@ (block-quote)
+* 'StyleBullet i'      → @- …@ (indented by @i × 2@ spaces)
+* 'StyleDash i'        → @- …@ (indented by @i × 2@ spaces)
+* 'StyleNumbered i ms' → @N. …@ (auto-counter per indent level)
+* 'StyleChecklist i b' → @- [x] …@ or @- [ ] …@
+
+'StyleMonospaced' is deferred; it renders as plain body text for now.
+
+Inline formatting: bold (@**@), italic (@_@), strikethrough (@~~@),
+link (@[text](url)@).  Underline has no Markdown equivalent and is dropped.
+-}
+noteToMarkdown :: NoteText -> Text
+noteToMarkdown nt =
+  T.concat (go (RenderState{rsCounters = IM.empty, rsPrevStyle = Nothing}) (filter hasContent (splitIntoParagraphs nt)))
+ where
+  go _ [] = []
+  go st [p] =
+    let (_, rendered) = renderParagraphWith st p
+        suffix = if isMonoPara p then "\n```" else ""
+     in [rendered <> suffix]
+  go st (p : rest@(next : _)) =
+    let (st', rendered) = renderParagraphWith st p
+        sep
+          | isMonoPara p && isMonoPara next = "\n"
+          | isMonoPara p = "\n```\n\n"
+          | isListPara p && isListPara next = "\n"
+          | otherwise = "\n\n"
+     in rendered : sep : go st' rest
+
+
+isMonoPara :: RawParagraph -> Bool
+isMonoPara RawParagraph{rpStyle} = rpStyle == Just StyleMonospaced
+
+
+isListPara :: RawParagraph -> Bool
+isListPara RawParagraph{rpStyle} = case rpStyle of
+  Just (StyleBullet _) -> True
+  Just (StyleDash _) -> True
+  Just (StyleNumbered _ _) -> True
+  Just (StyleChecklist _ _) -> True
+  _ -> False
+
+
+hasContent :: RawParagraph -> Bool
+hasContent = any (not . T.null . rsText) . rpSegments
+
+
+renderParagraphWith :: RenderState -> RawParagraph -> (RenderState, Text)
+renderParagraphWith st RawParagraph{rpStyle, rpSegments} =
+  let (st', prefix) = resolvePrefix st rpStyle
+      content = T.concat (map renderSegment rpSegments)
+   in (st', prefix <> content)
+
+
+resolvePrefix :: RenderState -> Maybe NoteStyle -> (RenderState, Text)
+resolvePrefix st style =
+  let st' = st{rsPrevStyle = style}
+   in case style of
+        Just (StyleBullet i) -> (st', indentText i <> "- ")
+        Just (StyleDash i) -> (st', indentText i <> "- ")
+        Just (StyleChecklist i b) ->
+          (st', indentText i <> if b then "- [x] " else "- [ ] ")
+        Just (StyleNumbered i ms) ->
+          let (n, counters') = nextCounter (rsCounters st) i ms (rsPrevStyle st)
+           in (st'{rsCounters = counters'}, indentText i <> T.pack (show n) <> ". ")
+        Just StyleMonospaced ->
+          let prefix = case rsPrevStyle st of
+                Just StyleMonospaced -> ""
+                _ -> "```\n"
+           in (st', prefix)
+        _ -> (st', staticPrefix style)
+
+
+staticPrefix :: Maybe NoteStyle -> Text
+staticPrefix (Just StyleTitle) = "# "
+staticPrefix (Just StyleHeading) = "## "
+staticPrefix (Just StyleSubheading) = "### "
+staticPrefix (Just (StyleBody True)) = "> "
+staticPrefix _ = ""
+
+
+{- | Returns the counter value to emit and the updated counter map.
+Group-start (first item or resume after non-numbered paragraph):
+resets to @ms@ (or 1 if absent).  Continuation: uses the running counter,
+ignoring @ms@ even if Apple Notes emits it on every item.
+-}
+nextCounter :: IM.IntMap Int -> Int -> Maybe Int -> Maybe NoteStyle -> (Int, IM.IntMap Int)
+nextCounter counters i ms prevStyle =
+  let isGroupStart = case prevStyle of
+        Just (StyleNumbered j _) -> j /= i
+        _ -> True
+      startVal
+        | isGroupStart = maybe 1 id ms
+        | otherwise = IM.findWithDefault 1 i counters
+   in (startVal, IM.insert i (startVal + 1) counters)
+
+
+indentText :: Int -> Text
+indentText i = T.replicate (max 0 i * 2) " "
+
+
+renderSegment :: RawSegment -> Text
+renderSegment RawSegment{rsText, rsBold, rsItalic, rsStrikethrough, rsLink} =
+  let inner = applyBoldItalic rsBold rsItalic rsText
+      withStrike = if rsStrikethrough then "~~" <> inner <> "~~" else inner
+   in case rsLink of
+        Nothing -> withStrike
+        Just url -> "[" <> withStrike <> "](" <> url <> ")"
+
+
+applyBoldItalic :: Bool -> Bool -> Text -> Text
+applyBoldItalic True True t = "**_" <> t <> "_**"
+applyBoldItalic True False t = "**" <> t <> "**"
+applyBoldItalic False True t = "_" <> t <> "_"
+applyBoldItalic False False t = t
diff --git a/src-internal/Network/HStratus/Internal/Notes/Note.hs b/src-internal/Network/HStratus/Internal/Notes/Note.hs
new file mode 100644
--- /dev/null
+++ b/src-internal/Network/HStratus/Internal/Notes/Note.hs
@@ -0,0 +1,139 @@
+{-# LANGUAGE StrictData #-}
+
+{- |
+Module      : Network.HStratus.Internal.Notes.Note
+Copyright   : (c) 2026 Tim Emiola
+Maintainer  : Tim Emiola <adetokunbo@emio.la>
+SPDX-License-Identifier: BSD-3-Clause
+
+Domain types for iCloud Notes: note and folder identifiers, note summaries
+and full notes, and the structured note-body representation ('NoteText',
+'NoteRun', 'NoteStyle').
+-}
+module Network.HStratus.Internal.Notes.Note
+  ( NoteId (..)
+  , FolderId (..)
+  , NoteSummary (..)
+  , NoteFolder (..)
+  , Note (..)
+  , NoteText (..)
+  , NoteRun (..)
+  , NoteStyle (..)
+  )
+where
+
+import Data.ByteString (ByteString)
+import Data.Int (Int32)
+import Data.Text (Text)
+import Data.Time (UTCTime)
+
+
+-- | CloudKit record name for a note (e.g. @\"68567409-5528-458C-9A00-7A2AB485CAD6\"@).
+newtype NoteId = NoteId
+  { unNoteId :: Text
+  -- ^ The raw CloudKit record name.
+  }
+  deriving (Eq, Ord, Show)
+
+
+-- | CloudKit record name for a folder (e.g. @\"4C3FC840-3B07-4215-8E61-128AB3EB425E\"@).
+newtype FolderId = FolderId
+  { unFolderId :: Text
+  -- ^ The raw CloudKit record name.
+  }
+  deriving (Eq, Ord, Show)
+
+
+-- | Lightweight summary of a note returned by list and query operations.
+data NoteSummary = NoteSummary
+  { nsId :: NoteId
+  -- ^ Stable CloudKit record identifier.
+  , nsTitle :: Maybe Text
+  -- ^ Decrypted title; 'Nothing' when the field is absent or unreadable.
+  , nsSnippet :: Maybe Text
+  -- ^ Decrypted snippet; 'Nothing' when absent or unreadable.
+  , nsModified :: Maybe UTCTime
+  -- ^ Last-modified timestamp from the @ModificationDate@ field.
+  , nsFolderId :: Maybe FolderId
+  -- ^ Containing folder; 'Nothing' for notes in the default folder.
+  , nsDeleted :: Bool
+  -- ^ 'True' when the note has been moved to the trash.
+  , nsLocked :: Bool
+  -- ^ 'True' when the record type is @PasswordProtectedNote@.
+  }
+  deriving (Eq, Ord, Show)
+
+
+-- | A Notes folder returned by 'Network.HStratus.Notes.noteFolders'.
+data NoteFolder = NoteFolder
+  { nfId :: FolderId
+  -- ^ Stable CloudKit record identifier.
+  , nfName :: Maybe Text
+  -- ^ Decrypted folder name; 'Nothing' when absent or unreadable.
+  }
+  deriving (Eq, Ord, Show)
+
+
+-- | A full note including its raw (compressed protobuf) body bytes.
+data Note = Note
+  { noteInfo :: NoteSummary
+  -- ^ Summary metadata for this note.
+  , noteBodyBytes :: ByteString
+  {- ^ Raw @TextDataEncrypted@ bytes (gzip- or zlib-compressed protobuf).
+  Pass to 'Network.HStratus.Notes.decodeNoteBody' to get 'NoteText'.
+  -}
+  }
+  deriving (Eq, Ord, Show)
+
+
+-- | Decoded plain-text content of a note with formatting runs.
+data NoteText = NoteText
+  { ntText :: Text
+  -- ^ Full plain-text content of the note.
+  , ntRuns :: [NoteRun]
+  -- ^ Formatting runs parallel to 'ntText'.
+  }
+  deriving (Eq, Ord, Show)
+
+
+-- | A single formatting run within a 'NoteText'.
+data NoteRun = NoteRun
+  { nrLength :: Int32
+  -- ^ Number of characters this run covers in 'ntText'.
+  , nrStyle :: Maybe NoteStyle
+  -- ^ Paragraph style, if any.
+  , nrBold :: Bool
+  -- ^ 'True' when the run is bold.
+  , nrItalic :: Bool
+  -- ^ 'True' when the run is italic.
+  , nrUnderline :: Bool
+  -- ^ 'True' when the run is underlined.
+  , nrStrikethrough :: Bool
+  -- ^ 'True' when the run is struck through.
+  , nrAttachmentId :: Maybe Text
+  -- ^ CloudKit attachment identifier, present when the run covers a @\xFFFC@ placeholder.
+  , nrLink :: Maybe Text
+  -- ^ Hyperlink URL, if any.
+  }
+  deriving (Eq, Ord, Show)
+
+
+-- | Paragraph style variants that can appear in a 'NoteRun'.
+data NoteStyle
+  = StyleTitle
+  | StyleHeading
+  | StyleSubheading
+  | StyleMonospaced
+  | -- | Plain body paragraph. 'True' when the paragraph is a blockquote.
+    StyleBody Bool
+  | -- | Bullet list item. Argument is the indent level (0 = top).
+    StyleBullet Int
+  | -- | Dash list item. Argument is the indent level (0 = top).
+    StyleDash Int
+  | {- | Numbered list item. Arguments are indent level and optional
+    list-start override (@'Nothing'@ means continue from current counter).
+    -}
+    StyleNumbered Int (Maybe Int)
+  | -- | Checklist item. Arguments are indent level and checked state.
+    StyleChecklist Int Bool
+  deriving (Eq, Ord, Show)
diff --git a/src-internal/Network/HStratus/Internal/Notes/NoteData.hs b/src-internal/Network/HStratus/Internal/Notes/NoteData.hs
new file mode 100644
--- /dev/null
+++ b/src-internal/Network/HStratus/Internal/Notes/NoteData.hs
@@ -0,0 +1,152 @@
+{-# LANGUAGE OverloadedStrings #-}
+
+{- |
+Module      : Network.HStratus.Internal.Notes.NoteData
+Copyright   : (c) 2026 Tim Emiola
+Maintainer  : Tim Emiola <adetokunbo@emio.la>
+SPDX-License-Identifier: BSD-3-Clause
+
+Converts raw CloudKit records into the Notes domain types ('NoteSummary',
+'NoteFolder', 'Note').
+-}
+module Network.HStratus.Internal.Notes.NoteData
+  ( noteRecordToSummary
+  , noteRecordToFolder
+  , noteRecordToNote
+  , parseSummariesFromQuery
+  , parseFoldersFromQuery
+  , parseSummariesFromChanges
+  , parseFoldersFromChanges
+  )
+where
+
+import Control.Monad (guard)
+import Data.ByteString (ByteString)
+import qualified Data.ByteString.Base64 as B64
+import Data.Int (Int64)
+import qualified Data.Map.Strict as Map
+import Data.Maybe (mapMaybe)
+import Data.Text (Text)
+import qualified Data.Text.Encoding as TE
+import Data.Time (UTCTime)
+import Network.HStratus.Internal.Notes.CloudKit
+  ( CKField (..)
+  , CKQueryResponse (..)
+  , CKRecord (..)
+  , CKRecordRef (..)
+  , CKZoneChangesResponse (..)
+  , CKZoneChangesZone (..)
+  , parseMillisTimestamp
+  )
+import Network.HStratus.Internal.Notes.Note
+  ( FolderId (..)
+  , Note (..)
+  , NoteFolder (..)
+  , NoteId (..)
+  , NoteSummary (..)
+  )
+
+
+-- | Convert a CloudKit record to a 'NoteSummary'; returns @Nothing@ if the record type is not @Note@ or @PasswordProtectedNote@.
+noteRecordToSummary :: CKRecord -> Maybe NoteSummary
+noteRecordToSummary rec = do
+  rt <- crType rec
+  guard (rt == "Note" || rt == "PasswordProtectedNote" || rt == "SearchIndexes")
+  pure
+    NoteSummary
+      { nsId = NoteId (crName rec)
+      , nsTitle = fieldEncryptedBytesAsText "TitleEncrypted" rec
+      , nsSnippet = fieldEncryptedBytesAsText "SnippetEncrypted" rec
+      , nsModified = fieldTimestamp "ModificationDate" rec
+      , nsFolderId = fieldFolderId "Folder" rec
+      , nsDeleted = maybe False (/= 0) (fieldInt64 "Deleted" rec)
+      , nsLocked = rt == "PasswordProtectedNote"
+      }
+
+
+-- | Convert a CloudKit record to a 'NoteFolder'; returns @Nothing@ if the record type is not @Folder@.
+noteRecordToFolder :: CKRecord -> Maybe NoteFolder
+noteRecordToFolder rec = do
+  rt <- crType rec
+  guard (rt == "Folder")
+  pure
+    NoteFolder
+      { nfId = FolderId (crName rec)
+      , nfName = fieldEncryptedBytesAsText "TitleEncrypted" rec
+      }
+
+
+-- | Convert a CloudKit record to a 'Note' with decoded body bytes; returns @Nothing@ if the record is not a note or the body is absent.
+noteRecordToNote :: CKRecord -> Maybe Note
+noteRecordToNote rec = do
+  summary <- noteRecordToSummary rec
+  bodyText <- fieldEncryptedBytes "TextDataEncrypted" rec
+  bodyBytes <- decodeBase64Text bodyText
+  pure Note{noteInfo = summary, noteBodyBytes = bodyBytes}
+
+
+-- | Extract all note summaries from a CloudKit query response.
+parseSummariesFromQuery :: CKQueryResponse -> [NoteSummary]
+parseSummariesFromQuery = mapMaybe noteRecordToSummary . qrRecords
+
+
+-- | Extract all note folders from a CloudKit query response.
+parseFoldersFromQuery :: CKQueryResponse -> [NoteFolder]
+parseFoldersFromQuery = mapMaybe noteRecordToFolder . qrRecords
+
+
+-- | Extract all note summaries from a CloudKit zone-changes response.
+parseSummariesFromChanges :: CKZoneChangesResponse -> [NoteSummary]
+parseSummariesFromChanges = mapMaybe noteRecordToSummary . allZoneRecords
+
+
+-- | Extract all note folders from a CloudKit zone-changes response.
+parseFoldersFromChanges :: CKZoneChangesResponse -> [NoteFolder]
+parseFoldersFromChanges = mapMaybe noteRecordToFolder . allZoneRecords
+
+
+-- Helpers
+
+allZoneRecords :: CKZoneChangesResponse -> [CKRecord]
+allZoneRecords = concatMap zczRecords . zcrZones
+
+
+fieldInt64 :: Text -> CKRecord -> Maybe Int64
+fieldInt64 key rec = case Map.lookup key (crFields rec) of
+  Just (CKInt64Field i) -> Just i
+  _otherwise -> Nothing
+
+
+fieldTimestamp :: Text -> CKRecord -> Maybe UTCTime
+fieldTimestamp key rec = case Map.lookup key (crFields rec) of
+  Just (CKTimestampField ms) -> Just (parseMillisTimestamp ms)
+  _otherwise -> Nothing
+
+
+fieldFolderId :: Text -> CKRecord -> Maybe FolderId
+fieldFolderId key rec = case Map.lookup key (crFields rec) of
+  Just (CKReferenceField ref) -> Just (FolderId (rrRecordName ref))
+  _otherwise -> Nothing
+
+
+fieldEncryptedBytes :: Text -> CKRecord -> Maybe Text
+fieldEncryptedBytes key rec = case Map.lookup key (crFields rec) of
+  Just (CKEncryptedBytesField t) -> Just t
+  _otherwise -> Nothing
+
+
+-- Decode a base64-encoded ENCRYPTED_BYTES field to UTF-8 text.
+-- For unprotected accounts, titles and snippets are plain UTF-8 in base64.
+fieldEncryptedBytesAsText :: Text -> CKRecord -> Maybe Text
+fieldEncryptedBytesAsText key rec = do
+  b64 <- fieldEncryptedBytes key rec
+  bs <- decodeBase64Text b64
+  case TE.decodeUtf8' bs of
+    Left _ -> Nothing
+    Right t -> Just t
+
+
+decodeBase64Text :: Text -> Maybe ByteString
+decodeBase64Text t = case B64.decode (TE.encodeUtf8 t) of
+  Left _ -> Nothing
+  Right bs -> Just bs
diff --git a/src-internal/Network/HStratus/Internal/Notes/Proto.hs b/src-internal/Network/HStratus/Internal/Notes/Proto.hs
new file mode 100644
--- /dev/null
+++ b/src-internal/Network/HStratus/Internal/Notes/Proto.hs
@@ -0,0 +1,155 @@
+{- |
+Module      : Network.HStratus.Internal.Notes.Proto
+Copyright   : (c) 2026 Tim Emiola
+Maintainer  : Tim Emiola <adetokunbo@emio.la>
+SPDX-License-Identifier: BSD-3-Clause
+
+Hand-written proto3-wire decoders for the Apple Notes protobuf schema.
+
+Uses proto3-wire's lower-level @Parser@ API rather than code generation
+because the schema has non-consecutive field numbers (e.g. @note_text = 2@,
+@attribute_run = 5@); explicit @\`at\` N@ bindings prevent the silent
+field-number mismatches that Generic derivation would introduce.
+Fields not listed in the decoders are silently ignored by the wire decoder.
+-}
+module Network.HStratus.Internal.Notes.Proto
+  ( ProtoNote (..)
+  , ProtoAttributeRun (..)
+  , ProtoParagraphStyle (..)
+  , decodeNoteStoreProto
+  )
+where
+
+import Data.ByteString (ByteString)
+import Data.Int (Int32)
+import Data.Text (Text)
+import qualified Data.Text.Lazy as LT
+import Proto3.Wire.Decode
+  ( Parser
+  , RawMessage
+  , at
+  , embedded
+  , embedded'
+  , int32
+  , one
+  , parse
+  , repeated
+  , text
+  )
+
+
+-- Proto types mirror the schema closely; conversion to domain NoteText/NoteRun
+-- types happens in Decode.hs where gzip decompression also lives.
+
+-- | Top-level note content decoded from the protobuf payload.
+data ProtoNote = ProtoNote
+  { pnNoteText :: Text
+  -- ^ full plain-text content of the note (proto field 2)
+  , pnAttributeRuns :: [ProtoAttributeRun]
+  -- ^ attribute runs describing inline and paragraph formatting (proto field 5)
+  }
+  deriving (Eq, Show)
+
+
+-- | A single attribute run from the protobuf schema.
+data ProtoAttributeRun = ProtoAttributeRun
+  { parLength :: Int32
+  -- ^ number of UTF-16 code units this run covers
+  , parParagraphStyle :: Maybe ProtoParagraphStyle
+  -- ^ paragraph-level formatting; @Nothing@ when the run has no paragraph style
+  , parFontWeight :: Int32
+  -- ^ font weight: 0 = none, 1 = bold, 2 = italic, 3 = bold+italic
+  , parUnderlined :: Int32
+  -- ^ non-zero when the run is underlined
+  , parStrikethrough :: Int32
+  -- ^ non-zero when the run has strikethrough
+  , parAttachmentId :: Maybe LT.Text
+  -- ^ attachment identifier; @Nothing@ when absent or empty in the wire bytes
+  , parLink :: LT.Text
+  -- ^ hyperlink URL; empty string when no link is present
+  }
+  deriving (Eq, Show)
+
+
+-- | Paragraph-level formatting for an attribute run.
+data ProtoParagraphStyle = ProtoParagraphStyle
+  { ppsStyleType :: Int32
+  -- ^ style_type field 1: 0=title, 1=heading, 2=subheading, 4=monospaced, 100=bullet, 101=dash, 102=numbered, 103=checklist
+  , ppsIndent :: Int32
+  -- ^ indent_amount field 4; 0 when absent.
+  , ppsChecked :: Maybe Bool
+  -- ^ checklist.done field 5 sub-field 2; 'Nothing' when checklist sub-message absent.
+  , ppsListStart :: Maybe Int32
+  -- ^ starting_list_item_number field 7; 'Nothing' when absent or zero.
+  , ppsBlockQuote :: Bool
+  -- ^ block_quote field 8 non-zero.
+  }
+  deriving (Eq, Show)
+
+
+{- | Decode a gzip-decompressed protobuf ByteString into a 'ProtoNote'.
+Returns 'Left' with a message if the outer document or note field is absent,
+which would indicate a malformed or empty payload rather than a real note.
+-}
+decodeNoteStoreProto :: ByteString -> Either String ProtoNote
+decodeNoteStoreProto bs =
+  case parse parseNoteStoreProto bs of
+    Left err -> Left (show err)
+    Right Nothing -> Left "NoteStoreProto: document field absent"
+    Right (Just Nothing) -> Left "Document: note field absent"
+    Right (Just (Just note)) -> Right note
+
+
+-- Drill straight through NoteStoreProto (field 2) → Document (field 3) → Note
+-- without defining a separate Document record type.  Each `embedded` call wraps
+-- the result in Maybe: Nothing means the field was absent in the wire bytes.
+parseNoteStoreProto :: Parser RawMessage (Maybe (Maybe ProtoNote))
+parseNoteStoreProto = embedded (embedded parseProtoNote `at` 3) `at` 2
+
+
+-- `one text LT.empty` reads a singular string field, returning the default
+-- (empty) when the field is absent.  `fmap LT.toStrict` converts the lazy
+-- Text that proto3-wire produces to the strict Text used in ProtoNote.
+-- `repeated (embedded' ...)` collects all occurrences of a length-delimited
+-- field into a list; embedded' (vs embedded) is used inside repeated because
+-- the field is known to be present (not optional) at each occurrence.
+parseProtoNote :: Parser RawMessage ProtoNote
+parseProtoNote =
+  ProtoNote
+    <$> (fmap LT.toStrict (one text LT.empty) `at` 2)
+    <*> (repeated (embedded' parseProtoAttributeRun) `at` 5)
+
+
+-- Scalar optional fields (font_weight, underlined, strikethrough) use
+-- `one int32 0` — the proto3 default of 0 means "absent / no effect" for
+-- all of them (0 = FONT_WEIGHT_UNKNOWN, 0 = not underlined, etc.).
+-- `embedded` for paragraph_style returns Maybe: Nothing when the run carries
+-- no paragraph-level formatting.
+parseProtoAttributeRun :: Parser RawMessage ProtoAttributeRun
+parseProtoAttributeRun =
+  ProtoAttributeRun
+    <$> (one int32 0 `at` 1)
+    <*> (embedded parseParagraphStyle `at` 2)
+    <*> (one int32 0 `at` 5) -- font_weight (fields 3 and 4 are absent in schema)
+    <*> (one int32 0 `at` 6) -- underlined
+    <*> (one int32 0 `at` 7) -- strikethrough
+    <*> fmap (>>= \t -> if LT.null t then Nothing else Just t) (embedded parseAttachmentInfo `at` 12)
+    <*> (one text LT.empty `at` 9) -- link (field 8 skipped: superscript)
+
+
+parseAttachmentInfo :: Parser RawMessage LT.Text
+parseAttachmentInfo = one text LT.empty `at` 1
+
+
+parseParagraphStyle :: Parser RawMessage ProtoParagraphStyle
+parseParagraphStyle =
+  ProtoParagraphStyle
+    <$> (one int32 0 `at` 1)
+    <*> (one int32 0 `at` 4)
+    <*> (embedded parseChecklist `at` 5)
+    <*> (fmap (\n -> if n == 0 then Nothing else Just n) (one int32 0) `at` 7)
+    <*> (fmap (/= 0) (one int32 0) `at` 8)
+
+
+parseChecklist :: Parser RawMessage Bool
+parseChecklist = fmap (/= 0) (one int32 0 `at` 2)
diff --git a/src/Network/HStratus/Notes.hs b/src/Network/HStratus/Notes.hs
new file mode 100644
--- /dev/null
+++ b/src/Network/HStratus/Notes.hs
@@ -0,0 +1,82 @@
+{-# LANGUAGE NamedFieldPuns #-}
+
+{- |
+Module      : Network.HStratus.Notes
+Copyright   : (c) 2026 Tim Emiola
+Maintainer  : Tim Emiola <adetokunbo@emio.la>
+SPDX-License-Identifier: BSD-3-Clause
+
+High-level API for reading iCloud Notes: listing folders, fetching recent
+notes, and downloading note content.
+-}
+module Network.HStratus.Notes
+  ( -- * Setup
+    NotesApi
+  , mkNotesApi
+
+    -- * Querying
+  , recentNotes
+  , noteFolders
+  , notesInFolder
+  , getNote
+
+    -- * Decoding note bodies
+  , decodeNoteBody
+
+    -- * Errors
+  , NotesError (..)
+
+    -- * Re-exports
+  , module Network.HStratus.Notes.Note
+  )
+where
+
+import Network.HStratus.Http (Api)
+import Network.HStratus.Internal.Notes.Decode (decodeNoteBody)
+import Network.HStratus.Internal.Notes.Download
+  ( NotesError (..)
+  , fetchFolders
+  , fetchNote
+  , fetchNotesInFolder
+  , fetchRecent
+  )
+import Network.HStratus.Internal.Notes.Endpoints
+  ( NotesEndpoints
+  , mkNotesEndpoints
+  )
+import Network.HStratus.Notes.Note
+import Network.HStratus.Session (AccountData, Session)
+
+
+{- | A bundled handle pairing a logged-in 'Api' with its notes endpoints.
+Construct with 'mkNotesApi'; pass to all notes operations.
+-}
+data NotesApi = NotesApi
+  { nApi :: !Api
+  , nEp :: !NotesEndpoints
+  }
+
+
+-- | Pair a logged-in 'Api' with notes endpoints derived from its session data.
+mkNotesApi :: AccountData -> Session -> Api -> IO NotesApi
+mkNotesApi ad sess api = NotesApi api <$> mkNotesEndpoints ad sess
+
+
+-- | Fetch recent notes, sorted by modification time descending.
+recentNotes :: NotesApi -> IO [NoteSummary]
+recentNotes NotesApi{nApi, nEp} = fetchRecent nApi nEp
+
+
+-- | Fetch all Notes folders.
+noteFolders :: NotesApi -> IO [NoteFolder]
+noteFolders NotesApi{nApi, nEp} = fetchFolders nApi nEp
+
+
+-- | Fetch notes belonging to the given folder.
+notesInFolder :: NotesApi -> FolderId -> IO [NoteSummary]
+notesInFolder NotesApi{nApi, nEp} = fetchNotesInFolder nApi nEp
+
+
+-- | Fetch a single note by ID. Returns 'Nothing' if the note has been deleted.
+getNote :: NotesApi -> NoteId -> IO (Maybe Note)
+getNote NotesApi{nApi, nEp} = fetchNote nApi nEp
diff --git a/src/Network/HStratus/Notes/Markdown.hs b/src/Network/HStratus/Notes/Markdown.hs
new file mode 100644
--- /dev/null
+++ b/src/Network/HStratus/Notes/Markdown.hs
@@ -0,0 +1,17 @@
+{- |
+Module      : Network.HStratus.Notes.Markdown
+Copyright   : (c) 2026 Tim Emiola
+Maintainer  : Tim Emiola <adetokunbo@emio.la>
+SPDX-License-Identifier: BSD-3-Clause
+
+Re-exports 'noteToMarkdown' for converting a decoded note body to Markdown text.
+-}
+module Network.HStratus.Notes.Markdown
+  ( noteToMarkdown
+  )
+where
+
+import Network.HStratus.Internal.Notes.Markdown
+  ( noteToMarkdown
+  )
+
diff --git a/src/Network/HStratus/Notes/Note.hs b/src/Network/HStratus/Notes/Note.hs
new file mode 100644
--- /dev/null
+++ b/src/Network/HStratus/Notes/Note.hs
@@ -0,0 +1,32 @@
+{- |
+Module      : Network.HStratus.Notes.Note
+Copyright   : (c) 2026 Tim Emiola
+Maintainer  : Tim Emiola <adetokunbo@emio.la>
+SPDX-License-Identifier: BSD-3-Clause
+
+Re-exports the core iCloud Notes domain types: identifiers, summaries,
+folders, note content, and inline-run styles.
+-}
+module Network.HStratus.Notes.Note
+  ( NoteId (..)
+  , FolderId (..)
+  , NoteSummary (..)
+  , NoteFolder (..)
+  , Note (..)
+  , NoteText (..)
+  , NoteRun (..)
+  , NoteStyle (..)
+  )
+where
+
+import Network.HStratus.Internal.Notes.Note
+  ( FolderId (..)
+  , Note (..)
+  , NoteFolder (..)
+  , NoteId (..)
+  , NoteRun (..)
+  , NoteStyle (..)
+  , NoteSummary (..)
+  , NoteText (..)
+  )
+
diff --git a/test/HStratus/Notes/Arbitraries.hs b/test/HStratus/Notes/Arbitraries.hs
new file mode 100644
--- /dev/null
+++ b/test/HStratus/Notes/Arbitraries.hs
@@ -0,0 +1,96 @@
+{-# LANGUAGE NamedFieldPuns #-}
+{-# OPTIONS_GHC -Wno-orphans #-}
+
+{- |
+Module      : HStratus.Notes.Arbitraries
+Copyright   : (c) 2026 Tim Emiola
+Maintainer  : Tim Emiola <adetokunbo@emio.la>
+SPDX-License-Identifier: BSD-3-Clause
+
+Orphan 'Arbitrary' instances for proto and domain types used in property tests.
+Import this module to bring the instances into scope; nothing else is exported.
+-}
+module HStratus.Notes.Arbitraries () where
+
+import qualified Data.Text as T
+import Network.HStratus.Internal.Notes.Note
+  ( NoteRun (..)
+  , NoteStyle (..)
+  , NoteText (..)
+  )
+import Network.HStratus.Internal.Notes.Proto (ProtoParagraphStyle (..))
+import Test.QuickCheck
+
+
+{- | Generates 'ProtoParagraphStyle' values that survive a proto3 encode/decode
+roundtrip.  Two constraints apply:
+
+  * 'ppsListStart' is 'Nothing' or 'Just n' with n >= 1, because proto3
+    cannot distinguish an absent int32 from a zero int32 on the wire.
+
+  * 'ppsChecked' is 'Just _' only when 'ppsStyleType' == 103 (checklist),
+    mirroring what 'parseParagraphStyle' produces for real notes.
+-}
+instance Arbitrary ProtoParagraphStyle where
+  arbitrary = do
+    ppsStyleType <- elements [0, 1, 2, 4, 100, 101, 102, 103]
+    ppsIndent <- choose (0, 4)
+    ppsChecked <- case ppsStyleType of
+      103 -> Just <$> arbitrary
+      _ -> pure Nothing
+    ppsListStart <- case ppsStyleType of
+      102 -> oneof [pure Nothing, Just <$> choose (1, 9)]
+      _ -> pure Nothing
+    ppsBlockQuote <- case ppsStyleType of
+      0 -> arbitrary
+      _ -> pure False
+    pure ProtoParagraphStyle{ppsStyleType, ppsIndent, ppsChecked, ppsListStart, ppsBlockQuote}
+
+
+{- | Generates 'NoteStyle' values that are in the image of 'toNoteStyle'.
+'StyleBody False' is excluded: 'toNoteStyle' maps both style_type=0 with
+block_quote=False and style_type=0 with block_quote absent to 'StyleTitle',
+never to 'StyleBody False'.
+-}
+instance Arbitrary NoteStyle where
+  arbitrary =
+    oneof
+      [ pure StyleTitle
+      , pure StyleHeading
+      , pure StyleSubheading
+      , pure StyleMonospaced
+      , pure (StyleBody True)
+      , StyleBullet <$> choose (0, 4)
+      , StyleDash <$> choose (0, 4)
+      , StyleNumbered <$> choose (0, 4) <*> oneof [pure Nothing, Just <$> choose (1, 9)]
+      , StyleChecklist <$> choose (0, 4) <*> arbitrary
+      ]
+
+
+{- | Generates 'NoteRun' values.  'nrLength' is kept in [1, 20] to bound the
+total text length generated by 'Arbitrary NoteText'.  'nrLink' is always
+'Nothing' (deferred; encoding links requires additional TestHelper support).
+-}
+instance Arbitrary NoteRun where
+  arbitrary =
+    NoteRun
+      <$> choose (1, 20)
+      <*> arbitrary
+      <*> arbitrary
+      <*> arbitrary
+      <*> arbitrary
+      <*> arbitrary
+      <*> pure Nothing -- nrAttachmentId (deferred)
+      <*> pure Nothing -- nrLink (deferred)
+
+
+{- | Generates 'NoteText' values whose 'ntText' length equals the sum of all
+'nrLength' fields.  Runs are generated first; the text is filled with
+lowercase ASCII to match the exact character count.
+-}
+instance Arbitrary NoteText where
+  arbitrary = do
+    runs <- listOf arbitrary
+    let totalLen = fromIntegral (sum (map nrLength runs)) :: Int
+    chars <- vectorOf totalLen (elements ['a' .. 'z'])
+    pure NoteText{ntText = T.pack chars, ntRuns = runs}
diff --git a/test/HStratus/Notes/CloudKitSpec.hs b/test/HStratus/Notes/CloudKitSpec.hs
new file mode 100644
--- /dev/null
+++ b/test/HStratus/Notes/CloudKitSpec.hs
@@ -0,0 +1,192 @@
+{-# LANGUAGE OverloadedStrings #-}
+
+{- |
+Module      : HStratus.Notes.CloudKitSpec
+Copyright   : (c) 2026 Tim Emiola
+Maintainer  : Tim Emiola <adetokunbo@emio.la>
+SPDX-License-Identifier: BSD-3-Clause
+
+Tests for the CloudKit JSON response decoders in
+'Network.HStratus.Internal.Notes.CloudKit'.
+-}
+module HStratus.Notes.CloudKitSpec (spec) where
+
+import Data.Aeson (FromJSON, eitherDecode)
+import qualified Data.ByteString.Lazy as LBS
+import qualified Data.Map.Strict as Map
+import Data.Text (Text)
+import Network.HStratus.Internal.Notes.CloudKit
+  ( CKField (..)
+  , CKLookupResponse (..)
+  , CKQueryResponse (..)
+  , CKRecord (..)
+  , CKRecordRef (..)
+  , CKZoneChangesResponse (..)
+  , CKZoneChangesZone (..)
+  )
+import Test.Hspec
+
+
+spec :: Spec
+spec = describe "Network.HStratus.Internal.Notes.CloudKit" $ do
+  describe "CKQueryResponse" $ do
+    it "parses a folders query response" $ do
+      r <- decodeOrFail queryFoldersJson :: IO CKQueryResponse
+      qrContinuationMarker r `shouldBe` Nothing
+      case qrRecords r of
+        [] -> expectationFailure "expected non-empty records"
+        rec : _ -> do
+          crName rec `shouldBe` "Folder/FOLDER-FIXTURE"
+          crType rec `shouldBe` Just "SearchIndexes"
+          Map.lookup "HasSubfolder" (crFields rec)
+            `shouldBe` Just (CKInt64Field 1)
+    it "parses an empty query response" $ do
+      r <- decodeOrFail emptyQueryJson :: IO CKQueryResponse
+      qrRecords r `shouldBe` []
+
+  describe "CKLookupResponse" $ do
+    it "parses a note lookup response" $ do
+      r <- decodeOrFail lookupNoteJson :: IO CKLookupResponse
+      lrSyncToken r `shouldBe` Just "notes-lookup-sync-token-fixture"
+      case lrRecords r of
+        [] -> expectationFailure "expected non-empty records"
+        rec : _ -> do
+          crName rec `shouldBe` "Note/NOTE-FIXTURE"
+          Map.lookup "TextDataEncrypted" (crFields rec)
+            `shouldBe` Just (CKEncryptedBytesField "c3ludGhldGljIG5vdGUgYm9keQ==")
+          Map.lookup "Folder" (crFields rec)
+            `shouldBe` Just (CKReferenceField (mkRef "Folder/FOLDER-FIXTURE"))
+          Map.lookup "Attachments" (crFields rec)
+            `shouldBe` Just (CKReferenceListField [mkRef "Attachment/ATTACHMENT-FIXTURE"])
+    it "parses an attachment lookup response" $ do
+      r <- decodeOrFail lookupAttachmentJson :: IO CKLookupResponse
+      lrSyncToken r `shouldBe` Nothing
+      case lrRecords r of
+        [] -> expectationFailure "expected non-empty records"
+        rec : _ -> do
+          crName rec `shouldBe` "Attachment/ATTACHMENT-FIXTURE"
+          Map.lookup "AttachmentUTI" (crFields rec)
+            `shouldBe` Just (CKStringField "public.url")
+          Map.lookup "Size" (crFields rec)
+            `shouldBe` Just (CKInt64Field 128)
+
+  describe "CKZoneChangesResponse" $ do
+    it "parses zone changes with records" $ do
+      r <- decodeOrFail zoneChangesJson :: IO CKZoneChangesResponse
+      case zcrZones r of
+        [] -> expectationFailure "expected non-empty zones"
+        zone : _ -> do
+          zczSyncToken zone `shouldBe` Just "notes-zone-sync-token-fixture"
+          zczMoreComing zone `shouldBe` Just False
+          case zczRecords zone of
+            [_, _, deleted] -> do
+              crName deleted `shouldBe` "Note/NOTE-DELETED-FIXTURE"
+              crDeleted deleted `shouldBe` Just True
+            recs -> expectationFailure $ "expected 3 records, got " <> show (length recs)
+    it "parses zone changes with no records" $ do
+      r <- decodeOrFail zoneChangesEmptyJson :: IO CKZoneChangesResponse
+      case zcrZones r of
+        [] -> expectationFailure "expected non-empty zones"
+        zone : _ -> do
+          zczRecords zone `shouldBe` []
+          zczSyncToken zone `shouldBe` Just "notes-changes-sync-token-fixture"
+
+
+-- Helpers
+
+decodeOrFail :: (FromJSON a) => LBS.ByteString -> IO a
+decodeOrFail bs = either fail pure (eitherDecode bs)
+
+
+mkRef :: Text -> CKRecordRef
+mkRef name = CKRecordRef{rrRecordName = name, rrAction = "VALIDATE"}
+
+
+-- Fixtures
+
+queryFoldersJson :: LBS.ByteString
+queryFoldersJson =
+  "{\"records\":[{\"recordName\":\"Folder/FOLDER-FIXTURE\"\
+  \,\"recordType\":\"SearchIndexes\"\
+  \,\"recordChangeTag\":\"folder-change-tag-fixture\"\
+  \,\"zoneID\":{\"zoneName\":\"Notes\",\"zoneType\":\"REGULAR_CUSTOM_ZONE\"}\
+  \,\"fields\":{\"TitleEncrypted\":{\"type\":\"STRING\",\"value\":\"Synthetic Folder\",\"isEncrypted\":true}\
+  \,\"HasSubfolder\":{\"type\":\"INT64\",\"value\":1}}}]\
+  \,\"continuationMarker\":null}"
+
+
+emptyQueryJson :: LBS.ByteString
+emptyQueryJson = "{\"records\":[]}"
+
+
+lookupNoteJson :: LBS.ByteString
+lookupNoteJson =
+  "{\"records\":[{\"recordName\":\"Note/NOTE-FIXTURE\"\
+  \,\"recordType\":\"Note\"\
+  \,\"recordChangeTag\":\"note-change-tag-fixture\"\
+  \,\"created\":{\"timestamp\":1735689600000,\"userRecordName\":\"_synthetic_user\"}\
+  \,\"modified\":{\"timestamp\":1735776000000,\"userRecordName\":\"_synthetic_user\"}\
+  \,\"deleted\":false\
+  \,\"zoneID\":{\"zoneName\":\"Notes\",\"zoneType\":\"REGULAR_CUSTOM_ZONE\"}\
+  \,\"fields\":{\
+  \\"TitleEncrypted\":{\"type\":\"STRING\",\"value\":\"Synthetic note\",\"isEncrypted\":true}\
+  \,\"SnippetEncrypted\":{\"type\":\"STRING\",\"value\":\"Synthetic snippet\",\"isEncrypted\":true}\
+  \,\"TextDataEncrypted\":{\"type\":\"ENCRYPTED_BYTES\",\"value\":\"c3ludGhldGljIG5vdGUgYm9keQ==\"}\
+  \,\"ModificationDate\":{\"type\":\"TIMESTAMP\",\"value\":1735776000000}\
+  \,\"Deleted\":{\"type\":\"INT64\",\"value\":0}\
+  \,\"Folder\":{\"type\":\"REFERENCE\",\"value\":{\"recordName\":\"Folder/FOLDER-FIXTURE\",\"action\":\"VALIDATE\"}}\
+  \,\"Attachments\":{\"type\":\"REFERENCE_LIST\",\"value\":[{\"recordName\":\"Attachment/ATTACHMENT-FIXTURE\",\"action\":\"VALIDATE\"}]}\
+  \}}]\
+  \,\"syncToken\":\"notes-lookup-sync-token-fixture\"}"
+
+
+lookupAttachmentJson :: LBS.ByteString
+lookupAttachmentJson =
+  "{\"records\":[{\"recordName\":\"Attachment/ATTACHMENT-FIXTURE\"\
+  \,\"recordType\":\"Attachment\"\
+  \,\"recordChangeTag\":\"attachment-change-tag-fixture\"\
+  \,\"zoneID\":{\"zoneName\":\"Notes\",\"zoneType\":\"REGULAR_CUSTOM_ZONE\"}\
+  \,\"fields\":{\
+  \\"AttachmentIdentifier\":{\"type\":\"STRING\",\"value\":\"ATTACHMENT-ALIAS-FIXTURE\"}\
+  \,\"AttachmentUTI\":{\"type\":\"STRING\",\"value\":\"public.url\"}\
+  \,\"Filename\":{\"type\":\"STRING\",\"value\":\"synthetic-link.webloc\"}\
+  \,\"Size\":{\"type\":\"INT64\",\"value\":128}\
+  \,\"PrimaryAsset\":{\"type\":\"ASSETID\",\"value\":{\
+  \\"downloadURL\":\"https://example.test/notes/asset\"\
+  \,\"fileChecksum\":\"notes-asset-checksum-fixture\"\
+  \,\"referenceChecksum\":\"notes-asset-reference-fixture\"\
+  \,\"wrappingKey\":\"notes-asset-wrapping-key-fixture\"\
+  \,\"size\":128}}}}]}"
+
+
+zoneChangesJson :: LBS.ByteString
+zoneChangesJson =
+  "{\"zones\":[{\"zoneID\":{\"zoneName\":\"Notes\",\"zoneType\":\"REGULAR_CUSTOM_ZONE\"}\
+  \,\"syncToken\":\"notes-zone-sync-token-fixture\"\
+  \,\"moreComing\":false\
+  \,\"records\":[\
+  \{\"recordName\":\"Note/NOTE-FIXTURE\"\
+  \,\"recordType\":\"Note\"\
+  \,\"recordChangeTag\":\"note-change-tag-fixture\"\
+  \,\"fields\":{\
+  \\"TitleEncrypted\":{\"type\":\"STRING\",\"value\":\"Synthetic note\",\"isEncrypted\":true}\
+  \,\"SnippetEncrypted\":{\"type\":\"STRING\",\"value\":\"Synthetic snippet\",\"isEncrypted\":true}\
+  \,\"ModificationDate\":{\"type\":\"TIMESTAMP\",\"value\":1735776000000}\
+  \,\"Deleted\":{\"type\":\"INT64\",\"value\":0}\
+  \,\"Folder\":{\"type\":\"REFERENCE\",\"value\":{\"recordName\":\"Folder/FOLDER-FIXTURE\",\"action\":\"VALIDATE\"}}}}\
+  \,{\"recordName\":\"Folder/FOLDER-FIXTURE\"\
+  \,\"recordType\":\"SearchIndexes\"\
+  \,\"recordChangeTag\":\"folder-change-tag-fixture\"\
+  \,\"fields\":{\
+  \\"TitleEncrypted\":{\"type\":\"STRING\",\"value\":\"Synthetic Folder\",\"isEncrypted\":true}\
+  \,\"HasSubfolder\":{\"type\":\"INT64\",\"value\":1}}}\
+  \,{\"recordName\":\"Note/NOTE-DELETED-FIXTURE\",\"deleted\":true}\
+  \]}]}"
+
+
+zoneChangesEmptyJson :: LBS.ByteString
+zoneChangesEmptyJson =
+  "{\"zones\":[{\"zoneID\":{\"zoneName\":\"Notes\",\"zoneType\":\"REGULAR_CUSTOM_ZONE\"}\
+  \,\"records\":[]\
+  \,\"syncToken\":\"notes-changes-sync-token-fixture\"\
+  \,\"moreComing\":false}]}"
diff --git a/test/HStratus/Notes/DecodeSpec.hs b/test/HStratus/Notes/DecodeSpec.hs
new file mode 100644
--- /dev/null
+++ b/test/HStratus/Notes/DecodeSpec.hs
@@ -0,0 +1,151 @@
+{-# LANGUAGE NamedFieldPuns #-}
+{-# LANGUAGE OverloadedStrings #-}
+
+{- |
+Module      : HStratus.Notes.DecodeSpec
+Copyright   : (c) 2026 Tim Emiola
+Maintainer  : Tim Emiola <adetokunbo@emio.la>
+SPDX-License-Identifier: BSD-3-Clause
+
+Tests for the protobuf-to-domain decoder in
+'Network.HStratus.Internal.Notes.Decode'.
+-}
+module HStratus.Notes.DecodeSpec (spec) where
+
+import Data.ByteString (ByteString)
+import HStratus.Notes.Arbitraries ()
+import HStratus.Notes.TestHelper
+import Network.HStratus.Internal.Notes.Decode (decodeNoteBody)
+import Network.HStratus.Internal.Notes.Note (NoteRun (..), NoteStyle (..), NoteText (..))
+import Test.Hspec
+import Test.Hspec.Benri (endsLeft_, endsRight)
+import Test.Hspec.QuickCheck (prop)
+import Test.QuickCheck (ioProperty, (===))
+
+
+spec :: Spec
+spec = describe "decodeNoteBody" $ do
+  it "decodes a gzip-compressed protobuf note and extracts the text" $
+    decodeNoteBody fixtureBytes
+      `endsRight` NoteText{ntText = "Step 6b test", ntRuns = []}
+
+  it "decodes a zlib-compressed protobuf note and extracts the text" $
+    decodeNoteBody zlibFixtureBytes
+      `endsRight` NoteText{ntText = "Step 6b test", ntRuns = []}
+
+  it "returns Left for bytes that are valid gzip but empty protobuf" $
+    endsLeft_ $
+      decodeNoteBody emptyNoteBytes
+
+  it "returns Left for a corrupt payload" $
+    endsLeft_ $
+      decodeNoteBody "\x00\x01\x02\x03"
+
+  it "propagates strikethrough to nrStrikethrough" $
+    decodeNoteBody strikethroughFixtureBytes
+      `endsRight` NoteText
+        { ntText = "hi"
+        , ntRuns = [baseRun{nrLength = 2, nrStrikethrough = True}]
+        }
+
+  it "decodes indent_amount into StyleBullet level" $
+    decodeNoteBody bulletIndent1FixtureBytes
+      `endsRight` NoteText
+        { ntText = "hi"
+        , ntRuns = [baseRun{nrStyle = Just (StyleBullet 1)}]
+        }
+
+  it "decodes checklist done into StyleChecklist True" $
+    decodeNoteBody checklistDoneFixtureBytes
+      `endsRight` NoteText
+        { ntText = "hi"
+        , ntRuns = [baseRun{nrStyle = Just (StyleChecklist 0 True)}]
+        }
+
+  it "decodes checklist undone into StyleChecklist False" $
+    decodeNoteBody checklistUndoneFixtureBytes
+      `endsRight` NoteText
+        { ntText = "hi"
+        , ntRuns = [baseRun{nrStyle = Just (StyleChecklist 0 False)}]
+        }
+
+  it "decodes starting_list_item_number into StyleNumbered list start" $
+    decodeNoteBody numberedListStart3FixtureBytes
+      `endsRight` NoteText
+        { ntText = "hi"
+        , ntRuns = [baseRun{nrStyle = Just (StyleNumbered 0 (Just 3))}]
+        }
+
+  it "decodes block_quote into StyleBody True" $
+    decodeNoteBody blockQuoteFixtureBytes
+      `endsRight` NoteText
+        { ntText = "hi"
+        , ntRuns = [baseRun{nrStyle = Just (StyleBody True)}]
+        }
+
+  it "propagates attachment_identifier to nrAttachmentId" $
+    decodeNoteBody attachmentIdFixtureBytes
+      `endsRight` NoteText
+        { ntText = "\xFFFC"
+        , ntRuns = [baseRun{nrAttachmentId = Just "att-1"}]
+        }
+
+  prop "encode/decode roundtrip preserves NoteText" $ \nt ->
+    ioProperty $ do
+      result <- decodeNoteBody (encodeNoteText nt)
+      pure (result === Right nt)
+
+
+-- Base NoteRun with all defaults; individual tests override specific fields.
+baseRun :: NoteRun
+baseRun =
+  NoteRun
+    { nrLength = 1
+    , nrStyle = Nothing
+    , nrBold = False
+    , nrItalic = False
+    , nrUnderline = False
+    , nrStrikethrough = False
+    , nrAttachmentId = Nothing
+    , nrLink = Nothing
+    }
+
+
+fixtureBytes :: ByteString
+fixtureBytes = mkNoteGzip "Step 6b test" []
+
+
+zlibFixtureBytes :: ByteString
+zlibFixtureBytes = mkNoteZlib "Step 6b test" []
+
+
+emptyNoteBytes :: ByteString
+emptyNoteBytes = mkEmptyGzip
+
+
+strikethroughFixtureBytes :: ByteString
+strikethroughFixtureBytes = mkNoteGzip "hi" [runFields 2 [(7, 1)]]
+
+
+bulletIndent1FixtureBytes :: ByteString
+bulletIndent1FixtureBytes = mkNoteGzip "hi" [runWith 1 (psStyleType 100 <> psIndentAmount 1)]
+
+
+checklistDoneFixtureBytes :: ByteString
+checklistDoneFixtureBytes = mkNoteGzip "hi" [runWith 1 (psStyleType 103 <> psChecklist True)]
+
+
+checklistUndoneFixtureBytes :: ByteString
+checklistUndoneFixtureBytes = mkNoteGzip "hi" [runWith 1 (psStyleType 103 <> psChecklist False)]
+
+
+numberedListStart3FixtureBytes :: ByteString
+numberedListStart3FixtureBytes = mkNoteGzip "hi" [runWith 1 (psStyleType 102 <> psListStart 3)]
+
+
+blockQuoteFixtureBytes :: ByteString
+blockQuoteFixtureBytes = mkNoteGzip "hi" [runWith 1 psBlockQuote]
+
+
+attachmentIdFixtureBytes :: ByteString
+attachmentIdFixtureBytes = mkNoteGzip "\xFFFC" [encodeNoteRun baseRun{nrAttachmentId = Just "att-1"}]
diff --git a/test/HStratus/Notes/EndpointsSpec.hs b/test/HStratus/Notes/EndpointsSpec.hs
new file mode 100644
--- /dev/null
+++ b/test/HStratus/Notes/EndpointsSpec.hs
@@ -0,0 +1,137 @@
+{-# LANGUAGE OverloadedStrings #-}
+
+{- |
+Module      : HStratus.Notes.EndpointsSpec
+Copyright   : (c) 2026 Tim Emiola
+Maintainer  : Tim Emiola <adetokunbo@emio.la>
+SPDX-License-Identifier: BSD-3-Clause
+
+Tests for the Notes CloudKit endpoint and request-body builders in
+'Network.HStratus.Internal.Notes.Endpoints'.
+-}
+module HStratus.Notes.EndpointsSpec (spec) where
+
+import Control.Monad (forM_)
+import Data.Aeson (Value (..), object)
+import qualified Data.ByteString as BS
+import qualified Data.ByteString.Char8 as BS8
+import qualified Data.ByteString.Lazy as LBS
+import qualified Data.Map.Strict as Map
+import Network.HStratus.Internal.Notes.Endpoints
+  ( changesBody
+  , changesReq
+  , foldersBody
+  , lookupBody
+  , lookupReq
+  , mkNotesEndpoints
+  , queryReq
+  , recentsBody
+  )
+import Network.HStratus.Session (AccountData (..), Credentials (..), Session (..), Webservice (..))
+import Network.HTTP.Client (Request (..))
+import Network.HTTP.Types (methodPost)
+import Test.Hspec
+
+
+spec :: Spec
+spec = describe "Network.HStratus.Internal.Notes.Endpoints" $ do
+  ep <- runIO (mkNotesEndpoints testAccountData testSession)
+
+  let reqs =
+        [ ("queryReq", queryReq ep, "/records/query")
+        , ("lookupReq", lookupReq ep, "/records/lookup")
+        , ("changesReq", changesReq ep, "/changes/zone")
+        ]
+  forM_ reqs $ \(name, req, suffix) ->
+    describe name $ do
+      it ("targets " <> suffix) $
+        path req `shouldSatisfy` BS.isSuffixOf (BS8.pack suffix)
+      it "uses POST" $
+        method req `shouldBe` methodPost
+      it "includes required query params" $ do
+        queryString req `shouldSatisfy` BS.isInfixOf "remapEnums=true"
+        queryString req `shouldSatisfy` BS.isInfixOf "getCurrentSyncToken=true"
+        queryString req `shouldSatisfy` BS.isInfixOf "clientId=auth-test-client-id"
+
+  describe "foldersBody" $ do
+    it "queries SearchIndexes with parentless filter" $
+      foldersBody 10 Nothing
+        `shouldSatisfy` lbsContains "\"recordType\":\"SearchIndexes\""
+    it "includes Notes zoneID with zoneType" $
+      foldersBody 10 Nothing
+        `shouldSatisfy` lbsContains "\"zoneType\":\"REGULAR_CUSTOM_ZONE\""
+    it "includes the requested resultsLimit" $
+      foldersBody 50 Nothing
+        `shouldSatisfy` lbsContains "\"resultsLimit\":50"
+    it "clamps resultsLimit to 200" $
+      foldersBody 999 Nothing
+        `shouldSatisfy` lbsContains "\"resultsLimit\":200"
+    it "includes continuationMarker when provided" $
+      foldersBody 10 (Just (String "test-marker"))
+        `shouldSatisfy` lbsContains "\"continuationMarker\""
+
+  describe "recentsBody" $ do
+    it "queries SearchIndexes records" $
+      recentsBody 10 Nothing
+        `shouldSatisfy` lbsContains "\"recordType\":\"SearchIndexes\""
+    it "includes Notes zoneID with zoneType" $
+      recentsBody 10 Nothing
+        `shouldSatisfy` lbsContains "\"zoneType\":\"REGULAR_CUSTOM_ZONE\""
+    it "includes the requested resultsLimit" $
+      recentsBody 42 Nothing
+        `shouldSatisfy` lbsContains "\"resultsLimit\":42"
+    it "clamps resultsLimit to 200" $
+      recentsBody 999 Nothing
+        `shouldSatisfy` lbsContains "\"resultsLimit\":200"
+    it "sorts by modTime descending" $
+      recentsBody 10 Nothing
+        `shouldSatisfy` lbsContains "\"fieldName\":\"modTime\""
+
+  describe "lookupBody" $ do
+    it "includes each record name" $
+      lookupBody ["Note/ABC", "Note/DEF"]
+        `shouldSatisfy` lbsContains "\"recordName\":\"Note/ABC\""
+    it "includes Notes zoneID with zoneType" $
+      lookupBody ["Note/ABC"]
+        `shouldSatisfy` lbsContains "\"zoneType\":\"REGULAR_CUSTOM_ZONE\""
+
+  describe "changesBody" $ do
+    it "includes REGULAR_CUSTOM_ZONE zoneType" $
+      changesBody Nothing
+        `shouldSatisfy` lbsContains "\"zoneType\":\"REGULAR_CUSTOM_ZONE\""
+    it "requests Note record type" $
+      changesBody Nothing
+        `shouldSatisfy` lbsContains "\"Note\""
+    it "includes syncToken when provided" $
+      changesBody (Just "test-sync-token")
+        `shouldSatisfy` lbsContains "\"syncToken\":\"test-sync-token\""
+
+
+-- Helpers
+
+lbsContains :: BS.ByteString -> LBS.ByteString -> Bool
+lbsContains needle = BS.isInfixOf needle . LBS.toStrict
+
+
+-- Fixtures
+
+testAccountData :: AccountData
+testAccountData =
+  AccountData
+    { adHsaVersion = 2
+    , adHsaChallengeRequired = False
+    , adHsaTrustedBrowser = Just True
+    , adWebservices =
+        Map.fromList
+          [("ckdatabasews", Webservice "https://p31-ckdatabasews.icloud.com" Nothing)]
+    , adRaw = object []
+    }
+
+
+testSession :: Session
+testSession =
+  Session
+    { sessionCreds = Credentials{credAccountName = "test@example.com", credPassword = "test-pass"}
+    , sessionTopDir = "/tmp/test"
+    , sessionClientId = "auth-test-client-id"
+    }
diff --git a/test/HStratus/Notes/MarkdownSpec.hs b/test/HStratus/Notes/MarkdownSpec.hs
new file mode 100644
--- /dev/null
+++ b/test/HStratus/Notes/MarkdownSpec.hs
@@ -0,0 +1,362 @@
+{-# LANGUAGE OverloadedStrings #-}
+
+{- |
+Module      : HStratus.Notes.MarkdownSpec
+Copyright   : (c) 2026 Tim Emiola
+Maintainer  : Tim Emiola <adetokunbo@emio.la>
+SPDX-License-Identifier: BSD-3-Clause
+
+Tests for the Markdown renderer in
+'Network.HStratus.Internal.Notes.Markdown'.
+-}
+module HStratus.Notes.MarkdownSpec (spec) where
+
+import Data.Text (Text)
+import Network.HStratus.Internal.Notes.Markdown
+import Network.HStratus.Internal.Notes.Note
+import Test.Hspec
+
+
+spec :: Spec
+spec = do
+  describe "noteToMarkdown" $ do
+    it "renders StyleTitle as h1" $
+      noteToMarkdown
+        NoteText
+          { ntText = "hello"
+          , ntRuns = [baseRun{nrLength = 5, nrStyle = Just StyleTitle}]
+          }
+        `shouldBe` "# hello"
+
+    it "renders StyleHeading as h2" $
+      noteToMarkdown
+        NoteText
+          { ntText = "section"
+          , ntRuns = [baseRun{nrLength = 7, nrStyle = Just StyleHeading}]
+          }
+        `shouldBe` "## section"
+
+    it "renders StyleSubheading as h3" $
+      noteToMarkdown
+        NoteText
+          { ntText = "sub"
+          , ntRuns = [baseRun{nrLength = 3, nrStyle = Just StyleSubheading}]
+          }
+        `shouldBe` "### sub"
+
+    it "renders plain body text without prefix" $
+      noteToMarkdown
+        NoteText{ntText = "plain", ntRuns = [baseRun{nrLength = 5}]}
+        `shouldBe` "plain"
+
+    it "renders StyleBody True as blockquote" $
+      noteToMarkdown
+        NoteText
+          { ntText = "quoted"
+          , ntRuns = [baseRun{nrLength = 6, nrStyle = Just (StyleBody True)}]
+          }
+        `shouldBe` "> quoted"
+
+    it "renders bold segment with ** markers" $
+      noteToMarkdown
+        NoteText{ntText = "bold", ntRuns = [baseRun{nrLength = 4, nrBold = True}]}
+        `shouldBe` "**bold**"
+
+    it "renders italic segment with _ markers" $
+      noteToMarkdown
+        NoteText{ntText = "ital", ntRuns = [baseRun{nrLength = 4, nrItalic = True}]}
+        `shouldBe` "_ital_"
+
+    it "renders bold+italic segment with **_ markers" $
+      noteToMarkdown
+        NoteText
+          { ntText = "bi"
+          , ntRuns = [baseRun{nrLength = 2, nrBold = True, nrItalic = True}]
+          }
+        `shouldBe` "**_bi_**"
+
+    it "renders strikethrough segment with ~~ markers" $
+      noteToMarkdown
+        NoteText
+          { ntText = "strike"
+          , ntRuns = [baseRun{nrLength = 6, nrStrikethrough = True}]
+          }
+        `shouldBe` "~~strike~~"
+
+    it "renders link segment as [text](url)" $
+      noteToMarkdown
+        NoteText
+          { ntText = "click"
+          , ntRuns = [baseRun{nrLength = 5, nrLink = Just "https://example.com"}]
+          }
+        `shouldBe` "[click](https://example.com)"
+
+    it "drops underline with no markup" $
+      noteToMarkdown
+        NoteText
+          { ntText = "under"
+          , ntRuns = [baseRun{nrLength = 5, nrUnderline = True}]
+          }
+        `shouldBe` "under"
+
+    it "renders bold text inside a link" $
+      noteToMarkdown
+        NoteText
+          { ntText = "bold link"
+          , ntRuns =
+              [ baseRun
+                  { nrLength = 9
+                  , nrBold = True
+                  , nrLink = Just "https://example.com"
+                  }
+              ]
+          }
+        `shouldBe` "[**bold link**](https://example.com)"
+
+    it "filters blank paragraphs and joins with double newline" $
+      noteToMarkdown
+        NoteText{ntText = "a\n\nb", ntRuns = [baseRun{nrLength = 4}]}
+        `shouldBe` "a\n\nb"
+
+    it "joins two plain paragraphs with double newline" $
+      noteToMarkdown
+        NoteText{ntText = "first\nsecond", ntRuns = [baseRun{nrLength = 12}]}
+        `shouldBe` "first\n\nsecond"
+
+  describe "noteToMarkdown monospaced" $ do
+    it "renders a single StyleMonospaced paragraph as a fenced code block" $
+      noteToMarkdown
+        NoteText{ntText = "code", ntRuns = [baseRun{nrLength = 4, nrStyle = Just StyleMonospaced}]}
+        `shouldBe` "```\ncode\n```"
+
+    it "merges consecutive StyleMonospaced paragraphs into one fence" $
+      noteToMarkdown
+        NoteText
+          { ntText = "line1\nline2"
+          , ntRuns =
+              [ baseRun{nrLength = 6, nrStyle = Just StyleMonospaced}
+              , baseRun{nrLength = 5, nrStyle = Just StyleMonospaced}
+              ]
+          }
+        `shouldBe` "```\nline1\nline2\n```"
+
+    it "closes the fence before a following body paragraph" $
+      noteToMarkdown
+        NoteText
+          { ntText = "code\nbody"
+          , ntRuns =
+              [ baseRun{nrLength = 5, nrStyle = Just StyleMonospaced}
+              , baseRun{nrLength = 4}
+              ]
+          }
+        `shouldBe` "```\ncode\n```\n\nbody"
+
+    it "opens the fence after a preceding body paragraph" $
+      noteToMarkdown
+        NoteText
+          { ntText = "body\ncode"
+          , ntRuns =
+              [ baseRun{nrLength = 5}
+              , baseRun{nrLength = 4, nrStyle = Just StyleMonospaced}
+              ]
+          }
+        `shouldBe` "body\n\n```\ncode\n```"
+
+  describe "noteToMarkdown list styles" $ do
+    it "renders StyleBullet 0 as top-level bullet" $
+      noteToMarkdown
+        NoteText{ntText = "item", ntRuns = [baseRun{nrLength = 4, nrStyle = Just (StyleBullet 0)}]}
+        `shouldBe` "- item"
+
+    it "renders StyleBullet 1 indented by 2 spaces" $
+      noteToMarkdown
+        NoteText{ntText = "nested", ntRuns = [baseRun{nrLength = 6, nrStyle = Just (StyleBullet 1)}]}
+        `shouldBe` "  - nested"
+
+    it "renders StyleBullet 2 indented by 4 spaces" $
+      noteToMarkdown
+        NoteText{ntText = "deep", ntRuns = [baseRun{nrLength = 4, nrStyle = Just (StyleBullet 2)}]}
+        `shouldBe` "    - deep"
+
+    it "renders StyleDash 0 as top-level bullet" $
+      noteToMarkdown
+        NoteText{ntText = "item", ntRuns = [baseRun{nrLength = 4, nrStyle = Just (StyleDash 0)}]}
+        `shouldBe` "- item"
+
+    it "separates consecutive list items with a single newline" $
+      noteToMarkdown
+        NoteText
+          { ntText = "first\nsecond"
+          , ntRuns =
+              [ baseRun{nrLength = 6, nrStyle = Just (StyleBullet 0)}
+              , baseRun{nrLength = 6, nrStyle = Just (StyleBullet 0)}
+              ]
+          }
+        `shouldBe` "- first\n- second"
+
+    it "separates a list item from body text with a double newline" $
+      noteToMarkdown
+        NoteText
+          { ntText = "item\nbody"
+          , ntRuns =
+              [ baseRun{nrLength = 5, nrStyle = Just (StyleBullet 0)}
+              , baseRun{nrLength = 4}
+              ]
+          }
+        `shouldBe` "- item\n\nbody"
+
+    it "numbers two consecutive StyleNumbered items" $
+      noteToMarkdown
+        NoteText
+          { ntText = "first\nsecond"
+          , ntRuns =
+              [ baseRun{nrLength = 6, nrStyle = Just (StyleNumbered 0 Nothing)}
+              , baseRun{nrLength = 6, nrStyle = Just (StyleNumbered 0 Nothing)}
+              ]
+          }
+        `shouldBe` "1. first\n2. second"
+
+    it "starts numbered list at ms when ms is Just" $
+      noteToMarkdown
+        NoteText
+          { ntText = "item"
+          , ntRuns = [baseRun{nrLength = 4, nrStyle = Just (StyleNumbered 0 (Just 3))}]
+          }
+        `shouldBe` "3. item"
+
+    it "resets numbered counter after a non-numbered paragraph" $
+      noteToMarkdown
+        NoteText
+          { ntText = "first\nsecond\nbody\nthird"
+          , ntRuns =
+              [ baseRun{nrLength = 6, nrStyle = Just (StyleNumbered 0 Nothing)}
+              , baseRun{nrLength = 7, nrStyle = Just (StyleNumbered 0 Nothing)}
+              , baseRun{nrLength = 5}
+              , baseRun{nrLength = 5, nrStyle = Just (StyleNumbered 0 Nothing)}
+              ]
+          }
+        `shouldBe` "1. first\n2. second\n\nbody\n\n1. third"
+
+    it "does not reset counter when ms is Just on every item" $
+      noteToMarkdown
+        NoteText
+          { ntText = "first\nsecond"
+          , ntRuns =
+              [ baseRun{nrLength = 6, nrStyle = Just (StyleNumbered 0 (Just 1))}
+              , baseRun{nrLength = 6, nrStyle = Just (StyleNumbered 0 (Just 1))}
+              ]
+          }
+        `shouldBe` "1. first\n2. second"
+
+    it "renders StyleNumbered 1 indented by 2 spaces" $
+      noteToMarkdown
+        NoteText
+          { ntText = "nested"
+          , ntRuns = [baseRun{nrLength = 6, nrStyle = Just (StyleNumbered 1 Nothing)}]
+          }
+        `shouldBe` "  1. nested"
+
+    it "renders StyleChecklist True as checked item" $
+      noteToMarkdown
+        NoteText
+          { ntText = "done"
+          , ntRuns = [baseRun{nrLength = 4, nrStyle = Just (StyleChecklist 0 True)}]
+          }
+        `shouldBe` "- [x] done"
+
+    it "renders StyleChecklist False as unchecked item" $
+      noteToMarkdown
+        NoteText
+          { ntText = "todo"
+          , ntRuns = [baseRun{nrLength = 4, nrStyle = Just (StyleChecklist 0 False)}]
+          }
+        `shouldBe` "- [ ] todo"
+
+  describe "splitIntoParagraphs" $ do
+    it "single styled run without newline produces one paragraph" $
+      splitIntoParagraphs
+        NoteText
+          { ntText = "hello"
+          , ntRuns = [baseRun{nrLength = 5, nrStyle = Just StyleHeading}]
+          }
+        `shouldBe` [RawParagraph{rpStyle = Just StyleHeading, rpSegments = [baseSeg "hello"]}]
+
+    it "neutral run is absorbed into the preceding styled paragraph" $
+      splitIntoParagraphs
+        NoteText
+          { ntText = "helloworld"
+          , ntRuns =
+              [ baseRun{nrLength = 5, nrStyle = Just StyleHeading}
+              , baseRun{nrLength = 5}
+              ]
+          }
+        `shouldBe` [ RawParagraph
+                       { rpStyle = Just StyleHeading
+                       , rpSegments = [baseSeg "hello", baseSeg "world"]
+                       }
+                   ]
+
+    it "newline in a run closes the current paragraph" $
+      splitIntoParagraphs
+        NoteText
+          { ntText = "a\nb"
+          , ntRuns =
+              [ baseRun{nrLength = 1, nrStyle = Just StyleHeading}
+              , baseRun{nrLength = 1}
+              , baseRun{nrLength = 1}
+              ]
+          }
+        `shouldBe` [ RawParagraph{rpStyle = Just StyleHeading, rpSegments = [baseSeg "a"]}
+                   , RawParagraph{rpStyle = Nothing, rpSegments = [baseSeg "b"]}
+                   ]
+
+    it "inline-only runs with different attributes produce distinct segments in one paragraph" $
+      splitIntoParagraphs
+        NoteText
+          { ntText = "boldnormal"
+          , ntRuns = [baseRun{nrLength = 4, nrBold = True}, baseRun{nrLength = 6}]
+          }
+        `shouldBe` [ RawParagraph
+                       { rpStyle = Nothing
+                       , rpSegments = [(baseSeg "bold"){rsBold = True}, baseSeg "normal"]
+                       }
+                   ]
+
+    it "xFFFC with an attachment id is replaced with [attachment: id]" $
+      splitIntoParagraphs
+        NoteText
+          { ntText = "\xFFFC"
+          , ntRuns = [baseRun{nrAttachmentId = Just "att-1"}]
+          }
+        `shouldBe` [RawParagraph{rpStyle = Nothing, rpSegments = [baseSeg "[attachment: att-1]"]}]
+
+    it "xFFFC without an attachment id is replaced with [attachment]" $
+      splitIntoParagraphs
+        NoteText{ntText = "\xFFFC", ntRuns = [baseRun]}
+        `shouldBe` [RawParagraph{rpStyle = Nothing, rpSegments = [baseSeg "[attachment]"]}]
+
+
+baseRun :: NoteRun
+baseRun =
+  NoteRun
+    { nrLength = 1
+    , nrStyle = Nothing
+    , nrBold = False
+    , nrItalic = False
+    , nrUnderline = False
+    , nrStrikethrough = False
+    , nrAttachmentId = Nothing
+    , nrLink = Nothing
+    }
+
+
+baseSeg :: Text -> RawSegment
+baseSeg t =
+  RawSegment
+    { rsText = t
+    , rsBold = False
+    , rsItalic = False
+    , rsStrikethrough = False
+    , rsUnderline = False
+    , rsLink = Nothing
+    }
diff --git a/test/HStratus/Notes/NoteDataSpec.hs b/test/HStratus/Notes/NoteDataSpec.hs
new file mode 100644
--- /dev/null
+++ b/test/HStratus/Notes/NoteDataSpec.hs
@@ -0,0 +1,232 @@
+{-# LANGUAGE OverloadedStrings #-}
+
+{- |
+Module      : HStratus.Notes.NoteDataSpec
+Copyright   : (c) 2026 Tim Emiola
+Maintainer  : Tim Emiola <adetokunbo@emio.la>
+SPDX-License-Identifier: BSD-3-Clause
+
+Tests for the CloudKit-to-domain record converters in
+'Network.HStratus.Internal.Notes.NoteData'.
+-}
+module HStratus.Notes.NoteDataSpec (spec) where
+
+import Data.Aeson (FromJSON, eitherDecode)
+import qualified Data.ByteString.Lazy as LBS
+import Data.Time.Clock.POSIX (posixSecondsToUTCTime)
+import Network.HStratus.Internal.Notes.CloudKit
+  ( CKLookupResponse (..)
+  , CKQueryResponse (..)
+  , CKZoneChangesResponse (..)
+  , CKZoneChangesZone (..)
+  )
+import Network.HStratus.Internal.Notes.NoteData
+  ( noteRecordToFolder
+  , noteRecordToNote
+  , noteRecordToSummary
+  , parseFoldersFromChanges
+  , parseFoldersFromQuery
+  , parseSummariesFromChanges
+  , parseSummariesFromQuery
+  )
+import Network.HStratus.Notes.Note
+import Test.Hspec
+
+
+spec :: Spec
+spec = describe "Network.HStratus.Internal.Notes.NoteData" $ do
+  describe "noteRecordToSummary" $ do
+    it "parses a note record into a NoteSummary" $ do
+      r <- decodeOrFail lookupNoteJson :: IO CKLookupResponse
+      case lrRecords r of
+        [] -> expectationFailure "expected records"
+        rec : _ ->
+          noteRecordToSummary rec
+            `shouldBe` Just
+              NoteSummary
+                { nsId = NoteId "Note/NOTE-FIXTURE"
+                , nsTitle = Just "Synthetic note"
+                , nsSnippet = Just "Synthetic snippet"
+                , nsModified = Just (posixSecondsToUTCTime 1735776000)
+                , nsFolderId = Just (FolderId "Folder/FOLDER-FIXTURE")
+                , nsDeleted = False
+                , nsLocked = False
+                }
+    it "parses a SearchIndexes record into a NoteSummary with nsLocked = False" $ do
+      r <- decodeOrFail querySearchIndexJson :: IO CKQueryResponse
+      case qrRecords r of
+        [] -> expectationFailure "expected records"
+        rec : _ ->
+          noteRecordToSummary rec
+            `shouldBe` Just
+              NoteSummary
+                { nsId = NoteId "Note/NOTE-FIXTURE"
+                , nsTitle = Just "Synthetic note"
+                , nsSnippet = Just "Synthetic snippet"
+                , nsModified = Just (posixSecondsToUTCTime 1735776000)
+                , nsFolderId = Just (FolderId "Folder/FOLDER-FIXTURE")
+                , nsDeleted = False
+                , nsLocked = False
+                }
+    it "returns Nothing for a tombstone" $ do
+      r <- decodeOrFail zoneChangesJson :: IO CKZoneChangesResponse
+      case concatMap zczRecords (zcrZones r) of
+        [_, _, tombstone] -> noteRecordToSummary tombstone `shouldBe` Nothing
+        recs -> expectationFailure $ "expected 3 records, got " <> show (length recs)
+    it "returns Nothing for a folder record" $ do
+      r <- decodeOrFail queryFoldersJson :: IO CKQueryResponse
+      case qrRecords r of
+        [] -> expectationFailure "expected records"
+        rec : _ -> noteRecordToSummary rec `shouldBe` Nothing
+
+  describe "noteRecordToFolder" $ do
+    it "parses a folder record into a NoteFolder" $ do
+      r <- decodeOrFail queryFoldersJson :: IO CKQueryResponse
+      case qrRecords r of
+        [] -> expectationFailure "expected records"
+        rec : _ ->
+          noteRecordToFolder rec
+            `shouldBe` Just
+              NoteFolder
+                { nfId = FolderId "Folder/FOLDER-FIXTURE"
+                , nfName = Just "Synthetic Folder"
+                }
+    it "returns Nothing for a note record" $ do
+      r <- decodeOrFail lookupNoteJson :: IO CKLookupResponse
+      case lrRecords r of
+        [] -> expectationFailure "expected records"
+        rec : _ -> noteRecordToFolder rec `shouldBe` Nothing
+
+  describe "noteRecordToNote" $ do
+    it "decodes the note body from TextDataEncrypted" $ do
+      r <- decodeOrFail lookupNoteJson :: IO CKLookupResponse
+      case lrRecords r of
+        [] -> expectationFailure "expected records"
+        rec : _ -> case noteRecordToNote rec of
+          Nothing -> expectationFailure "expected Just Note"
+          Just n -> noteBodyBytes n `shouldBe` "synthetic note body"
+    it "returns Nothing when TextDataEncrypted is absent" $ do
+      r <- decodeOrFail zoneChangesJson :: IO CKZoneChangesResponse
+      case concatMap zczRecords (zcrZones r) of
+        noteRec : _ -> noteRecordToNote noteRec `shouldBe` Nothing
+        [] -> expectationFailure "expected records"
+
+  describe "parseSummariesFromQuery" $ do
+    it "returns one summary from a note query response" $ do
+      r <- decodeOrFail queryNotesJson :: IO CKQueryResponse
+      length (parseSummariesFromQuery r) `shouldBe` 1
+    it "returns one summary from a SearchIndexes query response" $ do
+      r <- decodeOrFail querySearchIndexJson :: IO CKQueryResponse
+      length (parseSummariesFromQuery r) `shouldBe` 1
+    it "returns empty from a folders-only response" $ do
+      r <- decodeOrFail queryFoldersJson :: IO CKQueryResponse
+      parseSummariesFromQuery r `shouldBe` []
+
+  describe "parseFoldersFromQuery" $ do
+    it "returns one folder from a folders query response" $ do
+      r <- decodeOrFail queryFoldersJson :: IO CKQueryResponse
+      length (parseFoldersFromQuery r) `shouldBe` 1
+    it "returns empty from a notes-only response" $ do
+      r <- decodeOrFail queryNotesJson :: IO CKQueryResponse
+      parseFoldersFromQuery r `shouldBe` []
+
+  describe "parseSummariesFromChanges" $ do
+    it "extracts only Note records, skipping tombstones and folders" $ do
+      r <- decodeOrFail zoneChangesJson :: IO CKZoneChangesResponse
+      length (parseSummariesFromChanges r) `shouldBe` 1
+
+  describe "parseFoldersFromChanges" $ do
+    it "extracts only Folder records" $ do
+      r <- decodeOrFail zoneChangesJson :: IO CKZoneChangesResponse
+      length (parseFoldersFromChanges r) `shouldBe` 1
+
+
+-- Helpers
+
+decodeOrFail :: (FromJSON a) => LBS.ByteString -> IO a
+decodeOrFail bs = either fail pure (eitherDecode bs)
+
+
+-- Fixtures
+
+queryFoldersJson :: LBS.ByteString
+queryFoldersJson =
+  "{\"records\":[{\"recordName\":\"Folder/FOLDER-FIXTURE\"\
+  \,\"recordType\":\"Folder\"\
+  \,\"recordChangeTag\":\"folder-change-tag-fixture\"\
+  \,\"zoneID\":{\"zoneName\":\"Notes\",\"zoneType\":\"REGULAR_CUSTOM_ZONE\"}\
+  \,\"fields\":{\"TitleEncrypted\":{\"type\":\"ENCRYPTED_BYTES\",\"value\":\"U3ludGhldGljIEZvbGRlcg==\"}\
+  \,\"HasSubfolder\":{\"type\":\"INT64\",\"value\":1}}}]\
+  \,\"continuationMarker\":null}"
+
+
+querySearchIndexJson :: LBS.ByteString
+querySearchIndexJson =
+  "{\"records\":[{\"recordName\":\"Note/NOTE-FIXTURE\"\
+  \,\"recordType\":\"SearchIndexes\"\
+  \,\"fields\":{\
+  \\"TitleEncrypted\":{\"type\":\"ENCRYPTED_BYTES\",\"value\":\"U3ludGhldGljIG5vdGU=\"}\
+  \,\"SnippetEncrypted\":{\"type\":\"ENCRYPTED_BYTES\",\"value\":\"U3ludGhldGljIHNuaXBwZXQ=\"}\
+  \,\"ModificationDate\":{\"type\":\"TIMESTAMP\",\"value\":1735776000000}\
+  \,\"Deleted\":{\"type\":\"INT64\",\"value\":0}\
+  \,\"Folder\":{\"type\":\"REFERENCE\",\"value\":{\"recordName\":\"Folder/FOLDER-FIXTURE\",\"action\":\"VALIDATE\"}}}}]\
+  \,\"continuationMarker\":null}"
+
+
+queryNotesJson :: LBS.ByteString
+queryNotesJson =
+  "{\"records\":[{\"recordName\":\"Note/NOTE-FIXTURE\"\
+  \,\"recordType\":\"Note\"\
+  \,\"fields\":{\
+  \\"TitleEncrypted\":{\"type\":\"ENCRYPTED_BYTES\",\"value\":\"U3ludGhldGljIG5vdGU=\"}\
+  \,\"SnippetEncrypted\":{\"type\":\"ENCRYPTED_BYTES\",\"value\":\"U3ludGhldGljIHNuaXBwZXQ=\"}\
+  \,\"ModificationDate\":{\"type\":\"TIMESTAMP\",\"value\":1735776000000}\
+  \,\"Deleted\":{\"type\":\"INT64\",\"value\":0}\
+  \,\"Folder\":{\"type\":\"REFERENCE\",\"value\":{\"recordName\":\"Folder/FOLDER-FIXTURE\",\"action\":\"VALIDATE\"}}}}]\
+  \,\"continuationMarker\":null}"
+
+
+lookupNoteJson :: LBS.ByteString
+lookupNoteJson =
+  "{\"records\":[{\"recordName\":\"Note/NOTE-FIXTURE\"\
+  \,\"recordType\":\"Note\"\
+  \,\"recordChangeTag\":\"note-change-tag-fixture\"\
+  \,\"created\":{\"timestamp\":1735689600000,\"userRecordName\":\"_synthetic_user\"}\
+  \,\"modified\":{\"timestamp\":1735776000000,\"userRecordName\":\"_synthetic_user\"}\
+  \,\"deleted\":false\
+  \,\"zoneID\":{\"zoneName\":\"Notes\",\"zoneType\":\"REGULAR_CUSTOM_ZONE\"}\
+  \,\"fields\":{\
+  \\"TitleEncrypted\":{\"type\":\"ENCRYPTED_BYTES\",\"value\":\"U3ludGhldGljIG5vdGU=\"}\
+  \,\"SnippetEncrypted\":{\"type\":\"ENCRYPTED_BYTES\",\"value\":\"U3ludGhldGljIHNuaXBwZXQ=\"}\
+  \,\"TextDataEncrypted\":{\"type\":\"ENCRYPTED_BYTES\",\"value\":\"c3ludGhldGljIG5vdGUgYm9keQ==\"}\
+  \,\"ModificationDate\":{\"type\":\"TIMESTAMP\",\"value\":1735776000000}\
+  \,\"Deleted\":{\"type\":\"INT64\",\"value\":0}\
+  \,\"Folder\":{\"type\":\"REFERENCE\",\"value\":{\"recordName\":\"Folder/FOLDER-FIXTURE\",\"action\":\"VALIDATE\"}}\
+  \,\"Attachments\":{\"type\":\"REFERENCE_LIST\",\"value\":[{\"recordName\":\"Attachment/ATTACHMENT-FIXTURE\",\"action\":\"VALIDATE\"}]}\
+  \}}]\
+  \,\"syncToken\":\"notes-lookup-sync-token-fixture\"}"
+
+
+zoneChangesJson :: LBS.ByteString
+zoneChangesJson =
+  "{\"zones\":[{\"zoneID\":{\"zoneName\":\"Notes\",\"zoneType\":\"REGULAR_CUSTOM_ZONE\"}\
+  \,\"syncToken\":\"notes-zone-sync-token-fixture\"\
+  \,\"moreComing\":false\
+  \,\"records\":[\
+  \{\"recordName\":\"Note/NOTE-FIXTURE\"\
+  \,\"recordType\":\"Note\"\
+  \,\"recordChangeTag\":\"note-change-tag-fixture\"\
+  \,\"fields\":{\
+  \\"TitleEncrypted\":{\"type\":\"ENCRYPTED_BYTES\",\"value\":\"U3ludGhldGljIG5vdGU=\"}\
+  \,\"SnippetEncrypted\":{\"type\":\"ENCRYPTED_BYTES\",\"value\":\"U3ludGhldGljIHNuaXBwZXQ=\"}\
+  \,\"ModificationDate\":{\"type\":\"TIMESTAMP\",\"value\":1735776000000}\
+  \,\"Deleted\":{\"type\":\"INT64\",\"value\":0}\
+  \,\"Folder\":{\"type\":\"REFERENCE\",\"value\":{\"recordName\":\"Folder/FOLDER-FIXTURE\",\"action\":\"VALIDATE\"}}}}\
+  \,{\"recordName\":\"Folder/FOLDER-FIXTURE\"\
+  \,\"recordType\":\"Folder\"\
+  \,\"recordChangeTag\":\"folder-change-tag-fixture\"\
+  \,\"fields\":{\
+  \\"TitleEncrypted\":{\"type\":\"ENCRYPTED_BYTES\",\"value\":\"U3ludGhldGljIEZvbGRlcg==\"}\
+  \,\"HasSubfolder\":{\"type\":\"INT64\",\"value\":1}}}\
+  \,{\"recordName\":\"Note/NOTE-DELETED-FIXTURE\",\"deleted\":true}\
+  \]}]}"
diff --git a/test/HStratus/Notes/ProtoSpec.hs b/test/HStratus/Notes/ProtoSpec.hs
new file mode 100644
--- /dev/null
+++ b/test/HStratus/Notes/ProtoSpec.hs
@@ -0,0 +1,158 @@
+{-# LANGUAGE OverloadedStrings #-}
+
+{- |
+Module      : HStratus.Notes.ProtoSpec
+Copyright   : (c) 2026 Tim Emiola
+Maintainer  : Tim Emiola <adetokunbo@emio.la>
+SPDX-License-Identifier: BSD-3-Clause
+
+Tests for the proto3-wire decoders in
+'Network.HStratus.Internal.Notes.Proto'.
+-}
+module HStratus.Notes.ProtoSpec (spec) where
+
+import Data.ByteString (ByteString)
+import HStratus.Notes.Arbitraries ()
+import HStratus.Notes.TestHelper
+import Network.HStratus.Internal.Notes.Proto
+import Test.Hspec
+import Test.Hspec.Benri (endsLeft_, endsRight)
+import Test.Hspec.QuickCheck (prop)
+import Test.QuickCheck (counterexample, (===))
+
+
+spec :: Spec
+spec = describe "decodeNoteStoreProto" $ do
+  it "decodes a minimal note with text only" $
+    pure (decodeNoteStoreProto minimalNoteBytes)
+      `endsRight` ProtoNote{pnNoteText = "hello", pnAttributeRuns = []}
+
+  it "returns an error for empty input" $
+    endsLeft_ $
+      pure (decodeNoteStoreProto "")
+
+  it "decodes attribute run length and paragraph style" $
+    case decodeNoteStoreProto noteWithRunBytes of
+      Left err -> expectationFailure err
+      Right note -> do
+        pnNoteText note `shouldBe` "hi"
+        case pnAttributeRuns note of
+          [run] -> do
+            parLength run `shouldBe` 2
+            parParagraphStyle run
+              `shouldBe` Just
+                ProtoParagraphStyle
+                  { ppsStyleType = 1
+                  , ppsIndent = 0
+                  , ppsChecked = Nothing
+                  , ppsListStart = Nothing
+                  , ppsBlockQuote = False
+                  }
+          runs -> expectationFailure $ "expected 1 run, got " <> show (length runs)
+
+  it "decodes strikethrough field 7" $
+    case decodeNoteStoreProto noteWithStrikethroughBytes of
+      Left err -> expectationFailure err
+      Right note ->
+        case pnAttributeRuns note of
+          [run] -> do
+            parLength run `shouldBe` 2
+            parStrikethrough run `shouldBe` 1
+          runs -> expectationFailure $ "expected 1 run, got " <> show (length runs)
+
+  it "decodes indent_amount field 4 into ppsIndent" $
+    case decodeNoteStoreProto bulletIndent1Bytes of
+      Left err -> expectationFailure err
+      Right note ->
+        case pnAttributeRuns note of
+          [run] ->
+            fmap ppsIndent (parParagraphStyle run) `shouldBe` Just 1
+          runs -> expectationFailure $ "expected 1 run, got " <> show (length runs)
+
+  it "decodes checklist.done = 1 into ppsChecked = Just True" $
+    case decodeNoteStoreProto checklistDoneBytes of
+      Left err -> expectationFailure err
+      Right note ->
+        case pnAttributeRuns note of
+          [run] ->
+            fmap ppsChecked (parParagraphStyle run) `shouldBe` Just (Just True)
+          runs -> expectationFailure $ "expected 1 run, got " <> show (length runs)
+
+  it "decodes checklist.done = 0 into ppsChecked = Just False" $
+    case decodeNoteStoreProto checklistUndoneBytes of
+      Left err -> expectationFailure err
+      Right note ->
+        case pnAttributeRuns note of
+          [run] ->
+            fmap ppsChecked (parParagraphStyle run) `shouldBe` Just (Just False)
+          runs -> expectationFailure $ "expected 1 run, got " <> show (length runs)
+
+  it "decodes absent checklist into ppsChecked = Nothing" $
+    case decodeNoteStoreProto checklistAbsentBytes of
+      Left err -> expectationFailure err
+      Right note ->
+        case pnAttributeRuns note of
+          [run] ->
+            fmap ppsChecked (parParagraphStyle run) `shouldBe` Just Nothing
+          runs -> expectationFailure $ "expected 1 run, got " <> show (length runs)
+
+  it "decodes starting_list_item_number into ppsListStart = Just 3" $
+    case decodeNoteStoreProto numberedListStart3Bytes of
+      Left err -> expectationFailure err
+      Right note ->
+        case pnAttributeRuns note of
+          [run] ->
+            fmap ppsListStart (parParagraphStyle run) `shouldBe` Just (Just 3)
+          runs -> expectationFailure $ "expected 1 run, got " <> show (length runs)
+
+  it "decodes block_quote into ppsBlockQuote = True" $
+    case decodeNoteStoreProto blockQuoteBytes of
+      Left err -> expectationFailure err
+      Right note ->
+        case pnAttributeRuns note of
+          [run] ->
+            fmap ppsBlockQuote (parParagraphStyle run) `shouldBe` Just True
+          runs -> expectationFailure $ "expected 1 run, got " <> show (length runs)
+
+  prop "encode/decode roundtrip preserves ProtoParagraphStyle" $ \ps ->
+    case decodeNoteStoreProto (mkNote "x" [runWith 1 (encodeParagraphStyle ps)]) of
+      Left err -> counterexample err False
+      Right note -> case pnAttributeRuns note of
+        [run] -> parParagraphStyle run === Just ps
+        runs -> counterexample ("expected 1 run, got " <> show (length runs)) False
+
+
+minimalNoteBytes :: ByteString
+minimalNoteBytes = mkNote "hello" []
+
+
+noteWithRunBytes :: ByteString
+noteWithRunBytes = mkNote "hi" [runWith 2 (psStyleType 1)]
+
+
+noteWithStrikethroughBytes :: ByteString
+noteWithStrikethroughBytes = mkNote "hi" [runFields 2 [(7, 1)]]
+
+
+bulletIndent1Bytes :: ByteString
+bulletIndent1Bytes = mkNote "hi" [runWith 1 (psStyleType 100 <> psIndentAmount 1)]
+
+
+checklistDoneBytes :: ByteString
+checklistDoneBytes = mkNote "hi" [runWith 1 (psStyleType 103 <> psChecklist True)]
+
+
+checklistUndoneBytes :: ByteString
+checklistUndoneBytes = mkNote "hi" [runWith 1 (psStyleType 103 <> psChecklist False)]
+
+
+checklistAbsentBytes :: ByteString
+checklistAbsentBytes = mkNote "hi" [runWith 1 (psStyleType 103)]
+
+
+numberedListStart3Bytes :: ByteString
+numberedListStart3Bytes = mkNote "hi" [runWith 1 (psStyleType 102 <> psListStart 3)]
+
+
+blockQuoteBytes :: ByteString
+blockQuoteBytes = mkNote "hi" [runWith 1 psBlockQuote]
diff --git a/test/HStratus/Notes/TestHelper.hs b/test/HStratus/Notes/TestHelper.hs
new file mode 100644
--- /dev/null
+++ b/test/HStratus/Notes/TestHelper.hs
@@ -0,0 +1,171 @@
+{-# LANGUAGE NamedFieldPuns #-}
+
+{- |
+Module      : HStratus.Notes.TestHelper
+Copyright   : (c) 2026 Tim Emiola
+Maintainer  : Tim Emiola <adetokunbo@emio.la>
+SPDX-License-Identifier: BSD-3-Clause
+
+Programmatic proto\/gzip fixture builders for 'ProtoSpec' and 'DecodeSpec':
+encodes domain values with @proto3-wire@ and compresses with @zlib@, avoiding
+hand-crafted byte literals.
+-}
+module HStratus.Notes.TestHelper
+  ( mkNote
+  , mkNoteGzip
+  , mkNoteZlib
+  , mkEmptyGzip
+  , runWith
+  , runFields
+  , psStyleType
+  , psIndentAmount
+  , psChecklist
+  , psListStart
+  , psBlockQuote
+  , encodeParagraphStyle
+  , encodeNoteStyle
+  , encodeNoteRun
+  , encodeNoteText
+  )
+where
+
+import qualified Codec.Compression.GZip as GZip
+import qualified Codec.Compression.Zlib as Zlib
+import Data.ByteString (ByteString)
+import qualified Data.ByteString.Lazy as LBS
+import Data.Int (Int32)
+import Data.Text (Text)
+import qualified Data.Text.Lazy as TL
+import Network.HStratus.Internal.Notes.Note
+  ( NoteRun (..)
+  , NoteStyle (..)
+  , NoteText (..)
+  )
+import Network.HStratus.Internal.Notes.Proto (ProtoParagraphStyle (..))
+import qualified Proto3.Wire.Encode as Encode
+
+
+{- | Encode a NoteStoreProto with the given note text and attribute runs.
+Returns raw (uncompressed) proto bytes.
+-}
+mkNote :: Text -> [Encode.MessageBuilder] -> ByteString
+mkNote noteText runs =
+  LBS.toStrict . Encode.toLazyByteString $
+    Encode.embedded 2 $
+      Encode.embedded 3 $
+        Encode.text 2 (TL.fromStrict noteText)
+          <> foldMap (Encode.embedded 5) runs
+
+
+-- | Like 'mkNote' but gzip-compressed, suitable for 'decodeNoteBody'.
+mkNoteGzip :: Text -> [Encode.MessageBuilder] -> ByteString
+mkNoteGzip noteText runs =
+  LBS.toStrict . GZip.compress . LBS.fromStrict $ mkNote noteText runs
+
+
+-- | Like 'mkNote' but zlib-compressed, suitable for 'decodeNoteBody'.
+mkNoteZlib :: Text -> [Encode.MessageBuilder] -> ByteString
+mkNoteZlib noteText runs =
+  LBS.toStrict . Zlib.compress . LBS.fromStrict $ mkNote noteText runs
+
+
+{- | Gzip-compressed empty proto (no document field).
+'decodeNoteBody' returns @Left@ for this input.
+-}
+mkEmptyGzip :: ByteString
+mkEmptyGzip = LBS.toStrict (GZip.compress LBS.empty)
+
+
+-- | An AttributeRun with the given length and a ParagraphStyle sub-message.
+runWith :: Int32 -> Encode.MessageBuilder -> Encode.MessageBuilder
+runWith len style = Encode.int32 1 len <> Encode.embedded 2 style
+
+
+{- | An AttributeRun with the given length and a list of (fieldNumber, value)
+pairs for inline fields (font_weight, underlined, strikethrough, etc.).
+-}
+runFields :: Int32 -> [(Int32, Int32)] -> Encode.MessageBuilder
+runFields len fields =
+  Encode.int32 1 len
+    <> foldMap (\(fn, v) -> Encode.int32 (fromIntegral fn) v) fields
+
+
+-- ParagraphStyle field builders -------------------------------------------
+
+psStyleType :: Int32 -> Encode.MessageBuilder
+psStyleType = Encode.int32 1
+
+
+psIndentAmount :: Int32 -> Encode.MessageBuilder
+psIndentAmount = Encode.int32 4
+
+
+-- | Encode a Checklist sub-message (field 5) with the given done state.
+psChecklist :: Bool -> Encode.MessageBuilder
+psChecklist done =
+  Encode.embedded 5 (Encode.int32 2 (if done then 1 else 0))
+
+
+psListStart :: Int32 -> Encode.MessageBuilder
+psListStart = Encode.int32 7
+
+
+psBlockQuote :: Encode.MessageBuilder
+psBlockQuote = Encode.int32 8 1
+
+
+-- Encoder functions -------------------------------------------------------
+
+{- | Encode a 'ProtoParagraphStyle' back to wire bytes, reusing the ps* helpers.
+Proto3 default values (0 / False / Nothing) are omitted to match wire convention.
+-}
+encodeParagraphStyle :: ProtoParagraphStyle -> Encode.MessageBuilder
+encodeParagraphStyle ProtoParagraphStyle{ppsStyleType, ppsIndent, ppsChecked, ppsListStart, ppsBlockQuote} =
+  (if ppsStyleType /= 0 then psStyleType ppsStyleType else mempty)
+    <> (if ppsIndent /= 0 then psIndentAmount ppsIndent else mempty)
+    <> maybe mempty psChecklist ppsChecked
+    <> maybe mempty psListStart ppsListStart
+    <> (if ppsBlockQuote then psBlockQuote else mempty)
+
+
+{- | Encode a 'NoteStyle' as a paragraph_style sub-message.
+Inverse of 'toNoteStyle'; 'StyleBody False' is not in the image of
+'toNoteStyle' and should not appear in generated test data.
+-}
+encodeNoteStyle :: NoteStyle -> Encode.MessageBuilder
+encodeNoteStyle style = encodeParagraphStyle $ case style of
+  StyleTitle -> ProtoParagraphStyle 0 0 Nothing Nothing False
+  StyleHeading -> ProtoParagraphStyle 1 0 Nothing Nothing False
+  StyleSubheading -> ProtoParagraphStyle 2 0 Nothing Nothing False
+  StyleMonospaced -> ProtoParagraphStyle 4 0 Nothing Nothing False
+  StyleBody q -> ProtoParagraphStyle 0 0 Nothing Nothing q
+  StyleBullet i -> ProtoParagraphStyle 100 (fromIntegral i) Nothing Nothing False
+  StyleDash i -> ProtoParagraphStyle 101 (fromIntegral i) Nothing Nothing False
+  StyleNumbered i ms -> ProtoParagraphStyle 102 (fromIntegral i) Nothing (fmap fromIntegral ms) False
+  StyleChecklist i c -> ProtoParagraphStyle 103 (fromIntegral i) (Just c) Nothing False
+
+
+{- | Encode a 'NoteRun' as an AttributeRun sub-message.
+'nrLink' is always 'Nothing' in generated runs (deferred).
+-}
+encodeNoteRun :: NoteRun -> Encode.MessageBuilder
+encodeNoteRun NoteRun{nrLength, nrStyle, nrBold, nrItalic, nrUnderline, nrStrikethrough, nrAttachmentId, nrLink} =
+  Encode.int32 1 nrLength
+    <> maybe mempty (Encode.embedded 2 . encodeNoteStyle) nrStyle
+    <> (if fw /= 0 then Encode.int32 5 fw else mempty)
+    <> (if nrUnderline then Encode.int32 6 1 else mempty)
+    <> (if nrStrikethrough then Encode.int32 7 1 else mempty)
+    <> maybe mempty (\t -> Encode.embedded 12 (Encode.text 1 (TL.fromStrict t))) nrAttachmentId
+    <> maybe mempty (Encode.text 9 . TL.fromStrict) nrLink
+ where
+  fw :: Int32
+  fw = case (nrBold, nrItalic) of
+    (True, True) -> 3
+    (True, False) -> 1
+    (False, True) -> 2
+    (False, False) -> 0
+
+
+-- | Encode a 'NoteText' to gzip-compressed proto bytes, suitable for 'decodeNoteBody'.
+encodeNoteText :: NoteText -> ByteString
+encodeNoteText NoteText{ntText, ntRuns} = mkNoteGzip ntText (map encodeNoteRun ntRuns)
diff --git a/test/HStratus/NotesSpec.hs b/test/HStratus/NotesSpec.hs
new file mode 100644
--- /dev/null
+++ b/test/HStratus/NotesSpec.hs
@@ -0,0 +1,255 @@
+{-# LANGUAGE OverloadedStrings #-}
+
+{- |
+Module      : HStratus.NotesSpec
+Copyright   : (c) 2026 Tim Emiola
+Maintainer  : Tim Emiola <adetokunbo@emio.la>
+SPDX-License-Identifier: BSD-3-Clause
+
+Tests for the top-level Notes API ('Network.HStratus.Notes').
+-}
+module HStratus.NotesSpec (spec) where
+
+import Control.Exception (displayException)
+import Data.Aeson (object)
+import qualified Data.ByteString as BS
+import qualified Data.ByteString.Char8 as BS8
+import qualified Data.ByteString.Lazy as LBS
+import Data.IORef (IORef, newIORef, readIORef, writeIORef)
+import qualified Data.Map.Strict as Map
+import qualified Data.Text as Text
+import Network.HStratus.Http (mkApiWith)
+import Network.HStratus.Http.Endpoints (Endpoints (..))
+import Network.HStratus.Notes
+  ( NotesApi
+  , NotesError (..)
+  , getNote
+  , mkNotesApi
+  , noteFolders
+  , notesInFolder
+  , recentNotes
+  )
+import Network.HStratus.Notes.Note
+import Network.HStratus.Session (AccountData (..), Credentials (..), Session (..), Webservice (..))
+import Network.HTTP.Client (Request (..), defaultManagerSettings, defaultRequest, newManager)
+import Network.HTTP.Types (HeaderName, hContentType, methodPost, status200, status404)
+import Network.Wai (Application, rawPathInfo, responseLBS)
+import Network.Wai.Handler.Warp (testWithApplication)
+import System.IO.Temp (withSystemTempDirectory)
+import Test.Hspec
+import Test.Hspec.Benri (endsNothing)
+
+
+spec :: Spec
+spec = describe "Network.HStratus.Notes" $ do
+  describe "NotesError displayException" $ do
+    it "NotesHttpError" $
+      displayException (NotesHttpError 404) `shouldBe` "iCloud Notes: HTTP error 404"
+    it "NotesParseError" $
+      displayException (NotesParseError "bad json") `shouldBe` "iCloud Notes: parse error: bad json"
+
+  describe "noteFolders" $ do
+    it "returns NoteFolder list from a query response" $
+      withNotesMock queryFoldersJson "/records/query" $ \na -> do
+        folders <- noteFolders na
+        case folders of
+          [f] -> do
+            nfId f `shouldBe` FolderId "Folder/FOLDER-FIXTURE"
+            nfName f `shouldBe` Just "Synthetic Folder"
+          _ -> expectationFailure $ "expected 1 folder, got " <> show (length folders)
+
+  describe "recentNotes" $ do
+    it "returns NoteSummary list from a query response" $
+      withNotesMock queryNotesJson "/records/query" $ \na -> do
+        notes <- recentNotes na
+        case notes of
+          [n] -> do
+            nsId n `shouldBe` NoteId "Note/NOTE-FIXTURE"
+            nsTitle n `shouldBe` Just "Synthetic note"
+          _ -> expectationFailure $ "expected 1 note, got " <> show (length notes)
+    it "accumulates results across paginated responses" $
+      withPaginatedMock $ \na -> do
+        notes <- recentNotes na
+        length notes `shouldBe` 2
+
+  describe "getNote" $ do
+    it "returns Just Note with decoded body for a live record" $
+      withNotesMock lookupNoteJson "/records/lookup" $ \na -> do
+        result <- getNote na (NoteId "Note/NOTE-FIXTURE")
+        case result of
+          Nothing -> expectationFailure "expected Just Note"
+          Just n -> noteBodyBytes n `shouldBe` "synthetic note body"
+    it "returns Nothing for a tombstone record" $
+      withNotesMock tombstoneLookupJson "/records/lookup" $ \na ->
+        endsNothing $ getNote na (NoteId "Note/NOTE-DELETED-FIXTURE")
+
+  describe "notesInFolder" $ do
+    it "returns only notes in the given folder, excluding other folders and deleted notes" $
+      withNotesMock folderChangesJson "/changes/zone" $ \na -> do
+        notes <- notesInFolder na (FolderId "Folder/FOLDER-FIXTURE")
+        case notes of
+          [n] -> nsId n `shouldBe` NoteId "Note/NOTE-FIXTURE"
+          _ -> expectationFailure $ "expected 1 note, got " <> show (length notes)
+
+
+-- Mock servers
+
+withNotesMock
+  :: LBS.ByteString
+  -> BS.ByteString
+  -> (NotesApi -> IO a)
+  -> IO a
+withNotesMock json pathSuffix action =
+  withSystemTempDirectory "icloud-notes-mock" $ \tmpDir ->
+    testWithApplication (pure (simpleApp json pathSuffix)) $ \serverPort -> do
+      na <- mkTestNotesApi serverPort tmpDir
+      action na
+
+
+withPaginatedMock :: (NotesApi -> IO a) -> IO a
+withPaginatedMock action =
+  withSystemTempDirectory "icloud-notes-paginated" $ \tmpDir -> do
+    callRef <- newIORef (0 :: Int)
+    testWithApplication (pure (paginatedApp callRef)) $ \serverPort -> do
+      na <- mkTestNotesApi serverPort tmpDir
+      action na
+
+
+simpleApp :: LBS.ByteString -> BS.ByteString -> Application
+simpleApp json pathSuffix req respond
+  | pathSuffix `BS.isSuffixOf` rawPathInfo req =
+      respond $ responseLBS status200 jsonHeaders json
+  | otherwise =
+      respond $ responseLBS status404 [] "not found"
+
+
+paginatedApp :: IORef Int -> Application
+paginatedApp callRef _req respond = do
+  n <- readIORef callRef
+  writeIORef callRef (n + 1)
+  respond $ responseLBS status200 jsonHeaders (if n == 0 then page1Json else page2Json)
+
+
+mkTestNotesApi :: Int -> FilePath -> IO NotesApi
+mkTestNotesApi serverPort tmpDir = do
+  let baseUrl = Text.pack $ "http://127.0.0.1:" ++ show serverPort
+  mgr <- newManager defaultManagerSettings
+  api <- mkApiWith (testSession tmpDir) (testAuthEndpoints serverPort) mgr
+  mkNotesApi (testAccountData baseUrl) (testSession tmpDir) api
+
+
+-- Fixtures
+
+testAccountData :: Text.Text -> AccountData
+testAccountData baseUrl =
+  AccountData
+    { adHsaVersion = 2
+    , adHsaChallengeRequired = False
+    , adHsaTrustedBrowser = Just True
+    , adWebservices = Map.fromList [("ckdatabasews", Webservice baseUrl Nothing)]
+    , adRaw = object []
+    }
+
+
+testSession :: FilePath -> Session
+testSession topDir =
+  Session
+    { sessionCreds = Credentials{credAccountName = "test@example.com", credPassword = "test-pass"}
+    , sessionTopDir = topDir
+    , sessionClientId = "auth-test-client-id"
+    }
+
+
+testAuthEndpoints :: Int -> Endpoints
+testAuthEndpoints serverPort =
+  Endpoints
+    { epHome = "http://127.0.0.1:" <> BS8.pack (show serverPort)
+    , epAuth = dummyReq "/appleauth/auth"
+    , epSetup = dummyReq "/setup/ws/1"
+    , epWidgetKey = "test-widget-key"
+    }
+ where
+  dummyReq p =
+    defaultRequest
+      { host = "127.0.0.1"
+      , port = serverPort
+      , secure = False
+      , method = methodPost
+      , path = p
+      }
+
+
+jsonHeaders :: [(HeaderName, BS8.ByteString)]
+jsonHeaders = [(hContentType, "application/json")]
+
+
+queryFoldersJson :: LBS.ByteString
+queryFoldersJson =
+  "{\"records\":[{\"recordName\":\"Folder/FOLDER-FIXTURE\"\
+  \,\"recordType\":\"Folder\"\
+  \,\"fields\":{\"TitleEncrypted\":{\"type\":\"ENCRYPTED_BYTES\",\"value\":\"U3ludGhldGljIEZvbGRlcg==\"}}}]\
+  \,\"continuationMarker\":null}"
+
+
+queryNotesJson :: LBS.ByteString
+queryNotesJson =
+  "{\"records\":[{\"recordName\":\"Note/NOTE-FIXTURE\"\
+  \,\"recordType\":\"Note\"\
+  \,\"fields\":{\
+  \\"TitleEncrypted\":{\"type\":\"ENCRYPTED_BYTES\",\"value\":\"U3ludGhldGljIG5vdGU=\"}\
+  \,\"Deleted\":{\"type\":\"INT64\",\"value\":0}}}]\
+  \,\"continuationMarker\":null}"
+
+
+lookupNoteJson :: LBS.ByteString
+lookupNoteJson =
+  "{\"records\":[{\"recordName\":\"Note/NOTE-FIXTURE\"\
+  \,\"recordType\":\"Note\"\
+  \,\"fields\":{\
+  \\"TitleEncrypted\":{\"type\":\"ENCRYPTED_BYTES\",\"value\":\"U3ludGhldGljIG5vdGU=\"}\
+  \,\"TextDataEncrypted\":{\"type\":\"ENCRYPTED_BYTES\",\"value\":\"c3ludGhldGljIG5vdGUgYm9keQ==\"}\
+  \,\"Deleted\":{\"type\":\"INT64\",\"value\":0}}}]}"
+
+
+tombstoneLookupJson :: LBS.ByteString
+tombstoneLookupJson =
+  "{\"records\":[{\"recordName\":\"Note/NOTE-DELETED-FIXTURE\",\"deleted\":true}]}"
+
+
+folderChangesJson :: LBS.ByteString
+folderChangesJson =
+  "{\"zones\":[{\"zoneID\":{\"zoneName\":\"Notes\",\"zoneType\":\"REGULAR_CUSTOM_ZONE\"}\
+  \,\"syncToken\":\"sync-1\",\"moreComing\":false\
+  \,\"records\":[\
+  \{\"recordName\":\"Note/NOTE-FIXTURE\",\"recordType\":\"Note\"\
+  \,\"fields\":{\"TitleEncrypted\":{\"type\":\"ENCRYPTED_BYTES\",\"value\":\"U3ludGhldGljIG5vdGU=\"}\
+  \,\"Deleted\":{\"type\":\"INT64\",\"value\":0}\
+  \,\"Folder\":{\"type\":\"REFERENCE\",\"value\":{\"recordName\":\"Folder/FOLDER-FIXTURE\",\"action\":\"VALIDATE\"}}}}\
+  \,{\"recordName\":\"Note/NOTE-OTHER\",\"recordType\":\"Note\"\
+  \,\"fields\":{\"TitleEncrypted\":{\"type\":\"ENCRYPTED_BYTES\",\"value\":\"T3RoZXIgTm90ZQ==\"}\
+  \,\"Deleted\":{\"type\":\"INT64\",\"value\":0}\
+  \,\"Folder\":{\"type\":\"REFERENCE\",\"value\":{\"recordName\":\"Folder/OTHER-FOLDER\",\"action\":\"VALIDATE\"}}}}\
+  \,{\"recordName\":\"Note/NOTE-DELETED\",\"recordType\":\"Note\"\
+  \,\"fields\":{\"TitleEncrypted\":{\"type\":\"ENCRYPTED_BYTES\",\"value\":\"RGVsZXRlZCBOb3Rl\"}\
+  \,\"Deleted\":{\"type\":\"INT64\",\"value\":1}\
+  \,\"Folder\":{\"type\":\"REFERENCE\",\"value\":{\"recordName\":\"Folder/FOLDER-FIXTURE\",\"action\":\"VALIDATE\"}}}}]}]}"
+
+
+page1Json :: LBS.ByteString
+page1Json =
+  "{\"records\":[{\"recordName\":\"Note/NOTE-1\"\
+  \,\"recordType\":\"Note\"\
+  \,\"fields\":{\
+  \\"TitleEncrypted\":{\"type\":\"ENCRYPTED_BYTES\",\"value\":\"Tm90ZSAx\"}\
+  \,\"Deleted\":{\"type\":\"INT64\",\"value\":0}}}]\
+  \,\"continuationMarker\":\"page-marker-1\"}"
+
+
+page2Json :: LBS.ByteString
+page2Json =
+  "{\"records\":[{\"recordName\":\"Note/NOTE-2\"\
+  \,\"recordType\":\"Note\"\
+  \,\"fields\":{\
+  \\"TitleEncrypted\":{\"type\":\"ENCRYPTED_BYTES\",\"value\":\"Tm90ZSAy\"}\
+  \,\"Deleted\":{\"type\":\"INT64\",\"value\":0}}}]\
+  \,\"continuationMarker\":null}"
diff --git a/test/Spec.hs b/test/Spec.hs
new file mode 100644
--- /dev/null
+++ b/test/Spec.hs
@@ -0,0 +1,38 @@
+{- |
+Module      : Main
+Copyright   : (c) 2026 Tim Emiola
+Maintainer  : Tim Emiola <adetokunbo@emio.la>
+SPDX-License-Identifier: BSD-3-Clause
+
+Test suite entry point for the hstratus-notes package.
+-}
+module Main where
+
+import qualified HStratus.Notes.CloudKitSpec as CloudKit
+import qualified HStratus.Notes.DecodeSpec as Decode
+import qualified HStratus.Notes.EndpointsSpec as Endpoints
+import qualified HStratus.Notes.MarkdownSpec as Markdown
+import qualified HStratus.Notes.NoteDataSpec as NoteData
+import qualified HStratus.Notes.ProtoSpec as Proto
+import qualified HStratus.NotesSpec as Notes
+import System.IO
+  ( BufferMode (..)
+  , hSetBuffering
+  , stderr
+  , stdout
+  )
+import Test.Hspec
+
+
+main :: IO ()
+main = do
+  hSetBuffering stdout NoBuffering
+  hSetBuffering stderr NoBuffering
+  hspec $ do
+    CloudKit.spec
+    Decode.spec
+    Endpoints.spec
+    Markdown.spec
+    NoteData.spec
+    Proto.spec
+    Notes.spec
