Skip to Content
ReferenceConfig V3Draft & publish

Draft -> publish workflow

Every config write produces a draft. The draft becomes a published version only when you explicitly click Publish (or POST /publish). Workers read the published version from a DynamoDB replica; they never read your draft.

The model

+----------------+ +-----------------------+ +-----------------------+ | | PATCH | | POST | | | Dashboard +--------> connector_config_ +--------> connector_config_ | | AI agent |/draft | drafts |/publish| versions | | MCP server | | (1 row, mutable) | | (append-only history,| | Setup scripts | | | | one is_active=true) | | | +-----------------------+ +----------+------------+ +----------------+ | v +-----------+-----------+ | DynamoDB replica | | (worker hot read) | | + event_flow_ | | manifests | | + SQS notify | +-----------------------+

Invariants (see ADR-0014):

  • Single write path. Every writer (UI, agent, MCP, setup script) goes through PATCH /api/connectors/{id}/draft or one of the granular helpers below. No endpoint mutates state by side effect.
  • Single read source for workers. Workers consume DynamoConfigStore, written only at publish time by the outbox dispatcher. They never touch your draft.
  • Atomic publish. The transition `draft -> version + event_flow_manifests
    • outbox row` happens in one PostgreSQL transaction. The DynamoDB replica and SQS notify are async via the outbox dispatcher (transactional outbox).
  • Immutable history. Every published version is strictly immutable.

Endpoints

