From ff7ee7266cda50b0e2ea822bca70608e73d49802 Mon Sep 17 00:00:00 2001 From: Rachel Moore Date: Thu, 15 Oct 2026 17:57:31 -0400 Subject: [PATCH 1/2] Import skill: what the first real backfill taught (#65) The first real run, a backfill of 13 minimally-entered students, worked end to end and checked clean. It also showed what the skill got wrong: - The Drive connector returns only each tab's header and first 10 rows, so the full read is now an .xlsx download, read by read_xlsx.py (standard library only) from the scratchpad and deleted afterwards. Completeness is checked against the plan's people count. - Every block goes on the clipboard with pbcopy and is checked with pbpaste, since tabs copied out of a chat code block can become spaces. - Fills go in blank rectangles over consecutive rows, checked by Person ID on the first and last rows, instead of one row at a time. - Each pass ends on a fresh download compared with the previous one across the whole workbook, cell by cell. - The baseline also records the groups dry run, so a join that was already pending (one turned up) isn't taken for the import's own. - No rule splits every name of three or more words, so each one is confirmed; an operator-approved correction of an existing value is a write of its own; deploy before the baseline. - mapping.md gains Format E's team-added columns (CORI check dates are dates to get, never an expiry) and the conventions the earlier rows followed: grade to year, skill synonyms, note wording. Co-Authored-By: Claude Opus 5.5 --- .claude/skills/import-people/SKILL.md | 292 ++++++++++++---------- .claude/skills/import-people/mapping.md | 60 +++-- .claude/skills/import-people/read_xlsx.py | 73 ++++++ 3 files changed, 273 insertions(+), 152 deletions(-) create mode 100644 .claude/skills/import-people/read_xlsx.py diff --git a/.claude/skills/import-people/SKILL.md b/.claude/skills/import-people/SKILL.md index 9adc61b..985def9 100644 --- a/.claude/skills/import-people/SKILL.md +++ b/.claude/skills/import-people/SKILL.md @@ -36,15 +36,16 @@ which rows each kind of person gets. This skill is the safety wrapper around ## The rules that hold at every step - **The operator writes, by pasting. The agent prepares and checks.** The agent - reads the sheet through the Google Drive connector and has no write access to - it. + reads the sheet from a full `.xlsx` download (gate 1) and has no write access + to it. The Google Drive connector's `read_file_content` returns only each + tab's header and first 10 rows, so it can't stand in for a full read. - **Every date is copied from a record.** Expiry and completion dates come from - FIRST's or the state's own system, typed in by a person. A "yes", a TRUE, or - "done as of [date]" on a form becomes a **blank** cell plus a line on the - _dates to get_ list. The earlier manual conversion turned "yes" answers into - forward-dated expiries. That is the mistake this rule exists for: hawk-mod - trusts a mentor's CORI date to grant access, and all three screening dates - to count them as a screened adult. + FIRST's or the state's own system, typed in by a person. A "yes", a TRUE, a + "done as of [date]", or the date a check was done becomes a **blank** cell + plus a line on the _dates to get_ list. The earlier manual conversion turned + "yes" answers into forward-dated expiries. That is the mistake this rule + exists for: hawk-mod trusts a mentor's CORI date to grant access, and all + three screening dates to count them as a screened adult. - **Students are reached by School Email only.** A student's personal address goes in `People: Personal Email`, which hawk-mod uses for matching only. An address that belongs to a guardian goes on the guardian's @@ -53,13 +54,17 @@ which rows each kind of person gets. This skill is the safety wrapper around "Student with no School Email" as a sheet problem, which is the prompt for the operator to find the address and add it by hand. Until then the student joins no group and gets no Slack invite. -- **Personal data stays in chat.** Everything read from the sheet or from a - source (names, addresses, phone numbers, answers) appears only in this - conversation. Files, commits, issues, PRs and memory get structure only. The - repo is public. +- **Personal data stays in chat and the scratchpad.** Everything read from the + sheet or from a source (names, addresses, phone numbers, answers) appears + only in this conversation and in temporary files in the session scratchpad, + each download copied into its own new folder there. Delete them when the + import is done. Files in the repo, commits, issues, PRs and memory get + structure only. The repo is public. - **A fill only ever writes into a blank cell.** A cell that already holds a value keeps it. Where the source disagrees with that value, list it as a - _difference_ for the operator. + _difference_. If the operator decides the existing value is a mistake, the + correction becomes a write of its own. It goes in the pass its column + belongs to, and its check confirms the old value before the paste. - **Anything uncertain is set aside and listed, not guessed.** That covers a possible duplicate, an unclear role, a dropdown value missing from `Reference_Lists`, a misaligned source row, and a person outside the grade @@ -75,38 +80,49 @@ which rows each kind of person gets. This skill is the safety wrapper around Name current version, `before import YYYY-MM-DD`. Restoring it later undoes the import in one step, but it also discards anyone else's edits made since then. Say so if a restore ever comes up. -2. Ask the operator to run `/hawkmod lifecycle plan` in Slack and paste the - output. Record the people count, the role counts, and **every** sheet - problem line. That list is the baseline. -3. Read the sheet with the Drive connector. Compare its tab names and headers - with `docs/lifecycle-sheet.md` §3 and with `SHEET_TABS` in - `src/domain/lifecycle/schema.ts`. If anything differs, stop and ask. -4. Ask the operator for two things the Drive read cannot be trusted to give - (it may come back incomplete for a large sheet): - - **The highest Person ID in `People`.** If it doesn't match the highest one - in your read, your read is incomplete. Stop and say so. - - **Row 1 and the first empty row of each tab the import will touch.** To - find the first empty row: click a cell in column A, press Cmd+↓ to jump to - the last filled row, and add one. Row 1 gives the **live** column order, - which can differ from the docs. - - For a fill, the row number of each existing row comes from your read. It is - checked against column A at paste time (gate 3), so the read doesn't have to - be trusted for it. - -Done when: a named version exists, the baseline is recorded, the headers -match, the read is complete, and you have the live header order for every tab -you will touch, plus the first empty row of each tab you will add or append -to. +2. **Check what is deployed.** If a merged change to hawk-mod hasn't been + deployed yet (compare the last Deploy run with `main`), ask the operator to + deploy first, so the baseline and the checks after it run on the same code. +3. Ask the operator to run two dry runs in Slack and paste the replies: + - `/hawkmod lifecycle plan`: record the people count, the role counts, the + group sizes, and **every** sheet problem line. + - `/hawkmod lifecycle groups`: record every join and hold **already + pending**, so that gate 4 can tell this import's joins from older ones. + + Together they are the baseline. + +4. **Read the whole sheet.** Ask the operator to download it (File → Download + → Microsoft Excel (.xlsx)) with no edits since step 3. Copy the file into a + new, empty folder in the scratchpad, and read it with + [`read_xlsx.py`](read_xlsx.py), which needs only the Python standard + library. Check that: + - **the read is complete:** `People` has exactly the plan's people count of + data rows, with no Person ID twice; + - **the tab names and headers** match `docs/lifecycle-sheet.md` §3 and + `SHEET_TABS`. If anything differs, stop and ask. + + From the read, take each tab's **live** column order (row 1, which can + differ from the docs), its first empty row, and the row number of every + existing row a fill will touch. The row numbers are checked against + column A again at paste time. + +Done when: a named version exists, the code is deployed, the baseline is +recorded, and the read is complete with matching headers. ## Gate 2: map, match, and get the table approved 1. **Identify the source format** from [`mapping.md`](mapping.md). Each format there says how to recognise it. If none matches, stop: write the mapping - for the new format with the operator first, as structure only. -2. **Bound the batch.** At most 25 people per import. A bigger source goes in + for the new format with the operator first, as structure only. Columns the + mapping doesn't know (a team may add its own to a responses sheet) are set + aside until the operator says what they hold. +2. **Follow the conventions already on the sheet.** When earlier rows came from + the same format, compare them with their source responses (grade → year, + skill synonyms, how notes were worded) and do the same. Report any place + where the earlier rows disagree with `mapping.md`. +3. **Bound the batch.** At most 25 people per import. A bigger source goes in several imports, each through every gate. -3. **Match every incoming person** against the read. Search `People` by every +4. **Match every incoming person** against the read. Search `People` by every name form (legal, preferred, last name alone) and by every email the source gives. Compare each one against Personal Email, School Email, RHR Email **and** `Emergency_Contacts.Email`: a parent who is now a mentor is already @@ -115,31 +131,37 @@ to. - An exact match on email, or on name and email, is **existing**: use that Person ID. - No match on anything is **new**: mint the next Person ID, counting up from - the highest one the operator confirmed. IDs are never reused. + the highest one in the read. IDs are never reused. - A partial match (same name with a different email, or a similar name with a shared address) is **set aside** for the operator to decide. -4. **For an existing person, append the rows they are missing and fill the +5. **For an existing person, append the rows they are missing and fill the blank cells the source answers.** Mark each one live or quiet. Where the source disagrees with a cell that already has a value, list the difference. -5. **Check every dropdown value** against `Reference_Lists` in the read, and - use it verbatim. A value that isn't listed goes on the _values to add_ list, - and its cell stays blank. -6. **Show the approved-table draft in chat**, with: +6. **Check every dropdown value** against the full `Reference_Lists` tab in + the read, and use it verbatim. A value that isn't listed goes on the + _values to add_ list, and its cell stays blank. +7. **Show the approved-table draft in chat**, with: - **Per new person:** the Person ID, and every row on every tab, with every value, in live column order. - **Per existing person:** the Person ID, each appended row, and each filled cell with its tab, row number and column. Mark every one live or quiet. + - **Names to confirm:** every name of three or more words, with the split + proposed. No rule splits every name correctly, so the operator confirms + each one. - **Set aside:** each person or field left out, and why. - **Expected sheet problems:** each one the import will add, such as "Student with no School Email", by Person ID. - **Dates to get:** the person, the column, and what the source said. - **Values to add** to `Reference_Lists`. - - **Differences** on existing people. + - **Differences** on existing people, numbered so the operator can answer + each by its number. - **Already in Slack or a group:** anyone the source suggests already has an account at their School or RHR address (Format D, for example). See gate 4. - **Derived values:** any value computed rather than copied (a graduation year from a grade), each shown with its working. + - **What gate 4 will show:** the expected changes in group sizes, roster + updates and the "no parent email" list. Done when the operator replies in chat approving the table. A partial approval narrows the table. It does not stretch to rows that weren't approved. @@ -152,8 +174,8 @@ in it. 1. **Adds.** These are staged, as below. 2. **Quiet appends and fills.** No automation reads them, so paste them any time. -3. **Live appends and fills.** Paste these just after a :20 run, and finish - gate 4's checks for them before the next one. +3. **Live appends and fills, and approved corrections.** Paste these just + after a :20 run, and finish gate 4's checks for them before the next one. ### Adds: staged as Inactive @@ -166,45 +188,47 @@ Appends and fills for existing people can't be staged that way. They are already Active, and setting them to Inactive would make offboarding treat them as leaving. That is why the live ones get the :20 window instead. -### Adds and appends: one block per tab, at the bottom - -Prepare one paste block per tab, covering the whole batch, in this order: -`People`, `People_Roles`, then `Student_Details` or `Mentor_Details`, then -`Emergency_Contacts`, then `People_Skills`. For each block give: +### Every block goes on the clipboard -- the **start cell**, as `Tab!A`; -- a fenced code block of **tab-separated** values, one line per row, columns in - the live header order, with no header line. A blank cell is an empty field - between tabs, and dates are `YYYY-MM-DD`. +Write each approved block to a tab-separated file in the scratchpad. Hand it +over with `pbcopy < block.tsv`, then check `pbpaste | cmp - block.tsv`. Text +copied out of a chat code block can turn tabs into spaces, which puts a whole +row into one cell. Fall back to a fenced code block only where `pbcopy` isn't +available. One block at a time: the operator replies after each paste, and +only then does the next block go on the clipboard. -The operator then, for each block: +For each block, tell the operator: -1. clicks the Name Box (the cell-address box left of the formula bar), types - the start cell, and presses Enter; -2. pastes with **Cmd+Shift+V** (paste values only, which keeps the column's - formatting and dropdown validation); -3. tells you if Sheets showed a validation warning or rejected anything. If it - did, stop that tab and work out why before going on. +1. the **check**: a cell to look at in the Name Box (the cell-address box left + of the formula bar), and what it must show. If it shows anything else, + stop. Your read is stale or wrong, so take a new download before going on; +2. the **start cell** to type into the Name Box; +3. to paste with **Cmd+Shift+V** (paste values only, which keeps the column's + formatting and dropdown validation), and to report any validation warning. + If one appears, stop that tab and work out why before going on. -### Fills: one row at a time, with a Person ID check +### Adds and appends: one block per tab, at the bottom -Writing into the middle of a tab is the riskiest paste there is: a wrong row -overwrites someone else's data. So for each existing person, on each tab, -give: +One block per tab, covering the whole batch, in this order: `People`, +`People_Roles`, then `Student_Details` or `Mentor_Details`, then +`Emergency_Contacts`, then `People_Skills`. Columns go in the live header +order, with no header line, and dates as `YYYY-MM-DD`. The check is the last +filled row's Person ID, with the row below it empty. The start cell is +`Tab!A`. -- the **check cell**, `Tab!A`, and the Person ID it must show; -- each **span** to fill: its start cell, `Tab!`, and a - one-line tab-separated block covering **only blank cells** that sit next to - each other. A gap of filled cells between two blank ones means a second - span. Take the column letters from the live row 1. +### Fills: blank rectangles, checked at both ends -The operator then, for each row: +Writing into the middle of a tab is the riskiest paste there is: a wrong row +overwrites someone else's data. So a fill block is a **rectangle in which +every cell is blank in the gate 1 read**. A cell inside it that stays blank is +pasted as an empty field, which leaves it blank. Build the largest such +rectangles over consecutive rows, and keep each one within a single pass: +a quiet rectangle must not reach into a live column. The check is the Person +ID in column A on the rectangle's **first and last** rows. The start cell is +its top-left corner, with column letters taken from the live row 1. -1. types the check cell into the Name Box and presses Enter, and confirms - that column A shows that Person ID. If it doesn't, stop. Your read is - stale or wrong, so re-read before going on. -2. for each span, types the start cell into the Name Box, presses Enter, and - pastes with **Cmd+Shift+V**. +A correction is a one-cell block. Its check is that cell's old value as well as +the row's Person ID. **When `Emergency_Contacts` lists a contact who is already on the sheet** (the same email under another Person ID, such as a sibling's parent), copy @@ -218,30 +242,33 @@ start cells you gave, and should be checked by Person ID before deleting. A fill can be undone by clearing the cells pasted, since they were blank before. Or restore the named version. -Done when every approved block is pasted, with no validation complaints. +Done when every approved block in the pass is pasted, with no validation +complaints. + +## Gate 4: verify each pass -## Gate 4: verify, then go live +**Every pass ends on a fresh download, compared with the previous read across +the whole workbook, cell by cell.** Each cell the pass was meant to write holds +exactly the approved value, and was blank before (or held the confirmed old +value, for a correction). **Every other cell in every tab is unchanged.** A +whole-workbook comparison also catches a paste that landed on the wrong tab or +the wrong row, and the earlier conversion's one real error, a few columns of +dates shifted sideways. Then ask for the dry runs below. Each of them is a dry +run as typed. Never ask for `apply` or `sync` here: those act. ### Adds, while the new people are Inactive -1. **Re-read the sheet** and compare every new row **by column name, never by - position**, with the approved table. The earlier conversion's one real - error was a few columns of dates shifted sideways, caught this way. Also - check that the last pre-import row of each tab still holds the same Person - ID it did in gate 1, so nothing was overwritten. -2. Ask the operator to run these and paste the output. Each of them is a dry - run as typed. Never ask for `apply` or `sync` here: those act. - - `/hawkmod lifecycle plan`: `People` and `Inactive` each rose by exactly - the number of new people, `Active` is unchanged, and the sheet problems - are the baseline plus the expected ones from the approved table. Any - other new problem is a failure. - - `/hawkmod lifecycle roster`, `groups`, `slack-groups` and `onboarding`: - none of them mentions a new person. - - `/hawkmod lifecycle offboarding`: none of them appears. A new person - listed here already has a Slack account or a group membership at their - address, and hawk-mod reads Inactive as someone leaving. Before the next - :20 run, either finish verifying and flip them to Active, or have the - operator delete their rows. +- `/hawkmod lifecycle plan`: `People` and `Inactive` each rose by exactly + the number of new people, `Active` is unchanged, and the sheet problems + are the baseline plus the expected ones from the approved table. Any + other new problem is a failure. +- `/hawkmod lifecycle roster`, `groups`, `slack-groups` and `onboarding`: + none of them mentions a new person. +- `/hawkmod lifecycle offboarding`: none of them appears. A new person + listed here already has a Slack account or a group membership at their + address, and hawk-mod reads Inactive as someone leaving. Before the next + :20 run, either finish verifying and flip them to Active, or have the + operator delete their rows. ### Adds, after the flip @@ -253,35 +280,40 @@ again: to its baseline. - `roster`: exactly one create per new Student or Mentor, no updates, nothing else. -- `groups` and `slack-groups`: only the joins the approved table implies. - Students join by School Email, guardians join `grp-parents`, and mentors join - only once their CORI is current, which a fresh import usually leaves blank. - **This is the check on every guardian address.** +- `groups` and `slack-groups`: only the joins the approved table implies, on + top of the joins already pending in the baseline. Students join by School + Email, guardians join `grp-parents`, and mentors join only once their CORI + is current, which a fresh import usually leaves blank. **This is the check + on every guardian address.** - `onboarding`: only the requests the approved table implies. The hourly run at :20 then applies it. -### Appends and fills - -1. **Re-read every touched row.** Each filled cell holds the approved value. - **Every other cell in the row equals the gate 1 read**, so nothing was - overwritten. Each appended row matches the approved table, compared by - column name. -2. **After the quiet pass**, `/hawkmod lifecycle plan` is unchanged: the same - counts and the same sheet problems. Anything that moved means a write - landed in a column hawk-mod reads. -3. **After the live pass**, and before the next :20 run: - - `plan`: the same counts. The sheet problems are the baseline, minus any - the fill resolves ("Student with no School Email" once it's filled), plus - the expected ones. - - `roster`: no creates. Only the updates the fills imply, since the roster - copies names, identity emails and dates. - - `groups` and `slack-groups`: only the joins the approved table implies. - A filled School Email adds that student, and an appended guardian joins - `grp-parents`. **This is the check on every guardian address.** - - `onboarding`: only the requests the approved table implies, such as a - Slack invite for a student whose School Email was just filled. - - `offboarding`: none of them appears. +### Quiet pass + +`/hawkmod lifecycle plan` is unchanged: the same counts and the same sheet +problems. Anything that moved means a write landed in a column hawk-mod +reads. + +### Live pass, before the next :20 run + +- `plan`: the same people and role counts. Group sizes move only as the + approved table said they would (each appended guardian adds one to + `grp-parents`). The sheet problems are the baseline, minus any the pass + resolves ("Student with no School Email" once it's filled), plus the + expected ones. +- `roster`: no creates. Only the updates the pass implies. The roster's name + is the Preferred First Name (the Legal First Name when that's blank) plus + the Legal Last Name, so filling a legal first name under an existing + preferred one changes nothing there. +- `groups` and `slack-groups`: only the joins the approved table implies, on + top of the joins already pending in the baseline. A filled School Email + adds that student, and an appended guardian joins `grp-parents`. **This is + the check on every guardian address.** A pending join that was in the + baseline is not this import's, but put it on the follow-up list. +- `onboarding`: only the requests the approved table implies, such as a + Slack invite for a student whose School Email was just filled. +- `offboarding`: none of them appears. **If anything is unexpected, at any stage:** before the next :20 run, the operator sets the new people back to Inactive, clears the filled cells, deletes @@ -291,6 +323,12 @@ Done when every check above passes and the operator has confirmed the flip. ## After the import -Report in chat: how many people were added, how many rows appended and cells -filled, what was set aside, the dates to get, and the values to add. If the mapping was wrong or a -format was new, update [`mapping.md`](mapping.md) with structure only, in a PR. +1. **Report in chat:** how many people were added, how many rows appended and + cells filled, what was set aside, the dates to get, the values to add, and a + numbered **follow-up list** of everything left for a person (a missing + phone number, a pending join from the baseline). +2. **Delete the scratchpad copies** (the downloads, the JSON and the block + files), and remind the operator to delete the downloads from their own + Downloads folder. +3. If the mapping was wrong or a format was new, update + [`mapping.md`](mapping.md) with structure only, in a PR. diff --git a/.claude/skills/import-people/mapping.md b/.claude/skills/import-people/mapping.md index 9cf84a8..71acbe9 100644 --- a/.claude/skills/import-people/mapping.md +++ b/.claude/skills/import-people/mapping.md @@ -106,27 +106,35 @@ and interest-level pairs for several program tiers, a school-email column and a student-phone column (both for high-schoolers), a block of `Student Interests: [Category]` columns, a participation/availability question, a shirt-size question, a parent-volunteer question, and a trailing -`Email Address` that is sometimes the student's and sometimes a parent's. - -| Source field | Goes to | Notes | -| ------------------------------ | ------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| Student First / Last Name | `People`: Legal First / Last Name | | -| Pronouns | `People`: Pronouns | Dropdown rule above. | -| Dietary | `People`: Food Allergies / Requirements | What they can and can't eat. If the answer names a medical condition, the condition goes to `Student_Details`: Additional Notes (medical / special needs), so the dietary field stays a food-handling instruction. | -| Grade | `Student_Details`: Graduation Year | A **derived value**: the year that grade graduates, counted from the school year the question names. Show the working in the table. A grade below the range the sheet covers sets the whole person aside. | -| Media/photo release | `Student_Details`: Media Release Scope | Only if the chosen option matches a Reference_Lists F value. The release's expiry stays blank until the signed form comes back. | -| School name | `Student_Details`: Melrose Media Release Expiry = `N/A` | Only when the school is named and is clearly not a Melrose school. Otherwise blank. | -| School email | `Student_Details`: School Email | Only a school-domain address. One of the student's own personal addresses goes to `People`: Personal Email instead. A guardian's address goes nowhere on the student's row. With no school address, School Email stays blank and the student is still imported. List it as an expected sheet problem (gate 2). | -| Student phone | `People`: Cell Phone | | -| Parent/Guardian 1, 2 | `Emergency_Contacts`, one row per guardian given | See _Guardians_ below. | -| Sponsorship | `Student_Details`: Parent Employer (Potential Sponsor) | The company as given. Vague non-answers dropped. An unrelated volunteering offer in the same answer becomes a short trailing note. | -| Student Interests block | `People_Skills`, one row per "interested" | Map category labels onto Reference_Lists G only where they're clear synonyms. "Not interested" gives no row. | -| Participation/availability | `Student_Details`: Other Extracurriculars | Only a named outside activity and its effect on the schedule. General attendance wording is set aside. | -| Shirt size | `People`: Shirt Size | Dropdown rule above. | -| Trailing Email Address | match only | Helps work out which address is the student's and which a parent's. Never stored as its own field. | -| Prior experience, prior FRC | set aside | See Rookie Year above. | -| Program selection, other tiers | set aside | A respondent who picked a tier the sheet doesn't cover, and left its fields blank, is set aside whole. | -| Parent volunteer interest | set aside | Parents aren't People. That is a separate outreach list. | +`Email Address` that is sometimes the student's and sometimes a parent's. The +2026-27 responses sheet also has columns the team added by hand, which are not +form questions: `CORI parent?`, `CORI parent 2?` and `Summer Learning`. + +The first conversion left Pronouns, Food Allergies / Requirements and Shirt +Size blank for every student, so a backfill can fill them (all quiet). + +| Source field | Goes to | Notes | +| ----------------------------------------- | ------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| Student First / Last Name | `People`: Legal First / Last Name | | +| Pronouns | `People`: Pronouns | Dropdown rule above. | +| Dietary | `People`: Food Allergies / Requirements | What they can and can't eat, as written. A plain "No" is `None`, the value already on the sheet. An answer whose meaning is unclear (a single food, for example) is kept as written and listed for the operator. If the answer names a medical condition, the condition goes to `Student_Details`: Additional Notes (medical / special needs), so the dietary field stays a food-handling instruction. | +| Grade | `Student_Details`: Graduation Year | A **derived value**: the year that grade graduates, counted from the school year the question names. For the 2026-27 form: 12th is 2027, 11th is 2028, 10th is 2029 and 9th is 2030, which matches every earlier row. Show the working in the table. A grade below the range the sheet covers sets the whole person aside. | +| Media/photo release | `Student_Details`: Media Release Scope | Only if the chosen option matches a Reference_Lists F value. The release's expiry stays blank until the signed form comes back. | +| School name | `Student_Details`: Melrose Media Release Expiry = `N/A` | Only when the school is named and is clearly not a Melrose school. Otherwise blank. | +| School email | `Student_Details`: School Email | Only a school-domain address. One of the student's own personal addresses goes to `People`: Personal Email instead. A guardian's address goes nowhere on the student's row. With no school address, School Email stays blank and the student is still imported. List it as an expected sheet problem (gate 2). | +| Student phone | `People`: Cell Phone | | +| Parent/Guardian 1, 2 | `Emergency_Contacts`, one row per guardian given | See _Guardians_ below. | +| Sponsorship | `Student_Details`: Parent Employer (Potential Sponsor) | The company as given. "No" and vague non-answers dropped. An answer that names no company but asks for contact becomes a note in the earlier rows' style: "Note: no company given; …". Never infer the company from an email domain. An unrelated volunteering offer in the same answer becomes a short trailing note. | +| Student Interests block | `People_Skills`, one row per "interested" | CAD, Machining & Fabrication, Software, Electronics and Game Strategy keep their names. As the earlier rows did: Sponsor Outreach & Fundraising is `Fundraising & Grant Writing`, Social Media & Marketing is `Marketing & Outreach`, and Photography & videography is `Multimedia`. "Un-interested" gives no row. | +| Participation/availability | `Student_Details`: Other Extracurriculars | Only a named outside activity and its effect on the schedule, briefly (the activity and how long it conflicts). General attendance wording is set aside. | +| Shirt size | `People`: Shirt Size | Dropdown rule above. | +| Trailing Email Address | match only | Helps work out which address is the student's and which a parent's. Never stored as its own field. | +| Prior experience, prior FRC | set aside | See Rookie Year above. | +| Program selection, other tiers | set aside | A respondent who picked a tier the sheet doesn't cover, and left its fields blank, is set aside whole. | +| Parent volunteer interest | set aside | Parents aren't People. That is a separate outreach list. | +| `CORI parent?`, `CORI parent 2?` | **blank**, plus a line on _dates to get_ | Added by the team, not asked on the form. They hold the date the guardian's CORI check was done (or "n", or a note), not an expiry, so `Emergency_Contacts: CORI Expiry` stays blank until the expiry is entered from the state record. | +| `Summer Learning` | set aside | Added by the team. No column for it. | +| _(a blank Legal First Name on the sheet)_ | `People`: Legal First Name | Not from the form, which asks for the name a student goes by. When the sheet has only a preferred name, the School Email's first part (`firstname` in `firstname.lastname@…`) gives the legal one: a **derived value**, written only with the operator's approval. | ### Guardians @@ -135,10 +143,12 @@ question, a shirt-size question, a parent-volunteer question, and a trailing - **Relationship:** `Parent/Guardian` for both, because the form doesn't say which. - **Rank:** by slot. Guardian 1 is `1`, guardian 2 is `2`. -- **Name:** with three or more words, split at the second space, so a - compound first name stays together. With two words, split at the space. A - single word goes in First Name, with Last Name blank. List every split of - three or more words for the operator to check. +- **Name:** with two words, split at the space. A single word goes in First + Name, with Last Name blank. With three or more words **no rule works**: one + name splits after the first word (a first name, then a three-word family name), another before + the last (a two-word first name). Propose a split and list it under _names to + confirm_, so the operator decides each one. **(corrected:** this said "split + at the second space", which the earlier rows didn't follow either.) - **Siblings:** each sibling gets their own rows for a shared guardian. Each student needs their own contact chain. Give the new row the same `CORI Expiry` the guardian already has elsewhere on the sheet (gate 3). diff --git a/.claude/skills/import-people/read_xlsx.py b/.claude/skills/import-people/read_xlsx.py new file mode 100644 index 0000000..283ee17 --- /dev/null +++ b/.claude/skills/import-people/read_xlsx.py @@ -0,0 +1,73 @@ +"""Read an .xlsx with the Python standard library only, for the import-people +skill. Writes one JSON file, {sheet name: [{"row": n, "cells": {"A": value}}]}, +and prints each sheet's row count. Date-formatted numbers become YYYY-MM-DD. + +Usage: python3 -I read_xlsx.py + +The JSON holds every cell of the sheet, minors' personal data included: write +it only to the session scratchpad, never into the repo, and delete it when the +import is done.""" +import json, re, sys, zipfile, datetime +import xml.etree.ElementTree as ET + +NS = {"m": "http://schemas.openxmlformats.org/spreadsheetml/2006/main", + "r": "http://schemas.openxmlformats.org/officeDocument/2006/relationships"} +REL = "{http://schemas.openxmlformats.org/package/2006/relationships}" +DATE_IDS = set(range(14, 23)) | {45, 46, 47} + +def text(el): + return "".join(t.text or "" for t in el.iter("{%s}t" % NS["m"])) + +def main(src, out): + z = zipfile.ZipFile(src) + shared = [] + if "xl/sharedStrings.xml" in z.namelist(): + for si in ET.fromstring(z.read("xl/sharedStrings.xml")).findall("m:si", NS): + shared.append(text(si)) + date_styles = set() + st = ET.fromstring(z.read("xl/styles.xml")) + custom = {} + for nf in st.findall("m:numFmts/m:numFmt", NS): + custom[int(nf.get("numFmtId"))] = nf.get("formatCode", "") + for i, xf in enumerate(st.findall("m:cellXfs/m:xf", NS)): + fid = int(xf.get("numFmtId", "0")) + code = custom.get(fid, "") + if fid in DATE_IDS or (code and re.search(r"[yd]", re.sub(r'"[^"]*"', "", code), re.I)): + date_styles.add(i) + wb = ET.fromstring(z.read("xl/workbook.xml")) + rels = {r.get("Id"): r.get("Target") for r in ET.fromstring(z.read("xl/_rels/workbook.xml.rels")).iter(REL + "Relationship")} + result = {} + for sh in wb.findall("m:sheets/m:sheet", NS): + target = rels[sh.get("{%s}id" % NS["r"])].lstrip("/") + path = target if target.startswith("xl/") else "xl/" + target + rows = [] + for r in ET.fromstring(z.read(path)).iter("{%s}row" % NS["m"]): + cells = {} + for c in r.findall("m:c", NS): + col = re.match(r"[A-Z]+", c.get("r")).group(0) + t, s = c.get("t"), int(c.get("s", "0")) + v = c.find("m:v", NS) + if t == "s" and v is not None: + val = shared[int(v.text)] + elif t == "inlineStr": + val = text(c) + elif v is None: + continue + elif t in ("str", "b", "e"): + val = v.text or "" + elif s in date_styles: + val = (datetime.date(1899, 12, 30) + datetime.timedelta(days=float(v.text))).isoformat() + else: + n = float(v.text) + val = str(int(n)) if n.is_integer() else v.text + if val != "": + cells[col] = val + if cells: + rows.append({"row": int(r.get("r")), "cells": cells}) + result[sh.get("name")] = rows + json.dump(result, open(out, "w"), ensure_ascii=False, indent=1) + for name, rows in result.items(): + last = rows[-1]["row"] if rows else 0 + print(f"{name}: {len(rows)} non-empty rows, last row {last}") + +main(sys.argv[1], sys.argv[2]) From da601d3bc54e4bf4306dbb5de89681001f9b6b65 Mon Sep 17 00:00:00 2001 From: Rachel Moore Date: Thu, 15 Oct 2026 18:16:09 -0400 Subject: [PATCH 2/2] CORI expiry: three school years, ending 1 September The team's rule, settled during the first backfill: a CORI check covers the school year it was done in (July to June) and the next two, and its expiry is entered as 1 September after the third, about the first day of school. A summer check counts toward the coming school year. That puts a summer check's expiry up to two months past three years, and hawk-mod read anything past three years as a typo, so a mentor checked in July would have lost access. latestCoriExpiry allows the 1 September and is never earlier than three years, so nothing current before reads as a typo now. It replaces the plain bound in screeningStatus, mayHaveAccess and screeningEntryErrors. lifecycle-sheet.md now says what "never calculated" meant all along: the sheet stores the expiry, which is what everything triggers on. The CORI rule is the one expiry worked out, when it is entered. The import skill shows each one as a derived value. Co-Authored-By: Claude Opus 5.5 --- .claude/skills/import-people/SKILL.md | 5 +++- .claude/skills/import-people/mapping.md | 2 +- CLAUDE.md | 4 ++- docs/lifecycle-sheet.md | 29 +++++++++---------- src/domain/rules/screening.ts | 33 +++++++++++++++++----- test/rules.test.ts | 37 +++++++++++++++++++++++++ 6 files changed, 86 insertions(+), 24 deletions(-) diff --git a/.claude/skills/import-people/SKILL.md b/.claude/skills/import-people/SKILL.md index 985def9..51a9f94 100644 --- a/.claude/skills/import-people/SKILL.md +++ b/.claude/skills/import-people/SKILL.md @@ -42,7 +42,10 @@ which rows each kind of person gets. This skill is the safety wrapper around - **Every date is copied from a record.** Expiry and completion dates come from FIRST's or the state's own system, typed in by a person. A "yes", a TRUE, a "done as of [date]", or the date a check was done becomes a **blank** cell - plus a line on the _dates to get_ list. The earlier manual conversion turned + plus a line on the _dates to get_ list. The one exception is CORI: the + sheet's rule (`docs/lifecycle-sheet.md` §2) turns the date a CORI check was + done into its expiry, 1 September after the third school year. Show each one + as a derived value with its working. The earlier manual conversion turned "yes" answers into forward-dated expiries. That is the mistake this rule exists for: hawk-mod trusts a mentor's CORI date to grant access, and all three screening dates to count them as a screened adult. diff --git a/.claude/skills/import-people/mapping.md b/.claude/skills/import-people/mapping.md index 71acbe9..3f3a095 100644 --- a/.claude/skills/import-people/mapping.md +++ b/.claude/skills/import-people/mapping.md @@ -132,7 +132,7 @@ Size blank for every student, so a backfill can fill them (all quiet). | Prior experience, prior FRC | set aside | See Rookie Year above. | | Program selection, other tiers | set aside | A respondent who picked a tier the sheet doesn't cover, and left its fields blank, is set aside whole. | | Parent volunteer interest | set aside | Parents aren't People. That is a separate outreach list. | -| `CORI parent?`, `CORI parent 2?` | **blank**, plus a line on _dates to get_ | Added by the team, not asked on the form. They hold the date the guardian's CORI check was done (or "n", or a note), not an expiry, so `Emergency_Contacts: CORI Expiry` stays blank until the expiry is entered from the state record. | +| `CORI parent?`, `CORI parent 2?` | `Emergency_Contacts`: CORI Expiry, a **derived value** | Added by the team, not asked on the form. A date there is the day the guardian's CORI check was done, not an expiry: the expiry is 1 September after the third school year of that date (`docs/lifecycle-sheet.md` §2), shown with its working. "n", "NO" or a note gives nothing. Set every row that lists the same guardian. | | `Summer Learning` | set aside | Added by the team. No column for it. | | _(a blank Legal First Name on the sheet)_ | `People`: Legal First Name | Not from the form, which asks for the name a student goes by. When the sheet has only a preferred name, the School Email's first part (`firstname` in `firstname.lastname@…`) gives the legal one: a **derived value**, written only with the operator's approval. | diff --git a/CLAUDE.md b/CLAUDE.md index 580878c..06b2d5d 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -384,7 +384,9 @@ double-counting — both adults in a group DM also deliver the same event twice. (`rules/screening.ts`). Three items block: Youth Protection Training (`training_expires_on`, annual), Background Screening (`screening_expires_on`, 3 years — it was 4, FRC2713/hawk-mod#17) and CORI + fingerprints -(`cori_expires_on`, 3 years, state law). Consent & Release, Data Privacy and +(`cori_expires_on`, 3 years, state law, counted in school years: the team enters +1 September after the third, so `latestCoriExpiry` lets a summer check's date +run up to two months past three years without reading as a typo). Consent & Release, Data Privacy and Mentor Ready are reported and **never block** — gating on them would flag adults who have done everything the safety rules actually ask. FIRST expires its annual items on **1 August**, not a year after completion, so any diff --git a/docs/lifecycle-sheet.md b/docs/lifecycle-sheet.md index 5ed55cb..5c8f25a 100644 --- a/docs/lifecycle-sheet.md +++ b/docs/lifecycle-sheet.md @@ -54,8 +54,9 @@ The `_Instructions` tab inside the sheet is a living quick-reference for anyone - Blank = not on file / not started yet. - The one exception: `Melrose Media Release Expiry` on `Student_Details`, where the literal text `N/A` means the form does not apply to that student (they're not in the Melrose school district). - FIRST's annual items (Youth Protection Training, Consent & Release, Data Privacy for Mentors) all expire on **August 1**, the FIRST season rollover — not exactly one year after completion. The expiry date is always copied verbatim from what FIRST's system shows; it is never calculated in-sheet. - - Background Screening and CORI expire every **3 years**; that date is likewise always copied from the state/vendor record, never calculated. -- **Dates are never invented or calculated by whoever/whatever is editing the sheet.** Every date comes from FIRST's or the state's records (or the relevant form), entered by a human. New/blank date cells are left blank, not backfilled with a guess. + - Background Screening expires every **3 years**; that date is likewise always copied from the vendor record. + - **CORI counts in school years.** A CORI check covers the school year it was done in (July 1 to June 30) and the next two, and its `CORI Expiry` is **September 1** after the third, about the first day of school. Work it out from the date the check was done: a check in September 2023 or in May 2024 (both school year 2023-24) expires `2026-09-01`; a check over the summer of 2026 counts toward 2026-27 and expires `2029-09-01`. This is the same for mentors (`Mentor_Details`) and contacts (`Emergency_Contacts`). +- **The sheet stores the expiry date, and that is what everything triggers on.** Enter the date a requirement stops being valid, not the date it was done, so that nothing has to work out an expiry later. Every date comes from FIRST's or the state's records (or the relevant form), entered by a human; the CORI rule above is the one way an expiry is worked out, and it is worked out when it is entered. A "yes" or a checkbox is not a date: new/blank date cells are left blank, not backfilled with a guess. - **Slack User ID columns** (on `Mentor_Details` and `Student_Details`) are normally filled in **by hawk-mod** once the person joins Slack — `/hawkmod lifecycle slack-ids apply` in Slack. hawk-mod only fills a blank cell, and only on an exact match between the Slack account's email and the RHR Email (mentors) or School Email (students). Typing one in by hand is allowed when needed, typically when someone's Slack email is not that address; hawk-mod trusts a typed ID unless it is not a live Slack account or appears on two rows. - **hawk-mod reads this sheet by exact tab name and column header text.** Column _order_ does not matter, but renaming, adding, or removing a tab or header without first updating hawk-mod will break the sync with an error. @@ -115,16 +116,16 @@ A person can have multiple rows (multiple roles). Roles are additive/historical ### `Emergency_Contacts` (PK: `Person ID`, one row per contact — applies to every human, any role) -| Column | Notes | -| ------------------ | -------------------------------------------------------------------------------------------------------- | -| Person ID | Whose contact this is | -| Contact First Name | | -| Contact Last Name | | -| Cell Phone | | -| Email | | -| Relationship | Dropdown (`Reference_Lists!C`): `Parent/Guardian`, `Grandparent`, `Spouse`, `Sibling`, `Friend`, `Other` | -| Rank | Integer; `1` = primary contact (called first). `99` = on file but not to be contacted. | -| CORI Expiry | Date. Any contact, though mostly parents. Copied from the state record, never calculated. | +| Column | Notes | +| ------------------ | ----------------------------------------------------------------------------------------------------------- | +| Person ID | Whose contact this is | +| Contact First Name | | +| Contact Last Name | | +| Cell Phone | | +| Email | | +| Relationship | Dropdown (`Reference_Lists!C`): `Parent/Guardian`, `Grandparent`, `Spouse`, `Sibling`, `Friend`, `Other` | +| Rank | Integer; `1` = primary contact (called first). `99` = on file but not to be contacted. | +| CORI Expiry | Date. Any contact, though mostly parents. September 1 after the third school year of the check (Section 2). | For a **student**, a `Parent/Guardian` contact whose `Rank` is not `99` is on `grp-parents` while the student is Active. hawk-mod reads only `Person ID`, `Email`, `Relationship`, `Rank` and `CORI Expiry` here — never a contact's name or phone. @@ -143,7 +144,7 @@ Current header order: | Mentor Ready Completed | Date | One-time badge/certification — completion date, not an expiry. Column was renamed from "Mentor Ready Expiry"; old expiry-style values were cleared when it became a completion-date column. | | YPT Expiry | Date | Youth Protection Training; annual, expires Aug 1. (Column was renamed from "YPP Expiry" — same underlying certification.) | | Background Screening Expiry | Date | Every 3 years | -| CORI Expiry | Date | Every 3 years | +| CORI Expiry | Date | September 1 after the third school year of the check (Section 2) | | Data Privacy Expiry | Date | Annual (FIRST's Data Privacy for Mentors); expires Aug 1 | | RHR Media Release Completed | Date | One-time form — completion date | | Media Release Scope | Dropdown | `Reference_Lists!F`: `No recognizable image of me`, `Any image as long as I am not named`, `Any image containing me (unrestricted)` | @@ -261,7 +262,7 @@ One column per dropdown, values top-to-bottom. This is the **only** place free-t 1. Check `People` first — a returning alum or former student becoming a mentor should get a new _role_ row, not a new `Person ID`. 2. If genuinely new, generate the next `Person ID` and add a `People` row, including `Rookie Year`. Emergency contact info for mentors goes in `Emergency_Contacts`, not on the `People` row. 3. Add a `People_Roles` row: `Role = Mentor`. -4. Agree the mentor's `@redhawkrobotics.org` address with them (not everyone wants `firstname@`), and add a `Mentor_Details` row with it as `RHR Email`. hawk-mod then asks, in the onboarding channel, for a Super Admin to create the Google account at that address; the request closes once the account exists. If the address is not agreed yet, leave `RHR Email` blank and hawk-mod asks for both. Fill in each requirement date as it's completed (blank = not started yet): `YPT Expiry`, `Background Screening Expiry`, `CORI Expiry`, `Consent & Release Expiry`, `Data Privacy Expiry` (each copied from FIRST or the state, never calculated), and `Mentor Ready Completed` (the date the badge was earned). `RHR Media Release Completed` is the date the one-time form was done; set `Media Release Scope` from what they selected on that form. Set `Mentor Lead (Y/N)` and `RA (Y/N)` — both default to `N` unless told otherwise; `RA` is a manual judgment call, not derived from anything else. Leave `Slack User ID` blank — hawk-mod fills it in. The mentor joins Google Groups and is invited to Slack only once their CORI is current; hawk-mod posts the invite request then. +4. Agree the mentor's `@redhawkrobotics.org` address with them (not everyone wants `firstname@`), and add a `Mentor_Details` row with it as `RHR Email`. hawk-mod then asks, in the onboarding channel, for a Super Admin to create the Google account at that address; the request closes once the account exists. If the address is not agreed yet, leave `RHR Email` blank and hawk-mod asks for both. Fill in each requirement date as it's completed (blank = not started yet): `YPT Expiry`, `Background Screening Expiry`, `CORI Expiry`, `Consent & Release Expiry`, `Data Privacy Expiry` (each copied from FIRST or the state, except `CORI Expiry`, which follows the school-year rule in Section 2), and `Mentor Ready Completed` (the date the badge was earned). `RHR Media Release Completed` is the date the one-time form was done; set `Media Release Scope` from what they selected on that form. Set `Mentor Lead (Y/N)` and `RA (Y/N)` — both default to `N` unless told otherwise; `RA` is a manual judgment call, not derived from anything else. Leave `Slack User ID` blank — hawk-mod fills it in. The mentor joins Google Groups and is invited to Slack only once their CORI is current; hawk-mod posts the invite request then. 5. Only add a `Mentor_Admin_Roles` row if they're granted a delegated admin role — most mentors don't get one. ### New volunteer diff --git a/src/domain/rules/screening.ts b/src/domain/rules/screening.ts index 18dd414..2aedf8a 100644 --- a/src/domain/rules/screening.ts +++ b/src/domain/rules/screening.ts @@ -23,10 +23,31 @@ export const SCREENING_VALID_YEARS = 3; /** * CORI and national fingerprint checks every three years for volunteers with * direct and unmonitored contact with students — M.G.L. c. 71 §38R and - * 603 CMR 51.00. State law, independent of anything FIRST asks for. + * 603 CMR 51.00. State law, independent of anything FIRST asks for. The team + * counts them in school years: a check covers the school year it was done in + * (July to June) and the next two, and its expiry is entered as 1 September + * after the third, about the first day of school (lifecycle-sheet.md §2). */ export const CORI_VALID_YEARS = 3; +/** When a CORI check's coverage ends: about the first day of school. */ +export const CORI_EXPIRES = "09-01"; + +/** + * The latest CORI expiry that can be real on `asOf`: three years out, or — + * for a check done over the summer, which counts toward the coming school + * year — the 1 September at the end of that year's third school year, up to + * two months further. Never earlier than three years, so nothing that was + * current before this rule reads as a typo now. + */ +export function latestCoriExpiry(asOf: IsoDate): IsoDate { + const year = Number(asOf.slice(0, 4)); + const schoolYear = asOf.slice(5) >= "07-01" ? year : year - 1; + const sept = `${schoolYear + CORI_VALID_YEARS}-${CORI_EXPIRES}`; + const plain = addYears(asOf, CORI_VALID_YEARS); + return sept > plain ? sept : plain; +} + /** FIRST's season rollover, when its annual items expire. */ export const ROLLOVER = "08-01"; @@ -79,7 +100,7 @@ function blocking(p: RequirementDates): Requirement[] { { item: "CORI + fingerprints", expiresOn: p.cori_expires_on, - latest: (asOf) => addYears(asOf, CORI_VALID_YEARS), + latest: latestCoriExpiry, }, ]; } @@ -196,9 +217,7 @@ export function mayHaveAccess( asOf: IsoDate ): boolean { const e = p.cori_expires_on; - return ( - e !== null && notExpired(e, asOf) && e <= addYears(asOf, CORI_VALID_YEARS) - ); + return e !== null && notExpired(e, asOf) && e <= latestCoriExpiry(asOf); } /** @@ -221,8 +240,8 @@ export function screeningEntryErrors( ], [ "cori_expires_on", - `${CORI_VALID_YEARS} years`, - addYears(asOf, CORI_VALID_YEARS), + `${CORI_VALID_YEARS} school years`, + latestCoriExpiry(asOf), ], ["consent_release_expires_on", "a year", latestAnnualExpiry(asOf)], ["data_privacy_expires_on", "a year", latestAnnualExpiry(asOf)], diff --git a/test/rules.test.ts b/test/rules.test.ts index 52b6219..09c3eb7 100644 --- a/test/rules.test.ts +++ b/test/rules.test.ts @@ -6,6 +6,8 @@ import { consentStatus, mayHoldAccount } from "../src/domain/rules/consent.js"; import { isScreenedAdult, latestAnnualExpiry, + latestCoriExpiry, + mayHaveAccess, screeningEntryErrors, screeningStatus, } from "../src/domain/rules/screening.js"; @@ -160,6 +162,30 @@ describe("screening", () => { assert.equal(screeningStatus(p, AS_OF).current, false); }); + /** + * CORI covers the school year of the check (July to June) and the next two, + * and expires on 1 September after the third. A check done over the summer + * counts toward the coming year, so its expiry can be up to two months past + * three years out — and must not read as a typo. + */ + it("allows a summer CORI check's 1 September expiry", () => { + assert.equal(latestCoriExpiry("2026-07-01"), "2029-09-01"); + assert.equal(latestCoriExpiry("2026-08-19"), "2029-09-01"); + assert.equal(latestCoriExpiry("2026-09-26"), "2029-09-26"); + assert.equal(latestCoriExpiry("2027-06-30"), "2030-06-30"); + assert.equal( + mayHaveAccess({ cori_expires_on: "2029-09-01" }, "2026-07-15"), + true + ); + assert.equal( + mayHaveAccess({ cori_expires_on: "2029-09-02" }, "2026-07-15"), + false + ); + const p = screened(); + p.cori_expires_on = "2029-09-01"; + assert.equal(screeningStatus(p, "2026-07-15").current, true); + }); + it("finds the rollover on the right side of 1 August", () => { assert.equal(latestAnnualExpiry("2026-07-31"), "2027-08-01"); assert.equal(latestAnnualExpiry("2026-08-01"), "2028-08-01"); @@ -218,6 +244,17 @@ describe("recording screening dates", () => { ]); }); + it("accepts the 1 September expiry of a CORI check done over the summer", () => { + assert.deepEqual( + screeningEntryErrors({ cori_expires_on: "2029-09-01" }, "2026-07-15"), + {} + ); + assert.ok( + screeningEntryErrors({ cori_expires_on: "2029-09-02" }, "2026-07-15") + .cori_expires_on + ); + }); + it("refuses a Mentor Ready completion in the future", () => { assert.ok( screeningEntryErrors({ mentor_ready_completed_on: "2026-09-27" }, AS_OF)