Skip to Content
ReferenceConfig V3Entities

entities[] reference

An entity is a logical record type (company, contact, deal, …) plus one or more schemas that describe how that record looks in each system the connector touches. The HubSpot schema (schemas.hubspot) is by far the most common.

See connector.md for the surrounding ConnectorConfigV3 envelope, and simple-vs-federated.md for when to use a single hubspot schema versus a multi-schema federated layout.

Shape at a glance

A minimal simple-mode entity (1 source, HubSpot target):

{ "name": "company", "schemas": { "hubspot": { "object_type": "companies", "identity": {"strategy": "match_or_create", "property": "hs_tax_id"}, "identity_source_path": "$.payload.id", "identity_response_path": "$.id", "field_mappings": [ {"source": "$.payload.name", "target": "name"}, {"source": "$.payload.vat_number", "target": "hs_tax_id"} ] } } }

A federated entity adds canonical and source-specific schemas alongside hubspot; see simple-vs-federated.md.

EntityV3 fields

FieldTypeRequiredNotes
namestringyesUnique within the config. Used by flow steps as entity.
schemasdict[str, EntitySchemaSpec]yesKeyed by system name (hubspot, canonical, <source>).
payload_rootstring or nullnoRe-roots $.payload.* for this entity’s own field_mappings/transform-step sources. See “Multi-entity envelopes: payload_root” below.

The schemas dict accepts arbitrary keys, but two are behaviorally special:

  • hubspot - recognized by HubSpot-targeting actions like find / upsert (the runtime raises PermanentError if find is invoked with target != "hubspot").
  • canonical - the conventional intermediate shape that transform steps write into (see simple-vs-federated.md).

