---
title: "Operational Forms and Form Templates"
slug: operational-forms
section: Placement & Matching
status: stable
description: "Hub storage and APIs for the two back-office form systems: Dynamic Forms (`form_templates`) and Operational Forms (`operational_form_definitions`)."
last-updated: 2026-10-07
---

# Operational Forms and Form Templates

Hub storage and APIs for the two back-office form systems: Dynamic Forms (`form_templates`) and Operational Forms (`operational_form_definitions`). This page does not cover rendering; see [Form Engine (form-engine-portable)](/docs/form-engine-portable) for the render-only engine and [Interviews & Screening](/docs/interviews-screening) for the screening context that consumes Operational Forms.

## Overview

The code lives in the BackOfficePrototype repo (Hub and back office). Dynamic Forms: `server/routes/forms.ts`, `shared/models/forms.ts`, storage in `server/storage/rbacStorage.ts`. Operational Forms: `server/routes/operationalForms.ts`, `server/services/operationalForms/` (`definitions.ts`, `submissions.ts`, `screeningBridge.ts`), `shared/models/operationalForms.ts`, and the snapshot contract in `shared/operationalFormContract.ts`. Users are back-office staff. The route comments state Operational Forms are never served to the Applicant Portal.

## Two form systems

| | Dynamic Forms | Operational Forms |
| --- | --- | --- |
| Catalog table | `form_templates` | `operational_form_definitions` |
| Versions | `form_template_versions` (integer `version`, `is_active` flag) | `operational_form_versions` (`lifecycle` draft, published, deprecated) |
| Responses | `form_submissions` (polymorphic `entity_type` + `entity_id`) | `operational_form_submissions` (tied to a screening activity projection) |
| Auth gate | `forms.read` (writes: `forms.read` and `admin.write`) API-key scopes plus `dashboard.view`, `users.manage` or `applications.edit` permissions | `dashboard.read` / `applications.write` API-key scopes plus `operational_forms.*` permissions |
| Consumer seen in code | not traced (see Known gaps) | screening interview sheets |

## Data model

### Dynamic Forms tables

- `form_templates`: `template_key` (unique), `name`, `description`, `context` (default `ops_config`), `status` (default `draft`), `created_by`.
- `form_template_versions`: `template_id` (FK, cascade delete), `version` (default 1; the create-version storage method overrides it with max existing version plus 1), `layout_type` (default `single_page`), `config_json`, `is_active` (default false), `effective_from`.
- `form_submissions`: `template_id`, `template_version_id`, `entity_type`, `entity_id` (no FK, polymorphic), `data_json`, `status` (default `in_progress`), `submitted_by`, `submitted_at`.

### Operational Forms tables

- `operational_form_definitions`: `key` (unique, snake_case, regex `^[a-z0-9_]+$`), `name`, `description`, `status` (draft, published, deprecated).
- `operational_form_versions`: `form_definition_id` (FK, cascade), `version_number`, `lifecycle`, `schema_snapshot` (jsonb, the whole form structure), `published_by_user_id`, `published_at`. Unique on `(form_definition_id, version_number)`; a partial unique index allows one `draft` row per definition.
- `operational_form_submissions`: `application_screening_activity_id` (FK to the per-application screening projection), `form_version_id` (pinned), `screening_attempt_id` (nullable until an attempt is recorded), `status` (in_progress, submitted, superseded), `responses` (jsonb keyed by field key), `calculated_results`, `started_by_user_id`, `submitted_by_user_id`, `submitted_at`, `superseded_by_submission_id`. A partial unique index allows one `in_progress` row per screening activity.

## Lifecycle

### Definition and version lifecycle

1. Create: inserts the definition (`draft`) and version 1 (`draft`) in one transaction. Without a `schemaSnapshot` a placeholder with one instruction field is used.
2. Save draft: updates the open draft in place. If none exists (head is published), inserts a new draft at max version plus 1. A unique-violation race re-reads and updates the surviving draft.
3. Publish: in one transaction the prior published version becomes `deprecated`, the draft becomes `published` (stamps publisher and time), and the definition becomes `published`. The draft snapshot is re-validated first.
4. Deprecate: sets the definition to `deprecated` only. Version rows are untouched and no version is deleted. Pinned submissions keep working.

### Submission lifecycle

- Start: resolves the screening activity, lazily creates the projection, pins the current published version, and stamps source-bound read-only fields (applicant name, application id, program name, interviewer name) server-side. If an open draft exists it is returned (HTTP 200, `created: false`); otherwise 201.
- Draft save: only on `in_progress`. Unknown keys are dropped, type errors reject (400 `VALIDATION_ERROR` with `details.fieldErrors`), requiredness is not enforced, source-bound values are kept. `calculated_results` is recomputed on each draft save.
- Submit: full validation, server-computed `calculated_results`, status `submitted`; in the same transaction every other `submitted` row for the activity becomes `superseded`. Afterwards a screening re-evaluation is enqueued best-effort and never fails the submit.
- Supersede (correction): allowed only on a `submitted` row; creates a new `in_progress` row with the same pinned version and copied responses. The original stays current until the correction is submitted.
- No route in this module edits a `submitted` row in place.

