FLOW 1

Owned async transcript evaluation

The core pipeline · epic IMMIGR8-471 (PIPE-1..17)
What it solves: turn an uploaded foreign transcript into an auditable US credit-alignment + grade-placement evaluation — as Immigr8's own intellectual property, not a rented LLM call. Every mapping traces to Sandy's Manual, the NCAA guide, or a Texas TWEDS code, so districts and counsel can audit it, and the same transcript always produces the same evaluation.
Technical worker process
  1. 1

    Intake & enqueue

    A staff evaluator opens a submission and clicks “Run Immigr8 Evaluation”POST /submissions/:id/evaluate. Gated by a global kill-switch (EVAL_PIPELINE_ENABLED) and a per-country allow-list (EVAL_ENABLED_COUNTRIES). It inserts one row into the evaluation_jobs Postgres queue with status queued. A partial-unique index guarantees only one active job per submission — a re-run while a draft is in flight returns the existing job (202 deduped).

    Postgres queue · no Redisapps/api/src/routes/submissions.ts
  2. 2

    Worker claims the job

    The immigr8-worker — a separate Render process running the same apps/api tree (node dist/worker.js) — polls every 5 seconds. It claims the oldest queued job with SELECT … FOR UPDATE SKIP LOCKED, flips it to running, stamps claimed_at and bumps attempts. Jobs stuck running > 10 min are reclaimed to queued; after 3 attempts a job goes failed. A health check emits structured warnings on failure spikes or queue backlog.

    SKIP LOCKED claimstale-reclaim 10mmax 3 attemptsapps/api/src/worker.ts
  3. 3

    Phase A — read the transcript (OCR)

    For each job, the worker POSTs an r2Key to a Cloudflare Container running PaddleOCR (PP-StructureV3, Apache-2.0) at EXTRACT_ENDPOINT_URL. The container pulls the transcript bytes straight from R2 (immigr8-transcripts, free same-network egress) and returns ExtractedCourse[]{ foreignCourse, grade, yearTaken, gradeLevelHint, ocrConfidence }. A 240 s timeout plus a provisioning-503 retry (up to 9× / 20 s apart) absorbs the scale-to-zero container's ~150 s cold start. Multiple documents are merged + de-duplicated; supporting docs are skipped.

    PaddleOCR · Apache-2.0no Anthropic503 cold-start retryapps/api/src/lib/transcript-extraction.ts

    Commodity, swappable: reading is not the IP. The earlier Modal plan was superseded by the owned Cloudflare Container (decision 2026-06-08).

  4. 4

    Phase B — evaluate (the deterministic engine = the IP)

    evaluateTranscriptDeterministic() maps the structured courses to US equivalents with no LLM in the loop. Per course it consults a tiered source of truth:

    Tier 1
    Immigr8 Manual (@immigr8/shared) — Sandy's curated per-subject mappings + grading scales for the curated countries. → sourceOfTruth=manual, confidence 90.
    Tier 2
    NCAA International Guide (210 countries) — academic-calendar and grade-name context when the Manual is thin.
    Tier 3
    Texas TWEDS / PEIMS — resolves the canonical 8-digit course code + title + a next-course recommendation.
    Uncovered
    classify the subject domain only — never invent a US course name. → sourceOfTruth=ai_generic, confidence 20, “specialist mapping required.”

    Then: grade conversion via the Manual's scale bands; credit rules (a course at the student's current grade = 0 credit / in_progress_us, else 1.0 split 0.5 fall + 0.5 spring); STAAR eligibility; and grade placement = highest completed grade + 1.

    deterministic · auditablesame input → same outputapps/api/src/lib/deterministic-evaluation.ts
  5. 5

    Validate the output

    A 15-rule validator (IMMIGR8-354) scores every mapped row. BLOCK rows are dropped (grade out of 1–12 range, future year, ascending-grade/year inversion, duplicate course, malformed TWEDS code…); WARN rows are kept with flags attached (low_confidence, low_ocr_confidence < 0.6, missing source quote…). The validator is a second safety net regardless of which engine produced the draft.

    15 rules · BLOCK / WARNapps/api/src/lib/ai-output-validator.ts
  6. 6

    Persist a unified draft

    persistAiDraft() discards any prior isAiDraft=true rows (a specialist's hand-entered rows are never touched), sanitizes every field to satisfy the DB CHECK constraints, then writes course_mappings + evaluations with isAiDraft=true plus the AI metadata: aiConfidence, validationFlags, aiProvenance, sourceOfTruth.

    same tables as manualapps/api/src/lib/evaluate-submission.ts
  7. 7

    Specialist approval flip

    POST /submissions/:id/approve-ai-draft pre-flight-blocks approval if any row still carries a flag or confidence < 0.70 (unless a platform_admin forces it). On approval it flips isAiDraft true → false on every row + the evaluation. After the flip the record is operationally identical to a hand-typed one; the AI-metadata columns are retained for audit and Path-3 training capture.

    human-in-the-loop gate
Optional provider: an Anthropic path (/ai-analyze) still exists and writes through the same persistAiDraft(), but the deterministic engine is the live owned path. Losing an API key can never break the core evaluator. (Decision 2026-06-05.)
FLOW 2

Two intake doors

How a transcript enters the system
What it solves: Immigr8 sells both a self-service portal and staff-assisted processing, so a transcript can enter through two doors with two different status workflows — but both land in R2-backed storage and feed the same evaluation surface.
Technical worker process
  1. A

    Staff-assisted — submissions

    A district uploads a transcript for Immigr8 staff to evaluate. Status workflow: submitted → assigned → in_review → completed. This is the active path that the Flow 1 pipeline runs on today — evaluation_jobs.submission_id points here.

  2. B

    Self-service — transcriptReviews

    A district user reviews a transcript independently in the portal. Simpler status: draft → in_progress → completed. The table exists and is wired, but is dormant today; the owned pipeline writes through the submissions path. (Per the reconciliation ADR, the reviews path is untouched.)

Both doors store uploaded files in Cloudflare R2 (immigr8-transcripts, 50 MB max, 1-hour presigned URLs) and both eventually produce a course_mappings + evaluations pair.
FLOW 3

One record: manual ↔ bot reconciliation

Decision 2026-06-10 · IMMIGR8-481 (PIPE-10)
What it solves: a specialist must not be able to tell — after approval — whether a record was typed by hand or generated by the engine. Every downstream feature (PDF, STAAR, completion, per-year view, metrics) must behave identically for both origins.
Technical worker process
  1. 1

    One record model

    One submission → N course_mappings + one evaluation. There is deliberately no separate “AI evaluations” table — that would fork every downstream feature into two code paths.

  2. 2

    Both paths write the same repos

    Manual entry calls createForSubmission / upsertForSubmission with isAiDraft:false. The bot calls the identical repos with isAiDraft:true + the AI-metadata columns. The only difference between the two records is that flag.

  3. 3

    Approval erases the difference

    approveAiDraftForSubmission() sets isAiDraft=false on every draft row + the evaluation. Safety invariants: discard only deletes isAiDraft=true rows (specialist work is safe), the enqueue endpoint dedupes an active job (no race), and AI metadata is retained post-approval for the audit trail + the Path-3 training set.

FLOW 4

Student identity & dedup

IMMIGR8-199 / IMMIGR8-334 · FERPA-scoped
What it solves: a stable, canonical student record that persists across resubmissions and year-over-year evaluations — without silently merging two different children, and without ever leaking one district's students into another.
Technical worker process
  1. 1

    Match-or-create on intake

    findOrCreateStudent() runs as a submission is created. A strong match (first-word-of-first-name + last name + exact date of birth) reuses the existing record; a weak match (fuzzy name + DOB within tolerance) is flagged for human review; no match creates a new students row. The chosen path is captured in the activity log with a confidence + source.

    apps/api/src/lib/student-matching.ts
  2. 2

    District boundary is hard

    Matching never crosses districts. Cross-district student linkage requires a platform_admin action + an audit-log entry — the same RLS posture as submissions.

  3. 3

    Admin merge suggests, never auto-applies

    A pure scoring function (scoreStudentPair) powers a duplicates view that suggests merges; a specialist confirms each one. The dry-run backfill of historical duplicates is human-gated before --apply.

FLOW 5

Billing & onboarding

Stripe webhook · zero-touch provisioning
What it solves: a district checks out and is fully provisioned — account, campus, admin user, welcome emails — with no manual onboarding step.
Technical worker process
  1. 1

    Stripe checkout completes

    Stripe fires checkout.session.completedPOST /billing/webhook (signature-verified). The Stripe session id is stored for idempotency so a re-delivered webhook can't double-provision.

    Stripeapps/api/src/routes/billing.ts
  2. 2

    Provision the district

    The handler creates a districts row (name, size band, plan tier, subscriptionStatus=active, state, contact), a campuses row for a campus-level signup, and a users row as district_admin or campus_admin. Both creations are written to the activity log.

  3. 3

    Kick off the welcome sequence

    scheduleWelcomeSequence() queues the branded onboarding emails (SendGrid). No human touches the account between payment and a usable login.

ROADMAP

Where the pipeline is heading — Path-3

The deterministic engine is not a dead end before a proprietary model — it is the on-ramp. Every specialist-confirmed mapping (anonymized per the IMMIGR8-332 DPA layer) accrues as a training_pair. When the set crosses a decision gate (IMMIGR8-275), those pairs fine-tune an owned Immigr8 model for Phase A reading + the uncovered tail of Phase B — with the deterministic engine + the 15-rule validator staying on as the guardrail that keeps the model honest and auditable. Fully owned, end to end.

Sources of truth for this page

Pulled from the live code + the architecture decision records. If a flow here disagrees with the code, the code wins — open a ticket.
  • ARCHITECTURE.md — tables, RLS, the evaluation_jobs queue contract
  • docs/decisions/2026-06-05_deterministic-evaluation-engine.md — Phase B as owned IP + Path-3 roadmap
  • docs/decisions/2026-06-08_phase-a-transcript-reading.md — PaddleOCR reading (Cloudflare Container superseded the Modal plan)
  • docs/decisions/2026-06-05_manual-as-source-of-truth.md + 2026-06-05_ncaa-as-tier-2-fallback.md — the tiered source of truth
  • docs/decisions/2026-06-10_manual-bot-evaluation-reconciliation.md — one unified record
  • Epics: IMMIGR8-471 (owned async pipeline, live 2026-06-13), 422 (AI evaluator safety), 423 (compliance)