diff --git a/CHANGELOG.md b/CHANGELOG.md
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,5 +1,49 @@
 # Changelog for pgmq-migration
 
+## 0.5.0.0 -- 2026-08-06
+
+### Bug Fixes
+
+Migration `0003-notify-crash-safety-and-locking.sql` re-creates three functions. It is
+appended to the ledger; `0001` and `0002` are unchanged.
+
+* Insert notifications no longer stop permanently after a PostgreSQL crash.
+  `pgmq.notify_insert_throttle` is `UNLOGGED`, so crash recovery truncates it, and
+  `pgmq.notify_queue_listeners` notified only when its throttle `UPDATE` matched a row.
+  Zero updated rows had two causes — throttled, and row absent — and the trigger treated
+  both as "stay quiet", so after a crash it fired, matched nothing, and silently never
+  notified again until an application restart re-enabled notify. Sends succeeded and
+  messages accumulated while listeners starved. A `NOT EXISTS` probe on the already-failed
+  branch now tells the two cases apart and notifies unthrottled until the next reconcile
+  restores the configured interval. The row is deliberately not re-inserted: the
+  configured interval is the crash's data loss, and inventing one in the hot path would
+  silently change throttling.
+* Concurrent `pgmq.enable_notify_insert` calls for the same queue no longer race. Two
+  replicas reconciling the same config at startup could both pass the function's internal
+  `DROP TRIGGER IF EXISTS` — on a fresh queue it finds nothing and takes no lock — and the
+  loser then blocked on the throttle row's unique constraint, resumed, and created a
+  trigger that now existed, failing with SQLSTATE 42710 (duplicate_object) and taking down
+  that replica's entire startup reconcile. Measured at roughly a 28% collision rate over
+  200 concurrent iterations. The function now takes the per-queue advisory lock, as
+  `pgmq.create` and `pgmq.create_partitioned` already do, which makes concurrent callers
+  convergent.
+* `pgmq.enable_notify_insert` coalesces a NULL `throttle_interval_ms` to the documented
+  250 ms, so non-Haskell callers get the same guarantee the pgmq-hasql statement provides
+  client-side.
+* `pgmq.create_partitioned` is now re-entrant. The advisory lock serialized concurrent
+  creators, but the second one still called `partman.create_parent` on a parent the first
+  had just registered: its `CREATE TABLE IF NOT EXISTS` is a no-op while `create_parent`
+  rejects an already-managed parent. Both `create_parent` calls are now guarded by a
+  `part_config` probe.
+
+### Other Changes
+
+* The test suite derives the migration ledger from the plan rather than enumerating it
+  positionally, so appending a migration no longer turns unrelated tests red. The ledger
+  is spelled out once, in `testNativeComponent`.
+* Added a pg_partman-gated re-entry test that reports its skip rather than passing
+  silently when the extension is unavailable.
+
 ## 0.4.0.1 -- 2026-07-14
 
 ### Bug Fixes
