Skip to content

Onboard students via a hawk-mod-provisioned Google Form #14

Description

@tytremblay

Problem Statement

Getting people onto the roster is the one part of hawk-mod that still runs on a human with a CSV. Today an administrator collects names and emails somewhere off-system, hand-builds a CSV, and runs npm run cli -- import-roster on the host — which means an onboarding student is invisible to hawk-mod until somebody with shell access gets round to it, and every team that adopts hawk-mod has to invent their own collection process before they can use it at all.

Onboarding via a Google Form is the obvious fix, but pointing hawk-mod at a spreadsheet a team built by hand replaces one problem with a worse one: a renamed header, a reordered column, a filtered view somebody forgot to clear, or a stale tab all read as data rather than as errors. planGroupMembership's refusal logic already exists because of exactly this scenario — a bad sheet does not corrupt @students, it empties it.

And a hand-built form does not travel. If hawk-mod is to be usable by another FIRST team, "first, build a Google Form with these nine questions spelled exactly this way" is not a setup step anyone will get right.

Solution

hawk-mod creates the onboarding form itself, in the team's own Google Drive, from a question schema defined in this codebase. An administrator authorizes Google once; hawk-mod stamps out the form and records its id and its per-question item ids. Because hawk-mod authored every question, it knows each question's stable item id forever — a human renaming or reordering questions in the Forms UI cannot break the read, and a question that has been deleted is detected as a missing precondition before any sync runs, rather than being silently parsed as blanks.

Responses are read through the Forms API keyed by those item ids. hawk-mod never reads a spreadsheet. A team that wants a browsable view can link the form to a sheet in the Forms UI; that sheet is for humans, and nothing anyone does to it can affect a sync.

An administrator runs /hawkmod roster sync, sees a plan of exactly what would change, and confirms it. Applying the plan creates roster rows for newly onboarded students, links Slack accounts by email, and adds them to the student user group through the existing group planner. Everything the form touches only ever adds monitoring.

The form carries no authority. It is filled in by students, so it collects identity and nothing else: no role question, no screening dates, no consent. A form-originated row can only ever be created as a student. Screening dates, consents and any move out of student stay on the existing attributed admin paths, exactly as they are today.

