Skip to content

ADR: Global user identity

Identity is fragmented today. companyMembers.publicName, bio, avatarUrl, specializations, and links are per-tenant — a coach working at three gyms has three independent profiles, three independent reputations, and three places to update on a haircut. companyCustomers.name shadows users.globalName for online customers, with no clear winner. Marketplace patterns put identity on the person and per-tenant labels on the relationship. This ADR moves us there:

  • Identity (display name, avatar, bio, specializations, links) becomes global on users + a new 1:1 user_public_profile table.
  • Per-tenant companyMember retains only what is genuinely per-tenant — role (permission enum), roleLabel (display label), internalNotes, isActive.
  • companyCustomers.name becomes write-locked when the row is linked to a user (userId IS NOT NULL), with a one-shot backfill into users.globalName at link time when the user has no global name yet.
  • Coach owns their own profile via a new PATCH /api/client/me/public-profile endpoint; admins see it read-only.
  • Reviews are designed (target_user_id + booking_id) but explicitly NOT implemented in this ticket — a follow-up materialises them. The chosen shape ensures user_public_profile is forward-compatible.

This is a one-way migration with a release-cycle backup table and a conflict CSV for diverging per-company values, so no data is dropped silently.

The following nine decisions are the source of truth for downstream test writers and devs. Implementation code lives in the named files.

AC mapping note: companyMember.internalNotes (nullable text) is added in this ticket as an admin-only field; never exposed on the client surface. The spec’s per-field decision table (specs/global-user-identity.md line 49) assigns “Internal admin notes” to companyMember. The spec’s AC checklist is amended in the same MR to call this out explicitly.

D1. user_public_profile table (NEW, 1:1 with users)

Section titled “D1. user_public_profile table (NEW, 1:1 with users)”
  • Schema: users (Postgres schema, matches existing usersSchema).
  • Drizzle file: add to libs/shared/data-access-db/src/lib/schema/users.schema.ts directly after userProfiles (visual proximity — both are 1:1 extensions of users, same Postgres schema, see ADR cross-schema-references-without-fk — in-schema FK is allowed and used).
  • Columns:
    • id uuid PK default gen_random_uuid().
    • userId uuid NOT NULL UNIQUE references users.id ON DELETE CASCADE.
    • bio text (nullable).
    • specializations text[] (nullable).
    • links jsonb ({label, url}[], nullable; $type<{label:string;url:string}[]>()).
    • slug text UNIQUE (nullable; pretty handle like /coach/ivan-petrov). Forward-compat for v2 vanity URLs. Slug-collision validation lives in the PATCH service, not in DB triggers, but DB UNIQUE is the safety net.
    • verifiedAt timestamptz (nullable). Admin/system-set only. NEVER user-editable — explicit guard at the controller boundary on the client surface. No business endpoint to set it in this ticket (placeholder for future verified-coach feature).
    • coverPhotoUrl text (nullable). For richer profile pages.
    • createdAt timestamptz NOT NULL default now().
    • updatedAt timestamptz NOT NULL default now().
  • Indexes:
    • UNIQUE on (user_id) (via .unique() on the column).
    • UNIQUE on (slug) (partial index WHERE slug IS NOT NULL if Drizzle supports it; otherwise plain UNIQUE — NULLs are distinct in Postgres so plain UNIQUE works).
  • Relation:
    export const userPublicProfilesRelations = relations(userPublicProfiles, ({ one }) => ({
    user: one(users, { fields: [userPublicProfiles.userId], references: [users.id] }),
    }));

publicName and avatarUrl stay on users.globalName (mapped from column full_name) and users.avatarUrl. The reading layer for the public profile assembles the response from users + user_public_profile in one LEFT JOIN.

users.globalName (column full_name) and users.avatarUrl remain canonical for display name + avatar. Avoiding a rename keeps the migration minimal and keeps the existing /me/avatar upload path intact. The public-profile reading DTO maps both into the response (name and avatarUrl).

D3. companyMember — column drops + roleLabel add + internalNotes add

Section titled “D3. companyMember — column drops + roleLabel add + internalNotes add”

After the conflict-resolved backfill into user_public_profile:

  • DROP publicName, bio, avatarUrl, specializations, links from companies.company_member.
  • ADD roleLabel text (nullable). Display label for the company-specific role string (“yoga instructor”, “head trainer”), decoupled from the permission enum role.
  • ADD internalNotes text (nullable). Per-tenant admin-only notes about the member. The spec’s per-field decision table (specs/global-user-identity.md line 49) assigns “Internal admin notes” to companyMember; this column add makes the schema match the documented intent. The field is editable on the business surface (UpdateMemberDto), never exposed on the client surface (no DTO on /api/client declares it), and never visible to other companies. Today’s companyMember schema has no such column — this ticket introduces it.
  • KEEP role (enum), isActive, createdAt, updatedAt.

D4. company_member_legacy_identity (NEW, backup table)

Section titled “D4. company_member_legacy_identity (NEW, backup table)”
  • Schema: companies (same as company_member).
  • Columns mirror the dropped fields, plus origin metadata so we can reconstruct the source row if needed:
    • id uuid PK default gen_random_uuid().
    • companyMemberId uuid NOT NULL (no FK — the source row is about to be edited but stays; this is a soft historical reference).
    • companyId uuid NOT NULL, userId uuid NOT NULL (denormalised for fast lookups during audit).
    • publicName text, bio text, avatarUrl text, specializations text[], links jsonb.
    • archivedAt timestamptz NOT NULL default now().
  • Retention: dropped in a follow-up migration targeting release 2026.07. The named release is the source of truth so the table does not calcify; tracked as a separate follow-up ticket. Documented in the migration preview.
  • Population: INSERT INTO ... SELECT ... from company_member ran inside the same migration that drops the columns (atomic within the Drizzle migration file).

D5. Customer write-guard (application-layer, 409)

Section titled “D5. Customer write-guard (application-layer, 409)”
  • No DDL — no DB CHECK constraint. The guard lives in libs/features/companies/src/lib/services/company-customers.service.ts, method update.
  • Rule: when the incoming UpdateCustomerDto contains name (any value, including null/empty string), the service loads the target row first; if customer.userId IS NOT NULL → throw ConflictException with code errors.customer.name_locked. HTTP 409.
  • Read shape: every customer read DTO carries nameLocked: boolean (= customer.userId !== null) so the business UI can disable the input pre-emptively. (We expose it as nameLocked, not customerNameLocked, because the DTO is already namespaced as Customer*.)
  • Display semantics: every read path that displays a customer name must prefer users.globalName when customer.userId IS NOT NULL, else fall back to companyCustomers.name. This is enforced in the service projection (coalesce(users.global_name, companyCustomers.name) AS name) so consumers don’t reinvent the rule.

