RP Migration Guide — Adopting canonical_sub
This guide covers the procedure for an RP that is already integrated with logi to switch safely to canonical_sub-based user resolution while keeping its existing sub foreign keys. The RP-side work must be complete before logi turns on the first merge trigger in production.
The big picture
Your existing RP DB references a logi user like this:
users (RP own table)
logi_sub string unique ← the logi user's IDAfter migration:
users (kept as-is)
logi_sub string unique
logi_identity_links (new)
primary_user_id string ← canonical_sub
linked_user_id string unique ← the absorbed sub
merged_via string
occurred_at datetimeEvery user lookup in your RP passes through a canonical resolver:
def find_canonical_user(sub)
canonical = LogiIdentityLink.find_by(linked_user_id: sub)&.primary_user_id || sub
User.find_by(logi_sub: canonical)
endThe 5-phase deploy order
This is a joint deploy coordinated with logi. We recommend a 24-hour soak after each phase before the next.
Phase 1 — Schema + inactive code
- Add the
logi_identity_linkstable + index - Add the canonical resolver code (unused; feature flag
enforce_canonical_resolution = false) - The existing user-lookup path keeps using
logi_subdirectly
Phase 2 — Enable the webhook receiver
- Enable the
/webhooks/logiendpoint → on receivinguser.merged, INSERT alogi_identity_linksrow - Implement HMAC verification +
event_iddedupe - At this point logi has not yet fired any merge, so no rows arrive — the receiver's code path is effectively a dry run
Phase 3 — Enable the polling reconciler
- A per-minute
GET /api/v1/events?since=<cursor>polling job - Store the cursor in your own DB (advance the cursor + apply events within one transaction)
Phase 4 — Enable the login-time resolver
- A BearerAuthentication (or equivalent) concern looks at
canonical_subon every token verification, compares it againstlogi_identity_links, and inserts a row immediately if there's a difference - This is the last safety net for anything webhook/polling missed
Phase 5 — Flip enforce_canonical_resolution = true
- Change every user lookup so it goes through the canonical resolver
- Use grep to confirm no direct
User.find_by(logi_sub:)calls remain in the codebase (we recommendbin/rails runner+ a custom audit)
logi turns on the merge trigger in production only after this flip.
Code patterns for Rails
Concern example
# app/controllers/concerns/logi_canonical_resolution.rb
module LogiCanonicalResolution
extend ActiveSupport::Concern
private
def resolve_logi_user(sub:, canonical_sub:, linked_subs: [])
# 1) login-time fallback: backfill here when webhook/polling missed an event
if canonical_sub != sub && !LogiIdentityLink.exists?(linked_user_id: sub)
LogiIdentityLink.create!(
primary_user_id: canonical_sub,
linked_user_id: sub,
merged_via: "login_time_fallback",
occurred_at: Time.current
)
end
# 2) always look up by canonical
User.find_by(logi_sub: canonical_sub)
end
endA note on Pundit policies
If a Pundit policy does a direct comparison such as record.user_id == current_user.id, permissions can drop in the case where, after a merge, the same person has two rows with different logi_subs. The recommended pattern:
class ApplicationPolicy
def same_canonical?(record_user)
LogiCanonical.same_owner?(current_user, record_user)
end
endLogiCanonical.same_owner? resolves each user's logi_sub to its canonical and then compares them. For detailed patterns, see the Rails integration guide.
Merge Reconciler
When user.merged arrives via webhook or polling:
class MergeReconciler
def apply(event)
survivor = event[:data][:survivor_canonical_sub]
merged = event[:data][:merged_sub]
LogiIdentityLink.create_or_find_by!(linked_user_id: merged) do |link|
link.primary_user_id = survivor
link.merged_via = event[:data][:merged_via]
link.occurred_at = Time.parse(event[:data][:triggered_at])
end
# The logical merge of domain data is up to the RP's policy.
# e.g. force-terminate merged's active sessions, combine both users' order history, etc.
end
endcreate_or_find_by! guarantees idempotency, so it's safe even if the same webhook arrives twice.
What does not change
- Existing logi_sub foreign keys stay as-is. A row's logi_sub is not changed by the migration.
- The logi_sub of a newly signed-up user also stays the same.
subis stable under OIDC. - Your RP's external API responses — if you exposed a user identifier externally, that value stays the same too.
What changes
- When a user who has been merged receives a new token,
canonical_subdiffers fromsub. linked_subsmay be populated.- Every RP-side user lookup has to take one extra hop through the canonical resolver.
Verification checklist
Before the flip:
- [ ] The
LogiIdentityLinktable + a UNIQUE(linked_user_id) index - [ ] A webhook receiver with HMAC +
event_iddedupe implemented - [ ] A cursor-based polling reconciler (per-minute recommended)
- [ ] A login-time canonical resolver
- [ ] All direct
User.find_by(logi_sub:)calls replaced with calls that go through the canonical resolver - [ ] Pundit policies / scopes use canonical comparison
- [ ] Ready to flip the feature flag
enforce_canonical_resolution = true
After you notify the logi operators that the flip is complete, logi enables the T1 trigger in production.