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
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 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 thevrcdn:<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 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
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.jsover the.live.tstransport stream — the same library and endpoint VRCDN's own preview page uses. There is no HLS to play. The event watch surface previously handedhls.jsthe.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, theQuestandPCURLs, 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 previewpoints atpanel.vrcdn.live/preview/<id>, which#217had listed as an operator surface not to publish;#296requires 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.jsLOADING_COMPLETE, playerERROR, 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 saysPlayback stoppedfor the same reason. -
Volume is feature-detected, not sniffed. iOS Safari treats
HTMLMediaElement.volumeas 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;
requestFullscreenis absent rather than failing, so it falls back towebkitEnterFullscreenon 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:Request Live Idle Unknown id GET .live.ts200(video/mp2t)404401GET .m3u8404404404HEAD .live.ts200200200 -
The HLS manifest is not a liveness signal, despite being the original mechanism in
#217. It answers404for 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, both404for the wrong reason.HEADis not a signal either, since it answers200for ids that do not exist.vrcdn.liveitself is a Blazor SPA that answers200with its app shell for unknown paths, so the page and/api/liveare out too. -
Only
200counts as live. A bodyless2xxis an intermediary answering, not VRCDN handing over a stream. -
401(unknown id) and404(idle) both render as no badge. They are worth keeping apart because this endpoint distinguishes them: a typo in an owner's own link reads401forever, where an idle stream reads404. -
Live checks report the availability of the linked stream for both Twitch and VRCDN, including
community_submittedlinks. 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:
offlineis a real404,unavailableis 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 nowclaim 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.tsignoresRangeand 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 changepublicSurfacingReason: optional short reason the profile is hidden -- an opt-out, a suppression or an archival. Cleared when a profile returns topublic, since it describes the current state rather than the history; why a profile was archived or restored stays inprofileAuditEventsfieldVisibility: 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
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 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
Implemented 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. 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_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" reportsqueuedonly while a collector proof-path heartbeat is fresh, otherwise it reportsunavailablewhile preserving the pending attempt.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 surfacesarchived: 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_outandsuppressedboth 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 byprofileArchival, 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. Seedocs/backend/private-seed-operations.mdfor 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 projectionsunlisted: direct profile page onlyprivate: 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
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.archivedprofiles, 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
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 surfacing enforcement across every hidden state -- opt-out, suppression and archivalby_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