## Attaching to screening activities

`program_screening_activities.form_definition_id` attaches a form to a blueprint activity. The screening authoring code in `server/routes/screening.ts` accepts `formDefinitionId` per activity and returns VALIDATION_ERROR (400) if the form does not exist or its definition status is not `published`. Blueprint clone copies the attachment.

The gate: `PATCH /api/work-items/:id` (scope `applications.write`, permission `applications.manage`) on a work item that resolves a screening config, with a non-empty `outcome` that maps to an assessment outcome, returns 409 `FORM_SUBMISSION_REQUIRED` unless a `submitted` sheet exists for the activity (`assertScreeningFormGate`). If the application has no screening projection yet, there is no submitted sheet, so the gate blocks. `reschedule` and `no_show` outcomes, activities with no form, and work items without an application are not gated. If the gate lookup throws, the error is logged and the request is not blocked. When an attempt is recorded, the current submitted sheet is linked through `screening_attempt_id` and the attempt `form_version_id` is stamped, first write wins.

Seed and rules facts: `server/seed/operationalForms.ts` seeds one form, `teach_usa_sponsor_interview` ("Teach USA Sponsor Interview"), only when no definition with that key exists, and attaches it to the Teach USA program's published screening blueprint `sponsor_interview` activity on that same run. `server/services/operationalForms/facts.ts` maps the current submitted sheet of a `sponsor_interview` activity to screening rules facts (`screening.sponsor_interview.form_submitted`, `.english.total_score`, `.english.overall_level`, `.recommended_outcome`, `.assessment_confidence`) using the field keys `english_total`, `english_level`, `recommended_outcome`, `assessment_confidence`. These facts fill gaps only; a non-null attempt-store fact wins.

## Routes

Success bodies use the `createSuccessResponse` envelope. Each route uses `isAuthenticatedOrApiKey` with the scope shown, then `requirePermission`. A signed-in session passes the scope guard without a scope check; an API key must hold every scope listed.

### Operational Forms routes

| Verb and path | Scope / permission | Body | Success | Notes |
| --- | --- | --- | --- | --- |
| GET `/api/operational-forms` | `dashboard.read` / `operational_forms.view` | none | 200 `forms` | catalog with published and draft summary |
| POST `/api/operational-forms` | `applications.write` / `operational_forms.author` | `key`, `name`, `description?`, `schemaSnapshot?` | 201 `form` | audited |
| GET `/api/operational-forms/:formId` | `dashboard.read` / `operational_forms.view` | none | 200 `form` (definition and versions) | 404 NOT_FOUND |
| PATCH `/api/operational-forms/:formId` | `applications.write` / `operational_forms.author` | `name?`, `description?` | 200 `definition` | metadata only |
| PATCH `/api/operational-forms/:formId/draft` | `applications.write` / `operational_forms.author` | `schemaSnapshot` | 200 `version` | |
| POST `/api/operational-forms/:formId/publish` | `applications.write` / `operational_forms.publish` | `reason` (min 5 chars) | 200 `definition`, `version` | |
| POST `/api/operational-forms/:formId/deprecate` | `applications.write` / `operational_forms.publish` | `reason` (min 5 chars) | 200 `definition` | |
| GET `/api/applications/:applicationId/screening/:activityKey/form` | `dashboard.read` / `operational_forms.view` | none | 200 fill context (`attached`, `definition`, `version`, `currentSubmission`, `submissions`) | read-only, never creates the projection; 404 NOT_FOUND if the program has no such activity |
| POST `/api/applications/:applicationId/screening/:activityKey/form/submissions` | `applications.write` / `operational_forms.submit` | none | 201 new, 200 resumed | `submission`, `version`, `created` |
| PATCH `/api/operational-form-submissions/:submissionId` | `applications.write` / `operational_forms.submit` | `responses` object | 200 `submission` | |
| POST `/api/operational-form-submissions/:submissionId/submit` | `applications.write` / `operational_forms.submit` | none | 200 `submission` | |
| POST `/api/operational-form-submissions/:submissionId/supersede` | `applications.write` / `operational_forms.submit` | none | 201 `submission` | |

Mutating routes are wrapped in `auditMutation`; audit actions are `operational_form.created`, `operational_form.meta_updated`, `operational_form.draft_saved`, `operational_form.published`, `operational_form.deprecated`, and `applicant.screening.form_started`, `form_draft_saved`, `form_submitted`, `form_superseded`.

