ADR 0201 — Revocation targets the KEY, not the {project, user} pair
Status
Accepted. 2026-10-01 (AT-013, story HS-008, epic EP-003, round 36; owner decision on 01/10: "yes, revocation by KEY, with an ADR — key column in the socket ticket and a new auth contract; revoking drops only the connections opened with that key. It touches the engine, on purpose."). Revises RN-520 — born in ADR 0147 point 6 — and references, without editing them, ADR 0154 (the MACHINE key, which put "Revocation by key" among what it did NOT do) and ADR 0155 (the machine key born in the terminal and registered by the installation). New rule: RN-685.
Context
Since RN-520, revoking a device key drops the runner's LIVE connection, and not
only the next ticket. The target of that drop was {project, user}, and RN-520
said why: the identity of the credential that requested the ticket — the key's
kid (which is the record id, RN-475) or the PAT — died in the PatAuthGuard.
runner_socket_tickets stored project_id, user_id and kind, and nothing
else; the channel only knew how to compare the user.
Measured in the code before changing (origin/dev, ec0fbea52d):
- How the ticket is born.
POST /projects/:projectId/runner-ticketgoes throughPatAuthGuard, which authenticates by PAT (validarEUsarreturns{id, userId, projectId}) or by device key (the JWT header'skidfinds the public key inrunner_device_keys;chave.idis thatkid). The guard set onlyrequest.user.RequestRunnerTicketUseCaseasked the enginePOST /internal/projects/:projectId/runner-ticketswith{userId, kind}, andEngine.Runners.SocketTicket.emitir/3recorded the row. - How the engine consumes.
EngineWeb.RunnerSocket.connect/3validates the ticket and putsproject_id/user_id/kindinassigns; the socket'sid/1wasrunner_socket:<kind>:<projectId>:<userId>; the channel'sjoin/3consumes and registers the presence inEngine.Runners.Registry(:global, at most ONE runner per project in the cluster). - Where revocation dropped.
RevokeRunnerDeviceKeyUseCaserecords the revocation and callsdisconnectRunnerOfUser(projectId, userId)—POST /internal/projects/:projectId/runner/disconnect—, the engine finds the pid through theRegistry, the channel comparesuser_idand broadcastsdisconnectto its OWN id. For the MACHINE key (null project) PR #534 made the target PLURAL: the same pair, once perrunner-mode project the owner reaches (listRunnerModeReachableBy). - What drops today that should not. The same user's runner connected with a PAT, or with another key, in that project. With the machine key, the cost multiplies: revoking the laptop's key drops the desktop's agent in every project where both cross.
- What does NOT drop today and should. (1) The machine key's connection in a
project the api's list does not have — an owner's role that dropped, a project
converted in mode with the runner up. (2) The ticket requested seconds BEFORE
the revocation and used AFTER:
connect/3andjoin/3do not ask about the credential. (3) Revoking a PAT dropped nothing — it only prevented a new ticket.
AT-013 was blocked for two stacked reasons: it was outside the PHASE 30 cut, and changing the target is changing what "revoke" means. The owner decided.
Decision
-
The ticket records WHICH credential requested it. Two NULLABLE columns in
engine.runner_socket_tickets—credential_kind("device_key"|"pat") andcredential_id(thekid, or the PAT id) —, Ecto migration20261001120000_add_credential_to_runner_socket_tickets.exs, additive, no backfill (tickets live 30 s). The table is the ENGINE's (schemaengine), so the migration is its own; the Drizzle journal does not change (stays at0068). Null is a legitimate state: theterminalticket is the web tab's, authenticated by session, and arunnerone issued by an api predating this ADR arrives without it during the rollout. -
The auth contract now carries the credential up to the engine.
PatAuthGuardfillsrequest.credencialDeDispositivo({tipo: 'pat', id}or{tipo: 'device_key', id: chave.id}) after authorizing; the controller passes it on;RequestRunnerTicketUseCasesends it only withkind: "runner"(theterminalone never carries it, even if someone passes one); the internal request gainscredentialKind/credentialId, which only travel together. The engine normalizes the pair (SocketTicket.credencial/2): an unknown kind or empty id becomenil, never an invented credential — and the ticket still works. -
The engine keeps the credential in the socket.
connect/3setsassigns.credencial, andid/1gains two segments when it exists —runner_socket:<kind>:<projectId>:<userId>:<espécie>:<id>—, so thedisconnectthe channel broadcasts to its OWN id reaches only that credential's connections. Without a credential, the id is the previous one. -
Revoking ONE credential has its own route and its own target.
POST /internal/runner/disconnect-credential(no:projectId: the machine key does not have one),Engine.Runners.Revogacao.derrubar_credencial/4:- voids the credential's PENDING tickets (
consumed_atfilled — the same "already used" state thatvalidar/1andconsumir/2refuse, no new state), closing the window between issuing and using; - asks EVERY runner registered in the cluster (
Registry.todos/0) whether it was born from this credential — only the channel knows, because the credential lives inassigns. Whoever was born from it drops (transport +{:stop, …}releasing the presence); whoever was not answers:intocadoand stays up; - answers 200 with a TALLY (
derrubados,legados,intocados,semResposta,ticketsAnulados), with a 5 s ceiling for the WHOLE set. 400 only for a malformed credential — so "I targeted nothing" never passes for "there was nothing".
The MACHINE key stops being the pair applied N times: it is ONE request, and it drops in every project where it opened a connection, including one the api would not list.
- voids the credential's PENDING tickets (
-
Key and PAT revocation use that target.
RevokeRunnerDeviceKeyUseCase(and, by delegation, the Account door, RN-611) asks for the drop by{tipo: 'device_key', id}; both PAT revocations (the owner's, RN-426, and themaintainer's, RN-427) now drop by{tipo: 'pat', id}— before they dropped nothing. The order does not change: revoke FIRST, drop afterwards, and dropping is a side effect that only LOGS. -
Not failing to drop what already dropped (AT-013's prohibition). Two TRANSITION pieces, and neither is the target:
- The LEGACY connection (ticket without a credential) of the same owner,
in one of the projects the api sends as reach (the row's, or the machine's
candidates), drops by the pair as before (
:derrubado_legado). Only for a device key: the PAT dropped nothing before, so it gains no legacy reach — it would gain a drop it never had, on someone who has nothing to do with it. - PLAN B: if the engine does not serve the by-key request (an engine
predating this route answers 404), key revocation falls back to the old
target,
disconnectRunnerOfUserper project. Dropping too much is reversible; failing to drop would reopen RN-519.
- The LEGACY connection (ticket without a credential) of the same owner,
in one of the projects the api sends as reach (the row's, or the machine's
candidates), drops by the pair as before (
-
{project, user}still exists, with ANOTHER owner.POST /internal/projects/:projectId/runner/disconnectandRevogacao.derrubar/3stay byte for byte (with the finer socket id): it is the target of MEMBER REMOVAL (RN-615), which removes the PERSON from the project and needs to drop any connection of theirs, with whatever credential — besides plan B above. -
The screen changes the text, not the behaviour. The revoke confirmation — in the device keys section of Settings and in the machine keys section of Account — swaps the "collateral" ("another runner of yours in the same project also drops") for PRECISION ("only what connected with THIS key drops"), because RN-561 requires the screen to state the right reach and the old sentence would assert a drop that does not happen. It is the answer to AT-013's
TODO(humano)("does the screen change behaviour or only label?"): only label — the click, the route and the invalidation are the same. -
The runner does not change. It already sent the
kidin the JWT header, and that is all the path needs. Not one line ofapps/runner/.
What this ADR does NOT do
- It does not change what AUTHORIZES. No new ceiling, none loosened:
PatAuthGuarddecides as always, and the credential is only ANNOTATED after authorizing. This ADR moves the drop's target, never the authority. - It does not drop the web's Terminal tab. The
kind: "terminal"socket has no device credential, does not occupy theRegistryand is never asked — as before. - It does not close a ticket issued and not yet CONNECTED with an old api's credential. A ticket without a credential (issued by an earlier api) is not voided on revocation: there is nothing to compare. The window is 30 s and only exists during the rollout.
- It does not give the
maintainerview revocation of another user's key. It stays out by RN-519's decision.
Consequences
- The same user's other runner stays up. The cost RN-520 declared goes away: revoking the laptop's key does not drop the desktop connected with another key, nor the runner connected with a PAT.
- Revoking the machine key is ONE request, and more complete than the plural. It reaches projects the api's list does not have. The price is asking EVERY runner in the cluster — linear in connected runners, only when someone revokes, and with a 5 s ceiling for the set.
- Revoking a PAT now drops. It is an observable behaviour change: the runner
connected with a revoked token drops immediately, instead of going on until it
reconnects. For the
maintainer's revocation (incident response, RN-427) it is what was missing for it to contain anything. - The tally replaces the single outcome in the key revocation log. The four
outcomes of
derrubar/3still exist for member removal. - Rollout without operator action. The migration is additive and runs in the
usual migration job (
Engine.Release.migrate/0). New api with old engine: the ticket request's new fields are ignored and revocation falls into plan B. Old api with new engine: tickets without a credential, which drop by the pair (legacy), and the old revocation keeps calling the pair route, which did not change. That is why the branch isfeature/and notbreaking/. - The runner socket's id changes shape. Nothing outside reads it — it is
Phoenix's internal
disconnecttopic —, and a socket opened before the deploy dies with the old pod. - Engine proof only in CI. The ExUnit (real channel with a credential, a PAT
that stays up, a machine in two projects without a list, legacy by the pair, a
pending ticket voided, controller 200/400) was written without running locally
(
repo.hex.pmgives 403 in this environment); syntax and formatting checked.