---
title: "References Flow (Stateless)"
slug: references-flow
section: Portals
group: Applicant Portal
status: stable
description: "The Applicant Portal references flow under the target stateless architecture: the Globus Hub is the sole source of truth for all reference data, and the Applicant Portal is a pure presentation layer and proxy — it resolves templates, for…"
last-updated: 2026-10-07
---

# References Flow (Stateless)

The Applicant Portal references flow under the target stateless architecture: the Globus Hub is the sole source of truth for all reference data, and the Applicant Portal is a pure presentation layer and proxy — it resolves templates, forwards referee details, and reads step status at render time, never persisting reference templates, mappings, or submissions of its own.

See also: Portal Overview & Boundaries and Integration Contract .

## Stateless Model

The legacy implementation mirrored Globus-owned reference data into the Portal's own PostgreSQL tables (templates, template-to-step mappings, and submissions). Under the stateless model the Portal holds no reference state: every read is resolved from Globus at render time, and every write is proxied straight through to Globus.

> **Grounding correction — verify against Hub source, not the audit note**
>
> The originating audit ticket cited POST /api/references/receive for submission initiation. That route does not exist in the Hub (Alliance-Strategies/BackOfficePrototype). The real create-and-invite route is POST /api/reference-submissions. Likewise there is no inbound SendGrid webhook on the Hub, and the delivery-ack route is POST /api/reference-submissions/:id/dispatch-log with no /admin prefix. Endpoints on this page are taken from the Hub development branch (server/routes/referenceTemplates.ts, server/routes/referenceSubmissions.ts).

## Ownership Boundaries

| Concern | Globus Hub (owns) | Applicant Portal (proxy) |
| --- | --- | --- |
| Reference templates | Authoring, versioning, active state, resolution by program/type | Resolves at render; renders the form; stores nothing |
| Submissions | Creation, referee tokens, status, completion, audit | Forwards referee details and answers; stores nothing |
| Referee outreach | Triggers n8n dispatch; records delivery acks via dispatch-log | None — outreach is never portal-owned |
| Step progress | Authoritative step-access snapshot | Reads the snapshot to display status |

## Flows

### Template Resolution

At render time the Portal proxies GET /api/portal/reference-templates/resolve to obtain the active template(s) for the applicant's program and reference type. No local persistence; the previous upsertReferenceTemplateFromGlobus mirror is removed.

Reference templates now carry a version field (Aug 11 2026 release capsule). New submissions pick up the latest active template automatically, and documents already processed against a previous version remain correctly associated with that version — so template changes roll out without retroactively invalidating previously-processed documents. Versioning is a Globus-owned concern; the Portal resolves the current active template at render and stores nothing.

Each template also carries a `showLogo` flag (default true), editable on the Hub's reference-template authoring page. When false, the logo is omitted from the letterhead of the reference-letter PDF (both the completed letter and the blank-template "Save as PDF"). The letter PDF no longer forces a signature line: a signature (or blank signature line) is rendered only when the template has an explicit signature question; the former "Electronic Signature" / "Signature" fallback for templates without one has been removed.

`GET /api/reference-templates/:id/key-usage` (`portal.read` scope, no separate permission) returns, for each question key of the template, counts of referee data held against it and never any response contents; the template builder uses it to badge in-use questions before an edit is attempted. An unknown template returns 404 `NOT_FOUND`.

### Submission Initiation

When an applicant supplies referee details, the Portal proxies them to Globus via POST /api/portal/reference-submissions (create + invite). Globus creates the submission, issues the referee token, and triggers outreach. No reference_submissions row is created in the Portal.

The referee details the Applicant Portal sends are: name, email, relationship, organization, job title, phone, and the six-month confirmation (`knownSixMonths`):

- **Relationship detail for "Other".** When the relationship is exactly `Other`, a free-text relationship detail (`refereeRelationshipDetail`, trimmed, at most 120 characters) is required; the Applicant Portal form shows the field only for Other and the Hub rejects a missing or over-long detail with `RELATIONSHIP_DETAIL_REQUIRED` / `RELATIONSHIP_DETAIL_TOO_LONG`. For any other relationship the detail is stored as null. A lowercase `other` is normalized to `Other` before validation. The detail is shown with the relationship on the applicant's reference card and in the staff applicant-profile References section.
- **Six-month confirmation.** The applicant must tick "I confirm this referee has known me for at least 6 months" before a request can be sent. The Applicant Portal forwards this as `knownSixMonths: true` in the create (and replace) payload, and the Hub persists it on the reference row (`reference_submissions.known_six_months`, nullable boolean). The Hub requires `true` when the application's pinned workflow contains the `reference_details_v2_0` step; legacy workflows may omit it or send null, and `false` or a non-boolean is rejected with 400. A replacement request stores only its own confirmation and never inherits the revoked referee's. Historical rows read as null (unknown). In the Reference Details v2 step schema the confirmation is the `referenceN_known_six_months` checkbox (slots 1 and 2 required, slot 3 optional); confirmations saved under the older `referenceN_confirmKnown6Months` key are copied to the canonical key when the key is absent.
- **Replacing a referee.** `POST /api/portal/reference-submissions/:id/replace` (portal integration key plus the applicant user header) revokes the existing reference and creates a new invite in one call, returning 201 with `revoked` and `created`. The revoke only applies to a reference that is `invited` or `reminded`, or `completed` and rejected in review. If the row is in any other state, for example the referee submitted mid-replace or the review is pending or approved, the route returns 409 `REVOKE_CONFLICT` with `currentStatus` (the reference's current status, or null if it cannot be read) and nothing is created. An email already active on the application returns 409 `DUPLICATE_REFEREE`. Errors on these portal routes are a top-level `error` string.

### Referee Token Flow

The referee opens the emailed link. The Portal proxies GET /api/portal/reference-submissions/lookup (Portal key + referee token) to load the submission and template, and POST /api/portal/reference-submissions/respond to submit the answers. Globus owns the token, the state machine, and completion; the Portal proxies both calls via the Globus SDK with no local state.

In the lookup response the Hub makes relative brand asset paths (for example the logo) absolute so the portal, which lives on a different origin, can fetch them. The base is the optional `PUBLIC_HUB_URL` environment variable (trailing slashes removed), else the first entry of `REPLIT_DOMAINS`, else the request's own protocol and host.

### Step Progress

Reference step status (requested / submitted / approved) is read from the Globus step-access snapshot via GET /api/portal/applications/:applicationId/step-access. No local sync of submission status is required or performed.

### Delivery Acks (n8n)

SendGrid dispatch of referee emails is owned by the n8n [Globus] Handler: Reference Domain workflow. Delivery acknowledgements return to the Hub via POST /api/reference-submissions/:id/dispatch-log — a callback, not an inbound ESP webhook. The Portal is not involved in dispatch or acks.

A `status: "sent"` acknowledgement atomically stamps `reference_submissions.email_sent_at` on the first call only (first-write-wins); duplicate sent callbacks and `queued` / `failed` / `skipped` statuses leave it unchanged, and it is never derived from `invitedAt`. Reminders do not move it (they use `lastReminderAt`), and a replacement row starts null. The nullable `emailSentAt` is returned on the Hub's reference-submission reads and on the Applicant Portal's reference rows; the Applicant Portal shows "We've emailed {referee} on {date}" on the reference card, and the staff applicant-profile References section shows the same line, only when it is set.

The Hub adds `opsEmail` to the payload of each per-submission reference event it emits (`reference.invited`, `reference.reminder_requested`, `reference.completed`, `reference.approved` and `reference.rejected`, `reference.revoked`, `reference_submission.referee_changed` and `reference.expired`); the application-level `reference.facts.updated` event does not carry it. The value comes from the optional `REFERENCE_OPS_EMAIL` environment variable and defaults to `references@allianceabroad.com` when the variable is unset or empty.

## Reference-Facing Pages

### ReferenceRespondPage (token, current)

The current referee experience. Token-based: it calls GET /api/portal/reference-submissions/lookup on load and POST /api/portal/reference-submissions/respond on submit. Fully stateless — all state lives on Globus, keyed by the referee token.

### ReferenceFormPage (legacy)

The legacy path keyed by applicationId / referenceNumber rather than a referee token. It is the surface most coupled to the deprecated local tables and is the primary candidate for removal once the token flow is the sole entry point.

## Endpoint Contract

Portal-proxied endpoints (applicant / referee facing). Portal-key auth is the server-to-server integrations.portal scope; the referee-token routes require the token in addition.

Applicant-side routes on the Hub sit under `/api/portal/reference-submissions` and need the portal key plus a validated applicant identity, with a per-applicant rate limit. `POST /api/portal/reference-submissions` returns 409 `DUPLICATE_REFEREE` when an active reference already uses the same referee email, and 409 `MAX_REFERENCES_REACHED` (with `max`, 3) when the application already has 3 active references. `POST /api/portal/reference-submissions/:id/resend` re-sends the invite and returns 404 `NOT_FOUND`, 409 `INVALID_STATE` unless the reference is `invited` or `reminded`, 429 `REMINDER_LIMIT_REACHED` (with `max`, 5 reminders) and 429 `RESEND_COOLDOWN` (with `retryAfterMs`; 24 hours between resends); each resend is audited as `reference_submission.portal_resend`. The referee uploads a file answer with `POST /api/portal/reference-submissions/media` (portal key plus multipart `token`, `fieldKey` and `file`; JPEG or PNG, up to 5 MB): 201 returns `url`, `filename` and `mediaId`, and the failures are 400 `VALIDATION_ERROR`, 404 `INVALID_TOKEN`, 409 `ALREADY_COMPLETED`, 409 `INVALID_STATE` (revoked or expired), 410 `TOKEN_EXPIRED` and 415 `UNSUPPORTED_MEDIA_TYPE`. `POST /respond` returns the same 410 `TOKEN_EXPIRED` when the token is past its expiry.

Two further codes: `NO_TEMPLATE_AVAILABLE` (422) is returned by `POST /api/reference-submissions` and `POST /api/portal/reference-submissions` when no active template exists for the reference type and no `templateId` was passed, and the applicant-owned portal routes (create, replace, resend, the submission PDF and the application list) return 403 `APPLICANT_HEADER_REQUIRED` when no applicant user id was resolved on the request.

Hub-side initiation, outreach, and staff authoring routes. The create / remind / dispatch-log writes are intentionally portal-reachable under a portal.read scope with no requirePermission gate (no reference permission key gates them; the only reference permission is `references.review`, which gates staff disposition and the PDF routes below) — do not "harden" these without an owner-scoped RBAC change.

Staff disposition of a completed submission is the gated exception: `POST /api/reference-submissions/:id/review` (body `decision` of `approved` or `rejected`, `notes` required when rejecting, optional `forceReason`) requires the `references.review` permission on top of the portal.read scope. It returns 404 `NOT_FOUND` for an unknown submission, 400 `VALIDATION_ERROR` (with `details`, the flattened validation error; a rejection without `notes` appears under `fieldErrors.notes`) for a body that fails validation, which is checked before the submission is looked up, and 409 for `INVALID_STATE` (only a `completed` submission can be reviewed; the body carries the current `state` and a message), `ALREADY_DECIDED` (the same decision is already recorded; the body carries `reviewStatus`), `FORCE_REASON_REQUIRED` (rejecting a previously approved reference without `forceReason`) and `REVIEW_CONFLICT` (the guarded update matched no row because the review changed concurrently). These refusals use a top-level `error` string rather than the `error.code` envelope. Re-deciding an already approved or rejected reference is treated as an override and is allowed: moving rejected to approved needs no reason, while moving approved to rejected needs `forceReason`. The same key gates `GET /api/reference-submissions/:id/pdf` and the reference-template PDF routes `GET /api/reference-templates/:id/pdf` and `/:id/pdf/:versionId`. On `/pdf`, `:id` is the template id or its `templateKey` (the id is tried first); on `/pdf/:versionId` it must be the template id. `/pdf` streams the latest stored blank version (highest version number) (a legacy template with no stored version gets its first one rendered on demand, under an advisory lock so concurrent first reads create only one) and `/pdf/:versionId` streams one stored version. Both honour `?disposition=attachment` (inline otherwise) and answer a non-disclosing 404 for an unknown template or version, including a version id that belongs to a different template.

**Reads and staff completion.** `GET /api/reference-submissions` lists submissions (query `status`, `applicationId`, `limit`, `offset`) and `GET /api/reference-submissions/:id` returns one, 404 `NOT_FOUND` for an unknown id; both sit under the `portal.read` scope with no separate permission. `POST /api/reference-submissions/:id/complete` records a referee's responses on their behalf under the same scope, through the same completion service as the referee link; it returns 404 `NOT_FOUND`, 409 `ALREADY_COMPLETED`, 409 `INVALID_STATE` for an expired or revoked submission, and 400 `VALIDATION_ERROR` when the answers fail the template.

**Hub tables.** `reference_template_pdf_versions` keeps one row per blank-template PDF rendered by the template Save as PDF action (`template_id`, `version_number`, and the bare GCS object path in `file_url`, never a signed URL, plus file name, content type and size). `application_reference_facts` holds the normalised workflow facts materialised from referee answers on questions that declare a `workflowOutput` (`fact_key`, `value`, `value_type`, source submission, template and question provenance, `effective_at`). Rows are never deleted: a superseding submission or review decision stamps `superseded_at` on the prior rows, and the workflow condition evaluator reads only active rows. `reference_submission_media` holds the stamp and signature images a referee uploads (`submission_id`, `field_key`, internal `gcs_path`, file metadata), served only through the authorised proxy `GET /api/portal/reference-submission-media/:id/file`.

## Deprecated Local State

*Status: historical. The current PPPToo BFF has no database; none of the tables or code paths named in this section exist there. References are proxied to Globus by `references.controller.ts` and `applicant-references.controller.ts` in `server/src/references/`.*

These Applicant Portal tables are deprecated under the stateless model and are flagged for removal. Globus Hub is the sole source of truth for the data they mirrored.

| Table | Why deprecated |
| --- | --- |
| reference_templates | Was populated via upsertReferenceTemplateFromGlobus; under the stateless model the Portal resolves templates at render time from Globus and stores nothing locally |
| reference_template_mappings | Bound program steps to local template IDs; no longer needed because template resolution is proxied directly to Globus |
| reference_submissions | Tracked submission state locally with a globusReferenceId FK; under the stateless model the Portal proxies submission creation and status reads to Globus with no local row |

Code paths in the Applicant Portal that still write to the deprecated tables (to be removed):

- `references.ts` — submission creation writes to reference_submissions; template resolution writes to reference_templates / reference_template_mappings
- `applications.ts` — may trigger reference template upsert on application state transitions
- `DynamicFormContainer.tsx` — may read from local reference_templates for form rendering instead of proxying to Globus
- `integrationFallbackService.ts` — contains fallback logic that reads/writes local reference state when Globus is unreachable (entire fallback pattern is deprecated under stateless model)

## Follow-Up Engineering

> **Removal is owning-repo work**
>
> Identifying and flagging the deprecated tables and code paths is documentation work and lives here. The actual removal of the local tables and the listed code paths is engineering work on the Applicant Portal repo (source branch decouplewebhooks) and must be filed as a follow-up ticket against that repo — not as a task on this docs-site repo.
