Back to the evidence pageThe file your district already exports
OneRoster, Clever or a header nobody here has seen, read without editing it. The two columns no export carries, what filling them costs, and why three children in one house are one telephone call.
Two things a district hits in the first hour, and what this does about each.
Your system of record does not export id, phones, consent. And two children in one house
are two rows on one telephone.
What the header row can be
Drop the export in and the run says which format it recognised. Three are built in, and a
mapping covers the rest.
| Format |
Recognised by |
Identifier |
Numbers, in dialling order |
firstbell |
id and phones |
id |
phones, comma or semicolon separated |
oneroster-1.2 |
sourcedId |
sourcedId |
phone, then sms |
clever |
student_id |
student_id |
guardian_phone, then phone |
# a OneRoster users.csv, unmodified apart from the consent column
python -m firstbell --work-file examples/absences-oneroster.csv
# a format nobody here has seen
python -m firstbell --work-file exports/monday.csv \
--column-map '{"id": ["PupilRef"], "phones": ["Contact1", "Contact2"]}'
Three decisions inside that are worth arguing with.
sms is dialled second, not instead. A number a district recorded for text messages
is a weaker candidate for a voice call than one recorded as a phone, and it is not a bad
one. Both go into the fallback chain CALL-E dials down, in that order.
A student's own number is dialled after the guardian's. Clever exports can carry both.
Ringing a child's mobile to ask why the child is not at school is a call that reaches the
wrong person even when it connects.
A file matching two formats is refused. A header row carrying sourcedId and
student_id reads as two formats, and which column is the identifier decides which family
is telephoned about which child. Rename the one you do not mean, or pass --column-map.
The column no export has
This is the part that matters, and it is not a limitation of the parser.
No format above carries a column recording that a guardian agreed to be telephoned by an
automated system about an absence. Not OneRoster, not Clever, not any student information
system export, because that permission is not a fact about a student and no system of
record was built to hold it. phone and sms say how to reach a family. They do not say
you may.
So a recognised export with no consent column stops the run, and the refusal says so
outright rather than reporting a missing column:
users.csv reads as OneRoster v1.2 users.csv, and that format has no column recording
consent to be telephoned by an automated system about an absence. Nor does any other
export here: contactability is a fact a system of record holds, and permission is not,
so no rename can produce it.
Two ways forward, and a district picks one.
Add a 'consent' column to the export, which is a boolean, and the run will say so
on every line.
Produce a dated register and pass --consent-records. docs/consent-record.md is the
schema, and it is the defensible one.
The run stops here rather than dialling, because the reading of a missing permission
that ends with a family being telephoned is the one this refuses to make.
docs/the-legal-surface.md has argued since the first draft that a
boolean is not a consent record. A real export is where that stops being an argument: it
does not have the boolean either.
The other column no export has, and what filling it costs
voice is the same shape of problem and it is not solved the same way. No export carries a
column saying the telephone cannot reach this guardian: a guardian who is deaf, hard of
hearing, or has a speech disability. OneRoster does not have it, Clever does not have it, and
phone does not mean answerable.
Here the run does not stop, and that is deliberate. A blank cell means reachable, because the
other default is to stop calling every family nobody has recorded anything about, which is
worse than calling one who cannot answer. So on day one in a district that has not filled it,
the column is blank for every pupil and this software dials a deaf parent exactly as their
current dialler does. The difference only starts when the column is populated.
examples/absences-oneroster.csv carries one row with it filled, S-2204, and the run prints
what the gate does:
[HUMAN] S-2204 this family is not reachable by a voice call; nothing was dialled
and somebody has to reach them another way
no voice channel 1 never dialled, needs another way to reach them
Four rows, four endings, one command: a reason on record, the platform refusing a language,
nobody answered, and a guardian the telephone cannot reach.
Populating it is work nobody can do for a district. The office already knows: it is in
special education records and in whatever the family liaison keeps. A district operations
director who reviewed this put their own numbers on it. Two pilot schools is around forty
rows, a spreadsheet and an afternoon. Fourteen schools and nine thousand pupils is a data
project with a governance owner, and no vendor can hand it over. That is a scaling limit
rather than a pilot limit, and it is worth knowing before signing rather than after.
One call per household
Three siblings off with the same virus used to be three calls to the same mother inside a
quarter of an hour, each opening with the automated-caller disclosure and asking about one
child as though the other two calls had not happened.
Rows sharing a first number, compared on digits so +1 555 010 0301 and +15550100301
are one telephone, are one household for the run. The row the export put first is dialled.
The rest are held, and the run says how many calls it did not place:
2 row(s) share a telephone number with an earlier absence, so 2 fewer call(s) will be
placed. Each held row still goes to a person: one answer covers one named child.
[ok ] S-3101 schema-valid answer received
[skip ] S-3102 another absence in the same household is being called: S-3101 is on the same number. One answer closes one record, so a person closes this one.
held, same household 2 calls this run did not place, because the
same number was already being called about another
absence. Each one still goes to a person.
Held, not closed. A mother who says she knows all three children are at home has
answered for all three, in the room. What comes back from the platform is one structured
result about one named child, with no repeated field for the other two, so closing three
records on it means writing an answer this program was never given into a record about a
child. The held rows go to a person. Thirty seconds of a secretary's time against two
calls to a family that did not need them.
The instruction does name every absent child in the house, through also_absent_names on
the dialled row, so the parent is asked once about all of them rather than once each.
This sentence used to name also_absent, which is the neighbouring key holding their ids
and is read by nothing in the task builder.
A row nobody was going to dial holds nothing behind it. A sibling behind a family with no
consent, or behind a family the telephone cannot reach, is not a call saved, and counting
it as one would report work removed that was never going to happen.
The missing piece is not here. A structured result with one entry per subject would let
one answer close three records honestly, and
call-e-feedback.md files that as a platform finding instead of
this repository guessing at it.
What checks this
tests/test_sis_ingest.py, thirty tests: one per format, the ambiguous header row, the
unrecognised one, the supplied mapping, the consent refusal and its wording, both numbers
becoming a fallback chain, the guardian before the student, and eleven on the grouping,
including that a held row lands in its own bucket rather than under a run somebody
cancelled. Rows 194 to 201 of evidence/MUTATIONS.md break
each check and record how many tests noticed.
This page is docs/district-ingest.md in the repository, rendered at build time. The file is the original and this is a copy of it, so if the two ever disagree the file is right.