Membership Club — Training Manual

The complete manual in one document: every persona, then every workflow by area. Filtered to one area. Generated 2026-08-31T04:04:28Z · 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.

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.