Skip to content

Runtime & designer

This page covers what happens when a wizard runs: how the definition is stored, how a step’s SQL is resolved and executed, the HTTP surface the runner calls, and the visual designer that authors it.

A wizard definition is a WizardForm row in the Wizard.Forms table — Name, Slug, Label, Schema, the full FlowXml, a VersionHash, and the storage fields below.

Submitted runs go into one physical table per wizard, Wizard.Resp{Name} — not a shared table. Each row holds the collected values as JSON in ResponseData, plus a real, indexable column for every field the author marked Indexed.

Wizards are not part of the model-migration pipeline. The build does not copy *.wizard.xml into bin/models; instead you import the XML through the designer (or POST /save) and it is persisted to the database. Keep the XML in source control as the reference copy.

Field on Wizard.Forms Meaning
ResponseTable The physical table name, e.g. RespNewProductRequest. Derived from Name on first save and then frozen.
SchemaHash Short (6 lowercase hex) hash of the table’s shape — its field set and indexes.
ProvisionedSchemaHash The shape last successfully applied to the database.
ProvisionState Pending | Ready | Failed.
ProvisionError Why the last attempt failed, or null.

Because the table holds submitted data, its name is never changed once set — and therefore Name becomes immutable as soon as the table exists. A save that tries to change it is rejected with an explanatory error. Label and Slug stay freely editable; Label is what users see.

Deleting a wizard is a soft delete, and the wizard keeps its table and the responses in it. Saving or importing a wizard with the same Name again, with no Id, revives that deleted wizard with its original response table, so earlier submissions come back with it. A different wizard whose name happens to produce the same table name gets a numeric suffix instead (RespPMChecklist2).

Ids come from a single shared sequence, Wizard.ResponseIds, so a response id identifies exactly one row across every table — which is what lets a workflow instance keep pointing at a response by id alone.

Applying a definition’s shape to the database is queued, not inline: a designer save returns immediately and a background worker (WizardStorageWorker) does the DDL, recording the outcome in ProvisionState. Creating the table and adding its projected columns is metadata-only and effectively instant; building an index over a table that already holds millions of rows is not, which is exactly why a save must not block on it.

The same worker sweeps every wizard once at startup, so the response tables and their generated views are reconciled on boot as well. It appears on the hosted-services dashboard as Wizard Storage Worker, with how many wizards each cycle applied and how many failed, and it starts only once model migration has finished.

DDL is gated on SchemaHash, so an unchanged wizard costs nothing: moving a node on the canvas or editing a label mints a new VersionHash but leaves SchemaHash alone, and no DDL runs.

One thing is applied outside that gate: the IsSyncable flag and trigger that warehousing reads by. They are not part of the definition, so a wizard whose fields never change would otherwise never receive them. They are tracked separately in Genie.ExecutedScripts as Wizard.Resp{Name}.Warehousing, which makes an already-installed table one row read per boot — and lets the engine redeploy the trigger body across every wizard without disturbing a single shape hash. Until that record exists, the table is not offered to the warehousing sweep at all, so the two background workers racing at startup cannot produce a read against a column that is seconds from existing.

Dialect Indexed field is stored as
SQL Server a non-persisted computed column, JSON_VALUE(ResponseData, '$.Field'), with an index on it
PostgreSQL an expression index directly on the jsonb payload — no extra column

The asymmetry is forced by the platforms: SQL Server cannot index an expression, so it needs the computed column; PostgreSQL can, and going that way avoids rewriting the table on every schema change.

Two hashes, answering two different questions:

  • VersionHash — which definition produced a response. A fresh 6-char hash is minted on every save that changes the FlowXml at all, and the XML is snapshotted into the shared Workflow.FlowVersions history (via IFlowVersionService). The designer’s History button lists versions and can load an old one read-only.
  • SchemaHash — which storage shape captured it. Derived only from the field set and the declared indexes, so it is stable across cosmetic edits. This is the DDL re-run trigger.

Every submitted response is stamped with both.

Once a wizard’s table is provisioned, Genie registers a Genie table view over it, named after the table (Resp{Name}), so submissions are browsable at /table/Resp{Name} with no hand-authored view and no extra endpoint. Going through an object view rather than a raw SQL view is what earns the response grid the whole object stack — RBAC verbs, ParameterSanitizer, row-level security, session parameters, filtering, sorting, paging and export.

The grid projects the indexed fields plus the response metadata (status, reference, both hashes, submitted/updated by and when), shows only the latest response in each edit lineage, and is scoped to the active company. Only indexed fields are searchable — a search over anything else would be a table scan, which is what declaring an index is meant to avoid.

The runner drives the node graph and calls back to the server whenever a step needs data or SQL. WizardExecutionService is the backend engine; it locates the relevant block in the FlowXml, substitutes variables, and runs the SQL.

