ADR 0154 — The machine device key
Status
Accepted on 2026-09-27 (AT-108), after being implemented — the same path as
ADR 0055. It was born
Proposed in session 1 of PHASE 30 (2026-09-11) and was exercised by
RN-543..552 in sessions 2 to 8, with the closing narrated in
historico-de-fases.md and in
fase-30-runner-por-maquina.md.
Where the text below diverges from the code, the decision was NOT rewritten: the
divergence is named in "Acceptance notes", at the end.
First of the two decisions of PHASE 30. The other is ADR 0155, and it depends on this one: without a machine identity, there is nothing for the installer to register.
Context
The product direction is that of
PHASE 29: install Brabo in
one line and have the local agent up. The installer already comes close —
since RN-531 it downloads the binary, checks it against the signed
checksums.txt and installs it with install -m 0755, without a manual
chmod — and then stops. The last line of its output says why, in so many
words:
Não pareia o agente local com um projeto: o binário e a base ficam prontos aqui, mas a chave de dispositivo e o
brabo-runner.config.jsoncontinuam vindo da tela do projeto (ADR 0118).
The binary stays on disk, idle. Whoever wants to use it goes back to the project
screen, chooses the folder, and ends up pasting into the terminal the command
that apps/web/src/lib/runner-bootstrap.ts:398 builds —
chmod +x ./brabo-runner && ./brabo-runner. The comment right above it explains
why the screen cannot eliminate that step, and the argument is right:
Uma página web não executa binário na máquina de ninguém: este passo é humano em qualquer desenho, e o que dá para fazer é encolhê-lo a UMA linha copiável.
What the argument does not cover is that the installer is not a web page. It
runs in the terminal, with a TTY, and already asks — it is the whole design of
ADR 0150, which refuses curl | sh precisely
so it can ask. The human step is unavoidable on the screen and avoidable there.
What blocks is not the binary: it is the identity
brabo-runner service install has existed since RN-518
(ADR 0147 point 5) and installs the agent
as a user service (systemd --user/LaunchAgent). Nobody calls it from
install.sh, and it is not forgetfulness: it needs a projectId and a device
key, and at installation time no project exists — no account, no workspace.
The .env was just born, the compose just came up, nobody has signed up.
PHASE 29 measured the coupling and named it: "one runner per project is imposed
in five independent places". Measured again here, with arquivo:linha:
| # | where | what it imposes |
|---|---|---|
| 1 | apps/engine/lib/engine_web/channels/terminal_channel.ex:204 | the topic is terminal:<projectId> |
| 2 | apps/engine/lib/engine_web/channels/runner_socket.ex:73 | the socket id is runner_socket:<kind>:<project_id>:<user_id> |
| 3 | Engine.Runners.SocketTicket (runner_socket_tickets) | the ticket carries project_id and the join requires it to match |
| 4 | apps/api/src/db/schema/auth.ts:325-326 | runner_device_keys.project_id is NOT NULL |
| 5 | apps/runner/src/servico.ts:264,391 | the unit is brabo-runner-<projectId>.service |
Three of the five do not need to change, and that is this decision's discovery
The obvious cut would be to cross all five. Measuring the socket, it shrinks.
RunnerSocket.connect/3 (runner_socket.ex:33) authenticates by ticket,
and the ticket is requested from the api per project. The project_id arrives
in socket.assigns and the atomic consumption happens in the join, against
the topic's project_id. That is: items 1, 2 and 3 describe a connection,
not a process. A single process that opens N connections — one per project
it serves — satisfies all three byte for byte, without touching the topic, the
socket id, the ticket, nor the refusal of a second runner in the same project,
which remains the guarantee it is today.
What remains is what genuinely belongs to the process: the credential with which it requests the N tickets (item 4) and the unit that keeps it up (item 5). Those two are what this ADR moves.
And today the credential is born tied to a project
runner_device_keys.project_id is NOT NULL (auth.ts:325-326), and the key
is generated by the browser inside a project's screen
(ADR 0118,
RN-464..466, RN-475). A machine with three projects in runner mode has three
keys, three folders with brabo-runner-device-key.jwk.json, three service units
and three processes — and the person went three times through the same browser
flow to describe one machine.
Decision
The local agent's identity now belongs to the MACHINE, not to the project.
1. runner_device_keys.project_id becomes nullable, and NULL means machine
No new table is born. A machine key is a device key with project_id NULL —
same table, same public_key_jwk column, same kid recorded inside the private
JWK (RN-475), same revocation.
A sibling table was considered and rejected: PatAuthGuard finds the public key
by kid and does not need to know what kind it is; two tables would do two
lookups, and the second would be forgotten exactly once. RN-519 (listing) and
RN-520 (revocation that reaches the live connection) remain valid with no new
code, with a consequence declared below.
NULL and not a sentinel (project_id = '00000000-…'): the FK is real and a
sentinel would require a fake row in projects to satisfy it.
2. A machine key is valid for the user's projects, resolved at ticket time
POST .../runner-ticket remains per project — it is what carries the
project_id of item 3. What changes is what it accepts as a credential: a
machine key of the same user becomes valid for any project in which that
user reaches the role the route already requires. No new ceiling, and none
loosened: authorization remains the usual one, resolved against the requested
project.
What the machine key does not do is widen reach: it gives the runner nothing its owner did not already have. It swaps "one credential per project" for "one credential per machine", and that is all.
3. The runner discovers the projects it serves, and does not guess them
A new route, GET runner/projects (the route's minimum is developer, the same
as runner-ticket): the user's projects in execution_mode: runner, with the
workspace_dir_name and the verification state of each one. The runner queries
it at start and opens one connection per project it lists.
It asks instead of sweeping the base: the base is the user's and may have a
folder that is no project at all, and guessing by folder name is the class of
error ADR 0141 rejected when it
forbade PROJECT_WORKSPACES_HOST_DIR as the base.
4. The service unit becomes ONE per machine
brabo-runner.service / dev.brabo.runner, without projectId in the name.
The original argument of servico.ts:32-38 — "naming by the folder would allow
installing two units that can never be up at the same time" — disappears along
with the premise: with one process per machine there are not two.
Restart=on-abnormal stays byte for byte, for the reason of RN-518: exit 1 is a
fatal join refusal or an exhausted attempts ceiling, and restarting would be the
loop the CLI refuses to do on its own.
5. Per-project units keep working, and the conversion is stated
A new binary with an old unit installed does not break, and status answers
about both. Whoever has three per-project units keeps three processes until
running service uninstall on each one — and the machine unit's status
says that they exist, instead of leaving six processes competing for three
projects silently.
Corrected in the implementation (RN-545). This ADR said that
service installwithout--projectwould install the machine unit. Measured, it does not work: the absence of the flag already has a meaning —resolverProjetofalls back to the folder'sbrabo-runner.config.json, and the normal path of whoever uses the product today is to runinstallwith no flag at all from inside the folder the browser configured. Taken literally, the rule would silently convert the installation of whoever already uses the product — the opposite of what this very point promises. The discriminator is the--machineflag, explicit. Edited instead of contradicted because this ADR is stillProposed: an accepted ADR is never edited, the new one references the old.
What this ADR explicitly rejects
- Merging the topics into a
runner:<userId>. It would be crossing items 1, 2 and 3 to gain nothing: N connections already satisfy them, and the refusal of a second runner per project — which is a real guarantee — lives precisely there. - A machine key that crosses users.
runner_device_keys.user_idremainsNOT NULL. A machine shared by two people means two keys, and that is the right answer: the credential describes who acts, not only where. - Discovering projects from the disk. See point 3.
- Per-key revocation. RN-520 reaches
{projeto, usuário}by construction, and this ADR does not change it — it only makes its cost bigger, which is in Consequences. - Any exception in
decide.ts. The five absolute ceilings, the lexical scope of ADR 0055 and the auto-approval floor of RN-493 stay as they are. This ADR moves identity, never authority. RunnerReadinesswith a flag. The three preconditions of RN-507 stay byte for byte, for the reason already recorded in ADR 0147 point 4: it is through such a flag that the third would fall by accident.
Consequences
- Revoking now costs more, and that was foreseen. PHASE 29 declared: "when PHASE 30 makes the runner per machine, revoking will bring down all the projects of that machine. Cost declared here so it is not discovered there." That is exactly what happens, and the RN-519 listing now needs to say which kind each key is — a machine one is not "project X's key".
- One process instead of N. Less memory and fewer units, and a single point of failure where there were N independent ones: the service going down takes the local agent off all the projects of that machine.
espelhoandworkspace_createdo not change. Both are per project and travel in the grant of that connection'sjoin(RN-516, RN-532). With N connections, each one keeps receiving its own — and project B's mirror destination remains impossible to reach through project A's connection, which is the guarantee RN-515 exists to give.- The browser flow (ADR 0118) is not removed. It remains the path of whoever already has an installation, and becomes the second one. Deciding whether it should disappear is what BRB-031 calls the maintainer's decision, and it is not a consequence of this delivery.
- The
workspacecapability (RN-532) gains a second reason to exist. It already depended on STATE (consented base); with the machine agent, it is through it that the server knows that process can materialize the folder of a freshly created project — which is the "link the folder later" this phase pursues.
Acceptance notes (2026-09-27)
Checked against the code at acceptance. The decision stays as it is; these are the points where the text and the code diverge, and where the code resolved it:
- Point 1 — "RN-519 and RN-520 remain valid with no new code". They did not
remain without code. The listing now includes machine keys in every project of
the owner
(
apps/api/src/infrastructure/persistence/drizzle/runner-device-key.repository.ts:88) and says the kind, derived fromproject_idin a single place (:23); the revocation of a machine key now callsdisconnectRunnerOfUseronce per project inrunnermode of the owner (apps/api/src/application/use-cases/auth/revoke-runner-device-key.use-case.ts:120). It is the consequence the Consequences already foresaw, paid in code (RN-543). - Point 3 — "the route's minimum is
developer". There is no@RequireRoleonGET runner/projects: there is no project in the path against which to resolve it. The minimum is applied per ROW, with the project's effective role (apps/api/src/application/use-cases/runner/list-runner-projects.use-case.ts:75), and only a MACHINE credential gets in — one tied to a project is 403 with its own message (apps/api/src/interfaces/http/auth/pat-auth.guard.ts:279). The listed set is the same the point promises (RN-543). - Point 4 — the unit's discriminator. Already corrected in the text itself,
in session 4, while
Proposed: it is the--machineflag (apps/runner/src/servico.ts:898,resolverEspecieat:912), never the absence of--project(RN-545). - Point 5 — the two kinds coexisting. The text describes per-project units
and the machine one installed together, with
statuspointing out the overlap. The code does not let it get there:installREFUSES when the OTHER kind is already installed, naming theuninstallof each unit, without removing anything and without--force(apps/runner/src/servico.ts:993,recusaDeSobreposicaoat:1269).statusstill says in text that the other one exists — what remains of point 5 is the case of whoever installed before the guard (RN-545).