diff --git a/CHANGELOG.md b/CHANGELOG.md
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,12 +1,29 @@
 # Changelog
 
+## [Unreleased]
+
+## 0.9.0.0 — 2026-10-12
+
+### Breaking Changes
+
+- Compose Keiro migrations 0.20 and Kiroku migrations 0.7.1: **60 entries**
+  (15 Kiroku, 32 Keiro, 13 Kioku). Upstream 0013/0014 require subscription
+  workers stopped and historical targets confirmed; 0015 needs an index-build
+  write pause. No Kioku SQL bytes or existing checksums change, and no new
+  Kioku ledger fixup is required.
+
+### Changed
+
+- Align the public test-support sublibrary with Keiro test support 0.20.
+- Exercise the genuine released 0.6 Kiroku manifest with events, checkpoints,
+  and Kioku projections before upgrading; preserve the historical 30-entry Codd import.
+- Add explicit types for existing Codd evidence helpers under warnings-as-errors.
+
 ## 0.8.0.2 — 2026-10-06
 
 ### Fixed
 
 - Pin bare-database fixtures to the same stable effective-uid temporary root owned by `mori://shinzui/keiro/packages/keiro-test-support` as the delegated migrated fixtures; keep stale-instance sweeping enabled and require `mori://shinzui/ephemeral-pg/packages/ephemeral-pg` >=0.3.1 && <0.4.
-
-## [Unreleased]
 
 ## 0.8.0.1 — 2026-10-05
 
diff --git a/kioku-migrations.cabal b/kioku-migrations.cabal
--- a/kioku-migrations.cabal
+++ b/kioku-migrations.cabal
@@ -1,6 +1,6 @@
 cabal-version: 3.0
 name: kioku-migrations
-version: 0.8.0.2
+version: 0.9.0.0
 synopsis: Schema migrations for kioku
 description:
   Owns kioku schema evolution as a native pg-migrate component and composes
