Skip to content
 
 

Repository files navigation

ID Churn Sentinel

Cited, machine-checkable change detection for US transgender identity-document law and process. It watches registry-claimed government-source candidates — state vital records, DMVs, courts, State Department, SSA, the Federal Register — on a polite weekly cadence. For HTML and plain text, it hashes normalized text and produces the passage that changed; for PDFs and other binary sources, the current alpha reports only that the bytes changed. A named human reviews every detected change before it is published. The tool reports an observation about a URL; it never asserts what the law is or that an unverified URL is authoritative.

Status: Technical alpha (M0 shipped; first real baseline 2026-07-13: 152 registered sources, 52 of 52 jurisdictions, zero false drift on a second consecutive pass) · Track: Civic / trans infrastructure · License: AGPL-3.0-or-later · Runtime deps: zero (stdlib only)

This is a noncommercial, open-source, public-interest research project. It offers no paid services and has no customers, sponsors, or revenue.

The V1.0 plan

docs/00-V1-PLAN.md indexes the V1.0 plan, written July 13, 2026, as a forward-looking open-source plan.

V1.0 means: every active source is human-verified; high-impact publication has independent review; the feed contract and correction path are stable; eight weekly watch→review→publish cycles satisfy the pre-release evidence rule; accessibility evidence is complete; and every must-pass gate in docs/15-V1-RELEASE-CHECKLIST.md has an owner and dated receipt. V1.0 does not mean the service interprets law, gives advice, or guarantees it observed every policy change.

Read this before you rely on anything here

The source registry is not human-verified. 0 of 156 sources are human-verified, and the published site says so next to every single entry.

Current alpha limits: PDF and other binary changes do not yet have extracted-text or passage diffs; the SQLite store keeps only the newest five snapshots per source and prunes older ones; and neither durable long-term reproducibility nor full WCAG 2.2 AA conformance is a current claim. Both are V1 release gates. The canonical source-eligibility evaluator is now enforced by both the watcher and publisher. Each watch persists the exact eligible, attempted, successful, unmeasured, and observed source/change sets plus its registry revision; aggregate status.json and the site distinguish quiet, partial, failed, running, and stale health from page generation time. A source whose fetch succeeds and yields no readable text (a client-rendered shell, an empty 200, a bot-wall) is never baselined, never compared, and never counted as unchanged — it is named on every run it recurs, and a run holding one is partial, not quiet. Operational watch/publish commands always evaluate eligibility on today's UTC date—there is no backdating flag—and a jurisdiction-only run cannot make aggregate health current. Because the committed registry still needs real human verification evidence/expiry and dated robots/terms/fetch-policy decisions, its honest attempt denominator is currently zero and sentinel watch fails closed. No decision is inferred to make a dashboard look green.

Every URL in the registry was fetched by this tool's own crawler and had its title read. That is a machine fact about a socket, not a person confirming the page is the right page, and the difference is the entire reason this section is at the top rather than in an appendix. A reader who sees a table row saying "OH · Birth certificate · Ohio Department of Health · " will reasonably read it as "this is Ohio's official birth-certificate page" — and nobody has checked that it is. If it is wrong, that implicit claim sends a trans person to the wrong office on a day they took off work.

Machine-checking cannot close that gap, and this repo has the receipts: courts.oregon.gov serves a soft 404 — HTTP 200 with a body titled "404 Page Not Found", and ecfr.gov answers our crawler with a bot-wall titled "Request Access", also at HTTP 200. A status code blesses both. A title check blesses the second. Only a person opening the page catches either.

So, plainly:

What this tool claims The fetched content at this URL changed · for text/HTML, this is the passage that changed · here are the before/after hashes; the supporting bytes remain reproducible only while their snapshots are among the newest five in the current alpha
What it never claims What the law is · what a change means legally · that an unverified URL is the right page
What a consumer may rely on The machine observation, named change-review receipt, explicit source-verification status, and honesty of the gaps list—not source authority unless source_verification.status is verified and in date
What a consumer may NOT rely on The registry as a directory of official pages. It is a list of candidates. Every artifact carries a machine-readable verification_status per source; today every one of them reads unverified

The fix is not a disclaimer, it is the work: sentinel verify is the review aid that makes it cheap — it fetches each source, shows a human the page's own title and an excerpt of its text, asks one question, and records the answer with the verifier's name and the date. It refuses to record a verification without a name, and the registry will not even load a verified: true entry that has no human behind it. See docs/VERIFYING.md — it is about three and a half hours of work for all 152, and it is the most valuable three and a half hours anyone could spend on this repo. Verification is one of two decisions a source needs before it is ever fetched. The other is a named human's dated reading of the host's robots.txt and terms, recorded with sentinel sources policy; a source with only one of the two stays out of the attempt denominator, and both commands say which is missing rather than letting an afternoon's work end in an empty feed.

