Skip to content

[Edovo] Draft inbound API for course completions#205

Draft
darennkomo wants to merge 17 commits into
mainfrom
dnkomo/edovo_api_mvp
Draft

[Edovo] Draft inbound API for course completions#205
darennkomo wants to merge 17 commits into
mainfrom
dnkomo/edovo_api_mvp

Conversation

@darennkomo

@darennkomo darennkomo commented Jun 10, 2026

Copy link
Copy Markdown

Adds a draft inbound API for Edovo course completions.

Running the smoke test
The test spins up the Flask app in-process and hits the real local Postgres (case_triage_db on port 5433). BigQuery and Secret Manager are mocked, and the HMAC secret is generated in-process — no GCP credentials needed. this will obviously be changed when we ship this

  1. Start the local case_triage db:
    docker-compose up -d case_triage_db

  2. Run the smoke test:
    uv run python -m recidiviz.tools.case_triage.edovo.smoke_test

--

This is an initial, reviewable implementation of the webhook flow that ingests course-completion events from Edovo (an education tablet provider) and turns accumulated learning hours into earned-time credits for people incarcerated in Colorado (US_CO). All code lives under recidiviz/case_triage/edovo/, exposed as POST /edovo/course-completions on the existing case_triage Flask server. The endpoint can:

  • authenticate requests via HMAC-SHA256 (KeyId/Signature/Timestamp header, secret pulled from Secret Manager, 5-minute clock-skew window, constant-time compare)
  • validate the JSON payload into a frozen Pydantic model and return field-level validation errors
  • resolve the Edovo-supplied Colorado DOC ID to an internal person_id via BigQuery
  • persist completions idempotently, deduping exact replays and rejecting double-credit for the same person + course
  • calculate earned-time credits by pooling content_hours and emitting one credit per six accumulated hours
  • run a local end-to-end smoke test against Postgres with Secret Manager and BigQuery mocked

Concurrent webhooks for the same person serialize through a per-person Postgres advisory lock so two requests can't both read stale hours and double-issue a credit. Adds the EdovoCourseCompletion table and an Alembic migration, plus unit tests for every module.

The endpoint is locked to US_CO and requires a valid HMAC signature; there is no Auth0/JWT path.

Tickets: OBT-23206, OBT-23207, OBT-23208, OBT-23209, OBT-23210, OBT-23212, OBT-27564, OBT-31702

daren added 17 commits June 10, 2026 10:19
Adds a runnable end-to-end check against the local case_triage_db
Postgres with BigQuery person resolution mocked. Verifies 201 accepted,
200 duplicate replay, and 400 VALIDATION_ERROR for a missing field.
The DB table is correctly named edovo_course_completions; only the
Python class was misspelled. Renaming before the schema is referenced
anywhere outside this branch.
The person resolver hardcodes US_CO/US_CO_DOC_ID, but the request model
previously accepted any valid StateCode. A payload with a different
state_code would silently get resolved against CO. Tighten the model to
Literal["US_CO"] so the request layer rejects mismatched states up front.
Edovo can send the same instant in multiple timezones (a +00:00 retry of
a +05:00 original). Without normalization those two payloads produce
different idempotency keys, defeating the dedupe path. Convert to UTC
before stringifying the timestamp into the canonical key input.
Pydantic was coercing the JSON number to float and the route was
re-wrapping that float as Decimal(str(...)) — a lossy round-trip.
Declare the field as Decimal so it parses straight from the JSON
number, and drop the float→str→Decimal hop in the credit calculator
call. JSON serialization now emits a string ("4.5"), matching
Pydantic's default Decimal serialization.
The previous fallback returned constraint="invalid_state_code" for ANY
unmapped Pydantic error. A bad course_name or person_id would surface to
Edovo as an "invalid_state_code" complaint, which is wrong and confusing.

Switch to explicit (field, error_type) → constraint mapping and add a
new "invalid" literal as the honest fallback. The state-code path now
matches on literal_error specifically rather than being the implicit
default.
The previous regex required KeyId, Signature, Timestamp in that exact
order. RFC 7235 auth-scheme params are unordered, so any reordering by
Edovo's client (HTTP library upgrade, etc.) would 401 every request.
Parse params into a dict and validate the set instead, rejecting both
missing and unknown params explicitly.
The HMAC string-to-sign hardcoded "/edovo/course-completions" via a
module constant. That doesn't compose for additional endpoints under
the same scheme and silently breaks if a proxy ever rewrites the path
between the signer and the verifier. Plumb the path through from the
Flask route to the verifier and sign request.path.
Two concurrent webhooks for the same person could both read prior_hours
before either insert had landed, each compute that they crossed the
6-hour boundary, and each emit a credit — double-issuing for one
threshold crossing. Take a transaction-scoped Postgres advisory lock
keyed on (state_code, person_id) before reading prior_hours so the
two requests serialize through credit calculation.

The lock auto-releases on commit/rollback, costs ~nothing for the
non-conflict path, and requires no schema change.
Generate an ephemeral HMAC secret in-process and mock Secret Manager so
the smoke test no longer requires real GSM access or GOOGLE_CLOUD_PROJECT.
Randomize course_id per run to avoid dedup against prior rows.
@darennkomo darennkomo marked this pull request as draft June 10, 2026 19:26
@darennkomo darennkomo requested a review from joshl-rec June 10, 2026 21:17
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant