Skip to main content

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.json continuam 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:

#wherewhat it imposes
1apps/engine/lib/engine_web/channels/terminal_channel.ex:204the topic is terminal:<projectId>
2apps/engine/lib/engine_web/channels/runner_socket.ex:73the socket id is runner_socket:<kind>:<project_id>:<user_id>
3Engine.Runners.SocketTicket (runner_socket_tickets)the ticket carries project_id and the join requires it to match
4apps/api/src/db/schema/auth.ts:325-326runner_device_keys.project_id is NOT NULL
5apps/runner/src/servico.ts:264,391the 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 install without --project would install the machine unit. Measured, it does not work: the absence of the flag already has a meaning — resolverProjeto falls back to the folder's brabo-runner.config.json, and the normal path of whoever uses the product today is to run install with 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 --machine flag, explicit. Edited instead of contradicted because this ADR is still Proposed: 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_id remains NOT 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.
  • RunnerReadiness with 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.
  • espelho and workspace_create do not change. Both are per project and travel in the grant of that connection's join (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 workspace capability (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 from project_id in a single place (:23); the revocation of a machine key now calls disconnectRunnerOfUser once per project in runner mode 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 @RequireRole on GET 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 --machine flag (apps/runner/src/servico.ts:898, resolverEspecie at :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 status pointing out the overlap. The code does not let it get there: install REFUSES when the OTHER kind is already installed, naming the uninstall of each unit, without removing anything and without --force (apps/runner/src/servico.ts:993, recusaDeSobreposicao at :1269). status still says in text that the other one exists — what remains of point 5 is the case of whoever installed before the guard (RN-545).