D6. Linking flow backfill into users.globalName

Section titled “D6. Linking flow backfill into users.globalName”
  • Location: libs/features/auth/src/lib/services/auth-sync.service.ts, inside the existing customer-linking-by-email branch (method linkUnlinkedCustomersByEmail). After the UPDATE companyCustomers SET userId = ? returns the linked rows, the service runs a follow-up conditional update on users for the caller’s id only:
    UPDATE users.users
    SET full_name = (
    SELECT cc.name
    FROM companies.company_customer cc
    WHERE cc.id = $linkedCustomerId
    LIMIT 1
    )
    WHERE id = $supabaseId
    AND full_name IS NULL
    AND (SELECT cc.name FROM companies.company_customer cc
    WHERE cc.id = $linkedCustomerId LIMIT 1) IS NOT NULL;
    Drizzle equivalent (with explicit guards): a SELECT of the linked customer’s name, then a conditional UPDATE users SET globalName = name WHERE id = ? AND globalName IS NULL — only when the customer name is non-null.
  • Multi-company determinism. When the linking sweep finds multiple offline companyCustomer rows for the same email (e.g. the same person was added as offline to three gyms), the chosen name for backfill MUST be deterministic. The selection rule is:
    ORDER BY company_customer.created_at ASC, company_customer.id ASC
    LIMIT 1
    — the OLDEST offline customer row wins (with id as a stable tiebreaker for the unlikely identical-timestamp case). Rationale: the oldest CRM entry is the one with the most history behind it, and the rule is trivially testable. Document this in the service’s docblock and in the Phase C integration test.
  • Idempotency: the globalName IS NULL predicate makes the backfill one-shot per user; subsequent logins are no-ops.

D7. Client-surface profile self-edit endpoint

Section titled “D7. Client-surface profile self-edit endpoint”
  • Path: PATCH /api/client/me/public-profile.
  • Controller: add a new controller method on the existing UserProfileController (libs/features/user-profile/src/lib/user-profile.controller.ts). Rationale: the controller is already mounted at me with ClientJwtGuard, and the public profile is the user’s own data — the separation from /me / /me/avatar is purely a payload concern (different DTO shape), not a routing concern.
  • Request DTO UpdateMyPublicProfileDto (in libs/features/user-profile/src/lib/dto/update-my-public-profile.dto.ts):
    {
    globalName?: string; // maps to users.full_name
    avatarUrl?: string; // maps to users.avatar_url (read-only via this PATCH;
    // upload uses POST /me/avatar — we accept the field
    // here so a future "set from URL" flow doesn't need
    // a contract change, but the controller forbids
    // setting it from this endpoint with `@IsOptional()`
    // + a class-validator custom rule that returns 400
    // `errors.profile.avatar_via_upload_only`. Final
    // decision: OMIT from the DTO entirely for v1,
    // keep avatar upload on its own route. See OQ in
    // Risks.)
    bio?: string;
    specializations?: string[];
    links?: { label: string; url: string }[];
    slug?: string; // ^[a-z0-9-]{3,64}$ regex; 409 on UNIQUE collision
    coverPhotoUrl?: string; // same caveat as avatarUrl — omit for v1; cover
    // photo upload will be a sibling POST in a future
    // ticket. Final v1 DTO: { globalName, bio,
    // specializations, links, slug }.
    }
    Final v1 DTO fields: globalName, bio, specializations, links, slug. avatarUrl and coverPhotoUrl are NOT writeable from this endpoint in v1 — they go through dedicated upload routes (existing POST /me/avatar, future POST /me/cover). verifiedAt is NEVER in this DTO.
  • Response DTO UserPublicProfileDto — read shape exposed via GET /me/public-profile and as the response of the PATCH:
    {
    userId: string; // PK by foreign key, not the profile-row id
    globalName: string | null; // from users
    avatarUrl: string | null; // from users
    bio: string | null;
    specializations: string[] | null;
    links: { label: string; url: string }[] | null;
    slug: string | null;
    verifiedAt: string | null; // ISO date-time; read-only
    coverPhotoUrl: string | null;
    }
  • GET endpoint: GET /api/client/me/public-profile returns the same UserPublicProfileDto for the calling user, including verifiedAt (read).
  • GET behaviour when no user_public_profile row exists. The endpoint ALWAYS returns 200 with an all-null payload (every nullable field set to null; userId set to the caller’s id) — NEVER 404. The service synthesises the response by LEFT JOINing users with user_public_profile: if the right side is missing, the projection emits nulls. This pins behaviour for both client SDKs and test-writers so they don’t diverge between “no row yet” and “row exists with all nulls”. The PATCH performs an INSERT … ON CONFLICT (user_id) DO UPDATE so the row materialises on first write.
  • Slug normalisation + denylist (server-side). Service validates the incoming slug in this order before any DB write:
    1. Auto-lowercase the input.
    2. Collapse runs of - into a single -; strip leading/trailing -.
    3. Match against ^[a-z0-9-]{3,64}$; reject 400 errors.profile.slug_invalid on mismatch.
    4. Reject 400 errors.profile.slug_reserved if the slug is in the v1 denylist: me, admin, support, coach, api, business, superadmin, auth. Denylist lives in a single constant inside the service (RESERVED_SLUGS), not in the database — it is policy, not schema. Future slug squatting / reserved-handle expansion is out of scope here; see Open Questions.
  • Slug-collision race. The service MUST use the transactional UPSERT shape INSERT INTO user_public_profile (...) VALUES (...) ON CONFLICT (user_id) DO UPDATE SET ... RETURNING ... and let the DB UNIQUE constraint on slug be the arbiter. The Postgres 23505 error code with constraint = 'user_public_profile_slug_unique' is caught and mapped to 409 errors.profile.slug_taken. The service MUST NEVER do SELECT WHERE slug = ? followed by an INSERT — that pattern races under concurrent submissions.
  • Multi-tenancy edge case. Any authenticated user (gated by ClientJwtGuard) can read and edit their own public profile, regardless of whether they currently belong to any companyMember or companyCustomer row. The public profile is identity-owned, not company-gated. A user with zero company memberships can still set their bio, slug, etc.
  • Errors:
    • 400 errors.profile.validation — class-validator failure on any field (e.g. non-array specializations, malformed link.url, slug regex mismatch).
    • 409 errors.profile.slug_takenslug collides with another user’s. Service catches Postgres 23505 whose constraint = 'user_public_profile_slug_unique' and maps to this code.
  • verifiedAt enforcement: the DTO does not declare the field, so whitelist: true on the global ValidationPipe strips it. Defence in depth: the service ignores verifiedAt even if it leaks through (does not include it in the UPDATE/INSERT set).