@@ -27,6 +27,10 @@
   migrations/*.sql
   migrations/manifest
   test/fixtures/*.sql
+  test/fixtures/kiroku-0.6.0.0/*.sql
+  test/fixtures/kiroku-0.6.0.0/README.md
+  test/fixtures/kiroku-0.6.0.0/SHA256SUMS
+  test/fixtures/kiroku-0.6.0.0/manifest
 
 source-repository head
   type: git
@@ -71,8 +75,8 @@
     containers >=0.6 && <0.8,
     hasql >=1.10 && <1.11,
     hasql-transaction >=1.2 && <1.3,
-    keiro-migrations ^>=0.19.0.0,
-    kiroku-store-migrations ^>=0.6.0.0,
+    keiro-migrations ^>=0.20.0.0,
+    kiroku-store-migrations ^>=0.7.1.0,
     pg-migrate ^>=1.2.0.0,
     pg-migrate-embed ^>=1.2.0.0,
     pg-migrate-import-codd ^>=1.2.0.0,
@@ -88,8 +92,8 @@
     base >=4.18 && <5,
     directory >=1.3 && <1.4,
     ephemeral-pg >=0.3.1 && <0.4,
-    keiro-test-support ^>=0.19.0.0,
-    kioku-migrations ^>=0.8.0.2,
+    keiro-test-support ^>=0.20.0.0,
+    kioku-migrations ^>=0.9.0.0,
     text >=2.0 && <2.2,
     unix >=2.8 && <2.9,
 
@@ -111,10 +115,10 @@
     bytestring >=0.11 && <0.13,
     containers >=0.6 && <0.8,
     hasql >=1.6,
-    keiro-migrations ^>=0.19.0.0,
-    kioku-migrations ^>=0.8.0.2,
+    keiro-migrations ^>=0.20.0.0,
+    kioku-migrations ^>=0.9.0.0,
     kioku-migrations:test-support,
-    kiroku-store-migrations ^>=0.6.0.0,
+    kiroku-store-migrations ^>=0.7.1.0,
     pg-migrate ^>=1.2.0.0,
     pg-migrate-embed ^>=1.2.0.0,
     pg-migrate-import-codd ^>=1.2.0.0,
diff --git a/src/Kioku/Migrations/History/Codd.hs b/src/Kioku/Migrations/History/Codd.hs
--- a/src/Kioku/Migrations/History/Codd.hs
+++ b/src/Kioku/Migrations/History/Codd.hs
@@ -35,9 +35,11 @@
 import Database.PostgreSQL.Migrate
   ( Confirmation,
     ConnectionProvider,
+    EvidenceKey,
     EvidenceRequirement (AllOf, Evidence),
     HistoryMapping,
     PayloadRelation (EquivalentState, SamePayload),
+    StateValidationError,
     StateValidator,
     evidenceKey,
     historyMapping,
@@ -223,18 +225,22 @@
           else Left sessionRegistryValidationError
   ]
 
+keiroSchemaEvidenceKey :: EvidenceKey
 keiroSchemaEvidenceKey =
   definitionInvariant (evidenceKey "keiro:pre-pg-migrate-schema-relocated")
 
+keiroSchemaValidationError :: StateValidationError
 keiroSchemaValidationError =
   definitionInvariant
     ( stateValidationError
         "expected the relocated Keiro schema, tables, indexes, recovery columns, workflow backfill, and generation-aware workflow-step primary key"
     )
 
+sessionRegistryEvidenceKey :: EvidenceKey
 sessionRegistryEvidenceKey =
   definitionInvariant (evidenceKey "kioku:session-readmodels-v3")
 
+sessionRegistryValidationError :: StateValidationError
 sessionRegistryValidationError =
   definitionInvariant
     ( stateValidationError
diff --git a/test/Main.hs b/test/Main.hs
--- a/test/Main.hs
+++ b/test/Main.hs
@@ -88,6 +88,7 @@
           testCase "public schema (long-lived dev databases)" (assertRegistryBump "public")
         ],
       testCase "the full migration chain applies to a fresh database" testFreshDatabase,
+      testCase "the released previous cohort preserves data and checkpoints through 0013–0015" testPreviousCohortUpgrade,
       testGroup
         "migration-session isolation"
         [testCase "restores the host search path before later components" testHostSearchPathRestored],
@@ -445,6 +446,137 @@
     Nothing -> fail ("no manifest migration named " <> name)
     Just bytes -> pure (Text.Encoding.decodeUtf8 bytes)
 
+-- | A genuine released 12-entry Kiroku manifest, composed with the unchanged
+-- Keiro and Kioku payloads. No current SQL is sliced to simulate old history.
+testPreviousCohortUpgrade :: Assertion
+testPreviousCohortUpgrade = withBareDatabase $ \connStr -> do
+  let fixtureRoot = "test/fixtures/kiroku-0.6.0.0/"
+      settings = Settings.connectionString connStr
+  entries <- checkMigrationManifest (fixtureRoot <> "manifest") >>= either (assertFailure . show) pure
+  frozen <- Text.IO.readFile (fixtureRoot <> "SHA256SUMS") >>= either assertFailure pure . parseMigrationLock
+  [(name, checksumHex bytes) | (name, bytes) <- toList entries] @?= frozen
+  length entries @?= 12
+  oldKiroku <- either (assertFailure . show) pure (migrationComponentFromEmbeddedSql "kiroku" Set.empty entries)
+  keiro <- either (assertFailure . show) pure KeiroMigrations.keiroMigrations
+  kioku <- either (assertFailure . show) pure kiokuMigrations
+  old <- either (assertFailure . show) pure (migrationPlan (oldKiroku :| [keiro, kioku]))
+  current <- either (assertFailure . show) pure kiokuMigrationPlan
+  installed <- runMigrationPlan defaultRunOptions settings old >>= either (assertFailure . show) pure
+  length (appliedNow installed) @?= 57
+  withConnection connStr $ \conn -> do
+    run conn (Session.script previousCohortSeed)
+    -- Seed all seven application projections using the existing preservation fixture.
+    run conn (Session.script (Text.replace "kiroku.kioku_" "kioku." relocationSeedRows))
+  before <- query connStr previousCohortSnapshot
+  ledgerBefore <- query connStr previousCohortLedger
+  checkpointsBefore <- query connStr previousCheckpointSnapshot
+  query connStr previousCohortIsLegacy >>= (@?= True)
+  migrated <- runMigrationPlan defaultRunOptions settings current >>= either (assertFailure . show) pure
+  appliedNow migrated @?= [expectRight (migrationId "kiroku" n) | n <- ["0013", "0014", "0015"]]
+  query connStr previousCohortSnapshot >>= (@?= before)
+  query connStr previousCohortLedger >>= (@?= ledgerBefore)
+  query connStr previousCheckpointSnapshot >>= (@?= checkpointsBefore)
+  query connStr upgradedCheckpointShape >>= (@?= True)
+  verification <- verifyMigrationPlan defaultRunOptions settings current >>= either (assertFailure . show) pure
+  let VerificationReport {issues, appliedMigrations, pendingMigrations, unknownMigrations} = verification
+  issues @?= []
+  length appliedMigrations @?= 60
+  pendingMigrations @?= []
+  unknownMigrations @?= []
+  repeated <- runMigrationPlan defaultRunOptions settings current >>= either (assertFailure . show) pure
+  let MigrationReport {results} = repeated
+  length [() | MigrationResult {outcome = AlreadyApplied} <- toList results] @?= 60
+  appliedNow repeated @?= []
+  query connStr previousCohortSnapshot >>= (@?= before)
+  query connStr previousCheckpointSnapshot >>= (@?= checkpointsBefore)
+
+previousCohortSeed :: Text
+previousCohortSeed =
+  """
+  INSERT INTO kiroku.streams (stream_id, stream_name, stream_version)
+  VALUES (42, 'kioku_memory-upgrade-fixture', 1);
+  INSERT INTO kiroku.events (event_id, event_type, data, metadata)
+  VALUES ('00000000-0000-4000-8000-000000000048', 'UpgradeFixture', '{"content":"preserved"}', '{"origin":"previous-cohort"}');
+  INSERT INTO kiroku.stream_events
+    (event_id, stream_id, stream_version, original_stream_id, original_stream_version, category)
+  VALUES ('00000000-0000-4000-8000-000000000048', 42, 1, 42, 1, NULL),
+         ('00000000-0000-4000-8000-000000000048', 0, 1, 42, 1, 'kioku_memory');
+  UPDATE kiroku.streams SET stream_version = 1 WHERE stream_id = 0;
+  INSERT INTO kiroku.subscriptions
+    (subscription_name, stream_name, last_seen, consumer_group_member, consumer_group_size)
+  VALUES ('kioku-memory-embedding', 'kioku_memory', 1, 0, 1),
+         ('upgrade-group', 'kioku_memory', 1, 0, 1),
+         ('upgrade-group', 'kioku_memory', 0, 1, 1);
+  """
+
+-- Exact data, not just row counts: the envelope, junctions, stream metadata and
+-- every Kioku projection survive. Stable ordering makes the comparison deterministic.
+previousCohortSnapshot :: Statement () Text
+previousCohortSnapshot =
+  preparable
+    """
+    SELECT jsonb_build_object(
+      'events', (SELECT jsonb_agg(to_jsonb(t) ORDER BY event_id) FROM kiroku.events t),
+      'streams', (SELECT jsonb_agg(to_jsonb(t) ORDER BY stream_id) FROM kiroku.streams t),
+      'junctions', (SELECT jsonb_agg(to_jsonb(t) ORDER BY stream_id, stream_version) FROM kiroku.stream_events t),
+      'memories', (SELECT jsonb_agg(to_jsonb(t) ORDER BY memory_id) FROM kioku.memories t),
+      'sessions', (SELECT jsonb_agg(to_jsonb(t) ORDER BY session_id) FROM kioku.sessions t),
+      'turns', (SELECT jsonb_agg(to_jsonb(t) ORDER BY turn_id) FROM kioku.turns t),
+      'watermarks', (SELECT jsonb_agg(to_jsonb(t) ORDER BY session_id) FROM kioku.l1_watermarks t),
+      'decisions', (SELECT jsonb_agg(to_jsonb(t) ORDER BY decision_id) FROM kioku.consolidation_decisions t),
+      'scenes', (SELECT jsonb_agg(to_jsonb(t) ORDER BY scene_id) FROM kioku.scenes t),
+      'personas', (SELECT jsonb_agg(to_jsonb(t) ORDER BY persona_id) FROM kioku.personas t)
+    )::text
+    """
+    E.noParams
+    (D.singleRow (D.column (D.nonNullable D.text)))
+
+previousCheckpointSnapshot :: Statement () Text
+previousCheckpointSnapshot =
+  preparable
+    "SELECT jsonb_agg(to_jsonb(t) ORDER BY subscription_name, consumer_group_member)::text FROM kiroku.subscription_checkpoints_v1 t"
+    E.noParams
+    (D.singleRow (D.column (D.nonNullable D.text)))
+
+previousCohortLedger :: Statement () Text
+previousCohortLedger =
+  preparable
+    """
+    SELECT jsonb_agg(to_jsonb(t) ORDER BY component, migration)::text
+    FROM pgmigrate.migrations t
+    WHERE NOT (component = 'kiroku' AND migration IN ('0013', '0014', '0015'))
+    """
+    E.noParams
+    (D.singleRow (D.column (D.nonNullable D.text)))
+
+previousCohortIsLegacy :: Statement () Bool
+previousCohortIsLegacy =
+  preparable
+    """
+    SELECT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema = 'kiroku'
+      AND table_name = 'subscriptions' AND column_name = 'stream_name')
+      AND NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema = 'kiroku'
+      AND table_name = 'subscriptions' AND column_name = 'target_kind')
+      AND (SELECT bool_and(consumer_group_size = 1) FROM kiroku.subscriptions)
+    """
+    E.noParams
+    (D.singleRow (D.column (D.nonNullable D.bool)))
+
+upgradedCheckpointShape :: Statement () Bool
+upgradedCheckpointShape =
+  preparable
+    """
+    SELECT (SELECT count(*) = 3 AND bool_and(target_kind = 'unbound' AND target_category IS NULL)
+      AND bool_and(consumer_group_size = CASE WHEN subscription_name = 'upgrade-group' THEN 2 ELSE 1 END)
+      FROM kiroku.subscriptions)
+      AND NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema = 'kiroku'
+        AND table_name = 'subscriptions' AND column_name = 'stream_name')
+      AND EXISTS (SELECT 1 FROM pg_indexes WHERE schemaname = 'kiroku'
+        AND indexname = 'ix_streams_browse_name')
+    """
+    E.noParams
+    (D.singleRow (D.column (D.nonNullable D.bool)))
+
 -- * The fresh-database test
 
 -- | The rewritten body still applies inside the real pg-migrate chain.
@@ -459,7 +591,7 @@
         runMigrationPlan defaultRunOptions (Settings.connectionString connStr) plan
           >>= either (assertFailure . show) pure
       let MigrationReport {results = repeatedResults} = repeated
-      length [() | MigrationResult {outcome = AlreadyApplied} <- toList repeatedResults] @?= 57
+      length [() | MigrationResult {outcome = AlreadyApplied} <- toList repeatedResults] @?= 60
       length [() | MigrationResult {outcome = AppliedNow} <- toList repeatedResults] @?= 0
       verification <-
         verifyMigrationPlan defaultRunOptions (Settings.connectionString connStr) plan
@@ -578,10 +710,10 @@
     plan <- either (fail . show) pure kiokuMigrationPlan
     let settings = Settings.connectionString connStr
     initial <- runMigrationPlan defaultRunOptions settings plan >>= either (assertFailure . show) pure
-    length (appliedNow initial) @?= 57
+    length (appliedNow initial) @?= 60
 
     baselineLedger <- query connStr fullLedgerSnapshot
-    fst baselineLedger @?= 57
+    fst baselineLedger @?= 60
     baselineSchema <- query connStr cohortSchemaSnapshotStatement
     fixup <- Text.IO.readFile ledgerFixupPath
 
@@ -607,7 +739,7 @@
 
     repeated <- runMigrationPlan defaultRunOptions settings plan >>= either (assertFailure . show) pure
     let MigrationReport {results = repeatedResults} = repeated
-    length [() | MigrationResult {outcome = AlreadyApplied} <- toList repeatedResults] @?= 57
+    length [() | MigrationResult {outcome = AlreadyApplied} <- toList repeatedResults] @?= 60
     length [() | MigrationResult {outcome = AppliedNow} <- toList repeatedResults] @?= 0
 
     withConnection connStr \conn -> run conn (Session.script fixup)
@@ -1072,12 +1204,12 @@
     full <- either (fail . show) pure kiokuMigrationPlan
 
     kirokuReport <- runMigrationPlan defaultRunOptions settings kirokuOnly >>= either (assertFailure . show) pure
-    length (appliedNow kirokuReport) @?= 12
+    length (appliedNow kirokuReport) @?= 15
     ledgerBefore <- query connStr kirokuLedgerSnapshot
 
     adoption <- runMigrationPlan defaultRunOptions settings full >>= either (assertFailure . show) pure
     let MigrationReport {results = adoptionResults} = adoption
-    length [() | MigrationResult {outcome = AlreadyApplied} <- toList adoptionResults] @?= 12
+    length [() | MigrationResult {outcome = AlreadyApplied} <- toList adoptionResults] @?= 15
     length [() | MigrationResult {outcome = AppliedNow} <- toList adoptionResults] @?= 45
 
     -- Verified and skipped, never re-executed: the stored rows keep their checksums and their
@@ -1091,7 +1223,7 @@
     verification <- verifyMigrationPlan defaultRunOptions settings full >>= either (assertFailure . show) pure
     let VerificationReport {issues = adoptionIssues, appliedMigrations, pendingMigrations, unknownMigrations} = verification
     adoptionIssues @?= []
-    length appliedMigrations @?= 57
+    length appliedMigrations @?= 60
     pendingMigrations @?= []
     unknownMigrations @?= []
 
@@ -1442,13 +1574,13 @@
     verification <- verifyMigrationPlan defaultRunOptions settings plan >>= either (assertFailure . show) pure
     let VerificationReport {issues = verificationIssues, appliedMigrations, pendingMigrations, unknownMigrations} = verification
     verificationIssues @?= []
-    length appliedMigrations @?= 57
+    length appliedMigrations @?= 60
     pendingMigrations @?= []
     unknownMigrations @?= []
 
     repeated <- runMigrationPlan defaultRunOptions settings plan >>= either (assertFailure . show) pure
     let MigrationReport {results = repeatedResults} = repeated
-    length [() | MigrationResult {outcome = AlreadyApplied} <- toList repeatedResults] @?= 57
+    length [() | MigrationResult {outcome = AlreadyApplied} <- toList repeatedResults] @?= 60
     length [() | MigrationResult {outcome = AppliedNow} <- toList repeatedResults] @?= 0
 
 testSchemaConvergence :: Assertion
@@ -1614,6 +1746,9 @@
           migrationId "kiroku" "0010",
           migrationId "kiroku" "0011",
           migrationId "kiroku" "0012",
+          migrationId "kiroku" "0013",
+          migrationId "kiroku" "0014",
+          migrationId "kiroku" "0015",
           migrationId "keiro" "0015-keiro-outbox-claim-order-index",
           migrationId "keiro" "0016-keiro-inbox-drop-received-idx",
           migrationId "keiro" "0017-schema-management-comment",
@@ -1679,7 +1814,7 @@
       (EXISTS (SELECT 1 FROM information_schema.columns
         WHERE table_schema = 'kiroku' AND table_name = 'streams' AND column_name = 'truncate_before'))::int
       + (coalesce(obj_description(to_regnamespace('kiroku'), 'pg_namespace'), '') =
-          'Managed by pg-migrate component kiroku through 0012')::int
+          'Managed by pg-migrate component kiroku through 0015')::int
       + (to_regclass('keiro.keiro_outbox_claim_order_idx') IS NOT NULL)::int
       + (to_regclass('keiro.keiro_inbox_received_idx') IS NULL)::int
       + (coalesce(obj_description(to_regnamespace('keiro'), 'pg_namespace'), '') =
diff --git a/test/fixtures/kiroku-0.6.0.0/0001-kiroku-bootstrap.sql b/test/fixtures/kiroku-0.6.0.0/0001-kiroku-bootstrap.sql
new file mode 100644
--- /dev/null
+++ b/test/fixtures/kiroku-0.6.0.0/0001-kiroku-bootstrap.sql
@@ -0,0 +1,229 @@
+-- Kiroku Store bootstrap migration (codd)
+-- Supports PostgreSQL 17+.
+--
+-- kiroku-store-migrations is the schema owner. codd applies this file
+-- verbatim, records that the timestamped migration ran, and skips it on later
+-- runs. Future schema changes add new timestamped SQL files in this directory
+-- instead of changing kiroku-store.
+--
+-- All Kiroku-owned objects live in the dedicated `kiroku` schema, leaving
+-- `public` free for application objects. Creating the schema and setting
+-- search_path first means every unqualified object name in the rest of this
+-- file resolves into the Kiroku schema.
+CREATE SCHEMA IF NOT EXISTS kiroku;
+SET search_path TO kiroku, pg_catalog;
+
+-- PostgreSQL 18 provides pg_catalog.uuidv7(); PostgreSQL 17 needs this
+-- Kiroku-schema fallback before events.event_id DEFAULT uuidv7() is parsed.
+-- With search_path set above, the unqualified CREATE FUNCTION lands in the
+-- Kiroku schema, and to_regprocedure('uuidv7()') resolves through search_path
+-- (pg_catalog first for the built-in, then the Kiroku schema for the fallback).
+DO $$
+BEGIN
+    IF to_regprocedure('pg_catalog.uuidv7()') IS NULL
+       AND to_regprocedure('uuidv7()') IS NULL THEN
+        EXECUTE $fn$
+            CREATE FUNCTION uuidv7()
+            RETURNS uuid
+            AS $body$
+            DECLARE
+                unix_ts_ms bytea;
+                uuid_bytes bytea;
+            BEGIN
+                unix_ts_ms = substring(int8send(floor(extract(epoch from clock_timestamp()) * 1000)::bigint) from 3);
+                uuid_bytes = uuid_send(gen_random_uuid());
+                uuid_bytes = overlay(uuid_bytes placing unix_ts_ms from 1 for 6);
+                uuid_bytes = set_byte(uuid_bytes, 6, (b'0111' || get_byte(uuid_bytes, 6)::bit(4))::bit(8)::int);
+                RETURN encode(uuid_bytes, 'hex')::uuid;
+            END
+            $body$
+            LANGUAGE plpgsql
+            VOLATILE
+        $fn$;
+    END IF;
+END
+$$;
+
+-- Streams (including $all as stream_id = 0)
+CREATE TABLE IF NOT EXISTS streams (
+    stream_id    BIGSERIAL    PRIMARY KEY,
+    stream_name  TEXT         NOT NULL,
+    category     TEXT         GENERATED ALWAYS AS (split_part(stream_name, '-', 1)) STORED,
+    stream_version BIGINT     NOT NULL DEFAULT 0,
+    created_at   TIMESTAMPTZ  NOT NULL DEFAULT now(),
+    deleted_at   TIMESTAMPTZ,
+    CONSTRAINT ix_streams_stream_name UNIQUE (stream_name)
+);
+
+-- Seed the $all stream
+INSERT INTO streams (stream_id, stream_name, stream_version)
+VALUES (0, '$all', 0)
+ON CONFLICT DO NOTHING;
+
+-- Reset sequence past the reserved stream_id=0
+SELECT setval('streams_stream_id_seq', GREATEST((SELECT MAX(stream_id) FROM streams), 1));
+
+-- Events (flat table — stream membership tracked in stream_events)
+CREATE TABLE IF NOT EXISTS events (
+    event_id       UUID         PRIMARY KEY DEFAULT uuidv7(),
+    event_type     TEXT         NOT NULL,
+    causation_id   UUID,
+    correlation_id UUID,
+    data           JSONB        NOT NULL,
+    metadata       JSONB,
+    created_at     TIMESTAMPTZ  NOT NULL DEFAULT now()
+);
+
+-- Stream-event junction (each event gets 2+ rows: source stream + $all + any links)
+CREATE TABLE IF NOT EXISTS stream_events (
+    event_id                UUID   NOT NULL REFERENCES events(event_id),
+    stream_id               BIGINT NOT NULL REFERENCES streams(stream_id),
+    stream_version          BIGINT NOT NULL,
+    original_stream_id      BIGINT NOT NULL,
+    original_stream_version BIGINT NOT NULL,
+    PRIMARY KEY (event_id, stream_id)
+);
+
+-- Indexes
+
+-- Primary read path: fetch events from a stream in order
+CREATE INDEX IF NOT EXISTS ix_stream_events_stream_version
+    ON stream_events (stream_id, stream_version);
+
+-- Event type filtering (for server-side subscription filtering)
+CREATE INDEX IF NOT EXISTS ix_events_event_type
+    ON events (event_type);
+
+-- Correlation tracing
+CREATE INDEX IF NOT EXISTS ix_events_correlation_id
+    ON events (correlation_id) WHERE correlation_id IS NOT NULL;
+
+-- Causation tracing
+CREATE INDEX IF NOT EXISTS ix_events_causation_id
+    ON events (causation_id) WHERE causation_id IS NOT NULL;
+
+-- Category filtering (for readCategory — uses generated column, not LIKE)
+CREATE INDEX IF NOT EXISTS ix_streams_category
+    ON streams (category);
+
+-- Category read path: find $all entries by originating stream, ordered by global position
+-- Enables efficient category reads by allowing the planner to: look up category stream_ids →
+-- index scan $all for each → merge ordered by stream_version
+CREATE INDEX IF NOT EXISTS ix_stream_events_all_by_origin
+    ON stream_events (original_stream_id, stream_version)
+    WHERE stream_id = 0;
+
+-- Subscriptions (checkpoint persistence for subscription positions).
+-- consumer_group_member / consumer_group_size carry static consumer-group
+-- topology (ExecPlan 28 / EP-1). Non-group subscriptions are member 0, size 1.
+-- The unique key is composite (subscription_name, consumer_group_member) so each
+-- group member persists its own checkpoint under one shared subscription name.
+CREATE TABLE IF NOT EXISTS subscriptions (
+    subscription_id       BIGSERIAL    PRIMARY KEY,
+    subscription_name     TEXT         NOT NULL,
+    stream_name           TEXT         NOT NULL DEFAULT '$all',
+    last_seen             BIGINT       NOT NULL DEFAULT 0,
+    consumer_group_member INT          NOT NULL DEFAULT 0,
+    consumer_group_size   INT          NOT NULL DEFAULT 1,
+    created_at            TIMESTAMPTZ  NOT NULL DEFAULT now(),
+    updated_at            TIMESTAMPTZ  NOT NULL DEFAULT now()
+);
+
+-- Idempotent convergence for databases created before EP-1: add the columns if
+-- missing, drop the old auto-named single-column unique constraint if present,
+-- and install the composite unique index. All guarded so the bootstrap body is
+-- still safe in disposable local databases; codd records the timestamped
+-- migration and does not reapply it after a successful run.
+ALTER TABLE subscriptions ADD COLUMN IF NOT EXISTS consumer_group_member INT NOT NULL DEFAULT 0;
+ALTER TABLE subscriptions ADD COLUMN IF NOT EXISTS consumer_group_size   INT NOT NULL DEFAULT 1;
+ALTER TABLE subscriptions DROP CONSTRAINT IF EXISTS subscriptions_subscription_name_key;
+CREATE UNIQUE INDEX IF NOT EXISTS ix_subscriptions_name_member
+    ON subscriptions (subscription_name, consumer_group_member);
+
+-- Triggers
+
+-- NOTIFY on stream changes (fires once per append, not per event)
+CREATE OR REPLACE FUNCTION notify_events() RETURNS TRIGGER AS $$
+BEGIN
+    PERFORM pg_notify(
+        TG_TABLE_SCHEMA || '.events',
+        NEW.stream_name || ',' || NEW.stream_id || ',' || NEW.stream_version
+    );
+    RETURN NEW;
+END;
+$$ LANGUAGE plpgsql;
+
+DROP TRIGGER IF EXISTS stream_events_notify ON streams;
+CREATE TRIGGER stream_events_notify
+    AFTER INSERT OR UPDATE ON streams
+    FOR EACH ROW EXECUTE FUNCTION notify_events();
+
+-- Immutability: prevent event mutation
+CREATE OR REPLACE FUNCTION prevent_mutation() RETURNS TRIGGER AS $$
+BEGIN
+    RAISE EXCEPTION 'Immutable table: % cannot be updated', TG_TABLE_NAME;
+END;
+$$ LANGUAGE plpgsql;
+
+DROP TRIGGER IF EXISTS no_update_events ON events;
+CREATE TRIGGER no_update_events
+    BEFORE UPDATE ON events
+    FOR EACH ROW EXECUTE FUNCTION prevent_mutation();
+
+DROP TRIGGER IF EXISTS no_update_stream_events ON stream_events;
+CREATE TRIGGER no_update_stream_events
+    BEFORE UPDATE ON stream_events
+    FOR EACH ROW EXECUTE FUNCTION prevent_mutation();
+
+-- Gated hard deletes (for maintenance/GDPR only)
+CREATE OR REPLACE FUNCTION protect_deletion() RETURNS TRIGGER AS $$
+BEGIN
+    IF current_setting('kiroku.enable_hard_deletes', true) = 'on' THEN
+        RETURN OLD;
+    END IF;
+    RAISE EXCEPTION 'Hard deletes require: SET LOCAL kiroku.enable_hard_deletes = ''on''';
+END;
+$$ LANGUAGE plpgsql;
+
+DROP TRIGGER IF EXISTS no_delete_events ON events;
+CREATE TRIGGER no_delete_events
+    BEFORE DELETE ON events
+    FOR EACH ROW EXECUTE FUNCTION protect_deletion();
+
+DROP TRIGGER IF EXISTS no_delete_stream_events ON stream_events;
+CREATE TRIGGER no_delete_stream_events
+    BEFORE DELETE ON stream_events
+    FOR EACH ROW EXECUTE FUNCTION protect_deletion();
+
+DROP TRIGGER IF EXISTS no_delete_streams ON streams;
+CREATE TRIGGER no_delete_streams
+    BEFORE DELETE ON streams
+    FOR EACH ROW EXECUTE FUNCTION protect_deletion();
+
+-- TRUNCATE bypasses row-level triggers, so the BEFORE DELETE triggers above
+-- do not protect against an operator running TRUNCATE on these tables. Add
+-- statement-level BEFORE TRUNCATE triggers gated by the same GUC so the
+-- protection is symmetric. See EP-1 F6.
+CREATE OR REPLACE FUNCTION protect_truncation() RETURNS TRIGGER AS $$
+BEGIN
+    IF current_setting('kiroku.enable_hard_deletes', true) = 'on' THEN
+        RETURN NULL;
+    END IF;
+    RAISE EXCEPTION 'TRUNCATE requires: SET LOCAL kiroku.enable_hard_deletes = ''on''';
+END;
+$$ LANGUAGE plpgsql;
+
+DROP TRIGGER IF EXISTS no_truncate_events ON events;
+CREATE TRIGGER no_truncate_events
+    BEFORE TRUNCATE ON events
+    FOR EACH STATEMENT EXECUTE FUNCTION protect_truncation();
+
+DROP TRIGGER IF EXISTS no_truncate_stream_events ON stream_events;
+CREATE TRIGGER no_truncate_stream_events
+    BEFORE TRUNCATE ON stream_events
+    FOR EACH STATEMENT EXECUTE FUNCTION protect_truncation();
+
+DROP TRIGGER IF EXISTS no_truncate_streams ON streams;
+CREATE TRIGGER no_truncate_streams
+    BEFORE TRUNCATE ON streams
+    FOR EACH STATEMENT EXECUTE FUNCTION protect_truncation();
diff --git a/test/fixtures/kiroku-0.6.0.0/0002-add-subscription-dead-letters.sql b/test/fixtures/kiroku-0.6.0.0/0002-add-subscription-dead-letters.sql
new file mode 100644
--- /dev/null
+++ b/test/fixtures/kiroku-0.6.0.0/0002-add-subscription-dead-letters.sql
@@ -0,0 +1,31 @@
+-- Add the kiroku.dead_letters table (MasterPlan 6 / EP-2, docs/plans/40-...).
+--
+-- Forward, additive migration: codd applies this file once, records it, and
+-- skips it on later runs. It does not edit the bootstrap migration and does not
+-- mutate existing event data.
+--
+-- A dead-letter row records one event that a subscription handler asked to
+-- "dead-letter" (return DeadLetter, or exhaust its bounded retry budget). The
+-- event itself stays immutable in kiroku.events; this table references it by
+-- event_id and global_position rather than copying the payload. The
+-- consumer_group_member column (default 0, matching kiroku.subscriptions)
+-- attributes the row to the member that produced it, so a consumer group's dead
+-- letters are per-member. The worker writes a row and advances the member's
+-- checkpoint in one atomic statement (see SQL.insertDeadLetterAndCheckpointStmt).
+
+CREATE TABLE IF NOT EXISTS kiroku.dead_letters (
+    dead_letter_id        BIGSERIAL    PRIMARY KEY,
+    subscription_name     TEXT         NOT NULL,
+    consumer_group_member INT          NOT NULL DEFAULT 0,
+    global_position       BIGINT       NOT NULL,
+    event_id              UUID         NOT NULL REFERENCES kiroku.events(event_id),
+    reason                JSONB        NOT NULL,
+    reason_summary        TEXT         NOT NULL,
+    attempt_count         INT          NOT NULL,
+    created_at            TIMESTAMPTZ  NOT NULL DEFAULT now(),
+    UNIQUE (subscription_name, consumer_group_member, global_position, event_id)
+);
+
+-- Operator read path: list a subscription member's dead letters by recency.
+CREATE INDEX IF NOT EXISTS ix_dead_letters_subscription_created_at
+    ON kiroku.dead_letters (subscription_name, consumer_group_member, created_at);
diff --git a/test/fixtures/kiroku-0.6.0.0/0003-notify-trigger-append-guard.sql b/test/fixtures/kiroku-0.6.0.0/0003-notify-trigger-append-guard.sql
new file mode 100644
--- /dev/null
+++ b/test/fixtures/kiroku-0.6.0.0/0003-notify-trigger-append-guard.sql
@@ -0,0 +1,26 @@
+-- Guard append notifications at the trigger level.
+--
+-- The trigger function and payload format stay unchanged:
+--   stream_name,stream_id,stream_version
+--
+-- INSERT covers newly-created streams. UPDATE covers later appends to an
+-- existing stream. Both exclude the internal $all row (stream_id = 0), and the
+-- UPDATE trigger only fires when an append advances stream_version, not for
+-- lifecycle updates such as soft-delete or undelete.
+
+DROP TRIGGER IF EXISTS stream_events_notify ON kiroku.streams;
+
+DROP TRIGGER IF EXISTS stream_events_notify_insert ON kiroku.streams;
+CREATE TRIGGER stream_events_notify_insert
+    AFTER INSERT ON kiroku.streams
+    FOR EACH ROW
+    WHEN (NEW.stream_id <> 0)
+    EXECUTE FUNCTION kiroku.notify_events();
+
+DROP TRIGGER IF EXISTS stream_events_notify_update ON kiroku.streams;
+CREATE TRIGGER stream_events_notify_update
+    AFTER UPDATE ON kiroku.streams
+    FOR EACH ROW
+    WHEN (NEW.stream_id <> 0
+          AND NEW.stream_version IS DISTINCT FROM OLD.stream_version)
+    EXECUTE FUNCTION kiroku.notify_events();
diff --git a/test/fixtures/kiroku-0.6.0.0/0004-dead-letters-event-id-index.sql b/test/fixtures/kiroku-0.6.0.0/0004-dead-letters-event-id-index.sql
new file mode 100644
--- /dev/null
+++ b/test/fixtures/kiroku-0.6.0.0/0004-dead-letters-event-id-index.sql
@@ -0,0 +1,10 @@
+-- Index dead_letters by event_id (MasterPlan 9 / EP-5, docs/plans/60-...).
+--
+-- dead_letters.event_id has a FK to kiroku.events. The UNIQUE key leads with
+-- subscription_name, so every referential-integrity check triggered by a
+-- DELETE on kiroku.events (the hard-delete path) was a sequential scan of
+-- dead_letters, and the hard-delete transaction's own dead-letter pre-delete
+-- (Kiroku.Store.SQL.deleteDeadLettersForOrphanedEventsStmt) needs the same
+-- access path.
+CREATE INDEX IF NOT EXISTS ix_dead_letters_event_id
+    ON kiroku.dead_letters (event_id);
diff --git a/test/fixtures/kiroku-0.6.0.0/0005-index-hygiene-and-streams-fillfactor.sql b/test/fixtures/kiroku-0.6.0.0/0005-index-hygiene-and-streams-fillfactor.sql
new file mode 100644
--- /dev/null
+++ b/test/fixtures/kiroku-0.6.0.0/0005-index-hygiene-and-streams-fillfactor.sql
@@ -0,0 +1,33 @@
+-- Index hygiene and $all hot-row tuning (MasterPlan 9 / EP-5, docs/plans/60-...).
+
+-- 1. ix_events_event_type is referenced by no statement in kiroku-store; it is
+--    pure write amplification on every append. Server-side event-type pushdown
+--    (see the EventTypeFilter haddock in kiroku-store) should re-add a
+--    fit-for-purpose index when it ships.
+DROP INDEX IF EXISTS kiroku.ix_events_event_type;
+
+-- 2. Stream versions are unique per stream by construction (assigned under the
+--    stream row lock; $all versions are the global position). Enforce it so a
+--    version-assignment bug surfaces as a loud 23505 instead of silent
+--    duplicates. Built as a new unique index, then the old non-unique index is
+--    dropped (an index cannot be altered to unique in place).
+CREATE UNIQUE INDEX IF NOT EXISTS ux_stream_events_stream_version
+    ON kiroku.stream_events (stream_id, stream_version);
+DROP INDEX IF EXISTS kiroku.ix_stream_events_stream_version;
+
+-- 3. readDeadLetters orders by (global_position DESC, dead_letter_id DESC) --
+--    the store's canonical, deterministic "newest first". Re-key the read
+--    index to match so the read is index-ordered instead of sorting each time.
+CREATE INDEX IF NOT EXISTS ix_dead_letters_subscription_position
+    ON kiroku.dead_letters
+       (subscription_name, consumer_group_member,
+        global_position DESC, dead_letter_id DESC);
+DROP INDEX IF EXISTS kiroku.ix_dead_letters_subscription_created_at;
+
+-- 4. The $all row (stream_id 0) is updated by every append in the database.
+--    Its updated column (stream_version) is not indexed, so updates are
+--    HOT-eligible when the page has free space; fillfactor 50 reserves that
+--    space on newly written pages. Existing pages converge through normal
+--    update/prune activity (VACUUM cannot run inside this migration's
+--    transaction). Autovacuum tuning for this table is left to operators.
+ALTER TABLE kiroku.streams SET (fillfactor = 50);
diff --git a/test/fixtures/kiroku-0.6.0.0/0006-stream-name-length-check.sql b/test/fixtures/kiroku-0.6.0.0/0006-stream-name-length-check.sql
new file mode 100644
--- /dev/null
+++ b/test/fixtures/kiroku-0.6.0.0/0006-stream-name-length-check.sql
@@ -0,0 +1,9 @@
+-- Defense-in-depth bound on stream-name length (MasterPlan 9 / EP-5,
+-- docs/plans/60-...). The Haskell store validates this before any SQL
+-- (StoreError StreamNameTooLong, maxStreamNameBytes = 512); the constraint
+-- catches writers that bypass the library (raw SQL via runTransaction,
+-- psql sessions). 512 bytes is far below pg_notify's 8,000-byte payload
+-- limit, so the append-notification trigger can never abort on payload size.
+ALTER TABLE kiroku.streams
+    ADD CONSTRAINT chk_streams_stream_name_length
+    CHECK (octet_length(stream_name) <= 512);
diff --git a/test/fixtures/kiroku-0.6.0.0/0007-stream-truncate-before.sql b/test/fixtures/kiroku-0.6.0.0/0007-stream-truncate-before.sql
new file mode 100644
--- /dev/null
+++ b/test/fixtures/kiroku-0.6.0.0/0007-stream-truncate-before.sql
@@ -0,0 +1,8 @@
+-- Logical truncate-before marker for close-the-book compaction
+-- (ExecPlan docs/plans/65). Per-stream cursor: ordered stream reads return
+-- only events whose stream_version >= truncate_before. Default 0 keeps all
+-- events (per-stream versions are 1-based). Reversible; the global $all log
+-- is never affected. UPDATE on streams is already permitted (soft-delete
+-- uses it), so no trigger/GUC changes are needed.
+ALTER TABLE kiroku.streams
+    ADD COLUMN IF NOT EXISTS truncate_before BIGINT NOT NULL DEFAULT 0;
diff --git a/test/fixtures/kiroku-0.6.0.0/0008-schema-management-comment.sql b/test/fixtures/kiroku-0.6.0.0/0008-schema-management-comment.sql
new file mode 100644
--- /dev/null
+++ b/test/fixtures/kiroku-0.6.0.0/0008-schema-management-comment.sql
@@ -0,0 +1,2 @@
+COMMENT ON SCHEMA kiroku IS
+  'Managed by pg-migrate component kiroku through 0008-schema-management-comment';
diff --git a/test/fixtures/kiroku-0.6.0.0/0009.sql b/test/fixtures/kiroku-0.6.0.0/0009.sql
new file mode 100644
--- /dev/null
+++ b/test/fixtures/kiroku-0.6.0.0/0009.sql
@@ -0,0 +1,35 @@
+-- Publish the frozen, owner-rights, structurally read-only v1 checkpoint relation.
+CREATE VIEW kiroku.subscription_checkpoints_v1
+    (subscription_name,
+     consumer_group_member,
+     checkpoint_position,
+     checkpoint_updated_at)
+WITH (security_invoker = false)
+AS
+WITH checkpoint_rows AS NOT MATERIALIZED (
+    SELECT subscription_name,
+           consumer_group_member,
+           last_seen AS checkpoint_position,
+           updated_at AS checkpoint_updated_at
+    FROM kiroku.subscriptions
+)
+SELECT subscription_name,
+       consumer_group_member,
+       checkpoint_position,
+       checkpoint_updated_at
+FROM checkpoint_rows;
+
+COMMENT ON VIEW kiroku.subscription_checkpoints_v1 IS
+  'Stable read-only v1 relation of exact persisted subscription-member checkpoints; its columns and order are frozen, and its rows are unordered unless the caller supplies ORDER BY.';
+
+COMMENT ON COLUMN kiroku.subscription_checkpoints_v1.subscription_name IS
+  'Persisted subscription name; member zero does not distinguish a non-group subscription from member zero of a consumer group.';
+
+COMMENT ON COLUMN kiroku.subscription_checkpoints_v1.consumer_group_member IS
+  'Persisted consumer-group member key; member zero carries no topology classification.';
+
+COMMENT ON COLUMN kiroku.subscription_checkpoints_v1.checkpoint_position IS
+  'Exact persisted global position for this subscription member; an explicit reset may move it backward or forward.';
+
+COMMENT ON COLUMN kiroku.subscription_checkpoints_v1.checkpoint_updated_at IS
+  'Time of the latest checkpoint-row upsert; it does not imply position advancement or worker liveness.';
diff --git a/test/fixtures/kiroku-0.6.0.0/0010.sql b/test/fixtures/kiroku-0.6.0.0/0010.sql
new file mode 100644
--- /dev/null
+++ b/test/fixtures/kiroku-0.6.0.0/0010.sql
@@ -0,0 +1,146 @@
+-- add replay history retention
+
+-- Publish kiroku.uuidv7() as the component's version-independent, always
+-- schema-qualified UUIDv7 generator.
+--
+-- PostgreSQL 18 provides pg_catalog.uuidv7(), so 0001 installed no fallback
+-- and the name exists only in pg_catalog. PostgreSQL 17 has no builtin, so
+-- 0001 installed the fallback into the Kiroku schema. Naming uuidv7()
+-- unqualified therefore resolves only through search_path, which no migration
+-- after 0001 may depend on. Establishing kiroku.uuidv7() on both versions lets
+-- this migration -- and every migration after it -- name one generator that
+-- resolves without session state on every PostgreSQL version the component
+-- supports.
+DO $$
+BEGIN
+    IF to_regprocedure('kiroku.uuidv7()') IS NOT NULL THEN
+        -- PostgreSQL 17: 0001's fallback already occupies the name.
+        RETURN;
+    END IF;
+
+    IF to_regprocedure('pg_catalog.uuidv7()') IS NULL THEN
+        RAISE EXCEPTION
+            'no uuidv7() generator: neither pg_catalog.uuidv7() nor kiroku.uuidv7() exists';
+    END IF;
+
+    -- PostgreSQL 18+: alias the builtin so the qualified name is available.
+    EXECUTE $fn$
+        CREATE FUNCTION kiroku.uuidv7()
+        RETURNS uuid
+        LANGUAGE sql
+        VOLATILE
+        AS 'SELECT pg_catalog.uuidv7()'
+    $fn$;
+END
+$$;
+
+-- A schema-local singleton row serializes lease lifecycle changes with every
+-- destructive statement without adding work to append or ordinary read paths.
+CREATE TABLE kiroku.history_retention_coordinator (
+    singleton BOOLEAN PRIMARY KEY DEFAULT TRUE,
+    CONSTRAINT chk_history_retention_coordinator_singleton CHECK (singleton)
+);
+
+INSERT INTO kiroku.history_retention_coordinator (singleton)
+VALUES (TRUE)
+ON CONFLICT DO NOTHING;
+
+-- Durable operational evidence for rebuilds that require the retained event
+-- set to remain stable. Active state is derived from released_at and database
+-- time; expiry needs no worker or cleanup mutation.
+CREATE TABLE kiroku.history_retention_leases (
+    lease_id          UUID        PRIMARY KEY DEFAULT kiroku.uuidv7(),
+    owner             TEXT        NOT NULL,
+    reason            TEXT        NOT NULL,
+    protected_through BIGINT      NOT NULL,
+    created_at        TIMESTAMPTZ NOT NULL,
+    renewed_at        TIMESTAMPTZ NOT NULL,
+    expires_at        TIMESTAMPTZ NOT NULL,
+    released_at       TIMESTAMPTZ,
+    CONSTRAINT chk_history_retention_lease_owner_bytes
+        CHECK (octet_length(owner) BETWEEN 1 AND 512),
+    CONSTRAINT chk_history_retention_lease_reason_bytes
+        CHECK (octet_length(reason) BETWEEN 1 AND 2048),
+    CONSTRAINT chk_history_retention_lease_frontier
+        CHECK (protected_through >= 0),
+    CONSTRAINT chk_history_retention_lease_renewal_time
+        CHECK (renewed_at >= created_at),
+    CONSTRAINT chk_history_retention_lease_expiry
+        CHECK (expires_at > created_at),
+    CONSTRAINT chk_history_retention_lease_release_time
+        CHECK (released_at IS NULL OR released_at >= created_at)
+);
+
+CREATE INDEX ix_history_retention_leases_unreleased_expiry
+    ON kiroku.history_retention_leases (expires_at)
+    WHERE released_at IS NULL;
+
+-- This function is attached only to DELETE and TRUNCATE. TG_TABLE_SCHEMA is
+-- quoted into each dynamic statement so a schema-qualified maintenance command
+-- cannot be redirected to a coordinator selected through the caller's
+-- search_path.
+CREATE FUNCTION kiroku.protect_replay_history_from_destruction()
+RETURNS TRIGGER
+LANGUAGE plpgsql
+AS $$
+DECLARE
+    active_count BIGINT;
+BEGIN
+    EXECUTE format(
+        'SELECT singleton FROM %I.history_retention_coordinator WHERE singleton FOR UPDATE',
+        TG_TABLE_SCHEMA
+    );
+
+    EXECUTE format(
+        'SELECT count(*) FROM %I.history_retention_leases WHERE released_at IS NULL AND expires_at > clock_timestamp()',
+        TG_TABLE_SCHEMA
+    ) INTO active_count;
+
+    IF active_count > 0 THEN
+        RAISE EXCEPTION USING
+            ERRCODE = 'KR001',
+            MESSAGE = format(
+                'replay history is protected by %s active retention lease(s)',
+                active_count
+            );
+    END IF;
+
+    RETURN NULL;
+END;
+$$;
+
+CREATE TRIGGER protect_replay_history_delete
+    BEFORE DELETE ON kiroku.events
+    FOR EACH STATEMENT
+    EXECUTE FUNCTION kiroku.protect_replay_history_from_destruction();
+
+CREATE TRIGGER protect_replay_history_truncate
+    BEFORE TRUNCATE ON kiroku.events
+    FOR EACH STATEMENT
+    EXECUTE FUNCTION kiroku.protect_replay_history_from_destruction();
+
+CREATE TRIGGER protect_replay_history_delete
+    BEFORE DELETE ON kiroku.stream_events
+    FOR EACH STATEMENT
+    EXECUTE FUNCTION kiroku.protect_replay_history_from_destruction();
+
+CREATE TRIGGER protect_replay_history_truncate
+    BEFORE TRUNCATE ON kiroku.stream_events
+    FOR EACH STATEMENT
+    EXECUTE FUNCTION kiroku.protect_replay_history_from_destruction();
+
+CREATE TRIGGER protect_replay_history_delete
+    BEFORE DELETE ON kiroku.streams
+    FOR EACH STATEMENT
+    EXECUTE FUNCTION kiroku.protect_replay_history_from_destruction();
+
+CREATE TRIGGER protect_replay_history_truncate
+    BEFORE TRUNCATE ON kiroku.streams
+    FOR EACH STATEMENT
+    EXECUTE FUNCTION kiroku.protect_replay_history_from_destruction();
+
+COMMENT ON TABLE kiroku.history_retention_leases IS
+  'Durable, expiring replay-history retention leases; rows are operational evidence and remain until explicitly pruned.';
+
+COMMENT ON SCHEMA kiroku IS
+  'Managed by pg-migrate component kiroku through 0010';
diff --git a/test/fixtures/kiroku-0.6.0.0/0011.sql b/test/fixtures/kiroku-0.6.0.0/0011.sql
new file mode 100644
--- /dev/null
+++ b/test/fixtures/kiroku-0.6.0.0/0011.sql
@@ -0,0 +1,51 @@
+-- converge databases that applied the withdrawn 0.3.2.x payload of 0010
+
+-- kiroku-store-migrations 0.3.2.0 and 0.3.2.1 shipped a payload of 0010 whose
+-- lease_id default named uuidv7() unqualified. That payload could not parse on
+-- PostgreSQL 17 outside the bootstrap session (BUG-1), and where it did apply
+-- -- PostgreSQL 18, or a fresh PostgreSQL 17 install -- it left the schema in a
+-- state the corrected 0010 does not produce: no kiroku.uuidv7(), and a lease_id
+-- default bound to whichever uuidv7() the applying session happened to resolve.
+--
+-- The corrected 0010 cannot reach those databases: its ledger row is already
+-- present, and re-baselining that row (see ledger-fixups/) restores the
+-- checksum without replaying the SQL. This migration is what makes both
+-- populations converge, and it is a no-op on any database that applied the
+-- corrected 0010.
+
+-- 1. kiroku.uuidv7() exists on every supported major version. Identical to the
+--    header of the corrected 0010, and skipped there because 0010 ran first.
+DO $$
+BEGIN
+    IF to_regprocedure('kiroku.uuidv7()') IS NOT NULL THEN
+        -- PostgreSQL 17, or a database that applied the corrected 0010.
+        RETURN;
+    END IF;
+
+    IF to_regprocedure('pg_catalog.uuidv7()') IS NULL THEN
+        RAISE EXCEPTION
+            'no uuidv7() generator: neither pg_catalog.uuidv7() nor kiroku.uuidv7() exists';
+    END IF;
+
+    EXECUTE $fn$
+        CREATE FUNCTION kiroku.uuidv7()
+        RETURNS uuid
+        LANGUAGE sql
+        VOLATILE
+        AS 'SELECT pg_catalog.uuidv7()'
+    $fn$;
+END
+$$;
+
+-- 2. Bind lease_id's default to the qualified generator. A column default is
+--    stored as a resolved function OID, so a database that applied the
+--    withdrawn payload on PostgreSQL 18 holds pg_catalog.uuidv7() here. Both
+--    generators produce the same values; this only makes the stored default
+--    match what the corrected 0010 installs, so a dumped schema is identical
+--    whichever payload the database applied. Existing lease rows are untouched
+--    and no table is rewritten.
+ALTER TABLE kiroku.history_retention_leases
+    ALTER COLUMN lease_id SET DEFAULT kiroku.uuidv7();
+
+COMMENT ON SCHEMA kiroku IS
+  'Managed by pg-migrate component kiroku through 0011';
diff --git a/test/fixtures/kiroku-0.6.0.0/0012.sql b/test/fixtures/kiroku-0.6.0.0/0012.sql
new file mode 100644
--- /dev/null
+++ b/test/fixtures/kiroku-0.6.0.0/0012.sql
@@ -0,0 +1,51 @@
+-- denormalize category onto $all junction rows for category reads
+
+-- Category reads (readCategoryForwardSQL and its consumer-group variant in
+-- kiroku-store) used to start from every stream of the category and probe
+-- stream_events once per stream, so a caught-up poll cost work proportional to
+-- the number of streams ever written in the category (BUG-2). Carrying the
+-- originating stream's category on each $all junction row lets both reads run
+-- as one index range scan from (category, checkpoint) that stops at the limit.
+--
+-- The column is populated only on $all rows (stream_id = 0). Home rows and link
+-- rows keep it NULL; nothing reads them by category. The CHECK below makes any
+-- inserter that forgets the column fail loudly instead of writing rows that a
+-- category read cannot see.
+--
+-- The whole file runs in one transaction. The backfill rewrites every $all row
+-- and the index build blocks writes, so appends wait for the duration; apply it
+-- in a maintenance window on a large store and VACUUM (ANALYZE)
+-- kiroku.stream_events afterwards.
+
+ALTER TABLE kiroku.stream_events
+    ADD COLUMN category TEXT;
+
+COMMENT ON COLUMN kiroku.stream_events.category IS
+  'Originating stream''s category, present on $all rows (stream_id = 0) only; equals streams.category of original_stream_id.';
+
+-- Backfill every existing $all row from its originating stream. The immutability
+-- trigger rejects every UPDATE on this table, so it is suspended for this one
+-- statement and re-enabled before the transaction ends. Runs as the table owner.
+ALTER TABLE kiroku.stream_events DISABLE TRIGGER no_update_stream_events;
+
+UPDATE kiroku.stream_events AS se
+SET category = s.category
+FROM kiroku.streams AS s
+WHERE se.stream_id = 0
+  AND s.stream_id = se.original_stream_id;
+
+ALTER TABLE kiroku.stream_events ENABLE TRIGGER no_update_stream_events;
+
+ALTER TABLE kiroku.stream_events
+    ADD CONSTRAINT ck_stream_events_all_category
+    CHECK (stream_id <> 0 OR category IS NOT NULL);
+
+-- Category read path: rows of one category in global-position order. The
+-- INCLUDE column lets the consumer-group hash predicate run on index tuples.
+CREATE INDEX ix_stream_events_all_by_category
+    ON kiroku.stream_events (category, stream_version)
+    INCLUDE (original_stream_id)
+    WHERE stream_id = 0;
+
+COMMENT ON SCHEMA kiroku IS
+  'Managed by pg-migrate component kiroku through 0012';
diff --git a/test/fixtures/kiroku-0.6.0.0/README.md b/test/fixtures/kiroku-0.6.0.0/README.md
new file mode 100644
--- /dev/null
+++ b/test/fixtures/kiroku-0.6.0.0/README.md
@@ -0,0 +1,18 @@
+# Released previous-cohort migration fixture
+
+These are verbatim migration bytes from
+mori://shinzui/kiroku/packages/kiroku-store-migrations version 0.6.0.0,
+project-relative `kiroku-store-migrations/migrations/` (artifact-level SQL URIs pending).
+The authoritative Hackage archive is
+https://hackage.haskell.org/package/kiroku-store-migrations-0.6.0.0/kiroku-store-migrations-0.6.0.0.tar.gz
+with SHA-256 `8c0d8e9b98e39c332457a0d34d37c8e909e3c2618dc6d334b6a16cc98fc353de`.
+Every payload was compared byte-for-byte with upstream tag
+`kiroku-store-migrations-v0.6.0.0`, peeled commit
+`691c6efed78ca6bb344c9cac5d122eb5ca4980b2`, on 2026-10-13.
+`SHA256SUMS` freezes each payload; the test checks these before composing the old plan.
+
+This fixture is solely for disposable database evolution. Its comments and SQL
+are preserved verbatim. Do not edit it to repair a new migration failure.
+Keiro's 32 and Kioku's 13 payloads are unchanged across this upgrade; the fixture
+composes those current components after this genuine 12-entry Kiroku component.
+It is separate from the older 30-entry Codd cutover fixture.
diff --git a/test/fixtures/kiroku-0.6.0.0/SHA256SUMS b/test/fixtures/kiroku-0.6.0.0/SHA256SUMS
new file mode 100644
--- /dev/null
+++ b/test/fixtures/kiroku-0.6.0.0/SHA256SUMS
@@ -0,0 +1,12 @@
+af7c777f8702d9b5bce6b3ea16540ab7542bc78725cb45e945c6d5b6025a4d66  0001-kiroku-bootstrap.sql
+2771cdd2605a8808ea469c0bcba175c8ba4c5d2465f43eb05c56edb188fa8141  0002-add-subscription-dead-letters.sql
+432796c59389d45478ba773b62e9f38d0a562807061eb49280d295ec83248351  0003-notify-trigger-append-guard.sql
+f698db092ebf34beeddecc949bbd506afc0b9c63d3e9983ad5d14f203f2ee1cd  0004-dead-letters-event-id-index.sql
+80c28c7743f1d9967eadf921c0509fc8477db16500466bf7a49c5f59aadb0fa2  0005-index-hygiene-and-streams-fillfactor.sql
+a0e4338a2feadf8f01499a3809d3de78966966689b095ea46195c4d07743e4aa  0006-stream-name-length-check.sql
+a4fe42ad8702b43c3d6f72edf263d6b301e783493d49f40a35dd14d2a7928fa7  0007-stream-truncate-before.sql
+3fb3dd5a7172da3cd14edecb0877cc9963fc9a6dceaff9ed41705a2365070f70  0008-schema-management-comment.sql
+62c80d627dc8f17393d26ff7dbc00cabde11ed38ee7bf3cb1ae42ea89f484c74  0009.sql
+debc19187d79cd263be66c6c85cd789c1176e508d562a27390095aaa70f650c4  0010.sql
+bf8b76a712a31cb602aee793a11691283ccd1eafdc16224e45e14484512b5af7  0011.sql
+32a0a0c877ff1aa6c1f74de8db62c74884e96a9c7500b2b65725b9e654101a7a  0012.sql
diff --git a/test/fixtures/kiroku-0.6.0.0/manifest b/test/fixtures/kiroku-0.6.0.0/manifest
new file mode 100644
--- /dev/null
+++ b/test/fixtures/kiroku-0.6.0.0/manifest
@@ -0,0 +1,12 @@
+0001-kiroku-bootstrap.sql
+0002-add-subscription-dead-letters.sql
+0003-notify-trigger-append-guard.sql
+0004-dead-letters-event-id-index.sql
+0005-index-hygiene-and-streams-fillfactor.sql
+0006-stream-name-length-check.sql
+0007-stream-truncate-before.sql
+0008-schema-management-comment.sql
+0009.sql
+0010.sql
+0011.sql
+0012.sql
