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}/draftor 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
| Endpoint | Verb | Purpose |
|---|---|---|
/api/connectors/{id}/draft | PUT | Replace the entire draft config. |
/api/connectors/{id}/draft | PATCH | Partial draft update: RFC 7396 merge patch over the current draft. |
/api/connectors/{id}/draft/entities/{name} | PUT | Upsert a single entity in the draft. |
/api/connectors/{id}/draft/entities/{name} | DELETE | Remove a single entity from the draft. |
/api/connectors/{id}/draft/entities/{name}/discard | POST | Revert one entity in the draft back to the published version. |
/api/connectors/{id}/draft/flows/{name} | PUT | Upsert a single event flow in the draft. |
/api/connectors/{id}/draft/flows/{name} | DELETE | Remove a single event flow from the draft. |
/api/connectors/{id}/draft/flows/{name}/dry-run | POST | Run 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/settings | PATCH | Partial update of config.settings: RFC 7396 merge patch. |
/api/connectors/{id}/publish | POST | Publish 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).
| Endpoint | Body shape | Notes |
|---|---|---|
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 key | auto_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}/discard | none |
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:
| Endpoint | Legacy wrapper key |
|---|---|
PUT /draft | config |
PATCH /draft | patch |
PUT /draft/entities/{name} | config |
PUT /draft/flows/{name} | config |
PATCH /draft/settings | patch |
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:

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:

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

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.
Related reference
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.