Membership Club — Training Manual

The complete manual in one document: every persona, then every workflow by area. Generated 2026-08-31T02:47:50Z · content revision 2026-08-30T21:54:00Z.

Back to training

Contents

Part 1 — By persona

Part 2 — By area

Appendices

Customer (Guest)

Anyone on the public site who has not logged in yet.

You are the public. You have no account, no groups and no session — and a surprising amount of the platform is deliberately open to you: the event catalogue, an event's tier and price breakdown, the resale exchange window, the host application form, a comp-invite claim link and a contract signing link.

That is a design decision, not an oversight. Every gate in this platform is enforced at the moment of the action, never by hiding the page. A customer must be able to see what is on sale, and a prospective promoter must be able to apply, before either of them has an account.

The moment you need to own something — a ticket, house credit, a listing — you become a Member. Registering is the boundary.

Core workflows (4)

Takes part in (2)

What this persona cannot do

Without a session you cannot:

  • hold a reservation, check out or pay — the cart and checkout pages require the member group;
  • see anyone's tickets, orders, credit balance or wallet passes;
  • open any /admin/* surface or the door scanner.

Anonymous requests that need a session get an HTML redirect to /login?next=…, or a 401 envelope for JSON.

Member

A registered customer: buys, holds tickets, house credit and resale listings.

You are the platform's centre of gravity. Registering grants the member group automatically, and with it every customer-owned surface: the cart and checkout, your orders and tickets, your wallet passes, your house credit balance and statement, and the resale exchange as both a seller and a buyer.

Your hub is /my. Everything you own hangs off it — /my/tickets, /my/orders, /my/credit, /account/passes. Note the shape of those URLs: the member portal lives under /my/* and /account/*, and every one of those routes scopes its query to your user id. A member cannot read another member's order even by guessing the id — the lookup 404s rather than 403s, so ids cannot be probed.

Core workflows (13)

Takes part in (18)

  • Browse events as a guest — owned by Customer (Guest), your step 9. Shows the same pages with a session, so the difference is visible rather than described.
  • Create your member account — owned by Customer (Guest), your steps 3–6. The account you just made — everything after the redirect is theirs, including the verification the first purchase waits on.
  • Create and launch an event — owned by Host / Promoter, your step 15. The reason the whole thing exists — they are who finally sees it on sale.
  • Buy a ticket — owned by Customer (Guest), your steps 6–13. Everything from the cart onwards needs a session — the buying half is theirs.
  • Understand your event's tiers, prices and releases — owned by Host / Promoter, your step 11. The buyer the whole release schedule is designed for.
  • Refund a ticket or an order — owned by Admin, your steps 1–8. It is their money and their ticket — and where it lands depends on the policy, not on what they ask for.
  • Read the scanner screen — owned by Door Staff, your steps 9–10. Presents the credential — and can show you what the rotating QR looks like from their side.
  • Diagnose a red scan — owned by Door Staff, your step 9. Is the guest at the window, and usually holds the fix on their own phone.
  • Use your membership card at the bar (VIP) — owned by VIP Member, your steps 1–2. Holds the same card without Zone B — the contrast is the lesson.
  • Open or close the resale exchange — owned by Admin, your step 5. They are the seller and the buyer: the bounds you set are the prices they are allowed to type.
  • Process a payout run — owned by Admin, your steps 1–3. It is their money leaving the platform: they request it and they are the one the identity gate applies to.
  • Post a house credit adjustment — owned by Admin, your step 5. It is their balance that moves, and the correction lands on their statement in full view.
  • Claim your comp invite and name your plus-ones — owned by Customer (Guest), your steps 5–7. The same invite addressed to an account is claimed from the member portal instead of a token link.
  • Build a trigger rule — owned by Venue Manager, your step 9. Is the person whose purchase crosses the threshold that fires the rule.
  • Build a role from capabilities, and tune a membership tier — owned by Admin, your steps 18–19. Is the person a tier is for — and the only one who can confirm the benefit actually landed.
  • Grant and revoke roles — owned by Admin, your step 4. It is their account: the grant is what changes what they can buy and where they can walk.
  • Deactivate a user — owned by Admin, your step 3. It is their account: they are the one who suddenly cannot log in, and they are told nothing.
  • Run the feedback backlog as a build list — owned by Venue Manager, your step 1. Supplies the backlog, and is the only person besides an admin who may edit their own words.

What this persona cannot do

You cannot:

  • reach any /admin/* page, the door scanner, or the debug console;
  • refund your own order at will — self-cancellation is only offered when the event's refund policy sets a cancellation deadline;
  • see or edit another member's data, in any module;
  • issue yourself a comp, a door override or a role.

VIP Member

A member with a house tab and Zone B access. Implies everything a member can do.

You are a member with two extra things: a VIP tab (a credit limit you can spend against and settle later) and a wider door credential — your membership card opens Zone B, not just the general zone.

The group logic matters here. vip_member implies member: the platform's group check expands vip_member to include member, so you are never granted both and never lose member surfaces. Everything on the Member page is yours too — this page only covers the difference.

The tab is deliberately not a wallet. It is an obligation recorded on the double-entry ledger: spending against it raises what you owe, and a scheduled monthly settlement sweeps your prepaid balance first and then charges your card for the remainder. Nothing about it is a discount.

Core workflows (2)

Takes part in (7)

What this persona cannot do

VIP status does not grant staff powers. You cannot:

  • set or raise your own tab limit — only an admin can, and revoking VIP freezes the tab while the balance stays owed;
  • skip the door — a VIP card is scanned like every other credential;
  • reach any admin, scanner or ledger surface.

Host / Promoter

An outside promoter running an event at the venue. Scoped to that one event.

You are an outside party who wants to put on a night at the venue. You are not staff, and the platform is emphatic about that: your grant is a scoped one. A host grant carries an event_id, and the permission check only passes for that event. Two promoters with host grants cannot see each other's events at all.

Your journey starts before you have an account: the application form is public. Approval is what creates your user and your scoped grant — in that order, and never by hand.

Inside your event you are effectively read-only. You can see your event and its sales, you sign the contract, you invite guests against the comp buckets allocated to you. You do not price it, transition it, refund it or touch its money. That separation is the whole point of the contract: it is the record of what the venue agreed to do on your behalf.

Core workflows (11)

Takes part in (11)

What this persona cannot do

Even on your own event you cannot:

  • create the event yourself — it is converted from your approved application by staff;
  • change tiers, prices, capacity or the event's status;
  • issue refunds, read the ledger, or take a payout outside the contracted royalty;
  • see any other promoter's event, or any venue-wide report.

Door Staff

Works the door: scans credentials, searches the guest list, checks people in.

You are the last mile. Your job is two screens — the scanner and the guest list — and a single question per person: green or red.

The design intent is that you never have to decide. The scan endpoint returns a verdict and a reason, and the screen is deliberately loud and colour-coded so it reads at arm's length in the dark. A red scan is not an accusation; it is a reason code (already used, wrong event, outside the door window, listed for resale, revoked) and each one has a different answer.

You are one of the three employee groups. That is a pseudo-group — a gate meaning "door staff or venue manager or admin" — and it is what opens the scanner. It is not a grantable group: nobody is ever given "employee", they are given door_staff, venue_manager or admin.

Core workflows (5)

Takes part in (14)

  • Find your way around as staff — owned by Venue Manager, your steps 8–9. Learns the two screens their role actually opens, and what a 403 there means.
  • Monitor live sales on the night — owned by Venue Manager, your step 9. Supplies the other half of the picture — how many of the sold tickets actually walked in.
  • Get through the door — owned by Member, your steps 5–6. Scans the credential and reads the verdict out loud.
  • Use your membership card at the bar (VIP) — owned by VIP Member, your step 5. Scans the card at the bar and reads the credit summary off the verdict.
  • Run the door offline — owned by Venue Manager, your steps 5–7. Actually works the queue in offline mode and presses Sync now when the network returns.
  • Resell a ticket you can't use — owned by Member, your step 6. Shows the consequence of listing: the seller's own credential now scans red.
  • Buy a ticket on the resale exchange — owned by Member, your step 9. Confirms the new pass works and the seller's old one does not.
  • Allocate comps to a promoter — owned by Venue Manager, your step 9. Turns the names in the bucket into people in the room, and lives with your plus-one decisions.
  • Claim your comp invite and name your plus-ones — owned by Customer (Guest), your steps 8–9. Finds the guest by name on the night and checks them in with their plus-ones.
  • Invite a guest and track plus-ones — owned by Host / Promoter, your steps 11–12. Finds the name on the night and counts the plus-ones in.
  • Revoke a comp — owned by Venue Manager, your step 6. Meets the consequence at the door if the revocation lands after the guest arrives.
  • Grant and revoke roles — owned by Admin, your step 8. A door_staff grant is the thing that opens the scanner at all.
  • Map groups to door zones — owned by Admin, your steps 4–7. The map decides which reader turns green for them and for the guests they scan.
  • Deactivate a user — owned by Admin, your step 4. The revoked credential fails at their reader and they must not mistake it for a broken scanner.

What this persona cannot do

Your access is narrow on purpose. You cannot:

  • open any admin dashboard, ledger, payout or tax surface;
  • download the offline sync bundle or the push feed from a browser — those carry every pass secret, so they need a venue manager session or a provisioned reader's device token;
  • create or revoke passes, refund anything, or change a role;
  • allocate comps — you check in the guests a manager or promoter listed.

Venue Manager

Runs the floor and the calendar: intake review, live sales, the door, marketing.

You run the venue day to day. venue_manager implies door_staff, so everything on the Door Staff page is yours as well — plus the surfaces a shift lead actually needs: the host application queue, the live sales dashboards, the guest list allocations, the door overrides, the access-control readers view and the marketing tools.

What you will notice is where the platform stops you, and it is worth understanding why rather than filing a ticket about it. You are trusted with operations; you are not trusted with money and identity. Payouts, refunds, the ledger, the tax office, role management and the universal data suite are all admin-only. That boundary means a manager can be hired, trained and given the floor on day one without ever being able to move a cent or grant themselves a group.

Core workflows (17)

Takes part in (24)

What this persona cannot do

You are a 403 on all of these, by design:

  • the ledger, trial balance and adjustments;
  • refunds and the payout queue;
  • the tax office;
  • role grants and revocations;
  • the universal admin data suite and its audit log;
  • transitioning an event's status and editing tiers or prices.

You can read most of the dashboards those things feed — the boundary is on writing, and on the surfaces where money is decided.

Admin

Owns money, identity and configuration. Implies every other group.

Admin is not "manager plus a bit". The permission check special-cases it: admin implies everything, including every event-scoped host grant. There is no surface in this platform you cannot open.

What is genuinely yours alone is the set of decisions that are hard to reverse: money out (refunds, payouts, ledger adjustments), identity (who is in which group), the contract counter-signature that binds the venue, the tax filings that go to an agency in the post, and the universal data suite that can edit any row in the database.

Two habits the design assumes of you. First, the ledger is append-only — enforced by database triggers, not convention — so you correct a mistake by posting a compensating entry, never by editing history. Second, everything you do through the data suite and the debug console is audited with a full before/after row snapshot. Both exist so that "the admin fixed it by hand" is always a readable event rather than a mystery.

Core workflows (24)

Takes part in (43)

What this persona cannot do

There is no group above you, so the limits are structural rather than permission-based:

  • you cannot UPDATE or DELETE an append-only table — the ledger, the audit logs and the sealed contract documents refuse the write at the database level, even from the data suite;
  • you cannot un-send a mailed tax filing or un-seal a signed contract;
  • you cannot grant the pseudo-group employee — it is a gate, not a grantable group.

Getting Started & Accounts

Find your way around, get an account, and learn what your role can reach.

Browse events as a guest

See the whole storefront with no account, and learn exactly where the wall is.

Owned by Customer (Guest) · 9 steps · about 10 minutes

Why this exists

The public half of this platform is deliberately generous. A stranger can read the event catalogue, an event's full tier and price breakdown, live availability, the host application form and a comp-invite claim link — all with no session, no cookie banner and no "sign up to see prices" wall.

That is a design position, not laziness. Every gate in this platform is enforced at the moment of the action, never by hiding the page. Hiding pages produces two bad outcomes: customers who cannot evaluate what you are selling, and developers who start to believe that an invisible link is a security control. So the catalogue is open and the write is guarded.

The wall is therefore not where people expect it. It is not at "see the price" — it is at "hold inventory". Pressing Reserve without a session gets a 401 from the reservation API, and the page bounces you to /login with a next parameter so you come straight back. One surface that looks public but is not: the resale exchange. Reading other members' listings requires a session, because a listing is another member's activity, not the venue's storefront.

Before you start

  • No account and no session. Use a private window, or log out first.
  • A seeded database, so the demo events are on sale.

Practise with

PersonaEmailPasswordNote
membermember@club.test member123only for the last two steps, where a session changes what renders

Steps 1–8 — Customer (Guest)

their manual →
  1. 1
    Open the home page logged out. Read the upcoming-events feed and any site announcement.
    / frontend
    Expected result Events and the announcements whose audience is the public.
    Watch out for Announcements are audience-targeted. A banner aimed at members simply is not rendered for you — if a colleague says 'the notice is on the home page' and you cannot see it, you are not looking at the same page they are.
  2. 2
    Open the catalogue.
    /events events
    Expected result Only events that are announced, on sale, sold out or in progress. Drafts and cancelled events are not listed.
    Watch out for Opening this page quietly runs the scheduler tick, so tiers that were due to open have already opened by the time the list renders. That is why the catalogue is never stale even though nothing runs in the background.
  3. 3
    Open an event. Read the tier table row by row: name, price, the door zones it opens, and its state.
    /events/{event_id} events
    Expected result Per tier, one of: a green count left, an amber countdown to its on-sale time, Locked (with the tier it is waiting on), Sold Out, Paused or Closed.
    Watch out for Locked is not sold out and sold out is not closed. A locked tier is waiting for its trigger; a sold-out tier can come back if a hold expires or a ticket is refunded; a closed tier is past its sale end and never reopens. There is a third way in on a sold-out tier and it does NOT bring the tier back: members put seats on the exchange, and since plan 604 the sold-out row links straight to that event's exchange ('Sign in to see resale seats'). The tier still reads Sold Out — the seats are somebody else's, at a price inside the band the venue set — so a customer told 'sold out means wait' has been told two thirds of it.
  4. 4
    Look at the JSON the page is built from. It is public too.
    /api/events/{event_id}/availability events
    Expected result Server time plus a per-tier availability figure.
    Watch out for The available number is exact for a tier on sale and null for a locked one — locked inventory is deliberately not disclosed. Availability counts holds as taken, so the number can drop and recover without a single sale.
  5. 5
    Press Buy on a tier while still logged out. There is no Reserve button and no sign-in wall in front of it.
    /events/{event_id}/buy payments
    Expected result The whole transaction: line, tax, total, and a payment method picker — all with no account. Pressing Pay is what asks who you are.
    Watch out for Corrected 2026-08-24 (end2end): this step used to say Reserve returns 401 and bounces you to /login. Plan 42 moved the wall — buy first, sign in last. The Pay press parks your payment choice, hops to /login with this buy URL as the next parameter, and on the way back the page reserves and pays for you. The boundary is still 'hold inventory'; what changed is that you no longer meet it before you have seen the price.
  6. 6
    Sign in — or press Create an account, which is the same journey with three more fields.
    /login payments
    Expected result You land back on the buy URL with your tier and quantity intact, a hold is taken, and the payment you already chose goes through without asking a second time.
    Watch out for The tier and quantity ride in the next parameter and the payment choice rides in sessionStorage, so the two halves can disagree. If the total has moved while you were signing up — a tier cascaded, a price changed — the page falls back to asking you to pay again, currently without saying why.
  7. 7
    Open the host application form, still with no account, to see the other public entry point.
    /host/apply intake
    Expected result A working application form. Submitting it is what creates a promoter account later.
    Watch out for Applying is a separate workflow. Do not fill this in as practice on a shared demo database — it lands in a real review queue.
  8. 8
    Try the resale exchange logged out.
    /exchange resale
    Expected result A redirect to the login page. The exchange is not public.
    Watch out for This is the one storefront-looking surface that needs a session. It is member-to-member trade, not venue inventory, so it is gated one step earlier than the catalogue.

Steps 9 — Member

their manual →

Shows the same pages with a session, so the difference is visible rather than described.

  1. 9
    Log in as the demo member and open the exchange again.
    /exchange resale
    Expected result The index of events with an open exchange, and from there the listings for each.
    Watch out for Seller identity is never shown to a buyer, at any level of the exchange. If you are looking for who listed something you need the admin surface.

Create your member account

Register, get the member group automatically, and see where that grant is recorded.

Owned by Customer (Guest) · 8 steps · about 10 minutes

Why this exists

Submitting the form creates the user, opens a session and grants the member group with the reason "self_registration" — and writes that grant to the append-only RBAC audit log with a null actor, meaning the system did it. Three grants in the platform are made that way, with no human deciding, and a null actor is how you recognise all of them: member on registration, vip_member when a VIP membership is purchased, and the Lama Family group that comes with its bundle. Everything else names the admin who decided.

What matters is that membership is the automatic one, and that is why the rest of the permission model can be strict. Because everyone who registers is a member, no other group needs a self-service path: VIP and Lama Family arrive with a purchase, host comes from an approved application, and staff groups are granted by an admin with a written reason. There is no "request access" button anywhere, on purpose.

One thing the account does not get on submit is a proven address. A verify link goes out with the redirect, and until it is clicked the account is in a soft gate (plan 39): everything works except the two actions that commit you to something — buying a ticket and applying to host. A banner on every page names the address and offers to resend. Nothing is blocked silently; the refusal says which link and where it went.

Two built details worth knowing. Registration is deliberately CSRF-exempt (there is no session to protect yet, and the form must work from a cold browser). And email uniqueness is case-insensitive and includes deactivated accounts, so a banned account's address cannot be recycled into a fresh one.

Before you start

  • A logged-out browser.
  • A password of at least 8 characters — shorter ones are rejected by the server, not just the form.
  • An admin session for the last two steps.

Practise with

PersonaEmailPasswordNote
memberfreya@demo.club freya-pass-123a seeded self-registered member — the end state of this workflow; register a fresh address of your own to practise
adminadmin@club.test admin123to look the new account up and read its audit trail

Steps 1–2 — Customer (Guest)

their manual →
  1. 1
    Open the registration form. Use an address nobody has used on this database.
    /register rbac
    Expected result Email, password, confirm and display name.
    Watch out for If you are already logged in this page redirects you to your account instead. Log out first or you will think it is broken.
  2. 2
    Submit the form.
    /auth/register POST rbac
    Expected result A user, a signed session cookie, the member group, and a redirect to your account page.
    Watch out for The failures are all server-side and all specific: a password under 8 characters is refused, a mismatched confirmation is refused, and an address already in use is refused even if that account is deactivated. The form re-renders with the message rather than losing what you typed.

Steps 3 — Member

their manual →

The account you just made — everything after the redirect is theirs, including the verification the first purchase waits on.

  1. 3
    Before opening your inbox, go to any event on sale and press Buy.
    /events/{event_id} identity
    Expected result The purchase is refused on the page you pressed it from, in a sentence naming what to do: confirm your email — the link is in your inbox, and the banner above will resend it. Nothing else about the account is restricted.
    Watch out for The gate applies only to accounts that were ASKED to verify — an unconsumed verify_email link. Seeded and legacy accounts predate verification and are deliberately untouched, so testing this needs an address you registered yourself. Walked by e2e/scenarios/test_wf_create_your_member_account.py.

Steps 4 — Customer (Guest)

their manual →
  1. 4
    Understand the returning path too: log out and log back in from the login form.
    /login core
    Expected result The same session cookie, and a redirect to wherever the next parameter pointed.
    Watch out for A wrong password gives one uniform message. The platform never tells you whether the address exists — that is deliberate, and it is why a 'no such account' message is a bug report, not a feature request.

Steps 5–6 — Member

their manual →

The account you just made — everything after the redirect is theirs, including the verification the first purchase waits on.

  1. 5
    Read your own account page: your groups, the door zones those groups give you, and your active sessions.
    /account rbac
    Expected result One grant — member — and the general zone.
    Watch out for Zones are computed from your live grants every time. They are not a field on your user row, which is why a group change moves your door access immediately.
  2. 6
    Open the member hub, which is where the platform expects you to live from now on.
    /my frontend
    Expected result Your next ticket, your orders, your credit and any announcement aimed at members.
    Watch out for The member portal is /my and /account. There is no /portal — if a document tells you otherwise, that document predates the build.

Steps 7–8 — Admin

their manual →

Confirms the automatic grant from the user record and the audit log.

  1. 7
    As an admin, open the new user and read their grant list.
    /admin/users/{user_id} rbac
    Expected result The member grant with the reason 'self_registration' and no granting actor.
    Watch out for A null actor means the system granted it. Every human-made grant on this page carries a required reason and the name of who typed it.
  2. 8
    Find the same event in the RBAC audit log.
    Expected result One grant row for the registration.
    Watch out for This table is append-only at the database level — the triggers abort an UPDATE or DELETE even from the admin data suite. Nobody can quietly rewrite who was given what.

Get your host account and event access

What approval actually creates: a draft event, your user account, and a grant scoped to one event id.

Owned by Host / Promoter · 9 steps · about 15 minutes

Why this exists

This is the moment you stop being an applicant and become a host, and it is worth knowing exactly what the platform does — because it does three things in a single transaction and none of them are done by hand.

Conversion takes your approved application and creates a draft event from it: the name, description and slot times are copied, and the ticket tiers are built from your tier estimates in order (the first opens as soon as the event goes on sale, later ones cascade behind it, with door zones inferred from the tier names). Then it creates-or-reuses your user account. Then it grants you the host role.

Nothing secret is ever put in your inbox. If you had no account, the one made for you is passwordless — it is created with a random hash nobody holds and password_set off — and you receive a magic sign-in link, the same identity machinery the rest of the platform uses. An older build mailed a plaintext temporary password; that was retired on 2026-08-19 after an audit, and the reason is worth carrying: a credential in an email is a credential in a mailbox, a mail server and this platform's own outbound log. Set a password once you are in, from your account page.

This branch is also nearly unreachable, which is why it is easy to be wrong about. Since the application form began requiring an account, every normally-filed application already has a user behind it and conversion simply reuses it. Only staff-entered edge paths land here.

That grant is the thing to understand. It is scoped: it carries an event_id, and every permission check asks "is this person a host of this event". You are not "a host" in general. Two promoters with host grants cannot see each other's events at all — not the dashboard, not the sales, not the guest list, not even the draft page.

And your account carries only that grant. Conversion does not make you a member, so member-only surfaces are not yours by default. Inside your own event you are effectively read-only: you can see it, see its sales, sign its contract and invite guests against the comp buckets allocated to you. You do not price it, transition it, refund it, or touch its money. That separation is what the contract is for — it is the record of what the venue agreed to do on your behalf.

Before you start

  • An approved application (approval and conversion are two separate staff actions).
  • Access to the email address on the application — the magic sign-in link goes there.

Practise with

PersonaEmailPasswordNote
host_promoterhost@club.test host123already holds a scoped host grant on demo-event-0001
venue_managermanager@club.test manager123runs the conversion
adminadmin@club.test admin123grants and revokes roles, and can read the audit trail behind them

Steps 1 — Venue Manager

their manual →

Runs the conversion that creates the event, your account and your scoped grant.

  1. 1
    From the approved application, press the convert button: create the draft event and grant the host role.
    /api/intake/applications/{app_id}/convert POST intake
    Expected result 201 with the new event id. In one transaction: a draft event with tiers built from the tier estimates, the host user (created if new), and a host grant carrying that event id. The application is stamped with the event and host user ids.
    Watch out for 409 application_not_approved if you skipped approval, and 409 already_converted with the existing event id inside the error message if someone beat you to it. It is all-or-nothing: there is no state where an event exists but the host cannot log in.

Steps 2–7 — Host / Promoter

their manual →
  1. 2
    Open the sign-in link from your email, or the login page if you already had an account.
    /login core
    Expected result The standard login form — there is no separate host portal. A brand-new host account has NO password: conversion creates it passwordless and sends a one-time sign-in link instead.
    Watch out for Nothing secret is ever mailed. The account is created with a random hash nobody holds and password_set off, and the link is the same identity machinery the rest of the platform uses — corrected here 2026-08-29, having still described the plaintext temporary password this workflow's own intent says was retired on 2026-08-19. If you already had an account under that email, no new one was created and no link was sent: you log in with the password you already had.
  2. 3
    Log in.
    /auth/login POST core
    Expected result A session. Your account carries exactly one grant: host, scoped to your event.
    Watch out for Set a password from your account page once you are in — a converted account starts without one. The link that got you here is single-use: a replayed one renders 'This link has already been used' rather than signing you in.
  3. 4
    Open your event dashboard by its direct URL. This is your home base for the whole run.
    /admin/events/{event_id} events
    Expected result The event details, its tiers, a live sales panel, the status log and recent platform events — with a banner reading 'Read-only view — management actions require the admin role.'
    Watch out for The events list at /admin/events is admin and venue manager only: you get a 403 there even though your own event's page opens fine. Bookmark the direct URL. Another promoter's event id is a 403, not a 404 — the platform does not pretend their event does not exist, it tells you it is not yours.
  4. 5
    Open the public page for your event to see what the world sees.
    /events/{event_id} events
    Expected result While the event is still draft, only you and staff can load this page.
    Watch out for Everyone else gets a plain 404 on a draft event — not a 403. Drafts are invisible, not forbidden. Once the contract is counter-signed the event is announced and the page is public.
  5. 6
    Check your other scoped surface: the guest list dashboard for your event.
    /admin/guestlist/{event_id} guestlist
    Expected result Your comp buckets and their entries — and only the buckets whose owner is you.
    Watch out for It is normal for this to be empty at first. Allocating a bucket to you is a venue decision and it happens separately.
  6. 7
    Learn the boundary before you hit it. Even on your own event you cannot create or change tiers, prices, capacity or the event status; you cannot refund an order, read the ledger, or take money outside the contracted royalty; and you cannot see any other event or any venue-wide report.
    Expected result 403 forbidden on every one of those, with a message naming the role required.
    Watch out for The one admin-shaped thing you can do is read your own event's sales figures. That is deliberate, and it is the subject of its own workflow.

Steps 8–9 — Admin

their manual →

Owns role grants and revocations — the only person who can fix a grant pointed at the wrong event.

  1. 8
    As an admin, open the host's user record and confirm the grant.
    /admin/users/{user_id} rbac
    Expected result The host group shown as a scoped chip carrying the event id, plus the audit rows behind the grant.
    Watch out for The grant's audit reason records which application approved it, so a grant can always be traced back to paperwork.
  2. 9
    Grant or re-grant a scoped host role by hand when a conversion went to the wrong account.
    /api/rbac/users/{user_id}/groups POST rbac
    Expected result 201 with the new grant, and an audit row naming you as the actor.
    Watch out for A host grant without an event id is meaningless and is rejected. Revoke the wrong grant rather than deleting the user — the revocation is part of the record.

Leave feedback on any part of the app

Turn on feedback mode, click the ✎ on the thing that is wrong, and watch it land in the review backlog.

Owned by Member · 9 steps · about 6 minutes

Why this exists

Feedback on this platform is not collected in a form on a contact page. It is attached to the thing you are complaining about: a note carries the page path, the element it was left on, a text snippet of what that element said, and the viewport width you were using. So "this is confusing" arrives already answering "what is, and where".

Why it is a mode and not an always-on click target. Every page is full of real links, buttons and forms. If clicking an element left a note, the app would be unusable. So you switch on feedback mode, and while it is on each block you hover grows a dashed outline and a ✎ button pinned to its corner. The ✎ lives in an overlay layer, never inside the block, which is why links and forms keep working normally while the mode is on. You click the pencil, never the element.

Why other people's notes are visible to you. The point is a shared review list. A per-person silo would produce five copies of "this heading is wrong" and no discussion, so on any page you can already open you see every note left on it, and you can reply. Two carve-outs keep that safe: notes on staff-only path prefixes (/admin, /debug, /scanner) are readable by employees only, because a captured snippet from an admin grid can contain other members' data; and a single setting narrows every non-staff surface to "your own notes" if this instance ever holds real user data.

Anchors are allowed to go stale, on purpose. A note points at an element with a generated CSS selector. Redesign the page and the element may be gone — the note is then flagged unanchored and kept, never deleted, because "the thing I complained about no longer exists" is usually the signal that it was fixed. The same applies to notes pinned to a moment in a workflow clip: re-record the clip and the note re-anchors to the same numbered step, or is flagged as an orphan if the step is gone.

Before you start

  • A signed-in session — any role. The overlay is not rendered for anonymous visitors.
  • Nothing else. Feedback mode is available on every page of the app.

Practise with

PersonaEmailPasswordNote
membermember@club.test member123can leave feedback and see their own backlog
venue_managermanager@club.test manager123can triage and export the whole backlog

Steps 1–6 — Member

their manual →
  1. 1
    Open any page of the app. Find the ✎ Feedback pill at the bottom-right of the window — it is on every page while you are signed in. The number on it, when there is one, is how many notes already exist on this page.
    /events annotations
    Expected result A labelled pill in the bottom-right corner, plus a menu entry under Feedback in the site menu.
    Watch out for It steps above a page that owns the bottom of the window (the contract signing bar, for example) rather than covering it.
  2. 2
    Click the pill, then choose Start leaving feedback. The keyboard shortcut Shift+A does the same thing from anywhere. The pill turns solid and reads Feedback: ON.
    /api/annotations/mode POST annotations
    Expected result A one-time hint explains the gesture, and the mode sticks: it is stored per user, so it is still on when you open the next page.
    Watch out for It is a per-user preference, not a per-page one. Turn it off when you are done or every page keeps outlining blocks as you hover.
  3. 3
    Hover the thing you want to talk about — a heading, a card, a table, a form. It gets a dashed outline. Click the that appears in its top-right corner: the pencil, not the element.
    Expected result A small compose popover opens next to that block.
    Watch out for Clicking the block itself does what it always did — follows the link, submits the form. That is deliberate.
  4. 4
    Write what is wrong, missing or confusing. Pick a category (bug / copy / layout / feature / question) and a priority, then save. Ctrl or Cmd + Enter saves; Esc cancels.
    /api/annotations POST annotations
    Expected result A numbered marker appears on that block. The numbers are assigned server-side, so everyone reviewing the page sees the same ones.
    Watch out for Notes are shared, and on this demo deployment the admin login is published in the banner — do not put anything sensitive in a note.
  5. 5
    Watching a recorded workflow clip instead of using a page? Press n while it plays. The video pauses, the current frame is captured, and the note is pinned to that workflow step and millisecond.
    /clips/{workflow_slug} clips
    Expected result The backlog item carries the workflow, step label, timestamp and a thumbnail, and links back into the player at that moment.
  6. 6
    Review what you have said: open Feedback in the site menu. That page is your own slice — notes you wrote, notes assigned to you, and notes you replied to — filterable by status.
    /my/annotations annotations
    Expected result Your notes, newest activity first, with their status and replies.
    Watch out for This is not the whole backlog. Staff triage the shared queue elsewhere; you only ever see what you are involved in here.

Steps 7–9 — Venue Manager

their manual →

Triages what comes in: sets priority, category and assignee, and exports the build list.

  1. 7
    Open the shared review queue from Feedback Review in the site menu. Filter by page, status, priority, category, anchor health, author or free text; the page-counts table shows where the complaints cluster.
    /admin/annotations annotations
    Expected result Every non-deleted note in the app, in one triageable list.
    Watch out for A note whose page column is shown as plain text rather than a link is a clip note: that value is the route pattern the step was on, not a page you can open.
  2. 8
    Open one note. Change its status through the pipeline, set priority and category, assign it to somebody, and reply in the thread.
    /admin/annotations/{annotation_id} POST annotations
    Expected result Every change is written to an append-only audit trail shown on the same page.
    Watch out for You may triage someone's note but never rewrite its body — only the author or an admin can edit the words. Deleting across authors, and restoring, are admin-only.
  3. 9
    Export the current filter set as a Markdown checklist, CSV or JSON. The Markdown one is a build list you can paste straight into a tracker.
    Expected result A downloaded file covering exactly the rows your filters selected, and an export entry in the audit trail.
    Watch out for Exports are attachments with nosniff, and CSV cells that begin with = + - or @ are prefixed with an apostrophe — spreadsheet formula injection is a real risk with user-written text.

Manage your account, password and sessions

Change your password, see every device you are signed in on, and sign the others out.

Owned by Member · 6 steps · about 10 minutes

Why this exists

Account self-service on this platform is built around one rule: a member may see and end their own sessions, and nothing else. There is no self-service delete, no self-service group request, and no way to look at anyone else's anything.

The password change is the interesting part. Changing your password revokes every other session immediately and keeps only the one you are using. That is the built behaviour of "my account is compromised": one action, all other devices out, no waiting for a token to expire. Sessions are re-read on every request, so a revocation is effective on the very next click rather than at the next login.

The step that surprises people is deactivation, which only an admin can do. Deactivating an account does not just block login — it revokes every live door pass the user holds, blacklists the serials and pushes a void to their wallet. A pass never authenticates a session, so without that step a banned member's phone would keep opening doors for months.

Before you start

  • A member session (any registered account).
  • An admin session for the participant steps.

Practise with

PersonaEmailPasswordNote
membermember@club.test member123if you change this password, change it back — other workflows use it
adminadmin@club.test admin123grants, revokes and deactivations

Steps 1–4 — Member

their manual →
  1. 1
    Open your account page and read the three blocks: profile, grants and zones, active sessions.
    /account rbac
    Expected result One row per live session with where and when it was created, and your current one marked.
  2. 2
    Look at the same facts as JSON — it is the cleanest way to see what the server thinks you are.
    /auth/me rbac
    Expected result Your user, your groups with any event scope, your zones both global and per event, and your session expiry.
    Watch out for A scoped grant (a host on one event) contributes zones only for that event. Globally it contributes nothing, which is why a promoter's card does not open the building on someone else's night.
  3. 3
    Change your password. You need the current one.
    /auth/password POST rbac
    Expected result Success, and every other session of yours is signed out on the spot.
    Watch out for A wrong current password is a 401 and nothing changes. And be deliberate on a shared demo database: the seeded logins are documented in this manual, so changing one breaks the next trainee.
  4. 4
    Sign out one specific session — the laptop you left at a friend's flat.
    /auth/sessions/{session_id}/revoke POST rbac
    Expected result That session dies immediately; the list refreshes without it.
    Watch out for You can only revoke your own sessions; someone else's id is a 404, not a 403, so ids cannot be probed. Revoking the session you are currently using logs you out and drops you at the home page.

Steps 5–6 — Admin

their manual →

Owns everything a member cannot do to their own account: groups, deactivation, forced sign-out.

  1. 5
    As an admin, open a member and look at what only you can do: grant or revoke a group with a written reason, force every session out, deactivate.
    /admin/users/{user_id} rbac
    Expected result The grant timeline, the session list and the deactivate control.
    Watch out for A reason is required on every grant and revoke. This is enforced by the service, not the form — the API refuses a blank reason too.
  2. 6
    Deactivate a throwaway account and then look at the access dashboard.
    /admin/access access
    Expected result The user's passes appear in the revocation list with a wallet void recorded against them.
    Watch out for Pass revocation is terminal. Reactivating the account re-mints a membership card but does NOT bring event-ticket passes back — those tickets need re-issuing. Never deactivate an account as a way of 'pausing' someone.

Find your way around as staff

The five screens an employee actually uses, and the long list of things employees do not do.

Owned by Venue Manager · 11 steps · about 12 minutes

Why this exists

There are two employee roles on this platform and one idea behind both of them: operations are separated from money and identity. An employee can run a night — admit people, chase a guest list, watch the sales board, post to social — without ever being able to move a cent, grant themselves a role, or bind the venue to a contract. That separation is what lets you hire a manager on Monday and put them on the floor on Tuesday.

The role model has three moving parts worth learning once. First, venue_manager implies door_staff: a manager silently has every door permission as well, so you never have to hold two logins. Second, employee is a pseudo-group — it is not a role anybody is granted, it is a gate that means door_staff or venue_manager or admin, and it is what opens the scanner. Third, admin is a superuser: an admin passes every group check, which is why so many of the surfaces below say admin-only rather than listing exceptions.

The most common new-starter mistake is assuming that because a page is under the admin URL prefix, a manager can open it. Some can, some cannot, and the split is not cosmetic. /admin itself — the platform hub card page — is admin-only. So are the ledger, the tax office, payouts, refunds, the universal data suite, role management, and every write that changes an event's status, tiers or prices. Learn the boundary now and you will stop filing tickets about 403s that are working as intended.

The one thing no employee ever does is author an event. Events come from host applications and from admins; a manager reviews, converts and then monitors. If you came here looking for how an event is built, you want the Host / Promoter manual instead.

Before you start

  • A seeded database (run the seed once so the demo readers, event and applications exist).
  • A door staff or venue manager login.

Practise with

PersonaEmailPasswordNote
venue_managermanager@club.test manager123shift lead: implies door_staff, plus intake, sales, guest list, marketing
door_staffdoor@club.test door123door shift account: scanner + guest list, nothing else
adminadmin@club.test admin123the only account that can touch money, identity and contracts

Steps 1–7 — Venue Manager

their manual →
  1. 1
    Sign in as the manager and open the scanner. This is the surface every employee shares — it is gated to the employee pseudo-group, so door staff, managers and admins all land here.
    /scanner access
    Expected result The Door Scanner page with a reader picker, a scan box and a Recent scans table.
    Watch out for If you get a 403 here, you are signed in as a member or a host, not as staff. Employee is not a group you can be granted — check which of door_staff / venue_manager / admin you actually hold.
  2. 2
    Follow the Guest list search link at the bottom of the scanner. Pick an event from the dropdown so the page has something to search.
    /scanner/guestlist guestlist
    Expected result The Guest List — Door page with a search box and an override counter reading overrides used / cap.
    Watch out for The event dropdown only lists events in announced, on_sale, sold_out or in_progress. A draft event never appears here, and that is deliberate — you cannot check people into a night that has not been announced.
  3. 3
    Open the host application queue. This is the first surface that is yours and not the door's.
    /admin/intake intake
    Expected result A six-column pipeline board with viability scores and status filters.
    Watch out for Door staff get a 403 here. Deciding what goes on the calendar is not a door decision.
  4. 4
    Open the events list — your live sales board.
    /admin/events events
    Expected result Every event with its status and sales figures.
    Watch out for You can read this and every event dashboard behind it, but the edit controls are hidden: the page computes an edit flag that is true only for admins. Creating an event, changing its status, and editing tiers or prices are all admin-only writes.
  5. 5
    Open the marketing dashboard, the last of your five core screens.
    /admin/marketing marketing
    Expected result Channels, recent posts and scheduled posts.
    Watch out for You can compose, schedule, publish, cancel and retry posts. You cannot enable or disable a channel or change its handle, and you cannot delete a post — those are admin writes.
  6. 6
    Understand what is NOT yours: you do not author events. Read the Host / Promoter manual to see where events actually come from — an application, a viability review, an approval, a signed contract, then a draft event.
    Expected result The host manual, with the event-creation workflows listed under their own owner.
    Watch out for A promoter asking you to 'just add a date' is asking for something no employee role can do. The answer is an application, or an admin.
  7. 7
    Memorise the 403 list rather than discovering it at 1am. As a manager you are refused on: the platform admin hub, the ledger and trial balance, the tax office, payouts and refunds, role grants and revocations, the universal data suite and its audit log, event status transitions, tier and price edits, contract section and rider edits, contract lock and countersign, reader provisioning and signing-key rotation, guest-list door-override settings, marketing channel settings, and the intake settings page.
    Expected result A clear mental line: you run the night, an admin runs the money and the identities.
    Watch out for Being refused is not a bug report. If you genuinely need one of these, the answer is to get an admin, not to get your role changed.

Steps 8–9 — Door Staff

their manual →

Learns the two screens their role actually opens, and what a 403 there means.

  1. 8
    As door staff, sign in and confirm your world is two screens: this one and the guest list. Everything else you will try is a 403.
    /scanner access
    Expected result The scanner, with the offline bundle control replaced by a badge reading bundle: manager only.
    Watch out for You cannot download the offline bundle or watch the push feed from a browser. Both ship every pass secret for the venue, so they need a manager session or a provisioned reader's own device token.
  2. 9
    Try the guest-list admin index once so you know what the refusal looks like.
    /admin/guestlist guestlist
    Expected result A 403. Allocating comps is a manager or admin job; checking them in is yours.
    Watch out for Door staff DO have read access to a specific event's guest-list dashboard when they open it directly, but not to this index. Work from /scanner/guestlist during a shift — it is the screen built for your job.

Steps 10–11 — Admin

their manual →

Owns everything on the money-and-identity side of the boundary this workflow teaches.

  1. 10
    As an admin, open the platform hub to see the other side of the boundary — every card here is a surface no employee reaches.
    /admin adminsuite
    Expected result The admin dashboard with module cards, including Training Manual.
    Watch out for This page is admin-only. If you are writing a runbook for managers, never send them here; send them to the specific screen their role can open.
  2. 11
    Look at the group list and the zones each group maps to, so you can answer the question 'why did the door say wrong zone'.
    Expected result Groups with their zone mappings.
    Watch out for venue_manager implies door_staff and vip_member implies member. Granting venue_manager therefore hands over the whole door as well — that is intended, but it means there is no such thing as a manager who cannot scan.

Events & Ticketing

From a promoter's application to a customer holding a ticket.

The buying side of the platform: the event catalogue, tiered releases, reservation holds, checkout and refunds.

Create and launch an event

From a public application to a signed contract to a live, on-sale event.

Owned by Host / Promoter · 15 steps · about 25 minutes

Why this exists

This is the workflow the whole platform is shaped around, so it is worth understanding the design before the clicks.

An event is not created by staff. There is no "new event" form a manager fills in on a promoter's behalf. The event is converted from the promoter's own application, and the promoter's user account and event-scoped grant are created by that conversion. The reason is accountability: every event traces back to a specific application, submitted by a specific person, with the numbers they themselves committed to. Staff approve, price and publish — they do not invent.

The contract is the gate, not a formality. Approval drafts a contract automatically; locking it freezes a canonical rendering and takes its SHA-256 hash; the promoter signs a tokenised link; an admin counter-signs. Only the counter-signature flips the event from draft to announced. So an event cannot go public before both parties have signed the exact document that was hashed — which is why locking is irreversible-ish and why the seal stores the hash rather than a promise.

Built behaviour worth knowing: the counter-signature moves the event draft to announced. There is no contract_pending or approved event status in this platform — announced simply means publicly visible. Going on sale is a separate, deliberate admin action afterwards.

Before you start

  • Nothing at all to start: the application form is public and needs no login.
  • A venue manager or admin session to review and approve the application.
  • An admin session to lock and counter-sign the contract, and to put the event on sale.

Practise with

PersonaEmailPasswordNote
host_promoterhost@club.test host123the seeded promoter; already holds a scoped host grant on demo-event-0001
venue_managermanager@club.test manager123can review and approve applications, but cannot lock or counter-sign
adminadmin@club.test admin123the only persona that can counter-sign and transition the event

Steps 1–3 — Host / Promoter

their manual →
  1. 1
    Open the public host application form. No account, no login, nothing to set up first.
    /host/apply intake
    Expected result The form loads for anyone. If you happen to be logged in already, it prefills what it knows about you.
    Watch out for Returning hosts get suggested values highlighted in red — the platform will not accept them until you explicitly confirm each one, and that confirm gate is enforced on the server, not just in the browser.
  2. 2
    Submit the application: the date you want, your expected headcount range, your projected average ticket price, and the flags for live music, alcohol, a late finish and whether you want a ticket royalty.
    Expected result You get an application id back and the status is new_submission.
    Watch out for Those flags are not decoration. Each one automatically injects a rider into the contract later, so a wrong flag here becomes a wrong clause in a document you will be asked to sign.
  3. 3
    Land on the thank-you page and keep the link. It is your window into the application while staff review it.
    /apply/thanks/{app_id} intake
    Expected result You can see the status, and an edit link if staff ask you for more detail.

Steps 4–7 — Venue Manager

their manual →

Reviews the application, chases missing detail, and approves it.

  1. 4
    Open the intake queue. New applications sort to the top with their viability score.
    /admin/intake intake
    Expected result A list of applications with status and score.
    Watch out for The score is a scorecard, not a decision. It weights date, headcount and projected revenue — it does not know that the promoter's last night was a disaster.
  2. 5
    Open the application and read the whole scorecard: the proposed date against the calendar, the headcount against the room, the rider flags, and any attachments.
    /admin/intake/{app_id} intake
    Expected result The full application with its status history, notes and the rider summary the contract will be built from.
  3. 6
    Approve it.
    /api/intake/applications/{app_id}/approve POST intake
    Expected result Status moves to approved and — in the same transaction — a draft contract is created automatically, with the riders your flags implied and the headcount, ticket price and event date snapshotted into it.
    Watch out for Approval is the moment the contract exists. If the contract engine were to fail here, the approval itself rolls back: there is deliberately no state where an application is approved but has no paperwork.
  4. 7
    Convert the approved application into a draft event. This is also what creates the promoter's user account (if they did not have one) and grants them a host role scoped to this event id.
    /api/intake/applications/{app_id}/convert POST intake
    Expected result A draft event, and a promoter who can now log in and see exactly one event: theirs.
    Watch out for The grant carries the event id. The promoter is not 'a host' in general — they are a host of this event only.

Steps 8–9 — Admin

their manual →

Locks and counter-signs the contract, then puts the event on sale.

  1. 8
    Open the drafted contract. Check the auto-injected riders and fill in any variable still blank — headcount, ticket price and event date are rendered inline in blue.
    /admin/contracts/{contract_id} contracts
    Expected result A section-by-section editable document; every edit you make is recorded as a redline.
    Watch out for A missing variable blocks the lock. That is the intended failure: an unpriced contract must never reach a signature.
  2. 9
    Lock the contract.
    /contracts/{contract_id}/lock POST contracts
    Expected result The document is rendered canonically, hashed with SHA-256, and a tokenised signing link is emailed to the promoter.
    Watch out for Locking is the point of no return for the wording. Everything after this signs the hashed document — if the wording is wrong, unlock and re-lock rather than editing around it.

Steps 10–11 — Host / Promoter

their manual →
  1. 10
    Open the signing link from your email. It needs no login — the token is the credential.
    /sign/{token} contracts
    Expected result The full agreement, exactly as locked, with your rider clauses and your numbers.
    Watch out for The link expires. If it has, ask for it to be resent rather than hunting for another way in.
  2. 11
    Sign it.
    /sign/{token} POST contracts
    Expected result Your signature is recorded against the locked hash and the contract moves to awaiting counter-signature.
    Watch out for You are signing the hash. If the document you are reading were altered afterwards, the seal would not verify — which is exactly the guarantee the hash exists to give you.

Steps 12–14 — Admin

their manual →

Locks and counter-signs the contract, then puts the event on sale.

  1. 12
    Counter-sign on behalf of the venue.
    /contracts/{contract_id}/countersign POST contracts
    Expected result The contract seals into an immutable hash-verified document, and the linked event flips from draft to announced in the same transaction.
    Watch out for Announced means publicly visible, not on sale. Nobody can buy yet — that is the next step, and it is separate on purpose.
  2. 13
    Open the event dashboard and set up what the customer will actually see: tiers, prices, capacity and the release schedule.
    /admin/events/{event_id} events
    Expected result The event with its tiers, zone mapping and availability.
  3. 14
    Transition the event to on_sale.
    /api/events/{event_id}/transition POST events
    Expected result The event is live; the storefront shows it as buyable and the marketing rules watching for it can fire.
    Watch out for This endpoint is admin-only. A venue manager gets a 403 here even though they can see the dashboard — putting inventory on sale is a money decision.

Steps 15 — Member

their manual →

The reason the whole thing exists — they are who finally sees it on sale.

  1. 15
    Look at the event as a customer does, and buy one. Nothing in the manual is finished until you have seen the thing you built from the outside.
    /events/{event_id} events
    Expected result The public event page with live tiers and a working buy button.

Buy a ticket

Browse as a stranger, register, hold a reservation, pay, and hold a ticket.

Owned by Customer (Guest) · 14 steps · about 15 minutes

Why this exists

This workflow belongs to the customer, which is why it is owned by the guest persona rather than by a member: it starts with a stranger who has no account, and the account is something that happens during the purchase, not before it.

The design idea that everything else hangs off is the hold. Adding tickets to your cart reserves nothing; the inventory is only taken when a reservation is created, and that reservation has a short TTL. Between those two moments the tier's reserved count goes up, the available count goes down, and if you wander off, a sweeper returns the inventory. Overselling is therefore impossible by construction rather than by luck: the check and the decrement happen in one transaction, and whoever wins the write lock wins the ticket.

Payment is deliberately the last and smallest step. By the time you pay, the price, the tax and the inventory are already pinned down and snapshotted on the order. Paying converts a hold into sold inventory and issues the ticket rows; it does not decide anything.

Before you start

  • An event that is on_sale with at least one tier that has availability.
  • Nothing else — the browsing half of this workflow needs no account.

Practise with

PersonaEmailPasswordNote
membermember@club.test member123an ordinary member; use this once you reach the checkout half
vip_membervip@club.test vip123has a VIP tab, so the house-credit payment option is available
adminadmin@club.test admin123to see the same order from the staff side

Steps 1–5 — Customer (Guest)

their manual →
  1. 1
    Start where a customer starts: the home page. Do this logged out.
    / frontend
    Expected result Upcoming events, plus any site announcement targeted at the public.
  2. 2
    Open the catalogue and find an event that is on sale.
    /events events
    Expected result Only events that are announced or on sale appear. Drafts are invisible to the public.
  3. 3
    Open the event and read the tier breakdown: what each tier costs, what is left, and which zones it opens.
    /events/{event_id} events
    Expected result Live availability per tier.
    Watch out for A tier can be visible but locked — tiers unlock on a schedule or when the previous tier sells out. Sold out is not the same as closed.
  4. 4
    Register. This is the boundary between browsing and buying.
    /register rbac
    Expected result The registration form.
  5. 5
    Submit the registration.
    /auth/register POST rbac
    Expected result You get an account, a session, and the member group automatically. You are a Member from this point on.
    Watch out for The member group is granted at registration — nobody has to approve you. Every other group in the platform is granted by an admin or by a system trigger.

Steps 6–10 — Member

their manual →

Everything from the cart onwards needs a session — the buying half is theirs.

  1. 6
    Open the event's checkout page and choose your tier and quantity.
    /events/{event_id}/checkout payments
    Expected result The tiers with live availability, your cart for this event, and the refund policy that will apply.
    Watch out for There is a per-tier maximum per order. It is a platform setting, not a suggestion.
  2. 7
    Add the tickets to your cart.
    /api/cart/items POST payments
    Expected result A cart line with the price snapshotted at the moment you added it.
    Watch out for A cart reserves NOTHING. Someone else can still buy the last ticket while it sits there. The price snapshot exists so that a price change between adding and checking out is caught and shown to you rather than silently applied.
  3. 8
    Check out. This is the moment that matters: it creates the reservation that actually holds the inventory, and an order to pay for it.
    /api/checkout POST payments
    Expected result An order awaiting payment, with a countdown.
    Watch out for This is where you find out you were too slow. If the tier sold out between your cart and here, checkout fails with an inventory error and nothing is charged — that is the oversell guard doing its job.
  4. 9
    Land on the payment page and watch the hold countdown.
    /checkout/{order_id} payments
    Expected result The order total with tax broken out, your available house credit, and the payment methods.
    Watch out for This page also accepts a reservation id: the events buy button sends you here with the reservation, and the page adopts it into an order for you. Both URLs are legitimate.
  5. 10
    Pay with a card.
    /api/orders/{order_id}/pay POST payments
    Expected result The hold is committed into sold inventory, the payment is recorded, a balanced ledger transaction is posted, and your ticket rows are issued.
    Watch out for If the hold expired while you were finding your wallet, this fails with a conflict and the inventory has already gone back on sale. Start again — nothing was charged.

Steps 11–12 — VIP Member

their manual →

Can settle the same order from house credit or the VIP tab instead of a card.

  1. 11
    As a VIP, check your purchasing power before paying: prepaid credit, earned resale balance, and your remaining tab headroom.
    /my/credit ledger
    Expected result The three balances and the tab limit.
    Watch out for Purchasing power is not one pot. Prepaid is money you put in, earned is money you made reselling, and the tab is money you have not paid yet — the platform spends them in that order.
  2. 12
    Pay the same order with house_credit instead of a card.
    /api/orders/{order_id}/pay POST payments
    Expected result The order settles from promo credit first, then prepaid, then earned, then against your tab headroom — as one balanced ledger transaction.
    Watch out for If the total exceeds what all four can cover, the payment is refused outright rather than partially settled. There is no half-paid order.

Steps 13 — Member

their manual →

Everything from the cart onwards needs a session — the buying half is theirs.

  1. 13
    Look at what you now own.
    /my/tickets payments
    Expected result Your ticket, ready to be turned into a wallet pass.
    Watch out for A ticket and a pass are different things. The ticket is the entitlement; the pass is the door credential minted from it. Getting through the door is its own workflow.

Steps 14 — Admin

their manual →

Sees the finished order from the staff side, and is the only one who can refund it.

  1. 14
    Open the same order from the staff side to see what the customer's purchase looks like to you: line items, tax, the payment, and the refund controls.
    /admin/orders/{order_id} payments
    Expected result The order with its full event history.
    Watch out for Refunding is admin-only, and it is the one action here that moves money outward. A venue manager can read this page but not refund from it.

Hold a checkout reservation (and the timer that kills it)

Understand the hold: what reserves inventory, how long you get, and what happens when it lapses.

Owned by Member · 13 steps · about 15 minutes

Why this exists

This is the mechanism the whole ticketing side is built on, and it is worth ten minutes of anyone's time because almost every confusing checkout error traces back to it.

A cart reserves nothing. Adding an item writes a cart line with the price snapshotted at that moment, and takes zero inventory. Someone else can buy the last ticket while your cart sits open, and they should be able to — inventory belongs to whoever is actually trying to complete a purchase, not to whoever browsed first.

Checkout creates the hold. The reservation is where the tier's reserved count goes up and its available count goes down, all inside one write transaction with a database CHECK that makes overselling impossible even under contention: the check and the decrement cannot be separated, and whoever wins the write lock wins the ticket. The hold carries a TTL from the event (between 5 and 10 minutes; 10 by default) and the order shows it as a countdown.

An expired hold returns the inventory, and the return is committed before you are told. That ordering is deliberate: by the moment you see "hold expired", the seats are genuinely back on sale for everyone else rather than sitting in limbo while your browser catches up. You were not charged, because payment is the last step and it re-checks the hold before touching a card.

Finally, holds are rationed. Each user may keep only a small number of active holds per event (two by default), so a script cannot quietly sit on the room.

Before you start

  • A member session.
  • An event that is on sale with availability in at least one tier.
  • An admin session for the last three steps only.

Practise with

PersonaEmailPasswordNote
membermember@club.test member123the buyer
adminadmin@club.test admin123can see live holds and force the sweeper from the debug console

Steps 1–10 — Member

their manual →
  1. 1
    Open the event's Get tickets page and pick a tier and quantity.
    /events/{event_id}/checkout payments
    Expected result Live availability per tier, your cart for this event, and the refund policy that will apply to what you buy.
    Watch out for The quantity ceiling is the smaller of the tier's own per-order maximum and the platform-wide setting. The dropdown will not offer you more than is left.
  2. 2
    Add the tickets. Then wait a moment and reload the page.
    /api/cart/items POST payments
    Expected result A cart line at the current price. The tier's available count is unchanged.
    Watch out for Nothing is held. The price on the line is a snapshot, and it exists so that a price change between adding and checking out is caught and shown to you rather than silently charged.
  3. 3
    Open the cart page to see every event you have lines for, not just this one.
    /cart payments
    Expected result All open cart lines with their events.
  4. 4
    Check out. This is the moment inventory is actually taken.
    /api/checkout POST payments
    Expected result One reservation per event in the cart, an order awaiting payment, and a hold expiry stamped on it.
    Watch out for Three specific refusals live here and none of them charge anything: an empty cart; a price that changed since you added it (the error lists exactly which lines); and insufficient inventory (the error carries the real availability per tier). All three mean 'start again', not 'retry'.
  5. 5
    Learn the other route to the same place: the Reserve button on the public event page creates a hold directly and skips the cart entirely.
    /api/events/{event_id}/reservations POST events
    Expected result A reservation with the seconds remaining on it, then a redirect to the payment page carrying the reservation id.
    Watch out for A 429 here means you already hold the maximum number of active reservations for this event. Release one or let it lapse — a second browser tab does not get you a second allocation.
  6. 6
    Land on the payment page and watch the countdown.
    /checkout/{order_id} payments
    Expected result The order total with tax broken out, your available house credit, the payment methods, and the hold countdown.
    Watch out for This page accepts an order id OR a reservation id. Handed a reservation, it creates the order for you and redirects. Both URLs are legitimate — you are not lost.
  7. 7
    Read the hold itself while it is alive.
    /api/reservations/{reservation_id} events
    Expected result Status active, the per-tier lines with their snapshotted prices, and the seconds remaining.
  8. 8
    Change your mind properly: release the hold instead of walking away.
    /api/reservations/{reservation_id} DELETE events
    Expected result The inventory goes back immediately and the tier can leave sold-out state.
    Watch out for Releasing an already-committed or already-expired hold is a 409. That is not an error you need to fix — it means somebody or something got there first.
  9. 9
    Cancel the unpaid order (send no ticket ids). This is the order-level equivalent of releasing the hold.
    /api/orders/{order_id}/cancel POST payments
    Expected result The order is cancelled and its reservations are released.
    Watch out for If a payment attempt is in flight the cancel is refused with a conflict rather than racing it. The platform will not let you cancel an order that is mid-charge, because the money would be stranded.
  10. 10
    Now let a hold lapse on purpose and try to pay it.
    /api/orders/{order_id}/pay POST payments
    Expected result A 410 hold expired. Nothing was charged and the inventory is already back on sale.
    Watch out for This is the single most common support question on the platform. The answer is always the same: start again, you were not charged, and the seats you wanted may now be someone else's.

Steps 11–13 — Admin

their manual →

Watches the same hold from the inventory side and can force it to expire on demand.

  1. 11
    As an admin, look at what the sweeper would expire right now.
    Expected result The holds that are past their TTL and the inventory that would return.
    Watch out for Debug routes are admin-only and vanish entirely when debug endpoints are switched off. Do not build any habit that depends on them.
  2. 12
    Force the expiry so you can demonstrate the timer without waiting ten minutes.
    Expected result A list of cancelled order ids, and the tiers back on sale.
    Watch out for The sweeper and a live payment can race. That race is settled by the write lock rather than by luck: one of them wins, and there is no state where a hold is both committed and expired.
  3. 13
    Close the loop from the inventory side: list the active holds on the event with the members who own them.
    /api/admin/events/{event_id}/reservations events
    Expected result Live holds with expiry times and buyer emails.
    Watch out for Reserved and sold are different counters. A tier can read sold out purely because of holds and un-sell itself minutes later — that is normal, and it is why 'sold out' on this platform is a state, not a fact.

Pay for an order with house credit

Settle a checkout from your balance instead of a card, and see which pot it comes from.

Owned by Member · 8 steps · about 12 minutes

Why this exists

House credit is a real liability of the venue to you, held on a double-entry ledger, not a loyalty-points balance. That is why paying with it looks nothing like a discount code: it posts a balanced transaction that moves the amount out of the venue's obligation to you and into revenue and tax liability, in one step, with no card involved.

Your purchasing power is deliberately three separate pots spent in a fixed order: prepaid credit you paid in, then earned credit from resale, then — for VIPs only — headroom on the tab, which is money you have not paid at all. The order is not configurable. It exists so the tab is always the last resort and never quietly used while you have real money sitting there.

The rule that catches everyone is that there is no partial settlement. If the total is one cent more than everything you can bring, the payment is refused outright rather than half-paying and asking for a card for the rest. A half-paid order is a support ticket forever; a refused one is a top-up and a retry.

One consequence worth knowing before you buy: refunding an order that was paid with credit returns the money to credit. For a credit-paid order, "refund to the original method" IS house credit — there is no path back to a card that never got charged.

Before you start

  • A member session with some house credit (top it up first if you have none).
  • An unpaid order sitting on its hold.

Practise with

PersonaEmailPasswordNote
memberalice.credit@demo.club alice-pass-123seeded with $150 of prepaid credit and nothing else — the cleanest demo
vip_membervip@club.test vip123$250 tab limit with $80 already used, so the tab path is visible
adminadmin@club.test admin123reads the resulting ledger transaction

Steps 1–4 — Member

their manual →
  1. 1
    Check what you can actually spend before you start.
    /my/credit ledger
    Expected result Prepaid, earned and (if you are VIP) the tab, plus a single purchasing-power figure.
    Watch out for Purchasing power is not money you have. For a VIP it includes tab headroom, which is money you will owe.
  2. 2
    On the payment page, choose the House credit option instead of Card.
    /checkout/{order_id} payments
    Expected result The card fields disappear and the available balance is shown next to the option.
    Watch out for House credit needs no card token. If the form still asks you for one, you have not actually changed the selected method.
  3. 3
    Pay. Send the method as house credit and no token.
    /api/orders/{order_id}/pay POST payments
    Expected result One balanced ledger transaction, the hold committed into sold inventory, and your tickets issued.
    Watch out for A 402 insufficient credit means nothing was posted and nothing was spent — the order is untouched and still on its hold, so top up quickly and retry before the timer runs out.
  4. 4
    Read the movement on your statement and identify which pot it came out of.
    Expected result A debit against promo first if you hold any, then prepaid; earned only once both have run out.
    Watch out for You cannot choose the pot, and the order is promo → prepaid → earned → tab. If you were saving earned credit for a cash-out, spending it here is exactly what the platform will do once promo and prepaid are empty.

Steps 5–6 — VIP Member

their manual →

The only persona whose tab headroom can absorb what prepaid and earned cannot.

  1. 5
    As a VIP, read the balance payload and find the tab block: limit, used, headroom and status.
    Expected result Three pots plus the tab, and a purchasing power that includes the headroom.
    Watch out for A frozen tab contributes zero headroom no matter what the limit says. Freezing is a credit decision — the balance you already owe does not go away.
  2. 6
    Pay an order bigger than your prepaid and earned balances combined.
    /api/orders/{order_id}/pay POST payments
    Expected result Prepaid is consumed, then earned, and only the remainder lands on the tab.
    Watch out for This is a VIP-only path. A plain member with no tab simply gets the 402 at the point prepaid and earned run out — there is no borrowing without a tab, and VIP status alone does not create one. An admin has to set the limit.

Steps 7–8 — Admin

their manual →

Confirms the posting balances and can see the member's balances from the staff side.

  1. 7
    As an admin, open that member's credit page and read the same balances from the staff side.
    /admin/credit/users/{user_id} ledger
    Expected result Their pots, their tab and an adjustment composer.
    Watch out for This page is admin-only. A venue manager gets a 403 on every ledger surface, deliberately — running the calendar and moving money are different jobs.
  2. 8
    Find the transaction the payment posted and check the two sides.
    Expected result Debits equal credits, with the member's house-credit liability on one side and revenue plus tax on the other.
    Watch out for Try to edit it. The database refuses — the append-only triggers fire even from the admin data suite. Corrections are new balanced entries, never edits.

Get into a member-only drop

How tier releases actually work here, what 'member-only' really means, and what VIP does and does not buy you.

Owned by Member · 11 steps · about 15 minutes

Why this exists

Read this one before you promise anything to a customer, because the built platform and the phrase "member-only drop" do not line up the way most people assume.

What a drop is here. A drop is a tier release. A tier either opens on a schedule (it sits in a countdown until its sale start) or it cascades (it stays locked until the tier before it has sold a configured percentage), and the release fires from the scheduler tick, which also runs lazily whenever anyone loads the catalogue or reads availability. So a drop happens on time without a cron job, and the first person to look is the person who triggers the check.

What "member-only" actually is. There is no group restriction on a tier. Tiers have no audience field, so nobody is ever refused a tier for the groups they hold — and "becoming a member" is a 30-second registration rather than an approval. If you need a genuinely restricted allocation — a real invite-only release — the built tool for that is a guest-list comp bucket, not a tier flag.

But the purchase path does read your groups, for one thing: when. A group can carry an early-access head start, and the gate that applies it sits inside the reservation itself, not in the page. early_access_minutes on the membership-benefits table is joined to your groups; if it is greater than zero, a scheduled tier becomes buyable for you that many minutes before its sale start, and for nobody else. The tier's status never flips — a flip would be global, which is the exact opposite of a benefit — so the same tier is buyable for you and refused for the next person, in the same second. Everyone else waits for the scheduler to open it at sale start. Early access widens exactly one gate: locked stays locked and sold out stays sold out.

What VIP changes. The sale window, if the venue has configured it — see above; that is the one place a group changes what you can buy and when. It is a setting on the benefits table, not a property of being VIP, so check the row rather than assuming: a build with every early-access value at zero behaves exactly as though the feature were absent — and that is what ships. Measured 2026-08-27: member, lama_family and vip_member all carry early_access_minutes = 0, so on a stock install nobody has a head start and every buyer meets the same sale start. Somebody has to set the number before any of this is visible. Beyond that, VIP does not buy a lower price on a ticket. CORRECTED 2026-08-30 — it does buy a reduced FEE. VIP carries a 50% discount on the resale venue fee, and house and vendor discounts of 20% and 10%; Lama Family carries a house discount of its own. Those are rows in membership_tier_benefits, read by live consumers, and shown on the tier card. What VIP does not buy is a lower ticket price or a head start. Beyond the fees it buys Zone B on the door credential, a tab that can fund an instant purchase when a drop is a race, and the venue's own comp and announcement targeting. A tier named "VIP Lounge" is an ordinary tier whose zone mapping happens to include Zone B — the name is marketing, the zone is the mechanism.

The one genuinely audience-aware feature is site announcements, which are targeted at an audience: public, member, VIP, host or staff. That is how a drop is announced to members only, even though the sale itself is open to anyone with an account.

Before you start

  • A member session for the buying half.
  • An admin session for the setup half.
  • An event with a tier that is scheduled or cascade-locked (the seeded demo event has both).

Practise with

PersonaEmailPasswordNote
membermember@club.test member123the buyer waiting on the drop
vip_membervip@club.test vip123for the side-by-side: same sale window, different zones and a tab
adminadmin@club.test admin123schedules the tier and targets the announcement

Steps 1–4 — Admin

their manual →

Creates the tier release and is the only persona who can target an announcement at members.

  1. 1
    As an admin, open the event and read the tier ladder in sort order. The order is the cascade order.
    /admin/events/{event_id} events
    Expected result Each tier with its unlock mode, sale window, cap and counts.
    Watch out for The first tier can never be a cascade tier — it has nothing to cascade from, and the API refuses it.
  2. 2
    Create the drop: either a scheduled tier with a sale start at the drop time, or a cascade tier that unlocks when the previous one is a given percentage sold.
    /api/events/{event_id}/tiers POST events
    Expected result A tier in scheduled or locked state, visible to the public but not buyable.
    Watch out for A scheduled tier needs a sale start; a cascade tier needs a predecessor. Both are 422s with named reasons rather than silent defaults.
  3. 3
    Open the announcements editor — this is the only place the platform is audience-aware.
    Expected result The list of announcements with their level, audience and window.
  4. 4
    Publish the drop notice targeted at members (or VIPs) rather than the public.
    /admin/announcements POST frontend
    Expected result The announcement renders for that audience and is simply absent for everyone else.
    Watch out for This targets who sees the message, not who may buy. Anyone with an account can still purchase the tier when it opens — do not write copy that promises otherwise.

Steps 5–8 — Member

their manual →
  1. 5
    As a member, open the hub and read the announcement aimed at you.
    /my frontend
    Expected result The member-targeted notice with the drop time.
    Watch out for If you cannot see a notice a colleague can, compare groups before you suspect a bug. Audience targeting is doing its job.
  2. 6
    Open the event and watch the tier you are waiting for.
    /events/{event_id} events
    Expected result An amber countdown for a scheduled tier, or Locked with the name of the tier it is waiting on for a cascade tier.
    Watch out for A locked tier deliberately publishes no availability number. You cannot tell how many are behind it, and neither can a bot.
  3. 7
    Poll availability as the drop time approaches, and notice what opens the tier.
    /api/events/{event_id}/availability events
    Expected result Availability null while locked, then a real number the moment it opens.
    Watch out for Reading this runs the scheduler check. The tier opens because someone looked — there is no background thread, and a drop nobody is watching opens on the first visit after its time.
  4. 8
    At the drop, go straight to checkout, add your quantity and check out immediately.
    /events/{event_id}/checkout payments
    Expected result A hold, and an order with a countdown.
    Watch out for In a real drop the cart is your enemy: a cart holds nothing. The seconds that matter are between checkout and payment, not between browsing and adding.

Steps 9–10 — VIP Member

their manual →

Shows precisely which VIP differences are real and which are folklore.

  1. 9
    Log in as the VIP and open the same page at the same moment.
    /events/{event_id} events
    Expected result Exactly the same tiers, the same countdown and the same prices.
    Watch out for This is the point of the exercise. There is no VIP presale window in this build. If the venue wants one, the honest implementation today is a separate event or a comp bucket, not a promise about VIP.
  2. 10
    Pay from the tab to see the one advantage that is real in a race: instant settlement without reaching for a card.
    /checkout/{order_id} payments
    Expected result The order settles from promo, then prepaid, then earned, then tab headroom.
    Watch out for The tab is a limit on what you may owe and it is settled monthly. Winning a drop on the tab still means paying for it.

Steps 11 — Admin

their manual →

Creates the tier release and is the only persona who can target an announcement at members.

  1. 11
    Rehearse the drop before the night: force the cascade and watch the next tier open.
    /debug/events/tiers/{tier_id}/force-cascade POST events
    Expected result The locked tier opens and the release event is emitted, flagged as forced.
    Watch out for Marketing rules listen for that release event, so a rehearsal on a live database can genuinely publish a social post. Rehearse on a scratch database, not on production.

Understand your event's tiers, prices and releases

Read the tier ladder on your own event, and learn who can change what — and why it is not you.

Owned by Host / Promoter · 12 steps · about 20 minutes

Why this exists

Your application's tier estimates became real tiers when the event was converted, and from that point the ladder belongs to the venue. This workflow teaches you to read it fluently, because you will be asked about it all night, and it teaches you who to ask when it needs to change.

Why a host cannot price their own event. Tiers are inventory, and inventory is money: a price change moves revenue, a cap change can oversell the room, and a status change can put unsold stock on the market. Those are venue decisions, so every write here is admin-only and the whole dashboard is read-only for you. What was agreed is written down in the contract; if the ladder needs to change, that conversation goes through the venue, not through the UI.

How a tier ladder actually works. Each tier has a cap, a sold count and a reserved count; available is never stored, it is cap minus sold minus reserved, computed every time. A tier unlocks in one of three ways: on a schedule (it opens at its sale start time), by cascade (it sits locked until the tier before it has sold a set percentage, or is closed, or passes its sale end), or manually. Cascade thresholds count tickets actually sold — holds do not count toward a cascade. The first tier in the ladder can never be a cascade tier, because there is nothing for it to cascade from.

Sold out is automatic and two-sided: the moment sold plus reserved equals the cap the tier flips to sold_out — holds do count here — and it flips back automatically when those holds expire or inventory is returned, unless the sale window has closed. So "sold out" on this platform can un-sell-out, and that is correct behaviour, not a glitch.

Before you start

  • A host grant on the event whose dashboard you are reading.
  • An admin session for any of the configuration steps.

Practise with

PersonaEmailPasswordNote
host_promoterhost@club.test host123scoped host on demo-event-0001, which ships a fired cascade to look at
adminadmin@club.test admin123the only persona that can create, price or open a tier
venue_managermanager@club.test manager123can see every event including drafts, but cannot price or transition them
membermember@club.test member123experiences the release schedule from the buyer's side

Steps 1–3 — Host / Promoter

their manual →
  1. 1
    Open your event and read the Tiers table properly: number, name, price, status, Sold / Rsv / Cap, the sale window, and the zones each tier opens.
    /admin/events/{event_id} events
    Expected result One row per tier in cascade order, with your application's tier names on it.
    Watch out for There are no action buttons on those rows for you. That is the read-only view, not a rendering bug.
  2. 2
    Pull the same picture as the buyer's page computes it, so you can see the fields underneath the table.
    /api/events/{event_id}/availability events
    Expected result Per-tier availability and the server time, with the scheduler run lazily first so the numbers are fresh without a cron job.
    Watch out for A locked tier reports its availability as null rather than a number — the platform will not leak how much of an unreleased tier exists.
  3. 3
    Learn the six tier statuses so you can answer questions on the night: locked (not released yet), scheduled (opens at a set time), on_sale, paused (deliberately stopped by staff), sold_out, closed (the window has ended).
    Expected result Every tier is in exactly one of those states, and the dashboard shows it.
    Watch out for Sold out and closed are not the same thing, and the difference matters to a customer: sold out can reopen when holds expire, closed cannot.

Steps 4–8 — Admin

their manual →

Owns every write here: creates tiers, sets prices and caps, maps zones and puts the event on sale.

  1. 4
    Add a tier: name, price, cap, sort order, unlock mode, and either a sale start (scheduled) or a cascade threshold percentage.
    /api/events/{event_id}/tiers POST events
    Expected result 201 with the new tier, slotted into the ladder at its sort order.
    Watch out for 422 cascade_needs_predecessor if you try to make the first tier a cascade tier, 422 sale_start_required for a scheduled tier without a start, and 409 sort_order_taken because sort order is unique per event and it is the cascade order.
  2. 5
    Adjust a tier: price, cap, max per order, window, or status (open now, pause, resume, close).
    /api/tiers/{tier_id} PATCH events
    Expected result The updated tier, with the change reflected immediately in availability.
    Watch out for 422 cap_below_committed — you can never shrink a cap below what is already sold. 409 invalid_tier_transition for an illegal status move. Deleting a tier that has sales is refused outright with 409 tier_has_sales.
  3. 6
    Map the tier to the door zones its ticket should open. This is a full replace, not an add.
    /api/tiers/{tier_id}/zones PUT events
    Expected result The tier's zone set, by code or by id.
    Watch out for This is what a scanner reads at the door: the zone codes are embedded in the pass when the ticket is issued. Change zones after tickets are issued and the already-issued passes keep the zones they were minted with.
  4. 7
    Put the event on sale once the ladder is right.
    /api/events/{event_id}/transition POST events
    Expected result The event moves to on_sale, the storefront shows it as buyable, and the outbox emits the event so marketing rules can fire.
    Watch out for Venue manager and admin (capability events.publish). CANCELLING is the exception and still needs admin: it releases every live hold and fans out to refunds, so the route asks for events.cancel separately and answers 403 to a venue manager. Cancelling also needs a reason (422 reason_required).
  5. 8
    In a training or demo environment, force the next locked tier open so a room can watch a cascade happen without selling out a real tier.
    /debug/events/tiers/{tier_id}/force-cascade POST events
    Expected result The next locked tier unlocks and an outbox row is emitted flagged as forced.
    Watch out for Admin-only debug, and the whole /debug tree 404s when debug endpoints are disabled. In production the scheduler tick and the lazy evaluation on reads do this by themselves.

Steps 9–10 — Venue Manager

their manual →

Sees the same dashboard including drafts, but cannot price or transition anything.

  1. 9
    For an event the club already voted in, skip the host application entirely: fill the backload form once — name, room, times, tiers — pick the status to land at, and submit. Paste a whole season into the box below it if you have one.
    Expected result A real event at the status you asked for. Pick on_sale and it is selling when the page reloads; pick completed for a night that already happened.
    Watch out for It skips the intake workflow, not the rules: an event with no tier cannot go on sale, and a tier ladder over the room's capacity is refused the same way it would be from the manual path. The paste box reports bad lines one by one instead of dropping the batch — preview first. Every backloaded event carries 'backload' as the reason on its status log, which is how you find them later.
  2. 10
    As the venue manager, use the events list to see every event including drafts and their sold-versus-cap totals.
    /admin/events events
    Expected result All events, with tier counts and totals.
    Watch out for Since plan 41 this is a working surface for a venue manager, not a window: create, price, zone and publish are all yours (capabilities events.create / update / publish / tiers.manage). Deleting an event and cancelling one are not — those two are admin, because they destroy a record and trigger mass refunds respectively.

Steps 11 — Member

their manual →

The buyer the whole release schedule is designed for.

  1. 11
    As a buyer, open the public event page and watch the ladder from the outside.
    /events/{event_id} events
    Expected result Live availability per tier, countdowns on scheduled tiers, and a headline when a cascade fires along the lines of 'Early Bird Sold Out — General Admission Now Live!'.
    Watch out for A visible tier can still be locked. Locked, sold out and closed all look like 'you cannot buy this' to a customer and mean three different things to you.

Steps 12 — Host / Promoter

their manual →
  1. 12
    Come back to your dashboard and read the status log and recent outbox rows to see the release history of your own event.
    /admin/events/{event_id} events
    Expected result An append-per-transition log, plus the tier unlock, milestone and sold-out events the platform emitted.
    Watch out for Those outbox rows are what the marketing automation consumes. If a post went out about your event, this is where the trigger came from.

Draw a venue map

Upload a floor plan or start on a blank grid, draw each zone, and give every map in the app its labels.

Owned by Venue Manager · 7 steps · about 15 minutes

Why this exists

The map is an illustration of the zone roster, never its replacement — nothing the door reads comes from the drawing. Zones are drawn as polygons over a floor-plan image or a blank grid; points are stored normalised (0–1), so the same shape survives any plan image size.

Two rules the editor lives by since plan 72: everything it draws is visible while you draw it, and every failure says so — a page that silently ignores a refused save is indistinguishable from a broken one.

Before you start

  • A venue with zones defined
  • venue_manager (or admin) access

Steps 1–7 — Venue Manager

their manual →
  1. 1
    Open the layout editor for a venue with no plan yet.
    /admin/venues/{venue_id}/layout venues
    Expected result The upload form and a “Start on a blank grid” button. No canvas yet.
    Watch out for Nothing to draw on until a layout exists — that is a state, not a failure.
  2. 2
    Start on a blank grid.
    /api/venues/{venue_id}/layout POST venues
    Expected result A visible 50px grid appears.
    Watch out for Before plan 72 the grid painted with an unresolvable stroke and the box looked empty — var() works in CSS declarations, never in SVG presentation attributes.
  3. 3
    Pick a zone and click four corners.
    /admin/venues/{venue_id}/layout venues
    Expected result A dashed outline follows each click, with a corner count under the canvas.
    Watch out for That feedback is the whole difference between drawing and guessing.
  4. 4
    Double-click to close the shape.
    /api/venues/{venue_id}/shapes POST venues
    Expected result The shape saves and reloads as a filled polygon with its zone name.
    Watch out for The double-click only closes — it no longer leaves a stray corner at the closing position.
  5. 5
    Draw a second zone, then delete the first from the list.
    /admin/venues/{venue_id}/layout venues
    Expected result The list and the map agree.
    Watch out for Saving the same zone again replaces its shape — one shape per zone per layout.
  6. 6
    View the public map.
    /venues/{venue_id}/map venues
    Expected result Both zones, labelled.
    Watch out for The label fix lands here too — zone names were invisible on every map surface, and nobody had reported it.
  7. 7
    Try to upload a file that is too large or the wrong type.
    /api/venues/{venue_id}/layout POST venues
    Expected result A clear error beside the form, and nothing changes.
    Watch out for Silent failure was half the original bug: the editor could not distinguish “worked” from “refused”.

Watch your event sell, live

Read the sales dashboard for your own event and understand reserved versus sold.

Owned by Host / Promoter · 7 steps · about 12 minutes

Why this exists

This is the one staff-grade surface a scoped host gets in full, and it exists because a promoter who cannot see their own sales cannot promote. The sales endpoint is explicitly opened to admins, venue managers and the host scoped to that event — and to nobody else, for no other event.

What it will not show you is customer identity. You get counts and money; you do not get who bought what. The list of live holds, with buyer emails, is staff only. A host grant is a grant over an event, not over its audience.

The number people misread is reserved. Adding tickets to a cart reserves nothing at all. Inventory is only taken when a reservation is created at checkout, and that reservation has a short time-to-live (the event's hold TTL, between 5 and 10 minutes, default 10). During that window the tier's reserved count is up and its available count is down, and if the buyer wanders off the hold expires and the inventory comes straight back. So reserved is "someone is at the till", not "sold". Gross revenue on this dashboard counts only committed reservations — money that was actually paid.

This is also why an event can flip to sold out and then quietly un-sell-out twenty minutes later: sold out counts holds, and expiring holds reverse it.

Before you start

  • A host grant on an event that is on sale.
  • Ideally at least one live hold and one paid order to look at (the seed ships both on demo-event-0001).

Practise with

PersonaEmailPasswordNote
host_promoterhost@club.test host123scoped host on demo-event-0001: partly sold, one active hold
venue_managermanager@club.test manager123can see who is holding inventory right now
adminadmin@club.test admin123sees the orders and the money behind the same counts

Steps 1–4 — Host / Promoter

their manual →
  1. 1
    Open your event dashboard and find the Live Sales panel.
    /admin/events/{event_id} events
    Expected result Gross revenue and per-tier sold, reserved, available and percentage sold. The panel refreshes itself every 10 seconds.
    Watch out for Leave it open on a laptop during the on-sale. It is the fastest honest read you have.
  2. 2
    Pull the same dashboard as JSON when you want the raw numbers for your own spreadsheet.
    /api/admin/events/{event_id}/sales events
    Expected result Per-tier sold, reserved, available, percentage and gross in cents, plus the active hold count, the recent outbox rows and the event status log.
    Watch out for This is the single admin-shaped API a scoped host may call, and only for their own event. Put another promoter's event id in the URL and you get 403 forbidden.
  3. 3
    Read reserved and sold as different things: reserved is inventory held by someone mid-checkout on a short timer, sold is paid. Gross counts only what was paid.
    Expected result Reserved numbers that rise and fall on their own through the evening.
    Watch out for Do not announce a sell-out off the back of reserved. A tier at cap because of holds can drop back below cap minutes later when those holds expire, and the platform will reopen it automatically.
  4. 4
    Look at your own event the way your audience does, on the public page, while it is selling.
    /events/{event_id} events
    Expected result Live availability, countdowns, and the cascade headline when a tier unlocks.
    Watch out for This is the page your short links and social posts point at. If it looks wrong to you it looks wrong to everyone.

Steps 5 — Venue Manager

their manual →

Can see the live holds with buyer emails — a host deliberately cannot.

  1. 5
    As staff, look at who is holding inventory right now, with their email addresses and expiry times.
    /api/admin/events/{event_id}/reservations events
    Expected result Active holds with their line items and countdowns.
    Watch out for Admin and venue manager only — a scoped host gets 403 here. Buyer identity is not part of a host grant, and that is the deliberate line between 'your event' and 'our customers'.

Steps 6–7 — Admin

their manual →

Owns the orders, the refunds and the invariant checks behind the same numbers.

  1. 6
    As an admin, open the orders behind those counts: line items, tax, payment and refund controls.
    /admin/orders payments
    Expected result Every order for the event with its full history.
    Watch out for Refunding is admin-only and it is the one action here that moves money outward. A host asking for a refund to be issued is asking an admin, always.
  2. 7
    When the numbers look impossible, dump the event state and check the invariants: expected versus actual reserved counts recomputed from live reservation items.
    /debug/events/{event_id}/state events
    Expected result A full dump with an invariants block that says ok or names the drifting tier.
    Watch out for Admin-only debug. Reserved-count drift is repairable; do not fix it by editing the tier row by hand.

Cancel your own ticket and get refunded

Use the self-cancel window on your order, and understand the fee, the deadline and the credit-only rule.

Owned by Member · 9 steps · about 12 minutes

Why this exists

Self-cancellation is a pre-authorised refund: the venue decides the terms once, per event, and the platform then lets members act inside those terms without asking anyone. That is why the refund it issues is recorded as initiated by the system rather than by a person — nobody approved it, because the policy already did.

Three policy numbers do all the work, and all three are set per event. The cancellation deadline is a number of hours before doors; leave it empty and self-cancellation is simply off for that event. The non-refundable fee is withheld per refunded ticket, and it is snapshotted onto your order line at purchase time — so changing the policy later never rewrites what you were promised when you bought. Credit-only forces the refund to house credit rather than back to the card.

Cancellation is per ticket, not per order. You may cancel two of your four and keep the rest; each ticket may be refunded exactly once, enforced by a unique constraint rather than by the UI hiding a button. Refunding a ticket revokes it and kills its door pass, and the inventory goes straight back on sale.

The demo event ships with a $5 non-refundable fee and a 48-hour deadline, so you can see all of it working without configuring anything.

Before you start

  • A paid order with at least one issued ticket.
  • An event whose refund policy sets a cancellation deadline, and a clock still inside it.
  • An admin session for the participant steps.

Practise with

PersonaEmailPasswordNote
membernova@demo.club nova-pass-123holds a paid order with two tickets on the demo event
adminadmin@club.test admin123sets the policy and is the only persona who can refund outside it

Steps 1–6 — Member

their manual →
  1. 1
    Read the event's policy before you buy, not after. It is public.
    /api/events/{event_id}/refund-policy payments
    Expected result The non-refundable fee, whether refunds are credit-only, and the computed self-cancel deadline.
    Watch out for A null deadline means self-cancellation is disabled for that event entirely. No amount of clicking will produce a cancel button.
  2. 2
    Open your orders and pick the paid one.
    /my/orders payments
    Expected result Orders with their status and totals.
  3. 3
    Open the order. Tick the specific tickets you want to cancel and read the refund preview.
    /my/orders/{order_id} payments
    Expected result A per-ticket checkbox, a live preview of what you would get back, and the policy restated at the bottom of the page.
    Watch out for Tickets that cannot be cancelled have no checkbox at all — already refunded, past the deadline, or sold on the exchange.
  4. 4
    Cancel the selected tickets. Send their ids.
    /api/orders/{order_id}/cancel POST payments
    Expected result A refund for the ticket value plus its exact share of tax, minus the non-refundable fee per ticket, and the inventory returned to the tier.
    Watch out for Three refusals to recognise. Past the deadline is a 403. An event with no deadline configured is a 403 saying self-cancel is disabled. And a ticket you already sold on the resale exchange is a 409 saying it was transferred — you cannot refund a ticket that now belongs to someone else.
  5. 5
    Look at your wallet immediately afterwards.
    Expected result The pass for the cancelled ticket is revoked and shows as void.
    Watch out for Revocation is terminal and it blacklists the serial. Do not cancel a ticket you intend to use — there is no undo, and re-buying gets you a different ticket.
  6. 6
    If the event was credit-only, or you paid with credit in the first place, check where the money landed.
    /my/credit ledger
    Expected result Your prepaid balance has gone up by the refunded amount.
    Watch out for For an order paid with house credit, 'refund to the original method' means house credit. There is no card to refund, so asking for one is not an option the platform offers.

Steps 7–9 — Admin

their manual →

Owns the refund policy that decides whether you can self-cancel at all, and handles everything outside it.

  1. 7
    As an admin, open the policy editor for the event and read the three settings together.
    /admin/events/{event_id}/refund-policy payments
    Expected result Fee, credit-only flag and deadline hours, plus the refund-everyone danger zone.
  2. 8
    Change the policy and observe what it does and does not affect.
    /api/admin/events/{event_id}/refund-policy PUT payments
    Expected result New purchases carry the new fee.
    Watch out for It does not rewrite history. The fee is snapshotted onto existing order lines, so orders placed under the old policy keep the old terms. That is the point of snapshotting, not an oversight.
  3. 9
    Handle the case the policy does not cover: refund from the staff side, with a reason, optionally waiving fees or overriding the policy.
    /admin/orders/{order_id} payments
    Expected result A refund row recording the mode, the reason, who did it, and a snapshot of the policy at the time.
    Watch out for Refunding is admin-only. A venue manager can read this page and cannot refund from it — moving money outward is a deliberately narrower permission than running the night.

Monitor live sales on the night

Read the sales dashboard, the open holds and the order list without being able to touch prices.

Owned by Venue Manager · 10 steps · about 14 minutes

Why this exists

The sales board is a read surface for a manager, and that is a deliberate design decision rather than an oversight. You are the person who has to answer 'are we going to fill it', 'how many are inside', and 'why is that tier still showing available when the room is full' — and you can answer all three from here. What you cannot do from here is change a price, move a tier, or transition an event's status, because those decisions change what customers have already been sold.

The number that confuses people is reserved. Inventory is held during checkout: when a customer starts paying, their seats are moved out of available and into reserved for the hold's lifetime, and only converted to sold on payment. Holds exist so that two people cannot buy the last ticket simultaneously, and they expire on their own. So a tier can read zero available with nothing actually sold, simply because fifteen people are mid-checkout — and five minutes later it opens back up. Do not announce a sell-out off the availability figure alone.

The other thing to hold in your head is that a comp is a sale as far as capacity is concerned. Guest-list entries and door overrides both spend real inventory, so the sold figure includes people who paid nothing. That is intentional: capacity is a physical fact and the room does not care how anyone got in.

Pair this screen with the door. Sold tells you how many could come; the scan log tells you how many did. The gap between them is the single most useful operational number a venue manager has, and neither screen shows it on its own.

Before you start

  • A venue manager or admin session.
  • An event that is on sale or in progress. The seed ships demo-event-0001.

Practise with

PersonaEmailPasswordNote
venue_managermanager@club.test manager123shift lead: implies door_staff, plus intake, sales, guest list, marketing
adminadmin@club.test admin123the only account that can touch money, identity and contracts
door_staffdoor@club.test door123door shift account: scanner + guest list, nothing else

Steps 1–8 — Venue Manager

their manual →
  1. 1
    Open the events list. This is your board for the week — every event with its status and its headline sales figures.
    /admin/events events
    Expected result Events with status chips and sales columns.
    Watch out for Loading this page runs the event lifecycle tick, so statuses you see here are current rather than stale. That is also why it is a slightly heavier page than it looks.
  2. 2
    Open tonight's event. Read the dashboard block: per-tier sold, reserved and available, plus the totals.
    /admin/events/{event_id} events
    Expected result A tier table and a sales summary.
    Watch out for The edit controls are absent for you. The page computes an edit flag that is true only for admins, so you get the numbers without the levers — deliberately.
  3. 3
    Refresh the sales figures directly when you want the numbers without the page furniture — useful on a phone behind the bar.
    /api/admin/events/{event_id}/sales events
    Expected result The same dashboard payload as JSON.
    Watch out for This endpoint is also readable by the event's own scoped host, which is why a promoter can watch their night without you sending screenshots.
  4. 4
    When available looks wrong, list the active holds. Each is a customer part-way through checkout with an expiry.
    /api/admin/events/{event_id}/reservations events
    Expected result Open reservations with their items and expiry times.
    Watch out for This one is manager-and-admin only, unlike the sales figures — a promoter cannot see who is mid-checkout. Holds expiring is normal and self-healing; do not go hunting for a bug because a number moved.
  5. 5
    Move to the order list when a specific customer is the question rather than a specific tier.
    /admin/orders payments
    Expected result Orders with status, totals and customer.
    Watch out for Read-only for you. Refunds, refund policy and the dispute resolution controls are admin-only — you can see a chargeback, you cannot resolve it.
  6. 6
    Open a single order to answer a door question — did this person actually pay, and are these the tickets they are holding.
    /admin/orders/{order_id} payments
    Expected result The order, its tickets and its payment history.
    Watch out for This is the screen that resolves a Ticket Not Paid red at the door. A fully refunded order is exactly what that reason code means.
  7. 7
    Look at the public event page once before doors, the way a customer sees it.
    /events/{event_id} events
    Expected result The live catalogue page with what is actually on sale.
    Watch out for If a tier looks wrong to a customer it is wrong here too. Report it to an admin with the tier name — do not try to route around it.
  8. 8
    Understand the boundary you are about to bump into. Transitioning an event — announcing it, putting it on sale, cancelling it — and editing tiers, prices and zones are admin-only writes. So is refunding everybody after a cancellation.
    Expected result A clear escalation path rather than a 403 at 11pm.
    Watch out for If an event needs cancelling on the night, the manager job is to get an admin and to stop the door. Do not start checking people in on a cancelled event — the guest-list check-in refuses it anyway, with event_not_active.

Steps 9 — Door Staff

their manual →

Supplies the other half of the picture — how many of the sold tickets actually walked in.

  1. 9
    From the door, report the actual admitted count — green in scans for the event — so the manager can compare it against sold.
    Expected result The scan stats block with green and red counts.
    Watch out for Green out scans are people leaving, not entering. Count entries, not scans, or you will double-count everyone who stepped outside for air.

Steps 10 — Admin

their manual →

Owns every write on this page: transitions, tiers, prices and refunds.

  1. 10
    As an admin, make the status change the manager cannot: announce, open sales, mark in progress, complete or cancel.
    /api/events/{event_id}/transition POST events
    Expected result The event moves, and the change emits outbox events that marketing and resale react to.
    Watch out for Transitions are not just labels. Cancelling emits an event that downstream refund and exchange logic listens for — never fake one by editing a row.

Refund a ticket or an order

Set the policy, refund the right tickets, choose the destination, and read the posting it makes.

Owned by Admin · 11 steps · about 22 minutes

Why this exists

A refund on this platform is never just "send money back". One call does the processor refund, the balanced ledger posting, the refund records, the ticket revocation, the inventory return to the event and the audit — in a single transaction. Either all of that happened or none of it did, because a refunded ticket that still opens a door, or a returned seat that was never returned, are both worse than a failed refund.

The rule that catches people is that refunds are scoped to tickets the original purchaser still owns. If a ticket was resold, it belongs to somebody else now, and refunding the original order for it would pay the seller twice and strand the buyer. So the refund engine skips it, and a full refund of an order whose tickets have all moved on answers 409 nothing_to_refund. That is correct behaviour, not a bug.

Policy comes before the refund, not after. Each event may carry a non-refundable fee, a credit-only flag, and a self-cancellation deadline. Credit-only means exactly what it says: the money goes back as house credit and an attempt to refund to the card is refused with 422 policy_credit_only unless you deliberately override the policy. That refusal is a feature — it stops a well-meaning refund from quietly contradicting the terms the customer agreed to.

Note also that "original" for an order that was paid with house credit is house credit. There is no path that turns credit into cash through the refund engine; that is what payouts are for, and payouts have their own identity gate.

Refunding is admin-only. A venue manager can read every order and every dispute and cannot refund a penny. Refunds are the one routine action that moves money outward, so they sit with the group that carries that responsibility.

Before you start

  • An admin session. Venue managers are read-only here and members cannot see these pages at all.
  • A paid order with at least one live ticket the purchaser still owns.
  • The event's refund policy decided before you start — the refund snapshots it.

Practise with

PersonaEmailPasswordNote
adminadmin@club.test admin123the only persona that can issue a refund
membernova@demo.club nova-pass-123holds a paid order with tickets on the demo event
venue_managermanager@club.test manager123use it to prove the read-only boundary for yourself

Steps 1 — Member

their manual →

It is their money and their ticket — and where it lands depends on the policy, not on what they ask for.

  1. 1
    As the customer, open your order and see what you actually bought: line items, tax, and which tickets are still live.
    /my/orders/{order_id} payments
    Expected result The order with its tickets and, if the event allows self-cancellation, a self-cancel control.
    Watch out for If self-cancellation is available to you, use it — it is the same engine and it does not need anyone's morning. Past the deadline it is refused with 403 past_cancellation_deadline and it becomes an admin's job.

Steps 2 — Venue Manager

their manual →

They field the request and can read every order, but cannot issue the refund.

  1. 2
    As a manager, find the order the customer is asking about, by event, status or a text search.
    /admin/orders payments
    Expected result The order list with statuses and totals.
    Watch out for You can open everything here and refund nothing. The refund panel on the order page is admin-only, and the page now says so rather than leaving the space blank. Under that sentence is Ask an admin to refund: write why, press it once, and the order carries 'Asked . An admin has it' for you and the reason for them (plan 607). That is the escalation — not a message somewhere else — so the platform has a record the request was made. Do that rather than promising the customer a timeframe you do not control.

Steps 3–7 — Admin

their manual →
  1. 3
    Before refunding, read the event's policy: the non-refundable fee, whether refunds are credit-only, and the self-cancellation deadline.
    /admin/events/{event_id}/refund-policy payments
    Expected result The effective policy, with a note if it is the platform default rather than an event-specific one.
    Watch out for No policy row means defaults: no fee, not credit-only, and self-cancellation disabled. Silence is a policy too.
  2. 4
    Set the policy for the event if it needs one: fee in cents, credit-only on or off, deadline in hours before doors, or no deadline at all to disable self-cancellation.
    /api/admin/events/{event_id}/refund-policy PUT payments
    Expected result The saved policy.
    Watch out for Changing a policy does not change refunds already issued — each refund snapshots the policy it was made under. That snapshot is what you will be reading back in six months when somebody disputes it.
  3. 5
    Open the order and use the refund panel. Choose the mode deliberately: the whole order, specific tickets, or a bare amount.
    /admin/orders/{order_id} payments
    Expected result The panel showing what is refundable, with the fee the policy will withhold.
    Watch out for Refunding specific tickets is almost always the honest choice, because it is the only mode that also revokes exactly those tickets and returns exactly that inventory.
  4. 6
    Issue the refund with a real reason. Pick the destination — back to the original rail, or to house credit — and decide whether to waive the fee.
    /api/admin/orders/{order_id}/refunds POST payments
    Expected result 201 with the refund id, the amount, the fee withheld, the ledger transaction id, the revoked ticket ids and the order's new status.
    Watch out for A missing reason is 422. A disputed payment is 409 charge_disputed — you cannot refund around a chargeback. A ticket that is already refunded, has been transferred, or is mid-resale-settlement each has its own 409, and each is telling you something specific about who owns what.
  5. 7
    Deliberately try to refund a credit-only event back to the card, once, so you recognise the refusal.
    /api/admin/orders/{order_id}/refunds POST payments
    Expected result 422 policy_credit_only, explaining that the event allows refunds to house credit only.
    Watch out for There is an override, and using it is a decision you should be able to defend. It is recorded on the refund as a policy override.

Steps 8 — Member

their manual →

It is their money and their ticket — and where it lands depends on the policy, not on what they ask for.

  1. 8
    As the customer, check where the money landed when the destination was house credit.
    /my/credit ledger
    Expected result The prepaid balance is higher and the statement shows the refund.
    Watch out for House credit is spending power inside the club, not cash. If the customer wanted cash they wanted the original rail, and that decision was made at refund time.

Steps 9–11 — Admin

their manual →
  1. 9
    Find the posting behind the refund, filtering by the refund reference or the kind.
    Expected result A balanced transaction reversing revenue and tax pro-rata, crediting either the cash clearing account or the customer's house credit.
    Watch out for Tax is reversed proportionally and is capped so cumulative reversals can never exceed the original accrual. That cap is why a refund of a refund does not become a tax rebate.
  2. 10
    Pull the refund register when you need the shape of the last month rather than one case.
    Expected result Refunds filterable by event, initiator and mode.
    Watch out for Admin-only, unlike the order list. Refund volume by initiator is a management report, not a customer service tool.
  3. 11
    Know the big red button exists: cancelling an event refunds every order on it, with fees waived.
    /api/admin/events/{event_id}/refund-all POST payments
    Expected result A count of refunded orders, plus the resale settlements that were unwound first, plus any per-order failures collected rather than aborting the run.
    Watch out for It unwinds resales BEFORE it refunds primaries, so a ticket that changed hands walks back to its original buyer and is refunded exactly once. Where a reseller already spent their proceeds, the shortfall is written off to a dedicated account and flagged as unrecovered — the platform absorbs it so the resale buyer is always made whole.

Door & Access

Wallet passes, rotating QR codes, the scanner, and getting people inside.

Add your ticket to your phone wallet

Turn a pass into an Apple or Google wallet card, and understand the QR that changes every 15 seconds.

Owned by Member · 8 steps · about 12 minutes

Why this exists

A ticket and a pass are two different rows on purpose. The ticket is the entitlement — it survives being sold to someone else. The pass is the door credential minted from it, and it must not survive that, because the person walking in has to be the person who currently owns the ticket.

The pass carries two credentials. The NFC payload is a compact signed blob: the zones and identity are signed with a venue key that can be rotated without invalidating what is already in people's wallets. The QR fallback is a rotating code derived from a per-pass secret and the current 15-second time step, with one step of tolerance either side. That short window is the entire anti-screenshot design: a code someone photographed and sent to a friend is worthless within half a minute, so the venue never has to argue about whether a picture of a ticket is a ticket.

Your phone renders that code itself. The wallet page fetches the pass secret once — from an endpoint only the owner can call, admins included — caches it in the browser, and computes codes locally, so the QR keeps rotating with no signal in a basement venue.

Corrected 2026-08-24 (end2end). This paragraph used to say that paying for a ticket does not mint a pass, that a freshly bought ticket can legitimately show an empty wallet, and that the remedy is to re-run the seed. All three are wrong now, and the last one is worse than wrong — re-seeding a live instance destroys the feedback overlay, which is the input to the whole plan pipeline.

Paying mints the pass. Settlement issues it inside its own SAVEPOINT, so a credential failure rolls back only the half-written pass rows and the sale still commits — because a customer charged with no order, no ticket and no ledger record is strictly worse than a paid ticket whose pass is a moment late. Issuance is idempotent per ticket and a failed attempt leaves no pass row at all, in particular no revoked one. The scheduler tick payments.backfill_ticket_passes is the recovery arm: it mints the missing pass on the next tick for any purchased ticket that has none. Comp tickets are deliberately excluded — the guest-list module owns comp credential policy and mints their passes itself.

Before you start

  • A member session holding at least one issued ticket that has a pass (the seeded demo accounts do).
  • An admin session only for the last step.

Practise with

PersonaEmailPasswordNote
membermember@club.test member123holds a seeded membership card
membernova@demo.club nova-pass-123holds event tickets on the demo event, with passes after a second seed run
adminadmin@club.test admin123owns the signing keys and the wallet push log

Steps 1–7 — Member

their manual →
  1. 1
    Start from your tickets and find the one you are travelling with.
    /my/tickets payments
    Expected result Your tickets grouped into upcoming and past, with event, tier and serial.
    Watch out for This page shows tickets, not credentials. It links to your wallet rather than showing a code — do not try to scan anything from here.
  2. 2
    Open your wallet. Every credential you hold lives here: event passes and, if you have one, your membership card.
    Expected result One card per pass, with its status, the zones it opens and a live rotating code.
    Watch out for A pass showing Suspended means the ticket is listed on the resale exchange. A pass showing VOID has been revoked and is never coming back.
  3. 3
    Look at the same list as JSON when you need to see exactly what you hold.
    Expected result Your passes with kind, status, serial and zones.
  4. 4
    Add the pass to Apple Wallet.
    /api/access/passes/{pass_id}/pkpass access
    Expected result A pass file, and a wallet registration recorded so the venue can push updates to it later.
    Watch out for A revoked pass returns 410 rather than a broken file. The wallet copy is not a snapshot — if the pass is later suspended or revoked, a push updates or voids the copy on your phone.
  5. 5
    Do the same for Google Wallet.
    /api/access/passes/{pass_id}/gpass access
    Expected result A signed token for the Google pass.
  6. 6
    Fetch the current QR payload and watch the seconds remaining tick down.
    /api/access/passes/{pass_id}/qr access
    Expected result A payload plus how long it is valid and the 15-second period it rotates on.
    Watch out for A pass suspended for resale returns a 409 here instead of a code. You cannot list a ticket for sale and also carry a working code for it.
  7. 7
    Understand how the offline code works: this endpoint hands the pass secret to the phone once, and the page computes every future code locally.
    /api/access/tickets/{ticket_id}/secret access
    Expected result The secret, the digit count, the algorithm and the 15-second period.
    Watch out for Owner only. An admin calling this for your ticket gets a 403 — the secret is the credential, and staff never need it. Treat a leaked secret exactly like a leaked password: it is worth a revocation.

Steps 8 — Admin

their manual →

Owns the signing keys behind every credential and the log of wallet pushes.

  1. 8
    As an admin, look at the wallet push log and the signing keys behind all of this.
    /admin/access access
    Expected result Readers, keys with their status, the revocation list and recent wallet traffic.
    Watch out for Rotating a signing key keeps existing payloads valid — retired keys still verify — so rotation is safe to do mid-season. Marking a key compromised is the one that starts failing credentials.

Get through the door

Turn a ticket into a wallet pass, then get scanned green on the night.

Owned by Member · 8 steps · about 10 minutes

Why this exists

A ticket is an entitlement in the database. A pass is the physical-world credential minted from it, and the two are deliberately separate rows: the ticket can survive changes of ownership that the pass must not.

Every credential is signed, and the QR code rotates — it is derived from a per-pass secret and the current time step, so a screenshot someone sent a friend is worthless within a minute. The NFC payload is signed with a key that can be rotated venue-wide. This is why a red scan is almost never "the phone is broken": it is a specific, logged reason.

The other half of the design is that revocation is terminal. When a ticket is resold or refunded, the old pass is revoked, its serial and payload hash are blacklisted and its secret is killed — and a new pass is minted for the new owner. The old credential does not merely stop working; it fails loudly with a reason the door can read out.

Before you start

  • A paid order with at least one issued ticket (see 'Buy a ticket').
  • For the door half: an employee session (door staff, venue manager or admin).

Practise with

PersonaEmailPasswordNote
membermember@club.test member123the ticket holder
door_staffdoor@club.test door123works the scanner
venue_managermanager@club.test manager123sees the scan logs and the readers

Steps 1–4 — Member

their manual →
  1. 1
    Find the ticket you bought.
    /my/tickets payments
    Expected result The ticket with its event and tier.
    Watch out for A ticket does not automatically have a pass. Issuing the door credential is the next step and it is yours to do.
  2. 2
    Open your wallet. This is where every credential you hold lives — event passes and, if you have one, your membership card.
    Expected result Your passes with their status and the zones each one opens.
  3. 3
    Add the pass to Apple Wallet (or use the Google variant next to it).
    /api/access/passes/{pass_id}/pkpass access
    Expected result A downloadable pass file; the platform records a wallet registration so it can push updates to it later.
    Watch out for The wallet copy is not a snapshot. If the pass is later suspended or revoked, a push updates or voids the copy in your phone.
  4. 4
    Look at the rotating QR fallback — the thing you actually hold up if the NFC reader is having a bad night.
    /api/access/passes/{pass_id}/qr access
    Expected result A QR payload that changes on a short time step.
    Watch out for It rotates. A screenshot taken earlier will scan red, and that is the point, not a bug.

Steps 5–6 — Door Staff

their manual →

Scans the credential and reads the verdict out loud.

  1. 5
    On the door, open the scanner, pick your reader, and scan the guest's phone.
    /scanner access
    Expected result A full-screen green or red verdict readable at arm's length.
    Watch out for The scanner is gated to the employee pseudo-group — door staff, venue manager or admin. It is the only staff surface most door staff ever need.
  2. 6
    Understand what the scan actually did: it validated the signature, checked the pass is active, checked the event window and zone, and checked nobody has already walked in on this credential.
    /api/access/scan POST access
    Expected result Green with the holder and zone, or red with a specific reason code.
    Watch out for This endpoint is authenticated by the READER's device token, not by your login. That is what lets a provisioned door device keep scanning when the network drops and it has no session.

Steps 7–8 — Venue Manager

their manual →

Investigates a red scan from the access dashboard.

  1. 7
    When a guest insists their ticket is fine, open the access dashboard rather than arguing at the door.
    /admin/access access
    Expected result Readers, signing keys, revocations and recent scans.
  2. 8
    Look up the actual scan and read the reason code: already used, wrong event, outside the door window, suspended because the ticket is listed for resale, or revoked.
    Expected result The scan history for that pass, with the verdict and reason on each row.
    Watch out for 'Listed for resale' is the one that surprises people. Listing a ticket suspends its pass on purpose — you cannot sell it and also walk in on it.

Scan guests at the door

Work the scanner and the guest list through a whole door shift, including offline.

Owned by Door Staff · 8 steps · about 20 minutes

Why this exists

This is the door shift from the staff side, and it is designed around one constraint: the person holding the phone should never have to make a judgement call, and should never be blocked by the network.

Everything a door needs resolves to a verdict plus a reason. Ticket holders scan; guest-list names are searched and checked in; a walk-in a manager vouches for gets an override that is recorded as an override rather than quietly issued as a normal entry. The audit trail is the product here — after the night, the difference between "we let 40 people in on the list" and "we let 40 people in and here is who authorised each one" is the whole point.

Offline is a first-class mode, not a fallback. A venue manager pulls a signed bundle of the night's valid credentials to the device up front; scans queue locally and are pushed back in a batch. The bundle and the push feed carry pass secrets, which is exactly why door staff cannot download them from a browser — it needs a manager session or the reader's own device token.

Before you start

  • An employee session (door staff, venue manager or admin) for the scanner.
  • An event with issued passes and, ideally, a guest list with entries.

Practise with

PersonaEmailPasswordNote
door_staffdoor@club.test door123the door shift persona
venue_managermanager@club.test manager123the only non-admin who can pull the offline bundle
adminadmin@club.test admin123provisions readers and rotates the signing key

Steps 1–4 — Door Staff

their manual →
  1. 1
    Open the scanner and select the reader you are working on before the first guest arrives.
    /scanner access
    Expected result The reader picker and a scan surface.
    Watch out for Selecting the right reader matters: the reader is what defines which zones you are admitting to.
  2. 2
    Scan credentials one after another and act on the colour. Green: in. Red: read the reason to the guest, do not improvise.
    /api/access/scan POST access
    Expected result Green with holder and zone, or red with a reason code.
    Watch out for The same credential scanned twice is red with 'already used'. That is a passback guard, not a system error — the second person holding that screenshot is the problem it exists to catch.
  3. 3
    For guests with no ticket, switch to the guest list and search by name.
    /scanner/guestlist guestlist
    Expected result Matching entries with their bucket, their plus-one allowance and whether they are already in.
    Watch out for Search by the name on the list, not the name on the ID — comps are listed by whoever the promoter invited.
  4. 4
    Check the guest in, with their plus-ones counted.
    /api/guestlist/door/{event_id}/entries/{entry_id}/checkin POST guestlist
    Expected result The entry flips to checked in and the plus-one count is decremented.
    Watch out for Plus-ones are an allowance, not a suggestion. Over-admitting here is what makes the promoter's allocation meaningless.

Steps 5–6 — Venue Manager

their manual →

Prepares the device for offline work and watches the door from the dashboard.

  1. 5
    Before the doors open, pull the offline bundle for the event onto the device.
    Expected result A signed bundle of the night's valid credentials.
    Watch out for Door staff cannot do this from a browser and should not be asked to: the bundle contains pass secrets, so it needs a manager session or a provisioned reader's device token.
  2. 6
    Watch the door from the access dashboard during the shift — live scans, reds, and the readers that are actually reporting in.
    /admin/access access
    Expected result Readers, recent scans and revocations.

Steps 7–8 — Admin

their manual →

Provisions the reader and owns the signing keys behind every credential.

  1. 7
    Provision a new reader when a device is added, and note the device token it issues.
    /api/access/readers POST access
    Expected result A reader row with a rotatable token.
    Watch out for The token is the device's identity. If a device is lost, rotate the token — do not delete the reader, or you lose its scan history.
  2. 8
    After the night, review the scan log: greens, reds by reason, and every override.
    Expected result The full scan ledger for the event.
    Watch out for This is the record you will be asked for when a capacity or a comp question comes up weeks later. It is append-only by design.

Read the scanner screen

Reader, zone, direction, NFC versus rotating QR, and what green and red actually tell you.

Owned by Door Staff · 11 steps · about 15 minutes

Why this exists

The scanner is designed so that the person holding the phone never has to make a judgement call. Every scan returns a colour and a reason, the screen is enormous and colour-coded so it reads at arm's length in the dark, and there is no 'override' button anywhere on it. If the screen is red, the answer is not to try again harder; it is to read the reason out loud.

Two credential formats arrive at that box and it matters that you can tell them apart. An NFC payload is a signed blob — a base64url JSON claim set with an HMAC after a dot. A rotating QR is the short string beginning CLB1: followed by the pass serial and an eight-digit code. The scanner sniffs the CLB1 prefix and labels the scan qr, otherwise nfc; you do not choose. The QR code exists because NFC readers have bad nights, and it rotates on a fifteen-second step with one step of tolerance either side. That is the whole anti-screenshot design: a code a guest forwarded to a friend earlier in the evening is dead within about forty-five seconds.

The other thing to internalise is that zones come from the pass row, never from the credential. The signing scheme is symmetric HMAC, so a payload's own zone claim is not a trust boundary — if a presented payload claims zones that differ from the stored ones, that is a re-signed credential and it scans red for wrong zone. Your reader's zone is compared against what the database says the pass opens, and nothing else.

Finally, the reader you pick is not cosmetic. It carries the zone, the direction (in or out), and the anti-passback window. A credential scans red for wrong zone when your reader's zone is not among the zones written on the pass, so standing at VIP Door B and scanning general-admission tickets produces a wall of wrong-zone reds that are entirely your own doing. It does not run the other way. A VIP tier's ticket carries the main door as well as the VIP room, and a member's card carries every zone their groups grant, so neither is refused at the main door.

Before you start

  • An employee session — door staff, venue manager or admin.
  • At least one issued pass to scan. The seed mints membership cards for member@club.test and vip@club.test.
  • The four seeded demo readers: Main A (zone A, in), VIP B (zone B, in), Exit A (zone A, out) and the bar POS.

Practise with

PersonaEmailPasswordNote
door_staffdoor@club.test door123door shift account: scanner + guest list, nothing else
membermember@club.test member123holds a seeded pass and membership card to practise scans against
venue_managermanager@club.test manager123shift lead: implies door_staff, plus intake, sales, guest list, marketing

Steps 1 — Venue Manager

their manual →

Chooses which readers exist for the night and reads the scan log afterwards.

  1. 1
    Before the shift, open the Access Console and check the readers your door will use are active and pointed at the right zones and directions.
    /admin/access access
    Expected result A Readers table with name, zone, direction, passback window, status, last seen and cache freshness.
    Watch out for A reader with status disabled will refuse every scan with reader_disabled, not with a normal red verdict. Fix it here before doors, not at the door.

Steps 2–8 — Door Staff

their manual →
  1. 2
    Open the scanner and pick your reader from the dropdown. Check the two badges underneath it: they show the zone and the direction you are now scanning as.
    /scanner access
    Expected result Badges reading something like zone A and in. The choice is remembered in the browser, so it survives a reload.
    Watch out for The remembered reader is the classic first mistake of a second shift. If you worked the VIP door last night and the main door tonight, the page will happily reload with VIP B still selected.
  2. 3
    Learn to recognise the two payload shapes. A long string with a dot near the end is an NFC payload. A short string starting CLB1: is a rotating QR. The box accepts either — paste it, or let the reader hardware type it, and press Enter.
    Expected result The scan fires on Enter or on the Scan button; the input clears itself immediately so the next guest can go.
    Watch out for The input clearing is intentional. If you need to know what you just scanned, read the Recent scans table below — do not try to scroll the input back.
  3. 4
    Scan a valid pass and read the green screen properly. It shows a tick and Welcome, then the holder name and their roles, the event, the tier and the zones the pass opens, the entry count, and the round-trip latency in milliseconds.
    /api/access/scan POST access
    Expected result A full-screen green panel and a new row at the top of Recent scans.
    Watch out for Green on a door reader means the whole gate chain passed: signature or TOTP, revocation blacklist, pass status, order paid, event window, zone, and anti-passback. It is not a partial verdict — there is nothing else for you to check. One green is narrower than that and it is not yours: a membership card presented at a BAR reader returns green as soon as the card authenticates (access/service.py:1403), deliberately skipping anti-passback and writing no check-in. It answers 'is this a member', not 'has this person come through a door', so a bar green never tells you anyone is inside the building.
  4. 5
    Now scan the same credential a second time on the same in reader. Watch it go red with Already Scanned.
    /api/access/scan POST access
    Expected result A red panel reading Already Scanned.
    Watch out for This is anti-passback, and it is the guard that matters most in practice. The seeded in readers use a window of zero, which means infinite — once a serial is checked in it cannot check in again until it checks OUT on an out reader. The second person holding that screenshot is exactly the problem this exists to catch.
  5. 6
    Switch to the Exit A reader (direction out) and scan the same pass to check the guest out. Then scan them back in on the in reader.
    /api/access/scan POST access
    Expected result Green on the way out, then green on the way back in with the entry count incremented.
    Watch out for Scanning a guest OUT who never came in is red with Not Checked In. That is not an accusation of anything — it usually means they entered on a different door before you started, or on a reader that was offline.
  6. 7
    Take a general-admission pass to the VIP B reader and scan it. Read the wrong-zone red.
    /api/access/scan POST access
    Expected result A red panel reading Wrong Zone.
    Watch out for Zone codes and role zones are written differently in the two halves of the system — the event side uses A, B, C and the role side uses zone_a, zone_b, zone_c — and the scanner normalises them before comparing, so A really does equal zone_a and a spelling mismatch is never the cause. What the red DOES tell you is narrower: your reader's zone is not among the zones that pass opens. Two things produce that and only one of them is the guest. Check your reader badges first — the wall of wrong-zone reds this manual warns about two steps up is the same verdict, caused by the reader you picked — and only then tell somebody they do not hold the zone. There is also a second, rarer zone red, Zone Closed For This Event: that one means the event dropped the zone after the pass was issued, and it is never the guest's doing.
  7. 8
    Understand the bar reader, because it behaves differently on purpose. Scanning a membership card on the bar POS reader authenticates the member and returns their prepaid balance and available tab — and deliberately does NOT check anybody in or consume an entry.
    Expected result Green with a credit line showing prepaid and tab amounts.
    Watch out for Do not use the bar reader on the door. It never records an entry, so a night scanned on the bar reader leaves you with no check-in count at all.

Steps 9–10 — Member

their manual →

Presents the credential — and can show you what the rotating QR looks like from their side.

  1. 9
    From the guest's side: open your wallet and show the pass you are presenting.
    Expected result Your passes with status and the zones each one opens.
  2. 10
    Show the rotating QR fallback and watch the seconds-remaining counter tick down.
    /api/access/passes/{pass_id}/qr access
    Expected result A payload plus seconds_remaining and a fifteen-second period.
    Watch out for If your pass is suspended because you listed it for resale this returns a 409 rather than a code, and if it has been revoked it returns a 410. Neither is a network problem.

Steps 11 — Venue Manager

their manual →

Chooses which readers exist for the night and reads the scan log afterwards.

  1. 11
    After the doors settle, pull the scan log filtered to your event to see the shape of the night: greens, reds by reason, and the latency percentiles.
    Expected result Log rows plus a stats block with count, green, red, p50, p95 and max milliseconds.
    Watch out for A cluster of reds all sharing one reason code is nearly always a configuration mistake — the wrong reader selected, or a reader pointed at the wrong zone — not a crowd of fraudsters.

Diagnose a red scan

Every rejection reason the door can produce, what it means, and who fixes it.

Owned by Door Staff · 15 steps · about 18 minutes

Why this exists

The reason codes are a closed set. There are thirteen of them and the scanner can never invent a fourteenth, which is what makes it possible to train a door on them exhaustively in one sitting. A red scan is never 'the system is being weird'; it is one of thirteen specific statements about that credential, recorded in the scan log with a timestamp and a reader.

They are produced in a fixed order, and the order is itself the diagnosis. The pipeline runs: parse the payload, then verify the signature or the time-based code, then check the revocation blacklist, then the pass status, then whether the order is actually paid, then whether the event is inside its door window, then the zone, then anti-passback. The first gate that fails is the one you are shown.

Two codes can also appear before their place in that order, and one of them means something different when they do. The pipeline splits on which kind of credential was scanned (access/service.py:1270), and each half has an early exit:

  • Already Scanned, on the QR path only, fires right after the time-code check — before paid, event and zone are looked at at all. That is the cross-reader replay guard, and it is the good kind of early: it stops the second door before anything else is considered.
  • Wrong Zone, on the signed-pass path, fires right after the signature, when the zones written into the payload do not match the zones on the issued pass. A pass is re-issued rather than edited when its zones change, so those two can only disagree if the payload was altered after issuance. This wrong-zone is not "right guest, wrong door" — it is a credential whose claims do not match the pass it names, and the guest at the barrier may not be the person it was issued to.

So the free inference holds for the ordinary wrong-zone red — signature valid, pass live, order paid, event open — but only for the one raised at the zone gate. If the guest scanned a signed pass rather than a live QR code, a wrong-zone red may have skipped every one of those checks. When a wrong-zone red comes off a saved or forwarded pass rather than the wallet's live screen, treat it as a credential question and call a manager, not as a door mix-up.

The design intent behind showing a reason at all, rather than a generic refusal, is that most reds have a legitimate answer at the door. Listed for sale means the guest has their ticket on the exchange and needs to delist it. Code expired means their screenshot is stale and they should open the live wallet page. Ticket not paid usually means a refund. Only a handful are genuinely 'this person is not getting in', and you should be able to tell those apart in a second.

What you must not do is escalate by improvising. There is no override on the scanner. If a guest needs to come in and the pass says otherwise, the sanctioned route is a manager and the guest-list walk-in override, which is recorded as an override with a named authoriser and a reason.

Before you start

  • An employee session and a red scan in front of you.
  • A manager or admin reachable for the escalations at the end.

Practise with

PersonaEmailPasswordNote
door_staffdoor@club.test door123door shift account: scanner + guest list, nothing else
venue_managermanager@club.test manager123shift lead: implies door_staff, plus intake, sales, guest list, marketing
adminadmin@club.test admin123the only account that can touch money, identity and contracts
membermember@club.test member123holds a seeded pass and membership card to practise scans against

Steps 1–8 — Door Staff

their manual →
  1. 1
    Read the red screen out loud to the guest before doing anything else. The large text is the human wording of the reason code; the reason code itself is in the Recent scans row underneath.
    /scanner access
    Expected result One of: Unreadable Pass, Invalid Signature, Unknown Pass, Code Expired, Pass Revoked, Ticket Resold, Listed For Sale — Suspended, Ticket Not Paid, Event Not Active, Wrong Zone, Already Scanned, Not Checked In.
    Watch out for Saying the reason out loud is not a courtesy, it is the fastest triage you have. Half of these the guest can fix on their own phone in ten seconds once they know which one it is.
  2. 2
    Handle the three that mean 'the thing you scanned was not a real credential'. Unreadable Pass means the string was not a payload at all — usually a partial paste or a scuffed scan; try again. Invalid Signature means it was payload-shaped but the HMAC did not verify — a forgery, or a credential signed with a key that has since been marked compromised. Unknown Pass means the serial simply is not in the database.
    Expected result Unreadable Pass resolves on a clean re-scan. The other two do not.
    Watch out for Invalid Signature is the one to escalate rather than retry. A whole run of them at once usually means an admin marked a signing key compromised mid-shift, not that you have a queue of counterfeiters.
  3. 3
    Handle Code Expired. This only happens on the QR path and it means the eight-digit code was outside the fifteen-second step and its one-step tolerance. Ask the guest to open their live wallet page rather than a saved image, and scan again.
    Expected result A fresh code scans green.
    Watch out for There is a second, subtler cause: the same code already scanned green on a DIFFERENT reader within the same time step. That is a deliberate cross-reader replay guard and it surfaces as Already Scanned, not Code Expired — two people cannot walk two doors on one code.
  4. 4
    Handle the three ownership reasons. Pass Revoked means this credential was killed — refunded, resold, or revoked by an admin — and revocation is terminal, so it will never work again. Ticket Resold is what an old NFC payload shows after a transfer: the ticket is fine, but it belongs to somebody else now. Listed For Sale — Suspended means the guest currently has this exact ticket on the resale exchange.
    Expected result The guest recognises which of the three applies to them.
    Watch out for The suspended one has a real fix and is worth knowing: listing a ticket suspends its pass on purpose — you cannot sell it and also walk in on it. If the guest delists it, the same pass is restored and scans green. Nobody at the door can do that for them; it is their own listing to pull.
  5. 5
    Handle Ticket Not Paid. The pass is fine, but the order behind it is not in a paid state — nearly always a full refund, occasionally a checkout that never completed.
    Expected result A red that a manager can confirm from the order.
    Watch out for Partially refunded orders still scan green. If you see Ticket Not Paid, the whole order went, not one ticket of four.
  6. 6
    Handle Event Not Active. The credential is valid but you are outside the event's door window. A pass scans inside the window when the event is announced, on sale or sold out, and any time while it is in progress; before doors open or after teardown it goes red.
    Expected result A red that resolves by itself once doors open.
    Watch out for This is the most common red of the evening and it is almost always a clock question, not a ticket question. Check the event's doors-open time before you start telling people their ticket is broken.
  7. 7
    Handle Wrong Zone and the two anti-passback reasons, which are covered in detail in the scanner-screen workflow: wrong reader for the ticket, already inside, or trying to leave without having entered.
    Expected result You can name which of the three you are looking at without thinking.
    Watch out for Wrong Zone on a credential that worked ten minutes ago on the same reader means somebody presented a re-signed payload — the zones on the pass row are authoritative and a payload claiming different ones is rejected.
  8. 8
    When the guest genuinely should come in and the pass says otherwise, stop scanning and go to the guest list. Search their name — comps, plus-ones and prior overrides all live there.
    /scanner/guestlist guestlist
    Expected result Their entry, or nothing.
    Watch out for If there is nothing, the next step is a manager and a recorded walk-in override, not a decision by you. The scanner has no override control by design.

Steps 9 — Member

their manual →

Is the guest at the window, and usually holds the fix on their own phone.

  1. 9
    As the guest, do the two things that fix most reds without anybody's help: open your live wallet page rather than a saved screenshot, and check the status shown on the pass itself.
    Expected result Your passes with status active, suspended or revoked, and a QR that is regenerating rather than frozen.
    Watch out for A suspended pass means you listed that ticket on the exchange. Delist it and the same pass is restored — nobody at the door can do that for you, and arguing about it at the window costs you your place in the queue.

Steps 10–12 — Venue Manager

their manual →

Looks the scan up in the log and decides whether the guest gets in another way.

  1. 10
    As the manager, look the scan up rather than taking anyone's word for it. Filter by event, by reader, or by result and reason.
    Expected result The exact scan with its reason code, method, direction, latency and reader.
    Watch out for Scan logs are the record you will be asked for weeks later about capacity or comps. They are written on every scan, red ones included — a red is evidence, not a discarded attempt.
  2. 11
    If the reason was Pass Revoked or Ticket Resold, check the Revocation list panel to see the serial, the reason and when it happened.
    /admin/access access
    Expected result The blacklisted serial with reason resold, refunded, admin_revoke or superseded.
    Watch out for You cannot un-revoke anything from here, and neither can an admin — revocation is terminal by design. The guest's route back in is a new credential, not a repaired old one.
  3. 12
    If the reason was Ticket Not Paid, open the order to confirm the refund and see who processed it.
    /admin/orders/{order_id} payments
    Expected result The order with its status and refund history.
    Watch out for You can read this page but you cannot issue a refund from it — refunds are admin-only. Confirming what happened is your job; reversing it is not.

Steps 13–15 — Admin

their manual →

Owns the tools that reproduce a red on demand and the keys behind every signature.

  1. 13
    As an admin, reproduce a red deliberately when you are training staff or chasing a report. Simulate a scan against a ticket and a reader, optionally tampering the signature or the zone.
    Expected result A real scan envelope and a real scan_logs row, produced without a phone.
    Watch out for This writes genuine log rows. Do it on a demo event, or your training session shows up in the night's door numbers.
  2. 14
    When somebody insists their QR is correct, use the code oracle to see the previous, current and next valid codes for that ticket.
    /debug/access/totp/{ticket_id} access
    Expected result Three codes and the step boundaries.
    Watch out for If the guest's code matches the previous or next step it was a timing issue and a re-scan fixes it. If it matches nothing, they are not holding the credential they think they are.
  3. 15
    For a run of Invalid Signature reds, check the Signing keys panel: rotation keeps old payloads working because retired keys still verify, but a key marked compromised fails every payload signed with it.
    /admin/access access
    Expected result Keys with status active, retired or compromised.
    Watch out for Marking a key compromised is a venue-wide event. Every wallet pass signed under it dies at once — never do it mid-shift to test something.

Use your membership card at the bar (VIP)

Mint a membership card, see what VIP adds to it, and understand what a bar scan does and does not do.

Owned by VIP Member · 8 steps · about 12 minutes

Why this exists

A membership card is a pass with no ticket behind it. It exists so that being a member is itself a credential: it opens the zones your groups map to, plus the bar pseudo-zone, and it is re-issued automatically whenever your groups change.

This is where VIP status stops being a label. The zone map gives an ordinary member the general zone and a VIP member the general zone and Zone B — so the same card, scanned at the same reader, opens a room for one person and not the other. Nothing about the scan is a judgement call by staff.

The bar reader is a special case in the scan pipeline: it returns green with your credit summary attached — prepaid balance and remaining tab headroom — and it deliberately does not record a check-in. It is a look-up, not a turnstile, and above all it charges nothing. The scan tells the bartender what you are good for; the actual spend is a separate transaction on the ledger. Read that twice before you demo it, because "the scan took my money" is a misconception that spreads fast.

The plumbing behind the re-issue is worth knowing: any grant or revoke of a zone-carrying group fires a pass refresh, which revokes the old card and mints a new one with your current zones. That is why losing VIP takes Zone B away immediately rather than at the end of the month.

Corrected 2026-08-24 (end2end). This section used to warn of "one documented gap — editing the zone map for a whole group does not refresh cards that are already issued". Plan 21 closed it. set_group_zones now fans a refresh out to existing holders, queued rather than run inline so that re-zoning a four-thousand-member tier does not block the admin's request. A card in a wallet catches up on the next sweep, not on the next re-issue.

Before you start

  • A member or VIP session.
  • For the scan step: an employee session (door staff, venue manager or admin) or a provisioned bar reader.
  • An admin session for the tab step.

Practise with

PersonaEmailPasswordNote
vip_membervip@club.test vip123seeded membership card, $250 tab limit with $80 used
membermember@club.test member123an ordinary card, for the side-by-side zone comparison
door_staffdoor@club.test door123works the bar reader
adminadmin@club.test admin123sets and freezes tab limits

Steps 1–2 — Member

their manual →

Holds the same card without Zone B — the contrast is the lesson.

  1. 1
    As an ordinary member with no card yet, open your wallet and press Get my membership card.
    Expected result A card appears with the general zone and the bar zone.
    Watch out for Corrected 2026-08-24 (end2end): this used to say the no-script fallback posted to a route that does not exist. It posts to the real endpoint and carries a CSRF field, so it works without script. What it does not do is come back to a page — the endpoint answers with JSON, so a browser with script blocked lands on the raw record instead of the wallet. Use the button.
  2. 2
    Call the issuing endpoint yourself to see that it is idempotent.
    Expected result The same live card returned again, not a second one.
    Watch out for One live membership card per user is a database constraint, not a convention. You cannot accumulate cards.

Steps 3–4 — VIP Member

their manual →
  1. 3
    Now do the same as the VIP account and compare the zone list on the two cards.
    Expected result The VIP card carries Zone B in addition to the general zone and the bar zone.
    Watch out for The zones are snapshotted into the card when it is minted. This is why the platform re-mints on every group change instead of computing zones at scan time.
  2. 4
    Pull the rotating code for the card, exactly as for a ticket pass.
    /api/access/passes/{pass_id}/qr access
    Expected result A payload with the seconds remaining on the current 15-second step.

Steps 5 — Door Staff

their manual →

Scans the card at the bar and reads the credit summary off the verdict.

  1. 5
    At the bar reader, scan the card.
    /api/access/scan POST access
    Expected result Green, the holder's name, and a credit block showing prepaid balance and remaining tab headroom.
    Watch out for This scan records no check-in and moves no money. It authorises; it does not charge. Serving the drink and charging the tab are separate acts, and the second one is not this endpoint.

Steps 6 — VIP Member

their manual →
  1. 6
    Open your credit page and confirm the numbers the bartender just saw.
    /my/credit ledger
    Expected result Prepaid, earned, and the tab with its limit, used and headroom.
    Watch out for The tab is a limit on what you may owe, not a balance you hold. Headroom shrinking is you borrowing more, not you spending savings.

Steps 7–8 — Admin

their manual →

Owns the tab limit and its frozen state; VIP status alone creates no headroom.

  1. 7
    As an admin, set the VIP's tab limit — or freeze it.
    /api/admin/credit/users/{user_id}/tab PUT ledger
    Expected result The tab row with its new limit or status.
    Watch out for Freezing stops further spending; it does not forgive the balance. Revoking VIP status freezes the tab automatically for the same reason, and the money stays owed.
  2. 8
    Revoke the VIP grant on a throwaway account and then look at that account's wallet.
    /admin/users/{user_id} rbac
    Expected result The old card is revoked and superseded by a fresh card without Zone B.
    Watch out for Corrected 2026-08-24 (end2end): the refresh fires on grant and revoke, AND on a zone-map edit — plan 21 added the fan-out that this line used to say was missing. The fan-out is queued, so a card in a wallet catches up on the next sweep rather than the instant an admin saves the map.

Run the door offline

Pull the bundle before doors, queue scans on the device, and sync the batch back afterwards.

Owned by Venue Manager · 11 steps · about 20 minutes

Why this exists

Offline is a first-class mode here, not a degraded fallback. The assumption behind the design is that a warehouse door will lose the network at exactly the wrong moment, and that a queue of two hundred people is not the time to discover it. So the device is prepared before doors: a manager pulls a bundle containing the night's valid pass payloads, their time-code secrets, the revocation list, the verification key fingerprints and a snapshot of who is already checked in.

That bundle is the reason offline sync has a stricter permission than scanning itself. Anyone who can scan can scan; but the bundle is replayable credentials for the entire venue, so pulling it needs a venue_manager-or-above session naming a reader, or the reader's own provisioned device token. A plain door_staff session gets a 403, and the scanner page hides the control from them rather than teasing it — they see a badge reading bundle: manager only. That is not distrust of the person on the door, it is a decision about which device a venue's whole credential set is allowed to land on.

The second half is the sync. Offline scans are queued in the browser with a client-generated id, and pushed back as a batch of up to five hundred. The server trusts the results in that batch — it does not re-validate them, because the device was the thing that made the decision — but it replays the check-in state changes in scanned-at order and dedupes on the client id. So syncing twice is safe, and a scan that arrives late still lands in the right place in the night's timeline.

The trap, and you must teach it explicitly: the built scanner queues every offline scan as green / ok. It does not validate anything locally against the bundle. Offline mode on this build means 'let everyone in and record it', not 'validate against the bundle'. That is a deliberate simplification of the reference UI, and it means offline mode is a decision about crowd flow, not a security-neutral toggle.

Before you start

  • A venue manager or admin session for the bundle (door staff cannot pull it).
  • An event in announced, on_sale, sold_out or in_progress with issued passes.
  • A reader selected on the scanner — the sync endpoints require one.

Practise with

PersonaEmailPasswordNote
venue_managermanager@club.test manager123shift lead: implies door_staff, plus intake, sales, guest list, marketing
door_staffdoor@club.test door123door shift account: scanner + guest list, nothing else
adminadmin@club.test admin123the only account that can touch money, identity and contracts

Steps 1–4 — Venue Manager

their manual →
  1. 1
    Well before doors, sign in on the door device as the manager, pick the reader, and pick the event in the Event (for door bundle) dropdown.
    /scanner access
    Expected result Both dropdowns set, and a Download door bundle button — visible because you are a manager.
    Watch out for If you see a badge reading bundle: manager only instead of the button, you are signed in as door staff. Swap the session before the queue starts, not during it.
  2. 2
    Press Download door bundle. The page stores it in the device's local storage and the status badge changes to show how many passes came down.
    Expected result A badge reading bundle: N passes.
    Watch out for The bundle never contains the raw signing key — HMAC is symmetric, so publishing it would let the device mint credentials. It ships key fingerprints only, which are enough to notice a rotation and nothing else.
  3. 3
    Confirm the live push feed is running: the badge next to the reader picker should read push: live with a cursor number. This is the long-poll that tells a device about revocations and check-ins that happened elsewhere.
    Expected result push: live @ some number, refreshing every few seconds.
    Watch out for The push feed needs the same manager-or-token permission as the bundle. On a door_staff session it reads push: manager only and never polls — so a door staff device will not learn about a mid-shift revocation on its own.
  4. 4
    Brief the door before you hand the device over: while Offline mode is ticked, every scan is accepted and queued. Nobody is turned away by the device.
    Expected result The person on the door understands they are recording, not validating.
    Watch out for This is the single most important sentence in this workflow. Do not leave Offline mode ticked because it 'feels faster' — you have turned the door into a counter.

Steps 5–7 — Door Staff

their manual →

Actually works the queue in offline mode and presses Sync now when the network returns.

  1. 5
    When the network drops, tick Offline mode and keep scanning. The queue counter next to it climbs with every scan.
    /scanner access
    Expected result Each scan shows a green panel reading Queued (offline) with queued offline in place of a latency figure.
    Watch out for Queued (offline) is not a verdict. It means 'stored on this phone', nothing more. Watch the queue counter — if it stops climbing, the browser storage has a problem and you are now admitting people with no record at all.
  2. 6
    As soon as the network returns, untick Offline mode and press Sync now.
    Expected result The status badge changes to a sync line with the accepted count and the duplicate count.
    Watch out for Do not clear the browser data, close the tab into oblivion, or hand the device to the next shift before syncing. The queue lives in that browser and nowhere else.
  3. 7
    Understand what a duplicate means: every queued scan carries a client-generated id, and the server skips any id it has already stored. Pressing Sync now twice is harmless.
    Expected result Duplicates counted separately from accepted, and no double entries.
    Watch out for A scan queued without a client id is skipped entirely and reported as a conflict. If accepted plus duplicates does not equal what you scanned, say so — the difference is people you admitted with no log row.

Steps 8–9 — Venue Manager

their manual →
  1. 8
    After the sync, pull the scan log for the event and look at the offline flag column to see which rows arrived by batch.
    Expected result Synced rows flagged as offline, carrying their original scanned-at time rather than the sync time.
    Watch out for Batch scans keep the time they happened, which is why the door timeline still makes sense afterwards. If you see a wall of identical timestamps, someone queued without a real clock.
  2. 9
    Check the Readers table's cache column — push lag and last bundle time — to confirm the device really did sync and is not quietly stale.
    /admin/access access
    Expected result A recent last-sync time against that reader.
    Watch out for Conflicts reported by a batch are worth reading, not dismissing. The common one is a serial that was already checked in, which the server records without double-counting the entry — usually two devices scanning one queue.

Steps 10–11 — Admin

their manual →

Provisions the reader device tokens that make a device able to sync without any session at all.

  1. 10
    For a permanent door device, provision a reader properly instead of relying on a manager session. The creation response contains the device token, and that is the only time it is ever shown.
    /api/access/readers POST access
    Expected result A reader row and a one-time device token.
    Watch out for Copy the token then — the API never shows it again, and rotating is the supported way to get a working one. Do NOT treat that as the token being unrecoverable: it is still held in the reader row, so anyone with database or admin-console access can still obtain it. Rotate whenever a device leaves your control, not only when you have lost the copy.
  2. 11
    If a device is lost, rotate its token rather than deleting the reader.
    /api/access/readers/{reader_id}/rotate-token POST access
    Expected result A new token; the old one stops authenticating immediately.
    Watch out for Deleting the reader would orphan its scan history. Rotate, always — the reader row is the identity that the night's log hangs off.

Resale Exchange

Member-to-member ticket resale, capped pricing, and settlement.

Resell a ticket you can't use

List a ticket inside the price bounds, watch your pass suspend, and get paid in house credit.

Owned by Member · 12 steps · about 15 minutes

Why this exists

A resale on this platform is a transfer, not a refund and re-buy. The ticket row keeps its identity and simply changes owner; what gets destroyed and rebuilt is the door credential. This matters because the alternative — cancelling and re-selling — would return the seat to general inventory where anyone could take it, and the seller would lose the sale they had already agreed.

Built behaviour worth teaching explicitly: the ticket status enum has no "resold" value. While a ticket is listed its status is listed, and after settlement it goes back to issued under a new owner. If you are hunting for a resold ticket in the data, look at the settlement row, not at the ticket.

Listing suspends your pass — you cannot offer a seat for sale and also keep a working code for it. Delisting restores the very same pass and serial, because nothing was ever destroyed; only a sale destroys it.

Prices are bounded in basis points of face value, defaulting to a floor of 80% and a cap of 130%, and the face value is snapshotted at the moment you list. Bounds are the anti-scalping mechanism, and pinning face value at listing time means a later tier price change cannot retroactively make your live listing illegal.

You are paid in house credit, not cash. The venue takes a percentage fee and the event's host may take a royalty; the remainder lands in your earned balance, spendable on the platform immediately and cashable out to a bank only after a hold that runs to 24 hours past the event. And a correction to a common assumption: the venue fee is configured per event. There CORRECTED 2026-08-30: a VIP DOES pay a reduced resale fee. membership_tier_benefits.resale_fee_discount_bps is 5000 for vip_member in the shipped bootstrap — half the venue fee, taken off what the seller is charged — and the tier card says so to the member in as many words: “keeps 50% of the venue fee when reselling a ticket”. The discount is read at listing time and snapshotted onto the listing row, so a listing keeps the rate it was created under. What remains true is the sentence this one used to sit beside: the venue fee itself is configured per event.

Before you start

  • An issued ticket you own, not checked in, with a face value above zero.
  • An event whose exchange is active for that tier (it opens on sell-out, or an admin forces it on).
  • An admin session for the configuration steps.

Practise with

PersonaEmailPasswordNote
membernova@demo.club nova-pass-123owns tickets on the demo event and already has one live listing
adminadmin@club.test admin123sets the bounds, the fee and the host royalty
door_staffdoor@club.test door123demonstrates what a listed ticket does at the door

Steps 1–5 — Member

their manual →
  1. 1
    Find the ticket you cannot use.
    /my/tickets payments
    Expected result Your upcoming tickets with event and tier.
    Watch out for A comp ticket cannot be sold. Anything issued through the guest list has a zero face value and is rejected with a specific error — comps are a gift, not an asset.
  2. 2
    Open the sell page for that ticket and read the numbers before typing anything: face value, the allowed price range, and the fee preview.
    /resale/sell/{ticket_id} resale
    Expected result A floor and a cap in real money, plus what you would net at the price you type.
    Watch out for If the page says the tier's exchange is not active, no price will help. The exchange opens per tier when the tier sells out, or when an admin forces it on.
  3. 3
    List it. Set your asking price inside the bounds and submit.
    /resale/sell/{ticket_id} POST resale
    Expected result A live listing, your ticket flipped to listed, and your pass suspended in the same transaction.
    Watch out for A price outside the range is rejected with the exact allowed range in the message. The bounds come from face value, not from what you paid — if you bought on the exchange above face, the floor is still 80% of face.
  4. 4
    Note the API behind the form, which is what a mobile client would use.
    /api/resale/listings POST resale
    Expected result 201 with the listing including its floor and cap.
    Watch out for The refusals are specific and worth memorising: not your ticket, already listed, ticket not eligible (already checked in or not issued), comp not resalable, exchange locked, exchange not active.
  5. 5
    Open your wallet and look at the pass for that ticket.
    Expected result Suspended, labelled as listed for sale, with no live code.
    Watch out for Suspended is not revoked. This is reversible right up until someone buys.

Steps 6 — Door Staff

their manual →

Shows the consequence of listing: the seller's own credential now scans red.

  1. 6
    At the door, scan a credential for a ticket that is currently listed.
    /api/access/scan POST access
    Expected result Red, with the reason that the ticket is listed and suspended.
    Watch out for Read the reason to the guest verbatim. This one is almost always genuine forgetfulness — they listed it and came anyway — and the fix is for them to delist, not for you to override.

Steps 7–9 — Member

their manual →
  1. 7
    Track your listings and, once something sells, your net.
    Expected result Active listings with prices, and settled ones with the net you received.
    Watch out for Seller identity is hidden from buyers, but your own listings page is where you see the whole picture. A sale DOES mail you now, with your net — the page is where the detail lives, not where you find out.
  2. 8
    Change your mind and delist.
    /resale/listings/{listing_id}/delist POST resale
    Expected result The listing closes and the original pass and serial are restored, not replaced.
    Watch out for You cannot delist a listing that is mid-settlement — a buyer's payment in flight wins. And if the pass was revoked in the meantime (a refund, say), delisting refuses rather than pretending the credential still works.
  3. 9
    After a sale settles, look at where the money went.
    /my/credit ledger
    Expected result Your earned balance rises by the net after venue fee and any host royalty.
    Watch out for Earned credit is spendable on the platform straight away, but cashing out to a bank is held until roughly 24 hours after the event. That hold is what protects the venue if the show is cancelled and the sale has to be unwound.

Steps 10–12 — Admin

their manual →

Opens the exchange and owns the price bounds, the venue fee and the post-doors lock.

  1. 10
    As an admin, open the exchange configuration for the event and read every lever together.
    /admin/resale/{event_id} resale
    Expected result Floor and cap in basis points, venue fee, host royalty and recipient, the post-doors lock, per-tier modes and the live listings.
    Watch out for A cap below face value is legal configuration and it will astonish sellers. So will a 0% or a 100% venue fee — both are accepted.
  2. 11
    Change the bounds and confirm what it affects.
    /api/admin/resale/events/{event_id}/config PUT resale
    Expected result The new configuration, with the change written to the append-only activation log.
    Watch out for Config changes never touch existing listings. A listing created under the old bounds stays valid and purchasable — bounds are checked once, at creation.
  3. 12
    Preview the exact split for a price without creating anything.
    Expected result Venue fee, host royalty and seller net, summing exactly to the price.
    Watch out for Integer maths with the remainder going to the seller. The three parts always sum to the price — a database CHECK enforces it, so a rounding bug here is impossible to store.

Buy a ticket on the resale exchange

Buy another member's ticket with a card or house credit and get a brand-new pass.

Owned by Member · 10 steps · about 12 minutes

Why this exists

Buying on the exchange is one atomic act: your money moves, the split is posted to the ledger, the ticket changes owner, the seller's credential is destroyed and yours is minted — all inside one transaction. There is no window in which you have paid and do not have a ticket, and none in which two people hold working credentials for the same seat.

The concurrency design is worth understanding because it produces the error you are most likely to hit. A purchase first claims the listing, moving it from active to settling under a write lock; the buyer who loses that race is told the listing is no longer active, before anyone's card is touched. Nothing is authorised from a stale page: the ticket and the event are re-read under the claim, and a ticket that has since been refunded, checked in or transferred closes the listing instead of selling it.

Your new pass is genuinely new — new serial, new secret, new payload. The seller's old credential is not merely deactivated: its serial and payload hash are blacklisted, so the old NFC card scans red with "resold" and the old QR scans red as revoked. Even offline readers learn about it through the revocation list in their sync bundle.

You may pay by card or from house credit. Paying from credit uses the same fixed spend order as anywhere else — prepaid, then earned, then a VIP tab — which is the one place VIP status changes what you can afford here.

Before you start

  • A member session (any registered account can buy).
  • An event with at least one active listing — the seed ships one on the demo event.
  • Enough purchasing power if you intend to pay from credit.

Practise with

PersonaEmailPasswordNote
membermember@club.test member123the buyer; the seeded listing belongs to someone else, so self-purchase is not in the way
vip_membervip@club.test vip123can settle the same listing from the tab
door_staffdoor@club.test door123proves the credential swap at the scanner
adminadmin@club.test admin123reads the settlement and its split

Steps 1–5 — Member

their manual →
  1. 1
    Open the exchange index and pick an event with listings.
    /exchange resale
    Expected result Events whose exchange is open, with a count of active listings.
    Watch out for This page needs a session. There is no public view of the exchange, by design.
  2. 2
    Open the event's marketplace and read the listings per tier.
    /events/{event_id}/exchange resale
    Expected result Two sections — Member exchange and Public release — each sorted by price ascending, with face value alongside and your purchasing power shown for the credit option.
    Watch out for You never see who is selling. If a listing looks like a bargain, the reason is the price bounds, not a stranger being generous.
  3. 3
    Read the Member exchange section: every listing there is inside its member-first window, and each one counts down to the moment it opens to everybody.
    /events/{event_id}/exchange resale
    Expected result A countdown per listing spelled out in words — “public in 1 day 23 h” — and the same listing sitting under Public release once that runs out.
    Watch out for The window is 48 hours from the moment the ticket was LISTED, not from when the show sold out, and it is set per event by the venue manager (0 turns it off). Nothing is stored: the phase is recomputed from the listing time on every read, so there is no state to go stale and no job to miss.
  4. 4
    Look at the same state as JSON to see the mechanics: per-tier active flag, floor, cap and the listing array.
    /api/resale/events/{event_id} resale
    Expected result Exchange state including whether the event is locked and when the post-doors lock will engage.
    Watch out for Reading this quietly re-syncs whether each tier's exchange should be active from live sold counts. The exchange can therefore open for you between two page loads with nobody having pressed anything. The payload is also scoped to YOU: each listing carries its phase and public_at, and a caller without a membership tier is served only the public ones plus a count of what is still held back.
  5. 5
    Buy with a card. Press Buy, choose Card, and confirm.
    /api/resale/listings/{listing_id}/purchase POST resale
    Expected result A settlement with the price, venue fee, host royalty, seller net, and the id of your brand-new pass.
    Watch out for Buying your own listing is a 403, and so is buying a member-first listing without a membership tier (MEMBER_WINDOW_ACTIVE, and the message names the hour it opens). Losing the race is a 409 saying the listing is no longer active — reload and pick another; nothing was charged. A declined card leaves the listing active and records a failed settlement, so you can simply try again.

Steps 6 — VIP Member

their manual →

Settles the same purchase from tab headroom rather than a card.

  1. 6
    As a VIP, buy a listing with house credit instead.
    /api/resale/listings/{listing_id}/purchase POST resale
    Expected result Prepaid is spent first, then earned, then tab headroom, as one balanced posting.
    Watch out for If all three together fall short, the purchase is refused with a 402 and the claim is released for the next buyer. No partial payment, and no listing left stuck in settling.

Steps 7–8 — Member

their manual →
  1. 7
    Confirm what you bought.
    Expected result Your completed settlements with what you paid and which ticket you now own.
  2. 8
    Open your wallet. The purchase response points you straight here.
    Expected result A new pass with a new serial and the tier's zones, ready to scan.
    Watch out for The ticket is the same row it always was — only the credential is new. If someone shows you an older screenshot of 'the same ticket', it is worthless, and that is the design working.

Steps 9 — Door Staff

their manual →

Confirms the new pass works and the seller's old one does not.

  1. 9
    Scan the buyer's new pass, then scan the seller's old one.
    /api/access/scan POST access
    Expected result Green for the buyer. Red for the seller — 'resold' on the old NFC payload, revoked on the old QR.
    Watch out for Two different red reasons for the same event is not inconsistency: the NFC path recognises the blacklisted serial, while the QR path fails because the secret behind it was killed.

Steps 10 — Admin

their manual →

Sees the settlement, the split and the ledger transaction behind it.

  1. 10
    As an admin, read the settlement list for the event and check a split against the configuration.
    /admin/resale/{event_id} resale
    Expected result Settlements with gross, venue fee, host royalty and seller net, plus totals.
    Watch out for If the event is later cancelled, these settlements are unwound newest-first before the primary orders are refunded, so a chain of resales walks the ticket back one owner at a time. That is an admin workflow, not something a member can trigger.

Open or close the resale exchange

Activation, price floor and cap, the venue fee, the host royalty, and the post-doors lock.

Owned by Admin · 12 steps · about 22 minutes

Why this exists

The resale exchange exists so that a member who cannot come has somewhere legitimate to go, and so that the venue keeps control of the secondary market instead of watching it happen on somebody else's website. Every control on this page follows from that.

Activation is automatic by default. A tier's exchange opens when the tier sells out, and closes again if a refund puts inventory back. The logic is that a resale market only makes sense when the primary market is exhausted; otherwise you are competing with your own box office. You can override per tier — forced on, forced off, or automatic — and the override wins. An event-level enable switch sits above all of it.

Prices are bounded in basis points of face value, not in cents, so the bounds scale with the tier. The defaults allow a listing between eighty and one hundred and thirty percent of face. The floor is anti-dumping and the cap is anti-scalping. Bounds are enforced server-side at listing time, and a price outside them is a 422 whose message tells the seller the allowed range.

The splits are honest arithmetic. The venue fee and the optional host royalty come off the price, the seller gets the remainder, and the three parts must sum to the price to the cent — a database constraint, not a hope. The royalty only applies if a host is nominated to receive it, and it lands in that host's earned balance like any other credit.

The post-doors lock is the safety catch. At a configured number of minutes after doors, the exchange locks: active listings are cancelled, tickets and passes go back to their sellers, and no new listing can be made. The reason is the door — a ticket changing hands while people are queueing is how two people arrive holding the same seat. Null means never lock; zero means lock at doors exactly.

Before you start

  • An admin session. Every exchange control is admin-only; a venue manager gets 403.
  • An event that is on sale, sold out or in progress — a draft event has no exchange.
  • A decision, before you start, about the fee, the royalty and who receives it.

Practise with

PersonaEmailPasswordNote
adminadmin@club.test admin123the only persona that may configure or lock an exchange
membernova@demo.club nova-pass-123holds tickets and a live listing on the demo event
host_promoterharper.host@demo.club harper-pass-123the seeded royalty recipient
venue_managermanager@club.test manager123use it to see the boundary: the public exchange yes, the controls no

Steps 1–4 — Admin

their manual →
  1. 1
    Open the exchange index and see every non-draft event with its count of active listings.
    /admin/resale resale
    Expected result A list of events with live listing counts.
    Watch out for Draft events are absent because an exchange for an event nobody can buy into is meaningless.
  2. 2
    Open one event's exchange. Read the configuration, the per-tier activation states, the live listings with seller emails, the settlement totals and the activation log.
    /admin/resale/{event_id} resale
    Expected result Everything about this event's secondary market on one page.
    Watch out for The configuration is created lazily with defaults the first time you look. Seeing sensible values does not mean somebody chose them.
  3. 3
    Set the configuration: enabled or not, the floor and cap in basis points, the venue fee, the host royalty and the host who receives it, and the post-doors lock in minutes.
    /api/admin/resale/events/{event_id}/config PUT resale
    Expected result The saved configuration, and an entry in the append-only activation log recording old and new values.
    Watch out for A cap below face value is legal and will astonish sellers. A royalty with no host nominated pays nobody — the royalty is only applied when a host is set. Invalid combinations are 422 CONFIG_INVALID.
  4. 4
    Before you announce anything, preview the arithmetic: give it an event and a price and it returns the venue fee, the host royalty and the seller net, plus the floor and cap for every tier.
    Expected result The exact split a sale at that price would produce.
    Watch out for The split uses integer division with the remainder going to the seller, so the parts always sum to the price exactly. Do not recompute it in a spreadsheet and expect the last cent to match a percentage.

Steps 5 — Member

their manual →

They are the seller and the buyer: the bounds you set are the prices they are allowed to type.

  1. 5
    As a member, open the listing form for a ticket and see the bounds you configured, expressed in real money.
    /resale/sell/{ticket_id} resale
    Expected result The allowed price range for that ticket's tier.
    Watch out for A price outside the range is refused with 422 PRICE_OUT_OF_BOUNDS and the message names the allowed range. Listing also suspends the ticket's pass — you cannot sell a ticket and walk in on it.

Steps 6 — Admin

their manual →
  1. 6
    Override activation for a single tier: automatic, forced on or forced off.
    /api/admin/resale/tiers/{tier_id}/mode PUT resale
    Expected result The tier's mode is saved and the effective activation recomputed.
    Watch out for Forced on opens resale on a tier that has NOT sold out, so you are now competing with your own box office. That is sometimes the right call — make it knowingly.

Steps 7 — Host / Promoter

their manual →

The royalty share you configure is paid to them, in earned house credit.

  1. 7
    As the nominated host, look at where your royalty lands when a resale settles.
    /my/credit ledger
    Expected result Your earned balance rises. It is spendable in the club immediately and cashable once the post-event hold expires.
    Watch out for The royalty percentage is snapshotted onto the listing when it is created. Changing the configuration afterwards does not re-price listings that already exist.

Steps 8–9 — Admin

their manual →
  1. 8
    Engage the lock by hand when you need the exchange shut now rather than at the configured minute.
    /api/admin/resale/events/{event_id}/lock POST resale
    Expected result Active listings are cancelled, tickets return to issued, suspended passes are restored to their sellers, and the lock is recorded in the activation log.
    Watch out for 409 ALREADY_LOCKED if it is already engaged. Sellers whose listings were cancelled by a lock get their ticket and their working pass back — they are not left with nothing.
  2. 9
    Clear the lock if you engaged it in error and the event has not started.
    /api/admin/resale/events/{event_id}/lock DELETE resale
    Expected result The exchange reopens for new listings.
    Watch out for 409 NOT_LOCKED if it was not locked. Clearing the lock does NOT resurrect the listings it cancelled — sellers must list again.

Steps 10 — Venue Manager

their manual →

They can see the exchange as customers do and cannot change any of it — teach the boundary rather than papering over it.

  1. 10
    As a venue manager, look at the exchange the way a customer does, and then try an admin route to feel where the line is.
    /events/{event_id}/exchange resale
    Expected result The public exchange page loads. Every configuration and lock route answers 403.
    Watch out for This is one of the clearest RBAC boundaries in the platform: managers run the room, admins set the market. Do not work around it by borrowing an admin session.

Steps 11–12 — Admin

their manual →
  1. 11
    Read the settlement totals at the bottom of the exchange page: gross, venue fee, host royalty, seller net, completed and failed counts.
    /admin/resale/{event_id} resale
    Expected result The event's secondary market summarised.
    Watch out for Failed settlements are declined payments and insufficient credit, not errors you need to clean up. The listing simply did not sell to that buyer.
  2. 12
    Finish with the integrity check for the event before you walk away from a configuration change.
    /debug/resale/integrity/{event_id} resale
    Expected result A clean report, or named violations.
    Watch out for Read what it actually covers, because a clean report is narrower than it looks. It checks listings against settlements, the money split to the cent, the ledger transaction, that a transferred ticket is owned by its buyer, that the OLD pass was revoked and blacklisted, and that the buyer has a live pass. All of the pass checks are on the settlement side — after a sale. It does NOT check that an OPEN listing's pass is suspended, so a clean report is not evidence about the case you most care about: a listed seat whose pass still scans green is a seller who can walk in on a ticket that is about to be sold. Listing suspends the pass in the same transaction that lists the ticket, so that state should not arise — but this report is not what would tell you if it did.

Money & House Credit

House credit, VIP tabs, payouts and the double-entry ledger behind them.

Use and settle your VIP tab

Spend against the house tab, read what you owe, and understand the monthly settlement.

Owned by VIP Member · 8 steps · about 15 minutes

Why this exists

The tab is the platform's most misunderstood feature, so the design intent matters more than the clicks here.

A tab is not a balance you own — it is a limit on what you may owe. Spending against it does not spend money; it records an obligation on a double-entry ledger. That is why your purchasing power is three separate things: prepaid credit you actually paid in, earned credit from resale you actually made, and tab headroom you have not paid at all. The platform spends them in exactly that order, so the tab is always the last resort.

Settlement is scheduled, not manual. On the first tick of a new month the platform settles the previous month for everyone carrying a balance: it sweeps your prepaid credit first, then charges your card for the remainder, and emails an invoice. A declined card marks the settlement failed and freezes the tab — the balance stays owed, because freezing a tab is a credit decision, not forgiveness.

The ledger behind all of this is append-only, enforced by database triggers. Nothing here is ever edited; corrections are new balanced entries.

Before you start

  • A VIP member session (vip_member implies member, so all member surfaces work too).
  • A tab with a limit — an admin sets it; VIP status alone does not create headroom.

Practise with

PersonaEmailPasswordNote
vip_membervip@club.test vip123the VIP persona for this workflow
adminadmin@club.test admin123sets tab limits and runs settlement

Steps 1–4 — VIP Member

their manual →
  1. 1
    Open your credit page and read the three numbers separately: prepaid, earned, and tab used against the limit.
    /my/credit ledger
    Expected result The balances plus your purchasing power.
    Watch out for Purchasing power adds tab headroom to real money. It is what you can spend, not what you have.
  2. 2
    Look at the same balances as the API returns them, so you can see the fields the pages are built from.
    Expected result Prepaid, earned, tab used, headroom and status.
  3. 3
    Pay for something with house credit and watch which pot it comes out of.
    /checkout/{order_id} payments
    Expected result Promo is consumed first, then prepaid, then earned, and only then does the tab absorb the remainder.
    Watch out for If the total is more than promo + prepaid + earned + headroom, the payment is refused outright — that sum is `purchasing_power_cents`, and it is the number the refusal is measured against. There is no partial settlement of an order.
  4. 4
    Read your statement as a running story: every top-up, purchase, resale payout and tab movement in order.
    Expected result A line per ledger movement with a running balance.
    Watch out for Nothing on this statement can be edited or deleted, by anyone. If something is wrong it is corrected with a new compensating entry, which will also appear here.

Steps 5–8 — Admin

their manual →

Sets the tab limit, runs settlement, and reads the ledger it posts to.

  1. 5
    As an admin, set or adjust a VIP's tab limit — or freeze it.
    /api/admin/credit/users/{user_id}/tab PUT ledger
    Expected result The tab row with its new limit or status.
    Watch out for Freezing a tab stops further spending; it does not clear what is owed. Revoking VIP status freezes it automatically for the same reason.
  2. 6
    Open the settlement dashboard and look at last month's run: who settled, who failed, who was waived.
    Expected result One row per user per period, with status.
  3. 7
    Run a settlement for a period manually when you need to — for a retry, or to see the mechanism in a demo.
    Expected result Per-user results: prepaid swept, card charged, invoice emailed.
    Watch out for It is idempotent per user and period. Running it twice does not charge twice — the unique constraint on user plus period is the guard, not the button being greyed out.
  4. 8
    Trace one settlement into the ledger and confirm it balances: every transaction has at least two entries and the debits equal the credits.
    Expected result The settlement's transaction with its entries.
    Watch out for Try to edit one and the database itself refuses. The append-only trigger is not a UI convention — it fires even from the admin data suite.

Understand your points and the exchange

Where your points come from, what they buy, when they can leave — and exactly how long your ticket is sellable.

Owned by Member · 13 steps · about 25 minutes

Why this exists

Your balance is counted in points, and what one point is worth is your club's own setting: a point is one cent on this demo, and a club may price it higher — lama.live sets 200, so a point there is two dollars. The books hold the same cash either way, and every screen that spends points prints the money beside them, which is the figure that never changes meaning. Behind the number are four kinds of points with four different rules: Promo (goodwill the club granted you — spendable, never cashable, and spent first precisely because it is the pot you can least afford to be left holding), Prepaid (points your money bought — yours, from the moment they land), Earned (proceeds from selling a ticket on the exchange — spendable instantly, but they can only leave the platform 24 hours after the event ends), and the VIP Tab (headroom against a limit — permission to go negative, not points you own). Spending order is always promo → prepaid → earned → tab; you cannot choose which pot pays. (The order is `post_sale_on_credit`'s, and its docstring states it: the manual said prepaid → earned → tab until 2026-08-26 and had never mentioned promo at all.) One footnote for the curious: the club's books keep this balance under its accounting name, house credit — members read points, accountants read credits, and both describe the same integer.

And the exchange: your ticket becomes sellable when its tier sells out (or when staff force the exchange open), not on any date. Listing suspends your pass on the spot. New listings are members-only for their first 48 hours, then public. Everything unsold is handed back shortly after doors — that lock, set per event, is the real deadline. There is no four-day rule, and the site's word for releasing a ticket is list; the reverse is delist.

Before you start

  • A member account (membership is free — register and you are one)
  • A published event with tickets on sale

Steps 1–4 — Member

their manual →
  1. 1
    Read your balance.
    /my/credit ledger
    Expected result Prepaid, earned and tab shown separately, in points, with purchasing power as the sum.
    Watch out for One number on the button, FOUR kinds of points behind it — promo, prepaid, earned and the tab. They do not behave the same.
  2. 2
    Load $20 from the wallet page (the form posts here).
    /api/credit/topup POST ledger
    Expected result A balanced transaction: card charged, prepaid points up — $20 buys 2,000 points.
    Watch out for Min $5, max $1000 — both settings. This is the only kind of points you can create yourself.
  3. 3
    Buy a ticket and choose points at checkout.
    /events/{event_id} events
    Expected result The order pays without touching a card; promo is spent first if you have any, then prepaid.
    Watch out for The spend order is promo → prepaid → earned → tab, always. You cannot pick the pot. Promo goes first on purpose: it is the pot you did not pay for, and the one you are most likely to lose. Note what cashing out actually reaches — a payout is drawn from earned alone, so promo AND prepaid can only ever be spent inside the platform. Two of the four pots never leave; only earned does.
  4. 4
    Try to sell the ticket before its tier sells out.
    /my/tickets frontend
    Expected result No Sell link — the exchange is not open.
    Watch out for The exchange opens on SELL-OUT, not on a date. This is the fact the feedback note got wrong: there is no four-day rule and no calendar involved.

Steps 5 — Admin

their manual →

Staff can force a tier's exchange open before it sells out; the automatic path is sell-out.

  1. 5
    Force the tier's exchange on.
    /admin/resale/{event_id} resale
    Expected result The tier flips to forced_on, and the change is audited.
    Watch out for Staff can open it early; the automatic path is sell-out. Either way it is a state, not a schedule.

Steps 6–11 — Member

their manual →
  1. 6
    List the ticket at a legal price.
    /resale/sell/{ticket_id} resale
    Expected result The floor/cap band, the fee preview, and your listing.
    Watch out for The band is anti-scalping and comes from the event's own config. Your pass is suspended the instant you list.
  2. 7
    Look at the ticket you just listed.
    /my/tickets frontend
    Expected result It shows SUSPENDED.
    Watch out for This answers "can I list it and still go if it does not sell?" — you can, but only after you delist.
  3. 8
    Find your own listing on the exchange.
    /events/{event_id}/exchange resale
    Expected result It sits under Member exchange with a countdown to public release.
    Watch out for Members get the first 48 hours; after that everyone sees it. The clock runs from when you listed — not from the show date.
  4. 9
    Delist it.
    Expected result Ticket back to issued, pass restored, with its ORIGINAL serial.
    Watch out for Delisting restores the original credential. A sale does not — the buyer gets a new one and yours dies.
  5. 10
    Re-list it, let another member buy it, then watch the money land.
    /my/credit ledger
    Expected result Earned points up by price minus the venue fee and any royalty.
    Watch out for This is Earned (2020), not Prepaid. They spend immediately — and pay out only after the post-event hold.
  6. 11
    Ask for a payout of the proceeds.
    Expected result Held until 24 hours after the event ends.
    Watch out for The hold exists because a cancelled show has to be able to claw the money back.

Steps 12 — VIP Member

their manual →

The tab is VIP-only headroom — the one kind of money you do not own.

  1. 12
    Look at the tab line as a VIP.
    /my/credit ledger
    Expected result Headroom against a limit, not a balance.
    Watch out for A tab is permission to go negative up to a limit — the only one of the four that is a DEBT rather than a balance you hold.

Steps 13 — Member

their manual →
  1. 13
    Come back after the post-doors lock.
    /events/{event_id}/exchange resale
    Expected result Active listings cancelled, tickets and passes handed back to their sellers.
    Watch out for The window closes shortly after doors — that is the real deadline, and the venue sets it per event.

Top up your house credit

Put money on your account with a card, and see it land as a balanced ledger posting.

Owned by Member · 7 steps · about 10 minutes

Why this exists

A top-up is the one moment a member deliberately turns money into house credit, and the platform treats it as exactly what it is: the venue taking your cash and recording a liability to you. Two entries, balanced, append-only, and a separate transaction for the processor's fee so the cost of taking the money is never hidden inside the revenue.

The part worth teaching is idempotency. The top-up endpoint accepts an idempotency key, and the key check, the card charge and the ledger postings all happen inside one write transaction. A duplicate request blocks until the first commits and then returns "not applied" without ever reaching the processor. That is why a double-tap on a bad connection cannot charge a card twice — the guarantee is structural, not a disabled button.

Know what prepaid credit is and is not. It is spendable on anything the platform sells and it is spent first, before earned credit and before a VIP tab. It is not cashable out: only earned balance from resale can be paid to a bank account. Money that comes in through this door does not go back out through that one.

Before you start

  • A member session.
  • An amount inside the configured limits: at least $5 and at most $1000 per top-up.

Practise with

PersonaEmailPasswordNote
membermember@club.test member123starts with nothing, so the first top-up is easy to see
adminadmin@club.test admin123reads the resulting transaction and the trial balance

Steps 1–5 — Member

their manual →
  1. 1
    Open your credit page and find the Top up card. Use a quick amount button or type an amount in cents.
    /my/credit ledger
    Expected result An amount field, a card token field, and an Add credit button.
    Watch out for Amounts are in cents everywhere in this platform. Typing 50 gets you fifty cents, not fifty dollars — and the minimum will reject it.
  2. 2
    Submit the top-up. In the demo the token tok_success succeeds and tok_decline is refused.
    /api/credit/topup POST ledger
    Expected result 201 with the transaction, the companion fee transaction, and your refreshed balances.
    Watch out for A declined card still records the attempt in the outbound call log. Failure is evidence here, not silence.
  3. 3
    Send the same request again with the same idempotency key to see the guard.
    /api/credit/topup POST ledger
    Expected result 200 with applied false, and no second charge.
    Watch out for Without a key, a second request is a second genuine top-up. The key is what makes a retry safe.
  4. 4
    Read your balances and identify which pot grew.
    Expected result Prepaid up by the amount; earned and tab untouched.
    Watch out for Purchasing power went up by the same amount, but they are not the same number. Purchasing power includes a VIP tab's headroom, which is not money.
  5. 5
    Find the top-up on your statement and read the running balance around it.
    Expected result A line for the top-up with the balance after it.
    Watch out for Nothing on this statement is editable by anyone, including admins. If it is wrong, the fix is a new compensating entry that also appears here.

Steps 6–7 — Admin

their manual →

Verifies the posting balances and can make corrective adjustments if it does not.

  1. 6
    As an admin, open the transaction and read both sides plus the separate fee posting.
    /admin/ledger/transactions/{txn_id} ledger
    Expected result Cash clearing debited, the member's house-credit liability credited, and a second transaction for the processing fee.
    Watch out for The fee is a real expense posted separately on purpose. Netting it into the top-up would make the member's liability wrong by the fee.
  2. 7
    Check the trial balance still nets to zero after the movement.
    /admin/ledger ledger
    Expected result A balanced trial balance.
    Watch out for If it ever does not, stop and investigate rather than adjusting to taste. An unbalanced ledger on this platform means a bug, not a rounding difference — the posting rules make imbalance impossible to store.

Get paid: your host royalty and its settlement

How a host actually earns on this platform, when the money is releasable, and how to cash it out.

Owned by Host / Promoter · 13 steps · about 20 minutes

Why this exists

Be clear about what this platform does and does not pay a host, because the paperwork and the plumbing are not the same thing.

Primary ticket sales post to the venue's revenue account. There is no automatic revenue share that pays a host out of primary ticket sales — whatever door split your contract describes is settled by the venue as a commercial matter. What the platform genuinely pays you, automatically and on a ledger, is the resale royalty: a per-event share, in basis points, of every ticket resold on the exchange for your event.

When a resale settles, one balanced transaction splits the sale price three ways — a venue fee, your royalty, and the seller's net — and your share is credited to your earned balance. The split is integer arithmetic with the remainder going to the seller, and the parts always sum to the price; the database enforces it.

Earned balance is spendable inside the club immediately, but cashing it out is held: every credit creates an earning lot whose release time is the event's completion plus a hold (24 hours by default), so a promoter cannot cash out on a show that has not happened yet. Above a yearly threshold on secondary sales you must complete KYC before any external cash-out; the platform stores only a hash of your tax id and its last four digits.

Both of those hold, and here is what makes them hold — checked 2026-08-27 against the running code, because both are the kind of promise that is easy to write and hard to keep. The split is a table constraint, not a rule the code remembers to apply: resale_settlements carries CHECK (price_cents = venue_fee_cents + host_royalty_cents + seller_net_cents), so a row that does not add up cannot be written at all. The hold looks weaker than it is: the release time falls back to immediately available when an event has no end time, and main_end is a nullable column — which reads like a way to cash out early. It is not, and the reason is four links away: a royalty needs a settlement, a settlement needs a sold ticket, a ticket needs an event that reached sale, and an event cannot be announced or put on sale without main_end. Take that last requirement away and the hold quietly stops applying.

Built behaviour that the paperwork does not tell you: ticking "Request resale royalty" on your application, and the royalty rider it puts in your contract, do not switch the money on. The royalty rate and the recipient are fields on the event's resale configuration, and an admin has to set them. If nobody sets host_royalty_bps and the host user on that config, every resale on your event pays you nothing — and the contract will still say you have a royalty. Check it before the on-sale, not after.

Before you start

  • A resale royalty configured on your event by an admin (rate in basis points plus the recipient user).
  • At least one completed resale on that event.
  • For an external cash-out: matured earning lots, and KYC once you pass the yearly threshold.

Practise with

PersonaEmailPasswordNote
host_promoterharper.host@demo.club harper-pass-123the seeded royalty recipient on demo-event-0001 (5 percent, 500 basis points)
adminadmin@club.test admin123sets the royalty and is the only persona that can release a payout

Steps 1–2 — Admin

their manual →

Sets the royalty rate and recipient, and is the only one who can process a payout.

  1. 1
    Set the event's resale configuration: the host royalty in basis points and the host user who receives it, alongside the venue fee, the price floor and cap, and any post-doors lock.
    /api/admin/resale/events/{event_id}/config PUT resale
    Expected result The updated config, with the change written to the append-only activation log showing the old and new values.
    Watch out for This is the switch that actually pays the host. The application flag and the contract rider do not set it, and the recipient is a plain user id — it is not derived from the scoped host grant, so it can legitimately point at somebody who is not the event's host.
  2. 2
    Open the event's exchange console to confirm the configuration, the per-tier modes, the live listings and the settlements.
    /admin/resale/{event_id} resale
    Expected result Config, tier modes, lock state, listings with seller emails, settlements and the audit log.
    Watch out for Admin only. Neither the host nor the venue manager can open this page.

Steps 3–9 — Host / Promoter

their manual →
  1. 3
    As the host, look at your event's exchange the way a buyer does: which tiers are trading and at what prices.
    /events/{event_id}/exchange resale
    Expected result Active listings per tier with the price floor and cap derived from face value.
    Watch out for Seller identity is never exposed on the exchange, to you or to anyone. You see prices, not people.
  2. 4
    Open your wallet and read the balances separately: prepaid credit, earned balance, and how much of the earned balance is actually eligible for payout.
    /my/credit ledger
    Expected result Your royalties sitting in the earned balance, with the payout-eligible figure lower until the hold matures.
    Watch out for Earned and payout-eligible are two different numbers and people confuse them constantly. Spending inside the club ignores the hold; cashing out does not.
  3. 5
    Pull the same balances as fields, including your year-to-date secondary sales and your KYC status.
    Expected result Prepaid, earned, payout-eligible earned, tab (if any), purchasing power, YTD secondary sales and KYC state.
    Watch out for The YTD figure is what the tax module reads for a 1099-K. Resales that were later unwound because an event was cancelled are excluded from it.
  4. 6
    Read the statement: every royalty arrives as its own line, tied to the settlement that created it, with a running balance.
    Expected result One line per ledger movement, newest first, filterable by date and account.
    Watch out for Nothing on this statement can ever be edited or deleted, by anyone — the ledger is append-only and enforced by database triggers, not by UI convention. A mistake is corrected with a new balanced entry, which also appears here.
  5. 7
    Submit KYC once your year-to-date secondary sales pass the threshold: legal name and tax id.
    /api/credit/kyc POST ledger
    Expected result A KYC record, and external cash-out unblocked.
    Watch out for Only a SHA-256 hash of the tax id and its last four digits are stored — the raw number is never persisted, and your user row carries only a masked form. Until it is submitted, a payout request above the threshold is refused with KYC_REQUIRED.
  6. 8
    Open the payouts page and check what is releasable and when the rest matures.
    Expected result Your payout history, the eligible amount and a countdown on held funds.
    Watch out for The hold is per earning lot and it is measured from the event's completion, not from the sale. Royalties from a show next month are simply not cashable this month.
  7. 9
    Request a payout for an amount you have available, with your ACH routing and account numbers.
    /api/credit/payouts POST ledger
    Expected result A payout request that starts held and becomes eligible once its lots have matured.
    Watch out for Only the last four digits of the account number are stored. Requests below the minimum payout setting are refused, as are amounts above your eligible earned balance. You can cancel your own request while it is still held or eligible — not after it is processing.

Steps 10–12 — Admin

their manual →

Sets the royalty rate and recipient, and is the only one who can process a payout.

  1. 10
    As an admin, review the payout queue: who is asking, how much, and whether their KYC is in order.
    Expected result Payout requests grouped by status with their lots.
    Watch out for Admin only — a venue manager is 403 on every ledger and payout surface. That boundary is deliberate and worth teaching rather than papering over.
  2. 11
    Process an eligible payout to send it to the ACH rail.
    /api/admin/payouts/{payout_id}/process POST ledger
    Expected result A transfer to the payments mock, a balanced payout posting, and the request moving toward paid on the settlement webhook.
    Watch out for Eligible only, and KYC is re-checked at process time rather than trusted from when the request was made. A failed transfer posts a reversal and restores the money to the host's earned balance as an immediately available lot.
  3. 12
    Reconcile the period: pull the settlements for the event with their totals.
    Expected result Gross, venue fee, host royalty and seller net across completed and failed settlements.
    Watch out for Those totals are the honest answer to 'what did the promoter earn'. They come from the settlement rows, which carry a database check that the three parts sum exactly to the sale price.

Steps 13 — Host / Promoter

their manual →
  1. 13
    Understand what happens if your event is cancelled: every resale on it is unwound, the buyer is refunded in full on the rail they paid on, and your royalty is clawed back.
    Expected result A reversal posting that mirrors the original settlement exactly.
    Watch out for The clawback can only take what you still have. Earned balances are never allowed to go negative, so if you already spent or cashed out the royalty, the shortfall is written off by the venue and recorded as an unrecovered receivable against your name. It is not forgiven quietly — it is a number somebody will call you about.

Read your credit statement

Understand the three balances, the running statement, and why nothing on it can be edited.

Owned by Member · 7 steps · about 10 minutes

Why this exists

Most members meet the ledger exactly once: when they want to know why their balance is what it is. So the statement is built to be read as a story rather than queried as a table — every movement in order, with a running balance, and no line that exists without a cause.

The mental model that makes it click is that your balance is several separate things. Promo is credit the venue gave you, backed by nothing you paid. Prepaid is money you put in. Earned is money you made selling tickets to other members. Tab, for VIPs, is money you have not paid at all. They behave differently on the way out — the spend order is promo, then prepaid, then earned, then tab, always, so the venue's gift goes first and your own money is touched only after it is gone — and on the way to a bank account, where only earned can ever leave the platform, and only after its hold has passed.

Promo is easy to miss and it is the one spent first. Your wallet shows it beside the others; the statement's per-account columns and its account filter do not yet, so a promo movement is in the running balance without a column of its own. If the numbers look off by exactly the size of a gift you were given, that is where it went.

The reason nothing here can be corrected in place is the whole point of a double-entry ledger: the tables are append-only, enforced by database triggers, so an UPDATE or DELETE aborts even from the admin data suite or a raw SQL console. A mistake is fixed by posting a new, balanced, clearly labelled adjustment — which also appears on your statement, so the correction is part of the story rather than a quiet rewrite of it.

Before you start

  • A member session with some history — the seeded credit demo accounts are ideal.
  • An admin session for the last two steps.

Practise with

PersonaEmailPasswordNote
membersam.seller@demo.club sam-pass-1234earned balance from a resale plus one completed payout — the richest statement
memberalice.credit@demo.club alice-pass-123prepaid only, for the simplest possible reading
vip_membervip@club.test vip123shows the tab block with limit, used and headroom
adminadmin@club.test admin123sees the same movements as balanced transactions

Steps 1–4 — Member

their manual →
  1. 1
    Open your credit page and name each figure out loud before moving on.
    /my/credit ledger
    Expected result Prepaid, earned, the payout-eligible slice of earned, and purchasing power.
    Watch out for Earned and payout-eligible earned are different. Money from a sale is spendable here immediately but not cashable out until its hold expires, roughly a day after the event.
  2. 2
    Read the same thing as JSON so you know the field names when you are debugging someone else's screenshot.
    Expected result The balances, the tab block or null, purchasing power, year-to-date secondary sales and your KYC status.
  3. 3
    Open the statement and follow the running balance from the top.
    Expected result Newest first, one line per movement, with the balance after each.
    Watch out for The page is print-friendly on purpose. It is the artefact you hand someone who is disputing a balance.
  4. 4
    Narrow it down: filter by date range or by account when a member asks about one specific week.
    Expected result The same rows, filtered and paged.
    Watch out for Everything here is strictly self-scoped. There is no member-facing way to read anyone else's statement — not by id, not by guessing.

Steps 5 — VIP Member

their manual →

Has the fourth number — the tab — that ordinary members never see.

  1. 5
    As a VIP, read the tab card: limit, used, headroom and status.
    /my/credit ledger
    Expected result The tab shown separately from real money.
    Watch out for A frozen tab still shows what you owe. Freezing removes headroom, not debt, and the monthly settlement will still come for the balance.

Steps 6–7 — Admin

their manual →

Reads the same money from the ledger side and owns corrections.

  1. 6
    As an admin, open the trial balance and find the account that holds members' prepaid credit.
    /admin/ledger ledger
    Expected result Every account with its debits, credits and net, summing to zero.
    Watch out for A member's balance is a per-user slice of one platform account. There is no per-member table to fix — the balance is derived from entries.
  2. 7
    Correct something the right way: post a balanced adjustment with a memo explaining it.
    Expected result A new transaction, visible on the member's statement.
    Watch out for This is the only correction mechanism. Attempting to edit or delete an existing entry is refused by the database itself, and the debug console has a probe that demonstrates exactly that.

Read the trial balance

Understand the chart of accounts, prove the books balance, and trace one number to its transaction.

Owned by Admin · 8 steps · about 20 minutes

Why this exists

The house-credit economy is a real double-entry ledger, not a balance column on the user row. Every movement of money is a transaction with at least two entries whose debits equal its credits, and the sum of every entry ever written is zero. That single property is what lets you answer "where did it go" without ever guessing.

Why go to that trouble for an in-app wallet? Because the platform holds customer money in three genuinely different ways and they must not be allowed to blur. Prepaid credit is money customers paid in and the venue owes back. Earned balance is money resale sellers made and the venue owes them. Tab used is money customers owe the venue. A single wallet number would hide all three, and the difference between them is the difference between a liability, a payable and a receivable.

The chart of accounts is small and fixed. Cash and payout clearing, the four per-user accounts — the three above plus promotional credit, which the venue mints rather than the customer paying in — two tax liability accounts, ticket revenue and royalty fees, processing expense, and a write-off account for resale clawbacks the venue absorbed. Per-user accounts carry a user id on every entry, enforced by the database, which is what makes a per-person sub-balance meaningful.

The books are append-only, enforced by triggers that fire on raw SQL as readily as on service calls. Reading the trial balance is therefore a genuinely trustworthy act: you are not reading a cache or a view someone could have edited, you are reading the sum of everything that ever happened.

Before you start

  • An admin session. This entire area is admin-only; a venue manager gets 403 even on the read-only routes.
  • A seeded database, so there is enough traffic for the numbers to be interesting.

Practise with

PersonaEmailPasswordNote
adminadmin@club.test admin123the only persona that can open any ledger surface

Steps 1–8 — Admin

their manual →
  1. 1
    Open the trial balance. Read it top to bottom once before you look for anything in particular.
    /admin/ledger ledger
    Expected result Every account with its debit total, credit total and net, plus a balanced flag and a grand total net that must be zero.
    Watch out for If the grand total is not zero, stop everything else you were doing. It means the append-only guarantee has been circumvented, and no other number on the platform can be trusted until you know how.
  2. 2
    Pull the chart of accounts and learn the codes. You will be typing them into adjustments and filters for the rest of your time here.
    Expected result Each account with its normal side and whether it is per-user.
    Watch out for CORRECTED 2026-08-30: FOUR accounts are per-user, not three. Prepaid member credit (2010), promotional credit (2015), seller earned balance (2020) and VIP tab used (2030) — 2015 arrived with the promo pot and this sentence did not move. Those four are the only ones where a per-person number exists at all, and if you are reconstructing somebody's credit from account filters, leaving 2015 out under-reports what they can spend.
  3. 3
    Pull the trial balance as of a specific instant when you need to answer a question about a moment in the past rather than about now.
    Expected result The same structure, computed as of the timestamp you asked for.
    Watch out for As-of is computed from the entries every time — `trial_balance` sums `ledger_entries` with a date filter and reads no stored total, which is why it can be trusted. CORRECTED 2026-08-30: the reason this step used to give was wrong twice. There IS a month-end close (close, lock and reopen a period; it closes months in order and refuses over a red invariant), and closing DOES store a trial-balance snapshot on the period row. Neither invalidates the as-of figure, because nothing reads that snapshot to answer this question — but do not go to a closed month expecting to post into it. An adjustment there is 409 PERIOD_CLOSED, and a locked month refuses even the prior-period escape.
  4. 4
    Move from totals to movements. Filter by kind to see one class of thing at a time — sales, refunds, top-ups, resale settlements, payout holds and releases, tab settlements, adjustments.
    Expected result A paged list with memos, kinds and references.
    Watch out for The reference type and id are the join back to the rest of the platform. A refund transaction points at a refund; a settlement points at a resale settlement. Follow the reference rather than matching amounts by eye.
  5. 5
    Open one transaction and confirm the double entry with your own eyes: debits on one side, credits on the other, the same total.
    /admin/ledger/transactions/{txn_id} ledger
    Expected result Entries with account codes, amounts and the user attribution on per-user lines.
    Watch out for Card sales post the processing fee as a SEPARATE transaction, not as a line on the sale. If a sale's numbers look too round, the fee is next door.
  6. 6
    Use the API when a question needs filtering the page cannot do — by user, by account, by date range, by reference.
    Expected result The filtered transactions as JSON.
    Watch out for Filtering by account plus user id is how you reconstruct one person's history of one balance. That is usually the fastest answer to a support question about credit.
  7. 7
    Run the integrity report and read every check, not just the overall flag.
    Expected result Balanced transactions, earning lots matching the earned account, no negative sub-balances, payout clearing matching live payouts, the trial balance balanced, and the protective triggers present.
    Watch out for The triggers check is the load-bearing one. Everything else on this page assumes those triggers exist.
  8. 8
    Finally, open the ledger entries in the universal data suite and try to change something.
    Expected result A read-only grid. The suite classified it automatically because the table carries append-only triggers.
    Watch out for There is no admin override. The one legitimate way to change a balance is a new balanced adjustment posting — which is a different workflow, and deliberately so.

Process a payout run

Take sellers' earned balances out to the bank — through the hold, the KYC gate and the ACH result.

Owned by Admin · 10 steps · about 25 minutes

Why this exists

A payout is the only route by which money leaves this platform to a person's bank, so it is the most gated thing in it. Three gates sit in a row, and each one exists for a different reason.

The hold. Resale proceeds are spendable inside the club immediately but are not cashable until a hold expires — by default a day after the event finishes. The reason is that an event can still be cancelled after it sells, and a cancellation claws proceeds back from sellers. Money that has left for a bank account cannot be clawed back, and the ledger will not let an earned balance go negative, so the venue would absorb it as a write-off. The hold is what keeps that window small.

The KYC gate. Over the reporting threshold of gross secondary sales in a calendar year, a cash-out requires a verified tax identity. The check is on external cash-outs only — spending your earned balance inside the club is never gated, because that is not reportable income leaving the platform. The identity itself is stored as a hash plus the last four digits; the raw number is never persisted anywhere, and the user row keeps only a masked marker. The gate is re-checked at processing time, not just at request time, so revoking someone's verification actually stops a payout that is already in the queue.

The clearing account. Requesting a payout does not pay anybody. It moves the amount out of the seller's earned balance into a payout clearing account, which is where it sits while the transfer is in flight. It leaves clearing only when the bank tells us what happened: settled posts a release, failed posts a reversal and gives the seller their balance back as an immediately available lot. The clearing account balance should always equal the money genuinely in flight, and the integrity report checks exactly that.

Worth knowing what the platform does not do: it holds the KYC record and computes year-to-date gross secondary sales, but it does not generate a 1099-K document. The tax module's filing package produces federal, state and municipal returns, not information returns for sellers.

Before you start

  • An admin session for the processing half; a member session for the requesting half.
  • A seller with an earned balance (sam.seller@demo.club has one, plus a completed payout to read).
  • Debug endpoints enabled if you want to skip the hold or simulate the bank result.

Practise with

PersonaEmailPasswordNote
adminadmin@club.test admin123processes payouts and can revoke a KYC verification
membersam.seller@demo.club sam-pass-1234$46.75 earned and one already-paid payout to inspect

Steps 1–3 — Member

their manual →

It is their money leaving the platform: they request it and they are the one the identity gate applies to.

  1. 1
    As the seller, open your payouts page. Read the eligible amount, which is not the same as your earned balance.
    Expected result Your earned balance, the portion that has cleared its hold, any pending requests with a countdown, and a KYC panel if you are over the threshold.
    Watch out for Earned and payout-eligible are different numbers on purpose. Money from an event that has not finished yet is spendable in the club and not yet cashable.
  2. 2
    Submit your legal name and tax id if the page asks for them.
    /api/credit/kyc POST ledger
    Expected result A verified record. Only a hash and the last four digits are stored.
    Watch out for Formats are checked strictly and a bad one is 422 TAX_ID_INVALID. Under the threshold this step does not exist at all — the platform does not collect identity it does not need.
  3. 3
    Request the payout: an amount and your bank details.
    /api/credit/payouts POST ledger
    Expected result A request that is either held with a countdown or immediately eligible, and a ledger posting that moves the amount from your earned balance into payout clearing.
    Watch out for 409 for more than your earned balance, 422 for malformed bank details, 403 KYC_REQUIRED if you are over the threshold without verification. Only the last four digits of the account number are ever stored.

Steps 4–10 — Admin

their manual →
  1. 4
    As an admin, open the payout queue and filter by status. The lifecycle is held, eligible, processing, paid — or failed or cancelled.
    Expected result Requests with amounts, users, hold expiry and status.
    Watch out for Only eligible requests can be processed. A held one is not yours to hurry along by hand — the scheduler promotes it when the hold expires.
  2. 5
    In training, promote matured holds immediately instead of waiting, so you can see the rest of the workflow today.
    Expected result The ids that moved from held to eligible.
    Watch out for This runs the same promotion the scheduler runs; it does not skip an unexpired hold. To skip a hold in a demo, make the lot available first — do not edit the row.
  3. 6
    Process an eligible payout. This is the call that hands the transfer to the bank rail.
    /api/admin/payouts/{payout_id}/process POST ledger
    Expected result The request moves to processing and a transfer record is created with the processor's transfer id.
    Watch out for 409 PAYOUT_NOT_ELIGIBLE for anything not in eligible status. And the KYC gate is re-checked HERE, so a verification revoked after the request was made stops the payout at this point with 403 KYC_REQUIRED.
  4. 7
    Simulate the bank's answer both ways: settle one payout and fail another with a failure code.
    Expected result Settled posts the release out of payout clearing and marks the request paid. Failed reverses it and restores the seller's earned balance as an immediately available lot.
    Watch out for In production this arrives as a webhook, and both handlers are idempotent — replaying a settlement does not pay twice. Rehearse the failure path too: a seller whose transfer bounced gets their balance back, not an apology.
  5. 8
    Trace the whole life of one payout through the ledger: the hold, then the release or the reversal.
    Expected result Two transactions per completed payout, both balanced, both referencing the payout.
    Watch out for The payout clearing account should net to exactly the money in flight. If it does not, the integrity report will say so before anyone else notices.
  6. 9
    Learn the revocation path: an admin can revoke a verification when something about it turns out to be wrong.
    /api/admin/credit/users/{user_id}/kyc/revoke POST ledger
    Expected result The record moves out of verified status.
    Watch out for Revoking does not claw back payouts already paid. It stops the next one, at processing time. Treat it as a stop, not as an undo.
  7. 10
    Finish by running the payout gate invariant and reading its answer.
    Expected result Confirmation that paid payouts carry a release posting, that none was processed before its hold expired, and that none over the threshold was processed without verified identity.
    Watch out for This invariant is the reason the gates above are worth obeying rather than working around. If you find a way past a gate, this check is what will find it too.

Post a house credit adjustment

Move a balance by hand the only way the platform allows: a new, balanced, append-only entry.

Owned by Admin · 8 steps · about 18 minutes

Why this exists

Sooner or later somebody's balance is wrong and you have to fix it. This workflow is about the fact that "fix" here never means "edit". The ledger is append-only, enforced by database triggers rather than by convention, and there is no admin surface anywhere — not the data suite, not raw SQL through the console — that can update or delete a posted entry. The only way to change a balance is to post a new balanced transaction that says what you changed and why. Undoing one whole transaction has a control of its ownReverse this transaction, on any transaction's page — which posts the exact mirror with a reason code and links the pair both ways. Compose an adjustment by hand when you are correcting something in part; reverse when the whole posting was wrong.

That sounds bureaucratic until the first time somebody asks what happened to their money. Because nothing is ever rewritten, the statement is a complete and ordered story: the mistake is there, the correction is there, and the reason is attached to both. An editable ledger cannot make that promise no matter how careful its admins are.

Adjustments are ordinary double-entry postings, so the ordinary rules apply. At least two entries. Every entry is on exactly one side. Debits must equal credits to the cent. Per-user accounts require a user id — member house credit, seller earned balance and VIP tab used are all per-user, and the database refuses an entry on them without one. And no posting may drive a user's sub-balance negative: there is no overdraft, no receivable wallet, just a refusal.

Memo discipline matters more here than anywhere else on the platform. The memo you type is the explanation an auditor reads. "Adjustment" is not an explanation.

Before you start

  • An admin session — every ledger admin route is admin-only and a venue manager gets 403.
  • The chart of accounts to hand: the account codes are on the trial balance page.
  • A decided, written reason. Post the memo you would be happy to read aloud.

Practise with

PersonaEmailPasswordNote
adminadmin@club.test admin123the only persona that can post an adjustment
memberalice.credit@demo.club alice-pass-123seeded with $150 prepaid house credit — a safe balance to practise on

Steps 1–4 — Admin

their manual →
  1. 1
    Open the user's credit page. This is the adjustment composer as well as the readout: balances, recent movements, and the chart of accounts to pick from.
    /admin/credit/users/{user_id} ledger
    Expected result Prepaid, earned and tab figures for that user, plus their recent transactions.
    Watch out for Read the three balances as three different things. Prepaid is money they paid in, earned is money they made from resale, tab is money they owe. Correcting the wrong one is a bigger mess than the original error.
  2. 2
    Pull the raw blob for the same user when you want the underlying numbers rather than the page's presentation of them.
    /api/admin/ledger/users/{user_id} ledger
    Expected result Balances, earning lots, tab state and KYC status.
    Watch out for Earning lots matter for cash-out timing, not for spending. A user can spend earned credit immediately and still not be able to withdraw it yet.
  3. 3
    Post the correction: a memo that explains it, and the entries. Give every entry an account code, a direction and an amount, and put the user id on any per-user account.
    Expected result 201 with the new transaction id. The transaction is recorded with kind adjustment.
    Watch out for An empty memo is 422 MEMO_REQUIRED. Unbalanced entries are 422 UNBALANCED_ENTRIES and nothing is written. A per-user account without a user id is 422 USER_REQUIRED. A posting that would take a sub-balance below zero is 409 WOULD_GO_NEGATIVE. Every one of those refusals leaves the ledger exactly as it was.
  4. 4
    Open the transaction you just posted and check it reads the way you meant: debits on the left, credits on the right, equal totals, your memo on top. If it is wrong, use the Reverse this transaction card on this page — pick a reason code, say why in a sentence, confirm.
    /admin/ledger/transactions/{txn_id} ledger
    Expected result The entries with the user attribution on the per-user lines, and a Reverse card above them on any transaction that is not itself a reversal.
    Watch out for Reverse posts the exact mirror of every entry and links the two both ways, so the pair reads as one story. Do not hand-compose the mirror in the composer: it is the same money and about eleven presses instead of four, and the result is an unlinked transaction with no reason code — nothing joins it to the mistake it corrects. One reversal per transaction, ever, and a reversal cannot itself be reversed (409 CANNOT_REVERSE_REVERSAL — re-post the original instead). There is still no edit button anywhere, which is the point: two honest rows beat one tidy one.

Steps 5 — Member

their manual →

It is their balance that moves, and the correction lands on their statement in full view.

  1. 5
    Look at the correction from the member's side, on their statement.
    Expected result Your adjustment appears in the running story with its memo, between the ordinary top-ups and purchases.
    Watch out for The member sees the memo. Write it for them, not for you.

Steps 6–8 — Admin

their manual →
  1. 6
    Understand the neighbouring control that is not an adjustment: setting or freezing a VIP's tab limit.
    /api/admin/credit/users/{user_id}/tab PUT ledger
    Expected result The tab row with its new limit or status.
    Watch out for A tab limit is permission to owe, not a balance. Raising it gives nobody any money, and freezing it stops further spending without cancelling a penny of what is already owed.
  2. 7
    Prove the append-only claim to yourself rather than believing this page: run the mutation attempt probe.
    Expected result A report showing every attempted update and delete against the ledger being rejected by the database.
    Watch out for If any attempt ever succeeds, this endpoint fails loudly with a 500. That is intentional — a silently mutable ledger is worse than a broken endpoint.
  3. 8
    Finish with the integrity report so you know your correction did not break something else.
    Expected result Balanced transactions, lots reconciling to the earned account, no negative sub-balances, payout clearing matching live payouts, and a balanced trial balance.
    Watch out for Fix a red check before you move on. Ledger errors compound: the next report you run is built on the mess you left.

Run the VIP tab settlement

Close out a month of tab spending: sweep credit, charge cards, and handle the failures.

Owned by Admin · 9 steps · about 20 minutes

Why this exists

A VIP tab is not a balance the member owns; it is a limit on what they may owe. Spending against it moves no money at all — it records an obligation. That is why the platform spends real money first: prepaid credit, then earned balance, and only then the tab. The tab is always the last resort, which keeps the amount to be collected as small as it honestly can be.

Settlement is scheduled, not manual. On the first tick of a new calendar month the platform settles the previous month for everybody carrying a tab balance: it sweeps their prepaid credit first, charges their card for whatever remains, and emails an invoice. Doing it on a schedule rather than on demand means nobody has to remember, and nobody chooses who gets chased.

The admin surface exists for the exceptions, and the exceptions are the interesting part. A declined card marks the settlement failed and freezes the tab. The balance is still owed — freezing is a credit decision, not forgiveness. Retry re-attempts the charge. Waive is meant to write it off deliberately and on the record — as of 2026-08-30 it does neither: it relabels the row, posts nothing, and next month's run charges the same card for the same money. Plan 675 is the fix; until it ships, treat a waive as a note to yourself and not as forgiveness. Both are still actions with names, which is better than an admin quietly adjusting a balance to make a problem disappear.

The idempotency guard is a unique constraint on user plus period, not a greyed-out button. Running settlement twice for the same month cannot charge twice even if two admins press it at the same second — which is exactly the property you want in the thing that touches customers' cards.

Before you start

  • An admin session. Tab settlement, tab limits and the settlement dashboard are all admin-only.
  • A VIP with tab usage to settle (vip@club.test is seeded with $80 used against a $250 limit).
  • Know which period you mean. Periods are calendar months and the endpoint takes one.

Practise with

PersonaEmailPasswordNote
adminadmin@club.test admin123runs settlement, retries, waives and sets limits
vip_membervip@club.test vip123the seeded tab: $250 limit, $80 used

Steps 1–2 — Admin

their manual →
  1. 1
    Open the settlement dashboard. It shows one row per user per period, and tells you the last period the scheduler settled.
    Expected result Recent settlements with status paid, failed or waived, and a default period suggestion of last month.
    Watch out for The last-settled marker is how the monthly tick knows not to repeat itself. Do not edit that setting by hand — you will make settlement skip or repeat a month.
  2. 2
    Set a tab limit for a VIP before the month they will use it, or freeze an existing tab.
    /api/admin/credit/users/{user_id}/tab PUT ledger
    Expected result The tab row with its new limit or status.
    Watch out for VIP status alone does not create a tab limit. Granting vip_member gives somebody Zone B and a tab that may have no headroom at all until you set one.

Steps 3 — VIP Member

their manual →

It is their tab: the sweep takes their prepaid credit first and their card second.

  1. 3
    As the VIP, look at what settlement is going to act on: your prepaid balance — the only pot the run sweeps — your earned balance, and your tab used against its limit.
    /my/credit ledger
    Expected result Three separate numbers plus a combined purchasing power figure.
    Watch out for Purchasing power adds tab headroom to real money. It is what you can spend, not what you have. Settlement collects the difference — out of prepaid credit first and then the card. It never touches earned or promo balance, so a VIP can be charged while the platform still owes them money.

Steps 4–9 — Admin

their manual →
  1. 4
    Run settlement for a period. Leave the period out and it settles the previous month.
    Expected result Per-user results: prepaid swept, card charged for the remainder, invoice emailed. Counts of settled, failed and skipped.
    Watch out for Idempotent per user and period. Pressing it twice does not charge twice — the unique constraint is the guard, and it holds under concurrency.
  2. 5
    Read the results and separate the failures from the successes before you touch anything.
    Expected result Failed rows for declined cards, alongside paid rows.
    Watch out for A failed settlement also froze that person's tab. Somebody at the door or the bar will be told they cannot charge, and they will not know why unless you tell them.
  3. 6
    Retry a failed settlement once the member has fixed their card.
    /api/admin/tab-settlements/{settlement_id}/retry POST ledger
    Expected result A fresh charge attempt against the same period, and the tab unfrozen on success.
    Watch out for 409 SETTLEMENT_NOT_RETRYABLE if it is not in a retryable state. Retry is for failed rows; it is not a way to re-run a month that already settled.
  4. 7
    Waive a settlement when the venue has decided to absorb it — a goodwill gesture, a disputed charge, a member who has left.
    /api/admin/tab-settlements/{settlement_id}/waive POST ledger
    Expected result The settlement row moves to waived. CORRECTED 2026-08-30: that is ALL it does — no ledger transaction is posted, the tab usage is untouched, and next month’s run charges the card for it again. Plan 675.
    Watch out for The button’s own confirmation says the debt is written off permanently, and that is not true today, so do not repeat it to a member. Nothing records who waived it either — there is no such column. What is still right is the principle: forgiving a balance is a named act, and adjusting the ledger to make the number look nicer is not.
  5. 8
    In training, settle a single user for a single period so you can watch one person's mechanics end to end.
    Expected result The same settlement logic scoped to one user.
    Watch out for Admin-only debug. The production path is the monthly tick; this is a microscope, not a substitute.
  6. 9
    Trace one settlement into the ledger: the prepaid sweep, the card charge and its separate processing fee.
    Expected result Balanced transactions of kind tab settlement, referencing the settlement.
    Watch out for Try to edit one and the database itself refuses. The append-only trigger is not a UI convention — it fires from the data suite and from raw SQL exactly the same way.

Hosting & Contracts

Third-party host intake, review, and the dual e-signed venue agreement.

Approve a host application

Review the queue, chase what is missing, and approve — which drafts the contract.

Owned by Venue Manager · 9 steps · about 15 minutes

Why this exists

The intake queue is where the venue decides what its calendar looks like, so the design goal is that a manager can make that decision from one screen without trusting anyone's memory.

Two ideas are doing the work. The first is the viability scorecard: every application is scored on the same axes so that comparing two nights is comparing like with like. The second is that asking for more information is a first-class state, not an email. Requesting info sends the promoter a tokenised edit link and moves the application into a state that says, on the queue, that the ball is in their court. Nothing gets silently stuck in someone's inbox.

Approval is a bigger action than it looks: in the same transaction it drafts the contract, with riders derived from the flags the promoter set and the headcount, price and date snapshotted from what they submitted. Approving is therefore a statement that the numbers on the application are the numbers you are willing to put in a contract.

Before you start

  • A venue manager or admin session — the queue is gated to reviewers.
  • At least one application in the queue (the seed ships a viable one).

Practise with

PersonaEmailPasswordNote
venue_managermanager@club.test manager123the reviewer persona for this workflow
host_promoterhost@club.test host123the applicant; use them to answer a request for more information
adminadmin@club.test admin123picks the drafted contract up from here

Steps 1–3 — Venue Manager

their manual →
  1. 1
    Open the intake queue and filter to what needs a decision.
    /admin/intake intake
    Expected result Applications with status and viability score.
    Watch out for This page is gated to venue manager and admin. Door staff get a 403 — the calendar is not a door decision.
  2. 2
    Open one application and read it properly: the date against your calendar, the headcount against the room, the rider flags, the attachments and the notes.
    /admin/intake/{app_id} intake
    Expected result The full scorecard, status history and the rider summary a contract would be built from.
  3. 3
    If something is missing, request more information rather than declining. Say exactly what you need.
    /api/intake/applications/{app_id}/request-info POST intake
    Expected result The application moves to info_requested and the promoter is emailed a tokenised edit link.
    Watch out for Do not chase this by personal email. The token is what lets them edit without an account, and the status is what keeps the queue honest.

Steps 4–5 — Host / Promoter

their manual →

Answers a request for more information through their tokenised edit link.

  1. 4
    As the promoter, open the edit link you were sent and see your own application.
    /apply/edit/{token} intake
    Expected result Your application, editable, with no login required.
    Watch out for The token is the credential and it is scoped to this application. Treat it like a password.
  2. 5
    Answer the question and resubmit.
    /api/intake/public/applications/{app_id} PUT intake
    Expected result The application returns to the queue with your update recorded in its history.

Steps 6–7 — Venue Manager

their manual →
  1. 6
    Re-open the application and check the updated answer and the recomputed score.
    /admin/intake/{app_id} intake
    Expected result The new values, with the change visible in the status history.
  2. 7
    Approve.
    /api/intake/applications/{app_id}/approve POST intake
    Expected result Status approved, and a draft contract created in the same transaction with the riders the flags implied.
    Watch out for You can approve, but you cannot lock or counter-sign the contract that approval just drafted. That is an admin's job, and it is the deliberate split between running the calendar and binding the venue.

Steps 8–9 — Admin

their manual →

Takes the contract that approval drafted and turns it into a signed agreement.

  1. 8
    As an admin, pick the new draft up from the contracts list.
    /admin/contracts contracts
    Expected result The drafted contract against that application.
  2. 9
    Open it and continue into the contract workflow: fill the variables, lock, send for signature.
    /admin/contracts/{contract_id} contracts
    Expected result The editable draft with its auto-injected riders.

Author a rider so a host can ask for it

Turn a host's 'we do something you don't list' into an approved clause — and a checkbox that did not exist before.

Owned by Venue Manager · 22 steps · about 22 minutes

Why this exists

The application form is now governed by the contract library. Every feature a host can tick — live music, alcohol, late night, certified security staffing, aerial performance, a resale royalty — is a checkbox only because an approved rider stands behind it. Untick that relationship and the box does not exist. There is no list of features maintained anywhere else.

The reason is a single sentence: the form may only say things the venue has already agreed, in writing, to say. A checkbox is a promise. If a host can tick something the venue has no clause for, the venue has agreed to something nobody drafted, and it finds out at signature time.

So what happens when a host needs something new?

They write free text. That is the whole point of the free-text box, and it is deliberately a message, not a request for a checkbox. Free text can never become contract language by itself, however carefully it is worded, because the person who writes the clause must be the venue. What the host's words do is start a conversation and land a row in a queue.

A manager reads the message and authors a rider from it. The host's text sits beside the editor, read-only, and is never pre-filled into the body — if it were, the host would have authored contract language after all, just through a longer pipeline. The note is context. The manager writes the clause.

Two scopes, and why one-off is the default

A rider is either library — part of the standing catalogue every host sees — or one-off: written for one application, at one venue, and shown to nobody else. Answering a host in a hurry should never quietly extend what the whole world can ask for, so the answer-a-host path creates a one-off. A one-off that turns out to be generally useful can be promoted to the library later, as a separate, deliberate decision.

Nothing is selectable until a version is approved

Creating a rider creates its catalogue row and its first draft version. It is not selectable. The text goes draft → in review → approved, and only then can the catalogue row be made selectable — attempting it earlier is refused because there is no approved version to select.

Self-approval succeeds by default on this install. That is a deliberate trade-off, not an oversight: a two-person rule on a small team means riders never ship. The preventive control was replaced by three detective ones — a self-approved flag stored on the version and rendered as a chip wherever riders are listed, an append-only trail row pinning the exact bytes that were approved, and a notice to the chat the team already watches. One setting turns the two-person rule back on.

When a version is approved, every request linked to it flips to approved and the host who asked is emailed a working link back into their application. Without that last step the host never learns the thing they asked for now exists, and the whole loop is theatre.

Before you start

  • A venue_manager or admin session. Rider authoring and approval are staff surfaces.
  • An application carrying a host's free-text note, or an open rider request. The debug seed can make some.
  • The seeded catalogue: live music, alcohol, late night, certified security staffing, aerial performance, and a resale royalty with a basis-points parameter.

Practise with

PersonaEmailPasswordNote
venue_managermanager@club.test manager123authors riders, approves versions and triages the request queue
host_promoterhost@club.test host123owns the seeded applications; the person on the other end of the request

Steps 1–6 — Host / Promoter

their manual →

Ticks what the library already covers, and writes the free text that starts a new rider.

  1. 1
    Open the application form and find the features section. Each checkbox has a label and a line of help text — read the help, because it is what the clause commits you to, written for a host rather than for a lawyer.
    /host/apply intake
    Expected result Grouped checkboxes: Production & licensing, Commercial terms.
    Watch out for Ticking a box is not a preference. It attaches a clause to the contract you will be asked to e-sign. A careless tick is a wrong clause in a binding document.
  2. 2
    Understand where those boxes come from: the form draws exactly what this endpoint returns for your application and its venue.
    Expected result Groups of riders with their host-facing labels, help text, parameters and relationships.
    Watch out for Called without an application id it returns library riders only. That narrowness is on purpose — nobody can discover another host's one-off clause by probing this endpoint.
  3. 3
    Tick Aerial performance and watch Certified security staffing tick itself, with a line explaining which choice pulled it in.
    /host/apply contracts
    Expected result The implied box selected and annotated, not silently checked.
    Watch out for Some riders imply others and some exclude each other. Implication is enforced on the server too — the client is a convenience, and a payload that ticks a rider you may not select is refused as not selectable.
  4. 4
    A rider that needs a number asks for one. The resale royalty carries a basis-points parameter with a range, and it is required if you tick the box.
    /host/apply contracts
    Expected result A parameter row appearing under the checkbox you ticked.
    Watch out for Basis points, not percent: 250 is 2.5%. The bound is checked server-side, so a number outside the range comes back as a validation failure naming that rider.
  5. 5
    For something the list does not cover, open My event needs something that isn't listed and answer three questions: what is it called, what happens at your event that is not covered, and what would the venue need to agree to. Optionally name the closest existing rider.
    Expected result A 201 and a card in the venue's staff channel with your request on it.
    Watch out for This is a message, not a checkbox request that gets granted. Nobody converts your words into contract language — a manager reads them and writes the clause. Requests are rate limited per email per day.
  6. 6
    Submit the application with whatever you could legitimately tick. Your request rides along with it.
    Expected result A 201, and the application flagged as having an open rider request.
    Watch out for Do not wait for the rider before applying. The application is the thing that gets a date held; the clause can arrive afterwards and you will be emailed a link to add it.

Steps 7–17 — Venue Manager

their manual →
  1. 7
    Open the rider-request queue. Each row carries the host, the application it came from, what they say happens, what they think the venue would need to agree to, and how long it has been sitting there.
    Expected result Requests in new, triaged, authoring, approved or declined.
    Watch out for The age column is measured against a service-level setting, defaulting to 72 hours. A request that ages out is a host who has already booked somewhere else.
  2. 8
    Claim a request. Triaging marks it as picked up and optionally assigns it to somebody by name.
    /api/intake/rider-requests/{request_id}/triage POST intake
    Expected result Status triaged, with an event appended to the request's own history.
    Watch out for Triage is also available from the staff channel without opening the app. Either way the transition is recorded with its source, so 'who picked this up' always has an answer.
  3. 9
    Open the composer from the request. The host's words render in a read-only panel beside the editor, together with the application, the event name, the date and the venue.
    Expected result A blank clause editor next to the host's context.
    Watch out for The body starts empty and stays empty until you type. That is the control: the host's text is reference material, never a draft. Copying it in by hand defeats the entire design.
  4. 10
    Write the clause for a host's free-text note on the application — the 'anything else we should know' answer. You supply three things: the host label for the checkbox, the help text a host reads before ticking it, and the legal body.
    /api/intake/applications/{app_id}/author-rider POST intake
    Expected result A one-off rider scoped to that application and its venue, plus a note on the application recording that you authored it.
    Watch out for All three fields are required and the refusals say why — help text in particular, because a checkbox with no explanation is how a host agrees to something they did not read. The call is idempotent per application and label, so a double click does not mint two clauses.
  5. 11
    From the queue instead, author against the request itself. This creates the rider (or links an existing one) and moves the request to authoring, joining the two objects — the request page then shows live rider state and the rider page shows who asked for it and why.
    /api/intake/rider-requests/{request_id}/author POST intake
    Expected result Status authoring, with the rider key attached to the request.
    Watch out for If the request came from an application, the rider is created as a one-off scoped to that application and venue. Choose deliberately: a library rider written in a hurry is a clause every future host can tick.
  6. 12
    Open the rider. You get its versions, a word-level diff between them, its append-only trail, and where it is currently in use.
    /admin/contract-riders/{rider_key} contracts
    Expected result Version 1 in draft, not selectable, not on any contract.
    Watch out for Only a draft is editable. Once a version is in review or approved its text is frozen — corrections are a new version, never a quiet rewrite of the bytes somebody already approved.
  7. 13
    Submit the draft for review when the wording is right.
    /api/contracts/rider-versions/{template_id}/submit POST contracts
    Expected result Status in review, and a trail row recording who submitted it.
    Watch out for Submitting freezes the text. If you spot a typo now, reject it and fork a fresh draft rather than looking for an edit button that is deliberately not there.
  8. 14
    Approve the version. This retires whatever version was previously live, makes this the active text, and closes every request linked to the rider — emailing each requester a link back into their application.
    /api/contracts/rider-versions/{template_id}/approve POST contracts
    Expected result Status approved, is-active set, linked requests flipped to approved.
    Watch out for Approving your own draft succeeds on this install and is permanently marked as self-approved: a chip in every listing, a reason on the trail row, and a notice in the staff channel. If your install turns the two-person rule on instead, the same call is refused and says so.
  9. 15
    Reject a version whose wording is wrong, with a reason. The reason is required.
    /api/contracts/rider-versions/{template_id}/reject POST contracts
    Expected result Status rejected, the reason on the trail.
    Watch out for Rejecting is not deleting. The rejected text stays readable in the version history, which is what makes 'why did we not go with that wording' answerable a year later.
  10. 16
    Make the rider selectable, and set how it presents: group, label, help text, sort order.
    /api/contracts/riders/{rider_key} PATCH contracts
    Expected result The rider appears in the form for whoever its scope covers.
    Watch out for Selectable without an approved version is refused. That ordering is the product decision this whole workflow is built on — the checkbox cannot exist before the clause does.
  11. 17
    Decline a request the venue will never say yes to — boxing, pyrotechnics, whatever your licence does not cover — with a reason. The host is emailed.
    /api/intake/rider-requests/{request_id}/decline POST intake
    Expected result Status declined and an email carrying your reason.
    Watch out for Declining is a legitimate answer and it is deliberately as easy as approving. A queue where 'no' is harder than 'yes' becomes a queue where requests rot instead.

Steps 18 — Host / Promoter

their manual →

Ticks what the library already covers, and writes the free text that starts a new rider.

  1. 18
    Open the link from the 'your request is now available' email. Your application reopens with the new checkbox present.
    /apply/edit/{token} intake
    Expected result The clause you asked for, now tickable, with the venue's help text.
    Watch out for Read the help text before you tick it. The venue wrote the clause, not you — what it commits you to may be narrower, or broader, than what you described.

Steps 19–22 — Venue Manager

their manual →
  1. 19
    When the same one-off has been written three times, promote it to the library so every host can tick it.
    /api/contracts/riders/{rider_key}/promote POST contracts
    Expected result The rider leaves the written-for-you group and joins the standing catalogue.
    Watch out for Promotion is the deliberate widening this design keeps separate from answering one host. Do it on purpose, not as a shortcut while you are already in a hurry.
  2. 20
    Read the library periodically: live riders, retired ones, and which are one-offs. Filter by scope.
    Expected result The catalogue with self-approved and one-off badges visible in the list.
    Watch out for Those badges are the control. If a reviewer has to open each rider to find out whether anybody else read it, the control does not exist — which is exactly why they are rendered wherever riders are listed.
  3. 21
    Check recent rider activity — approvals inside the review window, with the self-approved ones surfaced.
    Expected result A short list you can actually read at a Monday meeting.
    Watch out for Watch the self-approved rate rather than individual rows. A single self-approval is a small team working; every rider self-approved means the second pair of eyes has quietly stopped existing.
  4. 22
    Back on the application, read the accepted riders block: what the host actually ticked, with one-off and self-approved flagged inline.
    /admin/intake/{app_id} intake
    Expected result The riders that will be injected into this booking's contract.
    Watch out for This is the last screen before approval where a wrong clause is cheap to remove. After approval the contract is drafted and only an admin can change it.

Apply to host an event

Pitch your night through the public application form — no account, no login.

Owned by Host / Promoter · 11 steps · about 20 minutes

Why this exists

The application form is the front door of the entire platform, and it is deliberately public. You do not register first, you do not get an account first, and no member of staff types your event into the system on your behalf. There is no "new event" form anywhere that a manager can fill in for you.

That is an accountability decision, not a convenience one. Every event on this platform traces back to one application, submitted by one named person, carrying the numbers they committed to: the date, the headcount range, the tier prices, the bar minimum. Staff score it, approve it, price it and publish it — but the numbers on the paperwork are yours, and the system keeps a snapshot of what you said.

Two parts of the form do more than they look like they do. The four flags in section 3 (live music, alcohol, late night, resale royalty) are not survey questions: each one automatically injects a rider clause into the contract you will later be asked to e-sign, so a careless tick becomes a wrong clause in a binding document. And the tier estimates in section 4 are the seed of your real ticket tiers — when the application is converted, those rows become the event's tiers, in order, with the first one opening on sale and later ones cascading behind it.

The viability score you see updating as you type is a scorecard, not a verdict. It weights your headcount against the room and your projected revenue against a floor, and it never blocks anything — a human still decides.

Before you start

  • Nothing. The form needs no account and no login.
  • A real proposed date at least intake.min_lead_days out (seeded: 7 days).
  • Your slot times in UTC: setup start, doors, main start, main end, teardown end.

Practise with

PersonaEmailPasswordNote
host_promoterhost@club.test host123the seeded promoter; already has three demo applications on file
venue_managermanager@club.test manager123reads the queue your submission lands in
adminadmin@club.test admin123owns the intake thresholds and can read the outbound mail log

Steps 1–7 — Host / Promoter

their manual →
  1. 1
    Open the public application form. Log out first if you want to see what a brand-new promoter sees.
    /host/apply intake
    Expected result Five sections: organizer profile, event concept, equipment and production, financial estimates, attachments. No login wall.
    Watch out for /apply redirects here, so both links are legitimate. If you are already logged in and have applied before, the form quietly opens a pre-verified autofill session and paints your old answers red — that is a separate workflow, and the red is not decoration.
  2. 2
    Fill sections 1 and 2: your name, organization, email, phone, socials and track record, then the event name, description, genre, type, proposed date, an optional alternate date, and the five slot times.
    Expected result The form accepts free text as you type; nothing is validated until you submit.
    Watch out for Your email is your identity on this platform, matched case-insensitively. Autofill next time, the approval email, the contract signing link and — if you are approved — your login all key off it. Slot times are UTC strings like 2026-01-01T20:00:00Z, and the proposed date must be at least intake.min_lead_days (seeded 7) days out or you get a per-field validation error.
  3. 3
    Work section 3 carefully: equipment needs, then the four flags — live music, alcohol served, late night, and request resale royalty. If you tick the royalty flag, set the basis points (1 to 3000).
    Expected result The royalty field only appears once the royalty flag is ticked. A hint appears if your main set ends between midnight and 6am local time.
    Watch out for Each flag injects a rider into the contract. The late-night rider has a fallback: even if you leave the flag unticked, the contract engine derives it when your stored main end time is before 06:00. Do not assume an unticked box keeps a clause out.
  4. 4
    Fill section 4: headcount minimum and maximum, the bar minimum in dollars, and between one and six ticket tiers with a name, a price and a quantity each.
    Expected result The summary bar at the bottom updates live: gross door projection, average ticket price, headcount against capacity, and a viability preview out of 100 with a band (viable / marginal / not viable).
    Watch out for Your headcount maximum is scored against intake.venue_capacity (seeded 450). Ask for more than the room holds and that half of the score goes to zero. These tier rows are not a wish list — they become the event's real tiers on conversion, so name and price them as you actually mean to sell them.
  5. 5
    Attach anything that helps: pitch deck, insurance certificate, rider. Pick the kind next to each file.
    Expected result Up to intake.max_files attachments (seeded 10), each up to intake.max_file_mb (seeded 10) MB, in pdf, png, jpg, gif, office or txt format.
    Watch out for The browser warns you about size and type, but the server is the authority: too many files is a 422, a bad extension is a 415, an oversized file is a 413. Nothing is stored until the whole submission validates.
  6. 6
    Press Submit application.
    Expected result 201 with your application id, status new_submission and your viability score. You are redirected to the thank-you page, and a confirmation email plus a Telegram summary card for the venue are recorded.
    Watch out for Three refusals bite here. 422 validation_failed comes back with a per-field message list and the form prints them under the offending fields. 409 duplicate_application means the same email, event name and date were submitted in the last 24 hours. 429 rate_limited means more than 3 submissions from your email or 10 from your IP in 24 hours. An Idempotency-Key header replays the original 201 for 24 hours instead of creating a second application.
  7. 7
    Land on the thank-you page and write down the reference shown there.
    /apply/thanks/{app_id} intake
    Expected result A short reference (the first 8 characters of your application id) and a plain-English note about what happens next.
    Watch out for Built behaviour worth knowing: this page is a receipt, not a portal. It shows no live status and no edit link — the plan imagined a status window here and that is not what was built. Until staff email you, the reference is all you have; quote it if you need to chase.

Steps 8–9 — Venue Manager

their manual →

Yours is the queue they work from — they are the first human to read it.

  1. 8
    As the reviewer, open the intake pipeline board and find the new submission.
    /admin/intake intake
    Expected result A six-column board with viability scores, plus q and band filters.
    Watch out for Gated to venue manager and admin. Door staff get a 403 — the calendar is not a door decision.
  2. 9
    Open the application and read the whole scorecard: the date against the calendar, the headcount against the room, the rider flags, the tier table, the attachments and the timeline.
    /admin/intake/{app_id} intake
    Expected result The full application with its status history, notes, rider summary and an action bar.
    Watch out for The score is a scorecard, not a decision. It knows the numbers; it does not know that this promoter's last night was a disaster.

Steps 10–11 — Admin

their manual →

Owns the capacity and revenue thresholds your form is validated and scored against.

  1. 10
    As an admin, confirm the submission actually generated its notifications: the intake_received email to the promoter and the Telegram summary card with its inline accept and decline buttons.
    Expected result Recorded outbound calls correlated to the application id.
    Watch out for Nothing here is a real email or a real Telegram message — every external service runs behind a mock adapter that records the call. That is why you can rehearse this whole workflow offline.
  2. 11
    Tune the thresholds the form validates and scores against: venue capacity, minimum revenue floor, minimum lead days, file limits.
    Expected result The updated settings; keys are accepted with or without the intake. prefix.
    Watch out for Admin only. A venue manager who runs the queue every day still gets a 403 here — changing the scoring rules is a different authority from working the pipeline.

Fast-track an application from the Telegram alert

Act on the summary card in chat, or open the deep link that claims the review for you.

Owned by Venue Manager · 12 steps · about 14 minutes

Why this exists

When a host application lands, the platform posts a summary card to the venue's Telegram chat with the headline facts and the viability score, plus inline buttons. The idea is that a decision which is obviously yes or obviously no should not require anybody to be at a laptop — a promoter waiting three days for a reply is a promoter taking their night somewhere else.

The card carries three tokenised actions and it is worth understanding what makes them safe. The accept and decline buttons are single-use tokens with a seventy-two hour life, consumed with a rowcount-guarded update so a double tap cannot double-act. The review deep link is multi-use and opens the application in the browser — and, if the application is still a new submission, it auto-claims it into under review, recording the source as telegram. That claim is the point: the queue always knows whether a human has picked something up, without anyone having to remember to press Start Review.

Fast-tracking from chat is exactly as powerful as approving from the dashboard, which means the same consequence follows: approval, in the same transaction, drafts the venue contract with riders derived from the flags the promoter set. Tapping accept in a group chat is signing up to the numbers on that application. Use the card when the answer is genuinely obvious and the deep link when it is not.

One safeguard to know: any transition to approved or rejected invalidates every unused token on that application — the accept and decline buttons, and any host edit link. A card sitting in chat from yesterday cannot re-decide something that has already been decided.

Before you start

  • A venue manager or admin session for the browser half.
  • An application in new_submission. The seed ships Warehouse Frequencies, scored 88.
  • Telegram is a mock here — outbound cards are recorded rather than sent.

Practise with

PersonaEmailPasswordNote
venue_managermanager@club.test manager123shift lead: implies door_staff, plus intake, sales, guest list, marketing
host_promoterhost@club.test host123owns the two seeded applications, Warehouse Frequencies and Analog Sunrise
adminadmin@club.test admin123the only account that can touch money, identity and contracts

Steps 1–8 — Venue Manager

their manual →
  1. 1
    Read the card in the chat before touching a button. It carries the event name, the proposed date, the headcount and the viability score and band — enough to tell an obvious yes from an obvious no.
    Expected result A summary card with inline accept, decline and review buttons.
    Watch out for The score is advice, never a gate. A 40-point application on a dead Tuesday can be a better decision than an 80 on your busiest Saturday — the scorecard does not know your calendar.
  2. 2
    For anything less than obvious, tap the review link. It opens the application detail page and, if the application was still a new submission, silently claims it into under review with the source recorded as telegram.
    /admin/intake/{app_id} intake
    Expected result The full application with the status chip now reading under review.
    Watch out for That auto-claim is real work being recorded against you. Opening the link out of curiosity marks the application as picked up — if you are not going to review it, do not tap it.
  3. 3
    Read the scorecard panel properly: the score out of a hundred, its band, and the split between capacity points and revenue points, each out of fifty. Then read the flags — over capacity, low utilisation, below revenue floor, tier quantity mismatch.
    /admin/intake/{app_id} intake
    Expected result Two point bars, a band of viable, marginal or not viable, and any flags as red chips.
    Watch out for Bands are fixed: seventy and above is viable, forty to sixty-nine marginal, below forty not viable. The inputs line shows the venue capacity and revenue floor used — those are settings, and only an admin can change them.
  4. 4
    If the promoter has updated their numbers since submission, press Recompute so you are scoring the current figures.
    /api/intake/applications/{app_id}/recompute-viability POST intake
    Expected result A fresh score and a new computed-at timestamp.
    Watch out for The score never blocks a transition. Recomputing is for your judgement, not for the machine's permission.
  5. 5
    To decline, press Decline and give a reason. The reason is required and it goes to the promoter in the decline email.
    /api/intake/applications/{app_id}/decline POST intake
    Expected result Status rejected, the promoter emailed, the chat card edited, and every unused token invalidated.
    Watch out for A missing reason is a 422 decline_reason_required. Write something a stranger could act on — 'date already held' or 'headcount exceeds capacity' — because they will read it.
  6. 6
    To approve, press Approve. In the same transaction this drafts the venue contract with riders derived from the promoter's flags.
    /api/intake/applications/{app_id}/approve POST intake
    Expected result Status approved and a draft contract waiting in the contracts list.
    Watch out for You approve; you cannot lock or countersign what approval drafted. That split is deliberate — running the calendar and binding the venue are different jobs.
  7. 7
    Leave a note whenever the decision needed context — a phone call, a past night, a condition you agreed verbally.
    /api/intake/applications/{app_id}/notes POST intake
    Expected result The note in the activity timeline against your name.
    Watch out for Notes are the only place the reasoning survives. The status history records what happened, never why.
  8. 8
    Know the two things you will be refused. You cannot reopen a rejected application, and you cannot reject one that has already been approved or moved to contract deposit. Both are admin-only and both come back as a 403.
    Expected result A clean escalation rather than a confusing error.
    Watch out for These are late-stage reversals with contracts and possibly deposits behind them. The 403 is protecting a decision somebody else has already relied on.

Steps 9 — Host / Promoter

their manual →

Is the applicant on the other end of whichever button gets tapped.

  1. 9
    As the promoter, if you were asked for more information instead, open the edit link you were emailed and answer it.
    /apply/edit/{token} intake
    Expected result Your application, editable, with no login.
    Watch out for That link dies the moment the application is approved or rejected. If it shows a friendly expired page, the decision has already been made.

Steps 10–12 — Admin

their manual →

Owns the debug tools that replay a card button and re-send a card, and the late-stage transitions a manager is refused.

  1. 10
    As an admin, replay a recorded card button when you are testing the fast-track path or a manager reports a button that did nothing.
    Expected result The accept or decline runs through the real webhook path, exactly as the tap would have.
    Watch out for The plaintext tokens exist only inside the recorded outbound payloads — that is what makes replay possible, and why the token table stores hashes only.
  2. 11
    Re-send a summary card when the original was lost or the chat was wrong.
    Expected result A fresh card with fresh tokens.
    Watch out for Re-sending mints new tokens. The old card's buttons are now the stale ones — tell the chat, or somebody taps yesterday's card.
  3. 12
    When somebody swears no card arrived, check the recorded outbound traffic. Telegram and email are both mocks here and every call is recorded.
    Expected result The outbound call rows correlated to the application id.
    Watch out for A recorded call proves the platform tried. Whether a message actually lands depends on the install: the Telegram adapter defaults to a mock that records without sending, but this deployment runs it LIVE against a real staff channel. Check /admin/telegram for the mode before deciding a delivery failure is impossible — see the 'run-the-staff-channel-on-live-telegram' workflow.

Build your run of show backwards from when the night ends

Answer when it ends and how long each part takes; the platform derives setup, doors, showtime and teardown for you — and stores them in UTC.

Owned by Host / Promoter · 17 steps · about 16 minutes

Why this exists

The application form used to ask for five timestamps: setup start, doors, main start, main end, teardown end. Hosts got them wrong constantly, and always in the same way — not because they did not know their own night, but because nobody plans a show forwards. You know the curfew. You know how long your headliner plays. Everything else is arithmetic somebody was being asked to do in their head, at midnight, on a phone.

So the builder asks the questions you can actually answer — when does it end, how long is the headliner, how long are the openers, how long is clean-up, how long is setup, how early are doors — and walks the schedule backwards from the end. Five timestamps still get stored. You just stop being the one computing them.

The midnight rule — read this twice

An event day is not a calendar day. The platform defines the day for a date as local 6am to 6am, so an end time before six in the morning belongs to the next calendar date. Saying "Saturday the 14th, ends at midnight" therefore means Sunday the 15th at 00:00 — which is what you meant — and the backwards walk lands setup on Saturday evening.

Concretely, with a two-hour headliner, 45 minutes of openers, doors 30 minutes early, two hours of setup and an hour of clean-up:

  • Setup starts Sat 14 Mar, 6:45 PM
  • Doors Sat 14 Mar, 8:45 PM
  • First act Sat 14 Mar, 9:15 PM
  • Music ends Sun 15 Mar, 12:00 AM
  • Teardown ends Sun 15 Mar, 1:00 AM

One night, two calendar dates, and that is correct. The preview labels every row with its own day for exactly this reason, and the card carries an ends after midnight badge so nobody has to notice it on their own.

Local in, UTC out

Every time you see is the venue's wall clock — not your phone's. A host applying from New York for a Los Angeles room types Los Angeles times, and the browser is never asked what time it thinks it is. What gets stored is UTC. In the worked example above, all five stored instants fall on 2026-03-15 in UTC, including the setup that happens on Saturday evening locally. That is not a bug and you will see it on staff screens: the stored date and the local date are allowed to disagree.

The derivation runs on the server, once. The page has no timezone logic of its own — it collects your answers, posts them to a preview endpoint, and draws whatever comes back. A second implementation in the browser would drift from the first, and it would drift precisely on daylight-saving weekends and midnight crossings, which is where being wrong actually costs something.

Pinning: the escape hatch, not the default

Every derived row has an edit button. Use it and that row is pinned — it stops being computed, keeps the exact time you typed, and is marked with a pin in the run of show. The other rows keep deriving around it. A pin is reversible from the same button, which then reads reset.

Pins exist because reality has load-ins that start at 2pm for a 10pm show, and no duration answer expresses that. They are not the way to use this feature. A pinned row that puts the schedule out of order is refused with a message on that row, and the offer to undo your pins rides on the error itself.

Before you start

  • No account. The application form is public.
  • A date for your event, and honest answers about how long each part takes.
  • The venue's timezone is shown on the form. Everything you type is in that clock.

Practise with

PersonaEmailPasswordNote
host_promoterhost@club.test host123owns the seeded applications, including one entered through the guided builder
venue_managermanager@club.test manager123reads the stored run of show on the application review page
adminadmin@club.test admin123owns the derivation trace and the timezone health check

Steps 1–12 — Host / Promoter

their manual →
  1. 1
    Open the application form and scroll to the run-of-show section. Read its one-line lede: it names the timezone every answer on this form is in.
    /host/apply intake
    Expected result Six questions and an empty run-of-show card that says it will fill in once you answer.
    Watch out for That timezone is the venue's, not your device's. If you are applying from another city, do not mentally convert — type the times the night actually happens at.
  2. 2
    Set the event date first. The whole derivation hangs off it, and until it is set the preview stays empty however many other answers you give.
    /host/apply intake
    Expected result The date field accepted; the card still empty.
    Watch out for This is the date the night starts in the ordinary sense — the Saturday, not the Sunday you go home on.
  3. 3
    Answer Event ends at. There are three presets — Midnight, 10pm, 8pm — plus Other… for an exact time. This is the anchor: every other row is computed backwards or forwards from it.
    /host/apply intake
    Expected result The question collapses to a summary chip showing your answer.
    Watch out for Choosing Midnight on a Saturday means Sunday 00:00. You are not saying the night ends 24 hours ago — the day-rollover rule handles it, and the preview will show you the Sunday label on that row.
  4. 4
    Answer the four duration questions: Headliner set length and Clean-up time are required; Opener time (all openers together) and Doors before first act are optional. Setup time is required too.
    /host/apply intake
    Expected result Each answered question collapses; the next unanswered one is highlighted.
    Watch out for Openers is the total for all of them, not each. And setup is measured back from doors, not from showtime — two hours of setup with doors 30 minutes early puts your load-in two and a half hours before the first act.
  5. 5
    Watch the run-of-show card fill in as you answer. It posts your answers to the public preview endpoint and renders the five rows it gets back, each with its local time, its day label and its UTC instant.
    Expected result Five rows, the gaps between them, a total duration, and any badges.
    Watch out for The preview writes nothing and needs no application — it is a pure calculator, and it is rate limited per IP. If it is unavailable the form still submits: the server re-derives from the same answers, so a missing preview costs you the picture, never the schedule.
  6. 6
    Read the badges under the card. Ends after midnight tells you the night crosses a date. Setup before the event date tells you your load-in starts on the previous day. A clocks-change weekend gets its own badge, and each row starts showing its timezone abbreviation.
    Expected result Badges that describe your schedule in words, not just colours.
    Watch out for A midnight-or-later finish also raises a late-night suggestion. That is advice about a contract clause, not a validation error — nothing here blocks you.
  7. 7
    If the clocks change during your night, the form asks one extra question: that wall-clock time happens twice, which one did you mean? Pick the earlier or the later.
    Expected result The fold question appears only when it is genuinely ambiguous.
    Watch out for A time that does not exist at all — the hour that is skipped when clocks go forward — is refused with a suggestion of the next valid time. There is no silently-rounded answer here, because 2:30am on that Sunday is not a moment.
  8. 8
    Pin a row you cannot express as a duration. Press edit on it, type the exact local time, and save. The row gains a pin marker and its button becomes reset.
    /host/apply intake
    Expected result That row keeps your time; the others keep deriving around it.
    Watch out for A pin that puts the schedule out of order — teardown before the music ends — is refused on that row, and the last good run of show stays on screen dimmed rather than vanishing. The message carries an undo for all your pins, which is the fastest way out.
  9. 9
    Finish the rest of the form and submit. The server re-derives your schedule from the same answers and stores five UTC instants.
    Expected result A 201 and the thank-you page.
    Watch out for Two submit modes exist and they are chosen by what you send. Answering the guided questions uses the derivation; posting five explicit timestamps uses the old direct path. Sending both is a 422 telling you they conflict — it will not guess which one you meant.
  10. 10
    Read the run of show back on the thank-you page. This is the stored schedule, projected into the venue's clock — the same rows staff will see.
    /apply/thanks/{app_id} intake
    Expected result Your five moments, with their day labels.
    Watch out for Check the day labels one last time here. A schedule that reads 'ends Sunday' when you meant Saturday afternoon is trivial to fix now and awkward to fix after a manager has already read it.
  11. 11
    If staff ask for more information, the emailed edit link reopens the form with your schedule loaded back into the guided questions.
    /apply/edit/{token} intake
    Expected result The builder, pre-answered, with your existing times reproduced exactly.
    Watch out for One thing does not survive the round trip: the split between headliner and openers. The reverse mapping resolves it as the whole programme being the headliner with no openers, because that reproduces your five stored instants byte for byte. Re-split it if you care about the distinction.
  12. 12
    Save the edit. The schedule is re-derived and re-stored from whatever the questions now say.
    /api/intake/public/applications/{app_id} PUT intake
    Expected result The application returns to under review with an updated card in the staff channel.
    Watch out for Timeline answers never prefill from a previous application, and they never count toward the red confirm gate on returning-host autofill. Every night gets its own schedule, deliberately.

Steps 13–14 — Venue Manager

their manual →

Reviews the stored schedule on the application, in the venue's clock, before approving it.

  1. 13
    Open the application and read the run of show in the timeline panel. It is rendered in the venue's timezone, with the same day labels the host saw.
    /admin/intake/{app_id} intake
    Expected result Five rows plus the badges, matching the host's screen.
    Watch out for The date on the application and the date on the stored setup time are allowed to differ by a day, in both directions. Before you query it with a host, check whether you are reading a UTC instant next to a local clock — that difference is the feature working, not a typo.
  2. 14
    Scan the board for midnight-crossing nights. The seed deliberately ships one — Midnight Cassette — entered through the guided builder with a midnight end, so the board always has a crossing row to look at.
    /admin/intake intake
    Expected result An application whose schedule spans two dates.
    Watch out for Late-night nights carry contract consequences. Noticing the crossing at review time is what lets you check the right rider is on the paperwork.

Steps 15–17 — Admin

their manual →

Owns the derivation trace and the timezone health check when a schedule looks wrong.

  1. 15
    When a schedule looks impossible, replay the derivation with the same inputs. The trace prints which rollover branch was taken, how the local time was resolved, and every step in both projections.
    Expected result A full derivation trace, answering 'why is setup on the 13th'.
    Watch out for There is also a built-in fixture table of known cases. If it reports any failures, the model itself is broken, not the application in front of you — that is a much bigger problem and a much shorter conversation.
  2. 16
    Render exactly what the UI would show for a stored application, and check the stored instants round-trip through the reverse mapping.
    /debug/intake/timeline-render/{app_id} intake
    Expected result The projected rows plus a round-trip verdict.
    Watch out for If the round trip fails, do not edit the application to 'fix' it — the stored instants are the truth and the inference is the derived view. Editing would overwrite good data with a bad guess.
  3. 17
    If every schedule in the platform is suddenly in UTC, check whether a timezone database is present in this container.
    Expected result A yes/no answer and which resolver answered it.
    Watch out for This is a real deployment failure mode on slim base images: with no timezone data, local time silently becomes UTC and nothing raises. This endpoint exists so the silence is diagnosable in one click.

Re-apply with autofill (and the red confirm gate)

Load last time's answers, then confirm or change every red field before the form will submit.

Owned by Host / Promoter · 8 steps · about 15 minutes

Why this exists

Returning promoters re-apply constantly, and the failure mode is always the same: they copy last year's application, change the date, and leave a stale price or a stale headcount in place. The venue then approves numbers nobody meant, and those numbers get snapshotted into a contract and signed.

So autofill on this platform is deliberately uncomfortable. It is opt-in (you press "I've applied before"), it is verified by a six-digit code emailed to the address you claim, and every value it loads arrives marked, with a From last time chip beside it. The mark does not mean wrong. It means nobody has looked at this yet. You clear it one of two ways: change the value, or switch its Confirmed toggle on. Until every marked field is cleared the submit button will not submit — it reads "Review N highlighted fields".

The part that matters most is invisible in the browser: the gate is enforced on the server. When the autofill session is verified, the exact set of prefilled values is frozen onto the session row, and that frozen snapshot is the source of truth. On submit the server re-canonicalises what you actually sent and compares it, field by field, with the frozen value. Skipping the UI and posting the form directly earns a 422 listing the fields you did not review.

Be precise about which half the server decides, because a reviewer reads the scorecard on the strength of it. Edited is derived: the value you sent differs from the frozen one, and no claim from the browser can create or erase that. Confirmed is a claim — you sent the same value and said you had looked at it — and the server takes it at face value, because there is no other evidence that a person read a field. A client that auto-confirms everything therefore passes the gate having changed nothing. What stops that being invisible is that the two are recorded differently: the audit table says confirmed, not edited, and the reviewer sees which answers were re-affirmed rather than re-typed. So read a screen of confirmations as what it is — an applicant saying "still right" — and not as the platform having checked anything.

Every one of those decisions is written to an audit table, so the reviewer can see on the scorecard which answers you actively re-affirmed and which you changed. That audit trail is the point of the whole feature.

Before you start

  • An earlier application under the same email address (the seed ships three for host@club.test).
  • Access to that mailbox — or, in a demo environment, the recorded outbound mail log.

Practise with

PersonaEmailPasswordNote
host_promoterhost@club.test host123has 'Warehouse Frequencies', 'Analog Sunrise' and 'Neon Circuit Gala' on file to autofill from
venue_managermanager@club.test manager123sees the prefill audit panel on the scorecard
adminadmin@club.test admin123can read the emailed code out of the outbound call log and clear rate limits

Steps 1–6 — Host / Promoter

their manual →
  1. 1
    Open the form, type the email address you applied with last time, and press the button beside it: I've applied before.
    /host/apply intake
    Expected result A code box appears under the email field.
    Watch out for If you were already logged in when you opened the page, autofill has already happened without any code — a logged-in visit creates a pre-verified session automatically. Skip to the red fields.
  2. 2
    Request the code.
    Expected result prefill_available true plus a session id, and a six-digit code emailed to that address.
    Watch out for prefill_available comes back false when no earlier application exists for that email — the platform will not tell a stranger whose address is on file. Limits are 5 per email per hour and 20 per IP per hour, then 429.
  3. 3
    Enter the six digits and press Unlock autofill.
    Expected result Your previous answers drop into the form, a warning banner appears, and every prefilled field is marked, with a 'From last time' chip and a Confirmed toggle beside it.
    Watch out for The code lives 15 minutes and you get 5 attempts. A wrong code is 401 bad_code; an expired or exhausted session is 410 prefill_expired and you start over. Only meaningful values prefill — blanks and unticked flags are dropped rather than carried forward.
  4. 4
    Work every marked field. Change the ones that changed — the date, almost certainly the prices. Switch Confirmed on for the ones that are genuinely still true.
    Expected result The submit button counts down: 'Review 12 highlighted fields', then 11, then 10, and finally 'Submit application' when the count reaches zero.
    Watch out for Three traps. First, editing a field back to exactly the suggested value marks it again — the gate compares values, not keystrokes. Second, the whole tier table is a single field: confirm it with the one 'Tier table confirmed' toggle, not row by row. Third, the bar minimum is confirmed as cents even though you type dollars.
  5. 5
    Submit. The form sends the prefill session id and your confirmations along with the answers.
    Expected result 201 as usual, plus an audit row per prefilled field recording whether it was confirmed or edited, with the old and the new value.
    Watch out for 422 unconfirmed_prefill_fields comes back with error.fields listing the field names you skipped — that is the server gate, and no browser trick avoids it. 410 prefill_session_invalid means the session was already used, was never verified, or is more than 24 hours old: sessions are single-use on purpose.
  6. 6
    Confirm you landed on the thank-you page with a new reference.
    /apply/thanks/{app_id} intake
    Expected result A new application, entirely separate from the one you autofilled from.
    Watch out for Autofill copies values; it does not copy attachments. Re-attach your insurance certificate.

Steps 7 — Venue Manager

their manual →

Reads the prefill audit to see which answers you re-affirmed and which you changed.

  1. 7
    Open the new application and scroll to the prefill audit panel.
    /admin/intake/{app_id} intake
    Expected result A table headed 'Prefill audit (RED-field gate)' with one row per prefilled field: the field, the action (confirmed or edited), the prefilled value and the final value.
    Watch out for A wall of 'confirmed' with an unchanged ticket price on a date twelve months later is exactly the signal this panel exists to give you. Read it before you approve.

Steps 8 — Admin

their manual →

Can recover the emailed code and clear the prefill rate limits when a demo hits them.

  1. 8
    As an admin running a training session, inspect the prefill and submission counters when a room full of trainees starts hitting 429.
    Expected result The fixed-window counters keyed by email and IP.
    Watch out for Clear them with the sibling endpoint /debug/intake/clear-rate-limits. Debug routes are admin-only and the whole tree 404s when debug endpoints are disabled, so this is a training and demo tool, not a production escape hatch.

Respond to a request for more information

Answer the venue's question through your tokenised edit link and put the ball back in their court.

Owned by Host / Promoter · 6 steps · about 10 minutes

Why this exists

When the venue needs something from you, they do not send a personal email and hope. Asking for more information is a status: the application moves to info_requested, the question is stored as a note against the application, and you are emailed a tokenised link that lets you edit your own submission with no account and no password.

Two design ideas sit behind that. The first is that the queue must never lie — as long as your application is in info_requested, the board says out loud that the venue is waiting on you, and nothing is quietly stuck in somebody's inbox. The second is that the token is the credential. It is a 14-day, multi-use link scoped to exactly one application, stored only as a hash. Treat it like a password: whoever holds it can edit your pitch.

When you resubmit, the application returns to under_review with your change recorded in its history, and the venue's Telegram channel gets a fresh "UPDATED APPLICATION" card with new action buttons. The moment a decision is made — approved or rejected — every unused token on the application is invalidated, so bookmarking the edit link buys you nothing afterwards.

Before you start

  • An application of yours sitting in info_requested.
  • The edit link the venue emailed you (or, in a demo, the recorded outbound email).

Practise with

PersonaEmailPasswordNote
host_promoterhost@club.test host123the applicant on all three seeded demo applications
venue_managermanager@club.test manager123asks the question and re-reviews the answer

Steps 1 — Venue Manager

their manual →

Asks the question — and requesting info is a first-class status, not an email thread.

  1. 1
    From the application's action bar, request more information and say exactly what is missing, in 1 to 2000 characters.
    /api/intake/applications/{app_id}/request-info POST intake
    Expected result Status moves to info_requested, the question is stored as a note flagged as an info request, a 14-day host_edit token is minted, and the promoter is emailed a link to it.
    Watch out for Only legal from under_review — anything else is 422 invalid_transition. Claim the application first (opening it from the Telegram review deep link does that for you).

Steps 2–4 — Host / Promoter

their manual →
  1. 2
    Open the link from your email. There is nothing to log into — the token in the URL is your credential.
    /apply/edit/{token} intake
    Expected result Your application, editable, with the venue's question printed at the top.
    Watch out for Four ways this page turns you away, and it tells you which: an invalid link, an application that no longer exists, one that is no longer editable because a decision was made, and an expired link. All of them render a friendly page with a 410 status rather than a stack trace. If it is dead, ask the venue to send a fresh one — do not go hunting for another way in.
  2. 3
    Understand what the page loaded: the same editable payload the API returns for your application, resolved from your token.
    /api/intake/public/applications/{app_id}/edit intake
    Expected result Your current answers, tiers and attachment list.
    Watch out for The guard order is deliberate and worth recognising in an error: a bad token is 401, a wrong status is 409 not_editable, and an expired token is 410. A 409 means your token is fine but a decision already happened.
  3. 4
    Answer the question, fix whatever they asked about, add or remove attachments, and resubmit.
    /api/intake/public/applications/{app_id} PUT intake
    Expected result The application returns to under_review, your change is recorded in its status history, and the venue's channel gets an UPDATED APPLICATION card with fresh action tokens.
    Watch out for Removing an attachment is explicit — you send the ids to remove, you do not just leave the file out. The same validation rules apply as on first submission, so a date that has drifted inside the minimum lead time will now fail.

Steps 5–6 — Venue Manager

their manual →

Asks the question — and requesting info is a first-class status, not an email thread.

  1. 5
    Re-open the application and read the update against the timeline.
    /admin/intake/{app_id} intake
    Expected result The new values, the recomputed viability score, and the whole exchange in the status history.
    Watch out for You can loop: request info again. Each round trip is another status event, which is exactly the record you want three months later.
  2. 6
    Approve once you are satisfied.
    /api/intake/applications/{app_id}/approve POST intake
    Expected result Status approved, every unused token on the application invalidated, an acceptance email to the promoter, and — in the same transaction — a draft contract created with the riders your flags implied.
    Watch out for Approving is the moment the paperwork exists. You can approve, but you cannot lock or counter-sign what you just drafted: that split between running the calendar and binding the venue is intentional.

Review the riders your approval created

Read the drafted contract and its auto-injected riders — and learn why you cannot edit them.

Owned by Venue Manager · 11 steps · about 12 minutes

Why this exists

Approving an application drafts a contract in the same transaction, and the riders in that draft are not chosen by a human — they are derived from the flags the promoter ticked on the application. Live music, alcohol, late night and ticket royalty each pull their clause from the rider library, and the headcount, average ticket price and event date are snapshotted into the contract's variables from what was submitted. The design goal is that the venue's standard terms cannot be forgotten by whoever happened to be reviewing that night.

Which is exactly why a venue manager can read this and not write it. The built permissions are clear and worth stating plainly, because the training plan assumed otherwise: every contract edit is admin-only. Editing a section, adding or removing one, changing the variables, re-syncing the riders, locking, unlocking, voiding, resending the signature link and countersigning are all refused for a manager. What you get is the contracts list, the contract page, the rendered preview, and the redlines view that shows how the draft differs from the template.

That is not a gap to work around. Your approval is the operational decision — this night is worth having — and the contract is the legal one. Reviewing the draft carefully and telling an admin exactly what is wrong is a faster path than an edit button would be, because the person who binds the venue is then the person who read the change.

One built behaviour to teach, because the requirements document says otherwise: sealing a contract flips the event from draft to announced. It does not move an application to some approved state — the application's own status stays approved until a deposit is recorded.

Before you start

  • A venue manager or admin session.
  • An approved application with a drafted contract. Approve the seeded Warehouse Frequencies to make one.

Practise with

PersonaEmailPasswordNote
venue_managermanager@club.test manager123shift lead: implies door_staff, plus intake, sales, guest list, marketing
adminadmin@club.test admin123the only account that can touch money, identity and contracts
host_promoterhost@club.test host123owns the two seeded applications, Warehouse Frequencies and Analog Sunrise

Steps 1–6 — Venue Manager

their manual →
  1. 1
    Start from the application you approved and re-read the Production flags block — live music, alcohol, late night, ticket royalty — plus the royalty percentage if it is set.
    /admin/intake/{app_id} intake
    Expected result The flags as the promoter submitted them.
    Watch out for These flags are the input to the riders. A promoter who ticked alcohol by accident has just acquired a clause, and this is the last screen where that is easy to see.
  2. 2
    Open the contracts list and find the draft your approval created.
    /admin/contracts contracts
    Expected result The contract against that application, in draft.
    Watch out for This list is open to managers and admins. The individual contract pages are broader still — a host can see their own — so do not assume a page being visible means it is yours to change.
  3. 3
    Open the draft and read it in the order that matters: the variables first — headcount, ticket price, event date — then the riders that the flags injected.
    /admin/contracts/{contract_id} contracts
    Expected result The draft with its sections, its auto-injected riders and its variable values.
    Watch out for The variables are a snapshot taken at approval. If the promoter has since changed their numbers, the contract does not follow them — that divergence is the single most common thing to catch here.
  4. 4
    Open the redlines view to see where the draft departs from the standard template.
    /contracts/{contract_id}/redlines contracts
    Expected result The differences between this contract and the template it was built from.
    Watch out for This view is manager-and-admin only. It is the fastest way to spot a clause somebody edited by hand, which is exactly what you want to know before it is signed.
  5. 5
    Render the preview to read it as the promoter will see it, with the variables substituted.
    /contracts/{contract_id}/preview contracts
    Expected result The contract as it will appear on the signing page.
    Watch out for Unfilled variables are glaringly obvious in the preview and easy to miss in the editor. Read the preview, not the source, when you are checking for blanks.
  6. 6
    Write up anything wrong and hand it to an admin — the clause, the variable, and what it should say. You cannot change it yourself: section edits, variable edits, rider re-sync, lock, unlock, void, resend and countersign are all admin-only.
    Expected result A specific request rather than 'the contract looks wrong'.
    Watch out for Do not ask for the permission instead. The split exists so that the person who binds the venue is the person who read the change.

Steps 7–10 — Admin

their manual →

Is the only role that can change anything in the contract you are reviewing.

  1. 7
    As an admin, correct the variables the manager flagged.
    /contracts/{contract_id}/variables PATCH contracts
    Expected result Updated values throughout the rendered contract.
    Watch out for Variables are substituted at render time, so fixing one repairs every place it appears — including clauses the manager did not think to check.
  2. 8
    Edit a clause where the standard wording genuinely does not fit this night.
    /contracts/{contract_id}/sections/{section_id} PATCH contracts
    Expected result The section updates and shows up in the redlines view.
    Watch out for Every hand edit is a redline somebody has to justify later. Prefer fixing the rider library over hand-editing the same clause for the fifth time.
  3. 9
    If the promoter's flags changed after approval, re-sync the riders rather than adding clauses by hand.
    /contracts/{contract_id}/resync-riders POST contracts
    Expected result The rider set matches the current flags.
    Watch out for Re-syncing touches the auto-injected riders. Check the redlines afterwards to make sure a deliberate hand edit did not get reverted along with it.
  4. 10
    Lock the contract when it is right. From here it goes out for signature and, once sealed, flips the event from draft to announced.
    /contracts/{contract_id}/lock POST contracts
    Expected result A locked contract ready for signing.
    Watch out for The seal moves the EVENT status, not the application's. The application stays approved until a deposit is recorded against it — the requirements document describes this differently from what was built.

Steps 11 — Host / Promoter

their manual →

Set the flags that produced these riders, and signs whatever they end up saying.

  1. 11
    As the promoter, read your own contract before signing. Full signing and countersigning is covered in the Host / Promoter manual.
    /admin/contracts/{contract_id} contracts
    Expected result Your contract, readable.
    Watch out for The riders came from the boxes you ticked on your application. If one surprises you, say so before signing, not after.

Convert an application into an event

One button that creates the draft event, creates or reuses the host account, and grants the scoped role.

Owned by Venue Manager · 10 steps · about 12 minutes

Why this exists

Conversion is the seam between the application pipeline and the events engine, and it is deliberately a single button doing three things atomically: it creates a draft event seeded from the application, it creates or reuses the host's user account, and it grants that user the host role scoped to this one event. If any part fails, none of it happened.

The scoped grant is the piece worth understanding. Being a host is not a standing privilege on this platform — it is a grant attached to a specific event id, so a promoter with three nights across the year has three grants and can see precisely three events. There is no such thing as a promoter who can browse the venue's calendar.

The event lands as a draft on purpose. A draft is invisible to the public and never appears in the door's event picker, so conversion cannot accidentally put something on sale. Announcing it is a separate, admin-only transition — and in practice it is the contract seal that flips draft to announced, which is the platform's way of saying no night goes public before the paperwork is done.

Conversion is once and only once. A second attempt returns a conflict carrying the existing event id, and a unique index on the source application backstops the race. If the converted event is wrong, the answer is to fix the event, not to convert again.

Before you start

  • A venue manager or admin session.
  • An application in approved status that has not been converted. The seed ships Analog Sunrise, approved and unconverted, scored 77.

Practise with

PersonaEmailPasswordNote
venue_managermanager@club.test manager123shift lead: implies door_staff, plus intake, sales, guest list, marketing
host_promoterhost@club.test host123owns the two seeded applications, Warehouse Frequencies and Analog Sunrise
adminadmin@club.test admin123the only account that can touch money, identity and contracts

Steps 1–6 — Venue Manager

their manual →
  1. 1
    Filter the queue to approved applications and pick one that has no event against it yet.
    /admin/intake intake
    Expected result Approved applications, some already showing a converted event link.
    Watch out for An application already showing View event has been converted. Look for that link before you go looking for the convert button.
  2. 2
    Open it and check the financials block one last time — the tier estimates are what the draft event's tiers will be seeded from.
    /admin/intake/{app_id} intake
    Expected result The tier estimate rows with names, prices and quantities.
    Watch out for A tier quantity mismatch flag on the scorecard means the tier quantities do not add up against the headcount. Converting anyway is legal, but you have just seeded an event with tiers somebody will have to fix.
  3. 3
    Press Convert to Draft Event and confirm the prompt, which asks in plain words whether to create the draft event and grant the host role.
    /api/intake/applications/{app_id}/convert POST intake
    Expected result A new draft event id; the page now offers a View event link.
    Watch out for 409 application_not_approved means you are trying to convert something still under review. 409 already_converted carries the existing event id in the message — read it rather than pressing again.
  4. 4
    Follow the link into the new draft event and check what was copied: name, description, the slot times, the doors-open time, and the tiers.
    /admin/events/{event_id} events
    Expected result A draft event with tiers seeded from the application's estimates.
    Watch out for Draft events are invisible to the public and never appear in the guest-list door picker. If a promoter says they cannot see their event, this is almost always why.
  5. 5
    Note that the application's status does not move. It stays approved; recording a deposit is what advances it to contract deposit, and that is the contract flow's job.
    /admin/intake/{app_id} intake
    Expected result Status still approved, with a converted event id stamped on it.
    Watch out for Do not force the status to make the board look tidy. The board is telling you the truth — an event exists, the deposit does not.
  6. 6
    Understand what you have and have not just done. You created a draft and a scoped host. You did not set prices, open sales, or announce anything — all admin writes.
    Expected result A clean handover point to an admin.
    Watch out for Do not promise the promoter a public listing off the back of a conversion. The event goes public when the contract is sealed, not when you press this button.

Steps 7–8 — Host / Promoter

their manual →

Receives the account and the scoped grant this button creates.

  1. 7
    As the promoter, sign in — new hosts receive an email with a temporary password — and confirm the host grant on your account.
    /account rbac
    Expected result A host grant scoped to that one event.
    Watch out for Scoped means scoped. You will not see the venue's other events anywhere, and that is not a bug in your account.
  2. 8
    Open your event's dashboard and read the sales figures. What you can and cannot change is covered in the Host / Promoter manual.
    /admin/events/{event_id} events
    Expected result Your event, read-only, with its tiers and sales.
    Watch out for Nothing about the grant lets you edit tiers or prices. It grants visibility of your night, not control of the venue's inventory.

Steps 9–10 — Admin

their manual →

Owns everything that happens to the draft event afterwards — tiers, prices and the transition to announced.

  1. 9
    As an admin, finish the draft: check the tiers the conversion seeded, set real prices, map the zones, and set the release rules.
    /admin/events/{event_id} events
    Expected result An event ready to announce.
    Watch out for The seeded tiers are the promoter's estimates, not your pricing. Treat them as a starting draft, never as agreed numbers.
  2. 10
    Check the host user the conversion created or reused, and their grants.
    /admin/users/{user_id} rbac
    Expected result The user with a host grant scoped to the event.
    Watch out for Conversion reuses an existing user when the email matches, so a returning promoter accumulates grants rather than accounts. Duplicate grants are an audited no-op, not an error.

Review and e-sign your venue agreement

Open the tokenised signing link, read the hashed document, sign it, and wait for the counter-signature.

Owned by Host / Promoter · 11 steps · about 20 minutes

Why this exists

The contract is a gate, not a formality, and the mechanism is worth understanding before you press anything.

When an admin locks the contract, the platform renders it canonically — a deterministic HTML rendering with no timestamps and no randomness — and takes the SHA-256 hash of that rendering. From that moment the sections and the variables are frozen by database triggers. The signing link you are emailed carries a token, and the token is your credential: no account, no password, no login. What you sign is recorded against that hash. If the document were altered underneath you, the recomputed hash would not match and signing would be refused with a hash mismatch. That is the guarantee the hash exists to give you, not the venue.

Signing is not the end. Your signature moves the contract to host_signed and it waits for the venue's counter-signature, which only an admin can give. The counter-signature seals the document into immutable stored HTML with a sealed hash, advances the application to contract_deposit, and attempts to flip the linked event from draft to announced.

Attempts, because the event has its own gate and it is checked separately. The seal always succeeds; the flip does not, and a refused flip does not undo anything. An event still missing a required time, or whose booked act has not yet confirmed, stays a draft — the agreement is final and the night is not yet public. Today the only sign of that is on the staff channel, so if your event has not appeared on the public listing shortly after you get the executed email, ask the venue; the usual answer is a confirmation somebody has not sent yet. (This paragraph describes what happens now. Making the refusal visible to you, rather than to a staff channel, is plan/631.)

Built behaviour, differing from the BRD: the seal moves the event draft to announced. There is no contract_pending or approved event status in this platform. Announced means publicly visible — it does not mean on sale. Going on sale is a separate, deliberate admin action afterwards.

Before you start

  • An approved application of yours with a drafted contract.
  • The signing link from the 'ready to sign' email (or a freshly resent one).
  • An admin to lock the document beforehand and to counter-sign afterwards.

Practise with

PersonaEmailPasswordNote
host_promoterhost@club.test host123owns the seeded 'Neon Circuit Gala' application and its draft contract
adminadmin@club.test admin123the only persona that can lock and counter-sign
venue_managermanager@club.test manager123can read the contract and its redlines, and nothing else

Steps 1–2 — Admin

their manual →

Locks and hashes the document, then counter-signs on behalf of the venue.

  1. 1
    Open the drafted contract, check the auto-injected riders against the promoter's flags, and fill every variable: headcount, ticket price and event date.
    /admin/contracts/{contract_id} contracts
    Expected result A section-by-section editor. Variables render inline in blue, and every edit you make is recorded as an append-only redline.
    Watch out for A blank variable blocks the lock. That is the intended failure — an unpriced agreement must never reach a signature.
  2. 2
    Lock the contract.
    /contracts/{contract_id}/lock POST contracts
    Expected result The document is canonically rendered and SHA-256 hashed, a signing token is minted, and the signing link is emailed to the host.
    Watch out for 422 comes back with a reasons list, for example an empty event_date variable. Locking freezes the wording: if it is wrong, unlock (only possible while unsigned) or void and supersede — do not try to edit around it.

Steps 3–4 — Venue Manager

their manual →

Reads the agreement and its redline history, but may never lock or sign it.

  1. 3
    As the venue manager, open the contracts list and find the locked agreement.
    /admin/contracts contracts
    Expected result The contract with its status, plus any approved applications that still have no paperwork.
    Watch out for You can read this list, but the lock, void, resend and counter-sign controls are not yours — the page renders read-only for you.
  2. 4
    Read the redline history to see exactly what the venue changed from the master template before locking.
    /contracts/{contract_id}/redlines contracts
    Expected result An append-only list of edits with word-level diffs.
    Watch out for Redlines cannot be edited or deleted by anyone — the tables carry both UPDATE and DELETE triggers.

Steps 5–7 — Host / Promoter

their manual →
  1. 5
    Open the signing link from your email and read the whole agreement — your rider clauses and your numbers are in it.
    /sign/{token} contracts
    Expected result The full document exactly as it was locked, with a signature pad below it.
    Watch out for An expired link renders 'Link expired' with a 410; a revised agreement renders 'Agreement revised' with a 410 because the old contract was voided and a fresh draft supersedes it. In both cases ask the venue to resend rather than hunting for another route in. /contracts/sign/{token} redirects here, so both forms of the link work.
  2. 6
    Sign: either draw your signature on the pad or switch to the Type tab and type your full legal name, then tick the consent box and press Sign Agreement.
    /sign/{token} POST contracts
    Expected result Your signature is stored against the locked hash together with your IP address and browser user agent, and the contract moves to host_signed.
    Watch out for The button stays disabled until you have both consented and produced a signature. Refusals you may see: 422 consent_required, 409 already_signed, 409 not_locked, and 409 hash_mismatch — the last one means the document drifted from what was hashed and is a stop-everything signal, not a retry.
  3. 7
    Check where the agreement stands without pestering anyone.
    /sign/{token}/status contracts
    Expected result The contract status plus two booleans: signed, and sealed.
    Watch out for signed true and sealed false is the normal in-between state — you have signed and the venue has not counter-signed yet. Reloading the signing page in that state shows a plain 'awaiting venue counter-signature' message, which is not an error.

Steps 8 — Admin

their manual →

Locks and hashes the document, then counter-signs on behalf of the venue.

  1. 8
    Counter-sign on behalf of the venue.
    /contracts/{contract_id}/countersign POST contracts
    Expected result Status executed with a sealed hash, and the response reports event_flipped and application_advanced. In the same transaction the event goes draft to announced and the application moves to contract_deposit.
    Watch out for 409 host_must_sign_first if you jump the queue, 409 already_executed on a replay. Executed rows are immutable at the database level — a trigger refuses any UPDATE, even from the admin data suite.

Steps 9–11 — Host / Promoter

their manual →
  1. 9
    Reopen your original signing link.
    /sign/{token} contracts
    Expected result The same URL now serves the sealed, executed document — your permanent copy.
    Watch out for The stored sealed HTML never changes again. A preview of an unexecuted contract is watermarked; the sealed document is not, because there is nothing provisional left about it.
  2. 10
    Once you have your host login, fetch the sealed copy from inside the app instead of from the email link.
    /contracts/{contract_id}/sealed contracts
    Expected result The same sealed HTML document.
    Watch out for This needs a session and the platform must recognise you: it lets you through if you hold the host grant for the event, or if the application's host or applicant is you, or simply if your account email matches the application's email. It is 404 not_executed until the counter-signature lands.
  3. 11
    Look at your event on the public catalogue.
    /events/{event_id} events
    Expected result It is visible — the seal announced it.
    Watch out for Announced is not on sale. Nobody can buy yet, and no amount of refreshing changes that; putting inventory on sale is a separate admin decision.

Guest List & VIP

Comp allocations, invites, plus-ones and door overrides.

Allocate comps to a promoter

Create buckets with hard caps, hand one to a promoter, and watch the utilisation.

Owned by Venue Manager · 10 steps · about 16 minutes

Why this exists

Comps are allocated in buckets, and the bucket is the unit of trust. Rather than granting a promoter the abstract right to add names, you give them a named allotment with a hard total, a tier, and a default plus-one allowance, and then let them fill it themselves. The venue keeps the ceiling; the promoter keeps the list.

The counting rule is the thing people get wrong, so learn it first: allocation counts plus-one ALLOWANCES at the moment the entry is created, not the plus-ones that actually get named. An entry with a two-plus-one allowance consumes three from the bucket immediately, whether or not anyone is ever named. That is what makes the ceiling real — a bucket of ten cannot turn into thirty on the night — and it is why a promoter who hands out generous allowances runs out of names faster than they expect.

The tier on the bucket is not just a price label; it determines the zones the resulting pass opens. A comp issued against the VIP tier gets VIP zones at the door. Putting a promoter's bucket on the VIP tier because it was the first one in the dropdown is how you end up with fifty people in a room built for twenty.

Everything a bucket produces is real: a zero-value ticket on a zero-value paid order, drawing on the same inventory a paying customer draws on, with a genuine door pass. No money moves and no ledger entry is written, but capacity absolutely does — a sold-out tier will refuse a comp with insufficient inventory.

Before you start

  • A venue manager or admin session.
  • An event with at least one tier — the bucket must point at a tier of that event.
  • For the promoter half: a scoped host user on the event. The seed ships promoter@demo.club on demo-event-0001.

Practise with

PersonaEmailPasswordNote
venue_managermanager@club.test manager123shift lead: implies door_staff, plus intake, sales, guest list, marketing
host_promoterpromoter@demo.club promoter123scoped host on demo-event-0001; owns the seeded Promoter X comp bucket
adminadmin@club.test admin123the only account that can touch money, identity and contracts
door_staffdoor@club.test door123door shift account: scanner + guest list, nothing else

Steps 1–6 — Venue Manager

their manual →
  1. 1
    Open the guest-list index and pick the event you are allocating for.
    /admin/guestlist guestlist
    Expected result Events with their guest-list activity.
    Watch out for Door staff get a 403 on this index. It is a planning surface, not a door surface.
  2. 2
    On the event dashboard, read the existing buckets first: each shows its allocation total, consumed and remaining, and its status.
    /admin/guestlist/{event_id} guestlist
    Expected result The seeded Promoter X bucket (10 allocated, one plus-one by default) and Artist Guest List (15, manual issue policy).
    Watch out for Consumed is recomputed live from the entries, not stored as a counter. If it disagrees with your head, the entries are right.
  3. 3
    Use the Create bucket form: give it a name, an owner type (promoter, artist, house, staff or other), the owning host user if it belongs to one, the tier, the allocation total, the default plus-ones and the issue policy.
    /api/guestlist/events/{event_id}/buckets POST guestlist
    Expected result A new bucket at zero consumed.
    Watch out for Choose the tier deliberately — it decides the door zones on every pass this bucket ever produces. A tier belonging to a different event is refused with 422 tier_not_of_event.
  4. 4
    Choose the issue policy on purpose. On_claim means the guest's ticket is minted when they claim their invite link. Manual means somebody has to press Issue for each one — slower, but nothing is minted until a human has looked at it.
    Expected result A policy that matches how much you trust the list.
    Watch out for Manual policy plus a busy promoter equals a queue of unticketed names at the door. That is survivable — the door can Issue and Check In — but only if you told the door that is what is coming.
  5. 5
    Adjust an allocation when a promoter asks for more, or freeze a bucket to stop further names without touching what is already issued.
    /api/guestlist/buckets/{bucket_id} PATCH guestlist
    Expected result The bucket updates, or freezes.
    Watch out for Lowering an allocation below what is already consumed is refused with 409 allocation_below_consumed. And freezing is not retroactive — already-issued tickets keep scanning green, because tickets are independent once minted.
  6. 6
    Watch utilisation through the event build-up: consumed against remaining per bucket, with the per-status counts.
    /api/guestlist/events/{event_id}/buckets guestlist
    Expected result Live numbers, with expiry and check-in sync swept before the read.
    Watch out for Remaining is null for the door-override house bucket. That bucket is allocation-exempt on purpose — walk-ins are governed by the override cap instead.

Steps 7–8 — Host / Promoter

their manual →

Fills the bucket you allocated, and can only ever see and spend their own.

  1. 7
    As the promoter, open the same dashboard. You see only the buckets you own.
    /admin/guestlist/{event_id} guestlist
    Expected result Your bucket, your entries, and nothing belonging to another promoter.
    Watch out for Scoping is on the bucket owner, not on the event. Being a host on the event does not show you the house or artist lists.
  2. 8
    Add a guest — invite mode sends them a claim link, direct mode issues to a known member straight away.
    /api/guestlist/buckets/{bucket_id}/entries POST guestlist
    Expected result An entry, and the bucket's consumed figure rising by one plus the plus-one allowance.
    Watch out for 409 allocation_exceeded means the allowance arithmetic caught up with you, not that the system miscounted. 409 duplicate_guest means that email or member is already on some bucket for this event — one live entry per guest per event, across all buckets.

Steps 9 — Door Staff

their manual →

Turns the names in the bucket into people in the room, and lives with your plus-one decisions.

  1. 9
    On the night, search the names the promoter added and check them in.
    /api/guestlist/door/{event_id}/search guestlist
    Expected result Entries with their bucket shown, so you know whose list a guest is on.
    Watch out for The bucket name is worth reading aloud when there is a dispute. 'You are on Artist Guest List with one plus-one' ends most of them.

Steps 10 — Admin

their manual →

Is the only role that can delete a bucket outright.

  1. 10
    Delete a bucket created in error. Only an admin can, and only when it is empty.
    /api/guestlist/buckets/{bucket_id} DELETE guestlist
    Expected result The bucket goes.
    Watch out for 409 bucket_not_empty is the normal answer. The right move for a bucket with history is to close it, not to try to erase it.

Claim your comp invite and name your plus-ones

Turn an invite link into a real $0 ticket and a door pass, with your guests named.

Owned by Customer (Guest) · 9 steps · about 12 minutes

Why this exists

A comp invite is designed for the person who has never heard of your platform. The link contains a token, and the token is the credential — no account, no password, no app. That is a deliberate trade: guest-list conversion dies at the login screen, and an invite that cannot be forwarded is an invite that gets forwarded as a screenshot instead. The token is scoped to one entry and expires, so its blast radius is one seat.

What claiming actually does is more than reserving a name. It mints a real $0 ticket through the same inventory guard as a paid one, on its own zero-value order, and issues a real door pass. Comp inventory is real inventory — a sold-out tier makes a claim fail, which is exactly the behaviour you want when a promoter is quietly giving away the room.

Plus-ones are named, not counted. Each named guest becomes their own ticket and their own credential, so the door admits people rather than a number, and a guest who does not turn up does not silently become a spare entry for someone else. You can rename and remove your guests up to the cutoff, which defaults to when doors open.

Two consequences of comps being real tickets: they can never be resold (zero face value is rejected by the exchange), and revoking one returns its seat to the tier.

Before you start

  • An invite link with a live token. The seed ships one — the entry named Gala Guest on the demo event.
  • For the member path: an account that the invite was addressed to.
  • For the door steps: an employee session.

Practise with

PersonaEmailPasswordNote
vip_membervip@club.test vip123has a directly-issued comp visible on the member comps page
door_staffdoor@club.test door123checks the guest in on the night

Steps 1–4 — Customer (Guest)

their manual →
  1. 1
    Open the invite link exactly as a guest would: no account, on a phone.
    /guestlist/claim/{token} guestlist
    Expected result A styled page naming the event and who put you down, with a form for your name and your plus-one slots.
    Watch out for The four failure pages are distinct and they mean different things: not found, expired, no longer valid (revoked), and temporarily paused (the bucket is frozen). Only the last one is worth waiting on.
  2. 2
    Claim your spot. Enter your name and the full names of your guests.
    /api/guestlist/claim/{token} POST guestlist
    Expected result The entry moves to claimed and — when the bucket issues on claim — a $0 ticket and a pass serial come back immediately.
    Watch out for Some buckets are set to manual issue: you will be claimed but not ticketed until staff release it. The page says so. Do not claim twice trying to force it.
  3. 3
    Change your mind about a guest: add, rename or remove until the cutoff.
    /api/guestlist/claim/{token}/plus-ones POST guestlist
    Expected result The plus-one list updated, each named guest holding their own ticket.
    Watch out for Past the cutoff (doors open, unless the bucket sets its own) this is refused. So is exceeding the allowance you were given, and so is removing a guest who has already walked in.
  4. 4
    Understand the credential you now hold. Claiming with no account creates a shadow user behind the scenes, because a ticket and a pass must have an owner — your pass serial comes back on the claim page and that page stays your way back to it.
    Expected result A comp pass with the bucket's tier zones, issued the moment the ticket was.
    Watch out for Keep the link. With no account there is no wallet page to fall back on — this page is the only route to the pass. It does NOT expire once claimed: `_expire_entry_if_stale` returns early for any status other than `invited`, so the claim URL is permanent. It is the UNCLAIMED invite that expires, one step earlier.

Steps 5–7 — Member

their manual →

The same invite addressed to an account is claimed from the member portal instead of a token link.

  1. 5
    Now the member path: log in as the account the invite was addressed to and open your comps.
    /me/comps guestlist
    Expected result Your entries with flags for whether you can still claim them and still manage plus-ones.
    Watch out for An invite addressed to a member account is claimed here with your session — you do not need the emailed token at all, and the token path would still work if you did.
  2. 6
    Claim it from your session.
    /api/guestlist/my/entries/{entry_id}/claim POST guestlist
    Expected result The same result as the token path: a ticket and a pass.
    Watch out for Claiming someone else's entry is a 403, even with a valid session. The entry has to be addressed to you.
  3. 7
    Open your wallet, where a claimed comp lands alongside anything you paid for.
    Expected result A comp pass with the bucket's tier zones and the same rotating code as a bought ticket.
    Watch out for A comp pass is an ordinary pass in every respect except one: it can never be listed for sale, because its face value is zero and the exchange rejects it.

Steps 8–9 — Door Staff

their manual →

Finds the guest by name on the night and checks them in with their plus-ones.

  1. 8
    On the night, search the list by the name on the invite.
    /api/guestlist/door/{event_id}/search guestlist
    Expected result Matching entries with their bucket, plus-one allowance and whether they are already in.
    Watch out for Search by the name the promoter wrote down, not the name on the ID. Comps are listed by whoever invited them.
  2. 9
    Check the guest in, and each named plus-one as they arrive.
    /api/guestlist/door/{event_id}/entries/{entry_id}/checkin POST guestlist
    Expected result The entry flips to checked in and the named guest is marked individually.
    Watch out for A door check-in still runs the real scan pipeline underneath. Only two refusals are waived here — the doors-not-open-yet window and the manual reader's zone. A revoked, refunded or suspended guest is refused with the scanner's own reason and no green row is written.

Invite a guest and track plus-ones

Spend your comp allocation: invite guests, send claim links, and keep plus-ones honest.

Owned by Host / Promoter · 13 steps · about 20 minutes

Why this exists

Your comps live in a bucket: a named allocation on your event with a hard cap, a tier, and a default plus-one count. The venue creates and sizes that bucket — you cannot create one, resize one, or move allocation between them. That is the whole point of an allocation, and it is why bucket creation is staff-only while inviting into your own bucket is yours.

The number that surprises people is how allocation is counted: consumed = 1 + the plus-ones you allowed, summed over every live entry, and it is counted the moment you invite, not when the guest turns up. Invite ten people with two plus-ones each out of a bucket of ten and you are refused on the fourth. Revoking or letting an entry expire gives the allocation back.

A comp is not a name on a clipboard. When it is claimed, the platform mints a real ticket at zero face value through the normal payments path — its own $0 paid order, zero ledger postings — and a real door pass with the zones of the bucket's tier. That means comps consume real inventory: a sold-out tier makes a comp fail with an inventory error, exactly as it would for a paying customer. It also means comp tickets are never resalable, by design.

The claim link is a token and the token is the credential: your guest needs no account to claim, name their plus-ones, and walk in.

Before you start

  • A comp bucket on your event with you as its owner (the venue creates it).
  • Enough remaining allocation for the guest plus the plus-ones you intend to allow.
  • A tier with inventory left — comps consume real inventory.

Practise with

PersonaEmailPasswordNote
host_promoterpromoter@demo.club promoter123scoped host on demo-event-0001, owns the 'Promoter X' bucket (allocation 10, one plus-one by default)
venue_managermanager@club.test manager123creates and sizes the buckets
door_staffdoor@club.test door123checks your guests in on the night

Steps 1 — Venue Manager

their manual →

Creates and sizes your comp bucket — how many comps you get is the venue's decision.

  1. 1
    Create the promoter's bucket: a name, owner type promoter, the host as owner, a tier, a total allocation and a default plus-one count.
    /api/guestlist/events/{event_id}/buckets POST guestlist
    Expected result 201 with the bucket, its allocation and its consumed and remaining counts.
    Watch out for The tier you pick decides the door zones the comp pass will open, so a guest-list ticket on a general admission tier does not open the VIP room. Admin and venue manager only — the host cannot create or resize their own bucket.

Steps 2–5 — Host / Promoter

their manual →
  1. 2
    Open the guest list dashboard for your event.
    /admin/guestlist/{event_id} guestlist
    Expected result Your buckets with allocation, consumed, remaining and a status breakdown, plus an entries table and an audit tab.
    Watch out for You see only the buckets you own. The venue's house buckets and the artist list are on the same event and invisible to you, so 'remaining' on your screen is your allowance, not the room's.
  2. 3
    Read the same allocation figures as JSON before a big invite run.
    /api/guestlist/events/{event_id}/buckets guestlist
    Expected result Consumed and remaining per bucket, recomputed live.
    Watch out for Remaining comes back as null for the venue's door-override bucket, because overrides are exempt from allocation. You will not see that bucket anyway.
  3. 4
    Invite a guest from your bucket: name, email, and how many plus-ones they may bring. On the dashboard that is the + Invite Guest form on the bucket.
    /api/guestlist/buckets/{bucket_id}/entries POST guestlist
    Expected result 201 with the entry in status invited, a claim token, and an invite email recorded to the guest.
    Watch out for 409 allocation_exceeded counts the plus-one allowance, not the plus-ones actually named — one guest with three plus-ones spends four. Also 409 duplicate_guest (one live entry per email per event, across all buckets), and 409 bucket_frozen or bucket_closed. Sending allow_duplicate deliberately overrides the duplicate guard and is audited with your name on it.
  4. 5
    Send the invite. Use Copy claim link on the entry if you would rather send it yourself over a direct message than rely on the email.
    Expected result A /guestlist/claim/ link carrying the entry's token.
    Watch out for That link is the credential. Anyone holding it can claim the comp, so do not post it publicly. If it leaks, revoke the entry and re-invite — a resend rotates the token.

Steps 6–7 — Customer (Guest)

their manual →

Claims the invite from a tokenised link and names their own plus-ones, with no account.

  1. 6
    As the guest, open the claim link. No account, no password.
    /guestlist/claim/{token} guestlist
    Expected result A styled claim page naming the event, the host and the plus-ones allowed.
    Watch out for 404 for an unknown token, 410 when the invite expired or was revoked, and 423 when the bucket has been frozen. Claiming closes at the bucket's cutoff, which defaults to the event's doors-open time — a link that worked yesterday can be dead at 9pm.
  2. 7
    Claim it, and name your plus-ones while you are there.
    /api/guestlist/claim/{token} POST guestlist
    Expected result A zero-value ticket and a door pass, with a pass serial returned. Named plus-ones each get their own ticket and pass.
    Watch out for 409 plus_one_limit if you name more than you were allowed. If you have no member account the platform creates a shadow user row for you so the ticket and pass have an owner — it grants you no groups and no login. Members who claim can see the comp afterwards on their comps page.

Steps 8–10 — Host / Promoter

their manual →
  1. 8
    Track the list as it moves: invited, claimed, ticketed, checked in — plus revoked and expired.
    /api/guestlist/events/{event_id}/entries guestlist
    Expected result Your entries only, filtered to the buckets you own, with plus-one counts.
    Watch out for Entries expire quietly at the claim cutoff and hand their allocation back. If your list looks smaller than you remember, check for expired rows before you blame the system.
  2. 9
    Chase a guest who never claimed: resend the invite, rotating the token by default.
    /api/guestlist/entries/{entry_id}/resend POST guestlist
    Expected result A fresh claim link and a new invite email.
    Watch out for Resend is also the only way to revive an expired entry — and the allocation is re-checked when you do, so a full bucket will refuse the revival. Rotating the token kills the old link immediately, which is the right move after a leak.
  3. 10
    Take a comp back when plans change. Give a reason.
    /api/guestlist/entries/{entry_id}/revoke POST guestlist
    Expected result The entry, its plus-ones, their tickets and their passes are all revoked together, and the tier inventory is returned.
    Watch out for You cannot revoke a guest who has already walked in: that needs an admin with force, and you get 403 'hosts cannot revoke checked-in guests'. Revocation is a cascade, so a guest with three plus-ones loses four passes, not one.

Steps 11–12 — Door Staff

their manual →

Finds the name on the night and counts the plus-ones in.

  1. 11
    On the night, open the guest list surface from the scanner and search for the name.
    /scanner/guestlist guestlist
    Expected result Matching entries with their bucket, plus-one allowance and whether they are already in.
    Watch out for Search by the name on the list, not the name on the ID — the promoter invited whoever they invited.
  2. 12
    Check the guest in, and check their plus-ones in with them.
    /api/guestlist/door/{event_id}/entries/{entry_id}/checkin POST guestlist
    Expected result The entry flips to checked in and the plus-one count comes down.
    Watch out for 409 already_checked_in is the anti-passback guard doing its job, not a broken list. A check-in runs the real access pipeline underneath, so a revoked or refunded credential is refused with the scanner's own reason code even here.

Steps 13 — Host / Promoter

their manual →
  1. 13
    After the night, come back and read the audit tab for your buckets.
    /admin/guestlist/{event_id} guestlist
    Expected result Every invite, resend, claim, revoke, expiry and check-in with its actor and timestamp.
    Watch out for That log is append-only — it cannot be edited or deleted by anyone, including admins. It is what settles the 'we had more people on the list than that' conversation.

Check in a guest from the list

Search the guest list, check in an entry and its plus-ones, and issue a ticket on the spot.

Owned by Door Staff · 10 steps · about 14 minutes

Why this exists

A comp is a real ticket. When a promoter's guest claims their invite the platform mints a genuine zero-value ticket on a zero-value paid order, and issues a genuine door pass from it. That is why guest-list check-in is not a separate honour system: the person on the list has a credential just like a paying customer, and this screen exists for the case where they have not got it on their phone.

The important design decision is that manual check-in still runs the real scan pipeline. Pressing Check In does not write a check-in flag; it validates the guest's pass through exactly the gate chain behind the scan endpoint, and only then records a green scan through a bookkeeping reader named Guestlist Manual Check-in. That means anti-passback, revocation, resale suspension and refunds all still apply here — you cannot route around a red scan by searching the name instead.

Exactly two refusals are waived, because a door check-in is a deliberate act by a member of staff. The first is the doors-open time window: staff legitimately work the list before doors, so a not-yet-open event does not block you. The second is wrong zone, because the manual reader is a bookkeeping reader in zone A rather than a real turnstile. Everything else about the guest's right to enter is not overridable and comes back as a refusal carrying the scanner's own reason code — a cancelled or completed event, a revoked or resold or suspended pass, a revoked ticket, or a refunded order.

Plus-ones are the other half of the screen and they are an allowance, not a suggestion. Each named plus-one is its own row with its own ticket and its own check-in button. Admitting more people than the promoter was allocated is what makes the whole allocation system meaningless by the third event.

Before you start

  • An employee session (door staff, venue manager or admin).
  • An event in announced, on_sale, sold_out or in_progress — draft events never appear in the picker.
  • A guest list with entries. The seed ships Promoter X and Artist Guest List buckets on demo-event-0001.

Practise with

PersonaEmailPasswordNote
door_staffdoor@club.test door123door shift account: scanner + guest list, nothing else
venue_managermanager@club.test manager123shift lead: implies door_staff, plus intake, sales, guest list, marketing
host_promoterpromoter@demo.club promoter123scoped host on demo-event-0001; owns the seeded Promoter X comp bucket

Steps 1–7 — Door Staff

their manual →
  1. 1
    Open the guest-list door screen and select tonight's event from the dropdown. The page reloads with the list bound to that event.
    /scanner/guestlist guestlist
    Expected result The event name, its status, and an override counter reading used / cap.
    Watch out for Selecting the event is not optional — the search endpoint is scoped per event, and an unselected page has nothing to search.
  2. 2
    Type at least two characters of the guest's name or email into the search box. Results appear as you type, capped at twenty-five rows.
    /api/guestlist/door/{event_id}/search guestlist
    Expected result Matching entries with their bucket, status, plus-one allowance and a set of action buttons.
    Watch out for One character does nothing at all — the endpoint requires two. Search by the name the promoter put on the list, not the name on the ID; comps are listed by whoever invited them.
  3. 3
    Read the buttons on the row before pressing anything. Check In appears when the entry already has a ticket. Issue and Check In appears when it does not — the entry was invited or claimed but never ticketed. Reinstate and Check In appears on an expired entry, and is manager-only.
    Expected result Exactly the buttons that apply to that row's state, and no others.
    Watch out for If a row you expected shows no button at all, it is usually already checked in or revoked. Read the status chip rather than clicking twice.
  4. 4
    Press Check In for a ticketed guest.
    /api/guestlist/door/{event_id}/entries/{entry_id}/checkin POST guestlist
    Expected result The row flips to checked in, and a green scan row is written through the manual check-in reader.
    Watch out for A second press comes back 409 already_checked_in. That is anti-passback reaching you through this screen too — the same guard the scanner uses, not a separate one.
  5. 5
    Check the plus-ones in individually using their own named buttons — one per named guest — as they actually arrive.
    /api/guestlist/door/{event_id}/entries/{entry_id}/checkin POST guestlist
    Expected result Each named plus-one flips to checked in separately.
    Watch out for Plus-ones arriving separately from their host is normal. Do not check them all in at once to save time; the counts are what the promoter's next allocation is based on.
  6. 6
    For a guest on the list who never claimed their invite, press Issue and Check In. This mints the comp ticket, issues its pass and checks them in as one transaction.
    /api/guestlist/door/{event_id}/entries/{entry_id}/issue-and-checkin POST guestlist
    Expected result A ticket, a pass and a check-in, all in one go.
    Watch out for Comp inventory is real. If the tier is sold out this refuses with 409 insufficient_inventory — a guest list does not conjure capacity, it spends it.
  7. 7
    Learn the refusals that are NOT overridable here, so you escalate instead of retrying: a cancelled or completed event, a revoked or resold pass, a pass suspended because the ticket is listed for resale, a revoked ticket, and a refunded order. Each comes back with the scanner's own reason code and writes no green row.
    Expected result A 409 carrying a familiar reason code rather than a check-in.
    Watch out for Two refusals ARE waived on purpose and will surprise you the other way: the doors-open window, and wrong zone. So yes, you can legitimately work the list an hour before doors.

Steps 8–9 — Venue Manager

their manual →

Allocated the list you are working, and is the only one who can revive an expired invite at the door.

  1. 8
    When the door hits an expired invite, come over and use Reinstate and Check In yourself — reviving an expired entry needs venue_manager or above.
    /api/guestlist/door/{event_id}/entries/{entry_id}/issue-and-checkin POST guestlist
    Expected result The entry revives, tickets and checks in.
    Watch out for Revival re-checks the bucket's allocation. If the bucket is full it refuses, and the honest answer is a walk-in override against the house bucket instead.
  2. 9
    Watch the same list from the event dashboard during the shift, where the entries tab shows live statuses and the audit tab shows every action with who did it.
    /admin/guestlist/{event_id} guestlist
    Expected result Buckets with consumed and remaining counts, entries with statuses, and an append-only audit log.
    Watch out for The audit log cannot be edited or deleted by anyone, admins included — database triggers abort the attempt. Treat it as the record of the night, because that is exactly what it is.

Steps 10 — Admin

their manual →

Holds the escalations the door cannot resolve — a revoked comp, a cancelled event, a settings change.

  1. 10
    If a check-in is disputed after the fact, pull the raw audit tail for the event filtered by action.
    /debug/guestlist/audit/{event_id} guestlist
    Expected result Rows for manual_checkin, door_override, entry_revoke and the rest, with actor and timestamp.
    Watch out for Guest token actions have no actor user id — they carry the entry token id instead. A blank actor is a guest acting on their own claim link, not a missing record.

Issue a VIP walk-in override

Let somebody in who is on no list — recorded as an override, with a reason and a name against it.

Owned by Door Staff · 10 steps · about 12 minutes

Why this exists

Every venue has the moment: someone the owner knows walks up, they are on no list, and they are coming in. The platform's position is that this is fine and entirely normal — and that it must leave a record. So instead of a back door, there is an override: a first-class action that mints a real comp ticket and a real pass, checks the guest in, and writes a row naming who authorised it, why, and on which device.

The difference this makes is only visible weeks later. 'We let forty people in on the list' and 'we let forty people in and here is who authorised each one' are very different conversations to have with an owner or an auditor, and the second one is only possible if the door never had a way to admit somebody silently.

The cap is the interesting piece of design. Overrides run against a per-event house bucket that is exempt from normal allocation, but door staff are limited by a per-event cap, defaulting to ten. Managers and admins are not blocked by the cap — their overrides still count toward it and are audited identically, they just are not refused. The cap is therefore not a security control, it is a forcing function: past ten, a manager has to be physically involved in the decision.

Note what the built system does not let a manager do. Turning overrides off, or changing the cap, is admin-only — the settings panel does not even render for a venue manager. If the cap is wrong for your venue, that is a conversation with an admin before doors, not a fix at the door.

Before you start

  • An employee session and an event selected on the guest-list door screen.
  • The event must be live — a draft, cancelled or completed event refuses an override outright.
  • Overrides must be enabled for the event (they are on by default, cap 10).

Practise with

PersonaEmailPasswordNote
door_staffdoor@club.test door123door shift account: scanner + guest list, nothing else
venue_managermanager@club.test manager123shift lead: implies door_staff, plus intake, sales, guest list, marketing
adminadmin@club.test admin123the only account that can touch money, identity and contracts

Steps 1–5 — Door Staff

their manual →
  1. 1
    Search the guest first. An override is for someone who is genuinely on no list — not a shortcut past a red scan.
    /scanner/guestlist guestlist
    Expected result No matching entry.
    Watch out for If they ARE on the list but the check-in refused, an override is the wrong tool. Read the refusal reason: a revoked pass or a refunded order is not fixed by minting a second ticket.
  2. 2
    Read the override counter on the event card before you start: it shows used against cap, and turns amber with Cap reached — get a manager when you are out.
    /scanner/guestlist guestlist
    Expected result A counter such as overrides 3 / 10.
    Watch out for If the card shows overrides disabled, stop. The endpoint will refuse with 403 overrides_disabled for everyone below admin, and no amount of retrying changes that.
  3. 3
    Press + VIP Walk-in to open the form. Fill in the guest's name, optionally their email, the tier the comp should be issued against, the number of extra guests, and — required — the reason.
    /scanner/guestlist guestlist
    Expected result The Add VIP Walk-in form with name and reason marked required.
    Watch out for The reason field is not decoration and it is server-enforced: a missing name or reason is a 422. Write what you would want to read in three weeks — 'owner guest', 'artist manager', 'comped after door error' — not 'ok'.
  4. 4
    Submit with Issue pass and check in. In one transaction this creates the entry against the house override bucket, mints the comp tickets, issues their passes and checks the party in.
    /api/guestlist/door/{event_id}/override POST guestlist
    Expected result An override id, the entry, the tickets, and the passes with their serials and zones.
    Watch out for Extra guests are capped at five per override. If somebody wants a party of nine through this form, that is a manager decision, not five overrides in a row.
  5. 5
    Understand the two refusals you will actually hit. 409 override_cap_reached means you personally are out of overrides — get a manager, whose own override will go through. 409 event_not_active means the event is not live, and it is checked before any comp inventory is spent.
    Expected result A clear refusal rather than a half-created guest.
    Watch out for The cap only blocks door staff. A manager standing next to you is not exempt from being recorded — their override is audited identically — they are just not refused.

Steps 6–8 — Venue Manager

their manual →

Is not blocked by the door cap, and owns the override report the next morning.

  1. 6
    When the door is capped out, issue the override yourself from the same screen, with your own reason.
    /api/guestlist/door/{event_id}/override POST guestlist
    Expected result The override goes through, with your account recorded as the actor and your role snapshotted on the row.
    Watch out for Your override still increments the used counter. The cap is a signal about the night, so do not treat your exemption as a reason to stop counting.
  2. 7
    The morning after, pull the override report for the event. This is the canonical walk-in record.
    /api/guestlist/events/{event_id}/overrides guestlist
    Expected result One row per override with the guest, the tier, the plus count, the reason, the device and who authorised it.
    Watch out for Comp inventory is real, so overrides show up in the event's sold numbers. If the sales board looks off by a dozen, check here before you go looking for a bug.
  3. 8
    Cross-check the audit tab on the event's guest-list dashboard, which carries the door_override actions alongside every other guest-list action of the night.
    /admin/guestlist/{event_id} guestlist
    Expected result An append-only timeline.
    Watch out for The door-override settings panel is not on this page for you — it renders for admins only. You can see the cap on the door screen; you cannot change it.

Steps 9–10 — Admin

their manual →

Is the only role that can enable, disable or re-cap overrides for an event.

  1. 9
    As an admin, review the per-event override settings: whether overrides are allowed, the door-staff cap, and the default tier comps are issued against.
    /api/guestlist/events/{event_id}/settings guestlist
    Expected result The three settings, with defaults of allowed, cap 10 and no explicit tier.
    Watch out for With no default tier set, overrides fall back to the event's first tier. On an event whose first tier is the expensive one, that quietly comps VIP seats — set the tier before a big night.
  2. 10
    When rehearsing the flow in a demo, reset the override cap rather than burning through it.
    /debug/guestlist/reset-override-cap/{event_id} POST guestlist
    Expected result The usage figures back to a workable state.
    Watch out for Resetting the cap does not delete the overrides you already issued — the audit rows and the comp tickets stay. It only makes the counter stop refusing.

Revoke a comp

Pull a name off the list, kill its pass, and give the capacity back.

Owned by Venue Manager · 7 steps · about 8 minutes

Why this exists

Revoking a comp is a cascade, not a delete, and the cascade is the point. One action marks the entry revoked, revokes every named plus-one under it, marks the underlying tickets revoked, revokes their door passes through the access module, and returns the tier inventory. Any one of those left undone would produce a familiar failure: a name off the list who still scans green, or capacity the venue has silently lost.

The pass revocation is terminal. A revoked credential is blacklisted by serial and by payload hash and its time-code secret is killed, so it fails loudly at the door with Pass Revoked rather than quietly not working. If you revoke the wrong guest, the fix is to invite them again — a new entry, a new ticket, a new pass — not to undo anything.

The one deliberate speed bump is a guest who has already walked in. Revoking a checked-in entry refuses with already_checked_in unless an admin forces it. That is not squeamishness: someone is physically in the building, and a system that lets a list edit quietly contradict the door count is a system whose numbers you cannot use.

Before you start

  • A venue manager or admin session.
  • A live entry on a bucket for the event.

Practise with

PersonaEmailPasswordNote
venue_managermanager@club.test manager123shift lead: implies door_staff, plus intake, sales, guest list, marketing
adminadmin@club.test admin123the only account that can touch money, identity and contracts
door_staffdoor@club.test door123door shift account: scanner + guest list, nothing else

Steps 1–5 — Venue Manager

their manual →
  1. 1
    Find the entry in the Entries table and check its status chip before acting: invited, claimed, ticketed, checked in, revoked or expired.
    /admin/guestlist/{event_id} guestlist
    Expected result The entry with its bucket, plus-ones and status.
    Watch out for An expired entry does not need revoking — it has already released its allocation. Revoking it just adds noise to the audit log.
  2. 2
    Press Revoke and give a reason. This cascades to the plus-ones, the tickets and the passes, and returns the inventory.
    /api/guestlist/entries/{entry_id}/revoke POST guestlist
    Expected result The entry and its plus-ones flip to revoked; the bucket's remaining figure rises again.
    Watch out for 409 already_checked_in means the guest is inside. Only an admin can force past that, and they should have a reason better than tidiness.
  3. 3
    Confirm the allocation actually came back by re-reading the bucket's consumed and remaining figures.
    /api/guestlist/events/{event_id}/buckets guestlist
    Expected result Consumed down by one plus the revoked entry's plus-one allowance.
    Watch out for It drops by the ALLOWANCE, not by the number of named plus-ones — the same asymmetry that governed the allocation on the way in.
  4. 4
    If you revoked in error, do not go looking for an undo. Invite the guest again as a fresh entry.
    /admin/guestlist/{event_id} guestlist
    Expected result A new entry, a new claim link, and eventually a new pass.
    Watch out for The old pass stays dead forever. Revocation is terminal by design, and re-issuing on the same ticket is refused with 409 pass_revoked rather than minting a serial the blacklist would not cover.
  5. 5
    For the softer case — a guest who let their invite expire — use Resend with a rotated token instead of revoking anything. This is the only way to revive an expired entry.
    /api/guestlist/entries/{entry_id}/resend POST guestlist
    Expected result A fresh claim link and the entry back in play.
    Watch out for Resend re-checks the allocation. If the bucket filled up while they were slow, the revival is refused — which is the correct answer, not a bug.

Steps 6 — Door Staff

their manual →

Meets the consequence at the door if the revocation lands after the guest arrives.

  1. 6
    On the night, expect a revoked guest to scan red with Pass Revoked. Read it out and send them to a manager; do not re-scan hopefully.
    /scanner access
    Expected result A red panel reading Pass Revoked.
    Watch out for If the device has been offline since before the revocation, its cached bundle may not know. This is exactly what the live push feed exists to prevent — and why a manager-grade session on the door device matters.

Steps 7 — Admin

their manual →

Is the only role that can force a revocation on a guest who is already inside.

  1. 7
    As an admin, force a revocation on a checked-in guest when it is genuinely necessary — an ejection, say.
    /api/guestlist/entries/{entry_id}/revoke POST guestlist
    Expected result The revocation completes despite the check-in.
    Watch out for The check-in row is not deleted. The night's door count still says they came in, because they did — the audit log is a history, not a current-state summary.

Marketing & Social

Social posts, trigger rules, short links and click-to-revenue attribution.

Compose and publish a social post

Write once, send to five channels, and get a tracked short link per platform for free.

Owned by Venue Manager · 11 steps · about 15 minutes

Why this exists

The publishing model is one post, many channels. You write a single body, choose which of the five platforms it goes to, and the platform materialises a separate delivery target per channel with its own state. That is why a post can end up partial: Instagram and Discord sent, X failed on length, and the post itself honestly reports partial rather than pretending it went out.

The reason the per-platform split matters more than it looks is the tracked link. Each target gets its own short link, stable across retries, carrying the platform as its source. That is the entire basis of the attribution report — without a link per platform there is no way to say which channel actually sold tickets, only that some did.

Placeholders are the other half of composing. Curly-brace tokens like event name, doors time, tier name, price and percentage sold are substituted at publish time, not at write time, so a scheduled post says what is true when it goes out rather than what was true when you typed it. Event placeholders require the post to be attached to an event — using one on a general club post is refused at save.

Length is enforced twice, at save and again at publish, because a channel's limit can bite a scheduled post that was fine when written. A target that violates it at publish time is marked failed with a body-too-long error rather than being silently truncated — the platform will not put half your sentence in public.

Before you start

  • A venue manager or admin session.
  • At least one enabled channel. The seed enables all five with the handle @neonclub.
  • For event placeholders: an event to attach the post to, such as demo-event-0001.

Practise with

PersonaEmailPasswordNote
venue_managermanager@club.test manager123shift lead: implies door_staff, plus intake, sales, guest list, marketing
adminadmin@club.test admin123the only account that can touch money, identity and contracts
host_promoterpromoter@demo.club promoter123scoped host on demo-event-0001; owns the seeded Promoter X comp bucket

Steps 1–8 — Venue Manager

their manual →
  1. 1
    Open the marketing dashboard and check the channel row first: which platforms are enabled, and what has been sent recently.
    /admin/marketing marketing
    Expected result Five channels with their handles and post counts, plus recent and scheduled posts.
    Watch out for A disabled channel is refused at save time with channel_disabled. Check before you write, not after.
  2. 2
    Open the compose page. Pick the event if this is about a specific night — that is what enables event placeholders — or leave it as a general club post.
    Expected result The compose form with an event dropdown, a body box, placeholder buttons, platform checkboxes, link target, media ref and a schedule field.
    Watch out for Attaching the event is what unlocks the placeholders. Using an event placeholder on a general post is refused with placeholder_needs_event.
  3. 3
    Write the body and use the placeholder buttons rather than typing tokens by hand. Tick the platforms. Set the link target — a path such as an event page, or a full URL.
    Expected result A body with placeholders and a target set of platforms.
    Watch out for An unrecognised token is refused with unknown_placeholder. Use the buttons; a typo in a placeholder name is not a typo you will spot by rereading.
  4. 4
    Save it. Save draft parks it, Schedule sets it to publish at the time you entered, and Publish now sends immediately.
    /api/marketing/posts POST marketing
    Expected result A post in draft, scheduled or publishing.
    Watch out for A schedule time in the past is refused with scheduled_in_past — the schedule field wants UTC in ISO form. Length limits are checked here too, against the longest platform you ticked.
  5. 5
    Open the post detail and read the Targets table: one row per platform with its state, its attempts and its external link once sent.
    /admin/marketing/posts/{post_id} marketing
    Expected result Targets as pending, sent, failed or skipped, plus a Tracked links panel with a short link per platform.
    Watch out for Skipped means the channel was disabled after you scheduled. Skipped targets are excluded from the status maths — but a post whose targets are ALL skipped ends up failed.
  6. 6
    If the post came back partial or failed, press Retry failed. This re-sends only the failed and skipped targets and never re-sends one that already went.
    /api/marketing/posts/{post_id}/retry POST marketing
    Expected result The failed targets attempted again; the sent ones untouched.
    Watch out for Retry is only available on partial or failed posts — anything else is 409 post_not_retryable. The short links are stable across retries, so your attribution does not fragment.
  7. 7
    To stop a scheduled post that is no longer true, cancel it rather than editing it into something unrelated.
    /api/marketing/posts/{post_id}/cancel POST marketing
    Expected result Status cancelled.
    Watch out for You cannot delete it — deletion is admin-only, and only for drafts, cancelled and failed posts. Cancelling is the manager-grade stop button.
  8. 8
    Edit a post that has not gone out yet — drafts and scheduled posts only.
    /api/marketing/posts/{post_id} PATCH marketing
    Expected result The updated body, platforms or schedule.
    Watch out for 409 post_not_editable means it is publishing or published. Public is public; the fix is a new post, not a rewrite of history.

Steps 9 — Host / Promoter

their manual →

Can read the posts for their own event, and will ask you why theirs has not gone out.

  1. 9
    As a promoter, open the marketing tab for your own event to see what has gone out for your night.
    /admin/marketing/events/{event_id} marketing
    Expected result Posts and rules for that event.
    Watch out for You must be looking at your own event. Hosts have no view across the venue's marketing, only their own night's.

Steps 10–11 — Admin

their manual →

Owns channel settings and post deletion, and can force a publish outside the schedule.

  1. 10
    As an admin, enable or disable a channel and set its handle when the venue's accounts change.
    /api/marketing/channels/{platform} PATCH marketing
    Expected result The channel updated; the dashboard's channel row reflects it immediately.
    Watch out for Disabling a channel does not cancel posts already scheduled to it — those targets turn into skipped at publish time. Check the schedule before you disable.
  2. 11
    Force-publish a draft or scheduled post when you are rehearsing the flow, ignoring its schedule.
    /debug/marketing/posts/{post_id}/force-publish POST marketing
    Expected result The post publishes through the real pipeline.
    Watch out for This goes through the genuine publish routine and records genuine outbound traffic. Do it on a demo post, not on the real weekend announcement.

Build a trigger rule

Post automatically when a tier hits a threshold, cascades or sells out — exactly once.

Owned by Venue Manager · 12 steps · about 16 minutes

Why this exists

Trigger rules exist because the moments worth posting about are the moments nobody is watching for. A tier crossing ninety per cent, a cascade unlocking the next release, a night selling out — these happen at odd hours and they are exactly when urgency is real. A rule turns a sales event into a post without anyone refreshing a dashboard.

The mechanism is worth knowing because it explains the timing. Events publish state changes into an outbox; the marketing tick consumes that outbox, evaluates percentage and countdown rules, and publishes anything due. So rules fire on the tick, not the instant — a rule is prompt, not instantaneous, and that is a design choice in favour of one consistent transaction over five racing ones.

Dedupe is the part that saves you from embarrassment. Every firing is recorded against a trigger reference and the pair is unique, so redelivery of the same underlying event cannot post twice. On top of that, fire once means at most once ever, while leaving it off means once per distinct trigger — so a percentage rule can fire again if the tier reopens after refunds. Choose deliberately: a sell-out announcement should be once ever, a last-release nudge probably should not.

One operational rule to learn now: if a rule fires and its post fails, retry the POST, not the rule. The firing record stays, so re-firing is blocked by the dedupe — which is correct. The post is the thing that failed and the post is the thing to fix.

You do not have to be looking at the page to find out. Since plan 608 a post that ends failed or partial sends one message to the staff channel naming the event, the rule and the reason — once per failure, and again if a retry fails, never on a clean publish. Retry from the event's marketing tab, which is the page the notice is about; the sentence on that tab is the same one a host reads (“failed — not published — discord: the channel was switched off”), and only the Retry control differs by role.

Before you start

  • A venue manager or admin session.
  • An event with tiers. The seed ships three rules on demo-event-0001 already.
  • At least one enabled channel for the rule's post to go to.

Practise with

PersonaEmailPasswordNote
venue_managermanager@club.test manager123shift lead: implies door_staff, plus intake, sales, guest list, marketing
adminadmin@club.test admin123the only account that can touch money, identity and contracts
membermember@club.test member123holds a seeded pass and membership card to practise scans against

Steps 1–8 — Venue Manager

their manual →
  1. 1
    Open the event's marketing tab. This is a standalone page, reached from the event, not a panel inside the event admin screen.
    /admin/marketing/events/{event_id} marketing
    Expected result The event's posts and rules, each rule with a live preview of its current condition.
    Watch out for The seeded event already has three rules — percentage sold at ninety, event sold out, and a cascade on the VIP Lounge tier. Read those before writing a fourth that duplicates one.
  2. 2
    Create a rule. Name it, then pick the trigger: percent sold, minutes before sale, tier cascade, tier sold out or event sold out.
    /admin/marketing/events/{event_id}/rules/new marketing
    Expected result The form revealing only the fields that trigger needs.
    Watch out for The trigger dictates the configuration. A percentage rule without a threshold is refused with threshold_required; a countdown rule without minutes with minutes_required; and supplying a threshold to a trigger that has no use for one is refused with config_not_allowed.
  3. 3
    Decide the scope: leave the tier blank for an event-level rule, or pick a tier to watch just that release.
    /admin/marketing/events/{event_id}/rules/new marketing
    Expected result A rule bound to the whole event or to one tier.
    Watch out for A tier from a different event is refused with tier_event_mismatch, and some triggers do not accept a tier at all — tier_not_allowed. The form is telling you the trigger's shape, not being awkward.
  4. 4
    Write the template body with placeholders — the same tokens as a manual post — pick the platforms, and set the link target.
    /admin/marketing/events/{event_id}/rules/new marketing
    Expected result A template that will render with live values at firing time.
    Watch out for Placeholder and channel validation happen here, not at firing time. An unknown placeholder or a disabled channel is refused at save, which is exactly when you want to hear about it.
  5. 5
    Set fire once deliberately, then save. Fire once means at most once ever; leaving it off means once per distinct trigger occurrence.
    /api/marketing/events/{event_id}/rules POST marketing
    Expected result The rule listed as active with a condition preview.
    Watch out for A sell-out announcement firing twice because the tier reopened after a refund is a genuinely bad look. When in doubt on a milestone, fire once.
  6. 6
    After the rule has had a chance to fire, read its firing history.
    /api/marketing/rules/{rule_id}/firings marketing
    Expected result Firings with their trigger reference and the post each produced.
    Watch out for A firing with no post attached is a skip — usually a missed drop window on a countdown rule. That is recorded rather than silently dropped, so you can see the rule did evaluate.
  7. 7
    Edit, deactivate or delete a rule that is no longer right. Deactivating leaves its history intact.
    /admin/marketing/rules/{rule_id}/edit marketing
    Expected result The rule updated, inactive, or gone from the list.
    Watch out for Deactivating does not delete the firings, and re-activating does not re-fire anything already recorded. If you genuinely need it to fire again, that is an admin reset. Added 2026-08-30, because the red Delete sits beside these two and this step never mentioned it: Delete is ALSO soft. It hides the rule from the list and leaves every firing and every post it ever produced exactly where they are, readable through the rule's firings endpoint. So the choice between Deactivate and Delete is about whether you expect to switch it back on, not about whether you keep the history — neither loses it.
  8. 8
    When a rule's post comes back failed, open the post and retry it. Do not go back to the rule.
    /admin/marketing/posts/{post_id} marketing
    Expected result The failed targets re-sent from the post.
    Watch out for This is the single most common mistake with rules. The firing succeeded; the delivery did not. Re-firing is blocked by dedupe, and correctly so.

Steps 9 — Member

their manual →

Is the person whose purchase crosses the threshold that fires the rule.

  1. 9
    From the customer side, buy into the tier until it crosses the threshold — this is what actually trips a percentage rule.
    /events/{event_id} events
    Expected result The tier's sold percentage rising past your threshold.
    Watch out for Reserved is not sold. A tier full of open checkout holds has not crossed anything yet — the rule watches committed sales.

Steps 10–12 — Admin

their manual →

Owns the tools that fire a rule on demand and reset its firing history for a re-test.

  1. 10
    As an admin, fire a rule immediately through the real pipeline to test it — this works even on an inactive rule.
    /debug/marketing/rules/{rule_id}/force-fire POST marketing
    Expected result A firing marked as forced, and a real post.
    Watch out for It publishes for real to the mock channels and writes real outbound traffic. Use a demo event.
  2. 11
    Reset a rule's firings when re-testing a fire-once rule. This is the sanctioned way to make it fire again.
    /debug/marketing/rules/{rule_id}/reset POST marketing
    Expected result Firing history cleared and the last-fired stamp removed.
    Watch out for Do not reach into the tables to do this by hand. The reset clears the firings AND the stamp — clearing one without the other leaves a rule that behaves inexplicably.
  3. 12
    Run the marketing tick manually when you do not want to wait for the scheduler.
    /debug/marketing/tick POST marketing
    Expected result A report of outbox rows consumed, rules fired and skipped, and posts published.
    Watch out for The tick is idempotent for the same clock time. Running it twice in a row is safe and is the fastest way to prove a rule did not fire because its condition is false, not because the tick is stuck.

Configure marketing channels and trigger rules

Own the channels, govern the automation, and force-fire a rule safely before an on-sale.

Owned by Admin · 12 steps · about 22 minutes

Why this exists

Marketing automation on this platform is a small, deliberate machine: five fixed channels, a rules engine that watches the event lifecycle, and short links that make the results measurable. This workflow is the part only an admin can do — the channels themselves, the debug controls and the forced firings — with the venue manager present doing the day-to-day composing, because that is how the work is actually split.

Channels are configuration, not content. There are exactly five, they cannot be added, and only an admin may change a handle or disable one. Disabling matters more than it looks: a disabled channel refuses new posts at creation time with a clear error, and a channel disabled after a post was scheduled causes that target to be skipped at publish time rather than failing. A post whose only targets were skipped ends up failed — which is the honest outcome, because nothing was published.

Rules fire from real events, not from a timer. The engine consumes an outbox that the events module writes to, plus percentage-sold and minutes-before-sale conditions evaluated on each tick. Every firing is deduped by a trigger reference, so a replayed outbox row cannot post twice. Fire-once means at most once ever; otherwise a rule fires once per distinct trigger.

A rule post that fails is a post problem, not a rule problem. The firing stays recorded, so retry the post; refiring the rule would either be deduped or produce a duplicate announcement. That distinction is the one thing to take away from this workflow.

The debug force-fire runs a rule through the real pipeline immediately, even if it is inactive, which is how you rehearse an on-sale announcement without waiting for the on-sale. Its companion reset deletes the rule's firings so a fire-once rule can be tested twice — the sanctioned way, rather than editing rows.

Before you start

  • An admin session for the channel and debug steps; a venue manager can do the composing half.
  • An event to hang rules on — the seed ships rules on demo-event-0001.
  • Remember that every published post is a real call to a mock social adapter and is recorded in the wire log.

Practise with

PersonaEmailPasswordNote
adminadmin@club.test admin123the only persona that can configure channels or force-fire a rule
venue_managermanager@club.test manager123composes posts and writes rules day to day
host_promoterhost@club.test host123scoped host on demo-event-0001 — read-only on its marketing

Steps 1–3 — Admin

their manual →
  1. 1
    Open the marketing dashboard: channels, recent posts, what is scheduled, and the channel report.
    /admin/marketing marketing
    Expected result The five channels with their handles and whether they are enabled, plus post activity.
    Watch out for There are exactly five channels and there is no add button. If somebody asks for a sixth network, that is a code change, not a configuration one.
  2. 2
    Pull the channels as JSON and note the per-channel count of posts sent.
    Expected result Each platform with its handle, enabled flag and volume.
    Watch out for Both admins and venue managers can read this. Only admins can change it.
  3. 3
    Change a handle or disable a channel you are not using this season.
    /api/marketing/channels/{platform} PATCH marketing
    Expected result The updated channel.
    Watch out for Disabling refuses NEW posts targeting it with 422 channel_disabled. Posts already scheduled to it are skipped at publish time instead — and a post whose only targets were skipped ends failed. Disable before people schedule, not after.

Steps 4–6 — Venue Manager

their manual →

They write the rules and the copy; the admin owns the channels the copy goes out on.

  1. 4
    As the manager, open the event's marketing tab: its rules, its posts and its funnel in one place.
    /admin/marketing/events/{event_id} marketing
    Expected result Rules with live condition previews, the event's posts, and the click-to-order funnel.
    Watch out for This is a standalone page, deliberately not injected into the event admin template. Link to it from the event rather than expecting it to appear there.
  2. 5
    Open the rule form and choose a trigger: a percentage sold, a number of minutes before sale, a tier cascade, a tier sell-out or an event sell-out.
    /admin/marketing/events/{event_id}/rules/new marketing
    Expected result A form whose required fields change with the trigger you pick.
    Watch out for Placeholders in the copy are checked against what the trigger can actually provide. An unknown placeholder, or an event placeholder on a rule with no event, is refused at creation rather than rendering as an empty string on a public post.
  3. 6
    Create the rule, choosing carefully whether it fires once ever or once per distinct trigger.
    /api/marketing/events/{event_id}/rules POST marketing
    Expected result 201 with the rule and its preview of when it would fire.
    Watch out for Threshold rules need a threshold and drop rules need a minutes value; the wrong combination is a 422 naming the missing field. Fire-once is the safer default for anything that reads like an announcement.

Steps 7–10 — Admin

their manual →
  1. 7
    Rehearse it: force the rule to fire now, through the real pipeline, before the real condition is anywhere near true.
    /debug/marketing/rules/{rule_id}/force-fire POST marketing
    Expected result A firing recorded with a forced trigger reference, and a real post published to the enabled channels.
    Watch out for This publishes for real to the mock adapters and it works even on an inactive rule. Rehearse on a test event, not on the one that goes on sale on Friday.
  2. 8
    Read the rule's firing log and see what it produced, including firings that were skipped.
    /api/marketing/rules/{rule_id}/firings marketing
    Expected result One entry per firing, with its trigger reference and the post it created, if any.
    Watch out for A firing with no post and a skipped note means the window had already passed — the rule fired and correctly decided not to announce. That is not a failure to investigate.
  3. 9
    Reset the rule's firings when you need to test a fire-once rule a second time.
    /debug/marketing/rules/{rule_id}/reset POST marketing
    Expected result The firings are deleted and the last-fired stamp cleared.
    Watch out for This is the sanctioned way to re-test. Editing the firing rows in the data suite to achieve the same thing will leave the dedupe index and the rule disagreeing.
  4. 10
    Run a marketing tick by hand to see the whole pipeline in one call: consume the outbox, evaluate the rules, publish what is due.
    /debug/marketing/tick POST marketing
    Expected result A report of rows consumed, rules fired and skipped, posts published, and per-target sends and failures.
    Watch out for The outbox is single-consumer: every row read is stamped, matched or not. Marketing runs after the events lifecycle in a tick, which is why a cascade and its announcement can land together.

Steps 11 — Host / Promoter

their manual →

They may read their own event's marketing and firings, and nothing else — the scope is the event.

  1. 11
    As the promoter, open your own event's marketing tab and confirm what you can see.
    /admin/marketing/events/{event_id} marketing
    Expected result Your event's posts, rules and funnel, read-only.
    Watch out for Another event id is a 403. A host grant is a grant over one event's marketing, not over the venue's.

Steps 12 — Admin

their manual →
  1. 12
    Finish in the social wire log and confirm what was really sent, to which platform, with which text.
    Expected result Outbound social calls joined to their posts.
    Watch out for The text sent always carries the SHORT link, never the expanded tracked URL. If you see a raw long URL in a post, something bypassed the link builder.

Trace a click to revenue

Follow a short link from the tap through the order and read the channel report honestly.

Owned by Venue Manager · 10 steps · about 14 minutes

Why this exists

Attribution here is deliberately simple and deliberately honest, and knowing exactly how simple it is stops you over-reading the numbers. Every published post gets a tracked short link per platform. When somebody taps it the platform records the click, drops a visitor cookie and an attribution cookie, and redirects them to the tagged destination. When an order is later paid, the attribution cookie is read and, if the click is inside the attribution window, a conversion row is written.

Three consequences follow, and every argument about these reports comes back to one of them. First, last click wins — the redirect overwrites the cookie, so a customer who taps Instagram then X is credited to X. Second, one conversion per order, first write wins, so revenue is never double-counted across channels. Third, the recorded revenue is a gross snapshot at attribution time and refunds do not remove it. A cancelled event does not empty this report, and that is a documented choice, not a bug.

What the report is genuinely good for is comparing channels against each other over the same window: posts sent, links, clicks, unique clicks, orders, revenue and conversion rate, per platform. What it is not good for is a net revenue figure — that lives in the ledger, which is admin-only, and for good reason.

These reports are computed live from the click and conversion tables with no denormalised counters anywhere. There is nothing to rebuild and nothing that can silently drift; a number that looks wrong is a question about the underlying rows, not about a cache.

Before you start

  • A venue manager or admin session.
  • A published post with tracked links. The seed publishes a three-platform post on demo-event-0001 with simulated clicks.

Practise with

PersonaEmailPasswordNote
venue_managermanager@club.test manager123shift lead: implies door_staff, plus intake, sales, guest list, marketing
adminadmin@club.test admin123the only account that can touch money, identity and contracts

Steps 1–7 — Venue Manager

their manual →
  1. 1
    Open the marketing reports and filter to the event and the date range you care about.
    Expected result A Channels table with posts sent, links, clicks, unique, orders, revenue and conversion rate, plus a Rule effectiveness table underneath.
    Watch out for Compare like with like. A channel that only received two posts in the window will have a flattering conversion rate off almost no traffic.
  2. 2
    Pull the same figures as data when you need them elsewhere — the endpoint takes the event, a date range, and a CSV format option.
    Expected result Per-platform rows and a totals row.
    Watch out for The event's own scoped host can read this too, but only for their own event id. If a promoter asks for the venue-wide numbers, that is a no.
  3. 3
    For one night, read the event funnel report and its per-rule breakdown to see whether automated posts or manual ones did the work.
    /api/marketing/reports/events/{event_id} marketing
    Expected result A funnel plus a rule-by-rule breakdown.
    Watch out for Rule effectiveness compares automated posts against each other. It does not prove causation — a sell-out rule fires because the night was already selling.
  4. 4
    List the tracked links to find the short code for a specific post and platform.
    Expected result Links with their short code, campaign, source platform and the post they belong to.
    Watch out for The link is stable per post and platform, including across retries. Two codes for what looks like one post usually means two posts.
  5. 5
    Tap a short link yourself in a fresh browser to see the customer's path: the click is recorded, cookies are set, and you are redirected to the tagged destination.
    /l/{short_code} marketing
    Expected result A redirect to the event page with the campaign parameters attached.
    Watch out for Your own tap is a real click in the report. Use a private window and expect to see yourself in the unique-click count.
  6. 6
    Close the loop on a real conversion: find the order the report credits and confirm the amount against the recorded revenue.
    /admin/orders/{order_id} payments
    Expected result An order whose gross matches the attributed figure.
    Watch out for If the order was later refunded the attributed revenue still stands. That is the gross-snapshot rule — the marketing report is not an accounting surface.
  7. 7
    Know where the real numbers live and that you cannot reach them. Net revenue, refunds and the double-entry ledger are admin-only. Quote this report as marketing performance, never as takings.
    Expected result The right report used for the right question.
    Watch out for The 403 on the ledger is deliberate. If somebody needs a revenue figure for a settlement, that is an admin's number to give, not yours to estimate from clicks.

Steps 8–10 — Admin

their manual →

Owns the per-link forensics and the simulators that let you prove the chain end to end.

  1. 8
    As an admin, run the single-link forensics when a manager disputes a figure: the link, every click on it, and every conversion attributed to it.
    /debug/marketing/links/{short_code} marketing
    Expected result The full chain for one short code.
    Watch out for Clicks outside the attribution window are recorded but never convert. That gap between clicks and orders is usually the answer to 'why is our conversion rate so low'.
  2. 9
    Simulate clicks against a short code when demonstrating or testing the chain, optionally as a repeat visitor.
    Expected result Click ids you can then attribute an order to.
    Watch out for Repeat clicks from the same visitor count once as unique and every time as total. That is exactly the distinction the report's two columns exist to show.
  3. 10
    Attribute an order to a click through the real attribution path and read the reason it gives back.
    Expected result Whether a conversion was written, and why not if not.
    Watch out for The reasons are the whole lesson: unknown click, expired window, duplicate order. Attribution never raises an error — it silently declines, which is why proving it needs this endpoint.

Report on marketing attribution

Follow one click through to revenue, and understand exactly what the number does and does not mean.

Owned by Admin · 10 steps · about 18 minutes

Why this exists

Attribution here is deliberately simple, and understanding the simplifications is the difference between a useful report and a misleading one.

Every post gets a tracked short link per platform. The public redirect records the click, flags whether this visitor has clicked this link before, sets two cookies — a long-lived visitor id and a short-lived attribution id — and sends the person on to the tagged destination. When an order is later paid, the payments module hands the attribution cookie to marketing, and if the click is recent enough a conversion row is written. CORRECTED 2026-08-30: that last handshake is not wired. The cookie is set on every click and read by nothing — payments does not import this module, and the only callers of attribute_order are the seed, the debug simulate-conversion endpoint and the test suite. So outside a seeded database there are no conversions at all, and every orders / revenue / conversion-rate figure in the reports below is structurally zero. Plan 647 wires it; read the rest of this workflow knowing which half runs.

Four rules follow, and each one is a caveat you should say out loud when you present the numbers. Last click wins, because the redirect overwrites the cookie. The window is finite — a click older than the configured number of days attributes nothing. One conversion per order, first write wins, enforced by a unique constraint. And revenue is a gross snapshot taken at attribution time: a later refund does not remove the conversion or reduce the figure.

Attribution is also never allowed to break a purchase. The attribution call cannot raise — an unknown click, an expired window, a duplicate order, even a missing table all return quietly false. Nobody's checkout has ever failed because a marketing cookie was odd, and that is a deliberate ordering of priorities.

The reports are pure read-time SQL over those rows. There are no denormalised counters anywhere, so there is nothing to drift and nothing to rebuild — a report is always a straight answer about what is in the tables right now.

Before you start

  • An admin session for the debug forensics; a venue manager can read the same reports.
  • At least one published post with short links (the seed ships one with simulated clicks).
  • An honest willingness to quote the caveats along with the conversion rate.

Practise with

PersonaEmailPasswordNote
adminadmin@club.test admin123reads the reports and the per-link forensics
venue_managermanager@club.test manager123reads the same reports; the debug forensics are admin-only

Steps 1 — Admin

their manual →
  1. 1
    List the tracked links and pick one to follow: note its short code, the post and platform it belongs to, and its campaign tags.
    Expected result Links with their short codes, targets and UTM parameters.
    Watch out for A link is stable per post and platform. Retrying a post reuses the same link, which is why retries do not fragment your reporting.

Steps 2 — Customer (Guest)

their manual →

The click that starts the whole chain is made by a stranger with no account at all.

  1. 2
    Open the short link the way a stranger would, from a phone, with no account and no session.
    /l/{short_code} marketing
    Expected result A redirect to the tagged destination, a click recorded, and two cookies set.
    Watch out for This is the only public route in the whole workflow, and it works with no login by design — a marketing link that demanded a session would convert nobody. An unknown code is a 404 that records nothing and sets no cookies.

Steps 3–8 — Admin

their manual →
  1. 3
    Look at that single link's forensics: the link row, every click on it, and every conversion attributed to it.
    /debug/marketing/links/{short_code} marketing
    Expected result The whole chain for one link in one response.
    Watch out for Admin-only. This is the tool for 'why did this campaign report nothing'. CORRECTED 2026-08-30: it used to say that clicks with no conversions is a landing page problem and not a tracking one. Until plan 647 lands it is ALWAYS a tracking problem — nothing reads the attribution cookie, so no purchase can ever produce a conversion. Read clicks-with-no-conversions as the known gap first, and only look at the landing page once the chain is wired.
  2. 4
    Open the channel report and read it per platform: posts published, links, clicks, unique clicks, orders, revenue and conversion rate, plus totals.
    Expected result One row per platform, filterable by event and date range.
    Watch out for Unique clicks are per visitor per link. The gap between clicks and unique clicks is people re-opening the same link, not new reach.
  3. 5
    Pull the same report as JSON, or ask for CSV when somebody wants it in a spreadsheet.
    Expected result The per-platform figures and totals in the format you asked for.
    Watch out for A scoped host may call this only for their own event id. If you are building a shared report, remember that the same endpoint answers differently depending on who calls it.
  4. 6
    Switch from channels to one event's funnel, broken down per rule.
    /api/marketing/reports/events/{event_id} marketing
    Expected result The funnel from clicks through to attributed orders and revenue, plus which rules produced which.
    Watch out for Rule-driven posts and hand-written posts appear side by side. A rule that produced clicks but no orders is worth more attention than one that produced neither.
  5. 7
    In training, simulate clicks on a link so the report has something in it, giving a visitor id and a count.
    Expected result The created click ids.
    Watch out for Simulated clicks are indistinguishable from real ones in the reports. Do this on a demo database only, or you will be explaining your own traffic to somebody next quarter.
  6. 8
    Attribute one of those clicks to an order through the real attribution path, with a revenue figure.
    Expected result A written flag and, when it is false, the reason: unknown click, expired window, duplicate order.
    Watch out for The reason codes are the fastest way to learn the rules. Try it with a click older than the window and read what it tells you, rather than trusting this page.

Steps 9 — Venue Manager

their manual →

They own the campaign and read the same report — without the debug tooling.

  1. 9
    As the manager who ran the campaign, read the same report — and notice you get the numbers without the debug tooling.
    Expected result The identical report page.
    Watch out for Every /debug route in this workflow is 404 or 403 for you. If a number looks wrong, the escalation is to an admin, and this page is the evidence you bring.

Steps 10 — Admin

their manual →
  1. 10
    Before you present anything, say the caveats out loud: last click wins, the window is finite, one conversion per order, and revenue is a gross figure that refunds never reduce — and, until plan 647 lands, the biggest one of all: no real purchase is attributed at all, so a non-zero conversion figure on this platform came from the seed or from a debug simulation.
    Expected result A report you can defend rather than one you have to withdraw.
    Watch out for The commonest mistake is comparing attributed revenue to actual takings. They are different numbers measuring different things, and the gap is refunds, unattributed sales and the attribution window — not an error.

Compliance & Tax

The rate matrix, quarterly filing packages, approvals and print-and-mail.

Run the quarterly tax filing

Close a quarter: check liability, generate the package, approve it, and mail it.

Owned by Admin · 8 steps · about 25 minutes

Why this exists

Tax is the part of the platform with a deadline attached to a physical envelope, so it is built to be boring and repeatable rather than clever.

The important design idea is that the filing is derived, never typed. Every taxable sale accrues its tax at the moment of the sale, line by line, with the rate snapshotted onto the accrual — so a rate change next year cannot retroactively alter last quarter's numbers. Refunds post negative reversal rows, prorated and capped so cumulative reversals can never exceed the original accrual. The quarterly package is simply the sum of those rows.

The second idea is the approval gate. A generated package is a draft with a watermark. It becomes a real filing only when an authorised approver approves it — from the dashboard or from a Telegram card — and only then is it rendered, hash-sealed and handed to the mail adapter. The stored document bytes and their hash never change afterwards; the watermark is applied when the document is served, not when it is stored.

This entire workflow is admin-only. A venue manager gets a 403 on every route in it, including the read-only ones.

Before you start

  • An admin session — every route in this workflow is admin-gated.
  • Accrued tax in the period you are filing (the seed ships some).
  • The four agency records configured, since a package allocates across exactly four.

Practise with

PersonaEmailPasswordNote
adminadmin@club.test admin123the only persona that can open any of these routes

Steps 1–8 — Admin

their manual →
  1. 1
    Open the tax office and read the quarter-to-date liability tiles and the reconciliation badge.
    Expected result QTD liability by jurisdiction plus a reconciliation status.
    Watch out for If reconciliation is red, stop. It means the accrual rows and the ledger disagree, and filing on top of that just posts the disagreement to an agency. QUALIFIED 2026-08-30: on every install today it is ALREADY red for one known reason — payments credits the tax liability accounts on every sale and writes no accrual row, so the whole ledger movement reads as drift. So 'stop' here means read reconcile-tax-accruals first and learn that baseline; it does not mean this platform can never file. What you must not do is treat a red badge as normal without knowing which red it is. Plan 680 closes the gap so the badge can mean something again.
  2. 2
    Pull the same liability figures from the API and check them against the ledger's tax liability accounts.
    Expected result Liability broken out by period and agency.
  3. 3
    Run the filing scheduler for the quarter you are closing (in training and demo environments — in production the scheduled tick does this).
    Expected result A filing package generated for the period, in draft.
    Watch out for It is idempotent per period: running it again returns the existing package rather than making a second one. Debug routes like this one are admin-only and vanish entirely when debug endpoints are disabled.
  4. 4
    Open the period and read the generated package: the totals, the documents, and the audit trail.
    /admin/tax/periods/{period_id} tax
    Expected result The package with its state-gated actions and DRAFT-watermarked documents.
  5. 5
    Set the allocation across the four agencies.
    /api/tax/packages/{package_id}/allocations PUT tax
    Expected result The allocation saved against the package.
    Watch out for Exactly four agencies, each at least zero. The endpoint rejects anything else rather than filing a package that does not add up.
  6. 6
    Send the package for approval. A Telegram card goes out with inline approve and reject buttons.
    /api/tax/packages/{package_id}/send-approval POST tax
    Expected result The package moves to awaiting approval and the outbound call is recorded.
    Watch out for The Telegram route is a convenience, not a second authority: an unauthorised approver pressing the button is answered with 'Not authorized' and nothing changes.
  7. 7
    Approve the package.
    /api/tax/packages/{package_id}/approve POST tax
    Expected result Documents are rendered and hash-sealed, and physical mail is submitted for each agency with a tracking number.
    Watch out for This is the irreversible one. Approving mails the filing. If the mail adapter partially fails you get a 502 and the package is left in mail_submitted — retry the mailing, do not re-approve; Retry mail sends only the envelopes that failed, so nobody gets two. Added 2026-08-30: know the way OUT before you press it, because this step is the last moment there is one. While the package is draft or awaiting approval the period page carries a red Cancel, which stops the filing dead, and Regenerate, which supersedes this revision and compiles a fresh one — and a cancelled package can still be regenerated, so cancelling is not the end of the quarter. Regenerating for a numbers change is reconcile-tax-accruals; both controls are gone the moment you approve.
  8. 8
    Close the loop: check the tax liability accounts on the trial balance against what you just filed.
    /admin/ledger ledger
    Expected result A balanced trial balance whose liability accounts match the filed figures.
    Watch out for If they do not match, the answer is a compensating ledger entry and a note — never an edit. The ledger refuses updates at the database level.

Approve a filing from Telegram

Send the approval card, understand who may press it, and recover when the mail fails.

Owned by Admin · 11 steps · about 20 minutes

Why this exists

The quarterly filing has a deadline attached to a physical envelope, and the person who must approve it is rarely at a desk. So the approval gate is reachable from a Telegram card with inline buttons as well as from the dashboard. This workflow is about that path, and specifically about the fact that convenience is not authority.

Pressing the button in a chat does not approve anything by itself. The callback maps the Telegram user id to a platform user through a configured map, and then requires that user to hold an active admin grant. An unmapped or non-admin presser is answered "Not authorized", an audit row is written, and nothing changes. The chat is a remote control, not a second set of credentials.

Approval is the irreversible step. It flips the package state under a rowcount-guarded update — so two people pressing approve at the same moment produce exactly one approval and one "already processed" — then renders and hash-seals the documents and hands four envelopes to the print-and-mail adapter. Four, not five: the two federal forms share one envelope to the same agency.

And the chat can refuse for a second reason that has nothing to do with who you are. If the accrual rows and the ledger disagree for the period, the callback answers with the drift figure and sends you to the admin page instead of filing. The gate is the same one the page has; what differs is that the page can carry an acknowledgement and a card cannot, so the irreversible act is moved to the surface that can record why it was taken. It does not block the filing — the page is one tap away.

Partial mail failure has its own state, and you must recognise it. If some envelopes go and others do not, you get a 502 and the package is left in mail-submitted with the failed rows visible. The correct response is to retry the mailing. Approving again is not possible and trying is the wrong instinct — the filing is already approved; it is the post that failed.

Documents are immutable once stored. The DRAFT or APPROVED watermark is injected when a document is served, never when it is stored, and the stored hash is re-verified on every read. A tampered document fails loudly rather than printing.

Before you start

  • An admin session — every tax route including the read-only ones is admin-only.
  • A generated package in pending approval (the seed ships one for the launch quarter).
  • The Telegram admin map configured, or the approve press will be refused as unauthorised.

Practise with

PersonaEmailPasswordNote
adminadmin@club.test admin123can approve from the dashboard
admintaxadmin@demo.club tax-admin-123the seeded tax admin — a second admin to test the mapping with

Steps 1–11 — Admin

their manual →
  1. 1
    Open the tax office and find the period whose package is waiting for approval.
    Expected result Quarter-to-date liability tiles, a reconciliation badge, and the list of periods with their package states.
    Watch out for If the reconciliation badge is red, read the reconciliation workflow before you approve anything. Filing on top of a disagreement posts the disagreement to an agency.
  2. 2
    Open the period. Read the totals, the five documents, the allocation across the four agencies, the mailings panel and the audit trail.
    /admin/tax/periods/{period_id} tax
    Expected result State-gated actions: only the buttons legal from the current status are live.
    Watch out for The documents are watermarked DRAFT until approval. That watermark is applied when the document is served, so a saved PDF from before approval will always say DRAFT — that is correct, not stale.
  3. 3
    Set or correct the allocation across the agencies before sending for approval.
    /api/tax/packages/{package_id}/allocations PUT tax
    Expected result The allocation saved against the package.
    Watch out for Exactly four agencies, each at or above zero. Anything else is rejected rather than filed. This is the last comfortable moment to change the numbers.
  4. 4
    Send the package for approval. A Telegram card goes out with inline approve, view and edit buttons.
    /api/tax/packages/{package_id}/send-approval POST tax
    Expected result The package moves to pending approval and the outbound call is recorded.
    Watch out for Sending again re-sends the card; it does not create a second package. If the chat is misconfigured you will see the failure in the wire log rather than on this page.
  5. 5
    Confirm the card actually left, filtering the wire log to the Telegram service.
    Expected result A send-message call with the package as its correlation id.
    Watch out for Correlation id is the package id. That is how you find every message about one filing, including the later edits that change the card as the state moves.
  6. 6
    Check the Telegram configuration: the chat, the webhook secret and the map from Telegram user ids to platform users.
    Expected result The settings, with the secret masked.
    Watch out for An approver who is not in the map cannot approve, no matter how senior they are. Being in the map is also not enough — the mapped user must hold an active admin grant at press time.
  7. 7
    Reproduce the button press deliberately: post the callback the way Telegram would, with the shared secret header and the approve callback data for your package.
    Expected result The package is approved, documents are rendered and hash-sealed, and four envelopes are submitted with tracking numbers.
    Watch out for A wrong or missing secret header is 403 — and an UNCONFIGURED secret leaves through the same refusal with the same body, on purpose, so the endpoint cannot be probed for whether it has a key. Malformed callback data is 400. An unauthorised presser gets a 200 with the answer 'Not authorized' — deliberately not an error, because the sender is Telegram, not the attacker. ADDED 2026-08-30, a fourth outcome this list omitted and the one you are likeliest to meet: if the accruals and the ledger disagree, an authorised press is REFUSED too, with the answer 'Ledger drift $X — approve from the admin page' and a link. That is not a bug and not an authority problem. A chat card has nowhere to record why you filed over a known disagreement, so the acknowledgement has to happen on the page.
  8. 8
    Use the debug replay when you want to drive the same logic without constructing a signed webhook body.
    Expected result The same handler, the same result.
    Watch out for Replaying an approve on a package that is already approved answers 'already processed' rather than approving twice. That idempotency is the property to check, not to work around.
  9. 9
    Go back to the period and read the mailings panel: one row per envelope, each with a status and a USPS tracking number.
    /admin/tax/periods/{period_id} tax
    Expected result Four mailings, tracked.
    Watch out for Four envelopes for five documents — the two federal forms travel together to the same agency. Counting five and finding four is not a missing mailing.
  10. 10
    If any envelope failed, retry the mailing rather than reaching for the approve button again.
    /api/tax/packages/{package_id}/retry-mail POST tax
    Expected result The failed envelopes are resubmitted; successful ones are not re-sent.
    Watch out for Approve is a one-way door and will refuse a second attempt with 409. A package sitting in mail-submitted is not un-approved, it is un-posted.
  11. 11
    Open one filed document and notice the watermark and the hash.
    /api/tax/documents/{doc_id} tax
    Expected result The stored HTML with an APPROVED watermark injected at serve time, and its hash re-verified before it is handed to you.
    Watch out for A hash mismatch is a 500 with a specific code, and it means the stored bytes changed after they were sealed. Treat that as a security incident, not a rendering bug.

Maintain the tax rate matrix

Add and end-date rates without ever rewriting what last quarter was taxed at.

Owned by Admin · 8 steps · about 18 minutes

Why this exists

The rate matrix is a small table with a strict discipline: for each jurisdiction and category there is at most one rate in force at any instant, and a rate is immutable once created. You may end-date it and you may deactivate it. You may not change its percentage, its jurisdiction or what it applies to.

That rule exists because of what happens downstream. Every taxable sale snapshots the rate onto its accrual row at the moment of the sale. Last quarter's numbers are therefore made of last quarter's rates, permanently, and a rate change today cannot retroactively alter a filing you have already posted to an agency. If rates were editable, every historical report would be a guess about what the rate had been when the report was run.

So a rate change is always two operations, not one: end-date the outgoing rate, create the incoming one from that instant. The no-overlap rule per jurisdiction and category is enforced, so you cannot accidentally have two rates racing each other.

Two distinctions to keep straight. Inclusive versus exclusive: an exclusive rate adds on top of the price, an inclusive one is carved out of it. The municipal admissions rate here is inclusive, which is why a ticket price is the price and the tax comes out of it rather than being added at the till. And point-of-sale versus filing: sales categories accrue at the till and need a liability account, while income estimate rates are filing-time only and never touch a sale.

Before you start

  • An admin session.
  • The exact date and time the new rate takes effect, in UTC. Rates are time-bounded, not dated by day.
  • The agency the rate is collected for — the code must already exist.

Practise with

PersonaEmailPasswordNote
adminadmin@club.test admin123the only persona that can read or write the rate matrix

Steps 1–8 — Admin

their manual →
  1. 1
    Open the rate matrix. Read the current rates and the history separately — the history is where end-dated rates live, and it is not clutter.
    Expected result Rates by jurisdiction and category with their effective windows, agencies and whether they are inclusive.
    Watch out for Reference rates ship in the schema itself and exist in every fresh database. They are real rates, not placeholders, and they have stable ids.
  2. 2
    Pull the same matrix as JSON, optionally filtered to a category, a jurisdiction, or the rates active on a particular date.
    Expected result The filtered rate rows.
    Watch out for Asking what was active on a past date is the honest way to answer 'what did we charge in March'. Do not infer it from today's matrix.
  3. 3
    End-date the outgoing rate: set its effective-to to the instant the new rate begins.
    /api/tax/rates/{rate_id} PATCH tax
    Expected result The rate is updated and an audit row records the end-dating.
    Watch out for Only the end date and the active flag may be patched. Send anything else — the percentage, the jurisdiction, the account — and you get 422 immutable_field naming exactly what you tried to change.
  4. 4
    Create the replacement rate: jurisdiction, category, agency, rate in basis points, inclusive or exclusive, when it applies and, for point-of-sale rates, which liability account it collects into.
    /api/tax/rates POST tax
    Expected result 201 with the new rate.
    Watch out for The no-overlap rule will refuse a rate whose window collides with an existing one for the same jurisdiction and category — that refusal usually means you forgot to end-date the old one. Point-of-sale rates must name a liability account; income estimate rates must be filing-time and never do.
  5. 5
    Quote the new rate before you trust it: give it a category and an amount and read the lines it produces.
    Expected result Gross, net and one line per matched jurisdiction with its own rate and tax.
    Watch out for Exclusive lines add to the gross; inclusive lines carve out of the net. CORRECTED 2026-08-30: a gross equal to the amount you passed does NOT mean every matched rate was inclusive — it also happens when NOTHING matched, which is exactly how the mistyped effective date in the next step shows up. Measured on the seeded matrix: a ticket today quotes gross 10000, net 9524, one line; the same ticket quoted before any rate exists quotes gross 10000, net 10000, zero lines. Read the LINES, not the gross — and note that an inclusive rate always moves the net, so a net equal to the amount is the same warning said twice.
  6. 6
    When a quote surprises you, ask which rows matched and why, for a category at an instant.
    Expected result The resolution trace: the candidate rates and the ones selected.
    Watch out for Zero matched rates is legal — the sale simply proceeds untaxed. That is the correct behaviour and it is also exactly how a mistyped effective date shows up.
  7. 7
    Understand the consequence you cannot see from this page: the rate in force at the moment of a sale is copied onto that sale's accrual row. Correcting a rate today does not correct yesterday's accruals.
    Expected result A clear model of why the matrix is append-and-end-date rather than editable.
    Watch out for If a wrong rate was genuinely charged, that is a refund-and-rebill question or a reconciliation adjustment, not a rate edit. Editing the rate would silently rewrite history and fix nobody's money.
  8. 8
    Check the agencies and their mailing addresses, since a rate collects for one of them and a filing posts an envelope to it.
    Expected result The four agency records with their addresses.
    Watch out for An unknown agency code on a new rate is a 422. Add or correct the agency first — a rate collecting for an agency nobody can post to is a filing that cannot be mailed.

Reconcile tax accruals

Make the accrual rows and the ledger agree — and know which disagreements are expected.

Owned by Admin · 9 steps · about 18 minutes

Why this exists

Tax lives in two places on purpose, and reconciliation is the act of checking that they still say the same thing. The accrual rows are the tax module's own record: one row per sale per jurisdiction line, with the rate snapshotted onto it, and negative rows for refund reversals. The ledger is the money: two liability accounts that the selling module credits as part of the same balanced transaction as the sale.

Two records of the same fact, written by different modules, is a design choice. The accrual rows carry the detail a filing needs — jurisdiction, rate, category, source — which does not belong in a general ledger. The ledger carries the money, which must balance against everything else. If they ever diverge, one of the two is wrong, and you want to find that out in your own dashboard rather than in an agency's letter.

One divergence in this build is expected and you must learn to recognise it. The payments module posts stub tax to the liability accounts when an order settles, but it does not yet call the tax module's accrual engine. So a seeded or demo database legitimately shows drift: money in the ledger, no accrual row behind it. The dashboard badge going red on a fresh install is not a bug you can fix, and it is documented rather than hidden. What matters is knowing which drift is that, and which drift is new.

Refund reversals are prorated and capped so cumulative reversals can never exceed the original accrual, and they are period-keyed to the refund date, not the sale date. That is why a quarter can carry a negative line for a sale that happened in the quarter before it — and why reconciling a closed quarter after a late refund is a normal thing to do.

Before you start

  • An admin session.
  • A period to reconcile — the current quarter is fine to practise on.
  • Access to the ledger's trial balance, since half the answer lives there.

Practise with

PersonaEmailPasswordNote
adminadmin@club.test admin123the only persona that can see either side of the reconciliation
admintaxadmin@demo.club tax-admin-123the seeded tax admin

Steps 1–9 — Admin

their manual →
  1. 1
    Open the tax office and read the reconciliation badge together with the liability tiles, not instead of them.
    Expected result Quarter-to-date liability by jurisdiction and category, and a badge saying whether the accruals and the ledger agree.
    Watch out for On a seeded database the badge is legitimately red, because the demo order posts stub tax with no accrual behind it. Learn what your baseline looks like before you treat red as an alarm.
  2. 2
    Pull the liability figures and read the two halves the badge is comparing: accrual sums by account, and the ledger movement on the same accounts in the same window.
    Expected result Totals by account, jurisdiction and category, plus the ledger balances and a reconciled flag.
    Watch out for The window is the quarter containing the date you asked about. Comparing an accrual total for one quarter against a ledger balance for another will always disagree.
  3. 3
    Reconcile a specific period and read the drift per account rather than the single flag.
    Expected result Accrued, ledger and drift figures per liability account.
    Watch out for Drift in one direction only usually means missing accruals; drift in both usually means a genuine posting error. The sign tells you which module to go and read.
  4. 4
    Go to the other side of the comparison and read the two tax liability accounts on the trial balance.
    /admin/ledger ledger
    Expected result The sales tax and municipal tax liability balances.
    Watch out for These accounts are collected, not earned. A large balance is money you are holding for somebody else — it is a liability, and it should fall when you file and pay.
  5. 5
    Filter the ledger to one of the tax liability accounts and look at what actually credited it.
    Expected result Sale postings crediting tax, and refund postings debiting it back.
    Watch out for Each of these belongs to a balanced transaction with a reference. Follow the reference to the order or refund rather than trying to match by amount and time.
  6. 6
    In a training database, seed accruals for a period so you have a clean, reconciling example to compare against the messy one.
    Expected result Accrual rows created, backed by REAL balanced ledger postings, so this seeded traffic reconciles exactly.
    Watch out for This posts real money movements through the ledger. Do not run it on anything you intend to file from.
  7. 7
    List the filing periods and their packages to see which quarters are open, generated, approved or mailed.
    Expected result Periods with their current package status.
    Watch out for A period with no row has never been generated. That is normal for a future quarter and alarming for a past one.
  8. 8
    When reconciliation changes the numbers before a filing goes out, regenerate the package rather than editing it.
    /api/tax/periods/{period_id}/regenerate POST tax
    Expected result A new revision, with the previous one superseded and its documents kept.
    Watch out for Regeneration is for a package that has not been approved. Once approved, documents are hash-sealed and mailed — the answer to a post-approval discovery is an amended filing, not a quiet regeneration.
  9. 9
    Run the tax accrual invariant as a second opinion.
    Expected result Confirmation that no cumulative reversal exceeds its original accrual and that every accrual points at a real rate row.
    Watch out for This invariant checks the accrual side's internal consistency, not its agreement with the ledger. Both checks matter and neither replaces the other.

Administration & Platform Ops

Roles, zones, the universal data suite, QA sweeps and the debug console.

Build a role from capabilities, and tune a membership tier

Create a group, tick the features it may use, map its doors, and set what a membership tier is actually worth — all without a code change.

Owned by Admin · 23 steps · about 24 minutes

Why this exists

Roles used to be a fixed list in Python. If the club hired a chef, or wanted bar staff who can charge a house-credit tab but cannot scan tickets, that was a code change and a deploy. Now groups, capabilities, the implication graph and the membership-tier benefits are all rows. You create a group, tick the features it may use, map its door zones and assign people to it — and nothing gets rebuilt.

What a capability is

One auditable thing a user may do, named module-noun-verb. There are a hundred and sixty-odd of them across eighteen modules/admin/capabilities, step 1 below, prints the exact total, and how many are dangerous and sensitive, every time you open it (162 across 18 on 2026-08-29, when this sentence last said 157 across 17) — and each carries a risk level that drives how the admin UI presents it: dangerous means it moves money, changes permissions, destroys data or bypasses a control; sensitive means it reads personal data or credentials; everything else is normal. Some are marked as acting only on the caller's own rows, which is how "read your own orders" and "read every customer's orders" stay two different things.

Capabilities are never auto-deleted. A slug that disappears from the catalogue is reported as stale so a human decides, because silently dropping a permission row is how an install quietly loses a control it thought it had.

What did NOT change

Every existing permission check kept its signature and its semantics. Only the source of the inheritance edges moved from a hardcoded dictionary into a table. That claim is not a hope — it is pinned by a committed route-by-role matrix that the test suite asserts against, so a group edit that would silently open a route to a role that never had it shows up as a diff somebody has to approve.

Membership tiers are just groups with a rank

A tier is a group with a rank from one to five. Member and VIP member are ranks one and two, seeded at values that reproduce exactly the behaviour the platform had before tiers existed — every legacy value is the do-nothing value. Your effective tier is the highest-ranked unscoped tier you hold, and there is deliberately no exclusivity rule, because a VIP already holds both member and VIP member today and breaking that would break everything downstream.

Five benefits are editable: how many minutes of early access the tier gets before a sale opens, how many basis points comes off the resale fee, the house-credit tab limit, how many guest-list seats the tier may self-allocate per event, and whether member-only drops are visible at all.

The anti-lockout design, which you should read before you experiment

Four guards, and three of them are database triggers — so the universal admin grid and a raw shell obey them too, not just this UI. System groups cannot be deleted, their slugs and kinds cannot be changed, and core capabilities cannot be removed from them. On top of that, a group marked superuser resolves every capability regardless of what its rows say, so an emptied permission table cannot lock the administrators out. And if it somehow still goes wrong, there is an offline repair command that grants admin back from a shell.

Belt and braces on top: applying a capability set runs a lockout analysis first, and you can ask for it as a dry run before you commit to anything.

Before you start

  • An admin session. Group administration is admin-only on a default install.
  • A clear idea of the job the new role does. Building the group is ten minutes; deciding what it may do is the work.
  • The seed already ships five non-system roles to read as worked examples: artist and promoter (event-scoped), and chef, security and bar (not scoped).

Practise with

PersonaEmailPasswordNote
adminadmin@club.test admin123the only role holding group administration on a default install
membermember@club.test member123rank 1 tier; the person whose benefits change when you edit one

Steps 1–17 — Admin

their manual →
  1. 1
    Start at the capability reference, not at the group form. Filter by module and by risk and read what actually exists before you decide what your new role needs.
    Expected result The live total in the header — capabilities, how many dangerous, how many sensitive — then the catalogue grouped by module, each with a label, a risk level and a sentence of description.
    Watch out for Read the dangerous ones deliberately. They are the ones that move money, change permissions, destroy data or bypass a control — and a role that holds one of them by accident is the failure this whole feature exists to make visible.
  2. 2
    Open the group list. Each row shows its kind — role, staff, partner, membership tier or the employee pseudo-group — and a tier badge where it has a rank.
    Expected result The system groups plus the seeded non-system ones.
    Watch out for Open security and bar before you build anything. Security is door staff minus the guest-list door override; bar can charge a house-credit tab and cannot scan tickets at all. Both are small, real examples of the point of this feature.
  3. 3
    Create the group. The slug is lowercase letters, digits and underscores; pick the kind; decide whether it is event-scoped — granted per event, like artist and promoter — or held outright, like chef.
    Expected result The form, with the free tier ranks listed if you choose a membership tier.
    Watch out for Scoping is the decision that is painful to change later, because it changes what a grant even means. 'Sound engineer on Saturday' is scoped; 'sound engineer' is not.
  4. 4
    Save it. The group is immediately grantable — nothing else has to know it exists.
    /api/rbac/groups POST rbac
    Expected result A redirect onto the new group's own page.
    Watch out for A slug that was used before is refused as retired rather than reused. Recycling a slug would silently attach a new role to every audit row the old one ever wrote.
  5. 5
    Work through the tabs: capabilities, zones, tier benefits (tiers only), members, implications.
    /admin/groups/{slug} rbac
    Expected result One page per concern, with the group's audit history underneath.
    Watch out for Capabilities decide which buttons exist. Zones decide which doors open. They are different questions and a role frequently needs one without the other — a bookkeeper needs no door at all.
  6. 6
    Tick the capabilities and submit it as a dry run first. You get back what would be added, what would be removed, and a lockout analysis.
    /api/rbac/groups/{slug}/capabilities PUT rbac
    Expected result A diff and a verdict, with nothing written.
    Watch out for Always dry-run a removal. The analysis answers the only question that matters — after this change, who can still administer groups — and it answers it about real users rather than about your intentions.
  7. 7
    Apply the set for real once the dry run reads the way you expect.
    /api/rbac/groups/{slug}/capabilities PUT rbac
    Expected result The additions and removals applied, audited, and live on the next request.
    Watch out for This is a full replacement, not a merge: whatever you did not tick is removed. Submitting a partially-loaded form is the classic way to strip a role of half its permissions without noticing.
  8. 8
    Map the group's door zones on the zones tab.
    /api/rbac/groups/{group_name}/zones PUT rbac
    Expected result The zone list replaced, audited, and a pass refresh queued for everybody already holding the group.
    Watch out for This is the fix for a long-standing gap: zone changes now fan out to existing holders instead of only affecting cards issued afterwards. Otherwise a terminated employee's membership card would keep opening the zone it was minted with.
  9. 9
    Declare what this group also grants. Holding it then resolves the capabilities of everything it implies.
    /api/rbac/groups/{slug}/implications PUT rbac
    Expected result The implication edges replaced; cycles refused.
    Watch out for Implication is where a small role quietly becomes a large one. Two hops is usually one too many — prefer ticking the capability you meant over inheriting a group that happens to contain it.
  10. 10
    Before you grant it to a person, simulate: given this set of grants, what does the graph resolve to?
    Expected result The effective groups and capabilities for a hypothetical user.
    Watch out for Simulating is free and reversing a grant is not — a revoked grant is still an audit row that somebody will ask about.
  11. 11
    Open the person you are granting it to and read what they already hold, including anything scoped to a single event.
    /admin/users/{user_id} rbac
    Expected result Their live grants, their sessions and their audit timeline.
    Watch out for Somebody who already holds a broader group gains nothing from your new one, and the new grant will then mislead the next person who reads their profile.
  12. 12
    Grant the group, with a reason. Event-scoped groups require the event.
    /api/rbac/users/{user_id}/groups POST rbac
    Expected result The grant recorded, effective on the user's very next request.
    Watch out for The reason is required and it is the only place the why ever lives. 'temp' is not a reason; 'covering Saturday for K' is.
  13. 13
    Verify from the other end: ask what this user can now do, with provenance for each capability.
    /api/rbac/users/{user_id}/capabilities rbac
    Expected result Every capability they hold and which group supplied it.
    Watch out for Provenance is what makes an over-permissioned account fixable. 'They can refund orders' is a complaint; 'they can refund orders because they are in bar, which implies a group that holds it' is a fix.
  14. 14
    Now build a membership tier: same form, kind set to membership tier, and a rank. Leave the rank on auto to take the first free slot.
    Expected result A tier occupying one of five slots, with an empty benefit row created for it.
    Watch out for There are five slots and the ranks are unique. Ranks one and two are already member and VIP member, so a tier you want between them needs the reorder step below rather than a clever choice of number now.
  15. 15
    Open the tier's benefits tab and set the five values: early-access minutes before a sale opens, basis points off the resale fee, the house-credit tab limit, the comp allowance per event, and whether member-only drops are visible.
    /admin/groups/{slug} rbac
    Expected result A form with each field explained and its do-nothing value named.
    Watch out for The tab limit has three meanings: a number is that limit, blank falls back to the platform default, and zero means no tab at all. Blank and zero are not the same answer and the difference is somebody's bar bill.
  16. 16
    Save the benefits — dry-run first if you are changing an existing tier rather than filling in a new one.
    /api/rbac/tiers/{slug}/benefits PUT rbac
    Expected result The changed fields listed, with a count of passes that would be reissued.
    Watch out for None of the five benefits is inside a signed door credential, so a benefit edit that does not touch zones queues no pass refresh. If it reports passes queued, you changed something you did not think you changed.
  17. 17
    Reorder the tiers when the ladder changes shape.
    Expected result Ranks reassigned in the order you gave.
    Watch out for Rank is what 'highest tier' means, and there is no exclusivity rule — somebody holding two tiers resolves to the higher one. Reordering therefore silently re-answers 'what tier is this person' for everybody holding more than one.

Steps 18–19 — Member

their manual →

Is the person a tier is for — and the only one who can confirm the benefit actually landed.

  1. 18
    As the member, open your account page and read the groups you hold.
    /account rbac
    Expected result Your grants, including your tier.
    Watch out for A VIP holds member and VIP member. That is correct and always has been — the higher rank is what decides your benefits, not the number of rows.
  2. 19
    Check the benefit actually landed: is the member-only drop visible, and does the early-access window open when it should?
    /events events
    Expected result The catalogue reflecting your tier's benefits.
    Watch out for This is the only real confirmation. An admin reading the benefits form sees what was configured; a member reading the catalogue sees what was delivered, and those are different claims.

Steps 20–23 — Admin

their manual →
  1. 20
    Export the group-by-capability matrix — as CSV when you want to diff it against last month's.
    Expected result Every capability against every group, in one grid.
    Watch out for Diffing this after a round of edits is the cheapest permission review that exists. A row that gained a dangerous capability nobody remembers discussing is exactly what the grid is for.
  2. 21
    Read the resolved graph when behaviour surprises you, and check the stale capability list while you are there.
    Expected result Groups, edges, superuser flags and any slugs no longer in the catalogue.
    Watch out for A stale slug is not cleaned up automatically, on purpose. Deciding whether a permission that no longer exists in code should be removed from every group is a human decision with consequences.
  3. 22
    After a zone change, watch the pass refresh queue drain. It is worked by a scheduled handler in batches.
    Expected result Queued entries clearing as cards are reissued.
    Watch out for Until it drains, existing holders are still carrying their old zone list. On a night when you have just revoked somebody's access, that lag is the thing to check rather than assume.
  4. 23
    Finally, know the guards you cannot switch off. System groups are undeletable, their slugs and kinds immutable, and their core capabilities un-removable — enforced by database triggers, so the universal data grid obeys them too. All five policy tables are registered read-only there. And there is an offline repair command that grants admin back from a shell.
    Expected result A refusal from the database itself, not merely from this UI.
    Watch out for Those guards exist because this feature's failure mode is not a bad page — it is nobody being able to sign in and fix it.

Find anything in admin

Six labelled groups, one row per tool, a filter box — and every top-level admin page guaranteed reachable from here.

Owned by Admin · 3 steps · about 5 minutes

Why this exists

A flat list of thirty-two cards is an inventory, not a menu. The hub groups its tools — Money · Events · People · Content · Operations · System — ranks them by how often they are touched, and filters live as you type. Nothing was removed and no page was re-gated; only the presentation changed.

The door console at /admin/access was the page that proved the problem: it existed, worked, and could only be found by knowing the URL. A test now fails if any top-level admin page is unreachable from the hub.

Steps 1–3 — Admin

their manual →
  1. 1
    Open the hub.
    /admin adminsuite
    Expected result Six labelled groups, one row per tool, most of it on one screen.
    Watch out for Nothing was removed — the same tools, grouped and tightened.
  2. 2
    Type “door” into the filter.
    /admin adminsuite
    Expected result The list narrows live; the count line reads how many tools match.
    Watch out for The filter is client-side over links already on the page — no request, keyboard-friendly.
  3. 3
    Follow Door & Passes under Operations.
    /admin/access access
    Expected result The access console: readers, signing keys, revocations, scan logs.
    Watch out for This page was reachable only by URL until the hub grouped itself. The reachability guard keeps it that way.

Grant and revoke roles

Give someone VIP, scoped host access or a staff role — and take it back cleanly.

Owned by Admin · 12 steps · about 20 minutes

Why this exists

Corrected 2026-08-24 (end2end). This section used to say authorisation was "grant-based, not capability-based", that there was "no permissions matrix to tick", and that six groups were the whole list. All three were true once and none of them is true now, which made this the most misleading paragraph in the manual: it is the one somebody reads during an incident.

There are two layers, and you need both. A user holds zero or more grants of a named role. Roles map to capabilities through a policy graph held in the database, and endpoints ask for the capability, not the role — events.publish, events.cancel, tiers.manage. So "who can do X" is answered by the matrix, not by reading route decorators, and the matrix is editable: see build-a-role-from-capabilities.

The role list lives in the database, not in this page. The six original roles — member, vip_member, host, door_staff, venue_manager, admin — are the ones every deployment has, and more are seeded on top: plan 109 added reviewer, a grantable role carrying exactly two capabilities and no staff surface. Ask /api/rbac/groups for the authoritative list; do not count the names in this paragraph.

Three roles imply others, so you never grant the implied one: admin implies everything, venue_manager implies door_staff, vip_member implies member. There is one name you will see in the code, employee, which is a pseudo-group meaning door_staff or venue_manager or admin. It is a gate used by endpoints; it is not grantable, and trying to grant it fails with an unknown group rather than silently doing nothing.

host is the odd one. A host grant carries an event id. The promoter is not "a host" in general — they are a host of exactly that event, and the same person can hold several host grants for several events. Scope is in the grant, not in a separate table, which is why revoking one event's access never touches another's.

Two design decisions bite people, and both are on purpose. First, every grant and revoke requires a reason and writes one row to an append-only audit table — you cannot quietly hand someone the keys. Second, groups carry door zones, so granting or revoking almost any group re-issues the person's membership pass: otherwise a terminated employee would walk around with a card that still opens Zone C.

Before you start

  • An admin session. Every route in this workflow is admin-only; a venue manager gets 403 on all of them.
  • A target user to practise on — use a demo account, never a real colleague.
  • For a scoped host grant, an existing event id (the seed ships demo-event-0001).

Practise with

PersonaEmailPasswordNote
adminadmin@club.test admin123the only persona that can grant or revoke anything
memberfreya@demo.club freya-pass-123a plain registered member — safe to promote and demote
host_promoterhost@club.test host123already holds a scoped host grant on demo-event-0001
door_staffdoor@club.test door123scanner-only; use to see what a staff grant actually opens

Steps 1–3 — Admin

their manual →
  1. 1
    Open the user directory and find the person by email. You can filter by group to answer questions like 'who are our admins'.
    Expected result A paged list of users with their active groups.
    Watch out for Deactivated users still appear here. Being listed is not the same as being able to log in.
  2. 2
    Open the user. Read the three panels before you touch anything: current grants, live sessions, and the audit timeline.
    /admin/users/{user_id} rbac
    Expected result Grants with who granted them and why, a session count, and the last 50 permission events for this person.
    Watch out for The audit timeline is the answer to 'who gave them that'. Read it before you assume it was a mistake.
  3. 3
    Grant vip_member from the grant form on that page, and type a real reason — 'subscription settled, ticket 4417', not 'vip'.
    /api/rbac/users/{user_id}/groups POST rbac
    Expected result 201 with the new grant row, a flash on the page, and one audit row. The user's VIP tab and Zone B become available.
    Watch out for An empty reason is refused with 400 reason_required. Granting a group they already hold is 409 duplicate_grant, not a silent no-op. And do not grant member alongside vip_member — vip_member already implies it.

Steps 4 — Member

their manual →

It is their account: the grant is what changes what they can buy and where they can walk.

  1. 4
    Log in as the target and look at your own account page: your groups, and the sessions you have open.
    /account rbac
    Expected result vip_member appears in your groups. Nothing about the grant is hidden from the person who holds it.
    Watch out for Group changes are re-read per request, so the new power appears on your very next page load. You do not need to log out and back in.

Steps 5 — Admin

their manual →
  1. 5
    Now grant a scoped host role: group host, plus the event id it applies to, plus a reason.
    /api/rbac/users/{user_id}/groups POST rbac
    Expected result A grant row carrying that event_id.
    Watch out for host without an event id is 400 event_id_required. An event id on any other group is 400 event_id_not_allowed. An event id that does not exist is 400 unknown_event. The scope is checked, not trusted.

Steps 6 — Host / Promoter

their manual →

A host grant is scoped to one event, and they must see only that one.

  1. 6
    Log in as the promoter and open the event dashboard for the event you were scoped to.
    /admin/events/{event_id} events
    Expected result Their own event, with live sales.
    Watch out for Put a different event id in the URL and they get 403. That is the scope doing its job — and it is also the fastest way to prove a grant was made against the wrong event.

Steps 7 — Admin

their manual →
  1. 7
    Grant door_staff to a new starter.
    /api/rbac/users/{user_id}/groups POST rbac
    Expected result A grant row, an audit row, and a re-issued membership pass carrying the staff zone set.
    Watch out for Do not reach for employee here. It is a pseudo-group the endpoints use as a gate; it is not in the grantable list and asking for it fails with unknown_group.

Steps 8 — Door Staff

their manual →

A door_staff grant is the thing that opens the scanner at all.

  1. 8
    Log in as the new starter and open the scanner.
    /scanner access
    Expected result The scanner loads. Without a door_staff (or venue_manager or admin) grant it is a 403.
    Watch out for Door staff get the scanner and the guest list, and nothing else. They cannot see orders, money or users, by design.

Steps 9–12 — Admin

their manual →
  1. 9
    Pull the group list and read it as the authoritative answer to 'what can I actually grant'.
    Expected result Every grantable role with its metadata, read from the roles table — six on a bare deployment, more wherever a plan has seeded one (reviewer, from plan 109).
    Watch out for Corrected 2026-08-24 (end2end): this step used to promise 'the six grantable groups'. The list is data, not a constant, so a count written into the manual goes stale the first time somebody adds a role. If a name is not in this response, no amount of typing will make it grantable — that part has always held.
  2. 10
    Revoke a grant using the grant id (not the group name) and a reason. Use the Revoke button on the user page — it already knows the id.
    /api/rbac/users/{user_id}/groups/{grant_id}/revoke POST rbac
    Expected result The grant is marked revoked, effective on the person's next request. Their pass is re-issued without that group's zones.
    Watch out for Revoking the last admin is refused with 409 last_admin, and that check runs inside the write transaction, so two admins revoking each other at the same moment cannot both succeed. Revoking a grant that is already revoked is 409 already_revoked.
  3. 11
    Open the RBAC audit log and find the two rows you just created.
    Expected result Actor, target, group, reason and timestamp for every grant, revoke, trigger and deactivation.
    Watch out for This table is append-only at the database level. It cannot be edited or deleted — not through the data suite, not through raw SQL. If a reason was wrong, add a new event; you cannot rewrite history.
  4. 12
    When someone insists their access is broken, dump their identity: every grant including revoked ones, every session with its status, and their computed zones globally and per event.
    Expected result One page that usually ends the argument.
    Watch out for Admin-only debug, and it 404s entirely when debug endpoints are disabled. The password hash is redacted — do not go looking for it.

Map groups to door zones

Decide which groups open which physical zones, and know what reaches the cards already in people's wallets.

Owned by Admin · 8 steps · about 15 minutes

Why this exists

Zones are the bridge between "who you are" in the database and "which door opens" in the building. A reader is installed in a zone; a pass carries a list of zones; a scan is green only if the two overlap. Nothing about the person's groups is looked up at the reader — that would need a network the door does not have.

That last sentence is the whole design. The zone list is snapshotted into the signed pass when the pass is issued. Readers can therefore work offline and still be right, and a tampered payload fails signature verification instead of being trusted. The cost is that a pass is a photograph of your permissions at issue time, not a live query — so the platform re-issues passes whenever a grant changes.

The map change reaches existing cards, and it does it in the background. Changing a group's zones queues one pass refresh per active holder and a scheduled job re-mints them in batches — so a 4,000-member tier does not block the admin's request, and the audit row for the edit records how many cards were queued. Grant and revoke re-issue too; all three paths end in the same place.

This paragraph taught the opposite until 2026-08-29, describing a fan-out gap that plan 21 closed: it told an admin tightening zones for security to revoke and re-grant real people, which is an access interruption and an audit trail full of grants nobody meant. Corrected against the code — set_group_zones enqueues, rbac.pass_refresh drains.

Event ticket passes are not affected either way, and that is worth knowing before you go looking: a ticket's zones come from its tier, not from the holder's groups. The group-to-zone map governs membership cards.

Defaults ship sensible: member gets zone_a, vip_member gets zone_a and zone_b, and host, door_staff, venue_manager and admin get all three. Editing them is a full replace, not a merge, and every edit is audited.

Before you start

  • An admin session — the zone editor and its API are admin-only.
  • Somebody with a live membership pass to test against (vip@club.test has one).

Practise with

PersonaEmailPasswordNote
adminadmin@club.test admin123the only persona that may edit the zone map
door_staffdoor@club.test door123sees the consequence at the reader
venue_managermanager@club.test manager123venue_manager implies door_staff, so it inherits door_staff's zones too

Steps 1–3 — Admin

their manual →
  1. 1
    Open the groups and zones editor and read the current map before changing it.
    Expected result One row per group with its zone codes and the implications spelled out.
    Watch out for The pseudo-group employee appears in the mapping data as a convenience for implication expansion. It is still not grantable to a person.
  2. 2
    Set the zone list for one group. Submit the complete list you want that group to end up with.
    /api/rbac/groups/{group_name}/zones PUT rbac
    Expected result The saved zone list comes back, and one audit row records old and new values.
    Watch out for This is a REPLACE, not an add. Send only zone_b and the group loses zone_a. Zone codes must match the simple lowercase pattern the endpoint enforces — a typo becomes a zone nobody has a reader in, and the failure shows up as a wrong-zone denial at a door, hours later.
  3. 3
    Probe the computed zones for one user, with and without an event id, before you trust the change.
    Expected result The union of that user's active grants. With no event id, scoped host grants contribute nothing at all.
    Watch out for That last part surprises people: a promoter's host zones only apply to their own event, so a bare probe legitimately shows fewer zones than you expect.

Steps 4 — Door Staff

their manual →

The map decides which reader turns green for them and for the guests they scan.

  1. 4
    As a staff member, open your own passes and look at the zone list printed on the membership card.
    Expected result The zones you had when the card was issued.
    Watch out for If this does not match the map you just saved, the card predates your change. That is the documented gap, not a bug you can fix by refreshing the page.

Steps 5–6 — Admin

their manual →
  1. 5
    Understand the re-issue rule: a grant, a revoke and a zone-map edit all re-issue the affected membership cards. The map edit does it in the background — one queued refresh per active holder, drained by the rbac.pass_refresh job in batches.
    Expected result The edit's audit row carries the number of cards queued, and the queue drains on the following ticks.
    Watch out for Corrected 2026-08-29: this step said a zone-map edit does NOT re-issue and told you to revoke and re-grant people to force one — an access interruption, and an audit trail full of grants nobody meant. Plan 21 closed that gap. What is still true is that a card is a photograph: between the edit and the drain a wallet carries the old zone set, so a tightening you need enforced this minute is a revoke, not a map edit.
  2. 6
    Open the access console and check which readers are installed in the zone you just changed.
    /admin/access access
    Expected result Readers with their zone assignment and status.
    Watch out for This page is one of the few admin surfaces a venue manager can also open. Reader token rotation and key rotation on it are still admin-only.

Steps 7 — Door Staff

their manual →

The map decides which reader turns green for them and for the guests they scan.

  1. 7
    Scan a pass at a reader in a zone the holder does not have.
    /scanner access
    Expected result A red result with the reason wrong_zone.
    Watch out for wrong_zone means the credential is genuine and the person is simply not allowed through this door. It is not a revoked pass and not a fake — do not treat it as fraud.

Steps 8 — Venue Manager

their manual →

venue_manager implies door_staff, so its zone set is the union of both.

  1. 8
    Confirm the implication for yourself: a venue manager needs no separate door_staff grant to use the scanner.
    /scanner access
    Expected result The scanner opens on a venue_manager grant alone.
    Watch out for Implication is one-way. door_staff does not imply venue_manager, and nothing implies admin.

Set up a venue and its door zones

Create the room, give it a capacity, a timezone and its doors — then let a manager decide which of those doors tonight actually uses.

Owned by Admin · 17 steps · about 25 minutes

Why this exists

A venue is the physical room: a name, a permanent slug, an address, an IANA timezone, a capacity, advisory door and curfew times, and the zones inside it — the doors the readers are pinned to. Before this existed the room was a free-typed text column on the event and the zones belonged to nobody, which meant three separate questions — what time is it here, how many people fit and which doors exist — had three different answers depending on who you asked.

The one rule that governs everything about zones

A door reader authorises a scan by comparing strings. The list of zone codes is snapshotted into the pass when it is issued, signed, and hashed; at the door the reader's own code is looked for in that list. Nothing in that comparison knows what a venue is. So a zone code is globally unique, forever — not unique per venue. If two rooms both owned a zone called A, a ticket for the warehouse would scan green at the other room's door, because there is no venue in scope at the moment of the decision.

Three consequences you will meet as soon as you start clicking. Codes are derived, never typed — you give a zone a name and a short suffix and the platform mints the code from the venue's slug. Codes and zone ids are immutable: editing a zone's code is refused outright. And a suffix that merely normalises onto an existing code is refused as a collision even when the two strings look different, which is also why a suffix beginning with zone_ is rejected as reserved.

Two kinds of zone, and why the second one exists

Venue zones are permanent. They own the readers, they are what ticket tiers are mapped to, and they outlive every event.

Event zones are per night. One roster table does two jobs: activation — the backstage door exists but tonight's event does not use it, so a tier that would normally open it does not — and temporary zones: a meet-and-greet room or a second stage that is a real zone for one event and is then retired forever.

The safety net is worth learning explicitly: an event with no roster rows at all allows every zone. That is what keeps every pre-existing event working. The first roster write for an event materialises its current effective zone set as active first, so adding one temporary room can never silently switch the rest of the building off.

Two things the plans describe differently from what was built

First, the per-event roster has no admin page. It is four JSON endpoints. Reading and writing it today means calling them; the venue pages cover the permanent half only.

Second, this is not an admin-only feature. A venue_manager can create venues, edit them, and manage zones. Only two actions are reserved to an admin: archiving a venue, and overriding an event's capacity gate. Both destroy or bypass a control, which is the line this platform draws everywhere else too.

Before you start

  • An admin session for the archive and capacity-override steps.
  • A venue_manager or admin session for everything else.
  • The seed ships three venues: Main Warehouse (250, the default), Blue Room (120) and Mezzanine (60).
  • An event to practise the per-event roster against. Any seeded event will do.

Practise with

PersonaEmailPasswordNote
adminadmin@club.test admin123the only account that can archive a venue or override a capacity gate
venue_managermanager@club.test manager123can create and edit venues and zones; runs the per-event zone roster

Steps 1–10 — Admin

their manual →
  1. 1
    Open the venue list. Each row shows the room, its resolved timezone, its capacity and its zones. One venue is flagged as the default — that is the one an event inherits from when it is not bound to a room of its own.
    /admin/venues venues
    Expected result Three seeded venues, with Main Warehouse marked as the default.
    Watch out for Archived venues are hidden unless you ask for them. A room you cannot find has usually been archived, not deleted — nothing here deletes a venue.
  2. 2
    Start a new venue. The four fields that carry real weight are the name, the slug, the timezone and the capacity. Address, contact and notes are description; the other four are load-bearing.
    Expected result The venue form, with the platform's timezone preselected.
    Watch out for Leave the slug blank and it is derived from the name. Whatever it ends up as, it is permanent — the slug is immutable and a rename attempt is refused. It is in the URL of the venue page and in every zone code the room ever mints.
  3. 3
    Save it. The timezone must be a real IANA name (Europe/Berlin, not CET) and the capacity is the fire-code ceiling for the room, not an ambition.
    /admin/venues/new POST venues
    Expected result A 303 straight onto the new venue's own page.
    Watch out for Capacity is the number that gates ticket sales for every event held here, so it is written to an append-only audit log the moment it changes. Type it as if somebody will ask you to justify it, because that is exactly what the log is for.
  4. 4
    Open the venue. The page has four blocks: Details, Zones, Capacity and Events. Read the Capacity block first — it tells you the ceiling and where that ceiling came from.
    /admin/venues/{slug} venues
    Expected result The room, its zones with reader counts, and the events booked into it.
    Watch out for Capacity resolves in a fixed order: this venue's own number, then the default venue's, then the platform setting. The default venue outranks the setting on purpose — a room's capacity is a physical fact and a setting is an opinion.
  5. 5
    Add a permanent zone from the Add zone form: a human name (Backstage), a short code suffix (backstage), an advisory capacity, and the groups that should be granted it.
    /api/venues/{venue_id}/zones POST venues
    Expected result A new zone whose code is the venue slug plus your suffix — blue_room_backstage.
    Watch out for Three refusals live here and all three are protecting the scan path. A suffix starting with zone_ is rejected as reserved. A suffix that normalises onto an existing code is rejected as a collision even if the raw strings differ. And hyphens become underscores, because the zone grammar has no hyphen in it.
  6. 6
    Look at the unattached list beside the zones. Those are zones that exist in the database but belong to no room — usually pre-existing zones from before venues had owners.
    /api/venues/{venue_id}/zones venues
    Expected result The venue's own zones, plus any orphans available to adopt.
    Watch out for One orphan is legitimate and must stay one: the bar's POS pseudo-zone, which every membership card carries and which is not a door in any room. Do not adopt it into a venue.
  7. 7
    Adopt a genuine orphan into this venue.
    /api/venues/{venue_id}/zones/{zone_id}/attach POST venues
    Expected result The zone now belongs to the room. Nothing else about it changes.
    Watch out for Attaching writes the owning venue and nothing else — not the code, not the id, not the readers. That is the whole point: adoption must be invisible to every pass already in somebody's wallet.
  8. 8
    Rename a zone or change its advisory capacity when the room is re-laid-out.
    /api/venues/{venue_id}/zones/{zone_id} PATCH venues
    Expected result The display name and capacity update.
    Watch out for You may change the name. You may never change the code — that comes back as a 409 telling you the code is immutable. The name is for humans; the code is inside signed credentials that are already issued.
  9. 9
    Open the zone map when a scan does not behave. It prints the three vocabularies side by side: the stored zone code, its normalised form, and the code the group-to-zone permissions were written against.
    Expected result One row per zone showing all three spellings.
    Watch out for This page exists because those three vocabularies drifting apart is the single hardest bug in the platform to see from anywhere else. If a pass will not open a door, look here before you look anywhere.
  10. 10
    Promote a venue to be the platform default when the club moves its main room. Exactly one venue is the default at a time.
    /api/venues/{venue_id}/default POST venues
    Expected result The default flag moves; the previous default keeps everything else.
    Watch out for The default is what an unbound event inherits its timezone and capacity from. Moving it silently re-answers 'what time is it' for every event that never named a room.

Steps 11–15 — Venue Manager

their manual →

Runs the per-event zone roster — which of the room's doors tonight's event actually uses.

  1. 11
    For tonight's event, read its zone roster. The response tells you the roster rows, the active codes, and — importantly — whether the event is unconstrained.
    /api/events/{event_id}/zones venues
    Expected result For an untouched event: an empty roster and unconstrained true.
    Watch out for Unconstrained means every zone is allowed. That is not an error and not a gap in the data — it is the default state of every event, and it is why this feature could ship without rewriting history. There is no page for this yet; the roster is a JSON surface only.
  2. 12
    Switch a zone off for this event — the backstage door on a night with no backstage. Post the zone reference with active set to false.
    /api/events/{event_id}/zones/{zone_ref}/activate POST venues
    Expected result The roster materialises with every current zone active, then that one flips off.
    Watch out for Your first write to an event's roster turns 'everything is allowed' into an explicit list. That materialisation is deliberate and it is a one-way door in practice: from now on this event has an opinion about every zone, so check the list afterwards rather than assuming.
  3. 13
    Create a temporary zone for tonight only — a meet-and-greet room, a second stage. Give it a name and a suffix exactly like a permanent one.
    /api/events/{event_id}/zones POST venues
    Expected result A real zone, usable by readers and mappable to a tier, whose code carries this event's id — ev3f9a2c_meet_greet.
    Watch out for The code is namespaced by the EVENT, not the venue, so it can never be minted twice and never collides with a permanent zone. It is still globally unique, because the door still decides by string comparison.
  4. 14
    Retire the temporary zone when the night is over. You rarely need to: every temporary zone is retired automatically when the event completes or is cancelled.
    /api/events/{event_id}/zones/{zone_ref}/retire POST venues
    Expected result The roster row goes inactive, the zone is soft-deleted, and the code is burned forever.
    Watch out for The response lists any readers still physically pinned to that zone. They are reported, never silently rewritten — the reader inventory belongs to the access module. They are already harmless, because no live pass can carry the code any more.
  5. 15
    Before the event goes on sale, come back to the venue page and read the Capacity block against the event's tier inventory.
    /admin/venues/{slug} venues
    Expected result The room's ceiling, the sum of tier inventory, and any capacity problems.
    Watch out for Selling more tickets than the room holds is refused at the point the event goes on sale, not at the point you type the number. Discovering it here is a five-minute fix; discovering it at announcement is a phone call to a promoter.

Steps 16–17 — Admin

their manual →
  1. 16
    When a manager escalates a capacity gate that is genuinely wrong — a licensed extension, a seated layout — record an override with a written reason.
    /api/events/{event_id}/capacity-override POST venues
    Expected result The gate lifts for that event and the reason is stored against it.
    Watch out for Admin only, and the reason is mandatory. An override with a reason is a decision; an override without one is an unexplained hole in a fire-code control. Prefer fixing the venue's capacity if the room really did change.
  2. 17
    Archive a room the club no longer uses. Archiving hides it from selection without touching a single event, zone or pass.
    /api/venues/{venue_id}/archive POST venues
    Expected result The venue disappears from the default list and can be restored.
    Watch out for A venue still in use is refused. And archiving deliberately does NOT cascade onto its zones — cascading would leave old readers opening doors while newly issued passes had quietly lost the codes, which is the worst of both outcomes.

Deactivate a user

Shut an account down completely — sessions, passes and all — and know what reactivation restores.

Owned by Admin · 8 steps · about 12 minutes

Why this exists

Deactivation is the platform's answer to "this person must stop being able to do things, now". It is deliberately one action with a wide blast radius rather than a checklist you might half-finish.

One call does four things in one transaction: the user row is soft-deleted so login is refused, every session is revoked immediately, the grants are left intact, and every live pass the person holds is revoked. That fourth part is the one people forget when they do this by hand. A pass never authenticates a session — it is a signed offline credential — so killing sessions alone would leave a banned account walking through doors all night. Revocation blacklists the serial, destroys the TOTP secret and pushes a wallet VOID so offline readers learn about it too.

Grants are kept on purpose. Deactivation is not a purge; it is a suspension you can undo, and throwing away the grant history would also throw away the audit story of what this person could do.

Pass revocation is terminal, which shapes reactivation: reactivating re-mints a membership card for someone who had one, but event ticket passes are not restored. Those must be re-issued from the ticket. And the final admin cannot be deactivated — 409 last_admin — for the same reason you cannot revoke the last admin grant.

The login failure a deactivated user sees is the ordinary invalid-credentials message. The platform does not say "your account is disabled", because that would let anyone enumerate account states from the login form.

Before you start

  • An admin session.
  • A target account that is not the last admin.
  • Ideally a target that holds a live pass, so you can watch the door consequence.
  • The seed also ships an already-deactivated account, dormant@demo.club, if you want to practise reactivation first.

Practise with

PersonaEmailPasswordNote
adminadmin@club.test admin123runs the deactivation
memberfreya@demo.club freya-pass-123a live registered member — safe to deactivate and reactivate
door_staffdoor@club.test door123sees the revoked pass fail at the reader

Steps 1–2 — Admin

their manual →
  1. 1
    Open the user and read what you are about to switch off: their grants, their session count, and their audit history.
    /admin/users/{user_id} rbac
    Expected result The full picture, including whether they are one of very few admins.
    Watch out for Check the grant list for an admin grant before you go further. The last admin is protected, but the second-to-last is not.
  2. 2
    Deactivate, with a reason. Use the Deactivate button on the user page.
    /api/rbac/users/{user_id}/deactivate POST rbac
    Expected result The account is soft-deleted, every session is revoked, every live pass is revoked, and the audit row records how many passes went with it.
    Watch out for 409 last_admin protects the final admin, and the guard is evaluated inside the write transaction so concurrent attempts cannot both win. Nothing here is a soft warning: the moment it returns, they are out.

Steps 3 — Member

their manual →

It is their account: they are the one who suddenly cannot log in, and they are told nothing.

  1. 3
    Try to log in as the deactivated account.
    /login core
    Expected result The same invalid-credentials message you would get from a wrong password.
    Watch out for This is deliberate. If you are supporting a confused user, you must look them up in the admin console — the login page will never tell either of you that the account is disabled.

Steps 4 — Door Staff

their manual →

The revoked credential fails at their reader and they must not mistake it for a broken scanner.

  1. 4
    Scan the deactivated person's membership card at a reader.
    /scanner access
    Expected result Red, with a revoked reason. The serial is blacklisted and the rotating code no longer verifies.
    Watch out for Do not re-scan, do not try the QR instead of the tap, and do not wave them through. A revoked pass is revoked on every rail at once, including offline.

Steps 5–8 — Admin

their manual →
  1. 5
    Find the user_deactivate row and read its detail: the reason you typed, and the number of passes revoked.
    Expected result One audit row, immutable.
    Watch out for If the passes-revoked count is zero and you expected otherwise, the person never held a live pass — that is information, not a failure.
  2. 6
    Learn the softer tool as well: revoking all sessions logs someone out everywhere without disabling the account.
    /api/rbac/users/{user_id}/sessions/revoke-all POST rbac
    Expected result A count of revoked sessions and its own audit row.
    Watch out for Use this for 'they left a laptop on a train', not for 'they left the company'. It does not touch passes.
  3. 7
    Reactivate the account when the suspension ends.
    /api/rbac/users/{user_id}/reactivate POST rbac
    Expected result Login works again, the grants are still there, and a membership card is re-minted for someone who held one.
    Watch out for Event ticket passes do NOT come back. Pass revocation is terminal by design, so a reactivated ticket holder needs their ticket pass issued again from their tickets page.
  4. 8
    Confirm the end state: sessions, grants, and the fact that the revoked pass has not quietly returned.
    Expected result An identity dump consistent with what you intended.
    Watch out for Admin-only, and gone entirely when debug endpoints are off.

Edit any row with the admin data suite

The universal CRUD console over every table — and the tables it refuses to let you touch.

Owned by Admin · 10 steps · about 20 minutes

Why this exists

The data suite is a generic console over every table in the database. It is introspected per request, so a table that lands in a migration this afternoon appears in the grid this afternoon with no code written anywhere. That is the point: the platform should never need a bespoke admin screen just to fix one bad row.

Power like that needs limits, and the limits are structural rather than polite. Some tables are read-only in the suite, and there is no admin override — the refusal is a 403, not a confirmation dialog. Three things make a table read-only, and the 403 tells you which one it was, so read it rather than guessing. Its module registered it that way; or the suite detected that it carries a matching pair of append-only triggers; or the table has no single-column primary key, so there is no way to name one row to edit. That third one is not a policy at all — it is the console admitting it cannot address the row. Join tables like tier_zones land there, and nothing is wrong when they do. Detection means the ledger and every audit table are protected automatically, without anyone remembering to register them, and if a trigger did somehow let a write through, the database rejects it and you get a 409 rather than a 500.

Every mutation writes one row to admin_audit_logs with the full before and after snapshots, inside the same transaction as the change — so a rolled-back edit leaves no audit row and an audit row always describes an edit that really happened. That table is itself append-only and read-only in its own grid.

Updates use optimistic concurrency: the form sends the values it believed were current, and if someone else changed the row in the meantime you get 409 stale_row with the current values attached, instead of silently flattening their work.

Before you start

  • An admin session. Anonymous gets 401, everyone else including venue_manager gets 403.
  • A safe table to practise on — the seeded sandbox_parents and sandbox_children exist for exactly this.

Practise with

PersonaEmailPasswordNote
adminadmin@club.test admin123the only persona with any access to the data suite at all

Steps 1–10 — Admin

their manual →
  1. 1
    Start at the admin hub. Every module that wants an admin surface registers a card here rather than editing a shared template.
    /admin adminsuite
    Expected result Cards for the data browser, the audit log, users, groups and zones, QA, the training manual and the debug console.
    Watch out for Debug-only cards disappear when debug endpoints are disabled. If a card you expect is missing, that is usually why.
  2. 2
    Open the table directory. Tables are grouped by the module that owns them.
    /admin/data adminsuite
    Expected result Every table in the database, with row counts.
    Watch out for The list is generated by introspection each time. If a table is missing, it does not exist in this database — that is a schema question, not a permissions one.
  3. 3
    Open the sandbox parents grid and get used to the controls: sort, per-page, a free-text search, column filters, and the deleted selector.
    Expected result A paged grid with the row count and the effective policy for the table.
    Watch out for The deleted selector defaults to include, so soft-deleted rows are visible and look normal. Check the is_deleted column before you conclude a row is live.
  4. 4
    Open one row. Read the three things beneath the form: recent audit for this row, the row guard notice if there is one, and the inbound dependencies.
    /admin/data/{table}/{pk} adminsuite
    Expected result Full values, plus who has touched this row before you.
    Watch out for A row guard is per-row immutability, not per-table — an executed contract is sealed while a draft one is editable. The refusal is 403 row_guard_forbidden and it happens before any SQL runs.
  5. 5
    Change one field and save. The form sends both the new value and the value you were shown.
    /api/admin/data/{table}/rows/{pk} PATCH adminsuite
    Expected result The updated row, an updated_at stamp if the table has one, and one audit row with old and new snapshots.
    Watch out for 409 stale_row means somebody edited the row while you were looking at it; the response carries the current values so you can re-decide. Also note that empty string and NULL are different things here — clearing a box is not the same as setting null.
  6. 6
    Insert a row from the new-row form. It pre-fills a fresh id and the current timestamp for you.
    /admin/data/{table}/new adminsuite
    Expected result 201 and a new row, audited as an insert with no old snapshot.
    Watch out for Read-only tables 403 on this page rather than showing you a form you cannot submit. Coercion errors are specific — invalid_integer, invalid_iso8601, invalid_json — so read the code, not just the red box.
  7. 7
    Now try the opposite: open a protected table and attempt an edit.
    Expected result The grid loads read-only, and any write is refused with 403 table_policy_forbidden.
    Watch out for There is no override flag, no admin escape hatch and no 'are you sure'. Ledger corrections go through the ledger's own adjustment posting; audit corrections do not exist at all.
  8. 8
    Dump the effective policy for every table and read the reasons column.
    Expected result Each table's policy plus why: registered by its module, detected append-only triggers, or a composite primary key.
    Watch out for The mismatches list is the one to care about — it names tables whose registered policy and detected triggers disagree.
  9. 9
    Finish in the audit browser and find your own edits by table, by action or by your admin id.
    /admin/data/audit adminsuite
    Expected result Your insert and update, with full before and after JSON.
    Watch out for There are no mutation verbs on this feed. You can read and filter it; nobody can prune it.
  10. 10
    When you need a real query rather than a grid, use the read-only SQL endpoint.
    /debug/adminsuite/sql POST adminsuite
    Expected result Up to 500 rows from a single SELECT or WITH statement.
    Watch out for It is provably read-only: one statement, checked for write opcodes before it runs, with a hard interrupt. Do not go looking for a write mode — there is not one, and adding one would defeat every guarantee above.

Delete rows safely

Soft delete, hard delete, dependency resolution, and the guard that stops you locking yourself out.

Owned by Admin · 9 steps · about 20 minutes

Why this exists

Deleting through the data suite is designed around one belief: most deletes should be reversible, and the irreversible ones should be hard to do by accident.

So soft delete is the default. Any table with an is_deleted column can be soft-deleted, which is a flag flip that keeps the row, its id and every foreign key pointing at it intact. It is the only kind of delete that has a restore button. Hard delete really removes the row and demands a typed confirmation string — not a checkbox, a typed word — because the muscle memory of clicking OK is exactly what the guard exists to defeat.

The second idea is that the suite refuses to leave the database inconsistent. Before a hard delete it computes what references the row. If anything does, it stops with 409 and hands you a report, and you must say what to do with each referencing column: reassign the children to another parent, cascade the delete down to them, or nullify the column if it is nullable. Cascades are recursive, but bounded — depth and row limits, children before parents, cycle-safe — because an unbounded cascade in a generic console is a way to lose a database.

Everything happens in one transaction, all or nothing, and every side effect is audited under a shared batch id, so a cascade that touched nine tables reads back as one operation.

Finally, the self-lockout guard. Deleting your own user row, your own session, or the grant that makes you an admin is refused unless you explicitly confirm you meant it. It has saved more evenings than any other check in this module.

Before you start

  • An admin session.
  • Sandbox rows to practise on: /debug/adminsuite/sandbox/seed creates parents with children.
  • Read the dependency report before every hard delete. Every time.

Practise with

PersonaEmailPasswordNote
adminadmin@club.test admin123the only persona with delete rights anywhere in the suite

Steps 1–9 — Admin

their manual →
  1. 1
    Seed the sandbox fixtures so you can practise destructive operations on rows nobody cares about.
    Expected result Parent rows with children hanging off them.
    Watch out for Practise here first. The suite will happily let you cascade real business data, and it will be right to.
  2. 2
    Open the row you intend to delete and read the dependencies panel at the bottom.
    /admin/data/{table}/{pk} adminsuite
    Expected result Every inbound foreign key, how many rows point at this one, whether that column is nullable, and whether the referencing table is even writable.
    Watch out for A read-only referencing table is the reason a cascade will refuse later. Find that out now, not halfway through.
  3. 3
    Pull the same report as JSON when you want to check several rows or keep a record of what you were told.
    /api/admin/data/{table}/rows/{pk}/dependencies adminsuite
    Expected result A list of referencing tables and columns with sample primary keys.
    Watch out for Sample keys are a sample. The count is the number that matters.
  4. 4
    Dry-run the whole thing: the dependency report plus a projected cascade plan with per-table delete counts and depth.
    Expected result A plan that executes nothing and tells you whether it is within the depth and row limits.
    Watch out for If within_limits is false, do not go looking for a bigger limit. Delete in smaller pieces or fix the data model.
  5. 5
    Do the ordinary thing first: a soft delete. Send the primary keys and soft mode.
    /api/admin/data/{table}/delete POST adminsuite
    Expected result is_deleted flips to 1, updated_at is stamped, and one audit row per row is written.
    Watch out for A table with no is_deleted column answers 422 soft_unavailable and tells you to resubmit as hard. That is your cue to go and read the dependency report, not to just change the word.
  6. 6
    Restore what you just soft-deleted, and notice how boring it is.
    /api/admin/data/{table}/rows/{pk}/restore POST adminsuite
    Expected result The row comes back exactly as it was, with a restore audit row.
    Watch out for 409 restore_conflict means a partial unique index has been filled by something else while the row was away — typically a second row now holds the email or the slug. The restore is refused rather than breaking the index.
  7. 7
    Now a hard delete on a parent that has children: send hard mode, the typed confirmation, and a resolution for each referencing column — reassign to another parent, cascade, or nullify.
    /api/admin/data/{table}/delete POST adminsuite
    Expected result One transaction, a per-table breakdown of what was deleted, reassigned or nullified, and every effect audited under a shared batch id.
    Watch out for Without the typed confirmation you get 422 confirmation_required. Without resolutions you get 409 fk_dependencies with the report attached. Neither is the system being awkward — it is refusing to guess.
  8. 8
    Deliberately try to delete your own admin grant, so you have seen the guard fire in a safe moment.
    /api/admin/data/{table}/delete POST adminsuite
    Expected result 409 self_lockout_guard, telling you to resubmit with an explicit self-confirmation.
    Watch out for It also covers your own user row and your own session. The confirmation exists for the legitimate case of decommissioning your own account — think carefully about who is left holding admin before you use it.
  9. 9
    Filter the audit browser by the batch id from the cascade and read the whole operation as one story.
    /admin/data/audit adminsuite
    Expected result Children before parents, each with its full old snapshot.
    Watch out for Those old snapshots are the only copy of a hard-deleted row. If a hard delete turns out to be wrong, the audit log is where the data is — there is no undelete.

Run the QA invariant sweep

Ask the platform to check its own cross-module truths, and read the violations honestly.

Owned by Admin · 8 steps · about 15 minutes

Why this exists

Every module tests itself. The invariant sweep exists for the statements that no single module owns — the ones that are only true if several modules agree. "Every ledger transaction balances." "A listed ticket has a suspended pass." "No payout over the reporting threshold was paid without verified KYC." Those are cross-module truths, and they are exactly the ones that rot quietly.

Fourteen invariants ship, each with a stable id, and they are read-only against any database state. The sweep never repairs anything, and it never writes outside its own two tables. That is deliberate: a checker that fixes things is a checker you stop trusting, because you can no longer tell whether the system was healthy or merely tidied up.

Each invariant is executed independently and its errors are caught individually, so one broken check cannot abort the sweep, and a table that does not exist yet means "nothing to check" rather than a failure. That is what makes the sweep safe to run on a half-built or freshly seeded database.

Read a violation as a question, not a verdict. Some are genuinely legal states in an unusual moment: a reservation looks expired because the clock was moved and nobody ticked the scheduler; a tab is over its limit because a settlement failed and the carryover is expected. The detail and snapshot on each violation are there so you can tell which kind you have.

Before you start

  • An admin session — the dashboard and the debug API are both admin-only.
  • Debug endpoints enabled, otherwise the run button has nothing to call.

Practise with

PersonaEmailPasswordNote
adminadmin@club.test admin123the only persona that can see or run the sweep

Steps 1–8 — Admin

their manual →
  1. 1
    Open the QA dashboard. Read the latest run first, then the journey evidence chips, then the interface-documentation matrix.
    Expected result The last sweep with its status and violations, four journey chips, and a row per module showing whether it ships an INTERFACE.md.
    Watch out for A seeded database always has one run already, recorded by the seed itself, so the dashboard is never empty on a fresh install. Do not mistake that seeded run for a run you just did.
  2. 2
    Run the full sweep with the button on the dashboard.
    Expected result A run id, a status of passed, failed or errored, counts, a duration, and one entry per violation naming the invariant, the table and the offending row.
    Watch out for failed means an invariant found violations. errored means an invariant itself blew up — those are recorded separately and are a bug in the check, not necessarily in your data.
  3. 3
    Read the registry of all fourteen invariant ids and what each one asserts, so a violation id means something to you before you panic.
    Expected result Ordered ids with descriptions.
    Watch out for The ids are stable and are meant to be quoted in incident notes. Do not paraphrase them.
  4. 4
    Re-run a single invariant by passing its id as the scope, so you can iterate quickly while you fix something.
    Expected result The same payload shape, scoped to one check.
    Watch out for An unknown id is 422 unknown_invariant. Copy the id from the registry rather than typing it.
  5. 5
    Look at the run history and ask whether this violation is new. A check that has been failing for a week is a different problem from one that started an hour ago.
    Expected result Recent runs with status and counts, filterable.
    Watch out for Every persisted run writes rows. If you are looping the sweep during an investigation, expect the history to fill up.
  6. 6
    Check the journey evidence probes: did each end-to-end journey's terminal state ever actually happen in this database?
    Expected result Four journeys with an ok flag each.
    Watch out for This is evidence, not a test run. A false chip means nobody has completed that journey here yet — on a fresh demo database that is normal, not broken.
  7. 7
    Pull the one-shot health blob when you want the whole picture in a single response: latest run, journeys, interface docs, append-only status.
    Expected result A single JSON summary suitable for pasting into an incident channel.
    Watch out for The append-only section checks that the protective triggers still exist. If that goes false, stop and treat it as serious — it means the guarantees the rest of this manual relies on are gone.
  8. 8
    Triage violations in the data suite grid, where you can filter and sort them by invariant and entity.
    Expected result One row per violation with its detail and snapshot.
    Watch out for Deleting violation rows makes the dashboard look better and changes nothing about the data. Fix the cause and re-run — a clean sweep you earned is the only one worth having.

Use the debug console

Control the clock, drive the scheduler by hand, read state, and reset a demo database safely.

Owned by Admin · 11 steps · about 20 minutes

Why this exists

Almost everything interesting on this platform happens on a schedule: holds expire, tiers cascade, payouts mature, tabs settle at a month boundary, tax packages generate at a quarter boundary. Waiting for real time to demonstrate any of that is impossible, so the platform ships two levers instead of sleep-based hacks: a clock override and a manual scheduler tick.

They are separate on purpose. Moving the clock changes what the platform believes "now" is; it does not make anything happen. The scheduler tick is what actually runs the handlers. Nearly every confusing training moment — "I set the date to next month and nothing settled" — is those two being conflated. Set the clock, then tick.

The whole tree is gated three ways: it 404s entirely when debug endpoints are disabled (the routes do not exist, so a probe cannot even learn they are there), it requires an admin group, and every non-GET call is recorded in an append-only debug audit log with its parameters. Debug is not a back door; it is a front door with a camera on it.

The database reset is guarded by environment as well: it refuses outright in prod, and it wants a typed confirmation everywhere else. It drops every table, re-runs the schema and re-seeds the core data — so it is the right tool before a demo and a catastrophe during one.

Before you start

  • An admin session and debug endpoints enabled — otherwise every route here is a 404.
  • A non-production environment. The reset refuses in prod, and the clock override should never be used against real customers.

Practise with

PersonaEmailPasswordNote
adminadmin@club.test admin123the only persona for whom /debug exists at all

Steps 1–11 — Admin

their manual →
  1. 1
    Open the debug console. The card at the top gives you the environment, the database path, the schema version and the current clock override, plus the two controls you will use most.
    /debug core
    Expected result A Set Clock form, a Clear button, and a Run Scheduler Tick button that report their JSON result in place.
    Watch out for The Registered debug routes table below renders empty in this build — the route index only walks top-level routes and this version of the framework nests them. The forms work; the directory does not. Navigate to module debug endpoints by URL or from the admin hub. FILED 2026-08-30 as plan 679. This paragraph was written on 2026-08-07 with the manual itself and had been the only record of the defect since: measured, app.routes holds 68 Mount and _IncludedRouter objects and zero paths beginning /debug, so the filter can never match. Delete this sentence and the two before it when 679 lands.
  2. 2
    Set the platform clock to a specific instant using the full UTC format, for example the first day of next month at midnight.
    /debug/clock POST core
    Expected result The override is stored and every timestamp the platform writes from now on comes from it.
    Watch out for A malformed timestamp is 400 bad_timestamp. And setting the clock alone changes nothing else: no handler runs until you tick.
  3. 3
    Run a tick. This is what actually executes the handlers: order-hold expiry, idle-cart abandonment, the event lifecycle, marketing, resale, payout release, monthly tab settlement and quarterly tax filings.
    Expected result A scheduler run row with a per-handler result.
    Watch out for You can pass an explicit now to a tick instead of moving the clock, which is often cleaner. Handlers run in sorted-name order, so events runs before marketing — that ordering is why a tier cascade and the social post announcing it can land in the same tick.
  4. 4
    Read the recent runs and their handler output when a tick did not do what you expected.
    Expected result Recent runs newest first with their results.
    Watch out for A handler that reported nothing usually had nothing due. Check your clock before you suspect the handler.
  5. 5
    Dump the whole environment: database path, schema version, clock override, row counts for every table, and every setting.
    Expected result One response that answers most 'is this database the one I think it is' questions.
    Watch out for Row counts are the fastest way to spot a database you have not seeded. If the counts are near zero, stop debugging and seed.
  6. 6
    List the platform settings with their values, then change one with the settings endpoint when you need to steer behaviour rather than data.
    Expected result Every setting row, and the updated row after a change.
    Watch out for Settings are real configuration, not scratch space. Changing a ledger threshold or a tax scheduler flag to make a demo work will still be changed tomorrow when someone else is demoing something else.
  7. 7
    Set one setting by key, sending the new value.
    /debug/settings/{key} PUT core
    Expected result The stored row with its new value and who updated it.
    Watch out for Internal markers exist here that you should not touch by hand — the ledger's last-settled-period marker is one. Changing it will make settlement skip or repeat a month.
  8. 8
    Read the outbound wire log: every call the platform made to a mock payment processor, Telegram, a wallet, the mail house or a social network, with its request and response.
    Expected result Recent calls, filterable by service, method and correlation id.
    Watch out for This is the single place to answer 'did we actually send it'. Filtering by correlation id gets you the whole story of one package, order or post.
  9. 9
    Read the debug audit log and see your own non-GET debug calls recorded with their parameters.
    Expected result Action names prefixed with the module, plus the body you sent.
    Watch out for Append-only, like every audit table here. Assume anything you do under /debug is on the record, because it is.
  10. 10
    When a demo database is beyond saving, reset it: send the typed confirmation and it drops every table, re-applies the schema and re-seeds core data.
    /debug/db/reset POST core
    Expected result Confirmation that the reset ran.
    Watch out for 403 env_forbidden in prod, and 400 without the exact confirmation word. It does NOT run the module seed hooks — run the seeder afterwards if you want demo events, credit and training content back.
  11. 11
    Clear the clock override when you are finished, by sending a null value.
    /debug/clock POST core
    Expected result The override is removed and the platform returns to real time.
    Watch out for This is the step everyone skips. A left-behind clock override is the single most common cause of 'the platform has gone mad' the next morning — expired sessions, reservations that will not hold, invariants that report impossible times.

Script a mock failure

Make the payment processor decline on purpose, replay a webhook, and read the wire log.

Owned by Admin · 9 steps · about 18 minutes

Why this exists

Every external service on this platform — the card processor, Telegram, the wallet push services, the print-and-mail house, the social networks — sits behind an adapter with an in-repo mock. The whole system therefore runs and tests offline, and, more usefully, failures are scriptable. You do not need a real declined card to practise a declined card.

A mock behaviour is a small instruction: for this service, for this method, behave this way, this many times. The behaviours are success, error, timeout and decline. They are consumed as they fire, which is what makes them safe — you script one decline, the next call declines, and the call after that succeeds again. Nothing is left permanently broken because you forgot to undo it.

Inbound events work the other way round. Real providers call webhooks; in training you simulate one, and every simulated event is stored exactly like a real one, which means you can replay it. Replay is how you prove idempotency: a settled payout replayed twice must not pay twice. Seven inbound events have handlers registered in this build — counted from the registry on 2026-08-30, and corrected from "three": payments charge.succeeded, charge.failed, payout.settled and payout.failed; pos.sales_snapshot; sound.reading_batch; and the Telegram callback_query. Anything else is stored but answered with 422 no_handler rather than being silently swallowed.

The point of all of it is that failure paths get rehearsed. A tab settlement that declines freezes a tab and leaves the balance owed; a filing whose mail partially fails leaves the package in a specific state you must recognise. You want to have seen those in training, not for the first time in production.

Before you start

  • An admin session with debug endpoints enabled.
  • A non-production environment — scripted failures are for demo and training databases.
  • A VIP with an unsettled tab if you want to watch a decline land somewhere real (vip@club.test has one).

Practise with

PersonaEmailPasswordNote
adminadmin@club.test admin123scripts the behaviour and reads the wire log
vip_membervip@club.test vip123seeded with a $250 tab limit and $80 used — the balance a declined settlement will strand

Steps 1–9 — Admin

their manual →
  1. 1
    List the behaviours currently scripted, before you add another. Leftovers from someone else's demo explain a surprising number of mysteries.
    Expected result Any queued behaviours with their service, method and remaining count.
    Watch out for An empty list means every adapter is behaving normally. That is the state you should leave it in.
  2. 2
    Script one decline: service payments, the charge method, behaviour decline, one time.
    Expected result The stored behaviour row.
    Watch out for Only five services and four behaviours are accepted; anything else is 400 bad_behavior. The count matters — script one, not one hundred, unless you want to spend the afternoon wondering why nothing works. And know what those five are NOT: the adapter registry holds EIGHT, and email, pos and push are refused by a hand-written list that predates them. So the one failure you cannot rehearse here is email — the channel carrying the signup code, the sign-in link and every invoice. Plan 677.
  3. 3
    Now fire something that charges a card: run the tab settlement for a period, for the one user you scripted the decline against.
    Expected result The settlement for that user is recorded as failed, and their tab is frozen. The balance is still owed.
    Watch out for Freezing a tab is a credit decision, not forgiveness. Nothing is written off, and the person keeps the debt — they simply cannot add to it.
  4. 4
    Confirm what the failure looks like from the operational surface you would actually be sitting in front of.
    Expected result A failed settlement row for that user and period.
    Watch out for Settlements are unique per user and period, so the retry path is a retry — not a second settlement. This is why a failed row is a thing to act on rather than delete.
  5. 5
    Read the wire log filtered to the payments service and find the declined charge, with the request and the response the mock returned.
    Expected result The outbound call recorded even though it failed.
    Watch out for Declines still commit their wire log. If a call is missing here, it never left — that is a different bug from a call that was rejected.
  6. 6
    Now go the other way: simulate an inbound event. Send a payments payout settlement with a transfer id that exists.
    Expected result The event is stored, dispatched to its handler and marked handled.
    Watch out for An event type nobody handles is stored and answered 422 no_handler. CORRECTED 2026-08-30: SEVEN event types have handlers, not three — payments charge.succeeded, charge.failed, payout.settled and payout.failed, pos.sales_snapshot, sound.reading_batch, and the Telegram callback_query. So a 422 means you named something outside that list; check it against the list before concluding the platform is missing a handler.
  7. 7
    Replay the same stored event and watch nothing happen twice.
    /debug/webhooks/{webhook_id}/replay POST core
    Expected result The handler runs again and reports a no-op; the payout is not paid a second time.
    Watch out for That no-op is the whole point of the exercise. If a replay ever does move money twice, you have found a real bug and it belongs in an incident, not in a training note.
  8. 8
    Delete any behaviour you scripted but did not consume.
    /debug/mock-behaviors/{behavior_id} DELETE core
    Expected result Confirmation that it is gone.
    Watch out for This is the tidy-up nobody remembers. An unconsumed decline sitting in the queue will ambush the next person to demo a purchase.
  9. 9
    Finish by reading your own trail: every behaviour you scripted, every webhook you simulated and replayed, with the bodies you sent.
    Expected result Your session's debug actions, in order.
    Watch out for Append-only. Scripting a failure is a legitimate thing to do and a recorded thing to do — both at once.

Keep the permanent event record

Fill the night's archive while it is still open, seal it at settlement, and correct it afterwards by appending — because nothing sealed can change.

Owned by Admin · 22 steps · about 26 minutes

Why this exists

Every event ends up with one permanent file: the contract that was actually signed, the counts that were actually taken, the bar and merch money, the sound readings, and a pointer into the ledger for everything financial. It is designed to be readable in ten years without any of the modules that produced it still behaving the same way — which is a much stronger promise than "we kept the data".

The freeze happens at settlement, not at the end of the night

The scheduler flips an event to completed at teardown, typically three or four in the morning. The bar's Z-report, the merch settlement and the promoter's invoice all arrive the following business day. Freezing at completion would therefore guarantee an empty archive, every single time.

So there is a middle state — pending seal — which is fully writable, unbounded, and completely safe to sit in. The automatic seal comes a week after teardown by default. Until then, the record is where you put the numbers.

After the seal, corrections are appends

A sealed record is immutable, and that is enforced by database triggers rather than by policy — the admin data grid and a raw shell obey it too. There is no delete route, no delete function, and no soft-delete column: a permanent record that can be soft-deleted is not permanent.

Corrections are amendments: they name what was wrong, the old value, the new value, a mandatory reason and who did it, and they render underneath the original rather than replacing it. Late real-world movement — a chargeback landing three months later — is drift: recomputed against live sources and appended, never absorbed into the frozen number. The frozen figure and the true figure are both visible, which is the only honest way to show a number that has moved since it was archived.

Money is referenced; documents are copied

The ledger is already append-only and is the platform's financial truth. Two tables holding money would be two answers to one question, so the record stores totals plus a reference into the ledger and writes no amount into any ledger table. Documents are the opposite: the signed contract's bytes are copied verbatim and hash-verified, because the document is the artifact itself and the archive's promise about it is stronger than its source's.

One measurements table, and the retention ladder

There is exactly one table of samples, and a single value is simply a series of length one. Whether a metric is a running total or a stream of deltas is one cell in the metric registry — changing it changes what the headline number means with zero stored rows rewritten.

"Forever" has a cost, so raw samples live for the dispute window, per-minute rollups for seven years, quarter-hour and whole-event rollups permanently — plus the top peak samples, pinned and never deleted at any tier. That last part is the legally meaningful artifact: not "the peak was 103.4 decibels" but "at this instant, this meter read 103.4", kept at full resolution with its provenance for about ten kilobytes.

Before you start

  • An admin session for sealing, amending, the metric registry and devices.
  • A venue_manager or admin session to read a record or enter measurements.
  • An event that has run. The seed ships a completed event with a sealed demo record.
  • The night's paperwork: the bar Z-report, the merch settlement, any sound-meter export.

Practise with

PersonaEmailPasswordNote
adminadmin@club.test admin123the only role that can seal, amend, or manage metrics and devices
venue_managermanager@club.test manager123reads every record and supplies the night's numbers

Steps 1–2 — Admin

their manual →
  1. 1
    The morning after, open the event's record. It already exists — one is opened automatically when the event goes in progress and moved to pending seal when it completes or is cancelled.
    /admin/events/{event_id}/record record
    Expected result A record in pending seal, writable, with the archived documents already captured and the metric tiles mostly empty.
    Watch out for Pending seal is a normal resting state, not a task you are behind on. The automatic seal is a week after teardown by default — that window is the settlement window, and using all of it is fine.
  2. 2
    If a record is genuinely missing — an event that never transitioned normally — open one explicitly. The call is idempotent.
    /api/records/{event_id}/open POST record
    Expected result A record in the open state, or the existing one returned unchanged.
    Watch out for State only ever moves forward, enforced by a trigger. There is no way back from pending seal to open, and none at all from sealed.

Steps 3–5 — Venue Manager

their manual →

Reads every record and is the person who actually has the night's numbers.

  1. 3
    Read the record as it stands. Attendance is already derived from the door scans; the bar, merch and sound tiles are the ones waiting for you.
    /admin/events/{event_id}/record record
    Expected result Frozen counts and ledger-referenced money, plus empty tiles for everything only you can supply.
    Watch out for Scan detail is referenced, not copied — the scanned-in count is frozen as a derived measurement rather than three thousand rows being duplicated so they can be recounted later.
  2. 4
    Type in the numbers you have on paper: bar sales, bar transactions, merch sales, merch units, staff hours, incidents.
    /api/records/{event_id}/measurements POST record
    Expected result Each value stored as a sample with your name on it as provenance.
    Watch out for Money is in integer cents everywhere in this platform. Typing dollars into a cents field is the single most common mistake here, and after the seal it costs an amendment with a written reason.
  3. 5
    For a sound-meter export or a POS report, import the CSV. Run it as a dry run first and read what it says it would accept and reject.
    /api/records/{event_id}/measurements/import POST record
    Expected result A per-row verdict; on the real run, accepted, rejected and duplicate counts.
    Watch out for Rejections are per sample, never per request — one bad timestamp in a thousand rows must not discard the other nine hundred and ninety-nine. Read the rejected list rather than assuming the import failed.

Steps 6–16 — Admin

their manual →
  1. 6
    Look at the metric registry before you invent a metric. It defines the label, the unit, whether the metric is a point or a series, and — the important one — how many samples collapse into one headline number.
    Expected result The catalogue: attendance, bar, merch, sound, ops.
    Watch out for Aggregation is the whole design. A till pushing a running total all night and a till pushing per-period deltas are one registry cell apart, with no stored row rewritten. Get this cell wrong and every headline for that metric is wrong; fix the cell and they are all right again.
  2. 7
    Add a metric the venue actually tracks. Decide its aggregation and its cardinality deliberately, and mark it required at seal only if a record without it is genuinely incomplete.
    /api/record/metrics POST record
    Expected result A new registry row, immediately usable by every ingestion door.
    Watch out for Deactivating a metric never deletes its row, and an unknown key arriving from a device is refused rather than auto-creating a metric. The registry is deliberately something a human curates.
  3. 8
    Open the device list. A metric device is the direct analogue of a door reader: a token, the metric keys it is allowed to post, which event it binds to, and when it was last seen.
    Expected result Registered devices with their last-seen times.
    Watch out for A device that has not been seen since the last event is the thing to notice before the doors open, not after the night is over and the sound data does not exist.
  4. 9
    Register a device and restrict it to the keys it should ever post — a sound meter cannot post bar sales.
    /api/record/devices POST record
    Expected result The device, and its token shown exactly once.
    Watch out for Once is once. If it is not captured at that moment the only way forward is to rotate it, which is a deliberate design and not an inconvenience to work around by storing tokens somewhere convenient.
  5. 10
    Rotate a token when a device is replaced, lost, or its token was pasted somewhere it should not have been.
    /api/record/devices/{device_id}/rotate-token POST record
    Expected result A new token, shown once; the old one stops working immediately.
    Watch out for Rotation is the recovery path for every device credential problem. Budget for reconfiguring the device at the same time — nothing tells it the token changed.
  6. 11
    Understand the device door, because it behaves unlike every other endpoint here: it authenticates on a device token header only, and session cookies are not consulted at all.
    /api/record/ingest POST record
    Expected result Batched samples accepted, with duplicates counted separately.
    Watch out for A logged-in admin posting here without the header is refused, deliberately. A meter is never a person, staff already have a form for typing numbers in, and 'a device can post with a token but not a session' is only a crisp property if there is no fallback. Replayed batches are recognised by their idempotency keys, so a flaky meter retrying cannot double-count.
  7. 12
    Know the one refusal that surprises people: a reading whose timestamp falls inside a night that is already sealed is rejected as sealed, not re-homed onto whatever record happens to be open.
    /api/record/ingest POST record
    Expected result A per-sample rejection naming the reason.
    Watch out for A meter whose clock has drifted by a week must not reopen history, and it must say so rather than quietly filing last month's readings against tonight.
  8. 13
    Before sealing, ask what is blocking. The check names each blocker and which ones cannot be forced.
    /api/records/{event_id}/seal-check record
    Expected result Ready, or a list: orders awaiting payment, open disputes, refunds in flight, a contract that is not yet terminal, a missing required metric, an unbalanced ledger, or a contract whose bytes do not match their hash.
    Watch out for Every blocker is a genuinely unfinished piece of the night. Work the list rather than reaching for force — the list is short precisely so that clearing it is realistic.
  9. 14
    Seal the record. The contract's bytes are copied and hash-verified, the section and signature manifests are captured, the event and venue are snapshotted, and the rollup tiers are materialised inside the same transaction.
    /api/records/{event_id}/seal POST record
    Expected result A sealed, immutable record with its documents attached.
    Watch out for Forcing overrides every blocker except a contract that does not match its own hash — a document that fails its hash is not evidence of anything, and sealing it would launder a corruption into a permanent record. Whatever you force is written into the record's reason and rendered in red on the page forever.
  10. 15
    Correct a sealed record by appending an amendment: what is wrong, the old value, the new value, and why.
    /api/records/{event_id}/amendments POST record
    Expected result The original still rendered, with your amendment beneath it and folded into the comparison view.
    Watch out for The reason is mandatory and an empty one is refused. The point of an amendment is that a stranger can read the pair and understand what happened; a correction with no explanation is just a second number.
  11. 16
    Verify a record whenever you have reason to doubt it — after a restore, after a migration, or before handing it to somebody official.
    /api/records/{event_id}/verify record
    Expected result Stored hashes recomputed and compared.
    Watch out for Verification is about the archive's integrity, not about whether the numbers are right. It answers 'has this been tampered with', which is a different and much more important question.

Steps 17–20 — Venue Manager

their manual →

Reads every record and is the person who actually has the night's numbers.

  1. 17
    Read the sealed record. Post-seal movement appears in its own panel rather than silently changing the frozen figures.
    /admin/events/{event_id}/record record
    Expected result Frozen headlines, a post-seal activity panel where there has been drift, and amendments under their originals.
    Watch out for A drift row is not an error to resolve by editing. Somebody with admin reads it and decides whether it warrants an amendment — that decision is the work, and the drift row is the prompt for it.
  2. 18
    Open an archived document. What you get is the stored bytes, verbatim — the contract exactly as it was signed, not re-rendered from today's template.
    /admin/events/{event_id}/record/documents/{doc_id} record
    Expected result The artifact itself.
    Watch out for Templates get revised and retired. This is why the record also keeps a manifest of which section came from which template version: it proves what was signed long after the template that produced it has moved on.
  3. 19
    Compare nights. Pick the metrics you care about and a date range and read them across events.
    Expected result A comparison table, exportable as CSV.
    Watch out for Comparison reads through the same resolver every other surface uses, so a metric whose aggregation was corrected reads consistently across every night at once — including the ones sealed before the correction.
  4. 20
    For a spreadsheet or a report, take the comparison as JSON or CSV directly.
    Expected result The same numbers the page shows, in a file.
    Watch out for Reading records is a venue_manager job; everything that writes to one is admin, because every write here is permanent by construction and there is no undo anywhere in this module.

Steps 21–22 — Admin

their manual →
  1. 21
    When something does not add up, pull the whole record as one blob: documents with their hashes, riders, facts with their ledger references, resolved metrics, per-tier sample counts, drift, amendments, and the computed record hash next to the stored one.
    /debug/record/{event_id} record
    Expected result Everything about that night on one screen.
    Watch out for The two hashes disagreeing is the strongest signal this module can give you. Treat it as an incident, not as a display bug.
  2. 22
    Sweep the entire archive periodically. An empty list means every record still verifies.
    Expected result An empty list on a healthy install.
    Watch out for Also worth watching: how often seals are forced. If staff routinely force past blockers, the blocker list has stopped being a control and become decoration, and the fix is upstream rather than here.

Preview the site as a guest — and leave feedback on it

See the signed-out site — or any persona's site — without signing out, so the experience most visitors have is one you can annotate.

Owned by Venue Manager · 14 steps · about 12 minutes

Why this exists

The feedback overlay needs a session. An anonymous visitor gets no overlay at all, by design. But the signed-out page genuinely differs from the signed-in one — an event offers Log in to buy instead of Reserve, the member and staff nav groups vanish, member-only sections disappear — so the experience most visitors actually have was the one experience nobody on the team could leave a note about.

Preview closes that gap. Your session keeps its real identity and its real feedback tools, and the page is drawn as somebody else.

The invariant: preview changes what is rendered, and nothing else

Your account and your groups are loaded from the real session and are never touched. Every permission check, every service call, every API response and every write runs with your real rights, in preview or out of it. An admin previewing as a guest still passes every admin API call.

The persona reaches exactly one place — the template context — and it can only subtract or restate what is drawn. There is no mechanism by which it could grant anything. Read that as the safety guarantee it is: previewing as an admin does not make you one, and previewing as a guest cannot lock you out of your own session.

Who may preview, and why the bar is where it is

venue_manager and above. Reviewing the site is a staff job, and a member must not be able to produce a screenshot of a fake staff page even cosmetically. door_staff sit below the bar — a manager implies door staff, not the other way round. Leaving preview is deliberately not gated at all: getting out must always work.

If your grants are revoked while a preview is open, it goes inert immediately — the persona stops applying on the very next page. A just-demoted account keeping a cosmetic staff view for the rest of its session is exactly the spoofing the bar exists to prevent.

Gated pages render the persona's real outcome, in place

Drawing the real member hub wrapped in guest chrome would be a screen that exists nowhere in the product, and notes about it would be notes about a fiction. So a page the persona could not reach is replaced by a page that states plainly what they would get: the sign-in wall for a guest, or a forbidden page naming the group they lack. The URL does not change, the preview bar stays, and there is an inline button to leave preview and open the page for real.

The status code is always 200 — this is our page describing a simulated outcome, not the outcome itself. Real 404s are never substituted: a missing page is missing for everybody, and re-dressing one would hide a genuine failure.

Where the state lives, and why it is not a link

Preview is stored per session, not per user and not in the URL. A shareable preview link would put a reader into a mode they never asked for, on a URL they believe is the real page. A per-user setting would stop you keeping a real window and a preview window open side by side. And because it hangs off the session, preview ends at logout is true by construction rather than by a hook somebody could forget.

Before you start

  • A venue_manager or admin session. Members and door staff cannot enter preview.
  • Nothing else. Every page of the app can be previewed.

Practise with

PersonaEmailPasswordNote
venue_managermanager@club.test manager123the lowest role that may preview; admin implies it
adminadmin@club.test admin123owns the gate-decision tool and the preview audit trail

Steps 1–11 — Venue Manager

their manual →
  1. 1
    From any page, open the site menu and look directly under the Signed in as line. That is where View as… sits: one primary button for Signed-out visitor, plus a chip for each of the other personas.
    /events frontend
    Expected result A persona switcher inside the menu, right under your own account line.
    Watch out for They are buttons, not links. Entering preview is a state change, so it is a form post with a CSRF token — you cannot bookmark it, and you cannot send it to somebody.
  2. 2
    Enter preview as the signed-out visitor.
    /preview/enter POST frontend
    Expected result The page reloads with a magenta bar pinned to the top, tagged PREVIEW, naming the persona in caps and showing your real email beside it.
    Watch out for The bar is deliberately nothing like the amber demo banner, and it sits above it. If you cannot see it, you are not in preview — check before you file a bug about the page you are looking at.
  3. 3
    Read the events list as a guest. The member and staff nav groups are gone; so is anything the catalogue only shows to a signed-in visitor.
    /events frontend
    Expected result The public catalogue, in public chrome, at the same URL.
    Watch out for You are still you. Your rights have not changed — only the drawing has. If a page still shows you something staff-only, that is a real bug worth a note, not the preview leaking.
  4. 4
    Open a single event. This is the page the feature was built for: a guest is offered Log in to buy where a member gets a reserve action.
    /events/{event_id} frontend
    Expected result The signed-out purchase path, exactly as a first-time visitor meets it.
    Watch out for This is the highest-value page to annotate, because it is the one every visitor sees before they have any reason to trust you.
  5. 5
    Now try a page a guest cannot reach. The URL stays; the page is replaced by a plain statement that a signed-out visitor would be sent to the sign-in wall.
    /my frontend
    Expected result A 200 page describing the outcome, carrying the preview bar and an inline button to exit preview and open the page for real.
    Watch out for The sign-in wall is the guest experience for everything under the member and admin areas. It is worth annotating — a bad sign-in wall loses more people than a bad events page.
  6. 6
    Leave a note on what you are looking at. The feedback pill and the whole overlay stay live while you are previewing — that is the entire point of the feature.
    /api/annotations POST annotations
    Expected result A numbered marker, exactly as on any other page.
    Watch out for The overlay belongs to whoever is really signed in, which is why it survives the signed-in chrome disappearing. Losing it while previewing as a guest would make the feature pointless.
  7. 7
    Find the note in the review queue. It carries an as guest pill in its location cell.
    /admin/annotations annotations
    Expected result The note tagged with the persona you were previewing when you wrote it.
    Watch out for That tag is written from the server's own record of your session state, never from anything the page sent. Without it, 'the buy button is missing' is ambiguous in the worst possible way — either a bug, or a correct rendering of the page the author was reading.
  8. 8
    Open the note. The header repeats the pill, a sentence explains what it means, and the location table carries a Viewed as row.
    /admin/annotations/{annotation_id} annotations
    Expected result For a note written in your own view, that row reads 'the author's own view'.
    Watch out for The JSON export carries the persona; the CSV export deliberately does not, because its column list is frozen. Use JSON when you need provenance in a machine-readable form.
  9. 9
    Switch persona without leaving preview, from the switcher in the bar itself or from the feedback drawer's View this page as… row.
    /preview/enter POST frontend
    Expected result The same URL, redrawn as the new persona.
    Watch out for The drawer entry exists because somebody who has just opened 'leave feedback on this page' is exactly the person who needs to ask what a signed-out visitor sees. On a phone the bar collapses to persona and exit only — use the drawer for the full switcher.
  10. 10
    If you are wiring this into a tool of your own, read the state as JSON: whether you may preview, whether one is active, the persona list and the minimum group.
    /ui/preview frontend
    Expected result A small JSON object; inert for anybody below the bar.
    Watch out for It reports the live answer, re-checking your grants rather than trusting the stored row. A preview whose owner has lost the right reads as inactive here.
  11. 11
    Leave preview with the ✕ on the bar when you are done.
    /preview/exit POST frontend
    Expected result The bar disappears and you are back on the same page as yourself.
    Watch out for Exiting is not group-gated — it always works, even if the grant that let you enter has been revoked in the meantime. Logging out also ends it, because a preview cannot outlive the session that started it.

Steps 12–14 — Admin

their manual →

Owns the gate-decision tool and the append-only audit of who previewed what.

  1. 12
    When somebody asks 'why did that page block for a member', ask the gate directly: give it a path and a persona and it shows the decision and the reason.
    Expected result The verdict — allowed, needs a session, or needs a group it names.
    Watch out for The decision is read off the route's own declared dependencies, not a hand-kept path-to-group table that could drift away from the real authorization. Anything it does not recognise renders normally, so the mechanism can only over-hide, never over-reveal.
  2. 13
    Read the preview state: your own, every open preview on the platform, and the recent audit entries.
    Expected result One row per enter, switch and exit, with actor, session, personas, page and IP.
    Watch out for That audit is append-only and registered read-only with the data suite — you cannot tidy it up, and neither can anybody else. It is the answer to 'who was looking at the site as an admin last Tuesday'.
  3. 14
    Know the one conservatism in the gate. A page guarded by 'host of this event' blocks the host persona whenever the event on screen is not the one the persona is scoped to.
    Expected result A blocked page where you might have expected a rendered one.
    Watch out for It is over-hiding on purpose. The alternative — guessing which event a persona should count as host of — is how a preview starts showing somebody a page they could not really open.

Run the feedback backlog as a build list

Triage what the pencil collects: filter it, assign it, close it, and export the current filter set as a checklist you can hand to a developer.

Owned by Venue Manager · 14 steps · about 15 minutes

Why this exists

Notes arrive attached to the thing they are about. A note carries the page path, the route pattern, a fingerprint of the element it was left on, a snippet of what that element said, the viewport width, and — if the author was previewing the site as somebody else — which persona they were looking at. So "this is confusing" arrives already answering what is and where.

That is why the backlog is worth running as a queue rather than reading as a mailbox. Everything a triager normally has to ask for is already on the row.

Why the queue is shared, and the two carve-outs that make it safe

Anyone signed in sees every non-deleted note on any page they can already open — not just their own. A per-person silo produces five copies of "this heading is wrong" and no discussion.

Two carve-outs keep that safe. Notes on staff-only path prefixes — the admin area, the debug console, the scanner — are readable and writable by employees only, because a text snippet harvested from an admin grid can contain other members' data. A non-staff read of one of those pages returns an empty list rather than a refusal, so the endpoint cannot be used to find out which admin pages exist. And one setting narrows every non-staff surface to your own notes only — the switch to flip the day this install holds real user data, with no code change and no deploy.

What you may change, and what you may not

A manager may triage a note but never rewrite its words. Status, priority, category and assignee are yours; the body belongs to its author, or to an admin. An author may resolve or reopen their own note and nothing else. Deleting across authors, and restoring, are admin-only — and deletion is a soft delete, because this module has no hard delete anywhere.

Every one of those changes writes a row to an append-only trail rendered on the note itself. So does every export. Nobody can quietly re-triage history.

Anchors are allowed to go stale, on purpose

A note points at an element by a generated fingerprint. Redesign the page and that element may be gone; the note is then flagged unanchored and kept, never deleted — because "the thing I complained about no longer exists" is usually the signal that it was fixed. Unanchored is therefore a filter you triage, not an error you repair.

The export is the point

The queue exists to become work. Any filter set exports as Markdown, CSV or JSON, and the Markdown one is literally a checklist you can paste into a tracker. Filter to open, high priority, category bug, export, hand it over. That is the loop this feature was built for.

Before you start

  • A venue_manager or admin session for the shared queue. Any signed-in session can leave notes.
  • Some notes to triage. The seed ships four, covering open, resolved, unanchored and a hostile-input probe.
  • The capture gesture itself is a separate workflow — Leave feedback on any part of the app.

Practise with

PersonaEmailPasswordNote
venue_managermanager@club.test manager123triages the shared queue and owns the export
membermember@club.test member123leaves notes and can resolve or reopen their own
adminadmin@club.test admin123the only role that can delete across authors, restore, or edit someone's words

Steps 1 — Member

their manual →

Supplies the backlog, and is the only person besides an admin who may edit their own words.

  1. 1
    A note enters the queue the same way every note does: feedback mode on, hover a block, click its pencil, write, choose a category and a priority. Any signed-in role can do this — member, host, door staff, manager, admin.
    /api/annotations POST annotations
    Expected result A numbered marker on the page and a new row in the shared queue.
    Watch out for Categories are bug, copy, layout, feature and question; priorities are low, normal and high. Whatever the author picks is a starting position, not a verdict — re-categorising is a normal part of triage.

Steps 2–10 — Venue Manager

their manual →
  1. 2
    Open the shared review queue from Feedback Review in the site menu. Start by sorting rather than reading: by status, by priority, by last activity.
    /admin/annotations annotations
    Expected result Every non-deleted note in the app, in one list, with filters above it.
    Watch out for Status and priority sort by pipeline order, never alphabetically — open before acknowledged before in progress, high before normal before low. Sorting by status and getting acknowledged first would be a bug, not a preference.
  2. 3
    Read the page-counts view to find where complaints cluster. One page with nine notes is a different problem from nine pages with one each.
    Expected result Counts per page path, honouring your current filters.
    Watch out for A row whose page is shown as plain text rather than a link is a note pinned to a moment in a recorded workflow clip — that value is the route pattern the step was on, not a page you can open.
  3. 4
    Open one note and read the captured context before the body: the page, the route pattern, the element and its text snippet, the viewport width, and the Viewed as row.
    /admin/annotations/{annotation_id} annotations
    Expected result Enough context to reproduce without asking the author anything.
    Watch out for The viewport width is the most under-used field here. 'The buttons overlap' at 375 pixels and at 1440 are two different tickets, and the note already told you which one it is.
  4. 5
    Triage it: move the status along the pipeline, set priority and category, and assign it to a person.
    /admin/annotations/{annotation_id} POST annotations
    Expected result The change applied and an entry appended to the note's trail.
    Watch out for The pipeline is enforced, not advisory. Open, acknowledged and in progress reach each other and both terminal states; resolved can only go back to open or in progress; won't-fix can only be reopened. An illegal jump is refused, and a stale status — somebody else moved it while your page was open — is refused as a conflict rather than silently winning.
  5. 6
    Reply in the thread instead of editing the note. Ask the question, record the decision, or say what you did.
    /admin/annotations/{annotation_id}/reply POST annotations
    Expected result A reply on the note, visible to its author and to anyone reviewing.
    Watch out for You may never rewrite somebody's words — only the author or an admin can edit a body. That boundary is what keeps the backlog trustworthy: a note you read is what its author actually wrote.
  6. 7
    Filter for anchor health: show the notes whose element can no longer be found. Filter the other way to hide them while you work through live ones.
    /api/annotations/list annotations
    Expected result The unanchored slice, kept rather than discarded.
    Watch out for An unanchored note is usually good news — the thing complained about is gone. Read it, decide whether it was fixed or merely moved, and close it with a reply saying which. Do not treat the flag as data corruption.
  7. 8
    Set the filters you actually want to hand over — open, high, bug — then export. Markdown gives you a checklist; CSV gives you a spreadsheet; JSON gives you everything including the preview persona.
    Expected result A downloaded file covering exactly the rows your filters selected, plus an export entry in the audit trail.
    Watch out for Exports are served as attachments with sniffing disabled, and any CSV cell starting with an equals, plus, minus or at sign is prefixed with an apostrophe. Spreadsheet formula injection through user-written text is a real attack, not a theoretical one.
  8. 9
    Check your own slice too — notes you wrote, notes assigned to you, notes you replied to.
    /my/annotations annotations
    Expected result Your involvement only, filterable by status.
    Watch out for This page ignores the 'own notes only' setting entirely: it is always yours-plus-assigned-plus-replied. Do not use it as a proxy for the backlog — things assigned to other people will not be here.
  9. 10
    Know the two visibility levers you do not own. Staff-only path prefixes are a setting; so is whether non-staff can see each other's notes. Both are platform settings an admin changes.
    Expected result A clear escalation rather than a confusing empty list.
    Watch out for If a member reports 'my note vanished', check whether it was left on a staff-only path — they can see the page but not the notes on it, and the empty list is deliberate rather than broken.

Steps 11–14 — Admin

their manual →

Holds the destructive half: cross-author delete, restore, and the module kill switch.

  1. 11
    Delete a note that should not be in a shared queue — someone's personal data pasted into a body, a duplicate, a test.
    /admin/annotations/{annotation_id}/delete POST annotations
    Expected result The note leaves every list and the deletion is recorded.
    Watch out for Admin only, and it is a soft delete — the row survives and can be listed with an explicit filter. There is no hard delete anywhere in this module, which is deliberate: an audit trail with a hole in it is not an audit trail.
  2. 12
    Restore something deleted in error.
    /admin/annotations/{annotation_id}/restore POST annotations
    Expected result The note returns to the queue with its replies and its trail intact.
    Watch out for Restoration is why the soft delete exists. If you find yourself wanting a hard delete, what you actually want is the 'own notes only' setting turned on so the sensitive content never gets shared in the first place.
  3. 13
    After a workflow clip is re-recorded, list the notes whose step no longer exists.
    Expected result The orphaned clip notes, kept and flagged rather than dropped.
    Watch out for Re-recording re-anchors notes onto the same numbered step where it still exists. What lands here is the genuinely homeless remainder — a step that was removed, which is itself worth knowing.
  4. 14
    Read the module-wide trail when you need to answer 'who changed this, and when' across notes rather than within one.
    Expected result Create, comment, status, priority, category, assign, edit, delete, restore, anchor and export events.
    Watch out for The trail table is append-only at the database level and registered read-only with the data suite, so neither the admin CRUD grid nor a raw shell can tidy it. Exports appear here with no note attached — that is correct, an export is about the queue rather than about one row.

Run the staff channel on live Telegram

Take the integration off the mock: a real bot posts the application card, a real button press flips the application, and the card edits itself.

Owned by Venue Manager · 16 steps · about 18 minutes

Why this exists

Telegram is the venue manager's primary channel, so intake's messages were built to be acted on from the chat rather than merely read there. A new application posts a card carrying the headline facts and three buttons: a Review deep link, Fast-Track Accept and Decline. Approving from the chat flips the application for real — same transaction, same contract draft, same emails — and then edits the card in place so the chat stops offering a decision that has already been made.

All of that already worked against a mock. What is new is that there is now a real bot at the other end, and a real button press has somewhere to arrive.

One module carries bytes; nothing about the buttons changed

The telegram module owns no business logic. Every button's meaning still lives where it always did — intake's shared callback dispatcher and the prefixes registered on it. A real press is normalised into exactly the payload the existing handlers already read and dispatched into the same registry the test simulator uses, which is why not one handler needed changing and why a handler cannot tell a real press from a simulated one.

The mock wins every tie

Mock is the default and it never raises. Live requires the mode to be set to live and a bot token to be present. Missing token, test environment, running under the test suite, an unrecognised mode, or a live adapter that fails to construct — every one of those falls back to the mock with a warning rather than breaking the app. The test suite is offline by construction, three separate ways.

Consequence worth knowing before you debug anything: a mode change needs a process restart, because intake caches its own adapter registry when the module is imported.

What the live adapter fixes before it sends

Four rewrites, each because the API would otherwise reject the whole message. Placeholder chat ids from the seed are not chat references and are replaced by the configured default. Button URLs pointing at localhost are rewritten onto the public base URL — Telegram validates button URLs and rejects the entire message on a bad one, so a single stale base URL setting would silence every card the platform ever sends. Text is clamped. The message id is coerced to the type the API wants. What gets recorded is what actually went on the wire, so the console never lies about what was sent.

The inbound door fails closed

There is exactly one new public endpoint, and it is sessionless. It authenticates on a secret header compared in constant time. A wrong secret, a missing secret, or a secret that was never configured all produce a refusal with no side effects at all — no stored update, no dispatch, no callback answered. Everything that gets past that is idempotent on the update id, claimed in its own committed transaction before anything runs, because Telegram retries until it gets a success and a retried button must not act twice.

Honest status

The channel envelope is implemented to the documented Bot API semantics and unit tested for channels, supergroups and inaccessible messages, but no live press has been confirmed against the real bot yet. That is why the first one is built to be fully diagnosable: the raw callback is stored verbatim and the delivery is listed in the admin console, so a surprise degrades the recorded context rather than the action — the only load-bearing field is the button's own data, which is identical in every chat type.

Before you start

  • An admin session for the console, the test send and the webhook registration.
  • A venue_manager or admin session to act on an application.
  • For a genuinely live run: a bot token, a default chat id, a webhook secret and a public HTTPS base URL, all in the server environment.
  • On a default install everything below still works — against the mock, with every call recorded instead of sent.

Practise with

PersonaEmailPasswordNote
adminadmin@club.test admin123the only role that can reach the Telegram console or register the webhook
venue_managermanager@club.test manager123acts on cards in the channel; cannot open the Telegram console
host_promoterhost@club.test host123the applicant whose submission produces the card

Steps 1–4 — Admin

their manual →

Owns the Telegram console, the webhook registration and every credential the integration needs.

  1. 1
    Open the Telegram console. It shows the mode, whether a bot token and a webhook secret are configured, the destination chat, the deep-link base URL, any warnings, and the last fifteen sends and inbound updates.
    /admin/telegram telegram
    Expected result Mode reading mock on a default install, live once the environment says so.
    Watch out for The token and the secret are reported as booleans. No endpoint on this platform ever returns either value — the console is admin-viewable and on a demo instance the admin login is published in the banner.
  2. 2
    Send a test message before you trust the channel with real cards.
    /api/telegram/test-send POST telegram
    Expected result A message in the chat, and a new row in the sends list.
    Watch out for If this succeeds against the mock and fails live, read the recorded request rather than guessing: the resolved chat id and any rewritten button URLs are in it, which is usually where the answer is.
  3. 3
    Register the inbound webhook so button presses have somewhere to arrive. This is a deploy step and it does not happen automatically.
    Expected result Telegram accepts the URL and starts delivering updates.
    Watch out for HTTPS only, and it refuses outright without a configured secret. That refusal is the feature: an endpoint with no secret rejects every delivery anyway, so registering one would just create a channel that silently never works.
  4. 4
    Ask Telegram what it thinks the current webhook is, including its pending update count and last error.
    Expected result The registration as the API sees it, not as we hope it is.
    Watch out for A climbing pending count with a repeated last error is the signature of a secret mismatch: we are refusing every delivery and Telegram is retrying every one of them.

Steps 5 — Host / Promoter

their manual →

Is the applicant on the other end of whichever button gets pressed.

  1. 5
    Submit an application. That is the only thing the applicant does in this workflow — everything after it happens in the chat.
    Expected result A 201, and a card in the venue's staff channel within the same request.
    Watch out for If the send fails, the application is still created. A telegram failure is recorded as a note on the application and never rolls the submission back — losing a booking because a chat was down would be absurd.

Steps 6–11 — Venue Manager

their manual →
  1. 6
    Read the card in the channel before touching a button: event name, date, headcount, the viability score and band, whether artwork was attached, and three buttons — Review Application, ✅ Fast-Track Accept, ❌ Decline.
    Expected result One card per application, edited in place as the application moves.
    Watch out for The presser is read from who tapped, not from who posted. In a channel the post is authored by the channel itself, so 'who approved this' comes from the press — which is why the audit still names a person.
  2. 7
    Press ✅ Fast-Track Accept when the answer is obviously yes. The press travels to the inbound endpoint, is dispatched into intake's callback handler, and approves the application.
    Expected result A toast in the chat confirming the outcome, and the card edited to show it.
    Watch out for The accept and decline buttons carry single-use tokens with a seventy-two hour life, consumed with a guarded update — a double tap cannot double-act, and the second one is told the token was already used.
  3. 8
    Open the application and confirm what the press actually did: status approved, the transition recorded with telegram as its source, a contract drafted, the applicant emailed.
    /admin/intake/{app_id} intake
    Expected result The same outcome as approving from the dashboard, because it is the same code path.
    Watch out for Fast-tracking from a chat is exactly as powerful as approving from a desk. Pressing accept is signing the venue up to the numbers on that application.
  4. 9
    For anything less obvious, press Review Application instead. It opens the application in the browser and, if it was still a new submission, claims it into under review with the source recorded as telegram.
    /admin/intake/{app_id} intake
    Expected result The full application, already marked as picked up.
    Watch out for That claim is real work recorded against you. Opening the link out of curiosity marks the application as being reviewed — do not tap it unless you are going to.
  5. 10
    Check the board afterwards. Every card in the chat deep-links to whatever is actionable next for that application, not to this board — lock the contract, chase the signature, counter-sign, convert, or just the audit trail.
    /admin/intake intake
    Expected result The pipeline, with your application in its new column.
    Watch out for Those deep links carry anchors into specific panels. A card sitting in somebody's chat history is a live link to a control, which is exactly why those anchors are never renamed.
  6. 11
    Know what a stale card can and cannot do. Any transition to approved or rejected invalidates every unused token on that application, including the host's edit link.
    Expected result Yesterday's card cannot re-decide today's decision.
    Watch out for The buttons go dead but the card keeps its next-step link on purpose — the message stays useful after the decision instead of becoming a dead end in the chat.

Steps 12–16 — Admin

their manual →

Owns the Telegram console, the webhook registration and every credential the integration needs.

  1. 12
    Understand the contract of the one public inbound endpoint, because every live failure is a failure of one of its five stages: secret, envelope, idempotency, dispatch, answer.
    /webhooks/telegram POST telegram
    Expected result A refusal with no side effects for a bad secret; a bad envelope rejected; everything else answered with success.
    Watch out for It returns success for an unknown button prefix and even for a handler that raised. That is deliberate: the state change is already durable, and a failure response would make Telegram retry a button that already ran.
  2. 13
    Read the inbound list on the console to see the deliveries that actually arrived, with their update ids, chat types and button data.
    /admin/telegram telegram
    Expected result One row per update, including duplicates marked as such.
    Watch out for A delivery recorded here with a duplicate flag is Telegram retrying, not somebody pressing twice. Both are normal; only the first one ran.
  3. 14
    When a button lands somewhere unhelpful, print the computed deep link for every application state in one table.
    Expected result The exact link each state produces, and why.
    Watch out for This view never mints a token, so reading it cannot accidentally claim an application. That restraint is why it is safe to open on a live system.
  4. 15
    When somebody swears no card arrived, read the recorded outbound traffic. Live calls are recorded identically to mock ones — including failures, with their status and the API's own description.
    Expected result The calls correlated to that application id, successful or not.
    Watch out for The bot token appears in none of it. It exists in the process environment only, is used solely to build the request URL, and is scrubbed from any error message before it is re-raised.
  5. 16
    Remove the webhook to take the integration back off live — for maintenance, or to hand the bot to another environment.
    Expected result Telegram stops delivering; the console reflects it.
    Watch out for One caller does not swallow adapter errors: the tax module's approval message deliberately lets a failure surface, so a live outage becomes a server error on that one admin action and rolls its transaction back. That is unchanged behaviour, but on live it can now be triggered by the network rather than only by a test.

Appendix A — Persona ↔ RBAC group

PersonaRBAC groupAuthenticatedScoped OwnsTakes part in
Customer (Guest) no no 4 2
Member member yes no 13 18
VIP Member vip_member yes no 2 7
Host / Promoter host yes yes 11 11
Door Staff door_staff yes no 5 14
Venue Manager venue_manager yes no 17 24
Admin admin yes no 24 43

Appendix B — Route index

Every application route this manual references, and the workflows that use it. A test asserts each of these resolves against the mounted router, so the manual cannot silently rot when a route moves.

RouteUsed by
DELETE /api/admin/resale/events/{event_id}/lock open-or-close-the-exchange
DELETE /api/guestlist/buckets/{bucket_id} allocate-comps-to-a-promoter
DELETE /api/reservations/{reservation_id} hold-a-checkout-reservation
DELETE /debug/mock-behaviors/{behavior_id} script-a-mock-failure
GET / browse-events-as-a-guest, buy-a-ticket
GET /account convert-an-application-into-an-event, grant-and-revoke-roles, build-a-role-from-capabilities, create-your-member-account, manage-your-account-and-sessions
GET /account/passes read-the-scanner-screen, diagnose-a-red-scan, map-groups-to-door-zones, self-cancel-a-ticket, add-your-ticket-to-your-wallet, use-your-membership-card-at-the-bar, resell-a-ticket-you-cant-use, buy-a-resale-ticket, claim-your-comp-invite, get-through-the-door
GET /admin find-your-way-around-as-staff, edit-any-row-with-admin-crud, find-anything-in-admin
GET /admin/access read-the-scanner-screen, diagnose-a-red-scan, run-the-door-offline, map-groups-to-door-zones, manage-your-account-and-sessions, add-your-ticket-to-your-wallet, get-through-the-door, scan-guests-at-the-door, find-anything-in-admin
GET /admin/annotations leave-feedback-on-the-app, run-the-feedback-backlog, preview-the-site-as-a-guest
GET /admin/annotations/export leave-feedback-on-the-app, run-the-feedback-backlog
GET /admin/annotations/{annotation_id} run-the-feedback-backlog, preview-the-site-as-a-guest
GET /admin/announcements get-into-a-member-only-drop
GET /admin/audit grant-and-revoke-roles, deactivate-a-user, create-your-member-account
GET /admin/capabilities build-a-role-from-capabilities
GET /admin/contract-riders author-a-rider-so-a-host-can-ask-for-it
GET /admin/contract-riders/new author-a-rider-so-a-host-can-ask-for-it
GET /admin/contract-riders/recent author-a-rider-so-a-host-can-ask-for-it
GET /admin/contract-riders/{rider_key} author-a-rider-so-a-host-can-ask-for-it
GET /admin/contracts review-the-riders-approval-created, sign-and-countersign-a-contract, approve-a-host-application
GET /admin/contracts/{contract_id} review-the-riders-approval-created, sign-and-countersign-a-contract, create-and-launch-an-event, approve-a-host-application
GET /admin/credit/users/{user_id} post-a-house-credit-adjustment, pay-with-house-credit
GET /admin/data edit-any-row-with-admin-crud
GET /admin/data/audit edit-any-row-with-admin-crud, delete-rows-safely
GET /admin/data/ledger_entries edit-any-row-with-admin-crud, read-the-trial-balance
GET /admin/data/qa_invariant_violations run-the-qa-invariant-sweep
GET /admin/data/sandbox_parents edit-any-row-with-admin-crud
GET /admin/data/{table}/new edit-any-row-with-admin-crud
GET /admin/data/{table}/{pk} edit-any-row-with-admin-crud, delete-rows-safely
GET /admin/events find-your-way-around-as-staff, monitor-live-sales-on-the-night, understand-your-events-tiers-and-releases
GET /admin/events/backload understand-your-events-tiers-and-releases
GET /admin/events/{event_id} monitor-live-sales-on-the-night, convert-an-application-into-an-event, grant-and-revoke-roles, get-into-a-member-only-drop, get-your-host-account-and-event-access, understand-your-events-tiers-and-releases, watch-your-event-sell-live, create-and-launch-an-event
GET /admin/events/{event_id}/record keep-the-permanent-event-record
GET /admin/events/{event_id}/record/documents/{doc_id} keep-the-permanent-event-record
GET /admin/events/{event_id}/refund-policy refund-a-ticket-or-order, self-cancel-a-ticket
GET /admin/groups find-your-way-around-as-staff, map-groups-to-door-zones, build-a-role-from-capabilities
GET /admin/groups/new build-a-role-from-capabilities
GET /admin/groups/{slug} build-a-role-from-capabilities
GET /admin/guestlist find-your-way-around-as-staff, allocate-comps-to-a-promoter
GET /admin/guestlist/{event_id} check-in-a-guest-from-the-list, issue-a-vip-walk-in-override, allocate-comps-to-a-promoter, revoke-a-comp, get-your-host-account-and-event-access, invite-a-guest-and-track-plus-ones
GET /admin/intake find-your-way-around-as-staff, convert-an-application-into-an-event, apply-to-host-an-event, build-your-run-of-show-backwards, create-and-launch-an-event, approve-a-host-application, run-the-staff-channel-on-live-telegram
GET /admin/intake/rider-requests author-a-rider-so-a-host-can-ask-for-it
GET /admin/intake/{app_id} fast-track-from-telegram, review-the-riders-approval-created, convert-an-application-into-an-event, apply-to-host-an-event, re-apply-with-autofill, respond-to-a-request-for-more-information, author-a-rider-so-a-host-can-ask-for-it, build-your-run-of-show-backwards, create-and-launch-an-event, approve-a-host-application, run-the-staff-channel-on-live-telegram
GET /admin/ledger read-the-trial-balance, reconcile-tax-accruals, top-up-your-house-credit, read-your-credit-statement, run-the-quarterly-tax-filing
GET /admin/ledger/transactions refund-a-ticket-or-order, read-the-trial-balance, process-a-payout-run, run-the-vip-tab-settlement, reconcile-tax-accruals, pay-with-house-credit, use-and-settle-your-vip-tab
GET /admin/ledger/transactions/{txn_id} post-a-house-credit-adjustment, read-the-trial-balance, top-up-your-house-credit
GET /admin/marketing find-your-way-around-as-staff, compose-and-publish-a-social-post, configure-marketing-channels-and-rules
GET /admin/marketing/events/{event_id} compose-and-publish-a-social-post, build-a-trigger-rule, configure-marketing-channels-and-rules
GET /admin/marketing/events/{event_id}/rules/new build-a-trigger-rule, configure-marketing-channels-and-rules
GET /admin/marketing/posts/new compose-and-publish-a-social-post
GET /admin/marketing/posts/{post_id} compose-and-publish-a-social-post, build-a-trigger-rule
GET /admin/marketing/reports trace-a-click-to-revenue, report-marketing-attribution
GET /admin/marketing/rules/{rule_id}/edit build-a-trigger-rule
GET /admin/orders monitor-live-sales-on-the-night, refund-a-ticket-or-order, watch-your-event-sell-live
GET /admin/orders/{order_id} diagnose-a-red-scan, monitor-live-sales-on-the-night, trace-a-click-to-revenue, refund-a-ticket-or-order, self-cancel-a-ticket, buy-a-ticket
GET /admin/payouts process-a-payout-run, get-paid-your-host-royalty
GET /admin/qa run-the-qa-invariant-sweep
GET /admin/records keep-the-permanent-event-record
GET /admin/records/devices keep-the-permanent-event-record
GET /admin/records/metrics keep-the-permanent-event-record
GET /admin/resale open-or-close-the-exchange
GET /admin/resale/{event_id} open-or-close-the-exchange, resell-a-ticket-you-cant-use, get-paid-your-host-royalty, buy-a-resale-ticket, understand-your-credit-and-the-exchange
GET /admin/tab-settlements script-a-mock-failure, run-the-vip-tab-settlement, use-and-settle-your-vip-tab
GET /admin/tax approve-a-filing-from-telegram, reconcile-tax-accruals, run-the-quarterly-tax-filing
GET /admin/tax/periods/{period_id} approve-a-filing-from-telegram, run-the-quarterly-tax-filing
GET /admin/tax/rates maintain-the-tax-rate-matrix
GET /admin/tax/settings approve-a-filing-from-telegram
GET /admin/telegram run-the-staff-channel-on-live-telegram
GET /admin/users grant-and-revoke-roles
GET /admin/users/{user_id} convert-an-application-into-an-event, grant-and-revoke-roles, deactivate-a-user, build-a-role-from-capabilities, create-your-member-account, manage-your-account-and-sessions, use-your-membership-card-at-the-bar, get-your-host-account-and-event-access
GET /admin/venues set-up-a-venue-and-its-door-zones
GET /admin/venues/new set-up-a-venue-and-its-door-zones
GET /admin/venues/{slug} set-up-a-venue-and-its-door-zones
GET /admin/venues/{venue_id}/layout draw-a-venue-map
GET /api/access/passes/mine add-your-ticket-to-your-wallet
GET /api/access/passes/{pass_id}/gpass add-your-ticket-to-your-wallet
GET /api/access/passes/{pass_id}/pkpass add-your-ticket-to-your-wallet, get-through-the-door
GET /api/access/passes/{pass_id}/qr read-the-scanner-screen, add-your-ticket-to-your-wallet, use-your-membership-card-at-the-bar, get-through-the-door
GET /api/access/scan-logs read-the-scanner-screen, diagnose-a-red-scan, run-the-door-offline, monitor-live-sales-on-the-night, get-through-the-door, scan-guests-at-the-door
GET /api/access/sync/bundle run-the-door-offline, scan-guests-at-the-door
GET /api/access/sync/push run-the-door-offline
GET /api/access/tickets/{ticket_id}/secret add-your-ticket-to-your-wallet
GET /api/admin/data/{table}/rows/{pk}/dependencies delete-rows-safely
GET /api/admin/events/{event_id}/reservations monitor-live-sales-on-the-night, hold-a-checkout-reservation, watch-your-event-sell-live
GET /api/admin/events/{event_id}/sales monitor-live-sales-on-the-night, watch-your-event-sell-live
GET /api/admin/ledger/accounts read-the-trial-balance
GET /api/admin/ledger/transactions read-the-trial-balance
GET /api/admin/ledger/trial-balance read-the-trial-balance
GET /api/admin/ledger/users/{user_id} post-a-house-credit-adjustment
GET /api/admin/refunds refund-a-ticket-or-order
GET /api/admin/resale/settlements get-paid-your-host-royalty
GET /api/annotations/list run-the-feedback-backlog
GET /api/annotations/pages run-the-feedback-backlog
GET /api/contracts/riders/selectable author-a-rider-so-a-host-can-ask-for-it
GET /api/credit/balance pay-with-house-credit, top-up-your-house-credit, read-your-credit-statement, get-paid-your-host-royalty, use-and-settle-your-vip-tab
GET /api/credit/statement read-your-credit-statement
GET /api/events/{event_id}/availability browse-events-as-a-guest, get-into-a-member-only-drop, understand-your-events-tiers-and-releases
GET /api/events/{event_id}/refund-policy self-cancel-a-ticket
GET /api/events/{event_id}/zones set-up-a-venue-and-its-door-zones
GET /api/guestlist/door/{event_id}/search check-in-a-guest-from-the-list, allocate-comps-to-a-promoter, claim-your-comp-invite
GET /api/guestlist/events/{event_id}/buckets allocate-comps-to-a-promoter, revoke-a-comp, invite-a-guest-and-track-plus-ones
GET /api/guestlist/events/{event_id}/entries invite-a-guest-and-track-plus-ones
GET /api/guestlist/events/{event_id}/overrides issue-a-vip-walk-in-override
GET /api/guestlist/events/{event_id}/settings issue-a-vip-walk-in-override
GET /api/intake/public/applications/{app_id}/edit respond-to-a-request-for-more-information
GET /api/marketing/channels configure-marketing-channels-and-rules
GET /api/marketing/links trace-a-click-to-revenue, report-marketing-attribution
GET /api/marketing/reports/channels trace-a-click-to-revenue, report-marketing-attribution
GET /api/marketing/reports/events/{event_id} trace-a-click-to-revenue, report-marketing-attribution
GET /api/marketing/rules/{rule_id}/firings build-a-trigger-rule, configure-marketing-channels-and-rules
GET /api/rbac/groups grant-and-revoke-roles
GET /api/rbac/matrix build-a-role-from-capabilities
GET /api/rbac/users/{user_id}/capabilities build-a-role-from-capabilities
GET /api/records/compare keep-the-permanent-event-record
GET /api/records/{event_id}/seal-check keep-the-permanent-event-record
GET /api/records/{event_id}/verify keep-the-permanent-event-record
GET /api/resale/events/{event_id} buy-a-resale-ticket
GET /api/resale/my/purchases buy-a-resale-ticket
GET /api/reservations/{reservation_id} hold-a-checkout-reservation
GET /api/tax/agencies maintain-the-tax-rate-matrix
GET /api/tax/documents/{doc_id} approve-a-filing-from-telegram
GET /api/tax/liability reconcile-tax-accruals, run-the-quarterly-tax-filing
GET /api/tax/periods reconcile-tax-accruals
GET /api/tax/quote maintain-the-tax-rate-matrix
GET /api/tax/rates maintain-the-tax-rate-matrix
GET /api/venues/{venue_id}/zones set-up-a-venue-and-its-door-zones
GET /apply/edit/{token} fast-track-from-telegram, respond-to-a-request-for-more-information, author-a-rider-so-a-host-can-ask-for-it, build-your-run-of-show-backwards, approve-a-host-application
GET /apply/thanks/{app_id} apply-to-host-an-event, re-apply-with-autofill, build-your-run-of-show-backwards, create-and-launch-an-event
GET /auth/me manage-your-account-and-sessions
GET /cart hold-a-checkout-reservation
GET /checkout/{order_id} hold-a-checkout-reservation, pay-with-house-credit, get-into-a-member-only-drop, buy-a-ticket, use-and-settle-your-vip-tab
GET /clips/{workflow_slug} leave-feedback-on-the-app
GET /contracts/{contract_id}/preview review-the-riders-approval-created
GET /contracts/{contract_id}/redlines review-the-riders-approval-created, sign-and-countersign-a-contract
GET /contracts/{contract_id}/sealed sign-and-countersign-a-contract
GET /debug use-the-debug-console
GET /debug/access/totp/{ticket_id} diagnose-a-red-scan
GET /debug/adminsuite/dependencies delete-rows-safely
GET /debug/adminsuite/policies edit-any-row-with-admin-crud
GET /debug/annotations/audit run-the-feedback-backlog
GET /debug/annotations/orphans run-the-feedback-backlog
GET /debug/audit use-the-debug-console, script-a-mock-failure
GET /debug/events/reservations/sweep-preview hold-a-checkout-reservation
GET /debug/events/{event_id}/state watch-your-event-sell-live
GET /debug/frontend/preview preview-the-site-as-a-guest
GET /debug/frontend/preview/gate preview-the-site-as-a-guest
GET /debug/guestlist/audit/{event_id} check-in-a-guest-from-the-list
GET /debug/intake/deeplinks run-the-staff-channel-on-live-telegram
GET /debug/intake/rate-limits re-apply-with-autofill
GET /debug/intake/timeline build-your-run-of-show-backwards
GET /debug/intake/timeline-render/{app_id} build-your-run-of-show-backwards
GET /debug/intake/tz build-your-run-of-show-backwards
GET /debug/ledger/integrity post-a-house-credit-adjustment, read-the-trial-balance
GET /debug/marketing/links/{short_code} trace-a-click-to-revenue, report-marketing-attribution
GET /debug/marketing/outbound configure-marketing-channels-and-rules
GET /debug/mock-behaviors script-a-mock-failure
GET /debug/outbound-calls fast-track-from-telegram, use-the-debug-console, script-a-mock-failure, approve-a-filing-from-telegram, apply-to-host-an-event, run-the-staff-channel-on-live-telegram
GET /debug/qa/health run-the-qa-invariant-sweep
GET /debug/qa/invariants run-the-qa-invariant-sweep
GET /debug/qa/invariants/runs run-the-qa-invariant-sweep
GET /debug/qa/journeys run-the-qa-invariant-sweep
GET /debug/rbac/graph build-a-role-from-capabilities
GET /debug/rbac/pass-refresh-queue build-a-role-from-capabilities
GET /debug/rbac/state grant-and-revoke-roles, deactivate-a-user
GET /debug/rbac/zones map-groups-to-door-zones
GET /debug/record/verify-all keep-the-permanent-event-record
GET /debug/record/{event_id} keep-the-permanent-event-record
GET /debug/resale/integrity/{event_id} open-or-close-the-exchange
GET /debug/resale/split-preview open-or-close-the-exchange, resell-a-ticket-you-cant-use
GET /debug/scheduler/runs use-the-debug-console
GET /debug/settings use-the-debug-console
GET /debug/state use-the-debug-console
GET /debug/tax/rate-resolution maintain-the-tax-rate-matrix
GET /debug/tax/reconcile reconcile-tax-accruals
GET /debug/telegram/webhook-info run-the-staff-channel-on-live-telegram
GET /debug/venues/zone-map set-up-a-venue-and-its-door-zones
GET /events build-a-role-from-capabilities, browse-events-as-a-guest, leave-feedback-on-the-app, preview-the-site-as-a-guest, buy-a-ticket
GET /events/{event_id} monitor-live-sales-on-the-night, build-a-trigger-rule, browse-events-as-a-guest, create-your-member-account, get-into-a-member-only-drop, sign-and-countersign-a-contract, get-your-host-account-and-event-access, understand-your-events-tiers-and-releases, watch-your-event-sell-live, preview-the-site-as-a-guest, create-and-launch-an-event, buy-a-ticket, understand-your-credit-and-the-exchange
GET /events/{event_id}/buy browse-events-as-a-guest
GET /events/{event_id}/checkout hold-a-checkout-reservation, get-into-a-member-only-drop, buy-a-ticket
GET /events/{event_id}/exchange open-or-close-the-exchange, buy-a-resale-ticket, get-paid-your-host-royalty, understand-your-credit-and-the-exchange
GET /exchange browse-events-as-a-guest, buy-a-resale-ticket
GET /guestlist/claim/{token} claim-your-comp-invite, invite-a-guest-and-track-plus-ones
GET /host/apply browse-events-as-a-guest, apply-to-host-an-event, re-apply-with-autofill, author-a-rider-so-a-host-can-ask-for-it, build-your-run-of-show-backwards, create-and-launch-an-event
GET /l/{short_code} trace-a-click-to-revenue, report-marketing-attribution
GET /login deactivate-a-user, browse-events-as-a-guest, create-your-member-account, get-your-host-account-and-event-access
GET /me/comps claim-your-comp-invite
GET /my create-your-member-account, get-into-a-member-only-drop, preview-the-site-as-a-guest
GET /my/annotations leave-feedback-on-the-app, run-the-feedback-backlog
GET /my/credit refund-a-ticket-or-order, run-the-vip-tab-settlement, open-or-close-the-exchange, pay-with-house-credit, self-cancel-a-ticket, use-your-membership-card-at-the-bar, resell-a-ticket-you-cant-use, top-up-your-house-credit, read-your-credit-statement, get-paid-your-host-royalty, buy-a-ticket, use-and-settle-your-vip-tab, understand-your-credit-and-the-exchange
GET /my/credit/payouts process-a-payout-run, get-paid-your-host-royalty, understand-your-credit-and-the-exchange
GET /my/credit/statement post-a-house-credit-adjustment, pay-with-house-credit, top-up-your-house-credit, read-your-credit-statement, get-paid-your-host-royalty, use-and-settle-your-vip-tab
GET /my/orders self-cancel-a-ticket
GET /my/orders/{order_id} refund-a-ticket-or-order, self-cancel-a-ticket
GET /my/tickets add-your-ticket-to-your-wallet, resell-a-ticket-you-cant-use, buy-a-ticket, get-through-the-door, understand-your-credit-and-the-exchange
GET /register create-your-member-account, buy-a-ticket
GET /resale/my/listings resell-a-ticket-you-cant-use, understand-your-credit-and-the-exchange
GET /resale/sell/{ticket_id} open-or-close-the-exchange, resell-a-ticket-you-cant-use, understand-your-credit-and-the-exchange
GET /scanner find-your-way-around-as-staff, read-the-scanner-screen, diagnose-a-red-scan, run-the-door-offline, revoke-a-comp, grant-and-revoke-roles, map-groups-to-door-zones, deactivate-a-user, get-through-the-door, scan-guests-at-the-door
GET /scanner/guestlist find-your-way-around-as-staff, diagnose-a-red-scan, check-in-a-guest-from-the-list, issue-a-vip-walk-in-override, invite-a-guest-and-track-plus-ones, scan-guests-at-the-door
GET /sign/{token} sign-and-countersign-a-contract, create-and-launch-an-event
GET /sign/{token}/status sign-and-countersign-a-contract
GET /training/role/host_promoter find-your-way-around-as-staff
GET /ui/preview preview-the-site-as-a-guest
GET /venues/{venue_id}/map draw-a-venue-map
PATCH /api/admin/data/{table}/rows/{pk} edit-any-row-with-admin-crud
PATCH /api/contracts/riders/{rider_key} author-a-rider-so-a-host-can-ask-for-it
PATCH /api/guestlist/buckets/{bucket_id} allocate-comps-to-a-promoter
PATCH /api/marketing/channels/{platform} compose-and-publish-a-social-post, configure-marketing-channels-and-rules
PATCH /api/marketing/posts/{post_id} compose-and-publish-a-social-post
PATCH /api/tax/rates/{rate_id} maintain-the-tax-rate-matrix
PATCH /api/tiers/{tier_id} understand-your-events-tiers-and-releases
PATCH /api/venues/{venue_id}/zones/{zone_id} set-up-a-venue-and-its-door-zones
PATCH /contracts/{contract_id}/sections/{section_id} review-the-riders-approval-created
PATCH /contracts/{contract_id}/variables review-the-riders-approval-created
POST /admin/annotations/{annotation_id} leave-feedback-on-the-app, run-the-feedback-backlog
POST /admin/annotations/{annotation_id}/delete run-the-feedback-backlog
POST /admin/annotations/{annotation_id}/reply run-the-feedback-backlog
POST /admin/annotations/{annotation_id}/restore run-the-feedback-backlog
POST /admin/announcements get-into-a-member-only-drop
POST /admin/venues/new set-up-a-venue-and-its-door-zones
POST /api/access/membership-card use-your-membership-card-at-the-bar
POST /api/access/readers run-the-door-offline, scan-guests-at-the-door
POST /api/access/readers/{reader_id}/rotate-token run-the-door-offline
POST /api/access/scan read-the-scanner-screen, use-your-membership-card-at-the-bar, resell-a-ticket-you-cant-use, buy-a-resale-ticket, get-through-the-door, scan-guests-at-the-door
POST /api/access/scan/batch run-the-door-offline
POST /api/admin/credit/users/{user_id}/kyc/revoke process-a-payout-run
POST /api/admin/data/{table}/delete delete-rows-safely
POST /api/admin/data/{table}/rows/{pk}/restore delete-rows-safely
POST /api/admin/events/{event_id}/refund-all refund-a-ticket-or-order
POST /api/admin/ledger/adjustments post-a-house-credit-adjustment, read-your-credit-statement
POST /api/admin/orders/{order_id}/refunds refund-a-ticket-or-order
POST /api/admin/payouts/{payout_id}/process process-a-payout-run, get-paid-your-host-royalty
POST /api/admin/resale/events/{event_id}/lock open-or-close-the-exchange
POST /api/admin/tab-settlements/run run-the-vip-tab-settlement, use-and-settle-your-vip-tab
POST /api/admin/tab-settlements/{settlement_id}/retry run-the-vip-tab-settlement
POST /api/admin/tab-settlements/{settlement_id}/waive run-the-vip-tab-settlement
POST /api/annotations leave-feedback-on-the-app, run-the-feedback-backlog, preview-the-site-as-a-guest
POST /api/annotations/mode leave-feedback-on-the-app
POST /api/cart/items hold-a-checkout-reservation, buy-a-ticket
POST /api/checkout hold-a-checkout-reservation, buy-a-ticket
POST /api/contracts/rider-versions/{template_id}/approve author-a-rider-so-a-host-can-ask-for-it
POST /api/contracts/rider-versions/{template_id}/reject author-a-rider-so-a-host-can-ask-for-it
POST /api/contracts/rider-versions/{template_id}/submit author-a-rider-so-a-host-can-ask-for-it
POST /api/contracts/riders/{rider_key}/promote author-a-rider-so-a-host-can-ask-for-it
POST /api/credit/kyc process-a-payout-run, get-paid-your-host-royalty
POST /api/credit/payouts process-a-payout-run, get-paid-your-host-royalty
POST /api/credit/topup top-up-your-house-credit, understand-your-credit-and-the-exchange
POST /api/events/{event_id}/capacity-override set-up-a-venue-and-its-door-zones
POST /api/events/{event_id}/reservations hold-a-checkout-reservation
POST /api/events/{event_id}/tiers get-into-a-member-only-drop, understand-your-events-tiers-and-releases
POST /api/events/{event_id}/transition monitor-live-sales-on-the-night, understand-your-events-tiers-and-releases, create-and-launch-an-event
POST /api/events/{event_id}/zones set-up-a-venue-and-its-door-zones
POST /api/events/{event_id}/zones/{zone_ref}/activate set-up-a-venue-and-its-door-zones
POST /api/events/{event_id}/zones/{zone_ref}/retire set-up-a-venue-and-its-door-zones
POST /api/guestlist/buckets/{bucket_id}/entries allocate-comps-to-a-promoter, invite-a-guest-and-track-plus-ones
POST /api/guestlist/claim/{token} claim-your-comp-invite, invite-a-guest-and-track-plus-ones
POST /api/guestlist/claim/{token}/plus-ones claim-your-comp-invite
POST /api/guestlist/door/{event_id}/entries/{entry_id}/checkin check-in-a-guest-from-the-list, claim-your-comp-invite, invite-a-guest-and-track-plus-ones, scan-guests-at-the-door
POST /api/guestlist/door/{event_id}/entries/{entry_id}/issue-and-checkin check-in-a-guest-from-the-list
POST /api/guestlist/door/{event_id}/override issue-a-vip-walk-in-override
POST /api/guestlist/entries/{entry_id}/resend revoke-a-comp, invite-a-guest-and-track-plus-ones
POST /api/guestlist/entries/{entry_id}/revoke revoke-a-comp, invite-a-guest-and-track-plus-ones
POST /api/guestlist/events/{event_id}/buckets allocate-comps-to-a-promoter, invite-a-guest-and-track-plus-ones
POST /api/guestlist/my/entries/{entry_id}/claim claim-your-comp-invite
POST /api/intake/applications/{app_id}/approve fast-track-from-telegram, respond-to-a-request-for-more-information, create-and-launch-an-event, approve-a-host-application
POST /api/intake/applications/{app_id}/author-rider author-a-rider-so-a-host-can-ask-for-it
POST /api/intake/applications/{app_id}/convert convert-an-application-into-an-event, get-your-host-account-and-event-access, create-and-launch-an-event
POST /api/intake/applications/{app_id}/decline fast-track-from-telegram
POST /api/intake/applications/{app_id}/notes fast-track-from-telegram
POST /api/intake/applications/{app_id}/recompute-viability fast-track-from-telegram
POST /api/intake/applications/{app_id}/request-info respond-to-a-request-for-more-information, approve-a-host-application
POST /api/intake/public/applications apply-to-host-an-event, re-apply-with-autofill, author-a-rider-so-a-host-can-ask-for-it, build-your-run-of-show-backwards, create-and-launch-an-event, run-the-staff-channel-on-live-telegram
POST /api/intake/public/prefill/start re-apply-with-autofill
POST /api/intake/public/prefill/verify re-apply-with-autofill
POST /api/intake/public/rider-requests author-a-rider-so-a-host-can-ask-for-it
POST /api/intake/public/timeline/preview build-your-run-of-show-backwards
POST /api/intake/rider-requests/{request_id}/author author-a-rider-so-a-host-can-ask-for-it
POST /api/intake/rider-requests/{request_id}/decline author-a-rider-so-a-host-can-ask-for-it
POST /api/intake/rider-requests/{request_id}/triage author-a-rider-so-a-host-can-ask-for-it
POST /api/marketing/events/{event_id}/rules build-a-trigger-rule, configure-marketing-channels-and-rules
POST /api/marketing/posts compose-and-publish-a-social-post
POST /api/marketing/posts/{post_id}/cancel compose-and-publish-a-social-post
POST /api/marketing/posts/{post_id}/retry compose-and-publish-a-social-post
POST /api/orders/{order_id}/cancel hold-a-checkout-reservation, self-cancel-a-ticket
POST /api/orders/{order_id}/pay hold-a-checkout-reservation, pay-with-house-credit, buy-a-ticket
POST /api/rbac/groups build-a-role-from-capabilities
POST /api/rbac/tiers/reorder build-a-role-from-capabilities
POST /api/rbac/users/{user_id}/deactivate deactivate-a-user
POST /api/rbac/users/{user_id}/groups grant-and-revoke-roles, build-a-role-from-capabilities, get-your-host-account-and-event-access
POST /api/rbac/users/{user_id}/groups/{grant_id}/revoke grant-and-revoke-roles
POST /api/rbac/users/{user_id}/reactivate deactivate-a-user
POST /api/rbac/users/{user_id}/sessions/revoke-all deactivate-a-user
POST /api/record/devices keep-the-permanent-event-record
POST /api/record/devices/{device_id}/rotate-token keep-the-permanent-event-record
POST /api/record/ingest keep-the-permanent-event-record
POST /api/record/metrics keep-the-permanent-event-record
POST /api/records/{event_id}/amendments keep-the-permanent-event-record
POST /api/records/{event_id}/measurements keep-the-permanent-event-record
POST /api/records/{event_id}/measurements/import keep-the-permanent-event-record
POST /api/records/{event_id}/open keep-the-permanent-event-record
POST /api/records/{event_id}/seal keep-the-permanent-event-record
POST /api/resale/listings resell-a-ticket-you-cant-use
POST /api/resale/listings/{listing_id}/purchase buy-a-resale-ticket
POST /api/tax/packages/{package_id}/approve run-the-quarterly-tax-filing
POST /api/tax/packages/{package_id}/retry-mail approve-a-filing-from-telegram
POST /api/tax/packages/{package_id}/send-approval approve-a-filing-from-telegram, run-the-quarterly-tax-filing
POST /api/tax/periods/{period_id}/regenerate reconcile-tax-accruals
POST /api/tax/rates maintain-the-tax-rate-matrix
POST /api/telegram/test-send run-the-staff-channel-on-live-telegram
POST /api/telegram/webhook/delete run-the-staff-channel-on-live-telegram
POST /api/telegram/webhook/register run-the-staff-channel-on-live-telegram
POST /api/venues/{venue_id}/archive set-up-a-venue-and-its-door-zones
POST /api/venues/{venue_id}/default set-up-a-venue-and-its-door-zones
POST /api/venues/{venue_id}/layout draw-a-venue-map
POST /api/venues/{venue_id}/shapes draw-a-venue-map
POST /api/venues/{venue_id}/zones set-up-a-venue-and-its-door-zones
POST /api/venues/{venue_id}/zones/{zone_id}/attach set-up-a-venue-and-its-door-zones
POST /auth/login get-your-host-account-and-event-access
POST /auth/password manage-your-account-and-sessions
POST /auth/register create-your-member-account, buy-a-ticket
POST /auth/sessions/{session_id}/revoke manage-your-account-and-sessions
POST /contracts/{contract_id}/countersign sign-and-countersign-a-contract, create-and-launch-an-event
POST /contracts/{contract_id}/lock review-the-riders-approval-created, sign-and-countersign-a-contract, create-and-launch-an-event
POST /contracts/{contract_id}/resync-riders review-the-riders-approval-created
POST /debug/access/simulate-scan diagnose-a-red-scan
POST /debug/adminsuite/sandbox/seed delete-rows-safely
POST /debug/adminsuite/sql edit-any-row-with-admin-crud
POST /debug/clock use-the-debug-console
POST /debug/db/reset use-the-debug-console
POST /debug/events/tiers/{tier_id}/force-cascade get-into-a-member-only-drop, understand-your-events-tiers-and-releases
POST /debug/guestlist/reset-override-cap/{event_id} issue-a-vip-walk-in-override
POST /debug/intake/press-button fast-track-from-telegram
POST /debug/intake/resend-telegram fast-track-from-telegram
POST /debug/ledger/attempt-mutation post-a-house-credit-adjustment
POST /debug/ledger/promote-payouts process-a-payout-run
POST /debug/ledger/run-tab-settlement script-a-mock-failure, run-the-vip-tab-settlement
POST /debug/ledger/simulate-ach process-a-payout-run
POST /debug/marketing/posts/{post_id}/force-publish compose-and-publish-a-social-post
POST /debug/marketing/rules/{rule_id}/force-fire build-a-trigger-rule, configure-marketing-channels-and-rules
POST /debug/marketing/rules/{rule_id}/reset build-a-trigger-rule, configure-marketing-channels-and-rules
POST /debug/marketing/simulate-click trace-a-click-to-revenue, report-marketing-attribution
POST /debug/marketing/simulate-conversion trace-a-click-to-revenue, report-marketing-attribution
POST /debug/marketing/tick build-a-trigger-rule, configure-marketing-channels-and-rules
POST /debug/mock-behaviors script-a-mock-failure
POST /debug/payments/expire-holds hold-a-checkout-reservation
POST /debug/qa/invariants/run run-the-qa-invariant-sweep, process-a-payout-run, reconcile-tax-accruals
POST /debug/rbac/simulate build-a-role-from-capabilities
POST /debug/scheduler/tick use-the-debug-console
POST /debug/tax/replay-telegram-callback approve-a-filing-from-telegram
POST /debug/tax/run-scheduler run-the-quarterly-tax-filing
POST /debug/tax/seed-accruals reconcile-tax-accruals
POST /debug/webhooks/simulate script-a-mock-failure
POST /debug/webhooks/{webhook_id}/replay script-a-mock-failure
POST /preview/enter preview-the-site-as-a-guest
POST /preview/exit preview-the-site-as-a-guest
POST /resale/listings/{listing_id}/delist resell-a-ticket-you-cant-use
POST /resale/sell/{ticket_id} resell-a-ticket-you-cant-use
POST /sign/{token} sign-and-countersign-a-contract, create-and-launch-an-event
POST /webhooks/telegram run-the-staff-channel-on-live-telegram
POST /webhooks/telegram/tax approve-a-filing-from-telegram
PUT /api/admin/credit/users/{user_id}/tab post-a-house-credit-adjustment, run-the-vip-tab-settlement, use-your-membership-card-at-the-bar, use-and-settle-your-vip-tab
PUT /api/admin/events/{event_id}/refund-policy refund-a-ticket-or-order, self-cancel-a-ticket
PUT /api/admin/resale/events/{event_id}/config open-or-close-the-exchange, resell-a-ticket-you-cant-use, get-paid-your-host-royalty
PUT /api/admin/resale/tiers/{tier_id}/mode open-or-close-the-exchange
PUT /api/intake/public/applications/{app_id} respond-to-a-request-for-more-information, build-your-run-of-show-backwards, approve-a-host-application
PUT /api/intake/settings apply-to-host-an-event
PUT /api/rbac/groups/{group_name}/zones map-groups-to-door-zones, build-a-role-from-capabilities
PUT /api/rbac/groups/{slug}/capabilities build-a-role-from-capabilities
PUT /api/rbac/groups/{slug}/implications build-a-role-from-capabilities
PUT /api/rbac/tiers/{slug}/benefits build-a-role-from-capabilities
PUT /api/tax/packages/{package_id}/allocations approve-a-filing-from-telegram, run-the-quarterly-tax-filing
PUT /api/tiers/{tier_id}/zones understand-your-events-tiers-and-releases
PUT /debug/settings/{key} use-the-debug-console

Appendix C — Demo accounts

Practice logins on the demo/test data set only. These render only outside production and only while training.show_demo_accounts is on.

PersonaEmailPasswordNote
Membermember@club.testmember123 A registered customer: buys, holds tickets, house credit and resale listings.
VIP Membervip@club.testvip123 A member with a house tab and Zone B access. Implies everything a member can do.
Host / Promoterhost@club.testhost123 An outside promoter running an event at the venue. Scoped to that one event.
Door Staffdoor@club.testdoor123 Works the door: scans credentials, searches the guest list, checks people in.
Venue Managermanager@club.testmanager123 Runs the floor and the calendar: intake review, live sales, the door, marketing.
Adminadmin@club.testadmin123 Owns money, identity and configuration. Implies every other group.