ADR 0141 — The single base for mounted projects, mounted by identity
Context
The mounted mode (the old local, ADR 0072/RN-169, renamed by ADR
0104/RN-421) keeps the project's code in a USER folder, and that folder needs to
be reachable from inside TWO containers: the api, which reads and writes the
permissions.json and serves the Code tab, and the engine, which builds the
worktree and writes the code. The mechanism, since the beginning, is a
bind-mount.
What was never decided is who creates that bind-mount, and when. What
existed was an INSTRUCTION: docker/docker-compose.yml carried, in the api
and engine services, a large comment teaching how to add by hand one line per
project —
- /home/voce/projetos/loja:/home/voce/projetos/loja
— and project creation refused with 400 (RN-170/RN-422) when the folder was not there, with the message repeating that line. The refusal is good and still exists; the problem is the price of the remedy it teaches.
One compose edit and one restart of api + engine PER PROJECT CREATED.
Restarting those two services kills every agent turn in progress, every open
terminal socket and every in-flight LLM call, across the whole installation — to
onboard one project. That was tolerable while mounted was an escape hatch for
those who already knew what they were doing. It stops being so the moment it
becomes a first-class choice, offered on the creation screen with a suggested
path.
And there is a second problem, which the path suggestion makes concrete. The
rule "the project folder cannot overlap the Brabo checkout", which is the heart
of ADR 0055, is enforced by
caminhoDeWorkspaceLocalValido comparing against raizDoBrabo() — which is
process.cwd(), and inside the api's container is /workspace. The REAL
checkout, on the developer's disk, the api has no way of knowing. Whoever clones
Brabo into $HOME/brabo and points the project folder there passes every
existing validation, and the dev agents start running commands inside the
product's own tree — exactly the failure ADR 0055 exists to prevent, coming in
through a door it does not guard.
Decision
A SINGLE base, configured by the operator, mounted by IDENTITY in the api
and engine services.
# .env
BRABO_PROJECTS_BASE=/home/voce/brabo
# docker/docker-compose.yml — serviços api E engine
- ${BRABO_PROJECTS_BASE:-brabo_projects_base}:${BRABO_PROJECTS_BASE:-/data/brabo-projects-base}
The operator configures it once, runs docker compose up -d api engine, and
nothing else is ever edited per project. Every mounted project lives inside
the base.
The ${VAR:-nome_de_volume} trick on the left, with an absolute and inert
default on the right, is the SAME already used by PROJECT_WORKSPACES_HOST_DIR/
GIT_LOCAL_REPOS_HOST_DIR on the neighboring lines: without the variable, the
left side is the name of a named volume declared at the end of the file, the
line remains valid Compose, and nothing is mounted from the host.
Without the variable, the api reports projectsBase: null (GET /workspaces/:workspaceId/projects-base, maintainer — the same minimum as
POST .../projects, because this value exists to decide what that route
offers) and the creation wizard does not offer the Mounted folder mode.
Never offer a mode the installation does not honor.
Why a NEW variable, and not PROJECT_WORKSPACES_HOST_DIR
There is a variable that already swaps a managed volume for a real host folder, and the temptation to reuse it is great. There are three reasons not to, and the first has a data consequence.
1. Namespace collision, with git init in someone else's folder. A managed
workspace is named by workspace_dir_name (RN-109), a UNIQUE column; a mounted
project is named by the USER, who types the path. With both roots pointing to
the same place, <base>/loja and a container project whose
workspace_dir_name is loja land in the SAME physical folder — and
Workspace.init_from_bare! would git init inside another person's project.
Nothing in the schema prevents it: uniqueness is among workspace_dir_name
values, never against the basename of workspace_path, and there is no way it
could be, because one side is chosen by the product and the other by whoever
types.
2. Opposite ownership semantics. /data/project-workspaces belongs to the
PRODUCT: disposable, opaque, and that is what makes it acceptable for a
container project to be deleted with the volume. The base belongs to the USER
— it is where their code lives, open in their editor, versioned by them. Merging
the two would make the documentation have to say "this folder is disposable and
also is not".
3. The base is browsable; the managed root must not be. The folder browser
served by the api (next PR) is scoped to the base, so the user can choose where
the project will live. Conflating the two would expose the interior of every
container project of the installation to anyone creating some project.
Why IDENTITY ($X:$X), and not a fixed mount point
A fixed mount — ${BRABO_PROJECTS_BASE}:/data/mounted — would be shorter to
write, and it is wrong for two reasons that add up.
The projects.workspace_path string is typed by the user and shown back to
them (AmbienteDoProjeto). With host ≠ container, the product would have to
choose which of the two to store, and any choice makes the screen lie to
someone: storing the inside path would show a folder that does not exist on
their machine; storing the outside one would require translating at every point
of use.
And it is precisely that translation that identity makes unnecessary.
projectScopeRoot (api) and Engine.Actions.Workspace.workspace_dir/2
(engine) discriminate the mode by the path's leading slash and use the column's
value as is. With host == container, both remain correct without a single new
line of code — and the terminal scope of ADR 0055, which is derived from that
same root, keeps authorizing exactly the folder the user sees.
The broker gains a SECOND root
BRABO_PROJECTS_HOST_BASE enters the broker service derived from
BRABO_PROJECTS_BASE (which is already a host path by definition — it is what
api/engine mount by identity), next to PROJECT_WORKSPACES_HOST_ROOT and
for the same reason as ADR 0130: the -v of a
docker run is resolved by the DAEMON, against the host filesystem, and a path
coming from inside the api would mount an empty folder. Containment remains
configured in the broker, never asserted by the caller — which is that
ADR's real invariant.
Nothing consumes this variable yet: whoever resolves a mounted project against
that root is the PR that gives a container to the Mounted folder mode. It is
born here because the base is born here, and a host variable that appears half a
release later is a variable nobody connects to the file that explains it.
The /workspace guard, in the place where it is possible
scripts/dev/preflight.mjs now refuses to start when BRABO_PROJECTS_BASE
overlaps the Brabo checkout, in both directions (the base contains the checkout;
the checkout contains the base), using git rev-parse --show-toplevel.
The preflight runs on the HOST. It can answer what the api cannot, and that is
why the guard lives there and not in the domain: the api compares against
process.cwd(), which is /workspace inside its container, and will never see
the real path. The pure logic lives in scripts/dev/base-de-projetos.mjs, its
own tested module — preflight.mjs runs await main() at the top, and
importing it from a test would start the whole preflight.
Consequences
What gets better. Creating a mounted project no longer requires a compose
edit and a restart of api + engine — it no longer costs every agent turn,
terminal socket and in-flight LLM call of the installation. Configuration
becomes an act of the OPERATOR, once, and not an act of the user, per project.
And the ADR 0055 vector that no api validation reached now has a guard, with a
message that explains why nothing else would catch it.
Operator action required BEFORE the deploy, and that is why the branch is
born breaking/ and the version goes up MAJOR: whoever already uses the
mounted mode has the per-project bind-mount lines written by hand in the
compose. They keep working — nothing removes them, and nothing in this PR starts
refusing a mounted project outside the base (the base rule is a CREATION and
CONVERSION rule, and enters in the next PR; the lexical predicate, which runs on
every read, does not change). But from here on the supported path is the base,
and continuing line by line per project is debt nobody maintains anymore.
Declared cost 1: a symlink under the base resolves differently on both
sides. A symbolic link inside the base pointing outside it is followed by the
kernel INSIDE the container only if the target is also mounted — and it is not.
The folder browser refuses to descend into a symlink (and counts how many it
skipped, so as not to lie "empty folder"); whoever catches the divergence for
real is the disk check at materialization time, as a NAMED failed. It is
lexical containment with a known hole, the same one ADR 0055 already declares
and for the same reason: resolving realpath would require I/O where the
decision needs to be pure.
Declared cost 2: code outside the base requires moving the folder. This v1 supports ONE base. Whoever has repositories scattered across several places on disk needs to move the project inside it — and the refusal message says exactly that, instead of suggesting there is a hidden configuration. A second base (or a list) is a product decision not taken before someone asks: each extra root is one more root the folder browser exposes and the broker needs to distinguish.
What this ADR does NOT decide. It does not touch
caminhoDeWorkspaceLocalValido nor projectScopeRoot — the requirement that a
mounted project be inside the base belongs to the next PR, and deliberately
goes to creation/conversion, not to the predicate that runs on every read
(otherwise a legacy mounted project, outside the base, would start exploding
when READ). It does not change the schema, has no migration, and the CHECK of
the (modo, caminho) pair stays intact. And it does not give a container to a
mounted project: that is another PR, and it is there that
BRABO_PROJECTS_HOST_BASE gains a consumer.