Skip to main content

ADR 0065 — Container per project: the boundary stops being policy

Status​

Accepted, with a declared cut. The half this ADR delivers — the Architect's artifact, the gate, and the external-effect boundary — is implemented and proven by test. The half it does not deliver — provisioning, stopping, recycling and cleaning up the container — is declared in "What this ADR does NOT do," with the reason. The cut is of scope, not of the argument: the architecture decision stands whole, and it's what dictates what the next phase builds.

This ADR revises ADR 0055, which stays accepted and is not edited. 0055 says of itself, in the consequences section, that it is policy, not isolation, and records the per-project container as an explicit pending item. That's the pending item this document addresses.

Context​

The debt, in the words of the document that created it​

What this ADR does NOT resolve. Scope is policy, not isolation. As long as the Brabo monorepo is mounted at /workspace inside the container that runs the commands, the boundary depends on the policy getting it right. — ADR 0055

And the same document's acceptance note, which measures the gap that was left: path normalization is lexical, .. is rejected, and a symlink from inside the project pointing outward is not detected. Closing that isn't a matter of writing a better rule — it's about having a wall.

Today the agent runs in the same container as the Brabo monorepo. What separates it from the platform's own code is a string comparison in decide.ts.

The findings that don't converge​

PHASE 13b left two findings open, and their argument matters more than the list of runs that produced them (docs/explanation/achados-execucao-real.md):

  • Z and AD — the verb allowlist doesn't converge. Verb, form, and invocation are distinct spaces: curl, wget, python -c "urllib..." and a .sh script doing any of the three are the same egress written four different ways. Runs 6, 7 and 8 got stuck on one of each.
  • AE — the QA agent tries to fix the code it's judging, against its own prompt, contained by two independent barriers (allowlist and scope).

PHASE 13's conclusion, written before this phase existed, is the same one this ADR arrives at: the path to autonomy doesn't go through loosening policy.

What the user decided​

"Each project should have its own separate infrastructure, meaning it will spin up via a per-project container, isolating the terminal that way and giving it full permission; Code is only unlocked after the architect's definition, since they will decide which type of container will run that code, being the one who decides the best fit for which image to use."

And, about the extent of "full permission":

"The agent is free to do whatever it wants as long as it isn't git commands related to deploy and PR — those actions still have to be human."

Two sentences, three decisions: who chooses the image, when Code unlocks, and where the freedom ends.

Decision​

1. The project's image is the ARCHITECT's ARTIFACT​

artifact.project_image in the event log, with image, rationale, network and resources. Versioned — revising it means emitting a new version, and the current one is whichever has the highest version.

It's an artifact and not configuration because whoever chooses the image chooses what the agent can do: which runtime exists, which package manager, which compiler. That's an architecture decision, of the same caliber as module_map, and an architecture decision has an author, a date, and a reason. An environment variable has none of the three.

No table, and not for economy's sake. The event log already provides the three properties this decision needs to have — immutable, versioned, with an author — and it's where artifact.module_map and artifact.business_rule already live. A table would give the same thing with a possible UPDATE, and UPDATE on an architecture decision is how it stops being auditable.

An explicit tag, latest rejected. An artifact that says node:latest describes nothing: March's container and today's are different images with the same name, and the audit trail ends up lying.

A resource cap that rejects instead of downgrading. A request above the maximum is a 400 with the reason, never a silent trim — an artifact that promises more than the container gets lies to whoever audits it.

2. While the Architect hasn't decided, Code doesn't unlock​

The gate is the user's literal order, and its reason is about the product: the container is what gives reading code there any meaning — reading it in order to later run, build, or fix it. The reading surface from ADR 0060 responds 409 while the state is sem_decisao (no decision), with the message saying what's missing.

409, not 403: nothing is wrong with the requester or their permission — the resource simply doesn't exist yet in this state. And the check lives in the same funnel as the path containment (alvo), not in the four routes, for the reason stated in RN-092: a check duplicated across four callers is a check that one day diverges in one of them.

3. The boundary: inside is free, outside is human​

Inside the container the agent is free — read, write, install, build, test, run. This is what closes Z and AD, and it's the only known way to close them: the wall replaces the enumeration.

Outside stays human. Three effects cross the wall and reach the world — git push, opening a PR, and deploy — and a terminal command that invokes them is denied, with the message saying which typed action to use instead.

deny, not require_approval, and this is the part that needs an argument. Each of those effects already has a typed path (git_push, pr_open, git_merge) that's born as a proposed_action, has its own minimum role, is executed by the platform, and leaves in the event log what was pushed and where. The terminal would be a second door to the same effect, with none of those guarantees: the log would just say "a command ran." And require_approval wouldn't be enough because "always allow" exists — one click would write the pattern to allow and the second door would stay open forever. deny beats allow at every stage, and that's why it's the right shape for this rule: it's not a configurable preference, it's where the container ends.

