Infra
System architecture and operational reference. Updated March 2026.
1. System Overview
The Jail Data Initiative runs across three environments. Each handles a distinct workload:
| Environment | What runs there | Key services |
|---|---|---|
| MongoDB Atlas | All application data (media, team, rosters, facilities, scraper state) | Database cluster, shared by Lambda and IONOS |
| AWS | API layer, static file hosting, CDN, email | Lambda, API Gateway, S3, CloudFront, SES |
| IONOS server | Scraper scheduling, container execution | systemd daemon, Docker, ThreadPoolExecutor |
MongoDB Atlas (central database)
All application state lives in the jaildatainitiative database. Both the Lambda API and the IONOS scheduler connect to the same cluster. Collections:
media— articles and publicationsteam— members and contributors (each doc has atypefield)rosters— roster entries keyed by facility (_id= facid)manual_facilities— manually added facilities (_id= facid)scrapers— scheduler state per scraper (status, intervals, next_due_at)scraper_runs— append-only run log (indexed on{scraper_name, started_at})bookings— longitudinal booking records built from roster snapshots (one doc per booking)snapshot_log— tracks which S3 stashes have been ingested into bookings (dedup index on{scraper_name, s3_prefix})bookings_standard— materialized standardized bookings with unified field names across all platforms (one doc per booking, keyed onbooking_id)field_maps— per-platform field mapping definitions: raw field names → standard names + value transforms (_id= platform name)
AWS services
- S3
jaildatainitiative-facility-registry— static file hosting only. Storesfacilities.json(full map data),facilities-slim.json(lightweight subset for rosters page),raw/...stashes (scraper archives), and*.htmlsite assets. No longer used as a database. - CloudFront
E1VVUL8P0QQLJN— CDN forregistry.jaildatainitiative.org. Serves S3 content. Invalidated on deploys and live facility updates. - Lambda
jdi-roster-api— API handler. All CRUD operations hit MongoDB. Still writes tofacilities.jsonon S3 for live facility patches. - API Gateway
jdi-roster-api— HTTP API with CORS. Routes GET/POST/PUT/DELETE to Lambda. - SES — transactional email for DUA submissions and facility issue reports.
- IAM — Lambda role scoped to
facilities.json(S3), CloudFront invalidation, and SES.
IONOS server
- Scheduler daemon (
jdi-scheduler.service) — systemd service that continuously picks the most overdue scraper and dispatches it in a Docker container. - Docker — two images:
jdi-scraper-base(Python 3.12-slim for HTTP scrapers) andjdi-scraper-selenium(adds Chromium for browser-based scrapers). - ThreadPoolExecutor — up to 12 concurrent containers (4 max for Selenium). Each container is resource-limited (
--memory 512m/2g,--cpus 1.0).
2. Full Pipeline
Chronological flow from external data sources through to the live site and scraper system. Steps are numbered in execution order.
3. Pipeline Steps (Chronological)
Step 1a/1b: Fetch external datasets (independent, run in parallel)
python entities/fetch_bjs.py downloads the BJS 2019 Census of Jails from S3, geocodes each facility via the Census Bureau API (with BOP and Nominatim fallbacks), computes population coverage metrics, and writes data/bjs_facilities.json (2,936 records).
python entities/fetch_hifld.py queries the DHS HIFLD ArcGIS REST API for county/local/multi-jurisdiction jail facilities, filters out closed and juvenile facilities, and writes data/hifld_facilities.json (3,472 candidates).
Step 2: Match HIFLD to BJS
python entities/match_hifld.py classifies every HIFLD facility as MATCHED (same facility in BJS), HIFLD_NEW (not in BJS), or HIFLD_SUBFACILITY (sub-unit of a BJS facility). Uses composite scoring: 40% name similarity + 30% address + 30% geographic proximity (Haversine with 50km linear decay). Writes data/bjs_hifld_comparison.csv.
Step 3: Build the unified facility registry
python entities/build.py merges three facility sources into one file:
- BJS records from
data/bjs_facilities.json - HIFLD_NEW records from
data/bjs_hifld_comparison.csv - Manual facilities from MongoDB (
manual_facilitiescollection)
Also downloads 2019 Census county populations for capacity_per_100k calculations, loads confirmed roster URLs and excluded facility IDs from MongoDB (rosters collection), filters out excluded facilities, and writes data/facilities.json. Also generates data/facilities-slim.json (~200 KB) containing only facid, facname, state, and county for fast loading on the rosters page.
Step 4: Deploy to production
python web/deploy.py uploads facilities.json, facilities-slim.json, and all *.html files to S3 (with Auth0/API URL substitution), then invalidates the CloudFront cache. The site goes live within seconds.
Step 5: Discover roster URLs (manual, periodic)
python entities/find_rosters.py takes the facility list, groups by jurisdiction, searches DuckDuckGo for roster pages, and scores results 0–100 based on platform detection, keyword presence, domain TLD, and locality match. Excludes 80+ aggregator domains (VineLink, JailBase, etc.). Writes data/roster_candidates.csv.
Step 6: Admin imports and reviews candidates
Candidates are bulk-imported via the admin UI (POST /roster/import) into the MongoDB rosters collection. Admins review each entry, confirming or rejecting roster URLs. Confirmed entries feed back into Step 3 on the next build.
Step 7: Scheduler dispatches scrapers (continuous, IONOS)
The scheduler daemon runs 24/7 as a systemd service. Every 10 seconds it:
- Checks the
scraperscollection for overdue scrapers (next_due_at ≤ now) - Picks the most overdue one (priority queue by
next_due_at ASC) - Launches it in an isolated Docker container with resource limits
- Up to 12 containers run concurrently (4 max for Selenium)
Inside each container, the scraper follows a 4-step pipeline: fetch → validate → spot-check IDs → stash to S3. The container outputs a JSON result line on stdout and exits.
Step 8: Scheduler records results and schedules next run
When a container finishes, the scheduler:
- Appends a run record to
scraper_runs(stash status, duration, record count, content hash) - Updates the scraper’s state in
scrapers(consecutive_failures, last_success_at, etc.) - Triggers bookings ingest asynchronously (Step 9)
- Computes
next_due_atusing adaptive logic (see Retry & Frequency Tuning below)
Step 9: Bookings ingest (async, after each successful stash)
After a successful stash, the scheduler submits an ingest job to a background thread pool. The ingest process:
- Loads the stashed files from S3 and parses them via the scraper class
- Matches each record against open bookings using
BOOKING_ID_TIERS - Creates new bookings for first appearances, continues existing bookings (diffing all fields for change tracking), and closes bookings for people no longer present
- Logs the snapshot to
snapshot_logfor deduplication
Ingest runs asynchronously in a 4-thread pool so it does not block scraper dispatch. See Bookings Pipeline below for full details.
4. Scraper Scheduling
Frequency settings
New scrapers default to a daily (24-hour) interval. Admins can change a scraper’s base interval from the Scrapers admin page using a frequency dropdown with these options:
| Label | Interval | Use case |
|---|---|---|
every_1h | 1 hour | High-frequency rosters with rapid turnover |
every_2h | 2 hours | Frequently updated API-based rosters |
every_4h | 4 hours | Active rosters with regular updates |
daily | 24 hours | Standard roster pages (default for new scrapers) |
weekly | 7 days | Slow-updating or heavy scrapes |
The actual running interval (target_interval_s) starts at the admin-set value and is then adjusted by the auto-tuning system (see below). Auto-tuning can speed up to a 1-hour floor or slow down to a 1-week cap.
Retry and backoff
A scrape is successful if it stashes (fetch + validate + stash to S3). Parse failures are non-critical — raw data is archived and can be re-parsed later.
| Condition | Next run | Behavior |
|---|---|---|
| Stash succeeded | now + target_interval | Reset failures to 0 |
| Failed, < 5 failures | now + min(30m × 2n, interval/2) | Exponential backoff: 30m, 1h, 2h, 4h |
| Failed, 5–9 failures | now + interval/2 | Cap at half interval |
| Failed, ≥ 10 failures | now + 2 × interval | Marked “broken”, slow retry. Auto-heals on success. |
Change detection and frequency tuning
After each successful stash, the scheduler computes a SHA-256 hash of the fetched content and compares it to the previous run:
- 6 consecutive unchanged runs: doubles
target_interval(capped at 1 week / 604,800s) - Data changed: halves
target_interval(floored at 1 hour / 3,600s)
This means scrapers that rarely change gradually slow down to weekly, while frequently changing rosters speed up to hourly. The unchanged counter resets after each adjustment.
Enable/disable: two layers
| Layer | Where | Who | When |
|---|---|---|---|
enabled = False | Scraper .py file | Developer | Permanent: site gone, legal hold |
admin_disabled flag | MongoDB (via admin UI) | Admin | Temporary: debugging, maintenance |
Either layer blocks the scraper. The scheduler checks both on every pick_next() call.
5. Scraper Pipeline (per-run)
Every scrape run follows a hard-gated pipeline. If any pre-stash step fails, the run aborts and nothing is archived.
2. Validate files → Check files are structurally sound
3. Spot-check IDs → Confirm person/booking ID fields exist in raw data
4. Stash → Archive raw files to S3 (this = success)
5. Parse → Extract structured records (post-stash, failure is non-critical)
Step 1: Fetch
Downloads raw content from the roster source. For API-based platforms (e.g. Citizen RIMS), this is one or more JSON responses. For HTML-based platforms, this may include list pages and individual detail pages. Fails on HTTP errors, timeouts, or authentication failures.
Step 2: Validate files
Checks that fetched files are structurally viable before archiving:
- At least one file was returned and no files are empty (0 bytes)
- JSON files must be valid JSON, and must not be empty arrays or objects
- HTML files must not contain error markers (403 Forbidden, 503, CAPTCHA pages, etc.) and must exceed a minimum size threshold
- Platform-specific checks (e.g. Citizen RIMS verifies the fetched record count matches the API’s Count endpoint)
Step 3: Spot-check IDs
A lightweight pre-stash check confirming that the raw data contains person and booking identification fields. Every record must satisfy at least one identification tier for both person and booking. This runs on the raw field names before any normalization, ensuring we never archive data that can’t be parsed into identifiable people and bookings downstream.
Step 4: Stash
Uploads all fetched files plus a manifest.json to S3 under a timestamped prefix: raw/{scraper_name}/{YYYY-MM-DD}/{HHmmss}/. The manifest records the source URL, content type, byte size, and fetch timestamp for each file.
Step 5: Parse (post-stash)
Extracts structured records from the raw files. Parse failures don’t lose data — raw files are already in S3 and can be re-parsed later with --parse-only. The parse step also normalizes names (produces _full_name) and tags records with facility/jurisdiction metadata.
6. Person & Booking Identification
Different platforms provide different fields. The scraper uses a tiered identification system, preferring more reliable identifiers:
| Category | Tier | Fields | Reliability |
|---|---|---|---|
| Person | 1 | Unique system ID (e.g. personId) | High — unique per individual |
| Person | 2 | Full name + date of birth | Medium — rare collisions |
| Person | 3 | Full name alone | Lower — possible collisions |
| Booking | 1 | Unique booking ID (e.g. bookingId) | High — unique per booking |
| Booking | 2 | Booking date | Medium — combined with person ID |
7. Bookings Pipeline
The bookings pipeline converts periodic roster snapshots into longitudinal booking records with start/end times, field change history, and data quality flags. It runs as Step 9 in the pipeline — automatically after each successful scrape, or manually via the backfill CLI.
Algorithm: ingest_snapshot(scraper_name, s3_prefix)
2. Parse → Load stash from S3, parse via scraper class
3. Gap detect → Flag if gap between scrapes exceeds 2× target interval
4. Match → Match each record to open bookings via BOOKING_ID_TIERS
5. Create/continue → New bookings or update existing (diff all fields)
6. Close → Close bookings absent for ≥ CLOSURE_THRESHOLD consecutive scrapes
7. Log → Insert snapshot_log entry
Booking lifecycle
| Event | Action | Timing |
|---|---|---|
| Person appears for first time | Create booking (status: "open") | start_time = admit_date (if reported) or scrape time |
| Person still present | Continue booking: diff fields, update last_seen | Reset consecutive_misses to 0 |
| Person absent 1 scrape | Increment consecutive_misses | Booking stays open |
| Person absent ≥ 2 scrapes | Close booking ("not_seen") | end_time = last_seen |
| Platform reports release date | Close booking ("released") | end_time = reported release date |
Change tracking
Every field in latest_record is diffed between snapshots. Scalar fields store old_value / new_value. Array fields (charges, holds, etc.) use set-based comparison that is order-independent — records added and removed elements without false positives from reordering.
Coverage quality flags
| Field | Meaning |
|---|---|
end_time_reliable | true if closed during normal scraping; false if closed after a coverage gap |
end_time_min | Last scrape where person was present (lower bound) |
end_time_max | First scrape where person was absent (upper bound) |
coverage_gap_hours | Largest gap between consecutive scrapes during this booking |
closed_reason | "not_seen", "not_seen_after_gap", or "released" |
Platform date extraction
Each platform declares which raw fields contain admission and release dates. When available, these provide more accurate timing than scrape timestamps alone:
| Platform | ADMIT_DATE_FIELDS | RELEASE_DATE_FIELDS |
|---|---|---|
| citizen_rims | bookingDate + bookingTime | None |
| inmate_roster_php | booking_datetime | None |
| telerik_radgrid (Navajo) | None (no dates reported) | None |
| ycso_inmate_search (Yavapai) | booking_date | None |
Backfill CLI
python -m ingest.backfill az_apache_county --since 2025-01-01 # from date
python -m ingest.backfill az_apache_county --force # drop + rebuild
python -m ingest.backfill --all # all scrapers
Processes stashes in strict chronological order. Skips already-ingested snapshots unless --force is used (which drops all existing bookings for that scraper first).
MongoDB schema: bookings
| Field | Type | Purpose |
|---|---|---|
scraper_name | str | Source scraper |
platform | str | Platform type (citizen_rims, etc.) |
jurisdiction | str | County + state |
facility_id / facility_ids | str / list | BJS/HIFLD facility IDs |
booking_id_tier / booking_id_fields | int / dict | Which tier matched, with field values |
person_id_tier / person_id_fields | int / dict | Person identification tier + fields |
full_name | str | Normalized name |
first_seen / last_seen | datetime | Scrape timestamps of first/last appearance |
admit_date / release_date | datetime? | Platform-reported dates (null if not available) |
start_time / end_time | datetime | Effective booking period (admit_date ?? first_seen) |
length_of_stay_hours | float? | Computed on closure |
status | str | "open" or "closed" |
closed_reason | str? | "not_seen", "not_seen_after_gap", "released" |
latest_record | dict | Full raw record (platform-native field names) |
changes | list | Field change history across snapshots |
snapshot_count | int | Number of snapshots this booking appeared in |
Indexes
| Collection | Index | Purpose |
|---|---|---|
bookings | {scraper_name, status} | Load open bookings per scraper for matching |
bookings | {facility_id, start_time DESC} | Query by facility + date range |
bookings | {jurisdiction, start_time DESC} | Query by jurisdiction + date range |
snapshot_log | {scraper_name, s3_prefix} unique | Dedup (prevents double-ingesting) |
snapshot_log | {scraper_name, scrape_time} | Chronological ordering, gap detection |
Files
| File | Purpose |
|---|---|
ingest/__init__.py | Package marker |
ingest/core.py | ingest_snapshot() — main algorithm |
ingest/db.py | MongoDB helpers: CRUD for bookings + snapshot_log, indexes |
ingest/match.py | Record-to-booking matching via BOOKING_ID_TIERS |
ingest/backfill.py | CLI for processing historical stashes |
8. Scraper Architecture
Each scraper is a Python class inheriting from BaseScraper (or a platform subclass). The class hierarchy:
├ CitizenRIMSScraper ← scrapers/platforms/citizen_rims.py
│ └ az_apache_county ← scrapers/AZ/apache_county.py
├ InmateRosterPHPScraper ← scrapers/platforms/inmate_roster_php.py
│ ├ al_cleburne_county ← scrapers/AL/cleburne_county.py
│ └ al_randolph_county ← scrapers/AL/randolph_county.py
└ BaseScraper (direct)
└ az_yavapai_county ← scrapers/AZ/yavapai_county.py
Each scraper class declares:
| Attribute | Type | Purpose |
|---|---|---|
name | str | Unique ID, e.g. "az_apache_county" |
facility_ids | list[str] | BJS/HIFLD facility IDs this roster covers |
image | str | "base" or "selenium" (Docker image) |
max_runtime | int | Max seconds before container is killed (default 7200) |
enabled | bool | False to permanently disable in code |
Docker images
| Image | Base | Extras | Memory limit |
|---|---|---|---|
jdi-scraper-base | Python 3.12-slim | requests, beautifulsoup4, boto3 | 512 MB |
jdi-scraper-selenium | Python 3.12-slim | + Chromium, ChromeDriver, selenium | 2 GB |
Adding a new scraper
2. Set image = "selenium" if it needs a browser (default "base")
3. Set max_runtime = 14400 if it's long-running (default 7200s)
4. Set enabled = False if not ready for production yet
5. Rebuild Docker image: docker build -f scrapers/Dockerfile.base -t jdi-scraper-base .
6. Scheduler picks it up on next sync_scrapers() (~10 min), defaults to daily
7. Adjust frequency from the Scrapers admin page if needed
9. API Endpoints
All endpoints go through api/roster_api.py (Lambda) via API Gateway. Admin endpoints require an Auth0 JWT with the Admin role.
| Method | Path | Auth | Purpose |
|---|---|---|---|
| POST | /dua | Public | DUA form submission (sends email via SES) |
| POST | /report-issue | Public | Facility issue report (sends email via SES) |
| GET | /rosters | Admin | All roster entries keyed by facility ID |
| POST | /roster | Admin | Update a single roster entry |
| POST | /roster/import | Admin | Bulk import roster candidates |
| POST | /facility | Admin | Add a manual facility |
| POST/PUT/DELETE | /media | Admin | Media CRUD |
| POST/PUT/DELETE | /team | Admin | Team CRUD |
| GET | /scheduler/status | Admin | Dashboard summary (counts, running/broken/overdue lists) |
| GET | /scheduler/scrapers | Admin | Full scraper list with state |
| GET | /scheduler/runs | Admin | Recent run history (filterable by scraper) |
| POST | /scheduler/toggle | Admin | Enable/disable a scraper |
| POST | /scheduler/trigger | Admin | Trigger a scraper to run immediately |
| POST | /scheduler/interval | Admin | Set scraper frequency/interval |
10. Deploy Commands
Deploy the web app (facilities + HTML)
Runs build.py first, then uploads everything to S3 and invalidates CloudFront.
Deploy the Lambda API
Packages roster_api.py + pymongo into a Lambda zip, updates code and environment variables.
Deploy the scheduler (IONOS)
Builds both Docker images and installs/restarts the systemd service.
Backfill bookings from historical stashes
python -m ingest.backfill --all --force
Processes S3 stashes in chronological order. Use --force to drop existing bookings and rebuild from scratch. Live ingest happens automatically via the scheduler daemon.
Run the data pipeline (Steps 1–3)
python entities/fetch_hifld.py
python entities/match_hifld.py
python entities/build.py
Steps 1a and 1b are independent and can run in parallel. Step 2 needs both. Step 3 needs 1a + 2.