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
10 changes: 6 additions & 4 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -28,10 +28,12 @@ npx tsx --test --test-name-pattern "two adults" test/rules.test.ts
```

CLI subcommands: `import-roster`, `import-consents`, `set-role`, `sweep`,
`backfill`, `findings [status]`, `export-conversation <id> [out.json]`.
`set-role` is the bootstrap that matters — the user-group sync only ever
assigns `student` or `adult`, so the first `lead_coach` must be set from the
CLI or nobody can run `/hawkmod` at all.
`backfill`, `findings [status]`, `export-conversation <id> [out.json]`. None of
them is a bootstrap step: administrative access is Slack's Workspace
Owner/Admin flags, read live in `src/slack/authz.ts`, so a fresh install is
usable by whoever installed it without anyone touching the host. Don't
reintroduce a roster role that grants access — see the "Lead Coach" note in
`docs/policy-mapping.md`.

## What this is

Expand Down
25 changes: 17 additions & 8 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -49,18 +49,19 @@ appear:
- **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 Lead Coach from **Resolve**
and **Acknowledge** buttons on the alert itself.
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 Lead Coaches.** When a
**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 Lead Coach has been notified, and it never
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
Expand Down Expand Up @@ -169,7 +170,9 @@ and gates on it.
4. `docker compose up -d` (or `npm install && npm run dev`). For the server —
DNS, TLS, backups, and the public-reachability requirement — see
[docs/deploy.md](docs/deploy.md).
5. A Lead Coach installs the app: visit `$PUBLIC_URL/slack/install`.
5. 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.
6. Import the roster and the consents you have already collected:

```bash
Expand Down Expand Up @@ -235,8 +238,9 @@ projection of Slack — not a second roster to keep in sync.
email,full_name,role,ypp_completed_on,mentor_ready_on,cori_completed_on,active,notes
```

`role` is one of `student`, `adult`, `lead_coach`, `admin`,
`district_observer` — the last being the MPS administrator seat from §8. Dates
`role` is one of `student`, `adult`, `district_observer` — the last being the
MPS administrator seat from §8. Nothing in this file grants access to
`/hawkmod`; that is Slack's Owner/Admin, and only Slack's. Dates
are `YYYY-MM-DD`. Email is the join key; Slack IDs are matched automatically
once people sign up. **If a Slack account's email doesn't match a roster row it
resolves to an unknown account, not a student** — which produces silence rather
Expand All @@ -258,14 +262,19 @@ the signed copy is actually filed.

## Commands

In Slack, restricted to Lead Coaches and admins:
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 screening @user | consent @user
/hawkmod ack <id> <note> | resolve <id> <note> | sweep | backfill
```

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` and `consent` open a form in Slack. They are how screening dates
and consent records get in day to day — the CSV importers below are a
season-start bulk load, not a workflow. Both record who entered what and when,
Expand Down
2 changes: 1 addition & 1 deletion docs/deploy.md
Original file line number Diff line number Diff line change
Expand Up @@ -90,7 +90,7 @@ before setup — running and waiting.

## 4. Set up the workspace

1. A Lead Coach visits `https://hawk-mod.example.org/slack/install`.
1. A workspace Owner or Admin visits `https://hawk-mod.example.org/slack/install`.
2. Invite the bot to the private findings channel.
3. Import the roster and consents (see the README).
4. Send every adult the same install URL, and watch `/hawkmod status` until
Expand Down
6 changes: 3 additions & 3 deletions docs/examples/roster.example.csv
Original file line number Diff line number Diff line change
@@ -1,9 +1,9 @@
email,full_name,role,ypp_completed_on,ypt_completed_on,mentor_ready_on,cori_completed_on,active,notes
lead1@example.org,Alex Lead,lead_coach,2026-09-01,2026-09-01,2026-09-01,2024-08-15,1,
lead2@example.org,Sam Lead,lead_coach,2026-09-01,2026-09-01,2026-09-01,2025-01-20,1,
lead1@example.org,Alex Lead,adult,2026-09-01,2026-09-01,2026-09-01,2024-08-15,1,Workspace Owner — §3
lead2@example.org,Sam Lead,adult,2026-09-01,2026-09-01,2026-09-01,2025-01-20,1,Workspace Owner — §3
adult1@example.org,Jordan Adult,adult,2026-09-03,2026-09-03,2026-09-03,2024-09-10,1,
adult2@example.org,Casey Adult,adult,2026-09-05,2026-09-05,2026-09-05,2026-01-05,1,MPS employee — see policy IJNDD
observer@melroseschools.com,MPS Administrator,district_observer,2026-08-20,2026-08-20,2026-08-20,2025-06-01,1,District visibility seat (§8)
treasurer@example.org,Pat Treasurer,admin,2026-09-01,2026-09-01,2026-09-01,2024-08-15,1,Workspace owner #2
treasurer@example.org,Pat Treasurer,adult,2026-09-01,2026-09-01,2026-09-01,2024-08-15,1,Workspace Owner #3
student1@example.org,Robin Student,student,,,,,1,
student2@example.org,Avery Student,student,,,,,1,
13 changes: 12 additions & 1 deletion docs/policy-mapping.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ Each control from _Moving Team Communication to Slack_, and what carries it.
| 2 | Parental consent on file before a student account exists | `consent.ts`; `team_join` event raises `unconsented_account` the moment an account appears; nightly sweep re-checks |
| 2 | Consent re-collected annually | `CONSENT_VALID_YEARS = 1`; consents expire rather than linger |
| 2 | Consents kept on file and producible | `consents` table records `document_ref`; the signed copies themselves live wherever the team files them — **manual** |
| 3 | Two YPP-screened Lead Coaches | roster `role = lead_coach` + `screening_lapsed` findings |
| 3 | Two YPP-screened Lead Coaches | `workspace_config` `screened_admins`: two of the workspace's Owners/Admins must be screened adults on the roster, plus `screening_lapsed` findings |
| 3 | Written communications copied to a second adult | `dmPolicy` — two screened adults required in any student conversation |
| 4.1 | No 1:1 adult–student DMs, ever | `dmPolicy` `one_to_one_adult_student`, raised on each new message and on backfill; a message after a finding is closed raises it again (`recurrence.ts`) |
| 4.2 | Two screened adults in every channel students are in | `twoAdults.ts`; re-evaluated on every join/leave, plus nightly |
Expand Down Expand Up @@ -36,6 +36,17 @@ training inside it is required. hawk-mod tracks it and reports it as
outstanding, but it never blocks screened-adult status. Requiring it would
have excluded adults who had done everything actually asked of them.

