Architectural decisions
An ADR records why something was decided, at the moment it was decided. They are immutable: no ADR is edited after being accepted. When a decision changes, a new ADR is born that supersedes the previous one — that's what happened with 0018, which records the replacement of the NoopDevAgent decided in 0011.
Practical consequence: an ADR can describe a state that is no longer current. For the current state, use Business rules, Architecture and the Runbook. For the reasoning, come here.
Of the 61, all are accepted except those still waiting for implementation — today 0053, 0054 and 0056, marked proposed in the file itself. 0055 was accepted once it was implemented in Phase F. 0154 and 0155 took the same path: accepted on 2026-09-27, after Phase 30 exercised them, with an "acceptance notes" section naming where the text and the code diverge. No check verifies this sentence: it aged saying 48 when there were already 54, and again 58 when there were already 59, and it's the argument of ADR 0029 (gerar > verificar > lembrar) confirming itself against itself.
The decisions are grouped by theme — the product front they decide about — and each ADR appears once, under its main theme. The theme lives in docs/adr/temas.yml, next to this page, and not in the ADR, because an accepted ADR is never edited (ADR 0202); pnpm docs:check fails an ADR without a theme or a row under the wrong section. The phase-by-phase story is in the phase history.
Git, repository and branch policy
The git provider contract, the repository's birth and adoption, protection and the branch policy.
| # | decision |
|---|---|
| 0001 | Format of the GitProvider's normalized contract: nine operations and declared capabilities, validated by a single contract suite |
| 0002 | Error normalization: six specific classes, with no common base class — the abstraction was considered and rejected for not paying for itself yet |
| 0003 | Automatic retry only on reads, never on mutations, with Full Jitter and 4 attempts |
| 0004 | Registration of git credentials: dedicated enum in the database, united with LLM providers only in type |
| 0005 | Gitflow bootstrap in six idempotent, resumable steps; skip is success |
| 0028 | Branch protection diverges between GitHub and GitLab — and that's why it's not the gate; the gate is the domain |
| 0030 | Mechanized branch policy: rule → mechanism, logic in a testable script, backmerge gate, and the cost of reintroducing each cut |
| 0044 | Adoption of an existing repository with origin (created|adopted) in both tables, and the PLAN as the gate: the dry-run is the check() from ADR 0005 without run(), the plan lives in the cursor (a snapshot, not a log), and nothing is changed while the decision is null — the Phase 2 runner goes out verbatim to a collaborator and is never filtered, just not called; "divergent protection" stays at the boolean level because that's all the contract exposes (ADR 0028) |
| 0056 | The engine works on a remote repository. get_local_repo_path/1 returns unsupported_provider for anything that isn't local, and four consumers stop along with it — the dev agent's worktree, the terminal executor, the diff that QA and SecOps judge, and the project context. The root cause is the asymmetry: the api speaks GitHub over HTTP (created the repo, committed, created the branches) and the engine works on the filesystem, only knowing a local bare repo; a remote project does the conversational half and stops at the construction half. That's what blocks Phase 13b from existing as writes. The git plumbing is already generic — init + remote add + fetch + checkout doesn't care whether the origin is a path or a URL —, so what's missing is a CREDENTIAL. The engine doesn't receive the master key: it asks the api for the working remote over the /internal/* channel that already exists, at the moment it needs it. And the token NEVER enters .git/config, a decision that's a direct consequence of 0055: writing https://x-access-token:TOKEN@… into origin puts the secret in plain text INSIDE the folder where RN-075 just granted the dev agent auto-approved read access — a cat .git/config would return the token, which would travel to the LLM provider on the next turn. Scope protects against reading outward, not against a secret the product placed inside. origin keeps the clean URL and authentication is injected per invocation. The credential is the OWNER's (RN-058), local stays identical, and credential failure fails with origin infra |
| 0139 | Promotion is --no-ff, and that rule was broken THREE times (#367, #394, #464) — the third being the very PR that fixed the first two. Investigating found the opposite of a detection gap: the check has existed since 2026-07-27 and fired correctly on both squashed promotions; the 2026-08-25 log says o merge em qa não é merge commit (1 pai) verbatim. What was missing was an ADDRESSEE — a push-triggered workflow failing on a permanent branch has no PR to turn red (the merge already happened), so it leaves a run in the Actions tab that nothing points at, and the repo had zero issues in its whole history. So tag-release gains an avisar job that opens an issue (and comments on the existing one rather than opening a second for the same branch, since a flood of identical issues is the fastest way to teach someone to ignore them), authenticating with github.token and never the PAT — an invalid BRABO_BOT_TOKEN is itself one of the failures it must report, and an alarm depending on what may be broken goes quiet exactly when needed. The rule also extends to dev, NARROWLY: dev takes work by squash on purpose, so "one parent is a defect" would fire on every PR; the defect there is only the PR that carried a NEW EDGE (its head was a merge whose second parent was not already in the base) — #464, whose entire delivery was that edge. Its benign twin, a work PR that pulled dev into itself, has a merge for a head too but its second parent is already in the base. Identifying the producing PR filters by merge_commit_sha, not .[0]: commits/{sha}/pulls returns every associated PR, and since dev is the head of the promotion PR that stays open all cycle, .[0].head.ref would read dev for every work-PR squash and fail the whole repository — an error made and caught against real data before shipping. Disabling squash repo-wide was measured and REFUSED: the six most recent dev merges are single-parent squashes and changelog.mjs runs git log --no-merges, so the fix would tax every work PR and change changelog generation. Prevention stays open and is a product decision: promote.yml performing the --no-ff merge itself would preserve authority while mechanizing method, but needs a ruling on whether a human dispatch counts as "manual merge" |
| 0165 | The project's repository is created when the handoff to the Architect is accepted, and execution refuses to start without one. A real v6.1.0 install (AT-092) showed the Architect proposing open_adr_pr three times before any repository existed, the Dev Lead handoff left offered, and POST .../execution/activate answering 201 on a project whose three git tables stayed empty forever. RN-522 had put the trigger on the Dev Lead handoff — AFTER the two agents that write to the repository (the Architect, and the Infra Lead it activates) — and that trigger was skippable, since activating execution never required it. The Architect can only be activated by an accepted handoff addressed to him (from the PO or a manual one), so the trigger moves into the same AcceptHandoffUseCase, before activateAgent, with the same mechanism (local, failure as an event). The Dev Lead acceptance stays as a second, idempotent door — the way out for projects that passed the Architect under the old rule, and the natural retry of a failed provisioning — and its idempotence is proven through both doors against Postgres and the real local provider. Activation without a repository becomes a 409 raised before any side effect, whose message is chosen from the project's handoffs (HandoffRepository.findByProject); the Overview makes the button inert and says why once in text, and the Dev Lead handoff card drops its activation shortcut. Declared: the git-bootstrap session now becomes the project's most recent session during the whole Architect phase |
| 0217 | While QA is in its test phase (QA_FASE_DE_TESTE=true), every merge into dev opens and merges the dev → qa promotion by itself, after all checks finish green; the approval ladder waives only that bot-opened pair, and qa → main stays manual |
Agent harness, turns and sessions
How an agent runs — context, the tool loop, the conversational turn, the session and its states.
| # | decision |
|---|---|
| 0006 | Deterministic context assembly: ordered layers, budget per layer, predictable truncation |
| 0007 | ToolLoop, tool catalog and ContextManager — the loop with a real LLM |
| 0061 | The session's type is creation-time data, and execution stays an event. Every session was born the same, and reaching the Creative agent meant opening it first and DISCOVERING a button in the top bar afterward — that's how the user reported the problem. The risk isn't the new field: it's that the product ALREADY knew how to tell an executing session apart, by derivation, and findActiveExecutionSession looks for the active session that carries execution.activated (that's what makes reactivating land on the session where the dev agents already are, finding #11 of the first dogfooding). Recording a type creates a SECOND source, and two sources diverge — here, over where the dev agents write. Deriving everything from the log doesn't work, and the reason is temporal: the intent exists BEFORE any event, in the click that opens the session, and a creative session nobody activated has no event to derive from. The decision is that the two coexist answering different questions — kind is creation INTENT and immutable, the event is execution STATE, and findActiveExecutionSession does NOT start looking at kind —, under the rule that keeps them from fighting: execution.activated on a consultiva session is 409, never silent conversion. The lock lives in the FUNNEL (AppendSessionEventUseCase), because both the user's route and the engine's /internal/* pass through it, and locking in the use case would leave the other open; and it runs before incrementSeq, so a rejected attempt doesn't consume seq. The field is mandatory at every creation point, including the four internal ones: optional would make the other callers silently inherit the default. The column's DEFAULT is consultiva (the type that can do less) and the BACKFILL is criativa, in the opposite direction and by the argument of 0046 — every existing row predates the distinction, and some are the sessions the dev agents are working in right now. Along with it come the friendly name, which is ADDED to the hashtag and never replaces it (the hashtag is what gets pasted into a URL; a person's name isn't unique), and the session screen's exit, which had none. The accepted price: the question "does this session execute?" now has two places to check |
| 0098 | The 413 request entity too large on PRs had its cause inside Brabo's own api, never the LLM provider (comments blaming the provider were wrong). Two ends close together (RN-412): apps/api/src/main.ts never configured Express's body limit (the 100 KB default applied, against the 8 MB Phoenix accepts on the heaviest engine→api leg, POST .../llm-turn) — it gained API_JSON_BODY_LIMIT (default 10 MB); and Engine.Harness.ContextManager.Default had compaction structurally unreachable before the overflow — estimate/1 didn't count toolCalls (they cost ~zero tokens) and the window used only context_window (128k, ~350 KB before compacting). The EFFECTIVE window becomes min(context_window, transport_cap), with the cut always at a ToolLoop iteration boundary so as to never separate an assistant with toolCalls from the tool messages that answer it |
| 0163 | The click that starts an agent turn answers on ACCEPTANCE, not when the turn ends, and a refusal stops being silent. Measured on a real v6.1.0 install (AT-089): answering the Criativo's question form took 97.3 s, confirming readiness 97.3 s and confirming the architecture 51.8 s — each click held for the WHOLE turn, because the conversational agent's handle_call only replied to the engine controller's synchronous GenServer.call when the turn's Task finished, and a turn past the 120/180 s ceiling became a 500 on a command that had worked. The same install shows the other half: a "Continue" typed during the Arquiteto's kickoff got {:error, :turno_em_andamento}, which the controller discarded before answering 202 — accepted, never read, no trace. Decision: TurnoAssincrono.iniciar/3 replies :ok as soon as the Task is up (after persisting agent.status: working, never before), generalizing what 0086 did only for the suspended Dev Lead — one mechanism for the six conversational agents, no second path. What the agent refuses BEFORE starting stays synchronous and gets its own status (409 turno_em_andamento/aguardando_aprovacao, 422 sem_regra_de_negocio), passed through by the api with the engine's sentence. The api's success contract does NOT change (201 { ok: true } always meant "accepted"). The screen stops reading "the call resolved" as "the turn ended" and follows the end through the channel plus the log tail. The Dev Lead handoff is HELD while the Arquiteto's closing turn runs, so it still lands after Infra's. Declared: the chat.message recorded before the engine refuses stays in the log (now explained), and an engine restart mid-turn leaves the strip up until "Stop". |
| 0175 | The Infra Lead converses through the composer and becomes the SEVENTH conversational agent (AT-141, maintainer's decision). RN-584 had left the question open and priced a "yes": the Infra Lead's whole turn ran INSIDE handle_call/handle_cast — no acceptance reply, no "Stop", no orphan-turn closing, a 180 s call ceiling below the 225 s of a container propose_action (RN-605), and a message sent during the kickoff waited in the process queue for the whole turn. So the turn moved FIRST: kickoff, gate correction and message all start through Engine.Agents.TurnoAssincrono, the same module as the other six — acceptance with working persisted before it, 409 turno_em_andamento, via_for("infra") for "Stop", TurnoOrfao, and toolloop.limit_reached at its own ceiling of 14. The Workflows consolidation runs inside the Task; a gate correction that arrives mid-turn is QUEUED and started when the turn closes, because TurnoAssincrono without from drops it. Only then message/2 gained its own infra clause (the named refusal left) and the web put infra in AGENTES_DE_CHAT, so it is the recipient when it is the most recently activated agent; its handoff keeps its own card (RN-499) and is excluded from the thread card by name. No new external effect: the same four tools, everything with effect still a proposed_action. SOLO_CONVERSATIONAL_AGENTS does not change — the Infra Lead is an area lead |
| 0177 | The language agents ANSWER in lives in three places — account, session and project — as an OPEN list of BCP-47 codes, never in users.locale (AT-162, maintainer's decisions in AT-168/AT-169). No enum: every place stores canonical text validated by normalizarIdiomaBcp47 (shape by Intl.getCanonicalLocales, language subtag known to the API's ICU, never und or private use, 35 chars max), because opening the response language needs no resource file and an enum would need a migration per language. Account: users.response_language (NULL is automatic, how every account starts) and the detected_language/detected_language_confirmed_at pair bound by a CHECK — detection only counts once confirmed (written by AT-163). Session: session_language_overrides, keyed by the {session, user} pair, configuration not event. Project: projects.language (AT-243, RN-619). Precedence for a turn with a human author, per PERSON: session override > account choice > confirmed detection > users.locale, so the chain always ends in a language; ResolverIdiomaDaRespostaUseCase returns winner, origin and chain. Declared: no turn changes yet — the transport to the model is AT-164 |
| 0179 | The agent loop asks the Jev which tool fits each step, and the user's chat model is offered only the menu that is left (AT-238, HS-063, maintainer's decisions in AT-236 and of 2026-09-29). A router of its OWN, ToolRouter, outside the ADR 0041 provider contract and outside the models catalog: it only acts with an OpenRouter chat model and two or more tools, and it only RESTRICTS (policy P3: {the Jev's pick, the previous tool of the same run}; responder_sem_ferramenta or no previous tool leaves the whole catalog). The adoption criterion was lowered from 90% to 80% by the owner, with the numbers in front of him (menu coverage 91%, 85.1–94%; Jev top-1 73%); real validation is AT-239. Any failure — HTTP, timeout (2,000 ms), unexpected JSON, pick outside the options — falls to the whole catalog with a named reason, and the turn never fails because of the Jev. The Jev does not approve, deny or choose a model. Its cost goes to token_usage as the REAL usage.cost with an IMPLICIT price (cost ÷ tokens, price_implicit), on the agent's own actor so it enters the area budget; the kill switch is per workspace (migration 0066); a wrong menu is escaped by repeating the step once with the whole catalog. |
| 0183 | A CLOSED session can be REOPENED (AT-071): one exit from the terminal states, only to active, through its own route (POST .../sessions/:id/reopen) and its own state-machine check (canReopen) — the generic transition still refuses closed → active, and closing → active stays forbidden. The row goes back to active with closed_at/termination_reason cleared, and a NEW session.reopened event, in the same seq, keeps the previous closing; no event is edited, kind is untouched. Three PROVISIONAL defaults await the owner: maintainer minimum, no time limit, and a session with execution.activated refused with 409 sessao_com_execucao so findActiveExecutionSession cannot be diverted. The engine's SessionLifecycleWorker ignores a close the reopening already undid; the Psychologist's second automatic analysis stays blocked by alreadyAnalyzed |
| 0184 | The owner answers the three PROVISIONAL defaults of ADR 0183 (AT-337): reopening a closed session needs developer — the same role that closes it through the generic transition — instead of maintainer, with viewer still 403; no time limit and the 409 sessao_com_execucao refusal for a session with execution.activated are CONFIRMED. ADR 0183 is not edited |
| 0191 | A message that arrives while a conversational agent is mid-turn is no longer refused with 409 turno_em_andamento (AT-267, owner decision 01/10, revising ADR 0163 for MESSAGES only): it joins the agent's QUEUE in TurnoAssincrono — one queue for the seven, the servers only say how a message becomes a turn —, and when the turn ends the pending messages are read TOGETHER, in order, in ONE turn. The state lives in the event log (chat.message_queued/_delivered/_cancelled, all carrying the chat.message id), never in a table: init/1 rebuilds the queue and the boot wakes whoever has one pending. Ceiling of 10 per agent and session (409 fila_de_mensagens_cheia); the person who SENT a pending message can cancel it. The Dev Lead suspended for approval keeps refusing; non-message commands keep turno_em_andamento |
The agent team — roles, areas and handoffs
Which agents exist, what each delivers to whom, delegation inside an area and the handoff between them.
| # | decision |
|---|---|
| 0008 | Conversational Creative agent (chat stops being a stateless echo) and the foundation of handoffs |
| 0009 | PO, three-level backlog and rule → story → task traceability; readiness becomes a domain validation |
| 0010 | Architect: ADRs per real PR, module_map and cross-validation |
| 0014 | Proposal-only InfraAgent (proposes via PR, never applies) and a live team panel over Phoenix channels |
| 0015 | Real Psychologist: hypotheses with evidence pointing to real events, and light/heavy triage by cost |
| 0016 | Anamnesis: a closed catalog of competencies, versioned instruction patches with rollback, and the cap that a patch is never auto-approvable |
| 0022 | Evidence that arrives, cost that shows up, analysis that survives a kill; termination cause as a deterministic classification |
| 0023 | A catalog that works, dedup that distinguishes who declined, and a queue that survives the round |
| 0038 | Area, lead and delegation: an external handoff only addresses a lead or an area-less agent, consolidation into a single artifact without changing the gates' external contract, cascading budget with a sub-cap per delegation, typed failure origin retrofitted across the whole system that already classified failure informally |
| 0046 | Story promotion goes back to being the user's: proposed_ready is a boolean and not a new enum value because the enum IS the claimNext gate; promoting reuses TransitionStoryUseCase (dead code from finding #13) and gets 12b's task.became_claimable for free; validation is unified in assertPromotable BEFORE the trigger becomes configurable, otherwise the two modes would be distinct rules with the same name; DIRECTED backfill (existing project goes to auto) because that behavior was already in use; the refusal is recorded before talking to the engine — the reverse of rearm, because the event asserts something about the USER, not about the engine |
| 0053 | Dev Lead as an area, and parallelism authorized by the user. Today one dev goes up per module plus an extra on a one-click accept, with no session cap at all: N modules × 2 agents, and spend rises without anyone authorizing it. 14d decides that the LEAD is the one who evaluates how many agents are worth it, and that going over two in the session becomes a proposed_action through the same pipeline as any action with external effect. The Dev Lead didn't exist and was forbidden, along with dynamic areas via module_map and the generic area apparatus — the three cuts fall together because dev area members are one per module_map module, decided by the Architect and different in each project: there's no way to hardcode them like qa and infra. agent_areas/agent_area_members come in per project, with max_parallel default 2, configurable per lead; the gates' external contract doesn't change and Phase 4's suite staying green with no modification is the acceptance criterion. Anamnesis proposes raising the cap when authorization becomes routine, and never raises it on its own — automating that would be the product raising its own spend cap |
| 0068 | C4 diagram (Context + Container, Simon Brown) from the Architect, rendered on the Overview. artifact.c4_diagram with no table, same design as artifact.project_image (ADR 0065): the Container level is DERIVED from the current module_map by the use case (never retyped by the model — avoids silent divergence between the diagram and the real map), and only the Context (system name + external actors) comes from the create_c4_diagram tool call. mermaid enters as a new RUNTIME dependency of apps/web (user's decision) — the first of its kind; isolated behind lib/mermaid-render.ts with a dynamic import(), confirmed by vite build that only the entrypoint loads eagerly and Mermaid's heavy chunks (cytoscape.esm 435 KB, katex 258 KB, used only by OTHER diagram types) stay on demand. ADR 0058's locked-down CSP confirmed intact: no Mermaid chunk uses eval/new Function, and style-src 'unsafe-inline' already covered the <style> the SVG might embed |
| 0069 | Conditional merge of accepting the handoff with activating execution. The Dev Lead's card had two clicks because they're two deliberately distinct authorizations (RN-137): accepting requires developer, activating requires maintainer because whoever activates becomes session.createdBy and resolves the EFFECTIVE role of the PRs the dev agents open afterward. The request doesn't lower that bar — for someone already maintainer/owner (read from the same useCurrentWorkspaceWithRole() that already authorizes "Auto mode" and Approvals/Settings), the second click protected nothing, just repeated a decision the role already authorized; handleAcceptHandoff now chains into the SAME handleActivateExecution the "Activate execution" button already calls (RN-137), reusing its error handling so as not to confuse "accept failed" with "activation failed after a successful accept". Backend untouched — RequireRole('maintainer') stays the same, it's a decision only in apps/web; developer keeps today's flow, with the two buttons separate |
| 0085 | docs/fluxo.yml records the RELATIONSHIPS between agent-team roles — who delivers what to whom — the third piece alongside the catalog (agent-areas.ts, PHASE 18) and the control (docs/gates.yml, ADR 0054), which until now only existed implicitly in prompts and use cases. Contracts per role (mission, typed inputs/outputs, an exit gate referencing gates.yml) with status active/planned/proposto/em-refinamento; a proposto role DECLARES who absorbs it today and the objective criterion for separation, so the target org chart becomes an activation sequence rather than an aspiration. docs/.docmap.yml gains the modelo-de-time rule at warn — the fluxo.yml × gates.yml × agent-areas.ts cross-check test is missing to promote it to block (backlog) |
| 0086 | The Dev Lead's execution plan becomes a real proposed_action, revising part of 0053 — the fluxo.yml × code audit found the flow already declared via: proposed_action for this output and the code was never adjusted to match. Direct precedent: 0052 and 0057, but both are triggered by cast and never waited on a synchronous reply — the Dev Lead is the FIRST conversational agent (GenServer.call of up to 180s) to suspend. TurnoAssincrono.tratar_resultado/2 gains a branch: state carrying the :aguardando_aprovacao key (non-nil value, not just present — the Dev Lead carries it since init/1) replies to the from the same way, at the same time, but emits only agent.status: awaiting_approval instead of agent.done. LiveBroadcast.agent_status/4 widened its guard to accept the new status. DevLeadServer subscribes to Engine.Dev.Wake under its own id (the same module QaLeadServer already reuses for QA subagents) and resumes via {:action_settled, ...}; a second user_message during the wait is refused with agent.error (origin politica), no new turn. propose_execution_plan was CONSIDERED and left out of decide.ts's absolute-cap block — unlike parallelize/raise_max_parallel, it can be configured to auto-approve. Accepted, declared gap: with no state table, a restart during the suspension loses the Wake subscription — the decision stays recorded and visible in Approvals, but the Dev Lead doesn't narrate the outcome on its own |
| 0087 | The UX Designer enters as the fifth conversational agent (Creative, PO, Architect, Dev Lead and now this one), SOLO with no area, ANTICIPATING the separation trigger docs/fluxo.yml (ADR 0085) had always declared for the role ("when the MANAGED project has its own interface to design") — a conscious decision by the product owner, not a misreading of the criterion. UxDesignerServer mirrors DevLeadServer (cap 14, bounded loop); the kickoff reads the Creative agent's artifact.product_brief, and the design system (design/tokens.css/COMPONENTS.md) is DESCRIBED in the identity — conversational agents have no repository-reading tool. The single tool, propose_prototype, writes artifact.prototipo_navegavel with NO dedicated api use case (unlike project_image/c4_diagram, which need one because their content is DERIVED or shares a domain refusal — neither applies here): shape validation lives in the engine (ArtifactSchemas) and writing goes through the generic append_event_returning. A single artifact, two handoffs over it (to "po" and to "dev-lead") — never a second artifact for "spec-visual", which IS the same prototype. Reuses the half of ADR 0086's design that survives without its suspension: success ends the turn, so as not to duplicate proposals |
| 0088 | The Staff (docs/fluxo.yml, camada_decisao_tecnica, status: planned since 0085) gets CODE before its automatic trigger exists — a conscious decision by the product owner, knowing Anamnesis (which would fire the handoff on "recurring systemic problem") is paused (ANAMNESE_ENABLED=false). Sixth solo conversational agent, mirroring the Architect (per-session GenServer, bounded loop, cap 14), with two differences: NO kickoff/1 (there's no session artifact to summarize — it stays idle until the first user_message) and activation via the GENERIC canActivateAgent path (a handoff accepted addressed to "staff"), without entering USER_STARTED_AGENTS — investigation confirmed no domain change was needed in the api, because assertHandoffTargetAllowed only refuses an area subagent, and Staff has no area. The single tool, propose_rfc (problem, options with trade-offs, recommendation, throwaway PoC), writes artifact.rfc_staff DIRECTLY via append_event_returning — the same tableless pattern as emit_insight, not the one for artifact.c4_diagram (0068), because the RFC derives nothing from the api side — and returns the handoff to the Architect in the SAME tool call, no proposed_action (recording a document isn't an external effect). Declared, not hidden: SessionPage.tsx/AGENTES_DE_CHAT were untouched — the same pattern already accepted for infra, a real lead that's also left out of that list |
| 0089 | analytics and delivery-metricas (docs/fluxo.yml, status: proposto) become status: active as a report SCRIPT — the product owner's decision to bring forward both roles without waiting for the organic trigger the flow itself already declared. apps/api/scripts/analise-funil.ts is the only new artifact, in the SAME shape as medir-execucao.ts (Phase 13b): pure Drizzle reads, zero writes, no GenServer, no LLM agent — the shape docs/fluxo.yml already prescribed ("never becomes an agent" for delivery-metricas). Measures a real funnel (session → commit → PR → merge, counting SESSIONS per step, only executed actions), real lead time (first commit to first merge, by the execution's updated_at, not the proposal's) and real deployment frequency (merges to a PROTECTED_BRANCHES branch, cross-referenced with the backmerge gate). Three metrics stay DECLARED absent, on purpose: the full product funnel from ideation → commit (sessions has no storyId, RN-230), evidence of adoption per feature (Brabo doesn't instrument what it builds — no path to that today, not just no data), and MTTR/change failure rate (they depend on a real incident signal, the same dependency as secops-runtime/platform) |
| 0092 | platform (docs/fluxo.yml, status: planned, activation tied to DEPLOY_ENABLED — which doesn't exist) is born as an on-demand report SCRIPT, neither an LLM agent nor a GenServer: pnpm --filter api relatorio:telemetria [--projeto <uuid>] reads the SAME questions DomainGaugesCollector already collects every METRICS_GAUGE_INTERVAL_MS for the Prometheus scrape — active/closing sessions per project, blocked tasks per project, the last backup's state (always GLOBAL, never per project) — but as a ONE-OFF, standalone read that ends after printing. The SQL queries are replicated, not imported from the collector: its methods are private and end up writing to a Prometheus gauge, with no pure "just the query" half to reuse without coupling a CLI script to an @Injectable's lifecycle. The output always links to what already exists, versioned (dashboards, alerts, runbook, pnpm dev:obs) instead of duplicating it, and declares — never invents — what it does NOT measure: a formal numeric SLO (none defined), postmortem (no real incident) and closed-loop telemetry back into the product (that's what would make platform active, and the trigger is still absent). The role's status: planned and gate_saida: operavel don't change; docs/fluxo.yml just gains a nota on the telemetria-consolidada output saying the manual version is already real |
| 0093 | The dbre role (docs/fluxo.yml, ADR 0085) becomes two mechanical scripts, brought forward by the product owner's decision without waiting for the trigger ("real data volume") — neither an LLM agent (there's no natural-language judgment to make) nor a GenServer (there's no long-lived loop or state), because neither deliverable needs that: lint-migracao.ts is STATIC analysis of SQL text (DROP TABLE/TRUNCATE/DROP COLUMN/ALTER COLUMN ... TYPE/ADD COLUMN ... NOT NULL with no DEFAULT, pure logic separate from the I/O adapter, same design as pr-police.ts) and relatorio-backup.ts just READS backup_runs with the same logic as DomainGaugesCollector.collectBackup(), formatted as an on-demand report, citing the already-tested restore (docs/runbook.md#restore) instead of re-running it. The linter scans the WHOLE repository (not a PR's diff) and for that reason did NOT go into CI — running it today finds three occurrences in migrations already merged and accepted (0006, 0007, 0034), and a gate that fails the whole repository would fail every PR forever; turning it into a real gate requires scoping it to the diff, as pr-police.ts already does, for when dbre needs to BLOCK and not just opine. plano-de-capacidade/tuning stay a declared GAP: they depend on real data volume, and simulating one without load would be inventing a number (same rule as 0042/0077) |
| 0094 | The Dev Lead → dev-<module> delegation becomes DATA in delegations (area: 'dev'), closing finding B1 of the fluxo.yml × code audit and revoking the cut declared in 0053 item 5. Written from the API side, inside AcceptParallelizationUseCase.execute (covers both paths — direct and approved — for free, because both already converge there), unlike QA/Infra which write from the engine side because that's where the subagent produces a verdict. status: 'completed' is REDEFINED for this area: it means "the delegation was EFFECTED" (the agent came up), not "verdict issued" — and parecerArtifactId points at the id of the project's most recent artifact.module_map event (via SessionEventRepository.listByTypeForProject, an existing method, reused without generalizing), the artifact that justified the delegation decision. With no module_map in the project: it does NOT write a fake id, it just logs the unexpected state (RN-059) and moves on — and a RecordDelegationUseCase failure never brings down the activation, which is already a success by the time the write is attempted |
| 0109 | Manual handoff to an agent of choice, closing the backlog item ADR 0088 had named precisely ("the generic UI remains in the backlog"). No third handoff state: CreateHandoffUseCase (the only writer of toAgent, ADR 0038) gets an optional actor?: Actor so a human-initiated handoff.offered records {kind:'user'} instead of the default {kind:'agent'} — the existing accept/activation path picks it up unchanged. addressableAgents() (leads ∪ SOLO_CONVERSATIONAL_AGENTS) is stricter than assertHandoffTargetAllowed: a human picking from a dropdown gets a closed catalog, not just "not a subagent". Investigation before coding found ux-designer (ADR 0087) in the exact same reachable-only-by-internal-route situation as the Staff — both entered AGENTES_DE_CHAT together, no engine change needed for either |
| 0182 | A handoff OFFER gets a lifecycle: status superseded (migration 0064) with an immutable handoff.superseded event, and at most ONE offered per (project, target) — the same session with no new artifact returns the existing offer, a new artifact or another session replaces it (AT-291/292). Concurrency by a transaction-scoped advisory lock per target, not a partial unique index, so the duplicates already in the table converge WITH an event instead of being rewritten by the migration. Activating an agent by any path supersedes the offers to it; an offer to an agent already active in the project is 409 agente_ja_ativo, whose message is what the model reads; "architecture ready" and AppSec (seAusente, RN-636) stop duplicating offers |
| 0185 | ONE click — "I'm ready — the need is validated" — closes readiness, the necessidade-validada gate and the PO acceptance (AT-311/312), revising ADR 0095's SEPARATE click by owner decision. The same POST records readiness.confirmed (marked) and, once the engine accepts the turn, necessity.validated with productBriefId: null (the brief comes from the turn the click starts); the gate stays warn. The Creative→PO offer carrying the brief born after that click is accepted on the internal route that creates it, through the SAME AcceptHandoffUseCase: the same events, the person as actor, and an implicito mark in handoff.accepted/agent.activated. A failed implicit acceptance becomes a durable agent.error; the engine does not change |
| 0186 | The PO's handoff to the Architect is accepted by the SYSTEM, without a click, when the backlog is COVERED (at least one business rule and none without a story citing it — the same computeCoverage as the Backlog tab), the repository the accept touches is local with no git credential in the project, and whoever opened the session still has developer (AT-314). It builds on ADR 0165 without editing it: provisioning stays in AcceptHandoffUseCase, before activateAgent; the new use case only reads and decides, and records handoff.accepted with the system actor handoff-auto-accept and the criterion in the payload. A failure after deciding becomes handoff.auto_accept_failed; a remote repository or a git credential still asks for the click |
| 0194 | APPROVING the Dev Lead's propose_execution_plan is what activates execution, and accepting the Dev Lead's handoff only brings him in to PLAN (AT-263, owner decision 01/10, revising the ADR 0069 fusion, RN-161). The plan gets an executor in the api that rereads it against the current module_map, writes each task's MODULE and calls the SAME ActivateExecutionUseCase as the button, so the 409 without a repository, the 409 on a consultative session and findActiveExecutionSession stay as they were. Tasks get a module column (migration 0068), assigned by the Dev Lead in the plan (tarefas: [{ taskId, modulo }], AT-274); a task without a module or with a module outside the module_map is refused with a named 400 at proposal time. Only the dev of that module claims it; a task with no module is claimable only when its story has exactly one module |
| 0212 | A story can be ARCHIVED, and it is a column — stories.archived_at/archived_reason (migration 0069) — not a new story_status value, because the status is the claim gate and archiving is not a progress step (AT-406, owner decision 03/10 "PO and user"). Every read that DECIDES (backlog, coverage, promotion queue, the Dev Lead plan, claim) filters archived_at IS NULL, and the tasks leave with their story; nothing is deleted, and the new immutable backlog.story_archived/backlog.story_updated events record it. One rule for the PO tool and the Backlog tab: only draft with no task in execution, a named 409 otherwise |
| 0213 | The deliverable runs on the user's machine through the Infra's compose, never through a port published by the broker (AT-517, owner decision 11/10; ADR 0130 untouched). The compose builds the APP from the module Dockerfile with FROM = the decided image, on host port ${APP_PORT:-8080}, with a committed .env.example; propose_infra_pr gets a mandatory run payload (command, 1–5 localhost urls, envExample) that the api turns into the versioned artifact.run_instructions. Devs serve /openapi.json and /docs (Swagger from a CDN under network none), QA proves them INSIDE the container, and the Overview card says "declared by Infra, not verified on your machine" — the real docker compose up stays a declared gap |
Gates, QA and security review
The gate registry, the QA and SecOps verdicts and the golden-sets that measure them.
| # | decision |
|---|---|
| 0013 | QA and SecOps gates with an immutable-order state machine |
| 0020 | Unlocking the gates: gitleaks on the working tree (not just the diff), verdicts validated as an artifact, tool call recovery from text |
| 0021 | Closing 4a: an infra gate that really validates, and a panel that tells the truth |
| 0054 | Gates as a declarative registry in docs/gates.yml. The flow's gates existed and had been validated by real execution, but were implicit — spread across agent prompt, pure rule, use case, test and CI —, and the Phase 10/13 lesson (metric extracted by script, never handwritten) has no way to apply to what isn't enumerable. This ADR is the top half of 0048: that one made an action's DECISION observable in the event log, this one makes the set ENUMERABLE. Each gate declares `evidencia: event_log |
| 0057 | The gate agent waits for approval, the way the dev agent does. ADR 0052 did this for the dev agent; the GATE agents were left out, and nobody noticed because no execution ever reached them — Phase 13b did. In the 6th real execution, qa-automacao ran `ls -la && find … |
| 0067 | The gate survives a restart. 0057 already declared "a restart mid-wait loses the loop" as a known limitation, but investigating again found a SECOND window, distinct from suspending on {:awaiting, ...}: DevAgentServer.correct/3 and Dispatcher.run_secops/2 are direct in-memory calls, made AFTER record_gate_verdict had already durably advanced gate_status on the api — a crash between the two locks the PR forever, and it's the EASIEST window to hit, not the rarest. gate_states (schema engine, key {project_id, task_id, gate}) records two steps: in_progress (no verdict recorded yet — no surgical resume of ctx, which doesn't survive a restart, but restarting the area from scratch is safe because the api rejects record_gate_verdict for a gate that's no longer the gate_status owner) and dispatch_pending (verdict already recorded, only the in-process call is missing). Engine.Gates.GateRescuer scans rows stuck for more than 15 min (generously, on purpose — QA's ToolLoop legitimately runs up to 60 iterations, and rescuing too early would duplicate a merely slow cycle), called at boot and by an Oban tick that reschedules itself, reusing the same queue mechanism the rest of the engine already uses. Two guards against duplicating work: a local Registry.lookup before any rescue, and the staleness threshold itself as a second line for what the first doesn't cover (a remote replica, Registry node-local, the same caveat as Engine.Dev.Wake since ADR 0045) — the worst outcome of a residual race is duplicated, cheap work, never inconsistent data |
| 0090 | Two "second moments" of the same conceptual design, each decided by the product owner to bring forward a trigger docs/fluxo.yml had already foreseen. The implementavel gate moves from planned to active: QA-strategy stops being a proposto role and becomes the SAME qa-lead, in a second MOMENT, same process (Engine.Gates.QaLeadServer.run_design/3, additive to the usual run/2), a separate deliverable — Engine.Gates.QaEstrategiaAgent (stateless, no Terminal, LIGHT context reusing list_backlog/get_infra_context with no new route) produces artifact.plano_de_teste PRE-DEV. The Dev Lead gains assess_implementability: with no plan yet, it triggers the assessment and returns an error asking for a retry (RN-163); with a plan, it proposes the verdict as a proposed_action, the SAME pattern as propose_execution_plan (0086). appsec (RN-360/361) follows the SAME pattern over secops: Engine.Gates.SecOpsAgentServer.run_design/2, in the same process as PR secops, produces artifact.threat_model (a STRIDE-lite checklist over the story's DESIGN) and creates a handoff to architect, dev-lead and infra. Neither agent EVER suspends — no tool goes through ActionPipeline — and the iteration cap stays at 8 (conversational) on purpose, the same reason as infra-workflows: with no token_budget_micros underneath, raising the cap would multiply the worst case with nothing to contain it. Both are ACTIONABLE, but the automatic trigger (assess_implementability calling appsec's run_design) still doesn't exist — out of scope for this delivery (RN-340/341/360/361) |
| 0091 | secops-runtime (docs/fluxo.yml, proposto role of the security layer) brought forward as a report SCRIPT, neither an LLM agent nor a GenServer — a conscious decision by the product owner, even with the declared trigger ("production with real traffic, post DEPLOY_ENABLED + platform active") still not having fired. pnpm --filter api relatorio:seguranca-runtime reads rate_limit_hits (the data ADR 0027's RateLimitGuard already records, including under dev/CI traffic) and produces a bucket ranking (user:<uuid>/ip:<address>, the only two formats ever recorded — no route or reason, which the table never stored) and a time distribution in fixed slices. Automatic incident detection, incident response and postmortem stay OUT — they depend on the same trigger that hasn't fired — and the report lists them in a permanent "not measured" section, with no example incident simulated and no invented number (same principle as ADRs 0041/0042/0077). The retained window is short (DomainGaugesCollector.pruneRateLimit erases hits older than 2 × RATE_LIMIT_WINDOW_MS, 240s by default) and the report declares both windows — CONFIGURED and OBSERVED — never letting the second pass for a longer history than it is |
| 0095 | The necessidade-validada gate (docs/fluxo.yml, criativo role, proposto since ADR 0085) closes the LAST wave of the fluxo.yml × code audit (finding B2), with a SEPARATE human confirmation — never the Creative agent self-validating, the anti-pattern already recorded in modelo-de-time.md. A dedicated button in SessionPage.tsx (same pattern as "Confirm architecture ready", RN-160), route POST .../agents/criativo/validate-necessity, ValidateNecessityUseCase writing necessity.validated — enabled only AFTER confirm_readiness has already consolidated artifact.product_brief (can't validate what doesn't exist yet), and it doesn't signal the engine: the Creative→PO handoff already happened inside confirm_readiness itself, so this event is just the record of merit, not an activation trigger. docs/gates.yml gains the gate as active/warn (nothing today CHECKS its passage before letting the PO move on) |
| 0123 | Closes ADR 0020's own open finding — the QA gate's semantic step (matching business rule to test) is non-deterministic with a local model, closing on the 10th of 11 runs — with a golden-set of six regression cases run deliberately (mix golden_set.qa) against a REAL local LLM (Ollama), never CI (confirmed blocked: no ollama in workflows, no LLM secret, and the CI's own culture rejects silent allow-failure). Engine.Gates.QaAutomacaoAgent.run/5 is called directly, same as qa_automacao_agent_test.exs, but against the REAL EngineApiClient.Live — which needs the api and engine actually running, and a NEW cross-language System.cmd (Elixir calling pnpm/ts-node) to seed real project/session/model-binding rows the engine test's own isolated engine_test database can't reach. apps/api/scripts/seed-golden-set-qa.ts does its OWN git checkout (the worktree has to live inside projectScopeRoot for decide()'s path-scope cap to ever auto-approve npm test — found empirically) and seeds agent_areas (missing it 404s the first LLM turn's mandatory metering — found empirically too). The :golden_set_qa ExUnit tag is excluded PERMANENTLY, never by binary/service detection, because this machine already runs Ollama continuously and auto-inclusion would fire the suite inside any mix test. A floor.json ratchet (mirroring coverage-floor.ts, >= never >, human-edited only) replaces a binary pass/fail: measured real pass rates were qwen2.5-coder:latest 1–4/6 across four runs (dominant failure: empty terminal command arguments — the same tool-calling unreliability ADR 0020 already found with this model family) versus gpt-oss:20b 4–5/6 across two (4x slower per case) — gpt-oss:20b is the recommended default for any workflow depending on this judgment being trustworthy. CI wiring stays a declared TODO(humano) |
| 0168 | The QA Automation golden-set (ADR 0123) starts running in CI on .github/workflows/golden-set-qa.yml, the mold of ADR 0138 — SCHEDULED and WEEKLY (Saturday 01:17 UTC, plus workflow_dispatch), never on pull_request. Two measurements made it possible: AT-067 showed the clock fits a GPU-less ubuntu-latest (refuting the "new infrastructure" half of ADR 0123's sentence) but scored 0/6 by construction under RN-502; AT-076 made the seed bring each case's container up through the broker by the production path, scoring 3/6 twice against the 1/6 floor in 52 and 86 min. Weekly rather than nightly because a run costs 52–86 min and the score VARIES between runs (same total, different cases) — one run a week reads the trend the ratchet floor guards; timeout-minutes: 150 covers the 86 min measured plus the model's variance. The verdict is the test's own floor; the workflow adds only two instrument guards (the score line must exist, and no RN-502 refusal may appear in the event log) and, on failure, prints logs exactly like the RAG job — no issue. api, engine and broker run natively on the runner and only the broker talks to Docker; the image to pre-pull is read from the seed's own constant, so the job can never pull one image while the broker asks for another inside the 30 s control ceiling (RN-604). Declared: the CI broker lacks the compose's five containment layers (what remains is what it accepts), and a PR that edits the workflow does not run it |
| 0192 | The test plan is written AFTER the dev agent's delivery, from the delivered code, not at design time (AT-269, owner decision 01/10, the "(b)" exit that contradicts ADR 0090). It becomes the FIRST step of the qa-verificada cycle: QaLeadServer.run_area/3 runs QaEstrategiaAgent.run/6 on the dev's worktree with the files the delivery touched (git diff dev...HEAD), once per TASK (the correction round reuses the plan by taskId), and the plan reaches Automation as INPUT — the verdict rule and the one-verdict contract don't change, and a failed plan never holds the review. The implementavel gate stays (same owner, warn, human approval) and is judged by the Dev Lead on the story and the module_map already in its context: the verdict comes out on the FIRST call, without plan fields. run_design/3, run_qa_estrategia/3 and QaEstrategiaContext go away; artifact.plano_de_teste requires taskId; the iteration cap STAYS at 8, now with the task budget underneath; appsec stays at design time (RN-674) |
| 0207 | A gate cycle stalled for more than 2 hours since its last activity is parked with a gate.rescue_parked event and only a human gesture resumes it; the rescuer asks the lead about the TASK, not the project, and leaves alone a cycle with a pending human decision (AT-391, revises 0067). |
| 0214 | An open question to the user becomes a pending item with state in the log (artifact.open_question + open_question.answered + a decision_record), declared only by the declare_open_question tool and answered only by the session author; QA gains the needs_user_decision verdict, which does not consume the correction cycle and parks the gate through the ADR 0207 mechanism until the answer; the decision reaches dev-context and the QA cut, and the PO may correct a ready story from an answered question (AT-521, extends 0207, revises part of 0212). |
| 0215 | The openQuestionId of a gate verdict must exist in the project and be open (unknown is 404, already answered is 409 with the decision), afeta.regras accepts the exact rule title and stores the id, and dev-context also returns the project's other decisions as decisoesDoProjeto inside the same cap of 10 (AT-526, revises part of 0214). |
Acceptance criterion still open. 0020 explicitly records that the gates demo is "CLOSED, but NOT DETERMINISTIC" — it depends on the judgment of a local 7B model and doesn't serve as a regression test. 0021 has a section "Acceptance criterion status: NOT CLOSED". Both are honest on purpose; don't read them as finished.
Execution, terminal policy and approvals
The dev agent at work, proposed_action, the terminal policy, path scope, auto mode and the absolute ceilings.
| # | decision |
|---|---|
| 0011 | Worktrees per dev agent, git executors and the merge lock: a protected branch is never auto-approvable |
| 0012 | Real DevAgent: ToolLoop in the worktree, budget per task, ReportDone requiring a green suite, blocking with diagnosis |
| 0017 | Workspace initialization lock, dev agent Monitor and cap inheritance for subagent corrections |
| 0018 | NoopDevAgent stops being scaffolding and becomes a permanent execution mode |
| 0019 | Unlocking the real DevAgent: writing, terminal, ADR per module and budget per project |
| 0045 | Event-driven dev agent rescheduling: outbox (not an in-process call) because it survives a restart between the gate's verdict and the wake; awaiting_gate holds the worktree per AGENT (not per task) until the gate finishes; delivery via PubSub (not Registry, node-local) with the at-most-once limit consciously accepted; the circuit breaker (RN-047) lives in the engine because actor.id of backlog.task_blocked doesn't identify an agent's sequence |
| 0047 | Closing Phase 12: validation is a SCRIPT that exits != 0, not a prose script, and evidence is extracted from the database instead of transcribed; runs local and LLM-free for two verified reasons (the fork was never named; gate judgment with a local model has not been deterministic since ADR 0020), and that's declared in the first paragraph, not the footer; the Noop entered the 12b state machine — finding #10 lived on inside the very measurement instrument —, moving it to AgentIo instead of copying it; Phase 10's harvest was written with the gaps as not measured |
| 0048 | Two findings revisited: an action's DECISION (created/approved/denied) becomes a domain event with the real actor — the outbox is transport, not memory, and events.md had documented the three since forever without them existing; status in the created payload is what distinguishes a human click from policy auto-approval, the metric Phase 10 couldn't collect. And D5: the cause wasn't the worktree being recycled, it was propose/3 discarding the status and the gate opening with no PR — QA swept the worktree, approved, and the task closed without a single line committed. With the order fixed, D5 dies as a consequence, and worktree-per-task (foreseen by ADR 0045) stops being necessary |
| 0052 | The dev agent waits for approval mid-loop, instead of burning iterations. The :pipeline tool pending returned the string proposed_action <id> status pending AS THE RESULT, and the ToolLoop kept going: the agent learned nothing about the command, tried something else, and died at toolloop.limit_reached {iteration: 8, max_iterations: 8} without writing a single line — approvals granted arrived after the loop had run out and were useless. The terminal allowlist doesn't solve it because it's a list of anticipated commands and the model invents commands: with read commands allowed, the same agent got stuck on git branch -a. :awaiting_approval ALREADY existed (Phase 12e, for the pr_open at the end of the task) with all the plumbing ready — outbox event, worker, Wake.deliver, worktree retention —, so what's missing is applying it mid-loop. It does NOT wait by blocking: the ToolLoop runs inside the GenServer callback, and blocking there locks the mailbox that would bring the decision itself |
| 0055 | Path scope in the terminal policy. The terminal approval ladder was MEASURED as unworkable: 15 turns, 209,031 input tokens against 4,546 output, 14 actions and ZERO lines written for the task, because almost every turn ended up waiting on a click. Two independent defects explain the "always asks": the agent emits cd <path> && <verb> and cd is in no allow list, and a composite command requires that ALL segments match (decide.ts:201), so the seeded allow is almost never reached; and "Always allow" records the WHOLE literal command, which never matches again because matching is by token prefix — the escape hatch doesn't escape. What the user is asking for, "always, as long as it's inside the project folder", is today inexpressible: only write_file has any notion of path, terminal doesn't. Execution exposed the aggravating factor: /workspace inside the executor IS the Brabo monorepo itself, and the hello-clean dev agent went as far as proposing to read git_executor.ex and context_builder.ex — there's no project boundary, and the reach is bigger than the project. Scope resolves by REALPATH and not by string prefix (a .. would escape), it PERMITS without exempting (network and install still ask; being in the folder doesn't make curl piped to a shell safe), deny still wins first, and outside the scope is require_approval, not deny. It's policy, not isolation: as long as the monorepo is mounted in the container that executes, the boundary depends on the policy getting it right |
| 0102 | Revision of ADR 0065, a GLOBAL decision by the product owner (explicitly confirmed after an automatic security warning): git push/PR/deploy (RN-106) and sudo/doas (new) move from unconditional deny to an ABSOLUTE CAP — unconditional require_approval, the same code pattern as the other caps, never auto-approvable by auto mode nor by permissions.json. The "always allow" loophole was closed AT THE SOURCE (ApproveAlwaysActionUseCase refuses to write a pattern for these commands) — without that, the cap would be decorative |
| 0167 | Auto mode frees the path-scope ceiling, and only that one. Measured on the owner's install (AT-226, project exp001, mounted with a running container): with the "*": auto_approve wildcard on, 51 terminal commands still asked for approval, 47 only because they cited /work or /tmp — the dev agent runs inside the container and ADR 0055's scope compares against the HOST root. Decided by the product owner: an agent in auto mode runs any terminal command without approval, even outside the project folder. decide() now receives the autonomy's ORIGIN (specific rule or wildcard) from the repository, which still owns the specific-over-wildcard precedence; only a wildcard resolved as auto_approve is auto mode. In auto mode the path-scope ceiling and the require_approval a compound command synthesises for an uncovered segment no longer apply; an ask written in permissions.json, every deny, the external-effect/privileged ceiling (RN-418), protected-branch merge, instruction_patch, parallelism and container_remove stay exactly as they were. The approval card and the agent card say, before the click, what auto mode frees and what still asks. Refuses: translating /work before the scope check, and freeing scope for any specific auto_approve |
| 0189 | Auto mode becomes the AUTOPILOT, and the path scope compares against the folder where the command RUNS (AT-259/255/258, owner decision 01/10). ADR 0167 is NOT revoked: the *: auto_approve wildcard approves everything — synthesized compound commands, paths outside the folder, git commit and LOCAL branch creation — except the absolute ceilings (push/PR/merge/deploy, sudo/doas, protected-branch merge, container_remove, instruction_patch, parallelism); deny still wins. A specific auto_approve written by "Always allow" under the wildcard resolves AS the wildcard, so it no longer turns the autopilot off. With a registered running container (container/mounted), the ADR 0055 scope root becomes /work plus the container's /tmp, with the host cwd translated like the engine does; without one it stays the host folder, /tmp outside. The UI shows one list of what the autopilot releases and what it does not, in the three places it is turned on. And "Always allow" records VERB + SUBCOMMAND, one pattern per command segment (AT-257, closing AT-170 and ADR 0055 point 6): verb + flag stays exact, and a unit that would prefix an RN-418 cap (git remote, gh pr) stays exact too. The containment that backs the decision is proved end to end in the broker, and what it does NOT cover is declared |
| 0204 | Auto mode releases git_push and pr_open (typed, and the same push/PR through the terminal command), by owner decision of 02/10 (AT-385, RN-713), revising ADR 0102 and RN-689 for push/PR only. ONLY the *: auto_approve wildcard releases them — a specific rule, "Always allow" and permissions.json still do not, and activation keeps seeding only git_commit. Protected-branch merge, deploy, sudo/doas, container_remove, instruction_patch and parallelism stay absolute ceilings; turning the toggle back to manual restores the approval |
Where the code lives — execution modes
The three execution modes, the user's folder, the projects base and converting a project between modes.
| # | decision |
|---|---|
| 0066 | Readable workspace folder name. Revises 0055: projectScopeRoot now receives workspace_dir_name, no longer the raw projectId, preserving the guarantee that api and engine agree on the project's root. User request: a readable physical folder (<slug>-<8 chars of the id>), with a unique id — PROJECT_WORKSPACES_ROOT is ONE root for the whole instance, and slug alone isn't globally unique. Between the two options presented (lower-risk symlink vs. actually renaming the folder), the user explicitly chose the second. The id is born in CODE (crypto.randomUUID() in CreateProjectUseCase, no longer Postgres's defaultRandom()), because the folder name needs it BEFORE the INSERT. A project created before this migration kept the physical folder it already had — the backfill writes workspace_dir_name = id and NEVER renames any directory, and the name is FROZEN at creation: editing the slug afterward doesn't recompute the folder. A BEFORE INSERT trigger (the first ever in the product's migration history) applies the same id::text fallback for an insert that arrives without the field — a safety net, not the main path, and what avoids rewriting more than fifty test specs that insert projects with no knowledge of the concept. The engine reads the SAME column (Engine.Projects.Project.workspace_dir_name/1) to resolve Workspace.workspace_dir/1, never recomputing the name — the two derivations are, in practice, the same read against the same database. Outside the tool loop's hot path (which already receives ctx[:workspace_root] resolved once, at the agent worktree's creation); WorktreeCleanup switched from scanning disk to querying Project.all_workspace_dirs/0 in a single query, because the folder stopped being the project_id and there's no way back from one to the other without a query |
| 0072 | The project chooses where the code lives: the user's folder (Local) or a managed folder (Container). Revises part of 0065 and treads on 0055's ground — both had decided the OPPOSITE direction, heading toward the container wall, and that's why the consequences section is the most important part of this ADR. User request, with the variant explicitly chosen: freely typed path, aware of the warning that it only works mounted in the container. projects gains the pair (workspace_mode, workspace_path) with a CHECK tying them together — local with no path would be a terminal scope pointing nowhere, container with a path would be a second source of truth —, and the lock lives in the DATABASE because the column is read by two processes and written by scripts that don't go through the use case. container is the default: an existing project doesn't change location. projectScopeRoot now receives the LOCATION instead of the folder name and stays the SOLE derivation — no caller gained its own validation, for the same reason the function exists. The creation guard (RN-170) REJECTS with 400 and teaches: absolute path, no .., that exists, is a folder and is writable by the process (`W_OK |
| 0104 | ADRs 0072 and 0103 had never talked to each other: RN-170 requires a bind-mount at creation, RN-420 routes to the runner under the SAME workspace_mode == 'local' flag with no bind-mount at all — actually using the runner still had to pass the mounted-folder validation. workspace_mode (2 values) becomes execution_mode (3: container, mounted — the old local, RN-170 now conditional on it — and runner, with no bind-mount). A runner project is born with a DECLARED path and workspace: unverified state, promoted to verified only when a runner connects and confirms it on the HOST — the same spirit as the evidence-by-event gates docs/gates.yml already uses. Converting between the three values is now allowed, with no need to recreate the project. Product owner's prioritized backlog, recorded in this order: distributing the runner via npm publish (@brabo/runner, tsup), a long-lived account token (PAT) replacing the login replay in apps/runner/src/auth.ts — BEFORE distribution —, exclusivity by {project_id, machine_id} DEFERRED until a second real simultaneous dev, and guard.ts REAFFIRMED as best-effort — the real security boundary is authentication + approval + consent, never sandboxing |
| 0107 | Local folder navigation, revising (without editing) ADR 0072: the folder picker 0072 had refused because it required the api to enumerate the CONTAINER's filesystem now exists via the RUNNER (ADR 0103), which already has the right view — the user's real machine. Two new events on the SAME terminal:<projectId> channel (fs_list_dir/fs_home_dir), same pure-relay design as the PTY, correlated by ref. The api still enumerates no filesystem at all — no new routes. The security boundary stays the one from 0103 (authentication + no-effect reads + consent), not a path allowlist — guard.ts (which restricts an approved command's cwd) deliberately does NOT apply here, because the point is FREE browsing. Declared gap: on the project CREATION screen the ticket is per-project and the project is only born on confirmation, so "Browse folder..." there shows the declared state instead of faking an impossible connection — it works end to end where the project already exists |
| 0108 | Closes the gap ADR 0107 itself had declared: in runner mode, NewProjectWizard.tsx now creates the project when "Browse folder..." is clicked, not only on confirmation — using the path already typed, or a lexically valid, provisional placeholder (/workspace-a-confirmar, overwritten once the real runner connects, RN-423) when the field is still empty. Reused by an IDENTITY snapshot (name/externalId/adotando — never the path, whose refinement is the very point of browsing): unchanged identity reuses the same project, changed identity creates another. mounted mode doesn't change — path validation touches disk at creation, so it still waits for confirmation. Accepted side effect: an orphaned "unprovisioned" project if the wizard is closed before finishing, the SAME state any interrupted creation already produces today |
| 0111 | Converting execution_mode on an EXISTING project, correcting ADR 0104 item 4's claim that this was already possible "without recreating the project" — it wasn't; UpdateProjectDto kept excluding the two fields on purpose. A dedicated route (PUT :projectId/execution-mode, role:maintainer) orchestrates what a bare column write can't: refuses (409) while any dev agent of the project is non-idle in ANY session (engine.dev_agent_states, idle_tripped counts as active here, unlike RN-409's online count, because the agent still holds a captured workspace_root) — never draining or migrating a live agent; relocates permissions.json to the new scope root via a new PermissionsFileStore.move() (content never changes, only the physical path); retires the project_containers lifecycle row to removed (through stopped first if running) BEFORE the column flips when leaving container, and never auto-provisions when entering it; resets workspaceVerifiedAt on every real conversion. Declared, not hidden: conversion never copies code between scope roots — the engine re-derives the worktree from the project's bare repo on demand (Engine.Actions.Workspace.ensure!/4), so committed work is safe but an UNCOMMITTED diff in the abandoned old worktree is orphaned in place, not migrated and not deleted |
| 0141 | Creating a mounted project used to require hand-editing a bind-mount line into docker-compose.yml for BOTH api and engine and restarting them — which kills every in-flight agent turn, terminal socket and LLM call in the whole installation, to onboard ONE project. Tolerable while mounted was an escape hatch; disqualifying now that it becomes a first-class choice with a suggested path. So: ONE base, BRABO_PROJECTS_BASE, configured once by the operator and mounted by IDENTITY ($X:$X) into api and engine, with every mounted project living underneath it and nothing edited per project, ever. Identity rather than a fixed mountpoint because projects.workspace_path is a string the user TYPES and the screen shows back: with host ≠ container the product would have to pick which of the two to store and either choice makes the screen lie to someone — and identity is exactly what lets projectScopeRoot (api) and Engine.Actions.Workspace.workspace_dir/2 (engine) stay correct with zero new code, the leading-slash discriminator still deciding. A NEW variable, never PROJECT_WORKSPACES_HOST_DIR, for three reasons whose first has a data consequence: a managed workspace is named by workspace_dir_name (UNIQUE) and a mounted project is named by the USER, so <base>/loja and a container project whose dir name is loja would land in the SAME physical folder and init_from_bare! would git init inside someone else's project — nothing in the schema prevents it, since uniqueness is among workspace_dir_name values and never against a workspace_path basename. Absent is a NORMAL state: baseDeProjetos() returns null and never throws, GET /workspaces/:workspaceId/projects-base (maintainer, the same minimum as POST .../projects since deciding what that route offers is the only thing the value is for) carries the null to the client, and the wizard simply doesn't offer Mounted mode — never offer a mode the installation can't honor. And the /workspace blind spot finally gets a guard where one is possible: scripts/dev/preflight.mjs REFUSES to bring the stack up when the base overlaps the Brabo checkout in either direction, because it runs on the HOST — the api compares against process.cwd(), /workspace inside its own container, so someone who clones Brabo into $HOME/brabo and points the base there passes every existing validation and has dev agents executing inside the product's own tree, the ADR 0055 failure entering through a door it doesn't watch. Two declared costs: a symlink under the base resolves differently on the two sides (the folder browser refuses to descend into symlinks and counts them; the disk check at materialization is what catches the divergence, as a NAMED failure), and code outside the base requires moving the folder — v1 supports one base, and the refusal message says so instead of hinting at a hidden setting |
| 0142 | ADR 0141 decided WHERE a mounted project lives and explicitly left the other half to the next PR: WHEN that folder comes into existence. The product owner's requirement is literal — "if it's Mounted folder, the bind-mount must be created AFTER the architect's decision" — and today's code makes it impossible: validarCaminhoDeWorkspaceLocal runs at project CREATION and asks three disk questions (does it exist? is it a directory? can this process write to it?), refusing with 400 when any fails. Creation is the FIRST screen of the flow; the architect's decision happens many sessions later, so requiring the folder to be ready at creation is requiring it before any decision exists at all — and it is what keeps mounted from being a first-class choice, since a path the wizard SUGGESTS is by construction a path that does not exist yet. So mounted adopts the discipline runner already had (RN-423): only the LEXICAL predicate at creation, plus one new rule of its own — the path must sit inside BRABO_PROJECTS_BASE, the only folder of the machine the api and engine containers can see, with no base configured meaning the MODE is not available on this installation and the refusal saying so. The deferred verification comes back in materializarWorkspaceMontado (mkdir -p plus the same three disk questions, with the out-of-base refusal BEFORE the mkdir so a path written outside the product can't make the api create folders anywhere it reaches), called from two places: ExecuteContainerStartUseCase, the normal one, where Infra starting the container creates the folder and stamps workspace_verified_at — failure there is a NAMED failed, never a throw, and the lifecycle is NOT marked provisioning, because marking it and only then finding out you cannot write would leave the row asserting a state that never existed; and ConvertProjectExecutionModeUseCase, the declared exception, which has no container step to hang the work on and moves permissions.json into that very folder right after. What deliberately does NOT change is caminhoDeWorkspaceLocalValido: the base rule stays out of it because that predicate runs on EVERY READ via projectScopeRoot, and a LEGACY mounted project outside the base would start exploding when merely read. No migration, and the (execution_mode <> 'container') = (workspace_path IS NOT NULL) CHECK is untouched — deferring VERIFICATION never touches the PAIRING invariant |
| 0146 | The Mounted mode was finished and unreachable: ADR 0141 built the single base and ADR 0142 made it possible to suggest a path that does not exist yet, but nothing in the product ever asks the operator to configure BRABO_PROJECTS_BASE — so GET /workspaces/:workspaceId/projects-base has no caller in the web and the mode is simply never offered. So pnpm bootstrap gains a consent step: it proposes $HOME/projetos-brabo (under $HOME because that is what Docker Desktop shares BY DEFAULT — a default that first requires opening Docker's settings is a question in disguise), and NOT $HOME/brabo-projetos, because .env.example already uses that exact name as the commented example for PROJECT_WORKSPACES_HOST_DIR, the root the PRODUCT manages — the very conflation ADR 0141 refused, since <base>/loja would collide with a container project whose workspace_dir_name is also loja and bootstrap would git init inside someone else's project. The name is only the dodge; the protection is MECHANISM: the script refuses any base equal to, containing or contained in that root, reusing the dentroDe that already backs baseSobrepoeOCheckout. Written by scripts/dev/consentir-base.mjs in Node and never in bash, for two measured reasons: bootstrap.sh has no helper that writes a variable (garantir_env only copies the example, the only in-place writer being escreverEnv in preflight.mjs), and — decisive — a menu item CANNOT ASK, since every menu command runs in background with stdin from /dev/null so that nothing steals the user's arrow keys; a consent step written there would by construction be a step that cannot consent. Docker Desktop file sharing is PROVED BY MOUNTING (docker run --rm -v <base>:/sonda ... ls /sonda), never by reading settings.json: that file is undocumented, differs between macOS and Windows, changes across versions, and describes what the user configured rather than what the daemon will do — the same rule the product already applies to LLM providers, where a capability is declared only when proved by execution (ADR 0041/0042). This becomes the product's SECOND platform guard, next to validarDirDentroDoHomeNoLinux, and the two are symmetric in principle: each restricts only where the restriction means something. And the broker leaves profiles: ["container-broker"] in the LOCAL compose only — ADR 0130 chose that profile because "nothing calls it to write yet... in exchange for nothing", and that premise is dead: ADRs 0133/0136 gave it four real callers and ADR 0144 made mounted bring its container up THROUGH the broker, so with mounted as the local default the broker is what separates the default mode from BrokerIndisponivelError. The asymmetry is what carries it: whoever runs pnpm dev already holds the Docker socket — they are running docker compose — so the broker receiving it grants nothing the operator lacks, while in production the socket is a real privilege boundary and the operator is not the developer. The five containment layers are untouched: this changes WHEN the broker runs, never WHAT it accepts |
Project container and broker
The project container's lifecycle, the image decision, the Docker port and the broker that talks to the daemon.
| # | decision |
|---|---|
| 0065 | Container per project: the boundary stops being policy. Revises 0055, which had declared its own pending item: path scope is POLICY, not isolation — as long as the Brabo monorepo is mounted in the same container that executes commands, the boundary depends on the policy getting it right, and a symlink from inside pointing outward isn't detected. Findings Z and AD (verb allowlist doesn't converge — verb, form and invocation are distinct spaces) reinforce Phase 13's same conclusion: the path to autonomy doesn't go through loosening policy, it goes through changing the boundary. The user's decision, verbatim: each project gets its own container, inside it the agent is FREE (read, write, install, build, test, run), and Code only unlocks once the ARCHITECT decides which image goes up — they're the one who knows which runtime the stack calls for. The image is born as an ARTIFACT (artifact.project_image in the event log, versioned, no table — revising means emitting again), with an explicit OCI tag (latest rejected) and mandatory rationale; the gate (RN-105) makes the Code tab respond 409 while sem_decisao, in the SAME function that already contains the path (RN-092), never duplicated across the four routes. What's left as the boundary is OUTSIDE: git push, PR and deploy don't go through the terminal — not even inside the project's scope —, and the rule is deny, not require_approval, because "always allow" writes the pattern into allow and one click would reopen the door forever; the message redirects to the TYPED action (git_push/git_merge/pr_open), which is already the dev agent's real path. Network is decided ONCE in the artifact (network: none by default, egress authorized by the user) and not command by command — repeating the allowlist for egress would repeat the mistake Z and AD proved doesn't converge; spend (cpus/memory/pids) has a cap that REFUSES instead of silently downgrading. HONEST, declared cut: the container's lifecycle (provisioning, recycling, cleanup, what survives a restart) is left for the phase with a migration slot — container state is mutable and needs a table, and a container that comes up without recycling is worse than none. Consequence: the "inside is free" half of the terminal policy has NOT changed yet; 0055 keeps holding as-is until the wall really goes up |
| 0081 | Container lifecycle: a state table, no orchestrator. Closes the cut 0065 declared about itself ("What this ADR does NOT do") — project_containers (migration 0046) records mutable STATE (status, image_version pointing at the Architect's decision without duplicating it, container_id, DECLARED resource caps, failure_reason), one row per project like dev_agent_states in the engine (0045), with a pure state machine (provisioning → running ⇄ stopped, failed reachable from all three, removed only exits by reprovisioning) in the same shape as session-state-machine.ts. Prior investigation confirmed NO product service mounts /var/run/docker.sock or runs privileged — and that's why the two use cases (Registrar/ObterCicloDeVidaDoContainer) only WRITE and READ, no real Docker call: mounting the socket to "actually make it work" would be a security decision (a root-escalation vector) nobody asked for in this slice. The first transition (provisioning) requires the image already decided (RN-105, same gate as the Code tab) and FREEZES version/resources onto the row; a project in workspace_mode: 'local' (0072) is refused, because it doesn't stand up its own container. No new HTTP route — with no real consumer yet, exposing one would be guessing a contract |
| 0083 | The Terminal tab gets the lifecycle's real consumer, not the terminal itself. Revises the part of 0081 that deferred the HTTP route until a real consumer existed — this piece (PROGRAM 28, Wave 5/F2) was that consumer. The original plan was the full interactive terminal, but Phase 25b stays cut: no service mounts /var/run/docker.sock, and nothing in production transitions project_containers (ADR 0081 created the table, not an orchestrator). Implementing a fake terminal — or one running in the same container as the Brabo monorepo, the debt 0055 already describes as policy — would invent capability, the same mistake ADRs 0041/0042 refuse for provider and model. The real delivery: GET /projects/:projectId/container/lifecycle (RN-267), a faithful mirror of ObterCicloDeVidaDoContainerUseCase, and CodeBottomPanel.tsx showing that state (translated badge, failure reason) UNDER the explanatory text that already existed — never replacing it —, fetched only while the Terminal tab is open (RN-268). null (never provisioned) is today's honest, most common result |
| 0128 | Docker access in the runner goes behind a PORT with exactly five operations (start/stop/remove/inspect/exec), and the implementation was chosen by a PROOF that failed. dockerode was installed, imported, instantiated and exercised (ping()) through a --self-test-docker flag run against the real artifacts — never an import a bundler could tree-shake. tsup passed (it leaves dependencies as an external require, so dockerode never entered the bundle); bun build --compile (ADR 0112, the standalone binary) REPROVED, with the error recorded verbatim: Could not resolve: "../build/Release/cpufeatures.node". The chain is mandatory, not accidental — docker-modem/lib/modem.js requires ./ssh at module top level, ssh2 requires cpu-features, and that optional native binding is wrapped in a runtime try/catch the BUNDLER does not have. Same class of problem ADR 0112 documented for node-pty, with the difference that decides it: there the binding is ESSENTIAL, here it accelerates an SSH transport this runner never uses (it speaks to the local unix socket). Measured and NOT adopted, so nobody redoes the investigation: --external cpu-features compiles (280 modules against 79, +1.7 MB per binary × 5 platforms) and dockerode works inside the binary — it is exactly the creative workaround the product owner excluded in advance. So the runner uses execFile('docker', …), dockerode is gone from the lockfile, and the BROKER (PR 1.2) does not inherit the decision: it ships in an image we build and never becomes a standalone binary. Containment is the TYPE, not caller discipline: no field for privileged/cap_add, RedeDoContainer is `'none' |
| 0130 | The container BROKER is born — a new Node/TS service (apps/broker), the only process in the product that talks to a Docker daemon on the SERVER, and the api never receives the socket. Three inseparable decisions. First, the port MOVES: docker-port.ts and docker-cli.ts (and their two specs) leave apps/runner/src/ for a new workspace package, packages/docker-port, consumed by runner and broker — exactly what ADR 0128 wrote it would do, never copied, because "a second file with the same five operations and a sixth 'broker only' is the beginning of the end of containment". packages/shared was refused for the invariant it declares and packages-shared-so-tipos.spec.ts keeps honest (100% type, and the api's production image dies with ERR_UNSUPPORTED_NODE_MODULES_TYPE_STRIPPING if a runtime value survives tsc); the new package has NO build step and is BUNDLED by both consumers (tsup, bun build --compile), which is why the api cannot consume it and why validarDecisaoDeImagem does NOT move. The standalone binary survived the move, measured: build, build:bin, smoke and smoke:bin green, Bun bundle 79 → 83 modules, npm pack --dry-run unchanged. Second, and this is the point of the service: the broker does not accept a container spec. It takes a projectId plus one of the five operations, READS the Architect's decision from the api itself (GET /internal/projects/:projectId/container-spec, RN-485) and COMPOSES image, network, resources and the single mount — there is no field in which to write privileged, cap_add, network: host or a free -v, because there is no field. It revalidates what the api returned, since containment that depends on the caller being correct is not containment, and the two validations answer different questions (the api's is about the ARTIFACT being reviewable; this one is the parse of untrusted JSON into the closed type). One check exists only here and is new: an image reference starting with - would be read by the CLI as a FLAG — execFile without a shell solves command injection, not ARGUMENT injection. Third, the api sends NO path at all: -v is resolved by the daemon against the HOST filesystem, so a path from inside the api container would silently mount an empty folder; the broker joins workspaceDirName (RN-109) with its own PROJECT_WORKSPACES_HOST_ROOT and refuses start when that is missing rather than guessing. pidsLimit joins the port for a reason worth naming — the artifact always carried three numbers and dropping one would make it promise a cap the container does not get. Contained by five independent layers: no published port, an internal: true network only the api joins, the shared service token compared in constant time, five operations, and the computed spec. It ships under profiles: ["container-broker"] in both composes and is therefore OFF by default, and its image is NOT published to the GHCR — docker-bake.hcl and ALVOS keep the four images of ADR 0119. The api gains a port and an HTTP client whose only CALLER is inspect: the container lifecycle route now returns the OBSERVED state beside the RECORDED one, never fused, with naoObservado naming why when there was no way to look (RN-486) — so the table can now lie, and the read says so. Nothing brings a container up: no loop, no queue, no container_start proposed_action, and RegistrarTransicaoDeContainerUseCase is byte-for-byte unchanged, mode guard included |
| 0131 | The Architect gains a seventh tool, route_modules_to_infra: one item {modulo, imagemCandidata, porque} per module of the CURRENT module_map, recorded as artifact.module_routing — no table, same design as artifact.module_map/artifact.project_image/artifact.c4_diagram, the event IS the artifact. The Architect only CANDIDATES; electing among the candidates (or refusing all of them) is Infra's job, in a later PR — this one only produces the auditable list. Each item's image goes through the SAME rule choose_project_image already applies (validarDecisaoDeImagem: explicit tag/digest, latest rejected, a real rationale), reused rather than reimplemented per item; the new domain (module-routing.ts) only adds what's proper to the LIST — empty is refused (not a decision), a repeated module is refused (ambiguous: which of the two images wins?), and each modulo must name one that exists in the current module_map, refused by naming the VALID ones, same rule as assign_story_modules. :direct, never proposed_action: routing has no external effect. build_kickoff/1 — the Architect's only source of tool ORDER — lists the new tool right after create_module_map |
| 0133 | ADR 0131 left half a sentence hanging — "the Architect only candidates, Infra elects, in a separate PR" — and this is that PR, wired to the other hanging sentence, in the broker's own port comment (ADR 0130): "starting is external effect and doesn't happen without a proposed_action; Infra proposes it, with the user's final authority — another PR." The join between the two exposes a question neither ADR answered: the broker takes no spec, it reads GET .../container-spec, which reads artifact.project_image (RN-105) — not artifact.module_routing. So an election that doesn't become a new version of artifact.project_image is auditable and INERT — the container that comes up is whatever choose_project_image said, unrelated to what Infra just elected. Decision: the election REUSES DecidirImagemDoProjetoUseCase whole, called now with decidedBy: 'infra-lead' instead of the default 'arquiteto' — same validation (validarDecisaoDeImagem, same resource ceiling), same single "current" artifact, same precedent module-routing.ts's own comment already set for reusing instead of reimplementing. The elected image MUST be one of the routing's imagemCandidata — never invented — checked before touching the artifact, refused by name with the valid candidates listed when it isn't. New container_start proposed_action (maintainer, deliberately outside the absolute-ceiling block in decide.ts, same reasoning already recorded there for propose_execution_plan/assess_implementability — first election, not a ceiling raise) is the FIRST real caller of ContainerBrokerPort.start — of the five operations ADR 0128/0130 declared, only inspect had one before. RegistrarTransicaoDeContainerUseCase gets its first non-test caller too, transitioning provisioning → running (or straight to running from stopped, since the ADR 0081 state machine refuses stopped → provisioning) only after the broker confirms. Dev agents still do not run inside the container that comes up — that's a later PR |
| 0134 | Closes what ADR 0133 left declared: "dev agents still do not run inside the container that comes up — that's a later PR." Engine.Actions.TerminalExecutor.decisao_de_execucao/1 gains a FIFTH outcome, :executar_no_container — execution_mode: container with a project_containers row REGISTERED running (Engine.Containers.ProjectContainerLifecycle.running?/1, a new read-only Ecto schema mirroring Engine.Projects.Project's pattern) — and the terminal command crosses engine → api (POST internal/projects/:projectId/container-exec, a new route in InternalProjectsController, not the broker-facing InternalContainersController, since the caller here is the engine and nothing is WRITTEN to the lifecycle table) → broker (ContainerBrokerPort.exec, the last of the five ADR 0128/0130 operations to gain a real caller), running via docker exec instead of System.cmd inside the engine process. cwd, when present, is translated from the HOST path (inside PROJECT_WORKSPACES_ROOT) to /work by the engine before the call — verified in code, not assumed: the broker's raizDoProjeto (PROJECT_WORKSPACES_HOST_ROOT + workspaceDirName) and the engine's PROJECT_WORKSPACES_ROOT + workspace_dir_name resolve the SAME physical directory, so Workspace.ensure!/4 needed zero changes — the container is just a second observer of the worktree the dev agent already had. A running row is only ever the REGISTERED state (RN-486: registered and observed never merge); a dead or externally-removed container turns the broker call into an ordinary failed_result — never a crash, never a silent fallback back outside the container, which would reopen the isolation gap this PR closes. Terminal actions scoped to the project's real container also gain an auto-approval FLOOR — containerExecutionActive in DecideContext, set by ProposeActionUseCase only for terminal in execution_mode: container — that changes decide()'s INITIAL value from require_approval to auto_approve; it is a floor, not a new ceiling: agent_autonomy/permissions.json can still lower it exactly as they already lower the old default, and the five absolute ceilings (path scope, external-effect git/privileged command, protected-branch merge, instruction_patch, parallelism) keep running on top unchanged. The safety argument is a second, stronger boundary, not the absence of the first: decide() never sees a /work path (that translation happens later, in the engine), so the lexical path-scope cap keeps operating on the same host paths as always, redundant with — not replaced by — the Docker mount namespace and the broker's own /work revalidation |
| 0135 | Executes decision #5 of the "real container execution" plan, already accepted before this PR existed: the RN-105 image gate — 409 on the Code tab until someone decided artifact.project_image — stops being dispensed for mounted/runner (ADR 0072/RN-169, revised by ADR 0104/RN-421). ReadProjectCodeUseCase.portaoDoContainer drops its executionMode !== 'container' early return; the gate becomes unconditional across all three modes, and ProjectCodeTab (web) stops treating mounted/runner as a special case — same useQuery, same four states, modoLocal branch removed, which SIMPLIFIES the component rather than adding a case. Accepted cost, stated plainly: every EXISTING mounted/runner project without a decided image — including this repo's own dogfooding projects, exp001/exp002 — loses Code-tab access the instant this ships, until the Architect (or Infra's election, ADR 0133) decides one; that is operator action required AFTER deploy, so the branch is breaking/, not bugfix/, even though the change reads as a fix, and the version bump to MAJOR is mechanical, not manual. RegistrarTransicaoDeContainerUseCase also drops its 400-by-mode rejection — project_containers can register a row for all three modes now — but investigated and confirmed unchanged: reaching running for real stays impossible for mounted/runner, enforced in exactly one place, ContainerBrokerPort.start()'s ModoDeExecucaoNaoSuportadoError (the broker runs on the SERVER and cannot see the user's folder), which ExecuteContainerStartUseCase already turns into an ordinary container.start_failed — never a crash, never a silent fallback — BEFORE any lifecycle transition is even attempted, so no row is born for those modes through the real approval path despite the 400 being gone. No second mode check was added at the api/propose layer on purpose: duplicating the broker's would-be gate creates a second place to drift from the first, the exact lesson the broker's own comment already recorded while pointing at this ADR. Also investigated, and left declared rather than fixed here (out of this PR's API/domain scope): neither propose_container_start (the Infra Lead's tool) nor GetInfraContextUseCase restrict by executionMode today, so the Infra Lead can still propose container_start for a mounted/runner project — pre-existing gap, wastes one approval cycle, fails loud and named rather than silently |
| 0136 | The global containers page (/containers, cross-project) closes the ADR 0130 port comment's other declared gap — "stop/remove have no caller yet" — with two decisions the plan didn't specify. First: the page reads the REGISTERED state in three constant queries (ContainersOverviewRepository, same no-N+1 shape as ProjectsSummaryRepository), but asking the broker for the OBSERVED state is a network call per project — so only provisioning/running rows are eligible, capped at TETO_DE_VERIFICACOES_POR_CARGA (20) per load, in parallel; what falls outside either rule carries its OWN naoVerificado reason, never confused with the broker's own naoObservado (RN-468/486 discipline extended to a new screen). Second: container_stop gets container_start's exact calibre (maintainer, configurable auto-approve, never seeded); container_remove — the only one of the three that discards the container and forces a full reprovision — joins the SAME absolute ceiling as external-effect git/privileged commands (RN-418): never auto-approvable, "always allow" refused at the source. ContainerBrokerPort.remove was already docker rm --force (removes even running, one call); rather than widen the state machine with a running -> removed shortcut, ExecuteContainerRemoveUseCase records the two real hops (stopped, then removed). "Start again" reuses container_start verbatim — zero backend change — building its payload from the project's current image decision instead of a fourth action type |
| 0137 | Closes the other half ADR 0135 left declared: mounted/runner now require a decided image (RN-105 gate, all three modes) but still had no way to bring a container up — the server-side broker refuses those two modes outright, because their code lives in a folder only the USER's machine can see. container_start/container_stop/container_remove gain a SECOND execution path, branched by executionMode only at EXECUTION time (never at propose or approve, both still mode-agnostic): container keeps going through the broker unchanged; mounted/runner now ask the ENGINE to relay to the connected RUNNER, over three new event pairs on the same Phoenix channel — container_start/_result, container_stop/_result, container_remove/_result — replicating the exact exec/exec_result pattern rather than inventing a new one. The runner answers with DockerViaCli (@brabo/docker-port), the SAME port the broker uses server-side, proven so far only by --self-test-docker and now with a real caller; the image it starts is READ from the current decision (ObterSpecDeContainerUseCase, the same use case the broker's own spec route already calls, invoked directly since both run in the api process), never re-elected. Engine.Actions.TerminalExecutor gained NO new outcome: it already routed every command of a connected runner project to the channel unconditionally (RN-423) — host-vs-container is a decision the RUNNER makes internally (EstadoDoRunner.containerAtivo, tratarExec switching to docker exec with cwd translated by prefix swap, cwdParaContainer). guard.ts's $HOME containment (RN-434) needed zero new code to cover the new mount — verified by reading the file whole: the bind IS estado.dir, the same root already validated at CLI startup, before any channel connects. "No runner connected"/timeout (RunnerNaoConectadoError) and "the runner tried and refused" (RunnerRecusouContainerError) are both ordinary failed outcomes, never an uncaught exception — same discipline as BrokerIndisponivelError/BrokerRecusouError |
| 0143 | A real exp001 run had ten dev tasks lock up at once, and the finding was not the particular folder that failed — it was that nothing ordered container before dev agent: no container_start was ever proposed, and ten dev agents started anyway. So Engine.Dev.AgentIo.try_claim/2 — the SINGLE claim point — now asks ProjectContainerLifecycle.running?/1 (the predicate ADR 0134 already introduced, no new machinery) BEFORE calling the api, and without a REGISTERED running row the agent falls to :idle, persists, and emits dev.blocked_by_container. In the ENGINE and not only at the api's activate-execution gate, because claiming has one path no route covers: REHYDRATION does no :work cast — DevAgentServer.init/1 -> finish_restart_recovery/1 -> try_claim/2 claims on its own, so an HTTP-only gate would let every rehydrated agent go back to work containerless, in exactly the situation where nobody is watching. :idle and not a new status, for the reason try_claim/2's own docblock had recorded back in Phase 12b: it is the only state a wake still rescues, and every handle_info/2 guard is keyed to it — a :blocked_by_container would be a state nothing rescues. What distinguishes "the queue emptied" from "there is no container" is the EVENT, never the status. Waking is by outbox: RegistrarTransicaoDeContainerUseCase appends container.running in the SAME transaction that records the arrival, under a container aggregate (the third Engine.Outbox.Drain reads, next to session/task) rather than borrowing task to save a query line, with the PROJECT as aggregateId; the delivered message is the EXISTING {:wake, :became_claimable}, whose semantics already are "there may be work now" — a new one would need a new handle_info/2 clause in both servers with an identical guard and no different decision to make. Second half, same rule seen from the other side: TerminalExecutor.decisao_de_execucao/1 stops degrading silently — container with no running container used to fall through to System.cmd INSIDE the engine process, so ADR 0134's isolation held only on the happy path. It now refuses (:recusar_container_ausente), mirroring the runner mode's two existing refusals, with mounted in the same branch, and the catch-all shrinks to what it should always have covered alone: nonexistent project or malformed id. Declared cost, and the reason this can only ship AFTER mounted gets a container: a project without one stops working — loudly, which is the point |
| 0144 | A project in Mounted mode could not have a container at all, blocked twice over: ExecuteContainerStartUseCase sent every non-container mode to the RUNNER (which requires a connected brabo-runner), and the broker refused any non-container mode at the source. Both refusals were about GEOMETRY rather than the mode's name — a mounted project's folder sat somewhere arbitrary on the operator's disk that the server's Docker daemon had no reason to see — and ADR 0141 changed exactly that geometry by putting every mounted project under ONE base mounted by identity. So the broker gains a SECOND root, BRABO_PROJECTS_HOST_BASE, and the api starts sending a DISCRIMINATED locator that says which root a segment belongs to: gerenciada (the managed folder, named by workspace_dir_name), montada (the RELATIVE path under the base) or indisponivel (no root here reaches it, with the reason). The ADR 0130 invariant is untouched and is the reason for the shape: what crosses the wire is still only the half the broker's root does not cover, the api never learns where those roots are on the host, and the broker never learns anything about the project — neither side alone can write an arbitrary path, and sending an absolute one would put the containment of a root-equivalent process back in the caller's hands. Three variants and not two because indisponivel has two DIFFERENT fixes — a runner project (fix: the runner, on the other side) and a LEGACY mounted project created outside the base (fix: move the folder) — and a null would send whoever operates to the wrong place half the time; a folder that IS the base lands there too, since <root>/ would mount every mounted project inside one project's container. Neither root ever stands in for the other: whichever is missing is named in the refusal (BaseDeProjetosNaoConfiguradaError, mirroring RaizDeWorkspacesNaoConfiguradaError), because the managed root is named by workspace_dir_name and the base is named by the user, so falling back would mount someone else's code with nothing indicating why. Three barriers stand over the concatenation, not one — the api refuses what is outside the base, packages/docker-port refuses a segment that is not relative (.., absolute, empty, NUL), and raizDeProjetoValidada still runs over root + segment before it becomes a -v. On the api side the branch becomes one of DESTINATION: container and mounted go to the broker, only runner to the runner, and container_stop/container_remove move with container_start necessarily — bringing a container up on the server and then asking the user's machine to stop it would leave running, with no way to stop it, what is running. And mounted ELECTS the image like container rather than reading the current one like the runner path: the broker composes from artifact.project_image, which it FETCHES from the api, so an election not written to that artifact would be inert — the ADR 0133 argument applied to a second mode |
| 0180 | The project container runs as the FOLDER'S OWNER (--user uid:gid, measured by the api, revalidated by the broker, never from a request body), so dev agents can write to /work under --cap-drop ALL (AT-247). Adds one field to the spec, opens no containment door |
| 0190 | The Infra brings the container up ON ITS OWN when its handoff is accepted (AT-260, owner decision 01/10), revising ADR 0133's "never seeded" without editing it: the accept seeds container_start: auto_approve for the Infra agent, and the Infra Lead's SERVER — not the model, not a new tool — elects the routing candidate covering the most modules (first one on a tie) and proposes container_start before the first model call, through the SAME path as the tool, with the mode/state refusals (RN-566/610) and the no-broker 409 intact. runner keeps container_start_via_runner with approval, container_remove stays in the absolute cap, and the RN-668 closing stays true. Same owner decision (AT-262): the Dev Lead is offered BY THE INFRA, only with the project container running — the Architect's double handoff goes away, and ADR 0053/0085's "the Dev Lead comes from the Architect" changes without being edited |
| 0199 | The container's MINIMUM resources are DERIVED from the module_map (AT-261, owner decision 01/10): each module may declare resources (cpus, memoryMb, pidsLimit — all three or none), and container_start with resources omitted — always the case for the server's start on handoff acceptance (ADR 0190) — starts with the SUM over modules, because they all share ONE container. A module without a declaration floors the container at today's default instead of inventing a number; a map with no declarations gives exactly today's default. A sum above the ceiling is 400 to the Architect when the map is created; a request below the minimum is a named failed |
| 0208 | The Infra INHERITS the network the Architect declared in artifact.project_image (AT-392, owner decision 02/10): the server start on handoff acceptance and the propose_container_start tool without network use the Architect's egress/none instead of a literal none, and still start without a click; revises ADR 0190 point 3, departs from ADR 0065 §4, and declares the price (egress reaches the host's published ports, ADR 0189). |
The local runner
The CLI on the user's machine — its channel, credentials, device keys, distribution, base and service.
| # | decision |
|---|---|
| 0103 | apps/runner (new workspace, Node/TS): a CLI that runs on the user's machine, connects to the engine over a new Phoenix channel (/runner, single-use ticket ISSUED BY THE ENGINE) to run an ALREADY-APPROVED agent command in the user's $SHELL, plus an interactive PTY terminal for the Code tab (the engine does pure byte relay). Routing always happens AFTER approval — never a second execution path. With no runner connected, it falls back to the usual System.cmd (ADR 0072 becomes the fallback). The security boundary is NOT sandboxing: it's authentication + the approval pipeline + the user's consent — guard.ts is declared best-effort. Real finding: with no long-lived account token mechanism in the product, the runner replicates the browser login until a real automation token exists |
| 0105 | The Personal Access Token (brb_…) closes the backlog item from ADR 0104: apps/runner/src/auth.ts swaps replicated login (password+cookie on disk) for a long-lived, project-scoped token. New personal_access_tokens table, HMAC-SHA256+pepper hash via hashDeToken() (a fix to the backlog, which had said argon2 — wrong for a HIGH-entropy secret). Central decision: the PAT never authenticates outside POST .../runner-ticket, BY CONSTRUCTION — IS_PAT_ROUTE_KEY/@RequirePatAuth() (a third branch in JwtAuthGuard, same pattern as IS_SERVICE_ROUTE_KEY) + a route-level PatAuthGuard, never a branch in the global guard that would let RolesGuard authorize the PAT for any route under the user's role. last_used_at is an unconditional UPDATE in the same validation query — no throttling, which would reject a legitimate reconnection under 5 minutes with a 401 |
| 0106 | Closes the last ADR 0104 backlog item: tsup bundles apps/runner into a single dist/index.cjs (node-pty as a mandatory external — a native binding, can't be bundled), published as @brabo/runner by its OWN workflow, triggered by the same final tag as release.yml. Real, empirically-tested finding: the obvious fix for the auto-run guard (import.meta.url === pathToFileURL(argv[1]).href) was BROKEN in the exact case this piece exists to enable — process.argv[1] is never realpath-resolved, import.meta.url always is, and npm install -g creates exactly that asymmetry via a symlink; the final fix applies realpathSync to argv[1] before comparing. The version is injected only into CI's disposable checkout (npm pkg set), never committed. Mitigates npm's latest regressing when republishing one of the six orphan tags already in the repo — it publishes under its own dist-tag when the target version is older than the current latest. breaking/ — an operator action (creating the npm Automation Token) is required before publishing works end to end |
| 0112 | Standalone binary of @brabo/runner via bun build --compile — companion of ADR 0106, closing the ADR 0104 backlog item explicitly deferred there. node-pty resolves its own native .node addon by a path COMPUTED at runtime, which Bun can't embed statically (proven empirically: the naive build compiles clean and throws at runtime); the fix embeds every file node-pty needs via with { type: 'file' } (the only Bun mechanism that accepts a literal specifier) and re-extracts them to a REAL temp directory at startup, preserving the original relative layout so node-pty's own relative require() calls resolve unmodified. Two bugs found only by running the compiled binary: --external node-pty is required on the bun build command itself (Bun silently bundles the non-compiled fallback's await import('node-pty') otherwise, throwing a misleading error at runtime instead of failing the build), and the ADR 0106 auto-run guard needed a second branch for process.argv[1] being a virtual /$bunfs/root/... path inside a compiled binary. Five-target matrix (linux-x64/linux-arm64/darwin-x64/darwin-arm64/win32-x64), each built on its own native runner; only linux-x64 was validated by real execution in this investigation — the other four, Windows especially, are reasoned through node-pty's source, with their first real execution being the next version tag |
| 0118 | Browser-driven runner setup: an Ed25519 key pair generated in the browser (Web Crypto) registers as a new runner_device_keys row, and POST .../runner-ticket accepts its short-lived (≤60s) self-signed EdDSA JWT as a SECOND credential alongside the PAT — additive, never a replacement, and explicitly not the session-JWT dual-auth RN-439 already forbids. A new @Public() route proxies the already-published GitHub Releases binary (5-minute in-memory cache of the asset resolution, never the bytes) instead of building or storing a second copy. The File System Access API writes the configured folder directly in Chromium (binary + config + private device key, three files, one click); everywhere else, two plain downloads are the fallback. Accepted limitation: the browser can't set the Unix execute bit, so chmod +x stays manual. --project/--dir/--token become optional in the CLI when a local config is present. Explicitly rejected in the same session: the runner auto-discovering a local Docker/Kubernetes container and connecting over SSH — would reopen Phase 25b, cut on purpose |
| 0145 | runner routed every approved terminal command to the connected CLI once workspace was verified and the runner was connected — never checking for a REGISTERED running container, the same predicate container/mounted already required since ADR 0143. The runner decided host-vs-container internally (ADR 0137), and with no container up it fell through to the raw HOST, silently, the exact fallback ADR 0143 had closed for the other two modes. A second defect, declared open since RN-478: Workspace.ensure!/4 tried LOCAL File.mkdir_p!/git init for a runner project's HOST-only path, which the engine process never had a bind-mount to — the earlier fix only improved the failure MESSAGE, the dev agent still had nowhere to work. So the three preconditions (verified, connected, container running) move into Engine.Runners.RunnerReadiness, shared by TerminalExecutor and by the worktree materialization, which now runs on the ACTUAL user machine over the SAME exec/exec_result channel that already runs approved terminal (Engine.Actions.Workspace.RunnerGit, new) instead of inside the engine process — Engine.Dev.WorktreeManager's four operations branch the same way, and the periodic cleanup job (Engine.Dev.WorktreeCleanup) SKIPS a runner project that isn't ready right now instead of either crashing or treating it as orphaned. The exec protocol gains an optional env field (RN-507) so git fetch's credential (ADR 0056) can travel in the child process's environment on the user's machine — merged onto process.env, never substituted, never sent to docker exec (no env support in packages/docker-port, by design) and never logged. Third finding: container_start also served runner, but its payload (imagem/network/resources/rationale — Infra ELECTS a candidate) never applied there — nothing to elect against, since the broker never reaches a runner project's folder. So container_start becomes exclusive to container/mounted (both via the broker, RN-503), and a new action, container_start_via_runner (schema: only optional rationale), covers runner — its Infra Lead tool checks execution_mode and runner connectivity LOCALLY (no HTTP) before proposing, refusing by name instead of proposing blind, unlike propose_container_start's pre-existing, declared, and still-open gap (RN-494) |
| 0147 | Writing into a folder the user picked ANYWHERE on the host has exactly three possible mechanisms — run api/engine natively, keep a live process on the host, or have the browser write via the File System Access API — and only the second is continuous. The brabo-runner already is that process, so rather than shipping a second "mirror" binary it becomes ONE local agent that DECLARES CAPABILITIES (espelho, exec, pty) at connection time, with the server granting only what the project's execution_mode requires: one install, one auth, one revocation. Declared in the join params, because today the join is MUTE on both sides — the runner sends {} and the channel signature literally ignores _params, the role coming from the ticket's kind — and granted by intersection, held in socket.assigns as CONNECTION state and never a table, the same reasoning that keeps containerAtivo client-side and that already lets the :global Registry entry die with the pid. A required capability that was not declared REFUSES THE JOIN, which is the right shape because the runner already treats JoinRecusadoError as fatal-without-retry: an incapable runner must not sit in a backoff loop. Capability goes in the join and not in a post-join push like workspace_confirm because that one describes the WORKSPACE, which changes, while capability describes the BINARY, fixed for the connection. The mirror gets its own scope guard, sibling of guard.ts, reusing the lexical+realpath double pass that already catches a symlink mid-path, and refuses three things: a symlink escaping the destination, a destination CONTAINING the workspace, and a destination INSIDE it — the last two being the same loop with ADR 0141's bind, in opposite directions. One direction, and NEVER --delete: a file removed in the workspace stays in the destination, and the accumulation is the declared cost. It does NOT require Docker, and gets a SEPARATE predicate rather than a RunnerReadiness with a flag — a shared function with a "skip the container" parameter is precisely how the third precondition gets dropped for exec by accident later; the mirror does not need it because that precondition exists to stop a command from falling through to the raw HOST (ADR 0145), and a file copy on the user's machine has no host to fall through FROM. Revocation turns out to be blind in a way the code's own docblock understates: it declares the cut as "no maintainer view", but PATs have five routes and device keys have two — a developer cannot list even their OWN keys, and you cannot revoke what you cannot see. So listing is added, and revoking stops meaning only "no new tickets": it now drops the LIVE channel, which Registry.whereis/1 already makes reachable. Sync is triggered BY EVENT (end of turn, commit) and never by fs.watch, which would be unbounded work triggered by anything — including the mirror's own writes, which is a loop. And the line that shapes all of it: consent to configuration is not approval of an action. The mirror's write does not go through proposed_action because it is not an agent asking to act; it is the system writing to a path the user declared. Doing it as a terminal command would be the opposite — it would land in ADR 0055's path scope, become a queue of routine approvals, and corrode the very ceiling that gives the click its meaning |
| 0151 | The runner gains a consented BASE, and folders get created through the channel — a deliberately narrow slice, with the per-MACHINE runner pushed to the next phase. Today the runner has no base: the only root is --dir, and without it the root IS the cwd (index.ts:242-250); estado.dir is one singular path feeding the cwd guard, the pty, cwdParaContainer and workspace_confirm. The base exists only in the api, and that same file forbids in writing (project-workspaces-root.ts:421-438) that the base rule enter caminhoDeWorkspaceLocalValido, which runs on EVERY read. Decision: the installer consents ONE base, stored locally; each project is a subfolder; the base is never received over the wire — same shape as the broker (ADR 0144), where the root belongs to whoever executes and only the SEGMENT travels. The guard REUSES dentroDoEscopo and the per-SEGMENT loop refusal from espelho-guard.ts:208-233 (/base-outra is NOT inside /base), never a fourth copy of the rule — the precedent is RN-515. New pair workspace_create/workspace_create_result in the exec/exec_result mould, and then the runner sends the workspace_confirm that already exists, so no new write path is born, the engine still does not write the table, and one single route keeps stamping workspace_verified_at. Records a naming precision: workspace.verified does not exist anywhere — the event is project.workspace_verified — and workspace_confirm is UNIDIRECTIONAL (terminal_channel.ex:356-373 answers {:noreply, socket}), so a pair needing a reply cannot be modelled on it. Capability enters the vocabulary only in the session the code enters (RN-514). Own predicate with TWO preconditions, adopting espelho.ex:19-24 literally on why RunnerReadiness never gains a "skip container" flag. Not a proposed_action. Declares the FIVE independent couplings the per-machine runner must cross, one of them a migration and one of them breaking/ |
| 0154 | The local agent's identity becomes the MACHINE's, not the project's. The installer already gets close — since RN-531 it downloads the binary, checks it against the signed checksums.txt and installs it with install -m 0755, no manual chmod — and then STOPS, saying so in its own last line: it does not pair the agent, and the device key still comes from the project screen. What blocks it is not the binary but the IDENTITY: service install needs a projectId and a key, and at install time no project exists — no account, no workspace. FASE 29 measured "one runner per project" as imposed in five independent places; re-measuring with arquivo:linha shows three of them need no change at all, and that is this ADR's finding. RunnerSocket.connect/3 authenticates by TICKET, the ticket is requested per project, and the atomic consume happens at join against the topic's project_id — so the topic (terminal_channel.ex:204), the socket id (runner_socket.ex:73) and the ticket describe a CONNECTION, not a process. One process opening N connections, one per project it serves, satisfies all three byte for byte and preserves the refusal of a second runner in the same project, which is a real guarantee rather than an accident. What remains is what genuinely belongs to the process: the credential that requests those N tickets and the unit that keeps it alive. So runner_device_keys.project_id becomes nullable, and NULL MEANS machine — same table, same kid inside the private JWK (RN-475), same revocation; a sibling table was considered and refused because PatAuthGuard finds the public key by kid and does not need to know its species, and two tables would mean two lookups, the second forgotten exactly once. A machine key grants nothing its owning user did not already have: runner-ticket stays per project and authorization is still resolved against the project asked for. The runner DISCOVERS which projects it serves through a new GET runner/projects rather than scanning the base — guessing by folder name is the class of error ADR 0141 refused. The unit becomes one per machine, and the per-project ones keep working, with status SAYING they exist rather than leaving six processes fighting over three projects in silence. Refuses: merging topics into a runner:<userId>, a key crossing users, discovering projects from disk, and any exception in decide.ts — this ADR moves identity, never authority. Declares the cost FASE 29 predicted: revoking now drops the agent for every project on that machine |
| 0174 | The runner's standalone binary stops promising the Intel Mac: four targets instead of the five of ADR 0112. Measured (AT-065): macos-13 has no runner (image retired by GitHub, 24h00m01s queued and cancelled on all three tags), and the replacement macos-15-intel schedules and builds but fails --self-test-pty because under Bun the node-pty onData never fires (oven-sh/bun#25822, open) — the same proof passes under Node. Decided by the maintainer: darwin-x64 leaves the matrix and ALVOS_ESPERADOS, the GET /runner-releases/binary proxy refuses it with its own 400 pointing at the npm package, install.sh on darwin-amd64 does not download and says to run npm install -g @brabo/runner, and the browser still detects it but skips the download. scripts/ci/alvos-do-runner.spec.ts ties the four places that enumerate targets together. Way back: a new ADR once Bun ships the fix |
| 0187 | The runner's standalone binary stops promising Windows: three targets instead of the four of ADR 0174, in the same mold. Measured in four workflow_dispatch rehearsals (AT-343, runs 36775746724, 36779817686, 36780804339, 36781729045): the binary builds, the usage runs and node-pty loads once the Windows virtual path is recognized, but under Bun the net.Socket over the ConPTY output named pipe ends after the first chunk — even cmd.exe /c echo dies with 0xC000013A (oven-sh/bun#25822 family). Decided by the maintainer: win32-x64 leaves the matrix and ALVOS_ESPERADOS, the GET /runner-releases/binary proxy refuses it with its own 400 pointing at npm install -g @brabo/runner, the install.sh Windows refusal names the npm path, and the browser still detects it but skips the download. Way back: a new ADR once Bun ships the fix |
| 0193 | CREDENTIALED git runs on the runner HOST, code runs in the container (AT-116, owner decision 01/10): the authenticated git fetch of RunnerGit goes MARKED (gitCredenciado: true, set only by RunnerRouter.exec_git_credenciado/5) and the runner executes it on the host even with an active container, where the env reaches the child process — the same folder, since estado.dir is the /work bind-mount. No ADR 0130 port changes (DockerPort.exec still has no env), RunnerReadiness stays byte for byte, and the RN-558 refusal shrinks to env WITHOUT the mark (in practice a runner older than this ADR) |
| 0201 | Revocation targets the KEY, not the {project, user} pair (AT-013, owner decision 01/10, revising RN-520 over ADR 0154 and ADR 0155). The runner socket ticket records WHICH credential asked for it (credential_kind/credential_id, nullable, Ecto migration in the engine's own schema), the PatAuthGuard hands it down, the socket keeps it in assigns and in its id, and POST /internal/runner/disconnect-credential voids the credential's pending tickets and asks EVERY registered runner whether it was born from it — so another runner of the same user, on a PAT or another key, stays up, and a MACHINE key is one request instead of the pair applied N times. PAT revocation (RN-426/427) now drops its connections too. Two transition pieces keep what used to fall falling: a LEGACY connection (ticket without a credential) drops by the pair within the projects the api sends, and if the engine does not know the route the key revocation falls back to disconnectRunnerOfUser. The pair route stays for member removal (RN-615). The confirm dialogs change wording only (RN-561); the runner does not change |
| 0203 | The browser-driven runner setup of ADR 0118 is retired (AT-014, owner decision 01/10, closes BRB-031): the browser no longer generates the key, downloads the binary or writes the folder, and RunnerOnboardingPanel shows the install.sh command instead — the installer already downloads the verified binary, installs it executable (install -m 0755, so no manual chmod +x anywhere) and pairs the machine from the terminal. apps/web/src/lib/runner-bootstrap.ts and POST /projects/:projectId/runner-device-keys are removed (no other caller; BREAKING), while GET/DELETE and the internal machine-key route stay. Project keys registered before keep working and stay revocable. Declared: team installations lose the PAT-less way to pair a second machine, and GET /runner-releases/binary is left without a production consumer, kept as a separate decision |
Auth, access and secrets
First-party auth, roles and their ceilings, workspace membership, cross-origin hardening and secret envelopes.
| # | decision |
|---|---|
| 0031 | Auth in the api's domain: argon2id with fixed parameters, EdDSA access token from a derived key, refresh rotation with family revocation on reuse, lockout keyed on email (not id), and the single invariant that closes off enumeration |
| 0032 | The atomic Keycloak cutover: our own issuer in the guard without touching RBAC, /internal/* outside the JWT with a shared secret, web session in an httpOnly cookie with CSRF, and a migrated user indistinguishable from an email that doesn't exist |
| 0037 | The CORS the engine didn't have (/health returned 200 and the browser discarded it), its own plug instead of Corsica with /internal and /metrics outside the allowlist, WEB_ORIGIN read in a single place, and strictPort because the port is part of the CORS contract |
| 0050 | Credential always encrypted and saved; verification becomes a separate action. ADR 0004's gate (test BEFORE persisting) looked prudent and produced the worst outcome: a write-only field + refusal = a user with no credential AND no text to fix, because the screen never re-shows what they pasted. Storing and verifying are different questions, and tying them together made the second depend on the first having an answer right now. Applies to LLM and git alike — same table, same promise, and treating them differently would give two screens with distinct rules for the same object. The verdict has THREE states because the tester is a NO-OP on ollama/anthropic/openai: with a binary they'd return ok and the screen would claim a verification nobody ran (the ADR 0041 capability rule applied here). A bad key stops being an HTTP exception and becomes a 200 result, so the two @Catch that mapped it went away — a filter that can never fire is dead rule |
| 0058 | CSP locked down on the api, and project scope contained at the root. Revises item 7 of 0027, which had decided contentSecurityPolicy: false because "the api serves JSON and the CSP is the web's" — true, but not the point: the alternative to a GENERIC CSP isn't no header at all, it's a SPECIFIC CSP, and for something that only serves JSON the specific one is default-src 'none', which costs zero behavior. Two paths put the api's response outside the web's CSP reach: DIRECT navigation to a route (pasted link, redirect), rendered on the api's own origin, and frame-ancestors, which only applies to the framed document. Outside production the profile loosens only what the /docs Swagger UI needs, under the SAME condition that mounts Swagger — if it ever goes up in production, the CSP follows instead of silently blocking it. The options moved out of the main.ts literal into security-headers.ts for the same reason that already moved cors-origins.ts: at boot they weren't testable, and the proof has to be a real request, because false and a directives object are both "valid config" and only differ in the HTTP response. Along with it comes containing projectId: it arrives from the route parameter already percent-decoded by Express, so ..%2F..%2Fetc became ../../etc and escaped the root — hitting not just reading and WRITING permissions.json but also the scope that AUTHORIZES the terminal via 0055, i.e., the policy pointing at the wrong place. Validation lives where the root is derived, not in each caller, for the same reason the function exists: duplicated checking is checking that one day diverges. Also records the seven CodeQL dismissals with the reason for each — among them js/insufficient-password-hash over an OAuth state HMAC, where there's no password and a slow hash would be a mistake |
| 0059 | The key that signs the git OAuth state has no default. Born from one of 0058's dismissals: dismissing js/insufficient-password-hash was right — there's no password there, the "password" is GIT_OAUTH_STATE_SECRET, a server-side HMAC key, and HMAC-SHA256 is the right primitive. What the argument didn't consider is that a server key with a known default value signs nothing, and that was the case: both read points did process.env.GIT_OAUTH_STATE_SECRET ?? 'dev-oauth-state-secret-change-me'. The same failure mode WEB_ORIGIN had already had closed in cors-origins.ts, and worse for two reasons — the value is PUBLIC (it's in the .env.example of an open-source repo, not a weak default, a published secret), and what it protects is the state, the only obstacle to forging the callback of a route that needs to be public: with the key known, anyone can sign {projectId, userId, provider} of their choice and make the callback write, into that payload's project, the git token obtained from the provider. The detail that decides the shape of the fix is that docker-compose.prod.yml supplied the literal as a fallback, so on the REAL error path the variable WAS defined — requiring just "not empty" wouldn't have caught anything, and that's why the example literal is rejected explicitly, along with a 16-character floor that turns the configuration table's ever-present "weak = CSRF" into an actual check. The check runs at BOOT, not on first use: an api that only failed when someone tried to connect git would stand for weeks accepting a state signed with a public key. Resolved into a single function for the RN-092 reason — there were two copies of the same literal, and diverging them would make the callback reject every legitimate state. Deliberate breakage: production with no variable set stops booting, which is the goal and not a side effect. The four sibling secrets of the same compose (AUTH_JWT_SECRET, BRABO_SERVICE_TOKEN, CREDENTIALS_MASTER_KEY, SECRET_KEY_BASE) were left declaredly out, in the backlog — closed later by the same rule, with no new ADR, in RN-114 |
| 0084 | Social login (GitHub/GitLab), revising the backlog from ADRs 0031/0032 — the ban lifted only for this piece, by the product owner's explicit decision. SocialLoginCallbackUseCase ends by calling the SAME EmitirSessaoUseCase as password login — no second session format. The OAuth client is reused (GithubOauthClient/GitlabOauthClient gain buildLoginAuthorizeUrl/fetchIdentity), but the state is NOT: domain/auth/social-oauth-state.ts signs with the SAME GIT_OAUTH_STATE_SECRET key only because the payload is structurally incompatible — a purpose: 'social_login' discriminant checked BEFORE any other field is what keeps a state from git's CONNECTION flow from being accepted here (RN-273), which would be privilege escalation. New social_identities table (migration 0047), keyed by (provider, provider_user_id) — never email/login, which can change owner (RN-276). The callback decides in three steps: identity already known → log in; email matches an existing account AND is provider-verified → link, and while at it verify the account's email if it wasn't already (RN-279); email matches but NOT verified → refuse, account takeover (RN-274); no matching account → provision a NEW passwordless user, reusing the same "pending" state from the Keycloak migration (RN-278) — here the email doesn't need to be verified, because there's no account to protect (RN-275). Reuses the SAME OAuth app as the git connection flow — zero new environment variables —, changing only redirect_uri and scope (minimal, no repo/api) per request; the OPERATOR action of registering the second callback with the provider is what justifies the breaking/ branch. The access token never travels in the URL: session cookies are written in the callback, and the web boot already rebuilds the session on its own |
| 0096 | Real SMTP in MailSender, closing the backlog item open since 0032 ("real SMTP stays future config"). nodemailer over plain SMTP transport — a stateful LINE protocol with MIME/STARTTLS/AUTH, unlike the JSON HTTP APIs the rest of the product integrates — behind MAIL_TRANSPORT (log, default including in production, or smtp), selected by a useFactory in AuthUseCasesModule without touching any use case. SMTP_HOST/SMTP_USER/SMTP_PASSWORD/SMTP_FROM follow the RN-114 pattern in production when the mode is smtp, but WITHOUT the public default the four original RN-114 variables had — the bar only kicks in once the operator opts into smtp, validated in SmtpMailSender's constructor (exercised by the useFactory when the provider graph is assembled, same design as CREDENTIALS_MASTER_KEY), not by an eager call in main.ts. Plain-text body, never HTML — the port carries no structure for rich bodies. Investigation found a real gap before implementing: email_verification had no web route at all (verificarEmail existed in apps/web/src/lib/auth.ts with no caller) — /verificar-email (VerifyEmailPage.tsx) closes that, mirroring SetPasswordPage.tsx |
| 0127 | ResolveEffectiveRoleUseCase.forProject stays projectRole ?? workspaceRole — the project row OVERRIDES the workspace one in BOTH directions, and restricting someone on a sensitive project (workspace developer → project viewer) is a deliberate capability that "the higher of the two" would delete. What lands instead are TWO caps on the write path, refused with 403 and configurable nowhere: nobody downgrades a workspace owner (so a maintainer can no longer lock the owner out of their own project), and nobody downgrades THEMSELVES (undoing needs the very role being given up). The owner is read from workspace_members.role, never workspaces.created_by — created_by is a historical fact that appears in no authorization path, and a cap reading it would shield a creator who already transferred ownership while leaving the current owner exposed. Cap 2 is stated WITHOUT a threshold ("yourself", not "below maintainer"): the threshold version copies a number out of a controller's @RequireRole into the domain and ages silently if the route's minimum changes; the declared price is that an owner demoting themselves to maintainer — reversible — also falls. They live in the USE CASE with the rule in domain/iam/tetos-de-rebaixamento.ts, not in RolesGuard, which authorizes the CALLER against the route and sees neither body (dto.role) nor target; the FORM follows domain/actions/decide.ts (RN-154/RN-418) minus its outcome, since a synchronous human call has no approval queue to fall into. The three OpenAPI descriptions RN-471 declared false ("the higher of this one and what the person already has", "includes whoever inherits access from the workspace") are corrected here. Declared still possible: downgrading another maintainer, self-PROMOTION (unchanged, not opened here), the workspace-level POST which has no cap at all, and self-downgrade through REMOVAL — RemoveProjectMemberUseCase gained no cap, and the premise that removal is always benign is FALSE (it is, when the workspace role catches the fall; it is irreversible self-downgrade when it doesn't). Both removal cases are pinned in tests, the open one included. The web stays out on purpose and for a structural reason: the screen can compute cap 2 but NOT cap 1, since listProjectMembers returns the project-row role and the target's workspace role is out of the client's reach — half a gate would be the second source of role RN-471 warns against; meanwhile the refusal shows, via the mensagemDaApi toast #444 added |
| 0156 | The self-downgrade cap reaches the OTHER door: removing your own project_members row. ADR 0127 capped only the write path and left this movement out in writing, pinning it in a test whose name documented the hole — "removal STILL gets to downgrade whoever called it" — and predicting the cost of closing it (RemoveProjectMemberUseCase.execute's signature). This is that next person. Removal does not delete a role, it SWAPS the effective one: projectRole ?? workspaceRole (RN-471) starts resolving through the second term, so a maintainer whose authority came from the project row, viewer in the workspace, dropped to viewer with no way back — restoring is POST :projectId/members, which demands the very maintainer just abandoned; with no workspace role at all, the fall is to NO access. The rule is cap 2, not a new cap: ehAutoRebaixamento compares effective-today against effective-after and never needed to know which route the second arrived by, so remocaoEhAutoRebaixamento DELEGATES to it passing the workspace role as the requested one, reimplementing no comparison. It exists as its own function for the single case the other's signature cannot state — an actor with no workspace role, where "after" is nothing, which is not a Role. Cap 1 gets no counterpart, on purpose: owner is the top of ROLE_ORDER, so removing a workspace owner's project row can only RAISE their effective role — it is in fact the only way to undo the restriction cap 1 forbids creating, and a cap there would refuse only benign movements while making permanent the state ADR 0127 was born to eliminate. Stated without a threshold, like cap 2, and the declared price rides along: self-removal from project owner down to workspace maintainer is reversible and falls too — the one benign movement that used to pass and now refuses. The message is its OWN, because whoever clicked "remove" asked for no role change and deserves the mechanism, not the add phrase. Refuses: an owner cap on removal, a second comparison rule, touching forProject, extending to the workspace scope (whose POST still has no cap and whose member @Delete does not exist), blocking removal of OTHER people who drop, and a client-side gate — MembersSection.handleRemove already surfaces mensagemDaApi in a toast, and computing only the half the screen can compute is the half-gate ADR 0127 refused. Closes BRB-001 |
| 0157 | The self-movement cap reaches the WORKSPACE upsert, and self-PROMOTION — which ADR 0127 left standing in writing — becomes a breach and closes. AddWorkspaceMemberUseCase was a twelve-line passthrough that never received the actor, on a route requiring owner: the same class of defect as project_members, one scope up and worse, because here there is no level above to catch the fall and no member @Delete at all (measured; ADR 0156 had used the same fact to postpone this). An owner writing themselves viewer lost the whole workspace — every project, the LLM spend that is theirs by RN-060, and the very ability to invite whoever would restore them — with undoing being this same route, demanding the owner just abandoned. The cap does not count owners, by the literal criterion of ADR 0127 ("stated as a clause, it has no number to age"): a count adds a read that can fall out of step with the write, must judge whether a second owner who left the company still counts, and makes the same request with the same body pass today and refuse tomorrow. The stronger argument is that the clause already produces the invariant a count would exist to guarantee — a workspace never ends up with zero owners, because removing the last one would require that owner to do it, which is exactly the refused movement. Cap 1 gets no counterpart here, and it was considered rather than mirrored: unlike removal (ADR 0156), the movement DOES exist — an owner can demote another owner. It is refused because cap 1 is a rule about hierarchy inversion, and @RequireRole('owner') already makes inversion impossible in this scope: whoever can call is never below whoever they touch. Adding it, on top of the missing removal route, would make owner an absorbing state nobody leaves over HTTP — offboarding, ownership rotation and fixing a wrong invite would all need direct database writes, which is the class of state ADR 0127 was born to eliminate, rebuilt with the sign flipped. So demoting another owner STAYS, reversible through the same route by any remaining owner. Self-promotion closes on both association routes. ADR 0127 had recorded it as a capability that stayed ("the caps are about going down"); reopened here, the answer is that both halves are one movement — a person deciding alone what authority they hold — and the upward half is the only one that ESCALATES privilege, which is precisely what that ADR could claim its caps never did. Rewriting the SAME role still passes: an idempotent upsert is not a movement, and refusing it would break seed.ts. The comparison is not copied: it becomes one classifier, autoMovimentoDoProprioPapel, returning the SENSE rather than a boolean, because the caller needs it to pick the message. ehAutoRebaixamento survives as a reading of that classifier — on purpose, not for compatibility: remocaoEhAutoRebaixamento delegates to it, and widening it would make REMOVAL refuse self-promotion too, breaking the benign movement ADR 0156 protected by name. Four messages, one per sense and per scope — nobody who tried to promote themselves reads the downgrade phrase, and nobody capped in the workspace is told to ask a maintainer. Refuses: counting owners, an owner cap in this scope, creating a workspace member @Delete (a new door would need this same cap plus the last-owner judgement), widening the removal door, touching forProject for the third ADR running, and a client gate — there is none to build, since no apps/web caller of this route exists. Declared cost: a legitimate maintainer who needs owner now depends on someone else. Closes BRB-002 |
| 0158 | The envelope starts saying WHICH master key wrapped it, and that label never decides whether it opens. The runbook's own page states the hole ("The wrapped_dek stored in the database doesn't identify which key wrapped it"), and two defects follow: the middle step of rotation has no progress — whoever interrupts it cannot tell where it stopped, and "is anything still on the old key?" has no SQL answer, only re-running the script for its printed counter — and a wrong key and a corrupt blob land in the same catch, two diagnoses with opposite actions wearing one generic failure. So both envelope tables gain a nullable key_id, always together, since they share the envelope and the script and changing one would leave an estate where the query answers half. The value is the key's fingerprint, HMAC-SHA256(derived key, label) truncated — which answers the open question the activity left ("not a hash of the key, that gives whoever reads the database an offline verification oracle") by measuring rather than assuming: the envelope is AES-256-GCM, which AUTHENTICATES, so testing a candidate passphrase against wrapped_dek was ALREADY a perfect oracle costing one scrypt; testing it against the fingerprint costs the same scrypt. No oracle is created, and no variable is added either — the operator types the fingerprint nowhere, it is a pure function of the key already published, which is what makes the old "one more metadatum to keep in sync" objection true of an arbitrary id and false of a fingerprint. The label is observability, never authority, and that is where this decision departs from the obvious one: decrypt stays byte for byte, and rewrap still decides "already on the current key" by ATTEMPTING. Using the label to skip the attempt was considered and refused for a failure mode of its own — a row labelled "current" whose envelope sits on the old key would be silently skipped, stay out of the re-wrapped count, and step 3 would make it unreadable forever, turning a metadata inconsistency into secret loss. So the label serves three things outside the crypto decision: the progress query (IS DISTINCT FROM, never <>, so NULL counts as pending), the diagnosis when NEITHER key opens, and the stamp rewrap writes beside the new envelope. No back-fill: key_id is born on the next write, existing rows stay NULL meaning "written before this column" and never "on the current key" — with the declared cost that on an existing installation the query only starts meaning something after the first rotation. The api prints the current fingerprint at boot, because a column nobody can compare against anything is useless. Refuses: KMS/HSM/per-tenant keys (ADR 0027 stands), an index on key_id, and moving the diagnosis into the hot read path |
| 0173 | Workspace member removal is born, capped by the SAME clause. DELETE /workspaces/:workspaceId/members/:userId (owner, 204) ends offboarding by database write, which ADR 0157 refused to open in passing. Cap 2 is inherited with no new rule — remocaoEhAutoRebaixamento with the workspace role as today's effective role and none as the role after, since nothing above catches the fall — so removing YOURSELF is always 403, and that same clause protects the last owner without counting: only an owner calls the route and nobody removes themselves, so every removal leaves at least the caller. Removing ANOTHER owner is allowed; cap 1 still has no counterpart. The removal cascades in one transaction (the target's project_members rows in the workspace, their project device keys and PATs there) and then drops their live runner per project through the RN-520 path. Declared open: workspaces.created_by does not change, so removing the creator leaves agents spending the creator's LLM key (RN-058). |
LLM providers, models and spend
The provider contract, the model catalog and curation, model bindings, metering and budgets.
| # | decision |
|---|---|
| 0040 | The dashboard card and the summary line show only USD (the prices' currency of origin, with no conversion involved) — a divergence isolated to this surface, TokenMeter default/live remain in R$+US$; per-workspace currency preference with a manual rate is recorded and deferred |
| 0041 | OpenAI-compatible base over node:http (not the SDK, to get an INACTIVITY timeout), error normalized with a mandatory code in the chunk, capabilities in two layers with agent binding refused without native tool calling, and a contract suite where the contract owns the assertions and the harness owns the dialect |
| 0042 | Catalog sync by capability, with a discovered model entering disabled and a vanished model marked instead of deleted; two independent availability axes (owner curation × remote reality); a cascade that revalidates capability when it drops a level; and price frozen in token_usage so old cost stays reproducible, with append-only auditing outside the outbox |
| 0043 | The six 9b providers (NVIDIA NIM, Together, DeepInfra, Bitdeer, Vultr, OpenRouter) enter as config over the base, each investigated from scratch against the official docs — capability declared only when proven, with two real cases of a decision reverted live during implementation; the only new hook in the base is the parseErrorFrame that only OpenRouter (the hub) needed |
| 0049 | Model curation per workspace: models.is_active was a column for the WHOLE installation — a workspace A owner turning on a model turned it on for B too, with the spend landing in the budget of whoever didn't decide. The catalog stays global because name, price and capabilities are facts about the provider, and duplicating it per workspace would create N truths about the same model besides splitting token_usage.model_id; only the decision migrates, to workspace_models. Absence of a row IS the off state — there's no third state, and that's how RN-043 survives with no column the sync could ever run over; turning off is an UPDATE, not a DELETE, so as not to erase who decided together. The picker's route hangs off the PROJECT (the three screens that consume it already had projectId, none had workspace), and isActive leaves the Model entity: whoever needs it uses a type that only exists with a workspace in hand |
| 0051 | Capability facets read from the provider, and "what it's for" as workspace curation. Sync never queried the remote for modality (supportsVision: local?.supportsVision ?? false), so the column was born false and died false — the 338 models from OpenRouter's first real sync all came out with no vision, including the 181 the provider declares multimodal. Image input and output become DISTINCT axes: reading a diagram and drawing one solve different problems. The other half of the request — "best for code/documentation" — doesn't exist in any catalog, and deriving it from the model's name would be a guess dressed up as data (ADR 0041); so it's the operator's opinion and lives in workspace_models, like all curation since ADR 0049. The two axes don't mix: marking a use doesn't turn the model on (the is_active column has DEFAULT true, and the new row is born with false explicit), and the screen never writes "doesn't read images", because false there means "the provider didn't declare it". Video was left out: zero models across nine providers, a filter that would never match anything |
| 0063 | Two audiences for the same spend: the owner's bill and the member's consumption. The request was a spend summary tab in the spirit of OpenRouter's activity, and the data was never the problem — token_usage has provider, model, actor, tokens and price FROZEN since 0042; what was missing was the AGGREGATIONS (there was none by model, by project within the workspace, by session or by person). The difficulty is the collision between two existing rules: by RN-058 the key the agent spends is the OWNER's, and by RN-060 that key's bill is his and only his — so "how much did I spend" is, literally, asking for a slice of someone else's bill. The two easy outs are both wrong: opening the credential report filtered revokes RN-060 through the back door (the answer would still talk about provider and key, and summing the lines reconstructs the bill), and showing nothing leaves the one person forbidden from seeing the consumption being exactly the one who is it. The decision separates the QUESTIONS: the owner's is by CREDENTIAL (provider, key, bill) and still requires owner, reused whole instead of rewritten; the member's is by ACTOR, in tokens and estimated cost, WITHOUT breaking down by provider or credential. The actor comes from the authenticated token and there's no parameter to write someone else's id into — "a member doesn't see another actor's line" becomes a property of the signature, not a check someone might forget; proven by test, and the mutation that removes the filter kills it. The provider axis DOESN'T EXIST in the new aggregation (sumGroupedBy has model/project/actor/session/day and nothing else): the absence is what keeps the member's view from getting it by accident, and for the same reason two providers serving a model with the same name fall into a single line. Agent doesn't enter the member's account because token_usage records who spent, not who ordered the spend. No charting library — two shapes, one series each, an inline <rect> handles it —, and the daily series comes DENSE from the api, otherwise three calls across three weeks turn into three sticking-together bars. No migration: measured at 525 thousand rows, the queries come out at 55 ms and 38 ms per seq scan, and an index on created_at would bring them to 32 ms and 19 ms — the gain is recorded and the index comes later |
| 0064 | Area scope in the model cascade, and the global agent binding. The request: area leads and their subgroup sharing a current model, with the agent free to diverge — an inheritable pattern, a decision the user had already made. The cascade (session > agent > project > workspace) gained area between agent and project, and answering WHERE it goes surfaced an inconsistency that needed a decision before coding: the agent binding was GLOBAL (scope_id = plain slug, the route's :projectId deliberately IGNORED since Phase 9a) and area is PER PROJECT since ADR 0053 — a per-project scope ABOVE a global scope would make the same agent resolve different models only where an area existed, behavior dependent on a data accident. Two mutually exclusive outs: make the agent binding per-project (changes existing behavior), or put area BELOW agent (contradicts "inheritable pattern", because almost every binding today IS of an agent). The first was chosen: agent and area's scope_id became COMPOSITE — ` |
| 0075 | Embeddings in the LLMProvider contract. The Chat RAG that's coming needs vectors, and the product didn't know how to make them: the word "embedding" appeared in ONE comment, in bitdeer-provider.ts's prose. The operation enters as embed?, optional under the same two-sided contract as listModels (whoever declares the capability implements it; whoever doesn't declare doesn't expose it), in BATCHES because an index receives N chunks at once — and from that comes the most important guarantee: one vector per input or an error, never a shorter list, because order is the only link between input and vector and a partial response is undetectable afterward. The error THROWS normalized by code instead of turning into a chunk, with 0041's taxonomy untouched: the chunk's reason is preserving spend from a turn in progress, and there's nothing to preserve here. The return states dimensions checked against what CAME BACK (not copied from the catalog — a vector index has a fixed dimension), the model the provider said it used (an alias resolves to a dated version, and that's what goes to metering for the frozen-price reason, RN-044) and inputTokens/estimated with RN-041's distinction. The capability is in TWO layers as 0041 required, but with a difference that had to be named: tool calling is a gradient (a model with no tool still converses) and embedding is EXCLUSION — nomic-embed-text doesn't answer questions and llama3.2 doesn't return vectors, two disjoint sets, and that's why the question didn't fit any existing supports_* column. Provider: capabilities.embeddings, MANDATORY by the same argument that made code mandatory. Model: supportsEmbeddings/embeddingDimensions in the catalog, optional because absence means "the provider didn't say" — Ollama is the only one of the nine that publishes this per model (capabilities: ["embedding"] in /api/tags). Only ollama declares true, proven by EXECUTION against the real 0.32.1 daemon (2 inputs → 2 vectors of 768, prompt_eval_count: 10), an execution that also produced the test for the 501 a chat model returns. The other eight stay false: seven for lack of proof (no key in the environment, and the only paid smoke test that has ever run was for CHAT — on a hub, embedding routes to a different provider), and Anthropic for lacking the operation (their docs say to use a third party). The base's dialect is proven separately, with the contract suite running a second time against it with the capability turned on, and that's what makes it cheap to turn a provider on once the key exists. Two declared cuts: embedding metering doesn't exist yet because token_usage.session_id is NOT NULL and indexing a repository doesn't happen inside a session, and the models.supports_embeddings column waited for another wave's migration slot |
| 0076 | provider becomes a spend dimension again, and containment shifts to the TYPE. Revises 0063, which had excluded the axis with a no-middle-ground sentence ("the absence is structural… there's no argument to pass") — the argument is still right, and that's exactly why the reopening, decided by the product owner, comes with the containment written in. What 0063 didn't separate and usage asked for: the owner couldn't see, WITHIN the sliding window, where the money went (credential-spend answers by provider, but in calendar months and tied to the key that exists today), and person and agent showed up in the same ranking. sumGroupedBy gains provider, the owner's report gains porProvider (RN-186), and the member gains no field at all. Containment changes FORM, not strength: there are two overloads, and the scope that carries actor — the only one the member's view has — only accepts SpendDimensionDoAtor, which is Exclude<SpendDimension, 'provider'>; asking for provider with actor DOESN'T COMPILE (RN-187). No if on the combination, on purpose, for the same reason auto mode's resolution lives in the repository and not in decide.ts (RN-153/154): a dynamic check suggests a dynamic guarantee, and that's what the next refactor removes without leaving a red test. Exclude instead of a second hand-written list, because a parallel restricted list ages silently. Person and agent become porOwner/porAgente, a PARTITION of porAtor in the use case rather than two queries (RN-188) — 0063 measured that the cost grows with the size of token_usage, not with the request's. The index on token_usage(created_at) that 0063 measured and deferred enters here (migration 0044): 55 ms → 32 ms and 38 ms → 19 ms at 525 thousand rows. Consequence declared without softening: the guarantee went from "impossible to express" to "impossible to compile", which is weaker — what holds the price down are two independent barriers, and the other is the member's route having no dimension parameter at all, with @ts-expect-error covering the first (if the barrier falls, tsc fails on the UNUSED directive, instead of the test passing while testing nothing). The SCREEN is left out, declared |
| 0077 | Model ranking by capability, with no invented score. The handoff asked for a "best models by capability" table with a SCORE per capability (code 9.4, image 9.1…) and an ideal badge on ModelPicker "when the model covers all capabilities the agent requires". The investigation found both requests need the same data the product doesn't have: the score is FICTIONAL, from the mock (no provider publishes "code quality", no product metric measures that), and "capabilities required per agent" doesn't exist in the domain — ModelsSection itself had already renamed the "Agent · capabilities" column to just "Agent" for that reason, and promising it again would reopen a gap that had already been refused. A second, structural barrier closes off the badge on its own: the agent-binding ModelPicker reads GET /projects/:id/models (role viewer), and curation (uses, ADR 0051) only exists in ModelComCuradoria, from a maintainer-only route — linking the two would widen RBAC, a separate product decision. The badge is NOT built (documented in ModelPicker.tsx itself); the ranking table IS built, with two REAL signals: "recommended"/"alternative" come from the curation (uses) the workspace already marked, ordered by how many agents IN THIS PROJECT already resolve to that model via the cascade (the most honest signal without inventing a score), cost as a tiebreaker; a capability with no curated coverage shows the gap, never hides the row |
| 0110 | Closes the "budget per area" backlog item open since Phase 8. agent_areas gains budget_micros/spent_micros, mirroring max_parallel on the SAME row — no new table, and not the generic budgets table (its CHECK enforces exactly one of project/session, built for a two-way exclusion, not a third scope). It's a THIRD ceiling ADDITIVE to project/session budgets, checked and incremented independently by CheckBudgetGateUseCase/RecordLlmUsageUseCase — explicitly NOT the ADR 0064 model-binding cascade, which the two mechanisms share only a name with. spent_micros accumulates whenever the actor belongs to an area (resolved by the pure areaDo, no DB hit for an actor with none), with or without a cap set; SetAreaBudgetUseCase requires maintainer and accepts null to clear the cap |
| 0114 | Native Ollama detection in pnpm dev's bootstrap: preflight.mjs confirms by HTTP that whatever holds OLLAMA_PORT (default 11434, the same default a native install uses) really is an Ollama daemon, asks ONCE whether to reuse it (defaulting to yes with no TTY, since the menu runs commands with stdin from /dev/null on purpose), and persists the answer to .env (OLLAMA_MODE=host|container) via escreverEnv — the first thing in the repo that WRITES .env in place rather than only reading it. ollama/ollama-model-loader move behind profiles: ["local-llm"], toggled per command by scripts/dev/perfil-ollama.sh; a profiled service invisible to docker compose config by default forced detectarOllamaNativo to grow its own conflict-reporting path (the generic per-port loop would otherwise stop seeing a genuine non-Ollama occupant) and forced api's depends_on: ollama to be removed (Compose refuses the whole file when a service outside an inactive profile depends on one inside it). New "Docker › Reconfigurar Ollama" menu item forgets the decision, on demand |
| 0115 | Hugging Face model pull gets its OWN small table, huggingface_model_pull_requests, never proposed_actions — traced and rejected because that pipeline's session_id NOT NULL, its decide()/agent_autonomy policy resolution, and its closed ActionType vocabulary are all built around an AGENT being supervised by a human, and here the actor already IS the human, gated by role on the route, with no session and no agent in play. The two-step confirmation IS the pending_confirmation → confirmed transition, not an extra flag — ConfirmModelPullUseCase refuses (409) to run it twice, and only a successful confirm calls OllamaProvider.pullModel SYNCHRONOUSLY inside the request (the api has no background-job runner of its own; GET .../pull-requests/:id exists for callers unwilling to hold that connection open). repoId travels in the request body, not the route, because the Hub's <publisher>/<model> shape breaks single-segment path matching (same fix already used for file paths in code.controller.ts). A hand-curated, case-sensitive allowlist of official Hub publishers (ADR 0042's "manual curation always," applied to a new badge) gates the default search results; community results require an explicit, off-by-default opt-in paired with a persistent security warning |
| 0166 | The hub's routing preference lives in the model binding, travels with it, and is frozen in metering. Measured on a v6.1.0 install (AT-090): 23 of 24 calls of one model through OpenRouter were served by an upstream roughly four times slower, because the request body never said how to choose (provider.sort). Decided by the maintainer: model_bindings.routing_preference (`price |
| 0188 | The REAL cost the provider returns becomes the metering number (AT-270, owner decision 30/09): when the response carries it (OpenRouter's usage.cost), it is token_usage.cost_micros, what the turn returns to the engine, what the budgets add and what the Spend tab shows; the frozen catalog price (ADR 0042) stays the number only where the response says nothing. The row's price becomes IMPLICIT (cost ÷ tokens, flagged price_implicit, the same column and function as ADR 0179's Jev), what the catalog would have charged goes alongside in catalog_cost_micros, and the resolved model and gen-… id are recorded. estimated keeps meaning the TOKENS; BYOK falls back to the catalog. The same round records the PARTS the dialect reports — cached_input_tokens and reasoning_tokens (AT-272), never added to the totals, null meaning "didn't say" — and medir-execucao.ts reports how much of the input was cache read |
| 0198 | The SEMANTIC duplicate of a story or business rule is WARNED by embedding with a threshold, never refused (AT-171, owner decision 01/10): after the item is written, its title is compared with the project's titles of the same kind through the RAG's RagEmbeddingService, and a cosine at or above 0.80 — a STARTING POINT, not calibrated, because the environment could not record real vectors — returns a warning to the agent in the tool result and backlog.semantic_duplicate_warned in the log. Without an embedding provider the check is SKIPPED and says why. Its spend is metered as its own token_usage row (actor system/duplicata-semantica) — ONE exception to ADR 0075's cut, which still holds for indexing and search. The exact duplicate is still refused (RN-080/081) |
RAG, knowledge graph and artifacts
Chunks and hybrid search, its measurement, the Neo4j graph and the artifacts projected from the event log.
| # | decision |
|---|---|
| 0079 | chunks table for the Chat RAG: vector(768) and tsvector on the SAME row (not two tables with a JOIN), an HNSW index (vector_cosine_ops, not IVFFlat — the table starts empty) and GIN, three honest scopes (docs/adr/session) mutually exclusive via CHECK. Migration 0045 creates CREATE EXTENSION IF NOT EXISTS vector itself, because docker/postgres/init.sql only runs on the volume's first initialization — and that's why it's born on a breaking/ branch: creating the extension may require a privilege the application's role doesn't have in production |
| 0080 | Hybrid search: weights, threshold and what counts as a citation. docs/adr are indexed from the project's OWN git repository (via ReadProjectCodeUseCase, the same credential/gate/path check as the Code tab); session indexes only chat.message/agent.response (the rest of the event log is mechanism, not citable knowledge). Chunking by paragraph/heading, 1200 characters with 150 of overlap — a documented starting point, not calibrated against real data. When the embedding provider (fixed: ollama/nomic-embed-text, same dimension as RN-222) doesn't respond, the pipeline saves the chunks WITHOUT a vector instead of failing outright — search_vector is GENERATED ALWAYS AS and depends on no provider at all — and declares the gap in the report, never faking full indexing. Hybrid search is TWO independent queries (searchByVector/searchByLexicalQuery, each on the index made for it), merged by a weighted sum (0.6 vector, 0.4 lexical — normalized ts_rank and cosine scales aren't comparable by nature) and cut at a threshold (0.2); weights and threshold are an adjustable starting point, not science. A citation is chunkId/content/separate scores (null, not zero, when a signal found nothing)/origin as a union discriminated by kind (file with sourcePath/headingPath, or session with sessionId/eventId). Reindexing is always MANUAL (POST .../rag/reindex, an idempotent full rebuild) — no watcher on push, no watcher on session close |
| 0082 | Chat RAG becomes its OWN tab (key: 'rag'), without renaming sessions. 0080 left the pipeline and the three routes ready; 0078/RN-202 had deferred the "Chat RAG" label because the capability didn't exist — now it does, and the question was only WHERE it lives. sessions is conversation with an ACTIVATED agent (spends the owner's key per turn, RN-058); RAG is search over an already-built index, with no agent in the middle and read-only by nature — merging the two would force the same thread to express structurally different things, so the answer was a new tab (ordem: 28, between Code and Backlog) instead of renaming the existing one. The screen consumes the three 0080 contracts with no guessed shape (searchRag/getRagCoverage/reindexRag, types mirrored 1:1) and shows the two honest degradations the backend already declared and no screen was reading yet: vectorAvailable: false becomes a visible warning (RN-252) and coverage never invents "reindexed Xmin ago" — the response carries no such data. Citation from a session origin navigates to the exact EVENT reusing useNavigate + highlightEvent, the SAME mechanism the Psychologist's evidence chips already use (RN-253); a file origin shows path/heading as text, no link — the Code tab has no deep-link by path today, and building one is out of scope for this piece. The reindex button only shows for owner/maintainer on the client, mirroring the maintainer rule the route already enforces (RN-238/RN-254) — the api is still the one that guarantees it |
| 0099 | Neo4j comes in as a KNOWLEDGE graph — memory DERIVED from the event log, never a source of truth — for versioned prompt templates (PromptTemplate/PromptVersion) and relational memory (interactions, Psychologist hypotheses with evidence, Anamnesis profiles, handoffs). pgvector STAYS the chunks' vector index — the graph stores no embedding. The neo4j-driver lives in the API (not the engine): the API already owns all persistence and the existing RAG, the engine consumes it over internal HTTP with a service token as always. GraphStore.onModuleInit never throws — the three env vars are mandatory in production and optional outside it, unavailability becomes a GraphUnavailableError/503, never a raw 500. A pattern explicitly inspired by the ErickWendel/neo4j-ai-experiments repository (graph as agent memory + prompts as files separate from code) |
| 0100 | New rag_search tool for the engine's agents — closes the biggest gap in the existing RAG (pgvector, hybrid search): no agent had ever queried it, only the "Chat RAG" web tab. Calls POST /internal/rag/search (reuses HybridSearchUseCase with no duplicated logic), formats hits with citation (path + snippet), degradation (degraded: true) always visible at the START of the text. Its own caps (RN-150): top_k clamped to 10, a 16 KiB byte cap. A new one-shot ollama-model-loader service (dev and prod) closes a separate real bug: nomic-embed-text was never pulled automatically, and RAG silently degraded to lexical-only in any clean environment |
| 0101 | Consuming the graph foundation: InstructionFiles gains a :graph source (precedence db > graph > dir > root, reusing the existing ETS cache), with no change to any GenServer's contract — ux-designer, Psychologist and Anamnesis now TRY resolving a template from the graph before the inline text, which becomes the fallback. Two separate flags (graph_templates_enabled?, graph_instruction_templates_enabled?) on purpose — a single key would clash with OPPOSITE defaults between parallel workstreams. Psychologist/Anamnesis compose "recent"/"time window" with RELEVANT snippets via rag_search (never a replacement, always additive — with no hit, behavior is identical to before). The graph writes itself: GraphProjector drains a SECOND outbox lane (aggregateType: 'graph_projection', never matched by the engine's filter) and calls the already-existing write use cases — the engine NEVER writes to the graph directly, preserving the event log as the single source of truth |
| 0113 | Attach a local folder from the user's OWN machine as read-only reference material — WITHOUT the brabo-runner CLI, explicitly distinct from execution_mode: runner (ADR 0104), which needs a real host path. The browser reads file CONTENTS (File.text()) and RELATIVE paths (File.webkitRelativePath) — never an absolute host path, because the File API doesn't expose one to any site; there is no path to route through the Runner (ADR 0107) or validate like ADR 0072's guard, because there is no path at all. chunks.scope gains a fourth value, 'local' (migration 0052, additive ALTER TYPE, zero new CHECK — the existing constraints already split on "is it session or not"), reusing the whole RAG pipeline (chunking, embedding, hybrid search, citation) instead of a parallel mechanism. One-shot upload: aggregate caps (file count, summed bytes) REJECT (400) the whole batch instead of truncating silently, an individual oversized/unrecognized file is only skipped; re-uploading is the ONLY resync mechanism, deliberately never wired into the generic "Reindex now" (which re-reads from a source the server can revisit — this scope has none) |
| 0129 | The RAG hybrid search finally leaves a trail — but as TABLES (rag_searches/rag_feedback), not session events: session_events.session_id is NOT NULL and a search from the RAG tab is a project search with no session, so the event log would silently drop exactly the searches a human judged (same class of problem that cut embedding metering in ADR 0075). The weights are FROZEN on each row, like the frozen price in metering (ADR 0042), so a later calibration cannot silently rewrite what every earlier measurement meant. rag.search/rag.feedback exist only when there IS a session, and are narration, never the source of the measurement. Recording telemetry never brings the search down — and never fails silently either: origin infra in the log and an explicit searchId: null. Nothing is calibrated here; this step only instruments. |
| 0132 | The RAG's turn for a golden-set: same shape as the QA Automation agent's (ADR 0123) — a permanently-excluded ExUnit tag (:golden_set_rag), a ratchet floor keyed by MODEL, a seed script called through System.cmd — copied on purpose rather than re-derived, with the one adaptation the domain forces: RAG's hybrid search runs entirely inside the api (HybridSearchUseCase), with no LLM chat judgment anywhere in the path, so seed-golden-set-rag.ts both provisions AND runs the search itself, and the Elixir side only invokes it and applies the floor — reimplementing the search in Elixir would test a second implementation, never the real one. The 17 questions are COMPOSED from real RNs/ADRs, not extracted — checked first: gh issue list on this solo-dev repo returns "No Issues", so there is no genuine question corpus to mine, and that is stated rather than faked. The corpus indexed is real, though: 22 curated files from this repository's own docs/, copied verbatim and indexed by the same IndexProjectDocsUseCase any real project uses — curated, not the full 130+ ADR tree, for embedding cost in a manual run. The hit criterion — file path, never the exact chunk, inside the top 5, never rank 1 — is a pure, tested function (golden-set-criterio.ts), because chunking is exactly the parameter this whole program exists to be free to revise, and the Chat RAG cites several chunks per answer, not just the first. New gate rag-acertivo in docs/gates.yml, warn (there is no CI running a real LLM), evidence tipo: teste — what proves this gate is the golden-set itself, not an event. Measured for real, twice, deterministically: 17/17 questions found their file inside the top 5, in both runs, against a real Ollama nomic-embed-text and an isolated Postgres |
| 0138 | Stage 3 of the RAG-measurable program: the RAG golden-set (ADR 0132) starts running for real in CI, on a new .github/workflows/golden-set-rag.yml — but SCHEDULED (nightly cron + workflow_dispatch), never on pull_request, a cadence the product owner chose given the ~3-6min a run costs (pulling nomic-embed-text plus indexing and 17 searches). rag-acertivo's severidade STAYS warn — deliberately, now for a cadence reason instead of a technical one, since a scheduled job blocks no merge. What makes this tractable where the QA golden-set (ADR 0123) still isn't: this one only calls the EMBEDDING model (CPU, deterministic, no sampling), never a chat model doing judgment — ubuntu-latest with no GPU is enough. A finding that simplifies the whole job: seed-golden-set-rag.ts never sets NODE_ENV, so the four secrets the api would refuse in production (RN-114) take their dev-literal fallback without complaint — unlike docker/smoke.sh, nothing here needs openssl rand. The Ollama service's health-check runs ollama list, not curl (the image guarantees the former, not the latter), reusing the exact wait pattern docker/ollama/pull-models.sh already validated with a real download. The QA golden-set gets zero changes from this ADR — still entirely manual, still a declared TODO(humano) |
| 0148 | Every artifact the agents produce — the Creative's brief, the PO's business rules, the Architect's C4 diagram and module routing, the six conversational agents' decisions — lives EXCLUSIVELY in the event log; emit_artifact.ex says it outright, "there is no artifacts table". That is the right decision for the SOURCE and is not touched here. The problem is that this memory reaches nowhere a person opens: it is readable one line at a time inside the product's session timeline, and whoever opens the project folder in an editor — where the code will be — finds none of the reasoning that produced it. Deferring git (the deferred provisioning (own PR)) widened the hole: between creation and the Architect's handoff the project has no repository at all, so everything the product accumulated sits in database rows while the user's folder is empty. So docs/ becomes a PROJECTION of the event log in the strict sense ADR 0101 gave the graph — the source stays the event, the folder can be deleted whole and rebuilt, and NOTHING in the product reads from it to decide anything; it exists to be opened by a person, and everything else follows from that. The mechanism copies GraphProjector piece by piece (own poller with .unref(), public drainOnce() for tests, draining flag, batch of 50, markProcessed only after success) — not code reuse but DESIGN reuse, since the two write to destinations with nothing in common, and copying the shape is what lets a reader who knows one understand the other without re-reading. Its own aggregate_type (artifact_projection) for the reason the graph's already documents: Engine.Outbox.Drain only drains session/task/container and marks the 'session' row every ~2s, so sharing it would be racing the engine and losing almost always — with one deliberate difference, aggregateId is the PROJECT rather than the session, sparing the projector one query per artifact for something the use case already held. An ALLOW list, not a deny list, so a new artifact type is born outside the projection rather than appearing unannounced in a folder the user reads; qa_verdict, secops_verdict, task_blocked and infra_delegation_files stay out as operational OUTCOMES rather than product documents — one gate verdict per task would fill docs/qa-lead/ with files nobody opens and cost the folder the very property that makes it useful, fitting in one glance. Layout is docs/<agent>/, so browsing answers "what did the Architect produce?" without opening anything; the four VERSIONED types overwrite one file (the folder shows the CURRENT one, history stays whole in the event log — projecting them per version would produce c4-diagram-v1.md, c4-diagram-v2.md and make the reader work out which one counts, exactly what reduction by version exists to spare), while append-only types each get their own file carrying the event's seq, because without it two artifacts of the same type and title would collide and the second would erase the first — the only way this projection could lose information the source has. Two barriers stand between an LLM-written title and the filesystem, and the second is not redundant: slugDeArquivo DERIVES a name from a closed alphabet (no return path lets a separator survive, and empty yields null since a name derived from nothing collides with every other), and FsArtifactFileStore checks again with relative() before opening — the two answer different questions, "what name do I derive" and "what am I about to open", and trusting only the first would make this write's safety depend on no future slug change ever letting a separator through, precisely the containment ADR 0130 refuses on principle. In runner mode the folder diverts to the managed root exactly as permissions.json does and for the same physical reason (the api writes from inside its container, a runner project is deliberately without bind-mount, and the runner channel carries COMMANDS with no primitive for writing file content) — declared cost: there, docs/ does not sit beside the code. And the write does NOT go through proposed_action, which is not an omission: that pipeline is the origin of every external effect of an AGENT ASKING TO ACT, and here no agent asks for anything — the artifact was already emitted and is already in the event log; routing this through approval would fill the queue with one routine decision per artifact and corrode the very ceiling that gives the click its meaning on the actions that really are external effects |
| 0196 | The Anamnesis is TURNED BACK ON with fixes (AT-277, owner decision 01/10). A round does not run without an eligible SUBJECT — an EFFECTIVE project member (projectRole ?? workspaceRole, RN-471; project creation writes no project_members row, which is why every round of the 29/09 real use paid the model to conclude "no eligible member"), outside the opt-out, with their OWN interaction in the window: no LLM call, no RAG search, the reason named in the engine log, and anamnese.run_skipped only for the round asked by hand. A Psychologist hypothesis ACCEPTED by the person the session belongs to becomes a PROFILE FACT in the graph — translated from psychologist.hypothesis_accepted by the same GraphEventTranslator, so grafo:reprojetar rebuilds it — and reaches the agents that talk to that person: the api reads it through QueryUserContextUseCase (project-scoped, 5 most recent, total alongside) and the engine appends it as an ephemeral system message on the author's turn, through the same path as the response language. A third party's acceptance and a dismissal stay only recorded. ANAMNESE_ENABLED defaults to true again; the Psychologist stays paused |
| 0200 | The contract between modules becomes a versioned ARTIFACT of the Architect, and dev agents read it instead of another module's worktree (AT-276, owner decision 01/10). artifact.module_contracts, no table, the current version replaces the previous one: per module, only what it EXPOSES (funcao/rota/evento/dado + signature, 1–40 items); what it consumes is derived from the current module_map's dependsOn on read. A SEPARATE artifact, never a field of the module_map. The Architect gets declare_module_contracts; dev agents get listar_contratos_de_modulos (no parameter, project-scoped, own module and its dependencies in full, 120-item cap) and their kickoff says to read interfaces there; with no contract, report_blocked naming what is missing |
| 0205 | What the Anamnesis sees as an AGENT deviation — a loop, a turn cancelled by the user, a user message repairing an agent failure — becomes a PRODUCT improvement signal (AT-356 part 2, owner decision 02/10): the durable anamnese.agent_deviation event, written by the new report_agent_deviation tool, separate from the user profile and WITHOUT personal data (only agent, closed-vocabulary deviation type and eventId evidence; no free text). Not projected into the user graph; read from the event log, no new screen |
Web UI and design system
Screens, navigation, themes and the structure of the web app's largest files.
| # | decision |
|---|---|
| 0036 | Auth screens faithful to the approved mock: the design system's three fonts self-hosted (the CSP blocked the CDN and typography fell back to a system font in production), the tag version baked into the artifact, contrast calculated from the tokens instead of measured by axe, and the six deliberate divergences from the mock |
| 0060 | The Code tab's code-reading surface, contained and budgeted. 26a put listTree and getPullRequestDiff into the contract and installed the check that fails an operation with no consumer; this ADR is the consumer, and deciding to write it forced resolving three things no prior route had faced, because none received a FILE PATH from the client. Where the search lives: outside the contract. None of the three providers has it — GitHub and GitLab have platform-level code search with their own semantics, and LocalGitProvider is a bare repo —, so declaring it would either be a 13th operation with false capability on local (a tab that vanishes on one provider) or leaking a platform's vocabulary into the normalized contract that exists precisely to prevent that; it stays composed at the application level, over listTree + getFileContent. How much it can spend: three budgets (directories walked, files opened, matches returned) plus a short-TTL cache, because it's the only read whose cost grows with the repository's SIZE and not with the request's, and who pays is the OWNER's credential (RN-058/RN-082) — with no cap it's the same failure family as the dashboard's 3,824 req/min, with 429 turning into a blank screen. Breadth-first scan: cut off midway, it delivers the shallow files, which is where one usually looks. How the path is contained: a single function, in the RN-092 file, reusing the scope primitives from 0055 — and returning the NORMALIZED path, because returning the original would allow checking one string and sending another. The vector isn't reading the wrong file: on a remote provider the path becomes a URL segment of the API and a .. SWITCHES ENDPOINT with the owner's token in hand. CodeQL will keep flagging it, and the price is the one already accepted in the POST-PHASE 15 — duplicating the check would silence the alert and create four copies that one day diverge. The four routes are GET/role:viewer and the controller has NO write verb: reading isn't an external effect and doesn't become a proposed_action (it would flood the queue with noise), and when writing arrives it's born as one — the absence of a verb is what makes this boundary verifiable instead of an intention |
| 0074 | Reachable theme, and the boot under CSP. The light theme existed in design/tokens.css, was measured by a parity test and referenced in design/README.md — and nothing in apps/web wrote data-theme: the only way to see it was typing the attribute into DevTools. Both contrast tests knew this and were right while it lasted (one asserted via expect that three light-theme pairs FAIL, "a record of what's inherited, not a guarantee of what's rendered"); with the theme button coming, measuring only the primary theme would start hiding half the visible surface. The boot is a FILE (public/theme-boot.js), not an inline script: the image is served under script-src 'self' (ADR 0058), and inline would work in pnpm dev:web and get blocked in production — 0036's failure with a different subject, since the handoff calls for exactly the inline version. Synchronous and before the bundle, or else a light-theme user would see a dark flash on every load; plain ES5 because vite's public/ doesn't go through a build, locked by a test; localStorage that throws degrades to "paints and doesn't persist", and an unknown value falls back to the default instead of turning into a data-theme the CSS doesn't know. Six light-theme tokens changed value because the theme's most demanding background is paper's --code-bg: --accent 3.56 → 4.81, --warning 3.15 → 4.98, --success 3.89 → 5.12, --violet 4.16 → 4.95, --text-muted 2.76 → 5.17 (at 2.40:1 over --surface-2 it failed even the 3:1 floor — a bug, not debt), and --accent-hover followed one step below. Recorded side effect: the five pairs that are known debt in DARK pass 4.5:1 in light, and the "known design-system exception" (--on-accent over --accent, 3.20:1) vanishes in light because the fix the test comment described — darkening down to --terracota-500 — is what light now does. Dark didn't change a single value. The syntax palette becomes the handoff's EIGHT roles with the --syntax-* prefix, and five of the handoff's eight values were rejected by measurement (--syn-cm 2.32:1 in light, --syn-kw 4.34, --syn-str 4.20, --syn-fn 4.14, --syn-op 4.00): where the handoff fails, the measured number wins, same rule as 0036. --font-display/--shadow-modal and --r-md/--r-lg/--r-pill enter as ALIASES, never renames — and --r-sm (7px) is NOT a synonym for --radius-sm (4px) |
| 0078 | Screen frame, and the tab registry deliberately diverges from the handoff. The handoff's "screen frame" checklist (60px header, tab bar with box-shadow: inset 0 -2px 0 var(--accent), horizontal scroll, content container with a 960–1040px max width) had all four defects in ProjectPage.tsx, and all four were fixed — --header-h became min-height, not height, because the PROJECT header (identity + compact TokenMeter) is richer than the handoff's generic header for the 6 inner screens, and forcing 60px would cut off the budget alert, the same class of mistake RN-088 forbids. The part that needed a decision, not just CSS: the handoff lists 7 tabs and the registry (project-tabs.ts) has 10. executores (RN-121), backlog (RN-048) and insights were born AFTER the handoff, with real data and their own RN, and STAY — the handoff is a reference for VISUAL fidelity, not a ceiling on how many tabs the product has (RN-203). And the sessions key stays labeled "Chat", never "Chat RAG" as the more recent handoff asks (RN-202): "Chat RAG" describes a feature that doesn't exist — no indexing pipeline, no citation UI, just the contract's foundation (embeddings, 0075) —, and renaming the tab today would announce a capability before it existed, the same mistake 0042 already refuses for catalog models. The tab bar spacing values that lived as a descendant CSS override in ProjectPage.module.css — a pending item declared since PHASE 16 — moved into Tabs.module.css, which now owns them. No export SHAPE changed (AbaDoProjeto, ABAS_DO_PROJETO); only code's label went from "Code" to "Código" |
| 0097 | The dashboard's "N agents online" (RN-409) reads engine.dev_agent_states DIRECTLY — raw SQL batched per workspace, inside the SAME Promise.all that already sums eleven queries of RN-090's read model —, instead of exposing a new internal HTTP route on the engine. api and engine share the SAME physical database, split only by Postgres schema ("engine" vs public), and there's already precedent for cross-schema reads (apps/api/scripts/medir-execucao.ts already reads engine.oban_peers) — this decision raises the bar from a manual script to TESTED production code, with a minimal, declared fixture of the engine table in test/support/global-setup.ts. Accepted consequence: the query assumes whoever operates the product migrates both sides together (db:migrate AND engine:migrate), and fails loudly/visibly if only the api migrated — no try/catch hiding the error |
| 0116 | apps/web's ActionType stops being hand-copied — it is now generated by openapi-typescript from docs/reference/openapi.json (already a drift-checked artifact), reusing that same freshness gate instead of duplicating it. Fixes a copy that already diverged from apps/api/src/domain/actions/decide.ts TWICE in production (the three Gitflow bootstrap types crashing ApprovalCard; later parallelize/raise_max_parallel), both times unnoticed by the compiler because apps/api isn't a dependency of apps/web. Only ActionType migrates — api-types.ts's other ~1600 lines stay hand-written, one type at a time, not a wholesale regeneration. aprovacoes.test.ts (FASE 19) stays: exhaustiveness is now also compiler-checked via Record<ActionType, ...>, but the test's content assertions (sentence quality, not just existence) aren't a type-level property |
| 0122 | apps/web/src/routes/SessionPage.tsx — 3 807 lines, 169 KiB, 25 test files importing it, under active churn (50 commits, zero prior extraction attempts) — declares the WHOLE 5-PR decomposition upfront so each PR executes it instead of re-deciding it: pure timeline/turn helpers → lib/session-timeline.ts (PR 1), StorySlide (PR 2), StructuredQuestionCard + permiteOutra (PR 3), backlog-tree helpers + ItemDeBacklog + ContextAside (PR 4), a useSessionReadiness hook — the only non-file-move, ordered last (PR 5). SessionPage.module.css has exactly one importer today, so PRs 2–4's new sibling files safely import it directly — no CSS split forced. Explicitly OUT of all five PRs: the turn-channel state cluster (turnoViaCanal/statusAgent/pensandoVisivel/atividadeDoTurno, read and written by four different turn-entry handlers, the in-code comment's "ONE place that ends a turn") and ProjectSettingsTab.tsx (separate scope). Every PR's acceptance bar is the SAME 25 SessionPage.*.test.tsx files passing unedited |
| 0124 | The turn-channel state cluster ADR 0122 deferred ("its own, later, separately-numbered ADR") moves into useTurnoDoAgente (apps/web/src/lib/session-turno.ts): streaming/streamingText/streamingAgent/turnoViaCanal/statusAgent/pensandoVisivel/atividadeDoTurno/optimisticUser, the connectSessionHeartbeat channel effect (verified 100% turn-lifecycle machinery), and iniciarTurnoDoAgente/finalizarTurnoDoAgente/cancelarTurnoOtimista. A line-by-line re-read found ADR 0122's own count stale — four turn-entry handlers named, seven real write-entry-points exist, including handleReturnStory/RN-174, never mentioned there — and built on a prior mechanical-dedup PR (not itself ADR'd) that first made three duplicated inline arms call the pre-existing iniciarTurnoDoAgente (which gained { comStatus } there) and introduced cancelarTurnoOtimista. That function is built to cover only two of five differently-shaped reset-on-failure blocks found in the file (handleReadiness/handleArchitectureReadiness's identical 4-field blocks) — NOT handleAcceptHandoff's 2-field block (its kickoff is an async GenServer.cast, so it never armed the other two fields; coupling it to cancelarTurnoOtimista would repeat the exact silent-coupling trap ADR 0122 already named) nor handleSend's two distinct shapes (optimisticUser belongs to handleSend's own lifecycle, not the arm). SessionPage.tsx goes from 2 681 to 2 479 lines; the object-shaped hook return (13 fields, positional destructuring rejected as unreadable) keeps every call site's variable name unchanged. Closes the turn-channel item from ADR 0122's "Explicitly out of scope" section specifically — ProjectSettingsTab.tsx, the other file in that debt row, stays untouched and separately scoped |
| 0125 | apps/web/src/routes/ProjectSettingsTab.tsx — 2 532 lines, 89.9 KiB, the LAST open half of the technical-debt row ADR 0122 and ADR 0124 both named and both deliberately deferred — splits into one file per section under apps/web/src/routes/settings/: 17 sections plus a two-symbol settings/shared.ts. ONE PR, not five like 0122's, and the reason is structural rather than a matter of taste: the parent held NOTHING (17 JSX children, no hook, no query, no t, no queryClient, no role check), no section took more than {projectId} (MatrixSection/CredentialsSection take no props at all), and 11 of the 17 were already exported — so every file is a contiguous, byte-identical block, and the churn argument that forced 0122's five-PR shape does not apply. ProjectSettingsTab.tsx STAYS at its path as entry + barrel (2 532 → 77 lines), and that is load-bearing, not cosmetic: ProjectSettingsTab.test.tsx imports 11 names from the path, ProjectPage.test.tsx and project-tabs.test.tsx both vi.mock it BY PATH, and ProficiencySection.test.tsx renders the barrel itself — the only test that mounts all 17 at once. Same barrel shape as ADR 0121, but with the exports NAMED instead of export *: a section is a component with a prop shape, and an explicit list is what lets the compiler catch one that stops matching its call site. Helper placement is mechanical and grep-verified — one caller means the caller's file (ten of twelve), two or more means shared.ts (ORIGIN_TONE, formatarCustoMicros) — which corrected a drive-by expectation: iniciaisDe/gradienteDe belong to MembersSection alone, not ProficiencySection. The 6 private sections gain export on their own module (mechanical; the barrel must import them) but are NOT re-exported by the barrel, so the public surface stays byte-identical in shape — widening it would be an observable change in a PR claiming none. ProjectSettingsTab.module.css stays ONE shared stylesheet, now 15 importers, same answer 0122 gave for SessionPage.module.css. Deliberately preserved: useTranslation('settings') ~14×, useQueryClient() 11×, useToast() 10×, useQuery(['project', projectId]) 7× — React Query dedupes them, and hoisting them into a parent or a context would be a design change disguised as cleanup. Closes the LAST declared open debt item in docs/architecture.md; the four UI improvements that land in these same files are later PRs of the same plan, explicitly out of this one |
| 0126 | The project page's two-level horizontal tab strip (components/ui/GroupedTabs.tsx) becomes a VERTICAL RAIL (apps/web/src/routes/ProjectRail.tsx), with all three groups open at once. Only one of the design review's three arguments survived verification and it is the only rationale recorded: 12 tabs in a bar built for ~6 does not scale. The other two were false and are written down so nobody re-litigates them — the second level was an inline row, never an overlay (GroupedTabs.tsx:143), and a closed group DID sum its children's counters (somarContagens, :33, tested at :141). Geometry is borrowed from routes/code/CodeShell.module.css — the only rail in the repo with an active state drawn (--surface-1 band, right divider, 32px item at --radius-sm, active tinted color-mix(… 12%)) — never from the sidebar's .trilha, which has no active item and no keyboard. One declared divergence: 180px wide instead of 48px, because this rail carries labels and group HEADINGS, which is the mechanism that keeps three groups open. The five counters stay SEPARATE and no group sums them; the badge stays solid --accent/--on-accent against the handoff's 18% tint, per the contrast debt of ADR 0074. onKeyDownDaLinha's tested keyboard contract is PORTED, axis swapped (ArrowDown/ArrowUp/Home/End, wrapping) and improved — refs by key instead of positional [role="tab"] DOM queries, which only existed because the Tabs primitive exposes none. GroupedTabs is deleted with its test; Tabs STAYS despite losing its only caller, because ds-bundle/_ds_sync.json publishes it as components/primitivas/Tabs and a DS primitive without a consumer is stock, not dead code. Revises RN-201: the Code tab's auto-collapse (AutoCollapseContext/useAutoCollapseSidebar) is REMOVED, because a permanent project rail beside the Shell's icon rail would be two adjacent vertical rails on the heaviest tab. The accepted price, measured: 492px of chrome before the first character of code (264 sidebar + 180 rail + 48 code rail) against ~110px before. What is bought is that collapsing goes back to being the user's decision — still manual, still persisted; the double rail survives under manual collapse, which is said out loud rather than pretended away |
| 0176 | apps/web/src/routes/SessionPage.tsx goes below 1,000 lines in ten stacked, MECHANICAL PRs, and only then gets a CI ceiling (AT-138, BRB-015, maintainer's decision: "yes, and lock it afterwards"). The debt row closed by ADRs 0122/0124 at 2,479 lines closed by a NUMBER that nobody re-measured: on origin/dev at 018b8cd24c the file was 2,637 lines, grown back by legitimate features. The program moves, without editing any of the 28 SessionPage.*.test.tsx files: the fio passes (session-fio.tsx), the 762-line timeline useMemo body (session-timeline-montagem.tsx, called from inside the SAME useMemo with the SAME dependency list, byte for byte), the three JSX strips (SessionTopbar, SessionFio, SessionComposer), the handoff derivations (lib/session-handoffs.ts), the scroll refs and effects (useRolagemDoFio), story promotion (usePromocaoDeHistorias + DevolverHistoriaModal) and the non-turn handoff/execution actions (useAcoesDeHandoff) — 2,637 → 834. The turn-entry handlers stay: they are the door of ADR 0124's cluster. The last PR adds SessionPage.teto.test.ts, failing the file at 1,000 lines or more; raising it takes a new ADR |
| 0181 | The dark theme becomes NEUTRAL BLACK with a SOFT terracotta accent, and light becomes the neutral of the same family (AT-283/AT-284). Semantic VALUES change, names don't; a raw --neutro-* scale enters. The final numbers came from MEASUREMENT, not the draft (--text-muted #86868f dark / #696972 light, dark --on-accent becomes neutral black), and the five dark-theme debt pairs 0074 locked by number now pass 4.5:1 and are asserted as FLOORS; the primary-button exception is gone without moving the brand hue. Color outside tokens moves into tokens: one checked copy of fallbacks for Mermaid/xterm/minimap, three agent tokens per theme, --overlay, and a test that fails on any var(--x) nobody declares |
| 0210 | Text animation with motion (AT-401): the AI Elements Shimmer, adapted to CSS Modules and tokens (--text-secondary base, --text-primary sweep), lives in components/ui/Shimmer.tsx — the only importer of motion, loaded through LazyMotion + domAnimation in its own vendor-motion chunk (26.5 kB gzip, outside the initial load); reduced motion keeps the text static. |
| 0211 | One navigation only (AT-404): the project's vertical rail (ADR 0126, ProjectRail.tsx) is removed and the sidebar's per-project list (AbasDoProjeto) absorbs everything it had — groups, active tab, the five SEPARATE counters (same useContagensDoProjeto hook as the frame, no new request), keyboard, a flyout when collapsed and the drawer on phones. Code tab chrome drops from 492px to 312px (110px collapsed). |
Operation, deploy and installation
Production images, Kubernetes, observability, backup, the dev compose and the one-line installer.
| # | decision |
|---|---|
| 0024 | Multi-stage, non-root production images, compose.prod, CI with scan and smoke test |
| 0025 | Kustomize base+overlays (not Helm), engine HPA by Oban queue depth, local overlay |
| 0026 | Graceful shutdown with drain, session registry in :global, OpenTelemetry with per-session trace, metrics and dashboards as code |
| 0027 | Tested backup/restore, rate limiting in Postgres (no Redis), classified HTTP surface and versioned release |
| 0035 | Instrument and export kept separate (trace with a real trace_id with no collector), the cross-layer path via AsyncLocalStorage + @Traced, readable log in dev and one line of JSON in production, and the three correlation gaps that were dead |
| 0070 | Observability in the local Compose: metrics and logs, no trace. 0026 set up Prometheus, Loki, Tempo, Alloy, Collector and Grafana only for Kubernetes — the acceptance criterion said "in Grafana local" and "local" there meant the k3d cluster. The practical consequence is that whoever develops with pnpm dev has the whole instrumentation running (the api exposes 102 series, the engine exposes oban_queue_depth, all three stamp trace_id in the log) and NONE of it is observable, because seeing a panel required standing up a cluster that by design doesn't coexist with pnpm dev (same ports, ADR 0025). The docker/docker-compose.observability.yml overlay is opt-in, not part of pnpm dev: that's four more containers on a machine already running Postgres, Ollama and three apps, and whoever isn't investigating shouldn't pay for them. It delivers the two telemetries that DON'T need a collector — metrics is scrape, logs is read from stdout, exactly the split from decision 9 of 0026 — and leaves trace out, with no OTEL_EXPORTER_OTLP_ENDPOINT set, because 0035 already established that not exporting is the normal state in development and pointing at a nonexistent address only produces an export error on every turn. The artifacts are the SAME ones from the cluster, not a copy: the Compose mounts deploy/k8s/observability/dashboards/ directly and the datasource UIDs are identical, because a dashboard references a datasource by UID and a dashboard copy diverges the first time one side gets a panel fixed; for the same reason the scrape emits the app/pod labels the panels group by, and the collector emits the app the cluster's Alloy derives from app.kubernetes.io/name. Log parsing is by regex and not stage.json because in dev pino-pretty draws the readable tree and the engine uses PrettyLogFormatter — a good decision that doesn't get swapped to please the collector; the collector adapts instead. Only api, engine and web go to Loki: Postgres and Ollama are noise, and the observability stack itself is left out because it runs in the SAME Compose project and would ingest its own log in a loop that feeds itself. trace_id is STRUCTURED metadata and never a label (cardinality), which required allow_structured_metadata and schema v13 in Loki's own config. Records three modes of silent failure that cost time, all with the same signature — valid config, zero error in the log, wrong data on the panel: stage.replace substitutes CAPTURE GROUPS and with no parenthesis substitutes nothing; escaping \x1b in a River string is processed before the regex compiles, and [[:cntrl:]] doesn't depend on that layer; and a Go template with a missing key renders the literal <NO VALUE>, which turned into a level value in the selector |
| 0150 | The one-line installer, and the three disk facts that shape it. docker-compose.prod.yml is not an installation manifest: every service points at brabo-*:prod with a local build: block, there is not one ghcr.io reference under docker/, and the file says of itself that it is the "VALIDATION compose" and "what this file is NOT: a hardened production manifest". The only consumer of .release/images.json is the kustomize overlay. Decision: invocation is sh -c "$(curl -fsSL …)" and **never `curl |
| 0152 | Backup and restore that run against compose — born because FASE 29 must migrate an existing installation and the documented path does not serve: make test-restore is a Kubernetes path (needs kubectl, a cluster and the brabo-backup CronJob), docker-compose.prod.yml has no backup service, and backup.sh covers only Postgres while demanding five S3 variables. The decision starts by REFUSING the easy move ("back up every volume") and classifying the seven instead: pgdata and git_local_repos are source of truth; neo4j_data and project_workspaces are derived; ollama_data is re-obtainable; brabo_projects_base belongs to the user. git_local_repos is the real hole and the finding of this ADR — a local-provider project keeps its bare repo there, losing that volume loses the code the agents produced, no backup covers it today, and it is NOT reconstructible from Postgres: the event log holds the narrative, not the git objects. Neo4j gets reprojection, not backup — restoring a possibly-stale projection beside a Postgres restored at another instant yields two derived states from different moments with nothing reconciling them; this phase declares the path and accepts that a migrated install starts with an EMPTY graph, which is known degradation (the RAG lives in pgvector, inside the dump), not data loss. Disk becomes a valid destination so migrating does not require a bucket. The compose restore reuses the SAME three validations of restore.sh:139-224 — the wrapper changes, the judgement does not — and deploy/k8s/test-restore.sh stays untouched. The installer only earns the right to delete volumes because it proved it can bring them back |
| 0153 | DEPLOY_ENABLED is cited as the activation trigger by ten documents — ADRs 0089/0091/0092 in their own Status field, fluxo.yml in three places (platform.ativacao, area-infra.gate_saida, and the TWO gap deliverables of secops-runtime), gates.yml on the operavel gate, plus business-rules.md, backlog.md, modelo-de-time.md, auditoria-fluxo-vs-codigo.md, historico-de-fases.md and CLAUDE.md — and it exists nowhere in the code: the only two occurrences are COMMENTS (external-effect.ts:144, listar_metricas_de_produto.ex:185). Ten documents point at a switch with no wall behind it. A finding that is not about deploy at all: the deployavel gate is declared as area-infra’s gate_saida in fluxo.yml:247 and is absent from the registry gates.yml, which is exactly the inconsistency the (still backlogged) cross-check between the team model’s three pieces would catch — the second such finding. Decision splits in two with a hard boundary: Stage A, decidable today, adds deployavel to the registry as planned/warn (RN-070/071 forbid promoting to block without a verification script), leaves the two anticipated scripts untouched, and stops the prose from treating DEPLOY_ENABLED as a variable, naming instead the MILESTONE it always meant; Stage B is blocked by an operator, not by code — a production environment with real traffic, after which platform leaves planned and the gaps become possible. The infrastructure is NOT the bottleneck (overlays, GHCR images by digest since 0119, smoke/rollout/restore scripts all exist) — what is missing is someone running and maintaining it. The alternative CONSIDERED AND REFUSED was creating the variable now with a false default: a variable no code reads is the same empty promise this ADR denounces, now with a config line propping it up; the flag is born in the PR that has the first real consumer. Refuses explicitly: creating a platform/secops-runtime agent in the engine (repeating 0091/0092’s refusal, which still holds), and marking MTTR/change failure rate as permanently unmeasurable when they only depend on an environment |
| 0155 | The installer creates the first account, in the terminal, and brings the local agent up already paired. The finding that forces it: a fresh one-line install lets nobody in. The generated .env carries no mail variable at all, MAIL_TRANSPORT falls to log (docker-compose.install.yml:200, whose own comment says the behaviour "stays log-only even here"), and registration requires verifying an e-mail — which, as provisionar-usuario.ts's docblock already recorded, "with the log-only MailSender does not close by itself". So the installation ends saying "Pronto" and the only way forward is fishing a verification link out of docker compose logs api. provisionarUsuario exists for exactly this hole — it creates a user with the password ALREADY verified — and its two callers are the demo seed and the smoke, neither of them a real installation. Decision: the step runs only when there is no user at all, through an internal route authenticated by the BRABO_SERVICE_TOKEN the installer just generated and wrote at mode 600; no public "create first owner" route is born, because a public route that creates an owner is a race between whoever installed and whoever scanned the port. The account is born verified, and the reason is what authorizes it: what e-mail verification proves is "this person controls this mailbox", while whoever is running the installer has already proved something stronger — they control the MACHINE, the .env with all five secrets, and the Docker daemon. Demanding the weaker proof, over a channel the installation knows is switched off, is theatre — and the installer SAYS so in its output rather than letting the person discover their account differs from everyone else's. Consent is explicit and without a TTY the step does not happen (RN-511's rule, consentir-base.mjs's shape): no path exists where an account is born with a password the script chose, neither generated nor default. The password is read without echo, confirmed, used and discarded — never in the .env, never in the marker, never in a log; the marker gains the owner's e-mail, which is identification, not a secret. With the account made, a machine device key is registered and the per-machine unit installed; the pair is generated ON the machine and the private half never travels, keeping true the sentence RN-519 used to refuse the maintainer view. The agent then comes up with NO project, and that is the normal state — it waits, emits no failure, and status says "up, no project in runner mode", one of the four states RN-518 already distinguishes rather than a fifth. Refuses: creating an account when one exists, a public first-owner route, a script-chosen password, loosening the normal registration, turning SMTP on by itself, and asking for an LLM credential |
| 0160 | The installer's compose travels with it, as a signed Release asset. The first clean-machine run of the installer (RN-549) found that the published install.sh brought nothing up by itself: it ran docker compose -f docker/docker-compose.install.yml, a path relative to wherever it ran, and that file was not a Release asset, was not in the signed checksums.txt, and was downloaded nowhere — so whoever followed the runbook's one-liner died with "no such file or directory" after the .env with five secrets was already written. Decided by the maintainer: four files become assets in the same signed manifest — the compose, the two files it bind-mounts (postgres/init.sql, ollama/pull-models.sh) and, measured while implementing, the backup/test-restore-compose.sh the migration path executes, which the E2E's manual workaround never carried because a clean machine has nothing to migrate. Names carry a brabo-install- prefix because a Release is flat and generic names would collide. The table lives on two sides — scripts/ci/assets-do-instalador.ts for whoever publishes, a case in install.sh for whoever downloads (bash 3.2, no Node) — and a spec fails when they diverge and derives every relative bind-mount from the compose, which is exactly how the defect was born. The checksums job publishes them from the tag's checkout and now runs sha256sum -c --strict on the manifest before attaching. The installer downloads and verifies the four right after verifying itself, against the SAME manifest, by exact name match, with three distinct named refusals, all on an untouched machine; COMPOSE_DE_INSTALACAO is born empty and only becomes an absolute path when the verified copies are placed under the install folder, replacing — never reading — whatever was there. The E2E drops its manual step and asserts its absence. Refuses: cloning the repository at the tag (trades the signature chain for trust in git transport, plus a dependency a clean machine may lack), a tarball (one manifest line covering four unnamed files), embedding them in install.sh, a flag pointing at another Release URL, and running the E2E on pull requests. Declared: proven end to end only at the next final tag; no already-published Release gains the assets; and, measured and not fixed, test-restore-compose.sh calls Compose without --env-file, so the migration's restore proof tends to fail safely |
| 0161 | The screens only offer the execution mode this installation can run, and an installation from a Release has no container broker. A real v6.1.0 install (AT-085) created a project in mounted — PRE-selected, because install.sh always writes BRABO_PROJECTS_BASE and RN-513 pre-selects that mode when the base is known — and got six dev.blocked_by_container plus two container.start_failed: container and mounted only start a container through the broker (ADR 0144), the installation compose has no broker service at all (not a disabled profile — ADR 0150 decision 7), and without a running container no dev agent works (ADR 0143). The maintainer decided "both": the installer asks whether to enable the broker, and the screens stop offering what does not run. The installer half was measured and stopped: the broker image is not obtainable by a Release install — four bake targets, no broker in images-manifest.ts or in the v6.1.0 images.json, docker manifest inspect ghcr.io/daneiel/brabo-broker:6.1.0 answers denied while the same anonymous call for brabo-api resolves, and the one-liner runs in a folder with no source to build from. A question whose "yes" has no image is worse than none; publishing the broker as a fifth image comes first, as its own front. The screen half ships: brokerConfigurado (from ContainerBrokerPort.configurado(), the same source the observed-state read uses) joins GET .../projects-base and every row of GET .../containers — no new route. The wizard has three states that never collapse: confirmed broker keeps RN-513; confirmed absence keeps Container and Mounted on screen but inert, says why once in text, and pre-selects Runner; unknown neither pre-selects Mounted nor locks anything. /containers gains a fourth local refusal, sem_broker_na_instalacao, for container/mounted, before the image check. RN-512 and the five containment layers are untouched. Out of scope, declared: mode conversion, stop/remove, the Infra Lead not knowing about a missing broker, and the parallelization suggestion that ignores blocked agents |
| 0162 | The container broker becomes the fifth published image, and the installer asks whether to turn it on. The installer half of ADR 0161's "both": in a Release installation container and mounted projects never ran, because the broker brings their containers up (ADR 0144) and the installation had no broker service — measured, ghcr.io/daneiel/brabo-broker answered denied, the bake had four targets. The Dockerfile.prod already existed and passed hadolint; nothing built it. Now the broker bake target goes through every gate the other four do — built, Trivy-scanned and refused as root on every PR, proven healthy read-only with no network, published, recorded in images.json, signed and verified by digest on every tag. The installation compose gains the service off, under the container-broker profile, with ADR 0130's five layers intact; the Kubernetes overlay does not know it. The installer asks after the base, says in text that turning it on hands the machine's Docker socket to the service, turns it on only on a typed "s", measures the socket's gid from inside a container (a named refusal otherwise, never a guessed 999), writes COMPOSE_PROFILES/BROKER_URL/DOCKER_GID/the managed root together, and checks after startup that the api reaches the broker and that the computed root is the volume's. RN-512's argument (whoever installs on their own machine already holds the socket) is why it can be offered; the installer's reader never having opened this repository is why it is asked, not assumed. Price, declared: a root-equivalent image is public, and a user installation mounts the Docker socket when it consents |
| 0169 | The local overlay's S3 backup destination stops being MinIO and becomes SeaweedFS (docker.io/chrislusf/seaweedfs 4.47, pinned by INDEX digest, server -s3 -s3.port=9000). MinIO stopped publishing its community image — the pinned digest answers 401 on quay.io and the Docker Hub repository 404 — so the bootstrap died at rollout status and no property proof ran (issue #633). Measured against the maintainer's criteria in order: SeaweedFS passes all four (official, anonymous, amd64+arm64 index; the real lib.sh flow through aws-cli — auth, mb, 30 MiB multipart stdin upload, head-object, download by sha256, server-side copy, list-objects-v2, rm; only the server changes; Apache-2.0); Garage is discarded on the third (config file plus post-boot layout and key import); Bitnami, its frozen legacy mirror and Chainguard's :latest-only MinIO on the first. Resources get a neutral name (s3-local); the client, the backup scripts, the Secret and the port are unchanged. Declared: a digest guarantees immutability, not availability; S3 compatibility is per operation, so a new operation in lib.sh is measured against it first |
| 0170 | The dev compose becomes the Docker project brabo-dev, and dev refuses to come up next to an installation. Measured on 2026-09-19: the dev compose and the installation compose were both name: brabo — ONE Docker project on a machine with both, sharing containers, network and volumes (brabo_pgdata…); an installation's database received a dev migration, and reset-total.sh would have dropped its schemas. The installation stays brabo (renaming it would unbind every existing installation from its volumes); dev renames, and the observability overlay repeats the name because the last -f wins. preflight.mjs and reset-total.sh REFUSE, naming the containers and the down (without -v) of each file, while any container whose config_files label points at the installation compose exists (in any state, under any -p), or while the dev compose would resolve to brabo; the reset's guard runs before anything else. Old brabo_* dev volumes are only WARNED about — dev-only keys derived from the two compose files prove them, shared keys stay ambiguous and the warning says so — and the runbook carries the copy-or-start-over procedure. Price declared: developing on a machine with an installation requires bringing the installation down first |
| 0209 | The engine moves to Elixir 1.20.4 / OTP 29.1.1 on Alpine 3.24.2 in dev, production and CI (AT-398): OTP 27 aborted at boot with sys_sigaltstack(): Internal error under Alpine 3.24's musl 1.2.6 on hosts with AMX (erlang/otp#11349), fixed only from OTP 29.1; the price is rebuilding the dev _build/deps volumes. |
CI, release and supply chain
The pipeline, the published images, signing, scanning, pinning, the test layers and the repository's own structure.
| # | decision |
|---|---|
| 0039 | actionlint pinned in the engine's Dockerfile (same pattern as gitleaks/hadolint), CI pipeline validation at GENERATION time instead of a new post-PR gate, and the documented gap of .gitlab-ci.yml having no equivalent offline static linter |
| 0119 | release.yml stops building the four production images just to prove the tag is buildable and PUBLISHES them to GHCR on every final tag, public, logging in with the job's own GITHUB_TOKEN (packages: write) — no new secret. The registry prefix and the build output become bake variables (REGISTRY/OUTPUT), both empty-by-default so ci.yml keeps building the short local names it scans; type=registry is not an optimization, it is the only mode in which bake returns containerimage.digest, because the digest is born from the push. What each tag published is recorded BY DIGEST in .release/images.json — an asset of the Release, riding the CHANGELOG PR into the repository, with NO third push exception. The overlay keeps REPLACE_WITH_DIGEST as a marker and make imagens-do-release applies the digest with kustomize edit set image; the repository never declares which release is in production. Found while doing it: the overlays listed three images, not four — the backup CronJob inherited brabo-backup:prod, a name that resolves in no registry |
| 0120 | A browser E2E layer (e2e/, Playwright, chromium only) closes the one hole left at the top of the pyramid: nothing exercised a real browser, and the mechanisms that carry the session are browser mechanisms — httpOnly refresh cookie paired with a readable brabo_csrf, cross-origin calls from :8088 to :3000 (preflight, SameSite, credentials: 'include'), and the single-use socket ticket of RN-108 handshaking against the engine on a THIRD origin. jsdom can enforce none of those, so a test asserting them there would pass while proving nothing (main.ts already says it: "teste não faz preflight"). It runs against the PRODUCTION compose that docker/smoke.sh already brings up (SMOKE_KEEP_UP=1) and in the SAME images job, because the four images are most of that job's wall clock and a separate job would rebuild them to reach the identical stack. e2e/ is NOT a workspace member — own pnpm-workspace.yaml and lockfile, same shape as website/ (ADR 0117), for the same reason: Playwright's tree reaches no image and has no business in the product's pnpm audit. Specs assert MECHANISM (the httpOnly flag as the browser reports it, document.cookie as the page sees it, survival across a reload, ticket= in the WebSocket URL) with structural selectors, never text — the interface language is the server's decision. Seeding goes over HTTP, mirroring the smoke. Declared as not covered: cross-browser differences, inline approval and streaming |
| 0121 | apps/api/src/db/schema.ts — 2 452 lines, 51 tables, 34 enums, the repo's most-changed file — splits into one file per DOMAIN AGGREGATE under db/schema/, mirroring src/domain/* instead of inventing a taxonomy the file was already keeping by hand in // --- section --- comments. The old path becomes a 25-line barrel of export *, so all 144 modules importing …/db/schema change nothing, drizzle-client.ts's import * as schema sees the identical export set, and drizzle.config.ts keeps pointing at it (verified: drizzle-kit follows the export * chain and reports "No schema changes, nothing to migrate"). Purely mechanical — no table, column, enum value, index name or constraint expression changed, and the acceptance bar is the ZERO-diff migration, stricter than the suite because Drizzle diffs the inferred SQL. Enums live with the table that calls them, not with their subject: a cross-file FK is safe under a cycle (.references() is a lazy callback), a cross-file ENUM is not (it runs at module evaluation), so project_execution_mode/story_promotion sit in iam.ts with their only consumer and failure_origin in backlog.ts, keeping the import graph a DAG. backup.ts is the one file with no domain/ counterpart — infra with no business rule, and a wrong boundary would cost more than a small file. docs/.docmap.yml grows schema/** alongside the barrel: without it the rule would go blind to constraints, which is what it exists to watch |
| 0140 | On 2026-09-04 the npm advisories endpoint (POST .../security/advisories/bulk) went intermittent for hours and failed the dependency-audit job on FOUR separate PRs with an identical signature (error (503) then TimeoutError) — reproduced outside CI from another machine, where a plain registry GET answered in 130ms and the bulk POST never answered at all. The cost was not the wasted minutes but the habit it teaches: every such red is cleared by re-running until green, and someone who learns that re-runs through a real red too. Product owner's decision: three attempts with no answer from the registry is an ACCEPTED RISK — the job goes green with a loud warning that says the tree was NOT audited, never that it is clean. What the decision does not loosen is what makes it defensible: a reported vulnerability still fails on the FIRST attempt with no retry, and an unrecognized failure fails too (fail closed). The precedence is the heart of it — classificarAudit tests the FINDING signatures before the network ones, so a report that happens to mention timeout (a package by that name, a timeout CVE) is never forgiven as downtime, and there is a test for exactly that. Logic lives in a testable script (11 cases, using the REAL output of run 33838507158 rather than paraphrases), not in YAML bash. Only the pnpm side changes; mix hex.audit/mix deps.audit stay as they were, since widening the exception to who did not ask for it would turn a pointed decision into general policy. Declared and unresolved: nothing alerts when the risk is assumed repeatedly — three timeouts on one PR is third-party noise, three on every PR for a week is the gate switched off in practice, and today nothing tells the two apart |
| 0149 | The product signs nothing it publishes: searching .github/, scripts/, docker/ and Makefile for cosign, sigstore, slsa and attestation returns one real hit, and it covers neither image nor binary (npm publish --provenance for the npm package, publish-runner.yml:188). release.yml builds and pushes the four images (:286-303) and records their digests in .release/images.json (:308-319) while declaring only three permissions (:40-43) — no id-token: write, which keyless signing requires. build-runner-binaries.yml uploads five binaries with gh release upload (:227) and no checksums.txt — while the sha256sum -c calls that DO exist (ci.yml:201, Dockerfile.prod:95) verify third-party binaries, the very pattern the repo knows and never applied to itself. And GET /runner-releases/binary is @Public() and streams GitHub bytes straight through with no integrity check at all. That is BRB-005, and it is what blocked the one-line installer in FASE 28. Decision: cosign keyless (Actions OIDC) signing the four images by digest — never by tag, a moving pointer — plus ONE signed checksums.txt covering the five binaries rather than five separate signatures (five artifacts to verify is five ways for verification to be half-done). Three named consumers, each with a refusal, never a warning: the installer, the download proxy, and the local build. Keyless and not a managed key pair for the reason the repo already learned the hard way — a private key means custody, rotation and one MORE CI secret, and a PAT expiring unnoticed already failed two tag-release runs (0139). The cosign binary that verifies is itself pinned and checked with sha256sum -c against a hash written in the versioned script. Refuses: managed keys, "sign now verify later", signing by tag, and full SLSA/SBOM attestation as a separate axis. Declares that the broker stays unsigned because it stays unpublished — the bake has four targets. Amended in session 3, which split the download proxy's row in two: it verifies integrity (sha256 against the release's checksums.txt, refusing — never warning — when the manifest is absent) and not provenance, because both ways for the api to check the signature were measured and refused — cosign in the image is 155 MB against an Alpine+Node runtime, and @sigstore/verify is cheap (2.5 MB) but would make a @Public() route depend on a second third-party host to verify what no Release carries yet, which ADRs 0041/0042 forbid declaring unproven. So BRB-005 closes for producing signatures and stays open for that half of the proxy (RN-525) |
| 0159 | Third-party images are pinned by digest, with the tag in a trailing comment, and a lint fails the next regression. This revokes a decision this repository had written down: the supply-chain page called tag pinning "a deliberate stop, not an oversight", buying day-to-day reproducibility "without the maintenance cost of digests on images we don't publish". Both halves of that sentence fall to measurement. There are 37 third-party references, and three of the places they run are not the day to day: thirteen are Dockerfile FROM lines, which are the base of the four images we publish to GHCR and sign with cosign (RN-524); four are in docker-compose.install.yml, which runs beside the Postgres of whoever installed the product, in the same file where the four own images already arrive by digest through a variable; and four are services: in ci.yml/golden-set-rag.yml, which is literally the runner the action rule exists to protect, reached by the other door — none of the three was in BRB-004's own evidence. The maintenance cost is real and is the SAME one already accepted for the action SHAs. The check is a sibling of actions-pinadas.ts, not an extension: uses: is one syntax in workflow YAML, images are three others across composes, kustomize manifests and Dockerfiles, and one function answering both questions answers both badly. It fails on a mutable reference, on a digest with no tag comment, and on the same tag carrying two different digests — that third one turns a promise written in a comment (golden-set-rag.yml claiming to run the "MESMA versão pinada" as the dev compose, because the golden-set floor is keyed by model, not environment) into something verifiable. The digest is the index digest, never a platform's: pinning linux/amd64 would break linux-arm64 with no warning. In a Dockerfile the tag comment goes on the line above, and that was learned by getting it wrong: Docker's parser only takes # at the start of a line, so a trailing one is a FROM with three arguments and the build dies — while hadolint passed the same file, because a linter agreeing is not the build agreeing. The check recognizes the trailing form only in order to refuse it, since a pattern that simply failed to match would make the reference vanish from the sweep instead of turning red — trading an error for silence, which is the defect it exists to prevent. The comment is one token in both shapes, which is what separates a tag from the prose already sitting above nearly every FROM. The check deliberately does not cover the four images the product publishes, for three independent reasons — no third party can move them; brabo-api:prod is a local tag whose digest does not exist before the build and changes with every build; and where they do cross a registry, ADR 0119 already resolves them by digest, with the overlay deliberately holding the MARKER rather than a frozen release, so demanding a literal there would fail the correct state. Same for interpolated references and multi-stage stages. The exception list is by name and fails closed. The observability compose was an open TODO(humano) and goes in: excluding it costs a named exemption inside docker/, which is the exact shape of hole the sibling's docblock diagnoses ("a new workflow is written by copying a neighbour, and the neighbour had a tag"). Declared and not fixed: a digest receives no security update until someone bumps it by hand — the action-SHA debt again — and Dependabot's docker ecosystem is not enabled, a separate decision; the dev bases (node:24-alpine) freeze harder than the production ones, which is the least comfortable consequence and is accepted; and otel-collector-values.yaml, where image: opens a map whose tag the chart chooses, stays uncovered. No RN is born: the sibling rule has none either, and both live in CLAUDE.md and the supply-chain page. BRB-004 stops reproducing |
| 0172 | The release runs Trivy on what it PUBLISHES, by digest and before signing (AT-179): until then only ci.yml scanned, and it scans a LOCAL PR build, while a tag builds cold in another run (AT-110) — the image the installer downloads was the one nobody scanned. The gate sits between recording .release/images.json and cosign sign, so a failing image is never signed; it uses the ci.yml flags byte for byte (--severity HIGH,CRITICAL --ignore-unfixed --exit-code 1, the same .trivyignore.yaml, no new allowlist) and Trivy's exit code is the verdict. HIGH/CRITICAL with no fix is REPORTED — job summary and a trivy-sem-correcao.md Release asset built by scripts/ci/trivy-do-release.ts — and does not block. Declared price: the push already happened, so a failed tag leaves unsigned images in the GHCR with no Release; and version, hash and flags live in two workflows, guarded by a spec. Measured against v6.1.0's digests: api and web would fail today on CVE-2026-45447 (openssl, fixed in 3.3.7-r1). |
| 0178 | A third-party image reference becomes image:tag@sha256:<index> — the tag moves from a comment INTO the reference, before the digest (AT-139, over ADR 0159; the maintainer's decision called it "ADR 0171", a number that was never written and stays vacant). Measured on 2026-09-27: Dependabot reads no tag comment in any format and moves a digest-only pin to the digest of latest, which would have taken pgvector, neo4j and node to another major with the comment still lying and the lint green. The 39 references migrate with the SAME digests (29 in docker/, 4 in deploy/k8s/, 6 in workflows), comments dropped; imagens-pinadas.ts derives the tag from the reference and fails a digest with no inline tag (the ADR 0159 shape included), a tag comment that disagrees, a comment at the end of a FROM and the same inline tag with two digests — each rule proved by mutation. NOT done, and declared: enabling Dependabot docker/docker-compose, because the decided step that aligns the workflows' services: on the bot's PR would have to push to .github/workflows/, which the GITHUB_TOKEN can never do — the way out is the maintainer's. The CNPG imageName and the golden-set QA image stay manual either way |
| 0197 | The workflows' services: read their image from the dev compose, and Dependabot's docker/docker-compose ecosystems are switched on (AT-246, owner decision 01/10, over ADR 0159 and ADR 0178). The six literals (pgvector and ollama in ci.yml and the two golden-sets) give way to ${{ needs.imagens.outputs.<image> }} from a reusable imagens-do-compose.yml that runs scripts/ci/imagens-do-compose.ts — one source of digest, so the bot's PR never touches .github/workflows/, and golden-set-rag.yml's "same Ollama as dev" goes from promise to construction. imagens-pinadas.ts now fails, in a workflow, any image literal, any expression other than exactly that one (a literal, ` |
Documentation
How documentation is generated, verified and published.
| # | decision |
|---|---|
| 0029 | Continuous documentation synchronization: generate > verify > remember, responsibility map validated in CI, and a mandatory escape hatch |
| 0033 | The API reference comes out of the code: response DTOs that mirror the entity by type, a table test covering the summary and the real response (the one synthesized by Swagger doesn't count), and a hash manifest instead of regenerating on --check |
| 0034 | Documentation published by stage in a Pages subdirectory: baseUrl by variable, noIndex outside of main with forceIgnoreNoIndex in local search, and the third exception to direct pushes |
| 0071 | Symmetric publishing per stage, and a root that chooses. 0034 had each permanent branch publish its own documentation, but left the ADDRESS asymmetric: main at the root, qa and dev in a subdirectory. The asymmetry charged three prices — the three sites couldn't see each other (whoever landed on /brabo/ had no way to reach qa), the root's special case forced a workflow step that only ran for a third of the publishes, and there was nowhere to declare maturity. The three now go to /brabo/<branch>/, and the root becomes a generated INDEX, not a redirect: redirecting would be simpler and would swallow the question that leads someone to look at more than one stage ("does qa already have what I need?"). The page offers what EXISTS in the tree, never a fixed list, and shows the stamped version read from the tags. A 404.html at the root catches every old deep link (/brabo/architecture → /brabo/main/architecture), with a loop guard for a 404 that happens INSIDE a stage. The tree is assembled for all three (not keep_files: true, which never removes an orphan), which brings the migration for free: the trash main left at the root isn't in the assembled tree and goes away on the first push. And the transition seeds /main/ from the old root — without it, dev's first push would leave /brabo/main/ nonexistent right while 404.html redirects there |
| 0073 | The published path names the ENVIRONMENT, not the branch. Revises 0071: it put the three stages at /brabo/<branch>/ by interpolating $GITHUB_REF_NAME, and with that the stable documentation ended up at /brabo/main/ — main is a committer's word, and a reader is choosing maturity, not branch. main now publishes at /brabo/prd/; qa and dev coincide by accident, not by rule. The branch→path map now EXISTS in one place per process (the workflow step, the docusaurus.config.ts DEGRAUS table and the one in landing.mjs, the latter separating the tree's directory from the branch the version TAG comes from). E_PRODUCAO stays DOCS_BRANCH === 'main' — item 4 of 0071 exists precisely for the day the path changes, and this is that day. Two traps handled: /brabo/main/ is published and vanishes with keep_files: false, so 404.html REWRITES the prefix (its own case, not the generic redirect, which would produce /brabo/prd/main/…) and the anti-loop guard now only covers existing paths; and the transition seeds /prd/ from gh-pages:main, otherwise /brabo/prd/ would return 404 for days between dev's first push and the next promotion |
| 0117 | website/ leaves the root pnpm-workspace.yaml and gets its own pnpm-lock.yaml, so pnpm audit at the root stops reporting Docusaurus's dependency tree, which never ships. Auditing all 13 root security overrides with pnpm why <pkg> -r (not by re-reading their comments) found most attributed to website/ alone are actually MIXED — mermaid/dompurify/uuid also resolve through apps/web's own runtime mermaid (ADR 0068), postcss/nanoid through apps/web's vite, js-yaml/fast-uri/lodash through apps/api's eslint/ajv/@nestjs/swagger chains — so those stay at the root AND get duplicated into a new website/package.json pnpm.overrides block; only serialize-javascript and the yaml@1.x range were truly website-exclusive and moved out entirely; esbuild stayed root-only, absent from website/'s tree. pnpm --filter website (root-only, requires workspace membership) becomes pnpm --dir website in every docs:* script and in docs-deploy.yml/docs-check.yml, which each gain a second, separate pnpm install step scoped to website/ |
| 0202 | The ADR index is grouped by THEME, with the theme OUTSIDE the ADR (AT-137, owner decision 01/10, finding BRB-026): docs/adr/temas.yml holds the closed list of 15 themes and one main theme per ADR, so no accepted ADR is edited, not even to gain frontmatter. The index is CHECKED, not generated — its one-line descriptions stay curated — and docs:check (scripts/docs/temas-de-adr.mjs, each rule proved by mutation) fails an ADR without a theme, a theme not in the list, a theme with no ADR, and an index row under the wrong section, outside any section or out of numeric order. The taxonomy comes from the product's fronts, not from the phases or the code folders, and is the lane's proposal for the owner to review; a new ADR's author picks its theme in the same PR, and going past 15 themes takes an ADR |
| 0206 | The API reference becomes ONE server-rendered Redoc page (redocusaurus) at /reference/api/, revising the publishing half of 0033: the OpenAPI plugin and theme dragged postman-collection → @faker-js/faker@5.5.3 (High alert #43) with no fixable path. The per-operation pages, tag sidebar, per-operation URLs, playground and cURL snippets go away by owner decision; api-render-check.mjs now asserts the title and one SSR section per operation |
| 0216 | Four languages for docs and UI — pt-BR becomes the SOURCE and default, with English, Russian and Simplified Chinese as translations in phases, measured parity and a per-page notice for untranslated or stale pages (AT-545, owner decisions 15/10) |
| 0218 | Every ADR is titled "ADR NNNN — title": the H1 gets the ADR prefix, a 4-digit number and an em dash; the file name (the URL) does not change, and normalizing the title label is not editing the decision (AT-559, owner decision 15/10) |
| 0219 | The reference enters translation: docs/reference/** becomes pt-BR source, translated and measured in parity, generated block included, except the parity report itself and openapi.json; the Redoc page is left to AT-565 (AT-564, owner's decisions of 10/15) |
| 0220 | The API page is translated by catalog: the source stays the api decorator text (an exception to the pt-BR source), one catalog per locale keyed by the text hash, spec derived at build time and kept out of git, orphan fails and missing warns, Redoc's fixed labels in English (AT-565, owner's decisions of 10/16) |
The convention
- One file per decision, at
docs/adr/NNNN-short-title.md, with sequential 4-digit numbering, and the title# ADR NNNN — <title>(0218). No number reuse, not even when an ADR is superseded — the next one is 0220. - One theme per decision, chosen by whoever writes the ADR, in the same
PR: a line in
docs/adr/temas.ymland the index row under that theme's section, in numeric order. The reviewer confirms it. A new theme is a change to the list intemas.yml, justified in the PR that adds it; going past 15 takes an ADR that revises 0202. - Three sections, only those: Context (the problem or force that motivated it), Decision (what was decided), Consequences (the accepted trade-offs and what's left for later).
- No third-party template — no Nygard, MADR or variants. The three sections are enough.
- In Portuguese, like the rest of the project.
- An accepted ADR is not edited. One superseded by a later decision stays as it is; the new ADR references the old one and explains what changed.
What gives these ADRs their value is recording what did not work: 0020 documents nine failed executions in a row, and 0027 records a justification that was revised within the same session that produced it. An omitted bad consequence is the part that's missed six months later.