diff --git a/CHANGELOG.md b/CHANGELOG.md
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,5 +1,16 @@
 # Changelog for `notion-client-effectful`
 
+## 0.4.0.0 - 2026-10-19
+
+### Breaking Changes
+
+* Requires `notion-client >=0.10 && <0.11`. Upgrade this companion to 0.4.0.0 alongside core 0.10.0.0. `runNotion` accepts refreshing `Methods` unchanged; no effect constructors changed.
+* `OAuthError`, `ReauthorizationRequired`, `TokenPersistenceError` and `RefreshOutcomeUnknown` propagate in IO; `Error NotionError` catches only final Notion API errors. Retry persistence on the same manager; reauthorization and uncertain refresh outcomes require a new login and manager.
+
+### Validation
+
+* Four OAuth runtime consumer tests verify reactive refresh and the IO exception boundary. The core client's live public-integration gate also passed login, rotation, persisted restart, cancellation and logout on 2026-10-19.
+
 ## 0.3.0.0 - 2026-10-12
 
 ### Breaking Changes
diff --git a/README.md b/README.md
--- a/README.md
+++ b/README.md
@@ -36,6 +36,18 @@
 
 Then `NE.retrievePage`, `NE.search`, `NE.runNotion`, and so on.
 
+The umbrella module exposes the common operations. For the complete surface,
+including filtered page requests, background operations, meeting notes and
+comment retrieval/update/deletion, import the effect and interpreter directly:
+
+```haskell
+import Notion.V1.Effectful.Effect qualified as NE
+import Notion.V1.Effectful.Interpreter (runNotion)
+```
+
+Use `NE.createPageFiltered`, `NE.createPageAsync`, `NE.retrieveAsyncTask`, and
+the other effect operations with the unqualified `runNotion` interpreter.
+
 ## Error handling
 
 `runNotion` catches `Notion.V1.Error.NotionError` thrown by the
@@ -50,12 +62,29 @@
 library and lets callers layer their own `Error ClientError`
 interpretation on top later. The same applies to
 `UnknownHTTPResponseError`, `RequestTimeoutError` and
-`InvalidPathParameterError` from `Notion.V1.Error`. Retries of
-rate-limited requests happen inside `Methods`, before an error reaches
-`runNotion`.
+`InvalidPathParameterError` from `Notion.V1.Error`. Retries of rate-limited
+requests and reactive OAuth renewal happen inside `Methods`, before an error
+reaches `runNotion`.
 
