Appearance
IAM/Profile UUID Cutover Design
Status: additive UUID shadow-column migrations, database-enforced dual-write triggers, UUID primary-key readiness views, owner-local UUID foreign-key validation constraints, and UUID primary-key promotion migrations are implemented. Public ids remain string-compatible, and default route promotion still requires production-like target validation plus browser/rollback proof.
Purpose
The pasted IAM/Profile requirements call for UUID primary keys. The Go platform keeps public id fields as strings for legacy/backfill compatibility, but the promoted IAM/Profile target schemas now enforce service-owned PostgreSQL primary keys on UUID columns.
This document is the migration design for editing existing IAM/Profile primary keys while preserving text ids for public route contracts and backfill upserts.
Non-Goals
- Do not recreate
auth-service; identity work stays iniam-service. - Do not remove deprecated
user-serviceuntil default route cutover and backfill evidence are complete. - Do not add cross-database foreign keys between Profile and IAM.
- Do not change public OpenAPI id fields from
stringto a non-string type. - Do not rewrite legacy ids in the legacy database.
Current Schema
IAM text primary keys and references:
identities.ididentity_memberships.id,identity_memberships.user_idrefresh_tokens.id,refresh_tokens.user_idroles.idpermissions.idrole_permissions.role_id,role_permissions.permission_iduser_roles.account_id,user_roles.role_idorganizations.idorganization_members.id,organization_members.organization_id,organization_members.account_idsessions.id,sessions.account_id,sessions.refresh_token_idinvites.id,invites.organization_id,invites.accepted_by_account_id,invites.revoked_by_account_idparent_student_links.id,parent_student_links.parent_account_id,parent_student_links.student_account_idteacher_subject_scopes.id,teacher_subject_scopes.account_id,teacher_subject_scopes.organization_idstaff_permission_scopes.id,staff_permission_scopes.account_id,staff_permission_scopes.organization_idlogin_audit_logs.id,login_audit_logs.account_idsecurity_events.id,security_events.account_idiam_membership_outbox.id,iam_membership_outbox.aggregate_idpassword_reset_tokens.id,password_reset_tokens.account_idauth_provider_settings.id,auth_provider_settings.updated_by_account_iduser_auth_providers.id,user_auth_providers.account_idmigration_runs.id,migration_id_map.migration_run_id
Profile text primary keys and references:
users.idteacher_profiles.id,teacher_profiles.user_idstudent_profiles.id,student_profiles.user_idparent_profiles.id,parent_profiles.user_idstaff_profiles.id,staff_profiles.user_idprofile_contacts.id,profile_contacts.account_idteacher_kyc.id,teacher_kyc.user_id,teacher_kyc.verified_by_idparent_students.id,parent_students.parent_id,parent_students.student_idmigration_runs.id,migration_id_map.migration_run_id
Compatibility views expose ids as strings:
- IAM
accounts,credentials - Profile
profiles,profile_public_views
Required Invariants
- New service-generated ids remain UUIDv4.
- Old legacy/backfill ids remain available as
legacy_idor explicit mapping rows until all public routes and reports are cut over. profile-service.users.idremains the logical IAMaccount_id. It must not become a physical cross-database foreign key.- OpenAPI response id fields stay
type: string; downstream clients should not depend on PostgreSQL storage type. - Event contracts keep source ids as strings.
sourceEventId,aggregateId,accountId, andentityIdremain string fields even when the underlying canonical id is a UUID column. - Rollback must be possible before dropping legacy text columns or views.
Proposed Columns
Use additive columns before primary-key promotion:
id_uuid UUIDfor each canonical primary-key table that currently hasid TEXT PRIMARY KEY.account_id_uuid UUID,user_id_uuid,organization_id_uuid, and similar reference columns for foreign-key relationships inside the same service database.legacy_text_id TEXTonly when the existingidcolumn must be converted in place later and the old string must remain queryable.
Profile-specific rule:
users.id_uuidis Profile's storage primary key.users.account_id TEXT UNIQUEshadows the IAM logical account id. It is backfilled by the shadow-column migration and maintained by the Profile dual-write trigger. Compatibility views useCOALESCE(account_id, id)so current API behavior remains stable.- Role-specific profile tables should reference
users.id_uuidinternally while still exposinguserIdoraccountIdas strings in DTOs.
IAM-specific rule:
identities.id_uuidis IAM's storage primary key.- Existing public/account route ids remain string-compatible through the API.
- Directory compatibility views continue to expose
idas the canonical string account id until gateway/browser cutover approves a stricter UUID-only contract.
Migration Order
- Add UUID columns and unique indexes.
- Backfill UUID columns:
- If the existing text id parses as UUID, copy it into
id_uuid. - If it does not parse as UUID, generate a new UUID and preserve the old value in
legacy_idorlegacy_text_id.
- If the existing text id parses as UUID, copy it into
- Add nullable reference UUID columns.
- Backfill reference UUID columns by joining within the same service database.
- Add validation queries and reports for:
- row counts before/after;
- null UUID columns;
- duplicate UUIDs;
- broken reference mappings;
- legacy text ids without mapping rows.
- Keep repositories on existing string-ID interfaces while the service-owned database triggers dual-write UUID shadow columns for every repository write.
- Run dual-write for one release: new rows get both UUID storage ids and string API ids, with repository integration tests proving clean validation views.
- Promote UUID foreign keys and constraints.
- Promote service-owned primary-key constraints to UUID columns while keeping legacy text ids unique for string-id APIs and backfill upserts.
- Drop old text id columns only in a final irreversible cleanup migration after rollback window closes.
Implemented additive slices:
services/iam-service/migrations/000009_iam_uuid_shadow_columns.sqladdsid_uuid,legacy_text_id, reference UUID columns, deterministic parse-or-hash UUID backfill, indexes, andiam_uuid_shadow_validation.services/profile-service/migrations/000006_profile_uuid_shadow_columns.sqladdsid_uuid, nullableusers.account_id,legacy_text_id, reference UUID columns, deterministic parse-or-hash UUID backfill, indexes, compatibility view fallback, andprofile_uuid_shadow_validation.services/iam-service/migrations/000010_iam_uuid_shadow_dual_write.sqladdshoctapaz_iam_uuid_shadow_dual_write()and per-table triggers so repository writes maintain IAMid_uuid,legacy_text_id, and owner-local reference UUID columns. It also tightens nullable IAM audit validation so nullaccount_idlogs do not create false UUID issues.services/profile-service/migrations/000007_profile_uuid_shadow_dual_write.sqladdshoctapaz_profile_uuid_shadow_dual_write()and per-table triggers so repository writes maintain Profileid_uuid,users.account_id, and owner-local reference UUID columns.services/iam-service/migrations/000011_iam_uuid_primary_key_readiness.sqladdsiam_uuid_primary_key_readiness, a read-only view for UUID primary-key promotion gates. It checks every IAM owner-localid_uuidcandidate for nulls and duplicates, verifies rollbacklegacy_text_idcoverage, and checks UUID composite-key readiness forrole_permissionsanduser_roles.services/profile-service/migrations/000008_profile_uuid_primary_key_readiness.sqladdsprofile_uuid_primary_key_readiness, a read-only view for Profile UUID promotion gates. It checks every Profile owner-localid_uuidcandidate, rollbacklegacy_text_id, and the logicalusers.account_idone-to-one mapping.services/iam-service/migrations/000012_iam_uuid_foreign_key_constraints.sqladds owner-local IAM UUID foreign keys asNOT VALIDconstraints plus theiam_uuid_foreign_key_constraintsstatus view for validation and ON DELETE policy proof.services/profile-service/migrations/000009_profile_uuid_foreign_key_constraints.sqladds owner-local Profile UUID foreign keys asNOT VALIDconstraints plus theprofile_uuid_foreign_key_constraintsstatus view for validation and ON DELETE policy proof.services/iam-service/migrations/000013_iam_uuid_primary_key_promotion.sqlvalidates IAM UUID FKs, adds non-null UUID key columns, preserves text-id uniqueness, removes legacy owner-local text FKs, promotes IAM primary keys toid_uuidor UUID composite keys, and exposesiam_uuid_primary_key_constraints.services/profile-service/migrations/000010_profile_uuid_primary_key_promotion.sqlvalidates Profile UUID FKs, adds non-null UUID key columns, preserves text-id uniqueness, removes legacy owner-local text FKs, promotes Profile primary keys toid_uuid, and exposesprofile_uuid_primary_key_constraints.
Still pending before default route promotion and final cleanup:
- production-like populated database proof that UUID shadow validation, primary-key readiness, foreign-key validation, and primary-key promotion views are clean after migration and backfill;
- final irreversible cleanup that drops old text id columns;
- public gateway/browser promotion evidence. Static rollback rehearsal exists through
make test-iam-profile-route-rollback, but it does not promote the default route table.
Validation Queries
Each migration slice must include service-owned validation SQL equivalent to:
sql
SELECT count(*) FROM identities WHERE id_uuid IS NULL;
SELECT id_uuid, count(*) FROM identities GROUP BY id_uuid HAVING count(*) > 1;
SELECT count(*) FROM refresh_tokens rt
LEFT JOIN identities i ON i.id_uuid = rt.account_id_uuid
WHERE rt.account_id_uuid IS NOT NULL AND i.id_uuid IS NULL;Profile validation must also prove that account_id has a one-to-one mapping:
sql
SELECT account_id, count(*) FROM users GROUP BY account_id HAVING count(*) > 1;
SELECT count(*) FROM users WHERE id_uuid IS NULL OR account_id IS NULL;The read-only primary-key readiness target wraps those checks for migrated target databases:
bash
IAM_PROFILE_UUID_PK_READINESS_CONFIRM=uuid-primary-key-readiness \
IAM_DATABASE_URL=postgres://.../hoctapaz_iam_db?sslmode=disable \
PROFILE_DATABASE_URL=postgres://.../hoctapaz_profile_db?sslmode=disable \
make test-iam-profile-uuid-primary-key-readinessPassing this target is prerequisite evidence for a future UUID primary-key swap. It does not itself promote constraints or change route contracts.
The UUID foreign-key validation target validates the owner-local NOT VALID constraints on migrated target databases:
bash
IAM_PROFILE_UUID_FK_VALIDATION_CONFIRM=uuid-foreign-key-validation \
IAM_DATABASE_URL=postgres://.../hoctapaz_iam_db?sslmode=disable \
PROFILE_DATABASE_URL=postgres://.../hoctapaz_profile_db?sslmode=disable \
make test-iam-profile-uuid-foreign-key-validationThis target scans the target tables and updates constraint metadata only. It does not write application rows, swap primary keys, or change route contracts.
The UUID primary-key promotion target validates the promoted target schema:
bash
IAM_PROFILE_UUID_PK_PROMOTION_CONFIRM=uuid-primary-key-promotion \
IAM_DATABASE_URL=postgres://.../hoctapaz_iam_db?sslmode=disable \
PROFILE_DATABASE_URL=postgres://.../hoctapaz_profile_db?sslmode=disable \
make test-iam-profile-uuid-primary-key-promotionPassing this target proves the migrated IAM/Profile schemas use UUID-backed primary keys for service-owned tables, preserve text-id uniqueness for string-id APIs and ON CONFLICT (id) upserts, have no remaining owner-local legacy text FKs, and have clean UUID FK metadata.
Rollback Strategy
Before final text-id cleanup:
- Roll back the promotion migrations to restore text primary-key constraints.
- Keep existing string-id views and route contracts untouched.
- Keep
legacy_text_idand textidcolumns until default routes and reports have production-like proof.
After final cleanup:
- Rollback requires restoring text id columns and primary-key constraints from backups or mapping tables.
- Do not enter this phase without a tested database dump restore, clean Compose/local K8s migration proof, and public route rollback notes.
Backfill Report Requirements
The legacy user backfill report must include:
- source legacy row counts for identity/profile/KYC rows;
- IAM inserted/replayed/failed counts;
- Profile inserted/replayed/failed counts;
- counts grouped by role/status;
- hash totals for stable identity/profile fields;
- warning rows for non-UUID legacy ids that require generated UUID mapping;
- proof that no legacy database writes occurred.
Acceptance For Implementation Slice
- IAM/Profile migrations preserve string-id APIs while promoting service-owned database primary keys to UUID columns.
- Repositories preserve existing API behavior while service-owned database triggers dual-write UUID columns.
- OpenAPI mirrors still expose ids as strings.
IAM_SERVICE_POSTGRES_TEST_DATABASE_URL=... go test ./services/iam-service/internal/repository -run TestPostgresIAMUUIDShadowDualWriteIntegration -count=1passes.PROFILE_SERVICE_POSTGRES_TEST_DATABASE_URL=... go test ./services/profile-service/internal/repository -run TestPostgresProfileUUIDShadowDualWriteIntegration -count=1passes.make test-auth-routes test-profile-routes test-service-task-packspasses.IAM_PROFILE_UUID_PK_READINESS_SELF_TEST=1 make test-iam-profile-uuid-primary-key-readinesspasses for the guard logic, and the same target passes with live target DSNs before UUID primary-key promotion validation.IAM_PROFILE_UUID_FK_VALIDATION_SELF_TEST=1 make test-iam-profile-uuid-foreign-key-validationpasses for the guard logic, and the same target passes with live target DSNs before any UUID primary-key promotion validation.IAM_PROFILE_UUID_PK_PROMOTION_SELF_TEST=1 make test-iam-profile-uuid-primary-key-promotionpasses for the guard logic, and the same target passes with live target DSNs after migration.GOTOOLCHAIN=go1.25.11 go test ./services/iam-service/... ./services/profile-service/... -count=1passes.- Clean Compose and local K8s migration smoke prove migration ordering before any default public route promotion.