uid: T-188 title: One profile per person β unify the RBAC user record onto the Individual, with input-once gates status: doing area: user-management created: 2026-07-27 updated: 2026-07-27 assignee: User Management owner: girafeev1 related: T-195, T-183, T-178, T-048, T-161, T-160
T-188 β One profile per person¶
Why¶
A person is described by two Firestore documents that both carry person-facts:
users/{uid}(aote-system) β the RBAC account. Besides access facts (role, badges, subsidiary access, status) it also holdsjobTitle,organization,phoneNumber,whatsappNumber,contactNotes,preferredContactMethod,isExternal.individuals/{id}(aote-system) β the canonical person in Contacts:basic.*,legal.*,system.*(incl. theuserUidbridge).
That split has already produced real drift β two independent "job title" fields
(users.jobTitle vs Individual.system.position), and only the latter prints on Payment
Confirmations.
Owner, 2026-07-27 (verbatim): "I actually wanted the RBAC user profile to be combined with the 'Individual''s profile (if they're 2 different sets of Firestore doc), with gates limiting user to only be able to input their info without further editing it."
The full direction arrived as a written brief (owner, drafted with Records (Infrastructure) Β· session_018RDB37kCqfouHdygVXTAtD, 2026-07-27) β recorded in "The brief" below.
The brief (owner, 2026-07-27) β recorded verbatim in substance¶
- Do not physically merge the two documents.
users/{uid}becomes a pure access record (role, badges, subsidiary access, status + the identity an account needs: email, displayName as the login label, photo, telegram link). Every duplicated person-fact leaves it.individuals/{id}becomes the only home of person-facts; the Profile page reads/writes the Individual through self-scoped endpoints, and the account section stays read-only RBAC display. - Rationale for two docs: person and account have different lifecycles (contractors, students and
directors exist with no login), and the auth layer reads
users/{uid}on every request β a physical merge is a high-risk auth migration with no clarity gain. - Finish the repo's existing journey:
lib/individuals/personalFields.tsalready resolves person-facts as "Individual wins when present, else the legacy user doc" β the end state flips that to Individual only. The decisions index (2026-07-25) already records that the closing's job title comes fromIndividual.system.positionand that the RBAC profile stays an access record. - Write seam:
updateIndividualServer(section shallow-merge + individualβpayee write-through) and the self-scoped endpoint pattern frompages/api/profile/payment-details.ts(T-183). Standing rule: no adapters, no second dialects of how data is written to Firestore. - The gate β input-once, amend-by-bookkeeper. Users may fill a blank field on their own record but not change it once set; enforced server-side (compare stored vs incoming; reject changes to non-empty values with a message pointing at the bookkeeper) β a disabled input is not a gate. Proposed classes (owner asked for confirmation before building):
| Class | Fields | User may |
|---|---|---|
| Payment/legal facts | legal.legalName, legal.bankAccounts (add allowed; edit/delete/change-default β bookkeeper), HKID if ever self-served |
Input once |
| Contact facts | phone, whatsapp, preferred contact method, address | Edit freely |
| Company assertions | abbreviation, all of system.*, role, badges, subsidiary access |
Never self-service |
- Migration expectations β the owner's instructions on moving the data (backfill onto blank Individual fields first, a conflict list where both disagree, re-point every reader, then remove the duplicated fields under whole-document approval, and the suggested field-by-field phasing) are not part of T-188. At the owner's request (2026-07-30) the data migration is its own task, T-195, which carries those instructions in full. T-188 owns only the surfaces and the shape they read/write; it moves no existing data.
- Ground rules:
/api/profile/*stays self-scoped (no id parameters, ever); all person-fact writes throughupdateIndividualServer, bank lists throughnormaliseBankAccountsForWrite; preserve theensureIndividualForUseronboarding guarantee and the singlesystem.userUidbridge; build after T-183/PR #933 merges, not beside it, preserving two behaviours of itsPaymentDetailsCard(silent when the record is complete; when the abbreviation is missing it says a bookkeeper must set it rather than offering a field) and thereadinessshape of/api/profile/payment-details+ the{ email, displayName, position, paymentReadiness }shape ofGET /api/profile/me; do not touch role/badge/subsidiary-access semantics, the director facet, or anything feeding WOPC signing.
Findings (verified in code 2026-07-27, before any change)¶
Onboarding β the abbreviation is system-generated, and the inviter is never asked.
- The invite form (components/settings/SettingsApp.tsx β InvitationsTab) collects email, role,
primary subsidiary, subsidiary access, expiry β no name, no abbreviation. CreateInvitationInput
(lib/rbac/types.ts) has no field to carry either.
- At first sign-in (pages/api/auth/[...nextauth].ts:219 and :314) ensureIndividualForUser
creates the Individual with abbreviation: '', and createIndividualServer
(lib/individuals/crud.server.ts:53) substitutes id.slice(0, 8).toUpperCase() β an
8-character slice of the opaque doc id. T-183 found the same line from the payments side.
CORRECTION to an earlier report of mine (2026-07-27). I wrote that such a record would print
ERL-WOPC/K3MZ8QP1-2026-001 on an issued document β taken from PR #933's description without
verifying. It would not. lib/wopc.server.ts:279 shows the current reference format is
ERL-WOPC/{YYYY}-{NNN} with no abbreviation (the abbreviation parameter is retained but
explicitly unused, _abbreviation, since #664; parseReferenceNumber still accepts the legacy
{ABBREV}- shape so old audit trails resolve). The abbreviation also does not print on the WOPC
PDF β pdfRenderer.server.ts uses payeeAbbreviation only to resolve the cross-issuance closing
director. The placeholder still matters, but for internal reasons: it is the payee doc key
(payees/{abbr}), the directory's human key in Contacts, the cross-issuance input, and T-183's
readiness predicate treats a placeholder as not payment-ready β which blocks reimbursements.
Profile page (components/profile/ProfileApp.tsx) β what is and isn't there.
- Renders the user record via /api/auth/profile: identity, Directory & Contact Info,
Name Preferences, Subsidiary Access, Effective Permissions, Account Details.
- No Telegram surface, although the plumbing is complete: /api/profile/telegram/link +
/status, the connect page at /auth/connect-telegram, and the admin per-user gate
telegramLinkAllowed. Nothing in the app links to the connect page β the only mentions of the URL
are two help strings inside the admin user editor. Users must be told the URL by hand.
- No legal information (legal name, HKID, marital status, spouse, bank accounts) β those live on
the Individual and are edited only in Contacts.
- Bank details are in flight: T-183 / PR #933 (draft) adds PaymentDetailsCard +
self-scoped /api/profile/payment-details. Merged β deployed; and a large backlog is currently
merged-but-undeployed.
- No nickname field. basic.preferredName (one display string) and T-161's
basic.preferredFirstName / preferredLastName exist but are Contacts-editable only. The
Profile's editable name is users.displayName, which feeds nothing in the naming system.
- Name Preferences is a fixed menu, not a composer: it renders standardCombinations(...) β a
curated catalog of 6 greeting + 6 document combinations as pick-one chips. The token vocabulary,
the grammar validator (title requires the last name; documents require a last name) and the
API-side validation of arbitrary submitted combinations already exist (T-161), so an inline
composer is a UI change over machinery already built for it.
Owner decisions, 2026-07-27 (answers to the findings)¶
- Attached brief β recorded above.
- Asked whether the WOPC still includes an abbreviation β answered by the correction above: it does not, in the reference number or on the PDF.
- "preferred name is good, no preferred last name is needed though. Nick name should be editable by the user and admin themselves"
- "Eons ago, there was a composer as a demo that was demonstrated to meβ¦ can you do a re-design to demonstrate to me one more time" β preview gate applies before any build.
- "It's okay for the onboarding user to type in their own preferred name" β a deliberate, scoped exception to T-161's no-self-typed-name-parts rule.
- "The inviting user should have inputted basic info like the invitee's first name and last name of some sort already" β the invite collects the invitee's names (and therefore can propose the abbreviation).
- "I'd like the user to be able to input their own nickname on their own profile on the profile page which that nickname could be use as a token at certain situations"
- Legal information on the profile β "read-only view on user's own profile after inputted to the system".
- Task-splitting/coordination β "Up to you".
Scope (phased β preview-gated per the owner's standing rule)¶
- P0 β preview. Re-design demonstration: the token composer, the unified Profile page, and the invite modal. No code until the owner approves it.
- P1 β invite-time identity. Invitee first/last name + abbreviation on the invite form
(live uniqueness check against
individuals, proposal derived from the names), carried on the invitation doc, threaded through bothensureIndividualForUsercall sites. Retires theid.slice(0,8)placeholder for every future user. Touches no T-183 file. - P2 β nickname + composer.
basic.preferredNamebecomes self-editable (user and admin) and gains a token; the Name Preferences card becomes an inline composer over the token vocabulary. - P3 β profile surfaces. The surfaces only β write-once gate enforcement (the endpoint that
compares stored vs incoming), the Profile restructure (Telegram card, read-only legal view,
absorbing
PaymentDetailsCard). Built after T-183 lands. No data migration lives here β the moving, grouping, backfilling and contracting of existing documents is entirely T-195 (owner's request, 2026-07-30). T-188 changes code and reads/writes the new shape; it never transforms a document that already exists.
Open questions for the owner (raised with the preview)¶
- Retiring
preferredLastName(decision 3) is a behaviour + stored-data change: it removes the document combinationsJake Ngai/Mr. Jake Ngaifrom the catalog, leaving documents on legal name only. That strengthens the T-160 grammar rule but is a real narrowing β and any stored preference referencing the token needs a decision (fall back, or migrate). - Nickname token scope: greeting-only, or documents too? A single nickname token can never satisfy "documents require a last name", so document use would need the rule relaxed.
P0 β preview published (2026-07-27)¶
https://claude.ai/code/artifact/b15ddd04-7353-47a8-853d-63b15227ad95 (label composer-v1)
Three plates: (1) the live name composer β slug tray, composition line, live impression, the grammar rules enforced inline as the owner stated them, context switch greeting/document, editable nickname feeding the render; (2) the regrouped Profile page β Account (read-only, incl. the directory key and a Telegram connect row), About you (freely editable, incl. nickname + the single job title), Legal & payment (read-only after input, HKID masked, add-another-account allowed); (3) the invitation form carrying first name, last name and a proposed directory key with a uniqueness state. Build starts only on the owner's approval, per the standing preview gate.
Two rulings requested with the preview: the nickname token's scope (greeting-only as drawn, or documents too), and whether an invited key is reserved at send time (recommended) or merely checked, given the record is created days later at first sign-in.
v2 β owner feedback on the preview (2026-07-27)¶
Owner (verbatim): "1. The nickname field should be included in the 'Directory & Contact Info' card, but now a casual inline edit field for Name Preferences. 2. No need for the composition field, just the parts and the 'impression' field is good. 3. Plate 2 and 3 is good 4. Just to clarify, Nickname and Preferred Name is the same thing. If there's already a Preferred Name writing on Firestore, keep it, while change the showing of 'Preferred Name' to 'Nickname' on the web app UI"
(Item 1 read as "but not a casual inline edit field for Name Preferences" β the contrast the sentence sets up, and consistent with item 2. Corrected in v2; flagged to the owner.)
Applied in composer-v2:
- The nickname input left the Name Preferences card and now sits in Directory & Contact Info
with the other freely-editable person-facts. Name Preferences composes only.
- The composition strip is gone. Parts are toggles β click to set, click again to remove β with
a small ordinal on each set part showing its position; the impression and the validity line are
the only other elements.
- Plates 2 and 3 approved as drawn (nickname row relocated into the Directory card).
DECISION β nickname IS the preferred name. basic.preferredName stays exactly as stored; only
the UI label changes to "Nickname". No copy, no rename, no migration, and therefore no Firestore
structure change for this part.
Consequence to settle at build time (P2): T-161 added basic.preferredFirstName /
preferredLastName precisely because the single preferredName string could not feed a name token.
With the owner's ruling, the token maps to preferredName and preferredLastName is retired β
so any record already carrying the split fields needs a stated resolution (prefer preferredName
when set; otherwise fold preferredFirstName into it) rather than two competing sources. That
resolution is a data question and will be put to the owner on its own before P2 code.
P2 shipped β composer + self-service nickname (2026-07-27, f1c07146 / #937)¶
Owner: "I think we can proceed to write it to the web app" β built the parts of the approved preview that need no new Firestore field. P1 (invitation) is held for the schema gate below.
- Composer replaces the six-item fixed catalogue in Name Preferences: parts are toggles (click
to set, click again to remove; ordinal shows print position), with the impression and the
refusing rule inline. Rules come from the shared
validateCombination, so the card refuses exactly what the API refuses. preferredNametoken = the nickname, mapped to the existingbasic.preferredName. No Firestore structure change β existing field, existing write seam.- Documents render from legal parts only: the preferred-first/last split leaves the catalogue and a nickname is refused on documents. Legacy split tokens still RENDER, so preferences already stored against them do not go dark (covered by a new test).
- Self-service nickname on
/api/profile/name-preferences(still self-scoped, still throughupdateIndividualServer); combinations are validated against the NEW nickname so a preference cannot be saved that stops rendering on save. Nickname row sits in Directory & Contact Info per the owner's note. Contacts relabels "Preferred name" β "Nickname". - Verification: tsc 0 errors (pinned 5.9.3), vitest 715 passed (4 pre-existing
workspace-billing failures, proven against a stashed baseline), eslint clean on touched files.
A production
next buildcould not be completed in-container (unrelated/v/[id]page fails for want of Firebase env).
β οΈ The verification gate was silently dead in this container¶
npx pulled TypeScript 6.0.2 because the container had no node_modules; TS 6 aborts before
type-checking on the repo's deprecated moduleResolution: node10 / baseUrl, reporting two
config errors and checking nothing. tsc --noEmit therefore "passed" while verifying nothing β
and a deliberate const x: number = 'x' probe confirmed it. After npm install, the pinned 5.9.3
immediately found 8 real call sites this change broke. The repo config is fine; the container
was not. Any agent claiming "heavy tsc clean" should confirm npx tsc --version matches the
pinned 5.x first. Recorded here rather than fixed in tsconfig, because changing the repo's
resolution settings is an infrastructure decision with repo-wide blast radius.
β P1 BLOCKED β awaiting the owner's Firestore approval¶
The invitation must carry the invitee's names and directory key from send to first sign-in, which
means new fields on invitations/{id}. Per AGENTS.md that is a stop-and-ask gate, and a broad
"proceed" is explicitly not schema approval. The whole-document current-vs-after was put to the
owner on 2026-07-27; no code written until they answer.
Also still open from the preview: whether an invited key is reserved at send time (recommended) or merely checked.
Firestore indexes β created and reconciled (2026-07-27, 31a19bb2 / #944)¶
Owner: "Proceed and create the index please" β done, plus a real gap it uncovered.
Created in production (aote-system, via the Firestore Admin API using the environment service
account, which holds datastore.indexes.create): users on status ASC + history.createdAt, in
BOTH directions, mirroring the existing createdAt pair. Created ahead of the migration so the
index is READY before any document carries the field β an index still building cannot serve a sort,
and a sort on a field documents lack drops them silently.
The gap. Production holds 17 composite indexes on aote-system; the repo's
firestore.aote-system.indexes.json declared 11. Four had been created ad hoc and never written
back. Since the Firebase CLI treats that file as the intended state, an index deploy could have
offered to DELETE them:
| Undeclared index | What depended on it |
|---|---|
users (status, createdAt) Γ2 |
every user list, the notification fan-out's "who is active?", System Emails routing's "All admins" |
notifications (read, recipientUid, createdAt) |
the unread inbox query |
ir56mSigningRequests (endingYear, payeeId, status, updatedAt) |
signing-request lookups |
The users pair is the dangerous one: Firestore omits documents lacking the sort field instead of
erroring, so losing it would have produced empty user lists and alerts reaching nobody, with no
error anywhere. All six (four pre-existing + two new) are now declared; verified by diffing the
parsed file against a live index listing β 17 declared, 17 in production, nothing declared that
production lacks.
Standing note for future work: the declarative index file is now a faithful mirror. Anyone creating an index from a query-error link must write it back here, or the next deploy proposes to delete it.
2026-07-30 β field inventory approved; name-field naming convention decided¶
- Design approved. Owner confirmed the four-document field inventory + purpose (the onboarding
walkthrough that mapped
invitations/users/individuals/auditLogs): "your design quoted as below is good." This approves WHAT fields exist and what each is for. The full grouped-map whole-document before/after (theaccess.*/history.*grouping etc.) is still its own pending schema gate. - Name-field naming convention (owner delegated the choice β "Depending on your choice, the other name relating fields should be of the same field naming style"). Decided:
A name with PARTS is a map named for its qualifier, with plain part fields (
firstName/lastName/title) β no requalifying prefix. A name that is a single string stays a scalar.
So legal.legalName.firstName, NOT legal.legalName.legalFirstName β the parent already carries
the qualifier, and re-prefixing the child is the reuse-a-word-at-two-levels anti-pattern from the
2026-07-27 AGENTS.md rule (the status.status smell). This is also the shape already stored
(PayeeLegalName = { title, firstName, lastName }), so the legal name needs zero migration.
- Applied across every name field:
| Field | Today | Under the convention | Migration |
|---|---|---|---|
| English legal name | legal.legalName.{title, firstName, lastName} | same | none |
| Chinese name | legal.chineseLastName + legal.chineseFirstName (flat) | legal.chineseName.{firstName, lastName} | yes β T-195 |
| Nickname β preferred name | basic.preferredName (string) | basic.preferredName.{ firstName, lastName } (map) β superseded by the 2026-07-30 reversal below | yes β T-195 |
| preferred split | basic.preferredFirstName/LastName | folds INTO the preferredName map (per the reversal) | T-195 |
- Deliberately NOT done: name fields are not gathered into one bucket β legal + Chinese names
stay on legal (identity facts), the nickname on basic (everyday facts). Grouped by kind of
fact, the axis the rest of the record uses. Flagged to the owner for override; recommended as-is.
2026-07-30 β further owner decisions (schema layout)¶
users/{uid}gets anaccessmap. Owner: "have an 'access' map field please" β role, badges, roles, subsidiaryAccess, primarySubsidiary and status group underaccess.*rather than sitting flat. Will appear so in the pending whole-document before/after.- Nickname consolidation β T-195 step 7. Owner: "migrate all preferredFirstName to
preferredName and keep only preferredName."
preferredLastNameis dropped,preferredFirstNamefolds intopreferredName. (Data migration; recorded on T-195.) - Terminology: "directory key" = the person's
abbreviation(e.g. "JC"). Plain-English name for the existing field; will useabbreviationgoing forward to match storage. - Inviter names land in
legal.legalName(decision #1, confirmed) β consistent with the naming convention above. - Reserve the abbreviation at invite time (decision #2). Owner: "reserve directory key please"
β the key is claimed the moment the invite is sent, not merely checked, so nobody else can take it
in the days before the invitee first signs in. Design consequence for P1: uniqueness must now
consider THREE sources β existing
individuals, outstanding pending invitations (two pending invites can't reserve the same key), and the claim must be released when the invitation is revoked or expires (the existing revoke/expire paths gain a release step). To be detailed in the invitation schema before/after.
2026-07-30 (later) β REVERSAL: the preferred name keeps first + last, as a map¶
Owner: "Okay, in that case, let's have preferred first name and last name then." This supersedes
(a) the 2026-07-27 "nickname = a single field, no preferred last name" decision and (b) the
2026-07-30 "consolidate to a single preferredName" decision (T-195 step 7). The preferred name is
structured again.
- Shape β applying the naming convention the owner set (not the old flat fields). A preferred
name with parts becomes a map named for its qualifier with plain part fields, exactly like
legalName/chineseName:basic.preferredName.{ firstName, lastName }β NOT flatpreferredFirstName/preferredLastName(which would break the 2026-07-27 no-flat-prefix rule). This is the owner's own convention applied to a first/last preferred name; flagged for correction if they'd rather the literal flat fields. - Migration consequence (T-195 step 7 rewritten): instead of consolidating away, the legacy
flat
preferredFirstName/preferredLastNamemigrate into the map (preferredName.firstName/.lastName); the current single-stringpreferredName(onboarding filler) seeds.firstNamewhere the map is otherwise empty. The field's type changes string β map β handled in the migration. - Tokens: the preferred first / last name return as greeting tokens (reading the map). The
P2 composer shipped with a single
preferredNametoken, so it needs a small follow-up build to expose the two parts again. - Documents β HARD RULE (owner, 2026-07-30): "preferred names are never allowed to signable
documents. They should always stay greeting-only." This is now an invariant, not a default β
the earlier "one-line override" affordance is withdrawn. The preferred parts render in greetings
only ("Dear Jake Ngai," is fine); they can never appear on a signable/legal document (WOPC
closing, Payment Confirmation, statutory form), which use the legal name exclusively. Already
enforced structurally, not just by UI omission:
validateCombination(..., 'document')refuses any combination containingpreferredName(lib/naming/nameRendering.ts), so no code path β composer, API, or future producer β can leak a nickname onto signed paper. Rationale (my expert view, owner concurring): a nickname has no legal standing, doesn't tie to the HKID / company-register identity that makes a signatory bindable, and breaks the name-consistency chain across WOPC β bank transfer β IR56M that authoritative documents depend on. The only legitimate "known-as" construct on a legal instrument is an explicit, labelled alias ("Ngai Wang Chi (also known as Jake Ngai)") β which still leads with the legal name and is a separate future capability, never a bare-nickname substitution.
Source¶
Owner brief 2026-07-27 (usermanagementunificationbrief.md) + the Q&A above; naming convention
2026-07-30; preferred-name reversal 2026-07-30.
Source: User Management Β· https://claude.ai/code/session_01GGT5n9vCxKWoUQSRAfMSiW