Skip to main content

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
  • profileType is explicit and currently supports person and community through a discriminated schema union
  • every profile has a canonical slug that 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" plus claimState: "unclaimed"
  • Discord no-match claim creation writes creationSource: "self" profiles and then grants owner authority through profileOwners
  • 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:submitCommunityProfile and 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 profiles until explicit review and a later publication/merge flow; imported candidate fields are not owner-authored fields

profiles Table​

Outbound destination metadata​

Authored outbound links remain inline. Optional labelMode distinguishes an explicit custom label from automatic naming. Missing modes on legacy links retain distinct labels; only recognized generated provider labels use automatic naming. Metadata refresh does not change profile revisions, aliases, ownership proofs, or verification state.

profileLinkDestinations caches the minimal public name, entity ID, artwork source, observation time, and resolution state for exact VRChat user/group locators and Discord guild invites. Invite codes remain separate locators because their guild binding can change. Public profile, editor preview, and lookup reads attach this data as destination after their existing visibility projection. The API preserves the original label and url.

Link additions, URL changes, and newly public links request asynchronous lookups. An actual profile-page visit also requests missing metadata or a refresh when the cached observation is at least 24 hours old. Search, lookup, and editor previews only read the cache. Concurrent requests for the same destination coalesce into one queued attempt; page rendering does not wait for a provider.

There are no destination discovery crons, scheduled daily refreshes, or automatic failed-lookup retries. A failed lookup records a 15-minute cooldown by default, respecting a provider retry delay bounded to one minute through 24 hours. A later eligible profile visit requests the next attempt. Temporary failures preserve the last successful branding; confirmed invalid or inaccessible results clear it. Merely becoming stale or reaching a cooldown deadline creates no work. Existing cached data is preserved, and previously unseen destinations are discovered on visits without a backfill.

Public link references are maintained on profile edits, publication, hiding, and restoration. They do not expire because a profile has not been scanned. Removing the final known reference removes the metadata and queued work; provider access and artwork reads still recheck current public visibility.

VRChat uses the collector's authenticated transport and shared account budget. The existing assignment response carries a due-work hint from the already-read fleet row, so an idle queue causes no separate destination request or queue scan. Group short codes use the canonical api.vrchat.cloud redirect endpoint, accepting its root-relative group path without following it or forwarding credentials. Discord uses one coalesced scheduled dispatcher for requested work, preserving its one-request-per-minute budget and provider cooldown. Dispatching stops when the queue is empty. Lease recovery can finish already-requested work after a worker crash; it does not turn a completed failed lookup into an automatic retry.

workDueAt identifies actual queued work; retryEligibleAt is only an eligibility deadline. Legacy nextAttemptAt, lastReferencedAt, and sweep rows remain inert during additive rollout. Deploy backend support, then the collector hint consumer, then the web visit trigger. The idle request guarantee applies after the new collector replaces old tasks.

Artwork is served through /api/profile-link-artwork/[key], which rechecks a current public reference, fetches provider-returned HTTPS URLs on exact trusted provider hosts through the bounded importer, and emits a static 128px WebP for links or a 512px derivative for automatic profile images. Paths, sizes, and signature query formats are provider-owned. Every redirect rechecks the host and public DNS/IP boundary; download, timeout, and image-decode limits remain enforced. It authorizes the exact rendering profile, rather than scanning a truncated reference list. Sanitized bytes persist in the existing private asset bucket under profile-assets/destination-thumbnails/, keyed by the destination and source. A cold S3 cache read can return AccessDenied when the role lacks bucket-list permission. Only this thumbnail cache treats that response as a miss, imports the public image, and requires a successful cache write. Other storage failures still propagate. Fresh bytes are reused for a day; failed refreshes retain the last successful bytes and wait an hour before retrying. Concurrent requests for one source share the import. Changed sources use separate keys, and every origin request still checks current visibility.

Browser caching is limited to five minutes; shared CDN caching is disabled so it cannot bypass the visibility check. The storage lifecycle expires unused thumbnail cache objects after 90 days. This uses existing asset storage configuration and requires the checked-in profile-assets lifecycle change when deployed. No browser credentials or arbitrary source-URL parameter are accepted. Missing artwork leaves the name and platform fallback usable.

Implementation and acceptance criteria: Profile link destination names.

Automatic profile images​

