Promotion and version pinning: dev -> staging -> live
A Plugin row carries a status from this set ((internal plugsync tooling - see the source repo), PluginStatus): draft, dev, promoting_to_staging, staging, promoting_to_live, live, error.
draft: created, no bundle deployed yet (or nothing pushed since creation).dev:plugsync plugin pushsucceeded andoutbox_dispatchercreated/updated your per-developer Lambda function, always invoked at$LATEST. See dev-loop.md.staging/live: reached viaplugsync plugin promote <id> staging|live(orplugin publish, an alias). Each promotion publishes a new immutable numbered version on the shared, per-org Lambda function — the same function for both targets, just a different version number recorded on thePluginrow (staging_versionorlive_version).
Nothing in POST /api/plugins/{id}/promote requires the plugin to already be in a particular status before promoting: you can call promote ... live directly from dev without ever promoting to staging first. staging is a real, independently invokable Lambda version, not a mandatory gate.
Promoting
plugsync plugin promote <plugin_id> staging
plugsync plugin promote <plugin_id> live # asks for interactive confirmationPOST /api/plugins/{id}/promote {"target": "staging"|"live"} sets the plugin’s status to promoting_to_staging/promoting_to_live and enqueues a deploy_plugin_versioned outbox row. When outbox_dispatcher drains it (_handle_deploy_plugin_versioned in (internal plugsync tooling - see the source repo)):
- It ensures the shared org function exists (
plugsync-{stage}-plugin-{plugin_id:.8}), pushes your currently-storedbundleas its code, and callslambda:PublishVersionto cut a new immutable numbered version. plugin.function_arnis set to that function’s ARN;plugin.live_versionorplugin.staging_versionis set to the new version number;plugin.statusbecomesliveorstaging;plugin.last_deployed_atis stamped.- A
propagate_plugin_refoutbox row is enqueued, which rewrites the embeddedpluginblock in every already-published, active config version that references this plugin bylogical_name— so connectors that were already live pick up the new ARN/version without a fresh publish. Connectors where resolution fails (e.g. a live connector referencing a plugin that has never been promoted to live) are skipped with a warning log and retried on the next propagate.
How resolution actually works today
app/services/plugin_resolver.py:resolve_plugin_function has exactly two automatic paths, keyed off Connector.mode (which is only ever "live" or "test", set at connector creation and immutable after):
| Connector | Resolves to |
|---|---|
mode == "test" | The per-developer function at $LATEST (requires connector.created_by_id; raises otherwise). |
anything else (i.e. mode == "live") | The shared org function at plugin.live_version (raises if the plugin has never been promoted to live). |
There is no automatic “staging connector” resolution path. plugin.staging_version is recorded on promotion but nothing in resolve_plugin_function reads it — promoting to staging publishes a real, invokable Lambda version and updates the Plugin row’s bookkeeping, but no connector is routed to it automatically. If you need to exercise a staged build against a specific connector before promoting it to live, use the explicit override below.
Pinning a connector to an explicit build
An explicit per-connector override in connector.settings.plugin_overrides wins over both paths above, unconditionally, regardless of connector mode:
curl -X PATCH "$BASE/api/connectors/$CONNECTOR_ID" -H "Authorization: Bearer $JWT" -H "Content-Type: application/json" \
-d '{"settings": {"plugin_overrides": {"my-plugin": {"function_arn": "arn:aws:lambda:eu-central-1:...:function:plugsync-dev-plugin-abcd1234", "version": 3}}}}'version can be a numbered version (int, e.g. a plugin’s staging_version) or the literal string "$LATEST". This is the only mechanism today for pointing a given connector at a specific plugin build outside the test/$LATEST and live/live_version defaults — for example, running a staging build against one connector before promoting it org-wide to live.
Where the resolved coordinates actually end up
You never author function_arn/version/capabilities on a flow step. You author:
{"id": "generate_doc", "action": "plugin",
"plugin": {"logical_name": "compass_doc_generator", "config": {"store_keys": ["$.payload.object.id"]}}}At publish time (_resolve_and_embed_plugin_refs in (internal plugsync tooling - see the source repo)), the backend looks up the Plugin row by (org_id, logical_name), calls resolve_plugin_function, and rewrites the step’s plugin block into the full embedded form:
{"logical_name": "compass_doc_generator",
"function_arn": "arn:aws:lambda:eu-central-1:...:function:plugsync-dev-plugin-abcd1234",
"version": 3,
"capabilities": {"http": ["generate-doc.example.cloudfunctions.net"], "store": ["read", "write"], "auth": ["doc_generator"]},
"config": {"store_keys": ["$.payload.object.id"]}}capabilities here always comes from the current Plugin.capabilities row, never from anything authored on the step. This resolved block — not the authored one — is what gets replicated to the DynamoDB manifest the flow_worker reads at runtime: zero PostgreSQL lookups in the hot event path. Publishing fails with 422 if the referenced plugin doesn’t exist in your org, or if resolution itself fails (e.g. a live connector referencing a plugin with no live_version yet).