Quickstart

make install                       # uv sync (Python 3.12+; zero runtime deps)
make verify                        # the full 7-gate merge pipeline
uv run sentinel sources validate   # the registry gate
uv run sentinel coverage           # the coverage numbers + the verification burn-down, DERIVED
uv run sentinel coverage --check-docs  # …and the gate that fails if a doc disagrees
uv run sentinel sources check      # live-fetch every URL (network) — liveness only
uv run sentinel sources check --twice  # find false-drift sources BEFORE they reach a reviewer
uv run sentinel verify --verifier "Your Name" --federal-first   # THE VERIFICATION QUEUE (network)
uv run sentinel verify --list      # what is still unverified. No network, no writes.
uv run sentinel baseline check     # what moved since the committed baseline (needs no store)
uv run sentinel watch              # fetch, normalize, hash, diff, record drift
uv run sentinel diff <change-id>   # the changed passages
uv run sentinel review --list      # what is still unreviewed. No network, no writes.
uv run sentinel review <change-id> --reviewer "Your Name" --significance substantive --status confirmed
uv run sentinel approve <change-id> --reviewer "Independent Name" --status confirmed \
  --qualification-ref governance/qualification.json --conflict-attestation-ref governance/conflict.json
uv run sentinel correct <change-id> --replacement-id <change-id> --actor "Your Name" --reason review_error
uv run sentinel publish --out docs/    # site + RSS + JSON + per-jurisdiction feeds + inventory
make serve                            # serve the site the way Pages does — under a SUBPATH

Two different humans, two different commands, and they are not interchangeable. verify is a judgment about a source ("this URL is the official page for this document class in this jurisdiction"). review is a judgment about a change ("this diff matters"). Both refuse to run without a name. Neither can be done by a machine, and nothing in this codebase tries.

The result that matters

A sentinel that cries wolf gets muted, and a muted sentinel is worse than none — so the first thing worth reporting is not the coverage number, it is what happened when the tool was pointed at the real internet twice in a row.

Run 1 baselined 125 reachable government-hosted candidates. Run 2, minutes later, reported 123 unchanged and 2 changed. Both "changes" were false:

