Skip to content

feat(messaging): let an install end (CHOO-2626) - #476

Open
petr-sandbox wants to merge 3 commits into
work/messaging-install-flowfrom
work/messaging-install-lifecycle
Open

petr-sandbox wants to merge 3 commits into
work/messaging-install-flowfrom
work/messaging-install-lifecycle

Conversation

@petr-sandbox

Copy link
Copy Markdown
Collaborator

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

status gains two terminal values beside active:

  • 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 active rows, 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_id is a real foreign key with no ON DELETE, so the row has to let go before the bridge can be deleted at all. (ON DELETE SET NULL was considered and rejected: the constraint is composite, so a plain SET NULL would null tenant_id too, and column-scoped SET 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_uninstalled and tokens_revoked are checked before resolve, 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/installs and DELETE /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 typecheck clean (255 files), just check clean, single alembic head a7f2c3e9b481, full suite green.

git diff work/multi-tenancy-phase4 work/messaging-install-lifecycle is empty — A+B+C reproduce #435 exactly.

Part of CHOO-2626. Stacked: A → B → C (this).

🤖 Generated with Claude Code

petr-sandbox and others added 3 commits September 15, 2026 10:20
…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>
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