Denying doesn't take power away from the agent — it redirects. That's how the dev agent always worked (agent_io.ex proposes git_push); what changes is that it's now guaranteed, not just agreed upon. And merging into a protected branch stays manual per RN-014, unchanged.

4. Networking is a CONTAINER posture, decided once — not command by command​

This is the decision the requested "own verdict" on networking produces, and it's the opposite of what intuition suggests.

The temptation would be to add egress rules to the allowlist: block curl, wget, npm install. That's exactly the attempt Z and AD proved doesn't converge. An egress allowlist would have the same shape, the same size, and the same fate as the verb allowlist.

So networking is decided once, in the artifact, at the boundary the kernel understands: network: none is the default, and it's what makes "inside the agent is free" a safe sentence — free in a place with no way out. egress is a legitimate request (a stack that downloads dependencies doesn't work without it), the Architect declares it with a justification, and the user is the one who authorizes it, at provisioning time — the same reason authorizing the parallelism cap works (ADR 0053): reaching the internet is spend and it's surface area.

Spend gets the same treatment: cpus, memoryMb and pidsLimit in the artifact, with a hard cap. pidsLimit deserves a note — it's what contains a fork bomb without depending on any verb allowlist, and it's the cleanest example of what changes once the boundary stops being lexical.

The token caps don't change: they stay project, session, and task.

What this ADR does NOT do​

The container's lifecycle (25b). Provisioning, stopping, recycling, cleaning up; what happens when the image changes; what survives a restart; the agent's worktree moving to live inside the container.

The reason is concrete and isn't a lack of design: container state needs a table. Container id, status, image in use, when it came up, which version of the artifact it corresponds to — none of that is an event, it's mutable state, and forcing it into the event log would be using the wrong tool because the right one was busy. This wave's single migration slot belongs to another phase.

Delivering half a provisioning flow would be worse than not delivering it: a container that comes up and never recycles is worse than none — it accumulates, nobody knows whose it is, and the first decided image becomes permanent in practice.

Honest consequence of the cut: the "inside the agent is free" half hasn't taken effect yet. The ADR 0055 terminal policy stays exactly as it is — path scope, narrow allowlist, cd loosened within scope. Loosening it before the wall exists would repeat the mistake this document came to correct, and PHASE 13 already wrote the conclusion: the path to autonomy doesn't go through loosening policy. What this phase delivers is the half outside the boundary — the one that needs to hold true first, not after.

Consequences​

What gets better now. The image decision exists, has an owner, a version and a justification; Code's gate is real and tested across the four routes; and the second door for push/PR/deploy closed — before it ever existed, which is the right time to close a door.

What changes for the operator. An existing project has no image decision, so the Code tab starts responding 409 until the Architect runs. It's an observable behavior change on a surface that just arrived (ADR 0060), and that's why this change is born under breaking/: whoever was already using the tab needs to know why it closed.

What's lost. Nothing about execution: no command that worked stops working, because git push through the terminal was never how the dev agent pushes. What's lost is the possibility of configuring a terminal shortcut for push/PR/deploy — and losing that is the point.

What's still owed, measured. A symlink from inside pointing outward is still not detected. This ADR doesn't close that vector; it decides how to close it (a wall) and delivers the image decision the wall needs. Until the container comes up, the weak point in 0055 keeps holding, and it's written here instead of being mistaken for resolved.

Alternatives considered​

Loosen the policy now and bring up the container later. Rejected with the strongest argument the project has: it's literally PHASE 13's conclusion in reverse. Freedom without a wall is the hole, not the solution.

A network-egress allowlist. Rejected by Z and AD: it would have the same shape and the same fate as the verb allowlist. Networking is a property of the container.

The image as project configuration (a column, .env, a Settings screen). Rejected because it takes away from the Architect the decision the user gave them, and because configuration has no rationale. The required rationale is what makes the decision reviewable instead of archaeological.

A project_containers table now. It's the right design for the container's state, and that's exactly why it's left for the wave with a migration slot. Improvising the state in the event log just to avoid waiting would produce the corrective migration right after.

require_approval on the terminal instead of deny. Rejected because of "always allow": it writes the pattern to allow, and one click would be enough for the second door to stay permanently open.

References​

  • ADR 0055 — the policy this document revises, which declared this pending item itself.
  • ADR 0060 — the Code tab, whose gate this decision closes.
  • ADR 0053 — whoever authorizes spend is whoever answers for the project; networking follows the same criterion.
  • ADR 0045 — the worktree is per AGENT, not per task; it's the one that will live inside the container.
  • RN-014, RN-092, RN-105, RN-106.
  • docs/explanation/achados-execucao-real.md — findings Z, AD and AE.
  • apps/api/src/domain/containers/project-container.ts, apps/api/src/domain/actions/external-effect.ts, apps/api/src/application/use-cases/containers/, apps/engine/lib/engine/harness/tools/choose_project_image.ex.