D8. Business panel — global profile is read-only

Section titled “D8. Business panel — global profile is read-only”
  • Member admin DTO change: the business surface returns the existing per-company fields plus an embedded user.publicProfile read-only block (see Contract patches below).
  • Editable from business surface:
    • role (existing — permission enum).
    • roleLabel (NEW — D3).
    • internalNotes (NEW — D3; admin-only per-tenant field).
    • isActive (existing).
  • NOT editable from business surface: every identity field on the user (globalName, avatarUrl, bio, specializations, links, slug, coverPhotoUrl). Backend UpdateMemberDto will not declare these fields → whitelist: true strips them silently → no admin can write a user’s identity by mistake. The business UI shows the read-only block with a notice “Public profile is owned by the user — ask them to update if needed.”

D9. ADR contributors-must-be-members amendment v4

Section titled “D9. ADR contributors-must-be-members amendment v4”

This ADR amends adrs/contributors-must-be-members.md to v4. The wording change is mechanical but its consequences span every consumer.

  • Old (v3): “every name/avatar/bio render goes through member.*.” Consumers read ContributorMemberPreviewDto.publicName, .avatarUrl, .bio, .specializations, .links directly off the contributor’s member row.
  • New (v4): “every name/avatar/bio render goes through member.user.* and member.user.publicProfile.*.” Consumers read ContributorMemberPreviewDto.user.globalName, .user.avatarUrl, .user.publicProfile.bio, .user.publicProfile.specializations, .user.publicProfile.links.
  • Member row still required. The contributor → companyMember FK with ON DELETE CASCADE stays; v4 changes the reading layer only, not the graph topology. A contributor still cannot exist without a companyMember row; we just read identity through that row’s user relation instead of its own (now-deleted) columns. member.roleLabel is the only member-level identity-ish field, and it is per-company by design.

The v4 amendment must be applied to adrs/contributors-must-be-members.md in the same MR that ships this ADR — bump frontmatter version: 4 and add a “v4 amendment” subsection describing the read-path move.

Field-shape per surface — divergence by design

Section titled “Field-shape per surface — divergence by design”

The two affected surface contracts diverge in how they expose the identity fields, and that divergence is intentional. Test-writers and devs MUST design against the per-surface shape and not assume a shared structure.

Client surface — flat fields kept. ContributorMemberPreviewDto and related coach-preview DTOs (e.g. SessionCoachClientDto) retain their existing flat fields (publicName, avatarUrl, bio, specializations, links) at the top level. The server-side projection moves to read from users.* / user_public_profile.*, but the JSON shape consumers see is unchanged. Goal: zero template churn on tktspace-web and gym_app rendering paths.

Business surface — restructured under user.* / user.publicProfile.*. ContributorMemberPreviewAdminDto, CoachPreviewDto, and any other admin-side member-preview DTOs nest identity under an embedded user block with user.publicProfile.* for the rich fields. Goal: the admin UI renders a clearly-distinct “Public profile (read-only, owned by coach)” section, and the nested DTO maps 1:1 to that section. Per-company fields (roleLabel, internalNotes, isActive, role) stay at the member top level. Admin reviewers seeing the DTO immediately understand which fields they can edit.

This divergence is intentional. It is NOT a contract bug. The patched contracts already reflect it — contracts/client.openapi.yaml at the ContributorMemberPreviewDto schema keeps flat fields; contracts/business.openapi.yaml at ContributorMemberPreviewAdminDto and CoachPreviewDto uses the nested form. Per the repo rule “never share types between surfaces” (_workflow/CLAUDE.md), this is the correct outcome.

When the rest of this ADR speaks of DTOs being “field-stable”, that claim applies to the client surface only unless explicitly qualified. The business surface is restructured by design.

(a) Put public-profile columns directly on users

Section titled “(a) Put public-profile columns directly on users”

Add bio, specializations, links, slug, verifiedAt, coverPhotoUrl to users.users.

  • Pros: one less table; one less join; simpler schema. /me could return everything in one row.
  • Cons:
    1. users is documented as the “thin Supabase shadow” (per docs/content/domain/identity/user.mdx). Fattening it with public- facing columns conflates the auth-mirror role with the marketing surface; the domain model gets muddier, not cleaner.
    2. Future player_profile (leagues / competitions, OQ-6 in the spec) either has to repeat the 1:1-table choice anyway or pile MORE columns onto users. Today is the right time to draw the line.
    3. Reviews aggregate (count, average) — a follow-up ticket — will benefit from a clear 1:1 table to hang materialised aggregates off of (user_public_profile.review_count etc., or a separate user_public_profile_stats 1:1).
    4. verifiedAt is admin-set; mixing it into the Supabase-mirror row blurs which writes are auth-driven vs identity-driven.

Rejected.

(b) Per-company companyMember.* override on top of global

Section titled “(b) Per-company companyMember.* override on top of global”

Keep companyMembers.publicName, .bio, etc. as nullable, where non-null overrides the global user.publicProfile.* for that company only.

  • Pros: zero migration risk on read paths (clients fall back to member fields, just like today, and only honour the new global fields when the member fields are NULL).
  • Cons:
    1. The reading layer becomes TWO sources of truth per field — COALESCE(member.publicName, user.globalName). Every renderer has to merge; reviews are even worse (do they target the member-override name or the global name?).
    2. The whole point of going global is that a coach at three gyms has one identity. Per-tenant overrides re-introduce N identities through the back door.
    3. The spec explicitly rules this out (Decisions → Per-field placement table, “Per-company override = No” for every identity field). Doing it anyway would be a quiet contradiction.

Rejected.

(c) Move identity to users columns + user_public_profile for only the long-tail fields

Section titled “(c) Move identity to users columns + user_public_profile for only the long-tail fields”

Hybrid: users gains bio and specializations (small fields used everywhere). user_public_profile only holds slug, verifiedAt, coverPhotoUrl, links (less common).

  • Pros: marginally fewer joins for the common “render a coach card” case.
  • Cons: boundary is arbitrary — every future field needs a debate about which table it lives in. The single-table-for-all-public-profile rule is simpler and cleaner.

Rejected.

New: GET /me/public-profile

  • operationId: meClientGetPublicProfile.
  • Security: BearerAuth (existing pattern).
  • Response 200: UserPublicProfileDto.
  • Errors: 401 (unauthenticated).

New: PATCH /me/public-profile

  • operationId: meClientUpdatePublicProfile.
  • Security: BearerAuth.
  • Request body: UpdateMyPublicProfileDto (see D7 for shape).
  • Response 200: UserPublicProfileDto (updated row).
  • Errors:
    • 400 errors.profile.validation — body schema invalid.
    • 401 — unauthenticated.
    • 409 errors.profile.slug_taken — UNIQUE collision on slug.