When no authored profile image or primary-logo placement exists, a profile can use cached destination artwork. Hidden authored media still blocks replacement. People use only custom VRChat userIcon images, never profile overrides, banners, or current-avatar images. A matching primary VRChat connection is preferred among public outbound links; otherwise saved link order decides. Communities prefer their selected VRChat group, then selected Discord server. Without a selection, the first eligible link of each kind is used, including on unclaimed communities.

Media Kit owners can disable the automatic fallback or select community sources. These preferences are stored separately in profiles.imageFallback; fetched images are not uploaded assets. The fallback is projected consistently into profile pages, discovery, event identities, owner previews, and share cards. Both image and source-link visibility apply for the requested surface. The 512px image route rechecks the currently selected fallback before serving bytes, including after source removal or preference changes.

VRChat user artwork requires artworkType: user_icon. Legacy rows without provenance, including the former profile_picture marker (which incorrectly identified profile overrides), are suppressed immediately on new reads and old URLs fail authorization; the existing demand-driven refresh can repopulate custom pictures. Existing browser-cached bytes may remain for the established five-minute cache lifetime. Versioned derivative cache keys prevent old avatar bytes or 128px thumbnails from being reused as profile portraits. Unused cached objects expire under the existing storage lifecycle. No sweep or additional scheduled provider requests are introduced.

For the user-icon correction rollout, deploy the backend before the collector, then expire the previous VRChat user cache records once and request their referencing public profiles through profileLinkDestinations:requestForProfile. This includes resolved rows with no artwork, since a missing override does not mean a missing icon. Let the existing collector budget drain the queue, then verify both link and 512px profile image responses. Retain the old schema marker during rollout so stored rows and in-flight old collector results remain valid, but never render it.

Core identity fields:

  • profileType: "person" | "community"
  • slug: globally unique canonical URL handle
  • displayName: public display name
  • sortName: normalized display-sort key for deterministic listing
  • aliases: alternate names or searchable display variants kept inline for the first schema slice
  • searchAliases: 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 aliases
  • tags: flexible shared discovery tags that should not silently become canonical genres
  • genres: 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 statement
  • bio: optional short public bio
  • about: optional longer owner-authored about section
  • avatarImageUrl: optional display/avatar image URL for controlled future owner or concierge inputs
  • bannerImageUrl: optional banner image URL for controlled future owner or concierge inputs
  • region: optional location or scene region text
  • timezone: optional time zone text
  • outboundLinks: optional inline typed external links for owner-authored, community-submitted, reviewed, or partner-provided profile storefront/contact links
  • every writer goes through sanitizeProfileLinks in convex/_profileLinks.ts, which rejects unknown link types and non-HTTPS URLs, resolves vrcdn input through parseVrcdnStreamLinks to the vrcdn:<streamId> identifier plus stream id, exempts that identifier alone from the HTTPS rule because VRCDN publishes no page for a stream and each surface derives the endpoint it needs, and stamps source from the caller rather than trusting the payload: owner_authored for the profile PATCH API and Discord claim creation, community_submitted for 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_store and the custom-domain commerce types stay unconstrained
  • link failures are thrown as ConvexError with code: "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, and linktree, 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, canonical displayName, optional short displayLabel, 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

Provider liveness is not stored on the profile. Twitch and VRCDN are read during the server render from apps/web/src/lib/server/twitch-live.ts and apps/web/src/lib/server/vrcdn-live.ts. The server-rendered VRCDN observation may use the shared sixty-second cache. After hydration, the profile-scoped /api/profile-live/[slug]/vrcdn route always obtains a fresh provider observation for heartbeats and player-triggered sanity checks. A successful 200 confirms live immediately. After a stream was confirmed live, two fresh 401 or 404 observations at least ten seconds apart are required to confirm offline, and an intervening 200 cancels that pending transition. unavailable preserves the last confirmed presentation for at most five minutes, with client retries after approximately 15, 30, and 60 seconds; that backoff also applies while an offline confirmation is pending, so a failing provider is never re-probed on a zero-length deadline. Player-requested sanity checks run at most once per ten seconds per tab. Recurring checks never expose a loading state or replace confirmed content with an unavailable answer.

