Loop prevention and conflicts
A bidirectional connector runs both an inbound flow and an outbound flow on the same entity. Without a guard, a write in one direction triggers the other: a HubSpot change pushes to the external system, the external system echoes the update back, and that echo re-enters HubSpot as a new event.
Plugsync does not stop this with one mechanism. It uses four, two per direction, each with its own flag, its own default, and its own trace. This page enumerates all of them and answers the question they exist to make answerable: if something other than plugsync writes to HubSpot, does the connector react?
The short answer
Yes, by default. Nothing on the outbound path looks at who made the write. Your connector reacts to a property change made from the HubSpot UI, from a private app, from a script of yours, or from plugsync itself, all identically.
The two cases where it does not react are both about the value, never about the author:
- the flow has
suppress_echo: true(off by default) and you wrote exactly the values this same connector wrote to that same object within the last 10 minutes; - the flow has
freshness_check: true(on by default) and by the time the event is processed a later write already replaced the value the event carried.
So the recipe for a write that is guaranteed to be seen is: write a value that differs from the last value plugsync wrote, and do not overwrite it again before the event drains. The credential you use is irrelevant.
Why writer identity is not the signal
HubSpot’s journal does not carry a reliable change-source, so the outbound path has no way to ask “was this us?”. Recognition is done on content instead: the write side stores a fingerprint (a hash of the written property values, never the values themselves) and the journal router compares it against the event’s propertyChanges.
Two consequences worth internalizing before designing a bidirectional connector or a test harness:
- Using a different credential or a different OAuth app does not make your write more visible. There is nothing to distinguish.
- Using plugsync’s own credential does not make your write invisible. Only a value match inside the fingerprint window does.
App identity does appear once, on the inbound side only, and its effect is the opposite of suppression: see foreign-only arbitration below.
Outbound guards: HubSpot to your target
| Guard | Flag | Default | Drops | Trace |
|---|---|---|---|---|
| Echo fingerprint | suppress_echo (per flow) | off | journal event whose changed properties match a recent write of this same connector | echo_suppressed in the journal log |
| Outbound freshness | freshness_check (per flow) | on | event whose carried value is no longer HubSpot’s current value | superseded row in conflicts |
Echo fingerprint is the guard that exists specifically to stop the 1:1 bounce of your own inbound write. It is scoped per connector: sibling connectors on the same portal still receive the event. It is deliberately conservative and fails open, so any uncertainty (HubSpot normalized the value, extra properties changed in the same event, unknown payload shape, expired fingerprint, unreachable store) lets the event through. A duplicate bounce is always preferred to a lost event. Full semantics and non-guarantees: flows.md.
Outbound freshness solves a different problem: a journal event can surface minutes after the write it reports, by which time a newer value may already be in HubSpot. Before sending, the guard re-reads the properties the step actually touches and compares them with what the event carried. A mismatch means the event’s value was superseded, and sending it would regress your target below data HubSpot already agrees on. It is on by default because the class of bug it prevents is silent data corruption; an explicit false opts out per flow.
Inbound guards: your system to HubSpot
| Guard | Flag | Default | Effect | Trace |
|---|---|---|---|---|
| Inbound echo | freshness_check (per flow) | on | skips a write whose properties merely echo this connector’s own recent write | superseded row in conflicts |
| Per-property arbitration | conflicts.strategy | source_wins | decides property by property who wins when HubSpot was also edited | row in conflicts |
| Replay guard | conflicts.reject_stale_source | on under most_recent, off otherwise | discards properties whose target write postdates the event’s business time | discarded_stale row in conflicts |
| Stale-by-history | conflicts.per_property_freshness | on under most_recent, off otherwise | keeps the target value when the incoming one matches an older version in HubSpot’s history | stale_echo reason on the conflict row |
Inbound echo shares the freshness_check flag with the outbound guard, because they are two halves of the same round-trip: the outbound side fingerprints what it sent, the inbound side recognizes it coming back. It is content-based on purpose, so it still catches the echo when the upstream system restamps its own updated_at to “now” on receipt, which defeats any clock-based guard. It requires an existing identity mapping: a brand-new record can never be an echo of a prior write to it.
Per-property arbitration is the conflicts block. Three strategies are available in v3 config:
source_wins(the default): the incoming change always applies, no extra read.target_wins: a contested property keeps its HubSpot value.most_recent: the target wins only if strictly more recently modified than the source. Ties favor the source.
Arbitration is per property, not per record, and only target_wins and most_recent trigger the read-before-write that makes it possible. Field reference, including source_modified_path: connector.md. Rationale: ADR-0023.
Foreign-only arbitration. target_wins and most_recent arbitrate only properties whose latest HubSpot version was written by someone else: a CRM user, your own tool, another integration. This is the one place where plugsync’s OAuth app id is compared against the writer recorded in HubSpot’s property history. When the latest version was plugsync’s own write, the property is not a conflict at all, it is the echo of the last sync, so it is written without arbitration and produces no conflicts row.
The practical implication for a connector where plugsync is the only writer on the HubSpot side is that zero conflicts is the correct outcome, not an inert engine. The exclusion is logged per property as conflict check: skipped (own write).
Observing a suppression
A suppressed event produces no error. It is not a failure, so nothing surfaces in an error list. Three places make it visible:
Journal log (GET /api/connectors/{connector_id}/journal-log) gives the per-event disposition for the outbound journal path, most recent first. An event dropped by the fingerprint guard appears with status: "echo_suppressed" and the reason echo of a recent write by this connector. The same response carries a backlog indicator (pending_count, oldest_pending_queued_at), which distinguishes the other common misreading: an event that looks lost but is only queued behind a backlog. This endpoint has no dashboard view today, query it directly.
Conflicts (GET /api/connectors/{connector_id}/conflicts, and the Conflicts view of the connector in the dashboard) records every discard the value-based guards make, so a suppression is never invisible even though no write happened:
resolution: "superseded"- the outbound freshness guard found the event’s value already replaced, or the inbound echo guard recognized the write as this connector’s own recent POST coming back.resolution: "discarded_stale"-reject_stale_sourcerejected a replayed or out-of-order event.resolution: "identity_superseded"- a lateupsert/update/deleteresolved an identity mapping thatpromote_identityhad already renamed away, so the write was skipped instead of clobbering the promoted record. Platform behavior, no config field governs it. Do not confuse it withsuperseded_source, which is a promotion declined byon_superseded: skip: step-actions.md lists the wholepromote_identityfamily and the naming asymmetry with$step.outcome.reason: "stale_echo"on a property of the conflict row -per_property_freshnesskept the target’s value because the incoming one matched an older version of that property’s history.
Worker logs carry the events that are decisions rather than discards, notably journal_echo_suppressed and conflict check: skipped (own write).
What is not a loop guard
- The credential or app you write with. Covered above: on the outbound path there is nothing to attribute a write to.
- A timestamp comparison on the identity mapping. Earlier versions of this page described a loop guard that compared the last sync time with each side’s last modification time on the identity-mapping record. That check belongs to the v2 pipeline, which no worker runs: the v3 runtime never maintains those fields, and no configuration exposes them. The guards on this page are the ones that execute.
- The v2
bidirectional.conflict_strategyblock, including itsmanualstrategy. It has no v3 reader. Conflict behavior in v3 is configured only through the top-levelconflictsblock.
Testing a bidirectional connector without fooling yourself
The failure mode this page exists to prevent is a test that quietly proves nothing. When you write to HubSpot by hand to exercise the HubSpot-to-target direction:
- Change the value. Rewriting the value plugsync already wrote is the one case the fingerprint guard is designed to swallow (when
suppress_echois on), and a no-op change may not produce a journal event at all. - Give it more than the 10 minute fingerprint window if you must reuse a value the connector recently wrote.
- Check the journal log before concluding the event was dropped. A backlog looks exactly like a drop from the outside.
- Do not assume your own writes are always filtered, or that they always come back. Both guards are best-effort and fail open by design.
- authoring-a-flow.md - end-to-end inbound and outbound walkthroughs.
- flows.md -
suppress_echo,freshness_checkandnarrow_outbound_projectionfield reference. - connector.md - the
conflictsblock field by field. - reliability/index.md - retries, failures and replay.
- index.md - guide overview and prerequisites.