Modified: coach / contributor previews on activity DTOs

ContributorMemberPreviewDto keeps its name (the field-shape rename would force a cascade of consumer renames for no semantic gain). Its fields are restructured:

  • BEFORE (today): publicName, avatarUrl, bio, specializations, links directly on the member preview, sourced from companyMembers.publicName etc.
  • AFTER: same field names, same JSON shape — but values are sourced from users.global_name / users.avatar_url / user_public_profile.bio / .specializations / .links via the join the service performs. The DTO is field-stable on the client surface; the projection moves. The business surface DTO for the same logical entity (ContributorMemberPreviewAdminDto) is restructured under user.* / user.publicProfile.* — see D9 “Field-shape per surface”.

Rationale: keeping the field names on the client surface lets the v4 ADR ship without renaming generated TypeScript types in tktspace-web or gym_app. The business surface accepts the restructure because admins benefit from the clearer “public profile read-only block” semantics.

Unchanged: SessionCoachClientDto

The DTO already only carries id, publicName, avatarUrl (verified at lines 2476-2486 of the current client contract). After the migration those values come from users.global_name and users.avatar_url — field names stay; data source moves.

Modified: CustomerProfileDto (returned by GET /companies/{companyId}/me)

  • ADD nameLocked: booleantrue when the customer row’s userId is set. The client surface uses this to show the user that their name is now sourced from their global profile, not the company’s CRM entry. The name field on this DTO is already populated from a service-side COALESCE (see D5 read semantics); nameLocked lets the client UI explain it.

New schemas (no new paths in this ticket — the customer admin CRUD isn’t yet in the contract; we add the schemas needed by today’s reads, and document the 409 on the update path that the spec mandates):

  • EmbeddedUserPublicProfileDto — read-only block for member-admin DTOs. Same JSON shape as UserPublicProfileDto on the client surface but intentionally a separate schema in the business file (the contracts are intentionally not shared — see CLAUDE.md “never share types between surfaces”). Carries globalName, avatarUrl, bio, specializations, links, slug, verifiedAt, coverPhotoUrl.
  • Embed user: EmbeddedUserDto (with nested publicProfile: EmbeddedUserPublicProfileDto) into every member-admin DTO that today carries identity fields directly:
    • ContributorMemberPreviewAdminDto (currently at lines 1718-1741 of the business contract).
    • CoachPreviewDto (currently at lines 1840-1856).
  • These admin DTOs DROP the direct bio / specializations / links fields. publicName and avatarUrl are RENAMED to live under user.globalName / user.avatarUrl in the response. The contract patch encodes this restructure.
  • ADD roleLabel to whatever member-admin DTO carries member info on the business side. CoachPreviewDto does NOT add roleLabel (it’s a coach card for sessions, not a member admin record) — only the full member-admin payload gets it. Since the contract doesn’t yet declare GET /members or PATCH /members/{id}, this ticket only ADDS the field to ContributorMemberPreviewAdminDto. A future “members admin” ticket will expose MemberAdminDto with the full read shape.

Modified: UpdateContributorDto — no change. Already trimmed in v3 to {roles?, order?}. v4 doesn’t touch DTOs of the contributor endpoints; the read shape change is in the response.

New: MemberRoleLabelUpdateDto — NOT in this ticket. The “edit member” admin endpoint isn’t in the contract yet (the spec doesn’t require us to add it). The backend UpdateMemberDto (in libs/features/companies/src/lib/dto/) gains roleLabel?: string; when a future ticket promotes the member-admin endpoints into the contract, the roleLabel field rides along.

Customer admin operations (deferred contract surface):

The customer admin CRUD (GET /customers, POST /customers, PATCH /customers/{id}, DELETE /customers/{id}) is implemented in the backend but not yet declared in the business OpenAPI contract. The spec requires a 409 on PATCH /api/business/customers/{id} when name is present and userId is set. This is enforced at the service layer (see D5). When customer CRUD is brought into the contract in a future ticket, the 409 response and the nameLocked boolean on the read DTO are documented there. This ticket adds the schemas as forward-compat fragments only (see Contract patches → business).

Not affected. The super-admin contract is untouched.

ContractTouched?What changes
contracts/client.openapi.yamlYESUserPublicProfileDto (NEW), UpdateMyPublicProfileDto (NEW), GET /me/public-profile (NEW), PATCH /me/public-profile (NEW). ContributorMemberPreviewDto keeps its flat field shape (intentional — see D9 “Field-shape per surface”); schema description updated to reflect the new data source. CustomerProfileDto adds nameLocked: boolean.
contracts/business.openapi.yamlYESEmbeddedUserDto (NEW), EmbeddedUserPublicProfileDto (NEW). ContributorMemberPreviewAdminDto restructured under user.* / user.publicProfile.* (intentional divergence from the client surface — see D9); roleLabel and internalNotes exposed as editable per-tenant fields on member-admin DTOs. CoachPreviewDto restructured: publicName / avatarUrl move under user.globalName / user.avatarUrl (the DTO already only carries those two identity fields plus id). New customer-admin fragments added as forward-compat hooks.
contracts/super-admin.openapi.yamlNONo change.

Field-level differences between surfaces (and why)

Section titled “Field-level differences between surfaces (and why)”
  • slug — exposed on client (the user reads + writes their own slug) AND on business (admin sees the slug as part of read-only public profile). Same field name, same semantics, both surfaces. No leak risk — slug is public by design (it’s literally the public URL handle).
  • verifiedAt — exposed read-only on BOTH surfaces. Client reads it to render the verified badge on their own profile; business reads it to render the same badge for admins. Neither surface accepts it on PATCH. Admin-set / system-set only (no endpoint to write it in this ticket — placeholder).
  • internalNotes — business only. Not on EmbeddedUserPublicProfileDto; it lives on the member row, not on the user. Client must never see this.
  • nameLocked — both surfaces. Client gets it inside CustomerProfileDto (so the user understands their name is global); business gets it inside the future customer-admin DTO (so the admin sees the input disabled). Same boolean, same source (customer.userId IS NOT NULL).
  • roleLabel — business only. This is a per-company display label (“yoga instructor here”) set by the admin; it’s a member-relationship property, not a person property. The client surface has no concept of “this person’s role inside that specific company” — when the client needs to render a coach, it uses user.globalName, full stop.
  • avatarUrl / coverPhotoUrl write — neither in the UpdateMyPublicProfileDto for v1 (see D7). Upload-based routes (POST /me/avatar already exists; cover-photo upload is a future ticket). The fields are returned on the read DTO.
  • bio / specializations / links — readable on BOTH client and business (business reads through user.publicProfile.*). Writable only on client (the owner). The business admin DTO that embeds user.publicProfile is intentionally not editable — even if an admin PATCHed those fields in the embedded path, the backend service for member admin updates would silently strip them via whitelist: true.

