Youth-protection monitoring for the Red Hawk Robotics Slack workspace.
It is Hawk Mod to everyone in Slack — the app, the bot, the DM that follows
a finding — and hawk-mod to everything that is not a person: this repo, the
package, the container, the database file, the log lines. The slash command
stays /hawkmod, because it is already in people's fingers and in every
runbook.
Slack does not let us block direct messages between adults and students below Enterprise Grid, and Information Barriers — the only feature that would — is Grid-only. So any adult can DM any student from day one. We cannot prevent this. hawk-mod is the detection half of that tradeoff, made continuous and recorded instead of quarterly and manual.
It implements the controls in Moving Team Communication to Slack §4 and §6 — the team's plan for running on Slack, and the source of every section number cited below.
Monitors DMs that include both an adult and a student — 1:1, or any number
of each. A conversation with only students in it, or only adults, is never
recorded. Each adult authorizes hawk-mod on
their own account, granting im:history and mpim:history. hawk-mod then
receives that adult's DM events, classifies each conversation by who is in it,
and records every message in the ones that include a student — including edits
and deletions. Conversations with no student in them are classified and then
discarded; hawk-mod is not a general archive of the adults' Slack.
Flags conversations the conduct agreement does not permit, the moment they appear:
| Situation | Verdict |
|---|---|
| 1:1 DM between any adult and a student | violation — prohibited outright (§4.1) |
| Group DM with a student and fewer than two screened adults | violation (§4.2) |
| Any conversation with a student and an account not on the roster | violation |
| Group DM with a student and two screened adults | recorded, allowed |
| Student-only conversation | not recorded |
| Adult-only conversation | not recorded |
Tracks the things that make the above meaningful, on a nightly sweep:
- students with Slack accounts and no current parental consent (§2)
- Slack accounts with no roster entry at all
- adults whose YPP, Mentor Ready, or CORI/fingerprint checks have lapsed (§7)
- adults who have not authorized hawk-mod, or who revoked it — see below
- channels containing students with fewer than two screened adults (§4.2)
- fewer than two workspace owners, or a student holding Owner/Admin (§6)
- fewer than two of those Owners/Admins being screened adults on the roster (§3)
Findings are posted once to a private channel, deduplicated, auto-closed when the underlying problem goes away, and closable by a workspace admin from Resolve and Acknowledge buttons on the alert itself.
The adults involved get a private nudge, not just the coaches. When a DM raises a finding, hawk-mod sends each adult in it a direct message naming the rule and the concrete fix — add a second screened adult, or move it to a channel. It is deliberately a colleague's heads-up rather than a warning: most violations are people who did not know the rule, and an adult who feels accused moves the conversation somewhere nobody can see it. The nudge goes once per occurrence, it says plainly that a coach has been notified, and it never replaces the finding.
Students never receive one. Youth protection governs adult conduct toward youth; the student has done nothing wrong, and a policy notice in a minor's chat window would frighten them to no purpose.
Closing a DM finding closes that DM, not that pair. If a message is sent in the conversation afterwards, the finding is raised again with a fresh alert and the old message is redrawn to say so. Closing it does not buy silence — only the absence of further messages does. Merely re-reading the conversation is not a new violation, so a conversation that has been dealt with and left alone stays quiet however many times hawk-mod walks past it. Both open a short form asking what happened — the reason stays mandatory, because a finding closed without one tells the quarterly review nothing. Once closed, the message redraws without buttons and shows who closed it and why.
Resolved means someone looked into it and it is dealt with; acknowledged means seen but not finished with.
A 1:1 that gets put right is acknowledged automatically. §4.1's own remedy is "anything that starts in a DM moves to a channel or gets a second adult added", so when a conversation containing the same pair plus a second screened adult is used, the original finding is acknowledged and a note is posted in the alert's thread. It does not matter whether that group is new or one the team already had — Slack cannot add anyone to an existing 1:1, so either is a legitimate way to comply. What matters is that it was spoken in after the 1:1 message: an old thread nobody has touched cannot clear a fresh violation. Carry on DMing the student privately afterwards and the group stops excusing it, because each new 1:1 message raises the finding again.
Acknowledged, not resolved: the 1:1 still happened and its messages are still on record. Only a person marks a DM violation resolved.
Stated plainly, because a control whose gaps are undocumented is worse than no control:
- Adults who do not enroll. hawk-mod sees DMs through adults' own tokens.
A adult who never authorizes it, or who revokes it later, is invisible —
which is why
adult_not_enrolledandenrollment_revokedare themselves treated as violations rather than as silence. Enrollment coverage is the first number in/hawkmod statusfor the same reason. - Huddles. Unrecorded 1:1 voice, the same problem as DMs, with no API to observe it. Turn them off (§6).
- Slack Connect DMs from outside the workspace. Disable them (§6).
- Private channels hawk-mod has not been invited to. Public channels are checked whether or not it is a member; private ones require an invite.
- File contents. Attachment names, types, and sizes are recorded; the files themselves are left in Slack rather than copied onto this host.
- Student-to-student DMs. Deliberately, not incidentally: FIRST YPP and the conduct agreement govern adult conduct toward youth, and neither asks the team to surveil youth-to-youth conversation. Students may not enrol, and a student's peer DMs are structurally invisible because no enrolled token can see them. A peer incident that genuinely needs investigating is reachable through a Corporate Export, which takes a Workspace Owner and a reason.
The Corporate Export remains the backstop for the first gap, since it captures non-enrolled adults too. It is a manual download — Business+ has no API for it, and the Discovery API is Enterprise Grid only — and ingesting one is not built yet.
hawk-mod records minors' private messages. That is defensible only if everyone involved knows:
- Adults sign the conduct agreement and authorize hawk-mod knowingly. The authorization screen names the scopes, and the success page says what is recorded.
- Students and parents are told in the consent form that DMs are recorded and subject to audit (§4.3) — not merely that they are "exportable."
- The alert channel is private and limited to screened adults. Findings name students.
- Access to the database is limited. It contains message text when
LOG_MODE=full. SetLOG_MODE=metadatato record who/when/how-many and no text at all; the policy violations above are all detectable without content.
Do not deploy this quietly. Covert monitoring of minors is a different thing from an audited, disclosed control, and only the second one is what the board approved.
./scripts/setup-local.shWalks through a local run end to end: a public URL via Tailscale Funnel, the Slack app, credentials, a small test lifecycle sheet of your own, the container, the roster built from that sheet, and a test DM that should raise a finding. It is safe to point at a real workspace provided you only enrol yourself and roster only accounts you control — a conversation with nobody rostered as a student in it is never recorded. So the roster comes from a two-person sheet you make, read by a Google service account you make for the test, never the real lifecycle sheet or its key. The wizard says so at the top and gates on it.
-
Create the Slack app from
docs/slack-app-manifest.yaml, replacing the example host with yourPUBLIC_URL. Then uploadassets/hawk-mod-icon-512.pngunder Basic Information → Display Information → App icon; a manifest cannot carry an image, so that step is manual and one-off. -
cp .env.example .envand fill it in. Generate the encryption key withopenssl rand -base64 32— it encrypts adult tokens at rest. -
Create a private channel for findings and put its ID in
ALERT_CHANNEL_ID. Invite Hawk Mod to it. -
docker compose up -d(ornpm install && npm run dev). For the server — DNS, TLS, backups, and the public-reachability requirement — see docs/deploy.md. -
A Slack workspace Owner or Admin installs the app: visit
$PUBLIC_URL/slack/install. Whoever can install it can administer it — there is nothing to grant afterwards. -
Build the roster from the lifecycle sheet (see below): check it with
/hawkmod lifecycle roster, then apply it with/hawkmod lifecycle roster apply. From then on it runs hourly. -
Send every adult to
$PUBLIC_URL/slack/installto enroll./hawkmod statusshows coverage; do not consider launch complete until it reads N/N. -
npm run cli -- backfillto walk DM history that predates enrollment.
Who is a student or a mentor, whether they are Active, their screening dates
and their Slack consent all come from the team's lifecycle sheet, a Google
Sheet read with a service account (docs/lifecycle-sync.md,
docs/google-setup.md). hawk-mod reads only the
columns it needs, keeps its own copy, and brings that copy in step every hour,
or on /hawkmod lifecycle sync.
Three properties make this safe to rely on:
- The sheet can only add monitoring. A new person, someone moved into Student, someone Active again — those apply on their own. Someone turned Inactive, graduated to Alumni, or gone from the sheet stays monitored, and an alert asks an administrator to click End monitoring, with a reason. A student the sheet now calls a Mentor waits for Make adult the same way. Both re-read the sheet before acting.
- A sheet hawk-mod cannot read changes nothing. The roster stays as the
last good run left it, and a
lifecycle_unreadablefinding says why. - Every role change is recorded in
role_changeswith who, when, and from what. Slack's audit log API is Enterprise Grid only, so on Business+ this table is the sole durable trail of who was monitored when.
Slack user groups (@students, @mentors) are for mentions and channel
access; they decide nobody's role. "Create and edit user groups" must still
be restricted to Owners/Admins in Workspace Settings → Roles & permissions.
That setting is not readable through any API, so it belongs on the quarterly
manual checklist.
Slack is the source of truth for everything Slack knows: identity, membership, role, active status. Three things cannot live there:
- Screening dates. Custom profile fields are the obvious home, but below Enterprise Grid a member can edit their own — a adult entering their own CORI date is not a control. They come from the lifecycle sheet.
- Consent. No Slack field holds it, and §2 obliges the team to produce consents to Slack on request, so they cannot live inside it. The signed forms are the record; the lifecycle sheet holds when each runs out.
- The past. Slack answers "who is a student now". An audit asks "was this
person a student in March, when this DM happened". Deactivate an account at
season's end and Slack forgets, while the DM log still points at them.
role_changesandscreening_changesare what survive.
So the people table is a compliance record copied from the lifecycle sheet
and linked to Slack accounts — kept locally because the rules consult it on
every message, and an unreadable sheet must never read as "no students".
In Slack, restricted to the workspace's Owners and Admins — read live from Slack on every command, so granting or revoking access is something you do in Slack's own admin settings and nowhere else:
/hawkmod status | enroll | findings [kind] | whois @user
/hawkmod ack <id> <note> | resolve <id> <note> | sweep | backfill
/hawkmod lifecycle plan | roster [apply] | sync | slack-ids [apply]
There is deliberately no roster role that confers this. A student who somehow holds Owner or Admin is refused anyway, and reported as a §6 violation.
Screening dates and consent are edited on the lifecycle sheet, not here: the
next hourly run, or /hawkmod lifecycle sync, copies them in and closes any
finding they fix. lifecycle roster shows what a run would change without
changing anything.
On the host, or docker compose exec hawk-mod node dist/src/cli/index.js …:
npm run cli -- export-conversation D01ABCDEF thread.jsonThat last one is what §4.4 promises: one conversation, in full, with edits and deletions, when a parent or the district asks for it.
npm run dev # tsx watch
npm run typecheck # tsc --noEmit
npm test # node:test over the policy rules
npm run formatThe rules in src/domain/rules/ are pure functions and are the part with
tests. They decide whether a conversation is allowed to exist based only on who
is in it — never on what was said — so the policy is enforceable without anyone
reading a child's messages.