Skip to content

fix(triggers): support multiple subscriptions per (connection, type) and cascade on connection delete#3319

Open
viktormarinho wants to merge 2 commits intomainfrom
viktormarinho/trigger-subscription-id
Open

fix(triggers): support multiple subscriptions per (connection, type) and cascade on connection delete#3319
viktormarinho wants to merge 2 commits intomainfrom
viktormarinho/trigger-subscription-id

Conversation

@viktormarinho
Copy link
Copy Markdown
Contributor

@viktormarinho viktormarinho commented May 8, 2026

Summary

The trigger registration system collapsed multiple subscriptions on the same (connection, event_type) into a single slot, overwriting credentials and wiping callbacks when one of them was disabled. Connection deletion also left orphan state on MCPs because mesh never told the MCP a subscription should disappear.

This PR carries three fixes:

  • bindings + runtime: TRIGGER_CONFIGURE gains an optional subscriptionId. The runtime's TriggerStateManager keys credentials by (connectionId, subscriptionId) instead of connectionId alone, and the TriggerStorage interface gains a list(connectionId) method so notify can fanout to every subscription on a connection. The legacy __default slot keeps single-sub MCPs working unchanged.
  • mesh storage: trigger_callback_tokens gets a subscription_id column (= automation_triggers.id). The unique index moves from (connection_id, organization_id) to subscription_id, with a non-unique lookup index left on the old pair. persistTokenHash/deleteBySubscription/listByConnection all key on the new column. Migration 077 backfills existing rows and ships the index swap.
  • mesh handlers: trigger-add pre-allocates the trigger id before calling the MCP, then reuses it on the DB insert, so configure and persistence agree on the same subscriptionId. automation/delete now passes tokenStorage so per-subscription tokens are cleaned up. connection/delete runs TRIGGER_CONFIGURE(enabled=false) on every event trigger bound to the connection before the row is removed — this stops the orphan accumulation we observed in the gmail MCP, where deleted-and-recreated connections left dead triggers:<connId> records in KV.

Why

Studio assigns one connectionId per OAuth credential and reuses it across automations. Each automation registers its own trigger with a fresh callback token. The previous runtime stored only the latest token, so:

  • Adding two automations on the same Gmail connection meant the second's enable overwrote the first's token.
  • Deleting any automation on a connection wiped every sibling's credentials because disable removed the entire connection record once activeTriggerTypes reached zero.
  • Deleting a connection in studio silently dropped tokens locally without telling the MCP, leaving permanent triggers:<connId> orphans that fired "nobody subscribed" on every webhook delivery for that email afterwards.

After this PR the MCP can hold N independent subscriptions per connection, mesh tracks each separately end-to-end, and connection deletion is a clean cascade.

Schema migration

077-trigger-callback-tokens-per-subscription.ts adds subscription_id, backfills existing rows with subscription_id = id (self-reference; rows stay validatable until the next TRIGGER_CONFIGURE writes a fresh id-keyed row), drops the old unique index on (connection_id, organization_id), and creates idx_trigger_callback_tokens_subscription. A non-unique idx_trigger_callback_tokens_connection_org_lookup is kept for the connection-cascade query path.

Backward compatibility

subscriptionId is optional in the bindings schema. MCPs running an older runtime ignore the field. Studio versions that haven't been updated yet keep working — they fall through to the __default slot in the new runtime, which preserves the single-sub-per-connection semantics they expect.

Test plan

  • bun test in packages/runtime — 15/15 pass, including new tests for sibling-disable, multi-sub fanout, and legacy-default fallback
  • bun test in apps/mesh for src/storage src/automations src/tools/automations src/tools/connection — 211 pass, 1 skip, 0 fail
  • Migration applies cleanly in test schema (77/77 migrations green)
  • bun run check clean in packages/runtime and packages/bindings
  • bun run check in apps/mesh produces only one pre-existing error in delete-organization-section.tsx (unrelated to this PR)
  • End-to-end smoke: add two automations on the same connection with different filter params, fire a matching event, verify both callback URLs receive it

