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
| Field | Type | Required | Notes |
|---|---|---|---|
name | string | yes | Unique within the config. Used by flow steps as entity. |
schemas | dict[str, EntitySchemaSpec] | yes | Keyed by system name (hubspot, canonical, <source>). |
payload_root | string or null | no | Re-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 likefind/upsert(the runtime raisesPermanentErroriffindis invoked withtarget != "hubspot").canonical- the conventional intermediate shape thattransformsteps 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
| Field | Type | Required | Notes |
|---|---|---|---|
object_type | string or null | no | The target system’s object identifier (e.g., "companies", "0-2"). |
identity | IdentityConfig or null | no | How to identify existing records. See “Identity” below. |
identity_source_path | string (JSONPath) | no | JSONPath into the inbound payload that yields the external id. Defaults to a fallback chain when omitted — see “identity_source_path default” below. |
identity_response_path | string (JSONPath) | no | JSONPath into the RAW create response body (not a step reference — see “identity_response_path resolution root” below). |
credential_name | string or null | no | Name 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_mappings | list[FieldMapping] | no | Defaults 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)):
| Strategy | Behavior |
|---|---|
match_or_create | Look 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_only | Look up the local mapping only. If a mapping exists, reuse it; otherwise the resolver returns create with no remote search. |
by_email | Search 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):
$.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.$.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 everyfield_mappings[].source) goes through the rich resolver (_resolve_valueinstep_actions.py), which understands the$.payload.*/$.canonical.*/$<step_id>.*roots.identity_response_pathgoes through a different, simpler function (resolve_jsonpathin (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 nopayload,canonical, orbodykey 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_configinpublish_service.py) rejects anyidentity_response_pathstarting with$.body.with aValidationFailedErrorexplaining the correct form. - Preflight (
_outbound_identity_findingsin (internal plugsync tooling - see the source repo)) flags the same prefix as anerror-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:
| Field | Type | Required | Notes |
|---|---|---|---|
source | string (JSONPath) | yes | Read path. Common roots: $.payload.*, $.canonical.*, $.event_type. |
target | string | yes | Meaning depends on which schema this mapping lives on. See “Which action reads a schema’s field_mappings” below. |
transform | string, dict, list of dicts, or null | no | Name 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. |
args | dict or null | no | Arguments 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. |
resolve | string or null | no | Owner resolution marker. See “Owner resolution” below. |
skip_if_source_empty | boolean, default false | no | Skip 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 byupsert/create/update/delete/submit_formand the*_manyarray actions.targethere is a literal HubSpot internal property name (ortarget_templatefor a computed one). This is the only schema key the hubspot-write actions ever read.- Any other schema, when it is a
transformstep’sfrom_schema—_action_transformreads that schema’s field_mappings and writes each mapping whosetargetstarts with"$.canonical.<key>"into the canonical output. A mapping on that same schema whosetargetis anything else (a bare property name, say) is silently dropped — it is never written anywhere by the transform step (a body-lesspoststep can still consume it directly, see below). - Any other schema, when it is a body-less
poststep’starget(issue #795) — apoststep that declaresentityand no explicitbodyprojectsentity["schemas"][step.target].field_mappingsinto the outbound request body, via_project_post_body((internal plugsync tooling - see the source repo)) reusing_project_hubspot_propsverbatim (it never assumed “hubspot” — it projects whatever field_mappings list it is handed).targethere is a literal key name in the outbound JSON body (no"$.canonical."prefix filtering: EVERY mapping on the schema is applied,transformincluded). This is FALLBACK-ONLY: apoststep that declares its ownbodynever reads the schema’s field_mappings at all, with zero merge between the two — the explicitbodyalways wins outright.datahas no effect on this gate either way: it is a base step field used byupsert/update_by_id, butpost’s ActionSpec never declares it and the runtime (_resolve_post_body) never reads it — onlybodypresence andentitydecide.fields(#463) further filters the projection to a subset of targets, same mechanism as upsert/upsert_many’s allowlist, but requiresentity(see step-actions.md). Unlike upsert/upsert_many, apostallowlist is not required to include the schema’s identity property:_project_post_bodyfiltersfieldsverbatim (no force-include), because that schema’sidentityblock (if any) drives only the create-vs-update HTTP routing (_resolve_identity_active), not a written property.notifyis excluded from this whole projection fallback even though it delegates to the sameposthandler — anotifystep 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 anytransformstep’sfrom_schema, and is not a body-lesspoststep’starget, its field_mappings have zero consumers. Declaring them has no runtime effect at all. Preflight (check_field_mappings_without_consumerin (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-lesspoststep 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 value | Direction | Effect |
|---|---|---|
owner_email_to_id | inbound | Maps the resolved value (an owner email) to the HubSpot owner id. |
owner_id_to_email | outbound | Maps the resolved value (an owner id) to the owner email. |
owner_ids_to_emails | outbound | List-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’sargs.defaults(see “Transforms” below) - a source insideargs.sourcesthat resolves toNoneOR""uses its declared default instead of being dropped. Scoped to that one concat mapping’s own sources. -
skip_if_source_empty(FieldMapping field, defaultfalse) - for an ordinary (non-concat/sum/sum_list) mapping consumed by_project_hubspot_props(upsert/create/update/delete/submit_form/*_many, and a body-lesspoststep’s fallback projection). Whentrue, the mapping resolves itssourceand, if that raw value isNoneor"", skips the WHOLE mapping beforetransformeven runs -targetis left exactly as any earlier mapping on the sametargetset it. Built for the ordered-fallback pattern (field_mappings are last-write-wins in declaration order, the declarative form of a legacya || bfallback): a later, more-specific mapping should overwrite the earlier fallback only when it actually carries a value, not when atransformturns its empty source into a non-empty string (e.g.prefixturning""into"Order "). Not available on atransformstep’sfrom_schemamappings (_action_transform’s canonical projection) - only on ahubspot-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: "",dealnamestays"Cart <cartId>"instead of being blanked to"Order "; withcollistar_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) andcalculatedproperties 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’sevent_type. Lets one shared mapping on a (possibly shared) entity stamp a per-EVENT literal via amaptransform - 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 (masierostate_change_api, #191). A per-flowconstcannot do this when two flows share one entity. Resolves toNonewhen 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:
| Variant | Purpose |
|---|---|
TransformNodePath | Read a JSONPath, optionally with default and transform / args. |
TransformNodeTemplate | String template ("{first} {last}"), with safe-brace validation. |
TransformNodeConst | Inline constant value. |
TransformNodeConcat | Concatenate 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.