+### OAuth installations
+
+Build `Methods` with core `makeMethodsWithTokenSource` and pass it to `runNotion`;
+existing effect operations need no changes. See the core
+[OAuth walkthrough](https://github.com/shinzui/notion-client#oauth) for browser
+login and token-store setup.
+
+`OAuthError`, `ReauthorizationRequired`, `TokenPersistenceError` and
+`RefreshOutcomeUnknown` propagate as IO exceptions. Handle them at the application
+boundary outside `runEff`; `Error NotionError` catches only final Notion API errors.
+For a persistence failure, call `retryTokenPersistence` on the same manager to
+save its retained pair without another refresh. Reauthorization or an uncertain
+remote outcome requires a new login and manager. Use one manager per installation;
+multiple processes must coordinate the entire load/refresh/save sequence.
+
 ## Minimum viable usage
 
+Use `GHC2024` with `OverloadedStrings` for this example.
+
 ```haskell
 module Demo where
 
@@ -110,6 +139,17 @@
 
 ## Versioning
 
-The `0.y.z` series tracks `notion-client` 0.7.x. A major version
-bump is planned if `notion-client` changes the shape of `Methods`
-in a way that forces a constructor rename.
+Use compatible versions of both packages:
+
+| `notion-client-effectful` | Required `notion-client` |
+|---|---|
+| 0.4.x | `>=0.10 && <0.11` |
+| 0.3.x | `>=0.9 && <0.10` |
+| 0.2.x | `>=0.8 && <0.9` |
+
+The prepared release is `notion-client-effectful-0.4.0.0` with
+`notion-client-0.10.0.0`. The companion's core bound moves with each breaking core
+release, even when `Methods` fields are unchanged. See the
+[changelog](CHANGELOG.md) for migration details and the repository's
+[release preparation](https://github.com/shinzui/notion-client#release-preparation)
+for validation.
diff --git a/notion-client-effectful.cabal b/notion-client-effectful.cabal
--- a/notion-client-effectful.cabal
+++ b/notion-client-effectful.cabal
@@ -1,15 +1,17 @@
 cabal-version: 3.4
 name: notion-client-effectful
-version: 0.3.0.0
+version: 0.4.0.0
 synopsis: Effectful effects for notion-client
 description:
-  Effectful effect + default interpreter for shinzui/notion-client.
+  Effectful effect and default interpreter for notion-client.
   Exposes every operation on 'Notion.V1.Methods' as a smart
   constructor of the 'Notion.V1.Effectful.Notion' effect and provides
   a 'runNotion' interpreter that dispatches through a concrete
   @Methods@ value. API-level 'Notion.V1.Error.NotionError' responses
   are surfaced via the @Error NotionError@ effect; other
-  @Servant.Client.ClientError@ values remain 'IO' exceptions.
+  @Servant.Client.ClientError@ values and OAuth lifecycle or persistence
+  failures remain 'IO' exceptions. Accepts fixed-token or refreshing
+  @Methods@ without changing effect operations.
   See @README.md@ for the full import pattern and a worked example.
 
 license: MIT
@@ -17,11 +19,19 @@
 author: Nadeem Bitar
 maintainer: nadeem@gmail.com
 category: Web
+homepage: https://github.com/shinzui/notion-client/tree/master/notion-client-effectful
+bug-reports: https://github.com/shinzui/notion-client/issues
+tested-with: ghc ==9.12.4
 build-type: Simple
 extra-doc-files:
   CHANGELOG.md
   README.md
 
+source-repository head
+  type: git
+  location: https://github.com/shinzui/notion-client.git
+  subdir: notion-client-effectful
+
 common warnings
   ghc-options:
     -Wall
@@ -55,8 +65,44 @@
     aeson >=2.2 && <2.3,
     base >=4.18 && <5,
     effectful-core >=2.5 && <3,
-    notion-client >=0.9 && <0.10,
+    notion-client >=0.10 && <0.11,
     text >=2.0 && <2.2,
 
   hs-source-dirs: src
   default-language: GHC2024
+
+test-suite oauth-runtime
+  type: exitcode-stdio-1.0
+  default-language: GHC2024
+  default-extensions:
+    DuplicateRecordFields
+    OverloadedStrings
+    RecordWildCards
+
+  hs-source-dirs:
+    test
+
+  main-is: OAuthRuntimeMain.hs
+  other-modules:
+    OAuthFixtures
+
+  build-depends:
+    aeson,
+    base,
+    bytestring,
+    containers,
+    effectful-core,
+    http-client,
+    http-types,
+    mtl,
+    notion-client,
+    notion-client-effectful,
+    servant-client,
+    servant-client-core,
+    tasty,
+    tasty-hunit,
+    time,
+
+  ghc-options:
+    -Wall
+    -threaded
diff --git a/src/Notion/V1/Effectful.hs b/src/Notion/V1/Effectful.hs
--- a/src/Notion/V1/Effectful.hs
+++ b/src/Notion/V1/Effectful.hs
@@ -14,10 +14,13 @@
 -- here shares its name with a field of @Methods@ on purpose, so
 -- migrating an IO call site is a near-mechanical rewrite.
 --
--- Error handling: 'runNotion' catches 'NotionError' from the
--- underlying 'Methods' value and re-throws via the @Error NotionError@
+-- Error handling: 'runNotion' catches @NotionError@ from the
+-- underlying @Methods@ value and re-throws via the @Error NotionError@
 -- effect. Other 'Servant.Client.ClientError' values (network
 -- failures, decoding errors) remain 'IO' exceptions.
+-- Refreshing @Methods@ work with the same effect operations. OAuth errors,
+-- required reauthorization, persistence failures and uncertain refresh outcomes
+-- also remain 'IO' exceptions; handle them at the application boundary.
 module Notion.V1.Effectful
   ( -- * Effect
     Notion,
diff --git a/src/Notion/V1/Effectful/Interpreter.hs b/src/Notion/V1/Effectful/Interpreter.hs
--- a/src/Notion/V1/Effectful/Interpreter.hs
+++ b/src/Notion/V1/Effectful/Interpreter.hs
@@ -87,7 +87,9 @@
 --
 -- The dispatched 'IO' action is wrapped with 'Control.Exception.try':
 -- a 'NotionError' is re-thrown via 'throwError', anything else
--- propagates as an 'IO' exception.
+-- propagates as an 'IO' exception. OAuth reauthorization, pending-persistence
+-- and uncertain-refresh exceptions also remain IO exceptions. A refreshing
+-- Methods value works without changing effect constructors.
 runNotion ::
   (IOE :> es, Error NotionError :> es) =>
   Methods ->
diff --git a/test/OAuthFixtures.hs b/test/OAuthFixtures.hs
new file mode 100644
--- /dev/null
+++ b/test/OAuthFixtures.hs
@@ -0,0 +1,88 @@
+-- | A scripted stand-in for the Notion API that never touches the network.
+--
+-- 'fakeClientEnv' builds a 'ClientEnv' whose middleware ignores the real HTTP
+-- application, records every request, and answers from a list of replies.
+module OAuthFixtures
+  ( tokenResponse,
+    unauthorized,
+    userJson,
+    Recorded (..),
+    FakeReply (..),
+    fakeClientEnv,
+    fakeClientEnvWith,
+    fakeBaseUrl,
+    jsonReply,
+    lookupRecordedHeader,
+  )
+where
+
+import Control.Monad.Error.Class (throwError)
+import Control.Monad.IO.Class (liftIO)
+import Data.Bifunctor (bimap)
+import Data.ByteString qualified as BS
+import Data.ByteString.Builder (toLazyByteString)
+import Data.ByteString.Lazy qualified as LBS
+import Data.Foldable (toList)
+import Data.IORef (IORef, atomicModifyIORef', newIORef)
+import Network.HTTP.Client (defaultManagerSettings, newManager)
+import Network.HTTP.Types (Header, HeaderName, Method, http11, mkStatus)
+import Servant.Client (BaseUrl (..), ClientEnv (..), ClientError (..), Scheme (..), mkClientEnv)
+import Servant.Client.Core (RequestF (..), ResponseF (..))
+
+-- | A request the fake received.
+data Recorded = Recorded
+  { method :: Method,
+    path :: LBS.ByteString,
+    headers :: [Header]
+  }
+  deriving stock (Show)
+
+-- | A scripted reply.
+data FakeReply = FakeReply
+  { status :: Int,
+    replyHeaders :: [Header],
+    body :: LBS.ByteString
+  }
+
+-- | A reply with a JSON content type.
+jsonReply :: Int -> LBS.ByteString -> FakeReply
+jsonReply s = FakeReply s [("Content-Type", "application/json")]
+
+fakeBaseUrl :: BaseUrl
+fakeBaseUrl = BaseUrl Https "api.notion.com" 443 "/v1"
+
+-- | First value of a header in a recorded request.
+lookupRecordedHeader :: HeaderName -> Recorded -> Maybe BS.ByteString
+lookupRecordedHeader name Recorded {headers} = lookup name headers
+
+-- | A 'ClientEnv' answering from the script, and the requests it records.
+fakeClientEnv :: [FakeReply] -> IO (ClientEnv, IORef [Recorded])
+fakeClientEnv script = do
+  remaining <- newIORef script
+  fakeClientEnvWith $ \_ -> do
+    next <- atomicModifyIORef' remaining (\case [] -> ([], Nothing); r : rs -> (rs, Just r))
+    maybe (ioError (userError "FakeNotion: script exhausted")) pure next
+
+-- | Header-dependent replies, with an atomic request recorder.
+fakeClientEnvWith :: (Recorded -> IO FakeReply) -> IO (ClientEnv, IORef [Recorded])
+fakeClientEnvWith reply = do
+  manager <- newManager defaultManagerSettings
+  recorded <- newIORef []
+  let mw _realApp req = do
+        let request = Recorded (requestMethod req) (toLazyByteString (requestPath req)) (toList (requestHeaders req))
+        liftIO $ atomicModifyIORef' recorded (\rs -> (rs <> [request], ()))
+        FakeReply {status, replyHeaders, body} <- liftIO (reply request)
+        let resp = Response (mkStatus status "") (foldMap pure replyHeaders) http11 body
+        if status >= 200 && status < 300
+          then pure resp
+          else throwError (FailureResponse (bimap (const ()) (\p -> (fakeBaseUrl, LBS.toStrict (toLazyByteString p))) req) resp)
+  pure ((mkClientEnv manager fakeBaseUrl) {middleware = mw}, recorded)
+
+tokenResponse :: LBS.ByteString
+tokenResponse = "{\"access_token\":\"access\",\"token_type\":\"bearer\",\"refresh_token\":\"refresh\",\"bot_id\":\"bot-id\",\"workspace_id\":\"workspace-id\",\"workspace_name\":\"Tanaka Hanako\",\"owner\":{\"type\":\"workspace\"}}"
+
+unauthorized :: FakeReply
+unauthorized = jsonReply 401 "{\"object\":\"error\",\"code\":\"unauthorized\",\"message\":\"invalid token\"}"
+
+userJson :: LBS.ByteString
+userJson = "{\"object\":\"user\",\"id\":\"6f1c2b9e-4d3a-4e8f-9b7c-2a1d0e5f3c4b\",\"name\":\"Sato Kenji\",\"type\":\"bot\",\"bot\":{\"owner\":{\"type\":\"workspace\",\"workspace\":true}}}"
diff --git a/test/OAuthRuntimeMain.hs b/test/OAuthRuntimeMain.hs
new file mode 100644
--- /dev/null
+++ b/test/OAuthRuntimeMain.hs
@@ -0,0 +1,69 @@
+{-# LANGUAGE GHC2024 #-}
+{-# LANGUAGE AllowAmbiguousTypes #-}
+
+module Main (main) where
+
+import Control.Exception
+import Control.Monad (void)
+import Data.Aeson qualified as A
+import Data.Time.Clock
+import Effectful (runEff)
+import Effectful.Error.Static (runErrorNoCallStack)
+import Notion.V1
+import Notion.V1.Effectful qualified as E
+import Notion.V1.Error
+import Notion.V1.OAuth qualified as O
+import Notion.V1.OAuth.Refresh qualified as R
+import OAuthFixtures
+import Test.Tasty
+import Test.Tasty.HUnit
+
+main :: IO ()
+main =
+  defaultMain $
+    testGroup
+      "Effectful OAuth"
+      [ testCase "refreshing Methods succeeds and final API error uses Error effect" $ do
+          (old, response) <- fixture
+          store <- R.inMemoryTokenStore old
+          manager <- R.newOAuthTokenManager (oauth (pure response)) store
+          (env, _) <- fakeClientEnv [unauthorized, jsonReply 200 userJson, unauthorized, unauthorized]
+          let api = makeMethodsWithTokenSource defaultClientConfig env (R.tokenManagerSource manager)
+              call = runEff . runErrorNoCallStack @NotionError . E.runNotion api $ E.retrieveMyUser
+          call >>= either (assertFailure . show) (const (pure ()))
+          call >>= either (\e -> code e @?= Unauthorized) (const (assertFailure "expected final API error")),
+        testCase "reauthorization stays an IO exception" $ do
+          (old, response) <- fixture
+          store <- R.inMemoryTokenStore old {R.refreshToken = Nothing}
+          manager <- R.newOAuthTokenManager (oauth (pure response)) store
+          expect @R.ReauthorizationRequired (run manager),
+        testCase "pending persistence stays an IO exception" $ do
+          (old, response) <- fixture
+          manager <- R.newOAuthTokenManager (oauth (pure response)) (R.TokenStore (pure old) (\_ -> ioError (userError "storage")))
+          expect @R.TokenPersistenceError (R.refreshTokensNow manager)
+          expect @R.TokenPersistenceError (run manager),
+        testCase "uncertain refresh stays an IO exception" $ do
+          (old, _) <- fixture
+          store <- R.inMemoryTokenStore old
+          manager <- R.newOAuthTokenManager (oauth (ioError (userError "lost response"))) store
+          expect @IOException (R.refreshTokensNow manager)
+          expect @R.RefreshOutcomeUnknown (run manager)
+      ]
+
+expect :: forall e a. (Exception e) => IO a -> IO ()
+expect action = try @e action >>= either (const (pure ())) (const (assertFailure "expected IO exception"))
+
+run :: R.OAuthTokenManager -> IO ()
+run manager = do
+  (env, _) <- fakeClientEnv [unauthorized]
+  let api = makeMethodsWithTokenSource defaultClientConfig env (R.tokenManagerSource manager)
+  void $ runEff . runErrorNoCallStack @NotionError . E.runNotion api $ E.retrieveMyUser
+
+oauth :: IO O.OAuthTokenResponse -> O.OAuthMethods
+oauth action = O.OAuthMethods (const action) (const (pure (O.OAuthRevokeResponse Nothing))) (const (pure (O.OAuthIntrospectResponse False Nothing Nothing Nothing)))
+
+fixture :: IO (R.OAuthTokens, O.OAuthTokenResponse)
+fixture = do
+  response <- either assertFailure pure (A.eitherDecode tokenResponse)
+  now <- getCurrentTime
+  pure ((R.oauthTokensFromResponse now response) {R.accessToken = "old", R.refreshToken = Just "old-refresh"}, response)