Follow-up (not in this PR)

The gmail MCP at decocms/mcps will adopt the new TriggerStorage interface and update its webhook handler to fanout via list(connectionId). Filed separately so this PR can land independently.

🤖 Generated with Claude Code


Summary by cubic

Enable multiple event trigger subscriptions per connection and cleanly disable them on connection delete. Fixes token overwrites between automations and stops orphaned MCP trigger state.

  • New Features

    • TRIGGER_CONFIGURE accepts optional subscriptionId; runtime keys by (connectionId, subscriptionId) and fans out on notify.
    • Mesh stores callback tokens per subscription (trigger_callback_tokens.subscription_id with a unique index); token APIs updated to use subscription ids.
    • trigger-add pre-allocates the trigger id and reuses it for MCP configure; automation/delete and connection/delete disable MCP subscriptions and remove tokens per subscription.
    • Backward compatible: if subscriptionId is missing, the legacy __default slot is used.
  • Migration

    • Adds migration 077 to add subscription_id, backfill existing rows, and swap indexes; run app migrations.
    • No breaking changes; passing subscriptionId is recommended to enable multiple subscriptions.

Written for commit 2a4e59c. Summary will update on new commits.

viktormarinho and others added 2 commits May 8, 2026 18:50
The TriggerStateManager keyed callback credentials by connectionId, so two
TRIGGER_CONFIGURE calls on the same (connectionId, type) overwrote each
other's tokens and one disable wiped the entire connection's state. This
made it impossible to host independent subscriptions on a shared OAuth
connection — e.g. two automations listening to gmail.message.received
with different filter params.

Add an optional `subscriptionId` to TRIGGER_CONFIGURE input. When present,
it disambiguates registrations on the same connection; when absent it
collapses to a single legacy slot so older mesh versions keep working.

The TriggerStorage interface gains a `list(connectionId)` method so notify
can fanout to every active subscription. Both built-in storages (StudioKV,
JsonFileStorage) implement it.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
… on connection delete

Each automation trigger now lives as its own row in `trigger_callback_tokens`,
keyed on the trigger id. Previously the table was constrained to one row
per (connection_id, organization_id) and re-running TRIGGER_CONFIGURE for
a sibling subscription overwrote the prior token, leaving older triggers
with a stale callback the MCP could no longer authenticate.

The `id` of `automation_triggers` is now passed to TRIGGER_CONFIGURE as
`subscriptionId` so the MCP can store independent records per
registration. `trigger-add` pre-allocates the uuid before calling the
MCP so the configure call and the DB insert agree on the same id.

`connection/delete` now disables every event trigger bound to the
connection on its MCP before removing the connection row. Without this
cascade the MCP retained orphaned subscription state and continued
trying to deliver to invalidated callback tokens.

Migration 077 relaxes the unique index from (connection_id,
organization_id) to (subscription_id), keeping a non-unique
lookup index on (connection_id, organization_id) for fanout queries.
Existing rows are backfilled with subscription_id = id; they remain
valid until the next TRIGGER_CONFIGURE replaces them.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@github-actions
Copy link
Copy Markdown
Contributor

github-actions Bot commented May 8, 2026

🧪 Benchmark

Should we run the Virtual MCP strategy benchmark for this PR?

React with 👍 to run the benchmark.

Reaction Action
👍 Run quick benchmark (10 & 128 tools)

Benchmark will run on the next push after you react.

@github-actions
Copy link
Copy Markdown
Contributor

github-actions Bot commented May 8, 2026

Release Options

Suggested: Patch (2.311.4) — based on fix: prefix

React with an emoji to override the release type:

Reaction Type Next Version
👍 Prerelease 2.311.4-alpha.1
🎉 Patch 2.311.4
❤️ Minor 2.312.0
🚀 Major 3.0.0

Current version: 2.311.3

Note: If multiple reactions exist, the smallest bump wins. If no reactions, the suggested bump is used (default: patch).

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant