Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
59 changes: 51 additions & 8 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,8 @@ code that carries it, and records which controls are deliberately manual.

## Architecture

**Two token planes.** The bot token posts alerts and reads channel membership.
**Two token planes, and a third narrow one.** The bot token posts alerts and
reads channel membership.
Each adult's _user_ token is what makes DMs visible at all — Slack exposes no
other way below Enterprise Grid. `src/slack/installStore.ts` keeps one `bot` row
per workspace and one `user` row per enrolled adult, and `fetchInstallation`
Expand All @@ -54,6 +55,18 @@ stripped so one adult's token never rides along to a request that did not ask
for it. Tokens are encrypted at rest (`src/crypto.ts`); the DB file alone is not
enough to read anyone's DMs.

The third is an `admin` row: an administrator's token scoped to
`usergroups:write` and nothing else, granted at `/slack/authorize-groups`. It
exists because Slack accepts a _bot_ token for `usergroups.users.update` only
when the workspace lets everyone edit user groups — which §6 forbids — so group
edits go out as a named person. It is a separate row rather than extra scopes on
that person's `user` row because Slack issues one token per authorization
carrying only that authorization's scopes: sharing a row would mean an
administrator who is also an enrolled mentor losing their DM token the moment
they authorized group editing, with coverage still reading 100%.
`saveInstallation` refuses a `user` write whose scopes lack `im:history` for
exactly that reason. Never merge these two grants.

**Students may not enroll.** `storeInstallation` throws on a student's user
token, and the sweep revokes and deletes one that appears later (a person can be
moved into the students group after enrolling). Their token would expose
Expand Down Expand Up @@ -88,12 +101,42 @@ policy that lands in a handler is policy nothing covers.
in the sweep, because everything after it reads roles, and again on
`subteam_*` events so an edit applies immediately rather than at 3am (the sync
is idempotent, which is what makes the duplicate events harmless). The
reconciliation in `domain/rules/rosterSync.ts` is pure and **add-only by
design**: a person dropped from the students group stays a student, since the
alternative is silently ending someone's monitoring. Only an explicit move into
the adults group leaves `student`, and that raises `roster_drift`. Every change
lands in `role_changes` — Slack's audit log API is Grid-only, so that table is
the only trail. Do not make this bidirectional.
reconciliation in `domain/rules/rosterSync.ts` is pure and **may only ever add
monitoring, never subtract it**: a person dropped from the students group stays
a student, since the alternative is silently ending someone's monitoring. Only
an explicit move into the adults group leaves `student`, and that raises
`roster_drift`. Every change lands in `role_changes` — Slack's audit log API is
Grid-only, so that table is the only trail. Do not make this bidirectional.

The invariant is about _direction_, not about writes, which is why `reactivate`
belongs there: a deactivated person who reappears in a role group is monitored
again immediately, for the same reason `create` needs nobody's approval. Ending
monitoring is `/hawkmod deactivate`, which demands a person and a reason — the
only operation in hawk-mod that makes it see less, and the only one no rule, job
or sync can reach.

**Group membership is declaration; the roster is monitoring.** `/hawkmod group
add|remove` edits the Slack user group and nothing else. Removing someone from
`@students` leaves them a student on the roster, and the command says so in its
reply rather than letting the caller assume otherwise. `CONTEXT.md` keeps the
two words apart; conflating them is how a graduated student ends up monitored
forever, or a returning one ends up invisible.

**Group edits go through a plan.** `domain/rules/groupMembership.ts` is pure and
diffs intended membership against actual, because `usergroups.users.update`
_replaces_ a group's whole member list — there is no add-one endpoint. So a bad
input does not corrupt a group, it empties one. The plan carries its own refusal
(over-large removals, emptying a group) so a caller cannot apply a bad one by
forgetting to check a flag elsewhere. `slack/groupAdmin.ts` serializes writes and
re-reads membership inside the lock. The single-user command is the degenerate
case of the spreadsheet-driven sync this was built for — one planner, two
callers. Editable groups are an allowlist (`MANAGED_USERGROUPS`), which is blast
radius rather than authorization: every caller is already a Slack admin who could
edit any group by hand.

`group_changes` records who asked for an edit. `role_changes` cannot: it is
written by the sync, which runs from a Slack event long after the human is gone,
and most group edits will change no role at all once subteams are managed here.

**Findings are the output, and have exactly one door each way.** `src/raise.ts`
persists then alerts, and only alerts when the finding is new or has recurred.
Expand Down Expand Up @@ -221,7 +264,7 @@ Building locally on macOS can trip the "access data from other apps" prompt;
detectable without message text; only investigation needs the text.
- Non-students with no screening on file do not count toward the two-adult rule,
whatever their role. Do not loosen `isScreenedAdult`.
- **Every Slack entry point is gated on `mayAdministerWorkspace`**, and each one
- **Every Slack entry point is gated on `administrator()`**, and each one
checks for itself: the slash command (`commands.ts`), the alert buttons and
their note modal (`actions.ts`), and the screening and consent submissions
(`modals.ts`). There is no middleware doing this centrally — anyone who can
Expand Down
142 changes: 142 additions & 0 deletions CONTEXT.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,142 @@
# hawk-mod