### Dynamic Forms routes

All in `server/routes/forms.ts`. Reads use scope `forms.read` plus any of the permissions `dashboard.view`, `users.manage`, `applications.edit`. Template and version writes use scopes `forms.read` and `admin.write` (both, for API keys) plus `users.manage`. Submission writes use the same two scopes plus `applications.edit`.

| Verb and path | Permission | Behaviour |
| --- | --- | --- |
| GET `/api/form-templates` | read set | optional `context` query; paged wrapper |
| GET `/api/form-templates/:id` and `/api/form-templates/key/:templateKey` | read set | 404 if absent |
| POST, PATCH, DELETE `/api/form-templates[/:id]` | `users.manage` | create returns 201; delete removes the template; audit log written |
| GET `/api/form-templates/:templateId/versions` and `/versions/active` | read set | 404 if no active version |
| POST `/api/form-templates/:templateId/versions` | `users.manage` | 201; `configJson` is checked for info-field presentation (`alert` or `document` only), else 400 |
| POST `/api/form-templates/versions/:versionId/activate` | `users.manage` | deactivates all versions of the template, activates this one, sets `effective_from` |
| GET `/api/form-submissions` | read set | `entityType` and `entityId` query params required, else 400 |
| GET `/api/form-submissions/:id` | read set | 404 if absent |
| POST and PATCH `/api/form-submissions[/:id]` | `applications.edit` | the handler checks no status or immutability |

## Permissions

Defined in `server/seed/rbac.ts`: `operational_forms.view` and `operational_forms.submit` (category applicants), `operational_forms.author` and `operational_forms.publish` (category programs; publish has `requiresReason: true`). None is restricted. `SYSTEM_ADMIN_PERMISSIONS` is built from all permission keys. In the seed, the arrays `COMPLIANCE_OFFICER_PERMISSIONS`, `COMPLIANCE_ADMIN_PERMISSIONS`, `PROGRAM_ADMIN_PERMISSIONS` and `APPLICANT_MANAGER_PERMISSIONS` list view and submit; `PROGRAM_MANAGER_PERMISSIONS` lists all four; `READONLY_PERMISSIONS` lists view only. Other role arrays were not checked.

## Error codes

All Operational Forms codes below are raised with the `conflict()` helper: HTTP 409 (per the CONFLICT mapping in `server/middleware/apiError.ts`), top-level `error.code` is `CONFLICT`, and the specific code is in `details.code`.

| `details.code` | Raised when |
| --- | --- |
| `DUPLICATE_FORM_KEY` | create hits a unique violation on `key` (details: `key`) |
| `FORM_DEPRECATED` | draft save on a deprecated definition |
| `NO_DRAFT_TO_PUBLISH` | publish with no `draft` version |
| `FORM_ALREADY_DEPRECATED` | deprecate on an already deprecated definition |
| `NO_PUBLISHED_BLUEPRINT` | start submission when the program has no published screening workflow for the activity (projection not created) |
| `FORM_NOT_ATTACHED` | start submission when the activity has no `form_definition_id` |
| `FORM_NOT_PUBLISHED` | start submission when the attached definition has no published version |
| `SUBMISSION_NOT_EDITABLE` | draft save or submit on a row that is not `in_progress` (details: `status`) |
| `SUBMISSION_NOT_SUPERSEDABLE` | supersede on a row that is not `submitted` |
| `CORRECTION_ALREADY_OPEN` | supersede when an open draft already exists (unique violation) |
| `DRAFT_WRITE_CONFLICT` | lost a concurrent write on draft save, publish, start, submit or supersede |
| `FORM_SUBMISSION_REQUIRED` | work-item assessment outcome without a submitted sheet (details: `formDefinitionId`, `activityKey`); raised from `PATCH /api/work-items/:id` |

Validation failures use top-level `VALIDATION_ERROR` (400): bad create payload or snapshot (`details.issues`; keys must match `^[a-z0-9_]+$`), snapshot key not matching the form key, missing reason, invalid answers (`details.fieldErrors`), and a draft that fails structural validation at publish. An unknown form, version, submission, or screening activity returns `NOT_FOUND`.

## Configuration

No environment variables were found in the Operational Forms or Dynamic Forms route and service files.

## Known gaps

- The `FORM_DEPRECATED` message says to restore the form before editing, but no restore or un-deprecate route exists in `operationalForms.ts`.
- The publish and deprecate `reason` is validated and written to the audit log; the services do not store it on a table row.
- No Dynamic Forms publish or deprecate workflow was found in the routes, only version activation.
- `PATCH /api/form-submissions/:id` accepts any partial body; no editing rules are enforced for Dynamic Forms submissions.
- Which Dynamic Forms `context` values exist and which UI consumes them was not traced.