Other keys are user-defined labels for a non-HubSpot system — a source schema in a federated entity, or a schema a post step targets directly. A post step with entity set and no explicit body projects that schema’s field_mappings into the outbound request body (issue #795); see “Which action reads a schema’s field_mappings” below for the full consumer list.

Unlike EntitySchemaSpec, IdentityConfig, and FieldMapping, EntityV3 does not declare extra="allow". Passing unknown fields (for example a literal mode key) at the entity level will fail validation.

EntitySchemaSpec fields

FieldTypeRequiredNotes
object_typestring or nullnoThe target system’s object identifier (e.g., "companies", "0-2").
identityIdentityConfig or nullnoHow to identify existing records. See “Identity” below.
identity_source_pathstring (JSONPath)noJSONPath into the inbound payload that yields the external id. Defaults to a fallback chain when omitted — see “identity_source_path default” below.
identity_response_pathstring (JSONPath)noJSONPath into the RAW create response body (not a step reference — see “identity_response_path resolution root” below).
credential_namestring or nullnoName of the credential to use for this schema. Optional - outbound schemas can run without auth. If declared, the publish-time validator checks the credential exists in your org.
field_mappingslist[FieldMapping]noDefaults to []. See “Field mappings” below.

EntitySchemaSpec declares extra="allow"; unknown keys do not raise but are ignored by the runtime.

Identity

{"identity": {"strategy": "match_or_create", "property": "hs_tax_id"}}

IdentityConfig.strategy is one of three values (verified against (internal plugsync tooling - see the source repo)):

StrategyBehavior
match_or_createLook up the local mapping first. If absent and the search property is set AND present in the payload, search the target by that property. Reuse on hit, otherwise create.
external_id_onlyLook up the local mapping only. If a mapping exists, reuse it; otherwise the resolver returns create with no remote search.
by_emailSearch the target directly by an email property (defaults to email) drawn from the inbound payload. Unlike match_or_create and external_id_only, by_email does not check the local mapping. On miss the resolver returns action=not_found rather than action=create.

IdentityConfig declares extra="allow", so per-strategy parameters (for example property for match_or_create) ride along on the same object. The resolver reads the search property out-of-band, so this page does not prescribe an exhaustive list; consult the engine source for the live set.

An identity.property on a hubspot schema does not need to pre-exist on the portal: a custom (non-hs_*) identity property is created automatically at publish. See the Schema provisioning reference.

Publish-time identity validator

When an entity schema declares an identity config and the schema key is not hubspot, the publish validator (_validate_identity_config in (internal plugsync tooling - see the source repo)) requires identity_response_path to be set. Without it the runtime cannot writeback the mapping after a successful create, so the next event for the same record would duplicate. Missing the path blocks publish with a ValidationFailedError (HTTP 422), not a draft-save error.

identity_source_path default

When a schema declares identity but does not set an explicit identity_source_path, the runtime (_resolve_identity_active in step_actions.py) tries two candidates in order, using the first one that resolves to a non-empty value (#775):

  1. $.payload.object.id — the shape every HubSpot-triggered outbound event actually carries today. Journal v4 enrichment (hubspot_enrichment.enrich_journal_event) wraps the raw event as {"event": {...raw event...}, "object": {"id": ..., "properties": {...}} | None}; webhook v2 enrichment (enrich_webhook_event) converges to the same shape. This is the id every journal-sourced outbound step actually reads.
  2. $.payload.objectId — kept only for retrocompatibility with a flat, pre-enrichment webhook-shaped payload. No live outbound call site produces that flat shape today, but a hard failure from silently dropping support for it would be worse than one extra JSONPath attempt.

Neither candidate is a fit for event_api (inbound enterprise) payloads: those carry their id as a sibling $.external_id, never nested under $.payload (see the external_id field on StepExecutionContext). An identity-aware schema fed by event_api still needs an explicit identity_source_path: "$.external_id" — the default fallback chain is specific to HubSpot-triggered outbound events.

If neither candidate resolves to a non-empty value, the step raises PermanentError naming both paths that were tried. Set an explicit identity_source_path only when the entity’s payload genuinely differs from both defaults (e.g. event_api, above, or a federated entity whose non-HubSpot source schema carries its own id shape) — an explicit override always wins and skips the fallback chain entirely, with no retry if it fails to resolve.

Degraded enrichment (enrichment_degraded marker)

The fallback chain above does not change to cover a degraded HubSpot enrichment (#799): when object is null because the enrichment itself failed rather than because the object genuinely doesn’t exist, the only surviving id lives at $.payload.event.objectId — not one of the two default candidates, and it deliberately never becomes a third one. Adding it as a candidate would let the step proceed with a null/empty object body, risking a null write to the target or a silent no-op sync (the same failure class already found in the blind Gargano run 5 P1). A truthful failure with a clear recovery path is preferred.

Instead, the enrichment layer marks the envelope explicitly. The result of hubspot_enrichment.enrich_journal_event carries a sibling enrichment_degraded: true key (alongside event/object) when:

  • the object GET fails transiently (5xx, timeout), including when the property-isolating retry that follows a proven 400 also fails;
  • in the per-portal journal path (source_worker.py), the HubSpot enrichment client itself could not be built for the whole poll tick (credential expired/revoked) — no GET was even attempted.

The marker is not set on the object_deleted path (the object genuinely doesn’t exist — see the section below) nor when enrichment is skipped by choice (enrich=False). A DELETE journal event’s expected 404 also does not get the marker: that is the correct shape of a delete, not a degradation.

When the default chain fails to resolve AND enrichment_degraded is set, _resolve_identity_source_id raises a PermanentError that names the degraded enrichment explicitly and suggests a journal replay instead of retrying the message: retrying does not help because the SQS payload is frozen with the same missing object body; only replaying from the journal re-enriches the event at the source.

identity_response_path resolution root

identity_response_path and identity_source_path are NOT resolved the same way, and neither is resolved the same way as the $<step_id>.* step-output references described in “JSONPath conventions” below. Confusing the two is an easy, high-damage mistake (#771): mix them up and the identity mapping is silently never written, so every following event for the same record POSTs a new one instead of PUTing the existing one (record duplication).

  • identity_source_path (and every field_mappings[].source) goes through the rich resolver (_resolve_value in step_actions.py), which understands the $.payload.* / $.canonical.* / $<step_id>.* roots.
  • identity_response_path goes through a different, simpler function (resolve_jsonpath in (internal plugsync tooling - see the source repo)) that has no roots at all: it walks directly into the raw JSON body of the create response. $. IS the response body — there is no payload, canonical, or body key to step through first. A target that returns {"resource": {"id": "abc"}} on create needs "identity_response_path": "$.resource.id", never "$.body.resource.id".

The $.body.* form is real, but it belongs to a different feature: a step-output reference like $my_step.body.resource.id (or $.my_step.body.resource.id) reads ctx.outputs["my_step"]["body"], because ctx.outputs[step_id] for an HTTP-ish step DOES wrap the parsed response under a "body" key. identity_response_path has no step id to anchor a step-output reference on — it fires against the response of the step that is currently executing, before that step has written anything to ctx.outputs. Writing identity_response_path: "$.body.*" applies the step-output convention where it does not apply.

Because this misuse is common enough to have caused a production incident, the $.body. prefix specifically is caught in two places, before it ever runs against real data:

  • Publish (_validate_identity_config in publish_service.py) rejects any identity_response_path starting with $.body. with a ValidationFailedError explaining the correct form.
  • Preflight (_outbound_identity_findings in (internal plugsync tooling - see the source repo)) flags the same prefix as an error-severity finding while still in draft, before the operator even attempts to publish.

Dry-run stub shape (#846). The dry-run rehearsal (POST .../draft/flows/{name}/dry-run) never calls the real target: its stub HTTP client answers every outbound create with a flat {"id": "dry-run-N"} body. An identity_response_path that is correct for the live API but reads a non-flat shape (e.g. "$.resource.id") fails ONLY in dry-run, against that flat stub — the source’s config.outbound.dry_run_response field lets an operator supply an example response body (e.g. {"resource": {"id": "ext-123"}}) shaping the stub after the real API instead, so the same path resolves in the rehearsal too. Absent, the flat default applies (today’s behavior, unchanged). Either way, the resulting trace entry carries stubbed_external_response: true with an explanatory note: the value shown there — resolved or not — never came from the real target.

And more generally, at runtime: even a well-formed path that simply does not resolve against a successful (2xx) create response is a hard PermanentError (_execute_identity_aware_http in step_actions.py) — never a server-side-only warning. Letting the step report success while the mapping silently failed to save was the actual bug: writeback_ok: false is now unreachable in step_trace, because the failure surfaces before that output is written.

Field mappings

{"source": "$.payload.vat_number", "target": "hs_tax_id"}
{"source": "$.payload.created_at", "target": "created_at", "transform": "iso_to_unix_ms"}

FieldMapping fields:

FieldTypeRequiredNotes
sourcestring (JSONPath)yesRead path. Common roots: $.payload.*, $.canonical.*, $.event_type.
targetstringyesMeaning depends on which schema this mapping lives on. See “Which action reads a schema’s field_mappings” below.
transformstring, dict, list of dicts, or nullnoName of a registered transform, or a transform pipeline: one step ({"name": ..., "args": ...}) or an ordered list of steps. A single dict is equivalent to a one-element list. See “Transforms” below.
argsdict or nullnoArguments for the flat/bare-string transform. For the dict/pipeline form, apply_transform reads args per-step ({"name": ..., "args": ...}), not this sibling — a single-dict transform with no args of its own merges this sibling in at draft-save time; a dict that already has its own args plus this sibling is rejected as ambiguous.
resolvestring or nullnoOwner resolution marker. See “Owner resolution” below.
skip_if_source_emptyboolean, default falsenoSkip this WHOLE mapping (never write target) when its OWN source resolves to None or "", checked BEFORE transform runs. See “Empty vs. absent sources” below.

FieldMapping declares extra="allow"; unknown keys are ignored.

On a hubspot schema, every mapping’s target (and a target_template’s declared provision_names) is provisioned as a custom property on the portal at publish — you do not create properties by hand, and per-mapping hubspot_type / hubspot_field_type / hubspot_label extras control what gets created. Mechanisms and limits (reserved hs_* / a<appId>_* prefixes, custom object schemas, forms) are documented in the Schema provisioning reference.

Which action reads a schema’s field_mappings

field_mappings are declared per-schema, but which runtime consumer (if any) applies depends entirely on which schema key they sit under:

  • schemas.hubspot.field_mappings — projected into a HubSpot properties dict by _project_hubspot_props ((internal plugsync tooling - see the source repo)), the single source of truth shared by upsert/create/update/delete/ submit_form and the *_many array actions. target here is a literal HubSpot internal property name (or target_template for a computed one). This is the only schema key the hubspot-write actions ever read.
  • Any other schema, when it is a transform step’s from_schema_action_transform reads that schema’s field_mappings and writes each mapping whose target starts with "$.canonical.<key>" into the canonical output. A mapping on that same schema whose target is anything else (a bare property name, say) is silently dropped — it is never written anywhere by the transform step (a body-less post step can still consume it directly, see below).
  • Any other schema, when it is a body-less post step’s target (issue #795) — a post step that declares entity and no explicit body projects entity["schemas"][step.target].field_mappings into the outbound request body, via _project_post_body ((internal plugsync tooling - see the source repo)) reusing _project_hubspot_props verbatim (it never assumed “hubspot” — it projects whatever field_mappings list it is handed). target here is a literal key name in the outbound JSON body (no "$.canonical." prefix filtering: EVERY mapping on the schema is applied, transform included). This is FALLBACK-ONLY: a post step that declares its own body never reads the schema’s field_mappings at all, with zero merge between the two — the explicit body always wins outright. data has no effect on this gate either way: it is a base step field used by upsert/update_by_id, but post’s ActionSpec never declares it and the runtime (_resolve_post_body) never reads it — only body presence and entity decide. fields (#463) further filters the projection to a subset of targets, same mechanism as upsert/upsert_many’s allowlist, but requires entity (see step-actions.md). Unlike upsert/upsert_many, a post allowlist is not required to include the schema’s identity property: _project_post_body filters fields verbatim (no force-include), because that schema’s identity block (if any) drives only the create-vs-update HTTP routing (_resolve_identity_active), not a written property. notify is excluded from this whole projection fallback even though it delegates to the same post handler — a notify step never projects a body, matching its pre-#795 behavior exactly.
  • Any other schema, when nothing reads it — if a schema is not hubspot, is not referenced as any transform step’s from_schema, and is not a body-less post step’s target, its field_mappings have zero consumers. Declaring them has no runtime effect at all. Preflight (check_field_mappings_without_consumer in (internal plugsync tooling - see the source repo)) flags both silent-drop shapes (an unconsumed schema, or a transform-consumed schema with a non-canonical dead target) as a non-gating warning at draft-preview time — it no longer fires on a schema a body-less post step actually projects.

Preflight also validates the post-projection shape itself (check_step_refs): a body-less post step whose target does not match any schema key on its entity is flagged as unknown_post_projection_schema (the runtime would fail with a PermanentError, exactly like an unknown transform from_schema).

Owner resolution

HubSpot’s owner field (hubspot_owner_id) takes a numeric owner id, but source systems often carry the owner as an EMAIL (and outbound the inverse). Owner resolution is a live HubSpot lookup, so it cannot be a (sync, stateless) registry transform; it is a separate marker on the mapping:

resolve valueDirectionEffect
owner_email_to_idinboundMaps the resolved value (an owner email) to the HubSpot owner id.
owner_id_to_emailoutboundMaps the resolved value (an owner id) to the owner email.
owner_ids_to_emailsoutboundList-aware inverse (#953). Maps a ;-joined (HubSpot multi-select) or already-list value of owner ids to a list of emails.
{"source": "$.payload.owner_email", "target": "hubspot_owner_id", "resolve": "owner_email_to_id"}

resolve applies AFTER source is read and any transform runs. The owner directory is fetched once per message (a single GET /crm/v3/owners call, lazy: flows with no owner-resolve mapping never make the call) and indexed in memory in both directions; matching on the email is case-insensitive.

On a miss (the email/id is not in the directory) the target property is set to the empty string "" and the unresolved value is surfaced as a non-fatal step warning in the trace (code owner_not_found) — never as a key inside the projected properties themselves, which HubSpot would reject as an unknown property. Implementation: (internal plugsync tooling - see the source repo).

Multi-select owners (owner_ids_to_emails)

A HubSpot multi-select owner property (e.g. a custom “collaborators” property) carries several owner ids joined with ; in one string — "4811992;9999999". The scalar owner_id_to_email treats that whole string as ONE id and misses; resolving it used to need a transform: split (args.index) per fixed slot, capped at however many slots were declared upfront. owner_ids_to_emails resolves the WHOLE list in a single mapping, with no fixed cap:

{"source": "$.payload.object.properties.collaborators", "target": "$.canonical.collaborator_emails", "resolve": "owner_ids_to_emails"}

$.canonical.collaborator_emails (or the equivalent hubspot/post-body target) is then a plain Python/JSON list of the resolved emails — consumable as-is by a downstream field_mappings entry ($.canonical.collaborator_emails as source) or by a plugin ref, exactly like any other list-shaped canonical value. Unresolvable ids are dropped from the list entirely (never a positional null/empty placeholder — there is no fixed slot to leave a hole in), and every miss for that ONE mapping is reported as a single aggregated owner_not_found warning carrying the list of missed ids, never one warning per id. A blank source value resolves to an empty list with no warning.

To write the resolved emails back into an actual HubSpot multi-select property (which needs a single ;-joined string, not a list), chain the existing join transform on a later mapping that reads the canonical list:

{"source": "$.canonical.collaborator_emails", "target": "collaborator_emails_hs", "transform": "join", "args": {"sep": ";"}}

Empty vs. absent sources

The general projection rule never changes: a plain field mapping whose source resolves to "" writes "" to target, exactly like any other value — writing an empty string is the canonical way to CLEAR a HubSpot property. None (an absent path) is the only value the not-None guard drops. This rule is load-bearing and does not have a global opt-out: the two mechanisms below are both explicit, per-mapping/per-transform opt-ins for the narrow cases where a source system conflates “empty” and “absent” (#1080 - Magento sends collistar_order_id: "" on every cart event, never omitting the key, mirroring the legacy connector’s orderId || 'new_cart').

  • concat’s args.defaults (see “Transforms” below) - a source inside args.sources that resolves to None OR "" uses its declared default instead of being dropped. Scoped to that one concat mapping’s own sources.

  • skip_if_source_empty (FieldMapping field, default false) - for an ordinary (non-concat/sum/sum_list) mapping consumed by _project_hubspot_props (upsert/create/update/delete/submit_form/*_many, and a body-less post step’s fallback projection). When true, the mapping resolves its source and, if that raw value is None or "", skips the WHOLE mapping before transform even runs - target is left exactly as any earlier mapping on the same target set it. Built for the ordered-fallback pattern (field_mappings are last-write-wins in declaration order, the declarative form of a legacy a || b fallback): a later, more-specific mapping should overwrite the earlier fallback only when it actually carries a value, not when a transform turns its empty source into a non-empty string (e.g. prefix turning "" into "Order "). Not available on a transform step’s from_schema mappings (_action_transform’s canonical projection) - only on a hubspot-consumed or body-less-post-consumed schema.

    {"source": "$.payload.externalObjectId", "target": "dealname", "transform": "prefix", "args": {"prefix": "Cart "}}, {"source": "$.payload.properties.collistar_order_id", "target": "dealname", "transform": "prefix", "args": {"prefix": "Order "}, "skip_if_source_empty": true}

    With collistar_order_id: "", dealname stays "Cart <cartId>" instead of being blanked to "Order "; with collistar_order_id: "O9" present, the second mapping still overwrites to "Order O9" as intended.

Enum option auto-append

When an upsert writes a value to a HubSpot enumeration property whose current option list does not already contain that value, the engine ADDS the option to the property (a HubSpot Schema API PATCH) before writing, then writes the value. This is automatic - there is no config field to enable it; it is keyed on the target property being an enumeration in HubSpot.

This exists for parity with the masiero-api connector’s checkValue behavior and is a conscious deroga from ADR-0001 (the engine is otherwise declarative and does not mutate the HubSpot schema at runtime). It is tightly gated:

  • only enumeration-type properties are auto-appended;
  • read-only (modificationMetadata.readOnlyValue) and calculated properties are never appended (the value is still written through to HubSpot, which rejects it if it is not a valid option - same as without this feature);
  • the PATCH is additive only (existing options are preserved, never reordered or removed);
  • the object type’s property schema is fetched at most once per message and an option is appended at most once per message (idempotent); a value that is already an option triggers no call.

Unlike masiero-api, the engine does not sleep to wait for HubSpot to propagate a freshly-appended option (masiero sleeps 15s); a blanket sleep would block the worker hot path. If a write momentarily races a just-appended option that has not propagated, that is an accepted edge. Implementation: (internal plugsync tooling - see the source repo).

JSONPath conventions

The field-mapping resolver (_resolve_value in (internal plugsync tooling - see the source repo)) recognizes exactly these roots:

  • $.payload.* - the raw inbound event body (inbound flows).
  • $.canonical.* - the intermediate canonical shape (federated mode).
  • $.event_type - scalar: the flow’s event_type. Lets one shared mapping on a (possibly shared) entity stamp a per-EVENT literal via a map transform - e.g. {"source": "$.event_type", "transform": "map", "args": {"map": {"new_prodotti": "add_product_in", "change_prodotti": "update_product_in"}}, "target": "state_change_api"} resolves the correct per-event marker for every flow sharing the entity (masiero state_change_api, #191). A per-flow const cannot do this when two flows share one entity. Resolves to None when no flow context set it.
  • $.external_id - scalar: the message-level external id from the event envelope (distinct from $.payload), e.g. to feed an identity property.
  • $<step_id> (no dot) - the full output of a prior step in the same flow.
  • $.<step_id>.* - a nested field on a prior step’s output.

Anything else (for example $.identity.* or $.event) returns None - those are not valid field-mapping roots.

Multi-entity envelopes: payload_root

A source system’s webhook sometimes nests SEVERAL business objects under one event, each under its own key:

{ "payload": { "cliente": {"codice": "C1", "nome": "Acme"}, "ordine": {"numero": "O1", "importo": 250} } }

Without payload_root, $.payload.codice on the cliente entity’s field_mappings reads the TOP-LEVEL payload dict directly - which holds cliente/ordine as keys, not codice. The result is an empty (or wrong) projection, surfaced at write time as identity property 'x' missing from projected properties. data: "$.payload.cliente" on the upsert step does not fix this - data is not read by upsert/create/update/delete today (dead field kept only for update_by_id’s literal body; see step-actions.md).

payload_root declares, once per entity, which payload key its OWN records live under:

{"name": "cliente", "payload_root": "cliente", "schemas": {"hubspot": { "identity": {"strategy": "match_or_create", "property": "ext_id"}, "field_mappings": [ {"source": "$.payload.codice", "target": "ext_id"}, {"source": "$.payload.nome", "target": "name"} ] }}}
{"name": "ordine", "payload_root": "ordine", "schemas": {"hubspot": { "identity": {"strategy": "match_or_create", "property": "ext_id"}, "field_mappings": [ {"source": "$.payload.numero", "target": "ext_id"}, {"source": "$.payload.importo", "target": "amount"} ] }}}

Both entities’ field_mappings keep the plain, un-nested form ($.payload.codice, never $.payload.cliente.codice) - payload_root changes what $.payload MEANS for that entity’s own projection, not the stored mapping strings. At runtime, $.payload.* is resolved against payload[payload_root] instead of the raw payload (_reroot_for_entity in (internal plugsync tooling - see the source repo)) whenever the step operates on that entity’s own field_mappings (upsert/create/update, delete, submit_form, and a transform step reading that entity’s source schema).

Deliberately scoped to field_mappings/transform sources only. Step-level $... references the operator writes ONCE per step - items, by.value, form_id, body, and conflicts.source_modified_path (see connector.md) - are unaffected and keep reading the raw, un-rooted payload; there is no repeated-path ambiguity to solve for a single reference the way there is for N field_mappings. Write those explicitly against the full envelope, e.g. "source_modified_path": ["$.payload.cliente.updated_at", "$.payload.ordine.updated_at"] (the existing #705 fallback-list feature), independent of whichever entities declare payload_root.

None (the default, and every existing entity before this field existed) is unchanged behavior: $.payload.* reads the raw payload directly. A payload_root whose key is absent or not an object on a given event is a silent no-op (fails open) - the normal missing-identity error surfaces exactly as before, and dry-run (POST .../draft/flows/{name}/dry-run) appends a suggested payload_root to that error when the event’s payload has a sub-key the entity’s own field_mappings would actually resolve against.

Transforms

The transform registry comes from the TransformNode union in (internal plugsync tooling - see the source repo). Four variants are supported:

VariantPurpose
TransformNodePathRead a JSONPath, optionally with default and transform / args.
TransformNodeTemplateString template ("{first} {last}"), with safe-brace validation.
TransformNodeConstInline constant value.
TransformNodeConcatConcatenate a list of values.

Named transforms (the strings passed in the mapping’s transform field) are specific to each project; consult the engine source or your connector’s configured transforms to enumerate the live set.

The publish-time transform validator (_validate_transforms in publish_service.py) requires the transform name on every FieldMapping to be either (a) a name in the engine’s built-in transform registry (supported_transform_names()), or (b) a name declared under config.transforms (the config-level transform registry, see roadmap). Unknown names fail at publish with ValidationFailedError, not at draft save.

See flows.md for how field_mappings are consumed inside upsert / create / update steps, and how transform steps populate $.canonical.* from $.payload.* in federated mode.

Last updated on