Youth-protection auditing for a FIRST team's Slack workspace: it records
adult–student direct messages so a human can review them, because Slack cannot
prevent those messages below Enterprise Grid.

This glossary exists because several words in this domain look like synonyms and
are not. Confusing any pair of them ends someone's monitoring quietly, which is
the failure this project is built to avoid.

## People

**Person**:
Someone the roster knows about, identified by their Slack account.
_Avoid_: User, member, account

**Student**:
A person the roster records as a minor, whose direct messages with adults are
recorded. See **Declared** vs **Monitored** — the word alone is ambiguous.
_Avoid_: Kid, child, minor

**Adult**:
Any person on the roster who is not a student, a district observer included.
Seniority is never an exemption.
_Avoid_: Mentor, coach, grown-up, leader

**Screened adult**:
An adult with current Youth Protection Screening, Youth Protection Training, and
CORI on file. Only screened adults count toward the two-adult rule.

**Administrator**:
Someone Slack records as a Workspace Owner or Admin. It is not a roster role and
is never stored — the roster says who is monitored, never who is in charge.
_Avoid_: Lead coach, admin role, superuser

## Declaration vs monitoring

These two are the distinction most easily lost, and the one that matters most.

**Declared**:
Present in the Slack user group that names a role — `@students` or `@adults`.
Cheap, reversible, and edited by hand in Slack or through hawk-mod. A
declaration is a statement of intent, not a fact about monitoring.
_Avoid_: Enrolled, rostered, assigned

**Monitored**:
Carried on the roster with a role and marked active. Sticky by design: gained
automatically when someone is declared, and lost only by an explicit,
attributed act. Removing a declaration never removes monitoring.
_Avoid_: Tracked, watched, covered

**Active**:
The state of a person whose monitoring is in force. The opposite is
**deactivated** — a person the roster remembers but no longer monitors.

**Deactivation**:
The deliberate act of ending a person's monitoring. The only operation in
hawk-mod that makes it see less, and therefore the only one that always names
who performed it and why.
_Avoid_: Removal, deletion, archiving, offboarding

**Reactivation**:
Restoring monitoring to a deactivated person. Happens automatically when they
are declared again, because gaining protection never needs permission.

## Enrollment

**Enrollment**:
An adult's own authorization letting hawk-mod read their direct messages. It is
what makes DMs visible at all, and it is granted by that adult, never on their
behalf. Students may never enroll.
_Avoid_: Onboarding, signup, opting in, installation

**Coverage**:
The proportion of adults requiring enrollment who have enrolled. An unenrolled
adult is a gap, and a gap is a finding rather than a silence.

**Group-editing authorization**:
An administrator's separate authorization letting hawk-mod edit Slack user
groups as them. Distinct from enrollment in purpose, lifetime, and consent, and
held separately so that neither can revoke the other.

## Conversations

**Conversation**:
A Slack direct message or group direct message that hawk-mod has classified.
Classification depends only on who is present, never on what was said.

**Verdict**:
What the rules conclude about a conversation from its participants alone.

**Two-adult rule**:
The requirement that a student's conversation include at least two screened
adults. Unknown accounts never satisfy it.

**Remedy**:
Moving a one-to-one conversation into a channel or adding a second adult. A
remedy acknowledges a finding; it never resolves one, because the one-to-one
still happened.
_Avoid_: Fix, resolution, correction

## Findings

**Finding**:
A recorded policy problem. Findings name people and conversations, never
message content.
_Avoid_: Alert, violation, issue, incident

**Condition finding**:
A finding describing something currently true, such as lapsed screening.
Re-detecting one is not news.

**Occurrence finding**:
A finding describing something that happened, such as a one-to-one message. Only
a newer event can repeat it.

**Dedupe key**:
The identity of a problem across sweeps. It is what separates an alert channel
someone reads from one nobody does.

**Guidance**:
An advisory note sent privately to the adults in a conversation that raised a
finding. It never reaches the student, and it never replaces the finding.

## Reconciliation

**Sweep**:
The scheduled pass that re-checks conditions and closes what it owns.

**Backfill**:
The hourly re-walk of enrolled adults' message history, catching what predates
enrollment or was missed while the process was down.

**Plan**:
The set of additions and removals that would bring a user group to an intended
membership. A plan is inspectable before it is applied, and is refused outright
when it would remove too much of a group at once.
_Avoid_: Diff, changeset, patch

**Drift**:
A disagreement between what Slack declares and what the roster monitors. Drift
is reported, never silently reconciled in the direction that reduces monitoring.
3 changes: 3 additions & 0 deletions docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,9 @@ services:
# is told to, and a missing one silently reads as "feature off".
STUDENT_USERGROUP: ${STUDENT_USERGROUP:-}
ADULT_USERGROUP: ${ADULT_USERGROUP:-}
# Further group handles `/hawkmod group` may edit, comma separated. The
# two above are always editable; this bounds how far a bad plan reaches.
MANAGED_USERGROUPS: ${MANAGED_USERGROUPS:-}

# --- Schedules -------------------------------------------------------
TZ: ${TZ:-America/New_York}
Expand Down
Loading
Loading