Record what each app answered when one of them refuses an offboarding - #585
Merged
davidmckayv merged 2 commits intoSep 16, 2026
Merged
Conversation
Follow-up to CopilotKit#574, at zopeVaibhav's request on that thread. `retireConnectionsFor` asked the broker to withdraw every brokered account in one loop, then deleted every row, then wrote every audit row. `ComposioBroker.revoke` throws, so one app refusing ended the act before the delete and before the trail — and the apps already withdrawn at Composio kept their `composio_connections` row, kept their `(toolkit, user_id)` gate, and left nothing behind saying the account had ended. That was survivable while repeating the act did nothing. CopilotKit#574 made repeating it the documented recovery and its changelog line tells an administrator to take it, so the second pass asks again for those apps, Composio answers false because the accounts are already gone, and each writes `vendorRevocationRequested: false` about a withdrawal this deployment asked for and got. That field exists for exactly that distinction. Its own docblock: "a reader has to be able to tell an account this deployment acted on from one that outlives it somewhere else." After a partial refusal the rows said the wrong one, permanently, and no later act could correct them. So the answer is kept per app and the refusal is held rather than thrown. Every app is still asked, so a later one is not punished for an earlier one; the apps that answered lose their rows and are recorded with the answer they actually gave; and the first refusal is rethrown after the loop, so the act still fails and the administrator still gets a 500. The delete is now scoped to the apps that answered rather than to the person. Deleting by user id would take the rows of apps that were refused or never reached, and those are the rows the recovery needs — the criterion "an offboarding the vendor refuses leaves the connection standing" already states it. What changes is that the rule now applies to the app it is about rather than to every app in the same act. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Hotragn
requested review from
MikeRyanDev,
davidmckayv,
guidovizoso,
mxmzb and
tylerslaton
as code owners
September 16, 2026 20:19
4 tasks
# Conflicts: # CHANGELOG.md
davidmckayv
approved these changes
Sep 16, 2026
davidmckayv
left a comment
Contributor
There was a problem hiding this comment.
Code-verified clean; CI green on this sha.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
What this changes
Follow-up to #574, which @zopeVaibhav asked for on that thread after we established the case in review. This is his preferred option of the two I offered: a
catchper app that records the answer that app actually got, rethrowing after the loop, so the revoke-before-delete order the docblock argues for is kept.retireConnectionsForasked the broker to withdraw every brokered account in one loop, then deleted every row, then wrote every audit row:ComposioBroker.revokethrows, so one app refusing ended the act before the delete and before the trail. With five apps and a refusal on the fourth, apps one to three were already withdrawn at Composio, kept theircomposio_connectionsrow, kept their(toolkit, user_id)gate, and left nothing behind saying the account had ended.Why it matters now rather than before
That was survivable while repeating the act did nothing. #574 made repeating it the documented recovery and its changelog line tells an administrator to take it — so the second pass asks again for those three apps, Composio answers
falsebecause the accounts are already gone, and each writesvendorRevocationRequested: falseabout a withdrawal this deployment asked for and got.That field exists for exactly that distinction, and its own docblock says so:
After a partial refusal, those rows said the wrong one — permanently, with no later act able to correct them.
Credit where it is due: zopeVaibhav corrected my account of the mechanism in review. The
falsedoes not come fromours.length === 0atcomposio-adapter.ts:3783— the configs are still this deployment's and still readable. It comes one listing further in, at the terminalreturn ids.length > 0(:3931), because the accounts are gone. Configs intact, accounts withdrawn, answerfalse, which is harder to spot than a missing config would be.Where it runs
vendorRevocationRequestednow carries this app's own answer rather than a map lookup that may not have been reached.Boundary and audit
mcp.account_disconnectedrow, where previously they got none. No new event type; no row is written for an app that was refused, because nothing happened to it.The refusal is still loud. Holding refusals is only correct if they are still thrown, so the first is rethrown after the loop and the route still answers 500.
an offboarding the vendor refuses leaves the connection standingis unchanged and still passes — the second new test pins the inverse risk, that collecting refusals could turn a failed offboarding into a reported one.The delete is now scoped to the apps that answered rather than to the person. Deleting by user id would take the rows of apps that were refused or never reached, and those are exactly the rows the recovery needs — which is the criterion that existing test already states. What changes is that the rule now applies to the app it is about rather than to every app in the same act.
Changelog
CHANGELOG.md, underUnreleased.Proof
Two tests in
server/tests/composio-connections.test.ts, on the existing two-app fixture.Against a migrated
openbot_testonpgvector/pgvector:pg17— the same image and credentials CI uses — with the fix reverted and the tests present:On this branch:
Whole server suite, both with the database up:
mainThe seven are identical on both and none is in this file — four are
tool-selection fixture model environment restoration, plusproduction server loader boundary,blank package endpoint…, and one 5s timeout.+2is exactly the two tests added.cd server && bunx tsc --noEmitis clean. Biome check on the three changed files: one formatting fix, applied.What is not covered
recordAuditEventitself. The order is still delete-then-record, so if the audit write fails after a successful delete, that app's row is gone and no trail row exists, and the retry finds nothing to redo. That is unchanged from before this PR and is really a question about what to do when the trail is the thing that is unavailable — worth its own issue, not a clause here.🤖 Generated with Claude Code