diff --git a/CHANGELOG.md b/CHANGELOG.md
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -6,6 +6,19 @@
 
 ## [Unreleased]
 
+## 0.21.0.0 — 2026-10-14
+
+### Bug Fixes
+
+- `verify-schema` and `Keiro.Migrations.SchemaCheck.verifyExpectedSchema` no longer
+  report a view that Keiro's runtime created and recorded in
+  `keiro.keiro_managed_read_objects`, such as the
+  `keiro.external_read_<function>_binding` view behind an all-rows external-read
+  contract, as unexpected drift. Only rows with `managed_by = 'keiro'` count; a role
+  without table `SELECT` or schema `USAGE` keeps the previous behaviour. New exports
+  `managedObjectSnapshotKeys` and `compareSchemaSnapshotWithManaged`;
+  `compareSchemaSnapshot` is unchanged.
+
 ## 0.20.0.0 — 2026-10-12
 
 ### Breaking Changes
diff --git a/keiro-migrations.cabal b/keiro-migrations.cabal
--- a/keiro-migrations.cabal
+++ b/keiro-migrations.cabal
@@ -1,6 +1,6 @@
 cabal-version: 3.0
 name: keiro-migrations
-version: 0.20.0.0
+version: 0.21.0.0
 synopsis: Schema migrations for keiro
 description:
   Embedded PostgreSQL schema migrations and a migration runner for the Keiro
diff --git a/src/Keiro/Migrations/SchemaCheck.hs b/src/Keiro/Migrations/SchemaCheck.hs
--- a/src/Keiro/Migrations/SchemaCheck.hs
+++ b/src/Keiro/Migrations/SchemaCheck.hs
@@ -3,6 +3,8 @@
 module Keiro.Migrations.SchemaCheck
   ( SchemaDrift (..),
     compareSchemaSnapshot,
+    compareSchemaSnapshotWithManaged,
+    managedObjectSnapshotKeys,
     expectedSchemaSnapshot,
     renderSchemaDrift,
     snapshotSchema,
@@ -15,6 +17,7 @@
 import Data.Map.Strict (Map)
 import Data.Map.Strict qualified as Map
 import Data.Maybe (mapMaybe)
+import Data.Set (Set)
 import Data.Set qualified as Set
 import Data.Text (Text)
 import Data.Text qualified as Text
@@ -40,9 +43,12 @@
       }
   deriving stock (Eq, Show)
 
--- | Compare canonical snapshots by their @kind<TAB>name@ object identity.
-compareSchemaSnapshot :: Text -> Text -> [SchemaDrift]
-compareSchemaSnapshot expected actual =
+-- | Compare canonical snapshots by their @kind<TAB>name@ object identity. A live
+-- object whose key is in the managed set is Keiro's own runtime object and is
+-- never reported as unexpected; it is still compared when the expected snapshot
+-- also lists it.
+compareSchemaSnapshotWithManaged :: Set Text -> Text -> Text -> [SchemaDrift]
+compareSchemaSnapshotWithManaged managed expected actual =
   mapMaybe driftFor allKeys
   where
     expectedObjects = snapshotObjects expected
@@ -55,8 +61,9 @@
       case (Map.lookup key expectedObjects, Map.lookup key actualObjects) of
         (Just (expectedLine, _), Nothing) ->
           Just (MissingObject expectedLine)
-        (Nothing, Just (actualLine, _)) ->
-          Just (UnexpectedObject actualLine)
+        (Nothing, Just (actualLine, _))
+          | Set.member key managed -> Nothing
+          | otherwise -> Just (UnexpectedObject actualLine)
         (Just (_, expectedValue), Just (_, actualValue))
           | expectedValue /= actualValue ->
               Just
@@ -67,6 +74,10 @@
                   }
         _ -> Nothing
 
+-- | Compare snapshots without allowing any runtime-managed additions.
+compareSchemaSnapshot :: Text -> Text -> [SchemaDrift]
+compareSchemaSnapshot = compareSchemaSnapshotWithManaged Set.empty
+
 -- | Render a drift as one operator-facing line with the affected object name.
 renderSchemaDrift :: SchemaDrift -> Text
 renderSchemaDrift drift =
@@ -113,7 +124,11 @@
   $(embedTextFile "expected-schema/native/keiro-v18.txt")
 
 -- | Compare the live @keiro@ and @keiro_read@ schemas with the embedded
--- PostgreSQL 18 snapshot.
+-- PostgreSQL 18 snapshot. Runtime binding views recorded as Keiro-managed are
+-- allowed additions when the role has registry table SELECT and schema USAGE;
+-- otherwise verification keeps its catalog-only comparison. Missing or changed
+-- migration objects remain drift. Registry identity does not validate a
+-- runtime-only view's definition or definition_hash.
 verifyExpectedSchema ::
   Settings.Settings ->
   IO (Either MigrationError [SchemaDrift])
@@ -129,10 +144,10 @@
       pure $ case result of
         Left sessionError -> Left (DatabaseSessionFailed sessionError)
         Right (Left migrationError) -> Left migrationError
-        Right (Right actual) ->
-          Right (compareSchemaSnapshot expectedSchemaSnapshot actual)
+        Right (Right (managed, actual)) ->
+          Right (compareSchemaSnapshotWithManaged managed expectedSchemaSnapshot actual)
   where
-    liveSnapshotSession :: Session (Either MigrationError Text)
+    liveSnapshotSession :: Session (Either MigrationError (Set Text, Text))
     liveSnapshotSession = do
       serverVersionNumber <- Session.statement () serverVersionStatement
       let majorVersion = fromIntegral serverVersionNumber `div` 10000
@@ -140,8 +155,64 @@
         then do
           privateSchema <- snapshotSchema "keiro"
           publicSchema <- snapshotSchema "keiro_read"
-          pure (Right (privateSchema <> publicSchema))
+          managed <- managedObjectSnapshotKeys
+          pure (Right (managed, privateSchema <> publicSchema))
         else pure (Left (UnsupportedPostgresVersion majorVersion))
+
+-- | Snapshot keys of the objects Keiro's runtime created and recorded in
+-- @keiro.keiro_managed_read_objects@ with @managed_by = 'keiro'@. Every state
+-- counts: retirement marks a managed object retired without dropping it. Rows
+-- whose kind has no snapshot line (functions and types) map to nothing. When
+-- the registry table does not exist, or the connected role lacks table @SELECT@
+-- or schema @USAGE@, the set is empty and verification reports every live object
+-- as before.
+managedObjectSnapshotKeys :: Session (Set Text)
+managedObjectSnapshotKeys = do
+  readable <- Session.statement () registryReadableStatement
+  if readable
+    then Set.fromList . mapMaybe snapshotKey <$> Session.statement () managedObjectsStatement
+    else pure Set.empty
+  where
+    snapshotKey (schema, name, kind)
+      | kind == "binding-view" = Just ("view\t" <> schema <> "." <> name)
+      | otherwise = Nothing
+
+-- | True only when @keiro.keiro_managed_read_objects@ is a table the connected
+-- role may read. Resolving the relation through @pg_class@ avoids the error
+-- that the text form of @has_table_privilege@ raises for a missing relation.
+registryReadableStatement :: Statement () Bool
+registryReadableStatement =
+  Statement.preparable
+    """
+    SELECT coalesce(
+      ( SELECT pg_catalog.has_table_privilege(c.oid, 'SELECT')
+                 AND pg_catalog.has_schema_privilege(n.oid, 'USAGE')
+        FROM pg_catalog.pg_class c
+        JOIN pg_catalog.pg_namespace n ON n.oid = c.relnamespace
+        WHERE n.nspname = 'keiro'
+          AND c.relname = 'keiro_managed_read_objects'
+          AND c.relkind = 'r' ),
+      false)
+    """
+    Encoders.noParams
+    (Decoders.singleRow (Decoders.column (Decoders.nonNullable Decoders.bool)))
+
+managedObjectsStatement :: Statement () [(Text, Text, Text)]
+managedObjectsStatement =
+  Statement.preparable
+    """
+    SELECT object_schema, object_name, object_kind
+    FROM keiro.keiro_managed_read_objects
+    WHERE managed_by = 'keiro'
+    """
+    Encoders.noParams
+    ( Decoders.rowList
+        ( (,,)
+            <$> Decoders.column (Decoders.nonNullable Decoders.text)
+            <*> Decoders.column (Decoders.nonNullable Decoders.text)
+            <*> Decoders.column (Decoders.nonNullable Decoders.text)
+        )
+    )
 
 snapshotObjects :: Text -> Map Text (Text, Text)
 snapshotObjects =
diff --git a/test/Main.hs b/test/Main.hs
--- a/test/Main.hs
+++ b/test/Main.hs
@@ -288,6 +288,134 @@
           `shouldSatisfy` any
             (Text.isInfixOf "keiro_read.projection_group_status_v1")
 
+    it "treats a managed live object as expected and still reports its unregistered neighbours" $ do
+      let expected = Text.unlines ["table\twidgets\tkind=r"]
+          actual =
+            Text.unlines
+              [ "table\twidgets\tkind=r",
+                "view\tkeiro.external_read_demo_v1_binding\tcolumns=1:id bigint",
+                "view\tkeiro.external_read_rogue_v1_binding\tcolumns=1:id bigint"
+              ]
+          managed = Set.singleton "view\tkeiro.external_read_demo_v1_binding"
+          migrationView = "view\tkeiro_read.status_v1\tcolumns=1:id bigint"
+          changedView = "view\tkeiro_read.status_v1\tcolumns=1:id text"
+          migrationManaged = Set.singleton "view\tkeiro_read.status_v1"
+      compareSchemaSnapshotWithManaged managed expected actual
+        `shouldBe` [UnexpectedObject "view\tkeiro.external_read_rogue_v1_binding\tcolumns=1:id bigint"]
+      compareSchemaSnapshot expected actual `shouldSatisfy` ((== 2) . length)
+      compareSchemaSnapshotWithManaged migrationManaged migrationView changedView
+        `shouldBe` [ChangedObject "view\tkeiro_read.status_v1" "columns=1:id bigint" "columns=1:id text"]
+      compareSchemaSnapshotWithManaged migrationManaged migrationView ""
+        `shouldBe` [MissingObject migrationView]
+      forM_ [(expected, actual), (migrationView, changedView), (migrationView, "")] $ \(expectedSnapshot, actualSnapshot) ->
+        compareSchemaSnapshotWithManaged Set.empty expectedSnapshot actualSnapshot
+          `shouldBe` compareSchemaSnapshot expectedSnapshot actualSnapshot
+
+    it "ignores registered binding views and still reports unregistered and consumer-managed views" $ do
+      plan <- requirePlan
+      withKeiroPg $ \database -> do
+        let settings = Pg.connectionSettings database
+            neighbours =
+              [ "schema drift: unexpected view keiro.external_read_foreign_v1_binding (actual: columns=1:id bigint)",
+                "schema drift: unexpected view keiro.external_read_rogue_v1_binding (actual: columns=1:id bigint)"
+              ]
+            check = do
+              drifts <- verifyExpectedSchema settings >>= requireRight
+              map renderSchemaDrift drifts `shouldBe` neighbours
+        _ <- runMigrationPlan defaultRunOptions settings plan >>= requireRight
+        verifyExpectedSchema settings >>= requireRight >>= (`shouldBe` [])
+        withConnection settings $ \connection -> do
+          useSession connection (Session.script managedBindingFixtureSql)
+          useSession connection $
+            Session.script
+              """
+              CREATE VIEW keiro.external_read_rogue_v1_binding AS SELECT 1::bigint AS id;
+              CREATE VIEW keiro.external_read_foreign_v1_binding AS SELECT 1::bigint AS id;
+              INSERT INTO keiro.keiro_managed_read_objects
+                (object_schema, object_name, object_kind, object_signature,
+                 managed_by, definition_hash, surface_generation, state)
+              VALUES ('keiro', 'external_read_foreign_v1_binding', 'binding-view', '',
+                      'consumer', 'test-definition', 1, 'active');
+              """
+          check
+          useSession connection $
+            Session.script
+              "UPDATE keiro.keiro_managed_read_objects SET state = 'pending-retirement', updated_at = now() WHERE object_name = 'external_read_demo_v1_binding'"
+          check
+          useSession connection $
+            Session.script
+              "UPDATE keiro.keiro_managed_read_objects SET state = 'retired', retired_at = now(), updated_at = now() WHERE object_name = 'external_read_demo_v1_binding'"
+          check
+
+    it "returns no managed keys for a role that cannot read the registry" $ do
+      plan <- requirePlan
+      withKeiroPg $ \database -> do
+        let settings = Pg.connectionSettings database
+            demoKeys = Set.singleton "view\tkeiro.external_read_demo_v1_binding"
+            demoDrift = [UnexpectedObject "view\tkeiro.external_read_demo_v1_binding\tcolumns=1:id bigint,2:total bigint"]
+            checkRole expectedKeys expectedDrifts =
+              withConnection settings $ \connection -> do
+                (keys, drifts) <-
+                  useSession
+                    connection
+                    ( do
+                        Session.script "SET ROLE keiro_verify_probe"
+                        managed <- managedObjectSnapshotKeys
+                        actual <- (<>) <$> snapshotSchema "keiro" <*> snapshotSchema "keiro_read"
+                        pure (managed, compareSchemaSnapshotWithManaged managed expectedSchemaSnapshot actual)
+                    )
+                    `finally` useSession connection (Session.script "RESET ROLE")
+                keys `shouldBe` expectedKeys
+                drifts `shouldBe` expectedDrifts
+        _ <- runMigrationPlan defaultRunOptions settings plan >>= requireRight
+        withConnection settings $ \connection -> do
+          useSession connection (Session.script managedBindingFixtureSql)
+          useSession connection $
+            Session.script
+              """
+              DO $probe$
+              BEGIN
+                IF NOT EXISTS (SELECT 1 FROM pg_catalog.pg_roles WHERE rolname = 'keiro_verify_probe') THEN
+                  CREATE ROLE keiro_verify_probe NOLOGIN;
+                END IF;
+              END
+              $probe$;
+              GRANT keiro_verify_probe TO CURRENT_USER;
+              GRANT USAGE ON SCHEMA keiro TO keiro_verify_probe;
+              """
+          useSession connection managedObjectSnapshotKeys `shouldReturn` demoKeys
+          checkRole Set.empty demoDrift
+          useSession connection $
+            Session.script
+              """
+              REVOKE USAGE ON SCHEMA keiro FROM keiro_verify_probe;
+              GRANT SELECT ON keiro.keiro_managed_read_objects TO keiro_verify_probe;
+              """
+          checkRole Set.empty demoDrift
+          useSession connection (Session.script "GRANT USAGE ON SCHEMA keiro TO keiro_verify_probe")
+          checkRole demoKeys []
+
+    it "falls back to ordinary drift when the registry is absent" $ do
+      plan <- requirePlan
+      withKeiroPg $ \database -> do
+        let settings = Pg.connectionSettings database
+            missingRegistry = MissingObject "table\tkeiro_managed_read_objects\tkind=r"
+            unexpectedBinding = UnexpectedObject "view\tkeiro.external_read_demo_v1_binding\tcolumns=1:id bigint,2:total bigint"
+        withConnection settings $ \connection ->
+          useSession connection managedObjectSnapshotKeys `shouldReturn` Set.empty
+        emptyDrifts <- verifyExpectedSchema settings >>= requireRight
+        emptyDrifts `shouldSatisfy` elem missingRegistry
+        _ <- runMigrationPlan defaultRunOptions settings plan >>= requireRight
+        actual <- withConnection settings $ \connection -> do
+          useSession connection (Session.script managedBindingFixtureSql)
+          useSession connection (Session.script "DROP TABLE keiro.keiro_managed_read_objects")
+          useSession connection managedObjectSnapshotKeys `shouldReturn` Set.empty
+          useSession connection ((<>) <$> snapshotSchema "keiro" <*> snapshotSchema "keiro_read")
+        drifts <- verifyExpectedSchema settings >>= requireRight
+        drifts `shouldBe` compareSchemaSnapshot expectedSchemaSnapshot actual
+        drifts `shouldSatisfy` elem missingRegistry
+        drifts `shouldSatisfy` elem unexpectedBinding
+
   describe "fresh native databases" $ do
     it "applies Kiroku then Keiro, verifies strictly, and is repeatable" $ do
       plan <- requirePlan
@@ -1531,3 +1659,16 @@
     )
   where
     column = Decoders.column . Decoders.nonNullable
+
+managedBindingFixtureSql :: Text
+managedBindingFixtureSql =
+  """
+  CREATE VIEW keiro.external_read_demo_v1_binding
+    WITH (security_barrier = true, security_invoker = false)
+    AS SELECT 1::bigint AS id, 10::bigint AS total;
+  INSERT INTO keiro.keiro_managed_read_objects
+    (object_schema, object_name, object_kind, object_signature,
+     managed_by, definition_hash, surface_generation, state)
+  VALUES ('keiro', 'external_read_demo_v1_binding', 'binding-view', '',
+          'keiro', 'test-definition', 1, 'active');
+  """