Drizzle preview (lands in libs/shared/data-access-db/src/lib/schema/users.schema.ts):

export const userPublicProfiles = usersSchema.table('user_public_profile', {
id: uuid('id').primaryKey().default(sql`gen_random_uuid()`),
userId: uuid('user_id')
.notNull()
.unique()
.references(() => users.id, { onDelete: 'cascade' }),
bio: text('bio'),
specializations: text('specializations').array(),
links: jsonb('links').$type<{ label: string; url: string }[]>(),
slug: text('slug').unique(),
verifiedAt: timestamp('verified_at', { withTimezone: true }),
coverPhotoUrl: text('cover_photo_url'),
createdAt: timestamp('created_at').defaultNow().notNull(),
updatedAt: timestamp('updated_at').defaultNow().notNull(),
});
export const userPublicProfilesRelations = relations(userPublicProfiles, ({ one }) => ({
user: one(users, { fields: [userPublicProfiles.userId], references: [users.id] }),
}));
export type UserPublicProfile = typeof userPublicProfiles.$inferSelect;
export type NewUserPublicProfile = typeof userPublicProfiles.$inferInsert;

Indexes:

  • user_public_profile_user_id_unique (auto from .unique() on column).
  • user_public_profile_slug_unique (auto from .unique() on column). Postgres treats NULLs as distinct, so plain UNIQUE allows many users without slugs.

Drizzle preview — edit libs/shared/data-access-db/src/lib/schema/companies.schema.ts lines 56-74:

export const companyMembers = companiesSchema.table('company_member', {
id: uuid('id').primaryKey().default(sql`gen_random_uuid()`),
userId: uuid('user_id').notNull().references(() => users.id),
companyId: uuid('company_id')
.notNull()
.references(() => companies.id, { onDelete: 'cascade' }),
role: CompanyMemberRoleEnum('role').default('MANAGER').notNull(),
isActive: boolean('is_active').default(true).notNull(),
roleLabel: text('role_label'), // NEW — D3
internalNotes: text('internal_notes'), // NEW — D3. Per-tenant
// admin notes about the
// member. Admin-only;
// never on the client
// surface. Matches the
// spec's per-field
// decision table.
// DROPPED: publicName, bio, avatarUrl, specializations, links
createdAt: timestamp('created_at').defaultNow().notNull(),
updatedAt: timestamp('updated_at').defaultNow().notNull(),
});

NEW table: companies.company_member_legacy_identity

Section titled “NEW table: companies.company_member_legacy_identity”

Drizzle preview (lands in companies.schema.ts just below companyMembers):

export const companyMemberLegacyIdentity = companiesSchema.table('company_member_legacy_identity', {
id: uuid('id').primaryKey().default(sql`gen_random_uuid()`),
companyMemberId: uuid('company_member_id').notNull(), // soft ref, no FK
companyId: uuid('company_id').notNull(), // soft ref
userId: uuid('user_id').notNull(), // soft ref
publicName: text('public_name'),
bio: text('bio'),
avatarUrl: text('avatar_url'),
specializations: text('specializations').array(),
links: jsonb('links').$type<{ label: string; url: string }[]>(),
archivedAt: timestamp('archived_at').defaultNow().notNull(),
});

No FKs by design — this is a backup, not a live reference. Removed in a follow-up migration one release cycle from now.

No CHECK constraint, no trigger. The 409 lives in CompanyCustomersService.update, with code errors.customer.name_locked. A DB constraint would be overkill and would surface as 500s instead of clean 409s when the UI accidentally sent name.

