# The Sponsor Ecosystem — Architecture Docs > Documentation for the Sponsor Ecosystem Architecture: a multi-brand, multi-system operating model covering applicant lifecycle, portals, the Globus core (Rules / Matching / Job Orders), governance, integrations, and delivery status. _Concatenated markdown of every documentation page. Last updated: 2026-10-07._ --- --- title: "Overview" slug: overview section: Overview & Architecture status: stable description: "Multi-Brand Governance & Operating Model" last-updated: 2026-10-07 --- # Overview Multi-Brand Governance & Operating Model ## How to Read These Docs Pick the trail that matches your question. Each row is ordered — read left to right. | You want to… | Read, in order | | --- | --- | | Understand the whole system | Overview (this page) → Ecosystem Model → Architecture Diagram → Data Flows | | Follow an applicant end-to-end | Applicant Lifecycle → PPPToo Architecture (as-built journey) → Applicant vs Application Data | | Build against a portal API | Globus Portal API Reference → the portal's Endpoint Reference (Applicant / Host) → Integration Contract | | Work on Globus internals | Capability Layers → Rules Engine → Queue System → Workflow Studio | | Operate / debug the platform | Infrastructure & Environments → Automation (n8n) → Webhook Event Registry | | Check delivery status | Delivery Overview → Project Status → Team & Capacity | > **Agents: trust pages marked as-built over target-state** > > Where a page documents a target-state contract that differs from what runs today, it carries a status callout near the top (e.g. Direct vs Partner Entry). Implementation ground truth for the applicant intake lives on PPPToo Architecture and Globus Portal API Reference ; endpoint pages state their extraction date. ## Purpose & Scope The Sponsor Ecosystem Architecture defines the unified operating model for all sponsor brands and systems. It establishes the governance structure, system boundaries, data flows, and role-based access controls across the full lifecycle — from recruitment and intake to placement, compliance, and program completion. This architecture provides a scalable, multi-brand ecosystem that separates authority from experience layers, centralizes compliance within the Sponsor Core (Globus), and enables controlled distribution through applicant, host, and partner portals that integrate directly with Globus. > **Extensible by Design** > > This architecture is intentionally extensible, allowing future expansion into finance, reporting, analytics, and additional operational modules without architectural redesign. ## Globus Replaces Hanover Globus is the new system replacing Hanover for managing Alliance Abroad program operations. Hanover is the legacy platform historically used to manage applicant, host, placement, and program records. Globus is the modern replacement — with updated workflows, improved data visibility, and a centralized, role-scoped experience for teams. > **Use Globus as the source of truth going forward** > > New and ongoing work should be completed in Globus. Hanover should only be used for historical reference or specific legacy workflows when explicitly instructed. During the transition period, some historical information may still exist in Hanover, but it is no longer the place to manage active operations. The two systems cover the same core business areas. The table below shows what each area looked like in Hanover and where that work happens now in Globus. | Area | Hanover (legacy) | Globus (now) | | --- | --- | --- | | Applicants | Applicant profiles, program details, status tracking | Centralized applicant records and status management | | Hosts | Host organization records and related details | Host profiles, locations, and related operational data | | Placements | Placement and job-related information | Placement tracking and program coordination | | Programs | Program participation and lifecycle tracking | Program workflows, participant progress, and operational reporting | | Reporting | Older operational data and reference exports | Improved access to business data and aggregate insights | ### Transition Rules Enter new applicant, host, placement, and program updates in Globus, not Hanover, unless there is a documented exception. / Use Hanover only to look up historical data, or for a specific legacy process that has not yet moved to Globus. / When a workflow exists in both systems, complete it in Globus. Hanover is no longer the system of record. / If a record is missing or conflicts between systems, treat Globus as authoritative and escalate the discrepancy to your team lead. > **Data migration** > > Historical Hanover data may be retained for reference, migration, validation, or audit purposes. Where data has been migrated into Globus, Globus is authoritative; records that still require a Hanover lookup are the documented exceptions. Access to Hanover may still be required for some roles depending on the need for historical reference. ## Architecture Principles The architecture is designed to scale across brands, programs, seasons, and future system expansions including finance, reporting, and analytics. ## Inviolable Laws These rules are absolute and must never be violated by any agent or contributor. They define the non-negotiable boundaries of the platform. ## System Landscape The ecosystem consists of core operational systems and supporting infrastructure layers. ### Core Systems ### Supporting Systems ## Tech Stack & Quality The platform is built on a modern, tested, production-grade stack with rigorous quality gates enforced on every commit. ## Design Goals > **Key Design Goals** > > The system must support multi-brand operations from day one. Each brand operates independently with its own programs, seasons, partners, and compliance rules — while sharing a unified platform for governance, reporting, and technology. Establish Globus as the unified sponsor back office across all brands / Unify all sponsor operations under a single governance model / Support independent brand operations with shared infrastructure / Enable scalable partner recruitment across programs / Maintain SEVIS compliance across all J-1 programs / Provide executive-level visibility across the entire ecosystem ## Strategic Summary This is not a CRM. This is a Global Sponsor Operating Platform with controlled external distribution networks — an ERP for J-1 sponsor operations. Decoupled systems with clear boundaries / Centralized authority in a single core / Time-bound partner governance / Quota-based fairness controls / Post-SEVIS transition model / Field-level permission enforcement / No hardcoding / No annual duplication --- --- title: "Ecosystem Model" slug: ecosystem section: Overview & Architecture status: stable description: "How all systems connect, communicate, and share authority" last-updated: 2026-10-07 --- # Ecosystem Model How all systems connect, communicate, and share authority ## Mental Model > **Think of it this way** > > Each system has a clear identity and purpose. No system overlaps in authority. Globus is the brain. Portals are the hands. n8n is the nervous system. The ecosystem supports multi-brand governance across all sponsor brands: Alliance Abroad / Wild Packs / Teacher Lounge / StepWest / GeoVisions ## Ecosystem Authority Diagram Authority flows downward. Globus is the sponsor core — the single system of record and compliance authority. Portals are experience layers that read and write through Globus APIs. Government SEVIS integration sits above Globus. Government SEVIS submissions status Sponsor Core Globus Alliance Abroad / Wild Packs / Teacher Lounge / StepWest / GeoVisions Programs & Seasons / Applications / Workflow Engine / Rules Engine / Enforcement / RBAC / Job Order Engine / Quota Engine / Partner Agreements / Audit Log / SEVIS Integration reads / writes reads / writes reads / writes Experience Layer Applicant Portal Dynamic Workflow / Doc Upload / SEVIS Check-in / Monthly Check-ins Experience Layer Host Portal Job Order Mgmt / Placement Conf / Evaluations / Arrival Confirm Experience Layer Partner Portal Intake Mgmt / Bulk Upload / Job Order View / Post-SEVIS job order reservations via Globus CRM HubSpot Lead capture + Pre-qual qualified leads to Applicant Portal Orchestration n8n Automation layer triggers from Globus state changes Authority (Globus) Experience layer Supporting system Data flow ## Globus (Sponsor Core) > **Want to see what Globus replaces and how?** > > For a capability-level view of Globus as a modular ERP, see Globus Capability Layers . Globus is the system of record for all sponsor operations. Every operational decision, compliance check, and data point ultimately lives in or flows through Globus. It is the sponsor back office and serves as the central hub that all other systems connect to. Globus manages: Brand and program configuration / Season management and quota allocation / Applicant operational records (post-portal submission) / Partner agreements, quotas, and compliance / Host organization management / SEVIS data submission and DS-2019 lifecycle / Role-based access control (RBAC) / Reporting and analytics ## Applicant Portal The Applicant Portal is the participant-facing experience layer — a guided data capture and journey interface where applicants apply to exchange visitor programs, submit required documents, complete program steps, and track their progress from application through program completion. > **Current Status** > > The Applicant Portal for Camp Counselor and Summer Work & Travel is being built by an external development team. It integrates directly with Globus as the back office. Powered By Globus workflow engine and step library schemas / Program + Season configuration / Rules engine outputs (eligibility, compliance gates) Capabilities Dynamic step rendering based on program + season configuration / AI-assisted document extraction and verification / Status progression and workflow milestone tracking / SEVIS unlock logic (applicant cannot bypass compliance gates) / SEVIS check-in (applicant confirms active status for SEVIS compliance) / Monthly check-ins during active program phase / DS-2019 visibility (view-only, issued by Globus) / Brand white-labeling per sponsor brand / Direct and partner-referred entry paths ### Applicant Boundaries > **Design Principle** > > The Applicant Portal is a guided data capture and experience layer. It renders what Globus configures. It does not make decisions. ## Host Portal The Host Portal serves host organizations — employers, schools, or organizations that place exchange visitors. Hosts manage their job orders, confirm placements, update housing and logistics, submit evaluations, and monitor assigned participants. > **Current Status** > > The Host Portal for Camp Counselor and Summer Work & Travel is being built by an external development team. It integrates directly with Globus as the back office. Capabilities Create and manage job orders / Confirm placements / Update housing information / Submit evaluations / View assigned participants / Confirm arrival compliance ### Host Boundaries > **Design Principle** > > The Host Portal is the operational confirmation layer. It captures host-side facts. It does not make eligibility or compliance decisions. ## Partner Portal The Partner Portal is the recruitment distribution network — the interface through which recruiting partners submit applicants, manage their allocated quotas, and track applicant progress through the program lifecycle. > **Partner Organization Taxonomy** > > In the Globus ecosystem, “Partner Organization” is a single concept with two categories: (1) Employer (Host) — managed through the Host Portal, and (2) Recruiter — managed through the Partner Portal. Recruiters include both external third-party recruiting companies and internal sponsor staff (sales offices, regional recruiting teams). Internal recruiters use the same Partner Portal as external recruiters but may have different RBAC configuration. This keeps Globus focused on back-office processing while all recruiting workflows — internal or external — stay in the Partner Portal. > **Partner Portal Dependency** > > The Partner Portal is critical for Teach programs (TeachUSA, Teach Intern/Trainee) which rely heavily on external partner recruiting. Camp programs also benefit from the Partner Portal when internal sponsor recruiting teams use it to complete applications on behalf of applicants. Applicant creation and bulk upload / Quota visibility and reservation / Limited applicant profile editing / Recruitment attribution tracking / Post-SEVIS milestone visibility (read-only) ## Job Order → Placement Flow The placement flow spans three systems — Host Portal, Globus, and Partner Portal. This is the unified sequence from job order creation through confirmed placement. s capacity., 3 / System / Activates capacity / Upon approval, the job order > **Governance** > > Every step is audit logged, season-aware, and permission-driven. Reservations respect partner quota limits and agreement scope. ## Integration Summary The ecosystem connects to several external platforms. Each integration has a dedicated page with full scope details. ## Infrastructure Evolution The ecosystem infrastructure is evolving to support the production workload. Two key migrations are underway: CI/CD on GitHub Actions For the Globus Hub (BackOfficePrototype), build and deployment run on GitHub Actions (quality-gate.yml; deploy-feature-request.yml / trusted-deploy-dev.yml for `feature/*` branches onto a `globus-dev-N` pool slot, deploy-release-qa.yml for `release/*` branches onto a `globus-qa-N` pool slot, and deploy-prod-bluegreen.yml for `main`, a GKE Gateway blue-green cutover that production moved to on 2026-10-05): images are built into Artifact Registry and rolled out to GKE with kubectl using the manifests under `k8s/`. The former deploy-development.yml, deploy-qa.yml and deploy-production.yml were disabled on 2026-10-05. See Architecture Diagram — CI/CD for details. Codebase → DDD Module Decomposition The Globus codebase is not yet decomposed into DDD modules: `server/` is organised as flat `routes/`, `services/`, `storage/` and `lib/` directories. Per-capability bounded contexts (each with its own routes, services, repositories, and domain models, communicating through domain events rather than direct imports) are the target structure. See Architecture Diagram — DDD Modules for the full module table. --- --- title: "Architecture Diagram" slug: architecture-diagram section: Overview & Architecture status: stable description: "Complete solution blueprint — all systems, integrations, and data flows in a single view" last-updated: 2026-10-07 --- # Architecture Diagram Complete solution blueprint — all systems, integrations, and data flows in a single view ## Solution Overview > **Globus decides. Portals reflect.** > > The entire ecosystem follows one principle: Globus is the system of record. External portals capture data and present decisions — they never make authoritative decisions independently. All business logic, eligibility rules, compliance gates, and approval workflows live in Globus. The architecture spans four tiers: External Entry Points (how data enters the system), the Globus Core Stack (10 capability layers processing all business logic), Integration Systems (external services Globus orchestrates), and Operational Outputs (what the system produces). Each click navigates to the detailed documentation. [](/arch_final_clean.png) Click image to view full size. Last updated: 2026-06-27 (Post-Audit) External Entry Points Globus Core — Capability Layers Integration Systems Operational Outputs ## External Entry Points Four external systems feed data into Globus. Each has a defined API contract with specific endpoints, authentication requirements, and event flows. | Entry Point | Type | Primary Endpoint | Auth | Key Event | | --- | --- | --- | --- | --- | | HubSpot (via n8n) | Workflow trigger (push) | POST /api/integrations/onboarding/provision | x-api-key (integrations.onboarding scope) | applicant provisioned in Globus + invite token | | Applicant Portal | API (bidirectional) | /api/portal/* | integrations.portal (X-API-Key) | travel_request.*, step_completed | | Partner Portal | API (bidirectional) | /api/partner/* | portal.partner_proxy | nomination.submitted, reservation.created | | Host Portal | API (bidirectional) | /api/partner/host/* | portal.host_proxy | host.arrival.create, host.reservation.hire | > **Globus-First Intake (as-built)** > > For applicant onboarding, Globus creates the applicant: n8n calls POST /api/integrations/onboarding/provision on the Hub, which mints the applicant, application and portal invite token in one call. The full as-built intake flow — provision, invite delivery, token preview, activation and the first-login agreement gate — is documented in Direct vs Partner Entry. Implementation ground truth: PPPToo Architecture. ## Globus Core Stack Globus is organized into 10 capability layers (L0–L9). Lower layers must stabilize before upper layers build on them. The Phase A exit criterion is a full operational run of the Camp Counselor program inside Globus. | Layer | Name | Responsibility | Project | Status | | --- | --- | --- | --- | --- | See Globus Capability Layers for detailed technical specifications of each layer. ## Integration Systems Globus orchestrates five external systems. Each integration follows a defined contract with clear ownership boundaries. #### Private OCR (GCP Document AI) Passport OCR inside Google Cloud over a private network path — replaces the OpenAI GPT-4o public OCR call. ID_PROOFING_PROCESSOR in dedicated globus-ocr project / Private Service Connect — no public OCR API / NetworkPolicy: only ocr-service-caller pods egress / Cross-project Workload Identity (no key files) / CMEK globus-docai-cmek + us residency Project: P8 · See Private OCR #### SEVIS & Compliance Gateway U.S. government integration for J-1/J-2 visa compliance. DS-2019 issuance & amendments / SEVIS status sync (active, terminated, no-show) / Check-in compliance tracking / Site visit management / SEVIS certificate generation Project: P10 · See Capability Layers #### NetSuite (Finance ERP) Financial system of record — NetSuite-first approach. Customer sync at first payment / Fee catalog management / Invoice generation & pulling / Payment status tracking / Multi-currency support Project: P9 · See Finance & NetSuite #### Keycloak (Identity Provider) Production authentication — replaces Replit Auth prototype. OIDC + Client Credentials flow / Realm-per-brand multi-tenancy / Social login brokering (Google, Facebook) / Keycloak = identity, Globus = authorization / API contract unchanged during migration Project: P1 · See Integration Contract #### n8n (Automation Engine) Workflow automation — the ecosystem's nervous system. Scheduled eligibility rechecks / Compliance deadline monitoring / External system sync orchestration / Error handling & retry cascades / Data transformation pipelines Project: P8 · See n8n Automation Layer ## Operational Outputs Five operational domains emerge from the Globus core and its integrations. These represent the business value the system delivers. | Domain | Project | Key Capabilities | Dependencies | | --- | --- | --- | --- | | On-Program Lifecycle | P23–P30 (8 projects) | Check-ins, Program Extensions, Program Renewals, travel approvals, terminations | P5, P10, P11 | | Reporting & Insights | P15 | Executive dashboards, compliance reports, data exports | All projects | | Housing & Accommodation | P14 | Housing assignments, inspections, host family matching | P4, P5 | | Quota & Capacity | — | Quota allocation, fill rate tracking, workforce intelligence | P1, P5 | | Partner Management | P12 | Partner models, agreements, performance scoring | P1 | ## Coordination & Tracking Two systems sit outside the core architecture but coordinate the ecosystem's development and alignment. #### MCP Coordination Server Ecosystem-wide state synchronization. Ensures all portals operate on current architecture. 3 portals registered (Partner, GLOBUS Hub, Governance Hub) / Heartbeat monitoring (current/stale detection) / Sync status with change summaries / Architecture change propagation Endpoint: mcp.allianceabroad.com/mcp · Portal ID: 3 #### Asana Portfolio Project tracking across the full development scope. 117 tracked Asana projects across 6 delivery goals / Live task counts on the Delivery Overview page / Dependency-ordered build sequence / Live portfolio dashboard on Roadmap page View Delivery Overview → ## Data Flow Paths Key data flows across the ecosystem. Each arrow represents a defined contract with specific payload and authentication. | Flow | From | To | Mechanism | Key Payload | | --- | --- | --- | --- | --- | | Applicant Onboarding (as-built) | HubSpot via n8n | Globus → email invite → Applicant Portal | POST /api/integrations/onboarding/provision → invite_url | contact + program → applicant, application, invite token | | Travel Request | Applicant Portal | Globus | POST /api/travel-requests/receive | 14-field push payload | | Travel Decision | Globus | Applicant Portal | Webhook (HMAC) | travel_request.approved/rejected | | Position Reservation | Partner Portal | Globus | POST /api/partner/reservations | applicant_id, reservation details | | Placement Confirmation | Globus | Host Portal | Webhook | applicant.placement_confirmed | | SEVIS Sync | Globus | SEVIS | API (batch) | DS-2019, status updates, amendments | | Invoice Sync | Globus | NetSuite | REST API | Customer, fee events, payment status | | Arrival Confirmation | Host Portal | Globus | POST /api/partner/host/arrivals (PATCH /:id to update) | Arrival date, housing status | See Data Flow Between Systems for the full data flow documentation. ## Dual Authentication Globus implements a dual authentication pattern supporting both browser-based staff users and external system integrations. #### Session Cookies Browser-based staff user authentication. 7-day TTL with rolling expiry / PostgreSQL session store (connect-pg-simple) / Session token fallback via Authorization: Bearer header / Secure, httpOnly, sameSite cookie attributes #### API Keys External integration authentication. X-API-Key header for portal \u2192 Globus auth / SHA-256 hashed storage (never stored in plain text) / Scope-enforced access control per key / Per-portal type scoping (applicant, host, partner) > **MFA Support** > > Multi-factor authentication is supported via TOTP (Time-based One-Time Password) with configurable per-role enforcement. MFA can be required for sensitive roles such as super admins and compliance officers. ## Standardized API Contract All Globus API endpoints follow a standardized contract ensuring consistency, traceability, and safe retries across the platform. ## Dual Event Outbox Globus uses two separate event outbox tables to cleanly separate external integration events from internal domain events. #### ops_events (External) Events for n8n webhooks and external integrations. Pushed to n8n webhook URLs immediately on emit / Best-effort delivery with configurable retries / Dead-letter queue for failed deliveries / Admin dashboard for monitoring delivery health / Enriched payloads with applicant/program context #### event_outbox (Internal) Domain events for cross-context consistency. Internal domain events within Globus bounded contexts / correlationId for grouping related events / causationId for event chain tracing / Supports aggregate-level event sourcing patterns / Used for cross-module communication without direct imports > **Why Two Outboxes?** > > External events (ops_events) are optimized for webhook delivery with retry semantics. Internal events (event_outbox) are optimized for domain consistency with correlation/causation chains. Merging them would compromise both use cases. ## Authentication Architecture Authentication follows a phased migration path. The API contract remains stable across both phases. Retired — Prototype Era #### Replit Auth + API Key Replit Auth handled identity during prototyping (retired — applicant portal now uses same-origin Keycloak /sign-in) / X-API-Key header for portal→Globus auth (still current) / API key scoped per portal type / Do not build new logins against Replit Auth Target — Production #### Keycloak OIDC Keycloak handles identity (OIDC + social login) / Client Credentials for service-to-service auth / Realm-per-brand multi-tenancy / Globus still owns authorization (RBAC) > **Key Separation** > > Keycloak = Identity (who are you?) · Globus = Authorization (what can you do?). Social login (Google/Facebook) is a target-state option via Keycloak identity brokering — not shipped today — and would not change the Globus API contract, the HubSpot webhook payload, or the structure of the Globus-emitted applicant.created event. ## CI/CD & Deployment The build and deployment pipeline runs on Google Cloud Build with quality gates enforced on every commit. Cloud Build provides native access to GCS, Cloud Run, and Secret Manager without cross-cloud credential management. ### Cloud Build Pipeline Active Standard #### GitHub Actions + GKE Native GCP integration (no cross-cloud credentials) / GitHub Actions workflows deploy feature/* branches to a dev pool slot, release/* branches to a QA pool slot, and main to production by blue-green cutover (sandbox branching model since 2026-10-06) / Automatic GKE rollouts via kubectl apply / Secret Manager integration for env vars / Built-in container registry (Artifact Registry) / Build steps run in isolated containers > **Quality Gates** > > Every commit must pass the full quality gate pipeline before deployment: Type Check \u2192 Lint \u2192 Test (7,711+ tests) \u2192 Docker Build. No step can be skipped or bypassed. ### Deployment Infrastructure #### Runtime Environment Docker containers on GKE (globus-app deployment; k8s/development, k8s/qa, k8s/prod) / Horizontal pod autoscaling (HPA) / Rolling updates gated by kubectl rollout status / Secret Manager for environment variables / Artifact Registry for container images #### Session Management PostgreSQL-backed sessions via connect-pg-simple / 7-day TTL with rolling expiry window / Sessions persist across deployments and container restarts / No in-memory session state (stateless containers) / Session ID usable as Bearer token for API clients ## DDD Module Decomposition Status: this is a conceptual decomposition, not the physical code layout. In BackOfficePrototype, server/ is organised as flat routes/, services/, storage/ and lib/ directories and shared/models/ is split by context; there are no per-module directories, no shared/kernel package, and tables are not module-prefixed (for example applicant_documents). The Globus codebase follows a Domain-Driven Design (DDD) module structure. Each capability layer maps to a bounded context with clear ownership, internal models, and a public API surface. This decomposition ensures that changes in one domain do not cascade unexpectedly to others. | Module | Bounded Context | Aggregates | Layer | | --- | --- | --- | --- | | identity | Identity & Access | User, Role, Permission, Session | L0 | | programs | Program Catalog | Program, ProgramRule, VisaCategory | L1 | | seasons | Operational Cohorts | Season, Intake, Capacity, Lifecycle | L2 | | applicants | Participant Records | Applicant, Application, Document, J2Dependent | L3 | | workflows | Workflow State | Journey, Step, StepCompletion, Evidence | L4 | | rules | Business Rules | RuleSet, RuleEvaluation, ComplianceGate | L5 | | job-orders | Job Orders & Capacity Management | JobOrder, JobOrderRequirement, CapacityCounter | L6 | | matching | Matching & Reservations | Match, Reservation, Placement | L7 | | comms | Communications | Template, Notification, DeliveryRecord, EventOutbox | L8 | | automation | Automation & Integrations | WorkflowTrigger, ExternalSync, WebhookSubscription, RetryPolicy | L9 | Each module has its own directory with routes, services, repositories, and domain models / Cross-module communication uses domain events (via the Event Outbox), not direct imports / Shared kernel types (IDs, enums, value objects) live in a shared/kernel package / Database tables are prefixed by module (e.g., seasons_intake, applicants_documents) / Module boundaries align with the 10-layer capability stack — one module per layer > **DDD Aligns with Capability Layers** > > The DDD module structure is not an accident — it directly mirrors the 10-layer capability stack. This means a developer working on “seasons” operates within a single module that maps to Layer 2, with clear boundaries and no need to navigate unrelated domain code. ## Build Status Summary Current build phase across all tracked projects. The architecture follows an "Architecture First, Not UI First" philosophy — lower layers stabilize before upper layers build. See Delivery Overview for the live Asana portfolio dashboard and detailed project breakdown. --- --- title: "Sequence Diagrams" slug: sequence-diagrams section: Overview & Architecture status: stable description: "Interactive sequence diagrams for every major cross-system flow in the ecosystem — from applicant registration through compliance, placement, and on-program operations." last-updated: 2026-10-07 --- # Sequence Diagrams Interactive sequence diagrams for every major cross-system flow in the ecosystem — from applicant registration through compliance, placement, and on-program operations. ## Overview Each diagram below shows a specific interaction sequence between the systems in the Globus ecosystem. Participants are shown as columns, numbered steps flow top-to-bottom, and highlighted bars mark key user touchpoints or state changes. > **System Boundaries** > > These diagrams complement the Architecture Diagram (structural view) and Data Flow Between Systems (data journey view). Sequence diagrams show the temporal ordering of interactions between specific systems for a given use case. Diagram Index > **Attribution Lock** > > Once an applicant enters through a partner invite, the partner_id is permanently locked on the operational record in Globus. This prevents downstream attribution disputes and ensures commission/recruitment tracking is immutable. > **Globus is the Only SEVIS Writer** > > No portal or automation layer may write to SEVIS directly. Globus is the sole system with SEVIS API credentials and the authority to submit or amend participant records. This is a hard compliance boundary. > **n8n Never Decides Globus State** > > n8n is a side-effect orchestrator, not an authority. It reads events from Globus and triggers external actions, but it never decides or advances Globus state. Its only writes back into Globus go through explicit integration endpoints (for example POST /api/integrations/onboarding/provision and PATCH /api/notifications/:id/delivery-status). --- --- title: "AAG MCP @ Connector Onboarding" slug: onboarding-mcp-connector section: Developer Onboarding status: stable description: "A guided way for new contributors to explore the Globus ecosystem, architecture, integrations, and operating model through grounded questions and answers." last-updated: 2026-10-07 --- # AAG MCP @ Connector Onboarding A guided way for new contributors to explore the Globus ecosystem, architecture, integrations, and operating model through grounded questions and answers. ## Purpose The AAG MCP @ connector is a core onboarding tool for new Globus contributors. It provides a guided way to explore the Globus ecosystem, architecture, integrations, workflows, and operating model through grounded questions and answers. The connector is not intended to replace documentation. Instead, it makes the existing documentation more accessible by allowing new team members to ask questions conversationally and discover how systems, processes, and architectural decisions fit together. Rather than relying solely on tribal knowledge, historical meeting notes, or scattered documentation, new contributors can use the connector to build ecosystem understanding while onboarding. Examples of useful onboarding questions include: - How do HubSpot and Globus work together? - What is the difference between a Program and an Operational Cycle? - What role does n8n play in the architecture? - Which system owns applicant communications? - What is the Globus SDK? - How do the Applicant, Host, and Recruiting Partner portals interact with Globus? This onboarding approach reflects the team’s goal that new contributors should be able to explore not only what the system does, but also why certain architectural and product decisions were made. ## Connector Setup During onboarding, all technical contributors should be granted access to the AAG MCP @ connector within ChatGPT. Open ChatGPT Settings. / Navigate to Apps. / Search for AAG MCP @. / Connect the application. / Authenticate using the company Microsoft account through Entra. / Start a new chat. / Explicitly select AAG MCP @ as the active source before asking Globus-specific questions. > **Select the connector as the active source** > > The connector must be selected as the active source. If it is not selected, ChatGPT may respond using general knowledge rather than Globus ecosystem knowledge. ## What the Connector Should Explain The connector should provide onboarding support across the major areas of the Globus ecosystem. | Area | Knowledge Available | | --- | --- | | Business Model | Hosts, applicants, recruiting partners, programs, placements, support workflows. | | Legacy Context | Why Globus is replacing Hanover and the architectural limitations being addressed. | | Core Data Model | Programs, Operational Cycles, workflows, applicants, hosts, placements. | | Portal Architecture | Applicant Portal, Host Portal, Recruiting Partner Portal. | | Integration Model | HubSpot, n8n, NetSuite, SEVIS, SDKs, MCPs, Data Lake. | | Workflow Model | Workflow Studio, enrollment workflows, lifecycle states, workflow versioning. | | Development Process | Replit environments, GitHub branches, Asana tickets, acceptance criteria. | | AI-Assisted Delivery | ChatGPT, Manus, MCPs, connectors, and AI-supported development workflows. | | Known Documentation Drift | Areas where implementation and documentation may not yet fully align. | ## Understanding HubSpot and Globus One of the most important onboarding topics is understanding system ownership. At a high level: HubSpot owns - Lead capture - Marketing funnels - Pre-qualification - CRM activities - Applicant communications - Early-stage prospect management Globus owns - Qualified applicant records - Applications - Operational workflows - Placement processing - Program participation lifecycle - Operational execution n8n - Event orchestration - System-to-system integrations - Workflow automation - Data synchronization Globus SDK - Standardized access to Globus services - Shared interfaces used by portals and applications - Consistent integration patterns across the ecosystem The connector should help explain not only the ownership boundaries but also how information moves between systems through onboarding links, events, workflows, and integration pipelines. ## Recommended First Questions New contributors should begin onboarding by asking questions such as: - Give me a practical overview of the Globus ecosystem. - How do HubSpot and Globus work together? - What problems is Globus solving compared to Hanover? - Explain Programs, Seasons, and Operational Cycles. - What is n8n responsible for, and what should it not be used for? - What are the primary portals connected to Globus? - What is the Globus SDK, and how do portals use it? - What should I know before picking up a Globus development ticket? - What documentation is known to be stale or drifting from implementation? These questions provide a useful foundation before working on features, integrations, workflows, or operational tooling. ## Expected Connector Behavior When responding to onboarding questions, the connector should: ## Documentation Drift & Verification > **The connector is a learning aid, not an unquestionable source of truth** > > Some ecosystem documentation may lag behind implementation. Treat connector answers as a starting point and verify before making changes. Known areas of terminology and architectural drift may include: - Program vs Season terminology - Operational Cycle terminology - Operational Cohort terminology - Legacy architecture diagrams - Historical process documentation When information appears inconsistent, contributors should verify against: - The current Globus environment - The active GitHub branch - The relevant Asana ticket - The latest product requirements - The responsible product or technical owner ## Onboarding Success Criteria By the end of onboarding, contributors should be able to independently answer: > **The onboarding question** > > Where does this fit within the Globus ecosystem, which system owns it, what documentation explains it, and what implementation details must be verified before making a change? The AAG MCP @ connector serves as a living knowledge layer that helps developers, product managers, operations users, and future contributors answer those questions consistently — accelerating onboarding and reducing dependence on tribal knowledge. --- --- title: "MCP Gateway" slug: mcp-gateway section: Developer Onboarding status: stable description: "One MCP endpoint for every Alliance Abroad tool." last-updated: 2026-10-07 --- # MCP Gateway One MCP endpoint for every Alliance Abroad tool. Configure it once with your personal token and the gateway routes each tool call to the correct backend (Teams, GCP, GitHub, Globus, Nexus, GoDaddy, WordPress, and more) — injecting the right credential and enforcing your role. > **This replaces the old MCP Shop** > > You no longer add each MCP server individually. Add the single gateway below; it aggregates all backends behind role-based access. ## Overview The gateway is a single Streamable-HTTP MCP endpoint. It authenticates you (Google SSO for humans via the admin portal, or an `aag-pat-` Personal Access Token for agents/robots), resolves your role-based toolpack, and proxies each call to the right upstream MCP — without ever forwarding your token to backends. - Gateway MCP endpoint: https://mcp.allianceabroad.com/mcp - Admin / setup portal: https://mcp-admin.allianceabroad.com (Google SSO, superadmin-only — humans never call `/mcp` directly; agent PATs are issued from here) ## Why a single gateway - Configure one server instead of two dozen — no per-backend URLs or keys to manage. - Role-gated: you only see the tools your role permits. - Credentials stay server-side; your JWT/PAT is never passed to backends. - Every call is audited. ## Step 1 — Get a token Agents and robots authenticate with a Personal Access Token (PAT). Create one in the admin portal: - Open the admin portal and sign in with your Alliance Google account. - Go to Users & Roles → Robot PATs → New Robot PAT. - Pick a label and roles, create it, and copy the token — it is shown only once (starts with `aag-pat-`). - An owner/superadmin caller can instead mint a robot PAT from an MCP session with `gateway_admin_create_robot_pat`. Any role name that is not yet registered is registered automatically in the same transaction (so it can immediately be granted permissions), and `gateway_admin_create_role` registers a role directly without minting a robot. > **Handle tokens safely** > > Never commit a PAT. Store it in 1Password / Secret Manager. It carries whatever roles you granted it. ## Step 2 — Add one MCP server Add a single HTTP MCP server that passes your PAT as an `Authorization: Bearer` header. This works in Claude, Cursor, Manus (Custom MCP → Import by JSON), n8n, and any MCP client that speaks Streamable HTTP. ```json { "mcpServers": { "alliance-gateway": { "url": "https://mcp.allianceabroad.com/mcp", "headers": { "Authorization": "Bearer aag-pat-..." } } } } ``` ## Step 3 — First calls Once connected, the gateway tells the agent what to do via its MCP `initialize` instructions. The mandatory startup sequence: - If unsure a tool exists, call `gateway_search_tools` with a keyword first — cheaper than scanning the full `tools/list`. - Call `gateway_getting_started` and follow it exactly. - Call `gateway_list_skills`, then `gateway_get_skill` for the skills relevant to your task. - Only then use domain tools, in the documented order — for example, read the Nexus documentation via the `ecosystem_*`/`nexusdocs_*` docs tools before taking any `ecosystem_*` write action. ## Auth model Every backend has an auth mode: - **Shared** — one org-wide credential, no per-user concept: `hubspot`, `ecosystem` (Nexus Hub), `nexusdocs`, `platformhub`, `wordpress`, `itrequest` (IT Request Portal) and `replitssh` (Replit SSH, owner role only). - **Personal** — the default. The gateway forwards YOUR per-user token so you act as yourself: `teams`, `gcp`, `gcplogs`, `auth0`, `azure`, `gworkspace`, `posthog`, `asana`, `manus`, `godaddy`, `1password`, `openstatus`, `outlook`, `replit`, `globus`, `github`. - **Hybrid fallback** — for most personal backends, if you haven't connected your own account the gateway falls back to the shared service credential so the tool still works now, then upgrades to your personal token once you connect. - **PersonalOnly (no fallback)** — `outlook`, `replit`, `teams` and `asana` have no shared fallback for human callers and hard-fail with "requires authorisation" until you connect your own account. (`teams_send_bot_channel_message` is the one Teams tool that works without a connected account, because it posts as the bot.) Asana was made PersonalOnly so that tasks created through the gateway are attributed to the person who made them rather than to whoever owned the shared token. - **Robot exception for Asana** — a robot (PAT) caller has no human account to connect, so for `asana` it skips the must-connect gate and is served on asana-mcp's shared credential, which is intended to be the dedicated platform bot Asana identity, never a person's token. The gateway does not send a per-user Asana token on that path, marks the request as a robot fallback for asana-mcp, and asana-mcp only honours the marker together with the gateway's shared API key. A connected account always wins; humans and the other PersonalOnly backends are unchanged. ### Connecting your own account Call `gateway_connect_url` to get a one-click browser link for connecting a personal backend — it walks you through the provider's OAuth consent screen, or (for API-key backends) a paste-token form: `globus` takes your `glb_` key, `1password` your service-account token, `github` your `ghp_`/`github_pat_` token. Pasted tokens are validated live before storage. All tokens are AES-256-GCM encrypted at rest, scoped strictly per user, and never visible to another caller. OAuth consent is wired for `teams`, `outlook`, `gworkspace`, `gcp`, `gcplogs`, `asana` and `replit`. `gcplogs` reuses the same Google web client and `cloud-platform` scope as `gcp` (callback `/auth/callback/gcplogs`) but is a separate connection, so a caller can connect one without the other, and an unconnected caller keeps the shared GCP fallback for that backend. ## What you get Backends are exposed as prefixed tools — `gcp_*`, `gcplogs_*`, `teams_*`, `github_*`, `globus_*`, `godaddy_*`, `ecosystem_*`, `nexusdocs_*`, `platformhub_*`, `wordpress_*`, `gworkspace_*`, `posthog_*`, `hubspot_*`, `outlook_*`, `auth0_*`, `azure_*`, `1password_*`, `asana_*`, `itrequest_*`, `replitssh_*`. Plus gateway-native tools, advertised to every authenticated caller regardless of role (the admin-style ones listed last are only usable by an owner/superadmin): - `gateway_getting_started`, `gateway_list_skills`, `gateway_get_skill` — onboarding and the skill library. - `gateway_search_tools` — keyword search across every tool your role grants; use this before assuming a tool doesn't exist. - `gateway_connect_url` — self-serve personal-account connect links (see above). - `gateway_rotate_pat` — rotate your own PAT when the gateway warns it's expiring. - `gateway_submit_ticket` / `gateway_get_tickets` / `gateway_submit_test_plan` — file and read tickets and QA test plans against the Development ticket board. These are always advertised, and degrade gracefully (a "board offline" message, no retry) if the upstream board isn't live. - `gateway_estimate_feature` — a read-only, deterministic planning calculator, available to every caller. You decompose the work into PR-sized tasks classified by shape (XS/S/M/L/XL) and it returns a planning object that keeps three clocks separate: agent wall-clock, attended orchestration hours (the capacity constraint) and calendar-to-hardened days (`committed_days` is the date to quote), plus confidence, risk, `prioritization.spikes_first` and a `model_version` stamp. It refuses to certify a single task for a date commitment (`usable_for_commitment=false`): estimate batches, not tasks. The method and constants are in the `estimation-model` skill, which must be updated together with the tool. - `gateway_tool_usage` — owner/superadmin only. Ranks tools by call volume from the audit log (30-day retention): calls, denied calls, distinct callers, average latency and owning backend per tool, aggregates only. Arguments: `days` (default 7, max 30), `limit` (default 15, max 100) and an optional `server_slug`. - `gateway_admin_list_permissions`, `gateway_admin_update_permissions`, `gateway_admin_create_robot_pat`, `gateway_admin_create_role` — MCP equivalents of the admin REST API for permissions, robot PATs and roles. They are listed in `tools/list` for every caller but checked inside the handler: a non-owner gets a permission error on `tools/call`. Updating permissions replaces the allowed-tools list for that role and backend rather than merging it. `nexusdocs_*` also carries the video library's two read tools, open to every caller like `searchDocs`: `nexusdocs_searchVideos` (`q`, optional `audience`, `program`, `maxDuration`, `updatedSince`, `limit`) finds clips by the question they answer, their chapter titles or transcript, and `nexusdocs_getVideo` (`videoId`) returns one clip's metadata, chapters with deep links and transcript. They are **link-only**: a result is a Nexus page link such as `/docs/video-approve-passport-scan?t=70` plus metadata, never a signed URL, bucket name or object path, because playback is authorised per signed-in viewer. There is no role grant to manage: the gateway proxies whatever the Nexus backend lists. The `help-video-routing` skill tells an assistant when to use them. Two backends added after launch are deliberately narrow: - `itrequest_*` (IT Request Portal, the Tech/IT request ticketing API): `create_ticket`, `list_tickets`, `get_ticket`, `list_freeze_windows`, `create_freeze_window`, `delete_freeze_window`. owner and admin get all six; member gets only the first four. Creating or deleting a deploy freeze window is admin/owner-only because a freeze window blocks deploys platform-wide. viewer and the service (`svc_*`) roles get nothing by default. - `replitssh_*` (Replit SSH): `exec`, `read_file`, `write_file`, `list_dir` over SSH into a Repl. This is arbitrary remote execution, so only the owner role has a grant; for every other role the absence of a grant is a hard deny. Call `tools/list` after connecting to see exactly what your role grants. ## Skills library and update notices The skills behind `gateway_list_skills` and `gateway_get_skill` are synced from GitHub into the gateway's skills table by the admin sync endpoint. Sources at `main`: the `skills` directory of `alliance-agent-config` (category platform), `deploy/skills/backoffice` in `alliance-mcp-gateway` (category backoffice, authored in the gateway repo and propagated to BackOfficePrototype by a separate publish step) and `deploy/skills/platform` in `alliance-mcp-gateway` (category platform, for example `estimation-model`). A skill's `updated_at` only advances when its content actually changes. - **Platform Pulse notice.** When a sync finds skills whose content changed, the gateway posts a short HTML notice ("MCP Gateway - Skills Updated", listing the changed skills) to the Platform Pulse channel through the bot identity, never a user token. The sync response also returns a `changed` list alongside `synced`, `failed` and `errors`. - **Signed skill-update directives.** An agent that already loaded a skill that has since changed is told through a signed directive on `result._meta.gateway_skill_update`, a sibling of the tool result `content` and never inside it, so a genuine gateway notice cannot be confused with instruction-like text a backend plants in its own output. The directive is an RS256 JWT of type `skill_update` (with `jti`, issuer, `aud` set to the caller identity, `iat`/`exp` and the changed skill name and version). The public keys are served at `/.well-known/skill-directive-jwks.json` so an MCP client adapter can verify a directive independently. Any `_meta.gateway_skill_update` a backend returns is stripped before the gateway's own value, if any, is applied. The feature is optional: with `SKILL_DIRECTIVE_SIGNING_KEY` unset no directive is attached and the JWKS route is not registered (there is no fallback to the old free-text banner). The wire schema and the validation algorithm for adapter authors are in `docs/skill-update-directive.md` in the repo. ## Operator Configuration Environment variables read by the gateway's admin UI, the two backends that fall back to a shared credential, and one gateway server setting (repo `alliance-mcp-gateway`). Read from `admin-ui/src/` and `backends/github-mcp` / `backends/globus-mcp`. | Variable | Component | Behaviour | |---|---|---| | `VITE_GOOGLE_CLIENT_ID` | admin UI | Enables the "Sign in with Google" fallback used when there is no IAP session (local development). Empty, or a value starting with `REPLACE_WITH`, counts as not configured. Sign-in is limited to the `allianceabroad.com` domain; there is no username/password path. | | `VITE_GATEWAY_URL` | admin UI | Base URL for the UI's API calls. Defaults to the same origin. | | `GITHUB_API_URL` | github-mcp | GitHub API base. Defaults to `https://api.github.com`; trailing slashes are stripped. | | `GITHUB_SERVICE_TOKEN` | github-mcp | Shared token used only when the caller supplies none. A caller's own token always wins. | | `GITHUB_DEFAULT_OWNER` | github-mcp | Owner assumed when a tool call omits one. Defaults to `Alliance-Strategies`. | | `MCP_APP_KEY` | github-mcp | Optional app-level gate. When set, requests must send a matching `x-api-key` header; when empty, no gate applies. | | `GLOBUS_SERVICE_API_KEY` | globus-mcp | Shared key used only when the caller supplies none. A caller's own key always wins. | | `SKILL_DIRECTIVE_SIGNING_KEY` | gateway | Signing key for skill-update directives. Optional: when unset, no directive is attached to tool results and `/.well-known/skill-directive-jwks.json` is not registered. | ## Notes & troubleshooting - 401 unauthorized: the `Authorization: Bearer` header is missing or the PAT is wrong. - "requires authorisation": that backend is personal and needs you to connect it first — call `gateway_connect_url` rather than hunting for a manual OAuth link. - A tool you expect is missing: your role does not grant it, or the backend is disabled — check the admin portal's Servers page. - Some older `ecosystem_get_*` tools tied to the pre-2026-07-16 build-health/n8n-status/agent-observatory dashboards were retired when those moved to Platform Hub and now return 410 Gone — look for a `platformhub_*` equivalent instead of retrying the old name. --- --- title: "Git Branching & Release Model" slug: git-branching-release-model section: Developer Onboarding status: stable description: "Trunk-based branching with no long-lived integration branch." last-updated: 2026-10-07 --- # Git Branching & Release Model Trunk-based branching with no long-lived integration branch. Every branch is cut from `main`. A feature branch deploys straight to a pooled **development** environment (3 slots). Ready features are merged into a named `release/` branch, which deploys to a pooled **QA** environment (3 slots). A `release/` → `main` merge, or a `hotfix/*` → `main` merge, is the only way into production, and it goes through a blue-green cutover. ## Status > **Implemented and verified in the `branching-sandbox` repo; adopted by WildPacksApplicationForm (2026-10-03), PPPToo (2026-10-04), HostPortalPrototype (2026-10-04) and BackOfficePrototype (2026-10-06). Revised 2026-10-06.** This page describes what the sandbox (`Alliance-Strategies/branching-sandbox`, with its infrastructure in `alliance-gcp-infra`) does and what the four converted repos inherited from it. It supersedes the 2026-09-22 revision, which kept a permanent `development` branch that features merged into. The model implements the opposite: **`development` is an environment, not a branch.** Sections below are marked **Implemented**, **Verified** (run end to end and observed), or **Not implemented** (design intent only), so nobody mistakes one for the other. The **Migration Plan** further down is done for all four repos; the items still open for BackOfficePrototype are listed under Phase 4. > > The sandbox is a **stateless static page**. It exercises branching, pooling, deploys and cutover. It does **not** exercise databases, migrations, or irreversible side effects (such as BackOfficePrototype/Globus SEVIS submissions, which a canary or rollback cannot undo). Those were the adoption risks for BackOfficePrototype, listed under Adoption Risks and worked through in the Migration Plan; BackOfficePrototype went live on the GKE edition of the model on 2026-10-06 (production cutover 2026-10-05). > > **PPPToo is live on this model since 2026-10-04** (Asana 1219058470201463): `main` is the default branch and production; `development`/`qa` are frozen legacy branches; `feature/*` pushes deploy to `dev-{1,2,3}.applicants.allianceabroad.com` (API at `dev-N-api.…`), `release/*` pushes to `qa-{1,2,3}.applicants.allianceabroad.com`, and `release/*` → `main` merges run a Cloud Run revision blue-green on the existing `ppp2-participant-{server,client}-prod` pair (server first, then client). All five proof runs (feature → dev slot, feature → release, release → QA slot, release → main blue-green with a forced-rollback test, hotfix → main) were observed live on 2026-10-04; see `docs/sandbox-model-proof-2026-10-04.md` in the repo. > > **HostPortalPrototype is live on this model since 2026-10-04 20:17 UTC** (Asana 1219139535095088): same shape as PPPToo with Auth0 instead of Keycloak. Pools `host-portal-{server,client}-{dev,qa}-{1,2,3}` in `us-central1` behind `dev-N` / `dev-N-api` / `qa-N` / `qa-N-api` `.hosts.allianceabroad.com`; production stays on `host-company-portal-{server,client}-prod` in `northamerica-northeast1` behind its existing load balancer; the first production blue-green (main `b27a182`) cut over both services with the previous revisions kept as `blue`. Rulesets: Branch names 24467828, Protect main 24464581, Protect release branches 24467830, Freeze legacy branches 24467833. Proven live: feature → dev slot, feature → release checks, release → main blue-green plus its automatic rollback (the first attempt failed the canary and restored traffic, see Known Limitations), hotfix → no deploy, protected-path block, branch-name allow-list. Still pending at the time of writing: the first `release/*` → QA slot deploy (its PR awaits a second approver), the two consumer PRs (vertex-agents #268, itrequestportal #111), and the dev Auth0 slot callbacks (the dev tenant's management clients lack `update:clients`). > **PartnerPortal is live on this model since 2026-10-26 19:55 UTC** (Asana 1219907982272590): the Globus recruiter Partner Portal, re-baselined that day on its Replit rebuild (Express 5 BFF over the Globus SDK + React SPA, one container, no database; PartnerPortal #36) and converted in the same session. Smallest pool so far, by decision ("3 slots total"): `partner-portal-dev-{1,2}` and `partner-portal-qa-1` in `us-central1` behind `dev-N` / `qa-1` `.partners.allianceabroad.com` (Cloud Run domain mappings), production `partner-portal-prod` behind its own HTTPS load balancer with a Certificate Manager DNS-authorized certificate on `partners.allianceabroad.com` (alliance-gcp-infra #430; the old GoDaddy A record pointed at a dead host). Rulesets: Branch names 25908861, Protect main 25908906, Protect release branches 25908913, Freeze legacy branches 25908917 (`development`, `qa`); release branch `release/2026-10-partner-portal-pilot`. Proven the same evening: feature push → dev-1 (`/api/version` reports the branch commit), feature → release PR gated by `quality-gate` + `branch-rules`, release push → qa-1, release → main (#43) → production plain deploy. Consumers: environment-index #44 (board row), itrequestportal #179 (Deployment Request), vertex-agents #380 (review eligibility, QA release dispatch, backport hygiene, PR latency). Not yet working: sign-in, because the Clerk and Globus partner keys in Secret Manager are placeholders until stored. ## Principle - `main` is the source of truth and maps 1:1 to production. - **Every branch is cut from `main`, except that in HostPortalPrototype and BackOfficePrototype a `feature/*` or `hotfix/*` branch may instead be cut from a `release/*` branch.** - There is no `development` branch. `development` is the name of an environment. - Environments are deploy targets chosen by the branch name, not branches themselves. - Production is reached only by a blue-green cutover. ## Branch Types | Branch | Cut from | Merges into | Lifetime | Deploys to | | --- | --- | --- | --- | --- | | `main` | — | — | Permanent | Production, via blue-green cutover (on every push to `main`). | | `feature/-` | `main` | a `release/` branch | Until merged into a release | Development, one of 3 pool slots (on every push). | | `release/` | `main` | `main` | Until merged into `main` | QA, one of 3 pool slots (on every push). | | `hotfix/-` | `main` | `main` | Very short-lived | Production only when its PR merges to `main`. It can also be tried in a development slot (push or dispatch) and a QA slot (manual dispatch); proven in the sandbox only, see [Hotfix Dev and QA Slots](#hotfix-dev-and-qa-slots). Everywhere else a hotfix push still deploys nothing. | Example: `feature/1218773865500269-branching-model`. ## Environments | Environment | Branch trigger | Pooled | Hostnames (sandbox / PPPToo / HostPortal) | GCP project | | --- | --- | --- | --- | --- | | development | `feature/**` | Yes, 3 slots, 30 min reservation, up to 24 h on request (BackOfficePrototype: **6** slots `dev-{1..6}.globus.allianceabroad.com` since 2026-10-15, Asana 1219535658787900 — the only pool that ran out) | `dev-{1,2,3}.sandbox.allianceabroad.com` / `dev-{1,2,3}.applicants.allianceabroad.com` + `dev-N-api.applicants.…` / `dev-{1,2,3}.hosts.allianceabroad.com` + `dev-N-api.hosts.…` | `skilled-script-469819-f2` | | QA | `release/**` | Yes, same mechanism, 3 slots | `qa-{1,2,3}.sandbox.allianceabroad.com` / `qa-{1,2,3}.applicants.allianceabroad.com` + `qa-N-api.applicants.…` / `qa-{1,2,3}.hosts.allianceabroad.com` + `qa-N-api.hosts.…` | `quality-assurance-496022` | | production | `main` | No, single service with weighted traffic | `sandbox.allianceabroad.com` / `applicants.allianceabroad.com` / `hosts.allianceabroad.com` + `hosts.wildpacks.com` (existing LB in front of the existing pair) | `production-499116` | Pool slots live in `us-central1` (Cloud Run domain mappings are not allowed in `northamerica-northeast1`). The sandbox's Artifact Registry is `northamerica-northeast1`; PPPToo keeps its existing `applicants-portal` registry in `us-east1`; HostPortal keeps `host-company-portal` (`us-east1`, dev and prod) and `portal` (`us-central1`, QA). PPPToo and HostPortal production stay in `northamerica-northeast1` behind their existing load balancers: the serverless NEG targets the service, so revision traffic splitting works there without touching the balancer. HostPortal's slots are mapped on `hosts.allianceabroad.com` only; the client chooses its brand from the hostname, so the WildPacks brand is not exercised on a slot. ## Flow ``` main ──┬── feature/- ──► development pool slot (dev-1..3; BOP dev-1..6) │ │ │ └── PR into a release branch (PR review agent: wired for PPPToo, HostPortal after vertex-agents #268) │ ├── release/ ──► QA pool slot (qa-1..3) ──► PR to main ──► blue-green ──► prod │ └── hotfix/- ──► PR to main ──► blue-green ──► prod │ │ │ └── hotfix-backport.yml: one feature/-backport-* PR per open release ├── push / dispatch ──► development pool slot (sandbox only so far) └── manual dispatch ──► QA pool slot, 2 h hold (sandbox only so far) ``` 1. **Build a feature.** Cut `feature/-` from `main` and push. It deploys to a development pool slot. 2. **Cut a release.** Cut `release/` from `main`. Open a PR from each ready feature branch into it. 3. **QA.** Every push to the release branch deploys it to a QA pool slot. 4. **Ship.** Open a `release/` → `main` PR. When it merges, `main` deploys to production via blue-green. 5. **Hotfix.** Cut `hotfix/-` from `main`, PR it straight to `main`. It does not have to pass through development or QA, but where the repo supports it (today only the sandbox) it can be tried in a dev slot and a QA slot first. Because feature branches are cut from `main` and merged into a release (not into a shared branch), a feature only meets other features inside the release branch it is merged into. ## Pools — Development and QA (Implemented, Verified) Development and QA each have three pre-provisioned Cloud Run services (`sandbox-web--1`, `-2`, `-3`; for PPPToo and HostPortal a server+client pair per slot, `ppp2-{server,client}--N` / `host-portal-{server,client}--N`, with the labels on the server service) with their own hostname. The service labels are the entire slot state, so there is no registry file or extra infrastructure: - `sandbox-owner` — the **ownership key**: `b-` plus the 40 hex characters of the SHA-256 of the full ref (`refs/heads/`). Two different branch names can never produce the same key, so one branch cannot renew or evict another's slot. This is the only label the allocator trusts. - `sandbox-branch` — sanitized, truncated branch name, **diagnostics only** (it is always a valid label value, so it can collide or be truncated without consequence). - `sandbox-reserved-until` — unix epoch when the reservation expires. A service that has no `sandbox-owner` label (for example one deployed before this scheme existed) counts as unowned and is eligible for the next claim. The page rendered into each slot HTML-escapes the branch name, so a hostile branch name cannot inject markup. On each push, `scripts/allocate-slot.sh` picks a slot in this order (`REQUESTED_SLOT=auto`, the default): 1. A slot this branch already holds — **renews** the reservation (30 minutes from now, or the later of that and the expiry it already has: a push never shortens a hold a requester asked for). 2. A slot whose reservation has expired (or never existed), oldest expiry first — **evicts** the previous holder. 3. Otherwise **fails the deploy**: every slot is reserved by another branch. The job prints each slot's holder and expiry. After claiming, it re-reads the labels and retries (up to 5 attempts) if another branch won the race. The reservation is renewed by pushing, not by time passing. In WildPacksApplicationForm the pool is not a hand-kept list: unless the caller pins `SLOTS`, `scripts/allocate-slot.sh` reads it at run time as the set of `-` Cloud Run services that exist (Terraform creates one per slot). If no such service is found, the script exits with an error. For `ACTOR`, `REQUESTED_BY` wins; the trusted workflow passes `github.actor`, or the `requested_by` dispatch input only when the GitHub App dispatched. With `REQUESTED_SLOT=` (the "Choose slot" option of the Tech Request Portal and the `slot` input of the deploy workflows) the allocator claims exactly slot ``, in one attempt and with no fallback to another slot. `` must be in `SLOTS`. The claim is refused when a different branch holds an unexpired reservation on the slot, when the service cannot be read, or when another branch wins the claim race. A slot this branch already holds is renewed; an expired reservation is taken over. **Requested hold (Asana 1219953281858269, 2026-10-28).** The same dispatch may set `hold_minutes`: `deploy-feature-request.yml` takes 30 (the push default) to 1440 for a development slot, the release workflow (`deploy.yml`; BackOfficePrototype `deploy-release-qa.yml`) takes 1440 (the release default) to 10080 for a QA slot. The trusted/deploy workflow bounds the value before anything deploys and hands it to the allocator as `TTL_MIN`; the allocator writes the expiry it chose to `GITHUB_OUTPUT` as `reserved_until` and the deploy step labels with exactly that, so the two never disagree. A push always deploys with the default, and because renewal keeps the later expiry a push never cuts a longer hold back. The Tech Request Portal offers the choice on its Dev and QA Deployment Requests (QA through the Dev-to-QA Sync Agent `holds` map); the hotfix QA path keeps its 2 h hold. ## CI/CD Trigger Logic (Implemented) One workflow, `.github/workflows/deploy.yml`, handles `release/**` and `main`; `feature/**` goes through `deploy-feature-request.yml` → the reusable `trusted-deploy-dev.yml@main` (see Identity). | Trigger | Action | | --- | --- | | Push to `feature/**` | Build image, claim a development slot, deploy to it. | | Push to a branch whose name contains `-backport-` | `deploy-feature-request.yml` skips its deploy request. | | Push to `release/**` | Build image, claim a QA slot, deploy to it. | | Push to `main` | Build image, run the production blue-green deploy. | | Push to `hotfix/**` | Sandbox: build image, claim a development slot through `trusted-deploy-dev.yml@main` (proven 2026-10-12). Every other repo: nothing (its dev rail still accepts `feature/**` only until its hotfix-paths PR is live; see Hotfix Dev and QA Slots). | | Manual dispatch of `deploy-hotfix-qa-request.yml` at a `hotfix/**` branch | Sandbox: claim a QA slot for 2 h through `trusted-deploy-qa.yml@main` (proven 2026-10-12). The job is skipped on any other ref type. | | Push that only changes `README.md` or `docs/**` | Nothing (`paths-ignore`). A push with no file changes also does not deploy — including creating a branch at a commit that already exists, so the first slot deploy of a fresh branch needs a real commit. | | A newer push to the same `feature/**` or `release/**` branch while a deploy is running | The older run is cancelled (concurrency group per ref). Different branches deploy in parallel. | | A newer push to `main` while a production deploy is running | **Not cancelled.** The run queues behind it (`cancel-in-progress` is false for `main`), because cancelling a cutover mid-canary could strand a split. | Each job has a 20 minute timeout; the production cutover step has its own 15 minute timeout. Images are built per environment from the branch commit, so the image that reaches production is a fresh build of `main`, not the QA image. A `quality` job (the repository quality gate) and, in the portal repos, a `tests` job gate every deploy. ## Production Blue-Green (Implemented, Verified) Production is one Cloud Run service per deployable (`sandbox-web-prod`; PPPToo: `ppp2-participant-server-prod` then `ppp2-participant-client-prod`; HostPortal: `host-company-portal-server-prod` then `host-company-portal-client-prod`). Blue-green is Cloud Run revision traffic splitting on that service — **no load balancer change is needed**, even where one exists in front. The cutover is its own script, `scripts/blue-green.sh`, with a test suite that runs it against a fake `gcloud` and `curl`. The portal copies take `PROBE_PATH` (`/api/v1/version` or `/api/version` for the server, `/` for the client), `PUBLIC_URL` and `DEPLOY_ARGS` (the server's authoritative `--env-vars-file` and, for HostPortal, `--update-secrets` list). HostPortalPrototype also carries `scripts/deploy-plain.sh`, a plain production deploy of one Cloud Run service with no canary and no traffic split. It deploys the image with `--no-traffic`, waits for the new revision to be Ready, then sends it 100% of traffic, and any non-zero exit after the pre-deploy allocation is recorded puts that allocation back. Its header describes the blue-green path as retired and says leftover `blue` and `g-` tags are no longer pruned. 1. **Record the exact pre-deploy allocation** (for example `rev-a=60,rev-b=40`), not an assumed `blue=100`. **Blue** is the revision serving the most traffic. 2. The new build is deployed as **green** with `--no-traffic` behind a `g-` tag (the first 12 hex characters of the commit). 3. **Smoke test:** the green revision's private tag URL must return a page containing the full commit SHA. 4. **Canary:** 10% of traffic to green. The 10% is taken from the revision serving the most traffic, and every other serving revision keeps exactly the share it had (a 60/40 start becomes green 10, 50, 40). If the largest revision has less than 10%, it is drained and the remainder comes from the next largest. The service URL is probed for at least 30 seconds, every probe must succeed, and **green must actually be seen serving** (at least 5 responses carrying the new commit). A canary that only ever hit blue fails as inconclusive. 5. **Cutover:** 100% to green. The previous leader keeps the `blue` tag as the instant rollback target. 6. **Prune:** only the newest 5 `g-*` tags are kept (`KEEP_GREEN_TAGS`); older ones are removed. A tag on a serving revision is never removed and a failed run prunes nothing. 7. **Failure handling:** from the first traffic change until the 100% update succeeds, any exit restores the recorded allocation — a failed probe, an inconclusive canary, a timeout, or SIGINT/SIGTERM from a cancelled run. Every probe is time-bounded, so a signal is acted on within about 15 seconds. The workflow also has a safety step that repeats the restore if the script itself was killed outright. Verified (sandbox): a deliberately broken change failed at the smoke test (step 3), the broken revision stayed at 0%, and production kept serving the previous revision. A fixing hotfix then cut over cleanly and moved the `blue` tag. Verified (PPPToo, 2026-10-04): two live cutovers on the real portal (90/10 canary → 100%, previous revision tagged `blue`, both services), and a forced-rollback run with an unreachable SHA that failed the smoke test and never touched traffic. Verified (HostPortal, 2026-10-04): the first cutover attempt failed at canary probe 1 because the then-current revision had no `/api/version` route (every probe of the public URL 404ed), and the script restored the recorded allocation exactly; after the probe route was present on the serving revision, the re-run cut over both services (server `00010`, client `00007`) with the previous revisions tagged `blue`. ## Identity and Infrastructure (Implemented) Infrastructure is Terraform in `alliance-gcp-infra`: `environments/{development,qa,production}/branching-sandbox.tf` for the sandbox; `environments/{development,qa}/ppp2-pool.tf` + `environments/production/ppp2-deploy-identity.tf` for PPPToo; `environments/{development,qa}/host-portal-pool.tf` + `environments/production/host-portal-deploy-identity.tf` for HostPortal. Per environment: - An Artifact Registry repo (`sandbox`; PPPToo reuses `applicants-portal`; HostPortal reuses `host-company-portal` / `portal`). - A runtime service account (no roles beyond what the app needs; PPPToo's server runtime gets `secretAccessor` on exactly its three secrets; HostPortal reuses its existing per-environment runtime accounts) and a deployer service account (`run.developer` on the pool/prod services, `run.viewer`, AR writer, `actAs` on the runtime accounts only; HostPortal's deployer also reads the four build-time client secrets and the private npm registry). - A Workload Identity pool per repo (`github-actions-sandbox`, `github-actions-ppp2`, `github-actions-hostportal`) that accepts only that repository **and only the matching branch type**: `refs/heads/feature/` in development, `refs/heads/release/` in QA, exactly `refs/heads/main` in production. In development the provider additionally requires `assertion.job_workflow_ref == /.github/workflows/trusted-deploy-dev.yml@refs/heads/main`, so a feature branch's own workflow can never hold the deploy identity: only the reusable workflow pinned to protected `main` can. In the sandbox (since 2026-10-12) the development provider also accepts `refs/heads/hotfix/` through that same workflow, and the QA provider has a second path for a `hotfix/` ref that is valid only when `job_workflow_ref == /.github/workflows/trusted-deploy-qa.yml@refs/heads/main`; the production provider is unchanged (`ref == refs/heads/main`), so a hotfix ref can never mint the production deployer. - Cloud Run services ignore changes to image, env, labels, traffic and revisions, so deploys and Terraform never fight. **Custom-domain mappings are not in Terraform.** Creating one needs a verified owner of the domain. Since 2026-10-02 `terraform-tf@` in dev and QA is a verified owner of `allianceabroad.com`, so mappings are created with `gcloud beta run domain-mappings create … --impersonate-service-account=terraform-tf@…` (the caller needs `iam.serviceAccountTokenCreator` on that SA) plus a GoDaddy CNAME to `ghs.googlehosted.com`. Production applies are `workflow_dispatch` only with a typed confirmation, one `target` at a time when the plan carries unrelated drift (two parties dispatching production applies at the same time collide on the state lock with HTTP 412; wait and retry). ## Enforcement: Rulesets and the branch-rules Check (Implemented, Verified) The rules on this page are enforced by repository rulesets, not by this document. No ruleset has bypass actors, so nobody, administrators included, can skip them. Copies are kept in each repo under `.github/rulesets/` as documentation; the live settings are what count. | Rule | Enforced by | | --- | --- | | Only `main`, `feature/<16-digit ticket>-`, `release/`, `hotfix/<16-digit ticket>-` and `dependabot/**` branches can be created | Ruleset "Branch names": a `creation` rule that applies to every ref except those patterns (an allow-list written as exclusions). Verified with real pushes on all three repos. | | No direct push, force push or delete on `main` and `release/**` | Rulesets "Protect main" and "Protect release branches" (the release ruleset needs `do_not_enforce_on_create: true` on its required checks, or cutting a release branch from `main` is itself rejected) | | Everything reaches those branches by pull request, with `quality-gate` and `branch-rules` (PPPToo also `build-test`; HostPortal also `typecheck (server)`, `lint (client)`, `secret-scan (gitleaks)`) green and 1 approving review (stale approvals dismissed on a new push), Code Owner review on governance paths | Same rulesets: pull request rule plus required status checks | | Allowed pull requests: feature → release, release → main, hotfix → main (Dependabot → main) | Required check `branch-rules` (`scripts/check-branch-rules.sh`) | | Feature and hotfix branches were cut from `main` (HostPortalPrototype and BackOfficePrototype: or from a `release/*` branch), not from another non-release branch | Same required check (ancestry test) | | A release → main pull request must be up to date with `main`: the tip of `main` must be an ancestor of the PR's head branch. A feature → release pull request is not held to this: when the base release is behind `main` the check only prints a notice and does not fail (`hotfix/*` and `dependabot/*` pull requests are exempt) | Same required check (`scripts/check-branch-rules.sh`) | | Changes to workflows, the gate, its config, deploy config, CODEOWNERS, AGENTS.md need a Code Owner review | CODEOWNERS + the rulesets' Code Owner review requirement | | Legacy `development`/`qa` branches cannot be pushed to, force-pushed or deleted during the transition | Ruleset "Freeze legacy branches" (PPPToo, WildPacks, HostPortal) | Rulesets cannot express which branch type may open a pull request into which, or where a branch was cut from; they can only require a named check to pass. Those two rules therefore live in the `branch-rules` check and the rulesets make passing it mandatory. The check runs on `pull_request_target`, so the workflow and script are read from the **base** branch and a pull request cannot weaken its own check; it never executes code from the pull request. The script also writes each failure and each pass to the job summary when `GITHUB_STEP_SUMMARY` is set. Verified live (historical: the label and the `protected-paths` rule below were retired on 2026-10-19; Code Owner review remains): a hotfix → release pull request failed `branch-rules` and its merge was refused, while hotfix → main passed (sandbox and PPPToo); a PPPToo PR that edited `deploy/README.md` without the label failed `quality-gate` with `protected-paths` and the file was dropped from the PR rather than the rule lifted; a HostPortal PR that edited `AGENTS.md` without the label failed `quality-gate` with `protected-paths` while `branch-rules` passed (#621, closed as a probe). Limits worth knowing: - Ancestry cannot be blocked at push time. A feature cut from the wrong branch can still deploy to a development slot; it can never be merged. - `branch_name_pattern` rules are accepted by the API on the current plan but are not enforced, which is why branch names use the creation allow-list. - One approving review is required, and GitHub does not let an author approve their own pull request, so every merge into `main` or a release branch needs a second identity (the PR review agent's App approval, or the `alliance-platform-bot` machine account, which is also a Code Owner). Dependabot branches can target `main` directly because Dependabot cannot know about release branches. - Making a new check required needs the workflow to exist on the target branches first, and fixing the check itself while it is required needs the requirement lifted temporarily. - `pull_request_target` workflows only register from the repository **default branch**: during a conversion, switch the default branch before expecting the checks to run. ## Hotfix Workflow 1. **Rollback vs. hotfix decision.** If the issue has no destructive or irreversible migration attached, flip traffic back to the `blue` revision first — this resolves user impact immediately. Only proceed straight to a hotfix if rollback is unsafe. 2. **Branch upstream-first:** `git checkout -b hotfix/- main`. 3. Smallest possible diff, plus a regression test that fails without the fix. 4. **PR into `main`.** The required checks and one approving review apply like any other PR into `main`. The PR review agent reviews hotfix PRs on PPPToo and HostPortal (their base-branch lists include `main`). 5. Merge to `main` → blue-green cutover (Implemented, Verified). 6. **Backport** the fix by opening one PR per open `release/*` branch (not `feature/*`; no auto-merge; a conflicting backport stays open, labelled, with a Platform Pulse post): **Decided 2026-10-08, implemented as `hotfix-backport.yml`** (see Open Questions): it runs after the hotfix PR merges to `main`, which is why a `hotfix/*` → `release/*` PR is never opened by hand (`branch-rules` rejects that route). It is on `main` in the sandbox and WildPacksApplicationForm and is on the release branches of PPPToo, HostPortalPrototype and BackOfficePrototype, reaching their `main` with the next release. Where it is not yet on `main`, a release branch cut before the hotfix does not contain the fix; merging `main` into the release branch is the manual way. Review the backport PRs as a mirror of the merged hotfix, and close and reopen each once so its required checks start. 7. Verify in production and write a short post-incident note. ## Hotfix Dev and QA Slots > **Verified in `branching-sandbox` on 2026-10-12 (Asana 1219393647120312). Not live in any other repo yet.** A hotfix used to be testable nowhere before production. The same trusted-workflow pattern that protects the development rail now lets a hotfix branch claim a pool slot, without giving it any route to production. | Path | Trigger | Runs | Hold | | --- | --- | --- | --- | | Development slot | Push to `hotfix/<16-digit ticket>-*`, or "Run workflow" on `deploy-feature-request.yml` at the branch | `trusted-deploy-dev.yml@main`; its caller guard accepts `feature/*` or `hotfix/*` | 30 minutes | | QA slot | Manual dispatch of `deploy-hotfix-qa-request.yml` at the branch (never on push: QA slots are shared with release branches) | `trusted-deploy-qa.yml@main`; its caller guard accepts `hotfix/*` only | 2 hours (a `release/*` branch holds 24 hours) | Trust boundary (Workload Identity, `alliance-gcp-infra` #384, applied to development and QA): - **Development:** ref `refs/heads/feature/` **or** `refs/heads/hotfix/`, and `job_workflow_ref` exactly `trusted-deploy-dev.yml@refs/heads/main`. - **QA:** the existing release path (`refs/heads/release/` and `deploy.yml@refs/heads/release/`) is unchanged; a second path accepts `refs/heads/hotfix/` only with `job_workflow_ref` exactly `trusted-deploy-qa.yml@refs/heads/main`. - **Production:** unchanged, exactly `refs/heads/main` through `deploy.yml@refs/heads/main`. A hotfix ref cannot mint the production deployer; only the merge to `main` deploys. Proof (sandbox, throwaway branch `hotfix/1219393647120312-slot-proof`, after `release/2026-q4-hotfix-slots` reached `main` as `b6dd1d0`): 1. **Development:** the dispatched request ran through `trusted-deploy-dev.yml@main`, claimed `sandbox-web-dev-1`, labelled it with the hotfix branch and served `b6dd1d0` (30 minute hold). 2. **QA:** the dispatched request ran through `trusted-deploy-qa.yml@main`, claimed `sandbox-web-qa-2` for about 2 hours and served `b6dd1d0`. 3. **Refusals:** the QA request on a `feature/*` ref and on a `release/*` ref was skipped; the development request on a `release/*` ref failed at "Verify caller context" before any cloud credential was requested; no other slot changed. 4. **Production:** untouched by every hotfix run; only the merge to `main` ran the blue-green. A `workflow_dispatch` only works on a ref that already contains the workflow file, so the refusal checks needed a throwaway release branch cut from `main`. Rollout to the real repos, as of 2026-10-12. **None of these is live yet.** A hotfix path needs both the infrastructure change and the release on `main`, because the trusted workflows are pinned `@main`; until then a hotfix branch cut from `main` does not even contain the new workflows, and once it does, authentication fails closed at Workload Identity until the infrastructure change is applied. | Repo | Repo change (merged into its release branch) | Infrastructure PR (`alliance-gcp-infra`) | Live when | | --- | --- | --- | --- | | PPPToo | #453, `release/2026-10-ppp2-pilot` | #387, open | #387 merged and applied (scoped), and the release reaches `main` | | HostPortalPrototype | #635, `release/2026-10-hostportal-pilot` | #385, open | same | | WildPacksApplicationForm | #127, `release/2026-q4-form-validation` | #386, open | same | | BackOfficePrototype (GKE) | #2837, `release/2026-10-globus-pilot` | #388, open | same | **Infrastructure applies are scoped for now.** The full `terraform-apply-dev` and `terraform-apply-qa` plans already fail before this change (development since at least 2026-10-06; QA wants to destroy the `prevent_destroy` Cloud SQL instance `module.globus_db_qa`), so apply-on-merge applies nothing. Each of these changes is applied with `workflow_dispatch` and `targets=
` (for the sandbox: `google_iam_workload_identity_pool_provider.sandbox`), then checked with `gcloud iam workload-identity-pools providers describe`. Never force an unscoped apply while the QA plan carries the database destroy (tracked in Asana 1219396006336148). ## Verified vs. Not Implemented | Behaviour | State | | --- | --- | | Feature push deploys to a dev slot; page shows branch and commit | Verified (sandbox, PPPToo, HostPortal) | | Re-push of the same branch keeps its slot and extends the reservation | Verified | | A new branch takes an expired slot (eviction) | Verified | | A 4th branch with all 3 slots reserved is rejected, other slots untouched | Verified | | Release push deploys to a QA slot | Verified (sandbox, PPPToo); HostPortal pending its first release push | | Release → `main` merge runs the production blue-green cutover | Verified (sandbox, PPPToo: two live cutovers 2026-10-04; HostPortal: one live cutover 2026-10-04) | | Hotfix PR to `main` deploys through the same cutover | Verified (PPPToo: docs-only hotfix proved the route and the `paths-ignore` no-deploy; HostPortal: hotfix push produced no run) | | Failed smoke test rolls back; production keeps serving the old revision | Verified (sandbox, PPPToo prod server) | | Canary that never sees green, a failed probe, and a cancelled run each restore the exact pre-deploy allocation | Failed probe verified **live** on HostPortal prod (2026-10-04, first cutover attempt); the other two by the script test suite | | Successful canary preserves a mixed starting allocation | Verified by the script test suite; the live cutovers so far started from a single revision | | Only the newest 5 `g-*` deploy tags kept after a cutover | Verified live and in the test suite | | Branch names outside the allowed patterns cannot be created; PR routing and cut-from-`main` enforced; `main` and `release/**` need a PR, green checks and 1 approval | Verified live (see Enforcement) | | Development WIF identity only obtainable by the reusable workflow pinned to `main` | Implemented and exercised (sandbox, PPPToo, HostPortal); QA/prod providers check repo + ref only | | PR review agent on feature → release and release/hotfix → main PRs | Implemented for PPPToo 2026-10-04; HostPortal via vertex-agents #268 (merged) | | Merge queue for concurrent releases | Not implemented | | Scheduled slot-reconciliation sweep, delete-triggered slot free, "waiting on capacity" notification | Not implemented (a full pool is a failed deploy; expiry is the only slot release) | | Hotfix backport CI (`hotfix-backport.yml`) | Implemented 2026-10-08/09 and exercised on the sandbox (proof PRs 2026-10-08); on `main` in the sandbox and WildPacksApplicationForm, on the release branches of PPPToo, HostPortalPrototype and BackOfficePrototype (reaches `main` with the next release) | | Hotfix → development slot and QA slot (dispatch) | Verified in the sandbox 2026-10-12; merged into the release branches of PPPToo, HostPortalPrototype, WildPacksApplicationForm and BackOfficePrototype but **not live** until their infrastructure PRs are applied and the releases reach `main` (see Hotfix Dev and QA Slots) | | Periodic merge of `main` into long-lived release branches | Not implemented (manual) | | Adoption by portal repos | WildPacksApplicationForm converted 2026-10-03; **PPPToo converted and verified end to end 2026-10-04**; **HostPortalPrototype converted 2026-10-04** (QA-slot proof and consumer PRs pending); **BackOfficePrototype converted 2026-10-06** (GKE edition: dev pool `globus-dev-N`, QA pool `globus-qa-N`, production cut over to the GKE Gateway blue-green 2026-10-05 08:11 UTC; see Phase 4) | ## Known Limitations - **A 30-minute reservation is not a long hold.** Earlier drafts assumed a named-initiative release branch holds a QA slot for weeks. In the sandbox the slot is only reserved for 30 minutes after the last push; after that another release can evict it, and the initiative's branch must push again to redeploy. Decided 2026-10-08: long-lived releases get a longer reservation TTL for `release/*` (value to be chosen; not yet implemented). - A branch carries its own copy of the workflow, so a long-lived branch keeps running an old version of it. Delete branches when done. - A push with no file changes (an empty commit, or a new branch at an existing commit) does not deploy. - The release branch is not deleted automatically after it merges to `main` (decided 2026-10-08 to auto-delete; not yet implemented), and its QA slot is only released by expiry or eviction. - **The portal repos cut the server and client over sequentially, not atomically**: the API must stay backward compatible with the previous client for the seconds between the two cutovers. - Slots share the environment's identity provider (Keycloak realm for PPPToo, Auth0 tenant for HostPortal) and Globus API; isolation is at the Cloud Run service level only (both BFFs are stateless, so this is sufficient there). PPPToo's `GLOBUS_API_BASE_URL` is `https://dev-3.globus.allianceabroad.com` in development and `https://qa-1.globus.allianceabroad.com` in QA. - **First-cutover bootstrap.** The canary probes the public URL, which mostly hits the *old* revision. If the old revision does not serve the probe route (`/api/version`), every probe fails and the cutover correctly rolls back forever. Ship the probe route through the old deploy path first, or pin traffic to a revision that has it, before the first blue-green. After one blue-green the service's traffic is pinned to a revision, so a legacy `gcloud run deploy` no longer moves traffic to `LATEST` on its own. ## Adoption Risks (Portal Repos) - **Stateful services.** Pool slots for BackOfficePrototype/Globus need their own data (a database per slot, migrations, secrets), which the sandbox does not model. PPPToo and HostPortal are stateless BFFs, which is why they could adopt the mechanism as-is. - **Irreversible side effects.** BackOfficePrototype/Globus carries compliance-sensitive actions (SEVIS submissions) that a canary or rollback cannot undo. A blue-green cutover protects the deploy, not the data. - **GKE workloads** (BackOfficePrototype/Globus) cannot use Cloud Run traffic splitting. They need GKE Gateway API with weighted `HTTPRoute` routing, since `Ingress` has no equivalent. That was the real build item; it shipped as BackOfficePrototype's `deploy-prod-bluegreen.yml`, and production cut over to it on 2026-10-05. - **Cloud Run workloads** (PPPToo participant server, HostPortal) adopt the sandbox mechanism closest to as-is: done for both. See [Disaster Recovery Plan](/docs/disaster-recovery-plan) for the load-balancer decision behind the GKE case. - **Migration from the current pipeline.** Portal repos used to promote `development` → `qa` → `main` through the nightly promotion agent. Moving to this model retires the `development` branch there, which touches the promotion agents, the PR review agent's gates, branch protections, and the IT request portal's deployment-request lists. Done for all four repos: WildPacks and PPPToo (vertex-agents #249/#264, itrequestportal #89), HostPortal (vertex-agents #268, itrequestportal #111) and BackOfficePrototype (vertex-agents #285, itrequestportal #124, both merged 2026-10-06). The only repo the promotion agents still promote is `n8n-workflows`. ## Migration Plan: BackOfficePrototype, PPPToo, HostPortalPrototype > **PPPToo: done 2026-10-04 (Phase 2 below). HostPortalPrototype: done 2026-10-04 (Phase 3 below). BackOfficePrototype: done 2026-10-06 (Phase 4 below, with the retirement items still open listed there).** The repo comparison table below is the read-only inspection of 2026-10-01 and is kept as the baseline; all three repo columns describe the state *before* conversion. Sizes (S/M/L/XL) are relative guides, not estimates; run `estimate-my-workload` before committing dates. ### Where each repo is today, compared with the sandbox | Dimension | Sandbox | BackOfficePrototype (BOP) | PPPToo (before 2026-10-04) | HostPortalPrototype (before 2026-10-04) | | --- | --- | --- | --- | --- | | Integration base | `main`; everything cut from `main` | `development` integrates; `qa` is a separately maintained branch; `main` default. Hotfixes also land on `main` directly | `development` is the **default branch**; `main` is 123 commits ahead of and 3 behind `development` | `main` default, but `main` carries a **prod-only baseline** (retired GKE manifests, prod Cloud Run workflow), so dev and QA are not `main`-derived | | Deploy triggers | `feature/**`, `release/**`, `main` | push to `development`, `qa`, `main` (fixed names) | same fixed names | same fixed names | | Deploy workflows | one, parameterised by branch | three, plus legacy `cloudbuild*.yaml` | three, plus an unused Cloud Deploy pilot and Keycloak image workflows | three that diverge (PostHog steps, secrets, ports), plus legacy workflows | | Runtime | Cloud Run, `us-central1` | **GKE**, `us-central1`, kustomize directory per environment, GCE Ingress + ManagedCertificate | Cloud Run (server, client, Keycloak) in `northamerica-northeast1`, behind a per-environment global load balancer | Cloud Run behind a per-environment load balancer with serverless NEGs. Dev and prod `northamerica-northeast1`, QA `us-central1`; service names, regions and registries differ per environment | | Environments | pooled: 3 dev, 3 QA, 1 prod | one fixed environment each | one fixed environment each | one fixed environment each; QA also has leftover GKE scaffolding | | Prod release | blue-green revisions, smoke test, canary, rollback | RollingUpdate; no rollback job (QA's rollback and E2E jobs exist but are disabled with `if: false`); `kubectl rollout undo` by hand | 100% to the new revision; only the Keycloak image pilot uses `--no-traffic` plus tag promotion | 100% to the new revision; the "3 failing prod deploys" seen on 2026-10-01 were fixed by #614 before conversion (prod matched `main`) | | State | none | Cloud SQL per environment; migrations run on **every deploy before rollout**, not backward-compatible by policy, no down-migration | no database in the BFF; Keycloak has its own; sessions are bearer tokens | no own database found; calls the Globus API; session secret | | Identity / hostname coupling | n/a | Keycloak/Auth0 issuer and redirects in manifests; hostnames in `ingress.yaml`; PPPToo origins hard-coded in code | Keycloak redirect URIs, `CORS_ORIGINS`, `KEYCLOAK_ISSUER` fixed per environment; client API URL **baked in at build** | Auth0 tenant per environment; the SPA redirects to `window.location.origin`, so every host must be in Auth0's callback list; CORS is a literal list | | Merge gates | none | strong (rulesets plus classic protection); `qa` has no required checks | `build-test` required on `development` only; **`main` unprotected**; deploys do not wait for CI | **no required checks at all**; CI runs on PRs only | | Promotion | none | agent promotes `development` → `qa`; `qa` → `main` by merge-commit PR | same pattern, merge-commit ruleset on `qa` | same pattern; `main` vs `qa` shows a permanent "diverged" signal from the prod-only baseline | | Image | built per environment | built per environment on the runner; Vite keys baked in, so an image cannot be promoted | built per environment; API URL baked in | built per environment | | Pipeline length | ~2 min | 9–16 min | ~1.5–2 min | ~3–4 min | ### Decisions needed before any repository moves 1. **Slot hostnames behind load balancers.** Cloud Run domain mappings are not allowed in `northamerica-northeast1`, where PPPToo and HostPortal live. Options: (a) host rules plus a wildcard certificate on each existing load balancer, with one serverless NEG per slot; (b) put pool services in `us-central1` and use domain mappings as the sandbox does; (c) fewer slots with static hostnames. BOP needs the GKE equivalent (an Ingress or Gateway host rule and certificate per slot). *PPPToo and HostPortal chose (b) for dev/QA and left prod where it is.* 2. **Artifact strategy.** The sandbox builds per environment, so production ships a fresh, untested build of `main`. BOP and PPPToo bake environment values into the bundle. Decide whether to accept build-per-environment or to move runtime configuration out of the bundle so one artifact can be promoted. *PPPToo and HostPortal accepted build-per-environment; the image carries the commit SHA (`GIT_SHA` / `VITE_GIT_SHA` build args) and the cutover verifies it.* 3. **Slot data isolation (BOP).** A database or schema per slot on the shared Cloud SQL instance, versus slots sharing the environment's database. 4. **Identity redirects.** Whether wildcard callback and redirect URIs are acceptable in Auth0 and Keycloak; otherwise per-slot URIs must be registered and managed in Terraform. *PPPToo and HostPortal: fixed slot hostnames, so literal entries per environment, no wildcards. HostPortal's Auth0 entries are registered by the Management API rather than Terraform; the dev tenant's management clients lack the `update:clients` scope and need a one-time grant.* 5. **Re-baselining `main`.** How `development` and `qa` history is reconciled into `main` for each repo, without rewriting history. *PPPToo and HostPortal: a last legacy promotion carried the tooling to `main`; `main` already contained everything else, so the switch was a ruleset change (default branch was already `main` on HostPortal).* 6. **The open model questions** already tracked in the Open Questions section: hotfix review, backport target, long-lived release slot hold. 7. **Side-effect isolation in pools (BOP).** How non-production slots are guaranteed never to receive production SEVIS, SendGrid or n8n credentials. ### Order, and why 1. **Phase 0 — Baseline and prerequisites (all repos).** Fix what is already broken or stale, so the migration starts from known state. 2. **Phase 1 — Shared foundation.** Extract the sandbox mechanism into reusable parts and make the decisions above. 3. **Phase 2 — PPPToo (pilot, size M). Done 2026-10-04.** Healthiest pipeline of the three, no database in the BFF, Cloud Run. 4. **Phase 3 — HostPortalPrototype (size M). Done 2026-10-04.** Also stateless and Cloud Run; followed the pilot the same day. 5. **Phase 4 — BackOfficePrototype (size XL, last). Done 2026-10-06.** GKE, a database per environment, deploy-time migrations and irreversible SEVIS side effects. It needed the most new capability and carried the most risk, so it went last, on proven shared parts. 6. **Phase 5 — Retire the old model.** Only after each repo has run at least one full release cycle on the new one. ### Phase 0 — Baseline and prerequisites (size S–M per repo) - **HostPortal (done 2026-10-04):** prod already matched `main` (#614 had fixed the failing deploy); the QA GKE namespace was empty; the Deploy QA workflow had been red for 11 days only because its post-deploy Playwright job lacked repo secrets (now skipped unless `vars.HOSTPORTAL_QA_E2E_ENABLED`); `main` protected; 69 merged branches pruned (338 left, mostly squash-merged batches). - **BOP:** decide whether to re-enable or delete the disabled QA E2E and rollback jobs; resolve the `SevisAlpha` branch references in workflows; refresh `docs/environment-promotion-strategy.md`, which still calls production "future". - **PPPToo (done 2026-10-04):** `main` protected; the Platform Pulse notifier's parse failure (442 consecutive phantom failures) fixed; the unused Cloud Deploy pilot disabled; 38 merged branches pruned (159 unmerged left for a prune rule). - **All repos:** prune stale branches (100+ in BOP) so rulesets and agents are not reasoning over noise. - **Verify ownership *(unverified)*:** which load balancers, certificates and Workload Identity pools are managed in `alliance-gcp-infra` and which are not. Exit: each repo's three deploy workflows are green and match; `main` is protected; the decisions above are recorded. ### Phase 1 — Shared foundation (size M) - Extract the sandbox `deploy.yml` logic into one **reusable workflow** (branch type → environment, build, claim slot, deploy; blue-green on `main`) that each repo calls with its own parameters (service names, regions, projects, health URL). *Not done yet: WildPacks, PPPToo and HostPortal each carry a verbatim copy of the sandbox scripts; the PR review agent flagged the duplication on alliance-gcp-infra #349 and it is tracked for after the pilots' first release cycle.* - Extract `allocate-slot.sh`, generalised so the slot lock lives in labels on Cloud Run services, and as an equivalent lease for GKE (see BOP). - Add a Terraform module in `alliance-gcp-infra` for a pool: N pre-provisioned services or namespaces, per-slot hostnames, per-environment Workload Identity condition restricted by branch type (`feature/`, `release/`, `main`) as in the sandbox. - Add the new ruleset pattern, copied from the sandbox (`.github/rulesets/*.json`): protect `main` and `release/**` (PR required, no force-push or delete, required checks, 1 approving review), add the branch-name creation allow-list, and add the `branch-rules` workflow. Make deploys depend on CI passing. Note that a `branch_name_pattern` rule is accepted but **not enforced** on the current GitHub plan, so the creation allow-list plus the required check is the working pattern. Exit: the reusable workflow and pool module are proven on the sandbox, with the slot-hostname and artifact decisions made. ### Phase 2 — PPPToo (pilot) — Done 2026-10-04 What was done, in order (Asana 1219058470201463 and its five phase subtasks hold the evidence): 1. **Phase 0.** `main` protected; `notify-platform-pulse.yml` given its `workflows:` list; Cloud Deploy pilot disabled; 38 merged branches pruned. 2. **Tooling (PPPToo #429).** The sandbox quality gate, `branch-rules`, pool/blue-green scripts and the three sandbox-model workflows landed as ordinary code on the legacy integration branch. Deviations recorded in the config: `max-lines-app` is a **ratchet at 2400** (31 files exceeded 300), the console rule is scoped to the client, the field-injection regex is anchored to line start, `deploy/**` / `terraform/**` / both Dockerfiles are protected paths. `GET /api/v1/version` and `` were added as the cutover probes. `blue-green.sh` gained `PROBE_PATH`, `PUBLIC_URL`, `DEPLOY_ARGS` so it runs once per service of the pair. 3. **Infrastructure (alliance-gcp-infra #349, #350).** Dev/QA pools `ppp2-{server,client}-{dev,qa}-{1,2,3}` in `us-central1`; WIF pool `github-actions-ppp2` per project (dev pinned to `trusted-deploy-dev.yml@main`); deployers `ppp2-deployer-{dev,qa,prod}`; prod identity scoped to the existing pair. Twelve domain mappings created by impersonating the verified `terraform-tf@`; twelve GoDaddy CNAMEs. Keycloak slot redirect URIs registered in dev and QA (the Terraform admin client had to be granted `manage-clients` first; now declared in the module). 4. **Switch.** One last legacy promotion (`development` → `qa` → `main`, PPPToo #434/#435) carried the tooling to `main`; default branch switched to `main`; `release/2026-10-ppp2-pilot` cut; rulesets created in order (Branch names → Protect main / Protect release → Freeze legacy branches); legacy deploy workflows disabled; promotion agents, review-eligibility agent, branch-drift agent and the IT request portal updated (vertex-agents #264, itrequestportal #89); `deploy-qa` / `deploy-prod` / `task-workflow` / `pr-review` skills updated. 5. **Proof.** feature → dev slot 1, feature → release (#437), release → QA slot 1, release → main blue-green (#439; the earlier push to `main` had already cut over the tooling release), forced rollback on the prod server, hotfix → main (#436, no deploy). The PR review agent reviewed the first PRs on the new base branches after its redeploy. Left open from the pilot: branch prune pass 2 (159 unmerged branches, rule to be decided); the dev Keycloak Terraform apply is blocked by an empty `globus-development-sendgrid-api-key` secret; `deploy/README.md` still describes the legacy rail until a governed PR updates it; the shared pool module (Phase 1) is still to be extracted. ### Phase 3 — HostPortalPrototype — Done 2026-10-04 What was done, in order (Asana 1219139535095088 and its five phase subtasks hold the evidence; it is a subtask of the rollout ticket 1219058294224793): 1. **Phase 0.** Ruleset "Protect main" created (PR required, no bypass); 69 merged branches pruned; the 11-day-red Deploy QA diagnosed as a missing E2E secret and gated on a repo variable; prod confirmed equal to `main`. 2. **Tooling (HostPortalPrototype #617).** Same vendored gate and scripts as PPPToo. Repo-specific: `scripts/build-images.sh` runs the Vite build first (the client Dockerfile expects a prebuilt bundle) with Auth0/PostHog values read from Secret Manager per `deploy/env/.build`; `deploy/env/.yaml` + `.server-secrets` + `.client-secrets` mirror the live services' env (`CORS_ALLOWED_ORIGINS` rewritten per slot by `deploy-slot.sh`); `GET /api/version` added next to `/api/health`; `max-lines-app` ratchet 2400; the Globus-URL rule allows the development URL (the code's fallback) and forbids QA/prod URLs; nine debug `console.log` calls removed to satisfy the client console rule. Two CodeQL "high" findings in the vendored gate (file-system race in `gate.mjs`, incomplete escaping in `report.mjs`) were fixed here and the fixes copied back to the skill templates (backport to the other repos tracked as Asana 1219139538996025). 3. **Infrastructure (alliance-gcp-infra #353).** Dev/QA pools `host-portal-{server,client}-{dev,qa}-{1,2,3}` in `us-central1` running as the existing per-environment runtime accounts; WIF pool `github-actions-hostportal` per project (dev pinned to `trusted-deploy-dev.yml@main`, prod to `refs/heads/main`); deployers `hostportal-deployer-{dev,qa,prod}` with `secretAccessor` on the four build-time secrets and reader on the private npm registry. Dev and QA applied by CI; prod applied as 14 single-`target` dispatches. Twelve domain mappings (impersonating `terraform-tf@`), twelve GoDaddy CNAMEs. QA Auth0 client given the three slot hosts; **dev Auth0 still open** (no management client with `update:clients`). 4. **Switch.** Last legacy promotions `development` → `qa` → `main` (#618, #619) carried the tooling to `main`; `release/2026-10-hostportal-pilot` cut; rulesets Branch names 24467828 → Protect main 24464581 (updated with checks, 1 approval, Code Owner) → Protect release branches 24467830 → Freeze legacy branches 24467833; legacy rulesets 18085163 / 20686782 deleted; legacy workflows Deploy Development / Deploy QA / Deploy to Production (Cloud Run) disabled. Consumer changes prepared as vertex-agents #268 and itrequestportal #111 (both awaiting a second approver); the `deploy-qa` / `deploy-prod` / `task-workflow` / `pr-review` / `qa-promotion-conflict-resolution` / `sandbox-branching-model` skills updated and synced. 5. **Proof.** feature → dev slot 1 (`feature/1219141530843533-pool-proof`, `/api/version` returns the exact SHA); feature → release PR #620 passes `quality-gate`, `branch-rules` and the `ci` jobs; release → main blue-green on `b27a182` for both services (first attempt rolled back correctly on the canary; see Known Limitations), previous revisions tagged `blue`; hotfix push produced no run; protected-path PR #621 blocked by `quality-gate`; branch-name allow-list rejected four bad names. Pending: the release → QA slot deploy (needs #620 merged) and the consumer PR merges. Left open from the conversion: dev Auth0 slot callbacks (scope grant or dashboard edit); the repo's `README.md` / `.github/rulesets/*.json` still carry the pre-switch wording until a governed PR updates them; Phase 5 retirement after a trust period; the branch-prune rule for the ~330 squash-merged legacy branches. ### Phase 4 — BackOfficePrototype — Done 2026-10-06 BOP was the hard one: it is the only repo on GKE, with a database per environment and irreversible side effects. It is live on the GKE edition of the model: - `feature/<16-digit Asana GID>-*` branches deploy to a **development pool slot** `globus-dev-N` (`dev-N.globus.allianceabroad.com`) through `deploy-feature-request.yml` / `trusted-deploy-dev.yml`. The allocator takes its pool size from the caller's `SLOTS`: development is slots 1-6 since 2026-10-15, QA is slots 1-6 since 2026-10-21. When a branch already holds more than one slot, the allocator returns the one with the latest reservation expiry (lowest slot number on a tie) rather than the lowest-numbered one; an expired slot is taken only if the branch holds none. With `CHECK_ONLY=1` and automatic slot selection, the allocator only reports whether a slot could be reserved right now, without claiming anything: it prints `free` or `full` on stdout (the slot table goes to stderr) and always exits 0. The dev deploy workflow uses this to skip the deploy job, rather than fail it, when every slot is held by another branch. With an explicit slot request, `CHECK_ONLY=1` always reports `free` and leaves the real claim to refuse it. - A slot namespace may carry the permanent label `sandbox-reserved-for` with the value `user_`, `branch_` or `bot`. It is a coordination aid, not access control, and `assign-slot` / `release-slot` only ever write the lease labels, so a reservation survives a release or an expiry. The allocator never picks a slot reserved for someone else; an explicit `REQUESTED_SLOT` on one is refused with no fallback or overwrite, naming the owner, even when the slot is free or expired. A slot the requesting branch already holds is always kept. A slot reserved for this run's `ACTOR` (`REQUESTED_BY` wins) is preferred over unreserved ones, but in automatic selection it is only a candidate when it was never claimed, its reservation has expired, its owner branch is deleted, or the holder's `sandbox-actor` label is the same actor; an empty `ACTOR` matches no user or bot reservation. The trusted workflow passes `github.actor`, or the `requested_by` dispatch input only when the GitHub App dispatched. The actor is recorded on the lease in label-safe form as `sandbox-actor`. - `scripts/release-slot-gke.sh` releases the hold on one pool slot (`PREFIX` and `SLOT` in the environment). It writes `sandbox-reserved-until=0` on the slot namespace with a compare-and-swap on the namespace `resourceVersion`, and if the claim changed while releasing it leaves the slot alone and exits 1. The owner labels stay, the slot's pods keep running until another branch claims the slot and deploys over it, and the same branch can reclaim it on its next push. It prints `released `, or `not-held ` when the slot's expiry is already at or before now; it exits 2 for a bad slot and 1 when the namespace cannot be read or the claim changed while releasing. - `release/*` branches (current: `release/2026-10-globus-pilot`) deploy to a **QA pool slot** `globus-qa-N` (`qa-N.globus.allianceabroad.com`) through `deploy-release-qa.yml`. - A `release/*` → `main` pull request (1 approval, plus Code Owner review for governance paths) runs `deploy-prod-bluegreen.yml`, a GKE Gateway blue-green cutover. Production cut over to it on 2026-10-05 at 08:11 UTC. `hotfix/-*` → `main` goes through the same cutover. - `deploy-development.yml`, `deploy-qa.yml` and `deploy-production.yml` were disabled on 2026-10-05. The `development` and `qa` branches are dead rails: nothing deploys from them, `qa`…`main` is permanently diverged, and the 18 pull requests still targeting `development` on 2026-10-06 must be retargeted to the release branch. - The promotion agents no longer promote BOP (vertex-agents #285, 2026-10-06: the dev-to-qa-sync-agent dispatches `deploy-release-qa.yml` at a release branch instead, and the qa-to-prod-sync-agent's default promotion list is empty); the Tech Request Portal treats BOP as a sandbox-model service (itrequestportal #124); form-engine bumps arrive as `feature/1219202098622754-form-engine-` branches cut from `main` into the newest release branch (form-engine-portable #183). Still open: rulesets on `release/**`, `feature/**` and `hotfix/**`; the freeze ruleset on `development` and `qa` (Asana 1219141533420592); retiring the frozen legacy GKE deployments; and the consumer decision for the legacy hostnames `development.globus` / `qa.globus`, which still point at the frozen legacy ingresses (their cutover to the environment-index board was reverted on 2026-10-05). The plan as written before the conversion, kept for the record: 1. **Re-baseline.** `development`, `qa` and `main` all have diverging history and `qa` has no required checks. Reconcile into `main` by merge and add a `release/*` ruleset (merge-commit only, same required checks as `main`). 2. **One workflow on GKE.** Keep `kubectl apply -k`, but fold the three per-environment manifest directories (`k8s/{development,qa,prod}`, which hard-code namespace, project, database, service account, IP and hostname) into a shared base with overlays. A slot becomes an overlay. 3. **Pool on GKE.** Cloud Run labels are not available, so the slot lock becomes a lease on a namespace or ConfigMap (the same holder-and-expiry idea). Pre-provision slot namespaces, each with its own ExternalSecrets, hostname and certificate (an Ingress `ManagedCertificate` per slot, or one wildcard certificate), and DNS. 4. **Slot data.** Per decision 3, a database or schema per slot on the shared Cloud SQL instance, created from a template snapshot, with migrations run as a Job per slot. Dev and QA also run a seed step today, which must target the slot's database only. 5. **Blue-green on GKE.** Add GKE Gateway API with a weighted `HTTPRoute` and two Deployments (blue and green) behind one route; promotion is a weight change and rollback is flipping it back. This is new cluster capability, not a policy switch. 6. **Migration policy.** Migrations run before rollout on every deploy and have no down path, so old pods run against the new schema during a cutover. Adopt **expand/contract**: a new migration must be backward compatible with the previous release, enforced with a CI check alongside the existing migration-idempotency gate. 7. **Side-effect isolation.** Enforce `SEVIS_ENVIRONMENT=beta` in every pool slot and lint overlays so they cannot reference production SEVIS, SendGrid or n8n credentials. Define how a cutover guarantees SEVIS submissions are not run twice or dropped, since a rollback cannot undo them. 8. **Artifact.** Decision 2 matters most here: the image bakes Vite keys for Google Maps and PostHog, so QA's artifact cannot be promoted to production. Exit: feature, release and prod cutover run on BOP with a verified rollback, an expand/contract check in CI, and slot databases isolated from shared environments. ### Phase 5 — Retire the old model - Delete the `development` branch (and `qa` where it is no longer a deploy source) in each migrated repo, and remove the three fixed-name deploy workflows after one release cycle. - Disable the development → QA sync agent for each migrated repo at its cutover and update the `/deploy-qa` and `/deploy-prod` skills; freeze the agent per repo during the transition so it does not promote into a repo that no longer uses those branches. *Done for all four repos (vertex-agents #249 WildPacks, #264 PPPToo, #268 HostPortal, #285 BackOfficePrototype on 2026-10-06); the only repo the promotion agents still promote is `n8n-workflows`, and the qa-to-prod-sync-agent's default promotion list is now empty.* - Extend the PR review agent's repo list and update its gates, update `task-workflow` and `pr-review`, and update the promotion documentation. *Skills done for PPPToo, HostPortal and (2026-10-06) BackOfficePrototype; HostPortal's eligibility config landed in #268.* - Keep the old workflows disabled, not deleted, until the repo has completed a release cycle on the new model. ### Risks and mitigations | Risk | Mitigation | | --- | --- | | BOP production: irreversible SEVIS side effects and a migration with no down path | Migrate BOP last; expand/contract migrations; enforce non-prod SEVIS isolation; rehearse a cutover and rollback on QA first | | Wildcard certificates, DNS and load balancer rules for slot hostnames | Decided on the pilot: `us-central1` slots with domain mappings and fixed hostnames; prod untouched behind its LB | | Auth0 and Keycloak redirect sprawl | Fixed per-slot entries in Terraform/tfvars (Keycloak) or via the Management API (Auth0); no wildcards | | The promotion agents keep promoting during the transition | Freeze the agent per repo at its cutover (done for WildPacks, PPPToo, HostPortal via frozen branches + #268, and BackOfficePrototype via #285) | | Branch rules block the first release-branch merges | Add `release/*` rulesets and review the bypass actors before the first release branch is cut; set `do_not_enforce_on_create` on the release ruleset's checks | | First production cutover cannot pass its canary | Ship the probe route through the old path before the first blue-green (HostPortal lesson) | | Pool cost and quota (up to 6 extra slot environments per repo) | Start with fewer slots on the pilot; size the pool from observed occupancy | | Production state drifts from `main` while repos are re-baselined | Phase 0 confirms prod equals `main` before any re-baseline | ### What would tell us it worked For each repo: a feature branch deploys to a pool slot; a release branch deploys to a QA slot; a `release/*` → `main` merge cuts over with a verified rollback; no `development` branch exists; deploys wait for CI; and a hotfix to `main` deploys through the same cutover. **PPPToo met all of these on 2026-10-04. HostPortalPrototype met all but the release → QA slot deploy on 2026-10-04 (pending its first release-branch merge). BackOfficePrototype runs feature, release and production deploys on the model since 2026-10-06, but its `development` and `qa` branches still exist as dead rails until the freeze ruleset lands (Asana 1219141533420592).** ## n8n-workflows: A Deliberately Different Pattern > **Not affected by the model above.** n8n-workflows' three-branch pipeline (`development` / `qa` / `main`, one n8n instance per branch) is independently justified by n8n's own source-control feature (one instance paired with one git branch per environment) and by the wider n8n user base's practice at scale. Its branches mean something different underneath: each one mirrors a specific **live running n8n instance's** state, not a deployable codebase promoted through generic environments. ### Why the pattern is different here n8n's branches aren't a deployable-codebase-promoted-through-environments story — each branch is a version-controlled mirror of one specific **live running n8n instance's** state (workflows, tags, credential/variable stubs), and a PR is the review gate for promoting a change from one running system to another. ### Sandbox: An Isolated Tier Upstream of `development` As of 2026-09-08 (instance) / 2026-09-10 (git branch + tooling), n8n-workflows also has a `sandbox` tier — a fully isolated GKE-based n8n instance (namespace `n8n-sandbox`) with its own git branch (`sandbox`) and its own integration-test harness (`scripts/test_integration.py`, run via the `Test Sandbox` Action). It sits **upstream of** the three-branch pipeline above, not inside it. (This is unrelated to the `branching-sandbox` repo described on this page.) - **Development and isolated testing happen in sandbox first.** Workflow authors build and iterate against the sandbox n8n instance. Sandbox's integration tests fire real synthetic events at a running instance end-to-end, which the pipeline's own gates (`validate_workflow_contracts.py`, `validate_references.py`) don't do. - **Only reviewed, ready workflows are exported to `development` for integrated testing.** The `Export Sandbox Workflows` Action snapshots sandbox's live state to the `sandbox` branch and flags any workflow that exists only there. A human runs `Promote Sandbox Workflow` for anything genuinely ready, opening a **review PR against `development`** — never auto-merged. - **Sandbox is not part of the promotion path and is not a deploy target.** `deploy_workflows.py` refuses `TARGET_ENV=sandbox` on purpose. ### What does need to change: which workflows belong on which instance 1. **Credential/environment scope.** Does the workflow need to reach real production systems? If so, it needs prod-scoped credentials and belongs in the prod instance. 2. **Reliability requirement.** The `development` instance is a low-governance iteration sandbox by design — fine for throwaway experimentation, harmful for anything whose job is to notice when something else is broken. **Workflows that should move to prod, not stay parked on dev:** Platform Pulse alerting, agent-issue-resolution triggers, and security-remediation workflows. **What should stay dev-only:** genuinely experimental automations someone is actively building and iterating on, with no operational dependency on them working right now. ## Why This Isn't GitFlow Classic GitFlow's problem is a long-lived `develop` branch that accumulates work for a long time before a manual, risky batch-sync into a release branch. Trunk-based development is a rule about **branch lifetime** — short-lived branches, integrated often — not about deleting pre-production environments. This model keeps both properties: there is no long-lived integration branch at all, feature branches are short-lived and deploy immediately to a pooled development environment, the release branch is bounded and disappears when it ships, and the blue-green cutover makes production rollback a traffic flip rather than a redeploy. ## Open Questions ### Decided 2026-10-08 (Asana 1219058395898772) - **Release branch after it merges to `main`:** auto-delete it. Production `g-` revision tags stay pruned to the newest 5. **Implemented 2026-10-09** in PPPToo, HostPortalPrototype, WildPacksApplicationForm, BackOfficePrototype and the sandbox (`release-branch-cleanup.yml` deletes the release branch once its PR into `main` merges; the live "Protect release branches" rulesets no longer carry the `deletion` rule). - **Hotfix PR review:** the PR review agent plus a human. Hotfix PRs into `main` are already in the agent's base-branch list. - **Hotfix backport target:** one PR per open `release/*` branch, not `feature/*` branches. No auto-merge. When a backport conflicts, leave the PR open, label it and post to Platform Pulse. **Implemented 2026-10-08/09** (`hotfix-backport.yml`): a merged hotfix pushes a `feature/-backport-*` branch for every open release branch that lacks it and comments the compare links on the hotfix PR, because GitHub Actions cannot open pull requests in this organisation; conflicts are noted and left for a human (Asana 1219058395899893). - **Long-lived named-initiative releases:** a longer QA-slot reservation TTL for `release/*` (the value is chosen when this is built). No pinned flag and no larger pool. **Implemented, 24 h** (feature branches keep 30 min): in each portal repo's `deploy.yml`, and for BackOfficePrototype in the shared slot allocator `scripts/allocate-slot-gke.sh`. Observed live on BackOfficePrototype: the release branch's QA slot was reserved until 24 h after its last deploy. ### Fixing a file that exists only on a release branch A fix to a file that lives only on a release branch (an in-flight feature file, the release QA workflow) cannot be cut from `main`. The `branch-rules` checks of HostPortalPrototype and BackOfficePrototype accept a `feature/*` or `hotfix/*` branch cut from a `release/*` branch, so there a fix can be cut from the release branch directly. Where a repo's check still requires a cut from `main`, the compliant route (proven live on BackOfficePrototype, 2026-10-09; no rule is loosened): ``` git checkout -b feature/- $(git merge-base origin/main origin/release/) git merge --no-ff origin/release/ # must be --no-ff: a fast-forward is the same as cutting from the release # edit the release-only files, commit, open a PR into release/ (the diff shows only the fix) ``` The branch-rules check decides what a branch was cut from by its first-parent lineage that is on neither `main` nor the pull request's base branch, so history already on the **base branch** is always allowed; history merged in from any other non-release branch that is on neither `main` nor the base branch is still rejected. The exception is a branch whose tip is exactly the head commit of a merged pull request (a squash-merged leftover that only still exists): it is never blamed, and the check prints a note asking for it to be deleted. The check recognises such a branch through an optional read-only `GH_TOKEN` and `GITHUB_REPOSITORY`; when the lookup is not configured, errors or finds nothing, the branch is still blamed and the check fails. PRs into a release branch run the release branch's copy of `scripts/check-branch-rules.sh`, so that copy must be the current one: it is on every sandbox-model repo's release branch as of 2026-10-09 and reaches each repo's `main` with its next release. ### Still open - Does a feature that misses a release get re-cut from the new `main`, or rebased? - Merge queue for concurrent releases landing on `main`: needed, and when? - Is the release batch cadence weekly (earlier assumption) or per feature, and does that change the pool size? - The remaining migration decisions for BOP (slot data, side-effect isolation) and HostPortal's dev Auth0 callbacks (management-client scope). ## Required Follow-up: Skill Updates The `task-workflow`, `pr-review`, `deploy-qa`, `deploy-prod` and `qa-promotion-conflict-resolution` skills were updated on 2026-10-02 (WildPacks), 2026-10-04 (PPPToo, HostPortal) and 2026-10-06 (BackOfficePrototype): the methodology is selected per repo, sandbox-model repos cut features from `main` and merge them into the current `release/`, Gate 5 enforces the three allowed routes, and the promotion skills refuse the converted repos. The `sandbox-branching-model` skill holds the templates of every live artifact and the conversion procedure, with the PPPToo and HostPortal learnings recorded. --- --- title: "Access & Accounts Checklist" slug: onboarding-access-checklist section: Developer Onboarding status: stable description: "Every system a new Alliance Abroad contributor needs before they can be productive — what it's for, and how to actually get it." last-updated: 2026-10-07 --- # Access & Accounts Checklist Every system a new Alliance Abroad contributor needs before they can be productive — what it's for, and how to actually get it. Work through this before (or during) your first few days. ## Overview Alliance's tooling is deliberately consolidated behind a small number of entry points — the Alliance MCP Gateway is the biggest lever, since one PAT unlocks dozens of downstream tools. Get that early; most of the rest follows from it. ## The Checklist | System | What it's for | How to get it | | --- | --- | --- | | GitHub org (`Alliance-Strategies`) | Read/write access to every Alliance repo — BackOfficePrototype, PPPToo, HostPortalPrototype, n8n-workflows, and more | Ask your manager to add you to the org. If push/pull fails with "Bad credentials," generate a personal access token and update your git remote — see the `github-credentials-fix` process. | | Alliance MCP Gateway | Single MCP endpoint for Teams, GCP, Nexus, GitHub, Asana, and every other Alliance tool, role-gated | Robot/agent callers: get a PAT (`aag-pat-...`) from the admin portal (Users & Roles → Robot PATs). Human callers: sign in with Alliance SSO. Never commit a PAT — store it in 1Password or Secret Manager. | | Nexus (Ecosystem Governance Hub) | The architecture and onboarding doc site you're reading right now | `nexus.allianceabroad.com`, SSO-gated via Microsoft Entra — no separate signup needed. | | AAG MCP @ connector (ChatGPT) | Conversational Q&A over the whole Globus ecosystem — the guided onboarding path | ChatGPT → Settings → Apps → search "AAG MCP @" → Connect → authenticate with your company Microsoft/Entra account. It must be explicitly selected as the active source before asking Globus-specific questions. | | 1Password vaults (Globus Dev, Globus QA, alliance-mcp-services) | Shared credentials for local dev and testing — including the dedicated E2E bot accounts | Requested via your manager or the platform engineer; you're added to the vault(s) relevant to your work. | | GCP IAM (`skilled-script-469819-f2` dev project, `alliance-mcp-services`) | Console/CLI access to Cloud Run, GKE, Cloud SQL, and Secret Manager for the environments you touch | Requested via the platform engineer. Access is scoped per environment — development is the default starting point; QA/prod access is granted separately as needed. | | Asana workspace | Ticket tracking — where all implementation work is scoped, assigned, and closed out | Invited by your manager. Orient yourself with the Asana Ticket Standard page (In-App Help section) and find the board your initiative routes through. | | Microsoft Teams | Platform Pulse channel (automated alerts) + the engineering team chat (human coordination) | Standard company Microsoft 365 account. Ask to be added to the Platform Pulse channel and the engineering group chat. | | Replit | Some services are developed/iterated in Replit before landing in GitHub | Requested per-repo, only if your work touches a Replit-hosted service. | ## Recommended Order 1. GitHub org access — you need this to read any code at all. 2. Alliance MCP Gateway PAT — this is the multiplier; it unlocks Teams, GCP, Asana, and Nexus itself through one credential. 3. Nexus — read Overview & Architecture and the Glossary before touching code. 4. AAG MCP @ connector — start asking the recommended onboarding questions (see the AAG MCP @ Connector Onboarding page). 5. 1Password vault access — needed the moment you try to run anything locally. 6. GCP IAM — needed once you're deploying or debugging against a live environment. 7. Asana + Teams — needed to pick up your first ticket and coordinate with the team. ## Personal vs Shared Credentials The gateway uses two auth modes, and it matters which one a tool you're calling falls under: - **Shared** — a genuinely org-wide service with one credential and no per-user concept. Only two: HubSpot and Nexus/ecosystem. Any valid gateway token uses the shared service credential. - **Personal** — the default for everything else (Teams, GCP, Asana, GitHub, 1Password, and more). The gateway forwards *your* per-user token so you act as yourself, and no user sees another's data. If you haven't connected your own account yet, the gateway falls back to a shared service credential so the tool still works, then upgrades to your personal token once you connect. > **Never use a real person's personal login for testing.** Portal testing (Applicant Portal, PPPToo, BackOffice) always uses the dedicated E2E bot accounts provisioned per environment — never your own or anyone else's real credentials. Resetting someone's personal password to "test" it locks them out of their real workday. ## Notes & Troubleshooting - A gateway tool responding "requires authorisation" means that backend is personal-mode and needs you to connect it via OAuth in the setup portal first. - 401s from the gateway mean your `Authorization: Bearer` PAT is missing or wrong — re-check your MCP client config. - GitHub "Bad credentials" almost always means a stale PAT in your git remote, not a broken account — regenerate and `git remote set-url` rather than escalating. --- --- title: "Local Environment Setup" slug: onboarding-local-environment section: Developer Onboarding status: stable description: "Getting the Alliance portal stack running on your own machine, so you can develop and debug against a real, logged-in session instead of guessing from code alone." last-updated: 2026-10-07 --- # Local Environment Setup Getting the Alliance portal stack running on your own machine, so you can develop and debug against a real, logged-in session instead of guessing from code alone. ## Overview Most day-to-day work touches more than one service at once — a portal frontend, its BFF, Keycloak for auth, and the underlying Globus API. The local stack wires all of it together so a login actually works end-to-end on your machine, not just in a deployed environment. > **This page is an orientation, not a command reference.** Exact commands, ports, and known breakages drift as the stack evolves — the authoritative, kept-current source is the platform engineering team's local-stack runbook. Ask your buddy or platform engineer for the current version if anything below is stale. ## Prerequisites - **Node** — Node 20: PPPToo declares `engines` `>=20 <21`, and the BackOfficePrototype and itrequestportal Docker images and the main CI workflows use Node 20, so a newer LTS is not guaranteed to work (the PPPToo server image itself builds on Node 22). Python is not part of the toolchain for any Alliance repo. - **A browser you can automate** — headless browser automation in PPPToo's `e2e/` workspace goes through `puppeteer`, pointed at your existing Chrome via `E2E_CHROME_PATH` or `PUPPETEER_EXECUTABLE_PATH`; without either it uses puppeteer's own browser download. - **Docker** (or native installs) for Keycloak and Redis, if you're not pointing at shared dev instances of those. - **1Password CLI/desktop access** — local dev credentials for Keycloak realms and API keys live in the Globus Dev vault (see the Access & Accounts Checklist page). ## The Stack A full local run typically brings up: | Component | Role | | --- | --- | | BackOfficePrototype (BOP) | The Globus staff-facing back office — applicants, applications, program configuration. | | PPPToo (Participant Portal v2) | The applicant-facing portal, plus its NestJS BFF layer that proxies to Globus. | | HostPortalPrototype | The host-organization-facing portal. | | Keycloak | Identity provider for the `applicant-portal` realm — every portal login flows through it. | | Redis | Not part of the current stack: the PPPToo BFF is stateless (Bearer tokens, no server-side session) and BackOfficePrototype's server has no Redis dependency. | ## Boot Order Auth has to exist before anything can log in, and the API has to exist before a BFF can proxy to it. As a rule of thumb: bring up Keycloak and Redis first, then the API-owning service (BOP), then the portal(s) and their BFFs last. ## Verifying It Worked Don't consider the stack "up" until you've done a real login, not just confirmed the process is listening: 1. Hit each service's health endpoint. 2. Log in through the portal UI (or via a direct BFF login call) with a dedicated test identity — never a real person's credentials. 3. Confirm the session survives a page reload (this is what actually proves Keycloak + BFF are wired correctly together, not just individually up; the portal keeps its tokens client-side in `sessionStorage`, so there is no server-side session). ## Common Gotchas - **Windows optional-deps gap:** installing `globus-sdk-src` on Windows can fail on optional native deps. Install with scripts skipped (`--ignore-scripts` on your package manager) rather than fighting the native build. - **BackOffice client build variables:** `VITE_PUBLIC_ENV` is baked into the bundle at build time and decides PostHog masking. `development` (or an unset value under `vite dev`) turns PII masking off, so replays and captures show real text and inputs; any other value, or an unset value in a built bundle, masks all text and inputs. PostHog itself stays off unless `VITE_POSTHOG_KEY` is set (`VITE_POSTHOG_HOST` defaults to `https://us.i.posthog.com`). `VITE_APPLICANT_PORTAL_BASE_URL` is read in `client/src/lib/constants.ts` (default `https://applicant-portal.replit.app`), but no other file in `client/src` imports that constant. - **CSRF tokens for local API testing:** the PPPToo BFF is Bearer-token only and has no CSRF layer. BackOfficePrototype's cookie-session routes do: echo the session's CSRF token in the `X-CSRF-Token` header on mutating calls, because a bare `curl`/Postman POST with only a session cookie will 403. - **DB seed state flip-flops:** a clean local DB boot doesn't always land on the same seed data twice in a row. If a test that worked yesterday now fails on missing data, check whether the seed step actually ran before assuming your code regressed. - **Address autocomplete needs a Maps key:** the BackOffice address field (organisation, partner detail, housing and activity-site forms) reads `VITE_GOOGLE_MAPS_API_KEY` at build time and loads the Google Maps Places script with it. With the variable unset no script loads and no suggestions appear, so the field behaves as a plain text input. - **Never reset your own or a colleague's real portal password to test locally.** Use one of the dedicated E2E bot accounts (see the Security & Secrets Handling page) — they exist exactly so nobody's real login has to be touched. --- --- title: "CI/CD & Deployment Process" slug: onboarding-cicd-deployment section: Developer Onboarding status: stable description: "How a change you make actually reaches a running environment — the current, live process." last-updated: 2026-10-07 --- # CI/CD & Deployment Process How a change you make actually reaches a running environment — the current, live process. (The Git Branching & Release Model page is the full specification of the sandbox branching model; this page is the short version of what's in effect today. Updated 2026-10-06, when BackOfficePrototype became the last portal repo to move onto it.) ## Overview Every portal repo (BackOfficePrototype, PPPToo, HostPortalPrototype, WildPacksApplicationForm) is on the sandbox branching model (WildPacks since 2026-10-03, PPPToo and HostPortal since 2026-10-04, BackOfficePrototype since 2026-10-06): branches are cut from `main`, deploy to pooled development and QA slots, and reach production from `main`: a plain deploy for PPPToo, HostPortalPrototype and WildPacksApplicationForm, and the GKE Gateway blue-green colour swap for BackOfficePrototype. Every deploy is a branch-driven GitHub Actions run — never a manual deploy, and never Cloud Build for these repos specifically, despite some older internal docs referencing it. > **There is no "staging" environment.** The only valid Globus environments are `development`, `QA`, and `production`. Any reference to `staging` or a staging-prefixed resource in a repo is legacy/dead and should not be used as a deploy target. ## Promotion Path ``` feature/-* → release/ → main ``` - **feature/``-\*** — cut from `main`; a push deploys to a pooled **development** slot (`dev-N.…`; `globus-dev-N` for BackOfficePrototype). It reaches the release branch by PR. - **release/``** — cut from `main`; a push deploys to a pooled **QA** slot (`qa-N.…`; `globus-qa-N` for BackOfficePrototype, whose current release branch is `release/2026-10-globus-pilot`). - **main** — production. A `release/*` → `main` PR (reviewed and approved; governance paths also require a Code Owner review) runs the production deploy. `hotfix/-*` → `main` is the only other way in. `development` is an environment, not a branch. The legacy `development`/`qa` branches are frozen (PPPToo, HostPortal, WildPacks) or dead rails awaiting their freeze ruleset (BackOfficePrototype, whose `deploy-development.yml`/`deploy-qa.yml`/`deploy-production.yml` were disabled on 2026-10-05) — never target them. ## Pipeline Tool GitHub Actions is the mandated CI/CD tool across the ecosystem — workflow files live in each repo's `.github/workflows/`. If you're debugging a deploy and find yourself looking at Cloud Build triggers for a portal repo, you're very likely looking in the wrong place; Cloud Build is used for a small number of specific microservices (OCR, Translation), not the main portals. ## Repo-Specific Processes Two repos have their own deliberately different promotion mechanics — know these before assuming the generic feature → release → `main` flow applies everywhere: - **form-engine-portable** — this is a *published package*, not a deployed service. Merging to `main` does **not** ship anything to consumers. Publishing only happens when a `v*` tag is pushed, which triggers the GitHub Actions publish workflow to GitHub Packages. Consumers (BackOfficePrototype, PPPToo) then need their own `package.json` bumped and redeployed to actually pick up the change. Skipping the tag push is the single most common way a form-engine fix looks merged but isn't actually running anywhere. - **n8n-workflows** — workflow changes are applied live via the n8n API first, verified for zero crashes, *then* the updated workflow JSON is fetched back and committed to the repo. This is the reverse order from normal code changes (commit → deploy); here it's apply → verify → commit. Pushing requires a GitHub App token, not a personal OAuth token, which lacks write access for this repo's automation. It is also the one repo still promoted `development` → `qa` → `main` by the dev-to-qa-sync-agent (the only `promote` repo left since vertex-agents #285, 2026-10-06). ## Pre-Merge Gates Nothing merges on CI green alone. Before a PR is approved: - **slopgate** runs against the diff to catch AI-generated code smells (not just lint) — run it locally before pushing (`slopgate --base origin/` for a feature branch — e.g. `origin/release/2026-10-globus-pilot` in BackOfficePrototype — and `origin/main` for a hotfix or a release → main PR), since local-green and CI-green can drift on slopgate's own version. - The PR review process pulls the branch and **functionally verifies** the change returns correct data — a passing test suite is necessary, not sufficient. See the PR & Code Review Process page. ## Post-Deploy Verification After a deploy lands, don't consider it "done" until you've confirmed the running service actually reflects it — check the service's health endpoint, and for anything touching secrets or environment config, confirm the new revision picked up the right values rather than assuming the deploy succeeded because the pipeline went green. BackOfficePrototype also posts deploy results to the Platform Pulse Teams channel. Its `Notify Platform Pulse` workflow runs when any other workflow in the repo completes, and alerts in two cases only: any workflow failure (the notifier itself is excluded), and a successful run of exactly `Deploy to Development`, `Deploy to QA` or `Deploy to Production`. The success match is exact workflow-name equality against that allowlist, never a substring check, so a PR linter such as `Lint Deploy Workflows` never triggers a deploy alert. The card is labelled `Deployed` on success and `Build Failure` on failure, and is posted through the Alliance MCP Gateway with a robot PAT. **Database settings (BackOfficePrototype).** All are optional environment variables with defaults. Connection pool: `DB_POOL_MAX` (default 20), `DB_POOL_IDLE_TIMEOUT_MS` (30000), `DB_POOL_CONNECTION_TIMEOUT_MS` (5000) and `DB_KEEPALIVE_DELAY_MS` (10000, the initial delay for TCP keepalive probes that stop Cloud SQL silently closing idle connections). Boot-time connect retry, so a slow first connection does not kill the process before it listens: `DB_BOOT_CONNECT_ATTEMPTS` (default 6, minimum 1) attempts spaced `DB_BOOT_CONNECT_RETRY_MS` (default 2000 ms, minimum 0); a non-numeric value falls back to the default, and after the last failed attempt startup throws. --- --- title: "Testing Practices" slug: onboarding-testing-practices section: Developer Onboarding status: stable description: "What \"tested\" actually means for an Alliance change, beyond a green checkmark." last-updated: 2026-10-07 --- # Testing Practices What "tested" actually means for an Alliance change, beyond a green checkmark. ## Overview Testing here has three distinct layers: a local AI-slop scan, the automated test suite, and a functional verification pass that actually exercises the change against real data. All three matter — each catches a different failure mode the others miss. ## Before You Push Run **slopgate** against your diff before pushing, not just letting CI catch it: ``` slopgate --base origin/release/ # feature branch: the release branch it will merge into (BackOfficePrototype today: release/2026-10-globus-pilot) slopgate --base origin/main # hotfix, or a release -> main PR ``` slopgate looks for AI-generated code smells — patterns that pass every conventional lint/type check but are still low-quality or subtly wrong (whole-file regenerations, misread diffs, common LLM anti-patterns). A clean local slopgate run and a clean CI slopgate run are not always the same thing — the two can drift on the tool's own version, so don't be surprised if CI flags something local didn't. ## CI Green Is Not Enough A passing test suite and green CI checks mean your change didn't break anything the suite already covers — they don't mean the change does the right thing. The PR review process's later gates require pulling the branch and actually exercising the change against real data to confirm it returns what it's supposed to. Treat "CI is green" as a necessary precondition to ask for review, not as evidence the feature works. ## End-to-End Test Accounts Never use your own or anyone else's real portal login to test a flow — dedicated E2E bot accounts exist for exactly this, provisioned per environment (development, QA) with known, stable states (a fresh application at the "Intake" stage, an application mid-flow, etc.). Using a real person's account risks corrupting their actual data and, if you reset their password to "test," locks them out of their real workday. Ask the platform engineer which bot account fits the flow you're testing — the right choice depends on what state the flow needs the applicant to be in (fresh vs. mid-application vs. a specific unaccepted agreement, for example). ## Test Cases Live in Kiwi TCMS Regression test cases are authored, reviewed and run from Kiwi TCMS at `tcms.allianceabroad.com`; the Atlas agent runs the CONFIRMED cases of its plan after every development deploy and records the results there. How to write a case, which Kiwi fields to fill, and how Atlas reads them is on [Test Case Standard (Kiwi TCMS)](/docs/test-case-standard). ## Load & Volume Testing The scheduled load/volume testing systems were decommissioned on 2026-08-26, so there is currently no scheduled volume coverage. A manually triggered k6 workflow (Dev Load Test, `workflow_dispatch` only, suite in `scripts/loadtest/` in BackOfficePrototype) exists. If you are adding a new high-traffic endpoint, raise load testing with the platform engineer rather than assuming manual spot-checks are sufficient. ## Notes - A test that passed yesterday and fails today on missing data is more often a seed-state problem than a regression — check the fixture/seed step before assuming your code broke something. - "It works on my machine" locally is not equivalent to "it works in dev" — environment-specific config (secrets, feature flags, VPC connectivity) is a common source of local-only-green results. --- --- title: "Test Case Standard (Kiwi TCMS)" slug: test-case-standard section: Developer Onboarding status: stable description: "How test analysts structure test cases, and how we log them in Kiwi TCMS at `tcms.allianceabroad.com`, the single source of test cases for humans and for the Atlas agent." last-updated: 2026-10-07 --- # Test Case Standard (Kiwi TCMS) How test analysts structure test cases, and how we log them in Kiwi TCMS at `tcms.allianceabroad.com`, the single source of test cases for humans and for the Atlas agent. ## Overview Kiwi TCMS (Kiwi) is the only place a test case lives. Humans write and review cases in the Kiwi web UI; Atlas pulls CONFIRMED cases from a Kiwi test plan before every run and writes a Kiwi test run with one execution per case afterwards. There is no bundled scenario folder in the agent image and no fallback: if Kiwi is unreachable or a plan has no runnable case, the run does not start and Platform Pulse is told why. This page sets the standard for what a case contains, how cases are grouped, and which Kiwi fields carry which information. It is based on ISO/IEC/IEEE 29119-3, Kiwi's own authoring guidance, and common practitioner guidance (see Sources below). The companion page [Testing Practices](/docs/onboarding-testing-practices) covers what "tested" means for a code change; this page covers the cases themselves. ## Anatomy of a Test Case ISO/IEC/IEEE 29119-3:2021 (the successor of IEEE 829) gives a test case nine parts. Practitioner guides converge on the same list under slightly different names. | Part | What it is | Common mistake | |---|---|---| | Identifier | Stable, unique, never reused | Encoding the module name in the id so it breaks on reorganisation | | Title / summary | What is verified, verb plus object, one line, under about 60 characters | "Save attachments", "Login works" | | Objective | One sentence: the behaviour being checked | Missing, so a reviewer cannot tell what pass means | | Preconditions / setup | State, data and environment that must already be true, stated precisely (which user, which record, which state) | "User is logged in" without saying who | | Inputs / test data | The values used, kept separate from the steps so they can vary | Hard-coding data inside step text | | Steps / actions | Numbered, low level, written for someone with no context, one action per step | Several actions in one step; more than about 15 steps | | Expected results | One observable, measurable result per step that asserts, never "works fine" | A single expected result at the end of 12 steps | | Postconditions / breakdown | What state is left and how to clean up; the case should be self-cleaning | Tests that only pass once | | Environment | Browser, device, environment, configuration | Implied, then the case flakes | | Traceability | Link to the requirement, story, spec or doc the case proves | Orphan cases nobody can justify deleting | Priority (business impact) is added by almost every team. The actual result lives on the execution, not the case. ## Writing Rules - **Atomic.** One behaviour per case. If a feature has three aspects, write three cases, so a failure points at one thing. - **Independent.** A case must be runnable on its own, in any order, with its own data setup. - **Deterministic.** No "sometimes". If the environment makes it flaky, fix the precondition or the environment; do not loosen the expected result. - **Measurable expected results.** Text shown, status code, record count, cookie absent, audit row written. - **Bounded.** Kiwi's guidance: fewer than 15 steps and executable in under 20 minutes. Split otherwise. - **Positive and negative.** For every happy path there is at least one invalid-input or forbidden-role case. - **Named consistently.** Verb plus object in sentence case, the same words for the same thing across cases. - **Prioritised.** P1 runs every build, P2 every release, P3 when the area changes. - **Traceable.** Every case cites the requirement or Nexus page it proves; coverage is measured against that. - **Reviewed.** A second person reads the case before it is trusted. Kiwi models this as PROPOSED to CONFIRMED. - **Maintained.** Obsolete cases are marked NEED_UPDATE or DISABLED, not left to rot in a suite everyone skips. - **Shared steps and data separated.** Common sequences (sign in, open record) are referenced, not pasted, so a UI change is one edit. Format choice: step and expected-result tables suit regression and manual execution; Given/When/Then suits acceptance criteria written with stakeholders. Both fit the same anatomy (Given = preconditions, When = steps, Then = expected results). A common blend is the objective in Given/When/Then and the body as numbered steps. ## Plans, Cases, Builds and Runs Every test management tool uses the same hierarchy. | Level | Meaning | Organising rule | |---|---|---| | Product and version | The system under test and its release line | One product per system, not per sprint; versions as a controlled list | | Test plan | Scope, purpose and strategy for an area or type of testing (regression, smoke, release) | Strategy only; steps live in cases | | Plan membership | Grouping of cases for a purpose | Group by feature area or type; use tags and components for cross-cutting dimensions (browser, role, journey) rather than duplicating cases | | Test case | One scenario with expected results | Reused across plans and runs, never copied per run | | Build | The concrete deployed artefact (commit, image, release) | One build per deploy; never reuse one build forever | | Test run | Execution of selected cases against one build in one environment | One run per build and environment; temporary, not a backlog | | Test execution | One case inside one run: status, comment, bug links, who, when | Always leave a comment or bug link on anything that is not a pass | ## How Kiwi TCMS Expects It Logged Kiwi's design principle, from its own introduction: "If it is not in the TCMS then we do not do it, and conversely, if we do it then it is in the TCMS." **Entities.** Classification, then Product, then Version, then Build. A Test Plan (types include Unit, Integration, Function, System, Acceptance, Smoke, Regression) holds Test Cases. A Test Run (plan + build + manager) holds Test Executions. Cross-cutting: Tag, Component (a feature or module of a product), Category (per product), Priority (P1 to P5), Properties (free key/value pairs on cases, runs and executions), bug and issue-tracker links, attachments. **Test case fields.** Summary, text (Markdown), notes, requirement, extra link, priority, category, status, is automated, script, arguments, default tester, reviewer, setup and testing duration, components, tags, plans, properties. **The text field has a built-in four-section template.** Kiwi merged its old separate fields into this layout and the editor pre-fills it: ``` **Setup:** **Actions:** **Expected result:** **Breakdown:** ``` Kiwi's authoring appendix: the summary is verb plus object, under 60 characters, sentence case; actions are written "clearly and at a low level, assume the tester has no experience"; expected results are stated in accurate terms, never "works fine"; cases should be accurate, economical, repeatable, appropriate, traceable and self-cleaning. **Review workflow.** New cases are PROPOSED. A reviewer checks the environment setup, test data, summary, prerequisites, clear actions with fewer than 15 steps and clear effects, then sets CONFIRMED. Only CONFIRMED cases can be added to a run. NEED_UPDATE and DISABLED cover drift and retirement. **Runs and executions.** Create a run per build (and per environment if more than one), add the cases, execute, and set the status (IDLE, RUNNING, PAUSED, PASSED, FAILED, BLOCKED, ERROR, WAIVED) with a comment and bug link where relevant. The Telemetry pages then report pass rates per plan, build, tester and over time. **Dimensions.** Use tags, components, categories and plan membership for browser, role, environment, journey and feature. Do not import a spreadsheet row per variant: normalise first, or the first automation import creates hundreds of near-identical records that nobody trusts. **Anti-patterns Kiwi users warn about.** A product per sprint; free-text version labels; one build reused forever; steps written in the plan document; the same case duplicated per run; a run kept as a permanent backlog; execution status changed without a comment or bug link; never confirming cases; never closing obsolete plans; letting automation create inconsistent products and versions. ## Conventions for tcms.allianceabroad.com | Kiwi field | Convention | |---|---| | Product | `Alliance Platform` (one product; versions are a controlled list) | | Test plan | One per purpose, named `Atlas: ` for plans Atlas runs (today `Atlas: regression`); human-only plans use a plain name | | Summary | Verb plus object, sentence case, under 60 characters. Example: "Load applicant intake pages as staff" | | Text | Kiwi's four sections: Setup (identity, environment, start state), Actions (numbered, one action per line), Expected result (one per asserting step), Breakdown ("read-only, nothing to clean up" or the cleanup) | | Status | Author as PROPOSED; a second person confirms. Atlas runs CONFIRMED cases only | | Priority | From risk: impact 3 = P1, impact 2 = P2, else P3 | | Category | `Atlas` for automated cases, `Manual` for human-only cases, so a run can mix both and the agent materialises only the automated ones | | Components | One per portal area: Applicants, J-2, Compliance, SEVIS, Admin, Partners, Reporting, Finance, Notifications, Sign-in | | Tags | Journeys `J01` to `J20`; identity used (`staff-e2e-dev`, `applicant-e2e-dev`); any cross-cutting dimension | | Requirement | The Nexus page the case proves (its `/docs/` path), so coverage can be read per page | | Is automated + script | Set for Atlas cases; the Automation script field holds the harness scenario JSON (`id` of the form `S-Jxx-nnnn`, `steps`). Humans do not edit it by hand | | Properties | `scenario_id` carries the harness id, so the summary stays human-readable | | Build | One per deploy, named ` ` for deploy-triggered runs; scheduled runs use the date and time | | Run | One per build and environment. Environment is a run dimension, not part of the case | | Execution | Status plus a comment on every non-pass: verdict, attempts, failure class and reason, link to the run record | Housekeeping: close plans that are no longer run, mark drifted cases NEED_UPDATE instead of editing silently, never reuse a build name across deploys. ## How Atlas Reads and Writes Kiwi Atlas (see [Vertex Agents](/docs/vertex-agents)) signs in to Kiwi as the `atlas-agent` user over JSON-RPC. 1. **Before a run**: it finds the active plan named `Atlas: `, filters its CONFIRMED cases, and writes each case's Automation script into a fresh directory the Playwright harness reads. Cases whose script is missing, not valid JSON, has no `S-Jxx-nnnn` id or no steps, or that are not flagged automated, are reported as not runnable rather than skipped silently. A duplicate scenario id across cases is also not runnable. 2. **After a run**: it finds or creates the Build for the deploy, creates a Test Run on the plan, adds every materialised case, and sets each execution's status from the Atlas verdict: `pass` = PASSED, `fail` = FAILED, `blocked` = BLOCKED, `flaky` = ERROR (never a pass), unevaluated = IDLE. Not-runnable cases are added as BLOCKED with the reason as a comment. Every non-pass gets a comment with the verdict, attempt count, failure class and reason. 3. **Platform Pulse** receives the summary with a link to the Kiwi run, so the TCMS is the record and Teams is the notification. Writing a new automated case therefore means: author it in Kiwi with the four-section text for humans, paste the scenario JSON into the Automation script field, tick "is automated", add it to the `Atlas: regression` plan, and have it confirmed. It runs on the next deploy of BackOfficePrototype to development. ## Sources - ISO/IEC/IEEE 29119-3 test case anatomy: [tessl.io test-case anatomy reference](https://tessl.io/registry/testland/test-case-anatomy-reference) - Perforce, [Test case best practices](https://www.perforce.com/blog/alm/test-case-best-practices) - Testiny, [How to write test cases](https://www.testiny.io/how-to-write-test-cases/) - TestLodge, [How to write test cases](https://www.testlodge.com/resources/learning_center/test_cases/how_to_write_test_cases) - TestMonitor, [Test case vs test suite vs test run](https://www.testmonitor.com/blog/test-case-test-suite-test-run-whats-the-difference) - Automation Panda, [BDD 101: the Gherkin language](https://automationpanda.com/2017/01/26/bdd-101-the-gherkin-language/) - Kiwi TCMS docs: [Introduction](https://kiwitcms.readthedocs.io/en/latest/guide/introduction.html), [Test cases](https://kiwitcms.readthedocs.io/en/latest/guide/testcase.html), [Appendix: writing test cases](https://kiwitcms.readthedocs.io/en/latest/guide/appendix.html) - Kiwi four-section text template: [migration 0006](https://github.com/kiwitcms/Kiwi/blob/master/tcms/testcases/migrations/0006_merge_text_field_into_testcase_model.py) - QA Skills, [Kiwi TCMS open-source test management guide](https://qaskills.sh/blog/kiwi-tcms-open-source-test-management-guide) --- --- title: "PR & Code Review Process" slug: onboarding-pr-review section: Developer Onboarding status: stable description: "Every Alliance PR — BackOfficePrototype, PPPToo, HostPortalPrototype, and the rest — goes through the same gated review sequence before it merges." last-updated: 2026-10-07 --- # PR & Code Review Process Every Alliance PR — BackOfficePrototype, PPPToo, HostPortalPrototype, and the rest — goes through the same gated review sequence before it merges. Knowing the shape of it in advance means fewer surprise round-trips. ## Overview The process has two halves: an automated pre-check that runs before any human or AI reviewer looks at the diff, and a sequence of gates that actually judge the change. A PR can fail at Gate 0 and never reach substantive review at all — that's by design, since it's cheap to catch there and expensive to catch later. ## Gate 0 — Quality/SLOP Pre-Check Before anything else: a merge-conflict check, a slopgate run, an AI-slop pattern scan, and a Nexus semantic-correctness check (does the change contradict what the architecture docs say is true). Any failure here blocks further review until fixed — there's no point running the expensive specialist gates against a diff that doesn't even apply cleanly. ## Gates 1–6 — Specialist Review The substantive review runs in parallel across multiple gates, each looking at the change from a different angle — correctness, security, design/reuse, and so on. Critically, one of these gates requires **pulling the branch and functionally verifying the change returns correct data** — not just reading the diff. A change that looks correct on paper but hasn't been run against real data has not cleared this gate. ## Posting the Verdict The reviewer's findings are posted as a **native GitHub PR review** (approve / request changes / comment), not as a plain issue comment — this keeps the verdict attached to the PR's own review history and gates GitHub's merge button correctly when changes are requested. ## Gate 7 — Author-Fix Verification If changes were requested, the *author* fixes them — the reviewer never edits the branch directly, even to save a round-trip. Once fixed, Gate 7 is a re-review: the reviewer checks the specific findings were actually addressed, not a full re-run of every earlier gate from scratch. ## Merge Procedure Once approved, the standard procedure is a **squash merge with branch deletion** — one clean commit onto the target branch, and the feature branch removed rather than left to accumulate. (Note: this generic procedure applies to routine feature-branch PRs; a few repos or branch types have their own distinct promotion mechanics — see the CI/CD & Deployment Process page for the exceptions.) ## Automation Status The gate sequence above is enforced by two live automation components, not left to human/AI reviewer discretion alone: - **`pr-review-agent`** (Vertex AI Agent Engine, `skilled-script-469819-f2`/`us-central1`) evaluates Gates 0–7 above against a PR's diff and renders a structured verdict (`approved` / `changes_requested` / `commented_pending`). It fetches this policy live from the Gateway's `pr-review` skill on every call, so it never drifts from what's documented on this page. It has no merge capability in any mode — findings are always left for the author to fix. - **`pr-review-eligibility-agent`** (Cloud Run) is the dispatcher: it listens for GitHub `workflow_run` webhook events, confirms every required status check is green on the PR's current head SHA, and only then calls `pr-review-agent`. This is what implements the "required checks green" condition deterministically rather than trusting the diff alone. As of 2026-08-19, this pipeline is live in `review_only` mode (posts exactly one native GitHub review + one Teams notification per terminal verdict) on `BackOfficePrototype`, `HostPortalPrototype`, and `PPPToo`. A PR authored by this automation's own GitHub App identity (`alliance-agent-workflow[bot]`, e.g. an automated dependency-bump PR) still gets reviewed, but its GitHub review is posted as a COMMENT rather than APPROVE/REQUEST_CHANGES — GitHub's API rejects a reviewer approving or requesting changes on its own PR — with the real verdict called out explicitly in the comment body instead. --- --- title: "Debugging & Observability" slug: onboarding-debugging-observability section: Developer Onboarding status: stable description: "Where to look when something is broken, slow, or silently failing — before you start guessing." last-updated: 2026-10-07 --- # Debugging & Observability Where to look when something is broken, slow, or silently failing — before you start guessing. ## Overview Alliance's observability surface is split across a few tools by layer: application/error logs, cluster-level pod health, in-app session/error tracking, a human-facing audit trail, and a single alerting channel that automated agents post to. Knowing which layer a symptom belongs to saves a lot of dead-end investigation. ## Logs Application logs and error logs for any GCP-hosted service are available per environment through the platform's logging tools — filter by service and time range before assuming a failure is new; many "new" errors have been quietly occurring for a while at low volume. BackOfficePrototype logs are structured JSON from a pino logger, mapped to GCP severity labels. `LOG_LEVEL` sets the level (default `info`; `debug` is typical locally), and only when `NODE_ENV` is `development` does the logger switch to human-readable pino-pretty output. Fields such as passwords, tokens, authorization headers and cookies are redacted. ## Pod & Service Health If a service is crash-looping, failing to start, or can't reach a dependency, the first thing to check is pod status and recent events at the cluster level, followed by Workload Identity configuration if the failure looks auth-related (a pod that can authenticate to nothing is a very common failure shape after a service account or binding change). Cloud Run services follow the same "check the running revision's logs and env" pattern. The Hub's `/api/health` and `/api/globus-status` probes report which tier answered in their `environment` field. That label comes from `CONFIG_ENV` (each tier's ConfigMap sets `development`, `qa` or `production`), falling back to `NODE_ENV` and then to `development` only where no ConfigMap exists (local runs, tests). `NODE_ENV` does not identify the tier, because the image sets it to `production` in every deployed tier, so it no longer drives the label. The field name is unchanged. ## Session & Error Tracking The portals (BackOfficePrototype, PPPToo, HostPortalPrototype) use PostHog for session replay and structured error tracking in the browser layer — useful for reproducing a user-reported bug you can't trigger yourself, since you can often watch the actual session that hit it. ## Audit Trail Every gateway tool call is audited (caller identity, tool, timestamp, allow/deny), and Globus itself has a dedicated Audit Console (see the governance section) for tracking who changed what applicant/application data and when. If you're investigating "who did this and when" rather than "why did this crash," the audit trail is the right tool, not the logs. ## Alerting Surface Automated findings (agent failures, workflow errors, health check failures) post to the **Platform Pulse** Teams channel — never as a direct message. If you're building or modifying an automated agent, its alerts belong there, formatted as HTML (Teams does not render Markdown syntax from the API), with a single 🤖 prefix and a status emoji (🟢/🟡/🔴) indicating severity. One scheduled monitor that follows this pattern is the `[Stability] Email Health` workflow. It runs hourly and probes the email surface for the development and QA environments (production is disabled in the workflow): whether the SendGrid key is valid and has the `mail.send` scope, whether Keycloak admin login and an SMTP send (an `execute-actions-email` call on a throwaway test user) succeed, and whether the n8n SendGrid delivery workflow logged more than three errors in the last hour. On any failure it posts a summary to Platform Pulse through the gateway tool `teams_send_bot_channel_message` (the gateway URL comes from the `GATEWAY_MCP_URL` environment variable, which must be set on the n8n pod or the post fails), and in parallel files one Asana ticket per distinct finding in the Platform Pulse project's New Intake section, titled `[Email Health] — ` (for example SendGrid key invalid, Keycloak SMTP send failing, n8n SendGrid delivery errors) and assigned to Sam. Tickets are deduplicated by exact title against the tasks already in New Intake, so a repeat finding is skipped instead of filed again. The ticket step authenticates to Asana with the `ASANA_PAT` environment variable on the n8n pod. ## CI Build Notifications The `n8n-workflows` repository posts GitHub Actions results to Platform Pulse, so pipeline failures and successful environment deploys show up in the same channel as agent alerts. The workflow `notify-platform-pulse.yml` runs on `workflow_run` completion for `ci`, `Deploy to Dev`, `Deploy to QA`, `Deploy to Production`, `Export Dev Workflows` and `Test Sandbox`. It only notifies when the run's branch is `development`, `qa` or `main`, and only in two cases: | Trigger | Condition | Card label | | --- | --- | --- | | Failure | Any watched workflow concludes `failure` (the notifier never reports on itself) | Build Failure | | Deploy success | `Deploy to Dev`, `Deploy to QA` or `Deploy to Production` concludes `success` | Deployed | The composite action `.github/actions/notify-platform-pulse` builds an HTML card (status emoji, repository, workflow name, branch, triggering actor, link to the run) with a single 🤖 prefix and posts it to the Platform Pulse channel by calling the gateway tool `teams_send_bot_channel_message` with `contentType` set to `html`. It authenticates with a dedicated gateway robot PAT stored as the repository secret `PLATFORM_PULSE_GATEWAY_PAT`. > **Notification failures never fail the build** > > If the gateway call errors, returns a tool error or the request itself fails, the action only emits a GitHub Actions warning annotation. The originating workflow is unaffected. The action also maps a `cancelled` status to a yellow "Build Cancelled" card, but the workflow's `if` condition currently only lets failures and deploy successes through, so cancelled runs are not posted. **Cloud Build Teams notifier.** BackOfficePrototype also ships a Cloud Run service, `cloudbuild-teams-notifier` (`infra/cloudbuild-teams-notifier`), that turns Cloud Build events into Teams cards. It exposes `GET /healthz` and `POST /pubsub` (Pub/Sub push; malformed messages get 400, non-terminal or filtered builds get 204, a failed Teams post gets 502 so Pub/Sub redelivers). Configuration by environment variable: `TEAMS_WEBHOOK_SECRET` is the Secret Manager version resource holding the Teams webhook URL (default `projects/skilled-script-469819-f2/secrets/cloudbuild-teams-webhook-url/versions/latest`), and `TEAMS_WEBHOOK_URL` supplies the URL directly and overrides the secret (local and test use). `GITHUB_TOKEN_SECRET` is the optional Secret Manager resource for a GitHub token used to add commit details to the card, and `GITHUB_TOKEN` supplies a token directly; with neither, cards are posted without commit enrichment. `ALLOWED_BRANCHES` (comma-separated, default all branches) and `NOTIFY_ON_SUCCESS` (default true) filter what is posted. **Portal base URL settings.** Email links that point applicants and hosts at a portal are built from two global settings that administrators edit through `/api/admin/applicant-portal/config` and `/api/admin/host-portal/config` (the admin portal config screens). They are stored in `integration_configs` as `applicant_portal/base_url` and `host_portal/base_url`. A saved value wins over the environment fallback (`APPLICANT_PORTAL_BASE_URL`, then `APPLICANT_PORTAL_URL`, for the applicant portal; `HOST_PORTAL_BASE_URL` for the host portal; a saved host value that resolves to the applicant portal origin is refused at read time and skipped), and clearing the field and saving removes the override and restores the fallback. Editing requires the `system_settings.manage` permission. A value must be an http or https URL whose host is not `localhost` or `127.0.0.1` (otherwise `INVALID_URL`), and a trailing slash is removed on save. The Host Portal URL is additionally rejected with `CROSS_PORTAL_ORIGIN` if it shares the Applicant Portal origin, so host invitations cannot be sent to the applicant portal. These settings are read fresh from the database rather than from the shared integration-config cache, so the config screens show the persisted value even when a different server handles the reload, and the email URL resolvers on other servers pick up a change within about 30 seconds. If a link in an email points at the wrong portal, check these two settings first. **Back-office base URL.** Links that point staff at the back office (for example the staff welcome email) use a third base URL. It resolves to the `integration_configs` entry `platform` / `base_url` when set, else the `PLATFORM_BASE_URL` environment variable, else `PUBLIC_BASE_URL`, else the first entry of `REPLIT_DOMAINS` (as an https URL), else the origin of the current request, and finally `http://localhost:5000`. The environment-stable part is cached for 30 seconds; the request origin is never cached. The signed participant-profile PDF link resolves its own origin separately (see Notification Model). --- --- title: "Security & Secrets Handling" slug: onboarding-security-secrets section: Developer Onboarding status: stable description: "The rules that keep credentials, tokens, and applicant data safe across a fairly large, multi-environment ecosystem." last-updated: 2026-10-07 --- # Security & Secrets Handling The rules that keep credentials, tokens, and applicant data safe across a fairly large, multi-environment ecosystem. ## Overview Nothing here is exotic — it's mostly "know which vault a secret lives in, know which token type a task actually needs, and never touch a real person's login to make testing easier." The failures that actually happen are almost always one of those three getting skipped, not a sophisticated attack. ## 1Password Vault Model Secrets are organized per environment/purpose, not pooled into one shared vault: - **Globus Dev vault** — dev credentials, dev E2E bot accounts, dev-scoped API keys. - **Globus QA vault** — the QA equivalents; QA credentials are deliberately separate from dev so a dev secret leak doesn't also compromise QA. - **alliance-mcp-services vault** — credentials for the shared MCP server fleet (Teams, GCP, GoDaddy, etc.), isolated from the Globus dev/QA/prod environments entirely. Never commit a secret to a repo "temporarily" — every credential you'll need for local dev or testing already has a home in one of these vaults; if it doesn't, that's a gap to flag, not a reason to hardcode it. ## GCP Secret Manager & External Secrets Production and QA GCP secrets live in Secret Manager and sync into Kubernetes via the External Secrets Operator (ESO). Two failure modes are common enough to know about in advance: - **Trailing newline contamination** — a secret written with a trailing newline (common when piping from a shell) can silently break auth for the consuming service even though the value "looks right" in the console. - **Stale ESO sync** — a secret updated in Secret Manager doesn't always propagate immediately; a forced re-sync is sometimes needed rather than waiting. Asana credentials in the Hub (`server/asana.ts`) are resolved in order: (1) `ASANA_CLIENT_ID` and `ASANA_CLIENT_SECRET`, used only when both are set, to fetch an OAuth2 client-credentials token that is cached until 60 seconds before expiry; (2) `ASANA_ACCESS_TOKEN`, a direct token; (3) the Replit Connectors token. If the client-credentials request fails once (for example a placeholder id or secret in Secret Manager), it is skipped for the rest of the process lifetime and the next mode is used. The Hub only throws when none of the three is available. ## GitHub Tokens: PAT vs App Not every GitHub write action can be done with a personal OAuth token. Workflow-file changes and certain automated pushes require a **GitHub App token**, which the platform mints on demand — a personal PAT will silently lack the scope and fail in ways that look like a permissions bug rather than a wrong-token-type issue. If an automated push fails specifically on `.github/workflows/*` changes, check the token type before assuming it's an IAM problem. ## Never Touch Personal Accounts > **Standing rule, no exceptions without an explicit ask.** Never reset, rotate, or otherwise mutate the credentials of a real person's personal account — Keycloak, Globus, or anything else — for testing, reseeding, or any automated purpose. Doing this to a real colleague's login locks them out of their actual workday for no benefit; dedicated E2E bot accounts exist in every environment specifically so this is never necessary. The only exception is a person explicitly asking you to reset **their own** login because they're genuinely locked out — that's a user-requested fix, not a testing shortcut. ## PII & Data Handling Applicant data (passports, background checks, biographical fields) has its own handling rules covering redaction, storage, and who can view what — see the PII & Data Handling page under Governance & Operations. If your change touches applicant records in any way, read that page before assuming your existing intuition about "sensitive data" covers it — some of the redaction rules are stricter than they look at first glance. ### Hub encryption and signing secrets Four Hub (BackOfficePrototype) environment variables hold keys that must be stable. In production all four throw when unset; outside production each falls back to a random per-boot key, so anything encrypted or signed under it cannot be decrypted or verified after a restart or by another instance. | Variable | Used for | Notes | | --- | --- | --- | | `PII_ENCRYPTION_KEY` | Encrypts MFA secrets and PII fields (`encryptField` and `decryptField` in server/lib/crypto-utils.ts) | Required in production. Decryption tries the env key and, outside production, the per-boot key too. | | `SEVIS_CERT_ENCRYPTION_KEY` | Encrypts stored SEVIS certificates | Required in production; separate from the PII key. | | `PARTICIPANT_PROFILE_PDF_SECRET` | HMAC-SHA256 signing of the short-lived participant-profile PDF URLs that host emails link to | Required in production so signatures verify across Cloud Run instances. Optional `PARTICIPANT_PROFILE_PDF_TTL_SECONDS` sets the lifetime (default 24 hours, clamped to 60 seconds to 7 days). | | `PORTAL_SESSION_SECRET` | HMAC signing of stateless portal session tokens (`POST /api/portal/session`) | Required in production. Optional `PORTAL_SESSION_TTL_SECONDS` sets the lifetime (default 30 minutes, clamped to 60 seconds to 24 hours). | `SEVIS_CERT_PASSWORD` is separate from the four keys above: it is the password of the SEVIS PKCS#12 certificate, read from the environment and with no per-boot fallback. A certificate upload falls back to it (or to an empty password) when the request carries no `password`, and direct SEVIS credentials use it alongside `SEVIS_CERT_PATH`. --- --- title: "Golden-Path Recipes" slug: onboarding-golden-paths section: Developer Onboarding status: stable description: "Pre-approved, \"just follow these steps\" processes for the tasks that come up repeatedly." last-updated: 2026-10-07 --- # Golden-Path Recipes Pre-approved, "just follow these steps" processes for the tasks that come up repeatedly. Following the recipe is almost always faster than reasoning it out from scratch — and avoids the specific mistakes past attempts already made. ## Overview Every row below corresponds to a task type Alliance engineers do often enough that the *right* process is already documented and battle-tested. Deviating from a recipe isn't forbidden, but it means you're now responsible for rediscovering the gotchas the recipe already encodes. ## Recipes | Task | Follow this process | | --- | --- | | Add or change a form field | Change it in `form-engine-portable`, then **tag a `v*` release** — publishing only triggers on the tag, not on merge to `main`. Bump both `BackOfficePrototype` and `PPPToo` to the new published version and verify each consumer picked it up. | | Add a new MCP server | Deploy it to Cloud Run in the dedicated `alliance-mcp-services` GCP project — never as a Manus-hosted built-in connector, and never a per-environment variant (there should be exactly one instance of any given MCP server). Register it behind the Alliance MCP Gateway so it's reachable as a role-gated tool. | | Add or modify an n8n workflow | Apply the change live via the n8n API first, verify zero crashes in post-fix executions, then fetch the updated workflow JSON and commit it to the `n8n-workflows` repo. Push with a GitHub App token — a personal OAuth token lacks write access for this. | | Add a new role or permission | Define the permission key, gate the relevant route, and wire the role into BackOfficePrototype's RBAC model rather than checking ad hoc conditions in the handler. | | Provision or reset a Globus/BackOffice user | Use the standard provisioning workflow — Cloud SQL proxy access, a DB upsert, MFA disable if needed, credential generation, and delivery via Teams DM. Never hand-edit the database directly outside this flow. | | Deploy a change to BackOfficePrototype | Sandbox branching model (since 2026-10-06), all through GitHub Actions: cut `feature/-*` from `main` (a push deploys it to a `globus-dev-N` pool slot), merge it by PR into the current `release/*` branch (deploys to a `globus-qa-N` pool slot), then the release → `main` PR runs the GKE Gateway blue-green cutover. Never push to `development` or `qa` (dead rails), and never Cloud Build for this repo. | | Reset a portal login | If the Keycloak account already exists, it's a password reset. If it doesn't (a staff member who was never provisioned), it's a provisioning task first — check which situation you're in before picking a process, since they're not interchangeable. | ## When No Recipe Exists If you're about to touch a production-adjacent system (real applicant data, a shared credential, a live deploy pipeline) and there's no recipe here for what you're doing, that's a signal to ask before improvising — not a gap to quietly fill in with your best guess. If the same ad hoc task comes up more than once, that's the point to propose it as a new recipe on this page. --- --- title: "Repo Inventory" slug: repo-inventory section: Developer Onboarding status: stable description: "What repos exist under `Alliance-Strategies`, what each one does, and how you actually work in it — the map that's normally missing before a new contributor's first PR." last-updated: 2026-10-07 --- # Repo Inventory What repos exist under `Alliance-Strategies`, what each one does, and how you actually work in it — the map that's normally missing before a new contributor's first PR. ## Overview The Alliance Abroad codebase is split across roughly 50 repos in the `Alliance-Strategies` GitHub org. Most of what a new contributor touches day-to-day falls into four groups: the portals (applicant, host, partner), a small number of shared packages consumed by more than one portal, the platform infrastructure that runs and documents everything, and a long tail of MCP server repos that back the Alliance MCP Gateway. This page inventories all of them as of 2026-08-14. Repos with a null or missing GitHub description are marked as such — their purpose below is a best-effort inference from naming, related docs, and skills, not a confirmed fact; verify with the repo's own README before relying on it. ## Portals & Applicant-Facing Apps | Repo | What it does | How you work in it | | --- | --- | --- | | **BackOfficePrototype** (BOP) | Staff-facing back-office portal (Globus BackOffice) | Deploys via **GitHub Actions**, not Cloud Build (some older docs incorrectly say Cloud Build). All BOP work MUST happen in a dedicated `wt-bop-*` git worktree — never a shared/default clone, since a shared clone's branch got switched underneath concurrent agents before and wiped in-progress work. The "Quality Gate" CI check has quirks: the blob-reporter can hide real failures, and bot-authored commits get zero CI checks run, so a human must verify CI actually ran rather than trusting the absence of a red X. PRs land via sha-pinned squash-merge, then the branch is deleted. | | **PPPToo** (`ParticipantPortalPrototype2`) | The live, current applicant-facing portal v2 — a full greenfield rebuild | Consumes `globus-sdk-src` as its Globus API facade and `form-engine-portable` for dynamic form rendering. Known bug class: a "client-only" PR that only touches frontend code often actually needs a paired backend-for-frontend (BFF) route change — always grep the BFF routes before approving a client-only-looking PR. | | **HostPortalPrototype** | Host Portal — the host-organization-facing app (org creation, offers, camp/activity sites, arrivals, user invitations) | GitHub description is null on this repo; purpose inferred from the nexus-docs "Host Portal" doc group and the `host-portal-provisioning` skill. Provisioning, ingress, and CI/CD issues are covered by that skill. Production runs on Cloud Run (not GKE) as of 2026-09-18 — see the Repo-Specific note below on the drift incident this cutover caused. | | **WildPacksApplicationForm** | Public Camp Counselor and Wild Packs SWT application form on `apply.wildpacks.com` and its small Express API. Stateless: writes a HubSpot Contact and Application, no database, no Globus calls | Standalone pnpm monorepo, not part of Globus or PPPToo. Branches are `development`, `qa` and `master` (production deploys from `master`); production runs on GKE, dev and QA on Cloud Run. See [WildPacks Application Form](/docs/wildpacks-application-form). Note: the legacy `wildpacks.com` Azure app is a separate, unrelated domain — never touch it from this repo's context. | | **PartnerPortal** | Likely the partner-organization-facing app backing the nexus-docs "Partner Portal" doc group (Partner Portal Governance, Partner Organizations) | *Unconfirmed — description unverified, confirm with repo README.* | | `ParticipantPortalPrototype` (no "2") | — | **Archived and deleted.** This is the *old* participant portal, superseded by PPPToo. Do not reference it as live — see Archived section below. | ## Shared Packages Consumed by more than one portal — a change here needs an explicit publish step before consumers see it. ### globus-sdk-src (repo: `Globus-SDK`) Shared Globus API SDK facade package, consumed by PPPToo (server) and HostPortalPrototype (server). BackOfficePrototype is the Hub itself and does not depend on it. - **Release process:** bump the package version, then tag `v*` and push that tag — this triggers the GitHub Actions publish workflow. Consumers do not get the new version until that tag is pushed. - Note: the GitHub repo itself is named `Globus-SDK` (its description still references an older Replit app, "Globus-Sync-Manager") — `globus-sdk-src` is the package/consumer-facing name. If you can't find a repo literally named `globus-sdk-src`, this is why. ### form-engine-portable Shared dynamic form-rendering engine package, published to GitHub Packages, consumed by BackOfficePrototype and PPPToo. - Publishing does **not** happen automatically on merge to `main` — it only triggers on a `v*` git tag push. Skipping the tag means consumers silently keep running the old bundle even though the code looks merged. - Full process: (1) build the bundle on a feature branch with `vite build --config vite.portable.config.ts`, commit; (2) PR → merge to `main`; (3) bump `package.json` version; (4) push a `v*` tag (`git tag v2.6.5 && git push origin v2.6.5`) — the step people forget; (5) wait ~3–5 min for the publish workflow; (6) bump the consumer's `package.json` and redeploy; (7) verify the consumer actually restarted (e.g. `/api/health` uptime reset) before calling it done. ## Platform Infrastructure & Docs | Repo | What it does | How you work in it | | --- | --- | --- | | **alliance-gcp-infra** | Terraform IaC repo — all GCP infrastructure changes (Cloud Run services, IAM bindings, secrets, DNS records) across every environment | Everything GCP-related is supposed to land here as Terraform. Manual `gcloud`/console changes cause drift that has repeatedly caused real incidents — dev, QA, **and now prod** have all needed drift-remediation campaigns. Prod's own incident (2026-09-18, Host Portal's GKE→Cloud Run cutover): live IAM grants, a GKE namespace teardown, and a static IP deletion were all done directly via `gcloud`/`kubectl` and had to be reconciled back into this repo's Terraform afterward, including a near-miss where an already-present-but-unapplied Terraform change (a Certificate Manager cert-map switch) would have destroyed the two classic SSL certs actually serving live production HTTPS on the very next `terraform apply` — caught only by reading that PR's own `terraform-plan-pr.yml` output before merging. Replaces the archived `00000_iac_*` repo family. | | **alliance-mcp-gateway** | Go service — the MCP Gateway aggregating ~330 tools with per-user OAuth, RBAC, and audit logging. This is the very service brokering the tool calls used to write this page. | Actively maintained, private repo. | | **n8n-workflows** | Canonical source-of-truth repo for n8n workflow JSON definitions across dev/QA/prod (`workflows/{id}__{slug}.json`) | **Corrected 2026-09-10 — the previous "live instance first, backfill git after" process below was backwards and is the exact anti-pattern that caused a real multi-day QA outage and a `development`/`qa`/`main` drift incident; do not follow it.** The correct, git-forward flow: (1) iterate in the Dev n8n UI (GKE instance, namespace `n8n-dev` on `globus-cluster-development`, no public DNS — reach it via `kubectl port-forward svc/n8n 8080:80 -n n8n-dev` after WIF auth); (2) `export-dev.yml` fires on push to `development` and opens a PR with the exported JSON (never commits directly — `development` requires PRs); (3) PR `development` → `qa` merges and `deploy-qa.yml` **pushes git's committed state onto the live QA instance** via `deploy_workflows.py` (id-map-resolved PUT/POST) — QA is a deploy *target*, not a source to copy from; (4) PR `qa` → `main` merges and `deploy-prod.yml` does the same against Production. Git is authoritative for QA and Prod; only Dev is meant to be hand-edited live and then captured back into git via the export PR. See the n8n Automation Layer doc's Workflow Version Control section for full detail, corrected the same day after this entry was found. | | **n8n-infra** (renamed from `n8n-migration` 2026-09-14 — the old name no longer matched its ongoing role) | n8n cluster infra — K8s manifests, workflow export/remap tooling, and credential migration for the self-hosted n8n deployment | Ops/infra tooling, not a day-to-day app repo. Its manifest still describes the pre-migration instance (namespace `n8n`, image `n8n:latest`) — the live instance moved to namespace `n8n-dev`/image `n8nio/n8n:2.30.4` around 2026-09-02/03 and was never fully reconciled here (only a memory-limit fix landed, PR #34, 2026-09-14); confirm against live GKE state before relying on this repo's manifest. | | **n8n** | Public fork/vendor copy of the open-source n8n automation platform itself | This is the upstream OSS project, not Alliance application code — you generally don't "work in" this repo directly; it exists to support self-hosting. | | **nexus-docs** | This documentation/ecosystem-governance hub — the repo this very page lives in. Node/TypeScript + Drizzle ORM + Vite/React, originally a Replit app ("Ecosystem-Governance-Hub") | Doc pages normally publish through `ecosystem_update_doc` (auto-merging edit flow). When that backend is down, or you're adding a *new* page (not editing an existing one), go through a normal PR against `content/manifest.json` + the matching `client/src/pages/.mdx` file instead — see the `nexus-hub-docs` skill for the AI/MCP markdown-extraction surface this repo also serves. | | **platform-hub** | Platform operations dashboards at platform.allianceabroad.com, extracted out of nexus-docs' old "Platform" section into its own repo. Migrated to the `alliance-mcp-services` GCP project | **Status: broken, needs attention** — this service has had a production outage since 2026-07-17 (a prod database wipe was never rebuilt). Still an active repo, but don't treat it as healthy. | | **globus-config** | Config-as-code for the Globus platform — reviewable per-entity config files, env manifests, and CI that validates and builds import envelopes with `@alliance-strategies/globus-config-schemas` | Enforces the exact contract BackOfficePrototype expects; CI validation is the gate, not manual review alone. | ## MCP Servers & Integrations Repos backing the self-hosted Cloud Run MCP servers behind the Alliance MCP Gateway (see the MCP Gateway page and CLAUDE.md's Cloud Run hosting rule — one instance per server in `alliance-mcp-services`, never environment-specific). | Repo | What it does | | --- | --- | | **gcp-connector** | Custom GCP connector for Manus agents — gcloud + kubectl wrapper, replacing the old google-cloud-gke/iam/logging MCP connectors | | **asana-integration** | Resilient Asana REST API integration — PAT auth, keep-alive connection pool, retry/backoff, task creation & tracking, n8n workflow | | **TeamsMCP** | Microsoft Teams MCP server (Microsoft Graph API integration) | | **openstatus-mcp** | MCP server for the Alliance Abroad self-hosted OpenStatus instance. Flag: `status.*` is live from prod with known env-var landmines — see prior openstatus exposure notes before touching config. | | **1password-mcp** | Secure multi-tenant 1Password MCP server — two-layer auth (server API key + per-tenant service account token) | | **auth0-mcp-server** | Secure MCP server for the Auth0 Management API | | **GoogleWorkspaceMCP** | Google Workspace Admin SDK MCP server — replaces AzureMCP for user directory, group management, and OIDC | | **AzureMCP** | MCP server for Microsoft Entra ID / Azure AD admin operations. Per its successor's own description this is being replaced by GoogleWorkspaceMCP — confirm current usage before building on it. | | **godaddy-connector** | GoDaddy MCP connector (DNS/domain management). *Description unverified — confirm with repo README.* | | **ReplitMCP** | Likely the MCP server for Replit integration (per-user repl access). *Description unverified — confirm with repo README.* | | **vertex-agents** | Alliance Abroad Vertex AI Agent Engine agents — PR review, QA, and future agents. Caution: several agents that historically lived in this fleet (the standalone `pr-review-agent`, `overseer`/`observer-agent-v2`) have since been decommissioned/deleted per platform history — confirm which agents in this repo are actually still deployed before assuming a given agent is live. | | **manus-platform-agent** | Alliance Abroad Manus agent configuration export — skills, connector config, and project context. This is the source repo the Claude Code global instructions (this very agent's `CLAUDE.md`) were ported from. Sync process: push skill/config changes here, then trigger the admin-portal skill sync — see the `manus-agent-sync`/`gateway-skill-source-sync` process. | ## Other Repos (Unconfirmed / Lower Confidence) Found active (non-archived, recently pushed) in the org but with no GitHub description and no clear cross-reference in existing docs/skills. Listed for completeness — treat the "what it does" column as a guess, not a fact, until confirmed against the repo's own README. | Repo | Best-effort guess | | --- | --- | | **app-portal-server-keycloak-flows** | Applicant Portal Keycloak backend/BFF flows service (referenced by the `applicant-portal-cicd` skill) | | **VirtualInterviews** | Possibly supports the "Interviews & Screening" / "Interview Result Capture" Globus Core capability — unconfirmed | | **SevisSDK** | Possibly a SEVIS government-reporting integration SDK — unconfirmed | | **Doc2web** | Possibly the "DocToWeb" PRD-to-website tooling referenced elsewhere in platform memory — unconfirmed | | **AsanaReporter** | Possibly Asana reporting/timesheet automation — unconfirmed, may overlap with `asana-integration` | | **AsanaTools** | Possibly additional Asana utilities — unconfirmed, may overlap or be legacy relative to `asana-integration` | | **itrequestportal** | Possibly an internal IT request-ticketing portal — unconfirmed | | **itp-dashboard** ("itp funnel dashboard") | Possibly an Intern/Trainee Program funnel dashboard, tying to the "Intern/Trainee Scope" Globus doc — unconfirmed | | **StepWestWP** | Purpose unclear from name alone; may not even be an internal platform app (possibly a WordPress marketing/brand site) — unconfirmed | | **globus-ocr-infra** | Infrastructure-as-code for the private `globus-ocr` GCP project (Document AI OCR processing) — description present and fairly clear, listed here only because it wasn't in the seed fact list | | **globus-translation-infra** | Private GCP Cloud Translation v3 microservice for Globus — criminal background & reference letter translation — same note as above | ## Service Configuration Reference Runtime settings for small services whose code is the only other source. Read from each repo's entry point. ### vertex-agents: `db-query-proxy` Read-only SQL proxy. `GET /health` is open; `POST /query` requires a bearer token. The process exits at start-up if a required variable is missing. | Variable | Required | Behaviour | |---|---|---| | `BEARER_TOKEN` | yes | Token callers must present on `POST /query`. | | `DB_HOST` | yes | Cloud SQL socket path, for example `/cloudsql/PROJECT:REGION:INSTANCE`. | | `DB_NAME` | yes | Database name. | | `DB_PASSWORD` | yes | Password for `DB_USER`. | | `DB_USER` | no | Defaults to `karin_readonly`. | | `STATEMENT_TIMEOUT_MS` | no | Per-statement timeout. Defaults to 10000. | | `MAX_ROWS` | no | Row cap per query. Defaults to 1000. | ### vertex-agents: `db-query-proxy-token-broker` Mints short-lived identity tokens for the proxy using the runtime service account's ambient credentials. `GET /health` is open; `POST /mint-token` requires the app token. | Variable | Required | Behaviour | |---|---|---| | `APP_TOKEN` | yes | Token callers must present on `POST /mint-token`. | | `TARGET_SERVICE_ACCOUNT` | yes | Service account to impersonate. | | `TARGET_AUDIENCE` | yes | Audience for the minted ID token. | | `ID_TOKEN_LIFETIME_SECONDS` | no | Token lifetime. Defaults to 300. | ### globus-config: drift-check and sync-apply | Variable | Used by | Behaviour | |---|---|---| | `CONFIG_EXPORT_API_KEY` | `scripts/drift-check.ts` | Required. The script exits with code 2 when it is unset. | | `CONFIG_EXPORT_BASE_URL` | `scripts/drift-check.ts` | Optional override of the per-environment default base URL; required if the environment has no default. | | `CONFIG_IMPORT_BASE_URL` | `scripts/sync-apply.ts` | Optional override of the per-environment default base URL used when applying a commit. | ## Archived — Do Not Use Confirmed `archived: true` on GitHub as of 2026-08-14. Do not build on these, and don't be confused if you find stale references to them elsewhere. | Repo | Why it's dead | | --- | --- | | **ParticipantPortalPrototype** (no "2") | Old participant portal, superseded by PPPToo. Do not treat as live. | | **app-portal-front-keycloak-flows** | Legacy Applicant Portal frontend, superseded by PPPToo. | | **manus-mcp-server** | Legacy Manus API MCP server, superseded by the current Alliance MCP Gateway architecture. | | **azure-mcp** | Lowercase duplicate of `AzureMCP`, archived. | | **alliance-build-dashboard** | Deprecated and disabled per platform convention (along with `alliance-build-health-dashboard`, which doesn't appear as a separate repo here). | | `00000_iac_database_terraform_gcloud`, `00000_iac_platform_cloud_run_services`, `00000_iac_gke_clusters_gcloud`, `00000_iac_cloud_run_services`, `00000_iac_iam_serviceaccounts_gcloud`, `00000_iac_load_balancer_GCP`, `00000_iac_artifact_registry_repository_GCP`, `00000_iac_secret_manager_GCP`, `00000_iac_vpc_gcloud` | The old per-resource-type Terraform repo family. Explicitly replaced by the single consolidated **alliance-gcp-infra** repo — see that repo's own description. | Also excluded here (by design, not because they were found and rejected): anything with "staging" in the name — there is no staging environment at Alliance Abroad, only `development`, `QA`, and `prod`. --- --- title: "30-60-90 Day Plan" slug: onboarding-30-60-90-plan section: Developer Onboarding status: stable description: "A sequenced path through everything else in this section, so onboarding has a shape instead of being a pile of pages to read in some order." last-updated: 2026-10-07 --- # 30-60-90 Day Plan A sequenced path through everything else in this section, so onboarding has a shape instead of being a pile of pages to read in some order. ## Overview Each phase builds on the last: orientation before contribution, contribution before independence. Moving faster than the plan is fine if you're genuinely ready — moving slower is also fine. Treat the day counts as a guide, not a deadline. ## Days 1–30: Orientation - Work through the Access & Accounts Checklist — don't wait until you're blocked by a missing credential to request it. - Get the Local Environment Setup running and confirm a real login works end-to-end. - Read Overview & Architecture and the Glossary — the domain vocabulary (Program vs Season vs Operational Cycle, Applicant vs Application) trips up most new contributors at least once. - Ask the AAG MCP @ connector the recommended first questions (see the AAG MCP @ Connector Onboarding page) — it's faster than reconstructing the ecosystem model from code alone. - Shadow at least one PR review end-to-end so the gate sequence (see PR & Code Review Process) is familiar before you're the one waiting on it. ## Days 31–60: Contribution - Pick up one ticket and take it through the full lifecycle yourself: Asana ticket → branch → implementation → PR through every review gate → merged and deployed. - Expect to need the Golden-Path Recipes page at least once — check there before improvising a process for a task type you haven't done yet. - Start using the Debugging & Observability tools directly when something breaks, rather than immediately escalating. ## Days 61–90: Independence - Own a feature through to production promotion without needing a walkthrough of the process itself (you may still need domain input — that's normal, not a sign you're behind). - Be able to answer, unprompted: where does this fit in the ecosystem, which system owns it, what documentation explains it, and what should I verify before changing it? (This is the same "onboarding question" the AAG MCP @ connector page poses as the ultimate success criterion.) ## FAQ - **Where do I ask questions the AAG connector doesn't have a good answer for?** The engineering Teams chat, or your onboarding buddy — the connector is a learning aid, not the final word, especially on anything that's changed recently. - **slopgate passed locally but CI flagged something — what happened?** Local and CI slopgate versions can drift. Re-run against the CI-pinned version before assuming it's a false positive. - **Is there a staging environment?** No. The only valid environments are `development`, `QA`, and `production` — anything referencing "staging" is legacy and should not be used. - **Can I use my own portal login to test something quickly?** No — use one of the dedicated E2E bot accounts. See Security & Secrets Handling. --- --- title: "Applicant Lifecycle" slug: applicant-lifecycle section: Applicant Journey status: stable description: "A structured lifecycle built on a fixed 8-phase reporting spine, program-specific Stages, Readiness, Queues, and compliance gates" last-updated: 2026-10-07 --- # Applicant Lifecycle # Applicant Lifecycle Model A structured lifecycle built on a fixed 8-phase reporting spine, program-specific Stages, Readiness, Queues, and compliance gates > **From Status Labels to Lifecycle Phases** > > Legacy systems often use status labels that mix operational, compliance, and placement states into a flat list. Globus introduces a structured lifecycle where each phase has a clear focus, defined actors, explicit transition triggers, and compliance gates between phases. > **Lifecycle is per-application, identity is per-applicant** > > Every phase, status, and transition on this page belongs to a single application row — one attempt at one program in one season. The person’s identity (name, email, nationality, brand, external ID) lives separately on the applicant row and is reused across every attempt. For the canonical boundary, the field-level rule of thumb, the document split, and the per-portal visibility matrix, see Applicant Data vs Application Data . ## Operating Model The applicant lifecycle is built on a small set of distinct layers. The core architecture is Phase + Stage, Readiness, and Queues. Two further layers — Processing Areas (diagnostic) and Progress Milestones (presentation) — sit alongside the core to answer “why is the applicant stuck?” and “what is their progress summary?” without ever becoming the source of truth. Each layer serves a different purpose and avoids the status explosion that plagues flat systems. | Layer | Purpose | Definition | Example | | --- | --- | --- | --- | | Lifecycle Phase | Reporting spine (fixed) | Where the applicant is, for reporting. 8 platform-defined phases, the same for every program; cannot be customized. Derived from the current Stage — never set manually. | Phase 4 — Placement | | Stage | Milestone (program-specific) | Furthest verified program milestone. Configurable per program via Stage Management; each Stage belongs to exactly one Phase. | Hired (owned by Placement) | | Work Type | Activity (operational) | Actual operational activities happening underneath. Interviews, document waves, compliance tasks. | Host Interview, Document Wave 2 | | Readiness | Can they move? (computed) | System-calculated read-model of whether the applicant can advance or is blocked. Computed from 5 signal sources; never manually edited, never the source of truth. | Ready to be Showcased ✅ | | Queue | What needs action? (views) | How work is organized for staff. Derived from Readiness. No stored queue field. | Host Interviews queue | | Processing Areas | Why stuck? (diagnostic) | Pre-SEVIS work running concurrently and out of order. Never overrides the current Stage or Phase. | Documentation area blocked | | Progress Milestones | Progress summary (presentation) | Applicant Progress Summary showing which major requirements are complete/incomplete. A presentation layer — explicitly not Readiness. | 4 of 6 milestones complete | > **Interview = Work Type, not Status** > > Sponsor interviews and host interviews are modeled as Work Types, not statuses. This avoids status explosion while still providing scheduling, tracking, queues, and SLA enforcement. An applicant in “Under Review” status may have a sponsor interview work item — the interview doesn’t change the lifecycle status. ## Phase & Stage Derivation The relationship between Phase and Stage is the load-bearing rule of the whole model. A Lifecycle Phase is platform-defined — the same 8 phases for every program, never customizable. A Stage is a program-specific milestone defined per program. The two are bound by one rule: every Stage belongs to exactly one Phase, and the applicant’s Phase is simply the Phase that owns their current Stage. > **Phase is derived from Stage — forward-only, never manual** > > Phase is never stored or edited directly. It is derived from the applicant’s current Stage, where the current Stage is the furthest verified milestone the applicant has reached. Derivation is forward-only: a Stage advances when a milestone is verified and is not automatically pulled back because some other piece of work is still outstanding. Because Stages are ordered and each maps to a Phase, the reporting spine moves forward monotonically. Derivation chain verified milestones / \u2192 / furthest = current Stage / owning Phase / reporting spine position > **Stage is the source of truth — completion percentage never drives it** > > Workflow completion percentage is a progress and readiness signal only. It may light up progress bars and incomplete-work warnings, but it must never set current_stage_id, journey_position, or the Lifecycle Phase. Stage advances only when a verified milestone is achieved — a submitted step is not an approved milestone — and journey_position is a derived mirror of the current Stage, not an independent field. A 55%-complete applicant who is Hired resolves to Stage Hired / Phase Placement / Readiness blocked, never back to Interview. > **One writer, always logged — application_stage_history** > > Every Stage change — manual, reconciliation, bulk action, rules-triggered, or system/supersession — flows through a single shared writer (transitionApplicationStage) owned by Globus-core. No code path writes current_stage_id or journey_position directly (migrations and seeds excepted, with a comment). The writer derives the Phase from the target Stage, prevents backwards movement unless an admin override is supplied, and writes an application_stage_history row (from_stage_id, to_stage_id, change_source, actor, reason, timestamp) for every transition — history that cannot be reconstructed after the fact. This writer is the seam a full Lifecycle Transition Service grows into later. > **Two signals move the Stage — verified milestones and live evidence** > > Automatic advancement resolves candidate target Stages from two sources and applies them only through the shared writer above, forward-only. (1) Verified milestones: a completed phase-completion event whose target is the status-change target authored on the workflow phase. (2) Live evidence, read from the same records the parallel pre-SEVIS tracks already trust: an interview track that has left not_started targets Sponsor Interview; a live (active or confirmed) reservation hold targets Reserved to Host; a live canonical placement (confirmed or active) targets Hired directly, and a confirmed placement never fabricates Host Interview. Candidates are tried forward-most first. The writer still owns placement readiness, authored edges, phase-gate rules and forward-only ordering, so when it refuses a move the applicant stays at the last legitimate Stage, and the refused or pending target is visible in the reconciliation outcome. Because targets are derived from live state, the periodic reconcile sweep converges applicants that are behind their own evidence. The sanctioned backwards moves are narrow. First, when the last live reservation ends (declined, cancelled, released or expired) and the applicant is still at Reserved to Host, an audited system transition with an explicit override returns them to Sponsor Interview, recorded in application_stage_history. Second, when the last live placement ends and the application is at Hired, with no live placement remaining, a transition through `transitionApplicationStage` with an explicit override and change source `system` moves it to Reserved to Host while a live hold remains and otherwise to Sponsor Interview; the move is written to stage history. The release accepts a `strict` option so that durable cancellation workers retry failures rather than silently succeed: when it is set, any failure, including a cancellation target stage that is not configured, is thrown instead of swallowed. The transition is requested with `skipReadinessRecompute` set to true. > > Placement (Reserved to Host, Host Interview, Hired) is a durable segment: an automatic move may enter or continue through it but may never start before it and land after it. A milestone target that would leapfrog it is a workflow configuration defect, not applicant state — the move is refused, the originating phase is told the applicant must enter the Placement stage first, and it is retried on later reconciliations once evidence has carried the applicant into the segment. Self-arranged placement programs are exempt, since placement is work they never do. Milestone target validation. A workflow phase's status-change target (`milestoneStatusChange`) is checked whenever a phase is created or updated, and an illegitimate target is refused with 400 `VALIDATION_ERROR` whose `details.code` names the problem (with `phaseKey`, `authoredTarget` and, where relevant, `relatedPhaseKey`): `UNKNOWN_STAGE_TARGET` (not a lifecycle stage), `INACTIVE_STAGE_TARGET` (a retired stage), `TERMINAL_STAGE_TARGET` (a terminal outcome is recorded by a staff decision, never by completing a phase), `TARGET_BEHIND_PREVIOUS_PHASE` (not further along than the earlier phase's target) and `TARGET_AHEAD_OF_NEXT_PHASE` (not before the later phase's target). Clearing a target is always allowed. The same rules feed the publish-time warnings, the staff coverage report and the stalled-applicant explanation; for an application workflow they also report `NO_MILESTONE_TARGETS` (no phase has a usable target, so completing phases can never move an applicant past the first stage) and `STAGES_UNREACHABLE` (phase targets stop short of later stages, which are listed). ### Worked Example — Hired before documents are clear A Camp Counselor applicant is matched and accepts a host offer. The program’s Hired Stage is owned by the Placement Phase, so the moment Hired is verified the applicant’s Phase derives to Phase 4 — Placement — even though their passport (Documentation) is still unverified and their sponsor interview (Screening) has not been conducted. | Signal | Value for this applicant | | --- | --- | | Current Stage (furthest verified) | Hired | | Derived Lifecycle Phase | Phase 4 — Placement | | Documentation Processing Area | Incomplete — passport not yet verified | | Screening Processing Area | Incomplete — sponsor interview not conducted | | Readiness | Blocked (cannot reach SEVIS until areas clear) | | Surfaced via | Queues, Blocking Reasons, Processing Diagnostics — not by changing Phase | > **The outstanding work does not move the Phase backward** > > The missing passport and un-conducted interview do not drag the applicant back to Validation or Interview. They surface as Readiness = blocked, as memberships in the relevant Queues, as explicit Blocking Reasons, and in the Processing Diagnostics view — the Phase stays at Placement because that is the furthest verified Stage. This is exactly why Phase alone is never enough to operate from: it is the reporting spine, and Readiness, Queues, and Processing Areas carry the operational detail. ## Stage Management — Program Lifecycle Builder Because Phases are fixed but programs differ, the program-specific variation lives entirely in Stages. Stage Management (the Program Lifecycle Builder) is the configuration surface where a program administrator defines the ordered list of Stages for a program and binds each one to a Phase. This is how a Camp Counselor program and an H-2B program can share the same 8-phase reporting spine while running very different milestone sets underneath. ### Stage Attributes | Attribute | Definition | | --- | --- | | Name | Human-readable milestone label shown to staff (e.g. “Hired”, “Contract Signed”). | | Owning Phase | Exactly one of the 8 fixed Lifecycle Phases. Determines where the Stage lands on the reporting spine. | | Program / workflow applicability | Which program(s) and workflow version(s) this Stage applies to. Stage definitions are global rows in `stage_definitions` (optionally limited by `applies_to_program_ids` and visa types); a program overrides a stage's name, description, sort order and active flag in `program_stage_overrides`. | | Sort order | Ordinal position within the program’s Stage list. Establishes which Stage is “further” for forward-only derivation. | | Active / inactive | Whether the Stage is currently in use. Inactive Stages are retained for historical applications but not assignable to new ones. | | Allowed transitions | The set of Stages this Stage may advance to. Constrains the milestone graph without re-encoding it as Phases. | Supporting tables (BackOfficePrototype): `program_stage_overrides` holds per-program overrides of a stage's name, description, sort order and active flag, unique on program and `stage_key`. `application_track_stages` holds one current stage per application and pre-SEVIS track (`application`, `validation`, `interview`, `placement`), advanced by the reconciliation stage projector, with `changed_by_user_id`, `change_source` and `reason` on manual moves. `requirement_gates` holds one row per application and `gate_type` with `state` (default `missing`), `notes` and override fields (`is_overridden`, `override_reason`, `overridden_by`, `overridden_at`). `application_classification_assertions` is an append-only record of who asserted a classification and on what basis: `field` is `j1_participation_classification`, `placement_source`, `proposed_employer_known` or `camp_same_camp_return`, `provenance` is `programme_backfill`, `staff_attestation` or `applicant_declaration`, and a correction is a new row that supersedes the old one, never an update. `applicant_alerts` holds internal-only, person-level banner cards for the applicant profile (`title`, `body`, `category`, `severity` of `info`, `warning` or `critical`, `is_active`). `applicant_contact_history` records changes to email, phone and preferred name with `previous_value`, `new_value` and `source` (`staff`, `portal`, `api_key` or `system`). `application_notes` holds staff notes per application with `visibility` (default `internal`), optional `category` and `is_pinned`. **Program stage overrides.** `GET /api/admin/program-stage-overrides?programId=` lists a program's overrides (400 VALIDATION_ERROR when `programId` is missing), `POST /api/admin/program-stage-overrides` upserts one (create or update, 201), and `DELETE /api/admin/program-stage-overrides/:id` removes one (404 NOT_FOUND when absent). All three need `programs.manage`; the reads use API key scope `admin.read` and the writes `admin.write`. ### Program Examples The same 8 Phases, three different Stage sets. Each Stage binds to exactly one Phase; the Phase column is the reporting spine, the Stage column is the program-specific milestone. | Program | Example Stage | Owning Phase | | --- | --- | --- | | Camp Counselor | Showcased → Hired | Placement | | Camp Counselor | DS-2019 Issued | Visa & SEVIS | | Teach USA | Credential Review Passed | Validation | | Teach USA | School Interview Accepted | Interview | | H-2B | Employer Contract Signed | Placement | | H-2B | Consular Appointment Booked | Visa & SEVIS | > **Builder runtime lives in Globus-core** > > This page is the specification for Stage Management. The Stage Builder UI runtime and the Stage–Phase reconciliation engine that derives Phase from the furthest verified Stage are owned by the Globus-core platform, not the docs-site. Configuration changes are made there; this page defines the contract those systems implement. ## Processing Areas (Pre-SEVIS Concurrency) The 8-phase spine above is a reporting order, not a march. Before SEVIS, the underlying work does not run in a strict line: an applicant can land a host offer (Placement complete) while their pre-showcase documents are still being cleared. Globus models this pre-SEVIS work as four Processing Areas — Intake, Documentation, Screening, and Placement — that progress concurrently and out of order. The Applicant Queue's “Also in” column exists precisely because a single applicant routinely sits in multiple Processing Areas at the same time. > **Processing Areas are diagnostic — they never override Stage or Phase** > > Processing Areas answer “why is this applicant stuck?”. They are a diagnostic view over the same work items, document waves, and placement records that already exist — not a second source of truth. An applicant’s Stage (and the Phase derived from it) is set by the furthest verified milestone and is never pulled back because one Processing Area is still in flight. A participant can be in Phase 4 (Placement) while their Documentation area is still incomplete. > **Concurrent until SEVIS, sequential after** > > Pre-SEVIS (Processing Areas): Intake, Documentation, Screening, Placement run in parallel with independent state machines. Order is not enforced — only completion is. SEVIS (gate): A single convergence point. SEVIS entry requires every Processing Area to be in a terminal-pass state. Post-SEVIS (phases): Pre-Departure, In Program, Completion run sequentially — they depend on regulatory state and arrival, which are inherently ordered. ### Per-Area State Model Each Processing Area has its own trigger-in, its own terminal-pass state, and its own gate-out condition. An area can be in flight, blocked, or terminal independently of the others. | Processing Area | Trigger-in | In-flight states | Terminal-pass (gate-out) | | --- | --- | --- | --- | | Intake | Account created (direct or partner invite) | Invited, In Progress, Submitted | Submitted & required fields complete | | Documentation | Wave 1 document uploaded or required field saved | Awaiting Docs, In Review, Additional Info Required | All Wave 1 docs verified & background check cleared | | Screening | Sponsor or host requests interview (work item) | Scheduled, Rescheduled, Conducted, Pending Outcome | Outcome = passed (sponsor) / accepted (host) | | Placement | Applicant flagged eligible-for-placement | Showcased, Awaiting Host Decision, Reserved | Confirmed placement against an active job order | Processing Areas are not new entities — they are views over the same work items, document waves, and placement records that already exist. The Processing Area abstraction names what each bucket of work owns and when it is “done enough” for SEVIS, without forcing the buckets into a serial order. ### SEVIS as the Convergence Gate SEVIS is the single point in the lifecycle where every Processing Area must converge. The rules engine evaluates sevis_ready by AND-ing the four area-terminal flags above with the regulatory readiness checks (passport on file, SEVIS fee receipt, DS-160 stub). An applicant can't enter the SEVIS pool with three areas green and one in flight — the gate is hard. → SEVIS gate Four Processing Areas in any order → one gate → Pre-Departure / In Program / Completion in sequence. ### Step Locking & Area Convergence Step locking inherits this Processing Area model directly. Whether a workflow step is editable, view_only, locked, or hidden is derived by Globus and rendered by the Applicant Portal — the full contract lives in Step Access & Lock Contract . The lifecycle rule that governs when a downstream step unlocks is the one on this page: - Before SEVIS — concurrent. Steps inside the Intake, Documentation, Screening, and Placement Processing Areas lock only on their own intra-area prerequisites (and on compliance holds). A step in one area must not be locked merely because another area is unfinished — a strict “previous phase complete” gate would wrongly serialize work that is designed to run in parallel. - At the gate — convergence. Steps that depend on sevis_ready stay locked until all four area-terminal flags AND the regulatory readiness checks resolve. This is the hard gate described above. - After SEVIS — sequential. Pre-Departure, In Program, and Completion steps unlock in order, so post-gate locking is a linear phase chain. Access is always evaluated against the application’s pinned workflow version, so re-publishing a workflow never retroactively re-locks an in-flight applicant; recomputation rides on application.lifecycle_reconciled and is pushed to the portal as step.lock_changed . Workflow records (BackOfficePrototype): `compliance_snapshots` stores point-in-time compliance results per application, with optional workflow instance, program and season, `trigger` (for example `sevis_pool_entry`, `issuance`, `manual`, `scheduled`, `gate_check`), `overall_status`, pass, fail, pending and needs-review counts, and the `checks` and `inputs_used` JSON. `phase_completion_events` records each detected phase completion per application, workflow definition and `phase_key`, with `status`, flags for the milestone notification, status change and auto-fire, the previous and new application status, `manual_confirmation_required` with confirmer, and a `dedupe_key`. `workflow_audit_events` is the audit trail of a workflow definition (`action`, actor, `before_json`, `after_json`, `diff_json`, `correlation_id`). `workflow_reconciliation_runs` records each reconciliation of a program's workflow definition (`definition_version`, `trigger_source`, applications affected, steps added, removed and moved, `results_json`). `workflow_linked_objects` links a workflow definition to the rule definitions and rule bundles imported with it (`linked_type` of `rule_definition` or `rule_bundle`, natural `linked_key`); the publish gate refuses to publish the workflow while a linked object is unpublished. ## Progress Milestones — Applicant Progress Summary Progress Milestones are a presentation layer: an Applicant Progress Summary that shows, at a glance, which major requirements are complete and which are still outstanding. They make a complex application legible to staff, partners, and the applicant — a “4 of 6 complete” read rather than a wall of statuses. Progress Milestones are explicitly not Readiness: they describe what is done, not whether the applicant can move. | Layer | Answers | Nature | | --- | --- | --- | | Progress Milestones | “How much of the application is done?” — a completeness summary across major requirements. | Presentation (read-only summary) | | Readiness | “Can the applicant advance, or are they blocked?” — a single computed move/blocked verdict. | Computed read-model | > **Readiness is a read-model — never manually set** > > Readiness is system-calculated and is never edited by hand. Its source of truth is the underlying operational signal set: workflow step completions, document reviews, Rules Engine evaluations, interview outcomes, placement records, and integration events. Staff never “set an applicant to ready” — they complete the underlying work, and Readiness recomputes. This is what keeps the Phase / Stage spine, the Queues, and the Progress Summary all consistent with one another. ## Work Types & Document Waves Work Types represent the actual operational activities that happen within each phase. They are tracked as work items attached to an applicant record — separate from the lifecycle status. #### Interviews Sponsor Interview (Phase 3) Conducted by sponsor staff during the Interview phase. Used for compliance validation, cultural fit assessment, or program-specific requirements. Outcome: passed / failed / reschedule. Host Interview (Phase 3) Conducted by the host employer after the applicant is showcased. Required before placement confirmation for most programs. Outcome: accepted / rejected / reschedule. See Interview Result Capture for how the outcome is recorded, gated, and fed to placement. #### Document Waves Wave 1 — Pre-Showcase (Phases 1–2) Documents needed to present the applicant to hosts: passport, profile photo, background check consent, skills assessment, application forms. Reviewed during Application and Validation. Wave 2 — Post-Showcase (Phase 4) Documents required after placement is identified but before SEVIS submission: medical clearance, financial disclosures, SEVIS fee receipt, housing agreements, insurance documentation. > **Work Item Data Model** > > Each work type instance is stored as a work item linked to the applicant. Key fields: work_type, status (pending / in_progress / completed), scheduled_at (for interviews), outcome, assigned_to, and notes. For interviews, additional fields: interview_type (sponsor / host), interviewer_id, job_order_id (host interviews). Lifecycle Flow ## Portal Phase Mapping The Globus lifecycle defines 8 fixed operational phases — the full truth of how an applicant moves from invitation to program completion. However, the Applicant Portal simplifies this into 3 phases, showing only what the applicant needs to act on and hiding back-office work such as sponsor interviews, host matching, SEVIS processing, and DS-2019 generation. | Portal Phase | Globus Phases | What the Applicant Sees | What's Hidden (Back-Office) | | --- | --- | --- | --- | | | Phase 1 (Application) + Phase 2 (Validation) + Phase 3 (Interview) | Personal Information About Me Skills & Hobbies ( Camp SWT / Camp Counselor taxonomy) Showcase Yourself Application (submit) Terms & Conditions | Sponsor Review Sponsor Interview Rules Engine auto-screening | | | Phase 4 (Placement) + Phase 5 (Visa & SEVIS) | Camp Contract (program-specific) Criminal Background Check Passport Information Reference Details | Host Matching Host Interview Reservation Management SEVIS Processing DS-2019 Generation | | | Phase 6 (Pre-Departure) + Phase 7 (In Program) + Phase 8 (Completion) | VISA Information Flight Arrival Details Online Orientation | SEVIS Check-In enforcement Compliance Monitoring Completion Processing SEVIS Closure Incident Handling | Note: Once on-program, the applicant interacts with the portal for monthly check-ins, SEVIS check-in, and travel requests — but these actions occur outside the 3-phase stepper. The stepper tracks pre-arrival progression; on-program engagement is handled through separate portal screens. ### Behind the Scenes The following Globus work types are invisible to the applicant in the portal. They are executed by sponsor staff, host organizations, or automated systems — but the applicant is never asked to take action on them. Which placement milestones *should* become participant-visible communications — and which remain hidden — is specified in [Participant Placement Journey & Notifications](/docs/participant-placement-notifications). > **Interviews are behind-the-scenes step nodes — not hidden applicant steps** > > Sponsor and host interviews remain real nodes in the program’s Workflow Studio step tree — admins author and see them in the builder. They are flagged behind-the-scenes by role assignment (staff-only or host-only), so they are excluded from the applicant payload entirely and never render on the applicant stepper. This is different from a conditionally hidden applicant step (a data-driven hide of an applicant-facing step): a behind-the-scenes interview is never sent to the portal at all — it is not transmitted as access = "hidden". The interview still produces an outcome fact that gates the phase (see Interview Result Capture ). ### Program-Specific Variation > **Steps Vary by Program — Structure Does Not** > > The specific steps shown within each portal phase vary depending on the program. For example, Camp Counselor applicants see a "Camp Contract" step in Phase 2, while Teach USA applicants would see different program-specific steps in the same position. However, the 3-phase structure is consistent across all programs — every applicant experiences Apply → Documents & Compliance → Visa & Travel regardless of their program. The portal stepper as seen by a Camp Counselor applicant. Phase 1 collects application data, Phase 2 gathers compliance documents, and Phase 3 handles visa and travel logistics. The specific steps within each phase are program-configured. Camp programs use a Camp-specific Personal Information step (`personal_information_camp_v1`). It carries the shared identity fields, with the name labels reworded to "Given name(s) (as shown on passport)" and "Surname (as shown on passport)", plus two camp sections: Program Availability (required earliest start and latest finish dates at camp) and Camp Placement (a required "Do you already have a position at a camp for this season?" yes/no question; answering Yes reveals the required "previously worked at this same camp" and camp name questions, and answering No hides them without validating them). The migration repoints only Camp Counselor (`camp_counselor_%`) application workflow drafts to it; published and archived workflow history and other programs' drafts are unchanged. The Camp applicant step seeds also define a Passport step version (`passport_information_v1_1`) that opens with a four-step photo guidance panel with illustrations (open and place flat, take photo, check, upload), adds a required "Date of birth shown on passport" field after the surname, and labels the name field "Given name(s) (as shown on passport)". ### Stepper Fallback (Over-Cap Scroll) Workflow Studio enforces structural caps of 7 steps per phase and 6 phases per workflow at publish time (see Workflow Studio — Publish-Time Cap Validation ). The applicant portal is not the enforcement point. It must still render legacy data, override paths, and edge cases without ever wrapping, truncating, or hiding steps. Step row — horizontal scroll If the active phase contains more than 7 rendered steps, the step-circles row stays in a single horizontal line and scrolls horizontally within the phase row. No step is wrapped, hidden, condensed, or truncated. Phase tabs — horizontal scroll If the workflow contains more than 6 phases, the phase-tab row above the stepper follows the same rule: single horizontal line, scrolls horizontally, never wraps. Active item is the scroll anchor On initial render, both rows are scrolled so the applicant’s active step (and active phase) are visible without manual scrolling. The applicant lands looking at where they are, not at step 1. Compliant workflows render with no scroll When step and phase counts are within the caps, both rows fit without scroll and no scroll affordance is shown. > **Portal does not enforce caps** > > The applicant portal renders defensively but never logs, warns about, surfaces, or modifies a workflow because of a cap violation. Enforcement lives entirely in Workflow Studio (publish-time) and the staff-targeted workflow_cap_violation_detected notification. See also the Workflow Studio Guide — Limits & Guardrails for the admin-facing summary. The drafted Asana ticket for this portal behavior lives in the Implementation Tickets — Step & Phase Caps appendix on the Workflow Studio page (Ticket B). ### Lifecycle Stepper — Current-Step Pulse The portal stepper continuously pulses the applicant’s current step so the active position is unmistakable at a glance. The active step circle animates with a soft pulse while completed and upcoming steps remain static, anchoring the applicant to “where I am now” the moment the stepper renders. Single active indicator Exactly one step pulses at a time — the current step derived from the applicant’s phase and readiness. Completed steps show the done state; future steps stay neutral. Pairs with the scroll anchor The pulse reinforces the over-cap scroll behavior above: the active step is both scrolled into view and visually pulsing, so the applicant lands on their current step rather than step 1. > **Shipped — On-Program Dashboard & Stepper** > > The current-step pulse is implemented and shipped under the On-Program Dashboard & Stepper project, which owns the program lifecycle stepper. Runtime ownership is the On-Program Lifecycle delivery area; this docs page is the spec source. ## Transition Triggers & Compliance Gates Every phase transition is governed by a specific trigger and a compliance gate. The Rules Engine evaluates readiness at each gate by running the 5-step evaluation pipeline (Collect → Resolve → Evaluate → Decide → Act) against structured rule definitions from the rule_definitions table. Each transition produces an immutable rule_evaluation_records entry. No manual override can bypass a compliance gate without producing an audit-logged rule_overrides exception record. > **Payment Gates at Phase Transitions** > > The Placement → Visa & SEVIS and Visa & SEVIS → Pre-Departure transitions include payment gates enforced by the Rules Engine. These gates check application_finance_summary.financeStatus and block progression until payment conditions are met. For full payment gate details, see Finance & NetSuite — Compliance Payment Gates . **Readiness gates and terminal status errors.** A stage transition (`services/stages.ts`) runs the Rules Engine phase gate; when it blocks, the transition is refused with 422 `PHASE_GATE_BLOCKED`, and `details` carries `applicationId`, `fromPhase`, `toPhase`, `blockingRules`, `reviewRequiredRules`, `indeterminateRules` and `aggregatedRequiredActions`. Entering the stage keys `visa`, `pre_departure`, `on_program` or `program_completion` also requires a pass on the matching global operational gate (Ready for Visa, Departure, Arrival, Completion), each of which depends on the prior gate; an explicit admin override skips this readiness check. A gate that is not ready, or has an unevaluated dependency, refuses the move with 422 `VISA_NOT_READY`, `DEPARTURE_NOT_READY`, `ARRIVAL_NOT_READY` or `COMPLETION_NOT_READY`; `details` holds `reasonCode` (`VISA_PREREQUISITES_NOT_MET`, `DEPARTURE_PREREQUISITES_NOT_MET`, `ARRIVAL_PREREQUISITES_NOT_MET` or `COMPLETION_PREREQUISITES_NOT_MET`), `queueKey` `blocked`, `readinessImpact` `blocked` and `failedDependencies`. Staff end an application with `POST /api/applications/:id/end` (permission `stage.transition.terminate`; body `outcome` of `reject`, `disqualify`, `withdraw` or `cancel`, required `reasonCodeId`, optional `notes`); an application already in a terminal status (`withdrawn`, `rejected`, `lapsed`, `cancelled`, `completed`) is refused with 409 `CONFLICT` whose `details.code` is `APPLICATION_STATUS_TERMINAL`. ## Exception Paths Not every applicant follows the happy path. Globus defines structured exception paths for common disruptions. Every exception produces an audit record and triggers the appropriate downstream actions. `application_status_history` is the append-only trail of every application status transition: `from_status`, `to_status`, `reason_code_id` (required when the new status is terminal), `actor_id` (null when the system made the change), `actor_kind` (`system`, `staff` or `applicant`), `notes` and `created_at`. It preserves the prior terminal reason code when an application is reactivated and the live row's reason is cleared. The staff End application action (`POST /api/applications/:id/end`) writes to it through the status transition service. > **Override Authority** > > Sponsor Staff can override certain business rules for edge cases (e.g., expedited processing, government exceptions). Compliance rules can only be overridden by users with compliance.rules.manage permission. Every override is stored in rule_overrides with the original rule outcome, justification, authority level, and before/after state. See Rules Engine — Override Mechanism . A `compliance_case_item` represents an independently governed rule request within a Compliance Case. A case can group several rules, where each item retains its own decision, evidence and lifecycle. The status of a `rule_overrides` record can include `superseded`. Lifecycle exceptions are stored in `lifecycle_exceptions` (application, `exception_type`, `reason`, `triggered_by`, `trigger_source` default `manual`, `previous_stage_key`, `resulting_stage_key`, `affected_entities`, `resolution_status` default `pending`, resolver, `resolved_at`, `resolution_notes`) and managed at `/api/admin/lifecycle-exceptions`, all requiring `compliance.view`. `GET` lists them (filters `applicationId`, `exceptionType`, `resolutionStatus`), `GET /:id` reads one (404 when missing), `POST` creates one (201, `triggered_by` set from the caller), `PATCH /:id` updates any field, and `POST /:id/resolve` (required body `notes`) sets `resolution_status` to `resolved` with `resolved_by`, `resolved_at` and `resolution_notes`. Create, update and resolve are audited as `lifecycle_exception.create`, `lifecycle_exception.update` and `lifecycle_exception.resolve`. **Orphaned applicants (operator check).** `GET /api/admin/orphaned-applicants` (permission `applicant.notes.edit`) lists applicants with no program association that were created more than a grace window ago. The window defaults to 24 hours and the `olderThanHours` query parameter overrides it; a non-numeric value returns 400 `VALIDATION_ERROR`. The response has `count`, `sample` (up to 25 applicants) and `olderThanHours`, and the count is capped at 500. ## DS-2019 Resubmission Flow When an applicant’s DS-2019 has already been issued (isIssued = true) and certain biographical or site-of-activity fields subsequently change, the application is flagged for DS-2019 resubmission. This triggers a structured flow from detection through SEVIS amendment resolution. ### Triggering Fields Two categories of field changes trigger a DS-2019 resubmission requirement: Biographical Changes firstName / lastName / dateOfBirth / nationality / passportNumber Site of Activity Changes placementJobOrderId / placementLocationCountry > **Reason Concatenation** > > If a previous resubmission reason already exists (e.g., a biographical change was flagged and then a site change also occurs), the new reason is appended with a semicolon separator. The full reason trail is preserved for audit purposes. ### Resubmission Lifecycle The resubmission follows a four-stage lifecycle tracked on the application record: | Stage | Field | Value | Description | | --- | --- | --- | --- | | Flagged | ds_resubmission_required | true | Field change detected on an issued DS-2019. Reason text generated automatically (e.g., "Biographical information changed: First Name, Passport Number") | | Queued | ds_resubmission_requested_at | timestamp | Application appears in the DS-2019 Resubmission Queue for staff review | | Amendment | (SEVIS batch) | batch created | Staff creates a SEVIS amendment batch to process the resubmission through government systems | | Resolved | ds_resubmission_resolved_at | timestamp | New DS-2019 issued. ds_resubmission_required cleared to false. Application exits the resubmission queue | > **Automatic Detection, Manual Resolution** > > The system automatically detects field changes that require resubmission — staff does not need to manually flag them. However, resolution requires explicit staff action: creating a SEVIS amendment batch and confirming the new DS-2019 was issued. This prevents premature closure. ## J-2 Dependent Tracking J-2 dependents (spouses and minor children of J-1 participants) require their own SEVIS records, DS-2019 issuance, and lifecycle tracking. Globus manages the full J-2 dependent lifecycle end-to-end, linked to the primary J-1 applicant record. J-2 rules are evaluated as a distinct application_type: 'j2' in the Rules Engine , with cascade dependency logic that auto-terminates J-2 records when the primary J-1 is terminated. J-2 Dependent Record application_id, enrollment_id (link to the J-1 application and enrollment) / dependent_type (spouse | child), relationship / first_name, last_name, date_of_birth, nationality / passport_number, passport_expiry_date / sevis_id (assigned upon SEVIS creation) / ds2019_number / status (default active) / workflow_instance_id Enforcement Rules J-2 cannot be created without an approved J-1 applicant / J-2 DS-2019 issuance requires J-1 DS-2019 to be issued first / J-2 SEVIS record is terminated when J-1 record is terminated / J-2 biographical changes trigger independent DS-2019 resubmission / J-2 dependents appear on the J-1 applicant profile in Globus ### J-2 Lifecycle Stages Created / \u2192 / Documents Uploaded / SEVIS Record Created / DS-2019 Issued / Active / Completed / Terminated > **J-2 dependents follow the primary J-1 applicant lifecycle but maintain independent SEVIS records and DS-2019 documents.** > > When the J-1 applicant’s program ends or is terminated, all linked J-2 dependent records are automatically flagged for closure. J-2 dependent management is visible on the J-1 applicant’s profile in Globus with a dedicated dependents tab. J-2 Finance Implications *Status: design spec, not built. The charge catalogue (`financeChargeCatalog.ts`) has no `J2_*` charge keys (its keys are `PROGRAM_FEE`, `SEVIS_FEE`, `INSURANCE_FEE`, `HOUSING_DEPOSIT`, `EXTENSION_FEE`, `RENEWAL_FEE`), and no J-2 payment gate or 3+ dependent budget flag exists. The `J2_*` charges, events and gates below are not implemented.* Each J-2 dependent incurs their own financial charges separate from the primary J-1 applicant. These charges are tracked on the J-1 applicant’s finance summary but are itemized as distinct charge entries in application_charges. J-2 SEVIS Fee (J2_SEVIS_FEE) — government fee for creating the J-2 SEVIS record, triggered by J2_SEVIS_SUBMISSION_INITIATED event / J-2 Insurance Fee (J2_INSURANCE_FEE) — insurance coverage for each J-2 dependent, triggered when the primary J-1 placement is confirmed / Payment gate: J-2 DS-2019 issuance is blocked until J2_SEVIS_FEE is PAID — prevents issuing J-2 immigration documents for unpaid dependents / Budget tracker requirement: applicants with 3 or more J-2 dependents are flagged for finance review and require budget verification before charges are finalized / All J-2 charges are linked to the primary J-1 application_finance_summary — there is no separate finance summary per dependent See Finance & NetSuite — Charge Trigger Events for J2_SEVIS_FEE and J2_INSURANCE_FEE trigger details. ### J-2 Workflow Evidence Scoping Shipped Sep 15 2026. Each J-2 dependent runs its own pinned workflow instance (spouse or child definition), and document evidence for a dependent is scoped to that exact dependent: - **Ownership is application-local and durable.** A new dependent is created transactionally from one exact `application_id`, a compatible enrollment, and the published spouse/child definition. Its workflow instance keeps `external_portal_application_id` null — that field is external sync metadata and is never used to associate a dependent checklist or document. - **Evidence stays in its own lane.** A document uploaded for a dependent is evaluated against that dependent's pinned workflow version and that dependent's document lane. It can complete only a step authored for that dependent — never a principal-applicant step and never a sibling dependent's. Every required document mapped to the dependent step must be accepted before the step completes. - **Fail closed on identity.** If the dependent cannot be proven to belong to the application's applicant/program enrollment, or has no pinned workflow, no status or snapshot is written. - **Evidence completion is not staff approval.** A qualifying document decision records a system `completed` step through the canonical workflow-step writer and synchronises the portal snapshot; manual staff approval, completion or rejection remains authoritative. Rejection or resubmission reopens only a step this projection completed; repeats are idempotent. Dependent status and snapshot identity include the dependent id, so two people may safely share a step key. - **Canonical passport mapping.** Both the spouse and child seeds author `j2_passport_copy` mapped to the `passport` document type, for draft and published definitions; seed repair and migration backfill preserve it without creating principal program-matrix requirements. - **Deletion preserves evidence.** An empty, unstarted dependent can be hard-deleted (its unused workflow instance is cancelled). Once documents, review history, or a dependent workflow status/snapshot projection exist, deletion is rejected with a conflict; staff must use the retention or cancellation process. The rejection is lane-scoped and never touches the principal or a sibling. **Dependent routes.** `POST /api/j2-dependents` (permission `applications.edit`, audited as `j2_dependent.create`) creates a dependent from the `applicationId` in the body and returns 201; a body without `applicationId` returns 409 `CONFLICT`. `POST /api/applications/:id/j2-dependents` does the same with the application in the path. `PATCH /api/j2-dependents/:id` (`applications.edit`) refuses `applicationId`, `enrollmentId` and `workflowInstanceId` in the body with 409 `CONFLICT`. `DELETE /api/j2-dependents/:id` (`applications.edit`) returns 404 for an unknown dependent; the evidence rule above returns 409 `CONFLICT` with `details.reason` `DURABLE_EVIDENCE_OR_PROJECTION_EXISTS` plus `dependentId`, `documentCount`, `statusCount` and `snapshotCount`. ## SEVIS Batch Processing SEVIS operations are processed in batches to align with Department of State submission requirements. Each batch contains one or more participant records for creation, amendment, or status update. Globus tracks individual participant results within each batch. | Batch Type | Purpose | Trigger | | --- | --- | --- | | Initial Creation | Create new SEVIS records for approved applicants entering the SEVIS pool | Applicant reaches Ready for SEVIS Pool readiness | | Amendment | Update existing SEVIS records (bio changes, site of activity, program extension) | DS-2019 resubmission flagged or program extension approved | | Status Update | Report check-in, completion, termination, or no-show to SEVIS | Lifecycle event (arrival confirmed, program ended, early termination) | | J-2 Dependent | Create or update dependent SEVIS records linked to primary J-1 | J-2 dependent added or biographical data changed | Batch selection requires more than readiness: a current Ready verdict and reviewed operational material are required before an applicant can be selected for a batch. An applicant is selectable only when it is ready and its SEVIS operational review status is `reviewed`; a ready applicant whose review is not complete cannot be selected and is shown with the prompt to complete the SEVIS operational review before selecting. ### Participant Results Each participant in a batch receives an individual result status after SEVIS processing: | Result | Meaning | Next Action | | --- | --- | --- | | Success | SEVIS accepted the record. SEVIS ID assigned or update confirmed. | Proceed to DS-2019 issuance or mark amendment resolved | | Validation Error | SEVIS rejected due to data quality issue (e.g., duplicate, missing field) | Queued for staff review with error details. Staff corrects and resubmits. | | Partial Success | Some participants in the batch succeeded, others failed | Successful records proceed; failed records queued individually | | System Error | SEVIS API unreachable or timeout during submission | Entire batch retried automatically. Staff alerted after 3 failures. | ## Certificate Management SEVIS communication requires valid digital certificates for API authentication. Globus manages certificate upload, validation, status tracking, and rotation to ensure uninterrupted SEVIS connectivity. Certificate upload via Globus admin interface with drag-and-drop support / Automatic extraction of certificate metadata (issuer, subject, validity period) / Expiry monitoring with 30/14/7 day advance warning notifications / Active certificate indicator — only one certificate is active at a time per environment / Certificate history maintained for audit trail (previous certificates archived, not deleted) ### Fingerprint Validation Every uploaded certificate is validated using its SHA-256 fingerprint to prevent duplicates and ensure integrity: > **Certificate Expiry** > > If the active SEVIS certificate expires without a replacement, all SEVIS batch processing is automatically paused. Staff receives critical alerts via in-app notification and Slack. Batches are queued (not discarded) and resume once a valid certificate is activated. ## Biographical Data Sync Globus synchronizes participant biographical data with SEVIS to ensure records match government systems. When biographical fields change in Globus (name, date of birth, nationality, passport), the system detects the delta and queues a SEVIS amendment. Field-level change detection compares current values against last-synced SEVIS snapshot / Only changed fields are included in the SEVIS amendment payload (delta sync, not full sync) / Sync history is maintained per-participant showing field, old value, new value, sync timestamp, and batch ID / Failed syncs are retried in the next batch cycle with the cumulative delta **Portal profile sync into Globus.** When the Applicant Portal syncs an applicant that already exists in Globus (a non-session caller, an API key, of `POST /api/applicants` that matches an existing applicant by id, Globus id or email; a staff session gets `409 DUPLICATE` instead), the portal's self-reported values win: `phone`, `nationality`, `dateOfBirth`, `gender`, `countryOfBirth` and `passportNumber` are overwritten whenever the incoming value is non-empty and differs from the stored one, and an empty incoming value never clears a field. The profile photo is the exception and stays fill-only: it is fetched and stored only when the applicant has no photo yet. Route detail is on [Globus Portal API](/docs/globus-portal-api). ### Environment Derivation The SEVIS API environment (production vs. test/staging) is derived from the active certificate rather than a manual configuration toggle. This prevents accidental submissions to the wrong SEVIS environment. Active certificate issued by the Department of State for the production SEVIS endpoint. All batch submissions affect real government records. Test certificate pointing to the SEVIS test endpoint. Used for development, QA, and pre-production validation. No impact on real SEVIS records. > **Certificate determines environment. No manual environment toggle exists.** > > This design eliminates the risk of accidentally submitting test data to production SEVIS or production data to the test endpoint. The certificate itself is the source of truth for which SEVIS environment Globus communicates with. ## Program Extension & Program Renewal Requests > **Terminology: Program Extension vs Reservation Extension** > > Program Extension (this section) extends a participant’s authorized program duration on an active DS-2019. It is a lifecycle event that modifies the existing application timeline. Reservation Extension is unrelated — it refers to a partner extending a time-limited hold on a job position (default 7-day hold; the hold window `holdDurationDays` and extension cap `maxHoldExtensions` are rule-library parameters) and is documented in Partner Governance — Reservation Rules . Program Extension and Program Renewal requests allow participants to continue beyond their original program end date. Program Extension modifies the existing application’s duration (same DS-2019, amended end date). Program Renewal creates a new linked application for a subsequent program season (new DS-2019). This subsystem handles duration validation, SEVIS amendment triggers, portal visibility, review queues, and carry-forward logic for multi-season scenarios. Both are evaluated as distinct application types (program_extension and program_renewal) in the Rules Engine , with rule resolution flattening the Brand → Program → Season hierarchy to determine which duration limits, placement requirements, and compliance gates apply. ### Program Extension — Duration Validation Program Extension requests are validated against program-specific duration rules before entering the review queue: | Validation Rule | Description | Enforcement | | --- | --- | --- | | Maximum duration | Total program duration (original + program extension) cannot exceed program-defined maximum | Hard block — request rejected with reason | | Minimum remaining | Program extension must be requested before the program end date (configurable lead time per program) | Hard block — late requests flagged for manual review | | Season boundary | Program extension cannot cross into a closed or non-existent season without explicit carry-forward approval | Soft block — requires supervisor override | | Active placement | Participant must have an active, confirmed placement to request a program extension | Hard block — no active placement, no program extension | | Compliance status | Participant must be in good compliance standing (no open compliance issues) | Soft block — queued for compliance review first | ### Program Extension — SEVIS Amendment Triggers When a Program Extension is approved, a SEVIS amendment is automatically triggered to update the participant’s program end date in government records. The amendment follows the same batch processing pipeline as other SEVIS updates. Program Extension Approved / \u2192 / SEVIS Amendment Queued / Batch Submitted / SEVIS Confirmed / New DS-2019 Issued ### Program Extension — Review Queue & Eligibility Program Extension requests enter a dedicated review queue with eligibility pre-screening: Portal visibility — applicants and partners can see Program Extension eligibility status on their dashboard when the program allows program extensions / Request submission — applicants submit Program Extension requests through the Applicant Portal with desired end date and justification / Pre-screening — Rules Engine evaluates duration limits, compliance standing, placement status, and EXTENSION_FEE payment status before queue entry. Payment status is checked against application_finance_summary or the specific program extension charge status (enforcement: hard block — program extension cannot proceed without EXTENSION_FEE in PAID or INVOICED status, configurable per program). / Review queue — approved requests enter the Program Extension Review queue with SLA tracking and priority based on program end date proximity / Staff decision — reviewer can approve, reject (with reason), or request additional information / Season validation — system verifies the program extension period falls within an active season boundary **Approval failure.** Approving through `POST /api/admin/extension-requests/:id/transition` (permission `extension_requests.manage`) first extends the reservation hold and only then marks the request approved. If the hold cannot be extended the call returns 409 with top-level `error.code` `RESERVATION_EXTENSION_FAILED` (`details` is null) and the request stays unapproved. The message says what failed (the request has no linked application, `extensionDurationDays` is not a positive integer, no active reservation hold was found, the hold update failed, or the hold was no longer active when the extension was applied); the internal reason code is not returned to the caller. ### Program Extension — Carry-Forward Logic *Status: cross-season carry-forward is not built. Approving a Program Extension (`extensionApprovalService.ts`) creates an `extension` application in the original application's season, linked back to the originating request through `sourceExtensionRequestId` (the prior application id is recorded in the `application.created` audit metadata), and extends the reservation hold. There is no `carry_forward_from_id` and no season-boundary logic.* When a Program Extension crosses a season boundary, carry-forward logic determines how the participant’s record transitions between seasons: The original season record is marked as completed with a program-extension carry-forward flag / A new application record is created in the target season, linked to the original via carry_forward_from_id / Placement, compliance history, and SEVIS linkage are preserved across the season boundary / The participant maintains their existing SEVIS ID — no new SEVIS record is created / Quota impact is calculated in the target season (program-extension participants count against target season quotas) / Partner attribution is preserved from the original application — no re-attribution on carry-forward > **Program Extensions are lifecycle events, not new applications.** > > A Program Extension modifies the participant’s existing program timeline. It does not create a new application, restart the lifecycle, or require re-placement. The carry-forward mechanism handles season boundary transitions while preserving continuity of the participant’s record. ### Program Renewals Unlike Program Extensions (which modify an existing application), Program Renewals create a new linked application for a subsequent program year or cycle. The Program Renewal workflow handles the transition between the completing application and the new application while preserving the participant’s history and SEVIS linkage. Program Renewal Eligibility Check / \u2192 / Program Renewal Request Submitted / Program Renewal Approved / RENEWAL_FEE Triggered / New Application Created / SEVIS Continuation Linked Program Renewal creates a new application record linked to the prior application (the prior application id is passed at creation and recorded in the `application.created` audit metadata; `renewal_requests.renewal_application_id` points back to the new application) — it is not a modification of the existing application / The new application enters the lifecycle at the Intake phase with pre-populated data carried forward from the original application / Program Renewal workflow type (RENEWAL) is distinct from standard applications — it follows a streamlined intake path since the participant is already known / RENEWAL_FEE is triggered upon Program Renewal approval (`renewal.approved` event) and must be paid before the new application can progress past the Review phase / The original application transitions to Completion with a program-renewal carry-forward flag / SEVIS continuity is maintained — the new application references the same SEVIS ID when the program and visa category permit it / Program Renewal eligibility is validated against season availability — the target season must be active and accepting program renewals / Partner attribution from the original application is preserved on the program renewal unless explicitly reassigned > **Program Renewals are new applications, not Program Extensions.** > > A Program Renewal creates a new application with its own lifecycle, finance charges, and season assignment. It is linked to the prior application for continuity but follows the full application workflow. See Finance & NetSuite — Charge Trigger Events for the RENEWAL_FEE trigger details. **Staff routes for extension and renewal requests.** `POST /api/admin/extension-requests/program-extension` (permission `extension_requests.manage`) creates a `program_extension` request with status `pending` and returns 201; the body needs `applicantId`, `applicationId`, `programId`, `hostOrgId`, `currentEndDate`, `requestedEndDate` and `reason`. It returns 400 when the application does not belong to the applicant or program, the program does not allow extensions, the duration is under 1 day or over 365 days, the requested end passes the program `maxDurationMonths` (counted from the placement start date, else the application creation date), or the applicant already has 1 approved program extension. Approval goes through `POST /api/admin/extension-requests/:id/transition` and creates the first-class extension application; a program extension has no reservation hold to advance. For a reservation-hold extension request the same call also advances the active hold, and if the hold cannot be advanced it returns 409 `RESERVATION_EXTENSION_FAILED` (`details.reason` such as `NO_ACTIVE_HOLD`, `MISSING_DURATION`, `HOLD_UPDATE_FAILED` or `MISSING_APPLICATION_LINK`) and the request is not marked approved. Renewal reads under `/api/admin/renewal-requests` need `renewal_requests.view` or `renewal_requests.manage`: `POST /check-eligibility` (body `applicantId`, `applicationId`, `programId`, optional `targetSeasonId`) returns the eligibility result, `GET /available-seasons/:programId` lists the program's seasons that end in the future ordered by start date, and `GET /:id` returns one renewal request. **Flow gate reason codes.** The shared flow gate, used both by the portal dashboard available-flows read and by application creation, evaluates program capability, in-flight application, window and eligibility for renewal, extension and J-2 (spouse or child) flows. A blocked flow carries one of `PROGRAM_CAPABILITY_DISABLED`, `FLOW_WINDOW_CLOSED`, `APPLICANT_INELIGIBLE` or `DUPLICATE_FLOW_IN_FLIGHT`. `APPLICANT_INELIGIBLE` comes from the renewal or extension eligibility service and lists the failed checks in `eligibility.details`; if the eligibility computation itself errors the gate fails closed with the detail `Eligibility could not be evaluated`. J-2 flows have no eligibility service and pass this check when the program capability is on. Creating an application for a blocked flow returns 400 `VALIDATION_ERROR` with the blocked code as `error.reasonCode` and `details` carrying `flowType`, `inFlightApplicationId`, `eligibility` and `availability`. ## Applicant Lifecycle Phase & Readiness Every applicant sits in exactly one of the eight fixed Lifecycle Phases. The lifecyclePhase field on the applications table records the current phase. It is never set manually: it is derived forward-only from the furthest verified Stage by the Lifecycle Reconciliation engine and never pulled back (see Phase & Stage Derivation). application / \u2192 / validation / interview / placement / visa_sevis / pre_departure / in_program / completion Independent of phase, each application carries a system-calculated readiness read-model that indicates whether the application can move forward or is held up at its current point: | Readiness | Meaning | | --- | --- | | on_track | Application is progressing normally. All required inputs are present and no blockers exist. | | blocked | Application cannot advance. A hard dependency is missing (e.g., required document, placement, SEVIS submission). | | needs_attention | Application can advance but has soft issues that should be reviewed (e.g., expiring reservation, pending interview). | | completed | Application has reached the Completion phase. No further transitions are expected. | > 📘 > > `blocked` derives only from **genuine** compliance failures on data the applicant has actually submitted. Fail-closed rules referencing data from steps the applicant has not yet reached count as *awaiting submission* — they surface as attention items for staff but never set readiness to `blocked` (and therefore never lock portal steps). This prevents the catch-22 where an application was blocked for missing data the applicant had not yet had the chance to enter. ## Portal Step Submission Flow When an applicant completes a step in the Applicant Portal, the following pipeline processes the submission end-to-end — from data capture through lifecycle reconciliation: Applicant fills step in Portal / POST /api/portal/steps/:stepKey/submit / stepValidation validates stepData against the step\u2019s defined schema / Data saved to portal_step_snapshots / step_status_tracking updated \u2192 status: completed / Event emitted: step.status_changed / Biographical sync extracts key fields \u2192 updates applicants table / Lifecycle reconciliation triggered: gathers all step progress + review decisions as evidence, advances Stage through the shared transitionApplicationStage writer only when a milestone is verified (never from completion percentage), which writes an application_stage_history row and derives lifecyclePhase forward-only from the furthest verified Stage, then sets readiness (on_track / blocked / needs_attention) ### Portal Contract Interface The portal contract is declared as endpoint schemas, not as a single shared interface file. ~~shared/portalContract.ts~~ (the former shared interface file) was **deleted on 2026-04-14** and no longer exists; do not import from it or cite it. | Component | Purpose | | --- | --- | | `server/routes/endpointSchemas/portal-*.ts` | Declarative request/response/param/header schemas and stability tier for every `/api/portal` endpoint — the authoritative contract surface. | | `server/versioning/portal-contract-snapshot.json` | Committed baseline of that contract. A diff against it is what detects a breaking portal change. | | `server/versioning/changeRegistry.ts` | Version entries that must accompany any new or altered portal endpoint. | | `scripts/checkers/portal-contract-guard.cjs` | Build-time guard (run via `scripts/run_skill_enforcement.cjs`) that fails the build when a portal endpoint changes without a snapshot update and a `changeRegistry` entry. | | `scripts/update-portal-contract-snapshot.js` | Regenerates the snapshot after an intentional contract change. | | `server/services/portalAdapter.ts` | Maps raw portal step data to compliance inputs. Supports hardcoded fallbacks and dynamic `complianceInputMapping`. | | `server/services/biographicalSync.ts` | Extracts key biographical fields from portal step snapshots to update the core `applicants` table. | | `server/services/workflowPublisher.ts` | Pushes workflow configuration to the Portal. | | `server/services/stepProgressSync.ts` | Local caching and sync of step completion states. | > **The endpoint schema registry plus its committed snapshot is the single source of truth for Portal integration types.** > > Any change to step schemas, submission payloads, or status enums must be made in `server/routes/endpointSchemas/portal-*.ts`, accompanied by a `changeRegistry.ts` version entry, and the snapshot regenerated — otherwise `portal-contract-guard` fails the build. ### Application-Bound Step Serving (Agreement Step Hardening) Shipped Aug 31 2026 (security correction; portal change-registry effective version `2028-10-24` for `GET /api/portal/applications/:appId/step-config/:stepKey` and `POST /api/portal/steps/:stepKey/submit`). Portal step serving is **application-bound**: the Applicant Portal must send its integration key **and** the signed-in applicant identity (the verified-token-aware portal auth boundary) when it reads or submits any workflow step. Before serving step configuration — including agreement text on an Agreement carrier step — or recording acceptance/snapshot evidence, Globus verifies that the applicant owns the application and that the requested step is a member of that application's exact pinned workflow — the current published definition, or an archived definition that was previously published (one carrying a publication timestamp) after a replacement publish archived it. Drafts and archived definitions that were never published are rejected (422 `WORKFLOW_VERSION_NOT_PUBLISHED`), and the portal never falls forward to the newest published workflow: moving an existing application to another version goes through the reconciliation preview-and-apply flow (corrective change, portal change-registry effective version `2028-11-16` for step config, step submit and the workflow snapshot). Calling with only an application UUID, or requesting a step key that is not in the pinned workflow, is rejected before any read or write. This closes the previous gap where a valid integration key plus a guessable step key could surface agreement text outside the applicant's own workflow. ## Profile Completeness Algorithm Profile completeness is a computed percentage that indicates how much of an applicant’s required data has been submitted and verified. It is never stored — it is calculated on-the-fly from the current state of workflow steps, documents, and biographical fields. | Signal Source | Weight | Logic | | --- | --- | --- | | Required workflow steps | Primary | Count of completed required steps / total required steps for the active workflow version. Hidden/conditional steps excluded unless their branch is active. | | Required documents | Primary | Count of approved documents / total required document types for the current wave. Documents in Pending Review count as submitted but not verified. | | Biographical fields | Secondary | Count of populated mandatory bio fields / total mandatory bio fields as defined by the program configuration. | | Compliance gates | Modifier | If a compliance gate is failed (e.g., background check rejected), completeness is capped at the pre-gate maximum regardless of other progress. | > **Program-Specific Weighting** > > Each program can configure custom weighting for completeness signals. For example, Camp Counselor programs may weight document completion higher than biographical fields, while Teach USA programs may prioritize credential verification steps. The default is equal weighting across all primary signals. ## Monthly Check-In System During the In Program phase, J-1 participants are required to complete monthly check-ins as part of ongoing compliance monitoring. The check-in system manages the full lifecycle from schedule generation through compliance enforcement. ### Cadence-Agnostic Check-In Contract The check-in system is built on a cadence-agnostic contract: Globus emits the set of check-in periods for a participant, and the Applicant Portal renders whatever periods it receives without assuming a fixed monthly rhythm. The portal never derives the schedule itself — it is a pure consumer of Globus-emitted periods, each carrying its own window, due date, and grace. Globus owns the cadence Schedule generation, period spacing, window/grace, and reminder timing are computed server-side from the season configuration. The cadence is data emitted to the portal, not portal logic. Portal renders periods generically The portal iterates the emitted periods and renders each as a check-in card. Introducing a new cadence (biweekly, quarterly, ad-hoc) requires no portal change — only a different emitted period set. v1 emits monthly-only For the first release Globus emits a monthly cadence (configurable due day, default the 15th). The contract supports other cadences without re-work, but only monthly is emitted today. > **Shipped — On-Program Monthly Check-In** > > This contract is implemented and shipped under the On-Program Monthly Check-In project, which owns the cadence-agnostic check-in contract. Runtime ownership is the On-Program Lifecycle delivery area; this docs page is the spec source. ### Check-In Compliance Reporting The check-in system generates compliance reports used for SEVIS oversight and sponsor accountability: ## Travel Request Flow During the In Program phase, applicants may submit travel requests through the Applicant Portal. The travel request flow is a Globus-controlled approval workflow — the portal collects the request but never approves or rejects it autonomously. Applicant submits travel request via portal (destination, dates, travel type, supporting documents) / Portal validates dates and displays holiday period warnings as UX guidance / POST /api/travel-requests/receive — request pushed to Globus / Request appears in staff Travel Request review queue / Staff reviews: travel type, date conflicts, program policy compliance, supporting documentation / Staff approves or rejects with written justification / Decision communicated back to portal via webhook event (travel_request.approved / travel_request.rejected) / Portal updates applicant view with decision and any conditions | Endpoint | Method | Purpose | | --- | --- | --- | | /api/travel-requests/receive | POST | Receive travel request from the Applicant Portal | | /api/travel-requests/application/:applicationId | GET | List travel requests for a specific application (staff view) | | /api/travel-requests/:id/review | POST | Staff approve/reject with justification | > **Approval Authority** > > Travel request approval is always a Globus staff decision. The Applicant Portal provides date validation and holiday warnings as guidance only. No travel request is auto-approved regardless of portal-side validation. ## Early Termination Flow Early termination occurs when a participant’s program ends before the scheduled completion date. This triggers a structured flow covering approval, SEVIS reporting, and downstream cleanup. ## No-Show Detection A no-show is detected when a participant fails to arrive at their program site by the expected arrival date. Detection is automated with configurable thresholds and escalation paths. | Detection Signal | Threshold | Action | | --- | --- | --- | | No flight arrival confirmation | Program start date + 2 days (configurable) | Flag as potential no-show, notify assigned staff | | No SEVIS check-in initiated | Program start date + 5 days | Escalate to compliance team, enter No-Show review queue | | Host reports non-arrival | Any time after program start | Immediate no-show flag, staff review required | | No activity after critical period | Program start + 20 days (configurable) | Auto-trigger SEVIS no-show reporting, compliance escalation | > **SEVIS No-Show Reporting** > > No-show detection feeds directly into the SEVIS batch processing system. When a participant is confirmed as a no-show, Globus generates the required SEVIS termination record with the no-show reason code. This is a compliance-critical flow with strict regulatory deadlines. ## Program Completion Flow Program completion is the structured process for transitioning a participant from In Program to Completed status. It encompasses exit surveys, final compliance checks, SEVIS closure, and record finalization. Program end date approaches — system generates pre-completion checklist (configurable: 14 days before end) / Applicant completes exit survey and final check-in via the portal / Staff verifies employment end confirmation from host organization / System validates all compliance requirements met (check-in history, no outstanding flags) / SEVIS closure record queued — program completion reason code added to next batch / Financial reconciliation — final charges processed, refunds calculated if applicable / Application status transitions to Completed — readiness set to completed / Post-program grace period begins (30-day travel window for J-1 participants) ## Step Submission API The step submission API defines the contract between the Applicant Portal and Globus for submitting workflow step data. These endpoints handle the full lifecycle of step data — from initial submission through staff review. | Endpoint | Method | Purpose | Auth | | --- | --- | --- | --- | | /api/portal/steps/:stepKey/submit | POST | Submit step data from the Applicant Portal (body: `applicationId`, `stepData`). Triggers validation, snapshot, bio-sync, and lifecycle reconciliation. | Portal token or integration key | | /api/portal/steps/:stepKey/validation-schema | GET | Retrieve the validation schema descriptor for a step (404 if the step has none). | Integration key | | /api/portal/applications/:appId/step-config/:stepKey | GET | Retrieve the configuration of a single step for an application (ownership-checked). | Portal token or integration key | | /api/applicants/:applicantId/steps/:stepKey/complete | POST | Staff completes a step for an applicant (body: `applicationId`, optional `stepData`). | Staff token (permission `applicant.journey.update`) | | /api/applications/:id/multi-year-steps | GET | Aggregate portal step snapshots for the application applicant and program across enrollment years. Requires the `stepKeyPattern` query parameter (400 `VALIDATION_ERROR` if missing), matched as a substring of the step key; 404 if the application is missing. Returns `totalYears` and `entries` (latest snapshot per enrollment year and step key, dependents excluded, each with `enrollmentYear`, season, `applicationId`, `stepKey`, `stepData`, `isCompleted`, `submittedAt`, `snapshotAt`). | Staff token (permission `applicant.profile.view`) or API key scope `applications.read` | ### Staff Back-Office View Staff members access submitted step data through the back-office applicant detail view. The interface displays all submitted steps with their data, review status, and version history. Staff with the appropriate RBAC permissions can edit or correct step data, which creates a new versioned snapshot attributed to the staff member. ### Step Completion Percentage The step completion percentage is computed per node type in the workflow tree: | Node Type | Calculation | | --- | --- | | Leaf Step | Binary — 0% (not started / in progress) or 100% (completed) | | Hub (group) | Average of child node percentages. Only visible children counted (respects visibility rules). | | Phase | Average of all hub and direct-child step percentages within the phase. | | Overall | Weighted average of phase percentages. Weighting follows program configuration or defaults to equal weight per phase. | ## Applicant Queue — Multi-Bucket Surfacing Because pre-SEVIS work runs across concurrent Processing Areas, a single applicant frequently has open work in more than one queue bucket at the same time (e.g. document review and host interview scheduling). The staff Applicant Queue surfaces this by listing the applicant once per primary bucket but exposing every secondary bucket they belong to in an “Also in” column — so staff never lose track of the parallel work and a bucket is never “hidden” just because the applicant happens to be visible somewhere else. Bucket definitions, SLA timers, and per-queue exception routing live in the Queue System spec; this section names only the multi-bucket surfacing contract. ### Also In Column & Primary Bucket Resolution | Column | Definition | | --- | --- | | Primary bucket | The queue the current view is rendering. The applicant row is sorted, SLA-coloured, and assigned-to-styled by this bucket only. | | Also in | Comma-separated chips of every other open bucket the applicant currently satisfies. Each chip is a link that re-opens the queue scoped to that bucket with the same applicant focused. | | Bucket count badge | Numeric badge next to the applicant name when bucket count > 1. Hovering reveals the full list; click jumps to the applicant detail page with the queue context preserved. | | De-dupe rule | An applicant is never listed twice within the same primary-bucket render. Across distinct queue views (e.g. switching from Document Review to Interview Scheduling), the same applicant will appear in each view that owns them. | Bucket membership is computed live from the per-area state model — there is no persisted queue_membership table. A Processing Area entering a terminal-pass state silently removes the applicant from that bucket on the next refresh. ### Screening Action Required The Screening Action Required queue (membership rule `screening_action_required`) lists applications with outstanding staff-owned screening activity, and each row explains itself per activity rather than with a generic blocked reason. Screening-category work items are classified by what is missing: no outcome and no scheduled time — **needs scheduling**; scheduled but no outcome — **needs an outcome**; outcome recorded as no-show — **needs no-show follow-up**. Completed or cancelled items drop out unless they ended as a no-show. The row's reason label is the first outstanding activity, the action text lists every outstanding activity, and the priority starts at high and is raised one step when the queue SLA is breached. ### Per-Bucket SLA, Sort & Filter Sort order, SLA colouring, and the default filter set are per-bucket, not per-applicant. Two staff looking at the same applicant from two different queues see different urgency cues because each bucket has its own SLA timer (e.g. document-review SLA starts at upload; interview-scheduling SLA starts at host request). The applicant detail page reconciles the picture by showing every bucket the applicant is in with its own SLA pill — never a single “overall” SLA. SLA timers are driven through `/api/sla`. `POST /api/sla/start` (body `applicationId` and `queueDefinitionId`) returns `eventId` and `started`; `POST /api/sla/pause/:eventId` (optional `reason`), `POST /api/sla/resume/:eventId` and `POST /api/sla/complete/:eventId` act on one timer event. These four need `dashboard.view` and are audited as `sla_timer.started`, `sla_timer.paused`, `sla_timer.resumed` and `sla_timer.completed`. `POST /api/sla/bulk-recalculate` recalculates every active timer, needs `admin.access`, and emits `sla.bulk_breach_detected` when any timer is breached. `PUT /api/admin/escalation-tickets/:id/resolve` (permission `admin.access`, optional `resolutionNote`) marks an escalation ticket resolved with the resolving user and time, and answers 404 `NOT_FOUND` when the ticket does not exist. The mutation registry lists `applicants.view` for the timer routes, but the handlers in `routes/slaPolicies.ts` check `dashboard.view`. ## Applicant Assignment Model Every application carries one assigned Globus staff owner at a time — the recruiter / programme coordinator responsible for moving that applicant's pre-SEVIS work forward. Assignment is decoupled from queue bucket: a single staff owner can own an applicant across all four concurrent Processing Areas, even when different operational queues surface different pieces of the work. Assignment is a back-office concept only and is never exposed to applicants or partner portals. ### Assignment States > **Data model (additive)** > > Assignment is person-level: `assigned_staff_id` (nullable user id), `assigned_at` and a stored `assignment_state` column (`pending`, then `assigned`, then `reassigned`) live on the applicants table and are written only through the assignment service (`applicantAssignment.ts`); `applications.assignedToUserId` is kept in lock-step for back-compat. A separate `applicant_assignment_history` table appends one row per change with `action` (created | reassigned | auto_assigned | unassigned), `from_staff_id`, `to_staff_id`, `actor_user_id`, `reason` and `created_at`. ### Manual Assign Flow (Default) - Staff opens the applicant detail page (or selects rows in the queue) and clicks Assign / Reassign. - A picker lists eligible staff scoped to the applicant's brand & programme, sorted by current open-applicant count (lightest load first) with a search field. - An optional reason note is captured (free text, mandatory for Reassigned, optional for first assignment). - On confirm: assigned_staff_id and assigned_at update atomically, a history row appends with source = manual, and an audit log row writes (vocabulary below). - The new owner receives a single in-app notification: “You were assigned applicant by .” ### Optional Load-Balanced Auto-Assign Auto-assign is OFF by default. When enabled per programme × season by an administrator, any application transitioning from Pending assignment state runs the auto-assign rule: - Filter the staff pool to active recruiters scoped to the applicant's brand & programme and not flagged Out-of-Office. - For each candidate, compute open applicant count — applications where they are assigned_staff_id and the application has not yet entered Phase 7 (In Program). - Select the candidate with the lowest open count. Tiebreaker: least-recently-assigned (oldest MAX(assigned_at)); if still tied, deterministic hash of staff id — never random, so re-runs are reproducible. - Write the assignment with source = auto and emit the dedicated auto_assigned audit action. - If the staff pool is empty, the application remains Pending and surfaces in the Unassigned bucket; no fallback to the previous owner, no silent drop. > **Auto-assign never overrides a human** > > Auto-assign only fires on transitions out of Pending. It never reassigns an already-assigned application — reassignment is always a manual action so workload changes have a named human actor. Staff routes: `POST /api/applicants/:id/unassign` (permission `applicant.manage`; optional `reason`) returns the applicant to Pending and answers 404 when the applicant does not exist. `GET` and `PUT /api/auto-assign-settings` (permission `applicant.manage`) read and set the per programme and season toggle: `GET` needs the `programId` and `seasonId` query parameters (400 `BAD_REQUEST` otherwise) and reports `enabled` false when no row exists; `PUT` takes `programId`, `seasonId` and boolean `enabled`, upserts the row, and is audited as `auto_assign.setting_changed`. `POST /api/applications/reconcile-lifecycle` runs lifecycle reconciliation with body `persist` (default true) and optional `applicationIds`; without ids it reconciles every application, with ids it returns `total`, `processed`, `changed`, `errors` and per-application `results`. The reconcile route checks `applications.manage` in `routes/applications/lifecycle.ts`, although the mutation registry lists `applicant.manage`. ### Assignment History & Audit Vocabulary Every assignment change writes one row to applicant_assignment_history and one row to the platform-wide audit_logs via the shared recordAudit() helper documented on the Audit Console. The named action vocabulary for the audit channel is: | Action | Emitted when | | --- | --- | | applicant.assignment.created | First time an application moves from Pending to Assigned via manual action. | | applicant.assignment.reassigned | Manual ownership change on an already-assigned application. before/after both populated; reason note carried in the audit payload. | | applicant.assignment.auto_assigned | Auto-assign rule selects an owner. Payload includes the candidate pool size, the open-applicant counts considered, and the tiebreaker that decided the pick — so the decision is reproducible from the log alone. | The history is read with `GET /api/applicants/:id/assignment-history`, which requires `applicant.view` or `applicant.manage` (so program managers who can assign and reassign but lack `applicant.view` can still read it) and returns the applicant `applicant_assignment_history` rows oldest first. ## Activity Timeline & Last-Touch Signals The Activity Timeline is the chronological record of who did what, when on an application. It powers the per-applicant timeline tab, the staleness indicators on the Applicant Queue, and the last-touch columns used by recruiter workload reports. It is staff-back-office only — applicants and partners never see it. ### Two Channels: Applicant vs Staff The application row carries two last-touch timestamps that are maintained independently — collapsing them into one number hides whether the application is silent because the applicant has gone cold or because the staff owner has stopped working it. | Column | Updated by | Definition | | --- | --- | --- | | last_applicant_activity_at | Applicant | Most recent timestamp on any applicant-driven event: portal login, field save, document upload, message reply, check-in submission. | | last_staff_activity_at | Staff | Most recent timestamp on any staff-driven event on this application: review action, note, assignment change, queue action, outbound message, manual status change. System / rule-engine events do not count. | Both columns are updated after the originating mutation succeeds, in the same transactional path that writes the audit log row — they cannot drift away from the timeline they index. A separate `last_staff_review_at` timestamp records when staff last reviewed the application, and it drives the unread dot in the queue and applicants list. `POST /api/applications/:id/acknowledge-review` (permission `dashboard.view`) sets it to now, returns `acknowledged: true` with `lastStaffReviewAt`, and returns 404 for an unknown application. The acknowledging user is recorded as the staff-activity actor. ### Event Vocabulary Timeline entries are grouped into four channels. The channel determines which last-touch timestamp is bumped and how the entry is rendered. | Channel | Bumps | Example actions | | --- | --- | --- | | Applicant | last_applicant_activity_at | profile_save, profile_photo_upload, step_save, step_submission, document_upload, agreement_acceptance, checkin_submission, registration, application_creation (the `APPLICANT_PORTAL_ACTIVITY_ACTIONS` set) | | Staff | last_staff_activity_at | applicant.assignment.created, applicant.assignment.reassigned, applicant.document.reviewed | | System | (neither) | Rule-engine evaluation, queue refresh and SLA events — audited but not counted as activity, so staleness is not artificially reset by background work. | | Partner | (neither) — system, partner and API-key writes never bump the staff timestamp, which needs a verified human actor | Host comments and partner-portal actions are recorded in the timeline for context but do not reset applicant staleness. | ### Staleness Bands & Queue Surfacing Staleness is computed at render time from now() − last_*_activity_at. Bands are deliberately coarse — the goal is to make “needs a nudge” visible at a glance, not to encode an SLA (queue-level SLAs continue to live in the Queue System spec). - The Applicant Queue renders two pills per row: applicant-side band (from last_applicant_activity_at) and staff-side band (from last_staff_activity_at). Either pill can independently turn Stale or Cold. - A saved view “Stale applicants” defaults to applicant-side ≥ Stale AND staff-side ≥ Stale — both sides quiet at once is the actionable case. - Bands are derived; no staleness_band column is persisted. Re-tuning the thresholds is a config change, not a backfill. - Withdrawn, cancelled, and completed applications are excluded from staleness rendering entirely. ### Timeline Source & Audit Bridge The Activity Timeline tab on the applicant detail page is a filtered view over audit_logs scoped to entity_type = 'application' and the applicant's application id(s) — same row shape as the Audit Console documented under the platform audit contract. There is no parallel activity_timeline table: the audit log is the source of truth, and the timeline is a presentation layer that adds applicant-friendly grouping, channel filters, and the two last-touch summary lines at the top. ## Applicant Detail Page The Applicant Detail Page is the staff-facing view for a single applicant. It provides a comprehensive operational view including profile data, per-area status, assignment, queue membership, last-touch signals, and full lifecycle history. > **One spec, multiple surfaces — full-page and split-view** > > Every sub-section below applies identically across every surface that opens an applicant. The same blocks render against the same data — there are no detail-only-on-one-surface sections, and a new block added here must appear everywhere. Current entry surfaces: Full-page Applicant Detail at /staff/applicants/ — the standalone surface. Split-View Applicant Workspace right panel — reached from the Applicant Queue. See Split-View Applicant Workspace for the queue-context preservation contract. Host Management → Applicants tab (row click) — clicking any row on the sponsor-side Host Management Applicants tab opens the full-page detail surface. See Partner Organizations → Applicants Tab Row Interaction . ### Workflow Step Progress View The step progress view on the applicant detail page renders the applicant’s workflow as a visual progress tracker. Each phase is expandable to show individual steps with their status, submitted data summary, and review notes. The view is driven by the applicant’s bound workflow version — it always shows the exact workflow the applicant is completing, not the latest published version. ### Lifecycle Phase Filter The phase filter dropdown on the applicant detail page aligns with the 8-phase Lifecycle spine (application through completion). Staff can filter the activity timeline, compliance panel, and document list to show only items relevant to a specific Lifecycle Phase. The filter also controls which phase of the workflow progress view is expanded by default. All Phases / Application / Validation / Interview / Placement / Visa & SEVIS / Pre-Departure / In Program / Completion ### Split-View Applicant Workspace The Split-View Workspace is the default staff entry point into an applicant when navigated from the Applicant Queue. The page is divided into two persistent regions: a left queue panel that keeps the surrounding queue rows (with their current filter, sort, and scroll position intact) and a right detail panel that renders the full Applicant Detail blocks documented in the sub-sections above. Opening an applicant never loses queue context; closing the detail panel returns the user to the exact queue position they came from — same filter, same scroll offset, same focused row. | Contract | Behavior | | --- | --- | | Queue-context preservation | Left panel retains the active queue's filter set, sort order, and scroll position for the full lifetime of the split-view session. Closing the right panel restores focus to the originating row. | | Deep-linkability | The split view is fully URL-addressable: `/queues?applicant=ID&app=ID` opens the queue with the applicant pre-selected. Pasting the URL into a fresh tab reproduces the exact same split-view state. | | Keyboard navigation | Left arrow moves to the previous applicant in the current queue order; Right arrow moves to the next. The detail panel updates in place — it never unmounts and remounts — so scroll position within open sub-sections is preserved. | | Parity with full-page detail | Every block documented in the Applicant Detail Page sub-sections (Header, Per-Area Status, Assignment, Workflow Progress, Compliance, Activity Timeline) renders identically on both surfaces. No detail-only-on-full-page surfaces are permitted. | | Full-page escape hatch | The detail panel header exposes an "Open in full page" affordance for tasks that need maximum screen real estate (e.g. document review). The full-page URL is also deep-linkable: /staff/applicants/ . | | Mobile responsiveness | Out of scope for this revision. The split view is a desktop-only surface; mobile staff fall back to the full-page Applicant Detail route. | > **Forward cross-link** > > The left queue panel is the same row contract documented in Queue System — Queue Row Contract and surfaces the multi-bucket “Also In” column from Also In Column & Primary Bucket Resolution without re-rendering them. **Change digest and view markers.** `POST /api/applicants/:id/workspace-visit` (permission `applicant.profile.view`; 404 for an unknown applicant) upserts the caller row in `applicant_view_markers` and returns `previousVisitAt` and a change `digest`. The table holds one row per staff user and applicant (`staff_user_id`, `applicant_id`, `last_viewed_at`, unique on the pair, no foreign key on the user). The digest is built from audit rows on the applicant and its applications written since the previous visit, scanning at most 500 rows; the first visit returns an empty digest. The marker is viewer-local presence state and drives no domain behavior. ### Operational Timeline The Operational Timeline is the Activity Timeline tab documented in Activity Timeline & Last-Touch Signals extended to surface the workflow-progression events processors need in order to action an applicant from the split-view detail panel. It remains a filtered view over audit_logs — there is no parallel store, no second write path, no second source of truth. The additions to the audit vocabulary that the timeline reads from: | Audit action | Channel | Fires when | | --- | --- | --- | | applicant.lifecycle.transitioned | System / Staff | Application's derived Lifecycle Phase advances (Application → Validation, Interview → Placement, …) or a Processing Area moves to a terminal-pass state. Records old_phase / new_phase in the diff. | | applicant.approval.granted | Staff | A staff approval gate is cleared (sponsor approval, host approval, compliance approval). Records the gate id, approver, and any rationale. | | applicant.approval.denied | Staff | A staff approval gate is denied. Records the gate id, denier, and required reason. | | `document.uploaded` (`document.replaced` on re-upload) | Applicant / Staff | A document is uploaded against this applicant. Records document_type_id and source channel; already counted toward last_*_activity_at by the channel rules above. | | applicant.document.reviewed | Staff | A document review decision is recorded (approved / rejected / needs-info). Records reviewer and decision. | | queue.reason.created | System | Rules Engine emits a new queue reason against the applicant. See Queue Reason Object. | | queue.reason.resolved | System / Staff | A queue reason is cleared by a passing re-evaluation (auto_cleared) or by staff override (staff_override). | | applicant.assignment.created | Staff | First-time staff assignment on this application. Already documented under Assignment History & Audit Vocabulary. | | applicant.assignment.reassigned | Staff | Existing assignment is moved to a different staff owner. | | applicant.assignment.auto_assigned | System | Optional load-balanced auto-assign rule selects an owner for an unassigned applicant. | | applicant.next_action.invoked | Staff | Processor clicks a Next Action recommendation in the split-view detail panel. See Next Action Engine below. | - The timeline tab adds an event-type filter grouped by the four channels already defined in Event Vocabulary plus a “Workflow progression” pseudo-filter that selects exactly the audit actions in the table above. - Every row in the operational timeline links to its underlying audit row in the Audit Console for full before/after JSON. The Audit Console's vocabulary block is the canonical list — adding a new action to the operational timeline starts by adding it to the audit contract there. - The system / partner channel rules from Event Vocabulary continue to apply: rule-engine-driven entries do not bump last_*_activity_at, so staleness bands are not artificially reset by background workflow progression events. ### Next Action Engine The Next Action Engine renders zero-or-more recommended next actions on the right-side detail panel (and on the full-page Applicant Detail Header Panel). Recommendations are the bridge between rule-engine output, open queue reasons, and the canonical Quick Actions a processor can perform — they turn “the engine knows something is wrong” into “here is the one click that fixes it.” > **Persisted on the application, recomputed on reconciliation** > > There is no next_actions table. The current recommendation is stored on the application row (`next_action_label`, `next_action_kind`, `next_action_deep_link`) and recomputed on every lifecycle reconciliation pass; the detail panel reads the persisted values. Recommendation shape (per item in the derived set): | Field | Definition | | --- | --- | | id | Stable derivation key — hash of (applicant_id, action_type, source). Lets the UI dedupe and keep keyboard focus across recomputes. | | action_type | Closed vocabulary (see table below). The detail panel will not render a recommendation whose action_type is outside this set. | | title | Human-readable label shown on the action button (e.g. "Request passport scan from applicant"). | | source | Either a rules-engine outcome id (rule_definition.id + evaluation id) or a queue_reason.id — whichever produced this recommendation. Used for click-through to evidence. | | severity | info \| warning \| blocker — inherits from the producing queue reason or rule outcome. Drives sort order and pill colour. | | generated_at | Timestamp of the producing evaluation. Used as the deterministic secondary sort (oldest first within a severity). | | operation | The exact same operation contract the matching Quick Action invokes — see Quick Actions below. The recommendation is a pre-filled invocation of that operation. | Closed action_type vocabulary: | action_type | Maps to Quick Action | Typical producer | | --- | --- | --- | | request_document | Request document | Rules-engine outcome with failure_behavior = route_to_queue on a missing document; or queue reason with source_system = rules_engine and linked_document_type. | | follow_up_host | Send message (host) | Queue reason emitted by a stalled host-side action (e.g. host has not responded to placement offer within SLA). | | follow_up_partner | Send message (partner) | Queue reason emitted by a stalled partner-side action (e.g. partner has not submitted required cohort data). | | schedule_interview | Schedule interview | Rules-engine outcome requiring sponsor or host interview as a precondition to advancing the Screening Processing Area. | | escalate_compliance | Escalate | Compliance rule failure with severity = blocker, or a queue reason with source_system = sevis. | | reassign | Reassign | Assignment-policy rule (e.g. owner away, owner over capacity, programme reassignment override). | | send_reminder | Send message (applicant) | Queue reason with source_system = portal_submission indicating a stalled applicant-side step past its nudge threshold. | | request_payment | Request payment | Finance rule outcome flagging an outstanding charge gating the next lifecycle step. | Invocation contract: clicking a recommendation calls the exact same underlying operation that the processor would invoke by clicking the matching Quick Action manually — no parallel code path, no separate permission gate, no second audit shape. Every successful invocation writes an audit row with action applicant.next_action.invoked in addition to the underlying operation's own audit row (e.g. the document-request operation still writes its own `document.request` row). The next-action row records action_type, source, and recommendation_id in its after diff so an analyst can later answer “how often did processors take the engine's suggestion?” without joining tables. The Audit Console contract already accepts the action under its applicant.* family — see Audit Contract. ### Quick Actions Quick Actions are the canonical set of operations a processor can invoke from the right-side detail panel header (and from the full-page Header Panel quick-action row). Each maps to an existing operation contract documented elsewhere; this section names the set, not the operation internals. | Quick Action | Underlying operation | Notes | | --- | --- | --- | | Add note | `add_note` (permission `applicant.notes.edit`) | Free-text staff note; rendered in the Activity Timeline under the Staff channel. `POST /api/applications/:appId/notes` takes `content`, `visibility` (`internal` or `partner`) and `category` (default `general`). A `partner` note is also written to the partner note log in the same transaction; if the application has no partner organization it is refused with `400` and `error.code` `NO_PARTNER_ORG` and nothing is saved. | | Reassign | `reassign` (permission `applicant.manage`) | Same operation that emits applicant.assignment.reassigned. Requires reason capture per Assignment Panel spec. | | Request document | `request_document` (permission `document.verify`) | Creates a pending document request for the application (POST /api/applications/:id/document-requests, audit action `document.request`); pre-filled by request_document recommendations. | Send message, Escalate and Request payment were withdrawn from the quick-action set (see `QUICK_ACTION_KEYS` in `shared/applicantActionModel.ts`). The profile header also offers two staff flow actions for renewal and extension flows. `GET /api/applications/:appId/available-flows` (permission `applications.view`, `applications.edit` or `applications.manage`) lists a `renewal` entry when the program allows renewals and an `extension` entry when it allows extensions, each with eligibility. `POST /api/applications/:appId/flow-actions/send-reminder` (permission `applications.edit` or `applications.manage`; body `applicationType` of `renewal` or `extension`) emits an `application.flow_reminder` event on every call so the applicant is prompted again, and returns `ok` true; it returns 400 when the program does not allow that flow or the applicant is not eligible for it. `POST /api/applications/:appId/flow-actions/start-on-behalf` (same permissions; body `applicationType`, optional `seasonId`, and a required `reason` of at least 3 characters) creates the renewal or extension application for the applicant, tagged `initiatedBy` `staff:USER_ID`, and returns 201 with `applicationId` and `workflowDefinitionId`. A renewal is created in the server-resolved next season (or `seasonId` when given) and an extension in the prior application's season; 400 covers an ineligible flow and `No upcoming season is configured for this program`. The actions are audited as `application.flow_reminder_sent` and `application.start_on_behalf`. > **One operation, two entry points** > > Every Quick Action is the same underlying operation regardless of whether it was invoked manually from the panel header or by clicking a Next Action recommendation. The recommendation path is just a pre-filled invocation — it does not introduce a parallel write path, a parallel permission gate, or a parallel audit shape. ### Implementation Follow-up — P16 Asana Tickets The following tickets carry the runtime work for the split-view detail surface and the next-action engine. They follow the house Asana standard documented in docs/asana-ticket-standard.md. Runtime ownership is P16 (Queue System & SLA Management); this docs page is the spec source. Title Globus: split-view applicant workspace layout + keyboard navigation FOR PRODUCT OWNERS / STAKEHOLDERS Staff processors will be able to action an applicant without losing their place in the queue: opening an applicant slides their full detail panel in next to the queue list, arrow keys move to the prev / next applicant in place, and closing the panel returns to the exact same scroll position. Replaces the “click out, click back, lose your filter” trip that legacy systems force on processors today. ---------------------------------------- BACKGROUND The current /staff/applicants/ route is a full-page detail surface. Navigating from the Applicant Queue replaces the queue entirely; on back-navigation the queue re-renders from scratch, losing filter facet state and scroll position. There is no shared component between the queue rows and the detail surface. WHAT THIS TICKET DOES - Introduces `/queues?applicant=ID&app=ID` as the deep-linkable split-view URL. - Renders the existing Applicant Detail blocks (Header, Per-Area Status, Assignment, Workflow, Compliance, Activity Timeline) inside a right-side panel without remounting between applicant navigations. - Preserves the left queue panel's active filter, sort, and scroll position for the lifetime of the split-view session. - Binds Left / Right arrow keys to prev / next applicant in the current queue order. - Adds an “Open in full page” affordance that routes to /staff/applicants/ without losing the queue context. ACCEPTANCE CRITERIA (Gherkin) Feature: Split-view applicant workspace Scenario: Opening an applicant from the queue preserves queue context Given a staff user has filtered the "Ready for Review" queue and scrolled to row 47 When they click the row for applicant "A-1234" Then the URL becomes "/queues?queue=ready-for-review&applicant=A-1234&app=ID" And the left queue panel still shows the "Ready for Review" filter And the queue is still scrolled to row 47 with row "A-1234" focused Scenario: Right arrow key moves to the next applicant in queue order Given the split view is open on applicant "A-1234" at queue position 47 When the user presses the "ArrowRight" key Then the right panel re-renders against applicant at queue position 48 And the detail panel component is not unmounted And the URL updates to reflect the new "applicant" query parameter Scenario: Closing the detail panel returns to the originating queue position Given the split view is open on applicant "A-1234" at queue position 47 When the user closes the detail panel Then the URL becomes "/queues?queue=ready-for-review" And the queue is still scrolled to row 47 with row "A-1234" focused Scenario: Deep-link reproduces split-view state in a fresh tab Given a staff user pastes "/queues?queue=ready-for-review&applicant=A-1234&app=ID" into a new tab When the page finishes loading Then the left panel renders the "Ready for Review" queue And the right panel renders the Applicant Detail blocks for "A-1234" Scenario: Parity guard — full-page Applicant Detail renders the same blocks Given the same applicant "A-1234" When the user opens "/staff/applicants/A-1234" in full-page mode Then the rendered set of detail blocks equals the set rendered in the split-view right panel And a build-time test fails if a new detail block is added to only one surface Title Globus: operational timeline audit-vocabulary extension FOR PRODUCT OWNERS / STAKEHOLDERS Invisible to end users, but processors will see a richer Activity Timeline tab on the applicant detail panel — lifecycle transitions, approvals, document uploads, queue-reason activity, and assignment changes will all flow into the same chronological view they already use, instead of being scattered across separate logs. ---------------------------------------- BACKGROUND The Activity Timeline tab is already a filtered view over audit_logs scoped to entity_type = 'application'. Today the assignment family (applicant.assignment.*), document-upload family, and queue-reason family already write through the shared recordAudit() helper, but lifecycle phase transitions and approval-gate decisions do not yet emit a dedicated audit action — they are inferred from a mix of status-column diffs and downstream notification rows. WHAT THIS TICKET DOES - Adds applicant.lifecycle.transitioned, applicant.approval.granted, applicant.approval.denied, and applicant.document.reviewed to the audit vocabulary, written through the existing recordAudit() helper. - Extends the operational-timeline filter on the applicant detail panel to include a “Workflow progression” pseudo-channel selecting the new actions plus the existing queue-reason and assignment families. - Updates the Audit Console applicant.* contract entry to list the new actions. - No new tables; no parallel write path; the timeline remains a read-side view. ACCEPTANCE CRITERIA (Gherkin) Feature: Operational timeline audit-vocabulary extension Scenario: Lifecycle phase transition writes a dedicated audit row Given application "A-1234" is in lifecycle phase "Interview" When the rules engine transitions it to "Placement" Then an audit_logs row exists with action "applicant.lifecycle.transitioned" And its "after" diff records old_phase "Interview" and new_phase "Placement" Scenario: Approval gate cleared writes a dedicated audit row Given application "A-1234" requires "sponsor_approval" When staff user "u-9" grants sponsor approval Then an audit_logs row exists with action "applicant.approval.granted" And its "after" diff records gate "sponsor_approval" and approver_user_id "u-9" Scenario: Operational timeline tab surfaces the new rows Given application "A-1234" has both a transition and an approval row from the above scenarios When a staff user opens the Activity Timeline tab and selects the "Workflow progression" filter Then both rows are visible in chronological order And each row links to the matching Audit Console entry Scenario: System-channel events do not bump staff staleness Given application "A-1234" When the rules engine writes "applicant.lifecycle.transitioned" without a staff actor Then "last_staff_activity_at" on the application is unchanged Scenario: Build-time guard — vocabulary list stays in sync Given the operational-timeline filter source list and the Audit Console "applicant.*" contract list When a developer adds a new "applicant.*" action to one list without adding it to the other Then the vocabulary-sync test fails Title Globus: next-action derivation service for applicant detail panel FOR PRODUCT OWNERS / STAKEHOLDERS Processors opening an applicant will see a small set of suggested next actions at the top of the detail panel (“Request passport scan”, “Reassign — current owner over capacity”, etc.). Suggestions come from rules the engine already evaluates, so processors stop having to translate a queue reason into the right click. ---------------------------------------- BACKGROUND The Rules Engine already evaluates applicants and emits Queue Reasons against them; the canonical Queue Reason shape is documented in queue-system.mdx. Today there is no derivation layer that translates those reasons into actionable UI items — processors read the reason text and then hunt for the matching control on the detail page. WHAT THIS TICKET DOES - Adds a read-side deriveNextActions(applicationId) service that returns the recommendation set defined in the “Next Action Engine” spec section. - Enforces the closed action_type vocabulary; recommendations with an unknown type are dropped server-side. - Does not introduce a next_actions table — the function is pure over (latest rule outcomes, open queue reasons). - Ships with the companion ticket “Globus: next-action invocation routing & audit”. ACCEPTANCE CRITERIA (Gherkin) Feature: Next-action derivation service Scenario: Open queue reason for a missing document produces a request_document recommendation Given application "A-1234" has an open queue reason with source_system "rules_engine" And the reason references document_type "passport_scan" When deriveNextActions("A-1234") is called Then the result contains a recommendation with action_type "request_document" And its "source" references the queue_reason.id And its "operation" matches the `request_document` contract Scenario: Resolving a queue reason removes its recommendation on next derivation Given a recommendation derived from queue_reason "qr-7" When "qr-7" is resolved (auto_cleared or staff_override) And deriveNextActions("A-1234") is called again Then the result no longer contains a recommendation with source "qr-7" Scenario: Unknown action_type is dropped server-side Given a rules-engine outcome that would map to an action_type outside the closed vocabulary When deriveNextActions runs Then no recommendation is returned for that outcome And a server-side warning log is emitted naming the rejected action_type Scenario: Severity drives sort order, generated_at breaks ties Given three recommendations with severities "blocker", "warning", "warning" And the two "warning" items have different generated_at timestamps When the derivation result is returned Then the "blocker" item is first And the older "warning" item precedes the newer "warning" item Scenario: Recommendation id is stable across recomputes Given the same (applicant_id, action_type, source) inputs When deriveNextActions runs twice in a row with no state change in between Then the returned "id" values are identical for matching recommendations Title Globus: next-action invocation routing & audit FOR PRODUCT OWNERS / STAKEHOLDERS Invisible to end users, but every time a processor clicks a suggested action, the system runs the same operation they would have run by hand and records the click so we can later report on how often the suggestions are useful. ---------------------------------------- BACKGROUND Each Quick Action listed in the “Quick Actions” spec section is already a real, permission-gated, audited operation. The derivation service from the companion ticket produces recommendations that already carry a fully-formed operation field. What is missing is the click-handler glue that invokes the underlying operation through the existing call site — not a parallel one — and stamps an additional audit row so recommendation usage is measurable. WHAT THIS TICKET DOES - Routes Next Action button clicks through the same operation handler that the matching Quick Action button uses — no second permission check, no second write path. - Writes an applicant.next_action.invoked audit row in addition to the underlying operation's own audit row, capturing action_type, source, and recommendation_id in the diff. - Updates the Audit Console applicant.* contract entry to list applicant.next_action.invoked. - Surfaces a quiet error state on the panel if the underlying operation refuses (permission, validation) — the recommendation is not auto-dismissed; it remains visible until the next derivation drops it. ACCEPTANCE CRITERIA (Gherkin) Feature: Next-action invocation routing and audit Scenario: Clicking a recommendation invokes the same underlying operation as the matching Quick Action Given a recommendation with action_type "request_document" on application "A-1234" When a staff user clicks the recommendation Then the `request_document` operation handler is invoked exactly once And the resulting audit row "document.request" has the same shape as a manual invocation Scenario: A second audit row records the recommendation usage Given the same successful click as above When the operation completes Then an audit_logs row exists with action "applicant.next_action.invoked" And its "after" diff records action_type "request_document", source, and recommendation_id Scenario: Permission denial does not bypass the standard gate Given a staff user without permission `document.verify` When they click a "request_document" recommendation Then the underlying operation rejects with a permission error And no "applicant.next_action.invoked" row is written And the recommendation remains visible on the panel Scenario: Recommendation is removed by the next derivation, not by the click Given a recommendation produced by queue_reason "qr-7" is clicked and the underlying operation succeeds When the panel re-derives recommendations And queue_reason "qr-7" is now resolved Then the recommendation is no longer rendered And no client-side cache override was needed to hide it Title Globus: quick-actions bar on applicant detail panel FOR PRODUCT OWNERS / STAKEHOLDERS The right-side applicant detail panel gets a small bar of one-click actions (add note, send message, reassign, escalate, request document, request payment) so processors can act on an applicant without leaving the queue context. *Status: ticket text as filed. The shipped quick-action set is Add note, Reassign and Request document (`add_note`, `reassign`, `request_document`); Send message, Escalate and Request payment were withdrawn, so the six-action wording here and the `applicant.finance.request_payment` scenario below do not apply as written.* ---------------------------------------- BACKGROUND The same set of operations is already invocable from the full-page Applicant Detail header. They are individually permission-gated and individually audited. The detail panel inside the split-view workspace currently has no equivalent control row. WHAT THIS TICKET DOES - Renders the canonical Quick Actions set (the “Quick Actions” spec table above) on the right-side detail panel header. - Reuses the existing per-operation permission gating — no duplicated permission logic. - Pre-fills inputs when invoked by a Next Action recommendation (see companion ticket “Globus: next-action invocation routing & audit”). - Quick-actions are identical on the full-page and split-view surfaces — the parity rule from the detail page header callout is enforced by a shared component. ACCEPTANCE CRITERIA (Gherkin) Feature: Quick-actions bar on applicant detail panel Scenario: The canonical action set renders on the split-view detail panel Given a staff user opens the split-view workspace on application "A-1234" Then the detail panel header shows actions "Add note", "Reassign", "Request document" Scenario: The same action set renders on the full-page detail surface Given the same applicant When the user opens "/staff/applicants/A-1234" Then the Header Panel renders the same three quick actions And a build-time test fails if the two surfaces diverge Scenario: Per-action permission gating Given staff user "u-9" lacks permission "applicant.notes.edit" When the detail panel renders for "A-1234" Then the "Add note" action is rendered disabled with a tooltip naming the missing permission And the other two actions remain enabled Scenario: Recommendation pre-fills the action invocation Given a Next Action recommendation with action_type "request_document" and document_type "passport_scan" When the user clicks the recommendation Then the "Request document" quick action opens with document_type pre-selected to "passport_scan" ## Structured Lifecycle Model Clear separation of operational vs compliance states / No overloaded status labels \u2014 interviews and document waves are Work Types, not statuses / Derived readiness based on rules \u2014 5 signal sources, never stored / Two document waves (pre-showcase & post-showcase) with distinct timing / Interview tracking without status explosion / Each phase has a single focus area / Supports rules engine gating between phases / Defined actors at every phase \u2014 no ambiguity about who does what / Structured exception paths for withdrawals, cancellations, and disruptions / Every transition is auditable with before/after state > **Principle** > > An applicant’s position in the lifecycle is determined by their state in Globus — not by a manually set label. The rules engine evaluates readiness, and the lifecycle phase follows from that evaluation. Work Types track what’s happening. Queues surface what needs attention. ## Profile Completeness Algorithm Profile Completeness is a derived score that shows how much of an applicant’s required information has been submitted and verified. It is computed in real-time from the workflow template’s required fields and documents cross-referenced against the applicant’s current data. The score is never stored — it is always derived. ### Completeness Segments *Status: the weighted segments below are a design spec, not built. The built score (`profileCompleteness.ts`) is filled required fields divided by total required fields for the applicant's program (`BASE_REQUIRED_FIELDS` plus per-program defaults, or the `required_fields` of the program's `documents_verification` rule), shown as one percentage with a missing-field list.* The overall completeness score is composed of weighted segments: | Segment | Weight | What Counts | Complete When | | --- | --- | --- | --- | | Biographical Data | 20% | Name, DOB, nationality, passport number, contact info, emergency contacts | All required biographical fields are populated and validated | | Documents | 35% | All required document types from the workflow template | Every required document type has at least one verified, non-expired record | | Workflow Steps | 25% | Applicant-facing workflow steps (forms, uploads, confirmations) | All required applicant steps are in “completed” state | | Compliance Gates | 10% | Background check, health clearance, insurance verification | All compliance gate steps are cleared | | Placement Data | 10% | Employer match, job order acceptance, housing assignment (if applicable) | Placement confirmed and all required placement fields populated | ### Scoring Model The scoring model is phase-aware — the required fields and weights adjust based on the applicant’s current lifecycle phase: > **Completeness Is Not Readiness** > > Profile completeness measures how much data has been provided. Readiness (used by phase gates and queues) measures whether the data passes validation. An applicant can have 100% completeness but still fail readiness if a compliance check is rejected. ## Travel Request Flow On-program participants who need to travel outside their placement area must submit a travel request through the Applicant Portal. This flow captures travel details and triggers compliance checks. ## Monthly Check-In Lifecycle Monthly check-ins are a Department of State requirement for all J-1 participants during the In Program phase. The lifecycle is managed by the system with configurable timing per season. | State | Description | Transition | | --- | --- | --- | | Scheduled | Check-in window created for the upcoming month | Window opens → Pending | | Pending | Window is open, participant has not submitted yet | Participant submits → Submitted | | Submitted | Participant completed the check-in form | If flags exist → Flagged; else → Reviewed | | Flagged | Check-in contains data requiring staff review (address change, well-being concern) | Staff reviews → Reviewed | | Reviewed | Check-in acknowledged by staff (or auto-acknowledged if no flags) | Terminal state for this month | | Missed | Window closed without submission (after grace period) | Triggers compliance escalation chain | > **Check-In Help** > > For a participant-facing guide to the monthly check-in form and process, see the Monthly Check-In help page . ## On-Program Dashboard The On-Program Dashboard is the staff-facing view for managing participants in the active program phase. It provides at-a-glance status across all on-program participants. ## Early Termination Detail Early termination occurs when a participant exits the program before the scheduled end date. The process involves multiple systems and has compliance implications. | Termination Reason | Initiated By | SEVIS Action | Grace Period | | --- | --- | --- | --- | | Voluntary Withdrawal | Participant | End program status, update reason code | 30 days (standard) | | Employer Termination | Host employer / Staff | End program, shortfall reason code | 30 days to find new placement | | Compliance Violation | Compliance officer | Terminate with violation reason | None — immediate termination | | No-Show | System (automated) | No-show termination code | 14-day confirmation window | | Medical / Emergency | Staff | Suspend status, medical reason | Varies (case-by-case) | Termination record created with reason, effective date, and supporting documentation / SEVIS amendment batch queued with appropriate termination reason code / All active workflow steps for the participant are cancelled / J-2 dependents (if any) are auto-terminated with the same effective date / Partner and host employer notified via the notification system / Financial reconciliation triggered (pro-rated charges if applicable) / Grace period timer starts for departure tracking ## No-Show Detection No-show detection identifies participants who were expected to arrive at their placement but failed to report. The system uses automated signals and configurable thresholds. > **No-Show Confirmation Window** > > A no-show flag does not immediately terminate the participant. Staff have a 14-day confirmation window to investigate (contact the participant, check with the employer, verify travel delays). If not resolved within 14 days, the system auto-transitions to early termination with the no-show reason code. ## Program Completion Flow Program completion is the standard exit path when a participant finishes their program on the scheduled end date. The flow ensures all compliance requirements are met before closing. ## Check-In Compliance Reporting Check-in compliance reporting provides aggregate analytics across all participants for audit and operational oversight: ## Applicant Detail Page The Applicant Detail Page is the staff-facing view for a single applicant. It consolidates all information about the applicant into a single screen with tabbed sections. ### Workflow Step Progress View The Workflow tab on the Applicant Detail Page shows a visual representation of the applicant’s progress through their assigned workflow: Phase headers show completion percentage and step count (e.g., "Intake \u2014 4/5 steps complete") / Each step shows its status, assigned role, completion date (if done), and SLA indicator / Conditional steps show the rule that controls their visibility and whether the condition is met / Hub nodes expand to show their child subtasks with individual completion states / Clicking a step opens its detail panel: submission data, attachments, review history, and staff notes ### Profile Completeness UI The profile completeness ring is a circular progress indicator on the Applicant Detail Page overview tab. It provides instant visual feedback on the applicant’s data status. Completeness Ring Segments Overall percentage displayed in the center of the ring / Each segment is color-coded and shows its individual completion percentage on hover / Incomplete segments list the specific missing items (e.g., \u201cMissing: Background Check, Insurance Proof\u201d) / The ring updates in real-time as documents are verified or steps are completed / Staff can click any segment to jump to the relevant tab with the incomplete items highlighted ### Lifecycle Phase Filter The Lifecycle Phase Filter is a dropdown on applicant list views that lets staff filter by Lifecycle Phase rather than raw status codes. It provides a human-readable way to find applicants based on where they are in their lifecycle. *Status: the curated option set below is a design spec, not built. The built applicants list filter (`journeyFilter` in `applicants.tsx`) is a multi-select over journey positions (`useJourneyPositions`).* | Filter Option | Matches | Use Case | | --- | --- | --- | | Just Started | Application phase, < 25% complete | Find new applicants who may need onboarding support | | In Review | Validation or Interview phase | Track applicants waiting for staff decisions | | Awaiting Placement | Placement phase, no confirmed placement | Identify applicants who need employer matching | | Visa Processing | Visa & SEVIS phase | Monitor SEVIS and DS-2019 progress | | In Program | Active in-program participants | View currently placed and active participants | | Completing Soon | Program end date within 30 days | Manage upcoming program completions | | Needs Attention | Any active compliance hold, missed check-in, or SLA breach | Priority triage for operational staff | | All Active | All non-completed, non-withdrawn applications | Full pipeline view | ### Nationality Filter The Nationality Filter sits alongside the Lifecycle Phase Filter on applicant list views and lets staff narrow the list to applicants of one or more nationalities. It is multi-select: an applicant matches if their nationality is any of the selected values (logical OR within the facet). The filter is available for all program types — the need surfaced from Teach staff, but it is not gated to Teach. | Property | Behavior | | --- | --- | | Selection model | Multi-select. An applicant matches when their nationality equals any one of the selected values (logical OR within the facet). | | Program-type scope | Available on every program type (Work & Travel, Teach, Intern/Trainee, etc.). Not gated to Teach — Teach was only the trigger for adding it. | | Default state | Empty by default. With no nationality selected the list applies no nationality narrowing — every applicant is eligible on this facet. | | Composition | Combines with the Lifecycle Phase Filter and any other active facets via AND. Selecting "Awaiting Placement" plus nationalities BR + AR returns applicants in the placement phase who are Brazilian or Argentine. | | Option source | The selectable values are the distinct nationality codes present on the applicant rows in scope, so staff only ever see nationalities that can actually return results. | > **Data source: nationality lives on the applicant row** > > Nationality is an applicant-row field (part of the person’s identity, reused across every application) — not an application-row field. See Applicant Data vs Application Data . This is the same nationality value the matcher reads and the host-facing Talent Pool Nationality Filter filters on (exposed there as citizenship), so the applicant-list filter must reuse it rather than introduce a parallel field — one source of truth keeps the terminology consistent across surfaces. Field caveat for the runtime team: the request describes this as “country of birth,” but the existing applicant-row nationality field models nationality / citizenship, which is not always the same as country of birth. Confirm which field the Teach staff actually need before wiring the P16 queue filter, and bind the filter to that field explicitly rather than assuming the two are interchangeable. --- --- title: "Direct vs Partner Entry" slug: entry-flows section: Applicant Journey status: stable description: "How applicants enter the ecosystem through the as-built Globus-first intake: n8n calls the Hub's provision endpoint, Globus creates the applicant, application and invite token in a single call, and the Applicant Portal onboards the appli…" last-updated: 2026-10-07 --- # Direct vs Partner Entry How applicants enter the ecosystem through the as-built Globus-first intake: n8n calls the Hub's provision endpoint, Globus creates the applicant, application and invite token in a single call, and the Applicant Portal onboards the applicant against that existing record. ## Core Principle > **One Applicant, One Record, One Path** > > Regardless of how an applicant discovers the program — through marketing, a recruitment partner, or a direct referral — they converge into the same onboarding journey. Globus, the sponsor back office, owns the applicant record. Keycloak owns credentials. The Applicant Portal owns nothing persistent — it is a stateless BFF over Globus and Keycloak. The entry channel is recorded as attribution, not as a structural fork in the architecture. Every applicant enters the ecosystem through one of two paths: Direct (self-initiated via marketing/HubSpot) or Partner-initiated (recruited by an international partner organization). Both paths produce the same result: an operational record in Globus — applicant, application and portal invite token — with a traceable origin, and, once the applicant activates, a portal login and application journey in the Applicant Portal. For the full authority separation between portals and Globus, see [Portal Functional Boundaries](/docs/portal-boundaries). ## Architecture Decision — Globus-First Intake > **Globus-First: Globus Creates First, the Portal Onboards Against It** > > Globus is the first and only system to create the applicant record. A single provision call creates the applicant, the application, and the portal invite token. The Applicant Portal never creates an applicant. This is a deliberate architectural decision. When a new applicant enters the ecosystem, n8n calls the Hub's provision endpoint (`POST /api/integrations/onboarding/provision`) and Globus mints the applicant, application and invite token in one transaction. The portal's onboarding flow then activates a login against that pre-existing record. This applies to both direct and partner entry. Why Globus-First? - **Single system of record.** There is exactly one applicant record, created once, in the system that staff already operate. No cross-system sync, no reconciliation, no split-brain between a portal copy and a back-office copy. - **Deduplication at the door.** Per the [Globus Portal API reference](/docs/globus-portal-api), the provision call is idempotent on `hubspot_contact_id` + `program` — repeated calls for the same lead return the same applicant instead of creating duplicates. - **The applicant is reachable before any portal account exists.** Staff can see, email and work the applicant in Globus from the moment of provisioning — portal activation is a later, optional-until-it-happens step, not a precondition for operations. ## Direct Intake In the direct flow, the applicant discovers the program through marketing channels, expresses interest, and is pre-qualified through HubSpot. Once qualified, HubSpot triggers an n8n workflow that provisions the applicant in Globus. The resulting invite email brings the applicant to the Applicant Portal to activate their login and begin their application journey. > **Direct Does Not Mean Unassigned** > > Direct does not mean "unassigned." Direct applicants can still be associated with an internal regional office for operational ownership. "Direct" only means "not initiated by an external recruiting partner." ### Technical Flow (Step-by-Step) The following is the as-built system interaction sequence for direct intake. 1. **HubSpot** — a contact reaching the qualifying lifecycle stage fires a HubSpot workflow that calls an n8n webhook with the lead data. 2. **n8n -> Hub** — n8n calls `POST /api/integrations/onboarding/provision` on the Hub, authenticated with `x-api-key` (scope `integrations.onboarding`; auth and idempotency contract are Hub-side, per the [Globus Portal API reference](/docs/globus-portal-api)). The call is idempotent on `hubspot_contact_id` + `program` and returns an absolute `invite_url` (e.g. `https://development.applicants.allianceabroad.com/onboarding?token=...`), which n8n forwards verbatim into the invite email. 3. **Globus -> n8n fan-out** — Globus emits an `applicant.created` event to the `callback_webhook_url` supplied on the provision call. The n8n dispatcher consumes it and fans out: it writes the `portal_invite_url` back to the HubSpot contact and sends the invite email via SendGrid. n8n typically also pre-creates the Keycloak account at this point (random Keycloak user id, no password, with the `globusApplicantId` attribute set). 4. **Applicant** — the applicant clicks the invite link and lands on `/onboarding?token=...` in the Applicant Portal. 5. **Token preview** — the portal calls `POST /api/v1/onboarding/validate-token`, a non-burning preview: the BFF opens a Globus portal session (`POST /api/portal/session`), fetches the applicant, and returns the applicant's details plus an `alreadyActivated` flag. 6. **Activation state** — whether an applicant is "activated" is determined by Keycloak password existence, not by token burn. The Globus invite token never expires, so the invite link keeps working. If `alreadyActivated` is true, the portal redirects to `/sign-in` instead of re-onboarding. 7. **Completion** — `POST /api/v1/onboarding/complete` (password of at least 10 characters) performs a create-or-claim on the Keycloak user. In the typical flow the account already exists (pre-created by n8n with a random Keycloak id) and is claimed via its `globusApplicantId` attribute; only when no account exists does the portal create one, setting the Keycloak user id to the Globus `portal_user_id`. A mismatched `globusApplicantId` on an existing account returns `410 Gone`; an already-activated account returns `409`. 8. **First login** — the portal immediately calls `POST /api/v1/auth/login`, stores the Bearer token in sessionStorage, and routes the applicant to `/dashboard`. 9. **Agreement gate** — on first login the TermsGateModal calls `GET /api/v1/agreements/required` and blocks the portal until the applicant accepts (checkbox or typed-name signature, depending on the agreement). The modal is non-dismissable; a Decline button is shown only when the agreement permits declining — mandatory agreements are accept-or-abandon — and declining signs the applicant out. 10. **Throttling** — the entire onboarding controller is auth-throttled to limit token-guessing and credential-stuffing attempts. **Invite-link base URL.** The origin the Hub uses when it builds portal invite links resolves in this order, first non-empty value wins: the admin-editable `integration_configs` entry `applicant_portal` / `base_url`; the `APPLICANT_PORTAL_BASE_URL` environment variable; the `APPLICANT_PORTAL_URL` environment variable; a default chosen by `CONFIG_ENV` (`development` gives `https://development.applicants.allianceabroad.com`, `qa` gives `https://qa.apply.allianceabroad.com`); otherwise the legacy pre-cutover default, which is what production still resolves to because production is not yet mapped in code. The result is a bare origin with no trailing slash, cached in-process for 30 seconds. ### Globus-Side Activation Stamp Separately from the portal's Keycloak-based activation check above, Globus records its own activation fact on the applicant: `applicants.portal_activated_at`. It is written first-write-wins (the timestamp never moves once set) and audited once as `portal_activated`, with the evidence source recorded on the audit entry. Globus derives it from portal-origin traffic it already authenticates rather than waiting only on the portal's explicit `mark-activated` callback (which remains authoritative). The evidence sources are: - the portal's `mark-activated` callback; - an `applicant.registered` portal event (self-registration); - any accepted portal event carrying the portal's signed-in user identifier (`X-Applicant-User-Id`); and - an accepted, ownership-checked write the applicant makes through the portal — a step submission or a document upload. Redeeming an access token does not count as activation, because nurture mail and portal invites carry the same magic-link token. The stamp is a best-effort side effect of these writes: a failure to stamp is logged and never fails the portal's own request. Applicants whose activity predated the change were backfilled by migration from the earliest portal-origin evidence in the audit log. Once stamped, the incomplete-profile nurture cadence stops for that applicant: the campaign predicate `incomplete_portal_profile` v2 returns ineligible with stop reason `portal_activated` ("Activated their portal account"), checked before profile completeness, both when enrolling and again when each step comes due. The seeded campaign (Camp Wild Packs, `CAMP_WP`) now declares predicate v2, and a data migration moved existing `incomplete_portal_profile` campaign definitions from v1 to v2. ### HubSpot Webhook Configuration The HubSpot team configures a workflow that fires when a contact reaches the qualifying lifecycle stage. The workflow does not call the portal or Globus directly — it calls an n8n webhook, and n8n owns the provision call, the callback handling, and the HubSpot write-back. ### Provision Request Payload The n8n workflow sends the following fields to the Hub's `POST /api/integrations/onboarding/provision` endpoint. The payload schema is owned Hub-side — see the [Globus Portal API reference](/docs/globus-portal-api) for the authoritative contract. These are mapped from the HubSpot contact record. | Field | Type | Notes | | --- | --- | --- | | hubspot_contact_id | string | HubSpot Contact Record ID. Part of the idempotency key (with program) and stored for attribution. | | email | string | Primary contact email for the applicant record and the invite email. | | firstname | string | HubSpot property: firstname. | | lastname | string | HubSpot property: lastname. | | program | string | Program name — the Hub matches by program code or name (e.g., "TEACH" or "Teach USA"), which must uniquely resolve to a program with an open season; ambiguous or closed-season names are rejected with a 422. Part of the idempotency key and determines the application created. | | role | string | Optional. Set by the n8n workflow from the HubSpot property which_role_are_you_applying_for_. | | callback_webhook_url | string | Where Globus sends the `applicant.created` event — the n8n dispatcher URL. | | correlation_id | string | Caller-supplied ID for tracing the provision call through logs and the callback. | ### applicant.created Event Globus emits an `applicant.created` event to the `callback_webhook_url` provided on the provision call. The n8n dispatcher consumes the event and fans out: it writes the `portal_invite_url` back onto the HubSpot contact and triggers the SendGrid invite email. See the [Webhook Event Registry](/docs/webhook-event-registry) for the event catalogue and delivery details. ### Pre-Populated Fields When the applicant first logs into the portal, their profile and application forms already contain the data captured during pre-qualification. These values are sourced from the Globus applicant record — populated at provision time and by staff — and served to the portal through the BFF. This reduces friction and avoids asking the applicant to re-enter information they already provided. | Field | Portal Form / Section | Notes | | --- | --- | --- | | first_name | About Me / Personal Info | Applicant can correct if needed. | | last_name | About Me / Personal Info | Applicant can correct if needed. | | email | Account / Login | Email is the identity key. Cannot be changed by the applicant (contact support to change). | | phone | About Me / Contact Info | Applicant may update with a different number. | | date_of_birth | About Me / Personal Info | Required for eligibility checks. If not captured at intake, left blank for applicant to complete. | | nationality | About Me / Personal Info | Used in eligibility rules. | | country_of_residence | About Me / Personal Info | May differ from nationality. Used for visa determination. | | gender | About Me / Personal Info | Optional field. If not captured at intake, left blank. | | brand | Application Context | Locked to the brand the applicant was provisioned under. Cannot be changed by the applicant. | | program | Application Context | Locked. Determines which workflow and forms the applicant sees. | | season | Application Context | Locked. Determines deadlines, eligibility rules, and compliance dates. | | hubspot_contact_id | Not displayed | Stored on the Globus record for back-reference to HubSpot and attribution tracking. | | notes | Staff-only | Visible to staff in Globus, not to the applicant. | > **Locked Fields Cannot Be Changed by the Applicant** > > Brand, program, and season are locked because they determine the entire application journey — which workflow the applicant follows, which eligibility rules apply, and which compliance deadlines govern their timeline. If an applicant needs to switch programs, a new application must be created through the proper channel. ## Partner-Initiated Intake Partners manage their recruited applicants through the Partner Portal, which integrates directly with Globus. Partner-recruited applicants enter through the Applicant Portal to complete their own application journey — provisioned in Globus with partner attribution instead of HubSpot attribution. For details on how partner governance and lifecycle phases work, see [Partner Portal Governance](/docs/partner-governance). ### Partner Invite Trigger A partner submits applicants for invitation through the Partner Portal (typically via CSV/Excel upload or one-by-one form entry). Each submitted applicant is created in Globus with partner attribution by the partner invite service (`POST /api/admin/partners/organizations/:orgId/invite`, or bulk-invite), not through the HubSpot provision call: the partner organisation comes from the URL rather than a `hubspot_contact_id` provision payload, and the service publishes `partner.invite.created`. From the moment the applicant clicks their invite link, their journey is identical to direct intake — the same token preview, activation, login and agreement gate described above. > **Important Boundary** > > Partners do not view or manage applicants inside the Applicant Portal. Partners use the Partner Portal for applicant management. The Partner Portal reads milestone data from Globus but cannot modify the applicant's application journey. ### Recruitment Attribution Attribution is captured at the moment the applicant is provisioned in Globus. For partner-invited applicants, the invite input (`inviteApplicantSchema`) must include: - Program and Season (cohort): `programId` and `seasonId` are required, `brandId` is optional - The Partner Organization ID is taken from the invite URL (`:orgId`), not from the payload For direct applicants, attribution includes the HubSpot Contact ID and marketing context instead of a partner ID. > **Attribution Lives in Globus** > > Attribution is stored on the Globus operational record at provision time. The back office always knows where the applicant came from — whether marketing or a partner — without any cross-system sync. ### What Happens If Attribution Is Missing? Attribution fields are required, not optional. The provision endpoint validates every request before processing. If any required field is missing or invalid, the request is rejected. > **No Silent Defaults** > > The system never silently fills in missing attribution. Every rejected request returns a specific error message identifying the missing or invalid field. For bulk CSV uploads, the Partner Portal returns a row-level error report so the partner can correct and re-submit. ### White-Label Application Links > **Target State — Not Yet Implemented** > > White-label links are a planned mechanism. No `/apply` route exists in PPPToo today; this section describes the intended design, not shipped behaviour. In addition to bulk CSV upload, partners will be able to generate unique tracking links that direct individual candidates to the Applicant Portal with pre-tagged attribution. This eliminates manual data entry for one-off referrals and enables partner-specific landing pages. `/apply?partnerId=xyz&program=camp&brand=wildpacks&season=2026-summer` When an applicant clicks a white-label link: - Partner attribution is auto-applied from the URL parameters at provision time and locked (not editable by the applicant) - The applicant lands on a program-specific registration page with partner branding context - The partner receives a notification confirming the applicant has registered > **Attribution Is Immutable** > > Once an applicant registers through a white-label link, the partner attribution cannot be changed by the applicant, the partner, or any non-sponsor user. Only sponsor staff with the appropriate RBAC role can reassign attribution — and that action is audit-logged with a required reason. ## Portal Architecture All three external portals — the Applicant Portal, the Host Portal, and the Partner Portal — integrate directly with Globus. There is no legacy intermediary. Each portal serves a specific audience with a purpose-built experience layer, while Globus remains the single system of record and authority. ### Readiness Criteria Each program is onboarded to the portal ecosystem on its own schedule rather than in a single cutover. Camp programs may go live before Teach programs, which additionally require the Partner Portal and partner governance to be in place — the dependency logic is described in [Partner Portal Governance](/docs/partner-governance). For detailed milestones and timelines, see [Delivery Overview](/docs/delivery-overview). ## Governance Distinction The entry channel determines attribution, not authority. Both direct and partner-initiated applicants are subject to the same governance rules once they enter the system. For full RBAC enforcement details, see [RBAC & Governance](/docs/rbac). | Aspect | Direct Applicant | Partner Applicant | | --- | --- | --- | | Entry Source | HubSpot lifecycle stage -> n8n -> Hub provision call | Partner Portal submission -> Globus provision | | Application | Self-initiated in Applicant Portal | Self-completed after invite | | Attribution | hubspot_contact_id + marketing context | partner_org_id | | Pre-Populated Data | From HubSpot payload | From partner invite payload | | Compliance Rules | Same program rules apply | Same program rules apply | | RBAC Governance | Sponsor-controlled via Globus | Sponsor-controlled via Globus | | Workflow Engine | Same portal application journey | Same portal application journey | | Globus Sync | Not applicable — Globus creates the record at provision time (attribution: hubspot) | Not applicable — Globus creates the record at provision time (attribution: partner) | | Partner Visibility | N/A | Read-only milestone tracking via Partner Portal | ### Data Ownership Principle Globus (Back Office) owns: - The applicant record, the application, and the portal invite token - Recruitment attribution and agreements - Operational processing, workflow state, placement decisions, and compliance status - Partner agreements, quotas, and governance - Reporting, analytics, and audit trail Keycloak owns: - Applicant identity and credentials (never stored in Globus) - The identity link to Globus is dual: the `globusApplicantId` attribute (surfaced as a token claim) is the authoritative binding to the applicant record; the token `sub` is the fallback. Only portal-created accounts have their Keycloak user id set to the Globus `portal_user_id` — n8n-provisioned accounts carry a random Keycloak id and are linked solely by the claim. This is the BFF's resolution order when it derives the applicant from a token: `globusApplicantId` claim first, then `sub`. The Applicant Portal owns nothing persistent. It is a stateless BFF: every read and write passes through to Globus (via portal-scoped APIs) or Keycloak, and the portal holds no applicant database of its own. > **Architectural Separation Guarantee** > > This separation ensures that Globus can evolve its back-office logic without rebuilding applicant identity or portal credentials, and the portal can evolve its UX without touching operational data. Credentials never live in Globus; identity links back to Globus through the `globusApplicantId` claim, falling back to the token `sub` (which equals `portal_user_id` only for portal-created accounts). > **Governance is Uniform** > > Once an applicant record exists in the system, the entry channel is irrelevant to compliance, workflow, and RBAC enforcement. The same rules, the same application journey, and the same audit trail apply to every applicant regardless of how they entered. --- --- title: "Summer Work Travel Eligibility" slug: swt-eligibility section: Applicant Journey status: stable description: "The full picture of how the platform decides when a Summer Work Travel applicant is allowed to work in the US — from proving they are a full-time student, to reading and translating their documents, to overlapping the dates their governm…" last-updated: 2026-10-07 --- # Summer Work Travel Eligibility The full picture of how the platform decides when a Summer Work Travel applicant is allowed to work in the US — from proving they are a full-time student, to reading and translating their documents, to overlapping the dates their government and their school both permit. ## What This Is For Summer Work Travel (SWT) lets international students work in the US during their school holidays on a J-1 visa. The US government sets strict rules about who qualifies (you must be a current full-time student) and when each student is allowed to work. Our platform automatically checks every applicant against those rules so we don't accept a student — or a set of work dates — that would put the student, or the program's visa sponsorship, out of compliance. There are two halves to eligibility, and an applicant must clear both: - Are you a qualifying student? Proof of full-time enrollment, plus verified vacation dates. This is the Proof of Student Status step. - Are these dates allowed? The work dates must fall inside the window where the student is both permitted (by their government) and free (by their school) to work. This is the eligible window. ## The Full Flow, End to End Here is the whole pipeline, in order. Each step has a clear owner, and one principle holds throughout: Globus decides, the portal only shows the answer. The US State Department publishes, per country, the months that country's students may participate. Those windows live in Globus only, as date objects hung off the Program, and the Globus Rules Engine reads them as the single source. The windows don't change on a fixed schedule — some seasons nothing changes, sometimes only a country or two, sometimes nothing for years. So the owner reviews them against the latest State Department chart and edits only what moved (no bulk reload, no audit trail), then marks a non-blocking "reviewed / current" flag. The flag never blocks applications or anything downstream — it just shows when the dates were last confirmed current. On the Proof of Student Status step, they select the document type (transcript, enrollment letter, or other), upload the document (PDF, JPEG or PNG) and enter their university country and vacation dates. Two Globus AI capabilities run in sequence: one detects the language, translates, and extracts the enrollment facts; the other scores how confident it is that the document proves full-time enrollment. Neither makes the final decision — when confidence is low, it hands off to a human. The Rules Engine passes a confident proof, sends an uncertain one to a Globus reviewer (and asks for a clear letter), and routes a missing one to a follow-up queue. Nothing is ever auto-rejected. A verifier — the recruiting partner, or Globus for direct sales — confirms or amends the dates (that becomes the authoritative record), or asks for proof: a school calendar or a professor/university verification, both accepted equally. It overlaps the country's official window with the verified vacation dates to produce the eligible window — the only stretch the student may actually work. A soft, non-blocking warning early on (at Proof of Student Status, if the dates don't overlap), then a hard gate later (at the Camp Contract step: the dates must fit the eligible window and the work period must be four months or less). ## The Two Windows That Must Overlap The when can they work half of eligibility comes down to fitting inside two windows at once: ### 1. The country's official window The US Department of State publishes, for each country, the months when that country's students may participate (it tracks their school calendar) on its official Summer Work Travel program page . A Brazilian student's window is in our winter; a German student's is in our summer. ### 2. The student's own university holiday Their actual vacation dates from their school — once verified (see below). ### 3. The overlap of the two A student can only work during the time that falls inside both. We call this their eligible window, and it's automatically calculated and shown to them on the step in the applicant portal. | Student | Their country's official window | | --- | --- | | Brazil | Falls in the US winter (their school summer holiday) | | Germany | Falls in the US summer (their school summer holiday) | ## Proof of Student Status Before any dates matter, the applicant has to prove they are a current full-time student. This all happens on the Proof of Student Status step — the same screen in both the applicant portal and the partner portal, with nothing built partner-specific. The step only collects information and shows back the platform's response; it never decides anything itself. The step is shown for every answer on the About Me Education Background question, including "No, I am not currently a student". An applicant who says they are not a student still sees the step, but is blocked as ineligible by the declared-current-student rule; only the step's visibility is unconditional. ### Proving Full-Time Enrollment The applicant selects the document type and uploads the matching document. Two types are accepted equally: - a university transcript, or - an enrollment letter from their university. The selector also offers Other proof of student status, but an Other (or unconfirmed) type can never auto-pass — it always goes to a human reviewer, however confident the AI is. The upload accepts PDF, JPEG and PNG only. The bar is the same whichever of the two they choose: the document has to establish full-time enrollment for the relevant term. Importantly, the platform does not try to count credit hours to decide this — "full-time" is defined differently in every country and institution, so a clear written enrollment statement is more reliable than a credit tally. > **No one is ever auto-rejected** > > If the evidence doesn't yet prove full-time enrollment, the applicant is routed to the Missing Student Evidence follow-up queue so it can be resolved — never silently declined. ### AI Translation & Check Those documents arrive from universities all over the world, in many languages and formats. Two separate Globus-controlled AI capabilities do the reading — one translates and reads, the other scores: - Translation & extraction — detects the language, translates the document, and extracts the enrollment facts (institution, student, term and year, and any explicit full-time enrollment statement). It returns the translation and facts only — no score, no decision. - Confidence scoring — takes that translated, extracted output and emits a calibrated confidence signal for how sure it is the document proves full-time enrollment, then flags the case for a reviewer when confidence falls below a configurable threshold. The AI assists but never decides. The Rules Engine takes the AI's facts and confidence and makes the actual call: | When the AI is… | What happens | | --- | --- | | Fully confident it proves full-time enrollment (selected type is transcript or enrollment letter) | The applicant passes and clears the Missing Student Evidence queue. | | Not fully confident | The case goes to a Globus reviewer, and the applicant is asked to supply a letter (or other proof) that states full-time enrollment plainly. | | Selected type is Other, or no type is confirmed | The case always goes to a Globus reviewer, regardless of AI confidence — never auto-passed. | | Given nothing that proves enrollment | The applicant is added to the Missing Student Evidence queue — never auto-rejected. | The confidence threshold that decides when the AI must hand off to a human is configurable, and both capabilities are owned and tuned by Globus in program configuration — the portal only uploads the file. Staff routes: `GET /api/programs/:id/enrollment-proof-settings` (permission `dashboard.view`, program scope enforced) returns `programId`, `confidenceEscalationThreshold` and `isOverridden`; the threshold is 0.7 when the program has no override. `PUT` on the same path (permission `programs.manage`) sets `confidenceEscalationThreshold`, a number from 0 to 1, and is audited as `enrollment_proof_confidence_threshold_updated`. Both return 404 for an unknown program. `GET /api/applications/:applicationId/academic-verification-requests` (`applications.view`) lists the professor or university verification requests, and `POST` on the same path (`applications.manage`; body `contactName`, `contactEmail`, `contactRole`, optional `vacationVerificationId`) creates one and returns 201. `GET /api/applications/:applicationId/enrollment-proof-assessment` (`applications.view`) returns the assessment, or 404 when none exists; `POST` on `.../enrollment-proof-assessment/process` (`applications.manage`) runs the AI pipeline for an uploaded document and returns 200, or 422 when processing does not succeed. ### Verifying the Vacation Dates Because vacation dates drive work-period eligibility, they can't simply be taken at face value — they have to be verified before the platform trusts them. The applicant enters their dates, and then: - A verifier — the partner who recruited them, or Globus for direct-sales applicants — accepts or amends the dates. That confirmation becomes the authoritative record, with a full audit trail of who verified and when. - If the verifier can't confirm them, they ask for proof. The applicant can supply a school calendar or a professor / university verification — both accepted equally. The academic-calendar verification is satisfied by the same approval that verifies current-student status: once staff have approved the exact application's Proof of Student Status, no duplicate approval is needed. A dedicated academic-calendar review, when it has an explicit approved or rejected decision, still takes precedence (a rejection produces a failure even if the proof was approved). With neither an approved proof nor a decided review, the fact stays unresolved and the applicant is held for review. The vacation dates are also checked against the programme window. The check passes when the official school vacation and the configured programme window overlap by at least one day; there is no minimum overlap length, and the vacation may start before or end after the programme window (only the shared period counts). Reversed dates, dates that do not overlap, or missing dates fail it (missing dates fail closed). > **Professor / university verification** > > This works like an applicant reference: the applicant names a professor or university contact, that person receives a request (with reminders) and submits their verification, and a Globus reviewer reviews it. It is a new, dedicated flow — modeled on the reference process but kept separate from Reference Management. Only verified dates are passed on to the eligible-window calculation. Two reason codes are seeded in the reason-code catalog for this flow. `SWT_NOT_CURRENTLY_A_STUDENT` (blocking) applies when the applicant declared on About Me that they are not currently studying (`education.currently_studying` is false: `no_completed`, `no_gap_year` or `no_no_plans`); it is a declaration gate that no document can satisfy, and the student status, academic calendar and vacation window evidence rules stand down so the applicant is not also asked for a Proof of Student Status. `ACADEMIC_VERIFICATION_OUTSTANDING` (review, never blocking) applies while a professor or university verification request is `PENDING` (verifier has not responded) or `SUBMITTED` (staff approve or reject still owed); it projects the applicant into the Academic Verification Required exception queue and clears when every request on the application is approved or rejected. ## The Eligible Window & the Two Checkpoints Once the country's window and the verified vacation dates are both known, the eligible window is simply the overlap of the two — the only stretch of time the student is both permitted and free to work. The platform calculates it and shows it to the applicant; they never have to work it out themselves. It is then enforced in two places: - A friendly preview, early on. On the Proof of Student Status step, the applicant sees their eligible window right away. If their dates don't overlap their country's window, they get a gentle warning — guidance, not a hard stop — so they can keep going and fix it. Two submit-time exceptions exist in the applicant portal: dates that run backwards are rejected (422), and so is a country with no State Department window on record for the season (422, "No Summer Work Travel window is on record for that country yet"), since no eligible window could ever be calculated for it. A failed window lookup (for example a temporary Globus error) does not block the applicant. The portal BFF backs this preview with three authenticated routes: `POST api/national-window/get` (one country window), `POST api/national-window/list` (every country that has a window for the applicant program and season, returned as `windows` keyed by country code with `startFormatted`, `endFormatted`, `countryName` and `display`, to populate the country dropdown) and `POST api/eligibility/calculate`. The calculation returns a `threeLineDisplay` of the country window, the declared vacation and an eligibility line whose wording depends on which side the State Department window clips: starts before the window, ends after it, both, or neither (a plain "You may travel between" range). Dates with no overlap return `NO_OVERLAP` with a warning message instead. - A firm check, later. When the actual work dates are set at the Camp Contract step, the platform enforces the rule for real: the dates must sit inside the eligible window, and the work period must be four months or less. Anything outside that is blocked and routed for review. For Wild Packs SWT Camp, the Ready for SEVIS gate requires six SWT prerequisites: current student status, academic calendar, vacation/programme overlap, both Camp Contract eligible-window boundaries, and the four-calendar-month contract maximum. The four-month check is the rule `j1_summer_work_travel.work_period_within_four_months` (22 CFR 62.32, SWT Camp only; other resolved SWT applicants pass): a missing contract period, or one longer than four months, hard-blocks with reason code `WORK_PERIOD_EXCEEDS_FOUR_MONTHS`, routed to the `missing-compliance-documents` queue, and overriding it requires compliance dual approval. `POST /api/integrations/applicant-portal/swt/eligibility/evaluate` (`integrations.portal` API key) previews the same decision from candidate dates without persisting anything; its `reasons` array carries the code. > **One simple rule, two inputs** > > Eligible window = the country's official DOS window intersected with the student's verified vacation dates. If those two never overlap, there is no eligible window — and the applicant is flagged so it can be resolved before any commitment is made. Eligible-window reason codes in the catalog: `ELIGIBLE_WINDOW_OVERLAP_WARNING` (warning, non-blocking, never queued) is the early notice at eligibility-cleared when declared or verified dates do not overlap the country's window. `CONTRACT_BEFORE_ELIGIBLE_START` and `CONTRACT_AFTER_ELIGIBLE_END` (both blocking) are the directional hard stops at the post-contract document batch when the camp contract start is before the eligible window opens or its end is after it closes, or when no eligible window exists. They replace the deprecated combined `PROGRAM_OUTSIDE_ELIGIBLE_WINDOW`, which stays in the catalog so historical rule versions and audit records resolve. `WORK_PERIOD_EXCEEDS_FOUR_MONTHS` (blocking) covers the four-month maximum, and `PROGRAM_OUTSIDE_VACATION_WINDOW` (blocking) is the vacation-window verification failure on the eligibility-cleared phase gate. ## Behind the Scenes A few design choices keep this trustworthy and consistent across the whole ecosystem: - Globus does the deciding; the portal just shows the answer. Every decision — full-time enrollment, date verification, the eligible window — is made by the Globus Rules Engine , not by the applicant portal. The portal collects and reflects; it never invents its own version of the rules. - AI assists, humans decide. One AI capability translates and extracts, another scores the confidence, but a person (via the Rules Engine's review routing) makes the final call whenever there's any doubt. - The country windows come from one official source. The per-country months are loaded from the US Department of State's published SWT dates ( j1visa.state.gov/programs/summer-work-travel ) and kept in Globus only — held as date objects hung off the Program that the Rules Engine reads as the single source, while HubSpot just stops banned countries upstream. The owner edits the existing dates in place rather than reloading a fresh set, and because the windows change irregularly — sometimes only a country or two, sometimes nothing for years — currency is tracked with a non-blocking "reviewed / current" flag the owner marks off, not a hard deadline, so nothing downstream is ever blocked. - Staff maintain the windows on the date windows admin page (`/swt/date-windows`, `programs.manage`). Each window is a `program_date_windows` row keyed by program, country (ISO2) and season label, with an open month and day, a close month and day, a year-boundary flag and an active flag. The page uses `GET` and `POST /api/swt/programs/:programId/date-windows` (list and upsert), `PATCH /api/swt/date-windows/:windowId/reviewed` (marking a window reviewed is one-way; no route clears it) and `DELETE /api/swt/date-windows/:windowId`, each audited. The same data is also served under program-scoped paths: `GET /api/programs/:programId/date-windows` (optional `seasonLabel` and `activeOnly=true`) and `GET /api/programs/:programId/date-windows/:countryIso2/:seasonLabel` need `programs.view` (404 when that window does not exist), while `PUT /api/programs/:programId/date-windows` upserts a window and `POST /api/programs/:programId/date-windows/:windowId/mark-reviewed` marks it reviewed by the caller; both need `programs.manage` and are audited as `program_date_window.upserted` and `program_date_window.reviewed`. - The applicant portal reaches the same data through two integration routes on the Hub, authenticated with the shared integration key (`x-api-key` or Bearer), an applicant portal session Bearer, or an API key with the `integrations.portal` scope. `GET /api/integrations/applicant-portal/swt/programs/:programId/seasons/:seasonId/date-window/:countryIso2` returns `found: false` when the season has no season key or no active window exists for that country, otherwise `found: true` and the window (open and close month and day, year-boundary flag, `seasonYear`). `POST /api/integrations/applicant-portal/swt/eligibility/evaluate` (body `programId`, `seasonId`, `countryIso2` of the university, `vacationStartDate`, `vacationEndDate`, `contractStartDate`, `contractEndDate` as YYYY-MM-DD) is a non-persisted preview: it derives the eligible window (DOS window intersected with the vacation dates) and runs the published contract-start, contract-end and four-month gates over a synthetic facts snapshot. It returns `decision` (`allow` or `block`), `eligibleWindow`, `eligibleWindowStatus` (`derived`, `empty_intersection` or `unavailable`), `seasonYear`, every failing `reasons` entry (code, applicant message, queue, rule key, severity) and the per-rule `rules` outcomes. Impossible dates, a start after its end, an unknown program or an unknown season for the program are 400 validation errors. - One step, both portals. The Proof of Student Status step and its rules run identically in the applicant portal and the partner portal — nothing partner-specific is built. - The DOS country windows are scoped to SWT_WP (Wild Packs Summer Work & Travel - Camp) only. A migration backfilled the full DOS chart for every open or active SWT_WP season, preserving any operator-edited row already present (`ON CONFLICT DO NOTHING`), and deactivated the provisional windows for seven other program codes (CAC, CAMP_WP, SWT, SWT1, SWT2, SWT_GEO, SWT_HOSP). Those rows are kept inactive as staff-visible history, not deleted. - Verification records live in three tables. `vacation_date_verifications` holds the submitted and verified vacation dates per application with `verification_state` (`UNVERIFIED`, `VERIFIED` or `PROOF_REQUESTED`), verifier and time, `proof_type` (`SCHOOL_CALENDAR` or `PROFESSOR_VERIFICATION`), `school_calendar_file_id` and `notes`. `academic_verification_requests` holds each professor or university request: contact name, email and role, `status` (`PENDING`, `SUBMITTED`, `APPROVED` or `REJECTED`), a unique `submission_token` with expiry, the confirmed enrollment term and vacation dates, the free-form response and its translation, reviewer, time and notes, and `reminder_count` with `last_reminder_sent_at`. `enrollment_proof_assessments` holds the AI output per application and document: `document_type` (`TRANSCRIPT`, `ENROLLMENT_LETTER` or `OTHER`), detected language, translated and OCR text, extracted institution, student name, term and enrollment statement, `confidence_score` (0 to 1), `escalate_flag`, `processing_status` (`PENDING`, `PROCESSING`, `COMPLETED` or `FAILED`), `rule_decision` (`PASS`, `REVIEW_REQUIRED` or `MISSING`), `rule_reason_codes`, and the staff decision (`APPROVED` or `REJECTED`) with notes. ## How This Is Being Built The runtime for all of this lives in the Globus core and applicant-portal repos, tracked by the following Asana tickets. (This documentation site only describes the architecture.) | Area | Ticket | What it owns | Board | Link | | --- | --- | --- | --- | --- | | Engine rule | SWT eligibility rule — national-window ∩ vacation eligible-window derivation + four-month work-period gate | The eligible-window predicate, decision classes, and the hard Camp Contract gate | P3: Rules Engine & Eligibility | [Open](https://app.asana.com/1/817430812898727/project/1213692100361485/task/1216024273302734) | | Engine rule | Full-time enrollment proof rule — transcript-or-letter equivalence with AI-confidence escalation and Missing Student Evidence routing | The pass / review / follow-up decision on enrollment proof | P3: Rules Engine & Eligibility | [Open](https://app.asana.com/1/817430812898727/project/1213692100361485/task/1216033663942819) | | Engine rule | Vacation-date verification rule — verifier attestation as the authoritative record with ask-for-proof escalation | Date verification as the authoritative record, shared across both portals | P3: Rules Engine & Eligibility | [Open](https://app.asana.com/1/817430812898727/project/1213692100361485/task/1216033683983994) | | Form fields | SWT Proof of Student Status fields — university country, vacation dates, enrollment evidence (collection only) | Collecting the three inputs; never computes eligibility | AP-6: Application Forms & Field Library | [Open](https://app.asana.com/1/817430812898727/project/1216785079269821/task/1217186757080252) | | Portal step | Proof of Student Status step — eligibility inputs, enrollment-proof upload, and vacation-date verification states | The collect-and-reflect step UI, identical in both portals | AP-6: Application Forms & Field Library | [Open](https://app.asana.com/1/817430812898727/project/1212278731177206/task/1216024188440694) | | Reference data | SWT country date-windows as Globus-only date objects hung off Program (single source, edited in place, non-blocking reviewed flag) | The DOS per-country windows the engine reads | P11.4: Program Configuration (Camp) | [Open](https://app.asana.com/1/817430812898727/project/1215728878667241/task/1216024273729588) | | AI capability | Globus-controlled AI translation and extraction for enrollment-proof documents | Language detection, translation, structured fact extraction (no score, no decision) | P11.4: Program Configuration (Camp) | [Open](https://app.asana.com/1/817430812898727/project/1215728878667241/task/1216033663967458) | | AI capability | Globus-controlled AI confidence scoring for enrollment-proof documents | Calibrated full-time-enrollment confidence with configurable reviewer-escalation threshold | P11.4: Program Configuration (Camp) | [Open](https://app.asana.com/1/817430812898727/project/1215728878667241/task/1216057552293296) | | Verification flow | Professor and university verification mechanism — referee-style request, reminders, submission, and review | The new dedicated flow modeled on (but separate from) Reference Management | P11.4: Program Configuration (Camp) | [Open](https://app.asana.com/1/817430812898727/project/1215728878667241/task/1216033770026123) | | Matching | Eligible-Window Overlap soft-scoring dimension (SWT-Camp) | Using the eligible window as a soft-scoring signal in matching | P7: Matching Engine & Skill Intelligence | [Open](https://app.asana.com/1/817430812898727/project/1213692100437234/task/1217036700195562) | > **Retired ticket** > > The original "annual refresh of the DOS SWT country date-window dataset" ticket on P11.4 was retired (Won't Do) when the model moved to irregular edit-in-place maintenance with a non-blocking reviewed flag — its scope was folded into the date-windows ticket above. > **Related pages** > > See the Applicant Portal — Eligibility Surface for how the portal renders these decisions, the Rules Engine for the decision pipeline and reason codes, and Pre-Qualification Eligibility for the earlier screening step. --- --- title: "Applicant vs Application Data" slug: applicant-vs-application section: Applicant Journey status: stable description: "The first-class boundary between who the person is and a single attempt at a program — and what each partner type sees through it." last-updated: 2026-10-07 --- # Applicant vs Application Data The first-class boundary between who the person is and a single attempt at a program — and what each partner type sees through it. > **Field-level enforcement** > > This page documents the boundary as it stands today. The mechanism that keeps it true tomorrow — required field-level metadata (scope, core) on every Workflow Studio field, the step-level data scope dropdown that replaces per-field redaction, plus a baseline audit of biographicalSync — lives on the Field Classification Contract page. > **One Person, Many Attempts** > > Applicant data describes the person and follows them across every program attempt. Application data describes a single attempt at one program in one season. The two live in separate tables (applicants and applications) and are exposed to partners through scoped, role-gated endpoints. This page is the canonical reference for the boundary. ## Two Concepts, One Person Applicant Data The identity record. Stable across program attempts. Created once per person per brand, then reused. - • First name, last name, email, nationality - • Brand scope and external ID (CRM / partner key) - • Lives in the applicants table Application Data One attempt at one program in one season. A new attempt produces a new row, even for the same person. - • programId, seasonId, status, submittedAt - • Workflow state, placement link, application-scoped documents - • `firstApplicantWorkAt` timestamp, for application-specific Portal work, never person-wide profile edits or registration - • Lives in the applications table ## Field-Level Rule of Thumb > **Ask one question of every field** > > “Would this still be true if the program didn’t exist?” If yes → it is applicant data. Put it on applicants. If no → it is application data. Put it on applications. A nationality is true regardless of which program the person applies to → applicant. A workflow status is meaningless without the program → application. A document like “passport” describes the person but is uploaded in the context of a program attempt; in the current schema it is application-scoped (see Documents below) and is not deduped across attempts. ## One Applicant, Many Applications applicants Maria Lopez id: 4821 · brand: Camp · nationality: MX → application #9012 Camp Counselor · Summer 2024 · completed application #11457 Camp Counselor · Summer 2026 · in_review Same person, two attempts, two application rows. Identity is not duplicated; workflow state is not shared. ## Where Each Field Lives | Field | Lives On | Why | | --- | --- | --- | | field | | rationale | _(row above is a template, repeated for each data row)_ ## Documents Follow the Same Split Two document concepts mirror the two data concepts: application_documents Documents collected for a specific application attempt. Today these are stored in the documents table with applicationId as a required FK. Re-applying does not carry these forward automatically. my_documents (applicant-scoped) Identity-bound documents the person owns regardless of program (passport scan, profile photo, language certificates). Surfaced in the Applicant Portal as the “My Documents” surface and intended to be reused across attempts. Schema gap: there is currently no applicant-scoped documents table — see the audit below. **Profile photo writes.** `PATCH /api/applicants/:id` rejects `profileImageUrl` and `profileImageSourceUrl` with 400 VALIDATION_ERROR (`details.field` `profileImageUrl`), the same pattern as `email` and `hubspotContactId`: the photo fields are owned by the photo pipeline (portal photo replace, applicant creation with a photo URL, the Personal Info sync and the reason-coded identity-photo override), which fetches, size-checks and re-uploads the image to storage. > **Why this split matters for partners** > > Hosts and recruiters see different document slices because the documents themselves are scoped differently. Application-scoped documents are visible only inside the application row they belong to (and only to the partner that owns or is placed against that application). Applicant-scoped documents would be visible across every attempt the partner is authorized to see — if/when that table exists. ## Partner Visibility Matrix Per partner type, the matrices below list the applicant fields and application fields visible, the endpoints that serve them, and any visibility rules driven by step scope and the platform PII registry. Globus is the source of truth; the matrix is the contract each partner portal must implement against. > **Now restated as a surface contract** > > These per-partner field tables are a snapshot of what fills each surface today. The authoritative enumeration of surfaces — recruiter.summary, recruiter.profile, host.talent_card, host.profile, staff.profile, and the workflow_tab_only fallback — and the rendering contract each portal must honour live on the Dynamic Field Surface Spec . Every Workflow Studio field carries a surfaces slot that decides which of these tables it lands in; new fields appear in the appropriate partner's view without a code change. The per-field tables below should therefore be read as “what currently fills each surface,” not as the complete contract. ### Employer / Host Visibility Hosts only see applicants who are placed against one of their own job orders. They never see the global applicant pool, never see other hosts’ placements, and never see pre-placement workflow state. Required host role: admin, recruiter (hiring manager), finance, or readonly (the HostRole values in server/middleware/partnerAuth.ts; the read-only role cannot mutate). | Field | Level | Host Sees | Endpoint | | --- | --- | --- | --- | | field | | visible | endpoint | _(row above is a template, repeated for each data row)_ ### Recruiter / Partner Visibility Recruiters see applicants they created or are the source-of-record for, scoped by their active partner agreement (brand, programs, seasons). They never see applicants belonging to another partner, even within the same brand. Required role: partner_admin, partner_recruiter, or partner_readonly. | Field | Level | Partner Sees | Endpoint | | --- | --- | --- | --- | | field | | visible | endpoint | _(row above is a template, repeated for each data row)_ ### Step Scope & Platform PII Registry Visibility is decided in two places, neither of them per field. Together they replace what the previous design called “redaction.” See Field Classification Contract → Step Scope and Platform PII Registry for the canonical definitions. s data on each portal request. There is no per-field redaction toggle., Platform PII registry (always-mask, hard-coded) / Passport, date of birth, SSN, background-check detail, and medical disclosures are masked on every non-staff surface regardless of step scope. The registry is owned by the platform team and not configurable per program., Host \u2192 placement.applicant.email / The step that captures the applicant ## Globus Boundary Audit > **Audit posture** > > This audit walks the canonical Globus contract (schema → storage interface → partner-facing routes → RBAC) to confirm where the boundary holds and to flag concrete gaps. Gaps are reported here, not fixed — remediation is tracked separately. ### Schema PASS — identity vs attempt are split at the table level applicants holds identity fields such as firstName, lastName, email, nationality and shortCode. applications holds applicantId (FK), programId, seasonId and status. The split matches the rule of thumb above. (shared/models/applicant-lifecycle.ts.) PASS — placements key off application, not applicant placements.applicationId correctly references the per-attempt row, so re-applying produces a fresh placement chain rather than overloading the previous one. GAP — no applicant-scoped documents table documents.applicationId is .notNull(), so every document is tied to a single attempt. There is no applicant_documents / my_documents table backing the “My Documents” concept described above. Identity-bound documents (passport, profile photo) cannot be reused across re-applications without manual recopy. Reported, not fixed. ### Storage Interface PASS — insert schemas honour the split insertApplicantSchema and insertApplicationSchema are derived from the two tables independently (defined in `shared/models/applicant-lifecycle.ts`). Storage CRUD operations cannot conflate the two without bypassing the typed interface. GAP — no `getApplicantWithApplications` helper Partner-facing flows that need “person + every attempt I’m authorized to see” have to compose two queries by hand at the route layer, which is where boundary mistakes typically creep in (over-fetching applications outside the caller’s scope). A scoped storage helper that takes the caller’s partner-org-id / host-org-id and returns only authorized applications would centralize the filter. Reported, not fixed. ### Partner-Facing Routes & Naming PASS — partner-facing reads are scoped Every documented partner endpoint (partner, host) requires either X-Partner-Org-Id or X-Host-Org-Id + an active agreement. Bootstrap context resolves the caller’s scope before any applicant or application read is served. RESOLVED — host document endpoints unified under /api/partner/host/documents All host document endpoints are now documented under the canonical /api/partner/host/* prefix on the Host Document Center, matching host overview / activity-log endpoints. The legacy /api/host/documents/* mount is retained as a thin alias that 308-redirects to the canonical path with Sunset / Deprecation headers (RFC 8594) and will be removed once portal traffic on it reaches zero. New integrations must code against the canonical prefix. GAP — no documented endpoint for application-scoped documents on the host side Hosts can read their own org-scoped compliance documents but the contract does not yet expose application-scoped documents on placed participants (e.g. arrival paperwork). This is “planned” in the visibility matrix above and should be added to Host Portal Endpoints. Reported, not fixed. ### RBAC & Scope Filters PASS — partner and host RBAC are separate from staff RBAC Partner roles (partner_admin / recruiter / partner_readonly) and host roles (host_admin / host_hiring_manager / host_finance / host_readonly) are governed by dedicated middleware layers and cannot grant access to applicants outside the caller’s org-scoped slice. See RBAC & Governance. GAP — visibility rules are policy, not yet enforced by the shared projector The visibility rules listed above (host email visibility gated on the step's data scope of staff_and_host_after_placement; partner-visible documents driven by the step's audience) describe the target behaviour. They are enforced ad hoc per-route today rather than by the shared projectForAudience(record, audience) projector that reads step scope plus the platform PII registry. The projector is being built under Asana 1214212456520951; the registry under the new Platform PII Registry ticket in P1: Foundation Hardening. Reported, not fixed on this page. ### Reported Gaps (No Fixes) | # | Gap | Layer | Severity | | --- | --- | --- | --- | | G1 | No applicant-scoped documents table; identity-bound docs cannot be reused across attempts. | | medium | | G2 | No scoped storage helper for `applicant + authorized applications`; route-layer filtering can drift. | | medium | | G3 | RESOLVED — host document endpoints unified under canonical `/api/partner/host/documents`; legacy `/api/host/documents` retained as a 308-redirect alias with Sunset/Deprecation headers. | | resolved | | G4 | No documented endpoint for application-scoped documents on placed participants (host side). | | medium | | G5 | Redaction is policy-by-route, not enforced by a shared projector keyed on caller + applicant + application. | | medium | ## Common Mistakes to Avoid --- --- title: "Applicant Processing Model" slug: applicant-processing-model section: Applicant Journey status: stable description: "How Globus models the applicant journey — phases, stages, readiness states, and operational queues" last-updated: 2026-10-07 --- # Applicant Processing Model How Globus models the applicant journey — phases, stages, readiness states, and operational queues ## Overview The Globus applicant processing model is built on a canonical 8-phase journey spine that every applicant traverses from initial intake through program completion. Within each phase, programs can define configurable stages that represent operational milestones. At any point in the journey, an applicant carries a readiness state computed by the Rules Engine, and may simultaneously belong to one or more operational queues for staff action. ## The 8-Phase Journey Spine The journey spine is the canonical ordered sequence of lifecycle phases defined in journeyPositionResolver.ts and phaseApplicability.ts. Every applicant progresses through these phases in order. Phase transitions are guarded by the lifecyclePhaseGate.ts hook, which evaluates all applicable rules before allowing advancement. ### Phase Reference | Phase | Key Activity | Gate Condition | | --- | --- | --- | | 1. Intake (`application`) | Applicant record created via portal or partner sync; applicant completes portal workflow steps | Step-completion threshold met to reach Validation | | 2. Validation | Staff review of submitted application data | Authored Validation steps complete (published workflow with full step coverage); failed rules and unverified documents remain separate blockers | | 3. Screening (`interview`) | Sponsor interview conducted and recorded | Interview outcome = pass | | 4. Placement | Applicant matched to a host organisation | Job order approved; placement confirmed | | 5. Visa / SEVIS (`visa_sevis`) | Application is in the SEVIS pool or has a confirmed placement; SEVIS processing | In the SEVIS pool, or placement confirmed | | 6. Pre-Departure | DS-2019 issued; visa obtained; travel booked | DS-2019 issued, in the SEVIS pool, and placement confirmed | | 7. In Program (`in_program`) | Applicant is in the US; monthly check-ins active | Program start date reached; SEVIS validated | | 8. Completion | Program end; departure confirmed; record closed | Program end date passed; no open compliance cases | **Validation track completion is independent of its blockers.** The Validation track (one of the four parallel pre-SEVIS tracks) is derived in preSevisTrackMachine.ts. When a published workflow resolves for the program and its steps carry complete journey-position coverage, the track reaches the terminal `complete` state once every step tagged to Validation is complete (including a deliberately empty Validation phase with zero authored steps). Failed compliance rules and unverified documents do not hold the track open; they remain separate, individually listed SEVIS blockers. While the phase is incomplete or coverage is missing, the track stays `in_progress` (it never promotes on incomplete or untagged coverage). Programs with no resolvable published workflow keep the legacy evidence-only behavior: the track completes only when documents are verified and compliance is compliant. Reconciliation advances the track forward-only and is idempotent on repeat passes. ## Configurable Stages Within each phase, programs can define stages via the stage_definitions table (each carrying a categoryKey and journeyPositionKey, and scoped through appliesToVisaTypes and appliesToProgramIds). Stages allow operational teams to track sub-milestones within a phase — for example, splitting the Validation phase into "Document Review" and "Background Check" stages. Stage transitions are managed by stages.ts and call enforcePhaseGate (lifecyclePhaseGate.ts) on each stage change. For manual operator moves, the allowed moves are rows in `stage_transitions`: each has a `fromStageId` (null for the entry point) and a `toStageId`, an `isActive` flag, `requiresReason` with a `reasonPrompt`, and an optional `requiresPermissionKey`; the (from, to) pair is unique. A manual move with no active matching row is rejected as an invalid stage transition together with the list of valid ones; a transition that `requiresReason` is rejected without a reason, and one whose `requiresPermissionKey` the caller lacks is not offered and is rejected. Milestone-verified, non-forward and admin-override moves do not traverse these rows. Offered transitions are grouped as forward, rollback (lower `sortOrder`) or exit (terminal stage). ## Readiness States Every applicant carries a readiness state computed dynamically by the Rules Engine via resolveReadiness(). The state reflects the aggregate outcome of all rules applicable to the applicant's current phase. | State | Meaning | Staff Action | | --- | --- | --- | | on_track | All rules pass; no blockers | No action required | | blocked | A reviewer rejected a step, a compliance check is failing, or an interview ended with a non-pass outcome. Observational for staff dashboards; it no longer feeds step access (compliance enforces at the SEVIS gates) | Investigate and resolve the blocker | | paused | A hold has been placed (e.g., awaiting external input) | Monitor; resume when condition clears | | needs_attention | A compliance check needs review, or the applicant is waitlisted | Review the flagged checks; resolve or escalate | ## Multi-Queue Membership Model An applicant can simultaneously belong to multiple operational queues. Queue membership is driven by rule outcomes (via the queue_review action) and is stored in compliance_review_queue_items. The queue architecture is managed by queueTransitions.ts, which handles entry, exit, and escalation transitions. Staff see queue membership surfaced on the applicant profile's Snapshot Cards Grid. ## Phase Gate Evaluation > **Gates Are Synchronous** > > Phase gate evaluation is synchronous and blocking. An applicant cannot advance to the next phase until all applicable rules for the current phase return a non-failing outcome. Gates are evaluated by lifecyclePhaseGate.ts on every phase transition attempt. The phaseGateEvaluator.ts service collects all rules applicable to the current phase (via the 6-layer cascade resolver), evaluates them in priority order, and returns a gate decision. If any rule returns a fail outcome without a permitted override, the gate is closed and the applicant's readiness state is set to blocked. ## Record-Mutation Endpoints (Staff) These write the records the journey runs on. All require `applications.edit`; API keys need `applications.write` plus `enrollments.read` for enrollments and workflow instances, and `admin.write` plus `forms.read` for form submissions. Each write is audited (`enrollment.create` / `enrollment.update`, `workflow_instance.create` / `workflow_instance.update`, `create_form_submission` / `update_form_submission`). | Method | Path | Behaviour | | --- | --- | --- | | POST | `/api/enrollments` | Creates a program enrollment (`applicantId`, which is the external portal user id and not a foreign key to local applicants, `programId`, optional `startSeasonId`; `status` defaults to `active`, `currentYearInProgram` to 1). `201`. | | PATCH | `/api/enrollments/:id` | Partial update of the same fields; the id must be a UUID; `404` when the enrollment does not exist. | | POST | `/api/workflow-instances` | Creates a workflow instance for an enrollment (`enrollmentId`, `workflowType`; `state` defaults to `draft`). A given `workflowDefinitionId` supplied without a `definitionVersion` is pinned at that definition's current version; without a `workflowDefinitionId`, the season's locked definition is used when a `seasonId` is given, otherwise (or when the season has none) the program's published definition for the workflow type. When no `seasonId` is given the season is then assigned from the enrollment's program. `201`. | | PATCH | `/api/workflow-instances/:id` | Partial update; `404` when the instance does not exist. | | POST | `/api/form-submissions` | Stores a submission against a form template version (`templateId`, `templateVersionId`, `entityType`, `entityId`, `dataJson`; `status` defaults to `in_progress`); `submittedBy` is set from the session user. `201`. | | PATCH | `/api/form-submissions/:id` | Partial update; `404` when the submission does not exist; the audit row keeps the previous and new `dataJson`. | --- --- title: "Interview Result Capture" slug: interview-result-capture section: Applicant Journey status: stable description: "How Globus tracks interviews as Work Types — non-status-changing operational activities — and captures their result at the applicant level." last-updated: 2026-10-07 --- # Interview Result Capture How Globus tracks interviews as Work Types — non-status-changing operational activities — and captures their result at the applicant level. Globus does not host or schedule the interview itself; it configures the activity per program, records the outcome with timestamp and interviewer, keeps a chronological history, and lets staff filter the applicant list by outcome. This page makes the documented Host Interview / Work Type model (Phase 3, P11.3) explicit and implementable. ## As-Built Implementation (2026-07-04) > **The feature shipped — on the generic work_items model, not the tables specced below** > > Interview result capture is live in Globus, but the implementation is built on the generic work_items table rather than the dedicated InterviewOutcome / work_type_definitions tables in the original P11.3 contract below. Treat this section as ground truth and the remainder of the page as the historical design contract (its intent — outcome capture, history, status independence, readiness linkage — all shipped). | Aspect | As built (BackOfficePrototype, verified 2026-07-04) | | --- | --- | | Storage | Rows on the generic work_items table keyed by work_type_key ('sponsor_interview' \| 'host_interview'). Columns: interview_type, scheduled_at, interviewer_id, status (pending \| in_progress \| completed), completed_at, outcome. No dedicated InterviewOutcome table; work types are seeded (server/seed/workTypes.ts config.outcomeOptions), not a per-program work_type_definitions table. | | Write endpoint | One route for both surfaces: PATCH /api/work-items/:id (server/routes/workItems.ts), gated requirePermission('applications.manage'). There is no separate host endpoint. | | Outcome vocabulary | Host: accepted \| rejected \| reschedule. Sponsor: passed \| failed \| reschedule. accepted/passed are one-click; the rest are confirm-gated. There is NO no_show outcome on the sponsor or host interview (the phone and in-person interview work types do allow no_show). | | English proficiency | PATCH /api/work-items/:id also accepts englishProficiencyLevel (excellent \| very_good \| good \| fair \| fail; shared/workItemVocabulary.ts), valid ONLY on the sponsor interview (400 ENGLISH_RATING_SPONSOR_INTERVIEW_ONLY otherwise) and only alongside a passed outcome, either in the same request or as a late write once already passed (400 ENGLISH_RATING_REQUIRES_PASSED). When the program's sponsor interview has an interview sheet bound, the sheet is the sole writer and the manual rating is rejected with 409 ENGLISH_RATING_SHEET_IS_AUTHORITATIVE (server/services/workItems/englishProficiency.ts). The server stores it under the reserved metadata.englishProficiency key (client copies are stripped) and it resolves into the fact screening.sponsor_interview.english.overall_level when no sheet value exists; a late write audits as applicant.work_item.english_rating_recorded. The legacy POST /api/work-items/:id/complete route now returns 409 SCREENING_OUTCOME_USE_PATCH for screening work items (all screening outcomes go through PATCH). | | Transitions | server/services/screening/outcomeEngine.ts — ONE generic, config-driven transition builder. Task #1626 Phase 2 replaced the per-type modules (hostInterviewTransition.ts / sponsorInterviewTransition.ts / modalityInterviewTransition.ts), which were deleted on 2026-07-13 (#1444) and no longer exist. Behaviour comes from a ScreeningTransitionConfig resolved by server/services/screening/configResolver.ts (pinned blueprint version → published blueprint → shared v1 defaults), so custom activity types need zero engine changes. The work-type key constants moved to shared/workItemVocabulary.ts (SPONSOR_INTERVIEW_WORK_TYPE, HOST_INTERVIEW_WORK_TYPE, PHONE_INTERVIEW_WORK_TYPE, IN_PERSON_INTERVIEW_WORK_TYPE). Scheduling sets status to in_progress; a terminal outcome sets completed + completed_at; reschedule returns to pending and clears timestamps. The engine returns a partial work_items column map only — it never writes applicant lifecycle status, and scripts/checkers/stage-write-guard.cjs enforces that at build time. | | Auto-generation | server/services/sponsorInterviewAutoGenerate.ts (ensureSponsorInterviewWorkItem), idempotent via partial unique index idx_work_items_sponsor_interview_unique ON work_items(application_id) WHERE work_type_key='sponsor_interview'; wired into lifecycleReconciliation.ts. | | Rule gating | interview_requirement rule type with interviewer param (host \| sponsor, default host) — shared/ruleParameterSchemas.ts + ruleTemplates.ts. Facts: hostInterviewOutcome, sponsorInterviewOutcome, interviewCompleted. | | Readiness | journeyPositionResolver.ts WorkItemSignals (hostInterviewCompleted/Passed, sponsorInterviewCompleted/Passed) + lifecycleReconciliation.ts fetchWorkItemSignals. | | Queue linkage | Queue next-action record_interview_outcome (shared/queueReason.ts). | | Staff UI | screening-section.tsx (ScreeningSection, using useInterviewOutcomeMutation from use-interview-outcome.ts), rendered from applicant-profile.tsx. | | Audit | applicant.work_item.created and applicant.next_action.invoked (plus route-level outcome audit). The applicant.interview_outcome.* / program.work_type.* names below were never implemented. | | Host Portal capture | NOT built. host_user roles carry zero permissions in server/seed/rbac.ts, so PATCH /api/work-items/:id returns 403 for hosts — the 'two entry points, one record' model remains aspirational; today all outcomes are entered by sponsor staff. | > **Scope — operational tracking & result capture, not hosting** > > Globus configures the interview Work Type, captures its outcome, and tracks it for completeness and accountability. It does not host video calls, send calendar invites, or schedule the meeting itself. The record is the structured artifact left behind around an interview, wherever the conversation actually takes place. ## Purpose & Scope Interviews (Sponsor Interview, Host Interview, and any program-defined variant such as Phone Interview or In-Person Interview) are tracked as Work Types — operational activities layered beneath the applicant's pipeline status, not status changes themselves. The lifecycle docs describe this model; this page defines it end to end so it is implementable: program-level configuration, the outcome record shape, lifecycle states and outcomes, SLA tracking, the two capture surfaces, status independence, the applicant-profile history view, applicant-list filtering, readiness/placement linkage, and audit. In scope: the spec for all of the above. Out of scope: hosting/scheduling interviews inside Globus, and the runtime/backend implementation itself — the live applicant list, program-configuration UI, and outcome storage are owned by Globus-core under P11.3 (see Ownership & Routing). This docs page is the build contract that work is filed against. ## Work Type Model — Operational Activity Tracking A Work Type is a per-applicant operational activity tracked for completeness and accountability. It is not an applicant status, and recording one never mutates the pipeline status (see Status Independence). An applicant can hold several activity records at once — e.g. one interview per host / job order, plus a sponsor interview. > **The interview is a behind-the-scenes Workflow Studio step node** > > Every interview tracked here corresponds to a behind-the-scenes step node in the program’s step tree — authored and visible to admins in the builder, flagged staff-only (sponsor) or host-only (host) by role assignment, and never rendered on the applicant stepper. Recording the outcome on this surface is what completes that step node. Through the Step → Fact Contract the node emits an outcome fact (sponsorInterviewOutcome / hostInterviewOutcome) that gates the phase, and a host interview’s outcome keeps its job_order_id so the passed result still feeds placement. See P11.3 Work Type for the lifecycle framing. Is - A per-applicant operational activity - Configured per program - SLA-tracked for accountability - Many-per-applicant Is Not - An applicant-level status field - A status change when recorded - A hosted/scheduled meeting - One-per-applicant Surfaces In - Sponsor Host Interviews queue + SLA timers - Applicant profile history (chronological) - Applicant-list filters (by outcome) ## Program-Level Configuration A program_manager configures which Work Types exist for a program. Adding a Work Type (e.g. Phone Interview, In-Person Interview) makes it available for tracking on applicants in that program. Each configured Work Type is a WorkTypeDefinition. | Field | Type | Notes | | --- | --- | --- | | field | type | notes | _(row above is a template, repeated for each data row)_ > **Program-scoped, deactivate over delete** > > Work Types are configured per program, so program A can offer a Phone Interview while program B does not. Deactivating (active = false) removes a Work Type from new activities while preserving every historical record that referenced it — history is never orphaned. ## Data Model — InterviewOutcome One row per interview activity on an applicant. Every field is required unless its type admits null. | Field | Type | Notes | | --- | --- | --- | | field | type | notes | _(row above is a template, repeated for each data row)_ > **reason_code comes from the catalog** > > The reason_code value is bound to the structured Reason Code Catalog — never an ad-hoc string. A failed outcome requires a code; a passed outcome may carry one but does not require it. Free-text colour belongs in notes. ## Lifecycle States & Outcomes The activity moves through states derived from the data (presence of scheduled_at / conducted_at and the value of outcome), not stored as a separate enum. The recorded outcome is one of: > **Failed is terminal for this attempt, not the applicant** > > A failed or no_show outcome closes this interview attempt. The applicant remains eligible to interview with other hosts or in a follow-up activity — each interview carries its own record. ## SLA Tracking When a Work Type defines sla_hours, opening the activity starts an SLA timer. The timer is for accountability and never changes pipeline status — a breach raises the activity's visibility (sort/flag) in the sponsor Host Interviews queue and feeds last-touch staleness, but the applicant's status is untouched. The timer stops when an outcome is recorded; a rescheduled outcome restarts it against the new scheduled_at. > **SLA timers live in P16** > > The queue buckets, SLA timers, and staleness model are owned by P16 — Queue System & SLA Management. This page only defines the per-activity sla_hours input and the events the queue consumes. ## Capture Surface There are two entry points writing the same record. Whichever surface captures it, the outcome surfaces identically in the sponsor Host Interviews queue, on the applicant profile history, and in the applicant-list filters. The recorded_by field distinguishes host vs. sponsor-staff capture. ### 1. Host Portal *Status: API built, no Host Portal screen found. The Hub exposes `POST /api/partner/host/work-items/:id/interview` (gated by `host.positions.manage` and the `job_orders.write` scope, with job-order ownership checked), but `host_user` roles carry no staff permissions (`server/seed/rbac.ts`) and no Host Portal UI calling that route was found in HostPortalPrototype; in practice outcomes are entered by sponsor staff (see the as-built table above).* The host records the outcome for an applicant they interviewed, directly in the Host Portal against the job order. recorded_by resolves to the host user; interviewer defaults to that user but can name a different host contact who actually conducted the interview. For host-decision interviews the portal shows accepted / rejected labels, stored as passed / failed. ### 2. Globus (Sponsor Staff) Sponsor staff can schedule and record the result directly on Globus — for example when the host relays it offline, by phone, or staff record it on the host's behalf, or for sponsor-conducted interviews. This writes the same record; recorded_by resolves to the staff actor while interviewer names whoever conducted it. > **One record, two writers** > > Both surfaces write the identical InterviewOutcome shape against the same applicant / Work Type. There is no separate "staff outcome" vs. "host outcome" model — only recorded_by tells the two apart, and the queue, history, and filters treat them the same. ## Status Independence > **Recording an outcome never mutates pipeline status** > > Work Type activities are tracked for completeness and accountability; they do not modify the applicant's pipeline status. If an applicant is in_review and an interview outcome is recorded, the applicant stays in_review. Any downstream phase advancement is a separate staff or rules-engine decision that may read outcomes as a readiness signal (see Readiness & Placement Linkage) but is never triggered as a side effect of writing the record. ## Interview History on the Applicant Profile The applicant profile shows all interview records for the applicant in chronological order (most recent first), each row rendering the Work Type label, host / job order (when set), interviewer, scheduled and conducted timestamps, the outcome chip, the reason code (when present), and notes. Multiple interviews — across hosts or repeated attempts — are all retained; nothing is overwritten. This is the accountability trail: every attempt and result is visible after the fact. ## Applicant List Filtering The applicant list exposes a Work Type outcome filter so staff can, for example, select "interview passed" and see only applicants with a matching recorded outcome. The filter matches on work_type_id + outcome; an applicant matches if any of their interview records satisfies the selected criteria. Like the rest of the ecosystem, the filter is applied server-side and scoped to the caller's permission (brand → program → season) so counts and rows agree. ## Readiness & Placement Linkage Open required activities are a readiness signal A required interview Work Type still in pending_outcome shows as an open readiness item on the applicant and in the Interview phase readiness. It is a signal staff and the rules engine read, not a status the activity writes. Passed feeds placement confirmation A passed (accepted) host-interview outcome is an input toward moving the corresponding placement match from reserved → confirmed. It is a necessary signal, not the sole one — the placement engine owns the actual transition and may require other gates (Wave 2 documents, capacity). Failed releases the match A failed (rejected) outcome resolves this host's interview but does not confirm a placement; the reserved match for that job order is freed for re-matching. ## Audit Vocabulary Every mutation — program configuration and per-applicant capture — writes through recordAudit() using applicant- and queue-scoped vocabulary, surfaced in the Audit Console. | Action | When | | --- | --- | | action | when | _(row above is a template, repeated for each data row)_ > **Best-effort, never blocking** > > Audit writes follow the ecosystem rule: they run after the success response and never fail the underlying mutation. Actor resolution uses resolveActor(req), so program-manager, host-user, and sponsor-staff actions are attributable in the same log. ## Acceptance Criteria The implementation (owned by P11.3, see below) is complete when all of the following Gherkin scenarios pass. ## Ownership & Routing > **Owned by P11.3 — Work Types / Interview Tracking** > > Implementation of Work Type configuration and interview capture is owned by P11.3 — Work Types / Interview Tracking, part of the Globus Phase 1 / Workflow Studio family — not On-Program. It is pipeline/intake mechanics, so tickets are filed against the Globus Phase 1 portfolio. The runtime (program-config UI, applicant-list filter, outcome storage) lives in Globus-core; this page is the docs-site spec only. ## Cross-References --- --- title: "Skills & Hobbies (Camp Counselor)" slug: skills-hobbies-camp-counselor section: Applicant Journey status: stable description: "The authoritative skill taxonomy collected at the Camp Counselor “Skills & Hobbies” intake step — length categories, skills." last-updated: 2026-10-07 --- # Skills & Hobbies (Camp Counselor) The authoritative skill taxonomy collected at the Camp Counselor “Skills & Hobbies” intake step — length categories, skills. > **Camp Counselor Program Only** > > This taxonomy applies to the Camp Counselor program only. It does not apply to SWT (Summer Work & Travel), Teach USA, Intern/Trainee, or any other program. Other programs use their own skill and experience capture, defined on their own intake workflows. ## Overview During Phase 1 intake, every Camp Counselor applicant completes a Skills & Hobbies step (see Applicant Lifecycle → Portal Phase Mapping ). This page is the source of truth for the catalog presented at that step: the full Skills & Hobbies V4.0 list of length categories and skills, transcribed exactly as authored. Skills selected here feed the Matching Engine , which weights the Skills dimension most heavily for Camp Counselor placements. This page documents the taxonomy only — it does not define the matching weights, the selection UI, or any validation logic. ## Selection Requirements At the Skills & Hobbies step, the applicant must make the following selections: 3–5 Skills required Minimum 3, maximum 5 skills selected from the categories below. 2–6 Hobbies required Minimum 2, maximum 6 hobbies. Both the V4 and V5 step variants show the “Select 2–6 hobbies” instruction and block a seventh selection. > **Selection Rule** > > 3–5 skills required, 2–6 hobbies required. ### Free-Text Word Minimum Every free-text answer that is filled in must contain at least **20 words**: each selected skill's Experience Description, the Hobbies Reflection, and (V4 only) My Experience with Children. Individual hobbies are a plain multi-select with no per-entry narrative, so they carry no word minimum. The same bound is enforced on the portal step-submit route and on staff step completion (the rule lives in the shared Skills & Hobbies schema), and the live word counters in the step read the same served minimums. Enforcement differs by route, because the Applicant Portal saves this step incrementally (on every added or edited entry): - **Portal submit (`POST /api/portal/steps/:stepKey/submit`)** enforces only the 20-word minimum, and only on free-text boxes that are filled in. Empty boxes and incomplete selections are accepted. A short answer returns 422 with per-field errors naming the minimum (for example “Please write at least 20 words describing your experience with Cooking (currently 3).”). - **Staff step completion** enforces the full completion rules: 3–5 skills with proficiency levels, certification documents for Certified skills, the hobby count, and the required reflection and (V4) children-experience boxes, together with the word minimums. **Grandfathering of `children_experience`.** The required My Experience with Children box and its 20-word minimum are completion-time rules, and they are not applied retroactively. On both the portal submit route and the staff completion path, a step that was already complete before the write is exempt from the `children_experience` rule when its prior persisted snapshot carried fewer than 20 words in that field (absent, or a short pre-rule answer). A prior answer that already met the minimum is not grandfathered, so the new submission must still meet it. A step that was never completed is not grandfathered, and neither is a regression on a step whose prior snapshot already carried a qualifying answer. Only `children_experience` is exempted; every other rule still applies in full. On the portal route the prior-snapshot lookup is best-effort: if it fails, that request falls back to the stricter behaviour. ## Children Experience Field (V4) Skills & Hobbies V4 adds a **Children Experience** textarea to the Skills & Hobbies step in the back-office (Shipped Aug 11 2026). The field is **required for Camp Counselor applicants**, and its value feeds directly into the Category 2 (Cat 2) eligibility derivation. The field's contents populate the `children_experience` data used to derive the Camp Counselor SEVIS eligibility path: an applicant with direct childcare experience captured here derives Category 2, which the CAMP-002 add-on rule ("Youth worker evidence verified") checks. For the full eligibility-path priority order (Cat 1 > Cat 2 > Cat 3), staff override, and the CAMP-001 / CAMP-002 / CAMP-003 gate, see the SEVIS Readiness page. ## Staff Review & Save Behaviour In the back-office applicant profile, staff review of the Skills & Hobbies step is gated on the applicant having submitted it: - **Item review waits for submission.** Item-level review and whole-step approval are available only once the applicant has submitted the step (the step is completed) or a whole-step review already exists. Until then the review panel is replaced by an “Awaiting applicant submission” notice. - **Staff “Save & Continue” completes an incomplete step.** When staff enter or edit the answers for a step that is not yet complete, “Save & Continue” saves the edits and then completes the step through the server completion gate (so invalid answers, such as a reflection under 20 words, are rejected and nothing is written). On an already-completed step the button reads “Save” and is a plain edit-save. - **Both data shapes are written.** A staff save writes the canonical nested `skills_hobbies_data` value and the same values flattened at the top level, matching what the Applicant Portal persists, because the completion gate's required-field check reads the top-level keys. ## Field-Level Sponsor Review *Status: item-level review (changes_requested / applicant_resubmitted items) and the portal `reviewFeedback` block are implemented; the scope decisions below record the design.* Sponsors reviewing the Skills & Hobbies step can approve or request changes on individual skills (and the hobbies reflection) rather than only accepting or rejecting the whole step. Staff compose targeted per-skill comments as a private draft, then publish them by returning the step to the applicant ("Return for changes") or approving it. The applicant sees an amber "Program team feedback" panel under each affected skill — visually distinct from the optional AI writing suggestions — and the feedback persists through edits and resubmission until staff resolves each item. Binding scope decisions of record: - **No new step status.** The tracked step status remains `rejected` — all reopen machinery (reject-clearable lock release, rejection-message staleness gate, portal needs-action) keys on that literal value. "Changes requested" is decision/item vocabulary only; the UI relabels the actions to "Return for changes" and "Approve step". - **No submission-attempt entity.** Review-item resolution keys on the resubmission event and timestamps. - **Stable `itemId` lands first.** Each selected skill and hobby entry in the composite contract gains a permanent UUID so feedback can target an entry across renames and reorders. This is a cross-repo frozen-schema change (the canonical schema is deep-frozen and drift-locked, shared with the Applicant Portal form-engine bundle); legacy rows without IDs resolve by name-match fallback. - **Server-side approve gate.** The step cannot be approved while any published review item remains in a changes-requested state. - **Curated reason code stays mandatory** on the overall return; per-item comments are supplemental free text. - **Items carry their own lifecycle.** Published items persist through resubmission until staff resolves them — deliberately not gated on live step status, unlike whole-step rejection messages. - **Portal delivery reuses existing read surfaces.** Feedback ships as a `reviewFeedback` block on the step-access payload with audience filtering (internal-only staff notes never leave Globus); no parallel channel. The review framework is generic (review sessions + review items) so future composite steps — work experience, education, references — inherit the capability without rework. **Persistence.** Whole-step review decisions are stored in `step_review_decisions`: one row per application and step key (unique on that pair) with `decision`, `review_notes`, the reviewer id and name, and the curated reason code id and label plus the applicant message as snapshots. Field-level review uses `step_review_sessions` (per application and step: `decision` of `draft`, `approved` or `changes_requested`, `overall_comment`, the reason code fields, `published_at` null while private and stamped on publish, and `supersedes_review_session_id` linking a correction to the published session it replaces; at most one open draft per application and step) and `step_review_items` (per session: `target_type`, today `skill`, `hobby` or `hobbies_reflection`; `target_item_id`, `target_label`, `field_path`; `status` of `not_reviewed`, `approved`, `changes_requested`, `resolved` or `applicant_resubmitted`; `comment`; `visibility` of `internal` or `applicant`; `resolved_at` and `resolved_by`). Delivery is sequenced across four tickets: the `itemId` contract change (P1.2 Form Control), the review sessions/items framework with staff UI and approve gate (P11.1 Step Locking), the portal read path (P11.1 Step Locking), and the Applicant Portal rendering (PPPToo Greenfield Rebuild). ## Skills & Hobbies V4.0 Taxonomy The per-category skill lists are not reproduced on this page; the served V4.0 catalogue is `SKILLS_HOBBIES_V4_SKILL_CATEGORIES` in `shared/skillsHobbiesSchema.ts`. The V4.0 catalogue has ten categories, in this canonical order: Arts & Crafts, Performing Arts, Adventure Sports, Land Sports, STEM, Water Sports, Outdoor Adventure, Nature, Child Care, and Other Cool Stuff. Other Cool Stuff is always listed last. --- --- title: "Skills & Hobbies (Camp SWT)" slug: skills-hobbies-taxonomy section: Applicant Journey status: stable description: "The authoritative Skills & Hobbies V5.0 list for the Camp Summer Work & Travel program: length categories, skills, and the selection rules an applicant follows during the Phase 1 “Skills & Hobbies” intake step." last-updated: 2026-10-07 --- # Skills & Hobbies (Camp SWT) The authoritative Skills & Hobbies V5.0 list for the Camp Summer Work & Travel program: length categories, skills, and the selection rules an applicant follows during the Phase 1 “Skills & Hobbies” intake step. > **Program scope — Camp Summer Work & Travel only** > > This taxonomy applies only to the Camp Summer Work & Travel (Camp SWT) program. It does not apply to Camp Counselor, Teach USA, Intern/Trainee, or any other program. Those programs carry their own, separate skill taxonomies and must not reuse this list. ## Program Scope Camp SWT applicants place into camp support roles (kitchen, maintenance, housekeeping, office, retail) rather than camper-facing counselor roles. The skill categories below reflect that operational reality and are surfaced to the applicant during the Phase 1 (Application) “Skills & Hobbies” step. This page is the documented source of truth for that list; it is documentation only and does not define any selection UI, validation logic, or matching-engine scoring behavior. ## Selection Requirements When completing the Skills & Hobbies step, a Camp SWT applicant must satisfy both of the following: Skills 3–5 skills required Selected from the skills across the length categories below. Hobbies 2-6 hobbies required Captured alongside skills on the same Phase 1 step. Free-text word minimum Every free-text answer that is filled in must contain at least 20 words: each selected skill's Experience Description and the Hobbies Reflection. Hobbies themselves carry no per-entry word minimum, and the V5 step has no My Experience with Children box (that box exists only in the V4 Camp Counselor variant). The rule lives in the shared Skills & Hobbies schema, so it is the same bound the Camp Counselor step enforces. ## Categories & Skills The complete V5.0 taxonomy — length categories, skills. Skills are listed exactly as they appear in the source list. ## Cross-References - Applicant Lifecycle — Phase 1 (Application) — the “Skills & Hobbies” step that collects these selections. - Matching Engine — Program-Specific Weight Profiles — how the SWT-Camp profile weights the Skills dimension during matching. --- --- title: "Applicant Dashboard Summary View" slug: applicant-dashboard-summary section: Applicant Journey status: stable description: "Specification and documentation for the mini-dashboard and profile summary view on the applicant detail page" last-updated: 2026-10-07 --- # Applicant Dashboard Summary View Specification and documentation for the mini-dashboard and profile summary view on the applicant detail page ## Overview The Applicant Dashboard Summary View is the at-a-glance status panel displayed at the top of every applicant detail page in the Globus Command Center. It aggregates the most operationally relevant information about an applicant's current state — journey position, readiness, blockers, and document status — into a compact, scannable layout. It is composed of two main components: the Profile Summary Panel and the Snapshot Cards Grid. ## Component Architecture | Component | File | Purpose | | --- | --- | --- | | Applicant Summary briefing | applicant-profile/applicant-summary-briefing.tsx | Renders the structured briefing derived in shared/applicantProfileSummary.briefing.ts | | Applicant workspace | applicant-profile/applicant-workspace.tsx | Renders the applicant profile workspace and mounts the briefing; consumes the ApplicantProfileSummary whose hero and snapshot cards are derived in shared/applicantProfileSummary.cards.ts | ## Data Sources The summary view is assembled from four API endpoints, all scoped to the authenticated staff user's permissions: | Endpoint | Data Provided | | --- | --- | | /api/applications/:id/journey-position | Current phase, stage, and journey position | | /api/applications/:id/local-step-progress | Portal step completion percentages and missing fields | | /api/applications/:id/eligibility-summary | Readiness state, active blockers, and rule outcomes | | /api/documents/by-application/:applicationId | Document submission status (passport, DS-2019, etc.) | ## Displayed Fields | Field | Source | Notes | | --- | --- | --- | | Journey Position | progress endpoint | Phase name and stage label | | Readiness State | eligibility-summary endpoint | Colour-coded chip: green (on_track), red (blocked), yellow (paused), blue (addressed) | | Active Blockers | eligibility-summary endpoint | List of failing rule names with applicant-facing messages | | Missing Fields | local-step-progress endpoint | Incomplete portal steps with percentage completion | | Passport Status | documents endpoint | Submitted / Approved / Expired / Missing | | Program Dates | progress endpoint | Start and end dates; days until program start | ## Relationship to Queue Membership The Snapshot Cards Grid also surfaces the applicant's current queue membership. Each queue the applicant belongs to is shown as a compact card with the queue name, entry reason, and time in queue. This is derived from the compliance_review_queue_items table and is consistent with the applicant's position in the staff-facing queue views. For the full queue architecture, see Applicant Processing Model . > **Real-Time Updates** > > The summary view refreshes automatically when the applicant's readiness state changes or a new queue entry is created. Staff do not need to reload the page to see updated status. ## Summary Derivation Modules (Sep 2026) Shipped Sep 14 2026 (Globus commit `bc6ceceac`, "Decompose Applicant Summary derivation"). The logic that turns the four data sources above into the summary was split out of one large shared module into two purpose-specific shared modules that both the applicant workspace and the **Next Best Action** component consume: | Module | Owns | | --- | --- | | `shared/applicantProfileSummary.briefing.ts` | The structured Applicant Summary briefing — headline and one-sentence overview, processing-area chips, attention items, consolidated readiness blockers, prioritized recommended actions, and the summary-generation timestamp (see "Structured briefing" below). | | `shared/applicantProfileSummary.cards.ts` | The hero and snapshot-card derivations — stage, readiness, compliance, placement, Market Ready, SEVIS and queue cards, plus the progress milestones. | | `shared/applicantProfileSummary.logic.ts` | Thin composition layer that re-exports the two modules above. | When the Market Ready gate carries `nativeGateProjections`, the Market Ready card is derived from them: the label is "Ready" when `nativeStatus` is `ready`, "Not Ready" when it is `blocked`, and "Awaiting" otherwise. The card's secondary text is "Retained market.exposure observations", and the projections are passed through on the card status. On the Placement card, a stale terminal track is not proof of a current commitment; the current-placement evidence supplied by the profile context (`inputs.hasConfirmedPlacement`) is what may assert Hired. On the SEVIS card, when `inputs.sevisReadiness?.ready` is set, the label is "Ready with override" if `inputs.sevisReadiness.overridden` is set and "Ready for SEVIS" otherwise. The card's secondary text is "Prerequisites met; batch checks still apply". Because the derivation is shared code, staff UI and the next-best-action recommendation read the *same* derived summary and cannot disagree. No change to the displayed field contract in the table above was verified for this window; treat the refactor as a maintainability change, and the field table as still authoritative. The Command Center's freshness bar (see [Operations Command Center](/docs/operations-command-center)) was also extracted from the Applicant Summary briefing's header in the same window. ### Structured briefing The Applicant Summary briefing is a compact, deterministic operational briefing (PR #2369, "Structure Applicant Summary briefing") that replaced the earlier paragraph-style summary. `buildApplicantProfileBriefing` returns: | Part | Behavior | | --- | --- | | Headline / overview | Headline is `{first name}: {operational position}`. The overview is one sentence: the operational position (and stage) plus, in priority order, the count of outstanding evaluated blockers, else the count of areas needing staff attention, else "no active attention item is recorded" (when readiness is ready), else the current readiness label. If the operational position is unavailable it says so rather than guessing. | | Processing areas | One chip per *applicable* processing track (applicability from application type and the program's applicable tracks; state from the journey track fields, status complete / in progress / not started), plus a "Visa / SEVIS" chip. If the journey track read is unavailable, the chips are suppressed rather than shown as fabricated "not started" states. | | Attention items | Only non-zero items: compliance failures (critical), documents awaiting review, incomplete required workflow steps, and active queues (warnings). Each carries a count and a target tab. | | Blockers | Consolidated and de-duplicated across Market Ready blockers and SEVIS gate blockers (matched on normalized rule identity, with gate names merged), linking to the Compliance tab. | | Recommended actions | At most 3, in priority order: failed compliance (critical), documents awaiting review, expiring or expired documents (within 90 days; critical at 30 days or expired), missing required steps, queue SLA breaches. Each has a CTA label and a permission-aware target tab. | | Timestamp | `generatedAt` / `lastUpdatedAt` come from an explicit summary-generation timestamp supplied by the caller (`inputs.summaryGeneratedAt`); the briefing does not invent one. | When the briefing picks the first failed compliance check for the `compliance-failed` recommended action and for the compliance-failure attention item, it skips failed checks that are awaiting data and failed checks that `isNonBlockingReviewFailure` identifies as non-blocking review failures. --- --- title: "Applicant Portal — Eligibility Surface" slug: applicant-portal-eligibility-surface section: Applicant Journey status: draft description: "A draft stub for the portal-side surface that renders Globus Rules Engine eligibility decisions." last-updated: 2026-10-07 --- # Applicant Portal — Eligibility Surface A draft stub for the portal-side surface that renders Globus Rules Engine eligibility decisions. This page intentionally captures only what is already settled, plus the open questions the applicant portal team still needs to answer. It is a placeholder for a future spec, not the spec itself. > **Draft — placeholder for future scoping** > > Nothing on this page is final. The contract shape, copy slots, and rendering behavior are sketched only well enough to anchor the next conversation. Treat every TBD as a real open question, not a default. > **The engine this surface reflects is LIVE (verified 2026-07-04)** > > The portal surface is unbuilt (PPPToo's only eligibility controller serves the SWT national-window and date-eligibility calls (POST /api/national-window/get, /api/national-window/list, /api/eligibility/calculate), not Rules Engine decisions, and its context read is GET /me/context), and the PRD confirms AP-8 is PENDING SCOPING. But the Globus Rules Engine the surface will reflect is substantial and shipping: server/services/eligibilityRulesEngine.ts plus the `rulesEngine*.ts` helpers in server/services/, shared models in shared/models/rules-eligibility.ts, rule templates / parameter schemas / catalogs / tree schema / risk classification under shared/, staff override routes in server/routes/admin/eligibilityOverrides.ts and the pool view in server/routes/eligibilityPool.ts. The draft payload contract below is the template the PRD's SWT eligibility display defers to — when refining it, align field names to the PRD's outcome / reasonCodes[] / nextStep spelling so the two drafts don't diverge. See Rules Engine . ## Scoping Status > **Asana scoping ticket filed for the Applicant Portal team** > > A scoping ticket has been filed in the AP-8 project on Asana in the canonical house format: Applicant Portal: AP-8 eligibility surface — PRD scoping pass . The ticket carries the architectural rule, the draft payload contract, and the Gherkin acceptance criteria the new PRD section must satisfy. The consolidated PRD update brief for the AP team lives at docs/applicant-portal-prd-update-brief.md. ## Architectural Principle Globus Rules Engine decides, the applicant portal reflects. The portal never computes eligibility, never re-implements predicates, never applies its own thresholds, and never overrides a Globus decision client-side. Its only job is to render what Globus returns — outcome, reason codes, human-readable copy, and any next-step CTA — in a brand-aware, applicant-appropriate way. For the authoritative definition of rule definitions, the five-layer evaluation pipeline, decision classes, and the reason code catalog, see the Rules Engine specification . That page is the source of truth; this page only describes how the applicant portal consumes its output. ## Open Scoping Questions These are the questions the applicant portal team needs to answer before this page becomes a real spec. Each is marked TBD on purpose — do not infer a default. - Does the applicant see a simple pass/fail result, or something richer? — TBD - Do we expose a per-rule breakdown (which checks passed, which failed), or only the aggregate outcome? — TBD - When a decision blocks, do we surface the blocking reason codes plus next-step copy, and at what level of detail? — TBD - Is the applicant-facing copy brand-aware (per Camp / Teach / Work & Travel), and who owns the copy slots? — TBD - What next-step CTAs are allowed (upload document, contact sponsor, retry later, none), and who decides which fires? — TBD ## Draft Payload Contract At draft level — not finalized — the portal expects Globus to return an eligibility result with at least the following surface fields. Field names mirror the Rules Engine output contract; the slots below are what the portal needs in order to render anything at all. | Field | Shape | Purpose | | --- | --- | --- | | status | enum | Outcome class from the engine (e.g. pass / fail / hold / allow_with_warning). Drives the headline state the portal renders. | | reason_codes[] | string[] | Machine-readable codes from the Rules Engine reason code catalog. Portal never invents codes — it only renders what Globus returns. | | applicant_copy | string (slot) | Human-readable, applicant-facing copy slot. Brand-aware variant resolution TBD. | | next_step_cta | object (slot) \| null | Optional next-step CTA descriptor (label + action hint). Allowed action set TBD. | | decision_id | string | Engine decision identifier for support reference and deterministic replay correlation. | | evaluated_at | timestamp | When the engine evaluated. Portal displays freshness; does not recompute. | > **Draft only** > > This shape is a starting point so the team has something concrete to argue with. The final contract will be co-designed with the Rules Engine output contract owners and ratified on this page once the open questions above are closed. ## Out of Scope on This Page - Rule authoring UI — lives in the Rules Engine spec and the Compliance Console. - The reason code catalog itself — owned by the Rules Engine reason code catalog . - Any decision logic, predicate evaluation, or threshold configuration — the portal never decides. - Final UI design, copy, and brand-specific messaging treatment. - Implementation or wiring — this page is documentation only. **Existing PPPToo window endpoints.** `POST /api/national-window/get`, `POST /api/national-window/list` and `POST /api/eligibility/calculate` sit behind the applicant JWT guard at those literal paths (no `/v1` prefix) and report the outcome in a `code` field in the response body; missing data never produces an error status. They resolve the caller's active application to a program and season, then ask Globus for the country's Summer Work Travel window. `national-window/get` (body `countryCode`): `MISSING_COUNTRY` when `countryCode` is absent, `NOT_FOUND` when there is no active application or no window on record for the country, otherwise `OK` with the formatted window and a callout message. `national-window/list` returns `NOT_FOUND` without an active application, otherwise `OK` with a `windows` map of every country that has a window. `eligibility/calculate` (body `countryCode`, `startDate`, `endDate`, all required): `ERROR` when a field is missing, there is no active application or no window is on record; `NO_OVERLAP` when the declared vacation dates do not overlap the window (or the end precedes the start), with a `warningMessage` and an `auditSnapshot`; `ELIGIBLE_WINDOW_CALCULATED` otherwise, with `threeLineDisplay`, `eligibilityWindow` (the declared dates clipped to the window) and `auditSnapshot`. **Flow availability reason `PROGRAM_CAPABILITY_DISABLED`.** In the Hub's available-flows surface (`GET /api/integrations/applicant-portal/applicants/:applicantId/available-flows`), a renewal, extension or J-2 flow reports `blockedReason` and `reasonCode` `PROGRAM_CAPABILITY_DISABLED` when the program flag for that flow is off (`programs.renewalsAllowed`, `programs.extensionsAllowed` or `programs.j2Allowed`). It has the highest precedence: capability, then `DUPLICATE_FLOW_IN_FLIGHT`, then `FLOW_WINDOW_CLOSED`, then `APPLICANT_INELIGIBLE`. `POST /api/portal/applications` refuses a renewal or extension for a program with that flag off with the same reason code. --- --- title: "Applicant Portal — Payments & Application Fees" slug: applicant-payments section: Applicant Journey status: draft description: "The portal-side surface that sits on top of the finance model." last-updated: 2026-10-07 --- # Applicant Portal — Payments & Application Fees The portal-side surface that sits on top of the finance model. The applicant portal reflects payment state and collects payment input — it never holds money, computes fees, or owns accounting truth. > **Three-System Anchor** > > NetSuite is the system of record for invoices, payments, and refunds. Globus caches payment status and decides what should be charged, who pays, and when. The applicant portal renders whatever Globus returns and forwards payment intent back. If a value is not on the cached fee object, the portal does not invent it. ## As-Built Globus Finance Backend (2026-07-04) > **Backend LIVE, portal surface unbuilt — and several table claims below are wrong** > > The Globus finance backend is shipping; the applicant-facing fee card is not (no /api/applicant/payments* route exists and PPPToo has no payments controller; AP-22 is PENDING SCOPING). Corrections to the field sourcing below: finance_rate_cards, finance_payment_plans and finance_payment_plan_schedules do not exist; the catalog column is display_name (not name); charges carry charge_status (not status) and there is no per-charge paidToDate — paid/outstanding amounts live at summary level. The page's initiated/processing/paid/failed/refunded lifecycle is a portal-facing abstraction; map it to the stored values before freezing the contract. | Piece | As built (shared/models/documents-finance.ts + server/routes/finance.ts) | | --- | --- | | Tables | finance_account_parties, finance_charge_catalog, finance_invoice_refs, application_finance_summary, application_charges, finance_payment_refs | | application_charges | chargeCatalogId, payerPartyId, amount (integer minor units), currency, quantity, chargeStatus (default PLANNED), triggerEventKey, dueDate, netsuiteInvoiceRef, metadata | | finance_charge_catalog | chargeKey (unique), displayName, defaultAmount, defaultCurrency, taxable, netsuiteItemId, triggerEventKey | | finance_payment_refs | paymentGateway (default MANUAL; Stripe/PayPal named as external gateways), gatewayRef, paymentStatus (default PENDING), paidAt, rawPayload | | Status enums | Summary financeStatus: NOT_BILLABLE_YET \| INVOICED \| PARTIALLY_PAID \| PAID (server/services/financeSummary.ts); non-settling payment statuses FAILED/VOIDED/VOID/CANCELLED/REFUNDED; summary carries paymentModel (default SELF_PAY), amountDue/Paid/Outstanding, lockReason | | Staff routes | GET/PUT /api/applications/:id/finance-summary; GET/POST /api/applications/:id/charges; POST /api/applications/:id/payment-requests; PATCH /api/charges/:id; GET /api/applications/:id/invoices; GET/POST /api/applications/:id/payments; GET /api/applications/:id/finance-audit; GET/POST /api/finance/charge-catalog (+PATCH /:id); GET/POST /api/finance/account-parties; POST /api/finance/bulk-import; GET /api/finance/overview | | NetSuite sync | server/routes/netsuiteFinanceSync.ts (n8n: invoice-sync + payment-sync); catalog seed server/seed/financeChargeCatalog.ts | ## Verified CyberSource Pay-by-Link Handoff The July 15, 2026 sandbox validation fixed the provider and delivery contract for **invoice payment links**, even though the applicant-facing portal surface remains unbuilt. The existing `application.created` event is the invoice trigger: the canonical dispatcher routes it to the Application Domain handler, whose new dedicated delivery branch creates the invoice. The separately emitted `applicant.created` event and its NetSuite contact-upsert workflow are not an invoice-readiness signal and must not be joined to this flow. The NetSuite create and read responses have different responsibilities. A successful `POST /services/rest/record/v1/invoice` returns HTTP `204 No Content`; its `Location` header identifies the saved invoice.[1] n8n then calls `GET /services/rest/record/v1/invoice/{internalId}?fields=...`; the expected hosted URL comes from the HTTP `200` JSON body field `custbody_cs_pymt_inv_paymentlink`, together with the CyberSource status and error fields.[2] The create response itself does not contain the payment link. n8n synchronizes the approved URL into Globus as `paymentLink` and emails the payer only after Globus has persisted the same link. NetSuite's standard `paymentlink` field is not a valid fallback. | Boundary | Required behavior | | --- | --- | | Globus trigger | Reuse `application.created` in the canonical dispatcher envelope; derive a deterministic invoice `externalRef`; do not depend on `applicant.created` delivery order. | | NetSuite create response | Return HTTP 204 and a `Location` header identifying the saved invoice; no JSON payment-link body is expected. | | NetSuite saved-record response | Return HTTP 200 JSON containing `custbody_cs_pymt_inv_paymentlink`, `custbody_cs_pymt_inv_invoicestatus`, and `custbody_cs_pymt_inv_errormessage`; tolerate a pending link after initial save. | | n8n | Parse the invoice ID from `Location`, poll the saved-record GET with bounded backoff, and require an approved CyberSource HTTPS host, payable state, blank SuiteApp error, positive amount due, and validated recipient. | | Globus | Persist the link as `hosted_payment_url` (wire input `paymentLink`), its `payment_link_fingerprint`, `cybersource_status`, and `cybersource_error` additively on `finance_invoice_refs`; omitted fields must not clear a previously valid link. | | Email delivery | Claim a durable semantic delivery key, invoke the shared SendGrid workflow, and mark delivery accepted only when SendGrid returns HTTP 202. | | Portal | May later render a Pay Now link from the Globus-cached invoice object; it must never synthesize, rewrite, or infer a provider URL. | CyberSource `deliveryMode=None` is deliberate: CyberSource publishes the hosted form but does not email the applicant. n8n and SendGrid own that communication. Link creation and form rendering are proven; card authorization, settlement, refund, and reconciliation remain outside this specification and must not be inferred from the onboarding result. See [Finance & NetSuite Integration](./finance-netsuite#phase-3-cybersource-pay-by-link-delivery) for the normative workflow, persistence, idempotency, callback, test, and deployment requirements, and [CyberSource Pay-by-Link Sandbox Onboarding Record](./cybersource-pay-by-link-onboarding) for the complete evidence account. ## Scoping Status > **Asana scoping ticket filed for the Applicant Portal team** > > A scoping ticket has been filed in the FN-3 / AP-22 project on Asana in the canonical house format: Applicant Portal: AP-22 payments & application fees — PRD scoping pass . The ticket carries the three-system anchor, the fee-card surface, the lifecycle states, the Payment Gate interaction, the draft data contract, and the Gherkin acceptance criteria the new PRD section must satisfy. The consolidated PRD update brief for the AP team lives at docs/applicant-portal-prd-update-brief.md. ## Purpose & Scope This spec defines the applicant-facing payment surface for AP-22: where it appears in the journey, what the applicant sees, what actions they can take, the lifecycle states surfaced to them, and how the portal interacts with Payment Gates that block compliance milestones until payment clears. It does not define the checkout UI or back-office finance workflow. CyberSource is the verified hosted-form provider for invoice Pay-by-Link; n8n and SendGrid are the verified email-delivery path. ## System of Record Anchor See Finance & NetSuite Integration for the full model. The relevant boundaries for the portal: NetSuite System of record. Holds invoices, payments, refunds, receipts, and accounting journals. Source of truth for status. Globus Caches payment status from NetSuite. Decides what to charge (rules + rate cards) and which compliance milestones a charge gates. Emits notifications on state change. Applicant Portal Reflects cached status, surfaces fee cards, collects payment intent, and forwards to Globus. Holds no money, computes no totals, owns no accounting state. ## Where the Payment Surface Appears The Payments tab is a persistent surface in the applicant portal, available from the moment the first charge is created. Inline fee cards also appear within phase-specific surfaces when a charge gates the next compliance milestone — see Applicant Lifecycle . ### Trigger Events | Phase | Lifecycle Event | Charge Created | Where the Card Renders | | --- | --- | --- | --- | | Phase 3 — Interview | APPLICATION_ACCEPTED | Program Fee (PROGRAM_FEE) | Payments tab + inline banner on the post-approval landing. | | Phase 5 — Visa & SEVIS | sevis.registration | SEVIS Fee (SEVIS_FEE) | Payments tab + inline card on the Visa & SEVIS surface, blocking SEVIS submission readiness. | | Phase 4 — Placement | insurance.enrollment / housing.assigned | Insurance / Housing deposits (provider-dependent) | Payments tab + inline card on the placement confirmation surface. | | Phase 7 — In Program (Extension) | extension.approved | Extension Fee (EXTENSION_FEE) | Payments tab + inline card in the extension confirmation flow. | ## Applicant-Facing Surface The atomic unit is the fee card — one card per outstanding or recently-resolved charge. Cards stack on the Payments tab in due-date order and may also render inline within phase-specific surfaces when a charge gates the next step. ### Fee Card Fields | Field | Source | Display | | --- | --- | --- | | field | source | display | _(row above is a template, repeated for each data row)_ ### Fee Card Actions | Action | When Visible | Behavior | | --- | --- | --- | | action | visibility | behavior | _(row above is a template, repeated for each data row)_ ## Payment Lifecycle States Five states surfaced to the applicant. The portal does not transition state itself — every change is observed via a refreshed fee object from Globus, which in turn caches NetSuite truth. initiated → processing → paid failed refunded | State | Meaning | | --- | --- | | | meaning | _(row above is a template, repeated for each data row)_ ### State Copy Slots Each state owns a single copy slot rendered on the fee card. Variables in braces are substituted from the fee object — no other dynamic copy is allowed in v1. | State | Card Title | Body Slot | | --- | --- | --- | | state | title | body | _(row above is a template, repeated for each data row)_ ### Portal Behavior on Transition | From | To | Portal Action | | --- | --- | --- | | from | to | portalDoes | _(row above is a template, repeated for each data row)_ > **Notification Ownership** > > The portal does not send notifications on payment transitions. Notifications are emitted by Globus per the catalog in Notification Model. The portal’s only responsibility on transition is the visible UI change. ## Payment Gate Interaction Some compliance milestones in the applicant lifecycle are gated by payment status. The gate decision is made by Globus (per Compliance Payment Gates); the portal’s job is to communicate the blocked state clearly and unblock the moment Globus reports paid. ### Blocked-on-Payment State - The phase action button (e.g., “Submit for SEVIS”) is disabled with a tooltip: “Payment of required before this step.” - An inline fee card renders directly above the disabled action, with a primary “Pay Now” button. - The phase progress indicator surfaces a “Waiting on payment” sub-state — see the lifecycle stepper. - The Payments tab badge increments to reflect the outstanding charge. ### Unblock Flow - Applicant pays via the surface defined by the chosen provider (Phase 3+). - NetSuite confirms the payment and updates Globus via the cached payment ref. - Globus flips application_charges.charge_status to paid and emits the gate-release event. - On the next event/webhook-driven refresh the fee card transitions to the paid state and the gated action becomes enabled. - Portal renders a one-time confirmation toast referencing the unlocked next step. > **Portal Never Self-Unblocks** > > Even if the applicant has just clicked Pay and the gateway returns success, the portal does not unblock the gated action until Globus confirms the cached status is paid. This prevents the portal from getting ahead of NetSuite truth on slow webhook delivery. ## Draft Data Contract The portal expects a single Globus endpoint to return all fee cards for the current application, and a second endpoint to accept payment intents. Field names are draft and will harden once the provider is chosen. ### Fee Object (Globus → Portal) Endpoint: GET /api/applicant/payments — returns . | Field | Type | Notes | | --- | --- | --- | | field | type | desc | _(row above is a template, repeated for each data row)_ ### Payment History Item (Globus → Portal) | Field | Type | Notes | | --- | --- | --- | | field | type | desc | _(row above is a template, repeated for each data row)_ ### Payment Intent Request (Portal → Globus) Endpoint: POST /api/applicant/payments/intent — returns a provider-specific redirect or client secret. Globus owns the call to the gateway; the portal never talks to the gateway directly. | Field | Type | Notes | | --- | --- | --- | | field | type | desc | _(row above is a template, repeated for each data row)_ ## Open Questions Tracked here so the team has a single place to look. None of these block drafting the portal contract; all of them harden the contract once decided. ## Out of Scope ## Cross-References - Finance & NetSuite Integration — system of record, charge catalog, rate cards, payment gates. - Applicant Lifecycle — phase placement and the gated milestones the payment surface unblocks. - Notification Model — who fires payment-related notifications (not the portal). ## References [1]: https://docs.oracle.com/en/cloud/saas/netsuite/ns-online-help/section_1545141395.html "Oracle NetSuite — Creating a Record Instance" [2]: https://docs.oracle.com/en/cloud/saas/netsuite/ns-online-help/section_1545141500.html "Oracle NetSuite — Getting a Record Instance" --- --- title: "Applicant Portal — My Documents & Resources" slug: applicant-portal-surfaces section: Applicant Journey status: draft description: "The Applicant Portal renders two content surfaces — My Documents and Program Resources." last-updated: 2026-10-07 --- # Applicant Portal — My Documents & Resources The Applicant Portal renders two content surfaces — My Documents and Program Resources. This page is the single normative statement of what each surface shows and, more importantly, who decides what is shown. The answer for both is the same: Globus decides, the portal renders. The portal holds no hard-coded list of documents or resources — it presents exactly what Globus supplies for the applicant’s program, season, lifecycle stage, and rule outcomes. > **Content gap — unauthored lists and tables** > > The conversion of this page left several lists and tables as empty templates. The Document Set, Resource Types and both Supplies-vs-Renders tables hold a single placeholder row, and the lists under Governing Globus Systems, Configured by Program, Season & Stage, Conditional-Rendering Inputs and Related Pages are empty. The principles and the status split above are authoritative; the missing enumerations still need their owner to author them. For the live My Documents routes see [Applicant Portal Endpoints](/docs/applicant-portal-endpoints). > **Status split (verified 2026-07-04): My Documents LIVE — Program Resources DESIGN-ONLY** > > My Documents is live and now fully Globus-backed. As of BOP PRs #1310/#1312/#1313 (header-authenticated portal document routes) the PPPToo controller (documents.controller.ts, JWT-guarded) proxies list, required-documents catalogue, upload (images up to 10MB, documents up to 20MB; PDF/DOC/DOCX and JPEG/PNG/GIF/WebP/HEIC/HEIF), versions and download straight to Globus with ownership enforced server-side; the interim GCS store survives only as a read fallback for legacy ppp2/ document ids. List items carry id, fileName, documentType (default 'general'), status (default 'pending_review'), downloadUrl, createdAt, official, rejectionReason; skill certifications post documentType='skill-certification' (AP-5). Note the portal's real status vocabulary — the Missing/Uploaded/Verified/Rejected/Expired chip set described below is the design intent, not the wire values. Official documents (PPPToo #319, #378, verified at development HEAD): the BFF list omits any DS-2019 (documentType 'ds-2019') whose status is not 'verified', marks a verified DS-2019 official, and returns downloadUrl as an authenticated BFF path only when Globus supplied a same-origin /api/portal/documents/:id/download path (otherwise null). The download route re-reads the applicant's own list, validates that exact path and streams it; an absent document, an unverified DS-2019 and an upstream 404 all return the same 'Document unavailable' 404. The Documents page shows 'Official program documents' in labelled groups — DS-2019 (View / Download, or 'Your DS-2019 is not available yet.'), Signed agreements (signed-agreement and terms-acceptance rows, or 'No signed agreements are available yet.'), and Other official documents (only when present). Signed-agreement rows show a readable display title derived from the filename (e.g. Camp Counselor Participant Agreement) with the original filename kept as the hover title and the download filename, plus a status pill (an unrecognised status shows 'Status unavailable'); a row with no download URL shows no View/Download actions. Globus now also supplies presentation metadata for these rows (BOP #2429, verified at development HEAD in server/services/portalDocumentProjection.ts): GET /api/portal/applications/:appId/documents (and documents-by-step) returns displayName ('Signed Agreement' for the signed-agreement type, 'DS-2019' for ds-2019) and presentationGroup 'official-program-documents' on those rows, where Globus's official flag stays true only for signed agreements (with createdAt set to the acceptance's signing date) and is false for DS-2019; Host Agreement classification is unchanged. Program Resources has no backend: PPPToo has no resources controller and the backing Globus Resource Library is entirely unbuilt — treat that half of this page as target-state. > **Architecture Principle — Globus decides, the portal renders** > > The Applicant Portal does not decide what documents or resources to show. It is a rendering surface. Globus — through Workflow Studio, the Rules Engine, Document Management, and the Resource Library — computes the authoritative set of document slots and program resources for each applicant based on their program, season, lifecycle stage, and rule outcomes, and the portal renders that set faithfully. Any list baked into portal code is a bug, not a contract. ## Architecture Principle Every surface in the applicant experience is downstream of Globus configuration. Two consequences follow, and they apply to both surfaces on this page: - No portal-side catalogs. The portal must not maintain its own list of “documents a J-1 needs” or “resources to show before departure.” Those lists are resolved server-side and delivered to the portal per request. - Content changes without portal deploys. Because the lists come from the published workflow version, the active rule bundle, and the Resource Library, a program admin can change what an applicant sees by editing configuration — not by shipping portal code. - Same applicant, different surface over time. As the applicant advances through lifecycle stages, slots and resources appear or unlock. The portal re-renders whatever Globus currently resolves; it does not gate stages itself. ## The Two Surfaces My Documents Documents and evidence the applicant provides (plus sponsor-issued artifacts they receive), with per-document review status. Source of truth: Globus via Workflow Studio, Rules Engine, and Document Management. Program Resources Program-published handbooks, orientation videos, insurance information, and placement information packs — read-only for the applicant. Source of truth: Globus Resource Library, configured by Program, Season, and Lifecycle Stage. ## My Documents “My Documents” is the applicant-facing view of the documents and evidence tied to their participation. It is a rendering of the document slots Globus has determined this applicant must satisfy, plus any sponsor-issued artifacts they receive. The applicant uploads, re-uploads rejected items, and downloads issued documents — but the slot list itself is owned by Globus, not the portal. ### The Document Set The full set spans applicant-uploaded evidence, sponsor-issued artifacts, and the existing database-driven categories. Which of these appears for any given applicant is resolved from their program, season, stage, and rule outcomes — this is the superset, not a checklist every applicant sees. | Document | Owner | Scope | Notes | | --- | --- | --- | --- | | doc | | scope | notes | _(row above is a template, repeated for each data row)_ > **Owner is not the same as uploader** > > Most documents are applicant-owned and applicant-uploaded, but the DS-2019 is Globus-issued — the applicant receives and downloads it, never uploads it. The portal shows a DS-2019 only once it is verified; an unverified DS-2019 is neither listed nor downloadable. Signed contracts are co-owned: Globus presents the agreement and captures the signed artifact. The portal must render an upload affordance only for slots the applicant is responsible for providing. Issuance timing is governed host-side: the DS-2019 slot stays unissued while the placement host fails the issuance guard — for camp placements that includes hosts still pending verification (refused with 403 `HOST_VERIFICATION_REQUIRED` even though the host is otherwise operational). The portal simply renders the slot as not-yet-issued; the gate itself is specified on Host Portal Governance → Operating Agreement (DS-2019 Guard) and Camp Program Divergence. ### Governing Globus Systems Three Globus systems jointly determine the My Documents surface. None of this logic lives in the portal: ### Carried-Forward Schema Gap > **No applicant-scoped documents table — reported, not solved** > > The conceptual “My Documents” surface implies identity-bound documents that follow the person across re-applications. Today they do not. The documents table (applicant_documents) requires applicationId (.notNull()), so every document is bound to a single application attempt. There is no person-level table backing the identity-bound design, so passport scans and other identity artifacts cannot be reused across attempts without manual recopy. This page documents the surface honestly: the gap is carried forward, not closed. See the audit on Applicant vs Application Data and the storage callout on Document Storage . ## Program Resources “Program Resources” (the Resource Library surface) is the applicant-facing view of program-provided reference material — content the program publishes to the applicant rather than collecting from them. Unlike My Documents, this surface is read-only for the applicant: there is no upload, no review state, only consumption. ### Resource Types | Resource Type | Description | Example | | --- | --- | --- | | type | desc | example | _(row above is a template, repeated for each data row)_ ### Globus Resource Library The source of truth for Program Resources is the Globus Resource Library — a program-managed catalog of resources. The portal queries Globus for the resolved resource set and renders it; it does not host, version, or curate resource content. As with documents, access to resource files flows through Globus-signed, short-lived proxy URLs — the portal never receives a raw storage path. > **Surface contract here — deeper engineering spec on its own page** > > This page establishes the surface-level contract for Program Resources: what the surface shows and that the Resource Library is its source of truth. The deeper engineering spec — the Resource Library data model, the admin authoring surface programs publish through, versioning, and the Program/Season/Lifecycle-Stage keying — lives on Globus Resource Library . ### Configured by Program, Season & Stage The Resource Library is keyed on three axes. The same applicant sees a different resource set as any of them change: ## Rendering Contract For each surface the division of responsibility is explicit: Globus supplies the resolved, authoritative set and all access tokens; the portal renders that set and handles the user interaction. The portal never substitutes its own judgement for Globus’s. ### My Documents — Supplies vs Renders | Globus supplies | Portal renders / is responsible for | | --- | --- | | globus | portal | _(row above is a template, repeated for each data row)_ ### Program Resources — Supplies vs Renders | Globus supplies | Portal renders / is responsible for | | --- | --- | | globus | portal | _(row above is a template, repeated for each data row)_ ### Conditional-Rendering Inputs Both surfaces are driven by the same four inputs. These are the only things that change what an applicant sees — and all four are evaluated by Globus, not the portal: ## Related Pages --- --- title: "Applicant Portal — Completion & Departure" slug: applicant-portal-completion section: Applicant Journey status: draft description: "The portal-side surface for AP-12 — what the applicant sees, does, and submits during program close." last-updated: 2026-10-07 --- # Applicant Portal — Completion & Departure The portal-side surface for AP-12 — what the applicant sees, does, and submits during program close. > **Scope** > > This page is the applicant-portal-side expression of Phase 8. It documents the surface the applicant sees during closeout — the trigger, the four-step flow, the post-completion state, and the portal’s role relative to SEVIS closure. It does not redefine the ecosystem-level lifecycle (see Applicant Lifecycle — Phase 8 ), and it does not design the staff-side closeout workflow or the exit survey questions themselves. > **Status check (verified 2026-07-04) — portal flow NOT built; Globus infra to reuse IS live** > > AP-12 is PENDING SCOPING in the PRD, and none of the portal endpoints this page reuses exist yet: PPPToo's check-ins controller is read-only (GETs for recurring current-season/season and templates — no submission POST, no checkin_type=final) and the portal has no travel or completion controller. Present the "reuses the monthly check-in / travel-request endpoints" claims below as proposed. The Globus side has real infrastructure the build will lean on: server/routes/checkins/monthlyCheckins.ts, checkins/templates.ts, checkins/sevisCheckins.ts, and the DS-2019/SEVIS routes (server/routes/ds2019.ts). One PRD reconciliation to settle at scoping: PRD AP-12B names Step 4 Close-Out Confirmation, while this page's Step 4 is Exit Survey / Program Evaluation. The program.completed trigger event and closeout_active flag remain unconfirmed against any Globus event catalog. ## Scoping Status > **Asana scoping ticket filed for the Applicant Portal team** > > A scoping ticket has been filed in the AP-12 project on Asana in the canonical house format: Applicant Portal: AP-12 program completion & departure — PRD scoping pass . The ticket carries the architectural rule, the four-step flow, the post-completion state, and the Gherkin acceptance criteria the new PRD section must satisfy. The consolidated PRD update brief for the AP team lives at docs/applicant-portal-prd-update-brief.md. ## Purpose & Scope AP-12 is the closeout surface inside the Applicant Portal. It exists so the applicant has a single, guided place to confirm their departure, file their final check-in, register return-travel details, and complete a program evaluation — without the staff team having to chase any of those four artefacts manually. Everything on this page is portal behavior. Globus owns the underlying state machine, the SEVIS closure submission, and the program.completed event. The portal consumes those signals; it does not produce them. In scope - Trigger that activates the closeout surface in the portal - Four applicant-facing steps and the data each collects - What gets pushed to Globus per step (endpoint reuse, payload shape) - Post-completion read-only archive view - Portal’s role relative to SEVIS closure Out of scope - Designing the actual exit-survey questions - Defining new Globus events — consumes whatever Globus emits today - Staff-side closeout workflows and queues - Re-engagement / alumni flows after completion - SEVIS submission mechanics (see SEVIS & Government) ## Trigger — When the Surface Appears The closeout surface is hidden until Globus tells the portal to show it. The portal does not compute “is this applicant near the end of their program” on its own — that decision belongs to Globus and is delivered as an event consumed by the portal. | Aspect | Detail | | --- | --- | | Source | A Globus-emitted lifecycle event. Exact event name and payload TBD — to be confirmed against the Globus event catalog before implementation. Working assumption: an event tied to the program end date entering the configured closeout window (e.g., 14 days out). | | Portal behavior on receive | The portal flips a closeout_active flag on the application view, surfaces a new “Program Completion” nav entry, and renders the four-step flow described below. | | Idempotency | Re-receiving the same trigger event is a no-op. The portal does not reset already-submitted steps. | | Deactivation | The closeout surface is replaced by the post-completion state when program.completed fires for the application. See Post-Completion Portal State. | ## In-Portal Completion Flow The flow is rendered as four numbered steps. Steps unlock sequentially — the applicant cannot skip ahead — with the exception of Step 4 (exit survey), whose required-vs-optional status is currently a TBD (see Open Questions ). All four steps reuse existing Globus endpoints documented elsewhere; this page does not introduce new write surfaces. ## Post-Completion Portal State When Globus fires program.completed for the application, the portal replaces the closeout surface with a read-only archive view. The applicant’s portal account remains active but the application itself is frozen. Still accessible (read-only) - Application summary (program, dates, host placement) - Submitted documents and uploaded itinerary - Check-in history including the final check-in - Submitted exit-survey response (the applicant’s own answers) - DS-2019 / SEVIS status as reported by Globus No longer present - The four-step closeout flow (replaced by a single “Program completed” banner) - The Travel Request entry point for this application - The Monthly Check-In entry point for this application - Any “action required” nudges — the application is terminal > **Application-scoped, not account-scoped** > > The post-completion state is scoped to the application — one attempt at one program in one season — per the canonical boundary on Applicant Data vs Application Data . The applicant’s account remains active. If the applicant later starts a new application (a separate attempt), that application begins fresh in Phase 1 and is unaffected by the completed application’s archived state. ## SEVIS Closure Interaction > **The portal does not own SEVIS closure** > > SEVIS closure is owned by Globus and submitted to the government via the SEVIS service layer. The portal only reflects the SEVIS status that Globus reports. The portal never calls SEVIS, never waits on a SEVIS response, and never blocks an applicant action on SEVIS state. - The applicant’s portal view shows the SEVIS status string verbatim from Globus (e.g., Active, Completed, Terminated) on the application summary card. - The four closeout steps do not wait for SEVIS confirmation. The applicant can complete all four steps before Globus has submitted closure to SEVIS. - If SEVIS closure fails on the Globus side, the portal continues to show the last known SEVIS status. Resolution is a staff-side concern, not a portal-side concern. - For the actual SEVIS closure mechanics — reason codes, batch submission, certificate auth, J-2 cascade — see the dedicated reference page. Cross-link: SEVIS & Government — Termination Processing . ## Open Questions / TBD The following questions are unresolved as of this draft. Each one is logged here so future scoping has a single home and PRD authors do not need to re-derive them from scratch. ## Cross-References - Applicant Lifecycle — Phase 8 (Completion) : the ecosystem-level definition of completion, including states (Completed, Early Termination, No Show), gate-out, and actors. - Applicant Lifecycle — Program Completion Flow : the staff-side closeout view that this portal surface feeds into. - SEVIS & Government — Termination Processing : the SEVIS closure mechanics this portal surface deliberately does not own. - Applicant Lifecycle — Monthly Check-In System : the check-in pipeline that Step 2 (final check-in) reuses with checkin_type=final. - Applicant Lifecycle — Travel Request Flow : the travel-request pipeline that Step 3 (return travel) reuses with travel_type=return. --- --- title: "Globus Resource Library" slug: resource-library section: Applicant Journey status: draft description: "The Globus Resource Library is the source of truth behind the Program Resources surface of the Applicant Portal — the program-managed catalog of handbooks, orientation videos, insurance information, and placement packs that programs publ…" last-updated: 2026-10-07 --- # Globus Resource Library The Globus Resource Library is the source of truth behind the Program Resources surface of the Applicant Portal — the program-managed catalog of handbooks, orientation videos, insurance information, and placement packs that programs publish to applicants. The surface page establishes what the surface shows and that the library is its source of truth; this page is the deeper engineering contract: the data model, the admin authoring surface programs use to publish, versioning, and how every resource is keyed to Program, Season, and Lifecycle Stage. > **NOT IMPLEMENTED (verified 2026-07-04) — proposed engineering spec only** > > Nothing on this page exists in code yet. Verified absences in the Globus Hub: no resource / resource_version / resource_binding tables, no /api/admin/resources* or /api/applicant/resources* routes, no resource.manage RBAC permission, and no resource.* audit vocabulary. PPPToo has no resources controller — the only "handbook"/"orientation" strings in the portal are UI labels. The PRD references a "Dynamic Resource Library" as a concept only. The signed-URL binary pattern this spec assumes does exist for documents (PPPToo documents pipeline + Globus portal document routes) and is the proven pattern to extend when this is picked up. Every table, endpoint, enum and the resolution-preview tool below is a proposal. > **Architecture Principle — programs publish, the portal renders** > > The Resource Library is a Globus-owned catalog. Program admins author and publish resources and bind them to a program, season, and (optionally) a lifecycle stage. Globus resolves the applicable set per applicant at request time; the portal renders that set faithfully. The portal never hosts, versions, or curates resource content, and never holds a hard-coded list of which resources belong to which program or stage. Any such list baked into portal code is a bug, not a contract. ## Architecture Principle The library exists so that content changes without portal deploys. A program admin can publish a new handbook, swap an orientation video, or unlock a placement pack by editing the catalog — not by shipping portal code. Three properties follow: - Globus-owned catalog. Resources, their versions, and their bindings all live server-side. The portal queries for the resolved set per applicant. - Bindings, not hard-coded lists. Which resources an applicant sees is the result of matching bindings against the applicant’s program, season, and stage — never a list in portal code. - Immutable artifacts, mutable pointers. Published versions never change; publishing a new version moves the live pointer, preserving a complete, auditable history. ## Data Model Three tables carry the library. A resource is the stable catalog entry (identity + type); a resource_version is an immutable published artifact under that entry; a resource_binding keys the entry to a program, season, and lifecycle stage. Resolution always reads a resource’s current published version, so bindings never need to be re-pointed when content changes. ### resource — the catalog entry ### resource_version — the published artifact ### resource_binding — the keying > **Bindings point at the resource, not a version** > > A binding references the resourceId, never a specific versionId. That is deliberate: publishing a new version updates what every existing binding serves, without anyone having to touch the bindings. The version pointer is resolved at read time. ### How the tables relate - One resource has many resource_version rows; at most one is in the published state. - One resource has many resource_binding rows — the same handbook can be bound to several seasons or stages. - Resolution joins bindings → resource → current published version, so a single publish fans out to every place that resource is surfaced. ## Program / Season / Lifecycle-Stage Keying Every binding answers three questions: which program, which season, and at which stage a resource appears. Program is required; season and stage are optional, and leaving them null widens the binding’s reach. ### The Three Binding Axes ### Resolution Order & Specificity At request time Globus collects every binding that matches the applicant’s context — a resource surfaces if its binding is satisfied. Matching is permissive on the null axes: - Program must match exactly. A binding only resolves for its own program. - Null season matches any season; a set season matches only that season. - Null stage matches any stage; a set stage resolves only once the applicant has reached (or passed) it. Stage-gated resources are hidden until then. - The resolved set is the union of all matching bindings, de-duplicated by resource so the same handbook bound at both program- and season-level appears once. > **Stage gating is evaluated by Globus, never by the portal** > > Whether a placement pack is unlocked is a Globus decision keyed off the applicant’s current lifecycle stage. The portal must not hold its own “show after placement” logic — it renders only what resolution returns. See the Applicant Lifecycle for the canonical stage set. ## Admin Authoring Surface Programs publish resources through an admin authoring surface in the sponsor tooling — a catalog manager where staff create a resource, upload (or embed) its artifact, set its bindings, and publish. This is the only place resource content enters the system; the portal is strictly downstream. Before publishing, staff can verify their bindings with the Resolution Preview — a live view of exactly which resources a given program/season/stage applicant would see. ### Who Authors Program administrators and authorized sponsor staff author resources, gated by the standard RBAC model — see Roles & Permissions . The acting user is resolved via resolveActor and stamped onto every create/publish for the audit trail. Applicants have no write access to the library; their view is read-only consumption. ### The Publish Flow - Create the resource. Pick a type and title — this mints the stable catalog entry that bindings and versions hang off. - Add a version. Upload the artifact (handbook PDF, video file) through the Globus-signed upload flow, or supply an embed URL for externally-hosted video. This creates a draft version. - Bind it. Add one or more bindings — choose program (required), season (optional), and lifecycle stage (optional) for each. - Publish. Transition the draft to published. Any previously-published version becomes superseded, and the resource immediately resolves to applicants whose context matches a binding. ### Resource Version States | State | Meaning | | --- | --- | | | meaning | _(row above is a template, repeated for each data row)_ ## Resolution Preview The authoring surface answers “what did I publish?”; the resolution preview answers “what will an applicant actually see?”. It is a staff-only tool in the catalog manager where a program admin enters a hypothetical applicant context — program, season, and lifecycle stage — and Globus runs the exact same resolution the portal consumes, returning the resolved resource set together with the bindings and versions that produced it. It closes the loop between authoring and consumption and lets staff catch mis-bound, unpublished, or stage-gated resources before publish, rather than discovering them through an applicant report. > **Same resolver, preview mode** > > The preview does not re-implement matching. It calls the one resolution path described in the Resolution & Access Contract with a supplied context instead of a live applicant’s, so what the preview shows is what the portal would render. If the two ever diverge, the preview is the bug. ### Inputs The admin supplies a synthetic applicant context. The axes mirror the three binding axes exactly, and the null-vs-set semantics match resolution: leaving an optional axis empty previews the wider, less-specific set. | Input | Required | Notes | | --- | --- | --- | | | required | notes | _(row above is a template, repeated for each data row)_ ### Output The preview returns the resolved set plus the provenance behind it, so an admin can see not just what resolves but why — which binding matched and which version is live. | Returned | Notes | | --- | --- | | field | notes | _(row above is a template, repeated for each data row)_ ### Preview Behavior & Guarantees - Read-only, no side effects. The preview resolves and reports; it never publishes, binds, or mutates. It is safe to run repeatedly while tuning bindings. - Drafts are excluded. The preview shows what applicants would see today — only published versions resolve. A resource whose only version is still a draft surfaces as “bound, nothing to resolve,” flagging an incomplete publish. - Stage gating is simulated, not bypassed. Setting a later stage reveals more resources exactly as advancing a real applicant would; the preview never shows stage-gated content the chosen stage has not yet reached, except in the explicit “stage-gated-but-hidden” diagnostic line. - Archived resources and retracted versions are omitted, matching live resolution, so the preview never overstates what an applicant gets. - It is a diagnostic, not a new contract. The preview adds no resolution rules of its own — it is a window onto the existing resolver, so it stays correct for free as resolution evolves. ## Versioning ### Immutable Versions, Mutable Pointer A published version is never edited in place. To change a handbook, an admin publishes a new version; the old one is marked superseded and retained. The “live” artifact is simply whichever version is currently in the published state. This gives a complete, auditable lineage and a clean rollback story: re-publishing an older version is a forward transition, not a destructive edit. ### Versioning Rules - One published version per resource. Publishing supersedes the prior live version atomically; there is never a window with two live versions. - Version numbers are monotonic and never reused per resource, even across retraction and re-publish. - Bindings are version-agnostic. Because they point at the resource, a new publish propagates to every binding automatically. - Retraction leaves a gap. A retracted resource with no published version stops resolving entirely until a new version is published — the surface shows nothing rather than stale content. ## Resolution & Access Contract This is the division of responsibility between Globus and the portal for the Program Resources surface. Globus resolves the set and mints access; the portal renders. File access always flows through Globus-signed, short-lived proxy URLs — the portal never receives a raw storage key, exactly as with My Documents. The wire-level shape of this contract — the exact endpoints, payloads, auth, and error responses — is enumerated in the HTTP Endpoint Contract below. | Globus supplies | Portal renders / is responsible for | | --- | --- | | globus | portal | _(row above is a template, repeated for each data row)_ ## HTTP Endpoint Contract The wire-level API behind the data model and resolution contract above. Two distinct surfaces: the admin authoring endpoints programs use to create, version, bind, publish, and retract resources, and the applicant resolution endpoints the portal calls to read the resolved set and mint access. Admin endpoints are gated by the standard RBAC resource.manage permission and stamp the resolved actor onto every write; applicant endpoints are identity-scoped and read-only. Artifact upload and access mirror the Document Storage signed-URL handshake — clients never stream bytes through Globus and never see a storage key. > **Contract, not implementation** > > These endpoints define the contract engineers code against. The binary-handling routes (signed upload/access URLs over object storage) live in the GLOBUS Hub repo, exactly as the document binary routes do; this docs site owns the wire shape. ### Admin Authoring Endpoints The authoring surface walks the publish flow with these endpoints: create the resource, add a version (uploaded artifact or embed), bind it, then publish. Bindings and archival are managed independently of versions because bindings point at the resource, not a version. ### Applicant Resolution Endpoints The portal never asks for specific resources — it sends only the applicant identity and renders whatever resolves. The list endpoint returns metadata only; a separate per-render call mints the short-lived access URL so the authorization check and audit record happen on every read. ### Request & Response Shapes The resolution response carries everything the portal needs to render a card and pick a viewer, and nothing more — no fileKey, no durable URL. Access URLs are requested per render against the resource id. GET /api/applicant/resources X-Applicant-User-Id: 200 OK resources POST /api/applicant/resources/:id/access X-Applicant-User-Id: 200 OK (uploaded artifact) render / download / inline / accessUrl / https://storage.googleapis.com/...signed... / fileName / camp-handbook-2026.pdf / expiresAt / 2026-06-24T18:05:00Z 200 OK (externally-hosted video) render / embed / embedUrl / https://player.example.com/v/abc123 POST /api/admin/resources/:id/versions/:versionId/publish (RBAC: resource.manage) 200 OK resourceId / uuid / versionId / versionNumber / state / published / publishedAt / 2026-06-24T17:40:00Z / supersededVersionId ### Proxied Access-URL Handshake Reading a resource artifact is a two-step handshake mirroring the Document Storage proxied download — the portal never holds a durable link to a file. - The portal renders the resolved set from GET /api/applicant/resources — titles, types, descriptions only. No URL is present in that list response, so nothing durable can be cached or leaked. - When the applicant opens or downloads a resource, the portal calls POST /api/applicant/resources/:id/access. Globus re-resolves the caller against that resource’s bindings (a stage-gated or archived resource is refused even if its id is known), then mints a fresh signed GET URL for the resource’s current published version — or returns the embedUrl for externally-hosted video. - The client uses the returned accessUrl (download or inline render) or loads the embedUrl in a player. The URL expires in minutes and cannot be reused once expired; the next render re-requests it. Each call is logged as a read. > **Access is re-authorized per request, not at list time** > > Resolving into the list is not a durable grant. Because the access URL is minted per request after re-checking bindings, a resource that is retracted, archived, or stage-locked between the list call and the access call is refused at access time. A request for a resource the caller does not resolve to returns 404, never 403 — existence is not leaked, exactly as with proxied document downloads. ### Error Responses All endpoints use the platform’s standard error envelope. The notable cases: | Status | When | | --- | --- | | | when | _(row above is a template, repeated for each data row)_ ## Audit & Governance Every mutating action on the library — create resource, add version, bind, publish, supersede, retract, archive — is recorded through the standard audit layer with a dotted, scoped vocabulary (e.g. resource.published, resource.binding_added, resource.retracted), keyed to the actor resolved at write time. Combined with immutable versions, this gives a full answer to “who published what to which applicants, and when.” Audit writes are best-effort and never fail the underlying mutation. The real-time stream is visible on the Audit Console . ## Related Pages function EndpointTable(: ) function FieldTable(: ) { return ( | Field | Type | Notes | | --- | --- | --- | | field | type | notes | _(row above is a template, repeated for each data row)_ --- --- title: "Education & Teaching Qualifications (Teach USA)" slug: education-teaching-qualifications section: Applicant Journey status: draft description: "Teach USA collects a teacher's education history, teaching registration, and the documents that prove both." last-updated: 2026-10-07 --- # Education & Teaching Qualifications (Teach USA) Teach USA collects a teacher's education history, teaching registration, and the documents that prove both. This page documents the approved spec that links them: structured education records with permanent IDs drive **data-generated document requirements** in a dedicated Teaching Qualifications Documents hub, with automated extraction, passport name matching, per-document verification, and compliance-queue wiring. > **Status (re-verified against BackOfficePrototype, 2026-09-30): APPROVED SPEC — tables and server services implemented** > > The PRD is approved for delivery as three dependency-chained tickets on **P11B: Workflow Studio & Step Library Phase 2** (Asana 1216839288136786 → 1216839250252504 → 1216839301448450, multi-homed on the IT Tech Delivery Kanban board). The new tables now exist in the implementation repo (migrations for Tasks #1885-#1887): `education_records`, `teaching_registrations`, `teaching_qualification_requirements` (the generated-requirements table; there is no `document_requirements` table), `document_extractions`, `document_name_mismatches` and `name_mismatch_support_links`, with the reconciliation service in `teachingQualificationReconciliation.ts` and reviewer routes in `qualificationReview.ts`. Client and portal delivery was not re-verified. The supporting infrastructure this spec builds on — the document review flow, Rules Engine V2, the Missing Compliance Documents and Compliance Review Required queues, the passport name-match rule family (REG-002), and the internal GCP Document AI pipeline — is all live. See Implementation Review Notes below for the verified deltas. ## Overview Today the Education step stores answers as one loose block of form data and the documents hub asks for generic uploads with no link to what the applicant said. The spec replaces this with a linked structured-data + document-verification workflow: 1. The applicant enters education and teaching-registration details as structured records, each with a permanent ID. 2. The system generates the exact document requirements each applicant owes — transcripts, degree certificates, teaching registration, and (where needed) name-change evidence. 3. Uploads are read automatically, compared against the applicant's answers and passport, versioned, and individually verified by staff. 4. The hub completes only when every required document is **verified, waived, or formally marked not applicable** — never merely uploaded. ## Problem Statement - Education answers combine degree and area of study in one field, carry no completion status, and have no stable identity per qualification — nothing else in the system can reference "this specific degree". - Upload steps are blank and generic; an applicant with three degrees can upload one unlabeled file. - Upload alone counts as progress: no per-document verification status, no supersede history, no qualification-level rollup. - Name differences against the passport (marriage, divorce, legal name change) are handled ad hoc. - Missing or unresolved documents do not automatically reach compliance queues. **Non-goals:** no uploads inside the Education step itself (uploads live only in the hub); no dynamic creation of workflow *steps* — hub and subtask placement stays operator-only in Workflow Studio, only the requirement rows and cards inside are data-generated; and this builds on, never replaces, the existing doc-review flow, Rules Engine V2, and compliance queues. ## Part A — Education Step The Education step collects four sections: Education History (repeatable), Languages (repeatable), Skills / Awards / Certificates (rich text), and a new Teaching Registration Summary. ### Education History Records Each repeatable education record collects: | Group | Fields | | --- | --- | | Institution (all required) | Name, location, contact name, email, website | | Qualification (all required) | Degree Level (existing dropdown), **Qualification Name** (e.g. Bachelor of Education), **Area of Study / Major** (e.g. Foundation Phase Teaching) | | Study dates | Start month/year, end month/year; "currently studying" checkbox hides end dates and stores `endMonth = null`, `endYear = null`, `currentlyStudying = true` | | Qualification status (new, required) | "Did you complete this qualification?" — Yes, completed / No, currently studying / No, not completed | | Document availability (new, required) | "Do you have documents for this qualification?" — Yes / No / I have requested them | The existing combined "Degree / Area of Study" field is **split** into Qualification Name + Area of Study — the documents hub must display each qualification clearly. Qualification status is distinct from current enrolment: an applicant may have studied somewhere in the past without completing. ### Teaching Registration Summary New section, no uploads. "Are you currently registered or licensed to teach?" — **Yes / No / Application pending / Not required in my country**. When Yes or Application pending is selected, the section expands to collect licence type, registration number, issuing authority, country of issue, issue/expiry dates, licence status (Active / Expired / Pending / Provisional / Suspended / Other), and multi-selects for authorised subjects and grade levels. This data prepopulates the Teacher Certification document subtask. ### Saving & Stable Record IDs On submit, every education record is saved with its own **permanent ID** (e.g. `educationRecordId = edu_01HX…`) — never array position. Submission generates or updates the hub's document requirements while preserving the record ↔ requirement relationship, and the step locks while under review per the existing step access & lock contract. The form-engine bundle must round-trip server-assigned record IDs unchanged through edit / reorder / delete (Form Control team dependency). ## Part B — Teaching Qualifications Documents Hub The hub's content is generated dynamically from the structured Education data — never a collection of blank generic uploads. Every card carries qualification context: qualification name, institution, area of study, completion status. ### Hub Subtasks | # | Subtask | Generation | | --- | --- | --- | | 1 | Academic Transcripts (renamed from "Academic Record"; never "Teaching Transcript") | One card per applicable education record | | 2 | Degree / Diploma Certificates | One card per record with completion status = completed | | 3 | Teacher Certification | Prepopulated from teaching-registration data | | 4 | Name Change Supporting Documents (conditional) | Active only while at least one unresolved name mismatch exists (server-driven visibility) | Transcript applicability is **configurable in Workflow Studio by degree level** (e.g. High School optional; Certificate through Doctorate required). Degree certificates are never requested for a qualification that is currently studying or not completed — proof of current study, if needed, uses separate configurable requirements (Proof of Enrolment, Current Academic Record), never a mislabeled certificate. Accepted file types: PDF, JPG, JPEG, PNG. ### Teacher Certification Branching | Registration answer | Behaviour | | --- | --- | | Yes | Certificate required | | Application pending | Proof-of-application upload required; certificate optional once issued | | No | No required upload auto-created; routes to a teaching-eligibility rule for review | | Not required in my country | Never silently passes — creates a manual-review result entering the Compliance Review Required queue | ## Document Processing & Name Matching After each upload of a degree certificate, diploma, transcript, or teacher certificate: store the original file and create a versioned application document; **extract** holder name, institution, qualification, dates, and registration number with a confidence score; **compare the extracted name to the applicant's passport legal name** (authoritative once the Passport Information step is submitted and verified; before that, compare against Personal Information and mark the comparison provisional); compare institution and qualification against the Education answers; write evaluation evidence and update rule results and queue membership. Matching normalizes capitalization, punctuation, spacing, accents, ordering, middle-name omission, and initials — "Jacobs, Mary A." is **not** a mismatch for "Mary Anne Jacobs"; a material surname difference (passport "Mary Jacobs" vs degree "Mary Williams") **is**. Extraction uses the existing internal GCP Document AI pipeline with no external AI fallback. ### Applicant Mismatch Flow A detected mismatch raises an inline issue on the specific document showing both names, qualification, and institution. The applicant selects a **Reason** (Marriage / Divorce / Legal name change / Different surname used previously / Document error / Other), a reason-dependent **Supporting Document Type** (e.g. Marriage → marriage certificate; Document error → corrected certificate or institution letter), and uploads the supporting document. **One supporting document may resolve several mismatches** sharing the same previous name — stored as a many-to-many relationship, never requested twice. The mismatch stays unresolved until evidence is uploaded **and reviewed**. Staff-side routes (authenticated, permission `qualifications.review`): `POST /api/name-mismatches/:id/reason` records the applicant's reason on their behalf and sets the reason-dependent supporting document type; `POST /api/name-mismatches/link-evidence` links one supporting document to one or more unresolved mismatches; `POST /api/name-mismatches/:id/resolve` records the reviewer decision (`decision` of `resolved` or `dismissed`, optional `note` up to 2,000 characters). An unknown mismatch returns 404, and `reason` or `resolve` on a mismatch that is already `resolved` or `dismissed` returns 409 `CONFLICT` with `details.code` `MISMATCH_ALREADY_RESOLVED`. ## Verification Lifecycle & Hub Completion Per document: `pending` → `processing` → `needs_applicant_action` / `under_review` → `verified` / `rejected`, plus `superseded`. A replacement upload never overwrites — the original becomes `superseded`, the replacement enters `under_review`, full audit history preserved. Per qualification, a computed rollup: Documents not started / Documents incomplete / Under review / Applicant action required / Verified / Verification failed / Not applicable. **Hub completion:** all required generated requirements must reach `verified`, **waived by authorised Globus staff** (mandatory reason + audit), or **not applicable** (recorded reason + audit). An uploaded document alone never completes a requirement. **Reviewer workspace:** per-qualification cards inside the rendered workflow step show the uploaded file, extracted values, expected values from Education, name/institution/qualification comparisons, AI confidence, rule results, supporting name evidence, and the applicant's explanation. Actions: Verify · Reject · Request replacement · Accept with supporting name evidence · Escalate — overrides, waivers, and accept-with-evidence require a note. A reviewer never needs multiple unrelated screens to understand one qualification. **Permissions.** `qualifications.review` gates the reviewer workspace (`GET /api/applications/:id/qualification-review`), the document decision (`POST /api/documents/:id/qualification-review-action`: verify, reject, request_replacement, accept_with_evidence or escalate; a note is required for every action except verify; an invalid state change is 409 `INVALID_QUALIFICATION_TRANSITION`) and the name-mismatch routes (`POST /api/name-mismatches/:id/reason`, `POST /api/name-mismatches/link-evidence`, `POST /api/name-mismatches/:id/resolve`). `qualifications.waive` (restricted, reason required) gates `POST /api/qualification-requirements/:id/waive` and `.../not-applicable`, each with a mandatory reason; an already waived or not-applicable requirement returns 409 `REQUIREMENT_ALREADY_WAIVED` or `REQUIREMENT_ALREADY_NOT_APPLICABLE`. `references.review` gates staff review of completed reference submissions (`POST /api/reference-submissions/:id/review` with decision approved or rejected, notes required to reject, and `forceReason` required to reject a previously approved reference) and the reference template PDF reads. **Reviewer routes and conflict codes.** `GET /api/applications/:id/qualification-review` and `POST /api/documents/:id/qualification-review-action` (body `action` of `verify`, `reject`, `request_replacement`, `accept_with_evidence` or `escalate`, plus `note`) need `qualifications.review`. `POST /api/qualification-requirements/:id/waive` and `.../not-applicable` (body `reason`) need `qualifications.waive`. Every action except `verify` requires a `note` (`400 VALIDATION_ERROR`, `details.field` `note`), and the document must be a qualification document type. The actions move the document to `verified` (`verify`, `accept_with_evidence`), `rejected`, `needs_applicant_action` (`request_replacement`) or `under_review` (`escalate`). Conflicts return `409 CONFLICT` with the code in `details.code`: `INVALID_QUALIFICATION_TRANSITION` (the document state machine refuses the move; details `fromState` and `toState`), and `REQUIREMENT_ALREADY_WAIVED` and `REQUIREMENT_ALREADY_NOT_APPLICABLE` (the requirement already carries that resolution; waiving and not-applicable exclude each other). The name-mismatch routes and their `MISMATCH_ALREADY_RESOLVED` conflict are described under Applicant Mismatch Flow. ## Reconciliation After Education Changes The idempotent service `reconcileTeachingQualificationRequirements(applicationId)` runs on Education save/submit, record edit/removal, teaching-registration change, and workflow-configuration change. It creates missing requirements, updates existing linked ones, marks obsolete ones no longer required, and **never hard-deletes documents or audit history**. Specifics: adding a qualification creates new requirements; editing institution/qualification names updates display info and flags for re-evaluation; completed → currently studying marks the degree-certificate requirement no longer required while retaining the document; deleting a record marks it and its requirements `withdrawn_from_application` after an applicant confirmation, preserved in audit history. ## Data Model (New Tables) | Table | Purpose | | --- | --- | | `education_records` | Institution details, degree level, qualification name, area of study, dates, currently_studying, completion_status, document_availability, display_order, status | | `teaching_registrations` | Registration status, licence type/number, issuing authority, country, issue/expiry dates, licence status, authorised subjects/grade levels | | `teaching_qualification_requirements` | Per-application generated requirements; requirement_type (`academic_transcript`, `degree_certificate`, `proof_of_enrolment`, `teacher_registration_certificate`, `teacher_registration_application_proof`, `name_change_supporting_document`), links to education record / teaching registration, required, source, status, generated_reason, waived_by/reason | | `applicant_documents` (existing table, extended) | Versioned uploads; version_number, state, supersedes_document_id / superseded_by_document_id | | `document_extractions` | Extracted values + confidence + raw payload | | `document_name_mismatches` | Expected vs extracted name, mismatch type, applicant reason, status, reviewer, resolution note | | `name_mismatch_support_links` | Many-to-many mismatch ↔ supporting document | ## Rules & Queue Wiring All authored through the Rules Engine V2 pipeline: qualification-document-required rules fail into the **Missing Compliance Documents** queue; degree-certificate / transcript / teacher-certification verification rules pass only when reviewed and verified; name, institution, and qualification match rules compare Education data against extracted document data (name match supports Pass / Pass with supporting evidence / Needs review / Fail); the name-change-evidence rule fails into Missing Compliance Documents when evidence is absent, or **Compliance Review Required** when evidence awaits review. The seeded reason codes and rules for this pipeline (all keys end `.j1_teacher`): | Reason code | Rule key | Queue | Raised when | | --- | --- | --- | --- | | `QUALIFICATION_DOCUMENT_MISSING` | `documentation.qualification_documents_complete.j1_teacher` | Missing Compliance Documents | An active, required teaching qualification requirement has no usable upload (missing, rejected, or superseded without replacement). The applicant is asked to upload in the Education section. | | `DEGREE_CERTIFICATE_NOT_VERIFIED` | `documentation.degree_certificate_verified.j1_teacher` | Compliance Review Required | A required degree certificate is not staff-verified (latest upload rejected or verification not established). | | `ACADEMIC_TRANSCRIPT_NOT_VERIFIED` | `documentation.academic_transcript_verified.j1_teacher` | Compliance Review Required | The same for a required academic transcript. | | `TEACHER_CERTIFICATION_NOT_VERIFIED` | `documentation.teacher_certification_verified.j1_teacher` | Compliance Review Required | The applicant said registration is not required in their country (a definite outcome that is reviewed, never silently passed), or the registration evidence was rejected. | | `QUALIFICATION_NAME_MISMATCH` | `identity.qualification_name_match.j1_teacher` | Compliance Review Required | The extracted holder name materially differs from the legal name and is not resolved with accepted supporting evidence. The applicant is pointed to the Name Change Supporting Documents task. | | `QUALIFICATION_INSTITUTION_MISMATCH` | `documentation.qualification_institution_match.j1_teacher` | Compliance Review Required | The extracted institution differs from the institution on the linked education record. | | `QUALIFICATION_DETAILS_MISMATCH` | `documentation.qualification_details_match.j1_teacher` | Compliance Review Required | The extracted qualification or award name differs from the linked education record. | | `NAME_CHANGE_EVIDENCE_REQUIRED` | `documentation.name_change_evidence.j1_teacher` | Missing Compliance Documents | An open name mismatch has no supporting evidence linked, so the name change supporting document requirement is active. | All eight rules fail into a `REVIEW_QUEUE` action with `blocksTransition` false; the two missing-document codes carry a blocking severity hint on the reason code but the rule action itself does not block a transition. The verification, institution and details codes show the applicant only a neutral message that the team is reviewing and no action is needed. ## Delivery Plan Three dependency-chained tickets on P11B: 1. **Structured education records + generated requirements** — Education step redesign, the three core tables, stable IDs, reconciliation service, hub cards with qualification context, subtask renames, per-degree-level transcript configuration. 2. **Verification lifecycle + reviewer workspace** — expanded statuses, supersede versioning, qualification-level rollup, requirement-driven hub completion, waivers with audit, single-screen reviewer workspace. 3. **Extraction, passport name matching + supporting-evidence flow** — extraction for qualification documents, name matching, mismatch + supporting-evidence flow, conditional Name Change subtask, rules and queue wiring. External dependencies: the **Form Control team** (record-ID round-trip through the form-engine bundle) and the **Applicant Portal team** (rendering generated requirement cards, mismatch prompts, and upload UX from the Globus-served hub structure — an integration-contract change). ## Implementation Review Notes Verified against the implementation repo (2026-07-23) — deltas the delivery tickets must reconcile: - **Requirement generation is a new mechanism, not an extension of the current one.** Today document requirements are derived at *publish time* by scanning each step's upload fields tagged with `metadata.documentType` (`documentRequirementDerivation.ts` → `workflow_step_document_requirements`). This spec introduces *runtime, per-applicant* generation from structured data. Both must coexist: publish-time derivation keeps serving generic tagged uploads while the new `teaching_qualification_requirements` table serves qualification-scoped rows. The resolver (`documentRequirementsResolver.ts`) and the applicant Documents tab will need to merge both sources without double-counting. - **Two status vocabularies must be mapped, not duplicated.** The live review path treats `verified` / `approved` / `accepted` as equivalent verified states (`isVerifiedDocumentState` in `shared/documentRequirements.ts`). The spec's per-document lifecycle (`pending` … `superseded`) is broader. Phase 2 should extend the existing state model rather than introduce a parallel one, and keep the shared predicate as the single verified-state authority. - **Versioning partially exists.** `document_versions` rows with reviewer audit and per-version download paths are already live in the requirements payload (`RequirementDocumentVersion`). The spec's `applicant_documents.supersedes_document_id` overlaps — Phase 2 should decide explicitly whether to extend `document_versions` or introduce the new table, and record the decision. - **Passport name matching has an existing pattern to reuse.** The REG-002 passport name-match rule family, its reason codes, and the Missing Compliance Documents / Compliance Review Required queue definitions are all seeded and live. Phase 3's qualification name-match rules should follow the same seed + reason-code-envelope pattern rather than a bespoke path. - **The extraction guardrail matches reality.** The internal GCP Document AI adapter is the only OCR path and hard-fails with no OpenAI fallback — exactly the architectural guardrail the spec states. Qualification-document extraction extends this adapter's document scope. - **Wave-2 precedent for per-document verification rollups.** The confirmed-match document wave (`wave2DocReview.ts`) already derives a work-item status from per-document verified state. The qualification-level rollup in Phase 2 is the same shape and can follow its pure-shared-module pattern. ## Related Pages - Passport-Authoritative Identity — the passport legal name this spec matches against. - Workflow Studio — operator placement of the hub and subtasks; per-degree-level transcript configuration. - Rules Engine / Queue System — the V2 rule pipeline and compliance queues this spec wires into. - Applicant Portal — My Documents & Resources — the portal rendering contract the generated cards flow through. - Section Locking & Unlocking — step lock behaviour while Education is under review. --- --- title: "Application Stepper and Review Feedback" slug: application-stepper-review-feedback section: Applicant Journey status: stable description: "How the Applicant Portal (PPPToo) marks steps as needing changes in the stepper, journey map and progress counts, including the Hub-supplied Camp Contract compliance feedback." last-updated: 2026-10-07 --- # Application Stepper and Review Feedback How the Applicant Portal (PPPToo) marks steps as needing changes in the stepper, journey map and progress counts, including the Hub-supplied Camp Contract compliance feedback. ## Overview The Applicant Portal shows a step as complete only when its workflow status is completed and no review or compliance feedback is open against it. A completed step that staff have sent back, or that carries Hub compliance feedback, is shown with an error state and a `!` marker and is not counted in progress. Compliance feedback does not itself lock the step, so the applicant can correct and resubmit. The dashboard journey is built from the lean journey projection, which omits compliance feedback, so there Camp Contract compliance feedback does not change a step; staff review attention does. Everything on this page is presentation logic in the PPPToo client plus one BFF projection; the decisions themselves (staff rejection, compliance evaluation) are made in the Globus Hub. See [PPPToo — Participant Portal v2](/docs/pppto-architecture) for the portal architecture. > **Raw status versus applicant-facing completion** > > `isDone` still means the raw lifecycle status is `completed`. Navigation, the journey map and progress use the stricter `isCompleteForApplicant`, so a step can be `completed` in the workflow and still render as incomplete to the applicant. ## Attention and Completion Model ### Predicates The predicates live in `client/src/lib/steps.completion.ts` and are re-exported from `client/src/lib/steps.ts`. | Predicate | Meaning | | --- | --- | | `isDone` | Raw status is `completed`. Lifecycle predicate, unchanged. | | `needsReviewAttention` | Staff review requested changes: `needsAction` is true, or status is one of `rejected`, `changes_requested`, `action_required`, `flagged`. | | `hasComplianceFeedback` | The step carries a non-empty `complianceFeedback` array from the Hub. Presentation only; it never drives access. | | `needsAttention` | `needsReviewAttention` or `hasComplianceFeedback`. Compliance feedback is treated exactly like a staff rejection for display. | | `isCompleteForApplicant` | `isDone` and not `needsAttention`. | | `needsNavigationAttention` | `needsAttention`, or for a hub step, any of its mapped sub-tasks needs attention. | | `isNavigationComplete` | For a non-hub step, `isCompleteForApplicant`. For a hub step, see below. | `progress()` excludes hub steps and counts the remaining steps with `isCompleteForApplicant`, so a rejected completed step lowers the percentage. ### Hub Roll-Up Sub-task steps are hidden from the top-level stepper and reached through the hub checklist. To keep a rejected sub-task from being invisible, a hub step is treated as needing attention when any sub-task in its `hubSubtaskMap` needs attention. A hub is navigation-complete only when all of these hold: - The hub itself does not need attention. - It maps at least one sub-task. - Every mapped sub-task is present in the step list. - Every mapped sub-task is complete for the applicant. In the hub checklist, a sub-task is passed to the engine as `completed` when complete for the applicant, `in_progress` when it is raw-done or needs attention (so a rejected sub-task is not shown as untouched), and `not_started` otherwise. **Step review sessions (Hub).** Field-level review feedback is stored in the Hub as review sessions with items. `GET /api/applications/:id/step-review-sessions` (optional `stepKey`; 404 for an unknown application) lists an application's sessions with their items, including private draft sessions and staff-only (`internal` visibility) comments, so it requires `applicant.journey.update` rather than the broader `dashboard.view`. `PUT /api/applications/:id/step-review-sessions/draft` creates or replaces the private draft session for a step: body `stepKey`, optional `overallComment` and `supersedesReviewSessionId`, and `items` with `targetType`, optional `targetItemId`, `targetLabel`, `fieldPath` and `comment`, a `status` and a `visibility` of `internal` or `applicant` (default `applicant`). Items are replaced wholesale on each save, a `changes_requested` item needs a non-blank comment, and a superseded session must be a published session on the same step (400 otherwise). Drafts are not visible to the applicant until published through the step-review decision route. Item statuses are `not_reviewed`, `approved`, `changes_requested`, `resolved` and `applicant_resubmitted`. ## Surfaces That Apply the Model | Surface | Behavior | Source | | --- | --- | --- | | Desktop stepper (`ApplicationStepperNav`) | Each step button gets an `aria-label` of the form `Title: changes requested`, `Title: complete` or `Title: incomplete`. An attention step has the `err` class and a `!` circle, and is never `done`. The connector line to the next step is lit only when the previous step is navigation-complete. A hub shows `x/y` sub-tasks complete, where `x` counts sub-tasks complete for the applicant. The active attention step is rendered with the `err` class. | PPPToo#314 | | Phase tabs | The `done/total` count on each tab uses `isNavigationComplete`. | PPPToo#314 | | Mobile nav (`MobileApplicationNav` in `ApplicationPage.tsx`) | Phase progress uses `isNavigationComplete`. The top rail segment is the error colour for attention, the ok colour for complete, brand for the current step, otherwise the line colour. The vertical step list uses the same two navigation predicates. | PPPToo#314 | | Dashboard journey (`ProgramJourney`) | Nodes use `isCompleteForApplicant`. Label colour checks attention first, then done, then current. An attention node is clickable. The journey counts as complete only when the `complete` prop is true and every step is complete for the applicant, so a rejected step removes the completed-journey state, the solid track overlay and the completed bookends. The current node is the first step that is not complete for the applicant. The journey is built from the lean projection, so it reflects staff review attention only, never Camp Contract compliance feedback. | PPPToo#315 | ## Camp Contract Compliance Feedback When the Hub finds that a Camp Counselor placement runs longer than four calendar months, the applicant sees feedback on the Camp Contract step and the step is marked as needing changes. The evaluation and its blocking of Ready for SEVIS are described on [Camp Program Rules](/docs/camp-program-rules); this section covers only the portal side. ### BFF Projection `server/src/applications/application-step-projection.ts` adds a `complianceFeedback` property to the full step projection, for these step keys only: - `camp_contract_v0_1` - `swt_camp_contract_v0_1` - `swt_camp_contract_v0_1_v0_1` The source may use `complianceFeedback` or the snake_case alias `compliance_feedback`. Each item is kept only if it has a string `reasonCode` and `message`, an `evaluatedAt` that parses as a date, and a `freshness` of `fresh` or `stale`; malformed items are dropped. The value is `null` when the source is not an array. The lean journey projection (used for the dashboard journey) does not include `complianceFeedback`. On the client, `normaliseSteps` passes the array through unchanged, or sets `null`. ### In-Step Banner For the reason code `CAMP_PROGRAM_DURATION_EXCEEDS_FOUR_MONTHS`, the step form renders a banner titled **Contract dates need attention** with `role="alert"`, using the same `review-feedback-banner` class as a staff rejection. The body is a fixed applicant message: the Camp Contract dates cover more than four calendar months, the applicant should correct the Contract Start Date and Contract End Date (and upload a corrected signed contract if the signed dates changed), then save and resubmit; Ready for SEVIS stays blocked until staff verify the corrected contract and update the official placement dates. If `freshness` is `stale`, the banner adds "This evaluation may be out of date." The banner is rendered in the step form of the Camp Contract step. ### Editable Correction Fields Compliance feedback is not a lock. Read-only and submit state still come only from the step-access snapshot; compliance feedback does not change them. While the duration feedback is present, the contract start date, contract end date and camp contract upload fields are removed from the read-only field list, matched by either the field `name` or `key` in the step's `fieldsConfig`, so an approved-document lock does not prevent replacing the contract. Corrections are submitted through the generic step submit endpoint, with no Camp-specific endpoint. ### Clearing the Feedback The portal never clears the feedback itself. The banner and the attention state remain until a refreshed step response arrives without the feedback, at which point `needsAttention` is false and a completed step counts as complete again. ## Analytics Boundary The `rejection_envelope_shown` event, fired from the application page and the dashboard, is computed with `needsReviewAttention` rather than `needsAttention`. Compliance feedback therefore does not inflate the staff-rejection funnel. The "Changes requested" banner on the step form is shown when the step has `needsAction` and a `rejectionMessage`, and the plain applicant-message banner is shown when the step has a `globusMessage` and is not already showing that rejection; neither is gated on `needsReviewAttention` or affected by compliance feedback. ## Sources - PPPToo#314 — mark rejected steps incomplete in stepper - PPPToo#315 — mark rejected dashboard journey steps incomplete - PPPToo#330 — show Camp Contract compliance feedback to applicants - PPPToo#334 — Camp Contract compliance feedback treated like a staff rejection - `client/src/lib/steps.completion.ts`, `client/src/lib/steps.normalisation.ts` (PPPToo, `development`) - `client/src/components/ApplicationStepperNav.tsx`, `client/src/components/ProgramJourney.tsx` - `client/src/pages/ApplicationPage.tsx` - `server/src/applications/application-step-projection.ts` --- --- title: "Program Register" slug: program-register section: Program Configuration status: stable description: "The stable program catalogue — long-lived program identities that define what Globus offers, independent of any particular season or cohort" last-updated: 2026-10-07 --- # Program Register The stable program catalogue — long-lived program identities that define what Globus offers, independent of any particular season or cohort > **Capability Layer 1** > > The Program Register corresponds to Layer 1 of the Globus Capability Layers. It provides the foundational "product list" that all downstream layers depend on. ## What It Enables A stable program catalogue ("product list"). Programs are long-lived identities (not recreated annually). The register stores baseline program metadata including visa type, program model, Program Extension / Program Renewal eligibility flags, and default workflow type. ## Program Identity & Metadata Each program in the register captures the essential identity and configuration that remains constant across seasons: **SEVIS sponsor identity.** Each program carries a SEVIS Program Number (sevis_program_number, the sponsor program number / OrgID that batch records are filed under) and, next to it, a SEVIS exchange-visitor category (sevis_category_code, e.g. 12 Summer Work Travel, 11 Camp Counselor). SEVIS designates a sponsor program number for specific categories and rejects any other pairing with S2163 (per record, after submission), so the two are edited together in the program form (Program Number input plus a SEVIS Category selector) and resolved together when batch records are built. The category is operator-configured, not derived from a hardcoded map keyed by program code. The category labels follow the official Department of State table (for example 11 is Camp Counselor, 12 is Summer Work/Travel, 13 is Au Pair, 14 is Trainee, 15 is Intern, 03 is Teacher, and 1A is Student Secondary). Every code is selectable in the SEVIS Category picker on the program form; the five codes that were once labelled Reserved (09, 11, 13, 14 and 15) are no longer hidden. A migration moved existing programs off the wrong values: Camp Counselor programs from 1A to 11 and Teach USA programs from 1C to 03 (only where the old value was still in place), and cleared the Intern and Trainee programs to not configured rather than guess. The write boundary rejects any value that is not a valid exchange-visitor category code, and a blank value is stored as NULL ("not configured"). A program with no category configured is never defaulted to a real category: SEVIS batch validation fails with REQ_CATEGORY_CODE before any send. The column was introduced by a migration that backfilled it once from the retired code-to-category map (filling NULLs only). Per the resolver's source comment, the Intern and Trainee programs are deliberately left unconfigured while their category is pending confirmation from the sponsor's Department of State contact. Camp Counselor for Wild Packs (`CAMP_WP`) carries its own sponsor program number, `P-4-38637`, paired with category `11`, and never borrows Summer Work Travel's `P-2-17835`. The two halves of a seeded program's pairing are written differently on boot: `sevis_program_number` is overwritten by the seed whenever it differs, while `sevis_category_code` is fill-only (seeded only while it is NULL) because staff can change it in Admin, Programs. Correcting an already-populated category therefore needs a data migration narrowed to the exact wrong value, so a deliberate staff choice is never overwritten. On the SEVIS beta sandbox account, camp sends fail with S2163 by design until that account designates `P-4-38637`; this is expected, not a fault. SEVIS program numbers are also mapped to a program type in `program_number_mappings`: `sevis_program_number`, `program_category_id` (canonical program category), `effective_date`, `notes` and retirement fields (`retired_at`, `retired_by_user_id`, `retired_reason`). A remap is a new row with a later effective date; the resolver picks the latest non-retired mapping whose effective date is on or before the as-of date, so a renumbering never reclassifies past records, and at most one non-retired mapping exists per program number and effective date. `GET /api/program-number-mappings` and `GET /api/program-number-mappings/resolve` (`programNumber` required, optional `asOf`) need `compliance.program_mapping.view`. `POST /api/program-number-mappings`, `PATCH /:id` and `POST /:id/retire` need `compliance.program_mapping.manage`, which system-admin god mode does not satisfy and API keys are refused (403); a duplicate active mapping, editing a retired mapping or retiring one twice returns 409. ## Ownership Model The Program Register has clear ownership boundaries that separate it from downstream layers: Owns Decides Does NOT own ## Relationship to Seasons Programs and seasons have a clean parent–child relationship. A program defines what is offered; a season defines when and how it operates for a specific cohort. This separation means programs are never rebuilt annually — only new seasons are created. > **Design Principle** > > Programs are stable catalogue entries. Seasons are operational instances. This separation eliminates the need for annual program rebuilds and allows configuration inheritance with per-season overrides. **Inactive programs are read-only.** While the status of a program is `inactive`, `PATCH /api/programs/:id` and `PATCH /api/admin/programs/:id` reject every change with HTTP 409 `PROGRAM_INACTIVE_READ_ONLY`, except a pure reactivation (a body that only sets `status` to `active`). A system administrator can edit an inactive program by adding an `override` object with non-empty `confirmedBy` and `reason`, which is stamped on the program audit-log row; a non-admin sending `override`, or an invalid envelope, gets the same 409. On a program that is not inactive the field is ignored. ## Auto-Attach: Regulatory Rule Bundles When a new program is created, the Program Register transaction calls the platform's bundle resolver against the program's metadata (visa_type, program_model_type, and allows_dependents). Every matching, published Regulatory Rule Bundle is attached in the same transaction as the program insert — brand operators never hand-pick compliance rules. The platform ships with a seeded j1_standard v1 bundle that attaches to every J-1 program. - One program_rule_configs row is written per (rule × bundle) match, with bundle_id and bundle_version stamped for provenance and compliance_locked = true when the bundle declared lock_required. - The Program Register's create response includes the resolved attached_bundles array so the UI can show "This program inherited 14 compliance rules from j1_standard v1" immediately on success. - A "Preview attached bundles" read-only call (POST /api/regulatory-bundles/resolve with body `{ programId }`, implemented in the Globus Hub) is available so brand admins can see which bundles attach to a program. It requires the compliance.bundles.read permission and returns the matched bundles (key, version, precedence band) and, per rule, the winning bundle with its lock state. - Auto-attach is idempotent: if a brand admin re-runs program create with the same metadata, the upsert never duplicates rows and never downgrades a compliance lock. > **See the Rules Engine spec for the full bundle model** > > Bundle shape, resolver semantics, immutability rules, version-update migration flow, the Compliance Console Bundle Author surface, and the bundle API are documented on the Rules Engine — Regulatory Rule Bundles section. The Program Rules page covers how the lock surfaces in the cascade. > **What the Program Manager sees next — the Rules tab** > > Once a program is created, the brand admin lands on the program detail page. The Rules tab is the surface that makes the auto-attached bundle visible and explains what was applied, what they can change, and where workflow coverage stands. See Program Setup — Rules Tab UX for the full specification of the four-card layout (bundle banner, locked compliance rules, operational rules, season overrides) and the five-stage flow that ties program creation through to workflow publish. ## Legacy Import Path (Retired) > **POST /api/admin/programs/import has been removed** > > The legacy bulk-import endpoint POST /api/admin/programs/import no longer exists. It bypassed the bundle resolver, the brand-scoped uniqueness checks, and the position-types lookup, leaving imported programs in a state the rest of the platform could not reason about. There is no replacement bulk endpoint. All program creation now flows through the same Program Register transaction used by the admin console — one program at a time, through POST /api/admin/programs, with the auto-attach resolver, uniqueness guarantees, and audit trail described above. Any external tooling that previously posted to the import endpoint must call the per-program endpoint in a loop and surface per-row errors to its own operator. ## Position Type Codes — Admin Screen ### Purpose & Scope The Host Offer Details contract freezes position_type as a code from a Program Register-owned lookup (program_position_types). The Program Register admin screen is the only surface where those codes are managed — adding a new program role or retiring an obsolete one is a data change made by Program Register admins, not a code change shipped by engineering. The screen is scoped per program: each program in the catalogue has its own list of allowed codes. > **Data, not code** > > No hardcoded position-type enum lives in the Host Portal, the Globus staff UI, or the database schema. The Program Register screen is the single write surface; the lookup endpoint at /api/partner/host/offer-details/position-types?program_id=… is the single read surface. > **Spec only — implementation lives in Globus** > > This page is a contract spec. The admin surface itself (table, CRUD, audit, RBAC) is owned and built inside the Globus Program Register, not in this architecture/spec repo. The sections below describe the screen Globus must build; nothing here is a working application. ### Screen Wireframe (reference) The screen lives under Program Register → [program] → Position Types. Program selection happens in the page header; the body shows a single sortable table with inline create / edit / deactivate / reactivate actions. Program Register Camp Counselor (J-1) — Position Types program_id: prog_camp_2026 + Add position type | | Code | Label | Description | Status | Actions | | --- | --- | --- | --- | --- | --- | | ⋮⋮ | counselor | Camp Counselor | Direct camper supervision | | Edit Deactivate | | ⋮⋮ | support | Support Staff | Kitchen, maintenance, admin | | Edit Deactivate | | ⋮⋮ | lifeguard | Lifeguard | Waterfront safety; certification required | | Edit Deactivate | | ⋮⋮ | kitchen_lead | Kitchen Lead (retired) | Replaced by support in 2025 | | Edit Reactivate | Edit opens an inline drawer with label, description, and sort_order editable. code is read-only after create. ### Field Rules | Field | Type | Required | Notes | | --- | --- | --- | --- | | field | type | required | notes | _(row above is a template, repeated for each data row)_ ### Activation Lifecycle Codes have a binary status — Active or Inactive — and never delete. This preserves the readability of historical offers while preventing accidental selection of retired codes on new writes. selectable on new offers • included in lookup with active: true historical offers still render the label • new PATCH referencing the code returns 422 • included in lookup with active: false so consumers can show greyed-out items > **Renames are deactivate + create** > > Because code is the persisted FK on every offer row, it is immutable after create. To rename a role, deactivate the old code and create a new one with the desired identifier; the label can be edited freely. ### RBAC & Permissions Access to this screen is gated by the program_register.position_types.manage permission, granted to the Program Register Admin role. See RBAC for the full role/permission map. | Role | View list | Create / Edit | Deactivate / Reactivate | | --- | --- | --- | --- | | Program Register Admin | ✓ | ✓ | ✓ | | Sponsor Ops (read) | ✓ | — | — | | Partner Ops / Host user | via lookup endpoint only | — | — | ### Audit Logging Every create, edit, deactivate, and reactivate emits one audit event with the staff actor, the program scope, and a before/after diff. No-op edits do not emit. Events feed the standard Globus audit trail and are filterable by program in the audit viewer. | Event | Payload fields | | --- | --- | | event | fields | _(row above is a template, repeated for each data row)_ ### Admin Endpoints The screen calls the following Globus-internal endpoints. The host-facing read endpoint (/api/partner/host/offer-details/position-types) reads from the same program_position_types table — the admin write surface and the host read surface stay in lock-step automatically. | Method | Path | Description | | --- | --- | --- | | method | path | desc | _(row above is a template, repeated for each data row)_ ## Identity & Uniqueness Guarantees Globus prevents duplicate brands, programs, and seasons through a single, layered pattern: a Zod-validated request body is normalized, a pre-flight SELECT looks for an existing row, a partial unique index in PostgreSQL is the last line of defense, the 23505 constraint-violation is caught, and the endpoint returns a structured 409 Conflict that always includes the conflicting row's id so portals can deep-link to it. Each entity is unique within a different scope. Brand identifiers are global. Program names are scoped to the brand. Season names and keys are scoped to the program. ### Brands — Global Uniqueness Both name and slug on a brand row are unique across the entire platform — there is no brand-of-brand container to scope against. The slug is the URL-safe form; the name is the human-readable form. Both sides are checked. **Deleting a brand.** `GET /api/brands/:slug/delete-preview` (`programs.manage`; the segment may be the slug or the brand id, 404 when no brand matches) reports what would block `DELETE /api/brands/:slug` without deleting anything. It returns `brandId`, `brandSlug`, `canDelete` (true only when every count is zero) and `references` with counts for `programs`, `sevisBatches`, `notificationTemplates`, `jobOrders`, `partnerAgreements`, `brandRuleDefaults`, `emailSenderIdentities`, `emailTemplateRegistry` and `history` (audit history that also blocks deletion). The delete itself (`brands.manage`) answers 409 `BRAND_HAS_REFERENCES` when references exist. | Layer | Guarantee | | --- | --- | | Database | Unique constraints on brands.name and brands.slug — the last-line-of-defense check that races cannot bypass. | | Application | Zod validates shape, normalizeBrandName(name) and normalizeSlug(slug) run before a pre-flight SELECT checks for an existing row. The insert is wrapped in a 23505 catcher. | | API response | On conflict the endpoint returns 409 with error code `CONFLICT`; `details.field` is `name` or `slug` and carries `attemptedName` / `attemptedSlug` (plus `conflictingBrandId` on update). | ### Programs — Brand-Scoped Uniqueness A program's name is unique within its brand — two brands can each own a “Camp Counselor” program, but a single brand cannot. The slug-style identifiers program_key and code are globally unique (used in URLs, integrations, and reporting). | Layer | Guarantee | | --- | --- | | Database | Unique index on programs(brand_id, lower(trim(name))) from migration 20260526000000_programs_brand_name_unique.sql, plus global unique constraints on programs(program_key) and programs(code). None of the three is partial (programs has no deleted_at column). | | Application | Zod → normalizeProgramName(name) (trim, collapse internal whitespace, case-fold for the index comparison) and normalizeSlug for program_key / code. Pre-flight SELECT by (brand_id, lower(trim(name))); 23505 catcher on insert. | | API response | On conflict the endpoint returns 409 with error code `CONFLICT`; details carry `field` (`name`), `conflictingProgramId`, `conflictingProgramName` and `brandId`. | The position-type lookup table documented above (program_position_types) uses its own per-(program_id, code) uniqueness rule — see the Field Rules table for that contract. **Moving a program to another brand.** `PATCH /api/programs/:id` (`programs.manage`) that changes `brandId` is refused with 409 `CONFLICT` while branded email templates are registered against the program's current brand (`details.code` `PROGRAM_HAS_TEMPLATE_BINDINGS`, with `templateIds` and `lifecycleStates`), or while a campaign narrows its audience to the program (`details.code` `PROGRAM_HAS_CAMPAIGN_AUDIENCE`, with `campaignKeys`). Retire or replace the template registrations, or remove the program from the campaign audience or move the campaign, first. The check runs inside the update transaction against the locked program row, after brand-scope authorisation on both the source and the destination brand; a payload that names the brand the program is already in is not a move. ### Seasons — Program-Scoped Uniqueness A season's name and season_key are unique within their parent program. The season_key is derived from the name by deriveSeasonKey(): lowercase, replace runs of non-alphanumeric characters with a single underscore, then trim leading and trailing underscores. Example: “Summer 2026 — Pilot” → summer_2026_pilot. | Layer | Guarantee | | --- | --- | | Database | Unique index on seasons(program_id, season_key) — because season_key is derived from the normalized name, a single composite index covers both the “same name” and “same key” race conditions. | | Application | Zod → normalizeSeasonName(name) → deriveSeasonKey(name) → pre-flight SELECT on (program_id, season_key) → insert wrapped in a 23505 catcher. | | API response | On conflict the endpoint returns 409 with error code `CONFLICT`; details carry `field` (`name`), `conflictingSeasonId`, `conflictingSeasonName` (and `seasonKey` when the derived season key collides). | ### Normalization Rules Normalization happens in application code before the pre-flight check, and the matching lower(trim(...)) expression sits in the database index so the two stay in lock-step. Names are compared insensitively to whitespace and case; slug-style identifiers (slug, program_key, code, season_key) are compared exactly but are required to already be in their canonical lowercase, snake/dash form. | Field kind | Normalization applied | Comparison | | --- | --- | --- | | Name (brand, program, season) | Trim leading/trailing whitespace, collapse runs of internal whitespace to a single space, case-fold for the index lookup. | Case- and whitespace-insensitive. | | Brand slug | Must already be lowercase, hyphenated, URL-safe. Zod rejects anything else. | Exact, case-sensitive. | | program_key / code | Must already be lowercase snake_case ASCII. Zod rejects anything else. | Exact, case-sensitive. | | season_key | When not supplied, derived server-side from the name via deriveSeasonKey(name). When the client does supply one, it must already satisfy the same canonical form (lowercase, snake_case ASCII) or Zod rejects it. | Exact, case-sensitive. | ### Scope Comparison | Field | Unique within | Case-sensitive? | Index expression | | --- | --- | --- | --- | | Brand name / slug | Global | No / Yes | brands.name · brands.slug | | Program name | Brand | No | (brand_id, lower(trim(name))) | | Program program_key / code | Global | Yes | program_key · code | | Season name / season_key | Program | No / Yes | (program_id, season_key) — name maps to key via deriveSeasonKey | ### Defense-in-Depth Pattern Every create endpoint that protects an identity field follows the same five-stage pattern. Each stage exists to catch a failure mode the previous stage cannot. - Zod validates request shape, types, and basic format rules (e.g. slug regex). Rejects malformed input with 400 VALIDATION_ERROR. - Normalizer applies trim, whitespace collapse, and case-fold to name fields; rejects illegal characters in slug fields. - Pre-flight SELECT looks up the canonical key inside the scope and returns a friendly 409 with the conflicting row's id before the insert runs. - Partial unique index enforces the rule at the database level for any race that slips past the pre-flight check. - 23505 catcher converts the constraint violation into the same structured 409 Conflict payload so portals see one consistent error shape regardless of which layer caught the duplicate. > **One shape, every endpoint** > > Brand, program, and season create endpoints all return the conflicting row's id (conflictingBrandId, conflictingProgramId, conflictingSeasonId) so portals can deep-link the user to the existing record instead of forcing a re-search. --- --- title: "Seasons" slug: seasons section: Program Configuration status: stable description: "Operational cohorts — where intake windows, capacity, lifecycle state, and per-season configuration live" last-updated: 2026-10-07 --- # Seasons Operational cohorts — where intake windows, capacity, lifecycle state, and per-season configuration live > **Capability Layer 2** > > Seasons correspond to Layer 2 of the Globus Capability Layers. They cleanly separate program definition from operational cohorts — no annual rebuild required. ## What It Enables Operational instances like Summer 2026 or School Year 2026–27. Seasons are where intake windows, dates, capacity windows, and operational status live. This cleanly separates program definition (Layer 1) from operational cohorts (Layer 2) — no annual rebuild required. Layer 1 (Program) owns identity, brand, visa type, category, regulatory lifecycle defaults, and default operational behavior. Layer 2 (Season) owns the operational cohort fields: operational dates, intake open/close windows, capacity, workflow bindings and version locks, renewal & extension availability per cohort, recurring check-in schedule, quota/capacity allocations, and season-level rule overrides. ## Season Configuration Each season captures the operational parameters for a specific cohort. Seasons inherit baseline configuration from their parent program but can override any setting at the season level: **Cycle type.** The season create/edit form requires a cycle type of `seasonal`, `annual` or `rolling`. Program End Date is required for Seasonal and Annual cycles and optional for Rolling. For Rolling cycles the form hides the intake open/close date inputs and submits them as null. ### Season Identity & Uniqueness A season's name and season_key are unique within their parent program — two different programs can both have a “Summer 2026” season, but a single program cannot. The season_key is derived server-side from the name by deriveSeasonKey(): lowercase the input, replace runs of non-alphanumeric characters with a single underscore, then trim leading and trailing underscores. Example: “Summer 2026 — Pilot” becomes summer_2026_pilot. On a duplicate, the create endpoint returns: 409 Conflict "code": "CONFLICT", "field": "name" (also used when the season key collides), "seasonKey": "…", "conflictingSeasonId": "season_…", "conflictingSeasonName": "…" See Program Register → Identity & Uniqueness Guarantees for the full Brand / Program / Season uniqueness contract, normalization rules, and the defense-in-depth pattern that backs the 409 above. ### Listing Seasons — Query Filters GET /api/seasons (also reachable as /api/operational-cycles, which is rewritten onto the same handler) applies its query parameters in SQL. The supported parameters are programId, programCode, status and limit; results are ordered by creation recency, newest first. - programCode — resolved against the program's public code (for example CAMP_WP); returns 404 if no program carries that code. Supplying both programId and programCode is a 400. - status — one of planned | open | active | closing | closed | archived | cancelled; any other value is a 400. - limit — a whole number, clamped to 1–200; a non-numeric value is a 400. The handler over-fetches by one so page.hasMore reports whether the limit truncated the result. - Unknown parameters are not rejected (the endpoint is reachable with a portal.read API key); they are ignored, and the response carries an additive warnings string array naming them. > **A status filter is not an intake-eligibility guarantee** > > status=open does not mean a cycle will accept an application: an open cycle outside its intake window still refuses new applications, and rolling cycles pass the intake check on open or active. Callers picking a cycle to provision against should not treat the first row of an unfiltered list, or a status filter alone, as proof of eligibility. **Dev seed intake-window refresh.** After the program seeder runs, `open` seasons whose intake window has expired or not yet started are rolled to a window from 30 days ago to 180 days ahead, but only outside production: `INTAKE_WINDOW_AUTOREFRESH` set to `true`, `1` or `yes` forces it on, `false`, `0` or `no` forces it off, and when unset it runs unless `NODE_ENV` is `production`. `tsx scripts/seed-update-intake-windows.ts --update-windows` runs the same refresh on demand (a dry run without the flag); in production the script refuses to run without `--force`, which sets the variable to `true` for that run. ## Lifecycle State Machine Seasons follow a formal lifecycle state machine. State transitions are system-enforced — manual overrides require appropriate RBAC permissions and produce audit entries. The transition matrix is enforced by `assertSeasonTransitionAllowed` on `PATCH /api/seasons/:id` when both the current and target status are lifecycle states. Forward edges (planned to open, open to active, active to closing, closing to closed, closed to archived) need a role with `seasons.manage`. Backward override edges (open to planned, active to open, closing to active, closed to closing, archived to closed) need `system.admin` and a non-empty `reason`. Every other transition is illegal. Errors: 422 `SEASON_TRANSITION_ILLEGAL` (edge not in the matrix, regardless of role; the message lists the allowed targets), 403 `SEASON_TRANSITION_FORBIDDEN` (the actor lacks the role the edge needs: `seasons.manage` for a forward edge, the system administrator role group for an override edge; raised before any state change) and 422 `SEASON_TRANSITION_REASON_REQUIRED` (override edge without a reason). Forbidden and missing-reason attempts are audit-logged before the error is returned. > **Implementation status — current contract** > > The 6 states above are the current canonical contract for the Season lifecycle. Active and Closing are part of that contract alongside Planned, Open, Closed, and Archived. Wind-down behavior is not represented as a separate lifecycle state. Instead, it is expressed as Active with intake closed plus operation-specific policy gates (renewals, extensions, recurring check-ins) that may continue to allow valid work where appropriate. > **Lifecycle state ≠ all behavior** > > Season-level behavior is governed by operation-specific gates in policy code, not only by lifecycle state. Two seasons in the same state can behave differently based on per-operation policy (e.g. renewals open, extensions closed, recurring check-ins still scheduled). **Season check-in binding.** `season_check_in_bindings` holds at most one row per season (unique on `season_id`) naming the check-in questionnaire the season uses: `step_key`, `step_version`, `cadence` (`monthly`, `quarterly` or `annual`), `period_offset_days` (default 0) and `grace_window_days` (default 7). It is read, written and deleted through `GET`, `PUT` and `DELETE /api/seasons/:seasonId/check-in-binding`. The recurring check-in reminder job iterates only seasons that have a binding. For `open` and `active` seasons it resolves the open periods, creates a `recurring_check_ins` row per on-program applicant and period where none exists, and emits `checkin.reminder_due`. `closing` seasons get no new reminders. For `closed` and `archived` seasons it marks in-flight `pending` and `submitted` check-ins `closed_season_ended` and sends nothing. ### Planned → Open Gate Creating a season only requires a published enrollment workflow; capability workflows (extension, renewal, …) may still be unauthored. The blocking enforcement point is the transition into open, implemented once in assertSeasonOpenPrerequisites and called from all three paths that can open a season: the canonical PATCH /api/seasons/:id, the admin PATCH /api/admin/seasons/:seasonId, and the date-triggered season lifecycle worker. - Capacity must be greater than 0 (“Cannot open a season with 0 sponsor capacity”). - Every application type the program requires must be bound to a published workflow definition of the matching workflow type. - A required type with no binding is auto-bound by inheriting the program's single published definition, so a workflow published after the season was created binds automatically. An enrollment season that already carries a locked definition keeps that locked definition rather than being upgraded to the latest published one. - A binding that points at an unpublished or wrongly-typed definition is never silently rebound; the transition fails with 409 Conflict, details.code SEASON_OPEN_BINDINGS_UNSATISFIED, plus missingApplicationTypes and unpublishedApplicationTypes arrays. - The lifecycle worker (polls every 5 minutes) auto-opens only non-rolling seasons whose intake open date has arrived. If the gate rejects a season it stays planned, a warning is logged, and it is re-checked on the next tick — so publishing the missing workflow heals it, and 0-capacity seasons no longer auto-open. After a successful auto-open the worker locks the season's enrollment workflow definition if none is set. ## Workflow Version Locking Workflow version locking at the season level is resolved through the publish & resolve pipeline and the season's workflow bindings: Workflows are pinned per (program, workflow type) at publish time — exactly one version is the active published version for that pair. / Season workflow bindings (season_workflow_bindings, keyed by season and application type) point each season at the published definition for each application type it requires; staff never pick versions, and the open gate above inherits any definition published in the meantime. / When the season opens, the enrollment definition is also mirrored into the legacy seasons.workflow_definition_id column (written only when it is still empty), so existing readers see a consistent lock. / When an application is created, it captures workflowVersionId on the application row and is bound to that snapshot for its lifetime. / Per-season tuning is done via season_step_overrides (keyed by seasonId + stepKey) — overrides ride on top of the published workflow without forking the definition. / Compliance-locked steps and rules cannot be overridden at the season level. > **Trust the per-application version for in-flight work** > > Consumers should resolve the active workflow via the program + workflow-type lookup (or the season's binding for the application type), and trust the per-application workflowVersionId for in-flight binding. Binding mutation and delete routes on already-open seasons are not yet locked down. ## Ownership Model Seasons have clear ownership boundaries that define what they control versus what they inherit: Owns Decides Does NOT own ## Relationship to Program Register Every season belongs to a parent program . The program defines what is offered; the season defines when and how it operates. Seasons inherit baseline configuration from their program (structured rule definitions via program_rule_configs, eligibility parameters, workflow type) but can override business rule parameters for their specific cohort via season_rule_overrides. Compliance-locked rules cannot be overridden at the season level. > **Design Principle** > > Seasons are operational instances — they should never require rebuilding the program definition. Configuration inheritance with per-season overrides keeps the system maintainable while allowing cohort-specific flexibility. **Program sunset and its seasons.** Moving a program from `active` to `sunsetting` cancels its `planned` seasons, and the program goes straight to `inactive` when every remaining season is closed or cancelled; moving a program to `inactive` closes its `open` and `recruiting` seasons. Moving a `sunsetting` program back to `active` (cancel sunset) restores to `planned` only the seasons cancelled by the most recent sunset that are still `cancelled`. `GET /api/admin/programs/:programId/sunset-restorable-seasons` (`programs.manage`, API keys need `admin.write`; program scope enforced; `404` for an unknown program) previews that set and returns `{ seasons }` with the full season records, read from the `cancelledSeasonIds` stored on the program's latest `admin_program.sunsetting` audit event. ## Settings & Feature Flags Globus uses a centralized settings and feature flag system to control platform behavior without code deployments. Settings govern system-wide defaults; feature flags enable progressive rollout of new capabilities per brand, program, or season. System Settings Global defaults for all brands and programs / SEVIS configuration thresholds (check-in due days, critical escalation day) / Queue SLA default targets and business hours / Notification delivery preferences (email, SMS, in-app) / Capacity management policies (auto-waitlist, hard cap) / Audit retention periods Feature Flags Toggle new features on/off without deployments / Scoped by brand, program, or season / Supports percentage-based rollout for gradual release / Override hierarchy: system → brand → program → season / Audit-logged — who enabled/disabled and when / Kill switch — instant disable for production incidents System setting writes (not feature flags) are gated by tier: `settings.global.manage` for global settings (create and update), `settings.program.manage` for program-scope overrides, `settings.season.manage` for season-scope overrides and `settings.brand.manage` for brand-scope overrides (brand-tier writes must also fall inside the brand scope of the caller). All four are restricted, reason-required permissions; only `settings.brand.manage` is delegated to Brand Admin, while the global, program and season keys are held by System Administrator. Reading settings, their scope stack and the audit history needs any one of the four. Scope overrides are written with `PUT /api/admin/settings/:key/scope` with `scopeType` and `scopeId` in the body. Feature flags use different keys: `feature_flags.manage` for the global flag and `feature_flags.override` for scoped overrides. ### Documented Program-Scope Settings The settings below are explicitly documented elsewhere in this site as overridable on the program scope. They follow the same scope inheritance chain (system → brand → program → season) as feature flags. | Setting key | Type | Default | Owner / spec | | --- | --- | --- | --- | | holdDurationDays (Rule Library parameter) | integer (days) | 7 | Reservation hold window — cascade-resolved Brand → Program → Season from the published Rule Library rule, with a fail-safe default of 7 days. No reservationTimeoutHours setting exists in code. | > **Adding new entries here** > > This table is the registry of program-scope settings that have a contract documented on another page. Any new tunable that ships with a contract (default value, type, scope rules, edit RBAC) should be added here so the Settings admin page stays in sync with the rest of the docs. ### Feature Flag Scoping Feature flags follow the same inheritance model as season configuration. A flag can be set at any scope level, with narrower scopes overriding broader ones: | Scope | Priority | Example | | --- | --- | --- | | System | Lowest | enable_travel_requests = false (globally off) | | Brand | Medium | Alliance Abroad: enable_travel_requests = true | | Program | High | Camp Counselor: enable_travel_requests = false (override brand) | | Season | Highest | Summer 2026: enable_travel_requests = true (override program) | ### Feature Flag Lifecycle > **Settings Admin** > > System settings and feature flags are managed by Super Admin and System Admin roles through the Settings admin page. All changes are audit-logged with the previous value, new value, scope, and the staff member who made the change. --- --- title: "Program Rules" slug: program-rules section: Program Configuration status: stable description: "The full story of how rules work in Globus — what they are, the difference between compliance and business rules, the Brand → Program → Season cascade, compliance_locked behavior, who edits what, and how it shows up in the Program Manage…" last-updated: 2026-10-07 --- # Program Rules The full story of how rules work in Globus — what they are, the difference between compliance and business rules, the Brand → Program → Season cascade, compliance_locked behavior, who edits what, and how it shows up in the Program Manager's UI — in one linear read. > **This is the canonical Program Rules reference** > > The Rules Engine spec, Program Register, Glossary, and RBAC pages all link here for the Program Rules story. Deeper engine internals (rule trees, facts layer, evaluation pipeline, override governance) live on the Rules Engine spec and are linked from each section below where relevant. This page is documentation only — the interactive Program Rules management UI is separate work. ## What a Rule Is A rule is a single named, structured policy fragment that the engine evaluates against a person, application, host, or job order to decide whether something can proceed. Each rule has a stable identity (e.g. passport_verified, min_age), a structured condition tree, configurable parameters, and a declared failure behavior (block, warn, or route). Programs do not author the rule logic. The Compliance Officer (or, for business rules, engineering) defines the rule once in rule_definitions; each program then chooses whether the rule is on for that program and what parameter values apply. That choice lives in program_rule_configs, and seasons can refine it further in season_rule_overrides. > **Rule logic vs rule configuration** > > The shape of a rule (its condition tree, its outcome contract, its action intents) is immutable across the cascade. What programs and seasons control is whether the rule is active and what parameter values it uses. See Rule Definition Anatomy for the full data model. ## Compliance vs Business Every rule carries a classification of either compliance or business. This single field decides who can edit it, whether seasons can override it, and what happens when a workflow tries to publish without it. ### Compliance Rules Compliance rules enforce regulatory or sponsor-mandated requirements (e.g. minimum age for J-1 Camp Counselor, valid passport, SEVIS check-in). They cite a regulatory basis, can only be authored or modified by users holding compliance.rules.manage, and they are the only rule class allowed to set compliance_locked = true on a program-level config. When a workflow does not cover a compliance rule, the Workflow Studio's coverage panel raises an error and refuses publish. For rules attached to a program by a regulatory bundle , "cover" specifically means an explicit row in program_rule_coverage_bindings that points at a workflow step (or named system process) producing the rule’s declared required_fact_keys — see the Bundle Publish Gate for the full contract. ### Business Rules Business rules enforce operating policy that programs choose for themselves (e.g. preferred match scoring hints, capacity caps, document preferences). They have no regulatory basis. Program managers with program_rules.edit can toggle them on or off and adjust parameters within whatever bounds the rule definition declares. Uncovered business rules produce warnings, not publish-blocking errors. ### Business Sub-classes Business rules carry a business_subclass so their intent is visible in queues, analytics, and the Program Rules UI: ### Side-by-Side Behavior | Behavior | Compliance | Business | | --- | --- | --- | | Authored by | Compliance Officer (compliance.rules.manage) | Engineering / rule library; toggled by Program Manager | | Requires regulatory basis | Yes (citation stored on rule_definition) | No | | Toggle at program level | Read-only for Program Manager (shield icon, "Set by Compliance") | Editable by program_rules.edit | | Parameter override at program level | Allowed only if not compliance_locked | Always allowed within declared bounds | | Season-level override | Blocked when compliance_locked = true; otherwise constrained | Allowed via season_rule_overrides | | Workflow Studio coverage | Error — blocks publish. Bundle-attached rules require an explicit program_rule_coverage_bindings row pointing at a step or system_process producing the rule's required_fact_keys. | Warning — does not block publish | | Failure outcome (default) | block (or hold-for-review) | Sub-class-dependent: block / warn / advisory | | Override at runtime | Only by compliance.rules.manage with documented justification | By program_rules.edit per rule policy | ## The Cascade At evaluation time the engine flattens five scopes into a single effective configuration per (rule, applicant, context) tuple. Lower scopes refine higher scopes; the rule definition itself is never copied — only parameter values and enabled/disabled flags cascade. The top scope (Regulatory Bundles) is read-only to brand and below; everything downstream layers on top of bundle defaults. ### Bundle → Brand → Program → Season → Partner table ### How Resolution Works - Lower scope wins for parameter values. Each parameter declared in the rule definition starts at its default; brand fills it in, program refines, season refines, partner refines. Whatever is set last (closest to the applicant) is the effective value. - Enabled/disabled cascades the same way for business rules. Any scope can flip a business rule on or off (subject to write-time policy). - Compliance rules cannot be disabled below the level that owns them. A program cannot turn off a brand-set compliance rule, and a season cannot turn off a program-set compliance rule. Attempted writes are rejected at the API layer with HTTP 422 and reason code RULE_PARAMETER_LOCKED — never silently dropped. See the canonical Season-Override Rejection Contract for the full envelope. - Compliance-locked parameters refuse lower-scope writes. When a program-level config carries compliance_locked = true, the season override and partner override layers cannot replace the locked parameter values; the API rejects the write at save time. - Resolution is logged. Every evaluation persists a resolution snapshot showing which scope contributed each parameter value, so "why is this rule running for season X?" is always answerable. See Resolution Snapshots . ## Regulatory Rule Bundles Brand operators do not hand-pick compliance rules when standing up a new program. Regulatory Rule Bundles are versioned packages of compliance rules — authored once by Compliance per visa regime — that the platform auto-attaches to a program based on its visa_type (and optional model and dependent predicates). They sit above the brand layer in the cascade and write directly into program_rule_configs with compliance_locked = true. ### What a Bundle Is - A row in regulatory_rule_bundles identified by a stable key (e.g. j1_standard) and a monotonic version integer. - Predicates: visa_type (required), program_model_type, allows_dependents, plus a JSONB condition_predicates escape hatch. - Member rules in regulatory_bundle_rules reference rule definitions (not versions) with default_parameters and a lock_required flag. - Lifecycle: draft → published → archived. Once published, the bundle row and its members are immutable; further changes require cloning to a new version. ### Auto-Attach on Program Create The Program Register create transaction calls a pure bundle resolver with the new program's metadata. Every matching, published bundle is attached in the same transaction: - One program_rule_configs row per (rule × bundle) match — compliance_locked set per the bundle's lock_required flag, with bundle_id + bundle_version provenance stamped on the row. - If a rule appears in multiple matched bundles, the higher-precedence bundle wins and the compliance lock is upgraded but never downgraded. - The Program Rules surface renders bundle-attached rows with a shield badge and "Locked by regulatory bundle v" tooltip; brand operators see them as read-only. ### When Compliance Publishes a New Version Bundles update through an explicit, reviewable workflow — never a silent rewrite: - Compliance clones the published version in the Bundle Author and edits the new draft (add/remove rules, change default parameters). - On publish, the system writes regulatory_bundle_publish_audit with the diff and flips bundle_update_available = true on every program_rule_configs row stamped with the prior version. - Each affected program shows a "Bundle update available" banner. Only Compliance Officers (compliance.bundles.manage) can apply a migration; brand admins (programs.manage) can only request one and a Compliance Officer approves and executes — see the canonical Migration Authority & Dual-Approval. The apply path records the requestor (if any), executor, diff, and reason in program_bundle_migrations. - Programs that have not yet been migrated continue to evaluate against their current bundle version's parameters until the migration is run. > **See the Rules Engine spec for the full bundle model** > > Resolver semantics, the Compliance Console Bundle Author surface, the bundle API, and the seeded J-1 Standard v1 bundle are documented in detail on the Rules Engine — Regulatory Rule Bundles section. ## compliance_locked compliance_locked is a boolean column on program_rule_configs (see shared/models/rules-sponsor.ts). It is the single mechanism that turns "a program manager configured this rule" into "Compliance has frozen this rule for this program; nobody downstream can change it". The lock provenance is recorded in a lock_source jsonb column (kind, bundleId, bundleKey, bundleVersion, lockReason), and locked_parameter_keys lists the frozen parameter keys (empty means the whole rule is frozen). ### Lock States `compliance_locked = false` Default state. Toggle and parameters editable per the rule's classification: business rules editable by Program Manager, compliance rules editable by Compliance Officer. Seasons may layer overrides. `compliance_locked = true` Toggle and the listed locked parameters become read-only across the program. Season overrides for those parameters are rejected at the API layer. The Program Rules UI shows a shield icon, "Set by Compliance", and the locking rationale on hover. ### Write Rules - Only users with compliance.rules.manage can set compliance_locked = true or change a locked rule's parameter values. - A locked rule's enabled flag cannot be flipped to false by anyone other than a compliance user, regardless of scope. - Attempts by a Program Manager to PATCH a locked rule (toggle or parameters) return HTTP 403; attempts to write a season override over a locked parameter return HTTP 422 with reason code RULE_PARAMETER_LOCKED. - Program creation (`POST /api/programs`, permission `programs.manage`) is checked against the regulatory bundle's locked defaults: a payload that sets a locked key (`minAge`, `maxAge`, `minDurationMonths` or `maxDurationMonths`) to a different value, including an explicit `null`, is rejected with HTTP 422 `PROGRAM_LOCKED_PARAMETER` (`details.violations` lists key, expected and received; `details.sourceBundleSlug` names the bundle). A locked key that is simply omitted is filled with the bundle floor. - Unlocking is itself an audited compliance action: it requires lock_reason replacement plus a justification entry on the audit trail. ## Who Sees and Edits What Three roles meet on the Program Rules surface. Permissions follow the standard RBAC model ; the table below is a summary of what each role experiences in this specific area. ### Program Manager Holds program_rules.view and program_rules.edit. Sees every rule attached to the program. Business rules render as editable toggles with editable parameter inputs. Compliance rules render as read-only toggles with a shield icon and a "Set by Compliance" label. Compliance-locked parameters appear inline with a tooltip showing the lock rationale and the compliance owner. ### Compliance Officer Holds compliance.rules.manage. Sees the same Program Rules surface plus full edit affordances on compliance rules: toggle, parameter editing, lock/unlock action, and the audit trail. Authoring new compliance rules and managing the override review queue happens on the dedicated Compliance surface, not here. ### Partner / Host Partners and hosts never see the Program Rules surface directly. They consume the resolved configuration through the Partner Portal and Host Portal — the rules already cascaded down to the partner agreement layer. Where a partner has a documented deviation (e.g. a tighter capacity cap than the season default), it is recorded as a partner-level override and shown back to them as a partner-specific term. | Action | Program Manager | Compliance Officer | Partner | | --- | --- | --- | --- | | View Program Rules screen | ✓ | ✓ | — | | Toggle a business rule | ✓ | ✓ | — | | Toggle a compliance rule | — | ✓ | — | | Edit business-rule parameters | ✓ | ✓ | — | | Edit compliance-rule parameters | — | ✓ | — | | Set / clear compliance_locked | — | ✓ | — | | Write a season override (business) | ✓ | ✓ | — | | Write a season override (compliance, unlocked) | — | ✓ | — | | Write a season override on a locked param | Rejected (422) | Rejected (422) | — | | See partner-specific deviation | ✓ (read) | ✓ (read) | ✓ | ## Worked Example We follow two rules through the cascade for the same program — Camp Counselor (J-1), Summer 2026, operated by Brand A under a partner agreement with Partner P. ### A Compliance Rule Through the Cascade Rule key: min_age — regulatory basis 22 CFR 62.30 (Camp Counselor minimum age 18). Classification: compliance. Configurable parameter: min_age_years. | Scope | Configured value | What happens | | --- | --- | --- | | Brand Defaults | min_age_years = 18 | Brand-wide baseline, set by Compliance. | | Program Rules | min_age_years = 19, compliance_locked = true | Compliance Officer raises the floor for this program (e.g. specific camp insurance requirement) and locks it. Program Manager UI now shows the rule with shield + "Set by Compliance" and the value 19 is read-only. | | Season Overrides | PATCH min_age_years = 18 → rejected | A season admin tries to drop the floor for Summer 2026. API returns HTTP 422 with reason code RULE_PARAMETER_LOCKED. No write occurs. | | Partner Agreement | no override | Partner P has no special deviation. Effective value remains 19. | | Effective at evaluation | min_age_years = 19 | Resolution snapshot records: provenance = program_rule_configs, locked_by = Compliance Officer, lock_reason logged. | ### A Business Rule Through the Cascade Rule key: preferred_languages — the matching engine prefers candidates who speak one of the listed languages. Classification: business, sub-class optimization. Configurable parameter: languages[]. | Scope | Configured value | What happens | | --- | --- | --- | | Brand Defaults | languages = ["en"] | Brand baseline set by operations. | | Program Rules | languages = ["en", "es"] | Program Manager toggles the rule on and adds Spanish. No lock — this is a business rule. | | Season Overrides | languages = ["en", "es", "pt"] | Season admin adds Portuguese for Summer 2026 only. Override accepted. | | Partner Agreement | enabled = false | Partner P opts out of language preference scoring entirely; their candidates are ranked without it. | | Effective at evaluation | disabled for Partner P; ["en", "es", "pt"] for everyone else this season | Resolution snapshot records contributing scopes per applicant. | ### What the Program Manager Sees On the (forthcoming) interactive Program Rules screen for Camp Counselor (J-1) → Summer 2026, the two rules above appear as separate rows. Wireframe (reference only): Program Configuration Camp Counselor (J-1) — Program Rules | Rule | Class | Enabled | Effective parameters | Source | | --- | --- | --- | --- | --- | | min_age | | 🛡 On (read-only) | min_age_years: 19 | Set by Compliance — locked | | preferred_languages | | On (toggle) | languages: [en, es, pt]* | Season override active | *The asterisk marks parameters that came from a season override rather than the program baseline. Hovering reveals the contributing scope and the user who set it. ## Program Setup — Rules Tab UX The cascade and lock model above describe the underlying data shape. This section specifies the Rules tab on the program detail page — the surface a Program Manager actually sees when they create a new J-1 program and need to know "what was applied for me, what can I change, and where does coverage stand?" > **Spec, not implementation** > > This is the contract for the Rules tab on the program detail page. The interactive screen itself is built in Globus on top of the bundle data model (regulatory bundles), the program_rule_configs store, and the Workflow Studio coverage validator . Where any of those data sources is unavailable, the corresponding card renders an explicit "data source unavailable" state instead of mocking values. ### The Five-Stage Setup Flow Standing up a new sponsor-grade program is a five-stage flow. The Rules tab is the surface that connects stages 2 → 4: it makes the auto-applied bundle visible, hosts operational configuration, and surfaces season override roll-ups and workflow coverage status before publish. s own Rules editor; the program tab shows the roll-up. / bg-emerald-50 dark:bg-emerald-950/30 border-emerald-200 dark:border-emerald-800, 5 / Build Workflow & Publish / Workflow Studio binds steps to bundle rules The Rules tab is where stages 2 → 4 are made visible and explainable. Stage 1 is the Program Register form; stage 5 is the Workflow Studio canvas. The publish gate itself is owned by the workflow surface and is referenced — never duplicated — from the Rules tab. ### Rules Tab — Card Layout Every program detail page has a Rules tab that renders four cards in the order below, plus a thin Workflow Coverage status block at the very top of the tab. The order is fixed: a brand operator should always meet the bundle context first, then the locked rules they cannot edit, then the operational rules they own, then the season-level roll-up. Program Detail Camp Counselor (J-1) — Rules Overview Rules Seasons Workflow Workflow Coverage 14 of 14 bundle rules covered · view in Workflow Studio → Bundle Banner — j1_standard v3 Applied automatically because this program is J-1, Camp Counselor, dependents allowed. 🛡 Locked Compliance Rules (14) Set by Compliance — read-only. Hover any row for the lock rationale. Operational Program Rules (3) Editable by Program Manager. + Add operational rule Season Overrides (2 seasons) Roll-up of overrides per season under this program. Click through to edit on the season. ### Bundle Banner The banner names every attached bundle and version, with a one-line rationale that restates the matched predicates in plain English. When a newer published version of an attached bundle exists, the banner shows a single CTA — "Bundle update available — review impact" — that opens the bundle diff modal. J-1 Standard v3 · J-2 Dependents v1 Applied automatically because this program is J-1, Camp Counselor, dependents allowed. ⚠ Bundle update available — review impact - The banner reads from the bundle_id + bundle_version stamped on the program's program_rule_configs rows; if multiple bundles attached, all are named. - The rationale string is rendered from the bundle predicates that matched (visa_type, program_model_type, allows_dependents) — never hardcoded copy. - "Bundle update available" appears only when at least one row carries bundle_update_available = true. Clicking opens a side modal with the diff (rules added, removed, reparameterised) and an Apply / Dismiss pair. - Apply calls the migration endpoint from the Bundles task, requires a free-text reason, and writes a program_bundle_migrations audit row with actor, before, after, and reason. - If no bundle is attached (rare — only programs created before bundles existed), the banner renders an info state: "No regulatory bundle attached. Compliance must run a back-fill before publish". ### Locked Compliance Rules Card Lists every program_rule_configs row where compliance_locked = true, joined to the rule definition for name, regulatory citation, and parameter shape. There is no edit affordance — no toggle, no parameter input, no "Add" button on this card. 🛡 Locked Compliance Rules 14 rules · Set by Compliance — read-only | Rule | Citation | Effective parameters | Source | | --- | --- | --- | --- | | min_age | 22 CFR 62.30 | applicants must be at least 19 | 🛡 j1_standard v3 | | passport_verified | 22 CFR 62.10 | verified before SEVIS pool entry | 🛡 j1_standard v3 | Hover any row to reveal the lock metadata: locked_by, locked_at, lock_reason, attaching bundle key & version. - Parameter values are formatted in plain English wherever the rule definition declares a configurable_params entry with display metadata; otherwise they fall back to the structured JSON shape. - The shield icon and the "Set by Compliance" pill are part of the row, not a tooltip — the lock state must be visible without interaction. - Sort order is bundle sort_order first, then rule name; rules are not regroupable by the Program Manager. - Empty state (zero locked rules): renders "No compliance rules locked for this program. If this is a J-1 program, contact Compliance." rather than hiding the card. ### Operational Program Rules Card Lists every business-class program_rule_configs row that the Program Manager owns: not compliance_locked, not part of an attached bundle. This is the only card on the tab with edit affordances. - Each row reuses the existing rule editor pattern: enabled toggle, inline parameter inputs bound to the rule definition's configurable_params, classification chip, and a "Why is this here?" tooltip linking back to the rule definition. - An "Add operational rule" picker queries rule_definitions filtered to classification = business and excludes any rule already attached by an active bundle or already configured on this program. - Removing an operational rule disables it (sets enabled = false) rather than deleting the row, so historical resolution snapshots remain explainable. - Empty state: "No operational rules configured. Click "Add operational rule" to layer business policy on top of the bundle." > **Picker scope is the guard** > > The picker is the enforcement point for the "you can’t accidentally re-add a locked rule" promise. The card itself does not need to validate against bundle membership at save time because the picker never lets a Program Manager select one in the first place. Uniqueness on (program_id, rule_definition_id) backs that up. ### Season Overrides Roll-Up The Rules tab does not host a season override editor — that lives on each season's own Rules screen. What this card surfaces is a roll-up per season: how many overrides exist, a one-line summary, and a deep-link. Season Overrides 2 seasons · 4 overrides total Summer 2026 3 overrides · preferred_languages adds "pt" · capacity_cap 250 → 280 · interview_window 7d → 5d Open season → Summer 2027 1 override · preferred_languages adds "de" Open season → - Query backs onto season_rule_overrides grouped by season for this program; overrides on rules whose program-level config is compliance_locked are still listed (the API rejected them at write time, so they shouldn’t exist — but if a row is found, it surfaces with a red "Locked-parameter override on file" badge for compliance to clean up). - Clicking Open season → takes the user to the season's own Rules editor; nothing on the program tab is editable here. - If a Program Manager arrives on the program Rules tab from a rejected season override write (HTTP 422 deep-link), this card surfaces an inline notice at the top — "Your last override on Summer 2027 was rejected because min_age is compliance-locked" — with a link to the lock rationale on the Locked card. - Empty state (program has no seasons): renders "No seasons exist under this program yet. Create a season to start authoring overrides." with a link to the seasons page. ### Workflow Coverage Status Block A thin status block sits at the very top of the Rules tab — above the bundle banner — so coverage status is visible the instant the tab is opened. It reads from the Workflow Studio coverage validator and reports three numbers: total bundle rules, count covered, count uncovered. Bundle rules 14 Covered 12 Uncovered 2 - When uncovered > 0, the block renders prominently with a "Open coverage panel in Workflow Studio →" link, deep-linked to the coverage panel filtered to this program. - The block references the publish gate but does not duplicate it — the publish button itself is only on the workflow surface. From the Rules tab the user simply knows whether they will be blocked. - If the coverage endpoint is unavailable, the block renders "Coverage status unavailable — try again from Workflow Studio" rather than a stale or zero count. - Coverage is keyed off the Step → Fact Contract, not portal visibility. A behind-the-scenes interview node that declares its outcome fact counts as the covering source for any rule that requires it — keeping an interview off the applicant stepper never opens a coverage gap. ### Loading, Empty & Error States Each card on the Rules tab is independently loaded and independently fault-tolerant. Loading shows a skeleton matching the card’s row shape; empty shows a card-specific copy line (see each section above); error shows a one-line failure with a Retry affordance. | Card | Loading | Empty | Error | | --- | --- | --- | --- | | Coverage block | 3 number tiles skeleton | — | Coverage status unavailable | | Bundle banner | Single skeleton bar | No regulatory bundle attached | Bundle metadata unavailable | | Locked Compliance Rules | Skeleton table (3 rows) | No compliance rules locked | Failed to load locked rules | | Operational Program Rules | Skeleton table + Add button disabled | No operational rules configured | Failed to load operational rules | | Season Overrides | Skeleton list (2 rows) | No seasons under this program yet | Failed to load season overrides | ### Audit Behavior - Reads do not audit. Opening the Rules tab, hovering the lock tooltip, or expanding a row produces no audit entry. - Operational rule writes audit normally through the existing program_rule_configs mutation path: actor, before, after, timestamp. - Apply bundle update is the only audited action that originates from this tab. It writes a program_bundle_migrations row containing actor, prior bundle version, new bundle version, the rule-set diff, and the free-text reason captured in the migration modal. - Dismiss bundle update does not migrate but does audit a lightweight "dismissed" entry so reviewers can see the banner was acknowledged and intentionally deferred. ## Where to Go Next - Rules Engine spec — five-layer architecture, rule trees, facts layer, evaluation pipeline, override governance, and the full data model. - Program Register — where program identities (and their baseline rule configurations) live. - Seasons — how season overrides are scoped and authored. - Roles & Governance — the full permissions map for program_rules.* and compliance.rules.manage. - Glossary — single-line definitions of Compliance Rule, Business Rule, Source Classification, and related terms. --- --- title: "Workflow Studio" slug: workflow-builder section: Program Configuration status: stable description: "A visual workflow editor for designing, versioning, and managing applicant workflow definitions across programs and seasons" last-updated: 2026-10-07 --- # Workflow Studio A visual workflow editor for designing, versioning, and managing applicant workflow definitions across programs and seasons > **Workflow Studio in Globus** > > The Workflow Builder (internally called Workflow Studio) is the primary tool for program administrators to define, configure, test, and publish the applicant workflows that power the Applicant Lifecycle . Every step an applicant sees in their portal, every document they upload, and every compliance gate they pass through is defined here. ## Architecture Overview The Workflow Builder operates on a layered architecture that separates workflow definition from workflow execution. Definitions are authored in the builder and published as immutable versioned snapshots. The runtime engine ( Layer 4 — Workflow State and Evidence ) consumes these snapshots to drive applicant progress. ## 4-Node-Type System Every element on the workflow canvas is one of four node types. This type system enforces structural consistency across all workflows while allowing flexible composition. > **Composition Rules** > > Standard nodes can contain subtask children. Hub nodes can contain standard or subtask children. Placeholder nodes are always leaf nodes. These rules are enforced by the builder canvas — invalid nesting is prevented at drag-and-drop time, not at publish time. ## Step Library The Step Library is a centralized catalog of reusable step templates. Program administrators browse, search, and insert steps from the library into their workflows. Steps are versioned independently from the workflows that use them. **Test extraction route.** `POST /api/step-library/test-extraction` (API key scope `workflows.read` or `admin.write`, permission `programs.manage`) runs a trial AI extraction or translation against an uploaded sample. The body needs `fileBase64`, `fileName`, `mimeType` (JPEG, PNG, GIF, WebP or PDF), `action` (`extract` or `translate`) and `fieldLabel`; a missing field, another action, an unsupported type or a base64 string over 10485760 characters returns `400 VALIDATION_ERROR`. The response is `{ result, action, model }` and the call is audited as `step_library.test_extraction`. ### Versioning & Publishing Each step in the library follows a semantic versioning model. When a step template is updated, existing workflows referencing the previous version are not affected until the workflow author explicitly upgrades. This ensures stability for in-flight applicants. Version Model Major version: breaking changes to step schema or behavior / Minor version: new optional fields or configuration / Patch version: content or label updates only / Each version is an immutable snapshot Upgrade Policy Patch upgrades: auto-applied to draft workflows / Minor upgrades: surfaced as suggestions, manual apply / Major upgrades: require explicit migration and review / In-flight applications: never affected by upgrades ### Deprecation Lifecycle Steps move through a defined lifecycle from creation to retirement. Deprecation is a soft-removal mechanism that preserves existing workflows while preventing new usage. > **Deprecation Safety** > > When a step is deprecated, the builder displays a warning badge on every workflow that still references it. Staff can continue to use those workflows, but new workflows cannot insert deprecated steps. A bulk migration tool assists with upgrading deprecated steps to their replacements. ### Browse, Search & Insert The Step Library panel slides open from within the builder canvas. It provides: ## Builder Canvas The builder canvas is the visual workspace where workflows are assembled. It renders the workflow as a structured sequence of phases, each containing ordered steps. ### Drag-and-Drop Editor The canvas supports full drag-and-drop interaction for workflow assembly: Reorder steps within a phase by dragging vertically / Move steps between phases by dragging horizontally / Insert new steps from the Step Library by dragging from the side panel / Nest subtasks under parent nodes by dragging onto a hub or standard node / Visual drop indicators show valid drop targets and insertion points / Undo/redo support for all canvas operations / Node composition rules enforced in real-time during drag ### Phase Management Phases are the top-level containers in a workflow. They correspond to the Applicant Lifecycle phases and can be managed directly from the canvas: Add Phase / Rename Phase / Delete Phase / Reorder Phases Deleting a phase requires confirmation and only succeeds if the phase contains no steps. Steps must be moved or removed first. Phase reordering updates the sequence for new applications; in-flight applications remain on their bound workflow version. ### Inline Step Editing Clicking any step on the canvas opens an inline configuration panel. Edits are reflected immediately on the canvas and auto-saved to the draft. The panel provides access to all step configuration options: labels, descriptions, role assignments, SLA targets, AI settings, visibility rules, and markdown content. For an existing hub step (`nodeType` of `hub_step`, or `isHub` set), submitting the edit form calls the staged hub editor's `saveHub` with the step data. On success a "Step and subtasks saved" toast appears and the edit dialog closes. If the save fails, the staged editor shows the error and keeps every pending edit. In the Workflow Studio, the Configure Hub panel manages the sub-tasks of a hub step. It also manages the hub's placeholder hub mappings and their conditions, through the `/api/placeholder-hubs` and `/api/placeholder-conditions` routes. Hub sub-task membership lives in two stores: `portal_hub_sub_tasks` (what the panel reads and writes through the `/api/hub-sub-tasks` routes) and the draft's `workflow_steps` rows (`node_type` `subtask_step` with `hub_key`), which publish validation and the runtime actually read. Every hub sub-task mutation (add, remove, required-flag change, reorder) is therefore projected into each draft definition of the same program and workflow type that already contains the hub step (a legacy row with no workflow type matches any type). Adding converts an existing same-key step in place or inserts a new `subtask_step` row; removal only deletes rows that are sub-tasks of that hub. When a qualifying draft and hub exist the projection must succeed or the request fails with an error (a failed add also deletes the just-created `portal_hub_sub_tasks` row). Before this projection, hubs configured in the panel failed publish with "Hub X has no subtasks"; the `20270811000000_backfill_hub_subtask_draft_steps` migration repaired drafts created earlier. ## Version Management Every workflow has a complete version history. The builder enforces a strict draft/published lifecycle to prevent accidental changes to live workflows. ### Draft & Published States Editable — all canvas operations available / Not visible to applicants or portals / Can be previewed via Admin Preview / Can be tested via Test Application Flow / Auto-saved on every change / Only one draft version exists at a time per program and workflow type (enforced by a database unique index, workflow_definitions_one_draft_uniq) / A draft whose version is at or below the current published version cannot be published (WorkflowVersionRegressionError) — create a new draft from the current published version instead Immutable — no edits allowed / Active for new applications entering the workflow / Bound to each application at creation time / Diff-able against any other version / Full audit trail of who published and when (publishedBy / publishedAt on the definition) / Previous published versions retained indefinitely (archived) > **Publish Flow** > > Publishing a draft creates a new immutable version snapshot. The system validates that every placeholder node has valid routing (an existing source step and field, at least one mapping with a value and a target hub, and mapped and default hubs that exist in the workflow), all required step configurations are complete, the prerequisite graph is valid (see Step Prerequisites), the effective V2 rule coverage has no blocking gaps (see Bundle Publish Gate), and the structural caps in Publish-Time Cap Validation are not exceeded before allowing publish. Publish requires the system-admin role; editing a draft requires programs.manage. When the program has active applications, publish also requires an explicit acknowledgement (acknowledgeActiveApplications: true), otherwise it returns a structured 409 carrying the active-application count. ### Version Invariants & Publication History Workflow Studio selects the authoring heads deterministically: the current published head is the highest-numbered published version and the current draft head is the highest-numbered draft for the program and workflow type. Publication is a single guarded transaction: - The publish transaction takes the workflow carrier lock and a pair-level (program + workflow type) publication lock, so two drafts can never publish concurrently and a reconciliation cannot restore the portal mirror for a definition that was just superseded. - The transaction asserts the draft version is greater than the published version; a lower or equal draft is refused rather than replacing the published head. - Callers that prepared a draft earlier (for example startup seeders) pass the publication head they expected; if another writer published in the meantime the publish is refused with WORKFLOW_PUBLICATION_HEAD_CHANGED. - The previous published version is archived and the draft is promoted, recording publishedBy, publishedAt and a content hash that covers steps, phases and prerequisite edges. - Every publish writes an immutable publication-history row (workflow_publish_history) holding the publisher, version, notes, and a snapshot of the definition, its steps, phases, prerequisite edges and the step-library versions in effect. Publication history is separate from Applicant Portal delivery/mirror attempts, which are audited separately (portal_mirror.synced / skipped_superseded / sync_failed). - **Pinned snapshot resolution.** The Applicant Portal reads a workflow through `GET /api/integrations/applicant-portal/applications/:applicationId/workflow-snapshot` (integration scope `integrations.portal`). It resolves the version pinned on the application (`workflow_version_id`) from the publication snapshot recorded for that version (a legacy publication with no snapshot reads the pinned version's own rows), never the program's current head, and an archived version that was previously published is still served. Unresolvable pins return `422` with the code at the top level of the error: `WORKFLOW_VERSION_NOT_PINNED` (no pin), `WORKFLOW_VERSION_NOT_FOUND` (the pinned definition is gone), `WORKFLOW_VERSION_NOT_PUBLISHED` (never published), `WORKFLOW_SNAPSHOT_MALFORMED` (the stored publication snapshot cannot be read, with `details.issues`, or its frozen step-key list names steps the pinned version no longer defines, with `details.missingStepKeys`) and `WORKFLOW_PHASE_METADATA_UNAVAILABLE` (steps reference phases with no stored metadata). A broken pin is reported rather than silently shortening the applicant's journey. > **Startup seeds never overwrite human publications** > > Seeded workflows are bootstrap defaults, not an authority over Workflow Studio. If the published version was not created by the seed and published by the seed actor, the seeder leaves it untouched and reports it as protected, even when the repository fixture differs. Seeds only create or update seed-owned drafts and publications, and they publish through the same guarded path (with the expected-head check above). ### Publish-Time Cap Validation (7 / 6) Workflow Studio enforces two structural caps so the applicant portal stepper renders cleanly on the first screen an applicant ever sees: maximum 7 steps per phase and maximum 6 phases per workflow. The caps are enforced at three points in the draft → published lifecycle. > **The 7-step cap counts applicant-facing steps only** > > Because the cap protects the applicant stepper, it counts only applicant-facing steps . Behind-the-scenes steps (e.g. sponsor or host interviews) never render on the stepper, so a phase may hold more than 7 total nodes as long as no more than 7 are applicant-facing. A phase can, for example, carry six applicant-facing steps plus a behind-the-scenes interview node and still publish. Soft warning, non-blocking. When an admin drags in the 8th step of a phase or the 7th phase of a workflow, the canvas surfaces an inline warning recommending the workflow be restructured. The draft still saves. This is the same mechanism the help page calls out under Limits & Guardrails . Hard validation gate, blocking. The publish endpoint runs the cap-validation module against every phase in the draft. Any phase with more than 7 steps, or a workflow with more than 6 phases, is rejected with a structured error naming each offending phase. No new published version is created. Same gate, applied to legacy versions. If a workflow was published before the caps existed and a new draft of it is re-published, the same cap-validation module runs and rejects the publish until the workflow has been manually restructured into compliant shape. See Legacy Workflow Remediation . > **Single source of enforcement** > > The cap-validation module is the only enforcement point. The applicant portal renders defensively with horizontal scroll if over-cap data ever reaches it (see Applicant Lifecycle — Stepper Fallback ) but never rejects, warns, or modifies workflow data. Conversely, the canvas warning is informational only — it does not block the draft save. All blocking happens at publish. Two further refusals carry stable codes. **`WORKFLOW_STRUCTURE_INVALID`**: the publish transaction re-validates the draft's steps and phases and refuses a draft with structural problems with a validation error whose `details.code` is `WORKFLOW_STRUCTURE_INVALID` and whose `details.errors` lists each problem. Phase checks: a workflow with no steps or no phases; blank, padded, duplicated or undeclared phase keys; phases without an applicant-facing name or without steps; and steps not assigned to a phase. Step checks (the publish route also runs these first and reports them as a plain validation error without that code): hubs with no subtasks, subtasks whose parent hub is missing or unknown, hub or standard steps without an applicant journey position, placeholders with missing or invalid routing (source step and field, mappings, target hubs, default hub), and visibility conditions that reference a missing step or one that is not earlier in the workflow order. Invalid prerequisite edges (duplicates, self-reference, non-approvable or permanently unavailable prerequisites, and cycles) are checked by the publish route and reported the same plain way. **`WORKFLOW_STEP_DOMAIN_MISMATCH`**: HTTP 422 when a step is attached to a definition of the opposite domain, host-side or applicant-side, judged from the step library's `supported_workflow_types`, for example a `jo_*` job-order step on an `application` workflow. `details` carries `definitionWorkflowType` and `offending` (step key and domain). Steps whose library entry is unknown or mixed-domain pass. The guard runs on draft step creation and update, portal program-config population, data import, step upsert, bootstrap seeding and cloning a definition to a draft. ### Legacy Workflow Remediation Workflows that were published before the caps were introduced and that already exceed 7 steps in any phase or 6 phases total are flagged as non-compliant. Their currently-published version continues to run for in-flight applications — existing applicants are never disrupted by a structural rule change — but the workflow is blocked from re-publishing until it has been restructured by hand. There is no automatic migration; the redesign is manual. Notification A single staff-targeted notification event, workflow_cap_violation_detected, is emitted by the cap-validation module: Recipient: Karin Brettenny (single named recipient, configured in the notification catalog) / Channel: email only (no in-app, no SMS) / Backfill: one-time sweep on first deployment lists every currently-published workflow that exceeds the caps / Ongoing: re-fires when a newly-detected violation appears (e.g. a previously-compliant workflow that was just edited) / Payload: program name, sponsor, phase name, and the violated cap (steps-per-phase or phases-per-workflow) for each offending workflow > **No silent migration, no automatic split** > > The platform never auto-splits a phase, never auto-merges phases, and never modifies workflow structure on behalf of an admin. Restructuring an over-cap legacy workflow is always a deliberate editorial action by Karin’s team. The notification exists so they know which workflows need attention without scanning every program manually. ### Content-Aware Reconciliation Publishing a new workflow version is followed by a reconciliation pass that decides which in-flight applicants are repinned to the newly published version. The reconciliation pass is content-aware: it looks at what changed step-by-step, not just whether a new version was minted, and requires explicit human approval before mutating in-flight applicants when material content differs. Publish and reconciliation are separate calls: publish only needs the active-application acknowledgement described above; the approval gate lives on the apply-reconciliation call. Reconciliation Contract GET /api/workflow-studio/programs/:programId/reconciliation-preview returns contentChangedSteps[] — steps present in both the current published definition and the most recently archived one whose isRequired, configJson or conditionalConfigJson differ (the matching step-library name, description and schema are attached as informational after-values). Pure ordering/cosmetic changes are excluded. / When contentChangedSteps is non-empty the preview sets requiresApproval = true. POST /api/workflow-studio/programs/:programId/reconcile-applications without an approval returns 422 RECONCILIATION_APPROVAL_REQUIRED naming the changed step keys. / Re-call reconcile-applications with approval: { confirmedBy, reason } in the body (both non-empty) to acknowledge the impact and proceed. The approval envelope is persisted on the reconciliation run alongside the content diff so the decision is queryable later. / The approval requirement is not exempted for auto-reconcile or cron-driven triggers — the apply service never derives confirmedBy on the caller's behalf. / If contentChangedSteps is empty (e.g. pure ordering change, presentation-only tweak) reconciliation proceeds without an approval envelope. Apply Behaviour Apply runs in one transaction under the same pair-level publication lock as publish; if the published head changed since the preview it fails with a 409 conflict ("Preview and retry") instead of repinning to a superseded version. / Inside that transaction the Applicant Portal mirror (structure and field configs) for the published definition is rebuilt first — including no-op reconciliations where every application is already current — so reconciliation doubles as the repair path for a mirror that missed a publication. / Every affected application's workflowVersionId is repinned to the published definition in the same transaction, including content-only reconciliations whose structural step diff is empty. Steps added by the target version are stamped with the target version; previously removed steps are restored from their reconciliation tombstone instead of staying archived. / After commit, conditional visibility is re-evaluated for affected applications; a failure there marks the run completed_with_warnings rather than rolling back. > **Why a hard gate, not a warning** > > Material content changes (new validation rule, new required attachment, changed completion contract) can retroactively invalidate work an applicant has already submitted. A soft warning would let an auto-reconcile job silently break in-flight applicants overnight. The RECONCILIATION_APPROVAL_REQUIRED gate forces a named human to sign off before that side-effect lands. ### Step-Edit Guard (Active Applicants) Library-level step edits (changes to a shared step template that flows into every workflow using it) and direct workflow-step edits are guarded against destructive change while applicants are mid-flight on a workflow that references the step. Guarded Endpoints POST /api/step-library / PATCH /api/step-library/:id / PATCH /api/workflow-steps/:id / DELETE /api/workflow-steps/:id Guard Contract Before the mutation commits, the guard counts active applicants currently on any published workflow version that references the target step. / If activeApplicantCount > 0 the call returns 409 STEP_EDIT_BLOCKED_BY_ACTIVE_APPLICANTS with so the UI can surface exactly which programs are blocking and how many applicants are affected. / The guard never auto-clones the step or auto-bumps a version — restructuring is a deliberate editorial action (draft a new step, publish a new workflow version through Content-Aware Reconciliation, deprecate the old step via the standard lifecycle). / Read paths (GET) are never blocked. The guard is mutation-only. / The guard runs before the database transaction opens; a 409 leaves zero side-effects. > **409, not 422** > > The guard is a conflict error, not a validation error. The edit itself is well-formed; the system state (active applicants on the step) is what makes it unsafe. Clients should treat the response as “retry after restructuring,” not as “fix your payload.” ### Side-by-Side Diffing The version diff view displays two workflow versions side-by-side with visual indicators for changes. This is available between any two versions (draft vs. published, or any two historical versions). Diff Indicators ### 360 Audit Trail Every change to a workflow — at both the workflow level and individual step level — is recorded in a 360 audit trail. This provides a complete history of who changed what, when, and why. Who changed it / What changed / Old value / New value Tracked Operations Step added, removed, or reordered / Step configuration changed (label, SLA, role, AI) / Visibility rule added, modified, or removed / Phase added, renamed, deleted, or reordered / Conditional branch created or modified / Version published or rolled back / Step Library template version upgraded / Markdown content or attachments updated > **Step-Level History** > > The audit trail is queryable at the step level. Clicking any step shows its full change history across all versions — useful for understanding why a step was modified and by whom. ### Rollback Any previously published version can be restored as the new draft. Rollback does not affect in-flight applications — they remain bound to their original workflow version. Rollback creates a new draft that is a copy of the selected historical version and must still go through the normal publish flow. ## Conditional Logic The Workflow Builder supports conditional logic that controls step visibility, branching paths, and automated phase progression based on applicant data, program rules, and external signals. ### Branching Visualization Conditional branches are rendered visually on the canvas as diverging paths from a decision point. Each branch is labeled with its condition and shows the steps that execute when the condition is met. The canvas highlights which branch an applicant would follow based on sample data in Test Application Flow Mode. ### Step Visibility Rules Each applicant-facing step can have visibility rules that control whether it appears to the applicant. Unlike branching (which controls execution flow), visibility rules control portal rendering. A hidden step is skipped in the portal UI but its position in the workflow is preserved. > **Hidden is not the same as behind-the-scenes** > > Visibility rules conditionally hide an applicant-facing step for some applicants based on their data — the step is still part of the applicant’s journey and may render for others. This is distinct from a behind-the-scenes step (e.g. a sponsor or host interview), which is staff/host-only by audience and never renders on the applicant portal for anyone. See Step Audience for that model. Visibility Rule Examples camp / returning / condition > **Visibility vs. Branching** > > Visibility rules are for portal rendering — the step exists in the workflow but may not be shown to the applicant. Branching is for execution flow — the step may or may not execute based on conditions. A step can have both: it may only execute for certain branches AND have additional visibility rules within that branch. ### Step Prerequisites A step can require one or more other steps to be staff-approved before applicants can start it. Unlike a visibility rule, a prerequisite keeps the step visible but locked, with an explanation that it is waiting for review. Prerequisites are explicit edges in the workflow definition (workflow_step_prerequisites); nothing is inferred from sort order or isRequired, and existing workflows without edges behave exactly as before. - Authored on draft steps beside Visibility (PUT /api/workflow-steps/:id/prerequisites replaces a draft step’s prerequisite set; staff-only, cannot modify published or archived definitions). Multiple prerequisites use AND semantics. - Only the tracked approved status satisfies an edge. Applicant submission, in-progress work, rejection and untracked snapshot completion do not unlock the dependent step; staff approval does. - The canonical Step Access resolver evaluates the edges dynamically against the application’s pinned workflow version and returns access: locked with lock_reason_code UPSTREAM_STEP_INCOMPLETE and unlock conditions step..status equals approved. No section-lock rows are materialized. Malformed or cross-definition edges fail closed for the affected step. - Validation rejects self-references, duplicate edges, edges to steps outside the workflow, non-approvable sources (only standard and subtask steps can be prerequisites), sources whose visibility is permanently unavailable, and directed cycles. It runs on draft edits, bundle import, Publish Preview and publish. - Edges belong to a definition version: clone, publish (content hash and publication-history snapshot), export and import all preserve them, keyed by stable step keys. - The builder surfaces prerequisite locks in list view (a “Blocked until staff approves” line), the Mind Map (count of required approvals) and Preview as Applicant (step stays visible but labelled locked). ### Step Audience (Behind-the-Scenes Steps) Every step node carries an audience: applicant-facing or behind-the-scenes (staff-only or host-only). Audience is derived from the step’s role assignment : a step whose acting roles are all staff or host roles — with no applicant role — is behind-the-scenes. A sponsor or host interview is the canonical behind-the-scenes step. > **Behind-the-scenes interviews stay in the step tree — they just never reach the applicant** > > An interview node remains a real node in the program’s step tree. Admins author it, see it on the canvas, order it, and bind its outcome like any other step. It is simply flagged behind-the-scenes by role assignment, so it is never sent to the Applicant Portal and never renders on the applicant stepper. This is the single model for interviews across the docs — an interview is not removed from the workflow, and it is not an applicant-facing step that happens to be hidden. Behind-the-Scenes (audience) - Staff-only or host-only by role assignment - Never applicant-facing for any applicant - Excluded from the applicant payload entirely — not transmitted with access = "hidden" - Does not count toward the 7-steps-per-phase stepper cap - Still produces facts/evidence that gate the phase (see below) Conditionally Hidden (visibility) - An applicant-facing step suppressed by a data-driven rule - May render for other applicants whose data matches - Carried in the contract as access = "hidden" on an applicant-facing step - Counts as an applicant-facing step for the stepper cap > **Gate bridge — a behind-the-scenes interview still gates the phase** > > Hiding an interview from the applicant does not weaken it as a gate. The interview node produces an outcome fact through the Step → Fact Contract (for the sponsor interview, e.g. screening.sponsor_interview.recommended_outcome), and that fact is what phase completion and the Rules Engine read. The host interview → job-order link is preserved — the outcome still carries its job_order_id so a passed (accepted) host interview can feed placement confirmation. Detailed outcome capture lives in the Interview Result Capture (P11.3 Work Type) spec. ### Phase Completion & Auto-Advance The workflow engine continuously evaluates phase completion based on step states. When all required steps in a phase are complete (accounting for visibility rules and conditional branches), the engine can automatically advance the applicant to the next phase. Detection Logic Evaluates only visible steps (hidden steps skipped) / Hub completion requires all child nodes complete / Optional steps do not block phase completion / Branching: only the active branch is evaluated Auto-Advance Configurable per phase (on/off) / When enabled, phase completes automatically / Rules Engine gate still required between phases / Audit log records auto-advance events Manual Override Staff can manually advance phases / Override requires RBAC permission / Override recorded in audit trail / Rules Engine bypass requires separate permission ## Workflow-to-Rules Coverage Panel The coverage panel provides condition-level traceability between the workflow steps and the Rules Engine structured rule definitions they satisfy. It reads from the rule_definitions table and maps each rule’s typed conditions and evidence sources to the workflow steps that produce the required evidence. It answers: “Does this workflow produce all the evidence the Rules Engine needs to make its decisions?” Effective rules are resolved live — the rule set is the high-wins resolution of the published V2 regulatory bundles matching the program (the same resolution the Rules workspace uses), each rule pinned to its bundle-pinned (or current published) rule version, with fact sources taken from coverage bindings, the system-process registry and workflow-step producer metadata in the facts catalogue. Only facts reachable for the program’s category and visa type count: branches of a rule tree that cannot apply to the program are skipped, and program context is treated as structural rather than applicant evidence. The panel splits into two stacked sections: a Regulatory Coverage section grouped by regulatory rule bundle (where every rule must carry an explicit fact-source binding before publish is allowed), followed by the existing business-rule rows (where uncovered rules are warnings, not errors). The two sections share the same step → fact and rule → fact contracts, but only the regulatory section gates publish. | Field | Description | | --- | --- | | label | description | _(row above is a template, repeated for each data row)_ > **Behind-the-scenes steps still count as coverage** > > Coverage is keyed off the Step → Fact Contract , not off portal visibility. A behind-the-scenes interview node that declares an outcome fact (for the sponsor interview, e.g. `screening.sponsor_interview.recommended_outcome`) is credited as the covering source for any rule condition that requires it — exactly as an applicant-facing step would be. Keeping an interview off the applicant stepper never opens a coverage gap. > **Gap Suggestions** > > When the coverage panel detects an uncovered condition, it suggests Step Library templates that could satisfy it. The evaluator metadata shows which rule evaluator (eligibility, compliance, SEVIS readiness) depends on the condition, helping administrators prioritize coverage gaps. ### Phase-Scoped Evaluation The coverage panel uses lifecycle phase scoping to determine which rules are relevant to the current workflow. Each structured rule definition in the Rules Engine declares which lifecycle phase(s) it applies to via the lifecycle_phases array. The coverage panel only evaluates rules scoped to the workflow’s target phases. | Rule Phase Scope | Coverage Panel Treatment | Publish Impact | | --- | --- | --- | | Application Flow (Phases 1–6) | Counted toward coverage percentage. Uncovered rules appear as gaps. | Compliance rules block publishing. Business rules produce warnings. | | In Program (Phase 7) | Displayed in a separate informational section. Not counted toward coverage %. | No publish impact. Shown for awareness only. | | Completion (Phase 8) | Displayed in a separate informational section. Not counted toward coverage %. | No publish impact. Shown for awareness only. | > **Coverage Percentage Calculation** > > The coverage percentage is calculated as: (covered Application Flow rules) / (total Application Flow rules) × 100. On-Program and Completion rules are excluded from this calculation entirely. This prevents false gaps for rules like SEVIS check-in enforcement, monthly check-ins, and exit surveys that are evaluated outside the application workflow. ### Regulatory Coverage Section Rules attached by a regulatory bundle (rows in program_rule_configs whose bundle_id is set) are pulled out of the general coverage list and shown in their own collapsible group, one group per attached bundle (J-1 Standard v3, Camp Counselor Addendum v2, etc.). Each row in a bundle group renders the rule key, the regulatory citation (rule_definitions.regulatory_basis), the required fact keys, and an inline binding picker. > **Why bundle grouping matters** > > Sponsor review treats "every rule attached by the J-1 Standard bundle" as a single auditable unit. Showing the rules grouped by bundle (with the bundle key, version, and citation per row) is what lets a Compliance Officer hand a Workflow Studio screenshot to a sponsor and say "here is exactly what satisfies the SEVIS check-in rule for this program" — not just "coverage = 87%". ### Step → Fact Contract *Status: design spec, only partly built. In code, step fact producers are declared in the facts catalogue (`workflowStepProducer` on the fact definitions that a workflow step produces, in `shared/factsCatalog.definitions.*.ts`) and the system-process registry (`shared/systemProcessRegistry.ts`). The `produces_fact_keys` array on the step template, the `source_kind` column and the "step does not declare its fact contract" message described below are not in the code.* Each step template in the Step Library declares the canonical fact keys it produces when its completion condition is satisfied. This is what makes a step bindable as the source for a rule’s fact requirement. The contract lives on the step template (not on the workflow node) so it travels with the step across every workflow that inserts it. type StepLibraryEntry ; // Example: passport upload + verify step documents.passport.upload_and_verify / passport.verified / passport.expiry_date / verification - A step may produce zero, one, or many fact keys. Hub and placeholder nodes never produce facts (a placeholder publishes as long as its routing is valid). - System processes are non-step fact producers (e.g. fee_collector, sevis_checkin_engine). They are registered in the code-seeded system-process registry (`shared/systemProcessRegistry.ts`) under a fixed processKey, so bindings (source_type = system_process) can target them when no workflow step is the right answer. - Adding a new fact_key to a published step is a minor version bump (the produced surface only widens). Removing one is a major version bump and triggers a coverage recheck on every program that bound to it. - Migration: every existing seeded step in the library is backfilled with its produces_fact_keys array before this gate goes live; steps that have not been backfilled cannot be bound and will show as "step does not declare its fact contract" in the coverage panel. ### Step Access & Lock Contract Step locking is an authority problem, not a portal feature. The structure is authored here in the builder, but whether a given applicant can open, edit, or submit a step is derived by Globus and shipped to the Applicant Portal as read-only truth. The portal renders that state and enforces it in its UI; it never computes its own locks. If the portal evaluated rules locally you would get split-brain the moment the portal and Globus disagree — and the portal would win on screen while Globus wins on enforcement. > **Globus derives access; the portal renders it** > > The Applicant Portal must never ask “should this step be locked?” It asks only “what did Globus say this applicant can do with this step?” Access is always evaluated against the application’s pinned workflow version (see Per-Application Version Tracking ) — republishing a workflow never retroactively re-locks an in-flight applicant. Locks only change on a fresh derivation, surfaced via the step.lock_changed event and reconciled on application.lifecycle_reconciled. Status and access are two orthogonal enums — do not mix them. A step can be completed and still view_only. Status describes how far the applicant has gotten; access describes what they may do right now. #### status - not_started - in_progress - submitted - approved - rejected - completed #### access - editable — applicant can complete or update the step - view_only — can view submitted data, cannot change it - locked — cannot open or edit yet - hidden — step is not shown for this applicant For every step in an application’s instance Globus returns a resolved access record. The portal renders sidebar, stepper, cards, form access, and buttons directly from it — including the capability booleans, which spell out the four actions the portal must gate independently: type StepAccess ; The lock cause maps onto the existing rule failure modes ( Rules Engine : block, warn, route_to_queue) rather than a parallel vocabulary: - block → access: locked with a lock_reason_code. - route_to_queue → locked + a staff gate that surfaces in the queue system with SLA timers — not just a boolean. - warn → not locked; the step stays editable and the portal renders a proceed-with-warning state. A binary editable/locked model would lose this case. - Globus returns a stable lock_reason_code plus structured unlock_conditions — the portal owns the human copy and i18n. Presentation text stays in the experience layer. > **Applicant edit-until-review window** > > A submitted step does not immediately become uneditable (Aug 11 2026 release capsule). An applicant can keep editing a submitted step until a staff member begins their review, and a step returned for revision reverts to editable the moment it is returned. Submitting a step no longer locks it; the step stays editable through the resolved access record until staff review begins. This removed the back-channel correction requests that previously had to be handled via support tickets. > **Request Changes and resubmission** > > Staff "Request Changes" on a step is still stored as step status rejected (for compatibility), but the decision must carry a reason drawn from the governed workflow-evidence review reasons (reason purpose workflow_evidence_review; exact-step reasons sort ahead of general ones; listed by GET /api/applications/:id/step-review-reasons, and any other reason is refused with WORKFLOW_REVIEW_REASON_NOT_APPLICABLE). The applicant-facing message is prefilled from the reason, staff may edit it (up to 2,000 characters), and the reason label and message are snapshotted on the decision and review session, separate from staff-only review notes. All deterministic validation runs before any decision, audit or tracked-status write, so a rejected request leaves no partial state. For Skills & Hobbies item-level review, Approve requires every item reviewed and approved, while Request Changes needs only at least one item marked changes_requested with an applicant-visible comment; other items may stay not_reviewed, and a published item that is still unresolved can drive a repeat whole-step Request Changes. A step cannot be approved while any published item is changes_requested or applicant_resubmitted. > > The Portal submit route (POST /api/portal/steps/:stepKey/submit) is the final Submit/Continue boundary: the unsupported isFinalSubmission field was removed, and every successful call runs the resubmission transition after the snapshot is saved. A rejected step moves back to submitted (recorded as an applicant resubmission) and its open changes_requested items become applicant_resubmitted; other steps are unaffected. Incremental saves go through the separate integrations applicant-portal events endpoint and never trigger this transition. Unresolved resubmissions are surfaced to staff on the Applicant Profile, the Applicants list and Attention views. > **Enforce server-side, fail closed** > > The rendered lock is a hint. The authoritative gate is Globus re-checking access on write: a step.submitted against a step whose can_applicant_submit is false returns 403 STEP_LOCKED, regardless of what the portal showed. Delivery is pull for truth on load, push for liveness via step.lock_changed. The event carries only the access delta — it is a liveness signal, not the source of truth; consumers read unlock_conditions and the capability booleans by refetching the StepAccess snapshot (on change and on application.lifecycle_reconciled). If access state is stale or unreachable, compliance-gating steps default to locked — staleness must never become a bypass. Locking is processing-area-aware: pre-SEVIS work runs across concurrent processing areas , so a naive “previous phase must complete” linearization is wrong before the SEVIS convergence gate. ### Rule → Fact Contract Each rule definition declares the fact keys it consumes via the required_fact_keys column on rule_definitions. For a regulatory bundle rule the field is mandatory — a rule with an empty required_fact_keys cannot be attached to a published bundle. For business rules the field is optional; when present it powers the same coverage matcher, when absent the panel falls back to the legacy evidence-source matching. > **Source of truth for required_fact_keys** > > For rules authored as a typed rule tree , the engine derives required_fact_keys automatically by walking every leaf, derived, and time_window node and collecting their fact_key references. The column is the materialized result, kept in sync on every rule version publish so the coverage matcher does not have to re-walk the tree on every panel render. ### Coverage Binding Model The binding itself lives in program_rule_coverage_bindings — one row per (program, regulatory rule): | Column | Meaning | | --- | --- | | program_id | FK → programs. Bindings are program-scoped (see binding scope rules below). | | rule_version_id | FK → rule_versions. The effective rule version being satisfied (bundle-pinned, or the rule’s current published version). The rule definition is derived from the version. | | source_type | workflow_step \| system_process. Constrained enum, no free text. Coverage validation only counts workflow_step rows. | | source_id | When source_type = workflow_step, the immutable step_key. When source_type = system_process, the registered process key. | | fact_key | The single fact key this row supplies for the rule version; a rule needing several facts has one row per fact. Rows with a null fact_key (whole-rule rows) remain readable but never participate in coverage diagnostics. | | notes | Optional free-text justification recorded for the audit trail (e.g. "using consular interview as the SEVIS-readiness proxy this season"). | | created_at | Audit column (no created_by, updated_by or updated_at). Bindings are versioned by audit, not by row — see history modal. | Validation rules (server-side, on save): - source_key must resolve in the step library (or the registered system-process catalog) and the resolved entry must not be deprecated or archived. - satisfied_fact_keys must be a subset of the source’s declared produces_fact_keys — you cannot claim a step satisfies a fact it does not produce. - satisfied_fact_keys must be a superset of the rule’s required_fact_keys — every fact the rule needs has to be covered by this binding. - A fact with deterministic provenance — one produced by an authoritative system process or by workflow-step producer metadata in the shared facts catalogue — cannot receive a manual binding; that provenance is platform-owned and is credited automatically. - When the binding is a workflow_step and the workflow being authored does not yet contain that step, the panel marks the binding pending step insertion and surfaces a one-click "insert this step here" affordance. Binding scope: - Bindings are program-scoped. Seasons inherit the program’s bindings unmodified by default. - A season may narrow a binding (swap one workflow_step for another that produces the same fact_key set, or substitute a system_process for a workflow_step). It may not blank a binding out — the publish gate evaluates the resolved (program ⊕ season) binding and blocks if the result is empty. - Auto-suggest is allowed; auto-commit is not. When more than one step in the current workflow produces the required fact_key, the panel ranks the candidates but the Compliance reviewer must click to accept one. Single-candidate bindings are also click-to-accept (never silently set on the user’s behalf). ### Bundle Publish Gate Workflow publish and Publish Preview both call the same canonical V2 coverage validator (validateV2WorkflowCoverage). It depends only on published V2 bundles, their high-wins rule resolution and program_rule_coverage_bindings — never on legacy compliance_rule_definitions or V1 eligibility rule sets, so equivalent V2 configuration gives the same publish decision in every environment. The publish endpoint runs it inline and rejects with a validation error listing the blockers (details: v2CoverageErrors, uncoveredRegulatoryRules, coveredRegulatoryRules). POST /api/workflow-versions/:id/validate-coverage (body: programId; permission compliance.bundles.read) exposes the same validator and returns HTTP 422 if any error is found for any effective rule: | Failure mode | Reason code | What the message names | | --- | --- | --- | | Effective rule definition is missing from the Rule Library | BUNDLE_RULE_DEFINITION_MISSING | rule definition id | | Effective rule version is not backed by a published version (or a pinned published/archived version of that rule) | BUNDLE_RULE_VERSION_UNPUBLISHED | rule name and key, expected vs found rule version id | | Rule tree is invalid and cannot be covered | BUNDLE_RULE_VERSION_INVALID | rule name and key | | A required fact key has no coverage binding, system-process provenance or active workflow-step producer | BUNDLE_RULE_BINDING_FACT_GAP | rule name and key, the missing fact key, suggested workflow step keys | | No coverage binding for the program points at the rule's effective version | BUNDLE_VERSION_MISMATCH | rule name and key, expected vs found rule version ids | | Binding points at a step that is retired, deprecated, archived or missing from the step library | BUNDLE_RULE_BINDING_SOURCE_RETIRED | rule name and key, source step key and its status | > **Failures are a list, not a toast** > > The validate-coverage 422 response carries errors[] (one entry per offending rule with code, rule_definition_id, rule_key, rule_name, classification, message, suggested_step_keys[] and details), a non-blocking business_rule_warnings[] list of { message }, plus workflow_version_id and program_id. The publish dialog renders the same blockers as a list of blocking issues rather than a toast, so a Compliance reviewer can hand the failure list to a workflow author verbatim. Errors classified as business rules are returned as warnings and never block publish; only compliance-classified rules block the publish and preview. Legitimate human-evidence gaps (for example applicantAge, passportExpiryDate, passportDataComplete) remain blockers until they are connected to real workflow steps. A program with no effective published V2 compliance rule publishes with a warning that it will run without program-rule enforcement. ### Regulatory Coverage Report *Status: design spec, not built. There is no Download Regulatory Coverage Report action in the Workflow Studio toolbar, and bindings carry no author or updated_at columns.* The Workflow Studio toolbar exposes a Download Regulatory Coverage Report action available on any draft or published workflow version, scoped to the current program (and optionally a specific season). The report is generated using the same print-styling pattern as the existing audit exports and is downloadable as PDF or printable HTML. Report Layout - Header: program name, season (if scoped), workflow version, generated_at, generated_by, applied policy_bundle_version. - One section per attached regulatory bundle (key + version + status pill). - Within each bundle, one row per member rule with: rule key, name, regulatory citation, required_fact_keys, binding source_kind + source_key, satisfied_fact_keys, binding author, binding last-updated_at. - Rules with no binding render as a single "Uncovered" row in red with a placeholder citation block so the document is still complete and printable. - Footer: full publish-gate validation status (pass / fail) and the count of unbound rules at the time of generation. > **Report is a snapshot, not a live view** > > The report is timestamped and embeds the workflow version id, the program rule binding rows as of the generation time, and the bundle versions in effect. It is intended to be archivable — sponsor reviews reference a fixed PDF, not a live URL. ## Testing & Preview Before publishing a workflow, administrators can validate it using three complementary tools: Test Application Flow Mode, Portal Contract validation, and Admin Preview. ### Publish Preview GET /api/workflow-studio/definitions/:id/publish-preview (draft definitions only) returns the diff against the current published version, the active-application count and a publishable flag with blockers[] and warnings[]. The blockers are computed with the same gates as the publish endpoint — structural validation, phase requirements, prerequisite-graph validation, the 7/6 caps, the linked-object gate for imported workflows, and the canonical V2 coverage validator — so the preview never reports a draft as safe when publish would reject it. The Publish dialog lists the server blockers with its own client checks and disables Publish while any blocker exists, publishable is false, or a regulatory rule is uncovered. Phase-milestone coverage gaps are warnings, not blockers. ### Test Application Flow Mode Test Application Flow Mode creates a simulated applicant that walks through the workflow step by step. The builder shows which steps are active, which branches are triggered, and which phase transitions fire. Capabilities Configure sample applicant data (nationality, program, season, documents) / Step through the workflow one step at a time or auto-play / See which conditional branches activate for the test data / Verify phase completion detection fires correctly / Test visibility rules against different applicant profiles / Validate Rules Engine coverage with synthetic evidence / Export test results as a validation report ### Portal Contract The portal contract defines how each workflow step renders in the Applicant Portal . The builder validates that every step has a valid portal contract before allowing publish. > **Step mirror written synchronously at publish** > > The applicant-portal step mirror is written synchronously in-process at publish time (Aug 11 2026 release capsule). Previously the mirror was written asynchronously after publish, which created a race where applicants could see a stale step view immediately after a workflow went live. Writing the mirror in-band at publish time closes that race — the portal reflects the newly published definition the moment publish completes. **Placeholder resolution.** `POST /api/workflow-studio/portal-contract/:programId/:workflowType/resolve-placeholder` (scope `workflows.read`, permission `programs.manage`) resolves a placeholder step of the published workflow against submitted answers. The body needs `placeholderStepKey` and `fieldData` (a map of field keys to values). The routing config reads `fieldData` at its source field and returns the mapped hub, or the default hub when no mapping matches. The response carries `placeholderId`, `resolvedHubId`, `resolvedSteps` (`stepKey`, `displayName`, `sortOrder`, `isRequired` for the hub's subtasks), `matchedValue` and `matchType` (`exact`, `default` or `none`; `none` returns a null hub and no steps). 404 when the program has no published definition of that type or the step key is not in it; 400 when the step is not a placeholder or has no valid routing configuration. The portal-facing `POST /api/portal/resolve-placeholder` takes `programId` and `workflowType` in the body and returns only `placeholderId`, `resolvedHubId` and `resolvedSteps`. ### Host Portal Contract The Host Portal Contract defines how each workflow step’s completed output is displayed in the Host Portal . While the Applicant Portal Contract controls how the applicant fills in a step, the Host Portal Contract controls how the host views the submitted data. When a new step is added to a workflow, the Host Portal renders it automatically based on this contract — no Host Portal development required. Dynamic rendering principle: When a workflow adds step 14, the Host Portal displays it without dev work. The published workflow version is the single source of truth for what the host sees on an applicant’s profile — phases, steps, completion status, and submitted data are all driven by the workflow definition. > **Host interviews are host-facing behind-the-scenes steps** > > A host interview is a behind-the-scenes step whose audience is the host (not the applicant): it renders in the Host Portal and never on the applicant stepper. The host records its outcome through the Interview Result Capture surface, and the outcome keeps its job_order_id so a passed (accepted) host interview still feeds the placement match it was for. ### Admin Preview Admin Preview renders the draft workflow as the applicant would see it in the portal. Unlike Test Application Flow (which simulates execution), Admin Preview focuses on the visual presentation — layout, labels, field ordering, help text, and step grouping. ### Showcase Yourself — Staff Review Rendering Shipped Sep 1 2026. The portable form engine selects its injected **Showcase** renderer by *field* type, not by the parent step type. The served workflow deliberately keeps the Showcase step's `photo_gallery` field as `file_upload_advanced` so applicants collect photos with the ordinary uploader; when the same step is rendered in **staff** or **readonly** mode, the engine's normalisation step (`normalizeFieldsConfigForEngine`, keyed on `stepType === "showcase_yourself"`) retypes that field to `showcase_yourself` so reviewers see the submitted photos as a gallery instead of generic filename rows. This is a presentation-only normalisation — the stored step schema, the applicant-facing collection experience, and the submitted data are unchanged. On the shared `showcase_yourself_v1_0` step, the **Video Link** (`video_introduction`) is optional: a migration sets `required` to false in both `step_library.schema_json` and `portal_step_library.fields_config`, and the field description now reads as optional. A supplied link is still validated (the engine accepts HTTP(S) YouTube, Vimeo or Google Drive links), and the minimum of three photos is unchanged. ## Step Configuration Each step on the canvas has a rich configuration panel. These settings control how the step behaves at runtime, who is responsible for it, and what deadlines apply. ### Free-Text Length Bounds (minWords / maxWords / minLength) Shipped Sep 2026. Declared free-text bounds in the step library are enforced server-side from the **same** field configuration the form renders, so a bound is live the moment it is declared on any step — previously only Skills & Hobbies had hand-written word enforcement and a `minWords`/`minLength` declared elsewhere was inert. | Bound | Convention | Enforcement | | --- | --- | --- | | `minWords` / `maxWords` | Current word-limit contract | Server-side on submit; issue names the field, word count and limit. | | `minLength` | Older character-minimum convention still used by a handful of fields (e.g. the **Personal Statement** box) | Server-side on submit against the **raw, untrimmed** character count (matching the renderer's `lengthBounds`, so a whitespace-padded value cannot pass one side and fail the other). Message: "*Label* must be at least N characters (currently M)." | | `maxLength` | — | Deliberately **not** re-enforced server-side: the renderer already applies it as a hard input cap, and adding a server gate would be new, unreviewed behaviour for fields that never asked for it. | Renderers can read `collectDeclaredMinLength(fieldsConfig)` to show a live "n of N characters" hint on a `minLength` field instead of a hardcoded number. Bounds apply to scalar fields and to repeated-item fields (per item). ### Role Assignments Steps can be assigned to specific RBAC roles . The assignment determines which staff members see the step in their work queues and who receives SLA notifications. Role assignment is also what derives a step’s audience : a step assigned only to staff or host roles (no applicant role) is behind-the-scenes and never renders on the applicant portal — this is how sponsor and host interviews are kept off the applicant stepper. Primary assignee: the role responsible for completing the step / Reviewer: the role that approves or verifies the step outcome / Escalation target: the role notified if SLA is breached / Scope: assignment respects brand and program scope boundaries / Audience: staff/host-only assignment (no applicant role) makes the step behind-the-scenes ### SLA Targets Each step can define SLA targets that trigger notifications and escalations when deadlines approach or are breached. SLA configuration integrates with the Queue System’s SLA management . SLA Fields Response time target (hours/days) / Resolution time target (hours/days) / Warning threshold (% of target elapsed) / Escalation action (notify, reassign, flag) Scope Hierarchy Step-level SLA (most specific, highest priority) / Phase-level SLA (applies to all steps in phase) / Workflow-level SLA (global default) / System-level SLA (fallback from Queue System config) ### AI Configuration Steps that involve document review or data validation can be configured with AI assistance. The AI configuration controls how automated review supplements human decision-making. AI review enabled/disabled toggle per step / Confidence threshold: minimum AI confidence score to auto-approve / Fallback behavior: route to human reviewer below threshold / AI model selection: document classification, OCR extraction, compliance check / Training data reference: which verified documents train the model / Audit: all AI decisions logged with confidence scores and model version > **AI as Assist, Not Authority** > > AI review is always configured as an assist layer. It can pre-screen, flag, and suggest — but compliance-critical decisions (SEVIS readiness, eligibility determinations) always require human confirmation unless the program administrator has explicitly enabled auto-approval above a configured confidence threshold. ### AI Capabilities Each AI surface in Globus is a registered capability with its own prompt, model configuration, and invocation log. Steps reference capabilities by key when AI assist is enabled. The catalog below names the capabilities the doc and codebase share vocabulary on. > **Planned capabilities are not yet wired** > > Translation Assist and Anomaly Detection are documented here as named capabilities so the doc and gap-analysis vocabulary line up. Neither is registered in the ai_capabilities table today; do not bind workflow steps or rule actions to them until they ship. ### Markdown Content & Attachments Each step can include rich markdown content that is rendered to the applicant in the portal. The markdown editor supports standard formatting, embedded images, and document attachments. `POST /api/workflow-studio/steps/:stepId/attachments` (API key scope `workflows.read` or `admin.write`, permission `programs.manage`) uploads one multipart `file` (10 MB limit) and appends `{ name, type, url, uploadedAt }` to the step's `attachments` in its `configJson`, returning that object. The file goes to GCS under `workflow-attachments/` when GCS is available, otherwise it is embedded as a `data:` URL. It returns `400` without a file, `404` for an unknown step or definition, and `409` when the definition is published or archived or another editor holds the draft lock. ## Per-Application Version Tracking Each application record stores a workflowVersionId that binds it to a specific published workflow version. This is the mechanism that ensures in-flight applications are never affected by workflow changes. Binding Rules Set at application creation time / Points to the currently published version / Immutable once set (default behavior) / Migration available via admin action Version Migration Admin can migrate applications to new version / Migration maps old steps to new steps / Completed steps preserved (not re-required) / Migration audit trail records before/after / Reconciliation repins active applications to the published definition in a single transaction (see Content-Aware Reconciliation) Reporting Dashboard shows version distribution / Count of applications per workflow version / Alerts when old versions have active apps / Migration impact preview before execution > **Stability Contract** > > The per-application version binding is the core stability guarantee of the Workflow Builder. An applicant who started their journey on workflow version 3 will complete that journey on version 3, even if the program administrator has since published versions 4 and 5. This prevents mid-process disruption and ensures auditability — the exact workflow an applicant experienced is always recoverable. ## Program Renewal Workflow The Program Renewal Workflow is a specialised workflow type for participants who are returning for a subsequent season. It re-uses verified data from the original application (carry-forward) while collecting only the fields that must be refreshed. > **Program Renewal vs Program Extension vs Reservation Extension** > > Program Renewal is a new application for a new season (new DS-2019). Program Extension extends the current program end date on the same DS-2019. Reservation Extension is unrelated — it is a partner extending a time-limited hold on a job position (see Partner Governance). Program Renewals create a new application record; Program Extensions modify the existing one. See Program Extension & Program Renewal Requests . ## J-2 Dependent Workflow J-2 dependents (spouse or minor children of a J-1 participant) follow a parallel workflow that is linked to — but separate from — the primary participant’s workflow. | Aspect | Primary (J-1) | Dependent (J-2) | | --- | --- | --- | | Workflow Trigger | Applicant starts application | Primary adds dependent during or after their own workflow | | Required Documents | Full document set (passport, background check, etc.) | Passport, relationship proof, insurance only | | SEVIS Record | Independent SEVIS record | Linked SEVIS record (references primary’s SEVIS ID) | | DS-2019 | Own DS-2019 form | Separate DS-2019 tied to primary’s program | | Phase Advancement | Independent phase progression | Cannot advance past primary’s current phase | | Termination | Independent (but terminates all J-2s) | Auto-terminated if primary is terminated | > **J-2 Phase Ceiling** > > A J-2 dependent can never be in a phase ahead of their primary J-1 participant. If the primary is in Phase 3 (Placement), the dependent cannot advance to Phase 4 (Visa) until the primary does. This is enforced by the workflow engine, not by manual staff action. ## Check-In Templates Check-In Templates define the form structure for monthly participant check-ins. Templates are managed in Workflow Studio and can be customised per program and season. Default Template Fields Current Address / Employment Status / Hours/Week / Well-Being / Employer Confirm / Travel Plans / Concerns / Signature ## Phase Library The Phase Library provides reusable phase templates that can be inserted into any workflow. Instead of building phases from scratch, workflow editors can browse pre-configured phase blueprints and customize them after insertion. | Phase Template | Included Steps | Programs | | --- | --- | --- | | Standard Intake | Application form, Document uploads, Background check, Emergency contacts | All | | Interview & Selection | Interview node (behind-the-scenes — staff/host-only, off the applicant stepper), Interview evaluation, Selection decision | Work & Travel, Intern | | Placement & Housing | Job order match, Housing assignment, Placement confirmation | Work & Travel | | Visa Processing | DS-2019 generation, SEVIS registration, Embassy interview prep | All | | On-Program Monitoring | Monthly check-in, Site visit scheduling, Compliance review | All | | Program Completion | Exit survey, Final check-in, SEVIS status update, Grace period tracking | All | ## Step Key Governance Every step in a workflow has a unique step_key that serves as a stable identifier across versions. Step keys are critical for rules engine bindings, reporting queries, and portal contract mappings. > **Step Key = Contract** > > Step keys are the contract between the workflow engine and every other system that references workflow steps. Changing a step key after publication would silently break rules, reports, and portal integrations. The system enforces immutability to prevent this class of errors. > **Field-level companion: classification metadata** > > The step_key identifies the step. It does not describe the fields the step captures — whether a value is biographical (applicant-scope) or per-attempt (application-scope), whether downstream systems are allowed to bind to it, or which partner surfaces should render it. Today, that classification is decided implicitly by hardcoded knowledge inside biographicalSync. The required field-level metadata, the authoring decision tree, the worked examples, and a baseline audit of biographicalSync live on the Field Classification Contract page. ## Draft Lock & Auto-Save Workflow Studio supports collaborative editing with draft locking and automatic save: Only one editor holds a draft at a time. A lock (table `workflow_draft_locks`, one row per definition) records `locked_by`, `locked_by_name`, `acquired_at` and `last_active_at`, and expires 15 minutes after `last_active_at`. While another user's unexpired lock is held, writes to the draft (steps, conditions, prerequisites, phases) are refused with a message naming the holder; an expired lock, or your own, does not block. All routes below require `programs.manage`. | Method | Path | Behavior | | --- | --- | --- | | GET | `/api/workflow-definitions/:id/lock` and `/lock/status` | Same handler. Returns `locked: false`, `locked: false` with `expired: true`, or `locked: true` with the `lock` row. | | POST | `/api/workflow-definitions/:id/lock/acquire` | Draft definitions only. 409 with `lockHolder` (name and last active time) when another user's lock is unexpired; an expired or own lock is released and re-acquired. Returns `acquired: true` and the lock. | | POST | `/api/workflow-definitions/:id/lock/refresh` | Refreshes `last_active_at`; returns `refreshed` true or false. | | POST | `/api/workflow-definitions/:id/lock/release` | Releases the caller's lock; returns `released`. | | GET | `/api/workflow-definitions/:id/checkpoints` | Draft change history, newest first, paged with `limit` (default 50, maximum 200) and `offset`; returns `items`, `limit` and `offset`. | | GET, POST | `/api/workflow-definitions/:definitionId/phases` | List phases; create a phase (201). Published or archived definitions are refused, the draft lock applies, and a milestone status target that moves an applicant backwards or jumps a durable segment is rejected. | | PATCH, DELETE | `/api/workflow-phases/:id` | Edit or delete a phase; same immutability and lock rules. | | POST | `/api/workflow-definitions/:definitionId/phases/reorder` | Body `phaseIds`, the ordered list of phase ids. | Draft edits record a checkpoint row in `workflow_draft_checkpoints` (for example adding a phase, reordering phases, or updating step conditions): `workflow_definition_id`, `user_id`, `user_name`, `change_type`, `entity_type`, `entity_id`, `summary`, an optional `snapshot_json` and `created_at`. A failure to record a checkpoint is logged and does not fail the edit. ## Coverage Gap Panel — Expanded The Workflow-to-Rules Coverage Panel evaluates every step in the workflow against the rules engine to identify gaps. The expanded panel provides deeper analysis: | Analysis | What It Checks | Gap Indicator | | --- | --- | --- | | Step-to-Rule Mapping | Every workflow step has at least one validation rule bound to it | Red badge on uncovered steps | | Rule-to-Step Mapping | Every active rule references a valid (non-deprecated) step key | Warning on orphaned rules | | Phase Gate Coverage | Every phase transition has at least one gate rule defined | Amber badge on ungated transitions | | Document Completeness | Every required document type has a corresponding upload step | List of document types without steps | | SLA Coverage | Every review or approval step has an SLA target defined | Grey badge on steps without SLA | | Conditional Branch Reachability | Every conditional branch is reachable (no dead branches) | Strikethrough on unreachable branches | ## Workflow Import / Export Staff can export a complete workflow definition as a self-contained, portable bundle and re-import it into any environment or program (Aug 11 2026 release capsule). This turns a workflow design into a transferable artifact instead of something that has to be rebuilt by hand each time. The export bundle captures the entire definition — not just the canvas shape: - Steps (with their configuration, role assignments, SLA targets, and markdown content) - Conditional logic and branching conditions - Sub-tasks and the hub structure that contains them - Field configurations - Agreements - Compliance mappings - Step prerequisite edges, carried by stable step key and re-validated on import (an edge that references a step not in the workflow is rejected) - Brand legal policies — added to the transfer surface so brand-scoped legal terms travel with the workflow rather than being re-authored per environment - Rule bundles owned by, or scoped to, the program or one of its seasons. Only each bundle's effective head is selected: the highest-numbered published, non-retired version, chosen before scope matching, so draft, archived and retired history, and an old program scope on a superseded version, are not selected Import Engine The import engine validates and applies every entity category individually, reporting errors per-entity so a single bad field never aborts the whole import. What can be applied is applied; what fails is reported against the specific entity that failed, so operators fix a narrow, named problem rather than re-running a blind all-or-nothing job. - Per-entity-category validation with per-entity error reporting — one bad field never aborts the whole import. - Dry-run mode lets operators verify exactly what would change before committing anything. - A content-hash uniqueness guard prevents silent version drift — the same definition cannot be re-imported as a divergent duplicate without detection. - Import targets the newest draft, or the newest published version when no draft exists. Archived versions are immutable history and are never an import target, even when their version number is higher. - Replacing an existing draft needs an explicit yes. Without `acknowledgeDraftOverwrite: true`, `POST /api/workflow-studio/workflow-bundles/import` (permission `programs.manage`) returns `409 CONFLICT` with `details.code` `DRAFT_OVERWRITE_NOT_ACKNOWLEDGED` and the `targetId` and `targetVersion` of the draft that would be deleted and rebuilt. - Steps that applicants are part-way through need per-step consent. `acknowledgeStepContentEdits` is a list of step keys taken from the dry run's `stepEdits`; any blocked step key missing from it returns `409 CONFLICT` with `details.code` `STEP_EDIT_NOT_ACKNOWLEDGED` and `details.steps` naming each step and its active applicants. Both acknowledgements are checked before a transaction opens and again inside the apply, and the audit entry records only the waivers that were actually applied. Rule-coverage configuration is portable too. Coverage bindings travel in the configuration export only when they are operator-authored wiring for a carried rule version; bindings for facts with deterministic provenance (system process or workflow-step producer in the shared facts catalogue) are platform-owned, recreated in every environment, and omitted from the export. > **Why portability matters** > > Workflow designs that previously had to be recreated by hand across brands or seasons can now be cloned in one operation. QA can promote a curated workflow from staging to production without a developer, and Operations can transfer a proven workflow template to a new partner program without rebuilding from scratch. ## Implementation Tickets — Step & Phase Caps The two Asana tickets below are drafted verbatim in the house format documented in the Asana Ticket Standard . They cover Workflow Studio enforcement and the applicant portal scroll fallback. Paste each block as-is into Asana — this site is the source of truth for the ticket text until the tickets are filed. The companion workflow_cap_violation_detected notification event is documented as a design rule in the Workflow Studio Guide and is not filed as a separate engineering ticket. Ticket A — Workflow Studio enforcement (lands in P11 / P11B) Title: Workflow Studio: step & phase caps with soft authoring warning, hard publish-time validation, and legacy republish block Body: FOR PRODUCT OWNERS / STAKEHOLDERS Sponsor admins can currently build workflows with unlimited steps per phase and unlimited phases. Long phases break the applicant portal "My Application" stepper because it renders steps horizontally. This ticket caps phases at 6 per workflow and steps at 7 per phase, warns admins as they author, blocks over-cap workflows from publishing, and refuses to re-publish any legacy workflow that already breaks the cap until it has been restructured. ---------------------------------------- BACKGROUND Workflow Studio (\`workflow-builder.tsx\`) lets sponsor admins draft workflows on a drag-and-drop canvas with phases as swim lanes and steps inside each phase. The draft → published lifecycle (documented in the Workflow Studio Lifecycle / P11/P11B section) currently has no structural validation gate at publish time. Any number of steps and any number of phases will publish successfully. The published workflow is then read by the applicant portal "My Application" surface, which renders phases as top tabs and steps as a horizontal stepper row. The applicant portal stepper has no overflow handling today — long phases wrap to multiple rows, breaking layout on the first screen applicants see. WHAT THIS TICKET DOES - Adds an authoring-time soft warning in Workflow Studio that fires when adding a step would push a phase past 7 steps, or when adding a phase would push the workflow past 6 phases. The warning is informational and does not block the draft save. - Adds a publish-time hard validation gate in the Workflow Studio Lifecycle publish flow that rejects publish on any draft exceeding 7 steps per phase or 6 phases total. The publish endpoint returns a structured error naming each offending phase. - Adds the same hard validation to the re-publish path for any currently-published workflow, so legacy workflows that already exceed the caps cannot be re-published until they have been manually restructured into compliant shape. - Emits a single staff-targeted notification event "workflow_cap_violation_detected" to Karin Brettenny (email-only, single named recipient) on a one-time backfill sweep when this module is first deployed, and again whenever a newly-detected violation appears. The event itself is configured in the Notification & Communication Model catalog and does not require a separate engineering ticket. - References companion ticket "Applicant Portal: horizontal-scroll fallback on My Application stepper for over-cap phases and phase tabs". ACCEPTANCE CRITERIA (Gherkin) Feature: Workflow Studio step and phase caps Scenario: Authoring-time warning fires at the 8th step in a phase Given a sponsor admin is editing a draft workflow in Workflow Studio And the phase "Intake" already contains 7 steps When the admin drags an 8th step into "Intake" Then Workflow Studio displays a soft warning recommending the admin split "Intake" into a new phase And the draft save still succeeds Scenario: Authoring-time warning fires at the 7th phase Given a sponsor admin is editing a draft workflow in Workflow Studio And the workflow already contains 6 phases When the admin adds a 7th phase Then Workflow Studio displays a soft warning that the workflow exceeds the recommended 6-phase cap And the draft save still succeeds Scenario: Publish is rejected when any phase exceeds 7 steps Given a sponsor admin has a draft workflow with 8 steps in the phase "Intake" When the admin clicks "Publish" Then publish is rejected with a validation error And the error names "Intake" as the offending phase And no new published version is created Scenario: Publish is rejected when the workflow exceeds 6 phases Given a sponsor admin has a draft workflow containing 7 phases When the admin clicks "Publish" Then publish is rejected with a validation error And the error lists the workflow as exceeding the 6-phase cap And no new published version is created Scenario: Re-publish of a legacy over-cap workflow is blocked until restructured Given a workflow was published before the cap was introduced And one of its phases contains 12 steps When a sponsor admin opens the workflow and clicks "Publish" on a new draft version Then publish is rejected with a validation error naming the offending phase And the workflow remains on its previous published version And the error message instructs the admin to restructure the workflow before re-publishing Scenario: Build-time guard prevents bypassing the validation Given a developer attempts to add a new publish path that does not call the cap-validation module When the test suite runs Then a lint or test fails with a message that all publish entry points must invoke the cap-validation module Ticket B — Applicant Portal scroll fallback (lands in Applicant Portal / Applicant Lifecycle) Title: Applicant Portal: horizontal-scroll fallback on My Application stepper for over-cap phases and phase tabs Body: FOR PRODUCT OWNERS / STAKEHOLDERS The "My Application" screen in the applicant portal renders workflow phases as top tabs and steps as a horizontal row of numbered circles. Today, if a workflow has too many steps or phases, the row wraps and the screen looks broken. This ticket makes the portal degrade gracefully: when there are too many steps or phases to fit, the row scrolls horizontally instead of wrapping, so applicants always see a clean stepper even on legacy or over-cap data. ---------------------------------------- BACKGROUND The applicant portal "My Application" surface receives published workflow data from Globus and renders it as a phase-tabs row at the top and a step-circles row below the active phase. The portal currently has no overflow handling on either row; long lists wrap to multiple lines and break layout. Companion ticket "Workflow Studio: step & phase caps with soft authoring warning, hard publish-time validation, and legacy republish block" introduces enforcement upstream, but the portal must still render legacy data, override paths, and edge cases defensively without ever wrapping or hiding steps. WHAT THIS TICKET DOES - Wraps the steps row in a horizontally-scrolling container that activates whenever the rendered step count exceeds what fits in the available width. - Wraps the phase-tabs row in the same horizontally-scrolling container with the same activation rule. - Preserves the active step / active phase as the scroll anchor on initial render so the applicant lands looking at where they are, not at step 1. - Does not truncate, hide, condense, or wrap any steps or phases under any circumstance. - The portal is not the enforcement point — it never rejects, warns about, or modifies workflow data based on cap violations. - References companion ticket "Workflow Studio: step & phase caps with soft authoring warning, hard publish-time validation, and legacy republish block". ACCEPTANCE CRITERIA (Gherkin) Feature: My Application stepper horizontal-scroll fallback Scenario: Steps row scrolls horizontally when over the cap Given an applicant opens "My Application" And the active phase contains 13 steps When the page renders on a 1280px-wide viewport with the sidebar open Then the step-circles row appears in a single horizontal line And the row scrolls horizontally And no step is hidden, wrapped, or truncated Scenario: Phase tabs scroll horizontally when over the cap Given an applicant opens "My Application" And the workflow contains 8 phases When the page renders Then the phase-tabs row appears in a single horizontal line And the row scrolls horizontally And no phase tab is hidden, wrapped, or truncated Scenario: Active step is the scroll anchor on initial render Given an applicant is on step 9 of a phase containing 13 steps When the page first renders Then the step-circles row is scrolled so that step 9 is visible without manual scrolling Scenario: Active phase is the scroll anchor on initial render Given an applicant is on phase 6 of a workflow containing 8 phases When the page first renders Then the phase-tabs row is scrolled so that phase 6 is visible without manual scrolling Scenario: Compliant workflow renders with no scroll Given an applicant opens "My Application" And the active phase contains 5 steps And the workflow contains 4 phases When the page renders on a 1280px-wide viewport Then both rows fit without horizontal scroll And no scroll affordance is shown Scenario: Portal does not enforce caps Given a published workflow with 13 steps in a phase reaches the applicant portal When the page renders Then the portal renders all 13 steps via horizontal scroll And the portal does not log, warn, surface, or modify the workflow based on the cap violation --- --- title: "Camp Program Rules" slug: camp-program-rules section: Program Configuration status: stable description: "Camp-specific compliance constraints, business rules, and regulatory bundle defaults for the J-1 Camp Counselor program" last-updated: 2026-10-07 --- # Camp Program Rules Camp-specific compliance constraints, business rules, and regulatory bundle defaults for the J-1 Camp Counselor program ## Overview The J-1 Camp Counselor program operates under a distinct set of regulatory constraints enforced by the j1-camp-counselor regulatory bundle. These constraints are implemented as a combination of locked bundle defaults (Tier 1 — compliance) and configurable business rules (Tier 2 — operational). For the full rules cascade model, see Rules Engine Architecture . ## Camp Model Type Detection Programs are identified as camp programs by a server-derived model type, not a stored column: resolveProgramModelType (server/services/programModelType.ts) returns camp when the program key, code, name or visa type starts with "camp". A value of camp activates camp-specific evaluation paths. The j1-camp-counselor regulatory bundle is selected by its condition predicates (visa_type J-1, category_code camp_counselor). ## J-1 Camp Counselor Bundle Defaults The following parameters are set at the bundle level and establish the monotone lock floor. No program or season configuration can override these values to a less restrictive setting. | Parameter | Value | Locked | | --- | --- | --- | | min_age | 18 | Yes | | max_duration_months | 4 | Yes | | accepted_paths | eligible-path enumeration (post-secondary student, youth worker, teacher, specialist) | Yes | > **No Extensions or Renewals** > > J-1 Camp Counselor programs do not permit extensions or renewals. Any attempt to create an extension or renewal request for a camp program will be rejected at the API layer with a validation error referencing the bundle lock. ## Camp Metadata Keys *Status: not built as described. There is no `program_metadata` column and no `staff_ratio_threshold`, `placement_readiness_required` or `min_age_counselor` key in the code. Camp type is a host-organisation field `campType` (values Overnight, Day, Both), and staff ratios are configured in the `staff_ratio_rules` table.* Camp programs carry additional metadata stored in the program_metadata JSONB column. The following keys are used by camp-specific rule evaluations: | Key | Type | Purpose | | --- | --- | --- | | camp_type | string | Identifies the camp category (e.g., day_camp, residential_camp) | | staff_ratio_threshold | number | Minimum staff-to-camper ratio that triggers a review queue action | | placement_readiness_required | boolean | Whether placement confirmation is required before SEVIS pool entry | | min_age_counselor | number | Minimum age for camp counselor applicants (default: 18) | ## Tier 1: Compliance Rules Tier 1 rules are classified as compliance and cannot be overridden at any layer. Failure produces a block_phase or auto_reject action. | Rule | Condition | Failure Action | | --- | --- | --- | | Minimum age | Applicant must be ≥ 18 at program start date | auto_reject | | Placement duration (`j1.camp_counselor.max_program_months`, 22 CFR 62.30(h)(2)) | The applicant's current non-cancelled placement must end no later than its start date plus four **calendar months** (inclusive boundary; month-end and leap-year dates are clamped). An active placement takes precedence over a confirmed one; conflicting current placements fail rather than picking one. Season dates and SEVIS check-in dates are never substituted. | BLOCK (blocks Ready for SEVIS) plus a REVIEW_QUEUE item in the compliance review queue | | No extension/renewal | Extension or renewal request must not exist for a camp applicant | block_phase | The placement-duration rule is evaluated at the Ready for SEVIS gate (it is one of the gate's dependencies, grouped as "Camp Counselor placement") rather than at every lifecycle gate, and it applies only to Camp applicants; for non-Camp applications it passes as not applicable. An end date before the start date, or one day past the four-calendar-month boundary, is a genuine failure. A missing start or end date is shown as awaiting evidence rather than a proven violation, but Ready for SEVIS stays blocked until the interval can be evaluated. A failure does not reject, cancel, or otherwise change the applicant: it blocks readiness, opens a compliance-review-required item for staff, and shows the applicant feedback in the Applicant Portal asking them to update the Camp Contract dates or upload a corrected contract and resubmit. ## Tier 2: Business Rules Tier 2 rules are classified as business and can be overridden at the program or season layer with appropriate permissions. Failure typically produces a queue_review or warn action. | Rule | Condition | Failure Action | | --- | --- | --- | | Staff ratio review | Host's confirmed counselor count falls below staff_ratio_threshold (default: 10) | queue_review | | Placement readiness | Placement must be confirmed before SEVIS pool entry when placement_readiness_required = true | block_phase | | Camp Contract offer terms (`compliance.camp_contract_offer_terms_match`, advisory) | The Confirm Camp Contract start date, end date, salary and pay frequency must match the terms on the linked active/confirmed placement's job order. A mismatch, missing terms, or missing linkage is `review_required`; an exact match passes. Applies to Wild Packs CAMP_WP and SWT_WP enrollment applications only. | queue_review only — never blocks a transition, lifecycle checkpoint or readiness decision (readiness impact: none) | The Camp Contract rule is a Wild Packs business rule in the `business.wild_packs_business_rules.v1` bundle, not a regulatory or compliance bundle rule. It does not replace the blocking duration and eligible-window checks. On the applicant profile's compliance section a `review_required` result is listed under **Needs sponsor review**, and its detail shows a side-by-side table of the **Job-order offer** against the **Camp Contract** for start date, end date, salary and pay frequency, with differing values highlighted and `Not recorded` for blanks. It is labelled a review flag only: differences do not block the applicant or host. Two seeded reason codes belong to the camp rules. `CAMP_SEASONAL_COMPENSATION_BELOW_MINIMUM` (blocking) is the reason on the `job_order.compensation.camp_seasonal_minimum` submit gate: a job order on a Camp Counselor program that guarantees less than $2,400 in total seasonal compensation cannot be submitted for review, and the host sees copy naming that floor. `CAMP_ENGLISH_PROFICIENCY_NOT_MET` (blocking) anchors both actions of the `j1_camp_counselor.english_proficiency_sufficient` rule: it blocks when the sponsor-interview English rubric level is below the accepted bands (excellent, very good, good), and a missing rating routes to compliance review without blocking. ## Camp Placement Classification For Camp applications the placement source and Same Camp return are derived from the applicant's committed Camp Personal Information step (`personal_information_camp_v1`); they are not entered by staff. A current Camp position of **No** means **Sponsor-arranged**; **Yes** means **Self-arranged**. **Same Camp return** is true only when the applicant has a current Camp position and previously worked at that same Camp. Previous J-1 participation is a separate fact and is never converted into a Camp fact, so Camp applicants have no Participant type shown. The newest committed snapshot wins, and a "No" answer clears the dependent Camp name and same-Camp answers, even in an older snapshot. When the derived values change, they are written to the application (`placement_source`, `camp_same_camp_return`) with an `applicant_declaration` assertion trail, and the Market Ready projection is invalidated in the same transaction. Becoming Sponsor-arranged also clears the proposed-employer answer and withdraws any active placement proposal, so contradictory self-arranged state cannot survive. Market exposure follows the same field. An application is outside the host market when its program key is `self_arranged_intern_trainee` or `self_arranged_intern_trainee_geo`, or when its `placement_source` is self-arranged; the Market Ready evaluation returns not applicable for it instead of a verdict. A write that would expose it to the market (the staff recommendation `POST /api/applicants/:applicationId/recommend`, `applicant.manage`) is refused with `422 MARKET_NOT_READY` and `details.reasonCode` `SELF_ARRANGED_MARKET_EXPOSURE_NOT_ALLOWED`, with `details.readinessImpact` `not_applicable`, an empty `details.failedDependencies` and `details.queueKey` `blocked`. The host-facing reservation and talent-pool recommend routes run the same live check but re-wrap any refusal into a host-safe `422 MARKET_NOT_READY` whose `details` carry only `reasonCode` `MARKET_NOT_READY`. Staff see the result in several places: the Applicants list and the Command Center rows show Placement source and, when true, **Same Camp return**; the applicant profile header shows the same chips plus the applicant-supplied **Proposed camp** and **Applicant availability** date range, kept visually separate from the confirmed Host; and the Command Center has a Camp-only **Same Camp return** filter, carried in the `sameCampReturn` URL parameter, that is independent of the Participant type and Placement source filters. ## Camp Counselor Eligibility Path & SEVIS Add-On Gate > **Superseded — Eligibility Engine now live** > > The legacy Cat 1/2/3 auto-derivation and staff-confirmed eligibility path described in earlier revisions of this section have been **fully retired**. Camp counselor eligibility is now decided by the automated Eligibility Engine (live on development, designed under ADR 0003): the engine evaluates a universal minimum-age requirement plus four qualifying paths from facts and staff-verified evidence, and a compliance officer issues the sponsor determination. See the Eligibility Engine & Compliance Tab page for the full model. Camp Counselor (Wildpacks) applicants are held at a program-specific eligibility gate that stacks on top of the global `ready_for_sevis` gate every applicant must satisfy. For the full gate architecture, see the SEVIS Readiness page. The eligibility gate now reads the engine's determination: | Rule | Condition | | --- | --- | | CAMP-001 Eligibility approved | The applicant has an **approved and currently valid** sponsor determination from the Eligibility Engine. Reads only the engine's determination — never an applicant-entered field — and fails closed if the fact is missing or unreadable. | The former standalone category-evidence checks (youth worker evidence, special skill verification) are subsumed by the engine's qualifying paths, each of which requires uploaded **and staff-verified** evidence before it can satisfy — with the special-skills path always routed to manual review. Applicants verified under the old model had their approvals preserved as legacy records and are never re-opened automatically. --- --- title: "Intern/Trainee Scope" slug: intern-trainee-scope section: Program Configuration status: draft description: "The sponsor-grade scope for bringing the J-1 Intern and Trainee programs onto Globus." last-updated: 2026-10-07 --- # Intern/Trainee Scope The sponsor-grade scope for bringing the J-1 Intern and Trainee programs onto Globus. It explains, in plain English, what Globus must know, what staff must decide, what the applicant must provide, and how the system preserves evidence and approvals over time. The scope is written against the platform as it actually exists: where it needs machinery the platform already has (a rules engine, versioned rulesets, conditional evidence collection), it reuses it; where Globus genuinely lacks a capability, the gap analysis below says so. This page is the official first draft and the source of truth for the Intern/Trainee scope. > **Status: approved as architectural baseline — decomposition pending.** Architecture review (July 2026) approved this scope as the baseline for Intern/Trainee. A scoping ticket covering final sign-off and Phase 1 ticket decomposition is filed on the P11.5 board; no implementation tickets have been filed yet. The Open Decisions below are formal architecture gates — dependent workstreams must not begin until the gate they rely on is resolved. The Rules Engine spec's Normative Contracts section remains the source of truth wherever this page and it intersect. ## Executive Summary This scope introduces the regulated assessment layer required to support the J-1 Intern and Trainee programs within Globus. The key architectural principles: - **Applicants never choose Intern or Trainee.** Both routes are evaluated after evidence collection; the system determines what the person is eligible for. - **Workflow Studio collects evidence.** Step trees and conditional evidence collection are configuration, not code. - **The Rules Engine evaluates verified facts.** All eligibility logic lives there as versioned rules; nothing is re-implemented. - **Sponsor staff approve regulatory decisions.** The engine calculates; a named, authorized human adjudicates. The two records never blur. - **Eligibility is a living assessment.** Evidence changes, facts change, rules rerun, and the workflow reacts — eligibility is never a one-time checkbox. - **Matching consumes the current Eligibility Projection.** Operations reads a fast current-status view, never the audit trail. - **Every regulatory decision is fully auditable.** Immutable records, supersession chains, and engine evaluation references make every decision explainable years later. The scope reuses existing platform capabilities wherever possible and introduces only the sponsor-specific entities that are genuinely missing. ## Not in Scope This document deliberately does not cover: - SEVIS implementation details (the gate is specified; the SEVIS integration itself is not) - Electronic signature provider selection for the DS-7002 (an Open Decision) - DS-2019 generation mechanics - Applicant Portal visual design - Reporting and analytics - API contracts and database schema — those belong to the decomposition tickets, not this scope ## Overview & Positioning Intern and Trainee are two distinct J-1 participant types under 22 CFR 62.22 that share one operational pipeline. In the platform they live under **one combined regulatory category (`intern_trainee`) with two eligibility routes** — and this is not net-new onboarding: **five Intern/Trainee programs are already live** across multiple brands ("Intern and Trainee" ×2 including the GEO brand, "Self-arranged Intern and Trainee" ×2, and "Teach Intern and Trainee" under Teacher Lounge). Every rollout decision in this scope has that installed base to consider. - A single intake flow collects evidence once; both routes are evaluated independently from the same facts. - The applicant is never asked to pick Intern vs Trainee at registration — the choice is a legal classification made after evidence is in, and the sponsor (not the applicant or partner) owns the final call. - Route eligibility is date-relative: the Intern 12-month graduation window is measured against the *proposed program start date*, so eligibility is an assessment at a point in time, never a static applicant attribute. > **Design principle: the Living Eligibility Model.** Eligibility is not a permanent status. It is always calculated from the latest verified evidence and the latest proposed placement details — references arrive over time, employers confirm or dispute employment, start dates move, and each material change produces a new immutable Eligibility Assessment while historical assessments stay on file. Operations, Matching, and the Applicant Portal always work from the current Eligibility Projection, never from a remembered decision. Everything on this page — the assessment records, the supersession machinery, the operational scenarios — follows from this one principle. > **Why eligibility changes.** Unlike Camp Counselor or SWT, an Intern/Trainee applicant may wait months between applying and a suitable placement being found. During that window, the facts underneath eligibility keep moving: employers respond to reference requests, alternative evidence is accepted, work history is corrected, the occupational classification shifts with the proposed placement, and placement dates change. The sponsor must therefore continually reassess eligibility against the latest verified evidence and the latest proposed placement — which is why Globus models eligibility as a living assessment rather than a permanent applicant attribute. The regulatory ground rules the system must enforce: | Area | Requirement | | --- | --- | | Intern eligibility | Currently enrolled in a qualifying foreign post-secondary institution, **or** graduated no more than 12 months before the program start date. | | Trainee eligibility | Foreign degree / professional certificate **plus** at least 12 months of related experience outside the U.S.; **or** five years of related experience without the degree route. | | Occupational scope | Training must be in an authorized occupational category and related field. | | Duration | Intern: max 12 months. Trainee: normally 18 months; Hospitality/Tourism and Agriculture limited to 12. Sponsor authorization may be stricter. | | Training quality | Structured and guided, not ordinary employment; must not merely duplicate prior experience. | | DS-7002 | Complete, signed, approved training plan before DS-2019 issuance. | | Prohibited placements | Controls for unskilled/casual labor, childcare/elder care, patient contact, excessive clerical work. | | Sponsor authority | An authorized sponsor user approves the Regulatory Decision. | ## The Core Problem in One Example An applicant is created with Brand: Teacher Lounge, Program: Teach Intern and Trainee, Cycle: 2027 Rolling. They then submit a current university enrollment record, a degree history, three jobs, and two employer references. Globus must be able to answer: 1. Is this person eligible as an **Intern**? 2. Is this person eligible as a **Trainee**? 3. In which **occupational category and field** are they eligible? 4. Which **job orders** are compatible? 5. Has the **sponsor approved** the final route? 6. Is the **DS-7002** complete and consistent with that route? Today the registration screen works fine — the missing capability is the regulated assessment layer between application evidence and final placement. That layer is what this scope adds. ## Scope Decisions | Decision | In scope | Rationale | | --- | --- | --- | | New Program Family hierarchy level | **No** | Existing Brand → Visa Type → Regulatory Category → Program → Operational Cycle carries it. | | Occupational Category as a first-class entity | **Yes** | Classification follows the training activities (DS-7002), not the host's industry. | | Intern/Trainee selection at registration | **No** | Both routes evaluated after evidence collection; seven-state operational projection. | | Parallel eligibility engine | **No** | The Eligibility Assessment is a *consumer* of the Rules Engine — it references engine evaluations, it does not re-implement or copy them. | | Partner-proposed route | **Yes** | Partner or job order may propose; only an authorized Globus user approves. | | Placement Assessment per match | **Yes** | Applicant eligibility and placement suitability are separate questions with separate records. | | DS-7002 as a first-class versioned record | **Yes** | Gates DS-2019 / SEVIS issuance. | | Participant dates on Operational Cycle | **No** | Dates live on placement / DS-7002 / DS-2019; the cycle manages intake only. | | Record-level remediation UI | **Deferred to platform** | Re-collecting a single failed evidence item is platform-wide work-item mechanics; this scope is its first consumer. | ## Program Hierarchy Placement Intern and Trainee slot into the existing hierarchy without structural change: ``` Brand └── Teacher Lounge (and other brands operating Intern/Trainee programs) Visa Type └── J-1 Regulatory Program Category ├── Teacher └── Intern / Trainee (one combined category, code intern_trainee) Program ├── Teach USA ├── Teach Intern and Trainee (Teacher Lounge) ├── Intern and Trainee / Self-arranged Intern and Trainee (other brands) └── ... future I/T programs (Hospitality, Business, Marketing, ...) Operational Cycle └── 2027 Rolling ``` The live platform has **one combined `intern_trainee` regulatory category**, not two sibling categories — Intern vs Trainee is a *participant type decided per applicant*, not a category split. Whether the category should ever be split into two is an Open Decision (below); this scope does not pre-decide it, and a split would move the category-scoped bundle attachment, the five live programs' category assignments, and the auto-attach predicates. > **Program is doing two jobs — acknowledged, not restructured here.** Today the Program entity carries both a **commercial role** (the product a brand sells: name, pricing, partner contracts, reporting) and a **regulatory role** (which BridgeUSA category and rules apply). "Teach Intern and Trainee" is a commercial product; *Intern/Trainee* is a regulatory category — different things sharing one entity. Intern/Trainee is the scope that exposed this. Nothing needs to change today: the program already carries a single category_id for its regulatory category, and the configuration fields below (*regulatory_category_scope*, ruleset and policy versions) are proposed additions on the regulatory side, while name, brand, and cycle stay commercial. But every future reader should interpret Program with that split in mind, and a formal Commercial Program vs Regulatory Program separation is recorded as an Open Decision below. Below program configuration sits the authority scoping chain — each program may only offer what the legal sponsor is authorized to operate. This chain is the Sponsor Authority model (detailed below): designation governs far more than occupational categories, so the chain runs from the legal entity down through program, occupational, and country authorities to versioned policies: ``` Legal Sponsor └── Designation (DS-2019 authority, effective/expiry) └── Program Authorities (which J-1 categories, e.g. Intern / Trainee) └── Occupational Authorities (authorized occupational categories → program-enabled subset → fields) └── Country Authorities (countries the sponsor may serve) └── Policy Versions (duration caps, site-visit policy, monitoring obligations) ``` - Intern/Trainee Operational Cycles are rolling administrative groupings; **no participant dates derive from the cycle**. - An optional Program Group for cross-category reporting may come later; it must never define an applicant's legal status. ## Data Model Additions The full sponsor-grade data model — every entity in the regulated chain from application evidence to SEVIS, with what each one carries. Each entity is marked **EXISTS** (already in Globus — gains Intern/Trainee fields) or **NEW** (must be built for this scope): | Entity | Status | What it carries | | --- | --- | --- | | **Application** | **EXISTS** | Declared education *(new field)*, declared work history *(new field)*, submitted evidence *(exists — document/evidence pipeline)*, intended occupational interests *(new field)* | | **Eligibility Assessment** | **NEW** | Intern result, Trainee result, occupational-category results, engine evaluation references (the live `eligibility_assessments` row holds the current result with `ruleset_key` and `ruleset_version`; each sponsor approval is an immutable `eligibility_approvals` row that stores `facts_snapshot` and `evidence_snapshot` copies), provisional / approved status, sponsor approval | | **Eligibility Projection** | **NEW** | The current-status view; in the shipped Phase 1 schema this is the live `eligibility_assessments` row itself (one per application and assessment type, ADR 0003 section 3.1) rather than a separate derived view, and it is what Operations and Matching read; never an audit source | | **Host Organization** | **EXISTS** | Verification *(exists — host vetting)*, site-visit status *(exists)*, workers'-comp insurance *(exists)*, eligibility status per occupational category *(new field)* | | **Job Order** | **EXISTS** | Occupational category *(new)*, occupational field *(new)*, proposed participant type *(new)*, duties / duration / hours *(exist)*, prohibited-activity checks *(new)* | | **Placement Assessment** | **NEW** | Applicant alignment, non-duplication review, category compatibility, duration validation, sponsor approval | | **DS-7002** | **NEW** | Versioned training plan, signatures, approval — a first-class record | | **Regulatory Decision** | **NEW** | The generic locked decision record (for Intern/Trainee it resolves to Intern or Trainee), program dates, approved evidence basis, authorized sponsor decision, compliance opinion | In short: the four entities Globus already has (Application, Host Organization, Job Order, plus Program configuration below) are extended, not rebuilt. The four regulated decision records (Eligibility Assessment, Placement Assessment, DS-7002, Regulatory Decision) are new — they are exactly the assessment layer the platform is missing today, and the gap analysis at the top of this page says the same thing entity by entity. The Eligibility Projection is not a decision record: it is the derived "where does this applicant stand right now" view over the decision records, following the same pattern as Rules → Effective Rules. A deliberate design principle runs through this list: **the reusable entities are platform concepts, not Intern/Trainee concepts.** Sponsor Authority, the Occupational Category taxonomy, the evidence-verification pipeline, the Eligibility Projection pattern, and the Regulatory Decision record are all written so Teach USA, Camp, SWT, and future regulated programs (H-2B, Australia, Canada) consume the same machinery instead of growing program-specific clones. Only the rule content — the 22 CFR 62.22 specifics — is Intern/Trainee-shaped. Why this shape: it keeps the current applicant-creation experience (no early legal classification), separates what the applicant *declares* from what the sponsor *assesses*, evaluates Intern and Trainee independently, separates applicant eligibility from host and job-order suitability, and gives a clean audit bridge from evidence to DS-7002, DS-2019 and SEVIS — with re-evaluation that never deletes or rewrites a prior sponsor decision. > **Plain-language terms used on this page.** **Snapshot** — a frozen copy of the exact evidence and rule versions behind a decision, kept so the decision can always be explained later. **Supersession** — replacing an approved decision with a new one while keeping the old one on file, instead of editing it. **Canonical fact** — a single agreed-upon data point (like "verified months of experience") that every rule reads from the same place. **Lock-required rule** — a compliance rule that must pass before the system allows the next regulated step. **Projection** — a derived "current status" view computed from immutable records, safe to read for operations but never the audit source. Two existing entities also gain Intern/Trainee-specific fields (no rebuild — additions only): **Program** **EXISTS** — gains configuration fields (proposed: apart from `Active / archived status`, none of the rows below exist in code today) | Field | Purpose | | --- | --- | | `regulatory_category_scope` | Which regulatory categories the program operates (proposed; today a program has a single `category_id`) | | `allowed_occupational_categories` | The subset of sponsor-authority scope this program offers | | `default_workflow_version` | The Workflow Studio tree new applications start on | | `eligibility_ruleset_version` | The pinned rule versions in-flight applications stay on | | `evidence_policy_version` | Versioned evidence policy (e.g. which documents verify employment) | | `host_policy_version` | Versioned host vetting policy | | Active / archived status | Lifecycle of the program offering itself | **Application** **EXISTS** — gains Intern/Trainee fields | Field | Purpose | | --- | --- | | Declared education records | What the applicant says about their studies (before verification) | | Declared work-history records | What the applicant says about their jobs (before verification) | | Submitted artifacts | The actual uploaded evidence documents | | Intended occupational interests | Which categories/fields the applicant wants to train in | | Current applicant-action state | Whether the applicant owes the system anything right now | ### Occupational Category & Field Taxonomy **NEW** A regulated configuration entity, owned at the platform level (not per-program). Categories match the DS-7002 category list; fields are a configurable child taxonomy: | Occupational category | Example fields | | --- | --- | | Hospitality and Tourism | Front Office, Food & Beverage, Rooms Division, Culinary Operations, Revenue Management | | Management, Business, Commerce and Finance | Marketing, Finance, Human Resources, Sales, Operations | | Education, Social Sciences, Library Science, Counseling and Social Services | Early Childhood Education, Curriculum Development, Educational Administration | | Information Media and Communications | Media Production, Communications, Digital Content | | Sciences, Engineering, Architecture, Mathematics and Industrial Occupations | Mechanical Engineering, Data / Analytics, Architecture | Scoping chain: sponsor authority constrains which categories the sponsor may operate → program configuration constrains which the program offers → the job order picks one from the allowed intersection. The host's industry is **descriptive metadata only** — validation always runs against the placement's occupational category (see Worked Example C). This is deliberately **platform master data, not an Intern/Trainee entity**. Intern/Trainee is simply its first full consumer: Teacher programs can classify placements against it, professional-visa programs can reuse it, and non-US expansions (Australia, Canada) can carry their own category sets in the same structure. Nothing in the taxonomy schema references Intern/Trainee. ### Sponsor Authority **NEW** Designation governs far more than occupational categories, so this is a platform-wide entity (SWT and Camp will consume it too) modeling everything the Department of State has authorized the legal sponsor to do. Conceptually it is the hierarchy shown in the Program Hierarchy section — Legal Sponsor → Designation → Program / Occupational / Country Authorities → Policy Versions — though whether each level becomes its own table is an implementation call, not a commitment made here: | Field | Purpose | | --- | --- | | `sponsor_number`, designation effective / expiry dates | Identity and validity of the designation itself — the sponsor's authority to issue DS-2019s at all | | `authorized_regulatory_categories` | Which J-1 categories the sponsor may operate (program authority) | | `authorized_occupational_categories` | Which I/T occupational categories are in scope (occupational authority) | | `authorized_countries` | Which countries the sponsor may serve (country authority) | | `category_duration_limits` | Where DOS authorization is stricter than the regulation | | Site-visit and monitoring policy versions | Sponsor-policy obligations that vary by designation, versioned like evidence policy | | Designation artifacts + version/approval history | Source-of-truth documents, immutable versions | Rules reference sponsor authority as facts, so an out-of-scope placement — wrong category, wrong country, over the duration cap, or a lapsed designation — fails with an explicit reason code instead of being silently accepted. ### Eligibility Assessment Record **NEW** The assessment record is new; the rule evaluation it consumes runs on the existing Rules Engine. The per-applicant assessment is a **sponsor adjudication record** — the sponsor's acceptance or rejection of what the engine concluded — not another evaluation record and not an engine: > **Is this the Rules Engine? No — and the boundary is normative.** The Rules Engine already exists: it is the platform machinery that evaluates rules against facts and returns pass/fail results with reason codes. Nothing about it is rebuilt for Intern/Trainee — this scope only adds new **rules** and **facts** for it to run. The engine also already keeps its own **immutable evaluation records**: persisted fact snapshots, per-rule results, exact rule and bundle versions, reason codes, and deterministic replay. The Eligibility Assessment must not duplicate any of that — two competing records of what the engine concluded would be worse than none. The boundary, stated normatively: **the Rules Engine is authoritative for the calculated result and its facts snapshot; the assessment is authoritative for the sponsor's acceptance or rejection of that calculated result.** The assessment *references* engine evaluation IDs and adds only what the engine cannot know: the sponsor-reviewed route and occupational outcomes, the approver and their approval authority, the Compliance Opinion, timestamps, supersession, and any human findings not expressible as rules. It must be new because today there is no durable "here is the decision the sponsor made, and on what basis" artifact attached to the applicant — and regulations require a sponsor to explain a decision years later exactly as it was made. The Placement Assessment below follows the same pattern: engine = exists (reused), adjudication record = new (built). | Field | Purpose | | --- | --- | | `intern_result`, `trainee_result` | Sponsor-reviewed per-route outcomes with reason codes | | Results by occupational category / field | An applicant can qualify in Education but not Hospitality | | `verified_experience_months` (by category/field) | The computed Trainee-route input (see Open Decisions) | | `proposed_start_date_used` | The date the Intern graduation window was measured against (see the pre-placement date model below) | | Engine evaluation references | References to the evaluation records (`eligibility_path_evaluations`, `eligibility_evidence_references`); the assessment row carries `ruleset_key` / `ruleset_version`, and each immutable approval in `eligibility_approvals` stores its own `facts_snapshot` and `evidence_snapshot` copies | | Approver, approval authority, timestamps | Provisional calculations are automatic; approval is human, by a named authority | | Compliance Opinion + human findings | Sponsor judgment not expressible as rules (see Compliance Opinion below) | | `provisional` / `approved` status | Provisional results never gate anything downstream | | `supersedes_assessment_id` | New facts create a new assessment; nothing is rewritten | **Pre-placement date model.** Intern eligibility is measured against a start date — but early in an application no placement exists, so no defensible start date exists either. The assessment therefore operates at two levels. A **pre-placement route indication** uses a clearly identified planning date or date range (the applicant's stated intended start, or the earliest/latest realistic window) and is always labeled **provisional** — it steers evidence collection and pool placement, nothing more. **Placement-context eligibility** is re-evaluated against the actual proposed placement start date and is required before any placement approval. Participant dates never derive from the Operational Cycle (per Scope Decisions), so no cycle-derived date is ever used as the measurement basis. ### Eligibility Projection **NEW** Assessments are immutable, so over an applicant's life a chain of them accumulates — and "which one is current?" should never be a query every consumer answers for itself. The Eligibility Projection is the single derived **current view** per applicant: the latest approved assessment's outcome per route and category, plus any pending provisional result and open material-change review. It mirrors the platform's existing Rules → Effective Rules pattern: immutable versions underneath, one computed "what applies right now" view on top. | Consumer | Reads | | --- | --- | | Operations staff | The projection — current pools, pending reviews, what needs action | | Matching | The projection — only current, approved eligibility gates proposals (see Matching below) | | Applicant portal | The projection, translated to plain language | | Audit / compliance review | The **assessments**, never the projection — the projection carries no evidence snapshot and is not a decision record | The projection is recomputed whenever an assessment is approved or superseded or a provisional result lands; it stores nothing that cannot be rebuilt from the assessment chain. **The projection is not a readiness flag.** Four neighboring platform concepts answer four different questions, and they must never become interchangeable: | Concept | The one question it answers | | --- | --- | | Rules resolution snapshot (engine) | Which rules and parameters applied to this evaluation? | | Eligibility Projection (this record) | What is the applicant's current approved regulatory route / category eligibility? | | Lifecycle Readiness | Can this application take its next operational step, considering all gates? | | Matching suitability | Is this specific applicant / job-order pair compatible? | The Eligibility Projection never becomes a generic "ready / not ready" field — readiness is Lifecycle Readiness's job, and it consumes the projection as one input among many. ### Placement Assessment Record **NEW** Applicant eligibility says "who can this person be"; the Placement Assessment says "is *this* match lawful". Like the Eligibility Assessment, it is not an engine and follows the same adjudication boundary: the checks below run as rules on the existing Rules Engine, and this record preserves the sponsor's approval of *that specific match*, referencing the engine's evaluation records (which own the evidence snapshot and rule versions) rather than copying them. One per proposed applicant / job-order pair: | Check | Question | | --- | --- | | Route compatibility | Does the proposed participant type match an approved eligibility route? | | Category / field alignment | Does the applicant's eligible category match the order's category? | | Non-duplication | Is the training structured and guided, not a repeat of prior experience or ordinary employment? | | Duration validation | Within the route cap, the category cap, and any stricter sponsor limit? | | Hours | Full-time requirement for Trainee placements | | Prohibited activity | Unskilled/casual labor, childcare/elder care, patient contact, clerical percentage | | Host eligibility | Host Compliance Assessment passed (below) | | Sponsor decision | Approver, timestamp, engine evaluation references | ### DS-7002 Training Plan Record **NEW** Today the DS-7002 lives outside the system of record; this makes it a first-class versioned record attached to the placement: - Participant, host, site, and supervisor binding; approved participant type and occupational category (must match the Placement Assessment). - Phase structure — site, supervisor, duration, objectives, activities, supervision and evaluation methods per phase. - Signature state per required signatory (applicant, host supervisor, sponsor officer), with method and timestamp per signature. - Versioned: material changes after any signature create a new version requiring re-signature; prior versions retained with amendment history. ### Regulatory Decision **NEW** A platform concept: any regulated program ends in a locked sponsor decision. For Intern/Trainee the decision resolves to Intern or Trainee; for Teacher it would resolve to Teacher; future categories bring their own outcomes. It is the legal sponsor decision — the single authoritative, locked answer for this participant, and the basis every downstream regulated action (DS-2019 issuance, SEVIS registration, program reporting) works from: - Set only by a user holding `compliance.regulatory_decisions.set` (see RBAC below), only when the approved Eligibility Assessment permits that route and the Placement Assessment is approved. - Partner / job-order preference is stored separately as `proposed_route` and never auto-promotes. - Records the locked regulatory basis — assessment references, approved DS-7002 version, program dates — used for DS-2019 / SEVIS. - Carries the sponsor's Compliance Opinion (below) as its human judgment layer. **The decision has a lifecycle — it cannot be born final.** A final decision records the approved DS-7002 version, but the DS-7002 is drafted *against* an approved route — so a single-state decision would have to reference a document that did not yet exist when the decision was made. The lifecycle resolves this: | State | Meaning | | --- | --- | | `draft` | A route is proposed; nothing downstream may rely on it. | | `provisionally_approved` | The sponsor has approved the route sufficiently to draft the DS-7002 against it. This is the state the operating flow's "set the Regulatory Decision" step produces. | | `final` | Set only after the approved DS-7002 version exists with all required signatures. This — and only this — is the locked basis for DS-2019 / SEVIS issuance. | | `corrected` | Post-issuance changes create a corrected decision through the formal correction / amendment flow, never an edit; the prior state is retained. | ### Compliance Opinion **NEW** Rules produce pass / fail with reason codes — but a sponsor's judgment is richer than a boolean, and regulators expect to see the judgment, not just the rule output. The Compliance Opinion is the explicit recorded opinion attached to an approved assessment or Regulatory Decision: | Opinion | Meaning | | --- | --- | | **Approved** | The sponsor is satisfied; proceed. | | **Approved with Conditions** | Proceed, but named conditions must be met or monitored (e.g. a follow-up site visit, an evidence refresh by a date). | | **Rejected** | The sponsor declines, with recorded reasons — even if some rules technically pass. | | **Requires Further Evidence** | No opinion yet; specific evidence is named and requested. | The opinion never overrides a fail-closed rule (a sponsor cannot "approve past" a hard gate); it adds the human layer on top. **Conditions are structured obligations, not notes.** "Approved with Conditions" only works if each condition is machine-trackable. Every condition carries: a **type**, an **owner**, a **due date**, an **evidence requirement** (what proves it was met), a **`blocks_ds2019` flag** — the split between conditions that gate issuance and post-issuance monitoring obligations is explicit per condition, never implied — a **status**, the **resolution actor and timestamp**, and a defined **breach behavior** (what happens if the due date passes unmet). Conditions are tracked as work items so "with Conditions" cannot silently decay into plain approval, and the DS-2019 gate checks only the conditions flagged as blocking. The Compliance Opinion is strictly distinct from the platform's existing rule-override / waiver machinery, and the two do not substitute for each other. An override changes what the engine concludes — it acts on the input side, adjusting a specific rule's evaluation with its own recorded justification, and the engine's outcome then reflects it. A Compliance Opinion acts on the output side: it is sponsor judgment layered on top of whatever the engine concluded, overrides included. A sponsor who is uncomfortable with a technically-passing case records a Rejected opinion; a sponsor who believes a rule fired wrongly pursues an override through the engine's own channel — never by "approving past" it with an opinion. ## End-to-End Operating Flow The whole journey in plain English, start to finish: 1. Staff create the application by selecting Program and Operational Cycle. Nobody selects Intern or Trainee — that decision comes later. 2. Workflow Studio publishes the evidence-collection workflow for the selected program. 3. The applicant declares their education, work history, and occupational interests, and uploads the required evidence. 4. Globus automatically recalculates provisional eligibility whenever material evidence changes. 5. Operations and Compliance review the evidence and approve an Eligibility Assessment. 6. The applicant lands in the Intern pool, the Trainee pool, both, or neither — within one or more occupational fields. 7. A partner or staff member proposes a compatible job order (which may carry a preferred route). 8. Globus runs a Placement Assessment against the order's actual dates, category, field, duties, hours, and host status. 9. An authorized sponsor user approves the placement, records a Compliance Opinion, and sets the Regulatory Decision to `provisionally_approved` — Intern or Trainee. 10. The DS-7002 training plan is drafted against the provisionally approved route, completed, signed by all parties, and approved. 11. The Regulatory Decision moves to `final`, referencing the signed DS-7002 version — and only then does DS-2019 / SEVIS issuance proceed, on the locked regulatory basis. 12. Any later material change (dates, host, evidence) triggers a review — never a silent rewrite of the approved decision. The same flow as a diagram — who acts at each stage, and where the hard gates sit: ``` APPLICANT GLOBUS (automatic) SPONSOR STAFF ───────── ────────────────── ───────────── Application created ───────► evidence-collection (Program + Cycle only; workflow published no route choice) │ │ │ declares education, │ work history, interests; │ uploads evidence ──────────► provisional eligibility ▲ recalculated on every │ material evidence change │ │ targeted evidence ▼ request (only the ◄── evidence gap found Operations + Compliance affected record review and APPROVE the reopens) Eligibility Assessment │ ▼ applicant lands in a pool: Intern / Trainee / both / neither │ partner or staff proposes a compatible job order (may carry a preferred route — never decides it) │ ▼ Placement Assessment runs: dates, category, field, duties, hours, host status │ ▼ authorized sponsor user APPROVES placement, records the opinion, and sets the Regulatory Decision │ ▼ DS-7002 completed, signed by all parties, approved │ ══════════▼══════════ HARD GATE: all five conditions must pass ═════════════════════ │ ▼ DS-2019 / SEVIS issuance on the locked basis │ ▼ any later material change → review work item, never a silent rewrite ``` The same platform seen as an architecture rather than a journey. This is deliberately not a straight line: the Rules Engine feeds both the immutable assessment history and the live operational gates, and everything downstream reads the current projection — which is why evidence changes propagate everywhere without anyone reopening a workflow: ``` ┌─────────────────────────┐ │ Workflow Studio │ │ Evidence Collection │ └────────────┬────────────┘ │ ▼ ┌─────────────────────────┐ │ Evidence Verification │ └────────────┬────────────┘ │ ▼ ┌─────────────────────────┐ │ Canonical Facts │ └────────────┬────────────┘ │ ▼ ┌─────────────────────────┐ │ Rules Engine │ └────────────┬────────────┘ │ ┌───────────────┴───────────────┐ ▼ ▼ Eligibility Assessment Operational Gates (immutable history) (readiness / queues) │ ▼ Eligibility Projection (current operational view) │ ┌────────┴────────┐ ▼ ▼ Matching Applicant Portal │ │ ▼ ▼ Placement Proposal Targeted Remediation │ │ │ (new placement facts feed back into │ Canonical Facts → Rules Engine) ▼ Placement Assessment │ ▼ DS-7002 │ ▼ Regulatory Decision │ ▼ DS-2019 / SEVIS ``` Note the feedback loop: a placement proposal is not the end of eligibility — it *supplies new regulatory facts* (start and end dates, occupational category, hours per week, host status) that flow back into the same fact-and-rules machinery. Matching therefore both consumes eligibility and changes it. ## Operational Scenarios The operating flow above reads as a straight line, but Operations does not live in a straight line. Eligibility is constantly moving — not because regulations change, but because **evidence changes**: an employer refuses a reference, an applicant uploads an alternative document, a host moves an arrival date. The five scenarios below show what the re-evaluation machinery looks like as a normal working day. Each one is routine, not an edge case, and each card reads the same way: where the applicant stands before, what changes, and where they land after. The scenarios are illustrative — the authoritative rule logic lives in the Rules Engine sections that follow. > **Evidence-driven, not workflow-driven.** Most systems in this space are workflow-driven: complete a step, move forward, and going back means reopening everything. Globus inverts this. Evidence changes → facts change → rules re-run → the assessment picture changes → the workflow reacts. The workflow is a consequence of the evidence state, not the other way around. This single inversion — the Living Eligibility Model stated at the top of this page — is what makes every scenario below automatic instead of a manual unwind-and-redo. ### Scenario 1: Evidence verified — eligibility expands **Intern only → Intern + Trainee** — Trigger: a second employer reference is returned and verified The most common movement: an applicant gains a route because more evidence lands. (Both this scenario and the next assume the applicant's degree-route prerequisites are already met — the months of verified experience are the only moving part.) 1. Approved Eligibility Assessment V1: **Intern eligible, Trainee not yet** — only 9 verified months of experience. 2. The applicant adds a second employer; the reference is returned and verified. 3. Verified experience recalculates to 14 months; the affected facts update. 4. Rules re-run automatically; the Trainee route group now passes provisionally. 5. The provisional result differs from the approved one, so a material-change review work item is created; staff approve Assessment V2: **Intern + Trainee**. V1 stays on file, superseded. 6. The Eligibility Projection updates and the applicant automatically surfaces in the Trainee matching pool — no one reopened a workflow, no one re-entered data. ### Scenario 2: Evidence rejected — eligibility shrinks **Intern + Trainee → Intern only** — Trigger: an employer disputes a claimed employment; the verification is rejected The same machinery runs in reverse, which is exactly why it must be fail-closed. 1. Approved Assessment V1: **both routes eligible**, resting partly on 20 verified months. 2. An employer disputes the claimed employment; that verification record is rejected. 3. Verified experience drops below 12 months; rules re-run; the Trainee group now fails. 4. Material-change review fires; staff approve Assessment V2: **Intern only**, with the rejection as its recorded basis. 5. The projection updates and the Trainee matching pool no longer shows the applicant. Any in-flight Trainee job-order proposal is flagged for review rather than silently proceeding. ### Scenario 3: Start date moves — the Intern window closes **Intern eligible → Trainee only** — Trigger: the host moves the arrival date past the 12-month post-graduation window Start date is an eligibility **input**, not scheduling detail — this is the scenario that surprises most readers (it is the same mechanic as Worked Example D below). 1. Approved Assessment V1: **Intern eligible** — graduation was 10 months before the proposed start, inside the 12-month window. 2. The host moves the arrival date; the program now starts 14 months after graduation. 3. The placement-sourced start-date fact changes; rules re-run; the Intern route fails for this placement while the Trainee route still passes. 4. Material-change review; Assessment V2 approved; the projection updates and Matching automatically restricts the applicant to Trainee-compatible orders. ### Scenario 4: Targeted remediation — one record reopens **Trainee pending → Intern + Trainee** — Trigger: one of three employers cannot be verified The scenario that defines the applicant experience: fixing one thing never reopens everything. 1. Provisional result: **Intern eligible, Trainee pending** — one of three employers cannot be verified. 2. The applicant receives a targeted request naming exactly that employer and the reason ("employment could not be verified"), with the option to correct dates or provide an approved alternative document. 3. **Only that employment record reopens.** The other two employers, the education evidence, and every other completed step stay locked. 4. The applicant uploads a reference letter; it is verified; experience recalculates; rules re-run; the projection moves to **Intern + Trainee eligible** pending staff approval of the superseding assessment. 5. Staff approve — or decline, with reasons. Either way the full chain (claim → evidence → verification → recalculation → decision) is on file. The Workflow Studio mechanics behind this scenario: when an applicant declares an employer, the workflow creates a per-employer **employment card** carrying its own reference request, employment documents, alternative-evidence slot, and verification state. Cards succeed or fail independently — one failed card reopens alone while the rest of the application stays locked, and each card's verification feeds the experience recalculation on its own. That per-record granularity is what makes targeted remediation possible at all; it depends on the platform record-level remediation capability listed under Platform Dependencies. ### Scenario 5: DS-7002 amendment triggers reassessment **Approved chain → Reassessment required** — Trigger: the host amends the signed DS-7002 training plan The loop does not close at approval — the DS-7002 itself is regulated evidence. 1. Placement approved, Regulatory Decision set to **Intern**, DS-7002 signed by all parties. 2. The host restructures the training plan; the amended phases shift the activities from Hospitality into Business-dominant duties. 3. The DS-7002's fields — occupational category, dates, phases, objectives — are regulated facts, so the amendment changes canonical facts and rules re-run. 4. A new Placement Assessment is required (and a new Eligibility Assessment where applicable); the amended DS-7002 goes back through signatures. 5. DS-2019 issuance and any downstream SEVIS action stay blocked until the corrected chain — placement assessment, decision, signed plan — is complete again. Never a silent rewrite of the approved basis. ## Eligibility via the Rules Engine All Intern/Trainee eligibility logic lives in the Rules Engine as rules over canonical facts. The Eligibility Assessment orchestrates evaluations and records references to their results; it contains no eligibility logic of its own. ### Evidence Verification Pipeline The architecture this whole scope hangs on is one repeating pipeline, and it is worth stating explicitly because every regulated program will reuse it: ``` Evidence (uploaded documents, declared records) │ ▼ Evidence Verification (human or automated review; immutable verification records) │ ▼ Evidence Sufficiency ("do we have enough verified evidence to calculate?" — per fact) │ ▼ Canonical Facts (single agreed data points, registered in the facts catalog) │ ▼ Rules Engine (versioned rules evaluate the facts; pass/fail + reason codes; owns the immutable facts snapshot + rule versions per evaluation) │ ▼ Assessment (immutable decision record; references the engine evaluations) ``` Nothing downstream ever reads raw evidence: rules read facts, facts come only from verified evidence, the engine's evaluation records freeze both at evaluation time, and assessments point at those records. That is what makes a decision explainable years later. **Sufficiency is a distinct question from verification.** "Is this document verified?" is per item; "do we have enough verified evidence to calculate this fact?" is per fact — and it is the question Operations actually spends its day answering. - Three employers with two verified and one pending can be *sufficient* to compute verified experience — the pending one simply doesn't count yet. One verified employer out of three may not be. - Sufficiency is evaluated per fact against the versioned evidence policy. It is a concept in the pipeline, not a new entity. - An insufficient fact leaves its rules indeterminate — never defaulting to eligible — while generating the targeted evidence request that tells the applicant exactly what is missing. **Experience Verification** is the first-class worked instance of this pipeline — the Trainee route depends on it entirely, and today no part of it exists. The chain runs in four stages: 1. **Employment Record** — the applicant's declared job, dates, role, and field. 2. **Evidence** — contracts, reference letters, payslips backing that record. 3. **Verification** — an immutable per-item verification record: verified, rejected, or alternative approved. 4. **Verified Experience** — verified items roll up into qualifying months per occupational category and field (computed by the algorithm in Open Decisions), feeding the Rules Engine as `applicant.experience.verified_months_in_field`. Each stage is separately auditable: "the applicant claimed 24 months, we verified 18, here is why" must always be answerable. Like the taxonomy, this is built as a **platform evidence-computation service**, not an Intern/Trainee one-off — any future program that asks "how much verified experience does this person have?" reuses it. ### Canonical Facts > **The single largest work item in this scope.** None of the fact keys below exist in the live facts catalog today, and in this engine an unregistered fact makes a rule *permanently indeterminate* — it never fails loudly, it just never resolves. Every fact needs catalog registration **plus** resolver wiring, and several need resolver source paths that barely exist today (placement-, host-, designation-, and DS-7002-sourced facts). The plan per fact: - `applicant.degree.awarded_date`, `applicant.degree.institution`, `applicant.degree.field_of_study` — **Missing** (today: nearest is boolean-level degreeCertificateVerified / applicant.degree_equivalent_verified). Plan: Register attribute-level facts; resolver source: verified education evidence (document wave). - `applicant.enrollment.current` — **Missing** (today: not in the catalog). Plan: Register; resolver source: verified enrollment evidence. - `applicant.experience.verified_months_in_field` — **Missing** (today: not in the catalog — and the computation feeding it is an Open Decision). Plan: Register once the experience-months algorithm is specified. - `application.proposed_start_date` — **Missing** (today: nearest is programStartDate, sourced from the active season, not the placement). Plan: Register a placement-sourced fact; do not reuse the season-sourced one. - `placement.occupational_category`, `placement.occupational_field`, `placement.hours_per_week`, `placement.duration_months`, `placement.clerical_percentage` — **Missing** (today: no placement-sourced resolver path exists). Plan: New resolver source path (placement / job order) + registration. - `sponsor.designation.*` — **Missing** (today: nearest is single-value program.sponsor_designation). Plan: Register against the new Sponsor Authority entity. - `host.compliance_assessment_passed` — **Missing** (today: no host-sourced resolver path). Plan: New resolver source path (Host Compliance Assessment record) + registration. - `ds7002.complete`, `ds7002.signatures_complete` — **Missing** (today: not in the catalog). Plan: New resolver source path (DS-7002 record) + registration. - `englishProficiencyTests` — **Partial** (today: English proficiency exists as this catalog fact). Plan: Reuse the existing catalog fact where semantics match. ### Outcomes & Matching Effect Per-route rule groups produce richer outcomes than pass / fail — the engine also distinguishes indeterminate results, review-required cases, resolver errors, and stale or conflicted facts. The assessment **retains those route-level technical outcomes in full**; the fold below is the *operational projection only*, and two failure classes are never allowed to masquerade as ordinary states: a resolver or system failure must never display as `evidence_pending`, and a human-review case must never display as plain `ineligible`. The projection therefore exposes seven states — the five route states plus two safety states: | Outcome | Meaning | Matching effect | | --- | --- | --- | | **intern_only** | Meets Intern rules for the proposed start date; no Trainee route. | Only Intern-compatible job orders may be proposed. | | **trainee_only** | Meets a Trainee route; outside the Intern graduation window. | Only Trainee-compatible job orders may be proposed. | | **both_eligible** | Independently meets both. | Either route may be proposed; sponsor approves the final route. | | **evidence_pending** | A route exists on paper, but required evidence is unverified. | Not placement-ready; per-fact reason codes drive targeted evidence requests. | | **ineligible** | No current route passes. | Matching blocked, with reason and remediation options shown. | | **review_required** | The engine returned an indeterminate or review-flagged result; a human must adjudicate before any route state applies. | Matching blocked; a compliance review work item exists — never shown as plain ineligibility. | | **system_error** | A resolver failed or facts are stale / conflicted; the result is not trustworthy. | Matching blocked; surfaced to platform operations as a fault — never shown as evidence pending. | ### Re-evaluation & Supersession Re-evaluation is event-driven but never silently changes an approved decision. Three levels: | Level | System behavior | Human action | | --- | --- | --- | | 1. Provisional calculation | Re-run rules whenever a material fact changes. | None, unless the outcome differs. | | 2. Material-change review | When the provisional result differs from the approved result, create a compliance work item in the queue system. | Authorized staff review the change. | | 3. Superseding assessment | A new immutable approved assessment supersedes the old one; the chain is retained. | Sponsor approver records decision and basis. | What Operations actually sees when this fires is a before-and-after, not a raw rule dump: *previous projection* (Intern only) → *current projection* (Intern + Trainee) → *reason* (additional employment verified) → review → approve. Operations never manually decides whether to reassess — the system identifies the material change, creates the review work item, and presents the delta with its cause. Trigger events: education record created/corrected; enrollment, degree, or graduation evidence verified/rejected; employment record or dates changed; employment evidence or reference verified/rejected; occupational classification changed; proposed start/end date changed; job order proposed, changed, or withdrawn; host, site, supervisor, or workers' compensation status changed; DS-7002 content or signatures changed. ## Regulatory Bundle **Start from the live draft, not from zero.** The live Rule Library already contains `teach-intern-trainee-compliance` (draft v1, 13 published rules, precedence 400, manual-attach, pinned to the "Teach Intern and Trainee" program only). It already covers most of what this scope needs: intern and trainee eligibility verification, English proficiency, DS-7002 completeness, evaluation schedule, full-time training, host screening, site-visit requirement, placement-before-DS-2019, finances, duration cap, program exclusions, and not-ordinary-employment. Genuinely net-new rules: designation-scope, occupational-category alignment, clerical-percentage, and the granular route inputs (graduation window, experience months, five-year route). Bundle keys are **hyphenated** (`j1-standard`, `teach-intern-trainee-compliance`), matching the live library. > **This scope supersedes the pathway-discriminator decision.** A previously recorded platform decision resolved the intern-vs-trainee question with a registration-time discriminator on the application. This scope explicitly replaces that design with the dual-route model and its seven-state operational projection: classification is post-evidence and sponsor-owned under 22 CFR 62.22(d); per-route rule groups are evaluated independently; the projection fold lives in the Eligibility Assessment (a consumer of the engine, no engine changes). The two designs must not be implemented in parallel. **Draft-bundle migration.** As authored, the draft cannot publish: `j1.intern.eligibility_verified` and `j1.trainee.eligibility_verified` are both fail-closed and mutually exclusive, so publishing would fail essentially every applicant. Migration: demote the two route rules from required fail-closed gates to **advisory route-group evaluations** consumed by the Eligibility Assessment, and move the hard gate to **DS-2019 issuance on Regulatory Decision + matching approved route** — which is where this scope's SEVIS-gate section puts it anyway. **Attach strategy** (five I/T programs already live across brands): - The **category-scoped auto-attach bundles** `j1-intern` and `j1-trainee` are already seeded at precedence 300 on `visa_type: J-1` plus `category_code: intern_trainee`, modeled on `j1-camp-counselor`, carrying the 22 CFR 62.22 floors so every current and future I/T program gets the regulatory minimum automatically. - The **program-pinned bundle** retained for program-specific extras (e.g. Teach-specific policy). - **Explicit collision rules** when both attach to one application: precedence ordering, and no rule evaluated or counted twice. Both contract points are already implemented in the shipped seed and resolver. **Precedence:** in the seed `j1-standard` sits at 100, the category bundles (`j1-intern`, `j1-trainee`, `j1-camp-counselor` and the other category bundles) at 300 (the same band as `j1-baseline`), and the program-pinned `teach-intern-trainee-compliance` bundle at 400; confirm against the live library. **Attach predicate:** the bundle resolver understands `visa_type`, `program_model_type`, `category_code` and `allows_dependents`, so category-scoped auto-attach is already expressible: `j1-intern` and `j1-trainee` attach on `visa_type: J-1` plus `category_code: intern_trainee`. No new predicate is required. - The bundle definition needs an explicit `allowedCycleTypes` decision — the worked examples' "2027 Rolling" cycle must be within the bundle's allowed cycle types or program/season creation is rejected by the cycle-type model. Compliance-critical members are `lock_required`; bundles are immutable — adding a rule later means a new bundle version plus the standard migration flow. One correction to the shared-gates assumption: **banned-country checking is currently SWT-specific** (an SWT-eligibility country flag plus SWT sync), not a platform-layer gate — applying it to Intern/Trainee is new work, tracked in the gap table. ## Workflow Studio vs Rules Engine Workflow Studio controls what the applicant is asked; the Rules Engine decides what it means. The boundary, capability by capability: | Capability | Workflow Studio | Rules Engine / Compliance | | --- | --- | --- | | Ask for education and work history | Yes | No | | Required fields and conditional visibility | Yes | Consumes results where needed | | Repeatable employment records | Yes | Evaluates each record | | Request references / evidence | Yes | Determines verification status and qualifying months | | Decide Intern eligibility | No | Yes | | Decide Trainee eligibility | No | Yes | | Approve occupational alignment | No | Yes, with human sponsor review | | Open targeted remediation | Provides the UI capability | Creates the request and reason | | Select final participant type | No | Placement proposal + sponsor approval | | Generate DS-7002 obligations | No | Compliance / placement module | ## Program Configuration (P11.5) Intern/Trainee program configuration is Workflow Studio work in the Phase-2 configuration family (`P11.5: Program Configuration (Teacher Lounge)` owns the Teach trees; Intern/Trainee gets its own step trees in the same family): - One shared intake tree — identity, education evidence, employment evidence, English proficiency, banned-country gate, insurance — with **conditional evidence steps**, not separate hard-coded workflows per route: a current student needs enrollment evidence; a recent graduate needs graduation evidence; a potential five-year Trainee needs enough employment evidence to establish five qualifying years. - Step status uses the canonical vocabulary (`completed`, not `complete`); locking follows the Globus-authoritative step-locking contract. - Program-level configuration binds the offered occupational categories (a subset of sponsor-authority scope) and pins workflow, ruleset, and evidence-policy versions so in-flight applications stay on the version they started with. ## Applicant Portal Behavior The Applicant Portal is an **evidence portal, not an eligibility portal**: applicants manage their evidence — documents, work history, references — and the system manages eligibility. An applicant never edits, requests, or manages an eligibility status directly. - Registration collects no route choice — one "Intern / Trainee" application, one document checklist. - Internal recalculations are invisible. The portal shows a neutral status ("Your education and experience are under review") unless the applicant actually needs to act. - When action is needed, a **targeted evidence request** reopens only the affected record, never the whole step: > **Additional employment evidence required.** We could not verify your employment at Hotel ABC for January 2021 to December 2022. Please either correct the employment dates, or provide an approved alternative document. *(Only the Hotel ABC work-history record is reopened.)* - Assessment outcomes are surfaced in plain language ("You currently qualify for the Intern route") without exposing rule internals, reason codes, or staff notes. - The full loop from the applicant's side, end to end: status shows *"Intern: eligible · Trainee: under review"* → the applicant receives *"Additional evidence required — ABC Hotel, Jan 2022 – Dec 2023: employment could not be verified"* → they upload a reference letter → it is verified → the status becomes *"Intern: eligible · Trainee: eligible."* No manual reopening, no restarted workflow, no support ticket — the portal simply reflects the Eligibility Projection as it moves. - Route explanation content is a docs/help concern (P9 family), not portal logic. ## Matching, Job Orders & Placement Job orders for Intern/Trainee extend the existing job-order model: | Field | Notes | | --- | --- | | `occupational_category` + `occupational_field` | From the taxonomy; must be within program scope | | `proposed_route` | Optional partner preference; never authoritative | | Duties / training summary | Phase-structured enough to seed the DS-7002 | | `hours_per_week` | Full-time check input for Trainee. Exists today as `averageHoursPerWeek` — a string inside the job-order requirements blob — so the `placement.hours_per_week` fact resolver needs type coercion to a number | | Start/end dates and duration | Validated against route and category caps | | `clerical_percentage` + prohibited-activity attestations | Rules Engine inputs | - Matching reads the **Eligibility Projection**, never assessment records directly: it filters candidates by the projection's current outcome — a `trainee_only` applicant never surfaces for an Intern-preferred order and vice versa; `both_eligible` applicants surface everywhere. This keeps Matching decoupled from the assessment chain: it asks one question ("who is currently eligible for what?") of one derived view, and supersession or pending reviews change what Matching sees without Matching knowing why. **Matching is stateless with respect to eligibility**: it never caches or copies eligibility into its own tables — every search consumes the latest Eligibility Projection at query time. - Every proposed match creates a **Placement Assessment**; placement confirmation re-runs eligibility against the order's actual start date and category before the reservation hardens — the start-date flip (Worked Example D) is a first-class flow, not an edge case. - Matching is therefore a **feedback loop, not a terminus**: a proposed placement contributes new regulatory facts (dates, category, field, hours, host status) into the same fact-and-rules machinery that produced the eligibility it consumed. If those facts flip the projection — the start date moves, the category no longer aligns — Matching sees the change immediately and adjusts which opportunities the applicant surfaces for, with no manual intervention. ## Host Compliance Assessment Host compliance is an ongoing assessment over the host relationship, not a one-time pass/fail gate. A host must hold a current, passing compliance assessment before any placement approval, bringing together what today lives in separate places: - Legal identity (name, EIN), sites of activity, and revenue thresholds (which drive site-visit requirements). Employee count — the other threshold input — already exists as `total_employees` on the host organization profile, alongside `annual_revenue`. - Workers' compensation evidence, supervisor and facilities review. - Site-visit required / completed / waived status — including recurring site-visit obligations, not just the initial visit. - Approved occupational categories for that host, with approval and expiry status. - Room over time for the fuller host relationship: complaint history, monitoring findings, and suspension state, so a host in bad standing blocks new placements the same way a failed check does. The assessment result is a Rules Engine fact (`host.compliance_assessment_passed` — named after this entity, since fact keys are effectively immutable once frozen into the engine's evaluation records that assessments reference); reviews are immutable records, re-run on host material changes via the same re-evaluation machinery — the same assessment-record pattern used for applicants, applied to hosts. ## DS-7002 → DS-2019 / SEVIS Gate DS-2019 issuance (and therefore SEVIS registration) is gated on: 1. Approved Eligibility Assessment permitting the chosen route. 2. Approved Placement Assessment for the actual placement. 3. Regulatory Decision in state `final` (which itself requires the signed, approved DS-7002 version), with a Compliance Opinion of Approved — or Approved with Conditions where every condition flagged `blocks_ds2019` is satisfied; non-blocking conditions remain open as monitoring obligations. 4. DS-7002 current version complete with **all** required signatures. 5. All `lock_required` bundle rules passing. **The DS-7002 is regulated evidence, not just a document.** Once the sponsor approves the placement, its contents — occupational category, participant type, host, supervisor, dates, training phases, objectives — become regulated facts. Any subsequent change to those facts requires a new Placement Assessment, a new Eligibility Assessment where applicable, an amended DS-7002 back through signatures, and, if required, a corrected Regulatory Decision — all before DS-2019 issuance or any SEVIS action continues (Scenario 5 above walks through exactly this). The gate is a Rules Engine evaluation, so blocking reasons are enumerable and surface in the queue system like every other compliance gate. Missing or inconsistent data **blocks progression — it never defaults to eligible**. Post-SEVIS lifecycle (check-in, monthly check-in, extensions, completion) reuses the existing On-Program machinery, plus midpoint/final evaluation obligations generated from the approved program duration. ## Worked Examples Four realistic applicants, traced through the model end to end. ### Example A: Education applicant eligible as Intern only | | | | --- | --- | | **Setup** | Brand: Teacher Lounge · Program: Teach Intern and Trainee · Cycle: 2027 Rolling | | **Evidence** | Currently enrolled in a qualifying education degree; six months of relevant teaching-assistant experience | | **Intern route** | **Eligible** | | **Trainee route** | **Not yet eligible** — under 12 verified months | | **Category / field** | Education, Social Sciences, … · Early Childhood Education | | **Matching effect** | A 12-month education Intern job order may be proposed; Trainee job orders are blocked | ### Example B: Hospitality applicant eligible for both | | | | --- | --- | | **Setup** | Program: Hospitality Intern & Trainee (future program — not live today) | | **Evidence** | Hospitality degree; graduated eight months before the proposed start; two years of verified hotel Front Office experience | | **Intern route** | **Eligible** — for the proposed start date | | **Trainee route** | **Eligible** — through the degree + one-year route | | **Combined display** | Eligible for Intern or Trainee | | **Job order** | Hospitality/Tourism · Front Office · proposed type: Trainee · 12 months | | **Result** | **Compatible** — subject to sponsor approval and DS-7002 | ### Example C: Hotel marketing role classified as business | | | | --- | --- | | **Applicant** | Marketing degree; digital-marketing experience | | **Host / job order** | A hotel group · Digital Marketing role | | **Classification** | Management, Business, Commerce and Finance · Marketing — **NOT Hospitality Operations** | | **The rule** | The actual training activities — not the host's industry — determine the classification | ### Example D: Start-date change flips eligibility | | | | --- | --- | | **Approved assessment** | Intern **Eligible** · Trainee **Evidence pending** | | **The change** | Placement start date moves four months later | | **New provisional result** | Intern **Ineligible** (outside the 12-month graduation window) · Trainee **Eligible** once one more employer verification lands | | **System response** | Create a material-change review work item; do **not** silently change the approved route; the sponsor approves a new superseding assessment; placement and DS-7002 updated if needed, before DS-2019 | ## Globus Gap Analysis The gap analysis, measured against what the platform actually has. **Priority** says how urgent a capability is; **Complexity** says how hard it is to build: **Low** — configuration or a small extension of machinery that already works; **Medium** — a new component built on existing platform paths; **High** — new machinery, new resolver paths, or work blocked on an Open Decision. A Critical-priority / Low-complexity row is quick to clear; a Critical / High row is where the schedule risk lives. | Capability | Current Globus position | Required change | Priority | Complexity | | --- | --- | --- | --- | --- | | Eligibility rule evaluation | **Exists** — Rules Engine with reason codes and bundles | Author I/T route-input rules; no new engine. The engine exists, but see the Facts wiring row — rules cannot resolve without it | — | **Low** | | Facts wiring | **Missing** — none of this scope's fact keys are in the live facts catalog; an unregistered fact leaves a rule permanently indeterminate | Register every fact + wire resolver source paths (placement, host, designation, DS-7002 are new paths) — see Canonical Facts | **Critical** | **High** | | Immutable rulesets / versioning | **Partial** — draft bundle `teach-intern-trainee-compliance` (13 published rules) already exists | Reconcile / extend / migrate the existing draft; only route-input, designation, and taxonomy rules are net-new | **Critical** | **Medium** | | Re-evaluation on fact change | **Exists** — engine re-evaluation + queue work items | Wire I/T trigger events; add supersession on the assessment record | **Critical** | **Medium** | | Conditional evidence collection | **Exists** — Workflow Studio conditional steps | Author I/T step trees (P11.5) | **Critical** | **Medium** | | Audit with reason codes | **Exists** — platform audit standard | New event vocabulary, plus engine-evaluation references on every assessment (the engine owns the immutable evidence/rule snapshots) and supersession-chain links between decisions | **High** | **Medium** | | Banned-country gate | **Missing for I/T** — today's machinery is SWT-specific (SWT-eligibility country flag + SWT sync), not a platform-layer gate | Build or generalize a banned-country gate for I/T | **High** | **Medium** | | Occupational Category / Field taxonomy | **Missing** | New platform entity + program mapping | **Critical** | **Medium** | | Sponsor Authority | **Missing** | New platform-wide entity | **Critical** | **Medium** | | Eligibility Assessment record | **Missing** | New adjudication record referencing engine evaluations | **Critical** | **High** | | Eligibility Projection | **Missing** | New derived current-view entity over the assessment chain | **Critical** | **Medium** | | Experience Verification pipeline | **Missing** | Employment Record → Evidence → Verification → Verified Experience chain; verified-months computation (algorithm TBD — Open Decisions) | **Critical** | **High** | | Placement Assessment | **Missing** | New per-match record with non-duplication, hours, prohibited-activity checks | **Critical** | **High** | | Host Compliance Assessment (consolidated) | **Partial** — host vetting exists, not consolidated as one assessment fact | Consolidate into an assessment record + engine fact | **Critical** | **Medium** | | Job Order regulatory fields | **Partial** — hours/week already exists (`averageHoursPerWeek`) | Extend with category, field, route, clerical %, attestations (hours already there) | **Critical** | **Low** | | DS-7002 record | **Partial** — a seeded DS-7002 document type and the live `ds7002_completed_signed` rule exist | Build the versioned entity with per-signatory signature state | **Critical** | **High** | | Regulatory Decision | **Missing** | New locked decision entity with Compliance Opinion | **Critical** | **Medium** | | Record-level remediation | **Partial** — staff-side item-level review exists in the step-review panel | Portal-side single-record reopen (platform work-item mechanics; dependency, not owned here) | **High** | **High** | | Evaluation obligations (midpoint/final) | **Partial** — On-Program machinery exists | Generate obligations from approved duration | **High** | **Low** | | RBAC for regulatory decisions | **Partial** | New grants for assessment approval, compliance opinions, the Regulatory Decision, taxonomy, sponsor authority | **Critical** | **Low** | ## RBAC, Approvals & Audit - "Authorized Globus user" is never the final answer for a legal sponsor decision — every regulated action maps to a named permission. Eligibility determinations are already gated by the shipped `eligibility.approve` (approve / reject / revoke) and `compliance.console.view` (reads and re-evaluation). Further new permission families: `compliance.placement_assessments.review`, `compliance.regulatory_decisions.set`, `compliance.regulatory_decisions.correct`, `compliance.sponsor_authority.manage`, `compliance.occupational_taxonomy.manage`. Sponsor-authority and taxonomy grants are platform-admin level, and the platform's existing role split is respected: Compliance Admin authors rules and policy; Compliance Officer reviews, adjudicates, and overrides live cases. - **Segregation of duties:** a person who changes Sponsor Authority, the occupational taxonomy, or underlying compliance policy must not approve an applicant under the newly changed policy without independent review. The system enforces this as a review requirement, not a convention. - Partners can propose routes and submit job orders; they can read assessment outcomes for their own applicants (through the standard partner redaction/projection layer) but never approve. Regulated decisions cannot be made by partner users. - Audit events use the dotted-scoped vocabulary: `eligibility.determination.approve / reject / revoke`, `eligibility.assessment.evaluate`, `placement_assessment.created / approved / rejected`, `regulatory_decision.set / correction_requested`, `compliance_opinion.recorded`, `ds7002.version_created / signed / completed`, `occupational_category.*`, `sponsor_authority.*`, `host_compliance.*`. Every approval records actor, time, and the engine evaluation references that resolve to the exact ruleset version and evidence snapshot. Nothing is hard-deleted. Audit reads assessment records, never the Eligibility Projection. ## Platform Dependencies Work this scope needs but does not own: - **Record-level remediation** — re-requesting one failed evidence item without resetting the step. Platform work-item mechanics; Intern/Trainee is the first consumer. - **Sponsor Authority** — platform-wide; SWT and Camp will consume it too. - **Experience Verification / verified-experience computation** — a reusable platform evidence-computation service, not an Intern/Trainee one-off. - **Occupational Category taxonomy** — platform master data consumed by any program that classifies placements. > **Future platform consumers.** These capabilities are intentionally reusable beyond Intern/Trainee: Sponsor Authority, the Occupational Taxonomy, Experience Verification, the Eligibility Projection, the Regulatory Decision record, and record-level remediation. Future programs — Teach, Camp, SWT, and non-US programs — are expected to consume these platform services rather than build parallel ones. ## Open Decisions These questions must be answered before this scope can be fully built — each one is a formal architecture gate for the workstreams that depend on it. Each row states the question in plain terms, why it matters, and who owns the answer. | Decision | The question, in plain terms | Owner | | --- | --- | --- | | Experience-months algorithm | How exactly do we count months of experience? If someone worked two jobs at once, does that count once or twice? Does part-time work count in full or in part? What about gaps — and how close must a past job be to the training field to count as "in field"? Trainee rules cannot be written until this math is pinned down. | Compliance + platform | | Ten-month Intern buffer | Our internal process mentions a ten-month buffer, but no regulation requires it. Do we keep it as an official, adjustable sponsor policy — or drop it? | Compliance | | DS-7002 signature mechanics | How do people actually sign the DS-7002 — electronically through a provider, or by uploading a signed paper copy? Can different signers use different methods? And where is the form created and stored today? | Platform + compliance | | Combined vs split programs | Do applicants apply to one combined Intern/Trainee program, or to two separate ones? Splitting sounds simple but isn't: the rule bundles, the five live programs, and the automatic rule-attachment logic are all built around the combined category today. | Product | | Legal sponsor entity per brand | Do all our brands sponsor visas under one State Department designation, or several? The actual designation paperwork is the source of truth, and it must be confirmed before production. | Compliance | | Commercial vs Regulatory Program split | A "program" currently means two things at once: the product we sell and the visa category we sponsor under (acknowledged in Program Hierarchy Placement). Should those become two separate concepts — and if so, when? This is a platform-wide decision, not an Intern/Trainee one, and it recurs in every architecture review — it warrants its own standalone platform architecture proposal rather than remaining a row here. | Product + platform | | Bundle naming convention | The live Rule Library uses hyphenated bundle keys (`j1-standard`); some platform contract documentation uses underscores. Two spellings of a stable identifier cannot both remain standard. Resolution: one side adopts the other — the cheapest path is aligning the contract documentation with the live library, since renaming live bundle keys under five running programs is high-risk. Owned by the Rules Engine platform side, not this scope. | Platform | | Evidence policy | Which documents count as proof of past employment? Who is allowed to accept a substitute when the standard document can't be obtained? Reference letters are our own policy, not a regulation — so the policy must stay versioned and editable, never hard-coded. | Compliance | ## Rollout & Installed Base > **Rollout is a migration, not a launch.** Five Intern/Trainee programs are already live. Publishing a category auto-attach bundle whose facts are not wired yet would mass-fail (or flood review queues for) every in-flight applicant. Rollout requirements: - **Staged bundle attach.** Wire and verify facts first; attach the category bundle to one program (Teach Intern and Trainee, where the draft is already pinned), observe, then extend to the remaining four programs. - **Fail-safe mode per rule.** Each rule gets an explicit choice: fail-closed (blocks progression), advisory (informs the assessment), or monitor-only during rollout. The two route rules are advisory by design (see the bundle migration above); the hard fail-closed gate lives at DS-2019 issuance. - **Impact statement for existing programs.** Before any attach, an explicit analysis of how many in-flight applicants each rule would flag, and what the review-queue load looks like. - **Bindings + gate wiring per phase.** A published rule enforces nothing until it has evidence bindings and is wired into the relevant gate (SEVIS-readiness / placement). Each delivery phase below carries its own bindings-and-gate-wiring work, not just rule authoring. - **Cycle-type constraints.** The bundle's `allowedCycleTypes` must admit the live rolling cycles, or program/season creation is rejected. ## Delivery Sequence When this scope is decomposed into tickets (not yet done), the phases and their owning surfaces: - **1. Regulatory foundation** (Globus platform entities) — Occupational Category taxonomy, Sponsor Authority, program authorization mapping, canonical terminology. First because it prevents more data being created against ambiguous categories. - **2. Applicant eligibility** (Rules Engine (P3) + Program Configuration (P11.5)) — Facts registration + resolver wiring, evidence steps, Experience Verification pipeline, route rules, draft-bundle migration, Eligibility Assessment + Projection. Creates reliable Intern/Trainee pools. - **3. Portal remediation** (Platform + Applicant Portal (AP)) — Record-level evidence requests, selective reopening. Makes re-evaluation operationally usable. - **4. Host & job-order compliance** (Globus platform + matching) — Host Compliance Assessment, job-order extensions, prohibited-activity and duration checks. Prevents invalid matching. - **5. Placement approval** (Matching / job orders) — Placement Assessment, Compliance Opinion, Regulatory Decision. Controlled route selection. - **6. DS-7002 & SEVIS gate** (SEVIS lifecycle) — Versioned DS-7002, signatures, amendment flow, issuance gate. Completes the regulatory chain. - **7. In-program obligations** (On-Program lifecycle) — Midpoint/final evaluations, monitoring, amendments. Covers the lifecycle beyond issuance. Applicant-facing route explanations land in the P9 documentation family alongside phase 2. ## Key Risks | Risk | Impact | Mitigation | | --- | --- | --- | | One generic "category" field | Regulatory category, occupational category, and job field get conflated | Canonical entities and migration rules before implementation | | Job-order selection treated as final authority | Partner or Operations makes an unapproved regulatory classification | Sponsor approval + explicit permissions | | Counting total instead of relevant experience | Applicant incorrectly found Trainee-eligible | Calculate by occupational category/field from verified evidence only | | Reopening whole workflow steps | Applicant confusion, data loss, poor auditability | Record-level remediation, immutable versions | | Conflating cycle and participant duration | Invalid program length, reporting errors | Dates on placement / DS-7002; validate separately | | Hard-coding reference letters as regulation | Policy becomes inflexible and legally mischaracterized | Versioned evidence policy with controlled alternatives | | Silent changes after approval | Approved decisions and issued documents diverge | Material-change review + supersession, never rewrite | | Eligibility without host / DS-7002 controls | System looks compliant but permits invalid placements | Deliver the complete chain, not applicant rules alone | ## Conclusion Intern/Trainee is intentionally the first consumer of this architecture rather than a special case. The objective is to establish reusable platform capabilities — living eligibility assessment, sponsor adjudication records, verified-evidence computation, and regulated decision audit — that can support future regulated programs without creating parallel implementations. ## Related Pages - Rules Engine — canonical facts, bundles, and the Normative Contracts section. - Applicant Lifecycle — where the assessment and SEVIS gate sit in the pipeline. - Job Orders — the base model extended here. - Workflow Builder — step trees and conditional evidence mechanics. --- --- title: "Portal Status Mappings, Compliance Inputs and Field Edits" slug: portal-reference-tables section: Program Configuration status: stable description: "Three Hub tables in BackOfficePrototype support the Workflow Builder and the Applicant Portal contract." last-updated: 2026-10-07 --- # Portal Status Mappings, Compliance Inputs and Field Edits ## Overview Three Hub tables in BackOfficePrototype support the Workflow Builder and the Applicant Portal contract. Two are small reference tables that are seeded and read by the back office. The third is an audit log of staff and system edits to applicant data. | Table | Kind | Defined in | |---|---|---| | `portal_status_mappings` | Reference data, maps a portal status string to a display stage | `shared/models/portal.ts` | | `compliance_input_definitions` | Reference data, catalogue of rule input keys | `shared/models/portal.ts` | | `applicant_field_edits` | Append-only audit log of field changes | `shared/models/portal.ts` | Routes for the two reference tables live in `server/routes/lookups.ts`. Routes for field edits live in `server/routes/applicantFieldEditRoutes.ts`, registered through `server/routes/applications/index.ts`. The seeder is `server/seed/systemLookups.ts`. The compliance input keys are what a Step Library entry binds form fields to (see [Workflow Studio](/docs/workflow-builder)). Field edits are written back to the portal step snapshot, which is how a staff correction reaches the applicant side (see [Endpoint Reference](/docs/applicant-portal-endpoints)). ## Data Model ### portal_status_mappings | Column | Type | Notes | |---|---|---| | `id` | varchar, PK | `gen_random_uuid()` default | | `portal_status` | text, not null, unique | The status string, for example `h_submitted` | | `stage_key` | text, not null | Display stage, for example `first_document_batch` | | `label` | text, not null | Display label | | `badge_variant` | text, not null | Default `outline` | | `color` | text, not null | Default `text-muted-foreground` (a CSS class name) | | `created_at` | timestamp | Default now | Index: `idx_portal_status` on `portal_status`. The seeder writes 7 rows: `i_initial`, `ii_initial_incomplete`, `initial_incomplete` and `draft` map to stage `initial_applicant`; `h_submitted` to `first_document_batch`; `fw_form_issued_waiting` to `sevis`; `p_participating` to `on_program`. ### compliance_input_definitions | Column | Type | Notes | |---|---|---| | `id` | varchar, PK | `gen_random_uuid()` default | | `input_key` | text, not null, unique | Rule input name, for example `dateOfBirth` | | `label` | text, not null | | | `description` | text | | | `data_type` | text, not null | Default `string`; seeded values are `date`, `string`, `number`, `boolean` | | `transform_options` | jsonb | Nullable; the seeder sets none | | `sort_order` | integer, not null | Default 0 | | `is_active` | boolean, not null | Default true | | `created_at` | timestamp | | Index: `idx_compliance_input_key` on `input_key`. The seeder writes 12 keys: `dateOfBirth`, `nationality`, `priorJ1Participations`, `backgroundCheckCompleted`, `referencesCompletedCount`, `orientationCompleted`, `passportExpiryDate`, `documentsCompletedCount`, `documentsTotalCount`, `flightDetailsCompleted`, `housingInfoCompleted`, `insuranceCompleted`. ### applicant_field_edits | Column | Type | Notes | |---|---|---| | `id` | varchar, PK | | | `applicant_id` | varchar, not null | FK to `applicants` | | `application_id` | varchar | Nullable FK to `applications` | | `step_key`, `field_key`, `field_label` | text | `field_key` is not null; `step_key` is nullable | | `actor_type`, `actor_id`, `actor_name` | text, varchar, text | `actor_type` not null; `actor_id` has no FK (polymorphic); the name is a snapshot kept for audit | | `source` | text, not null | Seen in code: `backoffice`, `api`, `backoffice_patch`, `biographical_sync` | | `set_by_role_id` | varchar | Nullable FK to `roles`; provenance for the cross-role overwrite check | | `previous_value`, `new_value` | jsonb | | | `reason` | text | | | `created_at` | timestamp | | Indexes: `idx_field_edit_applicant`, `idx_field_edit_application`, `idx_field_edit_step_field` (step_key, field_key), `idx_field_edit_created`. ## Routes ### Reference Table Reads Both are GET only. No route in the Hub writes to either table (see Known Gaps). | Verb and path | Guards | Response | |---|---|---| | `GET /api/portal-status-mappings` | `isAuthenticated`, `requirePermission("dashboard.view")` | All rows, wrapped with `wrapAsPage` in the success envelope | | `GET /api/compliance-input-definitions` | `isAuthenticated`, `requirePermission("dashboard.view")` | Rows with `is_active` true, ordered by `sort_order`, same wrapper | ### Field Edit Routes | Verb and path | Guards | Purpose | |---|---|---| | `POST /api/applicants/:applicantId/field-edits` | `isAuthenticatedOrApiKey("applications.write")`, `requirePermission("applicant.journey.update")`, field permission enforcer | Record one edit | | `POST /api/applicants/:applicantId/field-edits/batch` | Same guards except the enforcer runs inside the handler for every field | Record many edits for one step in one request | | `GET /api/applicants/:applicantId/field-edits` | `isAuthenticatedOrApiKey("applications.read")`, any of `applications.view`, `applicant.profile.view`, `applicants.list.view` | List edits, newest first | | `GET /api/applicants/:applicantId/field-edits/history/:fieldKey` | Same as the list route | Edits for one field | Single-edit body: `stepKey` and `fieldKey` required; `applicationId`, `fieldLabel`, `previousValue`, `newValue`, `reason` optional. Success is 201 with the created row. Batch body: `stepKey`, optional `applicationId`, and `edits` (at least one item, each with `fieldKey` and optional label, values, reason); success is 201 with the array of created rows. List query filters: `applicationId`, `stepKey`, `fieldKey`; history takes an optional `stepKey` query. Both reads return 200. ## Errors | Status | Top-level `error.code` | `details.code` | Raised when | |---|---|---|---| | 400 | `VALIDATION_ERROR` | none | Missing ids or keys, duplicate `fieldKey` in a batch, application not owned by the applicant, step or field is legally locked, unusable passport number or expiry | | 403 | `FIELD_ACCESS_DENIED` | none (`details.fieldPermission` carries forensics) | Role has `hidden` or `no_access` on the field | | 403 | `FIELD_READ_ONLY` | none | Role has `view` only | | 403 | `FIELD_OVERWRITE_DENIED` | none | Value was last set by a different role and the caller is not a sponsor with `override` | | 500 | `FIELD_PERMISSION_ERROR` | none | The field permission check itself threw; the route fails closed | | 500 | `FIELD_EDIT_SYNC_FAILED` | none (`details.stage`, `stepKey`, `fieldKeys`) | Snapshot persist or applicant-row promotion failed after the edit row was written | The batch route evaluates the field permission for every field before any write, so a single denied or locked field rejects the whole batch. ## Who Writes and Reads | Table | Writers | Readers | |---|---|---| | `portal_status_mappings` | `seedSystemLookups` upserts on `portal_status` | `GET /api/portal-status-mappings`; client hook `usePortalStatusMappings` | | `compliance_input_definitions` | `seedSystemLookups` upserts on `input_key` | `GET /api/compliance-input-definitions`; Step Library page via `useComplianceInputDefinitions` | | `applicant_field_edits` | The two POST routes; `PATCH` applicant route in `server/routes/applications/applicants.ts` (identity fields `firstName`, `lastName`, `dateOfBirth`, source `backoffice_patch`); `server/services/biographicalSync.ts` (date of birth promotion, actor `system`, source `biographical_sync`) | The two GET routes; the applicant profile progress section in the client | The Gateway Globus backend exposes `submit_applicant_field_edits` and `list_applicant_field_edits` tools that call the field-edit URLs (`alliance-mcp-gateway backends/globus-mcp/src/tools.ts`). ## Behaviour and Edge Cases - Seeding is an upsert: existing rows are overwritten from the seed for `label`, `stage_key`, `badge_variant`, `color` (mappings) and for label, description, type, options, order, active flag (inputs). A manual database change to those columns is reset on the next seed run. - Compliance input keys are consumed through the Step Library. Each entry has a `complianceInputMapping` array of `fieldName`, `inputKey`, optional `transform`. The Step Library form rejects a row with an empty field or key, and rejects a field name absent from the step schema unless it is a virtual mapping. `server/services/coverageAnalysis.ts` indexes these mappings by `inputKey` to decide whether each configured rule has a step that supplies its inputs, and suggests steps that would fill a gap. - A field edit with an `applicationId` also writes an audit log row (`field_edit`), emits a step-updated event, and merges the value into the portal step snapshot, keeping `is_completed` unchanged and promoting biographical fields to the applicant row. That write is awaited. After the snapshot write, a compliance re-evaluation keyed `staff.field.edit` is queued only when the snapshot step is already complete and the edit touched an evaluable fact field whose value reached the place the rules read it (for promoted biographical fields, the `applicants` column must now carry the edit). Queue failures are logged and swallowed. - Independent of `applicationId`, an edit to one of the biographical fields that trigger it flags DS-2019 resubmission for the applicant's issued applications. - Without `applicationId` the edit row and audit log are written, and the DS-2019 resubmission flag still applies; no step-updated event, snapshot write or re-evaluation occurs. - Empty passport number or expiry is accepted (clearing a field); a non-empty unreadable value is rejected. - The table is an audit log, not live state; the client test `progress-section.stale-field-edit.test.ts` records that overlaying the latest edit row onto step data was a bug. ## Known Gaps - No admin write route or UI for `portal_status_mappings` or `compliance_input_definitions` was found; maintenance is by changing the seed. - `usePortalStatusMappings` is defined but no component calling it was found. `server/services/checkInSync.ts` uses a hard-coded status map instead of this table. - The endpoint schema sample for `GET /api/portal-status-mappings` shows `internalStatus` and `portalStatus` fields, which are not columns of the table. - The API reference lists the field-edit GET routes with permission `applicants.view`, but the handlers check the permissions in the routes table above. - The batch route is absent from the mutation registry (only the single POST was found there). - The Gateway `submit_applicant_field_edits` tool posts an `edits` object as the body, which does not match the single-edit body the route validates. --- --- title: "Capability Layers" slug: globus-layers section: Globus Core status: stable description: "Globus is the Sponsor Core (ERP) — a modular, layered architecture" last-updated: 2026-10-07 --- # Capability Layers Globus is the Sponsor Core (ERP) — a modular, layered architecture > **Modular by Design** > > Globus is designed as a modular capability stack so we can add programs, seasons, partners, compliance, and future modules (finance, analytics) without rebuilding the system each year. confirmed\" to \"reserved\". Refresh the placement and try again. / details / placementId / from / confirmed / to / reserved / Cancelled / RESERVATION_POSITION_TAKEN / This position has already been reserved. Please pick another open position on this job order. / jobOrderId / positionsTotal / positionsReserved / positionsPlaced / placement / s role at that moment, not their current role — preserves an accurate historical view even if the user / s initial creation. | | newStatus | string | Status after the transition. Drawn from the placement status enum. | | reason | string (machine code) | Required. Drawn from the standard reason-code catalog (Task #163). Free-text rationale belongs in note, not here. | | note | string? | Optional free-text annotation supplied by the actor. Subject to PII redaction (see RBAC below). | | correlationId | string | Matches the originating event / corrected / s standard pagination convention. | | GET /api/admin/placements/:id/history?format=csv | CSV (oldest-first, full history) | Content-Type: text/csv; Content-Disposition: attachment; filename="placement-<id>-history.csv". CSV is a query parameter — there is no `.csv` path variant. | ## AI Integration Globus integrates AI capabilities as a cross-cutting service layer available to any module. AI features are registered in a capability registry, managed through an admin UI, and consumed through generic service endpoints. A model-provider abstraction layer (the `provider` field on `ai_capabilities`) routes each capability to the correct backend: sensitive document processing — passport OCR and language detection — runs on the private GCP provider (PRIVATE_GCP), while non-PII text tasks use the shared general-purpose provider. The abstraction gives consistent configuration, rate limiting, and cost tracking across all AI-powered features. See Private OCR (GCP Document AI). ### Capability Registry Every AI capability in Globus is registered in a central capability registry that controls availability, versioning, and feature flags: | Capability | Description | Status | Consumer | | --- | --- | --- | --- | | Document Extraction | Extract structured data from uploaded documents (passports, certificates, forms). Passport OCR runs on the private GCP provider (internal Document AI OCR service, `OCR_SERVICE_BASE_URL`) — no public OCR API. | | Document review, applicant onboarding | | Text Review | AI-assisted review of free-text fields for completeness, grammar, and compliance language | | Application review, job order descriptions | | Translation Assist | Contextual translation of applicant-submitted content for staff review | | Review queue, compliance notes | | Anomaly Detection | Flag statistically unusual patterns in application data for manual review | | Compliance, fraud detection | ### Admin UI The AI admin interface allows authorized staff to manage AI capabilities: Enable or disable individual AI capabilities via feature flags (no code deployment required) / View usage metrics per capability: request count, average latency, error rate, token consumption / Configure capability-specific settings (e.g., confidence thresholds, model version, max tokens) / Review AI invocation log showing every AI invocation with its input and output payloads, token counts, duration and status / Set rate limits per capability to control costs and prevent runaway usage Storage and provider settings: `ai_invocation_logs` holds one row per invocation (`capabilityKey`, `model`, `status`, `errorMessage`, prompt and completion token counts, `durationMs`, `userId` and, when supplied, `applicationId`). The logged prompt text is cut to 2,000 characters and the result text to 4,000, image base64 is replaced by its length, and the write is best-effort, so a logging failure never fails the AI call. `ai_provider_settings` holds one row per provider (`providerKey` unique, `displayName`, `apiKey`, `baseUrl`, `isActive`, `defaultModel`). It is managed with `GET /api/ai/providers` (key masked, `hasApiKey` flag), `PUT` and `DELETE /api/ai/providers/:providerKey` and `POST /api/ai/providers/test` (runs a small `text_enhancement` call), all requiring `programs.manage`; a write or delete resets the cached OpenAI client, and an empty or masked `apiKey` on `PUT` keeps the stored key. ### Generic AI Extraction Service The extraction service provides a generic interface for extracting structured data from unstructured content (documents, images, free text). It dispatches through the model-provider abstraction: passport and identity-document extraction is routed to the private GCP provider (Document AI), while general text extraction uses the shared provider — consistent configuration across all extraction tasks. ### Text Review Endpoints Text review endpoints allow any Globus module to submit free-text content for AI-assisted quality review. The service checks completeness, grammar, tone, and compliance language without modifying the original text. // Text review request POST /api/integrations/applicant-portal/ai/text-review "text": "...", "context": "camp counselor application essay", "skillName", "fieldName" and "hobbyName" are optional context strings // Response `{ "success": true, "data": { "success": true, "data": { "feedback": "..." } } }` Rate limited to 30 requests per minute; text is limited to 50,000 characters. Requires an API key with the `integrations.portal` scope and an `X-Applicant-User-Id` header. ### Form Component Integration AI capabilities are integrated directly into Globus form components, providing inline assistance during data entry without requiring users to navigate to separate AI tools. Smart field suggestions — AI pre-fills form fields from uploaded documents (e.g., passport scan populates name, nationality, DOB) / Inline text review — free-text fields show real-time quality indicators and improvement suggestions / Document type detection — uploaded files are automatically classified (passport, background check, medical clearance) based on content / Duplicate detection — AI flags potential duplicate applicant records based on biographical similarity scoring > **AI assists — it does not decide.** > > All AI-generated outputs are advisory. No AI capability in Globus makes autonomous decisions that affect applicant lifecycle, compliance status, or placement outcomes. Staff always reviews and confirms AI suggestions before they are applied. This is a deliberate architectural constraint. ## Enforcement Layer The Enforcement Layer is a hard control surface that spans multiple capability layers. It blocks illegal actions at the system level — invisible to users, non-negotiable by portals. Cannot issue DS-2019 if placement is not confirmed / Cannot reserve a job order position beyond quota / Cannot advance a workflow phase if a rule fails / Cannot create an application if the season is closed / Cannot access data outside RBAC scope > **System-Level Guardrail** > > Enforcement is not a user-facing feature. It is a system-level guarantee that prevents non-compliant state transitions regardless of which portal or API triggers the action. ## Event Outbox The Event Outbox is Globus’s event-driven integration mechanism. When authoritative state changes occur, Globus writes structured events to the outbox. External systems (n8n, portals, HubSpot) consume these events to stay synchronized without polling or direct coupling. Notify portals of status changes / Trigger n8n automation workflows / Sync milestone data to HubSpot / Send alerts and compliance reminders ## Audit Logging Every change in Globus produces an immutable audit record. This is critical for sponsor compliance, legal defense, and government audits. Who changed it / Old value / New value / Timestamp + source ## Architecture Checklist Globus provides a purpose-built architecture for J-1 sponsor operations: > **Principle** > > Each layer can evolve independently. Changing how job orders work (Layer 6) doesn’t require rebuilding the application model (Layer 3). This is by design. --- --- title: "Queue System" slug: queue-system section: Globus Core status: stable description: "The operational control center for sponsor teams — entity-agnostic, rule-driven, lifecycle-aware, zero hardcode" last-updated: 2026-10-07 --- # Queue System The operational control center for sponsor teams — entity-agnostic, rule-driven, lifecycle-aware, zero hardcode > **What is the Queue System?** > > The Queue System provides a real-time, actionable view of entities requiring attention. Queues are entity-agnostic — they support applicants, partner operations, job orders, housing assignments, and any future entity type. Queues are derived views — computed from rules engine outputs, workflow step completion, document status, and compliance signals. They are never manually assigned or stored as statuses. The Queue System sits at the intersection of the Rules Engine, Workflow Studio, and Applicant Lifecycle. It transforms raw system state into team-specific workspaces aligned to operational responsibilities across all entity types. ## Core Principles ### Entity-Agnostic Architecture The queue system is built on a unified, entity-agnostic foundation. Every queue definition declares an entity_type that determines what kind of record appears in the queue. The same infrastructure — rules, memberships, SLA tracking, bulk actions, and dashboards — works identically regardless of entity type. > **One system, many entity types** > > Adding a new entity type to the queue system requires only a database configuration change — create new queue_definitions rows with the desired entity_type, attach rules, and the full queue infrastructure (views, counts, SLA, bulk actions, export) activates automatically. ### Lifecycle ≠ Queue | Concept | Definition | Example | | --- | --- | --- | | Lifecycle Phase | Where the entity is in the journey | Phase 3 — Placement (applicant), Review (job order) | | Status / Position | Exact step within a phase | Invited, Submitted, Approved | | Queue | Operational grouping of entities needing action | Ready for SEVIS Pool ✅, Pending Approval 📋 | > **Queues are derived, not stored** > > An entity's queue membership is computed on-the-fly from system state — rules engine outcomes, workflow completion, document verification, and compliance flags. There is no "queue status" field on the entity record. Queues are views, not data. ### Zero Hardcode All queues must be defined via database configuration — never in application code. This ensures: New queues can be added without a code deployment / Queue rules can be modified by admin users / Each brand/program can have different queue configurations / New entity types can be onboarded without code changes / Queue definitions are auditable and version-trackable ### Rule-Driven Readiness Queue membership is powered by five signal sources: ## Queue Types Five categories of queues, each serving a different operational purpose. All categories support any entity type. ### Lifecycle Queues (Primary Navigation) Aligned to lifecycle phases per entity type. Each queue is an entry point — clicking into it reveals the readiness sub-queues. #### Applicant Queues ├── My Work ├── Application ├── Validation ├── Interview ├── Placement ├── Visa & SEVIS ├── Pre-Departure ├── In Program ├── Completion └── Exceptions ⚠️ #### Partner Ops Queues ├── My Work ├── Onboarding ├── Agreement Renewals ├── Host Company Verification ├── Quota Review ├── Compliance └── Exceptions ⚠️ #### Job Order Queues ├── My Work ├── Pending Review ├── Pending Approval ├── Active / Matching └── Exceptions ⚠️ #### Housing Queues ├── My Work ├── Inspection Due ├── Assignment Pending ├── Active Assignments └── Exceptions ⚠️ ### Readiness Queues (Core of the System) > **The most important queue type** > > Readiness queues answer one question: "Who is ready for the next step?" They are nested inside lifecycle queues and powered by the rules engine. Each entity type has its own set of readiness sub-queues. #### Intake & Review #### Placement Pre-showcase Post-showcase Placement readiness #### Visa & SEVIS #### In Program ####  Compliance Review Queue A dedicated readiness queue scoped to compliance officers and super admins. Created using the entity-agnostic queue_definitions model. Items enter this queue when the Rules Engine produces a compliance rule failure whose fail action carries a readiness dimension that routes to it (the dimension is the routing marker; there is no `failure_behavior` field). > **Ready for SEVIS Pool & Ready to be Showcased** > > These two key queues are derived queues — not lifecycle statuses. They are computed by the Rules Engine using structured rule definitions (typed conditions, configurable parameters, compliance gates) based on application approval, document verification, compliance clearance, and profile completeness. Phase 3 readiness is subdivided into pre-showcase, post-showcase, and placement readiness stages. > > **Live evaluator, not the Compliance Rules tile.** The authoritative signal for Ready for SEVIS Pool membership is a live per-applicant blocker evaluator (sevisReadiness.blockers[]), computed fresh on read. The "Compliance Rules" tile shown elsewhere on the applicant record is a separate, point-in-time snapshot and can be stale relative to the live evaluator — treat the live blockers list, not the tile, as ground truth when triaging a specific applicant. The live evaluator only recomputes on specific applicant-lifecycle events (stage transitions, hire) — not on unrelated edits such as a host-record change — and recompute can lag the triggering event by several minutes. > **Interview queues are behind-the-scenes staff/host work** > > The Interview lifecycle queue and the Sponsor Interview / Host Interview readiness sub-queues surface staff- and host-facing work, not an applicant-facing step. A sponsor or host interview is a step-tree node flagged behind-the-scenes by role assignment: it never renders on the applicant stepper and is never sent to the Applicant Portal as an access="hidden" step. It still produces an outcome fact that gates the phase, which is exactly what these queues track and assign. > **Registered, Never Started intake queue** > > A seeded child of the Intake lifecycle queue (slug `intake-never-started`, category `stage`, entity type application) lists applicants registered over a week ago who have not completed a single workflow step, so staff-registered applicants who never open step 1 have their own worklist instead of being mixed into `intake-initial`. Its entry rule is the `never_started` SQL condition. The row is inserted by migration in dev, QA and production, because deployed environments boot with SKIP_SEEDS=true and would otherwise never receive the seed. > **Entered Market stage retired; program-scoped stage names** > > The Entered Market (`market_ready`) lifecycle stage is retired as a user-facing stage: the forward transition from Sponsor Interview and the rollback edge from Reserved to Host were removed, so applicants advance directly from `sponsor_interview` to `reserved_to_host` when a reservation is created (rollback to Sponsor Interview requires a reason and the `stage.transition.rollback_sensitive` permission). Legacy records at `market_ready` are preserved. Separately, the stage catalogue is shared by all programs but a program can carry its own display wording for a stage (for example the Wild Packs programs show `first_document_batch` as "Application Submitted"). The override is naming only: stage keys, transitions, queue membership and rule bindings are untouched, and a program with no override falls back to the shared catalogue name. ### Exception Queues (Critical) High-visibility, prioritized queues for entities requiring immediate attention. Exception queues support a sub-category filter strip — a horizontal pill bar at the top of the exception view that lets staff filter by exception sub-category (e.g., "Documents", "Compliance", "SEVIS", "Placement") without leaving the exception queue. | Exception Queue | Trigger | Priority | Auto-Action | | --- | --- | --- | --- | | Missing Documents | Required documents not uploaded by deadline | 🟠 Warning | Notification to applicant + partner | | Needs Manual Review | Rules engine flags ambiguous eligibility | 🟠 Warning | Routed to review team queue | | Compliance Hold | Failed compliance check (background, medical) | 🔴 Critical | Block placement, notify compliance team | | SEVIS Check-In Overdue | 4+ days since arrival, no SEVIS check-in (configurable: sevis_checkin_due_days = 3) | 🟠 Warning | Reminder to applicant + SEVIS team notification | | SEVIS Risk Critical (Day 20) | 20+ days since arrival, no SEVIS check-in (configurable: sevis_critical_escalation_day = 20) | 🔴 Critical | Urgent escalation to SEVIS team + program manager + compliance lead | | Missed Monthly Check-ins | 2 consecutive check-ins missed | 🔴 Critical | Create support ticket, compliance queue | | Address Mismatch | Monthly check-in reports address change ("No") | 🟠 Warning | Flag for follow-up, update records | | Second Job Declared | Participant reports second job in check-in | 🟠 Warning | Compliance review, program rule check | | Re-placement Required | Placement cancelled or host withdrew | 🔴 Critical | Return to placement queue, notify partner | | Partner Suspension Impact | Partner suspended with active applicants | 🔴 Critical | Reassign applicants, freeze nominations | #### Sub-Category Filter Strip Horizontal pill bar at the top of the exception queue view: All / Documents / Compliance / SEVIS / Placement / Check-ins / Partner ### Exception Queue Definitions *Status: the table below is the original design. Exception queues are queue_definitions rows with category exception under the parent slug exceptions (server/seed/queueDefinitions.exceptions.ts). queue_definitions has no sub_category or brand_scope columns (it has brand_id, program_id, parent_queue_id, display_family and owner), and entity_type defaults to application. The seeded set differs, for example Document Expired, SEVIS Overdue, No-Show, Compliance Failed and the Issues queues.* All 10 exception queues are stored as queue_definitions records with category: exception and entity_type: applicant. Each definition includes priority level, icon, sub-category, and optional brand/program scoping. | name | icon | color | priority | sub_category | brand_scope | | --- | --- | --- | --- | --- | --- | | Missing Documents | file-warning | amber | 🟠 Warning | Documents | null (all) | | Needs Manual Review | eye | amber | 🟠 Warning | Compliance | null (all) | | Compliance Hold | shield-alert | red | 🔴 Critical | Compliance | null (all) | | SEVIS Check-In Overdue | clock-alert | amber | 🟠 Warning | SEVIS | null (all) | | SEVIS Risk Critical (Day 20) | alert-triangle | red | 🔴 Critical | SEVIS | null (all) | | Missed Monthly Check-ins | calendar-x | red | 🔴 Critical | Check-ins | null (all) | | Address Mismatch | map-pin-off | amber | 🟠 Warning | Check-ins | null (all) | | Second Job Declared | briefcase-business | amber | 🟠 Warning | Compliance | null (all) | | Re-placement Required | rotate-ccw | red | 🔴 Critical | Placement | null (all) | | Partner Suspension Impact | building-2 | red | 🔴 Critical | Partner | null (all) | ### Exception Queue Rules *Status: the table below is the original design. queue_rules columns are queue_definition_id, rule_type, conditions, logical_operator, group_index, sort_order, is_entry and is_active, and each seeded exception queue carries a queue_definitions.rule_config with a type such as sevis_checkin_overdue rather than SQL condition strings.* Each exception queue has a corresponding queue_rules entry defining its trigger condition as a JSONB rule_config. The rules engine evaluates these against live entity state. | Exception Queue | rule_type | rule_config (trigger condition) | | --- | --- | --- | | Missing Documents | sql_condition | "condition": "required_documents_pending > 0 AND document_deadline < NOW()" | | Needs Manual Review | rules_engine | "rule_outcome": "needs_review", "eligibility_result": "ambiguous" | | Compliance Hold | rules_engine | "compliance_check": "failed", "check_types": ["background", "medical"] | | SEVIS Check-In Overdue | sql_condition | "condition": "days_since_arrival > sevis_checkin_due_days AND sevis_checkin_status = 'not_completed'" | | SEVIS Risk Critical | sql_condition | "condition": "days_since_arrival >= sevis_critical_escalation_day AND sevis_checkin_status = 'not_completed'" | | Missed Monthly Check-ins | sql_condition | "condition": "consecutive_missed_checkins >= 2" | | Address Mismatch | event_trigger | "event": "checkin_submitted", "field": "address_confirmed", "value": false | | Second Job Declared | event_trigger | "event": "checkin_submitted", "field": "second_job_declared", "value": true | | Re-placement Required | event_trigger | "events": ["placement_cancelled", "host_withdrew"] | | Partner Suspension | event_trigger | "event": "partner_suspended", "has_active_applicants": true | ### Escalation & Auto-Routing Exception queues support enhanced escalation rules with auto-routing, manual override, and configurable escalation chains. When an entity enters an exception queue, the system evaluates escalation rules and can automatically route to the appropriate team or individual. #### Auto-Routing When an entity enters an exception queue, it is automatically routed to the team or individual defined in the escalation rule. Routing considers team load balancing, skill matching, and availability. #### Manual Override Staff with appropriate permissions can manually override auto-routing to reassign exception items to a different team or individual. All overrides are audit-logged. #### Escalation Chains Multi-level escalation: if the initial assignee does not act within the SLA window, the item escalates to the next level (team lead, then department head, then operations manager). ### SEVIS 4-Tier Progression #### sevis-readiness-blocked Queue > **sevis-readiness-blocked workbench queue** > > Applicants who fail the global `ready_for_sevis` 11-rule gate — or a program add-on gate such as CAMP-001 / CAMP-002 / CAMP-003 — route to the sevis-readiness-blocked workbench queue. The gate is **fail-closed**: an applicant stays in this queue until every failing rule passes (or is overridden). See the SEVIS Readiness page for the full gate architecture and rule list. > > **Seeded reason codes and SLA.** The queue-definition seed step also creates five global (brand-independent) reason codes for this queue, each with severity hint `blocking` and owner role `compliance`: `SEVIS_PASSPORT_NOT_UPLOADED` (passport document absent or rejected/archived), `PASSPORT_VALIDITY_INSUFFICIENT` (the `passport_validity` compliance rule is failing or pending), `AGE_MIN_NOT_MET` (the `age_min` rule is failing), `MINIMUM_AGE_NOT_MET` (the program-specific minimum-age variant) and `SEVIS_PREREQUISITES_NOT_MET` (catch-all). They are display metadata; no new document checks are introduced, and a code that already exists globally is skipped on re-seed. The same seed adds an SLA policy of 480 business minutes (one business day, 09:00 to 17:00) with a severity-upgrade escalation rule and no auto-lapse, so the block persists until the readiness data changes. ### Auto-Actions (Dual Trigger) Each exception queue can fire two independent action triggers — on queue entry and on SLA breach: | Exception Queue | On Queue Entry | On SLA Breach | | --- | --- | --- | | Missing Documents | notify (applicant + partner) | add_flag + notify (team lead) | | Needs Manual Review | reassign (review team queue) | notify (team lead) + create_ticket | | Compliance Hold | block_placement + notify (compliance team) | dashboard_alert + create_ticket + notify (operations lead) | | SEVIS Check-In Overdue | notify (applicant + SEVIS team) | reassign (SEVIS escalation queue) | | SEVIS Risk Critical (Day 20) | notify (SEVIS team + PM + compliance lead) + add_flag | dashboard_alert + create_ticket + reassign | | Missed Monthly Check-ins | create_ticket + add_flag | notify (compliance lead) + dashboard_alert | | Address Mismatch | notify (assigned staff) | notify (team lead) | | Second Job Declared | notify (compliance team) | add_flag + create_ticket | | Re-placement Required | reassign (placement queue) + notify (partner) | dashboard_alert + notify (operations lead) | | Partner Suspension Impact | reassign (all applicants) + add_flag | dashboard_alert + create_ticket | > **Event Architecture** > > Queue-entry actions are triggered by the queue.exception.entered event, emitted by the queue membership resolver when an entity is newly added to an exception queue. SLA-breach actions are triggered by the existing escalation_rules infrastructure when a queue_sla_events timer transitions to breached state. Both mechanisms are independent and can be configured separately per exception queue. For the full notification catalog, channel routing, and escalation chains, see the Notification & Communication Model . ### Team Queues (RBAC-Driven) Queues filtered by who owns the work. Driven by RBAC role assignments and program scope. Team queues apply across all entity types. Compliance Team / Placement Team / SEVIS Team / Operations Team / Partner Team Team queue membership is determined by assigned_team, assigned_role, and program_scope from the RBAC system. ### My Work Queue (Personal) A personalized view showing only what the logged-in user needs to act on. The My Work queue includes instant count refresh — counts update in real-time as items are assigned, completed, or escalated without requiring a page reload. Entities assigned to this user across all entity types / SLA-overdue items requiring immediate attention / High-priority flags from exception queues / Recently touched items (last 7 days) / Grouped by entity type with collapsible sections > **Instant Count Refresh** > > The My Work queue sidebar count badge refreshes by polling (React Query refetchInterval of 30 seconds); there is no WebSocket push channel for queue counts. When a queue membership changes, the badge catches up on the next poll or on refetch after a mutation. ## Queue Features The queue system includes a comprehensive set of operational features that work identically across all entity types. ### Bulk Actions Multi-select rows in any queue view to perform batch operations. Select individual items via checkbox or use "Select All" to select all visible (or all matching filter) items. | Action | Description | Example | | --- | --- | --- | | Batch Assign | Assign selected items to a staff member or team | Select 15 applicants → Assign to SEVIS Team | | Batch Transition | Move selected items to a different queue or status | Select 8 job orders → Transition to Approved | | Batch Reassign | Reassign selected items from one owner to another | Reassign 12 items from User A → User B | | Batch Unassign | Remove assignment from selected items | Unassign 5 items back to team pool | All bulk actions are audit-logged with the acting user, timestamp, and affected entity IDs. Bulk actions respect RBAC — users can only perform actions they have permission for. **Queue and review mutation routes (BackOfficePrototype).** The handlers, not the mutation registry, define the gates. `POST /api/queues/bulk-approve` and `POST /api/queues/bulk-move` take `applicationIds` (at most 100; more returns `400 BAD_REQUEST`, and no matching application returns `404 NOT_FOUND`) and drop ids outside the caller's RBAC scope, reporting the count as `skippedByScope`; when none are in scope the response is `403 FORBIDDEN`. Bulk approve sets `readinessState` to `on_track` and adds an internal note per application (response `approved`, `skippedByScope`). Bulk move takes `targetQueueSlug` (an active queue, otherwise `404 NOT_FOUND`) and an optional `sourceQueueSlug` that must differ from the target, and returns `moved`, `skippedAlreadyInTarget` and `skippedByScope`. `POST /api/queues/inline/change-readiness` takes `applicationId`, a `readinessState` of `on_track`, `needs_attention`, `blocked` or `paused`, and a required `note`; `POST /api/queues/inline/escalate` takes `applicationId` with an optional `note` and `assignToUserId`, sets `needs_attention`, and assigns through the canonical assignment service when the target user is active. These four routes require a session and `applicant.manage`, and errors use `{ error: { code, message, details, traceId } }`. `POST /api/work-items/:id/schedule` (API key scope `applications.write`, permission `applications.manage`) requires `scheduledAt`, sets the item to `scheduled`, and optionally sets `assignedToUserId` and `notes`. `POST /api/review/items/:source/:id/decision` (API key scope `dashboard.read`, permission `document.verify`) records `approved`, `rejected` or `resubmission_requested` on the applicant document `:id` (the handler does not read `:source`) with an optional `reason`. It returns `400` for a missing or invalid action, `404` for a document that is missing or outside the caller's scope, `403` when the document type's reviewer permission or assignment is missing, and `409` with the top-level `code` `INVALID_STATE_TRANSITION` when the state change is not allowed. ### CSV / Excel Export *Status: not built for queue item views. There is no export route under /api/queues and no export action in the queues page; the CSV and Excel sections below are a design spec. The only queue-related export is the aggregate Queue Aging & SLA report behind GET /api/reports/queue-metrics, which carries no participant rows.* Export the current queue view (with active filters applied) as a CSV or Excel file. Export is available from any queue view via the export button in the toolbar. #### CSV Export All visible columns included / Active filters applied / Supports up to 10,000 rows / UTF-8 encoding with BOM for Excel compatibility #### Excel Export Formatted headers and column widths / Color-coded priority and status columns / Separate sheet per entity type (if mixed) / Timestamp in filename for versioning ### Inline Row Actions Each row in a queue table includes a contextual action menu (three-dot icon or hover-reveal buttons) that allows staff to take action on a single item without navigating away from the queue view. Per-item workbench actions are gated by four permission keys. `queue.item.manage` covers `POST /api/queues/:slug/items/:applicationId/priority` (set priority without changing status). `queue.item.resolve` covers `.../resolve` (terminal; `overridden: true` records `resolutionType` overridden) and `.../pass` (resolves the item, marks its open reasons manually resolved and advances the applicant). `queue.item.dismiss` covers `.../dismiss` (terminal, `resolutionType` dismissed). Resolve and dismiss require a `note` of 1 to 2000 characters. `queue.item.override` is seeded as restricted with a required reason (resolve a blocked item through an eligibility override), but no route handler currently checks it: an override resolve only needs `queue.item.resolve`. An item that is missing, outside the caller's scope or not a queue member answers `404` `QUEUE_ITEM_NOT_FOUND`. ### Facet Filtering Queue views support multi-dimensional facet filtering. Facets update available options and counts dynamically as filters are applied (progressive disclosure). | Facet | Type | Applies To | | --- | --- | --- | | Entity Type | Single select | All views (when mixed entity queues) | | Brand | Multi-select | All entity types | | Program | Multi-select (filtered by brand) | Applicant, Job Order | | Season | Multi-select (filtered by program) | Applicant, Job Order | | Assigned To | Multi-select (user picker) | All entity types | | Priority | Multi-select (Critical, Warning, Normal) | Exception queues | | SLA Status | Multi-select (On Track, Warning, Breached) | All queues with SLA | | Date Range | Date picker (entered queue) | All entity types | | Partner | Multi-select | Applicant, Partner Ops | | Host | Multi-select | Job Order, Housing | On the Queues page, the worklist's search, priority, reason code, action-required, unassigned-only and SLA-breached-only filters, its activity sort direction and its page number are bound to the URL query parameters `search`, `priority`, `reasonCode`, `actionRequired`, `unassigned`, `slaBreached`, `sortDir` and `page`. The worklist renders Ops advanced applicant filters, an assignee filter and a classification filter at its top, and the Queues page shows applicant filter chips at its top. When the worklist query returns an error (`isError`), the worklist shows an alert reading "This worklist could not be loaded. Please retry; this is not an empty search result." instead of the empty state. In the split detail panel, when `position` is null the panel shows a status message reading "This application is not in the current results. The selected profile remains open; select a row to resume queue navigation." The worklist derives the open application's row position from its identity on every refresh, and does not treat a different application of the same person as the selected row. ### Reassignment & Unassignment Queue items can be reassigned to a different staff member or unassigned back to the team pool. Both operations are available as inline row actions or bulk actions. #### Reassignment User picker shows team members with current load counts / Reassignment preserves SLA timer (does not reset) / Previous assignee receives notification of reassignment / New assignee receives notification with context / Audit log records from/to/reason/timestamp #### Unassignment Returns item to the team pool for claim / SLA timer continues running (not paused) / Item visible in team queue, not personal queues / Optional reason field for context / Auto-routing may re-assign based on rules #### Bulk Assign from Unassigned Work Queues whose rule configuration type is `unassigned_work` support a safe bulk claim through `POST /api/queues/:slug/items/bulk-assign` (`applicant.manage`, API key scope `applications.write`). The body takes `applicationIds` (1 to 100) and an optional `targetUserId` that defaults to the caller, so the page selection can be assigned to me or to an eligible staff member. Other queues return 400 `BULK_ASSIGN_NOT_SUPPORTED`. The target must be an active, non-superadmin user holding an assignable role, otherwise 400 `INVALID_TARGET`. Queue membership and full Ops scope are re-checked at mutation time for every selected application. Each applicant is then claimed with an atomic compare-and-set on the assigned staff field (only while still unassigned), which keeps the application owner, assignment history and audit trail in step, so a concurrent claim is never overwritten. Applicants claimed in the meantime are returned as skipped rather than failing the batch. The response carries the `assigned` and `skipped` counts plus `assignedApplicantIds` and `skippedApplicantIds` (counted per applicant, not per application), and one `queue.unassigned.bulk_assigned` audit entry is written for the batch. **Queue list endpoints.** `GET /api/queues/:slug/entities` (`dashboard.view`; `limit` 1 to 100, default 25, `offset`, `search`) lists the rows of an entity-backed queue (host verification, job orders, site visits, housing leads) and falls back to the applicant list for applicant-backed queues. For entity-backed queues it also accepts optional UUID `programId` and `hostOrgId` filters, applied inside the caller's scope, and the response includes `scopeEmpty` when the caller holds the permission but has no brand or program grant. `GET /api/queues/:slug/items` (`dashboard.view`) is the operational worklist: filters `programId`, `seasonId`, `search`, `assignedToUserId`, `ruleId` (or `reason.rule_id`), `priority` (comma-separated), `reasonCode`, `sourceType`, `actionRequired`, `unassigned`, `slaBreached`, `sortBy`, `sortDir`, `page` and `pageSize` (at most 200); it returns `items`, `total`, `summary`, reason `groups` and `facets`, and strips note text for callers without `applicant.profile.view`. An unknown queue slug is not an error on these two reads, which return an empty result; the queue mutation routes answer 404 `QUEUE_NOT_FOUND` instead. The queue-item mutation routes (`resolve`, `dismiss`, `priority` and `pass` under `/api/queues/:slug/items/:applicationId/`, and `bulk-assign`) authorize each application id against the caller's full Ops visibility scope and the queue's membership predicate before any state, audit or event write. A missing id, an id outside the caller's scope and an id that is not a current member of the queue all return the same `404` with the code `QUEUE_ITEM_NOT_FOUND` and the message `Queue item not found in this queue`, so the response never reveals whether the application exists. When a request carries more than one id (bulk assign), the whole batch is rejected if any id is denied, and `details.deniedApplicationIds` echoes the submitted ids that were denied; single-item requests carry `details` null. `GET /api/swt/applications/:applicationId/review` (permission `compliance.overrides.review`) returns the same denial for a `queue` value that is not an SWT review queue or for a non-member application. ### Instant Count Refresh Queue sidebar counts and badge indicators refresh by polling (30-second React Query refetchInterval) and after queue mutations. There is no WebSocket push channel for queue counts. ## Data Model > **Zero Hardcode — Database-Driven Configuration** > > All queue definitions, rules, and display properties are stored in the database. Application code reads configuration — it never defines queues. This allows admin users to create, modify, and deactivate queues without code changes. The entity_type field on queue_definitions and queue_memberships enables a unified data source approach across all entity types. ### queue_definitions | Column | Type | Description | | --- | --- | --- | | id | UUID | Primary key | | name | VARCHAR(100) | Display name (e.g., "Ready for SEVIS Pool") | | entity_type | VARCHAR(50) | Entity type this queue applies to: applicant, partner, job_order, housing | | category | ENUM | lifecycle \| readiness \| exception \| team | | sub_category | VARCHAR(50) (nullable) | Sub-category for exception filter strip (e.g., Documents, Compliance, SEVIS) | | parent_queue_id | UUID (nullable) | Parent queue for nesting (e.g., readiness queue under lifecycle queue) | | icon | VARCHAR(50) | Icon identifier for UI rendering | | color | VARCHAR(20) | Color code for visual differentiation | | sort_order | INTEGER | Display order within category | | is_active | BOOLEAN | Soft delete / disable flag | | brand_scope | VARCHAR[] (nullable) | Restrict queue to specific brands (null = all brands) | | program_scope | VARCHAR[] (nullable) | Restrict queue to specific programs (null = all programs) | ### queue_rules | Column | Type | Description | | --- | --- | --- | | id | UUID | Primary key | | queue_id | UUID (FK) | Reference to queue_definitions | | rule_type | ENUM | rules_engine \| sql_condition \| event_trigger | | rule_config | JSONB | Rule configuration (conditions, thresholds, field checks) | | priority | INTEGER | Rule evaluation order (lower = first) | | is_active | BOOLEAN | Enable/disable individual rules | ### queue_memberships *Status: there is no queue_memberships table; the table below is the original design. Membership history is stored in queue_transitions (entity_type, entity_id, application_id, queue_definition_id, entered_at, exited_at, exit_reason), see Queue Transition History below.* | Column | Type | Description | | --- | --- | --- | | id | UUID | Primary key | | entity_type | VARCHAR(50) | Entity type: applicant, partner, job_order, housing | | entity_id | UUID (FK) | Reference to the entity record (polymorphic via entity_type) | | queue_id | UUID (FK) | Reference to queue_definitions | | assigned_to | UUID (FK, nullable) | Staff member currently assigned to this item | | entered_at | TIMESTAMP | When the entity entered this queue | | exited_at | TIMESTAMP (nullable) | When the entity left this queue (null = still active) | | computed_at | TIMESTAMP | When membership was last computed / refreshed | > **Unified Data Source** > > The entity_type + entity_id pair in queue_memberships creates a polymorphic reference. The queue system resolves the correct entity table at query time based on entity_type. This enables a single membership table to serve all entity types while maintaining referential integrity through application-level validation. ### Queue Transition History Every queue entry and exit is tracked with timestamps, enabling full audit trails and operational analytics. The transition history powers throughput metrics, bottleneck detection, and compliance reporting. | Field | Description | | --- | --- | | entered_at | Timestamp when the entity entered the queue (set on membership creation) | | exited_at | Timestamp when the entity left the queue (set on exit, null while active) | | duration | Computed: exited_at - entered_at (total time in queue including pauses) | | active_duration | Computed: total time minus SLA pause periods (actual working time) | | exit_reason | Why the entity left: completed, reassigned, escalated, cancelled, expired | | transitioned_by | User who triggered the exit (null for automated transitions) | #### Transition History Use Cases Audit & Compliance Full trail of every queue an entity passed through, with timestamps and actors Performance Analytics Average time-in-queue per queue, per team, per entity type Bottleneck Detection Identify queues where entities spend disproportionately long ## Queue Logic Examples Each queue's rule_config is a JSONB document describing the conditions an entity must meet. The rules engine evaluates these conditions against live system state. Examples span multiple entity types. Applicant #### Ready for SEVIS Pool entity_type / applicant / application_status / approved / placement_status / placed / documents / compliance Applicant #### Ready to be Showcased entity_type / applicant / application_status / approved / profile_complete / skills_completed / showcase_video / documents_verified Job Order #### Ready for Matching entity_type / job_order / status / approved / host_compliance / cleared / positions_available / > 0 / season_active Partner Ops #### Agreement Expiring entity_type / partner / agreement_end_date / < NOW() + 30 days / renewal_status / not_started / has_active_applicants Housing #### Inspection Due entity_type / housing / last_inspection_date / < NOW() - 365 days / has_active_assignments / inspection_status / not_scheduled Applicant #### SEVIS Risk Critical (Day 20+) entity_type / applicant / days_since_arrival / >= sevis_critical_escalation_day / sevis_checkin_status / not_completed Triggers: Urgent escalation — SEVIS team + program manager + compliance lead ## UI / UX Design The queue interface follows a master-detail layout consistent with the rest of Globus. The entity type selector at the top of the sidebar switches between entity-specific queue trees. #### Left Sidebar Entity type selector (Applicant, Partner, Job Order, Housing) / Queue categories (collapsible) / Icons + live counts per queue (instant refresh) / Active queue highlighted / Badge indicators for critical items #### Main Panel Table view (sortable columns) / Facet filters (program, season, partner, status, SLA) / Bulk assignment toolbar / Inline row actions (three-dot menu) / Multi-select checkboxes with Select All Table columns: | Column | Description | Sortable | | --- | --- | --- | | Entity Name | Full name / title with link to profile | Yes | | Entity Type | Applicant, Partner, Job Order, Housing | Yes | | Program / Season | Active program and season (where applicable); applicant worklist rows also render a participation classification display | Yes | | Phase | Current lifecycle phase | Yes | | Current Position | Exact step within the phase | Yes | | Queue Tags | Colored chips showing all active queue memberships | No | | Flags | Warning/critical indicators from exception queues | Yes (by priority) | | SLA Status | Timer indicator with time remaining or breach duration | Yes | | Last Activity | Timestamp of most recent state change | Yes | | Assigned To | Staff member or team responsible | Yes | The Queues page worklist table has separate Program and Placement columns and a Screening column. ### Queue Tags & Priority Each entity can appear in multiple queues simultaneously. Queue tags are displayed as colored chips: Example applicant tags: Ready for SEVIS Compliance OK Placement Complete Priority indicators: ## Queue Dashboard The queue dashboard provides operational leaders with a bird's-eye view of all queue activity across entity types. It surfaces pipeline health, bottlenecks, throughput, and workload distribution. ### Pipeline Visualization A horizontal pipeline strip shows queue stages with entity counts at each stage. Stages with high counts or breached SLAs are highlighted to draw attention. The pipeline adapts to the selected entity type. Example: Applicant Pipeline ### Bottleneck Detection The dashboard automatically identifies bottlenecks — queues where entities are accumulating faster than they are being processed. The Top Bottlenecks widget (GET /api/ops/top-bottlenecks) ranks unresolved queue reasons grouped by rule_id. Each bucket scores the sum of severity weight (blocker 3, warning 2, info 1) times an aging factor (1 plus days since the reason was generated divided by 7), and at most 6 buckets are shown. The percentile, SLA-overrun, inflow/outflow and breach-rate alert triggers described in the original design are not implemented as automatic alerts. Bottleneck Alert Example Bottleneck Detected: Placement Queue 67 entities waiting (avg: 28) · Avg time in queue: 4.2 days (SLA: 3 days) · 23% breach rate ### Throughput Metrics Throughput metrics track the rate at which entities move through queues over time. These metrics are powered by the queue transition history (entered_at / exited_at timestamps). `GET /api/admin/queue-definitions/:slug/metrics` (API key scope `dashboard.read`, permission `queues.manage`) returns `{ open, averageAgeDays, breaches }` for one queue, computed in a single aggregate pass over the caller's scoped membership; the optional `programId` and `seasonId` query parameters narrow it. `GET /api/applications/:id/current-queues` (API key scope `stages.read`; any of `applications.view`, `applications.edit`, `applicant.profile.view` or `applicants.list.view`) lists every active queue the application currently appears in, evaluated with the same membership predicates as the Queues page rather than read from `queue_transitions`; the applicant profile Current Queue rail uses it. The queue-item routes that resolve a queue by slug (bulk assign, resolve, dismiss, priority and pass) answer an unknown slug with `404` and the code `QUEUE_NOT_FOUND` in the envelope `{ error: { code, message, details, traceId } }`. ### Workload Distribution The workload view shows how queue items are distributed across team members. It helps managers identify overloaded staff and rebalance assignments. | Staff Member | Assigned | Breached | Completed Today | Load Status | | --- | --- | --- | --- | --- | | Sarah K. | 24 | 1 | 8 | 🟢 Normal | | Mike R. | 38 | 4 | 3 | 🔴 Overloaded | | Jane L. | 15 | 0 | 12 | 🟢 Normal | | Unassigned | 22 | 3 | 0 | ⚠️ Pool | ## Event-Driven Updates Queues update automatically — no manual refresh required. The following events trigger queue recomputation across all entity types: | Event Source | Events | Queue Impact | | --- | --- | --- | | Application Steps | step_completed, step_reset | Readiness queue recalculation | | Document System | document_uploaded, document_verified, document_rejected | Document-dependent queues + exception queues | | Rules Engine | eligibility_evaluated, compliance_checked | All readiness + exception queues | | SEVIS | sevis_checkin_recorded, ds2019_issued, sevis_status_changed | Visa & SEVIS queues + SEVIS exception queues | | Monthly Check-in | checkin_submitted, checkin_overdue | In Program queues + missed check-in exceptions | | Placement | placement_confirmed, placement_cancelled, reservation_expired | Placement queues + re-placement exception | | Partner | partner_suspended, partner_reactivated, agreement_expiring | Partner ops queues + partner suspension impact | | Job Order | job_order_submitted, job_order_approved, job_order_closed | Job order queues + matching readiness | | Housing | inspection_scheduled, inspection_completed, assignment_changed | Housing queues + housing exception queues | ## Integration Points #### Applicant Portal Portal actions drive queue updates: Step completion → readiness recalc / Document uploads → document queues / Check-in submission → compliance queues / Travel requests → on-program queues #### SEVIS SEVIS events update compliance queues: Check-in status → SEVIS overdue queue (Day 4+) or critical queue (Day 20+) / DS-2019 issuance → visa queue progression / Status changes → compliance flags / Arrival tracking → 4-tier SEVIS compliance (Arrival → Standard Days 0–3 → Overdue Day 4+ → Critical Day 20+) Deadline-driven exception reconciliation (document expiry, SEVIS check-in regulatory deadlines) is persisted in the durable `queue_deadline_jobs` table rather than in-process timers, so pending deadlines survive restarts and there is no scheduling horizon beyond which a deadline is dropped. Jobs are unique per (application, reason, due time); a polling worker running inside the SLA recalculation worker's advisory-locked cycle claims due rows, re-runs exception-queue reconciliation, and parks a job as `failed` after 5 attempts. A journey transition into a terminal position (withdrawn, rejected, completed, cancelled, alumni) that leaves the application in the SEVIS Ready Pool now revokes it from the pool (audit log and `sevis_readiness.revoked` event, no readiness evaluation). #### n8n / Workers Automation triggers from queue state: Notifications from queue transitions / Queue count-based alerts / Support ticket creation / Scheduled queue snapshots ## Queue-Powered Reporting Queue counts, throughput metrics, and transition history power the executive and operational dashboards: Ready for SEVIS / Ready for Placement / Compliance Risk Count / Check-in Completion Rate / Exception Trends / Pipeline Throughput / SLA Compliance Rate / Bottleneck Alerts / Workload Balance / Entity Type Breakdown ## SLA Management > **Queues show work. SLA shows urgency.** > > Without SLAs, queues are passive lists. With SLAs, they become real management tools — measurable service standards, operational accountability, escalation paths, and compliance-critical deadline tracking. SLA is a separate configuration layer on top of queues. ### Design Principle SLA rules are not embedded in queue logic. They are a separate configuration layer: #### Queue Responsibility Determines who belongs in the queue (membership rules) #### SLA Responsibility Determines how quickly that queue must be worked (time targets) This separation is critical because the same queue may later have different SLA rules by brand, program, season, priority, entity type, or visa type. A single queue definition supports multiple SLA policies with different scopes. ### Policy Configuration Super Admin configures SLA policies at Admin → Queue Configuration → SLA Policies. Each policy defines timing, behavior, and escalation for a specific queue and scope. The policy form includes timezone selection and a scope picker that allows narrowing the policy to a specific brand, program, season, or entity type. | Setting | Description | Example | | --- | --- | --- | | Response Target | Time to first action on the queue item | 4 business hours | | Resolution Target | Time to fully resolve / move entity out of queue | 2 business days | | Working Hours | Business hours vs 24/7 calendar | Mon–Fri 8am–6pm EST | | Timezone | Timezone for business hours calculation | America/New_York | | Scope Picker | Narrow policy to brand, program, season, or entity type | Brand: Alliance Abroad, Program: Camp | | Warning Threshold | % of SLA elapsed before warning triggers | 80% (amber highlight) | | Breach Threshold | Grace period after SLA expires before escalation | 30 minutes | | Escalation Rule | What happens when SLA breaches | Notify team lead + create ticket | | Pause Conditions | Events that pause the SLA timer | Waiting on applicant document upload | | Priority Override | Different SLA for different priority levels | Critical = 2hr, Normal = 2 days | Example SLA policies: | Queue | Response SLA | Resolution SLA | Hours | | --- | --- | --- | --- | | Ready for SEVIS Pool | — | 2 business days | Business | | SEVIS Check-In Overdue (Day 4+) | 4 hours | 1 business day | Business | | SEVIS Risk Critical (Day 20+) | 1 hour | 4 hours | 24/7 | | Ready to be Showcased | — | 3 business days | Business | | Missing Documents | 4 hours | 1 business day | Business | | Compliance Hold | 2 hours | 8 business hours | Business | | Missed Monthly Check-ins | 4 hours | 1 business day | 24/7 | ### Scope Controls SLA policies can be scoped at multiple levels. More specific scopes override broader ones. Scope override example: Default: All Compliance Hold queues = 8 business hours globally Override: Camp Counselor SEVIS queues = 4 business hours in peak season (Jun–Aug) ### Timer Behavior SLA timers attach to the queue membership event, not the entity. One entity may move through many queues, each with its own independent timer. Timers respect the configured timezone and business hours calendar. | Timer Event | When It Happens | Example | | --- | --- | --- | | Starts | Entity enters queue | Applicant joins Ready for SEVIS Pool → 2-day timer starts | | Pauses | Waiting on external dependency | Waiting on applicant document upload → timer frozen | | Resumes | External dependency resolved | Document arrives → timer resumes from where it paused | | Stops | Entity leaves queue or marked complete | SEVIS processed → timer ends as Completed | | Warns | Warning threshold reached (e.g., 80% elapsed) | 1 day 14hr of 2-day SLA → amber warning badge | | Breaches | SLA target exceeded (+ grace period) | 2 days + 30min grace → red breach indicator + escalation | Timer statuses visible on every queue item: ### Pipeline Strip Indicators The pipeline strip on the queue dashboard shows SLA health per pipeline stage. Each stage displays a color-coded bar indicating the proportion of items that are on-track, warning, or breached. Pipeline SLA Health Strip On Track Warning Breached ### SLA Data Model Three tables support the SLA system — policies, live timer events, and escalation rules. #### queue_sla_policies | Column | Type | Description | | --- | --- | --- | | id | UUID | Primary key | | name | VARCHAR(100) | Policy name (e.g., "SEVIS Pool — Standard") | | queue_definition_id | UUID (FK) | Reference to queue_definitions | | first_response_minutes | INTEGER (nullable) | Target time for first staff response (null = track resolution only) | | resolution_minutes | INTEGER | Target time to fully resolve / exit queue | | use_business_hours | BOOLEAN | true = business hours only (default); false = 24/7 calendar | | business_hours_start / business_hours_end | INTEGER | Business-day start and end hour (defaults 9 and 17) | | business_hours_timezone | VARCHAR(64) | IANA timezone for business hours (default America/New_York) | | include_weekends | BOOLEAN | When true, weekends count as working days (default false) | | warning_percent | INTEGER | % of SLA elapsed before warning (default: 75) | | breach_grace_minutes | INTEGER | Grace period after SLA expires before escalation | | pause_conditions_json | JSONB | Events that pause the timer (e.g., awaiting document) | | holiday_country / holiday_region / holiday_calendar_json, capacity_target / capacity_warning_threshold / capacity_critical_threshold / stop_assign_when_critical, reminder_ladder_json, health_score_config | various | Holiday calendar, queue capacity, reminder ladder and health-score configuration | | priority | INTEGER | For scope conflict resolution (higher = wins) | | is_active | BOOLEAN | Enable/disable without deleting | | created_by | UUID (FK) | Admin who created the policy | #### queue_sla_events | Column | Type | Description | | --- | --- | --- | | id | UUID | Primary key | | application_id | UUID (FK) | Reference to applications | | queue_definition_id | UUID (FK) | Reference to queue_definitions | | sla_policy_id | UUID (FK) | Reference to queue_sla_policies | | started_at | TIMESTAMP | When entity entered the queue | | paused_at | TIMESTAMP (nullable) | When timer was last paused | | resumed_at | TIMESTAMP (nullable) | When timer was last resumed | | completed_at | TIMESTAMP (nullable) | When entity left queue or was resolved | | breached_at | TIMESTAMP (nullable) | When SLA was breached | | current_state | ENUM | on_track \| warning \| breached \| paused \| completed | | elapsed_minutes | INTEGER | Total active time elapsed (excluding pauses) | | remaining_minutes | INTEGER | Time remaining before SLA target | #### escalation_rules | Column | Type | Description | | --- | --- | --- | | id | UUID | Primary key | | name | VARCHAR(100) | Rule name (e.g., "Compliance Escalation") | | escalation_type | ENUM | notify \| reassign \| create_ticket \| add_flag \| dashboard_alert | | escalation_level | INTEGER | Level in the escalation chain (1 = first responder, 2 = team lead, 3 = manager) | | auto_route_config | JSONB | Auto-routing configuration (target team, load balancing, skill matching) | | config_json | JSONB | Type-specific configuration (who to notify, which queue to reassign to, etc.) | | is_active | BOOLEAN | Enable/disable the rule | Two further tables support escalation. `escalation_tickets` holds the ticket created by a `create_ticket` escalation rule when an SLA breaches: it links the SLA event, application, policy, escalation rule and queue definition, snapshots the policy and queue names, and records `breached_at`, `elapsed_minutes`, `resolution_minutes`, an optional `assigned_to_user_id`, a `status` (default `open`) and the resolution (`resolved_at`, `resolved_by_user_id`, `resolution_note`). `escalation_executions` is the atomic dedupe lock: one row per (`sla_event_id`, `escalation_rule_id`), enforced by a unique index, with the `escalation_type` and `executed_at`, so a rule runs at most once for a given SLA event; if its action fails, the claim is released so it can be retried. ### Escalation Rules When an SLA breaches, Globus can trigger one or more escalation actions: | Escalation Type | Action | Example | | --- | --- | --- | | Notify | Send alert to assigned owner or team lead | Email + in-app notification to SEVIS team lead | | Reassign | Move item to escalation queue or different team | Compliance Hold → Senior Compliance Officer queue | | Create Ticket | Auto-create support or compliance ticket | New compliance ticket with entity details and SLA breach info | | Add Flag | Add red risk flag to entity profile | "SLA Breach" flag visible on entity profile and dashboards | | Dashboard Alert | Surface item on leadership dashboard | Breached items appear in Operations Manager overview panel | **SLA milestones.** A policy can carry a schedule of time-based actions. `queue_sla_milestones` has one row per (`policy_id`, `milestone_day`) with an `action_type`, a `template_key` (the notification template, for the reminder actions), a `reason_code` (for the lapse and cancel actions), a `label` and an `is_active` flag. Action types: `SEND_REMINDER` (notify the applicant), `ESCALATE_TO_APPLICANT` (ask the applicant to act, for example to replace a referee, without changing status or completing the timer), `AUTO_LAPSE` (transition the application to lapsed and close the queue item) and `AUTO_CANCEL` (the same flow, transitioning to cancelled). `queue_sla_milestone_events` is the idempotency log: one row per (SLA event, milestone) with `fired_at`, an `outcome` of `sent`, `skipped` or `failed`, and an `error_message`. Sent and skipped milestones never fire again; failed ones are retried on the next run. The executor runs hourly under an advisory lock and skips completed SLA events, so nothing fires after the applicant resolves the condition. **Milestone ladder.** Each policy can carry a day-based milestone ladder, managed under `/api/admin/sla-policies/:policyId/milestones` (permission `sla_policies.manage`; `POST` creates, `PUT` on `.../:milestoneId` updates, `DELETE` removes). Writable action types are `SEND_REMINDER` (needs the key of an active notification template), `AUTO_LAPSE` and `AUTO_CANCEL` (each needs an active global reason code); a missing or invalid one returns `400 VALIDATION_ERROR`. Only one milestone may exist per policy and `milestoneDay`: creating a second, or moving one onto an occupied day with `PUT`, returns `409` with the top-level code `DUPLICATE_MILESTONE_DAY` in the envelope `{ error: { code, message } }`. The check runs before the write, and a database unique-index violation is mapped to the same response. ### SLA in the UI SLA visibility appears in three places: #### Super Admin Config Admin → Queue Configuration → SLA Policies Create / Edit / Disable SLA / Clone SLA for new scope / Timezone + scope picker on form / Table: Name, Queue, Scope, Response, Resolution, Escalation, Status #### Queue Table View Per-row SLA indicators 2h 14m left On Track Warning 80% elapsed Breached 6h Overdue #### Entity Profile Operational SLA panel Queue: Ready for SEVIS Pool Entered: 18 Mar 2026 09:42 SLA Target: 2 business days Remaining: 6h 12m Status: Warning > **SLA & the Entity Profile** > > The entity profile shows lifecycle phase and position (where they are). The SLA panel shows operational urgency (how quickly they need to be worked). These are complementary — phase is the entity's journey, SLA is the team's accountability. ## Document Review Document Review is a focused, type-scoped workflow for verifying applicant-submitted documents. It is designed for simplicity: a temp reviewer hired for one month should be able to open the Review Hub, see only their assigned document types, and take one of three actions — approve, reject, or request resubmission. The architecture has two layers: the Review Hub (entry point showing what needs attention) and Review Mode (the sequential review workflow for a single document type). > **Document Review ≠ Queue Engine** > > Document Review is a simple, reviewer-facing workflow for approving and rejecting documents. It is not part of the Queue Engine (P16). The Queue Engine is the broader work-routing infrastructure that powers lifecycle queues, readiness queues, exception queues, and SLA tracking. Document Review is a standalone feature that feeds signals (e.g., “all documents verified”) into the queue system but does not use the queue engine’s rule definitions, membership tables, or auto-actions. ### Specialist Document Review Workbench — pre-release implementation > **Architecture investigation — release blockers, 12 November 2026.** The subsequent independent source review found that claims only prevent another claim, not another authorized decision or read. Separate transactional evidence-version fences remain in place; exclusive review ownership is not implemented. Workbench document/package rejection is not equivalent to applicant workflow-step rejection: reopening/editability, reasons and correction-notification paths differ. Passport correction recovery enqueues events without the direct applicant inbox notification used by the legacy resubmission wrapper; CBC rejection also has a seeded/requested template-key mismatch risk requiring live verification. Escalation creates an unassigned work item and audit visible in the workbench lane, but assigns and notifies nobody. Existing internal staff notification infrastructure is present but is not connected to this action. These are source-traced findings, not new runtime tests. AC4, AC5 and AC7 are not accepted; no runtime correction has yet been made. The walkthrough below describes controls currently present, not proof that exclusive ownership, full applicant correction parity or delivered escalation works. **Status checked 12 November 2026:** implemented on the isolated BackOfficePrototype branch feature/1219177235115066-document-review-workbench, revision b6a1304a073fcd0a6e72c04b8500107db1da7df4. Development workflow 43188802343 completed successfully. Dev-15 is the reserved target; this entry does not assert deployed endpoint acceptance, Karin's UAT acceptance, QA promotion or production approval. Dev-6 retains the separate minor-fixes stream. The existing Review Hub remains the entry point, not a replacement dashboard. #### Scope and ownership The specialist workbench handles passport and criminal-background-check (CBC) evidence across programmes and operational cycles without opening each applicant profile. Sam retains multiple-CBC/contract upload and replacement-handling mechanics. The workbench's governed request-replacement decision is included, but does not replace Sam's upload lifecycle work. Reference-source accounting and linked-reference decision consistency are included; reference decisions remain in their owning reference review surface. Host evidence retains a separately authorized lane. No blind approve-all, automatic gallery approval, new eligibility policy or production backfill is included. #### Preview walkthrough The isolated Replit preview uses local port 8000, exposed as port 8080. The bare workspace preview runs a different branch. Choose the isolated document-workbench preview, then open /review-hub. This preview uses a disposable local PostgreSQL database and synthetic three-page documents through a read-only storage emulator; it does not use real participant documents. Test account credentials are deliberately excluded from this documentation. Fixture locators regenerate on restart. 1. Open **Document Review** and select the **Passport Review** card. The workbench opens at /document-workbench with passport selected. 2. Choose programme and operational cycle. Set **Disposition / exception lane** to **Ready for review**, and **Submitted** to **Oldest first** or **Newest first**. Select a row to load its current evidence and applicant/application context. Lists fetch 25 rows by default, capped at 50, with server-side scope, stable cursor pagination and matching totals. 3. Inspect every document page. **Claim evidence** coordinates reviewers for two minutes; **Renew claim** extends it and **Release claim** releases it. A claim is not an approval and never replaces the atomic evidence-version check. 4. Select **Fullscreen**, reload, and use **Exit fullscreen** or Escape. Category, programme/cycle, lane, sort, cursor and selected evidence identity/version remain in the URL. Do not share evidence-bearing session URLs as attachments. 5. Use **Skip** to move without recording a decision. Use **Approve and next** only after reviewing the document. Navigation follows a confirmed save and successful refreshed list; failed or unknown saves must not claim success or silently advance. 6. For passport replacement, select an approved reason under **Replacement or rejection reason**, optionally enter **Review notes**, then choose **Request replacement and next**. Notes are staff-only; the governed reason supplies the applicant-facing message. **Reject and next** is a separate refusal action. 7. Enter an escalation reason and choose **Escalate and next**. Find the durable request in **Escalated for specialist review**. Escalation creates a work item and audit, not approval. 8. Switch **Evidence category** to **Criminal background check**. Inspect the current original and translation together, all pages, country context, provenance and validation warnings. The package reviewer uses **Approve** and **Request Replacement**. Missing, inaccurate or blocked required translation cannot produce a successful package approval. An approval must be based on the exact current artifacts. 9. Inspect **Processing**, **Processing failed**, **Missing evidence**, **Reviewed**, **Superseded**, **Not reviewable** and **Reconciliation exception** as appropriate. An empty lane is not proof of complete source coverage. Source-accounting diagnostics are administrator-only observations, not a combined pending count, and cover applicant documents/signed evidence, workflow media and reference submissions. 10. Return to the Hub or a second open review/profile tab. Refresh-on-focus and a 30-second polling interval provide bounded convergence. A changed version requires reselection; a failed access refresh hides previously loaded rows and previews. #### Security and decision contracts Every list, count, detail, proxied-byte read, claim and action intersects current reviewer document-type assignments, category permissions and resource scope. A passport-only reviewer cannot obtain CBC metadata, counts or bytes through direct endpoints; general dashboard access is not sufficient. API keys are refused for the specialist session. Files are served only by authorized Globus backend proxy endpoints, never raw portal or GCS URLs. Canonical passport and CBC writers fence decisions inside transactions against the expected evidence basis. Claims expire independently. Exact retries recover the same canonical result rather than duplicate decisions, audit or notifications. Generic legacy endpoints refuse independent decisions on passport/CBC/linked reference evidence. Durable recovery receipts use the existing outbox; a saved decision confirms durable enqueueing, not external email/webhook delivery. Reference submission and explicitly linked document status change together; identity is not inferred from matching paths. #### Engineering evidence and remaining acceptance Local production build, type checking, lint, repository skill-enforcement checks, targeted route/security tests, and guarded disposable-PostgreSQL concurrency/replay proofs passed before branch integration. The development pipeline subsequently passed for the revision above. One critical browser pass covered Hub entry, passport paging/fullscreen, skip, claims, escalation, replacement, approval-and-next, restricted CBC access and side-by-side CBC PDFs. It found reload and CBC-authority failures; fixes were subsequently verified by unit tests and real HTTP, not a second complete browser pass. Synthetic HTTP benchmarks used 100 and 1,000-item datasets, three-page PDFs, three warmups and 30 samples per operation. At 100 items, p50/p95 milliseconds were list 15.57/25.11, preview 19.69/44.46 and save-to-refetch 45.66/59.37. At 1,000 items they were 23.25/29.05, 18.06/31.78 and 51.12/77.49. These passed the documented local budgets; they are not deployed-GCS latency, browser-paint or multi-user stress results. Remaining release acceptance includes deployed endpoint verification, full cross-tab convergence and live-revocation browser evidence, country/replaced-artifact scenarios integrated with Sam's work, complete supported-source browser coverage, and Karin's manual acceptance. Keep engineering results, Karin acceptance and production approval separate. Reproducible fixtures, per-AC evidence, performance limitations and ten manual UAT scenarios are in docs/document-review-workbench on the isolated feature branch. Do not label all nine acceptance criteria passed. ### Review Hub The Review Hub is the sole entry point for document reviewers. It displays a card for each document type the reviewer is assigned to, showing the count of pending documents or an “all clear” state when no documents need attention. ### Review Mode Review Mode is the core workflow. When a reviewer selects a document type from the Hub, they enter a sequential review experience focused on one document at a time. ### Role-Based Document Assignment Reviewers can be scoped to specific document types using role-based access control. This enables hiring temporary staff who only see the document types they are qualified to review. | Role Example | Assigned Types | Hub Visibility | | --- | --- | --- | | Passport Reviewer | passport_scan | Only Passport cards appear in the Hub | | Background Check Reviewer | background_check | Only Background Check cards appear | | Full Document Reviewer | All types | All document type cards are visible | | Temp Reviewer (scoped) | passport_scan, reference_letter | Only assigned type cards appear | > **RBAC Integration** > > Document type assignments are managed through the RBAC system. Permissions are checked at the API level — even if a reviewer guesses a URL, they cannot access documents outside their assigned types. > **Host documents & brand scope (QA, 2026-07 — Task #1947)** > > Host documents (`host_documents`) are organization-scoped and carry no brand, so brand filtering cannot apply to them. `GET /api/document-review/queue` now always fetches host items for any caller passing the `dashboard.view` gate — previously host items only appeared for callers with unrestricted brand scope. `POST /api/document-review/action` likewise drops the unrestricted-brand-scope requirement on its `source: "host"` branch; the `document.verify` permission gate remains. Applicant-document brand filtering is unchanged — this authorization relaxation is scoped to host documents only. A host document id sent to `POST /api/document-review/action` without `source: "host"` is refused with `400` and code `HOST_SOURCE_REQUIRED` instead of falling through to the applicant branch and returning 404; the accepted `source` values are `local`, `host` and `host_offer_contract`. ### Document State Machine Every document follows a strict state machine. State transitions are audit-logged with the reviewer, timestamp, and review notes. | State | Description | Transitions To | Triggered By | | --- | --- | --- | --- | | pending | Document uploaded, awaiting staff review | verified, rejected | Portal sync or staff upload | | verified | Document reviewed and accepted by staff | expired (if expiry date passes) | Staff action (single or bulk) | | rejected | Document failed review. Applicant notified with reason | pending (resubmission) | Staff action with mandatory review_notes | | expired | Document validity period has lapsed | pending (resubmission) | Automated (expiry date check) | > **Rejection Always Notifies** > > When a document is rejected, three things happen automatically: (1) a notification is created for the applicant with the rejection reason, (2) an ops_event is emitted for n8n workflows, and (3) an n8n webhook push triggers email delivery. Staff cannot reject without providing a reason. ### Review Workflow Documents enter the system through two channels and flow through a structured review pipeline. Emitted Events document.verified — Feeds readiness signals into the queue system document.rejected — Triggers applicant notification and resubmission request document.resubmission_requested — Applicant notified to correct and resubmit ### Secure Storage & Access All document files are stored in Google Cloud Storage (GCS), and raw GCS paths are never exposed to clients — every read is brokered through a short-lived signed URL after an authorization and audit check. The full storage contract (bucket layout, the signed-URL upload flow, versioning, the proxied-download access model, retention, and encryption) now lives on the dedicated Document Storage spec, which is the source of truth for how files are persisted and served. The Queue System page keeps only the review-side concerns below. > **Downstream Integration** > > Document review feeds signals into the broader queue system. Verified documents clear the “Missing Documents” exception queue and contribute to readiness calculations. Rejected documents trigger applicant notifications and resubmission requests. SLA timers can be attached to review turnaround tracking. ### Document Completeness Signal The Document Completeness Signal is a computed readiness indicator that aggregates individual document verification states into a single boolean per applicant. It answers the question: “Has this applicant submitted and had all required documents verified?” The signal feeds into three downstream consumers: | Consumer | When Signal = true | When Signal = false | | --- | --- | --- | | Queue System (Readiness Queues) | Applicant exits the Missing Documents exception queue and becomes eligible for the next processing queue | Applicant remains in or re-enters the Missing Documents exception queue | | Workflow Engine (Phase Gates) | The document-completeness gate for the current phase is satisfied, enabling auto-advance if all other gates are also met | Phase gate remains blocked; applicant cannot advance to the next phase | | Applicant Profile (Completeness Ring) | Document segment of the profile completeness ring shows 100%. Green indicator on the staff-facing applicant detail page | Document segment shows partial completion. Amber/red indicator with a list of missing or pending document types | Signal Data Model | Field | Type | Description | | --- | --- | --- | | application_id | UUID (FK) | Reference to the application record | | is_complete | BOOLEAN | true if all required documents are verified and non-expired | | total_required | INTEGER | Count of required document types from the workflow template | | total_verified | INTEGER | Count of document types with at least one verified, non-expired record | | missing_types | TEXT[] | Array of document type keys that are not yet satisfied | | last_computed_at | TIMESTAMP | When the signal was last recomputed | | last_event_id | UUID | The document event that triggered the most recent recomputation | > **Eventual Consistency** > > The completeness signal is recomputed asynchronously after each document event. There is a brief window (typically <1 second) where the signal may not reflect the latest document state. Queue membership changes triggered by the signal use an idempotent upsert pattern to handle duplicate or out-of-order event deliveries safely. ## Queue Categories (Orthogonal Model) The Applicant Processing Workspace epic introduces a second axis on top of the existing lifecycle-phase queues. Lifecycle phase answers where in the journey an applicant sits (Intake, Review, Placement, …); the orthogonal category axis answers what state of attention the work needs right now. The two axes compose — a single applicant is always in exactly one lifecycle phase and simultaneously in one or more categories. > **Categories are derived, not stored** > > Like every other queue concept on this page, queue categories are computed views over existing system state (queue rules, SLA timer, assignment state, exception buckets). There is no queue_category column on the applicant or membership rows — the category an applicant appears under is the output of the derivation rules below, evaluated at read time. ### The Six Categories | Category | Meaning | Tone | | --- | --- | --- | | Ready | An action is available now and no blockers stand in the way. Pick it up and move it forward. | Action | | Waiting | Forward motion depends on a party outside the processor (applicant reply, host decision, partner upload, government response). | Hands-off | | Blocked | An internal precondition has failed (rules-engine compliance failure, missing required field/document, document rejected). Cannot proceed until cleared. | Internal | | Aging | Membership in the current queue has crossed an SLA threshold without breach yet — the timer is running yellow. | SLA | | Exceptions | An exception rule has fired (compliance flag, integrity issue, host-cancel after placement). Mirrors the existing Exception Queue catalog. | Risk | | Escalated | A supervisor or compliance officer has been pulled in — either auto-escalated by SLA breach or manually escalated by the assigned processor. | Leadership | ### Derivation Rules Each category is computed from already-existing signals. No new storage is introduced. | Category | Derivation | | --- | --- | | Ready | Primary queue is a readiness queue with status “Ready for …” and no Blocked / Exception / Waiting reasons attached. | | Waiting | Primary or any attached reason has source_system ∈ applicant_portal, host_portal, partner_portal, sevis, external AND no Blocked reason is present. | | Blocked | Any attached reason has severity = blocker (rules-engine compliance failure, missing required field/document, document rejected). | | Aging | SLA timer for the primary queue membership is past the warning threshold but before the breach threshold (see SLA Pipeline Strip Indicators). | | Exceptions | Applicant is a member of any queue whose definition_type = exception (mirrors the Exception Queue Definitions section). | | Escalated | SLA timer has breached AND auto-escalation rule has fired, OR the assigned processor has manually flagged escalation_required = true. | ### Composition with Lifecycle Phase The processor UI lets staff filter by either axis independently or both together. The category axis sits on the left rail of the workspace; the lifecycle-phase filter sits in the facet bar. Selecting Blocked + Placement returns applicants who are blocked while in the Placement phase — not a separate stored bucket, just the intersection. > **Categories never replace lifecycle phase** > > A category answers “how does this work item feel right now?”. A lifecycle phase answers “where in the journey is the applicant?”. The two questions are independent. An applicant can be Blocked in Placement today and Ready in Visa & Compliance next week — same applicant, same row in the database, different derived categories at different times. ### Worked Example Applicant: Maria Lopez — Camp Counselor, Summer 2026 - Lifecycle phase: Placement (host interview completed, awaiting Wave 2 documents) - Primary readiness queue: “Awaiting Wave 2 Documents” - Attached reasons: doc.wave2.health_form.missing (severity: blocker), sla.placement.aging (severity: warning) - SLA timer: 9 days in queue, warning threshold 7d, breach 14d - Derived categories: Blocked + Aging Maria appears in both the Blocked and Aging category lists, but her lifecycle phase remains a single value: Placement. Clearing the missing health form drops her out of Blocked; she stays in Aging until either she advances out of the queue or the SLA timer is reset. ## Queue Reason Object Today an applicant’s queue membership tells staff which queue they’re in, but not why. Processors operating from Hanover-style exports rely on tribal knowledge to answer “why is this applicant in this queue”. The Queue Reason object is the structured contract that lets the platform answer that question itself — one row per active reason, machine-readable, auditable, and explainable in the UI. ### Field Contract | Field | Type | Source of truth | | --- | --- | --- | | id | uuid | Generated on insert. Stable identifier referenced by audit log rows and override records. | | applicant_id | uuid | Foreign key into the applicant aggregate. Required. | | queue_id | uuid | Foreign key into queue_definitions — the queue this reason is attached to. | | title | string (≤ 80 char) | Human-readable short label rendered as the chip text (e.g. “Health form missing”). Comes from the rule definition that emitted the reason. | | description | string (markdown) | Longer explanation rendered in the reason popover. Templated by the rule with applicant-context interpolation. | | severity | enum info \| warning \| blocker | Drives both visual treatment and the Blocked-category derivation. Set by the emitting rule. | | generated_at | timestamptz | When the rule evaluation first emitted this reason. Used for sort and for SLA Aging derivation when the queue SLA is per-reason. | | source_system | enum rules_engine \| applicant_portal \| host_portal \| partner_portal \| sevis \| hubspot \| external \| manual_staff | Which subsystem produced the reason. Drives the Waiting-category derivation when the value is one of applicant_portal, host_portal, partner_portal, sevis, external — i.e. the reason is gated on a party outside the processor. | | linked_workflow_step_id | uuid (nullable) | If the reason relates to a specific workflow step (e.g. Wave 2 document upload), this points at the step so the UI can deep-link into Workflow Studio. | | rule_id | uuid (nullable, FK to queue_rules.id) | Id of the queue_rules row that emitted this reason. | | resolved_at | timestamptz (nullable) | Set when the reason is cleared. Once non-null the reason no longer participates in derivation. | | resolution_kind | enum auto_cleared \| manual_resolved \| superseded (nullable) | How the reason was resolved. Set together with resolved_at. | ### Multiplicity & Primary Reason An applicant can carry multiple simultaneous reasons against the same queue, and reasons across different queues coexist independently. The UI promotes a single primary reason to the Queue Row’s Primary Queue Reason column — the remainder render as a stacked chip count (“+3 more”) and expand in the row popover. Primary reason is selected deterministically by: severity (blocker → warning → info), then generated_at ascending (oldest first). No new column — computed on read. ### Rule-Driven Generation Path Reasons are emitted by the Rules Engine as a structured output of a rule evaluation. When a rule whose fail action carries a readiness dimension (the routing marker; there is no `failure_behavior` field) evaluates to a failing outcome, the engine writes a Queue Reason row in addition to placing the applicant into the target queue. The reason carries the rule_id back-pointer so the processor UI can render the underlying rule, its parameters, and the evaluation evidence. > **Two ways reasons enter the system** > > Rules-engine emission (primary path): any failing rule outcome whose fail action routes to a queue attaches the reason. SEVIS, HubSpot, and portal-submission sources hand off through the rules engine via the same emission API so the contract stays uniform. Manual staff entry (escape hatch): manual_staff is an accepted source_system value, but no staff-facing route attaches a free-text reason yet. Staff with the queues.manage permission can resolve an open reason through POST /api/queue-reasons/:id/override, which is always written to audit. ### Resolution & Override Path A reason has two ways to leave the active set: - Auto-clear: the next rules-engine evaluation finds the underlying condition no longer fails. The engine writes resolved_at = now(), resolution_kind = auto_cleared, and emits queue.reason.resolved. - Staff override: a user with the right permission supplies a written justification, which is stored on the audit row. The reason is written with resolution_kind = staff_override and emits queue.reason.overridden. The underlying rule outcome is unchanged — only this applicant’s reason is cleared. Auto-clear and staff override use the same row — reasons are never deleted, only resolved. This keeps the timeline complete. ### Audit Vocabulary Three new actions are appended to the Audit Logging Contract: | Action | Entity type | When it fires | | --- | --- | --- | | queue.reason.created | queue_reason | A new reason row is inserted. Actor is the emitting subsystem (e.g. system:rules-engine) or the staff user for manual entries. | | queue.reason.resolved | queue_reason | Auto-clear fires from a passing rule re-evaluation. Actor is system:rules-engine. before/after captures the resolution timestamp transition. | | queue.reason.overridden | queue_reason | A staff user overrides the reason. Actor is the staff user (real userId via resolveActor). The justification text is written into the audit row’s after.justification field. | ## Queue Row Contract (Processor UI) The processor workspace renders one row per primary queue membership. The column-by-column contract below extends the generic Table Columns described under UI / UX Design with the processor-specific fields the new workspace needs. Every value is derived — no new stored column is introduced. | Column | Source of truth | | --- | --- | | Applicant | applicants.first_name + applicants.last_name + link to applicant detail page. | | Program | applications.program_id → programs.name (the program of the active application row, not the applicant identity). | | Season | applications.season_id → seasons.name. | | Lifecycle Stage | Computed lifecycle phase + status (e.g. “Placement — Awaiting Wave 2 Documents”). Same value the lifecycle filter operates on. | | Primary Queue Reason | Resolved primary reason chip per the Multiplicity & Primary Reason rule; stacks remaining reasons as “+N more”. | | Also In | | | SLA Status | Pill driven by the SLA Pipeline Strip Indicators rules (on-track / aging / breached). Tooltip shows time remaining or breach duration. | | Days In Stage | now() − queue_transitions.entered_at for the primary membership, rounded to whole days. Independent of the SLA pill so a non-SLA queue still surfaces age. | | Assigned Processor | applicants.assigned_staff_id → user.display_name. “Unassigned” when null. Pulled from the Applicant Assignment model. | | Priority | Max severity across all attached reasons, expressed as the Priority Indicator chip (critical / warning / ready / informational). | | Next Action Hint | Short imperative phrase derived from the primary reason’s rule definition (e.g. “Review Wave 2 health form”). Falls back to the queue’s default action label when no reason is attached. | | Risk Indicators | Inline icons for active Exception-category memberships and Escalated category. Hover reveals which exception(s) fired and by which rule. | > **Cross-links, not duplication** > > The Also In multi-bucket surfacing, the Applicant Assignment model, and the Activity Timeline / last-touch staleness pills are all defined on the Applicant Lifecycle page. The Queue Row contract references those definitions rather than restating them. ## Saved Views — Seed Presets Saved Views are the existing facet-filter + grouping primitive already supported by the queue workspace. The processor workspace ships with three canonical seed presets to give new users a productive starting point. They are normal saved views over existing filter facets — no new infrastructure. | Preset name | Filter composition | | --- | --- | | My Jamaica applicants | assigned_processor = current_user AND applicant.country_of_origin = “Jamaica” AND lifecycle.phase ∈ Application, Validation, Placement, Visa & SEVIS . | | SEVIS Risk | lifecycle.phase = “Visa & SEVIS” AND ( category ∈ Blocked, Aging, Escalated OR reason.rule_id startswith “sevis.” ). | | Aging > 14 Days | days_in_stage > 14 AND sla_status ∈ aging, breached . No phase filter — surfaces aging items anywhere in the lifecycle. | ## Consumers of this Contract > **Where this spec is consumed** > > Two downstream docs pages render against the Queue Categories and Queue Reason Object contracts defined above. Neither is required reading to understand the contract itself, but both depend on it: Operations Command Center — the cross-queue leadership view that aggregates the six categories into pipeline health, bottleneck, and escalation panels. (Spec page pending; route reserved.) Split-View Applicant Workspace & Next-Action Engine — the per-applicant processor screen that renders the Queue Row contract on the left and consumes Primary Queue Reason + Next Action Hint on the right. (Spec page pending; route reserved.) ## Implementation Follow-up — P16 Asana Tickets This page is a docs-site spec extension only. Runtime ownership of the work below sits with the Globus core P16 — Queue System & SLA Management project. The four tickets below are drafted in the house format documented in docs/asana-ticket-standard.md so a P16 owner can copy-paste straight into Asana when work is picked up. None of these tickets should be filed against this docs-site repo. *Status: draft ticket text. Its references to `failure_behavior = "route_to_queue"` describe a proposed rule field that does not exist in code; queue routing today is by the readiness dimension on a fail-class rule action (ADR 0004).* P16 Ticket 1 Globus: queue-category derivation service for orthogonal Ready / Waiting / Blocked / Aging / Exceptions / Escalated axis FOR PRODUCT OWNERS / STAKEHOLDERS Invisible to end users, but it is the engine behind the new left-rail category filter in the Applicant Processing Workspace. Without it the workspace falls back to lifecycle-phase only and processors cannot ask "show me everyone who is Blocked right now, regardless of phase". ---------------------------------------- BACKGROUND Queues today are derived per-membership from "queue_definitions" + "queue_rules". The only axis available to filter on is lifecycle phase (Intake, Review, Placement, ...). There is no shared notion of "what state of attention does this work item need", which means the new Applicant Processing Workspace cannot offer a Ready / Waiting / Blocked / Aging / Exceptions / Escalated filter without each consumer reinventing the derivation locally. WHAT THIS TICKET DOES Adds a "deriveQueueCategories(applicantId)" service module that returns the set of orthogonal categories an applicant currently belongs to. Inputs are the existing primary queue membership, the SLA timer for that membership, the attached queue reasons, and exception-queue membership. Output is consumed by the workspace left rail and by the "Operations Command Center" downstream page. No new persistence — purely a derived read. ACCEPTANCE CRITERIA (Gherkin) Feature: Orthogonal queue-category derivation Scenario: deriveQueueCategories module exists with the documented signature Given the package "@globus/queue-system" Then it exports "deriveQueueCategories(applicantId: string): Promise " And "QueueCategory" is the union "'ready' | 'waiting' | 'blocked' | 'aging' | 'exceptions' | 'escalated'" Scenario: Blocked category fires when any attached reason has severity "blocker" Given applicant "A1" has a primary queue membership in "Awaiting Wave 2 Documents" And a queue reason with "severity" = "blocker" is attached to that membership When "deriveQueueCategories('A1')" runs Then the result includes "blocked" Scenario: Waiting category fires when the only attached reasons originate outside the processor Given applicant "A2" has a queue reason with "source_system" = "host_portal" And no reason with "severity" = "blocker" is attached When "deriveQueueCategories('A2')" runs Then the result includes "waiting" And the result does not include "blocked" Scenario: Aging fires from SLA timer crossing the warning threshold Given applicant "A3" has a primary queue membership 9 days old And the queue SLA warning threshold is 7 days and breach is 14 days When "deriveQueueCategories('A3')" runs Then the result includes "aging" And the result does not include "escalated" Scenario: Build-time guard against duplicating the derivation in consumers Given a lint rule "no-inline-queue-category-derivation" When a consumer file references "queue_memberships" together with the literal "blocked" Then the lint rule fails the build And the only allowed call site is "deriveQueueCategories" P16 Ticket 2 Globus: queue_reasons table + rules-engine emission path for structured queue reasons FOR PRODUCT OWNERS / STAKEHOLDERS Processors will, for the first time, see a written reason next to every applicant explaining why they are in the queue — health form missing, awaiting host decision, SEVIS error, and so on. Today this knowledge lives in spreadsheets and tribal memory. ---------------------------------------- BACKGROUND "queue_memberships" tells us which applicants are in which queues but not why. Rule evaluations in the Rules Engine that fail with "failure_behavior" = "route_to_queue" silently place the applicant into the target queue without leaving a structured trace. SEVIS, HubSpot, and portal submissions emit similar "this is why" signals into ad-hoc fields with no shared schema. WHAT THIS TICKET DOES Adds a "queue_reasons" table with the field contract documented in the docs page section "Queue Reason Object". Extends the rules-engine evaluation pipeline so that any outcome with "failure_behavior" = "route_to_queue" also inserts one "queue_reasons" row carrying the rule_id back-pointer. Exposes a single internal "emitQueueReason()" helper that SEVIS, HubSpot, and portal-submission handlers call through — no second emission path. Companion ticket: "Globus: queue-reason audit vocabulary extension for queue.reason.created / .resolved / .overridden". ACCEPTANCE CRITERIA (Gherkin) Feature: Structured queue reasons emitted by the rules engine Scenario: queue_reasons table has the documented columns Given the database schema Then table "queue_reasons" has columns "id", "applicant_id", "queue_id", "title", "description", "severity", "generated_at", "source_system", "linked_workflow_step_id", "rule_id", "resolved_at", "resolution_kind" And "severity" is enum "info" | "warning" | "blocker" And "source_system" is enum "rules_engine" | "applicant_portal" | "host_portal" | "partner_portal" | "sevis" | "hubspot" | "external" | "manual_staff" Scenario: Rules-engine outcome emits a reason Given a rule "R1" (a queue_rules row) with "failure_behavior" = "route_to_queue" And applicant "A4" fails that rule When the rules engine writes its outcome Then a "queue_reasons" row exists with "rule_id" = the id of "R1" And the row has "source_system" = "rules_engine" And the row has the queue_id of the rule's target queue Scenario: Multiple reasons coexist against the same membership Given applicant "A5" has two failing rules attached to the same primary queue Then "queue_reasons" contains two rows for "A5" with that queue_id And neither has "resolved_at" set Scenario: Auto-clear on next passing evaluation Given applicant "A6" has an active "queue_reasons" row with "rule_id" = "doc.wave2.health_form.missing" When the rules engine re-evaluates "doc.wave2.health_form.missing" and it passes Then that row has non-null "resolved_at" And the row has "resolution_kind" = "auto_cleared" Scenario: Build-time guard against bypassing emitQueueReason Given the package "@globus/rules-engine" Then a lint rule "no-direct-queue-reason-insert" prevents any "INSERT INTO queue_reasons" outside the "emitQueueReason" helper P16 Ticket 3 Globus: queue-reason audit vocabulary extension for queue.reason.created / .resolved / .overridden FOR PRODUCT OWNERS / STAKEHOLDERS Invisible to end users, but it is what lets compliance see who overrode which reason and why. The Audit Console gains three new action types so a queue-reason override leaves the same quality of trail as a partner-overview edit or a SEVIS submission. ---------------------------------------- BACKGROUND The shared "recordAudit()" helper writes every mutating endpoint to "audit_logs" with a dotted action vocabulary scoped by subsystem ("asana.project.created", "mcp.heartbeat.sent", "host_organization.note.created"). The Queue Reason object introduced by the companion ticket "Globus: queue_reasons table + rules-engine emission path for structured queue reasons" has no action names yet — overrides and auto-clears currently leave no audit trail. WHAT THIS TICKET DOES Registers three new action names — "queue.reason.created", "queue.reason.resolved", "queue.reason.overridden" — against "entityType" = "queue_reason" in the shared audit vocabulary. Wires emission into the three call sites: rules-engine reason insert, rules-engine auto-clear on passing re-evaluation, and the staff override endpoint. The override path requires a justification string which is written into the audit row's "after.justification" field. ACCEPTANCE CRITERIA (Gherkin) Feature: queue.reason.* audit actions Scenario: queue.reason.created fires on every reason insert Given applicant "A7" fails a rule with "failure_behavior" = "route_to_queue" When the rules engine emits the reason Then an "audit_logs" row exists with "action" = "queue.reason.created" And "entityType" = "queue_reason" And "entityId" = the new "queue_reasons.id" And "userId" is null and the actor label is "system:rules-engine" Scenario: queue.reason.resolved fires on auto-clear Given an active reason row for applicant "A8" When the rules engine re-evaluates and the underlying rule now passes Then an "audit_logs" row exists with "action" = "queue.reason.resolved" And "before.resolved_at" is null And "after.resolved_at" is non-null Scenario: queue.reason.overridden requires a justification Given a staff user "U9" with permission "queues.manage" When "U9" POSTs to the override endpoint with body "justification\\ / Confirmed via phone, waiver attached\\" Then an "audit_logs" row exists with "action" = "queue.reason.overridden" And "userId" = "U9" via "resolveActor" And "after.justification" = "Confirmed via phone, waiver attached" Scenario: Override without justification is rejected Given a staff user "U10" When "U10" POSTs to the override endpoint with an empty body Then the response is HTTP 400 And no "audit_logs" row is written P16 Ticket 4 Globus: saved-view seed presets for the Applicant Processing Workspace FOR PRODUCT OWNERS / STAKEHOLDERS New processors will land in the Applicant Processing Workspace with three ready-to-use saved views — "My Jamaica applicants", "SEVIS Risk", "Aging > 14 Days" — instead of an empty filter bar. Existing saved-views infrastructure does the heavy lifting; this ticket only seeds the presets. ---------------------------------------- BACKGROUND The queue workspace already supports user-defined saved views built on the existing facet-filter + grouping primitive. There is no seed data — every new tenant starts with zero saved views, forcing each processor to rebuild the same three views by hand on day one. WHAT THIS TICKET DOES Adds a seed migration that inserts three system-owned saved views with the exact filter composition documented in the docs page section "Saved Views — Seed Presets". The views are marked as "system_owned" so users cannot delete or rename them, only clone. Reuses the existing saved-views schema — no new tables, no new filter facets. ACCEPTANCE CRITERIA (Gherkin) Feature: Seed saved-view presets Scenario: Three seed views exist after migration Given the seed migration has run Then saved views named "My Jamaica applicants", "SEVIS Risk", "Aging > 14 Days" exist And each is marked "system_owned" = true And each has "owner_id" = null Scenario: "Aging > 14 Days" uses the documented filter Given the seed view "Aging > 14 Days" Then its filter JSON contains "days_in_stage > 14" And it contains "sla_status \u2208 " And it has no "lifecycle.phase" filter Scenario: System-owned views cannot be deleted Given a user "U11" attempts to DELETE saved view "SEVIS Risk" Then the response is HTTP 403 And the view still exists Scenario: Users can clone a system-owned view Given a user "U12" clones "My Jamaica applicants" Then a new saved view exists with "owner_id" = "U12" And "system_owned" = false And the filter JSON matches the source view ## Future Enhancements See Build Status for current build status and project tracking. --- --- title: "Notification Model" slug: notification-model section: Globus Core status: stable description: "Who gets notified, through which channel, when specific events fire — channel routing, notification catalog, HubSpot integration for applicants, governance rules, and the outbox data model." last-updated: 2026-10-07 --- # Notification Model Who gets notified, through which channel, when specific events fire — channel routing, notification catalog, HubSpot integration for applicants, governance rules, and the outbox data model. ## Employer Notifications > **Employer-specific notifications have a dedicated catalog** > > For the first-draft notification triggers targeting partner organizations of type employer (Host Company / Host Organization / Account Manager), see the Employer Notifications spec. That page is the source of truth for those trigger keys, message templates, and rollup rules. ## Channel Routing Matrix Each audience has a designated notification channel and owning system. Applicant-facing communications route through HubSpot; all other audiences use n8n-delivered channels. | Audience | Primary Channel | Secondary Channel | Owning System | Notes | | --- | --- | --- | --- | --- | | Applicant | Email (transactional) | SMS, In-app | HubSpot | All applicant comms routed via HubSpot transactional email API | | Partner | Email (SendGrid) | In-app, Portal notification | n8n → SendGrid | Partner Portal in-app notifications for time-sensitive items | | Host Organization | Email (SendGrid) | Portal notification | n8n → SendGrid | Host-specific compliance and placement notifications | | Internal Staff | In-app notification | Slack, Email | n8n → Slack / In-app | Critical alerts also surface on leadership dashboards | > **Sep 2026 exception — branded nurture campaigns** > > The nurture campaign engine shipped Sep 14 2026 delivers applicant-facing, email-only campaign steps (e.g. the Wild Packs incomplete-profile cadence) through the Globus branded template registry → `external_notification.created` → n8n → SendGrid path, not through HubSpot. Template governance, SendGrid trust settings, readiness gating and stop reasons are documented on [Communications Administration & Campaigns](/docs/communications-administration). The HubSpot handoff mapping for transactional applicant mail remains proposed, not shipped. ## HubSpot as Applicant Channel All applicant-facing email and SMS notifications are delivered through HubSpot, not directly via SendGrid or n8n. HubSpot acts as the single applicant communication channel, owning delivery, tracking, and preference management. ### Data Flow The notification delivery pipeline for applicant communications follows this path: Globus event / → / Notification outbox / n8n workflow / HubSpot transactional email API / Applicant inbox For SMS notifications, the flow is identical but terminates at the HubSpot SMS API instead of the email API. n8n acts as the orchestrator but never sends messages directly to applicants. ### Why HubSpot? CRM context — HubSpot maintains the full applicant contact record, enabling personalized messaging with lifecycle-aware content / Marketing preferences — unsubscribe management, communication preferences, and opt-out handling are centralized in HubSpot / Delivery analytics — open rates, click tracking, bounce management, and deliverability monitoring in a single dashboard / Regulatory compliance — CAN-SPAM, GDPR, and TCPA compliance built into HubSpot's sending infrastructure / Template management — marketing and operations teams can update email templates without developer involvement / Contact timeline — every communication appears on the applicant's HubSpot timeline, giving support staff full visibility > **Applicant comms route through HubSpot. n8n orchestrates but never sends directly to applicants.** > > This ensures all applicant-facing communication benefits from CRM context, preference management, and delivery analytics. Partner, host, and internal notifications continue to use n8n-delivered channels (SendGrid, Slack, in-app). ## Delivery & Reply Contract Applicant communication is a closed loop, not a fire-and-forget send. Globus owns the decision to notify; n8n executes delivery through HubSpot, acknowledges the result back to Globus, and routes applicant replies back into the Globus activity log. The n8n flow logic behind this contract is Flows 3 and 4 of the Four-Flow Bidirectional Event Contract . ### Channel Selection For applicant-facing sends, n8n selects a channel by preference order, falling through to the next channel only when the applicant has not opted in (or the channel is unavailable): WhatsApp / → / email / SMS Preference is driven by the Contact's opt-in status in HubSpot — the system of record for communication consent. WhatsApp is used first where the applicant has opted in and a number is on file; otherwise email; SMS is the last resort. The selected channel is recorded on the delivery acknowledgement so the audit trail reflects how each message was actually sent. ### Delivery Acknowledgement Loop After every successful HubSpot send, n8n POSTs a delivery confirmation back to the Globus notification outbox so the outbox status advances from sent to delivered (or records a failure). Globus stays authoritative and stateless about delivery; HubSpot/n8n report back rather than Globus polling. The acknowledgement uses a single endpoint: PATCH /api/notifications//delivery-status Called by n8n after a HubSpot send. Authenticated by session or API key with the integrations.comms scope (not HMAC). Upserts per (notification, channel): a repeat call updates the existing delivery row; there is no state-regression guard. | Field | Type | Notes | | --- | --- | --- | | id (path) | uuid | Notification outbox record id being acknowledged. | | status | enum | queued \| sent \| delivered \| failed \| bounced. Written to the delivery row for the given channel. | | channel | enum | email \| sms \| push \| in_app \| webhook — the channel the delivery row belongs to; (notification, channel) is the upsert key. | | delivered_at | timestamptz | Not a request field: the server stamps it when status = delivered (and sent_at when status is sent or delivered). | | providerMessageId | string | HubSpot/provider message id for downstream reconciliation. Optional. | | errorMessage | string | Provider error detail. Optional. | | provider, metadata, eventId | string, object, string | Optional provider name, free-form metadata and originating ops event id (camelCase body fields). | The status machine and audit behavior this endpoint drives are documented in Delivery Status Tracking below. ### Inbound Reply Path Replies are first-class. When an applicant answers a message inside HubSpot, the reply is routed back into Globus so the conversation lives on the applicant's operational record, not stranded in HubSpot: Applicant reply in HubSpot / → / HubSpot conversation.message_received / n8n (inbound filter) / Globus activity log n8n filters to inbound direction, resolves the Contact's globus_applicant_id and the most recent open Application, then POSTs the message content, channel, and timestamp to the Globus activity log. This is the previously-undocumented reverse direction of the delivery loop (Flow 4). ### Staff-Initiated Communication > **Staff act in HubSpot with Globus context; replies still land in the activity log.** > > Staff do not need a separate console to message applicants. Globus metadata (application status, readiness, journey position) is surfaced on the HubSpot Application record, so staff see operational context where they already work. A staff member chooses the channel and sends from HubSpot; the same delivery acknowledgement loop confirms the send back to Globus, and any applicant reply flows back through the inbound reply path into the Globus activity log. Globus remains the single source of truth for the conversation history regardless of who initiated the message. ## Notification Catalog Every notification event in the system, grouped by domain. Each entry defines the trigger event, target audience, delivery channel, priority level, and example content. See the Automation Layer for delivery infrastructure details. For the participant-facing placement journey — which of these applicant sends are live today, which are gaps, and the proposed contracts closing them — see [Participant Placement Journey & Notifications](/docs/participant-placement-notifications). ### Application Lifecycle | Trigger Event | Audience | Channel | Priority | Example | | --- | --- | --- | --- | --- | | Application submitted | Applicant | HubSpot email | 🟠 Normal | Thank you for applying — we'll review your application shortly | | Application approved | Applicant | HubSpot email + SMS | 🔴 High | Congratulations! Your application has been approved | | Application rejected | Applicant | HubSpot email | 🟠 Normal | We regret to inform you that your application was not approved | | Application returned for revision | Applicant | HubSpot email | 🟠 Normal | Please update the following sections of your application | | Status change (any) | Partner | SendGrid email | 🟠 Normal | Applicant Jane Doe status changed to Interview Scheduled | | Application submitted | Internal staff | In-app | 🟢 Low | New application received — queued for review | > **Verified 2026-09-02 — none of this table's approved/rejected/returned-for-revision rows produce an applicant email today.** A repo-wide search of `BackOfficePrototype` found zero call sites emitting an application-level status-change event (the catalog declares `application.status_changed`, but nothing ever calls it). Staff approve/reject/return-for-revision actually happens at the **step level**, via `POST /api/applications/:id/step-reviews` (`server/routes/applications/stepReviews.ts`), which genuinely emits `step_review.approved` / `step_review.rejected` / `step_review.manually_completed` (`emitStepReviewDecision` in `server/services/eventEmitter.ts`) through the real `ops_events` → n8n dispatcher pipeline. Live-verified 2026-09-02 (dispatcher execution 3010906, applicant `manus-e2e-bot-dev@mailinator.com`, application `GLB-A-00244`): the dispatcher's Route Lookup **does** register the `step_review` domain, but routes it straight to a `No-Op Check` → `Log No-Op` terminal — the same deliberate-no-op pattern as `queue.exception.entered` (see the Partner Operations "Sync failure (DLQ)" row). Separately, the `rejected`/`manually_completed` decisions also call `notifyApplicant()` directly (`server/notifications.ts`) — but that path is **in-app only** (writes a `notification` row + an `in_app` delivery row) and explicitly, by its own code comment, "does NOT emit any new webhook / n8n event or open any new email/SMS channel." No decision on `approved` triggers any notification at all, in-app or email. Net effect: there is currently no applicant-facing EMAIL anywhere in the approve/reject/return-for-revision path — only a possible in-app notification on reject/manually_completed. Treat this table's three rows as aspirational, not current behavior. See memory `email-notification-audit-2026-09-02` for the full investigation. ### Documents | Trigger Event | Audience | Channel | Priority | Example | | --- | --- | --- | --- | --- | | Document required | Applicant | HubSpot email | 🟠 Normal | Please upload your passport scan by March 15 | | Document upload deadline approaching | Applicant | HubSpot email + SMS | 🔴 High | Reminder: passport scan due in 3 days | | Document upload deadline missed | Applicant, Partner | HubSpot email / SendGrid | 🔴 High | Missing document — application on hold until uploaded | | Document verified | Applicant | HubSpot email | 🟢 Low | Your passport scan has been verified | | Document rejected | Applicant | HubSpot email | 🔴 High | Your passport scan could not be verified — please re-upload | | DS-2019 issued | Applicant, Partner | HubSpot email / SendGrid | 🔴 High | Your DS-2019 is ready — download instructions attached | ### Placement | Trigger Event | Audience | Channel | Priority | Example | | --- | --- | --- | --- | --- | | Placement confirmed | Applicant, Host | HubSpot email / SendGrid | 🔴 High | Match confirmed with Camp Sunshine — next steps attached | | Placement cancelled | Applicant, Host, Partner | HubSpot email / SendGrid | 🔴 High | Placement with Camp Sunshine has been cancelled | | Re-placement required | Applicant, Partner | HubSpot email / SendGrid | 🔴 High | We are working to find you a new placement | | Reservation created | Partner | SendGrid email | 🟠 Normal | Position reserved for applicant Jane Doe — confirm within 72h | | Reservation expiring | Partner | SendGrid email + In-app | 🔴 High | Reservation expires in 24h — confirm or release | | Nomination submitted | Host, Internal staff | SendGrid / In-app | 🟠 Normal | New candidate nominated for Lifeguard position | ### SEVIS & Compliance | Trigger Event | Audience | Channel | Priority | Example | | --- | --- | --- | --- | --- | | SEVIS check-in overdue (Day 4+) | Applicant, SEVIS team | HubSpot email + SMS / In-app | 🔴 High | Please complete your SEVIS check-in — overdue by 2 days | | SEVIS risk critical (Day 20+) | SEVIS team, PM, Compliance lead | In-app + Slack + Email | 🔴 High | URGENT: SEVIS check-in 20+ days overdue — immediate action required | | Compliance check failed | Compliance team, Internal staff | In-app + Slack | 🔴 High | Background check failed for applicant — placement blocked | | Compliance deadline approaching | Host, Internal staff | SendGrid / In-app | 🟠 Normal | Monthly check-in due in 7 days — reminder sent | | Monthly check-in missed (2+) | Compliance team | In-app + Slack | 🔴 High | 2 consecutive check-ins missed — support ticket created | | Address mismatch reported | Assigned staff | In-app | 🟠 Normal | Applicant reported address change during monthly check-in | | Second job declared | Compliance team | In-app | 🟠 Normal | Participant declared second job — compliance review needed | ### Finance & Invoicing | Trigger Event | Audience | Channel | Priority | Example | | --- | --- | --- | --- | --- | | Invoice generated | Applicant, Partner | HubSpot email / SendGrid | 🟠 Normal | Your invoice for program fees is ready — view details | | Payment received | Applicant | HubSpot email | 🟢 Low | Payment of $2,500 received — thank you | | Invoice overdue | Applicant, Partner | HubSpot email + SMS / SendGrid | 🔴 High | Invoice #1234 is overdue — payment required to continue | | Payment plan installment due | Applicant | HubSpot email | 🟠 Normal | Installment of $500 due on April 15 | | Refund processed | Applicant | HubSpot email | 🟠 Normal | Refund of $750 has been processed to your account | | Commission calculated | Partner | SendGrid email | 🟢 Low | Q2 commission statement available — $12,500 earned | ### Reservations | Trigger Event | Audience | Channel | Priority | Example | | --- | --- | --- | --- | --- | | Reservation hold created | Partner | SendGrid email + In-app | 🟠 Normal | Position reserved — confirm within 72 hours | | Reservation expiry warning (24h) | Partner | SendGrid email + In-app | 🔴 High | Reservation expires tomorrow — confirm or it will be released | | Reservation expired | Partner, Host | SendGrid email | 🟠 Normal | Reservation expired — position available again on job order | | Reservation confirmed | Partner, Host | SendGrid email | 🟠 Normal | Reservation confirmed — placement will proceed | | Quota threshold reached (80%) | Partner, Internal staff | SendGrid / In-app | 🟠 Normal | Partner has used 80% of seasonal reservation quota | ### Partner Operations | Trigger Event | Audience | Channel | Priority | Example | | --- | --- | --- | --- | --- | | Partner agreement expiring | Partner, Internal staff | SendGrid / In-app | 🟠 Normal | Your partnership agreement expires in 30 days — renewal required | | Partner suspended | Partner, Internal staff | SendGrid / In-app + Slack | 🔴 High | Partner account suspended — active applicants being reassigned | | Partner reactivated | Partner | SendGrid email | 🟠 Normal | Your partner account has been reactivated | | New partner onboarded | Internal staff | In-app + Slack | 🟢 Low | New partner Global Exchange registered — pending approval | | Sync failure (DLQ) | Internal staff | Slack + In-app | 🔴 High | Partner data sync failed — 3 records in dead-letter queue | ## Notification Governance Rules governing how, when, and to whom notifications are delivered. These governance rules ensure notification quality and prevent message fatigue across all channels. ### Opt-Out & Preferences Mandatory notifications (compliance, legal, safety) cannot be opted out — these are always delivered regardless of preferences / Marketing and informational notifications respect HubSpot subscription preferences for applicants / Partners and hosts can configure per-category notification preferences in their portal settings / Internal staff notification preferences are managed per-role — critical alerts bypass individual preferences / Unsubscribe links are included in all non-mandatory emails (enforced at the HubSpot/SendGrid template level) / SMS opt-out follows TCPA rules — STOP keyword immediately removes the recipient from SMS notifications ### Quiet Hours & Batching | Rule | Scope | Behavior | | --- | --- | --- | | Quiet hours | All non-critical notifications | Held from 10 PM – 8 AM recipient local time; delivered at 8 AM. Critical/safety alerts bypass quiet hours. | | Digest batching | Low-priority notifications | Batched into a daily digest email (sent at 9 AM recipient local time) instead of individual messages. | | Deduplication window | All notifications | Same event_type + audience + entity combination suppressed within a 1-hour window to prevent duplicates. | | Rate limiting | Per-recipient | Max 10 non-critical notifications per recipient per day. Excess notifications are batched into the next digest. | | Timezone awareness | All scheduled sends | Delivery times calculated using the recipient's timezone from their profile (applicant) or organization settings (partner/host). | ### Escalation Chains Unacknowledged alerts follow a time-based escalation chain. Each step widens the audience and increases urgency. Escalation chains integrate with the Queue System SLA-breach actions . | Step | Delay | Audience | Channel | | --- | --- | --- | --- | | 1. Initial alert | Immediate | Assigned staff member | In-app + email | | 2. First escalation | +2 hours unacknowledged | Team lead | In-app + Slack | | 3. Manager escalation | +4 hours unacknowledged | Department manager | In-app + Slack + email | | 4. Leadership escalation | +8 hours unacknowledged | Operations director | Slack + email + dashboard alert | > **Escalation chains are configurable per exception queue and can be customized by program and season.** > > The default chain above applies to all unacknowledged internal alerts. Critical compliance events (SEVIS risk, placement cancellation) use shorter escalation windows. ## Scheduled Notification Jobs Globus runs scheduled notification jobs to proactively send time-based notifications that are not triggered by immediate user actions. These jobs run on configurable cron schedules and process batches of eligible recipients. ### Job Types | Job | Schedule | Description | Audience | | --- | --- | --- | --- | | Document Deadline Reminders | Daily 08:00 UTC | Scans for documents due within 3/7/14 days and sends reminder notifications | Applicant, Partner | | SEVIS Check-in Reminders | Daily 06:00 UTC | Identifies participants with upcoming or overdue SEVIS check-in deadlines | Applicant, SEVIS team | | Monthly Check-in Batch | 1st of month 09:00 UTC | Generates monthly check-in reminders for all active on-program participants | Applicant | | Reservation Expiry Warnings | Every 6 hours | Scans for reservations expiring within 24/48 hours and sends partner alerts | Partner | | Invoice Overdue Digest | Weekly Monday 10:00 UTC | Compiles weekly digest of overdue invoices for finance and partner teams | Partner, Finance | | Compliance Digest | Daily 07:00 UTC | Summarizes new compliance flags, overdue items, and escalations for staff | Internal staff | | Program Extension Eligibility Alerts | Weekly Friday 14:00 UTC | Notifies eligible participants that their Program Extension request window is opening | Applicant, Partner | ### Execution & Monitoring Each job runs as an n8n scheduled workflow with configurable cron expression / Jobs query Globus for eligible recipients based on date/status criteria / Notifications are written to the outbox in batch — individual delivery is handled by the outbox processor / Job execution is logged with start time, end time, records processed, notifications created, and any errors / Failed jobs trigger a system health alert to operations staff via Slack and in-app notification / Jobs are idempotent — re-running a job for the same period does not create duplicate notifications ## Template-Based Notification System All notifications in Globus are rendered from templates. Templates are stored in the database, versioned, and support dynamic variable interpolation. This allows operations and marketing teams to update notification content without code deployments. Template Record template_id (UUID) / event_type (e.g., applicant.status_changed) / channel (email | sms | in_app | slack) / audience (applicant | partner | host | staff) / subject (with variable placeholders) / body (HTML or plain text with variables) / version (integer, auto-incremented) / is_active (boolean) / brand_id (optional brand-specific override) Rendering Pipeline Event fires → template resolver looks up active template for event + channel + audience / Brand-specific template overrides general template if one exists / Variable interpolation replaces placeholders with actual values / Rendered content written to notification outbox for delivery / Template version recorded in outbox for audit traceability ### Template Variables | Variable | Source | Example | | --- | --- | --- | | first_name, last_name (applicant audience) | Applicant record (preferred name, else first name) | Jane | | participant_first_name, participant_last_name (host and partner audiences) | Applicant record | Jane | | programme_name, programme_code | Program register | Camp Counselor | | host_organisation_name (host audience) | Host organization | Camp Sunshine | | partner_organisation_name (partner audience) | Partner organization | Global Recruiters Ltd | | brand_name, brand_website | Brand configuration | Alliance Abroad | | portal_url (applicant audience) | Personalised applicant portal link | https://portal.example.com | ### Template Management Templates are edited through the Globus admin interface with a rich text editor and variable picker / Every edit creates a new version — previous versions are preserved for rollback and audit / Templates can be previewed with sample data before activation / Brand-specific overrides allow different brands to customize notification content while sharing the same event triggers / Deactivating a template causes the system to fall back to the default (non-brand-specific) template **Staff role routing.** Which staff roles receive a given staff notification template is set per template key. `GET /api/admin/notification-routes` lists the routes and `PUT /api/admin/notification-routes/:templateKey` creates or replaces the route for that key (both need `notifications.manage`; the PUT is audited as `notification_route.upsert`). The body takes `roles` (array of role names, default empty; every name must exist or the call returns 400 `VALIDATION_ERROR` naming the unknown ones) and `isActive` (default true). These are the only two routes registered for the path, so a route is switched off with `isActive` false. At delivery time the routed roles resolve to every active staff user who holds one of them, directly or through role-group membership. ## Host Notification Templates Host organizations receive notifications through dedicated templates that are scoped to host-relevant events. These templates are distinct from applicant and partner templates and follow host-specific delivery rules. ### Host Event Types | Event | Template | Channel | Attachment | Timing | | --- | --- | --- | --- | --- | | host.profile_created | host_profile_created | Email | — | Immediate (on host org creation) | | host.user_invited | host_user_invited | Email (n8n → SendGrid) | — | Immediate (on first host_admin auto-provision during host-org creation, on every subsequent invite from the Host Portal, on every password-reset request, and on every Resend Invitation click against a not-yet-activated user). Recipient is the individual invitee, not the org distribution list. Resend reuses this same event with a resend=true payload flag (no separate event key) — see /docs/host-user-invitation#resend-invite for the rotation, throttle, and audit contract. Sibling to host.profile_created — see /docs/host-user-invitation. | | host.participant_matched | host_participant_matched | Email + Portal | Participant profile PDF (the matched participant) | Immediate (per match crossing host-confirmation threshold) | | host.ev_removed | host_ev_removed | Email | — | Immediate (Account-Manager relay) | | host.visa_denied | host_visa_denied | Email | Participant profile PDF (the denied participant) | Immediate (visa_decision = denied) | | host.document_expired | host_document_expired | Email | — | On expiration date (Document Expiry Scanner) | | host.document_expiring_soon | host_document_expiring_soon | Email | — | 30 days before expiry (Document Expiry Scanner) | | host.participant_recommended | host_participant_recommended | Email + Portal | Participant profile PDF (the recommended participant) | Immediate (per recommendation) | | host.participant_recommended_digest | host_participant_recommended_digest | Email | None (multi-participant rollup — attachments intentionally omitted) | Daily rollup (Participant Digest Scanner) | | host.reservation_expiring | host_reservation_expiring | Email + Portal | None (multi-reservation rollup — attachments intentionally omitted) | 24h before reservation holdExpiresAt — emitted by the Reservation Countdown Scanner (warning only). The Active → Expired state transition itself is performed separately by the Reservation Expiry Sweeper at holdExpiresAt; see Quota Model → Reservation Hold Policy & Auto-Expire. | | host.operating_agreement.renewed | host_operating_agreement_renewed | Email | — | Immediate (post-commit of the POST /api/hosts/:id/operating-agreement/renew transaction). Carries the successor agreement's effective range; fired exactly once per successful cutover. See /docs/host-governance#operating-agreement-renewal-api. | | host.account_manager_assigned | host_account_manager_assigned | In-app (staff back-office) | — | Immediate, post-commit of the AM mutation on partner_organizations.account_manager_user_id for the .assigned and .reassigned transitions; does not fire on .cleared. Staff-targeted (newly-assigned AM only) — the host itself is not notified. See /docs/host-governance#host-account-manager-assignment. | > **Host notifications are scoped to their participants only.** > > A host organization only receives notifications about participants placed at their location. They never see data about other hosts or applicants not assigned to them. ### Agreement-Derived Brand Resolution (Wild Packs) > **Required contract — brand-specific templates stop being optional for host email.** > > The template model above treats brand_id as an optional override. For **host-facing email**, agreement-derived branding is mandatory: every canonical host.* email in the Host Event Types catalog MUST resolve its brand server-side from the host company's applicable host agreement before rendering. A host with an applicable **Wild Packs** host agreement receives every host-facing email under Wild Packs branding — invitations, profile/account messages, compliance reminders, agreement notices, participant communications, and digests alike. No Alliance Abroad or generic-brand sender identity, copy, visual treatment, footer, support/contact details, or portal links may leak into those sends. This is a **required contract**, distinct from the current implementation, which supports brand template overrides but does not enforce agreement-derived selection. **Applicability predicate.** A Wild Packs agreement is *applicable* to a host when the host org has an `active`, in-window (`effective_from <= today <= end_date`) host agreement whose `engine_agreement_code` links a published Agreement Library entry of type `host_agreement` whose brand scope is Wild Packs (see Agreements Engine → Host Agreement Library Linkage). The library linkage's brand scope — not any free-text field, portal setting, or per-send parameter — is the single authoritative brand input. **Resolution rule (server-authoritative, deterministic).** - **Resolve at enqueue, snapshot for delivery.** The dispatcher resolves the brand once per **enqueue** — every event that produces a new outbox row, whether triggered immediately, by the Document Expiry / Participant Digest / Reservation Countdown scanners, by a daily digest rollup, or by an invitation **resend** (`resend=true`, which enqueues a *new* outbox row and resolves the then-applicable agreement brand fresh) — and stamps `resolved_brand_id` plus the selected `template_id`/`template_version` onto that row. Only **delivery retries of the same outbox row** reuse the recorded snapshot: retries never re-resolve, so a retried send is idempotent and can never flip brand mid-delivery, while a resend after an agreement change correctly picks up the new brand. - **Renewals and agreement changes.** Re-resolution happens per enqueue: a send enqueued after a renewal or agreement change uses the successor agreement's brand; a send already enqueued keeps its snapshot. The `host.operating_agreement.renewed` notice itself renders in the successor agreement's brand. - **Inherited agreements.** A child host org whose governing agreement is inherited from its parent (per the Parent/Children governance inheritance model) resolves brand from that inherited agreement, identically to a directly held one. - **Conflicting agreements.** If more than one applicable agreement exists and all resolve to the same brand, that brand wins. If they resolve to *different* brands, resolution fails closed to the default (non-brand) template and the outbox row is flagged `brand_resolution = ambiguous` for staff review — the system never guesses between brands and never leaks one brand's content into another's send. - **Missing or legacy linkage.** Hosts with no applicable agreement, grandfathered free-text agreements (legal content still in `partner_agreements.terms` with no library brand scope), or `host_agreement_migrated_*` rows whose brand scope is absent resolve to the existing correct brand where one is already recorded, otherwise the default template. A host is never switched *to* Wild Packs without a linked Wild Packs library agreement. **Required brand surfaces.** The resolved brand governs all of: sender identity (from-name and from-address), subject line and body copy, visual treatment (logo, colors, header/footer chrome), footer legal and support/contact details, and all portal deep links (Wild Packs hosts land on Wild Packs-branded portal URLs). **Audit.** Each outbox row records the brand snapshot as first-class columns — `resolved_brand_id`, `brand_source_agreement_id`, `resolved_template_id`, `resolved_template_version`, and `brand_resolution` (`resolved` | `default_fallback` | `ambiguous`) — and the audit trail copies `resolved_brand_id`, `brand_source_agreement_id`, and `brand_resolution` alongside its existing `template_id`/`template_version` fields. The exact column definitions, types, and nullability live in the Outbox Table Schema and Audit Trail Schema sections below; they are marked there as required contract, not yet implemented. This makes every send's brand decision explainable after the fact and retry idempotency verifiable. > **Staff-only alerts are excluded.** > > Internal, staff-targeted events — `host.ev_removed` (Account Manager relay) and `host.account_manager_assigned` (sponsor staff, in-app) — are delivered to Globus staff, not to the host, and always use internal staff branding even when the triggering host holds a Wild Packs agreement. The exclusion is by audience, not by event-name prefix. See Employer Notifications → Brand Resolution for the per-trigger applicability of this rule across the full host catalog. ### Participant Profile PDF Attachments Every host email that references a single, named participant carries that participant's profile PDF as an email attachment, so the host can review the candidate without logging into the portal first. In the current host event catalog those are host.participant_matched, host.participant_recommended, and host.visa_denied. The multi-record rollups ( host.participant_recommended_digest and host.reservation_expiring) intentionally carry no attachment — they list multiple participants and rely on portal links instead. See the Employer Notifications catalog for the per-event copy. What the participant profile PDF is. It is a single-candidate summary document rendered from the participant's record on the applicant-lifecycle / matching-engine surface (the same data the host would see on the candidate-review screen in the portal: identity, program, job-order fit, availability, and the review-relevant profile fields). It is generated on demand by the participant-profile runtime (see Implementation Follow-up ), not stored on this docs-site repo. The origin used for `document_url` is resolved when the URL is minted: an explicit base URL passed by the caller, else the `HUB_PUBLIC_BASE_URL` environment variable, else `PUBLIC_BASE_URL`, else `PORTAL_BASE_URL`, with trailing slashes trimmed. If none is set the URL is a relative path (`/api/partner/host/participant-profile/` plus the participant id, with `exp` and `sig` query parameters); it is still signature-verifiable, but the relay fetches it from outside the Hub, so each environment that sends host emails with attachments should set `HUB_PUBLIC_BASE_URL`. How it is referenced in the outbox payload. The attachment is not inlined as bytes in the notification row. Instead, the event's payload.attachments[] array carries a document reference that the n8n / HubSpot relay resolves and fetches at send time. Each entry has the following shape: | Field | Type | Description | | --- | --- | --- | | kind | "participant_profile_pdf" | Discriminator naming the attachment kind. The relay uses this to pick the participant-profile PDF resolver. | | participant_id | uuid NOT NULL | The participant the PDF describes. Source of truth is the applicant-lifecycle / matching-engine record. Exactly one participant per per-participant host email. | | document_url | text NOT NULL | Resolver URL the relay GETs to obtain the rendered PDF at send time (signed, short-lived). The runtime that serves this lives in the participant-profile / Host-Portal repo — see Implementation Follow-up. | | filename | text | Suggested attachment filename, e.g. participant-profile-participant_id.pdf. Relay may override for client compatibility. | | content_type | "application/pdf" | MIME type of the resolved attachment. Always application/pdf for this kind. | > **The reference is resolved at send time, not enqueue time.** > > Storing a document reference (not the bytes) keeps the outbox row small and ensures the host always receives the latest rendered profile. If the resolver cannot produce the PDF at send time, the relay still delivers the email body (which links the host back into the portal) and records the attachment failure on the notification row — specifically by setting attachment_status = failed (with detail in attachment_error) while leaving the send/delivery state untouched — rather than blocking the send. That field is carried on both the Audit Trail Schema and the Outbox Table Schema , and is populated only for the three per-participant events that carry a PDF ( host.participant_matched, host.participant_recommended, host.visa_denied). ### Implementation Follow-up *Status: built in BackOfficePrototype (the Hub). `server/services/hostEmailRelay.ts` reads payload.attachments[], fetches the PDF and attaches it; `server/routes/hostPortal/participantProfilePdf.ts` renders and serves it at the signed URL; attachment_status / attachment_error are written by the delivery-status route. The cross-repo ownership list below predates this.* This page is the contract for the attachment. The runtime that actually generates the participant profile PDF and physically attaches it to outbound email does not live in this docs-site repo. Per the cross-repo routing rule, that work is owned by, and must be filed against, the repos below — not as a task on this docs-site repo: - Participant-profile PDF generation: rendering the single-candidate profile PDF from the participant record and serving it at the signed document_url resolver — owned by the participant-profile / Host-Portal runtime repo. - Email attachment wiring: reading payload.attachments[], fetching the resolved PDF, and attaching it to the outbound message — owned by the HubSpot / n8n relay repo. - Attachment-failure handling: populating attachment_status / attachment_error on the notification row (set failed on a resolver/fetch failure, attached on success) without blocking body delivery — same relay repo. > **Docs-site side: contract only; runtime population is cross-repo.** > > The only obligations on this repo are documentation (this contract). The attachments[] reference itself is a field inside the existing payload jsonb column. The attachment_status / attachment_error columns are part of the audit/outbox contract documented below, but actually writing them at send time is owned by the relay / participant-profile runtime, not this repo. ## Notification Audit & Delivery Tracking Every notification sent through Globus is tracked in an immutable audit trail. This provides full visibility into what was sent, to whom, when, through which channel, and whether it was successfully delivered. ### Audit Trail Schema | Field | Type | Description | | --- | --- | --- | | notification_id | UUID | Unique identifier for this notification instance | | event_type | text | The event that triggered this notification | | template_id | UUID | Template used to render this notification | | template_version | integer | Version of the template at time of rendering | | recipient_id | UUID | Recipient entity ID (applicant, partner, host, or staff) | | recipient_type | enum | applicant \| partner \| host \| staff | | channel | text | email \| sms \| push \| in_app \| webhook (values accepted by the delivery-status route) | | rendered_subject | text | Final rendered subject line (after variable interpolation) | | delivery_status | text | pending (default) \| queued \| sent \| delivered \| failed \| bounced | | sent_at | timestamp | When the notification was sent to the delivery channel | | delivered_at | timestamp | When delivery confirmation was received (if available) | | error_details | jsonb | Error information for failed deliveries | | attachment_status | enum | not_applicable \| pending \| attached \| failed. Outcome of resolving and attaching the participant profile PDF (see Participant Profile PDF Attachments). Independent of delivery_status: a row can be delivery_status=delivered with attachment_status=failed, meaning the body was delivered but the PDF could not be attached. not_applicable for any event that carries no attachment (the rollups and all non-participant events). | | attachment_error | jsonb | Resolver/fetch error detail when attachment_status = failed (e.g. resolver HTTP status, document_url, participant_id). Null otherwise. Distinct from error_details, which records send/delivery-channel failures. | | resolved_brand_id | UUID | **Required contract (not yet implemented).** Copied verbatim from the outbox row's brand snapshot — the brand this notification actually rendered under. Null when the send used the default (non-brand) template or the audience is not host-facing. | | brand_source_agreement_id | UUID | **Required contract (not yet implemented).** The agreement whose Agreement Library brand scope produced resolved_brand_id, so every host email's brand decision is explainable after the fact. Null exactly when resolved_brand_id is null. | | brand_resolution | enum | **Required contract (not yet implemented).** resolved \| default_fallback \| ambiguous — the enqueue-time brand decision outcome, copied from the outbox row. Together with template_id/template_version (above), this makes retry idempotency verifiable: every retry row for the same notification_id must show identical brand snapshot values. Null for non-host-facing audiences. | | created_by | text | System component or job that created this notification | ### Delivery Status Tracking pending / → / sent / delivered sent / → / bounced (invalid address) pending / → / failed (channel error) / retry / sent **Manual retry of a failed item.** `POST /api/admin/notifications/:id/retry` (`notifications.manage`, audited as `notification.retry`) re-queues one item from the failed queue (`GET /api/admin/notifications/failed-queue`, `audit_log.view`, `limit` 1 to 200, default 50). The body must name `source`: `notification_delivery`, `ops_events` or `webhook_deliveries`. It returns 404 `NOT_FOUND` when no such failed item exists, and 422 `NOT_RETRYABLE` (top-level `error.code`) when the source is `notification_delivery` and the delivery row has no linked event, because there is nothing to re-dispatch. Otherwise a `notification_delivery` retry resets the linked ops event and the delivery to pending, an `ops_events` retry resets the event to pending, and a `webhook_deliveries` retry sets the delivery to failed with its attempt count cleared. > **Delivery tracking enables compliance auditing.** > > For government-related notifications (SEVIS deadlines, compliance reminders), the audit trail provides evidence that the sponsor fulfilled its communication obligations. This is critical for Department of State compliance reviews. > **Attachment status is a separate axis from delivery status.** > > delivery_status above tracks only whether the email body reached the recipient. The participant profile PDF carried by the three per-participant host events is tracked independently on attachment_status ( not_applicable → pending → attached / failed). A row can therefore be delivered with attachment_status = failed — the body went out, the PDF did not. See Participant Profile PDF Attachments . ## Delivery Acknowledgement Loop Outbound notification delivery is not fire-and-forget. After every successful HubSpot send (transactional email, SMS, or WhatsApp), n8n POSTs a delivery confirmation back to Globus so the originating notification row can transition out of sent into delivered (or to bounced / failed on a negative ack). This closes the loop with the same notification row that the Delivery Status Tracking section describes — the acknowledgement is what drives that state machine forward. Globus emits notification / → / n8n workflow / HubSpot send API / HubSpot delivery webhook / n8n router / PATCH /api/notifications//delivery-status / Globus updates notification row n8n is the only authorised caller of the delivery-status endpoint. HubSpot's delivery webhooks terminate inside n8n; n8n is responsible for normalising HubSpot's per-channel payloads (email email.delivered / email.bounced, SMS sms.statusUpdate, WhatsApp message.statusUpdate) into the canonical Globus enum before calling back. The notification id carried in the PATCH URL is the same UUID Globus emitted to n8n on the original outbound event — n8n echoes it back unchanged. ### PATCH /api/notifications//delivery-status Service-to-service endpoint called by n8n to update a notification's delivery state. A repeat call for the same notification and channel updates the existing delivery row instead of creating a second one. Request PATCH /api/notifications//delivery-status X-API-Key: Content-Type: application/json status / delivered / channel / email / providerMessageId / hs_msg_abc123 / errorMessage Accepted values The body takes status queued | sent | delivered | failed | bounced and channel email | sms | push | in_app | webhook. The delivery row for that channel is created (HTTP 201) or updated (HTTP 200); an unknown notification id returns 404. - No transition check: the endpoint does not validate the previous state and does not return 409 invalid_transition. It writes the supplied status (sent_at is stamped for sent and delivered, delivered_at for delivered). Auth Session or API-key auth with the integrations.comms scope (isAuthenticatedOrApiKey). There is no dedicated notification:delivery-status:write service principal. Error envelope error > **No Idempotency-Key header.** > > The endpoint does not read an Idempotency-Key header. Idempotency comes from the upsert on (notification, channel): a concurrent duplicate insert hits the unique index and converges onto the existing row. ### SendGrid Delivery Idempotency Claim The n8n event pipeline is at-least-once: Globus's event retry worker redrives any ops event stuck in processing for more than 5 minutes, so the same eventId can reach the SendGrid Delivery workflow more than once. To stop a redrive from re-sending the same email, Globus exposes a durable claim endpoint intended to be called before contacting SendGrid. At development HEAD the canonical [Globus] Delivery: SendGrid Email workflow (xxZgUmutMOKLlrsA) does not call it - its path is Normalize Email Input, Skip Email?, Send Email via SendGrid, then HubSpot log - so a redrive can still re-send until a workflow adopts the claim. The one workflow in the n8n-workflows repository that does call the claim is the dev-legacy copy of [Globus] Delivery: SendGrid Email (`1JNLQbaPPjwLWdhs`): its Claim Idempotency Key node posts `eventId` and `messageType` (the event type), and its Evaluate Claim node fails closed, treating anything other than `data.claimed === true` (a `claimed: false`, an error response or a timeout) as a possible duplicate and suppressing the send. A missed send is recoverable through the retry worker redrive; a duplicate email is not. Ledger table notification_dispatch_locks: columns event_id (text), message_type (text), claimed_at (timestamp, default now()); primary key (event_id, message_type). The insert is the lock — only the first claim for a given event + message type succeeds. message_type is part of the key so one event can fan out to more than one notification. POST /api/n8n/notifications/claim — body {eventId, messageType} (both non-empty strings, trimmed; 400 on an invalid body). Auth: session or API key with the integrations.events permission. The handler does INSERT ... ON CONFLICT (event_id, message_type) DO NOTHING and returns {eventId, messageType, claimed}. claimed = true means this caller owns the send; claimed = false means a prior claim exists and the caller must skip the send. Duplicate claims are logged as a warning, and each successful claim writes an audit row (action notification.delivery_claimed, entity type notification_dispatch_lock). Two further machine callbacks record the outcome of a branded SendGrid delivery (see [Communications Administration & Campaigns](/docs/communications-administration)); both use the `integrations.events` API-key scope. `POST /api/n8n/deliveries/:deliveryId/acceptance` records that the provider accepted the message (HTTP 202), with optional `providerMessageId` and `providerRequestId`; it answers `recorded: false` when a provider event already moved the delivery further or it is terminal, because acceptance never walks a delivery back. `POST /api/n8n/deliveries/:deliveryId/dispatch-result` records an unconfirmed or failed send with `outcome` (`ambiguous` or `failed`) and a `reasonCode` (`timeout`, `no_message_id`, `transport_error` or `provider_rejected`); the Hub stores a fixed sentence per code and never provider text. Each writes an audit entry only when the outcome is recorded (`notification.dispatch_accepted`, `notification.dispatch_ambiguous`, `notification.dispatch_failed`). An invalid body is 400. > **Dispatcher routing note.** In the Dispatcher: Event Router routing table, domains with no live handler are marked noOp (acknowledged and skipped, no handler invoked). step.* is noOp because the old push handler was confirmed dead and the portal pulls step data instead; workflow.* is noOp because Globus delivers workflow.published directly to the portals via a signed HMAC webhook. ## Inbound Reply Path Replies from applicants (or any HubSpot Contact in scope) flow back into Globus through the same n8n router used for delivery acknowledgements. HubSpot emits a conversation.message_received event when an inbound message lands on a tracked thread; n8n correlates the thread to a Globus application via the HubSpot Application record metadata and writes a single activity-log entry back into Globus. Applicant replies (email / SMS / WhatsApp) / → / HubSpot conversation.message_received / n8n router / POST /api/integrations/hubspot/communication-reply (recorded as audit action integration.hubspot.inbound_reply on the applicant) / Globus activity log | Field | Type | Description | | --- | --- | --- | | entity_type | literal 'applicant' | Always the applicant record matched from the HubSpot conversation | | entity_id | uuid | Globus applicant id resolved from `hubspotContactId` or email | | action | literal 'integration.hubspot.inbound_reply' | Audit action for an inbound applicant reply (`.idempotent` and `.not_found` variants for replays and resolver misses) | | channel | enum | whatsapp \| email \| sms — which HubSpot channel carried the reply | | hubspot_conversation_id | text | HubSpot conversation/thread id, persisted for round-tripping back into the HubSpot timeline | | hubspot_message_id | text | HubSpot id for this specific inbound message; doubles as the idempotency key | | received_at | timestamptz | Timestamp HubSpot recorded for the inbound message | | preview | text | Short snippet of the reply for activity-log rendering (full body stays in HubSpot) | > **HubSpot remains the message body of record.** > > Globus persists only the activity-log envelope (who, when, which channel, message id). The full message body, attachments, and rendered thread continue to live in HubSpot. Staff click through from the activity-log row to read the full conversation in HubSpot. ## Channel Selection For applicant-facing notifications, n8n picks the delivery channel per-recipient using a fixed preference order: WhatsApp > email > SMS. The decision is keyed off opt-in state on the HubSpot Contact record, not Globus-side toggles, so the source of truth for "can we contact this person on channel X?" stays in HubSpot's preference centre. | Order | Channel | HubSpot Opt-In Field | Selected When | | --- | --- | --- | --- | | 1 | WhatsApp | whatsapp_opt_in = true AND phone present | Always preferred when opted in — highest read-rate channel for applicants | | 2 | Email | email_opt_in = true (HubSpot subscription status: subscribed) | WhatsApp unavailable or not opted in | | 3 | SMS | sms_opt_in = true AND phone present | Neither WhatsApp nor email opted in | > **Fallback when no channel is opted in** > > If the Contact has opted out of all three channels, n8n does not attempt delivery and immediately PATCHes the notification to failed with error_details.reason = "no_opted_in_channel". The originating Globus event is logged on the application's activity feed with action communication.outbound_suppressed so staff can see the recipient is currently unreachable. No retry is scheduled — the recipient must update their HubSpot preferences before any further automated comms will be attempted. ## Staff-Initiated Communication Beyond system-triggered notifications, staff also need to send ad-hoc messages to applicants. Rather than building a parallel composer inside Globus, staff work directly on the HubSpot Application card — which is the same Contact/Application record n8n correlates inbound replies against. Globus metadata is synced onto that HubSpot record (application id, stage, assigned recruiter, queue, last system event) so staff have full lifecycle context without switching tabs. - Staff opens the applicant's HubSpot Application card and sees the synced Globus metadata block. - Staff picks a channel (WhatsApp / email / SMS) from HubSpot's composer — manual channel selection on staff sends overrides the automated preference rule above. - HubSpot sends the message and records it on the conversation thread. - When the applicant replies, the reply lands on the same conversation thread, which fires conversation.message_received and routes back into the Globus activity log via the Inbound Reply Path above — staff-initiated and system-initiated threads use the same return path. - The activity-log row carries the same `integration.hubspot.inbound_reply` action; the original outbound staff message is not duplicated into Globus — staff read it from the HubSpot timeline. > **No second composer in Globus.** > > Globus does not render a message composer for staff. All outbound applicant messaging — system-triggered or staff-initiated — is authored and sent inside HubSpot. Globus contributes the metadata block and consumes the inbound replies; HubSpot owns send, history, and the conversation UI. ## Notification Outbox Data Model All outbound notifications are persisted in a durable outbox table before delivery. This ensures no notifications are lost if the automation layer is temporarily unavailable. See the Durable Queue Design on the automation page for the infrastructure details. ### Outbox Table Schema | Column | Type | Description | | --- | --- | --- | | id | uuid PK | Unique notification identifier | | event_type | text NOT NULL | Notification trigger event (e.g., application.approved, document.deadline_missed) | | audience | text NOT NULL | Target audience: applicant, partner, host, internal | | channel | text NOT NULL | Delivery channel: hubspot_email, hubspot_sms, sendgrid_email, in_app, slack | | recipient_id | uuid NOT NULL | FK to the recipient entity (applicant, partner user, staff user) | | recipient_email | text | Denormalized recipient email for delivery (nullable for in-app/Slack) | | payload | jsonb NOT NULL | Notification content payload — template ID, merge fields, metadata, and (for per-participant host emails) an attachments[] array of document references. See Host Notification Templates → Participant Profile PDF Attachments. | | priority | text NOT NULL DEFAULT 'normal' | Delivery priority: low, normal, high, critical | | status | text NOT NULL DEFAULT 'pending' | Current status: pending, processing, delivered, failed, skipped | | retry_count | integer NOT NULL DEFAULT 0 | Number of delivery attempts (max retries configurable per channel) | | max_retries | integer NOT NULL DEFAULT 3 | Maximum retry attempts before marking as failed | | idempotency_key | text UNIQUE | Prevents duplicate sends — composite of event_type + entity_id + timestamp window | | scheduled_at | timestamptz | Scheduled delivery time (null = immediate). Used for quiet hours and digest batching | | delivered_at | timestamptz | Timestamp of successful delivery (null if not yet delivered) | | failed_at | timestamptz | Timestamp of final failure after all retries exhausted | | error_message | text | Last error message from delivery attempt (for debugging) | | attachment_status | text NOT NULL DEFAULT 'not_applicable' | Outcome of resolving + attaching the participant profile PDF: not_applicable, pending, attached, failed. Orthogonal to status — the body can be delivered (status=delivered) while the PDF failed to resolve (attachment_status=failed). Only the three per-participant host events (host.participant_matched, host.participant_recommended, host.visa_denied) ever leave not_applicable; rollups and all other events stay not_applicable. See Participant Profile PDF Attachments. | | attachment_error | text | Resolver/fetch error detail when attachment_status = failed (e.g. resolver HTTP status + document_url). Null otherwise. Separate from error_message, which records send-channel failures. | | resolved_brand_id | uuid | **Required contract (not yet implemented).** Brand snapshot resolved at enqueue from the host's applicable agreement's Agreement Library brand scope (see Agreement-Derived Brand Resolution (Wild Packs)). Null for non-host-facing audiences and for rows enqueued when brand_resolution = default_fallback or ambiguous (the default template carries no brand). Delivery retries of this row MUST reuse this value; a resend enqueues a new row and re-resolves. | | brand_source_agreement_id | uuid | **Required contract (not yet implemented).** FK to the partner_agreements row whose library brand scope produced resolved_brand_id. Null exactly when resolved_brand_id is null. | | brand_resolution | text NOT NULL DEFAULT 'default_fallback' (host-facing rows) | **Required contract (not yet implemented).** Outcome of the enqueue-time brand decision: resolved (agreement-derived brand applied), default_fallback (no applicable branded agreement / legacy free-text or migrated linkage — default template used), ambiguous (conflicting applicable brands — failed closed to default and flagged for staff review). Not populated (null) for non-host-facing audiences. | | resolved_template_id | uuid | **Required contract (not yet implemented).** The template selected for this send, promoted out of payload to a first-class column so retries and audit can prove which template/brand variant rendered. Paired with resolved_template_version. | | resolved_template_version | integer | **Required contract (not yet implemented).** Version of resolved_template_id at enqueue time. Retries MUST render this exact version; a resend's new row records its own freshly selected version. | | created_at | timestamptz NOT NULL DEFAULT now() | When the notification was enqueued | | updated_at | timestamptz NOT NULL DEFAULT now() | Last status update timestamp | ### Status Lifecycle pending / → / processing / delivered processing / → / failed (retry) / delivered processing / → / failed (max retries) / dead letter pending / → / skipped (outdated/duplicate) > **The notification outbox is the single source of truth for all outbound communication state.** > > Every notification — regardless of channel or audience — passes through this outbox before delivery. This enables full auditability, retry logic, and catch-up behavior after automation layer outages. ## Scheduled Background Scanners Globus runs periodic background scanners that detect time-based conditions and emit events into the notification pipeline. These scanners are defined in notificationJobs.ts and run on configurable schedules independent of user actions. | Scanner | Trigger | Events Emitted | | --- | --- | --- | | Document Expiry Scanner | Periodic scan | host.document_expired, host.document_expiring_soon | | Host Org Document Expiring-Soon Scanner (job `host_document_expiring_soon`) | Periodic scan of approved host-organization compliance documents. Warning tiers (days before expiry) come from the `host_org_doc_warning_tier_days` app setting (JSON array), else the optional `HOST_ORG_DOC_WARNING_TIERS_DAYS` env var (comma-separated), else 30, 14 and 7. Values must be positive integers; they are de-duplicated and sorted widest first. A document emits once per tier, and hosts that opted out are skipped. Only the 7-day tier resolves a host recipient; the wider tiers go to the Account Manager only | host.org_document_expiring_soon | | Reservation Countdown Scanner | Periodic scan — emits the 24h-before-TTL warning only | host.reservation_expiring | | Participant Digest Scanner | Periodic scan | host.participant_recommended_digest | > **Scanners emit events, not notifications.** > > Background scanners detect conditions and emit domain events. The notification pipeline then determines which templates, channels, and audiences apply. This keeps the scanning logic decoupled from delivery concerns. > **Reservation Countdown ≠ Reservation Expiry Sweeper** > > The Reservation Countdown Scanner above only emits the host.reservation_expiring 24h-warning notification. The actual Active → Expired transition (status flip + capacity release + audit row) is performed by a separate 15-minute sweeper that does not use the notification pipeline at all. See Batch Jobs → reservation-expiry-sweeper and Quota Model → Reservation Expiry Sweeper for why these are intentionally split. ## n8n Delivery Stats The n8nDeliveryStats.ts service provides a delivery health dashboard with aggregated metrics on event delivery to n8n webhooks. It includes entity name resolution so that delivery records show human-readable names alongside IDs. Aggregates delivery success/failure rates across all event types and webhook endpoints / Entity name resolution enriches delivery records with applicant names, program names, and organization names / Surfaces delivery latency metrics (time from event creation to successful delivery) / Powers the admin delivery health dashboard at /admin/n8n-workflow-health / Tracks retry counts and dead-letter queue depth for operational visibility ## Webhook Dispatcher The webhookDispatcher.ts service delivers events to registered webhook URLs for external subscribers. All webhook deliveries are signed with HMAC signatures for payload authenticity verification. Delivers events to subscriber-registered webhook URLs as HTTP POST requests / Each payload is signed with an HMAC-SHA256 signature using the subscriber's secret key / Subscribers verify payload authenticity by recalculating the HMAC and comparing signatures / Failed deliveries are retried with increasing backoff (delays step from 1 second up to 30 minutes; after 5 attempts the delivery is dead-lettered) / Delivery status is tracked per-subscriber per-event for audit and debugging / Webhook subscriptions are managed via the /api/admin/webhooks/subscriptions endpoints --- --- title: "Communications Administration" slug: communications-administration section: Globus Core status: stable description: "How Globus staff configure SendGrid trust, govern branded email templates, prove them before approval, and run unattended nurture campaigns — the administration layer that sits in front of the [Notification & Communication Model](/docs/n…" last-updated: 2026-10-07 --- # Communications Administration How Globus staff configure SendGrid trust, govern branded email templates, prove them before approval, and run unattended nurture campaigns — the administration layer that sits in front of the [Notification & Communication Model](/docs/notification-model) and the [Host Notifications](/docs/employer-notifications) catalogue. > **Shipped Sep 14 2026 (Globus 30-day window Aug 17 – Sep 15 2026)** > > Everything on this page is grounded in the merged BackOfficePrototype commits `8eebde8cb` (SendGrid settings + template registry screens), `6786a00dd` (campaign management/detail surface), `75759689c` (nurture campaign engine), `7bd1e50a5` (branded preview / test send through the n8n adapter) and `ff11bef00` (atomic evidence stamp + operation announcement), and the staff guides those commits seeded. Later merged work on the same surfaces (through Sep 29 2026) is described in the sections below: in-Globus template authoring and rendered preview, registry-driven SendGrid template creation, brand sender-identity management, reversible suspension, revisions of live mappings, audience as part of a template's identity, the administrator self-approval override, campaign authoring, the applicant-profile Campaigns tab and email timeline, the branded welcome email and the branded invite resend. The HubSpot handoff mapping remains outside this page (proposed, not shipped). ## Overview Five pieces make up the administration layer: | Piece | What it owns | Route / surface | | --- | --- | --- | | **SendGrid Settings** | The values Globus needs to *trust* SendGrid — today the Event Webhook verification key — plus the per-environment test-send domain allow-list. | `/admin/sendgrid-config` | | **Brand sender identities** | The verified from-address a brand (or one of its programmes) sends from. Every branded send and every test send needs one. | Brand Settings tab — `/api/brands/:idOrSlug/sender-identities` | | **Branded Template Registry** | Which approved provider template version serves which brand / programme / audience / event (or campaign step), with a governed lifecycle. Copy is authored and previewed in Globus; the SendGrid template is created and versioned from the registry. | `/admin/email-templates` | | **Template operations** | Proving a template under review: one rendered preview (rendered in Globus) and one allow-listed test send (performed by the n8n adapter), both recorded as approval evidence. | `/api/comms/templates/:templateId/...` | | **Nurture campaign engine** | Catalogue-driven, email-only cadences (e.g. the Wild Packs incomplete-profile nurture) with staff authoring, a readiness gate before enabling and a recorded stop reason on every enrolment. | `/api/comms/campaigns` | The design principle throughout: **Globus is the authority, n8n is the courier.** Globus verifies delivery-event signatures, decides which template version is routable, pins what a test send must prove, and decides who is enrolled in a campaign. n8n performs sends and reports outcomes; it never decides. ## Staff Surfaces & Permissions | Permission | Grants | | --- | --- | | `comms.template.view` | Read the template registry, lifecycle history, the kept preview render and recorded operations / evidence. | | `comms.template.author` | Register, edit and delete-if-untouched drafts, author content, open a revision of a live mapping, submit for review, render previews and request test sends. | | `comms.template.approve` | Approve (not your own — see the override below), activate, roll back, suspend, restore and retire template mappings. | | `comms.campaign.view` | Read the campaign catalogue, cadence, enrolment counts and readiness report. | | `comms.campaign.manage` | Create and edit campaign definitions, and enable and disable a campaign. | | `brands.manage` | Register, verify, revoke and deactivate a brand's sender identities (within the caller's brand scope). | | `applicant.profile.view` | Read the Campaigns tab and the non-campaign email entries on an applicant's Communications card. | SendGrid Settings is an administrator screen; every write requires a reason and is audited against the acting account. All routes are staff-only — none of these surfaces are exposed to the Applicant, Partner or Host portals. ## SendGrid Settings Shipped Sep 14 2026. `/admin/sendgrid-config` records the **Event Webhook verification key** — the elliptic-curve public key from SendGrid's Signed Event Webhook settings. Globus, not n8n, is the signature authority for delivery events: every event that arrives is checked against this key and an event that does not verify is refused before anything reads it. - **Per environment.** Development, QA and production each talk to a different SendGrid configuration, so the key is stored as data in the environment rather than baked into a release. Setting it changes only the current environment. - **Paste whatever you have.** SendGrid shows the key as bare base64; a PEM block or wrapped copy carries the same bytes and the header, footer and line breaks are stripped. If the value cannot be parsed as an EC public key the save is refused with the reason shown against the field and nothing is written (pasting an *API* key fails for exactly this reason). - **Reason-audited.** A reason is required before saving; the change is written to the audit log against your account, and the screen shows who last set each value and when — including database writes made outside the screen. - **"Not set" is silent, not safe.** With no key configured nothing visibly fails; delivery states simply stop moving. Check this screen first when delivery statuses stop updating. - **Test-send domain allow-list.** The same screen holds the per-environment list of **domains** a template test send may target. With none configured every test send is refused — there is deliberately no built-in default. - **Server credentials (environment).** Direct transactional email from the Hub (`server/sendgrid.ts`) reads `SENDGRID_API_KEY` and `SENDGRID_FROM_EMAIL`; both must be set for the environment values to be used. If either is missing the server falls back to Replit connector credentials when that runtime is present, and otherwise sending throws `SendGrid not configured`. A message may carry its own optional `from` sender (an `email` and an optional `name`); when it omits one, the environment sender `SENDGRID_FROM_EMAIL` is used. These are separate from the Event Webhook key above and from the branded path, which sends through the n8n adapter. A send is also suppressed silently (it returns false, not an error) while the `notification_emails_enabled` feature flag is off. - **API.** `GET /api/admin/sendgrid/config` lists each editable setting with whether it is configured, its value when it is not a secret, and who last set it and when; `PUT /api/admin/sendgrid/config` saves one or more settings. Both need `system_settings.manage`. The PUT body is `reason` (3 to 500 characters) and `configs` (1 to 50 entries of `key` and `value`); every entry is validated before any is written, an empty value clears the setting, and a secret value is never returned or recorded in the audit payload. Failures are `400` with a flat body whose `code` is `VALIDATION_ERROR` for a malformed body, `UNKNOWN_CONFIG_KEY` for a key outside the editable catalogue (`details.editableKeys` lists the valid keys), or `INVALID_CONFIG_VALUE` when a value fails that setting's validation (`details.key` names it). ### Event Webhook Destination URL — Where SendGrid Must Point This screen configures how Globus **verifies** an incoming SendGrid event; it does not configure where SendGrid **sends** that event. The destination URL is a separate setting, made directly in the SendGrid account's own Mail Settings → Event Webhook (or via `PATCH /v3/user/webhooks/event/settings`), and it must point at n8n's raw SendGrid event receiver workflow — never at a Globus URL directly. n8n forwards the raw bytes and signature header untouched so Globus's own ECDSA verification above still applies to the original SendGrid signature. | Environment | Event Webhook destination URL | | --- | --- | | Development | `https://development2.n8n.allianceabroad.com/webhook/globus-sendgrid-events` | | Sandbox | `https://sandbox.n8n.allianceabroad.com/webhook/globus-sendgrid-events` | | QA | `https://qa.n8n.allianceabroad.com/webhook/globus-sendgrid-events` (n8n path convention — confirm against the live QA workflow before relying on this) | | Production | `https://n8n.allianceabroad.com/webhook/globus-sendgrid-events` (n8n path convention — confirm against the live Production workflow before relying on this) | > **The `globus-` prefix is easy to drop — and dropping it fails silently.** A 2026-09-16 incident found Development's Event Webhook pointed at `.../webhook/sendgrid-events` (missing the `globus-` prefix the live n8n receiver workflow actually registers). SendGrid received a `404` on every delivered/opened/clicked event and gave up — no alert, no visible failure anywhere in Globus, because the events never reached the signature-verification step this screen configures. The email itself still sent and could still be opened and clicked normally; only the engagement callback loop back into Globus was broken, so Globus's delivery/engagement state for those messages never advanced. **If delivery or engagement status looks frozen for a message that otherwise sent fine, check this URL first** — `GET /v3/user/webhooks/event/settings` against the environment's SendGrid account — before assuming an n8n or Globus code bug. Fixed and confirmed live end-to-end (webhook receipt → Globus signature verification → 200 accepted) the same day; see [n8n-workflows PR #475](https://github.com/Alliance-Strategies/n8n-workflows/pull/475). n8n owns the receiver workflow itself (`[Globus] Delivery: SendGrid Event Webhook Receiver`) and the exact registered path; see [Automation (n8n)](/docs/automation#n8n-webhook-config) for how n8n's own webhook routing and instance hostnames are configured. ## Brand Sender Identities `email_sender_identities` holds the from-address and from-name a branded send uses. It is written from the brand page, where the rest of a brand's configuration already lives: a sending section on the brand's Settings tab shows the brand's from-address and from-name, its verification state, any programme-scoped overrides, and the add / mark-verified / revoke actions, plus a "what each programme will use" table so a reader can see which address a given programme's templates will actually send from. Retired addresses are listed separately rather than disappearing. | Staff route | Method | Purpose | | --- | --- | --- | | `/api/brands/:idOrSlug/sender-identities` | GET | The brand's identities and the per-programme resolution. | | `/api/brands/:idOrSlug/sender-identities` | POST | Register an address. Always created **unverified**; omit `programId` for the brand-wide address, set it to scope the address to one programme. | | `/api/brands/:idOrSlug/sender-identities/:identityId/verify` | POST | Record that the address is verified (optionally with the provider sender id). | | `/api/brands/:idOrSlug/sender-identities/:identityId/revoke` | POST | Mark the provider authentication as no longer trusted. | | `/api/brands/:idOrSlug/sender-identities/:identityId/deactivate` | POST | Retire the address without saying anything about the provider. | - **Gated by `brands.manage`, scoped to the caller's brands.** An out-of-scope brand answers `404`, the same as a brand that does not exist, and scope is resolved before any lifecycle check so a foreign id cannot learn a brand's state from the refusal. - **Retired brands are read-only.** A write to an inactive brand is refused unless a system administrator supplies the same recorded-reason override the rest of the brand page uses (a request-body envelope `override: { confirmedBy, reason }`, both non-empty strings; without it the write is refused with 409 `BRAND_INACTIVE_READ_ONLY`); an overridden write is audited as `…_while_inactive`. - **Every mutation needs a stated reason** and is audited with the actor and the before/after projection. - **One active address per scope.** A brand has one active brand-wide address and each programme one active override; the refusals are `BRAND_SENDER_IDENTITY_EXISTS`, `PROGRAMME_SENDER_IDENTITY_EXISTS`, `SENDER_IDENTITY_PROGRAMME_MISMATCH` (programme belongs to another brand), `SENDER_IDENTITY_NOT_FOUND` and `SENDER_IDENTITY_STATE_INVALID`. The programme-over-brand precedence shown on the screen is computed by the same resolver a real send uses. - **Revoking or deactivating an address breaks what is queued against it.** A test send pins its sender when requested; at dispatch the pinned identity is reloaded and the send is refused (`422 PINNED_SENDER_UNUSABLE`) unless it is still active, verified and unchanged. This is deliberate: moving a send to an address nobody authorised would make the approval evidence describe something that never happened. - **Globus never calls SendGrid to check a sender.** Verification is an attestation entered and audited here; the n8n adapter holds the credentials. With no verified sender a test send is refused (`SENDER_IDENTITY_UNAVAILABLE`). ## Branded Template Registry Shipped Sep 14 2026. `/admin/email-templates` is the registry of branded template **mappings**: for a brand, programme, audience, event key (or campaign + step) it records the approved SendGrid template, the approved **provider version**, plus Globus's own content key, version and hash. Filter by brand or by state; the summary line shows the total registered and how many are live. - **The version is pinned, not "whatever is live in SendGrid".** Because the approved provider version and content hash are recorded, a provider-side edit nobody approved is detectable rather than silently served. - **Audience is part of the identity.** A mapping is for an **applicant**, **host** or **partner** reader (default: applicant). The same business event can legitimately reach two audiences with different copy and separate approvals, so audience is one of the columns that make a binding unique. Applicant is the only audience with a recipient rule today; a send addressed to a host or partner is refused by name (`AUDIENCE_RULE_NOT_CONFIGURED`) rather than guessing an address, although host and partner copy can still be authored, reviewed and activated. The delivery header records the audience when the record is written, and the person the send resolved to is recorded against it, so "who was contacted" is answerable later. - **One live mapping per (brand, programme, audience, event, campaign, step).** The database enforces it. Activating a replacement supersedes the previous mapping instead of coexisting with it; activating while another is live returns `ACTIVE_MAPPING_CONFLICT` ("roll that one back first — two cannot be live at once"). A second draft for a binding that already has one waiting is refused (`BINDING_ALREADY_PENDING`). - **One line per mapping.** An amended mapping is listed as one line: the record in force (or, when nothing is in service, the one that was in force last) with its earlier versions filed underneath behind a chevron ("N earlier versions"). Lineage is read from the predecessor pointer alone; an open revision draft, and a suspended successor, keep their own line because they are work outstanding rather than history. Choosing a state in the filter lists records flat, and the registered total still counts every record. - **The provider template is optional at registration.** A registration may be created with no provider template id; the SendGrid template is created at the first content publish (see below) and recorded on the row. A mapping with no provider version cannot be previewed, test-sent or activated. - **Roll back** withdraws the live version and restores its predecessor in its place. The route is `POST /api/comms/templates/:templateId/rollback` (`comms.template.approve`, audited as `comms.template.rolled_back`); it returns the updated registry row and reports refusals through the governance codes below. A mapping with no predecessor cannot be rolled back (`NO_ROLLBACK_TARGET`) — activate a replacement instead. Retiring a live mapping that has a predecessor is refused (`ROLLBACK_AVAILABLE`) unless the caller confirms they mean to leave the event with no branded template. - **Drafts.** A draft (or a mapping still in review) is edited in place; any edit that changes the rendered artifact or the send's identity clears recorded evidence and returns an in-review mapping to draft. A draft whose ledger holds nothing but its own creation can be deleted outright; anything with history is retired instead (`TEMPLATE_NOT_DELETABLE`). ### Authoring Copy in Globus Staff write the email's subject, HTML and plain-text body in Globus (`POST /api/comms/templates/:templateId/content`, `comms.template.author`). Globus performs no provider mutation itself: the authored bytes are handed to the n8n adapter, which creates an **inactive** provider version (and, for a registration with no provider template yet, the template itself) and returns its identifiers, which Globus records on the registration. - **Only while draft or in review**, and against the version the editor read (`TEMPLATE_CHANGED_MEANWHILE` if it moved). A save while in review returns the mapping to draft and clears the recorded preview and test send, because the evidence describes the copy before. - **Tokens must be declared.** Content referencing a merge token the registration does not declare is refused before the adapter is called (`UNDECLARED_TOKEN_IN_CONTENT`); unparseable content is refused as `TEMPLATE_CONTENT_INVALID`. The authoring catalogue (`GET /api/comms/templates/authoring-catalogue`) serves the tokens Globus can supply to the chosen audience and the event keys a send is actually matched on. - **Nothing sends yet.** The new version only becomes sendable once the registration is approved and activated; a send pins the version the registration records. - **Environment-named provider templates.** Every environment authors against the same SendGrid account, so a template is created once as `[DEV] `, `[QA] …` or `[PROD] …` and each added version carries the Globus content version (`… v`); names are trimmed from the end to SendGrid's 100-character limit. - **Adapter errors are named.** `PROVIDER_ADAPTER_NOT_CONFIGURED` (no n8n webhook URL in integration settings) and `PROVIDER_ADAPTER_FAILED` (nothing was changed). ### Template Lifecycle — Only Live Can Send ``` draft ──▶ in_review ──▶ approved ──▶ active (Live) ──▶ superseded ──▶ (re-activate) active │ │ │ │ │ │ └─▶ draft └─▶ draft ├─▶ rolled_back ──▶ draft │ ├─▶ suspended ◀──▶ active (pause / restore) └──────────────────────────────────────── └─▶ retired (terminal, from any state) ``` | State | Label | Meaning | | --- | --- | --- | | `draft` | Draft | Registered, editable, unroutable. | | `in_review` | In review | Awaiting preview + test-send evidence and a second person's approval. | | `approved` | Approved | Signed off. **Still unroutable** — approval is a sign-off, not a switch. | | `active` | Live | The only state that can send. Exactly one per brand / programme / audience / event / campaign / step. | | `suspended` | Suspended | Paused. Mail for the event is refused until an approver restores it. Reversible. | | `superseded` | Superseded | Replaced by a newer live version; kept so a rollback has somewhere to go. Can be re-activated. | | `rolled_back` | Rolled back | Withdrawn; predecessor restored. Returns to draft for rework. | | `retired` | Retired | Terminal. Reachable from every non-terminal state. | Allowed transitions are a single shared map (`TEMPLATE_LIFECYCLE_TRANSITIONS`) imported by both the service and the screen, so the UI never offers a button that would 409 and never calls a template "live" that the service treats as unroutable: `draft → in_review | retired`; `in_review → approved | draft | retired`; `approved → active | draft | retired`; `active → suspended | superseded | rolled_back | retired`; `suspended → active | retired`; `superseded → active | retired`; `rolled_back → draft | retired`. **Suspend and restore.** Suspension (`POST /api/comms/templates/:templateId/suspend`, reason required) is the move with no price: it keeps the supersession lineage, spends no predecessor and is not permanent, unlike rollback and retirement. It is Globus-side only — SendGrid keeps the template and the version live there; what is paused is whether Globus will instruct a send at all. `POST …/restore` (reason optional) puts a suspended mapping back in force, and is refused if another mapping became live for the binding meanwhile (`ACTIVE_MAPPING_CONFLICT`) or the row carries no provider version. Both are `comms.template.approve`. Flows that look for a mapping (campaign readiness, the welcome email, the invite resend) report a paused mapping as suspended, not as missing. The registry list `GET /api/comms/templates` (`comms.template.view`) accepts optional `brandId` and `lifecycleState` query filters. The `lifecycleState` enum covers every state except `suspended`, so filtering by `suspended` is rejected with 400. **Activating and rolling back reach the provider first.** Activation and rollback put a version live at SendGrid through the n8n adapter *before* Globus records the move, so a provider refusal leaves the mapping where it was and is written to the ledger. If the adapter never confirms the outcome, the refusal is `PROVIDER_ACTIVATION_UNCONFIRMED` — the version may or may not be live at SendGrid, the mapping is left unchanged and the history records the uncertainty; check the template in SendGrid before trying again. ### Revising a Live Mapping A live or suspended mapping is the record of what an approver signed off, so it is never edited in place. `POST /api/comms/templates/:templateId/revise` (`comms.template.author`) opens a **successor draft** from it: same binding (audience included), declared tokens, provider template and current copy carried over, with review evidence deliberately not carried over. The mapping in force keeps sending until the successor is approved and activated, and activation supersedes it. Publishing content from the successor adds a *version* to the same provider template rather than creating a second one. Only live or suspended mappings can be revised (`REVISION_NOT_APPLICABLE` otherwise), and a second open revision for the same binding is refused. ### Governance Refusal Codes Governance refusals are a closed set with operator-facing copy; the screen renders the copy, never a provider error string. The main codes: | Code | Meaning | | --- | --- | | `ILLEGAL_TRANSITION` | The requested state is not reachable from the current one. | | `SELF_APPROVAL_FORBIDDEN` | The person who authored a mapping cannot approve it, unless a system administrator overrides the split. | | `SELF_APPROVAL_OVERRIDE_REASON_REQUIRED` | A system administrator approving their own registration must give a reason (3–1000 characters); it is recorded on the governance ledger. | | `APPROVAL_EVIDENCE_MISSING` | Approval requires a recorded successful preview **and** a recorded successful test send for this template version. | | `PREVIEW_EVIDENCE_STALE` / `PREVIEW_EVIDENCE_NOT_RENDERED` | The recorded preview is of a different provider version, or Globus holds no render behind it. | | `EVIDENCE_INVALID` / `EVIDENCE_CONTAINS_PII` / `TEST_RECIPIENT_NOT_ALLOWED` | Recorded evidence is incomplete or too long, would carry real recipient data, or names something other than an allow-listed domain — refused. | | `ACTIVE_MAPPING_CONFLICT` | Another mapping is already live for this binding. | | `BINDING_ALREADY_PENDING` | A draft for this exact binding is already waiting to be activated. | | `NO_ROLLBACK_TARGET` / `ROLLBACK_AVAILABLE` | No earlier version to restore; or retirement was asked for a live mapping that a rollback could restore. | | `REVISION_NOT_APPLICABLE` | A revision was asked for a mapping that is not live or suspended. | | `TEMPLATE_NOT_EDITABLE` / `TEMPLATE_NOT_DELETABLE` / `TEMPLATE_CHANGED_MEANWHILE` | Past the point of editing, reviewed so it can only be retired, or changed by someone else while the screen was open. | | `UNSUPPORTED_TOKEN_DECLARED` / `INCOMPATIBLE_TOKEN_TYPE` / `UNDECLARED_TOKEN_IN_CONTENT` | A declared token has no approved Globus source, a declared type its source cannot produce, or authored content uses a token the registration does not declare. | | `PROVIDER_VERSION_UNKNOWN` | No provider version recorded, so there is nothing to render, send or activate. | | `PROVIDER_ADAPTER_NOT_CONFIGURED` / `PROVIDER_ADAPTER_FAILED` / `PROVIDER_ACTIVATION_UNCONFIRMED` | See "Authoring Copy in Globus" and the lifecycle notes above. | | `SENDER_IDENTITY_UNAVAILABLE` | No verified sender resolves for the brand or programme. | | `TEMPLATE_NOT_FOUND` | Retired or removed while the screen was open — refresh the list. | The campaign-binding codes (`CAMPAIGN_NOT_FOUND`, `CAMPAIGN_OUTSIDE_BRAND`, `CAMPAIGN_STEP_NOT_FOUND`, `PROGRAMME_OUTSIDE_BRAND`, `PROGRAMME_OUTSIDE_CAMPAIGN`) refuse a mapping that names a campaign, step or programme a send could never match. ### Administrator Override of Self-Approval The author of a registration still cannot approve it: `403 SELF_APPROVAL_FORBIDDEN`. A **system administrator** (`system.admin`) may cross the author/approver split by supplying a reason (`selfApprovalReason`) with the approval; without one the answer is `400 SELF_APPROVAL_OVERRIDE_REASON_REQUIRED`. The reason is written into the governance ledger on the same `approved` entry (as its note plus `evidence.selfApprovalOverride`), so the exception is visible to anyone reading the history. An API key never holds the override however it is scoped, and the override does **not** cover the evidence requirement — missing preview or test-send evidence still refuses approval (`APPROVAL_EVIDENCE_MISSING`). ## Preview & Test Send Shipped Sep 14 2026; preview reworked afterwards. Before a branded template can be approved it must be proved: rendered once as a **preview**, and sent once to a real inbox as a **test send**. Both are recorded as approval evidence while the template is `in_review`. | Staff route | Method | Purpose | | --- | --- | --- | | `/api/comms/templates/:templateId/preview` | POST | Render the pinned provider version **in Globus** with synthetic token data and keep the result. Synchronous — nothing leaves Globus and no mail is sent. | | `/api/comms/templates/:templateId/preview` | GET | The kept render for the current provider version (`comms.template.view`). | | `/api/comms/templates/:templateId/test-send` | POST | Queue one real message to `recipientEmail`, whose **domain** must be on the environment's allow-list. | | `/api/comms/templates/:templateId/operations` | GET | Recent operations for the template plus the domains currently allow-listed. | - **Preview is rendered in Globus, not queued.** The adapter reads back the bytes of the pinned provider version; Globus renders them with sample values generated from the declared tokens (no applicant is read) and keeps one render per version, re-rendering replaces it. Preview can be rendered in any state, but it is recorded as approval evidence only while the template is `in_review`. The response reports whether the provider's bytes still match the hash Globus recorded at publish (`contentMatchesRegistry`), whether the provider says that version is the active one, and any tokens the content uses that the registration does not declare. - **Test send goes through the n8n adapter.** Staff holding `comms.template.author` request it while the template is `in_review`; Globus records the request, pins the instruction, hands it to the adapter once, and records the outcome the adapter reports. That recorded outcome is the evidence. - **Queued is not done.** Until the adapter picks a test send up the operation reads **Queued** and the panel says so rather than implying mail has gone. Emitted is not delivered. - **Failures are reasons, never provider text.** `SENDER_IDENTITY_UNAVAILABLE` (no verified sender resolves for the brand or programme — see [Brand Sender Identities](#brand-sender-identities)), `TEST_RECIPIENT_NOT_ALLOWED` (domain not allow-listed; the refusal does not echo the address back), and adapter-reported failures from a closed `reasonCode` set (e.g. `provider_rejected`). - **Evidence skips a template that left review.** If a template is no longer `in_review` when the result arrives, the evidence is not attached (`template_not_in_review`) — a result cannot retroactively prove a version that has already moved on. ### Adapter Callbacks & Atomic Evidence The n8n adapter performs three calls, in order, mirroring the delivery pipeline: **claim** the operation, fetch its **instruction**, then post the **result**. Two guarantees were hardened in `ff11bef00`: - **The evidence stamp is one transaction.** The ledger entry, the registry's evidence timestamps and the operation's own "evidence recorded" marker commit together, with the registry row locked first and the operation row second (the same lock order as the request path). A process that dies mid-way can no longer leave an operation that still claims to owe evidence — which previously made the adapter's retry append the entry a second time. Whoever loses a race between two simultaneous callbacks sees the marker and does nothing. - **The outbox announcement rides the request's transaction.** The event that tells the adapter an operation exists is enqueued in the same transaction that creates the operation; the immediate push remains a post-commit best effort and the retry worker drains anything the push misses. A queued operation can therefore never be visible on screen while no adapter will ever hear about it. Callback failure codes: `OPERATION_NOT_CLAIMED` (result posted before claim), `OPERATION_NOT_INSTRUCTABLE` (operation no longer in a state that can be instructed), `UNKNOWN_OPERATION`. The three machine callbacks are `POST /api/n8n/template-operations/:operationId/claim`, `GET /api/n8n/template-operations/:operationId/instruction` (with `claimRef` as a query parameter) and `POST /api/n8n/template-operations/:operationId/result`, all restricted to an API key with the `integrations.events` scope (there is no staff session branch). The result body carries `claimRef`, `outcome` (`succeeded` or `failed`), an optional `reference` (identifier characters only, at most 200) and a `reasonCode` from the closed failure set, which is required when the outcome is `failed`. The result endpoint responds: 400 for an invalid body, 404 for an unknown operation, 409 with reason `CLAIM_MISMATCH` when another execution holds the claim, and 200 with `recorded`, `state`, `evidenceRecorded` and `evidenceSkipCode` otherwise. `recorded: false` marks a replay of an operation that was already closed, so the caller stops instead of retrying. The instruction endpoint answers 404 for `UNKNOWN_OPERATION` and 422 with a `reasonCode` for other refusals. ### Pinned at Request vs Resolved at Dispatch An operation fixes **what the evidence is about** when it is requested — provider template, provider version, content key / version / hash, the synthetic token values, and the test-send recipient — because approval reads the evidence back against exactly those coordinates. It resolves **how the message is sent** from live configuration at dispatch — the verified sender, the unsubscribe (ASM) group, the correlation arguments and the environment — because a test send routed through a sender that has since been unverified would prove the wrong thing. Anything that cannot be resolved at dispatch is a refusal; nothing is substituted. ## Nurture Campaign Engine Shipped Sep 14 2026. A campaign is **catalogue data, not code**: a brand-scoped definition (optionally narrowed to programmes) names a versioned eligibility predicate (`predicateKey` / `predicateVersion`, e.g. `incomplete_portal_profile` v1), a versioned Day 0 anchor (`anchorKey` / `anchorVersion`, e.g. `application_created_at` v1), and an ordered list of steps, each with its own `dueOffsetDays` and template `eventKey` (e.g. `campaign.incomplete_profile.day_3`). Nothing in the engine names a brand, a programme or a campaign; adding one means adding rows. The first shipped campaign is `incomplete_profile_nurture_wild_packs` (Day 3 / 6 / 10 / 16). It is seeded scoped to the Camp Counselor programme (`CAMP_WP`) only. An attached programme is an audience scope, and readiness requires an active template mapping for every enabled step in every attached scope, so a programme added before its mappings are approved does not widen the audience: it fails readiness and skips the step for everyone, including the programme that was ready. Widen the programme list only alongside the approved mappings. - **Email-only.** A campaign step materialises a notification row for audit and dispatch, marked email-only: it never appears in a portal feed, an unread count or an in-app list. - **Delivery path.** Each due step emits `external_notification.created`; n8n, subscribed to that event, resolves the step's **live** template mapping, the verified sender and the unsubscribe group and performs the send. Delivery events come back through the signature-verified SendGrid webhook above. - **Ships disabled.** `enabled` is load-bearing — false means enrol nobody and materialise nothing. Enabling is an audited decision taken per environment. A campaign created by staff (below) is always created disabled. - **Disable stops, it does not pause.** Disabling stops every active enrolment (reason `campaign_disabled`, with the operator's note as the stop detail). Re-enabling starts fresh enrolments; it never releases a backlog of steps. - **Kill switches layer.** The delivery-kind switch (`external_email_delivery_enabled:`, resolved through season → programme → brand → global and failing closed when absent) is the operational stop; the campaign flag is the editorial one. - **Advance check for non-onboarding cadences.** When a step comes due, the materializer calls `canAdvanceCampaign` for every enrolment whose pinned predicate is not `incomplete_portal_profile`. The check keys off the enrolment's pinned predicate, not a later edit to the campaign definition. The existing onboarding cadence is deliberately unchanged. - **The `portal_url` token signs the recipient in.** A template that declares `portal_url` receives a link to the applicant portal's `/onboarding?token=…` carrying a freshly minted applicant access token scoped to the application the mail is about (30-day life, outliving the day-16 step), instead of the bare portal origin — a campaign chasing an unfinished profile otherwise lands the recipient at a sign-in wall. The credential is minted only when the resolved template declares the token, a delivery with no applicant refuses the send (`MISSING_REQUIRED_TOKEN`), earlier tokens are not revoked by a reminder, and the delivery-instruction endpoint is restricted to the `integrations.events` API-key scope. - **Provider custom arguments are a closed set with a two-part guard.** Every send carries exactly `glb_key`, `glb_env`, `glb_campaign` and `glb_step`. `assertTransportable` (length limit, no `@` address, safe-character set) is applied to every value including the generated `glb_key`. `assertNoIdentifierTell` (refuses a run of seven or more digits) is applied to the authored labels only. `glb_key` is exempt from the digit-run check because it is a generated opaque token (a UUID for a notification delivery, an `op_`-prefixed random string for a template operation) that carries no applicant data; about one UUID in eight contains seven consecutive digits by chance, and refusing those as `UNSUPPORTED_BINDING` was unrecoverable because the key is fixed for the life of the delivery, so every retry reproduced the refusal and the delivery stayed claimed with nothing sent. - **`GLOBUS_ENV` and the `glb_env` label.** The `glb_env` argument is resolved from `CONFIG_ENV`, or from `GLOBUS_ENV` when `CONFIG_ENV` is not set: a value containing `prod` gives `production`, `qa` or `stag` gives `qa`, and `dev` or `local` gives `development`. Anything else falls back to `NODE_ENV` (`production` gives `production`, otherwise `development`), so a misconfigured host can never tag its traffic as production. > **Confirmed live instruction wire shape (2026-09-16 incident).** A real dev enrolment's send failed because the n8n-side adapter had been built against a guessed, flat shape for `GET /api/n8n/deliveries/:deliveryId/instruction` that never matched production. The response is wrapped in the standard `{ success, data }` envelope, and `data` nests the fields the adapter needs: `recipient.email`, `template.providerTemplateId`, `sender.{fromEmail,fromName}`, a pre-built `customArgs` object to pass through unmodified, and `asm.groupId` — sent as a **string** even though SendGrid's Mail Send API requires `asm.group_id` as an **integer**, so callers must coerce it. The n8n side is now fixed and this shape is confirmed live (see [n8n-workflows PR #475](https://github.com/Alliance-Strategies/n8n-workflows/pull/475) and the updated `contracts/globus/v1/schemas/sendgrid-delivery-instruction.schema.json` in that repo, the canonical wire-schema source of truth). A second, independent bug in the same incident — a downstream node reading the wrong upstream context and losing `deliveryId` — meant the failed send was never reported back to Globus, so the delivery sat stuck at `dispatching` until it was manually recovered; the fix also corrects that so future failures resolve to a terminal `dispatch_unresolved` / `dispatch_ambiguous` outcome instead of hanging. ### Readiness Checks & Enabling Readiness is computed at request time, per environment (ready in QA says nothing about production), and enabling is refused with `409 READINESS_FAILED` when any **blocking** check fails: | Check | Passes when | | --- | --- | | `cadence_defined` | The campaign has at least one enabled step. | | `predicate_registered` | The named eligibility predicate and version exist in the registry. | | `anchor_registered` | The named Day 0 anchor and version exist in the registry. | | `template_mapping` | Every enabled step resolves an **active** template mapping for the audience (an approved-but-not-live mapping fails this check). | | `provider_version_approved` | Every active mapping carries an approved provider version. | | `token_schema` | Every active mapping declares the tokens it expects. | | `unsubscribe_group` | Every active mapping carries an unsubscribe (ASM) group. | | `sender_identity` | A verified programme or brand sender resolves for every audience scope. | | `dispatch_route` | An n8n webhook URL is configured for outbound events. | | `dispatch_subscription` | n8n is subscribed to `external_notification.created`. | | `provider_event_ingress` | The provider event webhook verification key is configured, so delivery outcomes can be recorded. | | `external_email_switch` | *(Non-blocking.)* External email is switched on for the campaign's delivery kind at its brand; off means the campaign can be enabled but no step is materialised. | The readiness report also returns a per-step template-mapping state (`active`, `registered_inactive`, `suspended` or `none`) resolved by the same lookup, so a suspended mapping is named as suspended rather than missing. Each check returns `key`, `label`, `passed`, `detail`, `blocking`; the detail names the exact gap (e.g. "No active mapping for step 1 (campaign.incomplete_profile.day_3)"). ### Enrolment Stop Reasons An enrolment stops for exactly one recorded reason; staff surfaces show the label, the raw reason is stable for reporting. | Reason | Meaning | | --- | --- | | `profile_completed` | The applicant did what the campaign was asking for. | | `portal_activated` | The applicant reached the portal the campaign exists to get them into — an account exists and they have used it. Distinct from `profile_completed`: the profile may still be unfinished. | | `no_longer_eligible` | The eligibility predicate no longer holds — programme change, terminal status, withdrawal, left the target population. | | `manual_stop` | A staff member stopped the enrolment. | | `suppressed` | The recipient is unsubscribed or suppressed at the provider. | | `campaign_disabled` | An operator disabled the campaign. | | `cadence_complete` | Every step has been materialised. | | `eligibility_unreadable` | Eligibility could not be evaluated, so the campaign stopped rather than guessed. | | `dispatch_unresolved` | A send could not be resolved to a definite outcome, or dead-lettered. Never shown as sent. | | `delivery_kind_disabled` | The kind-level kill switch was off when a step came due. | | `readiness_failed` | Configuration the send depends on stopped resolving. | | `campaign_expired` | The enrolment passed the campaign's maximum age. | ### Campaign Control API | Method | Path | Permission | Purpose | | --- | --- | --- | --- | | GET | `/api/comms/campaigns` | `comms.campaign.view` | Catalogue with brand / programme scope, enabled flag and enrolment counts. | | GET | `/api/comms/campaigns/authoring-catalogue` | `comms.campaign.view` | The closed lists an authoring screen picks from: registered predicates, anchors and delivery kinds. | | GET | `/api/comms/campaigns/:campaignKey` | `comms.campaign.view` | Detail: predicate, anchor, ordered steps, `enrolmentCounts { active, stopped, completed }`, and a readiness report computed at request time. | | POST | `/api/comms/campaigns` | `comms.campaign.manage` | Create a campaign definition (and optionally its steps). **Always created disabled**, even when complete. | | PATCH | `/api/comms/campaigns/:campaignKey` | `comms.campaign.manage` | Edit the definition, audience and cadence. Requires `expectedUpdatedAt`. | | POST | `/api/comms/campaigns/:campaignKey/enable` | `comms.campaign.manage` | Re-evaluates readiness inside the call; `409 READINESS_FAILED` if blocked. No body. | | POST | `/api/comms/campaigns/:campaignKey/disable` | `comms.campaign.manage` | Stops active enrolments; optional `note` recorded as the stop detail. | Error codes: `CAMPAIGN_NOT_FOUND`, `READINESS_FAILED`, `ENROLMENT_NOT_FOUND`, `ENROLMENT_NOT_ACTIVE` (already stopped or completed). The campaign detail screen in the Hub renders the same readiness table and stop-reason labels. **Authoring rules.** The campaign key is 3–120 lower-case letters, digits and underscores starting with a letter. The predicate and anchor must be registered (`PREDICATE_NOT_REGISTERED`, `ANCHOR_NOT_REGISTERED`, `400`) and any narrowing programmes must belong to the campaign's brand (`AUDIENCE_NOT_RESOLVABLE`, `400`). The campaign key, brand and delivery kind cannot be changed after creation (`CAMPAIGN_IDENTITY_IMMUTABLE`) because templates, provider suppression and the kill switch bind to them by value; an existing key is refused (`CAMPAIGN_KEY_TAKEN`); a stale edit is refused (`CAMPAIGN_STALE`); an invalid cadence is `CADENCE_INVALID`. Step numbers are permanent: new steps take the next unused number, a step a template mapping is bound to cannot be removed (`STEP_BOUND_TO_TEMPLATE`), and a step that has already scheduled or sent mail cannot be removed (`STEP_IN_USE`) — disable it instead. Editing a live campaign does not reschedule sends already scheduled; the response reports how many were left unchanged (`scheduledDeliveriesUnchanged`). ### Campaigns on the Applicant Profile The applicant profile's Communications card has a read-only **Campaigns** tab (`GET /api/applications/:appId/campaign-communications`, `applicant.profile.view`, `404` for an unknown application). It lists the enrolments for the application with their step deliveries and the delivery header's state, newest anchor first. Provider acceptance (the 202) and delivery to the recipient are shown as two separate timestamps and never collapsed into one "sent"; stop reasons use the shared labels; provider error text is never read. It has no send, retry, skip or edit — everything that can cause a send stays in the campaign control plane. ## Transactional Branded Email The branded path is not only for campaigns. Two transactional emails are queued by Globus itself onto the same registry → n8n → SendGrid delivery rail, and both are **email-only** records (no portal feed entry) gated by the transactional external-email switch. - **Welcome email — once per applicant.** When an applicant's **first** application is created Globus queues one branded welcome (event key `applicant.created`, which is at once the notification type, the template key and the `event_key` staff register the template under). Nothing is written, and the applicant's single welcome is not consumed, when the send could not resolve: the skip reasons are `already_sent` (a partial unique index arbitrates), `no_mapping`, `mapping_suspended`, `switch_off`, `not_first_application`, `unknown_application` and `no_brand`. - **Transactional external-email switch.** `external_email_delivery_enabled:transactional` is a seeded application setting (create-only seed plus a guarded migration) that **ships disabled**; arming it is an audited settings change, at global scope or as a per-brand / per-programme override. An absent or unreadable setting means off. The switch gates enqueue only: mail already queued when it is switched off can still be dispatched. - **Portal invite resend.** A staff **resend invite**, and a **change-email** with resend, now queue a branded email (`applicant.invite_resent`) in the same transaction that replaces the applicant's access token. Each intentional resend creates a new delivery; the welcome's once-only rule does not apply. The earliest application supplies the brand and programme; the approved `applicant.created` mapping is reused, and the send is skipped with a named reason (`no_application`, `no_brand`, `switch_off`, `no_mapping`, `mapping_suspended`, `missing_invite_link`) — the last when the mapping's template does not declare the invite link. The link in the mail is the exact staff-minted redemption URL (stored encrypted in notification metadata, never in audit or outbox data), not a second credential. The API success means the token was reissued, not that the provider accepted mail. The Hub routes are `POST /api/admin/applicants/:applicantId/resend-invite` (`applicant.manage`, or the narrower `invite.resend` support key; 409 `APPLICANT_ALREADY_ACTIVATED` once the applicant has activated the portal) and `POST /api/admin/applicants/:applicantId/change-email` (`applicant.manage`; body `email` and optional `resendInvite`, default false, which is what queues the email). If the enqueue itself fails the transaction rolls back and the request returns 500, so the previous invite is not revoked and no success is reported. On success the audit row (`resend_portal_invite` or `change_applicant_email`) carries the `inviteEmail` outcome: `queued` with the notification, delivery and event ids, or `skipped` with the reason. - **Non-campaign email on the applicant timeline.** Outbound email that is not a campaign step (the welcome, the invite resend) appears on the applicant profile's Communications timeline from `GET /api/applications/:appId/email-communications` (`applicant.profile.view`, `404` for an unknown application): Globus's record of when it was queued, the delivery kind, the provider's accepted/delivered/terminal timestamps, engagement counts (aggregate only) and the exact approved artifact used, newest first and capped at 200. "Non-campaign" means no campaign step delivery points at the notification, so an email can never fall between this list and the Campaigns tab. Applicant-addressed mail only; read-only. - **Host organization email audit.** `GET /api/admin/partners/organizations/:orgId/email-audit` (`audit_log.view`; API keys need `partners.read`) lists the notifications recorded for a host organization (recipient type `host`, recipient id the organization id), which are kept in `notifications` and `notification_delivery` rather than `audit_logs`. Each row carries the type, title, message, severity, read state, source event type and the latest delivery's channel, status, provider, sent and delivered times and attachment status. Paged with `page` (default 1) and `limit` (default 50, clamped to 1 to 100); the result includes the `total`. ## Relationship to the Notification Model - The [Notification & Communication Model](/docs/notification-model) describes *what* is sent to whom and through which channel; this page describes how staff *administer* the SendGrid-backed branded path. Its channel matrix routes applicant email through HubSpot — nurture campaigns are the first applicant-facing branded email delivered on the Globus template registry → n8n → SendGrid path instead, and the matrix carries a note to that effect. - Campaign step event keys (`campaign.*`) and transactional event keys (`host.*` for hosts — see [Host Notifications](/docs/employer-notifications)) resolve through the same template registry; a step or event with no **live** mapping is unroutable. - The n8n adapter contract for content publication, template activation, test sends and campaign dispatch is part of the [Automation (n8n)](/docs/automation) boundary: n8n claims work Globus has recorded, executes it, and reports back; it holds no state of its own about templates or campaigns. - **In-app notification admin routes.** Separate from the branded email path, staff can create notifications directly. `POST /api/admin/notifications/send` (`notifications.send`) needs `applicantId`, `subject` and `body` (`applicationId` and `severity` are optional, severity defaults to `info`); it creates an applicant notification from the `manual_message` template and best-effort emits a `staff.send_message` event. `POST /api/notifications/bulk-send` (`notifications.manage`) takes `applicantIds` (at least one), `templateId` (a template id or key; 404 `NOT_FOUND` when unknown) and optional `variables` that replace `{{key}}` placeholders in the template title and message, creates one notification per applicant and returns `sent`, `failed` and per-applicant `results`. `POST /api/admin/notification-templates` (`notifications.manage`) creates a template (`templateKey` required, `titleTemplate` and `messageTemplate` optional). Scheduled notification jobs can be run on demand with `POST /api/admin/notification-jobs/:jobName/run` or `POST /api/admin/notification-jobs/run` (job name in the body), both `notifications.manage`; the response reports `scannedCount`, `emittedCount`, `skippedDedupe`, `errors` and `durationMs`, and an unknown job name is 400 `INVALID_JOB_NAME` with `details.availableJobs` (the same list is returned by `GET /api/admin/notification-jobs`). --- --- title: "Field Classification Contract" slug: field-classification-contract section: Globus Core status: stable description: "The metadata every Workflow Studio field must carry so Globus can deterministically route values to the right table and the right partner surface." last-updated: 2026-10-07 --- # Field Classification Contract The metadata every Workflow Studio field must carry so Globus can deterministically route values to the right table and the right partner surface. Spec only — no code changes here. > **Capture is identical; classification must not be** > > Workflow Studio captures both biographical applicant attributes and program-specific application answers through the same step → snapshot mechanism. Today the difference is decided implicitly by hardcoded knowledge inside biographicalSync. Every new field a program admin adds is therefore a coin flip — sometimes it ends up on applicants and surfaces correctly, sometimes it stays in the snapshot and silently disappears from SEVIS, partner portals, and host cards. This contract makes the classification a property of the field itself, declared at authoring time. Audience visibility is not a per-field decision — it is set once at the step level (see Step Scope) and the platform's hard-coded PII registry always masks a fixed set of sensitive field types regardless. See the canonical Applicant vs Application Data boundary for what each scope means downstream. ## Problem: Capture Is Identical, Classification Is Invisible A Workflow Studio step writes the values it collects into portal_step_snapshots keyed by the step's step_key. There is no field-level metadata on the step that says “this value belongs to the person, not to this attempt” or “a partner needs to see this on the placement card.” The boundary documented on the Applicant vs Application Data page is correct as a snapshot of the system today, but nothing in the authoring path keeps it correct tomorrow when a new field is added. The silent-failure path - A program admin adds a new identity-bound field to a Workflow Studio step (e.g. a second passport for dual-nationals). - The field is captured into the snapshot row for the application. - biographicalSync doesn't know about the new step_key, so it doesn't promote the value to the applicants row. - The SEVIS payload, the partner applicant detail view, and the host placement card all read from the promoted columns and see nothing. - No error is raised. No warning is shown. The data is captured but invisible to every downstream consumer. ## The Contract: Required Field Metadata ### Metadata Shape Every field a Workflow Studio step captures (form input, upload, structured answer) must carry the following metadata as part of its step definition. This is the schema the future authoring UI must elicit and the future biographicalSync rewrite must read. | Key | Type | Required? | Purpose | | --- | --- | --- | --- | | key | type | | purpose | _(row above is a template, repeated for each data row)_ The surfaces slot is intentionally a placeholder on this page. The per-surface enumeration and the contract for how each surface renders the field live in the sibling Dynamic Field Surface Spec task. This contract requires the slot to exist on every field; the sibling task fills it in. Per-field redaction is intentionally not in this metadata shape. Audience visibility is set on the step (see Step Scope) and the always-mask list lives in the platform PII registry — neither is a per-field choice the program admin makes. ### scope vs core (and why both) scope Decides where the value is stored. - • applicant — promoted onto the applicants row, reused across every attempt. - • application — stays in the per-attempt snapshot. core Decides whether other systems are allowed to depend on it. - • true — part of the contract. Stable name, breaking changes require a step revision. - • false — free-form snapshot data; downstream systems must not bind to it. > **Both axes are needed** > > An applicant-scope field is not automatically core (e.g. a free-form “preferred nickname” on the person — promoted across attempts, but no other system cares). An application-scope field can be core (e.g. applications.status — per-attempt, but every consumer depends on it). The two axes describe orthogonal facts. ## Step Scope (Audience) Every Workflow Studio step carries a single data scope dropdown that picks the audience allowed to see the data the step collects. Every field in the step inherits that audience. There is no per-field redaction toggle, no per-field partner_visibility predicate, no per-field hidden / masked enum — the dropdown is the only audience knob a program admin sets, and the shared projectForAudience(record, audience) projector consumes it directly. | Value | Audience the projector serves | | --- | --- | | labelvalue | description | _(row above is a template, repeated for each data row)_ The dropdown is set per step, not per field, because the audience for a step's collected data is almost always uniform — a "Recruiter intake" step's fields all go to the recruiter, a "Background check review" step's fields all stay with staff. Forcing the program admin to set audience on every individual field was the authoring nightmare this design replaces. If a single step needs to mix audiences, split it into two steps. ## Platform PII Registry A small, fixed list of always-masked field types maintained by the platform team — not by program admins. The shared projector consults the registry on every read and applies the registry's masking format regardless of the step's data scope. The registry is the second half of the visibility contract: step scope decides who sees the step at all; the PII registry decides what is masked even for audiences that can see it. | Field type | Masking format on non-staff surfaces | | --- | --- | | field | masking | _(row above is a template, repeated for each data row)_ > **Governance** > > The registry is platform-team-owned and lives in the Globus codebase, not in any per-program configuration. Adding or removing entries requires a platform engineering change and a documented decision; program admins cannot opt fields in or out. The detailed schema (where in the code the registry is declared, how the projector matches a field against it, the masking-format primitives) is owned by the new Platform PII Registry ticket in P1: Foundation Hardening — this section is the contract; that ticket is the implementation. ## Decision Tree for Authoring When a program admin adds a field to a step in Workflow Studio, the authoring UI should walk them through these questions in order. The first two map directly to the rule of thumb already documented on the Applicant vs Application boundary page. ## Worked Examples Three fields a Workflow Studio admin might add today, classified end-to-end. ## Decision Record — Why We Dropped Field-Level Redaction The previous design required every field on every step to declare a per-field redaction policy (e.g. none / masked / hidden_from_partners / hidden_from_hosts) plus an optional partner_visibility predicate. In a single program with hundreds of fields, that was hundreds of independent decisions a program admin had to make and keep correct over time. Forgetting one — or picking the wrong default — was silent: data leaked or vanished without any error. We replaced it with two pieces that together preserve the same security guarantee with one decision per step instead of one decision per field: - Step-level data scope — one dropdown per step picks the audience. Every field in the step inherits it. - Platform PII registry — a fixed, hard-coded list of always-masked field types maintained by the platform team. The projector always masks these regardless of the step's audience. What we kept: the shared projectForAudience projector, the build-time guard that catches direct field reads bypassing the projector, and the field surfaces contract (where each field appears in each portal). What we dropped: the per-field redaction enum and the per-field partner_visibility predicate. Implementation is tracked in Asana ticket 1214212456520951 (response projector) and the new Platform PII Registry ticket in P1: Foundation Hardening. ## Baseline Audit What exists today — captured here so the gap between the contract and reality is concrete. No fixes are made on this page; remediation is tracked as follow-ups. ### Authoring UI (Workflow Studio) ### Runtime (biographicalSync) ### Reported Gaps (No Fixes) | # | Gap | Layer | Severity | | --- | --- | --- | --- | | n | gap | | sev | _(row above is a template, repeated for each data row)_ ## Related Pages Applicant vs Application Data Defines the scope boundary this contract enforces at the field level. Use it for the rule-of-thumb questions in the decision tree above. Workflow Studio → Step Key Governance Step keys are the only stable identifier today. Field classification metadata is the missing companion that turns the key into a routing decision. Workflow Studio Help In-app help for program admins. Once the authoring UI is updated, classification authoring guidance lives there. Glossary Canonical definitions for Field Scope, Core Field, Field Surface, Snapshot Field. Ecosystem MCP Sync This contract was synced to the AAG ecosystem MCP coordination server when the page shipped (heartbeat + sync-status posted against /api/mcp/heartbeat and /api/mcp/sync-status) so the Partner Portal, GLOBUS Hub, and other ecosystem portals are aware of the new field metadata schema, the new glossary terms, and the gaps list. Subsequent edits to this page must repeat the sync per the mcp-sync skill. --- --- title: "Dynamic Field Surface Spec" slug: dynamic-field-surface-spec section: Globus Core status: stable description: "The contract every partner portal must honour so any new Workflow Studio field, once classified via the Field Classification Contract, automatically appears in the right place in the right portal — without a code change." last-updated: 2026-10-07 --- # Dynamic Field Surface Spec The contract every partner portal must honour so any new Workflow Studio field, once classified via the Field Classification Contract, automatically appears in the right place in the right portal — without a code change. Spec only; no portal code lives here. > **Build status: not started (verified against BackOfficePrototype `development`, 2026-07-28).** > > Nothing in this spec exists in code yet. There is no `surfaces` slot on Workflow Studio field metadata, and the surface names `recruiter.summary`, `recruiter.profile`, `host.talent_card`, `host.profile`, `staff.profile`, and `workflow_tab_only` appear nowhere in the codebase. The "Additional Information" auto-render section, the authoring-time fallback warning, and the new-field notification rule are all unimplemented. > > What *does* exist today and is the current audience mechanism: the step-level `data_scope` column on `workflow_steps` (`staff_only`, `staff_and_host_after_placement`, `staff_and_recruiting_partner`, `staff_and_host_and_recruiting_partner` — see `WORKFLOW_STEP_DATA_SCOPES` in `shared/models/workflow.ts`) plus the platform PII registry enforced by `server/middleware/piiRedactor.ts` and `server/lib/redactForRole.ts`. Pages that cross-reference this spec (Applicant vs Application, Host Portal Governance, Partner Portal Governance, Glossary) describe target state, not shipped behaviour. > **Studio decides where; portals do what they're told** > > Today the Applicant Portal renders Studio fields dynamically; the Recruiter and Host Portals do not. A new field added in Workflow Studio lands in portal_step_snapshots and is technically reachable via the generic Workflow tab on the staff view, but it never appears on the cards and profiles partners actually look at. This spec closes that gap by defining a small set of named surfaces, the rendering contract each surface implements, and the fallback / warning rules that make the gap loud rather than silent. It consumes the metadata defined in the Field Classification Contract and depends on it being authored first. ## Problem: Studio Adds Fields, Partner Portals Don't See Them Workflow Studio is the source of truth for what a program collects. When a program admin publishes a new step or adds a new field, the Applicant Portal renders it on the next load — that path is dynamic. The Recruiter Portal and the Host Portal were both built around hardcoded sections (summary cards, profile sections, talent cards), so a new Studio field is captured but invisible on the surfaces partners look at every day. The silent-failure path - A program admin adds a field in Workflow Studio (e.g. “Driver's License Class” on a Camp Counselor workflow). - The applicant fills it in; the value lands in the snapshot row for the application. - The host's talent card and applicant profile render their hardcoded sections — neither knows about the new field. - The host calls the program manager asking why they can't see the driver's license info, even though they were told it was being collected. - No error. No warning. The data is captured but invisible where the host actually decides. ## Surface Taxonomy A field surface is a named, partner-facing destination on a portal that a Workflow Studio field can be tagged to. The surface set is closed and small on purpose — every surface listed here is a place a partner already looks, so a field tagged to a surface is guaranteed to be seen. The surfaces slot on every Studio field (defined by the Field Classification Contract ) takes its values from this taxonomy. ### Named Surfaces | Surface | Portal | Location | Purpose | | --- | --- | --- | --- | | | portal | location | purpose | _(row above is a template, repeated for each data row)_ > **A field can be tagged to multiple surfaces** > > The surfaces value is an array. A driver's-license-class field for a camp counselor workflow might carry ["host.talent_card", "host.profile", "staff.profile"] — appearing on the host's grid card, the host's deep view, and the staff view, while being invisible to recruiters. Per-partner visibility narrowing on top of this is handled by the step's data scope (which determines whether the step is projected to a given audience at all) and by the platform PII registry (which always masks the registered field types), not by adding more surfaces. ### workflow_tab_only — The Fallback Surface workflow_tab_only is the default destination for any field whose surfaces slot is empty. The value is captured normally and is reachable from the staff Workflow tab keyed by step_key, but no recruiter, host, or curated staff section ever displays it. This surface exists so the spec can be exhaustive — every field has a surface, even if that surface is “nowhere visible to a partner.” It is paired with the authoring-time warning (below) so the fallback is always a deliberate choice. ## Rendering Contract Every applicant detail view in every partner portal must honour the rules below for fields tagged to one of its surfaces. These rules are minimal on purpose — the goal is a deterministic mapping from field metadata to rendered row with no portal-side decisions. ### The “Additional Information” Section Every applicant detail view that exposes a profile-level surface (recruiter.profile, host.profile, staff.profile) must include an Additional Information section. This section auto-renders every Studio field tagged for the surface, in the order defined below, using the field's authored label, with the projector applying the step's data scope and the platform PII registry before the row reaches the portal. The section is present even if the workflow currently tags zero fields for the surface — the empty state renders “No additional information.” This guarantees that the next time a field is tagged, it appears without any portal change. Card-level surfaces (recruiter.summary, host.talent_card) do not have a separate “Additional Information” section — they auto-render the tagged fields directly on the card body, after the hardcoded fields, with the same ordering rule. ### Label & Rendering Rules ### Ordering & Grouping Fields inside Additional Information are ordered by step order in the published workflow, then by field order within the step. There is no portal-side re-ordering and no per-partner customization (explicitly out of scope — see below). If the Field Classification Contract is later extended with a surface_group slot, this spec will be revised to honour it; until then, step order is the single ordering signal. ### Field Metadata Shape Consumed The portal consumes the field-level metadata defined on Field Classification Contract → Metadata Shape . The relevant slots for this spec are: // Per-field metadata, partial — only the slots this spec reads "label": "Driver's License Class", "surfaces": ["host.talent_card", "host.profile", "staff.profile"] // Audience visibility is NOT a per-field property: // - the step that owns this field carries a single data-scope dropdown // (staff_only / staff_and_host_after_placement / // staff_and_recruiting_partner / staff_and_host_and_recruiting_partner) // - the platform PII registry hard-masks passport, DOB, SSN, // background-check detail, and medical regardless of step scope The portal does not read scope or core — those decide where the value is stored / whether other systems may bind to it, not where it renders. Storage routing is owned by Globus (see the Field Classification Contract's runtime audit). ## Fallback & Authoring-Time Warning ### Untagged-Field Default A field with an empty surfaces array defaults to workflow_tab_only. It is captured, stored per the field's scope, and reachable from the staff Workflow tab — and it is invisible everywhere else. This default is the same behaviour as today, made explicit. Nothing about a published workflow regresses by adopting this spec. ### Workflow Studio Warning Rule Workflow Studio must show a visible warning at authoring time on any field whose surfaces slot is empty, with copy along the lines of: ⚠️ This field has no partner surface. It will be captured but will only appear on the staff Workflow tab. No recruiter or host will see it. If that is intentional, dismiss this warning to confirm. The warning must be dismissible per-field (acknowledging the fallback is a valid choice for staff-only data) but must persist its dismissal state on the field metadata so the warning re-fires if the field is later edited and the surfaces slot is re-cleared. The rules-coverage panel on the Workflow Studio canvas (per Workflow-to-Rules Coverage Panel ) gains a new column counting the number of unsurfaced fields per step. The warning never blocks publishing — it makes the silent-failure path loud, not impossible. > **Implementation gap, surfaced not fixed** > > The Workflow Studio authoring UI does not yet render the surfaces input or the warning above (this is the same gap reported as FCC1 on the Field Classification Contract's authoring audit). This spec defines what the warning must look like and when it must fire; building it is a follow-up against the Workflow Studio page. ## New-Field Notification Rule When a published workflow change introduces a new field on a partner-visible surface, the affected partner orgs must be notified. This page only specifies the rule — the notification dispatcher, channel routing, and per-recipient preferences are owned by Notifications and per-portal preference systems. ### Trigger | field | value | | --- | --- | ### Recipients | field | value | | --- | --- | ### Template Reference | field | value | | --- | --- | ## Partner Visibility Matrix — Restated In Surface Terms The Partner Visibility Matrix on the Applicant vs Application page is, today, a static list of fields per partner type. This spec re-frames it as “what surfaces exist, and what currently fills them” — so the matrix becomes a snapshot of a dynamic system rather than a list that must be edited every time a field is added. | Surface | Filled today by | What changes under this spec | | --- | --- | --- | | surface | filledByToday | whatChanges | _(row above is a template, repeated for each data row)_ The Partner Visibility Matrix on Applicant vs Application Data cross-links this page as the authoritative surface enumeration. The per-field tables on that page remain useful as a snapshot of what currently fills each surface; they are not the surface contract. ## Out of Scope (Tracked Separately) - Implementing the “Additional Information” section in any actual portal — implementation is per-portal follow-up against this spec. - Building the new-field notification dispatcher — only the trigger and recipient rules are defined here; wiring lives on the notifications side. - Per-partner customization (each partner choosing which surfaces or fields they care about) — explicitly deferred. - Re-architecting portal navigation, replacing existing hardcoded sections, or changing the Host Portal Contract — this spec is additive on top of what is already there. - Adding per-field surface_group ordering hints — out of scope until step order proves insufficient. ## Related Pages Field Classification Contract Defines the per-field metadata (label, surfaces) this spec consumes, plus the step-level data scope dropdown and platform PII registry that together replace per-field redaction — that replacement is a settled product decision, recorded in code as DEPRECATED_DATA_ACCESS_TERMS in shared/contracts/dataAccessContract.ts. The surfaces slot defined there takes its values from this page's taxonomy and is likewise not yet built. Partner Visibility Matrix The static visibility matrix this spec restates in surface terms. The matrix page now cross-links here as the authoritative surface enumeration. Partner Portal → Applicant Detail View The recruiter detail surfaces this spec adds an Additional Information section to. References this spec as a required contract. Host Portal → Dynamic Applicant Data Rendering The host-side dynamic renderer this spec constrains to surface-tagged fields. References this spec as a required contract. Workflow Studio → Coverage Panel Where the unsurfaced-field count column is added. The authoring-time warning fires inside the Studio canvas. Notifications The dispatcher that delivers the new-field notifications defined here. This spec only specifies the trigger, recipients, and template reference. --- --- title: "Batch Jobs" slug: batch-jobs section: Globus Core status: stable description: "The central register of scheduled / background jobs in Globus — reservation sweepers, integrity scanners, SEVIS sync, SLA recalculation, notification scanners, and lifecycle workers." last-updated: 2026-10-07 --- # Batch Jobs The central register of scheduled / background jobs in Globus — reservation sweepers, integrity scanners, SEVIS sync, SLA recalculation, notification scanners, and lifecycle workers. Verified against source 2026-07-04. ## How Jobs Actually Run > **setInterval modules, not a job framework** > > There is no formal job registry and no cron library. Every background job is a self-managed setInterval module under server/services/, wired at boot in server/index.ts. (systemProcessRegistry.ts is a rules-engine facts registry, not a jobs catalog.) Notification scanners live together in server/services/notificationJobs.ts. ## Layer 7 Jobs (detailed) ### Reservation Auto-Expire The only system-driven active → expired transition for reservation holds. Implementation: startAutoReleaseJob() → runAutoReleaseExpiredReservations() in server/services/partnerReservationLock.ts. | Field | As built | | --- | --- | | Cadence | Every 10 minutes (INTERVAL_MS = 10*60*1000). Also invoked lazily on host-portal reservation reads and on demand via POST /api/admin/reservations/expire. | | Selection | reservation_holds WHERE status='active' AND expires_at < now() (column is expiresAt — not holdExpiresAt) | | Writes | Flips status to expired, sets expiredAt and expiredBy='system:reservation-sweeper'; releases the job-order seat via releaseReservationSeat; releases partner quota; emits partner.reservation.expired through the event outbox | | Idempotency | The status+expiry WHERE filter excludes already-flipped rows; safe to re-run | | Distinct from | The Reservation Countdown notification scanner (notificationJobs.ts) — that one emits the 24h expiring warning and never writes reservation state | Canonical hold policy: Quota Model . Note for readers of older drafts: the 15-minute quarter-hour cadence, FOR UPDATE SKIP LOCKED 500-row batching, and audit_logs reason='timeout' row described previously are not in the code. ### Duplicate Placement Integrity Scan Second line of defence behind the row-level lock and partial unique indexes: re-derives active placements and asserts uniqueness. Implementation: startDuplicatePlacementScanScheduler() in server/services/duplicatePlacementScan.ts (Task #291). | Field | As built | | --- | --- | | Cadence | Nightly at 02:30 UTC, plus an hourly freshness watchdog (26-hour stale threshold, Task #296) and a startup catch-up tick (Task #905) | | Grouping | Active placements (status IN ('confirmed','active')) grouped by (job_order_id, application_id) — placements carry applicationId; there is no position_id column | | Flagging | Any group with COUNT > 1 emits integrity.duplicate_placements_detected through the eventEmitter (per-UTC-day dedupe key) → n8n | | Health marker | Writes app_settings key integrity.duplicate_placement_scan.last_run_at each run | | Idempotency | Read-only; safe to re-run on demand | Concurrency contract: Capability Layers · runbook: Error Handling . Older drafts named this job reservation-placement-integrity-scan with a 02:15 America/Chicago cadence and on-call paging — the shipped identity, cadence and alert path are as above. ## Full Background Worker Catalog Workers wired in server/index.ts as of 2026-07-04; the catalog is not exhaustive (for example startAutoExpiryJob, the campaign worker and the SLA reconcile job are also started there and are not listed). Cadences and write shapes live in each module under server/services/. | Worker | Domain | What it does | | --- | --- | --- | | eventRetryWorker | Event Outbox | Retries failed outbox event deliveries | | webhookRetryWorker | Integrations | Retries failed outbound webhook deliveries | | reservation auto-expire (startAutoReleaseJob) | Reservations | Detailed above | | duplicatePlacementScan | Placements | Detailed above | | referenceExpirySweeper | References | Expires stale reference submissions/invites | | seasonLifecycleWorker | Seasons | Advances season lifecycle states on schedule | | slaRecalculationWorker | Queue/SLA | Recomputes SLA clocks feeding the Command Center SLA views | | rulesEngineActivationWorker | Rules Engine | Activates scheduled rule bundle versions | | eligibilityOverrideExpiryWorker | Rules Engine | Expires time-boxed eligibility overrides | | ruleEvaluationOutboxWorker | Rules Engine | Drains the rule-evaluation outbox | | operationalExceptionBreachSweeper | Queue/SLA | Flags operational exceptions that breach their windows | | noShowDetectionScheduler | Placements/Arrivals | Detects arrival no-shows | | sevisStatusSyncScheduler | SEVIS | Synchronises SEVIS statuses | | sectionLockingScheduler | Workflow | Applies scheduled section locks | | hostScoringScheduler (startHostScoringScheduler) | Hosts | Recomputes host scores | | campMetadataReconcilerScheduler | Hosts/Camp | Reconciles camp metadata | | flowAvailabilityScanner | Applications | Refreshes renewal/extension flow availability | | jobOrderDraftArchiveSweep | Job Orders | Archives stale draft job orders | | unownedHostSweep | Hosts | Flags host orgs with no account manager (feeds /api/ops/unowned-hosts) | | hostUserLinkageIntegrity | Hosts/Identity | Verifies partner_users ↔ Auth0 linkage integrity | | notificationJobs.ts scanners | Notifications | Document Expiry, Reservation Countdown, and Participant Digest scanners — emit events through the standard eventEmitter flow (see Data Flows → Scheduled Background Scanners) | **Disable switches.** Seven further background jobs started in `server/index.ts` (only operationalExceptionBreachSweeper appears in the catalog above) are skipped at boot when their environment variable is exactly `1` (any other value leaves the job running). The poll interval is the module constant. | Env var | Job | Poll interval | | --- | --- | --- | | `DISABLE_EXECUTIVE_KPI_SNAPSHOT_SCHEDULER` | Executive KPI snapshot scheduler (one `executive_kpi_snapshots` row per brand per UTC day) | 6 hours | | `DISABLE_GATE_STATUS_PROJECTION_SWEEPER` | Gate-status projection sweeper (keeps `application_gate_statuses` fresh) | 5 minutes | | `DISABLE_LIFECYCLE_RECONCILE_SWEEPER` | Lifecycle reconcile sweeper (re-runs stage reconciliation for all applications) | 30 minutes by default, overridden by `LIFECYCLE_RECONCILE_SWEEP_INTERVAL_MS` | | `DISABLE_OPERATIONAL_EXCEPTION_BREACH_SWEEPER` | operationalExceptionBreachSweeper (listed above) | 5 minutes | | `DISABLE_QUEUE_BACKLOG_SNAPSHOT_SCHEDULER` | Queue backlog snapshot scheduler (captures the last completed UTC day) | 6 hours | | `DISABLE_SEVIS_CHECKIN_SCHEDULER` | SEVIS check-in generator (creates one arrival `sevis_check_ins` row per on-program participant) | 6 hours | | `DISABLE_SEVIS_READINESS_PROJECTION_SWEEPER` | SEVIS readiness projection sweeper (writer of `sevis_readiness_projections`) | 1 minute | **Scheduler tuning variables** (all optional). `LIFECYCLE_RECONCILE_SWEEP_INTERVAL_MS` sets the interval of the lifecycle reconciliation sweeper, which is not in the table above (default 1800000, 30 minutes; a boot catch-up pass runs 60 seconds after start; `DISABLE_LIFECYCLE_RECONCILE_SWEEPER=1` turns it off). Each tick runs the phase-completion catch-up and then a persisted reconciliation of all applications. `SEVIS_STATUS_SYNC_BATCH_LIMIT` caps the applications scanned per SEVIS status sync tick (default 500); the tick does nothing when SEVIS is not configured. `NOTIFICATION_JOB_INTERVAL_MS`, when set to a positive number, runs every notification scanner on that fixed interval instead of the daily schedule; otherwise the scanners run once a day at `NOTIFICATION_JOB_SCHEDULE_HOUR_UTC` (default 6, ignored when the interval override is set). ## Still To Author Per-worker cadence/retry columns for the catalog above, SEVIS batch jobs (cross-link Applicant Lifecycle → SEVIS Batch Processing), finance/NetSuite reconciliation cadence, pause/resume/replay runbooks, and job-class RBAC. These describe documentation debt, not missing code. ## Related Pages Data Flows — Scheduled Background Scanners, Error Handling, Queue System, Notification Model, Applicant Lifecycle (SEVIS batches). --- --- title: "Hub AI Capability Execution" slug: hub-ai-capabilities section: Globus Core status: stable description: "The Hub (BackOfficePrototype) runs every AI call through a database-backed capability registry." last-updated: 2026-10-07 --- # Hub AI Capability Execution ## Overview The Hub (BackOfficePrototype) runs every AI call through a database-backed capability registry. A capability is a row in `ai_capabilities` holding a key, prompt template, model, token and temperature settings and a provider. `executeAiCapability` in `BackOfficePrototype server/services/aiExecutionService.ts` looks the row up by `capabilityKey`, renders the prompt, calls the provider, and writes a row to `ai_invocation_logs`. The admin routes under `/api/ai/*` live in `server/routes/aiCapabilities.ts` (split into `aiCapabilities.capabilities.ts`, `aiCapabilities.execution.ts` and `aiCapabilities.providers.ts`). The four `/api/openai/*` text routes live in `server/routes/aiTextReview.ts`. This is not the Nexus docs connector. For that, see [AI Connector](/docs/ai-connector). For where the AI layer sits in the Hub's capability layers, see [Capability Layers](/docs/globus-layers). In-process callers of `executeAiCapability` include `aiExtraction.ts`, `enrollmentProofProcessor.ts`, `hostVerificationDeps.ts`, `housingLeadScoring.ts`, `translationSkills.ts`, `cbcTranslationOrchestrator.ts`, `workflowStepLibrary.ts` and `integrations.portalAi.ts`, as well as the admin routes below. ## Routes All routes use `isAuthenticatedOrApiKey(...)` (session, or an API key holding every named scope) followed by a permission check. Success bodies use the standard `createSuccessResponse` envelope unless stated. ### Capability registry routes | Verb | Path | API key scope | Permission | Notes | |---|---|---|---|---| | GET | `/api/ai/capabilities` | `workflows.read` | `programs.manage` | Returns all capabilities (ordered by name) as a page, each with `usageStats` (`invocationCount`, `avgDurationMs`; zeros when never invoked). | | GET | `/api/ai/capabilities/:id` | `workflows.read` | `programs.manage` | Lookup by row id. Not found returns the standard not-found error. | | POST | `/api/ai/capabilities` | `workflows.read`, `admin.write` | `programs.manage` | Body validated by `insertAiCapabilitySchema` plus `outputSchema` check. 201. Writes an `ai_capability` audit log (`created`). | | PATCH | `/api/ai/capabilities/:id` | `workflows.read`, `admin.write` | `programs.manage` | Partial body. Audit log `updated`. | | DELETE | `/api/ai/capabilities/:id` | `workflows.read`, `admin.write` | `programs.manage` | Hard delete, returns `success: true`. No audit log call in the handler. | | POST | `/api/ai/capabilities/:id/version` | `workflows.read`, `admin.write` | `programs.manage` | Spreads the request body into the update and sets `version` to current plus 1. | | POST | `/api/ai/capabilities/seed` | `workflows.read`, `admin.write` | `programs.manage` | Inserts each entry of `getCapabilitySeeds()` that does not exist yet; returns `seeded` with per-key `created` or `skipped`. A unique violation (`23505`) is reported as `skipped`. | `outputSchema`, when present, must be an array of `key` (string), `type` (`string`, `number`, `boolean`, `date`, `json`, `array`) and optional `description`. ### Execute and invocation log routes | Verb | Path | API key scope | Permission | Notes | |---|---|---|---|---| | POST | `/api/ai/execute` | `workflows.read`, `admin.write` | any of `programs.manage`, `dashboard.view` | Body: `capabilityKey` (string), `payload` (object), optional `contextOverrides`. A missing or non-string `capabilityKey` fails schema validation (400 `VALIDATION_ERROR`); a missing `payload` is rejected by the handler's own validation error. | | GET | `/api/ai/invocations` | `workflows.read` | `programs.manage` | Query: optional `capabilityKey`, `limit` (default 50, no upper bound in the handler; `0` or a non-numeric value removes the limit). Newest first. | | GET | `/api/ai/invocations/:capabilityKey` | `workflows.read` | `programs.manage` | Same handler logic with the key in the path. Used by the registry page "View Logs" panel. | There is no route that fetches a single invocation by id. Both invocation routes return a page of log rows filtered by `capabilityKey` (all keys when the query parameter is omitted on the first route). Log rows include `inputPayload`, `outputPayload`, token counts, `status` and `errorMessage`, so `programs.manage` gates access to logged prompts and payloads. ### Provider settings routes All provider routes require API key scope `admin.write` and permission `programs.manage`. | Verb | Path | Behaviour | |---|---|---| | GET | `/api/ai/providers` | Lists `ai_provider_settings`. `apiKey` is masked (first 4 and last 4 characters around four dots; keys of 8 characters or fewer become eight dots) and `hasApiKey` is added. | | PUT | `/api/ai/providers/:providerKey` | Upsert by `providerKey`. Body (all optional): `apiKey`, `displayName`, `baseUrl`, `isActive`, `defaultModel`. An empty `apiKey`, or one containing the mask characters, keeps the stored key. `isActive` is true unless explicitly `false`. `displayName` defaults to the `providerKey`, and an omitted `baseUrl` or `defaultModel` is stored as null (overwriting any previous value). Resets the cached OpenAI client, writes an audit log, and `auditMutation` action `ai_provider.updated`. | | DELETE | `/api/ai/providers/:providerKey` | Deletes the row, resets the cached client, `auditMutation` action `ai_provider.deleted`. Missing row returns not-found. | | POST | `/api/ai/providers/test` | Body requires `providerKey`, but the handler then runs the `text_enhancement` capability with a fixed payload regardless of which key was given. Returns `connected`, `model`, `durationMs`, `error` (null on success). Always HTTP 200 for a handled failure. | ### OpenAI text routes Four POST routes in `aiTextReview.ts`, all with API key scope `portal.read` (named explicitly so read-only keys cannot invoke paid AI), permission `dashboard.view`, and `auditMutation` (entity type `ai_text_review`). Each calls `executeAiCapability` with the session user id. | Path | Capability key | Required body | Audit action | |---|---|---|---| | `/api/openai/review-text` | `text_review` | `text` (string); optional `context`, `skillName`, `fieldName`, `hobbyName` | `ai.review_text` | | `/api/openai/enhance-caption` | `text_enhancement` | `caption` (string); optional `context` | `ai.enhance_caption` | | `/api/openai/review-showcase` | `text_review` | `captions` or `items` (non-empty array) | `ai.review_showcase` | | `/api/openai/suggestions` | `field_suggestion` | `fieldName` (string); optional `context`, `currentValue` | `ai.suggestions` | Of these four routes, only `review-text` checks the `ai_extraction_enabled` feature flag (503 with `success: false` when off; the seeded default is on). A missing or non-string required field fails schema validation first (400 `VALIDATION_ERROR` envelope); an empty string, or an empty `captions`/`items` array, returns 400 with `{ success: false, error }`. On a failed execution, an error string containing the lowercase text `not configured` returns 503; anything else returns 500 with a generic `INTERNAL_ERROR` envelope (`createSafeErrorResponse`). A JSON result is returned as `parsed`, else the fenced or raw text is parsed, else a fallback object (`feedback`, `enhancedCaption` or `suggestions`). ## Execution flow `executeAiCapability` runs these steps: 1. Load the capability by `capabilityKey`. If none, return `success: false` with `Capability "" not found or inactive`. 2. If `capability.provider` is a private GCP provider, delegate (below). 3. Otherwise get the OpenAI client; if none, return `AI_NOT_CONFIGURED`. 4. Render `promptTemplate` and `systemPrompt` by replacing double-brace word tokens with values from `payload` merged with `contextOverrides` (unknown tokens stay as written). 5. For `type` `vision` with `imageBase64` plus `mimeType`, or `imageUrl`, build an image message (GCS URLs and object paths are fetched and inlined as data URLs). 6. Call `chat.completions.create`. Models whose name starts with `gpt-5`, `o1`, `o3` or `o4` (case-insensitive) get `max_completion_tokens`; others get `max_tokens` and `temperature` (default 0.3). `maxTokens` defaults to 2000. 7. If `responseFormat` is `json`, request `json_object` output and `JSON.parse` the content into `parsed`. ### Provider routing | `ai_capabilities.provider` | Backend | Falls back to OpenAI | |---|---|---| | `openai` (column default) | OpenAI chat completions | n/a | | `PRIVATE_GCP` | Internal GCP Document AI passport OCR | No (code comment) | | `PRIVATE_GCP_TEXT` | Internal GCP Document AI raw-text OCR (criminal background) | No (code comment) | | `PRIVATE_GCP_TRANSLATION` | Internal GCP translation service | No (code comment) | The private paths return an `error` string starting with `OCR_MISSING_DOCUMENT`, `OCR_DOCUMENT_NOT_RESOLVABLE` or `TRANSLATION_MISSING_TEXT` (followed by a colon and a message) when input is missing, or an image-fetch message when `imageUrl` cannot be read, and include a `provider` field in the result. The criminal background path logs only metadata, never `rawText`. ### OpenAI client resolution The client is cached in memory and rebuilt after `resetOpenAIClient()` (called on provider upsert and delete). Credential order: 1. `ai_provider_settings` row with `providerKey` `openai`, when `isActive` and `apiKey` set (uses its `baseUrl`). 2. `OPENAI_API_KEY` with `OPENAI_BASE_URL`. 3. `AI_INTEGRATIONS_OPENAI_API_KEY` with `AI_INTEGRATIONS_OPENAI_BASE_URL`. If none yields a key, the client is null and execution returns `AI_NOT_CONFIGURED`. ## Failure shapes `/api/ai/execute` returns the raw `AiExecutionResult` on failure, not the standard error envelope: `success: false`, `error` (string), `model`, `durationMs`, and optionally `provider`. | `error` value | HTTP status | Cause | Logged | |---|---|---|---| | `AI_NOT_CONFIGURED` | 503 | No OpenAI client (no active DB setting, no env keys) | No log row | | Message containing `not found` | 404 | No capability with that key | No log row | | `AI_MALFORMED_OUTPUT` | 500 | `responseFormat` is `json` and the model output failed `JSON.parse` | Yes, status `error`, message `AI_MALFORMED_OUTPUT: ` | | Image fetch message (for example `Failed to fetch image from GCS URL`) | 500 | Vision capability whose `imageUrl` could not be resolved | No log row | | Provider exception message | 500 | OpenAI or other error thrown | Yes, status `error` | | `OCR_*`, `TRANSLATION_*` or adapter error | 500 | Private GCP path failure | Yes, status `error` (missing-input and adapter failures; an image-fetch failure on the private OCR path is logged too) | These are plain top-level `error` strings on a `success: false` body. They are not `error.code` values in the envelope, and the HTTP status comes from the string checks in `aiCapabilities.execution.ts`. Callers that call `executeAiCapability` in-process receive the same result object and decide their own status. ## Configuration | Variable | Default | Required | Purpose | |---|---|---|---| | `OPENAI_API_KEY` | none | Optional | Direct OpenAI key (GKE/prod path). Used when no active DB provider key exists. | | `OPENAI_BASE_URL` | SDK default | Optional | Base URL paired with `OPENAI_API_KEY`. | | `AI_INTEGRATIONS_OPENAI_API_KEY` | none | Optional | Managed proxy key (dev fallback). | | `AI_INTEGRATIONS_OPENAI_BASE_URL` | SDK default | Optional | Base URL paired with the proxy key. | At least one credential source is needed for `openai`-provider capabilities. The `ai_extraction_enabled` app setting gates only `/api/openai/review-text`. ## Data model | Table | Key columns | |---|---| | `ai_capabilities` | `id`, `capability_key` (unique), `name`, `description`, `type` (default `chat`), `provider` (default `openai`), `prompt_template`, `system_prompt`, `model` (default `gpt-5-mini`), `temperature`, `max_tokens`, `response_format` (default `text`), `input_schema`, `output_schema`, `version`, `status` (default `active`), `created_by`, `updated_by` | | `ai_invocation_logs` | `id`, `capability_key`, `capability_id` (FK to `ai_capabilities`), `model`, `input_payload`, `output_payload`, `prompt_tokens`, `completion_tokens`, `duration_ms`, `status` (default `success`), `error_message`, `user_id` (FK to `users`), `application_id`, `created_at`. Indexes on key, created time, application. | | `ai_provider_settings` | `id`, `provider_key` (unique), `display_name`, `api_key`, `base_url`, `is_active` (default true), `default_model`, `updated_by`, timestamps | The seed list in `server/routes/aiCapabilities.seeds.ts` includes keys such as `document_extraction`, `passport_extraction_gcp`, `text_review`, `text_enhancement`, `field_suggestion`, `document_translation`, `housing_lead_scoring` and `host_verification_analysis`. The migration history for models and token limits is in `migrations/` (for example `20261021000000_upgrade_gpt_4o_mini_to_gpt_5_mini.sql`). ### Invocation logging `executeAiCapability` logs success and error paths fire-and-forget; a failed log write is swallowed and does not change the response. On success the log keeps the first 2000 characters of the prompt, the payload (with `imageBase64` replaced by a length marker), `contextOverrides`, the first 4000 characters of the result and the first 4000 characters of the stringified parsed JSON. On error it stores only `payloadKeys`. `application_id` is taken from `payload.applicationId` when it is a non-empty string, else null. `executeAiCapabilityDirect` logs the first 500 characters of the prompt and 1000 of the result, does not set `application_id`, does not log a malformed-JSON failure, and returns `OpenAI API key is not configured` rather than `AI_NOT_CONFIGURED` when there is no client. ## Behaviour and edge cases - `getAiCapabilityByKey` does not filter by `status` (comment in `server/storage/aiStorage.ts`). `executeAiCapability` does not check `status`, so a capability that is not `active` still executes if its row exists. - `GET /api/ai/providers` never returns the full key. A PUT that sends the masked value back keeps the stored key. - `api_key` is stored as plain text in `ai_provider_settings`; no encryption is applied in the storage code. - Capability edits and provider changes are audited; capability delete and seed are not audited in their handlers. ## Known gaps - The 404 path in `/api/ai/execute` depends on the substring `not found` in the error text; a provider error message containing that text would also map to 404. - The text routes test for lowercase `not configured`, but the service returns `AI_NOT_CONFIGURED`. By string comparison that does not match, so an unconfigured client on those four routes appears to surface as 500 INTERNAL_ERROR rather than 503. Not exercised against a running instance. - `POST /api/ai/providers/test` ignores `providerKey` for the actual call. - `limit` on the invocation routes is parsed with `parseInt` and not bounded. - Per-capability rate limits and usage metrics described in the product-level [Capability Layers](/docs/globus-layers) page were not looked for here; only `invocationCount` and `avgDurationMs` are computed by the listing route. --- --- title: "Rules Engine" slug: rules-engine section: Rules & Compliance status: stable description: "The single authority for every eligibility, compliance, and configuration-integrity decision in Globus." last-updated: 2026-10-07 --- # Rules Engine The single authority for every eligibility, compliance, and configuration-integrity decision in Globus. Built on a five-layer architecture (Policy, Facts, Evaluation, Enforcement, Action Orchestration), a typed rule-tree model, first-class versioning, deterministic replay, and governed override policy — all specified at final maturity before engineering begins. > **Documentation surface — implemented in Globus, not here** > > Every API endpoint, table, RBAC scope, reason code, response envelope, and SQL fragment on this page is a normative contract for the Globus implementation site. Nothing on this page is implemented in this portal — this is the architecture/specification site. The canonical Source of Truth for downstream contradictions is the Normative Contracts section; older sections of this page that conflict with it are superseded. > **One Engine, One Decision Authority** > > The Rules Engine replaces every scattered eligibility check — matching hard filters, SEVIS readiness probes, workflow publish gates, finance-pass checks, host vetting conditions — with one structured system that every consuming service queries. The engine produces a structured decision object that carries outcome, blocking effect, required actions, machine-readable reason codes, and case recommendations. Consumers never bypass it and never implement their own parallel logic. See how the engine gates lifecycle transitions in the Applicant Lifecycle Model . ## The Five-Layer Engine The engine is decomposed into five layers with explicit contracts between them. Each layer owns a single concern, is independently testable, and has a single input/output contract so the boundaries cannot blur over time. ### Layer 1 — Policy Policy owns what rules exist. A rule has an immutable identity (rule_definitions) plus one or more immutable versions (rule_versions) containing the rule tree and default parameters; action rows and the override policy are stored in the related rule_actions and rule_override_policies tables. Each action row carries its own reason code and queue-routing fields — reason codes are bound at the action level, not the version level. A policy bundle is the set of active rule versions resolved for a given (brand, program, season, application type, phase) context at a point in time. Retroactive policy changes and future-dated rules are expressed as new versions — the engine never mutates a historical version. Contract out of this layer: a policy bundle identified by policy_bundle_version, plus the parameter values resolved from Brand → Program → Season hierarchy. ### Layer 2 — Facts The Facts Layer produces a canonical, stable view of the world for the entity under evaluation. Upstream sources (applicant profile, documents, SEVIS, finance, matching, placement, host records) speak different schemas and change shape over time. The Facts Resolver normalizes them into canonical facts like passportExpiryDate, backgroundChecks, feeStatus, hasSevisCheckinOverdue. Every fact carries provenance metadata (collectedAt, sourceLastUpdatedAt, freshness, confidence, conflictStatus). Rules read facts — never raw service payloads — so schema drift in a source service is absorbed by the resolver, not leaked into thousands of rule expressions. ### Layer 3 — Evaluation Evaluation is a pure function: given (policy bundle, resolved parameters, facts snapshot), it produces a decision object. No I/O, no side effects, no calls out to services. Purity is what makes deterministic replay possible: re-running evaluation with the same inputs always produces the same outputs, regardless of the current state of the world. The evaluator walks the rule tree, resolves named predicates, evaluates derived facts, applies count and time-window operators, and classifies the outcome. ### Layer 4 — Enforcement Enforcement interprets the decision's outcome (pass / fail / review_required / indeterminate / error) and each action row's blocks_transition flag at the point of a lifecycle gate, publish check, or data mutation. It is the layer that stops a transition from happening, marks an applicant as hold-for-review, or allows passage with a surfaced warning. Enforcement never decides — it applies decisions handed to it by Evaluation. ### Layer 5 — Action Orchestration Action Orchestration reads required_actions and case_recommendations from the decision object and dispatches them: routes queue memberships, opens compliance cases, fires notifications, writes audit records, publishes outbox events. Action intents are declarative — rules describe what should happen, the orchestrator decides how. > **Why Separating These Matters** > > A monolithic engine where conditions, side effects, and blocking behavior are tangled together cannot be versioned, replayed, or reasoned about. The five-layer split means a compliance officer can load any historical evaluation, swap in a proposed new policy bundle, and see how the outcome would change — without any risk of firing real notifications or opening real cases. ## Rule Families & Classification Rules fall into two disjoint families based on what they evaluate against, independent of the regulatory / business category axis (see Rule Category & Rule Class). Family controls which engine invocation path runs the rule. ### Entity Policy Rules Evaluate against a live entity (applicant, host, job order, J-2 dependent) at runtime to decide whether that entity can proceed. Examples: age eligibility, passport verified, SEVIS check-in overdue, host probationary standing. These are what most people think of as “rules”. ### Configuration Integrity Rules Evaluate against configuration artifacts (workflows, program setups, season definitions) to gate publishing. Examples: every compliance rule is covered by at least one workflow step; DS-2019 issuance step exists; payment gates wired to finance status. These run when a configuration artifact is saved or published, not on runtime entities. > **Why separate the families** > > A workflow coverage rule and a passport-verification rule have nothing in common operationally: different triggers, different data sources, different consumers, different UIs. Conflating them in v1 led to ambiguous ownership. Separating them now means each family can have its own specialized tooling without cross-contamination. ### Business Rule Subclasses Business rules — those not mandated by regulation — are further sub-classified so their intent is visible in queues, analytics, and the override governance console: > **Capacity is Operational, Not Compliance** > > Capacity management and waitlisting are operational business rules, not compliance. They enforce program operating policy (how many applicants a season can hold) — not federal regulation. Treating them as compliance in v1 caused confusion over who could adjust caps and whether changes needed regulatory justification. They don’t. ## Rule Category & Rule Class Every rule carries two required, governed classification fields: rule_category (the high-level bucket) and rule_class (the functional type). Together they let operators find and manage rules by purpose — Documentation, Identity, SLA, Lifecycle Automation, Operational Review — rather than by the broad regulatory-vs-business cut alone. Both are distinct from family (which engine path runs the rule) and from the outcome / decision classes (the evaluation result, covered under Decision Classes). > **rule_category is the legacy classification field, renamed** > > The compliance-vs-business axis previously stored as classification is the same axis as rule_category — there is only one such bucket per rule. Going forward it is named rule_category with values regulatory | business; the old classification value compliance maps to regulatory. Do not model them as two separate fields. ### rule_category — High-Level Bucket Required on every rule. Stored as compliance (displayed as Regulatory), business, or operational_gate (readiness gates): ### rule_class — Functional Type Required on every rule. The allowed values depend on the selected rule_category — a regulatory rule may only take a regulatory class, a business rule only a business class. These are a closed enum: free-text class names are not permitted in MVP. Regulatory classes | rule_class | What it covers | | --- | --- | | eligibility | Baseline statutory eligibility to participate (e.g. minimum age, program eligibility floor). | | identity | Verifies who the applicant is and guards against impersonation or duplication (passport validity, identity verification, duplicate check). | | documentation | Requires a regulated document be present and valid (passport uploaded, required regulatory forms). | | compliance_attestation | Captures a legally significant declaration or attestation (criminal-history declaration, signed certifications). | | program_qualification | Confirms the applicant qualifies for the specific program category (current-student status, job-order fit). | | participation_window | Enforces date or time windows mandated by regulation (academic vacation dates, program start/end window). | | compliance_gate | Seventh regulatory class accepted by the engine (RULE_CLASSES_REGULATORY); not described further here. | Business classes | rule_class | What it covers | | --- | --- | | workflow_completion | Requires an applicant- or staff-completed workflow step before proceeding (profile completion, section sign-off). | | applicant_action | Requires a discrete applicant action or consent (terms accepted, acknowledgement). | | lifecycle_automation | Drives automated lifecycle transitions (auto-lapse, auto-cancel, auto-advance). | | queue_routing | Routes work into operational queues (queue assignment, reassignment). | | sla | Time-based reminders and escalations (reminder emails, SLA timers). | | operational_review | Routes to a human for manual operational review or decision (manual staff review). | > **rule_class vs business_subclass** > > rule_class is the functional type (what the rule does); business_subclass (operational / advisory / optimization) is the enforcement posture (how hard it bites). They are orthogonal — a business rule carries both. rule_class does not replace business_subclass. ### Builder UX — Dependent Dropdown The Rule Builder shows a Category control (Business / Regulatory) and a Class dropdown whose options are filtered by the chosen category. Changing the category resets the class. Valid pairings read naturally — e.g. Regulatory + Documentation or Business + SLA. Only enum values are selectable; there is no free-text entry. ### Default Migration Mapping Existing rules are back-filled with a default category and class on migration so no published rule is left unclassified: | Existing rule | rule_category | rule_class | | --- | --- | --- | | Profile completion | business | workflow_completion | | Terms accepted | business | applicant_action | | Reminder emails | business | sla | | Auto-lapse / auto-cancel | business | lifecycle_automation | | Manual staff review | business | operational_review | | Queue assignment | business | queue_routing | | Passport uploaded | regulatory | documentation | | Passport validity | regulatory | identity | | Criminal declaration | regulatory | compliance_attestation | | Identity verification | regulatory | identity | | Duplicate check | regulatory | identity | | Student status | regulatory | program_qualification | | Vacation date / window | regulatory | participation_window | | Minimum age | regulatory | eligibility | ### Where Category & Class Appear Once classification is governed it must surface consistently everywhere a rule is authored, listed, inspected, or served: - Creation & edit flows — both fields are captured; category and class are required. - Rules list — Category and Class show as columns. - Filters — the rules list and rule bundle pages can filter by Category and Class. - Rule detail — the detail page displays Category and Class. - Publish validation — publishing is blocked if either category or class is missing. - Audit log — changes to category and class are captured as audit events. - API responses — every rule payload includes rule_category and rule_class. ## Rule Definition Anatomy A rule is split into an immutable identity (in rule_definitions) and one or more immutable versions (in rule_versions). The version carries the policy payload: the rule tree, action rows, output contract, and override policy. New requirements create a new version — the old version stays live for replay. ### Identity & Classification | Field | Description | Example | | --- | --- | --- | | key | Unique immutable system reference | passport_verified | | name | Human-readable label | Passport Verification Required | | description | What this rule enforces and why | Valid passport verified before SEVIS pool entry | | family | entity_policy or configuration_integrity | entity_policy | | rule_category | regulatory or business — governed successor to the legacy classification field | regulatory | | rule_class | Functional type; allowed values depend on rule_category (see Rule Category & Rule Class) | documentation | | business_subclass | Optional free-form tag (e.g. finance, capacity) — not a closed enum, orthogonal to rule_class | — | | regulatory_basis | Regulatory citation (required for regulatory-category rules) | 22 CFR 62.10 | | current_published_version_id | FK to rule_versions — the currently-published version | rv_passport_verified_v3 | ### Scope & Bindings Scope (rule_scopes) declares where a rule version applies: lifecycle phases, application types, entity type, brand/program filters. Bindings (rule_bindings) attach the rule to specific trigger points — phase gates, configuration artifacts, schedules, or ad-hoc evaluation entry points. A single rule can be bound to multiple attachment points without duplicating the definition. field ### Structured Rule Fields Alongside the recursive rule_tree, every rule definition carries structured shorthand fields (subject_key and operator_key are required columns on rule_definitions; rule_group_key is optional) that the Rule Builder uses for its single-condition fast-path. They give the engine, the Builder, and external consumers a stable, queryable summary of what this rule is checking without having to traverse the tree. When present they must be derivable from — and consistent with — the tree; the engine validates this on publish. | Field | Purpose | Example | | --- | --- | --- | | subject_key | Canonical subject of the rule — usually a fact_key or derived attribute the rule asserts against. Drives Builder UI grouping, Facts Browser back-links, and per-rule analytics filtering. | passportExpiryDate | | operator_key | Single-condition shorthand of the comparison operator. Set only when the tree is a single leaf or simple combinator; null for compound trees. Must match the leaf operator if present. | gte | | rule_group_key | Optional grouping label that lets compliance authors cluster related rules in the Builder, dashboards, and reports (e.g. all passport-related rules). Free-form but governed. | passport_validity | > **Tree is the Source of Truth** > > When subject_key, operator_key, or rule_group_key are populated they are derived from the rule tree at author time. The engine evaluates the tree, not the shorthand fields. Publish-time validation rejects any version where the shorthand contradicts the tree. ### Internal vs Applicant-Facing Messages Every rule version carries two distinct failure messages. Confusing them leaks operational detail to applicants and starves staff of the context they need to triage. Both are required for regulatory rules; business rules may omit the applicant-facing copy when the failure is internal-only. | Field | Audience | Tone & Content | Surfaced In | | --- | --- | --- | --- | | internal_failure_message | Sponsor staff, compliance officers, queue owners | Operational, may name fact keys, regulatory citations, internal queue names, override paths, and triage pointers. | Compliance Console, queue cards, case detail, evaluation record viewer | | failure_message (applicant-facing) | Applicants and (where relevant) hosts | Plain-language, action-oriented, locale-aware. Must not leak fact keys, internal queue names, or staff workflow detail. | Applicant Portal status banners, notification emails, Host Portal blocking notices | > **Publish-Time Lint** > > Publish lint forbids identical strings between the two fields and rejects applicant-facing copy that contains a known fact_key, queue name, or regulatory citation. Compliance authors must write both — the Rule Builder will not accept a publish request with either field empty for a compliance rule. ### Inline Field-Level Failures on Portal Steps Shipped Aug 11 2026 (Aug 11 2026 release capsule — Workflows 2, PR #1786). When a compliance rule fails and that failure is associated with a specific step field, the applicant now sees the failure message directly on the relevant field inside their portal step — exactly the way a form validation error appears — rather than a single generic banner. Staff configures which rule's failure reason surfaces on which step field through the workflowFieldName mapping in the facts catalog (see Canonical Facts Catalog ). This supersedes the prior behavior, where applicants had no field-level visibility into why their application was blocked and only received a generic "compliance issue" message. Applicants now see the specific issue and the corrective action inline, without staff needing to send a separate communication. | Concern | Gate | | --- | --- | | Staff-side compliance view | compliance.console.view | | Applicant-side inline field feedback | the rule's applicant_visible flag on the reason code | ## Publish Lifecycle Rule versions are not edited in place — they move through an explicit draft → published → archived lifecycle. Only published versions are loaded into the active policy bundle and evaluated at runtime. Draft versions are visible in the Rule Builder and the Test Runner but are invisible to the engine’s evaluation pipeline. Archived versions remain queryable for replay and audit but are never selected for new evaluations. ### Rule Version States | State | Mutable? | Loaded into Engine? | Allowed Transitions | | --- | --- | --- | --- | | | Yes — rule_tree, params, messages, scopes can be revised | No | draft → published (via publish action), draft → archived (discard) | | | No — fully immutable | Yes — selected for active policy bundle | published → archived (when superseded by a newer published version of the same rule_definition) | | | No | No — but queryable for deterministic replay | Terminal state. Cannot return to draft or published; clone into a new draft instead. | Each rule_versions row carries: status (draft | published | archived), version_number (monotonic per rule_definition), cloned_from_rule_version_id (nullable FK; set when authoring a draft from an existing version), published_at / published_by_user_id, and archived_at / archived_by_user_id. **Definition delete and rule-set membership.** `DELETE /api/rules-engine/definitions/:id` (`compliance.rules.govern`, body `reason` of 8 to 2000 characters) permanently removes a rule definition with its versions, test cases, scopes and bindings, after detaching it from sets and bundles. It returns `404` for an unknown id and `409` with top-level `error.code` `RULE_HAS_OVERRIDES` while applicant overrides still reference the rule (revoke them first; the same code is returned if an override appears between the check and the delete). It returns `409` with code `RULE_DELETE_BUSY` and `details.retryable` set to true when the database reports error code `55P03`; the message says the rule is in use by another operation and no deletion was committed. Bulk add and bulk remove on `/api/rules-engine/business-sets/:id/rules/bulk-add` and `/bulk-remove` (`compliance.rules.author`) are refused wholesale for a published set with `409 RULE_SET_PUBLISHED_IMMUTABLE` and for an archived set with `409 RULE_SET_ARCHIVED`; a deprecated set still accepts changes. **Complete draft creation.** `POST /api/rules-engine/drafts` (`compliance.rules.author` and `compliance.rules.assign`; the route declares API-key scopes `compliance.read` and `admin.write`) validates the body against `ruleDraftCreationSchema`, which carries `definition`, `version`, `scopes` and `actions`, and creates the draft through `createCompleteRuleDraft`. Before creating, it checks the caller's `assign` authority for the definition's classification. It checks each scope (an unknown programme is a not-found error, and a season that does not belong to the selected programme is a validation error) and each action (the action type must be applicable to its outcome class, and the action passes the fail-action queue check). The audit entry (`create` on `rule_definition`) is written with `writeAuditInTransaction` from a callback that receives the transaction. The route returns `201` with the created result. ### One Published Version Per Scope At most one version of a given rule_definition may be in the published state for a given (brand, program, season) scope window at any moment. Drafts can stack freely; only one is live at a time. Publishing a new version of the same rule into the same (brand, program, season) scope automatically archives the previously-published version in the same transaction and emits both publish-audit entries atomically. This is the contract the runtime resolver depends on (see Runtime Resolution). > **Effective-Dating vs Publishing** > > effective_from / effective_to control when a published version is considered active inside the engine. Publishing is the act of making a version eligible for selection; effective dating selects which eligible version applies at a given evaluation timestamp. A version may be published yet not-yet-effective. ### Publish Audit Trail Every state transition writes one row to rule_version_publish_audit. The table is append-only and immutable; rows are never updated or deleted. Together with the version row itself it gives compliance a full reconstruction of who changed what, when, and why. | Field | Purpose | | --- | --- | | rule_version_id | FK to the version that transitioned | | rule_definition_id | Denormalized FK for fast filtering by rule | | from_status / to_status | Both states recorded — draft→published, published→archived, draft→archived | | changed_by_user_id | User who performed the transition (must hold publish permission for the rule classification) | | actor_role | Role at the time of action — captured for replay even if the user's role later changes | | reason | Required free-text justification on every transition; sponsor audits expect this present | | diff_summary | JSON summary of what changed vs the prior version (rule_tree hash, parameter deltas, scope deltas, message deltas) | | test_cases_passed / test_cases_total | Retired: columns remain for historical rows but are no longer written (test cases no longer gate publish) | | policy_bundle_version_after | The new active policy bundle version stamped after this publish; null for archive | | ip_address / user_agent | Request metadata for security forensics | | changed_at | Server-side timestamp of the transition (immutable) | ### Publish Gates (Builder Contract) The Rule Builder (Task #165) is the only legitimate path to publish. It must enforce all of the following before the publish API will accept the request — and the API itself re-checks each gate server-side: **Outcome copy gate (`OUTCOME_COPY`).** Every not-passed (`fail` / `needs_review`) action on a rule version must carry authored outcome copy: severity, reason code, destination queue, an applicant-facing message (the action's own `applicantMessage`, separate from the version-level applicant failure message above), and a remediation that names its `kind` plus an `applicantInstruction` or `operatorGuidance` (the next action and its owner). A `pass` outcome is never judged. The gate reports `INCOMPLETE_OUTCOME_COPY` naming the missing fields per action. It runs after the reason-code and queue gates and suppresses only a field those gates already blocked for the same action. A small capped register of exceptions (parameter-carrier rules such as `reservation.hold_duration_bound.*`, and unbound dormant `j1.intern|trainee|intern_trainee.*` placeholders) is exempt. The same definition of "complete" is re-checked at boot by a published-rule outcome-copy audit: blocking outside production, logged (not fatal) in production, where the deploy gate in `scripts/run-migrations-psql.sh` blocks instead. **Gate result codes.** Each gate returns issues, which block publish, or warnings, which are reported without blocking. Every entry carries `gate`, `code`, `message` and optional `detail`; clients should switch on `code`. A missing version or definition returns a single issue and no other gate runs. | Gate | Code | Result | When | | --- | --- | --- | --- | | `REQUIRED_FIELDS` | `VERSION_NOT_FOUND`, `DEFINITION_NOT_FOUND` | Issue | The rule version, or the definition it belongs to, does not exist. | | `REQUIRED_FIELDS` | `MISSING_REQUIRED_FIELD` | Issue | A required field is empty or `unspecified`; `detail.field` names it. Checked: `internal_failure_message`, the version's `applicant_failure_message` and `fail_safe_mode`, plus `subject_key` and `operator_key` (not required for operational gates). | | `REQUIRED_FIELDS` | `IDENTICAL_FAILURE_MESSAGES` | Issue | The definition's internal failure message and the version's applicant failure message are identical. | | `REQUIRED_FIELDS` | `MISSING_RULE_CLASS`, `INVALID_RULE_CLASS_FOR_CATEGORY` | Issue | `rule_class` is empty, or is not valid for the definition's classification. | | `TREE_VALIDITY` | `TREE_INVALID`, `TREE_HAS_NO_LEAVES` | Issue | The rule tree fails schema validation, or contains no leaf predicate. | | `TREE_VALIDITY` | `UNKNOWN_FACT_KEY` | Issue | A leaf, count or time-window fact key, or a `$fact` operand, is not in the fact catalog (`__now__` is always allowed). | | `TREE_VALIDITY` | `PARAMETER_REF_MALFORMED`, `FACT_REF_MALFORMED` | Issue | A leaf comparison value uses `$param` or `$fact` without naming a key. | | `TREE_VALIDITY` | `UNRESOLVABLE_PARAMETER_REF` | Issue | The tree compares against a parameter that has no value on the version, either in its parameter bag or in a `rule_parameter_defaults` row. | | `TREE_VALIDITY` | `SHORTHAND_GROUP_KEY_INCONSISTENT` | Issue | `rule_group_key` is set but a leaf subject falls outside that group prefix. | | `GATE_DEPENDENCIES` | `NO_GATE_DEPENDENCIES`, `DUPLICATE_GATE_DEPENDENCY`, `GATE_DEPENDENCY_SELF_REFERENCE`, `DEPENDENCY_NOT_FOUND`, `INELIGIBLE_GATE_DEPENDENCY`, `DEPENDENCY_NOT_PUBLISHED` | Issue | Operational gates only, in place of the tree check: no prerequisite, a repeated or self-referencing one, one that does not exist, one that is not a regulatory or business rule, or one with no current published version. | | `SCOPE_PRESENT` | `NO_SCOPES` | Issue | No `rule_scopes` row on the version. | | `ACTION_PRESENT` | `NO_FAIL_ACTION` | Issue | No action with outcome class `fail`. | | `REASON_CODE` | `MISSING_REASON_CODE`, `REASON_CODE_NOT_FOUND`, `REASON_CODE_ARCHIVED`, `REASON_CODE_DEPRECATED` | Issue | Checked per `BLOCK`, `WARNING`, `REVIEW_QUEUE`, `REQUIRE_DOCUMENT` and `AUTO_REJECT` action: no reason code, an id that does not resolve, or an archived or deprecated code. | | `VISIBILITY_POLICY` | `INVALID_MODE`, `INCOMPLETE_POLICY`, `INVALID_PREDECESSOR`, `FORWARD_PREDECESSOR` | Issue | Operational gates only, and only when a visibility policy is explicitly authored on the version: the mode is not Always or Progressive, a Progressive policy has no earliest journey phase, or the predecessor gate family is unknown or is not an earlier family. | | `OUTCOME_COPY` | `INCOMPLETE_OUTCOME_COPY` | Issue | Described in the outcome copy gate paragraph above. | | `QUEUE_ASSIGNMENT` | `MISSING_QUEUE_ASSIGNMENT` | Issue | A `REVIEW_QUEUE` action has no queue key. | | `REASON_CODE_QUEUE_MISMATCH` | `REASON_CODE_QUEUE_MISMATCH` | Issue | The reason code's routing area is not among the areas allowed for the destination queue. Passes when the queue is unconstrained or the code has no area. | | `SCOPE_CONFLICT` | `WILL_SUPERSEDE_PUBLISHED_VERSIONS` | Warning | An immediate publish will supersede prior published versions covering the same scope. | | `SCOPE_CONFLICT` | `OVERLAPPING_PUBLISHED_VERSIONS` | Issue | The same overlap on a future-dated version; archive the conflicting versions first. | | `REMEDIATION_TARGET` | `REMEDIATION_TARGET_MALFORMED`, `REMEDIATION_TARGET_UNRESOLVED` | Issue | A remediation target (`portal_step`, `profile_field` or `document_type`) is missing its key, field name or document type code or id, or names a portal step, promotable profile field or document type that does not exist. | | `REMEDIATION_TARGET` | `REMEDIATION_FACT_KEYS_NO_OVERLAP` | Warning | Declared remediation fact keys do not overlap the facts the rule evaluates. | | `AUTHORITY` | `MISSING_AUTHORITY_CONTEXT`, `INSUFFICIENT_AUTHORITY` | Issue | The publishing actor role was not supplied, or is not one permitted to publish rule versions. | | `PUBLISH_REASON` | `MISSING_PUBLISH_REASON`, `PUBLISH_REASON_TOO_SHORT` | Issue | Compliance rules only: no publish reason, or one shorter than 8 characters. Business rules need no reason. | | `LINT` | `APPLICANT_COPY_MENTIONS_FACT_KEY`, `APPLICANT_COPY_MENTIONS_QUEUE_CODE`, `APPLICANT_COPY_MENTIONS_CITATION` | Issue | The applicant failure message names `fact_key` identifiers, an internal queue code such as `queue:compliance_review`, or a regulatory citation (22 CFR, INA, USCIS, 8 U.S.C.). | | `BUNDLE_ASSIGNMENT` | `MISSING_BUNDLE_ASSIGNMENT` | Warning | A compliance or business rule is not yet in a regulatory bundle or business rule set, so it has no runtime effect until assigned. | **Reviewed candidate publication.** A candidate-preview route exists at `/api/rules-engine/versions/:id/candidate-preview`. The publish request body of `POST /api/rules-engine/versions/:id/publish` has a `candidatePreview` object with `inputHash` (64 lowercase hex characters) and `evaluatedAt` (an ISO datetime with offset). For compliance rules, business rules and operational gate versions that have a progression binding, the publish route checks whether `candidatePreview` is present. When it is missing, the route throws a conflict error with the message `Review the saved candidate and its impact before publishing.` and the code `CANDIDATE_PREVIEW_REQUIRED`. ### Imported Drafts & Environment Sync Publication Preflight Shipped Sep 2026. Two adjacent guarantees make machine-generated rule and bundle drafts safe to activate. **Imported-rule drafts publish only after review, all-or-nothing.** A file-based configuration import never modifies a published rule or regulatory bundle; it lands **fresh** rule-version and bundle draft rows and records their exact ids on the import run (the run's landed set is a provenance boundary — an older draft elsewhere in the Rules Library is never pulled into the batch). Data Management then offers **Review & publish drafts**: | Purpose | Method | Path | | --- | --- | --- | | Preview imported drafts | GET | `/api/admin/config-import/runs/:runId/publish-preview` | | Publish imported drafts | POST | `/api/admin/config-import/runs/:runId/publish` (body: `publishReason` 8–500 chars, `previewToken`) | - The preview returns rule and bundle counts, live publish blockers (the same gates as the Builder Contract above), superseded versions, bundle reach, queue-routing consequences with the governing SLA policy (a destination without an active policy is a warning, not a blocker), and a `previewToken` fingerprint of what was reviewed. - Publication locks and reloads the complete recorded set, re-derives the impact inside its own transaction and compares it with the approved review. Rules publish before bundles so every membership resolves to a live rule. Any rule, queue-route or bundle validation failure rolls back the **entire** batch; the batch is never trimmed to the members that would pass. - `400` — a recorded draft is no longer a draft (published or archived by someone else), or the reason/token is missing; nothing was published. `409 STALE_PUBLISH_PREVIEW` — something consequential moved between review and confirmation (new supersession, a gate now failing, a routing destination deactivated, bundle reach widened); reopen the preview and confirm the current one. `409 IMPORT_RUN_NOT_OPERATOR_OWNED` — the run belongs to Environment Sync and must be activated through Environment Sync's own finalisation, never through this route. - **Reason-code rule on rule-definition import.** When a configuration import applies rule definitions, a `BLOCK` or `REVIEW_QUEUE` action with no `reasonCode` fails the import with an `ImportError` (it was previously accepted silently with a null reason code), so queue routing and reporting always have a reason code to key off. A `reasonCode` that does not exist in the target environment also fails, with a message to seed the reason-code vocabulary first. **Environment Sync Dry Run catches publication blockers before Apply.** The Development → QA Environment Sync plan fingerprints QA's covered configuration and runs the *real* import pipeline inside a rollback-only transaction. Every automatic rule-publication candidate is then pushed through the same action and publication gates that Apply would use; the transaction is always rolled back and every blocker is returned in one plan without changing QA. Operators review field-level differences, validation errors, staged drafts, operational-gate drafts and proposed QA-only retirements, and continue only when the publication preflight reports no validation errors for any candidate. A failed preflight names the rule and the gate; correct the source rule or QA dependency and create a fresh plan — Apply is not the tool for discovering the next error. **Incomplete Development baseline.** When the Development rule and bundle manifest is incomplete (for example `ruleDefinitions` or `ruleBundles` absent, an entry without an explicit `lifecycle`, a rule with an unsupported `classification` outside compliance, business, regulatory and operational_gate, or a published or draft rule without an explicit behavior-bearing version), the plan from `POST /api/admin/environment-sync/development-to-qa` (system admin) returns 200 with a `blocker` object whose `code` is `CONVERGENCE_MANIFEST_INCOMPLETE` and whose message lists every problem, and `POST /api/admin/environment-sync/finalize` returns 422 with the same top-level `error.code`. Fix Development and create a fresh plan; the gap is not filled from QA. See [Config Import / Export](/docs/config-import-export) for the sync surfaces themselves. ## Test Cases Test cases are first-class artifacts of the rules engine — not ad-hoc scripts living outside the platform. Every rule definition can carry an unbounded set of named test cases, each pinning a FactsSnapshot fixture to an expected outcome. Test cases are the mechanism by which the publish gate verifies that a candidate version still satisfies its intended behavior, and the historical record of what behavior the rule was ever asserted to have. ### Test Case Model Stored in rule_test_cases. Cases are bound to a rule_definition, not a specific version, so they survive publishing. | Field | Purpose | | --- | --- | | rule_definition_id | The rule under test (cases survive across versions of that rule) | | name | Human-readable label (e.g. 'Passport expires day after program end — should fail') | | description | What real-world condition this case represents | | category | happy_path \| edge_case \| regression \| boundary \| adversarial — drives Test Runner grouping | | synthetic_facts_snapshot | JSONB FactsSnapshot fixture: full synthetic inputs the rule will be evaluated against (mirrors evaluation_pipeline input) | | resolved_parameters | Optional override of resolved Brand→Program→Season parameters; defaults to the version's defaults | | expected_outcome | pass \| fail \| needs_review — the outcome the case asserts | | expected_action_types | Array of action types the case asserts the rule will emit | | created_by / created_at / updated_at | Author of the case (only owner or compliance admin can deactivate) plus standard timestamps | ### Test Case Runs & Version Binding Every execution of a test case writes one row to rule_test_case_runs. Runs are immutable. They are produced by ad-hoc executions in the Test Runner UI. Test cases do not gate publishing: a rule can be published with no test cases or with failing ones; runs are informational only. | Field | Purpose | | --- | --- | | test_case_id | FK to the rule_test_cases row that was executed | | rule_version_id | FK to the specific version evaluated — every run is pinned to a version, so 'this test passed' stays meaningful after the rule is edited | | run_by_user_id | Null for system-triggered runs | | run_at | Server-side timestamp | | actual_outcome | Outcome the engine produced for the case | | actual_action_types | Array of action types the engine actually emitted | | passed | Boolean — whether the run met the case's expectation | | diff_summary | JSONB describing the delta when passed = false | `POST /api/rules-engine/versions/:id/test-runs` records one run row for a version (permission `compliance.rules.author`, plus the program-manager scope guard on the test case's rule). Body: `testCaseId` (UUID), `actualOutcome`, `actualActionTypes` (default empty), `passed` and optional `diffSummary`; `run_by_user_id` is the caller. The server stores what the client reports and does not re-evaluate. It returns `201` with the run row and `404` when the test case does not exist. `POST /api/rules-engine/versions/:id/test-runs/preview` (also `compliance.rules.author`) evaluates an ad-hoc payload (`name`, `facts`, `expectedOutcome`, optional `expectedActionTypes`) against the version in memory and writes no test case or run rows. > **Snapshotting at the Publish Gate** > > The publish gate executes every active test case for the rule against the candidate version, writes the run rows, and aggregates pass/fail counts onto the publish-audit row (test_cases_passed_count / test_cases_total). Any failure aborts the publish atomically — no version state change, no audit row, no policy bundle bump. ### Test Runner UI Surface The Test Runner is a Compliance Console surface, sibling to the Rule Builder. It is the only UI through which authors create, edit, execute, and review rule test cases. ## Per-Rule Evaluation Results *Status: the field table below is the original design. The built per-node child table is rule_evaluation_node_results, and the table named rule_evaluation_results is a separate application-scoped table (keyed by application_id and compliance_snapshot_id), not a child of rule_evaluation_records.* rule_evaluation_records captures one row per rule version evaluated against an entity for a given trigger source and idempotency key (unique index on rule_version_id, entity_id, trigger_source, idempotency_key). Its child table rule_evaluation_node_results holds one row per tree node (record_id, node_path, node_kind, outcome, subject_key, operator_key, fact_keys). This is the table that powers per-rule explainability: queue cards link to the specific failing rule_evaluation_results row, the case viewer shows the contributing rule’s tree-trace, and analytics dashboards aggregate “rules most often failing” without scanning JSONB blobs in the parent record. | Field | Purpose | | --- | --- | | evaluation_record_id | FK to the parent rule_evaluation_records row (the bundle pass) | | rule_definition_id | Denormalized FK for fast per-rule queries | | rule_version_id | Exact version evaluated; pins replay | | rule_key | Denormalized rule_definitions.key — stable identifier surfaced in queue cards, dashboards, and audit answer-back without joining | | result | Enum: passed \| failed \| skipped \| overridden \| errored. The single field that tells you what this rule did on this evaluation pass. | | severity | Severity at decision time (info \| warning \| blocking \| critical) — propagated from reason codes / output_contract | | reason_codes | JSONB ordered list of reason codes this specific rule emitted | | applicant_facing_message | failure_message after parameter interpolation; null on result = passed or when not applicable | | internal_message | internal_failure_message after parameter interpolation; null on result = passed | | parameters_snapshot | JSONB capture of the resolved parameter values this rule actually used (post Brand→Program→Season cascade) — required for replay without re-resolution | | provenance | JSONB: source_scope: 'brand' \| 'program' \| 'season', inherited_from_rule_version_id: int \| null, suppressed_higher_scopes: int[] . Explains which scope's published version actually fired and what it overrode. | | evaluated_at | Server timestamp; same as parent record's evaluated_at | > **Why a Separate Table** > > The parent rule_evaluation_records stores the full decision JSONB as the authoritative replay payload, but JSONB is opaque to indexes. Per-rule rows let queues, dashboards, and Compliance Console queries filter and aggregate by (rule_definition_id, result, evaluated_at) or by rule_key with normal B-tree indexes. The JSONB blob remains canonical; the normalized table is the queryable explainability layer. ## Resolution Snapshots *Status: the description and field table below are the original design. The built rule_resolution_snapshots table is written per evaluation, not per publish: it stores entity_type, entity_id, resolved_at, facts_schema_version, the verbatim facts snapshot and overrides_in_force, and is referenced by rule_evaluation_records.resolution_snapshot_id. It has no brand, program or season columns.* A resolution snapshot is a system-generated, publish-time artifact that records the effective resolution of all rules for a given (brand, program, season) scope at the moment a publish completes. For every rule in the resolved set the snapshot records which published rule_version won, which scope it came from, which higher-scope versions it replaced or suppressed, and the override_reason attached to the lower-scope record. The snapshot is the canonical answer to “why did this rule come out the way it did for program P, season S?” — without forcing anyone to re-walk Brand → Program → Season. A new snapshot row is written each time a publish completes anywhere in the (brand, program, season) scope chain. Snapshots are stored in rule_resolution_snapshots and are immutable. Subsequent runtime evaluations carry the latest snapshot’s id forward on each evaluation record so audit can trivially join evaluation → snapshot → exact rule_version per scope. > **Never Hand-Maintained** > > Resolution snapshots are derived/read-model artifacts — never user-authored or user-edited. There is no UI to create, modify, or delete a snapshot. Compliance staff who need to change resolution behavior do so by editing brand defaults, program rule configs, season rule overrides, or rule versions and publishing — the publish action computes a fresh snapshot. Manual writes to the snapshot table are rejected at the storage layer. | Field | Purpose | | --- | --- | | id | Snapshot primary key; referenced by every rule_evaluation_records row produced under this snapshot | | brand_id / program_id / season_id | Scope this snapshot resolves for. season_id may be null for brand+program-level snapshots; program_id may be null for brand-only | | policy_bundle_version | Bundle version active at the time of the publish that triggered this snapshot | | triggered_by_rule_version_id | FK to the rule_version whose publish caused this snapshot to be computed | | triggered_by_publish_audit_id | FK to the rule_version_publish_audit row for that publish — links snapshot to the audit story | | resolved_rules | JSONB array; one entry per rule in the resolved set: rule_key, published_rule_version_id, source_scope ('brand' \| 'program' \| 'season'), inherited_from_rule_version_id, replaced_rule_version_ids[], suppressed_rule_version_ids[], override_reason | | suppressed_rule_definition_ids | int[] of rules whose lower-scope override disabled them at this scope (audit trail for 'why is this rule not running') | | computed_at | Server timestamp of the publish that produced the snapshot; immutable | | engine_version | Engine semver that produced the snapshot | Each rule_evaluation_records row references the active snapshot via resolution_snapshot_id, so replay can reconstruct the exact resolved policy — including which lower-scope rule version replaced or suppressed which higher-scope version — without re-walking Brand → Program → Season. ## The Rule Tree Model Real sponsor policy cannot be expressed as a flat list of conditions. Consent requirements cascade from age. Missed-check-in violations count consecutive occurrences. J-2 cascades depend on primary J-1 status. The rule tree is a recursive, typed structure that expresses arbitrary sponsor policy while remaining statically checkable and visually editable. ### Tree Node Shape // Every node has a discriminating "kind" field. Shared fields: id, label (for debugging). type RuleTreeNode = | all | any | not | if_then_else | leaf | derived | count / eq / gte / lte / gt / lt | time_window / within / outside / before / after | cross_entity (named relation, resolved by an injected resolver); Parameter references (ParamRef) point at configurable parameter keys that are resolved from the Brand → Program → Season hierarchy at evaluation time. This keeps rule versions immutable while still letting operators tune thresholds per cohort. ### all / any / not The boolean combinators compose sub-trees. all requires every child to pass; any requires at least one; not inverts a single child. // Example: Fully compliant applicant = passport + background check + finance clear all ### if / then / else if_then_else is the only branching operator — it lets policy express conditional requirements without collapsing them into awkward any/all trees. The else branch is optional; when absent, the node passes trivially when the condition is false. // Worked example: Under-18 applicants require guardian consent on file if_then_else ### Leaf Predicates A leaf reads a single canonical fact by fact_key, applies a typed operator, and compares to a literal or parameter reference. Supported operators: eq, neq, lt, lte, gt, gte, in, not_in, matches (regex), plus exists / not_exists (and truthy / falsy) for presence checks; equals, nin, in_set and not_in_set are accepted aliases. A leaf whose fact has conflictStatus = conflicting yields review_required, and a missing fact yields indeterminate, not a silent pass. ### Derived (Computed) Facts Derived facts are computed on the fly from other facts — e.g. “days since last check-in”, “age at program start”, “J-2 surname matches J-1 primary”. They keep rule trees readable and avoid duplicating arithmetic inline. A derived fact declared in one rule can be referenced from another via its derived_fact_key. // Worked example: J-2 surname mismatch with primary J-1 applicant derived / j2.surname_matches_primary // Then in the rule tree: if_then_else ### Named Reusable Predicates *Status: not built. The rule tree has no predicate node or named_predicate definition (node kinds are leaf, all / any / not, if_then_else, count, time_window, derived and cross_entity), and the predicate keys below do not exist in code. Host standing and fee status are facts (hostStatus, feeStatus).* A predicate is a named, versioned tree fragment that accepts arguments. Predicates are defined once and referenced from many rules: background_check.current, host.in_good_standing, documents.wave_complete(wave: number), finance.status_is(status: string). The Rule Builder UI auto-completes predicate keys. // Predicate definition (stored once, referenced many times): named_predicate "background_check.current" // Referenced from rule tree: predicate / background_check.current ### Cross-Entity Aggregation Some rules need to reason about related entities: a J-2’s primary J-1, a host’s recent placements, a season’s siblings. The cross_entity node shifts the evaluation context to a related entity and evaluates a child tree against it. The count node aggregates across a collection. // Worked example: J-2 can advance only if primary J-1 has DS-2019 issued cross_entity / primary_of // Worked example: probationary host + stale site visit blocks job-order approval all ### Count Operators count aggregates events or entities over a query, with an optional consecutive flag for streak-style rules. // Worked example: two consecutive missed monthly check-ins → escalation count / gte ### Time-Window Operators time_window evaluates a fact across a window — rolling days, an absolute range, or a range defined relative to another fact (e.g. “within 90 days of program start”). // Worked example: passport expiry date checked against the program end date (passport-validity rule) time_window / passportExpiryDate / any ### Worked Examples — Full Rule Trees Five end-to-end rule trees covering the full expressive range of the model. ## Canonical Facts Layer Rules never read raw service data. Every input passes through the Facts Resolver, which normalizes source records into a small, stable vocabulary of canonical facts. This isolates the engine from upstream schema drift, gives every fact full provenance, and makes deterministic replay possible — historical evaluations replay against the facts snapshot that was persisted with the evaluation record. The fact_key vocabulary on this page is the same vocabulary referenced by rule_evidence_bindings.fact_key and by the workflow step library’s produces_fact_keys. That shared vocabulary is what lets Workflow Studio prove, before publish, that every regulatory rule has a workflow step (or named system process) producing the facts it consumes — see the Coverage Binding Model for the publish-gate contract. ### Facts Resolver The Resolver accepts (entity_type, entity_id, as_of_timestamp) and returns a FactsSnapshot versioned by facts_schema_version. It pulls from applicant profile, documents, SEVIS, finance, matching, placements, host records, and workflow progress — applies normalization, unit conversion, and canonicalization — then stamps each fact with provenance metadata. The snapshot is persisted alongside the evaluation record, so replay is deterministic even if source systems have since moved on. ### Canonical Facts Catalog *Status: the table below is the original dotted-key design. The implemented catalog (the factsCatalog definitions files, 206 facts) uses camelCase fact keys such as applicantAge, passportExpiryDate, hasSevisCheckinOverdue, feeStatus, hostStatus and daysSinceLastSiteVisit, plus a few dotted keys (host.status, host.agreements.hasActiveInWindow). Rows whose key has no counterpart there (for example finance.exceptions.open_count, sevis.record_status, ds2019.issued, documents.guardian_consent.verified and the host.document.* facts) are not implemented.* Every catalog entry also carries its presentation vocabulary: a required business `label`, a business meaning for each permitted value of an enum fact (`permittedValues`), and optional provenance (`producedByProcess`, `derivedFromFactKeys`, `computedFromFactKeys`) that drives the read-only "how this is determined" statement. Both the Rules Library and the applicant Compliance tab read fact names, value meanings and derivation statements from this one catalog. A module-load guard throws when a fact has no label or an enum fact's values have no meanings, so a missing label fails at import instead of degrading a screen. *Illustrative rows: the `background_check.*` rows below (and the worked examples built on `background_check.current` and `background_check.completed_at`) and the `host.terms_and_conditions.*` rows are design illustrations, not code keys. The code facts catalog (`shared/factsCatalog.definitions.*.ts`) has no such entries: background-check state is carried by `backgroundCheckPackageVerified`, `backgroundCheckPackageStatus`, `backgroundCheckProcessingStatus`, `backgroundCheckReviewStatus` and `backgroundCheckTranslationRequired` (none of them is a date), and host agreement state by the `host.agreements.*` facts such as `host.agreements.hasActiveInWindow` and `host.agreements.activeInWindowCount` (none of them is an expiry date).* | Fact Key | Type | Source | Meaning | | --- | --- | --- | --- | | applicantAge | number | Applicant Profile + Season | Age in years at program start_date | | applicant.nationality | string | Applicant Profile | ISO-3166 country code | | applicant.application_type | enum | Application | j1 \| j2 \| program_extension \| program_renewal | | passport.verified | bool | Document Store | Passport doc uploaded and verified | | passportExpiryDate | date | Document Store | Expiry of verified passport | | background_check.completed | bool | Document Store | Background check on file | | background_check.valid_on(date) | bool (parameterized) | Document Store | Background check within recency window relative to date | | documents.wave_complete(n) | bool (parameterized) | Document Store | Wave n documents all submitted and verified | | documents.guardian_consent.verified | bool | Document Store | Notarized guardian consent on file | | finance.sevis_fee_cleared | bool | Finance | SEVIS fee received and reconciled | | finance.ds2019_fee_cleared | bool | Finance | DS-2019 issuance fee cleared | | finance.exceptions.open_count | number | Finance | Count of unresolved finance exceptions | | placement.confirmed | bool | Placement Records | Active confirmed placement with host job order | | sevis.checkin_overdue_days | number | SEVIS | Days past due for next required check-in; 0 if on-time or future | | sevis.record_status | enum | SEVIS | initial \| active \| validated \| completed \| terminated | | ds2019.issued | bool | SEVIS | DS-2019 generated and delivered | | host.status | enum | Host Records | pending_verification \| active \| probation \| suspended \| terminated | | daysSinceLastSiteVisit | number | Host Records | Days since most recent completed site visit | | host.document.valid(category) | bool (parameterized) | Document Store | Derived: host document of category present AND reviewer-approved AND not expired at evaluation time | | host.document.expires_on(category) | date (parameterized) | Document Store | Expiration date of the host document of category; null when no dated document is on file | | host.document.policy_info(category) | object (parameterized) | Document Store | Policy information recorded on the document — issuer, policy number, coverage details, or accrediting body per category | | host.terms_and_conditions.accepted | bool | Agreement Records | Host has an accepted, in-window operating Terms and Conditions | | host.terms_and_conditions.expires_on | date | Agreement Records | End of the accepted Terms and Conditions validity window | | applicant.primary_j1 | entity_ref | Applications | Cross-entity pointer from J-2 to primary J-1 applicant | | missedMonthlyCheckinCount | integer (count) | Compliance Service | Longest run of consecutive missed monthly check-ins across the applicant's in-scope applications | Each catalog entry may also carry a workflowFieldName mapping — the portal step field a rule's failure reason surfaces on, which drives the inline field-level failures described in Internal vs Applicant-Facing Messages . > **Facts Catalog Gap Closure** > > Shipped Aug 11 2026 (Aug 11 2026 release capsule — Rules_Steps, PR #1814). Fact keys that were expected by active rule trees but absent from the catalog previously caused those rules to evaluate as permanently indeterminate. The missing keys were added during V2 UAT, and the eligibility rules engine now handles the corrected fact shapes, so the affected rule trees resolve to real pass/fail outcomes. ### Provenance Metadata Every resolved fact is wrapped in a FactWithProvenance : type FactWithProvenance ### Freshness, Confidence, Conflict ## Decision Classes & Output Contract The binary pass / fail / warn model from v1 is replaced with five explicit outcome classes. Mixing “the data is missing” with “the applicant failed” hides real compliance risk; the new classes force the distinction. ### Five Outcome Classes ### Fail-Safe Modes Each rule version declares how the engine should behave when it can’t produce a decisive pass or fail — i.e. indeterminate or error. This is the sponsor-grade replacement for “just fail open and hope someone catches it”. > **Missing Evidence Is Awaiting, Not Invalid** > > A fail_closed rule rewrites an indeterminate evaluation into a hard fail so SEVIS and queue gating stay conservative. The engine still distinguishes a fail-safe rewrite caused by evidence that has not arrived from a genuine violation: when the raw outcome is indeterminate and any node reports one of the missing-data reasons — missing_fact, no_related_entity, cross_entity_resolver_missing, fact_not_array_or_missing or missing_date_fact (emitted by leaf, cross_entity, count and time_window nodes) — the check is flagged awaitingData. The Compliance tab and the compliance-case classifier (awaiting_information vs confirmed_failure) share this one reason set, and applicant readiness subtracts awaiting-submission fails from its failing count, so data that was simply never submitted does not mark an application blocked. Only genuine failures, reviewer-rejected steps and non-pass interview outcomes do. t block, don ### Engine Output Contract Evaluation returns a structured RuleDecision object. This is the single contract all consumers code against: type RuleDecision ; > **Decision is a Value, Not a Side Effect** > > The engine produces a decision object. It never directly fires notifications, opens cases, or updates queues. Those effects are owned by Layer 5 (Action Orchestration), which reads required_actions and case_recommendations from the decision. This separation is what makes simulation and replay safe. > **reason_codes on the decision is a roll-up, not the source of truth** > > The version/decision-level reason_codes array on RuleDecision is aggregated from the reason codes carried by the actions that actually fired during evaluation. It exists for convenience (filtering, analytics, banner copy) — it is not the authoritative carrier of a single reason code. The authoritative binding is rule_actions.reason_code_id per action. If the action set fires zero actions, the roll-up is empty even when the rule itself failed; consumers that need per-cause detail read the emitted rule_outcome records, not the roll-up. ### Rule Outcome Emission When an action fires, the engine produces a structured rule_outcome — one per fired action. This is the unit that Layer 5 (Action Orchestration) consumes. Every field is copied from the action row that fired, so the outcome is self-describing and auditable without re-resolving the rule version: type RuleOutcome ; > **Action fields flow straight into the outcome** > > action_type, reason_code_id, queue_key, severity, blocks_transition, internal_message, and applicant_message all originate on the rule_actions row. The outcome is a snapshot of that row plus the evaluation identity (applicant_id, rule_version_id, rule_action_id), so replaying a historical decision reproduces the exact outcomes without consulting the live action configuration. ### Queue Item Emission & Dedup When a rule_outcome carries a non-null queue_key, Action Orchestration deterministically creates or updates the applicant’s queue item. Queue-item behavior is idempotent and auditable — the same logical cause never produces a duplicate row: s policy. The engine writes the resolution transition; it never silently drops the row., Manual override / Staff closing an item by hand must supply a written reason. The override writes an audit record (actor, item, reason, before/after) and leaves the underlying rule outcome unchanged — only this applicant > **Deterministic queue items, by construction** > > Because the dedup identity is the action that fired (not a free-form string or a timestamp), the engine guarantees one open item per (applicant, action, reason). This is what lets the Queue System render “why is this applicant here” from a stable back-pointer, and lets re-evaluation converge instead of accumulating noise. ## Versioning & Deterministic Replay Sponsor oversight demands the ability to answer: “Why did this applicant pass on 2026-03-15? Which rule version was active? What facts were present? Would today’s policy reach the same conclusion?” That requires first-class versioning and pure, deterministic replay. ### Versioned Evaluation Fields | Field | Purpose | | --- | --- | | rule_version | Exact rule_versions.id at evaluation time — immutable | | policy_bundle_version | Resolved set of active rule versions for the (brand, program, season, app_type, phase) context | | facts_schema_version | Schema version of the FactsSnapshot — required for replay compatibility | | evaluation_engine_version | Engine code version — lets historical replay match historical engine behavior | | trigger_source | What invoked the evaluation: phase_gate / data_mutation / scheduled / manual / simulation | | trigger_operation | Human-readable action name, e.g. 'applicant.advance_phase(3→4)' | | idempotency_key | Stable key across retries — prevents duplicate case/queue/notification fan-out | ### Policy Bundles & Future-Dated Rules A policy bundle is a snapshot of active rule versions resolved for an evaluation context at a timestamp. Bundles are computed on demand and memoized. Future-dated rule versions sit in rule_versions with a future effective_from; they enter the bundle automatically when the clock crosses the effective date. Retroactive rule changes create a new version with an earlier effective_from, and any entity whose historical decision would change is flagged for re-evaluation — the engine does not silently rewrite history. ### Deterministic Replay Walkthrough Load the historical rule_evaluation_records row by id / Fetch the persisted FactsSnapshot via facts_snapshot_ref / Resolve the exact rule_version and policy_bundle_version that were active / Load the evaluation_engine_version image — or simulate under current engine / Re-run evaluation as a pure function: (policy, facts) → decision / Compare the replayed decision to the persisted one — they must match byte-for-byte if engine version is held constant / In simulation mode, replay under a proposed new policy bundle and diff the outcomes `GET /api/rules-engine/records/:id` returns one stored evaluation record (`compliance.rules.author`; `404` when the id is unknown) as `record`, with its resolution `snapshot`, the per-node `nodeResults`, the version's `actions`, the version's `reasonCode` (or null) and `canSeeFacts`. The snapshot's raw `facts` are returned as null unless the caller holds `sensitive_data.view`; an unmasked read is written to the audit log as `rules_engine.record.facts_unmasked`. ## Re-Evaluation Strategy Re-evaluation is governed, not ambient. The engine re-fires for specific triggers, with controlled sync/async behavior, partial or full bundle scope, and explicit handling for retroactive policy changes on in-flight applicants. Compliance status is not gated on the scheduled sweep: a portal step submit that carries compliance-relevant facts triggers immediate in-band re-evaluation, and the nightly sweep remains only as a reconciliation backstop. | Trigger | Timing | Scope | Behavior | | --- | --- | --- | --- | | Phase transition attempt | Sync | Full bundle for target phase gate | Blocks transition if any bound rule does not pass | | Data mutation on a fact's source record | Async (outbox) | Partial — only rules with that fact in evidence_bindings | Re-evaluates affected rules; updates queue memberships and cases | | Portal step submit carrying compliance-relevant facts | Async (outbox), in-band | Partial — rules bound to the submitted facts (passport data, biographical fields, skills data, etc.) | Fires immediately on submit rather than waiting for the reconciliation sweep; queued via the outbox so it does not block the submit response, but runs within seconds. Shipped Aug 11 2026 (Aug 11 2026 release capsule — Workflows 2, PR #1786). | | Scheduled job (nightly) | Async | Full bundle for all in-flight entities | Reconciliation backstop — catches expiry-based and time-window rules (e.g. background-check staleness) and reconciles anything missed in-band | | Lifecycle reconcile sweep (every 30 minutes) | Async | All applications | Separate from rule evaluation: re-runs lifecycle reconciliation and phase-completion catch-up so persisted journey and track stage converges to live signals; interval `LIFECYCLE_RECONCILE_SWEEP_INTERVAL_MS` (default 30 minutes), disabled with `DISABLE_LIFECYCLE_RECONCILE_SWEEPER=1` | | Manual re-run by staff | Sync | Selectable per rule or full bundle | Writes evaluation record marked trigger_source = manual | | Policy bundle activation | Async | Affected rules only, targeted entities | Computes impact set, runs preview first, then writes new evaluations; retroactive changes flag existing passes that now fail | | Simulation | Sync | Targeted cohort | Pure-function replay under proposed bundle — no actions fired | Lifecycle reconciliation follows every successful outbox evaluation of an application. After the worker has processed an application event it runs lifecycle reconciliation (trigger `rule_evaluation_outbox`) before marking the outbox event complete, so track state is never left stale behind fresh decisions. A reconciliation error, or a reconciliation that returns a skipped result, fails the attempt and the event is retried with backoff until its attempts are exhausted (then dead-lettered); the one exception is `Application not found`, which is terminal success because there is no lifecycle row left to converge. > **Retroactive Policy on In-Flight Applicants** > > When a retroactive rule change would flip a passed applicant to failing, the engine does not immediately block the applicant’s next transition. Instead it opens a Compliance Case, notifies the compliance officer who activated the change, and leaves the applicant in their current phase pending case resolution. This prevents surprise blocks on participants already mid-program. > **Immediate Re-Evaluation on Step Submit** > > An applicant who uploads a new passport or corrects a date-of-birth sees their compliance status update within one refresh rather than waiting for the nightly sweep. Staff no longer need to manually trigger a re-check after an applicant corrects a blocking field. Shipped Aug 11 2026 (Aug 11 2026 release capsule — Workflows 2, PR #1786). ## Reason Code Catalog Reason codes are the machine-readable vocabulary that flows from rule decisions into queues, notifications, analytics dashboards, and API consumers. A stable catalog means downstream systems can be built against codes, not against English prose that shifts between releases. > **Reason codes are action-level** > > A reason code is carried by an individual rule_actions row (rule_actions.reason_code_id) — it is the authoritative cause for that action. The version/decision-level reason_codes on RuleDecision is a roll-up aggregated from the codes of the actions that fired, never the source of truth. Every code carries an explicit lifecycle status (active / deprecated / archived) so the per-action picker can filter to active codes only and the publish gate can hard-block on archived or deprecated selections. ### Reason Code Shape type ReasonCode ; ### Initial Reason Code Seed *Status: illustrative design seed, not the shipped list. The shipped catalog (`server/seed/reasonCodeCatalog.ts`) seeds roughly 90 codes and most names below differ (for example `MINIMUM_AGE_NOT_MET`, `PASSPORT_VALIDITY_INSUFFICIENT`, `PLACEMENT_PREREQUISITES_NOT_MET`, `HOST_AGREEMENT_REQUIRED`, `FINANCES_NOT_VERIFIED`); only `SEVIS_CHECKIN_OVERDUE` appears as listed. Treat the rows below as design intent.* | Code | Category | Severity | Meaning | | --- | --- | --- | --- | | AGE_INELIGIBLE | eligibility | | Applicant age outside program's min/max at program start | | NATIONALITY_NOT_ALLOWED | eligibility | | Applicant nationality not in program's approved list | | GUARDIAN_CONSENT_MISSING | documents | | Under-18 applicant missing notarized guardian consent | | PASSPORT_NOT_VERIFIED | documents | | Passport doc not on file or not yet verified | | PASSPORT_EXPIRES_BEFORE_PROGRAM_END | documents | | Passport expires before program end_date + required buffer | | BACKGROUND_CHECK_EXPIRED | documents | | Background check older than recency window | | WAVE_DOCUMENTS_INCOMPLETE | documents | | Required Wave-N documents not all submitted/verified | | SEVIS_FEE_NOT_CLEARED | finance | | SEVIS fee not reconciled in Finance | | DS2019_FEE_NOT_CLEARED | finance | | DS-2019 issuance fee not reconciled | | FINANCE_EXCEPTION_OPEN | finance | | Unresolved finance exception blocks issuance | | SEVIS_CHECKIN_OVERDUE | sevis | | Participant past required check-in window | | SEVIS_CHECKIN_MISSED_STREAK | sevis | | Consecutive missed check-ins exceed threshold — opens case | | DS2019_NOT_ISSUED | sevis | | DS-2019 not yet issued | | PLACEMENT_NOT_CONFIRMED | placement | | No active confirmed placement for the applicant | | J2_PRIMARY_NOT_APPROVED | eligibility | | J-2 cannot advance: primary J-1 not yet approved | | J2_PRIMARY_DS2019_MISSING | eligibility | | J-2 DS-2019 blocked: primary J-1 DS-2019 not issued | | J2_SURNAME_MISMATCH | eligibility | | J-2 surname does not match primary J-1 — requires review | | HOST_PROBATIONARY_STALE_SITE_VISIT | host | | Host probationary with site-visit past max age — job orders blocked | | HOST_SUSPENDED | host | | Host suspended — job orders blocked | | HOST_DOC_BUSINESS_LICENSE_INVALID | host | | Camp host verification: Business License missing, not approved, or expired | | HOST_DOC_WORKERS_COMP_INVALID | host | | Camp host verification: Workers Compensation missing, not approved, or expired | | HOST_DOC_CAMP_ACCREDITATION_INVALID | host | | Camp host verification: Camp Accreditation missing, not approved, or expired | | HOST_TERMS_NOT_ACCEPTED | host | | Camp host verification: accepted Terms and Conditions not on file or out of window | | WORKFLOW_COMPLIANCE_COVERAGE_GAP | config | | Configuration-integrity: compliance rule has no covering workflow step | | FACT_STALE | eligibility | | Decision is indeterminate — required fact is past its freshness SLA | | FACT_CONFLICTED | eligibility | | Multiple sources disagree — route to review | ### Catalog Governance ## Override Governance Override policy is declared per rule version — not by blanket role policy — so each rule can carry its own answer to “can this ever be waived, and under what conditions?”. Overrides are risk-classified, scoped, optionally time-bounded, and subject to scheduled re-review. ### Per-Rule Override Policy Classes ### Override Request & Approval Flow Every override record captures the full context so audits can reconstruct intent: | Field | Purpose | | --- | --- | | reason_code | From governed catalog — enumerates why the override was needed | | justification | Required free-text narrative explaining the exception | | risk_classification | low / medium / high / critical — drives alerting and review cadence | | scope | rule (this evaluation only) / entity (all future firings for this entity) / phase / season | | standing_flag | one_time = applies to the current decision only; standing = blankets future re-evaluations until withdrawn or expired | | expires_at | Optional expiry; required for temporary_only override policies | | primary_approver | User who authorized the override (must hold override permission for the rule) | | secondary_approver | Required for dual_approval policy; must be a different user | | before_state / after_state | Full decision snapshot before and after override — enables replay and audit | | created_at / review_due_at | Created timestamp and next scheduled review date for standing overrides | ### Standing Override Review Cycle Standing overrides are not fire-and-forget. The engine schedules a review at review_due_at — default 90 days, configurable per rule. On the review date the Override Governance console presents the override to a compliance officer who must renew, revoke, or escalate it. Unreviewed standing overrides expire automatically and the underlying rule re-blocks the entity. ### Non-Overridable Rules Rules marked forbidden cannot be overridden through any path — not by super admin, not by dual approval. The API rejects override attempts with HTTP 400 (`VALIDATION_ERROR`, with the violated `policyClass` in the error details), the UI hides the override affordance entirely, and the compliance dashboard surfaces any attempt as a security event. This is reserved for absolute federal requirements (e.g. no DS-2019 without cleared SEVIS fee). Exception for applicant compliance/regulatory rules: when the override targets an applicant entity and the rule version is the CURRENT published version of a compliance- or regulatory-classified definition, the rule is always overridable. A missing or legacy 'forbidden' rule_override_policies row is treated as 'permitted' (the published-rule audit surfaces this as a configuration finding), and the contextual Compliance action is offered whenever the version is the definition's current published version. The forbidden policy is enforced only for other rule targets and for non-current versions. Rules whose policy is dual_approval, whose definition has escalation_required, or whose policy has requires_dual_approval are routed to dual approval (pending_secondary) instead of applying on a single approval. ### Override Summary on the Compliance Tab Shipped Aug 11 2026 (Aug 11 2026 release capsule — Rules_Steps, PR #1814). Staff can now see a live summary of an applicant's active and pending rule overrides directly on the applicant compliance tab without navigating to the Override Governance Console . The card lists, per override: - Status — Awaiting 1st Approval / Awaiting 2nd Approval / Override Active / Under Review - The rule the override applies to - The justification - The risk classification - The expiry date A direct link opens the full Governance console for management actions. Gate: compliance.console.view. ## Compliance Case Model Severe compliance failures open a formal Compliance Case — not merely a queue item. A case has an owner, SLA, evidence attachments, and a resolution workflow. Cases are the durable record of how sponsor exceptions were handled; they survive individual queue lifecycles and aggregate into sponsor reporting. ### Case vs Queue Item | Attribute | Queue Item | Compliance Case | | --- | --- | --- | | Opened when | Any routable failure or review_required outcome | reason_code has opens_case = true OR severity = critical OR risk_classification = high\|critical OR an override request is created (any risk; see the callout below) | | Ownership | Queue-level routing rules (round robin, team) | Single named owner, reassignable with audit trail | | SLA | Queue-level SLA policy (queue_sla_policies) | Case-level SLA with pause rules, separate from any queue SLA | | Evidence | References the evaluation record | Attach docs, notes, external correspondence, sub-evaluations over time | | Resolution workflow | Action → exit queue | Open → Investigating → Awaiting-Info → Ready-for-Decision → Resolved/Escalated — with state transitions and required fields per transition | | Aggregation | Counted in queue dashboards | Aggregated into sponsor compliance reporting (opens, median time to resolve, outcomes) | | Survival | Closes when queue item exits | Persists across queue churn; multiple queue items can reference one case | ### Case Fields & Ownership type ComplianceCase ; ### Case Workflow open → case just opened, unowned cases auto-assigned via routing / investigating → owner acknowledges; can log evidence and notes; SLA ticks / awaiting_info → owner paused case pending external input (applicant response, partner); SLA pauses / ready_for_decision → all evidence gathered; case goes to decision queue / resolved (with outcome) → override-granted / remedied / upheld / withdrawn; final evaluation recorded / escalated → handed up to supervisor or legal; continues independently from originating trigger ### Criminal-background adjudication The compliance case whose ruleKey is compliance.criminal_background_review_outcome.j1_all_programs.p11_4 is decided by a human reviewer through the decision dialog, which offers four outcomes: Approved, Ineligible, More information required and Escalated. Recording a decision requires the compliance.review.manage permission and a rationale of at least 20 characters. The decision is persisted on the case summary as an audited criminalBackgroundAdjudication object (outcome, rationale, decidedAt, decidedByUserId, evidenceRefs) and is the ONLY source of the enum fact criminalBackgroundReviewOutcome (facts-v4.6.0). Only approved and ineligible are surfaced to rule evaluation; generic case closure, package status, translation status or an AI result can never make the rule pass. - Approved: case resolved (resolved_compliant). Terminal. - Ineligible: case resolved (confirmed_block). Terminal. - More information required: case moves to awaiting_info (resolved_no_finding recorded as the resolution); ready_for_decision → awaiting_info is a legal transition. - Escalated: case moves to escalated (escalated_external). For More information required and Escalated the linked review-queue item is reopened rather than resolved; for Approved and Ineligible it is resolved. Every persisted decision enqueues a compliance.case_adjudication data-mutation re-evaluation of the criminal-background rule. Mismatched outcome/resolution pairs are refused (CRIMINAL_BACKGROUND_OUTCOME_MISMATCH), as are a missing or short rationale (CRIMINAL_BACKGROUND_RATIONALE_REQUIRED) and a resolve/escalate without an adjudication outcome (CRIMINAL_BACKGROUND_OUTCOME_REQUIRED). > **When Does a Case Open vs Queue Item Only** > > A queue item alone is sufficient for routine failures where a single action resolves the issue (“upload passport”). A Compliance Case is opened when the reason code is flagged opens_case or the severity is critical. In addition, an override REQUEST of any risk always opens a linked Compliance Case at creation (caseKind override_request, 72-hour SLA, created with the override request; an immediately-activating override opens none). Override-request cases are decided only through the override approve/reject action: generic resolve/escalate is refused with OVERRIDE_DECISION_REQUIRED. Approval resolves the case 'overridden'; rejection resolves it 'confirmed_block'; a dual-approval override keeps the case open until the second, distinct approver approves. Review vs override: an Awaiting review result is a human decision on evidence, not an override. The Override Register records only the DB-level link to the case and defers to this page for behavior. Cases are for durable, evidence-backed, owner-accountable work — not routine checklist items. **Case API and decision refusals.** Cases are read and decided through `/api/compliance-cases`. `GET /api/compliance-cases/:id` requires `compliance.console.view` (API-key scope `compliance.read` or `admin.read`). It returns the case, its events and rule items, `reasonSummaries` (the catalog message paired with each opened reason code), the work item, the evidence held (with actor and document names), the documents on the subject that can still be cited, and a `decision` block (`confirmedFailure`, `rationaleMinLength` and the outcomes the case may currently be closed with), computed by the same function that enforces the gate. `POST /api/compliance-cases/:id/transition` (body `toStatus`; `resolutionOutcome`, `resolutionNotes`, `pauseReason` and `criminalBackgroundOutcome` optional) and `POST /api/compliance-cases/:id/evidence` require `compliance.review.manage`, not the rule-authoring permission, and are audited as `compliance_case.decision_recorded` or `compliance_case.transitioned`, and `compliance_case.evidence_attached`. The three refusal codes return HTTP 422. | Code | When | | --- | --- | | `COMPLIANCE_CASE_TRANSITION_ILLEGAL` | `toStatus` is not reachable from the case's current status. | | `COMPLIANCE_CASE_EVIDENCE_INVALID` | Evidence cannot be attached. `details.code` is `EVIDENCE_DOCUMENT_NOT_ON_SUBJECT` when the cited `documentId` is not on the record of the subject the case is about, or `EVIDENCE_REFERENCE_UNSAFE` when `referenceUrl` is not a complete http or https address. | | `COMPLIANCE_CASE_DECISION_REFUSED` | A resolving or escalating transition fails the decision policy. `details.refusals` lists every unmet requirement and `details.code` carries the first (codes below). | | Decision refusal | When | | --- | --- | | `RESOLUTION_OUTCOME_REQUIRED` | No outcome was chosen. | | `RESOLUTION_OUTCOME_UNKNOWN` | The outcome is not one a reviewer may record. | | `RESOLUTION_RATIONALE_REQUIRED` | The rationale (`resolutionNotes`) is shorter than 20 characters. | | `RESOLUTION_OUTCOME_NOT_APPLICABLE` | `resolved_no_finding` on a case opened by a confirmed rule failure. | | `RESOLUTION_OUTCOME_STATUS_MISMATCH` | The outcome does not match the target status: `escalated_external` is recorded only with `escalated`, every other outcome only with `resolved`. | | `RESOLUTION_EVIDENCE_REQUIRED` | `resolved_compliant` without at least one verifiable evidence item (a `documentId` on the subject's record or a safe absolute http or https `referenceUrl`), or on a review-queue item that has no case to hold evidence. | ## Rule Hierarchy & Flattening > **Program Rules story lives on its own page** > > For the linear, top-to-bottom explanation of compliance vs business rules, the Brand → Program → Season cascade, compliance_locked behavior, and who edits what (with worked examples), see Program Rules — One-Page Read . The section below covers the engine-internal mechanics of resolving the cascade. Rules are configured at three levels, resolved top-down at evaluation time. The engine flattens Brand → Program → Season into one effective parameter set per evaluation context. The rule tree itself is owned by rule_versions; only parameter values are tiered. table > **Compliance Rules Override All Levels** > > Compliance rules sit above all three hierarchy levels. They cannot be toggled or weakened at the program or season level by non-compliance users. When a compliance rule is activated, its parameters are locked across all affected program and season configurations. ## Application Type Coverage The rules engine gates all application types, not just initial J-1 enrollment. No application of any type can be published to the applicant portal without passing the applicable compliance rules. ### J-1 Primary Applications Full lifecycle from intake through completion. The standard rule set covers age eligibility, nationality, document verification, placement confirmation, SEVIS readiness, payment gates, and on-program compliance. ### J-2 Dependent Applications ### Program Extension Rules Program Extension refers to extending a participant's authorized program duration on an active DS-2019. This is distinct from Reservation Extension (a partner extending their time-limited hold on a job position — see Partner Governance). ### Program Renewal Rules Program Renewal refers to a returning participant re-applying for a new program season/cycle, creating a new DS-2019. Distinct from Program Extension (extending the current DS-2019). ## Evaluation Pipeline Every evaluation follows the same pipeline, whether triggered by a phase gate, a data mutation, a scheduled job, or a manual re-run. Each execution produces an immutable rule_evaluation_records row plus a persisted FactsSnapshot. > **Pipeline Guarantees** > > Every pipeline execution is atomic per entity — partial evaluations are never persisted. If a step fails (resolver exception, unknown fact), the prior outcome is retained, an error outcome is recorded, and fail_safe_mode applies. The evaluation record captures: rule_version, policy_bundle_version, facts_snapshot_ref, annotated tree, reason codes, required actions, trigger source, idempotency key. ### Runtime Resolution (Brand → Program → Season) Step 1 of the pipeline (“Resolve Policy”) is governed by the Brand → Program → Season precedence cascade. Lower scopes win — a rule defined at the brand level can be replaced or suppressed by a more specific rule of the same rule_definition.key at the program or season scope. The cascade is resolved at evaluation time; the `rule_resolution_snapshots` row that each evaluation references (`resolution_snapshot_id`) holds the entity's resolved facts, not the scope-resolution result. | Mechanism | Behavior | | --- | --- | | Replace by rule_key at lower scope | When a published rule_version with the same rule_definition.key exists at a lower scope (program > brand, season > program > brand), the lower-scope version is selected and the higher-scope version is suppressed for that evaluation context. Only one wins per (key, scope chain). | | Suppression by disabled lower-scope rule | A lower-scope record can mark a rule as is_active = false (e.g. season_rule_overrides.disabled). The rule is dropped from the resolved set entirely; the snapshot records it under suppressed_rule_definition_ids with the suppressing scope. | | Parameter cascade | rule_parameter_defaults provide the baseline; brand_rule_defaults_v2 / program_rule_configs / season_rule_overrides_v2 supply per-scope replacements. Compliance-locked parameters refuse program/season overrides at write time. | | Cascade admin routes | `GET /api/brands/:brandId/rule-defaults` lists a brand's `brand_rule_defaults_v2` rows ordered by rule definition; `POST` on the same path upserts one row per brand and rule definition from `ruleDefinitionId`, `parameterOverrides`, `pinnedRuleVersionId`, `disabled` and `notes` (200 with the row, 400 `VALIDATION_ERROR` for a bad payload). Both need the `compliance.rules.author` permission (API keys: `compliance.read` or `admin.read` for GET, `compliance.write` or `admin.write` for POST). The program and season equivalents are `/api/programs/:programId/rule-configs` and `/api/seasons/:seasonId/rule-overrides`, which also run the compliance-lock checks. | | Provenance per rule | For every rule selected, the snapshot records which scope won, which scopes were considered, and (for suppressions) which scope disabled it. This is the data that powers 'why is this rule running / not running for program X?' answers. | | Bundle pinning | Resolution is performed against the policy_bundle_version active at evaluated_at. Future-effective rule_versions inside the bundle are excluded if their effective_from is later than evaluated_at. | > **Compliance Floor Still Applies** > > Brand → Program → Season precedence governs business rules and parameter values. Compliance rules sit above the cascade: a season cannot suppress a compliance rule, and compliance-locked parameters cannot be replaced at lower scopes. Attempted writes that would weaken or suppress a compliance-locked rule are rejected at the API layer with HTTP 422 and reason code RULE_PARAMETER_LOCKED — never silently dropped. See Season-Override Rejection Contract for the exact response envelope and deep-link behavior. ## Operational Sequence The rules engine participates in the full operational lifecycle from program setup through applicant completion: ## Every Lifecycle Touchpoint *Status: most reason-code and fact names in the phase lists below (for example `AGE_INELIGIBLE`, `NATIONALITY_NOT_ALLOWED`, `GUARDIAN_CONSENT_MISSING`, `applicant.housing.verified`, `ds2019.issued`, `finance.sevis_fee_cleared`) are illustrative design names; `SEVIS_CHECKIN_OVERDUE` is one that shipped. The shipped vocabularies are `server/seed/reasonCodeCatalog.ts` and `shared/factsCatalog.definitions.*.ts` (for example `MINIMUM_AGE_NOT_MET`, `applicantAge`); no guardian-consent or housing fact exists.* The engine evaluates at every phase transition and at key points within phases. See the Applicant Lifecycle for the full phase model. Every touchpoint below is expressed as a rule binding attached to the named phase gate; fact keys used are the canonical ones from the Facts Catalog. ### Pre-Lifecycle (Setup Chain) Program created with baseline rule bindings in Program Register / Season instantiated with optional parameter overrides / Workflow designed in Workflow Studio; configuration_integrity rules validate coverage for all compliance bindings / Publish check: effective rule coverage must be complete (coverage gaps are reported as `BUNDLE_RULE_*` errors; `WORKFLOW_COMPLIANCE_COVERAGE_GAP` is not emitted) → publish allowed. Any gap in a non-business rule → publish refused with a validation error (HTTP 400) listing the uncovered rules. / Season opened → applicants can enter; entity_policy rules begin firing ### Phase 1 → 2 (Application → Validation) Completeness: mandatory fields and Wave 1 documents submitted (documents.wave_complete(1)) / Application type validation (J-1, J-2, Program Extension, Program Renewal — each has distinct required fields) / Age eligibility: applicantAge within min/max (AGE_INELIGIBLE) / Nationality eligibility: applicant.nationality in approved_countries list (NATIONALITY_NOT_ALLOWED) / Guardian consent (if under 18): documents.guardian_consent.verified (GUARDIAN_CONSENT_MISSING) ### Phase 2 → 3 (Validation → Interview) Staff approval or automated eligibility decision / For program renewals: good standing check against prior participation / No open Compliance Cases blocking advancement ### Phase 3 → 4 (Interview → Placement) Sponsor interview completion (if required by program rules) / Host interview accepted (if required by program rules) / Active placement confirmed: placement.confirmed = true (PLACEMENT_NOT_CONFIRMED) / Wave 2 documents complete — medical, financial (documents.wave_complete(2), WAVE_DOCUMENTS_INCOMPLETE) ### Phase 4 → 5 (Placement → Visa & SEVIS) SEVIS pool readiness: passport, forms, and confirmed placement complete / SEVIS fee cleared: finance.sevis_fee_cleared (SEVIS_FEE_NOT_CLEARED) / Background check valid on program start (time_window operator, BACKGROUND_CHECK_EXPIRED) / J-2: cross_entity primary_of → primary J-1 placement confirmed (J2_PRIMARY_NOT_APPROVED) ### Phase 5 → 6 (Visa & SEVIS → Pre-Departure) DS-2019 issued: ds2019.issued = true (DS2019_NOT_ISSUED) / Visa obtained and passport still valid through program end + buffer (PASSPORT_EXPIRES_BEFORE_PROGRAM_END) / DS-2019 issuance fee cleared: finance.ds2019_fee_cleared (DS2019_FEE_NOT_CLEARED) / No open finance exceptions (FINANCE_EXCEPTION_OPEN) / J-2: cross_entity primary_of → primary J-1 ds2019.issued (J2_PRIMARY_DS2019_MISSING) ### During Phase 7 (In Program — Continuous Evaluation) SEVIS validation check-in within configured window after arrival (SEVIS_CHECKIN_OVERDUE) / Monthly check-in compliance — count operator with consecutive flag; threshold triggers SEVIS_CHECKIN_MISSED_STREAK → case opens / Housing verification (leaf over applicant.housing.verified) / Travel request validation (cross_entity to trip record + time_window for dates) / For program extensions: max duration, remaining time, compliance standing ### Phase 7 → 8 (In Program → Completion) Program end_date reached OR early termination triggered / Early termination requires a Compliance Case resolution before SEVIS closure / SEVIS record closure with correct end reason (compliance rule) / Exit survey completion (business rule — advisory, does not block) / Cascade: J-1 termination triggers J-2 terminal closure via cross_entity rules ### Exception Paths (Any Phase) ## Job Order Compliance Job orders submitted from the Host Portal are also subject to engine evaluation. Bindings attach rules to job orders via entity_type = "job_order". ## Camp Host Verification Policy *Status: design spec, not built as described. No `camp_host_verification` rule set, `host.document.*` facts, `HOST_DOC_*` reason codes or `host_verification_incomplete` DS-2019 guard exist in code. Host verification today is the `host_verifications` table (one row per host organization): the automated run is advisory (`recommended_status`) and only a human compliance decision sets `status` to verified or failed.* Host verification for the **Camp** and **Camp SWT** program types is a Rules Engine policy — an entity_policy rule set evaluated against the host organization — not a static document-center checklist. The Host Document Center owns document upload, review, and expiry tracking; this policy owns the verification *decision*. A host with either camp program type must satisfy the same four document rules before its verification can pass: Business License, Workers Compensation, Camp Accreditation, and Terms and Conditions. Expressing verification as policy makes the decision auditable (every evaluation persists its facts snapshot and rule versions), versioned (requirement changes are new rule versions, never mutations), consistently enforced at the camp DS-2019 gate, and insulated from per-season configuration. > **Documents are evidence, not the decision** > > The four artifacts are evidence facts read through the Facts Resolver. Reviewer decisions on individual documents (approve / reject / resubmission-requested) remain the Document Review team's responsibility and are unchanged; the policy consumes the resulting review state as a fact. Enforcement of the derived outcome — refusing DS-2019 issuance — belongs to the existing camp issuance guard, not to the Document Center and not to the rules themselves. The human review surface where those document decisions (and the section-level compliance approvals built on top of them) are recorded is the compliance workbench on Host Verification & Approved Terms — it feeds these facts and never writes the derived outcome. ### Required-Document Rule Set The rule set key is `camp_host_verification`. It contains four entity_policy rules, each with `entity_type = "host_organization"`. Every rule applies the same verification standard: the artifact must be **present**, **reviewer-approved**, carry an **expiration date** that has not passed as of evaluation time, and carry the **policy information** needed to evaluate its current validity (issuer / policy number / coverage details, accrediting body, or accepted-agreement version, per artifact). | key | Name | rule_category | rule_class | Evidence facts | Reason code on failure | | --- | --- | --- | --- | --- | --- | | host_doc_business_license_valid | Business License Valid | business | workflow_completion | host.document.valid("business_license"), host.document.expires_on("business_license"), host.document.policy_info("business_license") | HOST_DOC_BUSINESS_LICENSE_INVALID | | host_doc_workers_comp_valid | Workers Compensation Valid | business | workflow_completion | host.document.valid("workers_comp"), host.document.expires_on("workers_comp"), host.document.policy_info("workers_comp") | HOST_DOC_WORKERS_COMP_INVALID | | host_doc_camp_accreditation_valid | Camp Accreditation Valid | business | workflow_completion | host.document.valid("camp_accreditation"), host.document.expires_on("camp_accreditation"), host.document.policy_info("camp_accreditation") | HOST_DOC_CAMP_ACCREDITATION_INVALID | | host_terms_and_conditions_accepted | Terms and Conditions Accepted | business | applicant_action | host.terms_and_conditions.accepted, host.terms_and_conditions.expires_on | HOST_TERMS_NOT_ACCEPTED | > **Classification is business until legal establishes a regulatory basis** > > All four rules carry rule_category = business (business_subclass = operational). None is classified regulatory because legal policy has not established a regulatory citation for these host artifacts, and regulatory_basis is required on regulatory-category rules — the catalog does not invent citations. If legal later establishes a basis for a specific artifact, that rule is reclassified via a new version through the standard publish lifecycle; nothing in this policy pre-commits the classification. The Terms and Conditions rule reads the host's accepted operating Terms and Conditions (the acceptance record documented under Host Portal governance — Operating Agreement — Portal Acceptance), not an uploaded file. Acceptance of the agreement is the evidence; this policy does not change agreement authoring or signature flows. ### Verification Evidence Facts The parameterized host-document facts and the Terms and Conditions facts are described here as part of the Canonical Facts Catalog (the host.document.* and host.terms_and_conditions.* rows); neither family exists as a key in the code facts catalog yet. host.document.valid(category) is a derived fact: document present AND review state approved AND expires_on not passed at evaluation time. Facts carry the standard provenance metadata, so a stale or conflicted document fact drives the decision to indeterminate / review, never to a silent pass. ### Derived Verification Outcome & DS-2019 Consumption Each evaluation of the rule set produces a standard engine decision — pass, fail, or review_required — recorded durably with its facts snapshot and rule versions: - **pass** — all four rules pass. - **fail** — one or more artifacts is missing, not approved, or expired. The decision carries the per-rule reason codes above as actionable remediation guidance (which artifact, and why it failed). - **review_required** — facts are indeterminate or conflicted (fail_safe_mode = hold_for_review), or a reviewer escalation applies. Routed to the Compliance Review Queue / sponsor review per the standard queue emission contract. > **Mapping to the portal `verification_status` enum — normative** > > The Host Portal bootstrap contract (`/api/host/me/context`) exposes `verification_status` as verified | pending | failed. The engine decision maps onto it as follows, and this mapping is the single guard input: > > | Engine decision | Portal verification_status | DS-2019 guard conjunct | Operational effect | > | --- | --- | --- | --- | > | pass | verified | satisfied | Issuance open; no banner. | > | fail or review_required (remediation in flight) | pending | not satisfied — 409 `host_verification_incomplete` | Full operational access continues under an accepted agreement; reason codes drive the remediation view. An engine fail alone never raises the blocking flag. | > | sponsor records a final failed verification outcome (escalating a fail / review_required decision) | failed | not satisfied — 409 `host_verification_incomplete` | Blocking flag raised; operations halt per the Camp Verification Failure Path. | > > `hostVerificationPassed(host)` in the camp DS-2019 issuance guard is therefore true exactly when the latest recorded decision is pass (portal status verified). The portal-facing **failed** state is a sponsor-recorded terminal outcome, not an automatic consequence of an engine fail — preserving the existing rule that pending camp hosts operate while remediating. The decision is re-evaluated event-wise when its evidence changes — document uploaded, replaced, approved, rejected, or expired, or Terms and Conditions accepted — per the standard re-evaluation strategy. The existing camp behavior documented on the Host Portal page is preserved unchanged: a **pending** camp host with an accepted operating agreement operates the portal fully while DS-2019 issuance is refused with `host_verification_incomplete`; a **failed** outcome raises a blocking flag and halts operations per the Camp Verification Failure Path. This policy supplies the *derived result* those guards consume; it does not alter their semantics, the expiry-warning timing, notification events, document-review states, or compliance-hold behavior. ### Program-Type Binding — No Season Scope The policy is scoped by rule_scopes to `entity_type = host_organization` with `program_model_type IN (camp, camp_swt)` — a program-type binding, deliberately without any season dimension. Bindings trigger evaluation at the verification lifecycle points (verification run on the pending host, evidence-change re-evaluation, and the DS-2019 issuance gate's read of the derived outcome). > **Seasons never touch verification** > > Creating, editing, or closing a season neither recreates the four requirements nor makes a valid document invalid. An artifact's validity comes only from its own facts — review state, expiration date, and policy information — never from season configuration. Seasonality remains fully in force everywhere else (cohorts, applicant eligibility windows, placements, agreements); only the host verification policy is non-seasonal. ## Capacity Management & Waitlisting Capacity rules are operational business rules — not compliance. When a program or season reaches its capacity limit the engine works with the Capacity Engine to decide whether new applicants should be waitlisted or rejected. Capacity checks are made by the Capacity and Waitlist engine (`evaluatePlacement`) against per-(program, season) capacity rows, not by rules-engine leaf predicates. Waitlist Mechanics Capacity check evaluated on application submission / When capacity reached + waitlist_enabled: applicant waitlisted / Waitlisted applicants remain in lifecycle phase but excluded from placement queues / De-waitlisting is a deliberate staff action — no auto-promotion Threshold Events 80% capacity: early warning event to ops team / 100% capacity: waitlisting begins automatically / Events feed into Queue System and notification workflows / Staff can increase capacity to release waitlisted applicants > **No Auto-Promotion** > > Waitlisted applicants are never automatically promoted when capacity opens. Ops staff must review and explicitly de-waitlist. This prevents capacity race conditions and ensures staff oversight. ## Compliance Review Queue Decisions whose outcome is review_required, indeterminate with fail_safe_mode = hold_for_review, or fail with a permitted override policy, are routed to the Compliance Review Queue — scoped to compliance officers and super admins. This queue uses the shared queue architecture (queue_definitions entity-agnostic model). ## UI Surfaces The engine exposes nine first-class UI surfaces. Each has defined entry points, primary actions, required RBAC, and empty/loading/error states. Together they let compliance officers author, simulate, observe, and govern the full engine without leaving the application. ### Rule Builder Entry: Governance & Operations → Rules → “New Rule” / “Edit Rule”. Also from Evaluation Inspector “Open rule version”. Primary actions: Visual rule-tree editor with drag-to-nest all/any/not, inline if/then/else, predicate picker with autocomplete, derived-fact builder, count & time-window helpers, cross-entity navigator. Sidebars for Scope, Parameters, Output Contract, Actions, Override Policy. Per-action editor: the Actions sidebar lists one row per action. Each row has an action_type selector (fail actions BLOCK / WARNING / REVIEW_QUEUE / AUTO_REJECT / REQUIRE_DOCUMENT; the stored vocabulary also has AUTO_APPROVE, CREATE_TASK and APPLY_TAG, which the picker does not offer as fail actions), a reason-code picker that shows active codes only (archived and deprecated codes are excluded; the selection persists to rule_actions.reason_code_id), and the per-action queue-routing fields — queue_key, severity, blocks_transition, internal_message, and applicant_message. Selecting a reason code seeds its default severity and suggested actions, which the author can then adjust per action. Publish is blocked client- and server-side if any publishable action has no active reason code. Roles: compliance.rules.author to draft rules, compliance.rules.publish to publish them and compliance.rules.assign to attach them to a program scope; compliance.rules.manage (leadership) to classify existing rules as compliance; program_rules.edit for business rules within scope. Classification routes: `POST /api/admin/rules/compliance-rule-definitions/classify` marks an existing rule key as compliance-mandated by inserting a `compliance_rule_definitions` row (201). It needs `compliance.rules.manage`, a stricter key than the `compliance.rules.author` used by the generic rule routes, and an API scope of `compliance.read` or `admin.write`. The body has `ruleKey`, `regulatoryBasis` (3 to 500 characters), `effectiveDate`, `scope` (`global` by default, or `brand`, `program` or `program_type` with the matching `brandId`, `programId` or `programType`, else 400), optional `applicableVisaTypes`, `applyMode` (`new_applications_only` by default, or `retroactive`) and optional `reason`. `regulatory_reference` is resolved server-side from the citation map, never taken from the client, and a `compliance_rule.classified` audit row records the basis, effective date and retroactivity. `GET /api/admin/rules/compliance-rule-keys` (`compliance.view`, optional `programId` query) returns `ruleKeys`, the compliance rule keys resolved for that program. States: empty = new rule template with placeholder tree; loading = skeleton tree + sidebar stubs; error = tree validation panel highlights offending node with jump-to-fix. Links: “Dry-run” opens Simulation Runner seeded with the current draft. “Impact Preview” before activation. ### Facts Browser Entry: From any entity detail page (“View Facts”). From Evaluation Inspector (“Show facts for this evaluation”). From Rule Builder leaf node (“Test this fact”). Primary actions: Browse canonical facts for an entity with full provenance — collected_at, source, freshness/confidence/conflict badges. Filter by fact key, source system, quality state. Replay against a historical FactsSnapshot. Roles: compliance.rules.author. Facts never expose raw PII beyond what the caller has rights to via RBAC on the source entity. States: empty = “No facts resolved yet — trigger resolver”; loading = per-source skeletons; error = resolver failures reported per source with retry. ### Evaluation Inspector Entry: From Compliance Review Queue, Compliance Case, Audit Log, or entity timeline. Deep-linkable via ?evaluation_id=…. Primary actions: Replay any past evaluation deterministically. Show the annotated tree with per-node pass/fail/indeterminate badges, resolved facts, version stamps. Side-by-side diff against another evaluation or against the same inputs under current policy. Roles: compliance.view (GET /api/admin/rules/evaluations). States: empty = “Select an evaluation”; loading = tree and facts pane skeletons; error = version mismatch banner when FactsSnapshot schema is no longer replayable with current engine, with option to simulate. ### Override Governance Console Entry: Governance & Operations → Overrides. Also contextually from queue rows and cases. Primary actions: Request new override (inline wizard capturing reason_code, justification, risk, scope, standing flag, expiry). Approve/reject pending overrides — including second-approver path for dual_approval rules. List standing overrides with review_due_at. Renew, revoke, or escalate on review. Roles: compliance.review.manage (primary and secondary approval: POST /api/rules-engine/overrides/:id/approve-primary and /approve-secondary). Second approver must be a different user for dual_approval. States: empty = “No pending override requests”; loading = list skeleton; error = policy mismatch banner when a rule’s override_policy has changed since request submitted. ### Simulation Runner Entry: From Rule Builder (“Dry-run”). From Impact Preview (“Run simulation”). Standalone from Governance & Operations → Simulations. Primary actions: Select target cohort (season / program / brand / custom entity list). Pick proposed rule version or full policy bundle. Engine runs pure-function replay with trigger_source = simulation — no actions fired, no cases opened, no notifications. Results: count of pass/fail/review_required/indeterminate/error per rule, and the set of entities that would flip outcome compared to current policy. Roles: compliance.rules.author (phase-gate simulation, POST /api/rules/phase-gate/simulate). States: empty = “Configure cohort and policy”; loading = progress bar with “N of M entities processed”; error = partial failures per entity with drill-in. ### Policy Impact Preview Entry: From Rule Builder before activation. From Reason Code Admin before retirement. Automatically shown in the activation confirmation dialog for compliance rules. Primary actions: Shows affected programs, seasons, and applicants at the moment of activation. Projects expected failure count, review queue growth, and case volume based on live facts. For retroactive changes, shows the set of existing passes that would flip to fail, with option to open Compliance Cases for each. Roles: compliance.rules.author, compliance.rules.publish or compliance.rules.govern (the Policy Impact sidebar entry at /rules/impact is shown to holders of any of the three). States: empty = “No impact — rule affects no live entities”; loading = summary skeleton; error = simulation failed with retry. ### Reason Code Admin Entry: Governance & Operations → Reason Codes. Primary actions: Create, version, deprecate, and archive reason codes — moving a code through the active → deprecated → archived lifecycle. Edit title/description/severity/default actions. View “used by” list of rule actions and versions. Deprecation/archive wizard requires a successor code and warns that any rule action still pointing at the code will hard-block on its next publish. Roles: compliance.rules.author to deprecate reason codes (reserved for compliance staff; PM-style callers are refused). States: empty = catalog bootstrapped from seed; loading = list skeleton; error = conflict with existing active rule versions when deprecating — lists blockers. ### Compliance Case Workspace Entry: Governance & Operations → Compliance Cases. From any evaluation record with an open case. From entity timeline. Primary actions: View case detail with all related evaluations, overrides, queue items, evidence attachments, correspondence. Change status (open → investigating → awaiting_info → ready_for_decision → resolved/escalated). For the criminal-background review case the decision dialog records one of the four adjudication outcomes (see Criminal-background adjudication above). Attach evidence, add notes, reassign owner, pause/resume SLA, record resolution outcome. Roles: compliance.review.manage for owners; compliance.console.view for watchers. States: empty = “No open cases”; loading = detail skeletons; error = stale SLA banner when paused_at exceeds configured max. ### Audit Log Viewer Entry: Governance & Operations → Audit Log. Filter from any rule, entity, case, or user page. Primary actions: Immutable chronological log of every rule evaluation, override request/approval, case transition, reason-code change, and policy-bundle activation. Full-text search, time-range filter, export. Roles: audit_log.view — broad read-only audit role. States: empty = “No audit entries in range”; loading = paged skeleton; error = partial index unavailable with retry. ### Identity Field Audit on Staff PATCH Shipped Aug 11 2026 (Aug 11 2026 release capsule — Workflows 2, PR #1786). Any time a staff member edits a core identity field on an applicant — firstName, lastName, dateOfBirth, nationality, passportNumber — via the admin API PATCH, the change is recorded in the audit log with a staff_identity_edit event type, including a before/after snapshot. This closes a gap where identity corrections were untraceable, and the events are queryable from the Audit Log Viewer alongside every other governed action. ## Override Authority (RBAC Summary) The per-rule override_policy (see Override Governance ) composes with role-level RBAC. Every override is recorded in rule_overrides with full audit trail. | Role | Permission | Can Override | Requirements | | --- | --- | --- | --- | | Compliance Users | compliance.review.manage (apply or review immediately); eligibility:override:create (request only) | Any rule with override_policy = permitted / dual_approval / temporary_only | Reason code + justification + risk + scope. Second approver for dual_approval. Expiry for temporary_only. | | Super Admins | super_admin | Same as compliance users; cannot override forbidden-class rules, except that the current published version of an applicant compliance/regulatory rule is always overridable (a missing or 'forbidden' policy resolves to permitted) | Same governance requirements apply. No special bypass of the per-rule override_policy. | | Program Managers | program_rules.edit | Business rules within their scope only; never compliance | Adjusts parameters and bindings, not override decisions on entity evaluations. | | All Other Users | — | None | Read-only on evaluations per RBAC scope. | ## Rule-Type Catalog (programRuleVersions Cutover) The eligibility engine originally modelled 8 authorable rule types, while the legacy program_rule_versions row carried ~24 keys driving SEVIS cadence math, profile completeness, and a long list of operational gates. Cutover ticket #209 (Asana 1214131179452076) could not retire the legacy reader safely until the new engine recognised every key, with a typed parameter schema, a canonical resolved shape, and a documented default-fallback that matches today’s legacy semantics. The catalog below closes that gap. The authoritative machine-readable list of rule types and their Zod parameter schemas lives in `shared/ruleParameterSchemas.ts` (`RULE_TYPES`). The table below records the original legacy-key design, and several rows differ from what shipped: j2_dependents ships as `j2_dependents_policy` (allowed only), extensions and renewals ship as `lifecycle_policy`, monthly_check_ins takes only enabled, and sponsor_capacity takes required, consumed_at_issuance and waitlist_enabled. Where the table and `RULE_TYPES` disagree, `RULE_TYPES` wins. ### Authorable Rules vs Engine Settings Each legacy key was classified as either an authorable rule (belongs in the Rule Builder catalog with a Zod parameter schema) or an engine setting (a pure operational threshold that belongs on the scoped settings surface, not the Rule Builder). Forcing operational thresholds into the rule catalog would have polluted the Compliance Console with non-authorable knobs, so the SEVIS cadence numbers and sponsor capacity ride a separate settings projection with the same brand → program → season scoping rules. | Legacy Key | Classification | Resolved Shape | Default Fallback | Primary Consumers | | --- | --- | --- | --- | --- | | requiredApplicantFields | | fields: string[], profileCompletenessWeight: number | Empty fields list, weight = 1.0 | profileCompleteness, applicantPortal/intake | | monthly_check_ins | | enabled, cadenceDays, graceDays | enabled=true, 30 / 7 days | onProgramCheckinEngine, queueSystem | | j2_dependents | | allowed, maxDependents, requiresPrimaryApproval | allowed=false | dependentLifecycle, sevisAmendments | | extensions | | allowed, maxExtensionMonths, requiresSponsorReview | allowed=false | programExtensionEngine, finance/EXTENSION_FEE | | renewals | | allowed, cooldownMonths, maxLifetimeRenewals | allowed=false | programRenewalEngine, finance/RENEWAL_FEE | | fees | | programFeeCents, extensionFeeCents, renewalFeeCents, waivers[] | All fees 0, no waivers | financeNetsuite, applicantPortal/fees | | orientation | | required, completionDeadlineDays, gateBlocking | required=true, deadline=null, gateBlocking=false | workflowEngine, applicantPortal/journey | | flight_details | | required, dueDaysBeforeStart, gateBlocking | required=true, due=14d, gateBlocking=false | arrivalsContract, hostPortalArrivals | | housing_info | | required, dueDaysBeforeStart, carryForwardOnRenewal | required=true, due=14d, carryForward=false | jobOrders, hostPortal/housing | | insurance | | required, minCoverageUsd, minCoverageDurationMonths | required=true, no minimum | documentsVerification, complianceGates | | passport_validity | | required, minMonthsBeyondProgramEnd, gateBlocking | required=true, 6 months, gateBlocking=true (DOS regulatory) | documentsVerification, sevisCheckinEngine | | prior_participation | | allowed, cooldownMonths, maxPriorPrograms | allowed=true, cooldown=0, max=null | eligibilityCheck, matchingEngine | | documents_verification | | enabled, blocking, routeToQueue, requiredCategories[], autoApproveOnCleanScan | enabled=true, blocking=false, routeToQueue=null | documentReview, complianceReviewQueue | | references | | enabled, minCount, requireProfessional, gateBlocking | enabled=true, minCount=0, gateBlocking=true (0 = rule disabled at runtime) | applicantPortal/references, complianceGates | | interview | | required, outcomes[], gateBlocking | required=true, full outcome set, gateBlocking=true | interviewQueue, complianceGates | | placement | | autoConfirmReservation, reservationHoldHours, requireSponsorApproval | autoConfirm=false, hold=72h, requireApproval=true | matchingEngine, reservationEngine | | sevis_checkin_due_days | | days: number | 3 days (DEFAULT_INTERNAL_SLA_DAYS, used when unset) | sevisCheckinEngine | | sevis_critical_escalation_day | | day: number | Day 20 | sevisCheckinEngine, escalationRules | | sevis_urgent_escalation_day | | day: number | Day 25 | sevisCheckinEngine, escalationRules | | sevis_regulatory_deadline_days | | days: number | 30 days (DOS regulatory window) | sevisCheckinEngine, complianceGates | | sponsor_capacity | | maxParticipants, waitlistEnabled, earlyWarningPercent | max=null (unbounded), waitlist=true, warn=80% | capacityEngine, queueSystem | ### Default-Fallback Contract > **Defaults Must Match Legacy Reader Byte-for-Byte** > > For each new rule type, the “scope has no published rule set” path must produce the same default the legacy programRuleVersions reader produces today. Any deviation will silently change behavior in profileCompleteness, sevisCheckinEngine, documentsVerification, and the finance fee derivation. The defaults table above is the contract — the parity comparator below is the enforcement. ### Parity Comparator Coverage The parity script that runs during the cutover compares the legacy reader’s output to the new resolved snapshot per (brand, program, season). Comparison goes through the NEW_CANONICALIZERS registry — one canonicalizer function per rule type, deterministic and pure. Every rule type in `RULE_TYPES` (`shared/ruleParameterSchemas.ts`) has an entry in the registry; the guard test LEGACY_RULE_KEYS_WITH_CANONICALIZERS fails the build if a rule type is added without a matching canonicalizer. Several keys in the catalog table above are registered under different rule-type names (for example j2_dependents_policy, lifecycle_policy, fee_requirement, pre_departure_check, references_check, interview_requirement and placement_settings), and others (for example requiredApplicantFields and passport_validity) have no registry entry. The registry is published from `shared/ruleCanonicalizers.ts` (not `shared/schema.ts`) so the rules-engine repo can import it directly. ### Legacy Read Sites — Cutover Complete > **This cutover has shipped. The section below is retained as history, not as outstanding work.** > > - ~~server/services/rulesEngine.ts~~ — the legacy reader — was **deleted on 2026-04-23 by Task #348**. It no longer exists; do not cite it. > - The `program_rule_versions` table and the dual-read compatibility layer were **removed by Task #354**. `shared/schema.ts` carries only a tombstone comment where the table used to be declared. > - Rules are now sourced exclusively from the eligibility-rules engine (Rules Engine V2). `server/services/newEngineRuleAdapter.ts` and `server/services/eligibilityRulesEngine.ts` both record the removal of the `programRuleVersions` fallback in-source. > - Of the six readers originally listed for migration, `profileCompleteness`, `sevisCheckinEngine`, and `capacityEngine` remain as services in `server/services/` (now reading the resolved snapshot); `documentsVerification`, `financeFees`, and `onProgramCheckinEngine` no longer exist as standalone service modules. ## Regulatory Rule Bundles Regulatory Rule Bundles are reusable, versioned packages of compliance rules that the Compliance team authors centrally and the platform auto-attaches to programs based on visa-type predicates. They are the answer to: "how does a brand operations user spin up a new J-1 program without having to know which 14 federal compliance rules must be on it, and which parameter values are non-negotiable?" ### Bundle Concept & Why Without bundles, every new program would need a brand operator (or worse, a brand admin) to manually pick the correct compliance rules out of the catalog, set their parameters, and toggle the lock — a workflow that is fragile, audit-hostile, and impossible to keep aligned when Department of State guidance changes. Bundles invert this: - Compliance authors a bundle once per regulatory regime (e.g., J-1 Standard, J-2 Dependents, Extension/Renewal Add-On). - The bundle resolver matches incoming program metadata against each bundle's `condition_predicates` JSONB (keys `visa_type`, `program_model_type`, `allows_dependents` and `category_code`; these are predicate keys, not columns of regulatory_rule_bundles) and picks the highest-precedence applicable bundles. - Matched bundle rules are written into program_rule_configs with compliance_locked = bundle_rule.lock_required (per-rule, not blanket-true) and provenance fields (bundle_id, bundle_version). Where the bundle marks a rule lock_required = true, the lock cannot be removed below compliance authority; where the bundle marks it false, the rule is bundle-provenanced but parameter-editable at brand/season scope. - When Compliance publishes a new bundle version, every affected program is flagged with bundle_update_available = true — migration is explicit, never silent. ### Bundle Shape & Identity A bundle is a (key, version) pair. key is the stable regulatory identity (e.g., j1_standard); version is a monotonically increasing integer. Each bundle row carries: | Field | Purpose | | --- | --- | | bundle_key + version_number | Identity. (bundle_key, version_number) is unique. The pair is recorded in program_rule_configs.lock_source (bundleId, bundleKey, bundleVersion) for locked rules. | | name, description | Human-readable label and rationale shown in the Bundle Author and the Program Rules surface. | | condition_predicates.visa_type | Predicate the resolver matches against the program's visa type (a string or a list, e.g., J-1, J-2, F-1). Stored inside the condition_predicates JSONB, not as a column. | | condition_predicates.program_model_type | Optional refinement against the program's model type (a string or a list, e.g., camp, intern, trainee). Omitted means any model. | | condition_predicates.allows_dependents | Optional boolean predicate. Omitted means any; true / false matches the program's dependent posture. | | condition_predicates | JSONB envelope of declarative predicates the resolver evaluates against program metadata: visa_type, program_model_type, category_code and allows_dependents. An empty object matches every program. Bundles with attach_mode = manual skip predicate matching and attach through bundle_scopes rows instead. | | precedence_band | Resolver ordering. Higher band wins when multiple bundles match (e.g., a J-1 Camp Counselor add-on can refine J-1 Standard). See Bundle Precedence Numbering. | | status | draft → published → archived. Only published bundles attach to new programs. Once published, the (bundle_id, bundle_version) row is immutable. | | cloned_from_bundle_id | Provenance for version updates — the new version always clones from the previous published version's payload. | | publish/archive metadata | published_at / published_by / publish_reason and archive equivalents are written by the Bundle Author and feed regulatory_bundle_publish_audit. | Each member rule lives in regulatory_bundle_rules: a row per (bundle_id, rule_definition_id) with default_parameters and a lock_required flag. Bundles reference rule_definitions (identity), not rule_versions — runtime evaluation always picks the currently-published rule version per the standard cascade. ### Bundle Resolver — Visa-Type Auto-Attach The Bundle Resolver is a pure function. Precedence is numeric high-wins: higher number = more specific / stronger precedence (see Bundle Precedence Numbering ). resolveBundles(program: ) -> Bundle[] // ordered: highest precedence first (numeric DESC) steps: 1. SELECT * FROM regulatory_rule_bundles WHERE status = 'published' AND attach_mode = 'auto' 2. Evaluate condition_predicates against program metadata; drop non-matching bundles. 3. For each (key) group, keep only the highest version with status = 'published'. 4. Sort compliance bundles before business bundles, then by precedence_band DESC (high-wins), then version_number DESC. > **Determinism** > > The resolver is purely a function of program metadata + the published bundle table at resolution time. It is called by the program-create hook, by the Bundle Author preview, and by the Compliance Console "what would attach" simulator — same inputs always yield the same ordered bundle list. ### Auto-Attach on Program Create The Program Register's program-create transaction calls the resolver and writes one program_rule_configs row per (rule, bundle) match in a single transaction: BEGIN; INSERT INTO programs (...) RETURNING id; bundles := resolveBundles(program_metadata); FOR EACH bundle IN bundles: FOR EACH bundle_rule IN bundle.rules: INSERT INTO program_rule_configs ( program_id, rule_definition_id, pinned_rule_version_id, parameter_overrides, disabled, compliance_locked, locked_parameter_keys, lock_source, bundle_update_available, notes ) VALUES ( program.id, bundle_rule.rule_definition_id, bundle_rule.pinned_rule_version_id, bundle_rule.parameter_overrides, false, bundle_rule.lock_required, bundle_rule.locked_parameter_keys when lock_required else empty, lock_source (kind, bundleId, bundleKey, bundleVersion, lockReason) when lock_required else NULL, false, 'auto-attached by bundle ' || bundle.bundle_key || ' v' || bundle.version_number ON CONFLICT (program_id, rule_definition_id) DO UPDATE SET every column above is overwritten with the winning bundle rule's values; COMMIT; Conflicts (a rule appearing in multiple matched bundles) are deterministic: the higher-precedence bundle wins, and compliance_locked is upgraded but never downgraded. The transaction also writes one row per bundle to a program-level provenance log (audit_logs) so the Program Register surface can show "this program was created with bundle X v3 + bundle Y v1 attached". ### Immutability & Compliance Lock - Bundle row immutability. Once a bundle row transitions to status = 'published', the row and its regulatory_bundle_rules children become read-only. Any change requires cloning to a new version. - Program rule lock. Every program_rule_configs row written from a bundle has compliance_locked = true when the bundle marked lock_required. The Brand and Program rule surfaces render these as read-only with a "Locked by regulatory bundle v" badge. - Lock removal authority. Only Compliance can remove a bundle-derived lock, and only by either (a) republishing the bundle without the rule, then migrating the program, or (b) issuing a documented exception via the standard override flow against the underlying rule. There is no UI path for a brand operator to clear the lock. - Parameter immutability for compliance params. Parameters whose rule_parameter_defaults declare them compliance-managed cannot be overridden at brand or season scope when the rule arrived via a bundle. ### Version Updates & Migration Flow Bundle updates are an explicit, reviewable workflow — never a silent reach-in across hundreds of programs: - Clone. Compliance opens the published bundle in the Bundle Author and clicks "Create new version". The system copies the row to version + 1 with status = 'draft' and cloned_from_bundle_id populated. - Edit. Compliance adds, removes, or re-parameterizes member rules in the new draft version. The current published version remains unchanged and continues to attach to new programs. - Diff. The Bundle Author shows a structured diff: rules added, rules removed, rules with changed default_parameters, and the count of programs currently on the prior version. - Publish. Compliance publishes the new version with a publish reason. The system writes regulatory_bundle_publish_audit with the diff summary and affected program count, archives the prior version's attach eligibility (the prior row stays for replay; new programs only get the new version), and sets bundle_update_available = true on every program_rule_configs row whose (bundle_id, bundle_version) matches the prior version. - Per-program migration. The Program Rules surface and Compliance Console both surface a "Bundle update available" banner per affected program. Only Compliance Officers (compliance.bundles.manage) can apply a migration; brand admins (programs.manage) can only request one via POST /api/programs/:id/bundle-migrations/request, which a Compliance Officer then approves and executes. The system applies the diff to that program's program_rule_configs rows and writes a program_bundle_migrations row recording the diff, requestor (if any), executor, and reason. See Migration Authority & Dual-Approval for the full contract; migration is a single transaction per program. > **Why per-program migration, not platform-wide rewrite?** > > Compliance changes that flip a fact for an applicant who has already started a phase transition can open Compliance Cases. By migrating one program at a time with an explicit actor and reason, every cascade is auditable and timed by the operator — not by a background job. Programs that have not yet been migrated continue evaluating against their current bundle version's parameters until explicitly migrated. ### Compliance Console Bundle Author The Bundle Author is a Compliance-Console surface (RBAC scope: compliance.bundles.manage) with the following panels: - Bundle list. All bundles grouped by key, with status chips for draft / published / archived, current published version, attach count (number of programs currently bound), and "update available" count for prior-version programs. - Predicate panel. Edit visa_type, program_model_type, allows_dependents, and the condition_predicates JSONB editor. A live "matched programs" preview re-runs the resolver against an in-memory snapshot of programs and shows a count plus a sample list. - Rule membership editor. Add/remove rule definitions, set default_parameters per rule (validated against rule_parameter_defaults), toggle lock_required, and reorder. - Diff view. When a draft is descended from a published version, render the structured diff (added / removed / re-parameterized) and the impact preview (programs that would be migrated). - Publish dialog. Publish requires a publish_reason text field and a confirmation that the affected program count has been reviewed; emits regulatory_bundle_publish_audit. - Migration tracker. A cross-program list of programs flagged with bundle_update_available, batched by bundle, so Compliance can drive migrations down to zero before the next publish. ### Bundle API Surface > **Documented contract — implemented in Globus** > > These endpoints describe the contract Globus exposes to the architecture-portal pages and to other ecosystem clients. They are not implemented in this documentation site. Detailed regulatory rule logic, member-rule definitions, and publish history require compliance.bundles.read on the Globus side; the read scopes below describe minimum scope needed to call the endpoint successfully. | Endpoint | Purpose | RBAC | | --- | --- | --- | | GET /api/regulatory-bundles | List bundles with filters (status, visa_type, key). Returns the applied/locked summary (key, version, predicates, precedence, attach + update-available counts) — no member-rule logic. | compliance.bundles.read.summary (summary); compliance.bundles.read (full row) | | GET /api/regulatory-bundles/:id | Bundle detail with member rules, regulatory citations, default parameters, and current diff vs. previous published version. | compliance.bundles.read (detail) | | POST /api/regulatory-bundles | Create a new bundle key in draft status. | compliance.bundles.manage | | POST /api/regulatory-bundles/:id/clone-version | Clone a published version to a new draft version (cloned_from_bundle_id populated). | compliance.bundles.manage | | PATCH /api/regulatory-bundles/:id | Edit a draft bundle's predicates, name, description. Rejected with 400 (VALIDATION_ERROR) on non-draft bundles. | compliance.bundles.manage | | POST /api/regulatory-bundles/:id/rules (add a member rule) and DELETE /api/regulatory-bundles/:id/rules/:ruleDefinitionId (remove one) | Edit the rule membership of a draft bundle. | compliance.bundles.manage | | POST /api/regulatory-bundles/:id/publish | Transition draft → published with publish_reason. Writes audit row and flips bundle_update_available for prior-version programs. | compliance.bundles.manage | | POST /api/regulatory-bundles/:id/archive | Archive a published bundle (no new attaches; prior attaches keep current parameters). | compliance.bundles.manage | | POST /api/regulatory-bundles/resolve | Pure resolver endpoint — given program metadata, return the ordered bundle list that would attach (key, version, precedence, attach reason). Returns summary fields only, no detailed rule logic. | compliance.bundles.read | | POST /api/programs/:id/bundle-migrations/:migrationId/apply | Approve and apply a requested per-program migration. The body carries a role of primary or secondary: a primary approval marks the migration approved, and a secondary approval from a different user (not the primary approver and not the requester) performs the apply and records the diff on the program_bundle_migrations row. See Migration Authority & Dual-Approval for the brand-initiated flow. | compliance.bundles.manage (sole executor) | | POST /api/programs/:id/bundle-migrations/request | Brand admin requests a migration; creates a program_bundle_migrations row with status requested. No program_rule_configs are written until approval. | programs.manage | | GET /api/programs/:id/bundle-migrations | List migration history for a program (summary fields). | compliance.bundles.read | ### Unified Operational Gate Management Shipped Sep 2026. Operational gates — Market Ready, Ready for Placement, Ready for SEVIS and later lifecycle checkpoints — are now authored in the **same** Rules Library / Bundles workspace as regulatory and business bundles: choose **Operational Gates** in the type selector. The catalogue comes from the Rules Engine gate definitions (classification `operational_gate`), not from applicant-facing visibility and not from ordinary bundle records, so progressive display on the applicant Compliance tab never hides a gate from staff authoring. - **Definition, not bundle.** An operational gate is a Rules Engine definition that aggregates eligible **published** regulatory and business rules into a lifecycle checkpoint. Its prerequisite rows each name a published rule, its accepted result, category, order and mandatory/informational behaviour. The picker refuses duplicates, unpublished rules, gate-to-gate references and out-of-scope references with a clear message. - **Draft / publish safety.** Published gate versions are immutable. **Start draft** clones the live version (or resumes an existing draft — a second competing draft is not created); only the draft's prerequisite editor is editable, every change is saved and audited, and the publish check must pass before publication. Publication is the activation point: readiness and effective-rule projections refresh without a restart, and editing a draft never changes applicant readiness. Published SEVIS-gate changes also invalidate stored readiness verdicts (see [SEVIS Readiness](/docs/sevis-readiness)). - **Scope.** An empty or unconfigured scope is a configuration problem, not a reason to hide the gate. - **Applicant Compliance visibility policy.** A gate version can carry `parameters.gateConfig.visibilityPolicy`: `always`, or `progressive` with a required `earliestPhase` and an optional `predecessorFamily`. The canonical family order is `verified_legal_identity`, `ready_for_placement`, `ready_for_sevis`, `ready_for_visa`, `ready_for_departure`, `ready_for_arrival`, `ready_for_completion`; a gate with no authored policy takes the family default (the first three `always`, the rest progressive after the previous family). The validator reports `INVALID_MODE` (mode is neither `always` nor `progressive`), `INCOMPLETE_POLICY` (the policy does not match the schema, for example a progressive policy with no canonical earliest phase or with a predecessor that is not a canonical family, or unexpected keys) and `FORWARD_PREDECESSOR` (the predecessor is the gate's own family or a later one, so self and cyclic dependencies are refused). Creating a rule version or patching its `parameters` with an invalid authored policy returns 400 `VALIDATION_ERROR` ("Invalid Applicant Compliance visibility policy") with the issues in `details.issues`; the publish check reports the same codes under gate `VISIBILITY_POLICY`. A stored policy that fails validation is ignored at read time and the default applies. - **Only published rules evaluate.** Gate dependencies are pinned by rule definition id, so archiving a dependency's last published version (or leaving only a draft) would otherwise leave it at not_evaluated and block every application. The evaluator skips any dependency whose definition has no current published version (or no definition at all): it is excluded from dependencies, failedDependencies and the verdict, and reported on the result's unevaluatableDependencies field with reason no_published_version or definition_missing, plus a warning log naming the gate version and rule keys. Open breaches recorded by rules that are no longer published are ignored, including by the gate-level blockOnActiveBlockedOutcome check. The gate status stays pass — a rule that cannot run must not block an applicant — but the compliance projection sets systemFault ("System Problem"), words the row as "Advancement is allowed, but a rule behind this gate is not published and was excluded from the check", and lists each excluded rule with an instruction to republish it or remove it from the gate. Authoring is not guarded: archiving a rule a published gate depends on is still allowed. - **Market Ready family (fail-closed).** The market_ready family (ruleClass marketing) decides host-portal exposure. It currently holds one global baseline gate, market_ready.j1_all_programs, with three prerequisites: profile completion (workflow_completion.profile_completion_gate.j1_all_programs.p11_4), minimum age, and authoritative sponsor screening (compliance.sponsor_screening_cleared.j1_all_programs). Program-scoped Market Ready checklists and terms-and-conditions acceptance are no longer prerequisites, and no passport or SEVIS dependency participates. Stacking is retained as a mechanism: every applicable gate must pass. The verdict fails closed — a not_evaluated dependency fails its gate, a missing published gate blocks, and an engine outage blocks. The sponsor-screening rule is deliberately not auto-attached to programs, so the service evaluates it explicitly against the applicant's facts snapshot and injects the result before the gate runs; an unresolvable, unpublished or erroring screening rule leaves the dependency not_evaluated and blocks. Applications outside the host market (by legacy programme or placement_source, e.g. self-arranged) are not exposed and receive no Market Ready grant. - **Host Portal enforcement.** Discovery surfaces (recommended and participant search) include only applications with a persisted application_gate_statuses row for gate family market_ready with status ready, applied as a set-based EXISTS before pagination; a missing, blocked or waiting row hides the applicant. Write paths (host recommendation, and reservation create per the code comments) call the live assertMarketReady and re-wrap any failure as a host-safe MARKET_NOT_READY 422 ("This participant is not currently available to reserve.") that leaks no blocker detail. Staff-side callers receive the full blocker list under reasonCode MARKET_READY_PREREQUISITES_NOT_MET. - **Seeding.** The sponsor-screening rule and the Market Ready gate family are authored by reference boot seeders (seedRulesEngineSponsorScreeningRule, seedRulesEngineMarketReadyGates). Boot-time seeding is skipped where SKIP_SEEDS=true, which is set for prod. - **Ready for Placement family (Task 2483).** By policy placement carries no prerequisites beyond Market Ready: if a host can see a participant, the host can reserve and hire that participant. The family (`ready_for_placement`, exported as `PLACEMENT_GATE_FAMILY_KEY`) holds exactly one gate, the global `ready_for_placement.j1_all_programs`, authored from the same shared prerequisite list as `market_ready.j1_all_programs` (`server/seed/readinessGatePrerequisites.ts`): profile completion, minimum age and sponsor screening, all hard-pass and fail-closed. The programme-scoped placement gates (`ready_for_placement.camp_counselor_wildpacks`, `ready_for_placement.swt_camp_wildpacks`, `ready_for_placement.teach_usa`) and the Wild Packs Operational Gates bundle are retired (published version archived, published pointer cleared, publish audit written). Programme qualification (current student status, academic calendar, vacation window, references and reference review, the J-1 Teacher checks) is left in place and still blocks in the separate ready_for_sevis chain, so a failing programme prerequisite blocks later in the journey and is never waived. A parity test fails if the two gates' dependency sets drift or a programme-scoped placement gate reappears. Because profile completion and sponsor screening are bundle-inert, `evaluatePlacementReadiness` now evaluates and injects their live outcomes exactly as Market Ready does (shared `injectInertBundleDependenciesForGates` and `INERT_BUNDLE_ONLY_DEPS` in `inertBundleRuleInjection.ts`). The seeder (`ready-for-placement-gates-v4`) and a paired idempotent data migration (`20271125000000_task_2483_align_placement_ready_with_market_ready`, needed because environments that boot with SKIP_SEEDS=true never run the seeder) drop every persisted `ready_for_placement` projection row (the seeder through the family-generic `invalidateAllGateStatusesForFamily`, only when it changed the gate set; the migration with a direct delete), so the sweeper recomputes verdicts; a missing row reads as waiting, never ready, and reserve and hire evaluate the gate live regardless. Self-arranged placements are exempt from Market Ready exposure but still pass the placement gate, so they now also require sponsor screening and no longer require terms acceptance. - **Host-safe placement refusal (Task 2484).** A placement readiness refusal raised inside the reservation or placement writers carries internal rule keys, rule names, dependency reasons and queue routing. Every host entry point that can reach it now maps it to a 422 with code `PLACEMENT_NOT_READY`, a host-readable message and `details.reasonCode` only (no blocker list); the full detail goes to the server log and the staff readiness surfaces are unchanged. Covered entry points: host reservation create, host-level hold convert and hire, host placement create and confirm-update, and the partner-API reservation create (its `reservations.write` scope is reachable by a host). Other rejections (agreement, quota, capacity, conflict) keep their own envelopes. `PLACEMENT_NOT_READY` is part of the reservation hireability reason-code vocabulary with separate host and staff wording, but `resolveReservationHireability` never returns it: the routes attach it to the refusal envelope. - **Seeding (placement).** The Ready for Placement gate is authored by `seedRulesEngineReadyForPlacementGates`, which runs after the sponsor-screening rule and the Market Ready gates because it shares their dependency set. The separate Teach USA placement gate seeder is removed. - **API (additive, effective `2026-10-28` in the change registry).** `GET /api/rules-engine/definitions` accepts `operational_gate` as a classification filter and returns published/draft version metadata plus prerequisite counts for gate rows; `GET /api/rules-engine/definitions/:id` includes caller-visible version scopes. Prerequisite authoring continues to use the existing gate-dependency routes. - **Gate prerequisite routes and refusals.** `GET /api/rules-engine/versions/:id/gate-dependencies` lists a version's prerequisites; `POST` on the same path adds one, and `PATCH` / `DELETE /api/rules-engine/gate-dependencies/:id` edit or remove a row in place. Writes need `compliance.rules.author` and are refused with `400 VALIDATION_ERROR` when the version is not a draft. Adding a prerequisite is refused with `400 VALIDATION_ERROR` and `details.code` `NOT_OPERATIONAL_GATE` (the owning definition is not classified `operational_gate`), `GATE_DEPENDENCY_SELF_REFERENCE`, `INELIGIBLE_GATE_DEPENDENCY` (the referenced definition is not classified compliance or business) or `DEPENDENCY_NOT_PUBLISHED` (it has no current published version); a rule already listed on the version is `409 CONFLICT` with `details.code` `GATE_DEPENDENCY_DUPLICATE`. ### Seed Bundle: J-1 Standard v1 *Status: illustrative design, not seeded data. The spec list in `server/seed/regulatoryBundles.ts` (`REGULATORY_BUNDLE_SPECS`) defines `j1-standard` (precedence_band 100, one member rule `j1_core_compliance_gate`) plus the J-1 bundles `j1-baseline`, `j1-au-pair`, `j1-camp-counselor`, `j1-intern`, `j1-trainee`, `j1-teacher` and `j1-swt` at precedence_band 300, but its `seedRegulatoryBundles` seeder is disabled in the boot seed list (`server/seed/index.ts`). Bundle keys use hyphens, and the 14-rule member list below is not defined there.* The platform ships with one published seed bundle so every new J-1 program acquires the baseline regulatory rule set automatically: key — `j1_standard` version — `1` name — J-1 Standard Compliance status — visa_type — `J-1` program_model_type — `NULL (any model)` allows_dependents — `NULL (any)` precedence — `300` (visa-category band — see [Bundle Precedence Numbering](#nc-precedence)) Member rules (all lock_required = true): - passport.verified_before_visa — passport verification gate before SEVIS DS-2019 issuance. - ds2019.issued_before_program_start — DS-2019 must be issued prior to program start date. - sevis.checkin_within_required_days — SEVIS check-in window enforcement (default 10 days from arrival). - monthly_checkin.required — monthly check-in cadence on for the duration of program participation. - insurance.coverage_active — required health insurance coverage active for entire program window. - host.site_visit_required — site visit obligation for new host placements. - placement.confirmed_before_arrival — placement must be confirmed before arrival fact is logged. - extension.requires_compliance_approval — program extension routes through Compliance even when extension_allowed is true. - renewal.requires_compliance_approval — program renewal requires Compliance sign-off. - document_review.complete_before_arrival — document_review.wave_complete fact must be true before arrival. - sevis.record_status_active — applicants on program must hold sevis.record_status = ACTIVE. - finance.payment_cleared_before_ds2019 — applicant finance status must be cleared before DS-2019 issuance. - host.status_active — host placements only against host.status = active hosts. - program_completion.required_documents_received — program closure gate on required completion documents. Default parameters (e.g., the 10-day SEVIS check-in window) come from each rule's rule_parameter_defaults and may be overridden in the bundle's regulatory_bundle_rules.parameter_overrides JSONB when sponsor policy differs from a rule's catalog default. ## Normative Contracts This section is the single source of truth for the wire-level contracts that other portals, services, and admin surfaces must obey when interacting with the Rules Engine and Regulatory Rule Bundles. Where any other page in this portal disagrees with what is written here, this section wins. ### Bundle Precedence Numbering Bundle precedence is a small integer stored on regulatory_rule_bundles.precedence_band (one of 100, 200, 300, 400, 500; other values are rejected on create). The resolver sorts numeric DESC — higher number wins. The reserved bands below are normative and must be used when authoring bundles so the merge order is predictable across visa regimes: | Band | Intent | Example | | --- | --- | --- | | 100 | Sponsor defaults (`sponsor_defaults`), the lowest band. | j1-standard (defined in `server/seed/regulatoryBundles.ts`) | | 200 | Program band (`program`). | - | | 300 | Regulatory standard (`regulatory_standard`), the band of the per-category J-1 bundles. | j1-baseline, j1-camp-counselor | | 400 | Regulatory exception (`regulatory_exception`). | teach-intern-trainee-compliance | | 500 | Federal mandate (`federal_mandate`), the highest band. | - | > **Compliance floor beats precedence** > > Numeric precedence orders bundles relative to each other. The compliance floor is absolute: no higher-precedence bundle, brand override, program config, or season override may weaken a compliance-locked rule. The merge upgrades a lock but never downgrades it. Operational (business) rules merge under high-wins; compliance rules merge under high-wins-then-lock-monotone. ### Bundle & Lock Reason Codes Reason codes are stable, machine-readable identifiers returned in API error envelopes, decision objects, and the publish-gate response. Clients should switch on these codes — never on human-readable messages. The full Rules Engine reason-code catalog lives in the Reason Code Catalog ; the codes below are the bundle / lock subset that PR 2 introduces. | Code | When emitted | HTTP | | --- | --- | --- | | RULE_PARAMETER_LOCKED | A POST/PATCH on season_rule_overrides (or partner_rule_overrides) attempts to set a parameter value or flip enabled = false on a rule whose program-level config is compliance_locked = true. Distinct from a 403: a 403 means the caller lacks scope to touch the rule at all; a 422 RULE_PARAMETER_LOCKED means the caller has scope but the requested write violates the compliance lock. Direct PATCH on a compliance-locked program_rule_configs row by a non-compliance caller returns 403 (RBAC) before this code is reached. | 422 | | BUNDLE_RULE_UNBOUND | Workflow Studio publish gate: a bundle-attached compliance rule has zero rows in program_rule_coverage_bindings for the workflow being published. | 422 (publish), surfaced in publish-gate envelope | | BUNDLE_RULE_BINDING_STEP_MISSING | A program_rule_coverage_bindings row points at a workflow_step_id that is no longer in the workflow's step list (orphaned binding after step removal). | 422 (publish) | | BUNDLE_RULE_BINDING_SOURCE_RETIRED | A binding points at a system_process source whose contract has been retired in the System Process Registry. | 422 (publish) | | BUNDLE_RULE_BINDING_FACT_GAP | A bound step or system_process does not produce all of the rule's required_fact_keys (covered-but-incomplete). | 422 (publish) | | BUNDLE_RULE_BINDING_FACT_DROPPED | A previously published binding's underlying fact is no longer produced (regression after step edit). | 422 (publish) | | BUNDLE_VERSION_MISMATCH | A migration apply call references a target version that is not the latest published version for the bundle key (lost-update guard). | 422 | | BUNDLE_NOT_PUBLISHED | An attach or migrate call resolves to a draft or archived bundle. | 422 | | BUNDLE_LOCK_DOWNGRADE_REJECTED | An author tries to publish a bundle version where a previously lock_required = true rule is now lock_required = false. Lock removal requires the explicit unlock workflow. | 422 | | `OPERATIONAL_GATE_READ_ONLY` (in `details.code` under `FORBIDDEN`) | Any write through the regulatory-bundle API (create, lifecycle, rule attach or detach, predicates, scopes) that targets an operational-gate bundle. Gate bundles are system-provisioned and read-only there. | 403 | | `BUNDLE_OWNER_REQUIRED` | Creating a business bundle with neither `ownerBrandId` nor `ownerProgramId` when none can be derived from the caller's single-program scope; also a scope attach by a bundle-manage-only caller on a business bundle that has no owner. | 422 | | `BUNDLE_OWNER_AMBIGUOUS` | Creating a business bundle with both `ownerBrandId` and `ownerProgramId`. | 422 | | `BUNDLE_OUT_OF_SCOPE` | A caller without the assign bypass attaches scopes to a compliance bundle, or to a business bundle owned by a program or brand outside their scope; or creates a business bundle for a brand outside their scope. | 403 | | `BUNDLE_KEY_EXISTS` (in `details.code` under `CONFLICT`) | Creating a bundle whose `bundleKey` is already used by any version, or losing a concurrent-create race for it; add a version with clone-version instead. | 409 | ### Bundle Publish-Gate Response Envelope The Workflow Studio publish gate calls POST /api/workflow-versions/:id/validate-coverage before flipping draft → published, and the publish endpoint reuses the same envelope on rejection. On bundle coverage failure the response is HTTP 422 with the envelope below — the workflow stays in draft and the Studio renders the failures as a stable copyable list (never a toast). The detailed UX contract for this surface lives on Workflow Builder — Bundle Publish Gate ; this section is the canonical wire envelope. POST /api/workflow-versions/:id/validate-coverage → 422 Unprocessable Entity errors / business_rule_warnings / workflow_version_id / wfv_7 / program_id / prog_camp_counselor_alpha - A non-empty errors[] array produces the 422; business_rule_warnings never block publish on their own (each warning carries a message). - Each error's reason_code is exactly one of the bundle reason codes from the table above; clients switch on the code, never on the message. - suggested_step_keys is always present (possibly empty) — the Studio uses it to power the "add coverage" action without a second roundtrip. - The envelope is identical between dry-run validate-coverage and the publish-time rejection so the Studio renders the same UI in both modes. ### Season-Override Rejection Contract When a season admin tries to write an override that would weaken a compliance-locked parameter or disable a compliance-locked rule, the API rejects the write. The response is always HTTP 422 with the envelope below — there is no silent drop, no partial success, and no auto-clamp. POST /api/seasons/:season_id/rule-overrides rule_definition_id / rd_min_age / parameters → 422 Unprocessable Entity ok / code / RULE_PARAMETER_LOCKED / rule_definition_id / rd_min_age / rule_key / min_age / season_id / sea_summer_2026 / program_id / prog_camp_counselor_alpha / locked_parameters / min_age_years / lock_source / deep_link / /programs/prog_camp_counselor_alpha/rules?focus=min_age&from=season-rejection / message / min_age_years is locked by regulatory bundle j1_standard v1; season cannot weaken it. - locked_parameters lists every parameter in the request that is locked — partial-OK writes are not supported, the entire write is rejected if any parameter is locked. - lock_source.kind is one of regulatory_bundle, compliance_authored, or brand_compliance_lock; clients use it to choose the right "manage lock" link. - deep_link is the canonical URL the season UI surfaces in the rejection toast — landing on the program Rules tab focused on the offending rule and surfacing the rejection notice (see Season Overrides Roll-Up). - The same envelope is returned for the equivalent PATCH on existing season override rows. ### RBAC Scope Catalog These are the scope identifiers used throughout the bundle and program-rules contracts. They sit inside the platform's standard RBAC model (see RBAC ) and are the canonical names other ecosystem portals must use when checking permissions against Globus. | Scope | Grants | Typical role | | --- | --- | --- | | compliance.rules.author | Draft new eligibility rule definitions in Program Rules. | Compliance Admin, Program Manager | | compliance.rules.manage | Classify compliance rules as a leadership reviewer (reason required). | Compliance Admin | | compliance.rules.publish | Publish a new version of a regulatory rule (reason required). | Compliance Admin | | compliance.rules.assign | Attach a published rule to a specific program scope (reason required). | Compliance Admin | | business.rules.manage | Move a Business Rule Set through its forward lifecycle edges (draft to published to deprecated to archived) via `POST /api/rules-engine/business-sets/:id/transition`; compliance.rules.author also authorises these edges. A holder of only this key must have program or brand scope covering the set. | Program Manager, Brand Admin, Compliance Admin | | compliance.rules.govern | Restricted, reason required. Override (rollback) edges on a Business Rule Set (published to draft, deprecated to published, archived to deprecated), each with a free-text reason and not limited by set scope; also hard-deletes a rule definition (`DELETE /api/rules-engine/definitions/:id`, reason of 8 to 2000 characters, 409 `RULE_HAS_OVERRIDES` while applicant overrides reference it). Not held by Compliance Officer. | Compliance Admin | | compliance.program_mapping.view | Read-only view of the effective-dated SEVIS program-number to program-type mapping and its resolution (GET /api/program-number-mappings and /resolve); grants no edit authority (editing is compliance.program_mapping.manage). | Compliance Admin | | compliance.bundles.manage | Create, edit drafts of, publish, and archive regulatory_rule_bundles. Sole executor of bundle migrations. | Compliance Officer / Super Admin | | compliance.bundles.read | Full detail read on bundle membership, rule-version history, and publish audit. Required to render the Bundle Author detail panel. | Compliance Officer | | compliance.bundles.read.summary | Read the applied/locked summary view of attached bundles (key, version, precedence, lock count). Does not expose member rule logic. | Brand Admin, Program Manager | | program_rules.edit | Toggle business rules and edit business-rule parameters within declared bounds. Cannot touch compliance-locked rows. | Program Manager | | programs.manage | Create or edit a J-1 program's configuration; also the scope for requesting a bundle migration (writes a requested program_bundle_migrations row - does not apply the migration). | Brand Admin | ### Version Coexistence & Deprecation Multiple published versions of the same bundle key may coexist indefinitely; the resolver always selects the highest-version published row at program-create time, but already-attached programs keep their original version until they are explicitly migrated. The lifecycle invariants below are normative: - Status transitions are draft → published → archived. There is no published → draft; mistakes are corrected by publishing a new version. - Published bundles are immutable. Predicates, member rules, default parameters, and lock_required flags are frozen on publish; edits create a new draft version. - Archived bundles do not auto-detach. Programs already attached to jX_v3 keep running it after jX_v3 is archived; the Compliance Console surfaces a "stuck on archived version" report so migrations can be planned. - Resolver excludes archived versions from new attaches — only published rows participate in resolveBundles. - Bundle update available (program_rule_configs.bundle_update_available = true) is set on every existing row whose bundle key publishes a new version, scoping the migration backlog. - Deprecation notice is a deprecation_reason + deprecated_at pair set when a bundle is archived. The Bundle Author surfaces it on every still-attached program so brand admins understand why a migration is being requested. ### Migration Authority & Dual-Approval Bundle migrations rewrite compliance-locked program rules and so are gated by an explicit authority model. The contract below is normative for both the Globus implementation and any ecosystem portal that proxies a brand admin's request. | Action | Who | Effect | | --- | --- | --- | | Apply migration (writes program_rule_configs) | compliance.bundles.manage only | Atomic transaction: snapshots prior config, replaces locked-rule rows with the new bundle version's defaults, stamps bundle_version, clears bundle_update_available, writes program_bundle_migrations audit row. | | Request migration (no writes to configs) | programs.manage | Writes a program_bundle_migrations row with status requested. Appears in the program's migration list (GET /api/programs/:id/bundle-migrations) for a compliance approver. | | Approve & execute a brand request | compliance.bundles.manage | Same as direct apply, but audit row records both requestor and approver. This is the documented dual-approval flow. | | Reject a brand request | compliance.bundles.manage | Marks the request rejected with a justification; no rule writes occur. | | Bulk migrate across programs | compliance.bundles.manage | Same per-program transaction repeated; partial failures are reported per-program and never roll back successful migrations. | > **Dual-approval is the brand-initiated path** > > Brand admins never directly write to program_rule_configs for bundle-attached rules. The brand-initiated path always goes through POST /api/programs/:id/bundle-migrations/request; a compliance approver then executes POST /api/programs/:id/bundle-migrations with the request id, which is the dual-approval audit linkage. Direct compliance-driven migrations skip the request step but record only one party in the audit row. ## Data Model The data model is decomposed so each concern has a dedicated table with clear relationships. Identity (rule_definitions) is separate from versioned payload (rule_versions) so rules can evolve without breaking historical replay. Tables are declared under `shared/models/` (for example `shared/models/rules-sponsor.ts`) and re-exported from `shared/schema.ts`. | Table | Purpose | Key Fields | | --- | --- | --- | | rule_definitions | Immutable rule identity & classification | key, name, family (entity_policy / configuration_integrity), rule_category (regulatory / business), rule_class, business_subclass, regulatory_basis, current_version_id, rule_group_key | | rule_versions | Versioned policy payload — one row per version, immutable once published | rule_definition_id, version_number, status (draft / published / archived), cloned_from_rule_version_id, rule_tree (JSONB: RuleTreeNode), parameters (JSONB), applicant_failure_message, fail_safe_mode, reason_code_id, effective_from, effective_to, published_at, published_by_user_id, archived_at, archived_by_user_id | | rule_version_publish_audit | Append-only audit trail of every rule version state transition | rule_version_id, rule_definition_id, from_status, to_status, changed_by_user_id, actor_role, reason, diff_summary (JSONB), test_cases_passed_count, test_cases_total, policy_bundle_version_after, ip_address, user_agent, changed_at | | rule_scopes | Scope filters per rule version | rule_version_id, lifecycle_phases, application_types, entity_type, brand_scope, program_scope | | rule_bindings | Attachment points — phase gate, publish check, schedule, etc. | rule_version_id, binding_kind (phase_gate / publish_check / scheduled / data_mutation / manual), target_ref | | rule_parameter_defaults | Default parameter values per rule version | rule_version_id, parameter_key, parameter_type, default_value | | rule_evidence_bindings | Canonical facts read by each rule version — powers Facts Browser and impact preview | rule_version_id, fact_key, required (bool) | | rule_actions | One row per action with first-class action-level columns — each carries its own reason code and queue-routing fields | rule_version_id, action_type (BLOCK / WARNING / REVIEW_QUEUE / AUTO_APPROVE / AUTO_REJECT / REQUIRE_DOCUMENT / CREATE_TASK / APPLY_TAG), reason_code_id (FK reason_codes), queue_key, severity (blocking / warning / review / informational), blocks_transition (bool), internal_message, applicant_message | | rule_outcomes | Structured outcome emitted per fired action (consumed by Action Orchestration; persisted for replay & explainability). When queue_key is present it creates or updates the applicant's queue item. | applicant_id, rule_id, rule_version_id, rule_action_id, action_type, reason_code_id, queue_key, severity, blocks_transition, internal_message, applicant_message, evaluation_record_id, emitted_at | | rule_override_policies | Per-rule-version override policy config | rule_version_id, policy_type (forbidden / permitted / dual_approval / temporary_only), max_duration_days, requires_dual_approval, allowed_role_keys, standing_review_days | | brand_rule_defaults | Brand-level parameter values per rule | brand_id, rule_definition_id, enabled, parameter_values (JSONB) | | program_rule_configs | Program-level parameters with compliance-lock flag and regulatory-bundle provenance | program_id, rule_definition_id, enabled, compliance_locked, parameter_values (JSONB), locked_by, lock_reason, bundle_id, bundle_version, bundle_update_available | | regulatory_rule_bundles | Reusable regulatory rule packages (e.g., J-1 Standard v1) matched on visa type and program model through condition_predicates — auto-attached on program create and immutable once published | bundle_key, name, description, condition_predicates (JSONB; holds visa_type, program_model_type and allows_dependents, which are not separate columns), precedence_band, version_number, status (draft / published / archived), cloned_from_bundle_id, published_at, published_by, publish_reason, archived_at, archived_by, created_by | | regulatory_bundle_rules | Rule membership within a bundle version with default parameters and required-lock flag | bundle_id, rule_definition_id, default_parameters (JSONB), lock_required, sort_order | | regulatory_bundle_publish_audit | Append-only audit trail of bundle state transitions and republish events | bundle_id, bundle_key, bundle_version, from_status, to_status, actor_user_id, reason, diff_summary (JSONB), affected_program_count | | program_bundle_migrations | Records explicit per-program migrations from one bundle version to a newer published version | program_id, bundle_id, bundle_key, from_version, to_version, diff_summary (JSONB: rules_added / rules_removed / rules_reparameterized), migrated_by, migration_reason | | season_rule_overrides | Season-level parameter overrides (non-compliance params only) | season_id, rule_definition_id, parameter_values (JSONB), override_reason | | compliance_gates | Gate definitions between lifecycle phases (legacy, superseded by rule_bindings kind = phase_gate) | name, from_phase, to_phase, rule_definition_ids, application_types | | reason_codes | Governed reason code catalog with explicit lifecycle status — only active codes are selectable per-action and only active codes pass the publish gate | code, version, status (active / deprecated / archived), category, severity, title, description, suggested_actions (JSONB), emits_notification, opens_case, deprecated_at, archived_at, replaced_by | | rule_test_cases | Named test cases bound to a rule_definition; pin a synthetic facts snapshot to an expected outcome and failing-rule set | rule_definition_id, name, description, category, synthetic_facts_snapshot (JSONB), resolved_parameters (JSONB), expected_outcome, expected_failing_rule_keys (JSONB), expected_reason_codes (JSONB), is_active, last_run_at, last_run_passed, last_run_id, created_by, created_at, updated_at | | rule_test_case_runs | Immutable history of every test-case execution; written by publish gate and ad-hoc Test Runner. Every run is pinned to an exact rule_version_id. | test_case_id, rule_version_id, version_number, run_by_user_id, run_at, actual_outcome, actual_failing_rule_keys (JSONB), actual_reason_codes (JSONB), passed (bool), diff_summary (JSONB), evaluation_engine_version, trigger_source (publish_gate / manual / scheduled / ci) | | rule_evaluation_records | Immutable evaluation audit log with versioning & replay fields (one row per bundle pass). Carries denormalized scope context for fast historical queries. | rule_version_id, entity_type, entity_id, applicant_id, brand_id, program_id, season_id, facts_snapshot_ref, resolution_snapshot_id, decision (JSONB: RuleDecision — authoritative blob), policy_bundle_version, facts_schema_version, evaluation_engine_version, trigger_source, trigger_operation, idempotency_key, evaluated_at | | rule_evaluation_results | Normalized per-rule child of rule_evaluation_records — one queryable row per rule that participated in the bundle pass (the explainability layer; the JSONB decision blob remains authoritative) | evaluation_record_id, rule_definition_id, rule_version_id, rule_key, result (passed / failed / skipped / overridden / errored), severity, reason_codes (JSONB), applicant_facing_message, internal_message, parameters_snapshot (JSONB), provenance (JSONB: source_scope, inherited_from_rule_version_id, suppressed_higher_scopes), evaluated_at | | application_rule_open_breaches | Projection of the rule failures currently open on each application, maintained from live evaluation and cleared when the rule passes again. Backs dimension-keyed Exception queue membership and readiness roll-ups. One row per application, rule action and reason code (legacy rows without an action are per rule). | application_id, rule_definition_id, rule_action_id, reason_code_id, dimension, queue_slug, latest_outcome, opened_at, updated_at | | business_rule_set_metadata | Revision header for a business rule set. `family` is the lineage key; published revisions are immutable and edits fork a draft revision linked by `parent_set_id`. | id, family, lifecycle_state (default draft), revision_number, parent_set_id, owner_user_id, description, brand_id, attached_program_ids, attached_season_ids | | business_rule_set_rules | Junction giving each rule set revision a curated membership, instead of deriving it from every rule sharing a family. | set_id, rule_definition_id (together the primary key), display_order | | rule_evaluation_runs | One row per batch eligibility evaluation (nightly scheduler, CLI or manual). Append-only: a rerun creates a new run. A run is marked completed only after every targeted applicant has a result row; a mid-run crash leaves it failed. | status (running / completed / failed), triggered_by (scheduler / cli / manual), started_at, completed_at, duration_ms, total_applicants, counts (JSONB: pass, fail, needs_review, pending, not_applicable, errored), error_message | | rule_evaluation_run_results | One row per targeted applicant or application per run, carrying the eligibility engine's rule-set evaluation result verbatim. An errored row records that the engine threw for that applicant. | run_id, applicant_id, application_id, rule_set_id, rule_set_version_number, overall_outcome, failure_rule_keys, per_rule_results, counts, error_message, evaluated_at | | rule_gate_dependencies | Prerequisite rules of an operational gate version. A gate reads the outcomes of other published rules, never applicant fields. Version-scoped and cloned when the gate is republished; a referenced rule cannot be hard-deleted while a gate depends on it. | rule_version_id, dependency_rule_definition_id (unique per version), accepted_outcome (PASS / PASS_OR_APPROVED_REVIEW / NOT_BLOCKING), mandatory (a mandatory dependency that misses its accepted outcome fails the gate; otherwise informational), group_label, sort_order | | rule_evaluation_node_results | Per-tree-node child of rule_evaluation_records, one row per node. | record_id, node_path (unique per record), node_kind, outcome, subject_key, operator_key, fact_keys, detail (JSONB) | | season_rule_overrides_v2 | Season and program rule override, the highest-precedence layer of the cascade; writes must respect compliance locks. | season_id, program_id, rule_definition_id (unique together), pinned_rule_version_id, parameter_overrides (JSONB), disabled, reason, approved_by_user_id, approved_at, effective_from, effective_until, created_by_user_id | | rule_resolution_snapshots | System-generated, publish-time effective-resolution artifact per (brand, program, season). Records which lower-scope rule_version replaces or suppresses which higher-scope rule_version, with override_reason and published_rule_version_id provenance. Never hand-maintained. | id, brand_id, program_id, season_id, policy_bundle_version, triggered_by_rule_version_id, triggered_by_publish_audit_id, resolved_rules (JSONB: per-rule rule_key, published_rule_version_id, source_scope, inherited_from_rule_version_id, replaced_rule_version_ids, suppressed_rule_version_ids, override_reason ), suppressed_rule_definition_ids, computed_at, engine_version | | rule_overrides | Governed overrides with reason, scope, expiry, approvers | evaluation_record_id, rule_version_id, reason_code, justification, risk_classification, scope, standing_flag, expires_at, primary_approver, secondary_approver, before_state, after_state, review_due_at | | compliance_cases | Durable compliance cases with owner, SLA, evidence | case_number, opened_reason_codes, entity_type, entity_id, owner_user_id, severity, status, sla_due_at, sla_paused_at, related_evaluations, related_queue_items, resolution_outcome | | eligibility_evaluation_results | Archive of request-driven single-applicant eligibility rule-set evaluations, used for replay. Written only when the environment variable `RULES_ENGINE_V2_PERSIST_EVALUATIONS` is `on`; a persistence failure is fail-soft and does not fail the evaluation. Batch runs use rule_evaluation_run_results instead. | applicant_id, application_id, rule_set_id, rule_set_version_number, engine_version, overall_outcome, failure_rule_keys, per_rule_results, counts, applicant_snapshot, resolved_snapshot_ids, correlation_id, evaluated_at | > **Legacy Tables** > > compliance_rule_definitions (overlay model) remains in the schema (`shared/models/partnerCompliance.ts`); program_rule_versions (boolean-toggle model) has been dropped (see Legacy Read Sites - Cutover Complete). New code must use the decomposed model exclusively. ### Admin Rules Routes Staff routes over the brand default, season override, override-record and legacy gate tables. Reads and writes both need the permission shown; API keys need `compliance.read` for reads and `admin.write` (with `compliance.read`) for writes. Writes are audited. | Route | Permission | Behavior | | --- | --- | --- | | `GET /api/admin/rules/brand-defaults`, `GET /api/admin/rules/brand-defaults/:id`, `POST` (201), `PATCH /:id`, `DELETE /:id` | compliance.rules.author | CRUD over brand_rule_defaults. The list takes an optional `brandId`. An unknown id on GET or PATCH is 404; DELETE returns success even when no row matched. | | `GET /api/admin/rules/season-overrides`, `POST` (201), `PATCH /:id`, `DELETE /:id` | compliance.rules.author | CRUD over season_rule_overrides. The list takes optional `seasonId` and `programId`; `approvedAt`, `effectiveFrom` and `effectiveUntil` are accepted as ISO timestamps. | | `POST /api/admin/rules/evaluate/:applicationId` | compliance.view | Returns `applicationId` and `resolvedRules` (the rules resolved for that application). Without an id the call is a validation error, and any verb other than POST on the evaluate paths returns 405 `METHOD_NOT_ALLOWED` with `Allow: POST`. | | `GET /api/admin/rules/overrides` | compliance.overrides.review | Lists rule_override_records (optional `applicationId`) with applicant and creating-staff names. | | `POST /api/admin/rules/overrides`, `PATCH /api/admin/rules/overrides/:id` | compliance.gates.override | Creates an override record, stamping `overriddenBy` from the caller (201), or updates one. A PATCH with no updatable fields is a validation error; an unknown id is 404. | | `GET /api/admin/rules/compliance-gates` | compliance.view | Lists compliance_gates, optional `programId`. | | `POST /api/admin/rules/compliance-gates` (201), `PATCH /:id`, `DELETE /:id` | compliance.rules.author | Manage compliance_gates rows (a legacy table, see Data Model). | | `POST /api/admin/rules/compliance-gates/check` | compliance.view | Body `applicationId`, `programId`, `fromStageKey`, `toStageKey`; returns the gate check result for that stage transition. | The `/api/admin/rules/evaluation-results` alias, the `/api/admin/rules/resolve` helper and the `/api/admin/rules/program-rule-versions/*` adapters are retired and no longer registered; use the eligibility rule-set engine at `/api/admin/eligibility/rule-sets/*`. ## Sponsor-Grade Requirements As a designated J-1 visa sponsor platform, the engine must satisfy Department of State regulatory standards. The features on this page map directly to those standards: s published version actually fired and what it overrode). Queues, dashboards, and case viewers query this table directly instead of unpacking JSONB blobs., Internal vs Applicant-Facing Messaging / Each rule definition carries internal_failure_message (operational, may name fact keys and queues) and each rule version carries applicant_failure_message (applicant-facing — plain-language, action-oriented, no internal detail). Publish lint forbids cross-contamination; both are surfaced in the Compliance Review Queue and Evaluation Inspector., Derived Resolution Snapshots / rule_resolution_snapshots are system-generated records of the facts resolved for one entity (entity_type, entity_id, resolved_at, facts_schema_version, the verbatim FactsSnapshot, and overrides_in_force). Snapshots are never hand-maintained — every evaluation references one for replay and provenance., Deterministic Replay / Any past decision can be re-run pure-functionally against the persisted FactsSnapshot, resolution_snapshot, and exact rule_version — byte-for-byte reproducibility., Authority Restriction / Compliance rules can only be authored by compliance users. Overrides follow each rule ## Asana Project Cross-Reference The sponsor-grade engine touches 12 of the 117 tracked Asana projects. Each connects through specific integration points: | Project | Name | Connection | | --- | --- | --- | | P3 | Rules Engine & Eligibility | This IS the rules engine project — all five layers plus UI surfaces. | | P1 | Foundation Hardening | RBAC: compliance.rules.author, compliance.rules.publish, compliance.review.manage, compliance.console.view, audit_log.view. | | P2 | Applicant Pipeline | Runtime entity_policy evaluation on applicant entry and phase transitions. | | P4 | Host & Job Order Foundation | Job order entity_policy rules; host.status and daysSinceLastSiteVisit facts. | | P5 | Matching & Placement Core | Placement-confirmed fact; reservation-to-placement gate rules. | | P7 | Matching Engine | Hard filters now expressed as entity_policy rules; optimization-subclass rules guide scoring. | | P10 | SEVIS & Compliance Gateway | SEVIS facts (hasSevisCheckinOverdue, sevisRiskLevel, isInSevisPool, sevisReadinessBlocked); DS-2019 and closure gate rules. | | P11 | Workflow Studio | configuration_integrity rules: coverage panel, publish checks. | | P13.1 | On-Program Lifecycle Management | Continuous evaluation, check-in count operators, program extension and program renewal rules, Compliance Cases. | | P16 | Queue System & SLA | Compliance Review Queue definition; case SLA integration. | | P18 | Document Review | Document facts (for example passportReadable, passportIdentityApplied, passportUploaded) feed the Facts Resolver. | | P21 | Applicant Profile Phase 2 | J-2 cross_entity rules; surname-match derived fact; program renewal eligibility. | --- --- title: "Rules Engine Architecture" slug: rules-engine-architecture section: Rules & Compliance status: stable description: "Tiered rule cascade model, conflict detection, and admin governance policy for the Globus Rules Engine Phase 3" last-updated: 2026-10-07 --- # Rules Engine Architecture Tiered rule cascade model, conflict detection, and admin governance policy for the Globus Rules Engine Phase 3 ## Overview The Globus Rules Engine Phase 3 uses a declarative, tiered cascade model to resolve which rules apply to a given applicant at any point in their journey. Rules are defined at multiple scopes and resolved in a strict priority order. The engine is built on the ruleCascadeResolver.ts service and dispatches side effects via actionOrchestrator.ts. For the full list of action types, see Rule Actions Reference . ## 6-Layer Rule Cascade Rules are resolved in a strict priority order from most-specific to least-specific. A rule at a higher specificity layer always overrides a rule at a lower layer for the same ruleKey. This allows global defaults to be set at the regulatory bundle level while permitting brand- or program-specific overrides. ### Layer Reference | Priority | Layer | Schema Table | Override Allowed | | --- | --- | --- | --- | | 1 (lowest) | Rule Default | rule_parameter_defaults | Yes — by all layers above | | 2 | Visa Type | visa_type_rule_defaults | Yes — by category and above | | 3 | Category | category_rule_defaults | Yes — by brand and above | | 4 | Brand | brand_rule_defaults_v2 | Yes — by program and above | | 5 | Program | program_rule_configs | Yes — by season only | | 6 (highest) | Season | season_rule_overrides_v2 | No — highest specificity | ## Conflict Detection Conflict detection runs at publish time, when a draft rule version is published. Scope uniqueness is checked per rule definition: a definition must not have two live published versions covering the same scope (overlap with a different definition is allowed). An immediate publish auto-supersedes the prior overlapping published version (archived, reason `superseded_by:`); a future-dated publish cannot supersede yet, so it is rejected with a scope uniqueness violation listing the conflicting published version IDs, and the prior version must be archived first. The exception is a version bound to gate progression and published with a reviewed publication scope: when every conflicting published version is the definition's current published version, the future-dated publish is not rejected for scope uniqueness. This prevents silent overrides and ensures the cascade remains deterministic. **Definition and version API.** The read routes below use the rules-engine read gate (any of `compliance.rules.author`, `compliance.console.view`, `compliance.bundles.read` or `programs.manage`; API-key scopes `compliance.read` or `admin.read`), and Program Managers only reach rules in their program scope. `GET /api/rules-engine/definitions/by-key/:key` returns a definition by its unique key (404 when missing). `GET /api/rules-engine/definitions/:id/scope-chain` returns the chain across the layers `rule_default`, `visa_type`, `category`, `brand`, `program` and `season`, each marked `this_rule`, `inherited` or `not_present`, plus the business rule sets that contain the rule. `GET /api/rules-engine/definitions/:id/applicant-gate-state` reads the live state of an `operational_gate` definition for one applicant (query `applicantId`, required; optional `applicationId`, otherwise the applicant's latest application); it returns 400 for a definition that is not an operational gate, and 404 for an unknown, out-of-scope or application-less applicant. Draft-version writes: `POST /api/rules-engine/versions/:id/scopes` and `DELETE /api/rules-engine/scopes/:id` need `compliance.rules.assign`, and `PUT /api/rules-engine/versions/:id/parameter-defaults` needs `compliance.rules.author`; all three return 400 when the version is not a draft. ## Admin-Only Creation Policy > **Rule Creation is Permission-Gated** > > New rule definitions are created through POST /api/rules-engine/definitions, which requires the compliance.rules.author RBAC permission. Program managers who pass that gate may only create business-classification rules and may only edit business rules within their program scope; they cannot create or re-classify compliance rules. Other business users can view and configure rule parameters within permitted bounds. This policy is enforced at the API layer. The admin-only policy prevents uncontrolled proliferation of rules and ensures that every new rule definition has been reviewed for compliance implications before activation. **Business rule set transitions.** `POST /api/rules-engine/business-sets/:id/transition` (body `nextState` of `draft`, `published`, `deprecated` or `archived`, optional `reason`) moves a set through its lifecycle and writes an audit row in the same transaction. Forward edges (`draft` to `published`, `published` to `deprecated`, `deprecated` to `archived`) need `business.rules.manage` or `compliance.rules.author`. Override edges (`published` to `draft`, `deprecated` to `published`, `archived` to `deprecated`) need `compliance.rules.govern` and a non-empty `reason`. God-mode administrators may perform any legal edge; every other pair is illegal. Errors: 422 `RULE_SET_TRANSITION_ILLEGAL` (no such edge), 403 `RULE_SET_TRANSITION_FORBIDDEN` (missing permission for the edge kind), 422 `RULE_SET_TRANSITION_REASON_REQUIRED` (override without a reason). Callers holding neither `compliance.rules.author` nor `compliance.rules.govern` must also have program scope that intersects the set's attached programs, or brand scope covering its brand, otherwise the call returns 403. ## Rule Families and Classifications Every rule definition carries two classification fields that govern how it is processed and displayed: | Field | Values | Meaning | | --- | --- | --- | | classification | compliance \| business \| operational_gate | Compliance = regulatory mandate; Business = operational policy; Operational gate = readiness aggregator over existing Business/Regulatory rule outcomes | | ruleCategory | e.g. eligibility, document, sevis | Groups rules for display and bulk evaluation | **Business rule sets.** A set has a lifecycle state (`draft`, `published`, `deprecated`, `archived`). `GET` and `PUT /api/rules-engine/business-sets/:family/metadata` read and write a set's owner, description, brand, attached programs and attached seasons (write needs `compliance.rules.author`; unknown program, season or brand ids are rejected with 400). `POST /api/rules-engine/business-sets/:id/fork` (`compliance.rules.author`) copies a published set into a new draft revision and returns 400 for any other state. `POST /api/rules-engine/business-sets/:id/rules` and `DELETE /api/rules-engine/business-sets/:id/rules/:ruleDefinitionId` add and remove one member. `POST .../rules/bulk-add` and `POST .../rules/bulk-remove` take `ruleDefinitionIds` (1 to 500 UUIDs) and an optional `reason`, apply each rule independently and return `batchId`, a `summary` (ok, failed, total) and per-rule `outcomes`. Metadata and membership writes on a published set fail with 409 `RULE_SET_PUBLISHED_IMMUTABLE`; the bulk routes also fail with 409 `RULE_SET_ARCHIVED` on an archived set. ## Monotone Lock Floor Regulatory bundle parameters marked as compliance_locked = true establish a monotone lock floor: no layer above the bundle (brand, program, or season) can override the locked parameter to a value that violates the regulatory constraint. For example, if the j1-camp-counselor bundle locks max_duration_months = 4, no program configuration can set a higher value. The lock is enforced at the API validation layer and surfaced as a read-only field in the Rules Studio UI. **Effective-rules read API.** The read-only routes under `/api/rules-engine/effective` use the same rules-engine read gate and Program Manager scoping. `GET /effective/bundles` returns the resolved scope (query `visaType`, `categoryId`, `categoryCode`, `brandId`, `programId`, `seasonId`; a Program Manager is pinned to their program) and the bundles in it with their resolution `status`. `GET /effective/dependencies/:ruleId` returns, for the rule's active published version, its evidence facts, actions, bindings, containing rule sets, queue definitions, program attachments and the regulatory bundles that contain it; forward applicant count and rule-to-rule used-by are reported as `unavailable`. `GET /effective/runtime-detail/:ruleId` returns the active version (number, publisher, publish reason, `failSafeMode`), the winning source bundle with its lock settings, actions and scope chain, and answers 404 when the rule has no active published version. **Applicant evaluation filters bundle members before precedence.** A bundle can be shared as an assignment or governance container across several evaluator families, so being in a bundle does not by itself put a rule in the applicant regulatory evaluator. Before the applicant evaluation resolves bundle precedence, it drops bundle-sourced rules owned by another evaluator (`filterRegulatoryApplicantBundleRules`, using `isRegulatoryApplicantBundleRule`). Some bundles use an allowlist (for `j1-phase-gate-governance`, only one criminal-background upload rule stays in the applicant stream) and some use a denylist of members owned by a narrower path (for `j1-camp-counselor`, `j1.camp_counselor.max_program_months`, which Ready for SEVIS evaluates itself with live placement facts). Bundles with no policy keep all members, and direct program attachments are not filtered. The filter runs first so that an ineligible governance-bundle copy of a rule definition cannot win precedence over the eligible program-bundle copy and then be discarded, which would make the rule vanish from applicant checks. A rule definition with reviewed, activated declared scope versions can also enter the applicant evaluation without a bundle or program attachment: when its declared scopes resolve to a matching version, it is attached as a `direct` attachment pinned to that version. An unpinned declaration whose scope does not match is dropped rather than falling back to the newest head, while explicit pins keep their attachment authority. Competing declared versions with no explicit direct pin raise a `RULE_SCOPE_VERSION_CONFLICT` conflict error. After attachments are selected, each attachment that has a resolved version has its parameters resolved with `resolveRulePolicy`, and the resulting `policy.parameters` replace the attachment's `parameterOverrides`. ## Published compliance review and override audit A read-only deploy/operations report checks every current published compliance or regulatory applicant rule (rule definitions whose classification is compliance or regulatory, at their current published version). It is run with `npx tsx server/scripts/auditPublishedComplianceReviewOverrides.ts`, which delegates to the pure auditPublishedComplianceRules service (publishedComplianceReviewOverrideAudit.ts), prints a human-readable report followed by the same result as JSON, and exits 1 when any finding is reported. It never writes. For each rule the report shows the version, whether the rule routes to review (a REVIEW_QUEUE or needs_review action, or a hold_for_review fail-safe mode) or resolves automatically, the fact keys the rule tree reads, and the override policy (permitted by default when no version-scoped policy exists). Every published applicant rule is classified as overridable, because all must support the governed override path. A review path counts as complete only when it has a live (active) review queue, a reason code, and an authoritative producer for each fact the rule reads (a fact with a producing process or workflow-step producer; merely closing a case is not evidence). Findings are reported as configuration findings with these codes: MISSING_OVERRIDE_POLICY (no version-scoped policy; runtime uses the standard governed override path), FORBIDDEN_OVERRIDE_POLICY (published applicant rules may not disable governed override), INVALID_REVIEW_ROUTE (hold-for-review with no review-queue action, or an action pointing at a missing or inactive queue), MISSING_REVIEW_REASON_CODE, MISSING_REVIEW_PRODUCER, and UNKNOWN_FAIL_SAFE_MODE (anything other than fail_closed, fail_open_with_warning or hold_for_review). --- --- title: "Rule Actions Reference" slug: rule-actions-reference section: Rules & Compliance status: stable description: "Complete reference for all rule action types, their triggers, and how they surface to applicants and staff" last-updated: 2026-10-07 --- # Rule Actions Reference Complete reference for all rule action types, their triggers, and how they surface to applicants and staff ## Overview The Globus Rules Engine Phase 3 uses a declarative side-effect model. When a rule evaluates, it produces a RuleDecision envelope containing requiredActions. The actionOrchestrator.ts processes this envelope and dispatches actions to pluggable handlers. For the architecture that produces these decisions, see Rules Engine Architecture . Each dispatched action is recorded in `rule_action_dispatches`, unique on `idempotency_key` and `action_index` (the action's position in `requiredActions`), so repeating a decision with the same idempotency key does not dispatch its actions twice. Columns: `action_type`, `evaluation_record_id` (deleted with its evaluation record), `status` (`open`, `succeeded`, `failed` or `skipped`; default `succeeded`), `payload`, `result` (for example a created queue item, case or notification id), `error` and `dispatched_at`. ## Action Types *Status: the table below describes the Phase 3 orchestrator vocabulary, and parts of it are out of date. `actionOrchestrator.ts` knows notify, queue_review, hold_documents, block_phase, open_exception, lock_parameter and sevis_ds2019_amendment, where block_phase and lock_parameter are informational only. Rule authoring uses the closed set BLOCK, WARNING, REVIEW_QUEUE, AUTO_APPROVE, AUTO_REJECT, REQUIRE_DOCUMENT, CREATE_TASK and APPLY_TAG (`shared/ruleOutcome.ts`), dispatched by `ruleActionDispatcher.ts`. `warn` and `auto_request` are not action types; the closest equivalents are WARNING and REQUIRE_DOCUMENT.* An action type outside the closed authoring set is rejected with 422 `INVALID_RULE_ACTION` on the rule action create route (`POST /api/rules-engine/versions/:id/actions`); the message lists BLOCK, WARNING, REVIEW_QUEUE, AUTO_APPROVE, AUTO_REJECT, REQUIRE_DOCUMENT, CREATE_TASK and APPLY_TAG. An unknown severity value is rejected with 422 `INVALID_RULE_SEVERITY`; the allowed values are `blocking`, `warning`, `review` and `informational`. Both errors carry `details.path`, `details.received` and `details.allowed`. | Action Type | Trigger Condition | System Function | | --- | --- | --- | | block_phase | Rule evaluates to fail without a permitted override | Prevents transition to the next lifecycle phase. Enforced synchronously by lifecyclePhaseGate.ts | | warn | Rule evaluates to fail_open_with_warning | Flags an issue but allows the phase transition to proceed | | auto_reject | A critical eligibility rule fails (e.g., applicant under absolute minimum age) | Automatically terminates the application and moves it to a terminal state | | queue_review | Rule evaluates to hold_for_review or carries a permitted_override_fail marker | Pushes the application into compliance_review_queue_items for manual intervention | | auto_request | Rule detects missing or expired information (e.g., passport expiring within 6 months) | Automatically generates a request for information or document upload | | hold_documents | Rule determines document processing should pause (e.g., awaiting payment clearance) | Sets a documents_on_hold flag downstream in the document service | | open_exception | A complex compliance violation requiring formal investigation | Opens a formal case in compliance_cases with SLA timers | | lock_parameter | A milestone is reached requiring data immutability (e.g., SEVIS pool entry) | Instructs downstream services to lock specific sections or fields. See Section Locking | | notify | Any rule evaluation can append a notify action based on configuration | Emits an event to the notification service for email, SMS, or portal alert | ## Visibility and User Experience | Action Type | Applicant Visibility | Staff Visibility | | --- | --- | --- | | block_phase | "Blocked" or "Action Required" status with the applicantFailureMessage | Readiness state shown as blocked with internalFailureMessage | | warn | Typically invisible unless configured to show a non-blocking alert | Warning banner on applicant profile; readiness state unchanged | | auto_reject | Application status changes to "Rejected". Automated email notification sent. | Application moves to terminal state; reason logged in audit history | | queue_review | Application shows as "Under Review". Progression temporarily paused. | Application appears in Compliance Review Queue for manual intervention | | auto_request | Email and portal notification prompting specific information upload | Request logged in applicant's communication history | | hold_documents | Upload sections may be locked or documents show as "Pending Review" indefinitely | Document review queues indicate hold status | | open_exception | "Under Investigation" status depending on severity | Case appears in Compliance Cases dashboard with SLA timers | | lock_parameter | Target sections become read-only with padlock indicator | Fields locked in Command Center; unlock requires journey.lock.unlock permission | | notify | Email, SMS, or portal alert based on payload | Notification logged in applicant communication history | --- --- title: "Section Locking & Unlocking" slug: section-locking section: Rules & Compliance status: stable description: "How Globus lets staff and admin-authored policies control when applicants can and cannot edit specific sections (steps) of their application — with fail-closed enforcement, authored explanations, scheduled auto-release, and full observab…" last-updated: 2026-10-07 --- # Section Locking & Unlocking # Section Locking & Unlocking How Globus lets staff and admin-authored policies control when applicants can and cannot edit specific sections (steps) of their application — with fail-closed enforcement, authored explanations, scheduled auto-release, and full observability. Shipped and in production. ## Overview Section Locking lets sponsor staff control **when applicants can and cannot edit specific sections (steps) of their application**, in two ways: 1. **Manually** — a case manager locks one or more sections of a specific application, with a note explaining why. 2. **Automatically** — program admins author **per-program policies** (presented in the admin UI as **applicant editing controls**) that lock sections when a lifecycle trigger fires (phase reached, stage entered, step completed, step approved by staff, phase completed). Once a lock is active, the applicant sees the section as read-only with a staff-authored explanation banner, and every write path (portal and staff-assisted) rejects edits with `STEP_LOCKED`. Locks can be released manually, auto-release at a scheduled time, step aside automatically when a rejected step needs resubmission, or be temporarily suspended for a bounded correction window (see **Suspension** below). Section Locking is one layer of a broader, fail-closed **Step Access** system: a single resolver decides, for every step of every application, whether it is `editable`, `view_only`, `locked`, or `hidden` — and every consumer (portal, write guards, webhooks) reads that one decision so they can never disagree. ## Problem & Design Position J-1 program operations require freezing parts of an application at specific points in the applicant journey: - **Regulatory integrity** — once data has been submitted to SEVIS or embedded in issued documents (DS-2019, insurance, placement agreements), applicant edits to the underlying sections create compliance drift between what was filed and what the record says. - **Operational review windows** — while staff review a submitted section, concurrent applicant edits invalidate the review. - **Milestone-based freezes** — after key milestones (placement confirmed, visa issued), earlier sections must become immutable except through a controlled staff process. Before Section Locking, the platform had only *derived* read-only states (upstream-dependency gating, SEVIS/DS-2019 gating). There was no way for an operator to say: *"When an applicant reaches phase X, freeze sections A and B and show them this message."* Deliberate **non-goals**: - **Sequential step gating** — steps are deliberately *not* gated by completion order (removed by product decision, June 2026). Section Locking must not be used to re-introduce it. - **Portal-side lock computation** — the portal is a pure renderer of the server's step-access contract; it never derives lock state itself. - **Document/legal locks** — "legally locked" step semantics (driven by step-type metadata) are adjacent but separate. - **Replacing derived locks** — SEVIS-track gating, DS-2019 gating, and explicit-prerequisite gating remain independent layers beneath Section Locks. ## Step Access Resolver & Precedence A single resolver (`server/services/stepAccessResolver.ts`) produces the access decision for every step of every application, resolved against the application's **pinned workflow version**. Precedence, highest first: | Priority | Layer | Result | | --- | --- | --- | | 1 | Conditional-visibility logic hides the step | `hidden` | | 2 | Persisted Section Lock (active lock row) | `locked` — authored banner + variant; `unlock_at` surfaced for time locks; skipped if the reject-clearable bypass applies or the lock is currently suspended | | 3 | Derived locks — DS-2019 not yet issued (`DS2019_NOT_ISSUED` — steps at the `visa_sevis` / `pre_departure` journey positions until the application has an issued DS-2019), the four pre-SEVIS track gates (`SEVIS_*_TRACK_INCOMPLETE`), upstream dependency (`UPSTREAM_STEP_INCOMPLETE` — explicit workflow prerequisite edges only, satisfied only by a staff `approved` status) | `locked` per layer | | 4 | Otherwise | `editable` | > **Fail-closed contract.** Any uncertainty produces a *more* restrictive access. If the pinned workflow version cannot be resolved, everything is treated as locked (`WORKFLOW_VERSION_UNRESOLVED`) — writes are blocked, never silently allowed. ### Compliance Block (`COMPLIANCE_BLOCKED`) — not a resolver layer at HEAD The `COMPLIANCE_BLOCKED` reason code still exists in the shared vocabulary (`shared/stepAccess.ts`), but the step-access resolver on `development` no longer produces it: there is no compliance/readiness layer in the precedence stack above, so an application's readiness state (including `blocked`) does not itself lock any step. Compliance results surface to staff as needs-attention rather than as applicant-facing step locks. The earlier intake-step exemption and awaiting-submission handling for this layer (Aug 11 2026 release capsule) are therefore moot. Every step write path (portal submission and staff-assisted writes) is intended to consult the same resolver and reject locked writes with error code `STEP_LOCKED`. The portal learns access exclusively from the published Step Access & Lock Contract endpoint (`GET /api/portal/applications/:applicationId/step-access`). > **Enforcement status (Aug 2026):** the *portal* write path is enforced — the applicant-portal BFF re-checks step-access before forwarding any submit and refuses non-editable steps with `423 STEP_LOCKED` (it also refuses when the record carries `can_applicant_submit: false`), fail-closed if the step-access read fails (live on dev, PPPToo PR #204). The Hub's own submit door (`POST /api/portal/steps/:stepKey/submit`) does **not** yet enforce the contract, so callers hitting the Hub directly bypass locks; it also has no validation schemas registered, so arbitrary payloads pass `valid:true`. Both gaps are escalated (Asana 1217435439544074) and tracked under **Open Items** below. Staff have a second, richer read of the same decision: `GET /api/applications/:id/step-access-explanation` returns every applicable lock layer per step — winner and suppressed alike, including an active-but-not-enforcing Section Lock (suspended or reject-clearable-released) — for diagnosing why a step is (or isn't) locked without guessing. Shared contract vocabulary lives in `shared/stepAccess.ts`: `STEP_ACCESS_LEVELS`, `LOCK_REASON_CODES`, `LOCK_TRIGGER_TYPES`, `POLICY_TRIGGER_TYPES`, `BANNER_VARIANTS`, `StepAccessRecord` / `StepAccessSnapshot`, `STEP_LOCKED_ERROR_CODE`. ## Submitted and Approved Steps Stay Editable Unless Locked Submitting a step no longer freezes it, and neither does staff approval or completion (Aug 11 2026 release capsule, extended since). Previously, once a step was submitted the applicant could no longer amend it — a derived `STEP_ALREADY_SUBMITTED` layer flipped the step to `view_only` the moment it was submitted. That behavior is superseded: - **No derived terminal-status layer.** The resolver has no already-submitted / already-approved layer any more (`STEP_ALREADY_SUBMITTED` survives only as a legacy code retained for backwards-compatible parsing). A step's tracked status — `submitted`, `approved`, `completed` — does not by itself make it read-only; staff sign-off is status/history evidence, not a lock. To freeze a step after review, author a Section Lock or policy (for example an approval-triggered policy — see **Section Lock Policies**). - **A submitted step stays `editable`.** Applicants can correct a submitted step without a support ticket unless a persisted Section Lock (or a SEVIS / DS-2019 / upstream layer) applies. - **Returned-for-revision reverts to editable immediately.** A step returned for revision becomes `editable` again the moment it is returned — the applicant does not wait for any sweep. Status still matters in two places: it selects the applicant banner variant on a persisted lock (`completed` only for an `approved` or `completed` step), and only a staff `approved` status satisfies an upstream prerequisite. This eliminates the back-channel correction requests that were previously handled via support tickets. The derived floor still applies: a step gated by SEVIS, DS-2019 or an unmet prerequisite is not made editable by this behavior. > **Every application status transition is audited with its rule evidence (Aug 11 2026 release capsule).** Every application status transition is now recorded with the rule-evidence snapshot that triggered it — which rules passed, which failed, and what the evaluated facts were at the moment of the transition. Staff can replay exactly why an application moved from one state to another. This snapshot is captured alongside the standard 6-dimension audit capture described under **Eventing & Audit**. ## Lock Objects A **Section Lock** is a concrete row (`application_section_locks`) on one application + one step key. | Aspect | Contract | | --- | --- | | Trigger types | `manual` (applied directly by staff), `status` / `completion` (materialized from policies), `time` (carries a scheduled auto-release `unlockAt`) | | Reason codes | Machine-readable shared vocabulary: `MANUAL_SECTION_LOCK`, `STATUS_TRIGGER_LOCK`, `COMPLETION_TRIGGER_LOCK`, `TIME_WINDOW_LOCK` | | States | `active` → `released` (staff action) or `expired` (time-based auto-release); an `active` lock can also be temporarily `suspended` without changing state (see **Suspension**) | | Invariant | At most **one active lock per (application, step)** — enforced by a partial unique index — so banner selection is unambiguous and re-materialization is idempotent | | Provenance | Records who locked (`lockedBy` / `lockedByName`), the internal reason, the source policy (if any), the workflow version evaluated against (`evaluatedAgainstVersion`), and release metadata (`releasedBy`, `releaseReason`, `releasedAt`) | ## Applicant-Facing Messaging Every lock (and policy) carries **two** applicant-facing banner messages: - `messageIncomplete` — shown when the locked step has not yet been approved or completed. - `messageCompleted` — shown when the step's status is `approved` or `completed`. The variant is chosen automatically server-side from the step's status. Authored messaging is exclusive to persisted Section Locks; derived locks (DS-2019, SEVIS, upstream) show no authored banner. ## Section Lock Policies A **Section Lock Policy** (`workflow_section_lock_policies`) is an admin-authored template on a program, optionally narrowed to one workflow type. In the admin UI these are presented as **applicant editing controls**; the workflow type shown while authoring is context, not an enforced scope boundary (materialization resolves targets against each application's pinned workflow and drops any that do not exist there, so a matching step key in another workflow version used by the program is also affected). **Policy trigger types:** | Trigger | Fires when | Notes | | --- | --- | --- | | `status` | Applicant's journey enters a chosen lifecycle phase | `triggerKey` = journey position key | | `stage_entry` | Applicant reaches a stage from the Program Lifecycle Builder | `triggerKey` = stage definition id; materializes as a `status`-reason lock | | `completion` | A specific step is completed | `triggerKeys` may hold several step keys; fires when ANY completes | | `approval` | Staff transition a specific step to `approved` | `triggerKey` = one step key (the create route rejects more than one trigger key). Fires only on a staff-attributed transition into `approved` on the principal lane — applicant submission and automatic completion do not fire it. To lock the approved step itself, select the same step as both trigger and target. Materializes as a `completion`-reason lock | | `phase_completion` | Every step in a chosen phase is completed | Materializes as a `completion`-reason lock | `stage_entry`, `approval` and `phase_completion` are **authoring-only** trigger types — they materialize down to `status` / `completion` reason codes so the resolver vocabulary stays small. **Targets** (`section_lock_policy_targets`): a policy locks either **specific steps** or **entire phases** (`targetType` = `step` | `phase`). Phase targets resolve to concrete step keys against each application's **pinned workflow version** at trigger time — never the latest published version, including for applications pinned to older versions. Policies can be created, edited, deactivated (`isActive`), and deleted; each stores author identity. Policy CRUD and per-trigger materialization live in `server/services/sectionLockPolicies.ts`. ## Backfill & Idempotency - **Backfill** — creating or (re)activating a policy fires a best-effort backfill that finds existing non-terminal applications whose current state already matches the trigger and materializes their locks — no gap cohort. The match is against current state (journey position, current stage, current step status; for `approval`, a step currently `approved` with a recorded staff `updatedBy`), not historical traversal: the system does not infer that an applicant previously passed a milestone from where they are now. Policies never fire retroactively on their own; backfill is an explicit (automatic-on-create/activate) sweep, so behavior is deterministic. - **Idempotency** — materialization skips steps that already hold an active lock (unless an explicit `override` is requested on manual creation). A `status` policy fires exactly once per application when the journey enters the trigger phase; re-entry does not duplicate locks (partial unique index + idempotent materialization). Later policy fires against an already-locked step are no-ops — the first lock's messaging wins. ## Unlock & Release Paths 1. **Manual release** — staff release a single lock with an optional `releaseReason` (max 2,000 chars), recorded with releaser identity and timestamp. There is no bulk applicant-facing "release all"; the staff panel multi-select issues per-lock calls. 2. **Scheduled auto-release** — `unlockAt` is required for `time` trigger locks. Read paths treat a past-due time lock as released immediately; the scheduler flips due locks to `expired` and emits events. 3. **Reject-clearable bypass** — if a lock is flagged `rejectClearable` and the step's status is `rejected`, the lock is skipped at read time so the applicant can resubmit; it re-applies once the step leaves `rejected`. In the admin UI this is the "Allow corrections after rejection" option. 4. **Staff suspension** — a bounded, deadline-enforced correction window that leaves the lock row `active` but non-enforcing until the deadline or resubmission, without a full release/re-lock cycle (see **Suspension** below). > **Derived locks are not releasable.** Staff can only release persisted Section Locks; SEVIS / DS-2019 / upstream states clear only when their underlying condition clears. Releasing a Section Lock restores the underlying derived access — a step with no derived lock beneath it becomes `editable` again regardless of its submitted or approved status (see **Submitted and Approved Steps Stay Editable Unless Locked**). ## Suspension — Temporary Correction Windows Permanently releasing a lock and re-creating it later is the wrong tool for a small applicant correction (a passport typo, a wrong date) — nothing guarantees the section actually gets re-locked afterward. **Suspension** gives staff a bounded, deadline-enforced alternative that leaves the lock row itself intact: - `POST /api/applications/:id/section-locks/:lockId/suspend` — suspends an active lock. Body: `suspendUntil` (ISO timestamp; defaults to now + 72h; must be in the future), `reason` (optional staff note), `reopenStep` (boolean — when true and the step's *derived* status is terminal, the step is flipped to `rejected` via the canonical step-status writer so the portal form actually reopens). Returns `{ lock, stepReopened }`; `409 LOCK_ALREADY_SUSPENDED` if the lock is already suspended. - `POST /api/applications/:id/section-locks/:lockId/resume` — early re-engage ("Re-engage now"): clears the suspension immediately. `404` if the lock is not an active, currently-suspended lock. Semantics: a suspended lock's row stays `state: "active"` — it still occupies the one-active-lock-per-step slot, so a lock policy re-materialization cannot race in and re-lock the step mid-correction — but the resolver stops enforcing its own persisted-lock decision until the applicant resubmits the step (the tracked status leaves `rejected`) **or** `suspendUntil` passes, whichever comes first. Like the reject-clearable bypass, a suspension never loosens the derived floor beneath the lock — an unmet SEVIS/DS-2019/upstream gate still applies even while a lock is suspended. Deadline enforcement is **fail-closed at read time**: the resolver re-checks `suspendUntil` on every read, so a past-deadline suspension enforces again immediately even before any background sweep runs. The same section-locking scheduler that expires time locks also runs a parallel sweep for suspensions (5-minute coarse poll + precise per-lock timers) that clears lapsed suspension fields and emits `step.lock_changed` for any suspension the read-time check has already treated as expired. Every suspend and resume writes an audit row (`section_lock_suspended` / `section_lock_reengaged`); an applicant-triggered auto-resume (resubmission) is tagged accordingly, distinct from a staff-initiated "Re-engage now". Shipped 2026-07-21 as an API + resolver capability. The Section Locks staff panel does not yet expose a manual "Suspend" / "Resume" button — staff currently call the API directly, and the read-only step-access-explanation view (see **Step Access Resolver & Precedence**) is how staff can confirm a suspension is in effect (see **Open Items**). ## Time-Lock Scheduler `server/services/sectionLockingScheduler.ts` combines a **5-minute coarse poll** with **precise per-lock timers**, and is restart-safe — a boot tick sweeps anything already past due. A time lock with `unlockAt` in the past is treated as released on read immediately, and flipped to `expired` (with a `step.lock_changed` event) within one poll interval. Success signal: time locks expire within one poll interval of `unlockAt`. ## API Surface & Permissions | Endpoint | Method | Permission | | --- | --- | --- | | `/api/applications/:id/section-locks` | GET (list, incl. history) | `applicant.profile.view` | | `/api/applications/:id/section-locks` | POST (create manual/time locks across a scope) | `applicant.journey.update` | | `/api/applications/:id/section-locks/:lockId/release` | POST | `applicant.journey.update` | | `/api/applications/:id/section-locks/:lockId/suspend` | POST (temporary correction window) | `applicant.journey.update` | | `/api/applications/:id/section-locks/:lockId/resume` | POST (early re-engage) | `applicant.journey.update` | | `/api/applications/:id/step-access-explanation` | GET (per-layer access breakdown) | `applicant.profile.view` | | `/api/admin/programs/:programId/section-lock-policies` | GET | `programs.view` | | `/api/admin/programs/:programId/section-lock-policies` | POST (`triggerType`: `status` \| `completion` \| `approval` \| `phase_completion` \| `stage_entry`) | `programs.manage` | | `/api/admin/section-lock-policies/:policyId` | PATCH / DELETE | `programs.manage` | | `/api/portal/applications/:applicationId/step-access` | GET (portal contract read) | portal auth | All staff endpoints additionally enforce entity scope (program / season / brand / partner) via the RBAC scope layer. Locked writes are rejected with `STEP_LOCKED`. ## Eventing & Audit - **`step.lock_changed`** — every lock mutation (create, release, expiry, suspend, resume) diffs the before/after step-access snapshot and emits one event per flipped step into the `ops_events` outbox (best-effort, deduped on application + step + access + version), delivered via the managed n8n subscription. This feeds webhooks and notifications. - **RBAC audit** — staff mutations flow through the standard 6-dimension audit capture (who / what / when / where / before-after / source). Every create, release, expiry, suspend, and resume is auditable with actor, reason, and timestamps. - **Application status transitions carry their rule evidence (Aug 11 2026 release capsule).** Every application status transition is recorded with the rule-evidence snapshot that triggered it — which rules passed, which failed, and the evaluated facts at the moment of the transition — so staff can replay exactly why an application moved from one state to another (see **Submitted and Approved Steps Stay Editable Unless Locked**). ## UX Surfaces **Admin — Applicant editing controls panel** (Workflow Studio / program config; formerly "Section Lock Policies"): framed in operational language ("Choose when completed application sections should become read-only"), with an **Add editing control** button and a four-question form — what should applicants stop editing ("Choose a whole phase" — the default — or "Choose individual sections"), when editing should stop, whether applicants may make corrections after rejection, and what the applicant should see. The standard "when" options are "Applicant reaches a milestone", "A section is completed" and "A workflow step is approved" (staff approval of the selected step; applicant submission and automatic completion do not trigger it); "Applicant reaches a stage" and "All steps in a phase are completed" sit behind **Advanced settings**, together with the optional internal name and the separate in-progress applicant message. A live **portal preview** of the applicant-facing banner is shown as the admin types. Creating a control requires ticking "Apply this control to existing applicants" (existing non-terminal applications that currently match are updated after save; submit button reads "Create and apply"). Rows read as "Protect … when …" with an Active / Paused badge and the internal name; reactivating a paused control opens a confirmation dialog because it starts an asynchronous review of existing non-terminal applications. **Staff — Section Locks panel** (Applicant Workspace, deep-linkable tab): lists every lock on the application — active and historical — with trigger type, reason, who/when, and messages. Staff create a manual lock across a scope (one or more step keys and/or phase keys, at least one required; optional `unlockAt`, required for time locks; optional internal reason; optional reject-clearable and override flags) and release active locks with an optional reason. A separate read-only **Explain step access** view surfaces every applicable lock layer per step, including a non-enforcing (suspended or reject-clearable-released) Section Lock — today this is the only staff-facing way to see that a lock is suspended, since the panel has no manual Suspend/Resume control yet. **Applicant portal**: locked steps render read-only with the authored banner (correct variant chosen server-side). Time-window locks surface `unlock_at`, letting the portal render a scheduled-unlock / countdown affordance — the portal never computes the unlock moment itself. ## Governance & Invariants - **One active lock per (application, step)** — partial unique index; later fires are no-ops absent explicit override. - **Fail-closed everywhere** — unresolvable workflow version blocks all writes (`WORKFLOW_VERSION_UNRESOLVED`); uncertainty always yields more restrictive access. - **Single resolver** — portal, write guards, and webhooks all read one decision; no consumer computes lock state independently. - **Pinned-version resolution** — phase targets resolve against each application's pinned workflow version at trigger time, never the latest published version. - **No sequential gating** — by decision of record (June 2026), neither upstream-dependency semantics nor lock policies may be used to force step order. - **Derived locks are a separate layer** — not releasable through the Section Lock release path. Success is measured by reduction in post-filing data-drift incidents, falling "why can't I edit this?" support volume (authored banners), the share of releases with a recorded reason, and scheduler health. ## Open Items Roadmap items from the decision record (not yet built), except the first which is a landed backend capability still missing its staff-facing control: 1. **Suspend/Resume staff UI** — the `/suspend` and `/resume` API endpoints, resolver support, and audit trail are live; the Section Locks staff panel doesn't yet expose a manual trigger for either action, so staff must call the API directly today. 2. **Policy simulation/preview for new policies** — the create dialog already shows advisory overlap warnings, and edits have a dry-run impact preview and an active-lock count, but there is no preview of which current applications a brand-new policy would lock before saving (today: backfill applies immediately on create). 3. **Bulk release UX** — first-class multi-lock release with a single audit reason (today: per-lock calls from the staff panel). 4. **Hub submit-door lock enforcement (F3)** — the Hub door must consult the same resolver and reject locked/hidden/view-only writes with the `STEP_LOCKED` envelope. Today only the applicant-portal BFF enforces this (interim mitigation, live on dev); direct Hub callers are unprotected. Asana 1217435439544074. 5. **Submit-door validation schemas (F4)** — published workflow steps need validation schemas registered on the Hub door; arbitrary payloads currently pass `valid:true` and persist as completed step data. Same escalation ticket. 6. **Applicant notifications** — applicant-facing notification on lock/unlock transitions; the `step.lock_changed` event already exists as the hook. --- --- title: "Agreements Engine" slug: agreements-engine section: Rules & Compliance status: stable description: "How Globus authors, versions, targets, serves, and records signed acceptance of legal agreements — with forensic-grade evidence of exactly which text every applicant saw and signed." last-updated: 2026-10-07 --- # Agreements Engine How Globus authors, versions, targets, serves, and records signed acceptance of legal agreements — with forensic-grade evidence of exactly which text every applicant saw and signed. Live in the `development` environment on Globus Hub (Portal ID 2). ## Overview The Agreements Engine manages legal agreements (terms of use, participant terms, program policies) across the ecosystem. It guarantees three things: 1. **Legal integrity** — published agreement text is immutable; every acceptance is recorded against the exact version (and SHA-256 content hash) the person saw. 2. **Operational targeting** — staff decide *which* agreement applies to *whom* via scoped assignments (brand / program / operational cycle / workflow type), with deterministic most-specific-scope-wins resolution. 3. **Two enforcement surfaces** — agreements gate **portal sign-in** (`portal_access` context) and appear as **workflow steps inside an application** (`application_workflow` context), each with deliberately different version-resolution semantics. The engine is composed of three layers: ``` Agreements Engine ├── Agreement Library agreements (catalog, business code) │ └── Version Manager agreement_versions (immutable content lifecycle) ├── Targeting Engine │ ├── Assignment Engine agreement_assignments (scope + context + version policy) │ └── Signature Policies agreement_signature_policies (method + re-sign triggers) └── Resolution & Signature Layer ├── Runtime Resolver resolveAgreementsForContext (most-specific-scope-wins) ├── Pin Store agreement_runtime_resolutions (workflow freeze) └── Acceptance Ledger agreement_acceptances (append-only evidence) ``` Operators are fully self-service: agreements, versions, assignments, signature policies, and Agreement workflow steps are all created through the Hub UI — no engineering involvement. Deliberate exclusions: no external e-signature provider (signatures are checkbox or typed-name captured in-product); legal content never travels with code deploys (promotion to QA/production is an explicit content-migration operation); and the engine never places steps into workflows — placement is exclusively a Workflow Studio operation (see [Workflow Studio](/docs/workflow-builder)). ## The Core Design Decision — Pin-Once vs Live The two serving surfaces intentionally resolve versions differently. An applicant must always be able to finish their application against the agreement text they began with — swapping legal text mid-process would invalidate the acceptance evidence. So workflow agreements **pin** per application: the pin is written at first serve and, once the applicant has signed (or the assignment is `fixed`, or the pin came from a governed cycle mapping), it never changes. Portal access, by contrast, is an ongoing relationship: when the terms of use change, continued access should require accepting the *current* terms. So portal agreements **resolve live** at every sign-in. One engine, two deliberate consistency models. | | Workflow step (`application_workflow`) | Portal sign-in (`portal_access`) | | --- | --- | --- | | When resolved | Once, at first serve of the step | Live, on every sign-in | | Mechanism | Resolve via assignments, then **pin** to `agreement_runtime_resolutions` | Re-resolve against the applicant's anchor application scope on each session start. The anchor is the token's application when the token is application-scoped; otherwise the applicant's newest live application (status outside `APPLICATION_STATUS_TERMINAL`), falling back to their oldest application when none is live | | Effect | Legal text is frozen per application once signed; later publishes and assignment changes never change what a signed applicant saw. Exception: an **unsigned** pin on a `latest_effective` assignment (default `assignment_version` mode) advances to a newly published version on the next serve | Always evaluates the *currently* applicable version and whether the applicant has accepted **that** one | **One-line summary:** assignments decide *which* version applies (`fixed` vs `latest_effective`, most-specific scope wins); workflow steps pin that answer per application at first open (an unsigned `latest_effective` pin follows newly published versions until the applicant signs, then freezes); the portal re-asks on every sign-in and forces re-acceptance on new versions unless the signature policy suppresses that trigger. ## Domain Model Schema lives in `shared/models/agreementLibrary.ts` on the Globus Hub. | Entity | Table | Role | | --- | --- | --- | | Agreement | `agreements` | Catalog entry. Stable snake_case `code` (unique business key), `agreementType` (seed baseline `applicant_portal_terms`, `participant_terms`, `program_policy`, `host_agreement`; the runtime set is lookup-driven and `other` was retired), owner `brandId` (NULL = global), `status` ∈ `active` / `archived`. | | Agreement Version | `agreement_versions` | Immutable legal content. `bodyHtml` (authoritative), `contentHash` (SHA-256), `effectiveDate`. Lifecycle: `draft` → `under_review` → `approved` → `published` → `superseded` / `withdrawn`. | | Assignment | `agreement_assignments` | Targeting rule. `context` ∈ `portal_access` / `application_workflow`; scope dims `brandId` / `programId` / `seasonId` (operational cycle) / `workflowType`; `versionPolicy` ∈ `fixed` / `latest_effective`. | | Signature Policy | `agreement_signature_policies` | Per-agreement default or per-assignment override. `signatureMethod` ∈ `checkbox` / `typed_name`; `resignTriggers` (JSONB array) e.g. `new_version_published`, `identity_changed`, `program_changed`, `manual_reset`. | | Acceptance | `agreement_acceptances` | **Immutable** evidence row: `versionId`, `contentHash`, `acceptedAt`, `ipAddress`, `userAgent`, `sessionId`, `typedName`; `supersededByAcceptanceId` chains re-signs to the acceptance they replace. | | Runtime Resolution (pin) | `agreement_runtime_resolutions` | Records the resolved version for a specific applicant/application the first time an Agreement workflow step is served, plus `cycle_mapping_id` provenance when a governed cycle mapping chose it. Unsigned pins on legacy `latest_effective` assignments advance to a newly published version; signed, fixed-version, and cycle-mapped pins never do. | | Re-Sign Obligation | `agreement_resign_obligations` | Staff-imposed, per-application override that re-serves a carrier step with a target version (see Governed Re-Signing below). | | Version Events | `agreement_version_events` | Append-only log of every version lifecycle transition. | | Cycle Version Mapping | `agreement_workflow_cycle_versions` | Governed version mapping for an `application_workflow` carrier assignment and an operational cycle (`assignmentId`, `seasonId`, `versionId`). `status` ∈ `active` / `superseded` / `retired`, `supersedesId` / `supersededById` lineage, `firstServedAt` stamped by the first pin taken through it. One active row per (assignment, season); rows are never deleted. | | Resolve Failure | `agreement_resolve_failures` | Rolled-up log of resolve-time failures (`failureClass`, e.g. `equal_specificity_tie`, `not_resolvable_archived`, `cycle_mapping_missing`), de-duplicated by `dedupeKey` while `active` (`occurrenceCount`, `firstSeenAt`, `lastSeenAt`). `status` ∈ `active` / `resolved` / `dismissed`. Listed at `GET /api/agreement-resolve-failures` (`agreement_library.view`) and closed with `PATCH /api/agreement-resolve-failures/:failureId` (`agreement_library.manage`). | | Signed Artifact Request | `agreement_signed_artifact_requests` | One row per applicant acceptance (`acceptanceId` unique), enqueued inside the acceptance transaction. `status` ∈ `pending` / `processing` / `completed` / `failed` / `dead_letter`, with `attempts`, `lastError`, `nextAttemptAt` and the produced `documentId`. | **Key invariants** - At most one `published` version per agreement — publish supersedes the prior published version in the same transaction. - At most one "open" version (`draft` / `under_review` / `approved`) per agreement at a time. - Published version content is immutable; edits require a new draft version. - Acceptance rows are never updated or deleted; re-signs create a new row chained via `supersededByAcceptanceId`. ## Business Identity — Agreement Codes `agreements.code` is the **canonical identity** of an agreement. UUIDs are internal implementation identifiers only — seeds, data migrations, Studio carrier steps, integrations, and documentation all reference agreements by code, which is what keeps content promotion and cross-environment addressing portable. | Question | Answer | Enforcement | | --- | --- | --- | | Can a code be renamed? | No — codes are immutable after creation. | The metadata PATCH accepts only `name` / `description` / `status`; `code` is not patchable. | | Can a code be reused by a different agreement? | No. | DB-level `UNIQUE` on `agreements.code`, and agreements can never be deleted, so a code is occupied forever. | | Can an archived agreement be restored? | Yes. | `status` is patchable back to `active`; after restore, new versions and assignments work normally. | | Can an agreement be deleted? | No — there is no delete operation. | `archived` is the only end-of-life state, and it preserves all versions, pins, and acceptances. | | Code format | snake_case, validated. | `^[a-z0-9_]+$` regex on the insert schema. | **Consequence for integrators:** a code, once created, permanently and unambiguously identifies one agreement across all environments and all time. Codes can go dormant (archived) and come back (restored), but they can never change meaning. ## Version Lifecycle & Governance ``` draft ──▶ under_review ──▶ approved ──▶ published ──▶ superseded ▲ │ │ └────────────┘ └──▶ withdrawn (return to draft) ``` - **Publish** marks the previous published version `superseded` transactionally. - **Return to draft** sends an `under_review` version back to `draft` for further editing — review rejection does not require abandoning the version. - **Withdraw** takes a published version out of service without publishing a replacement; the agreement becomes unresolvable until a new version is published. - **Archive** (agreement-level `status = archived`) blocks new assignments, hides the agreement from resolution for *new* pins, and blocks authoring new Agreement steps against its code. Existing pins and acceptances are untouched. Every version transition writes an `agreement_version_events` row, and all staff mutations emit standard six-dimension audit records (e.g. `agreement.version_published`) through the platform audit-capture layer. **Agreement Library write errors** | Code | Status | When | | --- | --- | --- | | `HOST_DESIGNATION_REQUIRED` | 400 | Creating a `host_agreement`-type entry without `hostDesignation` (`trial` or `host_company`), or clearing the designation of an existing one | | `HOST_DESIGNATION_NOT_ALLOWED` | 400 | `hostDesignation` sent for any other agreement type | | `HOST_AGREEMENT_BRAND_SCOPE_CONFLICT` | 409 | Another active entry already holds the same designation in the same brand tier; raised on create, on re-designation and on restoring an archived entry (details `hostDesignation`, `brandId`, `conflictingCodes`) | | `DUPLICATE_AGREEMENT_CODE` | 409 | The agreement `code` already exists | | `DUPLICATE_AGREEMENT_TYPE` | 409 | `POST /api/agreement-library/types` derives a key that already exists, retired types included | | `IMPACT_CONFIRMATION_REQUIRED` | 409 | Archiving an agreement, or withdrawing a version, whose impact (in-flight applications, active assignments, portal gating) is non-zero without `confirmImpact` set to true | | `OPEN_VERSION_EXISTS` | 409 | Starting a new draft while a version is `draft`, `under_review` or `approved` (details `versionId`) | | `AGREEMENT_ARCHIVED` | 409 | Starting a new draft on an archived agreement | | `VERSION_NOT_EDITABLE` | 409 | Saving content to a version that is not `draft` | | `INVALID_STATUS_TRANSITION` | 409 | A status move not allowed from the version's current status (details `from`, `to`) | | `VERSION_CONTENT_EMPTY` | 409 | Submit-for-review, approve or publish of a version with no legal content | | `WITHDRAW_BLOCKED_BY_FIXED_ASSIGNMENTS` | 409 | Withdrawing a version that is the fixed version of active assignments (details `blockingFixedAssignments`) | ## Targeting — Assignments, Contexts, Specificity **Contexts** - `portal_access` — gates applicant-portal sign-in. Outstanding agreements force a blocking signature modal before the portal is usable. - `application_workflow` — resolves which agreement/version an already-placed Agreement workflow step serves. Assignments in this context never place steps. - `host_portal_access` — gates host-portal sign-in. Server-side context; the admin create/list surface landed in QA 2026-07 (see below). **Scope dimensions:** `brandId`, `programId`, `seasonId` (operational cycle), `workflowType`. An all-NULL scope is a mis-configuration — every assignment must be scoped. The `host_portal_access` context rejects `workflowType` scope — workflow type is an applicant-journey dimension and does not apply to host sign-in. ### Host Portal Sign-in Assignments The Agreement Library's Portal Sign-in tab manages both sign-in contexts (QA, 2026-07 — Task #1942; frontend-only change — the `host_portal_access` context already existed server-side): - A portal-type dropdown switches between **Applicant Portal** (`portal_access`, default, unchanged) and **Host Portal** (`host_portal_access`), with per-portal counts in the dropdown and a combined count on the tab badge. - The host-portal create dialog offers brand, program, and cycle pickers plus an optional **Partner Organization** picker; it deliberately omits workflow-type scope (the server rejects it for the host context). - Host lists carry host-specific titles, empty states, an "All host organizations" wildcard label, and a Partner Org scope badge (name lookup with raw-id fallback). - The resolution preview is labeled "Applicant Portal Access" with an explicit note that Host Portal resolution is **not** simulated. **Resolution algorithm** (`resolveAgreementsForContext`): ``` resolve(context, scope): assignments ← active assignments WHERE context matches AND every non-null scope dim matches the caller's scope assignments ← reject those whose agreement is archived group by agreement: winner ← assignment with the highest specificity (specificity = count of non-null scope dimensions) # equal-specificity overlap is impossible: blocked at activation, # and re-checked at resolve time (fails loudly, never guesses) version ← winner.versionPolicy == fixed ? winner.fixedVersionId # must still be published : agreement's current published version if no published version → not resolvable return resolved (agreement, version, signature policy) list ``` A program+cycle assignment beats a brand-wide one. `assertNoEqualSpecificityConflict` rejects activating two assignments for the same agreement/context with equal specificity and overlapping scope — ties are impossible by construction. **Version policy per assignment** - `latest_effective` — the assignment always follows the agreement's *current* published version. - `fixed` — the assignment pins a specific published version ID at assignment time. This exists because some cohorts must be legally frozen on specific terms regardless of what is published later — a pilot program that ran under negotiated terms, a government/partner contract that mandates exact language, or a legal freeze during a dispute. `fixed` holds one scope on a known version while `latest_effective` scopes move forward. **Assignment lifecycle** ``` created (active) ⇄ deactivated — activate/deactivate via update; scope dimensions │ may also be retargeted via update (same assignment identity; existing pins and acceptances stay attached) └──▶ deleted — hard delete allowed ONLY when no runtime pins reference the assignment; otherwise 409 ("deactivate instead") ``` Deactivated assignments stop participating in resolution immediately; existing pins are untouched — the pin always wins. Operators can dry-run resolution for a hypothetical context via the resolution-preview endpoint before targeting goes live. ## Publish, Reassign & Archive Scenarios **A new version is published** - Workflow step, signed (or on a `fixed` assignment, or pinned by a governed cycle mapping): no change. The pin (and any signature) references the old version ID. - Workflow step, opened but **unsigned**, on a `latest_effective` assignment in the default `assignment_version` mode: the pin advances to the new version on the next serve (under the application row lock, and only while no current acceptance exists). The signature block carries the served version ID and content hash; a page opened before the advance is rejected on submit with `409 AGREEMENT_VIEW_STALE` and the applicant must refresh, review the current text, and sign again. - Workflow step, not yet opened: first open resolves to the new version (under `latest_effective`) and pins it. - Portal sign-in: by default the agreement **re-surfaces and blocks portal access** until re-accepted, because acceptance is checked against the latest resolved version. Exception: if a signature policy exists whose `resignTriggers` **omit** `new_version_published`, the older acceptance remains valid and no re-sign is forced. **A new assignment is created / scope changes** - Applications with a signed, fixed-version, or cycle-mapped pin: unaffected — that pin always wins. - Applications that have not opened the step: the new assignment participates in resolution at first open (most-specific wins). - Portal sign-in: picks up the new assignment on the very next sign-in (live resolution). **The agreement is archived** - New assignments: blocked at the API. New Agreement steps: blocked — Studio authoring validation rejects archived codes, and the guided Step Library dialog hides archived agreements. - Already-pinned applications: continue serving the pinned version — archiving never removes text from under an in-flight applicant. - Not-yet-pinned applicants: resolution filters out archived agreements, so the step has nothing to serve (`409 AGREEMENT_NOT_RESOLVABLE`); ops must fix targeting or restore/replace the agreement. - Existing acceptances: permanent and untouched. ## Serving Surfaces **Agreement workflow step (carrier step)** A Step Library step is an Agreement **carrier** when its `schemaJson` carries the marker `agreement.agreementCode`; the stored layout is a placeholder. At serve time the Hub resolves the code through assignments, pins the version, and replaces the placeholder with dynamic UI: content sections derived from the version's `bodyHtml` headings plus a signature block per the signature policy. Submission validates the signature (a typed name is normalized and matched against the applicant's legal first + last name) and that the version ID and content hash displayed on the page still match the version being served (`409 AGREEMENT_VIEW_STALE` otherwise), records the immutable acceptance, and advances step status. > **Governance boundary** > > Creating a carrier step (Step Library "New Agreement Step" guided dialog) only creates the library entry. Placement into a workflow happens exclusively in [Workflow Studio](/docs/workflow-builder) (draft → publish → reconciliation, with active-applicant guards). No migration or seeder may place steps. Authoring validation rejects steps referencing codes that don't exist (or are archived) in the library; a step whose agreement has no published version fails to render with `409 AGREEMENT_NOT_RESOLVABLE`. **Portal sign-in gate** On portal session start, the [Applicant Portal](/docs/applicant-portal-endpoints) calls `GET /api/portal/required-agreements` for outstanding `portal_access` agreements in the applicant's resolved scope, and blocks access behind a signature modal until `POST /api/portal/agreement-acceptances` succeeds for each. That endpoint returns flat `error` and `message` bodies: `400 VALIDATION_ERROR` (missing `agreementId` or `versionId`), `400 TYPED_NAME_REQUIRED` (the resolved signature method is `typed_name` and no `typedName` was sent), `422 AGREEMENT_NOT_APPLICABLE` (the agreement is not in the applicant's resolved set) and `409 AGREEMENT_VERSION_STALE` (the submitted `versionId` is no longer the resolved one; the body carries `currentVersionId` and `currentVersionLabel`). A repeat acceptance of the current version returns `200` with `alreadyAccepted: true`. Re-sign behavior on new versions is policy-driven (see scenarios above). `POST /api/portal/agreement-acceptances` answers with the portal's flat `error` / `message` body rather than the standard envelope: `400 VALIDATION_ERROR` when `agreementId` or `versionId` is missing; `422 AGREEMENT_NOT_APPLICABLE` when the agreement is not among those resolved for the signed-in account; `409 AGREEMENT_VERSION_STALE` (with `currentVersionId` and `currentVersionLabel`) when a newer version is now in effect; `400 TYPED_NAME_REQUIRED` when the outstanding version's signature policy needs a typed name and `typedName` was not sent. A new acceptance returns `201`; a repeat for an already-accepted version returns `200` with `alreadyAccepted: true`. A workflow step that carries an agreement marker (for example `agreement_participant_terms_v1_0`, the Camp Counselor Participant Agreement) does not use `POST /api/portal/agreement-acceptances`. Its acceptance is recorded by the Hub inside the generic step submit (`POST /api/portal/steps/:stepKey/submit`): `processAgreementStepSubmission` re-resolves the version currently servable to the application, validates the typed full legal name against the applicant's canonical legal name server-side, and writes the immutable acceptance row. A name mismatch returns a 422 field-error map and a resolution or publication conflict returns 409. That endpoint is the door for the sign-in gate; for a workflow step the step submit is the only place acceptance is recorded, so the portal does not call it in addition. **Portal sign-in flow** ``` Applicant signs in → resolve portal_access agreements (live, resolution algorithm above) → for each: current acceptance for the resolved version? yes → pass through no → blocking signature modal → POST /api/portal/agreement-acceptances (evidence recorded) → portal usable once no agreement is outstanding ``` **Workflow step flow (first open → acceptance)** ``` Applicant (or staff) opens the Agreement step → pin exists for this application? yes → serve the pinned version (unsigned + latest_effective + default mode: advance to the newly published version first) no → resolve via assignments → write pin to agreement_runtime_resolutions → step renders bodyHtml sections + signature block (per policy) → submission validated (typed name vs legal name where required; displayed version/hash must match the served version) → immutable acceptance row written; step advances ``` ## Governed Re-Signing (Re-Sign Obligations) Pin-once remains the default for *signed* `application_workflow` applications — a published new version never silently re-opens an application the applicant has already signed. Staff can instead impose a **targeted, audited override** via re-sign obligations (permission `agreement_library.resign_manage`): - **Obligation lifecycle:** `pending → completed | cancelled` (DB CHECK). At most one *pending* obligation per (application, agreement, step key) — a partial unique index makes concurrent creates race-safe (unique-violation → conflict, never a duplicate). - **Preview before impose:** a dry-run endpoint classifies every candidate application without writing: `eligible`, `alreadyCurrent` (pinned version already is the target and step unsigned), `duplicatePending`, `ineligible` (terminal application status), `unresolved` (no carrier step / no pin). Bulk create applies the same matrix and reports per-application results plus `failed` for row-level errors. - **Serve-time override:** a pending obligation re-serves the carrier step with the obligation's **target version** and a re-sign notice section; otherwise the pin-once path is unchanged. The prior step state is snapshotted on the obligation so **cancel** restores the step exactly as it was. - **Completion is transactional:** submission records the new acceptance (chained via `supersededByAcceptanceId`) and completes the obligation in a single DB transaction — an obligation can never be marked complete without its acceptance row, and vice versa. When the obligation completes, the application's pin is advanced to the signed version, so the step view after a re-sign shows the signed text. - **Audit trail:** `agreement.resign_previewed`, `agreement.resign_required` (single), `agreement.resign_bulk_required`, `agreement.resign_completed`, `agreement.resign_cancelled`. - **Error contract:** conflicts render `409` with `AGREEMENT_RESIGN_*` detail codes; validation failures render `400 VALIDATION_ERROR`. - **Re-sign and manual-reset conflict codes** (all `409 CONFLICT` with `details.code`): `AGREEMENT_RESIGN_TARGET_INVALID` (preview or create: the agreement is archived, the target version does not belong to it, is not published, or has no HTML body); `AGREEMENT_RESIGN_ALREADY_PENDING` (single-application create when a pending obligation already exists) and `AGREEMENT_RESIGN_NOT_ELIGIBLE` (single-application create for any other non-eligible classification; the details carry the `classification`); `AGREEMENT_RESIGN_ALREADY_COMPLETED` and `AGREEMENT_RESIGN_ALREADY_CANCELLED` (cancelling an obligation that is no longer pending). `POST /api/agreement-acceptances/:acceptanceId/manual-reset` (`agreement_library.acceptance_manage`) returns `AGREEMENT_ACCEPTANCE_NOT_RESETTABLE` (not an applicant acceptance), `AGREEMENT_ACCEPTANCE_NOT_CURRENT` (already superseded or invalidated) and `AGREEMENT_MANUAL_RESET_NOT_ENABLED` (the resolved signature policy does not list `manual_reset` in `resignTriggers`). `PATCH /api/agreement-resolve-failures/:failureId` (`agreement_library.manage`) returns `RESOLVE_FAILURE_ALREADY_CLOSED` when the failure is no longer `active`. ## Cycle Mapping — Projections & Governed Mappings Shipped Aug–Sep 2026 (Globus 30-day window ending Sep 15 2026). An `application_workflow` carrier assignment resolves a version through its own version policy and the pin rules above. Cycle mapping makes the per-operational-cycle picture visible and then governable, in two phases. Each carrier assignment has a `cycleResolution` mode: - `assignment_version` (default, "legacy") — the carrier's own version policy and pin behavior described above, including the unsigned `latest_effective` advance. - `cycle_mapping_required` (strict) — an existing runtime resolution always wins unchanged (publishing never moves it); only an application with no resolution yet is pinned, atomically, to the active governed mapping for its exact operational cycle (keyed by carrier assignment + season UUID — never by label). Pins created this way carry `cycle_mapping_id` provenance and are never advanced by the legacy path, even after a rollback to `assignment_version`. Staff work in the **Workflow cycle mappings** tab of Agreement Assignments (a local tab on that page; the selected carrier is local state, not part of the URL). Both requests are enabled only while the tab is active: the cross-carrier diagnostics (`GET /api/agreement-assignments/cycle-versions/diagnostics`, every page fetched, 100 per page) and, for the selected carrier, its detail (`GET /api/agreement-assignments/:assignmentId/cycle-versions`). It shows the carrier's Legacy or Strict mode and a Ready or Blocked badge, per-cycle activation coverage, full UUIDs, counts and statuses, unassignable-failure blockers, placement context and the complete mapping history (supersedes / superseded-by lineage, pinned / accepted / unpinned impact and mapping-pin provenance). Access is permission-based: view-only staff (`agreement_library.view`) get the read-only experience; `agreement_library.manage` holders additionally get the governed create / supersede / retire / mode-change controls. The server remains authoritative — hiding a control never replaces authorization. ### Read-Only Cycle Projections & Diagnostics Two staff read endpoints (permission `agreement_library.view`) project, per assignment, which agreement versions are **pinned** to in-flight applications in each cycle versus which version an **unpinned** application would resolve to today: | Method | Path | Purpose | | --- | --- | --- | | GET | `/api/agreement-assignments/:assignmentId/cycle-versions` | Per-carrier read model for one `application_workflow` assignment: current `cycleResolution` mode, mapping lineage (with per-mapping `impact` `{ pinned, accepted, unpinned }` and provenance counts), every workflow placement reaching the carrier, exact-season cohorts (`reachableApplications`, `reachablePinned`, `reachableUnpinned`, `orphanedPinned`), grouped open cycle-resolution failures, and `candidateVersions`. | | GET | `/api/agreement-assignments/cycle-versions/diagnostics` | Paginated cross-carrier diagnostics (one item per carrier assignment; `limit` 1–100, default 25; `offset`; optional UUID filters `assignmentId`, `programId`, `seasonId`): cohorts with active-mapping servability, placement statuses, open cycle failures, unassignable failures, `unassignableFailureCount`, and `activationCoverage`. | Contract rules: - **Read-only.** Neither endpoint writes pins, acceptances, or assignments; they are safe to call from dashboards and support tooling. - **Reachable = pinned + unpinned.** For every cycle the projection's `reachableApplications` equals `reachablePinned` plus `reachableUnpinned`. Orphaned pins (a pin whose application no longer reaches the carrier) are reported separately and never folded into reach, and `accepted` is a subset of pinned. A diagnostics response that breaks this invariant is a defect, not a data condition. - **Candidate versions.** `candidateVersions` on the per-carrier read lists every version owned by the carrier agreement (newest version number first) as safe metadata `{ versionId, versionLabel, versionStatus, servable, reason }`. `servable` is computed server-side by the same evaluator used when creating a mapping; `reason` is null exactly when servable (otherwise one of `version_not_found`, `version_agreement_mismatch`, `version_not_published`, `content_format_unsupported`, `no_visible_text`, `same_version`). Diagnostics does not carry this field. - **Activation coverage.** Each diagnostics item carries `activationCoverage` — the same canonical per-cycle coverage rows the mode preview renders (required cycles keyed by season UUID, with placements, placement statuses, grouped failure counts, server-derived blockers/warnings, `blocksActivation`, active mapping id, mapped version id/label, servability, and reachable/pinned/accepted/unpinned/orphaned counts). Honors the `programId` / `seasonId` filters. - **Privacy boundary.** The projection returns counts and version identifiers, never applicant PII, legal content, content hashes, fingerprints, or failure ids; the acceptance-evidence RBAC redaction rules still apply to any drill-down into individual acceptances. ### Governed Cycle Mappings & Assignment Mode The second phase turns the projection into a governed write surface (permission `agreement_library.manage`; the previews are manage-gated too because they issue the confirmation token): | Method | Path | Purpose | | --- | --- | --- | | GET | `/api/agreement-assignments/:assignmentId/cycle-resolution/preview?requestedMode=` | Dry-run of a mode change (`assignment_version` or `cycle_mapping_required`) — returns required exact-cycle coverage (placements, failure counts, blockers, warnings, `blocksActivation`), aggregate impact, the current `revision`, and a short-lived HMAC `confirmationFingerprint`. | | PATCH | `/api/agreement-assignments/:assignmentId/cycle-resolution` | Apply the reviewed mode change. Body: `requestedMode`, `reason`, `expectedMode`, `expectedRevision`, `confirmationFingerprint`, `acknowledgedImpact`. | | POST | `/api/agreement-assignments/:assignmentId/cycle-versions` | Create a governed mapping `{ seasonId, versionId, reason }` for an exact cycle UUID and a servable published HTML version (`201`). | | GET | `/api/agreement-assignments/cycle-versions/:mappingId/supersede-preview?newVersionId=` | Read-only supersede preview: impact, reachable/orphaned pin split, placements, failure counts, deterministic `blockerCodes` / `warningCodes`, target verdict, `confirmationFingerprint`, and expiry. | | POST | `/api/agreement-assignments/cycle-versions/:mappingId/supersede` | Replace a mapping with a new one (prior mapping retained as history). Body: `newVersionId`, `reason`, `confirmationFingerprint`. | | POST | `/api/agreement-assignments/cycle-versions/:mappingId/retire` | Retire a mapping without replacement. Body: `reason`. | - **Preview-then-apply with a fingerprint.** Both the mode change and the supersede require the HMAC fingerprint issued by their preview and are refused with `409` when the underlying state changed in between (`CYCLE_RESOLUTION_CONFIRMATION_STALE`, `CYCLE_MAPPING_CONFIRMATION_STALE`). The mode change additionally carries an optimistic `expectedRevision`/`expectedMode` and the exact `acknowledgedImpact`; the supersede has no separate impact acknowledgement — its fingerprint (bound to status, versions, served state, counts, the reachable/orphaned pin split, blockers, and lineage) is the sole confirmation authority. This is the same review-before-commit posture used by re-sign obligations and by imported-rule publication. - **Strict activation fails closed.** Moving to `cycle_mapping_required` is refused while any required cycle lacks a mapping (`CYCLE_MAPPING_MISSING`), its mapped version is not servable, or open cycle-resolution failures exist (`CYCLE_OPEN_RESOLUTION_FAILURES`, plus unassignable failures). A cycle is required when it is open/active, or whenever any application on the carrier's workflow still reaches it without a pin — regardless of status. Each cycle's coverage row lists blockers, and `blocksActivation` is true when any exists: `CYCLE_MAPPING_MISSING` (no active mapping), `CYCLE_MAPPED_VERSION_NOT_SERVABLE` (a mapping exists but its version is not servable) and `CYCLE_OPEN_RESOLUTION_FAILURES`. Advisory warnings that never block on their own are `CYCLE_ORPHANED_PINS`, `CLOSED_CYCLE_UNPINNED_REACH`, `ARCHIVED_PLACEMENT_WITH_APPLICATIONS` and `DRAFT_PLACEMENT_PRESENT`. At serve time a mapped version that is missing, not `published` or `superseded`, or has no content hash, HTML body or visible text fails the pin with `409 CYCLE_MAPPED_VERSION_NOT_SERVABLE` (details `reason`). Rolling back to `assignment_version` uses the same preview and confirmation. - **Retirement safeguards.** A mapping that has served an application or is referenced by any runtime resolution cannot be retired (supersede it instead), and under strict mode a cycle with reachable unpinned applications keeps its mapping (`409`, e.g. `CYCLE_MAPPING_REFERENCED`, `CYCLE_MAPPING_REQUIRED_COVERAGE`). - **Mapping write conflicts.** Creating a mapping for a cycle that already has an active one returns `409 CYCLE_MAPPING_EXISTS` (supersede it instead). Supersede and retire act only on an `active` mapping (`409 CYCLE_MAPPING_NOT_ACTIVE`, details `mappingId`, `status`). Superseding with the version the mapping already points at returns `409 CYCLE_MAPPING_SAME_VERSION`. Retiring a mapping that has already served an application returns `409 CYCLE_MAPPING_SERVED` (details `mappingId`, `firstServedAt`); supersede it instead. - **Version validation on create and supersede.** Creating a mapping and superseding one both refuse a target version that does not belong to the carrier's agreement with `400 VALIDATION_ERROR`, `details.code` `VERSION_AGREEMENT_MISMATCH` (`details.field` is `versionId`). A version that is not currently published is `409 VERSION_NOT_PUBLISHED`, a non-HTML version is `409 AGREEMENT_CONTENT_FORMAT_UNSUPPORTED`, and a version with no visible text is `409 CYCLE_MAPPED_VERSION_NOT_SERVABLE`. - **Generic assignment PATCH no longer touches cycle resolution.** Sending `cycleResolution` fields to `PATCH /api/agreement-assignments/:assignmentId` is refused with `CYCLE_RESOLUTION_GOVERNED_ROUTE`; the governed route above is the only writer. - **Refusals specific to the mode change.** `GET .../cycle-resolution/preview` and `PATCH .../cycle-resolution` on an assignment whose context is not `application_workflow` return `400 VALIDATION_ERROR` with `details.code` `ASSIGNMENT_NOT_WORKFLOW_CARRIER` (creating a cycle mapping is refused the same way). `PATCH` returns `409 CONFLICT` with `details.code` `CYCLE_RESOLUTION_STATE_CHANGED` (`expectedMode` or `expectedRevision` no longer match; the details carry `currentMode` and `revision`), `CYCLE_RESOLUTION_IMPACT_STALE` (`acknowledgedImpact` no longer equals current reach; the details carry the current `impact`), `CYCLE_RESOLUTION_MODE_UNCHANGED` (the requested mode is already active) or `CYCLE_RESOLUTION_CONCURRENT_UPDATE` (the revision-guarded update matched no row); preview again and resubmit after any of these. Moving to `cycle_mapping_required` is also refused with `409` `CYCLE_RESOLUTION_COVERAGE_MISSING` (a required cycle has no servable active mapping; the details list the cycles) or `CYCLE_RESOLUTION_OPEN_FAILURES` (the details carry `failureIds`). While an Agreement step is being served, `409 CONFLICT` `CYCLE_RESOLUTION_MODE_CHANGED` means the carrier's mode changed mid-request; the server re-resolves the carrier and retries once before returning it, after which the caller can simply retry. - **Pinned applications are never rewritten.** A mapping changes what *unpinned* applications resolve to in a cycle. Existing pins stay verbatim; moving an in-flight application onto a new version remains the job of a re-sign obligation. - **Audit.** Mapping create / supersede / retire and cycle-resolution applies are captured through the audit layer in the same transaction as the write (no mapping change commits without its audit row). ## Acceptance Evidence & Compliance Each acceptance stores `versionId`, `contentHash` (proves exactly which text was shown), `acceptedAt`, server-observed `ipAddress` + `userAgent`, `sessionId`, and `typedName` when the policy requires a typed signature. Typed-name validation is server-side against the applicant's legal name (whitespace/diacritic-normalized). Acceptances are append-only; re-signs chain via `supersededByAcceptanceId`, preserving full history. **Acceptance state machine** ``` (outstanding — no row yet) │ applicant signs ▼ current ──────────────▶ superseded re-sign occurred; new row chains back via │ supersededByAcceptanceId (+ supersededAt stamp) │ re-sign trigger fires ▼ invalidated one-time, first-write-wins stamp (invalidatedAt / invalidationReasonCode / invalidatedByUserId); agreement re-surfaces for signing ``` `superseded` and `invalidated` are the **only** writes the engine ever applies to an existing acceptance row; every evidence field is preserved verbatim in both cases. `invalidationReasonCode` carries a re-sign-trigger reason code (e.g. `identity_changed`) — this is how policy-driven re-signature marks an old acceptance as no longer current without destroying its evidentiary value. The one other change is database-level: for host acceptances, deleting the accepting host user does not delete the evidence — `partner_user_id` is `ON DELETE SET NULL`, so the row keeps its organization, agreement/version, content hash, and timestamp while only the optional actor link is detached. ### Signed-Agreement PDF Artifacts Shipped Sep 2026. Every **applicant** acceptance — portal sign-in gate or Agreement carrier step — also produces an immutable PDF document of the exact published text that was accepted, filed on the applicant's Documents tab and in the Applicant Portal: - **Document type** `signed-agreement`. The PDF is stamped with the agreement and version, the content fingerprint (`contentHash`), acceptance time and context, the signature method (typed name or checkbox), and the applicant's name, reference and email. IP address and user agent are deliberately **excluded** from the PDF; they remain on the evidence row only. - **Immutable across every mutation path.** The type cannot be deleted, updated, replaced by upsert, or manually uploaded: ordinary document deletion returns `SIGNED_AGREEMENT_NOT_DELETABLE` and an upload of the type code is refused up front with `SIGNED_AGREEMENT_NOT_UPLOADABLE`. A re-sign adds a second signed-agreement document; both remain visible. - **Generation is asynchronous and retried, never dropped.** A background worker renders the PDF after the acceptance commits and retries with increasing delays on transient failure (e.g. storage unavailable); after five attempts, or on a terminal data/configuration defect, the request is dead-lettered. Every request, retry, duplicate suppression and terminal failure is audited under `signed_agreement_artifact.*`, so a missing PDF is always explainable from the audit log. - **Approved single-request recovery of dead-lettered artifacts.** `GET /api/agreement-acceptances/:acceptanceId/artifact` (`agreement_library.acceptance_view`) returns the artifact request's state, attempts, parsed last error, an eligibility verdict, the audit timeline, and whether the caller may retry (`canRetry`). `POST /api/admin/agreement-artifacts/:requestId/retry` with a `reason` (10–1000 characters) requeues exactly one `dead_letter` request; it requires a human staff session (API keys are refused) and the dedicated restricted permission `agreement_library.artifact_retry`. Under a row lock the server re-validates the worker's own source-integrity preconditions (acceptance exists, the exact accepted version is renderable, the content-hash binding is intact, an application exists to attach to, the document type exists); only status and scheduling fields change — `attempts` is never reset, so a retry that fails again returns to dead letter, and nothing on acceptances, versions, assignments, or existing documents is touched. Success is `202` (`QUEUED`); anything else is a stable non-2xx envelope (`ARTIFACT_NOT_DEAD_LETTERED`, `ARTIFACT_ALREADY_COMPLETED`, `ARTIFACT_PROCESSING`, `ARTIFACT_RETRY_CONFLICT` for a concurrent change, `ARTIFACT_RETRY_INELIGIBLE`). If a canonical document already exists the worker's duplicate suppression reconciles the request without rendering a second PDF. Audited as `signed_agreement_artifact.manual_retry_requested` followed by `manual_retry_queued` or `manual_retry_rejected`. - **Eligibility and last-error codes.** The eligibility `code` on the artifact read is one of `ELIGIBLE`, `ELIGIBLE_EXISTING_DOCUMENT` (dead-lettered but a canonical document already exists; a requeue reconciles it without rendering a second PDF), `NOT_DEAD_LETTERED`, `ALREADY_COMPLETED`, `PROCESSING`, `ACCEPTANCE_NOT_FOUND`, `VERSION_MISMATCH`, `VERSION_NOT_RENDERABLE`, `CONTENT_HASH_MISMATCH`, `DOCUMENT_TYPE_MISSING` or `NO_APPLICATION` (the applicant has no application to attach the document to). The worker stores each failure as `: ` in the request's last error; a failure that is not a terminal data or configuration defect is recorded as `PROCESSING_ERROR`, retried with backoff, and dead-lettered once the attempt limit is reached. - **Host acceptances are excluded.** Host-organization operating-agreement acceptances by partner users appear in Acceptance Audit but do not generate a signed-agreement document; their evidence contract is the dual evidence-row + `operating_agreement.accept` audit entry described below. - **Portal grouping and signing date.** On the portal document lists (`GET /api/portal/applications/:appId/documents` and `.../documents-by-step`) every signed-agreement row carries `official: true` and a `createdAt` equal to the acceptance's `accepted_at` (the moment the applicant signed, resolved through the document's permanent link to its acceptance; `null` if unlinked). All other document types report `official: false` and their upload time. Two signed versions therefore show as two official rows, each with its own signing date. Signed-agreement rows also carry `displayName: "Signed Agreement"` and `presentationGroup: "official-program-documents"` (a DS-2019 shares that presentation group but stays `official: false`). - **Participant portal (PPPToo) precedence.** When the participant portal's document list contains one or more canonical signed-agreement rows, those rows are the legal record: the portal does not append its synthetic on-demand terms certificate (`terms-acceptance`), and each signed version stays a separate official row. The synthetic certificate remains only as a fallback for applications with no canonical signed-agreement document. ### Evidence Ledger vs Audit Trail An acceptance produces **two distinct, linked records** — do not conflate them: | | Evidence row (`agreement_acceptances`) | Audit trail entry (platform audit log) | | --- | --- | --- | | Purpose | Forensic proof of *what* was accepted: `versionId`, `contentHash`, `acceptedAt`, IP, user agent, `typedName` | Operational record of *who did what when*: actor, action, timestamp, entity, payload — queryable from the Audit Console | | Mutability | Immutable; only `superseded` / `invalidated` stamps are ever applied by the engine (and the host-actor link is detached if that host user is deleted) | Append-only audit log entry | | Written by | The acceptance transaction | The platform audit-capture layer (`recordAudit()`), in the same request | The canonical example is the **host portal operating-agreement acceptance**: the acceptance write records the evidence row *and* emits an `operating_agreement.accept` audit entry carrying the acceptance timestamp, the accepting host user's identity (resolved from the required `X-Host-User-Email` attribution header), and — for library-issued agreements — the linked library agreement code and accepted version. Acceptance writes without valid user attribution are rejected before either record is created. See [Host Portal Governance → Acceptance Audit Contract](/docs/host-governance) for the full contract. Re-signs follow the same dual-record rule: after a new library version is published, a re-acceptance writes a **new** evidence row chained to the superseded acceptance via `supersededByAcceptanceId` *and* emits its own audit entry with the re-signing user's attribution and timestamp — the superseded acceptance's evidence and audit records are never rewritten. ## RBAC & Audit | Permission | Grants | | --- | --- | | `agreement_library.view` | Read catalog + assignments | | `agreement_library.manage` | Create/edit agreements, drafts, assignments | | `agreement_library.publish` | Approve and publish versions | | `agreement_library.withdraw` | Withdraw published versions | | `agreement_library.resign_manage` | Preview, impose, track, and cancel re-sign obligations | | `agreement_library.acceptance_view` | View acceptance audit trail | | `agreement_library.acceptance_manage` | View unredacted evidence (IP / session) | | `agreement_library.artifact_retry` | Restricted: requeue one dead-lettered signed-agreement artifact request (human staff session only; no API keys) | Staff visibility of raw evidence (IP/session) is gated behind `agreement_library.acceptance_manage`; the standard acceptance view is redacted. See [Roles & Governance](/docs/rbac) for the platform permission model. ## API Surface **Agreement Library (staff)** | Method | Path | Purpose | | --- | --- | --- | | GET | `/api/agreement-library` | List agreements | | POST | `/api/agreement-library` | Create agreement | | GET | `/api/agreement-library/:agreementId` | Agreement detail + versions | | PATCH | `/api/agreement-library/:agreementId` | Update metadata / archive | | POST | `/api/agreement-library/:agreementId/versions` | Start new draft version | | PATCH | `/api/agreement-library/versions/:versionId/content` | Save draft HTML | | POST | `/api/agreement-library/versions/:versionId/submit-for-review` | draft → under_review | | POST | `/api/agreement-library/versions/:versionId/return-to-draft` | under_review → draft (send back for edits) | | POST | `/api/agreement-library/versions/:versionId/approve` | under_review → approved | | POST | `/api/agreement-library/versions/:versionId/publish` | approved → published (supersedes prior) | | POST | `/api/agreement-library/versions/:versionId/withdraw` | Withdraw version | | GET | `/api/agreement-library/:agreementId/archive-impact` | Read-only archive impact preview (`agreement_library.view`): unpinned in-flight applications, affected active assignments split fixed vs latest-effective, and whether portal gating currently resolves the agreement. Archiving through `PATCH` with non-zero impact is `409` `IMPACT_CONFIRMATION_REQUIRED` (impact in the details) unless `confirmImpact` is sent | | GET | `/api/agreement-library/versions/:versionId/withdraw-impact` | Read-only withdraw impact preview (`agreement_library.view`): same payload plus `blockingFixedAssignments`. Withdraw is `409` `WITHDRAW_BLOCKED_BY_FIXED_ASSIGNMENTS` while an active assignment pins the version as its fixed version, otherwise `409` `IMPACT_CONFIRMATION_REQUIRED` for non-zero impact unless `confirmImpact` is sent | **Assignments, Policies, Acceptances (staff)** | Method | Path | Purpose | | --- | --- | --- | | GET | `/api/agreement-assignments` | List targeting rules | | POST | `/api/agreement-assignments` | Create assignment | | PATCH | `/api/agreement-assignments/:assignmentId` | Update version policy / activate / deactivate (scope dims can be retargeted) | | DELETE | `/api/agreement-assignments/:assignmentId` | Remove assignment (409 if runtime pins reference it) | | PUT | `/api/agreement-signature-policies` | Upsert signature / re-sign policy | | GET | `/api/agreement-assignments/resolution-preview` | Dry-run scope resolution | | GET | `/api/agreement-acceptances` | Query acceptance audit trail | | GET | `/api/agreement-acceptances/:acceptanceId` | Single acceptance detail (evidence redacted per RBAC) | | GET | `/api/agreement-acceptances/:acceptanceId/artifact` | Signed-agreement artifact request state, eligibility, audit timeline, `canRetry` (`agreement_library.acceptance_view`) | | POST | `/api/admin/agreement-artifacts/:requestId/retry` | Requeue one dead-lettered artifact request; `reason` required; `202 QUEUED` (`agreement_library.artifact_retry`, human session only) | | GET | `/api/agreement-assignments/:assignmentId/cycle-versions` | Read-only per-carrier cycle read model incl. `candidateVersions` (`agreement_library.view`) | | GET | `/api/agreement-assignments/cycle-versions/diagnostics` | Paginated cross-carrier cycle diagnostics incl. `activationCoverage` (`agreement_library.view`) | | GET | `/api/agreement-assignments/:assignmentId/cycle-resolution/preview` | Fingerprinted dry-run of a cycle-resolution mode change (`agreement_library.manage`) | | PATCH | `/api/agreement-assignments/:assignmentId/cycle-resolution` | Apply a previewed mode change; 409 on stale fingerprint | | POST | `/api/agreement-assignments/:assignmentId/cycle-versions` | Create a governed cycle mapping | | GET | `/api/agreement-assignments/cycle-versions/:mappingId/supersede-preview` | Fingerprinted supersede preview (`newVersionId` query) | | POST | `/api/agreement-assignments/cycle-versions/:mappingId/supersede` (+ `/retire`) | Supersede (fingerprint required) or retire a governed mapping | Engine selection: when `agreements.engine` is `service`, the assignment, signature-policy, resolution-preview, cycle-mapping, acceptance and resolve-failure staff routes forward to the Agreements Service using an SDK admin client signed for the acting staff user, with the same response shapes. On `legacy` and `shadow` the in-process services answer as before. Each handler's own input parsing runs first, whichever engine answers. On the service engine the resolution preview is the service's own admin preview for the acting user, and the acceptance list takes its evidence redaction from the signed assertion's permissions (the same `acceptance_manage` rule). **Re-sign obligations (staff — all require `agreement_library.resign_manage`)** | Method | Path | Purpose | | --- | --- | --- | | POST | `/api/agreement-resign-obligations/preview` | Dry-run eligibility matrix (no writes) | | POST | `/api/agreement-resign-obligations` | Impose obligation(s) — single or bulk | | GET | `/api/agreement-resign-obligations` | List obligations (filterable) | | GET | `/api/agreement-resign-obligations/:obligationId` | Obligation detail | | POST | `/api/agreement-resign-obligations/:obligationId/cancel` | Cancel pending obligation + restore prior step state | **Applicant portal (the only two endpoints the portal consumes)** | Method | Path | Purpose | | --- | --- | --- | | GET | `/api/portal/required-agreements` | Outstanding `portal_access` agreements | | POST | `/api/portal/agreement-acceptances` | Record acceptance | Back-office UI surfaces on the Hub: Agreement Library (catalog + version lifecycle), Agreement Assignments (scope targeting, with an amber warning + deep-link CTA when an `application_workflow` assignment exists but no carrier step serves its code, and the **Workflow cycle mappings** tab for governed cycle mappings), Acceptance Audit (forensic evidence view; during an Agreements Service outage it shows an outage alert with a retry action instead of acceptance rows), the Step Library "New Agreement Step" guided dialog (agreement picker, generated `agreement_` step key, collision guard), and the re-sign governance panel (preview → impose → track → cancel) surfaced from the Agreement Library. ## Failure & Edge-Condition Matrix | Condition | Surface | Result | | --- | --- | --- | | No matching assignment for the context/scope | Workflow step | `409 AGREEMENT_NOT_RESOLVABLE` — step cannot render | | No matching assignment | Portal | Agreement simply isn't required (no gate) | | Agreement archived, no pin yet | Both | Filtered out of resolution → workflow step `AGREEMENT_NOT_RESOLVABLE`; portal drops the requirement | | Agreement archived, pin exists | Workflow step | Pinned version keeps serving — archive never pulls text from an in-flight applicant | | No published version (withdrawn / never published) | Both | Not resolvable — `409 AGREEMENT_NOT_RESOLVABLE` on the step | | Create an assignment for an archived agreement | Assignment create | `409 AGREEMENT_ARCHIVED` | | Create an assignment for an agreement with no published version | Assignment create | `409 AGREEMENT_NOT_PUBLISHED` — publish a version first | | Pin an agreement that resolves to no published version | Pin write | `409 NO_PUBLISHED_VERSION` (details `agreementId`) | | Two assignments, equal specificity, overlapping scope | Activation time | Rejected — cannot be created: `409 ASSIGNMENT_SCOPE_CONFLICT` (details `conflictingAssignmentId`, `specificity`) | | An assignment with the exact same scope already exists for the agreement, even if deactivated | Assignment write | `409 DUPLICATE_ASSIGNMENT_SCOPE` (details `conflictingAssignmentId`, `conflictingAssignmentStatus`, `conflictingAssignmentContext`) — edit that row instead | | Any scope (brand, program, cycle, workflow type or partner org) set on an `application_workflow` assignment | Assignment write | `400 WORKFLOW_ASSIGNMENT_SCOPE_NOT_ALLOWED` — placement in Workflow Studio decides reach | | `partnerOrgId` scope on a context other than `host_portal_access` | Assignment write | `400 PARTNER_ORG_SCOPE_NOT_ALLOWED` | | `workflowType` scope on a `host_portal_access` assignment | Assignment write | `400 WORKFLOW_TYPE_SCOPE_NOT_ALLOWED` | | Equal-specificity tie detected at resolve time (data drift) | Resolve time | Fails loudly ("deactivate or narrow one") — never guesses a winner | | Typed-name signature mismatch vs legal name | Submission | Validation error; acceptance not recorded | | Pinned version renders no text when the carrier step is served | Workflow step | `409 CONFLICT`, `details.code` `AGREEMENT_CONTENT_EMPTY` — a blank step is refused rather than served | | Application has no linked applicant when the acceptance is recorded | Workflow step submission | `409 CONFLICT`, `details.code` `APPLICANT_REQUIRED`; acceptance not recorded | | Page opened before an unsigned pin advanced to a newer version (or version changed during signing) | Submission | `409 AGREEMENT_VIEW_STALE` — acceptance not recorded; applicant refreshes and signs the current version | | Assignment deleted while pins reference it | Delete API | `409` — must deactivate instead | | Concurrent write did not converge (acceptance, assignment, signature policy, runtime pin, draft, version transition or publish) | Agreement writes | `409 CONFLICT` with `details.code` `ACCEPTANCE_WRITE_CONFLICT`, `ASSIGNMENT_WRITE_CONFLICT`, `SIGNATURE_POLICY_WRITE_CONFLICT`, `PIN_WRITE_CONFLICT`, `DRAFT_WRITE_CONFLICT` or `VERSION_WRITE_CONFLICT`; the message asks the caller to reload and retry | ## Ecosystem & Integration Notes - **Addressing:** external integrations and seeds reference agreements by business `code`, never UUID — codes are stable across environments. - **Content promotion:** UI-published agreement HTML is environment data. The seeder only creates fresh-DB *drafts*; promoting published legal text to QA/production requires an explicit full-snapshot content migration with a never-stomp-published guard. Published content never silently travels with code deploys. - **Portal boundary:** the Applicant Portal consumes only the two `/api/portal/*` endpoints above; all resolution, policy, and evidence logic stays in the Hub. The portal never decides versions. See the [Integration Contract](/docs/integration-contract). - **Error contract:** `409 AGREEMENT_NOT_RESOLVABLE` (no published version / archived at resolution time); assignment-time validation rejects archived agreements and equal-specificity scope conflicts. - **Eventing:** version lifecycle transitions are captured in `agreement_version_events` and the platform audit log; staff mutations flow through the standard audit-capture middleware. - **Brand legal policies are separate from the Agreement Library.** The brand-skinned portal's `disclaimer` and `privacy_policy` pages live in `brand_legal_policies` (unique per brand and `policyType`; `body` mirrors the live published text and `currentVersionId` points at the live version), with version history in `brand_legal_policy_versions` (`versionNumber`, `status` ∈ `draft` / `published` / `archived`). Staff routes under `/api/brands/:idOrSlug/legal-policies`: list the policies, and `GET /:policyType/versions` (`programs.manage` or `brand.legal.view`); `PUT /:policyType` saves a draft, `POST /:policyType/publish` promotes it and `POST /:policyType/restore` clones a historical version into a new draft (all `brand.legal.manage`). Only publish changes the live text. ## Host Agreement Library Linkage Staff-created **host agreements** (the `partner_agreements` rows documented on [Partner Organizations → Partner Agreements](/docs/partner-organizations)) reference a published Agreement Library entry at creation time. The legal content of a new host agreement is a selected `agreements` / `agreement_versions` pair — not free-form legal text typed into `partner_agreements.terms`. **Creation-time stamping (current contract)** - `engine_agreement_code` is set on the host agreement row **at creation time**, pointing at the selected library agreement's business `code`. There is no window in which a new host agreement exists without its library linkage. - Server-side validation on create enforces: the selected agreement must have a **published** version (draft / under_review / approved / withdrawn-only agreements are rejected), the agreement must be active and of type `host_agreement`, and its brand scope must cover the host org's brand. Library entries also carry a host designation (`trial` or `host_company`); the linked entry's designation must match the agreement type (trial agreements link a trial-designated entry, operating agreements a host-company-designated one — rejected with `ENGINE_AGREEMENT_WRONG_DESIGNATION` / `ENGINE_AGREEMENT_TYPE_CODE_MISMATCH`). Violations reject the create — there is no fallback to free-text terms. - **Create and update error codes.** Both are 400 `VALIDATION_ERROR` responses carrying `details.code`. `ENGINE_AGREEMENT_REQUIRED`: on `POST /api/admin/partners/organizations/:orgId/agreements` (permission partners.agreements.manage) a host-typed organization submitted a non-trial agreement without `engineAgreementCode` ("Operating host agreements must select a published Host Company Agreement"; trial agreements have the trial library code filled in server-side), and `PATCH /api/admin/partners/agreements/:id` returns it for an existing operating host agreement that has no linked library code. `LEGACY_TERMS_NOT_ALLOWED`: the request `terms` contains any retired free-text key (`termsText`, `specialConditions`, `complianceRequirements` or `governanceNotes`), on create, PATCH and `POST /api/admin/partners/agreements/:id/upgrade`; `details.legacyKeys` lists the keys. - **Designation and scope errors.** `GET /api/admin/partners/host-agreement-library` (picker feed; API key scope `partners.read`) returns `400 VALIDATION_ERROR` with `details.code` `INVALID_HOST_AGREEMENT_DESIGNATION` when the optional `designation` query is anything other than `trial` or `host_company`. `PATCH /api/admin/partners/agreements/:id` returns `422 AGREEMENT_SCOPE_REQUIRED` (a top-level `error.code`, with the effective scope in `details`) when the patch would leave the agreement with no brand, program or season. - **Link and selection errors.** Rejections are `400 VALIDATION_ERROR` with a `details.code`: `ENGINE_AGREEMENT_NOT_FOUND` (unknown library code), `ENGINE_AGREEMENT_WRONG_TYPE` (not a `host_agreement` entry), `ENGINE_AGREEMENT_ARCHIVED`, `ENGINE_AGREEMENT_BRAND_MISMATCH` (the entry is scoped to a different brand than the agreement), `ENGINE_AGREEMENT_NOT_PUBLISHED` (no published version) and `ENGINE_AGREEMENT_REQUIRED` (no library code supplied for an employer organization agreement, including cloning an agreement that has no library linkage). When the entry is chosen by designation, a brand-scoped entry beats a global one and exactly one must exist in the winning tier: no candidate in either tier gives `HOST_AGREEMENT_LIBRARY_ENTRY_MISSING`, more than one gives `HOST_AGREEMENT_LIBRARY_AMBIGUOUS` (`details.candidateCodes`). - **Readiness check.** `GET /api/admin/partners/organizations/:orgId/agreement-library-readiness` (permission `partners.view`, then the per-record organization scope clamp; `403 FORBIDDEN` outside scope) reports the host's resolved brand (`brandId`, `brandName`, `brandSource`), the entry each designation would serve (`resolved`) and named `gaps`. Gaps are returned as data, never as an error status: `HOST_BRAND_UNRESOLVED` (no programme brand and no organization brand, or programmes spanning several brands, so the global entry applies), `HOST_AGREEMENT_LIBRARY_ENTRY_MISSING` (no published entry for that designation), `HOST_AGREEMENT_LIBRARY_AMBIGUOUS` and `HOST_ORGANIZATION_NOT_FOUND`. - **Type vocabulary.** For a host (employer) organization, the staff create (`POST /api/admin/partners/organizations/:orgId/agreements`) and update (`PATCH /api/admin/partners/agreements/:id`) routes, both gated by `partners.agreements.manage`, accept only `trial` or `operating` as the agreement type; anything else is `400 VALIDATION_ERROR` with `details.code` `INVALID_HOST_AGREEMENT_LIFECYCLE` and `details.allowedTypes`. - Gating, acceptance evidence, and signed-PDF rendering for these agreements all resolve to the linked library version's `bodyHtml` (see [Host Portal Governance](/docs/host-governance) for the portal-side behavior). **Legacy paths (retired; pre-existing rows only)** - **Acceptance-time stamping** — older host agreements had `engine_agreement_code` stamped only when the host first accepted through the portal. This path no longer applies to newly created agreements; it is retained solely to describe how pre-existing rows acquired their codes. - **`host_agreement_migrated_*` backfill** — pre-existing free-text host agreements were once backfilled into the library under generated `host_agreement_migrated_*` codes. These codes are now vestigial: the migrated catalog entries were deleted and their rows repointed or cleared, nothing mints new ones, and the prefix is only checked as defense in depth. Every active host-organization agreement that lacked a valid published library linkage matching its type was terminated by the Task #2222 data migrations (audit-logged; a fresh library-linked agreement must be issued), and boot-time reports list any active row still awaiting repoint. - **Free-text terms are retired.** Host agreements are Agreement Library-only: the legacy `terms` keys (`termsText`, `specialConditions`, `complianceRequirements`, `governanceNotes`) were hard-deleted from every row, the API no longer accepts them, and the signed-PDF renderer serves only library version content (an explicit "no library content" note when nothing resolves). **Brand scope drives host email branding (required contract)** The linked library agreement's brand scope does double duty: beyond gating create-time validation, it is the **single authoritative input for host-facing email branding**. Every host-facing email Globus enqueues for a host company must resolve its brand from the host's applicable agreement's library brand scope — a host with an applicable Wild Packs-scoped `host_agreement` receives all host emails under Wild Packs branding. Grandfathered free-text rows and `host_agreement_migrated_*` rows without a brand scope resolve to the existing recorded brand or the default template — never to Wild Packs by guesswork. The normative resolution rule (precedence, inheritance, renewal timing, conflict fail-closed behavior, and audit fields) lives at [Notification Model → Agreement-Derived Brand Resolution (Wild Packs)](/docs/notification-model#host-brand-resolution). ### Host Agreement Signature Lifecycle & Portal Signing Host agreements carry an enforced signature lifecycle on top of the library linkage (shipped Aug 10–11 2026, Host Testing Fixes Batches 8 & 9): - **One-click activate-and-send** — creating a new host agreement activates it and sends it for signature in a single action; there is no intermediate `draft` status, no separate Activate step, and no separate Send step. The agreement lands `active` + `sent` the moment staff confirm creation. An agreement that was never sent cannot be accepted. - **Derived signature state** — staff endpoints and the host agreements tab expose a derived signature state: `not sent` / `awaiting signature` / `signed`. The host portal prompts only for **active, sent** agreements. - **Per-send acceptance matching** — when staff send a new agreement, the host signing modal reliably reappears: acceptance matching is per-send, so signatures recorded before the latest send no longer satisfy it, and signed status is forced false while the gate is blocking. - **Send stamps and the no-signer warning** — every send (the create-time send and `POST /api/admin/partners/agreements/:id/send-signature`) stamps `last_sent_at` and, if not already set, the `acceptance_required_at` marker the host-portal acceptance gate keys off, and returns `noActivatedSigner: true` when the host organization has no active portal user who has accepted their invite. The send still goes out; the staff UI shows a non-blocking "No one can sign yet" warning asking staff to invite or re-invite a host user. A portable migration backfilled `acceptance_required_at = last_sent_at` on previously sent, unaccepted draft/active agreements so that agreements sent by older builds became visible to the gate. - **Multiple active agreements** — a host may hold any number of active agreements, including several operating agreements for the same program (ADR 0005; the single-active partial unique indexes were dropped and a non-unique active lookup index remains). Create, edit, activate, and renew no longer reject an existing active agreement. Consumers that need one governing agreement (DS-2019 issuance, host access) pick the matching active agreement with the latest activation stamp, breaking ties by start time, creation time, then id, all descending. Activating an operating agreement still supersedes overlapping active trial agreements — an explicit upgrade-flow side effect, not a general invariant. - **Full legal content in the portal summary** — the host-portal agreements summary returns the full Agreement Library legal content per linked agreement (name, version label, effective date, format, and the published HTML body), with graceful nulls for unlinked rows. - **Unavailable legal-content states.** The operating-upgrade `legalContent` object (returned by `POST /api/partner/host/operating-agreement/upgrade` and, as `pendingOperatingLegalContent`, by the status read) is either `available` with the published copy or `{ status: unavailable, code, message }`. The `code` is `OPERATING_LEGAL_CONTENT_UNLINKED` (the pending operating draft carries no library agreement code), the library resolution's own `details.code` when resolution failed (for example `ENGINE_AGREEMENT_NOT_PUBLISHED` or `HOST_AGREEMENT_LIBRARY_ENTRY_MISSING`), `OPERATING_LEGAL_CONTENT_EMPTY` (the published Host Company version has no servable body HTML) or `OPERATING_LEGAL_CONTENT_UNRESOLVED` (resolution failed without a code). Portals must not fall back to the trial agreement's text. - **No modal without legal text** — a real-database end-to-end suite guarantees the host-portal sign modal never appears without complete published legal text across the create → send → sign lifecycle, including the corrupted-content case. - **Send and activation refusals.** `POST /api/admin/partners/agreements/:id/send-signature` (and the create-time send) refuses with `409` when the agreement is already signed, `409 AGREEMENT_NOT_ACTIVE` when its status is not `active`, `400 AGREEMENT_LIBRARY_LINK_REQUIRED` when it has no library link (or only a `host_agreement_migrated_*` code), then runs the linked-entry checks from the linkage section, `429 SIGNATURE_SEND_RATE_LIMITED` when the last send was under 60 seconds ago (`details.lastSentAt`), and `400 HOST_EMAIL_MISSING` when the host organization has no valid contact email. Trial activation for signing returns `400 NOT_A_TRIAL_AGREEMENT` for other agreement types and `400 TRIAL_AGREEMENT_SIGNING_PREREQ_FAILED` with `details.reason` `AGREEMENT_LIBRARY_LINK_REQUIRED` or `HOST_EMAIL_MISSING`. The Host Portal operating-agreement upgrade activation returns `400 NOT_OPERATING_AGREEMENT`, `409 AGREEMENT_ALREADY_ACCEPTED`, or `400 OPERATING_UPGRADE_SIGNING_PREREQ_FAILED` with `details.reason` `AGREEMENT_LIBRARY_LINK_REQUIRED`, `OPERATING_LEGAL_CONTENT_EMPTY` or `HOST_EMAIL_MISSING`; these prerequisites are checked before the agreement is activated. See [Host Portal Governance → Operating Agreement — Portal Acceptance](/docs/host-governance) for the portal-side acceptance and audit contract. ## Deferred Payment Plan Agreement (Draft) > **Draft — trigger scope to be determined** > > The agreement itself is being authored now; how it enters an applicant's workflow is deliberately undecided. Do not wire any Workflow Studio step or rule for this agreement until that scope is settled. Alliance Abroad offers participants a **Deferred Payment Plan**: instead of paying the full program fee up front, the participant pays a down-payment before the program and equal monthly installments during it. Enrollment is governed by a legally binding **Deferred Payment Plan Agreement**, which will live in the Agreements Library as a first-class versioned agreement. **Source-document shape** (reference values from the current legal draft; commercial terms are parameterized per participant/season, not hardcoded): | Term | Reference value | | --- | --- | | Total Program Fee | $3,000 USD | | Down-payment | $750 USD | | Deferred Program Fee | $2,250 USD | | Installments | $225 USD × 10 months, due the 15th | | Payment start | Second month on program | | Payment methods | Recurring ACH/wire from a U.S. bank account (established within 7 days of arrival) or recurring card payment | The agreement carries seven sections — Payment Plan Purpose, General Obligations, Representations & Warranties, My Payment Plan, Parties (Alliance Abroad Group, Inc. + Alliance Strategies Ltd. as OpCo), OFAC Warranty, Binding Arbitration (AAA rules, Texas law), and a Final Acknowledgment requiring **per-section initials plus a signature** — completion requires every initial and the signature. **Delivery status.** Work is tracked on the Asana board `P31: Deferred Payment Plan` (Globus Phase 2 portfolio). The first ticket covers authoring the agreement in the Agreements Library only. Explicitly deferred, to be scoped later: - The Workflow Studio trigger — which workflow type, step placement, and the eligibility criteria for who is offered the plan. - Payment-collection integration (recurring ACH/card capture and missed-payment handling — the source document ties missed payments to program termination). ## Open Items - All four `resignTriggers` are enforced at runtime: `new_version_published` (portal re-gates on next sign-in unless the policy omits it), `identity_changed` (an applicant legal-name/identity change invalidates matching acceptances, stamping `invalidationReasonCode`), `program_changed` (fires when an application's programId actually changes and invalidates that application's current acceptances) and `manual_reset` (a staff action, `POST /api/agreement-acceptances/:acceptanceId/manual-reset`, that invalidates one current acceptance). - In-application re-signing is implemented as staff-governed, per-application re-sign obligations — a targeted, audited override. Pin-once remains the default for signed `application_workflow` applications; there is no automatic policy-driven re-open of signed applications on version publish, and any such automation would be a deliberate follow-up. --- --- title: "Agreement Service (Proposal)" slug: agreement-service section: Rules & Compliance status: draft description: "The agreements subsystem is being extracted into its own service with a TypeScript SDK." last-updated: 2026-10-07 --- # Agreement Service (Proposal) The agreements subsystem is being extracted into its own service with a TypeScript SDK. This page is a draft: it describes the decided target design and the assessment behind it, not how the platform behaves today. Current behaviour is documented in [Agreements Engine](/docs/agreements-engine); this page flips to stable when the service is live. ## Overview Agreements are implemented inside the Globus Hub (`BackOfficePrototype`) as **two engines** joined by one column: - **Agreement Library engine** — legal text, versions, targeting (assignments, scope specificity), pinned resolutions, signature policies, immutable acceptance evidence, re-sign obligations and the signed-PDF artifact queue. Schema in `shared/models/agreementLibrary.ts`. - **Partner Agreement engine** — the commercial lifecycle of host agreements (`partner_agreements`: trial, operating, quota, probation, suspension, renewal). Linked to the Library by `partner_agreements.engine_agreement_code`. Three consumers reach it, all through the Hub and the Globus SDK: the staff UI, the Applicant Portal (PPPToo: first-login terms gate and the Agreement workflow step) and the Host Portal (operating and trial agreement gate, accept, upgrade, signed PDF). n8n only delivers the notification emails from outbox events. The subsystem is roughly 27,000 lines of services and tests in the Hub plus about 45 data migrations that repair host-agreement library links. > **Target design, not current state** > > Nothing on this page is live yet. The assessment of 2026-10-26 (Asana 1219899439997361) is kept below; the decision taken the same day is recorded next. ## Decision (2026-10-26) Sam O'Regan decided on 2026-10-26 to build the Agreement Service and its SDK now, rather than the two-stage path recommended below. The binding record is [ADR 0001](https://github.com/Alliance-Strategies/agreements-service/blob/main/docs/adr/0001-agreements-service.md) in the new repository `Alliance-Strategies/agreements-service`; delivery is tracked on the Asana board [Agreements Service + SDK (Microservice Extraction)](https://app.asana.com/1/817430812898727/project/1219906270704861) (37 tickets across phases P0 contract and foundations, P1 service core, P2 runtime, P3 governance, P4 SDK, P5 Globus integration and migration, P6 cutover and hardening). What the decision fixes, in the terms of this page: - **Boundary.** The service owns the Agreement Library engine's tables (agreements, versions, version events, assignments, signature policies, runtime pins, acceptances, re-sign obligations, cycle mappings, resolve failures, signed-artifact requests). The Partner Agreement engine (`partner_agreements`, quotas, trial/operating lifecycle), applicants, applications, workflow placements and documents stay in the Hub, which links to the library by `engine_agreement_code` only. - **Contracts to define first** (below) are adopted as written: resolve with typed failures and an explicit per-consumer failure policy (fail-closed by default); idempotent `accept` as a saga, with the service as the source of truth for acceptance; outbox events with idempotent consumers and a Hub read model instead of the rules-engine joins; service-to-service OIDC with the Hub asserting the subject; addressing by `code`, never UUID. - **Option B is the SDK shape.** `@alliance-strategies/agreements-sdk` (generated from the service's OpenAPI contract, runtime and admin clients, typed errors, event verification, in-memory fake) is the only way the Hub reaches the service. Phase 1 keeps the Hub as the facade for the portals; portal BFFs may later call the service directly for the sign-in gate and host content/evidence, with step submit staying a Hub route, as configuration. - **Stage 0 gaps** (atomic host accept, a guard for a missing host signature policy, an explicit failure policy for the Applicant Portal gate, the live re-check of production's agreements schema) are fixed in Globus before any environment cuts over. - **Deployment.** One Cloud Run service, Cloud SQL instance and Pub/Sub topic per environment (development pool slots, QA pool slots, production; no staging, no shared instance), provisioned by Terraform; published legal text moves between environments only through an explicit export/import with the never-stomp guard. - **Cutover.** Legacy, shadow-compare and service modes behind one Hub configuration switch; dev then QA then production, each after a soak, with a rehearsed rollback. ## Original Recommendation (superseded) The assessment recommended not extracting everything at once. That sequencing was superseded by the decision above; the analysis is kept because every seam it names is a delivery ticket now. Extract in two stages: 1. **Now: modular monolith.** Put a hard module boundary and an internal facade around the Library and Evidence engine inside the Hub. No network hop. 2. **Later: Agreement Service** containing Library, Assignments, Resolution, Acceptance evidence and Signed artifacts. `partner_agreements` (the commercial host lifecycle) stays in the Hub permanently. Start stage 2 when one of these triggers appears: - a second system outside Globus needs agreements (for example a recruiter or partner portal, or a non-Globus product); - legal evidence must scale or deploy independently of the Hub; - compliance requires the evidence store to be isolated from the Hub database. ## Why Not a Full Extraction Now | Seam | Where it lives | Why it blocks a clean split | |---|---|---| | Single transaction across domains | `agreementWorkflowSubmission.ts` | Applicant acceptance locks the `applications` row, checks the pin, writes the acceptance and enqueues the artifact request in one transaction. A service boundary breaks that atomicity. | | Hard foreign keys | `agreementLibrary.ts`, `partner.ts` | Library tables reference applicants, applications, brands, programs, seasons, partner organizations, partner users and partner agreements. `applicant_documents`, `job_orders` and `quotas` reference back. | | Synchronous in-process callers | rules engine `factsResolver`, `coreStorage`, `applicantIdentity`, `partnerEnforcementGuard` | The rule WORKFLOW-AGR-001 gates `eligibility_cleared`; identity and program changes trigger re-sign; about 16 files read `partner_agreements` directly. Each becomes a network call. | | Non-atomic host accept | `operatingAgreement.ts` | Recording the engine acceptance, updating `partner_agreements` and activating are separate transactions today. Fix before splitting. | | Two engines | Library vs Partner Agreement | Only the Library side is a clean candidate. Splitting both together drags the host lifecycle, quotas and job-order gates along. | ## Why a Service Still Makes Sense - Three consumers with different auth each re-implement verify-after-write and error mapping. - Content promotion between environments is manual: a full-snapshot SQL migration with a never-stomp guard, or config import as a draft. Agreement content is data, not code. - A missing configuration row has already caused an outage: on 2026-10-08 a missing host signature policy gated 61 host organizations and 73 users. - The Applicant Portal first-login gate fails open when the Hub errors, so the failure policy for legal evidence is implicit rather than chosen. - The domain churns heavily and is already a de facto bounded context. ## Current State ``` Staff UI (Hub client) PPPToo (NestJS BFF + React) HostPortalPrototype (BFF + React) library / assignments Keycloak JWT Auth0 JWT acceptances / resign X-Portal-Access-Token X-Host-Org-Id, X-Host-User-Email | X-Applicant-User-Id | | \--- globus-sdk (X-API-Key) ----/ v v v +------------------------------------------------------------------------+ | Globus Hub / BackOfficePrototype (GKE, per-environment Cloud SQL) | | | | Agreement Library engine Partner Agreement engine | | agreements, versions, events partner_agreements (+ extension) | | assignments, cycle versions quotas, dismissals | | signature policies trial / operating lifecycle | | runtime resolutions (pins) host accept / upgrade / renew | | acceptances (immutable) agreementAcceptanceGate | | resign obligations, failures | | | signed artifact queue -> PDF | engine_agreement_code | | +----------------------------------+ | | | | In-process callers: workflow engine (Agreement step), rules engine | | facts, identity and program hooks, enforcement guards, documents, | | notifications, audit, config transfer | +-----------+----------------------------+---------------------------------+ | outbox events | signed PDFs v v n8n dispatcher (emails) Cloud Storage ``` ## Target Architecture ``` Staff UI PPPToo BFF HostPortal BFF Atlas / n8n / Gateway \ | | / +------------+--------+--------+-------------------+ v Hub (BackOfficePrototype) = system of record for applicants, applications, workflow, rules, host orgs, partner_agreements | portal-facing routes unchanged (Hub is the facade) | events in: identity changed, program changed, step submitted v +--------------------------------------------------+ | Agreement Service (Cloud Run, own database) | | API: /library /assignments /resolve | | /acceptances /resign /artifacts | | | | Library + versioning Targeting + resolution | | Pins + cycle mapping Signature policy | | Evidence ledger (append-only, hash-chained) | | Re-sign engine Artifact worker (PDF) | | | | References by opaque ids and scope codes: | | subject id (applicant or host org), context id | | (application), brand / program / season keys. | | No cross-database foreign keys. | +-------------+------------------+------------------+ | outbox events | signed PDF to Cloud Storage v v accepted / invalidated / Hub projects the PDF into resign_required / applicant_documents version_published -> Hub read model, queues, notifications, n8n ``` The Hub keeps the portal-facing endpoints, so PPPToo and the Host Portal do not change their SDK calls in the first stages. ## Option B: Portals Call the Service Through Its Own SDK Instead of the Hub fronting every agreement call, each portal BFF calls the Agreement Service directly through a dedicated SDK (`@alliance-strategies/agreement-sdk`), the same way portals use `@alliance-strategies/globus-sdk` today. The SDK is server-side only; browsers never call the service. ``` PPPToo BFF HostPortal BFF future portals \ | / +---- @alliance-strategies/agreement-sdk ---+ | per-portal credential, caller asserts subject +---------------+----------------+ v (phase 1) v (phase 2 onward) Hub facade routes Agreement Service (SDK-shaped, thin) (own database, own API) \ / +--- events: accepted, invalidated, resign_required ---> Hub read model (rules facts, DS-2019 guard, host gate, queues) ``` **Recommended shape: SDK first, strangler cutover.** Define the SDK contract now and implement it against Hub facade routes. When the service exists, repoint the SDK base URL per endpoint. Portals change once (adopt the SDK); every later move is configuration. **What it gives** - The Hub is off the hot path for the sign-in terms gate and the host gate that re-checks every 60 seconds. - One contract replaces three hand-rolled integrations (staff UI, Applicant Portal, Host Portal), including verify-after-write and error mapping. - New portals onboard by installing the SDK. - Contract tests: PPPToo already checks its Hub calls against a contract snapshot; the SDK can carry the same check. **What breaks if portals go fully direct** | Problem | Why | Mitigation | |---|---|---| | Workflow Agreement step | The Hub owns application and step state. A portal calling both systems would coordinate a split write: acceptance recorded, step not completed. | Keep step-config and step submit as Hub routes that call the service. Only the sign-in gate and host content and evidence calls go direct. | | Host gate state | It combines `partner_agreements` (Hub) with acceptance evidence (service). The Hub acceptance gate on every host route still needs acceptance state. | Hub read model fed by events, or a synchronous check; the Host Portal keeps its verify-after-write. | | Resolution scope | Sign-in resolution uses the applicant brand, program and season, which only the Hub knows. | Caller passes scope codes from the Hub context, a Hub-signed scope token, or a service-side projection. | | Identity | The Hub verifies the Keycloak claim (Applicant Portal) and the host side uses Auth0. The service should not duplicate that. | Trust the portal BFF credential plus an asserted subject, as the Hub does with API keys today. | | Credentials | Each portal in each environment needs its own service credential. | Same per-environment key pattern as the Hub API key rows, with narrow scopes. | **Recommended split if this option is chosen.** The SDK exposes resolve, accept and evidence or content. The Hub keeps step orchestration and the host commercial lifecycle. ## Contracts to Define First 1. **Resolve.** Input is subject, context and scope codes; output is the pinned version or a typed failure (`AGREEMENT_NOT_RESOLVABLE`, `NO_PUBLISHED_VERSION`, `POLICY_MISSING`). Each consumer declares fail-open or fail-closed explicitly. 2. **Accept.** Idempotent by subject, version and send or revision anchor; returns the evidence id and content hash. Replaces the single in-transaction write with a saga: the Hub marks the step as accepting, calls the service, and completes the step on the evidence id. The service is the source of truth for acceptance. 3. **Events.** Outbox, at-least-once, idempotent consumers. A Hub read model of accepted agreements per application replaces the rules-engine joins. 4. **Identity.** Service-to-service OIDC from the Hub. The service receives the subject identity; it does not verify applicants or hosts itself. 5. **Content addressing.** Agreements stay addressed by `code`, never by UUID, so content and seeds promote cleanly between environments. ## Staged Path | Stage | Work | Risk | |---|---|---| | 0 | Fix existing gaps: atomic host accept, a guard for a missing host signature policy, an explicit failure policy for the Applicant Portal gate, Nexus drift (predicates and indexes that no longer exist) | Low | | 1 | In-repo module with a facade; a lint rule forbids direct table access from outside; move the 16 direct `partner_agreements` readers behind a read model | Low | | 2 | Outbox events and a Hub read model replace the rules-engine joins; dual-write evidence if needed | Medium | | 3 | Run the service against its own schema and database role; route staff UI and portals through it | Medium | | 4 | Split the database; build an export and import promotion tool with the never-stomp guard | High | ## Open Questions Status after the 2026-10-26 decision: - Is a non-Globus consumer planned? — Moot as a trigger: the service is being built now; the SDK is the onboarding path for any later consumer. - Should acceptance evidence live outside the Hub for legal isolation? — Yes: the service holds the evidence ledger in its own per-environment database (ADR 0001, decisions 1 and 9). - Prod state needs a live re-check (a July 2026 investigation found no agreements engine in prod while the 2026-10-08 prod host-policy fix shows the policy table exists). — Still open; carried as a stage-0 subtask of the Globus adapter ticket. - Which consumer failure policy is correct for legal evidence? — Fail closed by default, declared explicitly per consumer in the SDK call (ADR 0001, decision 8). ## Related Pages - [ADR 0001 — Agreements Engine becomes a standalone service with an SDK](https://github.com/Alliance-Strategies/agreements-service/blob/main/docs/adr/0001-agreements-service.md) — the binding decision record - [Agreements Engine](/docs/agreements-engine) — current Library, targeting, signature and evidence behaviour - [Host Portal Governance](/docs/host-governance) — host agreement structure, trial and operating agreements - [Partner Organizations](/docs/partner-organizations) — canonical `partner_agreements` model - [Portal Overview](/docs/portal-boundaries) — Hub as the authority behind every portal --- --- title: "SEVIS Readiness Criteria" slug: sevis-readiness section: Rules & Compliance status: stable description: "Completeness requirements and compliance gate for SEVIS pool entry and DS-2019 submission" last-updated: 2026-10-07 --- # SEVIS Readiness Criteria Completeness requirements and compliance gate for SEVIS pool entry and DS-2019 submission ## Overview Before an applicant can enter the SEVIS pool and have a DS-2019 issued, Globus enforces a layered readiness gate. A certificate-health precondition confirms the SEVIS infrastructure is reachable, and a two-layer gate then evaluates every applicant: a global `ready_for_sevis` gate that applies regardless of program, and program-level add-on gates that stack on top of it. All gate outcomes are computed by the sevisComplianceService.ts and enforced by the Rules Engine via ruleEnforcement.ts. ## Two-Layer Gate Architecture The SEVIS readiness gate architecture governs SEVIS pool entry for every applicant (Aug 11 2026 release capsule). It combines a certificate-health precondition with two stacked gate layers: a global gate every applicant must satisfy, and program-specific add-on gates. ### Certificate Health The SEVIS certificate health check verifies that the sponsor's SEVIS credentials are valid and that the target SEVIS environment (production or test) is reachable. If the certificate is expired, revoked, or the environment is unreachable, the gate is closed for all applicants regardless of their individual compliance status. This check is performed by the sevisComplianceService.ts before any individual applicant evaluation. Exactly one certificate is active per SEVIS environment. Uploading or activating a certificate atomically deactivates the previous active certificate for that environment (serialized by a per-environment lock), and credential, health, preflight, and submission checks ignore inactive certificate history. ### Global ready_for_sevis Gate The global `ready_for_sevis` gate enforces thirteen dependency rules that every applicant must satisfy to enter the SEVIS pool, regardless of program. It is **fail-closed** — the gate stays closed until every rule passes. Applicants who fail route to the sevis-readiness-blocked workbench queue. | Rule | Requirement | | --- | --- | | Minimum age | Applicant meets the minimum age at program start | | Minimum age at employment start | Camp-family programs only (the fact comes from the Camp Contract step, so it is absent elsewhere and the rule stays dormant). Applicant is 18+ on the Camp Contract employment start date (rule `eligibility.minimum_age_employment_start.j1_camp_programs`, `ageAtEmploymentStart >= 18`). A mandatory, PASS_OR_APPROVED_REVIEW dependency of `ready_for_sevis` only: it has no bundle binding and does not block Market Ready or Placement Ready. A shortfall routes to Compliance Review Required (`MINIMUM_AGE_NOT_MET`, hold for review) and can't be waived by override. If the contract start date is absent or cannot be read, the gate reports a missing-prerequisite blocker (Employment Contract Start Date) instead of an age failure. | | Intake complete | The application / Intake phase of the applicant's pinned published workflow is complete (the `workflowIntakeComplete` fact, resolved only by the authoritative step-completion service). If the workflow's Intake step count cannot be established the fact is absent and the rule fails closed. The legacy structural `application` track blocker (`track:application`) is no longer emitted alongside it, so only this rule blocker is shown. | | T&C acceptance | Terms & Conditions accepted | | Passport uploaded | A passport document has been uploaded | | Passport not expired | Passport is valid and not expired | | Passport OCR clear | Passport OCR completed without error | | Passport identity confirmed | Passport identity data confirmed | | Criminal background check clear | A criminal-background upload is present and the criminal-background assessment is clear (or an approved manual review) | | Camp Counselor placement duration | The applicant's current Camp Counselor placement fits within four calendar months (rule `j1.camp_counselor.max_program_months`); non-Camp applications pass as not applicable. See the Camp Program Rules page. | | Passport citizenship resolved | Passport citizenship resolved (pass or approved review) | | Duplicate passport detection | No unresolved duplicate-passport match (pass or approved review) | Failure of any single rule holds the applicant out of the SEVIS pool and routes them to the sevis-readiness-blocked workbench queue. A genuine minimum-age failure is also routed to the Eligibility Blocked queue with reason `MINIMUM_AGE_NOT_MET` (the minimum-age rule's fail action carries the `eligibility_documents` readiness dimension, which is the marker that routes a blocking action to a queue). An applicant with no date of birth is not an age failure: that result stays "Awaiting evidence" and opens no breach. Migration `20271115000001_backfill_minimum_age_open_breaches` opens the Blocked-queue breach rows for applications already evaluated as genuinely under age (latest record for the rule is a raw `fail`); applicants awaiting a date of birth are deliberately excluded. ADR 0004 records the contract behind this routing: the readiness dimension on a fail action is the routing marker, so `BLOCK` and other fail verbs route too, without changing their severity or gating, and a "fail" that is only a fail-closed rewrite of missing data never opens a breach. ### Program-Level Add-On Gates Program-level add-on gates stack on top of the global `ready_for_sevis` gate to enforce program-specific requirements. An applicant must clear both the global gate and any applicable program add-on gate. - **Teach USA** — gate is live and unblocked. - **Intern / Trainee** — gates are scaffolded and will activate when their SEVIS configuration is published. - **Summer Work Travel — Camp (Wild Packs)** — the `ready_for_sevis.swt_camp_wildpacks` add-on (scoped to the `summer_work_travel_wildpacks` program, seed `sevis-add-on-gates-v5`) additionally requires six SWT rules to pass: current student status verified, academic calendar verified, vacation window verified, Camp Contract start within the eligible window, Camp Contract end within the eligible window, and work period within four months. At the SEVIS chokepoint each of the six is resolved from fresh, application-scoped facts rather than a stored failure, and passes only on an explicit true verification fact, so a completed evidence review or a changed vacation or Camp Contract date cannot leave readiness reading a stale failure. Missing or incomplete Camp Contract dates, or unavailable DOS/vacation evidence, are held as awaiting evidence; a fully derived empty overlap between the declared vacation and the DOS travel window, a contract start before the permitted window, or a contract end after it is a blocking failure. The Applicant Profile compliance details show the evidence for these rules: the permitted SWT window, the Camp Contract window, and separate start and end outcomes. ### Camp Counselor (Wildpacks) Add-On Gate > **Superseded — Eligibility Engine now live** > > The legacy Cat 1/2/3 auto-derivation and staff-set eligibility path have been **fully retired**. The Camp eligibility gate now depends on the Eligibility Engine's sponsor determination (live on development, designed under ADR 0003). See the Eligibility Engine & Compliance Tab page for the full model. Camp Counselor applicants (Wildpacks) stack the eligibility gate on top of the global gate: | Rule | Condition | | --- | --- | | CAMP-001 Eligibility approved | The applicant has an **approved and currently valid** sponsor determination from the Eligibility Engine — engine-assembled path evaluation, human approval. Fails closed if the fact is missing or unreadable. | The former standalone evidence checks are subsumed by the engine's qualifying paths, each requiring uploaded and staff-verified evidence. ### Camp Eligibility Decision Model The engine evaluates a universal minimum-age requirement (18+, fail-closed) plus four qualifying paths — post-secondary student, youth worker, teacher, special skills (always manual review) — from applicant facts and staff-verified evidence. Evaluation is automatic and event-driven; a compliance officer issues the approve/reject/revoke determination, and validity is continuously protected: if underlying facts or evidence later change, the determination automatically moves to **Reassessment required** rather than silently relying on a stale approval. > **Staff determination control** > > Approve / reject / revoke actions live on the applicant profile's Compliance tab and require the `eligibility.approve` permission (session login only). Staff no longer set an eligibility path directly; preserved legacy approvals from the old model are never re-opened automatically. ## Ready Pool and Batch Admission One canonical readiness verdict drives the Ready Pool, batch admission, and the final submission check. An applicant is selectable only when the live verdict is ready; general readiness labels and the legacy pool flag are advisory and never grant eligibility. - SEVIS batches are scoped to one program and one operational cycle. The batch cycle picker lists only cycles in `open`, `active`, or `closing` status for the selected program. - When creating a batch from the Ready Pool, the draft is created first and the selected applicants are then added through the canonical eligibility check (`POST /api/sevis/batches/:batchId/add-items`). Each rejected applicant is reported with a reason — not found, already in batch, already issued, program mismatch, cycle mismatch, or not ready (with the structured blockers). A zero-item draft is never presented as a successful admission. - Production submission to SEVIS stays fail-closed unless the `SEVIS_ALLOW_PRODUCTION_SUBMIT` flag is explicitly enabled. A refused submission returns `403` with top-level `error.code` `SEVIS_SUBMISSION_BLOCKED` and the discriminator in `error.details.code`: `SEVIS_PRODUCTION_SUBMIT_DISABLED` (production without the flag), `SEVIS_ENVIRONMENT_CONFLICT` (the active sponsor certificate's environment disagrees with `SEVIS_ENVIRONMENT`) or `SEVIS_GATEWAY_ENDPOINT_MISMATCH` (a configured upload or download endpoint does not belong to the resolved environment, or targets the production gateway from a non-production environment). `details` also carries `environment` and `requiredFlag`. - Issuance guards fail with `SEVIS_NOT_READY` (`422`) when any pre-SEVIS track blocker is present, and with `SEVIS_COMPLIANCE_BLOCKED` (`403`) when only compliance or passport blockers remain. The compliance message lists the blocker reasons; when an approved override exists but immutable requirements still block, the message says the override cannot bypass them and `details` adds `overrideId` and `immutableBlockerKeys` next to `applicationId` and `blockers`. `PATCH /api/applications/:id` returns these when it sets `isInSevisPool` to true, and SEVIS batch creation and send return the same two codes (with `details.rejected`) when candidates fail readiness revalidation; batch confirm returns only `SEVIS_NOT_READY`. - The mandatory J-1 phase-gate governance bundle (`j1-phase-gate-governance`) carries a system-managed global J-1 scope so its rules, including criminal-background upload presence, enter live evaluation; without that scope those dependencies would fail closed as not evaluated. ## Surface Area The gate outcomes and camp eligibility path surface across these areas (Aug 11 2026 release capsule): - Applicant profile compliance tab - SEVIS-readiness-blocked queue - Command Center Readiness tab - Executive Overview gate aggregation - `POST /api/sevis/compliance/check` **SEVIS reconciliation report.** `GET /api/reports/sevis-reconciliation` (permission `reporting.view`, clamped to the caller's brand, program and season scope; query `brandId`, `programId`, `seasonId`, `from`, `to` and `format` for an export) compares the SEVIS status held here with the local application status for participants that have a SEVIS record. The JSON response carries `summary`, `exceptions`, `noShow`, `disposition`, `stalenessThresholdDays` and `scopeEmpty`. An exception is `locally_ended_still_active_in_sevis`, `terminated_in_sevis_no_local_disposition`, `reported_no_show_not_in_sevis` (discrepancies) or `status_not_confirmed` (unverified: the SEVIS status was not re-confirmed within the staleness threshold, or never). `GET /api/reports/sevis-reconciliation/exceptions/:exceptionKey` (same permission and scope clamps) is the drill-down: it re-runs the scoped read and returns the `exception` metadata (label, meaning, action, kind), the `window`, the participants with that exception (identity included) capped at 500 with `limit` and `total`, and `scopeEmpty`. An unknown `exceptionKey` returns a 400 validation error. The report only identifies discrepancies; any corrective SEVIS transaction stays a human decision. ## 100% Completeness Policy > **Zero-Tolerance Completeness** > > The SEVIS pool entry gate enforces a 100% completeness policy. There is no partial-pass threshold — every required global `ready_for_sevis` rule and every applicable program add-on rule must pass. This is a regulatory requirement, not a configurable threshold. The 100% policy is implemented by the Rules Engine's resolveReadiness() function, which aggregates all rule outcomes for the global gate and any program add-on gate and returns blocked if any single rule fails. The gate will not open until all rules pass. ## Stored Verdicts, Fingerprints & Invalidation Shipped Sep 2026. Readiness outcomes shown on the Applicant Profile and used to build the Ready Pool are **stored projection rows**, not live evaluations — screen loads never run the full readiness evaluator. To keep stored verdicts honest after configuration changes: - **Every verdict carries a PII-free fingerprint** of the evaluator release, the application's program and operational cycle, the applicable pre-SEVIS tracks, the published baseline and program add-on gate versions, and the pool override context used during evaluation. A row with no fingerprint, a malformed fingerprint, or an older evaluator version is treated exactly like a missing verdict: it displays as **Awaiting Evidence** (staff wording: *Checking readiness*), never Ready, and cannot be selected or counted as eligible. - **Configuration changes invalidate before they commit.** Publishing a SEVIS gate change, changing a gate's scope, changing a program's applicable tracks, or reassigning an application's program or operational cycle transactionally invalidates the affected stored rows inside the same transaction as the change. Global configuration changes also advance a persisted generation; each evaluation captures the generation before loading the rules catalogue and its persistence is refused if the generation moved while it ran, and readers reject rows from an older generation. An evaluation that started from a stale cached catalogue therefore cannot restore obsolete readiness after invalidation. - **Data changes invalidate the affected application.** Mutations that can flip a blocker tombstone that application's stored verdict synchronously. When lifecycle reconciliation persists a changed pre-SEVIS track state, the verdict is tombstoned in the same transaction as the application update (both roll back together; an unchanged reconciliation invalidates nothing). SWT evidence decisions (for example vacation dates submitted, updated or verified) invalidate that application's verdict before queueing the targeted rule re-evaluation, so a closed blocker cannot linger while the asynchronous evaluation catches up. - **The background sweeper rebuilds.** Missing, invalidated, expired, malformed-fingerprint, obsolete-evaluator and obsolete-generation rows are prioritised and rebuilt outside the request path. Readers only select; they never evaluate. - **Freshness and expiry.** A stored verdict older than six hours is presented as stale (the sweeper re-verifies after four hours); the Applicant Profile then shows *Rechecking readiness* rather than Ready. A verdict computed under a readiness override is withheld once that override's expiry passes. - **The Applicant Profile and the Ready Pool agree.** The profile's SEVIS card reads the same persisted full-readiness verdict as the Ready Pool; the operational gate detail only explains prerequisites and can no longer promote the applicant to Ready on its own. The profile's SEVIS readiness card renders the complete verdict: every unique structural blocker (workflow track, missing field) and every rule blocker is listed, deduplicated on rule key (with a composite-key fallback when a blocker has none), with linked incomplete workflow steps and an "Evaluated " line taken from the verdict's evaluation time. The earlier behavior of showing only structural blockers plus a count of additional compliance rules is gone. - **Filters and badges cannot disagree.** Ready Pool filtering matches on the same persisted row each application is rendered from; `awaiting` is exactly the set with no usable verdict. Rows are ordered by application `updatedAt` descending with an id tie-break, so an application cannot appear on two pages or on none. - **Ready Pool cards and labels.** The four summary cards mean different things: *Confirmed gate pass* counts applications whose readiness is confirmed passing right now (a floor, not a total, while any verdicts are still being evaluated), *In Ready Pool* counts applications admitted for SEVIS processing, *DS-2019 issued* counts completed issuance, and *Waitlisted* counts applications awaiting program capacity. A pending or stale verdict is never treated as gate-ready and is never selectable for a batch: the row shows *Checking readiness* (no verdict yet) or *Rechecking readiness* with *Previously ready / not ready · rechecking*. Otherwise rows show *Ready for SEVIS* or *Not ready for SEVIS processing*. The readiness filter options are All applicants, Ready for batching, Processing incomplete, Checks need attention, and Still checking, with a one-line help text under the filter. - **Backing tables.** `sevis_readiness_projection_config` is a single-row table (`id` fixed at 1, `generation`, `updated_at`) holding the configuration generation described above; each stored verdict records the generation it was computed under. `sevis_readiness_verdict_transitions` is an append-only history written when a stored verdict changes materially: the application, `from_ready` and `to_ready`, the full `from_verdict` and `to_verdict`, `change_kind`, the fingerprint and generation, and `changed_at`. `change_kind` is `initial` (first recorded verdict), `regression` (ready to not ready), `restored` (not ready to ready) or `changed`. The Ready Pool query returns each application's latest transition. ## Override and Audit Trail A staff member with the `sevis.override.manage` RBAC permission (Super Admin, Compliance Manager, Compliance Officer) manages SEVIS readiness overrides. New broad overrides are retired: `POST /api/applications/:id/sevis-readiness-overrides` (still gated by the permission and entity scope, and answering 404 first for an unknown application) always ends in a 409 `LEGACY_SEVIS_OVERRIDE_RETIRED`, directing staff to request a governed Compliance override for the specific failed rule instead. Existing overrides are grandfathered until they expire or are revoked, and the same permission governs revoking, shortening the expiry of, reading the audit history of, and relying on one. An override is recorded per application and operation scope (`pool`, `add`, `confirm`, `send`; authority on one scope never covers another) with the waived blocker keys, a justification (minimum 20 characters), its expiry and an audit row. It is bound to the application's program and operational cycle (a material change makes it ineffective) and fails closed on expiry. Overrides were applied in one action by one authorized person with no second approver, with a 24-hour default life (`SEVIS_OVERRIDE_DEFAULT_EXPIRY_HOURS`) and a hard ceiling of 168 hours (7 days) measured from the moment the override was applied; that ceiling still bounds any expiry edit. Some requirements can never be overridden: a terminal journey position (withdrawn, rejected, completed, cancelled, alumni), host site address state or postal-code defects, the minimum identity and contact set (first name, surname, contact phone, email, date of birth), and any minimum-age determination. Overrides are surfaced on the applicant's compliance timeline and are visible to compliance reviewers. Every override route requires `sevis.override.manage` plus entity scope over the application's program, season, brand and partner organization. | Route | Purpose | Contract | | --- | --- | --- | | `GET /api/applications/:id/sevis-readiness-overrides` | List the application's overrides | Optional `scope`, `status` (`pending`, `active`, `rejected`, `revoked`, `expired`; `pending` and `rejected` are legacy states) and `limit` (1 to 100). `includeBaseline=true` adds the no-override baseline: evaluation time, bypassable and non-bypassable blockers, and the scopes currently covered. | | `GET /api/applications/:id/sevis-readiness-overrides/active` | Active override for one scope | `scope` is required. Returns `{ override, active }`. | | `POST /api/applications/:id/sevis-readiness-overrides` | Apply (retired) | Once the permission, application and scope checks pass, always 409 `LEGACY_SEVIS_OVERRIDE_RETIRED`. | | `POST /api/sevis-readiness-overrides/:overrideId/revoke` | Revoke one scope's override | Body is `reasonCode` only, one of `no_longer_needed`, `risk_identified`, `issued_in_error`, `superseded` or `other`; free text is rejected. | | `PATCH /api/sevis-readiness-overrides/:overrideId/expiry` | Change the expiry | Body `expiresAt` plus `reasonCode` (`batch_rescheduled`, `remediation_delayed`, `administrative_correction` or `other`). Shorten only: a later expiry is 409 `LEGACY_SEVIS_OVERRIDE_EXTENSION_RETIRED`. The new expiry must be in the future and within 168 hours of when the override was applied (400 otherwise). | | `GET /api/sevis-readiness-overrides/:overrideId/audit` | Audit trail | The append-only override audit events merged with the used and denied usage entries, oldest first. | The audit events come from `sevis_readiness_override_audit`, one row per lifecycle event of an override: `override_id` (deletion of an override with events is restricted), `event_type` (`applied`, `expiry_changed`, `revoked`, `expired`, plus the retired `requested`, `approved` and `rejected`, kept so older rows still resolve), `actor_user_id` (null for system expiry), `status_snapshot`, `occurred_at` and a `metadata` object limited to the blocker keys, the justification length, the old and new expiry and a bounded `reasonCode`. The justification text stays on the override row and is never copied into the audit. --- --- title: "Eligibility Engine & Compliance Tab" slug: eligibility-engine section: Rules & Compliance status: stable description: "Globus decides camp counselor eligibility with an automated **eligibility engine** instead of applicant-selected checkboxes and ad-hoc staff judgment." last-updated: 2026-10-07 --- # Eligibility Engine & Compliance Tab Globus decides camp counselor eligibility with an automated **eligibility engine** instead of applicant-selected checkboxes and ad-hoc staff judgment. The engine continuously evaluates each application against a defined qualification model, records an auditable assessment, and holds the applicant's journey at the eligibility gate until a human compliance officer issues a formal sponsor determination. Staff see and act on all of this in the **Compliance tab** of the applicant profile. ## Status & Principle > **Live on development** — promotes to QA and production with the standard release train. Camp Counselor is the first program on the engine with qualifying paths; Intern/Trainee and Teacher are seeded with the universal minimum-age requirement only (ruleset v2; qualifying paths are not required). (Designed under ADR 0003, 2026-08-14; now shipped.) The core principle: > The machine assembles the evidence and checks the rules; a person makes the call. The engine never approves anyone on its own. ## Problem & Background Before this work, applicants effectively self-declared their eligibility basis (student / youth worker / etc.) with no systematic check that the claim was backed by verified evidence. Eligibility knowledge lived in staff heads and spreadsheets — no consistent record of *why* someone was considered eligible, no re-check when their information later changed, and no defensible audit trail for the sponsor determination. Compliance information was scattered across surfaces with no single view answering "is this applicant compliant, and if not, why not?" The legacy self-declared "basis" model has been **fully retired**. Applicants never pick a basis anymore; the engine derives qualification from what they actually submit. Applicants who were already verified under the old model had their approvals preserved as legacy records and are never re-opened automatically. ## How the Eligibility Engine Works ### Qualification Model (Camp Counselor, Ruleset v3) An applicant qualifies when **every universal requirement passes AND at least one qualifying path is satisfied**. **Universal requirement (applies to everyone):** | Requirement | Bar | Behavior | | --- | --- | --- | | Minimum age | 18 or older | Fail-closed: if age is unknown, the requirement is not treated as met. A failed universal dominates the overall result regardless of paths. | A universal requirement can also resolve to *not applicable* (shown as "Not applicable"). It is not treated as unmet, and it counts as satisfied when checking that all universals are satisfied before approval. **Qualifying paths (any one is sufficient):** | Path | Applicant-entered information checked (ruleset v3) | Document evidence | | --- | --- | --- | | **Post-secondary student** | Applicant states current study or current post-secondary enrollment (About Me education information) | None required — the path reads applicant information only | | **Youth worker** | A child/youth-related work or volunteer entry, **or** a real "My Experience with Children" narrative. Generic work experience does not count | None required | | **Teacher** | An education entry that names a teaching qualification (keyword-matched). Generic work experience does not count | None required | | **Special skills** | Skills claimed on the Skills & Hobbies step. **Always routed to manual review** — this path can never auto-satisfy, by design. | None required | Ruleset v3 replaced the earlier document-evidence paths: no Camp Counselor path demands an uploaded or staff-verified document, and a satisfied path means **ready for sponsor review** — the sponsor determination remains the sole qualification authority. Each path rule is a non-blocking machine signal: a failing path routes to eligibility review (compliance review queue) and never blocks progression by itself. **Per-path outcomes:** not claimed → *requirements not satisfied*; claim unknown (information missing) → *insufficient information*; claimed and the path rule passes → *requirements satisfied*; claimed but the rule has not passed → *possibly qualified*; special skills when claimed → *requires manual review*. **Overall assessment results:** | Result | Meaning | | --- | --- | | **Qualifying path identified** | Universals pass and at least one path is fully satisfied — ready for sponsor review | | **More information required** | A path is possible but something is still missing, pending, or awaiting manual review | | **No qualifying path** | No claimed path can qualify | | **Universal requirements not met** | A universal (e.g. minimum age) failed — this overrides everything else | | **Universal requirements satisfied** | All applicable universal requirements are met and the assessment has no qualifying paths — the sponsor determination decides qualification | ### When the Engine Evaluates Evaluation is automatic and event-driven. It re-runs whenever the inputs could have changed: - **Application lifecycle events** (submission, step completion, stage movement) via the event pipeline. - **Document uploads** — both applicant portal uploads and staff uploads. - **Document review decisions** — verifying or rejecting a document immediately re-evaluates. - **Staff field edits** — `POST /api/applicants/:applicantId/field-edits` (and its `/batch` counterpart) await both the step-snapshot write and the promotion onto the canonical applicant record before responding; if either fails the call returns HTTP 500 `FIELD_EDIT_SYNC_FAILED` (`details.stage` = `persist` or `promote`) instead of a success whose write was dropped. Once both have committed, an edit touching an applicant-editable fact field on a completed step queues the same generic plus fact-targeted re-evaluation pair as staff step completion, so the verdict refreshes on its own. - **Manual re-evaluate** — a staff recovery action on the Compliance tab. The call (`POST /api/rules-engine/applicants/:applicantId/re-evaluate`) runs the evaluation, then refreshes the application's operational-exception breach projection, reconciles exception queues and schedules step-access reconciliation *before* it responds; a failed breach projection returns an error instead of a contradictory success, and concurrent calls for the same application coalesce onto one run. - **Boot-time catch-up sweep** — an opt-in one-shot sweep, scheduled about 90 seconds after boot only where `ELIGIBILITY_ASSESSMENT_CATCHUP` is exactly `on` (off by default), finds active camp applications with no assessment (or an outdated one) and evaluates them. This is how applicants created before the engine cutover were brought on board. It is replica-safe, idempotent, and never touches preserved legacy approvals. Evaluations are idempotent: if nothing material changed, re-running produces no new record ("unchanged"). Every assessment records exactly which ruleset version and input snapshot produced it. ### Sponsor Determination — the Human Decision | Action | Who | Requirements | Effect | | --- | --- | --- | --- | | **Approve** | Compliance officer / system admin (`eligibility.approve`, session login only) | Reviewer selects the qualifying path(s) being approved (no path selection is needed when the assessment has no qualifying paths and its overall result is *Universal requirements satisfied*); all universals must be satisfied; selected paths must be *requirements satisfied* or *requires manual review* with any evidence meeting the path's configured bar (Camp Counselor ruleset v3 configures none); assessment must be current (not stale) | Determination = **Approved**, approval becomes the active record (superseding any prior one), validity flag turns on | | **Reject** | Same | A live reason code (global or the program's brand); any active approval must be revoked first | Determination = **Rejected**, validity off, reason recorded | | **Revoke** | Same | A live reason code **plus** a written rationale | Approval marked revoked; determination = **Reassessment required**, validity off | **Refusal codes.** The approve, reject and revoke routes (`POST /api/eligibility/applications/:applicationId/approve`, `.../reject`, `.../approvals/:approvalId/revoke`, all `eligibility.approve`) return the code in `details.code`. HTTP 400: `NO_APPROVED_PATHS` (empty path list), `PRIMARY_PATH_NOT_APPROVED` (primary path not among the approved paths), `UNIVERSAL_REQUIREMENT_NOT_APPROVABLE` (a universal requirement was sent as a path), `PATH_NOT_APPROVABLE` (path result is not approvable in the assessed revision), `PATH_DEFINITION_MISSING` (no published definition for an approved path), and, on reject and revoke, `REASON_CODE_NOT_FOUND`, `REASON_CODE_DEPRECATED` and `REASON_CODE_BRAND_MISMATCH`. HTTP 409: `ASSESSMENT_NOT_EVALUATED`, `STALE_ASSESSMENT_REVISION` (the submitted `expectedInputRevision` differs from the stored one), `NO_PATH_DEFINITIONS` (no published path definitions for the assessment type), `INPUTS_CHANGED_SINCE_EVALUATION` (live inputs no longer match the stored revision; re-evaluate first), `UNIVERSAL_REQUIREMENTS_NOT_MET` (lists the unmet requirements), `EVIDENCE_NOT_AT_BAR` (an approved path evidence requirement is not satisfied at its configured bar), `ACTIVE_APPROVAL_EXISTS` (reject while an approval is active; revoke it first), `APPROVAL_NOT_ACTIVE` (revoke of an approval that is not active) and `APPROVAL_CONCURRENTLY_MODIFIED` (the approval changed during the revoke). An unknown approval id on revoke returns 404. **Validity is continuously protected.** An approval is only trusted while the facts and evidence it was based on still hold. If a later evaluation detects material drift — an approved path's evidence replaced or un-verified, or a universal (like age data) no longer satisfied — the determination automatically moves to **Reassessment required**. Gate checks additionally re-verify the approval against the exact evidence documents (identity, version, review state) at read time and fail closed if anything mismatches. ### Camp Basis Assessments (Staff-Claimed, Reviewer-Verified) Alongside the machine assessment, a Camp Counselor application can carry per-basis assessment rows for the four bases (`post_secondary_student`, `youth_worker`, `teacher`, `special_skills`), non-exclusive, under `/api/applications/:applicationId/camp-basis-assessments`. Each row is `suggested` (migrated from the legacy declaration, not yet claimed), `pending_review` (claimed), `verified` or `rejected`. | Route | Purpose | Permission | | --- | --- | --- | | `GET /api/applications/:applicationId/camp-basis-assessments` | List rows with evidence links and the derived aggregate (empty list for a non-camp application) | `dashboard.view` | | `POST .../camp-basis-assessments/:basisKey/claim` | Claim a basis (idempotent; moves `suggested`/`rejected` back to `pending_review`) | `applications.manage` | | `POST .../camp-basis-assessments/:basisKey/review` | `verify` (needs at least one evidence link) or `reject` (needs review notes or a reason code); only a `pending_review` row can be decided | `applications.manage` | | `POST .../camp-basis-assessments/:basisKey/evidence` | Attach a document from the same application and/or a note | `applications.manage` | | `DELETE /api/applications/:applicationId/camp-basis-evidence/:evidenceId` | Remove an evidence link | `applications.manage` | A **verified** row is immutable — no re-claim, review, note edit or evidence change (409 `VERIFIED_IMMUTABLE`). Writes to a non-camp application are rejected. Error mapping: `400 VALIDATION_ERROR` with `details.code` `NOT_CAMP_APPLICATION` (the application's program category is not `camp_counselor`), `REVIEW_RATIONALE_REQUIRED` (reject with neither review notes nor a reason code) or `EVIDENCE_REQUIRED` (verify with no evidence link, or an evidence request with neither a document nor a note); `409 CONFLICT` with `details.code` `BASIS_NOT_CLAIMED` (review of a row that is not `pending_review`) or `VERIFIED_IMMUTABLE`; and a plain `404 NOT_FOUND` (no distinct code) for an unknown application, for a basis that was never claimed (`BASIS_NOT_FOUND`, on review or evidence), for a document not on the application and for an evidence link not on the application (`EVIDENCE_NOT_FOUND`). An unknown `basisKey` in the path is a `400` with `details.code` `UNKNOWN_BASIS_KEY`. The overall result is derived on read and never stored: an OR aggregate that is fail-closed, eligible-by-basis only when at least one row is `verified`. `GET /api/eligibility/camp-parity` is a read-only shadow-mode parity report comparing the engine's per-application overall result with that legacy verified-basis aggregate (any `verified` row). It needs an authenticated session or an API key carrying both `compliance.read` and `admin.read`, plus the `compliance.console.view` permission, and returns 503 with `code` `ELIGIBILITY_ENGINE_DISABLED` when the engine is switched off. Summary counts cover the caller's full in-scope camp population; the row list holds only the non-matching applications (capped). ### The Journey Gate The camp counselor journey is blocked at the eligibility gate (rule CAMP-001) until the applicant has an **approved and currently valid** determination. The gate reads only the engine's determination — never an applicant-entered field — and fails closed if the fact is missing or unreadable. Disabling the engine (emergency flag) freezes determinations at their last value rather than opening the gate. ## Compliance Tab (Staff Experience) The Compliance tab on the applicant profile is the single pane for eligibility and rule compliance. ### Eligibility Card Shown for camp counselor applications, titled "*Program* Eligibility": - **Machine assessment summary** — overall result, when it last ran, ruleset version, with expandable "Technical details". - **Universal requirements** and **Qualifying paths (at least one must qualify)** — per-path checklists showing exactly what is satisfied, missing, or awaiting verification. Intern/Trainee and Teacher applications show the universal minimum-age requirement only. - **Sponsor determination** — current status with badges such as "Approved", "Approval current", "Approval needs re-verification". - **Actions** — "Approve eligibility", "Reject eligibility", "Revoke approval" (dialogs enforce path selection, reason codes, rationale), plus "Re-evaluate assessment" as a recovery action, whose result is reported by outcome: a "No qualification paths published" notice when the engine has no published qualification paths in the environment, and a "Re-evaluation failed" notice when the engine reports a failure. Staff without the approve permission see a read-only view; approval is blocked with an explanatory message when universals are unmet or the assessment is stale. - **Empty state** — "No eligibility assessment yet — it runs automatically as the applicant submits information and evidence." (Where the opt-in boot catch-up sweep is enabled, active applicants should not sit in this state.) ### Compliance Rules Zone Shown for all applications: - **Summary zone** above the rules: overall compliance verdict and counts, SEVIS readiness, Market Ready, a "What's changed" log of changes since the previous evaluation, and a "Compliance attention" summary with the overrides summary. When the current readiness outcome is SEVIS, the summary card's "What is preventing progress" list shows the same complete SEVIS readiness verdict as the SEVIS card (all structural and rule blockers, plus a "SEVIS readiness evaluated