Skip to content

Flow Interface Builder ​

The Flow Interface Builder is a visual designer for a flow's run form. Authors compose a React Flow graph of section and field nodes connected by semantic edges; operators never see the graph — they see a normal, sectioned form rendered from a compiled contract on Start Run and Save as Schedule.

Migration required: apply database revision 20260607_016_run_interface_inputs before enabling this feature in an environment. Run the standard migration task:

task db:migrate

The builder persists submitted/resolved interface input provenance on runs, and those audit columns are added by this migration.

Authoring model ​

Open a flow in the editor and switch to the Form tab.

  • Sections group fields into cards. Sections and fields are laid out by canvas position: nodes sharing a horizontal band form one row (split evenly), rows read top-to-bottom, left-to-right.

  • Fields declare a field_id (the {{ inputs.<id> }} name), a type, a label, optional default/description, and validation rules (AND/OR/NOT rule trees with per-rule or per-group message overrides).

  • Edges carry one or more semantic relations:

    RelationEffect
    options_filterSource field's value filters the target's options (cascading selects)
    visible_whenTarget is shown only when the source condition holds
    enabled_whenTarget is editable only when the source condition holds
    required_whenTarget becomes required when the source condition holds

    Only these four relation types exist. Stored graphs containing retired types still load — the compiler ignores those relations and reports an UNSUPPORTED_EDGE_RELATION warning.

Built-in field types and option sources ​

Field types: core.text, core.number (int/float subtypes), core.bool, core.enum, core.multi_enum, and inventory.device.

inventory.device fields are device targets: their submitted value routes to the run's related_targets (the device-targeting path) instead of inputs, and each device field owns a target role (defaulting to its field id). Roles derived from the form win over settings-authored roles of the same name.

Option sources:

KindOptions come fromFilter keys (cascading or fixed)
core.statica hard-coded list on the field—
inventory.devicesinventory devices (searchable)site_id, provider_id, platform, maintenance_state, sync_status, tag.*
inventory.sitesinventory sites (searchable)parent_id, provider_id, tag.*
inventory.providersconfigured inventory providersprovider_type, enabled_only
inventory.objectssynced inventory objects of one type - prefixes, VLANs, whatever the installed plugins register (searchable)provider_id, sync_status, attr.<attribute>
attachment.csva CSV-style file attached to the flowany CSV column name

Filter values may be lists (a multi-select or multi-device parent): they match any of the submitted entries (OR semantics). inventory.providers lists only enabled providers unless a fixed enabled_only: false filter opts in, and it always offers the built-in local provider alongside configured ones.

maintenance_state accepts active, maintenance or disabled, so a form can offer only devices the source of truth still considers in service. It is a filter on the picker, not a gate on the run: nothing stops a run whose targets were chosen some other way.

Both registries are extensible by Python packages via the hegemony.flow_interface entry-point group; a plugin exposes register(registry) and may add field types and option sources.

attachment.csv — options from a file ​

A select/multi-select field can draw its options from a delimited file uploaded to the flow (Attachments tab). The field's option_source.config names the file and the columns:

jsonc
"option_source": {
  "kind": "attachment.csv",
  "config": {
    "filename": "data/locations.csv",
    "value_column": "code",          // optional; defaults to the first column
    "label_column": "datacenter",    // optional; defaults to value_column
    "description_column": "region",  // optional
    "delimiter": ",",                 // optional; defaults to ","
    "has_header": true                // optional; defaults to true
  }
}

Rows are de-duplicated by value (so a column reused as a parent select yields distinct choices), then filtered by search and the limit. The file is read version-consistently: a run pinned to a committed flow version reads that version's attachment snapshot, the live designer reads the current draft. Files are UTF-8 text capped at 1 MB (the attachment limit).

Authoring-time checks warn (never block) when the config is incomplete or stale: a missing filename at compile time (OPTION_SOURCE_CONFIG), and — via POST /flows/{id}/interface/validate — a file that doesn't exist on the flow (ATTACHMENT_NOT_FOUND), configured columns absent from its header (CSV_COLUMN_UNKNOWN), or a cascading/fixed filter key that isn't a column (CSV_FILTER_UNKNOWN — such a cascade would filter every row out).

