feat(messaging): let an install end (CHOO-2626) - #476
Open
petr-sandbox wants to merge 3 commits into
Open
petr-sandbox wants to merge 3 commits into
petr-sandbox wants to merge 3 commits into
Conversation
petr-sandbox
marked this pull request as ready for review
September 15, 2026 14:11
petr-sandbox
requested review from
amaudruz and
christian-mcdermott
as code owners
September 15, 2026 14:11
…O-2626) `messaging_installs` made `(platform, external_workspace_id)` unique across the deployment so that an inbound event from a workspace has exactly one tenant to go to. That is still the guarantee. The constraint enforcing it was too strong: nothing deletes an install row, so the claim outlived the install and a workspace could be connected once, ever. The error a customer got told them to remove the existing install, through a path that did not exist. What has to be unique is the set of installs that are serving. The constraint becomes a unique index over `status = 'active'`, under the same name — the store reads that name out of the integrity error to tell a claimed workspace from any other failed write, and Postgres reports a unique index by the name a constraint would have carried. `tenant_of_messaging_install` is redefined against the same predicate, and that part is load-bearing rather than tidy. It resolves a workspace to a tenant for traffic nobody has authenticated, and it was written relying on the old constraint to answer at most once; the caller refuses an ambiguous answer. Left alone, the first workspace to be installed, released and installed again would make it answer twice, and the customer's live install would stop receiving events because of one they had themselves ended. `get_for_workspace` takes the same predicate for the same reason. `ended_at` records when, alongside the `status` that says which of the two ways it ended — a decision here, or news from the platform. An operator looking at a bridge that stopped working needs to know which. `encrypted_bot_token` becomes nullable so an install that has ended can keep its record without keeping its secret. Nothing yet writes any of this: no store method ends an install and no route calls one. This is the schema that makes those possible. `_REDEFINED_SINCE` is new bookkeeping in the lookup test. Creating and dropping a function both show up as a function that is there or is not; a redefinition leaves one of the right name and signature answering a different question, which nothing about the shape of the schema reveals. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
An install could be made and never unmade. Disconnecting one now revokes the credential at the platform, removes the bridge, and marks the row ended so the workspace is free to be installed again — and the two events by which a platform says an install is over are read the same way. The order is deliberate. The platform is told first, so a refusal nobody understands leaves the install exactly as it was and the operator can try again rather than finding the bridge gone and the token still live. The row is ended next, which also releases its pointer at the bridge — the foreign key has no ON DELETE, so nothing could delete the bridge while the install still named it. The bridge goes last: if that fails, what is left is a credential-less bridge an operator can see and remove, not an install still claiming a workspace it has been thrown out of. Ending twice is success on both paths. Slack redelivers its uninstall event, and an operator can click disconnect on a row a redelivery ended a second earlier; neither is a fault to report. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The operator's two: list this organisation's installs, ended ones included, and disconnect one. The list keeps ended rows deliberately — somebody looking at it is usually looking because something stopped working, and "nothing here" is the wrong answer to "what happened to the bridge that was here yesterday". Neither carries a token or anything derived from one. On the webhook side, an uninstall is now read before the event is routed rather than after. It has to be: resolving insists on a running bridge, and this is the one event that arrives as the bridge goes away, so the ordinary path would drop it exactly when it mattered. An event for a workspace nobody holds now answers 200 instead of 404. That is the single place this endpoint says something other than what happened, and it is deliberate: an app left in a workspace whose install ended posts for as long as someone leaves it there, the platform cannot act on the refusal, and it counts refusals against the app as a whole — so the honest answer would be paid for by every other customer's delivery. The drop is in the log. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
petr-sandbox
force-pushed
the
work/messaging-install-lifecycle
branch
from
September 15, 2026 14:20
9b4a2d7 to
33e4992
Compare
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.
Last of three stacked PRs splitting #435. Base is
work/messaging-install-flow(PR B) — review A and B first; the diff shown here is only what C adds.A and B build an install. This one takes it apart again, from either end: the operator disconnecting it, or Slack saying the app was removed. Until this lands an install is one-way, and the first customer who uninstalls leaves a live bot token in the database and a bridge posting into a workspace that has thrown it out.
An install can now end
statusgains two terminal values besideactive:disconnected— ended here, by someone who decided to.revoked— ended there: the platform told us the app was removed or its token killed.They are distinct because an operator whose bridge stopped working needs to know whether it was news or a decision. The workspace uniqueness index now covers only
activerows, so a workspace that has been disconnected can be installed again — by the same tenant or by another.end()drops the encrypted token and the bridge pointer as it writes the status. A worthless credential still reads like a live one to whoever finds the dump; keeping the row is the record, keeping the secret is not part of it. Ending an install twice is success, because the two ways in can race.The order things are destroyed in
Tell the platform first, then destroy:
auth.revoke→ end the row and commit → remove the bridge.If Slack refuses for a reason we do not recognise, the install is left intact and retryable. The alternative — destroying the bridge first — leaves a live key in the next dump and nothing pointing at it to revoke.
The row before the bridge is not a preference.
messaging_installs.bridge_idis a real foreign key with noON DELETE, so the row has to let go before the bridge can be deleted at all. (ON DELETE SET NULLwas considered and rejected: the constraint is composite, so a plain SET NULL would nulltenant_idtoo, and column-scopedSET NULL (bridge_id)is Postgres 15+.) And if the deletion then fails, what is left is a bridge with no credential that an operator can see and remove, rather than an install still claiming a workspace it has been thrown out of. The lifecycle stub in the tests really deletes the row, so a caller that forgets to release the pointer fails here rather than in production.A token Slack already considers dead —
invalid_auth,token_revoked,account_inactive— is a success, not a failure.Slack ending it
app_uninstalledandtokens_revokedare checked beforeresolve, not after, because the two disagree about what a missing bridge means: this is the one event that arrives as the bridge it would resolve to is going away.One behaviour change comes with it. An event naming a workspace nobody has installed now answers 200 instead of 404. Slack cannot fix it — an app left in a workspace goes on posting for as long as someone leaves it there — and telling it repeatedly that its posts fail is held against the app as a whole.
Operator surface
GET /messaging-apps/installsandDELETE /messaging-apps/installs/{id}. The list includes ended installs deliberately: an operator looking at it is usually looking because something stopped working, and a list of only the live ones answers "there is nothing here" to the question "what happened to the one that was here yesterday". No token and nothing derived from one is in the response. There is no UI for it yet — that is the next piece of work.Known follow-up
Disconnecting removes the bridge, which detaches its rooms to internal-only. Re-installing the same workspace builds a new bridge and does not reattach them. Preserving room links across a re-install needs a reattach path and is not in this PR.
Checks
just typecheckclean (255 files),just checkclean, single alembic heada7f2c3e9b481, full suite green.git diff work/multi-tenancy-phase4 work/messaging-install-lifecycleis empty — A+B+C reproduce #435 exactly.Part of CHOO-2626. Stacked: A → B → C (this).
🤖 Generated with Claude Code