A live VRCDN stream also offers an in-site player, apps/web/src/app/_components/vrcdn-stream-player.tsx, shared with the event watch surface:

  • Playback is mpegts.js over the .live.ts transport stream — the same library and endpoint VRCDN's own preview page uses. There is no HLS to play. The event watch surface previously handed hls.js the .m3u8, so it never played a VRCDN stream at all.
  • Nothing connects until the viewer presses play. A player is unambiguously a viewer, unlike the liveness probe: plans are commonly capped at 100 while a VRChat instance holds 80–100, so connecting on page load would spend a slot on every passer-by, most heavily while an event is on. The poster state is the whole point, and teardown detaches the media element so a closed player stops holding a slot.
  • On profile pages only, the player appears once the stream is confirmed live. If a later heartbeat confirms offline while media is still playing, the badge is removed but the player remains mounted until playback stops. Heartbeat results never pause, release, or unmount active media. The event watch surface shares the component but has no provider liveness of its own: it gates on the event's opt-in and scheduled window.
  • Every valid VRCDN link has permanent profile playback controls, regardless of liveness or provenance. Open preview, the Quest and PC URLs, and their copy controls stay visible for quick reference. These controls remain available when the player appears or disappears, and VRCDN is not duplicated as a generic Links button. Community-submitted streams also receive live checks. Link provenance remains unchanged. Open preview points at panel.vrcdn.live/preview/<id>, which #217 had listed as an operator surface not to publish; #296 requires it on every valid stream because the controls are useful whether or not the stream is live, which supersedes that non-goal.

The control strip is custom (apps/web/src/components/media/vrcdn-player-controls.tsx), and each rule in it exists because replacing the browser's native controls meant inheriting a job they were doing for free:

  • No seek bar, and it must stay absent. A live MPEG-TS buffer grows and shifts under the element, so the native scrubber jittered against a timeline with nothing to seek. Hiding only the timeline is a Chromium/WebKit CSS trick that Firefox has no equivalent for, which is why the whole strip is replaced rather than patched.

  • Pausing and resuming returns to the live edge. The element keeps its timestamp while paused, so without seeking to the end of the buffered range on resume, playback falls further behind every time — and there is no scrubber left to catch up with.

  • Player events request checks, but never decide liveness. Media ended, mpegts.js LOADING_COMPLETE, player ERROR, and a wait or stall sustained for four seconds request a fresh sanity check. A clean EOF can also arrive when the CDN recycles a connection, so the player still offers a retry and the provider probes remain authoritative. The copy says Playback stopped for the same reason.

  • Volume is feature-detected, not sniffed. iOS Safari treats HTMLMediaElement.volume as read-only, so the slider is withheld there instead of sliding and changing nothing. Muting still works and stays.

  • Fullscreen is feature-detected too. iPhone Safari has no element fullscreen; requestFullscreen is absent rather than failing, so it falls back to webkitEnterFullscreen on the video, which brings iOS's own controls with it.

  • Hit areas are 44px, and the terminal message carries role="status" because it replaces a focused control. The strip is labelled per stream, since a profile can carry more than one live VRCDN link and render a player for each.

  • The strip is presentational and covered by Storybook stories wired into the component-snapshot lane. That is the only place it can be captured: it renders solely once a stream is connected, which fixtures cannot do and a real stream would charge a viewer slot for.

  • VRCDN publishes no liveness API. The signal is GET https://stream.vrcdn.live/live/<streamId>.live.ts, the transport stream a Quest client pulls. Measured against a real stream on 2026-08-10, live and then idle:

    RequestLiveIdleUnknown id
    GET .live.ts200 (video/mp2t)404401
    GET .m3u8404404404
    HEAD .live.ts200200200
  • The HLS manifest is not a liveness signal, despite being the original mechanism in #217. It answers 404 for a stream that is actively publishing, so probing it reported nobody live, ever. That issue's "verified" note covered only the negative leg — an idle stream and a bad id, both 404 for the wrong reason. HEAD is not a signal either, since it answers 200 for ids that do not exist. vrcdn.live itself is a Blazor SPA that answers 200 with its app shell for unknown paths, so the page and /api/live are out too.

  • Only 200 counts as live. A bodyless 2xx is an intermediary answering, not VRCDN handing over a stream.

  • 401 (unknown id) and 404 (idle) both render as no badge. They are worth keeping apart because this endpoint distinguishes them: a typo in an owner's own link reads 401 forever, where an idle stream reads 404.

  • Live checks report the availability of the linked stream for both Twitch and VRCDN, including community_submitted links. They do not verify stream ownership or change link provenance. BASIC approved this behavior on 2026-09-09.

  • Provider URL validation and the shared Twitch selector still ensure the channel being checked is the channel rendered on the profile.

  • Three states, not two: offline is a real 404, unavailable is a probe that could not finish. A failed probe renders no badge rather than an idle stream.

  • Observations expire after five minutes. The cache serves a stale entry while it refreshes, and a refresh that keeps throwing would otherwise leave a Live now claim standing for the length of a provider outage.

  • Nothing sweeps every stored stream on a server schedule. A stream nobody opens is never probed. An open, visible profile uses a jittered heartbeat approximately every sixty seconds, or every 120 seconds while playback is active. Heartbeats pause while the tab is hidden and resume immediately when the last check is over sixty seconds old. Concurrent heartbeat, visibility, and player signals are coalesced into one in-flight request.

  • The VRCDN request has a five-second budget. Production requests regularly exceeded the old 1.5-second budget, which made the first profile load omit a live badge even when the provider answered on a refresh. The longer budget now bounds the server-rendered initial check as an explicit latency tradeoff for a stable first render.

  • The probe touches a media endpoint, and this is the known cost of the mechanism. .live.ts ignores Range and starts pushing MPEG-TS as soon as it answers, so the probe cancels the body before reading a frame. VRCDN plans are viewer-capped and whether that brief connection spends a slot cannot be determined from outside the operator's account. Per-view heartbeats are an explicit product decision for open profiles. Server-maintained liveness for homepage or discovery surfaces remains separate research because it would probe streams with no active viewer.

