Phase history
Immutable record of Brabo's completed phases, moved from CLAUDE.md on 2026-08-23 without rewriting. The product's CURRENT state lives in CLAUDE.md, in the RNs (docs/business-rules.md) and in the ADRs — this file answers "how we got here", never "what holds today". An accepted ADR is never edited; the ADR corrections recorded in this text remain valid where they are.
Status
- FASE 1 (MVP) — COMPLETED: IAM/RBAC, sessions with an immutable event log, chat with an LLM router (Ollama + APIs), token metering/budget, proposed_actions pipeline + permissions.json, Elixir/OTP engine with supervision and a termination event.
- FASE 2 — COMPLETED: GitProvider (Local/GitHub/GitLab) with a contract suite and capabilities, encrypted credentials, idempotent Gitflow bootstrap resumable through the pipeline, wizard with live progress.
- FASE 3 — COMPLETED: Harness (PromptAssembler, ToolLoop, ContextManager, InstructionFiles, Hooks), explicit handoffs, Criativo, PO and Arquiteto agents with artifacts and validations in the domain.
- FASE 4 — COMPLETED: execution agents (dynamic per-module devs in isolated worktrees, QA and SecOps as PR gates, proactive Infra), protected-merge lock, live team panel; real Psicólogo and Anamnese with a closed loop. Gates validated by real execution (ADR 0020).
- FASE 5 — COMPLETED: non-root production images and CI (ADR 0024), Kubernetes with Kustomize, HPA by Oban queue, NetworkPolicies and ESO (ADR 0025), graceful shutdown + OpenTelemetry + dashboards (ADR 0026), backup with tested restore, runbooks and hardening (ADR 0027).
- FASE DOC — COMPLETED: docs/ as the single source with Diátaxis, a Docusaurus site in website/ reading from docs/, generated references, docs/.docmap.yml + drift check (docs:check) in CI, a community layer, MIT verified, mechanism documented in docs/explanation/documentation-workflow.md.
- FASE 6 — COMPLETED: mechanized branch policy (ADR 0030) — pr-police, approval-ladder with both modes, promote/tag-release with a calculated version and a tree anchor, backmerge gate with automatic back-propagation, versioned rulesets. Pipeline exercised end to end (v0.1.0 → v0.2.0) and the hotfix chain validated by real execution.
- FASE 7 — COMPLETED: first-party auth replacing Keycloak — argon2id, Ed25519 access, refresh rotation with family revocation, progressive lockout, account tokens and a log-only MailSender (ADR 0031, RN-030..033); atomic cutover with its own issuer, RBAC untouched, /internal/* with a service token, httpOnly cookie + CSRF, user migration and total removal of Keycloak (ADR 0032, RN-034/035); OpenAPI on the 23 controllers with type-locked DTOs, a table test requiring metadata, docs/reference/api/ generated with a hash manifest (ADR 0033).
- FASE 8 — COMPLETED WITH A CUT: agent hierarchy — areas with a single
lead as the external contact, private internal delegations (table
delegations,areaas TEXT), consolidated verdict with traceability, failure with origin; QA as an area (Lead + Automation + Performance/Security, the gates' external contract unchanged — the Fase 4 suite green without modification); Infra as an area with a Workflows subagent generating CI per provider; team panel by area and Psicólogo/Anamnese targeting subagents (ADR 0038). SCOPE CUT of the time, REVOKED by FASE 14d:agent_areas/agent_area_membersexist since then, per project, with configurablemax_parallel— it was the dev area, the first DYNAMIC one, that forced it. What remains cut is only the budget per area (real budget ceilings remain project, session and task). Theapps/web/src/lib/agents.tslist survived for color, icon and the addressing RULE; dev members come from the table. - FASE 9 — COMPLETED: LLMProvider contract suite running
against the existing ones, OpenAICompatibleProvider base on
node:http with an INACTIVITY timeout, error normalized by
code, capabilities in two layers (ADR 0041); catalog sync with a discovered model coming in disabled, a vanished model marked instead of deleted, price frozen intoken_usagewith append-only auditing (ADR 0042); ModelPicker regrouped by origin with curation. The six 9b providers came in through Fase 11 (ADR 0043). - FASE 10 — COMPLETED: Bitbucket + GenericGitProvider delivered VIA
dogfooding (the first real run of Brabo building Brabo itself,
in a fork with a manual seed, batches with a restart between tasks,
Criativo mandatory in the chain). Harvest written in Fase 12d, in
docs/explanation/primeiro-dogfooding.md — the 17 findings with
file:line are real; the QUANTITATIVE half (restarts,
interventions, cost) stayed as
não medido, because the observation table was never filled in. The three operability P1s were closed by Fase 12. The other 14 remain open and listed in the harvest — don't fix them in passing. - FASE 11 — COMPLETED: the six Fase 9b providers as config on top of
the base, each one investigated from scratch against the official docs (inheriting
quirks forbidden); LLM_PROVIDER_NAMES from 3 to 9; credential DTO and
connection tester deriving from a single list; capability only
declared when proven, with two live reversals (DeepInfra and
Vultr); a single new hook in the base (
parseErrorFrame, +31 lines) proven necessary by OpenRouter (ADR 0043). Pending smokes with a real credential: absorbed by FASE 13. - FASE 12 — COMPLETED: post-dogfooding operability, the three P1
findings closed and proven in a single run (ADR 0047).
12a — adoption of an existing repository: its own route,
getRepovalidating access before writing,origin(created|adopted) in both tables, and the PLAN as the gate — a dry-run that describes the divergence and changes nothing while the decision is null; re-adopting converges (ADR 0044, RN-045/046). 12b — event-driven rescheduling of the dev agent: a persisted state machine (working|awaiting_gate|idle|idle_tripped) reacting to two events of the existing outbox,awaiting_gateretaining the worktree (which is per AGENT, not per task) until the gate ends, and a circuit breaker with explicit rearm (ADR 0045, RN-047). 12c — story promotion with the user's authority:story_promotionper project withmanualas the NEW DEFAULT (a targeted backfill puts the existing projects inauto),proposed_readyas a proposal and not a state, promotion reusing TransitionStoryUseCase (dead code from finding #13) and refusal returning to the PO with the reason pinned in its session (ADR 0046, RN-048). 12d — the Noop entered the 12b state machine; the scriptpnpm --filter api validacao:fase-12that exits != 0 when the criterion doesn't close and extracts the evidence from the database; Fase 10 harvest written; docmap covering engine/dev and engine/agents. Pending items (running the validation; Local/Noop limits declared): absorbed by FASE 13. - Don't refactor what is done without an explicit request.
FASE 13 — COMPLETED on 2026-08-07 (prove it for real and triage)
No new feature. The phase closes the declared pending items, proves what the Local/Noop validation admittedly doesn't prove, and turns the 19 open findings into a prioritized plan. Lesson incorporated: the manual table of Fase 10 was never filled in — in this phase EVERY metric is extracted by script from the event log/token_usage, never noted by hand.
13a — Close the declared pending items
- COMPLETED on 2026-08-07:
pnpm --filter api validacao:fase-12exits0and the table of event ids is in docs/explanation/validacao-fase-12.md (the TODO(humano) came out). It cost four fixes — three to the INSTRUMENT (the guinea pig was born in /tmp, invisible to the engine; the Noop didn't open the gate; the Noop died atpr_settled) and one to the PRODUCT (finding W: the dev agent died when the queue emptied). - PARTIAL on 2026-08-07: the OpenRouter smoke ran with a real credential
and closed (real cost US$ 0.000005, read from
token_usage). The other five remain skipped — there is no key for them in the environment. The smoke had rotted against ADR 0049 and had never run; fixed along with it. Dated result in docs/explanation/aceite-providers.md
13b — COMPLETED on 2026-08-07 (v2.4.0)
The whole chain proven against REAL GitHub (daneiel/test), in ten
runs: remote adoption → plan with the user's decision → manual
promotion of ONE story → real dev agent writing code → commit, push and
REMOTE PR → gate opens → area delegates and dismisses with justification →
subagent SUSPENDS on approval → the user's refusal RESUMES the loop →
changes_requested verdict by LLM. Zero engine restarts. Merge
out, by design (RN-014).
3. The script is pnpm --filter api validacao:real, with phases split
by COST (--ate adocao|backlog|execucao) — the cheap one runs alone
first, and that is how configuration errors showed up before
costing money. It requires running FROM INSIDE the api container: the
policy is a file in a shared volume, and from the host it is born
in a filesystem the engine doesn't see.
4. pnpm --filter api medir:execucao is the instrument, and it is PROVEN:
it reproduces on its own the numbers the previous dogfooding had noted by hand
(18 calls, 292,211 tokens, ~US$ 0.03).
5. Result in docs/explanation/validacao-real.md, with the ten
runs and what each one taught.
SEVEN P1s closed along the way, all found by EXECUTION and none by test: W (dev agent died with an empty queue), Y (the search didn't distinguish empty from not-found), AA (git credential resolved by whoever clicks, not by whoever pays — RN-082), AB (the gate called a pending decision an infra failure — ADR 0057), AC (redirection made any command unapprovable), the ReDoS in the path scope (CodeQL HIGH), and the postgrex CVE.
Of the findings the phase opened, X closed later (FASE 14d, RN-085 — the iteration ceiling became per agent TYPE). TWO remain open, and their argument is worth more than the numbers: Z and AD (the verb allowlist does NOT converge: verb, form and invocation are distinct spaces, and runs 6/7/8 got stuck on one of each), and AE (the QA agent tries to fix the code it judges, against its own prompt — contained by two independent barriers, allowlist and scope). Neither of them is a bug to fix: they are a product decision, and are in docs/explanation/achados-execucao-real.md.
The conclusion the phase delivers, and that matters more than the PR: the path to autonomy does NOT go through loosening policy. It goes through the agent WAITING for the decision instead of dying — what ADR 0057 did for the gates, extending 0052.
13c — COMPLETED: the triage became docs/explanation/backlog.md
The 19 findings are closed (19 of 19). The document stays alive and received the new findings from 13b. 6. Triage session: read docs/explanation/primeiro-dogfooding.md and classify the 19 findings into P1/P2/P3 with a proposed grouping into cohesive phases (by theme and dependency between them), estimated relative cost (S/M/L) and the risk of waiting. The output is a PROPOSAL — the priority decision is the user's. 7. Consolidate the full backlog into a living document (docs/explanation/backlog.md or equivalent): triaged findings + old items — budget per area (ADR 0038; the areas apparatus and the Dev Lead left the backlog with ADR 0053, implemented by FASE 14d), manual handoff to an agent of choice, MFA/social/OIDC/federation (ADR 0031), real SMTP in MailSender, deploy (DEPLOY_ENABLED + Environments), return of rc/rcfix (ADR 0030), community mode of approval-ladder, "N agents online" on the dashboard, currency preference with a manual rate.
FASE 14 — COMPLETED on 2026-08-07 (what real execution demanded)
The hello world run didn't get past the first turn and revealed defects that no suite caught, because everything before ran with a LOCAL model. The fixes are already in; what remains is a feature decided by the user during the run.
14a — Agent operability (COMPLETED)
- A turn failure became a DURABLE
agent.errorwith origin, and the agent SPEAKS in the thread; the four conversational ones stopped writing an emptyagent.response(RN-059). The error path narrated in the final frame, which emitted no event at all, also closed. - The Criativo chat opens with an invitation instead of empty: it is activated and waits for the user to speak first, and the screen now says so — including that technology and code are NOT its job.
- The key the agent spends is the workspace OWNER's (RN-058). Before, the turn
looked for the credential by the agent's SLUG in a UUID column: no agent
ever used a provider with a credential, and only
ollamaworked. - Key spend report, only for the owner, with agent separate from person (RN-060).
- Gitflow bootstrap on GitHub: a freshly created repo answers 409 on the whole Git Data API, and the first commit goes through the Contents API. The tests' fake backend lied (404 where GitHub answers 409) — fixed along with it, it is what made the suite stay green with the product broken.
14b — Tree timeline (COMPLETED)
One branch per agent, from the first milestone to what it is doing NOW, derived 100% from the event log the screen already fetches. The chronological feed remains, in the activity column: it answers "what happened", the tree answers "who is doing what".
Revisited after FASE 26 (no new phase, same spirit of a targeted
fix as 12d/POST-FASE 15): the default opening criterion went from just
"active/stopped" to the 5 agents with the most RECENT activity — active keeps
priority (RN-118); each collapsed branch got its own NOVELTY counter,
with a per-agent "last seen" in read-state.ts (same mechanism
as the bell, now granular); and tool.call/tool.result/agent.response
started expanding INDIVIDUALLY (args, result, iteration), grouped
by ToolLoop iteration boundary.
14c — Automatic UI validation (COMPLETED)
Contrast became a test on design/tokens.css, with the known debt measured
and locked; layout (cut-off text, menu outside the viewport, dropdown clipped
by an ancestor, target < 24px) became a browser checker in
scripts/dev/validacao-visual.js. No new dependency.
14d — COMPLETED: parallelism decided by the LEAD, authorized by you
Delivered in four PRs (#181, #182, #183, #184). ADR 0053 became ACCEPTED, revoking for good the three cuts (Dev Lead, dynamic areas via module_map and the generic areas apparatus).
- Areas became data:
agent_areas/agent_area_membersper project, withmax_paralleldefault 2. What forced it was the dev area — the first DYNAMIC one, whose members are one per module of the module_map. What is not enumerable in code has to be data. - The ceiling is the SESSION's, not the module's (RN-083). Counting per module
would allow N modules × 2 without any authorization — the previous hole
under another name. Above the ceiling it becomes a
proposed_action. - Configurable per area in Settings, requiring
maintainerfor the same reason as activating execution: changing the ceiling is deciding how much the product spends without asking. - The Anamnese proposes raising it when authorizing has become routine — three approvals and NO denial (RN-086). One denial kills the signal: if you ever refused, the ceiling is doing its job.
- The Dev Lead exists (RN-087), conversational, receiving the handoff
from the Arquiteto in the same confirmation that already delivers to Infra, and
proposing the plan: how many agents per module and why. The
dev-<modulo>stopped being addressable by handoff — the rule of ADR 0038 starting to apply to dev as it already did to QA and Infra.
Finding X was closed here (RN-085): TOOL_LOOP_MAX_ITERATIONS
became a ceiling PER TYPE — 8 conversational, 60 execution and gate. The criterion
of who goes up is NOT "who works a lot": it is who has
token_budget_micros underneath holding the spend. infra-workflows
uses a heavy tool and stays at 8, because it runs without a budget.
Three gaps of the phase itself, found by me while writing the next slice, and all the same failure: the ceiling rule came in with green tests and the product didn't exercise it — no route called the use case, approving the action executed nothing, and the type could be auto-approved by permissions.json (closed by RN-086). Testing the piece is not testing the path to it.
Out of scope, by declared decision (ADR 0053 item 5): the
"Activate execution" button changing owner. The Dev Lead → dev-<modulo> delegation
through the delegations table with area = "dev", which this same item also
declared, STOPPED being out of scope — closed by ADR 0094
(fluxo.yml × code audit, Wave 2, RN-405). The button remains
reversible; execution remains on the current path, and the post-gate fix
still goes straight to the dev who opened the PR.
FASE 15 — COMPLETED on 2026-08-07 (gates as data)
No NEW gate. The phase extracts into docs/gates.yml the gates that ALREADY
exist implicitly in the product, with script verification and severity,
in the same spirit as docs/.docmap.yml. An agent gate that doesn't exist
(dev-lead, platform) comes in as status: planned referencing the
backlog — never active. The gates' external contract does NOT change.
15a — COMPLETED: declarative registry
- docs/gates.yml with the existing gates (see ADR 0054, which extends ADR 0048 — the decision in the event log is what makes passing a gate measurable), schema validated by test.
- Loader in the api reading the registry; internal read endpoint.
- Script
pnpm --filter api validacao:gatesthat extracts from the event log the last pass of each active gate and exits != 0 if someblockgate has no evidence of script verification.
Not every gate has proof in the event log, and the registry says where it lives:
merge-protegida is guaranteed by TEST (the lock in decide.ts emits no
event of its own) and backmerge is CI, with state in .release/gate.json.
That is why each gate declares evidencia: event_log | teste | ci with the
locator — demoting them to warn would be lying about the product's hardest
locks.
15b — COMPLETED: consumption
- The team panel shows the gate each story/PR is waiting on, derived from the registry (not hardcoded).
- docs/explanation/gates.md explaining the mechanism, in the docmap.
POST-FASE 15 — COMPLETED on 2026-08-08 (v2.5.0 and v2.5.1)
Not a planned phase: it is what USE found after 15, and it is here because the origin of each item matters. None of this came from a script — it came from browsing the app in Chrome and reading the security panel.
UI defects, found while browsing (RN-088..091):
429turned into a WHITE SCREEN and the app answered with more traffic. The rule became general: every screen distinguishes THREE states — loading, error (withtrace_idand a button) and empty.if (!dado) return nullcollapses all three.- The dashboard made one request per project: 3,824 → 12 req/min, with
GET /workspaces/:id/projects-summary. The bell was the missing half: 289 → one request per cycle (RN-090/091).
Security hardening, with both panels at ZERO:
- ADR 0058 — CSP closed in the api (
default-src 'none'), revising item 7 of ADR 0027; and theprojectIdcontained in the root (RN-092), which hit not onlypermissions.jsonbut the scope that AUTHORIZES the terminal. - ADR 0059 —
GIT_OAUTH_STATE_SECRETwithout a default in production (RN-093). The default was PUBLIC (.env.example) and the production compose supplied it as a fallback: requiring "not empty" would have caught nothing, because on the real error path the variable was DEFINED. - An alert that didn't hold up was DISMISSED with a written justification, never silenced — and dismissing is the user's decision, not the agent's.
Three operational lessons that cost a CI cycle, and hold as a rule:
- Green CodeQL on a PR doesn't prove an old alert closed — the PR
check reports a NEW alert. An alert closes on the scan of the DEFAULT branch,
that is, only after the fix reaches
main. - Gitleaks scans commits reachable from
refs/heads/*, not the tree. A high-entropy value committed once fails CI forever on that branch; fixing on top does NOT clean it. The way out is for the commit to stop being reachable (rebuild fromdevand DELETE the old branch BEFORE opening the new PR) — never an allowlist, which blinds the scanner on that path for every commit. PR refs are out of scope. A fixture that represents a secret is born WITHOUT entropy. - A barrier that lives in another function CodeQL doesn't see — hence the
three
js/path-injectionsurviving the fix. The centralized check was kept (RN-092) and the price was paid on the panel: duplicating it in each caller would be a check that one day diverges.
PROGRAMA 16–26 — what browsing asked for (in progress)
Eleven requests born from the FIRST real browsing of the app after the database reset. It's not a script: it's use. Three discoveries from the investigation defined the size and the ORDER of the program, and it's worth recording each one, because none was in the original request:
- There is a COMPLETE design handoff in
design_handoff_brabo/— 8 high-fidelity screens, tokens, typography and brand. The decision was to adopt it WHOLE and FIRST: that way the eleven requests are born in the new look instead of being done twice. - The Code tab's terminal became an ARCHITECTURE decision. Each project now
has its own container, and it is the ARQUITETO who decides the image. Inside it the
agent is free;
git push, PR and deploy remain human. This pays off the product's biggest open debt — today the agent runs in the SAME container as Brabo's monorepo, and ADR 0055 says of itself that it is policy, not isolation. - Two real defects showed up along the way, and neither was requested:
agent_areasis NEVER written (upsertwithout a caller, the API returns[]), and the bell drawer ordersseq ASCin SQL — it shows the OLDEST.
Execution ceiling: at most FOUR subagents in parallel. The phases were
cut by CONTESTED FILE, not by theme — that is what makes parallelism
possible. The backend's limiter is apps/api/src/db/migrations/meta/_journal.json
and the drizzle snapshots: ONE migration per wave, because resolving a snapshot
conflict by hand is a path to a divergent schema. RN ranges and ADR numbers
are PRE-ALLOCATED before the wave starts: two parallel phases writing
RN-094 make one renumber at merge, and a renumbered RN breaks the
#rn-0xx links that pnpm docs:check fails.
FASE 16 — COMPLETED: foundations (unblock parallelism)
None of the eleven requests delivers here. The phase exists because three of them touch
the SAME four points of router.tsx/ProjectPage.tsx, and another three
run into the lack of a common piece. Without it, the waves collapse into serial
execution.
design/tokens.cssgets--violet(agents/AI) and whatever else the handoff defines. The fonts REMAIN self-hosted — following the handoff there would break the app under nginx's CSP (ADR 0036). The contrast test covers the new pairs, and the known debt of 4 pairs stays locked.- The tab stops being a closed list in TWO places: a single registry from which
PROJECT_TABS,type TabKey, the ruler and the render start to derive. A test that fails a key present in one place and absent in the other — that's the real defect. Disclosurein the design system, with the semantics of the most complete implementation that already exists (ModelCatalogSection). COMPONENT ONLY: none of the six ad-hoc implementations migrates here, otherwise the phase would open the three most contested files of the program.- The session label becomes a helper and the five inline
slice(0, 8)migrate. It is this migration that keeps FASE 20 from colliding with 19. CLAUDE.mdenters the definition of done (see the Documentation section), with awarnrule in the docmap. DELIVERED: the rule isclaude-mdin docs/.docmap.yml, triggered bypackage.json,pnpm-workspace.yaml,apps/*/package.json,apps/engine/mix.exs,docs/adr/*.mdanddocs/gates.yml— the sentence "today it has ZERO coverage" that was here described the state BEFORE the phase and kept lying after it.
FASE 17 — COMPLETED: the 8 screens per the handoff
Visual fidelity BEFORE behavior, so as not to redo work. No
business rule changes: if a screen needs data that doesn't exist, that becomes a
declared pending item, not a hitchhiking feature.
6. Login and App/project list.
7. Project and Session, preserving the three states of RN-088 (error before empty).
8. Approvals and Settings — Approvals is the visual base of FASE 19.
9. Proof that fidelity happened: contrast measured on the tokens and
layout verified by scripts/dev/validacao-visual.js on ALL the screens
touched. The .dc.html is a reference, NOT code to copy — the handoff's own
README says so.
FASE 18 — COMPLETED: the area exists in the database (a defect, fixed first)
AgentAreaRepository.upserthas NO caller at all, so four use cases operate on an empty table. Provision on project creation + backfill, with a test that proves a freshly created project HAS areas — it's the same failure as FASE 14d: testing the piece is not testing the path to it.- Collapse the THREE copies of the areas list (api, web, engine) into one source.
FASE 19 — COMPLETED: an approval you can read
- Kill the generic fallback of
ApprovalCard, which dumpschave: JSON.stringify(valor)— the likely cause of "hard to read". Every type gets a SENTENCE in pt-BR; a type without a sentence shows verb + "see details" and the raw payload is born COLLAPSED, never dumped. - Collapse in THREE places: Approvals, Insights and the card in the chat, with verb and sentence coming from ONE module.
- Project constraint: the collapse does NOT introduce a new required prop in
ApprovalCard. That is what keepsSessionPage.tsxuntouched and removes the edge with FASE 20.
FASE 20 — COMPLETED: the session gets an identity
sessionsgetskindandnamein the SAME migration — two migrations on the same table collide in the journal and the snapshots.- Reconcile with the event-based derivation:
kindclassifies the creation INTENT,execution.activatedkeeps classifying execution STATE, and neither rewrites the other.execution.activatedin a consultative session is an explicit error, not a silent conversion. - Rename preserving the hashtag; without a name, it degrades to the hashtag alone.
- A back-to-dashboard button — today
SessionPage.tsximports neitherLinknoruseNavigate, and NO navigation leaves the screen. Destination revised in a later round: the button goes back to the session's PROJECT (/projects/:projectId), no longer to the root dashboard — a session is always born inside a project.
FASE 21 — COMPLETED: the volume of events (RN-099/100)
useSessionEventsremains the CURRENT STATE (latest, four consumers: roster, tree, execution, Approvals). The HISTORY becameuseSessionEventHistory, only for Activities, with theafterSeqthe endpoint already returned. No new route, no new parameter. The anchor is the TAIL, not the start of the session: the endpoint pages forward and there is nobeforeSeq, but opening the feed on event #1 of a session with thousands delivers the wrong screen. So the first page is the samelatestread (SAMEqueryKey, deduplicated) and each click goes down a fixed window backwards.- The bell orders
DESCin SQL. The "read" cutoff did NOT change semantics, and that's why there's no ADR: a cutoff byseqmarks a PREFIX and the drawer shows a SUFFIX, so "mark the 50 shown" is inexpressible without a per-event read table. What changed is the drawer no longer hiding what the advance swallows — total per project,+ N older, and the button saying how many it marks. The missing number comes from SUBTRACTION (latestSeqminus the cutoff), not from a request. - The RN-090/091 savings didn't regress: one request per cycle, and the
first "load older" costs ZERO — the
latestread brings 200 and the window shows 100. An old page hasstaleTime: Infinityand norefetchInterval: a closedseqwindow over an immutable event doesn't change.
FASE 22 — COMPLETED on 2026-08-09 (spend with two audiences)
Delivered as the Spend tab (ADR 0063, RN-101).
22. The missing AGGREGATIONS came into a single method —
sumGroupedBy(dimensao, escopo) with model, project, actor,
session and day. provider is NOT one of its dimensions, and the absence is
structural: breaking down by provider is breaking down by CREDENTIAL, and that is
what keeps the member's view from gaining the axis by carelessness — there is no
argument to pass.
23. No charting library: daily bars and a ranking in inline SVG. The daily
series comes DENSE from the api (a day without spend comes in with zero), otherwise three
calls in three weeks become three bars stuck together.
24. The collision was resolved by separating the QUESTIONS, not the filters. The
per-CREDENTIAL report stays exclusive to the owner (RN-060) and gained
alongside it the workspace breakdown by model/project/actor/day, also
owner. The member's view is by ACTOR, in tokens and estimated cost,
without provider and without credential — and the actor comes from the authenticated
TOKEN, with no parameter where one could write another person's id. An agent doesn't count in the
member's account: token_usage records who spent, not who ordered the
spending.
No migration, but WITH measurement: at 525 thousand rows the queries take
55 ms and 38 ms by seq scan, and an index on token_usage(created_at) would
bring them to 32 ms and 19 ms. The number is in the ADR; the index comes in the wave that
has the slot.
FASE 23 — COMPLETED on 2026-08-09 (model inheritable per area)
- The
areascope entered the cascade (session > agent > area > project > workspace), BETWEEN agent and project: it is the default that lead and subagents share, and the agent diverges by overriding it (ADR 0064, RN-102). - The incoherence resolved BEFORE coding: the agent binding was GLOBAL
(
scope_id= plain slug, the route's:projectIdignored since Fase 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 there was an area. Decision: the agent binding became per project too — thescope_idofagent/areabecame composite,<projectId>:<slug|chave>(RN-103) — and not the area below the agent, which would contradict "inheritable default" (almost every binding today is an agent's). - The UI shows who INHERITS and who DIVERGED (
AreaModelsSectionand the Origin column ofModelsSection, inProjectSettingsTab.tsx); "go back to inheriting" is aDELETEon the agent/area binding, never writes into it the model of the level below — copying would become a copy that diverges on its own at the area's next change. Themaintainerrole to change the area's model (same rule as the parallelism ceiling, RN-083); the agent's staysdeveloper.
FASE 24 — COMPLETED: Chat and Criativo as places (RN-104)
- Two tabs on the PROJECT screen, each listing the sessions of its
kindand creating in thatkindwithout asking again. The Session remains its own screen — the tab did not become a chat container. - The collision was resolved with "Sessions" LEAVING the ruler. What remained of it is
the deep-link KEY: Chat carries
key: 'sessions', and that is what makes an old?tab=sessionsopen on Chat with the tab MARKED. An alias resolved only in the panel would open the right panel and leave the ruler without a selection, becauseTabscomparesactivewithkeyand whoever writesactiveisProjectPage— one of the two files the wave kept closed. - Verdict on "Start ideation": it STAYS. Half the problem had already
closed in FASE 20 (the button vanished from the consultative session, RN-097), and the other
half wasn't redundancy: it is what brings the Criativo, and from there on
the owner's key is spent (RN-058). What was wrong was the PLACE — the
invitation spent a paragraph pointing at the topbar. Now the action lives
INSIDE the invitation while it is on screen, and goes back to the topbar when it
isn't;
conviteVisivelis the only question the two share, so that it neither shows up twice nor disappears.
FASE 25 — COMPLETED WITH A CUT: Container per project (the boundary stops being just policy)
The biggest architectural change of the program, and the one that pays the debt Phases B
and F already pointed at separately. Delivered 25a (the Arquiteto's decision and the
gate) and 25c (the external-effect boundary); 25b (the container's
lifecycle) was CUT and declared — not half-implemented (ADR 0065, RN-105
and RN-106).
30. COMPLETED: the ARQUITETO decides which image comes up for the project, as
its artifact (artifact.project_image in the event log, versioned, without a
table) — auditable, not hidden configuration. Until it
decides, the Code tab answers 409 and doesn't unlock (RN-105).
31. NOT DELIVERED AT THE TIME, declared: per-project lifecycle (provision,
recycle, clean, resource ceiling actually applied), with the agent's
worktree living inside the container. Container state is MUTABLE and
asked for its own table — improvising it in the event log just so as not to wait for the
migration slot would produce the fix right after.
PARTIALLY REVOKED by Wave 4/front F1 of PROGRAMA 28: the state
TABLE (project_containers, ADR 0081) arrived. What is still missing,
without softening, is the half that made the table worth anything: no
real orchestrator calls Docker yet, and the agent's worktree remains outside
the container. The "inside, the agent is free" half of the terminal policy
has NOT CHANGED YET — ADR 0055 keeps holding as it is until the container
actually comes up.
32. COMPLETED AT THE TIME, REVISED by the local runner (ADR 0102/0103, RN-418):
the external-effect boundary. git push, PR and deploy don't go out through the
terminal — not even inside the project scope —, and the rule WAS deny
(not require_approval, because of "always allow"), with the
message redirecting to the TYPED action (git_push/git_merge/
pr_open), which is born a proposed_action (RN-106, revised). The deny
became an ABSOLUTE CEILING (unconditional require_approval, never
auto-approvable) — the product owner's decision, "always allow" closed
at the source so it wouldn't become decorative (see the local runner section). Merge into a protected branch
remains manual (RN-014), untouched. Network and spend have their own verdict in the
image ARTIFACT (network: none/egress, a cpus/memory/pids ceiling
that refuses instead of silently downgrading) — decided ONCE, not
command by command, for the same reason Z and AD proved that a verb
allowlist doesn't converge. Closing Z and AD itself depends on 25b (the
physical wall), which hasn't come up yet.
FASE 26 — COMPLETED: Code, read-only
-
COMPLETED (26a):
GitProviderContractgainedlistTreeand PR diff (11th/12th operation), capability declared ONLY when proven by the suite. -
COMPLETED (26b): the four routes of
code.controller.ts— tree, file, search and diff — behind the CENTRALIZED check of RN-092/095, with a ceiling and cache on the search (which is COMPOSITE, not a contract operation). -
COMPLETED: the screen (
ProjectCodeTab/code/*), registered inproject-tabs.tswithout touchingrouter.tsx/ProjectPage.tsx. Rail (Explorer/Search — the only ones with real data), explorer loaded by DIRECTORY, editor tabs with breadcrumb, real search withfilesScanned/truncated, PR diff by known id (withpatch: nulltreated as "no text", never "no change"). Syntax highlighting is its OWN regex tokenizer (code/highlight.ts) — ZERO new dependency, against the 15-90 KB of Prism/highlight.js/Shiki for what the tab needs; three new color tokens (--syntax-function/ -comment/-operator) calibrated at 4.5:1 against--code-bgin BOTH themes. The fourth state of RN-088 — blocked by the Arquiteto's pending decision (RN-105) — became RN-107, asked BEFORE trying to read code, with its own re-query every 15s while blocked.Pending items DECLARED, with no real data behind them: interactive terminal (FASE 25b, which remains cut — an honest empty state in the tab), blame, list of PRs inside the tab (the diff is only reachable by known id, coming from Approvals) and a Problems/lint/tests panel. Line virtualization was also left out at the time — the handoff itself calls the code tab "the most costly of the program"; the 512 KB per file ceiling (
GIT_BLOB_MAX_BYTES) limited the worst case until then. CLOSED by Wave 4/front E2 of PROGRAMA 28 (RN-239..242): only the visible window becomes a DOM node, and the minimap reuses the same tokenization without a second pass.
FASE 26b — COMPLETED: blame foundation, navigable PRs and a rich branch
The phase started as ONLY the API layer, and the sentence "no UI" that stayed here
described the state of the FIRST batch — it aged and started teaching wrong. The
THREE UIs exist: the rich branch dropdown (item 38, CodeBranchPicker.tsx),
the on-demand blame toggle (CodeEditor.tsx, RN-113) and the list of PRs inside
the tab (CodeDiffPanel.tsx), the last two delivered in v3.0.0. The three
declared pending items of blame,
rich branch dropdown and PR list would touch the SAME files
(contract, the three providers, the use case, the controller, the contract
suite) if attacked separately by agents in parallel; the decision was to
deliver all three together, at once, and leave the UI of each to three
following agents with no risk of collision.
36. GitProviderContract gained blame, listPullRequests and
listBranchesDetailed (13th, 14th and 15th operation — RN-110/111/112),
capability declared only when proven by the contract suite against the
three providers (local against real git; github/gitlab
mocked — the real smokes remain skipped without
GITHUB_TEST_TOKEN/GITLAB_TEST_TOKEN in the environment, same situation as
FASE 13a). listBranchesDetailed is its OWN operation, not an extension of
listBranches (RN-112) — enriching costs an extra call to the provider
PER BRANCH, and listBranches remains the Gitflow bootstrap's,
which doesn't pay that cost.
37. Three new routes in code.controller.ts (GET .../code/blame,
GET .../code/pull-requests, GET .../code/branches), same role: viewer and the same central path check (RN-095) as the four
earlier ones. apps/web/src/lib/api-client.ts/api-types.ts gained the
corresponding functions and types (getCodeBlame, getCodePullRequests,
getCodeBranches) for the next wave to consume — CodeShell.tsx and
CodeDiffPanel.tsx were NOT touched beyond the comment documenting
that the foundation already exists.
38. COMPLETED: the rich branch dropdown (RN-112) — CodeBranchPicker.tsx,
opened from CodeShell.tsx, replaces the plain text field with a
selector listing each branch with ahead/behind relative to the default and the
associated PR, when there is one (aberta/mesclada/fechada). A ref outside the
list (tag or sha) remains reachable — listBranchesDetailed doesn't
enumerate those two, so the dropdown's footer has a manual field.
The program's freeze: each phase declares what it doesn't do, and the hardest is
26's — code READ-ONLY, no saving through the tab. Editing is the next
phase, and when it comes, writing is an external effect: it is born a proposed_action.
POST-PROGRAMA 16–26 — what use asked for afterwards (RN-148)
Same spirit as POST-FASE 15: not a planned phase, it is what USE asked for.
Stories with pending promotion at the same time (RN-048/RN-126) became N
loose cards in the PO session's timeline when it produced several in one
batch. Carousel (apps/web/src/components/ui/Carousel.tsx) is the design system's first
item-by-item navigation component — 2+ pending
become ONE navigable carousel (arrows, dots, keyboard), with "Approve all"
promoting the whole batch in a single call (promoteStories was already a batch,
no contract change); 1 pending remains the usual simple card. The
decision of when a "batch" forms is re-evaluated on each render — the
set of promotions STILL PENDING in the session, not "created in sequence
without interruption" — so that resolving a story in the middle of the carousel
recomputes the batch on its own, with no state of its own to synchronize.
Local or Container project (RN-169/RN-170, ADR 0072). The product
owner's request: "cross the boundary of only writing code in the container and be able to
write code from a user folder", with the freely typed path
variant chosen by him, aware that it only works mounted in the
container. projects gets (workspace_mode, workspace_path) in migration
0043, with container as the default — nothing changes for whoever doesn't choose. ADR
0072 REVISES part of 0065 and touches the ground of 0055, which decided the
opposite direction (the container wall), and so the consequence is written without
softening: the STRUCTURAL containment of join(env, coluna) — no
corrupted column leaving the managed root — ceases to exist for a Local project, and
what remains is the creation guard plus the lexical revalidation on read.
FASE 25b remains cut: a Local project runs in the SAME container as today, only
the folder changed.
The Arquiteto's C4 diagram (RN-149, ADR 0068). New deliverable:
create_c4_diagram generates Context + Container (Simon Brown) in Mermaid,
rendered in the Overview. The Container level is DERIVED from the current
module_map by the use case — never retyped by the model — so as not to open
a second source that diverges from the first; only the Context (system name and
external actors) comes from the tool call. artifact.c4_diagram is an artifact without a
table, versioned in the event log, same design as artifact.project_image
(ADR 0065). mermaid came in as a new RUNTIME dependency of
apps/web — the first of its kind (the docs site already uses Mermaid, but at
build time) — isolated behind apps/web/src/lib/mermaid-render.ts with dynamic
import(); vite build confirmed that only the entrypoint loads
eagerly and Mermaid's heavy chunks stay on demand, and none of them uses
eval/new Function — the closed CSP of ADR 0058 stayed intact, with no
configuration change.
RODADA exp003 — COMPLETED (RN-150..162, PRs #278–#293)
Not a phase: it is 17 points USE found in a live test session
on the exp003 project, fixed in five waves sequenced by
CONTESTED FILE — four of them converging on SessionPage.tsx, which
forced serialization. The origin matters: none of these was found by a test.
What the round taught, worth more than the list: the biggest fix
(RN-155) was invisible to any suite because the defect was in comparing
two DIFFERENT numeric spaces that happen to both be number — the
event.seq (gapless, per SESSION) against the action.seq
(proposed_actions.seq, a global bigserial of the WHOLE table, shared
by every project and every session in the system, and documented in the schema as a
deliberate contrast). The order only seemed right while the table was
small. The fix didn't invent a field: it used the
proposed_action.created event that ProposeActionUseCase already writes in the SAME
transaction, with payload.actionId pointing back.
Engine and contained reading (RN-150) — search_workspace returned ALL
the hits, without a ceiling: the second real cause of the 413 in PR review, after
the read_file fix. Two INDEPENDENT ceilings, because the search
blows up in two ways — number of hits (SEARCH_WORKSPACE_MAX_HITS,
500, over a Stream that STOPS scanning at max_hits + 1) and bytes of the
final text (SEARCH_WORKSPACE_MAX_BYTES, 32,768). Their own variables, not
reused from the other two: same class of overflow, diverging one shouldn't
require touching the others. The truncation mark is addressed to the MODEL
(it tells it to refine the term) and never invents a total, because counting the total
would pay again the I/O the ceiling exists to avoid.
Numbers that lie (RN-151) — the project badge in the sidebar counted
ANY unread event: exp003 showed 392 while the Approvals tab
of the same project showed 8. A number that doesn't correspond to anything actionable
when clicked is worse than none. It became pendingApprovalsCount in the read model
that already existed (RN-090), in one more aggregation in the Promise.all — never
N+1.
Traceability of what the agent produced (RN-152) — the
feature/task-XXXXXXXX branch in the Code tab's dropdown now says which dev
agent and module it belongs to. The module comes from the current module_map through the SAME
functions that generated it, never by reverse regex on the name; without a link,
it degrades to null instead of guessing.
Auto mode (RN-153/154, see Conventions) — the actionType: "*" wildcard.
What matters is where it lives: resolution is the REPOSITORY's (findMode),
and decide.ts didn't gain a line — that is what makes the three absolute
ceilings true by CONSTRUCTION, and not by one more if that one day
someone forgets.
The session thread you can read (RN-155..159) — ordering (above); the
5s indicator with the fixed text "Gathering information..." instead of
interpolating the agent, which already appears in the header (RN-156); the PO's
epic/story creation becoming a compact notice instead of a bubble with the same
visual weight as a real reply (RN-157); light Markdown with highlighting
in the chat by its OWN regex parser, zero new dependency and no
dangerouslySetInnerHTML, and only the agent's OUTPUT is rendered — what the
human types stays literal (RN-158); and "Generated artifacts" grouped by
agent with the Disclosure of RN-138, each artifact navigating to where it
lives (RN-159). The module_map/C4 stayed OUT by recorded decision: it is the project's
CURRENT state, not a session-dated artifact.
Order of authority (RN-160/161, ADR 0069) — "Confirm architecture
ready" only enables with at least 1 PROMOTED story (having a captured rule
isn't enough); and accepting the handoff to the Dev Lead chains the activation of
execution when the effective role of whoever accepts already authorizes it. The fusion is only on the
client: the backend still requires maintainer (RN-137), so it avoids
a redundant click without moving the authorization boundary.
Structured input (RN-162) — ask_structured_questions, the Criativo's:
the model declares the questions in a schema, the form is rendered by
StructuredQuestionCard, and the answer REUSES SendAgentMessageUseCase instead
of opening a second message path. Answering is a single act —
resending is 409, not an overwrite.
RODADA exp001 — the Criativo keeps its promise (RN-163)
Same spirit as the other rounds: it came from USE, not from a script. The report was
"the Criativo didn't reply after saying it would fix it and try again",
and the sentence was literal in the code — run_turn_capturing/1 called the model ONCE,
dispatched the tools and returned. The tool's result went into the
in-memory history and nobody read it again, so the promised fix only
happened if the user sent another message.
The Criativo was the only conversational one without a tool-use loop; it got the same as the
PO, with its own ceiling of 12. What matters beyond the fix: whoever decides what is
announced became the ceiling, and not a fixed text — the retry promise
only enters the sentence when there is still a round to keep it. An exhausted ceiling became
a narrated agent.error (po_server ended silently in that case, and that was the
next obvious debt — closed just below by RN-166), and the tool
failure stopped being an agent.response
— in the event log it was indistinguishable from a normal reply and stated no
origin at all, exactly what RN-059 closed for turn failure.
RODADA exp001 — the PO reads what already exists and is held to account (RN-164..166)
Not a phase: it is a defect found by real USE, and the origin matters because no suite would catch it. The backlog came out with an epic and no story — hence no task, hence execution stuck with no error at all. The investigation found four stacked causes, and the first three are the same: the PO had no way to know and no way to warn.
- The PO had four tools, all of them WRITE tools. The context was
built ONCE, at kickoff, from the last 200 events of the SESSION —
from then on it never re-read anything: it didn't know which rules existed,
which it had already covered, nor what it had itself already created. In came
listar_regras_de_negocioandlistar_backlog(RN-164), served by two internal routes scoped to the PROJECT — and the scope is the point: reading per session was exactly what hid the rule captured earlier. They reuse the plumbing that already existed (listByTypeForProject,ListBacklogUseCase,computeCoverage); what was missing was the route, the client method and the tool. - Nothing demanded the story after the epic and the loop ended silently.
An epic without a story became an EXPLICIT outcome —
backlog.epic_without_story, durable, in the pattern of RN-059 (RN-165) — and thePoServer's iteration ceiling started emittingtoolloop.limit_reached, the SAME type as theToolLoop, which the conversational agents never emitted because they have their own loop (RN-166). - The kickoff instruction didn't say a word about what to do when
information is missing — and faced with a gap without instruction a model chooses
between inventing and stopping. Stopping is what it did. The third way out came to
be written, with the tool to exercise it:
ask_structured_questions, the SAME one as the Criativo's (RN-162), now advertised to the PO too.
Two design decisions that hold beyond this case: only a create_story that
succeeded discharges the epic's obligation (a story refused by the api
covered nothing, and treating it as if it had is trading one silence for another); and the
demand is per OCCURRENCE, reported once, never an alarm that repeats every
turn until someone learns to ignore it.
RODADA exp001 — the thread reads as the turn happens (RN-172/173)
Same origin as the others: USE, not a script. Four complaints about the Session screen — the PO's handoff appearing ABOVE its last line, the approval going up into the middle of the Arquiteto's reply and only coming down when it finished, the approval card "badly laid out" and the scroll that didn't follow the conversation.
The investigation found that the ordering was RIGHT, and that is the round's
lesson. RN-155 is faithful to the event log; what the log says is that
po_server.ex#run_turn/2 emits, in the SAME iteration, the agent.response, THEN
the offer_handoff tool.call and ONLY THEN recurses into the closing line
— so the handoff's seq is honestly SMALLER than the last line's. The same
for proposed_action.created. The fix, therefore, isn't about ordering: it is
a declared PRESENTATION rule (RN-172), in a separate pass
(afundarDesfechos) AFTER the sort by seq — not a comparator with three
terms nobody would be able to justify a year later.
What keeps turns from mixing are three barriers, and each one exists
because of a real case: turn (the boundary between two turns may have no
VISIBLE entry — agent.activated opens a turn and doesn't become a thread item),
author (in an execution session several agents write without the user speaking
even once, and they all stay in the same turn) and non-outcome (two outcomes
in a row preserve the order between them — Infra before the Dev Lead).
RN-173 closes the other two complaints, and they were the same: the scroll
effect depended only on [events.length, streamingText], and actions is a
SEPARATE query — a card arriving pushed the conversation off screen without
scrolling anything. Dependency fixed, plus a ResizeObserver on the content
for what NO dependency list reaches (Disclosure collapse,
Markdown reflowing). The guard of 120px from the end stays intact, on
purpose: the thread follows the conversation, it doesn't hijack the reading of whoever scrolled up.
RODADA exp001 — the thread says who speaks, with which model and what it asks (RN-171/174/175/176)
Same origin: USE. Four complaints about the Session screen, and three of them share the same lesson — the product knew the information and didn't show it.
The question at the center, and with a way out (RN-171). The report was literal: "always
give the user the input option when they select Write". The model
offered a "Write it yourself" kind of option and the form had nowhere to
write — the ask_structured_questions schema couldn't express "besides
these, whatever you want". allowOther came into select with default
true, and the asymmetry is the argument: a list closed by FORGETFULNESS locks the
conversation and the user can't unlock it from outside; a list opened by mistake offers one
more field. The interface sentinel never travels to the backend, and the submit
button still requires everything filled in (the backend refuses with 400 either
way). The box also became centered with the 560px ceiling of
ApprovalCard and got an avatar: it was the only thread item aligned to nothing.
An action that triggers a turn arms the indicator (RN-174). The "thinking" animation
already existed — what was missing was COVERAGE. Two actions on the screen trigger a
synchronous turn in the engine and armed nothing: answering the form
(AnswerStructuredQuestionUseCase reuses SendAgentMessageUseCase) and returning a
story to the PO (ReturnStoryUseCase calls reviseStory, which is
handle_call({:revise, …})). The Phoenix channel doesn't cover the hole: with the join
still in progress (RN-108) the "working" agent.status has no listener and gets lost.
The model, for EVERYONE (RN-175). The premise that the defect was the PO's didn't
hold: the four conversational ones have written modelName since RN-146, with a
green test. Whoever never wrote it was the ToolLoop — the path of every execution
and gate agent — and the chat without an active agent in the api. No new
call: RunLlmTurnUseCase already returned the field in the body. The screen stopped
writing the loose word "model" (which reads as if the model were called
that) and became a readable chip; without the data, it SAYS it wasn't recorded —
guessing from the current binding would attribute to an old reply a model that
maybe didn't even exist, the same error RN-044's frozen price avoids.
A Markdown table becomes a table (RN-176). Two ways out were possible, and the
chosen one was to support tables in the chat's Markdown, with the design
system's Table. Rendering artifact.module_map in the thread was REFUSED by a decision
already recorded: it is the project's CURRENT state, not a session-dated
artifact (RN-159), and it lives in the Overview. Besides, the request was about the
table inside the message — and the fix serves any agent that
writes one, not just the Module Map. The separator line remains
mandatory (GFM), so prose with | never becomes a table by mistake.
RODADA exp001 — the panel groups, orders and says what it doesn't show (RN-177..181)
Same origin as the others: USE, not a script. Five complaints about the context panel and the Session log, and the round's lesson is a single one — the panel showed a slice as if it were the whole, in three different dimensions at once: it hid six event types without offering an alternative, read from oldest to newest, and cut the session at 200 events without saying so.
Origin as a new classification (RN-177). ActivityKind answers "what
SUBJECT the event is about" and decides icon and color; origem answers "what LAYER
it came from", and it is what makes the history readable — eventos, sistema, llm,
harness, agente, usuario, derived from the data that EXISTS (actor.kind and the
type prefix). The precedence is what makes it predictable and is in the order
of the ifs: mechanism beats actor (a tool.call belongs to the harness whoever it is), and
actor beats agent prefix (chat.message exists on both sides). The 5 most
recent stay open and the rest collapse by origin, in both places —
in the log panel and in the thread, where the axis is inverted (the thread is ascending, so the
history stays at the TOP). The machine-noise filter stays on by
default: its reason didn't change (116 of 193 real events), what changed is that it
became a CHOICE instead of a done deal.
Order and pagination (RN-178). The four sections became descending, and the "Load older" button switched sides as a consequence — the argument from before, with the sign flipped. Business rules above 5 paginate, with the page resolved by clamp and not by a synchronization effect.
The PO's tree (RN-179). backlog.task_created never entered the panel;
now epic → story → task form a tree, through the LINK the event already
carries (epicId/storyId), never by proximity in the log — a node without a loaded
parent goes up to the root instead of being hung by guesswork.
The ceiling that started showing (RN-180), and it is the item worth most. The panel
read 200 events by prop and had no way to say there were more. It started reading the
paginated history of RN-099 (same queryKey as the tail, ZERO extra requests
— RN-090/091), got the pager ActivityFeed always had and no call
site passed, and the note counts how many are missing by SUBTRACTION on the seq, like
the bell of RN-100. The derived sections read baixados (everything already fetched) and not the
feed's window, otherwise they would start showing LESS than they showed before.
Delegation in the thread (RN-181). delegation.completed|failed|dispensed only
existed in the log panel: the gate opened and closed with no sign at all that there was
a second attempt underneath. They became a compact notice in the RN-157 format,
with the sentence coming from classifyEvent — the same as the panel's. The area's external
contract doesn't change (ADR 0038): the thread narrates what the lead recorded, it doesn't start
addressing a subagent.
PROGRAMA 28 — Wave 1, front A: the theme stops being unreachable (RN-182..185)
The light theme existed in design/tokens.css, had a parity test and was
described in design/README.md as if it were a screen — and nothing in apps/web
wrote data-theme. The only way to see it was to type the attribute in
DevTools. The two contrast tests knew and said so: one asserted by expect
that THREE light pairs fail, "a record of what is inherited, not a guarantee of what
is rendered". While it lasted, the argument was honest — measuring a screen
nobody can open is measuring an intention. With the theme button arriving, it
flips sign.
The boot is a FILE (apps/web/public/theme-boot.js), synchronous in <head>,
before the bundle. The handoff says to use an INLINE script; the image serves under
script-src 'self' (ADR 0058), so inline would work in pnpm dev:web and
be blocked in production — ADR 0036's failure with another subject. tema.ts
is the API the shell consumes; the button doesn't live in it. A localStorage that throws
degrades to "paints and doesn't persist", and an unknown value falls to the default.
Six light-theme tokens changed value (ADR 0074), because the light theme's most
demanding background is the paper --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,
plus accent-hover one step below. The DARK didn't change any, and its debt
stays locked at the same five numbers. Two side effects recorded: the
five pairs that are debt in the dark PASS in the light, and the "known exception of the
design system" (--on-accent over --accent, 3.20:1) disappears in the light — the
fix the test's comment described, darkening down to --terracota-500, is
what the light theme now does.
Five of the handoff's eight --syn-* values were REFUSED by measurement (the
worst, --syn-cm, gives 2.32:1 in the light). Where the handoff fails, the measured
number holds: it's the same rule as the fonts. The eight roles now exist with the prefix
--syntax-*; the five semantic ones that still paint are measured alongside them.
PROGRAMA 28 — Wave 1, front D0: spend by provider (RN-186..188)
The containment changed nature, and that is what matters — not the new axis.
provider became a dimension of sumGroupedBy again, revising ADR 0063 by
the product owner's decision. Before, the member couldn't reach the breakdown by
CREDENTIAL because it didn't exist — ADR 0063 wrote "there is no argument to
pass". Now it exists, and whoever contains it is the TYPE: two overloads, and the scope that
carries actor (the only one of the member's view) only accepts
Exclude<SpendDimension, 'provider'>. The guarantee went from "impossible to
express" to "impossible to compile", which is weaker — and that is why the
barriers are TWO and independent: the member's route also has no dimension
parameter at all. No if on the combination, on purpose (same lesson as
RN-153/154), and the proof was by INVERSION: without the @ts-expect-error, tsc flags
TS2769 on the exact line.
porOwner/porAgente are a partition of porAtor by actor_kind, with no extra
query; system stays out of both and remains in the total. The index on
token_usage(created_at) that ADR 0063 measured and postponed came in in migration 0044.
The SCREEN didn't change — that's a later wave.
PROGRAMA 28 — Wave 2, front H2: model ranking without an invented score (RN-210, ADR 0077)
The handoff asked for two things that depended on the SAME data the product doesn't
have: a quality score per capability (mock: "code → claude-sonnet-4 /
qwen2.5-coder:14b (9.4)") and an "ideal" badge in ModelPicker when the
model covers "all the capabilities required by the agent". The investigation
confirmed twice that this second data doesn't exist: ModelsSection itself
had already renamed the "Agent · capabilities" column to just
"Agent" for that reason, and the agent-binding ModelPicker reads
GET /projects/:id/models (role viewer) — the curation (uses, ADR 0051)
only exists in ModelComCuradoria, from a maintainer-only route. The badge was NOT
built; the "Best models per capability" table WAS, with two
real signals: how many of the project's agents already resolve, through the cascade, to
each curated model, and the catalog cost as a tiebreaker — never a
calculated score.
PROGRAMA 28 — Wave 2, front B: the sidebar collapses (RN-195..201)
264px/62px with an icon track when collapsed; expandable projects revealing the
tabs (read from project-tabs.ts, never hardcoded); the Activities section grouped
by REAL base agent/instance — the -2 suffix of extraDevAgentId, never an
invented -01/-02 counter (finding of Wave 1/front B0) —, scoped to the
current route's project, not all of them, so as not to open N+1 event reads. Theme button
in the footer; the two inert global items go, only Projects and Activities
are global; auto-collapse of the Code tab via AutoCollapseContext
(sidebar-state.ts), without saving a preference. Persistence centralized in
sidebar-state.ts, the handoff's six brabo.* keys.
Two divergences from the handoff documented and NOT resolved (RN-197): the project
badge remains pendingApprovalsCount (RN-151 wins, it's more recent) and the
handoff's identity color only appears on the collapsed track — the expanded
line keeps only the existing status dot (RN-039), two dots would be noise.
PROGRAMA 28 — Wave 2, front C: screen frame (RN-202/203, ADR 0078)
The four literal fixes of the handoff's "screen frame" checklist:
.headerTop got min-height: var(--header-h) — a FLOOR, not a ceiling, because the
project header (identity + compact TokenMeter) is richer than the
generic header of the handoff's 6 internal screens, and forcing 60px would cut the
budget alert, the same class of error RN-088 forbids; the active tab with
box-shadow: inset 0 -2px 0 var(--accent) instead of border-bottom; horizontal
scrolling on the ruler; a content container with max-width: 1040px on the
document-shaped tabs (not on the semRespiro ones). The ruler's spacing values
that lived as a descendant CSS override in ProjectPage.module.css
since FASE 16 migrated to Tabs.module.css, now with the same owner.
The part that required a decision, not just CSS: the handoff lists 7 project tabs and the
registry (project-tabs.ts) has 10. executores (RN-121), backlog
(RN-048) and insights (Psicólogo hypotheses) were born AFTER the handoff, with
real data and their own RN, and they STAY — the handoff is a VISUAL fidelity reference,
not a ceiling on how many tabs the product has (RN-203, ADR 0078). And the
sessions key remains labeled "Chat", never "Chat RAG" as the handoff's most
recent version asks (RN-202): "Chat RAG" would describe a feature that doesn't
exist — the embeddings contract is ready (ADR 0075) but nothing consumes it yet,
with no indexing pipeline and no citation UI — and renaming the tab
today would be the same error ADR 0042 already refuses for a catalog model:
announcing a capability before it exists. No export SHAPE changed
(AbaDoProjeto, ABAS_DO_PROJETO); only the code label went from "Code" to
"Código".
PROGRAMA 28 — Wave 2, front H1: visible focus (ADR 0036)
Select, Modal (close button) and ProjectCard got :focus-visible
with the same calibrated treatment as Input.module.css, including the
forced-colors block: none of the three had a focus indication reachable by
keyboard alone. The Modal close button went up from 30px to 32px, the desktop
touch-target floor. Table and Badge were audited and did NOT need
change — neither exposes an interactive affordance of its own: a
Table row is pure presentation (whoever needs a clickable row already uses
<button>/<a> inside the cell, via render) and Badge is not used with
onClick anywhere in the product.
PROGRAMA 28 — Wave 3, front H3: the Criativo tab gets KPIs without faking data that doesn't exist (RN-227..230)
The handoff asks for 4 status seals for the session's 5 real states
(created/active/closing/closed/closed_abnormally). closed_abnormally→
aborted and created→waiting are direct; closing was NOT merged with
"closed" — it gets its own seal ("closing", accent tone, pulsing),
because in closing the outcome isn't known yet and calling it "closed" would
lie about that. The pill filters (only 4, for the same reason) group
created and closing by TRAJECTORY, not appearance: created falls in
"Active" (still in play), closing falls in "Closed" (on the way to closing
without error) — the row's seal is never rewritten by the filter, only the grouping
changes. Of the 4 KPIs, two are honest about missing data: "cost this month"
REUSES getMySpend (same queryKey as the Spend tab, the
member's view/RN-101) and never shows the project total — which is the owner's data
(porProjeto), and the Criativo tab is seen by any member; "ideation
→ commit rate" is DECLARED absent (shows "—"), because there is no link between
session and commit in the product today — inventing the calculation would be the same class
of error ADR 0042 already refuses for a model score.
PROGRAMA 28 — Wave 4, front G2: the RAG Chat gets an indexing pipeline and hybrid search (RN-231..238, ADR 0080)
On top of the chunks table from Wave 3 (ADR 0079): MANUAL indexing (no
watcher, POST .../rag/reindex, idempotent full rebuild — deletes the
scope/session before recreating) of the three honest scopes — docs/adr
via ReadProjectCodeUseCase (same credential/gate/path check
as the Code tab) and session only from chat.message/agent.response. Chunking
by heading/paragraph, 1200 characters with 150 of overlap —
documented as a starting point, not a calibration against real data (there
isn't, yet, a body of questions run against the index). Hybrid search
is TWO independent queries (vector via HNSW, lexical via GIN),
fused by a weighted sum (0.6/0.4) and cut at a threshold (0.2) —
numbers that are also a starting point. When the embedding provider (fixed:
ollama) doesn't answer, the pipeline writes the chunks WITHOUT a vector and declares the
gap in the report — it never fakes complete indexing, and the search degrades
to lexical-only with a warning. The screen (RAG Chat as its own tab) belongs to Wave
5; this front is just the backend.
PROGRAMA 28 — Wave 4, front F1: the container lifecycle becomes a table, without an orchestrator (RN-243..248, ADR 0081)
See item 31 of FASE 25 (PARTIALLY REVOKED): project_containers
(migration 0046) writes the mutable STATE — provisioning → running ⇄ stopped, failed reachable from all three, removed only leaves by reprovisioning
— distinct from artifact.project_image (the Arquiteto's immutable
DECISION). Confirmed by investigation (grep, zero occurrences):
NO service of the product mounts /var/run/docker.sock nor runs
privileged — both use cases only WRITE and READ, no real call
to Docker. The first transition requires the image already decided (RN-105)
and FREEZES version/resources in the row; a local project (ADR 0072) is
refused, because it doesn't bring up its own container. The resource ceiling
(cpus/memory_mb/pids_limit) is DECLARED, not applied — no
screen asserts "the container is limited to X", only "the recorded intention
was X". No new HTTP route at the time: without a real consumer yet, exposing
one would be guessing a contract — closed by Wave 5/front F2 just below.
PROGRAMA 28 — Wave 5, front G3: the RAG Chat gets its own tab (RN-252..254, ADR 0082)
The screen that RN-202 (Wave 2/C) had postponed — "Chat RAG" would describe a
capability that didn't exist yet — arrives together with the backend of Wave
4/G2: key: 'rag', a NEW tab (ordem: 28, between Code and Backlog),
NEVER renaming sessions. The distinction stays the same, only the reason
changed: sessions is a conversation with an ACTIVATED agent (spends the owner's key
per turn, RN-058); rag is a search over an already built index, with no
agent at all in between, read-only by nature. The screen consumes the three
contracts of ADR 0080 without guessing shape and shows the two honest
degradations the backend already declared and no screen read — vectorAvailable: false becomes a visible warning, and the coverage never invents "reindexed
Xmin ago". A citation with session origin navigates to the exact EVENT, reusing the
mechanism the Psicólogo's evidence chips already use; file origin
shows path/heading as text, without a link — the Code tab has no
deep link by path today, out of scope here.
PROGRAMA 28 — Wave 5, front F2: the real consumer of the container lifecycle (not the terminal) (RN-267/268, ADR 0083)
The original plan for this front was the complete interactive terminal. The
investigation confirmed that FASE 25b remains cut: no service
mounts /var/run/docker.sock, and even after ADR 0081 nothing in
production transitions project_containers — there is no real container
to open a terminal INSIDE it. Implementing a faked terminal, or one
running in the same container as Brabo's monorepo (the debt ADR 0055
already describes as policy, not isolation), would invent capability — the
same error ADRs 0041/0042 refuse for an LLM provider and a catalog
model. The real delivery: GET /projects/:projectId/container/lifecycle
(role:viewer), the first HTTP exposure of the lifecycle (revises ADR
0081, which had postponed the route for lack of a consumer), and the Terminal tab
showing that state — translated badge, failure reason — UNDER the
explanatory text that already existed, never in its place, fetched only while the
tab is open. null (never provisioned) is the honest and
most common result today.
PROGRAMA 28 — Wave 5, front I: social login revokes the prohibition from the ADR 0031 backlog (RN-272..283, ADR 0084)
Explicit request from the product owner, aware of the security
consequences — the prohibition goes away only for GitHub/GitLab; MFA, OIDC provider and
generic federation remain out of scope (see "What NOT to do").
SocialLoginCallbackUseCase ends in the SAME EmitirSessaoUseCase as the
password login — no second session format. It reuses the existing OAuth
client, but the state has its OWN purpose
(purpose: 'social_login', checked BEFORE any other field):
it never accepts the state of the git CONNECTION flow to a project, which
would be privilege escalation. New table social_identities (migration
0047), key (provider, provider_user_id) — never email/login.
A three-step decision: known identity → login; email matches an
existing account AND verified by the provider → links (and verifies the
account's email if it wasn't yet); email matches but NOT verified →
refuses — a typed email isn't proof of ownership, accepting it would open
account takeover; no matching account → provisions a new account without
a password, same "pending" state as the Keycloak migration. It reuses the SAME
OAuth app as the git connection — zero new environment variables, only
different redirect_uri/scope per flow; the OPERATOR's action of
registering the second callback in the provider is what justifies the
breaking/ branch.
fluxo.yml × code audit — the Dev Lead's plan becomes a real approval (RN-284, ADR 0086)
Not a planned phase: it closes the divergence a read-only audit of
docs/fluxo.yml × code found (finding A2,
docs/explanation/auditoria-fluxo-vs-codigo.md) — the flow already declared the
dev-lead's plano-de-paralelismo output as via: proposed_action
since ADR 0085, and the code was never adjusted to match. The product
owner decided the code was wrong.
propose_execution_plan entered decide.ts (minimum role
maintainer), DELIBERATELY outside the absolute ceilings block — it can be
configured to auto-approve, unlike
parallelize/raise_max_parallel. The Dev Lead is the FIRST conversational
agent (they all run a synchronous turn via GenServer.call of up to
180s) to suspend waiting for a human decision — the pattern already existed for
the dev agent (ADR 0052) and the QA/Infra gates (ADR 0057), but those two are
triggered by cast and never had to deal with a pending synchronous from.
TurnoAssincrono.tratar_resultado/2 gained a branch: when the
:aguardando_aprovacao key is present (checked by VALUE, not by presence —
the Dev Lead carries it as nil since init/1), it replies to from the same
way and at the same time as always, but emits only agent.status: awaiting_approval instead of agent.done — the turn hasn't ended.
LiveBroadcast.agent_status/4 got the new status in its guard. The resumption
uses the SAME Engine.Dev.Wake that QaLeadServer already reuses for the
QA subagents (delivery is per AGENT, not per type).
Accepted gap, declared: without its own state table, an engine restart
while the Dev Lead is suspended loses the subscription — the decision
remains recorded and visible in Approvals, but it doesn't narrate the outcome
on its own. Closing this would require the same persistence mechanism as ADR
0052; out of scope for this fix, which only aligns the behavior with what
docs/fluxo.yml already declared.
UX Designer — the fifth conversational agent, brought forward (RN-285..287, ADR 0087)
It's not a triggered separation trigger — docs/fluxo.yml always declared
"when the MANAGED project has its own interface to design" as the
criterion, and it didn't fire: the design system remains static input.
A CONSCIOUS decision by the product owner to bring the role forward anyway.
Engine.Agents.UxDesignerServer mirrors DevLeadServer — a GenServer per
session, ceiling of 14 iterations, activated by an accepted handoff addressed to
"ux-designer" through the already existing GENERIC mechanism (no line changed in
ActivateAgentUseCase). SOLO: no area, no subagents. The kickoff reads the
most recent artifact.product_brief — the SAME "business need"
the Criativo produces, without a new artifact — and the design system
(design/tokens.css, design/COMPONENTS.md) is DESCRIBED in the
agent's identity (Engine.Harness.Agents), static text: the conversational agents
have no tool to read repository files.
propose_prototype is the ONLY tool (personas, jornadas,
prototipo with telas/anotacoes, resumo). It writes
artifact.prototipo_navegavel without a dedicated use case in the api —
unlike choose_project_image/create_c4_diagram, which need one
because they have content DERIVED from another artifact or a shared domain
refusal; neither reason holds here, so the shape validation
lives in the engine (ArtifactSchemas) and the write uses the generic
append_event_returning the api already exposes, the same path as artifact.product_brief. One
single artifact, two handoffs about it — to "po" and to "dev-lead" — never
a second artifact for "spec-visual": the prototype (screens + annotations) IS the
visual spec, and duplicating it would risk the two copies diverging later. It reuses
the half of ADR 0086's design that survives without its suspension:
a successful propose_prototype ends the turn, so the model doesn't propose
again and duplicate the artifact — without the "aguardando_aprovacao", because proposing
a prototype has no external effect.
apps/web/src/lib/agents.ts got the entry (color: var(--accent), the
least reused semantic token of the roster; icon: PencilIcon).
uxDesignerActive entered the roster in BOTH sources RN-090 requires in
sync — the team panel (agent-status.ts) and the dashboard card
(projects-summary.repository.ts, the same infraActive query
widened, no new query).
Out of reach, declared: teste-de-usabilidade requires a real human
user — no agent replaces that. metricas-de-uso remains a gap even
with analytics active (ADR 0089): the funnel report measures
session→commit→PR→merge, not feature adoption by the FINAL users of the
project Brabo builds — "evidence of adoption per feature" is
DECLARED as a metric with no path to exist today, not a pending item to
close next round.
Staff — code ready, dormant for automatic triggering (RN-305/306, ADR 0088)
Not a planned phase: docs/fluxo.yml declares the Staff/Principal Engineer
as status: planned since ADR 0085 ("contract ready, activation
decided, waiting for a trigger"). A CONSCIOUS decision by the product owner:
bring the CODE forward knowing the AUTOMATIC trigger (the Anamnese
noticing a RECURRING systemic problem) won't fire — the Anamnese
is paused (ANAMNESE_ENABLED=false, decision of 2026-08-10). Dormant
for automatic triggering, not for MANUAL invocation.
The sixth solo conversational agent (Engine.Agents.StaffServer), alongside
Criativo/PO/Arquiteto/Dev Lead/UX Designer, mirroring the Arquiteto (bounded
loop, ceiling 14) with two differences: NO kickoff/1 (there is no session
artifact to summarize — it comes up and stays idle until the first user_message,
which is how whoever addressed the handoff explains the problem) and activated by the
GENERIC path of canActivateAgent (an accepted handoff addressed to
"staff"), without entering USER_STARTED_AGENTS — investigation confirmed that
no domain change in the api was necessary, because
assertHandoffTargetAllowed only refuses an area subagent and the Staff has no
area. The only tool, propose_rfc (problem, options with trade-offs,
recommendation, disposable PoC), writes artifact.rfc_staff DIRECTLY via
append_event_returning — the same tableless pattern as emit_insight, and
not that of artifact.c4_diagram (which derives the Container level from the
module_map in the api) — and returns the handoff to the Arquiteto in the SAME tool call,
without a proposed_action (recording a document is not an external effect).
staffActive entered the team panel roster and the dashboard card
(ProjectCardSummary.roster), same criterion as infraActive, so as not to
open the divergence the RosterFacts comment already warned about.
Declared, not hidden: SessionPage.tsx/AGENTES_DE_CHAT were not
touched — the same pattern already accepted for infra (a REAL and ACTIVE lead
also outside that list). The end-to-end usage path today is the internal
route (POST .../agent/message, agent: "staff"), not the Session
screen; the generic "manual handoff to an agent of choice" UI remains in the
backlog.
The implementavel gate active — QA strategy as the qa-lead's second moment (ADR 0090)
A conscious decision by the product owner to bring forward the trigger
docs/fluxo.yml already foresaw ("when the implementavel gate activates") — the
trigger HERE is the very work of building the mechanism, not a usage symptom
waiting to happen. docs/gates.yml, implementavel:
status: planned → active (owner dev-lead, severidade: warn
untouched — the registry already said "it's born warn even when it activates").
QA strategy stops being a proposto role: it is the qa-lead ITSELF, in a
second MOMENT — same process, a deliverable separate from the PR verdict,
exactly as docs/fluxo.yml already declared ("it may be the qa-lead itself
in a second MOMENT, not necessarily a new agent: the separation is by
deliverable"). Engine.Gates.QaLeadServer.run_design/3 is a NEW and ADDITIVE entry
point (without touching run/2, the usual path, tied to
DevAgentState.find_by_task_id), triggered by
Engine.Gates.Dispatcher.run_qa_estrategia/3 (the same test-swappable indirection
run_qa/2/run_secops/2 already use).
Engine.Gates.QaEstrategiaAgent is a STATELESS module (not a GenServer),
the same shape as QaPerformanceSegurancaAgent — a registry without Terminal
(ReadFile, SearchWorkspace, EmitPlanoDeTeste) —, but the CONTEXT is
different: Engine.Gates.QaEstrategiaContext.fetch/3 fetches ONLY the story (from
list_backlog) and the current module_map (from get_infra_context, reused only
for the moduleMap field — zero new route), without dev_state or
worktree_path, because the gate runs PRE-DEV: there is no dev agent, worktree
or task_id yet. None of the three tools goes through the
ActionPipeline (only terminal/write_file do), so this agent
NEVER suspends — run_design/3 runs synchronously inside its own
handle_cast. The iteration ceiling stays at 8 (conversational), not 60
(gate) — ON PURPOSE: with no token_budget_micros underneath (there is no task,
PRE-DEV), the same reason infra-workflows stays at 8 even while using a
tool (RN-085). The deliverable (emit_plano_de_teste: synthesis,
executable criteria, a GENERIC automation strategy with no framework)
becomes artifact.plano_de_teste in the event log of the calling session.
assess_implementability, a new Dev Lead tool, reads the most recent
artifact.plano_de_teste of the story in its OWN session's
history: with no plan yet, it triggers the assessment and returns an error asking for a
retry (a tool error is loop INPUT, not the end of the line —
RN-163); with a plan, it proposes the opinion (implementavel/inviavel +
justification, plan embedded in the payload) as a proposed_action, the SAME
three-outcome pattern as propose_execution_plan (ADR 0086) —
maintainer, DELIBERATELY outside the absolute ceilings block of
decide.ts.
appsec stops being proposto — the SecOps second moment comes in the
next section.
appsec gets the secops second moment (RN-360/361, ADR 0090)
docs/fluxo.yml (id: appsec, camada_seguranca) declared in advance
"same pattern as QA: two MOMENTS, not two agents for now" — a
conscious decision by the product owner to bring the activation forward, without waiting for the
implementavel gate (front qa-estrategia, same conceptual ADR 0090) that the
registry itself cited as the trigger.
Engine.Gates.SecOpsAgentServer.run_design/2 runs in the SAME process as the
PR secops (same Registry key), without any Diff/Scanner/
DevAgentState: it fetches the story from the backlog + the current module_map
(Engine.Gates.AppSecContextBuilder, without dev_state/worktree_path) and
calls Engine.Gates.AppSecAgent.run/3 — a STATELESS module (not a GenServer,
the same shape as QaPerformanceSegurancaAgent), a tool registry WITHOUT
Terminal, running a STRIDE-lite checklist via ToolLoop.run/1 over the
story's DESIGN, never over code. It ends by emitting artifact.threat_model
and creating a handoff to the three declared leads (arquiteto, dev-lead,
infra — the addressable AGENT of the id area-infra, RN-361).
Declared gap, not a bug: run_design/2 is INVOKABLE, but nothing invokes it
on its own yet — the natural trigger is the Dev Lead's assess_implementability
(front qa-estrategia), out of scope for this delivery, which was kept
self-contained (decide.ts/docs/gates.yml/dev_lead_tools.ex untouched).
analytics/delivery-metricas become a report (RN-320..322, ADR 0089)
A conscious decision by the product owner to BRING FORWARD two roles of the
target model (docs/fluxo.yml, status: proposto) without waiting for the
organic trigger each one already declared. The shape is the one the flow itself
prescribed: analytics "absorbed by medicao" until a PRODUCT metric
becomes mandatory PO input, delivery-metricas "never becomes an agent —
it becomes a REPORT of medicao". Both become a single SCRIPT —
apps/api/scripts/analise-funil.ts (pnpm --filter api analise:funil -- --projeto <uuid> [--json]), in the SAME format as medir-execucao.ts
(Fase 13b): a pure read via Drizzle, zero writes, no GenServer, no
LLM agent.
It measures FOR REAL, over proposed_actions.execution_result of the dev agent's three git
actions (git_commit/pr_open/git_merge, only status: 'executed'): a real funnel (how many sessions produced a commit / opened PR /
merged PR, and the conversion between stages — it counts SESSIONS, not actions), real lead
time (first commit to the session's first merge, by the EXECUTION's
updated_at, not the proposal's) and real deployment frequency (merge into a
PROTECTED_BRANCHES branch, per day, cross-referencing the
backmerge gate of docs/gates.yml — whose evidence is CI, out of reach of
a script that only reads the database).
Three metrics stay in a "Not measured, on purpose" section — a
PERMANENT declaration, not a gap to close next round: the complete product
funnel ideation → commit (sessions without storyId, RN-230, would require
a new schema — no migration in this front); evidence of adoption per
feature (it's not data that is missing collection — Brabo doesn't instrument the
projects it BUILDS, with no path at all for that telemetry to
exist today); MTTR and change failure rate (they require a real production
INCIDENT signal, the same dependency as secops-runtime/platform —
another front).
secops-runtime as a report script (RN-375..377, ADR 0091)
A decided bringing-forward by the product owner of the secops-runtime role
(docs/fluxo.yml, camada_seguranca, previously proposto), without waiting for the
declared trigger ("production with real traffic, after DEPLOY_ENABLED +
platform active"). Only what the trigger does NOT require comes in: pnpm --filter api relatorio:seguranca-runtime reads rate_limit_hits — the data that the
ADR 0027 RateLimitGuard already writes, including under dev/CI traffic — and
produces a ranking of buckets (user:<uuid>/ip:<endereço>, the only two
formats written; no route or reason, which the table never stored) and a
temporal distribution in fixed slices. It's a SCRIPT, not an LLM agent nor a
GenServer: there's no decision to make about the data, only aggregation.
Automatic incident detection, incident response and security
postmortem remain OUT — they depend on the same trigger that didn't fire — and
the report lists them in a PERMANENT "not measured" section, without simulating an
example incident nor inventing a number (same principle as ADRs
0041/0042/0077). The retained window is short by design
(DomainGaugesCollector.pruneRateLimit deletes 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.
platform gets a first delivery: an on-demand telemetry report (RN-385/386, ADR 0092)
A conscious decision by the product owner to bring forward part of the platform role
(docs/fluxo.yml, camada_plataforma) — whose status remains planned and
whose ativacao remains synchronized with DEPLOY_ENABLED, which doesn't exist.
pnpm --filter api relatorio:telemetria [--projeto <uuid>] [--json] is a
SCRIPT (not an LLM agent, not a GenServer) that reads, on demand, the SAME
questions DomainGaugesCollector already collects for the
Prometheus scrape — active/closing sessions per project, blocked tasks per
project, state of the last backup (always GLOBAL, never per project, because the
product backs up the whole database) — and ends after printing. The
SQL queries are REPLICATED, not imported from the collector: its methods are
private and end by writing to a Prometheus gauge, with no pure "just
the query" half to reuse without coupling a CLI script to the lifecycle of an
@Injectable.
The output always includes "where to see more" (the versioned dashboards in
deploy/k8s/observability/dashboards/*.json, the alerts in
deploy/k8s/observability/alerts/brabo-alerts.yaml, docs/runbook.md #observabilidade, pnpm dev:obs for local observability) — a link, never
duplication — and a "not measured" section that DECLARES, without inventing: no
numeric SLO is defined in the product, a postmortem depends on a real incident that
didn't happen, and telemetry back into the product in a closed loop is what
would make platform active — the trigger (DEPLOY_ENABLED) remains absent. The
role's gate_saida: { id: operavel, status: planned } doesn't change;
docs/fluxo.yml only gets a nota on the telemetria-consolidada output
saying the manual/on-demand version is already real.
dbre becomes two mechanical scripts (RN-400..403, ADR 0093)
Of the fluxo.yml dbre role, only two of the four deliverables depend on
real data volume (plano-de-capacidade, tuning — they remain a gap,
with no deadline). The other two don't need a real load: parecer-de- migracao is pattern recognition in SQL text
(apps/api/scripts/lint-migracao.ts, scans apps/api/src/db/migrations/ *.sql, flags DROP TABLE/TRUNCATE/DROP COLUMN/ALTER COLUMN ... TYPE/ADD COLUMN ... NOT NULL without DEFAULT, != 0 if it finds something — today
it finds 3 occurrences in migrations already merged and accepted, informative, not
fixed in passing); backup-restore-testado was already real since
Fase 5 (CronJob + backup_runs + make test-restore executed, RTO
~40s) — all that was missing was an on-demand report (relatorio:backup, the same
read as DomainGaugesCollector.collectBackup()). Neither of the two
goes into CI yet: the linter scans the whole repository, not the diff of
a PR, and would become a gate that fails a PR for a finding that isn't its own — see
ADR 0093 for the scoping technique (pr-police.ts) when that changes. The
"one migration per wave" rule (meta/_journal.json) stopped being
called the "mechanized version" of this role: it avoids snapshot
conflicts between agents in parallel, a concern orthogonal to "this SQL
has a risky pattern".
fluxo.yml × code audit — Wave 1: fluxo.yml/gates.yml up to date
The first of the six waves of the audit plan (section D,
docs/explanation/auditoria-fluxo-vs-codigo.md) to close — Waves 3, 4 and
5 had already been brought forward out of order (analytics/DORA, the
implementavel gate, QA strategy/appsec); this one is only metadata, no ADR, no
line of production code.
docs/gates.yml (paralelismo-autorizado): status: planned → active,
with evidencia in the SAME format as the neighboring implementavel gate —
event_types: [proposed_action.created/approved/denied], filtro: { actionType: parallelize }, onde: request-parallelization.use-case.ts (finding A1/B5 — fluxo.yml already
said ativo since ADR 0053/FASE 14d, only gates.yml lagged behind).
docs/fluxo.yml got three more fixes from the audit, all of citation
or label, none of behavior: the dev's state machine went
from 4 to 5 states (awaiting_approval came in, Fase 12e/ADR 0052 — A3); the
RN-160 citation left the Arquiteto's backlog-promovido input and went
to where it really applies, the handoff-duplo output (the
"Confirm architecture ready" button it locks — A4); the RN-161 citation
left handoff-duplo and became a note on the Dev Lead's input (it is the step of
ACCEPTING the handoff that chains execution activation, not the double handoff
itself — A5); and the origin of the dev's worktree-por-agente stopped saying
harness (Engine.Dev.WorktreeManager is not one of the 4 components of the
harness role — A8). docs/explanation/backlog.md was also
updated: the line about the outdated gates.yml left the table of
pending items (closed here), together with the implementavel gate (B3, already
closed by ADR 0090 before this wave, without anyone having removed it from the table)
and the part of B7 (deployment frequency/lead time) that analise:funil
(ADR 0089) already actually delivers — MTTR/change failure rate remain
out, but as a PERMANENT gap already declared in fluxo.yml, not an
engineering pending item.
Verification: pnpm --filter api validacao:gates -- --sem-banco (valid
registry, no ativo-sem-evidencia) and pnpm docs:check green. Phase 3 of the
script (real evidence in the event log) requires --projeto <uuid> against a
database with data — out of reach of this fix, which is metadata only.
Pending items of the same plan, not of this wave: Wave 2 (Dev Lead →
dev delegation, RN-160 revalidated in the backend) and Wave 6 (the necessidade-validada gate,
which requires an ADR and a product decision before coding).
fluxo.yml × code audit — Wave 2: RN-160 in the backend and Dev Lead → dev delegation (RN-404/405, ADR 0094)
The second of the six waves of the audit plan (section D), the TWO items of
largest scope after Wave 1 — the other four (3, 4, 5 brought forward out
of order; 6 still pending, requires an ADR and a product decision). Both live
exclusively in apps/api; no change in engine/web was
necessary.
B6 — RN-160 revalidated in the backend. The rule ("Confirm architecture
ready" requires at least 1 promoted story) was only guaranteed on the CLIENT
(SessionPage.tsx, hasPromotedStory) — a direct HTTP call to
POST /agents/arquiteto/handoff-infra ignored the rule entirely.
OfferInfraHandoffUseCase.execute now queries
StoryRepository.findByProject and refuses with BadRequestException BEFORE
writing the event or calling the engine, when no story has
status !== 'draft'.
B1 — the Dev Lead → dev delegation becomes data. ADR 0053 (item 5) already
foresaw this and declared it out of scope at the time — CLAUDE.md listed it as a
reversible cut. Closed here: AcceptParallelizationUseCase.execute
(API side, covers both the direct AND the approved-for-free paths, because both already
converge on it) writes delegations with area: 'dev', leadAgent: 'dev-lead', subagent = the id of the freshly activated dev-<modulo>. Unlike
QA/Infra (which write on the ENGINE side, because that's where the subagent produces
an opinion), status: 'completed' was REDEFINED for this area: it means
"the delegation was CARRIED OUT" (the agent came up), not "opinion issued" — and
parecerArtifactId points to the project's most recent artifact.module_map
(via SessionEventRepository.listByTypeForProject, an already
existing method, no new query). Without a module_map in the project — it shouldn't
happen —, the delegation isn't written with a fake id: it only logs the
unexpected state and moves on, because the dev agent's activation is already a success when that
write is attempted; a failure in RecordDelegationUseCase also doesn't bring down the
activation, for the same reason. Decision recorded in ADR 0094, which also
revokes the cut of ADR 0053 item 5.
docs/fluxo.yml (dev-lead, output delegacao): status: lacuna →
status: ativo, regra: RN-405.
fluxo.yml × code audit — Wave 6 (last): the necessidade-validada gate (RN-406, ADR 0095)
It closes the LAST of the six waves of the audit plan (section D,
docs/explanation/auditoria-fluxo-vs-codigo.md) — all six closed: 3,
4 and 5 had been brought forward out of order (analytics/DORA, the
implementavel gate, QA strategy/appsec, ADRs 0089/0090); 1 and 2
closed in the previous PRs (fluxo.yml/gates.yml up to date; RN-160 in the
backend + Dev Lead → dev delegation). This is the 6th: the
necessidade-validada gate (Criativo → PO), declared proposto since ADR
0085 with no mechanism at all behind it.
modelo-de-time.md already recorded why it stood still: the Criativo (the
model) deciding on its own that the need it produced itself is
validated would be self-validation, not a real gate. The product owner's
decision was a SEPARATE human confirmation — a third button in
SessionPage.tsx, "Confirm need validated", in the SAME pattern as
"Confirm architecture ready" (RN-160): a dedicated route
(POST .../agents/criativo/validate-necessity), a dedicated use case
(ValidateNecessityUseCase), its own event (necessity.validated).
It never reused confirm_readiness/RN-142 (which remains just the
structural floor, "≥1 captured rule") nor the PO's acceptance of the handoff
(AcceptHandoffUseCase, structural, without judgment of content).
The chaining chosen: the button only enables AFTER confirm_readiness
has consolidated the artifact.product_brief (hasProductBrief) — it makes no
sense to "validate" a summary that doesn't exist yet, and it is the most
consistent reading of docs/fluxo.yml (the gate is the Criativo's OUTPUT, the
moment its work has already delivered a concrete artifact).
Unlike OfferInfraHandoffUseCase, this confirmation does NOT signal the
engine — the Criativo→PO handoff already happened inside
confirm_readiness itself, so the new event is only the record of MERIT, with no
trigger at all waiting for it.
docs/gates.yml gets the necessidade-validada gate: active,
aprovacao_humana: true, but severidade: warn — nothing in the product today
CHECKS the passing of this gate before letting the PO proceed, unlike the
block ones of story-promovida/plano-de-adocao, which have a real code
lock behind them. docs/fluxo.yml (criativo, gate_saida): status: proposto → status: ativo, regra: RN-406.
fluxo.yml × code audit — B4 (the LAST pending item): the PO reads product metrics (RN-407)
It closes item B4 — the last line of the table "Team model backlog
(ADR 0085)" in docs/explanation/backlog.md; the table is EMPTY after
this fix, and the fluxo.yml × code audit ends here. No new
ADR: it follows the same pattern already established by RN-164 (agent read
scoped to the project, no external effect, no proposed_action), just with
a new RN. docs/fluxo.yml (role po, input metricas-de-produto):
status: lacuna → regra: RN-407.
The DATA already existed since ADR 0089 (analise:funil — real funnel
session → commit → PR → merge, real lead time, real deployment frequency);
all that was missing was for the PO to be able to READ that report within the turn. The PO's third
read tool, listar_metricas_de_produto
(GET .../product-metrics), the exact same design as the two siblings of
RN-164 — :direct, no parameter, scope closed to the project. The pure
CALCULATION functions and the query (calcularFunil/calcularLeadTimes/
leadTimeMedioMs/deploymentFrequencyPorDia/buscarAcoesGitDoFunil)
migrated from apps/api/scripts/analise-funil.ts to
apps/api/src/application/services/funil-metrics.ts — a MECHANICAL and
necessary refactoring (scripts/ can't live in src/, and src/ doesn't
import from scripts/): the script started to RE-EXPORT from there, without changing
signature or behavior, and the script's regression test stays
green without being touched. The JSON body has no field for the three permanent
absences the script already declared only in text (complete product funnel
ideation → commit, evidence of adoption per feature, MTTR/change
failure rate) — the PO's tool cites them by name in the TEXT it returns
to the model, always, so it never concludes from the omission of the numbers that
there is no gap.
GOVERNANCE.md — the criterion of the community mode, not its mechanism
An item of the previous backlog ("Community mode of approval-ladder") became a
record correction, not an implementation: investigation confirmed that the community mode
already exists in code since FASE 6 (scripts/ci/approval-ladder.ts), tested
on both sides, only turned off by APPROVAL_MODE=solo (default). The backlog's
sentence ("it becomes a change of aprovacao_humana in the gates registry") came from
a SPECULATIVE line of ADR 0054 — not edited, by rule, but the imprecision
is recorded in docs/explanation/backlog.md: the aprovacao_humana of the
aprovacoes-da-escada gate is already a fixed true in both modes, with no field at all in
the Gate schema that reacts to APPROVAL_MODE.
What was really missing was another TODO(humano), in
docs/explanation/branching-policy.md: the criterion for who goes into each
approvers list (devs/po/gestão) — who comes in, who leaves, based
on what. GOVERNANCE.md (repo root, new) closes that: the MECHANISM stays
in branching-policy.md (the ladder, distinct people, the step by step of the
variable switch), the CRITERION now lives in GOVERNANCE.md. It's a proposal,
not a functioning committee — the project still has a single maintainer, and the
document is explicit about it: most of it describes what comes into
force WHEN there are more people, not a structure already active. A new warn rule
in the docmap (governanca) demands a review when approval-ladder.ts/
branching-policy.md change — separate from the existing block rule
(politica-de-branches) for the same reason checks-e-rulesets is separate
from it: MECHANISM follows from the code, CRITERION is a human decision.
Real SMTP in MailSender (RN-408, ADR 0096)
It closes the backlog item open since the Keycloak cutover (ADR 0032, "real SMTP
remains future config"). nodemailer over pure SMTP transport
(a LINE protocol with state/MIME/STARTTLS/AUTH, unlike the HTTP
JSON APIs the rest of the product integrates), behind MAIL_TRANSPORT — log
(default, INCLUDING in production: sending real email is the operator's
opt-in) or smtp, selected by useFactory in AuthUseCasesModule
without touching any use case (RegisterUseCase,
RequestPasswordResetUseCase, LoginUseCase, the
migrate-keycloak-users.ts script keep just injecting MailSender).
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 ones have, so the rule only kicks in when the operator opted
for smtp; the validation runs in the constructor of SmtpMailSender, exercised
by the useFactory when the provider graph is assembled (the same design as
CREDENTIALS_MASTER_KEY), not by an eager call in main.ts. Body in
PLAIN TEXT, never HTML — the port carries no structure for a rich body, and
a template engine would be injection surface with no gain at all.
Investigation found a real gap before coding: email_verification had
no web route at all — verificarEmail existed in
apps/web/src/lib/auth.ts since Fase 7a with NO caller, so the
verification email's link would lead nowhere as soon as SMTP
started sending real email. /verificar-email
(VerifyEmailPage.tsx) closes that, mirroring SetPasswordPage.tsx — the same
validateSearch pattern in router.tsx, the same single response for a
nonexistent/expired/already used link — with one difference: no form, the
confirmation fires on its own on mount, so the screen needs the three
states of RN-088 (loading/error/success), not just two.
"N agents online" on the dashboard — LIVE status, never historical presence (RN-409, ADR 0097)
An item of the previous backlog closed. Prior investigation confirmed there
was no liveness aggregate at all, neither per project nor per workspace — only
HISTORICAL presence (RosterFacts, "has appeared at some point") and real
live status only on the client, derived from the event log, only when a project
is OPEN. ProjectCardSummary.onlineAgentCount adds two mechanisms by the
SAME rule ("not idle, not stuck"): dev agents
(engine.dev_agent_states.status NOT IN ('idle', 'idle_tripped'),
aggregated in batch in the engine) and conversational agents (the last agent.status
of the most recent session with status !== 'idle', a payload that only accepts
working/idle/awaiting_approval). QA/SecOps never count — they don't emit
agent.status, a single verdict per invocation, with no notion of "idle" between
calls.
The architecture decision worth recording: the query reads
engine.dev_agent_states DIRECTLY, via raw SQL batched by workspace, inside
the SAME Promise.all that already adds up eleven queries of the RN-090 read model
(TWELVE → FOURTEEN, still CONSTANT) — instead of opening a new internal HTTP route
in the engine. api and engine share the SAME physical database,
separated only by Postgres schema, and there was already a precedent
(apps/api/scripts/medir-execucao.ts already reads engine.oban_peers); this
decision raises the pattern from a manual script to TESTED production code
(a minimal, declared fixture of the engine table in
test/support/global-setup.ts). Consequence accepted and declared in ADR
0097: 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.
An execution session no longer closes with a pending dev agent (RN-411)
A fix for a defect found in a REAL execution session, not a script
item: five dev agents came up, became idle_tripped (RN-047, the
circuit breaker) stuck waiting for the user to unblock task by
task, and the session's 30s heartbeat (Engine.Sessions.SessionServer)
closed the session underneath while the manual unblocking went on. The
third signal of GetSessionPendingWorkUseCase (RN-064) only reads
agent.status, the conversational agents' vocabulary — dev agents
speak dev.* (Engine.Dev.AgentIo) and never emit agent.status, so
no dev agent ever held the session through that signal. The fourth signal
reads the last dev.* event of each dev-<modulo>/dev-<modulo>-2 in the
session: dev.working/dev.blocked/dev.idle_tripped hold the session
(stuck waiting for a human decision IS pending work); only dev.idle
(truly drained) doesn't hold it. dev.awaiting_gate/dev.awaiting_approval
ENTERED the rule through RN-412 — see below; the residual gap this
paragraph described was closed.
Automatic personal workspace on sign-up (RN-410)
Found while browsing: the dashboard's "New project" button did NOTHING, with no
error. Root cause: RegisterUseCase and SocialLoginCallbackUseCase (in the branch
that provisions a new account) created a user and a credential but NEVER a
workspace — EVERY new sign-up hit that wall, and it only didn't show before
because seed.ts always creates a workspace along with the demo data.
Both points now create the workspace and add the user as owner
in the SAME transaction that already creates the account. Name/slug come from a single pure
function, nomeESlugDoWorkspacePessoal (domain/auth/personal-workspace.ts),
so the rule doesn't diverge across two files; the slug is always suffixed with
userId.slice(0, 8) (the same pattern as extraDevAgentId) to be unique
without a round trip to the database (RN-410).
413 on PRs and Executors "without execution" — the two ends and the chain (RN-412, ADR 0098)
Not a planned phase: a fix requested by real USE ("on PRs it's always
blowing up with entity too large") plus the sibling defect of the Executors tab
showing up empty with real work going on. Investigation found the two
chained: the gate dies with 413 → devs get stuck in
dev.awaiting_gate → that state didn't hold the session (the gap of
RN-411) → the 30s heartbeat closes the session underneath → the tab requires an
active session and vanishes with the whole roster.
The cause of the 413 was Brabo's API, not the LLM provider — the
comments that attributed the error to the provider (runtime.exs,
terminal_executor.ex, docker-compose.yml) were wrong;
FalhaDeTurno.origem({413, _}) already classified it as "codigo", and
was right. apps/api/src/main.ts never configured Express's body
limit — the 100 KB default applied, far below the 8 MB that
Phoenix accepts, in the transport's heaviest direction (engine → api,
POST /internal/sessions/:sessionId/llm-turn, which resends the WHOLE
history on each ToolLoop iteration). API_JSON_BODY_LIMIT (default
10 MB) closes the transport end.
The second end, without which raising the limit would only postpone the overflow: the
engine's ContextManager (ADR 0098) had TWO defects that
kept compaction unreachable before the body overflowed —
estimate/1 counted only content, so assistant messages
full of toolCalls cost ~zero tokens in the estimate; and the compaction
window used only context_window (128,000 tokens in the gate
agents), which gave ~350 KB before compacting — well after
any reasonable transport ceiling. The EFFECTIVE window is now
min(context_window, teto_de_transporte), with the cut always on a
ToolLoop ITERATION BOUNDARY (never separating an assistant with
toolCalls from the role: "tool" ones that answer it — it would break the provider's
tool-use protocol).
RN-412 extends RN-411: DEV_PENDING_TYPES gained
dev.awaiting_gate (the original argument — a gate can die and lock
the dev agent in that state indefinitely) and also
dev.awaiting_approval, by a new argument found in the investigation:
the approval decision writes proposed_actions.status
SYNCHRONOUSLY, but the dev agent's resumption via outbox/Oban is ASYNCHRONOUS —
in the window between the two, nothing held the session, the same defect as
RN-411 one level deeper.
The Executors/Overview tab had the sibling defect on the web side:
executionActivated was derived from events.some(...) over the window
of only 200 events of useSessionEvents — execution.activated is among the
FIRST events of a session and left the window in any real
execution, erasing the whole roster. The correct value already existed, aggregated
over ALL the events, in the workspace summary (RN-090,
projects-summary.repository.ts) — the two screens started consuming
that value. A bonus finding along the way: ExecutionSection (Overview)
recomputed activated again, independently, over the
same window — a long execution went back to offering "Activate execution"
as if it had never started; fixed along with it. gatesEverOpened
suffers from the SAME class of defect and was DECLARED as a known
limitation, not fixed here — fixing it would require changing the signature
of deriveAgentRoster/rosterFactsFromEvents, out of scope for this
targeted fix.
Neo4j as a knowledge graph — the foundation (RN-413/414/415, ADR 0099/0100)
The product owner's request: Neo4j + ollama-model-loader (gemma:1b,
yi-coder:1.5b, nomic-embed-text), explicitly inspired by the repository
ErickWendel/neo4j-ai-experiments
— acknowledgment recorded in ADR 0099 and in prompts/README.md, for the
concrete inspiration of the "prompt as a versioned file, not a string
stuck in the code" pattern and of the graph as agent memory.
Two responsibilities, one foundation: versioned prompt templates
(PromptTemplate/PromptVersion, idempotent by hash — RN-413) and
relational memory (interactions, Psicólogo hypotheses with evidence,
Anamnese profiles, handoffs — real consumption is a future wave, this delivery
only builds the mechanism). The graph is DERIVED memory, never a source of
truth; pgvector remains the chunks' vector index, without
duplicating embeddings in two databases. The neo4j-driver driver in the api (not in the
engine) — the api already owns all persistence and the RAG, the engine
consumes through internal HTTP with a service token, as always.
rag_search (RN-414) closes the biggest gap of the existing RAG: no agent
queried it, only the web "Chat RAG" tab. A new tool in the engine, its own ceilings
(RN-150), degradation always visible at the start of the text to the model.
ollama-model-loader (RN-415) closes a separate real bug: nomic-embed-text
was never pulled automatically and the RAG degraded to lexical-only
silently in any clean environment.
First batch of templates extracted to prompts/*.md (ux-designer,
Psicólogo, Anamnese, ContextManager summarization).
Graph consumption — templates without touching GenServers, relevance without replacing recency (RN-416/417, ADR 0101)
InstructionFiles gained a :graph source (db > graph > dir > root —
the user's instruction_patch still beats everything), and THREE agents
started TRYING to resolve the template from the graph before the inline text, which
became a mandatory fallback: ux-designer (graph_instruction_templates_enabled?),
Psicólogo and Anamnese (graph_templates_enabled?, the same key for the
two). Two flags, not one — they would collide with OPPOSITE defaults between
parallel fronts if they shared the key; both defaults are false.
Only context-manager-summarize (of the four templates in the batch) was left without a
consumer at the time — a gap ADR 0101 declared open and that closed
later, through the FOURTH consumer of the SAME graph_templates_enabled?: the
ContextManager's summarization prompt (prompt/1, see RN-417).
Psychologist.ContextBuilder/Anamnese.ContextBuilder gained a
SECOND context source — rag_search, with a query derived from the analysis
trigger — in COMPOSITION with the usual recent/time-window
read, never as a replacement: with no hit, the behavior is identical to
before. The excerpts go into the EXISTING Triage budget, and
the RAG's degraded: true is always visible in the context, never hidden.
The graph writes itself: GraphProjector (api) drains a SECOND
outbox line (aggregateType: 'graph_projection', same transaction,
a value the engine's filter — IN ('session', 'task') — never matches,
avoiding the race with Engine.Outbox.Drain, which already drains 'session'
in ~2s) and calls the already existing write use cases. The engine
NEVER writes to the graph directly — it preserves the event log as the single source of
truth; the graph can be rebuilt by replay.
A real finding during verification, recorded because it teaches: the
body limit test of RN-412 (apps/api/test/main.spec.ts) had a
LATENT bug — AppModule imported STATICALLY at the top of the file evaluates
DrizzleModule (which creates its connection pool at MODULE scope, not
in a Nest factory) BEFORE beforeAll can point
DATABASE_URL to the test database. It stayed masked while the local dev
database was also migrated (same table, so the query "found" it even
pointing at the wrong place); it got unstuck as soon as the dev database was
recreated empty (by this very wave's real smoke test against
Neo4j+Postgres). Fixed with a DYNAMIC await import(...) inside
beforeAll, after DATABASE_URL is already set — it is the pattern to follow
whenever a test needs to redirect DATABASE_URL to a real
AppModule.
Local runner — execution on the user's machine (RN-418/419/420, ADR 0102/0103)
The product owner's request: an agent running on the USER'S MACHINE, in the project's local folder, through the user's default terminal — plus a real interactive terminal in the Code tab. Two changes, one depending on the other.
Policy (ADR 0102 — a GLOBAL decision, explicitly confirmed by the product
owner after an automatic security warning about the change):
git push/PR/deploy (RN-106) and sudo/doas (new) leave the unconditional
deny and become an ABSOLUTE CEILING — unconditional require_approval,
in the SAME block and SAME pattern as the neighboring ceilings (current.policy === 'auto_approve'), never auto-approvable by agent_autonomy (wildcard "*")
nor by permissions.json. The gap the original deny plugged by force
was closed at the SOURCE: ApproveAlwaysActionUseCase/patternForAction
refuse to write an allow pattern for an action with a git external effect or a
privileged command — "always allow" approves only the instance, never the
pattern, for those two cases. Without that second half, the ceiling would be
decorative. sudo/doas match by VERB in comandoPrivilegiadoNoComando
(external-effect.ts), scanning all the command's segments, same
principle as efeitoExternoNoComando.
Runner (apps/runner, a new Node/TS workspace, ADR 0103): a CLI
(brabo-runner --project <id> --dir <pasta> --token brb_...) that the
user runs on their own machine, connects to the ENGINE through a NEW Phoenix channel
(/runner,
topic terminal:<projectId>) authenticated by a SINGLE-USE ticket (same
pattern as RN-108, but a ticket ISSUED BY THE ENGINE — schema "engine",
its own migration — and requested by the api through an internal HTTP route, inverting the
session ticket's flow). It runs exec (an ALREADY APPROVED agent command
— the routing in TerminalExecutor only happens AFTER the usual
decide()/proposed_action pipeline, never before) in the user's $SHELL,
and an interactive PTY mode for the Terminal tab (pty_open/pty_data/
pty_input/pty_resize/pty_close — the engine does pure RELAY, never
interprets the bytes). Only ONE runner per project (Engine.Runners.Registry,
:global, exclusivity of :global.register_name/3); runner mode WITHOUT a
verified workspace or WITHOUT a connected runner REFUSES explicitly — it never
falls into System.cmd/bind-mount, which doesn't exist for a runner project
(revised by ADR 0104/RN-423, see above; the fallback to the usual path
remains valid, but only for container/mounted). PTY is an action of the
authenticated USER, it doesn't go through proposed_action, but it audits
(terminal.session.started/ended in the event log, even when the tab
drops without an explicit pty_close).
The runner's security boundary is NOT sandboxing — it is authentication
(the CLI presents a Personal Access Token of the user's ACCOUNT) + the
usual approval pipeline + the consent of the user running the
CLI on their own machine, with their privileges. apps/runner/src/guard.ts
validates cwd inside the root by lexical resolution, but it is declared
BEST-EFFORT, not the real guarantee.
The PAT closed the automation token gap (ADR 0105/RN-424..426). The
real finding of Wave 1 — "the product has no LONG-LIVED account token
mechanism for automation, so the runner replicates the browser login
(cookie/CSRF persisted in ~/.brabo/runner-credentials.json)" — was
closed in Wave 2: personal_access_tokens (brb_…, HMAC-SHA256+
pepper hash via hashDeToken(), never argon2 — wrong for a HIGH-entropy
secret) is issued in the project's Settings, scoped to ONE project,
revocable, with optional expiry. apps/runner/src/auth.ts lost
completely the interactive login, cookies and the credentials file — it only
validates the format and passes on --token/BRABO_ACCOUNT_TOKEN, NEVER written
to disk by the CLI. The PAT never authenticates outside
POST .../runner-ticket, by CONSTRUCTION (IS_PAT_ROUTE_KEY/
@RequirePatAuth() + PatAuthGuard) — never a branch in the global JwtAuthGuard,
which would let RolesGuard authorize the PAT for any route of the
user's role.
maintainer revokes another user's PAT (RN-427), closing the item
ADR 0105 had declared out of scope for now (incident
response — a dismissed dev with a leaking token). An extension of the same model,
without a new ADR: SEPARATE routes (GET .../personal-access-tokens/all,
DELETE .../personal-access-tokens/:tokenId/admin), scope by
projectId instead of userId — each user's self-revocation
(RN-426) doesn't change.
@xterm/xterm/@xterm/addon-fit (web) and phoenix/node-pty (runner)
are the four new dependencies — the same exception rule as mermaid
(ADR 0068): dynamic import(), no eval/new Function confirmed by
grep in the installed package (not a formal guarantee against obfuscation, only
strong evidence — declared as uncertainty, not asserted as certainty).
ADR 0104 — Wave 1 COMPLETED (RN-421/422/423): ADRs 0072 and 0103
never talked to each other — RN-170 required a bind-mount at creation, and the routing to the
runner (RN-420) reused the same workspace_mode == 'local' flag without
any bind-mount, so actually using the runner was still forced to
go through the mounted-folder validation. workspace_mode (2 values) became
execution_mode (container/mounted/runner, migration 0048) —
RN-170 (now RN-422) applies only to mounted; runner is born with a
path validated only LEXICALLY (no I/O) and workspaceVerifiedAt: null,
promoted to verified when a runner connects and confirms the path on the
HOST (POST /internal/projects/:projectId/workspace-verification, RN-423)
— the runner is the SOURCE OF TRUTH of the path, overwriting what was
typed in the wizard. Engine.Actions.TerminalExecutor.decisao_de_execucao/1
gained FOUR outcomes: it routes only with a verified workspace AND a connected
runner; without either of the two, it REFUSES explicitly — it never falls into
mounted's System.cmd/bind-mount, which doesn't exist for a runner
project.
A finding of the implementation, correcting the ADR (which is not edited — the
correction lives here and in
backlog.md):
ADR 0104's sentence that converting between the three modes of an
existing project "is now allowed without recreating the project" is INCORRECT.
UpdateProjectDto still excludes executionMode/workspacePath on
purpose — it isn't a trivial PATCH (worktree, permissions.json and the
engine cache point to the old scope). Wave 1 delivered only the three-value
field at CREATION; converting an existing project is a future wave, with
its own design, not yet planned. Runner backlog: Wave 2 (PAT,
ADR 0105) closed the item that blocked npm publish @brabo/runner, and
Wave 3 (ADR 0106) delivered the distribution itself — tsup bundles
apps/runner into a single dist/index.cjs (node-pty as a mandatory
external, native binding), published on each final tag by its own
workflow (publish-runner.yml), parallel to release.yml. A real finding
tested empirically: the auto-run guard of index.ts needed
realpathSync on process.argv[1] — without it, main() never ran
when the CLI was invoked through the bin installed via npm install -g
(a symlink, which process.argv[1] never resolves by realpath but
import.meta.url always does). Declared operational pending item: the
actual publication requires the product owner to create the npm Automation Token
and configure the NPM_TOKEN secret — without it, the workflow warns and skips,
never fails. Exclusivity by {project_id, machine_id} remains postponed
until a second real simultaneous dev; best-effort guard.ts is a
REAFFIRMED invariant, not a gap. See
ADR 0104/
ADR 0105/
ADR 0106.
DEVELOPMENT TOOL — pnpm bootstrap
A terminal menu in scripts/dev/bootstrap.sh grouping what is done
day to day: Docker, K8s, Database and Test. It exists because those commands live
in THREE places that don't talk to each other — package.json, Makefile and scripts in
deploy/k8s//docker/ — and memorized knowledge rots.
It reimplements nothing: each leaf calls the command that already exists. The
--print-commands mode resolves the whole tree without a TTY and without executing, and it is
on it that the test runs (scripts/dev/bootstrap.spec.ts) — a TUI can't be
unit tested, but the menu→command mapping can, and that's what goes wrong.
Zero new dependencies; the colors come from design/tokens.css in 24-bit ANSI
with degradation to 256 colors and to none.
The output of a running command is SCROLLABLE (mouse wheel in SGR, j/k,
PageUp/PageDown, G to go back to the end). What was missing wasn't reading the wheel: it was
OFFSET — tail -n only knows how to show the end —, so the window became a slice
with sed -n 'a,bp', testable without a TTY through --print-window. Two consequences
the footer announces: scrolling back FREEZES the window (like less +F, otherwise
the 5 Hz redraw undoes the scrolling) and mouse tracking, turned on only on the
execution screen, is turned off on EVERY exit — \e[?1006l\e[?1000l in
restaurar_terminal, which the EXIT trap covers. The escape parser is a generic
CSI one (reads up to the final byte): reading two fixed bytes left the rest of the sequence
in the buffer, and since the menu treats [1-9] as a choice, a wheel turn fired
menu items.
Three recorded decisions: Create provisions from scratch and Deploy publishes
into an environment that already exists (that's why only Deploy has a per-service choice);
in K8s only All works, and Api/Engine/Web appear DISABLED instead of
vanishing, because the menu should say what the product doesn't do; and
Database › Delete recreates the pgvector extension, since
docker/postgres/init.sql only runs on the volume's first initialization —
a plain DROP SCHEMA would make the next migration fail, and since the engine
shares the same database, recovering requires db:migrate AND engine:migrate.
Docker › Reset total (scripts/dev/reset-total.sh) adds up that
whole sequence into a single leaf — preflight, up --build --wait (the
--wait of Compose v2.17+ is the "until everything is healthy", with no health loop
written by hand), the same DROP SCHEMA as Delete, db:migrate +
engine:migrate and the seed — with its OWN confirmation screen
(confirmar_reset, requires typing RESET), distinct from Delete's (which
requires the database NAME) and without Delete's "postgres already up" precondition,
because it is the command itself that brings up compose from scratch. The seed
(apps/api/src/db/seed.ts) gained a step that activates a provider credential
from .env, reusing the SAME <PROVIDER>_TEST_KEY variables
that the LLM smokes already use
(apps/api/test/infrastructure/llm/*.smoke.spec.ts) — a single naming
convention, two consumers; a provider without a defined variable doesn't come in, without
error, and the credential stays with the OWNER (RN-058).
Two findings when running the reset for the first time, and not about the item itself — neither of them is hypothetical, both broke the real run:
gemma:1bdoesn't exist in the Ollama registry (manifest unknown— onlygemma3:1bexists), soollama-model-loaderalways failed and the--waitnever closed green. Fixed in the six places that carried the wrong value as CODE —docker-compose.yml/.prod.yml,.env.example,docker/ollama/pull-models.sh,scripts/dev/verificar-modelos-ollama.sh,deploy/k8s/base/ollama/job-model-loader.yamland RN-415 indocs/business-rules.md— without touching ADRs 0099/0100 (accepted) nor the historical narrative of this same section of CLAUDE.md, which describe what was requested/decided at the time, not the current operational value.- More serious, and older —
Database › Delete(item 3.4, existing since the menu was created) never really dropped:DROP SCHEMA public CASCADEdoesn't reachengine.*(Ecto/Oban lives in its OWN schema,engine) nordrizzle.__drizzle_migrations(drizzle-kit's control lives in its OWN schema,drizzle). Result:mix ecto.migratehitduplicate_tableon the engine tables that survived, andpnpm db:migrate— worse, WITHOUT any error — saw drizzle-kit's control intact, concluded it had already run everything and recreated NO api table.Delete/Reset totalnow dropengineanddrizzletoo, in that order, beforepublic.
Grouped tabs, project-wide PRs, local folder via Runner, the PO's carousel (2026-08-20)
Five independent waves, run in parallel by contested file, coming
from real use of the project screen — none planned in advance. The sixth wave
of the same program (complete translation of product and documentation, i18n) is
bigger than the five together; its FOUNDATION (Wave 6a — mechanism, no
content translation yet) has already closed: react-i18next+i18next on the web
(en became the app's default language, pt-BR kept — RN-425), a
locale column in users embedded in the login/refresh payload, a new AccountPage
(/account) proving the mechanism, and Docusaurus with
i18n.defaultLocale: 'en'/locales: ['en', 'pt-BR'] — the current pt-BR snapshot
of docs/ is already preserved in
website/i18n/pt-BR/docusaurus-plugin-content-docs/current/. Wave 6b
(mass extraction of the interface + translation of docs/) advanced a lot on
2026-08-22, in four parallel fronts — see CHANGELOG.md (Unreleased/
Documentation) for the detail of each — but it has NOT YET closed: missing are
docs/business-rules.md (only the front-matter was translated; the body, with
hundreds of RNs, remains 100% in Portuguese — it is the largest file of the whole
doc and grows faster than it can be translated in passing) and a
residual slice of smaller .tsx components. When it really closes,
this section gets the final summary and the rest of CLAUDE.md (Stack,
"Documentation") is fully revised to record English as the real primary
language.
- Grouped tab ruler: 11 loose tabs became 6 at the top — Overview,
Agents ▾ (Executors, Criativo, Chat, Insights), Dev ▾
(Code, PRs, Approvals), Documentation ▾ (Backlog, Architecture),
Spend, Settings — via a new
GroupedTabs(apps/web/src/components/ ui/GroupedTabs.tsx), built ON TOP of the genericTabs, which remains without groups for the other screens.ChaveDeAbaremains flat — only the presentation became grouped, so as not to touch the?tab=deep link. Chat and RAG Chat become ONE tab (ProjectChatShell.tsx) with an internal segmented control ("Converse"/"Search") — the business distinction between the two (RN-202: conversing activates an agent and spends the owner's key per turn; RAG is a read over an index, without an agent) didn't change, only the UI container. - PRs tab, project-wide:
ProjectApprovalsTabscoped "PRs under review" to the most recent session — the review of a PR from a previous session vanished from the screen as soon as a new session proposed another one. The newprstab lists straight from the git provider (never per session) and cross-references with a pendingproposed_actionviafindPendingByProject, new, alongside the already existing session-scoped method — the decision uses thesessionIdof the action ITSELF, neverlatestSession(RN-423). It is the first real producer ofgit_mergethrough the UI; the protected-branch lock (RN-154) remains absolute and untouched — the "Merge" button only PROPOSES, human approval remains the only way to execute. - Architecture tab: extracted from the Overview (module_map, C4 diagram,
ADRs, pending items), which keeps a condensed summary + link. The design system's first
lightbox —
Modalgainedsize="full"— to enlarge the C4 diagram (SVG, without quality loss) (RN-424). - Local folder navigation via the Runner (RN-422, ADR 0104): revises
ADR 0072 (which had explicitly refused a folder picker) WITHOUT
editing it — navigation exists, but through the Runner, which already runs with the
user's real privilege on their machine, never through the api enumerating the
container's filesystem. Two new events on the already existing channel
(
fs_list_dir/fs_home_dir), pure relay by the engine, same pattern as the PTY.FolderBrowserModalworks where the project already exists. At creation (NewProjectWizard.tsx), the gap stayed declared until ADR 0108: inmountedmode the project is still only born at confirmation (the path validation touches disk there), but inrunnermode "Browse folder..." now creates the project IN ADVANCE — reused by an identity snapshot instead of duplicated on each click — so it can anchor the channel ticket before the confirmation (RN-437, ADR 0108). A newRunnerOnboardingPanel(shared with the Terminal tab) replaces the raw<code>from before.apps/runnergot a README — actually publishing to npm/packaging a signed binary remains out of scope, declared in the README. The folder explorer itself became three columns (shortcuts, a list where one click selects/double click enters, a details panel — RN-436), following the product owner's visual reference. - The PO's carousel bug fixed (RN-421): the batch of pending
promotions depended on a scan over the last 200 events
(
useSessionEvents) — in a long session, the proposal left the window and the carousel degraded to a single card or vanished. The same class of bug that RN-180 had already fixed forContextAside; the source is nowuseBacklog(complete, no window), with a per-story fallback when the backlog hasn't answered yet.
Manual handoff to an agent of choice (RN-440/441, ADR 0109)
It closes the backlog item open since FASE 13c. CreateHandoffUseCase
remains the ONLY place that writes toAgent (ADR 0038) — it only gained an
optional actor?: Actor, defaulting to the usual {kind:'agent', id: fromAgent}.
RequestManualHandoffUseCase (a new route, POST .../sessions/:sessionId/handoffs, role developer) validates toAgent
against addressableAgents() — a CLOSED catalog (area leads ∪
SOLO_CONVERSATIONAL_AGENTS), stricter than assertHandoffTargetAllowed
(which only refuses a subagent) — resolves fromAgent through the session's most recent
agent.activated (sentinel "usuario" when there is none), and writes
actor: {kind:'user'}. The handoff is born offered just like an automatic one:
the already existing acceptance card (SessionPage.tsx) picks it up on its own, zero
change in the acceptance path.
Investigation BEFORE coding found ux-designer (ADR 0087) in the SAME
situation as the Staff (ADR 0088) that motivated the item: a message/2 clause
ready in agent_command_controller.ex, no human path to it.
Both entered AGENTES_DE_CHAT in the same change — infra/qa
remain out, the same pattern already documented there (leads without kickoff/1
or a message clause). Declared, not hidden: the mirror of
SOLO_CONVERSATIONAL_AGENTS on the web side (apps/web/src/lib/agents.ts)
is NOT cross-checked by test with the api's — diverging produces, at worst, a
stale option in the selector that the backend still refuses with 400.
Budget per area — closes the cut of ADR 0038 (RN-443, ADR 0110)
An approved backlog item ("Older backlog", docs/explanation/backlog.md).
agent_areas gets budget_micros/spent_micros, mirroring exactly
max_parallel — same row, same decision owner (maintainer), without a
new table. It is a THIRD ADDITIVE ceiling alongside project and session
(CheckBudgetGateUseCase checks all three in parallel, any one blocked
already refuses), not the model binding cascade of ADR 0064 (session > agent > area > project > workspace, "the most specific wins") — the two
mechanisms share the word "area" and nothing else; not confusing the
two is the central point of ADR 0110, in the same way RN-101/ADR 0063 already
separates the owner's view from the member's view in the spend report.
spent_micros always adds up when the actor belongs to an area (lead or
member, via areaDo — a pure function, without touching the database for an actor without an area),
with or without budget_micros configured (null is the default, no ceiling).
The runner's standalone binary (2026-08-23, RN-451/452, ADR 0112)
A backlog item of ADR 0104 ("standalone binary, pkg/bun build --compile"), companion of ADR 0106 (distribution via npm). The product
owner's explicit decision: a truly SINGLE binary, a complete matrix
with Windows since v1 — the cheaper alternative ("binary + a
node-pty-native/ folder next to it") was offered and refused.
bun build --compile embeds node-pty's native .node through
with { type: 'file' } and extracts it to a REAL TEMPORARY directory at
runtime (native-pty-loader.ts) — necessary because node-pty resolves its
own addon through a require() of a COMPUTED path
(lib/utils.js#loadNativeModule), which Bun can't embed on its own
(the build passes, the runtime throws Cannot find module, tested before
designing the fix). Five platforms
(linux-x64/linux-arm64/darwin-x64/darwin-arm64/win32-x64), each
one built on its NATIVE runner
(.github/workflows/build-runner-binaries.yml, final tag trigger,
same pattern as publish-runner.yml), the binary attached to the already
existing Release.
Honest validation, per platform: only linux-x64 was actually executed
in this environment (build:bin+smoke:bin green, repeatedly —
it was by running the real binary that the two bugs below showed up). The other
four — Windows especially, with THREE native files instead of one —
were reasoned about by reading node-pty's source code, never executed
on a real machine of that OS; their first real run is the NEXT
version tag, not this PR. No per-platform fallback was applied
preventively — nothing in this investigation PROVED that any of the five doesn't
work this way; if a real tag shows that it does, that becomes a new item, not
today's assumption.
Two defects found only by running the compiled binary, not by reading the API:
--external node-pty needs to be in the bun build command even without
any static import of node-pty in the code — the fallback await import(...)
(used only outside the binary) is discovered and embedded by Bun
anyway, and fails at runtime with a misleading error; and the
auto-run guard (ADR 0106) needed a second branch — process.argv[1] inside
the binary is a VIRTUAL path (/$bunfs/root/...) that realpathSync
can't reach, throwing ENOENT outside any try/catch before
main() is attempted.
Converting the execution_mode of an existing project (RN-447..450, ADR 0111)
It closes the correction that the runner's Wave 1 (ADR 0104) had recorded in
docs/explanation/backlog.md: item 4 of that ADR said that converting
execution_mode between container/mounted/runner in a project that already
exists "is now allowed without recreating the project", and that was false until
here — UpdateProjectDto still excludes both fields on purpose.
PUT projects/:projectId/execution-mode (maintainer,
ConvertProjectExecutionModeUseCase) is a DEDICATED route, not a loosened
PATCH: it orchestrates the migration instead of just switching the column.
It refuses with 409 while any dev agent of the project (in ANY session,
read straight from engine.dev_agent_states by project_id, the same path as
RN-409) isn't idle — idle_tripped COUNTS as active here, unlike
RN-409's "online" criterion, because the agent still has
workspace_root captured in memory waiting for human unblocking
(RN-447). PermissionsFileStore gained move(from, to): the content of
permissions.json never changes, only the physical path, because projectScopeRoot
derives from the (mode, path) pair (RN-448). Leaving container with a provisioned
container, the lifecycle (ADR 0081) is taken to removed — via
stopped first if it was running — BEFORE the column changes, because
RegistrarTransicaoDeContainerUseCase refuses a transition outside
container mode; entering container never auto-provisions (RN-449).
workspaceVerifiedAt is reset on every real conversion, including going back to
runner with a path that looks the same (RN-450). The same (mode, path) as
today is a no-op — it doesn't even check for an active dev agent.
Declared consequence, not hidden: the conversion NEVER copies the code
already written between the old root and the new one. That is safe for what was already
COMMITTED — the engine derives the worktree from the bare repo from scratch
(Engine.Actions.Workspace.ensure!/4) whenever the destination folder isn't
yet marked ready, so the next agent rebuilds the working
tree in the new root from the real git. What does NOT survive is an
UNCOMMITTED diff in the abandoned worktree — it stays orphaned on the old disk,
never migrated nor deleted. The active dev agent ceiling (RN-447) reduces the
risk (it never converts with a working agent) but doesn't eliminate it: an
idle agent between tasks may be sitting on a dirty diff, and nothing here
inspects the working tree's cleanliness before converting.
A local folder attached as a RAG Chat reference (2026-08-23, RN-455..457, ADR 0113)
The product owner's request: attach a folder from the user's OWN machine to
a project as a READ reference for the agents — without the
brabo-runner CLI (ADR 0103). Explicitly NOT project execution/workspace:
execution_mode: runner (ADR 0104) needs the CLI because it needs
a real HOST path for the container to route commands; this is more
like "attaching files to a conversation". The browser reads the CONTENT
(File.text()) and the RELATIVE path (File.webkitRelativePath) of the
chosen files — never an absolute host path (the File API
doesn't expose one, to any site), and that's why ADR 0113 is explicit that it is NOT
a revision of what ADRs 0072/0107 refused: those refused
RESOLVING a host path from the browser; here there is no path
at all to resolve, only bytes the browser already had the right to read.
chunks.scope (ADR 0079/0080) gains a FOURTH value, 'local' (migration
0052, ALTER TYPE ... ADD VALUE, additive — the two CHECKs of migration
0045 are already written as "it's session or it isn't", so local falls on the
same side as docs/adr without a new CHECK). IndexLocalFolderUseCase
mirrors IndexProjectDocsUseCase closely — Markdown-aware chunking for
.md/.mdx, RagEmbeddingService, a chunk without a vector instead of failing
when the embedding drops (RN-233) — but REJECTS (400) the whole upload
when the AGGREGATE ceilings overflow (number of files, summed
bytes), never truncates silently: it's a SINGLE gesture by the user with a
folder picker on screen, not a background scan. An individual
file that's too big or has an unrecognized extension is only SKIPPED
(filesSkipped), never brings down the batch (RN-456). origemDoChunk didn't
need any new branch: local carries sourcePath like
docs/adr, falls into the kind: 'file' that the citation already knew how to render.
It never enters the generic "Reindex now" (RN-457): ReindexProjectUseCase
reindexes by reading from a source the SERVER can revisit (the project's
repository, the event log); local has no source at all to revisit — the
text only exists in the browser of whoever attached it. Reattaching the folder (a new
upload to POST .../rag/local, maintainer) IS the
resync mechanism, a deliberately separate button, with a cross-comment
in both use cases pointing at each other and at ADR 0113, so that a future
"fix" doesn't wire the two back together and erase the user's material. The
coverage (RagCoverage.local) has its OWN shape — there's no "total in the
repository" to compare — and lastAttachedAt is the ONLY real exception to the
"never Xmin" rule (RN-237): a real MAX(chunks.created_at), not a
guess (RN-458).
The turn's activity strip — the thread only receives the reply at the end (RN-459/460)
The product owner's request: the Session screen starts showing, ABOVE the
composer, a strip that narrates in human language what a conversational
agent (Criativo, PO, Arquiteto, Dev Lead, UX Designer, Staff)
is doing DURING the turn — visual reference: Claude Code's status
line. The thread only receives the reply bubble AFTER the turn
ends; the rule belongs to the CHAT (Phoenix channel) and applies only to the six
conversational ones — the consultative chat without an active agent (SSE,
streamChatMessage) keeps the usual streaming bubble,
untouched.
Mechanism: the six servers already emitted a DURABLE tool.call; they now
also broadcast(state, "tool.call", %{tool: name, agent: @agent}) —
EPHEMERAL, without args (RN-096/RN-412) — for the channel to deliver in real time.
There is no shared behaviour/macro between the six dispatch_tools
(four distinct structural shapes, confirmed by a complete read) —
the change was six edits adapted to each one's local shape, not a
refactoring. On the web side, a PURE reducer
(reduzirAtividadeDoTurno/lib/atividade-do-turno.ts) accumulates the
agent.delta as current text; when tool.call arrives, it archives the
non-empty current text as a narration line, resets it, and always adds a
tool line with fraseDaFerramenta(tool) (a dictionary of the 19
tools, lib/narracao-de-ferramentas.ts, without its own RN). Its OWN
flag (turnoViaCanal, never derived from statusAgent/streamingAgent,
which go through legitimately null windows in the middle of the turn) decides whether the
strip appears or the old bubble — never both.
Presentation rule (history): agruparNarracoesDoTurno, a new
pure pass after afundarDesfechos (RN-172) in the same useMemo —
consecutive agent.responses of the same turno+autor become a
compact Disclosure ("Turn steps · N"), only the last stays intact
outside it. turno === 0 (the sentinel prologue of turnoDoSeq) stays
OUT of the grouping — old fixtures stack agent.response without a
turn boundary to test another mechanism (RN-138/RN-177), and without the
exclusion the new grouping would produce a Disclosure inside a
Disclosure there. afundarDesfechos itself didn't change.
A finding along the way, scope extended by the product owner's decision: the
investigation found that FOUR of the six servers (Arquiteto, Dev Lead, UX
Designer, Staff) ended SILENTLY at the iteration ceiling —
defp run_turn(state, remaining) when remaining <= 0, do: state, without
any event, contradicting the permanent rule that an agent loop doesn't
end silently. Only the PO already emitted toolloop.limit_reached (RN-166); the
Criativo deliberately diverges with agent.error (a decision documented
in the code itself). The four fixed now emit
toolloop.limit_reached, the same event as the PO (RN-459).
Migrated from the CLAUDE.md index on 2026-09-11
The entries below came from the CLAUDE.md index table and had no
section of their own in this file — their narrative existed in a single place. They were
moved without rewriting, in the order they were in, when the table was reduced to an
index. Whatever is here is history; what holds today lives in the RNs, the ADRs
and the "Current and open state" section of CLAUDE.md.
PROGRAMA 16–26 (ADR 0060–0072)
Design handoff adopted; 8 screens; session kind; paginated events; Spend; model per area; container per project (25b cut); Code tab
pnpm bootstrap
Terminal menu over existing commands; Reset total
Native Ollama / Hugging Face pull (RN-461..463, ADR 0114–0115)
The dev bootstrap asks once and persists the Ollama mode in .env;
a Hub model browser with a two-step pull and an allowlist of official
publishers
The website's own lockfile (ADR 0117)
website/ leaves the root pnpm workspace — lockfile and security overrides
isolated, the product's pnpm audit stops reporting the Docusaurus tree
Automatic runner configuration through the browser (RN-464..466, ADR 0118)
An Ed25519 device key generated in the browser, additive to the PAT; a binary
proxy via GitHub Releases; the File System Access API writes the configured folder,
with a download fallback outside Chromium; --project/--dir/--token become
optional in the CLI
Images published to GHCR (ADR 0119)
release.yml publishes the four images on each final tag and records the digests
in .release/images.json; the production overlay stops pointing at a
placeholder
Schema per aggregate (ADR 0121)
db/schema.ts becomes a barrel; the 51 tables and 34 enums become 16 files under
db/schema/, one per aggregate of domain/*
SessionPage.tsx in 5 mechanical PRs (ADR 0122)
3,807 → 2,661 lines; timeline/turn, StorySlide, StructuredQuestionCard,
backlog tree + ContextAside, hook useSessionReadiness — the turn
channel cluster and ProjectSettingsTab.tsx remain out, declared
The SessionPage.tsx turn channel hook (ADR 0124)
2,661 → 2,479 lines; a mechanical dedup PR
(iniciarTurnoDoAgente/finalizarTurnoDoAgente/cancelarTurnoOtimista)
followed by the extraction of state + effect of the Phoenix channel into the hook
useTurnoDoAgente (lib/session-turno.ts) — closes the item ADR 0122
left declared open; ProjectSettingsTab.tsx remains out, a separate
decision
ProjectSettingsTab.tsx by section (ADR 0125)
2,532 → 77 lines; the 17 sections become one file each under routes/settings/
(+ shared.ts with the TWO helpers with more than one caller), and the old file
stays on the path as an entry and barrel — the path and the 11 exported names are
the contract of 3 tests. Closes the LAST half of the debt line in
docs/architecture.md
The project's vertical navigation rail (ADR 0126, RN-201)
The two-level horizontal ruler (GroupedTabs) becomes a vertical rail with
the THREE groups open at the same time; geometry of the CodeShell rail,
keyboard ported, the 5 counters remain separate. Revises RN-201: the
Code tab no longer collapses the sidebar on its own
Anchored Settings table of contents (CHANGELOG)
The tab's 17 sections get an id, a <section> with an accessible name and a table of contents
in four groups INSIDE the content area — no new vertical strip, the frame
stays at the 444px of ADR 0126. Registry in routes/settings/sumario.ts
(the third list of the same design as project-tabs.ts), deep link ?section=,
spy by IntersectionObserver; only a section that MOUNTED enters the table of contents
A single pattern for an inherited value (CHANGELOG)
The FOUR wordings of "has no value of its own" in the Settings tab become two
poles and a verb, from a single source (routes/settings/heranca.tsx): the label says
the STATE, the detail says the CONSEQUENCE. Vocabulary unified, shape not — the
mark only goes where the control doesn't show the state on its own
Save by section in Settings (RN-469)
Parallelism and Spend ceiling swap N row buttons for ONE for the section
(settings/secao-salvavel.tsx), with a count of pending rows and an outcome
PER ROW — saving is N calls, not a transaction, and the screen never asserts what
it didn't get. The three autosave sections and Credentials stay out, each
one for a declared reason
The "needs you" panel (RN-467)
A chip at the top of the project opens the FIVE decision queues in a single place —
separated, ordered by urgency, with no sum at all (not even on the chip). Approvals
and merges are decided there through the ApprovalCard; the other three lead to the tab.
An architecture pending item borrows the story's date and SAYS it borrowed it
The model cascade as a visible chain (RN-470)
The Origin column stops printing the database enum and becomes workspace › projeto › área › agente with four states per node (settings/cascata.tsx) — separating
the TWO meanings of origin: 'agent' without touching the api. The warning of a
discarded level enters the chain; the three — get three texts. Closes the
UI improvements canvas (7 of 7 items taken)
Folder before the runner (RN-473/474)
The runner configuration through the browser (ADR 0118) inverts the order:
showDirectoryPicker is the FIRST step and the binary the LAST, best-effort — a
release without the asset returned 502 before the picker opened, and the folder became
unreachable. A binary failure keeps both written files and swaps the
instruction for the npm install -g @brabo/runner path. After the instruction,
EsperaDoRunner probes workspaceVerifiedAt with three states and a ceiling
The kid in the device key (RN-475)
ADR 0118's automatic mode NEVER authenticated: the browser discarded the id
the registration returned, and the written JWK was born without a kid — the CLI refused it
always. Registration and export become ONE function
(registrarChaveEExportarPrivada), on BOTH paths; and the CLI's refusal stops
being indistinguishable from "there is no key". The test that let it pass asserted
that the file had been OPENED, never its content
Provisioning that doesn't stay silent (RN-477)
Two failures didn't become any state — step.check outside the try and the refusal
of createRepo BEFORE the bootstrap row existed —, and the screen polled
"Starting…" forever without a button. Root cause of it all: /data/git-repos and
/data/project-workspaces were born root in the DEV images, which
Dockerfile.prod already knew how to create before USER
The policy file and the terminal scope (RN-478)
projectScopeRoot had TWO consumers with opposite needs, and the
runner mode (without a bind-mount) separated them: the scope keeps pointing at the HOST, the
permissions.json now lives in the MANAGED root. Execution activation
returned 500 (mkdir '/home/<usuario>'), and the READ degraded silently — in a
runner project the file never existed. Along with it: a typed error with 400 instead of a
raw Error, the Overview no longer swallowing the api's message, and the OpenAPI
annotation that promised 409 for two cases that were never 409
Warning about the human step before the click (RN-473/477)
The runner's terminal step was announced only in the success state; it now
is also said in the initial one, and the final command gets cd <caminho> when it can
be asserted that the chosen folder is the project's. The button label does NOT change —
it talks about the folder, which really is automatic
One model for all agents (RN-476)
The Modelos por agente table gets a bar that applies ONE model to all 17
at once, writing at the AGENT level (through the project it would be maintainer, and
it would touch the session default). The first EXCEPTION to RN-469's "a named value saves
on onChange" rule — the selector is an action argument, not configuration —,
with RN-469's own three-state outcome
Demotion ceilings in project_members (ADR 0127, RN-472)
The projectRole ?? workspaceRole override STAYS in both directions (it's a
capability, not a bug); what comes in are two 403 ceilings in the use case —
nobody demotes the workspace owner, nobody demotes themselves. The three
OpenAPI descriptions that RN-471 declared false now describe the
code; the Select gate is a separate PR, because the screen has no way to compute the
first ceiling
RAG search telemetry (RN-479..481)
The hybrid search leaves a trail: rag_searches (with the weights FROZEN in the
row) and rag_feedback (the useful/irrelevant vote, the only real signal).
A TABLE and not just an event, because session_events.session_id is NOT NULL and the
tab's search has no session — the rag.search/rag.feedback event is NARRATION,
only when there is a session. A rag_feedback tool (:direct) in the six agents
that already had rag_search, and medir:rag to read it. NOTHING calibrated — this
stage only instruments
Container broker (ADR 0130, RN-485/486)
apps/broker is born, the ONLY process that talks to a Docker daemon on the
server — and it does NOT accept a specification: it receives projectId + operation, READS the
Arquiteto's decision from the api and COMPOSES image, network, resources and the single mount. The
ADR 0128 port MOVES from apps/runner/src/ to packages/docker-port (both
consumers bundle it; the api can't consume it). The lifecycle
route now returns observed alongside registered, without merging them
Routing modules to infra (PR 1.4) (ADR 0131, RN-487)
The Arquiteto's seventh tool, route_modules_to_infra: one item {modulo, imagemCandidata, porque} per module of the current module_map, written as
artifact.module_routing (no table, the same design as
artifact.project_image/artifact.c4_diagram). It NOMINATES — the image of
each item goes through the same validarDecisaoDeImagem as choose_project_image
—, and whoever ELECTS among the candidates is Infra, in a separate PR (1.5, closed
just below)
The RAG accuracy golden set (ADR 0132, RN-490)
The QA golden-set mold (ADR 0123) applied to the hybrid search: since the
judgment doesn't live in the engine (it is HybridSearchUseCase, in the api),
seed-golden-set-rag.ts provisions ONE project with a real curated corpus (22
files from docs/) AND runs the search for 17 questions composed from real
RNs/ADRs; rag_golden_test.exs only invokes the script and applies the floor. The hit
criterion — file path, top-5, never exact chunk/rank 1 — is a tested pure
function. A new rag-acertivo gate, warn (no CI with an LLM). Actually measured,
twice, deterministically: 17/17 in the top-5
Dev agents execute INSIDE the real container (PR 1.6) (ADR 0134, RN-492/493)
It closes what RN-491 declared pending: Engine.Actions.TerminalExecutor
gets the FIFTH outcome, :executar_no_container — an execution_mode: container project with a REGISTERED running row
(Engine.Containers.ProjectContainerLifecycle, a direct read, the same pattern
as Engine.Projects.Project) — and the command crosses engine → api (POST internal/projects/:projectId/container-exec,
ExecutarComandoNoContainerUseCase) → broker (ContainerBrokerPort.exec, the
FIRST real caller of the last of the five operations of ADR 0128/0130),
running via docker exec instead of a local System.cmd. cwd is translated from the
HOST path to inside /work in the engine, BEFORE the call — confirmed
in code to be the SAME physical directory Workspace.ensure!/4 already writes
(the dev agent's worktree changes neither place nor mechanism).
BrokerRecusouError/BrokerIndisponivelError/a transport failure become a
normal failed_result, never a crash nor a silent fallback outside the
container (RN-486: a registered running never guarantees the container is up).
A terminal INSIDE the container gets an auto-approval FLOOR
(ProposeActionUseCase/decide.ts, containerExecutionActive) — the five
absolute ceilings and the usual lexical scope keep running on top, byte for
byte
The runner brings up the project's container (PR 1.3, Part 1 out of order — closes the half that RN-494/PR 1.7 had left declared) (ADR 0137, RN-497)
container_start/container_stop/container_remove get a SECOND execution
path, branched by executionMode: container keeps going through the broker
(unchanged); mounted/runner now ask the connected RUNNER, through THREE
new pairs on the Phoenix channel (container_start/_result,
container_stop/_result, container_remove/_result, the same mold as
exec/exec_result) — EngineWeb.ContainerCommandController passes it on to the
RunnerRouter, which the runner serves with DockerViaCli (ITS Docker,
@brabo/docker-port, which gets real use beyond --self-test-docker). The
image is READ (ObterSpecDeContainerUseCase, the same use case as the broker's),
never re-elected. "No runner"/"timeout" (RunnerNaoConectadoError) and "runner
refused" (RunnerRecusouContainerError) become a named failed, never an
exception. Engine.Actions.TerminalExecutor didn't get a new outcome — it already
routed every command of a connected runner project to the channel
unconditionally; the host-vs-container choice is INTERNAL to the runner
(EstadoDoRunner.containerAtivo, tratarExec routes via docker exec with
cwd translated by prefix swapping, cwdParaContainer). guard.ts/RN-434
now covers the bind-mount without ANY new validation — the mount IS the root already
confirmed at the CLI's startup
The Infra handoff could be accepted by no screen at all (D0) (RN-499)
offeredHandoff filters the thread's card by AGENTES_DE_CHAT, which excludes
infra — and the filter is RIGHT (the engine has no message clause for the
Infra Lead; widening it would make the screen offer a thread that doesn't exist and the composer
send a message to the Criativo). The defect was the consequence, which the code's own
comment recorded as accepted: acceptHandoff had ONE consumer
only, behind that filter, so the OfferInfraHandoffUseCase handoff stayed
offered forever, the Infra Lead was never activated,
propose_container_start was never called and NO project in any mode
ever got a container up. Fix: its OWN actionable card, outside the thread,
on the fixed strip between the scrolling area and the composer — the same one that already hosts the
manual handoff and declares itself the place for handoff actions that aren't conversation
—, calling the acceptHandoff that already existed, closed by the same activeFor.
The Infra handoff.offered remains NARRATED in the thread as a mute divider
The single base of mounted projects (PR 1, BREAKING) (ADR 0141, RN-500)
Creating a mounted project required a bind-mount line written BY HAND in api
AND engine plus a restart of both — which kills every agent turn, terminal
socket and LLM call in flight in the installation, to onboard ONE project.
BRABO_PROJECTS_BASE is born: ONE base, configured once by the operator,
mounted by IDENTITY ($X:$X) in both services, with all mounted projects
inside it and NOTHING edited per project. Identity and not a fixed mountpoint
because workspace_path is typed by the user and shown back to them
— and it is what keeps projectScopeRoot/workspace_dir/2 correct without
new code. Its OWN variable (a namespace collision with the UNIQUE workspace_dir_name
would git init in another project's folder; opposite owner; the base is
browsable and the managed root shouldn't be). ABSENT is normal: projectsBase: null (GET workspaces/:id/projects-base, maintainer) and the mode isn't
offered. And pnpm dev REFUSES to start with the base overlapping the checkout — the
only possible check, because the api compares against /workspace. Two declared
costs: a symlink under the base and a single base
Container for a mounted project (PR 3) (ADR 0144, RN-503)
A mounted project couldn't get ANY container, because of two independent
blocks — the api sent every non-container mode to the RUNNER (which requires
a connected brabo-runner) and the broker refused non-container at the source. The
two were about GEOMETRY, not the mode's name, and ADR
0141's single base changed the geometry. The branching becomes by DESTINATION: container AND
mounted → broker, only runner → runner (and container_stop/_remove change
TOGETHER with _start, otherwise it comes up on the server and stops on the user's machine). The
ADR 0130 invariant doesn't move — no absolute path crosses the network:
the broker gets a SECOND root (BRABO_PROJECTS_HOST_BASE) and the api sends a
DISCRIMINATED locator (gerenciada |montada |indisponivel) saying
against which root the relative segment applies. THREE variants because
indisponivel has two different fixes (runner × a legacy mounted outside
the base), and a missing root is NEVER supplied by the other one. mounted ELECTS the
image like container — the broker composes from artifact.project_image, so an
unrecorded election is an inert election (ADR 0133 in a second mode)
Dev agents only after the container (PR 7) (ADR 0143, RN-502)
In a real run of exp001, TEN dev tasks got stuck at once — and the
finding wasn't the folder that failed, it was that nothing ordered container before
dev agent. Engine.Dev.AgentIo.try_claim/2 — the SINGLE claim point — now
queries ProjectContainerLifecycle.running?/1 (the predicate ADR 0134
already had) BEFORE calling the api: without a REGISTERED running row, the agent falls
into :idle, persists and emits dev.blocked_by_container. In the ENGINE and not only in the
api's activate-execution because REHYDRATION reaches try_claim/2 without
going through any route (init/1 → finish_restart_recovery/1). :idle and
NOT a new status, for the reason try_claim/2's own docblock already
recorded since Fase 12b — it is the only state from which a wake still rescues,
and all the guards of handle_info/2 rely on that; whoever distinguishes "empty
queue" from "no container" is the EVENT, never the status. The wake comes through the outbox:
RegistrarTransicaoDeContainerUseCase publishes container.running in the SAME
transaction that writes the arrival at running, in a NEW aggregate_type
(container, the third one Engine.Outbox.Drain reads) instead of borrowing task,
with the PROJECT as the aggregateId; the message delivered is the {:wake, :became_claimable} that already existed. Second half: TerminalExecutor stops
degrading silently — container without running fell into System.cmd INSIDE the
engine process, and now it REFUSES, with mounted in the same branch
A pipeline merge alarm with a recipient (ADR 0139)
The rule "promotion is --no-ff" was broken THREE times (#367, #394, #464 — the
third was the PR that fixed the first two). The finding: the detection ALREADY
existed and ALREADY worked (tag-release failed the first two with the
right message) — what was missing was WHOM to ring, because a push workflow that fails
on a permanent branch has no PR to go red on and the repo had zero issues.
tag-release gets the avisar job, which opens an issue (and comments on the already open one,
never duplicates) with github.token and never with the PAT — an invalid PAT is one of the
failures it reports — and that stopped being hypothetical: the BRABO_BOT_TOKEN
expired on 2026-09-03 at night, failed two tag-release runs, and was
rotated on 2026-09-04; v4.0.0 stamped and TRIGGERED the Release, which only
happens with a valid PAT (a tag created with GITHUB_TOKEN doesn't trigger a workflow).
The rule now covers dev NARROWLY: the only defect is a PR that brought a
NEW EDGE (head was a merge whose second parent wasn't in the base), never the
squash of a work PR, which is the convention. Turning off squash in the repo was
MEASURED and refused (it would tax every PR and touch changelog.mjs, which runs
--no-merges)
The conversational agents' decision artifact (Front 3, generic slice) (RN-505)
decision_record reuses the GENERIC pattern of emit_artifact/ArtifactSchemas
(the same as note/business_rule) instead of the dedicated one of
project_image/c4_diagram — a decision is an append-only log, never a
"current" one that gets replaced. PO, Arquiteto, Dev Lead, UX Designer and Staff
get the tool (alias + tool_specs + run_tool/3 + the same sentence in
system_prompt/1); the Criativo already had emit_artifact since Fase 3b and only
got the new type, with no change to it. Distinct from open_adr_pr (only the
Arquiteto, a real commit + PR + human approval): the two COEXIST for
different scales of decision. Out of scope, declared: execution agents
(ToolLoop, without a common harness) and the tie-in with the Infra Lead — emitting
decision_record when proposing container_start_via_runner — which depends on a
tool from another front of the same plan, in a separate branch
"Always allow" separated per module Dev Agent (RN-509)
Clicking "always allow" on an action of a dev-<modulo> wrote to
permissions.json, the WHOLE PROJECT scope — it released the same command for
any other agent. It now writes to agent_autonomy(projeto, agente, tipo), the SAME mechanism already used for the 3 git actions seeded on execution
activation. dev-lead (leads the area, isn't a member of it) and any
non-modular actor keep going to the usual permissions.json; the two absolute
ceilings (a terminal with an external effect/privileged command,
container_remove) keep refusing the whole click BEFORE the new branch.
No migration: old entries remain in permissions.json, a conscious
decision
Docker becomes a real prerequisite of Runner mode (RN-507/508, ADR 0145)
runner routed every approved terminal to the CLI as soon as a verified workspace
- connected runner matched, without checking for a
runningcontainer — without Docker up, the runner fell on its own to the pure HOST, the SAME silent fallback that ADR 0143 had already closed forcontainer/mounted.Engine.Runners.RunnerReadinessunifies the THREE preconditions (verified, connected,runningcontainer) with two consumers:TerminalExecutorand the materialization of the dev agent's worktree, which stops trying a LOCALFile.mkdir_p!/git initagainst the HOST path (the gap open since RN-478) and starts running INSIDE the real container, on the user's machine, through the SAMEexec/exec_resultchannel (Engine.Actions.Workspace.RunnerGit, new) —WorktreeManagerforks by the same criterion, and the periodic cleanup job silently SKIPS arunnerproject without a ready runner now. Theexecprotocol gets an optionalenvfor the git credential (ADR 0056) to travel in the environment of the HOST's child process — merged overprocess.env, never passed on todocker execnor logged.container_startstops servingrunner(its payload, with image election, never made sense there);container_start_via_runneris born — a schema with onlyrationale, it brings up the already decided image —, with the Infra Lead's new tool refusing LOCALLY (without HTTP) before proposing blindly
RN-505 duplicated in dev, fixed
The "decision artifact" and the "always allow per Dev Agent" merged 2
minutes apart, each one allocating RN-505 without seeing the other; "always
allow per Dev Agent" was renumbered to RN-509 (docs/business-rules.md,
CHANGELOG, the comment in approve-always-action.use-case.ts and in
ApprovalCard.test.tsx), freeing the {#rn-505} anchor for the
decision_record. Found along with it: the 505→507/506→508 renumbering done during the
conflict resolution of another PR (Docker/runner, ADR 0145) had been left
INCOMPLETE — three mentions of "RN-505" remained in this very file, also
fixed to "RN-507"
FASE 28 (session 5) — the local agent's join stops being mute (RN-514, ADR 0147 point 1)
The join of the terminal:<projectId> channel was mute on BOTH sides — the runner
sent {} and the server did _params —, so the server had no way to
know whether the binary on the other side understands a new message, and the defect was the
worst possible: the message arrives, the handler doesn't exist and NOTHING happens, without
error and without log. Now the runner DECLARES in the params
(Engine.Runners.Capacidades, vocabulary exec/pty/espelho) and the
server GRANTS the intersection with what it KNOWS, in socket.assigns and NEVER
in a table (a capability is a property of that CONNECTION). The runner declares only
exec and pty — declaring espelho without implementing it is exactly the defect
the negotiation prevents; it comes in as a vocabulary NAME and nothing more, and
session 6 implements it. Absent/empty params are the LEGACY binary and grant
{exec, pty} — a FACT, not benevolence: they are the two functions the runner
was born with, and refusing legacy would be breaking/ and MAJOR. Unknown is IGNORED
(a runner NEWER than the engine connects); required-and-not-declared refuses the join
NAMING the missing one, BEFORE the Registry — a mechanism implemented and tested
that today does NOT fire (runner requires exec, which every binary has; nothing requires
espelho yet), and that is the design. The half that delivers value TODAY:
a message without the capability is REFUSED with a named reply — exec replies in the
exec_result format (126 + cause) instead of the RunnerRouter waiting for the
timeout, and pty_* becomes pty_error in the tab instead of vanishing. RunnerReadiness
byte for byte as it is
FASE 28 (session 6, half B) — the mirror comes into existence (RN-516, ADR 0147 points 2/3/4/8)
The espelho capability goes from a vocabulary name to code on BOTH
sides. The DESTINATION travels in the join GRANT (the reply stops being empty
for :runner: %{espelho: %{destino: ...}}), lives only in that connection and the
runner REFUSES mirror_sync for a destination not granted — never global
configuration nor a variable, which would make project B's artifact land in project
A's folder. A non-null mirror_path now REQUIRES the capability, and that is the FIRST
real firing of RN-514's refusal mechanism: an old binary in a project with a
destination STOPS connecting, named and fatal — opt-in, cost declared in the ADR.
espelho-guard.ts is born a SIBLING of guard.ts (reuses the three helpers and the double
pass, inherits the TOCTOU caveat in writing) and refuses the ADR's three
things; a finding while implementing: realpathMaisProximo falls back to the ancestor, and since the
destination almost never exists, collapsing both sides would make /base-outra and
/base become / — the second pass resolves the ancestor and PUTS BACK the
suffix. ONE direction and it NEVER deletes: a file deleted at the origin STAYS at the
destination (the user's folder is accumulation, not a replica). What gets copied is the GIT
LIST (tracked + untracked-not-ignored), with no exclusion list
of its own; a failing git becomes a NAMED error, never a fallback cp -r; a nested git
repo (the dev agent's worktree) comes back as ONE entry and isn't descended into, and
a symlink at the origin is skipped. Its OWN predicate (Engine.Runners.Espelho), TWO
preconditions and NEVER RunnerReadiness with a flag — runner_readiness.ex stays
byte for byte. Trigger: the COMMIT, a named moment, fire-and-forget and never a
watcher; "end of an agent turn" stays DECLARED and not wired, because there is no
unambiguous hook
The broker that never came up (fix of RN-512) (RN-512, ADR 0130/0146)
RN-512 took the broker out of profiles so it would be up in dev, and it NEVER
came up: corepack prepare ran as ROOT at build (cache in /root/.cache),
the container runs as uid 1000, and without a cache corepack tries to DOWNLOAD pnpm in a
service whose only network is internal: true — EAI_AGAIN registry.npmjs.org,
Exited (1) five seconds later. Without a healthcheck, up --wait said
Healthy and the reset total announced "reset complete" with the process dead;
while there was a profile, whoever brought up the broker was someone already watching it. The
fix takes the registry out of RUNTIME and does NOT open the network (the first of the five
layers of ADR 0130): COREPACK_HOME prepared at build with the runtime uid's
owner, pnpm install as a BUILD step, and — because the three
node_modules volumes cover what the image installed and Docker only seeds an EMPTY
volume — an entrypoint that reconciles them against a copy outside /workspace,
stamped with the sha256 of the build's lockfile (the same stamp WARNS that the image
fell behind, without refusing to start). A healthcheck on the service, from inside the
container. Production didn't suffer from this and doesn't change
FASE 28 (session 7) — the local agent becomes a user service (RN-518, ADR 0147 point 5)
brabo-runner service install|uninstall|status, and the precedent was ZERO
(no mention of systemd/launchd in apps/runner/; the model was
foreground killed by SIGINT). USER level always — systemd --user/LaunchAgent —, and root is a REFUSAL, not a warning: guard.ts's docblock
declares "runs with HIS privileges" as the premise of the three real
boundaries, and a root service would leave the best-effort cwd guard as the only
thing between an approved command and the disk. Windows is a NAMED refusal (a third
mechanism, not a variation of the two). One unit per PROJECT because the server already
enforces one runner per project — naming by folder would create two units that never
come up together, and the second would appear as a connection failure instead of an
installation error; on Linux the file goes where systemd LOOKS
($XDG_CONFIG_HOME/systemd/user, otherwise ~/.config). Restart=on-abnormal and
NEVER on-failure (in the plist, KeepAlive/Crashed): the runner's exit 1 is a
fatal join refusal (RN-514) or an exhausted attempt ceiling, and restarting
would be the loop the CLI refuses to do on its own. The service authenticates by DEVICE
KEY and never by token — writing it into a unit would leave it on disk, and this
CLI never wrote a credential to disk. uninstall removes the THREE by reading the
UNIT's folder and never the cwd (without a unit, only with --dir — guessing would delete
another project's key), and SAYS that the key left the disk and was NOT revoked on the
server. status answers FOUR states with their own sentence and code
(0/3/4/5), and the first answer comes from DISK — that's why it stays right on a
machine without a manager; a failed spawn never becomes "the manager said
no". BRB-031 does NOT close here
The runner's spec went without projectId, and the path NEVER brought up a container (RN-508, ADR 0145)
container_start_via_runner (RN-508, ADR 0145) ended failed 100% of the
time — "container specification refused at projectId: expected non-empty
text, got undefined" —, and in a real run of exp004 what was seen
was the EFFECT: ten dev tasks with no agent claiming them. That block
was RIGHT (RN-507 and RN-502/ADR 0143 refuse without a REGISTERED running
container); what was wrong was the previous step.
EspecificacaoDeContainerParaRunner was born with NINE fields and
especificacaoValidada (packages/docker-port) always required TEN — the data
was at hand in SpecDeContainer.projectId and simply wasn't copied; the engine
passes the map on OPAQUELY, so nothing was lost in the middle. By contrast the
BROKER always worked because it FETCHES the spec from the api and receives the
whole SpecDeContainer — that's why only runner mode broke. Three
green suites didn't catch it because each link built its own fixture: nobody
tested the CHAIN. It comes in — the payload the REAL use case composes
crossing the REAL validator, and the SET of fields locked (raizDoProjeto
is the only one the api doesn't send) — in the api suite, through a RELATIVE import, the only
place where both sides exist alive; the
api-nao-consome-docker-port invariant stays green and was EXTENDED to fail a relative
import from src/. Siblings checked and RIGHT:
container_stop/container_remove send only workspaceDirName, which is all
that nomeDeWorkspaceValidado asks for
The pause that couldn't be undone (RN-540)
Anamnese and Psicólogo had been PAUSED since 2026-08-10 and three places called
the pause reversible — the docblocks of
AnamneseSchedulerWorker.enabled?/0/PsychologistWorker.enabled?/0 ("Turning
it back on is X=true and restarting the engine") and
docs/reference/configuration.md. It wasn't reversible in ANY environment: the
environment: of the engine service mapped the THIRTEEN
Anamnese/Psicólogo variables — all cost CEILINGS — and neither of the two PRODUCT ones, and
docker compose does NOT pass on the host's environment, so ANAMNESE_ENABLED=true
in .env was inert and runtime.exs fell back to the "false" default SILENTLY.
Measured in the dev container: [true] [] []. Likely origin: when the default
became false, nobody needed the variable to TURN IT OFF — only whoever tries to
TURN IT ON discovers the wire doesn't exist, months later. The EIGHT boolean flags
(System.get_env("X", "true"|"false") == "true") go into BOTH composes with
the SAME default as the code — the four extra ones (START_MODEL_SYNC,
START_GATE_RESCUE, the two GRAPH_*) because they are the same defect, found
by the mechanism itself, and excepting them would require four excuses. Zero change
in behavior: the product decision to keep both off stays byte
for byte, what comes into existence is the SWITCH. The mechanism is
scripts/ci/flags-do-engine-no-compose.spec.ts, which DERIVES the list from
runtime.exs (the other 50 variables are ceilings with a good default and release
things, legitimately absent) and demands an equal default only in the DEV compose —
the production one turns off the background agents on purpose. deploy/k8s/ does NOT
change, for coherence: the layer that breaks doesn't exist there
FASE 29 (session 1) — one-line installation, planning only (ADR 0149-0152, fase-29-instalacao-de-uma-linha.md)
Installing Brabo is THREE disconnected gestures (compose with five secrets by hand,
runner through the browser with chmod +x, pnpm bootstrap that only exists in a
checkout), and FASE 28 refused in writing the one-line installer while
the artifacts weren't signed (BRB-005) — that's why the phase signs BEFORE
installing. Four Proposed ADRs: keyless cosign signing by DIGEST plus
ONE signed checksums.txt, never five signatures (0149); the installer
invoked by sh -c "$(curl …)" and NEVER curl|sh, because with the pipe the script
IS the stdin and consent dies (0150); the runner's consented base,
reusing dentroDoEscopo and the per-SEGMENT loop refusal of
espelho-guard.ts instead of a fourth copy of the rule (0151); and
backup/restore against compose (0152). The measurement corrected FIVE points of the
original scope: workspace.verified does NOT exist (what exists is
workspace_confirm, and it is ONE-WAY), point 5 of ADR 0146 versions
consentir-base.mjs and not an install.sh, docker-compose.prod.yml doesn't
consume GHCR and declares itself a VALIDATION compose, make test-restore is a Kubernetes
path with a Postgres-only backup, and "one runner per project" is
enforced in FIVE independent places — which took the per-machine runner out of
this phase, to FASE 30. Its own finding: git_local_repos isn't in any
backup, and losing it is losing the code of the local projects.
docs/reference/brb.md is born — and the finding is that the BRB register isn't the
repository's: it lives in the maintainer's vault, with 33 items, and here only two ids
appeared in prose. Measuring: BRB-032 and BRB-033 describe defects that no longer
reproduce, and BRB-004 isn't what the id suggests — it asks for third-party images
by DIGEST, not the tag #419 pinned. The file here keeps the EVIDENCE
(measured state, file:line); the vault remains the owner of the finding. Range
RN-524..538 and ADRs 0149-0152, skipping what is IN FLIGHT: three collisions
observed at the same moment (ADR 0148 ×2, RN-522 ×2, RN-523 ×2), resolved
later by the DATE criterion — whoever allocated first kept the number, and the
branches of 08/09 became ADR 0153, RN-539 and RN-540
FASE 29 (session 2) — the pipeline signs what it publishes (RN-524, ADR 0149)
The product signed nothing: cosign/sigstore/attestation didn't exist in
.github/, scripts/ or docker/, and the only provenance was the
--provenance of the runner's npm package. release.yml published the four
images without id-token: write; build-runner-binaries.yml uploaded the five
binaries with gh release upload and nothing more — while the sha256sum -cs that
already existed verify third-party binaries, the pattern the repository
knew how to apply and never applied to itself. Now the images are signed by
DIGEST (never by tag, a moving pointer) and the binaries get ONE
signed checksums.txt, in a checksums job that waits for the matrix — not five
signatures, because whoever verifies four and forgets the fifth doesn't know they
forgot. The job is if: always() on purpose (fail-fast: false in the matrix) and
names what the manifest doesn't cover. Both workflows verify before
publishing. Keyless because of the house's history: a private key would be one more
CI secret, and a PAT has already expired silently (ADR 0139). A verification
procedure in the runbook, with the two --certificate-* flags mandatory —
without them cosign accepts a valid signature from ANYONE. Closes BRB-005
for images and binaries; left out, declared, are OS code-signing and the
download proxy
FASE 29 (session 3) — the installer that doesn't install yet (RN-526, ADR 0150)
install.sh is born at the ROOT, versioned and served by the Release. The form is sh -c "$(curl …)" and never curl|sh, for a MECHANICAL reason: with the pipe, the
process's stdin IS the download, and every read reads bytes of the script itself or
hits EOF — an installer that doesn't ask would choose on its own where to create a folder
on someone's machine. It VERIFIES ITS OWN ORIGIN (the signature of RN-524's
checksums.txt, and its own hash INSIDE the verified manifest), with
cosign pinned and checked by sha256sum against the hash written in the script;
DETECTION names what it found before asking; without a TTY it reports and exits 0; and
it never deletes a user folder — the projects base and the mirror folder stay
outside any confirmation, by the mirror's rule (RN-516). A marker in
$XDG_STATE_HOME/brabo/, never in ~/.brabo/, which is PER PROJECT. It doesn't
install anything in this version, and SAYS so instead of ending silently.
--print-plan/--print-state expose the decision without a TTY, without network and without effect
— the mold of bootstrap.sh's --print-commands, tested the same way. Two bash
bugs found while running, both of the same kind: under set -e+pipefail, a [ cond ] && assignment that is false KILLS the script, and an ls that doesn't match
brings down the whole substitution — in a function whose normal case is "found
nothing". The repository ROOT wasn't covered by any docmap (the root globs
are literal names); the instalador rule is born
FASE 29 (session 4) — the installation comes up from its own compose (RN-527, ADR 0150)
docker/docker-compose.install.yml is born, GENERATED from the production one and
different in three points: the four images come from a mandatory variable
(${BRABO_API_IMAGE:?…} — with a default, a missing variable would bring up half the
stack with an image nobody chose, and the error would become strange behavior
instead of a refusal), no build: survives, and the broker service does NOT
exist because its image isn't published (the bake has four targets) — with
--source=ghcr, mounted doesn't bring up a container, and the compose SAYS so in the
BROKER_URL comment. docker-compose.prod.yml stays intact: it
declares itself a VALIDATION compose and is what smoke.sh uses building locally. The
cost of the two files is declared and the ceiling is MECHANICAL —
composes-em-conformidade.spec.ts fails an extra service/volume/port,
a surviving build: and a loosened third-party image. Two things from the plan
died on contact with the disk, for opposite reasons: migrate isn't a step
(compose chains api→migrate-api with service_completed_successfully,
and two places ordering migration would be two sources of the same truth) and the
smoke.sh isn't reused (it BUILDS what the installation downloaded by digest,
and tears down the stack at the end) — in its place, the installer ASKS before asserting,
hitting /health on both
FASE 29 (session 6) — migrating requires a PROVEN backup (RN-530, ADR 0150/0152)
Installing over an existing installation isn't offered — either it migrates, or it
stops: up over another version's volumes is damage that gives no warning. The order is
backup → PROVE → ask → delete → install → restore, and the middle step
is what gives the installer the right to delete: a backup nobody
tried to restore is a file, and the time to find that out isn't after the
down -v. The proof runs test-restore-compose.sh (the SAME three validations
of ADR 0152); a backup or proof that fails stops the migration without deleting anything.
The destination is a HOST folder — the compose default is the backup_local volume,
which down -v would delete ALONG with what one wants to preserve. The Postgres dump
is NOT restored on its own over a populated database (it's destructive, and brabo-restore
never touches the origin); the local git repos are, because they would be born empty
FASE 29 (session 7) — one base for both sides, and BRB-031 closes (RN-531, ADR 0150/0151)
The installer asks for the base ONCE and writes it in BOTH places:
BRABO_PROJECTS_BASE in .env (server, ADR 0141) and the base field of
runner.json (local agent, RN-529) — what differs between mounted and runner
is WHO executes, not where the code lives, and two bases for the same folder would be
the collision ADR 0141 refused. A refusal in BOTH directions against the checkout,
and ~ isn't expanded on purpose (the value goes into a configuration file,
where ~ is read literally). The runner binary is checked against the
same checksums.txt the script already verified to check itself —
downloading it again would be two chances to get different manifests, and the
second wouldn't be verified — and installed with install -m 0755. BRB-031
closes here: it survived FASE 28 because the installation didn't come from a
versioned artifact, and whoever reached service install had already needed the
chmod. A Release without the platform's binary does NOT interrupt the installation
FASE 29 (sessions 8-10) — the protocol, the picker and the E2E (RN-532..534, ADR 0149/0150/0151)
8 (RN-532): the workspace_create/_result pair is born in the mold of
exec/exec_result, and the confirmation REUSES the workspace_confirm that already
exists — no new write route, the engine still doesn't write the table.
The predicate is its OWN for a stronger reason than the mirror's: there the
third precondition would be circular — a runner project only has a
running container after the runner brings it up, and the container comes up ON the folder that
this message creates. The workspace capability is the only one whose declaration depends
on STATE (consented base), not on the binary's version. 9 (RN-533): the
picker goes back to reading the user's disk through fs_list_dir, and the decision NOT to
hide the mode without a runner is symmetric to RN-513, not contrary — there the api HAS a
signal and without a base the creation ends in 400; here there's no presence signal (
RN-521 already declares it), and hiding would be deciding "doesn't have" from "don't know".
Finding: the screen decided by matching the SENTENCE 'Nenhum runner conectado' that lives
in an engine .ex — it became a discriminated reason. 10 (RN-534): the
install.sh enters the signed checksums.txt (without it the verification it
does of ITSELF was a dead letter) and is exercised on a clean machine, with a TTY
simulated by script -qec and never by a pipe — the pipe is what kills
consent. It does NOT run on PRs, for the same reason as the signature
FASE 29 (session 8) — the local agent's base gets a consumer (RN-532, ADR 0151 points 3–6)
RN-529 built the BASE and declared in writing that nothing consumed it. In comes the
workspace_create/workspace_create_result pair, in the REQUEST WITH
REPLY mold of exec/exec_result — and NEVER in that of workspace_confirm, which is
ONE-WAY ({:noreply, socket}, pushes nothing back), a precision that
the phase had already recorded along with "workspace.verified does NOT exist". Only the
relative SEGMENT crosses the network (the invariant of ADR 0130/0144: whoever has the
root is whoever executes), and an ABSOLUTE segment is refused lexically. The confirmation
REUSES the workspace_confirm that already existed, and that is what saves a whole
front: no new write route is born, the engine still doesn't
write the table, and the only path that stamps workspace_verified_at
remains a single one — with ORDER as the mechanism (confirm BEFORE the result,
because both reach the same channel process in order and the
confirm handler is synchronous). The workspace capability enters the vocabulary IN THE SESSION
IN WHICH THE CODE COMES IN, on both sides — RN-514 made the opposite bet with
espelho and it only paid off because someone demanded it —, and it is the ONLY one of the
four whose declaration depends on the STATE of that execution: the runner only
declares it with a consented base, and it is through that line, and no other, that the
server KNOWS a base exists (the engine doesn't read the user's disk). NOBODY
requires it at join, on purpose: requiring it in runner would be breaking/ and MAJOR
for a function that project may never use. Its OWN predicate
(Engine.Runners.PastaDoProjeto), TWO preconditions, RunnerReadiness byte
for byte — and here the third would be worse than unnecessary, it would be CIRCULAR: a
runner project only gets a running container after the runner brings it
up, and the container comes up on the folder this message exists to create.
It also doesn't ask about workspace_verified_at (here that's the RESULT) nor the
mode. The TWO preconditions are answered in different places because "consented
base" is a property of the CONNECTION (socket.assigns, never a table): the channel
refuses BEFORE pushing, with motivo: "sem-base" through the same ref, in the
format the module expects — never a timeout. Five named reasons that don't
collapse, idempotency with its OWN outcome (ja-era-repositorio is a success),
and estado.dir does NOT change. It isn't a proposed_action and no ceiling gets an exception
FASE 29 (session 5) — the backup starts covering what hurts to lose (ADR 0152, RN-528)
The backup covered ONE database and ran in ONE place: make test-restore is a
Kubernetes path (it requires kubectl, a cluster and the CronJob) and docker-compose.prod.yml
had NO backup service at all — migrating a compose installation had no
proven path. The decision starts by REFUSING the easy move ("back up
all the volumes") and CLASSIFYING the seven: neo4j_data and
project_workspaces are derived, ollama_data can be re-obtained,
brabo_projects_base is the user's — copying them gives an impression of coverage without
adding recovery. The sources of truth are TWO, and the finding is the second:
git_local_repos wasn't in any backup, and a project with the local
provider keeps its bare repo there — it isn't rebuildable from Postgres (the event log
keeps the NARRATIVE, not the git objects), so losing the volume is losing the
agents' code. The runbook's prose asserted the opposite ("the real
repositories live in GitHub/GitLab") and it is what explains the years without
coverage: it holds for github/gitlab, and for local there's no upstream. The
file's guarantee is DECLARED and smaller than a "snapshot" — consistency by
REFERENCE (git writes objects before moving the ref, and swaps the ref by atomic
rename), without a global instant and without git fsck (the image has no git, on
purpose); a concurrent gc becomes a FAILURE and not a partial file, with the tar
status captured in a FILE because in POSIX sh a pipe's status is that of the
last command. The five S3 variables stop being mandatory and become the
configuration of ONE destination (BACKUP_DIR chooses disk; absent, S3 — the
inference is what keeps the CronJob INTACT). make test-restore-compose
triggers a REAL backup and calls the SAME brabo-restore with the SAME three
validations: the WRAPPER changes, never the judgment, and
deploy/k8s/test-restore.sh stays INTACT (unifying would make the compose one
depend on kubectl); restore.sh still doesn't touch the ORIGIN database.
Restoring the repos is a SIBLING command (brabo-restore-git, verifies by default,
writes only with --restaurar) and not a phase of the other — two artifacts, two
judgments, and joining them would make a git failure fail the event log validation.
A finding while RUNNING, which reading wouldn't give: the volume is shared by three
images with different uids and carries the owner of whoever mounted it first (measured
1000:1000, mode 0755), while the image runs as uid 70 — the extraction
died halfway leaving the volume half done; now it refuses BEFORE the tar
naming --user 0:0, and what's extracted as root goes back to the DIRECTORY's owner,
otherwise the volume would come back intact and USELESS (api and engine, non-root, unable
to write to what was just restored). Neo4j gets REPROJECTION and
not backup — not built here (BRB-018): a migrated installation is born with the
graph EMPTY, a named degradation and not data loss (the RAG lives in pgvector,
inside the dump)
FASE 30 — the machine's local agent (ADRs 0154/0155, RN-543..552, closes in session 8 with RN-549)
FASE 29 delivered installing in one line and STOPPED at two points the
product itself already declared in writing: the local agent was still paired per
PROJECT (whoever wanted to use the installed binary went back to the browser to paste
a command into the terminal), and nobody could get in — the generated .env has
no email variable, MAIL_TRANSPORT falls to log and registration requires
verifying the email, so the way out was fishing the link from docker compose logs api.
Both were solved in the same place, and that's what made a single phase. The finding that
defined its SIZE came from re-measuring with file:line FASE
29's claim that "one runner per project is enforced in five places": three of the
five didn't need to change, because topic, socket id and ticket describe a
CONNECTION, and a process that opens N connections — one per project — satisfies them byte
for byte, preserving the refusal of a second runner on the same project, which is a real
guarantee and not an accident. Nothing in the engine changed in the whole phase, and that's what
kept RunnerReadiness, the mirror and workspace_create intact. What remained
was what belongs to the PROCESS: the credential (runner_device_keys.project_id becomes
nullable, RN-543 — and the listing starts returning the TWO kinds, marked,
otherwise a machine key would be invisible and permanent, the defect RN-519
closed, reborn) and the unit (RN-545, service install --machine, one per
machine, COEXISTING with the per-project ones). Two decisions contradicted the letter of the
ADRs and both were MEASURED: the unit's discriminator is the --machine flag and not
the absence of --project (resolverProjeto has two sources, and the normal path
is to run install without a flag from inside the folder the browser configured — taken
literally, the ADR would silently convert the installation of whoever already uses the product), and
install REFUSES when the other kind is already installed, without --force, because
the two together would be two processes competing for the same project. Session 6
would touch three workspaces at once and was SPLIT into three, with the two
disjoint pieces going out first and install.sh after them, as orchestrator and nothing
more. From that split came the phase's most interesting revision: with ZERO connections
the agent stopped EXITING and started WAITING, re-querying at 15s/30s/60s
(RN-550) — RN-544's argument against re-querying stays whole where it
holds (a list that comes back SMALLER is ambiguous, and bringing down a LIVE connection through ambiguity
trades a certain state for a guess), and with zero connections there's nothing to bring down; what
fell was the exit 0 argument ("an active service that does nothing"),
because the process started doing something and SAYING it does. Measured: Restart=on-abnormal
doesn't look at the exit code, so no unit changed and the effect is that the machine one
is genuinely active (running). The other piece (RN-551) closes "nobody CREATES a
machine key" from the agent's side, and the decision there is the CUT: the CLI does not
talk to the api, because whoever registers is the installer with the BRABO_SERVICE_TOKEN
— each side keeps a secret and neither sees the other's. From that follows the two-step
design, and from it follows the invariant: the kid IS the registration id and only exists
after it, so create writes a .parcial the reader ignores and
finish writes the real name — the file the runner reads never exists without a
kid, the defect of RN-475 made impossible by construction. The api half
(RN-552) declared the cost instead of hiding it: the credential is the service token,
and a leaked token can now MANUFACTURE a lasting credential — that's why three
containments live in the ROUTE and not in the text (there's no userId in the body; registering
REPLACES the owner's active machine keys in the same transaction, so a reinstalled
machine passes and a thousand keys are impossible by clause, with no number that
ages; and a JWK with d is refused by name). Session 6 (RN-547) chained the
five commands and fixed three rules: a middle link that fails undoes nothing, becomes a
named line in a final block and the script ALWAYS exits 0 (undoing would require deleting an
account and revoking a key, and there's no route for that nor should there be one the
installer calls on its own); idempotency is the HTTP CODE, and 409 is an EXPECTED
outcome and not a failure, with 000 handled apart so "the api didn't come up" isn't
read as "the api refused"; and the password travels through curl's STDIN, never through
argv, because /proc/<pid>/cmdline is readable by any user on the machine.
A finding of that session: --help had been LYING for months ("THIS VERSION INSTALLS
NOTHING") because it prints the header, which is a comment, and the rule forbidding
talk of "phase sessions" only read code lines. Session 7 (RN-548) gave
the kind marker a consumer, and the decision was one of HONESTY: with a key and without
a live connection — the most common case — the screen says the two things it knows and names
the two it doesn't (a registered key isn't an agent running, the list belongs to your
ACCOUNT and not this browser), accent tone and never success. Session 8
(RN-549) is the only one that could prove the whole, and it EXTENDED the workflow that
FASE 29 already had instead of creating a second one. Its central decision is the SIGNAL:
perguntarEstado maps activating to rodando — with reason —, so
systemctl is-active and service status --machine would give green to a process
that's going to die in two seconds; the chosen signal is the LINE the agent
writes when entering the wait, which only exists after it has read the key,
taken a ticket, talked to the api and received an EMPTY list, and which is a RECORDED FACT
and not a sampling of state. The outcome is proven through the API and not the
browser, because the subject of the claim is the AGENT and what it observes is
GET /runner/projects — a browser would create the project through the same route with one
more layer that fails for unrelated reasons, and the assertion would still be the
journal line. And the E2E stays OUT of pull_request, with the consequence
declared instead of hidden (the PR that wrote it didn't run it): opening
that door is giving the installer a way to skip the origin verification, which is
what ADR 0150 refuses — hence the half that runs on PRs being a TEST
(scripts/dev/install-e2e.spec.ts), guarding the two ways the workflow can
rot silently, the loosened gate and the rewritten install.sh sentence (which makes
the assertions not fail, but VANISH). Two defects showed up, both
invisible because the workflow had never run: the TTY answers started with
nao-migrar, which on a clean machine falls into the MARKER question and made the
script exit 0 saying "Nothing was written"; and the FINDING that stayed a finding — the
published install.sh brings up nothing on its own on a clean machine, because
docker/docker-compose.install.yml (with ./postgres/init.sql along) isn't a
Release asset, doesn't enter the signed checksums.txt and it doesn't download it
anywhere, so whoever follows the runbook's curl … install.sh dies at "no
such file or directory" AFTER having already verified the signature and written the
.env. Fixing it means deciding between a signed asset and a clone, and it's a delivery of its own.
What the phase left DECLARED and not done: revocation still reaches
{project, user} and never {key}, and now it costs more (it brings down the agent in
ALL the projects of that machine); there's no screen to list and revoke a device
key, and the phase added a kind to list without building the screen — the
gap became LARGER, on purpose; one process per machine is a single point of
failure, the design's price; and install --machine still doesn't know whether the key
in that folder really is a machine one, because on disk both kinds are the same
file and whoever knows is the server
The QA golden set measures again and runs on a schedule, weekly (AT-076/AT-149, ADR 0168)
Two deliveries, one human decision between them. AT-067 had measured that the
clock fit in an ubuntu-latest without a GPU and that the instrument didn't measure: since
RN-502 the npm test of a container project without a running container is
refused, and the seed never brought one up — 0/6 by construction. The maintainer chose
"path 1" and AT-076 delivered it: the seed brings up each case's container through the
PRODUCTION path (module_map → routing → container_start proposed by
Infra and approved by the owner → ExecuteContainerStartUseCase → broker → running
through the state machine), with node:24.11.1-bookworm-slim pinned by digest,
RN-502 intact, and a container that doesn't come up becoming a NAMED error that fails the
ExUnit (before, the body's {:skip, _} passed green silently). Two runs in CI
gave 3/6 and 3/6 against the 1/6 floor, with different cases, in 52 and 86 min of
job. AT-149 closed the cadence: WEEKLY (Saturday 01:17 UTC, plus
workflow_dispatch, never pull_request), in golden-set-qa.yml, in the mold of
golden-set-rag.yml. Weekly and not nightly because a run costs up to 86 min and
VARIES — the same total through different cases —, and once a week is enough for the floor
to flag a drop. timeout-minutes: 150 for the worst measured case. The verdict is
the test's own; the workflow adds only two INSTRUMENT guards (the score
line must exist, and the event log can't have an RN-502 refusal) and, on
failure, prints the logs like the RAG one — no issue. The image to pull before the seed
is READ from the seed's constant, never from a second literal, because the broker's docker run
runs under the 30 s control ceiling (RN-604). The proof run, with a
temporary push trigger removed before the PR, was 36327281774: 3/6
again, zero RN-502 refusals, 10 min of mix golden_set.qa and 13 min 41 s of
job. Declared: the CI broker doesn't have the compose's five layers, the heartbeat
closes the seed's sessions while QA is still working on them (with no visible
effect), and a PR that touches the workflow doesn't run it.
The Infra Lead converses through the composer (AT-141, ADR 0175, RN-617)
RN-584 (AT-098) removed the chat's default recipient and left the product
question in writing: does the Infra Lead converse? The maintainer said yes on
2026-09-27, with the condition RN-584 itself had set — the turn first.
The Infra Lead's turn ran ENTIRELY inside handle_call/handle_cast: without
acceptance, without "Stop", outside TurnoOrfao, with the loop's 14th step ending
silently and a 180 s ceiling smaller than the 225 s of the container propose_action
(RN-605). The kickoff, being a cast, didn't hold a click, but it held the process —
8.4 min measured in AT-089 —, and a message in the meantime would wait in the queue.
Stage 1 put the three turns (kickoff, gate correction, message) into
TurnoAssincrono, with the Workflows consolidation inside the Task; a gate
correction arriving with a turn in progress got a queue, because TurnoAssincrono
without from would discard it. Stage 2 gave infra the message/2 clause
(the agente_sem_conversa refusal went away), the via_for of "Stop" and a place in
AGENTES_DE_CHAT; the acceptance of its handoff stayed in RN-499's own card, and
the thread card excludes it by name. SOLO_CONVERSATIONAL_AGENTS didn't change: the Infra
Lead is an area lead. Declared: it didn't get structured questions nor
backlog reading.
Credentialed git runs on the runner's host, the code in the container (AT-116, ADR 0193, RN-676)
2026-10-01, round 36. RN-558 had closed the SILENCE half — the authenticated git fetch in runner mode, with the container up, stopped running with
empty variables and started being refused with the mark
credencial-nao-atravessa-o-container — and declared the other half as a pending
ADR, because every known option touched the containment port of ADR 0130.
The owner's decision (01/10) chose the one that doesn't touch it: credentialed git operation
on the HOST, code in the container. Measured before: the engine's only exec with env
was RunnerGit.fetch!/3's fetch; there was no credentialed push nor clone
through exec (the folder-creation clone already ran on the host, RN-532); and the folder is
the same on both sides (estado.dir mounted at /work). The chosen discriminator
was an explicit MARK from the engine (gitCredenciado: true, set only
by RunnerRouter.exec_git_credenciado/5), and not the env that already existed —
with env as the key, any command with env would escape the container. RN-558's
refusal survived shrunk, for env without the mark, and in practice
came to mean "runner older than ADR 0193". The proof is AT-111, on both
sides: on the runner, a real git credential fill and git fetch origin with
GitAuth's helper succeed on the host with the container active; on the engine, the chain
through the real TerminalChannel ends in {:ok, _}. The ExUnit tests didn't run in the
delivery environment (repo.hex.pm 403) and were left for CI.
The consultative session without an agent asks for an agent (AT-254, RN-682)
2026-10-01, round 36. In the real use of 29/09 a consultative session without an agent
answered "I don't have access to previous conversations": the message without a
recipient went to the SSE of POST .../chat, which sends the bound model only the
current text — no history, no system prompt — and writes the reply with the
model's name as the actor. The owner's decision (01/10) was to ask for an agent instead
of giving history to the free chat. Measured before: the screen was the only client of the
route (the curl printed by the seed is a development hint, in a creative
session), and the SendChatMessageUseCase use case had a second legitimate
consumer, the provider smokes, which use it as an end-to-end instrument
in a session without an agent. That's why the api's refusal lives in the ROUTE, in its own
guard, and the use case stayed intact. On the screen, the recipient line started
carrying the manual handoff's own selector when there's no agent — the same
usual gesture, now in the place where it is the only way for the message to have a
destination —, and the Criativo stayed off the list because the consultative session promises it
doesn't come in.
Revocation targets the key, and not the {project, user} pair (AT-013, ADR 0201, RN-685)
2026-10-01, round 36. RN-520 had made revocation reach the live
connection, with the {project, user} target because the credential's identity died
in PatAuthGuard; ADR 0154 put "revocation by key" among what it didn't
do, and PR #534 gave the machine key the same pair applied project by
project. AT-013 stayed blocked by scope and by decision until the owner
chose (01/10): by KEY, with an ADR, touching the engine. Measured before: the
ticket was born from {userId, kind}, the socket kept only project/user/kind,
and the channel compared the user — the same user's runner with a PAT or another
key fell along; the machine key's connection in a project outside the api's
list stayed up; a ticket issued before the revocation got in after it; and
revoking a PAT brought down nothing. Delivered: two nullable columns in the ticket
(an Ecto migration, the table is the engine's — Drizzle's journal stayed at 0068),
the guard annotating the credential after authorizing, the socket id gaining the
kind and id of the credential, and a runner/disconnect-credential route that voids
the pending tickets and asks every runner in the cluster whether it was born from it. The
AT-013 prohibition (not ceasing to bring down what used to fall) became two transition
pieces: the legacy connection falls through the pair in the row's projects, and an engine
without the route makes the api go back to the pair. The pair stayed as the target of member
removal. The screen changed only the confirmation text (RN-561). The runner didn't change.
The ExUnit tests didn't run in the delivery environment (repo.hex.pm 403) and were left
for CI.