California's public school data, readable by the families it describes.
Working title. Not affiliated with the State of California or any school district.
Live at https://homeroom.chelseakr.com — every active California school, 10,534 of them, in English and Spanish. What is published there, and why the ask layer covers two of those pages rather than all 21,069, is under Status.
No acquired data and no network. site-offline renders every fixture school in
both languages, which is the same renderer and the same code path the published
site is built with — only the inputs differ.
uv sync --locked --extra ask # or: make sync
make site-offline # 3 fixture schools, English and Spanish
python3 -m http.server -d build/site-offline # then open http://localhost:8000The pages carry a fixture banner in both languages, because a page built from sample rows must never be mistakable for one about a real school.
| Command | What it does |
|---|---|
make verify |
The single local gate; run it before opening a PR. Needs Node 22 for the markup and accessibility stages, which run npm ci. See CONTRIBUTING.md. |
make site-offline |
The school pages, from committed fixtures. |
make data-offline |
The same pipeline's JSON artifacts (data/out/), from the same fixtures. |
make explain CDS=<code> |
One school's record from data/out/: every cell's state, its unit, and the CDE file and year it came from. A withheld or unreported cell carries no number — the value key is absent, not zero and not null. This is what a reporter cites. |
make diff OLD=<dir> NEW=<dir> |
What changed between two publishes, by school and by cell state, over the artifacts rather than the 23,310-file markup diff. A cell that went from a number to withheld is its own event, never a value that changed to nothing; a source nobody supplied is one event, not thousands. |
make dataset |
The artifacts as a citable dataset release under dist/dataset/: schools.csv where every measure is a state column and a value column, a Table Schema generated from the same header, one JSON record per school, and a manifest.json of SHA-256 digests — plus the whole directory as a byte-reproducible .tar.gz. Refuses a fixture build unless asked (ALLOW_FIXTURE=1). |
make site |
The pages for a real school. Needs the CDE extracts in data/raw/, which are not in git — PROVENANCE.md names each file and how to acquire it. |
make publish |
Rebuilds the committed site/ tree that is served at the live URL. Needs data/raw/ and ASK_ENDPOINT. Renders into build/publish-site/ and replaces site/ only once that tree is inside the limits its deploy is subject to. |
make publish-limits |
Weighs site/ against those limits and prints what it weighs. make publish runs it over what it has just rendered. |
make sources-check |
Reads CDE's HTML download pages and reports whether any of them now lists a file newer than the one PROVENANCE.md records as acquired. Fetches no data file. Not part of make verify. |
California publishes an enormous amount of data about its public schools: enrollment, chronic absenteeism, teacher assignments, per-pupil spending, English learner progress. Almost none of it is legible to a parent deciding where to enroll a child, or trying to understand the school their child already attends. The files live in download pages built for researchers, the dashboard flattens everything into color bands, and commercial school raters compress it all into a single score with well-documented equity harms.
Homeroom joins the state's own data into plain-language, bilingual school pages, and holds one rule above all others:
It refuses to rank schools. No composite score, no letter grade, no ordering of one school above another. Each measure is shown on its own terms, beside the statewide and district context needed to read it, with its suppression and coverage stated. A number that cannot be shown honestly is not shown at all.
- A suppressed or masked measure (CDE masks small cells to protect students) renders as not published, never as zero, never interpolated.
- "Not reported" and "reported as zero" are different facts and stay visually different.
- District and statewide context comes from the state's own aggregate rows, never from adding schools together. A sum over a column containing masked cells is wrong, and a sum that skips them is wrong and looks clean, because it drops exactly the students the mask protects.
- Every figure traces to a named public file with an access date (see PROVENANCE.md),
and
make sources-checksays when CDE has published a newer one. - Coverage is a first-class output: how many schools publish each measure is itself published, so absence reads as absence rather than as a clean dataset.
- English and Spanish from the first release. No account.
- Google Analytics 4 counts page views (owner decision, 2026-09-17), added to the
published pages after rendering by
src/homeroom/analytics.py. It loads only on homeroom.chelseakr.com, never under Global Privacy Control, Do Not Track or the "Opt out of analytics" button on every page, with Google signals and ad personalization off; the landing page carries the full disclosure in both languages.
Source files are downloaded from CDE's public data pages the way CDE intends: in a
browser, by a person. The pipeline treats them as locally acquired inputs, with each
file's origin, date, and name documented in PROVENANCE.md; drop them in data/raw/ and
make data validates and builds from there. CI never fetches a source file, and a
small committed fixture exercises every rendering case.
An acquisition date is only honest while it is the newest thing CDE publishes, so
make sources-check reads each source's download page — the HTML index, never a
data file — and compares the filename and posting note CDE lists now against the ones
recorded in PROVENANCE.md's machine-readable source register when the file was
acquired. It reports unchanged, newer, unreadable, or not checkable with the
reason, and an unreadable page never reads as unchanged: a page that answers HTTP 200
with no file listing at all, which is what www3.cde.ca.gov returns to a non-browser
client, is unreadable. .github/workflows/source-freshness.yml runs it weekly and
keeps exactly one issue up to date; it acquires nothing, because acquiring a source is
a browser step a person takes. That is narrower than the
"CI never touches the network" this line carried until 2026-08-29: make verify-ci
reaches a package index or an advisory database at uv sync, npm ci, pip-audit,
npm audit, and the pinned uvx runs of semgrep and zizmor. What never crosses the
network is the data. This mirrors the Afterward project's
answer to the same provenance problem with federal endpoints.
The pages are for families. The dataset is for the readers who check them, and it
carries the same rule: no blank means anything. Every measure in schools.csv is
two columns —
| column | value |
|---|---|
grades.GR_03__state |
reported, zero, suppressed or not_reported, never empty |
grades.GR_03__value |
the number CDE published, filled exactly when the state is reported or zero |
so a blank value cell cannot be read as a zero: the word beside it says which of the two
absences it is. Those are the four cells the school pages render, mapped through one
table, so the CSV cannot come to say something different from the markup. A published
zero is zero, not reported, for the same reason the page labels it in words.
make dataset writes schools.csv, schools.schema.json (a Frictionless Table Schema
generated in the same pass as the header, so a column cannot exist without a
declaration), one schools/<cds>.json per school — byte-for-byte what make explain
prints, from the same function — coverage.json verbatim, and manifest.json listing
every file with its size and SHA-256. The whole directory is packed as
homeroom-dataset-<date>.tar.gz, and two runs over one set of artifacts produce the
same bytes and the same digest: members sorted, every mtime, uid and gid zeroed, and
gzip's own timestamp zeroed too. A citable dataset whose digest moves is not citable.
The <date> is the latest access date in coverage.json — when the files were
taken from CDE, which is the dataset's identity. Never a build clock, which would give
two names to one dataset. A fixture build has no access date at all and is named
homeroom-dataset-fixture; a made-up vintage on a file is worse than no vintage.
It refuses rather than publishes: a fixture build without ALLOW_FIXTURE=1, artifacts
that do not state is_fixture as a boolean, a row count that is not exactly the
profiles figure the build recorded (a truncated dataset presenting itself as a
complete one), a school with no CDS code or a duplicated one, and schools that disagree
about which measures they carry.
Citing it. Cite the tarball's file name and its SHA-256, both of which the manifest
carries, together with the acquired file names, academic years and access dates it
copies from coverage.json. A figure cited from this dataset should be quoted with its
state: "23 students (reported)" and "withheld under CDE's small-cell rule
(suppressed)" are different claims, and "no figure was published" (not_reported) is
a third. Counts are what California published, not what is true of a school.
make dataset is not a CI job and cannot be one: data/raw/ is in .gitignore and
never reaches a runner, so the real dataset is built on the machine that acquired the
files. Uploading it to a release is a step the owner takes.
None of this touches site/. The dataset travels in a release, not on Pages, which is
why it can be built while the published tree is at 86.8% of its 1 GB ceiling.
The school-directory parser (the spine every other dataset joins against, via CDS
codes) is built, tested against a fixture, and verified against the live file
(acquired 2026-08-07): 18,396 directory rows parsed with no drift errors, yielding
10,534 active schools across 1,059 districts and all 58 counties, 1,238 of them
charters. Those 1,059 districts carry only 1,048 distinct names: ten names cover
two districts each, except "Jefferson Elementary", which covers three. Counting
districts by name loses eleven of them, which is why the CDS code is the only key
this project joins on. The 2025-26 Census Day enrollment file (269,090 rows, acquired the same
day) parses end to end and joins that spine, and make data now assembles one
profile per active school, with total, grade-span, and subgroup enrollment as
three-status measures, and emits deterministic JSON artifacts: 10,534 profiles,
byte-identical across re-runs, coverage published beside the data (9,860 school
totals joined, the 698-plus-674 join gap counted in both directions, masks kept
as nulls).
The first bilingual school pages are built (M4). One page per school per
language, rendered from those profiles: identity, total enrollment, TK-12 grade
spans, and 25 subgroup figures, each cell in exactly one of four states, with
coverage in the next three columns. Birch Lane Elementary in Davis Joint Unified
renders from the acquired files in English and Spanish, publishing 36 of its 40
figures (30 counts and 6 genuine zeros) and stating in words, for the other four,
that the state published nothing.
Every user-visible string exists in both languages: 228 keys per locale, zero
present in one and missing from the other, enforced by test. The renderer's pages
carry no script and no account, and reach nothing off this origin: since
2026-09-18 each school, county, district and landing page links a single
stylesheet the same build writes (homeroom.css, at the root of the site, under
that fixed name), and there is no font, no image, no CDN and no third party on any
of them. The ask pages are the exception in the other direction — they keep their
stylesheet inline, because ADR 0003 promises they fetch nothing at all until a
question is submitted. The published pages carry one script, the same-origin,
hash-pinned Google Analytics loader that homeroom.analytics adds after
rendering, and nothing else.
Chronic absenteeism (D3) is the first measure Homeroom publishes that CDE masks
at real scale, and it is now built end to end (M3). The 2024-25 file (341,490
rows, acquired 2026-08-21) parses, joins the spine, and renders on every school
page: a total rate plus race/ethnicity, gender, and student-group breakdowns, each
beside its district and statewide figure, read from CDE's own Charter School = All and DASS = All rows. Across the 10,534 active schools, the total rate is
published for 9,718 of them, withheld for 83, and not published for 733 that the
file never mentions; some subgroups are withheld far more often than that. The
most-withheld is Non-binary (GX), withheld at 1,990 of the 2,045 schools that
report it at all, 97.3%; the other 8,489 active schools carry no Non-binary row
for CDE to withhold, so that share describes a much smaller slice of the state
than the next one does. American Indian or Alaska Native (RI) is withheld at
9,350 of 9,801, 95.4%, and 9,801 is nearly every active school. Three categories
are withheld for over 94% of the schools that report them at all.
docs/SUPPRESSION-SHOWCASE.md carries the table and walks four real rows, one of
each cell state, from the source file to the rendered markup. Grade-span categories
are recognized so the file parses but are not rendered as a subgroup, the same
choice already made for D2's age ranges.
What a cell can say, and how the four states stay apart on the page:
| State | On the page | Never |
|---|---|---|
| Published figure | the number, as published | rounded, averaged, or derived |
| Published zero | 0, plus the words reported as zero |
confused with an empty cell |
Withheld (CDE's *) |
the words withheld to protect privacy, no digit | shown as 0, estimated, or recovered from its siblings |
| Nothing published | the words no figure published, no digit | shown as 0 or left blank |
Accessibility and translation are gated, not asserted. make verify builds the
pages from committed fixtures and runs html-validate and axe-core (WCAG 2.0/2.1/2.2
A and AA, plus best-practice) over every page in both languages, and re-checks
structure, EN/ES key parity, color contrast in both themes, and that every number
in a data cell is a number the pipeline counted. Re-verified with M3's four new
tables in the fixture build (2026-08-21): zero violations, same six rule sets.
What none of that can do is look at the pages: layout, reflow at small widths,
focus visibility in practice, and a screen-reader walkthrough in each language
need a person, and that walkthrough has not happened yet. M3 deliberately kept
its new tables at the same seven columns as the existing ones rather than adding
more while that gate is open; see docs/RESPONSIBLE-TECH-AUDITS.md §E and RR-05
in docs/audits/residual-risk-register.md. What a person is meant to do, page
type by page type and in both languages, is written down in
docs/accessibility-walkthrough.md, whose results table is the record of it and
is empty.
Teacher assignment monitoring (D5) is published on the school pages as of
2026-09-05, by the owner's decision recorded in
docs/adr/0005-publish-teacher-assignment-monitoring.md. CDE publishes these
files from the Commission on Teacher Credentialing's CalSAAS system: by school,
how much teaching FTE sat on a clear credential appropriately matched to the
assignment, and how much sat in one of the other authorization states the state
tracks. The 2023-24 file (234,206,408 bytes, 1,528,796 rows, acquired
2026-08-21) turned out to disagree with the parser's provisional contract in
every particular -- real column names, seven outcomes rather than five, FTE
fractions rather than integer counts, and up to 150 rows per school rather than
one -- and src/homeroom/assignments.py was rewritten to match what the file
actually contains (PROVENANCE.md D5 has the full list). Each page now carries
CDE's own whole-school row: the total teaching FTE, the seven outcome FTE
counts, and the seven outcome shares, every one a copied cell, beside the
district and statewide figures read from the file's own aggregate rows and with
coverage stated in the next three columns. Nothing is summed and no share is
divided out of a count, because a share Homeroom worked out would be a number
about a real school that nobody published -- and on a masked row it would print
a figure where the state withheld one. There is no headline "percent properly
credentialed": one number standing for seven is the compression ADR 0002
refuses, and it is the most sortable thing the file contains. From 2026-08-21 to
2026-09-05 this paragraph said the opposite, because it was true then: make site was not given the file, homeroom.site accepted no argument for it, and
whether to publish it was an open question tracked as issue #59. A build still
given no D5 file publishes nothing and says so in words, which is tested. The
remaining datasets (D4,
the state dashboard indicators, and D6, per-pupil spending) were surveyed on
2026-09-05: both exist and are readable, D6 at a different address than this
project had recorded, and neither is acquired or publishes anything. D4 turned
out to be nine files rather than one, sharing 22 columns and not a schema, in
which suppression is a blank cell and 0 means "No Color" rather than zero. D6
turned out not to be in the SACS files this repository had pointed at, which are
district-level; the school-level source is CDE's ESSA per-pupil expenditure
workbook, which publishes four components and no total, so the one number a
reader would expect does not exist in the file and could only be computed. Both
therefore wait on a decision rather than a download: whether an ordered
Dashboard band may be shown at all under ADR 0002, and what "per-pupil spending"
is allowed to mean when the state publishes no total. PROVENANCE.md D4 and D6
carry the measurements and docs/ROADMAP.md "M5 source survey" states the
decisions.
Live at https://homeroom.chelseakr.com since 2026-08-22, by the owner's decision. Whether these pages belonged on the internet was a separate question about real schools and real children, and no build made it; a person did.
What is published there is every active school in the directory file: 10,534 schools, 21,069 pages, English and Spanish for each. Until 2026-09-05 it was one -- Birch Lane Elementary in Davis Joint Unified -- and this section said so. The ratio it described, one school out of the 10,534 the pipeline profiles, is no longer the shape of the site.
The ask layer did not widen with the pages. It is published for the schools named
in ASK_SCHOOLS, which is still Birch Lane in both languages. Widening the pages
was a decision about which schools to put in front of families. Widening the ask
layer is a different decision, about an approved spend envelope: the service
calls a paid model per question, a CloudWatch alarm watches daily invocations
against that envelope, and it is the one surface here that can be asked something
nobody reviewed. That decision has not been made. The two halves cannot drift
apart quietly, because a school page carrying an ask link with nothing behind it
fails test_no_published_link_points_at_a_page_that_was_not_published.
The landing page does not print any ratio; what it says, in both languages, is that Homeroom is in development, that the schools listed are the ones published so far, and that more are added as each source is acquired and checked. This paragraph said "the landing page says so" until 2026-08-29, which read as a claim that the 10,534 figure is on the page. It is not, and the page does not imply the state is covered either.
The site is rendered here and committed to site/, because it cannot be built
in CI: the acquired CDE files never enter git and nothing in CI fetches them.
The workflow in .github/workflows/pages.yml publishes that directory and
builds nothing, so the bytes reviewed in a pull request are the bytes served,
and tests/test_published_site.py gates them in make verify on a machine with
no acquired file present.
Publishing every school put a number on that trade, and then the number started
making decisions. site/ is 867.6 MB across 23,310 files, against the 1 GB
GitHub Pages allows a published site — 86.8% of the cap, from 212 KB the same
morning — and it packs to about 46 MB in git because the pages differ from each
other in so few bytes. This paragraph said 836 MB across 21,076 files until
2026-09-05, which was the tree before the same day's 2,234 county and district
pages went on top of it.
What the cap is now deciding, measured rather than estimated. Publishing D5 on
the school pages, decided in ADR 0005 and not yet republished, is 8,723 bytes a
page and so 184 MB more, which is past the ceiling on its own
(#82). Ask pages for all
10,534 schools rather than two would add 303 MB and would not fit either. D4 and
D6 will add measures to every one of the 23,310 pages. And the one large saving
on the ask layer is not available: lifting the ask page's inline CSS and script
into shared files is 10,993 of its 14,206 bytes, about 216 MB across the
projected layer, and those bytes being inline is exactly what makes
tools/ask-optin.mjs's zero-requests-on-load assertion true. So the scope of
the ask layer is bounded twice over, by the spend envelope first and by this
limit second, and make publish prints both counts when it finishes.
That argument is about the ask pages and does not reach the other four page
kinds, which carry no script, made no request before this, and have nothing
whose timing an inline stylesheet protects. Taking the stylesheet off those —
issue #95, the renderer
change landed 2026-09-07 — is 117.1 MB the next make publish will not
write: 118,190,092 bytes of <style> across 23,305 files, less 5,341 bytes of
homeroom.css and 44 bytes of <link> a page. Stated with its own arithmetic
rather than as a rescue, because it does not rescue the budget: it takes the
tree to 750.5 MB (75.0% of the cap), and a D5 republish on top of that is
934.3 MB — 93.4% of the ceiling, and over the 900 MB budget
tests/test_published_limits.py holds. What it buys is the difference
between a publish that GitHub would refuse outright at 105% and one that is
inside the host's limit and over this project's own, which is a decision that
can be made rather than an artifact that cannot be uploaded. The saving is
counted here and not yet in site/: republishing is the owner's step, and
until she takes it the served tree is the 867.6 MB tree above.
The owner's answer to the second bound is to move hosts rather than keep shaving
bytes. deploy/site/ holds the shape that would replace GitHub Pages — a
private S3 bucket behind CloudFront with Origin Access Control, published by
.github/workflows/site-publish.yml over GitHub OIDC with no stored key — with
what it costs at this size (about two cents a month) and what the new ceilings
are (no total-size limit at all) both measured. Nothing there has been
applied. No stack exists, no byte has been uploaded, homeroom.chelseakr.com
still resolves to chelseakr.github.io, and .github/workflows/pages.yml is
untouched and still the deploy families receive. The cutover and the rollback
are the owner's to make, and deploy/site/README.md carries both in order,
with what to check after each step.
That limit is written down for a build to read, in
src/homeroom/publish_limits.py: the published tree against the Pages ceiling,
sitemap.xml against the sitemap protocol's 50,000 URLs and 50 MB, and any
single file against the 100 MB Pages will serve. Each fails at 90% of its limit
rather than at it, so a tree that trips one is still a tree Pages will serve
while it is sorted out — at 86.8% of the ceiling, the site has about 32 MB of
that budget left, which is less than one new section on every page, as the D5
figure above shows. tests/test_published_limits.py holds the committed tree to
those numbers, and to one more the sentinel sets: no published file may be
larger than half of what tools/verify_live_site.py can fetch, because past
that bound the daily check of the deployment stops comparing anything at all.
make publish reads the same numbers, one step earlier. It used to open
with rm -rf site and then spend about a quarter of an hour rendering the
replacement, which put the irreversible step first: a publish that could not
deploy destroyed the working copy of a site that was being served, printed
"commit it to deploy", and left the size to a later make verify — or to GitHub
refusing the artifact while every check here stayed green. It now renders into
build/publish-site/, weighs that, and moves it over site/ only if it passes.
A refusal names the ceiling, the budget, the measurement and where the bytes
are, points at the decision in
#82, takes none of it, and
leaves site/ exactly as it was.
tests/test_published_site.py also walks the published tree the way a family
does, index to county to district to school in both languages, because a school
that is published and unreachable is not published.
As of 2026-08-21, by the owner's direction, Homeroom has an optional
question-answering layer so a family can ask, in English or Spanish, what a
school's page is saying: "Is chronic absenteeism a problem here?", "How many
students are English learners?", "What does 'chronic absenteeism' mean and how
is it measured?". The design, recorded in docs/adr/0003-grounded-ai-at-the-edges.md,
keeps the founding rule intact:
- It still refuses to rank. The service sees one school per request, so it cannot compare schools it cannot see; "is this a good school", "give it a grade", "which is better" get a fixed, reviewed refusal in both languages that the model does not write, and a second guard withholds any sentence the model did write that carries better/worse, grade, score, rank, or recommendation language. An adversarial evaluation suite targets zero.
- The published dataset is the only evidence. Every claim cites a record (school CDS, measure, academic year) or a passage from a committed corpus of CDE's own definitions, and a verifier checks every number, every withheld cell, every comparison direction, and every quote against the data before anything is shown. A claim that cannot be verified is withheld and counted.
- A withheld cell is never a zero, in prose any more than in a table.
- The school pages do not change. They stay static and script-free and make no off-origin request. The opt-in is a link to a separate ask page for that school, which makes no request until a question is submitted, and a build not given a service endpoint renders neither.
- Everything the model says is labeled AI-generated, unofficial, not a ranking, and not a recommendation. Spanish narration is labeled AI-translated and unreviewed.
- It is deployed, as of 2026-08-22, by the owner's decision. The
cost-bounded shape in
deploy/ask/is applied: one Lambda behind a Function URL inus-west-2, CORS and a server-side origin check locked tohttps://homeroom.chelseakr.com, reserved concurrency 2, a daily cap of 400 model calls per warm container, six requests per client per minute, and a CloudWatch alarm at 400 daily invocations to an SNS topic in the same stack. The cap is per container, not a shared ledger (deploy/ask/template.yamlDailyCap), so with reserved concurrency 2 the worst case is two containers each spending it; reserved concurrency is the bound that actually holds, and RR-09 carries this as an open item. The deployed model is Bedrockglobal.anthropic.claude-sonnet-4-6-- the model the recorded evaluations name and the only Claude this account can invoke.make sitestill renders no ask page and no link until it is given an endpoint, so a build without one is byte-identical to a build from before ADR 0003. Rollback is indeploy/ask/README.md: delete the stack, rebuild the site without an endpoint.
Provider: the public anthropic SDK, default model claude-sonnet-5,
configurable, credentials from the environment only. The evaluation harness
and its five suites (ranking refusal, suppression fidelity, citation grounding,
comparability, question structuring) live in evals/, and results are
committed only from a recorded live run that names the model. The harness exits
nonzero when a suite falls short of its target, and CI holds every committed
results file to the same check, so a regression cannot be committed green
(ADR 0004).
Recorded run (2026-08-22, evals/results/): 157 cases over real schools from
the acquired files, on Amazon Bedrock global.anthropic.claude-sonnet-4-6
(the model this account could invoke; the code default, Sonnet 5, was not
available to it, and the results say which model they are about). Ranking
refusal 62/62, suppression fidelity 24/24, citation grounding 24/24,
comparability 19/19, structuring 28/28. Across the run the verifier showed
511 sentences and withheld 23 before display: 11 quotes that were not
verbatim CDE text, 5 numbers no cited cell publishes, 3 sentences carrying
judgment language, 2 comparisons of the wrong shape, 2 definitions without a
quote. Those 23 are the product working, not the model failing quietly: each
one would otherwise have reached a family. What no suite measures is whether
the Spanish reads well or whether a person, reading a sample of real answers,
finds them honest; both are open (RR-07, RR-08).
Built AI-assisted (Claude Code), with every claim, parser, and number verified against acquired source files and enforced by the test suite. The honesty rules above bind the tooling as much as the author: nothing ships that the data does not support.
Governed by portfolio-standards (private).
| Standard | State |
|---|---|
| Responsible-Tech Framework | Applies (see docs/RESPONSIBLE-TECH-AUDITS.md) |
| Code Quality | Applies |
| Security & Supply-Chain | Applies. make verify runs semgrep over this project's own source, tests/ included (semgrep's built-in ignore list drops tests/; the committed .semgrepignore replaces that list and does not), zizmor over the workflows under a hash-pin policy, pip-audit, npm audit, and a secret scan covering git history and the working tree, because history mode alone is blind to an uncommitted key. .semgrepignore excludes vendored, generated and built output, site/ among it, so the bytes actually served are gated by tests/test_published_site.py rather than by semgrep; this row said "the whole tree" until 2026-08-29. Every step in .github/workflows/ci.yml is accounted for by tests/test_ci_parity.py: each run: step calls a make target that make verify reaches, and each uses: step is either a setup or reporting action or a gating action registered against the target that reproduces it locally, which is what used to cover the secret-scan job, whose only step was an action. So the local gate is a strict superset of CI and the two cannot drift; the sentence said "every step ... is a make target", which was true only of the run: steps. CI runs make verify-ci and make secret-scan-history, which is all of it except the working-tree secret pass: in CI the working tree is the committed tree, so that pass has no uncommitted file to find. The secret-scan job was gitleaks/gitleaks-action until 2026-09-13, and this row's "secret scan covering git history" held only of make verify: the action scopes itself to the commits the triggering event names, which on the single-commit push that every squash merge into main is meant 1 of 138 commits, with fetch-depth: 0 set the whole time -- checkout depth decides what is on disk, not what gets read. The job runs the same make secret-scan-history walk now |
| CI/CD | Applies |
| Observability | Applies (Tier C, library/CLI; declared in docs/ROADMAP.md) |
| Accessibility | Applies, in two halves, one of them open. Automated: html-validate and axe-core over every built page in both languages, plus structure, EN/ES parity and contrast checks in make verify. Review, not yet done: the keyboard-only and screen-reader walkthrough in each language, and the reflow check at 320 CSS pixels on the seven-column tables. A headless DOM cannot decide any of it. The procedure for that walkthrough and its record are docs/accessibility-walkthrough.md — all five page types, both languages, every cell reading UNMET with no date and no name, because nobody has walked one. Tracked as issue #6 and RR-05; owner Chelsea Kelly-Reif; held to its record by tests/test_accessibility_review.py. The pages are live, so this is open work rather than work waiting on a publication decision |
| Internationalization | Applies (EN/ES is a launch requirement; the parity gate has been wired into make verify since ROADMAP M4 and fails the run, but main requires no status check, so CI reports it rather than blocking a merge on it — measured 2026-09-13, see RR-12) |
| AI Evaluation | Applies as of ADR 0003 (2026-08-21): a prompt, a retrieval corpus, and a model-version surface now exist in src/homeroom/ask/. Five evaluation suites and their harness live in evals/; results carry provider, model, prompt version, commit, and date, and a test rejects results without them. See docs/RESPONSIBLE-TECH-AUDITS.md AI-EVAL and Governance |
| Documentation | Applies |
| Quality & Metrics | Applies (see docs/ROADMAP.md metrics ledger) |
| Performance | Applies: the school pages are pre-rendered static HTML built from locally acquired files, with no client-side script and no network call at build time, and the pipeline is deterministic: re-running make data produces byte-identical artifacts. The optional ask service (ADR 0003) is a hosted route as of 2026-08-22; a measured answer takes about 5 s end to end against Bedrock claude-sonnet-4-6, and no latency objective is declared for it yet. The static pages declare none and need none: they are files. No page-weight or build-time budget is asserted in CI yet |
| AI Development Measurement | Applies: this project is built AI-assisted and says so below. The outcome side is the metrics ledger in docs/ROADMAP.md, where every gate names its measurement and its AUTO/REVIEW disposition, and every day-one value was measured against a named acquired file rather than estimated. The diagnostic counters the standard names (sessions, tokens, share of generated code, acceptance rate) are not instrumented here, and by the standard's own rule they would be observe-only if they were: they never gate a merge |
| Incident Response | Applies: SECURITY.md routes reports through GitHub private vulnerability reporting with a 72-hour acknowledgment target. The static site and the ask service are deployed as of 2026-08-22 and there is still no account; the ask service (ADR 0003) stores no question and keeps no user data, so the incidents this project can actually have are a wrong or mis-sourced figure on a school page and a model sentence that reached a reader unverified. The first is why masked cells are type-enforced to raise on read, why a number on a page that nothing counted fails the build, and why coverage is published beside the data; the second is why every AI claim passes a verifier and the withheld count is shown. A severity ladder and a committed postmortem template are not yet in the repository |
| Data Governance | Applies: PROVENANCE.md is the register: every source is a named California Department of Education public file with its acquisition method, access date, and status, and a source that has not been acquired publishes nothing and says so in coverage.json. CDE small-cell masking is preserved as null, never zero and never interpolated; the CDS code is the only join key; no third-party or commercial data enters the pipeline. The artifacts are school-level public aggregates, not personal data, and the site has no account; page views are counted by Google Analytics 4 (owner decision, 2026-09-17; subprocessor record in docs/RESPONSIBLE-TECH-AUDITS.md), with GPC, Do Not Track and an on-page opt-out honored. Raw source files are never committed and CI never fetches them. The ask service (ADR 0003) sends a reader's question and one school's published records to the model provider for the duration of the request and stores neither; the subprocessor record is in docs/RESPONSIBLE-TECH-AUDITS.md under Privacy, owner-approved 2026-08-22 |
| Release & Versioning | Applies |
Apache 2.0. Source data is California open data; per-source terms in PROVENANCE.md.