State fields:

  • claimState: "unclaimed" | "claimed_unverified" | "claimed_verified"
  • publicationState: "draft_private" | "published"
  • creationSource: "self" | "community" | "concierge" | "import" | "moderator"
  • publicSurfacingState: "public" | "opted_out" | "suppressed" | "archived"
  • publicSurfacingUpdatedAt: optional timestamp for the latest public-surfacing state change
  • publicSurfacingReason: optional short reason the profile is hidden -- an opt-out, a suppression or an archival. Cleared when a profile returns to public, since it describes the current state rather than the history; why a profile was archived or restored stays in profileAuditEvents
  • fieldVisibility: optional per-field visibility map using "public" | "unlisted" | "private"
  • claimedAt: optional claim timestamp, present only after claim authority is established
  • publishedAt: optional publication timestamp, present once a profile has been published
  • updatedAt: application-maintained update timestamp that every profile mutation must refresh
  • sourceAttribution: optional inline source metadata for community-submitted records

Type-specific fields:

  • person.pronouns: optional short pronoun text
  • person.roleTags: flexible role/type tags such as DJ, VJ, host, photographer, or performer
  • community.subtype: optional short subtype text such as venue, collective, brand, or agency
  • community.categoryTags: flexible category tags for community discovery and presentation

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
  • public asset-file reads apply the same placement-to-field visibility mapping, so a previously known asset URL cannot bypass a private avatar, banner, logo, or media-kit setting; an asset with multiple active placements remains reachable when at least one placement is visible on the direct profile page
  • 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 logo and additional logos instead of non-primary or defaulting to alternative 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

Implemented profileAssets fields:

  • profileId: owning person or community profile
  • kind: broad asset kind such as image or logo, kept flexible enough for later expansion
  • storageKey: optimized display object key
  • sourceStorageKey: optional exact private source object key for uploads made after source preservation was introduced
  • downloadStorageKey: optional metadata-sanitized, full-resolution download object key in the uploaded image format
  • originalFileName: optional original upload filename
  • sourceUrl: optional HTTPS URL used for import-by-download
  • mimeType and byteSize: validated optimized display type and size
  • optional source/download MIME, byte-size, and SHA-256 fields for the preserved variants
  • label: optional loose display label
  • caption: optional public caption or description
  • altText: optional concise accessibility description
  • credit: optional public creator or photographer credit
  • creditUrl: optional public HTTP(S) credit link without embedded credentials
  • contentSha256: normalized-content digest used to reject duplicate uploads, including recoverable removed assets, without exposing it publicly
  • width and height: validated stored dimensions
  • visibility: public, unlisted, or private visibility aligned with the profile visibility model
  • source: owner-authored, community-submitted, partner-provided, moderator, import, or concierge provenance
  • uploadedBy: authenticated subject or source attribution where available
  • uploadedAt, updatedAt, and optional deletion/replacement metadata

Candidate placement fields can live on the profile or in a companion placement table:

  • profileImageAssetId
  • bannerAssetId
  • primaryLogoAssetId
  • 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..50 percent 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, and details
  • 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 ownership
  • userId: VRDex user that owns the profile
  • roleKey: currently the singleton literal owner
  • state: "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. Operational lifecycle fields distinguish queue dispatch from an actual provider check, retain bounded check outcomes and counts, and record terminal resolution time and reason.

Historical attempts also permanently reserve each short code for its normalized (targetType, targetExternalId). The by_targetType_targetExternalId_proofCode index enforces nonreuse through mutation transaction reads; the by_targetType_targetExternalId_createdAt index bounds the rolling issuance quota. Production deletion must preserve the target/code reservation separately before removing an attempt. Fixture cleanup is restricted to synthetic targets.

The attempt row itself is the troubleshooting record. Its dispatch, provider check, outcome, and resolution fields avoid a second event stream that can disagree with current claim state.

Proof reading is split by target type:

  • vrchat_user and vrchat_group attempts are read by the collector fleet on its own schedule. VRCHAT_PROOF_ADAPTER_URL is optional and deliberately unset in production; with no adapter configured, a manual "check now" reports queued only while a collector proof-path heartbeat is fresh, otherwise it reports unavailable while preserving the pending attempt.
  • vrclinking attempts have no collector path at all. They require VRCLINKING_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.

#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), assetExternalId
  • controlLevel: manager | administrator | owner | self
  • state: "active" | "stale" | "revoked". revoked is a decision — Discord reported the access gone, or an operator withdrew it — and carries revokedAt and revokedReason. stale is only the passage of time: the revalidation sweep marks a proof whose revalidateAfter has passed, and re-verifying restores it to active. Both stop backing claims and delegations; only revoked says anything happened
  • evidenceSource and evidenceSummary: how control was shown
  • evidenceSubjectId: 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 it
  • verifiedAt, 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, optional assetDisplayName
  • linkRole: primary | secondary. One community may hold several servers and groups; one of each kind is primary
  • state: "active" | "removed"
  • linkedByUserId: absent when an operator seeded the association rather than a claimant asserting it
  • verifiedByProofId: 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 a secretRef only — never the key itself — bound to the guild it is for, with rotation and consultation stamps
  • discordVerificationStates: single-use OAuth round-trip state
  • discordVerificationWatermarks: 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, and seedImportCandidateFields
  • 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:queueCandidatePublication records a queue marker only; it does not create public profiles rows or overwrite existing owner-authored fields
  • seedImports:publishQueuedCandidate consumes that queue marker and is what actually creates or promotes the public unclaimed profile, copying accepted fields only and preserving each field's reviewed visibility
  • seedImports:bulkPublishBatch runs the same per-candidate path in cursor pages for a whole batch; see Publication
  • publication requires the batch's publicationPolicy to be reviewed_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 yet
  • claimed_unverified: a claimant controls the profile, but stronger verification is not complete
  • claimed_verified: owner control and verification are both established

publicationState describes public surfacing:

  • draft_private: not public and not searchable
  • published: 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 surfaces
  • opted_out: valid owner opt-out; hide from ordinary public surfaces
  • suppressed: moderation/safety suppression; hide from ordinary public surfaces
  • archived: operator judgement that the row should not be on the site at all -- a display name that is a pasted URL, a placeholder that is not a person, a duplicate an import produced. Hidden from ordinary public surfaces, reversible, and never a claim about the person: opted_out and suppressed both record that somebody asked, and filing one of those for a data-quality problem puts a fabricated take-down in the moderation history. Written only by profileArchival, which refuses to overwrite either of the other two hidden states; an accepted suppression arriving at an archived profile replaces it, because a request from a person outranks an operator's tidy-up in both orderings. See docs/backend/private-seed-operations.md for the operator flow.

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 projections
  • unlisted: direct profile page only
  • private: hidden from public projections

displayName, slug, profileType, and trust labels remain public while the profile itself is public.

The public About section renders owner-authored about when present on a claimed profile and falls back to the factual/community bio; an unclaimed record has no owner, so it uses bio rather than presenting its longer community narrative as owner-authored personalization. The page does not render both narratives as competing sections. Each field still obeys its own visibility projection before the page receives it.

Profile link previews use a separate card projection at the canonical /<slug> route. The projection keeps the public display name, prefers the public headline over the public bio, and may use managed profile or banner media. Because an unfurl is a card surface rather than the direct profile page, fields marked unlisted or private do not enter its summary or controlled profile/banner image slots. A public primary logo may still act as the compact fallback because logo assets do not use the avatar visibility field. Generated share cards prefer a managed profile image or primary logo before a legacy external avatar URL so the image route can embed a supported same-origin asset instead of dropping to initials. The projection also carries the existing public trust state so generated cards can label unclaimed and community-submitted records with the same wording already used on public profile surfaces.

