Event intake drafts and publication
Event intake accepts a signed-in VRDex account, including an account without a verified email. Publication targets any published, publicly surfaced community. It does not require community ownership, staff membership, or an acceptance queue.
Commands
Browser clients use eventIntake.saveEventIntakeDraft, getEventIntakeDraft, and
the publishEventIntake action. Save accepts { draftId?, expectedVersion?, patch }
and returns { draftId, version }. Updating requires both the ID and current
version. Get accepts { draftId } and returns the actor's private draft.
Publish accepts { draftId, expectedVersion, idempotencyKey } and returns
{ eventId, receiptId, eventPath }. Clients navigate to eventPath on success.
Trusted API adapters use the internal saveActorDraft, getActorDraft, and
publishActorIntake equivalents with actorUserId bound from their validated
credential. Never accept this identity from a request body. The browser action
resolves it through currentIntakeActor and requireUser.
flowchart LR
A[Community page or Add event] --> B[Sign in and return to intake]
C[Authenticated API or MCP] --> D[Private partial draft]
B --> D
D --> E[Save current version]
E --> D
D --> F[Publish]
F --> G{Deterministic checks}
G -->|Missing or conflicting facts| D
G -->|Near duplicate| H[Review existing events]
H --> D
G -->|Passing or exact replay| I[Canonical event page]
Draft contract
The strict schemas and TypeScript types live in
packages/api-contracts/src/event-intake.ts. Omitted top-level patch fields stay
unchanged, null clears a value, and empty strings normalize to unknown. Nested
objects and the lineup array replace their previous value as a unit. A lineup
row has a stable clientKey, position, and optional performer, role and times.
Those structural fields alone do not constitute a meaningful draft.
One meaningful input is enough to save. sourceText, posterSourceId, and a
bounded posterDeclaration of MIME/bytes/SHA-256 remain private. The declaration
allows a poster-only draft before upload and inherits ordinary draft quotas and
expiry. It does not confirm source bytes or satisfy publication minimums. The poster reference is an opaque integration reference, not a public
artwork URL or permission to read storage. The source service must validate its
actor and purpose before reading or attaching any source bytes. tentative holds
candidate fields separately; only ordinary fields can become canonical.
questions holds up to 100 unresolved extraction questions, with 2,800 characters
per question to preserve the field, reason, and alternatives without truncation.
The overall draft payload limit still applies. The server records the last
authored version and whether each top-level field is tentative or contributor
authored in provenance.
Drafts have a 48,000-character JSON payload ceiling and a maximum of 20 unexpired rows per actor. Each save renews the 30-day draft expiry. The hourly sweep deletes at most 100 expired drafts. Receipts and request bindings survive draft deletion. Private source-file retention is a separate source-service responsibility.
Publication
Publication requires a public community, an identifying title, a valid event
date, and either a start time or explicit timeTba: true. A source URL is optional.
Public worldSlug and lineup[].personSlug matches are rechecked at publication.
Unmatched lineup names are preserved. An incomplete lineup row remains valid in
a draft but needs a performer label before publication.
Times use { time: "HH:mm", dayOffset?, occurrence? }. dayOffset is explicit
cross-midnight intent relative to the event date. occurrence is earlier or
later for a repeated local time. Timed publication requires a valid IANA
timezone; date-only publication does not. resolveEventLocalTime returns zero,
one, or multiple instants. selectEventLocalTime rejects gaps and unresolved
repeated hours. Canonical schedules use normalizeEventSchedule.
The actual commit mutation calls requireDateOnlyEventsEnabled before inserting
a date-only canonical event. Keep EVENT_DATE_ONLY_ENABLED disabled until the
schedule migration and index readiness checks have passed. Legacy timed events
continue to work with the switch disabled.
Preflight applies strict text and link bounds, actor and target eligibility, 10 new publication receipts per actor per rolling day, 100 per community per rolling day, and 100 new idempotency bindings per actor per rolling day. An existing key replays without consuming another allowance. These are current implementation defaults, not promised product limits.
Duplicate identity is the community ID, calendar date, and normalized title.
Existing timed events without migrated date fields are checked in a bounded
time window. An exact public match returns its event and one canonical receipt.
If a legacy match has no slug, publication first creates its canonical route and
refreshes search from the existing event and associations. It preserves that
event's content instead of applying fields from the duplicate draft.
An exact unpublished or cancelled match refuses recreation. A same-day title
overlap or matching source URL produces a NEAR_DUPLICATE error with up to 20
choices containing eventId, title, and eventPath. The contributor can save
the reviewed IDs in duplicateAcknowledgements and publish that new version.
Unrelated same-day titles do not prompt. Candidate scans fail closed above their
100-row bounds.
The action calls classifyEventIntakeForPublication, defaulting to off. Optional
shadow mode records samples/outages without blocking. Blocking additionally
requires an approved measured evaluation and explicit threshold. Decisions bind
to the checked draft version; outages retain deterministic publication and record
an operator flag. See source/model controls. The internal
commitPublishIntake mutation always rechecks version and deterministic preflight.
It atomically writes the canonical event, short link, lineup, world association,
search document, receipt, and actor-bound request binding. Concurrent duplicate
attempts converge on one canonical event and receipt.
Creating a new canonical event also records its actor and transport surface in
eventAuditEvents. Replaying a receipt returns the event's current route.
Attribution and correction integration
Published records have sourceType: "contributor" and source label
Community-submitted. Public projections omit contributor identity, private
source text, poster references and draft provenance. Private source posters
never become event artwork implicitly. Participants and worlds retain the
contributor source type. Contributor-selected world associations start
unconfirmed: they appear on the event page but not as confirmed world-page
activity until staff confirm them. Contributor corrections to that world also
start unconfirmed. An unchanged world association retains its existing state.
Canonical contribution metadata is contributorUserId, contributionVersion,
contributionFingerprint, optional contributorEditsClosedAt, and
contributorLockRevision. Published drafts are immutable; use the correction
commands below to change the canonical event. Replay attempts against a
published draft return a conflict.
Correction commands
eventCorrections.getOwnContributedEvent({eventId}) returns eventId,
updatedAt, contributionVersion, and editable fields reconstructed from the
canonical event and lineup. It never returns private source evidence.
REST and MCP expose the same actor-scoped read by event slug. The returned
updatedAt is required for corrections and is not available from public event
reads. Contributor-selected performer matches appear in the event lineup but
remain unconfirmed in person-profile feeds until staff edits that lineup.
updateOwnContributedEvent({eventId, expectedUpdatedAt, patch, duplicateAcknowledgements?}) allows only the
original contributor while the listing is published, scheduled, and not taken
over by staff. It returns the new updatedAt and contributionVersion.
The patch keys are title, eventDate, timeTba, timezone, start, end,
doors, venueLabel, worldSlug, sourceUrl, summary, and lineup. Local
times and lineup rows use the intake contract above. Omitted fields remain;
empty or null optional text clears the field. Arrays replace as a unit. When
switching to TBA, clear event/set times explicitly. Community attachment,
source attribution, trust, private evidence, and watch controls are rejected.
Every correction compares expectedUpdatedAt, validates the resulting complete
listing with publication preflight (excluding itself from duplicate matches),
and validates the lineup. It does not spend new-publication quota. An exact
match to another listing returns DUPLICATE_EVENT; near matches return
NEAR_DUPLICATE. Retry with the reviewed event IDs in the optional command-level
duplicateAcknowledgements array, bounded to 100 IDs, outside patch. Confirm
near matches again for each correction, including previously acknowledged
publication matches. This does not bypass exact duplicate or suppression checks.
The transaction updates canonical content, search and
participant/world projections and records changed fields with actor identity.
retractOwnContributedEvent({eventId}) hides and audits the contributor's own
listing before staff takeover. Community owners and manage_events staff retain
the existing edit, cancel, unpublish and republish commands. Every staff content
or publication/status change sets contributorEditsClosedAt and increments
contributorLockRevision in the same transaction. The explicit
takeOverContributedEvent({eventId}) command closes editing without changing
content. Subsequent contributor mutations return CONTRIBUTOR_EDIT_CLOSED,
including mutations with a fresh revision. UI correction suggestions after
takeover remain a separate workflow; a stale direct edit never becomes one
automatically.
Owner API/MCP updates retain date-only schedules when startAt is omitted.
Empty owner updates are rejected without taking over the contributor's listing.
eventDate corrects a date-only event; supplying startAt explicitly changes it
to a timed event. venueLabel is editable. The optional lineup replaces all
canonical timed and untimed rows using stable clientKey values, names, optional
public person matches, roles and absolute set timestamps. Use it instead of the
legacy paired slotLinks/participantLinks arrays. An unchanged person's stored
stream choice survives replacement; new choices must belong to their public
streams, and changing/removing the person clears the old choice.
An explicit scheduleKind: "date_only" update clears inherited event and set
times, retaining omitted lineup rows as untimed performers. Supplying event times
in that update is invalid. Browser editor saves use their existing association
snapshots to retain unchanged private person matches, including when a profile
became private after readback. Replacing the person or removing its row removes
the old match; private identity is never added to the public response.
The shared owner-update transaction refreshes an existing contribution fingerprint from the corrected community, date and title. Published server artwork URLs may be retained only when the exact current event URL resolves to that event's published artwork record. Arbitrary relative image URLs remain invalid. Discord exports use the unified lineup, with per-set timestamps only where available and legacy slots/participants as a fallback for older payloads.
Trusted transports can use internal getActorContributedEvent,
updateActorContributedEvent, and retractActorContributedEvent with the same
arguments plus actorUserId. Derive that ID from the authenticated credential,
never the request body. The internal mutations record this ID in event audits.
Browser commands derive their actor from the active session.
The account event list pages the contributor's full event history in creation
order, including older published and retracted listings.
Reports and removal
reportEvent({eventId, reason}) accepts signed-out visitors, records identity
when available, and returns {accepted: true}. Reasons must contain 5 to 500
trimmed characters. Reports never change publication or automatically remove an
event. Server-side rolling limits allow 20 reports per event per 24 hours,
500 reports globally per hour, and 10 reports per signed-in account per 24 hours.
These limits share the mutation transaction with the insert. No caller-supplied
client key or IP is accepted, so rotating either cannot bypass the event/global
bounds. A malicious caller can exhaust those shared limits and delay legitimate
reports. A future trusted HTTP layer can add IP-based admission, but must retain
these limits since the public Convex mutation remains directly callable.
listEventReports({cursor, limit}) accepts a null initial cursor and a limit
from 1 to 100. Only community owners, manage_events staff and accounts with an
active super_admin grant can read it. Staff see only their communities; moderators
see all reports. Pagination applies the manager's community scope before
forming each page and cursor. Continue with continueCursor. Private
eventReports rows may also carry kind: classifier_outage or classifier_sample for a trusted
classifier commit to insert. Neither reports nor outage flags enter public
event projections.
removeContributedEvent({eventId, reason}) separately requires an active
super_admin grant. Community event authority alone does not grant removal.
The mutation hides the event, closes contributor editing, updates search and
participant/world projections, and records the moderator and reason in the
audit. Removed listings cannot be republished through ordinary staff commands.
Public page and calendar-export reads return no event; upcoming, community,
person and world feeds omit it. Already-cached exports retain their existing
cache lifetime, and files already downloaded cannot be recalled.
A removal computes the community/date/normalized-title fingerprint from the current canonical event, including staff corrections, and stores it for 30 days. Preflight rejects matching publication even if the canonical row is gone. The hourly cleanup deletes at most 200 expired suppression records per run. Expired records stop blocking immediately, independent of cleanup backlog. The removed canonical row stays hidden. This is exact-match prevention, not fuzzy spam detection; title/date changes can evade it.
Private posters and optional extraction
See source storage and model controls for private upload intents, explicit artwork selection, retention, bounded extraction and default-off spam classification. The manual path has no model-key dependency.
Integrated verification
The Task 9 checkpoint separates local transaction, transport and browser fixtures from hosted/storage/provider proof. Exact public copy was approved by BASIC on 2026-09-28. The connected production journey, hosted verification and deployment remain open.