Engineering Β· GSA Γ— current stack

How GSA fits the stack

GSA is a separate first-class object β€” an assessment, not an application. It has its own table, its own lifecycle and its own analytics, and it never enters the application workflows. A one-way bridge can later spawn a visa application from its data. One student, one login, both objects side by side.

🚧

Rule 1 β€” it is NOT an application. assessment is its own entity, not application.type='assessment'. Nothing that fires on an application fires on an assessment: no document pipeline, no expert-review orchestration, no submission automation, no application-lifecycle notifications, and a separate PostHog object/event taxonomy. The application state machine stays untouched.

πŸ”—

Rule 2 β€” the student is the join point. Not the application. One student (one Clerk identity, one login) can own N assessments and N applications at once. The portal has to resolve β€œwhat am I working on?” across object types β€” that's a real new surface, not a detail.

NEW β€” build it MODIFY β€” extend existing REUSE β€” untouched

Frontend Β· Next.js

Applicant PortalMODIFY
ov-capture-frontend

Render the assessment (own routes + no-verdict end state) and a student home that lists both objects β€” assessments and applications under one login.

Agency AdminMODIFY
ov-admin

New β€œAssessments” section (invite β†’ report β†’ β€œDo the visa” migrate). Same pipeline UI + a previous stage.

School surfaceNEW
ov-admin (school role)

School role: run assessments, import/invite agencies, decide + COE, and do visas for its own students. Built last.

CMSMODIFY
ov-cms

Author the assessment as a template/step type. The pre-step lives in the same Steps→Sections→Questions model.

Platform AdminMODIFY
ov-superadmin

Provision schools (org client_type=school), branding, slug β€” mostly existing org management.

Translation WorkbenchREUSE
ov-translator

Untouched β€” assessment declares data, no certified translation stage.

Identity Β· Clerk

Roles & RBACMODIFY
Clerk (all apps)

Add/extend the school role + assessment scopes. Uses the existing per-app JWT templates β€” no new auth system.

Backend Β· Python 3.11 + FastAPI (modular monolith)

Assessment domainNEW
ov-capture-backend

Own entity + own lightweight state machine (4 states). Isolated module β€” no shared status enum, no shared lifecycle hooks with applications.

Assessment ScoringNEW
ov-capture-backend

The Evaluator: GS risk rule set β†’ band + 5 dimensions + flags. Triggered by the assessment lifecycle only.

Conversion serviceNEW
ov-capture-backend

The bridge. Reads an approved assessment β†’ creates a visa application, pre-filled. One-way; the assessment record survives untouched.

Org ↔ Org associationsNEW
ov-capture-backend

Agency = global entity; school↔agency many-to-many. Import = match-and-link (never re-invite).

Application state machineREUSE
ov-capture-backend

Untouched. It only ever sees applications β€” including the one the conversion service creates, which enters at the normal starting state.

InvitationsMODIFY
ov-capture-backend

Extend to invite for an assessment (target object type). Must resolve to the same student if the email already exists β€” no duplicate identity.

Questions & Conditional logicREUSE
ov-capture-backend

Same form-definition resolver β€” it resolves a template, agnostic of which object owns the answers.

Billing domainREUSE
ov-capture-backend

Assessments are unlimited/free β€” so they simply never call billing. The credit is consumed by the application the conversion creates.

Async Β· Redis + Celery

Celery WorkersMODIFY
worker dynos

New task: on assessment submit β†’ run scoring β†’ persist report. Same broker/queue pattern as doc processing.

Celery BeatREUSE
scheduler

No new scheduled jobs required for v1.

AI Β· OpenAI (adapter-based) + Opik

LLM adaptersREUSE
vendor-agnostic interface

Same insulated adapters; scoring calls go through the existing interface + model tiering.

GS scoring promptsNEW
ov-cms Β· Prompt Mgmt

New prompt set + AI context for the risk rubric, versioned & traced in Opik like every AI call.

Data Β· Supabase (Postgres 15 + Storage)

PostgreSQL 15MODIFY
migrations

application.type, migration link, school_agency association, assessment report. Additive schema, no rewrite.

Supabase StorageREUSE
S3-compatible

COE PDF stored via the existing document path.

Notifications Β· SuprSend + Resend

SuprSendMODIFY
orchestration

New lifecycle events: assessment invite, agency welcome, decision (approve/decline). Same event-driven layer.

ResendREUSE
email delivery

Delivery channel unchanged.

Untouched layers

Automation layerREUSE
ov-submitter Β· immi-scrapper Β· ov-agents

Assessment never lodges to a gov portal β€” the submission layer is out of scope.

InfraREUSE
Heroku (web + worker)

No new deploy target. Same dynos.

ObservabilityREUSE
Logfire Β· Sentry Β· Opik Β· Better Stack

New code is covered by existing telemetry.

PaymentsMODIFY
Polar

Only the credit rule (assessment free); no integration change.

The core change: two objects, one student

Data model (new table, proposed)

student { // REUSE β€” the join point
  id Β· clerk_user_id // ONE login
}
   β”œβ”€< assessment[] // N
   β””─< application[] // N β€” independent

assessment { // NEW TABLE
  id Β· student_id
  agency_id Β· school_id // who sends / who decides
  template_id // REUSE: CMS template
  answers
  status // OWN enum (4 states)
  report // band + dims + flags
  decision Β· coe_document
  converted_application_id // nullable β†’ the bridge
}

application { /* UNCHANGED */ }

school_agency { // NEW (m:n)
  school_id Β· agency_id Β· status
}

No shared status enum. No polymorphic table. application needs no new column β€” the FK lives on the assessment side, so nothing in the visa domain has to know assessments exist.

Lifecycle β€” two separate tracks

assessment Invited β†’ In Progress β†’ Pending Decision
↓ school approves + COE
assessment Decided Β· Approved (final β€” it stays here)
β‡’ conversion service: reads answers β†’ CREATES a new object
application born pre-filled, at the normal starting state
↓ its own workflows: docs β†’ expert review β†’ submit
application the product you already run

Both rows coexist forever. The assessment is never mutated into an application β€” it's the source record, kept for audit, analytics and the school's history.

Workflow isolation (the thing to get right)

Everything that hangs off an application must not observe assessments. Concretely, these stay application-only:

Document pipeline

OCR / extraction / validation β€” assessments collect declared data, no uploads in v1.

Expert review orchestration

No assessment ever lands in a reviewer worklist.

Submission automation

ov-submitter / portal mapping never see an assessment.

Application notifications

Assessments get their own SuprSend event set, not the visa lifecycle one.

Billing / credits

Never called by the assessment domain.

PostHog

Separate object + event namespace, so funnels and dashboards don't blend.

Practically: the assessment module emits its own domain events. Any existing subscriber keyed on application events is untouched by construction β€” which is the main reason not to overload application.type.

Request flow (technical)

Agency invites a student to an assessment (school selected). Resolves to an existing student if the email is known. ov-admin β†’ Invitations Β· Assessment domain
Student logs in (one identity) and sees their objects β€” this assessment alongside any application they already have. ov-capture-frontend β†’ Clerk Β· student home
Completes the assessment template. ov-capture-frontend β†’ Questions & Logic (REUSE)
On submit, the assessment domain emits its own event β†’ enqueue scoring. Assessment domain β†’ Redis β†’ Celery
Scoring runs (GS rule set + LLM via adapter), report persisted on the assessment. Celery β†’ Scoring (NEW) β†’ Postgres Β· Opik trace
Report surfaces to agency & school; notify via the assessment event set. β†’ ov-admin Β· SuprSend (assessment events)
School decides: approve + upload COE / decline. Assessment reaches its final state. ov-admin (school) β†’ Assessment domain β†’ Supabase Storage
Convert (agency action): read the approved assessment β†’ create a pre-filled application for the same student, 1 credit. Assessment untouched. Conversion service (NEW) β†’ Application domain (REUSE)

Net footprint

5
NEW β€” assessment domain, scoring, conversion service, org association, school surface
6
MODIFY β€” portal (multi-object home), agency admin, CMS, superadmin, Clerk roles, invitations
10
REUSE β€” untouched, incl. the entire application domain, submission automation, billing & infra

⚠️ Biggest architectural decision: the assessment is a separate object with its own module, status enum and events β€” deliberately not application.type. That's what guarantees no application workflow ever fires for it, keeps analytics clean, and lets one student hold an assessment and an application at the same time under a single login. The only coupling is a one-way conversion service that creates an application from assessment data. New module inside the modular monolith β€” no new service, no new deploy target.

Go deeper

OneVisa Β· GSA stack-fit Β· grounded in the current Tech Stack (modular monolith Β· Next.js Β· FastAPI Β· Clerk Β· Supabase Β· Redis/Celery Β· OpenAI/Opik Β· Heroku Β· SuprSend/Resend/Polar). NEW/MODIFY marks are a proposed impact map for review, not a committed design.