EndpointVerbPurpose
/api/connectors/{id}/draftPUTReplace the entire draft config.
/api/connectors/{id}/draftPATCHPartial draft update: RFC 7396 merge patch over the current draft.
/api/connectors/{id}/draft/entities/{name}PUTUpsert a single entity in the draft.
/api/connectors/{id}/draft/entities/{name}DELETERemove a single entity from the draft.
/api/connectors/{id}/draft/entities/{name}/discardPOSTRevert one entity in the draft back to the published version.
/api/connectors/{id}/draft/flows/{name}PUTUpsert a single event flow in the draft.
/api/connectors/{id}/draft/flows/{name}DELETERemove a single event flow from the draft.
/api/connectors/{id}/draft/flows/{name}/dry-runPOSTRun a draft flow against stub clients; no external system is touched. An associate step whose endpoints were not upserted in the same rehearsal reports not_verifiable (the stub starts with no identity mapping), not an error; the remaining steps keep rehearsing. A plugin step authored for the Lambda runtime (plugin.logical_name) is never invoked, live or via any fallback path — it is reported as not_executed: true with a not_executed_reason, and the rehearsal keeps going past it (#1113; see plugins/dev-loop.md for why, and plugsync plugin invoke for actually exercising the handler). Any step that hit the stub HTTP client (e.g. a post/identity-aware upsert to a non-HubSpot target) is flagged stubbed_external_response: true — its response never came from the real target; a source’s config.outbound.dry_run_response shapes that stub’s body (see entities.md, “identity_response_path resolution root”). A delete step against a REST destination resolves the id to delete from the identity map, which is empty here: the rehearsal simulates that mapping so the request (url, method, headers) is actually exercised, and flags the step simulated_identity: true with a simulated_identity_note. The external id in that url is invented (prefix dry-run-external-id-) and a green outcome is no proof the record is mapped — at runtime an unmapped record makes the same step a silent no-op, which the trace still reports on its own as reason: "no_identity_mapping" without the flag (#1189).
/api/connectors/{id}/draft/settingsPATCHPartial update of config.settings: RFC 7396 merge patch.
/api/connectors/{id}/publishPOSTPublish the current draft as the next version.

The publish endpoint requires a base_version field in the body; see “Concurrency” below.

Body contract for granular writes

Every write below expects its payload flat: the config/patch object itself, directly as the request body — no top-level wrapper key, the same convention as POST /sources. This is the one place that contract is documented — the request/response schemas live in (internal plugsync tooling - see the source repo).

EndpointBody shapeNotes
PUT /draft<full v3 connector config>Replaces the draft wholesale.
PATCH /draft<RFC 7396 merge patch>Deep-merged into the current draft; null values delete keys.
PUT /draft/entities/{name}<entity object>, with an optional top-level auto_flow: {"source": ..., "event_type": ...} sibling keyauto_flow is only honored when the org’s simple_mode_autoflow flag is on; omit it otherwise. It is pulled out of the body before the rest is saved as the entity config — never persisted as an entity field.
PUT /draft/flows/{name}<event flow object>
PATCH /draft/settings<RFC 7396 merge patch>Merged into config.settings only.
POST /draft/flows/{name}/dry-run{"input_event": <event payload>, "event_type": <string> | null}event_type previews a wildcard flow ("cliente.*") against a concrete event type; omit for non-wildcard flows. Unrelated to the flat/wrapper contract above — this endpoint isn’t a config write.
DELETE /draft/entities/{name}, DELETE /draft/flows/{name}, POST /draft/entities/{name}/discardnone

Example — PUT /draft/entities/company:

{ "name": "company", "schemas": { "hubspot": { "object_type": "companies", "identity": {"strategy": "by_email"}, "field_mappings": [] } } }

Legacy wrapper (deprecated)

Before issue #748, these endpoints required the payload wrapped under a single top-level key — inconsistent with POST /sources, which never did. The wrapper key is not the same on every endpoint:

EndpointLegacy wrapper key
PUT /draftconfig
PATCH /draftpatch
PUT /draft/entities/{name}config
PUT /draft/flows/{name}config
PATCH /draft/settingspatch

That wrapper still works during the transition (removal tracked separately, not yet scheduled) when it matches the endpoint’s own key: sending it does not fail, but the response carries a Deprecation: true header (plus an explanatory X-Plugsync-Deprecation header) and the server logs a warning. New integrations should use the flat shape above; existing ones sending the correct wrapper keep working unchanged.

A body is only recognized as the legacy wrapper when its top-level keys are exactly the wrapper key (plus auto_flow on the entity endpoint) — no other fields. ConnectorConfigV3 / EntityV3 / FlowV3 all allow unrecognized extra fields (extra="allow"), so a flat body that happens to declare its own extra field literally named config or patch alongside its real fields is unambiguous and stays flat; only a body with nothing else in it is treated as wrapped.

Trap (issue #776, blind PNO run 1): the PUT-shaped {"config": ...} wrapper does not carry over to PATCH /draft. PATCH /draft’s wrapper key is patch, not config, so {"config": {...}} sent there is not recognized as a wrapper at all — it is parsed as a flat merge patch whose one top-level key happens to be literally named config. Merging that into the draft used to nest it silently (draft.config.config, absorbed by extra="allow" without error). Since #776, ConnectorConfigV3 rejects any top-level key besides version/entities/flows/settings/conflicts (plus the transforms escape hatch) post-merge, so this now fails loudly with a 422 unknown top-level config key(s) ['config'] instead — see ConnectorConfigV3._no_unknown_top_level_keys in (internal plugsync tooling - see the source repo). The fix is to send the config flat to PATCH /draft (the canonical shape above), never wrapped under config.

A validation error inside the payload (e.g. an unsupported version) keeps its own, separate 422 message either way — the flat/wrapper distinction only changes how the body is parsed, never how it’s validated.

Dashboard walkthrough

After any draft write (UI edit, agent edit, script setup), the connector workspace shows an “unpublished changes” banner:

Unpublished changes banner

Dashboard UI as of 2026-07-11 (post step editor v2, PR #520); controls may have moved by the time you read this. The underlying workflow is stable.

Click Review changes to see a diff between your draft and the currently active version:

Review changes modal

Click Publish to commit the new version. The banner disappears and the version label updates:

Published state

Concurrency

POST /publish requires a base_version field. If your draft was started when version 3 was active but someone else published version 4 in the meantime, your publish fails with HTTP 409 and the dashboard shows the DraftConflictModal:

  • Reset draft: drop your changes and start from version 4.
  • Keep editing: keep your draft on disk; you’ll resolve manually.

Restore

The dashboard lets you restore an older version into the draft. Restore does not auto-publish; it copies the old version’s config blob into connector_config_drafts, leaving the active version unchanged. You then review and publish (or further edit) explicitly.

Setup-script behavior

The standard setup scripts ((internal plugsync tooling - see the source repo)) default to draft-only. Pass --publish to also publish atomically. This split keeps CI from publishing by accident.

Open follow-ups (not blocking this reference)

  • The three screenshots above were captured against staging using a dedicated docs-screenshot account and connector (avatar initials “DS”), not an implementer’s personal login, so re-running the capture script doesn’t leak anyone’s account chrome. Re-capture with (internal plugsync tooling - see the source repo) after any dashboard redesign; see the script’s docstring for the current selectors and required env vars.
Last updated on