IndexTableTypeWhy
user_public_profile_user_id_uniqueusers.user_public_profileUNIQUE1:1 with users
user_public_profile_slug_uniqueusers.user_public_profileUNIQUEpretty-URL handle
(none new)companies.company_memberdrops only, no new indexes
(none)companies.company_member_legacy_identitybackup table; no live reads expected
ConcernLibraryFile(s)
user_public_profile schemalibs/shared/data-access-dbsrc/lib/schema/users.schema.ts
companyMember schema editslibs/shared/data-access-dbsrc/lib/schema/companies.schema.ts
company_member_legacy_identity backup tablelibs/shared/data-access-dbsrc/lib/schema/companies.schema.ts
Migration fileslibs/shared/data-access-dbmigrations/*.sql (generated by drizzle-kit generate; the conflict-resolved backfill needs a HAND-INSERTED block — see drafts/migration-global-user-identity.sql)
PATCH /me/public-profile + GET /me/public-profilelibs/features/user-profilesrc/lib/user-profile.controller.ts (extend), src/lib/user-profile.service.ts (new methods), src/lib/dto/update-my-public-profile.dto.ts (NEW), src/lib/dto/user-public-profile.dto.ts (NEW), src/lib/user-profile-client.module.ts (no change — controller is already imported)
Customer write-guard + nameLocked read fieldlibs/features/companiessrc/lib/services/company-customers.service.ts (update method), src/lib/dto/update-customer.dto.ts (DTO already exists — no field change needed; service enforces the rule), src/lib/dto/customer-response.dto.ts (add nameLocked to read DTO if it exists; else add the field projection in service)
Linking-flow backfill into users.globalNamelibs/features/authsrc/lib/services/auth-sync.service.ts (extend the existing customer-linking branch)
Member-admin DTO restructure (read user.publicProfile.*)libs/features/companiessrc/lib/dto/member-*.dto.ts (whichever currently carries publicName/bio/avatarUrl); read-time JOIN added in company-members.service.ts
Contributor read DTO restructure (read member.user.publicProfile.*)libs/features/activitiessrc/lib/services/activity-contributors.service.ts (JOIN companyMembers → users → userPublicProfiles); src/lib/dto/activity-contributor.dto.ts (response DTO updates — field-stable on the client surface, restructured under user.* on the business surface; data source moves on both)
Coach preview restructure for sessionslibs/features/activitiessrc/lib/services/sessions.service.ts (JOIN through userPublicProfiles); preserve the existing CoachPreviewDto field shape on the client surface, restructure on the business surface

No new libs/shared/ utility is needed. The “read identity through user.publicProfile” projection is a normal Drizzle LEFT JOIN; a tiny helper to assemble the embedded shape is encapsulated inside each feature.

  • client/src/app/features/dashboard/member/pages/* — split the member edit page into TWO visual sections:
    • Global profile (read-only). Read off member.user.publicProfile.*. Renders global name, avatar, bio, specializations, links, slug, verifiedAt badge, cover photo. Shows a Taiga tui-notification with “Public profile is owned by the user — ask them to update if needed.” No formControlName bindings — pure display.
    • Company-specific (editable). Existing FormGroup, plus roleLabel: FormControl<string | null>(null) and role, internalNotes, isActive. The form submit hits the existing member-update endpoint with the trimmed payload.
  • client/src/app/features/dashboard/customers/forms/* and pages/* — customer edit form must disable the name input when the customer row is nameLocked = true (<input formControlName="name" [readOnly]="customer.nameLocked">). A small notice explains that online customers’ display name comes from their global profile. On submit, the form must NOT include name in the PATCH body when nameLocked is true — defensive client-side guard mirroring the server-side 409.
  • client/src/app/features/dashboard/activities/* — contributor pages already use member.publicName via the v3 schema. After v4, the generated DTO will instead expose member.user.globalName (and optionally member.user.publicProfile.bio etc.). All template references like c.member?.publicName become c.member?.user?.globalName. TypeScript compile errors after npm run generate:api flag every stale reference — the intended forcing function.
  • client/src/app/core/api/** — regenerated via npm run generate:api.
  • Taiga components used: tui-avatar for global avatar in read-only block, tui-badge for verifiedAt checkmark, tui-chip / tui-tag for specializations, tui-notification for the ownership notice.
  • src/app/pages/event/* — coach previews. After regen, the ContributorMemberPreviewDto schema still has publicName, avatarUrl, bio, specializations, links (field-stable on the client surface per D9 “Field-shape per surface”); web only needs to regenerate. NO template changes expected.
  • src/app/pages/profile/* — if the web app exposes a user-self profile edit screen, add a “Public profile” section that calls meClientUpdatePublicProfile. Out of scope for v1 unless product asks; the spec’s primary edit path is the mobile gym app.
  • src/app/core/api/** — regenerated via npm run generate.
  • ng-openapi-gen.json + swagger.json — the client contract feeds the regen; no manual API edits.
  • Not affected. Landing has no profile rendering.

Affected apps: apps/gym_app ONLY. apps/tickets_app does not render coach / contributor / global identity flows today (verified during v3 contributors work — grep history in adrs/contributors-must-be-members.md). tickets_app regenerates its API client as a no-op.

Affected shared packages:

  • packages/api — Chopper regen via melos run sync:spec && melos run generate:api. The regenerated swagger_api.swagger.dart carries the new endpoints (meClientGetPublicProfile, meClientUpdatePublicProfile) and the new DTOs (UserPublicProfileDto, UpdateMyPublicProfileDto).
  • packages/profile — NEW screens / state:
    • Edit public profile modal/sheet under packages/profile/lib/src/modals/edit_public_profile_modal.dart (NEW). Form fields: globalName, bio, specializations (chip input), links (label + url pairs), slug (with debounced availability hint — server returns 409 on collision; UI shows inline). NO avatarUrl / coverPhotoUrl / verifiedAt fields. The existing edit_profile_modal.dart keeps handling /me (name, language, birthDate) — splitting “core me” from “public-facing me” matches the backend split.
    • Read-only view for the user’s own public profile, accessible from the profile page. Lives next to profile_page.dart. Shows avatar (uploaded via existing /me/avatar flow), name, bio, specializations (chips), links (tappable), slug, verified badge if verifiedAt != null, cover photo if coverPhotoUrl != null.
  • packages/auth, packages/checkout, packages/core, packages/notifications, packages/ui — no direct change.
  • packages/i18n — IS touched. New i18n keys land under packages/i18n/assets/i18n/{en,ru,uk}.json in the profile.public.* group (title, hints, slug helper text, validation errors including slug_invalid, slug_reserved, slug_taken). The spec’s mobile-packages frontmatter is amended in the same MR to include i18n so build-phase tooling counts this package as in-scope.

apps/gym_app/lib/pages/activity/activity_page.dart — coach preview rendering. After regen, Contributor.member.publicName is still present as a field-stable name on the client surface. No template change required for v4. Only apps/gym_app/lib/pages/profile/ (or wherever the user’s “me” tab lives) needs to gain the “Edit public profile” entry point. The existing MyProfile page imports packages/profile’s tktspace_profile.dart barrel — adding the new modal there propagates automatically.

Offline considerations. The public-profile read is small (a few KB); no offline cache is mandatory. The PATCH must surface server 409 (errors.profile.slug_taken) as an inline form error, not a global toast — matches the existing gym-app form-error pattern.

Deep-linking. Slug-based vanity URLs (/coach/{slug}) are not in scope for v1. The slug column exists for forward compatibility; routing is added by a future ticket.

No new app added. Brand/flavor setup unchanged.

  1. Migration race on multi-instance backfill. The conflict-resolved backfill from companyMember into user_public_profile walks every row, so two instances running migrations concurrently could write the same row twice. Mitigation: Drizzle migrations run via the project’s plain-SQL runner, single-process — verified in the contributors-must-be-members ADR Migration notes. We document explicitly that the migration must NOT be run on a hot multi-pod deployment, only via the standard one-shot release process.
  2. Conflict CSV review burden. Diverging per-company values (a coach with three different bios across three gyms) write a row to data/global-user-identity-conflicts.csv. On dev that’s a small handful; on prod it’s an open question. Mitigation: the migration self-counts before and after; if the conflict count exceeds 50 rows the build engineer or the on-call team member who runs the deploy reviews the file with product before the release ships. Logged in the migration’s NOTICE output so it lands in deployment logs.
  3. v4 ADR amendment carries behavioural changes for shipped features. Every consumer that renders a ContributorMemberPreview moves its data source. Field-stable DTOs (we kept names) reduce blast radius, but the displayed VALUES change (a coach who differed between gyms will now show one canonical name everywhere — by design). The conflict CSV is the audit trail.
  4. Customer linking backfill races with concurrent admin edits. If an admin is editing companyCustomers.name at the same instant the user signs up and the linking flow fires, the backfill could copy a stale name into users.globalName. Mitigation: the backfill runs inside the same auth-sync.service transaction as the link UPDATE, so it sees a snapshot — the worst case is that the admin races and overwrites their own change on the next save. Acceptable.
  5. Slug collisions on first use. When the slug feature flips on, two users could submit the same slug simultaneously. The DB UNIQUE
    • service 409 mapping handles this cleanly. Tested via integration test (Phase C).
  6. verifiedAt write leak. A misbehaving client could try to PATCH verifiedAt. Mitigation: not in the UpdateMyPublicProfileDto → stripped by whitelist: true. Defence in depth: service code does not include the field in its UPDATE set. Integration test asserts a PATCH with verifiedAt in the body leaves the row unchanged.
  7. internalNotes add on companyMember — domain-doc / schema gap closed. The current companyMember schema has no internalNotes column; companyCustomer has its own internalNotes (per-tenant customer notes — different field, different table). The spec’s per-field decision table assigns “Internal admin notes” to companyMember, and this ticket closes the gap by adding the column. The field is admin-only (business surface) and never exposed on the client surface. The spec’s AC checklist is amended in the same MR to make this explicit.

Phased, single migration, no feature flag.

  • Phase 0 — Audit on dev + prod. Run the conflict-count query (see drafts/migration-global-user-identity.sql for SQL). If conflict count is unexpectedly high (>50), product reviews the CSV before release.
  • Phase 1 — Migration (one drizzle-generated file, hand-edited backfill block). Sequence inside the file:
    1. CREATE TABLE user_public_profile.
    2. CREATE TABLE company_member_legacy_identity.
    3. INSERT INTO company_member_legacy_identity SELECT ... — atomic snapshot of every populated identity field on companyMember.
    4. INSERT INTO user_public_profile (user_id, bio, specializations, links) SELECT ... — conflict-resolved (most-recently-updated row wins per user; conflicts also row-written to a TEMP TABLE tmp_global_user_identity_conflicts and dumped to NOTICE so the deployment logs preserve them).
    5. UPDATE users SET global_name = ... WHERE global_name IS NULL — same conflict resolution for global name (only when the user had no global name yet).
    6. UPDATE users SET avatar_url = ... WHERE avatar_url IS NULL — same.
    7. ALTER TABLE company_member ADD COLUMN role_label text.
    8. ALTER TABLE company_member ADD COLUMN internal_notes text — unconditional (see D3; admin-only per-tenant field).
    9. ALTER TABLE company_member DROP COLUMN public_name, ... links.
  • Phase 2 — Backend deploy. New endpoint + service changes ship. Contract regen kicks off consumer rebuilds.
  • Phase 3 — Consumer deploys (parallel). Business, web, gym_app regenerate their clients; templates / forms updated; deployed.
  • Phase 4 — Conflict review. Product reads data/global-user-identity-conflicts.csv (extracted from the deployment NOTICE log into a CSV) and reaches out to users whose global name was chosen vs the alternatives. Out of scope for the technical migration; documented as a product follow-up.
  • Phase 5 — Backup table cleanup. Drop target: release 2026.07 (the second scheduled release after Phase 1 ships). A new migration drops company_member_legacy_identity. Tracked as a separate follow-up ticket; the named release prevents the table from calcifying past its retention window.

No feature flag. The migration is one-shot data restructuring; the endpoints are additive (existing /me, /me/avatar, etc. all keep working). The flag-vs-migrate tradeoff is documented in the spec.

Phase B test-writers use this table to know where each AC’s tests live. File paths are conventions — backend tests are co-located with the feature they cover.

AC (from spec)Test file(s)
New table user_public_profile exists with shape + 1:1 FKlibs/shared/data-access-db/test/schema/user-public-profile.spec.ts (schema/migration smoke)
publicName/avatarUrl stay on userslibs/features/user-profile/test/user-profile.service.spec.ts (read projection includes both)
companyMember loses identity fields after backfilllibs/shared/data-access-db/test/migrations/global-user-identity.spec.ts (migration integration test)
Migration backfills cleanly with conflict reportlibs/shared/data-access-db/test/migrations/global-user-identity.spec.ts (asserts NOTICE output + winner selection)
companyMember.roleLabel (text) addedlibs/features/companies/test/company-members.service.spec.ts (CRUD path includes roleLabel)
companyMember.internalNotes (text) added; admin-onlylibs/features/companies/test/company-members.service.spec.ts (asserts internalNotes editable on business surface, whitelist: true strips from client-surface DTOs — covered in client surface DTO tests under libs/features/companies/test/dto/)
Identity DTOs read via user.* (not member.*)libs/features/activities/test/activity-contributors.service.spec.ts; libs/features/activities/test/sessions.service.spec.ts
Business panel surfaces global profile read-onlylibs/features/companies/test/dto/contributor-member-preview-admin.dto.spec.ts (DTO shape includes nested user.publicProfile)
Web + mobile render coach previews from user.*Consumer-side template tests live in tktspace-web and tktspace-mobile-app/apps/gym_app; out of scope for this backend ADR but flagged for Phase B consumer test-writers
Reviews table designed (not implemented)None — design-only AC; covered by ADR text
PATCH /api/business/customers/{id} 409 when name + userId IS NOT NULLlibs/features/companies/test/company-customers.service.spec.ts (update guard)
Linking flow backfills users.globalNamelibs/features/auth/test/auth-sync.service.spec.ts (single-customer + multi-customer determinism cases)
Read paths display users.globalName when userId setlibs/features/companies/test/company-customers.service.spec.ts (COALESCE projection)
PATCH /api/client/me/public-profile new endpointlibs/features/user-profile/test/user-profile.controller.spec.ts (PATCH happy path + slug validation + whitelist: true strips verifiedAt)
Business “view member” renders global profile read-onlylibs/features/companies/test/dto/contributor-member-preview-admin.dto.spec.ts
ADR contributors-must-be-members v4 amendmentRepo-level review — no test file (ADR text only)

The mapping is deliberately rough: Phase B test-writers will design the case-level matrix. This table only unblocks the “where do my tests live?” question.

  • Slug squatting / reserved-handle expansion beyond the v1 denylist (me, admin, support, coach, api, business, superadmin, auth). Punted to a future “slugs are routable” ticket (CU-TBD) — the v1 denylist is enough until slugs are actually surfaced as public URLs.
  • Cover photo upload route. POST /me/cover is referenced as a future addition in D7; tracked as CU-TBD. Not in scope here.

v3 amendment — Symmetric per-surface identity (2026-05-27)

Section titled “v3 amendment — Symmetric per-surface identity (2026-05-27)”

The original spec treated users as “one row per real person.” This is wrong: there are three independent Supabase projects (admin / client / superadmin) and our users.users table holds the union of admin + client (superadmin doesn’t sync into it — see superadmin-jwt.strategy.ts). UUIDs from different Supabase projects never collide, so a real person who uses both surfaces has two independent users rows. The same email can belong to a business-Ivan and a client-Ivan as two different system identities.

  • v1/v2 phrased the goal as “one global identity per person.” Reality is “one identity per users row, where users.id = auth.users.id from the issuing Supabase project.”
  • Phase C added GET/PATCH /api/client/me/public-profile and EditPublicProfileScreen in gym_app — but did NOT add the symmetric business-side endpoint. Asymmetric.
  • Docs claimed “single identity across surfaces” — incorrect.

Symmetric per-surface model. Both business and client surfaces have their own /me/public-profile endpoint, gated by their respective JWT strategy, operating on their respective users row. Each user owns and edits their profile; cross-surface reads (coach card on client side, customer name on business side) traverse the users table by FK but never edit across the boundary.

Concretely:

  • PATCH /api/business/me/public-profile (new in v3) — business user edits own profile. Gated by AdminJwtGuard (Admin Supabase).
  • PATCH /api/client/me/public-profile (existing from v1) — client user edits own profile. Gated by ClientJwtGuard (Client Supabase).
  • Both endpoints share UserPublicProfileService.updatePublicProfile(userId, dto) internals — same slug normalisation, same verifiedAt strip, same UPSERT logic.
  • DTOs (UserPublicProfileDto, UpdateMyPublicProfileDto, PublicProfileLinkDto) intentionally duplicated in both contracts (contracts/client.openapi.yaml + contracts/business.openapi.yaml) per the repo’s “never share types between surfaces” convention.

New schema column — users.scope ('business' | 'client')

Section titled “New schema column — users.scope ('business' | 'client')”

Added in migration 0041_users_scope.sql:

ALTER TABLE users.users ADD COLUMN scope text
CHECK (scope IS NULL OR scope IN ('business', 'client'));
-- Backfill via inferred relationships:
UPDATE users.users SET scope = 'business'
WHERE id IN (SELECT user_id FROM companies.company_member);
UPDATE users.users SET scope = 'client'
WHERE id IN (SELECT user_id FROM companies.company_customer WHERE user_id IS NOT NULL)
AND scope IS NULL;
-- Orphans (no relations) → NULL; AuthSyncService fills on next login.

The column is nullable initially to allow gradual backfill; AuthSyncService.validateBusiness() / validateClient() (refactored) write the scope on each new auth-sync. A separate ticket can flip the column to NOT NULL once all rows are populated.

Superadmin is not in this enum because superadmin-jwt.strategy.ts does NOT invoke AuthSyncService — superadmin users never get a users row. Their identity is JWT bearer + an external Supabase project, full stop.

  • Migration 0040 (drop companyMember.{publicName,bio,avatarUrl,specializations,links}user_public_profile) — still correct. It’s a business-scope unification: one business-Ivan, one profile across all his companyMember rows.
  • Customer name-lock guard + COALESCE projection + nameLocked: boolean — still correct (client-scope concern, doesn’t cross to business).
  • Admin member-edit split (read-only global section + editable per-company) — still correct: admin sees the member’s business-side profile read-only.
  • ADR contributors-must-be-members v4 amendment — still correct: identity reading via member.user.publicProfile.* is the business-Supabase user’s profile.

Why we are NOT adding scope as required (yet)

Section titled “Why we are NOT adding scope as required (yet)”
  • Backfill is straightforward for rows with relations (companyMember → business, companyCustomer → client) but ambiguous for orphans — users who logged in once and never interacted with anything. Flipping to NOT NULL would force a guess.
  • Auth-sync now writes the value on every login, so orphans will heal naturally.
  • A follow-up ticket can flip NOT NULL once count(*) WHERE scope IS NULL = 0 is stable for a release cycle.
  • Cross-surface identity unification — making business-Ivan and client-Ivan recognise each other across Supabase projects is a much bigger ADR (consolidate to one Supabase OR add a person table linking by email/phone). Out of scope here.
  • Removing client-side public profile — was considered, rejected: clients have legitimate use for bio/specializations/links (future leagues, player profile, comments-on-coaches with bio context).

v3 (2026-05-27) — symmetric per-surface identity; added scope column

Section titled “v3 (2026-05-27) — symmetric per-surface identity; added scope column”
  • Recognised the spec’s “one identity per person” was wrong: 3 Supabase projects, admin + client both write to users.users, superadmin doesn’t.
  • Added symmetric business-side endpoint GET/PATCH /api/business/me/public-profile.
  • Added users.scope text column (nullable) with auth-sync backfill via validateBusiness() / validateClient() refactor.
  • Migration 0041_users_scope.sql — column add + relationship-based backfill.
  • Patched contracts/business.openapi.yaml — new endpoint + DTO schemas (PublicProfileLinkDto, UserPublicProfileDto, UpdateMyPublicProfileDto), intentionally duplicated from client.openapi.yaml per repo convention.
  • Business panel: added “My profile” page for self-edit.
  • Docs updated: identity context, user.mdx, user-public-profile.mdx, glossary.mdx — phrased correctly as “per-surface identity, symmetric on business and client.”

v2 (2026-05-26) — addressed critic blockers 1/2/3, folded high-value NITs.

Section titled “v2 (2026-05-26) — addressed critic blockers 1/2/3, folded high-value NITs.”
  • Blocker 1: migration DO $$ block now uses a separate loser RECORD variable for the inner conflict-emit loop so the outer cursor’s rec.user_id is no longer clobbered. Added IF NOT EXISTS index on companies.company_member(user_id) before the backfill (supports the per-user scan).
  • Blocker 2: committed the companyMember.internalNotes add unconditionally. Removed every “if reverting” / “if product disagrees” hedge from D3, Risks #7, Rollout step 8, and the Drizzle preview comment. Spec amended in the same MR (added AC line; added i18n to mobile-packages).
  • Blocker 3: added D9 subsection “Field-shape per surface — divergence by design” pinning the flat-client / nested-business divergence. Audited and qualified every “field-stable” mention in the ADR to “field-stable on the client surface” where it had read as a global statement (Surface impact table, contributor DTO description, backend module placement, tktspace-web section).
  • NIT (high-value): added company_member_user_id_idx to the migration with rationale comment.
  • NIT (high-value): pinned multi-company linking determinism in D6 — ORDER BY company_customer.created_at ASC, id ASC LIMIT 1.
  • NIT (high-value): pinned slug normalisation (auto-lowercase + collapse- hyphens + regex), denylist constant, slug-collision race-safe UPSERT, and GET /me/public-profile 200-all-null synthesise behaviour in D7.
  • NIT (high-value): added multi-tenancy edge case statement in D7 (any authenticated user can edit own profile, identity-owned not company-gated).
  • NIT (high-value): replaced auth-sync.service.ts line numbers in D6 with a method-name reference (linkUnlinkedCustomersByEmail).
  • NIT (high-value): named the conflict-CSV operator (build engineer / on-call deploy runner) in Risks #2.
  • NIT (high-value): set concrete drop target (release 2026.07) for company_member_legacy_identity in D4, Rollout Phase 5, and the migration footer.
  • NIT (high-value): added AC → test-file mapping table at the end of the Decision section.
  • NIT: added Open Questions section punting slug squatting and tracking cover-photo upload route to CU-TBD.

STATUS: READY_FOR_REVIEW