The owner privacy mutation currently accepts these field keys: aliases, tags, genres, headline, bio, about, avatarImageUrl, bannerImageUrl, mediaKit, outboundLinks, region, timezone, personPronouns, personRoleTags, communitySubtype, and communityCategoryTags.

profileMediaSubmissions is the private moderation boundary for unclaimed-profile media. It stores target and submitter IDs, the target's public slug and display name at submission time, a purpose-bound upload-intent reference, requested placement, source and public metadata, optional reviewer context, status, target-profile revisions, review disposition, approved asset link, retention timestamps, and audit-relevant decision metadata. The submission-time target identity remains the contributor-safe fallback if the profile is later hidden and privately renamed. Indexed reads are bounded by profile/status, profile/time, submitter/status, submitter/time, status/creation time, and content hash.

profileAssetUploadIntents.purpose is either owner_publish or community_proposal, and every current constructor writes it explicitly. The field remains optional in storage because production already contained consumed owner-upload intents created before the discriminator existed; an absent value retains owner-upload behavior. No backfill or migration job is required. A community proposal must explicitly use community_proposal and also pins targetSubmissionId, so upload finalization cannot consume it into a public asset without approval.

Mutation Contracts​

Convex schema validation cannot enforce conditional timestamp invariants, so profile mutations must preserve these application-level rules:

  • set claimedAt when claimState leaves "unclaimed"
  • set publishedAt when publicationState becomes "published"
  • patch updatedAt on 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_out profiles. This is the canonical "keep off ordinary public surfaces" signal and it is what seedHandoffs writes 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.
  • suppressed profiles, which are a moderation state rather than a default.
  • archived profiles, which an operator has already judged should not be on the site. Publishing one would undo that decision silently, and the seed re-derivation path skips them for the same reason.
  • Profiles with an accepted profileSuppressionRequests row, 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_out with reason Concierge handoff invitation pending. A bare skip would advance the migration cursor, leaving a profile whose invitation later expires stuck at draft_private with no record of why; opted_out is the same state seedHandoffs writes 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 legacy draft_private profile whose surfacing state is still public, 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 uniqueness
  • by_profileType_publicationState: public page/discovery entry points split by person vs community
  • by_publicationState_claimState: public/trust filtering for later profile lists
  • by_publicSurfacingState_publicationState: public surfacing enforcement across every hidden state -- opt-out, suppression and archival
  • by_claimState_profileType: moderation and claim-review flows by claim state, with optional type splitting
  • by_creationSource_claimState: moderation and community-submitted/unclaimed review flows
  • by_profileType_sortName: deterministic profile listing by type
  • profileOwners.by_profileId_roleKey_state: active owner singleton enforcement
  • profileOwners.by_userId_state: account profile ownership lookup
  • profileClaimRequests.by_profileId_state: profile claim review lookup
  • profileVerificationAttempts.by_state_expiresAt: pending proof attempt expiry scans
  • externalControlProofs.by_userId_assetType_assetExternalId: one user's proof for a given asset, in any state
  • externalControlProofs.by_assetType_assetExternalId_state: who currently proves control of an asset
  • externalControlProofs.by_state_revalidateAfter: revalidation sweeps
  • profileExternalLinks.by_profileId_assetType_state: a profile's active connections, primary first
  • profileExternalLinks.by_assetType_assetExternalId_state: which profiles an asset backs

Implementation Boundaries​

  • #10 adds canonical slugs, validation, and uniqueness rules
  • #11 adds type-aware person/community fields and documents shared vs type-specific data
  • #12 defines read/write permission behavior
  • #13 defines claim-state transitions and trust labeling behavior
  • #22 added presentation fields and public-page rendering for avatar/banner, short bio, and longer about content
  • #23 added the authenticated community submission mutation and source attribution details
  • #25 and #26 add public trust/source labeling and the first audit trail
  • #30 adds public surfacing suppression enforcement
  • #31, #32, and #33 add universal public search/discovery surfaces
  • #82 added inline typed external links for first-slice creator commerce/profile links, with public https filtering
  • #90 adds scoped vocabulary normalization for tags, roles, categories, and discovery facets
  • the DJ lookup genre slice adds optional inline profiles.genres plus profile_genre vocabulary/search indexing as the minimal bridge to a later normalized genre graph
  • #27 adds field-level visibility controls and the claimed-owner privacy update surface
  • #31 adds public search behavior and any search-specific indexing