Every physical name in a Hologram database is derived from the model - tables from module paths, indexes and constraints from the table plus a column plus a $kind suffix. The grammar is sound and the $ reservation makes it collision-free against anything an app can declare. Three things around it are not sound, and #1189 ran into all of them while adding a second index to the grant store.
A long name loses the part that matters. Mapper.fit_identifier/1 takes a string and, past 63 bytes, keeps a prefix and appends a hash - so the tail goes, and the tail is where _$uidx lives. The grant store's unique index spelled with its columns is 64 bytes, one over, and comes out as hologram_role_grant_user_id_resource_type_resource_id__adb4cfaf: no kind marker, no way to grep unique indexes by suffix, a partial word. A Phoenix-style context path does the same to every foreign key on the entity - billing_subscriptions_recurring_invoice_line_item_subscription_id_$fk is 69 bytes and today loses subscription_id_$fk entirely.
The fix is that fit_identifier takes the parts, not the joined string, because _ inside a string can't say where a column ends. If the joined name fits it is returned unchanged, so every name that fits today stays byte-identical. If it doesn't: keep the $kind suffix always, drop the table's leading context segments first, then trailing columns, keep whole names only, and put an 8-hex digest of the full name before the suffix so two names that differ only in what was dropped stay distinct. A table name over the limit is the same rule applied to the table alone - last segment plus digest, recurring_invoice_line_item_d4f59a7b, never billing_subscriptions_d4f59a7b. No table budget and no policy number: each identifier is cut only if it overflows, and a table name stays a function of the module alone, so adding a long relationship never renames the table. The order of what survives is kind, then column, then the entity's own name, then its context. The digest is short_digest/2 from the digest issue, which this one depends on.
Composite indexes spell their columns in order. <table>_<c1>_<c2>_..._$idx or _$uidx, the single-column grammar being the n=1 case. The grant store's unique index is renamed under it - hologram_role_grant_user_id_resource_type_a493c1d4_$uidx once the cut above has run, whole columns and the kind kept - and four test files that name it move. The naming rule was a bare _$uidx and a hand-picked word until now, which reads fine while there is one composite per kind per table and stops being a rule the moment there are two.
The grant store gains a second index, keyed by the resource. (resource_type, resource_id, role), non-unique. The only index on the table today leads with user_id, so every actor-keyed read is served - can?, delete_grant, the correlated grant subquery every policy reference compiles to - and two reads that carry no user_id scan the whole table: count_managing_grants/3, which runs on every revoke under an actor with FOR UPDATE, and the members list, filter(RoleGrant, resource_type: _, resource_id: _), the one query every sharing screen shows. nulls_distinct: true is not optional on it: introspection reads NOT indnullsnotdistinct, which is true for any non-unique index, and a mapper saying otherwise makes the reconciler see drift on every boot. Verified once with EXPLAIN on a seeded scratch probe rather than as a test, since a planner on a tiny table picks a sequential scan whatever exists.
Index names join the collision check. validate_derived_names! refuses colliding enum types and join tables with a message naming both declarations. Index and constraint names are safe by construction - table prefix, reserved $, deterministic digest - but nothing checks it, and two framework entries with one name would overwrite each other silently in Map.merge. Now that the grant store has two, they should be checked the way the others are.
The reconciler recognises a rename. This is the one that decides whether any of the above can ever change again. There are two diffs. The migration renderer compares a pre model against a post model, both derived through the current mapper, so it already emits rename_index and rename_constraint when a derived name moves - but a change to the naming rule re-derives both sides under the new rule and the renderer sees nothing. The schema reconciler compares the real database against the mapper and matches indexes by name (schema.ex:391-416), so a renamed index is one missing plus one unexpected: drop_index and create_index, a rebuild. After release, changing any naming rule would rebuild every index in every app in dev and refuse to boot in prod with no migration able to describe it.
Postgres already holds every definition - pg_index has the table, the columns in order, unique and nulls-distinct, pg_constraint has a foreign key's columns, target and delete action - and Introspection.schema/0 already reads all of it into the shape the reconciler compares. The change is to pair actual and target by that definition before comparing names: same definition, different name, emit the rename op the DDL already has, which Postgres runs as a catalog update on any table size. Foreign keys pair by fk_structure, primary keys are one per table, join tables by their two ends, enum types through the column that uses them rather than by value set. Only genuinely new or gone objects create or drop. The mapper stays pure, the catalog is the registry, and a future rule change becomes a generated metadata-only migration. It also fixes something that happens today: renaming an attribute in dev rebuilds its sort index instead of renaming it.
DECIDE: when prod drift consists of renames only, does the node heal itself at boot or still refuse and hand back a migration? A rename is instant and safe to apply unattended, which argues for healing, but it is a deliberate loosening of check_drift! and should be ruled rather than slipped in.
Sequencing: the digest issue first, then this. #1189 keeps its identity_columns/0 guard, which ties the grant id's derivation to the unique index's columns and is about the id rather than the index. The reasoning behind each item is in tmp/client-role-grants/design.md and the session that produced it.
Every physical name in a Hologram database is derived from the model - tables from module paths, indexes and constraints from the table plus a column plus a
$kindsuffix. The grammar is sound and the$reservation makes it collision-free against anything an app can declare. Three things around it are not sound, and #1189 ran into all of them while adding a second index to the grant store.A long name loses the part that matters.
Mapper.fit_identifier/1takes a string and, past 63 bytes, keeps a prefix and appends a hash - so the tail goes, and the tail is where_$uidxlives. The grant store's unique index spelled with its columns is 64 bytes, one over, and comes out ashologram_role_grant_user_id_resource_type_resource_id__adb4cfaf: no kind marker, no way to grep unique indexes by suffix, a partial word. A Phoenix-style context path does the same to every foreign key on the entity -billing_subscriptions_recurring_invoice_line_item_subscription_id_$fkis 69 bytes and today losessubscription_id_$fkentirely.The fix is that
fit_identifiertakes the parts, not the joined string, because_inside a string can't say where a column ends. If the joined name fits it is returned unchanged, so every name that fits today stays byte-identical. If it doesn't: keep the$kindsuffix always, drop the table's leading context segments first, then trailing columns, keep whole names only, and put an 8-hex digest of the full name before the suffix so two names that differ only in what was dropped stay distinct. A table name over the limit is the same rule applied to the table alone - last segment plus digest,recurring_invoice_line_item_d4f59a7b, neverbilling_subscriptions_d4f59a7b. No table budget and no policy number: each identifier is cut only if it overflows, and a table name stays a function of the module alone, so adding a long relationship never renames the table. The order of what survives is kind, then column, then the entity's own name, then its context. The digest isshort_digest/2from the digest issue, which this one depends on.Composite indexes spell their columns in order.
<table>_<c1>_<c2>_..._$idxor_$uidx, the single-column grammar being the n=1 case. The grant store's unique index is renamed under it -hologram_role_grant_user_id_resource_type_a493c1d4_$uidxonce the cut above has run, whole columns and the kind kept - and four test files that name it move. The naming rule was a bare_$uidxand a hand-picked word until now, which reads fine while there is one composite per kind per table and stops being a rule the moment there are two.The grant store gains a second index, keyed by the resource.
(resource_type, resource_id, role), non-unique. The only index on the table today leads withuser_id, so every actor-keyed read is served -can?,delete_grant, the correlated grant subquery every policy reference compiles to - and two reads that carry nouser_idscan the whole table:count_managing_grants/3, which runs on every revoke under an actor withFOR UPDATE, and the members list,filter(RoleGrant, resource_type: _, resource_id: _), the one query every sharing screen shows.nulls_distinct: trueis not optional on it: introspection readsNOT indnullsnotdistinct, which is true for any non-unique index, and a mapper saying otherwise makes the reconciler see drift on every boot. Verified once withEXPLAINon a seeded scratch probe rather than as a test, since a planner on a tiny table picks a sequential scan whatever exists.Index names join the collision check.
validate_derived_names!refuses colliding enum types and join tables with a message naming both declarations. Index and constraint names are safe by construction - table prefix, reserved$, deterministic digest - but nothing checks it, and two framework entries with one name would overwrite each other silently inMap.merge. Now that the grant store has two, they should be checked the way the others are.The reconciler recognises a rename. This is the one that decides whether any of the above can ever change again. There are two diffs. The migration renderer compares a pre model against a post model, both derived through the current mapper, so it already emits
rename_indexandrename_constraintwhen a derived name moves - but a change to the naming rule re-derives both sides under the new rule and the renderer sees nothing. The schema reconciler compares the real database against the mapper and matches indexes by name (schema.ex:391-416), so a renamed index is one missing plus one unexpected:drop_indexandcreate_index, a rebuild. After release, changing any naming rule would rebuild every index in every app in dev and refuse to boot in prod with no migration able to describe it.Postgres already holds every definition -
pg_indexhas the table, the columns in order, unique and nulls-distinct,pg_constrainthas a foreign key's columns, target and delete action - andIntrospection.schema/0already reads all of it into the shape the reconciler compares. The change is to pair actual and target by that definition before comparing names: same definition, different name, emit the rename op the DDL already has, which Postgres runs as a catalog update on any table size. Foreign keys pair byfk_structure, primary keys are one per table, join tables by their two ends, enum types through the column that uses them rather than by value set. Only genuinely new or gone objects create or drop. The mapper stays pure, the catalog is the registry, and a future rule change becomes a generated metadata-only migration. It also fixes something that happens today: renaming an attribute in dev rebuilds its sort index instead of renaming it.DECIDE: when prod drift consists of renames only, does the node heal itself at boot or still refuse and hand back a migration? A rename is instant and safe to apply unattended, which argues for healing, but it is a deliberate loosening of
check_drift!and should be ruled rather than slipped in.Sequencing: the digest issue first, then this. #1189 keeps its
identity_columns/0guard, which ties the grant id's derivation to the unique index's columns and is about the id rather than the index. The reasoning behind each item is intmp/client-role-grants/design.mdand the session that produced it.