Profile Schema
Status Note
This doc captures the durable profile schema foundation from #9 through #13, plus later extensions in #20, #22, #23, #25, #26, #30, #31, #32, #33, #82, #90, the DJ lookup genre slice, and the first file-backed media-kit and bounded appearance slices.
The implemented schema is intentionally narrow. It establishes one shared profiles table for people and communities plus first-slice account ownership, claim request, verification attempt, field visibility, media asset, and bounded appearance tables, without advanced moderation workflows.
One normalized link table now exists: #200 added profileExternalLinks and externalControlProofs for the claim verification platform. See External Control Proofs And Profile Links. That is a deliberate exception to the "no normalized link tables" posture above, and it is scoped to external assets — Discord servers, VRChat groups, and VRChat accounts. Aliases, authored blocks, and outbound links remain inline.
Locked Decisions
- profiles are first-class records independent from the user account that may later claim them
profileTypeis explicit and currently supportspersonandcommunitythrough a discriminated schema union- every profile has a canonical
slugthat is globally unique across people and communities - claim state, publication state, and creation provenance are separate fields
- community-submitted unclaimed records are represented by
creationSource: "community"plusclaimState: "unclaimed" - Discord no-match claim creation writes
creationSource: "self"profiles and then grants owner authority throughprofileOwners - public surfacing state is separate from ordinary publication state so valid opt-out and suppression can hide otherwise-published profiles
- account/user ownership references live in
profileOwners; provider login alone is not ownership - most broad profile editing mutations are deferred until auth and permissions are wired;
profiles:submitCommunityProfileand the claim mutations are the current auth-gated write exceptions - the community submission mutation requires a Convex authenticated identity before writing
- normalized alias and rich authored block tables are deferred to later profile presentation issues
- file-backed media-kit assets are the model for profile pictures, logos, banners, and other reusable profile images
- profile image appearance is stored as display preference metadata, not by mutating the uploaded image asset
- public profile body section ordering is bounded to known sections; duplicate entries are ignored and missing default sections are appended
- profile outbound links are currently inline typed external links; normalized link tables remain a later scaling option
- avatar and banner fields are URL placeholders for later controlled owner or concierge inputs, not ordinary community-submitted fields
- reviewed seed imports stage proposed profile facts outside
profilesuntil explicit review and a later publication/merge flow; imported candidate fields are not owner-authored fields
profiles Table
Core identity fields:
profileType:"person" | "community"slug: globally unique canonical URL handledisplayName: public display namesortName: normalized display-sort key for deterministic listingaliases: alternate names or searchable display variants kept inline for the first schema slicesearchAliases: optional private search-only variants such as handles, underscores, old spellings, or stylized forms that should match lookup/search but should not render as public aliasestags: flexible shared discovery tags that should not silently become canonical genresgenres: optional structured public genre facts for profiles, kept separate from flexible tags while normalized genre tables remain deferred
Core presentation fields:
headline: optional short label or one-line positioning statementbio: optional short public bioabout: optional longer owner-authored about sectionavatarImageUrl: optional display/avatar image URL for controlled future owner or concierge inputsbannerImageUrl: optional banner image URL for controlled future owner or concierge inputsregion: optional location or scene region texttimezone: optional time zone textoutboundLinks: optional inline typed external links for owner-authored, community-submitted, reviewed, or partner-provided profile storefront/contact links- every writer goes through
sanitizeProfileLinksinconvex/_profileLinks.ts, which rejects unknown link types and non-HTTPS URLs, resolvesvrcdninput throughparseVrcdnStreamLinksto the canonical page URL plus stream id, and stampssourcefrom the caller rather than trusting the payload:owner_authoredfor the profile PATCH API and Discord claim creation,community_submittedfor the community submit form - branded link types are pinned to their provider's hosts, because the public profile renders the type as a branded action and community submission publishes immediately for a profile the submitter does not own;
website,other,commissions,generic_storeand the custom-domain commerce types stay unconstrained - link failures are thrown as
ConvexErrorwithcode: "INVALID_PROFILE_LINK"so the reason survives production message redaction and reaches the submit form and the PATCH problem response - first-class profile link types include DJ/operator lookup needs such as
vrchat_profile,discord,soundcloud,mixcloud,twitch,youtube,spotify,bandcamp,instagram, andlinktree, plus existing website/store/commission link types - profile links may optionally set
presentation: "icon" | "copy"; lookup treats Twitch as icon-only unless a link explicitly requests copy presentation, while VRCDN stream rows remain the preferred elevated stream controls - first-slice profile genre facts include a stable
slug, canonicaldisplayName, optional shortdisplayLabel, optional featured display intent, optional aliases, optional parent genre slugs, source, confidence, explicit/inferred state, and optional external IDs such as MusicBrainz genre UUID or Wikidata QID
State fields:
claimState:"unclaimed" | "claimed_unverified" | "claimed_verified"publicationState:"draft_private" | "published"creationSource:"self" | "community" | "concierge" | "import" | "moderator"publicSurfacingState:"public" | "opted_out" | "suppressed"publicSurfacingUpdatedAt: optional timestamp for the latest public-surfacing state changepublicSurfacingReason: optional short reason for opt-out or suppression statefieldVisibility: optional per-field visibility map using"public" | "unlisted" | "private"claimedAt: optional claim timestamp, present only after claim authority is establishedpublishedAt: optional publication timestamp, present once a profile has been publishedupdatedAt: application-maintained update timestamp that every profile mutation must refreshsourceAttribution: optional inline source metadata for community-submitted records
Type-specific fields:
person.pronouns: optional short pronoun textperson.roleTags: flexible role/type tags such as DJ, VJ, host, photographer, or performercommunity.subtype: optional short subtype text such as venue, collective, brand, or agencycommunity.categoryTags: flexible category tags for community discovery and presentation
Follow-On Profile Media Kit Assets
Current recommendation:
- people and communities should share the same file-backed media-kit asset system
- profile picture/avatar, banner, primary logo, additional ordered logos, and other public image placements should reference assets instead of becoming separate one-off URL fields over time
- public profile images can be reused by event lineup and host cards when their field visibility allows the image on discovery surfaces
- user-provided public HTTPS image URLs should be treated as import sources; VRDex should reject private/internal destinations, copy bounded PNG/SVG/JPEG/WebP responses into managed object storage such as S3, and serve the VRDex-owned object as the canonical asset
- one uploaded asset can fill multiple placements, such as both profile picture and primary logo
- public UX should say
primary logoandadditional logosinstead ofnon-primaryor defaulting toalternative logo - uploaded assets can have loose labels, optional public captions, credit names and safe HTTP(S) credit links, and an owner-authored accessibility description
- PNG and SVG logos are required from day one
- unclaimed and community-submitted profiles may carry public logos/assets, but public projections must preserve claim, source, and trust labels
- public media-kit surfaces should support individual asset downloads and a zip of all public logos
- claimed owners can order up to 12 active public gallery images, select one featured image, and soft-delete or restore an item
Candidate profileAssets fields:
profileId: owning person or community profilekind: broad asset kind such asimageorlogo, kept flexible enough for later expansionstorageKey: optimized display object keysourceStorageKey: optional exact private source object key for uploads made after source preservation was introduceddownloadStorageKey: optional metadata-sanitized, full-resolution download object key in the uploaded image formatoriginalFileName: optional original upload filenamesourceUrl: optional HTTPS URL used for import-by-downloadmimeTypeandbyteSize: validated optimized display type and size- optional source/download MIME, byte-size, and SHA-256 fields for the preserved variants
label: optional loose display labelcaption: optional public caption or descriptionaltText: optional concise accessibility descriptioncredit: optional public creator or photographer creditcreditUrl: optional public HTTP(S) credit link without embedded credentialscontentSha256: normalized-content digest used to reject duplicate uploads, including recoverable removed assets, without exposing it publiclywidthandheight: validated stored dimensionsvisibility: public, unlisted, or private visibility aligned with the profile visibility modelsource: owner-authored, community-submitted, partner-provided, moderator, import, or concierge provenanceuploadedBy: authenticated subject or source attribution where availableuploadedAt,updatedAt, and optional deletion/replacement metadata
Candidate placement fields can live on the profile or in a companion placement table:
profileImageAssetIdbannerAssetIdprimaryLogoAssetId- ordered additional logo asset ids
- ordered gallery asset ids and an optional featured asset id
- compact/card display preference with an automatic fallback that uses profile image first and logo when no distinct profile image exists or the owner chooses logo-first display
- avatar appearance controls: border on/off, six-digit border color, bounded border thickness, bounded border softness, and
0..50percent roundedness from square to circle
Convex automatically provides _id and _creationTime; those are not duplicated in the schema.
Owner-triggered accessibility suggestions use a separate bounded telemetry table. It records request id, owner/profile, provider/model, result, image byte count, latency, output length, and an error code. It does not store the image or generated description.
Bounded Profile Appearance
Locked decision:
- avatar frame controls are presentation metadata only and never mutate the stored image asset
- public profile section ordering is constrained to
about,events,links,media_kit,worlds, anddetails - section ordering normalization removes duplicates, ignores unknown values, and appends any missing default sections so public pages always stay complete
- raw HTML, arbitrary CSS, premium effects, and generic page-builder blocks are outside the baseline bounded customization slice
Current recommendation:
- theme presets should remain a small enum mapped to shared design tokens when implemented, not owner-authored color strings or CSS
- the first owner-facing customization editor should stay focused on avatar frame controls and the constrained public section order
- premium animated effects and richer styling should remain a follow-on system after this calm, readable baseline is stable
Ownership And Claim Tables
Clerk provides authentication. users is VRDex's own table and remains the
identity spine every v.id("users") foreign key points at; clerkUserId links a
row to its Clerk identity. There is no authAccounts table — connected sign-in
providers live in Clerk. See
auth-sessions.md.
profileOwners stores durable profile authority:
profileId: profile receiving ownershipuserId: VRDex user that owns the profileroleKey: currently the singleton literalownerstate:"active" | "revoked"grantedByClaimRequestId: optional claim request that granted ownership
profileClaimRequests stores claim review state for Discord, VRChat, VRCLinking, and manual methods. Discord methods currently distinguish discord_person, discord_community, and the stronger discord_community_admin flow.
profileVerificationAttempts stores proof-code attempts for external proof readers. Attempts have a proof code, target type, target external id, state, expiry, and optional evidence summary.
Proof reading is split by target type:
vrchat_userandvrchat_groupattempts are read by the collector fleet on its own schedule.VRCHAT_PROOF_ADAPTER_URLis optional and deliberately unset in production; with no adapter configured, a manual "check now" reportsqueuedrather than failing.vrclinkingattempts have no collector path at all. They requireVRCLINKING_PROOF_ADAPTER_URL, because the answer comes from a community's delegated key rather than from a posted code.
Both adapters exist so provider behaviour is not hard-coded into the product
backend. See docs/backend/profile-access-and-claims.md for the claim rules
and docs/deployment/group-telemetry-collector.md for the fleet.
External Control Proofs And Profile Links
#200 splits two things a claim used to conflate: whether somebody controls an
external asset, and which profile that asset stands for. They are separate
because proving you administer a Discord server says nothing about which
community listing that server represents.
externalControlProofs records the first — durable evidence that a user
controls an asset:
userId,assetType(discord_guild|vrchat_group|vrchat_user),assetExternalIdcontrolLevel:manager|administrator|owner|selfstate:"active" | "stale" | "revoked".revokedis a decision — Discord reported the access gone, or an operator withdrew it — and carriesrevokedAtandrevokedReason.staleis only the passage of time: the revalidation sweep marks a proof whoserevalidateAfterhas passed, and re-verifying restores it toactive. Both stop backing claims and delegations; onlyrevokedsays anything happenedevidenceSourceandevidenceSummary: how control was shownevidenceSubjectId: which external identity produced the evidence. A user may verify through more than one Discord account, and a result is only authoritative about the guilds of the identity that produced itverifiedAt,revalidateAfter,lastRevalidatedAt: proofs expire. A lapsed proof stops backing claims and delegations without deleting the record
profileExternalLinks records the second — a many-to-many association between a
profile and an asset:
profileId,assetType,assetExternalId, optionalassetDisplayNamelinkRole:primary|secondary. One community may hold several servers and groups; one of each kind is primarystate:"active" | "removed"linkedByUserId: absent when an operator seeded the association rather than a claimant asserting itverifiedByProofId: the control proof that backed the link when it was made
The trust rule between them: a proof alone grants claimed_unverified
ownership. claimed_verified additionally requires an active link recorded by
somebody other than the claimant — otherwise a claim corroborates itself, and
any asset could verify any listing.
Links deliberately outlive proofs. A community that stops being administered by its original claimant keeps its association; what lapses is the authority to act on it.
Supporting tables from the same slice:
communityVrclinkingCredentials: a community's delegated VRCLinking key, stored as asecretRefonly — never the key itself — bound to the guild it is for, with rotation and consultation stampsdiscordVerificationStates: single-use OAuth round-trip statediscordVerificationWatermarks: per Discord identity, when the newest applied reconciliation read that identity's guilds, so overlapping callbacks landing out of order cannot resurrect revoked access
Reviewed Seed Import Staging Tables
Current recommendation:
- reviewed seed imports live in
seedImportBatches,seedImportCandidateProfiles, andseedImportCandidateFields - these tables preserve provenance, confidence, field visibility, review state, reviewer metadata, matched profile links, and queue-only publication metadata
- internal fake fixture tooling can create candidate rows for backend tests and review workflow development
seedImports:queueCandidatePublicationrecords a queue marker only; it does not create publicprofilesrows or overwrite existing owner-authored fieldsseedImports:publishQueuedCandidateconsumes that queue marker and is what actually creates or promotes the public unclaimed profile, copying accepted fields only and preserving each field's reviewed visibilityseedImports:bulkPublishBatchruns the same per-candidate path in cursor pages for a whole batch; see Publication- publication requires the batch's
publicationPolicyto bereviewed_publication_allowed, which an operator sets deliberately with a recorded reason; a batch with no explicit policy fails closed - owner handoff remains a separate flow: accepting a concierge handoff still publishes nothing
State Semantics
claimState describes owner authority:
unclaimed: no owner authority has been attached yetclaimed_unverified: a claimant controls the profile, but stronger verification is not completeclaimed_verified: owner control and verification are both established
publicationState describes public surfacing:
draft_private: not public and not searchablepublished: eligible for public profile pages and later discovery flows, subject to permission, trust, and opt-out rules
publicSurfacingState describes whether an otherwise-published profile is allowed to appear on ordinary public surfaces:
public: profile can appear on profile pages, search, discovery, event participant references, and linked attribution surfacesopted_out: valid owner opt-out; hide from ordinary public surfacessuppressed: moderation/safety suppression; hide from ordinary public surfaces
creationSource describes how the record entered the system. It is not an authority marker by itself; authority comes from claimState and later claim records.
fieldVisibility controls public projection surfaces for eligible fields including aliases, tags, genres, text, images, links, region/timezone, and type-specific role/category fields:
public: direct profile page plus discovery/search/card projectionsunlisted: direct profile page onlyprivate: hidden from public projections
displayName, slug, profileType, and trust labels remain public while the profile itself is public.
The owner privacy mutation currently accepts these field keys: aliases, tags, genres, headline, bio, about, avatarImageUrl, bannerImageUrl, outboundLinks, region, timezone, personPronouns, personRoleTags, communitySubtype, and communityCategoryTags.
Mutation Contracts
Convex schema validation cannot enforce conditional timestamp invariants, so profile mutations must preserve these application-level rules:
- set
claimedAtwhenclaimStateleaves"unclaimed" - set
publishedAtwhenpublicationStatebecomes"published" - patch
updatedAton every profile write
Locked decision: profiles:submitCommunityProfile is the public community-submitted unclaimed write path. It requires ctx.auth.getUserIdentity() to return a signed-in identity, generates the slug server-side, publishes the profile as creationSource: "community" plus claimState: "unclaimed", and stores narrow source attribution for later moderation and display decisions.
Current recommendation: profileClaims:createClaimedDiscordPersonProfile and profileClaims:createClaimedDiscordCommunityProfile are the explicit Discord no-match creation paths. They require Convex auth, verified email, a Discord identity VRDex has itself verified, and caller confirmation that no suitable unclaimed match exists. They create self-authored public profiles, record an approved Discord claim request, grant singleton owner authority, and leave the profile at claimed_unverified.
Current recommendation: Discord community Administrator verification remains the stronger server-authority path for community profiles. A verified Discord identity alone can create and control a new community profile, but it does not prove server administration and must not set claimed_verified by itself.
"Verified Discord identity" means a discordVerificationWatermarks row VRDex wrote through its own purpose-scoped OAuth round-trip — not a Discord sign-in method linked in Clerk. Clerk owns which providers an account can sign in with, and that says nothing about control of a Discord identity, so the two are deliberately unrelated. accounts:getLinkedProviderAccount reads the watermark.
The claimed-owner field visibility path is profilePrivacy:updateFieldVisibility. It requires an active profile owner, stores non-public field overrides on profiles.fieldVisibility, treats omitted or explicit public fields as the public default, patches updatedAt, and refreshes the profile search document so discovery follows the new field visibility.
The migrations:backfillProfilePublicSurfacingState internal mutation sets missing legacy publicSurfacingState values to "public" and fills publicSurfacingUpdatedAt so previously-written profiles keep their existing publication behavior after the surfacing-state schema addition.
The migrations:publishGatedProfiles internal mutation takes previously gated profiles live: it flips draft_private profiles to published and reindexes each one for search and vocabulary, since a flipped profile that is not reindexed stays invisible to discovery.
It only touches profiles that are draft_private and public, which is the default-private state with no explicit surfacing decision attached. It deliberately skips:
opted_outprofiles. This is the canonical "keep off ordinary public surfaces" signal and it is whatseedHandoffswrites on a prepared concierge profile, including unclaimed ones prepared for outreach but never accepted. Those were offered on the explicit promise that nothing is published, so claim state cannot be used to discard the opt-out.suppressedprofiles, which are a moderation state rather than a default.- Profiles with an accepted
profileSuppressionRequestsrow, which records someone asking not to be listed. All three request shapes are checked (profile id, slug, and pre-claim name/type), not just slug. - Claimed profiles, because publication of an owned profile is the owner's decision.
- Profiles with a live concierge handoff invitation, which are instead marked
opted_outwith reasonConcierge handoff invitation pending.A bare skip would advance the migration cursor, leaving a profile whose invitation later expires stuck atdraft_privatewith no record of why;opted_outis the same stateseedHandoffswrites on a prepared concierge profile, and the ordinary publication and suppression paths govern it from there. The migration bypasses both publication gates, so it repeats their handoff check: an invitation can reuse a legacydraft_privateprofile whose surfacing state is stillpublic, and publishing it would expose the profile while its private review link is live.
Known limitation: there is currently no owner-facing control that changes publicationState or publicSurfacingState. profilePrivacy:updateFieldVisibility controls individual field visibility only. An owner who accepts a concierge handoff therefore has no self-service path to publish their profile, and needs an operator. That gap is not addressed here.
Unlike the other migrations it is not part of migrations:runAll, because publishing profiles publicly is outward-facing and not cleanly reversible. Run it deliberately:
pnpm cx -- prod run migrations:runPublishGatedProfiles
Follow it with one world search rebuild, which covers every attribution that became visible and records world vocabulary with it:
pnpm cx -- prod run search:rebuildWorldSearchDocuments
The migration deliberately does not reindex worlds per row; that would mean one full worlds scan per migrated profile. The rebuild is delta-aware — it compares each world's stored vocabularyKeys against the rebuilt ones and records only what appeared, releasing what went away — so running it against already-indexed worlds does not re-increment existing counts.
That runner executes backfillProfilePublicSurfacingState and backfillHandoffInvitationProfileIds first. The second gives every active handoff invitation a profileId — one created before its candidate was matched carries none, which would make the migration's liveness check blind to it. A legacy profile with no publicSurfacingState would otherwise be skipped while the publication migration's cursor advanced, and running the backfill afterwards cannot make a completed migration revisit it.
Deploy-time migrations use @convex-dev/migrations and are run by migrations:runAll after production function deploys when CONVEX_DEPLOY_KEY is configured.
Initial Indexes
by_slug: canonical profile lookup and mutation-enforced slug uniquenessby_profileType_publicationState: public page/discovery entry points split by person vs communityby_publicationState_claimState: public/trust filtering for later profile listsby_publicSurfacingState_publicationState: public suppression and opt-out enforcementby_claimState_profileType: moderation and claim-review flows by claim state, with optional type splittingby_creationSource_claimState: moderation and community-submitted/unclaimed review flowsby_profileType_sortName: deterministic profile listing by typeprofileOwners.by_profileId_roleKey_state: active owner singleton enforcementprofileOwners.by_userId_state: account profile ownership lookupprofileClaimRequests.by_profileId_state: profile claim review lookupprofileVerificationAttempts.by_state_expiresAt: pending proof attempt expiry scansexternalControlProofs.by_userId_assetType_assetExternalId: one user's proof for a given asset, in any stateexternalControlProofs.by_assetType_assetExternalId_state: who currently proves control of an assetexternalControlProofs.by_state_revalidateAfter: revalidation sweepsprofileExternalLinks.by_profileId_assetType_state: a profile's active connections, primary firstprofileExternalLinks.by_assetType_assetExternalId_state: which profiles an asset backs
Implementation Boundaries
#10adds canonical slugs, validation, and uniqueness rules#11adds type-aware person/community fields and documents shared vs type-specific data#12defines read/write permission behavior#13defines claim-state transitions and trust labeling behavior#22added presentation fields and public-page rendering for avatar/banner, short bio, and longer about content#23added the authenticated community submission mutation and source attribution details#25and#26add public trust/source labeling and the first audit trail#30adds public surfacing suppression enforcement#31,#32, and#33add universal public search/discovery surfaces#82added inline typed external links for first-slice creator commerce/profile links, with publichttpsfiltering#90adds scoped vocabulary normalization for tags, roles, categories, and discovery facets- the DJ lookup genre slice adds optional inline
profiles.genresplusprofile_genrevocabulary/search indexing as the minimal bridge to a later normalized genre graph #27adds field-level visibility controls and the claimed-owner privacy update surface#31adds public search behavior and any search-specific indexing