**"Lead Coach" is not a role hawk-mod stores.** It used to be: a roster role
that granted every administrative action in the app. That put a
youth-protection permission behind a label anyone with CLI access could type,
checked against nothing, and it meant a freshly installed app had no
administrator at all until someone opened a shell on the host. Authority is now
read live from Slack's Workspace Owner/Admin flags, which the workspace already
manages and audits. §3 is still checked — see the `screened_admins` row — but
it asks the question of the people who demonstrably hold the authority rather
than of a self-assigned label. A student holding Owner or Admin is refused
regardless (§6), and reported.

**The screening and the training run on different clocks.** The background
screening is valid for longer than a year; the training is annual. Treating
both as annual flags people who are current, and an alert channel that cries
Expand Down
30 changes: 30 additions & 0 deletions migrations/0005_authority_from_slack.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
-- Administrative authority moved out of the roster and onto Slack's own
-- Workspace Owner/Admin flags (see src/slack/authz.ts). The `lead_coach` and
-- `admin` roles existed only to answer "may this person run /hawkmod", which
-- Slack already answers, and answering it here meant a fresh install had no
-- administrator at all until someone ran the CLI on the host.
--
-- Both collapse to `adult`. No rule loses anything: enrollment, screening, and
-- the two-adult rule already treated all three identically.

INSERT INTO role_changes
(person_id, slack_user_id, from_role, to_role, source, detail, changed_at)
SELECT id, slack_user_id, role, 'adult', 'migration',
json_object('migration', '0005_authority_from_slack',
'reason', 'authority now read from Slack admin/owner'),
strftime('%Y-%m-%dT%H:%M:%fZ', 'now')
FROM people
WHERE role IN ('lead_coach', 'admin');

UPDATE people
SET role = 'adult',
updated_at = strftime('%Y-%m-%dT%H:%M:%fZ', 'now')
WHERE role IN ('lead_coach', 'admin');

-- The CHECK constraint on people.role still lists the two retired names, and
-- stays that way on purpose. Dropping a CHECK in SQLite means rebuilding the
-- table, and `people` is the parent of four ON DELETE CASCADE foreign keys —
-- consents among them. A migration that can destroy consent records to tidy a
-- constraint is a bad trade. Nothing can write the old values now: ROLES no
-- longer contains them, the CLI validates against ROLES, and the user-group
-- sync only ever writes 'student' or 'adult'.
10 changes: 2 additions & 8 deletions scripts/setup-local.sh
Original file line number Diff line number Diff line change
Expand Up @@ -630,14 +630,8 @@ const {listPeople}=require("./dist/src/db/repo.js");
for (const p of listPeople()) console.log(` ${p.role.padEnd(18)} ${p.full_name}`);
' 2>/dev/null || note " (could not list — check the logs)"
say ""
say "The group sync only ever assigns student or adult, so make yourself a"
say "Lead Coach — /hawkmod commands need one to exist."
ask LEAD_COACH_EMAIL "Your Slack email:"
"${COMPOSE[@]}" exec -T hawk-mod node dist/src/cli/index.js \
set-role "$LEAD_COACH_EMAIL" lead_coach | sed 's/^/ /' \
|| warn "couldn't set the role — is that the email on your Slack account?"
note "Being a lead_coach also survives the next sync: someone already recorded"
note "as a lead coach is not demoted for being in the adults group."
note "Nothing to grant yourself here: /hawkmod answers to Slack's Workspace"
note "Owners and Admins, read live. You installed the app, so you can run it."
say ""
step "Now record consent for the test student, from Slack:"
note " /hawkmod consent @their-account"
Expand Down
12 changes: 6 additions & 6 deletions src/cli/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,9 +22,9 @@ const USAGE = `hawk-mod cli
cori_completed_on,active,notes
import-consents <file.csv> email,signed_on,form_version,guardian_name,
guardian_email,document_ref,recorded_by[,expires_on]
set-role <email|U…> <role> role: student|adult|lead_coach|admin|
district_observer. Bootstraps the first Lead
Coach, since /hawkmod needs one to exist.
set-role <email|U…> <role> role: student|adult|district_observer.
Nothing here grants access to /hawkmod — that
is Slack's Owner/Admin, read live.
sweep run the compliance sweep
backfill walk enrolled adults' DM history
findings [status] list findings (default: open)
Expand Down Expand Up @@ -124,9 +124,9 @@ function exportConversation(id: string, out?: string) {
}

/**
* The user group sync only ever assigns `student` or `adult`, so the first
* Lead Coach has to be set from outside Slack — otherwise nobody can run
* /hawkmod at all. Recorded in role_changes like any other role change.
* The user group sync only ever assigns `student` or `adult`, so
* `district_observer` (§8) has to be set from outside Slack. Recorded in
* role_changes like any other role change.
*/
function setRole(who: string, role: string) {
if (!ROLES.includes(role as Role)) {
Expand Down
8 changes: 4 additions & 4 deletions src/domain/guidance.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import type { DmVerdict } from "./rules/dmPolicy.js";
*
* The audience is someone who almost certainly did not know the rule, so the
* tone is a colleague's, not a compliance system's: name the rule, give the
* concrete fix, say plainly that a Lead Coach can see it, and do not imply
* concrete fix, say plainly that a coach can see it, and do not imply
* anyone is in trouble. An adult who feels accused moves the conversation
* somewhere nobody can see it, which is the opposite of what this is for.
*
Expand All @@ -23,8 +23,8 @@ const OPENING = ":wave: Hi — a quick heads-up, not a telling-off.";

const CLOSING =
"Conversations that include a student are recorded for youth-protection " +
"audit, and a Lead Coach has been notified as usual. Nothing here is an " +
"accusation — if you are not sure what to do, just ask a Lead Coach.";
"audit, and a coach has been notified as usual. Nothing here is an " +
"accusation — if you are not sure what to do, just ask a coach.";

function mentions(ids: string[]): string {
return ids.map((id) => `<@${id}>`).join(", ");
Expand Down Expand Up @@ -67,7 +67,7 @@ export function guidanceFor(verdict: DmVerdict): string | null {
`This conversation includes a student and ` +
`${verdict.unknownIds.length} account(s) that are not on the team ` +
`roster, so ${APP_NAME} cannot tell whether they are a screened adult.`,
"*To put it right:* ask a Lead Coach to add them to the roster, or " +
"*To put it right:* ask a coach to add them to the roster, or " +
"to the students or adults user group. Until then the " +
"conversation counts as an exception.",
];
Expand Down
36 changes: 13 additions & 23 deletions src/domain/people.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,12 @@
import type { IsoDate } from "./dates.js";

export const ROLES = [
"student",
"adult",
"lead_coach",
"admin",
"district_observer",
] as const;
/**
* What the roster says about a person, and nothing more. Deliberately no role
* for "runs hawk-mod": administrative authority is Slack's Owner/Admin flags,
* read live in `slack/authz.ts`, so there is nothing here to keep in sync with
* Slack and nothing to bootstrap by hand on a fresh install.
*/
export const ROLES = ["student", "adult", "district_observer"] as const;

export type Role = (typeof ROLES)[number];

Expand Down Expand Up @@ -43,29 +43,19 @@ export function isStudent(m: Member): boolean {
}

/**
* Anyone on the roster who is not a student — `lead_coach`, `admin`, and
* `district_observer` included, not just the `adult` role. Do not rewrite this
* as `role === "adult"`: a Lead Coach alone with a student is exactly the
* situation the rules exist for. Unknown members are not adults either, so an
* unidentified account can never satisfy the two-adult rule.
* Anyone on the roster who is not a student — `district_observer` included,
* not just the `adult` role, and a workspace Owner is no exception. Seniority
* has never been an argument for being alone with a student; that is exactly
* the situation the rules exist for. Unknown members are not adults either, so
* an unidentified account can never satisfy the two-adult rule.
*/
export function isAdult(m: Member): boolean {
return isKnown(m) && m.role !== "student";
}

/** Roles that may hold Workspace Owner/Admin. Never a student (§6). */
export function mayAdministerWorkspace(p: Person): boolean {
return p.role === "lead_coach" || p.role === "admin";
}

/** Roles whose DMs hawk-mod expects to monitor via an enrolled user token. */
export function requiresEnrollment(p: Person): boolean {
return (
p.active === 1 &&
(p.role === "adult" ||
p.role === "lead_coach" ||
p.role === "district_observer")
);
return p.active === 1 && p.role !== "student";
}

export function label(m: Member): string {
Expand Down
11 changes: 3 additions & 8 deletions src/domain/rules/rosterSync.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,12 +23,7 @@ export type RoleDecision =
/** In both groups at once; too ambiguous to act on. */
| { kind: "conflict"; slackId: string; personId: number | null };

const ADULT_ROLES: Role[] = [
"adult",
"lead_coach",
"admin",
"district_observer",
];
const ADULT_ROLES: Role[] = ["adult", "district_observer"];

function isAdultRole(role: Role): boolean {
return ADULT_ROLES.includes(role);
Expand Down Expand Up @@ -74,8 +69,8 @@ export function reconcileRoles(
continue;
}

// Someone already recorded as a lead coach, admin, or district observer is
// in the adults group too; that is agreement, not a demotion to `adult`.
// A district observer in the adults group is agreement, not a demotion to
// plain `adult`.
if (target === "adult" && isAdultRole(person.role)) {
decisions.push({ kind: "unchanged", slackId });
continue;
Expand Down
2 changes: 1 addition & 1 deletion src/health.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ type Health = {
* later, when the failing thing would be a DM nobody recorded.
*
* `installed: false` is not unhealthy — a freshly deployed container is
* expected to be running and waiting for a Lead Coach to install the app.
* expected to be running and waiting for an admin to install the app.
*/
export function healthHandler(_req: unknown, res: ServerResponse): void {
let body: Health;
Expand Down
Loading
Loading