inventory.objects - options from synced inventory objects ​

Any object type an inventory provider syncs can back a select, so a form can offer the VLANs or prefixes that exist in NetBox rather than free text. The field's option_source.config names the type and, optionally, what a picked option submits:

json
{
  "kind": "inventory.objects",
  "config": { "object_type": "vlan", "value_field": "attr.vid" }
}
  • object_type (required) - an object type id. The builder offers the types this instance registers. A type nothing here registers still compiles and still offers whatever rows a provider synced under that id, because a registered type is what the builder reads attributes and search fields from, not a gate on what a provider may sync; the validate endpoint reports it as an OBJECT_TYPE_UNKNOWN warning, since usually nothing synced it either.
  • value_field - what the run receives: id (default, the object's UUID), external_id, name, or attr.<attribute> for a manifest attribute, so a step can use the value as is ({{ inputs.vlan }} is the VID, not a UUID). Rows sharing a value - the same VLAN reported by two providers - become one option.

Options are the type's rows with sync_status: active; a fixed sync_status filter widens that (stale, or a list). provider_id narrows to one or more providers, and attr.<attribute> compares an attribute as text (numbers welcome), which is how a picked VRF or site can cascade into the prefixes under it. Free text matches the name, display name, external id and the manifest's search fields. Membership is enforced at run creation like every dynamic source: a value that is not among the current options is rejected, by name when the value is an id.

Fixed (static) filters ​

Beyond cascading edges, a field can declare always-on filter values in its option_source.filters config; the compiler turns them into OptionFilter.static_value entries:

jsonc
"option_source": {
  "kind": "inventory.sites",
  "filters": {
    "provider_id": { "static_value": "netbox:prod" },
    "parent_id": { "static_value": null }   // null = explicitly unset → root sites only
  }
}

The null sentinel works wherever the underlying column is nullable: parent_id on inventory.sites (root sites only) and site_id on inventory.devices (devices not assigned to any site).

In the builder these are the Fixed filters rows on a dynamic source (an empty value means "unset"). An options_filter edge with the same key overrides the fixed value (the cascade refines the default). Filter keys starting with __ are reserved and ignored with a RESERVED_FILTER_KEY warning; two edges feeding the same key raise DUPLICATE_FILTER_KEY.

Cascading selects (options_filter) ​

An options_filter edge makes one field's value narrow another field's options. The edge's relation value is the target source's filter key; the source field's current value is passed under that key to the target source. Examples:

CascadeSource → targetEdge relation value
Site → Deviceinventory.sites → inventory.devicessite_id
Provider → Deviceinventory.providers → inventory.devicesprovider_id
Provider → VLANinventory.providers → inventory.objectsprovider_id
VRF → Prefix (an attribute)inventory.objects → inventory.objectsattr.vrf (compared as text)
Parent site → Child siteinventory.sites → inventory.sitesparent_id
Region → Datacenter (one CSV)attachment.csv → attachment.csvregion (a CSV column)
Env → App → Versionchained attachment.csv fieldseach next CSV column

If the relation carries no value, the source field's own id is used as the filter key. Circular options_filter chains are rejected at compile time (CIRCULAR_OPTIONS_DEPENDENCY). At runtime the form re-fetches a field's options whenever one of its parent values changes, and prunes a now-invalid selection (entry-by-entry for multi-selects) unless the source sets allow_clear: false. Pruning only ever runs against a complete, unfiltered result for the current request — it is skipped while a server-side search narrows the list or when the response is truncated by the fetch limit, since absence from a partial view says nothing about validity. Dynamic selects are searchable comboboxes: typing sends the query to the options endpoint, and when the result is capped a "showing N of total" hint appears.

Authoring in the builder. On the Form tab, a select/multi-select field's inspector has an Option source picker (Static list, File attachment (CSV), Inventory sites/providers/devices); choosing File attachment (CSV) reveals the file + column configuration: the file is picked from the flow's attachments, the value/label/description columns from the file's header, and an Options preview shows the first choices the operator will see. To make a cascade, connect two fields and add a Filter options relation to the edge, setting its filter key to the target source's key — the input suggests the source's known keys (for CSV targets, the file's real column names). Fixed filters rows get the same key suggestions, and for CSV sources the value input suggests the chosen column's distinct values.

