User Identity: Official Name vs Preferred Name¶
A user account carries two kinds of name, with different owners.
Official name (IdP-owned on SSO accounts)¶
User.first_name / User.last_name are the institutional record.
On every SSO login, SocialAccountAdapter.pre_social_login (campuscore_app/apps/auth/allauth_adapter.py) re-syncs them (and the email) from the IdP's claims, so a local edit to these fields on an SSO-linked account would silently revert at the next sign-in.
PATCH /api/account therefore rejects first/last-name edits from SSO-linked accounts with a 403, and the settings form renders them read-only with a "Managed by your institution." hint.
A local account (no SSO identity linked - in practice a createsuperuser account) may edit its official name, but only until an SSO login auto-links it: the adapter connects a first-time SSO identity to an existing account by IdP-asserted email and immediately resyncs the name.
The preferred name is the field a user can rely on across that transition.
The 403 keys off the field being present (non-null) in the PATCH body, not off the value differing from what is stored: null means "not editing this field", and any non-null name from a locked account - even one equal to the stored value - rejects the whole request.
The settings form sends null for locked fields, so a well-behaved client never hits this.
Preferred name (user-owned)¶
UserProfile.preferred_name (campuscore_app/apps/auth/models/sso_models.py) is set, changed, and cleared only by its user, via PATCH /api/account; an empty string means "unset".
Django admin shows it read-only on the user profile, so support can see it but never write it; profile rows cannot be deleted there either, because the bootstrap and account APIs read the profile unconditionally.
No SSO code path writes it: the adapter's save lists only User columns, and no signal re-saves the profile on user save.
There is no IdP claim mapping for it - deliberately, so the sync conflict this field exists to escape cannot be reintroduced.
How SSO linkage is detected¶
is_name_managed_by_sso(user) (campuscore_app/apps/auth/services/identity_service.py) is the one home for the rule: an allauth SocialAccount row exists exactly for SSO-provisioned or auto-linked accounts, so its existence answers "does an IdP own this account's name" - including for accounts provisioned before the rule existed.
Which surfaces use which name¶
The bootstrap payload (CurrentUser in campuscore_app/apps/main_app/apis/bootstrap_api.py) carries preferred_name plus the server-decided can_edit_official_name capability; the account settings form is driven entirely by these two fields.
Any user-facing surface that starts displaying the signed-in user's name should prefer preferred_name and fall back to the official name.
That rule has two executable forms in campuscore_app/apps/auth/services/identity_service.py, sharing one preferred-name lookup: display_name(user) falls back to the full official name, and salutation_name(user) falls back to the first name only.
The agent's system prompt is a consumer of display_name: the ## User Identity block in the per-request dynamic context uses it, so the assistant addresses people by their preferred name (see agent-retrieval.md).
The chat home's greeting is a consumer of salutation_name: GET /api/bootstrap composes greeting ("How can I help you today, Addie?") from it through chat_greeting in campuscore_app/apps/main_app/services/chat/greeting.py, unless an admin stored their own welcome message, which is shown verbatim.
A greeting wants one word where the prompt wants the full name, which is why the two forms exist rather than one.
Admin surfaces (workspace user list, folder-sharing picker, /cc_admin/), audit records, and user search deliberately stay on the official name, so staff always match the institution's directory.