Happy path
Path A — Organizer creates the invite-only access type and sends invitations
-
From the event-setup hub, click into Access types and create a new one.
Choose distribution: Invite to RSVP. Capacity, transferability, FCFS settings appear with sensible defaults. Plus-ones are off by default — the organizer can enable per-invitation later.
-
Save. Return to setup hub. The access types card status pill updates.
Hub's checklist now suggests Invite guests as the next action.
-
Click into Audience. Choose to invite individuals or import a list.
For individuals: pick from address book autocomplete (uses
ui-autocompleteagainst contact records) OR add new ones inline. For import: CSV upload (ui-file-upload) with column-mapping preview before commit. -
Send invitations.
For each invited guest, the system creates a row in
event_guestswithaccess_type=invited, mints an invite token (HMAC-signed), and queues an invitation email (an EF-052 association-driven send for the invitation message-type). The send dialog shows progress: "Sent 24 of 50…". Failures (bounces, invalid emails) are surfaced per-guest, not silently dropped.
Path B — Invited guest receives email and RSVPs
-
Guest receives invitation email.
Email is sent via Cloudflare Email Sending using the configured invitation design (EF-051). Subject and body are templated with merge tokens (EF-056). The invite link points to
/p/<event-slug>/rsvp?token=<invite-token>. -
Guest clicks the link.
Lands on the public RSVP page. Server validates the token: signature, expiry, guest binding. On success, the page shows the event's name, date, location, AND the guest's name pre-filled (so they know the invite was personal). The RSVP form has two primary actions: Accept and Decline, plus an optional message field.
-
Guest clicks Accept.
A confirmation modal appears (uses
ui-modal) restating the event details. Guest confirms. Server transitions theevent_guestsrow tostatus=confirmed. The page updates: confirmation card shows "You're confirmed for <Event name>" with calendar links (EF-058) and a copy of the invite they can save. -
Guest receives confirmation email automatically (EF-052 confirmation association).
QR code (EF-057) embedded for day-of check-in. Calendar links work in Google / Outlook / Yahoo / Apple (EF-058).
Failure modes
The rejection — uninvited guest tries to access the RSVP page
Trigger: someone forwards their invite link to a friend, OR a bot probes /p/<event-slug>/rsvp?token=... with a guessed token, OR the link gets posted publicly. The token doesn't validate (signature mismatch, no matching guest, or guest is on a different event).
The dignified-rejection contract. The page does not 404. It does not show a hostile error. It shows a clear, courteous explanation:
- Page H1: "This invitation isn't valid for this account" (or "This event is invitation-only" if the token is structurally absent).
- One sentence of context: "<Event name> is a private event. Invitations are personal and can't be shared."
- If the event has a public-facing description (organizer enabled "show event title to non-invitees"): the title and date are shown so the recipient can verify they were trying to access the right event. If not: no leak — just the message.
- A primary action: Request an invitation. Clicking opens a form (uses
ui-form) asking for the requester's email and an optional message. Submission goes to the organizer for approval, NOT to the public page. - A secondary action: Already invited? Try a different link — leads to a help page explaining the invite link is personal and what to do if they've lost theirs.
- NO retry button, NO error code visible to the user, NO mention of why specifically the token failed (signature vs expiry vs unknown guest — that's diagnostic info that helps probing).
The tone matters as much as the mechanics. The page is warm, not hostile. The user did nothing wrong; they're just not on the list. Visual style is muted, not red-alert. Color contrast still meets WCAG-AA throughout.
Recovery: Request access via the form. If the organizer has the request, they can issue a new invitation through the audience tools.
Expired invite
Trigger: the event has passed, OR the organizer set an invitation expiry that has elapsed.
The page shows a different message: "This invitation has expired." The event title and date ARE shown (since the recipient was confirmed-invited; we already knew them). If the event has passed, the page offers to add a follow-up email subscription if the organizer enabled future-events-list. If only the invite expired but the event is upcoming, the primary action is Request a new link — sends a request to the organizer to re-issue.
Recovery: Request a new link; organizer can reissue or update the expiry.
Already accepted — re-click invite link
Trigger: guest accepted yesterday, clicks the invite link again today.
The page recognizes the token + the existing status=confirmed row. Shows the confirmation card directly: "You're confirmed for <Event name>" with calendar links, QR, and event details. Below: an option to Change my response if the event still allows it (organizer config). Re-clicking does NOT re-accept (no double-acceptance).
Recovery: No action needed. The guest is reassured they're confirmed.
Already declined — re-click invite link
Trigger: guest declined last week, changes their mind, clicks the invite link.
The page recognizes the existing status=declined row. Shows: "You declined <Event name> on <date>. Would you like to update your response?" If the event allows status changes (organizer config; default yes until N days before event): the Accept button is offered. If declined-locked: a message explains why ("Responses can no longer be changed; contact the organizer if needed").
Recovery: Update response, or contact organizer.
Capacity full when guest tries to accept (FCFS race)
Trigger: organizer sent invitations for an FCFS access type with capacity = 50. The 51st guest tries to accept; the slot is gone.
The page shows: "<Event name> is now at capacity. We've added you to the waitlist — you'll be notified if a spot opens." The system creates a status=waitlisted row. The guest does NOT see a hostile error or "you missed it." Waitlist position is visible (guest is #3 on the list), so they have realistic information about likelihood.
Recovery: Waitlist; auto-promotion if a confirmed guest cancels (EF-025).
Token tampering — signature invalid
Trigger: someone modifies the token query parameter (changing one character, etc.).
Server's HMAC verification fails. The page shows the same "This invitation isn't valid" message as the rejection failure mode — does NOT distinguish a tampered token from an unknown one. Distinguishing would help an attacker probing for valid token formats. Logged server-side at higher severity than a normal rejection so security can flag suspicious patterns.
Recovery: Same as the rejection failure mode — Request an invitation.
Guest tries someone else's invite link
Trigger: Alice forwards her invite link to Bob. Bob clicks; the page loads with Alice's name pre-filled in the RSVP form.
This is technically the happy path from a security perspective — the token is valid, the guest record is for Alice, the page renders Alice's RSVP form. Bob sees Alice's name in the field. The page does NOT auto-send an "Alice accepted" if Bob clicks Accept — but it WILL register Alice, not Bob. This is a forwarding-detection moment, not a rejection.
To handle Bob: the page includes a small "Not Alice? Request your own invitation" affordance. Clicking takes Bob through the request-invitation flow. The page does NOT prompt Bob to enter his email "instead of Alice" — that would let him trivially accept-on-behalf-of-Alice. The invitation belongs to Alice and only Alice.
Recovery: Bob requests his own. Alice's invite is unaffected.
Invitation email bounced — guest never knew they were invited
Trigger: invitation email bounces (full mailbox, dead address, spam-blocked). The organizer's deliverability report (EF-089) shows the bounce.
This isn't a guest-side failure mode — it's an organizer-side observability one. The audience view shows guests with their last-email-status pill: delivered, opened, clicked, bounced, declined. Bounced guests are surfaced with a "Resend invitation" affordance and an option to update their email address. The guest who didn't receive isn't blamed for not RSVPing.
Recovery: Organizer updates the address and resends, OR removes the guest from the audience.
Network failure during RSVP submit
Trigger: guest clicks Accept; their connection drops; the request fails.
The form (uses ui-form) preserves their selection. Error banner above the form: "Couldn't save your response. Check your connection and try again." Retry sends the same request with the same Idempotency-Key so the server dedupes if the original landed but the response was lost. Guest doesn't accidentally double-confirm or end up in an inconsistent state.
Recovery: Retry. Idempotent.
Plus-one denied for an access type that doesn't allow them
Trigger: invitation didn't grant plus-ones, but the guest tries to add a +1 in the form.
The plus-one field is hidden when not allowed — not shown disabled. (Showing-disabled invites the question "why can't I?" which we can't always answer well.) The guest only sees fields that apply to their invite. If the organizer DID grant +1: the field is visible, with a count limit if applicable, and the +1's name + email are required.
Recovery: N/A — the +1 affordance simply isn't shown when not allowed.
Organizer revokes an invitation while guest is mid-RSVP
Trigger: guest opened the RSVP page 5 minutes ago. While they were composing their response, organizer revoked the invitation.
Guest clicks Accept. Server returns 410 Gone with code INVITATION_REVOKED. The page transitions to the rejection state with a softened message: "Your invitation was withdrawn before you could respond. If you think this was a mistake, please contact the organizer." Their composed response is shown back to them ("You were going to say: '<message>'") so they don't lose what they wrote — they can copy it for an email to the organizer.
Recovery: Out-of-band — contact the organizer.
Event was archived between invite-send and click
Trigger: organizer archived the event. Guest clicks invite link a day later.
Page shows "<Event name> is no longer available." with the event date. If a follow-up email subscription was enabled, an option to subscribe. No "request invitation" affordance — the event is gone, can't reissue.
Recovery: Subscribe to future events, or move on.
Edge cases
Guest's email address has changed since invitation was sent
Token validates against the guest record, not the email at click-time. If the organizer updated Alice's email in the audience after sending, the original token still works — it's bound to Alice's guest_id, not her email.
Guest declines, then organizer re-invites them after deletion
Re-invitation creates a new guest row with a new token. The old declined-row is preserved in audit. Guest's response history is visible to the organizer (guest declined version 1, was re-invited and accepted version 2).
Multi-language event
If event has translated content, the RSVP page renders in the language inferred from the guest's record (organizer can set per-guest), with a language switcher. Rejection / expired / capacity-full messages all translate.
Mobile viewport (375×667)
RSVP page is single-column. Accept / Decline buttons are full-width and stack. Confirmation modal is a full-screen sheet (per ui-modal mobile contract). Calendar buttons stack vertically; QR is rendered at 200×200 minimum so it's scannable.
Print or save-as-PDF the RSVP confirmation page
Print stylesheet hides the navigation chrome and prints the event card with the QR. Useful when guests want to print a hard copy of their confirmation for their travel plans.
Page evaluation
| Surface | Discoverability | Error UX | Layout | Orientation |
|---|---|---|---|---|
| Organizer · Access types create | Distribution chooser (Invite to RSVP / Public Registration / Public Purchase / Invite to Purchase) is the first visible field. | Inline errors per field; no toast. | Single-column form; submit sticky-bottom on mobile. | Page H1 includes the event name + "New access type." |
| Organizer · Audience invitations | Invite-list editor uses ui-autocomplete for contact picker. Send button is visible without scrolling once at least one guest is added. | Per-guest validation surfaces invalid emails inline. Bulk paste of mixed valid/invalid splits and shows both groups. | Two-pane: list of selected guests on left, send config on right at ≥1024px. | Send progress modal indicates count and percent complete. |
| Guest · RSVP page (valid invite) | Accept and Decline are equal-prominence buttons, side-by-side at ≥640px wide. Optional message field is below, not blocking. | Network errors show inline; idempotent retry. | Single-column, max-width 640px centered. Event header card is sticky on scroll. | Page H1 is "<Event name>" — not "RSVP." Guest's name pre-filled and visible. |
| Guest · Rejection page | Request-an-invitation CTA is the primary visible action. The "already invited?" link is secondary. | The page IS itself an error UX. No nested error states. Tone is warm, not hostile. | Single-column, max-width 640px centered. No event branding if organizer disabled "show event title to non-invitees." | H1 is "This invitation isn't valid for this account" — not "Error 401." |
| Guest · Confirmation page (post-accept) | Calendar buttons (Google / Outlook / Yahoo / Apple) are equal-prominence and visible without scrolling. | If a calendar provider's link fails for some reason, the others still work. | Confirmation card; QR below; calendar buttons row; "Add to wallet" affordance if applicable. | H1 is "You're confirmed for <Event name>." Past tense; communicates completion. |
Acceptance signals
- Organizer-side: POST
/v1/admin/events/:id/access-typeswith{ kind: "invite_to_rsvp" }returns 201 and the access type row is inevent_access_types. - Organizer-side: POST
/v1/admin/events/:id/guests/invitewith N guest rows returns 200 and N notification_outbox rows are queued (per EF-052 association-driven send). - Guest-side, valid token: GET
/p/:slug/rsvp?token=...returns 200 and the page H1 contains the event's name AND the guest's name is in the form pre-fill. - Guest-side, accept: POST
/v1/public/events/:id/rsvpwith valid token returns 200 andevent_guests.statusfor that guest_id transitions toconfirmed. - Guest-side, invalid token: GET returns 200 (not 404, not 401) AND the page contains "isn't valid" or "invitation-only" copy AND the rejection page's data-test attributes are visible.
- The rejection page does NOT leak the event title or date in the rendered HTML when "show event title to non-invitees" is disabled.
- document.title for the rejection page is generic ("Invitation not valid · Voyage") — does NOT contain the event name.
- No console errors at severity ≥
warn.
Stable test attributes
This branch's contract — the named data-test attributes the Aperture booking-pages and admin code MUST expose. Branch-specific only; trunk and component attributes are inherited and not re-listed.
Visibility teeth. Each attribute must be present AND effectively visible when its state is active. Hiding without removal is a Ratchet violation.
| data-test | Where | Purpose |
|---|---|---|
access-type-distribution-chooser | Admin · access types create form | The 4-option distribution chooser |
access-type-distribution-invite-to-rsvp | Inside the chooser | The Invite to RSVP option |
audience-invite-list | Admin · audience page | The list of selected guests to invite |
audience-invite-send-cta | Admin · audience page | Send invitations button |
audience-send-progress | Admin · send progress modal | Progress UI during bulk send |
audience-guest-status-pill | Admin · audience page (per-row) | delivered / opened / clicked / bounced / accepted / declined |
audience-bounced-resend-cta | Admin · audience row in bounced state | Resend invitation affordance |
rsvp-page | Guest · public RSVP page (valid token) | Root container of the RSVP form |
rsvp-event-title | Inside rsvp-page | Event name as page H1 |
rsvp-guest-name-prefill | Inside rsvp-page | Pre-filled guest name (read-only) |
rsvp-accept-cta | Inside rsvp-page | Accept button |
rsvp-decline-cta | Inside rsvp-page | Decline button |
rsvp-message-field | Inside rsvp-page | Optional message textarea |
rsvp-confirm-modal | Modal portal (uses ui-modal) | Accept-confirmation modal |
rsvp-confirmation-page | Guest · post-accept page | "You're confirmed" page |
rsvp-confirmation-h1 | Inside confirmation page | Past-tense H1 with event name |
rsvp-qr-code | Inside confirmation page | QR for day-of check-in (uses EF-057 contract) |
rsvp-calendar-google | Inside confirmation page | Google Calendar add link (uses EF-058) |
rsvp-calendar-outlook | Inside confirmation page | Outlook calendar link |
rsvp-calendar-yahoo | Inside confirmation page | Yahoo calendar link |
rsvp-calendar-ics | Inside confirmation page | ICS download link |
rejection-page | Guest · invalid-token page | The dignified-rejection page; root container |
rejection-h1 | Inside rejection-page | "This invitation isn't valid for this account" or equivalent |
rejection-context | Inside rejection-page | One-sentence courteous explanation |
rejection-event-title-optional | Inside rejection-page | Event title — present ONLY if organizer enabled "show event title to non-invitees" |
rejection-request-invite-cta | Inside rejection-page | Primary action — opens the request-invitation form |
rejection-already-invited-help | Inside rejection-page | Secondary action — link to "lost your invite?" help |
request-invitation-form | Inside rejection-page after CTA click | Form to request access (uses ui-form, ui-text-input) |
request-invitation-success | After form submit | "Your request was sent" confirmation |
expired-invite-page | Guest · expired-token page | "This invitation has expired" variant |
expired-invite-request-new-cta | Inside expired-invite-page | Request a new link |
capacity-full-page | Guest · capacity-full state | "At capacity — added to waitlist" |
capacity-full-waitlist-position | Inside capacity-full-page | Waitlist position number |
already-confirmed-page | Guest · re-click after accept | Existing confirmation, optionally with change-response CTA |
already-declined-page | Guest · re-click after decline | Decline confirmation; optional update-response CTA if allowed |
change-response-cta | Inside already-confirmed or already-declined page | Reopens the form to update the response |
archived-event-rsvp-page | Guest · event archived after invite sent | "Event no longer available" page |
revoked-invitation-page | Guest · invitation revoked mid-flow | "Your invitation was withdrawn" with composed-message preserved |
Agent test plan
Inherits trunk preconditions from event-setup (which itself inherits admin-shell-access). Public-event-page trunk is planned; until written, public-side probes navigate directly to /p/:slug/rsvp?token=... URLs against fixture events.
Probe list — happy path A (organizer)
- create-invite-rsvp-access-type: from event-setup hub, click access types CTA, pick Invite to RSVP, submit, assert access_type row created
- audience-pick-and-send: navigate to audience, add 3 guests via autocomplete, click send, assert progress visible AND notification_outbox has 3 queued rows
- audience-shows-bounced-pill: stub one of the queued rows to bounce, assert audience-guest-status-pill for that row reads "bounced" AND audience-bounced-resend-cta visible
Probe list — happy path B (guest)
- valid-token-shows-rsvp: navigate to /p/:slug/rsvp?token=${fixture.validToken}, assert rsvp-page visible, rsvp-event-title contains event name, rsvp-guest-name-prefill matches guest's name
- accept-confirms: click rsvp-accept-cta, confirm in modal, assert API POST returns 200 AND URL transitions to /p/:slug/rsvp/confirmed AND rsvp-confirmation-page visible
- confirmation-shows-calendar-and-qr: assert rsvp-calendar-google + outlook + yahoo + ics all visible AND rsvp-qr-code visible
Probe list — failure modes (the centerpiece)
- rejection-page-renders: navigate with token=invalid, assert rejection-page visible AND rejection-h1 matches /isn't valid|invitation-only/ AND rejection-request-invite-cta visible
- rejection-no-event-leak: with "show event title to non-invitees" disabled, assert rejection-event-title-optional ABSENT AND document.title does not contain event name AND HTML body does not contain event name in any form
- rejection-event-leak-when-enabled: with the org setting enabled, assert rejection-event-title-optional visible
- rejection-tone-not-hostile: assert no element contains text matching /(?i)error|denied|forbidden|unauthorized|401|403|404/
- rejection-no-diagnostic-leak: with token=structurally-malformed and token=signature-tampered, assert the rendered HTML is byte-identical (no distinguishing diagnostic information)
- request-invitation-flow: click rejection-request-invite-cta, fill request-invitation-form, submit, assert request-invitation-success visible
- expired-invite: navigate with token=expired, assert expired-invite-page visible AND expired-invite-request-new-cta present
- already-accepted-reclick: with token whose guest is already confirmed, navigate, assert already-confirmed-page visible AND change-response-cta visible (if event allows changes)
- already-declined-reclick: similar with declined guest
- capacity-full: stub event capacity exhausted, click accept, assert capacity-full-page visible AND capacity-full-waitlist-position has a number
- token-tampered-same-as-rejection: navigate with tampered token, assert page is rendering-equivalent to navigate-with-invalid-token (anti-probing)
- network-fail-rsvp: stub POST /v1/public/events/:id/rsvp to network-fail, click accept, assert form-error-banner visible AND idempotency-key sent on retry
- archived-event: navigate after archiving, assert archived-event-rsvp-page visible
- revoked-mid-flow: open RSVP page, fill message, stub POST to 410 INVITATION_REVOKED, click accept, assert revoked-invitation-page visible AND composed message preserved