User Stories

  1. As a team administrator setting up hawk-mod for the first time, I want hawk-mod to create the onboarding form for me, so that I do not have to reproduce a question schema by hand and get it subtly wrong.
  2. As a team administrator, I want to authorize hawk-mod against my team's Google Workspace from a link in Slack, so that the form is created in our Drive and stays ours.
  3. As a team administrator, I want the Google authorization to request the narrowest scopes that will do the job, so that granting it does not hand hawk-mod access to the rest of our Drive.
  4. As a team administrator, I want to be shown the form's URL after it is created, so that I can share it with onboarding students.
  5. As a team administrator, I want /hawkmod roster status to tell me whether a form exists, how many responses are waiting, and when the last sync ran, so that I can tell at a glance whether onboarding is flowing.
  6. As a team administrator, I want to run /hawkmod roster sync and be shown a plan before anything changes, so that I can see what hawk-mod is about to do rather than find out afterwards.
  7. As a team administrator, I want the plan to name each person it would create, so that an unexpected name is caught by a human and not by a later finding.
  8. As a team administrator, I want to confirm the plan explicitly, so that reading the plan and applying it are two separate acts.
  9. As a team administrator, I want a plan that looks like an accident to be refused outright with a reason, so that I cannot apply a broken sync by ignoring a warning.
  10. As a team administrator, I want a sync that would add an implausibly large number of people at once to be refused, so that a duplicated or test-flooded form does not flood the roster.
  11. As a team administrator, I want a sync that finds no new responses to say so plainly and change nothing, so that running it twice is safe.
  12. As a team administrator, I want to re-run a sync immediately after a failed one without double-creating anybody, so that a network error mid-sync is not a data problem.
  13. As an onboarding student, I want to fill in a short Google Form with my name and email, so that I am on the team roster without anyone re-typing my details.
  14. As an onboarding student, I want the form to collect my verified Google account email rather than one I type, so that a typo cannot leave me unlinked or link me as somebody else.
  15. As an onboarding student, I want to be told what the form is for, so that I understand hawk-mod records my direct messages with adults.
  16. As a team administrator, I want a student's roster row created from a form response to be a student regardless of anything they typed, so that the form cannot be used to claim an adult role.
  17. As a team administrator, I want the form to have no role question at all, so that the question of who is an adult is never asked of a minor.
  18. As a team administrator, I want screening dates to remain unreachable from the form, so that nobody can self-certify Youth Protection Screening, Youth Protection Training or CORI.
  19. As a team administrator, I want parental consent to remain on its existing attributed path, so that a consent record always names the adult who recorded it.
  20. As a team administrator, I want a person who submits the form twice to be treated as one person, so that a resubmission corrects their details rather than creating a duplicate.
  21. As a team administrator, I want the most recent response for an email to win, so that a student correcting a misspelled name simply resubmits.
  22. As a team administrator, I want a response whose email already belongs to a roster person to update that person's name at most, so that re-onboarding an existing member is harmless.
  23. As a team administrator, I want a response from someone already on the roster as an adult to be reported and skipped rather than demoted to student, so that a mentor filling in the wrong form cannot end their own monitoring exemption or anyone else's.
  24. As a team administrator, I want a response that is missing a required answer to be reported as an anomaly with the person named, so that I can chase that one person instead of re-running everything.
  25. As a team administrator, I want anomalous rows to be skipped while good rows still apply, so that one bad response does not block an entire onboarding cohort.
  26. As a team administrator, I want responses that have already been applied to be ignored on later syncs, so that the plan only ever shows me what is genuinely new.
  27. As a team administrator, I want a person who disappears from the responses to be left completely alone, so that no read of the form can ever end anybody's monitoring.
  28. As a team administrator, I want a deleted or emptied form to produce a refusal rather than an empty plan, so that "no responses" and "the form is gone" never look the same.
  29. As a team administrator, I want a form whose questions hawk-mod created but can no longer find to produce a refusal naming the missing question, so that I know exactly what was edited away.
  30. As a team administrator, I want a human reordering the questions in the Forms UI to have no effect whatsoever on a sync, so that ordinary tidying is not a hazard.
  31. As a team administrator, I want a human renaming a question's text to have no effect on a sync, so that rewording for clarity is safe.
  32. As a team administrator, I want to be told that adding my own extra questions is safe and ignored, so that I can collect team-specific information in the same form.
  33. As a team administrator, I want applying a sync to add the new students to the student user group, so that declaration and monitoring land together and I do not have to remember a second step.
  34. As a team administrator, I want the group edit to go through the same planner and refusal rules as /hawkmod group, so that a sync cannot empty a group in a way a manual edit could not.
  35. As a team administrator, I want the sync to tell me when a response's email matches no Slack account, so that I can chase the student to join the workspace rather than wondering why they are unmonitored.
  36. As a team administrator, I want a roster row created even when the Slack account cannot yet be found, so that the person is known and can be linked automatically once they join.
  37. As a team administrator, I want to know who ran each sync and what it changed, so that a surprising roster row has an attributable origin.
  38. As a team administrator, I want every role change from a sync to land in the existing role-change trail, so that there is one place to look regardless of how a role was set.
  39. As a team administrator who is not a Slack admin, I want to be refused, so that the onboarding pipeline is gated exactly like every other hawk-mod entry point.
  40. As a team administrator, I want the Google authorization to be a separate grant from Slack enrollment and from group-editing authorization, so that authorizing one never silently costs me another.
  41. As a team administrator, I want the stored Google credentials encrypted at rest like every other token, so that the database file alone is not enough to reach our Drive.
  42. As a team administrator, I want to see in /hawkmod config whether Google is authorized and which form is in use, so that the same command answers "why does this look wrong" for onboarding too.
  43. As a team administrator, I want a stored form id validated against Google when it is set, so that a typo does not read exactly like a form with no responses.
  44. As a team administrator whose Google authorization has expired or been revoked, I want the sync to say so and tell me how to re-authorize, so that I am not left guessing at an opaque API error.
  45. As a team administrator, I want a Google outage to fail the sync loudly and change nothing, so that a partial sync is never a state I have to reason about.
  46. As an adopting team, I want to run through form creation without hand-editing any configuration files, so that adopting hawk-mod does not require shell access to the host.
  47. As an adopting team, I want the created form to be identical to every other team's, so that the questions hawk-mod reads are the questions hawk-mod wrote.
  48. As a maintainer, I want the question schema to live in this codebase, so that changing the onboarding questions is a code change with a review and not a click in somebody's browser.
  49. As a maintainer, I want a schema change to be applied to an existing form rather than requiring a new one, so that a team does not lose its response history when hawk-mod adds a question.
  50. As a maintainer, I want the sheet reader path to remain unbuilt unless it is needed, so that there is one way to read onboarding data and not two.
  51. As a team administrator, I want the form to be shareable by link to people inside our Workspace, so that distributing it is a Slack message rather than an access-request queue.
  52. As a team administrator, I want to be reminded that the response data contains minors' names and emails, so that I set its sharing deliberately.

