---
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, current release v2.17.0) is the single shared React engine that renders every dynamic applicant form across the ecosy…"
last-updated: 2026-07-04
---

# Form Engine (form-engine-portable)

# Form Engine (form-engine-portable)

@alliance-strategies/form-engine-portable (repo Alliance-Strategies/form-engine-portable, published to GitHub Packages, current release v2.17.0) 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. |
| 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.17.0 (adoption 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. The composite BRIDGE still takes over composite-only steps in review modes with local V4/V5 components (deliberate). |
| PPPToo (Participant Portal v2) | ^2.17.0 (deployed dev) | StepForm in ApplicationPage.tsx + lib/portal.ts | BFF document routing, IndexedDB drafts, offline queue; keeps a Showcase injection for Bearer-authenticated thumbnails. 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). |
| Extractions | POST /api/extract-passport-data, /api/certificate-extraction, /api/extract-birth-certificate-data, /api/extract-proof-of-name-change-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. |

## 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).

- 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.