Field values arrive keyed as NodeId__FieldName. Before executing any SQL, the service (PrepareVariablesForSql) builds the substitution scope:

  1. copies the supplied variables;
  2. adds a bare FieldName alias for each NodeId__FieldName (so @FieldName works);
  3. seeds every declared field/Startup variable that wasn’t supplied as DBNull (→ SQL NULL);
  4. injects session parameters (@SessionUserId, @SessionCompanyId, @SessionCartId).

Substitution replaces each @Token with a formatted literal (strings single-quote-escaped and quoted, booleans as 1/0, null/DBNull as NULL), longest key first so @SessionCompanyId resolves before @Session, matched on word boundaries so @Email won’t match inside @EmailAddress. See the substitution rules in Authoring a wizard.

POST /{id}/submit (WizardExecutionService.SubmitAsync) does the following, in order:

  1. moves any Attachment temp uploads to permanent storage;
  2. resolves the wizard’s ResponseTable (rejecting the submit if provisioning has Failed);
  3. transforms NodeId__FieldName variables to bare FieldName and inserts a row into Wizard.Resp{Name} (JSON ResponseData, Status from the Terminal’s SubmissionStatus, and both the current VersionHash and SchemaHash). Unlike the authored SQL blocks, this write binds real parameters rather than substituting text;
  4. runs <TerminalSql> if present;
  5. workflow side — mutually exclusive:
    • new run → if the Terminal declares a WorkflowName, starts that workflow via IWorkflowEngine.StartAsync(workflowName, "WizardFormResponse", responseId, formData);
    • continue → if the submission carries a FlowId (edit/resubmit), fires the resubmission transition on the existing instance via TransitionAsync and repoints it at the new response.

In both workflow cases the instance also records EntityTable — the schema-qualified table its EntityId points into. EntityType alone no longer identifies a table now that each wizard has its own, and it is this column that lets the task grids resolve the originating wizard in one hop.

Loading a prior response for edit is gated: only the latest response in a lineage (UpdatedTo == null) may be edited; superseded ones return 404. On resubmit, the prior response’s UpdatedTo is pointed forward to the new row, forming an immutable edit chain — guarded inside the UPDATE itself, so two concurrent edits cannot both claim to supersede the same response. A FlowId requires an UpdatedForResponseId, must reference an active instance bound to that WizardFormResponse, or the submit is rejected.

Because each wizard owns its table, a response id belonging to another wizard simply is not found — the ownership check is now structural rather than an explicit comparison.

All endpoints are under /api/v1/genie/wizard and require authentication ([Authorize]).

Method + route Purpose
GET /list List every wizard as a lightweight summary (id / name / slug / label).
GET /{key} Load a wizard by id, Name, or Slug (the runner’s entry call).
GET /{id}/response/{responseId} Load a prior response for edit mode (latest-in-lineage only).
POST /save Create or update a wizard from { Id?, Name, Slug?, Label, Schema?, FlowXml, Notes? } (upserts by Name).
GET /{id}/versions List saved versions, newest first (History picker).
GET /{id}/versions/{versionHash} Fetch one historical version’s XML (read-only load).
DELETE /{id} Soft-delete a wizard.
POST /{id}/options/{fieldName} Resolve a Select/Radio field’s OptionsSql (body = current variables).
POST /{id}/field-dataset/{fieldName} Searchable dataset for a ModalSelector/Select2 field ({ SearchText?, Variables? }).
POST /{id}/execute/{nodeId} Run an ExecuteSQL action node’s SQL (auto-advance).
POST /{id}/set-variable/{nodeId} Run a SetVariable SQL action and return the scalar.
POST /{id}/submit Submit the wizard: save the response, run terminal SQL, start/continue the workflow.

Responses use the standard SuccessDataResult<T> envelope. Field names are unique across the wizard, so the options/dataset/execute endpoints address a field or node without ambiguity.

GenieWizardRunner (driven by the useWizardRuntime hook) executes a wizard for an end user at /form/{slug} (a wizard is a multi-step form; legacy /wizard/{slug} still resolves). It:

  • loads the definition via GET /{key}, parses the FlowXml, and finds the start node;
  • walks the graph in an auto-advance driver — Startup, Decision and non-alert Action nodes run silently (calling execute/set-variable as needed); the loop stops on an Input step, an Alert, or a Terminal;
  • renders each Input step with the shared form field controls and layout (GenieField + the form layout engine), pointed at the wizard’s options/dataset endpoints, with a progress bar and Required-field validation;
  • shows a read-only summary of every visited step for review, then submits;
  • supports edit mode via responseId (supersede a prior response) and flowId (continue an existing workflow instance), re-walking only the branches the original submission took.

GenieWizardDesigner (route /wizard-designer) is the visual node-graph editor. It provides a drag-and-drop canvas with the five node types (Startup, Input, Decision, Action, Terminal), port-to-port link drawing, per-node property panels (fields, variables, action config, terminal handoff), and a raw-XML code editor — the model round-trips losslessly between the canvas and the <Wizard> XML. Saving posts to POST /save; the History icon button (next to the XML icon button) opens the version list. Saved wizards are listed by the registered object view at /object/wizard-definitions.

A toolbar search box finds any node or field by its name or label: matching is debounced, the matched text is highlighted in yellow, matching nodes get a highlight ring so they stand out when zoomed out, and a live match count sits beside the box. Press Enter to hop through the matches one at a time (Shift+Enter for the previous) — the canvas centres on each hit and marks the current one in a distinct colour. The canvas is tuned to stay responsive on large flows (hundreds of nodes) — drag and pan re-render only the affected nodes and links, and zoom goes down to 5% so a big flow fits on screen.

A selection-mode toggle sits beside the zoom controls: turn it on to drag a marquee over the empty canvas and multi-select nodes (Shift+click adds/removes one), then grab any selected node to move the whole group together; Delete removes them all. In selection mode the mouse wheel pans the canvas (Figma-style — Shift scrolls horizontally, Ctrl/⌘+wheel still zooms); outside it, the wheel zooms. Dragging with the middle mouse button grabs and pans the whole frame from anywhere (in either mode).

The right-hand pane (drag its left edge to resize it) is the same property grid as the report designer’s. A chip at the top names the selection — WIZARD for the settings shown when nothing is selected, LINK, the node’s type (STARTUP, INPUT, DECISION, ACTION, TERMINAL), or N NODES for a multi-selection — and every property is a two-column row under an uppercase category, all in one bordered box:

  • Wizard — Identity: Name, Slug, Label, Schema.
  • Link — Condition: Expression.
  • Node — Identity: Id (read-only), Label, Type; then per type — Input: Form Name, Submit SQL, and the Fields and Layout sections; Action: Action Type plus that type’s rows (Subject and Message, SQL, Target / Value Source / Static Value or SQL, or Custom XML); Terminal: Submission, Workflow handoff, SQL and Message rows, plus Storage & indexes and Indexes; Startup: Variables; Decision: Branch conditions (one row per outgoing link).

Each field in an Input node is a collapsible card whose body is its own grid — Identity (Name, Label, Type), Behaviour (Required, Disabled, Readonly and, for indexable types, Indexed), Value (Default, Mask), then Select (Control Type, Options SQL, static options) or Attachment (Max Size, Max Files, Accept). Flags are — (unset) / true / false selects; unset and false both leave the attribute out of the XML. List sections add rows from the Add button in their header.

A footer under the pane explains the focused property, starting on the selection’s first one. A full-width Delete button below the box removes the selected node, link or nodes. While viewing an old version from History, every editor is disabled and the add, remove and delete buttons are hidden.

Ctrl+Z steps back through your edits; Ctrl+Shift+Z or Ctrl+Y replays them. The toolbar carries the same two buttons, and each tooltip names what it would reverse. A drag is one step, a run of typing in one property is one step, and loading XML (the XML dialog, or an assistant Apply) is one step. Ctrl+Z inside a text box or the XML editor belongs to that editor. A dot on Save marks unsaved changes, and leaving the page with any asks first. Opening an old version from History starts a fresh stack, so an old version can never be undone into the live wizard.

When the AI Assistant module is on, attach the wizard from the composer’s + menu (Current wizard) and two tools scoped to this page become available:

  • wizard_get_definition reads the saved wizard under your own permissions, together with a condensed reference of the <Wizard> elements and attributes, so the model writes from the contract rather than guessing from what the open wizard happens to use.
  • wizard_apply_change proposes a complete rewritten <Wizard> document as an Apply card in the chat.

Clicking Apply loads the proposal onto the canvas as one undoable step; Ctrl+Z reverses it. From there it is an ordinary unsaved edit — review it, then Save.

Apply keeps your layout. Nodes that already exist stay exactly where they are on your canvas (position and size), and links keep their connection sides. A new node is placed beside the node that links into it, clear of every other node. A field added to a step that has an authored form <Layout> is appended to that layout as a full-width item, so it actually appears in the runner (a step with no <Layout> shows every field anyway). The flip side: asking the assistant to move existing nodes or tidy the canvas does nothing, so arrange nodes by hand. The XML dialog’s Load is not adjusted; it loads exactly what you paste.

  • The assistant never writes. Apply fills the canvas; saving stays your action. Each tool re-checks Update on the WizardDefinitions resource itself.
  • A proposal is validated before you see it. It must be a well-formed <Wizard> with a Name, known node types, unique node ids, links between existing nodes, and field names that are valid identifiers unique across the whole wizard — the same identifier rules provisioning enforces. Renaming a wizard whose response table exists is refused, as it is on Save. When the model gets it wrong, the validator’s message goes back to it and it corrects itself.
  • There is no data preview. Wizard SQL runs with runtime values substituted in, and some of it writes, so the model probes tables with execute_query instead of executing your SQL.
  • Apply needs this page open, and is refused while you are viewing an old version.
  • “Reply in chat” — or “don’t change the wizard” — answers in the conversation and withholds the apply tool for that turn.

The assistant reads the saved definition, so Save before asking if the canvas has changes you want it to see.