Implementation Decisions

Google is a fourth narrow token plane, and never merges with the other three.
An administrator authorizes Google at its own route, alongside /slack/authorize-groups. The grant is stored as its own row — scoped, purpose-marked, encrypted through the existing token encryption — for the same reason the admin usergroups grant is a separate row from a mentor's user row: sharing a row means one authorization silently costing another. Scopes requested are the narrowest that work: form authorship, response reading, and the Drive scope that grants access only to files the app itself created. Nothing here can read a file hawk-mod did not create.

The form is created by hawk-mod, from a schema in code.
A module owns the canonical question list — identity only: full name, preferred name, verified respondent email, and graduation year. Provisioning creates the form and persists the mapping from schema key to Google's returned item id. That mapping is the contract for every subsequent read. Reading is by item id, never by question title and never by position.

Responses are read from the Forms API. No spreadsheet is read, ever.
The Forms API cannot link a form to a response spreadsheet — that is a UI/Apps-Script operation — which settles the question rather than complicating it: the sheet becomes a human convenience that hawk-mod has no relationship with.

Verified email is the identity.
The form collects the respondent's authenticated Google account email rather than a typed field. Email is the key for dedupe, for matching an existing roster person, and for resolving a Slack account. Names are never used as keys.

One new pure planner in domain/rules/, at the same altitude as reconcileRoles and planGroupMembership.
It takes the form's current structure (which item ids exist), the responses, and the current roster as plain objects, and returns a plan: per-response decisions, per-response anomalies, and a plan-level refusal. All validation, dedupe, and diffing happen inside it. It performs no I/O.

The plan's decision vocabulary:

type OnboardingDecision =
  | { kind: "create"; email; fullName; role: "student" }   // role is a literal, not read from input
  | { kind: "update-name"; personId; email; from; to }
  | { kind: "unchanged"; email }
  | { kind: "skip"; email; reason }        // already an adult; response predates last sync
  | { kind: "anomaly"; email: string | null; reason }  // missing required answer, unparseable

The plan carries its own refusal, following GroupPlan.
A caller must not be able to apply a bad plan by forgetting to check a flag elsewhere. Refusals: the form is missing or inaccessible; a schema question hawk-mod created is no longer present (named); the response set is empty in a way that indicates a read failure rather than a quiet week; the plan would create more people at once than a bound allows.

Applying a plan reuses every existing write path.
Roster rows are created through the existing person upsert. Slack accounts are linked by email through the existing linking path, and a person with no matching Slack account is still created. Group membership goes through planGroupMembership and the serialized groupAdmin writer, so a sync inherits the empty-group and over-large-removal refusals, and the existing lock keeps a sync from racing a slash command. Role changes land in role_changes; the sync itself is recorded with the administrator who ran it, in the manner of group_changes.

Sync is never destructive, by construction.
The planner emits no decision that deactivates, demotes, or removes anybody. A response that vanishes produces nothing. This is the same invariant rosterSync.ts holds, and for the same reason: the direction is what matters, not the writes.

Idempotence comes from a stored high-water mark plus per-response ids.
Each applied response's id is recorded, so re-running a sync — including after a mid-apply failure — shows only genuinely new work. The Forms API's per-response timestamps supply the ordering that makes "most recent response for an email wins" well-defined.

Slack surface: a new /hawkmod roster subcommand family. sync is already taken by the user-group re-read, so the new commands are roster setup, roster status, roster sync (plan), and a confirm action on the plan message. Every one checks administrator() for itself — there is no central middleware, and a handler that forgets the check is open to the workspace.

Settings. The form id and the sync's bounds join the SETTINGS allowlist, resolved database → environment → unset like everything else, validated against Google when stored (a typo'd form id reads exactly like a form with no responses). Google client credentials are environment-only and never reachable from Slack, exactly as the Slack credentials and TOKEN_ENCRYPTION_KEY are.

Schema evolution applies in place. Adding a question to the schema updates the existing form and records the new item id, rather than creating a second form; a team keeps its response history. A question hawk-mod no longer needs is left in the form and ignored.

Testing Decisions

A good test here exercises external behavior through the highest seam available, and in this codebase that means calling a pure rule with plain objects and asserting on what it returns. The tests must not know how the planner iterates, what it calls internally, or in what order. There is no database fixture, no Slack mock and no harness beyond node:test, and this feature must not introduce any of them — nor a Google mock.

The module under test is the new onboarding planner, and it is the only new thing tested directly. Its inputs are three plain values (form structure, responses, roster) and its output is a plan, so every interesting case is a literal in a test file. Cases to cover, drawn from the user stories above: questions reordered (no effect); a question renamed (no effect); a schema question deleted (refusal naming it); an unknown extra question present (ignored); an empty response set; duplicate responses for one email (latest wins); a response matching an existing student (unchanged or name update); a response matching an existing adult (skipped, never demoted); a response missing a required answer (anomaly, other rows still apply); a response already applied (not replanned); a response set large enough to trip the creation bound (refusal); and — the load-bearing one — that no input whatsoever produces a decision that deactivates, demotes or removes a person.

Prior art to follow exactly: test/groupMembership.test.ts for a planner whose refusal is part of its return value, and test/rosterSync.test.ts for reconciliation decisions asserted against plain Person objects. test/settings.test.ts is the pattern for the new setting's resolution, and test/userGroups.test.ts for handle/reference parsing if any is added.

Deliberately untested: the Google client — provisioning, response fetch, token refresh. It is the impure edge and does nothing but turn API responses into the plain objects the planner consumes, which is precisely how Slack reads are treated today. Pushing logic into it is the failure mode to watch for in review: policy that lands in a client or a handler is policy nothing covers.

Group edits are not re-tested. The sync reuses planGroupMembership; its refusals are already covered.

Out of Scope

  • Reading a Google Sheet. Explicitly not built. If Google's app-verification requirements ever block an adopting team from using the Forms API path, a header-mapped sheet reader is the documented degraded mode — but building both now means two ways to read onboarding data and two ways for it to be wrong.
  • Linking the form to a response spreadsheet. The Forms API cannot do it. A team that wants one clicks it in the Forms UI, and hawk-mod neither knows nor cares.
  • Adult onboarding through the form. Adults reach the roster through the mentor user group and enroll by their own authorization. Nothing about that changes.
  • Collecting screening dates or consent through the form. Both stay on their existing attributed admin paths. Self-certified Youth Protection Screening is a contradiction in terms.
  • Automatic or scheduled syncing. Every sync is an administrator's deliberate, attributed act. Nothing in the sweep touches this.
  • Deactivation, demotion, or group removal from a sync. Ending monitoring remains /hawkmod deactivate alone.
  • Editing a submitted response. Google does not offer it and it is not needed: corrections are a resubmission, and everything else is an admin path.
  • Google Workspace as a roster store, e.g. Google Groups. Membership there would duplicate the Slack user groups and re-introduce exactly the declaration/monitoring conflation CONTEXT.md exists to prevent.
  • Google app verification itself. Getting through Google's review for public distribution is a project task, not a code change. Until then an unverified app shows a consent warning and is capped at 100 users, which is tolerable while adoption is a handful of teams with one authorizing administrator each.

Further Notes

  • The groupMembership.ts doc comment already names this feature as its reason for existing: "the single-user command and the eventual spreadsheet-driven sync share this and not just a naming convention." This spec is that sync arriving — by a different door than the comment anticipated, but through the same planner.
  • The response data contains minors' names and email addresses. The form's own sharing settings are the access control on it, and the setup flow should say so rather than leaving it to be discovered. docs/policy-mapping.md should gain a row for this pipeline.
  • Google's app-verification path is the main risk to the "available to other teams" goal, and it is a paperwork risk rather than a technical one. Worth confirming the exact scope classification before promising other teams a frictionless install.
  • A student appearing in the roster before they have joined Slack is a normal state, not an error: the existing email-matching linker picks them up when they arrive.

Metadata

Metadata

Assignees

No one assigned

    Labels

    ready-for-agentSpec is ready for an agent to implement

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions