<?xml version="1.0" encoding="UTF-8"?>
<feed xmlns="http://www.w3.org/2005/Atom" xml:lang="en">
    <title>The Keycloak Upgrade Ledger</title>
    <subtitle>What breaks on Keycloak upgrades, measured in rehearsal. Advisory coverage, migration timings, and the corrections.</subtitle>
    <link rel="self" type="application/atom+xml" href="https://ledger.mlabs.city/atom.xml"/>
    <link rel="alternate" type="text/html" href="https://ledger.mlabs.city"/>
    <generator uri="https://www.getzola.org/">Zola</generator>
    <updated>2026-09-01T00:00:00+00:00</updated>
    <id>https://ledger.mlabs.city/atom.xml</id>
    <entry xml:lang="en">
        <title>Rehearsal Record — FGAP-v2 activation path pinned (26.7.3)</title>
        <published>2026-09-01T00:00:00+00:00</published>
        <updated>2026-09-01T00:00:00+00:00</updated>
        
        <author>
          <name>Unknown</name>
        </author>
        
        <link rel="alternate" type="text/html" href="https://ledger.mlabs.city/findings/2026-09-01-fgap-v2-activation/"/>
        <id>https://ledger.mlabs.city/findings/2026-09-01-fgap-v2-activation/</id>
        
        <content type="html" xml:base="https://ledger.mlabs.city/findings/2026-09-01-fgap-v2-activation/">&lt;h2 id=&quot;question&quot;&gt;Question&lt;/h2&gt;
&lt;p&gt;&lt;a href=&quot;/findings/2026-08-31-fgap-v2-token-exchange/&quot;&gt;&lt;code&gt;2026-08-31-fgap-v2-token-exchange&lt;/code&gt;&lt;/a&gt; left this open: the pre-26.7 procedure
(&lt;code&gt;adminFineGrainedAuthz&lt;/code&gt; realm field + &lt;code&gt;GET /admin/realms/{realm}/fine-grained-permissions&lt;/code&gt;)
is dead in 26.7.3 — the field is unrecognized and the endpoint 404s — while the
&lt;code&gt;ADMIN_FINE_GRAINED_AUTHZ_V2&lt;/code&gt; feature flag reports &lt;code&gt;type=DEFAULT, enabled=true&lt;/code&gt;.
How does 26.7.3 actually turn FGAP v2 on, and where is the permission catalog?&lt;/p&gt;
&lt;h2 id=&quot;answer-pinned-from-bytecode-verified-live&quot;&gt;Answer (pinned from bytecode + verified live)&lt;/h2&gt;
&lt;p&gt;&lt;strong&gt;FGAP v2 is a DEFAULT feature and activates per-realm with the classic
&lt;code&gt;adminPermissionsEnabled&lt;/code&gt; flag.&lt;/strong&gt; No &lt;code&gt;--features&lt;/code&gt; gate, no &lt;code&gt;adminFineGrainedAuthz&lt;/code&gt;
field.&lt;/p&gt;
&lt;p&gt;Bytecode path (jars extracted from &lt;code&gt;quay.io/keycloak/keycloak:26.7.3&lt;/code&gt;):&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;&lt;code&gt;org.keycloak.services.resources.admin.fgap.AdminPermissions.evaluator(...)&lt;/code&gt;
branches on &lt;code&gt;Profile.isFeatureEnabled(ADMIN_FINE_GRAINED_AUTHZ_V2)&lt;/code&gt; —
v2 selects &lt;code&gt;MgmtPermissionsV2&lt;/code&gt;, v1 selects &lt;code&gt;MgmtPermissions&lt;/code&gt;. The flag is
&lt;code&gt;type=DEFAULT&lt;/code&gt; in 26.7.3, so v2 is the only line in play.&lt;/li&gt;
&lt;li&gt;&lt;code&gt;org.keycloak.authorization.fgap.AdminPermissionsSchema.init(session, realm)&lt;/code&gt;
(spi-private) runs when the realm has admin permissions enabled. It creates a
client with clientId &lt;strong&gt;&lt;code&gt;admin-permissions&lt;/code&gt;&lt;/strong&gt; (openid-connect), sets it as the
realm&#39;s &lt;code&gt;adminPermissionsClient&lt;/code&gt;, creates its authorization resource server,
and seeds the schema:
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;resources&lt;/strong&gt; = resource types: &lt;code&gt;Users&lt;/code&gt;, &lt;code&gt;Groups&lt;/code&gt;, &lt;code&gt;Roles&lt;/code&gt;, &lt;code&gt;Clients&lt;/code&gt;,
&lt;code&gt;Organizations&lt;/code&gt; (&lt;code&gt;Realms&lt;/code&gt; is handled cross-realm);&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;scopes&lt;/strong&gt; = the permission catalogue.&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;/ol&gt;
&lt;h3 id=&quot;live-verification-26-7-3-postgres-compose-lab&quot;&gt;Live verification (26.7.3, Postgres, Compose lab)&lt;/h3&gt;
&lt;p&gt;&lt;code&gt;POST /admin/realms {&quot;realm&quot;:&quot;fgap&quot;,&quot;adminPermissionsEnabled&quot;:true}&lt;/code&gt; → then:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;GET /admin/realms/fgap            adminPermissionsEnabled: true
                                  adminPermissionsClient: {clientId: &amp;quot;admin-permissions&amp;quot;}
                                  &amp;quot;adminFineGrainedAuthz&amp;quot; NOT in representation

GET /admin/realms/fgap/clients    -&amp;gt; includes &amp;quot;admin-permissions&amp;quot; (authzServicesEnabled)
GET .../authz/resource-server/resource -&amp;gt; Clients, Groups, Organizations, Roles, Users
GET .../authz/resource-server/scope    -&amp;gt; 16 permission scopes (below)
GET /admin/realms/fgap/fine-grained-permissions        -&amp;gt; 404
GET /admin/realms/fgap/admin-fine-grained-permissions  -&amp;gt; 404
&lt;/code&gt;&lt;/pre&gt;
&lt;h3 id=&quot;the-v2-permission-catalogue-scopes-on-the-admin-permissions-client&quot;&gt;The v2 permission catalogue (scopes on the &lt;code&gt;admin-permissions&lt;/code&gt; client)&lt;/h3&gt;
&lt;p&gt;&lt;code&gt;view&lt;/code&gt;, &lt;code&gt;manage&lt;/code&gt;, &lt;code&gt;manage-members&lt;/code&gt;, &lt;code&gt;view-members&lt;/code&gt;, &lt;code&gt;manage-membership&lt;/code&gt;,
&lt;code&gt;manage-membership-of-members&lt;/code&gt;, &lt;code&gt;manage-group-membership&lt;/code&gt;, &lt;code&gt;map-roles&lt;/code&gt;,
&lt;code&gt;map-roles-client-scope&lt;/code&gt;, &lt;code&gt;map-roles-composite&lt;/code&gt;, &lt;code&gt;map-role&lt;/code&gt;,
&lt;code&gt;map-role-client-scope&lt;/code&gt;, &lt;code&gt;map-role-composite&lt;/code&gt;, &lt;code&gt;impersonate&lt;/code&gt;,
&lt;code&gt;impersonate-members&lt;/code&gt;, &lt;code&gt;reset-password&lt;/code&gt;.&lt;/p&gt;
&lt;h2 id=&quot;what-changed-vs-pre-26-7-migration-surfacing&quot;&gt;What changed vs pre-26.7 (migration-surfacing)&lt;/h2&gt;
&lt;table&gt;&lt;thead&gt;&lt;tr&gt;&lt;th&gt;Thing&lt;/th&gt;&lt;th&gt;pre-26.7 (v1)&lt;/th&gt;&lt;th&gt;26.7.3 (v2)&lt;/th&gt;&lt;/tr&gt;&lt;/thead&gt;&lt;tbody&gt;
&lt;tr&gt;&lt;td&gt;Feature gate&lt;/td&gt;&lt;td&gt;&lt;code&gt;--features=admin-fine-grained-authz&lt;/code&gt; (preview)&lt;/td&gt;&lt;td&gt;&lt;code&gt;ADMIN_FINE_GRAINED_AUTHZ_V2&lt;/code&gt;, &lt;code&gt;type=DEFAULT&lt;/code&gt;&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Realm activation&lt;/td&gt;&lt;td&gt;&lt;code&gt;adminFineGrainedAuthz&lt;/code&gt; field&lt;/td&gt;&lt;td&gt;&lt;code&gt;adminPermissionsEnabled&lt;/code&gt; flag&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Permissions client&lt;/td&gt;&lt;td&gt;&lt;code&gt;realm-management&lt;/code&gt; (managed)&lt;/td&gt;&lt;td&gt;&lt;strong&gt;&lt;code&gt;admin-permissions&lt;/code&gt;&lt;/strong&gt; (new, schema-seeded)&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Permission catalogue&lt;/td&gt;&lt;td&gt;&lt;code&gt;GET .../fine-grained-permissions&lt;/code&gt;&lt;/td&gt;&lt;td&gt;resources+scopes on &lt;code&gt;admin-permissions&lt;/code&gt;&#39;s resource server&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Grant a permission&lt;/td&gt;&lt;td&gt;v1 policy API&lt;/td&gt;&lt;td&gt;standard authz scope-permission API on &lt;code&gt;admin-permissions&lt;/code&gt;&lt;/td&gt;&lt;/tr&gt;
&lt;/tbody&gt;&lt;/table&gt;
&lt;h2 id=&quot;how-to-audit-drive-fgap-v2-in-26-7-3-the-new-procedure&quot;&gt;How to audit / drive FGAP v2 in 26.7.3 (the new procedure)&lt;/h2&gt;
&lt;ol&gt;
&lt;li&gt;&lt;code&gt;PUT/POST /admin/realms/{realm}&lt;/code&gt; with &lt;code&gt;{&quot;adminPermissionsEnabled&quot;: true}&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;Read &lt;code&gt;adminPermissionsClient&lt;/code&gt; from the realm representation to get the
&lt;code&gt;admin-permissions&lt;/code&gt; client id.&lt;/li&gt;
&lt;li&gt;Catalogue: &lt;code&gt;GET /admin/realms/{realm}/clients/{id}/authz/resource-server/resource&lt;/code&gt;
(resource types) and &lt;code&gt;.../scope&lt;/code&gt; (permissions).&lt;/li&gt;
&lt;li&gt;Grant a permission to a role/group: create a scope-based permission policy on
the &lt;code&gt;admin-permissions&lt;/code&gt; client (&lt;code&gt;.../authz/resource-server/permission/scope&lt;/code&gt;),
assigning the role/group policy — the standard authorization-services API.&lt;/li&gt;
&lt;/ol&gt;
&lt;h2 id=&quot;what-this-unblocks&quot;&gt;What this unblocks&lt;/h2&gt;
&lt;p&gt;The FGAP-v2 RBAC CVE reproductions no longer need the dead v1 activation:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;role-groups bypass — CVE-2026-14613&lt;/li&gt;
&lt;li&gt;composite-endpoint authz — CVE-2026-16105&lt;/li&gt;
&lt;li&gt;default-group disclosure — CVE-2026-16108&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Each can now be set up by (a) enabling &lt;code&gt;adminPermissionsEnabled&lt;/code&gt;, (b) building the
role/group + scope-permission on &lt;code&gt;admin-permissions&lt;/code&gt;, (c) exercising the admin
endpoint under test. Still deferred: token-exchange CVEs (CVE-2026-18215/18214,
external IdP) and CVE-2026-79652 (jwt-bearer &lt;code&gt;consentRequired&lt;/code&gt;).&lt;/p&gt;
&lt;h2 id=&quot;verification&quot;&gt;Verification&lt;/h2&gt;
&lt;table&gt;&lt;thead&gt;&lt;tr&gt;&lt;th&gt;Claim&lt;/th&gt;&lt;th&gt;Evidence&lt;/th&gt;&lt;/tr&gt;&lt;/thead&gt;&lt;tbody&gt;
&lt;tr&gt;&lt;td&gt;v2 is the active line, DEFAULT feature&lt;/td&gt;&lt;td&gt;&lt;code&gt;AdminPermissions&lt;/code&gt; bytecode + serverinfo flag &lt;code&gt;enabled=true&lt;/code&gt; ☑&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Activation is &lt;code&gt;adminPermissionsEnabled&lt;/code&gt;&lt;/td&gt;&lt;td&gt;live realm rep + &lt;code&gt;admin-permissions&lt;/code&gt; client created ☑&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Catalogue = resources+scopes on &lt;code&gt;admin-permissions&lt;/code&gt;&lt;/td&gt;&lt;td&gt;live &lt;code&gt;resource&lt;/code&gt; (5 types) + &lt;code&gt;scope&lt;/code&gt; (16) responses ☑&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;v1 surface removed&lt;/td&gt;&lt;td&gt;&lt;code&gt;adminFineGrainedAuthz&lt;/code&gt; absent; both catalog endpoints 404 ☑&lt;/td&gt;&lt;/tr&gt;
&lt;/tbody&gt;&lt;/table&gt;
</content>
        
    </entry>
    <entry xml:lang="en">
        <title>Rehearsal Record — Postgres HA: sync replication + clean (promote-while-up) failover</title>
        <published>2026-09-01T00:00:00+00:00</published>
        <updated>2026-09-01T00:00:00+00:00</updated>
        
        <author>
          <name>Unknown</name>
        </author>
        
        <link rel="alternate" type="text/html" href="https://ledger.mlabs.city/findings/2026-09-01-ha-sync-and-clean-failover/"/>
        <id>https://ledger.mlabs.city/findings/2026-09-01-ha-sync-and-clean-failover/</id>
        
        <content type="html" xml:base="https://ledger.mlabs.city/findings/2026-09-01-ha-sync-and-clean-failover/">&lt;h2 id=&quot;summary&quot;&gt;Summary&lt;/h2&gt;
&lt;p&gt;This closes the last two untested legs of the Postgres HA line
(&lt;a href=&quot;/runs/2026-08-26-ha-failover-mid-migration/&quot;&gt;&lt;code&gt;2026-08-26-ha-failover-mid-migration&lt;/code&gt;&lt;/a&gt; covered the crash case). Two results:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;Synchronous replication works as advertised.&lt;/strong&gt; With
&lt;code&gt;synchronous_commit=remote_apply&lt;/code&gt; + &lt;code&gt;synchronous_standby_names=*&lt;/code&gt; the replica
reports &lt;code&gt;sync_state=sync&lt;/code&gt;, idle replay lag ~44 ms right after the clone
settling to &lt;strong&gt;~0.7 ms&lt;/strong&gt; in steady state, and a committed realm write is
&lt;strong&gt;immediately visible on the hot standby&lt;/strong&gt; — the primary does not ack until
the standby has applied the WAL.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;A clean failover has a different failure shape than a crash.&lt;/strong&gt; Promoting
the replica while the primary is &lt;em&gt;alive&lt;/em&gt; leaves the old primary running with
sync replication but &lt;strong&gt;no standby&lt;/strong&gt; — so its writes &lt;strong&gt;block indefinitely&lt;/strong&gt;
(the probe &lt;code&gt;CREATE TABLE&lt;/code&gt; never completed). That is a safety property (the
old primary cannot accept split-brain writes), but it means the old primary
&lt;em&gt;wedges&lt;/em&gt; until stopped. The switchover must therefore be: promote → repoint
Keycloak → stop the old primary, and an operator who promotes first without
quiescing writes will find the old primary hung, not failed.&lt;/li&gt;
&lt;/ol&gt;
&lt;h2 id=&quot;environment&quot;&gt;Environment&lt;/h2&gt;
&lt;table&gt;&lt;thead&gt;&lt;tr&gt;&lt;th&gt;Field&lt;/th&gt;&lt;th&gt;Value&lt;/th&gt;&lt;/tr&gt;&lt;/thead&gt;&lt;tbody&gt;
&lt;tr&gt;&lt;td&gt;Keycloak&lt;/td&gt;&lt;td&gt;26.7.3 (official image), &lt;code&gt;start&lt;/code&gt; w/ external DB&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Database&lt;/td&gt;&lt;td&gt;PostgreSQL 16, &lt;strong&gt;primary + synchronous streaming replica&lt;/strong&gt;, containers&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Replication&lt;/td&gt;&lt;td&gt;&lt;code&gt;wal_level=replica&lt;/code&gt;, &lt;code&gt;synchronous_commit=remote_apply&lt;/code&gt;, &lt;code&gt;synchronous_standby_names=*&lt;/code&gt;, streaming via &lt;code&gt;pg_basebackup -X stream&lt;/code&gt;&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Topology&lt;/td&gt;&lt;td&gt;1 primary (&lt;code&gt;db&lt;/code&gt;) + 1 replica (&lt;code&gt;db-replica&lt;/code&gt;); Keycloak single container&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Dataset&lt;/td&gt;&lt;td&gt;3 realms (&lt;code&gt;master&lt;/code&gt;, &lt;code&gt;lab&lt;/code&gt;, &lt;code&gt;ha-sync&lt;/code&gt;), seeded in-place&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Host&lt;/td&gt;&lt;td&gt;laptop (Docker 29, Compose 5.5.0)&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;JVM heap&lt;/td&gt;&lt;td&gt;&lt;code&gt;-Xms1g -Xmx4g&lt;/code&gt;&lt;/td&gt;&lt;/tr&gt;
&lt;/tbody&gt;&lt;/table&gt;
&lt;h2 id=&quot;procedure&quot;&gt;Procedure&lt;/h2&gt;
&lt;pre&gt;&lt;code&gt;COMPOSE_FILE=docker-compose.yml:docker-compose.ha.yml
HA_SYNC_COMMIT=remote_apply HA_SYNC_STANDBYS=&amp;#39;*&amp;#39;
docker compose up -d db                       # primary, sync settings
./bin/ha-init-replica.sh                       # clone + start standby
docker compose up -d keycloak                  # writes now ack only via replica
# create a realm to prove write visibility on the standby
POST /admin/realms {&amp;quot;realm&amp;quot;:&amp;quot;ha-sync&amp;quot;}

# clean failover: promote while the primary is STILL UP
docker compose exec -u postgres db-replica pg_ctl promote -D /var/lib/postgresql/data
# probe: write to the OLD primary (sync, no standby) -&amp;gt; BLOCKS, CREATE TABLE never commits

# switchover
sed KC_DB_URL -&amp;gt; jdbc:postgresql://db-replica:5432/keycloak
docker compose up -d --force-recreate keycloak  # repoint
docker compose stop db                          # break split brain
&lt;/code&gt;&lt;/pre&gt;
&lt;h2 id=&quot;results&quot;&gt;Results&lt;/h2&gt;
&lt;h3 id=&quot;sync-replication-steady-state&quot;&gt;Sync replication (steady state)&lt;/h3&gt;
&lt;table&gt;&lt;thead&gt;&lt;tr&gt;&lt;th&gt;Signal&lt;/th&gt;&lt;th&gt;Value&lt;/th&gt;&lt;/tr&gt;&lt;/thead&gt;&lt;tbody&gt;
&lt;tr&gt;&lt;td&gt;&lt;code&gt;pg_stat_replication.sync_state&lt;/code&gt;&lt;/td&gt;&lt;td&gt;&lt;strong&gt;sync&lt;/strong&gt; (priority 1)&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;replay lag right after clone&lt;/td&gt;&lt;td&gt;~44 ms&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;replay lag steady state (after a write)&lt;/td&gt;&lt;td&gt;&lt;strong&gt;~0.7 ms&lt;/strong&gt;&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;realm write visible on standby&lt;/td&gt;&lt;td&gt;&lt;code&gt;ha-sync&lt;/code&gt; present on the replica immediately ☑&lt;/td&gt;&lt;/tr&gt;
&lt;/tbody&gt;&lt;/table&gt;
&lt;h3 id=&quot;clean-failover-promote-while-up&quot;&gt;Clean failover (promote-while-up)&lt;/h3&gt;
&lt;table&gt;&lt;thead&gt;&lt;tr&gt;&lt;th&gt;Step&lt;/th&gt;&lt;th&gt;Observation&lt;/th&gt;&lt;/tr&gt;&lt;/thead&gt;&lt;tbody&gt;
&lt;tr&gt;&lt;td&gt;&lt;code&gt;pg_ctl promote&lt;/code&gt; on replica&lt;/td&gt;&lt;td&gt;promoted cleanly, out of recovery&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Old primary (alive) after promotion&lt;/td&gt;&lt;td&gt;still reports itself a primary (&lt;code&gt;pg_is_in_recovery()=f&lt;/code&gt;)&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Write to old primary&lt;/td&gt;&lt;td&gt;&lt;strong&gt;blocked&lt;/strong&gt; — &lt;code&gt;CREATE TABLE sync_block_probe&lt;/code&gt; never committed (&lt;code&gt;pg_tables&lt;/code&gt; count 0); session hung waiting for a sync standby&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Repoint Keycloak → replica&lt;/td&gt;&lt;td&gt;ready; realms &lt;code&gt;ha-sync&lt;/code&gt;, &lt;code&gt;lab&lt;/code&gt;, &lt;code&gt;master&lt;/code&gt; all present ☑&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Stop old primary&lt;/td&gt;&lt;td&gt;split brain broken; single primary remains&lt;/td&gt;&lt;/tr&gt;
&lt;/tbody&gt;&lt;/table&gt;
&lt;h2 id=&quot;what-this-teaches&quot;&gt;What this teaches&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Sync replication is the &quot;no data loss at failover&quot; configuration&lt;/strong&gt;: a commit
is not acknowledged until the standby has &lt;em&gt;applied&lt;/em&gt; it, so the failover target
is always current (contrast the async crash case, where the standby is a
committed-prefix snapshot with possible lag).&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;The clean failover is operator-hazardous in the other direction.&lt;/strong&gt; The crash
case is bounded data loss; the clean case is a &lt;em&gt;wedge&lt;/em&gt;. Promote the replica
while the primary lives, and with sync replication the old primary blocks on
every subsequent write — silent, indefinite, and easy to mistake for a healthy
node that just &quot;went slow&quot;. The pre-flight for a maintenance failover is: &lt;strong&gt;stop
the old primary (or quiesce all writers) &lt;em&gt;before&lt;/em&gt; promoting the replica.&lt;/strong&gt;&lt;/li&gt;
&lt;li&gt;The switchover order the lab already uses (promote → repoint → stop old
primary) is correct for async; for sync it leaves a brief window where the old
primary is wedged rather than split-brained — safer, but the &lt;code&gt;depends_on&lt;/code&gt;
gotcha from the crash record still applies.&lt;/li&gt;
&lt;/ul&gt;
&lt;h2 id=&quot;verification&quot;&gt;Verification&lt;/h2&gt;
&lt;table&gt;&lt;thead&gt;&lt;tr&gt;&lt;th&gt;Claim&lt;/th&gt;&lt;th&gt;Evidence&lt;/th&gt;&lt;/tr&gt;&lt;/thead&gt;&lt;tbody&gt;
&lt;tr&gt;&lt;td&gt;Replica is a synchronous standby&lt;/td&gt;&lt;td&gt;&lt;code&gt;pg_stat_replication&lt;/code&gt;: &lt;code&gt;sync_state=sync&lt;/code&gt;, &lt;code&gt;sync_priority=1&lt;/code&gt; ☑&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Primary does not ack before standby applies&lt;/td&gt;&lt;td&gt;realm write visible on replica, replay lag ~0.7 ms ☑&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Old primary blocks writes after promote&lt;/td&gt;&lt;td&gt;&lt;code&gt;CREATE TABLE&lt;/code&gt; probe never committed; session hung ☑&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Keycloak resumes on promoted replica&lt;/td&gt;&lt;td&gt;ready; &lt;code&gt;ha-sync&lt;/code&gt;/&lt;code&gt;lab&lt;/code&gt;/&lt;code&gt;master&lt;/code&gt; realms intact ☑&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;No split brain at end&lt;/td&gt;&lt;td&gt;old primary stopped; one primary (&lt;code&gt;db-replica&lt;/code&gt;) ☑&lt;/td&gt;&lt;/tr&gt;
&lt;/tbody&gt;&lt;/table&gt;
</content>
        
    </entry>
    <entry xml:lang="en">
        <title>Rehearsal Record — seed-sql.sh ported to MySQL and MariaDB (2M-class seeder)</title>
        <published>2026-09-01T00:00:00+00:00</published>
        <updated>2026-09-01T00:00:00+00:00</updated>
        
        <author>
          <name>Unknown</name>
        </author>
        
        <link rel="alternate" type="text/html" href="https://ledger.mlabs.city/findings/2026-09-01-seed-sql-mysql-mariadb-port/"/>
        <id>https://ledger.mlabs.city/findings/2026-09-01-seed-sql-mysql-mariadb-port/</id>
        
        <content type="html" xml:base="https://ledger.mlabs.city/findings/2026-09-01-seed-sql-mysql-mariadb-port/">&lt;h2 id=&quot;what-was-done&quot;&gt;What was done&lt;/h2&gt;
&lt;p&gt;&lt;code&gt;bin/seed-sql.sh&lt;/code&gt; is now engine-aware for &lt;code&gt;postgres&lt;/code&gt; (unchanged,
&lt;code&gt;generate_series&lt;/code&gt;) and &lt;code&gt;mysql&lt;/code&gt;/&lt;code&gt;mariadb&lt;/code&gt; (new, recursive CTE). Oracle is still
rejected with a loud pointer (see &lt;em&gt;Remaining&lt;/em&gt;).&lt;/p&gt;
&lt;p&gt;Three engine differences had to be handled, each of which would otherwise fail
silently-late or corrupt the fixture:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;Identifier case.&lt;/strong&gt; Keycloak creates UPPERCASE identifiers
(&lt;code&gt;REALM&lt;/code&gt;, &lt;code&gt;USER_ENTITY&lt;/code&gt;, &lt;code&gt;CREDENTIAL&lt;/code&gt;, &lt;code&gt;USER_ATTRIBUTE&lt;/code&gt;, &lt;code&gt;ID&lt;/code&gt;, &lt;code&gt;REALM_ID&lt;/code&gt;,
&lt;code&gt;USERNAME&lt;/code&gt;, …). Postgres folds unquoted identifiers to lowercase; MySQL 8 /
MariaDB on Linux (&lt;code&gt;lower_case_table_names=0&lt;/code&gt;, the lab&#39;s setting) preserve
them. The first MySQL attempt failed at once with &lt;code&gt;Table &#39;keycloak.realm&#39; doesn&#39;t exist&lt;/code&gt;. The shared lookups (realm id, template id, row count) and the
INSERT column lists are now written per engine.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Recursion-depth knob.&lt;/strong&gt; MySQL 8 caps a recursive CTE with
&lt;code&gt;cte_max_recursion_depth&lt;/code&gt;; MariaDB calls the same thing
&lt;code&gt;max_recursive_iterations&lt;/code&gt;. Both default to 1000, so a 250k batch fails
without a &lt;code&gt;SET SESSION&lt;/code&gt; — and the name is engine-specific.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Credential JSON.&lt;/strong&gt; The argon2 &lt;code&gt;secret_data&lt;/code&gt; contains &lt;code&gt;$argon2id$…&lt;/code&gt;, so it
cannot be carried across the shell boundary (an unquoted heredoc would expand
the &lt;code&gt;$&lt;/code&gt;). On MySQL/MariaDB the JSON is copied &lt;strong&gt;in-database&lt;/strong&gt; with a scalar
subquery against the template row, so it never enters the shell at all.&lt;/li&gt;
&lt;/ol&gt;
&lt;h2 id=&quot;result-verified-at-1000-users-on-both-engines&quot;&gt;Result (verified at 1000 users on both engines)&lt;/h2&gt;
&lt;table&gt;&lt;thead&gt;&lt;tr&gt;&lt;th&gt;Engine&lt;/th&gt;&lt;th&gt;users&lt;/th&gt;&lt;th&gt;credentials&lt;/th&gt;&lt;th&gt;attributes&lt;/th&gt;&lt;th&gt;credential hash copied from template&lt;/th&gt;&lt;/tr&gt;&lt;/thead&gt;&lt;tbody&gt;
&lt;tr&gt;&lt;td&gt;MySQL 8.4&lt;/td&gt;&lt;td&gt;1000 (+1 template)&lt;/td&gt;&lt;td&gt;1000&lt;/td&gt;&lt;td&gt;2000&lt;/td&gt;&lt;td&gt;☑ (matched)&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;MariaDB 11.4&lt;/td&gt;&lt;td&gt;1000 (+1 template)&lt;/td&gt;&lt;td&gt;1000&lt;/td&gt;&lt;td&gt;2000&lt;/td&gt;&lt;td&gt;☑ (matched)&lt;/td&gt;&lt;/tr&gt;
&lt;/tbody&gt;&lt;/table&gt;
&lt;p&gt;Also exercised on MariaDB: &lt;code&gt;--start&lt;/code&gt; extension (append users), &lt;code&gt;--batch&lt;/code&gt;
(multi-batch loop), and &lt;code&gt;--no-analyze&lt;/code&gt;. The Postgres path was re-verified
unchanged (100 users).&lt;/p&gt;
&lt;h2 id=&quot;observation-worth-its-own-note&quot;&gt;Observation worth its own note&lt;/h2&gt;
&lt;p&gt;&lt;code&gt;--no-analyze&lt;/code&gt; on MariaDB reports the planner&#39;s row estimate from
&lt;code&gt;information_schema.tables.table_rows&lt;/code&gt;, and that value is &lt;strong&gt;inherently fuzzier&lt;/strong&gt;
than Postgres&#39;s &lt;code&gt;pg_class.reltuples&lt;/code&gt;: after seeding 1101 rows InnoDB reported
&lt;code&gt;1303&lt;/code&gt;. Postgres&#39;s &lt;code&gt;reltuples&lt;/code&gt; is exact after &lt;code&gt;ANALYZE&lt;/code&gt;; InnoDB&#39;s &lt;code&gt;table_rows&lt;/code&gt;
is a statistical estimate even when freshly analyzed. So the &quot;stale planner&quot;
model the lab uses to reproduce the index-skip threshold (Run A) will not map
one-to-one onto MySQL/MariaDB — the pre-condition that Keycloak reads there is a
number that is never exact. Worth pinning before claiming the threshold finding
generalises.&lt;/p&gt;
&lt;h2 id=&quot;remaining&quot;&gt;Remaining&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Oracle&lt;/strong&gt; — still not supported. Two blockers: the credential columns are
&lt;code&gt;VARCHAR2&lt;/code&gt;/&lt;code&gt;CLOB&lt;/code&gt; (the changelog notes the &lt;code&gt;SECRET_DATA&lt;/code&gt;/&lt;code&gt;CREDENTIAL_DATA&lt;/code&gt;
fill is &quot;used on all databases beside Oracle&quot;), and row generation needs
&lt;code&gt;CONNECT BY LEVEL&lt;/code&gt; instead of a CTE. Separate port.&lt;/li&gt;
&lt;/ul&gt;
&lt;h2 id=&quot;verification&quot;&gt;Verification&lt;/h2&gt;
&lt;table&gt;&lt;thead&gt;&lt;tr&gt;&lt;th&gt;Claim&lt;/th&gt;&lt;th&gt;Evidence&lt;/th&gt;&lt;/tr&gt;&lt;/thead&gt;&lt;tbody&gt;
&lt;tr&gt;&lt;td&gt;MySQL seeds 1000 with correct shape&lt;/td&gt;&lt;td&gt;1000 users / 1000 creds / 2000 attrs, hash copied ☑&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;MariaDB seeds 1000 with correct shape&lt;/td&gt;&lt;td&gt;same counts, hash copied ☑&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Extension + multi-batch + no-analyze work&lt;/td&gt;&lt;td&gt;&lt;code&gt;--start&lt;/code&gt;/&lt;code&gt;--batch&lt;/code&gt;/&lt;code&gt;--no-analyze&lt;/code&gt; runs ☑&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Postgres path unchanged&lt;/td&gt;&lt;td&gt;100 users seeded, 101 rows ☑&lt;/td&gt;&lt;/tr&gt;
&lt;/tbody&gt;&lt;/table&gt;
</content>
        
    </entry>
    <entry xml:lang="en">
        <title>Rehearsal Record — seed-sql.sh ported to Oracle (CLOB credential + CONNECT BY)</title>
        <published>2026-09-01T00:00:00+00:00</published>
        <updated>2026-09-01T00:00:00+00:00</updated>
        
        <author>
          <name>Unknown</name>
        </author>
        
        <link rel="alternate" type="text/html" href="https://ledger.mlabs.city/findings/2026-09-01-seed-sql-oracle-port/"/>
        <id>https://ledger.mlabs.city/findings/2026-09-01-seed-sql-oracle-port/</id>
        
        <content type="html" xml:base="https://ledger.mlabs.city/findings/2026-09-01-seed-sql-oracle-port/">&lt;h2 id=&quot;what-was-done&quot;&gt;What was done&lt;/h2&gt;
&lt;p&gt;&lt;code&gt;bin/seed-sql.sh&lt;/code&gt; now supports &lt;code&gt;oracle&lt;/code&gt;. Two Oracle-specific mechanics, both of
which are load-bearing rather than cosmetic:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;Row generation — &lt;code&gt;CONNECT BY LEVEL&lt;/code&gt;.&lt;/strong&gt; Oracle has no &lt;code&gt;generate_series&lt;/code&gt; and
no recursive CTE (well, it has recursive &lt;code&gt;WITH&lt;/code&gt;, but the idiomatic generator
is &lt;code&gt;SELECT lo + LEVEL - 1 FROM dual CONNECT BY LEVEL &amp;lt;= (hi-lo+1)&lt;/code&gt;).&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Credential copy — a PL/SQL block.&lt;/strong&gt; &lt;code&gt;SECRET_DATA&lt;/code&gt; / &lt;code&gt;CREDENTIAL_DATA&lt;/code&gt; are
&lt;code&gt;CLOB&lt;/code&gt; on Oracle (confirmed from &lt;code&gt;all_tab_columns&lt;/code&gt;; the changelog creates them
as CLOB and only the &lt;em&gt;fill&lt;/em&gt; SQL is Oracle-specific, because Oracle&#39;s &lt;code&gt;CONCAT&lt;/code&gt;
takes exactly two args). A scalar subquery returning a CLOB is rejected
(&lt;code&gt;ORA-00932&lt;/code&gt;/&lt;code&gt;ORA-22848&lt;/code&gt;), so the template credential is read into PL/SQL
&lt;code&gt;CLOB&lt;/code&gt; variables with &lt;code&gt;SELECT … INTO&lt;/code&gt; and then referenced by the INSERT —
the value never crosses the shell boundary (important: the argon2
&lt;code&gt;secret_data&lt;/code&gt; contains &lt;code&gt;$argon2id$…&lt;/code&gt;).&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;Also engine-specific: &lt;code&gt;EMAIL_VERIFIED&lt;/code&gt;/&lt;code&gt;ENABLED&lt;/code&gt; are native &lt;code&gt;BOOLEAN&lt;/code&gt; (insert
&lt;code&gt;TRUE&lt;/code&gt;), &lt;code&gt;USERNAME&lt;/code&gt;/&lt;code&gt;FIRST_NAME&lt;/code&gt;/&lt;code&gt;LAST_NAME&lt;/code&gt;/&lt;code&gt;VALUE&lt;/code&gt; are &lt;code&gt;NVARCHAR2&lt;/code&gt;,
&lt;code&gt;CREATED_TIMESTAMP&lt;/code&gt; is &lt;code&gt;NUMBER&lt;/code&gt; (ms epoch via &lt;code&gt;ROUND((SYSDATE - DATE &#39;1970-01-01&#39;) * 86400000)&lt;/code&gt;), and identifiers are UPPERCASE (shared with the
mysql/mariadb branch). Stats are gathered with &lt;code&gt;DBMS_STATS.GATHER_TABLE_STATS&lt;/code&gt;
and the planner estimate is &lt;code&gt;all_tables.num_rows&lt;/code&gt;; size is
&lt;code&gt;user_segments&lt;/code&gt;.&lt;/p&gt;
&lt;h2 id=&quot;result-verified-at-100-users-then-extension-multi-batch-no-analyze&quot;&gt;Result (verified at 100 users, then extension + multi-batch + --no-analyze)&lt;/h2&gt;
&lt;table&gt;&lt;thead&gt;&lt;tr&gt;&lt;th&gt;Check&lt;/th&gt;&lt;th&gt;Value&lt;/th&gt;&lt;/tr&gt;&lt;/thead&gt;&lt;tbody&gt;
&lt;tr&gt;&lt;td&gt;&lt;code&gt;--users 100&lt;/code&gt;&lt;/td&gt;&lt;td&gt;100 users + 1 template; 100 creds; 200 attrs&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;credential CLOB copied&lt;/td&gt;&lt;td&gt;&lt;code&gt;DBMS_LOB.COMPARE = 0&lt;/code&gt; (match), &lt;code&gt;secret_data&lt;/code&gt; length 116&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;&lt;code&gt;--start&lt;/code&gt; extension&lt;/td&gt;&lt;td&gt;50 appended users&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;&lt;code&gt;--batch 25&lt;/code&gt; multi-batch&lt;/td&gt;&lt;td&gt;2 batches clean&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;&lt;code&gt;--no-analyze&lt;/code&gt;&lt;/td&gt;&lt;td&gt;reports &lt;code&gt;all_tables.num_rows&lt;/code&gt; (102, the pre-stats estimate)&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;clean run&lt;/td&gt;&lt;td&gt;10 users in 2s, no sqlplus feedback noise&lt;/td&gt;&lt;/tr&gt;
&lt;/tbody&gt;&lt;/table&gt;
&lt;h2 id=&quot;gotcha-worth-recording&quot;&gt;Gotcha worth recording&lt;/h2&gt;
&lt;p&gt;The deterministic &lt;code&gt;sql-&amp;lt;32-digit&amp;gt;&lt;/code&gt; ids are a &lt;strong&gt;table-wide primary key&lt;/strong&gt;, not
per-realm. Seeding a second realm with the same &lt;code&gt;--start&lt;/code&gt;/&lt;code&gt;--users&lt;/code&gt; range
collides (&lt;code&gt;ORA-00001 … ID already exists&lt;/code&gt;) — this is true on every engine, not
Oracle-specific, and is the reason &lt;code&gt;--start&lt;/code&gt; exists. Re-running an overlapping
range is not idempotent; the script assumes each range is seeded once.&lt;/p&gt;
&lt;h2 id=&quot;verification&quot;&gt;Verification&lt;/h2&gt;
&lt;table&gt;&lt;thead&gt;&lt;tr&gt;&lt;th&gt;Claim&lt;/th&gt;&lt;th&gt;Evidence&lt;/th&gt;&lt;/tr&gt;&lt;/thead&gt;&lt;tbody&gt;
&lt;tr&gt;&lt;td&gt;Oracle seeds 100 with correct shape&lt;/td&gt;&lt;td&gt;100 users / 100 creds / 200 attrs ☑&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;CLOB credential copied from template&lt;/td&gt;&lt;td&gt;&lt;code&gt;DBMS_LOB.COMPARE = 0&lt;/code&gt; ☑&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;BOOLEAN + NVARCHAR2 + NUMBER columns accept the seed&lt;/td&gt;&lt;td&gt;rows inserted, no type errors ☑&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Extension / multi-batch / no-analyze paths work&lt;/td&gt;&lt;td&gt;3 extra runs ☑&lt;/td&gt;&lt;/tr&gt;
&lt;/tbody&gt;&lt;/table&gt;
</content>
        
    </entry>
    <entry xml:lang="en">
        <title>Rehearsal Record — Oracle S4: a non-owner &quot;DML-only&quot; account cannot run the migration at all</title>
        <published>2026-09-01T00:00:00+00:00</published>
        <updated>2026-09-01T00:00:00+00:00</updated>
        
        <author>
          <name>Unknown</name>
        </author>
        
        <link rel="alternate" type="text/html" href="https://ledger.mlabs.city/runs/2026-09-01-oracle-s4-dml-only/"/>
        <id>https://ledger.mlabs.city/runs/2026-09-01-oracle-s4-dml-only/</id>
        
        <content type="html" xml:base="https://ledger.mlabs.city/runs/2026-09-01-oracle-s4-dml-only/">&lt;h2 id=&quot;summary&quot;&gt;Summary&lt;/h2&gt;
&lt;p&gt;The S4 &quot;DML-only account&quot; row is now closed on the fourth engine. On Oracle it
does not just differ in &lt;em&gt;fix&lt;/em&gt; — the whole failure mode is structurally different,
because &lt;strong&gt;the user IS the schema&lt;/strong&gt;. Two runs:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Run A — DML-only (+ synonyms):&lt;/strong&gt; fails before any Keycloak changeset, at
Liquibase&#39;s own changelog-table discovery, with &lt;code&gt;ORA-01031&lt;/code&gt;. Liquibase does not
resolve synonyms when checking for &lt;code&gt;DATABASECHANGELOG&lt;/code&gt; (a synonym is not a
table in &lt;code&gt;USER_TABLES&lt;/code&gt;), so it believes the table is absent and tries to
&lt;code&gt;CREATE TABLE KC_DML.DATABASECHANGELOG&lt;/code&gt;, which a DML-only account cannot do.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Run B — DML + full &lt;code&gt;ANY&lt;/code&gt; DDL:&lt;/strong&gt; the same CREATE now fails with &lt;code&gt;ORA-00955: name is already used by an existing object&lt;/code&gt; — the existing object is the
synonym itself. The synonyms that are &lt;em&gt;required&lt;/em&gt; to make DML resolve are
&lt;em&gt;themselves&lt;/em&gt; the obstacle to Liquibase&#39;s DDL.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Conclusion: on Oracle there is &lt;strong&gt;no privilege-based fix&lt;/strong&gt;. The MySQL finding
(&quot;DDL privileges suffice&quot;) does not translate; neither does the Postgres finding
(&quot;ownership required, no GRANT confers DROP INDEX&quot;). On Oracle the account
Keycloak connects as must simply &lt;strong&gt;be the schema owner&lt;/strong&gt; — a non-owner DML-only
account is not a viable migration posture, because Liquibase cannot see the
existing schema through synonyms and cannot create its changelog table beside
them.&lt;/p&gt;
&lt;h2 id=&quot;environment&quot;&gt;Environment&lt;/h2&gt;
&lt;table&gt;&lt;thead&gt;&lt;tr&gt;&lt;th&gt;Field&lt;/th&gt;&lt;th&gt;Value&lt;/th&gt;&lt;/tr&gt;&lt;/thead&gt;&lt;tbody&gt;
&lt;tr&gt;&lt;td&gt;Keycloak from → to&lt;/td&gt;&lt;td&gt;26.0.0 → 26.7.3&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Database&lt;/td&gt;&lt;td&gt;Oracle Database Free (23ai/26ai), release 23.26.2.0.0, container &lt;code&gt;gvenzl/oracle-free:23-slim&lt;/code&gt;&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;JDBC driver&lt;/td&gt;&lt;td&gt;&lt;code&gt;ojdbc17&lt;/code&gt; + &lt;code&gt;orai18n&lt;/code&gt; 23.26.0.0.0 in &lt;code&gt;fixtures/providers/&lt;/code&gt;&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Schema owner&lt;/td&gt;&lt;td&gt;&lt;code&gt;keycloak&lt;/code&gt; (DB_DEVELOPER_ROLE, USERS tablespace) — 87 tables, 194 indexes, 0 sequences&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Account under test&lt;/td&gt;&lt;td&gt;&lt;code&gt;kc_dml&lt;/code&gt; — DML grants + private synonyms on all 87 tables (348 grants)&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Topology&lt;/td&gt;&lt;td&gt;single container&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Host&lt;/td&gt;&lt;td&gt;laptop (Docker 29, Compose 5.5.0)&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;JVM heap&lt;/td&gt;&lt;td&gt;&lt;code&gt;-Xms1g -Xmx4g&lt;/code&gt;&lt;/td&gt;&lt;/tr&gt;
&lt;/tbody&gt;&lt;/table&gt;
&lt;h2 id=&quot;procedure&quot;&gt;Procedure&lt;/h2&gt;
&lt;pre&gt;&lt;code&gt;# 26.0.0 schema created by Keycloak as the keycloak owner (87 tables)
./bin/snapshot.sh baseline-26.0.0-oracle-s4

# kc_dml: DML-only account + synonyms (user-is-schema: unqualified names need synonyms)
CREATE USER kc_dml IDENTIFIED BY kc_dml DEFAULT TABLESPACE USERS QUOTA UNLIMITED ON USERS;
GRANT CREATE SESSION TO kc_dml;
-- for each of the 87 tables T owned by KEYCLOAK:
GRANT SELECT,INSERT,UPDATE,DELETE ON keycloak.T TO kc_dml;
CREATE OR REPLACE SYNONYM kc_dml.T FOR keycloak.T;

# run A — DML only
KC_DB_USERNAME=kc_dml ./bin/upgrade.sh 26.7.3 --no-backup     # FAILED, 16s

# run B — DML + ANY-* DDL
GRANT CREATE/ALTER/DROP ANY TABLE|INDEX|SEQUENCE|VIEW|SYNONYM|TRIGGER|PROCEDURE TO kc_dml;
docker compose up -d keycloak                                  # FAILED, 6s
&lt;/code&gt;&lt;/pre&gt;
&lt;h2 id=&quot;results&quot;&gt;Results&lt;/h2&gt;
&lt;table&gt;&lt;thead&gt;&lt;tr&gt;&lt;th&gt;Run&lt;/th&gt;&lt;th&gt;Account rights&lt;/th&gt;&lt;th&gt;Outcome&lt;/th&gt;&lt;th&gt;Error&lt;/th&gt;&lt;/tr&gt;&lt;/thead&gt;&lt;tbody&gt;
&lt;tr&gt;&lt;td&gt;A&lt;/td&gt;&lt;td&gt;DML only (SELECT/INSERT/UPDATE/DELETE) + synonyms&lt;/td&gt;&lt;td&gt;&lt;strong&gt;exit 1, refused to start&lt;/strong&gt;&lt;/td&gt;&lt;td&gt;&lt;code&gt;ORA-01031&lt;/code&gt; on &lt;code&gt;CREATE TABLE KC_DML.DATABASECHANGELOG&lt;/code&gt;&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;B&lt;/td&gt;&lt;td&gt;DML + full &lt;code&gt;ANY&lt;/code&gt; DDL + synonyms&lt;/td&gt;&lt;td&gt;&lt;strong&gt;exit 1, refused to start&lt;/strong&gt;&lt;/td&gt;&lt;td&gt;&lt;code&gt;ORA-00955&lt;/code&gt; on the same CREATE — collides with the synonym&lt;/td&gt;&lt;/tr&gt;
&lt;/tbody&gt;&lt;/table&gt;
&lt;p&gt;Schema after both runs: &lt;code&gt;keycloak&lt;/code&gt; still 26.0.0, 87 tables, 144 changelog rows;
&lt;code&gt;kc_dml&lt;/code&gt; has 0 tables and 87 synonyms. Neither run modified the owner&#39;s schema.&lt;/p&gt;
&lt;h3 id=&quot;run-a-failure&quot;&gt;Run A failure&lt;/h3&gt;
&lt;pre&gt;&lt;code&gt;liquibase.exception.SetupException: An error occurred while attempting to create
the database changelog table. ... &amp;#39;KC_DML.DATABASECHANGELOG&amp;#39;
ORA-01031: insufficient privileges
[Failed SQL: (1031) CREATE TABLE KC_DML.DATABASECHANGELOG (ID VARCHAR2(255) ...)]
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Liquibase checks for the changelog table and does not find
&lt;code&gt;keycloak.DATABASECHANGELOG&lt;/code&gt;, because a synonym is not reported as a table by the
catalogue. It concludes the table is missing and tries to create it in the
connection&#39;s own schema (&lt;code&gt;KC_DML&lt;/code&gt;) — a DDL operation a DML-only account cannot
perform. Note this is &lt;em&gt;earlier&lt;/em&gt; than MySQL, where Liquibase found the existing
changelog and only failed trying to &lt;code&gt;ALTER&lt;/code&gt; it.&lt;/p&gt;
&lt;h3 id=&quot;run-b-failure&quot;&gt;Run B failure&lt;/h3&gt;
&lt;pre&gt;&lt;code&gt;ORA-00955: name is already used by an existing object
[Failed SQL: (955) CREATE TABLE KC_DML.DATABASECHANGELOG (...)]
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;With &lt;code&gt;CREATE ANY TABLE&lt;/code&gt; granted, the same CREATE now clears the privilege check
and collides with the synonym &lt;code&gt;kc_dml.DATABASECHANGELOG&lt;/code&gt;. A schema cannot hold a
table and a synonym of the same name, so the very mechanism that made DML resolve
is what blocks Liquibase&#39;s DDL.&lt;/p&gt;
&lt;h2 id=&quot;verification&quot;&gt;Verification&lt;/h2&gt;
&lt;table&gt;&lt;thead&gt;&lt;tr&gt;&lt;th&gt;Claim&lt;/th&gt;&lt;th&gt;Evidence&lt;/th&gt;&lt;/tr&gt;&lt;/thead&gt;&lt;tbody&gt;
&lt;tr&gt;&lt;td&gt;DML-only non-owner fails on Oracle&lt;/td&gt;&lt;td&gt;run A &lt;code&gt;ORA-01031&lt;/code&gt; at &lt;code&gt;CREATE TABLE KC_DML.DATABASECHANGELOG&lt;/code&gt; ☑&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;DDL grant does not rescue it&lt;/td&gt;&lt;td&gt;run B &lt;code&gt;ORA-00955&lt;/code&gt; (synonym collision) ☑&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Owner schema untouched by both failures&lt;/td&gt;&lt;td&gt;&lt;code&gt;keycloak&lt;/code&gt; 26.0.0, 87 tables, 144 changelog rows ☑&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Synonyms are the obstacle&lt;/td&gt;&lt;td&gt;&lt;code&gt;kc_dml&lt;/code&gt; 87 synonyms, 0 tables ☑&lt;/td&gt;&lt;/tr&gt;
&lt;/tbody&gt;&lt;/table&gt;
</content>
        
    </entry>
    <entry xml:lang="en">
        <title>Rehearsal Record — CVE-2026-16093 spot-check: unsigned JWT client assertion rejected</title>
        <published>2026-08-31T00:00:00+00:00</published>
        <updated>2026-08-31T00:00:00+00:00</updated>
        
        <author>
          <name>Unknown</name>
        </author>
        
        <link rel="alternate" type="text/html" href="https://ledger.mlabs.city/findings/2026-08-31-cve-2026-16093-signed-jwt-assertion/"/>
        <id>https://ledger.mlabs.city/findings/2026-08-31-cve-2026-16093-signed-jwt-assertion/</id>
        
        <content type="html" xml:base="https://ledger.mlabs.city/findings/2026-08-31-cve-2026-16093-signed-jwt-assertion/">&lt;h2 id=&quot;method&quot;&gt;Method&lt;/h2&gt;
&lt;p&gt;Created a confidential client (&lt;code&gt;jwt-assert-client&lt;/code&gt;) with
&lt;code&gt;clientAuthenticatorType=client-secret-jwt&lt;/code&gt;, then exercised the
&lt;code&gt;client_credentials&lt;/code&gt; grant with a JWT client assertion in both forms:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;HS256&lt;/strong&gt; — a properly HMAC-signed assertion over the client secret.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;&lt;code&gt;alg:none&lt;/code&gt;&lt;/strong&gt; — the same header/payload with no signature (the bypass).&lt;/li&gt;
&lt;/ul&gt;
&lt;h2 id=&quot;result&quot;&gt;Result&lt;/h2&gt;
&lt;table&gt;&lt;thead&gt;&lt;tr&gt;&lt;th&gt;Assertion&lt;/th&gt;&lt;th&gt;Response&lt;/th&gt;&lt;/tr&gt;&lt;/thead&gt;&lt;tbody&gt;
&lt;tr&gt;&lt;td&gt;HS256 (signed)&lt;/td&gt;&lt;td&gt;&lt;strong&gt;HTTP 200&lt;/strong&gt; — access token issued&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;&lt;code&gt;alg:none&lt;/code&gt; (unsigned)&lt;/td&gt;&lt;td&gt;&lt;strong&gt;HTTP 400&lt;/strong&gt; — &lt;code&gt;{&quot;error&quot;:&quot;invalid_client&quot;,&quot;error_description&quot;:&quot;Invalid signature algorithm&quot;}&lt;/code&gt;&lt;/td&gt;&lt;/tr&gt;
&lt;/tbody&gt;&lt;/table&gt;
&lt;h2 id=&quot;finding&quot;&gt;Finding&lt;/h2&gt;
&lt;p&gt;26.7.3 &lt;strong&gt;rejects unsigned JWT client assertions&lt;/strong&gt; (&lt;code&gt;alg:none&lt;/code&gt; → &quot;Invalid
signature algorithm&quot;), so the CVE-2026-16093 bypass is closed. The signed path is
unaffected. This confirms the OIDC client-assertion fix in the 26.7.3 batch.&lt;/p&gt;
&lt;h2 id=&quot;still-open-need-external-idps-dedicated-harness&quot;&gt;Still open (need external IdPs / dedicated harness)&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;CVE-2026-18215 / CVE-2026-18214 — Microsoft/Google token-exchange
tenant/hosted-domain bypasses. These need a real (or mocked) external IdP;
the lab has no Google/Microsoft fixture.&lt;/li&gt;
&lt;li&gt;CVE-2026-79652 — jwt-bearer grant &lt;code&gt;consentRequired&lt;/code&gt; enforcement (same
client-assertion harness could be extended).&lt;/li&gt;
&lt;/ul&gt;
</content>
        
    </entry>
    <entry xml:lang="en">
        <title>Rehearsal Record — CVE-2026-35563 spot-check: LDAP TLS hostname verification at 26.7.3</title>
        <published>2026-08-31T00:00:00+00:00</published>
        <updated>2026-08-31T00:00:00+00:00</updated>
        
        <author>
          <name>Unknown</name>
        </author>
        
        <link rel="alternate" type="text/html" href="https://ledger.mlabs.city/findings/2026-08-31-cve-2026-35563-ldap-hostname/"/>
        <id>https://ledger.mlabs.city/findings/2026-08-31-cve-2026-35563-ldap-hostname/</id>
        
        <content type="html" xml:base="https://ledger.mlabs.city/findings/2026-08-31-cve-2026-35563-ldap-hostname/">&lt;h2 id=&quot;environment&quot;&gt;Environment&lt;/h2&gt;
&lt;table&gt;&lt;thead&gt;&lt;tr&gt;&lt;th&gt;Field&lt;/th&gt;&lt;th&gt;Value&lt;/th&gt;&lt;/tr&gt;&lt;/thead&gt;&lt;tbody&gt;
&lt;tr&gt;&lt;td&gt;Keycloak&lt;/td&gt;&lt;td&gt;26.7.3 (official image)&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Directory&lt;/td&gt;&lt;td&gt;&lt;code&gt;bitnamilegacy/openldap:2.6&lt;/code&gt;, TLS on 1636 (LDAPS) + StartTLS on 1389&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Federation&lt;/td&gt;&lt;td&gt;&lt;code&gt;corp-ldap&lt;/code&gt;, &lt;code&gt;connectionUrl=ldap://ldap:1389&lt;/code&gt;, &lt;code&gt;startTls=true&lt;/code&gt;, &lt;code&gt;editMode=READ_ONLY&lt;/code&gt;&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Certificates&lt;/td&gt;&lt;td&gt;private CA (&lt;code&gt;lab-ldap-ca&lt;/code&gt;); server cert &lt;code&gt;CN=ldap&lt;/code&gt;, SAN deliberately set to &lt;code&gt;DNS:wronghost&lt;/code&gt;&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Trust&lt;/td&gt;&lt;td&gt;&lt;code&gt;KC_TRUSTSTORE_PATHS&lt;/code&gt;, &lt;code&gt;ca.crt&lt;/code&gt; present (so hostname is the &lt;em&gt;only&lt;/em&gt; failure)&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Database&lt;/td&gt;&lt;td&gt;PostgreSQL 16, single node, container&lt;/td&gt;&lt;/tr&gt;
&lt;/tbody&gt;&lt;/table&gt;
&lt;h2 id=&quot;result&quot;&gt;Result&lt;/h2&gt;
&lt;p&gt;Full sync via &lt;code&gt;POST /admin/realms/lab/user-storage/{id}/sync?action=triggerFullSync&lt;/code&gt;
with the CA trusted and the server cert SAN set to &lt;code&gt;DNS:wronghost&lt;/code&gt;:&lt;/p&gt;
&lt;table&gt;&lt;thead&gt;&lt;tr&gt;&lt;th&gt;Signal&lt;/th&gt;&lt;th&gt;Value&lt;/th&gt;&lt;/tr&gt;&lt;/thead&gt;&lt;tbody&gt;
&lt;tr&gt;&lt;td&gt;Sync API response&lt;/td&gt;&lt;td&gt;&lt;code&gt;{&quot;added&quot;:0,...,&quot;status&quot;:&quot;0 imported users, 0 updated users&quot;}&lt;/code&gt; HTTP &lt;strong&gt;200&lt;/strong&gt;&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Keycloak log&lt;/td&gt;&lt;td&gt;&lt;code&gt;SSLPeerUnverifiedException: hostname of the server &#39;ldap&#39; does not match the hostname in the server&#39;s certificate&lt;/code&gt;&lt;/td&gt;&lt;/tr&gt;
&lt;/tbody&gt;&lt;/table&gt;
&lt;h2 id=&quot;finding&quot;&gt;Finding&lt;/h2&gt;
&lt;p&gt;&lt;strong&gt;26.7.3 enforces LDAP TLS hostname verification.&lt;/strong&gt; A directory cert whose SAN
does not match the connection hostname is a hard failure
(&lt;code&gt;SSLPeerUnverifiedException&lt;/code&gt;, via &lt;code&gt;sun.security.util.HostnameChecker&lt;/code&gt;). This
confirms the CVE-2026-35563 regression — the LDAP client at 2.1.7 that stopped
verifying the hostname — is &lt;strong&gt;closed&lt;/strong&gt; in 26.7.3.&lt;/p&gt;
&lt;p&gt;The failure signature is &lt;strong&gt;unchanged from the 26.7.1 measurement&lt;/strong&gt;
(&lt;a href=&quot;/findings/2026-08-26-starttls-1389/&quot;&gt;&lt;code&gt;2026-08-26-starttls-1389&lt;/code&gt;&lt;/a&gt;): silent at the API (HTTP 200, &lt;code&gt;added:0&lt;/code&gt;),
loud in the log. The existing guidance therefore still holds — a federation
smoke test must assert on the &lt;em&gt;imported count&lt;/em&gt;, not the status code.&lt;/p&gt;
</content>
        
    </entry>
    <entry xml:lang="en">
        <title>Rehearsal Record — FGAP-v2 / token-exchange spot-check (26.7.3): activation surface changed</title>
        <published>2026-08-31T00:00:00+00:00</published>
        <updated>2026-08-31T00:00:00+00:00</updated>
        
        <author>
          <name>Unknown</name>
        </author>
        
        <link rel="alternate" type="text/html" href="https://ledger.mlabs.city/findings/2026-08-31-fgap-v2-token-exchange/"/>
        <id>https://ledger.mlabs.city/findings/2026-08-31-fgap-v2-token-exchange/</id>
        
        <content type="html" xml:base="https://ledger.mlabs.city/findings/2026-08-31-fgap-v2-token-exchange/">&lt;h2 id=&quot;what-was-checked&quot;&gt;What was checked&lt;/h2&gt;
&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;Feature flags&lt;/strong&gt; (serverinfo): &lt;code&gt;ADMIN_FINE_GRAINED_AUTHZ_V2 enabled=true&lt;/code&gt;,
&lt;code&gt;ADMIN_FINE_GRAINED_AUTHZ enabled=false&lt;/code&gt; — v2 is the active line in 26.7.3.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Realm activation field&lt;/strong&gt;: &lt;code&gt;PUT /admin/realms/lab&lt;/code&gt; with
&lt;code&gt;adminFineGrainedAuthz:&quot;v2&quot;&lt;/code&gt; → &lt;strong&gt;HTTP 400&lt;/strong&gt;
&lt;code&gt;Unrecognized field &quot;adminFineGrainedAuthz&quot;&lt;/code&gt;. The RealmRepresentation no
longer carries this field in 26.7.3.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Permission-catalog endpoint&lt;/strong&gt;: &lt;code&gt;GET /admin/realms/lab/fine-grained-permissions&lt;/code&gt;
and &lt;code&gt;/admin-fine-grained-permissions&lt;/code&gt; both → &lt;strong&gt;HTTP 404&lt;/strong&gt; on the lab realm.&lt;/li&gt;
&lt;/ol&gt;
&lt;h2 id=&quot;findings&quot;&gt;Findings&lt;/h2&gt;
&lt;h3 id=&quot;fgap-v2-activation-surface-changed-in-26-7-3&quot;&gt;FGAP-v2 activation surface changed in 26.7.3&lt;/h3&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Symptom:&lt;/strong&gt; the documented way to turn on FGAP v2 (set
&lt;code&gt;adminFineGrainedAuthz&lt;/code&gt; on the realm) is rejected as an unrecognized field, and
the permission-catalog endpoint 404s even though the v2 feature flag reports
enabled.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Additional evidence (2026-08-31):&lt;/strong&gt; &lt;code&gt;ADMIN_FINE_GRAINED_AUTHZ_V2&lt;/code&gt; is
&lt;code&gt;type=DEFAULT, enabled=true&lt;/code&gt; (not a preview gate). 26.7.3&#39;s
&lt;code&gt;RealmRepresentation&lt;/code&gt; carries &lt;code&gt;adminPermissionsEnabled&lt;/code&gt; and
&lt;code&gt;adminPermissionsClient&lt;/code&gt; but &lt;strong&gt;no&lt;/strong&gt; &lt;code&gt;adminFineGrainedAuthz&lt;/code&gt;. Enabling
&lt;code&gt;adminPermissionsEnabled=true&lt;/code&gt; on a realm does &lt;strong&gt;not&lt;/strong&gt; expose any
&lt;code&gt;fine-grained-permissions&lt;/code&gt;/&lt;code&gt;admin-fine-grained-permissions&lt;/code&gt; endpoint (still
HTTP 404). The fgap REST resources exist in the jar
(&lt;code&gt;org.keycloak.services.resources.admin.fgap.*&lt;/code&gt;) but their registration path
is no longer the documented one.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Cause:&lt;/strong&gt; the realm-level toggle/endpoint was renamed/relocated in the
26.7.x FGAP-v2 rollout; pinning the exact &lt;code&gt;@Path&lt;/code&gt; needs the 26.7.3 admin REST
/ upgrade notes or source.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Impact:&lt;/strong&gt; an operator migrating to 26.7.x cannot activate or audit FGAP v2
with the pre-26.7 procedure. The CVE-by-CVE RBAC reproductions (role-groups
bypass CVE-2026-14613, composite-endpoint authz CVE-2026-16105, default-group
disclosure CVE-2026-16108, etc.) are &lt;strong&gt;blocked on re-establishing the
activation procedure&lt;/strong&gt;, which is now a first-class lab task rather than a
quick flag flip.&lt;/li&gt;
&lt;/ul&gt;
&lt;h2 id=&quot;deferred-need-external-idps-dedicated-harness&quot;&gt;Deferred (need external IdPs / dedicated harness)&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;CVE-2026-18215 / CVE-2026-18214&lt;/strong&gt; — Microsoft/Google token-exchange
tenant/hosted-domain bypass. Needs a real (or mocked) external IdP; not
reproducible in the Compose lab.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;CVE-2026-16093&lt;/strong&gt; — signed-JWT assertion bypass with unsigned assertion
headers. Needs a &lt;code&gt;client-jwt&lt;/code&gt; client + keypair harness.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;CVE-2026-79652&lt;/strong&gt; — jwt-bearer grant &lt;code&gt;consentRequired&lt;/code&gt; enforcement.&lt;/li&gt;
&lt;/ul&gt;
</content>
        
    </entry>
    <entry xml:lang="en">
        <title>Rehearsal Record — remaining version-matrix paths at 26.7.3</title>
        <published>2026-08-31T00:00:00+00:00</published>
        <updated>2026-08-31T00:00:00+00:00</updated>
        
        <author>
          <name>Unknown</name>
        </author>
        
        <link rel="alternate" type="text/html" href="https://ledger.mlabs.city/findings/2026-08-31-matrix-remaining-26.7.3/"/>
        <id>https://ledger.mlabs.city/findings/2026-08-31-matrix-remaining-26.7.3/</id>
        
        <content type="html" xml:base="https://ledger.mlabs.city/findings/2026-08-31-matrix-remaining-26.7.3/">&lt;h2 id=&quot;results&quot;&gt;Results&lt;/h2&gt;
&lt;p&gt;All direct (unstepped) hops to 26.7.3, Postgres 16, &lt;code&gt;typical&lt;/code&gt; profile, 1,000 users,
single container, laptop.&lt;/p&gt;
&lt;table&gt;&lt;thead&gt;&lt;tr&gt;&lt;th&gt;Path&lt;/th&gt;&lt;th&gt;Time&lt;/th&gt;&lt;th&gt;Index audit&lt;/th&gt;&lt;th&gt;Realm&lt;/th&gt;&lt;/tr&gt;&lt;/thead&gt;&lt;tbody&gt;
&lt;tr&gt;&lt;td&gt;25.0.6 → 26.7.3&lt;/td&gt;&lt;td&gt;20s&lt;/td&gt;&lt;td&gt;119/119&lt;/td&gt;&lt;td&gt;1,000 users intact&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;24.0.5 → 26.7.3&lt;/td&gt;&lt;td&gt;20s&lt;/td&gt;&lt;td&gt;119/119&lt;/td&gt;&lt;td&gt;1,000 users intact&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;22.0.5 → 26.7.3&lt;/td&gt;&lt;td&gt;20s&lt;/td&gt;&lt;td&gt;119/119&lt;/td&gt;&lt;td&gt;1,000 users intact&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;26.6.4 → 26.7.3&lt;/td&gt;&lt;td&gt;17s&lt;/td&gt;&lt;td&gt;119/119&lt;/td&gt;&lt;td&gt;1,000 users intact&lt;/td&gt;&lt;/tr&gt;
&lt;/tbody&gt;&lt;/table&gt;
&lt;h2 id=&quot;findings&quot;&gt;Findings&lt;/h2&gt;
&lt;ol&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;Every source version goes direct to 26.7.3&lt;/strong&gt; — no stepping needed across
the 25→26 major boundary, the 24→26 two-major jump, or the 22→26 three-major
jump. Liquibase applies the pending changesets in one pass. This is the same
shape as the 21.1.2 → 26.7.1 direct hop already recorded.&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;Migration duration is flat across the span&lt;/strong&gt; (~17–20s) — it tracks the
&lt;em&gt;changeset count that actually runs&lt;/em&gt;, not the version distance. Confirms the
lab&#39;s earlier finding that &quot;duration against a stated dataset size&quot; is the
right frame, and the causal variable is the changeset set.&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;The 26.6.5 / 26.6.6 container images are not published.&lt;/strong&gt; GitHub carries the
&lt;code&gt;26.6.5&lt;/code&gt; and &lt;code&gt;26.6.6&lt;/code&gt; tags, but quay.io/keycloak/keycloak stops the 26.6 line
at &lt;strong&gt;26.6.4&lt;/strong&gt;. 26.6.5 is the patch the 2026-08-06 advisory batch names as the
26.6-line fix, so a customer pinned to the 26.6 line cannot pull that fix as a
container image — they must go to 26.7.x. This belongs in the runbook&#39;s
version-availability note (the lab already has one for the 22.0.x line ending
at 22.0.5).&lt;/p&gt;
&lt;/li&gt;
&lt;/ol&gt;
</content>
        
    </entry>
    <entry xml:lang="en">
        <title>Rehearsal Record — Oracle Data Pump port (snapshot/restore)</title>
        <published>2026-08-31T00:00:00+00:00</published>
        <updated>2026-08-31T00:00:00+00:00</updated>
        
        <author>
          <name>Unknown</name>
        </author>
        
        <link rel="alternate" type="text/html" href="https://ledger.mlabs.city/findings/2026-08-31-oracle-datapump-port/"/>
        <id>https://ledger.mlabs.city/findings/2026-08-31-oracle-datapump-port/</id>
        
        <content type="html" xml:base="https://ledger.mlabs.city/findings/2026-08-31-oracle-datapump-port/">&lt;h2 id=&quot;what-was-done&quot;&gt;What was done&lt;/h2&gt;
&lt;p&gt;Ported &lt;code&gt;bin/snapshot.sh&lt;/code&gt; and &lt;code&gt;bin/restore.sh&lt;/code&gt; to Oracle using Data Pump
(&lt;code&gt;expdp&lt;/code&gt;/&lt;code&gt;impdp&lt;/code&gt;), closing the last engine for the snapshot/restore tooling.&lt;/p&gt;
&lt;h2 id=&quot;the-port&quot;&gt;The port&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;&lt;code&gt;snapshot.sh&lt;/code&gt; (oracle)&lt;/strong&gt; — exports as &lt;strong&gt;SYSTEM&lt;/strong&gt; (&lt;code&gt;ORACLE_PASSWORD&lt;/code&gt;) because
the schema owner carries only &lt;code&gt;DB_DEVELOPER_ROLE&lt;/code&gt; and cannot read the
&lt;code&gt;DATA_PUMP_DIR&lt;/code&gt; directory object. &lt;code&gt;expdp SCHEMAS=keycloak&lt;/code&gt; writes the dump into
a GUID-named subdirectory of &lt;code&gt;DATA_PUMP_DIR&lt;/code&gt; inside the container; the script
locates it (&lt;code&gt;find /opt/oracle/admin&lt;/code&gt;) and &lt;code&gt;docker compose cp&lt;/code&gt;&#39;s it out to
&lt;code&gt;snapshots/&amp;lt;name&amp;gt;.dump&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;&lt;code&gt;restore.sh&lt;/code&gt; (oracle)&lt;/strong&gt; — &lt;code&gt;DROP USER keycloak CASCADE&lt;/code&gt;, recreate it exactly
as the gvenzl image does (&lt;code&gt;DB_DEVELOPER_ROLE&lt;/code&gt;, &lt;code&gt;USERS&lt;/code&gt; tablespace), copy the
dump back in, then &lt;code&gt;impdp SCHEMAS=keycloak EXCLUDE=USER TABLE_EXISTS_ACTION=REPLACE&lt;/code&gt;.
&lt;code&gt;EXCLUDE=USER&lt;/code&gt; avoids the benign &lt;code&gt;ORA-31684 user already exists&lt;/code&gt; (the user is
pre-created so the password/grants survive, which a SCHEMA-only dump does not
carry). An Oracle &lt;code&gt;model_version&lt;/code&gt; (read &lt;code&gt;keycloak.migration_model&lt;/code&gt; via SYSTEM)
preserves the no-re-migration gate.&lt;/li&gt;
&lt;/ul&gt;
&lt;h2 id=&quot;result&quot;&gt;Result&lt;/h2&gt;
&lt;pre&gt;&lt;code&gt;./bin/snapshot.sh baseline-oracle-test    -&amp;gt; snapshots/baseline-oracle-test.dump (4.8M)
./bin/restore.sh baseline-oracle-test
  ==&amp;gt; restored schema reports model 26.7.3
  ==&amp;gt; ready
  ==&amp;gt; restored baseline-oracle-test — running 26.7.3, schema 26.7.3, no re-migration
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Schema intact after restore: 100 tables, &lt;code&gt;migration_model&lt;/code&gt; 26.7.3, 1 user.&lt;/p&gt;
&lt;h2 id=&quot;remaining-oracle-item&quot;&gt;Remaining Oracle item&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;S4 DML-only&lt;/strong&gt; — untested; a non-owner user needs synonyms under the
user-is-schema model.&lt;/li&gt;
&lt;/ul&gt;
</content>
        
    </entry>
    <entry xml:lang="en">
        <title>Rehearsal Record — Oracle index-audit port</title>
        <published>2026-08-31T00:00:00+00:00</published>
        <updated>2026-08-31T00:00:00+00:00</updated>
        
        <author>
          <name>Unknown</name>
        </author>
        
        <link rel="alternate" type="text/html" href="https://ledger.mlabs.city/findings/2026-08-31-oracle-index-audit-port/"/>
        <id>https://ledger.mlabs.city/findings/2026-08-31-oracle-index-audit-port/</id>
        
        <content type="html" xml:base="https://ledger.mlabs.city/findings/2026-08-31-oracle-index-audit-port/">&lt;h2 id=&quot;what-was-done&quot;&gt;What was done&lt;/h2&gt;
&lt;p&gt;Ported &lt;code&gt;bin/index-audit.sh&lt;/code&gt; / &lt;code&gt;bin/index-audit.py&lt;/code&gt; to Oracle, closing the last
engine for the schema-correctness gate.&lt;/p&gt;
&lt;h2 id=&quot;the-port&quot;&gt;The port&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;&lt;code&gt;index-audit.py&lt;/code&gt;&lt;/strong&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;norm()&lt;/code&gt; now strips all double-quotes (Oracle writes expressions with quoted
uppercase identifiers: &lt;code&gt;SUBSTR(&quot;VALUE&quot;,1,250)&lt;/code&gt; → &lt;code&gt;substr(value,1,250)&lt;/code&gt;) and
folds the result to the changelog&#39;s &lt;code&gt;&amp;lt;modifySql dbms=&quot;oracle&quot;&amp;gt;&lt;/code&gt; form.&lt;/li&gt;
&lt;li&gt;&lt;code&gt;col_norm()&lt;/code&gt;&#39;s &lt;code&gt;(N)&lt;/code&gt; prefix-length strip is now scoped to &lt;code&gt;mysql&lt;/code&gt;/&lt;code&gt;mariadb&lt;/code&gt;
only (Oracle has no prefix-length columns; previously &lt;code&gt;!= postgresql&lt;/code&gt; also
applied it to Oracle).&lt;/li&gt;
&lt;li&gt;Added &lt;code&gt;parse_db_oracle()&lt;/code&gt; (index-name → ordered column list) and the
&lt;code&gt;--engine oracle&lt;/code&gt; choice.&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;&lt;code&gt;index-audit.sh&lt;/code&gt;&lt;/strong&gt;
&lt;ul&gt;
&lt;li&gt;The Oracle catalogue query reads &lt;code&gt;USER_IND_COLUMNS&lt;/code&gt; joined to
&lt;code&gt;USER_IND_EXPRESSIONS&lt;/code&gt;. &lt;code&gt;USER_IND_EXPRESSIONS.COLUMN_EXPRESSION&lt;/code&gt; is a &lt;code&gt;LONG&lt;/code&gt;,
which cannot be concatenated or &lt;code&gt;SUBSTR&lt;/code&gt;ed in a plain SELECT
(&lt;code&gt;ORA-00932&lt;/code&gt;/&lt;code&gt;ORA-24856&lt;/code&gt;), so the emission is a PL/SQL block that reads the
expression into a &lt;code&gt;VARCHAR2(4000)&lt;/code&gt; and writes &lt;code&gt;index|column&lt;/code&gt; lines via
&lt;code&gt;DBMS_OUTPUT&lt;/code&gt;. &lt;code&gt;BIN$%&lt;/code&gt; recycle-bin ghosts are excluded.&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;h2 id=&quot;result&quot;&gt;Result&lt;/h2&gt;
&lt;pre&gt;&lt;code&gt;--- 121 index definitions applied to this database: 121 correct, 0 missing, 0 wrong shape, 0 unverified (9 skipped: table dropped in a later version)
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The 26.7.3 Oracle schema is correct by definition — including the function-based
indexes (&lt;code&gt;IDX_GROUP_ATT_BY_NAME_VALUE&lt;/code&gt; → &lt;code&gt;SUBSTR(&quot;VALUE&quot;,1,250)&lt;/code&gt;), which is the
Oracle spelling of the same index that produced the postgres false-positive
earlier.&lt;/p&gt;
&lt;h2 id=&quot;remaining-oracle-tooling&quot;&gt;Remaining Oracle tooling&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;snapshot/restore&lt;/strong&gt; — still needs Data Pump (&lt;code&gt;expdp&lt;/code&gt;/&lt;code&gt;impdp&lt;/code&gt;); the one Oracle
item left unported.&lt;/li&gt;
&lt;/ul&gt;
&lt;h2 id=&quot;verification&quot;&gt;Verification&lt;/h2&gt;
&lt;table&gt;&lt;thead&gt;&lt;tr&gt;&lt;th&gt;Claim&lt;/th&gt;&lt;th&gt;Evidence&lt;/th&gt;&lt;/tr&gt;&lt;/thead&gt;&lt;tbody&gt;
&lt;tr&gt;&lt;td&gt;Function-based expressions fold to the changelog form&lt;/td&gt;&lt;td&gt;&lt;code&gt;SUBSTR(&quot;VALUE&quot;,1,250)&lt;/code&gt; vs &lt;code&gt;substr(value,1,250)&lt;/code&gt; matched, 121/121&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;LONG column handled without &lt;code&gt;ORA-00932&lt;/code&gt;&lt;/td&gt;&lt;td&gt;PL/SQL block + DBMS_OUTPUT, no errors&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Recycle-bin ghosts excluded&lt;/td&gt;&lt;td&gt;&lt;code&gt;WHERE index_name NOT LIKE &#39;BIN$%&#39;&lt;/code&gt;&lt;/td&gt;&lt;/tr&gt;
&lt;/tbody&gt;&lt;/table&gt;
</content>
        
    </entry>
    <entry xml:lang="en">
        <title>Rehearsal Record — 21.1.2 → 26.7.3 (stepped ladder, 100k rich)</title>
        <published>2026-08-31T00:00:00+00:00</published>
        <updated>2026-08-31T00:00:00+00:00</updated>
        
        <author>
          <name>Unknown</name>
        </author>
        
        <link rel="alternate" type="text/html" href="https://ledger.mlabs.city/runs/2026-08-31-21.1.2-to-26.7.3-stepped/"/>
        <id>https://ledger.mlabs.city/runs/2026-08-31-21.1.2-to-26.7.3-stepped/</id>
        
        <content type="html" xml:base="https://ledger.mlabs.city/runs/2026-08-31-21.1.2-to-26.7.3-stepped/">&lt;h2 id=&quot;environment&quot;&gt;Environment&lt;/h2&gt;
&lt;table&gt;&lt;thead&gt;&lt;tr&gt;&lt;th&gt;Field&lt;/th&gt;&lt;th&gt;Value&lt;/th&gt;&lt;/tr&gt;&lt;/thead&gt;&lt;tbody&gt;
&lt;tr&gt;&lt;td&gt;Keycloak from → to&lt;/td&gt;&lt;td&gt;21.1.2 → 26.7.3&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Stepping path&lt;/td&gt;&lt;td&gt;21.1.2 → 22.0.5 → 23.0.7 → 24.0.5 → 25.0.6 → 26.7.3&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Distribution&lt;/td&gt;&lt;td&gt;quay.io/keycloak/keycloak&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Start mode&lt;/td&gt;&lt;td&gt;&lt;code&gt;start&lt;/code&gt; w/ external Postgres 16, single node&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Dataset&lt;/td&gt;&lt;td&gt;&lt;code&gt;baseline-21.1.2-rich-100k&lt;/code&gt; — 1 realm, &lt;strong&gt;100,006 users&lt;/strong&gt;, rich fixture&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Seeding method&lt;/td&gt;&lt;td&gt;&lt;code&gt;partialImport&lt;/code&gt; (baseline pre-built)&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Host&lt;/td&gt;&lt;td&gt;laptop (docker 29.7.2)&lt;/td&gt;&lt;/tr&gt;
&lt;/tbody&gt;&lt;/table&gt;
&lt;h2 id=&quot;timings&quot;&gt;Timings&lt;/h2&gt;
&lt;table&gt;&lt;thead&gt;&lt;tr&gt;&lt;th&gt;Hop&lt;/th&gt;&lt;th&gt;Duration&lt;/th&gt;&lt;th&gt;Index audit&lt;/th&gt;&lt;/tr&gt;&lt;/thead&gt;&lt;tbody&gt;
&lt;tr&gt;&lt;td&gt;21.1.2 → 22.0.5&lt;/td&gt;&lt;td&gt;47s&lt;/td&gt;&lt;td&gt;84 defs: &lt;strong&gt;1 missing, 1 false-mismatch&lt;/strong&gt;&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;22.0.5 → 23.0.7&lt;/td&gt;&lt;td&gt;28s&lt;/td&gt;&lt;td&gt;84/84&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;23.0.7 → 24.0.5&lt;/td&gt;&lt;td&gt;24s&lt;/td&gt;&lt;td&gt;88/88&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;24.0.5 → 25.0.6&lt;/td&gt;&lt;td&gt;20s&lt;/td&gt;&lt;td&gt;89/89&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;25.0.6 → 26.7.3&lt;/td&gt;&lt;td&gt;20s&lt;/td&gt;&lt;td&gt;119/119&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;&lt;strong&gt;Total&lt;/strong&gt;&lt;/td&gt;&lt;td&gt;&lt;strong&gt;~139s&lt;/strong&gt;&lt;/td&gt;&lt;td&gt;&lt;/td&gt;&lt;/tr&gt;
&lt;/tbody&gt;&lt;/table&gt;
&lt;h2 id=&quot;outcome&quot;&gt;Outcome&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;&lt;input disabled=&quot;&quot; type=&quot;checkbox&quot; checked=&quot;&quot;/&gt;
All five hops reached ready; final state 26.7.3, 100,006 users intact&lt;/li&gt;
&lt;li&gt;&lt;input disabled=&quot;&quot; type=&quot;checkbox&quot; checked=&quot;&quot;/&gt;
Index audits pass end-to-end after the two findings below are handled&lt;/li&gt;
&lt;/ul&gt;
&lt;h2 id=&quot;breakage-observed&quot;&gt;Breakage observed&lt;/h2&gt;
&lt;h3 id=&quot;1-idx-client-att-by-name-value-genuinely-missing-pre-existing-in-the-21-1-2-baseline&quot;&gt;1. &lt;code&gt;idx_client_att_by_name_value&lt;/code&gt; genuinely missing (pre-existing in the 21.1.2 baseline)&lt;/h3&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Symptom:&lt;/strong&gt; audit at 22.0.5: &lt;code&gt;MISSING idx_client_att_by_name_value on client_attributes&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Evidence:&lt;/strong&gt; index absent from &lt;code&gt;pg_indexes&lt;/code&gt;; &lt;code&gt;client_attributes&lt;/code&gt; has 30 rows, so
the ~300k-row skip heuristic does &lt;strong&gt;not&lt;/strong&gt; explain it. Changeset
&lt;code&gt;14.0.0-KEYCLOAK-18286-supported-dbs&lt;/code&gt; is marked EXECUTED.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Cause:&lt;/strong&gt; pre-existing in the 21.1.2 baseline (a 14.0.0 changeset predates the
hop; the 22.0.5 migration does not touch it). Not introduced by the ladder.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Fix:&lt;/strong&gt; &lt;code&gt;CREATE INDEX CONCURRENTLY idx_client_att_by_name_value ON client_attributes (name, (value::varchar(250)));&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Would this hit a customer?&lt;/strong&gt; Yes — a 21.1.2 install that skipped this index
looks migrated (schema stamp advances) while missing an index. This is exactly
the class the index-audit exists to catch, and it validates shipping the audit
as part of the runbook rather than trusting the schema version.&lt;/li&gt;
&lt;/ul&gt;
&lt;h3 id=&quot;2-index-audit-py-false-positive-varchar-vs-character-varying&quot;&gt;2. &lt;code&gt;index-audit.py&lt;/code&gt; false positive: &lt;code&gt;varchar&lt;/code&gt; vs &lt;code&gt;character varying&lt;/code&gt;&lt;/h3&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Symptom:&lt;/strong&gt; audit flagged &lt;code&gt;idx_group_att_by_name_value&lt;/code&gt; as MISMATCH —
changelog &lt;code&gt;(name, (value::varchar(250)))&lt;/code&gt; vs DB &lt;code&gt;(name, ((value)::character varying(250)))&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Cause:&lt;/strong&gt; &lt;code&gt;norm()&lt;/code&gt; only stripped a cast at end-of-token and did not fold the
&lt;code&gt;varchar&lt;/code&gt;/&lt;code&gt;character varying&lt;/code&gt; alias or collapse redundant parens. Same type,
different spelling.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Fix:&lt;/strong&gt; &lt;code&gt;bin/index-audit.py&lt;/code&gt; &lt;code&gt;norm()&lt;/code&gt; now folds &lt;code&gt;charactervarying&lt;/code&gt;→&lt;code&gt;varchar&lt;/code&gt;,
strips &lt;code&gt;::type[(n)]&lt;/code&gt; casts anywhere, and collapses redundant parens.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Would this hit a customer?&lt;/strong&gt; No — tool-only. Re-audit passes 84/84.&lt;/li&gt;
&lt;/ul&gt;
&lt;h2 id=&quot;rollback&quot;&gt;Rollback&lt;/h2&gt;
&lt;p&gt;Not exercised on this path this run (exercised on the patch-hop path in run 3).&lt;/p&gt;
</content>
        
    </entry>
    <entry xml:lang="en">
        <title>Rehearsal Record — 26.0.0 → 26.7.3 (within-26 direct)</title>
        <published>2026-08-31T00:00:00+00:00</published>
        <updated>2026-08-31T00:00:00+00:00</updated>
        
        <author>
          <name>Unknown</name>
        </author>
        
        <link rel="alternate" type="text/html" href="https://ledger.mlabs.city/runs/2026-08-31-26.0.0-to-26.7.3-1/"/>
        <id>https://ledger.mlabs.city/runs/2026-08-31-26.0.0-to-26.7.3-1/</id>
        
        <content type="html" xml:base="https://ledger.mlabs.city/runs/2026-08-31-26.0.0-to-26.7.3-1/">&lt;h2 id=&quot;environment&quot;&gt;Environment&lt;/h2&gt;
&lt;table&gt;&lt;thead&gt;&lt;tr&gt;&lt;th&gt;Field&lt;/th&gt;&lt;th&gt;Value&lt;/th&gt;&lt;/tr&gt;&lt;/thead&gt;&lt;tbody&gt;
&lt;tr&gt;&lt;td&gt;Keycloak from → to&lt;/td&gt;&lt;td&gt;26.0.0 → 26.7.3&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Stepping path&lt;/td&gt;&lt;td&gt;direct (no stepping)&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Distribution&lt;/td&gt;&lt;td&gt;quay.io/keycloak/keycloak&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Start mode&lt;/td&gt;&lt;td&gt;&lt;code&gt;start&lt;/code&gt; w/ external Postgres 16, single node&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Dataset&lt;/td&gt;&lt;td&gt;&lt;code&gt;baseline-26.0.0-typical-1k&lt;/code&gt; — 1,000 users&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Host&lt;/td&gt;&lt;td&gt;laptop&lt;/td&gt;&lt;/tr&gt;
&lt;/tbody&gt;&lt;/table&gt;
&lt;h2 id=&quot;procedure&quot;&gt;Procedure&lt;/h2&gt;
&lt;pre&gt;&lt;code&gt;./bin/restore.sh baseline-26.0.0-typical-1k
./bin/upgrade.sh 26.7.3
&lt;/code&gt;&lt;/pre&gt;
&lt;h2 id=&quot;timings&quot;&gt;Timings&lt;/h2&gt;
&lt;table&gt;&lt;thead&gt;&lt;tr&gt;&lt;th&gt;Phase&lt;/th&gt;&lt;th&gt;Duration&lt;/th&gt;&lt;/tr&gt;&lt;/thead&gt;&lt;tbody&gt;
&lt;tr&gt;&lt;td&gt;&lt;strong&gt;Total to ready&lt;/strong&gt;&lt;/td&gt;&lt;td&gt;&lt;strong&gt;20s&lt;/strong&gt;&lt;/td&gt;&lt;/tr&gt;
&lt;/tbody&gt;&lt;/table&gt;
&lt;h2 id=&quot;outcome&quot;&gt;Outcome&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;&lt;input disabled=&quot;&quot; type=&quot;checkbox&quot; checked=&quot;&quot;/&gt;
Reached ready, 26.7.3&lt;/li&gt;
&lt;li&gt;&lt;input disabled=&quot;&quot; type=&quot;checkbox&quot; checked=&quot;&quot;/&gt;
Index audit 119/119 correct&lt;/li&gt;
&lt;li&gt;&lt;input disabled=&quot;&quot; type=&quot;checkbox&quot; checked=&quot;&quot;/&gt;
Realm intact (1,000 users)&lt;/li&gt;
&lt;/ul&gt;
&lt;h2 id=&quot;breakage-observed&quot;&gt;Breakage observed&lt;/h2&gt;
&lt;p&gt;None.&lt;/p&gt;
&lt;h2 id=&quot;rollback&quot;&gt;Rollback&lt;/h2&gt;
&lt;p&gt;Not exercised this path.&lt;/p&gt;
</content>
        
    </entry>
    <entry xml:lang="en">
        <title>Rehearsal Record — 26.7.1 → 26.7.3</title>
        <published>2026-08-31T00:00:00+00:00</published>
        <updated>2026-08-31T00:00:00+00:00</updated>
        
        <author>
          <name>Unknown</name>
        </author>
        
        <link rel="alternate" type="text/html" href="https://ledger.mlabs.city/runs/2026-08-31-26.7.1-to-26.7.3-1/"/>
        <id>https://ledger.mlabs.city/runs/2026-08-31-26.7.1-to-26.7.3-1/</id>
        
        <content type="html" xml:base="https://ledger.mlabs.city/runs/2026-08-31-26.7.1-to-26.7.3-1/">&lt;h2 id=&quot;environment&quot;&gt;Environment&lt;/h2&gt;
&lt;table&gt;&lt;thead&gt;&lt;tr&gt;&lt;th&gt;Field&lt;/th&gt;&lt;th&gt;Value&lt;/th&gt;&lt;/tr&gt;&lt;/thead&gt;&lt;tbody&gt;
&lt;tr&gt;&lt;td&gt;Keycloak from → to&lt;/td&gt;&lt;td&gt;26.7.1 → 26.7.3&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Stepping path (if multi-hop)&lt;/td&gt;&lt;td&gt;direct (patch hop, no stepping)&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Distribution&lt;/td&gt;&lt;td&gt;quay.io/keycloak/keycloak (official image)&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Start mode&lt;/td&gt;&lt;td&gt;&lt;code&gt;start&lt;/code&gt; w/ external Postgres — &lt;strong&gt;never &lt;code&gt;start-dev&lt;/code&gt;&lt;/strong&gt;&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Database&lt;/td&gt;&lt;td&gt;Postgres 16, single node&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Postgres tuning&lt;/td&gt;&lt;td&gt;stock defaults (no shared_buffers / maintenance_work_mem / statement_timeout override)&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Adverse scenario&lt;/td&gt;&lt;td&gt;none — routine patch hop, motivated by the 2026-08 advisory batch&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Dataset scale&lt;/td&gt;&lt;td&gt;1 realm (&lt;code&gt;lab&lt;/code&gt;), &lt;strong&gt;1,000 users&lt;/strong&gt;, 11 clients (5 seeded + 6 default), 1 group (&lt;code&gt;corp&lt;/code&gt; + 3 children), 2 IdP brokers, 1 LDAP component, 1 organization, 1 fine-grained-authz client&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Seeding method&lt;/td&gt;&lt;td&gt;&lt;code&gt;partialImport&lt;/code&gt; (1,000 users in 69s)&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Fixture profile&lt;/td&gt;&lt;td&gt;&lt;code&gt;kitchen-sink&lt;/code&gt;&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Topology&lt;/td&gt;&lt;td&gt;single container&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Host&lt;/td&gt;&lt;td&gt;laptop (Arch Linux, docker 29.7.2 / compose 5.5.0)&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;JVM heap&lt;/td&gt;&lt;td&gt;&lt;code&gt;-Xms1g -Xmx4g&lt;/code&gt;&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Elapsed clock&lt;/td&gt;&lt;td&gt;&lt;strong&gt;24s&lt;/strong&gt; (stop → ready)&lt;/td&gt;&lt;/tr&gt;
&lt;/tbody&gt;&lt;/table&gt;
&lt;h2 id=&quot;procedure&quot;&gt;Procedure&lt;/h2&gt;
&lt;pre&gt;&lt;code&gt;./bin/lab-up.sh 26.7.1
./bin/seed-realm.sh --profile kitchen-sink --users 1000
./bin/upgrade.sh 26.7.3     # stop → pg_dump backup → image swap → up → wait-ready → index-audit
&lt;/code&gt;&lt;/pre&gt;
&lt;h2 id=&quot;timings&quot;&gt;Timings&lt;/h2&gt;
&lt;table&gt;&lt;thead&gt;&lt;tr&gt;&lt;th&gt;Phase&lt;/th&gt;&lt;th&gt;Duration&lt;/th&gt;&lt;th&gt;Notes&lt;/th&gt;&lt;/tr&gt;&lt;/thead&gt;&lt;tbody&gt;
&lt;tr&gt;&lt;td&gt;Container start → DB connect&lt;/td&gt;&lt;td&gt;—&lt;/td&gt;&lt;td&gt;not isolated this run&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Liquibase / schema migration&lt;/td&gt;&lt;td&gt;&lt;strong&gt;0 changesets&lt;/strong&gt;&lt;/td&gt;&lt;td&gt;no migration log lines emitted; see Finding&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Cache/realm warm&lt;/td&gt;&lt;td&gt;—&lt;/td&gt;&lt;td&gt;not isolated&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;First successful token issue&lt;/td&gt;&lt;td&gt;—&lt;/td&gt;&lt;td&gt;admin-cli token issued immediately after ready&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;&lt;strong&gt;Total to ready&lt;/strong&gt;&lt;/td&gt;&lt;td&gt;&lt;strong&gt;24s&lt;/strong&gt;&lt;/td&gt;&lt;td&gt;includes image pull for 26.7.3&lt;/td&gt;&lt;/tr&gt;
&lt;/tbody&gt;&lt;/table&gt;
&lt;h2 id=&quot;outcome&quot;&gt;Outcome&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;&lt;input disabled=&quot;&quot; type=&quot;checkbox&quot; checked=&quot;&quot;/&gt;
Reached ready (&lt;code&gt;wait-ready.sh&lt;/code&gt; → READY)&lt;/li&gt;
&lt;li&gt;&lt;input disabled=&quot;&quot; type=&quot;checkbox&quot; checked=&quot;&quot;/&gt;
Realm config intact — verified via admin API post-upgrade: 1,000 users, all 11 clients, &lt;code&gt;corp-oidc&lt;/code&gt; + &lt;code&gt;corp-saml&lt;/code&gt; brokers, &lt;code&gt;corp-ldap&lt;/code&gt; component, &lt;code&gt;lab-org&lt;/code&gt;, &lt;code&gt;corp&lt;/code&gt; group all present&lt;/li&gt;
&lt;li&gt;&lt;input disabled=&quot;&quot; type=&quot;checkbox&quot; checked=&quot;&quot;/&gt;
Token endpoint works (admin-cli)&lt;/li&gt;
&lt;li&gt;&lt;input disabled=&quot;&quot; type=&quot;checkbox&quot;/&gt;
Login flow works (end-user password grant not re-exercised this run)&lt;/li&gt;
&lt;li&gt;&lt;input disabled=&quot;&quot; type=&quot;checkbox&quot;/&gt;
Custom SPI loaded (none deployed — &lt;code&gt;fixtures/providers&lt;/code&gt; empty)&lt;/li&gt;
&lt;li&gt;&lt;input disabled=&quot;&quot; type=&quot;checkbox&quot;/&gt;
Themes render (theme files not mounted)&lt;/li&gt;
&lt;li&gt;&lt;input disabled=&quot;&quot; type=&quot;checkbox&quot;/&gt;
Federation/brokering intact end-to-end (components present; no live IdP/LDAP to sync against)&lt;/li&gt;
&lt;/ul&gt;
&lt;h2 id=&quot;breakage-observed&quot;&gt;Breakage observed&lt;/h2&gt;
&lt;p&gt;None attributable to the migration. One pre-existing seed-time issue (not a
migration result):&lt;/p&gt;
&lt;h3 id=&quot;seed-realm-user-profile-400-on-26-7-1&quot;&gt;seed-realm &lt;code&gt;--user-profile&lt;/code&gt; 400 on 26.7.1&lt;/h3&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Symptom:&lt;/strong&gt; declarative user-profile PUT rejected with
&lt;code&gt;[Validator &#39;username_prohibited_characters&#39; defined for attribute &#39;username&#39; doesn&#39;t exist]&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Evidence:&lt;/strong&gt; seed-realm.sh output, before the upgrade ran.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Cause:&lt;/strong&gt; the seed fixture names a validator that no longer exists at 26.7.1.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Fix or workaround:&lt;/strong&gt; correct the validator name in &lt;code&gt;bin/seed-realm.sh&lt;/code&gt;
(or drop it for 26.7.x). Not chased this run — out of scope for the patch hop.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Would this hit a customer?&lt;/strong&gt; No — fixture-only, no bearing on migration.&lt;/li&gt;
&lt;/ul&gt;
&lt;h2 id=&quot;rollback&quot;&gt;Rollback&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Exercised this run?&lt;/strong&gt; no&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Method:&lt;/strong&gt; snapshot restore — backup &lt;code&gt;pre-upgrade-26.7.1-to-26.7.3-20260831T212644Z&lt;/code&gt; (pg_dump -Fc)&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Time to restore:&lt;/strong&gt; not measured&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Data loss / divergence:&lt;/strong&gt; n/a&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Verdict:&lt;/strong&gt; backup taken; restore path unexercised (must be exercised on one
of the remaining two clean runs per launch-plan §6).&lt;/li&gt;
&lt;/ul&gt;
&lt;h2 id=&quot;verification&quot;&gt;Verification&lt;/h2&gt;
&lt;table&gt;&lt;thead&gt;&lt;tr&gt;&lt;th&gt;Claim&lt;/th&gt;&lt;th&gt;Primary source&lt;/th&gt;&lt;th&gt;Checked&lt;/th&gt;&lt;/tr&gt;&lt;/thead&gt;&lt;tbody&gt;
&lt;tr&gt;&lt;td&gt;26.7.1 already carries the 2026-08-06 advisory batch (7 CVEs, all &lt;code&gt;keycloak-services&lt;/code&gt;)&lt;/td&gt;&lt;td&gt;GHSA-95cx-vmr5-3cmr etc.; affected range &lt;code&gt;&amp;lt; 26.7.1&lt;/code&gt;, patched &lt;code&gt;26.7.1&lt;/code&gt;&lt;/td&gt;&lt;td&gt;yes&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;26.7.3 adds the 26.7.2 + 26.7.3 security batches (~27 fixes) missing from 26.7.1&lt;/td&gt;&lt;td&gt;26.7.2 / 26.7.3 release notes (github.com/keycloak/keycloak/releases)&lt;/td&gt;&lt;td&gt;yes&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;26.7.1 → 26.7.3 emits no Liquibase changesets&lt;/td&gt;&lt;td&gt;this run&#39;s log (no &lt;code&gt;liquibase&lt;/code&gt;/&lt;code&gt;changelog&lt;/code&gt; lines)&lt;/td&gt;&lt;td&gt;yes&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Schema indexes correct at 26.7.3&lt;/td&gt;&lt;td&gt;&lt;code&gt;index-audit.sh 26.7.3&lt;/code&gt;: 119/119 correct, 0 missing/wrong&lt;/td&gt;&lt;td&gt;yes&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;26.7.2 → 26.7.3 removes 4 SPI signatures&lt;/td&gt;&lt;td&gt;&lt;code&gt;reports/diffs/api-26.7.2-to-26.7.3.txt&lt;/code&gt;&lt;/td&gt;&lt;td&gt;yes&lt;/td&gt;&lt;/tr&gt;
&lt;/tbody&gt;&lt;/table&gt;
&lt;h2 id=&quot;publishable-extract&quot;&gt;Publishable extract&lt;/h2&gt;
&lt;p&gt;(empty — internal; needs the two remaining clean runs + rollback before a
ledger entry can be derived.)&lt;/p&gt;
</content>
        
    </entry>
    <entry xml:lang="en">
        <title>Rehearsal Record — 26.7.1 → 26.7.3 (run 2)</title>
        <published>2026-08-31T00:00:00+00:00</published>
        <updated>2026-08-31T00:00:00+00:00</updated>
        
        <author>
          <name>Unknown</name>
        </author>
        
        <link rel="alternate" type="text/html" href="https://ledger.mlabs.city/runs/2026-08-31-26.7.1-to-26.7.3-2/"/>
        <id>https://ledger.mlabs.city/runs/2026-08-31-26.7.1-to-26.7.3-2/</id>
        
        <content type="html" xml:base="https://ledger.mlabs.city/runs/2026-08-31-26.7.1-to-26.7.3-2/">&lt;h2 id=&quot;environment&quot;&gt;Environment&lt;/h2&gt;
&lt;p&gt;Identical to run 1 (&lt;a href=&quot;/runs/2026-08-31-26.7.1-to-26.7.3-1/&quot;&gt;&lt;code&gt;2026-08-31-26.7.1-to-26.7.3-1&lt;/code&gt;&lt;/a&gt;) except:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Fixture was re-seeded from scratch after the &lt;code&gt;seed-realm.sh --user-profile&lt;/code&gt;
fix (&lt;code&gt;username-prohibited-characters&lt;/code&gt;), so the declarative user profile is now
present in the fixture.&lt;/li&gt;
&lt;li&gt;Baseline: &lt;code&gt;baseline-26.7.1-kitchen-sink-1k&lt;/code&gt; (pg_dump -Fc, 544K).&lt;/li&gt;
&lt;/ul&gt;
&lt;h2 id=&quot;procedure&quot;&gt;Procedure&lt;/h2&gt;
&lt;pre&gt;&lt;code&gt;./bin/lab-down.sh --purge
./bin/lab-up.sh 26.7.1
./bin/seed-realm.sh --profile kitchen-sink --users 1000     # user-profile now seeds
./bin/snapshot.sh baseline-26.7.1-kitchen-sink-1k
./bin/upgrade.sh 26.7.3
&lt;/code&gt;&lt;/pre&gt;
&lt;h2 id=&quot;timings&quot;&gt;Timings&lt;/h2&gt;
&lt;table&gt;&lt;thead&gt;&lt;tr&gt;&lt;th&gt;Phase&lt;/th&gt;&lt;th&gt;Duration&lt;/th&gt;&lt;/tr&gt;&lt;/thead&gt;&lt;tbody&gt;
&lt;tr&gt;&lt;td&gt;&lt;strong&gt;Total to ready&lt;/strong&gt;&lt;/td&gt;&lt;td&gt;&lt;strong&gt;16s&lt;/strong&gt;&lt;/td&gt;&lt;/tr&gt;
&lt;/tbody&gt;&lt;/table&gt;
&lt;h2 id=&quot;outcome&quot;&gt;Outcome&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;&lt;input disabled=&quot;&quot; type=&quot;checkbox&quot; checked=&quot;&quot;/&gt;
Reached ready&lt;/li&gt;
&lt;li&gt;&lt;input disabled=&quot;&quot; type=&quot;checkbox&quot; checked=&quot;&quot;/&gt;
Schema migration: 0 changesets (no Liquibase lines)&lt;/li&gt;
&lt;li&gt;&lt;input disabled=&quot;&quot; type=&quot;checkbox&quot; checked=&quot;&quot;/&gt;
Index audit 119/119 correct, 0 missing/wrong&lt;/li&gt;
&lt;li&gt;&lt;input disabled=&quot;&quot; type=&quot;checkbox&quot; checked=&quot;&quot;/&gt;
Realm config intact (1,000 users, 11 clients — verified after restore in run 3)&lt;/li&gt;
&lt;/ul&gt;
&lt;h2 id=&quot;breakage-observed&quot;&gt;Breakage observed&lt;/h2&gt;
&lt;p&gt;None.&lt;/p&gt;
&lt;h2 id=&quot;rollback&quot;&gt;Rollback&lt;/h2&gt;
&lt;p&gt;Not exercised this run (exercised in run 3).&lt;/p&gt;
</content>
        
    </entry>
    <entry xml:lang="en">
        <title>Rehearsal Record — 26.7.1 → 26.7.3 (run 3, rollback exercised)</title>
        <published>2026-08-31T00:00:00+00:00</published>
        <updated>2026-08-31T00:00:00+00:00</updated>
        
        <author>
          <name>Unknown</name>
        </author>
        
        <link rel="alternate" type="text/html" href="https://ledger.mlabs.city/runs/2026-08-31-26.7.1-to-26.7.3-3/"/>
        <id>https://ledger.mlabs.city/runs/2026-08-31-26.7.1-to-26.7.3-3/</id>
        
        <content type="html" xml:base="https://ledger.mlabs.city/runs/2026-08-31-26.7.1-to-26.7.3-3/">&lt;h2 id=&quot;environment&quot;&gt;Environment&lt;/h2&gt;
&lt;p&gt;Identical to run 2 (same &lt;code&gt;baseline-26.7.1-kitchen-sink-1k&lt;/code&gt;).&lt;/p&gt;
&lt;h2 id=&quot;procedure&quot;&gt;Procedure&lt;/h2&gt;
&lt;pre&gt;&lt;code&gt;./bin/restore.sh baseline-26.7.1-kitchen-sink-1k   # reset to 26.7.1
./bin/upgrade.sh 26.7.3                            # -&amp;gt; 26.7.3, backup taken
./bin/restore.sh pre-upgrade-26.7.1-to-26.7.3-20260831T213925Z   # rollback
&lt;/code&gt;&lt;/pre&gt;
&lt;h2 id=&quot;timings&quot;&gt;Timings&lt;/h2&gt;
&lt;table&gt;&lt;thead&gt;&lt;tr&gt;&lt;th&gt;Phase&lt;/th&gt;&lt;th&gt;Duration&lt;/th&gt;&lt;/tr&gt;&lt;/thead&gt;&lt;tbody&gt;
&lt;tr&gt;&lt;td&gt;&lt;strong&gt;Upgrade to ready&lt;/strong&gt;&lt;/td&gt;&lt;td&gt;&lt;strong&gt;25s&lt;/strong&gt;&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;&lt;strong&gt;Rollback to ready&lt;/strong&gt;&lt;/td&gt;&lt;td&gt;~30s (drop + pg_restore + recreate + boot)&lt;/td&gt;&lt;/tr&gt;
&lt;/tbody&gt;&lt;/table&gt;
&lt;h2 id=&quot;outcome&quot;&gt;Outcome&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;&lt;input disabled=&quot;&quot; type=&quot;checkbox&quot; checked=&quot;&quot;/&gt;
Upgrade reached ready, 0 changesets, index audit 119/119&lt;/li&gt;
&lt;li&gt;&lt;input disabled=&quot;&quot; type=&quot;checkbox&quot; checked=&quot;&quot;/&gt;
Rollback restored the pre-upgrade dump and returned to 26.7.1&lt;/li&gt;
&lt;li&gt;&lt;input disabled=&quot;&quot; type=&quot;checkbox&quot; checked=&quot;&quot;/&gt;
&lt;strong&gt;No re-migration&lt;/strong&gt;: schema stamp &lt;code&gt;26.7.1&lt;/code&gt; before and after the recreate&lt;/li&gt;
&lt;li&gt;&lt;input disabled=&quot;&quot; type=&quot;checkbox&quot; checked=&quot;&quot;/&gt;
Data intact post-rollback: 1,000 users, 11 clients&lt;/li&gt;
&lt;/ul&gt;
&lt;h2 id=&quot;breakage-observed&quot;&gt;Breakage observed&lt;/h2&gt;
&lt;h3 id=&quot;restore-sh-postgres-rollback-path-was-broken-model-version-command-not-found&quot;&gt;&lt;code&gt;restore.sh&lt;/code&gt; postgres rollback path was broken (&lt;code&gt;model_version: command not found&lt;/code&gt;)&lt;/h3&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Symptom:&lt;/strong&gt; on the postgres engine, &lt;code&gt;restore.sh&lt;/code&gt; aborted at
&lt;code&gt;SCHEMA=$(model_version)&lt;/code&gt; — the function is defined only in the
&lt;code&gt;mysql|mariadb&lt;/code&gt; branch of the first &lt;code&gt;case &quot;$ENGINE&quot;&lt;/code&gt;, but is called
unconditionally for all engines.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Evidence:&lt;/strong&gt; &lt;code&gt;./bin/restore.sh: line 118: model_version: command not found&lt;/code&gt;;
the DB was restored (schema at 26.7.1) but the script exited before the
&lt;code&gt;--force-recreate&lt;/code&gt; and before the re-migration check.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Cause:&lt;/strong&gt; regression from the multi-engine refactor — the postgres
&lt;code&gt;model_version&lt;/code&gt; definition was dropped.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Fix:&lt;/strong&gt; re-added the postgres &lt;code&gt;model_version()&lt;/code&gt; (with the S13 search_path
care) to the &lt;code&gt;*&lt;/code&gt; branch. Fix is in &lt;code&gt;bin/restore.sh&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Would this hit a customer?&lt;/strong&gt; Only our own lab tooling — but it means the
rollback &lt;em&gt;verification&lt;/em&gt; gate had been silently non-functional for postgres.
This run is the first to actually exercise it since the refactor.&lt;/li&gt;
&lt;/ul&gt;
&lt;h2 id=&quot;rollback&quot;&gt;Rollback&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Exercised this run?&lt;/strong&gt; yes&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Method:&lt;/strong&gt; pg_dump -Fc restore (&lt;code&gt;dropdb&lt;/code&gt; + &lt;code&gt;createdb&lt;/code&gt; + &lt;code&gt;pg_restore --no-owner&lt;/code&gt;), then &lt;code&gt;--force-recreate&lt;/code&gt; at 26.7.1&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Time to restore:&lt;/strong&gt; ~30s at 1k users&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Data loss / divergence:&lt;/strong&gt; none (1,000 users, 11 clients intact)&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Verdict:&lt;/strong&gt; rollback IS a point-in-time restore; no schema downgrade, no
re-migration. Confirmed available on this path.&lt;/li&gt;
&lt;/ul&gt;
</content>
        
    </entry>
    <entry xml:lang="en">
        <title>Rehearsal Record — Kubernetes / Infinispan rolling update at 26.7.3</title>
        <published>2026-08-31T00:00:00+00:00</published>
        <updated>2026-08-31T00:00:00+00:00</updated>
        
        <author>
          <name>Unknown</name>
        </author>
        
        <link rel="alternate" type="text/html" href="https://ledger.mlabs.city/runs/2026-08-31-k8s-infinispan-rolling-update-26.7.3/"/>
        <id>https://ledger.mlabs.city/runs/2026-08-31-k8s-infinispan-rolling-update-26.7.3/</id>
        
        <content type="html" xml:base="https://ledger.mlabs.city/runs/2026-08-31-k8s-infinispan-rolling-update-26.7.3/">&lt;h2 id=&quot;environment&quot;&gt;Environment&lt;/h2&gt;
&lt;table&gt;&lt;thead&gt;&lt;tr&gt;&lt;th&gt;Field&lt;/th&gt;&lt;th&gt;Value&lt;/th&gt;&lt;/tr&gt;&lt;/thead&gt;&lt;tbody&gt;
&lt;tr&gt;&lt;td&gt;Keycloak from → to&lt;/td&gt;&lt;td&gt;26.0.0 → 26.7.3&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Distribution / start mode&lt;/td&gt;&lt;td&gt;quay.io/keycloak/keycloak, &lt;code&gt;start&lt;/code&gt; w/ external DB&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Database&lt;/td&gt;&lt;td&gt;PostgreSQL 16, single pod (StatefulSet), in-cluster&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Cache&lt;/td&gt;&lt;td&gt;embedded Infinispan, &lt;code&gt;KC_CACHE_STACK=kubernetes&lt;/code&gt; (DNS_PING)&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Cluster&lt;/td&gt;&lt;td&gt;&lt;strong&gt;kind &lt;code&gt;kclab&lt;/code&gt;&lt;/strong&gt; (k8s v1.37.0, kind v0.33.0) — note: laptop, not the k3d box&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Topology&lt;/td&gt;&lt;td&gt;Deployment &lt;code&gt;replicas: 2&lt;/code&gt;, &lt;code&gt;maxSurge=1, maxUnavailable=0&lt;/code&gt;&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Host&lt;/td&gt;&lt;td&gt;laptop (16 vCPU, 31 GB RAM)&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;JVM heap&lt;/td&gt;&lt;td&gt;&lt;code&gt;-Xms256m -Xmx1g&lt;/code&gt;&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Dataset&lt;/td&gt;&lt;td&gt;&lt;code&gt;master&lt;/code&gt; realm + &lt;code&gt;lab&lt;/code&gt; realm (1 user)&lt;/td&gt;&lt;/tr&gt;
&lt;/tbody&gt;&lt;/table&gt;
&lt;h2 id=&quot;result-rolling-update-completes-cleanly-cache-still-partitions&quot;&gt;Result — rolling update completes cleanly, cache still partitions&lt;/h2&gt;
&lt;p&gt;Timeline (from pod logs):&lt;/p&gt;
&lt;table&gt;&lt;thead&gt;&lt;tr&gt;&lt;th&gt;t&lt;/th&gt;&lt;th&gt;event&lt;/th&gt;&lt;/tr&gt;&lt;/thead&gt;&lt;tbody&gt;
&lt;tr&gt;&lt;td&gt;0s&lt;/td&gt;&lt;td&gt;&lt;code&gt;set image&lt;/code&gt; → new 26.7.3 pod (Infinispan &lt;code&gt;v=16.0.14&lt;/code&gt;) starts, old 26.0.0 pods (&lt;code&gt;v=15.0.8&lt;/code&gt;) still serving&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;~25s&lt;/td&gt;&lt;td&gt;new pod forms a &lt;strong&gt;1-member&lt;/strong&gt; cluster (&lt;code&gt;v=16.0.14&lt;/code&gt;); &lt;code&gt;no members discovered&lt;/code&gt;&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;~36s&lt;/td&gt;&lt;td&gt;second new pod starts; first new pod ready; first old pod terminated&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;~47s&lt;/td&gt;&lt;td&gt;the two new pods form a &lt;strong&gt;2-member&lt;/strong&gt; cluster (&lt;code&gt;v=16.0.14&lt;/code&gt;)&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;~57s&lt;/td&gt;&lt;td&gt;last old pod terminated; rollout complete&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;end&lt;/td&gt;&lt;td&gt;&lt;code&gt;migration_model&lt;/code&gt; = &lt;strong&gt;26.7.3&lt;/strong&gt;, both pods ready&lt;/td&gt;&lt;/tr&gt;
&lt;/tbody&gt;&lt;/table&gt;
&lt;h2 id=&quot;findings&quot;&gt;Findings&lt;/h2&gt;
&lt;ol&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;Schema migration stays additive at 26.7.3&lt;/strong&gt; — &lt;code&gt;migration_model&lt;/code&gt; reached
26.7.3, old pods logged no schema errors, rollout completed under
&lt;code&gt;maxUnavailable=0&lt;/code&gt;. The database layer is safe; the cache is the risk, as at
26.7.1.&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;The cache still partitions during the transition.&lt;/strong&gt; 26.7.3 ships
&lt;strong&gt;Infinispan 16.0.14&lt;/strong&gt; (26.7.1 was 16.0.12; 26.0.0 is 15.0.8). The new pods
never merge with the old &lt;code&gt;v=15.0.8&lt;/code&gt; cluster — they form a separate 1-member
cluster and only merge with each other once the old pods are gone. Same
operational consequence as the 26.7.1 record: sessions are not shared between
old and new pods during the window.&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;New signal: 26.7.3 enables JGroups encryption (mTLS).&lt;/strong&gt; The 26.7.3 pod logs
&lt;code&gt;JGroups Encryption enabled (mTLS)&lt;/code&gt;. This is a second axis of inter-version
incompatibility in addition to the Infinispan/JGroups version bump, and
belongs in the pre-flight as a thing to check when crossing into 26.7.x.&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;kind vs k3d: a discovery race at first boot.&lt;/strong&gt; On kind, two pods started
simultaneously each became a 1-member cluster (&lt;code&gt;no members discovered after 2.00s&lt;/code&gt;) and did not merge on their own; deleting one pod triggered DNS_PING
re-discovery and the 2-member cluster formed. The k3d run (original record)
clustered on first boot. This is an environment/timing difference, not a
Keycloak change — but it means the baseline &quot;2-member cluster&quot; must be
verified explicitly before relying on a kind run.&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;Cross-pod session sharing confirmed at the final 26.7.3 state&lt;/strong&gt; — a refresh
token issued on pod A was accepted on pod B (HTTP 200), proving the healed
2-member cluster shares the session cache.&lt;/p&gt;
&lt;/li&gt;
&lt;/ol&gt;
&lt;h2 id=&quot;what-is-still-not-tested&quot;&gt;What is still not tested&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;Two new pods migrating &lt;strong&gt;concurrently&lt;/strong&gt; (here &lt;code&gt;maxSurge=1&lt;/code&gt; made them sequential).&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Functional session stranding&lt;/strong&gt; inside the transition window (issuing on an
old pod and failing on a new pod) — the partition is proven at the cluster-view
level, not the HTTP level. The explicit &lt;code&gt;JGRP000006 cookie does not match&lt;/code&gt; line
from the 26.7.1 record was in the &lt;em&gt;old&lt;/em&gt; pods&#39; logs, which the Deployment
deletes on termination; reproducing it cleanly needs live log capture during
the rollout.&lt;/li&gt;
&lt;li&gt;The 26.7.3 default &lt;code&gt;jdbc-ping&lt;/code&gt; stack (this run pinned &lt;code&gt;kubernetes&lt;/code&gt; to keep the
version the only variable).&lt;/li&gt;
&lt;/ul&gt;
</content>
        
    </entry>
    <entry xml:lang="en">
        <title>Rehearsal Record — multi-DB re-runs at 26.7.3 (MySQL, MariaDB)</title>
        <published>2026-08-31T00:00:00+00:00</published>
        <updated>2026-08-31T00:00:00+00:00</updated>
        
        <author>
          <name>Unknown</name>
        </author>
        
        <link rel="alternate" type="text/html" href="https://ledger.mlabs.city/runs/2026-08-31-multidb-26.0.0-to-26.7.3/"/>
        <id>https://ledger.mlabs.city/runs/2026-08-31-multidb-26.0.0-to-26.7.3/</id>
        
        <content type="html" xml:base="https://ledger.mlabs.city/runs/2026-08-31-multidb-26.0.0-to-26.7.3/">&lt;h2 id=&quot;environment&quot;&gt;Environment&lt;/h2&gt;
&lt;table&gt;&lt;thead&gt;&lt;tr&gt;&lt;th&gt;Field&lt;/th&gt;&lt;th&gt;Value&lt;/th&gt;&lt;/tr&gt;&lt;/thead&gt;&lt;tbody&gt;
&lt;tr&gt;&lt;td&gt;Keycloak from → to&lt;/td&gt;&lt;td&gt;26.0.0 → 26.7.3 (direct)&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Distribution&lt;/td&gt;&lt;td&gt;quay.io/keycloak/keycloak&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Start mode&lt;/td&gt;&lt;td&gt;&lt;code&gt;start&lt;/code&gt; w/ external DB&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Engines&lt;/td&gt;&lt;td&gt;MySQL 8.4, MariaDB 11.4&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Fixtures&lt;/td&gt;&lt;td&gt;&lt;code&gt;baseline-26.0.0-mysql-typical-1k&lt;/code&gt; (1,000 users), &lt;code&gt;baseline-26.0.0-mariadb-typical-200&lt;/code&gt; (200 users)&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Host&lt;/td&gt;&lt;td&gt;laptop&lt;/td&gt;&lt;/tr&gt;
&lt;/tbody&gt;&lt;/table&gt;
&lt;h2 id=&quot;results&quot;&gt;Results&lt;/h2&gt;
&lt;table&gt;&lt;thead&gt;&lt;tr&gt;&lt;th&gt;Engine&lt;/th&gt;&lt;th&gt;Hop&lt;/th&gt;&lt;th&gt;Time&lt;/th&gt;&lt;th&gt;Index audit&lt;/th&gt;&lt;th&gt;Realm&lt;/th&gt;&lt;/tr&gt;&lt;/thead&gt;&lt;tbody&gt;
&lt;tr&gt;&lt;td&gt;MySQL 8.4&lt;/td&gt;&lt;td&gt;26.0.0 → 26.7.3&lt;/td&gt;&lt;td&gt;20s&lt;/td&gt;&lt;td&gt;121/121 correct&lt;/td&gt;&lt;td&gt;1,000 users intact&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;MariaDB 11.4&lt;/td&gt;&lt;td&gt;26.0.0 → 26.7.3&lt;/td&gt;&lt;td&gt;20s&lt;/td&gt;&lt;td&gt;121/121 correct&lt;/td&gt;&lt;td&gt;200 users intact&lt;/td&gt;&lt;/tr&gt;
&lt;/tbody&gt;&lt;/table&gt;
&lt;h2 id=&quot;breakage-observed&quot;&gt;Breakage observed&lt;/h2&gt;
&lt;h3 id=&quot;env-predates-the-multi-db-fields-mysql-root-password-unbound-variable&quot;&gt;&lt;code&gt;.env&lt;/code&gt; predates the multi-DB fields (&lt;code&gt;MYSQL_ROOT_PASSWORD: unbound variable&lt;/code&gt;)&lt;/h3&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Symptom:&lt;/strong&gt; &lt;code&gt;restore.sh&lt;/code&gt; failed on the mysql engine with
&lt;code&gt;MYSQL_ROOT_PASSWORD: unbound variable&lt;/code&gt; — the checked-in &lt;code&gt;.env&lt;/code&gt; was created
from a pre-multi-DB &lt;code&gt;.env.example&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Cause:&lt;/strong&gt; &lt;code&gt;.env&lt;/code&gt; is gitignored and was never regenerated after the
multi-DB variables were added to &lt;code&gt;.env.example&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Fix:&lt;/strong&gt; appended &lt;code&gt;MYSQL_VERSION&lt;/code&gt;/&lt;code&gt;MARIADB_VERSION&lt;/code&gt;/&lt;code&gt;MYSQL_PORT&lt;/code&gt;/
&lt;code&gt;MYSQL_ROOT_PASSWORD&lt;/code&gt;/&lt;code&gt;ORACLE_*&lt;/code&gt; to &lt;code&gt;.env&lt;/code&gt;. A fresh &lt;code&gt;cp .env.example .env&lt;/code&gt;
would do the same; worth a &lt;code&gt;lab-up.sh&lt;/code&gt; guard that &lt;code&gt;.env&lt;/code&gt; carries every
&lt;code&gt;.env.example&lt;/code&gt; key.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Would this hit a customer?&lt;/strong&gt; No — lab tooling only.&lt;/li&gt;
&lt;/ul&gt;
&lt;h2 id=&quot;still-deferred-engine-work&quot;&gt;Still deferred (engine work)&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Oracle&lt;/strong&gt;: &lt;code&gt;snapshot.sh&lt;/code&gt;/&lt;code&gt;restore.sh&lt;/code&gt;/&lt;code&gt;index-audit.sh&lt;/code&gt; are not ported to
Oracle (need Data Pump &lt;code&gt;expdp&lt;/code&gt;/&lt;code&gt;impdp&lt;/code&gt; + &lt;code&gt;*_INDEXES&lt;/code&gt;/&lt;code&gt;*_IND_COLUMNS&lt;/code&gt;), and the
Oracle Free container&#39;s first init is ~11 min. Needs a dedicated session.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Kubernetes rolling update&lt;/strong&gt;: &lt;code&gt;kubectl&lt;/code&gt;/&lt;code&gt;kind&lt;/code&gt;/&lt;code&gt;k3d&lt;/code&gt; are not installed on
this host. Needs tooling install + a kind cluster before the 26.0.0 → 26.7.3
Infinispan mixed-cluster leg can be re-run.&lt;/li&gt;
&lt;/ul&gt;
</content>
        
    </entry>
    <entry xml:lang="en">
        <title>Rehearsal Record — Oracle: 26.0.0 → 26.7.3 migration re-run</title>
        <published>2026-08-31T00:00:00+00:00</published>
        <updated>2026-08-31T00:00:00+00:00</updated>
        
        <author>
          <name>Unknown</name>
        </author>
        
        <link rel="alternate" type="text/html" href="https://ledger.mlabs.city/runs/2026-08-31-oracle-26.0.0-to-26.7.3/"/>
        <id>https://ledger.mlabs.city/runs/2026-08-31-oracle-26.0.0-to-26.7.3/</id>
        
        <content type="html" xml:base="https://ledger.mlabs.city/runs/2026-08-31-oracle-26.0.0-to-26.7.3/">&lt;h2 id=&quot;environment&quot;&gt;Environment&lt;/h2&gt;
&lt;table&gt;&lt;thead&gt;&lt;tr&gt;&lt;th&gt;Field&lt;/th&gt;&lt;th&gt;Value&lt;/th&gt;&lt;/tr&gt;&lt;/thead&gt;&lt;tbody&gt;
&lt;tr&gt;&lt;td&gt;Keycloak from → to&lt;/td&gt;&lt;td&gt;26.0.0 → 26.7.3&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Database&lt;/td&gt;&lt;td&gt;Oracle Database Free (23ai/26ai), release 23.26.2.0.0, single node, &lt;code&gt;gvenzl/oracle-free:23-slim&lt;/code&gt;&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;JDBC driver&lt;/td&gt;&lt;td&gt;&lt;code&gt;ojdbc17&lt;/code&gt; + &lt;code&gt;orai18n&lt;/code&gt; 23.26.0.0.0 in &lt;code&gt;fixtures/providers/&lt;/code&gt; (unchanged from the 26.7.1 run)&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Schema/user&lt;/td&gt;&lt;td&gt;&lt;code&gt;keycloak&lt;/code&gt; (APP_USER; user IS schema, &lt;code&gt;KC_DB_SCHEMA&lt;/code&gt; not used)&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Topology&lt;/td&gt;&lt;td&gt;single container&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Host&lt;/td&gt;&lt;td&gt;laptop (16 vCPU, 31 GB RAM)&lt;/td&gt;&lt;/tr&gt;
&lt;/tbody&gt;&lt;/table&gt;
&lt;h2 id=&quot;procedure&quot;&gt;Procedure&lt;/h2&gt;
&lt;pre&gt;&lt;code&gt;docker compose up -d db        # Oracle Free first init (healthy in ~37s on this host)
docker compose up -d keycloak  # 26.0.0 (schema created)
docker compose stop keycloak &amp;amp;&amp;amp; sed -i &amp;#39;s/^KC_VERSION=.*/KC_VERSION=26.7.3/&amp;#39; .env
docker compose up -d keycloak  # migrate
&lt;/code&gt;&lt;/pre&gt;
&lt;h2 id=&quot;results&quot;&gt;Results&lt;/h2&gt;
&lt;table&gt;&lt;thead&gt;&lt;tr&gt;&lt;th&gt;Check&lt;/th&gt;&lt;th&gt;Before (26.0.0)&lt;/th&gt;&lt;th&gt;After (26.7.3)&lt;/th&gt;&lt;/tr&gt;&lt;/thead&gt;&lt;tbody&gt;
&lt;tr&gt;&lt;td&gt;Keycloak version&lt;/td&gt;&lt;td&gt;26.0.0&lt;/td&gt;&lt;td&gt;26.7.3&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;&lt;code&gt;migration_model&lt;/code&gt;&lt;/td&gt;&lt;td&gt;26.0.0&lt;/td&gt;&lt;td&gt;&lt;strong&gt;26.7.3&lt;/strong&gt;&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Tables&lt;/td&gt;&lt;td&gt;87&lt;/td&gt;&lt;td&gt;&lt;strong&gt;100&lt;/strong&gt;&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;User indexes&lt;/td&gt;&lt;td&gt;218&lt;/td&gt;&lt;td&gt;&lt;strong&gt;283&lt;/strong&gt;&lt;/td&gt;&lt;/tr&gt;
&lt;/tbody&gt;&lt;/table&gt;
&lt;p&gt;Master-realm token endpoint ready after migration; migration clean and additive
(no DDL errors surfaced in the Keycloak log).&lt;/p&gt;
&lt;h2 id=&quot;still-not-ported-unchanged-from-the-26-7-1-run&quot;&gt;Still not ported (unchanged from the 26.7.1 run)&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;snapshot/restore&lt;/strong&gt; — needs Data Pump (&lt;code&gt;expdp&lt;/code&gt;/&lt;code&gt;impdp&lt;/code&gt;); &lt;code&gt;snapshot.sh&lt;/code&gt;/&lt;code&gt;restore.sh&lt;/code&gt;
still exit 1 on &lt;code&gt;KC_DB=oracle&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;index-audit&lt;/strong&gt; — Oracle&#39;s catalogue is &lt;code&gt;USER_INDEXES&lt;/code&gt;/&lt;code&gt;USER_IND_COLUMNS&lt;/code&gt;/
&lt;code&gt;USER_IND_EXPRESSIONS&lt;/code&gt; (no &lt;code&gt;pg_indexes&lt;/code&gt;, no &lt;code&gt;information_schema.statistics&lt;/code&gt;);
&lt;code&gt;index-audit.sh&lt;/code&gt; still exits 1 on &lt;code&gt;KC_DB=oracle&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;S4 DML-only&lt;/strong&gt; — untested; Oracle&#39;s user-is-schema model needs synonyms for a
non-owner account, so the Postgres/MySQL finding does not translate mechanically.&lt;/li&gt;
&lt;/ul&gt;
</content>
        
    </entry>
    <entry xml:lang="en">
        <title>Rehearsal Record — the `http challenge` flow removal, narrowed to 22.0.0</title>
        <published>2026-08-26T00:00:00+00:00</published>
        <updated>2026-08-26T00:00:00+00:00</updated>
        
        <author>
          <name>Unknown</name>
        </author>
        
        <link rel="alternate" type="text/html" href="https://ledger.mlabs.city/findings/2026-08-26-http-challenge-flow-removal-narrowed/"/>
        <id>https://ledger.mlabs.city/findings/2026-08-26-http-challenge-flow-removal-narrowed/</id>
        
        <content type="html" xml:base="https://ledger.mlabs.city/findings/2026-08-26-http-challenge-flow-removal-narrowed/">&lt;h2 id=&quot;why-this-run-existed&quot;&gt;Why this run existed&lt;/h2&gt;
&lt;p&gt;&lt;a href=&quot;/runs/2026-08-26-rich-21-realm-ladder-100k/&quot;&gt;&lt;code&gt;2026-08-26-rich-21-realm-ladder-100k&lt;/code&gt;&lt;/a&gt; observed that the built-in
&lt;code&gt;http challenge&lt;/code&gt; authentication flow — present on the 21.1.2 realm — was
&lt;strong&gt;absent on 26.7.1&lt;/strong&gt;, and left two rows open: &lt;em&gt;which hop removed it&lt;/em&gt;, and &lt;em&gt;what
happens to a custom flow that references it&lt;/em&gt;. Neither needed a box: the flow
definition and the migration logic are in the published artifacts, so this run
is pure jar inspection against the &lt;code&gt;.m2&lt;/code&gt; cache.&lt;/p&gt;
&lt;h2 id=&quot;environment&quot;&gt;Environment&lt;/h2&gt;
&lt;table&gt;&lt;thead&gt;&lt;tr&gt;&lt;th&gt;Field&lt;/th&gt;&lt;th&gt;Value&lt;/th&gt;&lt;/tr&gt;&lt;/thead&gt;&lt;tbody&gt;
&lt;tr&gt;&lt;td&gt;Artifacts inspected&lt;/td&gt;&lt;td&gt;&lt;code&gt;keycloak-server-spi-private&lt;/code&gt; (flow definitions), &lt;code&gt;keycloak-model-legacy-private&lt;/code&gt; / &lt;code&gt;keycloak-model-storage-private&lt;/code&gt; (migrators)&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Versions&lt;/td&gt;&lt;td&gt;21.1.2, 22.0.0, 22.0.1–22.0.5, 23.0.7, 24.0.5, 25.0.6, 26.0.0–26.7.1&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Method&lt;/td&gt;&lt;td&gt;&lt;code&gt;javap&lt;/code&gt; / &lt;code&gt;jar tf&lt;/code&gt; / &lt;code&gt;strings&lt;/code&gt; on &lt;code&gt;DefaultAuthenticationFlows&lt;/code&gt; and &lt;code&gt;MigrateTo22_0_0&lt;/code&gt;&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Host&lt;/td&gt;&lt;td&gt;local laptop Docker (Maven container); no Keycloak server, no DB&lt;/td&gt;&lt;/tr&gt;
&lt;/tbody&gt;&lt;/table&gt;
&lt;h2 id=&quot;findings&quot;&gt;Findings&lt;/h2&gt;
&lt;h3 id=&quot;finding-1-the-flow-definition-was-deleted-at-22-0-0&quot;&gt;Finding 1 — the flow definition was deleted at 22.0.0 ⭐&lt;/h3&gt;
&lt;p&gt;&lt;code&gt;org.keycloak.models.utils.DefaultAuthenticationFlows&lt;/code&gt;
(&lt;code&gt;keycloak-server-spi-private&lt;/code&gt;) carries the built-in flow definitions. At
21.1.2 it has:&lt;/p&gt;
&lt;pre&gt;&lt;code data-lang=&quot;java&quot;&gt;public static final String HTTP_CHALLENGE_FLOW;   // = &amp;quot;http challenge&amp;quot;
public static void httpChallengeFlow(RealmModel); // adds the flow + executions
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;and both &lt;code&gt;addFlows(RealmModel)&lt;/code&gt; and &lt;code&gt;migrateFlows(RealmModel)&lt;/code&gt; call
&lt;code&gt;httpChallengeFlow(realm)&lt;/code&gt; — i.e. the flow is created for new realms &lt;strong&gt;and&lt;/strong&gt;
re-added to existing realms during migration.&lt;/p&gt;
&lt;p&gt;At 22.0.0 the constant and the method are &lt;strong&gt;gone&lt;/strong&gt; from the class, and every
version 22.0.0 → 26.7.1 lacks them:&lt;/p&gt;
&lt;table&gt;&lt;thead&gt;&lt;tr&gt;&lt;th&gt;Version&lt;/th&gt;&lt;th&gt;&lt;code&gt;HTTP_CHALLENGE_FLOW&lt;/code&gt; + &lt;code&gt;httpChallengeFlow()&lt;/code&gt;&lt;/th&gt;&lt;/tr&gt;&lt;/thead&gt;&lt;tbody&gt;
&lt;tr&gt;&lt;td&gt;21.1.2&lt;/td&gt;&lt;td&gt;present&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;22.0.0&lt;/td&gt;&lt;td&gt;&lt;strong&gt;absent&lt;/strong&gt;&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;22.0.1 … 22.0.5, 23.0.7, 24.0.5, 25.0.6, 26.x&lt;/td&gt;&lt;td&gt;absent&lt;/td&gt;&lt;/tr&gt;
&lt;/tbody&gt;&lt;/table&gt;
&lt;p&gt;The flow&#39;s constituent authenticators were not removed, only re-homed:
&lt;code&gt;http-basic-authenticator&lt;/code&gt; moved into &lt;code&gt;samlEcpProfile&lt;/code&gt; (SAML ECP) and
&lt;code&gt;docker-http-basic-authenticator&lt;/code&gt; into &lt;code&gt;dockerAuthenticationFlow&lt;/code&gt;. The top-level
&quot;http challenge&quot; flow container itself is what disappeared.&lt;/p&gt;
&lt;h3 id=&quot;finding-2-a-new-migrator-actively-removes-the-flow-from-existing-realms-at-22-0-0&quot;&gt;Finding 2 — a new migrator actively removes the flow from existing realms, at 22.0.0 ⭐⭐&lt;/h3&gt;
&lt;p&gt;The flow definition removal alone would leave an upgraded realm&#39;s existing
&quot;http challenge&quot; flow in place. Keycloak closes that gap with a dedicated
migration step, &lt;code&gt;org.keycloak.migration.migrators.MigrateTo22_0_0&lt;/code&gt;, which is
introduced at 22.0.0 (present in &lt;code&gt;keycloak-model-legacy-private&lt;/code&gt; 22.0.0, absent
at 21.1.2; lives in &lt;code&gt;keycloak-model-storage-private&lt;/code&gt; from 24+).&lt;/p&gt;
&lt;p&gt;Its &lt;code&gt;removeHttpChallengeFlow(KeycloakSession, RealmModel)&lt;/code&gt; method:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;looks the flow up by alias — &lt;code&gt;realm.getFlowByAlias(&quot;http challenge&quot;)&lt;/code&gt;;&lt;/li&gt;
&lt;li&gt;if present, calls
&lt;code&gt;KeycloakModelUtils.deepDeleteAuthenticationFlow(session, realm, flow, onRemoved, onInUse, flow.isBuiltIn())&lt;/code&gt;.&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;The two callbacks encode two distinct outcomes, both visible in the migration log:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;not in use&lt;/strong&gt; → the flow is deleted, and a &lt;strong&gt;DEBUG&lt;/strong&gt; line is logged:
&lt;code&gt;Removed &#39;http challenge&#39; authentication flow in realm &#39;&amp;lt;realm&amp;gt;&#39;&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;in use&lt;/strong&gt; (referenced by a client&#39;s flow-binding override or another flow) →
the flow is &lt;strong&gt;left in place&lt;/strong&gt; and an &lt;strong&gt;ERROR&lt;/strong&gt; is logged:
&lt;code&gt;Authentication flow &#39;http challenge&#39; is in use in realm &#39;&amp;lt;realm&amp;gt;&#39; and cannot be removed. Please update your deployment to avoid using this flow before migration to latest Keycloak&lt;/code&gt;.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;This resolves the open &quot;custom flow that references it&quot; question: the removal is
&lt;strong&gt;not silent data loss&lt;/strong&gt;. A referenced flow survives the migration as realm data,
but Keycloak emits an ERROR and stops maintaining the flow — the reference is
left dangling against a definition that no longer exists. The operator must
re-write the custom flow before (or at) the 22.0.0 hop.&lt;/p&gt;
&lt;h3 id=&quot;finding-3-the-release-boundary-is-the-21-22-hop-not-any-later-one&quot;&gt;Finding 3 — the release boundary is the 21 → 22 hop, not any later one ⭐&lt;/h3&gt;
&lt;p&gt;Both halves (definition deleted + migrator added) land at &lt;strong&gt;22.0.0&lt;/strong&gt;, so the
removal is squarely the &lt;strong&gt;21.1.2 → 22.0.0 hop&lt;/strong&gt;. The rich-ladder run&#39;s six hops
started at 21.1.2 and could not see &lt;em&gt;where&lt;/em&gt; it happened; the answer is the
first hop.&lt;/p&gt;
&lt;h2 id=&quot;verification&quot;&gt;Verification&lt;/h2&gt;
&lt;table&gt;&lt;thead&gt;&lt;tr&gt;&lt;th&gt;Claim&lt;/th&gt;&lt;th&gt;Primary source&lt;/th&gt;&lt;th&gt;Checked&lt;/th&gt;&lt;/tr&gt;&lt;/thead&gt;&lt;tbody&gt;
&lt;tr&gt;&lt;td&gt;&lt;code&gt;httpChallengeFlow&lt;/code&gt; + &lt;code&gt;HTTP_CHALLENGE_FLOW&lt;/code&gt; present at 21.1.2&lt;/td&gt;&lt;td&gt;&lt;code&gt;javap&lt;/code&gt; of &lt;code&gt;DefaultAuthenticationFlows&lt;/code&gt; 21.1.2&lt;/td&gt;&lt;td&gt;☑&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Both absent at 22.0.0 and every later cached version&lt;/td&gt;&lt;td&gt;&lt;code&gt;strings&lt;/code&gt;/&lt;code&gt;javap&lt;/code&gt; across 22.0.0 … 26.7.1&lt;/td&gt;&lt;td&gt;☑&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;&lt;code&gt;addFlows&lt;/code&gt; and &lt;code&gt;migrateFlows&lt;/code&gt; both called it at 21.1.2&lt;/td&gt;&lt;td&gt;&lt;code&gt;javap -c&lt;/code&gt; call sites&lt;/td&gt;&lt;td&gt;☑&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;&lt;code&gt;MigrateTo22_0_0&lt;/code&gt; introduced at 22.0.0&lt;/td&gt;&lt;td&gt;present in &lt;code&gt;keycloak-model-legacy-private&lt;/code&gt; 22.0.0, absent at 21.1.2&lt;/td&gt;&lt;td&gt;☑&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;It removes the flow by alias, two outcomes&lt;/td&gt;&lt;td&gt;&lt;code&gt;javap -c&lt;/code&gt; of &lt;code&gt;removeHttpChallengeFlow&lt;/code&gt; + log strings&lt;/td&gt;&lt;td&gt;☑&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;&lt;code&gt;http-basic-authenticator&lt;/code&gt; re-homed, not removed&lt;/td&gt;&lt;td&gt;22.0.0 &lt;code&gt;DefaultAuthenticationFlows&lt;/code&gt;: referenced by &lt;code&gt;samlEcpProfile&lt;/code&gt; / &lt;code&gt;dockerAuthenticationFlow&lt;/code&gt;&lt;/td&gt;&lt;td&gt;☑&lt;/td&gt;&lt;/tr&gt;
&lt;/tbody&gt;&lt;/table&gt;
&lt;h2 id=&quot;publishable-extract&quot;&gt;Publishable extract&lt;/h2&gt;
&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;One specific Keycloak built-in flow dies on the 21 → 22 upgrade, and it dies
loudly only if you are using it.&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;Keycloak 22.0.0 removed the built-in &lt;code&gt;http challenge&lt;/code&gt; authentication flow. The
flow definition and the code that created it are gone, and a dedicated
migration step (&lt;code&gt;MigrateTo22_0_0&lt;/code&gt;) deletes the flow from every realm on the
way up. If your realm does not reference it, it is removed silently — a DEBUG
line, nothing more. If a custom flow or client still references it, Keycloak
leaves it in place and logs an ERROR telling you to update your deployment
before migrating.&lt;/p&gt;
&lt;p&gt;The constituent HTTP Basic authenticators were not removed — they moved into
the SAML ECP and docker-auth flows. What vanished is the standalone
&quot;http challenge&quot; flow, and the place that matters is the 21 → 22 hop, not any
later upgrade.&lt;/p&gt;
&lt;/blockquote&gt;
</content>
        
    </entry>
    <entry xml:lang="en">
        <title>invalid_grant status change 401 → 400 — narrowed to 26.6.0</title>
        <published>2026-08-26T00:00:00+00:00</published>
        <updated>2026-08-26T00:00:00+00:00</updated>
        
        <author>
          <name>Unknown</name>
        </author>
        
        <link rel="alternate" type="text/html" href="https://ledger.mlabs.city/findings/2026-08-26-invalid-grant-401-to-400-narrowed/"/>
        <id>https://ledger.mlabs.city/findings/2026-08-26-invalid-grant-401-to-400-narrowed/</id>
        
        <content type="html" xml:base="https://ledger.mlabs.city/findings/2026-08-26-invalid-grant-401-to-400-narrowed/">&lt;h2 id=&quot;environment&quot;&gt;Environment&lt;/h2&gt;
&lt;table&gt;&lt;thead&gt;&lt;tr&gt;&lt;th&gt;Field&lt;/th&gt;&lt;th&gt;Value&lt;/th&gt;&lt;/tr&gt;&lt;/thead&gt;&lt;tbody&gt;
&lt;tr&gt;&lt;td&gt;Keycloak versions&lt;/td&gt;&lt;td&gt;26.0.0, 26.1.0, 26.2.0, 26.3.0, 26.4.0, 26.5.0, 26.5.7, 26.6.0, 26.6.4, 26.7.0, 26.7.1&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Distribution&lt;/td&gt;&lt;td&gt;quay.io/keycloak/keycloak (official image)&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Start mode&lt;/td&gt;&lt;td&gt;&lt;code&gt;start&lt;/code&gt; w/ external DB&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Database&lt;/td&gt;&lt;td&gt;Postgres 16, single node, &lt;strong&gt;fresh empty DB per version&lt;/strong&gt; (&lt;code&gt;docker compose down -v&lt;/code&gt;)&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Postgres tuning&lt;/td&gt;&lt;td&gt;stock (compose &lt;code&gt;db&lt;/code&gt; service sets none)&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Adverse scenario&lt;/td&gt;&lt;td&gt;none — holds environment stock, varies the version&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Dataset scale&lt;/td&gt;&lt;td&gt;master realm only (auto-created), no seeded data&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Topology&lt;/td&gt;&lt;td&gt;single container&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Host&lt;/td&gt;&lt;td&gt;Hetzner ccx33 (8 vCPU / 32 GB), fsn1&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;JVM heap&lt;/td&gt;&lt;td&gt;container default&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Probe&lt;/td&gt;&lt;td&gt;one password grant with a wrong password against &lt;code&gt;admin-cli&lt;/code&gt;&lt;/td&gt;&lt;/tr&gt;
&lt;/tbody&gt;&lt;/table&gt;
&lt;h2 id=&quot;procedure&quot;&gt;Procedure&lt;/h2&gt;
&lt;p&gt;For each version, in order: purge volumes, set &lt;code&gt;KC_VERSION&lt;/code&gt;, start db + keycloak,
&lt;code&gt;wait-ready.sh&lt;/code&gt;, then:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;curl -s -w &amp;#39;\n%{http_code}&amp;#39; \
  -d &amp;quot;client_id=admin-cli&amp;quot; -d &amp;quot;username=admin&amp;quot; \
  -d &amp;quot;password=definitely-wrong&amp;quot; -d &amp;quot;grant_type=password&amp;quot; \
  &amp;quot;http://localhost:8080/realms/master/protocol/openid-connect/token&amp;quot;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;A fresh DB per version guarantees a clean master realm, ruling out the
&quot;accumulated brute-force state&quot; hypothesis left open in
&lt;a href=&quot;/findings/2026-08-25-s7-custom-spi/&quot;&gt;&lt;code&gt;2026-08-25-s7-custom-spi&lt;/code&gt;&lt;/a&gt;. Script: &lt;code&gt;bin/narrow-invalid-grant.sh&lt;/code&gt; (one-off).&lt;/p&gt;
&lt;h2 id=&quot;result&quot;&gt;Result&lt;/h2&gt;
&lt;table&gt;&lt;thead&gt;&lt;tr&gt;&lt;th&gt;Version&lt;/th&gt;&lt;th&gt;HTTP status&lt;/th&gt;&lt;th&gt;Body&lt;/th&gt;&lt;/tr&gt;&lt;/thead&gt;&lt;tbody&gt;
&lt;tr&gt;&lt;td&gt;26.0.0&lt;/td&gt;&lt;td&gt;&lt;strong&gt;401&lt;/strong&gt;&lt;/td&gt;&lt;td&gt;&lt;code&gt;{&quot;error&quot;:&quot;invalid_grant&quot;,&quot;error_description&quot;:&quot;Invalid user credentials&quot;}&lt;/code&gt;&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;26.1.0&lt;/td&gt;&lt;td&gt;&lt;strong&gt;401&lt;/strong&gt;&lt;/td&gt;&lt;td&gt;same&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;26.2.0&lt;/td&gt;&lt;td&gt;&lt;strong&gt;401&lt;/strong&gt;&lt;/td&gt;&lt;td&gt;same&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;26.3.0&lt;/td&gt;&lt;td&gt;&lt;strong&gt;401&lt;/strong&gt;&lt;/td&gt;&lt;td&gt;same&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;26.4.0&lt;/td&gt;&lt;td&gt;&lt;strong&gt;401&lt;/strong&gt;&lt;/td&gt;&lt;td&gt;same&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;26.5.0&lt;/td&gt;&lt;td&gt;&lt;strong&gt;401&lt;/strong&gt;&lt;/td&gt;&lt;td&gt;same&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;26.5.7 (last 26.5 patch)&lt;/td&gt;&lt;td&gt;&lt;strong&gt;401&lt;/strong&gt;&lt;/td&gt;&lt;td&gt;same&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;26.6.0&lt;/td&gt;&lt;td&gt;&lt;strong&gt;400&lt;/strong&gt;&lt;/td&gt;&lt;td&gt;same&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;26.6.4&lt;/td&gt;&lt;td&gt;&lt;strong&gt;400&lt;/strong&gt;&lt;/td&gt;&lt;td&gt;same&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;26.7.0&lt;/td&gt;&lt;td&gt;&lt;strong&gt;400&lt;/strong&gt;&lt;/td&gt;&lt;td&gt;same&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;26.7.1&lt;/td&gt;&lt;td&gt;&lt;strong&gt;400&lt;/strong&gt;&lt;/td&gt;&lt;td&gt;same&lt;/td&gt;&lt;/tr&gt;
&lt;/tbody&gt;&lt;/table&gt;
&lt;p&gt;&lt;strong&gt;The change landed in 26.6.0.&lt;/strong&gt; The entire 26.5 line, including its final
patch 26.5.7, returns 401; 26.6.0 onward returns 400. Body text is unchanged.&lt;/p&gt;
&lt;h2 id=&quot;verification&quot;&gt;Verification&lt;/h2&gt;
&lt;table&gt;&lt;thead&gt;&lt;tr&gt;&lt;th&gt;Claim&lt;/th&gt;&lt;th&gt;Primary source&lt;/th&gt;&lt;th&gt;Checked&lt;/th&gt;&lt;/tr&gt;&lt;/thead&gt;&lt;tbody&gt;
&lt;tr&gt;&lt;td&gt;Change is version-caused, not realm state&lt;/td&gt;&lt;td&gt;fresh DB per version, clean master realm&lt;/td&gt;&lt;td&gt;☑&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Boundary is 26.6.0, not a 26.5.x patch&lt;/td&gt;&lt;td&gt;26.5.7 (last 26.5 patch) = 401, 26.6.0 = 400&lt;/td&gt;&lt;td&gt;☑&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Entire 26.6+ line is 400&lt;/td&gt;&lt;td&gt;26.6.0, 26.6.4, 26.7.0, 26.7.1 all 400&lt;/td&gt;&lt;td&gt;☑&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;RFC 6749 says 400 for &lt;code&gt;invalid_grant&lt;/code&gt;&lt;/td&gt;&lt;td&gt;RFC 6749 §5.2 (as noted in &lt;code&gt;s9-ldap-federation.md&lt;/code&gt;)&lt;/td&gt;&lt;td&gt;☑&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Whether the change is in Keycloak release notes&lt;/td&gt;&lt;td&gt;&lt;strong&gt;checked 2026-08-26&lt;/strong&gt; — issue &lt;a rel=&quot;external&quot; href=&quot;https://github.com/keycloak/keycloak/issues/45812&quot;&gt;#45812&lt;/a&gt; &quot;ROPC: invalid_grant Error Response not RFC Compliant&quot; (area &lt;code&gt;oidc&lt;/code&gt;) in the 26.6.0 release notes&lt;/td&gt;&lt;td&gt;☑&lt;/td&gt;&lt;/tr&gt;
&lt;/tbody&gt;&lt;/table&gt;
&lt;h2 id=&quot;publishable-extract&quot;&gt;Publishable extract&lt;/h2&gt;
&lt;blockquote&gt;
&lt;p&gt;A failed password grant against a Keycloak token endpoint answers &lt;strong&gt;401&lt;/strong&gt; on
26.0.0 through 26.5.7, and &lt;strong&gt;400&lt;/strong&gt; from 26.6.0 onward — same request, same
wrong password, identical &lt;code&gt;Invalid user credentials&lt;/code&gt; body. The change landed in
26.6.0. RFC 6749 specifies 400 for &lt;code&gt;invalid_grant&lt;/code&gt;, so the newer behaviour is
the correct one; but any client keying on 401 to detect a bad password breaks
on upgrade past 26.6.0, and that code is common.&lt;/p&gt;
&lt;/blockquote&gt;
</content>
        
    </entry>
    <entry xml:lang="en">
        <title>KC-SERVICES0047 (internal eventsListener SPI warning) — present on 25.0.6, not new in 26</title>
        <published>2026-08-26T00:00:00+00:00</published>
        <updated>2026-08-26T00:00:00+00:00</updated>
        
        <author>
          <name>Unknown</name>
        </author>
        
        <link rel="alternate" type="text/html" href="https://ledger.mlabs.city/findings/2026-08-26-kc-services0047-not-new-in-26/"/>
        <id>https://ledger.mlabs.city/findings/2026-08-26-kc-services0047-not-new-in-26/</id>
        
        <content type="html" xml:base="https://ledger.mlabs.city/findings/2026-08-26-kc-services0047-not-new-in-26/">&lt;h2 id=&quot;environment&quot;&gt;Environment&lt;/h2&gt;
&lt;table&gt;&lt;thead&gt;&lt;tr&gt;&lt;th&gt;Field&lt;/th&gt;&lt;th&gt;Value&lt;/th&gt;&lt;/tr&gt;&lt;/thead&gt;&lt;tbody&gt;
&lt;tr&gt;&lt;td&gt;Keycloak versions&lt;/td&gt;&lt;td&gt;25.0.6 and 26.0.0, same provider jar on both&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Distribution&lt;/td&gt;&lt;td&gt;quay.io/keycloak/keycloak (official image)&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Start mode&lt;/td&gt;&lt;td&gt;&lt;code&gt;start&lt;/code&gt; w/ external DB&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Database&lt;/td&gt;&lt;td&gt;Postgres 16, fresh empty DB per version&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Provider under test&lt;/td&gt;&lt;td&gt;&lt;code&gt;lab-event-listener&lt;/code&gt; (fixtures/spi), built against 25.0.6 via &lt;code&gt;bin/build-spi.sh 25.0.6&lt;/code&gt;&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Topology&lt;/td&gt;&lt;td&gt;single container&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Host&lt;/td&gt;&lt;td&gt;Hetzner ccx33 (8 vCPU / 32 GB)&lt;/td&gt;&lt;/tr&gt;
&lt;/tbody&gt;&lt;/table&gt;
&lt;h2 id=&quot;procedure&quot;&gt;Procedure&lt;/h2&gt;
&lt;pre&gt;&lt;code&gt;./bin/build-spi.sh 25.0.6          # lab-event-listener-built-for-25.0.6.jar
# per version: down -v, set KC_VERSION, up db+keycloak, wait-ready
docker compose logs keycloak | grep -i &amp;quot;KC-SERVICES0047&amp;quot;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The jar is mounted at &lt;code&gt;/opt/keycloak/providers&lt;/code&gt; (compose default).&lt;/p&gt;
&lt;h2 id=&quot;result&quot;&gt;Result&lt;/h2&gt;
&lt;p&gt;Both versions emit the warning at provider load:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;WARN [org.keycloak.services] KC-SERVICES0047: lab-event-listener
  (lab.LabEventListenerProviderFactory) is implementing the internal SPI
  eventsListener. This SPI is internal and may change without notice
&lt;/code&gt;&lt;/pre&gt;
&lt;table&gt;&lt;thead&gt;&lt;tr&gt;&lt;th&gt;Version&lt;/th&gt;&lt;th&gt;&lt;code&gt;KC-SERVICES0047&lt;/code&gt; emitted?&lt;/th&gt;&lt;/tr&gt;&lt;/thead&gt;&lt;tbody&gt;
&lt;tr&gt;&lt;td&gt;25.0.6&lt;/td&gt;&lt;td&gt;&lt;strong&gt;yes&lt;/strong&gt;&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;26.0.0&lt;/td&gt;&lt;td&gt;&lt;strong&gt;yes&lt;/strong&gt;&lt;/td&gt;&lt;/tr&gt;
&lt;/tbody&gt;&lt;/table&gt;
&lt;p&gt;&lt;strong&gt;The warning is not new in 26.&lt;/strong&gt; It was present in 25.0.6. The earlier record
(&lt;a href=&quot;/findings/2026-08-25-s7-custom-spi/&quot;&gt;&lt;code&gt;2026-08-25-s7-custom-spi&lt;/code&gt;&lt;/a&gt;) had left this unchecked and warned against
claiming it was new in 26; that caution was correct, and the claim is now
refuted.&lt;/p&gt;
&lt;h2 id=&quot;verification&quot;&gt;Verification&lt;/h2&gt;
&lt;table&gt;&lt;thead&gt;&lt;tr&gt;&lt;th&gt;Claim&lt;/th&gt;&lt;th&gt;Primary source&lt;/th&gt;&lt;th&gt;Checked&lt;/th&gt;&lt;/tr&gt;&lt;/thead&gt;&lt;tbody&gt;
&lt;tr&gt;&lt;td&gt;Warning appears on 25.0.6&lt;/td&gt;&lt;td&gt;&lt;code&gt;docker compose logs keycloak&lt;/code&gt; on 25.0.6&lt;/td&gt;&lt;td&gt;☑&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Warning appears on 26.0.0 (same jar)&lt;/td&gt;&lt;td&gt;&lt;code&gt;docker compose logs keycloak&lt;/code&gt; on 26.0.0&lt;/td&gt;&lt;td&gt;☑&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Same provider on both (attributable jar)&lt;/td&gt;&lt;td&gt;jar filename records build version&lt;/td&gt;&lt;td&gt;☑&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Earliest release that emits it&lt;/td&gt;&lt;td&gt;&lt;strong&gt;not checked&lt;/strong&gt; — only 25.0.6 and 26.0.0&lt;/td&gt;&lt;td&gt;☐&lt;/td&gt;&lt;/tr&gt;
&lt;/tbody&gt;&lt;/table&gt;
</content>
        
    </entry>
    <entry xml:lang="en">
        <title>Rehearsal Record — brittle protocol mapper: the last row of the brittle matrix, loud at use</title>
        <published>2026-08-26T00:00:00+00:00</published>
        <updated>2026-08-26T00:00:00+00:00</updated>
        
        <author>
          <name>Unknown</name>
        </author>
        
        <link rel="alternate" type="text/html" href="https://ledger.mlabs.city/findings/2026-08-26-provider-types-brittle-protocol-mapper/"/>
        <id>https://ledger.mlabs.city/findings/2026-08-26-provider-types-brittle-protocol-mapper/</id>
        
        <content type="html" xml:base="https://ledger.mlabs.city/findings/2026-08-26-provider-types-brittle-protocol-mapper/">&lt;h2 id=&quot;why-this-run-existed&quot;&gt;Why this run existed&lt;/h2&gt;
&lt;p&gt;&lt;a href=&quot;/findings/2026-08-26-provider-types-protocol-mapper-brittle/&quot;&gt;&lt;code&gt;2026-08-26-provider-types-protocol-mapper-brittle&lt;/code&gt;&lt;/a&gt; closed three of the four
brittle rows (listener = lazy + silent; authenticator = lazy + loud;
user-storage = lazy + loud) and left one ☐: the &lt;strong&gt;protocol mapper&lt;/strong&gt;. The stated
blocker was that &quot;token-transform methods moved out of the base &lt;code&gt;ProtocolMapper&lt;/code&gt;
interface in 26, so the removed helper has no natural home there.&quot;&lt;/p&gt;
&lt;p&gt;That blocker is &lt;strong&gt;refuted&lt;/strong&gt;. The base &lt;code&gt;ProtocolMapper&lt;/code&gt; interface
(&lt;code&gt;keycloak-server-spi-private&lt;/code&gt;) has never carried a token-transform method — its
only abstract methods across 21.1.2 → 26.7.1 are &lt;code&gt;getProtocol&lt;/code&gt; /
&lt;code&gt;getDisplayCategory&lt;/code&gt; / &lt;code&gt;getDisplayType&lt;/code&gt; (plus a couple of defaults that grow
over time, &lt;code&gt;validateConfig&lt;/code&gt; from the start and &lt;code&gt;getEffectiveModel&lt;/code&gt; from 23.x). The work method lives on the
OIDC marker interface &lt;code&gt;org.keycloak.protocol.oidc.mappers.OIDCAccessTokenMapper&lt;/code&gt;
(&lt;code&gt;transformAccessToken&lt;/code&gt;), which sits in &lt;strong&gt;&lt;code&gt;keycloak-services&lt;/code&gt;&lt;/strong&gt;, not
&lt;code&gt;keycloak-server-spi-private&lt;/code&gt;, and whose signature is &lt;strong&gt;stable&lt;/strong&gt; across the
25 → 26 boundary. So there is a natural home; the previous note mistook &quot;the
work method is not in the artifact we build against&quot; for &quot;the work method moved
in 26&quot;.&lt;/p&gt;
&lt;p&gt;This run builds &lt;code&gt;fixtures/spi-brittle-pm/&lt;/code&gt; — a mapper that implements
&lt;code&gt;ProtocolMapper&lt;/code&gt; + &lt;code&gt;OIDCAccessTokenMapper&lt;/code&gt; and calls the same removed
three-argument &lt;code&gt;runJobInTransactionWithResult&lt;/code&gt; overload the other brittle
fixtures use, from inside &lt;code&gt;transformAccessToken()&lt;/code&gt;.&lt;/p&gt;
&lt;h2 id=&quot;environment&quot;&gt;Environment&lt;/h2&gt;
&lt;table&gt;&lt;thead&gt;&lt;tr&gt;&lt;th&gt;Field&lt;/th&gt;&lt;th&gt;Value&lt;/th&gt;&lt;/tr&gt;&lt;/thead&gt;&lt;tbody&gt;
&lt;tr&gt;&lt;td&gt;Keycloak&lt;/td&gt;&lt;td&gt;built against 25.0.6, loaded on 26.7.1 (crosses the 26.0.0 API-removal boundary)&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Fixture&lt;/td&gt;&lt;td&gt;&lt;code&gt;fixtures/spi-brittle-pm/&lt;/code&gt; (&lt;strong&gt;new&lt;/strong&gt;) — &lt;code&gt;lab-brittle-protocolmapper&lt;/code&gt;&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Build&lt;/td&gt;&lt;td&gt;&lt;code&gt;bin/build-spi.sh 25.0.6 --module spi-brittle-pm&lt;/code&gt; (Maven in Docker)&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;New dependency&lt;/td&gt;&lt;td&gt;&lt;code&gt;keycloak-services&lt;/code&gt; (&lt;code&gt;provided&lt;/code&gt;) — for &lt;code&gt;OIDCAccessTokenMapper&lt;/code&gt;, the one artifact the other three brittle fixtures do not need&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Database&lt;/td&gt;&lt;td&gt;PostgreSQL 16, fresh empty DB&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Host&lt;/td&gt;&lt;td&gt;local laptop Docker (not Hetzner); 1k-class, no scale component&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;JVM heap&lt;/td&gt;&lt;td&gt;&lt;code&gt;-Xms1g -Xmx4g&lt;/code&gt; (compose default)&lt;/td&gt;&lt;/tr&gt;
&lt;/tbody&gt;&lt;/table&gt;
&lt;p&gt;The removed API is the same vehicle used across the whole brittle matrix: the
three-argument overload &lt;code&gt;KeycloakModelUtils.runJobInTransactionWithResult( KeycloakSessionFactory, KeycloakContext, KeycloakSessionTaskWithResult)&lt;/code&gt; —
present in 25.0.6, absent in 26.0.0 (replaced by a four-argument overload),
living in &lt;code&gt;keycloak-server-spi-private&lt;/code&gt; (no compatibility promise).&lt;/p&gt;
&lt;h2 id=&quot;procedure&quot;&gt;Procedure&lt;/h2&gt;
&lt;pre&gt;&lt;code data-lang=&quot;bash&quot;&gt;cd lab
# fixture is checked in; build the brittle mapper against the OLD major
./bin/build-spi.sh 25.0.6 --module spi-brittle-pm
./bin/lab-up.sh 26.7.1          # jar is bind-mounted into /opt/keycloak/providers

# verify it LOADED (no ERROR, internal-SPI warning, registered)
docker compose logs keycloak | grep -E &amp;#39;lab-brittle-pm|KC-SERVICES0047&amp;#39;
#   KC-SERVICES0047: lab-brittle-protocolmapper ... internal SPI protocol-mapper ...
#   [lab-brittle-pm] protocol mapper init -- provider loaded
curl -s -H &amp;quot;Authorization: Bearer $TOKEN&amp;quot; http://localhost:8080/admin/serverinfo \
  | jq &amp;#39;.providers[&amp;quot;protocol-mapper&amp;quot;]&amp;#39;          # internal:true, &amp;quot;lab-brittle-protocolmapper&amp;quot; present

# trigger token issuance (the mapper&amp;#39;s work method) with a service account:
# realm pmtest, confidential client brittle-client w/ the mapper attached, serviceAccountsEnabled
curl -s -o /tmp/tok.out -w &amp;#39;%{http_code}&amp;#39; \
  -d client_id=brittle-client -d client_secret=brittle-secret -d grant_type=client_credentials \
  http://localhost:8080/realms/pmtest/protocol/openid-connect/token
#   -&amp;gt; HTTP 500, {&amp;quot;error&amp;quot;:&amp;quot;unknown_error&amp;quot;,...}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Attach the mapper by including it in the client&#39;s &lt;code&gt;protocolMappers&lt;/code&gt; at create
time (&lt;code&gt;{&quot;protocolMapper&quot;:&quot;lab-brittle-protocolmapper&quot;,&quot;config&quot;:{}}&lt;/code&gt;), then
enable &lt;code&gt;serviceAccountsEnabled&lt;/code&gt; and issue a client-credentials grant. Any token
grant exercises &lt;code&gt;transformAccessToken&lt;/code&gt;; the client-credentials grant is the
cleanest trigger because it does not involve the user required-actions
machinery (see note under &lt;em&gt;Outcome&lt;/em&gt;).&lt;/p&gt;
&lt;h2 id=&quot;findings&quot;&gt;Findings&lt;/h2&gt;
&lt;h3 id=&quot;finding-1-the-token-transform-work-method-is-in-keycloak-services-not-server-spi-private-and-it-is-stable-across-25-26&quot;&gt;Finding 1 — the token-transform work method is in &lt;code&gt;keycloak-services&lt;/code&gt;, not &lt;code&gt;server-spi-private&lt;/code&gt;, and it is stable across 25 → 26 ⭐&lt;/h3&gt;
&lt;p&gt;The base &lt;code&gt;ProtocolMapper&lt;/code&gt; interface carries no token-transform method at any of
21.1.2, 23.0.7, 24.0.5, 25.0.6, 26.0.0 or 26.7.1 — its abstract methods are
&lt;code&gt;getProtocol&lt;/code&gt;, &lt;code&gt;getDisplayCategory&lt;/code&gt;, &lt;code&gt;getDisplayType&lt;/code&gt; (with &lt;code&gt;getPriority&lt;/code&gt; and
&lt;code&gt;validateConfig&lt;/code&gt; defaults from the start, &lt;code&gt;getEffectiveModel&lt;/code&gt; from 23.x). So the
earlier &quot;moved out of the base interface in 26&quot; note does not describe a real
change.&lt;/p&gt;
&lt;p&gt;The work method is &lt;code&gt;OIDCAccessTokenMapper.transformAccessToken&lt;/code&gt; (and
&lt;code&gt;OIDCIDTokenMapper.transformIDToken&lt;/code&gt;), which live in &lt;strong&gt;&lt;code&gt;keycloak-services&lt;/code&gt;&lt;/strong&gt;:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;public interface org.keycloak.protocol.oidc.mappers.OIDCAccessTokenMapper {
  public abstract AccessToken transformAccessToken(AccessToken,
    ProtocolMapperModel, KeycloakSession, UserSessionModel, ClientSessionContext);
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;That signature is byte-for-byte identical in 25.0.6, 26.0.0 and 26.7.1. The
practical consequence: a customer&#39;s OIDC mapper that extends
&lt;code&gt;AbstractOIDCProtocolMapper&lt;/code&gt; (also &lt;code&gt;keycloak-services&lt;/code&gt;) or implements
&lt;code&gt;OIDCAccessTokenMapper&lt;/code&gt; has a stable work-method home to override — which is
exactly where a removed-API call would live, and therefore the right place to
ask the load-vs-use question.&lt;/p&gt;
&lt;h3 id=&quot;finding-2-a-brittle-protocol-mapper-fails-at-use-loudly-http-500&quot;&gt;Finding 2 — a brittle protocol mapper fails at use, loudly (HTTP 500) ⭐&lt;/h3&gt;
&lt;p&gt;&lt;code&gt;fixtures/spi-brittle-pm&lt;/code&gt; calls the removed three-argument overload from inside
&lt;code&gt;transformAccessToken()&lt;/code&gt;.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;It loads.&lt;/strong&gt; On 26.7.1 the server starts with zero ERRORs, the factory &lt;code&gt;init()&lt;/code&gt;
runs, &lt;code&gt;KC-SERVICES0047&lt;/code&gt; fires, and the provider registers in
&lt;code&gt;/admin/serverinfo&lt;/code&gt; under &lt;code&gt;protocol-mapper&lt;/code&gt; (SPI &lt;code&gt;internal: true&lt;/code&gt;):&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;WARN KC-SERVICES0047: lab-brittle-protocolmapper (lab.LabBrittleProtocolMapper)
  is implementing the internal SPI protocol-mapper. This SPI is internal and may
  change without notice
[lab-brittle-pm] protocol mapper init -- provider loaded
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;It dies at first use, loudly.&lt;/strong&gt; A client-credentials grant returns HTTP 500
with the standard &lt;code&gt;unknown_error&lt;/code&gt; body, and the log carries the uncaught error —
thrown from &lt;code&gt;transformAccessToken&lt;/code&gt; and propagating straight up through the token
endpoint:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;ERROR [org.keycloak.services.error.KeycloakErrorHandler] (executor-thread-1)
  Uncaught server error: java.lang.NoSuchMethodError: &amp;#39;java.lang.Object
  org.keycloak.models.utils.KeycloakModelUtils.runJobInTransactionWithResult(
  org.keycloak.models.KeycloakSessionFactory, org.keycloak.models.KeycloakContext,
  org.keycloak.models.KeycloakSessionTaskWithResult)&amp;#39;
    at lab.LabBrittleProtocolMapper.transformAccessToken(LabBrittleProtocolMapper.java:89)
    at org.keycloak.protocol.oidc.TokenManager.transformAccessToken(TokenManager.java:827)
    at org.keycloak.protocol.oidc.TokenManager.createClientAccessToken(TokenManager.java:429)
    at org.keycloak.protocol.oidc.grants.ClientCredentialsGrantType.process(ClientCredentialsGrantType.java:147)
    at org.keycloak.protocol.oidc.endpoints.TokenEndpoint.processGrantRequest(TokenEndpoint.java:174)
&lt;/code&gt;&lt;/pre&gt;
&lt;h3 id=&quot;finding-3-the-brittle-matrix-is-now-complete-and-it-splits-two-ways&quot;&gt;Finding 3 — the brittle matrix is now complete, and it splits two ways ⭐&lt;/h3&gt;
&lt;p&gt;All four provider types now have a brittle row, and none fails at &lt;strong&gt;load&lt;/strong&gt;:&lt;/p&gt;
&lt;table&gt;&lt;thead&gt;&lt;tr&gt;&lt;th&gt;Type&lt;/th&gt;&lt;th&gt;Fails at&lt;/th&gt;&lt;th&gt;Visibility&lt;/th&gt;&lt;th&gt;Record&lt;/th&gt;&lt;/tr&gt;&lt;/thead&gt;&lt;tbody&gt;
&lt;tr&gt;&lt;td&gt;event listener&lt;/td&gt;&lt;td&gt;use&lt;/td&gt;&lt;td&gt;&lt;strong&gt;silent&lt;/strong&gt; (request succeeds)&lt;/td&gt;&lt;td&gt;S7&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;authenticator&lt;/td&gt;&lt;td&gt;use&lt;/td&gt;&lt;td&gt;&lt;strong&gt;loud&lt;/strong&gt; (HTTP 500)&lt;/td&gt;&lt;td&gt;&lt;code&gt;…-provider-types-protocol-mapper-brittle.md&lt;/code&gt; F2&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;user-storage&lt;/td&gt;&lt;td&gt;use&lt;/td&gt;&lt;td&gt;&lt;strong&gt;loud&lt;/strong&gt; (HTTP 500)&lt;/td&gt;&lt;td&gt;&lt;code&gt;…-provider-types-protocol-mapper-brittle.md&lt;/code&gt; F4&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;protocol mapper&lt;/td&gt;&lt;td&gt;use&lt;/td&gt;&lt;td&gt;&lt;strong&gt;loud&lt;/strong&gt; (HTTP 500)&lt;/td&gt;&lt;td&gt;this run, F2&lt;/td&gt;&lt;/tr&gt;
&lt;/tbody&gt;&lt;/table&gt;
&lt;p&gt;Keycloak never invokes a provider&#39;s work method during startup, so a removed API
that only the work method reaches never breaks the upgrade itself. The
visibility split is: &lt;strong&gt;event dispatch swallows the error (listener); every other
path propagates it to HTTP 500.&lt;/strong&gt; A protocol mapper fails exactly the way an
authenticator and a user-federation provider do, not the way a listener does.&lt;/p&gt;
&lt;h2 id=&quot;outcome&quot;&gt;Outcome&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;&lt;input disabled=&quot;&quot; type=&quot;checkbox&quot; checked=&quot;&quot;/&gt;
Reached ready (26.7.1, fresh DB)&lt;/li&gt;
&lt;li&gt;&lt;input disabled=&quot;&quot; type=&quot;checkbox&quot; checked=&quot;&quot;/&gt;
Custom SPI loaded (brittle protocol mapper registered, &lt;code&gt;internal:true&lt;/code&gt;)&lt;/li&gt;
&lt;li&gt;&lt;input disabled=&quot;&quot; type=&quot;checkbox&quot; checked=&quot;&quot;/&gt;
Failure reproduced at use, loudly (HTTP 500 + &lt;code&gt;NoSuchMethodError&lt;/code&gt;)&lt;/li&gt;
&lt;li&gt;&lt;input disabled=&quot;&quot; type=&quot;checkbox&quot;/&gt;
Login flow works — n/a: the mapper is wired to fail on purpose&lt;/li&gt;
&lt;li&gt;&lt;input disabled=&quot;&quot; type=&quot;checkbox&quot;/&gt;
Realm config intact — n/a: fresh throwaway realm&lt;/li&gt;
&lt;/ul&gt;
&lt;h3 id=&quot;operational-note-password-grant-is-the-wrong-trigger-for-a-mapper&quot;&gt;Operational note — password grant is the wrong trigger for a mapper&lt;/h3&gt;
&lt;p&gt;A password (ROPC) grant against a hand-rolled realm returned
&lt;code&gt;400 invalid_grant: &quot;Account is not fully set up&quot;&lt;/code&gt; (&lt;code&gt;error=resolve_required_actions&lt;/code&gt;)
even with the user&#39;s &lt;code&gt;requiredActions&lt;/code&gt; empty and &lt;code&gt;emailVerified=true&lt;/code&gt;; clearing
required actions and toggling realm flags did not move it. The client-credentials
grant sidesteps this and exercises &lt;code&gt;transformAccessToken&lt;/code&gt; directly, so it is the
canonical trigger for a protocol-mapper fixture. The ROPC quirk is &lt;strong&gt;not
diagnosed&lt;/strong&gt; here and is not a finding of this run — it is the auth-session
required-actions path, upstream of the mapper, and deserves its own look if the
lab ever needs ROPC against a hand-rolled realm.&lt;/p&gt;
&lt;h2 id=&quot;verification&quot;&gt;Verification&lt;/h2&gt;
&lt;table&gt;&lt;thead&gt;&lt;tr&gt;&lt;th&gt;Claim&lt;/th&gt;&lt;th&gt;Primary source&lt;/th&gt;&lt;th&gt;Checked&lt;/th&gt;&lt;/tr&gt;&lt;/thead&gt;&lt;tbody&gt;
&lt;tr&gt;&lt;td&gt;Base &lt;code&gt;ProtocolMapper&lt;/code&gt; has no token-transform method at any version&lt;/td&gt;&lt;td&gt;&lt;code&gt;javap&lt;/code&gt; of &lt;code&gt;org.keycloak.protocol.ProtocolMapper&lt;/code&gt; in &lt;code&gt;server-spi-private&lt;/code&gt; 21.1.2 / 23.0.7 / 24.0.5 / 25.0.6 / 26.0.0 / 26.7.1 — abstract methods only &lt;code&gt;getProtocol&lt;/code&gt;/&lt;code&gt;getDisplayCategory&lt;/code&gt;/&lt;code&gt;getDisplayType&lt;/code&gt; throughout&lt;/td&gt;&lt;td&gt;☑&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;&lt;code&gt;OIDCAccessTokenMapper&lt;/code&gt; lives in &lt;code&gt;keycloak-services&lt;/code&gt;, not &lt;code&gt;server-spi-private&lt;/code&gt;&lt;/td&gt;&lt;td&gt;jar listing: class present in &lt;code&gt;keycloak-services&lt;/code&gt;, absent from the other three artifacts&lt;/td&gt;&lt;td&gt;☑&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;&lt;code&gt;transformAccessToken&lt;/code&gt; signature stable 25 → 26&lt;/td&gt;&lt;td&gt;&lt;code&gt;javap&lt;/code&gt; of &lt;code&gt;OIDCAccessTokenMapper&lt;/code&gt; in &lt;code&gt;keycloak-services&lt;/code&gt; 25.0.6 / 26.0.0 / 26.7.1 — identical&lt;/td&gt;&lt;td&gt;☑&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Brittle mapper loads on 26.7.1 (not load-failing)&lt;/td&gt;&lt;td&gt;startup log: &lt;code&gt;init -- provider loaded&lt;/code&gt;, 0 ERRORs, &lt;code&gt;KC-SERVICES0047&lt;/code&gt;, registered in serverinfo&lt;/td&gt;&lt;td&gt;☑&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Brittle mapper dies at use, loudly&lt;/td&gt;&lt;td&gt;HTTP 500 + &lt;code&gt;unknown_error&lt;/code&gt; body + &lt;code&gt;NoSuchMethodError&lt;/code&gt; stack quoted above&lt;/td&gt;&lt;td&gt;☑&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Throw site is the work method&lt;/td&gt;&lt;td&gt;stack: &lt;code&gt;LabBrittleProtocolMapper.transformAccessToken&lt;/code&gt; → &lt;code&gt;TokenManager.transformAccessToken&lt;/code&gt; → &lt;code&gt;createClientAccessToken&lt;/code&gt; → &lt;code&gt;TokenEndpoint&lt;/code&gt;&lt;/td&gt;&lt;td&gt;☑&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;The exact 24.x release that split model/storage&lt;/td&gt;&lt;td&gt;still &lt;strong&gt;not narrowed&lt;/strong&gt; — bracketed to (23.0.7, 24.0.5]&lt;/td&gt;&lt;td&gt;☐&lt;/td&gt;&lt;/tr&gt;
&lt;/tbody&gt;&lt;/table&gt;
&lt;h2 id=&quot;publishable-extract&quot;&gt;Publishable extract&lt;/h2&gt;
&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;A custom protocol mapper that outlived its API breaks your next token, not
your upgrade.&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;We built an OIDC protocol mapper against Keycloak 25.0.6 that calls a helper
removed in 26.0.0, then loaded it on 26.7.1. The server started cleanly — the
mapper registered, &lt;code&gt;/admin/serverinfo&lt;/code&gt; listed it, zero errors. The first token
request that reached its &lt;code&gt;transformAccessToken&lt;/code&gt; method returned HTTP 500 and a
full &lt;code&gt;NoSuchMethodError&lt;/code&gt; stack as an uncaught server error.&lt;/p&gt;
&lt;p&gt;That is the same shape as a broken authenticator and a broken user-federation
provider, and the opposite of a broken event listener: event dispatch swallows
the exception and the login still succeeds, while the mapper&#39;s failure
propagates out of the token endpoint. So a &quot;can users still get a token&quot; check
catches a broken mapper and misses a broken listener. The mapper&#39;s work method
lives in Keycloak&#39;s &lt;code&gt;keycloak-services&lt;/code&gt; artifact, not the SPI artifact — a
mapper that registers cleanly can still be sitting on an API that no longer
exists.&lt;/p&gt;
&lt;/blockquote&gt;
</content>
        
    </entry>
    <entry xml:lang="en">
        <title>Rehearsal Record — protocol mappers are internal SPI; brittle providers fail loudly at use, not load</title>
        <published>2026-08-26T00:00:00+00:00</published>
        <updated>2026-08-26T00:00:00+00:00</updated>
        
        <author>
          <name>Unknown</name>
        </author>
        
        <link rel="alternate" type="text/html" href="https://ledger.mlabs.city/findings/2026-08-26-provider-types-protocol-mapper-brittle/"/>
        <id>https://ledger.mlabs.city/findings/2026-08-26-provider-types-protocol-mapper-brittle/</id>
        
        <content type="html" xml:base="https://ledger.mlabs.city/findings/2026-08-26-provider-types-protocol-mapper-brittle/">&lt;h2 id=&quot;why-this-run-existed&quot;&gt;Why this run existed&lt;/h2&gt;
&lt;p&gt;&lt;a href=&quot;/findings/2026-08-26-provider-types/&quot;&gt;&lt;code&gt;2026-08-26-provider-types&lt;/code&gt;&lt;/a&gt; established the SPI relocation (&lt;code&gt;UserStorageProvider&lt;/code&gt;
left &lt;code&gt;keycloak-server-spi&lt;/code&gt; for &lt;code&gt;keycloak-model-storage&lt;/code&gt;) and that a &lt;em&gt;tame&lt;/em&gt;
authenticator + user-storage provider cross 25 → 26 cleanly. It left two rows
unchecked: &lt;strong&gt;protocol mappers&lt;/strong&gt;, and &lt;strong&gt;brittle (removed-API) versions of each
type — whether they fail at load or at first use&lt;/strong&gt;. S7
(&lt;a href=&quot;/findings/2026-08-25-s7-custom-spi/&quot;&gt;&lt;code&gt;2026-08-25-s7-custom-spi&lt;/code&gt;&lt;/a&gt;) answered that question for &lt;em&gt;event listeners&lt;/em&gt;
(lazy, silent) but for no other type. This run adds a tame protocol mapper, a
brittle authenticator, and a brittle user-storage provider.&lt;/p&gt;
&lt;h2 id=&quot;environment&quot;&gt;Environment&lt;/h2&gt;
&lt;table&gt;&lt;thead&gt;&lt;tr&gt;&lt;th&gt;Field&lt;/th&gt;&lt;th&gt;Value&lt;/th&gt;&lt;/tr&gt;&lt;/thead&gt;&lt;tbody&gt;
&lt;tr&gt;&lt;td&gt;Keycloak&lt;/td&gt;&lt;td&gt;built against 25.0.6, loaded on 26.7.1 (crosses the 26.0.0 API-removal boundary)&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Fixtures&lt;/td&gt;&lt;td&gt;&lt;code&gt;fixtures/spi-types/&lt;/code&gt; (tame: authenticator + user-storage + &lt;strong&gt;new&lt;/strong&gt; protocol mapper); &lt;code&gt;fixtures/spi-brittle-auth/&lt;/code&gt; (&lt;strong&gt;new&lt;/strong&gt;: brittle authenticator); &lt;code&gt;fixtures/spi-brittle-us/&lt;/code&gt; (&lt;strong&gt;new&lt;/strong&gt;: brittle user-storage)&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Build&lt;/td&gt;&lt;td&gt;&lt;code&gt;bin/build-spi.sh 25.0.6 --module …&lt;/code&gt; (Maven in Docker)&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Database&lt;/td&gt;&lt;td&gt;PostgreSQL 16, fresh empty DB&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Host&lt;/td&gt;&lt;td&gt;&lt;strong&gt;local laptop Docker&lt;/strong&gt; (not Hetzner); 1k-class fixture, no scale component&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;JVM heap&lt;/td&gt;&lt;td&gt;&lt;code&gt;-Xms1g -Xmx4g&lt;/code&gt; (compose default)&lt;/td&gt;&lt;/tr&gt;
&lt;/tbody&gt;&lt;/table&gt;
&lt;p&gt;The removed API the brittle fixtures target is the three-argument overload
&lt;code&gt;KeycloakModelUtils.runJobInTransactionWithResult(KeycloakSessionFactory, KeycloakContext, KeycloakSessionTaskWithResult)&lt;/code&gt; — present in 25.0.6, absent in
26.0.0 (replaced by a four-argument overload carrying an extra &lt;code&gt;String&lt;/code&gt;), living
in &lt;code&gt;keycloak-server-spi-private&lt;/code&gt; (no compatibility promise).&lt;/p&gt;
&lt;h2 id=&quot;findings&quot;&gt;Findings&lt;/h2&gt;
&lt;h3 id=&quot;finding-1-a-tame-protocol-mapper-registers-on-26-7-1-and-the-spi-is-internal&quot;&gt;Finding 1 — a tame protocol mapper registers on 26.7.1, and the SPI is internal ⭐&lt;/h3&gt;
&lt;p&gt;A custom &lt;code&gt;ProtocolMapper&lt;/code&gt; implementing only the stable base interface loads and
registers on 26.7.1 alongside the authenticator and user-storage provider. All
three appear in &lt;code&gt;/admin/serverinfo&lt;/code&gt;:&lt;/p&gt;
&lt;table&gt;&lt;thead&gt;&lt;tr&gt;&lt;th&gt;SPI (serverinfo key)&lt;/th&gt;&lt;th&gt;Provider&lt;/th&gt;&lt;/tr&gt;&lt;/thead&gt;&lt;tbody&gt;
&lt;tr&gt;&lt;td&gt;&lt;code&gt;authenticator&lt;/code&gt;&lt;/td&gt;&lt;td&gt;&lt;code&gt;lab-authenticator&lt;/code&gt;&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;&lt;code&gt;protocol-mapper&lt;/code&gt;&lt;/td&gt;&lt;td&gt;&lt;code&gt;lab-protocol-mapper&lt;/code&gt;&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;&lt;code&gt;storage&lt;/code&gt;&lt;/td&gt;&lt;td&gt;&lt;code&gt;lab-user-storage&lt;/code&gt;&lt;/td&gt;&lt;/tr&gt;
&lt;/tbody&gt;&lt;/table&gt;
&lt;p&gt;And the &quot;internal SPI&quot; warning now covers a &lt;strong&gt;third&lt;/strong&gt; type. &lt;code&gt;KC-SERVICES0047&lt;/code&gt;
fires for the protocol mapper and the authenticator, and &lt;strong&gt;not&lt;/strong&gt; for the
user-storage provider:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;WARN KC-SERVICES0047: lab-protocol-mapper (lab.LabProtocolMapper) is
  implementing the internal SPI protocol-mapper. This SPI is internal and may
  change without notice
WARN KC-SERVICES0047: lab-authenticator (lab.LabAuthenticatorFactory) is
  implementing the internal SPI authenticator. This SPI is internal and may
  change without notice
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The reason is the same one the earlier record found for user-storage, inverted:
&lt;code&gt;org.keycloak.protocol.ProtocolMapper&lt;/code&gt; lives in &lt;strong&gt;&lt;code&gt;keycloak-server-spi-private&lt;/code&gt;&lt;/strong&gt;
(verified: present there, absent from &lt;code&gt;keycloak-core&lt;/code&gt; and &lt;code&gt;keycloak-server-spi&lt;/code&gt;
in 26.7.1), the artifact that carries no compatibility promise. So a customer&#39;s
&lt;strong&gt;protocol mappers&lt;/strong&gt; sit on the same unstable ground as their &lt;strong&gt;authenticators&lt;/strong&gt;
and &lt;strong&gt;event listeners&lt;/strong&gt;; only &lt;strong&gt;user federation&lt;/strong&gt; is a public SPI.&lt;/p&gt;
&lt;p&gt;The base &lt;code&gt;ProtocolMapper&lt;/code&gt; interface itself is stable across the ladder — the
same three abstract methods (&lt;code&gt;getProtocol&lt;/code&gt;, &lt;code&gt;getDisplayCategory&lt;/code&gt;,
&lt;code&gt;getDisplayType&lt;/code&gt;) from 21.1.2 through 26.7.1 — so a tame mapper crosses majors
intact. (What does &lt;em&gt;not&lt;/em&gt; survive is in the ☐ rows: token-transform behaviour,
which moved out of the base interface — see &lt;em&gt;Not tested&lt;/em&gt;.)&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Correction (added 2026-08-26, same day):&lt;/strong&gt; the parenthetical above is
imprecise. The base &lt;code&gt;ProtocolMapper&lt;/code&gt; interface &lt;em&gt;never&lt;/em&gt; carried the
token-transform methods — they live on &lt;code&gt;OIDCAccessTokenMapper&lt;/code&gt; /
&lt;code&gt;OIDCIDTokenMapper&lt;/code&gt; in &lt;strong&gt;&lt;code&gt;keycloak-services&lt;/code&gt;&lt;/strong&gt;, a separate artifact, and their
signatures are stable 25 → 26. The brittle-protocol-mapper row is resolved in
&lt;a href=&quot;/findings/2026-08-26-provider-types-brittle-protocol-mapper/&quot;&gt;&lt;code&gt;2026-08-26-provider-types-brittle-protocol-mapper&lt;/code&gt;&lt;/a&gt;; the &quot;moved out of the
base interface in 26&quot; blocker does not describe a real change.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;h3 id=&quot;finding-2-a-brittle-authenticator-fails-at-use-not-at-load-and-it-fails-loudly&quot;&gt;Finding 2 — a brittle authenticator fails at use, not at load, and it fails loudly ⭐⭐&lt;/h3&gt;
&lt;p&gt;&lt;code&gt;fixtures/spi-brittle-auth&lt;/code&gt; calls the removed three-argument overload from
inside &lt;code&gt;authenticate()&lt;/code&gt; — the work method, deliberately not &lt;code&gt;init()&lt;/code&gt; — so the
question asked is the one S7 asked of listeners: &lt;em&gt;does Keycloak die at startup,
or only when a flow reaches the step?&lt;/em&gt;&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;It loads.&lt;/strong&gt; On 26.7.1 the server starts with zero ERRORs, the factory &lt;code&gt;init()&lt;/code&gt;
runs, and the provider registers in &lt;code&gt;/admin/serverinfo&lt;/code&gt;:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;[lab-brittle-auth] authenticator factory init -- provider loaded
WARN KC-SERVICES0047: lab-brittle-authenticator (lab.LabBrittleAuthenticatorFactory)
  is implementing the internal SPI authenticator ...
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;It dies at first use, loudly.&lt;/strong&gt; Wiring it as a REQUIRED execution in a browser
flow and attempting a login returns &lt;strong&gt;HTTP 500&lt;/strong&gt; with a JSON error body, and the
log carries the full stack as an &lt;em&gt;uncaught server error&lt;/em&gt;:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;ERROR [org.keycloak.services.error.KeycloakErrorHandler] (executor-thread-1)
  Uncaught server error: java.lang.NoSuchMethodError: &amp;#39;java.lang.Object
  org.keycloak.models.utils.KeycloakModelUtils.runJobInTransactionWithResult(
  org.keycloak.models.KeycloakSessionFactory, org.keycloak.models.KeycloakContext,
  org.keycloak.models.KeycloakSessionTaskWithResult)&amp;#39;
    at lab.LabBrittleAuthenticator.authenticate(LabBrittleAuthenticator.java:25)
    at org.keycloak.authentication.DefaultAuthenticationFlow.processSingleFlowExecutionModel(...)
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Response body:&lt;/p&gt;
&lt;pre&gt;&lt;code data-lang=&quot;json&quot;&gt;{&amp;quot;error&amp;quot;:&amp;quot;unknown_error&amp;quot;,&amp;quot;error_description&amp;quot;:&amp;quot;For more on this error consult the server log.&amp;quot;}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;This is the answer to the open row, and it splits two ways. The &lt;strong&gt;load-vs-use&lt;/strong&gt;
half generalises S7: Keycloak does not invoke an authenticator&#39;s &lt;code&gt;authenticate()&lt;/code&gt;
(or a listener&#39;s &lt;code&gt;onEvent()&lt;/code&gt;) during startup, so a removed API that only those
methods reach does not break the upgrade — the breakage is deferred to first use.
The &lt;strong&gt;silent-vs-loud&lt;/strong&gt; half does &lt;em&gt;not&lt;/em&gt; generalise: S7&#39;s listener failure was
swallowed (the triggering request still returned 201/200, nothing at default
log level), whereas the authenticator&#39;s &lt;code&gt;NoSuchMethodError&lt;/code&gt; propagates out of the
authentication flow as an uncaught error and the login fails with HTTP 500.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Why that matters for a post-upgrade check:&lt;/strong&gt; a brittle &lt;em&gt;authenticator&lt;/em&gt; is
self-announcing — the first login fails loudly and someone investigates. A
brittle &lt;em&gt;listener&lt;/em&gt; is the dangerous one — logins keep succeeding while audit
events silently stop being forwarded. A smoke test (&quot;can users log in?&quot;) catches
the former and misses the latter.&lt;/p&gt;
&lt;h3 id=&quot;finding-3-the-brittle-listener-control-reproduces-s7-in-this-environment&quot;&gt;Finding 3 — the brittle-listener control reproduces S7 in this environment&lt;/h3&gt;
&lt;p&gt;Re-running the existing &lt;code&gt;fixtures/spi-brittle&lt;/code&gt; listener on the same 26.7.1
instance confirmed S7: it registers, factory &lt;code&gt;init()&lt;/code&gt; runs, &lt;code&gt;KC-SERVICES0047&lt;/code&gt;
fires — and a successful login returns 200 with &lt;strong&gt;no&lt;/strong&gt; error and &lt;strong&gt;no&lt;/strong&gt;
&lt;code&gt;[lab-brittle] user event&lt;/code&gt; line in the log. The &lt;code&gt;onEvent&lt;/code&gt; println sits &lt;em&gt;after&lt;/em&gt;
the removed-API call, so its absence means the throw happened before it and was
swallowed by event dispatch. The same &lt;code&gt;NoSuchMethodError&lt;/code&gt; that is loud in an
authentication flow is silent in an event listener.&lt;/p&gt;
&lt;h3 id=&quot;finding-4-brittle-user-storage-also-fails-lazily-and-it-fails-loudly&quot;&gt;Finding 4 — brittle user-storage also fails lazily, and it fails loudly ⭐&lt;/h3&gt;
&lt;p&gt;&lt;code&gt;fixtures/spi-brittle-us&lt;/code&gt; calls the same removed three-argument overload from
&lt;code&gt;getUserByUsername&lt;/code&gt; (the lookup work method), with the session captured in the
constructor — the natural shape of a real user-federation provider, since lookup
methods receive no session argument of their own.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;It loads.&lt;/strong&gt; On 26.7.1 the server starts with zero ERRORs, the factory &lt;code&gt;init()&lt;/code&gt;
runs, and the provider registers under the &lt;code&gt;storage&lt;/code&gt; SPI in &lt;code&gt;/admin/serverinfo&lt;/code&gt;
with &lt;strong&gt;no&lt;/strong&gt; &lt;code&gt;KC-SERVICES0047&lt;/code&gt; — consistent with user federation being the one
public SPI. The component is not even instantiated at startup: its constructor
line appears only on first use, when the realm&#39;s &lt;code&gt;UserProvider&lt;/code&gt; is built.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;It dies at first lookup, loudly.&lt;/strong&gt; Configuring the provider as a user
federation component and attempting a password grant for any username returns
HTTP 500 with the same &lt;code&gt;unknown_error&lt;/code&gt; body and an uncaught &lt;code&gt;NoSuchMethodError&lt;/code&gt;:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;ERROR [org.keycloak.services.error.KeycloakErrorHandler] (executor-thread-1)
  Uncaught server error: java.lang.NoSuchMethodError: &amp;#39;java.lang.Object
  org.keycloak.models.utils.KeycloakModelUtils.runJobInTransactionWithResult(...)&amp;#39;
    at lab.LabBrittleUserStorageProvider.getUserByUsername(LabBrittleUserStorageProvider.java:37)
    at org.keycloak.storage.UserStorageManager.getUserByUsername(UserStorageManager.java:559)
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The exception escapes &lt;code&gt;UserStorageManager.tryResolveFederatedUser&lt;/code&gt; rather than
being swallowed, so user federation failures announce themselves the way an
authenticator&#39;s does — not the way a listener&#39;s does.&lt;/p&gt;
&lt;p&gt;The brittle matrix is now complete across four types, and it splits cleanly:&lt;/p&gt;
&lt;table&gt;&lt;thead&gt;&lt;tr&gt;&lt;th&gt;Type&lt;/th&gt;&lt;th&gt;Fails at&lt;/th&gt;&lt;th&gt;Visibility&lt;/th&gt;&lt;th&gt;Record&lt;/th&gt;&lt;/tr&gt;&lt;/thead&gt;&lt;tbody&gt;
&lt;tr&gt;&lt;td&gt;event listener&lt;/td&gt;&lt;td&gt;use&lt;/td&gt;&lt;td&gt;&lt;strong&gt;silent&lt;/strong&gt; (request succeeds)&lt;/td&gt;&lt;td&gt;S7&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;authenticator&lt;/td&gt;&lt;td&gt;use&lt;/td&gt;&lt;td&gt;&lt;strong&gt;loud&lt;/strong&gt; (HTTP 500)&lt;/td&gt;&lt;td&gt;this run, Finding 2&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;user-storage&lt;/td&gt;&lt;td&gt;use&lt;/td&gt;&lt;td&gt;&lt;strong&gt;loud&lt;/strong&gt; (HTTP 500)&lt;/td&gt;&lt;td&gt;this run, Finding 4&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;protocol mapper&lt;/td&gt;&lt;td&gt;use&lt;/td&gt;&lt;td&gt;&lt;strong&gt;loud&lt;/strong&gt; (HTTP 500)&lt;/td&gt;&lt;td&gt;&lt;a href=&quot;/findings/2026-08-26-provider-types-brittle-protocol-mapper/&quot;&gt;&lt;code&gt;2026-08-26-provider-types-brittle-protocol-mapper&lt;/code&gt;&lt;/a&gt; (added later same day)&lt;/td&gt;&lt;/tr&gt;
&lt;/tbody&gt;&lt;/table&gt;
&lt;p&gt;No brittle type fails at &lt;strong&gt;load&lt;/strong&gt;. Keycloak does not invoke any provider&#39;s work
method during startup, so a removed API only reachable from the work method
never breaks the upgrade itself — it defers the breakage to first use. The
difference between the rows is purely how the failure surfaces: event dispatch
swallows it; authentication and user-lookup paths let it propagate to HTTP 500.&lt;/p&gt;
&lt;h2 id=&quot;verification&quot;&gt;Verification&lt;/h2&gt;
&lt;table&gt;&lt;thead&gt;&lt;tr&gt;&lt;th&gt;Claim&lt;/th&gt;&lt;th&gt;Primary source&lt;/th&gt;&lt;th&gt;Checked&lt;/th&gt;&lt;/tr&gt;&lt;/thead&gt;&lt;tbody&gt;
&lt;tr&gt;&lt;td&gt;Tame protocol mapper registers on 26.7.1&lt;/td&gt;&lt;td&gt;live &lt;code&gt;/admin/serverinfo&lt;/code&gt; query — &lt;code&gt;protocol-mapper → lab-protocol-mapper&lt;/code&gt; (quoted above)&lt;/td&gt;&lt;td&gt;☑&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;&lt;code&gt;ProtocolMapper&lt;/code&gt; is in &lt;code&gt;keycloak-server-spi-private&lt;/code&gt;, not &lt;code&gt;-spi&lt;/code&gt;/&lt;code&gt;-core&lt;/code&gt;&lt;/td&gt;&lt;td&gt;jar listing of all three 26.7.1 artifacts (0/0/1 matches)&lt;/td&gt;&lt;td&gt;☑&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;&lt;code&gt;protocol-mapper&lt;/code&gt; SPI is internal&lt;/td&gt;&lt;td&gt;&lt;code&gt;KC-SERVICES0047&lt;/code&gt; line for &lt;code&gt;lab-protocol-mapper&lt;/code&gt; in startup log&lt;/td&gt;&lt;td&gt;☑&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;&lt;code&gt;authenticator&lt;/code&gt; internal, &lt;code&gt;storage&lt;/code&gt; not&lt;/td&gt;&lt;td&gt;warning fired for authenticator only, consistent with prior record&lt;/td&gt;&lt;td&gt;☑&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Brittle authenticator loads (not load-failing)&lt;/td&gt;&lt;td&gt;startup log: &lt;code&gt;init -- provider loaded&lt;/code&gt;, 0 ERRORs, registered in serverinfo&lt;/td&gt;&lt;td&gt;☑&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Brittle authenticator dies at use, loudly&lt;/td&gt;&lt;td&gt;HTTP 500 + &lt;code&gt;unknown_error&lt;/code&gt; body + &lt;code&gt;NoSuchMethodError&lt;/code&gt; stack (quoted above)&lt;/td&gt;&lt;td&gt;☑&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Removed overload present 25.0.6, absent 26.x&lt;/td&gt;&lt;td&gt;&lt;code&gt;javap&lt;/code&gt; of &lt;code&gt;KeycloakModelUtils&lt;/code&gt; in both artifacts&lt;/td&gt;&lt;td&gt;☑&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Brittle listener is silent (S7 control)&lt;/td&gt;&lt;td&gt;login 200, no error, no &lt;code&gt;[lab-brittle] user event&lt;/code&gt; line&lt;/td&gt;&lt;td&gt;☑&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Brittle user-storage (load vs use)&lt;/td&gt;&lt;td&gt;lazy + loud — HTTP 500 on first lookup, &lt;code&gt;NoSuchMethodError&lt;/code&gt; at &lt;code&gt;getUserByUsername&lt;/code&gt; (Finding 4)&lt;/td&gt;&lt;td&gt;☑&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Brittle protocol mapper (load vs use)&lt;/td&gt;&lt;td&gt;&lt;strong&gt;done&lt;/strong&gt; — &lt;a href=&quot;/findings/2026-08-26-provider-types-brittle-protocol-mapper/&quot;&gt;&lt;code&gt;2026-08-26-provider-types-brittle-protocol-mapper&lt;/code&gt;&lt;/a&gt;: lazy + &lt;strong&gt;loud&lt;/strong&gt; (HTTP 500). That record &lt;strong&gt;refutes&lt;/strong&gt; the &quot;no natural home&quot; blocker below: the work method &lt;code&gt;OIDCAccessTokenMapper.transformAccessToken&lt;/code&gt; lives in &lt;code&gt;keycloak-services&lt;/code&gt; and is stable 25 → 26&lt;/td&gt;&lt;td&gt;☑&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;The exact 24.x release that split model/storage&lt;/td&gt;&lt;td&gt;&lt;strong&gt;narrowed&lt;/strong&gt; — 24.0.0 (+ 25.0.3 anomaly). See &lt;a href=&quot;/findings/2026-08-26-userstorage-artifact-split-narrowed/&quot;&gt;&lt;code&gt;2026-08-26-userstorage-artifact-split-narrowed&lt;/code&gt;&lt;/a&gt;&lt;/td&gt;&lt;td&gt;☑&lt;/td&gt;&lt;/tr&gt;
&lt;/tbody&gt;&lt;/table&gt;
&lt;h2 id=&quot;publishable-extract&quot;&gt;Publishable extract&lt;/h2&gt;
&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;A custom authenticator that outlived its API does not break your upgrade. It
breaks your next login.&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;We built an authenticator against Keycloak 25.0.6 that calls a helper removed
in 26.0.0, then loaded it on 26.7.1. The server started cleanly — the provider
registered, &lt;code&gt;/admin/serverinfo&lt;/code&gt; listed it, zero errors. The first login that
reached it returned HTTP 500 and a full &lt;code&gt;NoSuchMethodError&lt;/code&gt; stack as an
uncaught server error.&lt;/p&gt;
&lt;p&gt;A broken user-federation provider behaves like the authenticator, not the
listener: the first login that consults it returns HTTP 500.&lt;/p&gt;
&lt;p&gt;Compare the same mistake in an event listener: the server also starts, the
listener also registers, but when the first event fires the exception is
swallowed and the request still succeeds. Your audit trail stops silently.&lt;/p&gt;
&lt;p&gt;All three are the same removed API. The difference is not the provider, it is
where Keycloak calls it: authentication and user-lookup paths propagate the
error, event dispatch eats it. So your post-upgrade check cannot be &quot;can users
log in&quot; — that catches the loud failures and misses the quiet one. Verify the
listener actually produced output, not that login works.&lt;/p&gt;
&lt;/blockquote&gt;
</content>
        
    </entry>
    <entry xml:lang="en">
        <title>Rehearsal Record — provider types beyond event listeners: the SPI relocation, and which SPIs are &quot;internal&quot;</title>
        <published>2026-08-26T00:00:00+00:00</published>
        <updated>2026-08-26T00:00:00+00:00</updated>
        
        <author>
          <name>Unknown</name>
        </author>
        
        <link rel="alternate" type="text/html" href="https://ledger.mlabs.city/findings/2026-08-26-provider-types/"/>
        <id>https://ledger.mlabs.city/findings/2026-08-26-provider-types/</id>
        
        <content type="html" xml:base="https://ledger.mlabs.city/findings/2026-08-26-provider-types/">&lt;h2 id=&quot;why-this-run-existed&quot;&gt;Why this run existed&lt;/h2&gt;
&lt;p&gt;S7 (&lt;a href=&quot;/findings/2026-08-25-s7-custom-spi/&quot;&gt;&lt;code&gt;2026-08-25-s7-custom-spi&lt;/code&gt;&lt;/a&gt;) established that an &lt;em&gt;event listener&lt;/em&gt; built
for 21.1.2 loads and registers on 26.7.1, and that a brittle one fails lazily —
at first use, silently. It left unchecked whether other provider types
(authenticators, user storage, mappers) behave the same way. This run adds an
authenticator and a user-storage provider, and the attempt to write them
surfaced a finding S7 never could have: &lt;strong&gt;the user-storage SPI is not in
&lt;code&gt;keycloak-server-spi&lt;/code&gt; at all&lt;/strong&gt;, and its Maven artifact changed between majors.&lt;/p&gt;
&lt;h2 id=&quot;environment&quot;&gt;Environment&lt;/h2&gt;
&lt;table&gt;&lt;thead&gt;&lt;tr&gt;&lt;th&gt;Field&lt;/th&gt;&lt;th&gt;Value&lt;/th&gt;&lt;/tr&gt;&lt;/thead&gt;&lt;tbody&gt;
&lt;tr&gt;&lt;td&gt;Keycloak&lt;/td&gt;&lt;td&gt;built against 25.0.6, loaded on 26.7.1 (crosses the 26.0.0 API-removal boundary)&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Fixture&lt;/td&gt;&lt;td&gt;&lt;code&gt;fixtures/spi-types/&lt;/code&gt; — &lt;code&gt;lab-authenticator&lt;/code&gt; (Authenticator + Factory), &lt;code&gt;lab-user-storage&lt;/code&gt; (UserStorageProvider + Factory), stable SPI only&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Build&lt;/td&gt;&lt;td&gt;&lt;code&gt;bin/build-spi.sh 25.0.6 --module spi-types&lt;/code&gt; (Maven in Docker)&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Database&lt;/td&gt;&lt;td&gt;PostgreSQL 16, fresh empty DB&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Host&lt;/td&gt;&lt;td&gt;Hetzner CCX33 (fsn1) — 8 vCPU, 30 GB RAM&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;JVM heap&lt;/td&gt;&lt;td&gt;&lt;code&gt;-Xms1g -Xmx4g&lt;/code&gt;&lt;/td&gt;&lt;/tr&gt;
&lt;/tbody&gt;&lt;/table&gt;
&lt;h2 id=&quot;verification&quot;&gt;Verification&lt;/h2&gt;
&lt;table&gt;&lt;thead&gt;&lt;tr&gt;&lt;th&gt;Claim&lt;/th&gt;&lt;th&gt;Primary source&lt;/th&gt;&lt;th&gt;Checked&lt;/th&gt;&lt;/tr&gt;&lt;/thead&gt;&lt;tbody&gt;
&lt;tr&gt;&lt;td&gt;&lt;code&gt;UserStorageProvider&lt;/code&gt; absent from &lt;code&gt;keycloak-server-spi&lt;/code&gt; 25.0.6&lt;/td&gt;&lt;td&gt;jar contents listed; compile failure&lt;/td&gt;&lt;td&gt;☑&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;It lives in &lt;code&gt;keycloak-model-legacy&lt;/code&gt; (23) then &lt;code&gt;keycloak-model-storage&lt;/code&gt; (24+)&lt;/td&gt;&lt;td&gt;Keycloak git tree at 23.0.7 / 24.0.5 / 25.0.6&lt;/td&gt;&lt;td&gt;☑&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;&lt;code&gt;keycloak-model-legacy&lt;/code&gt; gone by 25.0.6&lt;/td&gt;&lt;td&gt;Maven Central 404 at 25.0.6, 200 at 23/24&lt;/td&gt;&lt;td&gt;☑&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;&lt;code&gt;spi-api-diff.sh&lt;/code&gt; misses it (does not diff model artifacts)&lt;/td&gt;&lt;td&gt;script source diffs only core/server-spi/server-spi-private&lt;/td&gt;&lt;td&gt;☑&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Tame authenticator loads + registers on 26.7.1&lt;/td&gt;&lt;td&gt;factory init line; &lt;code&gt;lab-authenticator&lt;/code&gt; in serverinfo&lt;/td&gt;&lt;td&gt;☑&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Tame user-storage loads + registers on 26.7.1&lt;/td&gt;&lt;td&gt;factory init line; &lt;code&gt;lab-user-storage&lt;/code&gt; in serverinfo&lt;/td&gt;&lt;td&gt;☑&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;&lt;code&gt;authenticator&lt;/code&gt; SPI is internal, &lt;code&gt;user-storage&lt;/code&gt; is not&lt;/td&gt;&lt;td&gt;&lt;code&gt;KC-SERVICES0047&lt;/code&gt; fired for authenticator only&lt;/td&gt;&lt;td&gt;☑&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Protocol mappers (third S7 type)&lt;/td&gt;&lt;td&gt;&lt;strong&gt;done&lt;/strong&gt; — &lt;a href=&quot;/findings/2026-08-26-provider-types-protocol-mapper-brittle/&quot;&gt;&lt;code&gt;2026-08-26-provider-types-protocol-mapper-brittle&lt;/code&gt;&lt;/a&gt;: registers, SPI is internal&lt;/td&gt;&lt;td&gt;☑&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Brittle (removed-API) versions of these types, and when they fail&lt;/td&gt;&lt;td&gt;&lt;strong&gt;authenticator + user-storage done&lt;/strong&gt; — both lazy + loud (HTTP 500), &lt;a href=&quot;/findings/2026-08-26-provider-types-protocol-mapper-brittle/&quot;&gt;&lt;code&gt;2026-08-26-provider-types-protocol-mapper-brittle&lt;/code&gt;&lt;/a&gt;. Protocol-mapper brittle variant still open&lt;/td&gt;&lt;td&gt;◐&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;The exact 24.x release that split model/storage from model/legacy&lt;/td&gt;&lt;td&gt;&lt;strong&gt;narrowed&lt;/strong&gt; — 24.0.0; &lt;code&gt;keycloak-model-legacy&lt;/code&gt; dropped at 25.0.0 with a 25.0.3 anomaly. See &lt;a href=&quot;/findings/2026-08-26-userstorage-artifact-split-narrowed/&quot;&gt;&lt;code&gt;2026-08-26-userstorage-artifact-split-narrowed&lt;/code&gt;&lt;/a&gt;&lt;/td&gt;&lt;td&gt;☑&lt;/td&gt;&lt;/tr&gt;
&lt;/tbody&gt;&lt;/table&gt;
</content>
        
    </entry>
    <entry xml:lang="en">
        <title>Rehearsal Record — Run C: export/import across 23 → 24 keeps unmanaged attributes, and S15 said it would not</title>
        <published>2026-08-26T00:00:00+00:00</published>
        <updated>2026-08-26T00:00:00+00:00</updated>
        
        <author>
          <name>Unknown</name>
        </author>
        
        <link rel="alternate" type="text/html" href="https://ledger.mlabs.city/findings/2026-08-26-runC-realm-export-import-23-to-24/"/>
        <id>https://ledger.mlabs.city/findings/2026-08-26-runC-realm-export-import-23-to-24/</id>
        
        <content type="html" xml:base="https://ledger.mlabs.city/findings/2026-08-26-runC-realm-export-import-23-to-24/">&lt;h2 id=&quot;why-this-run-existed&quot;&gt;Why this run existed&lt;/h2&gt;
&lt;p&gt;S15 found that in-place upgrading 23 → 24 preserves unmanaged user attributes,
because the migration sets that realm&#39;s &lt;code&gt;unmanagedAttributePolicy&lt;/code&gt; to &lt;code&gt;ENABLED&lt;/code&gt;,
and that a realm &lt;em&gt;created&lt;/em&gt; on 24+ silently discards them. It then generalised:&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;So the danger is not the upgrade. It is every path that produces a &lt;em&gt;new&lt;/em&gt; realm
on a new version: &lt;strong&gt;export/import migration&lt;/strong&gt;, a rebuilt environment, disaster
recovery, or simply a realm created after the upgrade.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;Export/import is the most common of those and the one customers choose &lt;em&gt;because&lt;/em&gt;
it feels safer than an in-place upgrade. We ranked it the untested case most likely to bite a real customer.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;It does not bite. The generalisation was wrong, and we would have told a
customer otherwise.&lt;/strong&gt;&lt;/p&gt;
&lt;h2 id=&quot;environment&quot;&gt;Environment&lt;/h2&gt;
&lt;table&gt;&lt;thead&gt;&lt;tr&gt;&lt;th&gt;Field&lt;/th&gt;&lt;th&gt;Value&lt;/th&gt;&lt;/tr&gt;&lt;/thead&gt;&lt;tbody&gt;
&lt;tr&gt;&lt;td&gt;Source&lt;/td&gt;&lt;td&gt;realm &lt;code&gt;expimp&lt;/code&gt; created on &lt;strong&gt;23.0.7&lt;/strong&gt; through the admin API, 3 users each carrying &lt;code&gt;department&lt;/code&gt; and &lt;code&gt;employeeId&lt;/code&gt;&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Export&lt;/td&gt;&lt;td&gt;&lt;code&gt;kc.sh export --dir /exp --realm expimp --users realm_file&lt;/code&gt;, run as a one-shot container against the same database&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Import&lt;/td&gt;&lt;td&gt;&lt;code&gt;kc.sh import --dir /exp&lt;/code&gt; into a &lt;strong&gt;freshly purged&lt;/strong&gt; database at each target version&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Targets&lt;/td&gt;&lt;td&gt;24.0.5, 25.0.6, 26.0.0, 26.7.1&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Database&lt;/td&gt;&lt;td&gt;PostgreSQL 16, single node, container; &lt;code&gt;docker compose down -v&lt;/code&gt; between legs&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Host&lt;/td&gt;&lt;td&gt;Hetzner CCX33 (fsn1) — 8 vCPU dedicated, 30 GB RAM, local NVMe&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;JVM heap&lt;/td&gt;&lt;td&gt;&lt;code&gt;-Xms1g -Xmx4g&lt;/code&gt;&lt;/td&gt;&lt;/tr&gt;
&lt;/tbody&gt;&lt;/table&gt;
&lt;h2 id=&quot;results&quot;&gt;Results&lt;/h2&gt;
&lt;table&gt;&lt;thead&gt;&lt;tr&gt;&lt;th&gt;Leg&lt;/th&gt;&lt;th&gt;Users&lt;/th&gt;&lt;th&gt;&lt;code&gt;USER_ATTRIBUTE&lt;/code&gt; rows&lt;/th&gt;&lt;th&gt;Admin API returns them&lt;/th&gt;&lt;th&gt;Accepts a &lt;strong&gt;new&lt;/strong&gt; attribute&lt;/th&gt;&lt;/tr&gt;&lt;/thead&gt;&lt;tbody&gt;
&lt;tr&gt;&lt;td&gt;23.0.7 as built&lt;/td&gt;&lt;td&gt;3&lt;/td&gt;&lt;td&gt;6&lt;/td&gt;&lt;td&gt;yes&lt;/td&gt;&lt;td&gt;—&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;23.0.7 → &lt;strong&gt;23.0.7&lt;/strong&gt; (control)&lt;/td&gt;&lt;td&gt;3&lt;/td&gt;&lt;td&gt;6&lt;/td&gt;&lt;td&gt;yes&lt;/td&gt;&lt;td&gt;—&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;23.0.7 → &lt;strong&gt;24.0.5&lt;/strong&gt;&lt;/td&gt;&lt;td&gt;3&lt;/td&gt;&lt;td&gt;&lt;strong&gt;6&lt;/strong&gt;&lt;/td&gt;&lt;td&gt;&lt;strong&gt;yes&lt;/strong&gt;&lt;/td&gt;&lt;td&gt;&lt;strong&gt;204, rows 6 → 7&lt;/strong&gt;&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;23.0.7 → &lt;strong&gt;25.0.6&lt;/strong&gt;&lt;/td&gt;&lt;td&gt;3&lt;/td&gt;&lt;td&gt;&lt;strong&gt;6&lt;/strong&gt;&lt;/td&gt;&lt;td&gt;&lt;strong&gt;yes&lt;/strong&gt;&lt;/td&gt;&lt;td&gt;&lt;strong&gt;204, rows 6 → 7&lt;/strong&gt;&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;23.0.7 → &lt;strong&gt;26.0.0&lt;/strong&gt;&lt;/td&gt;&lt;td&gt;3&lt;/td&gt;&lt;td&gt;&lt;strong&gt;6&lt;/strong&gt;&lt;/td&gt;&lt;td&gt;&lt;strong&gt;yes&lt;/strong&gt;&lt;/td&gt;&lt;td&gt;&lt;strong&gt;204, rows 6 → 7&lt;/strong&gt;&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;23.0.7 → &lt;strong&gt;26.7.1&lt;/strong&gt;&lt;/td&gt;&lt;td&gt;3&lt;/td&gt;&lt;td&gt;&lt;strong&gt;6&lt;/strong&gt;&lt;/td&gt;&lt;td&gt;&lt;strong&gt;yes&lt;/strong&gt;&lt;/td&gt;&lt;td&gt;&lt;strong&gt;204, rows 6 → 7&lt;/strong&gt;&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;in-place 23 → 24, then export/import within 24.0.5&lt;/td&gt;&lt;td&gt;3&lt;/td&gt;&lt;td&gt;6&lt;/td&gt;&lt;td&gt;yes&lt;/td&gt;&lt;td&gt;—&lt;/td&gt;&lt;/tr&gt;
&lt;/tbody&gt;&lt;/table&gt;
&lt;p&gt;Nothing is lost, at any target version, and the imported realm continues to
&lt;em&gt;accept&lt;/em&gt; unmanaged attributes afterwards — the write test added one and the row
count went to 7. That last column matters: S15&#39;s realm-creation failure showed
&lt;code&gt;HTTP 201&lt;/code&gt; with &lt;strong&gt;zero rows reaching the database&lt;/strong&gt;. Here the write lands.&lt;/p&gt;
&lt;h2 id=&quot;verification&quot;&gt;Verification&lt;/h2&gt;
&lt;table&gt;&lt;thead&gt;&lt;tr&gt;&lt;th&gt;Claim&lt;/th&gt;&lt;th&gt;Primary source&lt;/th&gt;&lt;th&gt;Checked&lt;/th&gt;&lt;/tr&gt;&lt;/thead&gt;&lt;tbody&gt;
&lt;tr&gt;&lt;td&gt;Attributes survive export/import into 24.0.5&lt;/td&gt;&lt;td&gt;6 &lt;code&gt;USER_ATTRIBUTE&lt;/code&gt; rows, admin API returns both&lt;/td&gt;&lt;td&gt;☑&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;…and into 25.0.6, 26.0.0, 26.7.1&lt;/td&gt;&lt;td&gt;same query at each version&lt;/td&gt;&lt;td&gt;☑&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;The imported realm still accepts new unmanaged attributes&lt;/td&gt;&lt;td&gt;PUT → 204, rows 6 → 7, at all four versions&lt;/td&gt;&lt;td&gt;☑&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;The import sets &lt;code&gt;unmanagedAttributePolicy=ENABLED&lt;/code&gt;&lt;/td&gt;&lt;td&gt;&lt;code&gt;component_config&lt;/code&gt; for &lt;code&gt;declarative-user-profile&lt;/code&gt;, read directly&lt;/td&gt;&lt;td&gt;☑&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;A 23.0.7 export carries the attributes but not the policy&lt;/td&gt;&lt;td&gt;the export JSON, both greps quoted&lt;/td&gt;&lt;td&gt;☑&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Export/import within 23.0.7 is lossless (control)&lt;/td&gt;&lt;td&gt;6 rows before and after&lt;/td&gt;&lt;td&gt;☑&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Larger realms behave the same&lt;/td&gt;&lt;td&gt;&lt;strong&gt;not tested&lt;/strong&gt; — 3 users&lt;/td&gt;&lt;td&gt;☐&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;&lt;code&gt;--users different_files&lt;/code&gt; / &lt;code&gt;same_file&lt;/code&gt; strategies&lt;/td&gt;&lt;td&gt;&lt;strong&gt;not tested&lt;/strong&gt; — only &lt;code&gt;realm_file&lt;/code&gt;&lt;/td&gt;&lt;td&gt;☐&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Partial export through the admin API rather than &lt;code&gt;kc.sh export&lt;/code&gt;&lt;/td&gt;&lt;td&gt;&lt;strong&gt;not tested&lt;/strong&gt;, and it excludes users by default&lt;/td&gt;&lt;td&gt;☐&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Whether a realm created by the admin API on 24+ still loses them&lt;/td&gt;&lt;td&gt;&lt;strong&gt;not retested here&lt;/strong&gt; — S15&#39;s result stands as measured&lt;/td&gt;&lt;td&gt;☐&lt;/td&gt;&lt;/tr&gt;
&lt;/tbody&gt;&lt;/table&gt;
</content>
        
    </entry>
    <entry xml:lang="en">
        <title>Rehearsal Record — Run D: LDAPS works across three majors; what breaks is trust, and it says &quot;SocketReset&quot;</title>
        <published>2026-08-26T00:00:00+00:00</published>
        <updated>2026-08-26T00:00:00+00:00</updated>
        
        <author>
          <name>Unknown</name>
        </author>
        
        <link rel="alternate" type="text/html" href="https://ledger.mlabs.city/findings/2026-08-26-runD-ldaps-starttls/"/>
        <id>https://ledger.mlabs.city/findings/2026-08-26-runD-ldaps-starttls/</id>
        
        <content type="html" xml:base="https://ledger.mlabs.city/findings/2026-08-26-runD-ldaps-starttls/">&lt;h2 id=&quot;summary&quot;&gt;Summary&lt;/h2&gt;
&lt;p&gt;S9 ran federation over plaintext and said TLS is where real deployments break.
Two candidate reasons were on the table. One is refuted and one is confirmed, and
the confirmed one is confirmed for a reason nobody would guess from the error
message.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Refuted:&lt;/strong&gt; the truststore option rename does &lt;em&gt;not&lt;/em&gt; break federation across
23 → 24. A deployment configured the pre-24 way keeps working on 24.0.5 and on
26.7.1, three majors later, with only deprecation warnings.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Confirmed:&lt;/strong&gt; an untrusted certificate breaks it completely — and Keycloak
reports it as &lt;strong&gt;&lt;code&gt;SocketReset&lt;/code&gt;&lt;/strong&gt;, with no mention of a certificate anywhere in the
response. The server starts perfectly happily; nothing surfaces until something
touches LDAP.&lt;/p&gt;
&lt;h2 id=&quot;environment&quot;&gt;Environment&lt;/h2&gt;
&lt;table&gt;&lt;thead&gt;&lt;tr&gt;&lt;th&gt;Field&lt;/th&gt;&lt;th&gt;Value&lt;/th&gt;&lt;/tr&gt;&lt;/thead&gt;&lt;tbody&gt;
&lt;tr&gt;&lt;td&gt;Directory&lt;/td&gt;&lt;td&gt;&lt;code&gt;bitnamilegacy/openldap:2.6&lt;/code&gt;, TLS on &lt;strong&gt;1636&lt;/strong&gt;, plaintext 1389 still up&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Certificates&lt;/td&gt;&lt;td&gt;private CA, server cert &lt;code&gt;CN=ldap&lt;/code&gt;, SAN &lt;code&gt;DNS:ldap, DNS:localhost, IP:127.0.0.1&lt;/code&gt;, generated by &lt;code&gt;bin/make-ldap-certs.sh&lt;/code&gt;&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Federation config&lt;/td&gt;&lt;td&gt;&lt;code&gt;seed-realm.sh --ldap --ldap-host ldaps://ldap:1636&lt;/code&gt;, &lt;code&gt;editMode=READ_ONLY&lt;/code&gt;, &lt;code&gt;importEnabled=true&lt;/code&gt;&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Trust (24+)&lt;/td&gt;&lt;td&gt;&lt;code&gt;KC_TRUSTSTORE_PATHS=/opt/keycloak/conf/truststores&lt;/code&gt;, a bind-mounted directory&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Trust (pre-24)&lt;/td&gt;&lt;td&gt;&lt;code&gt;KC_SPI_TRUSTSTORE_FILE_FILE&lt;/code&gt; + &lt;code&gt;_PASSWORD&lt;/code&gt;, a PKCS12 built with &lt;code&gt;keytool&lt;/code&gt;&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Database&lt;/td&gt;&lt;td&gt;PostgreSQL 16, single node, container&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Host&lt;/td&gt;&lt;td&gt;Hetzner CCX33 (fsn1) — 8 vCPU dedicated, 30 GB RAM, local NVMe&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;JVM heap&lt;/td&gt;&lt;td&gt;&lt;code&gt;-Xms1g -Xmx4g&lt;/code&gt;&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Lab change&lt;/td&gt;&lt;td&gt;&lt;code&gt;docker-compose.ldaps.yml&lt;/code&gt;, &lt;code&gt;docker-compose.ldaps-legacy.yml&lt;/code&gt;, &lt;code&gt;bin/make-ldap-certs.sh&lt;/code&gt;&lt;/td&gt;&lt;/tr&gt;
&lt;/tbody&gt;&lt;/table&gt;
&lt;h2 id=&quot;breakage-observed&quot;&gt;Breakage observed&lt;/h2&gt;
&lt;h3 id=&quot;a-certificate-trust-failure-is-reported-as-socketreset&quot;&gt;A certificate trust failure is reported as &lt;code&gt;SocketReset&lt;/code&gt;&lt;/h3&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Symptom:&lt;/strong&gt; federation returns nothing; sync fails with
&lt;code&gt;{&quot;errorMessage&quot;:&quot;SocketReset&quot;}&lt;/code&gt; and HTTP 400. Server healthy throughout.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Evidence:&lt;/strong&gt; D1 against D2 — same server, same config, the only difference
being one CA certificate in the truststore directory.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Cause:&lt;/strong&gt; &lt;strong&gt;confirmed&lt;/strong&gt; by the differential.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Fix:&lt;/strong&gt; put the directory&#39;s CA in the truststore —
&lt;code&gt;KC_TRUSTSTORE_PATHS&lt;/code&gt; on 24+, &lt;code&gt;KC_SPI_TRUSTSTORE_FILE_FILE&lt;/code&gt; before that.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Would this hit a customer?&lt;/strong&gt; &lt;strong&gt;Every customer using LDAPS with an internal
CA&lt;/strong&gt;, which is most of them. The failure is undiagnosable from its message.&lt;/li&gt;
&lt;/ul&gt;
&lt;h3 id=&quot;a-malformed-truststore-fails-the-same-way-as-no-truststore&quot;&gt;A malformed truststore fails the same way as no truststore&lt;/h3&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Symptom:&lt;/strong&gt; trust configured, path correct, file present, and LDAPS still
fails exactly as if nothing had been configured.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Evidence:&lt;/strong&gt; found by making the mistake. &lt;code&gt;openssl pkcs12 -export -nokeys&lt;/code&gt;
writes the CA as a plain certBag; Java requires a &lt;code&gt;trustedCertEntry&lt;/code&gt;. The store
loads, the trust anchor set is empty, and the only trace is
&lt;code&gt;PKIXParameters.setTrustAnchors&lt;/code&gt; in a stack trace.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Fix:&lt;/strong&gt; build truststores with &lt;code&gt;keytool -importcert&lt;/code&gt;, and verify with
&lt;code&gt;keytool -list&lt;/code&gt; that the entry type is &lt;code&gt;trustedCertEntry&lt;/code&gt;. &lt;code&gt;bin/make-ldap-certs.sh&lt;/code&gt;
now does both.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Would this hit a customer?&lt;/strong&gt; Yes, and it is a plausible pre-flight finding —
&quot;your truststore is empty&quot; is not visible from the file existing.&lt;/li&gt;
&lt;/ul&gt;
&lt;h2 id=&quot;verification&quot;&gt;Verification&lt;/h2&gt;
&lt;table&gt;&lt;thead&gt;&lt;tr&gt;&lt;th&gt;Claim&lt;/th&gt;&lt;th&gt;Primary source&lt;/th&gt;&lt;th&gt;Checked&lt;/th&gt;&lt;/tr&gt;&lt;/thead&gt;&lt;tbody&gt;
&lt;tr&gt;&lt;td&gt;LDAPS federation works with the CA trusted&lt;/td&gt;&lt;td&gt;D2 — 3 users imported, HTTP 200, lookup returns &lt;code&gt;alice&lt;/code&gt;&lt;/td&gt;&lt;td&gt;☑&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Without trust it fails as &lt;code&gt;SocketReset&lt;/code&gt;, HTTP 400&lt;/td&gt;&lt;td&gt;D1, response quoted&lt;/td&gt;&lt;td&gt;☑&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Keycloak starts and passes readiness either way&lt;/td&gt;&lt;td&gt;both legs reached ready before the sync was attempted&lt;/td&gt;&lt;td&gt;☑&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;No certificate wording in the response or default log&lt;/td&gt;&lt;td&gt;grepped for &lt;code&gt;PKIX&lt;/code&gt;, &lt;code&gt;handshake&lt;/code&gt;, &lt;code&gt;truststore&lt;/code&gt;, &lt;code&gt;certification&lt;/code&gt; — nothing in D1&lt;/td&gt;&lt;td&gt;☑&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;TLS federation survives 26.0.0 → 26.7.1&lt;/td&gt;&lt;td&gt;D3 — READY, 3 users still linked&lt;/td&gt;&lt;td&gt;☑&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Pre-24 truststore config still works on 24.0.5 and 26.7.1&lt;/td&gt;&lt;td&gt;D5/D6/D7 — syncs return 200, 3 users at every step&lt;/td&gt;&lt;td&gt;☑&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Keycloak warns that the pre-24 options are deprecated&lt;/td&gt;&lt;td&gt;both warnings quoted, from 24.0.5 and 26.7.1&lt;/td&gt;&lt;td&gt;☑&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;A certBag-only PKCS12 behaves as an empty truststore&lt;/td&gt;&lt;td&gt;reproduced, then fixed; &lt;code&gt;keytool -list&lt;/code&gt; confirms &lt;code&gt;trustedCertEntry&lt;/code&gt; after&lt;/td&gt;&lt;td&gt;☑&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;&lt;strong&gt;StartTLS on 1389&lt;/strong&gt;&lt;/td&gt;&lt;td&gt;&lt;strong&gt;tested&lt;/strong&gt; — works trusted, fails &lt;em&gt;silently&lt;/em&gt; (HTTP 200, 0 imported) — &lt;a href=&quot;/findings/2026-08-26-starttls-1389/&quot;&gt;&lt;code&gt;2026-08-26-starttls-1389&lt;/code&gt;&lt;/a&gt;&lt;/td&gt;&lt;td&gt;☑&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Hostname-verification failures (cert whose SAN does not match)&lt;/td&gt;&lt;td&gt;&lt;strong&gt;tested&lt;/strong&gt; — hard failure, &lt;code&gt;SSLPeerUnverifiedException&lt;/code&gt;, silent at the API — &lt;a href=&quot;/findings/2026-08-26-starttls-1389/&quot;&gt;&lt;code&gt;2026-08-26-starttls-1389&lt;/code&gt;&lt;/a&gt;&lt;/td&gt;&lt;td&gt;☑&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Expired or revoked directory certificates&lt;/td&gt;&lt;td&gt;&lt;strong&gt;expired: tested&lt;/strong&gt; (silent, &lt;code&gt;CertificateExpiredException&lt;/code&gt;); &lt;strong&gt;revoked: not tested&lt;/strong&gt; — no CRL in the lab CA&lt;/td&gt;&lt;td&gt;☑ (expired) / ☐ (revoked)&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Behaviour when the truststore path exists but is empty on 24+&lt;/td&gt;&lt;td&gt;&lt;strong&gt;not tested separately&lt;/strong&gt; — D1&#39;s empty directory is that case, but it was not distinguished from &quot;no truststore configured&quot;&lt;/td&gt;&lt;td&gt;☐&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;&lt;code&gt;editMode=WRITABLE&lt;/code&gt; / &lt;code&gt;UNSYNCED&lt;/code&gt; over TLS&lt;/td&gt;&lt;td&gt;&lt;strong&gt;not tested&lt;/strong&gt; — still &lt;code&gt;READ_ONLY&lt;/code&gt;, as S9 left it&lt;/td&gt;&lt;td&gt;☐&lt;/td&gt;&lt;/tr&gt;
&lt;/tbody&gt;&lt;/table&gt;
</content>
        
    </entry>
    <entry xml:lang="en">
        <title>Rehearsal Record — StartTLS on 1389: works when trusted, and fails *silently* (unlike LDAPS)</title>
        <published>2026-08-26T00:00:00+00:00</published>
        <updated>2026-08-26T00:00:00+00:00</updated>
        
        <author>
          <name>Unknown</name>
        </author>
        
        <link rel="alternate" type="text/html" href="https://ledger.mlabs.city/findings/2026-08-26-starttls-1389/"/>
        <id>https://ledger.mlabs.city/findings/2026-08-26-starttls-1389/</id>
        
        <content type="html" xml:base="https://ledger.mlabs.city/findings/2026-08-26-starttls-1389/">&lt;h2 id=&quot;why-this-run-existed&quot;&gt;Why this run existed&lt;/h2&gt;
&lt;p&gt;Run D (&lt;a href=&quot;/findings/2026-08-26-runD-ldaps-starttls/&quot;&gt;&lt;code&gt;2026-08-26-runD-ldaps-starttls&lt;/code&gt;&lt;/a&gt;) covered implicit LDAPS on 1636 and
left three rows unchecked: StartTLS on 1389, hostname-verification failures, and
expired certificates. This run closes all three, and the first one changes what
Run D concluded about the failure signature.&lt;/p&gt;
&lt;h2 id=&quot;environment&quot;&gt;Environment&lt;/h2&gt;
&lt;table&gt;&lt;thead&gt;&lt;tr&gt;&lt;th&gt;Field&lt;/th&gt;&lt;th&gt;Value&lt;/th&gt;&lt;/tr&gt;&lt;/thead&gt;&lt;tbody&gt;
&lt;tr&gt;&lt;td&gt;Keycloak&lt;/td&gt;&lt;td&gt;26.7.1 (official image)&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Directory&lt;/td&gt;&lt;td&gt;&lt;code&gt;bitnamilegacy/openldap:2.6&lt;/code&gt;, &lt;code&gt;LDAP_ENABLE_TLS=yes&lt;/code&gt; — TLS on 1636 (LDAPS) &lt;strong&gt;and StartTLS on 1389&lt;/strong&gt;&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Federation config&lt;/td&gt;&lt;td&gt;&lt;code&gt;seed-realm.sh --ldap --ldap-starttls&lt;/code&gt; → &lt;code&gt;connectionUrl=ldap://ldap:1389&lt;/code&gt;, &lt;code&gt;startTls=true&lt;/code&gt;, &lt;code&gt;editMode=READ_ONLY&lt;/code&gt;&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Certificates&lt;/td&gt;&lt;td&gt;private CA (&lt;code&gt;lab-ldap-ca&lt;/code&gt;); server cert &lt;code&gt;CN=ldap&lt;/code&gt;, SAN &lt;code&gt;DNS:ldap, DNS:localhost, IP:127.0.0.1&lt;/code&gt; — plus ad-hoc variants for the failure legs&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Trust&lt;/td&gt;&lt;td&gt;&lt;code&gt;KC_TRUSTSTORE_PATHS=/opt/keycloak/conf/truststores&lt;/code&gt; (bind-mounted dir); trusted = &lt;code&gt;ca.crt&lt;/code&gt; present&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Database&lt;/td&gt;&lt;td&gt;PostgreSQL 16, single node, container&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Host&lt;/td&gt;&lt;td&gt;Hetzner CCX33 (fsn1) — 8 vCPU dedicated, 30 GB RAM&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;JVM heap&lt;/td&gt;&lt;td&gt;&lt;code&gt;-Xms1g -Xmx4g&lt;/code&gt;&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Lab change&lt;/td&gt;&lt;td&gt;&lt;code&gt;seed-realm.sh&lt;/code&gt; gained &lt;code&gt;--ldap-starttls&lt;/code&gt; (sets &lt;code&gt;startTls:[&quot;true&quot;]&lt;/code&gt; on the LDAP component)&lt;/td&gt;&lt;/tr&gt;
&lt;/tbody&gt;&lt;/table&gt;
&lt;h2 id=&quot;result-four-legs-one-sync-each&quot;&gt;Result — four legs, one sync each&lt;/h2&gt;
&lt;p&gt;Full sync via &lt;code&gt;POST /admin/realms/lab/user-storage/{id}/sync?action=triggerFullSync&lt;/code&gt;.&lt;/p&gt;
&lt;table&gt;&lt;thead&gt;&lt;tr&gt;&lt;th&gt;Leg&lt;/th&gt;&lt;th&gt;Trust&lt;/th&gt;&lt;th&gt;Cert&lt;/th&gt;&lt;th&gt;Sync API response&lt;/th&gt;&lt;th&gt;HTTP&lt;/th&gt;&lt;th&gt;Log cause&lt;/th&gt;&lt;/tr&gt;&lt;/thead&gt;&lt;tbody&gt;
&lt;tr&gt;&lt;td&gt;&lt;strong&gt;StartTLS trusted&lt;/strong&gt;&lt;/td&gt;&lt;td&gt;CA trusted&lt;/td&gt;&lt;td&gt;SAN matches &lt;code&gt;ldap&lt;/code&gt;&lt;/td&gt;&lt;td&gt;&lt;code&gt;{&quot;added&quot;:3, ...}&lt;/code&gt; 3 imported&lt;/td&gt;&lt;td&gt;&lt;strong&gt;200&lt;/strong&gt;&lt;/td&gt;&lt;td&gt;—&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;&lt;strong&gt;StartTLS untrusted&lt;/strong&gt;&lt;/td&gt;&lt;td&gt;empty truststore&lt;/td&gt;&lt;td&gt;SAN matches&lt;/td&gt;&lt;td&gt;&lt;code&gt;{&quot;added&quot;:0, ...}&lt;/code&gt; 0 imported&lt;/td&gt;&lt;td&gt;&lt;strong&gt;200&lt;/strong&gt;&lt;/td&gt;&lt;td&gt;&lt;code&gt;SunCertPathBuilderException: unable to find valid certification path&lt;/code&gt;&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;&lt;strong&gt;StartTLS hostname mismatch&lt;/strong&gt;&lt;/td&gt;&lt;td&gt;CA trusted&lt;/td&gt;&lt;td&gt;SAN &lt;code&gt;DNS:wronghost&lt;/code&gt;&lt;/td&gt;&lt;td&gt;&lt;code&gt;{&quot;added&quot;:0, ...}&lt;/code&gt; 0 imported&lt;/td&gt;&lt;td&gt;&lt;strong&gt;200&lt;/strong&gt;&lt;/td&gt;&lt;td&gt;&lt;code&gt;SSLPeerUnverifiedException: hostname of the server &#39;ldap&#39; does not match the hostname in the server&#39;s certificate&lt;/code&gt;&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;&lt;strong&gt;StartTLS expired cert&lt;/strong&gt;&lt;/td&gt;&lt;td&gt;CA trusted&lt;/td&gt;&lt;td&gt;notAfter = issue time&lt;/td&gt;&lt;td&gt;&lt;code&gt;{&quot;added&quot;:0, ...}&lt;/code&gt; 0 imported&lt;/td&gt;&lt;td&gt;&lt;strong&gt;200&lt;/strong&gt;&lt;/td&gt;&lt;td&gt;&lt;code&gt;CertificateExpiredException: NotAfter: …&lt;/code&gt; + &lt;code&gt;CertPathValidatorException: validity check failed&lt;/code&gt;&lt;/td&gt;&lt;/tr&gt;
&lt;/tbody&gt;&lt;/table&gt;
&lt;h2 id=&quot;findings&quot;&gt;Findings&lt;/h2&gt;
&lt;h3 id=&quot;1-starttls-failure-is-silent-ldaps-failure-is-loud&quot;&gt;1. StartTLS failure is silent; LDAPS failure is loud&lt;/h3&gt;
&lt;p&gt;Run D established that an untrusted certificate on &lt;strong&gt;LDAPS&lt;/strong&gt; (1636) fails with
&lt;code&gt;{&quot;errorMessage&quot;:&quot;SocketReset&quot;}&lt;/code&gt; and &lt;strong&gt;HTTP 400&lt;/strong&gt; — loud, and the word
&quot;certificate&quot; never appears. On &lt;strong&gt;StartTLS&lt;/strong&gt; the equivalent failures all return
&lt;strong&gt;HTTP 200&lt;/strong&gt; with a body that looks like a successful, empty sync:&lt;/p&gt;
&lt;pre&gt;&lt;code data-lang=&quot;json&quot;&gt;{&amp;quot;ignored&amp;quot;:false,&amp;quot;added&amp;quot;:0,&amp;quot;updated&amp;quot;:0,&amp;quot;removed&amp;quot;:0,&amp;quot;failed&amp;quot;:0,
 &amp;quot;status&amp;quot;:&amp;quot;0 imported users, 0 updated users&amp;quot;}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The real cause is only in the Keycloak log, as a full stack trace. An operator
(or monitoring integration) that checks the HTTP code — or only greps for
&lt;code&gt;SocketReset&lt;/code&gt;/&lt;code&gt;errorMessage&lt;/code&gt; — sees a &lt;em&gt;successful&lt;/em&gt; sync that imported nothing,
and reaches for &quot;is the directory empty? is the DN wrong?&quot; instead of &quot;is the
certificate trusted?&quot;. &lt;strong&gt;The same misdirection Run D warned about for LDAPS is
worse on StartTLS, because there is no error at all at the API level.&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;The one correct signal in the response is the count: &lt;code&gt;added:0&lt;/code&gt; where you expect
users. The lesson for the runbook is &lt;em&gt;check the imported count, not the status
code&lt;/em&gt; — on StartTLS, &quot;0 imported&quot; is a failure signal, not a success.&lt;/p&gt;
&lt;h3 id=&quot;2-keycloak-enforces-hostname-verification-on-ldap-tls&quot;&gt;2. Keycloak enforces hostname verification on LDAP TLS&lt;/h3&gt;
&lt;p&gt;A trusted CA with a cert whose SAN is &lt;code&gt;DNS:wronghost&lt;/code&gt; fails with
&lt;code&gt;SSLPeerUnverifiedException&lt;/code&gt; / &lt;code&gt;No subject alternative DNS name matching ldap found&lt;/code&gt;. Keycloak does &lt;strong&gt;not&lt;/strong&gt; default to &quot;trust everything, skip hostname&quot; on
LDAP connections — the &lt;code&gt;HostnameChecker&lt;/code&gt; runs, and a SAN that does not match the
connection hostname is a hard failure (silent at the API, loud in the log). This
means a directory cert issued for the wrong name — or a wildcard that does not
cover the host — breaks federation exactly like an untrusted CA.&lt;/p&gt;
&lt;h3 id=&quot;3-starttls-itself-works-and-survives-matching-ldaps&quot;&gt;3. StartTLS itself works and survives, matching LDAPS&lt;/h3&gt;
&lt;p&gt;With the CA trusted and the SAN matching, StartTLS on 1389 imports all three
users — no functional difference from implicit LDAPS. The mode is not the
problem; the &lt;em&gt;failure signature&lt;/em&gt; is.&lt;/p&gt;
&lt;h2 id=&quot;verification&quot;&gt;Verification&lt;/h2&gt;
&lt;table&gt;&lt;thead&gt;&lt;tr&gt;&lt;th&gt;Claim&lt;/th&gt;&lt;th&gt;Primary source&lt;/th&gt;&lt;th&gt;Checked&lt;/th&gt;&lt;/tr&gt;&lt;/thead&gt;&lt;tbody&gt;
&lt;tr&gt;&lt;td&gt;StartTLS on 1389 works when trusted&lt;/td&gt;&lt;td&gt;sync returned &lt;code&gt;added:3&lt;/code&gt;, HTTP 200&lt;/td&gt;&lt;td&gt;☑&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;&lt;code&gt;startTls=true&lt;/code&gt; is actually negotiated (not plaintext fallback)&lt;/td&gt;&lt;td&gt;untrusted leg failed at the TLS handshake (&lt;code&gt;StartTlsResponseImpl.negotiate&lt;/code&gt; → PKIX)&lt;/td&gt;&lt;td&gt;☑&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Untrusted CA under StartTLS is silent&lt;/td&gt;&lt;td&gt;HTTP 200, &lt;code&gt;added:0&lt;/code&gt;, &lt;code&gt;SunCertPathBuilderException&lt;/code&gt; in log&lt;/td&gt;&lt;td&gt;☑&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Hostname mismatch is a hard failure&lt;/td&gt;&lt;td&gt;HTTP 200, &lt;code&gt;added:0&lt;/code&gt;, &lt;code&gt;SSLPeerUnverifiedException&lt;/code&gt; in log&lt;/td&gt;&lt;td&gt;☑&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Expired cert is silent&lt;/td&gt;&lt;td&gt;HTTP 200, &lt;code&gt;added:0&lt;/code&gt;, &lt;code&gt;CertificateExpiredException&lt;/code&gt; in log&lt;/td&gt;&lt;td&gt;☑&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Contrast with LDAPS (loud)&lt;/td&gt;&lt;td&gt;Run D D1: &lt;code&gt;SocketReset&lt;/code&gt;, HTTP 400&lt;/td&gt;&lt;td&gt;☑&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Revoked certificates (CRL/OCSP)&lt;/td&gt;&lt;td&gt;&lt;strong&gt;not tested&lt;/strong&gt; — the lab CA has no revocation list&lt;/td&gt;&lt;td&gt;☐&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;&lt;code&gt;editMode=WRITABLE&lt;/code&gt; / &lt;code&gt;UNSYNCED&lt;/code&gt; over StartTLS&lt;/td&gt;&lt;td&gt;&lt;strong&gt;not tested&lt;/strong&gt; — still &lt;code&gt;READ_ONLY&lt;/code&gt;&lt;/td&gt;&lt;td&gt;☐&lt;/td&gt;&lt;/tr&gt;
&lt;/tbody&gt;&lt;/table&gt;
</content>
        
    </entry>
    <entry xml:lang="en">
        <title>Rehearsal Record — user-storage artifact split, narrowed: 24.0.0 (and a 25.0.3 Maven Central anomaly)</title>
        <published>2026-08-26T00:00:00+00:00</published>
        <updated>2026-08-26T00:00:00+00:00</updated>
        
        <author>
          <name>Unknown</name>
        </author>
        
        <link rel="alternate" type="text/html" href="https://ledger.mlabs.city/findings/2026-08-26-userstorage-artifact-split-narrowed/"/>
        <id>https://ledger.mlabs.city/findings/2026-08-26-userstorage-artifact-split-narrowed/</id>
        
        <content type="html" xml:base="https://ledger.mlabs.city/findings/2026-08-26-userstorage-artifact-split-narrowed/">&lt;h2 id=&quot;why-this-run-existed&quot;&gt;Why this run existed&lt;/h2&gt;
&lt;p&gt;&lt;a href=&quot;/findings/2026-08-26-provider-types/&quot;&gt;&lt;code&gt;2026-08-26-provider-types&lt;/code&gt;&lt;/a&gt; established that &lt;code&gt;org.keycloak.storage. UserStorageProvider&lt;/code&gt; moved out of &lt;code&gt;keycloak-server-spi&lt;/code&gt; and changed Maven
artifact — &lt;code&gt;keycloak-model-legacy&lt;/code&gt; (≤ 23) → &lt;code&gt;keycloak-model-storage&lt;/code&gt; (24+) —
with &lt;code&gt;keycloak-model-legacy&lt;/code&gt; gone by 25.0.6. It left the exact boundary open,
bracketed to (23.0.7, 24.0.5]. This run closes the bracket by inspection of
Maven Central coordinates and the published jars.&lt;/p&gt;
&lt;h2 id=&quot;environment&quot;&gt;Environment&lt;/h2&gt;
&lt;table&gt;&lt;thead&gt;&lt;tr&gt;&lt;th&gt;Field&lt;/th&gt;&lt;th&gt;Value&lt;/th&gt;&lt;/tr&gt;&lt;/thead&gt;&lt;tbody&gt;
&lt;tr&gt;&lt;td&gt;Artifacts inspected&lt;/td&gt;&lt;td&gt;&lt;code&gt;keycloak-model-legacy&lt;/code&gt;, &lt;code&gt;keycloak-model-storage&lt;/code&gt;&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Versions&lt;/td&gt;&lt;td&gt;23.0.7, 24.0.0, 24.0.5, 25.0.0–25.0.6&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Method&lt;/td&gt;&lt;td&gt;Maven Central directory listing + per-coordinate HTTP status + &lt;code&gt;jar tf&lt;/code&gt; for &lt;code&gt;org/keycloak/storage/UserStorageProvider.class&lt;/code&gt;&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Host&lt;/td&gt;&lt;td&gt;local laptop; Maven container + &lt;code&gt;curl&lt;/code&gt;&lt;/td&gt;&lt;/tr&gt;
&lt;/tbody&gt;&lt;/table&gt;
&lt;h2 id=&quot;findings&quot;&gt;Findings&lt;/h2&gt;
&lt;h3 id=&quot;finding-1-the-relocation-happens-at-exactly-24-0-0&quot;&gt;Finding 1 — the relocation happens at exactly 24.0.0 ⭐&lt;/h3&gt;
&lt;p&gt;&lt;code&gt;UserStorageProvider.class&lt;/code&gt; moves between artifacts on the 23 → 24 boundary:&lt;/p&gt;
&lt;table&gt;&lt;thead&gt;&lt;tr&gt;&lt;th&gt;Version&lt;/th&gt;&lt;th&gt;&lt;code&gt;UserStorageProvider.class&lt;/code&gt; in &lt;code&gt;keycloak-model-legacy&lt;/code&gt;&lt;/th&gt;&lt;th&gt;in &lt;code&gt;keycloak-model-storage&lt;/code&gt;&lt;/th&gt;&lt;/tr&gt;&lt;/thead&gt;&lt;tbody&gt;
&lt;tr&gt;&lt;td&gt;23.0.7&lt;/td&gt;&lt;td&gt;&lt;strong&gt;yes&lt;/strong&gt;&lt;/td&gt;&lt;td&gt;artifact not published&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;24.0.0&lt;/td&gt;&lt;td&gt;&lt;strong&gt;no&lt;/strong&gt;&lt;/td&gt;&lt;td&gt;&lt;strong&gt;yes&lt;/strong&gt;&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;24.0.5&lt;/td&gt;&lt;td&gt;no&lt;/td&gt;&lt;td&gt;yes&lt;/td&gt;&lt;/tr&gt;
&lt;/tbody&gt;&lt;/table&gt;
&lt;p&gt;&lt;code&gt;keycloak-model-storage&lt;/code&gt; is first published at &lt;strong&gt;24.0.0&lt;/strong&gt;, and from that release
&lt;code&gt;keycloak-model-legacy&lt;/code&gt; no longer contains the class. So the customer-relevant
break is the &lt;strong&gt;23 → 24 hop&lt;/strong&gt;: a user-storage provider built against
&lt;code&gt;keycloak-model-legacy&lt;/code&gt; keeps its imports (&lt;code&gt;org.keycloak.storage.*&lt;/code&gt;, unchanged)
but must change its Maven coordinate to &lt;code&gt;keycloak-model-storage&lt;/code&gt; at 24.0.0.
Notably, &lt;code&gt;keycloak-model-legacy&lt;/code&gt; &lt;strong&gt;is still published at 24.0.x&lt;/strong&gt; — it just no
longer carries &lt;code&gt;UserStorageProvider&lt;/code&gt;, so a 24.x build that still declares the
old coordinate &lt;em&gt;resolves&lt;/em&gt; but fails to link the class.&lt;/p&gt;
&lt;h3 id=&quot;finding-2-keycloak-model-legacy-is-dropped-at-25-0-0-but-25-0-3-is-a-one-off-anomaly&quot;&gt;Finding 2 — &lt;code&gt;keycloak-model-legacy&lt;/code&gt; is dropped at 25.0.0, but 25.0.3 is a one-off anomaly ⭐&lt;/h3&gt;
&lt;p&gt;The publish history of &lt;code&gt;keycloak-model-legacy&lt;/code&gt; on Maven Central is not a clean
cut. Per-coordinate HTTP checks:&lt;/p&gt;
&lt;table&gt;&lt;thead&gt;&lt;tr&gt;&lt;th&gt;Version&lt;/th&gt;&lt;th&gt;&lt;code&gt;keycloak-model-storage&lt;/code&gt; POM&lt;/th&gt;&lt;th&gt;&lt;code&gt;keycloak-model-legacy&lt;/code&gt; POM&lt;/th&gt;&lt;/tr&gt;&lt;/thead&gt;&lt;tbody&gt;
&lt;tr&gt;&lt;td&gt;25.0.2&lt;/td&gt;&lt;td&gt;200&lt;/td&gt;&lt;td&gt;404&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;&lt;strong&gt;25.0.3&lt;/strong&gt;&lt;/td&gt;&lt;td&gt;&lt;strong&gt;404&lt;/strong&gt;&lt;/td&gt;&lt;td&gt;&lt;strong&gt;200&lt;/strong&gt;&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;25.0.4&lt;/td&gt;&lt;td&gt;200&lt;/td&gt;&lt;td&gt;404&lt;/td&gt;&lt;/tr&gt;
&lt;/tbody&gt;&lt;/table&gt;
&lt;p&gt;&lt;code&gt;keycloak-model-legacy&lt;/code&gt; is absent for 25.0.0–25.0.2, &lt;strong&gt;reappears at 25.0.3 and
there carries &lt;code&gt;UserStorageProvider.class&lt;/code&gt; again&lt;/strong&gt; (verified by &lt;code&gt;jar tf&lt;/code&gt;), then
is gone again from 25.0.4 onward. The mirror image holds for
&lt;code&gt;keycloak-model-storage&lt;/code&gt;: published for every 25.0.x except &lt;strong&gt;25.0.3&lt;/strong&gt;, where it
is missing.&lt;/p&gt;
&lt;p&gt;So the two artifacts invert at exactly 25.0.3 — a one-off publishing anomaly in
that release. Practical consequences, both worth a line in the assessment:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;a build pinned to &lt;code&gt;keycloak-model-storage:25.0.3&lt;/code&gt; fails with &quot;could not
resolve dependency&quot; (404);&lt;/li&gt;
&lt;li&gt;a build still declaring &lt;code&gt;keycloak-model-legacy&lt;/code&gt; and bumped to 25.0.3 &lt;em&gt;silently
works&lt;/em&gt; (200, class present), then fails again at 25.0.4 (404) — the stale
coordinate masks the migration for exactly one release and breaks the next.&lt;/li&gt;
&lt;/ul&gt;
&lt;h3 id=&quot;finding-3-the-stable-end-state-from-25-0-4&quot;&gt;Finding 3 — the stable end state from 25.0.4&lt;/h3&gt;
&lt;p&gt;From 25.0.4 onward the state is what the earlier record reported:
&lt;code&gt;keycloak-model-storage&lt;/code&gt; published, &lt;code&gt;keycloak-model-legacy&lt;/code&gt; gone. The split is
real and permanent at 24.0.0; the 25.0.3 inversion is a transient anomaly, not a
reversal of the move.&lt;/p&gt;
&lt;h2 id=&quot;verification&quot;&gt;Verification&lt;/h2&gt;
&lt;table&gt;&lt;thead&gt;&lt;tr&gt;&lt;th&gt;Claim&lt;/th&gt;&lt;th&gt;Primary source&lt;/th&gt;&lt;th&gt;Checked&lt;/th&gt;&lt;/tr&gt;&lt;/thead&gt;&lt;tbody&gt;
&lt;tr&gt;&lt;td&gt;&lt;code&gt;keycloak-model-storage&lt;/code&gt; first published at 24.0.0&lt;/td&gt;&lt;td&gt;Maven Central directory listing&lt;/td&gt;&lt;td&gt;☑&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;&lt;code&gt;UserStorageProvider.class&lt;/code&gt; in model-storage (not legacy) from 24.0.0&lt;/td&gt;&lt;td&gt;&lt;code&gt;jar tf&lt;/code&gt; of both artifacts at 23.0.7 / 24.0.0 / 24.0.5&lt;/td&gt;&lt;td&gt;☑&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;model-legacy dropped for 25.0.0–25.0.2&lt;/td&gt;&lt;td&gt;per-coordinate HTTP 404&lt;/td&gt;&lt;td&gt;☑&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;model-legacy reappears at 25.0.3 carrying the class&lt;/td&gt;&lt;td&gt;HTTP 200 + &lt;code&gt;jar tf&lt;/code&gt;&lt;/td&gt;&lt;td&gt;☑&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;model-storage missing at 25.0.3&lt;/td&gt;&lt;td&gt;HTTP 404&lt;/td&gt;&lt;td&gt;☑&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;stable end state from 25.0.4&lt;/td&gt;&lt;td&gt;model-storage 200, model-legacy 404&lt;/td&gt;&lt;td&gt;☑&lt;/td&gt;&lt;/tr&gt;
&lt;/tbody&gt;&lt;/table&gt;
&lt;h2 id=&quot;publishable-extract&quot;&gt;Publishable extract&lt;/h2&gt;
&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Your user-federation provider does not change its imports between Keycloak
23 and 24 — it changes its Maven coordinate.&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;&lt;code&gt;org.keycloak.storage.UserStorageProvider&lt;/code&gt; moved from the &lt;code&gt;keycloak-model-legacy&lt;/code&gt;
artifact to &lt;code&gt;keycloak-model-storage&lt;/code&gt; at exactly 24.0.0. The import line is
identical; the dependency is not. The old artifact kept publishing through the
24.0.x line but stopped containing the class, so a stale build resolves and
then dies at runtime — the worst kind of break.&lt;/p&gt;
&lt;p&gt;And there is a one-release trap: at Keycloak 25.0.3 the two artifacts inverted
on Maven Central — &lt;code&gt;keycloak-model-storage&lt;/code&gt; was not published (404) and
&lt;code&gt;keycloak-model-legacy&lt;/code&gt; was (200, class present), before settling back at
25.0.4. A dependency audit that checks &quot;does the coordinate resolve&quot; rather
than &quot;which artifact actually carries the class&quot; misses both halves.&lt;/p&gt;
&lt;/blockquote&gt;
</content>
        
    </entry>
    <entry xml:lang="en">
        <title>Rehearsal Record — HA: primary crash mid-migration, promoted replica resumes cleanly</title>
        <published>2026-08-26T00:00:00+00:00</published>
        <updated>2026-08-26T00:00:00+00:00</updated>
        
        <author>
          <name>Unknown</name>
        </author>
        
        <link rel="alternate" type="text/html" href="https://ledger.mlabs.city/runs/2026-08-26-ha-failover-mid-migration/"/>
        <id>https://ledger.mlabs.city/runs/2026-08-26-ha-failover-mid-migration/</id>
        
        <content type="html" xml:base="https://ledger.mlabs.city/runs/2026-08-26-ha-failover-mid-migration/">&lt;h2 id=&quot;summary&quot;&gt;Summary&lt;/h2&gt;
&lt;p&gt;First run of the HA/failover line. A streaming replica was stood up behind the
primary, the migration was started, the primary was &lt;strong&gt;SIGKILLed mid-migration&lt;/strong&gt;
(changeset &lt;code&gt;26.6.0-45009-broker-link-identity-provider&lt;/code&gt;), the replica was
promoted, Keycloak was repointed at it, and it &lt;strong&gt;resumed and finished cleanly&lt;/strong&gt;.&lt;/p&gt;
&lt;p&gt;Four findings, in the order they matter:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;The replica is a consistent, half-migrated snapshot.&lt;/strong&gt; At the crash the
replica had &lt;strong&gt;179 of 211&lt;/strong&gt; changesets applied — every &lt;em&gt;committed&lt;/em&gt; changeset,
with the in-flight one rolled back. Streaming replication does not ship a
half-applied transaction, so the failover target is never corrupt.&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;&lt;code&gt;migration_model&lt;/code&gt; is the way to see it.&lt;/strong&gt; At the crash the replica&#39;s
changelog said 179 changesets but &lt;code&gt;migration_model&lt;/code&gt; still said &lt;strong&gt;26.0.0&lt;/strong&gt;,
because the model stamp is written last. The half-migrated state is therefore
&lt;em&gt;detectable&lt;/em&gt;, not silent — the same &quot;changelog ≠ model&quot; signal S8 taught us to
read, now produced by a crash instead of a pooler.&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;The changelog lock is NOT a stuck-lock hazard across failover.&lt;/strong&gt; After the
crash the replica&#39;s &lt;code&gt;DATABASECHANGELOGLOCK&lt;/code&gt; rows were &lt;code&gt;LOCKED=f&lt;/code&gt; — the crashed
primary&#39;s lock acquisition did not persist to the standby. Keycloak re-acquires
the lock and resumes rather than waiting out a lock held by a dead host.&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;Keycloak resumes, it does not restart.&lt;/strong&gt; Re-pointed at the promoted replica,
Keycloak ran changesets 180 → 211, stamped the model 26.7.1, and reached ready
with all 202 users intact. Index audit clean (119/119).&lt;/p&gt;
&lt;/li&gt;
&lt;/ol&gt;
&lt;h2 id=&quot;environment&quot;&gt;Environment&lt;/h2&gt;
&lt;table&gt;&lt;thead&gt;&lt;tr&gt;&lt;th&gt;Field&lt;/th&gt;&lt;th&gt;Value&lt;/th&gt;&lt;/tr&gt;&lt;/thead&gt;&lt;tbody&gt;
&lt;tr&gt;&lt;td&gt;Keycloak from → to&lt;/td&gt;&lt;td&gt;26.0.0 → 26.7.1&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Distribution / start mode&lt;/td&gt;&lt;td&gt;quay.io/keycloak/keycloak official image, &lt;code&gt;start&lt;/code&gt; w/ external DB&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Database&lt;/td&gt;&lt;td&gt;PostgreSQL 16, &lt;strong&gt;primary + streaming (async) replica&lt;/strong&gt;, containers&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Replication&lt;/td&gt;&lt;td&gt;&lt;code&gt;wal_level=replica&lt;/code&gt;, streaming via &lt;code&gt;pg_basebackup -X stream&lt;/code&gt;, &lt;code&gt;wal_keep_size=64MB&lt;/code&gt;&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Postgres tuning&lt;/td&gt;&lt;td&gt;stock (&lt;code&gt;shared_buffers&lt;/code&gt; 128MB, &lt;code&gt;maintenance_work_mem&lt;/code&gt; 64MB, &lt;code&gt;statement_timeout&lt;/code&gt; 0)&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Adverse scenario&lt;/td&gt;&lt;td&gt;#4 — primary crash + failover, mid-migration&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Dataset scale&lt;/td&gt;&lt;td&gt;200 users, 2 realms&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Seeding method&lt;/td&gt;&lt;td&gt;&lt;code&gt;partialImport&lt;/code&gt; (&lt;code&gt;bin/seed-realm.sh --profile typical&lt;/code&gt;)&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Topology&lt;/td&gt;&lt;td&gt;1 primary + 1 replica; Keycloak single container&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Host&lt;/td&gt;&lt;td&gt;laptop (Docker 29)&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;JVM heap&lt;/td&gt;&lt;td&gt;&lt;code&gt;-Xms1g -Xmx4g&lt;/code&gt;&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Elapsed clock&lt;/td&gt;&lt;td&gt;migration ~4s to the crash point; recovery after repoint ~17s&lt;/td&gt;&lt;/tr&gt;
&lt;/tbody&gt;&lt;/table&gt;
&lt;h2 id=&quot;procedure&quot;&gt;Procedure&lt;/h2&gt;
&lt;pre&gt;&lt;code&gt;# primary + keycloak, then seed + snapshot (replica initialised by hand)
COMPOSE_FILE=docker-compose.yml:docker-compose.ha.yml
docker compose up -d db keycloak
./bin/seed-realm.sh --profile typical --users 200
./bin/snapshot.sh baseline-26.0.0-ha-typical-200
./bin/ha-init-replica.sh          # pg_basebackup + standby.signal + primary_conninfo

# failover: start migration, SIGKILL primary mid-flight, promote, repoint
sed -i &amp;#39;s/^KC_VERSION=.*/KC_VERSION=26.7.1/&amp;#39; .env
docker compose up -d keycloak &amp;amp;    # build + migrate
# poll for &amp;quot;Updating database&amp;quot;, sleep 1s, then:
docker compose kill db            # SIGKILL, not a clean stop — a crash
./bin/ha-failover.sh              # pg_ctl promote + KC_DB_URL -&amp;gt; db-replica
./bin/index-audit.sh 26.7.1       # DB_SERVICE=db-replica
&lt;/code&gt;&lt;/pre&gt;
&lt;h2 id=&quot;results&quot;&gt;Results&lt;/h2&gt;
&lt;h3 id=&quot;the-crash-point&quot;&gt;The crash point&lt;/h3&gt;
&lt;p&gt;Keycloak was mid-changeset when the primary died:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;ERROR: Migration failed for changeset META-INF/jpa-changelog-26.6.0.xml::
  26.6.0-45009-broker-link-identity-provider::keycloak:
  Reason: ... PSQLException: An I/O error occurred while sending to the backend.
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The replica (still a standby) held, at that instant:&lt;/p&gt;
&lt;table&gt;&lt;thead&gt;&lt;tr&gt;&lt;th&gt;Replica state&lt;/th&gt;&lt;th&gt;Value&lt;/th&gt;&lt;/tr&gt;&lt;/thead&gt;&lt;tbody&gt;
&lt;tr&gt;&lt;td&gt;&lt;code&gt;databasechangelog&lt;/code&gt; rows&lt;/td&gt;&lt;td&gt;&lt;strong&gt;179&lt;/strong&gt; (of 211)&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;&lt;code&gt;migration_model&lt;/code&gt;&lt;/td&gt;&lt;td&gt;&lt;strong&gt;26.0.0&lt;/strong&gt;&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;&lt;code&gt;databasechangeloglock&lt;/code&gt;&lt;/td&gt;&lt;td&gt;two rows, both &lt;code&gt;LOCKED=f&lt;/code&gt;&lt;/td&gt;&lt;/tr&gt;
&lt;/tbody&gt;&lt;/table&gt;
&lt;h3 id=&quot;after-promote-repoint&quot;&gt;After promote + repoint&lt;/h3&gt;
&lt;table&gt;&lt;thead&gt;&lt;tr&gt;&lt;th&gt;Promoted replica state&lt;/th&gt;&lt;th&gt;Value&lt;/th&gt;&lt;/tr&gt;&lt;/thead&gt;&lt;tbody&gt;
&lt;tr&gt;&lt;td&gt;&lt;code&gt;databasechangelog&lt;/code&gt; rows&lt;/td&gt;&lt;td&gt;&lt;strong&gt;211&lt;/strong&gt;&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;&lt;code&gt;migration_model&lt;/code&gt;&lt;/td&gt;&lt;td&gt;&lt;strong&gt;26.7.1&lt;/strong&gt;&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;&lt;code&gt;user_entity&lt;/code&gt;&lt;/td&gt;&lt;td&gt;202 (unchanged)&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;index audit&lt;/td&gt;&lt;td&gt;&lt;strong&gt;119 correct / 0 missing / 0 wrong shape / 0 unverified&lt;/strong&gt;&lt;/td&gt;&lt;/tr&gt;
&lt;/tbody&gt;&lt;/table&gt;
&lt;p&gt;The old primary, restarted by &lt;code&gt;depends_on&lt;/code&gt;, crash-recovered
to its stale 179-changeset state and was then stopped — the promoted replica is
the only primary.&lt;/p&gt;
&lt;h2 id=&quot;what-this-teaches&quot;&gt;What this teaches&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Failover mid-migration is safe to rehearse and to expect&lt;/strong&gt;: the standby is
always a committed-prefix snapshot, and Keycloak resumes from that prefix. No
corrupt schema, no stuck lock, no data loss.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;The pre-flight for a customer running HA&lt;/strong&gt;: the check after a failover is
still &quot;does &lt;code&gt;migration_model&lt;/code&gt; match the target version&quot; — not &quot;did the
changelog reach N rows&quot;. The crash leaves them divergent by construction.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;This closes the last Compose-reachable part of the failover scenario.&lt;/strong&gt; What is
still untested: &lt;em&gt;synchronous&lt;/em&gt; replication and a &lt;em&gt;clean&lt;/em&gt; (promoted-by-choice)
failover — a DBA promoting the replica for maintenance while Keycloak keeps
writing — which is a different failure shape than a crash.&lt;/li&gt;
&lt;/ul&gt;
&lt;h2 id=&quot;verification&quot;&gt;Verification&lt;/h2&gt;
&lt;table&gt;&lt;thead&gt;&lt;tr&gt;&lt;th&gt;Claim&lt;/th&gt;&lt;th&gt;Evidence&lt;/th&gt;&lt;/tr&gt;&lt;/thead&gt;&lt;tbody&gt;
&lt;tr&gt;&lt;td&gt;Replica holds a committed-prefix snapshot after crash&lt;/td&gt;&lt;td&gt;179/211 changelog rows, model 26.0.0 ☑&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Lock is not stuck after failover&lt;/td&gt;&lt;td&gt;&lt;code&gt;databasechangeloglock&lt;/code&gt; &lt;code&gt;LOCKED=f&lt;/code&gt; ☑&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Keycloak resumes on the promoted replica&lt;/td&gt;&lt;td&gt;changelog 211, model 26.7.1, ready ☑&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Schema correct after recovery&lt;/td&gt;&lt;td&gt;index audit 119/119 ☑&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Data intact&lt;/td&gt;&lt;td&gt;&lt;code&gt;user_entity&lt;/code&gt; 202 before and after ☑&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Streaming works end-to-end&lt;/td&gt;&lt;td&gt;&lt;code&gt;pg_stat_replication&lt;/code&gt; &lt;code&gt;streaming&lt;/code&gt;, lag ~24µs ☑&lt;/td&gt;&lt;/tr&gt;
&lt;/tbody&gt;&lt;/table&gt;
</content>
        
    </entry>
    <entry xml:lang="en">
        <title>Rehearsal Record — what Keycloak&#39;s index check actually checks, and the upgrade it kills</title>
        <published>2026-08-26T00:00:00+00:00</published>
        <updated>2026-08-26T00:00:00+00:00</updated>
        
        <author>
          <name>Unknown</name>
        </author>
        
        <link rel="alternate" type="text/html" href="https://ledger.mlabs.city/runs/2026-08-26-index-precondition-anatomy/"/>
        <id>https://ledger.mlabs.city/runs/2026-08-26-index-precondition-anatomy/</id>
        
        <content type="html" xml:base="https://ledger.mlabs.city/runs/2026-08-26-index-precondition-anatomy/">&lt;h2 id=&quot;why-this-run-existed&quot;&gt;Why this run existed&lt;/h2&gt;
&lt;p&gt;Run A established that Keycloak&#39;s index changeset is guarded by a name-based
precondition and that the 300,000-row skip reads a planner statistic. It left
four questions open, and each was cheap to answer once &lt;code&gt;reltuples&lt;/code&gt; was known to
be a lever. Answering them turned up a failure mode none of the previous
twenty-one records contains: &lt;strong&gt;an upgrade that dies mid-migration and leaves the
server unable to start.&lt;/strong&gt;&lt;/p&gt;
&lt;h2 id=&quot;environment&quot;&gt;Environment&lt;/h2&gt;
&lt;p&gt;Identical to &lt;a href=&quot;/runs/2026-08-25-runA-indexes-manually-created/&quot;&gt;&lt;code&gt;2026-08-25-runA-indexes-manually-created&lt;/code&gt;&lt;/a&gt; — 2,000,003 users,
&lt;code&gt;baseline-26.0.0-sqlseed-2m&lt;/code&gt;, Postgres 16 at stock settings, Hetzner CCX33
(8 vCPU dedicated, 30GB RAM, local NVMe), JVM &lt;code&gt;-Xms1g -Xmx4g&lt;/code&gt;, official image,
&lt;code&gt;start&lt;/code&gt; with an external DB. Every leg begins with a verified restore of that
baseline, so the legs are comparable to each other and to Run A&#39;s.&lt;/p&gt;
&lt;p&gt;&lt;code&gt;autovacuum&lt;/code&gt; is disabled on &lt;code&gt;USER_ENTITY&lt;/code&gt; in the legs that write to &lt;code&gt;pg_class&lt;/code&gt;,
so an analyze cannot silently undo the setup between the write and the upgrade.&lt;/p&gt;
&lt;h2 id=&quot;results&quot;&gt;Results&lt;/h2&gt;
&lt;table&gt;&lt;thead&gt;&lt;tr&gt;&lt;th&gt;Leg&lt;/th&gt;&lt;th&gt;Setup&lt;/th&gt;&lt;th&gt;Upgrade&lt;/th&gt;&lt;th&gt;&lt;code&gt;exectype&lt;/code&gt;&lt;/th&gt;&lt;th&gt;Index on &lt;code&gt;USER_ENTITY&lt;/code&gt;&lt;/th&gt;&lt;th&gt;WARNs&lt;/th&gt;&lt;/tr&gt;&lt;/thead&gt;&lt;tbody&gt;
&lt;tr&gt;&lt;td&gt;&lt;strong&gt;A6a&lt;/strong&gt;&lt;/td&gt;&lt;td&gt;&lt;code&gt;reltuples&lt;/code&gt;=1000, &lt;code&gt;relpages&lt;/code&gt; honest&lt;/td&gt;&lt;td&gt;READY&lt;/td&gt;&lt;td&gt;&lt;code&gt;EXECUTED&lt;/code&gt;&lt;/td&gt;&lt;td&gt;&lt;strong&gt;built, correct&lt;/strong&gt;&lt;/td&gt;&lt;td&gt;0&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;&lt;strong&gt;A6b&lt;/strong&gt;&lt;/td&gt;&lt;td&gt;&lt;code&gt;reltuples&lt;/code&gt; honest, &lt;code&gt;relpages&lt;/code&gt;=100&lt;/td&gt;&lt;td&gt;READY&lt;/td&gt;&lt;td&gt;&lt;code&gt;EXECUTED&lt;/code&gt;&lt;/td&gt;&lt;td&gt;&lt;strong&gt;absent&lt;/strong&gt;&lt;/td&gt;&lt;td&gt;2&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;&lt;strong&gt;A7a&lt;/strong&gt;&lt;/td&gt;&lt;td&gt;same index name on the &lt;strong&gt;&lt;code&gt;realm&lt;/code&gt;&lt;/strong&gt; table&lt;/td&gt;&lt;td&gt;READY&lt;/td&gt;&lt;td&gt;&lt;code&gt;EXECUTED&lt;/code&gt;&lt;/td&gt;&lt;td&gt;&lt;strong&gt;absent&lt;/strong&gt;&lt;/td&gt;&lt;td&gt;&lt;strong&gt;1&lt;/strong&gt;&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;&lt;strong&gt;A7b&lt;/strong&gt;&lt;/td&gt;&lt;td&gt;same name, same table, &lt;strong&gt;UNIQUE&lt;/strong&gt;, on &lt;code&gt;(id)&lt;/code&gt;&lt;/td&gt;&lt;td&gt;READY&lt;/td&gt;&lt;td&gt;&lt;strong&gt;&lt;code&gt;MARK_RAN&lt;/code&gt;&lt;/strong&gt;&lt;/td&gt;&lt;td&gt;&lt;strong&gt;wrong index kept&lt;/strong&gt;&lt;/td&gt;&lt;td&gt;0&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;&lt;strong&gt;A7c&lt;/strong&gt;&lt;/td&gt;&lt;td&gt;A7a &lt;strong&gt;+&lt;/strong&gt; falsified &lt;code&gt;reltuples&lt;/code&gt;&lt;/td&gt;&lt;td&gt;&lt;strong&gt;FAILED&lt;/strong&gt;&lt;/td&gt;&lt;td&gt;—&lt;/td&gt;&lt;td&gt;&lt;strong&gt;absent&lt;/strong&gt;&lt;/td&gt;&lt;td&gt;0&lt;/td&gt;&lt;/tr&gt;
&lt;/tbody&gt;&lt;/table&gt;
&lt;h2 id=&quot;breakage-observed&quot;&gt;Breakage observed&lt;/h2&gt;
&lt;h3 id=&quot;an-index-name-collision-on-an-unrelated-table-fails-the-upgrade-and-blocks-startup&quot;&gt;An index name collision on an unrelated table fails the upgrade and blocks startup&lt;/h3&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Symptom:&lt;/strong&gt; &lt;code&gt;relation &quot;idx_user_created_timestamp&quot; already exists&lt;/code&gt;, server
refuses to start, changelog stops at 187/211 with &lt;code&gt;migration_model&lt;/code&gt; at 26.0.0.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Evidence:&lt;/strong&gt; A7c, quoted above.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Cause:&lt;/strong&gt; &lt;strong&gt;confirmed&lt;/strong&gt; — per-schema index names against a per-table precondition.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Fix or workaround:&lt;/strong&gt; rename or drop the colliding index, restart. The
half-applied changelog resumes, as it did in S8 and S14.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Would this hit a customer?&lt;/strong&gt; Config-dependent and latent. It needs someone to
have created an index carrying a Keycloak index name on another table — which
our own runbook makes likelier, since it hands operators those exact names to
paste. It then waits for a build to be attempted.&lt;/li&gt;
&lt;/ul&gt;
&lt;h3 id=&quot;databaseindexchecker-reports-a-healthy-database-when-the-index-is-on-the-wrong-table&quot;&gt;&lt;code&gt;DatabaseIndexChecker&lt;/code&gt; reports a healthy database when the index is on the wrong table&lt;/h3&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Symptom:&lt;/strong&gt; the checker runs and emits nothing, while the index the changeset
defines does not exist on the table it names.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Evidence:&lt;/strong&gt; §3 above; &lt;code&gt;Running database index checker&lt;/code&gt; present, no
&lt;code&gt;Missing database index&lt;/code&gt; line, &lt;code&gt;pg_indexes&lt;/code&gt; shows the index on &lt;code&gt;realm&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Cause:&lt;/strong&gt; &lt;strong&gt;confirmed&lt;/strong&gt; — the checker matches on index name alone.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Fix:&lt;/strong&gt; compare definitions, per table, as &lt;code&gt;bin/index-audit.sh&lt;/code&gt; does.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Would this hit a customer?&lt;/strong&gt; Anyone relying on Keycloak&#39;s own index check.&lt;/li&gt;
&lt;/ul&gt;
&lt;h2 id=&quot;verification&quot;&gt;Verification&lt;/h2&gt;
&lt;table&gt;&lt;thead&gt;&lt;tr&gt;&lt;th&gt;Claim&lt;/th&gt;&lt;th&gt;Primary source&lt;/th&gt;&lt;th&gt;Checked&lt;/th&gt;&lt;/tr&gt;&lt;/thead&gt;&lt;tbody&gt;
&lt;tr&gt;&lt;td&gt;&lt;code&gt;reltuples&lt;/code&gt; is the input; &lt;code&gt;relpages&lt;/code&gt; is not&lt;/td&gt;&lt;td&gt;A6a built, A6b skipped&lt;/td&gt;&lt;td&gt;☑&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Keycloak reads the raw value, not the planner&#39;s estimate&lt;/td&gt;&lt;td&gt;Run A L5 — planner would compute ~533,920 and skip; index was built&lt;/td&gt;&lt;td&gt;☑&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Precondition ignores columns&lt;/td&gt;&lt;td&gt;Run A L3 — &lt;code&gt;MARK_RAN&lt;/code&gt; on a single-column index&lt;/td&gt;&lt;td&gt;☑&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Precondition ignores uniqueness&lt;/td&gt;&lt;td&gt;A7b — &lt;code&gt;MARK_RAN&lt;/code&gt; on a UNIQUE index of a different column&lt;/td&gt;&lt;td&gt;☑&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Precondition honours the table&lt;/td&gt;&lt;td&gt;A7a — same name on &lt;code&gt;realm&lt;/code&gt;, changeset ran&lt;/td&gt;&lt;td&gt;☑&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;&lt;code&gt;DatabaseIndexChecker&lt;/code&gt; does &lt;strong&gt;not&lt;/strong&gt; honour the table&lt;/td&gt;&lt;td&gt;control 2 WARNs vs A7a 1 WARN, message text captured&lt;/td&gt;&lt;td&gt;☑&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;A name collision fails the upgrade&lt;/td&gt;&lt;td&gt;A7c — Liquibase exception, quoted&lt;/td&gt;&lt;td&gt;☑&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;It leaves 187/211 and &lt;code&gt;migration_model&lt;/code&gt; 26.0.0&lt;/td&gt;&lt;td&gt;A7c — queried directly&lt;/td&gt;&lt;td&gt;☑&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Restarting after clearing the collision resumes the changelog&lt;/td&gt;&lt;td&gt;&lt;strong&gt;not tested this run&lt;/strong&gt; — inferred from S8 and S14, which is not evidence&lt;/td&gt;&lt;td&gt;☐&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;The threshold constant on &lt;code&gt;USER_ENTITY&lt;/code&gt; is 300,000, inclusive&lt;/td&gt;&lt;td&gt;bisected here — 300,000 builds, 300,001 skips&lt;/td&gt;&lt;td&gt;☑&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Behaviour on version pairs other than 26.0.0 → 26.7.1&lt;/td&gt;&lt;td&gt;&lt;strong&gt;not tested&lt;/strong&gt;&lt;/td&gt;&lt;td&gt;☐&lt;/td&gt;&lt;/tr&gt;
&lt;/tbody&gt;&lt;/table&gt;
</content>
        
    </entry>
    <entry xml:lang="en">
        <title>Rehearsal Record — Kubernetes / Infinispan: the cache cluster partitions during a rolling update</title>
        <published>2026-08-26T00:00:00+00:00</published>
        <updated>2026-08-26T00:00:00+00:00</updated>
        
        <author>
          <name>Unknown</name>
        </author>
        
        <link rel="alternate" type="text/html" href="https://ledger.mlabs.city/runs/2026-08-26-k8s-infinispan-rolling-update/"/>
        <id>https://ledger.mlabs.city/runs/2026-08-26-k8s-infinispan-rolling-update/</id>
        
        <content type="html" xml:base="https://ledger.mlabs.city/runs/2026-08-26-k8s-infinispan-rolling-update/">&lt;h2 id=&quot;summary&quot;&gt;Summary&lt;/h2&gt;
&lt;p&gt;First Kubernetes run of the lab. A 2-replica Keycloak Deployment on k3d with
embedded Infinispan was rolled 26.0.0 → 26.7.1 under &lt;code&gt;maxUnavailable=0&lt;/code&gt;. Four
findings:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;The cache stack changed inside major 26.&lt;/strong&gt; 26.0.0 hardcodes
&lt;code&gt;stack=&quot;udp&quot;&lt;/code&gt; (multicast) in &lt;code&gt;cache-ispn.xml&lt;/code&gt;, which cannot cluster in
Kubernetes; 26.7.1 drops the hardcode and defaults to &lt;code&gt;jdbc-ping&lt;/code&gt; (database
discovery). Asking 26.0.0 for &lt;code&gt;jdbc-ping&lt;/code&gt; fails with
&lt;code&gt;ISPN000540: No such JGroups stack &#39;jdbc-ping&#39;&lt;/code&gt;. The &lt;code&gt;kubernetes&lt;/code&gt; stack
(DNS_PING) works on both but is deprecated in 26.7.1 — it is what this run
used, so the version was the only variable.&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;Embedded Infinispan clusters and shares sessions across pods&lt;/strong&gt; (26.0.0):
with &lt;code&gt;KC_CACHE_STACK=kubernetes&lt;/code&gt; + &lt;code&gt;-Djgroups.dns.query=&amp;lt;headless FQDN&amp;gt;&lt;/code&gt;, two
pods form a 2-member cluster, and a refresh token issued by pod A is accepted
by pod B.&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;The schema migration is not what breaks a rolling update.&lt;/strong&gt; During the
rollout the new 26.7.1 pod migrated the shared Postgres under the two still-
serving 26.0.0 pods, and the old pods logged &lt;strong&gt;no&lt;/strong&gt; schema errors — the
26.0.0 → 26.7.1 changesets are additive. &lt;code&gt;migration_model&lt;/code&gt; reached 26.7.1 and
the rollout completed cleanly.&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;The cache cluster partitions during the rollout&lt;/strong&gt; (the finding). Infinispan
15.0.8 (26.0.0) and 16.0.12 (26.7.1) — JGroups 5.3.10 vs 5.5.5 — refuse to form
a mixed cluster. The TCP handshake between an old and a new pod fails with
&lt;code&gt;JGRP000006 ... cookie sent by ... does not match own cookie; terminating connection&lt;/code&gt;. Old pods stay in their 2-member cluster; each new pod starts a
1-member cluster; they only merge once the old pods are gone. &lt;strong&gt;Sessions are
therefore not shared between old and new pods during the transition&lt;/strong&gt; — an
in-flight user session lands on whichever pod owns it, and a request routed to
the other side of the partition finds nothing.&lt;/p&gt;
&lt;/li&gt;
&lt;/ol&gt;
&lt;h2 id=&quot;environment&quot;&gt;Environment&lt;/h2&gt;
&lt;table&gt;&lt;thead&gt;&lt;tr&gt;&lt;th&gt;Field&lt;/th&gt;&lt;th&gt;Value&lt;/th&gt;&lt;/tr&gt;&lt;/thead&gt;&lt;tbody&gt;
&lt;tr&gt;&lt;td&gt;Keycloak from → to&lt;/td&gt;&lt;td&gt;26.0.0 → 26.7.1&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Distribution / start mode&lt;/td&gt;&lt;td&gt;quay.io/keycloak/keycloak official image, &lt;code&gt;start&lt;/code&gt; w/ external DB&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Database&lt;/td&gt;&lt;td&gt;PostgreSQL 16, single pod (StatefulSet), in-cluster&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Cache&lt;/td&gt;&lt;td&gt;embedded Infinispan (&lt;code&gt;KC_CACHE=ispn&lt;/code&gt;), &lt;code&gt;KC_CACHE_STACK=kubernetes&lt;/code&gt; (DNS_PING)&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Cluster&lt;/td&gt;&lt;td&gt;k3d &lt;code&gt;kclab&lt;/code&gt; (k3s v1.35.5), 1 server node&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Topology&lt;/td&gt;&lt;td&gt;Deployment &lt;code&gt;replicas: 2&lt;/code&gt;, &lt;code&gt;strategy.maxSurge=1, maxUnavailable=0&lt;/code&gt;&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Host&lt;/td&gt;&lt;td&gt;Hetzner CCX33 (fsn1) — 8 vCPU dedicated, 32 GB RAM, 240 GB NVMe&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;JVM heap&lt;/td&gt;&lt;td&gt;&lt;code&gt;-Xms256m -Xmx1g&lt;/code&gt;&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Dataset&lt;/td&gt;&lt;td&gt;&lt;code&gt;master&lt;/code&gt; realm + &lt;code&gt;lab&lt;/code&gt; realm (1 user)&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Seeding&lt;/td&gt;&lt;td&gt;admin REST API (create realm + user)&lt;/td&gt;&lt;/tr&gt;
&lt;/tbody&gt;&lt;/table&gt;
&lt;h2 id=&quot;procedure&quot;&gt;Procedure&lt;/h2&gt;
&lt;pre&gt;&lt;code&gt;# on the box: k3d + kubectl (NOT installed on the laptop)
k3d cluster create kclab --k3s-arg --disable=traefik@server:* --k3s-arg --disable=servicelb@server:*
kubectl apply -f lab/k8s/                 # namespace, postgres StatefulSet, keycloak Deployment

# seed, then rolling update
kubectl -n keycloak set image deploy/keycloak keycloak=quay.io/keycloak/keycloak:26.7.1
kubectl -n keycloak rollout status deploy/keycloak --timeout=180s
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Manifests are kept with the lab&#39;s Kubernetes fixtures. The initial
26.0.0 run failed with &lt;code&gt;ISPN000540: No such JGroups stack &#39;jdbc-ping&#39;&lt;/code&gt; — the
stack 26.7.1 defaults to did not exist in 26.0.0, which is finding #1.&lt;/p&gt;
&lt;h2 id=&quot;results&quot;&gt;Results&lt;/h2&gt;
&lt;h3 id=&quot;timeline-of-the-rolling-update-from-pod-logs&quot;&gt;Timeline of the rolling update (from pod logs)&lt;/h3&gt;
&lt;table&gt;&lt;thead&gt;&lt;tr&gt;&lt;th&gt;t&lt;/th&gt;&lt;th&gt;event&lt;/th&gt;&lt;/tr&gt;&lt;/thead&gt;&lt;tbody&gt;
&lt;tr&gt;&lt;td&gt;19:31:34&lt;/td&gt;&lt;td&gt;first new 26.7.1 pod starts, forms a &lt;strong&gt;1-member&lt;/strong&gt; cluster (&lt;code&gt;v=16.0.12&lt;/code&gt;)&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;19:31:44&lt;/td&gt;&lt;td&gt;old 26.0.0 pods still in their &lt;strong&gt;2-member&lt;/strong&gt; cluster (&lt;code&gt;v=15.0.8&lt;/code&gt;)&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;19:32:13–14&lt;/td&gt;&lt;td&gt;repeated &lt;code&gt;JGRP000006 ... cookie ... does not match own cookie&lt;/code&gt; between old and new pods&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;19:32:15&lt;/td&gt;&lt;td&gt;second new pod starts, also a 1-member cluster&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;19:32:36&lt;/td&gt;&lt;td&gt;the two new pods form a &lt;strong&gt;2-member&lt;/strong&gt; cluster (&lt;code&gt;v=16.0.12&lt;/code&gt;) — old pods gone&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;end&lt;/td&gt;&lt;td&gt;&lt;code&gt;migration_model&lt;/code&gt; 26.7.1, both pods ready, cross-pod session sharing restored&lt;/td&gt;&lt;/tr&gt;
&lt;/tbody&gt;&lt;/table&gt;
&lt;h3 id=&quot;the-partition-in-the-log&quot;&gt;The partition, in the log&lt;/h3&gt;
&lt;pre&gt;&lt;code&gt;WARN [org.jgroups.protocols.TCP] JGRP000006: 10.42.0.13:7800: failed accepting
  connection from peer Socket[addr=/10.42.0.15 ...]:
  BaseServer.TcpConnection.readPeerAddress(): cookie sent by ... does not match
  own cookie; terminating connection
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The old cluster (JGroups 5.3.10) and the new pod (JGroups 5.5.5) never merge.
DNS_PING hands each new pod the old pods&#39; addresses, the handshake fails, and the
new pod continues as a cluster of one until the old pods are terminated.&lt;/p&gt;
&lt;h2 id=&quot;what-this-teaches&quot;&gt;What this teaches&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;A within-major Keycloak rolling update is safe at the database layer&lt;/strong&gt; —
additive changesets mean old pods keep serving while the new pod migrates. The
risk is not the schema, it is the &lt;strong&gt;cache&lt;/strong&gt;.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Infinispan major/minor bumps do not inter-cluster.&lt;/strong&gt; 26.0.0 (Infinispan 15,
JGroups 5.3) and 26.7.1 (Infinispan 16, JGroups 5.5) form separate clusters, so
a rolling update across them has a window where the session/authentication
caches are partitioned. For upgrades that cross an Infinispan/JGroups version,
the safe posture is a &lt;strong&gt;recreate rollout&lt;/strong&gt; (terminate old, then start new), or
accept a short cache-cold window.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;The cache stack itself is a migration surface.&lt;/strong&gt; A customer who pinned
&lt;code&gt;KC_CACHE_STACK&lt;/code&gt; (or relied on the 26.0.0 default) is changing discovery
mechanism when they reach 26.7.1 — &lt;code&gt;jdbc-ping&lt;/code&gt; did not exist in 26.0.0, and the
&lt;code&gt;kubernetes&lt;/code&gt; stack is deprecated in 26.7.1. This belongs in the upgrade
runbook&#39;s pre-flight, not the schema checklist.&lt;/li&gt;
&lt;/ul&gt;
&lt;h2 id=&quot;what-is-still-not-tested&quot;&gt;What is still not tested&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;Two new pods migrating &lt;strong&gt;concurrently&lt;/strong&gt; (S2&#39;s exact half, here maxSurge=1 made
the new pods sequential) — the DATABASECHANGELOGLOCK serialization was shown in
Compose (&lt;code&gt;s1-s2-lock-failure-modes.md&lt;/code&gt;) but not re-shown under k8s.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Functional proof of session stranding&lt;/strong&gt; during the partition (issuing a
session on an old pod and failing to use it on a new pod inside the transition
window) — the partition is proven at the JGroups level, not the HTTP level.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;External (remote) Infinispan&lt;/strong&gt;, and the 26.7.1 default &lt;code&gt;jdbc-ping&lt;/code&gt; stack.&lt;/li&gt;
&lt;/ul&gt;
&lt;h2 id=&quot;verification&quot;&gt;Verification&lt;/h2&gt;
&lt;table&gt;&lt;thead&gt;&lt;tr&gt;&lt;th&gt;Claim&lt;/th&gt;&lt;th&gt;Evidence&lt;/th&gt;&lt;/tr&gt;&lt;/thead&gt;&lt;tbody&gt;
&lt;tr&gt;&lt;td&gt;26.0.0 lacks &lt;code&gt;jdbc-ping&lt;/code&gt;&lt;/td&gt;&lt;td&gt;&lt;code&gt;ISPN000540: No such JGroups stack &#39;jdbc-ping&#39;&lt;/code&gt; ☑&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;2 pods form a cluster and share sessions (26.0.0)&lt;/td&gt;&lt;td&gt;&lt;code&gt;ISPN000094 ... (2) [...]&lt;/code&gt;, cross-pod refresh OK ☑&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Rolling update completes, no old-pod schema errors&lt;/td&gt;&lt;td&gt;&lt;code&gt;migration_model&lt;/code&gt; 26.7.1, no &lt;code&gt;PSQLException&lt;/code&gt; in old logs ☑&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Cache partitions during the rollout&lt;/td&gt;&lt;td&gt;&lt;code&gt;JGRP000006 cookie ... does not match&lt;/code&gt; ☑&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Cluster heals after rollout&lt;/td&gt;&lt;td&gt;final view &lt;code&gt;(2) [... v=16.0.12 ...]&lt;/code&gt;, cross-pod refresh OK ☑&lt;/td&gt;&lt;/tr&gt;
&lt;/tbody&gt;&lt;/table&gt;
</content>
        
    </entry>
    <entry xml:lang="en">
        <title>Rehearsal Record — MariaDB: toolchain port + S4 confirmed identical to MySQL</title>
        <published>2026-08-26T00:00:00+00:00</published>
        <updated>2026-08-26T00:00:00+00:00</updated>
        
        <author>
          <name>Unknown</name>
        </author>
        
        <link rel="alternate" type="text/html" href="https://ledger.mlabs.city/runs/2026-08-26-mariadb-s4-and-toolchain/"/>
        <id>https://ledger.mlabs.city/runs/2026-08-26-mariadb-s4-and-toolchain/</id>
        
        <content type="html" xml:base="https://ledger.mlabs.city/runs/2026-08-26-mariadb-s4-and-toolchain/">&lt;h2 id=&quot;summary&quot;&gt;Summary&lt;/h2&gt;
&lt;p&gt;MariaDB 11.4 (LTS) is the second non-Postgres engine. Three results:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;The toolchain needed one more port.&lt;/strong&gt; MariaDB renames the MySQL client tools —
&lt;code&gt;mariadb&lt;/code&gt; / &lt;code&gt;mariadb-dump&lt;/code&gt; instead of &lt;code&gt;mysql&lt;/code&gt; / &lt;code&gt;mysqldump&lt;/code&gt;, with no compat
symlinks in the image. &lt;code&gt;snapshot.sh&lt;/code&gt; / &lt;code&gt;restore.sh&lt;/code&gt; / &lt;code&gt;index-audit.sh&lt;/code&gt; now pick
the binary from the engine; the dump flag set also differs (MariaDB has no
&lt;code&gt;--set-gtid-purged&lt;/code&gt; / &lt;code&gt;--no-tablespaces&lt;/code&gt;).&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;The migration is clean.&lt;/strong&gt; 26.0.0 → 26.7.1 reaches ready, &lt;code&gt;migration_model&lt;/code&gt;
26.7.1, and the index audit (engine &lt;code&gt;mariadb&lt;/code&gt;) reports &lt;strong&gt;121 correct / 0
missing / 0 wrong shape&lt;/strong&gt;.&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;S4 carries over byte-for-byte from MySQL.&lt;/strong&gt; A DML-only account fails the
migration at Liquibase&#39;s own setup — &lt;code&gt;ALTER command denied to user &#39;kc_dml&#39; for table DATABASECHANGELOG [Failed SQL: (1142) ALTER TABLE keycloak.DATABASECHANGELOG ADD PRIMARY KEY (ID, AUTHOR, FILENAME)]&lt;/code&gt; — and
granting the DDL set (&lt;code&gt;CREATE, ALTER, DROP, INDEX, REFERENCES, CREATE VIEW, SHOW VIEW&lt;/code&gt;) makes it succeed. No ownership concept, same as MySQL.&lt;/p&gt;
&lt;/li&gt;
&lt;/ol&gt;
&lt;h2 id=&quot;environment&quot;&gt;Environment&lt;/h2&gt;
&lt;table&gt;&lt;thead&gt;&lt;tr&gt;&lt;th&gt;Field&lt;/th&gt;&lt;th&gt;Value&lt;/th&gt;&lt;/tr&gt;&lt;/thead&gt;&lt;tbody&gt;
&lt;tr&gt;&lt;td&gt;Keycloak from → to&lt;/td&gt;&lt;td&gt;26.0.0 → 26.7.1&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Database&lt;/td&gt;&lt;td&gt;MariaDB 11.4.13, single node, container &lt;code&gt;mariadb:11.4&lt;/code&gt;&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;DB flags&lt;/td&gt;&lt;td&gt;&lt;code&gt;utf8mb4&lt;/code&gt; / &lt;code&gt;utf8mb4_unicode_ci&lt;/code&gt; (no invisible-PK flag — that is MySQL-only)&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Cache&lt;/td&gt;&lt;td&gt;local (single node)&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Dataset scale&lt;/td&gt;&lt;td&gt;200 users, 2 realms&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Seeding method&lt;/td&gt;&lt;td&gt;&lt;code&gt;partialImport&lt;/code&gt; (&lt;code&gt;seed-realm.sh --profile typical&lt;/code&gt;)&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Host&lt;/td&gt;&lt;td&gt;laptop (Docker 29)&lt;/td&gt;&lt;/tr&gt;
&lt;/tbody&gt;&lt;/table&gt;
&lt;h2 id=&quot;results&quot;&gt;Results&lt;/h2&gt;
&lt;table&gt;&lt;thead&gt;&lt;tr&gt;&lt;th&gt;Run&lt;/th&gt;&lt;th&gt;Account&#39;s rights&lt;/th&gt;&lt;th&gt;Outcome&lt;/th&gt;&lt;/tr&gt;&lt;/thead&gt;&lt;tbody&gt;
&lt;tr&gt;&lt;td&gt;smoke&lt;/td&gt;&lt;td&gt;&lt;code&gt;keycloak&lt;/code&gt; (all on &lt;code&gt;keycloak.*&lt;/code&gt;)&lt;/td&gt;&lt;td&gt;READY, audit 121/121&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;A&lt;/td&gt;&lt;td&gt;&lt;code&gt;kc_dml&lt;/code&gt; DML only&lt;/td&gt;&lt;td&gt;&lt;strong&gt;exit 1&lt;/strong&gt; — &lt;code&gt;ALTER command denied ... DATABASECHANGELOG ADD PRIMARY KEY&lt;/code&gt; (error 1142)&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;B&lt;/td&gt;&lt;td&gt;&lt;code&gt;kc_dml&lt;/code&gt; DML + DDL&lt;/td&gt;&lt;td&gt;&lt;strong&gt;READY&lt;/strong&gt;, audit 121/121&lt;/td&gt;&lt;/tr&gt;
&lt;/tbody&gt;&lt;/table&gt;
&lt;h2 id=&quot;verification&quot;&gt;Verification&lt;/h2&gt;
&lt;table&gt;&lt;thead&gt;&lt;tr&gt;&lt;th&gt;Claim&lt;/th&gt;&lt;th&gt;Evidence&lt;/th&gt;&lt;/tr&gt;&lt;/thead&gt;&lt;tbody&gt;
&lt;tr&gt;&lt;td&gt;Migration clean on MariaDB&lt;/td&gt;&lt;td&gt;&lt;code&gt;migration_model&lt;/code&gt; 26.7.1, audit 121/121 ☑&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;DML-only fails, DDL suffices (no ownership)&lt;/td&gt;&lt;td&gt;run A 1142 vs run B READY ☑&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Snapshot/restore round-trips on MariaDB&lt;/td&gt;&lt;td&gt;restore &quot;no re-migration&quot;, users 200 ☑&lt;/td&gt;&lt;/tr&gt;
&lt;/tbody&gt;&lt;/table&gt;
</content>
        
    </entry>
    <entry xml:lang="en">
        <title>Rehearsal Record — Oracle: driver install + migration, the third engine</title>
        <published>2026-08-26T00:00:00+00:00</published>
        <updated>2026-08-26T00:00:00+00:00</updated>
        
        <author>
          <name>Unknown</name>
        </author>
        
        <link rel="alternate" type="text/html" href="https://ledger.mlabs.city/runs/2026-08-26-oracle-migration/"/>
        <id>https://ledger.mlabs.city/runs/2026-08-26-oracle-migration/</id>
        
        <content type="html" xml:base="https://ledger.mlabs.city/runs/2026-08-26-oracle-migration/">&lt;h2 id=&quot;summary&quot;&gt;Summary&lt;/h2&gt;
&lt;p&gt;Oracle is the third non-Postgres engine. Keycloak does &lt;strong&gt;not&lt;/strong&gt; ship the Oracle
JDBC driver (licence), so this run is also the driver-install deliverable. Two
results:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;Keycloak runs and migrates on Oracle.&lt;/strong&gt; With &lt;code&gt;ojdbc17&lt;/code&gt; + &lt;code&gt;orai18n&lt;/code&gt; (Maven
Central 23.26.0.0.0) dropped into the base file&#39;s providers bind-mount — no
custom image needed — Keycloak 26.0.0 starts against Oracle Database Free,
creates 87 tables, and the 26.0.0 → 26.7.1 migration reaches &lt;code&gt;migration_model&lt;/code&gt;
26.7.1 cleanly.&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;The Oracle Free image self-identifies as &quot;26ai&quot;.&lt;/strong&gt; &lt;code&gt;gvenzl/oracle-free:23-slim&lt;/code&gt;
reports &lt;code&gt;Oracle AI Database 26ai Free Release 23.26.2.0.0&lt;/code&gt; — Oracle renamed
23ai → 26ai in 2026. Keycloak&#39;s documented support is &quot;23.x (23.5+)&quot;, and this
release reports a 23.x version string, so it is within the supported line, but
the rename is worth flagging in the runbook&#39;s pre-flight (a customer on &quot;26ai&quot;
is on the 23.x line Keycloak supports).&lt;/p&gt;
&lt;/li&gt;
&lt;/ol&gt;
&lt;h2 id=&quot;environment&quot;&gt;Environment&lt;/h2&gt;
&lt;table&gt;&lt;thead&gt;&lt;tr&gt;&lt;th&gt;Field&lt;/th&gt;&lt;th&gt;Value&lt;/th&gt;&lt;/tr&gt;&lt;/thead&gt;&lt;tbody&gt;
&lt;tr&gt;&lt;td&gt;Keycloak from → to&lt;/td&gt;&lt;td&gt;26.0.0 → 26.7.1&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Database&lt;/td&gt;&lt;td&gt;Oracle Database Free (23ai/26ai), release 23.26.2.0.0, single node, container &lt;code&gt;gvenzl/oracle-free:23-slim&lt;/code&gt;&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;JDBC driver&lt;/td&gt;&lt;td&gt;&lt;code&gt;com.oracle.database.jdbc:ojdbc17:23.26.0.0.0&lt;/code&gt; + &lt;code&gt;com.oracle.database.nls:orai18n:23.26.0.0.0&lt;/code&gt; in &lt;code&gt;fixtures/providers/&lt;/code&gt;&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Schema/user&lt;/td&gt;&lt;td&gt;&lt;code&gt;keycloak&lt;/code&gt; (APP_USER; in Oracle the user IS the schema, so &lt;code&gt;KC_DB_SCHEMA&lt;/code&gt; is not used)&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Topology&lt;/td&gt;&lt;td&gt;single container&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Host&lt;/td&gt;&lt;td&gt;laptop (Docker 29, 31 GB RAM)&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;JVM heap&lt;/td&gt;&lt;td&gt;&lt;code&gt;-Xms1g -Xmx4g&lt;/code&gt;&lt;/td&gt;&lt;/tr&gt;
&lt;/tbody&gt;&lt;/table&gt;
&lt;h2 id=&quot;procedure&quot;&gt;Procedure&lt;/h2&gt;
&lt;pre&gt;&lt;code&gt;# drivers (Keycloak server/db docs &amp;quot;Installing the Oracle Database driver&amp;quot;)
curl -o fixtures/providers/ojdbc17.jar https://repo1.maven.org/maven2/com/oracle/database/jdbc/ojdbc17/23.26.0.0.0/ojdbc17-23.26.0.0.0.jar
curl -o fixtures/providers/orai18n.jar https://repo1.maven.org/maven2/com/oracle/database/nls/orai18n/23.26.0.0.0/orai18n-23.26.0.0.0.jar

# .env: KC_DB=oracle, COMPOSE_FILE=docker-compose.yml:docker-compose.oracle.yml
docker compose up -d db        # Oracle Free first init ~11 min
docker compose up -d keycloak  # 26.0.0
docker compose stop keycloak &amp;amp;&amp;amp; sed -i &amp;#39;s/^KC_VERSION=.*/KC_VERSION=26.7.1/&amp;#39; .env
docker compose up -d keycloak  # migrate
&lt;/code&gt;&lt;/pre&gt;
&lt;h2 id=&quot;results&quot;&gt;Results&lt;/h2&gt;
&lt;table&gt;&lt;thead&gt;&lt;tr&gt;&lt;th&gt;Check&lt;/th&gt;&lt;th&gt;Value&lt;/th&gt;&lt;/tr&gt;&lt;/thead&gt;&lt;tbody&gt;
&lt;tr&gt;&lt;td&gt;Keycloak 26.0.0 starts on Oracle&lt;/td&gt;&lt;td&gt;ready; 87 tables created&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;&lt;code&gt;migration_model&lt;/code&gt; before → after&lt;/td&gt;&lt;td&gt;26.0.0 → &lt;strong&gt;26.7.1&lt;/strong&gt;&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Master-realm token endpoint after migration&lt;/td&gt;&lt;td&gt;ready&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Oracle banner&lt;/td&gt;&lt;td&gt;&lt;code&gt;Oracle AI Database 26ai Free Release 23.26.2.0.0&lt;/code&gt;&lt;/td&gt;&lt;/tr&gt;
&lt;/tbody&gt;&lt;/table&gt;
&lt;h2 id=&quot;verification&quot;&gt;Verification&lt;/h2&gt;
&lt;table&gt;&lt;thead&gt;&lt;tr&gt;&lt;th&gt;Claim&lt;/th&gt;&lt;th&gt;Evidence&lt;/th&gt;&lt;/tr&gt;&lt;/thead&gt;&lt;tbody&gt;
&lt;tr&gt;&lt;td&gt;Driver loads from providers, no custom image&lt;/td&gt;&lt;td&gt;Keycloak ready on Oracle ☑&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Migration clean on Oracle&lt;/td&gt;&lt;td&gt;&lt;code&gt;migration_model&lt;/code&gt; 26.7.1 ☑&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Schema created&lt;/td&gt;&lt;td&gt;87 tables, 283 user indexes ☑&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Oracle Free is the 23.x line under a new name&lt;/td&gt;&lt;td&gt;banner &quot;26ai Free Release 23.26.2.0.0&quot; ☑&lt;/td&gt;&lt;/tr&gt;
&lt;/tbody&gt;&lt;/table&gt;
</content>
        
    </entry>
    <entry xml:lang="en">
        <title>Rehearsal Record — a rich 21-era realm survives the six-hop ladder to 26.7.1 at 100k</title>
        <published>2026-08-26T00:00:00+00:00</published>
        <updated>2026-08-26T00:00:00+00:00</updated>
        
        <author>
          <name>Unknown</name>
        </author>
        
        <link rel="alternate" type="text/html" href="https://ledger.mlabs.city/runs/2026-08-26-rich-21-realm-ladder-100k/"/>
        <id>https://ledger.mlabs.city/runs/2026-08-26-rich-21-realm-ladder-100k/</id>
        
        <content type="html" xml:base="https://ledger.mlabs.city/runs/2026-08-26-rich-21-realm-ladder-100k/">&lt;h2 id=&quot;why-this-run-existed&quot;&gt;Why this run existed&lt;/h2&gt;
&lt;p&gt;Run E proved the &lt;em&gt;schema&lt;/em&gt; migrates across five majors but ran at small scale
with a partially-applied seed, so it explicitly could not say whether &lt;em&gt;features&lt;/em&gt;
survive. This run fixes both gaps: a genuinely rich 21-era realm, at 100k users,
walked the full six-hop ladder, with a feature inventory captured before and
after.&lt;/p&gt;
&lt;h2 id=&quot;environment&quot;&gt;Environment&lt;/h2&gt;
&lt;table&gt;&lt;thead&gt;&lt;tr&gt;&lt;th&gt;Field&lt;/th&gt;&lt;th&gt;Value&lt;/th&gt;&lt;/tr&gt;&lt;/thead&gt;&lt;tbody&gt;
&lt;tr&gt;&lt;td&gt;Keycloak from → to&lt;/td&gt;&lt;td&gt;21.1.2 → 26.7.1, stepped 22.0.5 → 23.0.7 → 24.0.5 → 25.0.6&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Distribution&lt;/td&gt;&lt;td&gt;quay.io/keycloak/keycloak (official image), &lt;code&gt;start&lt;/code&gt; w/ external DB&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Database&lt;/td&gt;&lt;td&gt;PostgreSQL 16, single node, container, stock tuning&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Dataset scale&lt;/td&gt;&lt;td&gt;&lt;strong&gt;100,006 users&lt;/strong&gt; (100,000 &lt;code&gt;seed-sql&lt;/code&gt; + 5 &lt;code&gt;partialImport&lt;/code&gt; + 1 template), 1 realm, 10 clients, 11 client scopes, 8 realm roles, 1 group, 10 auth flows, 9 required actions, 2 IDPs, 6 key providers&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Seeding method&lt;/td&gt;&lt;td&gt;&lt;code&gt;seed-realm.sh&lt;/code&gt; (features) + &lt;code&gt;seed-sql.sh&lt;/code&gt; (100k users, direct SQL) — record &lt;em&gt;both&lt;/em&gt;&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Fixture profile&lt;/td&gt;&lt;td&gt;rich 21-era: clients, client-scopes, groups-roles, auth-flows, required-actions, events, themes, localization, lifespans, password-policy, key-providers, idp&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Topology&lt;/td&gt;&lt;td&gt;single container&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Host&lt;/td&gt;&lt;td&gt;Hetzner CCX33 (fsn1) — 8 vCPU, 30 GB RAM&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;JVM heap&lt;/td&gt;&lt;td&gt;&lt;code&gt;-Xms1g -Xmx4g&lt;/code&gt;&lt;/td&gt;&lt;/tr&gt;
&lt;/tbody&gt;&lt;/table&gt;
&lt;h2 id=&quot;result-the-ladder&quot;&gt;Result — the ladder&lt;/h2&gt;
&lt;table&gt;&lt;thead&gt;&lt;tr&gt;&lt;th&gt;Hop&lt;/th&gt;&lt;th&gt;Elapsed&lt;/th&gt;&lt;th&gt;Outcome&lt;/th&gt;&lt;th&gt;Index audit&lt;/th&gt;&lt;/tr&gt;&lt;/thead&gt;&lt;tbody&gt;
&lt;tr&gt;&lt;td&gt;21.1.2 → 22.0.5&lt;/td&gt;&lt;td&gt;21s&lt;/td&gt;&lt;td&gt;READY&lt;/td&gt;&lt;td&gt;82/82&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;22.0.5 → 23.0.7&lt;/td&gt;&lt;td&gt;20s&lt;/td&gt;&lt;td&gt;READY&lt;/td&gt;&lt;td&gt;82/82&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;23.0.7 → 24.0.5&lt;/td&gt;&lt;td&gt;21s&lt;/td&gt;&lt;td&gt;READY&lt;/td&gt;&lt;td&gt;86/86&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;24.0.5 → 25.0.6&lt;/td&gt;&lt;td&gt;15s&lt;/td&gt;&lt;td&gt;READY&lt;/td&gt;&lt;td&gt;86/86&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;25.0.6 → 26.7.1&lt;/td&gt;&lt;td&gt;17s&lt;/td&gt;&lt;td&gt;READY&lt;/td&gt;&lt;td&gt;118/118&lt;/td&gt;&lt;/tr&gt;
&lt;/tbody&gt;&lt;/table&gt;
&lt;p&gt;&lt;strong&gt;~94s wall-clock for the whole ladder, every hop clean.&lt;/strong&gt; (Run E&#39;s six-hop
ladder at small scale was 106s; the 100k run is no slower, consistent with the
index-skip threshold keeping the migration flat.)&lt;/p&gt;
&lt;h2 id=&quot;result-feature-survival&quot;&gt;Result — feature survival&lt;/h2&gt;
&lt;p&gt;A diffable inventory (&lt;code&gt;bin/feature-inventory.sh&lt;/code&gt;) captured at 21.1.2 and 26.7.1.
&lt;strong&gt;Every custom artifact survived unchanged:&lt;/strong&gt;&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;custom themes &lt;code&gt;lab-login&lt;/code&gt; / &lt;code&gt;lab-account&lt;/code&gt; / &lt;code&gt;lab-email&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;10 clients incl. &lt;code&gt;app-confidential&lt;/code&gt;, &lt;code&gt;spa-public&lt;/code&gt;, &lt;code&gt;saml-app&lt;/code&gt;, &lt;code&gt;api-bearer-only&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;custom client scope &lt;code&gt;lab-profile&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;group &lt;code&gt;corp&lt;/code&gt;, roles &lt;code&gt;lab-admin&lt;/code&gt; / &lt;code&gt;lab-auditor&lt;/code&gt; / &lt;code&gt;lab-composite&lt;/code&gt; / &lt;code&gt;lab-readonly&lt;/code&gt; / &lt;code&gt;lab-user&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;custom auth flows &lt;code&gt;lab-browser&lt;/code&gt; and &lt;code&gt;lab-registration&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;all configured required actions (CONFIGURE_TOTP, webauthn-register, …)&lt;/li&gt;
&lt;li&gt;identity providers &lt;code&gt;corp-oidc&lt;/code&gt; (OIDC) and &lt;code&gt;corp-saml&lt;/code&gt; (SAML)&lt;/li&gt;
&lt;li&gt;key providers &lt;code&gt;lab-rsa-secondary&lt;/code&gt;, &lt;code&gt;lab-hmac&lt;/code&gt;, plus the generated set&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;users: 100,006 before and after&lt;/strong&gt; — nothing lost&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;credentials: both seeding paths log in post-ladder (HTTP 200)&lt;/strong&gt; — &lt;code&gt;sqluser1&lt;/code&gt;
(seed-sql shared hash) and &lt;code&gt;labuser1&lt;/code&gt; (partialImport)&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;The only differences are Keycloak&#39;s &lt;em&gt;own&lt;/em&gt; built-ins changing across the majors,
none of which touched the customer&#39;s artifacts:&lt;/p&gt;
&lt;table&gt;&lt;thead&gt;&lt;tr&gt;&lt;th&gt;Change&lt;/th&gt;&lt;th&gt;Direction&lt;/th&gt;&lt;th&gt;Notes&lt;/th&gt;&lt;/tr&gt;&lt;/thead&gt;&lt;tbody&gt;
&lt;tr&gt;&lt;td&gt;client scopes &lt;code&gt;AuthnContextClassRef&lt;/code&gt;, &lt;code&gt;basic&lt;/code&gt;&lt;/td&gt;&lt;td&gt;added&lt;/td&gt;&lt;td&gt;new built-ins&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;role/client-scope &lt;code&gt;service_account&lt;/code&gt;&lt;/td&gt;&lt;td&gt;added&lt;/td&gt;&lt;td&gt;new built-in&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;required actions &lt;code&gt;delete_credential&lt;/code&gt;, &lt;code&gt;idp_link&lt;/code&gt;&lt;/td&gt;&lt;td&gt;added&lt;/td&gt;&lt;td&gt;new built-ins&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;component &lt;code&gt;declarative-user-profile&lt;/code&gt;&lt;/td&gt;&lt;td&gt;added&lt;/td&gt;&lt;td&gt;new in 24+&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;built-in auth flow &lt;strong&gt;&lt;code&gt;http challenge&lt;/code&gt;&lt;/strong&gt;&lt;/td&gt;&lt;td&gt;&lt;strong&gt;removed&lt;/strong&gt;&lt;/td&gt;&lt;td&gt;see below&lt;/td&gt;&lt;/tr&gt;
&lt;/tbody&gt;&lt;/table&gt;
&lt;h2 id=&quot;breakage-observed&quot;&gt;Breakage observed&lt;/h2&gt;
&lt;h3 id=&quot;the-built-in-http-challenge-flow-is-gone-by-26-7-1&quot;&gt;The built-in &lt;code&gt;http challenge&lt;/code&gt; flow is gone by 26.7.1&lt;/h3&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Symptom:&lt;/strong&gt; the &lt;code&gt;http challenge|true|basic-flow&lt;/code&gt; flow present on 21.1.2 is
absent on 26.7.1. Keycloak removed/absorbed it at some point in the ladder
(not narrowed to a hop here — &lt;strong&gt;narrowed later: 22.0.0&lt;/strong&gt;, see
&lt;a href=&quot;/findings/2026-08-26-http-challenge-flow-removal-narrowed/&quot;&gt;&lt;code&gt;2026-08-26-http-challenge-flow-removal-narrowed&lt;/code&gt;&lt;/a&gt;).&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Impact here:&lt;/strong&gt; none — the lab&#39;s custom flows do not reference it, and no
customer artifact depended on it.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Would this hit a customer?&lt;/strong&gt; &lt;strong&gt;Only if a custom flow references the built-in
&lt;code&gt;http challenge&lt;/code&gt; flow&lt;/strong&gt; (e.g. a hardened browser flow that chains it in for
SPNEGO or client-cert auth). That reference would go stale. Worth a targeted
pre-flight check for customers with custom flows. &lt;strong&gt;Resolved:&lt;/strong&gt; the 22.0.0
migrator leaves a referenced flow in place and logs an ERROR rather than
deleting it — the reference goes stale loudly, not silently.&lt;/li&gt;
&lt;/ul&gt;
&lt;h2 id=&quot;verification&quot;&gt;Verification&lt;/h2&gt;
&lt;table&gt;&lt;thead&gt;&lt;tr&gt;&lt;th&gt;Claim&lt;/th&gt;&lt;th&gt;Primary source&lt;/th&gt;&lt;th&gt;Checked&lt;/th&gt;&lt;/tr&gt;&lt;/thead&gt;&lt;tbody&gt;
&lt;tr&gt;&lt;td&gt;Six-hop ladder reaches READY at 100k&lt;/td&gt;&lt;td&gt;per-hop &lt;code&gt;upgrade.sh&lt;/code&gt; outcome, elapsed&lt;/td&gt;&lt;td&gt;☑&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Index audit clean at every hop&lt;/td&gt;&lt;td&gt;82/82 → 86/86 → 118/118&lt;/td&gt;&lt;td&gt;☑&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Every custom feature survives&lt;/td&gt;&lt;td&gt;&lt;code&gt;feature-inventory.sh&lt;/code&gt; diff, 21.1.2 vs 26.7.1&lt;/td&gt;&lt;td&gt;☑&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;User count unchanged (100,006)&lt;/td&gt;&lt;td&gt;&lt;code&gt;/users/count&lt;/code&gt; before and after&lt;/td&gt;&lt;td&gt;☑&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Credentials functional post-ladder&lt;/td&gt;&lt;td&gt;password-grant login HTTP 200 for both seed paths&lt;/td&gt;&lt;td&gt;☑&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Custom flows and IDPs still resolvable&lt;/td&gt;&lt;td&gt;API reads at 26.7.1&lt;/td&gt;&lt;td&gt;☑&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;The exact hop that removed &lt;code&gt;http challenge&lt;/code&gt;&lt;/td&gt;&lt;td&gt;&lt;strong&gt;narrowed&lt;/strong&gt; — 22.0.0 (the 21→22 hop). See &lt;a href=&quot;/findings/2026-08-26-http-challenge-flow-removal-narrowed/&quot;&gt;&lt;code&gt;2026-08-26-http-challenge-flow-removal-narrowed&lt;/code&gt;&lt;/a&gt;&lt;/td&gt;&lt;td&gt;☑&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;A custom flow that &lt;em&gt;references&lt;/em&gt; &lt;code&gt;http challenge&lt;/code&gt;&lt;/td&gt;&lt;td&gt;&lt;strong&gt;answered&lt;/strong&gt; — &lt;code&gt;MigrateTo22_0_0&lt;/code&gt; leaves it in place and logs an ERROR (&quot;is in use … cannot be removed&quot;); not silent data loss&lt;/td&gt;&lt;td&gt;☑&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Realm export/import equivalence (vs in-place migration)&lt;/td&gt;&lt;td&gt;&lt;strong&gt;not compared&lt;/strong&gt; — in-place ladder only&lt;/td&gt;&lt;td&gt;☐&lt;/td&gt;&lt;/tr&gt;
&lt;/tbody&gt;&lt;/table&gt;
&lt;h2 id=&quot;publishable-extract&quot;&gt;Publishable extract&lt;/h2&gt;
&lt;blockquote&gt;
&lt;p&gt;We built a Keycloak realm on 21.1.2 with the full surface a real install has —
custom themes, ten clients, custom client scopes, groups, composite roles, two
custom authentication flows, required actions, an OIDC and a SAML broker, and
six key providers — then seeded it to 100,000 users and walked it up the
six-hop ladder to 26.7.1, one major at a time. Every hop reached ready; the
whole ladder took about 94 seconds; the index audit was clean at every step;
and &lt;strong&gt;every custom artifact, all 100,006 users, and both credential paths
survived unchanged.&lt;/strong&gt; The only things that changed were Keycloak&#39;s own
built-ins — new default scopes and required actions, and one built-in flow
(&lt;code&gt;http challenge&lt;/code&gt;) that Keycloak removed. If a custom flow references that
built-in, check it; ours did not, and nothing else was lost.&lt;/p&gt;
&lt;/blockquote&gt;
</content>
        
    </entry>
    <entry xml:lang="en">
        <title>Rehearsal Record — Run B at 100k: optimized image, migration timing and heap floor at scale</title>
        <published>2026-08-26T00:00:00+00:00</published>
        <updated>2026-08-26T00:00:00+00:00</updated>
        
        <author>
          <name>Unknown</name>
        </author>
        
        <link rel="alternate" type="text/html" href="https://ledger.mlabs.city/runs/2026-08-26-runB-optimized-image-100k/"/>
        <id>https://ledger.mlabs.city/runs/2026-08-26-runB-optimized-image-100k/</id>
        
        <content type="html" xml:base="https://ledger.mlabs.city/runs/2026-08-26-runB-optimized-image-100k/">&lt;h2 id=&quot;why-this-run-existed&quot;&gt;Why this run existed&lt;/h2&gt;
&lt;p&gt;Previously untested: the stepped ladder and
Run B each had only one scale. our lab protocol asks every path for two scales. This
is the second scale for Run B, and it clears two ☐ rows in the original Run B
record — the heap floor at scale, and the migration timing at scale.&lt;/p&gt;
&lt;h2 id=&quot;environment&quot;&gt;Environment&lt;/h2&gt;
&lt;table&gt;&lt;thead&gt;&lt;tr&gt;&lt;th&gt;Field&lt;/th&gt;&lt;th&gt;Value&lt;/th&gt;&lt;/tr&gt;&lt;/thead&gt;&lt;tbody&gt;
&lt;tr&gt;&lt;td&gt;Keycloak from → to&lt;/td&gt;&lt;td&gt;26.0.0 (stock image) → 26.7.1 (&lt;strong&gt;pre-built image&lt;/strong&gt;)&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Image&lt;/td&gt;&lt;td&gt;&lt;code&gt;kclab/keycloak-optimized:26.7.1&lt;/code&gt; (same Dockerfile as Run B)&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Start mode&lt;/td&gt;&lt;td&gt;&lt;code&gt;start --optimized --http-enabled=true&lt;/code&gt;&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Database&lt;/td&gt;&lt;td&gt;PostgreSQL 16, single node, container&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Postgres tuning&lt;/td&gt;&lt;td&gt;stock&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Dataset scale&lt;/td&gt;&lt;td&gt;&lt;strong&gt;100,000 users&lt;/strong&gt;, &lt;code&gt;typical&lt;/code&gt;, &lt;code&gt;baseline-26.0.0-typical-100k&lt;/code&gt;&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Topology&lt;/td&gt;&lt;td&gt;single container&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Host&lt;/td&gt;&lt;td&gt;Hetzner CCX33 (fsn1) — 8 vCPU dedicated, 30 GB RAM, local NVMe&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;JVM heap&lt;/td&gt;&lt;td&gt;&lt;code&gt;-Xms1g -Xmx4g&lt;/code&gt; for the migration; variable in the heap leg&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Compose overrides&lt;/td&gt;&lt;td&gt;&lt;code&gt;docker-compose.yml:docker-compose.optimized.yml:docker-compose.optimized-noprov.yml&lt;/code&gt;&lt;/td&gt;&lt;/tr&gt;
&lt;/tbody&gt;&lt;/table&gt;
&lt;h2 id=&quot;result&quot;&gt;Result&lt;/h2&gt;
&lt;h3 id=&quot;migration-timing-at-100k&quot;&gt;Migration timing at 100k&lt;/h3&gt;
&lt;table&gt;&lt;thead&gt;&lt;tr&gt;&lt;th&gt;&lt;/th&gt;&lt;th&gt;1k (Run B)&lt;/th&gt;&lt;th&gt;&lt;strong&gt;100k (this run)&lt;/strong&gt;&lt;/th&gt;&lt;/tr&gt;&lt;/thead&gt;&lt;tbody&gt;
&lt;tr&gt;&lt;td&gt;&lt;code&gt;upgrade.sh&lt;/code&gt; elapsed&lt;/td&gt;&lt;td&gt;11s&lt;/td&gt;&lt;td&gt;&lt;strong&gt;11s&lt;/strong&gt;&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;&lt;code&gt;migration_model&lt;/code&gt;&lt;/td&gt;&lt;td&gt;26.7.1&lt;/td&gt;&lt;td&gt;26.7.1&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Index audit&lt;/td&gt;&lt;td&gt;—&lt;/td&gt;&lt;td&gt;&lt;strong&gt;118/118 correct, 0 missing, 0 wrong shape&lt;/strong&gt;&lt;/td&gt;&lt;/tr&gt;
&lt;/tbody&gt;&lt;/table&gt;
&lt;p&gt;The migration is flat between 1k and 100k — same 11s. This is the same
mechanism the stock-image runs found (&lt;code&gt;index-skip-threshold.md&lt;/code&gt;, Run A): the
expensive DDL is declined above 300,000 rows, so &quot;time to ready&quot; does not grow
with realm size on this pair. The optimized image saves the augmentation step
regardless of scale; it does not change the migration.&lt;/p&gt;
&lt;h3 id=&quot;heap-floor-at-100k&quot;&gt;Heap floor at 100k&lt;/h3&gt;
&lt;p&gt;Same bisect boundary values as B2, but on the 100k database:&lt;/p&gt;
&lt;table&gt;&lt;thead&gt;&lt;tr&gt;&lt;th&gt;&lt;code&gt;-Xmx&lt;/code&gt;&lt;/th&gt;&lt;th&gt;Result at 100k&lt;/th&gt;&lt;/tr&gt;&lt;/thead&gt;&lt;tbody&gt;
&lt;tr&gt;&lt;td&gt;96m&lt;/td&gt;&lt;td&gt;&lt;strong&gt;READY&lt;/strong&gt;&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;64m&lt;/td&gt;&lt;td&gt;&lt;strong&gt;FAILED&lt;/strong&gt; — &lt;code&gt;Terminating due to java.lang.OutOfMemoryError: Java heap space&lt;/code&gt;&lt;/td&gt;&lt;/tr&gt;
&lt;/tbody&gt;&lt;/table&gt;
&lt;p&gt;The floor is &lt;strong&gt;&lt;code&gt;(64m, 96m]&lt;/code&gt; at 100k, unchanged from 1k&lt;/strong&gt;. Combined with S5&#39;s
finding that the stock floor is identical at 1k and 2M, this closes the
&quot;scale-dependence&quot; question for the optimized floor too — the heap floor is a
function of the build/augmentation step, not of dataset size.&lt;/p&gt;
&lt;h3 id=&quot;reproduced-the-providers-bind-mount-trap-b3b-i&quot;&gt;Reproduced: the providers bind-mount trap (B3b-i)&lt;/h3&gt;
&lt;p&gt;The first attempt at this run failed with exit code 2 before any migration:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;A provider JAR was updated since the last build, please rebuild for this to be fully utilized.
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Cause: &lt;code&gt;bin/build-spi.sh 25.0.6&lt;/code&gt; (run earlier for the KC-SERVICES0047 probe) had
left &lt;code&gt;lab-event-listener-built-for-25.0.6.jar&lt;/code&gt; in &lt;code&gt;fixtures/providers/&lt;/code&gt;, and the
base compose bind-mounts that directory over the optimized image&#39;s own empty
&lt;code&gt;/opt/keycloak/providers&lt;/code&gt;. The runtime jar did not match the image&#39;s build.
Removing the mount (&lt;code&gt;docker-compose.optimized-noprov.yml&lt;/code&gt;) makes the same image
start. This is a faithful re-occurrence of B3b-i, not a new finding — worth
noting because it means &lt;strong&gt;any later run that builds a SPI must clear
&lt;code&gt;fixtures/providers/&lt;/code&gt; before an optimized-image run&lt;/strong&gt;, or add the noprov
override.&lt;/p&gt;
&lt;h3 id=&quot;tooling-observation-minor&quot;&gt;Tooling observation (minor)&lt;/h3&gt;
&lt;p&gt;&lt;code&gt;upgrade.sh&lt;/code&gt; reported &lt;code&gt;from: 26.7.1&lt;/code&gt; on the successful run, because the failed
first attempt had already rewritten &lt;code&gt;.env&lt;/code&gt;&#39;s &lt;code&gt;KC_VERSION&lt;/code&gt; to 26.7.1. The actual
migration was 26.0.0 → 26.7.1 (the migration log shows the full 26.1.0 → 26.7.0
realm-migration ladder). &lt;code&gt;upgrade.sh&lt;/code&gt;&#39;s &lt;code&gt;from&lt;/code&gt; is read from &lt;code&gt;.env&lt;/code&gt;, not from the
database&#39;s &lt;code&gt;migration_model&lt;/code&gt; — a stale &lt;code&gt;.env&lt;/code&gt; after a failed attempt makes the
label lie. Not load-bearing for this run; noted for the tooling.&lt;/p&gt;
&lt;h2 id=&quot;verification&quot;&gt;Verification&lt;/h2&gt;
&lt;table&gt;&lt;thead&gt;&lt;tr&gt;&lt;th&gt;Claim&lt;/th&gt;&lt;th&gt;Primary source&lt;/th&gt;&lt;th&gt;Checked&lt;/th&gt;&lt;/tr&gt;&lt;/thead&gt;&lt;tbody&gt;
&lt;tr&gt;&lt;td&gt;Migration at 100k takes the same 11s as at 1k&lt;/td&gt;&lt;td&gt;&lt;code&gt;upgrade.sh&lt;/code&gt; elapsed, both runs&lt;/td&gt;&lt;td&gt;☑&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Index audit clean at 100k&lt;/td&gt;&lt;td&gt;&lt;code&gt;index-audit.sh&lt;/code&gt; output: 118/118&lt;/td&gt;&lt;td&gt;☑&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Heap floor &lt;code&gt;(64m, 96m]&lt;/code&gt; holds at 100k&lt;/td&gt;&lt;td&gt;96m READY, 64m OOM at 100k&lt;/td&gt;&lt;td&gt;☑&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;The failure mode is the providers shadowing, not the migration&lt;/td&gt;&lt;td&gt;exit 2 before Liquibase; noprov fixes it&lt;/td&gt;&lt;td&gt;☑&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Heap floor at &lt;strong&gt;2M&lt;/strong&gt; under &lt;code&gt;--optimized&lt;/code&gt;&lt;/td&gt;&lt;td&gt;&lt;strong&gt;not tested&lt;/strong&gt; — 100k only; S5&#39;s stock 2M point + this 100k point imply it, but it is not measured&lt;/td&gt;&lt;td&gt;☐&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Migration timing at &lt;strong&gt;2M&lt;/strong&gt; under &lt;code&gt;--optimized&lt;/code&gt;&lt;/td&gt;&lt;td&gt;&lt;strong&gt;not tested&lt;/strong&gt; — 100k only&lt;/td&gt;&lt;td&gt;☐&lt;/td&gt;&lt;/tr&gt;
&lt;/tbody&gt;&lt;/table&gt;
</content>
        
    </entry>
    <entry xml:lang="en">
        <title>Rehearsal Record — Run B: the pre-built image halves the heap floor and turns S7&#39;s silent failure loud</title>
        <published>2026-08-26T00:00:00+00:00</published>
        <updated>2026-08-26T00:00:00+00:00</updated>
        
        <author>
          <name>Unknown</name>
        </author>
        
        <link rel="alternate" type="text/html" href="https://ledger.mlabs.city/runs/2026-08-26-runB-optimized-image/"/>
        <id>https://ledger.mlabs.city/runs/2026-08-26-runB-optimized-image/</id>
        
        <content type="html" xml:base="https://ledger.mlabs.city/runs/2026-08-26-runB-optimized-image/">&lt;h2 id=&quot;why-this-run-existed&quot;&gt;Why this run existed&lt;/h2&gt;
&lt;p&gt;&lt;code&gt;docker-compose.yml&lt;/code&gt; says, in a comment, why the lab does not use &lt;code&gt;--optimized&lt;/code&gt;:
&lt;code&gt;start --optimized&lt;/code&gt; is valid, but when Keycloak detects a config change it re-runs
the build step with the same argv and &lt;code&gt;build&lt;/code&gt; rejects &lt;code&gt;--optimized&lt;/code&gt;. The supported
route is to run &lt;code&gt;kc.sh build&lt;/code&gt; at &lt;strong&gt;image&lt;/strong&gt; build time. Nobody had done it, which
left the lab&#39;s single biggest divergence from a production deployment untested —
and left S5&#39;s published heap floor carrying a caveat.&lt;/p&gt;
&lt;h2 id=&quot;environment&quot;&gt;Environment&lt;/h2&gt;
&lt;table&gt;&lt;thead&gt;&lt;tr&gt;&lt;th&gt;Field&lt;/th&gt;&lt;th&gt;Value&lt;/th&gt;&lt;/tr&gt;&lt;/thead&gt;&lt;tbody&gt;
&lt;tr&gt;&lt;td&gt;Keycloak from → to&lt;/td&gt;&lt;td&gt;26.0.0 (stock image) → 26.7.1 (&lt;strong&gt;pre-built image&lt;/strong&gt;)&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Image&lt;/td&gt;&lt;td&gt;&lt;code&gt;kclab/keycloak-optimized:26.7.1&lt;/code&gt;, built by &lt;code&gt;fixtures/optimized/Dockerfile&lt;/code&gt; — &lt;code&gt;kc.sh build&lt;/code&gt; with &lt;code&gt;KC_DB=postgres&lt;/code&gt;, health and metrics on; 772 MB against the stock 766 MB&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Start mode&lt;/td&gt;&lt;td&gt;&lt;code&gt;start --optimized --http-enabled=true&lt;/code&gt;&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Database&lt;/td&gt;&lt;td&gt;PostgreSQL 16, single node, container&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Postgres tuning&lt;/td&gt;&lt;td&gt;stock&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Dataset scale&lt;/td&gt;&lt;td&gt;1,000 users, &lt;code&gt;typical&lt;/code&gt;, &lt;code&gt;baseline-26.0.0-typical-1k&lt;/code&gt;&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Topology&lt;/td&gt;&lt;td&gt;single container&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Host&lt;/td&gt;&lt;td&gt;Hetzner CCX33 (fsn1) — 8 vCPU dedicated, 30 GB RAM, local NVMe&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;JVM heap&lt;/td&gt;&lt;td&gt;&lt;code&gt;-Xms1g -Xmx4g&lt;/code&gt; except in B2, where it is the variable&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Lab change&lt;/td&gt;&lt;td&gt;&lt;code&gt;fixtures/optimized/Dockerfile&lt;/code&gt;, &lt;code&gt;bin/build-optimized.sh&lt;/code&gt;, &lt;code&gt;docker-compose.optimized.yml&lt;/code&gt;, &lt;code&gt;docker-compose.optimized-noprov.yml&lt;/code&gt;&lt;/td&gt;&lt;/tr&gt;
&lt;/tbody&gt;&lt;/table&gt;
&lt;h2 id=&quot;outcome&quot;&gt;Outcome&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;&lt;input disabled=&quot;&quot; type=&quot;checkbox&quot; checked=&quot;&quot;/&gt;
Migration completes under &lt;code&gt;--optimized&lt;/code&gt;: 211 rows, model 26.7.1&lt;/li&gt;
&lt;li&gt;&lt;input disabled=&quot;&quot; type=&quot;checkbox&quot; checked=&quot;&quot;/&gt;
Time to ready 17s → 11s at 1k users&lt;/li&gt;
&lt;li&gt;&lt;input disabled=&quot;&quot; type=&quot;checkbox&quot; checked=&quot;&quot;/&gt;
Heap floor re-bisected: &lt;code&gt;(64m, 96m]&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;input disabled=&quot;&quot; type=&quot;checkbox&quot; checked=&quot;&quot;/&gt;
Provider handling characterised in four configurations&lt;/li&gt;
&lt;/ul&gt;
&lt;h2 id=&quot;verification&quot;&gt;Verification&lt;/h2&gt;
&lt;table&gt;&lt;thead&gt;&lt;tr&gt;&lt;th&gt;Claim&lt;/th&gt;&lt;th&gt;Primary source&lt;/th&gt;&lt;th&gt;Checked&lt;/th&gt;&lt;/tr&gt;&lt;/thead&gt;&lt;tbody&gt;
&lt;tr&gt;&lt;td&gt;The optimized image runs the migration normally&lt;/td&gt;&lt;td&gt;211 changelog rows, &lt;code&gt;migration_model&lt;/code&gt; 26.7.1&lt;/td&gt;&lt;td&gt;☑&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Upgrade elapsed 17s → 11s&lt;/td&gt;&lt;td&gt;&lt;code&gt;upgrade.sh&lt;/code&gt; output, same baseline both runs&lt;/td&gt;&lt;td&gt;☑&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;The saving is the build step, not the migration&lt;/td&gt;&lt;td&gt;Quarkus &lt;code&gt;started in&lt;/code&gt; and Bootstrap identical to within 0.03s&lt;/td&gt;&lt;td&gt;☑&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Heap floor is &lt;code&gt;(64m, 96m]&lt;/code&gt; on the optimized image&lt;/td&gt;&lt;td&gt;96m READY, 64m FAILED with an OOM line&lt;/td&gt;&lt;td&gt;☑&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;S5&#39;s floor of &lt;code&gt;(128m, 160m]&lt;/code&gt; was the build step&lt;/td&gt;&lt;td&gt;128m FAILED stock, READY optimized&lt;/td&gt;&lt;td&gt;☑&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;A runtime-only provider jar prevents startup&lt;/td&gt;&lt;td&gt;B3a, message quoted&lt;/td&gt;&lt;td&gt;☑&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;A providers bind mount shadows the image&#39;s own&lt;/td&gt;&lt;td&gt;B3b-i vs B3b-ii; &lt;code&gt;ls&lt;/code&gt; inside the container empty vs populated&lt;/td&gt;&lt;td&gt;☑&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;The provider registers when built in and unmounted&lt;/td&gt;&lt;td&gt;&lt;code&gt;/admin/serverinfo&lt;/code&gt; lists &lt;code&gt;lab-event-listener&lt;/code&gt;&lt;/td&gt;&lt;td&gt;☑&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Heap floor at 2M users under &lt;code&gt;--optimized&lt;/code&gt;&lt;/td&gt;&lt;td&gt;&lt;strong&gt;not tested&lt;/strong&gt; — B2 ran at 1k. S5 showed the stock floor is scale-independent; that is an assumption here, not a measurement&lt;/td&gt;&lt;td&gt;☐&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Whether a &lt;em&gt;broken&lt;/em&gt; provider built into an optimized image still fails lazily&lt;/td&gt;&lt;td&gt;&lt;strong&gt;not tested&lt;/strong&gt; — only the working jar was built in&lt;/td&gt;&lt;td&gt;☐&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;&lt;code&gt;--optimized&lt;/code&gt; across a version boundary (build at 26.0.0, start at 26.7.1)&lt;/td&gt;&lt;td&gt;&lt;strong&gt;not tested&lt;/strong&gt; — the optimized image existed only for 26.7.1, so the 26.0.0 side of B1 ran stock&lt;/td&gt;&lt;td&gt;☐&lt;/td&gt;&lt;/tr&gt;
&lt;/tbody&gt;&lt;/table&gt;
</content>
        
    </entry>
    <entry xml:lang="en">
        <title>Rehearsal Record — Run E: 21.1.2 → 26.7.1 goes direct, and the ladder is one changeset longer</title>
        <published>2026-08-26T00:00:00+00:00</published>
        <updated>2026-08-26T00:00:00+00:00</updated>
        
        <author>
          <name>Unknown</name>
        </author>
        
        <link rel="alternate" type="text/html" href="https://ledger.mlabs.city/runs/2026-08-26-runE-stepping-ladder-21-to-26/"/>
        <id>https://ledger.mlabs.city/runs/2026-08-26-runE-stepping-ladder-21-to-26/</id>
        
        <content type="html" xml:base="https://ledger.mlabs.city/runs/2026-08-26-runE-stepping-ladder-21-to-26/">&lt;h2 id=&quot;summary&quot;&gt;Summary&lt;/h2&gt;
&lt;p&gt;&lt;strong&gt;The five-major jump goes direct.&lt;/strong&gt; 21.1.2 → 26.7.1 in a single hop reaches
ready in 18 seconds, stamps &lt;code&gt;migration_model&lt;/code&gt; 26.7.1, and issues tokens. No
stepping required.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;The six-hop ladder also works&lt;/strong&gt;, every hop clean, and ends one changeset
further along: &lt;strong&gt;211 rows against the direct jump&#39;s 210&lt;/strong&gt;. The extra one is
&lt;code&gt;25.0.0-28265-index-cleanup&lt;/code&gt;, and the schema it leaves is equivalent.&lt;/p&gt;
&lt;h2 id=&quot;environment&quot;&gt;Environment&lt;/h2&gt;
&lt;table&gt;&lt;thead&gt;&lt;tr&gt;&lt;th&gt;Field&lt;/th&gt;&lt;th&gt;Value&lt;/th&gt;&lt;/tr&gt;&lt;/thead&gt;&lt;tbody&gt;
&lt;tr&gt;&lt;td&gt;Distribution / start mode&lt;/td&gt;&lt;td&gt;quay.io/keycloak/keycloak official images, &lt;code&gt;start&lt;/code&gt; w/ external DB&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Database&lt;/td&gt;&lt;td&gt;PostgreSQL 16, single node, container, purged between the two runs&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Postgres tuning&lt;/td&gt;&lt;td&gt;stock&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Dataset scale&lt;/td&gt;&lt;td&gt;small — realm &lt;code&gt;lab&lt;/code&gt; seeded on 21.1.2 via &lt;code&gt;seed-realm.sh --profile typical&lt;/code&gt;&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Topology&lt;/td&gt;&lt;td&gt;single container&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Host&lt;/td&gt;&lt;td&gt;Hetzner CCX33 (fsn1) — 8 vCPU dedicated, 30 GB RAM, local NVMe&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;JVM heap&lt;/td&gt;&lt;td&gt;&lt;code&gt;-Xms1g -Xmx4g&lt;/code&gt;&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Source state&lt;/td&gt;&lt;td&gt;21.1.2, &lt;code&gt;DATABASECHANGELOG&lt;/code&gt; 113 rows&lt;/td&gt;&lt;/tr&gt;
&lt;/tbody&gt;&lt;/table&gt;
&lt;p&gt;&lt;strong&gt;Fixture caveat, stated because it bounds the result.&lt;/strong&gt; &lt;code&gt;seed-realm.sh&lt;/code&gt; targets
current admin APIs and only partially applied against 21.1.2 — the realm ended
with one user rather than the profile&#39;s full set. The migration path is
exercised; a rich 21.1.2 realm is not. Treat this as &quot;the schema migrates&quot; and
not &quot;every 21.1.2 feature survives&quot;.&lt;/p&gt;
&lt;h2 id=&quot;outcome&quot;&gt;Outcome&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;&lt;input disabled=&quot;&quot; type=&quot;checkbox&quot; checked=&quot;&quot;/&gt;
Direct 21.1.2 → 26.7.1 reaches ready, model 26.7.1, tokens issue&lt;/li&gt;
&lt;li&gt;&lt;input disabled=&quot;&quot; type=&quot;checkbox&quot; checked=&quot;&quot;/&gt;
All six ladder hops reach ready with no failures&lt;/li&gt;
&lt;li&gt;&lt;input disabled=&quot;&quot; type=&quot;checkbox&quot; checked=&quot;&quot;/&gt;
Index audit clean at every hop and on the direct result&lt;/li&gt;
&lt;li&gt;&lt;input disabled=&quot;&quot; type=&quot;checkbox&quot; checked=&quot;&quot;/&gt;
The only changelog difference identified by name&lt;/li&gt;
&lt;/ul&gt;
&lt;h2 id=&quot;verification&quot;&gt;Verification&lt;/h2&gt;
&lt;table&gt;&lt;thead&gt;&lt;tr&gt;&lt;th&gt;Claim&lt;/th&gt;&lt;th&gt;Primary source&lt;/th&gt;&lt;th&gt;Checked&lt;/th&gt;&lt;/tr&gt;&lt;/thead&gt;&lt;tbody&gt;
&lt;tr&gt;&lt;td&gt;21.1.2 → 26.7.1 works in one hop&lt;/td&gt;&lt;td&gt;E0 — READY, model 26.7.1, token 200&lt;/td&gt;&lt;td&gt;☑&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;The full ladder works, hop by hop&lt;/td&gt;&lt;td&gt;E1 — six hops, all READY, model advancing each time&lt;/td&gt;&lt;td&gt;☑&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Direct ends at 210 changesets, the ladder at 211&lt;/td&gt;&lt;td&gt;&lt;code&gt;count(*)&lt;/code&gt; from &lt;code&gt;DATABASECHANGELOG&lt;/code&gt; on both&lt;/td&gt;&lt;td&gt;☑&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;The difference is &lt;code&gt;25.0.0-28265-index-cleanup&lt;/code&gt;&lt;/td&gt;&lt;td&gt;&lt;code&gt;comm&lt;/code&gt; of the two ordered changeset-id lists&lt;/td&gt;&lt;td&gt;☑&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;The direct result passes a full index audit&lt;/td&gt;&lt;td&gt;&lt;code&gt;bin/index-audit.sh&lt;/code&gt; — 118/118 correct&lt;/td&gt;&lt;td&gt;☑&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;The two schemas are otherwise identical&lt;/td&gt;&lt;td&gt;&lt;strong&gt;not proven&lt;/strong&gt; — the direct DB&#39;s table/column/index counts were captured, the ladder DB was gone before its own could be. A column-by-column diff was never run&lt;/td&gt;&lt;td&gt;☐&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;A rich 21.1.2 realm survives&lt;/td&gt;&lt;td&gt;&lt;strong&gt;not tested&lt;/strong&gt; — see the fixture caveat&lt;/td&gt;&lt;td&gt;☐&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;A custom provider survives the span at runtime&lt;/td&gt;&lt;td&gt;&lt;strong&gt;not tested&lt;/strong&gt; here; S7 covered the endpoints only&lt;/td&gt;&lt;td&gt;☐&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;The same at 100k+ users&lt;/td&gt;&lt;td&gt;&lt;strong&gt;not tested&lt;/strong&gt; — README asks for two scales and this is one&lt;/td&gt;&lt;td&gt;☐&lt;/td&gt;&lt;/tr&gt;
&lt;/tbody&gt;&lt;/table&gt;
</content>
        
    </entry>
    <entry xml:lang="en">
        <title>Rehearsal Record — S14 revisited: at 2M the disk floor is 150 MB, not 8 MB, and scaling up no longer saves you</title>
        <published>2026-08-26T00:00:00+00:00</published>
        <updated>2026-08-26T00:00:00+00:00</updated>
        
        <author>
          <name>Unknown</name>
        </author>
        
        <link rel="alternate" type="text/html" href="https://ledger.mlabs.city/runs/2026-08-26-s14-revisited-disk-floor-at-2m/"/>
        <id>https://ledger.mlabs.city/runs/2026-08-26-s14-revisited-disk-floor-at-2m/</id>
        
        <content type="html" xml:base="https://ledger.mlabs.city/runs/2026-08-26-s14-revisited-disk-floor-at-2m/">&lt;h2 id=&quot;summary&quot;&gt;Summary&lt;/h2&gt;
&lt;p&gt;S14 found the migration dies at &lt;code&gt;CREATE INDEX IDX_USER_CREATED_TIMESTAMP&lt;/code&gt; when
disk runs out, needing &lt;strong&gt;between 4 and 8 MB&lt;/strong&gt; free on a 224 MB / 100,002-user
database — and concluded:&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;And the failure only happens on the &lt;em&gt;smaller&lt;/em&gt; realm.&lt;/strong&gt; … At 2M it is
declined, and needs none. &lt;strong&gt;Scaling the realm up removes this failure mode.&lt;/strong&gt;&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;That was true &lt;em&gt;because Keycloak declines the build above 300,000 rows&lt;/em&gt;. Run A
showed a stale &lt;code&gt;reltuples&lt;/code&gt; makes it build anyway, at any true size. So the
scenario is reachable at 2M, and there it is far worse:&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;At 2,000,003 users the floor is between 125 MB and 150 MB — roughly 20× the
100k figure.&lt;/strong&gt; Scaling up does not remove the failure mode. It removes the
&lt;em&gt;trigger&lt;/em&gt;, and multiplies the &lt;em&gt;damage&lt;/em&gt; if anything pulls the trigger.&lt;/p&gt;
&lt;h2 id=&quot;environment&quot;&gt;Environment&lt;/h2&gt;
&lt;table&gt;&lt;thead&gt;&lt;tr&gt;&lt;th&gt;Field&lt;/th&gt;&lt;th&gt;Value&lt;/th&gt;&lt;/tr&gt;&lt;/thead&gt;&lt;tbody&gt;
&lt;tr&gt;&lt;td&gt;Keycloak from → to&lt;/td&gt;&lt;td&gt;26.0.0 → 26.7.1&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Database&lt;/td&gt;&lt;td&gt;PostgreSQL 16, single node, container&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Postgres tuning&lt;/td&gt;&lt;td&gt;stock — &lt;code&gt;maintenance_work_mem&lt;/code&gt; &lt;strong&gt;64MB&lt;/strong&gt;, &lt;code&gt;max_parallel_maintenance_workers&lt;/code&gt; &lt;strong&gt;2&lt;/strong&gt;&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;&lt;strong&gt;Storage&lt;/strong&gt;&lt;/td&gt;&lt;td&gt;&lt;strong&gt;6 GB ext4 loopback at &lt;code&gt;/mnt/tinypg&lt;/code&gt;&lt;/strong&gt;, bind-mounted as &lt;code&gt;pgdata&lt;/code&gt; (&lt;code&gt;docker-compose.smalldisk.yml&lt;/code&gt;)&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Dataset scale&lt;/td&gt;&lt;td&gt;&lt;strong&gt;2,000,003 users&lt;/strong&gt;, database &lt;strong&gt;3,026 MB&lt;/strong&gt;&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Seeding method&lt;/td&gt;&lt;td&gt;&lt;code&gt;seed-sql&lt;/code&gt;, restored from &lt;code&gt;baseline-26.0.0-sqlseed-2m&lt;/code&gt;&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Adverse scenario&lt;/td&gt;&lt;td&gt;S14 — free space at the moment of migration is the variable&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Host&lt;/td&gt;&lt;td&gt;Hetzner CCX33 (fsn1) — 8 vCPU dedicated, 30 GB RAM, local NVMe&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;JVM heap&lt;/td&gt;&lt;td&gt;&lt;code&gt;-Xms1g -Xmx4g&lt;/code&gt;&lt;/td&gt;&lt;/tr&gt;
&lt;/tbody&gt;&lt;/table&gt;
&lt;p&gt;&lt;strong&gt;How the build was forced.&lt;/strong&gt; With honest statistics at 2M Keycloak declines the
index and never touches the disk, so &lt;code&gt;pg_class.reltuples&lt;/code&gt; was set to 1000 with
autovacuum disabled on &lt;code&gt;USER_ENTITY&lt;/code&gt; — the mechanism from
&lt;a href=&quot;/runs/2026-08-26-index-precondition-anatomy/&quot;&gt;&lt;code&gt;2026-08-26-index-precondition-anatomy&lt;/code&gt;&lt;/a&gt;. That is a deliberate fixture, and it
stands in for the customer state measured in
&lt;a href=&quot;/runs/2026-08-26-stale-statistics-reachability/&quot;&gt;&lt;code&gt;2026-08-26-stale-statistics-reachability&lt;/code&gt;&lt;/a&gt;: autovacuum off on a large table,
plus a loader that does not &lt;code&gt;ANALYZE&lt;/code&gt;.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Method.&lt;/strong&gt; A clean copy of the restored data directory is parked once and
restored before each level, so every leg starts byte-identical without paying 90
seconds for a fresh &lt;code&gt;pg_restore&lt;/code&gt;. Free space is then set exactly with a ballast
file.&lt;/p&gt;
&lt;h2 id=&quot;results&quot;&gt;Results&lt;/h2&gt;
&lt;table&gt;&lt;thead&gt;&lt;tr&gt;&lt;th&gt;Free space at migration&lt;/th&gt;&lt;th&gt;Outcome&lt;/th&gt;&lt;th&gt;Index built&lt;/th&gt;&lt;th&gt;&lt;code&gt;DATABASECHANGELOG&lt;/code&gt;&lt;/th&gt;&lt;th&gt;&lt;code&gt;migration_model&lt;/code&gt;&lt;/th&gt;&lt;/tr&gt;&lt;/thead&gt;&lt;tbody&gt;
&lt;tr&gt;&lt;td&gt;200 MB&lt;/td&gt;&lt;td&gt;READY&lt;/td&gt;&lt;td&gt;yes&lt;/td&gt;&lt;td&gt;211&lt;/td&gt;&lt;td&gt;26.7.1&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;175 MB&lt;/td&gt;&lt;td&gt;READY&lt;/td&gt;&lt;td&gt;yes&lt;/td&gt;&lt;td&gt;211&lt;/td&gt;&lt;td&gt;26.7.1&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;&lt;strong&gt;150 MB&lt;/strong&gt;&lt;/td&gt;&lt;td&gt;&lt;strong&gt;READY&lt;/strong&gt;&lt;/td&gt;&lt;td&gt;&lt;strong&gt;yes&lt;/strong&gt;&lt;/td&gt;&lt;td&gt;211&lt;/td&gt;&lt;td&gt;26.7.1&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;&lt;strong&gt;125 MB&lt;/strong&gt;&lt;/td&gt;&lt;td&gt;&lt;strong&gt;FAILED&lt;/strong&gt;&lt;/td&gt;&lt;td&gt;no&lt;/td&gt;&lt;td&gt;&lt;strong&gt;187&lt;/strong&gt;&lt;/td&gt;&lt;td&gt;&lt;strong&gt;26.0.0&lt;/strong&gt;&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;100 MB&lt;/td&gt;&lt;td&gt;FAILED&lt;/td&gt;&lt;td&gt;no&lt;/td&gt;&lt;td&gt;187&lt;/td&gt;&lt;td&gt;26.0.0&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;50 MB&lt;/td&gt;&lt;td&gt;FAILED&lt;/td&gt;&lt;td&gt;no&lt;/td&gt;&lt;td&gt;187&lt;/td&gt;&lt;td&gt;26.0.0&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;25 MB&lt;/td&gt;&lt;td&gt;FAILED&lt;/td&gt;&lt;td&gt;no&lt;/td&gt;&lt;td&gt;187&lt;/td&gt;&lt;td&gt;26.0.0&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;12 MB&lt;/td&gt;&lt;td&gt;FAILED&lt;/td&gt;&lt;td&gt;no&lt;/td&gt;&lt;td&gt;187&lt;/td&gt;&lt;td&gt;26.0.0&lt;/td&gt;&lt;/tr&gt;
&lt;/tbody&gt;&lt;/table&gt;
&lt;p&gt;&lt;strong&gt;Floor: &lt;code&gt;(125 MB, 150 MB]&lt;/code&gt;.&lt;/strong&gt; Against S14&#39;s &lt;code&gt;(4 MB, 8 MB]&lt;/code&gt; at 100k.&lt;/p&gt;
&lt;p&gt;Every failure is identical and stops at 187 of 211 — the same point as S14&#39;s own
failure and as the index-name collision in
&lt;a href=&quot;/runs/2026-08-26-index-precondition-anatomy/&quot;&gt;&lt;code&gt;2026-08-26-index-precondition-anatomy&lt;/code&gt;&lt;/a&gt;, because all three die on the same
changeset.&lt;/p&gt;
&lt;h2 id=&quot;breakage-observed&quot;&gt;Breakage observed&lt;/h2&gt;
&lt;h3 id=&quot;disk-exhaustion-at-2m-needs-20x-the-headroom-s14-measured&quot;&gt;Disk exhaustion at 2M needs 20× the headroom S14 measured&lt;/h3&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Symptom:&lt;/strong&gt; upgrade fails on &lt;code&gt;CREATE INDEX IDX_USER_CREATED_TIMESTAMP&lt;/code&gt;;
changelog 187/211, &lt;code&gt;migration_model&lt;/code&gt; 26.0.0, server does not start.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Evidence:&lt;/strong&gt; the sweep above; Postgres error quoted.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Cause:&lt;/strong&gt; &lt;strong&gt;confirmed&lt;/strong&gt; — parallel external sort spilling to &lt;code&gt;pgsql_tmp&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Fix:&lt;/strong&gt; free space, restart. (Not re-tested here; S14 showed the half-applied
changelog resumes and completes.)&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Would this hit a customer?&lt;/strong&gt; Scale-dependent &lt;strong&gt;and&lt;/strong&gt; conditional on the index
build being attempted at all — which needs a stale row estimate. Narrow, but
the consequence is a failed maintenance window on the largest installations.&lt;/li&gt;
&lt;/ul&gt;
&lt;h2 id=&quot;verification&quot;&gt;Verification&lt;/h2&gt;
&lt;table&gt;&lt;thead&gt;&lt;tr&gt;&lt;th&gt;Claim&lt;/th&gt;&lt;th&gt;Primary source&lt;/th&gt;&lt;th&gt;Checked&lt;/th&gt;&lt;/tr&gt;&lt;/thead&gt;&lt;tbody&gt;
&lt;tr&gt;&lt;td&gt;Floor at 2,000,003 users is &lt;code&gt;(125 MB, 150 MB]&lt;/code&gt;&lt;/td&gt;&lt;td&gt;eight levels, 150 READY / 125 FAILED&lt;/td&gt;&lt;td&gt;☑&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Failures are identical and stop at 187/211&lt;/td&gt;&lt;td&gt;&lt;code&gt;count(*)&lt;/code&gt; and &lt;code&gt;migration_model&lt;/code&gt; at every failing level&lt;/td&gt;&lt;td&gt;☑&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;The exhausted space is a parallel sort&#39;s temp fileset&lt;/td&gt;&lt;td&gt;Postgres error naming &lt;code&gt;pgsql_tmp70.0.fileset&lt;/code&gt;, two backends&lt;/td&gt;&lt;td&gt;☑&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Finished index is 14 MB; net build cost 14 MB&lt;/td&gt;&lt;td&gt;&lt;code&gt;pg_relation_size&lt;/code&gt; and &lt;code&gt;df&lt;/code&gt; before/after an unconstrained build&lt;/td&gt;&lt;td&gt;☑&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Transient requirement is ~10× the finished index&lt;/td&gt;&lt;td&gt;14 MB index against a &amp;gt;125 MB floor&lt;/td&gt;&lt;td&gt;☑&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;S14&#39;s &quot;scaling up removes the failure mode&quot; was conditional&lt;/td&gt;&lt;td&gt;it holds only while the build is declined; forcing it re-arms the scenario&lt;/td&gt;&lt;td&gt;☑&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Recovery by freeing space and restarting&lt;/td&gt;&lt;td&gt;&lt;strong&gt;not re-tested at 2M&lt;/strong&gt; — S14 showed it at 100k&lt;/td&gt;&lt;td&gt;☐&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;The floor with &lt;code&gt;maintenance_work_mem&lt;/code&gt; raised&lt;/td&gt;&lt;td&gt;&lt;strong&gt;not tested&lt;/strong&gt; — stock 64MB throughout&lt;/td&gt;&lt;td&gt;☐&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;The floor with parallel maintenance workers disabled&lt;/td&gt;&lt;td&gt;&lt;strong&gt;not tested&lt;/strong&gt;&lt;/td&gt;&lt;td&gt;☐&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Whether the floor scales linearly between 100k and 2M&lt;/td&gt;&lt;td&gt;&lt;strong&gt;not tested&lt;/strong&gt; — two points, no intermediate&lt;/td&gt;&lt;td&gt;☐&lt;/td&gt;&lt;/tr&gt;
&lt;/tbody&gt;&lt;/table&gt;
</content>
        
    </entry>
    <entry xml:lang="en">
        <title>Rehearsal Record — S3 revisited: `statement_timeout` does half-migrate, and autovacuum is why</title>
        <published>2026-08-26T00:00:00+00:00</published>
        <updated>2026-08-26T00:00:00+00:00</updated>
        
        <author>
          <name>Unknown</name>
        </author>
        
        <link rel="alternate" type="text/html" href="https://ledger.mlabs.city/runs/2026-08-26-s3-revisited-autovacuum-lock-wait/"/>
        <id>https://ledger.mlabs.city/runs/2026-08-26-s3-revisited-autovacuum-lock-wait/</id>
        
        <content type="html" xml:base="https://ledger.mlabs.city/runs/2026-08-26-s3-revisited-autovacuum-lock-wait/">&lt;h2 id=&quot;summary&quot;&gt;Summary&lt;/h2&gt;
&lt;p&gt;&lt;strong&gt;S3 measured correctly and concluded wrongly.&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;Its measurement stands: no statement in this migration takes meaningful time to
&lt;em&gt;execute&lt;/em&gt;. We re-measured the statement that matters and got &lt;strong&gt;0.177 ms&lt;/strong&gt;.&lt;/p&gt;
&lt;p&gt;Its conclusion does not. S3 reasoned that because the longest statement was
2.142 ms, no &lt;code&gt;statement_timeout&lt;/code&gt; value could land between &quot;too short to start&quot;
and &quot;long enough to finish&quot;. That inference assumes a statement&#39;s duration is its
execution time. In Postgres it is not: &lt;strong&gt;a statement&#39;s duration includes the time
it spends waiting for its lock, and &lt;code&gt;statement_timeout&lt;/code&gt; is charged for that
wait.&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;At 2,000,003 users, &lt;code&gt;statement_timeout&lt;/code&gt; values of 500 ms, 200 ms and 50 ms all
leave the database half-migrated — &lt;code&gt;DATABASECHANGELOG&lt;/code&gt; at &lt;strong&gt;157 of 211&lt;/strong&gt;,
&lt;code&gt;migration_model&lt;/code&gt; still &lt;strong&gt;26.0.0&lt;/strong&gt;, and the server refusing to start. 1 s and 10 s
complete. The band S3 called empty is &lt;code&gt;(500 ms, 1 s]&lt;/code&gt;.&lt;/p&gt;
&lt;h2 id=&quot;environment&quot;&gt;Environment&lt;/h2&gt;
&lt;table&gt;&lt;thead&gt;&lt;tr&gt;&lt;th&gt;Field&lt;/th&gt;&lt;th&gt;Value&lt;/th&gt;&lt;/tr&gt;&lt;/thead&gt;&lt;tbody&gt;
&lt;tr&gt;&lt;td&gt;Keycloak from → to&lt;/td&gt;&lt;td&gt;26.0.0 → 26.7.1&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Distribution / start mode&lt;/td&gt;&lt;td&gt;quay.io/keycloak/keycloak official image, &lt;code&gt;start&lt;/code&gt; w/ external DB&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Database&lt;/td&gt;&lt;td&gt;PostgreSQL 16, single node, container&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Postgres tuning&lt;/td&gt;&lt;td&gt;stock except where a leg names the variable. &lt;code&gt;deadlock_timeout&lt;/code&gt; &lt;strong&gt;1s (stock)&lt;/strong&gt;, &lt;code&gt;autovacuum&lt;/code&gt; &lt;strong&gt;on (stock)&lt;/strong&gt;, &lt;code&gt;statement_timeout&lt;/code&gt; set per leg on the &lt;code&gt;keycloak&lt;/code&gt; role&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Adverse scenario&lt;/td&gt;&lt;td&gt;S3&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Dataset scale&lt;/td&gt;&lt;td&gt;2,000,003 users · &lt;code&gt;CREDENTIAL&lt;/code&gt; 2,000,002 rows / 105,296 pages&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Seeding method&lt;/td&gt;&lt;td&gt;&lt;code&gt;seed-sql&lt;/code&gt;, restored from &lt;code&gt;baseline-26.0.0-sqlseed-2m&lt;/code&gt;&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Topology&lt;/td&gt;&lt;td&gt;single container&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Host&lt;/td&gt;&lt;td&gt;Hetzner CCX33 (fsn1) — 8 vCPU dedicated, 30 GB RAM, local NVMe&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;JVM heap&lt;/td&gt;&lt;td&gt;&lt;code&gt;-Xms1g -Xmx4g&lt;/code&gt;&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Instrumentation&lt;/td&gt;&lt;td&gt;&lt;code&gt;log_min_duration_statement=0&lt;/code&gt;, &lt;code&gt;log_lock_waits=on&lt;/code&gt; on the diagnostic legs&lt;/td&gt;&lt;/tr&gt;
&lt;/tbody&gt;&lt;/table&gt;
&lt;h2 id=&quot;breakage-observed&quot;&gt;Breakage observed&lt;/h2&gt;
&lt;h3 id=&quot;a-statement-timeout-below-one-second-half-migrates-the-database&quot;&gt;A &lt;code&gt;statement_timeout&lt;/code&gt; below one second half-migrates the database&lt;/h3&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Symptom:&lt;/strong&gt; &lt;code&gt;canceling statement due to statement timeout&lt;/code&gt; on changeset
26.2.0-26106; server exits; &lt;code&gt;DATABASECHANGELOG&lt;/code&gt; 157/211, &lt;code&gt;migration_model&lt;/code&gt; 26.0.0.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Evidence:&lt;/strong&gt; the sweep above; Postgres log quoted in full.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Cause:&lt;/strong&gt; &lt;strong&gt;confirmed&lt;/strong&gt; — autovacuum holds a conflicting lock on &lt;code&gt;CREDENTIAL&lt;/code&gt;
and Postgres waits &lt;code&gt;deadlock_timeout&lt;/code&gt; before evicting it; the wait is charged
to &lt;code&gt;statement_timeout&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Fix or workaround:&lt;/strong&gt; any one of — raise &lt;code&gt;statement_timeout&lt;/code&gt; above
&lt;code&gt;deadlock_timeout&lt;/code&gt; for the migration role; lower &lt;code&gt;deadlock_timeout&lt;/code&gt;; or make
sure autovacuum is not mid-scan of the large tables when the window opens
(&lt;code&gt;VACUUM&lt;/code&gt; them beforehand, or set &lt;code&gt;autovacuum_enabled=false&lt;/code&gt; for the window).&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Would this hit a customer?&lt;/strong&gt; &lt;strong&gt;A &lt;code&gt;statement_timeout&lt;/code&gt; on the database role is
ordinary hardening&lt;/strong&gt;, and this is the fourth route the lab has found to a
half-applied changelog. It is likeliest immediately after a restore or bulk
load — which is the state of every staging rehearsal, and of a production
database that has just been restored for a DR test.&lt;/li&gt;
&lt;/ul&gt;
&lt;h2 id=&quot;verification&quot;&gt;Verification&lt;/h2&gt;
&lt;table&gt;&lt;thead&gt;&lt;tr&gt;&lt;th&gt;Claim&lt;/th&gt;&lt;th&gt;Primary source&lt;/th&gt;&lt;th&gt;Checked&lt;/th&gt;&lt;/tr&gt;&lt;/thead&gt;&lt;tbody&gt;
&lt;tr&gt;&lt;td&gt;500 ms / 200 ms / 50 ms half-migrate at 2M&lt;/td&gt;&lt;td&gt;three runs, &lt;code&gt;DATABASECHANGELOG&lt;/code&gt; and &lt;code&gt;migration_model&lt;/code&gt; queried&lt;/td&gt;&lt;td&gt;☑&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;1 s and 10 s complete&lt;/td&gt;&lt;td&gt;two runs, 211 rows, model 26.7.1&lt;/td&gt;&lt;td&gt;☑&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;The failing statement is 26.2.0-26106, not the index build&lt;/td&gt;&lt;td&gt;Liquibase exception quoted&lt;/td&gt;&lt;td&gt;☑&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;That statement executes in well under a millisecond&lt;/td&gt;&lt;td&gt;F1 0.177 ms, F2 0.156 ms, F3 0.210 ms, from Postgres&#39; log&lt;/td&gt;&lt;td&gt;☑&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;The duration is a lock wait, not execution&lt;/td&gt;&lt;td&gt;&lt;code&gt;still waiting for AccessExclusiveLock&lt;/code&gt; / &lt;code&gt;acquired … after 100.161 ms&lt;/code&gt;&lt;/td&gt;&lt;td&gt;☑&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;The blocker is an autovacuum worker on &lt;code&gt;CREDENTIAL&lt;/code&gt;&lt;/td&gt;&lt;td&gt;&lt;code&gt;canceling autovacuum task … automatic vacuum of table &quot;keycloak.public.credential&quot;&lt;/code&gt;&lt;/td&gt;&lt;td&gt;☑&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;The wait length is &lt;code&gt;deadlock_timeout&lt;/code&gt;&lt;/td&gt;&lt;td&gt;E2 — 100 ms setting produced a 100.485 ms statement&lt;/td&gt;&lt;td&gt;☑&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Turning autovacuum off makes 500 ms survivable&lt;/td&gt;&lt;td&gt;F3 — READY&lt;/td&gt;&lt;td&gt;☑&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;S3&#39;s own measurement was of an uncontended run&lt;/td&gt;&lt;td&gt;inferred from F1 reproducing 2.142 ms&#39;s order of magnitude; &lt;strong&gt;the original run cannot be re-inspected&lt;/strong&gt;&lt;/td&gt;&lt;td&gt;☐&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;The autovacuum worker predicts the outcome&lt;/td&gt;&lt;td&gt;ten repetitions, 10/10 agreement, 7 failures&lt;/td&gt;&lt;td&gt;☑&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Failure rate immediately after a restore&lt;/td&gt;&lt;td&gt;&lt;strong&gt;70%&lt;/strong&gt; (7/10) at &lt;code&gt;statement_timeout=500ms&lt;/code&gt;&lt;/td&gt;&lt;td&gt;☑&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Whether other version pairs have an equivalent DDL on a large table&lt;/td&gt;&lt;td&gt;&lt;strong&gt;not tested&lt;/strong&gt;&lt;/td&gt;&lt;td&gt;☐&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Whether &lt;code&gt;lock_timeout&lt;/code&gt; behaves differently from &lt;code&gt;statement_timeout&lt;/code&gt; here&lt;/td&gt;&lt;td&gt;&lt;strong&gt;not tested&lt;/strong&gt;&lt;/td&gt;&lt;td&gt;☐&lt;/td&gt;&lt;/tr&gt;
&lt;/tbody&gt;&lt;/table&gt;
</content>
        
    </entry>
    <entry xml:lang="en">
        <title>Rehearsal Record — S4 on MySQL: the DML-only failure holds, but the fix is *privileges*, not ownership</title>
        <published>2026-08-26T00:00:00+00:00</published>
        <updated>2026-08-26T00:00:00+00:00</updated>
        
        <author>
          <name>Unknown</name>
        </author>
        
        <link rel="alternate" type="text/html" href="https://ledger.mlabs.city/runs/2026-08-26-s4-mysql-dml-only-db-user/"/>
        <id>https://ledger.mlabs.city/runs/2026-08-26-s4-mysql-dml-only-db-user/</id>
        
        <content type="html" xml:base="https://ledger.mlabs.city/runs/2026-08-26-s4-mysql-dml-only-db-user/">&lt;h2 id=&quot;summary&quot;&gt;Summary&lt;/h2&gt;
&lt;p&gt;This is the first run against an engine other than Postgres, and it
&lt;strong&gt;closes the ☐ &quot;holds on MySQL/MariaDB/Oracle&quot; row in S4&lt;/strong&gt;. Two findings, one
expected and one that changes what we tell customers:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;A DML-only account fails the migration on MySQL, same as Postgres&lt;/strong&gt; — so
the &lt;em&gt;failure mode&lt;/em&gt; generalises. It fails &lt;strong&gt;even earlier&lt;/strong&gt; on MySQL than on
Postgres: before any Keycloak changeset runs, at Liquibase&#39;s own changelog-table
setup (&lt;code&gt;ALTER TABLE DATABASECHANGELOG ADD PRIMARY KEY (ID, AUTHOR, FILENAME)&lt;/code&gt;),
not at the first Keycloak DDL changeset.&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;The fix is different.&lt;/strong&gt; On Postgres the S4 record found that granting DDL is
&lt;em&gt;not&lt;/em&gt; enough — &lt;code&gt;DROP INDEX&lt;/code&gt; requires &lt;strong&gt;ownership&lt;/strong&gt;, which no &lt;code&gt;GRANT&lt;/code&gt; confers.
MySQL has no ownership concept: granting the DDL privileges (&lt;code&gt;CREATE, ALTER, DROP, INDEX, REFERENCES, CREATE VIEW, SHOW VIEW&lt;/code&gt; on &lt;code&gt;keycloak.*&lt;/code&gt;) makes the
same migration succeed cleanly. &lt;strong&gt;The pre-flight question is therefore
engine-specific&lt;/strong&gt;: on Postgres, &quot;does the account &lt;em&gt;own&lt;/em&gt; the objects&quot;; on MySQL,
&quot;does the account &lt;em&gt;hold the DDL privileges&lt;/em&gt;&quot;.&lt;/p&gt;
&lt;/li&gt;
&lt;/ol&gt;
&lt;h2 id=&quot;environment&quot;&gt;Environment&lt;/h2&gt;
&lt;table&gt;&lt;thead&gt;&lt;tr&gt;&lt;th&gt;Field&lt;/th&gt;&lt;th&gt;Value&lt;/th&gt;&lt;/tr&gt;&lt;/thead&gt;&lt;tbody&gt;
&lt;tr&gt;&lt;td&gt;Keycloak from → to&lt;/td&gt;&lt;td&gt;26.0.0 → 26.7.1&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Distribution / start mode&lt;/td&gt;&lt;td&gt;quay.io/keycloak/keycloak official image, &lt;code&gt;start&lt;/code&gt; w/ external DB&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Database&lt;/td&gt;&lt;td&gt;&lt;strong&gt;MySQL 8.4 (LTS), single node&lt;/strong&gt;, container &lt;code&gt;mysql:8.4&lt;/code&gt;&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;DB flags&lt;/td&gt;&lt;td&gt;&lt;code&gt;sql_generate_invisible_primary_key=OFF&lt;/code&gt; (8.4 enables it by default and it breaks Keycloak migrations with error 1068), &lt;code&gt;utf8mb4&lt;/code&gt; / &lt;code&gt;utf8mb4_unicode_ci&lt;/code&gt;&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Adverse scenario&lt;/td&gt;&lt;td&gt;S4 — the account Keycloak connects as is the variable&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Dataset scale&lt;/td&gt;&lt;td&gt;1,000 users, 2 realms&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Seeding method&lt;/td&gt;&lt;td&gt;&lt;code&gt;partialImport&lt;/code&gt; (&lt;code&gt;bin/seed-realm.sh --profile typical&lt;/code&gt;)&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Topology&lt;/td&gt;&lt;td&gt;single container&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Host&lt;/td&gt;&lt;td&gt;laptop (Docker 29)&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;JVM heap&lt;/td&gt;&lt;td&gt;&lt;code&gt;-Xms1g -Xmx4g&lt;/code&gt;&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Elapsed clock&lt;/td&gt;&lt;td&gt;run A failed at 13s; run B ready in ~15s (Liquibase + realm migrators ~5s)&lt;/td&gt;&lt;/tr&gt;
&lt;/tbody&gt;&lt;/table&gt;
&lt;h2 id=&quot;procedure&quot;&gt;Procedure&lt;/h2&gt;
&lt;p&gt;The whole run needed a MySQL toolchain first. Then:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;# run A — DML only
CREATE USER &amp;#39;kc_dml&amp;#39;@&amp;#39;%&amp;#39; IDENTIFIED BY &amp;#39;kc_dml&amp;#39;;
GRANT SELECT, INSERT, UPDATE, DELETE ON keycloak.* TO &amp;#39;kc_dml&amp;#39;@&amp;#39;%&amp;#39;;
# .env: KC_DB=mysql, COMPOSE_FILE=docker-compose.yml:docker-compose.mysql.yml,
#        KC_DB_USERNAME=kc_dml, KC_DB_PASSWORD=kc_dml
./bin/upgrade.sh 26.7.1          # FAILED, 13s

# run B — DML + DDL (no ownership concept to add)
GRANT CREATE, ALTER, DROP, INDEX, REFERENCES, CREATE VIEW, SHOW VIEW
      ON keycloak.* TO &amp;#39;kc_dml&amp;#39;@&amp;#39;%&amp;#39;;
./bin/restore.sh baseline-26.0.0-mysql-typical-1k
./bin/upgrade.sh 26.7.1          # READY
&lt;/code&gt;&lt;/pre&gt;
&lt;h2 id=&quot;results&quot;&gt;Results&lt;/h2&gt;
&lt;table&gt;&lt;thead&gt;&lt;tr&gt;&lt;th&gt;Run&lt;/th&gt;&lt;th&gt;Account&#39;s rights&lt;/th&gt;&lt;th&gt;Outcome&lt;/th&gt;&lt;th&gt;Schema after&lt;/th&gt;&lt;th&gt;Users&lt;/th&gt;&lt;/tr&gt;&lt;/thead&gt;&lt;tbody&gt;
&lt;tr&gt;&lt;td&gt;&lt;strong&gt;A&lt;/strong&gt;&lt;/td&gt;&lt;td&gt;DML only (&lt;code&gt;SELECT, INSERT, UPDATE, DELETE&lt;/code&gt;)&lt;/td&gt;&lt;td&gt;&lt;strong&gt;exit 1, refused to start&lt;/strong&gt;&lt;/td&gt;&lt;td&gt;model 26.0.0, changelog untouched&lt;/td&gt;&lt;td&gt;1,000&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;&lt;strong&gt;B&lt;/strong&gt;&lt;/td&gt;&lt;td&gt;DML &lt;strong&gt;+ DDL&lt;/strong&gt; (&lt;code&gt;CREATE, ALTER, DROP, INDEX, REFERENCES, CREATE VIEW, SHOW VIEW&lt;/code&gt;)&lt;/td&gt;&lt;td&gt;&lt;strong&gt;READY, clean&lt;/strong&gt;&lt;/td&gt;&lt;td&gt;model 26.7.1, index audit 121/121&lt;/td&gt;&lt;td&gt;1,000&lt;/td&gt;&lt;/tr&gt;
&lt;/tbody&gt;&lt;/table&gt;
&lt;h3 id=&quot;run-a-failure-earlier-than-postgres-and-not-a-keycloak-changeset&quot;&gt;Run A failure — earlier than Postgres, and not a Keycloak changeset&lt;/h3&gt;
&lt;pre&gt;&lt;code&gt;ERROR: liquibase.exception.ChangeLogParseException:
  liquibase.exception.SetupException: liquibase.exception.DatabaseException:
  ALTER command denied to user &amp;#39;kc_dml&amp;#39;@&amp;#39;172.19.0.3&amp;#39; for table &amp;#39;DATABASECHANGELOG&amp;#39;
  [Failed SQL: (1142) ALTER TABLE keycloak.DATABASECHANGELOG ADD PRIMARY KEY (ID, AUTHOR, FILENAME)]
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Contrast with Postgres S4, where the first failure was a real Keycloak changeset
(&lt;code&gt;20.0.0-12964-supported-dbs-edb-migration&lt;/code&gt;, &lt;code&gt;DROP INDEX IDX_GROUP_ATT_BY_NAME_VALUE&lt;/code&gt;
→ &quot;must be owner of index&quot;). On MySQL, Liquibase 26.7.1 wants a primary key on its
own &lt;code&gt;DATABASECHANGELOG&lt;/code&gt; table that the 26.0.0-era Liquibase did not create, and it
tries to add it &lt;strong&gt;before touching any Keycloak changeset&lt;/strong&gt;. A DML-only account is
therefore rejected at the door, not mid-migration.&lt;/p&gt;
&lt;h3 id=&quot;run-b-ddl-privileges-are-sufficient&quot;&gt;Run B — DDL privileges are sufficient&lt;/h3&gt;
&lt;p&gt;Granting the DDL set (still no ownership concept — MySQL has none) let the same
migration run to completion: &lt;code&gt;Updating database&lt;/code&gt; → realm migrators 26.1.0 → 26.7.0
(~5s), &lt;code&gt;migration_model&lt;/code&gt; 26.7.1, index audit &lt;strong&gt;121 correct / 0 missing / 0 wrong
shape&lt;/strong&gt;, all 1,000 users intact.&lt;/p&gt;
&lt;h2 id=&quot;verification&quot;&gt;Verification&lt;/h2&gt;
&lt;table&gt;&lt;thead&gt;&lt;tr&gt;&lt;th&gt;Claim&lt;/th&gt;&lt;th&gt;Evidence&lt;/th&gt;&lt;/tr&gt;&lt;/thead&gt;&lt;tbody&gt;
&lt;tr&gt;&lt;td&gt;DML-only account fails on MySQL&lt;/td&gt;&lt;td&gt;run A log above, ☑ reproduced once&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;DDL-privileged account succeeds on MySQL&lt;/td&gt;&lt;td&gt;run B &lt;code&gt;migration_model&lt;/code&gt; 26.7.1 ☑&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Index audit clean after the MySQL migration&lt;/td&gt;&lt;td&gt;121 correct / 0 missing / 0 wrong shape ☑&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Realm survives the MySQL upgrade&lt;/td&gt;&lt;td&gt;&lt;code&gt;users/count&lt;/code&gt; = 1000 ☑&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Toolchain round-trips (snapshot/restore) on MySQL&lt;/td&gt;&lt;td&gt;&lt;code&gt;restore.sh&lt;/code&gt; reported &quot;schema 26.0.0, no re-migration&quot;, users 1000 ☑&lt;/td&gt;&lt;/tr&gt;
&lt;/tbody&gt;&lt;/table&gt;
</content>
        
    </entry>
    <entry xml:lang="en">
        <title>Rehearsal Record — how a stale `reltuples` actually happens, and how it mostly does not</title>
        <published>2026-08-26T00:00:00+00:00</published>
        <updated>2026-08-26T00:00:00+00:00</updated>
        
        <author>
          <name>Unknown</name>
        </author>
        
        <link rel="alternate" type="text/html" href="https://ledger.mlabs.city/runs/2026-08-26-stale-statistics-reachability/"/>
        <id>https://ledger.mlabs.city/runs/2026-08-26-stale-statistics-reachability/</id>
        
        <content type="html" xml:base="https://ledger.mlabs.city/runs/2026-08-26-stale-statistics-reachability/">&lt;h2 id=&quot;why-this-run-existed&quot;&gt;Why this run existed&lt;/h2&gt;
&lt;p&gt;Run A showed that Keycloak decides whether to build an index from
&lt;code&gt;pg_class.reltuples&lt;/code&gt;, and forced the interesting case by writing to the catalogue
by hand. That proves a mechanism. It does not prove anyone hits it, and a finding
published on the strength of a hand-edited catalogue row deserves the scepticism
it would get.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;This run is the attempt to reach the same state without touching &lt;code&gt;pg_class&lt;/code&gt;.
Two of the three attempts failed, and saying so is the point.&lt;/strong&gt;&lt;/p&gt;
&lt;h2 id=&quot;environment&quot;&gt;Environment&lt;/h2&gt;
&lt;p&gt;&lt;code&gt;baseline-26.0.0-typical-100k&lt;/code&gt; restored (100,002 users, &lt;code&gt;partialImport&lt;/code&gt;
provenance), then extended with &lt;code&gt;bin/seed-sql.sh&lt;/code&gt;. Postgres 16 stock — in
particular &lt;code&gt;autovacuum&lt;/code&gt; &lt;strong&gt;on&lt;/strong&gt;, &lt;code&gt;autovacuum_naptime&lt;/code&gt; 60s. Hetzner CCX33, single
container, JVM &lt;code&gt;-Xms1g -Xmx4g&lt;/code&gt;, official image, &lt;code&gt;start&lt;/code&gt; with an external DB.&lt;/p&gt;
&lt;h2 id=&quot;outcome&quot;&gt;Outcome&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;&lt;input disabled=&quot;&quot; type=&quot;checkbox&quot; checked=&quot;&quot;/&gt;
Both negative attempts reproduced the &lt;em&gt;correct&lt;/em&gt; behaviour: index skipped, 211 rows, model 26.7.1&lt;/li&gt;
&lt;li&gt;&lt;input disabled=&quot;&quot; type=&quot;checkbox&quot; checked=&quot;&quot;/&gt;
Attempt 3 reached ready with the index built and no warning&lt;/li&gt;
&lt;li&gt;&lt;input disabled=&quot;&quot; type=&quot;checkbox&quot; checked=&quot;&quot;/&gt;
&lt;code&gt;bin/seed-sql.sh --no-analyze&lt;/code&gt; added, so the field&#39;s loader can be modelled&lt;/li&gt;
&lt;/ul&gt;
&lt;h2 id=&quot;verification&quot;&gt;Verification&lt;/h2&gt;
&lt;table&gt;&lt;thead&gt;&lt;tr&gt;&lt;th&gt;Claim&lt;/th&gt;&lt;th&gt;Primary source&lt;/th&gt;&lt;th&gt;Checked&lt;/th&gt;&lt;/tr&gt;&lt;/thead&gt;&lt;tbody&gt;
&lt;tr&gt;&lt;td&gt;&lt;code&gt;seed-sql.sh&lt;/code&gt; analyzes after loading&lt;/td&gt;&lt;td&gt;source line 141, and &lt;code&gt;last_analyze&lt;/code&gt; set in the run&lt;/td&gt;&lt;td&gt;☑&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;With stock autovacuum, a 73s load is corrected before the upgrade&lt;/td&gt;&lt;td&gt;&lt;code&gt;last_autoanalyze&lt;/code&gt; 01:33:30, mid-load; &lt;code&gt;reltuples&lt;/code&gt; accurate after&lt;/td&gt;&lt;td&gt;☑&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;With &lt;code&gt;autovacuum_enabled=false&lt;/code&gt; and no ANALYZE, the estimate stays stale&lt;/td&gt;&lt;td&gt;&lt;code&gt;reltuples&lt;/code&gt; 100,002 against 1,100,003 true rows&lt;/td&gt;&lt;td&gt;☑&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Keycloak then builds the index above the threshold&lt;/td&gt;&lt;td&gt;index present, &lt;code&gt;EXECUTED&lt;/code&gt;, 0 warnings, 1,100,003 rows&lt;/td&gt;&lt;td&gt;☑&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;How long stock autovacuum takes to correct a load of this size&lt;/td&gt;&lt;td&gt;&lt;strong&gt;not isolated&lt;/strong&gt; — it fired during the load, so the correction latency was never measured&lt;/td&gt;&lt;td&gt;☐&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Whether a load large enough to outrun autovacuum exists&lt;/td&gt;&lt;td&gt;&lt;strong&gt;not tested&lt;/strong&gt; — 1M rows did not&lt;/td&gt;&lt;td&gt;☐&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Whether throttled (rather than disabled) autovacuum is enough&lt;/td&gt;&lt;td&gt;&lt;strong&gt;not tested&lt;/strong&gt; — &lt;code&gt;autovacuum_vacuum_cost_delay&lt;/code&gt; untouched&lt;/td&gt;&lt;td&gt;☐&lt;/td&gt;&lt;/tr&gt;
&lt;/tbody&gt;&lt;/table&gt;
</content>
        
    </entry>
    <entry xml:lang="en">
        <title>Rehearsal Record — Backup/rollback tooling defect, and verified rollback at 2M</title>
        <published>2026-08-25T00:00:00+00:00</published>
        <updated>2026-08-25T00:00:00+00:00</updated>
        
        <author>
          <name>Unknown</name>
        </author>
        
        <link rel="alternate" type="text/html" href="https://ledger.mlabs.city/findings/2026-08-25-backup-and-rollback-tooling/"/>
        <id>https://ledger.mlabs.city/findings/2026-08-25-backup-and-rollback-tooling/</id>
        
        <content type="html" xml:base="https://ledger.mlabs.city/findings/2026-08-25-backup-and-rollback-tooling/">&lt;h2 id=&quot;verification-full-loop-at-2-000-003-users&quot;&gt;Verification — full loop at 2,000,003 users&lt;/h2&gt;
&lt;table&gt;&lt;thead&gt;&lt;tr&gt;&lt;th&gt;Step&lt;/th&gt;&lt;th&gt;Result&lt;/th&gt;&lt;/tr&gt;&lt;/thead&gt;&lt;tbody&gt;
&lt;tr&gt;&lt;td&gt;Restore baseline to 26.0.0&lt;/td&gt;&lt;td&gt;schema 26.0.0, &lt;code&gt;no re-migration&lt;/code&gt;&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;&lt;code&gt;upgrade.sh 26.7.1&lt;/code&gt;&lt;/td&gt;&lt;td&gt;auto-backup &lt;code&gt;pre-upgrade-26.0.0-to-26.7.1-20260825T182154Z&lt;/code&gt; (65 MB), READY&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Canary written post-upgrade&lt;/td&gt;&lt;td&gt;users 2,000,003 → 2,000,004&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;&lt;code&gt;restore.sh &amp;lt;backup&amp;gt;&lt;/code&gt; — &lt;strong&gt;no &lt;code&gt;--version&lt;/code&gt;, inference only&lt;/strong&gt;&lt;/td&gt;&lt;td&gt;&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;→ running image&lt;/td&gt;&lt;td&gt;&lt;strong&gt;quay.io/keycloak/keycloak:26.0.0&lt;/strong&gt;&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;→ &lt;code&gt;migration_model&lt;/code&gt;&lt;/td&gt;&lt;td&gt;&lt;strong&gt;26.0.0&lt;/strong&gt;&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;→ users&lt;/td&gt;&lt;td&gt;&lt;strong&gt;2,000,003&lt;/strong&gt;&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;→ canary&lt;/td&gt;&lt;td&gt;&lt;strong&gt;absent&lt;/strong&gt; — correctly lost&lt;/td&gt;&lt;/tr&gt;
&lt;/tbody&gt;&lt;/table&gt;
&lt;h2 id=&quot;timings&quot;&gt;Timings&lt;/h2&gt;
&lt;table&gt;&lt;thead&gt;&lt;tr&gt;&lt;th&gt;&lt;/th&gt;&lt;th&gt;1,000 users&lt;/th&gt;&lt;th&gt;2,000,003 users&lt;/th&gt;&lt;/tr&gt;&lt;/thead&gt;&lt;tbody&gt;
&lt;tr&gt;&lt;td&gt;Backup (&lt;code&gt;pg_dump -Fc&lt;/code&gt;)&lt;/td&gt;&lt;td&gt;&amp;lt;1s&lt;/td&gt;&lt;td&gt;&lt;strong&gt;~6s&lt;/strong&gt; (65 MB from a 3.5 GB database)&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Upgrade overhead from backup&lt;/td&gt;&lt;td&gt;negligible&lt;/td&gt;&lt;td&gt;&lt;strong&gt;~8s&lt;/strong&gt; on a 16s upgrade&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Full rollback to serving&lt;/td&gt;&lt;td&gt;&lt;strong&gt;15.9s&lt;/strong&gt;&lt;/td&gt;&lt;td&gt;&lt;strong&gt;70s&lt;/strong&gt;&lt;/td&gt;&lt;/tr&gt;
&lt;/tbody&gt;&lt;/table&gt;
&lt;p&gt;&lt;strong&gt;Rollback time scales with data; migration time does not.&lt;/strong&gt; At 2M the migration
is 3.84s and the rollback is 70s — the rollback is ~18× the migration it undoes.
That inverts the usual assumption and is the number that actually matters when
deciding whether to roll back mid-window.&lt;/p&gt;
&lt;h2 id=&quot;publishable-extract&quot;&gt;Publishable extract&lt;/h2&gt;
&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;We added an automatic backup to our upgrade tooling, and testing it revealed
our rollback had never worked.&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;Restoring a pre-upgrade snapshot put the old schema back — and then the running
container, still the &lt;em&gt;new&lt;/em&gt; Keycloak, immediately re-ran the migration. The
restore looked successful: every row was there. But the schema was back on the
new version and every write since the snapshot was gone. The worst of both.&lt;/p&gt;
&lt;p&gt;The cause is one word. &lt;code&gt;docker compose start&lt;/code&gt; reuses the existing container;
only &lt;code&gt;--force-recreate&lt;/code&gt; picks up the image you just rolled back to. Any
rollback procedure that restores a database without also pinning the binary
version has this bug, whatever tooling it is written in.&lt;/p&gt;
&lt;p&gt;Two things worth taking from it. First, &lt;strong&gt;verify a rollback by reading the
schema version back out of the database afterwards&lt;/strong&gt;, not by confirming your
data is present — the data is present in the broken case too. Our restore now
compares &lt;code&gt;migration_model&lt;/code&gt; before and after restart and fails loudly if the
server re-migrated.&lt;/p&gt;
&lt;p&gt;Second, the timings. On a 2,000,003-user realm (Postgres 16.15, Hetzner CCX33 —
8 vCPU dedicated, 32 GB, local NVMe), the upgrade&#39;s schema migration takes 3.8
seconds. The rollback takes &lt;strong&gt;70 seconds&lt;/strong&gt; — about eighteen times longer than
the thing it undoes. Migration duration is flat with realm size; restore
duration is not. If you are sizing a maintenance window, the rollback is the
expensive half, and it is the half nobody measures.&lt;/p&gt;
&lt;/blockquote&gt;
</content>
        
    </entry>
    <entry xml:lang="en">
        <title>Rehearsal Record — S7: the upgrade succeeds, the extension stops working, and nothing fails</title>
        <published>2026-08-25T00:00:00+00:00</published>
        <updated>2026-08-25T00:00:00+00:00</updated>
        
        <author>
          <name>Unknown</name>
        </author>
        
        <link rel="alternate" type="text/html" href="https://ledger.mlabs.city/findings/2026-08-25-s7-custom-spi/"/>
        <id>https://ledger.mlabs.city/findings/2026-08-25-s7-custom-spi/</id>
        
        <content type="html" xml:base="https://ledger.mlabs.city/findings/2026-08-25-s7-custom-spi/">&lt;h2 id=&quot;summary&quot;&gt;Summary&lt;/h2&gt;
&lt;p&gt;Two findings, and the second is the sellable one.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;1. &quot;Has a custom SPI&quot; is not a risk by itself.&lt;/strong&gt; A provider that uses only
stable SPI, compiled against &lt;strong&gt;21.1.2&lt;/strong&gt;, loads and works correctly on &lt;strong&gt;26.7.1&lt;/strong&gt; —
five majors and 619 removed public signatures later — with no source change at
all. The same source compiles unchanged against 21.1.2, 25.0.6, 26.0.0 and
26.7.1.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;2. A provider that touches a removed internal API does not fail the upgrade.
It fails silently, afterwards.&lt;/strong&gt; The server starts clean in 15 seconds. The
provider loads, prints its init line, and appears in &lt;code&gt;/admin/serverinfo&lt;/code&gt;. Every
check an operator runs during the window passes. Then the first event arrives
and the listener throws &lt;code&gt;NoSuchMethodError&lt;/code&gt; — logged, dropped, and invisible to
the request that caused it, which returns &lt;code&gt;201&lt;/code&gt;.&lt;/p&gt;
&lt;p&gt;For an event listener forwarding to a SIEM, that is &lt;strong&gt;audit forwarding that
stops without an outage.&lt;/strong&gt;&lt;/p&gt;
&lt;h2 id=&quot;environment&quot;&gt;Environment&lt;/h2&gt;
&lt;table&gt;&lt;thead&gt;&lt;tr&gt;&lt;th&gt;Field&lt;/th&gt;&lt;th&gt;Value&lt;/th&gt;&lt;/tr&gt;&lt;/thead&gt;&lt;tbody&gt;
&lt;tr&gt;&lt;td&gt;Keycloak path&lt;/td&gt;&lt;td&gt;25.0.6 → 26.0.0 → 26.7.1 (each hop a real migration, jar mounted throughout)&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Distribution / start mode&lt;/td&gt;&lt;td&gt;quay.io/keycloak/keycloak official image, &lt;code&gt;start&lt;/code&gt; w/ external DB, auto-build on start&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Database&lt;/td&gt;&lt;td&gt;PostgreSQL 16.15, single node, container&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Postgres tuning&lt;/td&gt;&lt;td&gt;stock — &lt;code&gt;shared_buffers&lt;/code&gt; 128MB, &lt;code&gt;maintenance_work_mem&lt;/code&gt; 64MB, &lt;code&gt;statement_timeout&lt;/code&gt; 0&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Adverse scenario&lt;/td&gt;&lt;td&gt;S7&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Dataset scale&lt;/td&gt;&lt;td&gt;1,002 users, 2 realms&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Seeding method&lt;/td&gt;&lt;td&gt;&lt;code&gt;partialImport&lt;/code&gt; (&lt;code&gt;bin/seed-realm.sh --profile typical&lt;/code&gt;)&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Fixture profile&lt;/td&gt;&lt;td&gt;&lt;code&gt;typical&lt;/code&gt;, plus custom provider jars in &lt;code&gt;/opt/keycloak/providers&lt;/code&gt;&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Topology&lt;/td&gt;&lt;td&gt;single container&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Host&lt;/td&gt;&lt;td&gt;Hetzner CCX33 (fsn1) — 8 vCPU dedicated, 30 GB RAM, local NVMe&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;JVM heap&lt;/td&gt;&lt;td&gt;&lt;code&gt;-Xms1g -Xmx4g&lt;/code&gt;&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Lab change&lt;/td&gt;&lt;td&gt;&lt;code&gt;fixtures/spi-brittle/&lt;/code&gt; added; &lt;code&gt;bin/build-spi.sh&lt;/code&gt; gained &lt;code&gt;--module&lt;/code&gt; and now reads the artifactId from the pom&lt;/td&gt;&lt;/tr&gt;
&lt;/tbody&gt;&lt;/table&gt;
&lt;h2 id=&quot;results&quot;&gt;Results&lt;/h2&gt;
&lt;h3 id=&quot;compile-time&quot;&gt;Compile-time&lt;/h3&gt;
&lt;table&gt;&lt;thead&gt;&lt;tr&gt;&lt;th&gt;Source&lt;/th&gt;&lt;th&gt;21.1.2&lt;/th&gt;&lt;th&gt;25.0.6&lt;/th&gt;&lt;th&gt;26.0.0&lt;/th&gt;&lt;th&gt;26.7.1&lt;/th&gt;&lt;/tr&gt;&lt;/thead&gt;&lt;tbody&gt;
&lt;tr&gt;&lt;td&gt;tame&lt;/td&gt;&lt;td&gt;&lt;strong&gt;builds&lt;/strong&gt;&lt;/td&gt;&lt;td&gt;&lt;strong&gt;builds&lt;/strong&gt;&lt;/td&gt;&lt;td&gt;&lt;strong&gt;builds&lt;/strong&gt;&lt;/td&gt;&lt;td&gt;&lt;strong&gt;builds&lt;/strong&gt;&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;brittle&lt;/td&gt;&lt;td&gt;—&lt;/td&gt;&lt;td&gt;&lt;strong&gt;builds&lt;/strong&gt;&lt;/td&gt;&lt;td&gt;—&lt;/td&gt;&lt;td&gt;&lt;strong&gt;FAILS&lt;/strong&gt;&lt;/td&gt;&lt;/tr&gt;
&lt;/tbody&gt;&lt;/table&gt;
&lt;pre&gt;&lt;code&gt;[ERROR] LabBrittleEventListener.java:[31,44] no suitable method found for
        runJobInTransactionWithResult(org.keycloak.models.KeycloakSessionFactory,
        org.keycloak.models.KeycloakContext,(s)-&amp;gt;event[...]mId())
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The API diff predicted the removal; the compiler confirms it independently.&lt;/p&gt;
&lt;h3 id=&quot;run-time&quot;&gt;Run-time&lt;/h3&gt;
&lt;table&gt;&lt;thead&gt;&lt;tr&gt;&lt;th&gt;Jar built against&lt;/th&gt;&lt;th&gt;Running on&lt;/th&gt;&lt;th&gt;Loads&lt;/th&gt;&lt;th&gt;In &lt;code&gt;serverinfo&lt;/code&gt;&lt;/th&gt;&lt;th&gt;Events delivered&lt;/th&gt;&lt;/tr&gt;&lt;/thead&gt;&lt;tbody&gt;
&lt;tr&gt;&lt;td&gt;tame / 25.0.6&lt;/td&gt;&lt;td&gt;25.0.6&lt;/td&gt;&lt;td&gt;☑&lt;/td&gt;&lt;td&gt;☑&lt;/td&gt;&lt;td&gt;☑ user + admin&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;tame / 25.0.6&lt;/td&gt;&lt;td&gt;26.0.0&lt;/td&gt;&lt;td&gt;☑&lt;/td&gt;&lt;td&gt;☑&lt;/td&gt;&lt;td&gt;☑ user + admin&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;tame / 25.0.6&lt;/td&gt;&lt;td&gt;26.7.1&lt;/td&gt;&lt;td&gt;☑&lt;/td&gt;&lt;td&gt;☑&lt;/td&gt;&lt;td&gt;☑ user + admin&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;&lt;strong&gt;tame / 21.1.2&lt;/strong&gt;&lt;/td&gt;&lt;td&gt;&lt;strong&gt;26.7.1&lt;/strong&gt;&lt;/td&gt;&lt;td&gt;☑&lt;/td&gt;&lt;td&gt;☑&lt;/td&gt;&lt;td&gt;&lt;strong&gt;☑ user + admin&lt;/strong&gt;&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;tame / 26.7.1&lt;/td&gt;&lt;td&gt;26.7.1&lt;/td&gt;&lt;td&gt;☑&lt;/td&gt;&lt;td&gt;☑&lt;/td&gt;&lt;td&gt;☑&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;&lt;strong&gt;brittle / 25.0.6&lt;/strong&gt;&lt;/td&gt;&lt;td&gt;&lt;strong&gt;26.7.1&lt;/strong&gt;&lt;/td&gt;&lt;td&gt;&lt;strong&gt;☑&lt;/strong&gt;&lt;/td&gt;&lt;td&gt;&lt;strong&gt;☑&lt;/strong&gt;&lt;/td&gt;&lt;td&gt;&lt;strong&gt;☒ every event throws&lt;/strong&gt;&lt;/td&gt;&lt;/tr&gt;
&lt;/tbody&gt;&lt;/table&gt;
&lt;p&gt;Migration timings with the jar mounted throughout: 25.0.6 → 26.0.0 in &lt;strong&gt;13s&lt;/strong&gt;,
26.0.0 → 26.7.1 in &lt;strong&gt;16s&lt;/strong&gt;. The provider did not slow, block or fail any hop.&lt;/p&gt;
&lt;h3 id=&quot;what-the-brittle-failure-actually-looks-like&quot;&gt;What the brittle failure actually looks like&lt;/h3&gt;
&lt;p&gt;Startup — indistinguishable from success:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;[lab-brittle] factory init -- provider loaded
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;code&gt;/admin/serverinfo&lt;/code&gt; lists &lt;code&gt;lab-brittle-listener&lt;/code&gt; among the event listeners.
&lt;code&gt;wait-ready.sh&lt;/code&gt; returns 0 in 15 seconds. Container &lt;code&gt;running&lt;/code&gt;.&lt;/p&gt;
&lt;p&gt;First event — and only then:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;ERROR [org.keycloak.services] KC-SERVICES0085: Failed to send type to
  lab.LabBrittleEventListener@6012fda7: java.lang.NoSuchMethodError:
  &amp;#39;java.lang.Object org.keycloak.models.utils.KeycloakModelUtils.runJobInTransactionWithResult(
     org.keycloak.models.KeycloakSessionFactory, org.keycloak.models.KeycloakContext,
     org.keycloak.models.KeycloakSessionTaskWithResult)&amp;#39;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;And the blast radius is &lt;em&gt;nil&lt;/em&gt;, which is the problem:&lt;/p&gt;
&lt;table&gt;&lt;thead&gt;&lt;tr&gt;&lt;th&gt;Check after the failure&lt;/th&gt;&lt;th&gt;Result&lt;/th&gt;&lt;/tr&gt;&lt;/thead&gt;&lt;tbody&gt;
&lt;tr&gt;&lt;td&gt;Admin request that raised the event&lt;/td&gt;&lt;td&gt;&lt;strong&gt;HTTP 201&lt;/strong&gt;&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Group actually created in the database&lt;/td&gt;&lt;td&gt;&lt;strong&gt;yes&lt;/strong&gt;&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Token endpoint&lt;/td&gt;&lt;td&gt;&lt;strong&gt;HTTP 200&lt;/strong&gt;&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Container&lt;/td&gt;&lt;td&gt;&lt;strong&gt;running&lt;/strong&gt;&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Keycloak&#39;s own &lt;code&gt;admin_event_entity&lt;/code&gt;&lt;/td&gt;&lt;td&gt;&lt;strong&gt;1,015 rows — still recording&lt;/strong&gt;&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Keycloak&#39;s own &lt;code&gt;event_entity&lt;/code&gt;&lt;/td&gt;&lt;td&gt;&lt;strong&gt;8 rows — still recording&lt;/strong&gt;&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;The tame listener on the same server&lt;/td&gt;&lt;td&gt;&lt;strong&gt;still delivering&lt;/strong&gt;&lt;/td&gt;&lt;/tr&gt;
&lt;/tbody&gt;&lt;/table&gt;
&lt;p&gt;&lt;strong&gt;Keycloak&#39;s internal audit tables stay complete.&lt;/strong&gt; Anyone verifying the audit
trail by querying Keycloak sees nothing wrong. It is only the delivery to the
custom listener — the path to whatever is downstream — that has stopped.&lt;/p&gt;
&lt;h3 id=&quot;a-note-on-kc-services0047&quot;&gt;A note on &lt;code&gt;KC-SERVICES0047&lt;/code&gt;&lt;/h3&gt;
&lt;p&gt;On both 26.0.0 and 26.7.1, every provider load emitted:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;WARN [org.keycloak.services] KC-SERVICES0047: lab-event-listener
  (lab.LabEventListenerProviderFactory) is implementing the internal SPI
  eventsListener. This SPI is internal and may change without notice
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;code&gt;eventsListener&lt;/code&gt; — the most commonly extended SPI in the wild — is classified
internal. Whether this warning also appears on 25.0.6 was &lt;strong&gt;not checked&lt;/strong&gt; at the
time and the containers were gone; re-run on 2026-08-26
(&lt;a href=&quot;/findings/2026-08-26-kc-services0047-not-new-in-26/&quot;&gt;&lt;code&gt;2026-08-26-kc-services0047-not-new-in-26&lt;/code&gt;&lt;/a&gt;) shows it &lt;strong&gt;is&lt;/strong&gt; present on
25.0.6 — the warning is &lt;strong&gt;not&lt;/strong&gt; new in 26.&lt;/p&gt;
&lt;h3 id=&quot;an-unrelated-observation-deliberately-not-attributed&quot;&gt;An unrelated observation, deliberately not attributed&lt;/h3&gt;
&lt;p&gt;A failed password grant against the &lt;code&gt;lab&lt;/code&gt; realm returned &lt;strong&gt;401 on 25.0.6 and
26.0.0, and 400 on 26.7.1&lt;/strong&gt;. We tested whether the brittle listener caused it:
with the listener registered, removed, and registered again, the response was
&lt;strong&gt;400 every time&lt;/strong&gt;. It is not the listener. Narrowed on 2026-08-26
(&lt;a href=&quot;/findings/2026-08-26-invalid-grant-401-to-400-narrowed/&quot;&gt;&lt;code&gt;2026-08-26-invalid-grant-401-to-400-narrowed&lt;/code&gt;&lt;/a&gt;): it is a &lt;strong&gt;version change&lt;/strong&gt;
that landed in &lt;strong&gt;26.6.0&lt;/strong&gt; — 26.0.0 through 26.5.7 return 401, 26.6.0 onward
returns 400.&lt;/p&gt;
&lt;h2 id=&quot;verification&quot;&gt;Verification&lt;/h2&gt;
&lt;table&gt;&lt;thead&gt;&lt;tr&gt;&lt;th&gt;Claim&lt;/th&gt;&lt;th&gt;Primary source&lt;/th&gt;&lt;th&gt;Checked&lt;/th&gt;&lt;/tr&gt;&lt;/thead&gt;&lt;tbody&gt;
&lt;tr&gt;&lt;td&gt;A 21.1.2-built provider works on 26.7.1&lt;/td&gt;&lt;td&gt;built, mounted, both callbacks fired, registered in serverinfo&lt;/td&gt;&lt;td&gt;☑&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;The tame source compiles unchanged on all four versions&lt;/td&gt;&lt;td&gt;four successful &lt;code&gt;build-spi.sh&lt;/code&gt; runs&lt;/td&gt;&lt;td&gt;☑&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;The removed signature is genuinely removed&lt;/td&gt;&lt;td&gt;&lt;code&gt;spi-api-diff.sh 25.0.6 26.0.0&lt;/code&gt; &lt;strong&gt;and&lt;/strong&gt; an independent compile failure on 26.7.1&lt;/td&gt;&lt;td&gt;☑&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;The brittle provider loads and registers on 26.7.1&lt;/td&gt;&lt;td&gt;init line in log, present in &lt;code&gt;/admin/serverinfo&lt;/code&gt;&lt;/td&gt;&lt;td&gt;☑&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;It throws on every event&lt;/td&gt;&lt;td&gt;&lt;code&gt;KC-SERVICES0085&lt;/code&gt; + &lt;code&gt;NoSuchMethodError&lt;/code&gt;, both user and admin events&lt;/td&gt;&lt;td&gt;☑&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;The failure does not affect the request or the server&lt;/td&gt;&lt;td&gt;201 / 200 / container running / group created&lt;/td&gt;&lt;td&gt;☑&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Keycloak&#39;s own event tables stay populated&lt;/td&gt;&lt;td&gt;&lt;code&gt;admin_event_entity&lt;/code&gt; 1,015, &lt;code&gt;event_entity&lt;/code&gt; 8, read from Postgres&lt;/td&gt;&lt;td&gt;☑&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;&lt;code&gt;KC-SERVICES0047&lt;/code&gt; is new in 26&lt;/td&gt;&lt;td&gt;&lt;strong&gt;no&lt;/strong&gt; — present on 25.0.6 (&lt;a href=&quot;/findings/2026-08-26-kc-services0047-not-new-in-26/&quot;&gt;&lt;code&gt;2026-08-26-kc-services0047-not-new-in-26&lt;/code&gt;&lt;/a&gt;)&lt;/td&gt;&lt;td&gt;☑&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;The 401 → 400 change is version-caused&lt;/td&gt;&lt;td&gt;&lt;strong&gt;yes&lt;/strong&gt; — landed in 26.6.0 (&lt;a href=&quot;/findings/2026-08-26-invalid-grant-401-to-400-narrowed/&quot;&gt;&lt;code&gt;2026-08-26-invalid-grant-401-to-400-narrowed&lt;/code&gt;&lt;/a&gt;)&lt;/td&gt;&lt;td&gt;☑&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Behaviour of provider types other than event listeners&lt;/td&gt;&lt;td&gt;&lt;strong&gt;not tested&lt;/strong&gt; — authenticators, user storage and mappers have different lifecycles and may fail at load rather than at use&lt;/td&gt;&lt;td&gt;☐&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Behaviour when the removed API is called from &lt;code&gt;init()&lt;/code&gt;&lt;/td&gt;&lt;td&gt;not tested — would likely fail at startup, which is the &lt;em&gt;safe&lt;/em&gt; case&lt;/td&gt;&lt;td&gt;☐&lt;/td&gt;&lt;/tr&gt;
&lt;/tbody&gt;&lt;/table&gt;
&lt;h2 id=&quot;publishable-extract&quot;&gt;Publishable extract&lt;/h2&gt;
&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;We upgraded Keycloak across five major versions with a custom extension
installed. The upgrade succeeded. The extension stopped working. Nothing
failed.&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;We built two event listeners. One uses only stable SPI. The other calls
&lt;code&gt;KeycloakModelUtils.runJobInTransactionWithResult(...)&lt;/code&gt; — a helper any listener
wanting its own transaction would reach for, and one that our API diff shows
present in 25.0.6 and gone in 26.0.0.&lt;/p&gt;
&lt;p&gt;The stable one is the good news, and it is worth saying plainly: &lt;strong&gt;compiled
against 21.1.2, it runs correctly on 26.7.1.&lt;/strong&gt; Five majors, 619 removed public
signatures in between, and the same source compiles unchanged against every
version we tried. &quot;The customer has a custom SPI&quot; is not, on its own, a reason
to expect trouble.&lt;/p&gt;
&lt;p&gt;The other one is why you test. On Keycloak 26.7.1, carrying the jar built for
25.0.6:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;the server started in 15 seconds;&lt;/li&gt;
&lt;li&gt;the provider loaded and printed its startup line;&lt;/li&gt;
&lt;li&gt;it appeared in &lt;code&gt;/admin/serverinfo&lt;/code&gt; alongside the built-in listeners;&lt;/li&gt;
&lt;li&gt;the readiness check passed.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Every check an operator runs during a maintenance window passed. Then the first
event arrived:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;ERROR KC-SERVICES0085: Failed to send type to lab.LabBrittleEventListener:
  java.lang.NoSuchMethodError: KeycloakModelUtils.runJobInTransactionWithResult(...)
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The admin request that triggered it returned &lt;strong&gt;201&lt;/strong&gt;. The group was created.
The token endpoint returned &lt;strong&gt;200&lt;/strong&gt;. The container stayed up. And Keycloak&#39;s own
&lt;code&gt;admin_event_entity&lt;/code&gt; table kept recording normally — 1,015 rows — so anyone
auditing Keycloak&#39;s audit trail sees a complete one.&lt;/p&gt;
&lt;p&gt;If that listener was shipping your authentication events to a SIEM, the events
stopped and your dashboard says everything is fine.&lt;/p&gt;
&lt;p&gt;The check that catches this is not a smoke test. It is: &lt;strong&gt;fire one real event
after the upgrade and grep the log for &lt;code&gt;KC-SERVICES0085&lt;/code&gt;.&lt;/strong&gt;&lt;/p&gt;
&lt;/blockquote&gt;
</content>
        
    </entry>
    <entry xml:lang="en">
        <title>Rehearsal Record — 25.0.6 → 26.0.0</title>
        <published>2026-08-25T00:00:00+00:00</published>
        <updated>2026-08-25T00:00:00+00:00</updated>
        
        <author>
          <name>Unknown</name>
        </author>
        
        <link rel="alternate" type="text/html" href="https://ledger.mlabs.city/runs/2026-08-25-25.0.6-to-26.0.0-1/"/>
        <id>https://ledger.mlabs.city/runs/2026-08-25-25.0.6-to-26.0.0-1/</id>
        
        <content type="html" xml:base="https://ledger.mlabs.city/runs/2026-08-25-25.0.6-to-26.0.0-1/">&lt;h2 id=&quot;environment&quot;&gt;Environment&lt;/h2&gt;
&lt;table&gt;&lt;thead&gt;&lt;tr&gt;&lt;th&gt;Field&lt;/th&gt;&lt;th&gt;Value&lt;/th&gt;&lt;/tr&gt;&lt;/thead&gt;&lt;tbody&gt;
&lt;tr&gt;&lt;td&gt;Keycloak from → to&lt;/td&gt;&lt;td&gt;25.0.6 → 26.0.0&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Stepping path (if multi-hop)&lt;/td&gt;&lt;td&gt;none — direct&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Distribution&lt;/td&gt;&lt;td&gt;quay.io/keycloak/keycloak (official image), 680MB → 691MB&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Start mode&lt;/td&gt;&lt;td&gt;&lt;code&gt;start --http-enabled=true&lt;/code&gt; w/ external DB&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Database&lt;/td&gt;&lt;td&gt;PostgreSQL 16.15 (Debian pgdg13), single node, container&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Postgres tuning&lt;/td&gt;&lt;td&gt;&lt;strong&gt;stock&lt;/strong&gt; — &lt;code&gt;shared_buffers&lt;/code&gt; 128MB, &lt;code&gt;maintenance_work_mem&lt;/code&gt; 64MB, &lt;code&gt;statement_timeout&lt;/code&gt; 0. &lt;em&gt;Added retroactively 2026-08-25 to close a gap in our own records: read from the same unmodified &lt;code&gt;postgres:16&lt;/code&gt; image on the same box. The compose &lt;code&gt;db&lt;/code&gt; service sets no tuning, so stock is guaranteed by the file, not remembered.&lt;/em&gt;&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Dataset scale&lt;/td&gt;&lt;td&gt;1000 users · 2 realms (master + lab) · 10 clients · 1 group · 8 realm roles&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Seeding method&lt;/td&gt;&lt;td&gt;&lt;code&gt;partialImport&lt;/code&gt;, batch size 2000 (one batch), 25.2s wall clock&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Fixture profile&lt;/td&gt;&lt;td&gt;&lt;code&gt;typical&lt;/code&gt; — auth-flows, password-policy, lifespans, clients, client-scopes, themes, groups-roles, events, required-actions&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Topology&lt;/td&gt;&lt;td&gt;single container, single replica&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Host&lt;/td&gt;&lt;td&gt;Hetzner CCX33 (fsn1) — 8 vCPU dedicated, 30GB usable RAM, 226GB local NVMe, Debian 13, kernel 6.12.101+deb13-cloud-amd64&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;JVM heap&lt;/td&gt;&lt;td&gt;&lt;code&gt;-Xms1g -Xmx4g&lt;/code&gt;&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Elapsed clock&lt;/td&gt;&lt;td&gt;&lt;strong&gt;14s&lt;/strong&gt; total, container stop → first token issued&lt;/td&gt;&lt;/tr&gt;
&lt;/tbody&gt;&lt;/table&gt;
&lt;h2 id=&quot;procedure&quot;&gt;Procedure&lt;/h2&gt;
&lt;pre&gt;&lt;code&gt;./bin/lab-up.sh 25.0.6
./bin/seed-realm.sh --profile typical --users 1000
./bin/snapshot.sh baseline-25.0.6-typical-1k
./bin/upgrade.sh 26.0.0
&lt;/code&gt;&lt;/pre&gt;
&lt;h2 id=&quot;timings&quot;&gt;Timings&lt;/h2&gt;
&lt;table&gt;&lt;thead&gt;&lt;tr&gt;&lt;th&gt;Phase&lt;/th&gt;&lt;th&gt;Duration&lt;/th&gt;&lt;th&gt;Notes&lt;/th&gt;&lt;/tr&gt;&lt;/thead&gt;&lt;tbody&gt;
&lt;tr&gt;&lt;td&gt;Container start → DB connect&lt;/td&gt;&lt;td&gt;~3s&lt;/td&gt;&lt;td&gt;from stop to config load at 15:31:36&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Liquibase / schema migration&lt;/td&gt;&lt;td&gt;&lt;strong&gt;~0.8s&lt;/strong&gt;&lt;/td&gt;&lt;td&gt;15:31:39,198 &quot;Updating database&quot; → 15:31:39,992 &quot;Migrating older model to 26.0.0&quot;; runs on &lt;code&gt;(main)&lt;/code&gt;, i.e. &lt;strong&gt;before&lt;/strong&gt; the server reports ready&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Cache/realm warm&lt;/td&gt;&lt;td&gt;not separately measured&lt;/td&gt;&lt;td&gt;unknown&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;First successful token issue&lt;/td&gt;&lt;td&gt;within 14s of stop&lt;/td&gt;&lt;td&gt;&lt;code&gt;wait-ready.sh&lt;/code&gt; poll granularity is 3s, so this bounds it rather than measures it&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;&lt;strong&gt;Total to ready&lt;/strong&gt;&lt;/td&gt;&lt;td&gt;&lt;strong&gt;14s&lt;/strong&gt;&lt;/td&gt;&lt;td&gt;&lt;/td&gt;&lt;/tr&gt;
&lt;/tbody&gt;&lt;/table&gt;
&lt;p&gt;At this scale the migration is not the cost — process start is. The 14s is
dominated by JVM/Quarkus startup, not by schema work. &lt;strong&gt;This number&#39;s only use
is as the 1k anchor for a scale comparison; it says nothing about a real realm.&lt;/strong&gt;&lt;/p&gt;
&lt;h2 id=&quot;outcome&quot;&gt;Outcome&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;&lt;input disabled=&quot;&quot; type=&quot;checkbox&quot; checked=&quot;&quot;/&gt;
Reached ready&lt;/li&gt;
&lt;li&gt;&lt;input disabled=&quot;&quot; type=&quot;checkbox&quot; checked=&quot;&quot;/&gt;
Realm config intact — users 1000, clients 10, roles 8, auth flows 9, all unchanged across the upgrade&lt;/li&gt;
&lt;li&gt;&lt;input disabled=&quot;&quot; type=&quot;checkbox&quot; checked=&quot;&quot;/&gt;
Login flow works — &lt;code&gt;labuser7&lt;/code&gt; issued a token from the &lt;code&gt;lab&lt;/code&gt; realm post-upgrade&lt;/li&gt;
&lt;li&gt;&lt;input disabled=&quot;&quot; type=&quot;checkbox&quot;/&gt;
Custom SPI loaded — none in fixture, not exercised&lt;/li&gt;
&lt;li&gt;&lt;input disabled=&quot;&quot; type=&quot;checkbox&quot;/&gt;
Themes render — theme &lt;em&gt;names&lt;/em&gt; set, theme files not mounted; not exercised&lt;/li&gt;
&lt;li&gt;&lt;input disabled=&quot;&quot; type=&quot;checkbox&quot;/&gt;
Federation/brokering intact — not in &lt;code&gt;typical&lt;/code&gt; profile&lt;/li&gt;
&lt;/ul&gt;
&lt;h2 id=&quot;breakage-observed&quot;&gt;Breakage observed&lt;/h2&gt;
&lt;h3 id=&quot;hostname-v1-option-rejected-at-error-level-but-startup-continues&quot;&gt;Hostname v1 option rejected at ERROR level, but startup continues&lt;/h3&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Symptom:&lt;/strong&gt; &lt;code&gt;ERROR [org.keycloak.quarkus.runtime.configuration.mappers.PropertyMappers] (main) Hostname v1 options [hostname-strict-https] are still in use, please review your configuration&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Evidence:&lt;/strong&gt; container log 2026-08-25 15:31:36,186. Also &lt;code&gt;WARN ... The following run time options were found, but will be ignored during build time: kc.spi-hostname-v2-hostname-strict&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Cause:&lt;/strong&gt; &lt;strong&gt;confirmed&lt;/strong&gt; — &lt;code&gt;KC_HOSTNAME_STRICT_HTTPS&lt;/code&gt; is a hostname-v1 option; Keycloak 26 moved to hostname v2. The lab&#39;s compose file sets it for cross-version compatibility.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Fix or workaround:&lt;/strong&gt; drop &lt;code&gt;KC_HOSTNAME_STRICT_HTTPS&lt;/code&gt; for 26+. It is a no-op there.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Would this hit a customer?&lt;/strong&gt; &lt;strong&gt;Yes, and it is config-dependent, not scale-dependent.&lt;/strong&gt; Anyone carrying a 25-era hostname config into 26 hits this. It logs at ERROR, which will trip log-based alerting and read as a failed upgrade to an operator watching the console — while the server is in fact healthy. That gap between &quot;looks failed&quot; and &quot;is fine&quot; is the operationally expensive part.&lt;/li&gt;
&lt;/ul&gt;
&lt;h3 id=&quot;two-jdbc-resource-leak-warnings-during-migration&quot;&gt;Two JDBC resource leak warnings during migration&lt;/h3&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Symptom:&lt;/strong&gt; &lt;code&gt;WARN [io.agroal.pool] Datasource &#39;&amp;lt;default&amp;gt;&#39;: JDBC resources leaked: 3 ResultSet(s)&lt;/code&gt; and &lt;code&gt;16 ResultSet(s)&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Evidence:&lt;/strong&gt; log 15:31:39,738 and 15:31:40,957, both during the Liquibase phase&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Cause:&lt;/strong&gt; &lt;strong&gt;suspected&lt;/strong&gt; — Liquibase/Agroal interaction during schema update. Not investigated.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Fix or workaround:&lt;/strong&gt; none applied; no observed effect.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Would this hit a customer?&lt;/strong&gt; Almost certainly universal on this path, and almost certainly cosmetic. Worth knowing only so it is not mistaken for the cause of a real failure.&lt;/li&gt;
&lt;/ul&gt;
&lt;h2 id=&quot;rollback&quot;&gt;Rollback&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Exercised this run?&lt;/strong&gt; &lt;strong&gt;no&lt;/strong&gt;&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Method:&lt;/strong&gt; snapshot taken (&lt;code&gt;baseline-25.0.6-typical-1k&lt;/code&gt;, 500K) but not restored&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Time to restore:&lt;/strong&gt; unknown&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Data loss / divergence:&lt;/strong&gt; unknown&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Verdict:&lt;/strong&gt; &lt;strong&gt;not yet established for this path.&lt;/strong&gt; The 3-clean-runs gate requires at least one rollback; this run does not satisfy it.&lt;/li&gt;
&lt;/ul&gt;
&lt;h2 id=&quot;verification&quot;&gt;Verification&lt;/h2&gt;
&lt;table&gt;&lt;thead&gt;&lt;tr&gt;&lt;th&gt;Claim&lt;/th&gt;&lt;th&gt;Primary source&lt;/th&gt;&lt;th&gt;Checked&lt;/th&gt;&lt;/tr&gt;&lt;/thead&gt;&lt;tbody&gt;
&lt;tr&gt;&lt;td&gt;&lt;code&gt;KC_HOSTNAME_STRICT_HTTPS&lt;/code&gt; removed / v1 in KC26&lt;/td&gt;&lt;td&gt;Keycloak 26 upgrade guide — &lt;strong&gt;NOT YET CHECKED&lt;/strong&gt;&lt;/td&gt;&lt;td&gt;☐&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Admin env var rename &lt;code&gt;KEYCLOAK_ADMIN*&lt;/code&gt; → &lt;code&gt;KC_BOOTSTRAP_ADMIN_*&lt;/code&gt;&lt;/td&gt;&lt;td&gt;not exercised — compose sets both, so this run cannot confirm it&lt;/td&gt;&lt;td&gt;☐&lt;/td&gt;&lt;/tr&gt;
&lt;/tbody&gt;&lt;/table&gt;
&lt;p&gt;&lt;strong&gt;This record is not publishable until the two rows above are filled.&lt;/strong&gt; The
compose file deliberately sets both admin env var schemes, which means this run
says &lt;em&gt;nothing&lt;/em&gt; about the rename that the version matrix lists as the headline
breaking change for this path. A run that isolates it is still needed.&lt;/p&gt;
&lt;h2 id=&quot;publishable-extract&quot;&gt;Publishable extract&lt;/h2&gt;
&lt;p&gt;&lt;em&gt;Withheld pending the Verification table above.&lt;/em&gt;&lt;/p&gt;
</content>
        
    </entry>
    <entry xml:lang="en">
        <title>Rehearsal Record — 26.0.0 → 26.7.1</title>
        <published>2026-08-25T00:00:00+00:00</published>
        <updated>2026-08-25T00:00:00+00:00</updated>
        
        <author>
          <name>Unknown</name>
        </author>
        
        <link rel="alternate" type="text/html" href="https://ledger.mlabs.city/runs/2026-08-25-26.0.0-to-26.7.1-1/"/>
        <id>https://ledger.mlabs.city/runs/2026-08-25-26.0.0-to-26.7.1-1/</id>
        
        <content type="html" xml:base="https://ledger.mlabs.city/runs/2026-08-25-26.0.0-to-26.7.1-1/">&lt;h2 id=&quot;environment&quot;&gt;Environment&lt;/h2&gt;
&lt;p&gt;Identical to &lt;code&gt;2026-08-25-25.0.6-to-26.0.0-1&lt;/code&gt; (same box, same fixture, same
container) except:&lt;/p&gt;
&lt;table&gt;&lt;thead&gt;&lt;tr&gt;&lt;th&gt;Field&lt;/th&gt;&lt;th&gt;Value&lt;/th&gt;&lt;/tr&gt;&lt;/thead&gt;&lt;tbody&gt;
&lt;tr&gt;&lt;td&gt;Keycloak from → to&lt;/td&gt;&lt;td&gt;26.0.0 → 26.7.1&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Distribution&lt;/td&gt;&lt;td&gt;quay.io/keycloak/keycloak 691MB → 766MB&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Dataset scale&lt;/td&gt;&lt;td&gt;unchanged: 1000 users, carried through the previous upgrade&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Prior state&lt;/td&gt;&lt;td&gt;realm had already been migrated 25.0.6 → 26.0.0 in the run above&lt;/td&gt;&lt;/tr&gt;
&lt;/tbody&gt;&lt;/table&gt;
&lt;p&gt;&lt;strong&gt;Caveat on comparability:&lt;/strong&gt; this realm reached 26.0.0 by migration, not by a
clean 26.0.0 install. A realm that was &lt;em&gt;born&lt;/em&gt; at 26.0.0 may migrate differently.
Stated because our fixture-comparability rule makes differently-shaped fixtures
non-aggregatable, and this is a shape difference.&lt;/p&gt;
&lt;h2 id=&quot;procedure&quot;&gt;Procedure&lt;/h2&gt;
&lt;pre&gt;&lt;code&gt;./bin/snapshot.sh baseline-26.0.0-typical-1k
./bin/upgrade.sh 26.7.1
&lt;/code&gt;&lt;/pre&gt;
&lt;h2 id=&quot;timings&quot;&gt;Timings&lt;/h2&gt;
&lt;table&gt;&lt;thead&gt;&lt;tr&gt;&lt;th&gt;Phase&lt;/th&gt;&lt;th&gt;Duration&lt;/th&gt;&lt;th&gt;Notes&lt;/th&gt;&lt;/tr&gt;&lt;/thead&gt;&lt;tbody&gt;
&lt;tr&gt;&lt;td&gt;Container start → server &quot;started&quot;&lt;/td&gt;&lt;td&gt;&lt;strong&gt;3.029s&lt;/strong&gt;&lt;/td&gt;&lt;td&gt;Quarkus self-report, 15:32:28,510&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Liquibase / schema migration&lt;/td&gt;&lt;td&gt;&lt;strong&gt;~3.3s&lt;/strong&gt;&lt;/td&gt;&lt;td&gt;15:32:29,111 &quot;Updating database&quot; → 15:32:32,414 reaching 26.7.0&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Model migration chain&lt;/td&gt;&lt;td&gt;within the above&lt;/td&gt;&lt;td&gt;stepped internally through 26.3.0 → 26.4.0 → 26.4.3 → 26.6.1 → 26.6.2 → 26.7.0, each migrating both &lt;code&gt;lab&lt;/code&gt; and &lt;code&gt;master&lt;/code&gt;&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;&lt;strong&gt;Total to ready&lt;/strong&gt;&lt;/td&gt;&lt;td&gt;&lt;strong&gt;not captured&lt;/strong&gt;&lt;/td&gt;&lt;td&gt;operator error — the elapsed line was filtered out of the captured output. Re-run required for a clean total.&lt;/td&gt;&lt;/tr&gt;
&lt;/tbody&gt;&lt;/table&gt;
&lt;h2 id=&quot;outcome&quot;&gt;Outcome&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;&lt;input disabled=&quot;&quot; type=&quot;checkbox&quot; checked=&quot;&quot;/&gt;
Reached ready&lt;/li&gt;
&lt;li&gt;&lt;input disabled=&quot;&quot; type=&quot;checkbox&quot; checked=&quot;&quot;/&gt;
Realm config intact — 1000 users&lt;/li&gt;
&lt;li&gt;&lt;input disabled=&quot;&quot; type=&quot;checkbox&quot; checked=&quot;&quot;/&gt;
Login flow works — &lt;code&gt;labuser7&lt;/code&gt; token issued&lt;/li&gt;
&lt;li&gt;&lt;input disabled=&quot;&quot; type=&quot;checkbox&quot;/&gt;
Custom SPI / themes / federation — not in fixture&lt;/li&gt;
&lt;/ul&gt;
&lt;h2 id=&quot;breakage-observed&quot;&gt;Breakage observed&lt;/h2&gt;
&lt;h3 id=&quot;schema-migration-ran-after-the-server-reported-started&quot;&gt;Schema migration ran AFTER the server reported &quot;started&quot;&lt;/h3&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Symptom:&lt;/strong&gt; Quarkus logged &lt;code&gt;started in 3.029s. Listening on: http://0.0.0.0:8080&lt;/code&gt; at 15:32:28,510 — then &lt;code&gt;Updating database&lt;/code&gt; began at 15:32:29,111 on &lt;code&gt;executor-thread-1&lt;/code&gt;, finishing ~15:32:32,4. On the 25.0.6 → 26.0.0 run the same work ran on &lt;code&gt;(main)&lt;/code&gt; &lt;em&gt;before&lt;/em&gt; ready.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Evidence:&lt;/strong&gt; container log, both runs, timestamps above.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Cause:&lt;/strong&gt; &lt;strong&gt;suspected, NOT confirmed.&lt;/strong&gt; Two candidate explanations and this run cannot distinguish them: (a) 26.7.x defers schema migration out of the startup path, or (b) migration is lazily triggered by the first inbound request, and the trigger here was &lt;code&gt;wait-ready.sh&lt;/code&gt; polling the token endpoint. The &lt;code&gt;executor-thread-1&lt;/code&gt; context is consistent with (b).&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Fix or workaround:&lt;/strong&gt; none — behaviour, not a fault.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Would this hit a customer?&lt;/strong&gt; &lt;strong&gt;Potentially serious if (a) holds.&lt;/strong&gt; A server that reports started and opens its port while the schema is still migrating will pass a naive TCP or &lt;code&gt;/health/started&lt;/code&gt; check, so a load balancer or Kubernetes readiness probe could route production traffic mid-migration. That is precisely the Infinispan/rolling-update failure class the ledger already lists.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;RESOLVED 2026-08-25 by &lt;code&gt;2026-08-25-26.0.0-to-26.7.1-2&lt;/code&gt;: explanation (a) holds — migration runs unprompted, confirmed from Postgres with the server left untouched for 120s. But the &lt;em&gt;consequence&lt;/em&gt; stated here was wrong: &lt;code&gt;/health/ready&lt;/code&gt; correctly reports 503 throughout, so a readiness probe is safe. The exposure is &lt;code&gt;/health/started&lt;/code&gt; and TCP port checks. See that record.&lt;/strong&gt;&lt;/li&gt;
&lt;li&gt;&lt;del&gt;NEXT ACTION — this is the highest-value follow-up from today.&lt;/del&gt; Re-run with no client polling at all, watching the log passively, to establish which explanation holds. It is cheap, and if (a) is true it is publishable ledger content that nobody else has measured.&lt;/li&gt;
&lt;/ul&gt;
&lt;h3 id=&quot;hostname-v1-warning-changed-emission-path-between-26-0-0-and-26-7-1&quot;&gt;Hostname v1 warning changed emission path between 26.0.0 and 26.7.1&lt;/h3&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Symptom:&lt;/strong&gt; same message, different form. 26.0.0: &lt;code&gt;ERROR [org.keycloak.quarkus.runtime.configuration.mappers.PropertyMappers] (main) Hostname v1 options [hostname-strict-https] are still in use&lt;/code&gt;. 26.7.1: &lt;code&gt;WARNING: Hostname v1 options [hostname-strict-https] are still in use&lt;/code&gt; — no timestamp, no logger name, i.e. emitted by the CLI layer rather than the logging subsystem.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Evidence:&lt;/strong&gt; logs from both runs, quoted above.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Cause:&lt;/strong&gt; &lt;strong&gt;suspected&lt;/strong&gt; — the message moved from the logger to CLI-level output between these versions. Not confirmed against release notes.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Would this hit a customer?&lt;/strong&gt; Only as an alerting artifact: anyone who wrote a log-scraping rule against the 26.0.0 ERROR form will silently stop matching on 26.7.x. Minor, but it is exactly the kind of thing that erodes trust in an alerting pipeline.&lt;/li&gt;
&lt;li&gt;Additionally on 26.7.1: &lt;code&gt;WARNING: With HTTPS not enabled, proxy-headers unset, and hostname-strict=false, the server is running in an insecure context.&lt;/code&gt; Expected for this lab config; would be a real finding on a customer system.&lt;/li&gt;
&lt;/ul&gt;
&lt;h2 id=&quot;rollback&quot;&gt;Rollback&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Exercised this run?&lt;/strong&gt; &lt;strong&gt;no&lt;/strong&gt;&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Method:&lt;/strong&gt; snapshot &lt;code&gt;baseline-26.0.0-typical-1k&lt;/code&gt; exists, not restored&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Verdict:&lt;/strong&gt; not established.&lt;/li&gt;
&lt;/ul&gt;
&lt;h2 id=&quot;verification&quot;&gt;Verification&lt;/h2&gt;
&lt;table&gt;&lt;thead&gt;&lt;tr&gt;&lt;th&gt;Claim&lt;/th&gt;&lt;th&gt;Primary source&lt;/th&gt;&lt;th&gt;Checked&lt;/th&gt;&lt;/tr&gt;&lt;/thead&gt;&lt;tbody&gt;
&lt;tr&gt;&lt;td&gt;26.7.x defers schema migration out of the startup path&lt;/td&gt;&lt;td&gt;&lt;strong&gt;UNRESOLVED — see NEXT ACTION above&lt;/strong&gt;&lt;/td&gt;&lt;td&gt;☐&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Hostname v1 message moved from logger to CLI output&lt;/td&gt;&lt;td&gt;Keycloak release notes 26.1–26.7 — not checked&lt;/td&gt;&lt;td&gt;☐&lt;/td&gt;&lt;/tr&gt;
&lt;/tbody&gt;&lt;/table&gt;
&lt;h2 id=&quot;publishable-extract&quot;&gt;Publishable extract&lt;/h2&gt;
&lt;p&gt;&lt;em&gt;Withheld — the headline observation is explicitly unconfirmed.&lt;/em&gt;&lt;/p&gt;
</content>
        
    </entry>
    <entry xml:lang="en">
        <title>Rehearsal Record — 26.0.0 → 26.7.1 at 100k</title>
        <published>2026-08-25T00:00:00+00:00</published>
        <updated>2026-08-25T00:00:00+00:00</updated>
        
        <author>
          <name>Unknown</name>
        </author>
        
        <link rel="alternate" type="text/html" href="https://ledger.mlabs.city/runs/2026-08-25-26.0.0-to-26.7.1-100k/"/>
        <id>https://ledger.mlabs.city/runs/2026-08-25-26.0.0-to-26.7.1-100k/</id>
        
        <content type="html" xml:base="https://ledger.mlabs.city/runs/2026-08-25-26.0.0-to-26.7.1-100k/">&lt;h2 id=&quot;environment&quot;&gt;Environment&lt;/h2&gt;
&lt;table&gt;&lt;thead&gt;&lt;tr&gt;&lt;th&gt;Field&lt;/th&gt;&lt;th&gt;Value&lt;/th&gt;&lt;/tr&gt;&lt;/thead&gt;&lt;tbody&gt;
&lt;tr&gt;&lt;td&gt;Keycloak from → to&lt;/td&gt;&lt;td&gt;26.0.0 → 26.7.1&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Distribution&lt;/td&gt;&lt;td&gt;quay.io/keycloak/keycloak official image&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Start mode&lt;/td&gt;&lt;td&gt;&lt;code&gt;start --http-enabled=true&lt;/code&gt; w/ external DB&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Database&lt;/td&gt;&lt;td&gt;PostgreSQL 16.15, single node, container&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;&lt;strong&gt;Postgres tuning&lt;/strong&gt;&lt;/td&gt;&lt;td&gt;&lt;strong&gt;stock&lt;/strong&gt; — &lt;code&gt;shared_buffers&lt;/code&gt; 128MB, &lt;code&gt;maintenance_work_mem&lt;/code&gt; 64MB, &lt;code&gt;statement_timeout&lt;/code&gt; 0&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Dataset scale&lt;/td&gt;&lt;td&gt;&lt;strong&gt;100,002 users&lt;/strong&gt; · 2 realms · 10 clients · 8 roles · 100,001 credentials · 200,001 user attributes · &lt;strong&gt;100,004 admin events&lt;/strong&gt;&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;DB size&lt;/td&gt;&lt;td&gt;264 MB before, 265 MB after&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Seeding method&lt;/td&gt;&lt;td&gt;&lt;code&gt;partialImport&lt;/code&gt;, batch 2000 — &lt;strong&gt;5,235s (87 min)&lt;/strong&gt; for 100k, i.e. 19.1 users/s&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Fixture profile&lt;/td&gt;&lt;td&gt;&lt;code&gt;typical&lt;/code&gt;&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Topology&lt;/td&gt;&lt;td&gt;single container&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Host&lt;/td&gt;&lt;td&gt;Hetzner CCX33 (fsn1) — 8 vCPU dedicated, 30GB RAM, local NVMe&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;JVM heap&lt;/td&gt;&lt;td&gt;&lt;code&gt;-Xms1g -Xmx4g&lt;/code&gt;&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Adverse scenario&lt;/td&gt;&lt;td&gt;none&lt;/td&gt;&lt;/tr&gt;
&lt;/tbody&gt;&lt;/table&gt;
&lt;h2 id=&quot;timings-and-the-finding&quot;&gt;Timings — and the finding&lt;/h2&gt;
&lt;table&gt;&lt;thead&gt;&lt;tr&gt;&lt;th&gt;Phase&lt;/th&gt;&lt;th&gt;1k (&lt;code&gt;…-26.0.0-to-26.7.1-2&lt;/code&gt;)&lt;/th&gt;&lt;th&gt;&lt;strong&gt;100k (this run)&lt;/strong&gt;&lt;/th&gt;&lt;/tr&gt;&lt;/thead&gt;&lt;tbody&gt;
&lt;tr&gt;&lt;td&gt;Quarkus &lt;code&gt;started&lt;/code&gt;&lt;/td&gt;&lt;td&gt;11.4s&lt;/td&gt;&lt;td&gt;10.5s&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Liquibase migration&lt;/td&gt;&lt;td&gt;12.2s → 16.2s = &lt;strong&gt;3.97s&lt;/strong&gt;&lt;/td&gt;&lt;td&gt;13.0s → 16.8s = &lt;strong&gt;3.79s&lt;/strong&gt;&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Total to ready&lt;/td&gt;&lt;td&gt;17.0s&lt;/td&gt;&lt;td&gt;&lt;strong&gt;15.4s&lt;/strong&gt;&lt;/td&gt;&lt;/tr&gt;
&lt;/tbody&gt;&lt;/table&gt;
&lt;p&gt;&lt;strong&gt;A 100× increase in users produced no increase in migration time.&lt;/strong&gt; The 100k run
was marginally &lt;em&gt;faster&lt;/em&gt;, which is run-to-run noise. This is not a small
difference from expectation — it is the opposite of what the lab was built to
find.&lt;/p&gt;
&lt;h3 id=&quot;why-corrected-2026-08-25-by-2026-08-25-index-skip-threshold&quot;&gt;Why — CORRECTED 2026-08-25 by &lt;a href=&quot;/runs/2026-08-25-index-skip-threshold/&quot;&gt;&lt;code&gt;2026-08-25-index-skip-threshold&lt;/code&gt;&lt;/a&gt;&lt;/h3&gt;
&lt;p&gt;&lt;strong&gt;The explanation below is wrong and is kept for the record.&lt;/strong&gt; It was built by
reading the &lt;em&gt;descriptions&lt;/em&gt; of the last 22 of 67 changesets and generalising from
the sample. Reading all 67 from source shows nine operations that do touch
user-scaled tables, including &lt;code&gt;createIndex&lt;/code&gt; on &lt;code&gt;USER_ENTITY&lt;/code&gt; and on
&lt;code&gt;EVENT_ENTITY&lt;/code&gt;.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;The real mechanism:&lt;/strong&gt; Keycloak &lt;em&gt;deliberately skips&lt;/em&gt; index creation on tables
above 300,000 rows, logs a WARN, and records the changeset as &lt;code&gt;EXECUTED&lt;/code&gt;. The
migration is flat because the expensive work does not happen — not because there
is no expensive work. That behaviour is far more valuable than the finding it
replaces, and it was only found because the prediction tool disagreed with the
measurement.&lt;/p&gt;
&lt;h3 id=&quot;original-incorrect-explanation-migration-cost-is-o-schema-change-not-o-rows&quot;&gt;Original (incorrect) explanation: migration cost is O(schema change), not O(rows)&lt;/h3&gt;
&lt;p&gt;Reading the 67 changesets that ran, &lt;strong&gt;not one rewrites user data.&lt;/strong&gt; They are:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;createTable&lt;/code&gt; on &lt;strong&gt;new, empty&lt;/strong&gt; tables — CLUSTER_EVENT, LOGIN_FAILURE,
SINGLE_USE_OBJECT, AUTH_SESSION, ROOT_AUTH_SESSION, ISSUED_VER_CREDENTIAL,
OUTBOX_ENTRY, USER_VER_CREDENTIAL, FED_USER_VER_CREDENTIAL&lt;/li&gt;
&lt;li&gt;&lt;code&gt;createIndex&lt;/code&gt; on those same empty tables&lt;/li&gt;
&lt;li&gt;&lt;code&gt;addColumn&lt;/code&gt; on &lt;strong&gt;CLIENT&lt;/strong&gt; (10 rows) and &lt;strong&gt;REALM&lt;/strong&gt; (2 rows)&lt;/li&gt;
&lt;li&gt;&lt;code&gt;addColumn&lt;/code&gt; on USER_CONSENT_CLIENT_SCOPE (0 rows)&lt;/li&gt;
&lt;li&gt;one &lt;code&gt;delete&lt;/code&gt; on REALM_ATTRIBUTE (tiny)&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;The only reference to a large table is
&lt;code&gt;addForeignKeyConstraint ... referencedTableName=USER_ENTITY&lt;/code&gt; on a &lt;strong&gt;new empty
table&lt;/strong&gt; — validating an empty child, not scanning the parent.&lt;/p&gt;
&lt;p&gt;So the duration is set by &lt;strong&gt;how many DDL statements run and what they touch&lt;/strong&gt;,
not by how many users exist. For this version pair the answer is &quot;nothing
large&quot;, and it would take ~4 seconds on a realm of any size.&lt;/p&gt;
&lt;h2 id=&quot;outcome&quot;&gt;Outcome&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;&lt;input disabled=&quot;&quot; type=&quot;checkbox&quot; checked=&quot;&quot;/&gt;
Reached ready · [x] 100,002 users intact · [x] &lt;code&gt;MIGRATION_MODEL&lt;/code&gt; → 26.7.1&lt;/li&gt;
&lt;li&gt;&lt;input disabled=&quot;&quot; type=&quot;checkbox&quot; checked=&quot;&quot;/&gt;
&lt;code&gt;DATABASECHANGELOG&lt;/code&gt; 144 → 211 · [x] end-user login OK&lt;/li&gt;
&lt;li&gt;&lt;input disabled=&quot;&quot; type=&quot;checkbox&quot; checked=&quot;&quot;/&gt;
&lt;code&gt;/health/ready&lt;/code&gt; behaved exactly as at 1k: 503 until migration finished&lt;/li&gt;
&lt;/ul&gt;
&lt;h2 id=&quot;breakage-observed&quot;&gt;Breakage observed&lt;/h2&gt;
&lt;p&gt;None. Confirms the started-before-ready timing at 100k: &lt;code&gt;/health/started&lt;/code&gt;
returned 200 at 10.5s while &lt;code&gt;/health/ready&lt;/code&gt; stayed 503 until 15.4s — a &lt;strong&gt;4.9s&lt;/strong&gt;
window, essentially identical to the 5.6s measured at 1k, for the same reason
the migration did not grow.&lt;/p&gt;
&lt;h2 id=&quot;verification&quot;&gt;Verification&lt;/h2&gt;
&lt;table&gt;&lt;thead&gt;&lt;tr&gt;&lt;th&gt;Claim&lt;/th&gt;&lt;th&gt;Primary source&lt;/th&gt;&lt;th&gt;Checked&lt;/th&gt;&lt;/tr&gt;&lt;/thead&gt;&lt;tbody&gt;
&lt;tr&gt;&lt;td&gt;No changeset rewrites user data on this path&lt;/td&gt;&lt;td&gt;&lt;code&gt;DATABASECHANGELOG.description&lt;/code&gt; for all 67, read directly&lt;/td&gt;&lt;td&gt;☑&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Migration time flat 1k → 100k&lt;/td&gt;&lt;td&gt;Two instrumented runs, same box, same fixture profile&lt;/td&gt;&lt;td&gt;☑&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Holds at 2M&lt;/td&gt;&lt;td&gt;not tested — 100k may simply be too small to expose an effect&lt;/td&gt;&lt;td&gt;☐&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Holds for pairs that DO touch user tables&lt;/td&gt;&lt;td&gt;&lt;strong&gt;not tested — this is the open question&lt;/strong&gt;&lt;/td&gt;&lt;td&gt;☐&lt;/td&gt;&lt;/tr&gt;
&lt;/tbody&gt;&lt;/table&gt;
&lt;h2 id=&quot;publishable-extract&quot;&gt;Publishable extract&lt;/h2&gt;
&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;We upgraded a 1,000-user Keycloak and a 100,000-user Keycloak on identical
hardware. The schema migration took the same four seconds.&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;26.0.0 → 26.7.1, official image, Postgres 16.15 at stock settings, Hetzner
CCX33 (8 vCPU dedicated, 32 GB, local NVMe), JVM &lt;code&gt;-Xms1g -Xmx4g&lt;/code&gt;. At 1,000
users the Liquibase phase ran 3.97s; at 100,002 users, 3.79s. Total time to
serving traffic was 17.0s and 15.4s respectively — the larger realm was
marginally faster, which is noise.&lt;/p&gt;
&lt;p&gt;We expected the opposite, so we read all 67 changesets. None of them rewrite
user data. They create new empty tables, index those new tables, and add
columns to CLIENT (10 rows) and REALM (2 rows). The only mention of
USER_ENTITY is a foreign key &lt;em&gt;from&lt;/em&gt; a new empty table.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;SUPERSEDED.&lt;/strong&gt; The real reason is in
&lt;a href=&quot;/runs/2026-08-25-index-skip-threshold/&quot;&gt;&lt;code&gt;2026-08-25-index-skip-threshold&lt;/code&gt;&lt;/a&gt;: Keycloak skips index creation above
300,000 rows and records the changeset as executed anyway. Migration is flat
because the expensive work is declined, not because it is absent. Use that
extract, not this one.&lt;/p&gt;
&lt;/blockquote&gt;
</content>
        
    </entry>
    <entry xml:lang="en">
        <title>Rehearsal Record — 26.0.0 → 26.7.1 (instrumented)</title>
        <published>2026-08-25T00:00:00+00:00</published>
        <updated>2026-08-25T00:00:00+00:00</updated>
        
        <author>
          <name>Unknown</name>
        </author>
        
        <link rel="alternate" type="text/html" href="https://ledger.mlabs.city/runs/2026-08-25-26.0.0-to-26.7.1-2/"/>
        <id>https://ledger.mlabs.city/runs/2026-08-25-26.0.0-to-26.7.1-2/</id>
        
        <content type="html" xml:base="https://ledger.mlabs.city/runs/2026-08-25-26.0.0-to-26.7.1-2/">&lt;h2 id=&quot;environment&quot;&gt;Environment&lt;/h2&gt;
&lt;p&gt;Identical to attempt 1, except the management port (9000) is now published to
loopback so the health endpoints can be observed. The realm reached 26.0.0 by
restoring &lt;code&gt;baseline-26.0.0-typical-1k&lt;/code&gt;, so the fixture is byte-identical to
attempt 1&#39;s starting point rather than merely similar.&lt;/p&gt;
&lt;table&gt;&lt;thead&gt;&lt;tr&gt;&lt;th&gt;Field&lt;/th&gt;&lt;th&gt;Value&lt;/th&gt;&lt;/tr&gt;&lt;/thead&gt;&lt;tbody&gt;
&lt;tr&gt;&lt;td&gt;Keycloak from → to&lt;/td&gt;&lt;td&gt;26.0.0 → 26.7.1&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Database&lt;/td&gt;&lt;td&gt;PostgreSQL 16.15, single node&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Postgres tuning&lt;/td&gt;&lt;td&gt;&lt;strong&gt;stock&lt;/strong&gt; — &lt;code&gt;shared_buffers&lt;/code&gt; 128MB, &lt;code&gt;maintenance_work_mem&lt;/code&gt; 64MB, &lt;code&gt;statement_timeout&lt;/code&gt; 0. &lt;em&gt;Added retroactively 2026-08-25 to close a gap in our own records: read from the same unmodified &lt;code&gt;postgres:16&lt;/code&gt; image on the same box. The compose &lt;code&gt;db&lt;/code&gt; service sets no tuning, so stock is guaranteed by the file, not remembered.&lt;/em&gt;&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Dataset scale&lt;/td&gt;&lt;td&gt;1000 users · 2 realms · 10 clients · 8 roles&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Fixture profile&lt;/td&gt;&lt;td&gt;&lt;code&gt;typical&lt;/code&gt;&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Host&lt;/td&gt;&lt;td&gt;Hetzner CCX33 (fsn1) — 8 vCPU dedicated, 30GB RAM, local NVMe&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;JVM heap&lt;/td&gt;&lt;td&gt;&lt;code&gt;-Xms1g -Xmx4g&lt;/code&gt;&lt;/td&gt;&lt;/tr&gt;
&lt;/tbody&gt;&lt;/table&gt;
&lt;h2 id=&quot;procedure&quot;&gt;Procedure&lt;/h2&gt;
&lt;p&gt;Two experiments.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;A — does migration require a client request?&lt;/strong&gt; Restore 26.0.0, swap the image
to 26.7.1, start it, and issue &lt;strong&gt;zero requests for 120 seconds&lt;/strong&gt;. Measure the
schema from Postgres directly, not from the application.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;B — what does the server serve during migration?&lt;/strong&gt; Same restore and swap, then
poll &lt;code&gt;/health/started&lt;/code&gt;, &lt;code&gt;/health/ready&lt;/code&gt; (port 9000) and the master-realm token
endpoint (port 8080) continuously from T0, printing only state transitions.&lt;/p&gt;
&lt;h2 id=&quot;findings&quot;&gt;Findings&lt;/h2&gt;
&lt;h3 id=&quot;1-schema-migration-runs-unprompted-confirmed&quot;&gt;1. Schema migration runs unprompted — CONFIRMED&lt;/h3&gt;
&lt;p&gt;With no client contact whatsoever for 120s:&lt;/p&gt;
&lt;table&gt;&lt;thead&gt;&lt;tr&gt;&lt;th&gt;&lt;/th&gt;&lt;th&gt;before&lt;/th&gt;&lt;th&gt;after&lt;/th&gt;&lt;/tr&gt;&lt;/thead&gt;&lt;tbody&gt;
&lt;tr&gt;&lt;td&gt;&lt;code&gt;DATABASECHANGELOG&lt;/code&gt; rows&lt;/td&gt;&lt;td&gt;144&lt;/td&gt;&lt;td&gt;&lt;strong&gt;211&lt;/strong&gt;&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;latest changeset&lt;/td&gt;&lt;td&gt;&lt;code&gt;26.0.0-33201-org-redirect-url&lt;/code&gt;&lt;/td&gt;&lt;td&gt;&lt;code&gt;26.7.0-cluster-event&lt;/code&gt;&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;&lt;code&gt;MIGRATION_MODEL&lt;/code&gt;&lt;/td&gt;&lt;td&gt;26.0.0&lt;/td&gt;&lt;td&gt;&lt;strong&gt;26.7.1&lt;/strong&gt;&lt;/td&gt;&lt;/tr&gt;
&lt;/tbody&gt;&lt;/table&gt;
&lt;p&gt;&lt;strong&gt;This settles attempt 1&#39;s open question.&lt;/strong&gt; Migration is performed on
&lt;code&gt;executor-thread-1&lt;/code&gt; after startup completes, and is &lt;em&gt;not&lt;/em&gt; triggered by a first
inbound request — the lazy-trigger hypothesis is refuted. Measured from
Postgres, so it does not depend on trusting the application&#39;s own logging.&lt;/p&gt;
&lt;h3 id=&quot;2-timeline-t0-container-create-15-48-19-utc&quot;&gt;2. Timeline (T0 = container create, 15:48:19 UTC)&lt;/h3&gt;
&lt;table&gt;&lt;thead&gt;&lt;tr&gt;&lt;th&gt;Elapsed&lt;/th&gt;&lt;th&gt;Event&lt;/th&gt;&lt;/tr&gt;&lt;/thead&gt;&lt;tbody&gt;
&lt;tr&gt;&lt;td&gt;0.8s&lt;/td&gt;&lt;td&gt;port 8080 not yet listening — connection refused&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;&lt;strong&gt;11.4s&lt;/strong&gt;&lt;/td&gt;&lt;td&gt;Quarkus: &lt;code&gt;started in 3.499s. Listening on: http://0.0.0.0:8080&lt;/code&gt;&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;11.4s&lt;/td&gt;&lt;td&gt;&lt;code&gt;/health/started&lt;/code&gt; &lt;strong&gt;000&lt;/strong&gt; · &lt;code&gt;/health/ready&lt;/code&gt; &lt;strong&gt;503&lt;/strong&gt; · token &lt;strong&gt;503&lt;/strong&gt;&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;&lt;strong&gt;11.5s&lt;/strong&gt;&lt;/td&gt;&lt;td&gt;&lt;code&gt;/health/started&lt;/code&gt; → &lt;strong&gt;200&lt;/strong&gt; · ready &lt;strong&gt;503&lt;/strong&gt; · token &lt;strong&gt;503&lt;/strong&gt;&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;12.2s&lt;/td&gt;&lt;td&gt;&lt;code&gt;Updating database. Using changelog META-INF/jpa-changelog-master.xml&lt;/code&gt;&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;16.2s&lt;/td&gt;&lt;td&gt;&lt;code&gt;Migrating older model to 26.7.0&lt;/code&gt; (last of six model steps)&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;16.6s&lt;/td&gt;&lt;td&gt;started 200 · ready &lt;strong&gt;503&lt;/strong&gt; · token → &lt;strong&gt;200&lt;/strong&gt;&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;&lt;strong&gt;17.0s&lt;/strong&gt;&lt;/td&gt;&lt;td&gt;started 200 · ready → &lt;strong&gt;200&lt;/strong&gt; · token 200 — fully serving&lt;/td&gt;&lt;/tr&gt;
&lt;/tbody&gt;&lt;/table&gt;
&lt;p&gt;T0 includes container recreation, which is why 11.4s elapses before Quarkus&#39;s
own self-reported 3.499s startup.&lt;/p&gt;
&lt;h3 id=&quot;3-health-ready-is-honest-health-started-is-not-a-sufficient-gate&quot;&gt;3. &lt;code&gt;/health/ready&lt;/code&gt; is honest. &lt;code&gt;/health/started&lt;/code&gt; is not a sufficient gate.&lt;/h3&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;&lt;code&gt;/health/ready&lt;/code&gt; returned 503 for the entire migration window&lt;/strong&gt; and only
flipped to 200 at 17.0s, after migration completed. A readiness probe pointed
at it behaves correctly.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;&lt;code&gt;/health/started&lt;/code&gt; returned 200 at 11.5s — during migration&lt;/strong&gt;, ~5.5s before
the server was actually able to serve.&lt;/li&gt;
&lt;li&gt;Port 8080 accepted TCP connections from 11.4s, ~5.6s before service.&lt;/li&gt;
&lt;li&gt;Requests arriving during migration received &lt;strong&gt;503&lt;/strong&gt;, not timeouts, connection
resets, or partially-migrated data. It fails closed.&lt;/li&gt;
&lt;/ul&gt;
&lt;h3 id=&quot;4-correction-to-attempt-1&quot;&gt;4. Correction to attempt 1&lt;/h3&gt;
&lt;p&gt;Attempt 1 flagged this as &lt;em&gt;&quot;a readiness probe could route production traffic
mid-migration.&quot;&lt;/em&gt; &lt;strong&gt;That is wrong and this run refutes it.&lt;/strong&gt; &lt;code&gt;/health/ready&lt;/code&gt;
reports unready throughout. The real exposure is narrower and different: any
gate that is &lt;em&gt;not&lt;/em&gt; &lt;code&gt;/health/ready&lt;/code&gt; — a TCP port check, an L4 load balancer
health check, or a Kubernetes &lt;code&gt;startupProbe&lt;/code&gt; on &lt;code&gt;/health/started&lt;/code&gt; — goes green
while the schema is still migrating.&lt;/p&gt;
&lt;p&gt;Recorded prominently rather than quietly amended, per our no-unverified-claims rule and
the ledger&#39;s publishing rule 4.&lt;/p&gt;
&lt;h2 id=&quot;outcome&quot;&gt;Outcome&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;&lt;input disabled=&quot;&quot; type=&quot;checkbox&quot; checked=&quot;&quot;/&gt;
Reached ready · [x] 1000 users intact · [x] login works (verified attempt 1)&lt;/li&gt;
&lt;li&gt;&lt;input disabled=&quot;&quot; type=&quot;checkbox&quot;/&gt;
Custom SPI / themes / federation — not in fixture&lt;/li&gt;
&lt;/ul&gt;
&lt;h2 id=&quot;rollback&quot;&gt;Rollback&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Exercised this run?&lt;/strong&gt; no. Restore-from-snapshot was used twice to rebuild the
starting state, which exercises the &lt;em&gt;mechanism&lt;/em&gt; but is not a rollback of a
failed upgrade and does not satisfy the gate.&lt;/li&gt;
&lt;/ul&gt;
&lt;h2 id=&quot;verification&quot;&gt;Verification&lt;/h2&gt;
&lt;table&gt;&lt;thead&gt;&lt;tr&gt;&lt;th&gt;Claim&lt;/th&gt;&lt;th&gt;Primary source&lt;/th&gt;&lt;th&gt;Checked&lt;/th&gt;&lt;/tr&gt;&lt;/thead&gt;&lt;tbody&gt;
&lt;tr&gt;&lt;td&gt;&lt;code&gt;/health/ready&lt;/code&gt; = &quot;only direct traffic once the readiness probe succeeds&quot;&lt;/td&gt;&lt;td&gt;keycloak.org/observability/health, fetched 2026-08-25&lt;/td&gt;&lt;td&gt;☑&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;&lt;code&gt;/health/started&lt;/code&gt; = startup probe, precedes liveness&lt;/td&gt;&lt;td&gt;same&lt;/td&gt;&lt;td&gt;☑&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Docs describe behaviour during schema migration&lt;/td&gt;&lt;td&gt;&lt;strong&gt;They do not&lt;/strong&gt; — checked 2026-08-25. This gap is why the measurement is worth publishing&lt;/td&gt;&lt;td&gt;☑&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Behaviour identical on other major pairs&lt;/td&gt;&lt;td&gt;not tested — only 26.0.0 → 26.7.1&lt;/td&gt;&lt;td&gt;☐&lt;/td&gt;&lt;/tr&gt;
&lt;/tbody&gt;&lt;/table&gt;
&lt;h2 id=&quot;publishable-extract&quot;&gt;Publishable extract&lt;/h2&gt;
&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Keycloak 26.7.1 opens its HTTP port about 5.6 seconds before it can serve a
request, and migrates your schema in between.&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;On a 1,000-user realm (Postgres 16.15, single node, Hetzner CCX33 — 8 vCPU
dedicated, 32 GB, local NVMe, JVM &lt;code&gt;-Xms1g -Xmx4g&lt;/code&gt;), upgrading 26.0.0 → 26.7.1:
Quarkus logged &lt;code&gt;started&lt;/code&gt; at T+11.4s and began the Liquibase schema migration at
T+12.2s, on an executor thread, after startup had completed. Migration ran
through six model steps and finished at T+16.2s. The server first issued a
token at T+16.6s.&lt;/p&gt;
&lt;p&gt;We verified the migration is not triggered by an inbound request: with the
server left completely untouched for 120 seconds, &lt;code&gt;DATABASECHANGELOG&lt;/code&gt; still
went from 144 rows to 211 and &lt;code&gt;MIGRATION_MODEL&lt;/code&gt; from 26.0.0 to 26.7.1.&lt;/p&gt;
&lt;p&gt;The good news is that &lt;code&gt;/health/ready&lt;/code&gt; is honest — it returned 503 for the whole
window and only went green after migration finished. The trap is everything
else: &lt;code&gt;/health/started&lt;/code&gt; returned 200 at T+11.5s, mid-migration, and the TCP
port accepted connections from T+11.4s. If your load balancer health check is
a port probe, or your Kubernetes &lt;code&gt;startupProbe&lt;/code&gt; gates on &lt;code&gt;/health/started&lt;/code&gt;,
your infrastructure believes the instance is up roughly five seconds before it
is. Requests in that window get a clean 503 rather than corrupt data — it fails
closed — but they do fail.&lt;/p&gt;
&lt;p&gt;Five seconds does not sound like much, and on this path it stays about five
seconds no matter how big your realm is — see the correction below.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;&lt;strong&gt;CORRECTED 2026-08-25 by &lt;code&gt;2026-08-25-26.0.0-to-26.7.1-100k&lt;/code&gt;.&lt;/strong&gt; This extract
originally continued: &lt;em&gt;&quot;The migration phase is the part that scales with your
data … so on a large realm this window is not five seconds.&quot;&lt;/em&gt; &lt;strong&gt;That was an
extrapolation from a single scale point and it is false for this version pair.&lt;/strong&gt;
At 100,002 users the migration took 3.79s versus 3.97s at 1k, and the unready
window was 4.9s versus 5.6s. None of the 67 changesets on this path rewrite user
data. The window is real and worth designing around; its &lt;em&gt;size&lt;/em&gt; does not grow
with your realm on this path.&lt;/p&gt;
</content>
        
    </entry>
    <entry xml:lang="en">
        <title>Rehearsal Record — 26.0.0 → 26.7.1 at 2,000,003 users</title>
        <published>2026-08-25T00:00:00+00:00</published>
        <updated>2026-08-25T00:00:00+00:00</updated>
        
        <author>
          <name>Unknown</name>
        </author>
        
        <link rel="alternate" type="text/html" href="https://ledger.mlabs.city/runs/2026-08-25-26.0.0-to-26.7.1-2m/"/>
        <id>https://ledger.mlabs.city/runs/2026-08-25-26.0.0-to-26.7.1-2m/</id>
        
        <content type="html" xml:base="https://ledger.mlabs.city/runs/2026-08-25-26.0.0-to-26.7.1-2m/">&lt;h2 id=&quot;environment&quot;&gt;Environment&lt;/h2&gt;
&lt;table&gt;&lt;thead&gt;&lt;tr&gt;&lt;th&gt;Field&lt;/th&gt;&lt;th&gt;Value&lt;/th&gt;&lt;/tr&gt;&lt;/thead&gt;&lt;tbody&gt;
&lt;tr&gt;&lt;td&gt;Keycloak from → to&lt;/td&gt;&lt;td&gt;26.0.0 → 26.7.1&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Distribution&lt;/td&gt;&lt;td&gt;quay.io/keycloak/keycloak official image&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Start mode&lt;/td&gt;&lt;td&gt;&lt;code&gt;start --http-enabled=true&lt;/code&gt; w/ external DB&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Database&lt;/td&gt;&lt;td&gt;PostgreSQL 16.15, single node, container&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Postgres tuning&lt;/td&gt;&lt;td&gt;&lt;strong&gt;stock&lt;/strong&gt; — &lt;code&gt;shared_buffers&lt;/code&gt; 128MB, &lt;code&gt;maintenance_work_mem&lt;/code&gt; 64MB, &lt;code&gt;statement_timeout&lt;/code&gt; 0&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Dataset scale&lt;/td&gt;&lt;td&gt;&lt;strong&gt;2,000,003 users&lt;/strong&gt; · 2,000,002 credentials · 4,000,001 user attributes · 2 realms · 10 clients · 8 roles&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;DB size&lt;/td&gt;&lt;td&gt;&lt;strong&gt;3,512 MB&lt;/strong&gt;&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;&lt;strong&gt;Seeding method&lt;/strong&gt;&lt;/td&gt;&lt;td&gt;&lt;strong&gt;&lt;code&gt;seed-sql&lt;/code&gt;&lt;/strong&gt; — direct SQL, &lt;em&gt;not&lt;/em&gt; &lt;code&gt;partialImport&lt;/code&gt;. All users share one argon2id hash. See &lt;code&gt;bin/seed-sql.sh&lt;/code&gt;&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Fixture profile&lt;/td&gt;&lt;td&gt;&lt;code&gt;typical&lt;/code&gt; (structure) + &lt;code&gt;seed-sql&lt;/code&gt; (users)&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Topology&lt;/td&gt;&lt;td&gt;single container&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Host&lt;/td&gt;&lt;td&gt;Hetzner CCX33 (fsn1) — 8 vCPU dedicated, 30GB RAM, local NVMe&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;JVM heap&lt;/td&gt;&lt;td&gt;&lt;code&gt;-Xms1g -Xmx4g&lt;/code&gt;&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Adverse scenario&lt;/td&gt;&lt;td&gt;none&lt;/td&gt;&lt;/tr&gt;
&lt;/tbody&gt;&lt;/table&gt;
&lt;p&gt;&lt;strong&gt;Comparability warning.&lt;/strong&gt; Per our fixture-comparability rule, this fixture is &lt;em&gt;not&lt;/em&gt;
comparable to the 1k and 100k records in every respect: those were seeded through
Keycloak&#39;s own write path via &lt;code&gt;partialImport&lt;/code&gt;, this one bypasses it. Row counts
and column contents match; provenance does not.&lt;/p&gt;
&lt;h2 id=&quot;timings&quot;&gt;Timings&lt;/h2&gt;
&lt;table&gt;&lt;thead&gt;&lt;tr&gt;&lt;th&gt;Phase&lt;/th&gt;&lt;th&gt;Value&lt;/th&gt;&lt;/tr&gt;&lt;/thead&gt;&lt;tbody&gt;
&lt;tr&gt;&lt;td&gt;Quarkus &lt;code&gt;started&lt;/code&gt;&lt;/td&gt;&lt;td&gt;3.32s (self-reported)&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;&lt;code&gt;/health/started&lt;/code&gt; → 200&lt;/td&gt;&lt;td&gt;&lt;strong&gt;10.83s&lt;/strong&gt;&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Liquibase migration&lt;/td&gt;&lt;td&gt;18:06:58,654 → 18:07:02,490 = &lt;strong&gt;3.84s&lt;/strong&gt;&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;&lt;code&gt;/health/ready&lt;/code&gt; → 200&lt;/td&gt;&lt;td&gt;&lt;strong&gt;15.60s&lt;/strong&gt;&lt;/td&gt;&lt;/tr&gt;
&lt;/tbody&gt;&lt;/table&gt;
&lt;h3 id=&quot;against-every-other-scale-measured&quot;&gt;Against every other scale measured&lt;/h3&gt;
&lt;table&gt;&lt;thead&gt;&lt;tr&gt;&lt;th&gt;Users&lt;/th&gt;&lt;th&gt;DB size&lt;/th&gt;&lt;th&gt;Liquibase phase&lt;/th&gt;&lt;th&gt;Time to ready&lt;/th&gt;&lt;/tr&gt;&lt;/thead&gt;&lt;tbody&gt;
&lt;tr&gt;&lt;td&gt;1,000&lt;/td&gt;&lt;td&gt;~13 MB&lt;/td&gt;&lt;td&gt;3.97s&lt;/td&gt;&lt;td&gt;17.0s&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;100,002&lt;/td&gt;&lt;td&gt;264 MB&lt;/td&gt;&lt;td&gt;3.79s&lt;/td&gt;&lt;td&gt;15.4s&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;&lt;strong&gt;2,000,003&lt;/strong&gt;&lt;/td&gt;&lt;td&gt;&lt;strong&gt;3,512 MB&lt;/strong&gt;&lt;/td&gt;&lt;td&gt;&lt;strong&gt;3.84s&lt;/strong&gt;&lt;/td&gt;&lt;td&gt;&lt;strong&gt;15.6s&lt;/strong&gt;&lt;/td&gt;&lt;/tr&gt;
&lt;/tbody&gt;&lt;/table&gt;
&lt;p&gt;&lt;strong&gt;Migration duration is flat across a 2,000× range in user count and a 270×
range in database size.&lt;/strong&gt; This is not a weak effect; there is no effect.&lt;/p&gt;
&lt;h2 id=&quot;outcome&quot;&gt;Outcome&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;&lt;input disabled=&quot;&quot; type=&quot;checkbox&quot; checked=&quot;&quot;/&gt;
Reached ready in 15.6s · [x] 2,000,003 users intact · [x] &lt;code&gt;MIGRATION_MODEL&lt;/code&gt; → 26.7.1&lt;/li&gt;
&lt;li&gt;&lt;input disabled=&quot;&quot; type=&quot;checkbox&quot; checked=&quot;&quot;/&gt;
&lt;code&gt;DATABASECHANGELOG&lt;/code&gt; 211 rows · [x] login as a SQL-seeded user OK&lt;/li&gt;
&lt;li&gt;&lt;input disabled=&quot;&quot; type=&quot;checkbox&quot;/&gt;
&lt;code&gt;IDX_USER_CREATED_TIMESTAMP&lt;/code&gt; — &lt;strong&gt;absent, silently&lt;/strong&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;h2 id=&quot;breakage-observed&quot;&gt;Breakage observed&lt;/h2&gt;
&lt;h3 id=&quot;the-upgrade-completes-with-a-missing-index-on-user-entity&quot;&gt;The upgrade completes with a missing index on USER_ENTITY&lt;/h3&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Symptom:&lt;/strong&gt; &lt;code&gt;IDX_USER_CREATED_TIMESTAMP&lt;/code&gt; absent after a successful upgrade.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Evidence:&lt;/strong&gt; the two WARN lines above; &lt;code&gt;pg_indexes&lt;/code&gt; returns 0.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Cause:&lt;/strong&gt; &lt;strong&gt;confirmed&lt;/strong&gt; — &lt;code&gt;CustomCreateIndexChange&lt;/code&gt; skips index creation above
300,000 rows. Deliberate Keycloak behaviour.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Fix:&lt;/strong&gt; create it manually, ideally &lt;code&gt;CREATE INDEX CONCURRENTLY&lt;/code&gt; so production
is not locked — Keycloak&#39;s plain &lt;code&gt;CREATE INDEX&lt;/code&gt; is exactly what it avoided.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Would this hit a customer?&lt;/strong&gt; &lt;strong&gt;Every customer above 300,000 rows in any
affected table, on every upgrade that adds an index.&lt;/strong&gt; Scale-dependent by
definition, and it hits the largest installs hardest.&lt;/li&gt;
&lt;/ul&gt;
&lt;h2 id=&quot;verification&quot;&gt;Verification&lt;/h2&gt;
&lt;table&gt;&lt;thead&gt;&lt;tr&gt;&lt;th&gt;Claim&lt;/th&gt;&lt;th&gt;Primary source&lt;/th&gt;&lt;th&gt;Checked&lt;/th&gt;&lt;/tr&gt;&lt;/thead&gt;&lt;tbody&gt;
&lt;tr&gt;&lt;td&gt;2,000,003 users present and migrated&lt;/td&gt;&lt;td&gt;&lt;code&gt;count(*)&lt;/code&gt; from Postgres, before and after&lt;/td&gt;&lt;td&gt;☑&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Migration flat 1k → 2M&lt;/td&gt;&lt;td&gt;three instrumented runs, same box, same version pair&lt;/td&gt;&lt;td&gt;☑&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;&lt;code&gt;USER_ENTITY&lt;/code&gt; index skipped at 2M&lt;/td&gt;&lt;td&gt;&lt;code&gt;pg_indexes&lt;/code&gt; = 0, plus both WARN lines&lt;/td&gt;&lt;td&gt;☑&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Same index created at 1k and 100k&lt;/td&gt;&lt;td&gt;earlier runs in this series&lt;/td&gt;&lt;td&gt;☑&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Threshold is 300,000 for &lt;code&gt;USER_ENTITY&lt;/code&gt; too&lt;/td&gt;&lt;td&gt;&lt;strong&gt;not bisected&lt;/strong&gt; — only 100k (created) and 2M (skipped) tested on this table&lt;/td&gt;&lt;td&gt;☐&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Holds for other version pairs&lt;/td&gt;&lt;td&gt;not tested&lt;/td&gt;&lt;td&gt;☐&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;&lt;code&gt;seed-sql&lt;/code&gt; fixture behaves like a &lt;code&gt;partialImport&lt;/code&gt; one under migration&lt;/td&gt;&lt;td&gt;&lt;strong&gt;not tested&lt;/strong&gt; — different provenance, see warning above&lt;/td&gt;&lt;td&gt;☐&lt;/td&gt;&lt;/tr&gt;
&lt;/tbody&gt;&lt;/table&gt;
&lt;h2 id=&quot;publishable-extract&quot;&gt;Publishable extract&lt;/h2&gt;
&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;We upgraded a two-million-user Keycloak. The schema migration took 3.8
seconds — the same as a thousand-user realm. Here is why that is bad news.&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;26.0.0 → 26.7.1, official image, Postgres 16.15 at stock settings, Hetzner
CCX33 (8 vCPU dedicated, 32 GB, local NVMe), JVM &lt;code&gt;-Xms1g -Xmx4g&lt;/code&gt;.&lt;/p&gt;
&lt;table&gt;&lt;thead&gt;&lt;tr&gt;&lt;th&gt;Users&lt;/th&gt;&lt;th&gt;Database&lt;/th&gt;&lt;th&gt;Migration&lt;/th&gt;&lt;th&gt;Time to serving&lt;/th&gt;&lt;/tr&gt;&lt;/thead&gt;&lt;tbody&gt;
&lt;tr&gt;&lt;td&gt;1,000&lt;/td&gt;&lt;td&gt;13 MB&lt;/td&gt;&lt;td&gt;3.97s&lt;/td&gt;&lt;td&gt;17.0s&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;100,002&lt;/td&gt;&lt;td&gt;264 MB&lt;/td&gt;&lt;td&gt;3.79s&lt;/td&gt;&lt;td&gt;15.4s&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;2,000,003&lt;/td&gt;&lt;td&gt;3,512 MB&lt;/td&gt;&lt;td&gt;3.84s&lt;/td&gt;&lt;td&gt;15.6s&lt;/td&gt;&lt;/tr&gt;
&lt;/tbody&gt;&lt;/table&gt;
&lt;p&gt;A 2,000-fold increase in users changes nothing. We went looking for the reason
and found it: &lt;strong&gt;Keycloak skips creating indexes on tables larger than 300,000
rows.&lt;/strong&gt; It logs a WARN and moves on, and &lt;code&gt;DATABASECHANGELOG&lt;/code&gt; records the
changeset as &lt;code&gt;EXECUTED&lt;/code&gt; — so a schema-version check shows everything applied.&lt;/p&gt;
&lt;p&gt;At two million users, this upgrade quietly declined to create
&lt;code&gt;IDX_USER_CREATED_TIMESTAMP&lt;/code&gt; on &lt;code&gt;USER_ENTITY&lt;/code&gt;. That same index is created
automatically on a hundred-thousand-user realm. The bigger your Keycloak, the
more indexes it does not build.&lt;/p&gt;
&lt;p&gt;The design choice is defensible — locking &lt;code&gt;USER_ENTITY&lt;/code&gt; on a two-million-row
table during startup would be far worse than a late index, and it is why your
upgrade window is short. But the consequence is rarely stated: &lt;strong&gt;a fast
Keycloak upgrade at scale is fast partly because work was deferred onto you,
and the only notice is a warning in the noisiest log of your month.&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;After any Keycloak upgrade, list the indexes its changelogs define and diff
them against your database. If any are missing, create them yourself with
&lt;code&gt;CREATE INDEX CONCURRENTLY&lt;/code&gt;. Then keep doing it, because every future release
that indexes a large table will behave the same way.&lt;/p&gt;
&lt;p&gt;One note on method, because it changes what these numbers mean: the
two-million-user realm was seeded with direct SQL rather than through
Keycloak&#39;s API. &lt;code&gt;partialImport&lt;/code&gt; runs at about 19 users/second — Keycloak 26
hashes every password with argon2id on a single thread — which puts two million
users at roughly 29 hours. Direct SQL does it in 167 seconds. Every user shares
one password hash, which is fine for migration timing and useless for anything
about authentication performance.&lt;/p&gt;
&lt;/blockquote&gt;
</content>
        
    </entry>
    <entry xml:lang="en">
        <title>Rehearsal Record — 26.0.0 → 26.7.1 (rollback exercised)</title>
        <published>2026-08-25T00:00:00+00:00</published>
        <updated>2026-08-25T00:00:00+00:00</updated>
        
        <author>
          <name>Unknown</name>
        </author>
        
        <link rel="alternate" type="text/html" href="https://ledger.mlabs.city/runs/2026-08-25-26.0.0-to-26.7.1-3/"/>
        <id>https://ledger.mlabs.city/runs/2026-08-25-26.0.0-to-26.7.1-3/</id>
        
        <content type="html" xml:base="https://ledger.mlabs.city/runs/2026-08-25-26.0.0-to-26.7.1-3/">&lt;h2 id=&quot;environment&quot;&gt;Environment&lt;/h2&gt;
&lt;p&gt;Identical to attempts 1 and 2: Hetzner CCX33 (fsn1), 8 vCPU dedicated, 30GB RAM,
local NVMe; Postgres 16.15 single node; 1000 users, &lt;code&gt;typical&lt;/code&gt; profile, 2 realms,
10 clients, 8 roles; JVM &lt;code&gt;-Xms1g -Xmx4g&lt;/code&gt;; single container.&lt;/p&gt;
&lt;h2 id=&quot;procedure&quot;&gt;Procedure&lt;/h2&gt;
&lt;pre&gt;&lt;code&gt;restore baseline-26.0.0-typical-1k          # identical starting state
upgrade 26.0.0 -&amp;gt; 26.7.1, wait ready
POST /admin/realms/lab/users {canary-postupgrade}   # data written AFTER the snapshot
# --- roll back ---
stop keycloak; KC_VERSION=26.0.0
dropdb / createdb / pg_restore baseline-26.0.0-typical-1k
start keycloak 26.0.0, wait ready
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The canary is the point of the run. Anything written between the snapshot and
the rollback decision is what a customer actually loses, and asserting that it
disappears is the only way to know the rollback is real rather than nominal.&lt;/p&gt;
&lt;h2 id=&quot;timings&quot;&gt;Timings&lt;/h2&gt;
&lt;table&gt;&lt;thead&gt;&lt;tr&gt;&lt;th&gt;Phase&lt;/th&gt;&lt;th&gt;Duration&lt;/th&gt;&lt;/tr&gt;&lt;/thead&gt;&lt;tbody&gt;
&lt;tr&gt;&lt;td&gt;DB drop + create + &lt;code&gt;pg_restore&lt;/code&gt;&lt;/td&gt;&lt;td&gt;&lt;strong&gt;2.475s&lt;/strong&gt;&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Total, rollback decision → serving traffic again&lt;/td&gt;&lt;td&gt;&lt;strong&gt;15.9s&lt;/strong&gt;&lt;/td&gt;&lt;/tr&gt;
&lt;/tbody&gt;&lt;/table&gt;
&lt;p&gt;The restore is 2.5s of the 15.9s. &lt;strong&gt;The rest is Keycloak process start&lt;/strong&gt;, which
is the same fixed cost seen everywhere else in these runs.&lt;/p&gt;
&lt;h2 id=&quot;outcome&quot;&gt;Outcome&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;&lt;input disabled=&quot;&quot; type=&quot;checkbox&quot; checked=&quot;&quot;/&gt;
Reached ready on 26.0.0 after rollback&lt;/li&gt;
&lt;li&gt;&lt;input disabled=&quot;&quot; type=&quot;checkbox&quot; checked=&quot;&quot;/&gt;
&lt;code&gt;serverinfo&lt;/code&gt; reports 26.0.0&lt;/li&gt;
&lt;li&gt;&lt;input disabled=&quot;&quot; type=&quot;checkbox&quot; checked=&quot;&quot;/&gt;
&lt;code&gt;MIGRATION_MODEL&lt;/code&gt; back to 26.0.0 — schema genuinely reverted, not just the binary&lt;/li&gt;
&lt;li&gt;&lt;input disabled=&quot;&quot; type=&quot;checkbox&quot; checked=&quot;&quot;/&gt;
1000 users present&lt;/li&gt;
&lt;li&gt;&lt;input disabled=&quot;&quot; type=&quot;checkbox&quot; checked=&quot;&quot;/&gt;
End-user login works (&lt;code&gt;labuser7&lt;/code&gt; issued a token)&lt;/li&gt;
&lt;li&gt;&lt;input disabled=&quot;&quot; type=&quot;checkbox&quot; checked=&quot;&quot;/&gt;
&lt;strong&gt;Canary absent&lt;/strong&gt; — the post-upgrade user is gone, as it must be&lt;/li&gt;
&lt;/ul&gt;
&lt;h2 id=&quot;breakage-observed&quot;&gt;Breakage observed&lt;/h2&gt;
&lt;p&gt;None. The rollback was clean.&lt;/p&gt;
&lt;h2 id=&quot;rollback&quot;&gt;Rollback&lt;/h2&gt;
&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;NOTE added 2026-08-25:&lt;/strong&gt; this run performed the restore steps by hand with
&lt;code&gt;docker compose up -d --force-recreate&lt;/code&gt;. &lt;code&gt;bin/restore.sh&lt;/code&gt; as shipped at the
time used &lt;code&gt;docker compose start&lt;/code&gt;, which reuses the existing container and
silently re-migrates — see &lt;a href=&quot;/findings/2026-08-25-backup-and-rollback-tooling/&quot;&gt;&lt;code&gt;2026-08-25-backup-and-rollback-tooling&lt;/code&gt;&lt;/a&gt;. The
finding below holds for the procedure tested; the script did not implement it.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Exercised this run?&lt;/strong&gt; &lt;strong&gt;yes&lt;/strong&gt; — this is the gate run&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Method:&lt;/strong&gt; stop Keycloak, &lt;code&gt;pg_restore&lt;/code&gt; the pre-upgrade snapshot, start the old image&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Time to restore:&lt;/strong&gt; 2.475s DB, 15.9s to serving (1k users)&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Data loss / divergence:&lt;/strong&gt; &lt;strong&gt;Total, for everything written after the snapshot.&lt;/strong&gt;
Confirmed by the canary. This is not a downgrade — Keycloak has no schema
downgrade path — it is a point-in-time restore, and it takes the data with it.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Verdict:&lt;/strong&gt; &lt;strong&gt;Rollback is genuinely available on 26.0.0 ↔ 26.7.1, with a hard
caveat.&lt;/strong&gt; It is available as &lt;em&gt;restore&lt;/em&gt;, not as &lt;em&gt;downgrade&lt;/em&gt;. Availability is
therefore a property of the customer&#39;s snapshot discipline and their tolerance
for losing the write window, not a property of Keycloak.&lt;/li&gt;
&lt;/ul&gt;
&lt;h2 id=&quot;verification&quot;&gt;Verification&lt;/h2&gt;
&lt;table&gt;&lt;thead&gt;&lt;tr&gt;&lt;th&gt;Claim&lt;/th&gt;&lt;th&gt;Primary source&lt;/th&gt;&lt;th&gt;Checked&lt;/th&gt;&lt;/tr&gt;&lt;/thead&gt;&lt;tbody&gt;
&lt;tr&gt;&lt;td&gt;Schema reverted, not just the binary&lt;/td&gt;&lt;td&gt;&lt;code&gt;MIGRATION_MODEL&lt;/code&gt; read from Postgres directly = 26.0.0&lt;/td&gt;&lt;td&gt;☑&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Post-snapshot writes are lost&lt;/td&gt;&lt;td&gt;canary user, asserted absent after rollback&lt;/td&gt;&lt;td&gt;☑&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Keycloak has no supported schema downgrade&lt;/td&gt;&lt;td&gt;&lt;strong&gt;not verified against docs&lt;/strong&gt; — inferred from the restore being required at all&lt;/td&gt;&lt;td&gt;☐&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Holds at scale&lt;/td&gt;&lt;td&gt;not tested — 2.5s restore is a 500K dump&lt;/td&gt;&lt;td&gt;☐&lt;/td&gt;&lt;/tr&gt;
&lt;/tbody&gt;&lt;/table&gt;
&lt;h2 id=&quot;publishable-extract&quot;&gt;Publishable extract&lt;/h2&gt;
&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Rolling back a Keycloak upgrade is a database restore, and it costs you every
write since the snapshot.&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;On a 1,000-user realm (Postgres 16.15, Hetzner CCX33 — 8 vCPU dedicated,
32 GB, local NVMe), rolling 26.7.1 back to 26.0.0 took 15.9 seconds end to end.
Only 2.5s of that was the &lt;code&gt;pg_restore&lt;/code&gt;; the rest was Keycloak starting.&lt;/p&gt;
&lt;p&gt;We verified it is a real rollback and not a cosmetic one: &lt;code&gt;MIGRATION_MODEL&lt;/code&gt; in
Postgres went back to 26.0.0, so the schema itself reverted. We also created a
user &lt;em&gt;after&lt;/em&gt; the upgrade and confirmed it was gone afterwards — because that is
the part people miss. There is no schema downgrade. You are restoring a
point-in-time backup, so every registration, password change, session and admin
edit between your snapshot and your rollback decision is discarded.&lt;/p&gt;
&lt;p&gt;The practical consequence: your rollback window is not bounded by how long the
restore takes. It is bounded by how much write activity you can afford to throw
away. Decide that number &lt;em&gt;before&lt;/em&gt; the maintenance window, because during one
you will not want to.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;&lt;em&gt;Publishable. The 15.9s figure is honest but must always be quoted with its
1,000-user scale attached — the restore half scales with the dump, the startup
half does not.&lt;/em&gt;&lt;/p&gt;
</content>
        
    </entry>
    <entry xml:lang="en">
        <title>Rehearsal Record — Keycloak silently skips index creation above 300,000 rows</title>
        <published>2026-08-25T00:00:00+00:00</published>
        <updated>2026-08-25T00:00:00+00:00</updated>
        
        <author>
          <name>Unknown</name>
        </author>
        
        <link rel="alternate" type="text/html" href="https://ledger.mlabs.city/runs/2026-08-25-index-skip-threshold/"/>
        <id>https://ledger.mlabs.city/runs/2026-08-25-index-skip-threshold/</id>
        
        <content type="html" xml:base="https://ledger.mlabs.city/runs/2026-08-25-index-skip-threshold/">&lt;h2 id=&quot;environment&quot;&gt;Environment&lt;/h2&gt;
&lt;table&gt;&lt;thead&gt;&lt;tr&gt;&lt;th&gt;Field&lt;/th&gt;&lt;th&gt;Value&lt;/th&gt;&lt;/tr&gt;&lt;/thead&gt;&lt;tbody&gt;
&lt;tr&gt;&lt;td&gt;Keycloak from → to&lt;/td&gt;&lt;td&gt;26.0.0 → 26.7.1&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Database&lt;/td&gt;&lt;td&gt;PostgreSQL 16.15, single node, container&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Postgres tuning&lt;/td&gt;&lt;td&gt;stock — &lt;code&gt;shared_buffers&lt;/code&gt; 128MB, &lt;code&gt;maintenance_work_mem&lt;/code&gt; 64MB, &lt;code&gt;statement_timeout&lt;/code&gt; 0&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Dataset&lt;/td&gt;&lt;td&gt;100,002 users, &lt;code&gt;typical&lt;/code&gt; profile; &lt;code&gt;EVENT_ENTITY&lt;/code&gt; varied per run&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Seeding&lt;/td&gt;&lt;td&gt;users via &lt;code&gt;partialImport&lt;/code&gt;; events via direct SQL &lt;code&gt;INSERT … generate_series&lt;/code&gt;, then &lt;code&gt;ANALYZE&lt;/code&gt;&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Host&lt;/td&gt;&lt;td&gt;Hetzner CCX33 (fsn1) — 8 vCPU dedicated, 30GB RAM, local NVMe&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;JVM heap&lt;/td&gt;&lt;td&gt;&lt;code&gt;-Xms1g -Xmx4g&lt;/code&gt;&lt;/td&gt;&lt;/tr&gt;
&lt;/tbody&gt;&lt;/table&gt;
&lt;h2 id=&quot;procedure&quot;&gt;Procedure&lt;/h2&gt;
&lt;p&gt;For each row count: restore &lt;code&gt;baseline-26.0.0-typical-100k&lt;/code&gt;, insert N rows into
&lt;code&gt;EVENT_ENTITY&lt;/code&gt;, &lt;code&gt;ANALYZE&lt;/code&gt;, upgrade to 26.7.1, then check &lt;code&gt;pg_indexes&lt;/code&gt; for
&lt;code&gt;idx_event_entity_user_id_type&lt;/code&gt; and grep the log for the skip warning.&lt;/p&gt;
&lt;h2 id=&quot;results&quot;&gt;Results&lt;/h2&gt;
&lt;table&gt;&lt;thead&gt;&lt;tr&gt;&lt;th&gt;&lt;code&gt;EVENT_ENTITY&lt;/code&gt; rows&lt;/th&gt;&lt;th&gt;Time to ready&lt;/th&gt;&lt;th&gt;Index created&lt;/th&gt;&lt;th&gt;Skip warning&lt;/th&gt;&lt;/tr&gt;&lt;/thead&gt;&lt;tbody&gt;
&lt;tr&gt;&lt;td&gt;0&lt;/td&gt;&lt;td&gt;15.7s&lt;/td&gt;&lt;td&gt;&lt;strong&gt;yes&lt;/strong&gt;&lt;/td&gt;&lt;td&gt;no&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;1,000&lt;/td&gt;&lt;td&gt;15.4s&lt;/td&gt;&lt;td&gt;&lt;strong&gt;yes&lt;/strong&gt;&lt;/td&gt;&lt;td&gt;no&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;100,000&lt;/td&gt;&lt;td&gt;15.6s&lt;/td&gt;&lt;td&gt;&lt;strong&gt;yes&lt;/strong&gt;&lt;/td&gt;&lt;td&gt;no&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;200,000&lt;/td&gt;&lt;td&gt;15.7s&lt;/td&gt;&lt;td&gt;&lt;strong&gt;yes&lt;/strong&gt;&lt;/td&gt;&lt;td&gt;no&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;&lt;strong&gt;300,000&lt;/strong&gt;&lt;/td&gt;&lt;td&gt;15.9s&lt;/td&gt;&lt;td&gt;&lt;strong&gt;yes&lt;/strong&gt;&lt;/td&gt;&lt;td&gt;no&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;&lt;strong&gt;301,000&lt;/strong&gt;&lt;/td&gt;&lt;td&gt;15.5s&lt;/td&gt;&lt;td&gt;&lt;strong&gt;NO&lt;/strong&gt;&lt;/td&gt;&lt;td&gt;&lt;strong&gt;yes&lt;/strong&gt;&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;350,000&lt;/td&gt;&lt;td&gt;15.5s&lt;/td&gt;&lt;td&gt;&lt;strong&gt;NO&lt;/strong&gt;&lt;/td&gt;&lt;td&gt;&lt;strong&gt;yes&lt;/strong&gt;&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;400,000&lt;/td&gt;&lt;td&gt;16.0s&lt;/td&gt;&lt;td&gt;&lt;strong&gt;NO&lt;/strong&gt;&lt;/td&gt;&lt;td&gt;&lt;strong&gt;yes&lt;/strong&gt;&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;450,000&lt;/td&gt;&lt;td&gt;15.3s&lt;/td&gt;&lt;td&gt;&lt;strong&gt;NO&lt;/strong&gt;&lt;/td&gt;&lt;td&gt;&lt;strong&gt;yes&lt;/strong&gt;&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;500,000&lt;/td&gt;&lt;td&gt;15.4s&lt;/td&gt;&lt;td&gt;&lt;strong&gt;NO&lt;/strong&gt;&lt;/td&gt;&lt;td&gt;&lt;strong&gt;yes&lt;/strong&gt;&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;5,000,000&lt;/td&gt;&lt;td&gt;14.9s&lt;/td&gt;&lt;td&gt;&lt;strong&gt;NO&lt;/strong&gt;&lt;/td&gt;&lt;td&gt;&lt;strong&gt;yes&lt;/strong&gt;&lt;/td&gt;&lt;/tr&gt;
&lt;/tbody&gt;&lt;/table&gt;
&lt;p&gt;&lt;strong&gt;The threshold is exactly 300,000 rows.&lt;/strong&gt; Time to ready is flat across a range
of 0 to 5,000,000 rows — a 2.1 GB database at the top end — precisely &lt;em&gt;because&lt;/em&gt;
the expensive work is skipped.&lt;/p&gt;
&lt;h2 id=&quot;outcome&quot;&gt;Outcome&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;&lt;input disabled=&quot;&quot; type=&quot;checkbox&quot; checked=&quot;&quot;/&gt;
Upgrade reached ready in every run · [x] realm intact · [x] login works&lt;/li&gt;
&lt;li&gt;&lt;input disabled=&quot;&quot; type=&quot;checkbox&quot; checked=&quot;&quot;/&gt;
Threshold reproduced across 11 runs with a clean boundary at 300,000&lt;/li&gt;
&lt;/ul&gt;
&lt;h2 id=&quot;verification&quot;&gt;Verification&lt;/h2&gt;
&lt;table&gt;&lt;thead&gt;&lt;tr&gt;&lt;th&gt;Claim&lt;/th&gt;&lt;th&gt;Primary source&lt;/th&gt;&lt;th&gt;Checked&lt;/th&gt;&lt;/tr&gt;&lt;/thead&gt;&lt;tbody&gt;
&lt;tr&gt;&lt;td&gt;Index absent above threshold&lt;/td&gt;&lt;td&gt;&lt;code&gt;pg_indexes&lt;/code&gt;, queried directly&lt;/td&gt;&lt;td&gt;☑&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Changeset recorded EXECUTED&lt;/td&gt;&lt;td&gt;&lt;code&gt;DATABASECHANGELOG.exectype&lt;/code&gt;&lt;/td&gt;&lt;td&gt;☑&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Keycloak warns at skip and at startup&lt;/td&gt;&lt;td&gt;container log, both messages quoted&lt;/td&gt;&lt;td&gt;☑&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Threshold is 300,000&lt;/td&gt;&lt;td&gt;300,000 creates; 301,000 skips&lt;/td&gt;&lt;td&gt;☑&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;The 300,000 constant applies to other tables/indexes&lt;/td&gt;&lt;td&gt;&lt;strong&gt;confirmed on &lt;code&gt;USER_ENTITY&lt;/code&gt;&lt;/strong&gt; — bisected to the same inclusive 300,000 boundary in &lt;a href=&quot;/runs/2026-08-26-index-precondition-anatomy/&quot;&gt;&lt;code&gt;2026-08-26-index-precondition-anatomy&lt;/code&gt;&lt;/a&gt; §3b&lt;/td&gt;&lt;td&gt;☑&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Same behaviour in majors other than 26.7.1&lt;/td&gt;&lt;td&gt;&lt;strong&gt;not tested&lt;/strong&gt;&lt;/td&gt;&lt;td&gt;☐&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Whether row count comes from statistics or an exact count&lt;/td&gt;&lt;td&gt;&lt;strong&gt;answered — the planner estimate.&lt;/strong&gt; Falsifying &lt;code&gt;pg_class&lt;/code&gt; for &lt;code&gt;USER_ENTITY&lt;/code&gt; at 2M made Keycloak build the index; see &lt;a href=&quot;/runs/2026-08-25-runA-indexes-manually-created/&quot;&gt;&lt;code&gt;2026-08-25-runA-indexes-manually-created&lt;/code&gt;&lt;/a&gt; L5&lt;/td&gt;&lt;td&gt;☑&lt;/td&gt;&lt;/tr&gt;
&lt;/tbody&gt;&lt;/table&gt;
&lt;h2 id=&quot;publishable-extract&quot;&gt;Publishable extract&lt;/h2&gt;
&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Keycloak will skip creating a database index during an upgrade if your table
is too big, and the only place it tells you is a WARN in the startup log.&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;Upgrading 26.0.0 → 26.7.1 adds an index to &lt;code&gt;EVENT_ENTITY&lt;/code&gt;. We ran that upgrade
eleven times against the same 100,000-user realm, varying only the number of
rows in &lt;code&gt;EVENT_ENTITY&lt;/code&gt; (Postgres 16.15 at stock settings, Hetzner CCX33 —
8 vCPU dedicated, 32 GB, local NVMe).&lt;/p&gt;
&lt;p&gt;At 300,000 event rows the index is created. At 301,000 it is not.&lt;/p&gt;
&lt;p&gt;The upgrade still succeeds. The server starts. &lt;code&gt;MIGRATION_MODEL&lt;/code&gt; reads 26.7.1.
And &lt;code&gt;DATABASECHANGELOG&lt;/code&gt; records changeset &lt;code&gt;26.4.0-51321&lt;/code&gt; as &lt;strong&gt;EXECUTED&lt;/strong&gt;, not
as skipped — so a schema-version check, which is what most people verify with,
shows everything applied. Only two WARN lines say otherwise:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;WARN [CustomCreateIndexChange] Following index should be created:
  CREATE INDEX IDX_EVENT_ENTITY_USER_ID_TYPE ON public.EVENT_ENTITY(USER_ID, TYPE, EVENT_TIME);
WARN [DatabaseIndexChecker] Missing database index IDX_EVENT_ENTITY_USER_ID_TYPE
  on table EVENT_ENTITY. Create the index manually: ...
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;This is deliberate. &lt;code&gt;CustomCreateIndexChange&lt;/code&gt; exists so a large table is not
locked during startup, and that is a reasonable design choice — a slow upgrade
is worse than a late index. We measured what it is avoiding: at 5,000,000 rows
the index takes about 3 seconds to build on this hardware.&lt;/p&gt;
&lt;p&gt;The consequence is what nobody mentions. &lt;strong&gt;The installations that skip the
index are exactly the ones large enough to need it&lt;/strong&gt;, small deployments get it
automatically, and every future release that indexes a large table will behave
the same way, so the gap grows with each upgrade.&lt;/p&gt;
&lt;p&gt;If you have upgraded Keycloak recently and your event table is bigger than
300,000 rows, check whether &lt;code&gt;IDX_EVENT_ENTITY_USER_ID_TYPE&lt;/code&gt; exists. Then add
&quot;list indexes Keycloak wanted and did not create&quot; to your post-upgrade
checklist permanently, because this will happen again.&lt;/p&gt;
&lt;p&gt;Incidentally, this also explains something that surprised us. We had expected
migration time to grow with data volume. It does not — 15 seconds whether the
database holds 0 or 5,000,000 events — and this is why. Keycloak keeps the
upgrade fast by declining to do the expensive part.&lt;/p&gt;
&lt;/blockquote&gt;
</content>
        
    </entry>
    <entry xml:lang="en">
        <title>Rehearsal Record — the indexes Keycloak declined, created manually</title>
        <published>2026-08-25T00:00:00+00:00</published>
        <updated>2026-08-25T00:00:00+00:00</updated>
        
        <author>
          <name>Unknown</name>
        </author>
        
        <link rel="alternate" type="text/html" href="https://ledger.mlabs.city/runs/2026-08-25-runA-indexes-manually-created/"/>
        <id>https://ledger.mlabs.city/runs/2026-08-25-runA-indexes-manually-created/</id>
        
        <content type="html" xml:base="https://ledger.mlabs.city/runs/2026-08-25-runA-indexes-manually-created/">&lt;h2 id=&quot;why-this-run-existed&quot;&gt;Why this run existed&lt;/h2&gt;
&lt;p&gt;&lt;code&gt;bin/upgrade.sh&lt;/code&gt; prints, after every run:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;&amp;gt;&amp;gt; create these manually, preferably with CREATE INDEX CONCURRENTLY.
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;We had never once run an upgrade against a database where that advice had been
followed. &lt;a href=&quot;/runs/2026-08-25-index-skip-threshold/&quot;&gt;&lt;code&gt;2026-08-25-index-skip-threshold&lt;/code&gt;&lt;/a&gt; and
&lt;a href=&quot;/runs/2026-08-25-26.0.0-to-26.7.1-2m/&quot;&gt;&lt;code&gt;2026-08-25-26.0.0-to-26.7.1-2m&lt;/code&gt;&lt;/a&gt; both name this as the largest untested
configuration in the lab, and it ranked first on our own untested list.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;The question was whether our own advice breaks the next upgrade.&lt;/strong&gt; It does not.
Asking only that question would have missed both of the findings below.&lt;/p&gt;
&lt;h2 id=&quot;environment&quot;&gt;Environment&lt;/h2&gt;
&lt;table&gt;&lt;thead&gt;&lt;tr&gt;&lt;th&gt;Field&lt;/th&gt;&lt;th&gt;Value&lt;/th&gt;&lt;/tr&gt;&lt;/thead&gt;&lt;tbody&gt;
&lt;tr&gt;&lt;td&gt;Keycloak from → to&lt;/td&gt;&lt;td&gt;26.0.0 → 26.7.1&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Distribution&lt;/td&gt;&lt;td&gt;quay.io/keycloak/keycloak official image&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Start mode&lt;/td&gt;&lt;td&gt;&lt;code&gt;start --http-enabled=true&lt;/code&gt; w/ external DB&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Database&lt;/td&gt;&lt;td&gt;PostgreSQL 16, single node, container&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Postgres tuning&lt;/td&gt;&lt;td&gt;&lt;strong&gt;stock&lt;/strong&gt; — &lt;code&gt;shared_buffers&lt;/code&gt; 128MB, &lt;code&gt;maintenance_work_mem&lt;/code&gt; 64MB, &lt;code&gt;statement_timeout&lt;/code&gt; 0. L4 varies &lt;code&gt;maintenance_work_mem&lt;/code&gt; deliberately and says so&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Dataset scale&lt;/td&gt;&lt;td&gt;&lt;strong&gt;2,000,003 users&lt;/strong&gt; · 2 realms · 10 clients · 8 roles · DB 3,026 MB&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Seeding method&lt;/td&gt;&lt;td&gt;&lt;code&gt;seed-sql&lt;/code&gt; — direct SQL. Baseline &lt;code&gt;baseline-26.0.0-sqlseed-2m&lt;/code&gt;&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Fixture profile&lt;/td&gt;&lt;td&gt;&lt;code&gt;typical&lt;/code&gt; (structure) + &lt;code&gt;seed-sql&lt;/code&gt; (users)&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Topology&lt;/td&gt;&lt;td&gt;single container&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Host&lt;/td&gt;&lt;td&gt;Hetzner CCX33 (fsn1) — 8 vCPU dedicated, 30GB RAM, local NVMe&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;JVM heap&lt;/td&gt;&lt;td&gt;&lt;code&gt;-Xms1g -Xmx4g&lt;/code&gt;&lt;/td&gt;&lt;/tr&gt;
&lt;/tbody&gt;&lt;/table&gt;
&lt;p&gt;Every leg starts from a restore of the same baseline — 71s, verified against
re-migration by &lt;code&gt;bin/restore.sh&lt;/code&gt; each time — so the legs are comparable to each
other. The Liquibase phase is measured identically in every leg: from
&lt;code&gt;QuarkusJpaUpdaterProvider: Updating database&lt;/code&gt; to
&lt;code&gt;DefaultMigrationManager: Migrating older model to 26.1.0&lt;/code&gt;.&lt;/p&gt;
&lt;h2 id=&quot;results&quot;&gt;Results&lt;/h2&gt;
&lt;table&gt;&lt;thead&gt;&lt;tr&gt;&lt;th&gt;Leg&lt;/th&gt;&lt;th&gt;Upgrade&lt;/th&gt;&lt;th&gt;&lt;code&gt;exectype&lt;/code&gt;&lt;/th&gt;&lt;th&gt;Index after&lt;/th&gt;&lt;th&gt;WARNs&lt;/th&gt;&lt;th&gt;Liquibase phase&lt;/th&gt;&lt;th&gt;Bootstrap&lt;/th&gt;&lt;/tr&gt;&lt;/thead&gt;&lt;tbody&gt;
&lt;tr&gt;&lt;td&gt;&lt;strong&gt;L1&lt;/strong&gt; correct index present&lt;/td&gt;&lt;td&gt;READY&lt;/td&gt;&lt;td&gt;&lt;strong&gt;&lt;code&gt;MARK_RAN&lt;/code&gt;&lt;/strong&gt;&lt;/td&gt;&lt;td&gt;correct, 14 MB&lt;/td&gt;&lt;td&gt;&lt;strong&gt;0&lt;/strong&gt;&lt;/td&gt;&lt;td&gt;1.72s&lt;/td&gt;&lt;td&gt;4.304s&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;&lt;strong&gt;L2&lt;/strong&gt; control, absent&lt;/td&gt;&lt;td&gt;READY&lt;/td&gt;&lt;td&gt;&lt;strong&gt;&lt;code&gt;EXECUTED&lt;/code&gt;&lt;/strong&gt;&lt;/td&gt;&lt;td&gt;&lt;strong&gt;absent&lt;/strong&gt;&lt;/td&gt;&lt;td&gt;2&lt;/td&gt;&lt;td&gt;1.69s&lt;/td&gt;&lt;td&gt;4.240s&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;&lt;strong&gt;L3&lt;/strong&gt; wrong-shape index present&lt;/td&gt;&lt;td&gt;READY&lt;/td&gt;&lt;td&gt;&lt;strong&gt;&lt;code&gt;MARK_RAN&lt;/code&gt;&lt;/strong&gt;&lt;/td&gt;&lt;td&gt;&lt;strong&gt;wrong shape&lt;/strong&gt;&lt;/td&gt;&lt;td&gt;&lt;strong&gt;0&lt;/strong&gt;&lt;/td&gt;&lt;td&gt;—&lt;/td&gt;&lt;td&gt;5.291s&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;&lt;strong&gt;L5&lt;/strong&gt; stats say the table is small&lt;/td&gt;&lt;td&gt;READY&lt;/td&gt;&lt;td&gt;&lt;strong&gt;&lt;code&gt;EXECUTED&lt;/code&gt;&lt;/strong&gt;&lt;/td&gt;&lt;td&gt;&lt;strong&gt;built, correct&lt;/strong&gt;&lt;/td&gt;&lt;td&gt;&lt;strong&gt;0&lt;/strong&gt;&lt;/td&gt;&lt;td&gt;&lt;strong&gt;3.51s&lt;/strong&gt;&lt;/td&gt;&lt;td&gt;6.156s&lt;/td&gt;&lt;/tr&gt;
&lt;/tbody&gt;&lt;/table&gt;
&lt;p&gt;Every leg reached ready, ended at &lt;code&gt;migration_model&lt;/code&gt; 26.7.1 with a 211-row
&lt;code&gt;DATABASECHANGELOG&lt;/code&gt;, and kept 2,000,003 users. L1&#39;s admin token endpoint
returned 200.&lt;/p&gt;
&lt;h3 id=&quot;l1-following-our-own-advice-is-safe&quot;&gt;L1 — following our own advice is safe&lt;/h3&gt;
&lt;pre&gt;&lt;code&gt;CREATE INDEX CONCURRENTLY idx_user_created_timestamp
  ON user_entity (realm_id, created_timestamp);        -- 970 ms, 14 MB
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Liquibase&#39;s precondition sees the index, records changeset
&lt;code&gt;26.6.0-43829-user-created-timestamp-index&lt;/code&gt; as &lt;strong&gt;&lt;code&gt;MARK_RAN&lt;/code&gt;&lt;/strong&gt;, and says nothing —
no &lt;code&gt;CustomCreateIndexChange&lt;/code&gt; warning, no &lt;code&gt;DatabaseIndexChecker&lt;/code&gt; warning. The
checker runs (&lt;code&gt;Running database index checker&lt;/code&gt;) and reports a clean database.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;No &quot;already exists&quot; error, no failure, and no measurable change in migration
time.&lt;/strong&gt; The advice we have been giving is sound.&lt;/p&gt;
&lt;h3 id=&quot;l2-the-control-and-the-reason-l1-means-anything&quot;&gt;L2 — the control, and the reason L1 means anything&lt;/h3&gt;
&lt;p&gt;Same restore, nothing created. Changeset &lt;strong&gt;&lt;code&gt;EXECUTED&lt;/code&gt;&lt;/strong&gt;, index &lt;strong&gt;absent&lt;/strong&gt;, both
WARNs present — reproducing &lt;a href=&quot;/runs/2026-08-25-26.0.0-to-26.7.1-2m/&quot;&gt;&lt;code&gt;2026-08-25-26.0.0-to-26.7.1-2m&lt;/code&gt;&lt;/a&gt; exactly, this
time on a restored rather than freshly seeded database.&lt;/p&gt;
&lt;p&gt;1.69s against L1&#39;s 1.72s: pre-creating the index neither costs nor saves
migration time, because the work being avoided is under a second (L4).&lt;/p&gt;
&lt;h3 id=&quot;l3-a-wrong-index-with-the-right-name-is-accepted-silently&quot;&gt;L3 — a wrong index with the right name is accepted, silently ⭐&lt;/h3&gt;
&lt;p&gt;An operator creates the index Keycloak asked for, with the name Keycloak logged,
and gets the columns wrong — one instead of two:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;CREATE INDEX CONCURRENTLY idx_user_created_timestamp
  ON user_entity (created_timestamp);                   -- 628 ms
&lt;/code&gt;&lt;/pre&gt;
&lt;ul&gt;
&lt;li&gt;changeset → &lt;strong&gt;&lt;code&gt;MARK_RAN&lt;/code&gt;&lt;/strong&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;pg_indexes&lt;/code&gt; still shows the &lt;strong&gt;single-column&lt;/strong&gt; index; Keycloak never replaced it&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;&lt;code&gt;warn_count=0&lt;/code&gt;&lt;/strong&gt; — &lt;code&gt;CustomCreateIndexChange&lt;/code&gt; silent&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;&lt;code&gt;DatabaseIndexChecker&lt;/code&gt; silent too&lt;/strong&gt;, declaring the database correct&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;&lt;strong&gt;The precondition matches on index name only.&lt;/strong&gt; That is visible in Keycloak&#39;s
own changelog, not merely inferred from the behaviour —
&lt;code&gt;META-INF/jpa-changelog-26.6.0.xml&lt;/code&gt;:&lt;/p&gt;
&lt;pre&gt;&lt;code data-lang=&quot;xml&quot;&gt;&amp;lt;changeSet author=&amp;quot;keycloak&amp;quot; id=&amp;quot;26.6.0-43829-user-created-timestamp-index&amp;quot;&amp;gt;
    &amp;lt;preConditions onSqlOutput=&amp;quot;TEST&amp;quot; onFail=&amp;quot;MARK_RAN&amp;quot;&amp;gt;
        &amp;lt;not&amp;gt;
            &amp;lt;indexExists tableName=&amp;quot;USER_ENTITY&amp;quot; indexName=&amp;quot;IDX_USER_CREATED_TIMESTAMP&amp;quot; /&amp;gt;
        &amp;lt;/not&amp;gt;
    &amp;lt;/preConditions&amp;gt;
    &amp;lt;createIndex tableName=&amp;quot;USER_ENTITY&amp;quot; indexName=&amp;quot;IDX_USER_CREATED_TIMESTAMP&amp;quot;&amp;gt;
        &amp;lt;column name=&amp;quot;REALM_ID&amp;quot; type=&amp;quot;VARCHAR(255)&amp;quot; /&amp;gt;
        &amp;lt;column name=&amp;quot;CREATED_TIMESTAMP&amp;quot; type=&amp;quot;BIGINT&amp;quot; /&amp;gt;
    &amp;lt;/createIndex&amp;gt;
&amp;lt;/changeSet&amp;gt;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;code&gt;indexExists&lt;/code&gt; takes a table and a name. It does not take columns. Anything with
the right name satisfies it, whatever it indexes — no check of columns, order or
uniqueness — and Keycloak&#39;s own post-upgrade index verifier accepts it as well.&lt;/p&gt;
&lt;p&gt;The database ends in a state where the index Keycloak wanted does not exist, the
index that does exist does not serve the query it was added for, every
Keycloak-provided diagnostic reports success, and — unlike the skip case — &lt;strong&gt;not
even a WARN is left in the log.&lt;/strong&gt; The skip leaves evidence. This leaves none.&lt;/p&gt;
&lt;p&gt;The route into that state is our own runbook telling the customer to create the
index by hand.&lt;/p&gt;
&lt;h3 id=&quot;l4-what-the-deferred-ddl-costs-at-2-000-003-rows&quot;&gt;L4 — what the deferred DDL costs at 2,000,003 rows&lt;/h3&gt;
&lt;p&gt;Index &lt;code&gt;(realm_id, created_timestamp)&lt;/code&gt; on &lt;code&gt;USER_ENTITY&lt;/code&gt;, measured directly:&lt;/p&gt;
&lt;table&gt;&lt;thead&gt;&lt;tr&gt;&lt;th&gt;&lt;code&gt;maintenance_work_mem&lt;/code&gt;&lt;/th&gt;&lt;th&gt;&lt;code&gt;CREATE INDEX&lt;/code&gt;&lt;/th&gt;&lt;th&gt;&lt;code&gt;CREATE INDEX CONCURRENTLY&lt;/code&gt;&lt;/th&gt;&lt;th&gt;Index size&lt;/th&gt;&lt;/tr&gt;&lt;/thead&gt;&lt;tbody&gt;
&lt;tr&gt;&lt;td&gt;64MB (stock)&lt;/td&gt;&lt;td&gt;&lt;strong&gt;754 ms&lt;/strong&gt;&lt;/td&gt;&lt;td&gt;&lt;strong&gt;969 ms&lt;/strong&gt;&lt;/td&gt;&lt;td&gt;14 MB&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;1GB&lt;/td&gt;&lt;td&gt;&lt;strong&gt;765 ms&lt;/strong&gt;&lt;/td&gt;&lt;td&gt;&lt;strong&gt;1000 ms&lt;/strong&gt;&lt;/td&gt;&lt;td&gt;14 MB&lt;/td&gt;&lt;/tr&gt;
&lt;/tbody&gt;&lt;/table&gt;
&lt;p&gt;&lt;strong&gt;The work Keycloak declines is under one second at two million users.&lt;/strong&gt; The
index-skip finding is not &quot;Keycloak avoids an expensive rebuild&quot;; it is &quot;Keycloak
avoids 750 ms and a brief &lt;code&gt;ACCESS EXCLUSIVE&lt;/code&gt; lock on &lt;code&gt;USER_ENTITY&lt;/code&gt;&quot;. The lock is
the real reason. The duration is not.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;&lt;code&gt;maintenance_work_mem&lt;/code&gt; does not matter for this index.&lt;/strong&gt; Our own scenario notes claimed that &quot;a 2M-row index build is dominated by&quot; those settings. Raising it 16×
changed the build by 1.5%: the index is 14 MB and the sort fits inside the stock
64 MB either way. The field is still worth recording — the reason given for it is
wrong at this scale.&lt;/p&gt;
&lt;h3 id=&quot;l5-the-threshold-is-a-planner-estimate-not-a-row-count&quot;&gt;L5 — the threshold is a planner estimate, not a row count ⭐⭐&lt;/h3&gt;
&lt;p&gt;&lt;a href=&quot;/runs/2026-08-25-index-skip-threshold/&quot;&gt;&lt;code&gt;2026-08-25-index-skip-threshold&lt;/code&gt;&lt;/a&gt; left open &quot;whether row count comes from
statistics or an exact count&quot;. L2 could not separate them: a freshly restored 2M
database carries an accurate &lt;code&gt;reltuples&lt;/code&gt; (2000003, &lt;code&gt;relpages&lt;/code&gt; 53392) even with no
&lt;code&gt;last_analyze&lt;/code&gt;, because the index builds during &lt;code&gt;pg_restore&lt;/code&gt; set it.&lt;/p&gt;
&lt;p&gt;So we lied to the planner instead, changing nothing else:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;UPDATE pg_class SET reltuples = 1000, relpages = 100 WHERE relname = &amp;#39;user_entity&amp;#39;;
-- actual rows: 2000003
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;Keycloak built the index.&lt;/strong&gt; Correct two-column definition, changeset
&lt;code&gt;EXECUTED&lt;/code&gt;, &lt;strong&gt;zero warnings&lt;/strong&gt; — a genuine &lt;code&gt;CREATE INDEX&lt;/code&gt; against a 2,000,003-row
table, during the migration, because a catalogue row said the table was small.&lt;/p&gt;
&lt;p&gt;An exact &lt;code&gt;SELECT count(*)&lt;/code&gt; is unaffected by &lt;code&gt;pg_class&lt;/code&gt;, so that hypothesis is
refuted. &lt;strong&gt;&lt;code&gt;CustomCreateIndexChange&lt;/code&gt; reads the planner&#39;s estimate.&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;And it produced the first non-flat migration this lab has measured:&lt;/p&gt;
&lt;table&gt;&lt;thead&gt;&lt;tr&gt;&lt;th&gt;&lt;/th&gt;&lt;th&gt;Liquibase phase&lt;/th&gt;&lt;/tr&gt;&lt;/thead&gt;&lt;tbody&gt;
&lt;tr&gt;&lt;td&gt;L2 — index skipped (honest statistics)&lt;/td&gt;&lt;td&gt;1.69s&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;&lt;strong&gt;L5 — index built (statistics say 1,000)&lt;/strong&gt;&lt;/td&gt;&lt;td&gt;&lt;strong&gt;3.51s&lt;/strong&gt;&lt;/td&gt;&lt;/tr&gt;
&lt;/tbody&gt;&lt;/table&gt;
&lt;p&gt;&lt;strong&gt;+1.82s, at identical true scale, from the same baseline.&lt;/strong&gt; Everything this lab
has said about migration duration being flat holds only while Keycloak is
declining the DDL, and what makes it decline is a number that is stale by design.&lt;/p&gt;
&lt;h2 id=&quot;breakage-observed&quot;&gt;Breakage observed&lt;/h2&gt;
&lt;h3 id=&quot;a-manually-created-index-with-the-wrong-columns-is-accepted-silently&quot;&gt;A manually created index with the wrong columns is accepted silently&lt;/h3&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Symptom:&lt;/strong&gt; upgrade succeeds, changeset &lt;code&gt;MARK_RAN&lt;/code&gt;, zero warnings, and the
database holds an index that does not match what the changeset defines.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Evidence:&lt;/strong&gt; L3. &lt;code&gt;pg_indexes.indexdef&lt;/code&gt; reads &lt;code&gt;btree (created_timestamp)&lt;/code&gt; where
the changeset wants &lt;code&gt;btree (realm_id, created_timestamp)&lt;/code&gt;; &lt;code&gt;warn_count=0&lt;/code&gt; from
both &lt;code&gt;CustomCreateIndexChange&lt;/code&gt; and &lt;code&gt;DatabaseIndexChecker&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Cause:&lt;/strong&gt; &lt;strong&gt;confirmed&lt;/strong&gt; — the Liquibase precondition and the index checker both
key on index &lt;em&gt;name&lt;/em&gt;.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Fix:&lt;/strong&gt; verify by definition, not by existence. Compare &lt;code&gt;pg_indexes.indexdef&lt;/code&gt;
against the changeset&#39;s column list; name equality is not evidence.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Would this hit a customer?&lt;/strong&gt; Any customer who followed the &quot;create these
manually&quot; advice and mistyped, and nothing in Keycloak will ever tell them.&lt;/li&gt;
&lt;/ul&gt;
&lt;h3 id=&quot;whether-the-index-is-built-depends-on-when-autovacuum-last-looked&quot;&gt;Whether the index is built depends on when autovacuum last looked&lt;/h3&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Symptom:&lt;/strong&gt; the same database, at the same true size, either skips the index
or builds it, according to &lt;code&gt;pg_class.reltuples&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Evidence:&lt;/strong&gt; L2 (accurate statistics → skipped) against L5 (statistics say
1,000 → built, +1.82s of migration).&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Cause:&lt;/strong&gt; &lt;strong&gt;confirmed&lt;/strong&gt; — &lt;code&gt;CustomCreateIndexChange&lt;/code&gt; consults the planner
estimate. Which of &lt;code&gt;reltuples&lt;/code&gt; / &lt;code&gt;relpages&lt;/code&gt; it reads was not isolated; both were
changed together.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Fix:&lt;/strong&gt; none available to the operator, and that is the point. &lt;code&gt;reltuples&lt;/code&gt; is
only refreshed by &lt;code&gt;ANALYZE&lt;/code&gt;, &lt;code&gt;VACUUM&lt;/code&gt; and index builds, so a table that has
grown since its last autovacuum carries a stale, low estimate.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Would this hit a customer?&lt;/strong&gt; Scale-dependent and &lt;em&gt;timing&lt;/em&gt;-dependent. Two
identical production databases can diverge on autovacuum history alone — and a
staging rehearsal is not evidence about production unless the estimates match.
It is also the mechanism by which a customer gets a slow migration they have no
way to predict, which re-arms S3 (&lt;code&gt;statement_timeout&lt;/code&gt;) and S14 (disk) at any
realm size.&lt;/li&gt;
&lt;/ul&gt;
&lt;h3 id=&quot;executed-and-mark-ran-do-not-mean-what-they-look-like&quot;&gt;&lt;code&gt;EXECUTED&lt;/code&gt; and &lt;code&gt;MARK_RAN&lt;/code&gt; do not mean what they look like&lt;/h3&gt;
&lt;ul&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;Symptom:&lt;/strong&gt; the row that reads as &quot;we did not run this&quot; is the healthy one.&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;Evidence:&lt;/strong&gt; four combinations observed across the legs:&lt;/p&gt;
&lt;table&gt;&lt;thead&gt;&lt;tr&gt;&lt;th&gt;&lt;code&gt;exectype&lt;/code&gt;&lt;/th&gt;&lt;th&gt;Index&lt;/th&gt;&lt;th&gt;What actually happened&lt;/th&gt;&lt;/tr&gt;&lt;/thead&gt;&lt;tbody&gt;
&lt;tr&gt;&lt;td&gt;&lt;code&gt;EXECUTED&lt;/code&gt;&lt;/td&gt;&lt;td&gt;present&lt;/td&gt;&lt;td&gt;Keycloak built it — L5, and every realm under the threshold&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;&lt;code&gt;EXECUTED&lt;/code&gt;&lt;/td&gt;&lt;td&gt;&lt;strong&gt;absent&lt;/strong&gt;&lt;/td&gt;&lt;td&gt;&lt;strong&gt;Keycloak skipped it&lt;/strong&gt; — L2, the dangerous case&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;&lt;code&gt;MARK_RAN&lt;/code&gt;&lt;/td&gt;&lt;td&gt;present, correct&lt;/td&gt;&lt;td&gt;someone created it — L1, healthy&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;&lt;code&gt;MARK_RAN&lt;/code&gt;&lt;/td&gt;&lt;td&gt;present, &lt;strong&gt;wrong shape&lt;/strong&gt;&lt;/td&gt;&lt;td&gt;someone created the wrong thing — L3, silent&lt;/td&gt;&lt;/tr&gt;
&lt;/tbody&gt;&lt;/table&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;Fix:&lt;/strong&gt; &lt;code&gt;exectype&lt;/code&gt; is only usable together with the index&#39;s presence &lt;em&gt;and&lt;/em&gt; its
definition. On its own it distinguishes nothing.&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;Would this hit a customer?&lt;/strong&gt; Anyone auditing the changelog rather than the
schema — which is exactly what the changelog invites.&lt;/p&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;h2 id=&quot;rollback&quot;&gt;Rollback&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Exercised this run?&lt;/strong&gt; L1 took a pre-upgrade backup through the default path
(&lt;code&gt;pre-upgrade-26.0.0-to-26.7.1-20260825T225316Z&lt;/code&gt;, 65 MB). L2, L3 and L5 ran
&lt;code&gt;--no-backup&lt;/code&gt; deliberately: each was preceded by a verified restore of the same
baseline, which is the case that flag documents.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Verdict:&lt;/strong&gt; unchanged from &lt;a href=&quot;/runs/2026-08-25-26.0.0-to-26.7.1-3/&quot;&gt;&lt;code&gt;2026-08-25-26.0.0-to-26.7.1-3&lt;/code&gt;&lt;/a&gt; — rollback on
this path is a point-in-time restore.&lt;/li&gt;
&lt;/ul&gt;
&lt;h2 id=&quot;verification&quot;&gt;Verification&lt;/h2&gt;
&lt;table&gt;&lt;thead&gt;&lt;tr&gt;&lt;th&gt;Claim&lt;/th&gt;&lt;th&gt;Primary source&lt;/th&gt;&lt;th&gt;Checked&lt;/th&gt;&lt;/tr&gt;&lt;/thead&gt;&lt;tbody&gt;
&lt;tr&gt;&lt;td&gt;A pre-existing correct index does not break the upgrade&lt;/td&gt;&lt;td&gt;L1 — READY, model 26.7.1, 211 changelog rows&lt;/td&gt;&lt;td&gt;☑&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;It is recorded &lt;code&gt;MARK_RAN&lt;/code&gt;, not &lt;code&gt;EXECUTED&lt;/code&gt;&lt;/td&gt;&lt;td&gt;L1 &lt;code&gt;databasechangelog.exectype&lt;/code&gt;&lt;/td&gt;&lt;td&gt;☑&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;No warning is emitted when the index is present&lt;/td&gt;&lt;td&gt;L1 — grep of both WARN strings, 0 hits&lt;/td&gt;&lt;td&gt;☑&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Control reproduces the skip on a restored DB&lt;/td&gt;&lt;td&gt;L2 — &lt;code&gt;EXECUTED&lt;/code&gt;, index absent, 2 WARNs&lt;/td&gt;&lt;td&gt;☑&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Migration time unchanged by the index being present&lt;/td&gt;&lt;td&gt;L1 1.72s vs L2 1.69s, same boundary&lt;/td&gt;&lt;td&gt;☑&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;A wrong-shape index with the right name is accepted&lt;/td&gt;&lt;td&gt;L3 — &lt;code&gt;MARK_RAN&lt;/code&gt;, &lt;code&gt;warn_count=0&lt;/code&gt;, &lt;code&gt;indexdef&lt;/code&gt; unchanged&lt;/td&gt;&lt;td&gt;☑&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;The precondition is name-only by construction&lt;/td&gt;&lt;td&gt;&lt;code&gt;jpa-changelog-26.6.0.xml&lt;/code&gt; — &lt;code&gt;&amp;lt;indexExists tableName= indexName=&amp;gt;&lt;/code&gt;, no columns&lt;/td&gt;&lt;td&gt;☑&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;&lt;code&gt;DatabaseIndexChecker&lt;/code&gt; accepts it too&lt;/td&gt;&lt;td&gt;L3 — checker ran, emitted nothing&lt;/td&gt;&lt;td&gt;☑&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Index build at 2M is sub-second&lt;/td&gt;&lt;td&gt;L4 — 754 ms plain, 969 ms concurrent&lt;/td&gt;&lt;td&gt;☑&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;&lt;code&gt;maintenance_work_mem&lt;/code&gt; does not change that build&lt;/td&gt;&lt;td&gt;L4 — 64MB vs 1GB, 1.5% apart&lt;/td&gt;&lt;td&gt;☑&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;The threshold reads a planner estimate, not &lt;code&gt;count(*)&lt;/code&gt;&lt;/td&gt;&lt;td&gt;L5 — falsified &lt;code&gt;pg_class&lt;/code&gt;, index built at 2M&lt;/td&gt;&lt;td&gt;☑&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;A restored database carries accurate &lt;code&gt;reltuples&lt;/code&gt;&lt;/td&gt;&lt;td&gt;L2 pre — 2000003, &lt;code&gt;relpages&lt;/code&gt; 53392, no &lt;code&gt;last_analyze&lt;/code&gt;&lt;/td&gt;&lt;td&gt;☑&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Building the index makes the migration non-flat&lt;/td&gt;&lt;td&gt;L5 3.51s vs L2 1.69s, same true scale&lt;/td&gt;&lt;td&gt;☑&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Whether &lt;code&gt;reltuples&lt;/code&gt; or &lt;code&gt;relpages&lt;/code&gt; is the input&lt;/td&gt;&lt;td&gt;&lt;strong&gt;not isolated&lt;/strong&gt; — both were changed together&lt;/td&gt;&lt;td&gt;☐&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Whether the precondition accepts a &lt;em&gt;unique&lt;/em&gt; index of the same name&lt;/td&gt;&lt;td&gt;&lt;strong&gt;not tested&lt;/strong&gt;&lt;/td&gt;&lt;td&gt;☐&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Whether other &lt;code&gt;CustomCreateIndexChange&lt;/code&gt; changesets behave identically&lt;/td&gt;&lt;td&gt;&lt;strong&gt;not tested&lt;/strong&gt; — only &lt;code&gt;26.6.0-43829&lt;/code&gt;&lt;/td&gt;&lt;td&gt;☐&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Behaviour on version pairs other than 26.0.0 → 26.7.1&lt;/td&gt;&lt;td&gt;&lt;strong&gt;not tested&lt;/strong&gt;&lt;/td&gt;&lt;td&gt;☐&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Whether a stale estimate arises naturally at customer scale&lt;/td&gt;&lt;td&gt;&lt;strong&gt;answered, and narrower than this run implies&lt;/strong&gt; — stock autovacuum corrects a bulk load mid-flight; it needs &lt;code&gt;autovacuum_enabled=false&lt;/code&gt; on the table. See &lt;a href=&quot;/runs/2026-08-26-stale-statistics-reachability/&quot;&gt;&lt;code&gt;2026-08-26-stale-statistics-reachability&lt;/code&gt;&lt;/a&gt;&lt;/td&gt;&lt;td&gt;☑&lt;/td&gt;&lt;/tr&gt;
&lt;/tbody&gt;&lt;/table&gt;
&lt;h2 id=&quot;publishable-extract&quot;&gt;Publishable extract&lt;/h2&gt;
&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Keycloak decides whether to build a database index during an upgrade by
asking Postgres how big the table is — and Postgres answers from a cached
estimate that can be wrong.&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;Some background. Keycloak skips creating an index during an upgrade when the
target table has more than 300,000 rows: it logs two warnings, records the
changeset as &lt;code&gt;EXECUTED&lt;/code&gt;, and leaves the index uncreated. We measured that
boundary earlier and it is exact.&lt;/p&gt;
&lt;p&gt;What we had not established is where the row count comes from. It is
&lt;code&gt;pg_class&lt;/code&gt; — the planner&#39;s estimate — not a &lt;code&gt;SELECT count(*)&lt;/code&gt;.&lt;/p&gt;
&lt;p&gt;On a 2,000,003-user realm (Keycloak 26.0.0 → 26.7.1, Postgres 16 at stock
settings, Hetzner CCX33: 8 vCPU dedicated, 32 GB, local NVMe), we changed one
catalogue row to claim &lt;code&gt;USER_ENTITY&lt;/code&gt; held 1,000 rows, and changed nothing else.
Keycloak built the index — a real &lt;code&gt;CREATE INDEX&lt;/code&gt; on two million rows — and the
schema migration went from &lt;strong&gt;1.69 seconds to 3.51 seconds&lt;/strong&gt;.&lt;/p&gt;
&lt;p&gt;That matters because &lt;code&gt;reltuples&lt;/code&gt; is refreshed only by &lt;code&gt;ANALYZE&lt;/code&gt;, &lt;code&gt;VACUUM&lt;/code&gt; and
index builds. A table that has grown since autovacuum last visited carries a
stale, low estimate. So two databases of identical size can take different
paths through the same upgrade, and neither operator has any way to know which
they will get. &lt;strong&gt;Your staging rehearsal tells you about production only if the
statistics match, and nobody checks that.&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;Second finding, and the one we did not go looking for. Keycloak&#39;s warning tells
you to create the index yourself:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;Missing database index IDX_USER_CREATED_TIMESTAMP on table USER_ENTITY.
Create the index manually: CREATE INDEX IDX_USER_CREATED_TIMESTAMP ON
  public.USER_ENTITY(REALM_ID, CREATED_TIMESTAMP);
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Doing exactly that is safe: the next upgrade sees the index, marks the
changeset &lt;code&gt;MARK_RAN&lt;/code&gt;, and warns about nothing. We checked, because &quot;the
changeset will try to create an index that already exists&quot; was a real risk and
we had never tested it.&lt;/p&gt;
&lt;p&gt;But the check Keycloak performs is &lt;strong&gt;on the index&#39;s name, and nothing else.&lt;/strong&gt;
We created an index called &lt;code&gt;IDX_USER_CREATED_TIMESTAMP&lt;/code&gt; on the wrong columns —
one column instead of two, the kind of mistake a hurried copy-paste makes — and
upgraded. Keycloak marked the changeset satisfied, emitted no warning, and its
own &lt;code&gt;DatabaseIndexChecker&lt;/code&gt; reported the database correct. The index Keycloak
wanted does not exist. The one that does will not serve the query it was added
for. Nothing in Keycloak will ever say so again — and unlike the skip, this
state does not even leave a warning in the log.&lt;/p&gt;
&lt;p&gt;A last number, because it reframes the trade-off. That index takes &lt;strong&gt;754 ms&lt;/strong&gt;
to build on two million rows, or 969 ms with &lt;code&gt;CONCURRENTLY&lt;/code&gt;, and occupies
14 MB. Raising &lt;code&gt;maintenance_work_mem&lt;/code&gt; from the stock 64 MB to 1 GB changes it
by 1.5%. Keycloak is not avoiding an expensive rebuild — it is avoiding a
sub-second &lt;code&gt;ACCESS EXCLUSIVE&lt;/code&gt; lock. That is a defensible thing to avoid during
startup. It is worth knowing that the price of avoiding it is an index your
database may never get.&lt;/p&gt;
&lt;p&gt;If you have upgraded Keycloak at any real scale: list the indexes its
changelogs define, and compare them against &lt;code&gt;pg_indexes.indexdef&lt;/code&gt; — the
definition, not just the name. Presence is not correctness here.&lt;/p&gt;
&lt;/blockquote&gt;
</content>
        
    </entry>
    <entry xml:lang="en">
        <title>Rehearsal Record — S1/S2: Liquibase lock failure modes (could not reproduce)</title>
        <published>2026-08-25T00:00:00+00:00</published>
        <updated>2026-08-25T00:00:00+00:00</updated>
        
        <author>
          <name>Unknown</name>
        </author>
        
        <link rel="alternate" type="text/html" href="https://ledger.mlabs.city/runs/2026-08-25-s1-s2-lock-failure-modes/"/>
        <id>https://ledger.mlabs.city/runs/2026-08-25-s1-s2-lock-failure-modes/</id>
        
        <content type="html" xml:base="https://ledger.mlabs.city/runs/2026-08-25-s1-s2-lock-failure-modes/">&lt;h2 id=&quot;summary&quot;&gt;Summary&lt;/h2&gt;
&lt;p&gt;&lt;strong&gt;We tried to reproduce the canonical Keycloak upgrade horror story — a killed
migration leaving a stuck Liquibase lock that blocks every subsequent start — and
could not, on Postgres 16 single-node.&lt;/strong&gt; Every failure mode we induced recovered
by itself.&lt;/p&gt;
&lt;p&gt;This is a negative result and it is stated as one. It does not prove the story is
false; it bounds where it applies.&lt;/p&gt;
&lt;h2 id=&quot;environment&quot;&gt;Environment&lt;/h2&gt;
&lt;table&gt;&lt;thead&gt;&lt;tr&gt;&lt;th&gt;Field&lt;/th&gt;&lt;th&gt;Value&lt;/th&gt;&lt;/tr&gt;&lt;/thead&gt;&lt;tbody&gt;
&lt;tr&gt;&lt;td&gt;Keycloak from → to&lt;/td&gt;&lt;td&gt;26.0.0 → 26.7.1&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Database&lt;/td&gt;&lt;td&gt;PostgreSQL 16.15, &lt;strong&gt;single node&lt;/strong&gt;, container&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Postgres tuning&lt;/td&gt;&lt;td&gt;stock — &lt;code&gt;shared_buffers&lt;/code&gt; 128MB, &lt;code&gt;maintenance_work_mem&lt;/code&gt; 64MB, &lt;code&gt;statement_timeout&lt;/code&gt; 0&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Dataset&lt;/td&gt;&lt;td&gt;100,002 users, &lt;code&gt;typical&lt;/code&gt; profile&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Topology&lt;/td&gt;&lt;td&gt;single container (S1); &lt;strong&gt;two containers, one compose network&lt;/strong&gt; (S2)&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Host&lt;/td&gt;&lt;td&gt;Hetzner CCX33 (fsn1) — 8 vCPU dedicated, 30GB RAM, local NVMe&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;JVM heap&lt;/td&gt;&lt;td&gt;&lt;code&gt;-Xms1g -Xmx4g&lt;/code&gt;&lt;/td&gt;&lt;/tr&gt;
&lt;/tbody&gt;&lt;/table&gt;
&lt;h2 id=&quot;what-we-could-not-observe&quot;&gt;What we could not observe&lt;/h2&gt;
&lt;p&gt;&lt;strong&gt;The lock was never sampled in a held state&lt;/strong&gt;, in either scenario. Polling ran
through &lt;code&gt;docker compose exec … psql&lt;/code&gt;, roughly 200–400ms per sample, against a
migration that completes in ~3.5s. Absence of observation here is a limit of the
instrument, not evidence the lock is not taken. Do not read this record as
saying Keycloak does not lock — it says we never caught it holding one.&lt;/p&gt;
&lt;h2 id=&quot;verification&quot;&gt;Verification&lt;/h2&gt;
&lt;table&gt;&lt;thead&gt;&lt;tr&gt;&lt;th&gt;Claim&lt;/th&gt;&lt;th&gt;Primary source&lt;/th&gt;&lt;th&gt;Checked&lt;/th&gt;&lt;/tr&gt;&lt;/thead&gt;&lt;tbody&gt;
&lt;tr&gt;&lt;td&gt;Half-migrated schema self-completes&lt;/td&gt;&lt;td&gt;changelog 165 → 211 across a restart, read from Postgres&lt;/td&gt;&lt;td&gt;☑&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;No stuck lock after SIGKILL&lt;/td&gt;&lt;td&gt;&lt;code&gt;databasechangeloglock&lt;/code&gt; queried directly, both runs&lt;/td&gt;&lt;td&gt;☑&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Concurrent start ends in a correct schema&lt;/td&gt;&lt;td&gt;changelog 211, model 26.7.1, users intact&lt;/td&gt;&lt;td&gt;☑&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Lock is genuinely held at some point&lt;/td&gt;&lt;td&gt;&lt;strong&gt;not observed&lt;/strong&gt; — sampling too coarse&lt;/td&gt;&lt;td&gt;☐&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Holds on a slow migration&lt;/td&gt;&lt;td&gt;&lt;strong&gt;not tested&lt;/strong&gt; — every migration here was ~3.5s&lt;/td&gt;&lt;td&gt;☐&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Holds on Postgres HA / failover mid-migration&lt;/td&gt;&lt;td&gt;not tested&lt;/td&gt;&lt;td&gt;☐&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Holds on other databases&lt;/td&gt;&lt;td&gt;not tested — Postgres only&lt;/td&gt;&lt;td&gt;☐&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Holds for other version pairs&lt;/td&gt;&lt;td&gt;not tested&lt;/td&gt;&lt;td&gt;☐&lt;/td&gt;&lt;/tr&gt;
&lt;/tbody&gt;&lt;/table&gt;
&lt;h2 id=&quot;publishable-extract&quot;&gt;Publishable extract&lt;/h2&gt;
&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;We tried to reproduce Keycloak&#39;s most-repeated upgrade horror story and
could not.&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;The story: a Keycloak upgrade dies partway, Liquibase&#39;s lock row is never
released, and every subsequent start hangs waiting for a lock nobody holds —
including the start you attempt in order to roll back.&lt;/p&gt;
&lt;p&gt;On Postgres 16.15, single node, with a 100,002-user realm on a Hetzner CCX33
(8 vCPU dedicated, 32 GB, local NVMe), upgrading 26.0.0 → 26.7.1, we killed
Keycloak with SIGKILL during the schema migration. Twice. The second time we
waited until the migration was genuinely half-applied — 165 of 211 changesets
committed — before killing it.&lt;/p&gt;
&lt;p&gt;Both times the lock table showed zero held locks afterwards, and both times the
next start completed the migration by itself in 13 seconds, ending at the
correct schema version with all 100,002 users intact. We also started two
instances against an un-migrated schema simultaneously; both came up, and the
schema ended correct.&lt;/p&gt;
&lt;p&gt;One honest caveat, and it is the interesting part. These migrations take about
three and a half seconds — because Keycloak &lt;em&gt;skips&lt;/em&gt; index creation on tables
over 300,000 rows rather than block startup on them. The window in which a
crash can hurt you is narrow because Keycloak works hard to keep it narrow. If
your upgrade includes a changeset that genuinely rewrites a large table, or you
have manually built the indexes Keycloak declined to build, your window is
wider than ours and this result may not carry.&lt;/p&gt;
&lt;p&gt;We would rather publish a negative result than repeat folklore. If you have a
reproducible stuck lock, we want the configuration — we will run it and publish
what happens.&lt;/p&gt;
&lt;/blockquote&gt;
</content>
        
    </entry>
    <entry xml:lang="en">
        <title>Rehearsal Record — S10: a missing theme falls back silently, and the error arrives after the window closes</title>
        <published>2026-08-25T00:00:00+00:00</published>
        <updated>2026-08-25T00:00:00+00:00</updated>
        
        <author>
          <name>Unknown</name>
        </author>
        
        <link rel="alternate" type="text/html" href="https://ledger.mlabs.city/runs/2026-08-25-s10-missing-theme/"/>
        <id>https://ledger.mlabs.city/runs/2026-08-25-s10-missing-theme/</id>
        
        <content type="html" xml:base="https://ledger.mlabs.city/runs/2026-08-25-s10-missing-theme/">&lt;h2 id=&quot;summary&quot;&gt;Summary&lt;/h2&gt;
&lt;p&gt;The &lt;code&gt;lab&lt;/code&gt; realm has named custom themes since the first run — &lt;code&gt;seed-realm.sh&lt;/code&gt;&#39;s
&lt;code&gt;typical&lt;/code&gt; profile sets &lt;code&gt;loginTheme: lab-login&lt;/code&gt;, &lt;code&gt;accountTheme: lab-account&lt;/code&gt;,
&lt;code&gt;emailTheme: lab-email&lt;/code&gt; — and &lt;strong&gt;no theme files have ever been mounted.&lt;/strong&gt; So S10
has silently been in force in every record this lab has produced.&lt;/p&gt;
&lt;p&gt;The answer: Keycloak &lt;strong&gt;falls back to the built-in theme and serves a working
login page&lt;/strong&gt;. It logs the problem at &lt;code&gt;ERROR&lt;/code&gt; level — but &lt;em&gt;lazily, on the first
request that needs the theme&lt;/em&gt;, not at startup. An operator who upgrades, watches
the startup log, sees a clean start and closes the maintenance window has had no
opportunity to see it.&lt;/p&gt;
&lt;h2 id=&quot;environment&quot;&gt;Environment&lt;/h2&gt;
&lt;table&gt;&lt;thead&gt;&lt;tr&gt;&lt;th&gt;Field&lt;/th&gt;&lt;th&gt;Value&lt;/th&gt;&lt;/tr&gt;&lt;/thead&gt;&lt;tbody&gt;
&lt;tr&gt;&lt;td&gt;Keycloak version&lt;/td&gt;&lt;td&gt;26.7.1 (arrived via 26.0.0 → 26.7.1)&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Distribution / start mode&lt;/td&gt;&lt;td&gt;quay.io/keycloak/keycloak official image, &lt;code&gt;start&lt;/code&gt; w/ external DB&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Database&lt;/td&gt;&lt;td&gt;PostgreSQL 16.15, single node, container&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Postgres tuning&lt;/td&gt;&lt;td&gt;stock — &lt;code&gt;shared_buffers&lt;/code&gt; 128MB, &lt;code&gt;maintenance_work_mem&lt;/code&gt; 64MB, &lt;code&gt;statement_timeout&lt;/code&gt; 0&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Adverse scenario&lt;/td&gt;&lt;td&gt;S10&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Dataset scale&lt;/td&gt;&lt;td&gt;2,000,003 users, 2 realms&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Seeding method&lt;/td&gt;&lt;td&gt;direct SQL COPY, over a &lt;code&gt;partialImport&lt;/code&gt; realm skeleton&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Realm theme config&lt;/td&gt;&lt;td&gt;&lt;code&gt;login_theme=lab-login&lt;/code&gt;, &lt;code&gt;account_theme=lab-account&lt;/code&gt;, &lt;code&gt;email_theme=lab-email&lt;/code&gt;, &lt;code&gt;admin_theme=keycloak&lt;/code&gt; — read from the &lt;code&gt;realm&lt;/code&gt; table&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Themes mounted&lt;/td&gt;&lt;td&gt;&lt;strong&gt;none&lt;/strong&gt; — &lt;code&gt;/opt/keycloak/themes/&lt;/code&gt; is empty; only &lt;code&gt;fixtures/providers&lt;/code&gt; is bind-mounted&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Topology&lt;/td&gt;&lt;td&gt;single container&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Host&lt;/td&gt;&lt;td&gt;Hetzner CCX33 (fsn1) — 8 vCPU dedicated, 30 GB RAM, local NVMe&lt;/td&gt;&lt;/tr&gt;
&lt;/tbody&gt;&lt;/table&gt;
&lt;h2 id=&quot;procedure&quot;&gt;Procedure&lt;/h2&gt;
&lt;pre&gt;&lt;code&gt;docker compose exec -T db psql -U keycloak -c \
  &amp;quot;select name, login_theme, account_theme, email_theme, admin_theme from realm&amp;quot;

curl -sL &amp;quot;http://localhost:8080/realms/lab/protocol/openid-connect/auth\
?client_id=spa-public&amp;amp;response_type=code&amp;amp;scope=openid\
&amp;amp;redirect_uri=https%3A%2F%2Fspa.lab.invalid%2Fcb\
&amp;amp;code_challenge_method=S256&amp;amp;code_challenge=&amp;lt;S256 of a verifier&amp;gt;&amp;quot;

curl -s &amp;quot;http://localhost:8080/realms/lab/account/&amp;quot;
docker compose logs keycloak | grep -i theme
&lt;/code&gt;&lt;/pre&gt;
&lt;h2 id=&quot;results&quot;&gt;Results&lt;/h2&gt;
&lt;table&gt;&lt;thead&gt;&lt;tr&gt;&lt;th&gt;Surface&lt;/th&gt;&lt;th&gt;HTTP&lt;/th&gt;&lt;th&gt;Body&lt;/th&gt;&lt;th&gt;Theme actually served&lt;/th&gt;&lt;/tr&gt;&lt;/thead&gt;&lt;tbody&gt;
&lt;tr&gt;&lt;td&gt;&lt;code&gt;lab&lt;/code&gt; realm login page&lt;/td&gt;&lt;td&gt;&lt;strong&gt;200&lt;/strong&gt;&lt;/td&gt;&lt;td&gt;8,094 bytes, full &lt;code&gt;kc-form-login&lt;/code&gt; form, &lt;code&gt;&amp;lt;title&amp;gt;Sign in to Lab realm lab&amp;lt;/title&amp;gt;&lt;/code&gt;&lt;/td&gt;&lt;td&gt;built-in &lt;code&gt;login/keycloak.v2&lt;/code&gt;&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;&lt;code&gt;lab&lt;/code&gt; realm account console&lt;/td&gt;&lt;td&gt;&lt;strong&gt;200&lt;/strong&gt;&lt;/td&gt;&lt;td&gt;4,213 bytes&lt;/td&gt;&lt;td&gt;built-in &lt;code&gt;account/keycloak.v3&lt;/code&gt;&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;&lt;code&gt;master&lt;/code&gt; realm login (no custom theme configured)&lt;/td&gt;&lt;td&gt;302 → 200&lt;/td&gt;&lt;td&gt;—&lt;/td&gt;&lt;td&gt;built-in, as expected&lt;/td&gt;&lt;/tr&gt;
&lt;/tbody&gt;&lt;/table&gt;
&lt;p&gt;The login page is fully functional. Asset URLs in the served HTML resolve to
&lt;code&gt;resources/he5u5/login/keycloak.v2/…&lt;/code&gt; and
&lt;code&gt;resources/he5u5/account/keycloak.v3/…&lt;/code&gt; — the built-in themes, not the
configured names.&lt;/p&gt;
&lt;h3 id=&quot;the-logging-is-real-but-late&quot;&gt;The logging is real but late&lt;/h3&gt;
&lt;pre&gt;&lt;code&gt;19:03:19,962 ERROR [org.keycloak.theme.DefaultThemeManager] Failed to find ACCOUNT theme lab-account, using built-in themes
19:03:42,949 ERROR [org.keycloak.theme.DefaultThemeManager] Failed to find LOGIN  theme lab-login,   using built-in themes
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Three theme-related lines in the entire container log, and &lt;strong&gt;all three carry the
timestamp of the request that triggered them&lt;/strong&gt;, not of startup. The ACCOUNT line
appeared when the account console was first fetched; the LOGIN line 23 seconds
later, when the login page was first fetched. Before those requests, the log
contained nothing about themes at all.&lt;/p&gt;
&lt;p&gt;That is the finding. The severity is right — &lt;code&gt;ERROR&lt;/code&gt; — but the &lt;em&gt;timing&lt;/em&gt; is
wrong for an upgrade: the signal is emitted by the first user, not by the
upgrade.&lt;/p&gt;
&lt;h2 id=&quot;outcome&quot;&gt;Outcome&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;&lt;input disabled=&quot;&quot; type=&quot;checkbox&quot; checked=&quot;&quot;/&gt;
Reached ready&lt;/li&gt;
&lt;li&gt;&lt;input disabled=&quot;&quot; type=&quot;checkbox&quot; checked=&quot;&quot;/&gt;
Login flow renders — with the wrong branding&lt;/li&gt;
&lt;li&gt;&lt;input disabled=&quot;&quot; type=&quot;checkbox&quot;/&gt;
Themes render — &lt;strong&gt;no&lt;/strong&gt;, silently substituted&lt;/li&gt;
&lt;li&gt;&lt;input disabled=&quot;&quot; type=&quot;checkbox&quot; checked=&quot;&quot;/&gt;
Logged, at &lt;code&gt;ERROR&lt;/code&gt;, but only on first use&lt;/li&gt;
&lt;/ul&gt;
&lt;h2 id=&quot;verification&quot;&gt;Verification&lt;/h2&gt;
&lt;table&gt;&lt;thead&gt;&lt;tr&gt;&lt;th&gt;Claim&lt;/th&gt;&lt;th&gt;Primary source&lt;/th&gt;&lt;th&gt;Checked&lt;/th&gt;&lt;/tr&gt;&lt;/thead&gt;&lt;tbody&gt;
&lt;tr&gt;&lt;td&gt;Realm names themes that do not exist&lt;/td&gt;&lt;td&gt;&lt;code&gt;realm&lt;/code&gt; table read directly&lt;/td&gt;&lt;td&gt;☑&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Login page still returns 200 and a usable form&lt;/td&gt;&lt;td&gt;&lt;code&gt;curl&lt;/code&gt;, 8,094 bytes, &lt;code&gt;kc-form-login&lt;/code&gt; present&lt;/td&gt;&lt;td&gt;☑&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Built-in theme is substituted&lt;/td&gt;&lt;td&gt;asset paths &lt;code&gt;login/keycloak.v2&lt;/code&gt;, &lt;code&gt;account/keycloak.v3&lt;/code&gt; in the served HTML&lt;/td&gt;&lt;td&gt;☑&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;The ERROR is emitted on first request, not at startup&lt;/td&gt;&lt;td&gt;log timestamps vs. request times; zero theme lines before the requests&lt;/td&gt;&lt;td&gt;☑&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Email theme behaviour&lt;/td&gt;&lt;td&gt;&lt;strong&gt;not tested&lt;/strong&gt; — needs an actual send; a broken &lt;code&gt;emailTheme&lt;/code&gt; may not be as forgiving&lt;/td&gt;&lt;td&gt;☐&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;A &lt;em&gt;partially&lt;/em&gt; present theme (dir exists, files missing)&lt;/td&gt;&lt;td&gt;not tested — likely a different and worse failure than a wholly absent one&lt;/td&gt;&lt;td&gt;☐&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Admin theme&lt;/td&gt;&lt;td&gt;not tested — &lt;code&gt;admin_theme&lt;/code&gt; was set to the valid built-in &lt;code&gt;keycloak&lt;/code&gt;&lt;/td&gt;&lt;td&gt;☐&lt;/td&gt;&lt;/tr&gt;
&lt;/tbody&gt;&lt;/table&gt;
&lt;h2 id=&quot;publishable-extract&quot;&gt;Publishable extract&lt;/h2&gt;
&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;If your Keycloak upgrade loses its custom theme, Keycloak will not tell you
during the upgrade. It will tell the first person who tries to log in.&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;We ran a realm configured with &lt;code&gt;loginTheme: lab-login&lt;/code&gt; and no such theme
mounted, on Keycloak 26.7.1 / Postgres 16.15. The login page returned HTTP 200
with a complete, working sign-in form — rendered in the built-in theme. The
only signal was one line, at &lt;code&gt;ERROR&lt;/code&gt;:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;ERROR [org.keycloak.theme.DefaultThemeManager] Failed to find LOGIN theme lab-login, using built-in themes
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;and it was written &lt;strong&gt;at the moment of the first login request&lt;/strong&gt;, not at
startup. Our container log contained no theme-related line at all until
somebody asked for a page.&lt;/p&gt;
&lt;p&gt;This is a graceful fallback, and as engineering it is the right call — nobody
wants an outage because a CSS file is missing. But it means the standard
upgrade verification, &lt;em&gt;&quot;the server came up clean, close the window&quot;&lt;/em&gt;, cannot
catch it. Custom themes are mounted from outside the image, so they are exactly
the kind of thing an image bump, a chart change, or a moved volume quietly
drops.&lt;/p&gt;
&lt;p&gt;Add one line to your post-upgrade checks: fetch the login page and confirm the
asset URLs contain your theme name, not &lt;code&gt;keycloak.v2&lt;/code&gt;.&lt;/p&gt;
&lt;/blockquote&gt;
</content>
        
    </entry>
    <entry xml:lang="en">
        <title>Rehearsal Record — S12: a Keycloak *minor* upgrade raised the minimum Postgres major</title>
        <published>2026-08-25T00:00:00+00:00</published>
        <updated>2026-08-25T00:00:00+00:00</updated>
        
        <author>
          <name>Unknown</name>
        </author>
        
        <link rel="alternate" type="text/html" href="https://ledger.mlabs.city/runs/2026-08-25-s12-postgres-major-floor/"/>
        <id>https://ledger.mlabs.city/runs/2026-08-25-s12-postgres-major-floor/</id>
        
        <content type="html" xml:base="https://ledger.mlabs.city/runs/2026-08-25-s12-postgres-major-floor/">&lt;h2 id=&quot;summary&quot;&gt;Summary&lt;/h2&gt;
&lt;p&gt;&lt;strong&gt;Keycloak 26.0.0 runs on PostgreSQL 12. Keycloak 26.7.1 refuses to.&lt;/strong&gt; The
minimum supported Postgres major moved from 12 to 13 &lt;em&gt;inside major version 26&lt;/em&gt; —
across what looks, from the version number, like a routine minor bump.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;ERROR: Persistence unit &amp;#39;keycloak-default&amp;#39; was configured to run with a database
version of at least &amp;#39;13.0.0&amp;#39;, but the actual version is &amp;#39;12.22.0&amp;#39;.
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The upgrade fails before the migration. Nothing is applied, no data is lost, and
the fix is a database upgrade — which is a different project, a different change
window and usually a different team.&lt;/p&gt;
&lt;h2 id=&quot;environment&quot;&gt;Environment&lt;/h2&gt;
&lt;table&gt;&lt;thead&gt;&lt;tr&gt;&lt;th&gt;Field&lt;/th&gt;&lt;th&gt;Value&lt;/th&gt;&lt;/tr&gt;&lt;/thead&gt;&lt;tbody&gt;
&lt;tr&gt;&lt;td&gt;Keycloak from → to&lt;/td&gt;&lt;td&gt;26.0.0 → 26.7.1&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Distribution / start mode&lt;/td&gt;&lt;td&gt;quay.io/keycloak/keycloak official image, &lt;code&gt;start&lt;/code&gt; w/ external DB&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Database&lt;/td&gt;&lt;td&gt;&lt;strong&gt;PostgreSQL 13.23 / 12.22 / 11.16, single node, container — the variable&lt;/strong&gt;&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Postgres tuning&lt;/td&gt;&lt;td&gt;stock defaults for each major&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Adverse scenario&lt;/td&gt;&lt;td&gt;S12&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Dataset scale&lt;/td&gt;&lt;td&gt;202 users, 2 realms (fresh install + seed per major)&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Seeding method&lt;/td&gt;&lt;td&gt;&lt;code&gt;partialImport&lt;/code&gt; (&lt;code&gt;bin/seed-realm.sh --profile typical --users 200&lt;/code&gt;)&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Topology&lt;/td&gt;&lt;td&gt;single container&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Host&lt;/td&gt;&lt;td&gt;Hetzner CCX33 (fsn1) — 8 vCPU dedicated, 30 GB RAM, local NVMe&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;JVM heap&lt;/td&gt;&lt;td&gt;&lt;code&gt;-Xms1g -Xmx4g&lt;/code&gt;&lt;/td&gt;&lt;/tr&gt;
&lt;/tbody&gt;&lt;/table&gt;
&lt;p&gt;Each major started from an empty &lt;code&gt;pgdata&lt;/code&gt; volume: fresh install at 26.0.0, seed,
then the upgrade under test.&lt;/p&gt;
&lt;h2 id=&quot;results&quot;&gt;Results&lt;/h2&gt;
&lt;table&gt;&lt;thead&gt;&lt;tr&gt;&lt;th&gt;Postgres&lt;/th&gt;&lt;th&gt;Keycloak &lt;strong&gt;26.0.0&lt;/strong&gt;&lt;/th&gt;&lt;th&gt;Keycloak &lt;strong&gt;26.7.1&lt;/strong&gt;&lt;/th&gt;&lt;/tr&gt;&lt;/thead&gt;&lt;tbody&gt;
&lt;tr&gt;&lt;td&gt;&lt;strong&gt;13.23&lt;/strong&gt;&lt;/td&gt;&lt;td&gt;installs, ready 19s&lt;/td&gt;&lt;td&gt;&lt;strong&gt;upgrade READY in 16s&lt;/strong&gt; — model 26.7.1, changelog 211, 202 users, login 200&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;&lt;strong&gt;12.22&lt;/strong&gt;&lt;/td&gt;&lt;td&gt;&lt;strong&gt;installs, ready 19s&lt;/strong&gt;, changelog 144, 202 users seeded&lt;/td&gt;&lt;td&gt;&lt;strong&gt;REFUSES TO START&lt;/strong&gt; — requires ≥ 13.0.0&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;&lt;strong&gt;11.16&lt;/strong&gt;&lt;/td&gt;&lt;td&gt;&lt;strong&gt;REFUSES TO START&lt;/strong&gt; — requires ≥ 12.0.0&lt;/td&gt;&lt;td&gt;not reached&lt;/td&gt;&lt;/tr&gt;
&lt;/tbody&gt;&lt;/table&gt;
&lt;p&gt;So the floors are:&lt;/p&gt;
&lt;table&gt;&lt;thead&gt;&lt;tr&gt;&lt;th&gt;Keycloak&lt;/th&gt;&lt;th&gt;Minimum Postgres major&lt;/th&gt;&lt;/tr&gt;&lt;/thead&gt;&lt;tbody&gt;
&lt;tr&gt;&lt;td&gt;26.0.0&lt;/td&gt;&lt;td&gt;&lt;strong&gt;12&lt;/strong&gt;&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;26.7.1&lt;/td&gt;&lt;td&gt;&lt;strong&gt;13&lt;/strong&gt;&lt;/td&gt;&lt;/tr&gt;
&lt;/tbody&gt;&lt;/table&gt;
&lt;h3 id=&quot;the-failure-is-clean&quot;&gt;The failure is clean&lt;/h3&gt;
&lt;p&gt;After 26.7.1 refused to start on Postgres 12.22:&lt;/p&gt;
&lt;table&gt;&lt;thead&gt;&lt;tr&gt;&lt;th&gt;&lt;/th&gt;&lt;th&gt;&lt;/th&gt;&lt;/tr&gt;&lt;/thead&gt;&lt;tbody&gt;
&lt;tr&gt;&lt;td&gt;&lt;code&gt;migration_model&lt;/code&gt;&lt;/td&gt;&lt;td&gt;&lt;strong&gt;26.0.0 — unchanged&lt;/strong&gt;&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;&lt;code&gt;DATABASECHANGELOG&lt;/code&gt;&lt;/td&gt;&lt;td&gt;&lt;strong&gt;144 — unchanged&lt;/strong&gt;&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Users&lt;/td&gt;&lt;td&gt;&lt;strong&gt;202 — intact&lt;/strong&gt;&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Container&lt;/td&gt;&lt;td&gt;exited 1&lt;/td&gt;&lt;/tr&gt;
&lt;/tbody&gt;&lt;/table&gt;
&lt;p&gt;Fail-closed, like S3, S4 and S5 before it. The database is exactly as it was and
rolling back means putting the old image tag back.&lt;/p&gt;
&lt;h3 id=&quot;the-escape-hatch-and-its-wording&quot;&gt;The escape hatch, and its wording&lt;/h3&gt;
&lt;p&gt;Both versions offer one, and they do not name the same property:&lt;/p&gt;
&lt;table&gt;&lt;thead&gt;&lt;tr&gt;&lt;th&gt;Refusing version&lt;/th&gt;&lt;th&gt;Suggested override&lt;/th&gt;&lt;/tr&gt;&lt;/thead&gt;&lt;tbody&gt;
&lt;tr&gt;&lt;td&gt;26.7.1 on PG 12&lt;/td&gt;&lt;td&gt;&lt;code&gt;jakarta.persistence.database-product-version=12.22.0&lt;/code&gt;&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;26.0.0 on PG 11&lt;/td&gt;&lt;td&gt;&lt;code&gt;quarkus.datasource.db-version=11.16.0&lt;/code&gt;&lt;/td&gt;&lt;/tr&gt;
&lt;/tbody&gt;&lt;/table&gt;
&lt;p&gt;Both append the same warning: &lt;em&gt;&quot;but this may disable some features and/or impact
performance negatively&quot;&lt;/em&gt;. &lt;strong&gt;We did not test either override.&lt;/strong&gt; It is a documented
way to start on an unsupported database; it is not a supported configuration,
and a rescue engagement should treat it as a bridge to a database upgrade rather
than a destination.&lt;/p&gt;
&lt;h2 id=&quot;verification&quot;&gt;Verification&lt;/h2&gt;
&lt;table&gt;&lt;thead&gt;&lt;tr&gt;&lt;th&gt;Claim&lt;/th&gt;&lt;th&gt;Primary source&lt;/th&gt;&lt;th&gt;Checked&lt;/th&gt;&lt;/tr&gt;&lt;/thead&gt;&lt;tbody&gt;
&lt;tr&gt;&lt;td&gt;26.7.1 upgrades cleanly on PG 13.23&lt;/td&gt;&lt;td&gt;run: READY 16s, model 26.7.1, changelog 211, login 200&lt;/td&gt;&lt;td&gt;☑&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;26.0.0 installs and serves on PG 12.22&lt;/td&gt;&lt;td&gt;run: ready 19s, changelog 144, 202 users seeded&lt;/td&gt;&lt;td&gt;☑&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;26.7.1 refuses on PG 12.22&lt;/td&gt;&lt;td&gt;&lt;code&gt;at least &#39;13.0.0&#39;, but the actual version is &#39;12.22.0&#39;&lt;/code&gt;&lt;/td&gt;&lt;td&gt;☑&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;26.0.0 refuses on PG 11.16&lt;/td&gt;&lt;td&gt;&lt;code&gt;at least &#39;12.0.0&#39;, but the actual version is &#39;11.16.0&#39;&lt;/code&gt;&lt;/td&gt;&lt;td&gt;☑&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;The refusal applies nothing&lt;/td&gt;&lt;td&gt;changelog 144, model 26.0.0, 202 users after the failure&lt;/td&gt;&lt;td&gt;☑&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Which 26.x release moved the floor&lt;/td&gt;&lt;td&gt;&lt;strong&gt;not bracketed&lt;/strong&gt; — only known to be in (26.0.0, 26.7.1]&lt;/td&gt;&lt;td&gt;☐&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Whether the override flags work&lt;/td&gt;&lt;td&gt;&lt;strong&gt;not tested&lt;/strong&gt; — deliberately&lt;/td&gt;&lt;td&gt;☐&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Whether PG 13 is the floor for &lt;em&gt;later&lt;/em&gt; Keycloak&lt;/td&gt;&lt;td&gt;not tested — the floor evidently moves, so this expires&lt;/td&gt;&lt;td&gt;☐&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Managed-service equivalents (RDS/Cloud SQL/Azure majors)&lt;/td&gt;&lt;td&gt;not tested — same engine versions, different upgrade mechanics&lt;/td&gt;&lt;td&gt;☐&lt;/td&gt;&lt;/tr&gt;
&lt;/tbody&gt;&lt;/table&gt;
&lt;h2 id=&quot;publishable-extract&quot;&gt;Publishable extract&lt;/h2&gt;
&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Keycloak 26.0.0 runs on PostgreSQL 12. Keycloak 26.7.1 does not. Same major
version of Keycloak.&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;We installed Keycloak 26.0.0 on Postgres 12.22, seeded a realm, and upgraded to
26.7.1 — the kind of bump most change advisory boards would wave through as a
minor. It stopped before it started:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;ERROR: Persistence unit &amp;#39;keycloak-default&amp;#39; was configured to run with a database
version of at least &amp;#39;13.0.0&amp;#39;, but the actual version is &amp;#39;12.22.0&amp;#39;.
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The floors we measured:&lt;/p&gt;
&lt;table&gt;&lt;thead&gt;&lt;tr&gt;&lt;th&gt;Keycloak&lt;/th&gt;&lt;th&gt;Minimum Postgres&lt;/th&gt;&lt;/tr&gt;&lt;/thead&gt;&lt;tbody&gt;
&lt;tr&gt;&lt;td&gt;26.0.0&lt;/td&gt;&lt;td&gt;12&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;26.7.1&lt;/td&gt;&lt;td&gt;&lt;strong&gt;13&lt;/strong&gt;&lt;/td&gt;&lt;/tr&gt;
&lt;/tbody&gt;&lt;/table&gt;
&lt;p&gt;On Postgres 13.23 the same upgrade took sixteen seconds and worked perfectly.&lt;/p&gt;
&lt;p&gt;The good news is that the refusal is clean: 144 changelog rows before, 144
after, all 202 users present, container exited 1. Nothing was half-done. The bad
news is what the fix is. You cannot resolve this inside the Keycloak change
window — you need a database major upgrade, which is a different project with a
different risk profile and usually a different team. If you discovered it &lt;em&gt;during&lt;/em&gt;
the window, the window is over.&lt;/p&gt;
&lt;p&gt;Keycloak does offer an override (&lt;code&gt;jakarta.persistence.database-product-version&lt;/code&gt;)
and warns in the same breath that it &quot;may disable some features and/or impact
performance negatively&quot;. We deliberately did not test it. Treat it as a bridge
to a database upgrade, not a destination.&lt;/p&gt;
&lt;p&gt;The pre-flight question is one line, and almost nobody asks it, because the
Keycloak version number does not suggest it needs asking:&lt;/p&gt;
&lt;pre&gt;&lt;code data-lang=&quot;sql&quot;&gt;select version();
&lt;/code&gt;&lt;/pre&gt;
&lt;/blockquote&gt;
</content>
        
    </entry>
    <entry xml:lang="en">
        <title>Rehearsal Record — S13: a non-default schema works, and it broke our rollback tool</title>
        <published>2026-08-25T00:00:00+00:00</published>
        <updated>2026-08-25T00:00:00+00:00</updated>
        
        <author>
          <name>Unknown</name>
        </author>
        
        <link rel="alternate" type="text/html" href="https://ledger.mlabs.city/runs/2026-08-25-s13-non-default-schema/"/>
        <id>https://ledger.mlabs.city/runs/2026-08-25-s13-non-default-schema/</id>
        
        <content type="html" xml:base="https://ledger.mlabs.city/runs/2026-08-25-s13-non-default-schema/">&lt;h2 id=&quot;summary&quot;&gt;Summary&lt;/h2&gt;
&lt;p&gt;Keycloak handles a non-default schema correctly: &lt;strong&gt;87 tables created in &lt;code&gt;kc&lt;/code&gt;,
zero in &lt;code&gt;public&lt;/code&gt;, migration clean in 16 seconds, nothing leaked&lt;/strong&gt;.&lt;/p&gt;
&lt;p&gt;Two things around it are less clean.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Keycloak will not create the schema.&lt;/strong&gt; If &lt;code&gt;KC_DB_SCHEMA&lt;/code&gt; names a schema that
does not exist, startup fails — and the message it fails with is
&lt;code&gt;Cannot invoke &quot;CustomLockService.waitForLock(...)&quot; because &quot;this.lockService&quot; is null&lt;/code&gt;, &lt;strong&gt;the exact error S8 produced from an entirely different cause.&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Our own &lt;code&gt;restore.sh&lt;/code&gt; was broken by it,&lt;/strong&gt; in the specific way that matters:
it read &lt;code&gt;migration_model&lt;/code&gt; unqualified, got &lt;code&gt;relation &quot;migration_model&quot; does not exist&lt;/code&gt;, and would have reported an empty schema version rather than a failed
rollback.&lt;/p&gt;
&lt;h2 id=&quot;environment&quot;&gt;Environment&lt;/h2&gt;
&lt;table&gt;&lt;thead&gt;&lt;tr&gt;&lt;th&gt;Field&lt;/th&gt;&lt;th&gt;Value&lt;/th&gt;&lt;/tr&gt;&lt;/thead&gt;&lt;tbody&gt;
&lt;tr&gt;&lt;td&gt;Keycloak from → to&lt;/td&gt;&lt;td&gt;26.0.0 → 26.7.1&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Distribution / start mode&lt;/td&gt;&lt;td&gt;quay.io/keycloak/keycloak official image, &lt;code&gt;start&lt;/code&gt; w/ external DB&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Database&lt;/td&gt;&lt;td&gt;PostgreSQL 16.15, single node, container&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Postgres tuning&lt;/td&gt;&lt;td&gt;stock — &lt;code&gt;shared_buffers&lt;/code&gt; 128MB, &lt;code&gt;maintenance_work_mem&lt;/code&gt; 64MB, &lt;code&gt;statement_timeout&lt;/code&gt; 0&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Adverse scenario&lt;/td&gt;&lt;td&gt;S13 — &lt;strong&gt;&lt;code&gt;KC_DB_SCHEMA=kc&lt;/code&gt;&lt;/strong&gt;&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Dataset scale&lt;/td&gt;&lt;td&gt;202 users, 2 realms&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Seeding method&lt;/td&gt;&lt;td&gt;&lt;code&gt;partialImport&lt;/code&gt; (&lt;code&gt;bin/seed-realm.sh --profile typical --users 200&lt;/code&gt;)&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Topology&lt;/td&gt;&lt;td&gt;single container&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Host&lt;/td&gt;&lt;td&gt;Hetzner CCX33 (fsn1) — 8 vCPU dedicated, 30 GB RAM, local NVMe&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;JVM heap&lt;/td&gt;&lt;td&gt;&lt;code&gt;-Xms1g -Xmx4g&lt;/code&gt;&lt;/td&gt;&lt;/tr&gt;
&lt;/tbody&gt;&lt;/table&gt;
&lt;p&gt;Started from an empty &lt;code&gt;pgdata&lt;/code&gt; volume so no &lt;code&gt;public&lt;/code&gt; schema objects existed to
confuse attribution.&lt;/p&gt;
&lt;h2 id=&quot;results&quot;&gt;Results&lt;/h2&gt;
&lt;table&gt;&lt;thead&gt;&lt;tr&gt;&lt;th&gt;Step&lt;/th&gt;&lt;th&gt;Outcome&lt;/th&gt;&lt;/tr&gt;&lt;/thead&gt;&lt;tbody&gt;
&lt;tr&gt;&lt;td&gt;Start with &lt;code&gt;KC_DB_SCHEMA=kc&lt;/code&gt;, schema &lt;strong&gt;absent&lt;/strong&gt;&lt;/td&gt;&lt;td&gt;&lt;strong&gt;exit 1&lt;/strong&gt; — &lt;code&gt;ERROR: schema &quot;kc&quot; does not exist&lt;/code&gt;&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;&lt;code&gt;create schema kc&lt;/code&gt;, start again&lt;/td&gt;&lt;td&gt;&lt;strong&gt;ready in 16s&lt;/strong&gt;&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Tables in &lt;code&gt;kc&lt;/code&gt;&lt;/td&gt;&lt;td&gt;&lt;strong&gt;87&lt;/strong&gt;&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Tables in &lt;code&gt;public&lt;/code&gt;&lt;/td&gt;&lt;td&gt;&lt;strong&gt;0&lt;/strong&gt;&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Fresh install state&lt;/td&gt;&lt;td&gt;model 26.0.0, changelog 144&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Seed 200 users&lt;/td&gt;&lt;td&gt;202 users in &lt;code&gt;kc.user_entity&lt;/code&gt;&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;&lt;strong&gt;Upgrade to 26.7.1&lt;/strong&gt;&lt;/td&gt;&lt;td&gt;&lt;strong&gt;READY in 16s&lt;/strong&gt; — model 26.7.1, changelog 211, 202 users&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Tables leaked into &lt;code&gt;public&lt;/code&gt; after the upgrade&lt;/td&gt;&lt;td&gt;&lt;strong&gt;0&lt;/strong&gt;&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Admin login after the upgrade&lt;/td&gt;&lt;td&gt;&lt;strong&gt;200&lt;/strong&gt;&lt;/td&gt;&lt;/tr&gt;
&lt;/tbody&gt;&lt;/table&gt;
&lt;h3 id=&quot;the-misleading-error-and-what-it-means-for-s8&quot;&gt;The misleading error, and what it means for S8&lt;/h3&gt;
&lt;p&gt;Starting against a missing schema produces this, in this order:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;Caused by: org.postgresql.util.PSQLException: ERROR: schema &amp;quot;kc&amp;quot; does not exist
        at ...QuarkusJpaConnectionProviderFactory.createOrUpdateSchema(...:247)
ERROR: Failed to start server in (production) mode
ERROR: Cannot invoke &amp;quot;org.keycloak.connections.jpa.updater.liquibase.lock.CustomLockService
       .waitForLock(org.keycloak.models.dblock.DBLockProvider$Namespace)&amp;quot;
       because &amp;quot;this.lockService&amp;quot; is null
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;The last line is the same one &lt;a href=&quot;/runs/2026-08-25-s8-pgbouncer-transaction-mode/&quot;&gt;&lt;code&gt;2026-08-25-s8-pgbouncer-transaction-mode&lt;/code&gt;&lt;/a&gt;
recorded&lt;/strong&gt;, where the cause was a transaction-mode connection pooler and no
schema was missing at all.&lt;/p&gt;
&lt;p&gt;That reclassifies it. &lt;code&gt;lockService is null&lt;/code&gt; is &lt;strong&gt;not a diagnosis&lt;/strong&gt; — it is what
Keycloak prints whenever schema initialisation failed for &lt;em&gt;any&lt;/em&gt; reason, because
the error path dereferences a lock service that was never constructed. The real
cause is always further up the log, in a &lt;code&gt;Caused by:&lt;/code&gt; that the summary lines at
the bottom do not repeat.&lt;/p&gt;
&lt;p&gt;This matters operationally because the summary block is what an operator sees
first, and it is the least informative part of the failure.&lt;/p&gt;
&lt;h2 id=&quot;outcome&quot;&gt;Outcome&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;&lt;input disabled=&quot;&quot; type=&quot;checkbox&quot; checked=&quot;&quot;/&gt;
Reached ready&lt;/li&gt;
&lt;li&gt;&lt;input disabled=&quot;&quot; type=&quot;checkbox&quot; checked=&quot;&quot;/&gt;
Realm config intact&lt;/li&gt;
&lt;li&gt;&lt;input disabled=&quot;&quot; type=&quot;checkbox&quot; checked=&quot;&quot;/&gt;
Login flow works&lt;/li&gt;
&lt;li&gt;&lt;input disabled=&quot;&quot; type=&quot;checkbox&quot; checked=&quot;&quot;/&gt;
No object leakage into &lt;code&gt;public&lt;/code&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;h2 id=&quot;verification&quot;&gt;Verification&lt;/h2&gt;
&lt;table&gt;&lt;thead&gt;&lt;tr&gt;&lt;th&gt;Claim&lt;/th&gt;&lt;th&gt;Primary source&lt;/th&gt;&lt;th&gt;Checked&lt;/th&gt;&lt;/tr&gt;&lt;/thead&gt;&lt;tbody&gt;
&lt;tr&gt;&lt;td&gt;Keycloak does not create a missing schema&lt;/td&gt;&lt;td&gt;run with &lt;code&gt;kc&lt;/code&gt; absent: exit 1, &lt;code&gt;schema &quot;kc&quot; does not exist&lt;/code&gt;&lt;/td&gt;&lt;td&gt;☑&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;It works once the schema exists&lt;/td&gt;&lt;td&gt;87 tables in &lt;code&gt;kc&lt;/code&gt;, 0 in &lt;code&gt;public&lt;/code&gt;, ready 16s&lt;/td&gt;&lt;td&gt;☑&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;The upgrade is clean on a non-default schema&lt;/td&gt;&lt;td&gt;model 26.7.1, changelog 211, 202 users, login 200&lt;/td&gt;&lt;td&gt;☑&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Nothing leaks into &lt;code&gt;public&lt;/code&gt;&lt;/td&gt;&lt;td&gt;&lt;code&gt;information_schema.tables&lt;/code&gt; count = 0 for &lt;code&gt;public&lt;/code&gt;, before and after&lt;/td&gt;&lt;td&gt;☑&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;&lt;code&gt;lockService is null&lt;/code&gt; also arises from a missing schema&lt;/td&gt;&lt;td&gt;this run&#39;s log, with &lt;code&gt;schema &quot;kc&quot; does not exist&lt;/code&gt; as the &lt;code&gt;Caused by&lt;/code&gt;&lt;/td&gt;&lt;td&gt;☑&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;&lt;code&gt;snapshot.sh&lt;/code&gt; copes&lt;/td&gt;&lt;td&gt;ran clean, 300K dump&lt;/td&gt;&lt;td&gt;☑&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;&lt;code&gt;restore.sh&lt;/code&gt; coped&lt;/td&gt;&lt;td&gt;&lt;strong&gt;no — it failed&lt;/strong&gt;, fixed here, re-verified&lt;/td&gt;&lt;td&gt;☑&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Behaviour with a non-default schema &lt;em&gt;and&lt;/em&gt; a &lt;code&gt;search_path&lt;/code&gt; set on the role&lt;/td&gt;&lt;td&gt;&lt;strong&gt;not tested&lt;/strong&gt; — a plausible customer configuration that could mask this&lt;/td&gt;&lt;td&gt;☐&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Multiple Keycloak instances in separate schemas of one database&lt;/td&gt;&lt;td&gt;not tested&lt;/td&gt;&lt;td&gt;☐&lt;/td&gt;&lt;/tr&gt;
&lt;/tbody&gt;&lt;/table&gt;
&lt;h2 id=&quot;publishable-extract&quot;&gt;Publishable extract&lt;/h2&gt;
&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Keycloak in a non-default Postgres schema upgrades cleanly. The interesting
part is what it says when the schema is missing.&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;With &lt;code&gt;KC_DB_SCHEMA=kc&lt;/code&gt; and the schema created in advance, Keycloak put all 87
of its tables in &lt;code&gt;kc&lt;/code&gt;, none in &lt;code&gt;public&lt;/code&gt;, and the 26.0.0 → 26.7.1 migration ran
in sixteen seconds with 202 users intact and nothing leaking into the default
schema.&lt;/p&gt;
&lt;p&gt;Keycloak will not create the schema for you. Point it at one that does not
exist and it exits — with this at the bottom of the log:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;ERROR: Cannot invoke &amp;quot;CustomLockService.waitForLock(DBLockProvider$Namespace)&amp;quot;
       because &amp;quot;this.lockService&amp;quot; is null
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;We had seen that exact line earlier the same day, from a completely different
cause: PgBouncer in transaction pooling mode, with every schema present and
correct. &lt;strong&gt;&lt;code&gt;lockService is null&lt;/code&gt; is not a diagnosis.&lt;/strong&gt; It is what Keycloak
prints whenever it failed to initialise its database connection, because the
error path then dereferences a lock service that was never built. The actual
reason — &lt;code&gt;ERROR: schema &quot;kc&quot; does not exist&lt;/code&gt; — is fifteen lines further up, in
a &lt;code&gt;Caused by:&lt;/code&gt; that the summary at the bottom never repeats.&lt;/p&gt;
&lt;p&gt;If you are staring at that null, scroll up. The summary block at the end of a
Keycloak startup failure is the least informative part of it.&lt;/p&gt;
&lt;/blockquote&gt;
</content>
        
    </entry>
    <entry xml:lang="en">
        <title>Rehearsal Record — S14: the bigger realm is the safer one, and a half-migrated schema resumes</title>
        <published>2026-08-25T00:00:00+00:00</published>
        <updated>2026-08-25T00:00:00+00:00</updated>
        
        <author>
          <name>Unknown</name>
        </author>
        
        <link rel="alternate" type="text/html" href="https://ledger.mlabs.city/runs/2026-08-25-s14-disk-exhaustion/"/>
        <id>https://ledger.mlabs.city/runs/2026-08-25-s14-disk-exhaustion/</id>
        
        <content type="html" xml:base="https://ledger.mlabs.city/runs/2026-08-25-s14-disk-exhaustion/">&lt;h2 id=&quot;summary&quot;&gt;Summary&lt;/h2&gt;
&lt;p&gt;Running out of disk during the migration &lt;strong&gt;does&lt;/strong&gt; break it, and it breaks it at
one specific place: &lt;strong&gt;&lt;code&gt;CREATE INDEX IDX_USER_CREATED_TIMESTAMP&lt;/code&gt;&lt;/strong&gt;, which needs a
temporary sort file that will not fit.&lt;/p&gt;
&lt;p&gt;Three things make this the most interesting result of the set.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;The amount of headroom needed is tiny.&lt;/strong&gt; A 224 MB database needed somewhere
between &lt;strong&gt;4 MB and 8 MB&lt;/strong&gt; free. At 8 MB the upgrade completed and used 1.58 MB;
at 4 MB it died.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;It leaves a genuinely half-applied changelog&lt;/strong&gt; — &lt;code&gt;DATABASECHANGELOG&lt;/code&gt; stops at
&lt;strong&gt;187 of 211&lt;/strong&gt; rows, committed, with &lt;code&gt;migration_model&lt;/code&gt; still 26.0.0. Freeing the
space and restarting completed it in 17 seconds, to 211 and 26.7.1, with the
index built and all 100,002 users intact.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;And the failure only happens on the &lt;em&gt;smaller&lt;/em&gt; realm.&lt;/strong&gt;&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;⚠ QUALIFIED 2026-08-26.&lt;/strong&gt; True only while Keycloak is declining the index
build. Force the build at 2,000,003 users — which a stale &lt;code&gt;pg_class.reltuples&lt;/code&gt;
does on its own — and the floor is &lt;strong&gt;125–150 MB&lt;/strong&gt;, roughly 20× the figure below.
Scaling up removes the &lt;em&gt;trigger&lt;/em&gt;, not the failure mode, and multiplies the
damage if anything pulls it. See &lt;a href=&quot;/runs/2026-08-26-s14-revisited-disk-floor-at-2m/&quot;&gt;&lt;code&gt;2026-08-26-s14-revisited-disk-floor-at-2m&lt;/code&gt;&lt;/a&gt;. The index that runs out
of space is the same one &lt;a href=&quot;/runs/2026-08-25-index-skip-threshold/&quot;&gt;&lt;code&gt;2026-08-25-index-skip-threshold&lt;/code&gt;&lt;/a&gt; shows Keycloak
&lt;strong&gt;skipping above 300,000 rows&lt;/strong&gt;. At 100k it is built, and needs temp space. At 2M
it is declined, and needs none. &lt;strong&gt;Scaling the realm up removes this failure
mode.&lt;/strong&gt; That is the opposite of what anyone provisioning a maintenance window
would assume.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;h2 id=&quot;environment&quot;&gt;Environment&lt;/h2&gt;
&lt;table&gt;&lt;thead&gt;&lt;tr&gt;&lt;th&gt;Field&lt;/th&gt;&lt;th&gt;Value&lt;/th&gt;&lt;/tr&gt;&lt;/thead&gt;&lt;tbody&gt;
&lt;tr&gt;&lt;td&gt;Keycloak from → to&lt;/td&gt;&lt;td&gt;26.0.0 → 26.7.1&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Distribution / start mode&lt;/td&gt;&lt;td&gt;quay.io/keycloak/keycloak official image, &lt;code&gt;start&lt;/code&gt; w/ external DB&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Database&lt;/td&gt;&lt;td&gt;PostgreSQL 16.15, single node, container&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Postgres tuning&lt;/td&gt;&lt;td&gt;stock — &lt;code&gt;shared_buffers&lt;/code&gt; 128MB, &lt;code&gt;maintenance_work_mem&lt;/code&gt; 64MB, &lt;code&gt;statement_timeout&lt;/code&gt; 0&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Adverse scenario&lt;/td&gt;&lt;td&gt;S14 — &lt;strong&gt;free space on the Postgres data directory is the variable&lt;/strong&gt;&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;&lt;strong&gt;Storage&lt;/strong&gt;&lt;/td&gt;&lt;td&gt;&lt;strong&gt;2 GB ext4 loopback filesystem mounted at &lt;code&gt;/mnt/tinypg&lt;/code&gt;, bind-mounted as &lt;code&gt;pgdata&lt;/code&gt;&lt;/strong&gt;&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Dataset scale&lt;/td&gt;&lt;td&gt;100,002 users, 2 realms; database 223–225 MB&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Seeding method&lt;/td&gt;&lt;td&gt;&lt;code&gt;partialImport&lt;/code&gt; (restored from &lt;code&gt;baseline-26.0.0-typical-100k&lt;/code&gt;)&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Topology&lt;/td&gt;&lt;td&gt;single container&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Host&lt;/td&gt;&lt;td&gt;Hetzner CCX33 (fsn1) — 8 vCPU dedicated, 30 GB RAM, local NVMe&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;JVM heap&lt;/td&gt;&lt;td&gt;&lt;code&gt;-Xms1g -Xmx4g&lt;/code&gt;&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Lab change&lt;/td&gt;&lt;td&gt;&lt;code&gt;docker-compose.smalldisk.yml&lt;/code&gt; added&lt;/td&gt;&lt;/tr&gt;
&lt;/tbody&gt;&lt;/table&gt;
&lt;p&gt;The box&#39;s root filesystem is 226 GB, so exhaustion is not reachable there. Free
space at the moment of migration is set exactly, with a ballast file, so each run
states a real number rather than &quot;nearly full&quot;.&lt;/p&gt;
&lt;h2 id=&quot;results&quot;&gt;Results&lt;/h2&gt;
&lt;table&gt;&lt;thead&gt;&lt;tr&gt;&lt;th&gt;Free space&lt;/th&gt;&lt;th&gt;Outcome&lt;/th&gt;&lt;th&gt;Elapsed&lt;/th&gt;&lt;th&gt;Space used&lt;/th&gt;&lt;th&gt;&lt;code&gt;migration_model&lt;/code&gt;&lt;/th&gt;&lt;th&gt;&lt;code&gt;DATABASECHANGELOG&lt;/code&gt;&lt;/th&gt;&lt;th&gt;Users&lt;/th&gt;&lt;/tr&gt;&lt;/thead&gt;&lt;tbody&gt;
&lt;tr&gt;&lt;td&gt;25 MB&lt;/td&gt;&lt;td&gt;&lt;strong&gt;ready&lt;/strong&gt;&lt;/td&gt;&lt;td&gt;17s&lt;/td&gt;&lt;td&gt;~1 MB&lt;/td&gt;&lt;td&gt;26.7.1&lt;/td&gt;&lt;td&gt;211&lt;/td&gt;&lt;td&gt;100,002&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;&lt;strong&gt;8 MB&lt;/strong&gt;&lt;/td&gt;&lt;td&gt;&lt;strong&gt;ready&lt;/strong&gt;&lt;/td&gt;&lt;td&gt;17s&lt;/td&gt;&lt;td&gt;&lt;strong&gt;1,580 KB&lt;/strong&gt;&lt;/td&gt;&lt;td&gt;26.7.1&lt;/td&gt;&lt;td&gt;211&lt;/td&gt;&lt;td&gt;100,002&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;&lt;strong&gt;4 MB&lt;/strong&gt;&lt;/td&gt;&lt;td&gt;&lt;strong&gt;exit 1&lt;/strong&gt;&lt;/td&gt;&lt;td&gt;14s&lt;/td&gt;&lt;td&gt;512 KB&lt;/td&gt;&lt;td&gt;&lt;strong&gt;26.0.0&lt;/strong&gt;&lt;/td&gt;&lt;td&gt;&lt;strong&gt;187&lt;/strong&gt;&lt;/td&gt;&lt;td&gt;100,002&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;2 MB&lt;/td&gt;&lt;td&gt;exit 1&lt;/td&gt;&lt;td&gt;14s&lt;/td&gt;&lt;td&gt;504 KB&lt;/td&gt;&lt;td&gt;26.0.0&lt;/td&gt;&lt;td&gt;&lt;strong&gt;187&lt;/strong&gt;&lt;/td&gt;&lt;td&gt;100,002&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;1 MB&lt;/td&gt;&lt;td&gt;exit 1&lt;/td&gt;&lt;td&gt;14s&lt;/td&gt;&lt;td&gt;344 KB&lt;/td&gt;&lt;td&gt;26.0.0&lt;/td&gt;&lt;td&gt;&lt;strong&gt;187&lt;/strong&gt;&lt;/td&gt;&lt;td&gt;100,002&lt;/td&gt;&lt;/tr&gt;
&lt;/tbody&gt;&lt;/table&gt;
&lt;p&gt;&lt;strong&gt;The floor is between 4 MB and 8 MB free, for a 224 MB database.&lt;/strong&gt;&lt;/p&gt;
&lt;h3 id=&quot;where-it-dies-and-why&quot;&gt;Where it dies, and why&lt;/h3&gt;
&lt;pre&gt;&lt;code&gt;ERROR: could not write to file &amp;quot;base/pgsql_tmp/pgsql_tmp705.0.fileset/0.0&amp;quot;:
       No space left on device
[Failed SQL: (0) CREATE INDEX IDX_USER_CREATED_TIMESTAMP ON public.USER_ENTITY(REALM_ID, CREATED_TIMESTAMP)]
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;code&gt;base/pgsql_tmp&lt;/code&gt; is Postgres&#39;s temporary-file area. Building an index sorts, and
a sort that exceeds &lt;code&gt;maintenance_work_mem&lt;/code&gt; (64 MB here, stock) spills to disk.
The space that runs out is &lt;strong&gt;not&lt;/strong&gt; the space the finished index occupies — it is
the scratch space needed to build it.&lt;/p&gt;
&lt;p&gt;That is why the margin is so small and so easy to miss: the finished index is
about 2 MB, and the sort needs more room than the result.&lt;/p&gt;
&lt;h3 id=&quot;why-a-larger-realm-would-not-hit-this&quot;&gt;Why a larger realm would not hit this&lt;/h3&gt;
&lt;p&gt;&lt;code&gt;CustomCreateIndexChange&lt;/code&gt; skips index creation on tables above &lt;strong&gt;300,000 rows&lt;/strong&gt;
(&lt;a href=&quot;/runs/2026-08-25-index-skip-threshold/&quot;&gt;&lt;code&gt;2026-08-25-index-skip-threshold&lt;/code&gt;&lt;/a&gt;). This realm has 100,002 users, so the
index is built — the run confirms it, with &lt;code&gt;skipped_idx=0&lt;/code&gt; in every leg, and the
index present after recovery.&lt;/p&gt;
&lt;p&gt;At 2M users Keycloak declines to build it, logs a WARN, marks the changeset
EXECUTED, and moves on. No sort, no temp file, no exhaustion. &lt;strong&gt;The dangerous
size band for this failure is &lt;em&gt;below&lt;/em&gt; the skip threshold&lt;/strong&gt;, and a customer who
manually created the indexes Keycloak declined has re-entered it at any size.&lt;/p&gt;
&lt;h3 id=&quot;recovery-the-half-migrated-schema-resumes&quot;&gt;Recovery: the half-migrated schema resumes&lt;/h3&gt;
&lt;p&gt;From the failed state — &lt;code&gt;DATABASECHANGELOG&lt;/code&gt; 187, &lt;code&gt;migration_model&lt;/code&gt; 26.0.0 —
removing the ballast and restarting:&lt;/p&gt;
&lt;table&gt;&lt;thead&gt;&lt;tr&gt;&lt;th&gt;&lt;/th&gt;&lt;th&gt;&lt;/th&gt;&lt;/tr&gt;&lt;/thead&gt;&lt;tbody&gt;
&lt;tr&gt;&lt;td&gt;Restart&lt;/td&gt;&lt;td&gt;&lt;strong&gt;ready in 17s&lt;/strong&gt;&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;&lt;code&gt;DATABASECHANGELOG&lt;/code&gt;&lt;/td&gt;&lt;td&gt;187 → &lt;strong&gt;211&lt;/strong&gt;&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;&lt;code&gt;migration_model&lt;/code&gt;&lt;/td&gt;&lt;td&gt;26.0.0 → &lt;strong&gt;26.7.1&lt;/strong&gt;&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;&lt;code&gt;idx_user_created_timestamp&lt;/code&gt;&lt;/td&gt;&lt;td&gt;&lt;strong&gt;present&lt;/strong&gt;&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Users&lt;/td&gt;&lt;td&gt;&lt;strong&gt;100,002&lt;/strong&gt;&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Admin login&lt;/td&gt;&lt;td&gt;&lt;strong&gt;200&lt;/strong&gt;&lt;/td&gt;&lt;/tr&gt;
&lt;/tbody&gt;&lt;/table&gt;
&lt;p&gt;Liquibase resumed from the last committed changeset. No manual intervention, no
lock clearing, no restore.&lt;/p&gt;
&lt;p&gt;This is the third route this lab has found to a stuck upgrade
(after S8&#39;s pooler and S12&#39;s database floor) and the &lt;strong&gt;second&lt;/strong&gt; to a genuinely
inconsistent database — and in both cases the recovery was &quot;fix the environmental
cause and start it again&quot;. &lt;a href=&quot;/runs/2026-08-25-s1-s2-lock-failure-modes/&quot;&gt;&lt;code&gt;2026-08-25-s1-s2-lock-failure-modes&lt;/code&gt;&lt;/a&gt; reached that
conclusion without ever producing a half-migrated schema. It has now been
produced twice, and the conclusion held both times.&lt;/p&gt;
&lt;h2 id=&quot;verification&quot;&gt;Verification&lt;/h2&gt;
&lt;table&gt;&lt;thead&gt;&lt;tr&gt;&lt;th&gt;Claim&lt;/th&gt;&lt;th&gt;Primary source&lt;/th&gt;&lt;th&gt;Checked&lt;/th&gt;&lt;/tr&gt;&lt;/thead&gt;&lt;tbody&gt;
&lt;tr&gt;&lt;td&gt;8 MB free is sufficient for a 224 MB database&lt;/td&gt;&lt;td&gt;run: ready 17s, changelog 211, 1,580 KB consumed&lt;/td&gt;&lt;td&gt;☑&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;4 MB free is not&lt;/td&gt;&lt;td&gt;run: exit 1, changelog 187&lt;/td&gt;&lt;td&gt;☑&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;The failing statement is the index build&lt;/td&gt;&lt;td&gt;&lt;code&gt;Failed SQL: CREATE INDEX IDX_USER_CREATED_TIMESTAMP&lt;/code&gt; in three runs&lt;/td&gt;&lt;td&gt;☑&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;The space that runs out is temp sort space&lt;/td&gt;&lt;td&gt;Postgres names &lt;code&gt;base/pgsql_tmp/...&lt;/code&gt;&lt;/td&gt;&lt;td&gt;☑&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;The changelog is genuinely half applied&lt;/td&gt;&lt;td&gt;187 of 211, read from Postgres, across three runs&lt;/td&gt;&lt;td&gt;☑&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Recovery is a restart once space exists&lt;/td&gt;&lt;td&gt;changelog 187 → 211, model → 26.7.1, index present, login 200&lt;/td&gt;&lt;td&gt;☑&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;The index is skipped above 300k rows&lt;/td&gt;&lt;td&gt;&lt;a href=&quot;/runs/2026-08-25-index-skip-threshold/&quot;&gt;&lt;code&gt;2026-08-25-index-skip-threshold&lt;/code&gt;&lt;/a&gt;; &lt;code&gt;skipped_idx=0&lt;/code&gt; at 100k here&lt;/td&gt;&lt;td&gt;☑&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;That a 2M realm therefore avoids this&lt;/td&gt;&lt;td&gt;&lt;strong&gt;not directly tested&lt;/strong&gt; — inferred from the two records; worth one run to close&lt;/td&gt;&lt;td&gt;☐&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Behaviour with a larger &lt;code&gt;maintenance_work_mem&lt;/code&gt;&lt;/td&gt;&lt;td&gt;&lt;strong&gt;not tested&lt;/strong&gt; — a bigger setting keeps the sort in RAM and may remove the failure entirely&lt;/td&gt;&lt;td&gt;☐&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Behaviour when WAL, not the data directory, fills&lt;/td&gt;&lt;td&gt;not tested — separate filesystem in most real deployments&lt;/td&gt;&lt;td&gt;☐&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Whether a &lt;em&gt;full&lt;/em&gt; disk (0 bytes) corrupts rather than errors&lt;/td&gt;&lt;td&gt;not tested — the tightest run still had 676 KB&lt;/td&gt;&lt;td&gt;☐&lt;/td&gt;&lt;/tr&gt;
&lt;/tbody&gt;&lt;/table&gt;
&lt;h2 id=&quot;publishable-extract&quot;&gt;Publishable extract&lt;/h2&gt;
&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Your Keycloak upgrade can run out of disk with 4 MB free and succeed with 8.
And the bigger your realm, the less likely it is to happen.&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;We put a 100,002-user Keycloak database — 224 MB — on a 2 GB filesystem and
upgraded 26.0.0 → 26.7.1 with the free space set to an exact figure each time.
With 8 MB free it completed in 17 seconds and consumed 1.58 MB. With 4 MB it
died:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;ERROR: could not write to file &amp;quot;base/pgsql_tmp/...&amp;quot;: No space left on device
[Failed SQL: (0) CREATE INDEX IDX_USER_CREATED_TIMESTAMP ON public.USER_ENTITY(REALM_ID, CREATED_TIMESTAMP)]
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;code&gt;base/pgsql_tmp&lt;/code&gt; is the giveaway. The space that ran out is not the space the
index occupies — it is the &lt;strong&gt;temporary sort file&lt;/strong&gt; Postgres needs to build it,
which is larger than the result. Free space equal to your index size is not
enough.&lt;/p&gt;
&lt;p&gt;Now the counter-intuitive part. That index is the one Keycloak &lt;strong&gt;refuses to
build on tables over 300,000 rows&lt;/strong&gt; — it logs a warning, marks the changeset
executed, and moves on. So a 2-million-user realm never runs that sort, never
writes that temp file, and never hits this. &lt;strong&gt;The vulnerable realms are the
small ones&lt;/strong&gt;, and anyone who has manually created the indexes Keycloak declined
has put themselves back in range at any size.&lt;/p&gt;
&lt;p&gt;Finally, the reassuring half. The failure left a genuinely half-applied schema —
187 of 211 changesets committed, &lt;code&gt;migration_model&lt;/code&gt; still on the old version.
We freed the space and started Keycloak again. Seventeen seconds later:
changelog 211, model 26.7.1, index built, all 100,002 users present, admin login
returning 200. Liquibase resumed from the last committed changeset.&lt;/p&gt;
&lt;p&gt;If you find a Keycloak stopped partway through a migration, the first thing to
try is starting it again — after you have fixed whatever stopped it. That
advice has now survived every way we have found to break one.&lt;/p&gt;
&lt;/blockquote&gt;
</content>
        
    </entry>
    <entry xml:lang="en">
        <title>Rehearsal Record — S15: upgrading does not hide custom attributes; *re-creating the realm* discards them</title>
        <published>2026-08-25T00:00:00+00:00</published>
        <updated>2026-08-25T00:00:00+00:00</updated>
        
        <author>
          <name>Unknown</name>
        </author>
        
        <link rel="alternate" type="text/html" href="https://ledger.mlabs.city/runs/2026-08-25-s15-unmanaged-attributes/"/>
        <id>https://ledger.mlabs.city/runs/2026-08-25-s15-unmanaged-attributes/</id>
        
        <content type="html" xml:base="https://ledger.mlabs.city/runs/2026-08-25-s15-unmanaged-attributes/">&lt;h2 id=&quot;summary&quot;&gt;Summary&lt;/h2&gt;
&lt;p&gt;&lt;strong&gt;S15&#39;s hypothesis was wrong, and the truth is worse.&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;The hypothesis: upgrading across 23 → 24, where the declarative user profile
became the default, makes existing custom attributes invisible to the admin API —
a data-visibility regression triggered purely by upgrading.&lt;/p&gt;
&lt;p&gt;It does not. A realm created on 23.0.7 with users carrying &lt;code&gt;department&lt;/code&gt; and
&lt;code&gt;employeeId&lt;/code&gt; shows those attributes through the admin API on 23.0.7, on 24.0.5
and on 26.7.1, unchanged at every hop. &lt;strong&gt;An in-place upgrade preserves them,
because the migration sets that realm&#39;s &lt;code&gt;unmanagedAttributePolicy&lt;/code&gt; to &lt;code&gt;ENABLED&lt;/code&gt;.&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;What actually loses attributes is &lt;strong&gt;creating a realm on 24+&lt;/strong&gt;. Such a realm has
no &lt;code&gt;unmanagedAttributePolicy&lt;/code&gt;, and then the admin API:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;silently discards&lt;/strong&gt; custom attributes on write — &lt;code&gt;HTTP 201&lt;/code&gt;, and &lt;strong&gt;zero rows
reach the database&lt;/strong&gt;; and&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;hides&lt;/strong&gt; any that reach the database by another route — 2 rows present,
&lt;code&gt;attributes: null&lt;/code&gt; returned.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;So the danger is not the upgrade. It is a realm created on a new version.&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;⚠ CORRECTED 2026-08-26.&lt;/strong&gt; This paragraph originally read &quot;every path that
produces a &lt;em&gt;new&lt;/em&gt; realm on a new version: &lt;strong&gt;export/import migration&lt;/strong&gt;, a rebuilt
environment, disaster recovery, or simply a realm created after the upgrade.&quot;
That generalisation is wrong. Run C exported a 23.0.7 realm and imported it into
24.0.5, 25.0.6, 26.0.0 and 26.7.1: all six &lt;code&gt;USER_ATTRIBUTE&lt;/code&gt; rows survive, the
admin API returns them, and the realm still accepts new ones — because &lt;strong&gt;the
import sets &lt;code&gt;unmanagedAttributePolicy=ENABLED&lt;/code&gt;&lt;/strong&gt;, exactly as the in-place
migration does. See &lt;a href=&quot;/findings/2026-08-26-runC-realm-export-import-23-to-24/&quot;&gt;&lt;code&gt;2026-08-26-runC-realm-export-import-23-to-24&lt;/code&gt;&lt;/a&gt;.&lt;/p&gt;
&lt;p&gt;The path measured below — a realm created through the &lt;strong&gt;admin API&lt;/strong&gt; — is the one
that loses attributes. The measurements in this record stand; the inference from
them to a class of untested paths did not.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;h2 id=&quot;environment&quot;&gt;Environment&lt;/h2&gt;
&lt;table&gt;&lt;thead&gt;&lt;tr&gt;&lt;th&gt;Field&lt;/th&gt;&lt;th&gt;Value&lt;/th&gt;&lt;/tr&gt;&lt;/thead&gt;&lt;tbody&gt;
&lt;tr&gt;&lt;td&gt;Keycloak path&lt;/td&gt;&lt;td&gt;23.0.7 → 24.0.5 → 26.7.1 (in-place, real migrations)&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Distribution / start mode&lt;/td&gt;&lt;td&gt;quay.io/keycloak/keycloak official image, &lt;code&gt;start&lt;/code&gt; w/ external DB&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Database&lt;/td&gt;&lt;td&gt;PostgreSQL 16.15, single node, container&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Postgres tuning&lt;/td&gt;&lt;td&gt;stock — &lt;code&gt;shared_buffers&lt;/code&gt; 128MB, &lt;code&gt;maintenance_work_mem&lt;/code&gt; 64MB, &lt;code&gt;statement_timeout&lt;/code&gt; 0&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Adverse scenario&lt;/td&gt;&lt;td&gt;S15&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Dataset scale&lt;/td&gt;&lt;td&gt;small — 3 attribute-carrying users, plus SQL-inserted controls; 2 realms, later 3&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Seeding method&lt;/td&gt;&lt;td&gt;&lt;code&gt;partialImport&lt;/code&gt; (&lt;code&gt;--profile minimal&lt;/code&gt;), admin API user creation, &lt;strong&gt;and&lt;/strong&gt; direct SQL inserts as a control&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Topology&lt;/td&gt;&lt;td&gt;single container&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Host&lt;/td&gt;&lt;td&gt;Hetzner CCX33 (fsn1) — 8 vCPU dedicated, 30 GB RAM, local NVMe&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;JVM heap&lt;/td&gt;&lt;td&gt;&lt;code&gt;-Xms1g -Xmx4g&lt;/code&gt;&lt;/td&gt;&lt;/tr&gt;
&lt;/tbody&gt;&lt;/table&gt;
&lt;h2 id=&quot;results&quot;&gt;Results&lt;/h2&gt;
&lt;h3 id=&quot;the-upgrade-path-no-regression-at-any-hop&quot;&gt;The upgrade path: no regression at any hop&lt;/h3&gt;
&lt;p&gt;Users created on 23.0.7 with &lt;code&gt;attributes: {department, employeeId}&lt;/code&gt;:&lt;/p&gt;
&lt;table&gt;&lt;thead&gt;&lt;tr&gt;&lt;th&gt;&lt;/th&gt;&lt;th&gt;&lt;code&gt;USER_ATTRIBUTE&lt;/code&gt; rows&lt;/th&gt;&lt;th&gt;Admin API returns&lt;/th&gt;&lt;/tr&gt;&lt;/thead&gt;&lt;tbody&gt;
&lt;tr&gt;&lt;td&gt;&lt;strong&gt;23.0.7&lt;/strong&gt; (before)&lt;/td&gt;&lt;td&gt;6 (2 per user × 3)&lt;/td&gt;&lt;td&gt;&lt;code&gt;{&quot;department&quot;:[&quot;engineering&quot;],&quot;employeeId&quot;:[&quot;E001&quot;]}&lt;/code&gt;&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;&lt;strong&gt;24.0.5&lt;/strong&gt; (after the hop that matters)&lt;/td&gt;&lt;td&gt;6&lt;/td&gt;&lt;td&gt;&lt;code&gt;{&quot;department&quot;:[&quot;engineering&quot;],&quot;employeeId&quot;:[&quot;E001&quot;]}&lt;/code&gt;&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;&lt;strong&gt;26.7.1&lt;/strong&gt;&lt;/td&gt;&lt;td&gt;6&lt;/td&gt;&lt;td&gt;&lt;code&gt;{&quot;employeeId&quot;:[&quot;E001&quot;],&quot;department&quot;:[&quot;engineering&quot;]}&lt;/code&gt;&lt;/td&gt;&lt;/tr&gt;
&lt;/tbody&gt;&lt;/table&gt;
&lt;p&gt;Migration timings: 23.0.7 → 24.0.5 in &lt;strong&gt;18s&lt;/strong&gt;, 24.0.5 → 26.7.1 in &lt;strong&gt;17s&lt;/strong&gt;.&lt;/p&gt;
&lt;p&gt;Two controls ruled out the other candidate explanations. Users inserted by
&lt;strong&gt;direct SQL&lt;/strong&gt; — the way &lt;code&gt;seed-sql.sh&lt;/code&gt; does it, with &lt;code&gt;long_value_hash&lt;/code&gt; left NULL —
returned their attributes through the admin API on 26.7.1 just as
admin-API-created users did, both before and after a restart. &lt;strong&gt;The seeding
method is not the variable, and neither is the hash column.&lt;/strong&gt;&lt;/p&gt;
&lt;h3 id=&quot;the-realm-s-birth-version-is-the-variable&quot;&gt;The realm&#39;s birth version is the variable&lt;/h3&gt;
&lt;p&gt;On the same 26.7.1 server, side by side:&lt;/p&gt;
&lt;table&gt;&lt;thead&gt;&lt;tr&gt;&lt;th&gt;&lt;/th&gt;&lt;th&gt;realm &lt;code&gt;lab&lt;/code&gt; (created on 23.0.7, upgraded in place)&lt;/th&gt;&lt;th&gt;realm &lt;code&gt;born26&lt;/code&gt; (created on 26.7.1)&lt;/th&gt;&lt;/tr&gt;&lt;/thead&gt;&lt;tbody&gt;
&lt;tr&gt;&lt;td&gt;&lt;code&gt;unmanagedAttributePolicy&lt;/code&gt;&lt;/td&gt;&lt;td&gt;&lt;strong&gt;&lt;code&gt;ENABLED&lt;/code&gt;&lt;/strong&gt;&lt;/td&gt;&lt;td&gt;&lt;strong&gt;UNSET&lt;/strong&gt;&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Create user with attributes&lt;/td&gt;&lt;td&gt;—&lt;/td&gt;&lt;td&gt;&lt;strong&gt;HTTP 201&lt;/strong&gt;&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;&lt;code&gt;USER_ATTRIBUTE&lt;/code&gt; rows written&lt;/td&gt;&lt;td&gt;10&lt;/td&gt;&lt;td&gt;&lt;strong&gt;0&lt;/strong&gt;&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Admin API returns&lt;/td&gt;&lt;td&gt;&lt;code&gt;{&quot;employeeId&quot;:…,&quot;department&quot;:…}&lt;/code&gt;&lt;/td&gt;&lt;td&gt;&lt;strong&gt;&lt;code&gt;attributes: null&lt;/code&gt;&lt;/strong&gt;&lt;/td&gt;&lt;/tr&gt;
&lt;/tbody&gt;&lt;/table&gt;
&lt;p&gt;The user creation &lt;strong&gt;succeeded&lt;/strong&gt;. It returned 201. The attributes were simply not
persisted, and nothing said so.&lt;/p&gt;
&lt;h3 id=&quot;the-policy-governs-both-sides-independently&quot;&gt;The policy governs both sides, independently&lt;/h3&gt;
&lt;p&gt;Toggling &lt;code&gt;unmanagedAttributePolicy&lt;/code&gt; on &lt;code&gt;born26&lt;/code&gt; and re-testing:&lt;/p&gt;
&lt;table&gt;&lt;thead&gt;&lt;tr&gt;&lt;th&gt;Policy&lt;/th&gt;&lt;th&gt;Write through the admin API&lt;/th&gt;&lt;th&gt;Rows in DB after&lt;/th&gt;&lt;th&gt;Read back&lt;/th&gt;&lt;/tr&gt;&lt;/thead&gt;&lt;tbody&gt;
&lt;tr&gt;&lt;td&gt;UNSET&lt;/td&gt;&lt;td&gt;&lt;code&gt;HTTP 201&lt;/code&gt; — accepted&lt;/td&gt;&lt;td&gt;&lt;strong&gt;0&lt;/strong&gt;&lt;/td&gt;&lt;td&gt;&lt;code&gt;attributes: null&lt;/code&gt;&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;&lt;strong&gt;&lt;code&gt;ENABLED&lt;/code&gt;&lt;/strong&gt;&lt;/td&gt;&lt;td&gt;&lt;code&gt;HTTP 204&lt;/code&gt; — accepted&lt;/td&gt;&lt;td&gt;&lt;strong&gt;2&lt;/strong&gt;&lt;/td&gt;&lt;td&gt;&lt;code&gt;{&quot;employeeId&quot;:[&quot;E001&quot;],&quot;department&quot;:[&quot;engineering&quot;]}&lt;/code&gt;&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;back to UNSET, &lt;strong&gt;rows left in place&lt;/strong&gt;&lt;/td&gt;&lt;td&gt;—&lt;/td&gt;&lt;td&gt;&lt;strong&gt;2&lt;/strong&gt;&lt;/td&gt;&lt;td&gt;&lt;strong&gt;&lt;code&gt;attributes: null&lt;/code&gt;&lt;/strong&gt;&lt;/td&gt;&lt;/tr&gt;
&lt;/tbody&gt;&lt;/table&gt;
&lt;p&gt;The last row is the S15 scenario as originally described: the data is present and
invisible. The middle row shows the fix. And the first row shows the part nobody
warns about — &lt;strong&gt;with the policy unset, the write is a no-op that reports success.&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;Setting the policy to &lt;code&gt;ENABLED&lt;/code&gt; does &lt;strong&gt;not&lt;/strong&gt; recover attributes that were
discarded earlier. There is nothing to recover.&lt;/p&gt;
&lt;h2 id=&quot;verification&quot;&gt;Verification&lt;/h2&gt;
&lt;table&gt;&lt;thead&gt;&lt;tr&gt;&lt;th&gt;Claim&lt;/th&gt;&lt;th&gt;Primary source&lt;/th&gt;&lt;th&gt;Checked&lt;/th&gt;&lt;/tr&gt;&lt;/thead&gt;&lt;tbody&gt;
&lt;tr&gt;&lt;td&gt;Attributes survive 23.0.7 → 24.0.5&lt;/td&gt;&lt;td&gt;admin API returns them on both sides; 6 rows in DB throughout&lt;/td&gt;&lt;td&gt;☑&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;And survive on to 26.7.1&lt;/td&gt;&lt;td&gt;same&lt;/td&gt;&lt;td&gt;☑&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;An upgraded realm has &lt;code&gt;unmanagedAttributePolicy=ENABLED&lt;/code&gt;&lt;/td&gt;&lt;td&gt;&lt;code&gt;/admin/realms/lab/users/profile&lt;/code&gt;&lt;/td&gt;&lt;td&gt;☑&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;A realm created on 26.7.1 has it unset&lt;/td&gt;&lt;td&gt;&lt;code&gt;/admin/realms/born26/users/profile&lt;/code&gt;&lt;/td&gt;&lt;td&gt;☑&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;With it unset, an admin API write silently discards&lt;/td&gt;&lt;td&gt;HTTP 201, &lt;code&gt;select count(*) from user_attribute&lt;/code&gt; = &lt;strong&gt;0&lt;/strong&gt;&lt;/td&gt;&lt;td&gt;☑&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;With it unset, existing rows are hidden&lt;/td&gt;&lt;td&gt;2 rows present, &lt;code&gt;attributes: null&lt;/code&gt;&lt;/td&gt;&lt;td&gt;☑&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;With it &lt;code&gt;ENABLED&lt;/code&gt;, both write and read work&lt;/td&gt;&lt;td&gt;HTTP 204, 2 rows, attributes returned&lt;/td&gt;&lt;td&gt;☑&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Seeding method is not the cause&lt;/td&gt;&lt;td&gt;SQL-inserted users returned attributes on 26.7.1, hash column NULL and populated alike&lt;/td&gt;&lt;td&gt;☑&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Keycloak&#39;s &lt;em&gt;migration&lt;/em&gt; is what sets &lt;code&gt;ENABLED&lt;/code&gt;&lt;/td&gt;&lt;td&gt;&lt;strong&gt;inferred&lt;/strong&gt; from the two realms&#39; policies, not read from source or release notes&lt;/td&gt;&lt;td&gt;☐&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Behaviour of realm export/import across the boundary&lt;/td&gt;&lt;td&gt;&lt;strong&gt;not tested&lt;/strong&gt; — the most likely real-world path to this, and the obvious next run&lt;/td&gt;&lt;td&gt;☐&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Whether tokens/mappers see unmanaged attributes&lt;/td&gt;&lt;td&gt;&lt;strong&gt;not tested&lt;/strong&gt; — this record covers the admin API only&lt;/td&gt;&lt;td&gt;☐&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;&lt;code&gt;ADMIN_VIEW&lt;/code&gt; / &lt;code&gt;ADMIN_EDIT&lt;/code&gt; policy values&lt;/td&gt;&lt;td&gt;not tested — only UNSET and &lt;code&gt;ENABLED&lt;/code&gt;&lt;/td&gt;&lt;td&gt;☐&lt;/td&gt;&lt;/tr&gt;
&lt;/tbody&gt;&lt;/table&gt;
&lt;h2 id=&quot;publishable-extract&quot;&gt;Publishable extract&lt;/h2&gt;
&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Upgrading Keycloak does not hide your custom user attributes. Rebuilding the
realm deletes them, and tells you it worked.&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;Keycloak 24 made the declarative user profile the default, and the widely
repeated warning is that custom attributes go invisible when you cross that
boundary. We tested it: a realm created on 23.0.7 with users carrying
&lt;code&gt;department&lt;/code&gt; and &lt;code&gt;employeeId&lt;/code&gt;, upgraded in place to 24.0.5 and on to 26.7.1.
The attributes were returned by the admin API at every hop, unchanged. The
in-place upgrade sets that realm&#39;s &lt;code&gt;unmanagedAttributePolicy&lt;/code&gt; to &lt;code&gt;ENABLED&lt;/code&gt;, and
everything keeps working.&lt;/p&gt;
&lt;p&gt;Then we created a &lt;strong&gt;new&lt;/strong&gt; realm on 26.7.1 and posted the identical user:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;POST /admin/realms/born26/users   →  HTTP 201
select count(*) from user_attribute where user_id = ...  →  0
GET  /admin/realms/born26/users?username=...   →  &amp;quot;attributes&amp;quot;: null
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The request succeeded. The attributes were never written. Nothing warned us. A
realm created on 24 or later has no &lt;code&gt;unmanagedAttributePolicy&lt;/code&gt;, and with it
unset the admin API accepts custom attributes and drops them on the floor.&lt;/p&gt;
&lt;p&gt;The same policy hides rows that got there another way. We inserted attributes
straight into Postgres, confirmed two rows, and the admin API still answered
&lt;code&gt;attributes: null&lt;/code&gt;. Turning the policy on makes both problems go away — but it
does not bring back what was discarded, because that was never stored.&lt;/p&gt;
&lt;p&gt;So the risk is not the upgrade. It is every path that produces a &lt;em&gt;new&lt;/em&gt; realm on
a &lt;em&gt;new&lt;/em&gt; version: export-and-reimport migrations, environment rebuilds, disaster
recovery, and any realm created after the upgrade. Those are precisely the paths
people take &lt;em&gt;because&lt;/em&gt; they consider them safer than an in-place upgrade.&lt;/p&gt;
&lt;p&gt;One query tells you which kind of realm you have:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;GET /admin/realms/{realm}/users/profile   →   .unmanagedAttributePolicy
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;If it is absent, your custom attributes are not being stored.&lt;/p&gt;
&lt;p&gt;We nearly published the opposite of this. Our own large test realms were built
on 26.0.0 and showed exactly the &lt;code&gt;attributes: null&lt;/code&gt; symptom the folklore
predicts — and we had written it down as an upgrade regression before checking
where the realm came from.&lt;/p&gt;
&lt;/blockquote&gt;
</content>
        
    </entry>
    <entry xml:lang="en">
        <title>Rehearsal Record — S3: `statement_timeout` cannot half-migrate this path</title>
        <published>2026-08-25T00:00:00+00:00</published>
        <updated>2026-08-25T00:00:00+00:00</updated>
        
        <author>
          <name>Unknown</name>
        </author>
        
        <link rel="alternate" type="text/html" href="https://ledger.mlabs.city/runs/2026-08-25-s3-statement-timeout/"/>
        <id>https://ledger.mlabs.city/runs/2026-08-25-s3-statement-timeout/</id>
        
        <content type="html" xml:base="https://ledger.mlabs.city/runs/2026-08-25-s3-statement-timeout/">&lt;h2 id=&quot;summary&quot;&gt;Summary&lt;/h2&gt;
&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;⚠ CONCLUSION OVERTURNED 2026-08-26.&lt;/strong&gt; It does produce one. At 2,000,003 users
a &lt;code&gt;statement_timeout&lt;/code&gt; of 500 ms, 200 ms or 50 ms leaves &lt;code&gt;DATABASECHANGELOG&lt;/code&gt; at
157 of 211 with &lt;code&gt;migration_model&lt;/code&gt; on 26.0.0 and the server dead — &lt;strong&gt;seven times
in ten identical runs.&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;The measurements below are correct and the reasoning from them is not. The
statement that dies is &lt;code&gt;ALTER TABLE CREDENTIAL ADD VERSION INTEGER DEFAULT 0&lt;/code&gt;,
which &lt;em&gt;executes&lt;/em&gt; in 0.177 ms and &lt;em&gt;waits&lt;/em&gt; about a second for an
&lt;code&gt;AccessExclusiveLock&lt;/code&gt; held by an autovacuum worker — Postgres evicts a blocking
autovacuum only after &lt;code&gt;deadlock_timeout&lt;/code&gt;, and &lt;code&gt;statement_timeout&lt;/code&gt; is charged for
the wait. This record measured statement durations on a single run that happened
to be uncontended.&lt;/p&gt;
&lt;p&gt;See &lt;a href=&quot;/runs/2026-08-26-s3-revisited-autovacuum-lock-wait/&quot;&gt;&lt;code&gt;2026-08-26-s3-revisited-autovacuum-lock-wait&lt;/code&gt;&lt;/a&gt;. Everything below stands as
a measurement of an uncontended migration; nothing below should be quoted as a
conclusion about &lt;code&gt;statement_timeout&lt;/code&gt;.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;&lt;strong&gt;S3 was written to produce the half-migrated schema our failure-class notes name. It does not produce one.&lt;/strong&gt; On this path, at
2,000,003 users, the longest single SQL statement the migration issues is
&lt;strong&gt;2.142 ms&lt;/strong&gt;. There is no &lt;code&gt;statement_timeout&lt;/code&gt; value that lands between &quot;too short
to start&quot; and &quot;long enough to finish&quot;: at 2 ms Keycloak fails &lt;em&gt;before applying
any changeset&lt;/em&gt;, at 3 ms it completes the whole migration. The band that would
leave a schema half-applied is empty.&lt;/p&gt;
&lt;p&gt;This is the second scenario in a row (after S1/S2) that fails to reproduce a
failure class we have been citing.&lt;/p&gt;
&lt;h2 id=&quot;environment&quot;&gt;Environment&lt;/h2&gt;
&lt;table&gt;&lt;thead&gt;&lt;tr&gt;&lt;th&gt;Field&lt;/th&gt;&lt;th&gt;Value&lt;/th&gt;&lt;/tr&gt;&lt;/thead&gt;&lt;tbody&gt;
&lt;tr&gt;&lt;td&gt;Keycloak from → to&lt;/td&gt;&lt;td&gt;26.0.0 → 26.7.1&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Distribution / start mode&lt;/td&gt;&lt;td&gt;quay.io/keycloak/keycloak official image, &lt;code&gt;start&lt;/code&gt; w/ external DB&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Database&lt;/td&gt;&lt;td&gt;PostgreSQL 16.15 (Debian 16.15-1.pgdg13+2), &lt;strong&gt;single node&lt;/strong&gt;, container&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Postgres tuning&lt;/td&gt;&lt;td&gt;&lt;code&gt;shared_buffers&lt;/code&gt; 128MB, &lt;code&gt;maintenance_work_mem&lt;/code&gt; 64MB — stock; &lt;code&gt;statement_timeout&lt;/code&gt; &lt;strong&gt;is the variable&lt;/strong&gt;, 10s / 3ms / 2ms&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Adverse scenario&lt;/td&gt;&lt;td&gt;S3&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Dataset scale&lt;/td&gt;&lt;td&gt;2,000,003 users, 2 realms&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Seeding method&lt;/td&gt;&lt;td&gt;direct SQL COPY (&lt;code&gt;bin/seed-sql.sh&lt;/code&gt;)&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Topology&lt;/td&gt;&lt;td&gt;single container&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Host&lt;/td&gt;&lt;td&gt;Hetzner CCX33 (fsn1) — 8 vCPU dedicated, 30 GB RAM, local NVMe&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;JVM heap&lt;/td&gt;&lt;td&gt;&lt;code&gt;-Xms1g -Xmx4g&lt;/code&gt;&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Instrumentation&lt;/td&gt;&lt;td&gt;&lt;code&gt;log_min_duration_statement = 0&lt;/code&gt; for the measurement run&lt;/td&gt;&lt;/tr&gt;
&lt;/tbody&gt;&lt;/table&gt;
&lt;h2 id=&quot;procedure&quot;&gt;Procedure&lt;/h2&gt;
&lt;pre&gt;&lt;code&gt;./bin/restore.sh baseline-26.0.0-sqlseed-2m
docker compose exec -T db psql -U keycloak -c &amp;quot;alter system set log_min_duration_statement=0&amp;quot; -c &amp;quot;select pg_reload_conf()&amp;quot;
./bin/upgrade.sh 26.7.1                       # measurement run
docker compose logs db | grep &amp;#39;LOG:  duration:&amp;#39; | sort -gr   # rank statements

# then, per timeout value:
./bin/restore.sh baseline-26.0.0-sqlseed-2m
docker compose exec -T db psql -U keycloak -c &amp;quot;alter role keycloak set statement_timeout=&amp;#39;&amp;lt;T&amp;gt;&amp;#39;&amp;quot;
./bin/upgrade.sh 26.7.1
&lt;/code&gt;&lt;/pre&gt;
&lt;h2 id=&quot;what-we-could-not-observe&quot;&gt;What we could not observe&lt;/h2&gt;
&lt;p&gt;&lt;strong&gt;No half-migrated schema, at any timeout value.&lt;/strong&gt; The empty band is a
consequence of every statement in the migration falling inside 1.8–2.2 ms: the
parse-phase reads and the DDL writes are the same order of magnitude, so a
timeout tight enough to kill a changeset is also tight enough to kill the parse
that precedes it. That coincidence is a property of &lt;em&gt;this pair at this scale&lt;/em&gt;,
not a general law, and we did not test whether it holds elsewhere.&lt;/p&gt;
&lt;table&gt;&lt;thead&gt;&lt;tr&gt;&lt;th&gt;Claim&lt;/th&gt;&lt;th&gt;Primary source&lt;/th&gt;&lt;th&gt;Checked&lt;/th&gt;&lt;/tr&gt;&lt;/thead&gt;&lt;tbody&gt;
&lt;tr&gt;&lt;td&gt;Longest migration statement is 2.142 ms at 2M users&lt;/td&gt;&lt;td&gt;&lt;code&gt;log_min_duration_statement=0&lt;/code&gt; output, ranked&lt;/td&gt;&lt;td&gt;☑&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;10s timeout has no effect on this path&lt;/td&gt;&lt;td&gt;full run, changelog 211, 2M users intact&lt;/td&gt;&lt;td&gt;☑&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;2ms fails before applying any changeset&lt;/td&gt;&lt;td&gt;changelog still 144, model still 26.0.0&lt;/td&gt;&lt;td&gt;☑&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Failure leaves no held lock&lt;/td&gt;&lt;td&gt;&lt;code&gt;databasechangeloglock&lt;/code&gt; read directly, both rows &lt;code&gt;f&lt;/code&gt;&lt;/td&gt;&lt;td&gt;☑&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;A half-migrated schema is reachable via timeout&lt;/td&gt;&lt;td&gt;&lt;strong&gt;no&lt;/strong&gt; — refuted for this pair at this scale&lt;/td&gt;&lt;td&gt;☑&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Holds on a pair with data-rewriting changesets&lt;/td&gt;&lt;td&gt;&lt;strong&gt;not tested&lt;/strong&gt; — this pair has none&lt;/td&gt;&lt;td&gt;☐&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Holds if the customer built the skipped indexes manually&lt;/td&gt;&lt;td&gt;&lt;strong&gt;not tested&lt;/strong&gt; — the obvious next run&lt;/td&gt;&lt;td&gt;☐&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Holds on other databases&lt;/td&gt;&lt;td&gt;not tested — Postgres only&lt;/td&gt;&lt;td&gt;☐&lt;/td&gt;&lt;/tr&gt;
&lt;/tbody&gt;&lt;/table&gt;
</content>
        
    </entry>
    <entry xml:lang="en">
        <title>Rehearsal Record — S4: it is not DDL *rights* that the migration needs, it is *ownership*</title>
        <published>2026-08-25T00:00:00+00:00</published>
        <updated>2026-08-25T00:00:00+00:00</updated>
        
        <author>
          <name>Unknown</name>
        </author>
        
        <link rel="alternate" type="text/html" href="https://ledger.mlabs.city/runs/2026-08-25-s4-dml-only-db-user/"/>
        <id>https://ledger.mlabs.city/runs/2026-08-25-s4-dml-only-db-user/</id>
        
        <content type="html" xml:base="https://ledger.mlabs.city/runs/2026-08-25-s4-dml-only-db-user/">&lt;h2 id=&quot;summary&quot;&gt;Summary&lt;/h2&gt;
&lt;p&gt;S4 asked whether a DML-only database account fails cleanly. It does. But the run
answered a &lt;strong&gt;more useful question than the one asked&lt;/strong&gt;: granting that account
full DDL rights &lt;em&gt;does not fix it&lt;/em&gt;. The first changeset in this upgrade issues
&lt;code&gt;DROP INDEX&lt;/code&gt;, and in Postgres &lt;code&gt;DROP INDEX&lt;/code&gt; requires &lt;strong&gt;ownership&lt;/strong&gt; of the index —
a thing no &lt;code&gt;GRANT&lt;/code&gt; confers.&lt;/p&gt;
&lt;p&gt;The correct pre-flight check is therefore not &quot;does the Keycloak database user
have DDL permissions&quot;, which is what everyone asks. It is &lt;strong&gt;&quot;does the Keycloak
database user own the Keycloak objects&quot;&lt;/strong&gt;.&lt;/p&gt;
&lt;h2 id=&quot;environment&quot;&gt;Environment&lt;/h2&gt;
&lt;table&gt;&lt;thead&gt;&lt;tr&gt;&lt;th&gt;Field&lt;/th&gt;&lt;th&gt;Value&lt;/th&gt;&lt;/tr&gt;&lt;/thead&gt;&lt;tbody&gt;
&lt;tr&gt;&lt;td&gt;Keycloak from → to&lt;/td&gt;&lt;td&gt;26.0.0 → 26.7.1&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Distribution / start mode&lt;/td&gt;&lt;td&gt;quay.io/keycloak/keycloak official image, &lt;code&gt;start&lt;/code&gt; w/ external DB&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Database&lt;/td&gt;&lt;td&gt;PostgreSQL 16.15, &lt;strong&gt;single node&lt;/strong&gt;, container&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Postgres tuning&lt;/td&gt;&lt;td&gt;&lt;code&gt;shared_buffers&lt;/code&gt; 128MB, &lt;code&gt;maintenance_work_mem&lt;/code&gt; 64MB, &lt;code&gt;statement_timeout&lt;/code&gt; 0 — stock&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Adverse scenario&lt;/td&gt;&lt;td&gt;S4 — &lt;strong&gt;the database role Keycloak connects as is the variable&lt;/strong&gt;&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Dataset scale&lt;/td&gt;&lt;td&gt;1,002 users, 2 realms&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Seeding method&lt;/td&gt;&lt;td&gt;&lt;code&gt;partialImport&lt;/code&gt; (&lt;code&gt;bin/seed-realm.sh --profile typical&lt;/code&gt;)&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Topology&lt;/td&gt;&lt;td&gt;single container&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Host&lt;/td&gt;&lt;td&gt;Hetzner CCX33 (fsn1) — 8 vCPU dedicated, 30 GB RAM, local NVMe&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;JVM heap&lt;/td&gt;&lt;td&gt;&lt;code&gt;-Xms1g -Xmx4g&lt;/code&gt;&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Lab change&lt;/td&gt;&lt;td&gt;&lt;code&gt;docker-compose.dbidentity.yml&lt;/code&gt; added so &lt;code&gt;KC_DB_USERNAME&lt;/code&gt; / &lt;code&gt;KC_DB_PASSWORD&lt;/code&gt; / &lt;code&gt;KC_DB_SCHEMA&lt;/code&gt; are env-driven without editing the base compose file&lt;/td&gt;&lt;/tr&gt;
&lt;/tbody&gt;&lt;/table&gt;
&lt;h2 id=&quot;procedure&quot;&gt;Procedure&lt;/h2&gt;
&lt;pre&gt;&lt;code&gt;./bin/restore.sh baseline-26.0.0-typical-1k

# a locked-down enterprise account: DML on everything, DDL on nothing
create role kc_dml login password &amp;#39;kc_dml&amp;#39;;
grant connect on database keycloak to kc_dml;
grant usage on schema public to kc_dml;
grant select,insert,update,delete on all tables in schema public to kc_dml;
grant usage,select on all sequences in schema public to kc_dml;
revoke create on schema public from kc_dml, public;

# .env
COMPOSE_FILE=docker-compose.yml:docker-compose.dbidentity.yml
KC_DB_USERNAME=kc_dml
KC_DB_PASSWORD=kc_dml

./bin/upgrade.sh 26.7.1
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Then two further runs, each changing exactly one thing: &lt;code&gt;grant create on schema public to kc_dml&lt;/code&gt; (run B), and &lt;code&gt;alter table … owner to kc_dml&lt;/code&gt; for every table
and sequence (run C).&lt;/p&gt;
&lt;h2 id=&quot;results&quot;&gt;Results&lt;/h2&gt;
&lt;table&gt;&lt;thead&gt;&lt;tr&gt;&lt;th&gt;Run&lt;/th&gt;&lt;th&gt;Role&#39;s rights&lt;/th&gt;&lt;th&gt;Outcome&lt;/th&gt;&lt;th&gt;Elapsed&lt;/th&gt;&lt;th&gt;Schema after&lt;/th&gt;&lt;th&gt;Users&lt;/th&gt;&lt;/tr&gt;&lt;/thead&gt;&lt;tbody&gt;
&lt;tr&gt;&lt;td&gt;&lt;strong&gt;A&lt;/strong&gt;&lt;/td&gt;&lt;td&gt;DML only, no &lt;code&gt;CREATE&lt;/code&gt;&lt;/td&gt;&lt;td&gt;&lt;strong&gt;exit 1, refused to start&lt;/strong&gt;&lt;/td&gt;&lt;td&gt;died at 182s&lt;/td&gt;&lt;td&gt;model 26.0.0, changelog &lt;strong&gt;144 (untouched)&lt;/strong&gt;&lt;/td&gt;&lt;td&gt;1,002&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;&lt;strong&gt;B&lt;/strong&gt;&lt;/td&gt;&lt;td&gt;DML &lt;strong&gt;+ &lt;code&gt;CREATE&lt;/code&gt; on schema&lt;/strong&gt; (verified: can &lt;code&gt;CREATE TABLE&lt;/code&gt;)&lt;/td&gt;&lt;td&gt;&lt;strong&gt;exit 1, identical failure&lt;/strong&gt;&lt;/td&gt;&lt;td&gt;died at 151s&lt;/td&gt;&lt;td&gt;model 26.0.0, changelog &lt;strong&gt;144 (untouched)&lt;/strong&gt;&lt;/td&gt;&lt;td&gt;1,002&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;&lt;strong&gt;C&lt;/strong&gt;&lt;/td&gt;&lt;td&gt;&lt;strong&gt;owner&lt;/strong&gt; of every table and sequence&lt;/td&gt;&lt;td&gt;&lt;strong&gt;READY, clean&lt;/strong&gt;&lt;/td&gt;&lt;td&gt;17s&lt;/td&gt;&lt;td&gt;model 26.7.1, changelog 211&lt;/td&gt;&lt;td&gt;1,002&lt;/td&gt;&lt;/tr&gt;
&lt;/tbody&gt;&lt;/table&gt;
&lt;h3 id=&quot;the-failure-identical-in-runs-a-and-b&quot;&gt;The failure, identical in runs A and B&lt;/h3&gt;
&lt;pre&gt;&lt;code&gt;ERROR [liquibase.changelog.ChangeSet] ChangeSet
  META-INF/jpa-changelog-20.0.0.xml::20.0.0-12964-supported-dbs-edb-migration::keycloak
  encountered an exception.: liquibase.exception.DatabaseException:
  ERROR: must be owner of index idx_group_att_by_name_value
  [Failed SQL: (0) DROP INDEX public.IDX_GROUP_ATT_BY_NAME_VALUE]
ERROR: Failed to update database
ERROR: Failed to start server in (production) mode
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Run B is the load-bearing one. &lt;code&gt;kc_dml&lt;/code&gt; demonstrably had DDL rights — the probe
&lt;code&gt;create table ddl_probe(x int); drop table ddl_probe;&lt;/code&gt; succeeded as that role
immediately before the run — and the migration failed at exactly the same
changeset with exactly the same message. &lt;strong&gt;&lt;code&gt;must be owner of&lt;/code&gt; is not a
privilege error and cannot be granted away.&lt;/strong&gt; Postgres reserves &lt;code&gt;DROP INDEX&lt;/code&gt;,
and &lt;code&gt;ALTER TABLE&lt;/code&gt;, to the object&#39;s owner (or a superuser, or a member of the
owning role).&lt;/p&gt;
&lt;h3 id=&quot;why-it-fails-on-the-first-changeset&quot;&gt;Why it fails on the &lt;em&gt;first&lt;/em&gt; changeset&lt;/h3&gt;
&lt;p&gt;The 26.0.0 baseline carries 144 changelog rows. Reading them back after the
successful run C:&lt;/p&gt;
&lt;table&gt;&lt;thead&gt;&lt;tr&gt;&lt;th&gt;id&lt;/th&gt;&lt;th&gt;exectype&lt;/th&gt;&lt;th&gt;orderexecuted&lt;/th&gt;&lt;/tr&gt;&lt;/thead&gt;&lt;tbody&gt;
&lt;tr&gt;&lt;td&gt;&lt;code&gt;20.0.0-12964-supported-dbs&lt;/code&gt;&lt;/td&gt;&lt;td&gt;EXECUTED&lt;/td&gt;&lt;td&gt;109&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;&lt;code&gt;20.0.0-12964-unsupported-dbs&lt;/code&gt;&lt;/td&gt;&lt;td&gt;MARK_RAN&lt;/td&gt;&lt;td&gt;110&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;&lt;strong&gt;&lt;code&gt;20.0.0-12964-supported-dbs-edb-migration&lt;/code&gt;&lt;/strong&gt;&lt;/td&gt;&lt;td&gt;&lt;strong&gt;EXECUTED&lt;/strong&gt;&lt;/td&gt;&lt;td&gt;&lt;strong&gt;145&lt;/strong&gt;&lt;/td&gt;&lt;/tr&gt;
&lt;/tbody&gt;&lt;/table&gt;
&lt;p&gt;Order 145 is the first row past the 144-row baseline. The very first thing a
26.0.0 → 26.7.1 migration does is drop and rebuild &lt;code&gt;IDX_GROUP_ATT_BY_NAME_VALUE&lt;/code&gt;,
via a changeset that lives in the &lt;em&gt;20.0.0&lt;/em&gt; changelog file and exists to fix up
EDB deployments. So the ownership requirement bites immediately — which is the
good news, because nothing else has been applied when it does.&lt;/p&gt;
&lt;h2 id=&quot;outcome&quot;&gt;Outcome&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;&lt;input disabled=&quot;&quot; type=&quot;checkbox&quot; checked=&quot;&quot;/&gt;
Failed cleanly, before touching data — &lt;code&gt;databasechangelog&lt;/code&gt; still 144, all 1,002 users present, both &lt;code&gt;databasechangeloglock&lt;/code&gt; rows &lt;code&gt;locked=f&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;input disabled=&quot;&quot; type=&quot;checkbox&quot; checked=&quot;&quot;/&gt;
Container exited 1 and stayed exited (one attempt, no retry loop)&lt;/li&gt;
&lt;li&gt;&lt;input disabled=&quot;&quot; type=&quot;checkbox&quot; checked=&quot;&quot;/&gt;
&lt;code&gt;503&lt;/code&gt; from &lt;code&gt;BootstrapFilter&lt;/code&gt; while the failed bootstrap was in flight&lt;/li&gt;
&lt;li&gt;&lt;input disabled=&quot;&quot; type=&quot;checkbox&quot; checked=&quot;&quot;/&gt;
Recoverable by fixing ownership alone — no restore needed&lt;/li&gt;
&lt;/ul&gt;
&lt;h2 id=&quot;verification&quot;&gt;Verification&lt;/h2&gt;
&lt;table&gt;&lt;thead&gt;&lt;tr&gt;&lt;th&gt;Claim&lt;/th&gt;&lt;th&gt;Primary source&lt;/th&gt;&lt;th&gt;Checked&lt;/th&gt;&lt;/tr&gt;&lt;/thead&gt;&lt;tbody&gt;
&lt;tr&gt;&lt;td&gt;DML-only account fails the migration&lt;/td&gt;&lt;td&gt;run A, exit 1, changelog 144&lt;/td&gt;&lt;td&gt;☑&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Granting &lt;code&gt;CREATE&lt;/code&gt; does &lt;strong&gt;not&lt;/strong&gt; fix it&lt;/td&gt;&lt;td&gt;run B, same changeset, same message, after a successful &lt;code&gt;CREATE TABLE&lt;/code&gt; probe as that role&lt;/td&gt;&lt;td&gt;☑&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Ownership &lt;strong&gt;does&lt;/strong&gt; fix it&lt;/td&gt;&lt;td&gt;run C, ready in 17s, changelog 211&lt;/td&gt;&lt;td&gt;☑&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Failure applies nothing&lt;/td&gt;&lt;td&gt;changelog 144 and 1,002 users after both failed runs&lt;/td&gt;&lt;td&gt;☑&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;The blocking changeset is the first pending one&lt;/td&gt;&lt;td&gt;&lt;code&gt;orderexecuted = 145&lt;/code&gt; against a 144-row baseline&lt;/td&gt;&lt;td&gt;☑&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Holds on other version pairs&lt;/td&gt;&lt;td&gt;&lt;strong&gt;not tested&lt;/strong&gt; — a pair with no &lt;code&gt;DROP INDEX&lt;/code&gt;/&lt;code&gt;ALTER TABLE&lt;/code&gt; in its delta may not hit this&lt;/td&gt;&lt;td&gt;☐&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Holds on MySQL / MariaDB / Oracle&lt;/td&gt;&lt;td&gt;not tested — Postgres only; ownership semantics differ per engine&lt;/td&gt;&lt;td&gt;☐&lt;/td&gt;&lt;/tr&gt;
&lt;/tbody&gt;&lt;/table&gt;
&lt;h2 id=&quot;publishable-extract&quot;&gt;Publishable extract&lt;/h2&gt;
&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Everyone asks whether the Keycloak database user has DDL permissions. That is
the wrong question.&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;We gave Keycloak a locked-down Postgres account — &lt;code&gt;select&lt;/code&gt;, &lt;code&gt;insert&lt;/code&gt;, &lt;code&gt;update&lt;/code&gt;,
&lt;code&gt;delete&lt;/code&gt; on every table, no &lt;code&gt;create&lt;/code&gt; — and upgraded a 1,002-user realm from
26.0.0 to 26.7.1 on Postgres 16.15. It failed, cleanly, having applied nothing:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;ERROR: must be owner of index idx_group_att_by_name_value
[Failed SQL: (0) DROP INDEX public.IDX_GROUP_ATT_BY_NAME_VALUE]
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Then we granted that same account &lt;code&gt;CREATE&lt;/code&gt; on the schema and proved it could
create and drop a table. &lt;strong&gt;The upgrade failed again, at the same changeset,
with the same message.&lt;/strong&gt; Only making the account the &lt;em&gt;owner&lt;/em&gt; of the tables let
the migration run — after which it completed in 17 seconds.&lt;/p&gt;
&lt;p&gt;The reason is plain Postgres, not Keycloak: &lt;code&gt;DROP INDEX&lt;/code&gt; and &lt;code&gt;ALTER TABLE&lt;/code&gt;
require ownership, and ownership is not a privilege you can &lt;code&gt;GRANT&lt;/code&gt;. Any shop
where a DBA creates the schema and the application connects as a different
role — which is most shops with a change-control process — has a Keycloak
upgrade that will stop dead on its first changeset.&lt;/p&gt;
&lt;p&gt;The check to run before your maintenance window is one query:&lt;/p&gt;
&lt;pre&gt;&lt;code data-lang=&quot;sql&quot;&gt;select tablename, tableowner from pg_tables
where schemaname = &amp;#39;public&amp;#39; and tableowner &amp;lt;&amp;gt; &amp;#39;&amp;lt;your keycloak role&amp;gt;&amp;#39;;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;If that returns rows, your upgrade has not been tested. Ours hadn&#39;t either.&lt;/p&gt;
&lt;/blockquote&gt;
</content>
        
    </entry>
    <entry xml:lang="en">
        <title>Rehearsal Record — S5: the heap floor does not move with realm size</title>
        <published>2026-08-25T00:00:00+00:00</published>
        <updated>2026-08-25T00:00:00+00:00</updated>
        
        <author>
          <name>Unknown</name>
        </author>
        
        <link rel="alternate" type="text/html" href="https://ledger.mlabs.city/runs/2026-08-25-s5-jvm-heap-floor/"/>
        <id>https://ledger.mlabs.city/runs/2026-08-25-s5-jvm-heap-floor/</id>
        
        <content type="html" xml:base="https://ledger.mlabs.city/runs/2026-08-25-s5-jvm-heap-floor/">&lt;h2 id=&quot;summary&quot;&gt;Summary&lt;/h2&gt;
&lt;p&gt;S5 asked us to &quot;establish the heap floor per realm size — a number customers can
be given directly.&quot; &lt;strong&gt;There is no per-realm-size floor on this path.&lt;/strong&gt; A
1,002-user realm and a 2,000,003-user realm have the &lt;em&gt;same&lt;/em&gt; floor, bracketed
identically between 128 MB and 160 MB, because the thing that runs out of memory
is &lt;strong&gt;Quarkus augmentation — the auto-build that happens before the migration
starts&lt;/strong&gt; — and augmentation does not care how many users exist.&lt;/p&gt;
&lt;p&gt;The lab&#39;s own compose file pins &lt;code&gt;-Xmx4g&lt;/code&gt;. The measured floor is 160 MB. We have
been provisioning &lt;strong&gt;25× the heap the run needs&lt;/strong&gt;, and had assumed the multiple
was there for the data.&lt;/p&gt;
&lt;h2 id=&quot;environment&quot;&gt;Environment&lt;/h2&gt;
&lt;table&gt;&lt;thead&gt;&lt;tr&gt;&lt;th&gt;Field&lt;/th&gt;&lt;th&gt;Value&lt;/th&gt;&lt;/tr&gt;&lt;/thead&gt;&lt;tbody&gt;
&lt;tr&gt;&lt;td&gt;Keycloak from → to&lt;/td&gt;&lt;td&gt;26.0.0 → 26.7.1&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Distribution / start mode&lt;/td&gt;&lt;td&gt;quay.io/keycloak/keycloak official image, &lt;code&gt;start&lt;/code&gt; w/ external DB, &lt;strong&gt;auto-build on start&lt;/strong&gt; (no &lt;code&gt;--optimized&lt;/code&gt;)&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Database&lt;/td&gt;&lt;td&gt;PostgreSQL 16.15, single node, container&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Postgres tuning&lt;/td&gt;&lt;td&gt;stock — &lt;code&gt;shared_buffers&lt;/code&gt; 128MB, &lt;code&gt;maintenance_work_mem&lt;/code&gt; 64MB, &lt;code&gt;statement_timeout&lt;/code&gt; 0&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Adverse scenario&lt;/td&gt;&lt;td&gt;S5 — &lt;strong&gt;&lt;code&gt;-Xmx&lt;/code&gt; is the variable&lt;/strong&gt;&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Dataset scale&lt;/td&gt;&lt;td&gt;two runs: 1,002 users / 2 realms, and 2,000,003 users / 2 realms&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Seeding method&lt;/td&gt;&lt;td&gt;&lt;code&gt;partialImport&lt;/code&gt; (1k) and direct SQL COPY (2M)&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Topology&lt;/td&gt;&lt;td&gt;single container&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Host&lt;/td&gt;&lt;td&gt;Hetzner CCX33 (fsn1) — 8 vCPU dedicated, 30 GB RAM, local NVMe&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;JVM heap&lt;/td&gt;&lt;td&gt;&lt;code&gt;-Xms64m -Xmx&amp;lt;variable&amp;gt;&lt;/code&gt;; lab default is &lt;code&gt;-Xms1g -Xmx4g&lt;/code&gt;&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Lab change&lt;/td&gt;&lt;td&gt;&lt;code&gt;docker-compose.heap.yml&lt;/code&gt; added so &lt;code&gt;JAVA_OPTS_APPEND&lt;/code&gt; is env-driven, defaulting to the base file&#39;s &lt;code&gt;-Xms1g -Xmx4g&lt;/code&gt;&lt;/td&gt;&lt;/tr&gt;
&lt;/tbody&gt;&lt;/table&gt;
&lt;h2 id=&quot;procedure&quot;&gt;Procedure&lt;/h2&gt;
&lt;pre&gt;&lt;code&gt;# .env
COMPOSE_FILE=docker-compose.yml:docker-compose.heap.yml

for H in 256m 192m 160m 128m 64m; do
  ./bin/restore.sh &amp;lt;baseline&amp;gt;              # at the lab default heap -- see note
  JAVA_OPTS_APPEND=&amp;quot;-Xms64m -Xmx$H&amp;quot;
  docker compose up -d --force-recreate keycloak
  TIMEOUT=240 ./bin/wait-ready.sh
done
&lt;/code&gt;&lt;/pre&gt;
&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Note, and a trap worth recording:&lt;/strong&gt; the heap must be reset to a working value
&lt;em&gt;before&lt;/em&gt; &lt;code&gt;restore.sh&lt;/code&gt;, not after. &lt;code&gt;restore.sh&lt;/code&gt; starts Keycloak as part of its
re-migration check, so a heap too small to boot makes the &lt;strong&gt;restore&lt;/strong&gt; fail, and
the next iteration then measures the wrong baseline. The first attempt at this
ladder was lost to exactly that.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;h2 id=&quot;results&quot;&gt;Results&lt;/h2&gt;
&lt;table&gt;&lt;thead&gt;&lt;tr&gt;&lt;th&gt;&lt;code&gt;-Xmx&lt;/code&gt;&lt;/th&gt;&lt;th&gt;1,002 users&lt;/th&gt;&lt;th&gt;2,000,003 users&lt;/th&gt;&lt;/tr&gt;&lt;/thead&gt;&lt;tbody&gt;
&lt;tr&gt;&lt;td&gt;512m&lt;/td&gt;&lt;td&gt;—&lt;/td&gt;&lt;td&gt;&lt;strong&gt;ready, 16s&lt;/strong&gt;&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;256m&lt;/td&gt;&lt;td&gt;&lt;strong&gt;ready, 17s&lt;/strong&gt;&lt;/td&gt;&lt;td&gt;&lt;strong&gt;ready, 16s&lt;/strong&gt;&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;192m&lt;/td&gt;&lt;td&gt;&lt;strong&gt;ready, 17s&lt;/strong&gt;&lt;/td&gt;&lt;td&gt;—&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;160m&lt;/td&gt;&lt;td&gt;&lt;strong&gt;ready, 17s&lt;/strong&gt;&lt;/td&gt;&lt;td&gt;&lt;strong&gt;ready, 17s&lt;/strong&gt;&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;&lt;strong&gt;128m&lt;/strong&gt;&lt;/td&gt;&lt;td&gt;&lt;strong&gt;OOM, died at 241s&lt;/strong&gt;&lt;/td&gt;&lt;td&gt;&lt;strong&gt;OOM, died at 241s&lt;/strong&gt;&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;64m&lt;/td&gt;&lt;td&gt;&lt;strong&gt;OOM, died at 242s&lt;/strong&gt;&lt;/td&gt;&lt;td&gt;&lt;strong&gt;OOM, died at 242s&lt;/strong&gt;&lt;/td&gt;&lt;/tr&gt;
&lt;/tbody&gt;&lt;/table&gt;
&lt;p&gt;Every successful run: model 26.7.1, changelog 211, full user count intact.
Every failed run: model 26.0.0, changelog &lt;strong&gt;144 — untouched&lt;/strong&gt; — full user count
intact, container exited.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;The floor is &lt;code&gt;(128m, 160m]&lt;/code&gt; at both scales.&lt;/strong&gt; Time to ready is 16–17 s at
both scales too, which is the flat-migration result from
&lt;a href=&quot;/runs/2026-08-25-26.0.0-to-26.7.1-100k/&quot;&gt;&lt;code&gt;2026-08-25-26.0.0-to-26.7.1-100k&lt;/code&gt;&lt;/a&gt; holding again at 2M.&lt;/p&gt;
&lt;h3 id=&quot;where-it-actually-dies&quot;&gt;Where it actually dies&lt;/h3&gt;
&lt;p&gt;The last phase reached, from the container log:&lt;/p&gt;
&lt;table&gt;&lt;thead&gt;&lt;tr&gt;&lt;th&gt;&lt;code&gt;-Xmx&lt;/code&gt;&lt;/th&gt;&lt;th&gt;Last phase before death&lt;/th&gt;&lt;/tr&gt;&lt;/thead&gt;&lt;tbody&gt;
&lt;tr&gt;&lt;td&gt;128m, 64m&lt;/td&gt;&lt;td&gt;&lt;strong&gt;&lt;code&gt;Updating the server image&lt;/code&gt;&lt;/strong&gt;&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;160m and above&lt;/td&gt;&lt;td&gt;&lt;code&gt;Updating database&lt;/code&gt; (i.e. it got to the migration)&lt;/td&gt;&lt;/tr&gt;
&lt;/tbody&gt;&lt;/table&gt;
&lt;pre&gt;&lt;code&gt;Appending additional Java properties to JAVA_OPTS
Changes detected in configuration. Updating the server image.
Updating the configuration and installing your custom providers, if any. Please wait.
Terminating due to java.lang.OutOfMemoryError: Java heap space
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;code&gt;Updating the server image&lt;/code&gt; is Quarkus augmentation — the build step Keycloak
re-runs on start whenever it detects a configuration change. It happens &lt;strong&gt;before
a single row of the database is read&lt;/strong&gt;, which is why the number is identical at
1k and 2M, and why no failed run touched the schema.&lt;/p&gt;
&lt;h3 id=&quot;the-failure-is-slow&quot;&gt;The failure is slow&lt;/h3&gt;
&lt;p&gt;Both OOM cases took &lt;strong&gt;just over four minutes&lt;/strong&gt; to die (241 s, 242 s), not
seconds. The JVM thrashes the collector at the ceiling before giving up. In a
maintenance window that is four minutes of an operator watching a container that
looks like it is working.&lt;/p&gt;
&lt;h2 id=&quot;outcome&quot;&gt;Outcome&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;&lt;input disabled=&quot;&quot; type=&quot;checkbox&quot; checked=&quot;&quot;/&gt;
Failed closed at and below the floor — nothing applied, no data loss, no held lock&lt;/li&gt;
&lt;li&gt;&lt;input disabled=&quot;&quot; type=&quot;checkbox&quot; checked=&quot;&quot;/&gt;
Recoverable by raising &lt;code&gt;-Xmx&lt;/code&gt; alone; no restore required&lt;/li&gt;
&lt;li&gt;&lt;input disabled=&quot;&quot; type=&quot;checkbox&quot; checked=&quot;&quot;/&gt;
Container exits and stays exited (CrashLoopBackOff under Kubernetes)&lt;/li&gt;
&lt;/ul&gt;
&lt;h2 id=&quot;verification&quot;&gt;Verification&lt;/h2&gt;
&lt;table&gt;&lt;thead&gt;&lt;tr&gt;&lt;th&gt;Claim&lt;/th&gt;&lt;th&gt;Primary source&lt;/th&gt;&lt;th&gt;Checked&lt;/th&gt;&lt;/tr&gt;&lt;/thead&gt;&lt;tbody&gt;
&lt;tr&gt;&lt;td&gt;160m is sufficient at 1,002 users&lt;/td&gt;&lt;td&gt;run: ready in 17s, changelog 211&lt;/td&gt;&lt;td&gt;☑&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;160m is sufficient at 2,000,003 users&lt;/td&gt;&lt;td&gt;run: ready in 17s, changelog 211, 2M users intact&lt;/td&gt;&lt;td&gt;☑&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;128m fails at &lt;strong&gt;both&lt;/strong&gt; scales&lt;/td&gt;&lt;td&gt;two runs, both OOM, both &lt;code&gt;cl=144&lt;/code&gt; after&lt;/td&gt;&lt;td&gt;☑&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;The OOM precedes the migration&lt;/td&gt;&lt;td&gt;last log phase is &lt;code&gt;Updating the server image&lt;/code&gt;; changelog untouched at 144&lt;/td&gt;&lt;td&gt;☑&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Failure applies nothing&lt;/td&gt;&lt;td&gt;changelog 144 and full user count after every failed run&lt;/td&gt;&lt;td&gt;☑&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Holds with &lt;code&gt;--optimized&lt;/code&gt; / a pre-built image&lt;/td&gt;&lt;td&gt;&lt;strong&gt;not tested&lt;/strong&gt; — augmentation is skipped there, so the floor may be lower and may then depend on something else&lt;/td&gt;&lt;td&gt;☐&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Holds with custom providers in &lt;code&gt;/opt/keycloak/providers&lt;/code&gt;&lt;/td&gt;&lt;td&gt;&lt;strong&gt;not tested&lt;/strong&gt; — augmentation installs providers, so a large jar plausibly raises this floor. Direct follow-on from S7&lt;/td&gt;&lt;td&gt;☐&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Holds where the migration itself is expensive&lt;/td&gt;&lt;td&gt;not tested — this pair skips index creation above 300k rows&lt;/td&gt;&lt;td&gt;☐&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Steady-state serving floor (vs. startup floor)&lt;/td&gt;&lt;td&gt;&lt;strong&gt;not measured&lt;/strong&gt; — this is a startup/migration number only, not a number to run production on&lt;/td&gt;&lt;td&gt;☐&lt;/td&gt;&lt;/tr&gt;
&lt;/tbody&gt;&lt;/table&gt;
&lt;h2 id=&quot;publishable-extract&quot;&gt;Publishable extract&lt;/h2&gt;
&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;We tried to find the heap a Keycloak upgrade needs per million users. It does
not exist.&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;On Postgres 16.15, upgrading 26.0.0 → 26.7.1, we walked &lt;code&gt;-Xmx&lt;/code&gt; down against two
realms — one with 1,002 users, one with 2,000,003 — and the boundary landed in
exactly the same place for both: &lt;strong&gt;160 MB works, 128 MB dies with
&lt;code&gt;OutOfMemoryError&lt;/code&gt;.&lt;/strong&gt; Not &quot;roughly the same&quot;. The same bracket, and the same
16–17 seconds to ready on either side of a 2,000× difference in user count.&lt;/p&gt;
&lt;p&gt;The reason is in the log. At 128 MB the last thing Keycloak prints is
&lt;code&gt;Updating the server image&lt;/code&gt; — Quarkus augmentation, the build step that runs
before the database is touched at all. The schema was untouched afterwards:
144 changelog rows before, 144 after, every user present. It never got as far
as the data, so the data never mattered.&lt;/p&gt;
&lt;p&gt;Two practical consequences. First, if your Keycloak dies on startup with a heap
error during an upgrade, &lt;strong&gt;stop looking at your user count&lt;/strong&gt; — you are almost
certainly looking at the build step, and the fix is a flat number, not a
proportional one. Second, the failure is not fast: both of our OOM runs took
&lt;strong&gt;just over four minutes&lt;/strong&gt; to give up, thrashing the collector at the ceiling.
If your window budget assumes a heap error announces itself immediately, it
does not.&lt;/p&gt;
&lt;p&gt;One caveat we cannot yet close: we run without &lt;code&gt;--optimized&lt;/code&gt;, so augmentation
happens on every start. A deployment with a properly pre-built image skips that
phase, and its floor is a different number we have not measured.&lt;/p&gt;
&lt;/blockquote&gt;
</content>
        
    </entry>
    <entry xml:lang="en">
        <title>Rehearsal Record — S8: transaction pooling breaks the upgrade, and this is what half-migrated looks like</title>
        <published>2026-08-25T00:00:00+00:00</published>
        <updated>2026-08-25T00:00:00+00:00</updated>
        
        <author>
          <name>Unknown</name>
        </author>
        
        <link rel="alternate" type="text/html" href="https://ledger.mlabs.city/runs/2026-08-25-s8-pgbouncer-transaction-mode/"/>
        <id>https://ledger.mlabs.city/runs/2026-08-25-s8-pgbouncer-transaction-mode/</id>
        
        <content type="html" xml:base="https://ledger.mlabs.city/runs/2026-08-25-s8-pgbouncer-transaction-mode/">&lt;h2 id=&quot;summary&quot;&gt;Summary&lt;/h2&gt;
&lt;p&gt;&lt;strong&gt;PgBouncer in &lt;code&gt;transaction&lt;/code&gt; pooling mode makes the first start after an upgrade
fail, deterministically, and it leaves the database in the inconsistent state
this lab has spent all day failing to produce by other means.&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;Three reproductions, three identical failures, in 16–19 seconds each:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;ERROR: Failed to start server in (production) mode
ERROR: Cannot invoke &amp;quot;org.keycloak.connections.jpa.updater.liquibase.lock.CustomLockService
       .waitForLock(org.keycloak.models.dblock.DBLockProvider$Namespace)&amp;quot;
       because &amp;quot;this.lockService&amp;quot; is null
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The state it leaves behind is the important part. &lt;code&gt;DATABASECHANGELOG&lt;/code&gt; is at
&lt;strong&gt;211 rows — the migration completed and committed&lt;/strong&gt; — while &lt;code&gt;migration_model&lt;/code&gt;
still reads &lt;strong&gt;26.0.0&lt;/strong&gt;. The schema is on the new version; the model stamp and the
model-level migrations are not. &lt;strong&gt;A verification that counts changelog rows would
call this a successful upgrade.&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;The same pooler in &lt;code&gt;session&lt;/code&gt; mode upgrades cleanly in 18 seconds. The variable is
the pooling mode, not the pooler.&lt;/p&gt;
&lt;h2 id=&quot;environment&quot;&gt;Environment&lt;/h2&gt;
&lt;table&gt;&lt;thead&gt;&lt;tr&gt;&lt;th&gt;Field&lt;/th&gt;&lt;th&gt;Value&lt;/th&gt;&lt;/tr&gt;&lt;/thead&gt;&lt;tbody&gt;
&lt;tr&gt;&lt;td&gt;Keycloak from → to&lt;/td&gt;&lt;td&gt;26.0.0 → 26.7.1&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Distribution / start mode&lt;/td&gt;&lt;td&gt;quay.io/keycloak/keycloak official image, &lt;code&gt;start&lt;/code&gt; w/ external DB&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Database&lt;/td&gt;&lt;td&gt;PostgreSQL 16.15, single node, container&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Postgres tuning&lt;/td&gt;&lt;td&gt;stock — &lt;code&gt;shared_buffers&lt;/code&gt; 128MB, &lt;code&gt;maintenance_work_mem&lt;/code&gt; 64MB, &lt;code&gt;statement_timeout&lt;/code&gt; 0&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;&lt;strong&gt;Pooler&lt;/strong&gt;&lt;/td&gt;&lt;td&gt;&lt;strong&gt;PgBouncer 1.23.1 (&lt;code&gt;edoburu/pgbouncer:v1.23.1-p2&lt;/code&gt;), &lt;code&gt;default_pool_size=5&lt;/code&gt;, &lt;code&gt;max_client_conn=100&lt;/code&gt;, &lt;code&gt;auth_type=scram-sha-256&lt;/code&gt;&lt;/strong&gt;&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Adverse scenario&lt;/td&gt;&lt;td&gt;S8 — &lt;strong&gt;&lt;code&gt;pool_mode&lt;/code&gt; is the variable&lt;/strong&gt;&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Dataset scale&lt;/td&gt;&lt;td&gt;1,002 users, 2 realms&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Seeding method&lt;/td&gt;&lt;td&gt;&lt;code&gt;partialImport&lt;/code&gt; (&lt;code&gt;bin/seed-realm.sh --profile typical&lt;/code&gt;)&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Fixture profile&lt;/td&gt;&lt;td&gt;&lt;code&gt;typical&lt;/code&gt;; &lt;strong&gt;provider jars removed&lt;/strong&gt; so S7&#39;s fixtures could not confound this&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Topology&lt;/td&gt;&lt;td&gt;single container + pgbouncer sidecar&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Host&lt;/td&gt;&lt;td&gt;Hetzner CCX33 (fsn1) — 8 vCPU dedicated, 30 GB RAM, local NVMe&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;JVM heap&lt;/td&gt;&lt;td&gt;&lt;code&gt;-Xms1g -Xmx4g&lt;/code&gt;&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Lab change&lt;/td&gt;&lt;td&gt;&lt;code&gt;docker-compose.pgbouncer.yml&lt;/code&gt; added; &lt;code&gt;KC_DB_URL&lt;/code&gt; made env-driven; &lt;code&gt;restore.sh&lt;/code&gt; hardened&lt;/td&gt;&lt;/tr&gt;
&lt;/tbody&gt;&lt;/table&gt;
&lt;h2 id=&quot;procedure&quot;&gt;Procedure&lt;/h2&gt;
&lt;pre&gt;&lt;code&gt;./bin/restore.sh baseline-26.0.0-typical-1k

# .env
COMPOSE_FILE=docker-compose.yml:docker-compose.dbidentity.yml:docker-compose.pgbouncer.yml
KC_DB_URL=jdbc:postgresql://pgbouncer:6432/keycloak
POOL_MODE=transaction            # or session, for the control

docker compose up -d pgbouncer
sed -i &amp;#39;s/^KC_VERSION=.*/KC_VERSION=26.7.1/&amp;#39; .env
docker compose up -d --force-recreate keycloak
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Connectivity through the pooler was confirmed independently before each run
(&lt;code&gt;psql postgresql://keycloak:keycloak@127.0.0.1:6432/keycloak -tAc &#39;select 1&#39;&lt;/code&gt; → &lt;code&gt;1&lt;/code&gt;),
so a failure could not be mistaken for the pooler simply not working.&lt;/p&gt;
&lt;h2 id=&quot;results&quot;&gt;Results&lt;/h2&gt;
&lt;table&gt;&lt;thead&gt;&lt;tr&gt;&lt;th&gt;Run&lt;/th&gt;&lt;th&gt;&lt;code&gt;pool_mode&lt;/code&gt;&lt;/th&gt;&lt;th&gt;Outcome&lt;/th&gt;&lt;th&gt;Time&lt;/th&gt;&lt;th&gt;&lt;code&gt;migration_model&lt;/code&gt;&lt;/th&gt;&lt;th&gt;&lt;code&gt;DATABASECHANGELOG&lt;/code&gt;&lt;/th&gt;&lt;th&gt;Users&lt;/th&gt;&lt;/tr&gt;&lt;/thead&gt;&lt;tbody&gt;
&lt;tr&gt;&lt;td&gt;Control&lt;/td&gt;&lt;td&gt;&lt;strong&gt;session&lt;/strong&gt;&lt;/td&gt;&lt;td&gt;&lt;strong&gt;ready&lt;/strong&gt;&lt;/td&gt;&lt;td&gt;18s&lt;/td&gt;&lt;td&gt;26.7.1&lt;/td&gt;&lt;td&gt;211&lt;/td&gt;&lt;td&gt;1,002&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;A&lt;/td&gt;&lt;td&gt;transaction&lt;/td&gt;&lt;td&gt;&lt;strong&gt;exit 1&lt;/strong&gt;&lt;/td&gt;&lt;td&gt;—&lt;/td&gt;&lt;td&gt;&lt;strong&gt;26.0.0&lt;/strong&gt;&lt;/td&gt;&lt;td&gt;&lt;strong&gt;211&lt;/strong&gt;&lt;/td&gt;&lt;td&gt;1,002&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;B (repro)&lt;/td&gt;&lt;td&gt;transaction&lt;/td&gt;&lt;td&gt;&lt;strong&gt;exit 1&lt;/strong&gt;&lt;/td&gt;&lt;td&gt;&lt;strong&gt;16s&lt;/strong&gt;&lt;/td&gt;&lt;td&gt;&lt;strong&gt;26.0.0&lt;/strong&gt;&lt;/td&gt;&lt;td&gt;&lt;strong&gt;211&lt;/strong&gt;&lt;/td&gt;&lt;td&gt;1,002&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;C (repro)&lt;/td&gt;&lt;td&gt;transaction&lt;/td&gt;&lt;td&gt;&lt;strong&gt;exit 1&lt;/strong&gt;&lt;/td&gt;&lt;td&gt;&lt;strong&gt;19s&lt;/strong&gt;&lt;/td&gt;&lt;td&gt;&lt;strong&gt;26.0.0&lt;/strong&gt;&lt;/td&gt;&lt;td&gt;&lt;strong&gt;211&lt;/strong&gt;&lt;/td&gt;&lt;td&gt;1,002&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Restart after A&lt;/td&gt;&lt;td&gt;transaction&lt;/td&gt;&lt;td&gt;&lt;strong&gt;ready&lt;/strong&gt;&lt;/td&gt;&lt;td&gt;14s&lt;/td&gt;&lt;td&gt;26.7.1&lt;/td&gt;&lt;td&gt;211&lt;/td&gt;&lt;td&gt;1,002&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Restart again&lt;/td&gt;&lt;td&gt;transaction&lt;/td&gt;&lt;td&gt;ready&lt;/td&gt;&lt;td&gt;33s&lt;/td&gt;&lt;td&gt;26.7.1&lt;/td&gt;&lt;td&gt;211&lt;/td&gt;&lt;td&gt;1,002&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Recovery&lt;/td&gt;&lt;td&gt;&lt;strong&gt;direct, no pooler&lt;/strong&gt;&lt;/td&gt;&lt;td&gt;ready&lt;/td&gt;&lt;td&gt;32s&lt;/td&gt;&lt;td&gt;26.7.1&lt;/td&gt;&lt;td&gt;211&lt;/td&gt;&lt;td&gt;1,002&lt;/td&gt;&lt;/tr&gt;
&lt;/tbody&gt;&lt;/table&gt;
&lt;p&gt;Runs B and C started from a freshly restored 26.0.0 baseline (&lt;code&gt;cl=144&lt;/code&gt;) each
time, and both reached &lt;code&gt;cl=211&lt;/code&gt; before dying. The failure is not a race — it is
what happens every time work has to be done through a transaction-pooled
connection.&lt;/p&gt;
&lt;h3 id=&quot;the-order-of-events&quot;&gt;The order of events&lt;/h3&gt;
&lt;pre&gt;&lt;code&gt;21:05:00  container starts
21:05:10  INFO  QuarkusJpaUpdaterProvider — Updating database
          ... all 67 pending changesets apply and COMMIT (144 → 211) ...
21:05:16  ERROR Cannot invoke CustomLockService.waitForLock(...) because &amp;quot;this.lockService&amp;quot; is null
21:05:16  container exits 1
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Liquibase&#39;s schema update succeeds. What fails is the step &lt;em&gt;after&lt;/em&gt; it, which
needs Keycloak&#39;s &lt;code&gt;DBLockProvider&lt;/code&gt; — a &lt;strong&gt;session-scoped&lt;/strong&gt; lock. Transaction
pooling hands out a different backend per transaction, so the session-scoped
lock service never gets established, and Keycloak dereferences a null.&lt;/p&gt;
&lt;h3 id=&quot;it-recovers-on-the-second-start-through-the-same-pooler&quot;&gt;It recovers on the second start — through the same pooler&lt;/h3&gt;
&lt;p&gt;This is the part that makes it dangerous rather than merely broken. The restart
succeeded in 14 seconds and stamped &lt;code&gt;migration_model&lt;/code&gt; to 26.7.1, &lt;strong&gt;still through
transaction-mode PgBouncer.&lt;/strong&gt; Under Kubernetes this presents as one
CrashLoopBackOff followed by a healthy pod, which most operators will never look
at twice.&lt;/p&gt;
&lt;p&gt;For those 16–19 seconds and for however long the restart takes, the database has
a 26.7.1 schema and a 26.0.0 model stamp.&lt;/p&gt;
&lt;h3 id=&quot;no-stuck-lock&quot;&gt;No stuck lock&lt;/h3&gt;
&lt;p&gt;&lt;code&gt;databasechangeloglock&lt;/code&gt; showed &lt;strong&gt;0 held locks&lt;/strong&gt; after every failure. Consistent
with &lt;a href=&quot;/runs/2026-08-25-s1-s2-lock-failure-modes/&quot;&gt;&lt;code&gt;2026-08-25-s1-s2-lock-failure-modes&lt;/code&gt;&lt;/a&gt;: this lab has still never observed
a stuck Liquibase lock. The failure here is the &lt;em&gt;absence&lt;/em&gt; of a lock service, not
a lock that was taken and never released.&lt;/p&gt;
&lt;h2 id=&quot;verification&quot;&gt;Verification&lt;/h2&gt;
&lt;table&gt;&lt;thead&gt;&lt;tr&gt;&lt;th&gt;Claim&lt;/th&gt;&lt;th&gt;Primary source&lt;/th&gt;&lt;th&gt;Checked&lt;/th&gt;&lt;/tr&gt;&lt;/thead&gt;&lt;tbody&gt;
&lt;tr&gt;&lt;td&gt;Session mode upgrades cleanly through the pooler&lt;/td&gt;&lt;td&gt;control run, ready 18s, model 26.7.1&lt;/td&gt;&lt;td&gt;☑&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Transaction mode fails the first start&lt;/td&gt;&lt;td&gt;three runs, three exits with the same NPE&lt;/td&gt;&lt;td&gt;☑&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Failure is fast, 16–19s&lt;/td&gt;&lt;td&gt;&lt;code&gt;docker inspect&lt;/code&gt; StartedAt → FinishedAt on both reproductions&lt;/td&gt;&lt;td&gt;☑&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;The schema migration commits before the failure&lt;/td&gt;&lt;td&gt;&lt;code&gt;cl=144&lt;/code&gt; before start, &lt;code&gt;cl=211&lt;/code&gt; after the failed run&lt;/td&gt;&lt;td&gt;☑&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;The model stamp does not advance&lt;/td&gt;&lt;td&gt;&lt;code&gt;migration_model&lt;/code&gt; reads 26.0.0 after each failure&lt;/td&gt;&lt;td&gt;☑&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;The pooler itself works&lt;/td&gt;&lt;td&gt;&lt;code&gt;select 1&lt;/code&gt; through port 6432 before every run&lt;/td&gt;&lt;td&gt;☑&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Restart recovers, still pooled&lt;/td&gt;&lt;td&gt;ready in 14s, model 26.7.1&lt;/td&gt;&lt;td&gt;☑&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;No held lock at any point&lt;/td&gt;&lt;td&gt;&lt;code&gt;databasechangeloglock&lt;/code&gt; read directly after each failure&lt;/td&gt;&lt;td&gt;☑&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Data survives&lt;/td&gt;&lt;td&gt;1,002 users after every run&lt;/td&gt;&lt;td&gt;☑&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Whether &lt;em&gt;serving&lt;/em&gt; (not upgrading) is safe in transaction mode&lt;/td&gt;&lt;td&gt;&lt;strong&gt;not tested&lt;/strong&gt; — this record covers startup and migration only&lt;/td&gt;&lt;td&gt;☐&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Whether prepared-statement multiplexing bites separately&lt;/td&gt;&lt;td&gt;&lt;strong&gt;not observed&lt;/strong&gt; — the lock failure happens first and masks anything later&lt;/td&gt;&lt;td&gt;☐&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Other poolers (Odyssey, RDS Proxy, Cloud SQL connectors)&lt;/td&gt;&lt;td&gt;not tested&lt;/td&gt;&lt;td&gt;☐&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;&lt;code&gt;pool_mode=statement&lt;/code&gt;&lt;/td&gt;&lt;td&gt;not tested — expected worse, not verified&lt;/td&gt;&lt;td&gt;☐&lt;/td&gt;&lt;/tr&gt;
&lt;/tbody&gt;&lt;/table&gt;
&lt;h2 id=&quot;publishable-extract&quot;&gt;Publishable extract&lt;/h2&gt;
&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;We finally reproduced a half-migrated Keycloak database. It took a connection
pooler, not a crash.&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;Earlier the same day we tried two textbook routes to the &quot;upgrade died partway
and left the schema half-applied&quot; failure. We killed Keycloak with SIGKILL
mid-migration; it completed itself on the next start. We aborted its SQL with a
&lt;code&gt;statement_timeout&lt;/code&gt;; it refused to apply anything at all. Neither produced the
state everyone warns about.&lt;/p&gt;
&lt;p&gt;Then we put &lt;strong&gt;PgBouncer in &lt;code&gt;transaction&lt;/code&gt; pooling mode&lt;/strong&gt; in front of Postgres —
the default mode nearly everywhere, because it is the mode that actually saves
connections — and upgraded 26.0.0 → 26.7.1 on a 1,002-user realm. Three times
out of three, Keycloak died 16 to 19 seconds in:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;ERROR: Cannot invoke &amp;quot;CustomLockService.waitForLock(DBLockProvider$Namespace)&amp;quot;
       because &amp;quot;this.lockService&amp;quot; is null
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Keycloak&#39;s &lt;code&gt;DBLockProvider&lt;/code&gt; needs a &lt;strong&gt;session-scoped&lt;/strong&gt; lock. Transaction pooling
gives you a different backend for every transaction, so the lock service is
never established and the server dereferences a null.&lt;/p&gt;
&lt;p&gt;Here is the state it left, and why it matters more than the crash:&lt;/p&gt;
&lt;table&gt;&lt;thead&gt;&lt;tr&gt;&lt;th&gt;&lt;/th&gt;&lt;th&gt;&lt;/th&gt;&lt;/tr&gt;&lt;/thead&gt;&lt;tbody&gt;
&lt;tr&gt;&lt;td&gt;&lt;code&gt;DATABASECHANGELOG&lt;/code&gt;&lt;/td&gt;&lt;td&gt;&lt;strong&gt;211 rows — the full migration, committed&lt;/strong&gt;&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;&lt;code&gt;migration_model&lt;/code&gt;&lt;/td&gt;&lt;td&gt;&lt;strong&gt;26.0.0 — unchanged&lt;/strong&gt;&lt;/td&gt;&lt;/tr&gt;
&lt;/tbody&gt;&lt;/table&gt;
&lt;p&gt;The schema moved. The version stamp did not. &lt;strong&gt;If your upgrade verification
counts changelog rows or diffs the schema, it will tell you this upgrade
succeeded.&lt;/strong&gt; The only column that knows the truth is &lt;code&gt;migration_model&lt;/code&gt;.&lt;/p&gt;
&lt;p&gt;Two more things worth your time. The same pooler in &lt;code&gt;session&lt;/code&gt; mode upgraded
cleanly in 18 seconds — the pooler is not the problem, the pooling &lt;em&gt;mode&lt;/em&gt; is.
And the second start succeeded, in 14 seconds, still through transaction-mode
PgBouncer. Under Kubernetes that is one CrashLoopBackOff followed by a healthy
pod, which nobody investigates.&lt;/p&gt;
&lt;p&gt;Before your window: &lt;strong&gt;ask what &lt;code&gt;pool_mode&lt;/code&gt; your pooler runs, and point Keycloak
at the database directly for the upgrade.&lt;/strong&gt; Afterwards, check
&lt;code&gt;select version from migration_model order by update_time desc limit 1&lt;/code&gt; — not
the changelog.&lt;/p&gt;
&lt;/blockquote&gt;
</content>
        
    </entry>
    <entry xml:lang="en">
        <title>Rehearsal Record — S9: LDAP federation survives the upgrade, and the fixture that claimed to test it did not exist</title>
        <published>2026-08-25T00:00:00+00:00</published>
        <updated>2026-08-25T00:00:00+00:00</updated>
        
        <author>
          <name>Unknown</name>
        </author>
        
        <link rel="alternate" type="text/html" href="https://ledger.mlabs.city/runs/2026-08-25-s9-ldap-federation/"/>
        <id>https://ledger.mlabs.city/runs/2026-08-25-s9-ldap-federation/</id>
        
        <content type="html" xml:base="https://ledger.mlabs.city/runs/2026-08-25-s9-ldap-federation/">&lt;h2 id=&quot;summary&quot;&gt;Summary&lt;/h2&gt;
&lt;p&gt;&lt;strong&gt;Two results, and one of them is about this lab rather than about Keycloak.&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;The Keycloak result: &lt;strong&gt;LDAP user federation crosses 26.0.0 → 26.7.1 intact.&lt;/strong&gt;
Component config preserved verbatim, 3 federated users still linked, a fresh
full sync still works afterwards, zero LDAP-related errors in the log, and a
federated user gets a token from a password validated against LDAP.&lt;/p&gt;
&lt;p&gt;The lab result: &lt;strong&gt;before this run, &lt;code&gt;seed-realm.sh --ldap&lt;/code&gt; pointed at
&lt;code&gt;ldap://ldap:389&lt;/code&gt;, and no compose file in this repository defined a host called
&lt;code&gt;ldap&lt;/code&gt;.&lt;/strong&gt; The &lt;code&gt;kitchen-sink&lt;/code&gt; profile therefore created a federation component
aimed at nothing, and every record produced with it carried an unticked
&lt;code&gt;Federation/brokering intact&lt;/code&gt; box that could never have been ticked. That is
now fixed, and the fix took four separate corrections — none of which were the
port number this scenario predicted.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Bonus finding, which resolves an open question from S7:&lt;/strong&gt; the token endpoint&#39;s
status for &lt;code&gt;invalid_grant&lt;/code&gt; changed from &lt;strong&gt;401 on 26.0.0 to 400 on 26.7.1&lt;/strong&gt;.&lt;/p&gt;
&lt;h2 id=&quot;environment&quot;&gt;Environment&lt;/h2&gt;
&lt;table&gt;&lt;thead&gt;&lt;tr&gt;&lt;th&gt;Field&lt;/th&gt;&lt;th&gt;Value&lt;/th&gt;&lt;/tr&gt;&lt;/thead&gt;&lt;tbody&gt;
&lt;tr&gt;&lt;td&gt;Keycloak from → to&lt;/td&gt;&lt;td&gt;26.0.0 → 26.7.1&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Distribution / start mode&lt;/td&gt;&lt;td&gt;quay.io/keycloak/keycloak official image, &lt;code&gt;start&lt;/code&gt; w/ external DB&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Database&lt;/td&gt;&lt;td&gt;PostgreSQL 16.15, single node, container&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Postgres tuning&lt;/td&gt;&lt;td&gt;stock — &lt;code&gt;shared_buffers&lt;/code&gt; 128MB, &lt;code&gt;maintenance_work_mem&lt;/code&gt; 64MB, &lt;code&gt;statement_timeout&lt;/code&gt; 0&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;&lt;strong&gt;Directory&lt;/strong&gt;&lt;/td&gt;&lt;td&gt;&lt;strong&gt;OpenLDAP 2.6 (&lt;code&gt;bitnamilegacy/openldap:2.6&lt;/code&gt;), &lt;code&gt;dc=lab,dc=invalid&lt;/code&gt;, 3 users under &lt;code&gt;ou=people&lt;/code&gt;&lt;/strong&gt;&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Adverse scenario&lt;/td&gt;&lt;td&gt;S9&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Dataset scale&lt;/td&gt;&lt;td&gt;1,002 local users + 3 LDAP-federated, 2 realms&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Seeding method&lt;/td&gt;&lt;td&gt;&lt;code&gt;partialImport&lt;/code&gt;, plus a full LDAP sync&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Federation config&lt;/td&gt;&lt;td&gt;&lt;code&gt;READ_ONLY&lt;/code&gt;, &lt;code&gt;importEnabled=true&lt;/code&gt;, &lt;code&gt;usernameLDAPAttribute=uid&lt;/code&gt;, &lt;code&gt;uuidLDAPAttribute=entryUUID&lt;/code&gt;, &lt;code&gt;searchScope=2&lt;/code&gt; — byte-for-byte what &lt;code&gt;seed-realm.sh --ldap&lt;/code&gt; writes&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Topology&lt;/td&gt;&lt;td&gt;single container + ldap sidecar&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Host&lt;/td&gt;&lt;td&gt;Hetzner CCX33 (fsn1) — 8 vCPU dedicated, 30 GB RAM, local NVMe&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;JVM heap&lt;/td&gt;&lt;td&gt;&lt;code&gt;-Xms1g -Xmx4g&lt;/code&gt;&lt;/td&gt;&lt;/tr&gt;
&lt;/tbody&gt;&lt;/table&gt;
&lt;h2 id=&quot;results&quot;&gt;Results&lt;/h2&gt;
&lt;table&gt;&lt;thead&gt;&lt;tr&gt;&lt;th&gt;Check&lt;/th&gt;&lt;th&gt;26.0.0 (pre)&lt;/th&gt;&lt;th&gt;26.7.1 (post)&lt;/th&gt;&lt;/tr&gt;&lt;/thead&gt;&lt;tbody&gt;
&lt;tr&gt;&lt;td&gt;LDAP components in realm&lt;/td&gt;&lt;td&gt;1&lt;/td&gt;&lt;td&gt;&lt;strong&gt;1&lt;/strong&gt;&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;&lt;code&gt;connectionUrl&lt;/code&gt; stored&lt;/td&gt;&lt;td&gt;&lt;code&gt;ldap://ldap:1389&lt;/code&gt;&lt;/td&gt;&lt;td&gt;&lt;strong&gt;&lt;code&gt;ldap://ldap:1389&lt;/code&gt;&lt;/strong&gt;&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;&lt;code&gt;editMode&lt;/code&gt; stored&lt;/td&gt;&lt;td&gt;&lt;code&gt;READ_ONLY&lt;/code&gt;&lt;/td&gt;&lt;td&gt;&lt;strong&gt;&lt;code&gt;READ_ONLY&lt;/code&gt;&lt;/strong&gt;&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Users with &lt;code&gt;federation_link&lt;/code&gt;&lt;/td&gt;&lt;td&gt;3&lt;/td&gt;&lt;td&gt;&lt;strong&gt;3&lt;/strong&gt;&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;&lt;code&gt;alice&lt;/code&gt; present&lt;/td&gt;&lt;td&gt;yes&lt;/td&gt;&lt;td&gt;&lt;strong&gt;yes&lt;/strong&gt;&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Full sync&lt;/td&gt;&lt;td&gt;&lt;code&gt;3 imported, 0 updated&lt;/code&gt;&lt;/td&gt;&lt;td&gt;&lt;strong&gt;&lt;code&gt;0 imported, 3 updated&lt;/code&gt;&lt;/strong&gt;&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;LDAP errors in log&lt;/td&gt;&lt;td&gt;0&lt;/td&gt;&lt;td&gt;&lt;strong&gt;0&lt;/strong&gt;&lt;/td&gt;&lt;/tr&gt;
&lt;/tbody&gt;&lt;/table&gt;
&lt;p&gt;Upgrade elapsed: &lt;strong&gt;17s&lt;/strong&gt;. Nothing about federation slowed or blocked it.&lt;/p&gt;
&lt;h3 id=&quot;proving-the-ldap-bind-is-actually-evaluated&quot;&gt;Proving the LDAP bind is actually evaluated&lt;/h3&gt;
&lt;p&gt;A federation component that is merely &lt;em&gt;present&lt;/em&gt; proves nothing — that was the
whole problem with the old fixture. So each side was probed with a wrong
password and a correct one:&lt;/p&gt;
&lt;table&gt;&lt;thead&gt;&lt;tr&gt;&lt;th&gt;Probe&lt;/th&gt;&lt;th&gt;26.0.0&lt;/th&gt;&lt;th&gt;26.7.1&lt;/th&gt;&lt;/tr&gt;&lt;/thead&gt;&lt;tbody&gt;
&lt;tr&gt;&lt;td&gt;&lt;code&gt;alice&lt;/code&gt; / &lt;strong&gt;wrong&lt;/strong&gt; password&lt;/td&gt;&lt;td&gt;&lt;strong&gt;401&lt;/strong&gt; &lt;code&gt;Invalid user credentials&lt;/code&gt;&lt;/td&gt;&lt;td&gt;&lt;strong&gt;400&lt;/strong&gt; &lt;code&gt;Invalid user credentials&lt;/code&gt;&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;&lt;code&gt;alice&lt;/code&gt; / &lt;strong&gt;correct&lt;/strong&gt; password&lt;/td&gt;&lt;td&gt;400 &lt;code&gt;Account is not fully set up&lt;/code&gt;&lt;/td&gt;&lt;td&gt;&lt;strong&gt;200 TOKEN ISSUED&lt;/strong&gt;&lt;/td&gt;&lt;/tr&gt;
&lt;/tbody&gt;&lt;/table&gt;
&lt;p&gt;The two answers differ, which is the point: Keycloak is genuinely binding to
LDAP and evaluating the result. &lt;code&gt;Account is not fully set up&lt;/code&gt; is raised &lt;em&gt;after&lt;/em&gt;
successful credential validation — the realm&#39;s &lt;code&gt;typical&lt;/code&gt; profile makes
&lt;code&gt;CONFIGURE_TOTP&lt;/code&gt; a default required action, so every freshly imported user
carries one.&lt;/p&gt;
&lt;p&gt;The post-upgrade &lt;code&gt;TOKEN ISSUED&lt;/code&gt; is the first time this lab has verified a
federated login end to end. It also exposes a detail worth keeping: clearing
&lt;code&gt;user_required_action&lt;/code&gt; &lt;strong&gt;by direct SQL did not take effect until the server
restarted&lt;/strong&gt;, because Keycloak caches the user. The pre-upgrade probe still said
&quot;not fully set up&quot; after the delete; the post-upgrade one issued a token. Do not
edit user state under a running Keycloak and expect it to be seen.&lt;/p&gt;
&lt;h3 id=&quot;the-invalid-grant-status-changed-401-400&quot;&gt;The &lt;code&gt;invalid_grant&lt;/code&gt; status changed: 401 → 400&lt;/h3&gt;
&lt;p&gt;Same request, same realm, same wrong password. &lt;strong&gt;26.0.0 answers 401. 26.7.1
answers 400.&lt;/strong&gt; The body is &lt;code&gt;Invalid user credentials&lt;/code&gt; in both.&lt;/p&gt;
&lt;p&gt;&lt;a href=&quot;/findings/2026-08-25-s7-custom-spi/&quot;&gt;&lt;code&gt;2026-08-25-s7-custom-spi&lt;/code&gt;&lt;/a&gt; recorded this shift and could only rule out one
cause (a broken event listener). This run isolates it properly: it appears on a
clean realm with no custom providers loaded, and it tracks the version. RFC 6749
specifies &lt;code&gt;400&lt;/code&gt; for &lt;code&gt;invalid_grant&lt;/code&gt;, so the newer behaviour is the correct one —
but &lt;strong&gt;any client keying on 401 to detect a bad password breaks on upgrade&lt;/strong&gt;, and
that code is common. The exact release between 26.0.0 and 26.7.1 that changed it
was not identified.&lt;/p&gt;
&lt;h2 id=&quot;outcome&quot;&gt;Outcome&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;&lt;input disabled=&quot;&quot; type=&quot;checkbox&quot; checked=&quot;&quot;/&gt;
Reached ready&lt;/li&gt;
&lt;li&gt;&lt;input disabled=&quot;&quot; type=&quot;checkbox&quot; checked=&quot;&quot;/&gt;
Realm config intact — component and all config values preserved&lt;/li&gt;
&lt;li&gt;&lt;input disabled=&quot;&quot; type=&quot;checkbox&quot; checked=&quot;&quot;/&gt;
Login flow works — &lt;strong&gt;federated&lt;/strong&gt; login, token issued post-upgrade&lt;/li&gt;
&lt;li&gt;&lt;input disabled=&quot;&quot; type=&quot;checkbox&quot; checked=&quot;&quot;/&gt;
&lt;strong&gt;Federation/brokering intact&lt;/strong&gt; — first time this box has been truthfully ticked&lt;/li&gt;
&lt;/ul&gt;
&lt;h2 id=&quot;verification&quot;&gt;Verification&lt;/h2&gt;
&lt;table&gt;&lt;thead&gt;&lt;tr&gt;&lt;th&gt;Claim&lt;/th&gt;&lt;th&gt;Primary source&lt;/th&gt;&lt;th&gt;Checked&lt;/th&gt;&lt;/tr&gt;&lt;/thead&gt;&lt;tbody&gt;
&lt;tr&gt;&lt;td&gt;Component config survives the upgrade&lt;/td&gt;&lt;td&gt;&lt;code&gt;component&lt;/code&gt; / &lt;code&gt;component_config&lt;/code&gt; read from Postgres, before and after&lt;/td&gt;&lt;td&gt;☑&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Federated users survive&lt;/td&gt;&lt;td&gt;&lt;code&gt;federation_link is not null&lt;/code&gt; count = 3 on both sides&lt;/td&gt;&lt;td&gt;☑&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Sync still works after the upgrade&lt;/td&gt;&lt;td&gt;admin API returns &lt;code&gt;0 imported users, 3 updated users&lt;/code&gt;&lt;/td&gt;&lt;td&gt;☑&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;The LDAP bind is genuinely evaluated&lt;/td&gt;&lt;td&gt;wrong vs. correct password give different answers on both versions&lt;/td&gt;&lt;td&gt;☑&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;A federated user can obtain a token on 26.7.1&lt;/td&gt;&lt;td&gt;&lt;code&gt;http=200&lt;/code&gt;, &lt;code&gt;access_token&lt;/code&gt; present&lt;/td&gt;&lt;td&gt;☑&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;&lt;code&gt;invalid_grant&lt;/code&gt; status changed 401 → 400&lt;/td&gt;&lt;td&gt;two independent runs (here and S7), clean realm, tracks the version&lt;/td&gt;&lt;td&gt;☑&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Which release changed it&lt;/td&gt;&lt;td&gt;&lt;strong&gt;26.6.0&lt;/strong&gt; — see &lt;a href=&quot;/findings/2026-08-26-invalid-grant-401-to-400-narrowed/&quot;&gt;&lt;code&gt;2026-08-26-invalid-grant-401-to-400-narrowed&lt;/code&gt;&lt;/a&gt;&lt;/td&gt;&lt;td&gt;☑&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;A federated user can obtain a token on 26.0.0&lt;/td&gt;&lt;td&gt;&lt;strong&gt;not shown&lt;/strong&gt; — blocked by a cached required action, not by federation&lt;/td&gt;&lt;td&gt;☐&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;&lt;code&gt;editMode=WRITABLE&lt;/code&gt; / &lt;code&gt;UNSYNCED&lt;/code&gt;&lt;/td&gt;&lt;td&gt;&lt;strong&gt;not tested&lt;/strong&gt; — only &lt;code&gt;READ_ONLY&lt;/code&gt;&lt;/td&gt;&lt;td&gt;☐&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;LDAPS / StartTLS&lt;/td&gt;&lt;td&gt;&lt;strong&gt;not tested&lt;/strong&gt; — plaintext 1389 only, and TLS is where real deployments break&lt;/td&gt;&lt;td&gt;☐&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Active Directory specifically&lt;/td&gt;&lt;td&gt;not tested — &lt;code&gt;vendor: other&lt;/code&gt;, and AD differs in &lt;code&gt;objectGUID&lt;/code&gt;, paging and referrals&lt;/td&gt;&lt;td&gt;☐&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Federation at scale&lt;/td&gt;&lt;td&gt;not tested — 3 users; sync cost against 100k LDAP entries is unknown&lt;/td&gt;&lt;td&gt;☐&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Kerberos / SPNEGO&lt;/td&gt;&lt;td&gt;not tested&lt;/td&gt;&lt;td&gt;☐&lt;/td&gt;&lt;/tr&gt;
&lt;/tbody&gt;&lt;/table&gt;
&lt;h2 id=&quot;publishable-extract&quot;&gt;Publishable extract&lt;/h2&gt;
&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;We upgraded a Keycloak with LDAP user federation from 26.0.0 to 26.7.1 and
checked, specifically, that the federation still worked afterwards — rather
than that it still appeared to be configured.&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;It did. Seventeen seconds, component configuration preserved value for value,
all three federated users still linked, a fresh full sync returning
&lt;code&gt;0 imported users, 3 updated users&lt;/code&gt;, zero LDAP errors in the log, and a
federated user obtaining a token with a password validated against the
directory.&lt;/p&gt;
&lt;p&gt;We checked it the annoying way, because the easy way proves nothing. A
federation component that merely &lt;em&gt;exists&lt;/em&gt; after an upgrade tells you the row
survived, not that anyone can log in. So we probed with a wrong password and a
right one and confirmed Keycloak answered differently — which is the only way to
know the bind is being evaluated at all.&lt;/p&gt;
&lt;p&gt;That distinction is not academic, and here is our own embarrassment as
evidence. Until this run, our lab&#39;s LDAP fixture pointed at a hostname that no
compose file in the repository defined. Every previous run created a federation
component aimed at nothing and reported it as configured. When we finally built
the thing it was supposed to point at, we found &lt;strong&gt;four&lt;/strong&gt; independent breakages,
not one — a missing service, a container image that had been moved to a
different namespace and no longer resolved at all, a deprecated environment
variable that killed the server with exit code 68 and no error message, and
users with no &lt;code&gt;mail&lt;/code&gt; attribute so every import produced a null email.&lt;/p&gt;
&lt;p&gt;None of those would have failed loudly. An inert fixture never does. If your
upgrade test plan contains a step that has never actually run, it is not a test
— and it is worth more than the step it replaced, because it reads like
coverage.&lt;/p&gt;
&lt;p&gt;One incidental finding for anyone writing client code: &lt;strong&gt;a bad password
returned HTTP 401 on 26.0.0 and HTTP 400 on 26.7.1&lt;/strong&gt;, with the same
&lt;code&gt;Invalid user credentials&lt;/code&gt; body. 400 is what RFC 6749 requires, so the change
is a fix — but if your client checks for 401 to detect a bad password, it stops
working on upgrade, and nothing in the release notes will look like it is about
you.&lt;/p&gt;
&lt;/blockquote&gt;
</content>
        
    </entry>
</feed>
