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.
Render the assessment (own routes + no-verdict end state) and a student home that lists both objects β assessments and applications under one login.
New βAssessmentsβ section (invite β report β βDo the visaβ migrate). Same pipeline UI + a previous stage.
School role: run assessments, import/invite agencies, decide + COE, and do visas for its own students. Built last.
Author the assessment as a template/step type. The pre-step lives in the same StepsβSectionsβQuestions model.
Provision schools (org client_type=school), branding, slug β mostly existing org management.
Untouched β assessment declares data, no certified translation stage.
Add/extend the school role + assessment scopes. Uses the existing per-app JWT templates β no new auth system.
Own entity + own lightweight state machine (4 states). Isolated module β no shared status enum, no shared lifecycle hooks with applications.
The Evaluator: GS risk rule set β band + 5 dimensions + flags. Triggered by the assessment lifecycle only.
The bridge. Reads an approved assessment β creates a visa application, pre-filled. One-way; the assessment record survives untouched.
Agency = global entity; schoolβagency many-to-many. Import = match-and-link (never re-invite).
Untouched. It only ever sees applications β including the one the conversion service creates, which enters at the normal starting state.
Extend to invite for an assessment (target object type). Must resolve to the same student if the email already exists β no duplicate identity.
Same form-definition resolver β it resolves a template, agnostic of which object owns the answers.
Assessments are unlimited/free β so they simply never call billing. The credit is consumed by the application the conversion creates.
New task: on assessment submit β run scoring β persist report. Same broker/queue pattern as doc processing.
No new scheduled jobs required for v1.
Same insulated adapters; scoring calls go through the existing interface + model tiering.
New prompt set + AI context for the risk rubric, versioned & traced in Opik like every AI call.
application.type, migration link, school_agency association, assessment report. Additive schema, no rewrite.
COE PDF stored via the existing document path.
New lifecycle events: assessment invite, agency welcome, decision (approve/decline). Same event-driven layer.
Delivery channel unchanged.
Assessment never lodges to a gov portal β the submission layer is out of scope.
No new deploy target. Same dynos.
New code is covered by existing telemetry.
Only the credit rule (assessment free); no integration change.
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.
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.
Everything that hangs off an application must not observe assessments. Concretely, these stay application-only:
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.
β οΈ 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.
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.