diff --git a/migrations/0003-notify-crash-safety-and-locking.sql b/migrations/0003-notify-crash-safety-and-locking.sql
new file mode 100644
--- /dev/null
+++ b/migrations/0003-notify-crash-safety-and-locking.sql
@@ -0,0 +1,297 @@
+-- Notification crash safety, reconcile locking, and partitioned re-entry.
+--
+-- Three deliberate divergences from upstream pgmq 1.11.0, all re-created with
+-- their original signatures so this file is a drop-in replacement:
+--
+--   1. pgmq.notify_queue_listeners() fails open when its throttle row is
+--      missing. pgmq.notify_insert_throttle is UNLOGGED, so crash recovery
+--      truncates it; the trigger's UPDATE then matched zero rows and the
+--      updated_count > 0 gate suppressed PG_NOTIFY forever, silently starving
+--      every listener until an application re-enabled notify. Losing the
+--      throttle interval in a crash is acceptable; losing deliveries is not.
+--
+--   2. pgmq.enable_notify_insert() takes the per-queue advisory lock and
+--      coalesces a NULL throttle to the documented 250 ms. Without the lock,
+--      two replicas reconciling the same queue at startup both pass the
+--      internal DROP TRIGGER IF EXISTS, and the loser then fails with SQLSTATE
+--      42710 when it creates a trigger the winner just committed, taking down
+--      that replica's whole reconcile. Without the COALESCE, a bound SQL NULL
+--      raises 23502: a plpgsql parameter DEFAULT applies only to an omitted
+--      argument, never to an explicit NULL.
+--
+--   3. pgmq.create_partitioned() skips partman.create_parent when the table is
+--      already registered in part_config. The advisory lock serializes
+--      concurrent creators, but the second replica still calls create_parent on
+--      a parent the first just registered — its CREATE TABLE IF NOT EXISTS is a
+--      no-op, while create_parent rejects an already-managed parent. Idempotence
+--      is the missing property, not locking.
+--
+-- See docs/plans/14-make-insert-notifications-survive-crashes-and-document-the-channel-contract.md.
+
+CREATE OR REPLACE FUNCTION pgmq.notify_queue_listeners()
+RETURNS TRIGGER AS $$
+DECLARE
+  queue_name_extracted TEXT; -- Queue name extracted from trigger table name
+  updated_count        INTEGER; -- Number of rows updated (0 or 1)
+BEGIN
+  queue_name_extracted := substring(TG_TABLE_NAME from 3);
+
+  UPDATE pgmq.notify_insert_throttle
+  SET last_notified_at = clock_timestamp()
+  WHERE queue_name = queue_name_extracted
+    AND (
+      throttle_interval_ms = 0 -- No throttling configured
+          OR clock_timestamp() - last_notified_at >=
+             (throttle_interval_ms * INTERVAL '1 millisecond') -- Throttle interval has elapsed
+    );
+
+  -- Check how many rows were updated (will be 0 or 1)
+  GET DIAGNOSTICS updated_count = ROW_COUNT;
+
+  IF updated_count > 0 THEN
+    PERFORM PG_NOTIFY('pgmq.' || TG_TABLE_NAME || '.' || TG_OP, NULL);
+  ELSIF NOT EXISTS (
+    SELECT 1 FROM pgmq.notify_insert_throttle nit
+    WHERE nit.queue_name = queue_name_extracted
+  ) THEN
+    -- Fail open: the trigger exists but its throttle row does not. Zero updated
+    -- rows has two causes -- throttled (suppress, correct) and row absent -- and
+    -- only this probe tells them apart. The row lives in an UNLOGGED table that
+    -- crash recovery truncates, so losing it must not silently stop delivery.
+    -- Notify unthrottled until pgmq.enable_notify_insert restores the row.
+    --
+    -- The row is deliberately NOT re-inserted here: the configured interval is
+    -- the crash's data loss, and inventing one in the trigger's hot path would
+    -- silently change throttling. The reconciler restores the configured value.
+    PERFORM PG_NOTIFY('pgmq.' || TG_TABLE_NAME || '.' || TG_OP, NULL);
+  END IF;
+
+RETURN NEW;
+END;
+$$ LANGUAGE plpgsql;
+
+CREATE OR REPLACE FUNCTION pgmq.enable_notify_insert(queue_name TEXT, throttle_interval_ms INTEGER DEFAULT 250)
+RETURNS void AS $$
+DECLARE
+  qtable TEXT := pgmq.format_table_name(queue_name, 'q');
+  v_queue_name TEXT := queue_name;
+  -- A bound SQL NULL never triggers the parameter DEFAULT above, so the
+  -- documented 250 ms has to be applied here for non-Haskell callers.
+  v_throttle_interval_ms INTEGER := COALESCE(throttle_interval_ms, 250);
+BEGIN
+  -- Serialize the whole disable-then-create sequence per queue, exactly as
+  -- pgmq.create and pgmq.create_partitioned already do. That makes concurrent
+  -- callers convergent: the second one drops and re-creates the first one's
+  -- identical trigger, an idempotent no-op in effect, with no error-code
+  -- matching anywhere. Accepted side effect: a concurrent second enable resets
+  -- last_notified_at to the epoch, exactly as a sequential re-enable does.
+  PERFORM pgmq.acquire_queue_lock(queue_name);
+
+  -- Validate that throttle_interval_ms is non-negative
+  IF v_throttle_interval_ms < 0 THEN
+    RAISE EXCEPTION 'throttle_interval_ms must be non-negative';
+  END IF;
+
+  -- Validate that the queue table exists
+  IF NOT EXISTS (SELECT 1 FROM information_schema.tables WHERE table_schema = 'pgmq' AND table_name = qtable) THEN
+    RAISE EXCEPTION 'Queue "%" does not exist. Create it first using pgmq.create()', v_queue_name;
+  END IF;
+
+  PERFORM pgmq.disable_notify_insert(v_queue_name);
+
+  INSERT INTO pgmq.notify_insert_throttle (queue_name, throttle_interval_ms)
+  VALUES (v_queue_name, v_throttle_interval_ms)
+  ON CONFLICT ON CONSTRAINT notify_insert_throttle_queue_name_key DO UPDATE
+      SET throttle_interval_ms = EXCLUDED.throttle_interval_ms,
+          last_notified_at = to_timestamp(0);
+
+  EXECUTE FORMAT(
+    $QUERY$
+    CREATE CONSTRAINT TRIGGER trigger_notify_queue_insert_listeners
+    AFTER INSERT ON pgmq.%I
+    DEFERRABLE FOR EACH ROW
+    EXECUTE PROCEDURE pgmq.notify_queue_listeners()
+    $QUERY$,
+    qtable
+  );
+END;
+$$ LANGUAGE plpgsql;
+
+CREATE OR REPLACE FUNCTION pgmq.create_partitioned(
+  queue_name TEXT,
+  partition_interval TEXT DEFAULT '10000',
+  retention_interval TEXT DEFAULT '100000'
+)
+RETURNS void AS $$
+DECLARE
+  partition_col TEXT;
+  a_partition_col TEXT;
+  qtable TEXT := pgmq.format_table_name(queue_name, 'q');
+  qtable_seq TEXT := qtable || '_msg_id_seq';
+  atable TEXT := pgmq.format_table_name(queue_name, 'a');
+  fq_qtable TEXT := 'pgmq.' || qtable;
+  fq_atable TEXT := 'pgmq.' || atable;
+  l_already_managed BOOLEAN; -- Parent already registered in pg_partman's part_config
+BEGIN
+  PERFORM pgmq.validate_queue_name(queue_name);
+  PERFORM pgmq.acquire_queue_lock(queue_name);
+  PERFORM pgmq._ensure_pg_partman_installed();
+  SELECT pgmq._get_partition_col(partition_interval) INTO partition_col;
+
+  EXECUTE FORMAT(
+    $QUERY$
+    CREATE TABLE IF NOT EXISTS pgmq.%I (
+        msg_id BIGINT GENERATED ALWAYS AS IDENTITY,
+        read_ct INT DEFAULT 0 NOT NULL,
+        enqueued_at TIMESTAMP WITH TIME ZONE DEFAULT now() NOT NULL,
+        last_read_at TIMESTAMP WITH TIME ZONE,
+        vt TIMESTAMP WITH TIME ZONE NOT NULL,
+        message JSONB,
+        headers JSONB
+    ) PARTITION BY RANGE (%I)
+    $QUERY$,
+    qtable, partition_col
+  );
+
+  -- Re-entry guard: the CREATE TABLE above is IF NOT EXISTS, but create_parent
+  -- rejects a parent that is already registered, so a second caller would fail
+  -- on a queue the first one finished creating.
+  EXECUTE FORMAT(
+    $QUERY$
+    SELECT EXISTS (SELECT 1 FROM %I.part_config WHERE parent_table = %L)
+    $QUERY$,
+    pgmq._get_pg_partman_schema(),
+    fq_qtable
+  ) INTO l_already_managed;
+
+  IF NOT l_already_managed THEN
+    -- https://github.com/pgpartman/pg_partman/blob/master/doc/pg_partman.md
+    -- p_parent_table - the existing parent table. MUST be schema qualified, even if in public schema.
+    EXECUTE FORMAT(
+      $QUERY$
+      SELECT %I.create_parent(
+        p_parent_table := %L,
+        p_control := %L,
+        p_interval := %L,
+        p_type := case
+          when pgmq._get_pg_partman_major_version() = 5 then 'range'
+          else 'native'
+        end
+      )
+      $QUERY$,
+      pgmq._get_pg_partman_schema(),
+      fq_qtable,
+      partition_col,
+      partition_interval
+    );
+  END IF;
+
+  EXECUTE FORMAT(
+    $QUERY$
+    CREATE INDEX IF NOT EXISTS %I ON pgmq.%I (%I);
+    $QUERY$,
+    qtable || '_part_idx', qtable, partition_col
+  );
+
+  EXECUTE FORMAT(
+    $QUERY$
+    UPDATE %I.part_config
+    SET
+        retention = %L,
+        retention_keep_table = false,
+        retention_keep_index = true,
+        automatic_maintenance = 'on'
+    WHERE parent_table = %L;
+    $QUERY$,
+    pgmq._get_pg_partman_schema(),
+    retention_interval,
+    'pgmq.' || qtable
+  );
+
+  EXECUTE FORMAT(
+    $QUERY$
+    INSERT INTO pgmq.meta (queue_name, is_partitioned, is_unlogged)
+    VALUES (%L, true, false)
+    ON CONFLICT
+    DO NOTHING;
+    $QUERY$,
+    queue_name
+  );
+
+  IF partition_col = 'enqueued_at' THEN
+    a_partition_col := 'archived_at';
+  ELSE
+    a_partition_col := partition_col;
+  END IF;
+
+  EXECUTE FORMAT(
+    $QUERY$
+    CREATE TABLE IF NOT EXISTS pgmq.%I (
+      msg_id BIGINT NOT NULL,
+      read_ct INT DEFAULT 0 NOT NULL,
+      enqueued_at TIMESTAMP WITH TIME ZONE DEFAULT now() NOT NULL,
+      last_read_at TIMESTAMP WITH TIME ZONE,
+      archived_at TIMESTAMP WITH TIME ZONE DEFAULT now() NOT NULL,
+      vt TIMESTAMP WITH TIME ZONE NOT NULL,
+      message JSONB,
+      headers JSONB
+    ) PARTITION BY RANGE (%I);
+    $QUERY$,
+    atable, a_partition_col
+  );
+
+  -- Same re-entry guard for the archive table.
+  EXECUTE FORMAT(
+    $QUERY$
+    SELECT EXISTS (SELECT 1 FROM %I.part_config WHERE parent_table = %L)
+    $QUERY$,
+    pgmq._get_pg_partman_schema(),
+    fq_atable
+  ) INTO l_already_managed;
+
+  IF NOT l_already_managed THEN
+    -- https://github.com/pgpartman/pg_partman/blob/master/doc/pg_partman.md
+    -- p_parent_table - the existing parent table. MUST be schema qualified, even if in public schema.
+    EXECUTE FORMAT(
+      $QUERY$
+      SELECT %I.create_parent(
+        p_parent_table := %L,
+        p_control := %L,
+        p_interval := %L,
+        p_type := case
+          when pgmq._get_pg_partman_major_version() = 5 then 'range'
+          else 'native'
+        end
+      )
+      $QUERY$,
+      pgmq._get_pg_partman_schema(),
+      fq_atable,
+      a_partition_col,
+      partition_interval
+    );
+  END IF;
+
+  EXECUTE FORMAT(
+    $QUERY$
+    UPDATE %I.part_config
+    SET
+        retention = %L,
+        retention_keep_table = false,
+        retention_keep_index = true,
+        automatic_maintenance = 'on'
+    WHERE parent_table = %L;
+    $QUERY$,
+    pgmq._get_pg_partman_schema(),
+    retention_interval,
+    'pgmq.' || atable
+  );
+
+  EXECUTE FORMAT(
+    $QUERY$
+    CREATE INDEX IF NOT EXISTS %I ON pgmq.%I (archived_at);
+    $QUERY$,
+    'archived_at_idx_' || queue_name, atable
+  );
+
+END;
+$$ LANGUAGE plpgsql;
diff --git a/migrations/manifest b/migrations/manifest
--- a/migrations/manifest
+++ b/migrations/manifest
@@ -1,2 +1,3 @@
 0001-install-v1.11.0.sql
 0002-schema-management-comment.sql
+0003-notify-crash-safety-and-locking.sql
diff --git a/pgmq-migration.cabal b/pgmq-migration.cabal
--- a/pgmq-migration.cabal
+++ b/pgmq-migration.cabal
@@ -1,6 +1,6 @@
 cabal-version:      3.4
 name:               pgmq-migration
-version:            0.4.0.1
+version:            0.5.0.0
 synopsis:           PGMQ schema migrations without PostgreSQL extension
 description:
   Installs the PGMQ schema into PostgreSQL without requiring the pgmq extension.
diff --git a/test/Main.hs b/test/Main.hs
--- a/test/Main.hs
+++ b/test/Main.hs
@@ -47,8 +47,13 @@
   ( ComponentDescription (..),
     PlanDescription (..),
     componentNameText,
+    migrationIdName,
+    migrationNameText,
     planDescription,
   )
+-- MigrationDescription's 'migrationId' field would shadow the smart constructor
+-- of the same name imported above, so it is reached through this alias only.
+import Database.PostgreSQL.Migrate.Internal qualified as MigrateInternal
 import EphemeralPg
   ( connectionSettings,
     withCached,
@@ -93,7 +98,7 @@
     [ testGroup
         "native definition"
         [ testCase "baseline bytes equal vendored pgmq.sql" testNativePayload,
-          testCase "component pgmq has two migrations and no dependencies" testNativeComponent
+          testCase "component pgmq lists the ledger in order and has no dependencies" testNativeComponent
         ],
       testGroup
         "native runner"
@@ -118,6 +123,9 @@
   vendored <- ByteString.readFile vendorPath
   native @?= vendored
 
+-- | The one place the ledger is spelled out. Every other expectation in this
+-- suite derives from 'nativeMigrationNames', so appending a migration means
+-- reviewing this list and nothing else.
 testNativeComponent :: IO ()
 testNativeComponent = do
   component <- either (assertFailure . show) pure Migration.pgmqMigrations
@@ -127,9 +135,39 @@
     [ComponentDescription {name, dependencies, migrations}] -> do
       componentNameText name @?= "pgmq"
       dependencies @?= mempty
-      length migrations @?= 2
+      migrationNames migrations
+        @?= [ "0001-install-v1.11.0",
+              "0002-schema-management-comment",
+              "0003-notify-crash-safety-and-locking"
+            ]
     actual -> assertFailure ("unexpected native PGMQ plan: " <> show actual)
 
+migrationNames :: (Foldable f) => f MigrateInternal.MigrationDescription -> [Text]
+migrationNames descriptions =
+  [ migrationNameText (migrationIdName (MigrateInternal.migrationId description))
+  | description <- toList descriptions
+  ]
+
+-- | Migration names in the native PGMQ ledger, in manifest order.
+nativeMigrationNames :: IO [Text]
+nativeMigrationNames = do
+  component <- either (assertFailure . show) pure Migration.pgmqMigrations
+  plan <- either (assertFailure . show) pure (migrationPlan (component :| []))
+  let PlanDescription components = planDescription plan
+  case toList components of
+    [ComponentDescription {migrations}] -> pure (migrationNames migrations)
+    actual -> assertFailure ("unexpected native PGMQ plan: " <> show actual)
+
+-- | Every migration a history import leaves unapplied. An import records the
+-- immutable baseline only, so everything after it is still pending.
+pendingAfterBaseline :: IO [VerificationIssue]
+pendingAfterBaseline = do
+  names <- nativeMigrationNames
+  traverse pendingIssue (drop 1 names)
+  where
+    pendingIssue name =
+      PendingMigration <$> either (assertFailure . show) pure (migrationId "pgmq" name)
+
 findFile :: [FilePath] -> IO FilePath
 findFile candidates = do
   existing <- filterM doesFileExist candidates
@@ -181,14 +219,15 @@
 testNativeRunner :: Settings.Settings -> Connection.Connection -> IO ()
 testNativeRunner settings conn = withCleanDb conn $ \c -> do
   plan <- nativePlan
+  ledgerLength <- length <$> nativeMigrationNames
   first <- runMigrationPlan defaultRunOptions settings plan
   case first of
     Left err -> assertFailure ("fresh native migration failed: " <> show err)
-    Right report -> (outcome <$> toList (results report)) @?= [AppliedNow, AppliedNow]
+    Right report -> (outcome <$> toList (results report)) @?= replicate ledgerLength AppliedNow
   second <- runMigrationPlan defaultRunOptions settings plan
   case second of
     Left err -> assertFailure ("repeated native migration failed: " <> show err)
-    Right report -> (outcome <$> toList (results report)) @?= [AlreadyApplied, AlreadyApplied]
+    Right report -> (outcome <$> toList (results report)) @?= replicate ledgerLength AlreadyApplied
   functionExists c "pgmq.metrics_all()" >>= (@?= True)
   hasCanaryComment c >>= (@?= True)
 
@@ -236,15 +275,17 @@
   historyOutcomes second @?= [AlreadyImported]
 
   plan <- nativePlan
-  canaryId <- either (assertFailure . show) pure (migrationId "pgmq" "0002-schema-management-comment")
+  pending <- pendingAfterBaseline
+  ledgerLength <- length <$> nativeMigrationNames
   beforeCanary <- verifyMigrationPlan defaultRunOptions settings plan
   case beforeCanary of
     Left err -> assertFailure ("native verify failed after direct import: " <> show err)
-    Right (VerificationReport issues _ _ _) -> issues @?= [PendingMigration canaryId]
+    Right (VerificationReport issues _ _ _) -> issues @?= pending
   nativeRun <- runMigrationPlan defaultRunOptions settings plan
   case nativeRun of
     Left err -> assertFailure ("native runner failed after direct import: " <> show err)
-    Right report -> (outcome <$> toList (results report)) @?= [AlreadyApplied, AppliedNow]
+    Right report ->
+      (outcome <$> toList (results report)) @?= AlreadyApplied : replicate (ledgerLength - 1) AppliedNow
   afterCanary <- verifyMigrationPlan defaultRunOptions settings plan
   case afterCanary of
     Left err -> assertFailure ("native verify failed after direct canary: " <> show err)
@@ -252,7 +293,7 @@
   repeated <- runMigrationPlan defaultRunOptions settings plan
   case repeated of
     Left err -> assertFailure ("native rerun failed after direct canary: " <> show err)
-    Right report -> (outcome <$> toList (results report)) @?= [AlreadyApplied, AlreadyApplied]
+    Right report -> (outcome <$> toList (results report)) @?= replicate ledgerLength AlreadyApplied
   functionExists c "pgmq.metrics_all()" >>= (@?= False)
   hasCanaryComment c >>= (@?= True)
 
@@ -319,15 +360,17 @@
   historyOutcomes second @?= [AlreadyImported]
 
   plan <- nativePlan
-  canaryId <- either (assertFailure . show) pure (migrationId "pgmq" "0002-schema-management-comment")
+  pending <- pendingAfterBaseline
+  ledgerLength <- length <$> nativeMigrationNames
   beforeCanary <- verifyMigrationPlan defaultRunOptions settings plan
   case beforeCanary of
     Left err -> assertFailure ("native verify failed after equivalent import: " <> show err)
-    Right (VerificationReport issues _ _ _) -> issues @?= [PendingMigration canaryId]
+    Right (VerificationReport issues _ _ _) -> issues @?= pending
   nativeRun <- runMigrationPlan defaultRunOptions settings plan
   case nativeRun of
     Left err -> assertFailure ("native runner failed after equivalent import: " <> show err)
-    Right report -> (outcome <$> toList (results report)) @?= [AlreadyApplied, AppliedNow]
+    Right report ->
+      (outcome <$> toList (results report)) @?= AlreadyApplied : replicate (ledgerLength - 1) AppliedNow
   afterCanary <- verifyMigrationPlan defaultRunOptions settings plan
   case afterCanary of
     Left err -> assertFailure ("native verify failed after equivalent canary: " <> show err)
@@ -335,7 +378,7 @@
   repeated <- runMigrationPlan defaultRunOptions settings plan
   case repeated of
     Left err -> assertFailure ("native rerun failed after equivalent canary: " <> show err)
-    Right report -> (outcome <$> toList (results report)) @?= [AlreadyApplied, AlreadyApplied]
+    Right report -> (outcome <$> toList (results report)) @?= replicate ledgerLength AlreadyApplied
   hasCanaryComment c >>= (@?= True)
 
 testEquivalentContractRejections :: Settings.Settings -> Connection.Connection -> IO ()
@@ -549,19 +592,21 @@
 assertNativeCanaryLifecycle :: Settings.Settings -> Connection.Connection -> IO ()
 assertNativeCanaryLifecycle settings connection = do
   plan <- nativePlan
-  canaryId <- either (assertFailure . show) pure (migrationId "pgmq" "0002-schema-management-comment")
+  pending <- pendingAfterBaseline
+  ledgerLength <- length <$> nativeMigrationNames
   beforeCanary <- verifyMigrationPlan defaultRunOptions settings plan
   case beforeCanary of
     Left err -> assertFailure ("native verify failed after shared-ledger import: " <> show err)
-    Right (VerificationReport issues _ _ _) -> issues @?= [PendingMigration canaryId]
+    Right (VerificationReport issues _ _ _) -> issues @?= pending
   nativeRun <- runMigrationPlan defaultRunOptions settings plan
   case nativeRun of
     Left err -> assertFailure ("native runner failed after shared-ledger import: " <> show err)
-    Right report -> (outcome <$> toList (results report)) @?= [AlreadyApplied, AppliedNow]
+    Right report ->
+      (outcome <$> toList (results report)) @?= AlreadyApplied : replicate (ledgerLength - 1) AppliedNow
   repeated <- runMigrationPlan defaultRunOptions settings plan
   case repeated of
     Left err -> assertFailure ("native rerun failed after shared-ledger import: " <> show err)
-    Right report -> (outcome <$> toList (results report)) @?= [AlreadyApplied, AlreadyApplied]
+    Right report -> (outcome <$> toList (results report)) @?= replicate ledgerLength AlreadyApplied
   hasCanaryComment connection >>= (@?= True)
 
 canaryCommentStatement :: Statement.Statement () Bool
