---
title: "Form Engine (form-engine-portable)"
slug: form-engine-portable
section: Portals
status: stable
description: "@alliance-strategies/form-engine-portable (repo Alliance-Strategies/form-engine-portable, published to GitHub Packages; both consumers currently pin v2.47.1) is the single shared React engine that renders every dynamic applicant form acr…"
last-updated: 2026-10-07
---

# Form Engine (form-engine-portable)

@alliance-strategies/form-engine-portable (repo Alliance-Strategies/form-engine-portable, published to GitHub Packages; both consumers currently pin v2.47.1) is the single shared React engine that renders every dynamic applicant form across the ecosystem. It is consumed as a compiled package — the engine is never re-implemented per portal. Each portal implements the engine's consumer contract through a thin data wrapper: auth, routing, and persistence stay in the portal; rendering, field dispatch, validation, conditional logic, and the composite widgets live here.

See also: PPPToo — Participant Portal v2 , Globus Portal API Reference .

## What It Is

The engine takes a served step configuration (fieldsConfig from Globus's step library) plus the step's persisted values, and renders the complete form: 24+ field types, tabbed / multi-step / sectioned layouts, conditional show-hide, per-field and step-level validation, and the built-in composites (Skills & Hobbies v4/v5, Showcase Yourself, granular skills controls, Additional References). Configuration is authored once in Globus workflow tooling and rendered identically in the applicant portal and the back-office staff view.

## Render-Only Architecture

The engine is deliberately render-only. Three hard rules define the boundary:

- It never posts bytes. File selection hands the raw File to the consumer's onUploadDocument; only the returned reference is persisted into form state. Uploads fail loud if no uploader is supplied.
- It never knows URLs. Server interaction goes through named logical operations dispatched by the data boundary; consumers remap operations to their own routes.
- It never persists. onSave / onComplete / onAutoSave receive the final step data through a single write chokepoint (which also folds Additional References entries); the consumer owns storage.
Config ingestion is normalized at a single chokepoint (normalizeFieldsConfig, v2.17.0): sections declared as fieldKeys arrays are resolved to real field objects and every field is stamped with name/id identity, so served schema variations can no longer blank a form.

## The Consumer Contract

A portal implements three surfaces. Everything else is engine-internal.

### Data Boundary & Endpoint Registry

Call sites invoke logical operations (aiTextReview, extractCertificate, uploadPhoto, …) from the generated contract (GLOBUS_CONTRACT, ~50 operations) via useDataOperation(). Consumers wire an adapter (createGlobusAdapter / createHttpAdapter) with a createEndpointRegistry route map to remap operations and inject auth headers. An unwired consumer falls back to a same-origin Globus adapter, so Globus-hosted embedding works with zero configuration. Two envelope rules are load-bearing: extraction responses must be a single success/data envelope (the adapter does not unwrap; double-wrapping makes auto-fill silently no-op), and upload doors return flat bodies (the widget spreads the result into the field value).

### Host Callbacks (Persistence & Bytes)

| Prop | Contract |
| --- | --- |
| onSave / onComplete / onAutoSave | Receive the final step data (references folded, uploads already swapped for stored ids). The consumer persists; StepValidationError from onComplete maps back to per-field errors. |
| onUploadDocument(file, ctx) | Mandatory for any upload UI. Consumer stores the bytes and returns a reference (id string or url/filename object); the engine writes only the reference. |
| registry.passportExtractionFactory | Built via createPassportExtractionFactory(transport): consumer supplies the HTTP transport; the engine owns the canonical field mapping and loading states. |
| registry.proofExtractionFactory | Built via createProofExtractionFactory(transport, options) — new in v2.17.0. Invoked by AdvancedFileUpload on proof-of-name-change uploads. |
| handleCertificateExtraction | Exists as a prop, but certificate extraction is ADAPTER-driven by design (the extractCertificate operation), not factory-driven. |

### Override & Injection Slots

| Prop | Purpose |
| --- | --- |
| fieldComponents | Per-field-type renderer registry; a registered component beats every built-in switch case. The extension point for bespoke types. An override replaces the engine's ENTIRE built-in renderer for that type, so it must honor any engine contract for the type itself (e.g. the info-field presentation contract below). |
| skillsHobbiesV4Component / V5Component / showcaseComponent | Whole-composite overrides. Since v2.15/2.16 the engine ships real built-ins, so these are only for portal-specific needs (e.g. PPPToo's Bearer-authenticated Showcase thumbnails). |
| registry.customComponents | Whole-step takeover keyed by shouldRender(stepId) — used by the Globus composite bridge. The bundle matches on the TOP-LEVEL stepId prop, so takeover mounts must pass both stepConfig and stepId. |
| fieldPermissions | readOnlyFields / hiddenFields enforced at render time (v2.17.0) — replaces consumer-side DOM patching for served _fieldPermissions envelopes. |
| additionalReferences | Auto-enabled repeater on reference steps; pass false to opt out or an object to change the entry cap (default 3). |
| noValidationSteps + enforceRequiredOnComplete | Disable bundle validation for partial per-field saves, then re-assert unconditional required fields at the COMPLETE chokepoint only (v2.17.0). |

## What Shipped in v2.17.0

v2.17.0 (2026-07-06) upstreamed the form behaviors that previously lived only in the Globus wrapper, so every consumer gets them from the package:

| Behavior | Detail |
| --- | --- |
| Additional References repeater | Engine-native on reference steps: hydrate from _additional_references, add/remove up to 3 entries, folded into EVERY save/complete payload. For reference steps the array is authoritative — an empty array clears the persisted list, never resurrects it (Globus Fix #804 semantics). |
| Canonical Skills & Hobbies validation | 3-5 skills each with a level, hobbies AT LEAST 4 (was 2-6, contradicting the canonical schema), custom skills need a typed name, and Certified-level skills REQUIRE a certification document — schema-driven overrides via the step's validation block. |
| AI review in the composite | Review-my-description for skill notes and the hobbies reflection, via the aiTextReview data-boundary operation with an envelope-tolerant parser (parseAiReviewResponse) — an empty review can never present as complete. |
| Config normalizer | normalizeFieldsConfig at ingestion: fieldKeys sections resolved, name/id stamped — previously blanked skills_hobbies_v4/v5 schemas. |
| Proof-of-name-change factory | createProofExtractionFactory export; AdvancedFileUpload invokes registry.proofExtractionFactory on proof uploads. Globus routes the companion door (name_change_extraction capability). |
| Showcase reconciliation | Tolerant hydration of the Globus photos-array shape plus data-driven moderation badges (approvalStatus / rejectionReason) on thumbnails. |
| fieldPermissions + enforceRequiredOnComplete | Render-time field permissions and the optional required-fields gate at completion. |

> **Why v2.17.0 matters organizationally**
>
> Before v2.17.0, several of these behaviors existed only inside the BackOfficePrototype wrapper — a fix verified in Globus was silently absent from the participant portal. The rule going forward: generic form behavior (rendering, validation, value shapes, folding) belongs in the engine; wrappers carry only auth, routing, and persistence.

## Consumer Matrix

| Consumer | Version | Wrapper | Notes |
| --- | --- | --- | --- |
| BackOfficePrototype (Globus) | 2.47.1 (exact pin; adopted 2.17.0 in PR #1324) | client/src/components/portal-form-engine.tsx + data-boundary-wiring.tsx | Staff edit-reason dialog, Bearer+CSRF headers, upload doors, aiTextReview remap, passport + proof factories, and a `PortalRichTextField` renderer registered under `PORTAL_RICH_TEXT_TYPE` via `fieldComponents` (caller-supplied `fieldComponents` are spread after it, so a caller entry for the same type wins). Unless the caller supplies a `textarea` override, the wrapper passes the step's `fieldsConfig` through `preparePortalRichTextFields`. The composite BRIDGE still takes over composite-only steps in review modes with local V4/V5 components (deliberate). |
| PPPToo (Participant Portal v2) | 2.47.1 (exact pin in client/package.json) | StepForm in ApplicationPage.tsx + lib/portal.ts | BFF document routing, IndexedDB drafts, offline queue; keeps a Showcase injection for Bearer-authenticated thumbnails and an 'info'/'info_text' override (InfoFieldWithTemplateDownload) for authenticated template downloads that mirrors the engine's presentation contract and runs the authored `content` HTML through DOMPurify before rendering. No repeater / validation / normalizer code of its own — all engine-provided. |
| ParticipantPortalPrototype (legacy) | ^2.11.4 | n/a | Reference-only; out of scope for new work. |
| HostPortalPrototype | not a consumer | n/a | Does not use the form engine. |

## Canonical Globus Doors

The routes a consumer's wrapper (or the default adapter) actually calls. The known-good, drift-guarded map lives in Globus at server/contracts/formEngineUploadContractMap.ts.

| Purpose | Door |
| --- | --- |
| Step submit (applicant portals) | POST /api/portal/steps/:stepKey/submit with applicationId + stepData in the body (integration auth). WARNING: the application-nested variant /api/portal/applications/:id/steps/:key/submit is a PHANTOM — it has never existed; PPPToo called it until 2026-07-06 and every step save 404'd. |
| Staff step save / complete | POST /api/applicants/:applicantId/field-edits/batch and POST /api/applicants/:applicantId/steps/:stepKey/complete (validates the client-submitted stepData, descends into _additional_references). |
| Photo / main-document upload | POST /api/upload-photo and POST /api/upload-criminal-background (flat response bodies). The criminal-background endpoint is being repointed internally at the parent/child document-package service — contract unchanged for consumers; see Criminal Background Translation . |
| Extractions | POST /api/extract-passport-data, /api/certificate-extraction, /api/extract-birth-certificate-data — each returns a SINGLE success/data envelope with flat field names. |
| AI text review | aiTextReview operation; Globus remaps it to POST /api/openai/review-text, PPPToo to its BFF AI routes. |
| Portal file upload | POST /api/integrations/applicant-portal/files (multipart) and POST /api/integrations/applicant-portal/files/base64 (body files[] of portalUrl, base64, contentType). Integration auth with the `integrations.portal` scope, portal-sync gate and per-applicant rate limit. The base64 door skips entries missing a field or with a `text/html` content type, and returns `received` and a `files` list of portalUrl, size and gcsUrl. |
| Document language analysis | POST /api/analyze-existing-document: session auth with `applicant.manage`, `applications.edit` or `document.verify`. Body needs `documentType` (a registered type) and one of fileUrl/imageUrl or fileBase64/imageBase64, otherwise 400; a failed extraction is 502. Returns `detectedLanguages`, `primaryLanguage`, `ocrEmpty` and `confidence`. Registered defensively as the data-boundary fallback so the bundle's analysis error handler does not swallow a 404. |
| AI text enhance | POST /api/integrations/applicant-portal/ai/text-enhance (`integrations.portal` scope, applicant id header required): body `text` (required string, size-capped, else 400) and optional `context`. |
| Host-delegated contract paths | The bundle declares POST /api/criminal-background/extract-date, /api/criminal-background/translate and /api/extract-proof-of-name-change-data, but its adapter never calls them (extraction and translation run through host callbacks), so Globus deliberately leaves them unrouted; the guard test fails if one gains a route. |

## Invariants & Field-Level Gotchas

- Phone fields store objects. Type phone persists countryCode + number as an object — a flat +353… string renders as an empty widget at the +1 default. Country-code keys differ by consumer lib (ISO in the engine stub, dial-code keys in Globus).
- portal_step_library is the served config in Globus; seed edits are masked when a portal row exists. Only file_upload_advanced fires document AI extraction; plain file_upload is upload-only.
- One document per type per application (Globus unique constraint) — a multi-photo gallery cannot go through the generic documents door; Showcase photos need a dedicated path.
- Responsive variants are dead inside step widgets — use container-based grids (repeat/auto-fit/minmax), never md:/sm: breakpoint classes.
- Value shapes are contracts. Skills & Hobbies persists selected_skills (flat array with name/level/category), selected_hobbies, hobbies_reflection + a validation blob; Showcase persists photo_gallery / photo_captions / video_introduction / file_upload (Globus legacy photos-array shape is hydrated tolerantly).
- Info field presentation contract. An 'info' / 'info_text' field renders in one of two modes chosen by the authored `presentation` value, compared with strict equality: `presentation: "document"` renders undecorated document output (no card background/border/padding, no leading icon, a semantic h3 label, structured HTML content styled by the engine); omitted, `"alert"`, or any unknown value renders the legacy alert card (the default). The rendered wrapper carries `data-info-presentation="document|alert"`. The value is used exactly as authored by the Globus field configuration — never inferred from field name, id or step key. A consumer that overrides the info type via fieldComponents (PPPToo does, to add authenticated template downloads) must reimplement this contract itself, since the override replaces the whole renderer. Agreement body fields only render as documents once the served field configuration actually carries `presentation: "document"`; until then they keep the alert fallback.
- Declared length bounds are enforced server-side in Globus, not by the engine. A step-library free-text field's minWords / maxWords (on the field or under its validation / props) and the older minLength character-count convention are enforced by BackOfficePrototype (shared/stepFieldWordLimits.ts) at the portal step-submit gate (POST /api/portal/steps/:stepKey/submit, 422 with per-field errors keyed stepData.<path>) and the staff step-complete gate; the widgets only display a counter. Only filled-in boxes are checked (empty is the required-field contract's job), hidden fields are skipped, fields the staff caller cannot edit are exempt, and on an already-completed step unchanged text is grandfathered (editing it re-arms the bound; repeater entries match by itemId). minLength is measured on the raw untrimmed length, matching the renderer's lengthBounds. maxLength is enforced by this gate only on multi-line free-text boxes (`textarea`); single-line fields that declare maxLength (phone, zip, passport number) keep the renderer's hard input cap only.
- Namespace-import safety: consumers access newer-only exports defensively (a static named import of a missing export white-screens an older installed bundle).
## Release History

| Version | Highlights |
| --- | --- |
| v2.17.0 (2026-07-06) | Upstreamed Globus form fixes: references repeater, canonical skills validation + mandatory certs, AI review, config normalizer, proof factory, showcase badges, fieldPermissions, enforceRequiredOnComplete. |
| v2.16.0 (2026-07-03) | Every remaining render-only stub resolved: granular skills controls, Showcase composite, PhotoWithStatusBadge, SectionLockMessage, toast bridge. |
| v2.15.0 (2026-07-03) | Built-in Skills & Hobbies composite (v4/v5) replaces the empty stub; useCertificationUpload. |
| v2.13.1 - v2.14.0 (2026-07-01) | Consumer-injectable fieldComponents registry; upload/onSave smoke fixes. |
| v2.11.x - v2.12.1 (2026-06-30/07-01) | Array-value conditional logic; embassy searchable_select always-refresh country autofill. |
| v2.10.x (2026-06-29) | P1.2 form-control fixes; extraction factory wiring; embassy searchable_select; calendar/display render fixes. |
| v2.9.0 (2026-06-26) | Generic file-upload references persisted into step_data. |

## How to Ship a Change

Merging to main does not publish. The package publishes only when a v* tag is pushed, and consumers install from GitHub Packages — a fix that is merged but untagged is not running anywhere.

- Feature branch; build the bundle (vite portable config); unit tests + the harness verifies (render, writepath, upload, certification, adapter, seed).
- PR to main (1 approving review required); merge.
- Bump package.json, push the v* tag, wait for the publish workflow (~3-5 min).
- Bump each consumer and deploy; verify the deployed service restarted before closing the task.
> **Known CI quirk**
>
> The repo commits its built dist/ bundle, and the slopgate AI gate scans it — release commits can flag false positives (precedent: multiple releases merged with slopgate noise on dist-only diffs). Local Node 24 breaks two browser-DOM verify harnesses; publish CI runs Node 20 and is unaffected.