Versioning, storage, and export ​

The authoring graph lives in definition_json["interface_graph"] — nowhere else. It is therefore:

  • versioned with the flow: form edits mark the flow dirty, are saved with the draft, committed with versions, and restored by revert/restore;
  • exported/imported with the flow in YAML bundles and git sync;
  • never stale: the runtime contract is compiled on demand from the graph (the compiler is a pure function), so a pinned version always resolves against its own snapshot.

Run-time resolution (authoritative) ​

The frontend is never trusted. At run creation the API:

  1. compiles the contract from the version's definition (compile errors reject the run with HTTP 400);
  2. routes device-target field values into related_targets;
  3. evaluates visibility/enablement/requirement conditions — values submitted for hidden or disabled fields are dropped from inputs (a field's last value may still participate in other fields' conditions; the client and server evaluate conditions against the same form state, so they always agree on which fields show);
  4. applies defaults, coerces types, and runs validation rules (values that still contain Jinja templates pass through; their checks are downgraded to warnings and re-evaluated on the worker after rendering);
  5. enforces option membership for every select — static lists and dynamic sources (attachment.csv, inventory.*) alike. The submitted value must be one the source would actually offer given the submitted sibling values, so cascade constraints hold too (an emea region cannot smuggle in an amer datacenter). Because every entry point — API, schedules, webhooks, nested runs — resolves through this pipeline, the check covers them all. Template values are exempted (worker-resolved), and plugin sources opt in by implementing OptionSource.contains() (sources without it are skipped, never broken);
  6. applies the same membership rule to device-target fields. They route to related_targets rather than inputs, so they are checked separately — a device field narrowed by a cascade (e.g. "devices at the chosen site") or a fixed filter rejects a device the form would not have offered, instead of leaving that constraint to the picker UI. Rejections are reported by name — Target field 'Routers': device fra-edge-01 (09a48b03-…) is not one of the allowed targets — since a bare UUID tells the operator nothing about which device they picked. Sources whose values are opaque ids supply the names via OptionSource.describe_many(); sources whose values are already readable (CSV columns, static lists) leave it unimplemented and the value is shown as-is;
  7. persists the raw submission (submitted_inputs_json) and a provenance snapshot (resolved_inputs_snapshot_json) on the run for audit.

An options_filter edge into a device field therefore constrains it on both sides: the run form's device picker opens pre-filtered (its Site / Inventory source / Platform / tag controls locked to the cascade value), and run creation rejects anything outside that set. Only filter keys the device search understands are pushed into the picker (site_id, provider_id, platform, tag.*); a multi-valued parent has no single-key equivalent there, so the picker stays unfiltered while the server-side check still applies.

API ​

EndpointPurpose
GET /flow-interface/field-typesRegistered field type manifests
GET /flow-interface/option-sourcesRegistered option source manifests
GET /flows/{id}/interfaceGraph + on-demand compiled contract
POST /flows/{id}/interface/validateValidate a provided (unsaved) or the persisted graph
POST /flows/{id}/interface/optionsDynamic options for a field (cascading, search)
POST /flows/{id}/interface/preview-resolveDry-run resolution of form values

The graph itself is saved through the regular flow draft endpoints (PUT /flows/{id} with interface_graph).

The interface graph is the sole source of truth for form fields and device targeting - there is no separate inputs or target-roles mechanism.

Released as open source under the AGPL-3.0-or-later license. Development is sponsored by Rexonix s.r.o.. Contact — [email protected].