Source What "changed"
dpbh.nv.gov (NV birth certificates) a rotating "Nevada state symbol" trivia block in the footer: state fish → state reptile. It re-rolls on every single request — three back-to-back fetches gave three different hashes.
azdot.gov/mvd (AZ driver's license) a randomly-sampled "frequently viewed links" widget: rest area rulespenalties.

Neither is markup churn, so the normalizer could not have caught them — that is real, visible page text. And "normalize harder" is the wrong instinct: a normalizer that guesses which visible text doesn't count is one that can hide a real change, which is the one failure this repo will not trade for tidiness.

So: Nevada's page was removed from the registry (the widget is site-wide across the nv.gov CMS — there is no stable Nevada vital-records page to substitute) and recorded as a named GAP. Arizona was swapped for a deeper page carrying the same content and no widget. Then the tool got a new command — sentinel sources check --twice — which fetches every source twice and names anything that hashes differently. Run across the whole registry it caught a third: nebraskajudicial.gov renders its "recently adopted rules" list in non-deterministic order, so the same rules come back shuffled. Swapped for the stable self-help page.

Re-baselined and re-run: 125 unchanged, 0 changed, 0 false positives. The three sources that would have alerted every week forever are gone, and they are the finding — a monitor that had shipped without this pass would have been ignored inside a month.

And --twice is not enough, which is the second finding. It catches a widget that re-rolls on every request. It cannot catch a page that rotates on a slower cycle — and three candidate sources fetched cleanly, hashed identically twice in a row, and would have drifted forever anyway:

Candidate What it renders into the page
leg.state.fl.us (FL name-change statute) today's date. A change record every day, whose diff is the date.
legislature.mi.gov (MI statutes, HTML view) a live session ticker"Senate adjourned until Wednesday, July 15, 2026 10:00 AM".
ecfr.gov (the federal SSA regulations) a bot-wall page titled "Request Access" — served with HTTP 200.

The last one is the nastiest thing in this repo. A status-code check blesses it, it hashes perfectly stably, and we would have watched a captcha for years and called it Social Security policy. None of the three is in the registry: they were caught by reading the normalized text of every candidate before adding it, which is now part of adding one.

One narrower version of that trap --twice used to walk straight into, and no longer does. A page with no extractable text at all — a JS shell, an empty 200 — normalizes to zero passages, and the hash of nothing matches itself perfectly on two back-to-back fetches. So --twice counted every blind page as stable, and printed nothing for it, because only UNSTABLE and unreach get a line. On the check that gates adding a source, that read as safe to watch about precisely the pages sentinel watch can never observe. Those sources now land in their own bucket, are never called stable, and are named on stdout. Note what this does not fix, because it is the more dangerous half: ecfr.gov serves a bot-wall with real, readable text in it, so it has passages, it is genuinely stable, and only a person opening the page catches it. Reading the text is still part of adding a source.

Closing the map without lying to get there

Michigan and New Hampshire used to be absent entirely: michigan.gov and every nh.gov host serve a browser and return 403 to our descriptive User-Agent, and courts.michigan.gov normalizes to zero passages. There is a two-line change that "fixes" that — send a Chrome User-Agent — and a tool that lies about who it is, to a government server, on behalf of a population under surveillance, has not earned the trust it is asking for. So the gap stood.

The honest fix is that a state often publishes related policy content on a second government-hosted surface, and that surface may answer us. These substitutes remain registry claims—not authoritative citations—until a named human verifies them:

Was Now watched instead
michigan.gov (403) · courts.michigan.gov (SPA, 0 passages) the Michigan Compiled Laws section governing a new birth certificate "to show a sex designation other than that designated at birth" (MCL 333.2831), the licence-application statute, and the SCAO's PC 51 name-change petition — the form a Michigan name change actually runs on
every nh.gov host (403) RSA 5-C:87 (amending a birth record), Saf-C 1000 (the DMV's own licensing rules), RSA 547:3-i (probate-court name change)
odh.ohio.gov (404s its own site root — a WAF wearing a 404's clothes) OAC 3701-5, the vital-statistics rules ODH itself administers
sccourts.org (robots.txt disallows us, site-wide) the S.C. Code name-change chapter, whose robots.txt permits us
dmv.nv.gov (JavaScript shell) · dpbh.nv.gov (rotating state-fish widget — removed in the pass above) NAC 483 and NRS 440 on the Legislature's site, which carries no widget
dps.ms.gov (unverifiable TLS chain) the Driver Service Bureau host the registry claims as the same authority, with a TLS chain our fetcher verifies

Sixteen jurisdictions were closed this way, and the two absent ones are gone: MI, NH, DE, HI, ID, KS, LA, MN, MS, MT, NC, NV, OH, RI, SC, TX. Coverage is now 52 of 52 jurisdictions.

Note precisely what these entries claim, because it is less than it looks: a statute page is the law an agency administers, not the agency's own process page. Every one of them says so in its notes. Watching Texas's Family Code Chapter 45 does not mean we watch a Texas county's filing process — Texas publishes no statewide name-change instructions at all, and we still say so.

And what did not get fixed on the first pass is the more interesting half. Of the original 12 gaps this pass named, most stayed named rather than closed because the only ways to close them are the ways we will not use — hosts that 403 our UA with no working statutory alternative, TLS chains that do not verify anywhere on the same domain, robots.txt refusals with no unblocked equivalent, JavaScript shells with nothing to hash. Colorado's statutes are published, but only as year-stamped PDFs at a frozen URL: a source that can never drift is worse than no source, because it is a wrong "no change" with a green light on it.

A second pass, 2026-08-21, closed five more the same honest way — a different government host on the same domain, never a guessed replacement authority — and found one new one. AK, AR, DC, LA and SD driver's-license/name-change gaps had each been checked against an "obvious" statutory alternative already, and rejected for a real reason (a fragment link that can't scope a fetch, a broken TLS chain, a client-rendered shell); this pass found what the first one missed: Alaska's statute site takes real server-side scoping query parameters, not just fragments; Arkansas's DMV-adjacent host's TLS chain has since been repaired; South Dakota's statute page is itself a JS shell, but the server-rendered API endpoint it calls client-side is real, static HTML on the same official host. Full detail in each entry's own notes. The same pass found a new hole: courts.michigan.gov tightened its robots.txt to a site-wide Disallow: /, so the SCAO name-change petition form watched from that host is now a named gap (robots-disallowed) rather than a source — we honour a robots.txt refusal without appeal, and did not spend effort routing around it.

8 named gaps remain, each one a (jurisdiction, document class) pair we do not watch, with the host that refused us and the reason: blocked-403 (3), robots-disallowed (3), tls-unverifiable (2).

Why it matters

Three organizations already document how to change your name and gender marker on an ID in the United States. All three cover the ground. All three say, in their own words, that they cannot keep up with how fast it moves.

  • Advocates for Trans Equality (A4TE) — the ID Documents Center covers all 50 states, DC, 5 territories, and 5 federal document classes. It also says, verbatim: "Due to the ever-changing nature of state laws and policies, we are working to keep the ID Documents Center as up to date as possible. If you see something that needs updating, please contact us." Their freshness mechanism is a contact form.
  • Trans Lifeline — the ID Change Library has been maintained by volunteers since 2016. It is self-acknowledged incomplete (entries are literally flagged "Help Us Find It"), publishes no API or export, and carries no last-updated dates at all.
  • Namesake (namesake.fyi) — genuinely well-engineered and open-source. Its repository already documents a daily upstream-PDF monitor: PDFs with a canonicalUrl are fetched, their extracted text is compared with the local copy, changed lines are diffed, and a scheduled workflow opens or updates an issue. That is a material substitute for the PDF-monitoring slice of this product, not an organization waiting for someone else to invent monitoring.

Coverage is not the gap. Freshness is. Namesake proves that upstream PDF freshness monitoring already exists in this space. What this project explores is the narrower combined contract: a multi-jurisdiction registry with dated human source verification, shared fetch/publication eligibility, text evidence across heterogeneous government surfaces, named gaps and run health, independent review/correction, and a public no-reader-tracking feed. The dated scan (2026-07-13) found no purpose-built offering publicly combining all of those elements — an observation about what was publicly findable, not proof that nothing similar exists.

This repository is a noncommercial technical exploration of that combined layer. Its public artifacts are designed for possible institutional downstream use rather than individual guidance. A4TE, Trans Lifeline, Namesake, and legal-aid organizations illustrate the domain and possible technical use cases; none is affiliated with, endorses, or uses this project. See docs/CONSUMERS.md.

Why this is worth building carefully: a wrong "no change" is a safety failure. Someone reads guidance that a monitor silently failed to flag as stale, drives to a DMV with the wrong documents, and loses a day of work, a filing fee, or — in the wrong state on the wrong day — considerably more. That asymmetry drives every design decision below.

What it does

  • Watches government-source candidates with explicit verification state. A committed registry (sources/registry.json) of https government URLs, keyed by jurisdiction (50 states + DC + a US federal bucket) and document class (birth certificate, driver's license, court-order name change, passport, Social Security, Selective Service). Each entry names the authority the registry claims and ships verified: false — the registry is seeded, and only a human who has actually opened the URL may flip that flag. Nothing in the codebase decides it, and the registry will not load an entry claiming verified: true without a named verifier and a date attached — so the flag cannot be flipped by a hurried maintainer, a sed, or an AI agent asked to make the file look finished. sentinel verify is the one writer, and it refuses to record a verification without a name.
  • Says "unverified" out loud, on every source, in every artifact. The published site marks each source UNVERIFIED — machine-checked, not human-confirmed as a word rather than relying on colour or an icon; sources.json, changes.json and every per-jurisdiction feed carry a machine-readable verification_status on every source and on every change record's source; the RSS channel states the count and each item carries the status as a <category>. A merge-blocking gate asserts on the published bytes that no source appears anywhere without it. This status labelling is tested; full WCAG 2.2 AA audit and remediation remain a V1 gate.
  • Refuses to watch a page it cannot watch honestly. Some government-hosted pages re-roll a rotating widget on every single request — dpbh.nv.gov renders a "Nevada state symbol" trivia block (state fish → state reptile) into its footer, and hashes differently every time it is asked. A page like that would report a change every week, forever, and its diff would be a fact about the desert tortoise. The normalizer cannot save us: that rotating text is real, visible page text, and a normalizer that guesses which visible text "doesn't count" is a normalizer that can hide a real change. So the answer is not to normalize harder — it is to not watch the page, to say so in the registry's GAP list, and to ship the diagnostic that finds them: sentinel sources check --twice.
  • Tells you what changed for text/HTML, and labels the binary limitation. On text or HTML drift it computes a unified diff of the normalized text and hands the reviewer the changed passages. For a PDF or other binary source, the alpha reports only that its bytes changed and directs the reviewer to compare the retained snapshot; extracted-text passage diffs are a V1 gate.
  • Ignores markup churn. Government pages churn a rotated CSRF token, a re-minified stylesheet, and an &nbsp; far more often than they churn text. Normalization strips script/style/comments/tags and resolves entities before hashing, so a cosmetic re-deploy does not wake anyone up. (A watcher that cries wolf gets muted, and a muted watcher is worse than none.)
  • Treats an outage as an outage — but does not treat a disappearance as an outage. A fetch failure is never drift. A 503, a WAF block, a timeout: the previous hash is held, no snapshot is written, no content change is recorded. A state's website falling over is not a state changing its policy. But a page that has been taken down used to look exactly like a brief outage — forever — and the tool answered that silence with silence. It now counts consecutive failures per source, and once a source has both failed a threshold number of times and been silent for a minimum stretch of real time, escalates to a distinct possibly_removed record that a human must review. A government page about trans identity documents vanishing is itself a policy signal; it is never auto-classified as one. Both conditions, because a count of failed attempts is not a length of time and the rule used to assume it was — docs/THRESHOLD-EVIDENCE.md documents the case that proved it, and is candid that the threshold itself is still an unmeasured guess and why.
  • Keeps a bounded, versioned recent evidence window. The current SQLite snapshot store retains the newest five fetches per source — raw bytes, normalized text, sha256, timestamp, HTTP status, and the exact normalizer/extractor contract versions — and prunes older snapshots. Successful watch-attempt receipts carry the same versions; migrated historical snapshots are labeled legacy-unknown rather than assigned invented provenance. That supports immediate review and recent-diff reproduction, not a months-long archive. Release-manifest propagation, pinning published evidence, and proving long-term reproduction are V1 release gates.
  • Commits the baseline, so a clean clone has a memory without bypassing source policy. The snapshot store is not committed (it is megabytes of government HTML and grows weekly), which used to mean a fresh checkout knew nothing. sources/baseline-hashes.json retains 125 historical hashes, but sentinel baseline check now fetches only sources that pass the same dated verification and fetch-policy predicate as sentinel watch. From a fresh clone it can answer "which eligible page is not what it was?" without a store. Today that means zero network requests because the honest attempt denominator is zero. It holds the prior hash, not the text, so after sources become eligible it can say that a page moved but not what moved; sentinel watch retains the evidence needed to show the passage.
  • Requires independent review for high-impact observations. Every detected change is born unclassified / unreviewed. A person classifies it (editorial | substantive) and confirms or dismisses it. A substantive confirmation remains unpublishable until a distinct reviewer records a qualified, conflict-attested independent confirmation; returned decisions are preserved and terminal for that immutable observation in the current foundation.
  • Keeps corrections visible and rationale private. Review and correction facts append and cannot be updated or deleted. Corrections require a separately publishable replacement and an acyclic successor link; withdrawals never erase the original. Free-form reviewer and registry notes stay private. Public JSON/RSS/HTML carries only bounded observation copy, controlled lifecycle reasons, and the names/times needed to understand the decision trail.
  • Publishes something an incumbent can inspect, with no account. A static site (docs/index.html), RSS (feed.xml), a versioned JSON feed against a published schema (changes.json), a versioned inventory of every registered candidate, its exact attempt eligibility, and every named gap (sources.json), and one feed per jurisdiction (feed-us-tx.xml, changes-us-tx.json). The public surface is fetchable now, but it explicitly identifies itself as a technical alpha rather than an operating monitor while the attempt denominator remains zero. No auth, email capture, tracking, or third-party request appears in the published bytes.
  • Cannot lie about its own coverage. Every number in this README — sources, jurisdictions, gaps, unreachable — is derived from the registry by sentinel coverage, and sentinel coverage --check-docs fails the build if any doc disagrees, or if a jurisdiction/document-class pair is neither watched nor a named gap. A project whose pitch is "we tell you what went stale" cannot have a stale front page. It found two silent holes on the day it was written (see below).

Design lineage

This project builds on an earlier five-jurisdiction content-hash watcher. It preserves two useful disciplines — normalize visible text before hashing, and never treat a fetch failure as drift — while providing a self-contained public implementation that covers more jurisdictions, shows what changed, and publishes reviewed artifacts.

Consuming it

No account, no key, no email, nothing to switch on. The published bytes are committed, so raw.githubusercontent.com serves every artifact straight off main — that is a legitimate consumption path, not a workaround, and it works today:

BASE=https://raw.githubusercontent.com/ChelseaKR/id-churn-sentinel/main/docs

curl -s "$BASE/changes.json"       | jq '.changes[] | select(.significance=="substantive")'
curl -s "$BASE/changes-us-tx.json" | jq '.changes[]'   # just Texas — not all 52
curl -s "$BASE/feed-us-tx.xml"                         # …or the same, as RSS, in Slack
curl -s "$BASE/sources.json"       | jq '.gaps[]'      # what we do NOT watch, and why

The same committed bytes are also served from main / docs at https://chelseakr.github.io/id-churn-sentinel/, which is the canonical URL stamped into the JSON and RSS artifacts. The raw GitHub URL remains a valid mirror with identical endpoint paths.

Why it is served from a branch and not from CI. This account has an account-wide GitHub Actions spending limit, so an Actions-driven Pages deploy would never run — the workflow that used to do it has been deleted rather than left in the repo pretending. Branch-based Pages serves exactly two source paths, / or /docs, which is why the published site lives in docs/ alongside the prose docs. A feed that only exists once somebody else's billing system agrees to run a job is a feed that does not exist.

The full integrator guide — the field meanings, the review states, the versioning promise, and what this tool will never tell you — is docs/CONSUMERS.md. The layout of the published directory is docs/README.md.

The weekly run. make watch-weekly is the operational job, and .github/workflows/watch.yml is the same thing on a cron. The workflow opens or updates a single human-review issue when a source moves and cannot publish — publication requires sentinel review --reviewer, a named human, and there is no path from CI to that command. Note that this repo's owner has an account-wide GitHub Actions spending limit, so do not assume the hosted workflow ever runs: the Makefile target is the primary path and the workflow is the convenience. A monitor whose only trigger is someone else's billing system is not a monitor.

Gates

make verify runs seven merge-blocking stages:

# Gate What it holds
1 lint ruff (correctness, bandit security, import hygiene, no bare TODOs)
2 type mypy --strict
3 cov pytest, coverage floor 90%
4 security pip-audit
5 sources-validate every registry entry: well-formed https government-domain candidate URL + known jurisdiction + known document class + claimed authority + unique id + no duplicate watch target + no verified: true without a named human and a dateand coverage --check-docs: every coverage number in every doc is re-derived from the registry (including how many sources a human has verified), and every unwatched (jurisdiction, document class) pair is a named gap
6 no-unreviewed-in-feed safety — unreviewed or dismissed drift cannot be published, and no source can appear in any published artifact without its verification status rendered alongside it (-m "feed_integrity or source_labelling")
7 no-auto-classification safety — nothing is classified substantive without a named human

Gates 6 and 7 are not code-quality checks. They are the safety properties this tool exists to hold. If either goes red, the correct response is to stop, not to weaken the test.

Gate 6 holds two properties, and the second is new. Unreviewed drift never reaches a consumer — and a source never reaches a consumer stripped of the fact that no human has confirmed it. They are the same discipline pointed at two different implicit claims: "a machine noticed this, so it must matter" and "this URL is in your list, so it must be the right page." Both are claims the tool would be making by omission, and neither is one it has earned. The second is enforced structurally as well as by test: publish() requires the registry, so there is no code path that can write an artifact without the thing that knows each source's status.

The whole suite runs with no network — the fetcher is injected, and the tests hand it fixtures.

Honest limits

  • The registry is machine-checked, not human-verified — and this is the biggest thing wrong with this repo. 156 sources across 52 of 52 jurisdictions, every one verified: false. Every URL in it has been live-fetched by the tool's own fetcher, its title read, and its normalized text read; none of them has been confirmed by a human as the right page, and that is a different, unfinished job. It is now a cheap job: sentinel verify is a review aid built for exactly this (docs/VERIFYING.md), sentinel coverage prints the burn-down, and every published artifact carries the status as a machine-readable field on every source — so an integrator cannot consume one without being told, and a merge-blocking gate asserts that on the published bytes. The checked block on each entry records machine facts (status, redirect target, reachability) and is deliberately a separate field from verified — a socket returning 200 is not a person confirming a page.
  • What a reader could still be misled by, stated rather than implied. The site says "unverified" beside every source, and a reader in a hurry may still take a table of one official-looking URL per state as a directory. Better copy cannot eliminate that risk; it is materially reduced only when all sources are verified and kept in date. Until then: treat every URL here as a lead, not a citation.
  • 8 named gaps remain, each one a (jurisdiction, document class) pair we do not watch, with the host that refused us and the reason: blocked-403 (3), robots-disallowed (3), tls-unverifiable (2). They are data, not prose (gaps in sources/registry.json), they are on the published site, and the completeness gate proves that every unwatched pair is one of them. We do not spoof a browser User-Agent, we do not disable certificate verification, and we do not route around a robots.txt. A blocked source degrades the tool without corrupting it.
  • The gaps used to be prose, and prose does not get checked. DC and RI were each missing an entire document class that no gap paragraph mentioned — they were not decisions, they were omissions wearing the costume of decisions. The derived gate makes that unrepresentable: an unwatched pair that is not a named gap now fails the build. (RI is now watched; DC's courts 403 us and it is a named gap.)
  • The TLS stack is part of the claim. jud.ct.gov answers a legacy OpenSSL 1.1.1 client with 200 and refuses the tool's OpenSSL 3.5 handshake outright. "It loads in my browser" is not evidence that this tool can watch it, and a registry recording the browser's opinion would be quietly wrong. Several of these hosts ship an incomplete chain that a browser silently repairs by chasing the AIA extension; the chain can be completed correctly, and it is the server's job to send it — the fetcher does not relax verification to compensate, and it never will.
  • 12 of the 156 registered sources cannot currently be fetched by our own crawler — a measured figure, not an estimate: a real end-to-end sentinel sources check over the live registry on 2026-08-22 read 144 of 156. Every one of the twelve is an observed failure, not an inference: ssa.gov (×2), health.ny.gov, cdph.ca.gov, ilsos.gov, and — as a regression found 2026-08-21 and reconfirmed 2026-08-22 — travel.state.gov (×2, behind a Cloudflare bot-challenge), ldh.la.gov (×2, a blanket 403 including on /robots.txt itself), legislature.mi.gov's two MCL PDFs (an incomplete TLS chain this tool's fetcher — unlike a browser — will not repair by chasing the AIA extension), and courts.mo.gov (HTTP 500 on the pass and again on an independent retry). The twelve are not one kind of thing, and flattening them into one number would be the lie this bullet exists to avoid: seven are outright refusals (HTTP 403), three are TLS verification failures, one is a connection timeout (ilsos.gov), and one is a server-side outage (courts.mo.gov's 500 — an outage, not a block, and expressly not grounds to demote the source to a gap). Nor do they carry the same evidence: five have no baseline hash at all, because a hash we did not observe is not a hash, while the other seven still hold the last hash actually observed — a dated historical fact, not a claim about today. A source we could not fetch is reported as unreachable on every run it recurs, and never as unchanged. Carrying a hash forward is how an outage is kept from becoming drift; it is never how an outage becomes a clean bill of health. None are deleted: deleting them would erase the fact that we cannot watch them.
  • nycourts.gov's name-change page moved, and the replacement is confirmed — but it is only intermittently readable. The 2026-07-13 URL returns 404; the entry now points at the path NY Courts migrated CourtHelp to, and the full-registry pass read it cleanly (HTTP 200, "Name Change Basics | New York Courts", 222 passages, on-topic for both name change and sex-designation change). The honest caveat is the spacing: on the same day, 5 consecutive targeted attempts — 4 of them two minutes apart — all returned 403, while the full pass, which reaches the host once after a long gap, succeeded. /robots.txt itself 403s, and since a robots.txt we cannot read is treated as permissive, this is not a robots-disallowed gap. A 403 here is an outage and is reported as one — see issue #10 and the entry's own notes. (A structurally different regression closed in #35: Michigan's SCAO name-change form moved from unreachable to a proper named gap, robots-disallowed — its host now disallows crawling site-wide, and CLAUDE.md's guardrail is to remove such a source rather than keep a dead entry.)
  • This detects change, it does not detect importance. A state can gut a policy by an internal directive that never touches a web page, and this tool will see nothing. It is one signal, not a guarantee.
  • Coverage is uneven by document class, and a landing page is often the deepest honest target: several states publish no statewide page for a document class at all (Texas's name-change process is county-level). Where the office page is all there is, the office page is what is watched, and the entry says so. The feed's silence about a jurisdiction means nothing at all.
  • The feed is currently, legitimately, empty. docs/feed.xml is valid RSS 2.0 with zero <item>s and an XML comment saying it is empty rather than broken; the site says the same thing in prose. Nothing has been reviewed and confirmed by a human yet, so nothing is published, and no change was manufactured to make the feed look alive. An empty feed is not a claim that nothing changed anywhere.
  • The site says when it was generated, which is not the same as when the watcher last ran. A consumer could read a fresh generated_at as evidence that a watch pass ran and found nothing — a wrong "no change" with a friendly face on it. Publishing the last watch pass's own timestamp and outcome is the next thing that should ship (docs/ROADMAP.md §10).
  • The published output is committed, deliberately, and it lives in docs/. It is not a build artifact; it is the product. Committing it means the site is servable from a clean clone with no build step and no CI run — which matters, because this account has an Actions spending limit and a feed that only exists once someone else's billing system agrees to run a workflow is a feed that does not exist. It sits in docs/ rather than dist/ because that is the only non-root path branch-based GitHub Pages will serve, and the Actions-based deploy that could have served dist/ is exactly the thing the billing limit stops. The prose docs live alongside it; docs/README.md says which files are generated and which are written by a human. Do not hand-edit a published filemake publish overwrites them, and a merge-blocking test asserts the committed changes.json contains only human-confirmed records, because with no CI in the loop the committed bytes are the served bytes.

Responsible technology

The real risks are named and addressed in docs/RESPONSIBLE-TECH-AUDITS.md: a wrong "no change" as a safety failure; auto-classification as an out-of-scope, forbidden capability; the subscriber list as a list of trans people; and polite crawling of public infrastructure.

Standards

The repository vendors the immutable v2.0.0 public standards projection under docs/standards/. The version marker and managed manifest make every projected file reviewable and testable without access to a private checkout. Per-repo values live in docs/ROADMAP.md and docs/RESPONSIBLE-TECH-AUDITS.md.

Standards Conformance

Applicability per the portfolio manifest (monitoring service plus a published human-facing site), with current state stated honestly:

Standard Applies? State
Responsible-Tech Framework Applies Risks named and addressed in docs/RESPONSIBLE-TECH-AUDITS.md; safety gates 6–7 are merge-blocking
Code Quality Applies ruff (incl. bandit rules, complexity ≤10) + mypy --strict in make verify
Security & Supply-Chain Applies Zero runtime deps; pip-audit + Dependabot; CodeQL + TruffleHog workflows; gitleaks in pre-commit; SHA-pinned actions
CI/CD Applies ci.yml runs the literal make verify; no CI-only or local-only gate (note the account-wide Actions spending limit — local make verify is the gate that always exists)
Observability Applies Watch receipts + derived status.json; site distinguishes quiet/partial/failed/running/stale health from generation time
Accessibility Applies Published site is plain server-less HTML; full WCAG 2.2 AA conformance is a V1 release gate, not a current claim (docs/08-ACCESSIBILITY-I18N.md)
Internationalization Applies English-only today; the owned Spanish V1 metadata scope, review workflow, fail-closed fallback, and target date are declared in docs/I18N.md
AI Evaluation N/A — deterministic source-change detector; no LLM/model component N/A — hashing + difflib + a named human; nothing generative or agentic anywhere in src/
Performance Applies There is no hosted request path: the watcher is a scheduled batch job and the site is static HTML served from a published artifact, so what matters is run time and freshness, not request latency. docs/10-OPERATIONS-SRE.md makes schedule-start performance and status freshness gates evaluated across the V1 eight-week baseline window. No page-weight or run-duration budget is asserted in make verify yet
Documentation Applies README + docs/ corpus; ADR log in docs/adr/; CHANGELOG.md; CONTRIBUTING.md
AI Development Measurement Applies Built AI-assisted and disclosed as such (see Provenance below). The outcome side is the merge-blocking gate set: a 90% branch-coverage floor, a coverage-drift check that re-derives the numbers in the docs from the registry, and the two safety gates, all inside the literal make verify that ci.yml runs. 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 and they never rank a person
Quality & Metrics Applies 90% branch-coverage floor (measured 93%); coverage numbers in docs are re-derived from the registry by a merge-blocking gate
Release & Versioning Applies Pre-1.0, no tag yet; tag-triggered release.yml re-runs make verify and enforces CHANGELOG parity when the first tag lands
Incident Response Applies SECURITY.md names private vulnerability reporting and states the worst case plainly: a wrong or manipulated published change, or a subscriber list that becomes a list of trans people. docs/10-OPERATIONS-SRE.md carries the runbooks, each naming trigger, severity, first safe action, decision owner, commands, verification, communication, and post-incident follow-up, with correction acknowledgment at Sev-1 within 1 hour and Sev-2 within 4 business hours; the commands default to stopping publication rather than skipping evidence checks. Not yet exercised against a real incident
Data Governance Applies docs/05-DATA-AND-EVIDENCE.md is the plan: evidence principles, canonical data classes, the provenance chain, source states, data-quality rules, retention and deletion, schema governance, and evidence-quality release checks. The watched material is public government identity-document guidance rather than personal data; the threat model in docs/06-SECURITY-PRIVACY-THREAT-MODEL.md treats any subscriber list as the sensitive asset

Provenance

Built AI-assisted, within a portfolio that shares a common quality standard: every project ships merge-blocking gates for its core safety properties, and audit artifacts are committed rather than claimed.

About

Technical alpha. Cited, machine-checkable change detection for US transgender identity-document law and process: watches candidate government sources on a weekly cadence, hashes normalized text, and surfaces the changed passage for a named human to review. The registry holds candidates, not verified official pages.

Resources

Contributing

Security policy

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages