/roster — Slash Command Spec¶
Not yet implemented. This page is a spec for a planned feature.
Overview¶
Secretary-facing commands for processing intake screener submissions and managing member roster records. Replaces the manual Grist button workflow (add person, link screener, create status) with Discord commands runnable from #records-workstream.
All commands in this family are run from #records-workstream unless otherwise noted.
Commands¶
/roster screener list
/roster screener process [id]
/roster screener link [id] @member
/roster status add @member [status]
/roster note @member [text]
Webhook Ingest — New Screener Submission¶
Grist fires an outbound webhook on new Intake_screen_form rows. Bot receives and posts to #records-workstream:
📋 New screener submission
Email: s*****r@g***l.com
Discord handle: "someuser"
Region: South
National member #: 12345
Referred by: stated yes
Why join: [first 100 chars, truncated]
⚠️ Membership prohibitions: "Some prohibitions apply" (if flagged)
⚠️ Military veteran (if flagged)
/roster screener process [id] — create person + add to backlog
/roster screener link [id] @member — link to existing $People row
PII masking: email is masked per standard pattern. Discord handle and why_join are shown as submitted — they are pseudonymous by design.
Flags: check membership_prohibitions field on the screener row. If it contains "military veteran" or "Some prohibitions apply", surface prominently so the secretary can review before processing.
Referred by: if referred field is non-empty, note it. Referral confirmation ping happens after processing (see below).
/roster screener list¶
Access¶
Representative+
Purpose¶
Returns all unprocessed screener submissions — rows in Intake_screen_form where validated_identity is null.
Response¶
Ephemeral embed, oldest first:
📋 Unprocessed screeners (3)
#42 Jun 1 s*****r@g***l.com South nat# 12345
#41 May 28 b**@p***n.me North no nat#
#40 May 25 t****@g***l.com East nat# 67890 ⚠️ prohibitions
/roster screener process [id] to process
/roster screener process [id]¶
Access¶
Representative+
Purpose¶
Replicates the two-button Grist workflow (add_person_button + action_link_person_and_create_status) in a single bot command. Creates a new $People row, links it to the screener, and creates a "join backlog" Status_actions entry.
Pre-flight Checks¶
- Screener row exists and
validated_identityis null — error if already processed - Check
membership_prohibitions— if flagged, warn and require explicit confirmation:⚠️ This submission has flagged prohibitions: "Some prohibitions apply" Process anyway? [Yes] [Cancel]
Writes¶
Replicates add_person_button + action_link_person_and_create_status exactly, in order:
Step 1 — Create $People row:
- initial_email_address → Intake_screen_form.your_email (stripped, lowercased)
- national_member_number → Intake_screen_form.national_member_number (if present)
- referred_by → Intake_screen_form.referred (text field — attempt fuzzy match against $People.all_aliases_no_PII; if ambiguous, leave null and note it)
- initial_contact_notes → "mil vet" if military veteran flagged, "**prohibitions apply**" if prohibitions flagged, else empty
Step 2 — Link screener to person:
- PATCH Intake_screen_form.validated_identity → new $People row id
Step 3 — Create Status_actions row:
- status → "join backlog"
- person → new $People row id
- status_effective_datetime → Intake_screen_form.creation_timestamp (backdate to screener submission time)
Post-processing¶
Referral confirmation: if referred_by resolved to a $People row with a known Discord ID, bot DMs the referrer:
Hey! Someone listed you as a referral for PSSRA.
Can you confirm you referred them? (We won't share their name or contact info.)
[✅ Yes, I referred them] [❌ No, I didn't]
Button responses PATCH $People.referral_confirmed on the new person's row.
Confirmation email: bot sends the screener-received email to initial_email_address via Proton SMTP immediately after processing. Template: /templates/intake/screener_received.md. Secretary does not need to touch Proton for this step.
Response to secretary:
✅ Screener processed
Person created: temp_a1b2c3d4 (no Discord yet)
Status: join backlog (backdated to Jun 1)
National #: 12345
Referral ping sent to: @referrer / Referral lookup ambiguous — set manually
Confirmation email sent to: s*****r@g***l.com
/roster screener link [id] @member¶
Access¶
Representative+
Purpose¶
Links a screener to an existing $People row instead of creating a new one. Used when the person has contacted us previously and already has a record.
Flow¶
- Fetch screener row by id
- Resolve
@member→$People - Confirm:
Link screener #42 to @alice? [Yes] [Cancel] - On confirm:
- PATCH
Intake_screen_form.validated_identity→ existing$Peoplerow id - If no
"join backlog"status exists for this person: create one backdated to screener submission timestamp - If status already exists: skip, notify secretary
- Run referral confirmation ping if applicable
/roster status add @member [status]¶
Access¶
Representative+ for most statuses; see note on "vetted" below.
Purpose¶
Creates a new Status_actions row for a member. Covers status transitions throughout the intake pipeline that currently require navigating to Grist manually.
Parameters¶
@member— Discord mention, resolved to$Peoplestatus— autocomplete from known status values:join backlogwaiting for donationsent vetting inviteunvetted (ready for vetting)vettedarrears (local)separation (voluntary)national roster tbdnational roster do not contactunvetted (inactive or excessive no-show)unvetted (left vetting)rejected at vetting
Writes¶
Creates new Status_actions row:
- person → resolved $People row id
- status → selected status
- status_effective_datetime → now (send explicitly — do not rely on Grist default via API)
Status history is append-only — this command never modifies or deletes existing rows.
Response¶
Ephemeral:
✅ Status added
@alice → "waiting for donation"
Effective: Jun 14 2025, 2:34 PM
Previous: join backlog (Mar 3 2025)
Note on "vetted" status¶
This is the most consequential status change. Confirm with org whether Representative+ is sufficient or if Central Committee should be required. See Open Questions.
/roster note @member [text]¶
Access¶
Representative+
Purpose¶
Appends a timestamped note to $People.notes. Quick context logging without opening Grist.
Flow¶
- Resolve
@member→$People - Fetch current
$People.notes - Append:
[YYYY-MM-DD @operator_handle]: {text}\n - PATCH
$People.notes - Ephemeral confirmation
Unvetted Discord — New Member Linking¶
When a member joins the unvetted Discord server, their snowflake and username are new information we haven't seen before. The bot automates the linking workflow that currently requires manual Grist entry.
Trigger¶
Bot listens for on_member_join in the unvetted Discord server. This requires the bot to be a member of the unvetted server with GUILD_MEMBERS privileged intent enabled — already a requirement for the DM flow. The event fires in real time and provides the full member object: Discord snowflake, discord_username, discord_display, discord_join_datetime. No n8n or polling needed.
Step 1 — Attempt auto-match¶
Query $People for rows where all_aliases_no_PII contains the username or display name (case-insensitive). Also check initial_email_address against any email the member may have provided in the screener.
- High-confidence single match: proceed to auto-link (with confirmation post to
#records-workstream) - Ambiguous or no match: proceed to DM flow
Step 2 — DM the new member¶
Bot DMs the new member in the unvetted Discord:
👋 Welcome to the PSSRA vetting Discord!
To link your account to your intake record, please reply with one of:
• The email address you used to contact us
• Your Discord username or display name as you provided it previously
This helps us find your record and get you set up quickly.
Member's reply is parsed and matched against $People.all_aliases_no_PII and email fields.
- Match found: bot auto-links (see Step 3) and replies:
✅ Got it — you're all linked up. Keep an eye on #vetting-announcements for upcoming sessions. - Still ambiguous: bot posts to
#records-workstreamfor human resolution (see Step 4) - No reply after 48 hours: post to
#records-workstreamflagging as unresponsive - Matched to unprocessed screener (no
$Peoplerow yet): bot notifies#records-workstream—⚠️ @username matched to unprocessed screener #42 — run /roster screener process 42 first, then /roster discord link— does not auto-link until screener is processed
Step 3 — Write on successful match¶
- Check if
Discord_usersrow exists for this snowflake — if not, create it: discord_id→ snowflakediscord_username→ from join eventdiscord_display→ from join eventdiscord_join_datetime→ from join eventserver→ unvetted Discord server identifier- PATCH
$People.discord_id→ new/existingDiscord_usersrow id - Create
Status_actionsrow: status→"unvetted (ready for vetting)"person→$Peoplerow idstatus_effective_datetime→ now- Post to
#records-workstream:✅ Auto-linked @username → alice (matched on email) Status set to: unvetted (ready for vetting) Confirm or correct: /roster discord link @member [id]
Step 4 — Human resolution fallback¶
When auto-match fails or is ambiguous, bot posts to #records-workstream:
❓ New member joined unvetted Discord: @username
Possible matches:
[1] alice — email partial match, screener processed ✅
[2] bob — username partial match, screener unprocessed ⚠️
[none of these]
/roster discord link @username [people_id] to link manually
If no matches at all: since everyone must complete the screener before reaching the unvetted Discord, a complete no-match suggests either a process gap or a data mismatch (typo in username, different email used). Bot flags with:
⚠️ @username joined unvetted Discord but no screener match found.
They should have a screener on file — check for typos or alternate contact info.
/roster discord link @username [people_id] once identified
Do not create a $People row without a screener — the screener contains chapter info confirmation that all members must receive.
/roster discord link @member [people_id]¶
/roster discord link @member [people_id]
Access: Representative+
Manual linking command for human resolution of ambiguous matches. people_id is the Grist integer row id from the candidate list.
Flow:
1. Resolve @member Discord snowflake
2. Create/update Discord_users row
3. PATCH $People.discord_id
4. Create "unvetted (ready for vetting)" status
5. Confirm to secretary + reply to member DM thread if open
Builder Notes¶
- The two Grist action buttons (
add_person_button,action_link_person_and_create_status) inIntake_screen_formdefine the exact write sequence — replicate them precisely, in order, with the same field values. Do not simplify. Intake_screen_form.referredisText()— freeform. Fuzzy match against$People.all_aliases_no_PII. If ambiguous or no match, skip and note; do not block processing.- Screener
idin bot context is the Grist integer row id — stable and short. Carry it from webhook payload to notification message to command invocation. #records-workstreamcurrently receives OC notifications via n8n. Bot posts will coexist during transition. n8n should be deprecated once the bot is stable — do not add new n8n automations.- Confirm that
Intake_screen_formis included in the Grist webhook configuration for the no-PII bot user, and that the bot can read:your_email,discord_handle,region,national_member_number,referred,why_join,membership_prohibitions,creation_timestamp. - Email sending: bot sends intake confirmation emails via Proton SMTP relay. Credentials:
smtp.protonmail.com:587(STARTTLS), Proton account username, app-specific password. AddPROTON_SMTP_USERandPROTON_SMTP_PASSWORDto env vars. Templates in/templates/intake/. The domain SPF/DKIM/DMARC settings already configured for the domain cover bot-sent mail automatically. - Proton Drive (future): templates could be read from Proton Drive at startup instead of the filesystem, allowing non-technical members to update them without GitHub access. Not needed for initial build — filesystem templates are fine. Note as upgrade path.
#records-workstreamis NDA/PII-ok — unmasked emails and full member info can be posted here. Apply masking only in public or mixed-access channels.
Open Questions¶
"vetted"status gate — Representative+ or Central Committee only?- Referral DM consent — the ping goes to the referrer, not the applicant. Confirm acceptable — referrer's Discord ID is known, applicant's is not yet at this stage.
Intake_screen_formwebhook access — confirm this table is in the webhook config and readable by the no-PII Grist user.- Multi-person applications — the SOP notes that a single email can represent multiple interested parties ("my partner and I"). Since the screener is mandatory and one submission per person, each person should have their own screener row. Confirm this is enforced in the form before simplifying any multi-person logic.
- Proton SMTP relay availability — confirm SMTP submission is enabled on your Proton plan (Settings → All Settings → Email → SMTP submission). If not available on current plan, may require plan upgrade or Bridge on the Droplet.
- Unvetted Discord bot membership — the bot needs to be a member of the unvetted Discord server to listen for
on_member_join. Add a second guild ID env var:UNVETTED_GUILD_ID. Slash commands do not need to be registered there — the bot only needs event listener access. - DM permission — members must share a server with the bot to receive DMs. Since the bot is in the unvetted Discord and the member just joined it, this is satisfied automatically.