Pular para o conteúdo principal

Business rules

Each rule has a statement, where it lives (file:line), and the test that covers it. If you change a rule, update the line here in the same change — that's what .docmap.yml requires with block severity.

They all live in apps/api/src/domain/, which is pure: no IO, no framework, no database. That's why each one has a fast, deterministic unit test.

Business context​

Brabo executes engineering work through AI agents. Two forces shape virtually every rule here:

The agent is not trustworthy by construction. It's a language model: it can hallucinate, get stuck in a loop, or request something destructive. The rules exist so the possible damage is bounded by structure, not by prompt quality.

Spend is real and continuous. Every turn consumes a paid token. Budget, ceiling, and metering aren't administrative conveniences — they're what stops an agent loop from turning into a loss.

Actors​

actorwho it isauthority
Userthe person. Roles: viewer, developer, maintainer, ownerfinal decision on any action with external effect; merge is theirs exclusively
Agentlong-running process with a role (Creative, PO, Architect, Dev, Infra, QA, SecOps, Psychologist, Anamnesis)proposes; never decides alone what policy doesn't allow
Systemapi and engineenforces policy, records the event, measures cost

The vocabulary is in the glossary.


Session​

RN-001 — The session has five states and closed transitions​

created → active → closing → closed, with closed_abnormally reachable from any non-terminal state. From closing there's never a way back to active, and terminal states have no exit.

  • Where: apps/api/src/domain/sessions/session-state-machine.ts:29
  • Test: test/domain/sessions/session-state-machine.spec.ts
  • Edge case: closing is a transient state. A session stuck there means the drain started and didn't complete — there's an alert for that, see the runbook.

RN-002 — Every session event is immutable and seq is dense​

There's never an UPDATE on session_events. seq is unique per session (unique(session_id, seq)) and has no gaps: it starts at 1 and is contiguous.

  • Where: apps/api/src/db/schema/sessions.ts (table session_events)
  • Test: verified on restore — docker/backup/restore.sh fails if count(*) ≠ max(seq) − min(seq) + 1 or min(seq) ≠ 1
  • Why: it's what makes the Psychologist's evidence traceable and the backup verifiable. State that needs to change lives in its own table, alongside.

RN-097 — The session's kind is creation intent; execution stays an event​

The session is born consultiva (consultative) or criativa (creative), chosen by whoever opens it and recorded in sessions.kind. consultiva is just conversation; criativa is the one that produces — it opens ideation with the Creative agent and is the only one that enters execution. The kind never changes: there's no route that swaps it.

The risk in this rule isn't the field, it's the second source of truth. The product already knew how to say whether a session is executing, and knew it through another path: findActiveExecutionSession looks for the active session that carries the execution.activated event (it's what makes reactivation land on the session where the dev agents already are, finding #11 of the first dogfooding). The two coexist under a rule that keeps them from fighting:

  • kind classifies creation INTENT. A criativa session that never activated execution is not the current execution session, and the event-based derivation keeps not looking at kind — if it started to, every open creative session would become a candidate to receive the dev agents.
  • The event classifies execution STATE, as always.
  • execution.activated on a consultiva session is an ERROR (409), never silent conversion. This is the point that decides which of the two sources wins: letting the event promote the kind would be exactly having them write over each other.

The guard lives in the funnel — AppendSessionEventUseCase — not in ActivateExecutionUseCase, because both paths that write an event (the user's route and the engine's /internal/*) go through it. The extra session read is only paid when the event is the execution one. The refusal happens before incrementSeq: a refused attempt can't consume seq, or RN-002 would break.

The column's DEFAULT is consultiva — the kind that can do less: a row arriving through a path that doesn't go through the route (migration, maintenance SQL) doesn't earn the right to execute. The migration's backfill does the opposite and is deliberate, by the 0033 reasoning: at the moment it runs, every session predates the distinction, and some are sessions where dev agents are working — waking them up as consultiva would make reactivating an in-progress project fail without anyone having decided anything.

  • Where: apps/api/src/domain/sessions/session-kind.ts:50 (podeAtivarExecucao), apps/api/src/db/schema/sessions.ts:68 (the column), apps/api/src/application/use-cases/sessions/append-session-event.use-case.ts:53 (the guard)
  • Test: apps/api/test/application/use-cases/sessions/session-kind-e-nome.spec.ts
  • Origin: ADR 0061

RN-098 — The session name adds to the hashtag, never replaces it​

The session can receive a friendly name (sessions.name, optional), at creation or later, via PATCH /projects/:projectId/sessions/:sessionId. The label shown on screen is composite: <name> · #<first 8 characters of the id>. With no name, it degrades to the hashtag alone.

The hashtag never disappears, and the reason is operational: it's what gets pasted into a URL, a command, or a conversation, and a name chosen by a person isn't unique — two sessions named "Checkout" are normal. A blank name (or whitespace only) counts as absence, both on creation and on renaming: writing '' would turn the label into " · #a1b2c3d4", worse than the hashtag alone. null in the PATCH body is the way to undo.

Renaming is not a session event. The event log is what the session lived through, and the name is a navigation label, changed as many times as desired — N renaming events would push exactly what matters out of the 200-event tail.

  • Where: apps/web/src/lib/session-label.ts:61 (rotuloDaSessao), apps/api/src/application/ports/session-repository.port.ts:52 (rename). Reachable both from inside the session (apps/web/src/routes/SessionPage.tsx:439, handleRename) and from the project's list, without needing to open the session first (apps/web/src/routes/ProjectSessionsTab.tsx:101, handleRenomear) — both screens call the same renameSession and the same rotuloDaSessao.
  • Test: apps/web/src/lib/session-label.test.ts, apps/api/test/application/use-cases/sessions/session-kind-e-nome.spec.ts, apps/web/src/routes/ProjectSessionsTab.test.tsx
  • Origin: ADR 0061

RN-104 — The tab derives from the recorded kind, and creates in that kind​

The project has two session tabs — Creative and Chat — and each one is a kind: it lists the sessions whose sessions.kind is its own, and its CTA creates in that kind, without asking again. There's no third tab listing both together.

The kind was already immutable (RN-097), and that's what makes it a legitimate navigation coordinate instead of a saved filter: a session never switches tabs. The tab reads the recorded field — it doesn't derive the kind from any event, for the same reason as RN-097: execution.activated classifies execution STATE, and a tab that looked at it would move on its own.

Two consequences the rule fixes, and which aren't screen detail:

  • The kind choice leaves the form. It used to live in a radio-button fieldset, and now it happens the moment the person clicks the tab. Keeping both would offer the chance to create a session that contradicts the place you're standing in. What's left in the form is the name, which stays optional (RN-098).
  • One action, one place at a time. "Start ideation" still exists — it's what brings the Creative agent into the session, and from there on the owner's key starts being spent (RN-058); no one steps in alone. What changed is where it lives: inside the invite while the invite is on screen, and in the topbar when it isn't. The invite used to POINT to the topbar ("use Start ideation, at the top of the screen"), which is the literal version of the problem that originated RN-097 — the action in one place and the explanation in another. The topbar can't simply lose the button: it's possible to type into a creative session without ever having called the Creative agent, and then the invite leaves the scene.

The three screens honor the three states of RN-088, with error before empty: the filtered list is empty in both cases, and saying "no ideation yet" after a 429 would be lying about what happened.

The Chat's deep-link key stays sessions, not chat. That's what makes an old bookmarked ?tab=sessions link open the Chat with the tab marked in the tab bar — resolving the old key as an alias only in the panel would leave the tab bar with no selection at all, because Tabs compares active to key, and whoever writes active receives the raw key from validateSearch.

  • Where: apps/web/src/routes/project-tabs.ts:95 (both entries), apps/web/src/routes/ProjectSessionsTab.tsx:114 (the filter by recorded kind) and :98 (the CTA creating in the tab's kind), apps/web/src/routes/SessionPage.tsx:716 (conviteVisivel, the one question the topbar and the invite share)
  • Test: apps/web/src/routes/ProjectSessionsTab.test.tsx, apps/web/src/routes/project-tabs.test.tsx, apps/web/src/routes/SessionPage.sessao.test.tsx
  • Edge case: a project with no session of that kind shows the empty state OF THAT KIND, even when it has sessions of the other kind — that's information, not an error.
  • Origin: ADR 0061

RN-123 — Creative session with no active Creative agent: the first message ALSO activates it​

In a kind: 'criativa' session (RN-097) with no agent.activated yet, sending the first message through the composer activates the Creative agent first (waiting for activation to finish) and only then delivers that message to it through the real path (sendAgentMessage — history, system prompt, emit_artifact tool). Before this rule, whoever typed without clicking "Start ideation" fell into a generic SSE chat with no history and no business rules: it was NOT the real Creative agent, and the conversation recorded nothing in the domain.

The "Start ideation" click still exists (RN-104) — it's still what triggers activation when the person prefers the explicit gesture, and it's from it that the owner's key (RN-058) starts being spent on either of the two paths. What changed is that the FIRST MESSAGE now also counts as that gesture: no one should need a separate click before talking to whoever the screen already invited them to talk to.

  • Where: apps/web/src/routes/SessionPage.tsx:678 (handleSend)
  • Test: apps/web/src/routes/SessionPage.ideacao-automatica.test.tsx
  • Edge case: a consultiva session has no Creative agent — the rule doesn't apply, and the generic SSE path stays the right one for it.

RN-119 — The session's ACTIVE agent is the one with the most recent agent.activated — never a fixed chain​

SessionPage decides two things from the SAME agent: who the composer's message goes to, and which agent the topbar resolves the displayed model from (the full cascade session→agent→area→project→workspace, the same one RunLlmTurnUseCase uses to actually run the turn). The two questions used to have DIFFERENT answers before this rule: routing used a fixed precedence chain (architect > po > creative, based on historical EXISTENCE — "has already activated once", which never "turned off"), and the displayed model didn't even know who was active — the model-binding route received no agent at all and always fell back to the Creative agent's fixed fallback (herdarModeloDeStart), even after a handoff to the PO, Architect, or Dev Lead.

The single definition: the ACTIVE agent is the one with the most RECENT agent.activated (by seq), among the conversational agents of the chat flow (Creative, PO, Architect, Dev Lead). Infra Lead is left out: agent_command_controller.ex (engine) only has a message route for po/dev-lead/arquiteto (plus Creative, as the last clause's implicit fallback) — Infra never had message wired, only start. Sending a message to "infra" today would silently fall through to the Creative agent; treating it as the composer's active agent would reopen that trap instead of closing one.

Superseded in part by RN-631 (AT-251): "most recent agent.activated wins" was itself an implicit default recipient, and an invisible one. The composer's recipient is now CHOSEN by the person (a "To" selector listing the agents already in the session) and the topbar model follows that recipient. What survives from this rule is the half about the model: the binding route receives the agent, never falls back silently.

  • Where: apps/web/src/lib/session-destinatario.ts:290 (useDestinatarioDoChat, since RN-631), apps/web/src/lib/api-client.ts:1160 (getSessionModelBinding, the agentId), apps/api/src/interfaces/http/llm/model-bindings.controller.ts:166 (getSessionBinding, @Query('agentId'))
  • Test: apps/web/src/routes/SessionPage.destinatario-do-chat.test.tsx (formerly SessionPage.agente-mais-recente.test.tsx), apps/web/src/routes/SessionPage.modelo-do-agente-ativo.test.tsx, apps/api/test/application/use-cases/llm/resolve-model-binding.use-case.spec.ts
  • Edge case: Infra Lead doesn't participate in composer routing nor in the "active agent" definition — it's proactive (Phase 4), not conversational through the composer.

RN-120 — The session's event poll pauses during an in-progress turn​

useSessionEvents fetched events every few seconds with NO awareness of an in-progress turn. If the poll landed DURING a turn (common — turns usually last longer than the interval), it brought already-persisted chat.message/agent.response, which rendered ALONGSIDE the optimistic/streaming state still on screen: the same message, twice.

The fix pauses only the TIMER (refetchInterval) while streaming is true — the query stays enabled, so the explicit invalidation that already fires at the end of the turn (finalizarTurnoDoAgente) keeps fetching fresh data at the right time. pausarPoll is an OPTIONAL parameter, default false: the hook's other consumers (Overview, Code, Provisioning, AdoptionPlan) have no conversational turn in progress and stay as they were.

  • Where: apps/web/src/lib/hooks.ts:221 (useSessionEvents), apps/web/src/routes/SessionPage.tsx:277 (eventsQuery)
  • Test: apps/web/src/lib/hooks.pausar-poll.test.tsx
  • Edge case: pausing the timer isn't disabling the query — explicit invalidation keeps working, and the fix depends on it to never miss data.

RN-122 — The "Stop" button cancels the turn FOR REAL, killing the task holding the LLM call​

Until now there was no cancellation anywhere. The root was structural: agent_command_controller.ex handled the user's message with GenServer.call(pid, {:user_message, texto}, ...) — SYNCHRONOUS — and the entire turn (tool loop + SSE call to the LLM) ran INSIDE handle_call. The agent's process stayed blocked until the turn finished and handled NO other message in the meantime — not even a future cancel command.

The fix: the turn handle_call/handle_cast of the four conversational agents (Creative, PO, Architect, Dev Lead) spawns a Task.Supervisor.async_nolink/2 (Engine.Agents.TurnoAssincrono, new, shared by all four) with the heavy work and returns {:noreply, state} — the reply to the original GenServer.call is DEFERRED until the task finishes (GenServer.reply/2, fired when {ref, resultado} arrives at handle_info). Since the agent process STOPS being blocked, a new handle_cast(:cancel, state) arrives and is handled while the turn runs: Task.shutdown/2 in :brutal_kill mode kills the task, which drops the HTTP (SSE) connection it holds with the api — that's what makes cancellation actually save tokens, not just stop rendering on the client. The original from receives {:error, :cancelado}, and a TERMINAL agent.error is recorded with origin "politica" (policy — the same origin as budget/credential/binding — cancelling is a user decision to stop spending tokens, not a fifth value in ADR 0020's closed vocabulary) — without it, GetSessionPendingWorkUseCase would see agent.activated with no subsequent agent.response/agent.error, and the session would stay hung on the pending-work signal.

Two competing messages to the same agent (the user sends again while a turn is already running) never spawn a second task: TurnoAssincrono.iniciar/3 replies {:error, :turno_em_andamento} (turn in progress) right away.

Revised by RN-578 (ADR 0163): the reply to the original GenServer.call is no longer deferred until the task finishes — it goes out on ACCEPTANCE, as soon as the task is up, and cancelling answers nobody (the caller already has :ok). {:error, :turno_em_andamento} stopped being discarded by the controller: it is a named 409 and a durable agent.error.

  • Where: apps/engine/lib/engine/agents/turno_assincrono.ex (the mechanism), apps/engine/lib/engine/agents/{criativo,po,arquiteto,dev_lead}_server.ex (the four turn handle_call/handle_cast), apps/engine/lib/engine_web/controllers/agent_command_controller.ex:373 (cancel/2), apps/engine/lib/engine_web/router.ex (POST /internal/sessions/:sessionId/agent/cancel), apps/api/src/application/use-cases/agents/cancel-agent-turn.use-case.ts, apps/api/src/interfaces/http/agents/agents.controller.ts (POST /projects/:projectId/sessions/:sessionId/agents/:agent/cancel), apps/web/src/routes/SessionPage.tsx (handleCancel, "Stop" button in the composer)
  • Test: apps/engine/test/engine/agents/turno_assincrono_test.exs (the task dies FOR REAL — Process.alive?/1 before and after — not just "stopped importing the result"), apps/api/test/application/use-cases/agents/cancel-agent-turn.use-case.spec.ts
  • Edge case: cancelling with no turn in progress is an idempotent NO-OP — there's no task to kill nor pending from to reply to. Cancellation does NOT undo a side effect that already ran BEFORE the task died (e.g., a tool that had already been dispatched and recorded its event) — it only interrupts what hasn't happened yet.
  • Origin: this session's investigation; no dedicated ADR (a concurrency pattern change INSIDE the harness, not a layer/database boundary).

RN-142 — Confirming readiness with NO business rule at all is refused by the engine, not just hidden in the UI​

Before, clicking "I'm ready to produce" — or calling the route directly — ALWAYS created the product_brief and offered the handoff to the PO, even in a conversation where zero business rules had been captured. business_rule_refs(state) already existed in criativo_server.ex, but only to POPULATE the brief's "rules" field — never as a blocking condition. In the UI, SessionPage.tsx disabled the button only during streaming, without checking the rule count.

The real guarantee had to live on the server, not on the screen: whoever calls the route directly (or a future client that isn't this frontend) can't punch through the guardrail just because the button is drawn disabled. CriativoServer.handle_call(:confirm_readiness, ...) now checks business_rule_refs(state) BEFORE spawning the Engine.Agents.TurnoAssincrono Task (RN-122) — empty, it refuses right there, without running the consolidation turn, without a product_brief, without a handoff.

The refusal doesn't turn into an HTTP 4xx: agent_command_controller.ex#readiness/2 IGNORES this GenServer.call's return and always answers 202, the same pattern message/2 already uses since RN-122 ("this response is just the acknowledgment" — the real outcome lives in the event log). That's why the refusal is narrated as a DURABLE agent.error (RN-059), with origin "politica" (policy — it's a product decision, not an infra/model/turn failure) — and a message explaining why; the session thread already knows how to render this event type (lerFalhaDeTurno, the same red bubble as any other turn failure).

The complementary UX, in SessionPage.tsx: the button is born disabled (with a title explaining why) while events has no artifact.business_rule — the SAME source that already feeds the "Business rules" panel (ContextAside), with no second read that could diverge from the first.

  • Where: apps/engine/lib/engine/agents/criativo_server.ex (handle_call(:confirm_readiness, ...), emit_falha_sem_regra/1), apps/web/src/routes/SessionPage.tsx (hasBusinessRule, the "I'm ready to produce" button)
  • Test: apps/engine/test/engine/agents/criativo_server_test.exs ("readiness: refuses when NO business rule was captured" — no turn, no brief, no handoff; the refusal narrated with origin "politica"), apps/web/src/routes/SessionPage.readiness-exige-regra.test.tsx (button disabled with no rule, enabled with 1+)
  • Edge case: the session already had readiness.confirmed recorded by the api BEFORE signaling the engine (ConfirmReadinessUseCase) — that doesn't change: it's the record of the user's CLICK, a fact that happened independent of the engine accepting or refusing afterward.
  • Origin: code investigation confirming a user report — no ADR (new guardrail on an already-existing flow, no layer/database boundary change).

Action approval​

The heart of the system. Every action with an external effect is born as a proposed_action.

RN-003 — The action has six states, and denied is terminal​

  • Where: apps/api/src/domain/actions/action-state-machine.ts:36
  • Test: test/domain/actions/action-state-machine.spec.ts
  • Edge case: an approved action that executed becomes executed, not still approved. Counting approvals by status = 'approved' gives the wrong number — the correct criterion is decided_by IS NOT NULL.

RN-004 — The decision is evaluated in three stages, and deny wins immediately​

Order: (a) IAM → (b) agent_autonomy → (c) permissions.json. Each stage can only raise permissiveness; a silent stage never lowers the previous one. deny at any stage returns immediately.

  • Where: apps/api/src/domain/actions/decide.ts:118
  • Test: test/domain/actions/decide.spec.ts (10 KB — the largest in the domain)

RN-005 — Minimum role per action type​

Before any policy, IAM: each ActionType requires a minimum effective role. Without it, deny with an explicit reason.

  • Where: apps/api/src/domain/actions/decide.ts:115 (MIN_ROLE_FOR_ACTION_TYPE)
  • Test: test/domain/actions/decide.spec.ts

RN-006 — Ceiling: merge into a protected branch is never auto-approvable​

dev, qa, rc, and main are protected. A git_merge targeting one of them is downgraded from auto_approve to require_approval after all the policy has run. Neither agent_autonomy nor permissions.json can promote it.

rc stays on the list even after the step left the policy (ADR 0030) and the bootstrap stopped creating it (RN-029). This list decides what the lock refuses, and repositories bootstrapped by earlier versions still have the branch: protecting one that doesn't exist costs nothing; unprotecting one that exists costs dearly.

  • Where: apps/api/src/domain/actions/decide.ts:511 (isProtectedBranch, o teto da trava de merge) + protected-branches.ts:4
  • Test: test/domain/actions/decide.spec.ts
  • Origin: ADR 0011 §1
  • Note: the equivalent protection on the platform (GitHub/GitLab) diverges between providers and isn't the gate — see ADR 0028.

RN-007 — Ceiling: an instruction patch is never auto-approvable​

Changing an agent's instruction always requires a human decision. The feature's value is in the human seeing the diff; auto-approving would be the agent rewriting itself.

  • Where: apps/api/src/domain/actions/decide.ts:168
  • Test: test/domain/actions/decide.spec.ts
  • Origin: ADR 0016 §8

RN-008 — Command matching is by pattern, not substring​

permissions.json matches a terminal command by structured pattern, with the command properly tokenized — not by includes().

  • Where: apps/api/src/domain/actions/command-matcher.ts
  • Test: test/domain/actions/command-matcher.spec.ts

Backlog​

RN-009 — The story has four states, and done is terminal​

draft → ready → in_progress → done. Rework is allowed: ready → draft and in_progress → ready.

  • Where: apps/api/src/domain/backlog/story-state-machine.ts:27
  • Test: test/domain/backlog/story-state-machine.spec.ts

RN-010 — draft → ready requires four things, validated in the domain​

For a story to become ready it needs, all of them:

  1. dod not empty — Definition of Done
  2. dor not empty — Definition of Ready
  3. at least 1 functional requirement (rf)
  4. at least 1 linked business rule (businessRuleIds)

The error names exactly what's missing, not a generic "invalid".

  • Where: apps/api/src/domain/backlog/story-readiness.ts:39
  • Test: test/domain/backlog/story-readiness.spec.ts
  • Origin: ADR 0009 §3
  • Why: it's what stops the PO from dumping a vague story into the dev queue.

RN-011 — Business rule with no story is a finding, not an error​

Rule→story coverage is computed, and what has no story shows up as pending — information, not a failure.

  • Where: apps/api/src/domain/backlog/coverage.ts
  • Test: test/domain/backlog/coverage.spec.ts

RN-012 — A module removed from module_map demotes the story​

A story linked to a module that ceased to exist goes back to draft, with a backlog.story_demoted event.

  • Where: apps/api/src/domain/architecture/module-resolution.ts; the event is emitted in application/use-cases/architecture/create-module-map.use-case.ts:73
  • Test: test/domain/architecture/module-resolution.spec.ts

RN-013 — The module graph cannot have a cycle​

  • Where: apps/api/src/domain/architecture/module-graph.ts
  • Test: test/domain/architecture/module-graph.spec.ts

PR Gates​

RN-014 — The gate order is immutable and awaiting_user is terminal​

awaiting_qa → awaiting_secops → awaiting_user. Approving QA never skips straight to the user. changes_requested sends it back to the dev on the same branch, with no new PR.

  • Where: apps/api/src/domain/execution/pr-gate-state-machine.ts:24
  • Test: test/domain/execution/pr-gate-state-machine.spec.ts
  • Origin: ADR 0013 §1
  • Edge case: awaiting_user is terminal by design — the system never merges (RN-006).

RN-015 — Cycle K: the correction ceiling is finite and inherited​

Every gate return consumes a lap. Once the ceiling is exhausted, the task is blocked with a reason, instead of spinning forever. A subagent created by parallelization inherits the base agent's ceiling.

Since Phase 8b the ceiling also applies to the QA AREA, with no code change: QaLeadServer is the only caller of record_gate_verdict, so one gate round — no matter how many specializations it consulted underneath — still consumes exactly ONE lap. See RN-036.

  • Where: DEFAULT_MAX_GATE_CORRECTIONS = 3 in apps/api/src/application/use-cases/execution/record-gate-verdict.use-case.ts:21; applied in activate-execution.use-case.ts:89 and, in the engine, in qa_lead_server.ex:268 and secops_agent_server.ex:142
  • Test: apps/engine/test/engine/gates/qa_automacao_agent_test.exs (the specialization returns {:blocked, ...} without calling the api) and qa_lead_server_test.exs (the Lead NEVER calls record_gate_verdict in that case — that's what stops the correction from being burned)
  • Origin: ADR 0017 §4

RN-016 — The gate's verdict prevails over the task's statement​

If the task description says one thing and the gate's verdict says another, the verdict wins.

Since Phase 8b, "the verdict" can come from two specializations — the rule doesn't change: each one prevails over the task WITHIN what it evaluates (Automation over test coverage; Performance/Security over performance NFRs and code findings). QaLead.consolidar/1 doesn't arbitrate between them and the task: any changes_requested from any of them already fails the whole thing (see RN-036).

  • Where: prompt for each specialization in apps/engine/lib/engine/gates/qa_automacao_agent.ex and qa_performance_seguranca_agent.ex
  • Origin: ADR 0020 §9

RN-036 — QA becomes an area: the Lead consolidates without changing the gate's contract​

The Automation specialization (Phase 4a's QAAgent) always delegates; Performance/Security only when the story has a pertinent performance NFR (Engine.Gates.QaLead.rnf_de_performance?/1 — a keyword heuristic, not NLP). The decision NOT to delegate is always recorded — a dispensed delegation with a justification, never silence.

Consolidation: approved only if ALL delegations that ran approved; any changes_requested fails the whole thing, with itens (items) from the specialization(s) that requested changes, each item prefixed with the label of whoever raised it ("[QA de Automação] ...") — that's how the origin is traced WITHOUT changing itens from string[] to another shape. Delegation failure (any origin — infra, modelo, codigo, politica) NEVER becomes changes_requested: the Lead blocks the task with the real origin (RN-015).

The qa_verdict that reaches the api is the SAME artifact and goes through the SAME route as always (RecordGateVerdictUseCase, nextGateStatus) — neither of the two changed. What the api learns about the area lives only in the delegation.completed/delegation.failed/ delegation.dispensed events, which the specialization and the Lead record separately.

  • Where: apps/engine/lib/engine/gates/qa_lead.ex (consolidar/1, rnf_de_performance?/1), qa_lead_server.ex (the wiring); apps/api/src/application/use-cases/execution/record-delegation.use-case.ts; apps/api/src/db/schema/agents.ts (table delegations, enum failure_origin)
  • Test: apps/engine/test/engine/gates/qa_lead_test.exs (the pure decision tree), qa_lead_server_test.exs (the wiring: decision → delegation → recording → consolidation → the SAME call as always), and — the proof that the contract didn't change — record-gate-verdict.use-case.spec.ts, pr-gate-state-machine.spec.ts and record-infra-gate-verdict.use-case.spec.ts pass with no change at all
  • Origin: ADR 0038

On the apps/web side (Phase 8d): the team panel groups qa/ qa-automacao/qa-performance-seguranca as lead + collapsible specializations, the PR timeline expands the consolidated verdict into its internals (ProjectApprovalsTab.tsx, PrGateTimeline.tsx), and the feed narrates delegation.* — see apps/web/src/lib/agents.ts (AREAS/areaFor).


RN-054 — External handoff addresses the area's lead or an agent with no area — never a subagent​

Whoever talks to an area from outside talks to the lead. A handoff addressed to a subagent (qa-automacao, qa-performance-seguranca, infra-workflows) is refused with a typed error that names the lead the caller should have addressed — refusing without saying the right path would just trade a hierarchy breach for a stuck agent.

The refusal happens before the INSERT. Refusing afterward would leave a ghost handoff in the table and a handoff.offered — an immutable event — asserting an offer the policy doesn't allow.

Internal delegation (lead → subagent) does not go through here: it's private to the area, has its own table (delegations) and its own path (RecordDelegationUseCase). The rule is about the area's external boundary, not about what happens inside it.

ADR 0038 called for this validation, naming the place — CreateHandoffUseCase is the only one in the system that writes toAgent — and it had never been implemented (finding #12 of the first dogfooding). The engine's offer_handoff passed to_agent through as a free string, so until now nothing stopped an agent from breaching the hierarchy.

Area, lead, and members have ONE source since PHASE 18: apps/api/src/domain/agents/agent-areas.ts. The list used to be hand-written in three places (api, web, engine) and the test only compared api against web — the engine silently diverged. Now apps/web/src/lib/agent-areas.generated.ts and apps/engine/lib/engine/agents/areas.ex are produced by pnpm --filter api gerar:areas, and the test fails what's stale on disk. Since AT-046 the same generator also writes SOLO_CONVERSATIONAL_AGENTS (RN-440) into the web file — and only there, since the engine has no reader for it. The list is still the CATALOG; agent_areas is the per-project STATE (RN-094), and the two answer different questions.

  • Where: apps/api/src/domain/agents/agent-areas.ts (assertHandoffTargetAllowed, HandoffToSubagentError), apps/api/src/application/use-cases/agents/create-handoff.use-case.ts
  • Test: test/domain/agents/agent-areas.spec.ts (the rule + the check that the derived copies don't diverge from the source), test/application/use-cases/agents/create-handoff.use-case.spec.ts (refusal with no row and no event)
  • Origin: ADR 0038, closed from finding #12 of the first dogfooding

RN-037 — Infra becomes an area: Workflows generates CI per provider, Lead consolidates into a single PR​

Second instance of the ADR 0038 model, after the QA area (RN-036) — with one structural difference: the Infra area's two delegations ALWAYS run (Dockerfiles/compose by the Lead itself — "delegates to itself"; CI pipeline by the Workflows subagent), never is one dispensed. Workflows decides the pipeline format from the context's gitProvider (GetInfraContextUseCase, read from project_repositories.provider — not from GitProvider capabilities, which are the SAME for GitHub and GitLab): "gitlab" generates .gitlab-ci.yml; any other value ("github", "local", or unknown) generates .github/workflows/ci.yml. Each file goes through validate_infra_file before finishing — hadolint for Dockerfile, actionlint for GitHub Actions workflow (ADR 0039; .gitlab-ci.yml has no local validation, a documented gap, not a half-solution).

Consolidation: the two delegates' files are merged by path (Workflows' wins on collision) into a SINGLE PR — the mechanism to propose the PR (propose_infra_pr) changes from "the tool calls the api directly" to "the tool signals the Lead, which consolidates and calls once" — the open_infra_pr the api receives is the SAME as always, byte for byte (ExecuteInfraPrUseCase untouched). Failure of any delegate (origin infra/modelo/codigo/politica) NEVER opens a partial PR — the same rule as RN-036, one level up again.

Each delegate (even the Lead, on itself) is tracked as a delegations row — reused exactly as RN-036 left it, with ONE correction: the task_id column became NULLABLE (the Infra area delegates on the SESSION, with no backlog task behind an infra PR), and the route POST /internal/sessions/:sessionId/delegations stopped being nested under /tasks/:taskId — taskId now goes in the body, optional. Cycle K and budget have no dedicated column in the Infra area (the original InfraAgent never had per-task budget, and this work didn't introduce one).

  • Where: apps/engine/lib/engine/infra/infra_lead.ex (consolidar/2), infra_lead_server.ex (the wiring), workflows_agent.ex, tools/validate_infra_file.ex (dispatch by extension), apps/api/src/application/use-cases/execution/get-infra-context.use-case.ts (gitProvider), record-delegation.use-case.ts (taskId optional)
  • Test: apps/engine/test/engine/infra/infra_lead_test.exs (the merge and the block, pure), infra_lead_server_test.exs (the wiring: single PR with both file sets, two delegations, gitProvider: "gitlab" → .gitlab-ci.yml, Workflows failure → no PR), workflows_agent_test.exs — and the proof that the ExecuteInfraPrUseCase/InfraGateRunner contract didn't change: execute-infra-pr.use-case.spec.ts and infra_gate_runner_test.exs pass with no change at all
  • Origin: ADR 0038, ADR 0039

On the apps/web side (Phase 8d): the team panel groups infra/ infra-workflows the same way as QA (RN-036), and the feed narrates the area's delegations — the same AREAS/areaFor registry from apps/web/src/lib/agents.ts, with no Infra-specific code in the UI.


RN-140 — A gate cycle that dies mid-way resumes on its own, with no manual intervention​

QaLeadServer/SecOpsAgentServer are restart: :temporary, and the gate's intermediate transitions (DevAgentServer.correct/3, Dispatcher.run_secops/2) are direct in-memory calls — made AFTER record_gate_verdict has already durably advanced gate_status on the api. A crash between the two used to trap the PR forever in awaiting_qa/awaiting_secops: nothing knew that step had been left pending, and no engine restart fixed it — ADR 0057 itself already declared suspension in {:awaiting, ...} as a known limit, and investigating again found that the window between a recorded verdict and a dispatch call is, in practice, easier to hit.

gate_states (schema engine, key {project_id, task_id, gate}) records the in-flight cycle at the SAME points where the transitions already happened — "in_progress" before any subagent/scanner runs, "dispatch_pending" right after record_gate_verdict returns correct/run_secops and before the in-process call. Engine.Gates.GateRescuer scans rows stuck for more than 15 minutes (configurable, GATE_RESCUE_STALE_AFTER_SECONDS) — generous on purpose, because a QA subagent's ToolLoop legitimately runs up to TOOL_LOOP_MAX_ITERATIONS_GATE (60) iterations, and a short threshold would rescue (and duplicate) a cycle that's merely slow — and resumes: "in_progress" restarts the whole area (no surgical resumption of ctx, which doesn't survive a restart, the same choice as the dev agent); "dispatch_pending" resends exactly the lost call. Called on boot and by an Oban tick self-rescheduled every 5 minutes (GATE_RESCUE_INTERVAL_SECONDS), Engine.Workers.GateRescueSchedulerWorker — the same idiom as ModelSyncSchedulerWorker/AnamneseSchedulerWorker.

Two guards against duplicating work: a process alive ON THE SAME node (Registry.lookup) is never disturbed, and the staleness threshold covers what the local guard doesn't reach (remote replica — Registry is local to the node, the same caveat as Engine.Dev.Wake since ADR 0045). The worst outcome of a residual race is duplicated, cheap work — the api rejects a second record_gate_verdict for a gate that no longer owns gate_status (nextGateStatus), and DevAgentServer.correct/3 is already idempotent via a state guard since ADR 0052 — never inconsistent data.

  • Where: apps/engine/lib/engine/gates/gate_state.ex, gate_rescuer.ex, the write points in qa_lead_server.ex (run_area/3, apply_gate_result/6) and secops_agent_server.ex (run_secops/3, apply_verdict/6), and apps/engine/lib/engine/workers/gate_rescue_scheduler_worker.ex
  • Test: apps/engine/test/engine/gates/gate_rescuer_test.exs — kills a real QaLeadServer with the cycle in flight (DynamicSupervisor.terminate_child/2, the durable row surviving the process) and proves GateRescuer.run/0 reconnects the area on its own up to a real outcome; the scenario in the statement (lost run_secops) and the lost correct return, both with REAL process and dispatch, no FakeGateDispatcher; and the two guards (a live local process isn't disturbed; a recent row isn't touched)
  • Origin: ADR 0067, which extends ADR 0057

Cost​

As RNs de orçamento, metering e teto de custo moram em Regras de negócio — Custo.

Mesmo motivo da seção de Autenticação abaixo: tamanho, não assunto. Âncoras #rn-NNN inalteradas.

Painel de projetos​

RN-039 — Dot de status do projeto: risco sempre vence inatividade​

A sidebar do dashboard mostra um dot de saúde por projeto, derivado de três sinais independentes: orçamento (mesmos limiares 70%/90% do RN-018), task bloqueada (tasks.blocked) e atividade recente (7 dias). Cores: verde = saudável e ativo; âmbar = orçamento ≥70%; vermelho = orçamento ≥90% ou task bloqueada; cinza = sem atividade nos últimos 7 dias. Quando um sinal de risco (âmbar/vermelho) e o de inatividade (cinza) se aplicam ao mesmo tempo, o de risco vence — um projeto estourado e parado ainda é algo a olhar, não algo a esconder atrás de "sem atividade".

  • Onde: apps/web/src/lib/project-status.ts (deriveProjectStatus)
  • Teste: apps/web/src/lib/project-status.test.ts
  • Origem: fidelidade do dashboard de projetos ao design aprovado

RN-088 — Falha de carregamento é dita na tela; 429 não se retenta​

Toda tela que carrega dado da api distingue três estados, e nunca dois: carregando (esqueleto), erro (a mensagem que a api mandou, com o trace_id e um botão de tentar de novo) e vazio (o texto de lista vazia). if (!dado) return null colapsa os três, e o desfecho observado foi o pior possível: com a api limitando por 429, a área principal de /projects/:id ficava completamente em branco — sem mensagem, sem erro, sem esqueleto — e o motivo existia só no console. No dashboard era pior que branco: !projects também era verdadeiro no erro, então a tela convidava a criar o primeiro projeto de um workspace que podia ter vinte.

É a RN-059 do outro lado do fio: falha nunca vira resposta vazia. Quem falha, diz.

A frase é a da api, extraída por mensagemDaApi — a mesma função que o caminho de mutação já usava nos toasts. Um texto genérico nosso não sabe a diferença entre "limite de requisições excedido, tente em instantes" (espere) e "acesso negado" (não adianta esperar).

O outro lado da mesma regra é não responder ao limite com mais tráfego. 4xx não se retenta: 429 é literalmente o servidor mandando parar, e 401 já foi renovado uma vez dentro de request(). E poll para quando a query erra — volta no foco da janela, na remontagem ou no botão. Antes eram três retentativas por falha somadas a ~25 polls de 3 a 5 segundos que não sabiam parar: uma sessão real acumulou 1128 erros 429 contra um limite de 300 requisições por minuto por usuário (RateLimitGuard), e o laço impedia a janela deslizante de se refazer. 5xx e falha de rede continuam com as três tentativas, onde repetir é a reação certa.

  • Onde: apps/web/src/lib/query-policy.ts (deveRetentar, pollQueParaNoErro), apps/web/src/components/ErroDeCarregamento.tsx, apps/web/src/routes/ProjectPage.tsx, apps/web/src/routes/Dashboard.tsx, apps/web/src/routes/Shell.tsx, apps/web/src/main.tsx
  • Teste: apps/web/src/lib/query-policy.test.ts; apps/web/src/routes/ProjectPage.test.tsx (429 vira mensagem com a frase da api; 403 mostra o motivo do 403; carregando não é erro); apps/web/src/routes/Dashboard.test.tsx (erro na lista não vira "nenhum projeto ainda"); apps/web/src/routes/Shell.test.tsx (a sidebar diz em vez de ficar vazia)
  • Origem: navegação real em /projects/:id com 1128 erros 429 no console

RN-089 — Projeto de nome repetido se desempata na barra lateral​

Nome de projeto não é único — nada no domínio impede. Quando dois ou mais projetos da lista têm o mesmo nome, cada um deles mostra na sidebar o id abreviado e a data de criação (#a1b2c3d4 · 07/08 14:32), os dois já presentes no payload do projeto. Quem tem nome único não mostra nada: a legenda em toda linha seria ruído no lugar com menos espaço da tela.

Origem concreta: uma execução de validação criou vinte projetos chamados validacao-real, e as vinte linhas da sidebar eram visualmente idênticas.

  • Onde: apps/web/src/lib/project-label.ts (nomesRepetidos, desempateDoProjeto), apps/web/src/routes/Shell.tsx
  • Teste: apps/web/src/routes/Shell.test.tsx (nome repetido ganha desempate, nome único não)
  • Origem: navegação real com a sidebar cheia de projetos de validação

RN-090 — O dashboard lê o workspace, não um projeto de cada vez​

A grade de cards e os dots da barra lateral se alimentam de uma requisição por ciclo — GET /workspaces/:workspaceId/projects-summary —, e o número de requisições não cresce com a quantidade de projetos.

O card mostra provedor de git, status de provisionamento, orçamento, chips de agentes e última atividade; a sidebar mostra o dot de status e o contador de não lidos. Tudo isso vinha de sete consultas em POLL por projeto, o que fazia 23 projetos custarem 3.824 requisições por minuto contra um limite de 300 (RateLimitGuard) — a tela derrubava a si mesma em 429 antes de terminar de carregar. A RN-088 fez a app parar de insistir quando isso acontece; esta reduz o pedido.

Duas fronteiras que a regra fixa, e que não são detalhe de implementação:

  • A api responde fatos, não roster. roster traz o que aconteceu no event log (execução ativada, módulos, gate já aberto, subagentes delegados, infra aceita). Quem é lead de área, que ícone cada agente tem e como os membros viram um chip continua sendo do web — e a regra de PRESENÇA é uma só (rosterFromFacts), compartilhada com o painel do time, para que um agente novo não apareça num lugar e falte no outro.
  • A gaveta do sino só busca quando aberta. Ela era, até a RN-091, a única leitura que continuava sendo uma requisição por projeto — o corte de "onde parei de ler" é seq guardado no navegador (read-state), que o servidor não conhece. Hoje o navegador manda esse corte, e a gaveta também é uma requisição só.

Efeito colateral aceito e desejado: gatesEverOpened e delegatedSubagents passam a cobrir a sessão INTEIRA. O cliente derivava dos últimos 200 eventos e perdia chips em sessão longa (ver ADR 0021) — o texto de deriveAgentRoster sempre disse "já abriu alguma vez".

  • Onde: apps/api/src/application/ports/projects-summary-repository.port.ts, apps/api/src/infrastructure/persistence/drizzle/projects-summary.repository.ts, apps/api/src/interfaces/http/iam/workspaces.controller.ts (getProjectsSummary), apps/web/src/lib/hooks.ts (useProjectsSummary), apps/web/src/lib/agent-status.ts (rosterFromFacts), apps/web/src/routes/Dashboard.tsx, apps/web/src/routes/Shell.tsx
  • Teste: apps/web/src/routes/Dashboard.fanout.test.tsx (30 projetos custam o mesmo que 3; nenhum endpoint por projeto é chamado); apps/api/test/infrastructure/persistence/drizzle/projects-summary.repository.spec.ts (o número de consultas ao banco não cresce com N); apps/web/src/lib/agent-status.test.ts (os fatos da api produzem os mesmos chips que o event log)
  • Origem: dashboard de 23 projetos medido no navegador — 3.824 req/min antes, 12 depois

RN-091 — O navegador manda onde parou de ler; o sino é uma requisição​

Com a gaveta de notificações aberta, o conteúdo dela sai de uma requisição por ciclo — POST /workspaces/:workspaceId/unread-events — e o número de requisições não cresce com a quantidade de projetos.

É a metade que faltava da RN-090. Aquela derrubou o dashboard de 3.824 para 12 req/min, mas o sino continuou buscando um projeto de cada vez: 23 projetos com a gaveta aberta mediam 286 req/min contra o limite de 300 (RateLimitGuard). Passava, e sumia com um projeto a mais.

O obstáculo, e por que a saída é esta. Não existe "marcar como lido" no servidor, de propósito: o corte é um seq por projeto guardado no localStorage de cada navegador (read-state). Havia duas saídas, e a diferença entre elas é de PRODUTO:

  • parar de repolar enquanto a gaveta está aberta muda a atualidade do que o usuário vê — recusada, ninguém pediu essa troca;
  • o navegador mandar o mapa projeto → afterSeq devolve exatamente os mesmos dados na mesma cadência. É batelamento puro: nada muda para quem olha a tela, nem a frescura nem o conteúdo.

É POST sem mutar nada, e a api responde 200 (nunca 201) para dizer isso. O verbo foi escolhido por ser o único com CORPO: são dezenas de pares, e em query string isso vira URL longa — que proxy trunca — além de pôr id de projeto do usuário em log de acesso, contra a regra de não passar dado pessoal por query string.

Três garantias de semântica que a rota fixa, porque batelar leituras com cortes diferentes é onde o erro fácil mora:

  • mapa vazio devolve vazio, e sem tocar no banco. "Não perguntei nada" não é "me dê tudo";
  • cada projeto respeita o SEU corte — seq estritamente maior que o dele;
  • o teto de 50 eventos é por projeto, o mesmo que GET .../events aplica sem limit. Um limite na resposta inteira deixaria o projeto barulhento comer a cota dos calados.

Cursor apontando para projeto de outro workspace é ignorado, não recusado: ele vem do armazenamento local de quem chama e pode ser sobra de um workspace antigo.

  • Onde: apps/api/src/application/ports/projects-summary-repository.port.ts (unreadEventsForWorkspace), apps/api/src/infrastructure/persistence/drizzle/projects-summary.repository.ts, apps/api/src/interfaces/http/iam/workspaces.controller.ts (getUnreadEvents), apps/api/src/interfaces/http/iam/dto/unread-events.dto.ts, apps/web/src/lib/notifications.ts (useNotificationGroups), apps/web/src/lib/api-client.ts (getUnreadEvents)
  • Teste: apps/web/src/routes/Dashboard.fanout.test.tsx (com a gaveta ABERTA: uma requisição, e 30 projetos custam o mesmo que 3); apps/api/test/infrastructure/persistence/drizzle/projects-summary.repository.spec.ts (o número de consultas ao banco não cresce com N; mapa vazio, corte por projeto, teto por projeto e isolamento de workspace)
  • Origem: residual medido da RN-090 — 286 req/min com a gaveta aberta num workspace de 23 projetos

RN-099 — Atividades pagina o passado sem repolar o que não muda​

A coluna de Atividade da Visão geral mostra uma janela de 100 eventos ancorada na cauda da sessão, e desce no passado de página em página. Cada página usa o afterSeq/nextCursor que GET /projects/:p/sessions/:s/events já devolve: nenhuma rota nova, nenhum parâmetro novo.

Duas perguntas, duas queries. useSessionEvents responde "como está agora" — os últimos 200 em poll — e alimenta o painel do time, a linha do tempo em árvore, a seção de execução e a aba de Aprovações. Nenhuma delas pagina. A de Atividades responde "o que aconteceu", e era a única que fazia essa pergunta com a resposta da outra: 200 itens de uma vez, sem começo nem fim, e ainda assim sem alcançar o início de uma sessão longa.

A âncora é a cauda, não o começo da sessão. O endpoint pagina para FRENTE, e não existe beforeSeq — inventar um seria contrato novo. Mas abrir o feed no evento nº 1 de uma sessão de milhares entrega a tela errada: quem abre Atividades quer o que acabou de acontecer. Então a primeira página é a mesma leitura latest que a tela já faz, com a mesma queryKey (o React Query serve as duas com UMA busca), e cada clique desce uma janela fixa com afterSeq.

O que a paginação não pode custar. Uma requisição por ciclo de poll — a mesma de antes, e a economia da RN-090/RN-091 não regride. Três peças garantem isso:

  • a cauda é a query que a tela já tinha, compartilhada por queryKey;
  • o primeiro clique não vira requisição: a leitura latest traz 200 e a janela mostra 100, então revelar o que já se pagou vem antes de pedir de novo;
  • página antiga é uma janela fechada de seq sobre eventos imutáveis (nunca há UPDATE em tabela de evento): staleTime: Infinity e nenhum refetchInterval. Um poll ali seria pagar N requisições para receber N respostas idênticas.

A contagem é honesta sobre o que ela sabe. Os filtros por agente e por tipo rodam sobre a página carregada, não sobre a sessão — então a UI diz N de M carregados, e não "N resultados". Levar o filtro ao servidor daria o total verdadeiro e mexeria no repositório de eventos; não é desta fase, e dizer o total errado seria pior que dizer menos.

Os três estados da RN-088 valem aqui: erro antes de vazio — a coluna dizia "nenhuma atividade" quando a api tinha recusado.

  • Onde: apps/web/src/lib/hooks.ts (useSessionEventHistory, EVENTOS_POR_PAGINA), apps/web/src/components/ActivityFeed.tsx, apps/web/src/routes/ProjectOverviewTab.tsx, apps/api/src/infrastructure/persistence/drizzle/session-event.repository.ts (listPaginated, já existente)
  • Teste: apps/web/src/lib/session-history.test.tsx (abre na cauda; o primeiro clique não custa requisição; o seguinte pagina com afterSeq contíguo; o laço termina no começo da sessão; montado junto com o estado atual a cauda é UMA busca; página antiga não repolla); apps/web/src/components/ActivityFeed.test.tsx (o "N de M carregados" acompanha o filtro; sem as props de paginação nada muda)
  • Origem: pedido do usuário na primeira navegação real — "Atividades na aba Visão Geral deve ser paginada, para não ficar infinita"

RN-100 — A ordem do sino é do SQL, não do front​

A gaveta de notificações mostra os eventos do mais recente para o mais antigo, e quando um projeto tem mais não lidos que o teto de 50 os que aparecem são os mais NOVOS.

A ordenação é do banco por necessidade, não por gosto. A consulta corta em 50 por projeto com uma função de janela (row_number() OVER (PARTITION BY session_id ORDER BY seq DESC)), e é ela que decide quais 50 eventos sobrevivem — não só em que ordem eles saem. Com ASC sobreviviam os 50 mais antigos, e um .sort() no cliente ordenaria por recência justamente a janela que a consulta já tinha escolhido errado: num projeto com 300 não lidos, o 251º evento apareceria como "o mais recente". É por isso que não há ordenação nenhuma no caminho do front, e o comentário no componente diz que não pode haver.

A consequência, e por que o corte de leitura não mudou. O corte é um seq por projeto no localStorage, e não existe endpoint de marcar lido, por decisão registrada na RN-091. Um corte por seq marca um prefixo; a gaveta agora mostra um sufixo. Marcar como lidas "as 50 exibidas" é inexprimível sem um conjunto de lidos por evento — tabela nova, fora de escopo. Os dois únicos cortes expressáveis continuam sendo "nada" e "tudo até agora".

A saída não foi mudar a semântica (ela continua avançando para o último seq, exatamente como antes), foi parar de esconder o que esse avanço engole:

  • cada projeto mostra o total de não lidos, não o tamanho da janela, e declara quantos ficaram de fora (+ N mais antigos);
  • o botão passa a dizer o que faz — marcar as N como lidas — quando há algo fora da janela;
  • o número que falta sai de subtração, não de requisição: latestSeq menos o corte já vem no resumo do workspace (RN-090).

Como a semântica do corte não mudou, não há ADR nesta regra: o que mudou foi qual janela a consulta escolhe e o que a gaveta declara sobre ela.

  • Onde: apps/api/src/infrastructure/persistence/drizzle/projects-summary.repository.ts (unreadEventsForWorkspace), apps/api/src/application/ports/projects-summary-repository.port.ts, apps/api/src/interfaces/http/iam/dto/iam.response.dto.ts (ProjectUnreadEventsResponseDto), apps/web/src/lib/notifications.ts (useNotificationGroups), apps/web/src/components/NotificationBell.tsx
  • Teste: apps/api/test/infrastructure/persistence/drizzle/projects-summary.repository.spec.ts ("com mais não lidos que o teto, voltam os MAIS RECENTES, do novo para o velho" — afirma o CONJUNTO antes da ordem); apps/web/src/components/NotificationBell.test.tsx (renderiza na ordem recebida, sem reordenar; o contador é o total; o botão diz quantas marca); apps/web/src/lib/notifications.test.tsx (o que ficou fora da janela sai por subtração, sem segunda requisição); apps/web/src/routes/Dashboard.fanout.test.tsx (a gaveta continua sendo UMA requisição)
  • Origem: pedido do usuário na primeira navegação real — "Sino e notificações devem sempre ordenar para última data modificada em descendente para a mais antiga"

RN-118 — A árvore do time expõe detalhe de execução por marco, e abre os "5 últimos"​

AgentTimelineTree/timeline-tree.ts (FASE 14b) já agrupava o event log por agente, mas com três lacunas: o critério de abrir um ramo era só ativo/parado (sem relação com quantos agentes existiam), o contador do cabeçalho era o TOTAL de marcos (nunca "o que é novo"), e tool.call/ tool.result/agent.response viravam marco mostrando só o nome da ferramenta — args, result e iteration, que o event log já grava, nunca chegavam na tela.

Abertura padrão passa a ser "os 5 agentes de atividade mais recente (maior seq do último marco)", com ativo mantendo prioridade sobre recência — não se soma: como montarArvore já ordena ativos primeiro e, dentro de cada grupo, do mais recente pro mais antigo, os dois critérios colapsam numa fatia só (ramosAbertosPorPadrao, os primeiros max(nº de ativos, 5) ramos). Mais de 5 ativos: todos abrem, sem corte.

Contador de novidade por agente usa o MESMO mecanismo do sino (read-state.ts, localStorage), granular por projectId:agentId em vez de só projectId — não existia "último visto" nessa granularidade antes. Ramo colapsado com marco de seq maior que o último visto daquele agente mostra a contagem de NOVIDADE no lugar do total; abrir o ramo (automático ou manual) marca como visto até o seq mais recente.

Detalhe expansível é por MARCO, não por ramo — só tool.call, tool.result e agent.response (EVENTOS_EXPANSIVEIS) viram botão individual, porque só eles carregam payload que vale expandir. O agrupamento visual por ITERAÇÃO usa iteration do payload de agent.response quando existe (ToolLoop — tool.call/tool.result herdam a iteração da resposta que os despachou, porque no ToolLoop eles são emitidos DEPOIS dela); agentes fora do ToolLoop (PO, Criativo, sem iteration no payload) ganham um contador PRÓPRIO por agente, incrementado a cada resposta — inferência por proximidade de seq, não um campo novo no event log.

  • Onde: apps/web/src/lib/timeline-tree.ts (Marco.iteracao, Marco.eventType, Marco.payload, ramosAbertosPorPadrao, marcoExpansivel), apps/web/src/components/AgentTimelineTree.tsx, apps/web/src/lib/read-state.ts (getAgentLastSeenSeq/ setAgentLastSeenSeq)
  • Teste: apps/web/src/lib/timeline-tree.test.ts (agrupamento por iteração real e inferida, ramosAbertosPorPadrao com ativo/recência/sem corte), apps/web/src/components/AgentTimelineTree.test.tsx (abertura padrão, contador de novidade aparecendo/sumindo, marco expandindo com args/resultado/iteração)
  • Origem: pedido do usuário — lacunas confirmadas por investigação no componente existente da FASE 14b; sem ADR, mesma semântica de "não visto" do sino, só granularidade nova

RN-096 — Toda decisão diz em português o que acontece; o payload cru nasce colapsado​

Todo tipo de proposed_action tem uma frase em português que descreve o efeito de aprovar, e ela é a primeira coisa visível no card — antes de qualquer detalhe, nas três telas onde uma decisão é pedida (a fila de Aprovações, o card dentro do chat da sessão e a aba Insights). O payload cru continua acessível, dentro de um colapso que nasce fechado, e nunca é despejado na tela.

O que existia era um despejo. Todo tipo sem corpo visual próprio caía num Object.entries(payload).map(([k, v]) => k + ': ' + JSON.stringify(v)), sempre aberto. Quem abria a fila lia worktree: /workspaces/dev-api, coAuthor: Brabo User <user@brabo.dev> e proposto: 4 — e tinha de deduzir o que ia acontecer se clicasse em Aprovar. Aprovação que exige dedução é aprovação que se dá no automático, e o produto inteiro se apoia em o usuário ser a autoridade final.

A frase degrada, e é isso que a torna confiável. Nenhuma delas assume que uma chave do payload existe: com payload vazio a frase continua sendo uma frase verdadeira, só menos específica ("Registra um commit no repositório do projeto."). O payload vem do engine e de dez casos de uso diferentes, e nenhum deles promete um formato — frase que só funciona com a fixture certa é frase que quebra na primeira aprovação real.

Tipo que o web ainda não conhece não é caso teórico. A união ActionType do web já ficou defasada duas vezes: primeiro com os três tipos do bootstrap de Gitflow, e de novo com parallelize/raise_max_parallel, que entraram na FASE 14d. O compilador não pega: a lista canônica está em apps/api, que o web não importa. Por isso duas coisas ao mesmo tempo — o card degrada (verbo neutro

  • "ver detalhes", com o payload colapsado, em vez do undefined no mapa de ícones que derrubava a árvore inteira do React), e o teste lê o decide.ts do backend e reprova quando um tipo entra sem frase.

O default de aberto/fechado é derivado, não configurado. A regra é uma só — abre o que ainda espera decisão de quem está olhando: no chat, ação pending; na fila, nunca (são N cards, e N detalhes abertos são a mesma parede de texto); em Insights, hipótese proposed. E o payload CRU não abre em variante nenhuma. A consequência de projeto é que ApprovalCard não ganhou prop nova obrigatória: o default sai de variant e status, que já existiam.

Verbo e frase saem de um módulo só, consumido pelas três telas. A hipótese do Psicólogo usa literalmente o verbo do instruction_patch em vez de um vocabulário paralelo — e a frase dela diz o que o accept faz de verdade (enfileira para a Anamnese, que pode propor o ajuste, que ainda vem para aprovação), nunca "a instrução será alterada".

write_file tinha frase, mas não corpo. Ele nasceu de fora de COM_CORPO_PROPRIO: a frase mostrava só o path, e o detalhe caía no mesmo despejo de JSON cru colapsado que esta RN existe para evitar — então um write que genuinamente pedia aprovação (fora do prefixo dev-, ou caminho fora do escopo do agente) exigia um clique a mais para ver o content. Entrou em COM_CORPO_PROPRIO com corpo próprio: path e um preview do content (até 25 linhas/4.000 caracteres, com aviso de truncamento — nunca o arquivo inteiro, mesma regra do payload cru), aberto por padrão no chat enquanto pendente, igual terminal.

Payload vazio não é payload ausente. command (terminal) ou path/content (write_file) chegando como string vazia — tool-call malformada do modelo, não bug de renderização — degradava para um prompt $ ou um preview em branco, que o usuário lia como defeito da tela. Os dois corpos agora distinguem os dois casos e mostram "o modelo não produziu um X válido para esta ação" em vez de um branco.

  • Onde: apps/web/src/lib/aprovacoes.ts (VERBO_DA_ACAO, fraseDaAcao, descreverAcao, descreverHipotese), consumido por apps/web/src/components/ApprovalCard.tsx (Aprovações + chat da sessão, COM_CORPO_PROPRIO, previewConteudo para o content de write_file) e apps/web/src/components/HypothesisCard.tsx (Insights); colapso pelo Disclosure de apps/web/src/components/ui/Disclosure.tsx
  • Teste: apps/web/src/lib/aprovacoes.test.ts (lê ACTION_TYPES de apps/api/src/domain/actions/decide.ts e exige verbo + frase para cada tipo, com payload vazio); apps/web/src/components/ApprovalCard.test.tsx (frase e colapso — payload colapsado nas duas variantes, JSON legível ao abrir, tipo desconhecido não derruba a tela, write_file com corpo próprio aberto por padrão e truncamento do preview, command/path/content vazios mostrando a mensagem de fallback); apps/web/src/components/HypothesisCard.test.tsx (frase e colapso)
  • Origem: FASE 19 do programa 16–26, do pedido "hoje está muito difícil a leitura" na primeira navegação real depois do reset do banco

RN-121 — Dev agent e QA são "executores": aba própria, fora do "Time de agentes"​

O grid "Time de agentes" e a "Linha do tempo do time" da Visão geral misturavam Criativo/PO/Arquiteto/Infra com dev-<módulo> e QA (lead + qa-automacao/qa-performance-seguranca) — sete, oito agentes na mesma grade, sem distinguir quem CRIA/DECIDE de quem IMPLEMENTA/VERIFICA. A aba Executores isola os dois últimos, com a MESMA renderização da Visão geral (AgentCard, o grid de área — extraído para AgentTeamGrid.tsx — e AgentTimelineTree), nunca um card reinventado.

A regra de separação é uma função só, isExecutorGroup/ isExecutorAgentId (lib/agent-status.ts): dev-lead/dev-<qualquer módulo> e a área qa inteira (lead + subespecialidades) são executor; o resto não é. As DUAS telas filtram o MESMO groupRosterByArea com essa função — a Visão geral com !isExecutorGroup, Executores com isExecutorGroup — então um agente não pode aparecer nos dois nem sumir dos dois: é sempre exatamente um lado.

A árvore não duplica: cada aba filtra os EVENTOS antes de passar para AgentTimelineTree (por actor.id pertencer ou não ao conjunto de executores), então o ramo de um dev agent aparece só em Executores, nunca também na Visão geral. A coluna de Atividade (ActivityFeed, RN-099/100) NÃO foi filtrada — ela responde "o que aconteceu" na sessão inteira, dev/QA inclusive, e o filtro por agente do próprio componente já lista todo mundo que falou; só o grid e a árvore de "quem está fazendo o quê" mudam de lugar.

  • Onde: apps/web/src/lib/agent-status.ts (isExecutorGroup, isExecutorAgentId, autonomyActionTypeFor — movido de ProjectOverviewTab.tsx), apps/web/src/components/AgentTeamGrid.tsx (extraído do grid que já existia), apps/web/src/routes/ ProjectExecutorsTab.tsx, apps/web/src/routes/ProjectOverviewTab.tsx, apps/web/src/routes/project-tabs.ts (key: 'executores', ordem: 12 — logo depois da Visão geral, antes de Criativo/Chat/Code)
  • Teste: apps/web/src/lib/agent-status.test.ts (isExecutorAgentId/ isExecutorGroup — dev-<módulo>, dev-lead, área qa inteira são executor; criativo/po/arquiteto/infra/secops não são); apps/web/src/routes/ProjectExecutorsTab.test.tsx (mostra só dev/QA, estado vazio sem os dois, contagem do cabeçalho); apps/web/src/routes/ProjectOverviewTab.test.tsx (dev-backend/QA somem do grid, o resto do time continua); apps/web/src/routes/ project-tabs.test.tsx (a aba nova entra na varredura genérica do registro — régua, painel, deep-link, ordem única)
  • Borda: SecOps NÃO é executor — ele entra na roster pelo MESMO pr.gate_changed que traz QA (Fase 4a, rosterFactsFromEvents), mas fica na Visão geral. dev-lead está na lista de executores por completude (RN-087 o descreve como conversacional), embora hoje nenhum caminho o instancie na roster da sessão (a delegação Dev Lead → dev-<módulo> segue fora de escopo, ADR 0053 item 5).
  • Origem: pedido do usuário — "Executores" própria pra dev agent e QA, fora do grid misturado da Visão geral.

RN-125 — O aceite de handoff é INLINE no fio, com CTA pro Dev Lead apontando pra Executores​

O divisor "X passou o bastão ao Y" que já existia na timeline (handoff.offered) vira um card acionável — com o botão de aceitar embutido — sempre que representa a oferta pendente atual (a mesma que offeredHandoff já resolvia para o botão da topbar). O botão da topbar saiu: com o aceite morando dentro do fio, no lugar exato onde a passagem aconteceu, manter os dois puxaria dois botões com o texto IDÊNTICO visíveis ao mesmo tempo — o mesmo problema que ApprovalCard já evita ao nunca duplicar a ação fora do fio (RN-096).

Qual evento vira o card é decidido por PAR, não por id. O payload de handoff.offered carrega só toAgent; o fromAgent é o actor do evento (RN-054 já usava essa leitura para o texto do divisor). Sem o id do handoff no evento, "esta é a oferta atual" é respondido achando o handoff.offered mais RECENTE (maior seq) com o MESMO par fromAgent/toAgent de offeredHandoff — o que impede reabrir um convite de aceite que uma oferta mais antiga pro mesmo par já tenha resolvido (aceita, recusada ou superada por uma oferta nova).

O card pro Dev Lead ganha um segundo link, "Acompanhe a execução em Executores", pra aba que já existe (RN-121) — é o início da EXECUÇÃO, e quem aceita precisa saber onde olhar depois. As outras ofertas (PO, Arquiteto…) não ganham o link: a sessão delas segue sendo o próprio lugar de acompanhar, não há "onde mais olhar".

  • Onde: apps/web/src/routes/SessionPage.tsx (timeline — bloco handoff.offered, offeredHandoffEventSeq; isActive subiu para antes do useMemo), apps/web/src/routes/SessionPage.module.css (.handoffCard, .timelineLink)
  • Teste: apps/web/src/routes/SessionPage.handoff-inline-e-links.test.tsx (card acionável só na oferta atual, oferta antiga do mesmo par fica muda, handoff já aceito não mostra botão nenhum, link de Executores só no dev-lead); apps/web/src/routes/SessionPage.pista-e-status.test.tsx atualizado para o botão inline (não mais na topbar)
  • Verificado por execução real: aceitar um handoff pro PO numa sessão criativa contra um build isolado (stack Docker próprio, projeto/sessão reais) — o card vira divisor mudo e o log de eventos avança assim que o clique volta da api; o CTA pro Dev Lead navega pra ?tab=executores de verdade.
  • Origem: pedido do usuário — aceite de handoff também inline no fio, não só na topbar.

RN-124 — O PO narra épico/história criados no fio, com link direto pro Backlog​

backlog.epic_created/backlog.story_created — que o PO já gravava no event log (CreateEpicUseCase/CreateStoryUseCase) — não tinham tratamento nenhum na timeline da sessão: criar história não deixava rastro NENHUM no fio, só na aba Backlog, pra quem já soubesse ir olhar lá. Os dois tipos ganham entrada na timeline, no MESMO corpo visual das outras mensagens do agente (.message/.bubble) — nenhuma classe nova pro chip em si, só o link.

O link ("Ver no Backlog") reusa o deep-link genérico da aba (?tab=backlog, o mesmo mecanismo do CTA "Definir orçamento" do card do dashboard) — não tenta realçar a história recém-criada dentro da aba, que é fora de escopo.

  • Onde: apps/web/src/routes/SessionPage.tsx (timeline — bloco backlog.epic_created/backlog.story_created)
  • Teste: apps/web/src/routes/SessionPage.handoff-inline-e-links.test.tsx (item 2)
  • Verificado por execução real: épico e história inseridos no event log de uma sessão real aparecem como "Épico criado"/"História criada" com o título, e o link navega pra ?tab=backlog de verdade.
  • Origem: pedido do usuário — link do PO pras histórias criadas, direto pro Backlog.

RN-126 — Promoção de história é decidível INLINE no fio, com o mesmo mecanismo da aba Backlog​

backlog.story_promotion_proposed (RN-048) ganha o mesmo tratamento que RN-125 deu ao handoff: o evento vira card acionável na timeline da sessão, com os botões "Promover" e "Devolver" chamando os MESMOS promoteStories/returnStory que PromotionQueue (ProjectBacklogTab.tsx) já usa — nenhum endpoint novo, nenhuma segunda fonte de verdade. A decisão continua acontecendo em UM lugar (o backend), só o gatilho ganhou um segundo caminho.

Resolução por evento posterior, não por status derivado à parte. O card some sozinho (vira divisor mudo, sem botões) assim que um evento de seq MAIOR decidir o destino da MESMA história — backlog.story_transitioned{storyId} (promovida, emitido por PromoteStoriesUseCase via TransitionStoryUseCase) ou backlog.story_promotion_returned{storyId} (devolvida). Sem essa checagem, promover ou devolver deixaria os mesmos dois botões plantados no fio, oferecendo de novo uma decisão já tomada.

backlog.story_promotion_returned ganha narração simétrica no fio (motivo incluído), reusando a mesma frase que apps/web/src/lib/activity.ts já usava só no log colapsado da sidebar.

  • Onde: apps/web/src/routes/SessionPage.tsx (timeline — blocos backlog.story_promotion_proposed/backlog.story_promotion_returned, handlePromoteStory/handleReturnStory, modal de motivo reusando Modal/Textarea)
  • Teste: apps/web/src/routes/SessionPage.promocao-inline-e-volta.test.tsx (card com os botões, promover chama promoteStories com o storyId do payload, devolver exige motivo e chama returnStory, história resolvida por story_transitioned posterior vira divisor mudo, story_promotion_returned narra com o motivo)
  • Origem: pedido do usuário — promoção de história inline no fio, opção barata reusando RN-048 em vez de gatear a criação da história.

RN-131 — Três corridas confirmadas AO VIVO no fio da sessão: convite por cima do histórico, indicador ansioso e turno preso em handleReadiness​

Três defeitos achados navegando SessionPage.tsx de verdade no Chrome, não por teste — e os três eram condição de corrida ou critério incompleto disfarçado de decisão de produto.

1. conversaComecou virou "existe QUALQUER evento", não "existe chat.message/agent.response". O critério anterior (achado G) nasceu pra não confundir os cards do bootstrap do git com conversa, mas tinha o efeito contrário: uma sessão criada pelo git-bootstrap (ações de commit/branch já aprovadas, zero chat.message) mostrava o convite por cima delas, e o mesmo acontecia — de forma bem mais grave — na sessão que a ativação de execução usa, com dezenas de eventos reais (tool.call, tool.result, eventos de task) e nenhum chat.message/agent.response: o convite cobria o histórico de execução inteiro. Sessão nova é a única sem nenhum evento — essa é a pergunta certa.

2. conviteVisivel espera useSessionEvents terminar de carregar. Em cache frio (reload de página), session podia chegar enquanto events ainda era [] — o default de data?.items, indistinguível de "sessão vazia de verdade" até o primeiro fetch resolver. Sem o gate !eventsQuery.isPending, o convite piscava por cima de sessões com histórico grande.

3. O indicador de "pensando" (bolha com os 3 pontinhos) só liga depois de 5s sem nenhum texto chegar. Antes ele ligava no instante em que streaming/statusAgent virava truthy — ruído visual na maioria dos turnos, que respondem em menos de um segundo. Um useEffect arma um setTimeout(…, 5000) quando o turno começa sem texto ainda, e o desarma (via cleanup) assim que o primeiro delta chega ou o turno termina antes do prazo. Texto de verdade (streamingText) continua aparecendo na hora, nunca espera o timer — só o indicador vazio é que ganhou paciência.

4. handleReadiness ganhou a mesma rede de segurança que handleSend já tinha. confirmReadiness é, como sendAgentMessage, um GenServer.call síncrono no engine (até 120s) — e o canal Phoenix pode não ter terminado de conectar (ticket + join, RN-108) quando o turno acaba, perdendo o broadcast de agent.done pra sempre. handleSend já chamava finalizarTurnoDoAgente() assim que a chamada síncrona resolvia, independente do canal ter entregue o evento; handleReadiness tinha ficado de fora dessa correção, e sem ela a bolha do agente ficava presa vazia (streaming: true, sem texto) até a página recarregar.

  • Onde: apps/web/src/routes/SessionPage.tsx (conversaComecou, conviteVisivel, o useEffect de pensandoVisivel logo abaixo de agenteExibido, e o bloco de sucesso de handleReadiness)
  • Teste: apps/web/src/routes/SessionPage.convite-so-em-sessao-vazia.test.tsx (sessão vazia mostra o convite; sessão com eventos de git-bootstrap ou com dezenas de tool.call/tool.result esconde; eventsQuery.isPending segura o convite até o primeiro fetch resolver), apps/web/src/routes/SessionPage.pista-e-status.test.tsx (achado B — indicador não aparece antes de 5s, aparece depois, some no primeiro delta e nunca aparece quando o turno termina antes do prazo), e apps/web/src/routes/SessionPage.readiness-turno-preso.test.tsx (confirmReadiness resolvendo sem onAgentDone reconcilia o estado do mesmo jeito que SessionPage.turno-preso.test.tsx já prova pra handleSend)
  • Origem: investigação AO VIVO do produto no Chrome — os três reproduzidos manualmente antes da correção.

RN-136 — O card acionável de handoff no chat só considera quem CONVERSA nesta tela​

Desde o ADR 0185 (RN-658) o handoff Criativo→PO que o "Estou pronto" pediu nasce e é aceito na mesma chamada, em nome de quem clicou — o card de aceite dele só aparece quando esse aceite não cabe (sessão anterior ao ADR, brief que não veio do clique) ou falhou. O filtro abaixo não muda.

OfferInfraHandoffUseCase oferece o handoff pro Infra e, na MESMA confirmação, oferece pro Dev Lead logo em seguida (FASE 14d) — duas chamadas separadas, a de Infra primeiro. handoffs (o que SessionPage.tsx lê de useHandoffs) vem ordenado por createdAt ASC (DrizzleHandoffRepository#findBySession), e o offeredHandoff que decide qual card vira "acionável" resolvia com um .find() puro — sempre o offered mais antigo. Como o Infra Lead não é conversacional (não está em AGENTES_DE_CHAT, e nunca é aceito por esta tela), o card do Dev Lead só ficaria acionável depois de alguém aceitar o de Infra num lugar que esta tela não mostra — na prática, nunca: o usuário só via "aceitar handoff de Infra" e o convite pro Dev Lead ficava invisível atrás dele.

O filtro restringe offeredHandoff a handoffs endereçados a um agente de AGENTES_DE_CHAT. O handoff pro Infra continua narrado no fio — o handoff.offered dele vira divisor mudo, como qualquer oferta que não é "a atual" — só o card com botão é que passa a ignorá-lo: Infra nunca teve (nem precisa ter) um jeito de ser aceito por aqui.

  • Onde: apps/web/src/routes/SessionPage.tsx (offeredHandoff)
  • Teste: apps/web/src/routes/SessionPage.handoff-devlead-e-colapso.test.tsx, describe "problema 1" — Infra mais antigo + Dev Lead mais novo resolve pro card do Dev Lead, nunca pro de Infra; só Infra offered não mostra card nenhum
  • Origem: investigação de código + teste ao vivo no Chrome — o handoff pro Dev Lead nunca ficava acionável depois da FASE 14d

RN-137 — "Ativar execução" tem atalho inline no card do Dev Lead, sem baixar a exigência de papel​

O card de aceite do handoff pro Dev Lead ganhou um botão "Ativar execução" ao lado do link "Acompanhe a execução em Executores" — atalho pra quem já sabe o que quer, sem passar pela conversa. Chama a MESMA activateExecution que a Visão Geral usa, agora com sessionId (a sessão de chat aberta) como originSessionId — sem isto a sessão que trouxe o Dev Lead ficava active para sempre, mesmo com a execução (numa sessão SEPARADA) já tendo decolado por este atalho (RN-135).

A rota continua exigindo maintainer — DELIBERADAMENTE não alinhada ao developer que já basta pra aceitar o handoff no mesmo card. Quem ativa vira session.createdBy da sessão de execução, e é esse papel que ProposeActionUseCase resolve (ResolveEffectiveRoleUseCase.forProject) como o EFETIVO de todo git_commit/git_push/pr_open que os dev agents propuserem dali em diante — é o mesmo motivo que já justificava o maintainer do botão da Visão Geral (ExecutionController#activate). Baixar a exigência aqui inverteria essa resolução em silêncio: toda PR que a execução abrisse passaria de auto_approve para require_approval sempre que quem clicou fosse developer, sem ninguém ter decidido isso. Quem não é maintainer recebe a frase real da api ("Papel insuficiente para esta ação", via mensagemDaApi) em vez de um erro genérico.

Sem gate de module_map client-side: quando este card existe, o Arquiteto já o definiu — é o artefato que precede a oferta do handoff pro Dev Lead —, então replicar o disabled={!hasModuleMap} da Visão Geral travaria o botão à toa. O caso raro (sessão inconsistente) cai no mesmo catch que trata 403/outros erros.

  • Onde: apps/web/src/routes/SessionPage.tsx (handleActivateExecution), apps/web/src/lib/api-client.ts (activateExecution ganhou originSessionId)
  • Teste: apps/web/src/routes/SessionPage.handoff-devlead-e-colapso.test.tsx, describe "problema 2" — clique chama activateExecution com sessionId; 403 mostra a frase real da api; o botão convive com o de aceitar o handoff e com o link de Executores
  • Origem: investigação de código + teste ao vivo no Chrome — pedido de atalho, com a divergência de papel confirmada e mantida por decisão (não corrigida por alinhamento automático)

RN-138 — Mensagens de um agente colapsam depois que ele passa o bastão​

A timeline do chat mostrava tudo sempre expandido, sem agrupar por autor — numa sessão longa com Criativo, PO e Arquiteto se revezando, o histórico de quem já saiu de cena competia por espaço com quem está falando agora. Cada entrada da timeline ganhou um agentId (o actor.id de quem a gerou, quando é um agente — agent.response, agent.error, épico/história criados pelo PO, card de aprovação); entradas de usuário e as que marcam uma TRANSIÇÃO (handoff, promoção de história) ficam sem agentId de propósito, porque são pontos de corte por natureza.

Uma sequência CONSECUTIVA do mesmo agentId vira cabeçalho colapsável (Disclosure do design system, fechado por padrão — nome do agente + contagem, reabre com um clique) quando as duas condições valem:

  1. o agente já passou o bastão — existe um handoff dele (fromAgent) com status: 'accepted' (a mesma verdade que o handoff.accepted do event log grava, sem precisar reconstruir por junção de evento); e
  2. nenhuma ação dele está pending — a checagem é por actor.id em TODAS as actions da sessão, não só nas da sequência corrente: uma corrida de aprovação em aberto não pode ficar escondida atrás de um clique em NENHUM ponto do fio.

Uma sequência de 1 entrada nunca colapsa — "Fulano · 1 mensagem" no lugar da própria mensagem não ganha nada. Qualquer entrada sem agentId, ou de um agente diferente, quebra a sequência corrente exatamente como uma troca de agente quebra — só agrupa o que é realmente consecutivo.

  • Onde: apps/web/src/routes/SessionPage.tsx (TimelineEntry.agentId, timelineAgrupada), apps/web/src/routes/SessionPage.module.css (.agentGroup*)
  • Teste: apps/web/src/routes/SessionPage.handoff-devlead-e-colapso.test.tsx, describe "problema 3" — colapsa com bastão passado e sem ação pendente; não colapsa sem handoff aceito; não colapsa com ação pendente do mesmo agente; mensagem de outro autor no meio quebra o agrupamento
  • Origem: investigação de código + teste ao vivo no Chrome — pedido de colapso, com AgentTimelineTree/timeline-tree.ts como referência de FORMA (agrupar por agente, nome + contagem, aberto/fechado), adaptada à timeline heterogênea intercalada do chat

Psicólogo e Anamnese​

RN-021 — Hipótese sem evidência válida não é gravada​

Os evidenceEventIds precisam apontar para eventos que existem e pertencem à sessão analisada. A validação é atômica por lote: um id inválido reprova o lote inteiro.

  • Onde: apps/api/src/domain/psychologist/hypothesis-evidence.ts
  • Teste: test/domain/psychologist/hypothesis-evidence.spec.ts
  • Origem: ADR 0015 §3

RN-022 — O ciclo de vida da hipótese é compare-and-swap​

proposed → accepted | dismissed. Duas decisões concorrentes sobre a mesma hipótese: uma vence, a outra recebe conflito — não 500, não silêncio.

  • Onde: apps/api/src/domain/psychologist/hypothesis-lifecycle.ts
  • Teste: test/domain/psychologist/hypothesis-lifecycle.spec.ts
  • Origem: ADR 0022 §7

RN-023 — A causa de término é classificação determinística​

Vem do motivo registrado, nunca de julgamento do LLM e nunca por eliminação. Toda falha registra a origem: infra | modelo | código | política.

  • Onde: apps/engine/lib/engine/psychologist/termination_classifier.ex
  • Origem: ADR 0022 §5 e ADR 0020 §6

RN-024 — A Anamnese só perfila competência do catálogo — guarda-corpo estrutural​

Competências de processo são seis, fechadas: git, agile, arquitetura, testes, seguranca, infra. Mais as stacks técnicas derivadas do module_map. Qualquer outra coisa — "ansiedade", "saúde mental" — não tem caminho de escrita: é erro no domínio, não instrução de prompt.

  • Onde: apps/api/src/domain/anamnese/competency-catalog.ts:16
  • Teste: test/domain/anamnese/competency-catalog.spec.ts
  • Origem: ADR 0016 §1
  • Por quê: a Anamnese perfila competência técnica, não pessoa. O limite é estrutural porque uma instrução de prompt não é garantia.

RN-025 — Apagar o perfil apaga de verdade, e o opt-out impede a re-derivação​

  • Onde: apps/api/src/domain/anamnese/proficiency-profile.entity.ts + tabela anamnese_opt_outs
  • Origem: ADR 0016 §2

RN-026 — Patch de instrução negado não é reproposto​

A comparação é sobre o conteúdo normalizado, não sobre o texto literal: reindentar o mesmo patch não o transforma em proposta nova. Não há tabela de dedup — a fonte é o próprio histórico de proposed_action com actionType = instruction_patch e status = denied.

  • Onde: apps/api/src/domain/instructions/patch-dedup.ts:22
  • Teste: test/domain/instructions/patch-dedup.spec.ts

RN-027 — Rollback de instrução é operação para frente​

Reverter cria uma versão nova com o conteúdo antigo; não apaga histórico. A tabela de versões é append-only.

  • Onde: apps/api/src/domain/instructions/
  • Origem: ADR 0016 §4

Git​

RN-028 — Capability decide, não o nome do provider​

Operação não suportada (proteção de branch no provider local) é declarada em capabilities e rejeitada com GitNotSupportedError — nunca falha silenciosa.

São quatro capabilities: protectBranch e pullRequests desde a Fase 2, e listTree e pullRequestDiff desde a FASE 26 (aba Code, só leitura). A regra não mudou com elas — mudou o alcance. E o critério de declarar continua sendo prova: capability só é true quando a suite a exercita naquele provider; sem prova, declara-se false e degrada (mesmo critério dos ADRs 0041/0042).

  • Onde: packages/shared/src/index.ts (GitProviderCapabilities); os tetos das duas operações de leitura em apps/api/src/domain/git/git-read-limits.ts, fora do shared porque ele é 100% tipo
  • Teste: suite de contrato única, test/contract/git-provider.contract.ts, rodada contra os três providers
  • Origem: ADR 0001

RN-029 — O bootstrap de Gitflow é idempotente e retomável​

Cinco passos; cada um verifica antes de agir e pode ser retomado do ponto que falhou. skip é sucesso, não erro.

Eram seis. O sexto criava a branch rc, degrau que o ADR 0030 removeu da política — o bootstrap continuou criando, protegendo e documentando no repositório do usuário uma escada que o produto já tinha abandonado (achado #3 do primeiro dogfooding). O valor create_rc_branch continua no enum bootstrap_step: linhas antigas o referenciam, e passo que aconteceu de verdade não se apaga do histórico.

  • Onde: apps/api/src/application/use-cases/git/bootstrap-steps.ts + domain/git/repo-bootstrap.entity.ts (RETIRED_BOOTSTRAP_STEPS)
  • Teste: test/domain/git/repo-bootstrap-status.spec.ts
  • Origem: ADR 0005

Credenciais​

Chave de LLM e token de git do usuário vivem na mesma tabela (user_credentials), sob o mesmo envelope encryption. Esta seção vale para as duas.

RN-055 — Credencial é sempre cifrada e gravada; verificar é ação à parte, com três respostas​

O cadastro não julga a credencial: cifra e grava, mesmo que o provider fosse recusá-la. Verificar é uma ação explícita sobre a credencial já gravada — a api decifra, chama o provider e devolve só o veredito. O texto plano nunca volta em resposta nenhuma, nem em pedaço, nem no motivo da recusa.

Era o contrário: o cadastro testava antes de persistir e recusava a gravação (ADR 0004, estendido às chaves de LLM na Fase 11a). O modo de falha real foi o oposto do previsto — como o campo é write-only e a tela nunca reexibe o que foi digitado, uma recusa deixava o usuário sem credencial e sem o texto para corrigir.

O veredito tem três valores, e o terceiro é obrigatório:

quando
oko provider aceitou
recusadoo provider rejeitou — carrega o motivo dele
nao_suportadonão há endpoint de teste verificado (ollama, anthropic, openai)

Sem nao_suportado, um provider cujo tester é NO-OP voltaria ok e a tela afirmaria uma verificação que nunca aconteceu. É a regra de capability do ADR 0041 aplicada aqui: só se declara o que foi provado. Por isso o port declara supports() — o silêncio de test() sozinho é ambíguo.

Chave ruim não é exceção HTTP: o caso de uso captura LLMCredentialConnectionTestFailedError/GitCredentialConnectionTestFailedError e devolve resultado, com 200. A única exceção é não existir credencial para (usuário, provider) — 404, porque aí não há o que testar.

  • Onde: apps/api/src/application/use-cases/credentials/test-stored-credential.use-case.ts, apps/api/src/application/use-cases/llm/upsert-user-credential.use-case.ts, apps/api/src/application/use-cases/git/register-git-credential.use-case.ts, apps/api/src/application/ports/llm-credential-connection-tester.port.ts (supports)
  • Teste: test/application/use-cases/credentials/test-stored-credential.use-case.spec.ts (os três resultados, o despacho git×LLM, o 404 e a ausência do segredo na resposta); test/application/use-cases/llm/upsert-user-credential.use-case.spec.ts e test/application/use-cases/git/register-git-credential.use-case.spec.ts (a gravação incondicional, que é a inversão)
  • Origem: ADR 0050

Autenticação​

As RNs do auth first-party moram em Regras de negócio — Autenticação.

Saíram daqui por TAMANHO, não por assunto: junto com Custo elas eram metade de uma página de 640 KB. O conteúdo não mudou, e as âncoras #rn-NNN são as mesmas — o que mudou foi o arquivo que as hospeda.

PROGRAMA 28 — Onda 4, frente G2: pipeline de indexação e busca híbrida do Chat RAG (RN-231..238, ADR 0080)​

RN-231 — ChunkRepository ganha DELETE e as duas metades da busca híbrida​

O port da Onda 3 (RN-226) só cobria escrita/leitura básica. A Onda 4 acrescentou deleteByScope/deleteBySession (para reindexação idempotente por full rebuild — apagar e recriar, nunca UPDATE) e searchByVector/searchByLexicalQuery (as duas metades da busca híbrida, cada uma uma consulta independente, aproveitando o índice feito para ela — HNSW ou GIN). deleteByScope é tipado Exclude<ChunkScope, 'session'>: apagar session por projeto inteiro apagaria sessões que não estão sendo reindexadas agora, então sessão só apaga por deleteBySession.

  • Onde: apps/api/src/application/ports/chunk-repository.port.ts (linhas 105-146), apps/api/src/infrastructure/persistence/drizzle/chunk.repository.ts
  • Teste: apps/api/test/infrastructure/persistence/chunk.repository.spec.ts — describes "Onda 4 (G2)" (delete por escopo/sessão, busca por vetor/léxico, caso feliz e vazio de cada um)
  • ADR: 0080

RN-232 — Origem do texto indexado: docs/adr do repositório do PRÓPRIO projeto, session só de chat.message/agent.response​

docs/adr são indexados via ReadProjectCodeUseCase — a mesma superfície da aba Code, com a mesma credencial do owner, o mesmo portão de container (RN-105) e a mesma checagem de caminho (RN-095) — nunca a documentação do Brabo enquanto produto. session indexa só dois tipos de evento (chat.message, agent.response); o resto do event log (tool.call, agent.status, agent.error...) é mecanismo/falha, não conhecimento citável.

  • Onde: apps/api/src/application/use-cases/rag/index-project-docs.use-case.ts (linhas 16-17, 64), apps/api/src/application/use-cases/rag/index-session.use-case.ts (linha 29, 45)
  • Teste: apps/api/test/application/use-cases/rag/index-project-docs.use-case.spec.ts, apps/api/test/application/use-cases/rag/index-session.use-case.spec.ts (casos felizes + "projeto sem docs/" + "evento sem payload.text é ignorado")
  • ADR: 0080

RN-233 — Provider de embedding indisponível: grava sem vetor e declara a lacuna, nunca finge indexação completa​

Quando ollama/nomic-embed-text não responde, RagEmbeddingService.embedMany não lança — devolve available: false e null por entrada pedida. O pipeline grava os chunks mesmo assim (embedding: null; search_vector é GENERATED ALWAYS AS e não depende de provider nenhum), e o relatório de indexação declara a lacuna (embedding: { available, embedded, skipped, reason }). A mesma degradação vale na busca: HybridSearchUseCase roda só com o sinal léxico e vectorAvailable: false avisa.

  • Onde: apps/api/src/application/use-cases/rag/rag-embedding.service.ts (linhas 53-58)
  • Teste: apps/api/test/application/use-cases/rag/rag-embedding.service.spec.ts — "CASO DE FALHA: provider sem a capability..." e "CASO DE FALHA: provider que lança no meio do lote..."; index-project-docs.use-case.spec.ts/ index-session.use-case.spec.ts — "CASO DE FALHA: provider de embedding indisponível grava os chunks SEM vetor..."; hybrid-search.use-case.spec.ts — "quando o embedding está indisponível, degrada para busca só léxica"
  • ADR: 0080

RN-234 — Busca híbrida: duas consultas independentes, fusão por peso (0.6/0.4), limiar 0.2, e o contrato de citação​

searchByVector/searchByLexicalQuery são consultas SEPARADAS (nunca um JOIN), fundidas em HybridSearchUseCase por soma ponderada (RAG_SEARCH_WEIGHT_VECTOR = 0.6, RAG_SEARCH_WEIGHT_LEXICAL = 0.4 — as escalas de cosseno e ts_rank normalizado não são comparáveis por natureza) e cortadas em RAG_SEARCH_SCORE_THRESHOLD = 0.2. Nenhum dos quatro números vem de calibração com dado real — são ponto de partida documentado. A citação (HybridSearchHit) expõe chunkId/content/scores separados (null, não zero, quando o sinal não achou o chunk)/origin como união discriminada por kind (file com sourcePath/headingPath, session com sessionId/eventId).

  • Onde: apps/api/src/application/use-cases/rag/hybrid-search.use-case.ts (linhas 44-113), apps/api/src/domain/rag/rag-search-limits.ts (linhas 60-93), apps/api/src/domain/rag/rag-citation.ts (linhas 12-71)
  • Teste: apps/api/test/application/use-cases/rag/hybrid-search.use-case.spec.ts (fusão/ordenação/limiar, query fora da faixa, degradação léxico-only, origin de sessão) e apps/api/test/infrastructure/persistence/chunk.repository.spec.ts (as consultas SQL de fato, contra pgvector/tsvector real)
  • ADR: 0080

RN-235 — Chunking: 1200 caracteres, 150 de sobreposição, por parágrafo/heading​

CHUNK_TARGET_CHARS = 1200 (~300 tokens em português), CHUNK_OVERLAP_CHARS = 150 (12,5%), com corte preferindo quebra de parágrafo, depois de palavra, dentro de uma janela de 200 caracteres. Markdown (docs/adr) é dividido por HEADING antes de ser recortado por tamanho, preservando headingPath. Números documentados como ponto de partida ajustável, não calibrados contra dado real de qualidade de recuperação (não existe, ainda, um corpo de perguntas reais rodado contra este índice).

  • Onde: apps/api/src/domain/rag/chunking.ts (linhas 30-31, 53 em diante)
  • Teste: apps/api/test/domain/rag/chunking.spec.ts (texto que cabe/não cabe, texto vazio, sobreposição real, texto patológico sem espaço, headings aninhados, markdown sem heading)
  • ADR: 0080

RN-236 — Reindexação é sempre MANUAL, full rebuild idempotente​

Não há watcher por push nem por fechamento de sessão. ReindexProjectUseCase roda docs/adr uma vez e uma IndexSessionUseCase por sessão do projeto, cada indexação apagando o escopo/sessão antes de recriar — rodar duas vezes seguidas não duplica. Disparado por POST /projects/:projectId/rag/reindex, role:maintainer.

  • Onde: apps/api/src/application/use-cases/rag/reindex-project.use-case.ts (linha 36)
  • Teste: apps/api/test/application/use-cases/rag/reindex-project.use-case.spec.ts (agrega docs+sessões, embeddingAvailable falso quando qualquer rodada falha, projeto inexistente)
  • ADR: 0080

RN-237 — Cobertura do índice é contagem REAL, nunca "há N minutos" inventado​

GetRagCoverageUseCase conta arquivos .md reais no repositório do projeto contra quantos têm chunk, e sessões do projeto contra quantas têm chunk. Não existe coluna de timestamp de indexação por escopo, e a resposta não inclui nenhum "reindexado há Xmin" — um número chutado mentiria (mesma régua do ADR 0042 para nota de modelo).

  • Onde: apps/api/src/application/use-cases/rag/get-rag-coverage.use-case.ts (linha 47)
  • Teste: apps/api/test/application/use-cases/rag/get-rag-coverage.use-case.spec.ts
  • ADR: 0080

RN-238 — As três rotas HTTP do RAG dividem papel por quem MUDA o que o produto gasta​

POST .../rag/search e GET .../rag/coverage são role:viewer (leitura pura); POST .../rag/reindex é role:maintainer, porque dispara N chamadas ao repositório do projeto e ao provider de embedding — mesma régua "muda o que o produto gasta sem perguntar" do teto de paralelismo de área (RN-083).

  • Onde: apps/api/src/interfaces/http/rag/rag.controller.ts (linhas 50, 57, 81, 98), docs/security-surface.md
  • Teste: apps/api/test/interfaces/route-surface.spec.ts (classificação de papel, tags fechadas, metadados OpenAPI das três rotas)
  • ADR: 0080

PROGRAMA 28 — Onda 4, frente E2: virtualização de linha e minimapa na aba Code (RN-239..242)​

RN-239 — Virtualização de linha: só a janela visível vira nó de DOM​

CodeEditor.tsx renderiza só as linhas dentro da janela visível (mais OVERSCAN = 20 de margem) como [data-line-row]; o resto vira dois espaçadores que reservam a altura sem existir como nó. A decisão de NÃO usar react-window/react-virtual: a altura de linha é FIXA (ALTURA_LINHA = 21, monoespaçado), o que reduz "qual linha está visível" a uma divisão inteira sobre scrollTop — o problema que essas libs resolvem de verdade é altura VARIÁVEL por item, que não é o caso da aba Code.

  • Onde: apps/web/src/routes/code/CodeEditor.tsx (linhas 38-96, 45, 48)
  • Teste: apps/web/src/routes/code/CodeEditor.test.tsx — describe "CodeEditor — virtualização", "caminho feliz: arquivo de 5.000 linhas renderiza uma janela pequena de nós, não o arquivo inteiro" (< 150 nós [data-line-row]) e "rolar para o meio do arquivo troca a janela — linha 1 some, linhas do meio aparecem"

RN-240 — Sem medição de altura, degrada para janela padrão generosa, nunca renderiza tudo​

Quando containerHeight ainda não foi medido (1º render, ou jsdom sem ResizeObserver em teste), a janela cai para LINHAS_SEM_MEDICAO = 40 — generosa o bastante para não truncar os arquivos pequenos que os testes usam, pequena o bastante para nunca virar "renderiza tudo" num arquivo grande de verdade.

  • Onde: apps/web/src/routes/code/CodeEditor.tsx (linha 57)
  • Teste: apps/web/src/routes/code/CodeEditor.test.tsx — "falha/borda: sem medição de altura (jsdom sem ResizeObserver, clientHeight 0), degrada para uma janela padrão em vez de não renderizar nada"

RN-241 — Minimapa reusa a MESMA tokenização da virtualização — zero segundo passe sobre o arquivo​

minimap.ts não tokeniza nada: ele resume a saída que highlight.ts já produziu (HighlightToken[][] de highlightFile, chamada uma vez só) — a MESMA que a virtualização usa para colorir as linhas visíveis. Tokenizar de novo só para o minimapa pagaria o custo de leitura do arquivo pela segunda vez, e por isso o minimapa só entra depois de a virtualização estar de pé, nunca antes.

  • Onde: apps/web/src/routes/code/minimap.ts (linhas 1-18, 39)
  • Teste: apps/web/src/routes/code/minimap.test.ts

RN-242 — Minimapa é CANVAS, um nó de DOM só, nunca um <div> por linha​

O desenho é em <canvas> (desenharMinimapa), não em elemento por linha: um minimapa com um nó por linha dobraria de novo a contagem que a virtualização (RN-239) acabou de cortar. Sem contexto de canvas 2D disponível (jsdom sem o pacote canvas), o overlay clicável continua funcionando — só o desenho é pulado, nunca a interação.

  • Onde: apps/web/src/routes/code/minimap.ts (linhas 14-16, 91)
  • Teste: apps/web/src/routes/code/CodeEditor.test.tsx — describe "CodeEditor — minimapa", "caminho feliz: clicar no minimapa rola o editor e troca a janela renderizada" e "falha: sem contexto de canvas 2D (jsdom sem o pacote canvas), o overlay continua clicável e nada quebra"

RN-243 — O ciclo de vida do container é TABELA, e nenhuma linha dela chama Docker​

project_containers (migração 0046) grava o ESTADO mutável do container de um projeto — distinto de artifact.project_image no event log (ADR 0065), que é a DECISÃO imutável do Arquiteto. Nem RegistrarTransicaoDeContainerUseCase nem ObterCicloDeVidaDoContainerUseCase chamam um daemon Docker: nenhum serviço do produto monta /var/run/docker.sock nem roda privileged hoje, e conceder isso é decisão de segurança fora do escopo desta regra. Um orquestrador real, quando existir, CONSOME esta tabela depois de agir de verdade — nunca o contrário.

  • Onde: apps/api/src/domain/containers/container-lifecycle.ts, apps/api/src/application/use-cases/containers/registrar-transicao-de-container.use-case.ts
  • Teste: apps/api/test/application/use-cases/containers/ciclo-de-vida-do-container.use-case.spec.ts
  • ADR: 0081

RN-244 — A máquina de estados do container: só removed sai reprovisionando​

provisioning → running ⇄ stopped, com failed alcançável de provisioning/running/stopped, e removed → provisioning como a ÚNICA saída de removed — nenhum estado é terminal de verdade, porque um projeto pode reprovisionar com uma imagem revisada pelo Arquiteto. Transição fora da tabela lança InvalidContainerTransitionError, que o caso de uso traduz para 409 — mesmo formato de session-state-machine.ts e pr-gate-state-machine.ts.

  • Onde: apps/api/src/domain/containers/container-lifecycle.ts (ALLOWED_TRANSITIONS)
  • Teste: apps/api/test/domain/containers/container-lifecycle.spec.ts
  • ADR: 0081

RN-245 — A primeira transição exige a imagem já decidida, e CONGELA versão e recursos​

Não existe linha até a primeira chamada com to: 'provisioning', e ela só é aceita se o Arquiteto já tiver decidido a imagem do projeto (RN-105) — o mesmo portão que já protege a aba Code, aplicado na origem em vez de duplicado. A versão de artifact.project_image e os recursos declarados naquele instante são CONGELADOS na linha nova (image_version, cpus, memory_mb, pids_limit): uma revisão posterior do artefato não muda retroativamente o que uma instância já provisionada promete.

  • Onde: apps/api/src/application/use-cases/containers/registrar-transicao-de-container.use-case.ts
  • Teste: apps/api/test/application/use-cases/containers/ciclo-de-vida-do-container.use-case.spec.ts — "a primeira transição (provisioning) cria a linha…" e "sem decisão de imagem do Arquiteto, não há o que provisionar"
  • ADR: 0081

RN-246 — Projeto em modo local não tem ciclo de vida de container​

Um projeto com workspace_mode: 'local' (ADR 0072, RN-169) roda no container do AGENTE de sempre — não sobe container próprio. Pedir qualquer transição para um projeto local é recusado com 400 ANTES de tocar a tabela, na origem, não filtrado depois na UI.

  • Onde: apps/api/src/application/use-cases/containers/registrar-transicao-de-container.use-case.ts
  • Teste: apps/api/test/application/use-cases/containers/ciclo-de-vida-do-container.use-case.spec.ts — "projeto em modo local não tem ciclo de vida de container (ADR 0072)"
  • ADR: 0081

RN-247 — Uma linha por projeto: project_id é único​

Só existe UM container vigente por projeto de cada vez — o mesmo desenho de dev_agent_states no engine (ADR 0045). A constraint única mora no BANCO (project_containers_project_id_unique), não só no caso de uso: create chamado duas vezes para o mesmo projeto falha na escrita, nunca produz uma segunda linha silenciosa.

  • Onde: apps/api/src/db/migrations/0046_chilly_forgotten_one.sql
  • Teste: apps/api/test/infrastructure/persistence/drizzle/container.repository.spec.ts — "só uma linha por projeto — create duplicado viola a constraint única"
  • ADR: 0081

RN-248 — Teto de recursos DECLARADO, não aplicado — o campo existe para quando houver orquestrador​

cpus/memory_mb/pids_limit gravam o que o artefato do Arquiteto prometia no momento do provisionamento, mas nenhum processo hoje faz o kernel respeitar esse teto — não há orquestrador chamando docker run --cpus … --memory … --pids-limit …. O campo nasce mesmo assim porque adiar a coluna para quando o orquestrador existir obrigaria uma migration de correção no dia em que ele chegasse; declarar sem aplicar é honesto enquanto a tabela não mentir sobre APLICAR (nenhuma tela ou resposta de API hoje afirma "o container está limitado a X" — só "a intenção registrada era X").

  • Onde: apps/api/src/db/schema/containers.ts (projectContainers)
  • Teste: apps/api/test/infrastructure/persistence/drizzle/container.repository.spec.ts — "create nasce em provisioning, com a versão e os recursos passados"
  • ADR: 0081

PROGRAMA 28 — Onda 4, frente H4: os colapsos ad-hoc restantes migram para o Disclosure (RN-249..251)​

RN-249 — Alvo de clique do marco com detalhe sobe de 20px para o piso de 24px do Disclosure​

O marco COM detalhe de AgentTimelineTree tinha min-height: 20px no cabeçalho clicável — abaixo do WCAG 2.2 AA 2.5.8 (Target Size, mínimo 24px) que um comentário anterior já prometia sem cumprir. A migração para o Disclosure compartilhado corrige isso de graça: o componente genérico já nasce em 24px, e não é preciso lembrar de setar a régua em cada novo consumidor.

  • Onde: apps/web/src/components/AgentTimelineTree.module.css (linhas 135-155, .marcoLinha)
  • Teste: apps/web/src/components/AgentTimelineTree.test.tsx
  • ADR: nenhum — correção pontual de a11y, mesmo padrão do ADR 0036

RN-250 — Faixa de arquivo do diff em ApprovalCard NÃO migra para o Disclosure — a animação própria é a razão​

A faixa de arquivo do diff em ApprovalCard gira o chevron por transform: rotate(90deg) com transição própria (.chevron.open/ApprovalCard.module.css); o Disclosure genérico TROCA o ícone (seta direita → seta baixo) sem animação nenhuma. Forçar a migração apagaria a micro-interação sem ganho — a exclusividade (só um arquivo aberto por vez) já vem de fora (expandedFile), o mesmo que o Disclosure controlado faria. O que faltava, e não era peculiaridade — é o mesmo defeito que o Disclosure existe para fechar —, era aria-controls apontando para uma região nomeada: corrigido diretamente, sem trocar de componente.

  • Onde: apps/web/src/components/ApprovalCard.tsx (linhas 511-539)
  • Teste: apps/web/src/components/ApprovalCard.test.tsx

RN-251 — Cinco call sites migram para o Disclosure compartilhado; o componente ganha testId como único hook novo​

ModelCatalogSection (a referência original que gerou o componente na FASE 16), AgentTimelineTree (ramo + marco), code/CodeExplorer.tsx (pasta da árvore) e code/CodeShell.tsx (painel inferior) passam a usar o Disclosure compartilhado — nenhuma das seis implementações ad-hoc que sobreviveram à FASE 16 fica de fora, exceto a exceção declarada da RN-250. testId é o único prop novo que um consumidor precisou (data- testid no cabeçalho, para os testes existentes continuarem selecionando o botão certo sem reescrever a suíte inteira). A região controlada existe no DOM mesmo fechada — só hidden e sem os filhos montados —, porque aria-controls apontando para um id que não resolve é pior que não ter o atributo (leitor de tela anunciaria controle de algo inexistente); é essa mesma propriedade que permite colapsar listas caras (o catálogo do OpenRouter tem 338 modelos) sem montar o que ninguém está vendo.

  • Onde: apps/web/src/components/ui/Disclosure.tsx (linhas 33-71), apps/web/src/components/ModelCatalogSection.tsx, apps/web/src/components/AgentTimelineTree.tsx, apps/web/src/routes/code/CodeExplorer.tsx, apps/web/src/routes/code/CodeShell.tsx
  • Teste: apps/web/src/components/ui/Disclosure.test.tsx, apps/web/src/routes/code/CodeExplorer.test.tsx, apps/web/src/routes/code/CodeShell.test.tsx

PROGRAMA 28 — Onda 5, frente G3: a tela do Chat RAG (RN-252..254, ADR 0082)​

RN-252 — A tela do Chat RAG mostra as duas degradações honestas que o backend já declara, nunca as esconde​

HybridSearchUseCase já devolve vectorAvailable: false quando o provider de embedding não respondeu (RN-233) e GetRagCoverageUseCase já nunca inclui timestamp de indexação (RN-237) — mas um contrato honesto no backend não garante uma tela honesta se ela decidir não ler os dois campos. ProjectRagTab mostra um aviso acima dos resultados quando vectorAvailable é false (com vectorUnavailableReason, quando existe), e RagCoveragePanel só renderiza contagem REAL (filesIndexed/ filesInRepo, sessionsIndexed/sessionsInProject) — nenhum texto do tipo "reindexado há Xmin" nasce nesta tela, porque a resposta não carrega esse dado e inventá-lo mentiria (mesma régua do ADR 0042 para nota de modelo).

  • Onde: apps/web/src/routes/ProjectRagTab.tsx (linhas 27-31, 176-182), apps/web/src/components/rag/RagCoveragePanel.tsx (linhas 27-34)
  • Teste: apps/web/src/routes/ProjectRagTab.test.tsx — "CASO DE FALHA (degradação honesta): vectorAvailable false avisa..."; apps/web/src/components/rag/RagCoveragePanel.test.tsx — "CASO DE FALHA (degradação honesta): nunca escreve..."
  • ADR: 0082

RN-253 — Citação de origem session navega até o EVENTO exato, reusando o mecanismo do Psicólogo​

RagCitationCard não inventa um segundo caminho de navegação: origem { kind: 'session', sessionId, eventId } chama useNavigate para /projects/:projectId/sessions/:sessionId com search: { highlightEvent: eventId } — a MESMA rota e o MESMO parâmetro que os chips de evidência do Psicólogo já usam (HypothesisCard.tsx, Fase 4b) para rolar o fio até o evento e destacá-lo. Origem { kind: 'file' } mostra caminho e headingPath como texto, sem link: a aba Código não tem hoje deep-link por caminho, e construir essa navegação está fora do escopo desta frente.

  • Onde: apps/web/src/components/rag/RagCitationCard.tsx (linhas 32-38, 49-56)
  • Teste: apps/web/src/components/rag/RagCitationCard.test.tsx — "caminho feliz: origem de sessão navega até o evento exato ao clicar"
  • ADR: 0082

RN-254 — O botão de reindexar é maintainer/owner na TELA, espelhando a régua da rota​

POST .../rag/reindex já exige role:maintainer (RN-238) — quem garante é a api. ProjectRagTab espelha a régua no CLIENTE pelo mesmo padrão que ProjectSettingsTab/ProjectApprovalsTab já usam para outros gates de maintainer (useCurrentWorkspaceWithRole, já que não existe hoje um papel de PROJETO no cliente, só o de workspace que a listagem devolve): o botão "Reindexar agora" nem aparece para quem não é owner/maintainer, em vez de aparecer desabilitado — reindexar dispara N chamadas ao repositório do projeto e ao provider de embedding (mesma régua "muda o que o produto gasta sem perguntar" do teto de paralelismo, RN-083), e um botão visível mas sempre recusado só ensinaria a ignorar o 403.

  • Onde: apps/web/src/routes/ProjectRagTab.tsx (linhas 43-44, 109)
  • Teste: apps/web/src/routes/ProjectRagTab.test.tsx — "botão de reindexar só aparece para maintainer/owner..."; "maintainer vê e pode disparar a reindexação"
  • ADR: 0082

RN-267 — GET /projects/:projectId/container/lifecycle é a primeira exposição HTTP do ciclo de vida do container​

O ADR 0081 criou ObterCicloDeVidaDoContainerUseCase sem rota, de propósito ("expor uma seria adivinhar contrato" antes de existir um consumidor real). A Onda 5/F2 é esse consumidor (RN-268), e a rota nasce como espelho fiel do caso de uso: null quando o projeto nunca foi provisionado — o resultado esperado hoje, porque nenhum orquestrador real transiciona project_containers em produção (RegistrarTransicaoDeContainerUseCase não tem chamador nenhum fora de teste) — ou o estado registrado (status/imageVersion/resources/failureReason/statusChangedAt), NUNCA confirmado contra um daemon Docker, porque não existe cliente Docker no produto (RN-243). Mesma permissão da rota irmã (GET /projects/:projectId/container): viewer, GET, sem @Post — quem transicionaria o ciclo de vida é um orquestrador que ainda não existe.

  • Onde: apps/api/src/interfaces/http/containers/containers.controller.ts (cicloDeVida), apps/api/src/interfaces/http/containers/dto/containers.response.dto.ts (CicloDeVidaDoContainerResponseDto)
  • Teste: apps/api/test/interfaces/http/containers/containers.controller.spec.ts
  • Borda: id/projectId/containerId da linha interna NÃO vazam na resposta — o contrato HTTP não é a mesma forma que a linha do banco.
  • ADR: 0083 (revisa o 0081)

RN-268 — A aba Terminal mostra o estado REAL do container, nunca finge um terminal que não existe​

O plano original desta frente era o terminal interativo completo, mas a investigação confirmou que a FASE 25b continua cortada: nenhum serviço monta /var/run/docker.sock, e mesmo depois do ADR 0081 (Onda 4) nada transiciona project_containers em produção. Implementar um terminal que finge executar comandos — ou que roda no mesmo container do monorepo do Brabo, a dívida que o ADR 0055 já descreve como política e não isolamento — seria inventar capacidade, o mesmo erro que os ADRs 0041/0042 já recusam para provider de LLM e modelo de catálogo.

O que a aba GANHA é honesto: sob o texto explicativo que já existia (FASE 26b), CodeBottomPanel busca GET .../container/lifecycle (RN-267) só enquanto a aba Terminal está aberta (enabled: aba === 'terminal' — sem polling em segundo plano, a mesma disciplina de tráfego da RN-107) e mostra o status com um Badge (provisioning/running/stopped/failed/ removed, em pt-BR), há quanto tempo, e o failureReason quando o estado é failed. Projeto nunca provisionado — o caso comum hoje — mostra a frase "ainda não foi provisionado", nunca um badge inventado.

  • Onde: apps/web/src/routes/code/CodeBottomPanel.tsx, apps/web/src/routes/code/CodeBottomPanel.module.css, apps/web/src/lib/api-client.ts (getContainerLifecycle), apps/web/src/lib/api-types.ts (CicloDeVidaDoContainer)
  • Teste: apps/web/src/routes/code/CodeBottomPanel.test.tsx (describe "Terminal — o estado REAL do ciclo de vida do container")
  • Borda: o terminal interativo em si NÃO nasce aqui — é FASE 25b, que segue cortada e depende da parede física do container (o worktree do agente vivendo lá dentro), não desta rota de leitura.
  • ADR: 0083

RN-272 — O callback do login social decide em ORDEM: identidade conhecida, depois vínculo por e-mail, depois conta nova​

SocialLoginCallbackUseCase resolve a identidade do provider em três passos, nesta ordem, e nunca fora dela: (provider, providerUserId) já vinculada → login direto; sem vínculo mas o e-mail bate com uma conta existente → decide por RN-274/RN-275; nenhuma das duas → provisiona conta nova (RN-278). A chave de busca do primeiro passo é sempre providerUserId, nunca e-mail — evita que trocar o e-mail no provider "perca" o vínculo.

  • Onde: apps/api/src/application/use-cases/auth/social-login-callback.use-case.ts
  • Teste: apps/api/test/application/use-cases/auth/social-login-callback.use-case.spec.ts — os sete casos de SocialLoginCallbackUseCase
  • ADR: 0084

RN-273 — O state do login social tem propósito PRÓPRIO, mesmo assinado pela mesma chave do fluxo de conexão de git​

domain/auth/social-oauth-state.ts assina com GIT_OAUTH_STATE_SECRET — a MESMA chave HMAC do state de "conectar git ao projeto X" (domain/git/oauth-state.ts) — mas o payload carrega purpose: 'social_login', checado ANTES de qualquer outro campo. Um state do fluxo de conexão de git, mesmo com assinatura válida, é recusado aqui: sem o discriminante, aceitá-lo equivaleria a logar como o userId de quem iniciou aquela conexão — escalação de privilégio.

  • Onde: apps/api/src/domain/auth/social-oauth-state.ts
  • Teste: apps/api/test/domain/auth/social-oauth-state.spec.ts — "RN-273: rejeita um state do fluxo de CONEXÃO de git, mesma chave"
  • ADR: 0084

RN-274 — Vincular identidade social a conta existente exige e-mail VERIFICADO pelo provider​

Quando o e-mail devolvido pelo provider bate com uma conta já cadastrada, o vínculo só acontece se o provider marca aquele e-mail como verificado (emailVerified: true) — GitHub via GET /user/emails, GitLab via confirmed_at. Um e-mail digitado mas não verificado não é prova de identidade: qualquer um pode registrar um e-mail alheio num provider OAuth. Aceitar o vínculo sem essa checagem abriria account takeover — quem já tem alguem@empresa.com na Brabo não pediu para um GitHub alheio, com aquele endereço só DIGITADO, herdar a conta. A recusa é 403, e nenhum vínculo é gravado.

  • Onde: apps/api/src/application/use-cases/auth/social-login-callback.use-case.ts (vincularAContaExistente)
  • Teste: apps/api/test/application/use-cases/auth/social-login-callback.use-case.spec.ts — "RECUSA vincular quando o e-mail bate mas NÃO está verificado pelo provider"
  • ADR: 0084

RN-275 — Provisionar conta NOVA não exige e-mail verificado — só vincular a uma existente exige​

A verificação de e-mail da RN-274 protege uma conta que já existe; quando não há conta correspondente, não há nada a proteger, só uma conta nova a nascer. Exigir e-mail verificado nesse caso encareceria o caminho comum (a maioria dos usuários de GitHub tem e-mail verificado, mas nem todos, e recusar o login por isso seria atrito sem ganho de segurança correspondente) sem reduzir risco nenhum — o pior caso é uma conta nova com um e-mail não comprovado, o mesmo risco que o registro por senha já aceita implicitamente até o clique no link de verificação.

  • Onde: apps/api/src/application/use-cases/auth/social-login-callback.use-case.ts (provisionarContaNova)
  • Teste: apps/api/test/application/use-cases/auth/social-login-callback.use-case.spec.ts — "provisiona conta nova mesmo com e-mail NÃO verificado"
  • ADR: 0084

RN-276 — providerUserId, nunca e-mail ou login, é a chave de identidade social​

social_identities tem índice único em (provider, provider_user_id) — o id NUMÉRICO e estável do provider. E-mail e login (username) podem mudar de dono ou de valor no provider sem aviso; usá-los como chave faria uma troca de e-mail no GitHub "perder" o vínculo de quem já tinha conta, ou pior, herdar silenciosamente o vínculo de outra pessoa que reusou aquele endereço depois.

  • Onde: apps/api/src/db/migrations/0047_complete_hannibal_king.sql, apps/api/src/application/ports/social-identity-repository.port.ts
  • Teste: apps/api/test/application/use-cases/auth/social-login-callback.use-case.spec.ts — "identidade já conhecida: login direto, sem criar segunda linha"
  • ADR: 0084

RN-277 — Login social pede escopo MÍNIMO, nunca o de conexão de repositório​

buildLoginAuthorizeUrl pede read:user user:email (GitHub) e read_user (GitLab) — nunca o repo/api que o fluxo de CONEXÃO de git pede. Entrar na conta não deveria conceder acesso a repositório nenhum; os dois fluxos reusam o MESMO app OAuth (RN-281) mas pedem autorizações diferentes, decididas na hora da autorização, não na configuração do app.

  • Onde: apps/api/src/infrastructure/git/github-oauth-client.ts, apps/api/src/infrastructure/git/gitlab-oauth-client.ts (buildLoginAuthorizeUrl)
  • Teste: apps/api/test/application/use-cases/auth/social-login-callback.use-case.spec.ts — "StartSocialLoginUseCase › caminho feliz"
  • ADR: 0084

RN-278 — Conta provisionada por login social nasce SEM senha — mesmo estado "pendente" da migração do Keycloak​

Provisionar por login social grava uma linha em users e NENHUMA em auth_credentials — o mesmo par que a migração do Keycloak já deixava para "conta sem senha ainda" (ver RN-032 e migracao-keycloak.spec.ts). LoginUseCase e ResetPasswordUseCase já tratam esse estado; não foi necessário um segundo mecanismo de "senha pendente" para o login social.

  • Onde: apps/api/src/application/ports/auth-credential-repository.port.ts (criarUsuarioSemCredencial), apps/api/src/infrastructure/persistence/drizzle/auth-credential.repository.ts
  • Teste: apps/api/test/application/use-cases/auth/social-login-callback.use-case.spec.ts — "caminho feliz: provisiona um usuário NOVO, sem senha (RN-278)"
  • ADR: 0084

RN-279 — Vincular por e-mail verificado também verifica o e-mail da conta existente​

Quando uma conta registrada por senha (e nunca verificada) é vinculada a uma identidade social cujo e-mail o provider marca como verificado, a conta ganha emailVerifiedAt preenchido como efeito colateral. O provider acabou de provar, por um caminho independente, exatamente o que o clique no link de verificação provaria — não faz sentido a conta continuar bloqueada do login por senha (RN-032) depois de provar posse do e-mail por outro caminho igualmente forte.

  • Onde: apps/api/src/application/use-cases/auth/social-login-callback.use-case.ts (vincularAContaExistente)
  • Teste: apps/api/test/application/use-cases/auth/social-login-callback.use-case.spec.ts — "vincula a conta existente (…) e marca o e-mail dela como verificado (RN-274/279)"
  • ADR: 0084

RN-280 — Login social recusa conta DESABILITADA, mesma régua do login por senha​

Identidade social vinculada a uma conta com auth_credentials.disabled_at preenchido é recusada com 403, tanto no login direto (identidade já conhecida) quanto na tentativa de vincular. Desabilitar uma conta não pode ser contornado trocando de método de entrada.

  • Onde: apps/api/src/application/use-cases/auth/social-login-callback.use-case.ts (entrarComIdentidadeConhecida, vincularAContaExistente)
  • Teste: apps/api/test/application/use-cases/auth/social-login-callback.use-case.spec.ts — "recusa login de identidade vinculada a conta DESABILITADA"
  • ADR: 0084

RN-281 — O login social reusa o MESMO app OAuth da conexão de git — sem variável de ambiente nova​

GITHUB_OAUTH_CLIENT_ID/_SECRET e GITLAB_OAUTH_CLIENT_ID/_SECRET continuam sendo os únicos client id/secret cadastrados. O que muda por fluxo é o redirect_uri (/auth/oauth/<provider>/callback contra /git/oauth/<provider>/callback) e o scope (RN-277) — os dois decididos em tempo de requisição. Ação do OPERADOR continua necessária (cadastrar o segundo callback URL no app de cada provider), documentada em .env.example — é essa exigência, não uma env var nova, que justifica o branch nascer breaking/.

  • Onde: .env.example, apps/api/src/application/use-cases/auth/start-social-login.use-case.ts
  • Teste: manual (cadastro no app OAuth) — não há como testar automaticamente uma configuração externa ao produto
  • ADR: 0084

RN-282 — O access token do login social nunca viaja na URL nem no corpo do callback​

GET /auth/oauth/:provider/callback grava os cookies de sessão (definirCookiesDeSessao, a MESMA função do login por senha) e redireciona para WEB_ORIGIN/. O boot da web (restaurarSessao(), chamado em toda carga de página) troca o refresh recém-gravado por um access token — nenhum código novo do lado do cliente além dos botões e o alias de rota de erro.

  • Onde: apps/api/src/interfaces/http/auth/auth.controller.ts (oauthCallback)
  • Teste: apps/api/test/interfaces/route-surface.spec.ts — cobre a classificação pública e os metadados OpenAPI da rota; o fluxo completo de cookie→boot é E2E, fora do escopo de teste de unidade
  • ADR: 0084

RN-283 — Falha do callback de login social não vaza o motivo na URL​

Qualquer falha (state inválido, code rejeitado pelo provider, conta desabilitada, e-mail não verificado) redireciona para WEB_ORIGIN/login?oauth_error=1 — um único sinal genérico, nunca o motivo real. Mesmo padrão do callback de conexão de git (WEB_ORIGIN/git-error).

  • Onde: apps/api/src/interfaces/http/auth/auth.controller.ts (oauthCallback, bloco catch)
  • Teste: apps/api/test/application/use-cases/auth/social-login-callback.use-case.spec.ts — cobre os desfechos de falha que o controller mapeia para o mesmo redirect genérico
  • ADR: 0084

Auditoria fluxo.yml × código — o plano do Dev Lead vira aprovação de verdade (RN-284, ADR 0086)​

Não é fase planejada: é a correção de uma divergência que a auditoria de docs/fluxo.yml × código encontrou (docs/explanation/auditoria-fluxo-vs-codigo.md, achado A2) — o fluxo já declarava a saída plano-de-paralelismo do dev-lead como via: proposed_action, e o código nunca foi ajustado para bater.

RN-284 — O turno do agente conversacional pode SUSPENDER esperando aprovação humana​

Revisada pela RN-677 (ADR 0194): propose_execution_plan ganhou pipeline de execução — aprovar o plano grava o módulo das tarefas (RN-678) e ATIVA a execução; a ação termina executed ou failed, e é esse desfecho que o Dev Lead lê ao retomar. O item 1 abaixo ("não tem execute-* pipeline") deixou de valer para este tipo.

Revisada pela RN-578 (ADR 0163): o turno de TODO conversacional deixou de segurar o GenServer.call — o from é respondido no aceite, e a suspensão aqui descrita mudou só o FECHO do turno (awaiting_approval em vez de agent.done), não mais o momento da resposta. A user_message recusada durante a suspensão passa a ser 409 no clique.

Primeira vez que um agente conversacional (Criativo, PO, Arquiteto, Dev Lead — todos rodam turno síncrono via GenServer.call de até 180s, mediado por Engine.Agents.TurnoAssincrono) suspende esperando uma decisão humana. O padrão já existia para o dev agent (RN-073, ADR 0052) e os gates de QA/Infra (ADR 0057), mas os dois são disparados por cast e nunca esperavam resposta síncrona — é exatamente esse ponto que este mecanismo resolve, e o teto de paralelismo que fez o Dev Lead existir (RN-083) é a razão de a primeira aprovação suspensa ser a dele.

O mecanismo, em quatro peças:

  1. Engine.Agents.DevLeadTools.run/2 (apps/engine/lib/engine/agents/dev_lead_tools.ex:81-107) chama EngineApiClient.propose_action/5 em vez de append_event/3, e devolve {:ok, texto} (status executed/auto_approved/approved — os três contam como sucesso, porque propose_execution_plan não tem execute-* pipeline própria e a aprovação manual fica em "approved" para sempre), {:pending, action_id} ou {:error, texto}.
  2. Engine.Agents.DevLeadServer.run_turn/2 (apps/engine/lib/engine/agents/dev_lead_server.ex, bloco de despacho de tool_calls) usa Enum.reduce_while para PARAR no primeiro :pending, sem processar as chamadas seguintes nem recursar — e devolve o state com a chave :aguardando_aprovacao setada (%{action_id:, tool_call_id:, tool_name:, remaining:}, o remaining já descontando a iteração suspensa contra o teto). A mensagem role: "tool" NÃO entra em state.messages nesse momento — gravar "pending" ali mentiria pro modelo que o comando já respondeu isso (mesmo raciocínio do dev agent, Engine.Harness.Hooks.ActionPipeline).
  3. Engine.Agents.TurnoAssincrono.tratar_resultado/2 (apps/engine/lib/engine/agents/turno_assincrono.ex) responde ao from do mesmo jeito e na mesma hora de sempre — é o que rompe o bloqueio síncrono no momento certo —, mas em vez de finalizar/1 (que emite agent.done e agent.status: idle) chama suspender/1: só agent.status: awaiting_approval, sem agent.done, porque o turno não terminou. A checagem é pelo VALOR da chave (Map.get/2, truthy), nunca pela presença dela — o Dev Lead carrega aguardando_aprovacao: nil desde o init/1, então a chave em si está sempre presente.
  4. Engine.Sessions.LiveBroadcast.agent_status/4 (apps/engine/lib/engine/sessions/live_broadcast.ex:38-39) ampliou a guarda de ["working", "idle"] para incluir "awaiting_approval" — sem isto o agent.status do passo 3 nem seria persistido.

A retomada. Engine.Agents.DevLeadServer assina Engine.Dev.Wake.subscribe(project_id, "dev-lead") no init/1 — o MESMO módulo que Engine.Gates.QaLeadServer já reusa para os subagentes de QA, apesar do nome ser "dev": a entrega de {:action_settled, ...} é por AGENTE, roteada pelo agentId do payload (apps/engine/lib/engine/workers/dev_agent_wake_worker.ex), não por tipo de agente. Quando a decisão chega, um handle_info monta a mensagem role: "tool" com o resultado REAL (texto_do_desfecho/1, mesmo vocabulário do dev agent e do QaLeadServer), zera aguardando_aprovacao e retoma com TurnoAssincrono.iniciar(state, nil, fn -> run_turn(state, pendente.remaining) end).

Enquanto suspenso, uma segunda user_message não inicia turno novo — um guard em handle_call({:user_message, _text}, _from, %{aguardando_aprovacao: %{}}), testado ANTES da cláusula genérica, emite agent.error (origem politica) explicando que há uma decisão pendente em Aprovações, sem subir task nenhuma.

Lacuna aceita, declarada — restart durante a espera. Ao contrário do dev agent (que reidrata laco_pendente via handle_continue no init/1, ADR 0052), o Dev Lead NÃO reidrata aguardando_aprovacao — é só em memória. Se o engine reiniciar enquanto ele está suspenso, a decisão continua registrada e visível em Aprovações (é durável na api), mas o Dev Lead não narra o desfecho automaticamente: o processo que assinou o Wake morreu, e o próximo restart sobe um Dev Lead novo, sem inscrição para aquela ação. Fechar isto exigiria o mesmo mecanismo do ADR 0052 — fora do escopo desta mudança, que só faz o comportamento bater com o que docs/fluxo.yml já declarava.

  • Onde: apps/engine/lib/engine/agents/dev_lead_tools.ex, dev_lead_server.ex; apps/engine/lib/engine/agents/turno_assincrono.ex (compartilhado pelos quatro conversacionais); apps/engine/lib/engine/sessions/live_broadcast.ex; apps/api/src/domain/actions/decide.ts (propose_execution_plan)
  • Teste: apps/engine/test/engine/agents/dev_lead_tools_test.exs, dev_lead_server_test.exs (describe "suspensão em aprovação"), turno_assincrono_test.exs (describe "resultado com :aguardando_aprovacao"), live_broadcast_test.exs, wake_do_outbox_ao_dev_lead_test.exs (a corrente INTEIRA — outbox → drain → worker → Engine.Dev.Wake → o processo do Dev Lead, mesmo padrão de Engine.Dev.WakeDoOutboxAoAgenteTest), apps/api/test/domain/actions/decide.spec.ts (describe "plano de execução do Dev Lead")
  • ADR: 0086 (revisa parte do ADR 0053; precedente direto: ADR 0052 e ADR 0057)

UX Designer — o quinto agente conversacional (RN-285..287, ADR 0087)​

Não é gatilho de separação disparado (docs/fluxo.yml sempre declarou "quando o projeto GERENCIADO tiver interface própria a desenhar" como critério) — é decisão consciente do dono do produto de antecipar o papel. teste-de- usabilidade (exige usuário humano real) fica fora de alcance; metricas-de- uso segue lacuna, porque depende do papel analytics, que continua proposto.

RN-285 — O UX Designer é conversacional SOLO, sem área​

Engine.Agents.UxDesignerServer (apps/engine/lib/engine/agents/ux_designer_server.ex:40) espelha Engine.Agents.ArquitetoServer/DevLeadServer: GenServer por sessão, laço bounded de tool use próprio com teto 14 (mesmo calibre de Arquiteto/Dev Lead — agente de raciocínio, não conversação leve como Criativo/PO). Ativado por handoff accepted endereçado a "ux-designer" — mecanismo GENÉRICO já existente (ActivateAgentUseCase/canActivateAgent na api não ganharam linha nenhuma: qualquer agente com handoff aceito já é ativável).

O kickoff (build_kickoff/1, ux_designer_server.ex:222) lê a artifact.product_brief mais recente da sessão — a MESMA "necessidade de negócio" que o Criativo produz, sem artefato novo. O sistema de design (design/tokens.css, design/COMPONENTS.md) é DESCRITO na identidade (Engine.Harness.Agents, entrada "ux-designer"), texto estático, porque os agentes conversacionais não têm ferramenta de leitura de arquivo do repo — é a única camada do prompt presente em TODO turno, não só no kickoff.

Sem área, sem subagentes: docs/fluxo.yml já classificava o papel como camada_produto, ao lado de Criativo e PO, nenhum dos quais tem área.

RN-286 — propose_prototype grava artefato sem tabela e sem caso de uso dedicado, e oferece DOIS handoffs sobre o MESMO artefato​

artifact.prototipo_navegavel segue o desenho sem tabela de artifact.project_image/artifact.c4_diagram (RN-149) — o event log é o registro —, mas por um caminho DIFERENTE do que os dois usam. choose_project_image/create_c4_diagram precisam de caso de uso NA API (DecidirImagemDoProjetoUseCase/CreateC4DiagramUseCase) porque têm conteúdo DERIVADO de outro artefato (o Container level vem do module_map vigente) ou recusa de domínio compartilhada por mais de um consumidor (teto de recursos da imagem). propose_prototype (Engine.Agents.UxDesignerTools.run/2, apps/engine/lib/engine/agents/ux_designer_tools.ex:122) não tem nenhum dos dois motivos — é conteúdo AUTOCONTIDO que só o próprio UX Designer escreve e só ele lê de volta —, então a validação de FORMA mora no engine (Engine.Harness.ArtifactSchemas, tipo "prototipo_navegavel", artifact_schemas.ex:49/135 — personas e jornadas não-vazias, ao menos uma tela) e a gravação usa o caminho GENÉRICO que a api já expõe para qualquer tipo de evento (EngineApiClient.append_event_returning/3), o mesmo mecanismo do artifact.product_brief do Criativo. Nenhuma rota nova na api.

Depois de gravar, gravar_e_ofertar_handoffs/2 (ux_designer_tools.ex:149) oferece DOIS handoffs sobre o MESMO artefato — create_handoff para "po" e para "dev-lead", os dois com o artifactId do protótipo. Nunca um segundo artefato para "spec-visual" (docs/fluxo.yml): o PO lê resumo/prototipo para desenhar o backlog, o Dev Lead lê as MESMAS telas/anotacoes como referência visual de implementação — duplicar o conteúdo arriscaria as duas cópias divergirem na revisão seguinte, o mesmo argumento por trás do C4 não redigitar o module_map.

Falha ao ofertar UM dos dois handoffs não desfaz o artefato já gravado nem o outro handoff (RN-116) — o motivo volta como texto do tool-result, entrada do laço e não fim de linha (RN-163): o modelo lê e pode reportar ao usuário na resposta seguinte.

Um propose_prototype BEM-SUCEDIDO encerra o turno (ux_designer_server.ex:120, mesma guarda de propose_execution_plan no Dev Lead) — sem isso o laço voltaria ao modelo, que poderia propor de novo e produzir dois protótipos com o mesmo total. Só existe UMA ferramenta aqui (não há um segundo tool call a encadear como no Arquiteto), então "para no primeiro sucesso" não perde nada.

RN-287 — uxDesignerActive no roster, nas DUAS fontes (RN-090)​

Mesmo critério de infraActive: handoff accepted endereçado a "ux-designer" na sessão. apps/web/src/lib/agent-status.ts (rosterFactsFromEvents/rosterFromFacts) ganhou o fato e o empurra condicionalmente no roster como agente SOLO — sem pushAreaMembers correspondente, porque ele não é área.

A RN-090 exige as DUAS fontes em sincronia (painel do time via event log; o card do dashboard via ProjectCardSummary.roster, computado na api) para que um agente novo não apareça num lugar e falte no outro. O fato entrou nas duas: apps/api/src/infrastructure/persistence/drizzle/projects-summary.repository.ts amplia a MESMA consulta de infraActive (um inArray(handoffs.toAgent, ['infra', 'ux-designer']) no lugar do eq único, partido em dois Set por toAgent) — nenhuma consulta nova, a contagem de doze consultas constantes que projects-summary.repository.spec.ts prova não cresce.

  • Onde: apps/engine/lib/engine/agents/ux_designer_server.ex, ux_designer_tools.ex, ux_designer_supervisor.ex; apps/engine/lib/engine/harness/agents.ex (identidade), artifact_schemas.ex (tipo prototipo_navegavel); apps/engine/lib/engine/application.ex; apps/engine/lib/engine_web/controllers/agent_command_controller.ex; apps/web/src/lib/agents.ts, agent-status.ts; apps/api/src/application/ports/projects-summary-repository.port.ts, apps/api/src/infrastructure/persistence/drizzle/projects-summary.repository.ts
  • Teste: apps/engine/test/engine/agents/ux_designer_server_test.exs, ux_designer_tools_test.exs; apps/web/src/lib/agent-status.test.ts; apps/api/test/infrastructure/persistence/drizzle/projects-summary.repository.spec.ts (describe com os dois toAgent na mesma sessão)
  • ADR: 0087

Staff: código pronto, dormente para disparo automático (RN-305/RN-306, ADR 0088)​

Não é fase planejada: docs/fluxo.yml declara o Staff/Principal Engineer como status: planned desde o ADR 0085 ("contrato pronto, ativação decidida, aguarda gatilho"). O dono do produto decidiu antecipar o CÓDIGO mesmo sabendo que o gatilho automático (a Anamnese notando um problema sistêmico RECORRENTE) não vai disparar — a Anamnese está pausada (ANAMNESE_ENABLED=false, decisão de produto de 2026-08-10). Ver a pendência já documentada em RN-086: o mesmo sinal que faria a Anamnese propor subir o teto de paralelismo é o que faria ela propor um handoff ao Staff, e nenhum dos dois dispara enquanto ela estiver pausada.

RN-305 — O Staff ativa pelo caminho GENÉRICO de handoff, sem USER_STARTED_AGENTS, e sem kickoff/1​

USER_STARTED_AGENTS (apps/api/src/domain/sessions/agent-activation.ts) é a exceção do Criativo (inicia SEM handoff, por comando do usuário) — o Staff NÃO entra nela. Investigação confirmou que canActivateAgent já ativa qualquer agente com handoff accepted endereçado a ele, o mesmo caminho que já vale para dev-lead/arquiteto/infra; e assertHandoffTargetAllowed (apps/api/src/domain/agents/agent-areas.ts) só recusa handoff endereçado a SUBAGENTE de área — o Staff não tem área, então nenhuma mudança de domínio na api foi necessária. "Acionável manualmente" significa que a MECÂNICA de domínio permite (qualquer agente pode chamar EngineApiClient.create_handoff(..., "staff", ...), e um humano aceita pela rota já existente), não que existe hoje uma tela dedicada para escolher "endereçar handoff ao Staff" — a UI genérica de handoff a agente à escolha segue no backlog (docs/explanation/backlog.md), como já estava antes desta mudança.

Engine.Agents.StaffServer (apps/engine/lib/engine/agents/staff_server.ex) é o quinto agente conversacional solo (junto de Criativo, PO, Arquiteto, Dev Lead), espelhando o Arquiteto — GenServer por sessão, rehydration do event log, laço bounded de tool use com teto 14 (staff_server.ex:49) — mas sem kickoff/1: os outros leads sintetizam uma instrução de abertura a partir de um artefato anterior no event log da sessão (product_brief, module_map, backlog); o Staff não tem essa fonte, porque o problema sistêmico nasce de fora da sessão. StaffSupervisor.start_agent/2 sobe o processo (rehidrata o histórico) e ele fica ocioso até a primeira user_message (staff_server.ex:60) — que é como quem endereçou o handoff explica o problema. apps/engine/lib/engine_web/controllers/agent_command_controller.ex:60 (cláusula start/2 de "staff") nunca chama kickoff (a função nem existe), ao contrário de po/arquiteto/dev-lead/infra.

  • Onde: apps/api/src/domain/sessions/agent-activation.ts (canActivateAgent, USER_STARTED_AGENTS — intocado), apps/api/src/domain/agents/agent-areas.ts (assertHandoffTargetAllowed — intocado), apps/engine/lib/engine/agents/staff_server.ex, staff_supervisor.ex, apps/engine/lib/engine_web/controllers/agent_command_controller.ex (cláusulas start/2/message/2/via_for/2 de "staff"), apps/engine/lib/engine/application.ex (Engine.Agents.StaffSupervisor na árvore de supervisão)
  • Teste: apps/engine/test/engine/agents/staff_server_test.exs (rehydration, ausência de kickoff automático — o turno só roda por user_message)
  • ADR: 0088

RN-306 — propose_rfc grava o artefato DIRETO e devolve o handoff no MESMO tool call, sem proposed_action​

Engine.Agents.StaffTools.propose_rfc (apps/engine/lib/engine/agents/staff_tools.ex:41) é a única ferramenta do Staff: problema, opções com trade-offs, recomendação e o escopo de uma PoC DESCARTÁVEL (descartavel: true é FIXO — nunca escrito pelo modelo). run/2 (staff_tools.ex:89) grava artifact.rfc_staff via EngineApiClient.append_event_returning/3 — mesmo padrão SEM tabela e SEM caso de uso dedicado de Engine.Harness.Tools.EmitInsight (o emit_insight do Arquiteto), e não o de artifact.c4_diagram (ADR 0068), que tem caso de uso próprio na api porque DERIVA o nível Container do module_map vigente — o RFC não deriva nada do lado de lá, todo o payload vem do tool call.

Depois de gravar, o MESMO run/2 chama EngineApiClient.create_handoff(..., "staff", "arquiteto", artifact_id) — sem confirmação humana no meio, mesmo padrão de CriativoServer.executar_confirm_readiness/1 emitindo o product_brief e oferecendo o handoff ao PO na mesma resposta. propose_rfc NÃO é proposed_action: registrar um documento de arquitetura não é efeito externo (não é git, terminal, nem gasto de agente) — a decisão real (adotar, adaptar, recusar a recomendação) é do Arquiteto, no handoff que a ferramenta já devolve. Falha ao criar o handoff NÃO derruba o processo nem perde o RFC já gravado (RN-116) — o motivo entra no tool-result, e o modelo sabe que precisa tentar de novo só o handoff, não reescrever o RFC.

  • Onde: apps/engine/lib/engine/agents/staff_tools.ex
  • Teste: apps/engine/test/engine/agents/staff_server_test.exs ("propõe o RFC, grava o artefato e devolve o handoff ao arquiteto"; "propose_rfc com opções vazias vira tool-result de erro"; "falha ao devolver o handoff ao arquiteto NÃO derruba o processo")
  • ADR: 0088

Declarado, não escondido: a roster do painel do time (apps/web/src/lib/agent-status.ts, staffActive) e o card do dashboard (ProjectCardSummary.roster, apps/api/.../projects-summary.repository.ts) mostram o Staff quando ativo, mesmo critério de infraActive — evitando a divergência que o comentário de RosterFacts já alertava. SessionPage.tsx NÃO foi tocado: staff fica fora de AGENTES_DE_CHAT, o mesmo padrão já aceito para infra (um lead REAL e ATIVO também fora dessa lista — o handoff para ele "nunca é aceito por AQUI", conforme o comentário do próprio arquivo). O caminho ponta a ponta de uso hoje é a rota interna (POST .../agent/message, agent: "staff"), não a tela de Sessão.


O gate implementavel ativa — QA-estratégia como segundo momento do qa-lead (RN-340/341, ADR 0090)​

RN-340 — O gate implementavel decide a story ANTES do dev agent escrever código​

docs/gates.yml declarava o gate implementavel (dono dev-lead) desde a FASE 14d com status: planned — nunca ativado. O ADR 0090 ativa: a ferramenta assess_implementability do Dev Lead propõe o parecer de implementabilidade de uma story (implementavel/inviavel + justificativa) como proposed_action, MESMO padrão de propose_execution_plan (RN-284) — três desfechos ({:ok, texto} | {:pending, action_id} | {:error, texto}), papel mínimo maintainer em decide.ts, e DELIBERADAMENTE fora do bloco de tetos absolutos (é decisão inicial, não ultrapassagem de teto).

O parecer depende do plano de teste, que é um PRÉ-REQUISITO, não um argumento. run_assessment/2 lê o artifact.plano_de_teste mais recente da story no HISTÓRICO da própria sessão do Dev Lead (emitido pela QA-estratégia, RN-341):

  1. Sem plano ainda — dispara Engine.Gates.Dispatcher.run_qa_estrategia/3 (mesma indireção trocável em teste que run_qa/2/run_secops/2 já usam) e devolve {:error, texto} pedindo para tentar de novo em instantes. Erro de ferramenta é ENTRADA do laço, não fim de linha (RN-163): o Dev Lead tem teto de 14 iterações para tentar de novo. A janela de espera é aceita e declarada — a QA-estratégia roda em processo separado (qa-lead), e um run/2 síncrono não pode bloquear esperando o resultado sem acoplar os dois processos.
  2. Com plano — monta o parecer com o plano de teste EMBUTIDO no payload (síntese e critérios executáveis), para o usuário decidir sem precisar abrir dois eventos, e propõe a ação.

Desde a RN-674 (ADR 0192) os dois passos acima NÃO valem mais. O plano de teste nasce DEPOIS da entrega do dev e alimenta o qa-verificada; run_assessment/2 não lê nem dispara plano, propõe o parecer na PRIMEIRA chamada, sobre a história e o module_map que o Dev Lead já tem no contexto, e o payload perdeu planoDeTeste/criteriosExecutaveis. Dispatcher.run_qa_estrategia/3 saiu. O resto desta regra (proposta, três desfechos, papel mínimo, fora dos tetos absolutos) segue igual.

  • Onde: apps/engine/lib/engine/agents/dev_lead_tools.ex (run_assessment/2, spec_assess_implementability/0), dev_lead_server.ex (run_tool/3); apps/api/src/domain/actions/decide.ts (assess_implementability); docs/gates.yml (implementavel, status: active)
  • Teste: apps/engine/test/engine/agents/dev_lead_tools_test.exs (describe "assess_implementability"), dev_lead_server_test.exs (describe "suspensão em aprovação" — os dois testes novos), apps/api/test/domain/actions/decide.spec.ts (describe "parecer de implementabilidade do Dev Lead")
  • Origem: ADR 0090

RN-341 — A QA-estratégia é o segundo MOMENTO do qa-lead, e nunca suspende​

docs/fluxo.yml declarava o papel qa-estrategia como proposto, com o critério de separação escrito no próprio registro: "pode ser o próprio qa-lead em segundo MOMENTO, não necessariamente agente novo — a separação é de entregável". O ADR 0090 constrói exatamente isso: Engine.Gates.QaEstrategiaAgent é módulo SEM ESTADO (não é GenServer), acionado por Engine.Gates.QaLeadServer.run_design/3 — um ponto de entrada NOVO e ADITIVO no MESMO processo qa-lead, sem tocar run/2 (o caminho de sempre, revisão de PR, amarrado a DevAgentState.find_by_task_id).

O contexto é LEVE, e é aí que a separação de entregável aparece. Engine.Gates.QaEstrategiaContext.fetch/3 busca SÓ story (de EngineApiClient.list_backlog/1, a árvore que o PO já lê — RN-164) e module_map vigente (de EngineApiClient.get_infra_context/2, o MESMO GetInfraContextUseCase que o Infra Lead consome, aqui só pelo campo moduleMap) — SEM dev_state, SEM worktree_path: o gate implementavel roda PRE-DEV, antes de existir dev agent, worktree ou task_id. Nenhuma rota nova na api — as duas funções já existiam.

Nunca suspende. O registro de ferramentas (ReadFile, SearchWorkspace, EmitPlanoDeTeste) não inclui terminal nem write_file — as DUAS únicas tools que Engine.Harness.Hooks.ActionPipeline intercepta para criar proposed_action. Sem chamada nenhuma passando pelo pipeline de ações, o ToolLoop deste agente nunca produz :pending, e run_design/3 roda SÍNCRONO dentro do próprio handle_cast — sem mecanismo de suspensão/retomada, ao contrário do resto da área de QA.

O teto de iterações fica em 8 (conversacional), não 60 (gate) — de propósito, não lacuna. Este agente roda SEM token_budget_micros — não há task nem budget de task ainda. O critério da RN-085 não é "quem trabalha muito": é "o que segura o gasto além do teto de iterações". Sem budget por baixo, subir o teto multiplicaria o pior caso sem nada para conter — a MESMA razão pela qual infra-workflows fica em 8 mesmo usando ferramenta. "qa-estrategia" NÃO ganhou cláusula própria em Engine.Harness.Iteracoes.tipo/1: cair no default é a decisão certa.

O entregável — emit_plano_de_teste (síntese, critérios executáveis verificáveis, estratégia de automação GENÉRICA e sem framework — decisão de escopo desta frente, na spec/0 que o modelo lê, não uma validação em código) — vira o artefato artifact.plano_de_teste (ArtifactEmitter.emit/5, schema validado, criteriosExecutaveis não pode ser vazio), no event log da MESMA sessão que chamou run_design/3 — é lá que assess_implementability (RN-340) o lê depois. Falha (limite de iterações, orçamento, modelo que para sem emitir) NUNCA é silenciosa: agent.error durável com origem, mesma régua da RN-059.

Desde a RN-674 (ADR 0192) o MOMENTO mudou. O segundo momento continua sendo do qa-lead, sem agente novo, e continua nunca suspendendo; mas roda DEPOIS da entrega, no começo do ciclo de run/2, com o worktree do dev e os arquivos do diff — run_design/3 e QaEstrategiaContext saíram, e o artefato ganhou taskId. O teto continua 8, agora COM o orçamento da task por baixo.

  • Onde: apps/engine/lib/engine/gates/qa_estrategia_agent.ex, qa_lead_server.ex (run_area/3, desde a RN-674), tools/emit_plano_de_teste.ex, hooks/termination_plano_de_teste.ex; apps/engine/lib/engine/harness/artifact_schemas.ex (plano_de_teste); apps/engine/lib/engine/harness/iteracoes.ex (SEM cláusula nova, ver acima); docs/fluxo.yml (qa-estrategia, status: active)
  • Teste: apps/engine/test/engine/gates/qa_estrategia_agent_test.exs, qa_lead_server_test.exs (describe "plano de teste da entrega (ADR 0192)"), apps/engine/test/engine/harness/artifact_schemas_test.exs (describe "plano_de_teste"), iteracoes_test.exs ("qa-estrategia é conversacional DE PROPÓSITO")
  • Origem: ADR 0090

O appsec ganha o segundo momento do secops (RN-360/361, ADR 0090)​

docs/fluxo.yml já declarava o id: appsec como proposto, com o critério de separação escrito por antecipação: "mesmo padrão do QA: dois MOMENTOS, não dois agentes por ora". Esta mudança constrói esse segundo momento — threat model de DESIGN, ANTES de existir código/PR — decisão consciente do dono do produto de antecipar a ativação, sem esperar o gate implementavel que o próprio registro citava como gatilho.

RN-360 — O threat model de DESIGN roda sobre story + module_map, sem worktree/task_id, no MESMO processo do secops​

Quem roda o appsec é o MESMO Engine.Gates.SecOpsAgentServer que já existe para o veredito determinístico de PR — não um processo novo. run_design/2 (apps/engine/lib/engine/gates/secops_agent_server.ex:76) é um GenServer.cast para a mesma chave de Registry ({project_id, "secops"}) que run/2 já usa; o handle_cast({:run_design, story_id}, state) (secops_agent_server.ex:93) busca o contexto por Engine.Gates.AppSecContextBuilder.fetch/2 (apps/engine/lib/engine/gates/appsec_context_builder.ex:31) — a story no backlog do projeto (EngineApiClient.list_backlog/1, a MESMA leitura que a RN-164 deu ao PO) e o module_map vigente da sessão que criou a story (EngineApiClient.get_infra_context/2, a MESMA leitura sem task/story que a área de Infra já faz) — e então chama Engine.Gates.AppSecAgent.run/3 (apps/engine/lib/engine/gates/appsec_agent.ex:47).

AppSecAgent é módulo SEM ESTADO (não é GenServer, mesma forma de QaPerformanceSegurancaAgent), com registro de ferramentas SEM Terminal — [ReadFile, SearchWorkspace, EmitThreatModel] — rodando um checklist STRIDE-lite (Spoofing/Tampering/Repudiation/Information disclosure/Denial of service/Elevation of privilege) via Engine.Harness.ToolLoop.run/1. A diferença estrutural do "segundo momento sem Terminal, sem task_id" para o padrão de gate anterior: nenhum dev_state/worktree_path entra no ctx — ReadFile/SearchWorkspace degradam para o fallback de Engine.Actions.Workspace.workspace_dir/1 (o checkout COMPARTILHADO do projeto, não um worktree de agente), e session_id vem de story["sessionId"], nunca de um dev_state. EmitThreatModel (apps/engine/lib/engine/gates/tools/emit_threat_model.ex) não tem veredito approved/changes_requested — o appsec sempre TERMINA registrando o threat model, nunca aprovando/reprovando nada —, e por isso não reaproveita Engine.Gates.Hooks.Termination (a forma extraída é outra): tem hook PRÓPRIO, Engine.Gates.Hooks.AppSecTermination.

Terminado com sucesso, run_appsec_design/3 (secops_agent_server.ex:234) emite artifact.threat_model (storyId/threatModel/requisitosDeSeguranca/riscos, schema em apps/engine/lib/engine/harness/artifact_schemas.ex:71 — riscos fica de fora das chaves obrigatórias porque lista vazia é resposta válida). Falha (teto de iterações, orçamento, ou o modelo parando sem chamar emit_threat_model) vira agent.error durável com origem (modelo/politica/infra), mesma régua da RN-059 — nunca resposta vazia nem silêncio só em broadcast.

O gatilho automático existe desde a RN-539. Esta entrega foi mantida autocontida de propósito (nenhum arquivo de outra frente tocado: decide.ts, docs/gates.yml e dev_lead_tools.ex intocados), e declarava a lacuna: run_design/2 era ACIONÁVEL e nada acionava sozinho. O ponto de disparo que ela nomeava — assess_implementability do Dev Lead, frente qa-estrategia, gate implementavel, mesmo ADR 0090 — passou a ser o chamador de verdade, em paralelo e uma vez só por story.

  • Onde: apps/engine/lib/engine/gates/secops_agent_server.ex (run_design/2, handle_cast/2, run_appsec_design/3, emit_threat_model/3, emit_bloqueio_appsec/3); apps/engine/lib/engine/gates/appsec_agent.ex; apps/engine/lib/engine/gates/appsec_context_builder.ex; apps/engine/lib/engine/gates/tools/emit_threat_model.ex; apps/engine/lib/engine/gates/hooks/appsec_termination.ex; apps/engine/lib/engine/harness/artifact_schemas.ex (schema threat_model)
  • Teste: apps/engine/test/engine/gates/appsec_agent_test.exs, apps/engine/test/engine/gates/appsec_context_builder_test.exs, apps/engine/test/engine/gates/secops_agent_server_test.exs (os três testes de run_design)
  • Origem: ADR 0090

RN-361 — O threat model concluído cria TRÊS handoffs, sempre endereçando o LEAD​

criar_handoffs_appsec/3 (secops_agent_server.ex:326) cria um handoff por alvo declarado em docs/fluxo.yml (saidas do appsec): arquiteto, dev-lead e infra — mesmo padrão de OfferInfraHandoffUseCase/ArquitetoServer.executar_offer_infra_handoff/1 (chamadas SEPARADAS, uma por alvo, para uma falha de handoff não desfazer os outros dois já criados). O id do fluxo é area-infra, mas o AGENTE endereçável é "infra" (apps/api/src/domain/agents/agent-areas.ts — lead: 'infra'), nunca area-infra: handoff externo endereça só o LEAD de área (ADR 0038), e CreateHandoffUseCase.assertHandoffTargetAllowed recusaria um toAgent que não resolve a um lead/agente-sem-área. Falha de UM alvo vira agent.error narrado por alvo (RN-116) — os outros dois handoffs já criados não são desfeitos.

Desde a RN-636 os três são PERGUNTADOS, não mais sempre criados: o destino que já tem oferta pendente ou já está ativo no projeto não recebe outra, e isso não é falha.

  • Onde: apps/engine/lib/engine/gates/secops_agent_server.ex:55 (@appsec_handoff_targets), :377 (criar_handoffs_appsec/3)
  • Teste: apps/engine/test/engine/gates/secops_agent_server_test.exs ("run_design: threat model concluído emite artifact.threat_model e cria os TRÊS handoffs")
  • Origem: ADR 0090

Analytics e delivery-metricas: os dois papéis viram RELATÓRIO (RN-320..322, ADR 0089)​

Decisão consciente do dono do produto de ANTECIPAR a construção dos papéis analytics e delivery-metricas (docs/fluxo.yml, status: proposto) sem esperar o gatilho orgânico que cada um já declarava. Os dois viram um SCRIPT só — apps/api/scripts/analise-funil.ts — no mesmo formato de medir-execucao.ts (Fase 13b): NestFactory.createApplicationContext, --projeto <uuid> obrigatório, leitura pura via Drizzle, sem escrita nenhuma. A forma é a que o próprio fluxo já prescrevia: analytics é "absorvido por medicao" e delivery-metricas "nunca vira agente" — nenhum GenServer, nenhum agente de LLM.

RN-320 — analise-funil.ts é script, nunca agente — mesmo esqueleto de medir-execucao.ts​

O comando é pnpm --filter api analise:funil -- --projeto <uuid> [--json]. Só lê proposed_actions (filtrado por actionType IN (git_commit, pr_open, git_merge)) e projects, nunca escreve. A guarda if (process.argv[1]?.endsWith('analise-funil.ts')) void main(); no fim do arquivo é a mesma de medir-execucao.ts: sem ela, importar o módulo no teste subiria o Nest inteiro e derrubaria o processo no process.exit do parser de argumentos.

  • Onde: apps/api/scripts/analise-funil.ts:64-73 (lerOpcoes), :256-319 (main), :390 (guarda de execução)
  • Teste: apps/api/test/scripts/analise-funil.spec.ts (só as funções puras — a parte que fala com o banco é exercitada por execução real, mesmo padrão de medir-execucao.spec.ts)
  • ADR: 0089

RN-321 — O funil conta SESSÃO por etapa, só ação executed, e o lead time usa updated_at da execução​

calcularFunil conta quantas sessões produziram pelo menos um git_commit, pr_open e git_merge com status: 'executed' — uma sessão com três commits entra uma vez só em cada etapa que alcançou, nunca três. A taxa de conversão de uma etapa é sessões-da-etapa / sessões-da-etapa-anterior, e é null (não 0, não Infinity) quando o denominador é zero — não há "conversão de" nada para medir. calcularLeadTimes usa updated_at da linha de proposed_actions, não created_at: é o instante em que ExecuteGitActionUseCase#record gravou o execution_result de verdade (updateExecutionResult bumba updatedAt), não quando a ação foi PROPOSTA — a mesma distinção que token_usage/preço congelado já fazem em outro contexto (RN-042). Merge cujo updated_at precede o do commit (duas levas na mesma sessão, ou dado incoerente) é descartado, nunca vira lead time negativo.

  • Onde: apps/api/scripts/analise-funil.ts:115-160 (calcularFunil), :170-193 (calcularLeadTimes)
  • Teste: apps/api/test/scripts/analise-funil.spec.ts (describe "calcularFunil", "calcularLeadTimes")
  • ADR: 0089

RN-322 — Deployment frequency real filtra merge em branch PROTEGIDA; três métricas ficam DECLARADAS ausentes, nunca aproximadas​

deploymentFrequencyPorDia só conta git_merge executed cujo executionResult.targetBranch está em PROTECTED_BRANCHES (apps/api/src/domain/actions/protected-branches.ts) — merge numa branch de feature não é deploy. Cruza por REFERÊNCIA com o gate backmerge (docs/gates.yml): a evidência dele é CI, em .release/gate.json, fora do alcance de um script que só lê o banco, então não há junção de dado, só o mesmo recorte de branch que o gate observa.

Três métricas saem do relatório com uma seção "Não medido, de propósito" em vez de um número aproximado:

  1. Funil de produto completo (ideação → commit). sessions não tem storyId — RN-230 já declara a lacuna na aba Criativo. Fechá-la exige schema novo, fora do escopo desta frente (nenhuma migration).
  2. Evidência de adoção por feature. Não é dado que falta coletar: o Brabo não instrumenta os projetos que ele CONSTRÓI, e não há caminho nenhum para essa telemetria existir hoje.
  3. MTTR e change failure rate. Exigem sinal de INCIDENTE de produção real — a mesma dependência que docs/fluxo.yml já registra para secops-runtime/platform (status: proposto/planned, ativação sincronizada com DEPLOY_ENABLED).
  • Onde: apps/api/scripts/analise-funil.ts:220-247 (deploymentFrequencyPorDia), :372-386 (seção "Não medido" impressa)
  • Teste: apps/api/test/scripts/analise-funil.spec.ts (describe "deploymentFrequencyPorDia")
  • ADR: 0089

secops-runtime — relatório de abuso sobre rate_limit_hits (RN-375..377, ADR 0091)​

Decisão consciente do dono do produto de antecipar o papel secops-runtime (docs/fluxo.yml, camada_seguranca), que nasceu proposto com o critério de separação "produção com tráfego real (pós DEPLOY_ENABLED + platform ativo)". Esse gatilho não disparou — não há tráfego de produção. O que existe hoje é rate_limit_hits (RateLimitGuard, ADR 0027): uma linha por request contado, gravada mesmo sob tráfego de dev/CI. secops-runtime entra como SCRIPT (pnpm --filter api relatorio:seguranca-runtime), não agente LLM nem GenServer — não há decisão a tomar, só dado a agregar.

RN-375 — O relatório declara a janela, nunca finge um histórico maior​

DomainGaugesCollector.pruneRateLimit (apps/api/src/infrastructure/observability/domain-gauges.collector.ts:177-186) apaga hits mais velhos que 2 × RATE_LIMIT_WINDOW_MS (240s com o default de 60s), a cada METRICS_GAUGE_INTERVAL_MS (15s por padrão) — a tabela nunca guarda mais que uns poucos minutos. relatorio-seguranca-runtime.ts imprime DUAS janelas, nunca uma só: a CONFIGURADA (o teto teórico da poda) e a OBSERVADA (o que os dados efetivamente cobrem, do primeiro ao último occurred_at lido). Quando as duas coincidem, o relatório diz explicitamente que é sinal de poda — hits mais antigos podem ter existido e já foram apagados — nunca "não houve mais hits que isso".

RN-376 — O ranking trabalha só com o que bucket_key guarda: balde e quando​

RateLimitGuard.registrarEContar (apps/api/src/interfaces/http/shared/rate-limit.guard.ts:150-170) grava só bucket_key (user:<uuid> ou ip:<endereço>) e occurred_at — NÃO há rota, método HTTP nem motivo do bloqueio. rankingDeBaldes e interpretarBalde (apps/api/scripts/relatorio-seguranca-runtime.ts) classificam por esses dois campos e só eles; um bucket_key fora do formato user:/ip: cai em desconhecido em vez de estourar, para o script não quebrar se um RateLimitGuard futuro gravar outro formato. Pedir "ranking de IP por rota" seria inventar dimensão que a tabela nunca guardou.

RN-377 — A seção "não medido" é permanente, e cita as três lacunas por nome​

montarRelatorio sempre inclui naoMedido: detecção automática de incidente, resposta a incidente e postmortem de segurança. As três dependem do mesmo gatilho que docs/fluxo.yml já declarava para secops-runtime — tráfego de produção real — e nenhuma delas é simulada com incidente de exemplo nem número inventado (mesmo princípio dos ADRs 0041/0042/0077: sem o dado real, a lacuna fica visível em vez de fingida). A seção só sai da lista quando o produto tiver tráfego real para medir contra ela — não é um TODO a apagar na próxima limpeza.

  • Onde: apps/api/scripts/relatorio-seguranca-runtime.ts, apps/api/package.json (relatorio:seguranca-runtime)
  • Teste: apps/api/test/scripts/relatorio-seguranca-runtime.spec.ts
  • ADR: 0091

platform ganha uma primeira entrega: relatório de telemetria sob demanda (RN-385/386, ADR 0092)​

RN-385 — O papel platform nasce como SCRIPT de leitura pontual, sobre as mesmas fontes do DomainGaugesCollector​

docs/fluxo.yml descreve platform (camada_plataforma) como "SRE / Platform — dono do loop de retorno", status: planned, com ativação sincronizada a DEPLOY_ENABLED — que não existe. O dono do produto decidiu antecipar a metade que já tem dado real por trás: pnpm --filter api relatorio:telemetria [--projeto <uuid>] [--json] lê, sob demanda, as MESMAS perguntas que o DomainGaugesCollector já responde a cada METRICS_GAUGE_INTERVAL_MS para o scrape do Prometheus — sessões ativas/closing por projeto, tasks bloqueadas por projeto, estado do último backup (idade, status, tamanho, sempre GLOBAL, nunca por projeto, porque o produto tem um backup só). Não é agente LLM nem GenServer: é leitura avulsa, sem estado, que termina depois de imprimir.

As consultas SQL são REPLICADAS, não importadas do coletor — os métodos dele (collectSessions/collectBlockedTasks/collectBackup) são privados e terminam escrevendo num gauge Prometheus (this.metrics.*.set(...)); não há uma metade pura de "só a query" para reusar sem acoplar um script avulso ao ciclo de vida de um @Injectable do NestJS. O script também NÃO é um segundo coletor: não registra métrica nenhuma, não roda em setInterval.

A saída sempre traz "onde ver mais" (os três dashboards versionados em deploy/k8s/observability/dashboards/*.json, os alertas em deploy/k8s/observability/alerts/brabo-alerts.yaml, docs/runbook.md #observabilidade e pnpm dev:obs para observabilidade local) — o script LINKA para o que já existe, nunca duplica.

  • Onde: apps/api/scripts/relatorio-telemetria.ts; espelha apps/api/src/infrastructure/observability/domain-gauges.collector.ts
  • Teste: apps/api/test/scripts/relatorio-telemetria.spec.ts (funções puras — parseArgs, formatarIdade, formatarBytes — mesmo recorte de medir-execucao.spec.ts: a parte que fala com o banco é exercitada rodando o script contra um banco real)
  • ADR: 0092

RN-386 — O relatório de telemetria declara, sem inventar, o que NÃO mede​

O gatilho real do papel platform (DEPLOY_ENABLED) não existe: não há ambiente de produção com tráfego real, não há SLO numérico definido em lugar nenhum do produto, e não há postmortem possível sem incidente de verdade. relatorio-telemetria.ts diz isso na PRÓPRIA saída, numa seção "não medido", em vez de fingir cobertura que não tem — a mesma disciplina do ADR 0042 para nota de modelo e do ADR 0077 para qualidade de código: sem o dado real, o produto DECLARA a lacuna, nunca inventa o número.

Três lacunas, todas explícitas: SLO numérico formal (nenhum está definido); postmortem (depende de incidente real que não aconteceu); e telemetria de volta ao produto em loop fechado (o script é leitura pontual sob demanda — observar, decidir e agir sozinho é o que tornaria platform active em docs/fluxo.yml, e por isso o gate_saida: { id: operavel, status: planned } do papel permanece planned, intocado por esta mudança).

  • Onde: apps/api/scripts/relatorio-telemetria.ts (função imprimir, seção "Não medido"); docs/fluxo.yml (camada_plataforma › platform › saidas_alvo, artefato telemetria-consolidada, campo nota)
  • Teste: cobertura indireta — a seção é texto fixo verificado por leitura do arquivo; não há verdade condicional a testar aqui (nada a errar entre "medido" e "não medido" além do texto em si)
  • ADR: 0092

O papel dbre vira dois scripts mecânicos (RN-400..403, ADR 0093)​

RN-400 — O parecer de migração é análise ESTÁTICA de SQL, e ignora o padrão de risco quando ele só aparece em comentário​

lintarConteudo (o núcleo puro de lint-migracao.ts) varre cada .sql de apps/api/src/db/migrations/ linha a linha, sem depender de --projeto nem de banco — o risco que ele acha é de SCHEMA, não de carga, e por isso não depende de volume real de dados nenhum (o critério de separação que docs/fluxo.yml já declarava para o papel). Cinco padrões, cada um com a razão anexada ao achado: DROP TABLE/TRUNCATE (perda total, irreversível), DROP COLUMN (perda da coluna, irreversível), ALTER COLUMN ... TYPE/SET DATA TYPE (pode reescrever a tabela inteira) e ADD COLUMN ... NOT NULL sem DEFAULT (falha contra tabela não-vazia — o padrão que 0042_tough_captain_midlands.sql evitou conscientemente, com nullable-primeiro-depois-backfill-depois-SET NOT NULL).

Linha que começa com -- (comentário) é ignorada de propósito: este repositório explica em prosa, no próprio SQL, por que um padrão foi EVITADO — analisar o texto do comentário acharia o padrão exatamente na frase que descreve por que ele não foi usado.

  • Onde: apps/api/scripts/lint-migracao.ts (REGRAS, linha 75; lintarConteudo, linha 126)
  • Teste: apps/api/test/scripts/lint-migracao.spec.ts — cada padrão individualmente, combinação de vários na mesma migration, e "ignora o padrão de risco quando ele aparece só em COMENTÁRIO"
  • ADR: 0093

RN-401 — O linter não é gate de CI: varre o repositório inteiro, não o diff da PR​

Rodar pnpm --filter api lint:migracao contra as migrations reais do repositório ACHA três ocorrências em migrations já mergeadas e aceitas (0006_whole_princess_powerful.sql:22 e 0034_quick_saracen.sql:33 — DROP COLUMN; 0007_groovy_bullseye.sql:2 — ALTER COLUMN ... SET DATA TYPE). Isso não é defeito a corrigir de passagem (mesma regra do CLAUDE.md que protege os achados Z/AD/AE): são migrations já aceitas, e apagá-las apagaria a evidência de por que a decisão foi tomada. É também por isso que o script sai != 0 mas não está wireado em .github/workflows/ci.yml — um gate que reprova o repositório inteiro reprovaria toda PR para sempre, por um achado que não é dela. Virar gate de bloqueio de verdade exige escopar ao DIFF contra a base do PR (a mesma técnica de scripts/ci/pr-police.ts), deixado para quando dbre precisar BLOQUEAR merge — hoje ele é parecer manual, não veredito automático.

  • Onde: apps/api/scripts/lint-migracao.ts (principal, exit code no fim do arquivo); decisão documentada no cabeçalho do arquivo e no ADR
  • Teste: não aplicável a CI (não há step); a execução manual contra o repositório real está registrada no ADR 0093, seção Consequências
  • ADR: 0093

RN-402 — O relatório de backup relê exatamente a lógica de collectBackup(), nunca uma segunda forma de calcular​

relatorio-backup.ts não é um gauge Prometheus novo — é a MESMA leitura de backup_runs que DomainGaugesCollector.collectBackup() já faz (último SUCESSO, para idade e tamanho; a ÚLTIMA execução, para pegar o caso de estar falhando há dias com um backup bom mais antigo), sob demanda. avaliarBackup é pura — recebe as duas linhas e o instante atual, devolve status (ok|atrasado|nunca_houve|falha_recente_com_sucesso_antigo). O limiar de "atrasado" (26h = BACKUP_AGE_ATRASADO_SEGUNDOS) é o MESMO do alerta brabo-backup-atrasado (deploy/k8s/observability/alerts/brabo-alerts.yaml), duplicado (não importado — o YAML do Grafana não é lido pelo processo Node) e sujeito a divergir se um lado mudar sem o outro.

  • Onde: apps/api/scripts/relatorio-backup.ts (avaliarBackup, linha 82; BACKUP_AGE_ATRASADO_SEGUNDOS, linha 54)
  • Teste: apps/api/test/scripts/relatorio-backup.spec.ts — nunca houve backup, backup ok, atrasado, exatamente no limiar (não conta como atrasado), falha recente com sucesso antigo, atrasado tem prioridade sobre falha recente, idade nunca negativa
  • ADR: 0093

RN-403 — O relatório de backup NÃO reexecuta o restore; ele aponta para o procedimento já testado​

O procedimento de restaurar de verdade (make test-restore / deploy/k8s/test-restore.sh) já foi executado e está documentado em docs/runbook.md#restore, com RTO real (~40s contra um banco de ~108 KB) registrado na seção "Última execução verificada" daquele documento. relatorio-backup.ts responde uma pergunta mais estreita e mais frequente — "o backup que esse restore usaria está saudável agora?" — e cita o runbook para quem precisa de fato restaurar, em vez de duplicar o procedimento ou reexecutá-lo.

  • Onde: apps/api/scripts/relatorio-backup.ts (mensagem final de imprimir, citando docs/runbook.md#restore)
  • Teste: não aplicável (é texto estático apontando para o runbook, não lógica); a existência do procedimento testado é RN de docs/runbook.md#restore em si
  • ADR: 0093

Auditoria fluxo.yml × código — Onda 2: RN-160 no backend e delegação Dev Lead → dev (RN-404/405, ADR 0094)​

Fecha os dois últimos achados da auditoria (seção D, docs/explanation/auditoria-fluxo-vs-codigo.md): B6 (RN-160 garantida só no cliente) e B1 (a delegação Dev Lead → dev-<modulo> declarada pelo ADR 0053 item 5 e nunca implementada).

RN-404 — "Confirmar arquitetura pronta" (RN-160) é revalidada no BACKEND, não só desabilitada na UI​

RN-160 garantia a regra ("pelo menos 1 história promovida antes do handoff duplo Arquiteto→Dev Lead/Infra") só desabilitando o botão em SessionPage.tsx — uma chamada HTTP direta a POST /agents/arquiteto/handoff-infra, sem passar pela UI, ignorava a regra por completo. OfferInfraHandoffUseCase.execute agora consulta StoryRepository.findByProject(projectId) e recusa com BadRequestException quando NENHUMA história do projeto tem status !== 'draft' — a checagem vem ANTES de gravar architecture.readiness_confirmed e ANTES de qualquer chamada ao engine (offerInfraHandoff; o offerDevHandoff saiu na RN-672): uma recusa não pode deixar rastro de handoff meio-ofertado no event log, que é imutável. StoryRepository foi escolhido (e não ListBacklogUseCase) por ser mais leve — não precisa montar a árvore épico→história→tarefa para responder "existe alguma não-draft?".

  • Onde: apps/api/src/application/use-cases/agents/offer-infra-handoff.use-case.ts (linhas 27-34, a checagem; injeção de StoryRepository no construtor)
  • Teste: apps/api/test/application/use-cases/agents/offer-infra-handoff.use-case.spec.ts — zero história promovida recusa com ZERO chamada ao engine e ZERO evento gravado; com ao menos uma ready/in_progress/done, segue o fluxo normal
  • ADR: 0094

RN-405 — A delegação Dev Lead → dev vira dado em delegations, com parecerArtifactId redefinido para "o que justificou a decisão"​

O ADR 0053 (FASE 14d) já previa a delegação Dev Lead → dev-<modulo> como "o mesmo caminho de QA e Infra" (delegations, area = 'dev'), mas declarou isso fora de escopo. dev_lead_server.ex nunca gravava a tabela — só QA (qa_lead_server.ex) e Infra (infra_lead_server.ex) gravavam, e os dois do lado ENGINE, porque é lá que o subagente produz um PARECER (veredito de rodada única) que justifica parecerArtifactId.

O Dev Lead não tem esse padrão: a ativação de um dev-<modulo> acontece do lado API, em AcceptParallelizationUseCase.execute — chamada tanto pelo caminho direto (RequestParallelizationUseCase, abaixo do teto de sessão da RN-083) quanto pelo aprovado (ExecuteParallelizationUseCase, depois que o usuário aprova a proposed_action tipo parallelize). A gravação entrou DENTRO desse método — cobre os dois caminhos de graça, porque os dois já convergem ali —, com status: 'completed' REDEFINIDO para esta área: significa "a delegação foi EFETIVADA" (o agente subiu), não "o subagente terminou e emitiu parecer" como em QA/Infra (decisão registrada no ADR 0094).

parecerArtifactId aponta para o id do evento artifact.module_map mais recente e vigente do projeto — obtido de SessionEventRepository.listByTypeForProject(projectId, 'artifact.module_map') (método genérico já existente, usado por computeCoverage para artifact.business_rule; nenhuma consulta nova foi escrita, só uma chamada a mais dele — o último item da lista é o mais recente, porque a função ordena por createdAt ASC) — o artefato que justificou a decisão de delegar. area: 'dev', leadAgent: 'dev-lead', subagent é o id exato do agente ativado (extraDevAgentId(module), a MESMA função que constrói o id em todo o resto do use case — nenhum formato novo). Sem artifact.module_map no projeto (não deveria acontecer — é entrada obrigatória do Dev Lead em docs/fluxo.yml), a delegação NÃO é gravada com um id inventado: só loga o estado inesperado, pela mesma lição da RN-059 (nunca falha silenciosa, mas também nunca finge uma justificativa que não existe). E falha de RecordDelegationUseCase.execute (ex.: banco fora do ar) é capturada e logada, nunca propagada — a ativação do dev agent já é sucesso quando a tentativa de gravar a delegação acontece, e não pode ser derrubada por uma gravação auxiliar.

  • Onde: apps/api/src/application/use-cases/execution/accept-parallelization.use-case.ts (recordDevDelegation, chamado ao fim de execute)
  • Teste: apps/api/test/application/use-cases/execution/accept-parallelization.use-case.spec.ts, describe "delegação Dev Lead → dev (área dev, ADR 0094)" — grava com area: 'dev' apontando pro module_map mais recente; sem module_map não grava (e não lança); RecordDelegationUseCase falhando não derruba a ativação
  • ADR: 0094

Auditoria fluxo.yml × código — Onda 6 (última): o gate necessidade-validada (RN-406, ADR 0095)​

Fecha o último achado do plano da auditoria (seção D, docs/explanation/auditoria-fluxo-vs-codigo.md) — B2. As outras cinco ondas já tinham fechado (a 3, 4 e 5 antecipadas fora de ordem pelos ADRs 0089/0090; a 1 e a 2 nos PRs anteriores). docs/fluxo.yml (papel criativo) declarava gate_saida: { id: necessidade-validada, status: proposto } desde o ADR 0085 sem mecanismo nenhum atrás — e modelo-de-time.md já registrava por que: o Criativo (o modelo) decidir sozinho que a necessidade que ele mesmo produziu está validada seria autovalidação, não gate de verdade.

RN-406 — O gate necessidade-validada se fecha com um clique SEPARADO do usuário, nunca com o Criativo se autovalidando​

Revista pelo ADR 0185 (RN-657), por decisão do dono em 30/09. O clique deixou de ser SEPARADO: "Estou pronto — a necessidade está validada" grava readiness.confirmed e necessity.validated no mesmo POST, com o product_brief produzido DEPOIS do clique (productBriefId: null). O botão "Confirmar necessidade validada" saiu da tela. O que desta regra continua valendo: quem valida é sempre uma PESSOA, nunca o Criativo; o gate segue warn. POST .../agents/criativo/validate-necessity continua na api, sem consumidor na tela — é o caminho de uma sessão cujo "Estou pronto" é anterior ao ADR 0185. O texto abaixo é o desenho do ADR 0095, mantido como histórico.

SessionPage.tsx ganha um terceiro botão de confirmação, no MESMO padrão interacional de "Estou pronto para produzir" (RN-142) e "Confirmar arquitetura pronta" (RN-160): "Confirmar necessidade validada" (handleValidateNecessity) chama POST .../agents/criativo/validate-necessity, que grava necessity.validated com payload.productBriefId apontando para o artifact.product_brief mais recente da sessão.

O botão só habilita DEPOIS que confirm_readiness já consolidou o product_brief (hasProductBrief, events.some(e => e.type === 'artifact.product_brief')) — não faz sentido "validar" um resumo executivo que ainda não foi produzido, e é essa a leitura mais consistente com docs/fluxo.yml: necessidade-validada é gate de SAÍDA do Criativo, o momento em que o trabalho dele já entregou um artefato concreto. ValidateNecessityUseCase revalida a mesma pré-condição no BACKEND (SessionEventRepository.listByTypeInSession(sessionId, 'artifact.product_brief'), pega o último por seq) — a UI desabilitada sozinha não bastaria, mesma lição da RN-404.

Diferente de OfferInfraHandoffUseCase (RN-160), esta confirmação NÃO sinaliza o engine: o handoff Criativo→PO já aconteceu dentro do próprio confirm_readiness (CriativoServer.executar_confirm_readiness/1), então não há agente nenhum esperando por este evento — ele é só o registro de que um humano validou o MÉRITO do que já foi entregue. ValidateNecessityUseCase não recebe ApiToEngineClient no construtor.

docs/gates.yml ganha o gate necessidade-validada: status: active, aprovacao_humana: true (é literalmente um clique humano), verificacao: script, mas severidade: warn — nada no produto hoje CONSULTA a passagem deste gate antes de deixar o PO seguir (diferente de story-promovida/plano-de-adocao, que são block porque uma trava real de código os impede de serem pulados). docs/fluxo.yml (papel criativo) passa status: proposto → status: ativo.

  • Onde: apps/api/src/application/use-cases/agents/validate-necessity.use-case.ts; apps/api/src/interfaces/http/agents/agents.controller.ts (validateNecessityHandoff); apps/web/src/routes/SessionPage.tsx (hasProductBrief, necessidadeJaValidada, handleValidateNecessity)
  • Teste: apps/api/test/application/use-cases/agents/validate-necessity.use-case.spec.ts — sem product_brief, recusa ANTES de gravar qualquer evento; com um ou mais, grava referenciando o MAIS RECENTE; apps/web/src/routes/SessionPage.validar-necessidade.test.tsx — botão desabilitado sem product_brief, habilitado com ele, some depois de necessity.validated
  • ADR: 0095

RN-407 — O PO lê o funil de entrega e DORA parcial, terceira leitura escopada ao projeto​

O PO ganhou uma TERCEIRA ferramenta de leitura — listar_metricas_de_produto (:direct, sem parâmetro nenhum) — servida por GET /internal/projects/:projectId/product-metrics, mesmo desenho das duas irmãs da RN-164: escopo fechado no projeto, sem termo de busca, sem paginação.

docs/fluxo.yml (papel po, entrada metricas-de-produto) declarava status: lacuna desde o ADR 0089, que já tinha entregue o DADO — o script pnpm --filter api analise:funil mede o funil real sessão → commit → PR → merge, o lead time real e a deployment frequency real a partir de proposed_actions. O que faltava era só o MECANISMO: o PO nunca tinha como reler esse relatório dentro do turno. Fecha o item B4 da auditoria fluxo.yml × código (docs/explanation/auditoria-fluxo-vs-codigo.md) — a ÚLTIMA pendência da tabela "Backlog do modelo de time" (docs/explanation/backlog.md), que fica vazia depois desta.

O CÁLCULO é o MESMO do script, nunca duplicado: as funções puras (calcularFunil/calcularLeadTimes/leadTimeMedioMs/ deploymentFrequencyPorDia) e a query que monta AcaoGit[] (buscarAcoesGitDoFunil) foram extraídas de apps/api/scripts/analise-funil.ts para apps/api/src/application/services/funil-metrics.ts — um caso de uso em src/ não pode importar de scripts/, e o script passou a REEXPORTAR dali em vez de definir localmente, sem mudar assinatura nem comportamento (o teste de regressão do script continua verde sem ser tocado).

O corpo JSON que a rota devolve não tem campo nenhum para as três ausências permanentes que o script já declarava em texto ("Não medido, de propósito": funil de produto completo ideação → commit, evidência de adoção por feature, MTTR/change failure rate) — o shape do relatório nunca reservou espaço para elas. A ferramenta do PO cita as três pelo NOME no TEXTO que devolve ao modelo, sempre, e não só quando o número dá zero: sem essa seção o PO leria só os números do funil e concluiria por omissão que não há lacuna nenhuma.

  • Onde: apps/api/src/application/services/funil-metrics.ts; apps/api/scripts/analise-funil.ts (reexporta); apps/api/src/application/use-cases/backlog/list-product-metrics.use-case.ts; apps/api/src/interfaces/http/internal/internal-projects.controller.ts; apps/api/src/interfaces/http/internal/dto/product-metrics.response.dto.ts; apps/engine/lib/engine/harness/tools/listar_metricas_de_produto.ex; apps/engine/lib/engine/agents/po_server.ex; apps/engine/lib/engine/sessions/engine_api_client.ex
  • Teste: apps/api/test/application/use-cases/backlog/list-product-metrics.use-case.spec.ts; apps/api/test/scripts/analise-funil.spec.ts (regressão — continua importando de scripts/analise-funil.ts, sem alteração); apps/engine/test/engine/harness/tools/listar_metricas_de_produto_test.exs; apps/engine/test/engine/agents/po_server_test.exs
  • Origem: mesmo padrão da RN-164 (leitura de agente escopada ao projeto, sem efeito externo, sem proposed_action); o dado é do ADR 0089 — esta RN só fecha o mecanismo de leitura que faltava.

RN-408 — MAIL_TRANSPORT=smtp deriva o boot pelo padrão da RN-114, sem o default público​

Fecha o item de backlog "SMTP real no MailSender" (ADR 0096). resolverConfigSmtp() valida SMTP_HOST/SMTP_USER/SMTP_PASSWORD/ SMTP_FROM no MESMO formato da RN-114 — em produção (NODE_ENV === 'production'), cada um derruba o boot se estiver ausente, só com espaços, ou (no caso de SMTP_HOST) igual ao literal de exemplo publicado (comentado) em .env.example. SMTP_FROM tem uma checagem a mais: precisa casar "Nome <email@dominio>" ou só email@dominio. SMTP_PORT inválida (não numérica ou fora de 1–65535) também derruba o boot em produção.

A diferença para a RN-114 original é estrutural, não de rigor: os quatro segredos da RN-114 (AUTH_JWT_SECRET e companhia) TÊM um default de desenvolvimento público, e é a existência desse default que torna "não vazia" uma checagem insuficiente. Aqui não há default nenhum — SMTP_HOST fica em branco se ninguém setar —, então a régua inteira só é aplicada quando NODE_ENV=production E MAIL_TRANSPORT=smtp. Fora de produção, ou com o transporte em log (o default, inclusive em produção), nenhuma das cinco variáveis é exigida: enviar e-mail de verdade é opt-in do operador, e sem esse opt-in explícito o comportamento continua sendo o log-only de sempre — inclusive em produção, para quem já roda o produto hoje não quebrar ao atualizar.

A validação roda dentro do construtor de SmtpMailSender, exercitado pelo useFactory de AuthUseCasesModule na montagem do grafo de providers do Nest (NestFactory.create()) — não por uma chamada eager em main.ts, como os quatro segredos da RN-114 original. A diferença é deliberada: aqueles quatro protegem caminho que QUALQUER requisição pode exercitar a qualquer momento, então falhar antes de subir importa. SmtpMailSender só é instanciado quando o operador optou por smtp, e AuthUseCasesModule é importado incondicionalmente (via AuthHttpModule), então a validação ainda acontece no boot — mesmo desenho que CREDENTIALS_MASTER_KEY já usa (validada no construtor de EnvelopeEncryptionService, exercitado pela mesma montagem de grafo).

  • Onde: apps/api/src/infrastructure/mail/smtp-config.ts (resolverConfigSmtp, resolverModoDeTransporte), apps/api/src/infrastructure/mail/smtp-mail-sender.ts (SmtpMailSender, chama resolverConfigSmtp() no construtor), apps/api/src/application/use-cases/auth/auth-use-cases.module.ts (useFactory do MailSender)
  • Teste: apps/api/test/infrastructure/mail/smtp-config.spec.ts (mesmo padrão de auth-key-material.spec.ts/service-token.spec.ts), apps/api/test/infrastructure/mail/smtp-mail-sender.spec.ts
  • Origem: ADR 0096, estendendo o padrão da RN-114 (que por sua vez estende o ADR 0059)

"N agentes online" no dashboard — status AO VIVO, nunca presença histórica (RN-409, ADR 0097)​

Item do backlog anterior. Investigação prévia (docs/explanation/backlog.md) confirmou que não existia agregado de liveness nenhum — nem por projeto, nem por workspace: o que existia era só presença HISTÓRICA (RosterFacts — "já apareceu na sessão alguma vez") e status ao vivo de VERDADE só no cliente, derivado do event log, e só quando um projeto está ABERTO (deriveAgentRoster). O card do dashboard hoje forçava () => 'ocioso' porque não desenhava status nenhum — este é o hardcode que a RN substitui.

RN-409 — "N online" soma dois mecanismos pela MESMA régua: não ocioso, não travado​

ProjectCardSummary.onlineAgentCount (GET /workspaces/:workspaceId/ projects-summary) é a contagem de agentes ONLINE agora — trabalhando ou com uma pendência esperando decisão —, nunca tamanho de equipe. Chamar de "online" um número que na verdade é "já apareceu alguma vez" seria enganoso (mesmo princípio dos ADR 0041/ 0042/ 0077 contra dado fingido).

Duas FONTES, uma régua:

  • Dev agents — engine.dev_agent_states.status, agregado em lote por project_id (status NOT IN ('idle', 'idle_tripped')). working, awaiting_gate e awaiting_approval contam (RN-047/ADR 0052 — os cinco estados da máquina do dev); idle/idle_tripped não. dev-<modulo> e dev-<modulo>-2 são chaves DISTINTAS (agent_id na tabela), então contam separado sem esforço nenhum — é a própria chave primária que já separa.
  • Agentes conversacionais (criativo/po/arquiteto/dev-lead/ux-designer/ staff/infra) — último evento agent.status de cada actor_id, na sessão MAIS RECENTE do projeto (mesmo escopo de sessão que o resto de RosterFacts usa, RN-090). agent.status só tem três valores possíveis (Engine.Sessions.LiveBroadcast.agent_status/4 recusa qualquer outro): working/awaiting_approval contam, idle não.

QA/SecOps NUNCA contam — não é filtro, é AUSÊNCIA de dado: nenhum dos dois emite agent.status (rodam veredito único por invocação, sem noção de "ocioso" entre chamadas — o status deles vem de pr.gate_changed/ infra.gate_changed, um mecanismo à parte que deriveAgentRoster já trata separado). Não precisou de exclusão explícita: a consulta só soma o que tem o tipo de evento certo.

A régua é a MESMA que deriveAgentRoster já aplicaria se o projeto estivesse aberto ('trabalhando'/'aguardando' no cliente ≅ working/ awaiting_gate/awaiting_approval no backend) — os DOIS caminhos calculam "online" a partir do mesmo eventual estado (event log/tabela de estado persistida), só que por MECANISMOS diferentes (agregação SQL no backend, dobra sobre eventos já buscados no cliente), no mesmo espírito de infraActive/uxDesignerActive/staffActive (RN-090/RN-287: duas fontes, uma régua). Não há função compartilhada entre apps/api e apps/web para isto — os dois já divergem de linguagem/runtime para o resto de RosterFacts também, e introduzir um pacote compartilhado só para uma contagem seria peso maior que o problema.

Consulta nova contra schema que a api não migra. engine.dev_agent_states é tabela do ENGINE (Ecto, schema Postgres "engine"), mesmo banco físico, mesma conexão — não uma segunda fonte de dados, um segundo NAMESPACE no mesmo Postgres. O precedente já existia: apps/api/scripts/medir-execucao.ts já lê engine.oban_peers pelo mesmo caminho (raw SQL via Drizzle), só que como SCRIPT manual, nunca testado. Esta RN eleva o padrão para código de PRODUÇÃO testado — ver o ADR 0097 para a decisão de NÃO expor isto como rota HTTP interna no engine.

Custo medido, não fingido grátis. As duas consultas novas levam o total do read model de DOZE para CATORZE (RN-090 continua valendo — catorze é CONSTANTE, provado por projects-summary.repository.spec.ts contando idas ao banco com 2 e com 20 projetos). Nenhuma tem WHERE por tempo — a de agent.status escaneia os mesmos session_events das sessões mais recentes que lastEvents/marcos já escaneiam ao lado dela, então o custo marginal é o de mais um filtro de type sobre um plano de consulta que já ia acontecer, não uma tabela nova sendo varrida do zero. Lacuna DECLARADA: não há execução real recente (dogfooding) com volume de produção para medir ms de verdade contra a régua da Fase 22 (525 mil linhas, EXPLAIN ANALYZE) — as duas consultas novas ficam sem número medido, só o argumento estrutural acima.

  • Onde: apps/api/src/application/ports/projects-summary-repository.port.ts, apps/api/src/infrastructure/persistence/drizzle/projects-summary.repository.ts, apps/api/src/interfaces/http/iam/dto/iam.response.dto.ts, apps/web/src/lib/api-types.ts, apps/web/src/components/ProjectCard.tsx, apps/web/src/routes/Dashboard.tsx
  • Teste: apps/api/test/infrastructure/persistence/drizzle/projects-summary.repository.spec.ts (describe('onlineAgentCount (RN-409)') — dev agent working/idle/ idle_tripped, awaiting_gate/awaiting_approval, duas instâncias do mesmo agente-base, agente conversacional working/idle/awaiting_approval, só o ÚLTIMO agent.status conta, QA/SecOps nunca contam, soma entre as duas fontes, isolamento entre projetos); apps/api/test/support/global-setup.ts (ensureEngineFixture — a tabela do engine criada como fixture MÍNIMA e declarada, só para o teste acima poder existir); apps/web/src/components/ProjectCard.test.tsx (badge "N online" some com 0/undefined, aparece com onlineAgentCount > 0)
  • Origem: docs/explanation/backlog.md (item herdado da FASE 13c/ colheita do dogfooding); decisão de produto de que o número tinha de ser liveness de verdade, não presença histórica, registrada no prompt que encomendou esta correção

RN-411 — O quarto sinal de trabalho pendente: dev agents falam dev.*, não agent.status​

Achado por USO real, não por teste: numa sessão de execução, cinco dev agents subiram, ficaram idle_tripped (o circuit breaker da RN-047, travados esperando o usuário desbloquear uma task manualmente), e o heartbeat de 30 segundos (Engine.Sessions.SessionServer.handle_info(:heartbeat_timeout, state)) fechou a sessão por baixo enquanto o trabalho — e a espera por decisão humana — continuava.

GetSessionPendingWorkUseCase já tinha um terceiro sinal (RN-064) para o mesmo problema: agente ativado, sem idle posterior, segura a sessão. Mas esse sinal só lê agent.status, o vocabulário dos agentes CONVERSACIONAIS (Criativo/PO/Arquiteto/Dev Lead/UX Designer/Staff/Infra). Engine.Dev.DevAgentServer (via Engine.Dev.AgentIo) nunca emite agent.status — usa vocabulário PRÓPRIO no event log da sessão: dev.started, dev.working, dev.awaiting_gate, dev.awaiting_approval, dev.idle, dev.idle_tripped, dev.blocked, dev.blocked_by_container, dev.error. Uma sessão de execução com dev agent trabalhando OU travado sempre devolvia pending: false pelo terceiro sinal, porque nenhum agent.status existe para ele.

O QUARTO sinal busca o ÚLTIMO evento dev.* de cada actor.id (dev-<modulo>/dev-<modulo>-2, a mesma chave da RN-195) que já apareceu na sessão — igual ao terceiro sinal, mas sobre múltiplos tipos de evento em vez de um só, porque o estado do dev agent não é UM tipo com um payload.status variável, são tipos DE evento distintos por transição. A régua: pending: true quando o último for dev.working, dev.blocked ou dev.idle_tripped — os três significam "tem trabalho rolando ou um humano precisa agir", e travado esperando desbloqueio É trabalho pendente (é literalmente o que o usuário estava fazendo quando a sessão fechou na execução real). Só dev.idle (sem tarefa nenhuma pra pegar, drenado de verdade) não conta.

dev.awaiting_gate e dev.awaiting_approval, como último evento, NÃO disparavam este sinal originalmente — ficaram de fora da régua tal como decidida à época. awaiting_gate era uma lacuna residual CONHECIDA; a RN-412 a fechou, junto com awaiting_approval (por um argumento novo, não o original — ver RN-412).

dev.blocked_by_container entrou DEPOIS, pelo mesmo argumento e por um achado igualmente real. O tipo nasceu com a RN-502/ADR 0143 — o dev agent não reivindica task antes de o projeto ter container running registrado — e a lista DEV_EVENT_TYPES do caso de uso não acompanhou. Como é a lista que o caso de uso CONSULTA, o evento ficou invisível: a api não o via nem para ignorá-lo. Na execução real do exp004 (sessão f782257e) cinco dev agents emitiram dev.started e dev.blocked_by_container às 22:41:38, o engine fechou por heartbeat_timeout às 22:42:07 — 30 segundos cravados —, e eventos daquela sessão continuaram chegando 13 e 32 minutos depois. O último evento VISÍVEL por agente era dev.started, que está deliberadamente fora da régua. Os cinco esperavam um HUMANO subir o container, que é a definição de trabalho pendente que os outros sinais já usam.

A afirmação de que a lista era "o vocabulário completo, confirmado por leitura direta do código do engine" era verdadeira quando foi escrita — e foi ela que envelheceu calada. O que a mantém verdadeira agora é MECANISMO: scripts/ci/vocabulario-de-eventos-dev.spec.ts extrai os tipos dev.* de apps/engine/lib/**/*.ex e reprova quando divergirem das duas listas do caso de uso, nomeando o tipo que falta. Ele COMPARA em vez de GERAR (o precedente de agent-areas.ts) porque só metade do par é derivável: DEV_EVENT_TYPES é mecânica, DEV_PENDING_TYPES é julgamento — quais estados significam "um humano precisa agir".

A MESMA deriva tinha uma terceira instância, no WEB, e o mecanismo passou a cobri-la. DEV_STATUS_EVENTS (apps/web/src/lib/agent-status.ts), que o painel do time consulta para dizer o estado de cada dev agent, também não conhecia dev.blocked_by_container nem dev.awaiting_approval — e ali a consequência não era invisibilidade, era MENTIRA: os dois caíam no default: return 'trabalhando' do switch, então no exp004 o painel dizia que cinco dev agents parados havia HORAS, esperando um humano subir o container, estavam trabalhando. Os dois mapeiam para aguardando, o mesmo estado que dev.awaiting_gate já usava (o agente não segue até que algo FORA dele aconteça), sem estado novo em AgentStatus; dev.error, que não estava em lista nenhuma e por isso fazia o painel voltar ao dev.working anterior, mapeia para falhou. A lista deixou de ser lista: virou MAPA tipo -> AgentStatus, o que apaga o default e torna impossível acrescentar um tipo sem responder o que ele significa. E o teste cruzado ganhou o par engine × web, com uma exigência que a api não tem — DECISÃO explícita por tipo, mapeado OU declarado em DEV_STATUS_EVENTS_FORA (tipo -> motivo, vazio hoje) —, porque no painel um tipo dev.* PODE legitimamente não interessar; o que não pode é ficar de fora por esquecimento.

  • Onde: apps/api/src/application/use-cases/sessions/get-session-pending-work.use-case.ts
  • Teste: apps/api/test/application/use-cases/sessions/get-session-pending-work.use-case.spec.ts (dev agent dev.working/dev.blocked/dev.idle_tripped/dev.blocked_by_container como último evento → pending: true, inclusive os cinco agentes do cenário exp004 e com dev.blocked_by_container vencendo um dev.idle anterior; dev.idle → pending: false; sessão sem evento dev.* preserva o comportamento anterior; só o ÚLTIMO evento de cada agente importa; dev-<modulo>-2 também segura; isolamento entre sessões), scripts/ci/vocabulario-de-eventos-dev.spec.ts (o vocabulário dev.* do engine × as duas listas da api E × o mapa do painel, mais os três casos fabricados que provam a válvula DEV_STATUS_EVENTS_FORA, que nenhum dado real exercita hoje) e apps/web/src/lib/agent-status.test.ts (dev.blocked_by_container e dev.awaiting_approval → aguardando, dev.error → falhou, nunca trabalhando; e o evento mais recente continuando a mandar)
  • Origem: achado por uso real — sessão de execução real com cinco dev agents em idle_tripped fechada pelo heartbeat enquanto o usuário ainda desbloqueava tarefas manualmente; e, mais tarde, cinco dev agents em blocked_by_container no exp004, pelo mesmo mecanismo

RN-412 — dev.awaiting_gate/dev.awaiting_approval seguram a sessão; a janela efetiva de compactação é coerente com o transporte​

Achado por USO real ("nas PRs sempre está estourando entity too large"), com dois defeitos que se encadeiam. O gate de QA/SecOps morria com 413 request entity too large → o dev agent que esperava o veredito ficava preso em dev.awaiting_gate indefinidamente → esse estado NÃO era um dos três que a RN-411 segurava → o heartbeat de 30s fechava a sessão por baixo → a aba Executores exigia sessão ativa (findActiveExecutionSession) e apagava o roster inteiro, mesmo com trabalho real pendurado.

A causa do 413 era da própria api do Brabo, nunca do provider de LLM — ver ADR 0098. Duas correções, as duas necessárias (uma sem a outra só adia o estouro):

  1. apps/api/src/main.ts nunca configurou limite de body do Express — valia o default de 100 KB, o gargalo mais estreito no sentido engine→api (o Phoenix aceita até 8 MB). POST /internal/sessions/:sessionId/llm-turn reenvia o histórico INTEIRO da conversa a cada iteração do ToolLoop, e 3-4 tool results de 32 KiB (teto individual da RN-150) já somavam mais que 100 KB. API_JSON_BODY_LIMIT (default 10mb) fecha essa ponta.
  2. Engine.Harness.ContextManager.Default tinha dois defeitos que deixavam a compactação inalcançável antes do corpo estourar: estimate/1 contava só content, então mensagens de assistant com toolCalls pesados custavam ~zero tokens na estimativa; e a janela de compactação usava só context_window (128.000 tokens nos agentes de gate — qa_automacao_agent.ex, qa_performance_seguranca_agent.ex, qa_estrategia_agent.ex, appsec_agent.ex, dev_agent_server.ex), dando ~350 KB antes de compactar (threshold 0.7 × janela). A janela EFETIVA agora é min(context_window, teto_de_transporte) (transport_max_body_bytes, config única, default 8 MiB), e o corte sempre acontece em FRONTEIRA DE ITERAÇÃO do ToolLoop (group_by_iteration/1) — nunca separando uma mensagem assistant com toolCalls dos role: "tool" que a respondem, que quebraria o protocolo de tool-use do provider.

A régua de trabalho pendente (DEV_PENDING_TYPES, get-session-pending-work.use-case.ts) ganhou dois tipos: dev.awaiting_gate (o argumento original — o gate agora não deveria mais morrer, mas travar a sessão por causa dele nunca foi correto, é defesa em profundidade) e dev.awaiting_approval, por um argumento DIFERENTE do que a RN-411 tinha descartado: a decisão de aprovação/negação grava proposed_actions.status de forma SÍNCRONA na transação do ApproveActionUseCase (o segundo sinal já não vê mais pending ali), mas a retomada do dev agent é ASSÍNCRONA — só depois de avisarQuemEsperava() gravar task.action_settled/task.pr_settled na outbox é que Engine.Outbox.Drain enfileira o job do Oban que acorda DevAgentServer. Nessa janela, nada segurava a sessão: o mesmo defeito da RN-411, um nível mais fundo.

A aba Executores/Visão Geral tinham o defeito irmão do lado web: executionActivated era derivado de events.some((e) => e.type === 'execution.activated') sobre a janela de só 200 eventos de useSessionEvents ({ limit: 200, latest: true }) — execution.activated é dos PRIMEIROS eventos de uma sessão de execução e saía da janela em qualquer sessão real. O valor correto já existia, agregado sobre TODOS os eventos (bool_or(...), RN-090), no resumo do workspace (ProjectCardSummary.roster.executionActivated) — ProjectExecutorsTab.tsx e ProjectOverviewTab.tsx passaram a consumi-lo em vez de derivar da janela. gatesEverOpened sofre da MESMA classe de defeito e ficou DECLARADO como limitação conhecida, não corrigido aqui — corrigi-lo exigiria mudar a assinatura de deriveAgentRoster/rosterFactsFromEvents, fora do escopo desta correção.

  • Onde: apps/api/src/main.ts, apps/engine/lib/engine/harness/context_manager.ex, apps/engine/lib/engine/harness/tokenizer.ex, apps/api/src/application/use-cases/sessions/get-session-pending-work.use-case.ts, apps/web/src/routes/ProjectExecutorsTab.tsx, apps/web/src/routes/ProjectOverviewTab.tsx
  • Teste: apps/api/test/main.spec.ts (body de 1 MB aceito; acima do limite → 413); apps/engine/test/engine/harness/context_manager_test.exs (toolCalls pesados disparam compactação; :pinned sobrevive; teto de transporte força compactação mesmo com janela de modelo grande); get-session-pending-work.use-case.spec.ts (último evento dev.awaiting_gate/dev.awaiting_approval → pending: true); ProjectExecutorsTab.test.tsx/ProjectOverviewTab.test.tsx (sessão com >200 eventos ainda mostra o roster via resumo agregado)
  • Origem: achado por uso real — "nas PRs sempre está estourando entity too large", investigado em conjunto com o dono do produto

RN-413 — Templates de prompt vivem fora do código, versionados no grafo​

Todo prompt de agente era heredoc Elixir inline até esta entrega — identidades (Engine.Harness.Agents), kickoffs de PO/Arquiteto/Dev Lead/UX/Infra, o prompt de sumarização do ContextManager. Um template de prompt agora pode viver como (:PromptTemplate {name})-[:HAS_VERSION]-> (:PromptVersion {version, body, hash, active}) no Neo4j, gravado via POST /internal/graph/prompt-templates e lido via GET /internal/graph/prompt-templates/:name. Upsert é idempotente por hash — gravar o MESMO conteúdo duas vezes não cria versão nova, só a versão com hash igual já existente é devolvida.

Primeira leva extraída para prompts/*.md (front-matter name/version, placeholders documentados numa seção "Variáveis" para trechos que no .ex original eram interpolação): ux-designer-identity, psychologist-kickoff, anamnese-kickoff, context-manager-summarize. scripts/dev/seed-prompts.ts lê prompts/*.md, calcula hash sha256 do corpo e envia ao endpoint acima. Nenhum .ex foi editado nesta entrega — os quatro GenServers continuam com o texto inline; consumir os templates do grafo (fonte :graph do InstructionFiles, precedência db > graph > dir > root) é a Onda seguinte, declarada fora daqui.

  • Onde: apps/api/src/application/use-cases/graph/upsert-prompt-template.use-case.ts, get-prompt-template.use-case.ts; apps/api/src/interfaces/http/internal/internal-graph.controller.ts; prompts/*.md, prompts/README.md; scripts/dev/seed-prompts.ts (SECAO_DE_DOCUMENTACAO, corpoSemDocumentacao, parseFrontMatter)
  • Teste: casos de uso com GraphStore mockado (upsert com mesmo hash não duplica) + teste de integração contra Neo4j real, pulando graciosamente quando indisponível; scripts/dev/seed-prompts.spec.ts (parsing de front-matter, hash determinístico, template malformado reprova com mensagem clara; e, desde a AT-244, a varredura de TODO prompts/*.md: o corpo semeado não tem a seção de documentação e cada placeholder aparece nele uma vez só, e o sumarizador recebe os turnos uma vez só pelo mesmo render do engine)

Desde a AT-244 a seção "## Variáveis" de cada arquivo NÃO é semeada: o corpo do template termina na linha ## Variáveis, e o seeder descarta dela em diante. Antes o corpo levava a seção inteira — documentação que cita os mesmos {{placeholders}} do corpo —, e como cada consumidor troca TODAS as ocorrências (String.replace/3), com graph_templates_enabled? ligada o modelo recebia a documentação e o dado DUAS vezes: o sumarizador do ContextManager os turnos em dobro, os kickoffs do Psicólogo e da Anamnese o log de eventos em dobro, e a identidade do UX (atrás da graph_instruction_templates_enabled?) a documentação em todo turno. Os quatro arquivos tinham a forma; o corte mora no SEEDER, e não no render, porque há um render por consumidor e a pergunta "o que é corpo" é sobre o arquivo, que só o seeder lê. As versões subiram (anamnese-kickoff e ux-designer-identity para "2", psychologist-kickoff e context-manager-summarize para "3") com o MESMO texto de prompt. Não fecha sozinho numa instalação que já semeou: a versão ATIVA no grafo segue a antiga, com a documentação, até rodar scripts/dev/seed-prompts.ts de novo — o hash do corpo mudou, então o seeder cria a versão nova e desativa a anterior. Com as flags desligadas (o default) o defeito nunca existiu: vale o texto inline, que nunca teve a seção.


RN-414 — rag_search: agentes ganham a ferramenta que o produto nunca tinha exposto​

grep -rn "rag" apps/engine/lib dava ZERO ocorrências antes desta entrega — o RAG completo (pgvector, busca híbrida) só era consumido pela aba web "Chat RAG". A tool nova rag_search (apps/engine/lib/engine/harness/tools/rag_search.ex, categoria :direct, leitura não é efeito externo) chama POST /internal/rag/search — rota nova que REUSA HybridSearchUseCase sem duplicar a lógica de busca — e devolve hits formatados com citação (path + trecho), sempre com degraded: true visível no INÍCIO do texto quando o embedding não estava disponível (nunca escondido pelo corte de teto).

Tetos próprios, no espírito da RN-150: top_k clampado a 10 dentro da própria tool (não confia no que a api aceitaria), teto de 16 KiB no texto formatado (menor que os 32 KiB de search_workspace/read_file — cada hit de RAG já é chunk+excerpt inteiro). Falha de rede vira erro legível ao modelo, nunca crash do ToolLoop (RN-163).

Registrada em Engine.Harness.Tools (PO/Arquiteto/conversacionais) e Engine.Dev.Tools (dev agent); estendida também aos gates de leitura que já citam ADR/convenção indexada (QA-automação, QA-estratégia, AppSec, QA-performance/segurança) — não a Infra.WorkflowsAgent nem Psicólogo/Anamnese, que raciocinam sobre event log, não sobre docs/código do projeto.

  • Onde: apps/engine/lib/engine/harness/tools/rag_search.ex; apps/engine/lib/engine/sessions/engine_api_client.ex (rag_search/4); apps/api/src/interfaces/http/internal/internal-rag.controller.ts
  • Teste: rag_search_test.exs (hits formatados com citação; degradação visível; falha de rede sem crash; teto de bytes truncando com marca clara; clamp de top_k); engine_api_client_rag_test.exs
  • Origem: ver ADR 0100

RN-415 — Modelos Ollama garantidos no boot, degradação do RAG declarada​

nomic-embed-text (RAG_EMBEDDING_MODEL) nunca era puxado automaticamente — bug real, não só desta feature: o entrypoint do serviço ollama só puxava llama3.2:1b, e o RAG degradava para léxico-only em SILÊNCIO em qualquer ambiente limpo, sem nenhum sinal de que isso estava acontecendo. Serviço novo ollama-model-loader (one-shot, docker-compose.yml dev e prod), lendo OLLAMA_REQUIRED_MODELS (default gemma3:1b,yi-coder:1.5b,nomic-embed-text), aditivo ao serviço ollama existente — o entrypoint dele continua intocado.

A degradação deixou de ser silenciosa: rag_search (RN-414) e a busca híbrida da api já devolviam degraded: true quando o embedding falhava — o que faltava era um CONSUMIDOR que tornasse essa flag visível. Agora que a tool existe, o próprio modelo vê o aviso no texto formatado, e o model-loader reduz a chance de a degradação acontecer em primeiro lugar.

  • Onde: docker/docker-compose.yml, docker/docker-compose.prod.yml, docker/ollama/pull-models.sh; deploy/k8s/base/neo4j/, deploy/k8s/base/ollama/job-model-loader.yaml (template, NÃO wireado em nenhuma kustomization — deploy/k8s/ ainda não tem Service ollama pra apontar, ligar isso hoje faria CrashLoopBackOff em todo deploy real)
  • Teste: scripts/dev/verificar-modelos-ollama.sh, executado de ponta a ponta com um modelo pequeno real (all-minilm) para não pagar o custo de vários GB da lista de produção numa máquina compartilhada
  • Origem: achado durante a fundação do grafo de conhecimento — ver ADR 0100

RN-416 — O grafo é memória DERIVADA, reconstruível por projeção da outbox​

A alternativa de o engine escrever no Neo4j direto foi RECUSADA — abriria um segundo caminho de escrita além do event log, quebrando a garantia de fonte única de verdade. GraphProjector (api) drena uma SEGUNDA linha de outbox, mesma transação de sempre, aggregateType: 'graph_projection' — valor que o Engine.Outbox.Drain do lado engine nunca casa (o filtro dele é aggregate_type IN ('session', 'task')), evitando a corrida que existiria se reusasse 'session' (o engine já drena e marca esse tipo em ~2s). Mesmo padrão de deny-action.use-case.ts, que já grava em dois aggregateType na mesma transação.

Instrumentado em dois pontos: AppendSessionEventUseCase (para handoff.offered, psychologist.hypothesis_proposed, anamnese.profile_updated — payload só {eventId}, o projector RELÊ o envelope completo do event log na hora de projetar, nunca confia numa cópia potencialmente velha) e TransitionSessionUseCase (para session.closed/session.closed_abnormally, que não passam por session_events). GraphProjector é um poller (~2s, mesmo formato do DomainGaugesCollector) que chama os casos de uso de gravação já existentes da fundação anterior — a idempotência mora NELES (chave natural por tipo: Hipotese.id, Handoff(sessionId,seq), PerfilAnamnese(userId,dimensao), Interacao.sessionId com extensão de faixa). GraphUnavailableError no meio de um lote PARA o ciclo inteiro (o resto falharia pelo mesmo motivo) — a linha fica não-processada e tenta de novo sozinha no próximo ciclo, sem intervenção.

  • Onde: apps/api/src/application/graph-projection/graph-projector.ts; apps/api/src/domain/graph/graph-projection-events.ts; apps/api/src/application/use-cases/sessions/append-session-event.use-case.ts, transition-session.use-case.ts
  • Teste: graph-projector.spec.ts (caminho feliz dos quatro tipos; resolução evidência→seq de hipótese; GraphUnavailableError deixa a linha sem marcar e para o ciclo; ciclo seguinte reprocessa com sucesso; reprocessar a MESMA linha duas vezes não duplica no grafo); testes novos em append-session-event.use-case.spec.ts/ transition-session.use-case.spec.ts (tipo projetável grava a segunda linha; tipo não-projetável não grava nada extra)
  • Origem: ver ADR 0101

RN-417 — Psicólogo e Anamnese consultam por relevância, com degradação declarada para recência​

Psychologist.ContextBuilder/Anamnese.ContextBuilder continuam lendo o que sempre leram (eventos recentes / janela temporal), e ganham uma SEGUNDA fonte, EngineApiClient.rag_search/4, com uma query derivada do GATILHO da análise — a causa de término já classificada, no Psicólogo; competências do catálogo ainda sem current_profile, na Anamnese (NUNCA texto livre de hipótese/racional, pela proibição já estabelecida de a Anamnese jamais inferir saúde/personalidade/idade/gênero — a query só contém nomes de competência/membro/projeto). Os hits entram no orçamento EXISTENTE de Triage (max_prompt_events/max_payload_chars) — descontam vagas da janela de recentes, nunca somam por fora dela.

A chamada ao RAG é estritamente ADITIVA: sem hit (RAG indisponível, erro, ou simplesmente sem resultado), o comportamento é IDÊNTICO ao de antes desta RN — nenhum teste pré-existente precisou mudar. degraded: true (RAG caiu pra léxico-only por falta de embedding) aparece EXPLICITAMENTE no contexto final, nos dois agentes — nunca escondido pelo corte de teto.

Os kickoffs de Psicólogo (psychologist-kickoff) e Anamnese (anamnese-kickoff) passam a resolver como TEMPLATE do grafo (EngineApiClient.get_prompt_template/2) quando graph_templates_enabled? está ligada (GRAPH_TEMPLATES_ENABLED, default false) — com fallback obrigatório pro texto inline em qualquer falha (api fora, template não semeado, flag desligada). :pinned => true continua igual nos dois caminhos.

O TERCEIRO consumidor da mesma flag é o prompt de sumarização do ContextManager (context-manager-summarize, o quarto e último template da leva da RN-413 — era o único ainda sem consumidor, lacuna que a ADR 0101 declarou aberta e que fecha aqui). Mesma régua, byte a byte: flag ligada tenta o template e substitui {{turnos}} pelo MESMO corpo <role>: <content> que a trilha inline já montava; qualquer desfecho que não seja {"body" => corpo} com corpo binário não-vazio cai no inline — SEM erro e SEM log de erro, porque flag desligada, template ainda não semeado e api do grafo fora do ar são o MESMO caminho de degradação. Com a flag desligada (o default) a api nem é chamada. O render é função privada PRÓPRIA, não um helper compartilhado: o conjunto de placeholders é do template, não do mecanismo — cada um dos três consumidores tem o seu. E o fallback determinístico de quando o MODELO falha ("(N turnos anteriores omitidos)") é outra coisa e NÃO passa por template nenhum: é comportamento de código, não texto de prompt, e o próprio .md do template declara isso.

Desde a RN-621 o prompt de resumo, nas duas trilhas, pede que cada turno mantenha o idioma original e que citação e código não se traduzam; o template versionado subiu para a versão "2".

Consumo do restante do grafo (query_user_context — hipóteses com evidência e perfis lidos DIRETO do Neo4j) fica DECLARADO fora desta entrega: ainda sem rota HTTP exposta do lado api; Psicólogo/Anamnese hoje só consultam o RAG (pgvector) via rag_search, não o grafo de relações em si.

  • Onde: apps/engine/lib/engine/psychologist/context_builder.ex, apps/engine/lib/engine/workers/psychologist_worker.ex; apps/engine/lib/engine/anamnese/context_builder.ex, apps/engine/lib/engine/workers/anamnese_worker.ex; apps/engine/lib/engine/harness/context_manager.ex (prompt/1, render_template/2, prompt_inline/1)
  • Teste: context_builder_test.exs dos dois agentes (hits presentes; falha do RAG degrada sem erro; degraded: true visível; clamp de top_k; query derivada do gatilho); psychologist_worker_test.exs/ anamnese_worker_test.exs (template com sucesso e com fallback; :pinned idêntico nos dois caminhos; flag desligada nunca chama a api); context_manager_test.exs (template com {{turnos}} substituído; {:error, :not_found} cai no inline e a compactação termina normal; flag desligada nunca chama get_prompt_template)
  • Origem: ver ADR 0101

RN-418 — Efeito externo git e comando privilegiado (sudo/doas) viram teto absoluto, nunca deny​

Revisada pela RN-713 (AT-385, 02/10): com o modo automático (a curinga em auto_approve), push e PR são auto-aprovados.

Revisada pela RN-689 (AT-347, decisão do dono de 01/10): o teto abaixo vale também para as ações TIPADAS git_push e pr_open, que até ali só tinham o papel mínimo e nasciam auto_approved pelo curinga, por regra específica ou por permissions.json. O resto desta entrada continua valendo.

Revisa a RN-106 por decisão GLOBAL e explícita do dono do produto: git push, abertura de PR, deploy (a mesma detecção por prefixo que a RN-106 já tinha) e sudo/doas (novo — casados por VERBO em comandoPrivilegiadoNoComando, varrendo todos os segmentos do comando) deixam de ser deny incondicional e viram TETO ABSOLUTO — require_approval incondicional, no MESMO bloco final e MESMO padrão de código dos outros tetos (merge protegida, instruction_patch, parallelize/raise_max_parallel, escopo de caminho): current.policy === 'auto_approve' → sobrescrito. Nunca auto-aprovável por agent_autonomy (inclusive o curinga "*" do modo automático) nem por permissions.json.

A condição que torna isto seguro — sem a qual o teto seria decorativo — é a metade que fecha "sempre permitir" NA FONTE: ApproveAlwaysActionUseCase/patternsForAction recusam gravar padrão em allow pra ação de terminal com efeito externo git ou comando privilegiado. A instância específica ainda pode ser aprovada pelo fluxo normal (ApproveActionUseCase); só o clique que gravaria um padrão pra sempre é recusado, com mensagem clara. É o mesmo argumento que a RN-106 original usava pra justificar deny — resolvido na origem, não mais bloqueando o sintoma.

Um aviso automático de segurança sinalizou esta mudança durante a implementação (reescrever uma regra que o produto documentava como deny absoluto merece escrutínio) — o dono do produto confirmou explicitamente, depois de revisar, que a decisão era essa.

  • Onde: apps/api/src/domain/actions/decide.ts (bloco de tetos absolutos), apps/api/src/domain/actions/external-effect.ts (comandoPrivilegiadoNoComando, mensagemDeComandoPrivilegiado), apps/api/src/application/use-cases/actions/approve-always-action.use-case.ts
  • Teste: decide.spec.ts (git push com auto mode "*" E com allow casando no permissions.json resolvem pra require_approval; sudo idem; os tetos anteriores continuam intactos — regressão completa); approve-always-action.use-case.spec.ts ("sempre permitir" sobre git com efeito externo/sudo não grava padrão; comando comum continua gravando normalmente — regressão); external-effect.spec.ts
  • Origem: ver ADR 0102

RN-419 — O runner local se autentica por ticket de uso único, escopado a projeto e papel​

REVISADA pela RN-421 (ADR 0104): a condição de recusa do ticket citada abaixo (workspaceMode !== 'local') virou executionMode !== 'runner' — mesma régua, nome novo. O resto desta entrada continua valendo tal como está.

O canal terminal:<projectId> (socket Phoenix novo, /runner) recebe dois papéis distintos — :runner (o CLI na máquina do usuário, no máximo UM por projeto, exclusividade garantida por :global.register_name/3) e :web (a aba Terminal, múltiplos simultâneos) — e os dois entram autenticados por TICKET DE USO ÚNICO, mesmo padrão de segurança da RN-108 (ticket de socket de sessão), com uma inversão de propriedade: o ticket é EMITIDO PELO ENGINE (runner_socket_tickets, schema "engine", migration Ecto própria — não uma extensão da tabela de ticket de sessão, que é da api), e a API o pede via rota HTTP interna nova (POST /internal/projects/:projectId/runner-tickets) — o inverso do fluxo de ticket de sessão, onde a api grava direto na própria tabela.

POST /projects/:projectId/runner-ticket (role developer+, recusa se workspaceMode !== 'local') e POST /projects/:projectId/terminal-ticket (role viewer+, qualquer modo) são as duas rotas públicas que emitem o ticket pro cliente certo — o runner pede a primeira, a web pede a segunda. Ticket consumido (validação + UPDATE condicional de uso único) no join do canal; ticket de OUTRO projeto ou reusado nunca abre o tópico.

  • Onde: apps/engine/lib/engine/runners/socket_ticket.ex, apps/engine/lib/engine/runners/registry.ex, apps/engine/lib/engine_web/channels/{runner_socket,terminal_channel}.ex; apps/api/src/interfaces/http/runner/runner-tickets.controller.ts, apps/api/src/application/use-cases/runner/request-runner-ticket.use-case.ts
  • Teste: runner_socket_test.exs/terminal_channel_test.exs (join com ticket válido sucede; ticket reusado recusa; ticket de outro projeto não abre o tópico errado; segundo :runner no mesmo projeto é recusado; um segundo runner consegue conectar depois que o primeiro cai); request-runner-ticket.use-case.spec.ts/ runner-tickets.controller.spec.ts (recusa pra projeto não-local no ticket de runner; ticket de terminal funciona pra qualquer modo)
  • Origem: ver ADR 0103

RN-420 — Comando de agente roteado ao runner passa pelo MESMO pipeline de aprovação; PTY é ação do usuário, auditada​

REVISADA pela RN-423 (ADR 0104): a condição pra rotear deixou de ser binária. workspace_mode == "local" virou execution_mode == "runner" E workspace_verified_at não-nulo E runner conectado — faltando qualquer uma das TRÊS, o comando é RECUSADO explicitamente, nunca cai no System.cmd/bind-mount de mounted (que não existe pra um projeto runner, sem bind-mount nenhum). O resto desta entrada — o pipeline de aprovação de sempre, PTY como ação do usuário — continua valendo tal como está.

Engine.Actions.TerminalExecutor decide rotear um comando pro runner (em vez do System.cmd de sempre, dentro do container) DEPOIS que o pipeline normal (decide()/proposed_action) já aprovou — o roteamento é só uma escolha de DESTINO pro mesmo comando já autorizado, nunca um segundo caminho de execução que escapa da política (que continua com os tetos absolutos da RN-418 valendo igual, sudo/git incluídos). Condição pra rotear (histórico, ver revisão acima): workspace_mode == "local" E runner conectado (Engine.Runners.Registry.connected?/1) — sem qualquer uma das duas, o comportamento de sempre continua (System.cmd no container via bind-mount, ADR 0072 vira FALLBACK, nunca removido).

PTY interativo é DIFERENTE: é ação do USUÁRIO autenticado digitando no terminal da própria máquina, não do agente — por isso NÃO passa por proposed_action. Mas precisa deixar rastro: pty_open/pty_close vindos da web emitem terminal.session.started/terminal.session.ended no event log (endereçados à sessão mais recente do projeto, ProjectSession.latest_id/1, mesmo mecanismo que a Anamnese já usa pra narrar algo project-scoped). Achado real na consolidação: sem runner conectado, a web precisa de um pty_error EXPLÍCITO de volta — descartar o pedido só com um log no servidor deixaria a aba presa em "carregando" pra sempre, nunca alcançando o estado "sem runner" que a tela já sabia mostrar.

  • Onde: apps/engine/lib/engine/actions/terminal_executor.ex, apps/engine/lib/engine/runners/runner_router.ex, apps/engine/lib/engine_web/channels/terminal_channel.ex
  • Teste: terminal_executor_test.exs (roteia pro runner com modo local + runner conectado; cwd passa adiante; cai no caminho atual sem runner conectado; nunca roteia em modo container); terminal_channel_test.exs (pty_open sem runner conectado devolve pty_error pra web, nunca fica sem resposta)
  • Origem: ver ADR 0103

Workspace pessoal automático no cadastro (RN-410)​

Achado navegando o produto depois de um reset de banco: o botão "Novo projeto"/"+" do dashboard não fazia NADA, sem erro nenhum. A causa raiz estava em duas camadas. A visível: Dashboard.tsx só abre o NewProjectWizard quando useCurrentWorkspace() acha um workspace (list[0]?.workspace de GET /workspaces); sem nenhum, o clique não tem onde ir. A funda: RegisterUseCase criava usuário e credencial mas NUNCA um workspace, e o mesmo valia para SocialLoginCallbackUseCase no ramo que provisiona conta nova — TODO cadastro novo, por senha ou por login social, caía nessa parede. Só não tinha aparecido antes porque db/seed.ts sempre cria um workspace junto dos dados de demonstração, e nenhuma tela do produto chama createWorkspace (a rota, o caso de uso e o client HTTP existem — só não têm chamador nenhum na UI).

RN-410 — Toda conta NOVA nasce com um workspace pessoal, na MESMA transação da conta​

RegisterUseCase (registro por e-mail/senha) e SocialLoginCallbackUseCase (provisionamento de conta nova via login social) criam o workspace e adicionam o usuário como owner — mesmo par create/addMember que CreateWorkspaceUseCase já usa — dentro da MESMA transação que já cria usuário e credencial. Não é uma segunda chamada ao use case: ele abre a PRÓPRIA transação, e o ponto é nunca existir usuário sem workspace se algo falhar no meio.

Nome e slug saem de UMA função pura, nomeESlugDoWorkspacePessoal(nome, email, userId) (apps/api/src/domain/auth/personal-workspace.ts), reusada pelos dois pontos de criação — para a regra não divergir em dois arquivos, o mesmo motivo por trás de normalizarEmail/exigirSenhaValida morarem no domínio. O nome usa o nome informado (login social usa o login do provider), com o local-part do e-mail como fallback quando não há nome — nunca inventa nenhum dos dois. O slug é kebab-case do mesmo texto, SEMPRE sufixado com userId.slice(0, 8) (mesmo padrão de extraDevAgentId/workspaceDirName/rótulo de sessão): workspaces.slug é UNIQUE no banco e nada no produto faz retry-on-conflict, então dois cadastros com nome ou local-part iguais colidiriam sem o sufixo. Nome ou e-mail sem NENHUM caractere alfanumérico degradam para o literal workspace-<8 chars>, nunca para uma string vazia.

  • Onde: apps/api/src/domain/auth/personal-workspace.ts (nomeESlugDoWorkspacePessoal), apps/api/src/application/use-cases/auth/register.use-case.ts, apps/api/src/application/use-cases/auth/social-login-callback.use-case.ts (método provisionarContaNova)
  • Teste: apps/api/test/domain/auth/personal-workspace.spec.ts (nome com/sem nome, fallback para o local-part do e-mail, acento/maiúscula virando kebab-case ASCII, fallback workspace-<id> sem caractere alfanumérico nenhum, slug sempre único por conta do sufixo); apps/api/test/application/use-cases/auth/register.use-case.spec.ts (conta nova ganha workspace com o usuário como owner, e-mail duplicado não cria um segundo); apps/api/test/application/use-cases/auth/ social-login-callback.use-case.spec.ts (mesma prova no ramo que provisiona conta nova via login social)
  • ADR: nenhum — correção de um caminho que devia ter feito isso desde o início, não decisão estrutural nova
  • Origem: achado por uso real, navegando o produto — não item de backlog planejado

Onda 1 do ADR 0104 — execution_mode em três valores, workspace verificado pelo runner​

O ADR 0072 (RN-169/RN-170) e o ADR 0103 (RN-419/RN-420) nunca se falaram: o runner reusava a mesma flag workspace_mode == 'local' sem bind-mount nenhum, então usar o runner de verdade continuava exigindo passar pela validação de PASTA MONTADA. O ADR 0104 reconcilia os dois. Migração 0048 recria o enum (project_workspace_mode → project_execution_mode, local → mounted) numa transação só — ALTER TYPE ... ADD VALUE não pode ser referenciado na mesma transação em que foi adicionado — e soma workspace_verified_at (nullable).

RN-421 — execution_mode tem TRÊS valores: container/mounted/runner​

projects.workspace_mode (dois valores) virou projects.execution_mode (três): container (DEFAULT, inalterado), mounted (o antigo local, RENOMEADO — mesmo comportamento, mesma validação de disco na criação) e runner (NOVO: uma pasta do usuário sem bind-mount, confirmada pelo CLI brabo-runner). O CHECK do banco passou a ser (execution_mode <> 'container') = (workspace_path IS NOT NULL) — cobre os dois modos não- container com a MESMA condição, sem OR explícito por valor.

projectScopeRoot/Project.workspace_dir_name (api e engine) derivam a raiz pela MESMA regra nos dois modos não-container: o caminho do usuário é a raiz, distinguido do nome de pasta gerenciada pela barra inicial. O que muda entre mounted e runner não é ONDE a raiz fica — é QUANDO/QUEM confirma que ela existe de verdade (RN-422/RN-423).

  • Onde: apps/api/src/db/migrations/0048_quiet_iron_fist.sql, apps/api/src/db/schema/iam.ts (projectExecutionModeEnum), apps/api/src/domain/iam/project.entity.ts (PROJECT_EXECUTION_MODES), apps/api/src/infrastructure/filesystem/project-workspaces-root.ts (projectScopeRoot), apps/engine/lib/engine/projects/project.ex
  • Teste: apps/api/test/infrastructure/filesystem/project-workspaces-root.spec.ts (describe "projectScopeRoot nos modos mounted/runner"), apps/engine/test/engine/projects/project_test.exs
  • Decisão arquitetural: ADR 0104
  • Origem: reconciliação pedida pelo dono do produto, entre os ADRs 0072 e 0103

RN-422 — A validação de criação DIVERGE por modo: mounted toca disco, runner só o léxico​

CreateProjectUseCase.caminhoValidado ganhou um terceiro ramo. mounted continua exatamente como local era (RN-170, histórico): recusa com 400 o caminho que não existe, não é pasta ou não é gravável dentro do container, com a instrução de montagem. runner valida só o LÉXICO — mesma lista de proibições (absoluto, sem .., fora de raiz/pasta de sistema, sem sobreposição com o checkout do Brabo) — sem tocar disco: só o runner, rodando no HOST de verdade, tem autoridade para confirmar que a pasta existe. O projeto nasce com workspace_verified_at: null.

caminhoDeWorkspaceLocalValido (o predicado léxico) foi EXPORTADO de project-workspaces-root.ts para ser reusado nos dois lados que precisam dele sem tocar disco: a criação de projeto runner e a confirmação do runner (RN-423) — a mesma função, nunca duas cópias que um dia divergem.

  • Onde: apps/api/src/application/use-cases/iam/create-project.use-case.ts (caminhoValidado), apps/api/src/infrastructure/filesystem/project-workspaces-root.ts (caminhoDeWorkspaceLocalValido, exportada), apps/api/src/interfaces/http/iam/dto/create-project.dto.ts
  • Teste: apps/api/test/application/use-cases/iam/create-project-modo-de-workspace.spec.ts (bloco runner), apps/api/test/interfaces/http/iam/project-dto-modo-de-workspace.spec.ts, apps/web/src/lib/wizard.test.ts (canAdvanceFromWorkspace para runner)
  • Decisão arquitetural: ADR 0104
  • Origem: reconciliação do ADR 0104

RN-423 — O runner CONFIRMA o caminho; sem confirmação, o comando é recusado — nunca cai no container​

O runner é a fonte da verdade do caminho de um projeto runner, não o que foi digitado no wizard. Logo depois do canal Phoenix conectar (terminal:<projectId>, papel :runner), apps/runner/src/index.ts empurra workspace_confirm com o --dir que recebeu na linha de comando. O engine repassa (Engine.Sessions.EngineApiClient.confirm_workspace/4) para POST /internal/projects/:projectId/workspace-verification (ConfirmProjectWorkspaceUseCase), que:

  1. Recusa com 400 se o projeto não estiver no modo runner;
  2. Revalida o caminho pelo MESMO predicado léxico da criação (RN-422) — mesmo vindo do runner, raiz de sistema e sobreposição com o Brabo continuam proibidas;
  3. SOBRESCREVE workspacePath com o caminho normalizado e grava workspaceVerifiedAt = now() — sem exigir igualdade com o que foi digitado na criação: o runner manda;
  4. É IDEMPOTENTE — reconectar reportando o MESMO caminho não regrava nada (changed: false); um caminho DIFERENTE regrava, porque o runner continua sendo a fonte da verdade a cada reconexão;
  5. Sem sessão no projeto ainda (ProjectSession.latest_id/1 devolve nil): o UPDATE acontece do mesmo jeito, só o evento project.workspace_verified (e a evidência do gate workspace-verificado) fica ausente — a MESMA degradação que pty_open/pty_close já aceitam (RN-108/RN-420). Lacuna aceita e declarada, não silenciosa.

Engine.Actions.TerminalExecutor.decisao_de_execucao/1 ganhou QUATRO saídas, não duas: runner com workspace_verified_at: nil recusa (:recusar_nao_verificado); runner verificado sem runner conectado recusa (:recusar_runner_desconectado); runner verificado e conectado roteia (:rotear_runner); qualquer outro modo segue o caminho de sempre (:caminho_de_sempre). As duas recusas NUNCA caem no System.cmd — um projeto runner não tem bind-mount, e "cair pro caminho de sempre" seria executar às cegas numa pasta que o processo do engine não enxerga. A mesma recusa vale na corrida (Registry dizia conectado no início de run/3, mas o runner caiu antes do dispatch responder).

  • Onde: apps/api/src/application/use-cases/iam/confirm-project-workspace.use-case.ts, apps/api/src/interfaces/http/internal/internal-projects.controller.ts (POST :projectId/workspace-verification), apps/engine/lib/engine/actions/terminal_executor.ex (decisao_de_execucao/1), apps/engine/lib/engine_web/channels/terminal_channel.ex (handle_in("workspace_confirm", ...)), apps/engine/lib/engine/sessions/engine_api_client.ex (confirm_workspace/4), apps/runner/src/channel.ts (enviarWorkspaceConfirm), apps/runner/src/index.ts
  • Teste: apps/api/test/application/use-cases/iam/confirm-project-workspace.use-case.spec.ts, apps/engine/test/engine/actions/terminal_executor_test.exs (describe "roteamento pro runner local"), apps/engine/test/engine_web/channels/terminal_channel_test.exs (describe "workspace_confirm"), apps/runner/src/channel.spec.ts (enviarWorkspaceConfirm depois do join)
  • Borda: o docs/gates.yml registra workspace-verificado como severidade: warn — quem trava de verdade é a recusa explícita do engine, o gate é só o registro/evidência.
  • Decisão arquitetural: ADR 0104
  • Origem: reconciliação do ADR 0104, decisão confirmada com o dono do produto (o runner é a fonte da verdade; sem sessão, o UPDATE acontece mesmo assim; comando recusado explicitamente sem verificação/conexão)

RN-424 — PAT autentica SÓ runner-ticket, por construção — nunca dual-auth com JWT nessa rota​

Um Personal Access Token (brb_…) nunca autoriza nenhuma rota além de POST /projects/:projectId/runner-ticket. A garantia não é um if que uma rota nova poderia esquecer de checar: é estrutural. @RequirePatAuth() marca o handler com IS_PAT_ROUTE_KEY; JwtAuthGuard (o APP_GUARD global) checa esse metadado ANTES de tentar verify() de JWT e devolve true sem validar nada — o mesmo formato de bypass que IS_PUBLIC_KEY e IS_SERVICE_ROUTE_KEY já usam. PatAuthGuard, aplicado só nesse handler via @UseGuards(), é quem de fato autentica: extrai o bearer, recusa (401) qualquer valor que não comece com brb_ sem consultar o repositório — nunca tenta validar como JWT nessa rota, e nenhuma outra rota aceita brb_… como bearer.

Depois que o PatAuthGuard popula request.user, RolesGuard/ ResolveEffectiveRoleUseCase continuam rodando, inalterados — cinto e suspensório: se o dono do PAT perder papel suficiente no projeto pela via normal (ProjectMember/workspace), o token para de autorizar mesmo sem ter sido revogado explicitamente.

A alternativa considerada e recusada: o JwtAuthGuard global reconhecer o prefixo brb_ e popular request.user direto, pra QUALQUER rota. Isso autorizaria o PAT a tudo que o papel do usuário permite no resto da api — o escopo "só pede ticket de runner" viraria decorativo.

  • Onde: apps/api/src/interfaces/http/auth/pat-route.decorator.ts (IS_PAT_ROUTE_KEY/@RequirePatAuth()), apps/api/src/interfaces/http/auth/jwt-auth.guard.ts (o terceiro early-out), apps/api/src/interfaces/http/auth/pat-auth.guard.ts (PatAuthGuard), apps/api/src/interfaces/http/runner/runner-tickets.controller.ts (runnerTicket, o único handler marcado)
  • Teste: apps/api/test/interfaces/pat-auth.guard.spec.ts, apps/api/test/interfaces/jwt-auth.guard.spec.ts (@RequirePatAuth() passa sem tentar verify()), apps/api/test/interfaces/http/runner/runner-tickets.controller.spec.ts (runnerTicket tem o decorator, terminalTicket não)
  • Decisão arquitetural: ADR 0105

RN-425 — Validação de PAT colapsa inexistente/revogado/expirado numa resposta só; escopo errado é 403; last_used_at nunca throttla​

PersonalAccessTokenRepository.validarEUsar(hash) é UMA query: UPDATE personal_access_tokens SET last_used_at = now() WHERE token_hash = $1 AND revoked_at IS NULL AND (expires_at IS NULL OR expires_at > now()) RETURNING id, user_id, project_id. Zero linhas devolve null, e o guard responde 401 pra "não existe", "revogado" e "expirado" com a MESMA mensagem — quem apresenta um token roubado ou expirado não descobre qual dos três é o motivo (mesmo padrão de AccountTokenRepository.consumir()).

Escopo de projeto incorreto é uma categoria DIFERENTE: se project_id devolvido pela query não bate com :projectId da rota, é 403, não 401 — o token autenticou de verdade, só não tem direito a ESTE projeto.

last_used_at é atualizado de forma INCONDICIONAL, na MESMA query de validação — nunca com um throttle (ex.: "só se last_used_at for NULL ou tiver mais de 5 minutos") no mesmo WHERE. Um throttle ali é um bug real: um PAT reapresentado duas vezes em menos de 5 minutos cairia fora do WHERE na segunda vez (porque last_used_at estaria "fresco demais"), e a query devolveria zero linhas pra um token válido — rejeitando com 401 uma reconexão legítima. O laço de retry do runner reconecta em segundos, não minutos. O custo de não throttlar é um UPDATE de uma linha por índice único a cada chamada — irrelevante no pior caso real (até 10 tentativas seguidas, teto do runner).

  • Onde: apps/api/src/infrastructure/persistence/drizzle/personal-access-token.repository.ts (validarEUsar), apps/api/src/interfaces/http/auth/pat-auth.guard.ts
  • Teste: apps/api/test/infrastructure/persistence/personal-access-token.repository.spec.ts (inexistente/revogado/expirado → null; "toca last_used_at sempre que válido, sem throttle" — regressão do bug acima), apps/api/test/interfaces/pat-auth.guard.spec.ts (escopo errado → 403, não 401)
  • Decisão arquitetural: ADR 0105

RN-426 — Listar/revogar PAT é escopado ao PRÓPRIO usuário, no WHERE da query — sem admin cross-user nesta onda​

ListPersonalAccessTokensUseCase/RevokePersonalAccessTokenUseCase filtram por userId dentro da consulta SQL (WHERE user_id = $1 AND ...), nunca trazendo tudo e filtrando depois em memória. Cada usuário só enxerga e só revoga os PRÓPRIOS tokens — inclusive dentro de um projeto onde ele é maintainer.

revogar(id, userId, motivo) é IDEMPOTENTE: se o UPDATE (com o mesmo WHERE user_id = $1) não acha linha porque já estava revogado, uma segunda consulta de desempate devolve a linha (revogar de novo não é erro); se não acha porque o token não existe OU não é do usuário chamador, devolve null — a MESMA resposta (404 no controller) pros dois casos, pra não vazar a existência de um token alheio pelo código de status.

Fechado pela RN-427: um maintainer revogar o PAT de outro usuário — o caso de resposta a incidente (dev desligado com token vazando) — que esta seção declarava fora de escopo saiu do backlog e está implementado. Esta régua (autorevogação, escopo por userId) não mudou em nada.

  • Onde: apps/api/src/application/use-cases/auth/list-personal-access-tokens.use-case.ts (ListPersonalAccessTokensUseCase), apps/api/src/application/use-cases/auth/revoke-personal-access-token.use-case.ts, apps/api/src/infrastructure/persistence/drizzle/personal-access-token.repository.ts (listarDoUsuarioNoProjeto, revogar)
  • Teste: apps/api/test/application/use-cases/auth/list-personal-access-tokens.use-case.spec.ts, apps/api/test/application/use-cases/auth/revoke-personal-access-token.use-case.spec.ts, apps/api/test/infrastructure/persistence/personal-access-token.repository.spec.ts (revogar token de outro usuário → null)
  • Decisão arquitetural: ADR 0105

RN-427 — maintainer revoga PAT de QUALQUER usuário do projeto, escopado por project_id — resposta a incidente​

Extensão do modelo da RN-426, não uma decisão de arquitetura nova: mesmo padrão já usado para fechar a RN-407 (PO lendo métricas de produto) sobre um modelo já decidido, sem ADR próprio. Fecha o corte que o ADR 0105 declarava "fora desta onda" — resposta a incidente real (dev desligado com token vazando), onde esperar o próprio usuário revogar não é opção.

ListPersonalAccessTokensAsMaintainerUseCase/ RevokePersonalAccessTokenAsMaintainerUseCase são casos de uso NOVOS, em rotas SEPARADAS (GET .../personal-access-tokens/all, DELETE .../personal-access-tokens/:tokenId/admin, ambas @RequireRole('maintainer')) — nunca um if dentro dos handlers de self-service, mesmo princípio já usado no resto do produto para autorização por nível (OfferInfraHandoffUseCase). A rota de autorevogação (RN-426) não muda em nada.

listarDoProjeto traz TODOS os tokens do projeto — sem filtro de userId — com um innerJoin em users pro e-mail do dono, mesmo padrão que a listagem de membros do projeto já usa: sem o e-mail, um maintainer vendo "revogar token de quem?" só teria um UUID cru, e o próprio motivo do item (resposta a incidente) pede saber QUEM é o dono.

revogarComoMaintainer(id, projectId, motivo) é o mesmo desenho idempotente de revogar() — UPDATE condicional seguido de SELECT de desempate quando o UPDATE não acha linha —, mas o WHERE compara project_id, nunca user_id: um maintainer revoga qualquer dono DENTRO do projeto dele, nunca um token de outro projeto. Sem linha nenhuma (token não existe OU é de outro projeto), devolve null — a MESMA resposta (404) pros dois casos, mesma disciplina de não vazar existência que a RN-426 já aplicava.

  • Onde: apps/api/src/application/use-cases/auth/list-personal-access-tokens-as-maintainer.use-case.ts, apps/api/src/application/use-cases/auth/revoke-personal-access-token-as-maintainer.use-case.ts, apps/api/src/infrastructure/persistence/drizzle/personal-access-token.repository.ts (listarDoProjeto, revogarComoMaintainer), apps/api/src/interfaces/http/runner/personal-access-tokens.controller.ts (listAllPats, revokePatAsMaintainer), apps/web/src/routes/settings/PersonalAccessTokensSection.tsx (sub-lista visível só para owner/maintainer)
  • Teste: apps/api/test/application/use-cases/auth/list-personal-access-tokens-as-maintainer.use-case.spec.ts, apps/api/test/application/use-cases/auth/revoke-personal-access-token-as-maintainer.use-case.spec.ts, apps/api/test/infrastructure/persistence/personal-access-token.repository.spec.ts (listarDoProjeto/revogarComoMaintainer), apps/api/test/interfaces/http/runner/personal-access-tokens.controller.spec.ts, apps/web/src/routes/ProjectSettingsTab.test.tsx
  • Decisão arquitetural: ADR 0105 (extensão do modelo existente, sem ADR novo)

RN-428 — Carrossel de promoção de histórias sobrevive à janela de eventos​

promocoesPendentes/o carrossel de promoção do PO (RN-148) não depende mais de scan sobre a janela dos últimos 200 eventos de useSessionEvents. A fonte de conteúdo/contagem é useBacklog (Story.proposedReady, por sessão, completa e sem janela) — numa sessão longa, backlog.story_promotion_proposed sai da janela enquanto a história continua pendente de verdade, e antes disso o carrossel encolhia (ou sumia) silenciosamente. Mesma classe de bug que a RN-180 já corrigiu para ContextAside.

Quando a story não está no backlog carregado (query ainda não respondeu), degrada story a story para o scan de janela de sempre. O ancoramento na timeline continua vindo do evento dentro da janela quando ele existir; se o evento que abriu a leva já saiu da janela, o carrossel ancora no TOPO do trecho visível em vez de sumir — nunca esconder um estado real por causa de corte de leitura.

  • Onde: apps/web/src/routes/SessionPage.tsx (useMemo timeline)
  • Teste: apps/web/src/routes/SessionPage.carrossel-janela-estourada.test.tsx
  • ADR: nenhum — correção de bug, mesma classe já corrigida pela RN-180
  • Origem: achado por uso real ("o chat falhou em mostrar o carrossel do PO")

RN-429 — Navegação de pasta local é relay puro pelo Runner, nunca a api enumerando o container​

O canal terminal:<projectId> ganha dois eventos, no MESMO desenho de relay do PTY: fs_list_dir/fs_home_dir (:web pede, engine faz relay DIRETO pro pid do runner registrado, erro imediato quando não há runner conectado — nunca fica esperando uma resposta que não vem) e fs_list_dir_reply/fs_home_dir_reply (:runner responde, broadcast filtrado só pra :web, correlacionado por ref gerado pelo cliente). A api NÃO ganha rota nova nenhuma — ela continua sem enumerar filesystem, o argumento que a ADR 0072 já tinha fixado contra um seletor de pasta continua de pé. apps/runner/src/guard.ts (que restringe cwd de comando já aprovado à raiz do projeto) de propósito NÃO se aplica à navegação — listar diretório é leitura livre pela máquina do usuário, com os privilégios que ele já tem, e uma entrada sem permissão é pulada, não aborta a listagem inteira.

Gap declarado: o ticket do canal é emitido POR PROJETO já existente — na tela de criação de projeto (NewProjectWizard.tsx), o projeto só nasce na confirmação, então FolderBrowserModal recebe projectId: null ali e mostra o estado declarado em vez de tentar uma conexão impossível; o campo de texto livre continua sendo o caminho manual, como antes.

  • Onde: apps/engine/lib/engine_web/channels/terminal_channel.ex; apps/runner/src/fs-browser.ts, apps/runner/src/channel.ts, apps/runner/src/index.ts; apps/web/src/lib/fs-browser-channel.ts, apps/web/src/components/FolderBrowserModal.tsx, apps/web/src/components/RunnerOnboardingPanel.tsx, apps/web/src/routes/NewProjectWizard.tsx, apps/web/src/routes/code/TerminalPanel.tsx
  • Teste: terminal_channel_test.exs (relay puro web↔runner, erro imediato sem runner, papel errado ignorado); fs-browser.spec.ts (ordenação, pasta vazia/inexistente/arquivo, permissão por entrada); FolderBrowserModal.test.tsx (projectId nulo, navegação, seleção, sem runner); NewProjectWizard.test.tsx (botão "Procurar pasta...")
  • ADR: 0107, revisa a 0072 sem editá-la
  • Origem: pedido do dono do produto — "não consegui linkar com uma pasta do usuário"

RN-430 — PRs são project-wide; a decisão usa o sessionId da própria ação, nunca a mais recente​

A aba prs resolve o defeito de ProjectApprovalsTab.tsx, que escopava a seção "PRs em revisão" a usePendingActions(projectId, latestSession?.id) — a revisão pendente de uma sessão anterior desaparecia assim que uma sessão nova nascia. A listagem vem direto do provider (GET /projects/:id/code/pull-requests, já project-wide por desenho); o cruzamento com a proposed_action correspondente (ex.: git_merge) usa ProposedActionRepository.findPendingByProject(projectId, actionType?), novo, ao lado do já existente escopado por sessão. A decisão (aprovar/negar/sempre permitir) usa o sessionId que a própria ProposedAction carrega, nunca latestSession — a ação pode ter nascido numa sessão diferente da atual.

A aba PRs é a primeira produtora real de git_merge pela UI (actor.kind: 'user'); a trava de branch protegida em decide.ts (RN-154) segue absoluta independente de quem propõe. git_merge ganhou corpo próprio no card de aprovação em vez do despejo de JSON cru (mesmo defeito que a RN-096 já corrigiu para outros tipos).

  • Onde: apps/api/src/application/ports/proposed-action-repository.port.ts, apps/api/src/infrastructure/persistence/drizzle/proposed-action.repository.ts, apps/api/src/application/use-cases/actions/list-project-pending-actions.use-case.ts, apps/api/src/interfaces/http/actions/project-actions.controller.ts (GET /projects/:projectId/actions?status=pending&actionType=), apps/web/src/routes/ProjectPrsTab.tsx, apps/web/src/components/ApprovalCard.tsx
  • Teste: list-project-pending-actions.use-case.spec.ts (ação pendente de sessão antiga encontrada project-wide; filtro por actionType; nunca devolve ação decidida); propose-action.use-case.spec.ts (git_merge com payload real da aba PRs segue pending mesmo com GitMerge() em allow, e com agent_autonomy curinga ligado); ProjectPrsTab.test.tsx (PRs de múltiplas sessões aparecem juntas; decisão usa o sessionId da ação, não o da sessão mais recente); ApprovalCard.test.tsx (corpo próprio de git_merge)
  • ADR: nenhum — extensão de um caminho de leitura já existente, sem mudar o pipeline de decisão
  • Origem: pedido do dono do produto — "não há maneiras de gerir PRs"

RN-431 — O selo da aba Arquitetura conta pendência de validação, nunca "diagrama não gerado"​

contagens.arquiteturaPendente (régua de abas) vem de architecture.pendencies.length (divergência de validação cruzada história↔módulo), não de c4Diagram.status === 'sem_diagrama'. As outras três contagens da régua (promocoesPendentes, aprovacoesPendentes, hipotesesPendentes) significam todas "algo espera SUA decisão" — gerar o diagrama é trabalho do Arquiteto, não fila de decisão do usuário, e badigar por isso seria ruído.

  • Onde: apps/web/src/routes/ProjectPage.tsx
  • Teste: ProjectPage.test.tsx
  • ADR: nenhum
  • Origem: decisão de design tomada ao extrair a aba Arquitetura da Visão Geral

RN-432 — Preferência de idioma vem no payload de login/refresh, nunca numa chamada extra​

Revisada pela RN-891: a coluna aceita também ru e zh-Hans, e o padrão do web passou de en para pt-BR (ADR 0216).

Fundação de i18n da interface (Onda 6a de um programa maior — a extração em massa das strings do resto do app é etapa separada, em paralelo). Coluna locale em users ('pt-BR' | 'en', default 'pt-BR' — nunca flipa silenciosamente quem já tem conta). EmitirSessaoUseCase é o ÚNICO ponto que emite sessão (login, refresh, login social) — locale embutido no corpo de /auth/login//auth/refresh a partir dele, sem round-trip adicional. GET/PATCH /users/me/preferences existe como via REDUNDANTE, pra AccountPage reafirmar o valor sem esperar o próximo refresh.

en é o idioma DEFAULT do app a partir de agora (react-i18next, nova dependência — mesma régua de "dependência nova precisa de justificativa" que mermaid/@xterm/xterm já seguiram); pt-BR continua disponível. apps/web/src/lib/idioma.ts (mesmo desenho de tema.ts): o SERVIDOR é a fonte de verdade, localStorage['brabo.locale'] é só cache pra evitar flash de idioma errado no primeiro paint. Usuário sem conta ainda usa navigator.language só como sugestão de EXIBIÇÃO, nunca persiste nada antes de existir conta.

  • Onde: apps/api/src/application/use-cases/auth/emitir-sessao.use-case.ts, apps/api/src/interfaces/http/iam/user-preferences.controller.ts, apps/web/src/lib/idioma.ts, apps/web/src/lib/i18n.ts, apps/web/src/routes/AccountPage.tsx
  • Teste: apps/web/src/lib/idioma.test.ts, apps/web/src/routes/AccountPage.test.tsx, apps/api/test/application/use-cases/auth/*.spec.ts, apps/api/test/interfaces/route-surface.spec.ts
  • ADR: nenhum — extensão aditiva de um choke point já existente, sem mudar o formato de sessão pra ninguém que não usa o campo novo
  • Origem: pedido do dono do produto — interface e docs em inglês por padrão, com português preservado

Terminal do runner local preso em "Abrindo terminal..." para sempre (RN-433)​

Achado testando um projeto novo no modo runner (ADR 0103/0104) e abrindo Code → Dev → Terminal: a tela nunca saía do skeleton de carregamento, e o console mostrava socket do terminal com erro/socket do terminal fechado em loop, com backoff crescente até estabilizar em ~5-6s — o padrão do backoff PADRÃO do phoenix.js.

RN-433 — O socket do terminal desiste depois de um timeout, o default de ENGINE_PUBLIC_URL é alcançável PELO BROWSER, e o endpoint não duplica path​

Três defeitos empilhados. Os dois primeiros foram achados investigando o sintoma; o TERCEIRO só apareceu depois de corrigir os dois primeiros — sem eles, o browser nunca alcançava o engine pra revelar o bug de path por baixo. Corrigir só um deixa os outros de pé:

  1. ENGINE_PUBLIC_URL (RN-419) nunca tinha default nenhum no docker/docker-compose.yml — só ENGINE_URL (http://engine:4000, hostname que SÓ resolve dentro da rede do Compose). O fallback do código (process.env.ENGINE_PUBLIC_URL ?? process.env.ENGINE_URL ?? …, request-runner-ticket.use-case.ts) caía silenciosamente no valor errado, e o browser tentava abrir um WebSocket contra um hostname que ele nunca resolve. docker/docker-compose.yml ganhou ENGINE_PUBLIC_URL: ${ENGINE_PUBLIC_URL:-http://localhost:4000} no serviço api — o MESMO default que VITE_ENGINE_URL já usa no serviço web, pelo mesmo motivo.
  2. Mesmo com a URL certa, um transporte que NUNCA abre (engine fora do ar, firewall, reconfiguração futura errada) não tinha teto nenhum: apps/web/src/lib/terminal-channel.ts nunca passava reconnectAfterMs pro Socket, então o phoenix.js caía no backoff PADRÃO dele e ficava tentando reconectar sozinho pra sempre — o próprio docblock do módulo já declarava a intenção de "sem reconexão automática" (mesma régua de session-channel.ts/RN-108), mas nunca era cumprida na prática. Sem onError/onClose chamando handlers.onErro, TerminalPanel ficava preso em 'carregando' (RN-088 — toda tela distingue carregando/erro/ vazio — violada em silêncio). Corrigido com reconnectAfterMs devolvendo um valor que praticamente nunca dispara (24h, mesmo padrão de session-channel.ts) e um timeout PRÓPRIO de 8s (TIMEOUT_CONEXAO_MS): se onOpen não disparar antes disso, o módulo chama handlers.onErro com mensagem acionável e desconecta.
  3. O defeito que de fato impedia a conexão, mascarado pelos dois acima: terminal-channel.ts concatenava '/runner/websocket' a um engineWsUrl que a api já devolve PRONTO — ws://host:porta/runner (engineWsUrlPublico()) —, e o PRÓPRIO Socket do phoenix.js ainda acrescenta /websocket sozinho no construtor (this.endPoint = ${endPoint}/websocket). O resultado batia no engine como GET /runner/runner/websocket/websocket, que Phoenix.Router.NoRouteError recusa — confirmado no log do engine (docker logs brabo-engine-1) durante a verificação end-to-end desta correção. apps/runner/src/channel.ts (o lado do CLI) já fazia certo — passa engineWsUrl direto pro Socket, sem tocar — e o teste de terminal-channel.ts nunca pegou porque o mock de getTerminalTicket usava um engineWsUrl (http://engine.local, sem /runner) que não reflete o contrato real da api; corrigido junto. Fix: terminal-channel.ts passa a usar engineWsUrl (só com o .replace(/^http/, 'ws') defensivo) direto, sem concatenar path nenhum.

Verificado END TO END contra exp001 (modo runner, sem brabo-runner real conectado): antes da correção, a aba girava pra sempre; depois, o log do engine mostra CONNECTED TO EngineWeb.RunnerSocket → JOINED terminal:<projectId> → pty_resize descartado — sem runner conectado, e a tela mostra RunnerOnboardingPanel com o comando brabo-runner em menos de 1s — nunca o timeout de 8s do item 2, que segue como rede de segurança pro caso do engine estar genuinamente fora do ar.

  • Onde: docker/docker-compose.yml, .env.example, apps/web/src/lib/terminal-channel.ts
  • Teste: apps/web/src/lib/terminal-channel.test.ts (socket que nunca abre chama onErro dentro do timeout, sem reconexão automática do phoenix; engineWsUrl do mock corrigido pra refletir o contrato real — já com /runner, sem /websocket — e a asserção do caminho feliz passa a exigir a URL usada no Socket IGUAL ao engineWsUrl do ticket, sem concatenação)
  • ADR: nenhum — correção de bug restaurando o comportamento que o ADR 0103/0104 e o próprio docblock do módulo já declaravam como intenção
  • Origem: achado testando manualmente um projeto novo no modo runner; o item 3 só apareceu ao verificar a correção dos itens 1/2 ponta a ponta no browser contra o engine real

RN-434 — No Linux, --dir do runner local só é aceito dentro do $HOME do usuário​

O CLI brabo-runner (modo runner, ADR 0103/0104) validava só que --dir existe e é uma pasta — sem restrição nenhuma de ONDE essa pasta podia estar no sistema de arquivos. lerArgumentos() (apps/runner/src/index.ts) passou a chamar validarDirDentroDoHomeNoLinux(dir, process.platform, homedir()) (apps/runner/src/guard.ts) logo depois da checagem de existência: no Linux (process.platform === 'linux'), --dir só é aceito dentro de os.homedir() — o próprio $HOME ou qualquer subpasta dele; um caminho fora dessa árvore (/etc, /root, /, ou até outra conta em /home) é recusado com process.exit(2) e mensagem explicando o motivo e mostrando o home esperado. Fora do Linux (macOS, Windows) o comportamento não muda — a restrição é só para Linux.

A checagem reusa dentroDoEscopo/semBarraFinal, os mesmos primitivos de comparação de caminho que validarCwdDentroDaRaiz já usa no mesmo módulo, mas é deliberadamente mais simples que ela: sem realpath nem proteção a TOCTOU/symlink, porque não protege contra um SERVIDOR malicioso (aquele é o papel de validarCwdDentroDaRaiz, exercido a cada exec) — é uma checagem de STARTUP do próprio CLI local, orientando o usuário que digitou um caminho errado ao subir o runner.

  • Onde: apps/runner/src/guard.ts (DirForaDoHomeError/validarDirDentroDoHomeNoLinux), apps/runner/src/index.ts (lerArgumentos)
  • Teste: apps/runner/src/guard.spec.ts (describe('validarDirDentroDoHomeNoLinux', …) — aceita o próprio home e subpasta dele no Linux, recusa caminho fora do home e outra conta dentro de /home no Linux, e confirma que a restrição NÃO se aplica fora do Linux)
  • ADR: 0104 (extensão aditiva — o runner já era declarado "fonte da verdade do caminho"; esta regra só estreita o que ele aceita como --dir no Linux)
  • Origem: pedido explícito do dono do produto, durante um teste real de criação de projeto (tentativa de apontar --dir para um caminho fora do home, /home/dev/exp001, que não existia na máquina — levou à decisão de travar caminhos fora do $HOME no Linux)

RN-435 — --dir do runner local inexistente é criado, não recusado​

O CLI brabo-runner (modo runner, ADR 0103/0104) recusava com process.exit(2) qualquer --dir que ainda não existisse no disco — a pasta do projeto tinha de ser criada manualmente antes de subir o CLI. garantirDiretorio() (apps/runner/src/guard.ts) passou a criar a pasta automaticamente (mkdirSync(dir, { recursive: true })) quando ela não existe, e lerArgumentos() (apps/runner/src/index.ts) chama essa função LOGO DEPOIS de validarDirDentroDoHomeNoLinux (RN-434) — a ORDEM é a regra: a checagem do $HOME funciona em caminho que ainda não existe (só resolve(), sem tocar disco), então roda primeiro e continua recusando --dir fora do home no Linux mesmo quando ele ainda não existe, ANTES de qualquer tentativa de criação — criar primeiro reabriria a brecha que a RN-434 tinha acabado de fechar. --dir apontando para um ARQUIVO já existente continua erro real (DirNaoEUmaPastaError, process.exit(2), sem tentar criar nada) — este CLI nunca sobrescreve um arquivo silenciosamente. Falha na criação em si (permissão negada, disco cheio, etc.) também recusa (NaoConsegiuCriarDiretorioError), com a mensagem do erro original embutida.

  • Onde: apps/runner/src/guard.ts (garantirDiretorio/DirNaoEUmaPastaError/NaoConsegiuCriarDiretorioError), apps/runner/src/index.ts (lerArgumentos)
  • Teste: apps/runner/src/guard.spec.ts (describe('garantirDiretorio', …) — não faz nada quando a pasta já existe, cria recursivamente quando não existe, recusa sem criar quando o caminho já existe e é um arquivo, e embrulha falha de mkdir em NaoConsegiuCriarDiretorioError; e um caso a mais em describe('validarDirDentroDoHomeNoLinux', …) provando que um --dir fora do home e AINDA INEXISTENTE continua recusado, confirmando que a ordem das duas checagens não abre brecha)
  • ADR: 0104 (extensão aditiva — não muda o desenho do runner como fonte da verdade do caminho, só reduz o atrito de subir um projeto novo)
  • Origem: pedido do dono do produto, ao notar que a pasta de um projeto novo no modo runner não existia na máquina e precisava ser criada manualmente antes de rodar o CLI — o mesmo cenário que motivou a RN-434 (/home/dev/exp001 inexistente), agora resolvido criando a pasta em vez de só recusar

RN-453 — --dir relativo do runner local resolve contra INIT_CWD, não contra o cwd rebaseado do pnpm​

resolve(dirBruto) em lerArgumentos() resolvia --dir relativo contra process.cwd() — correto quando o CLI roda direto (binário standalone, npm install -g @brabo/runner), mas ERRADO no único caminho de invocação que existe hoje sem publicação real no npm (achado real, ADR 0106/backlog): pnpm --filter runner start REBASEIA process.cwd() para a pasta do PACOTE (apps/runner), não a pasta de onde o usuário digitou o comando. pnpm --filter runner start -- --dir ../exp001, rodado de ~/dev/brabo, criava ~/dev/brabo/apps/exp001 em vez de ~/dev/exp001 — silenciosamente, sem erro, porque RN-435 já faz --dir inexistente ser criado em vez de recusado.

resolverDir() (apps/runner/src/guard.ts) resolve contra INIT_CWD quando presente — a variável que npm/pnpm SEMPRE define com a pasta original de invocação, existente só quando o processo nasce de um script do package.json — e cai em process.cwd() quando ausente (binário direto, onde já é a pasta certa). resolve() do Node já ignora a base quando dirBruto é absoluto, então caminho absoluto (o caso documentado no README) não muda de comportamento.

  • Onde: apps/runner/src/guard.ts (resolverDir), apps/runner/src/index.ts (lerArgumentos)
  • Teste: apps/runner/src/guard.spec.ts (describe('resolverDir', …) — relativo contra INIT_CWD quando presente, cai no cwd do processo quando ausente, absoluto ignora as duas bases)
  • ADR: 0104 (extensão aditiva, mesma categoria de RN-435 — reduz atrito de invocação, não muda o runner como fonte da verdade do caminho)
  • Origem: achado em uso real, rodando pnpm --filter runner start -- dir ../exp001 de dentro do checkout do monorepo — o único caminho de invocação disponível hoje, porque @brabo/runner ainda não foi publicado de verdade no npm (NPM_TOKEN pendente, ver backlog do ADR 0106)

O explorador de pasta do Runner vira três colunas, e a criação de um projeto runner deixa de esperar o passo final (RN-436/436)​

Duas mudanças pedidas pelo dono do produto depois de testar a criação de um projeto real: (1) o navegador de pasta existente (FolderBrowserModal) era uma lista simples com breadcrumb, quando a referência visual que ele mandou (um picker estilo GNOME Files/GTK, de três colunas) já existia como handoff de design pronto pra adaptar; (2) "Procurar pasta..." não funcionava na TELA DE CRIAÇÃO do projeto — só depois de criado — porque o ticket do canal do Runner (ADR 0107) é ancorado a um projectId que, até esta correção, só nascia na confirmação.

RN-436 — FolderBrowserModal vira um explorador de três colunas: atalhos, lista com um clique seleciona/duplo clique entra, e um painel de detalhes​

O layout de lista única virou três colunas dentro do Modal size="full": atalhos (Pasta pessoal, que chama diretorioInicial(); Raiz, /), a lista central com breadcrumb, e um painel de detalhes à direita. O protocolo (FsEntrada, em apps/web/src/lib/fs-browser-channel.ts) só tem { nome, isDir } — sem tamanho nem data de modificação — e não foi estendido (mexeria em apps/engine/apps/runner, fora do escopo desta entrega); o painel de detalhes só mostra o que dá pra derivar client-side: nome, tipo, e a contagem de itens quando o item exibido é a pasta JÁ ABERTA (nunca de uma pasta só selecionada, que exigiria uma chamada extra ao runner).

Os DOIS gestos que antes estavam fundidos num único clique (que já navegava pra dentro da pasta) foram separados: um clique agora só SELECIONA (destaca o item e atualiza o painel de detalhes) e um duplo clique ENTRA. O botão final ("Usar esta pasta", renomeado de "Selecionar esta pasta" — a chave folderBrowserModal.select de pt-BR/en mudou junto) usa o item selecionado quando houver um (selected.kind === 'dir' ? join(path, selected.name) : path) ou a pasta aberta no momento quando não houver nenhum. A lista deixou de filtrar arquivos — a listagem inteira aparece agora, com arquivos renderizados como <div aria-disabled> em vez de <button>: visualmente apagados e sem gesto nenhum, porque só pasta continua navegável/selecionável.

  • Onde: apps/web/src/components/FolderBrowserModal.tsx, FolderBrowserModal.module.css, apps/web/src/locales/{pt-BR,en}/terminal.json
  • Teste: apps/web/src/components/FolderBrowserModal.test.tsx — clique único seleciona sem chamar listarDiretorio de novo; duplo clique navega; arquivo aparece na lista mas não é alcançável por getByRole('button'); atalhos "Pasta pessoal"/"Raiz"; e "Usar esta pasta" usa o selecionado quando houver, senão a pasta aberta
  • ADR: nenhum — redesenho de UI sobre um componente que já existia, sem mudança de protocolo nem de fronteira de dado (o ADR 0107 continua descrevendo o mecanismo por trás)
  • Origem: pedido do dono do produto, depois de testar a criação de um projeto real e achar o navegador de pasta pouco parecido com um explorador de arquivos de verdade — referência visual enviada por ele (picker de três colunas, estilo GNOME Files/GTK)

RN-437 — No modo runner, "Procurar pasta..." cria o projeto ANTECIPADAMENTE, e a confirmação final reusa por SNAPSHOT de identidade​

O ADR 0107 já tinha declarado esta lacuna na própria seção de Consequências: sem projeto ainda, o ticket do canal (RN-429) não tem a quem se ancorar, e "Procurar pasta..." caía sempre no estado "disponível depois que o projeto existir" — mesmo no modo runner, onde a criação NÃO depende de validar disco (ADR 0104, item 2). Fechado pelo ADR 0108: NewProjectWizard.tsx (handleProcurarPasta) chama createProject ao clicar "Procurar pasta..." quando modoDeWorkspace === 'runner' — usando o caminho já digitado, ou um placeholder lexicalmente válido e claramente provisório (/workspace-a-confirmar) quando o campo ainda está vazio, nunca bloqueando o clique. O modo mounted não muda: continua com projectId={null} até a confirmação, porque ali a validação de disco roda na criação e criar cedo com caminho vazio devolveria a recusa da RN-422 num momento em que o usuário nem pensou em caminho ainda.

Um SNAPSHOT de identidade ({ name, externalId, adotando } — nunca caminhoLocal, cujo refino é o PROPÓSITO de navegar) decide quando reusar: clicar "Procurar pasta..." de novo com a identidade intacta reabre o modal com o MESMO projeto, sem criar outro; mudar o nome (ou voltar e trocar o repositório a adotar) invalida o snapshot, e a próxima navegada cria um projeto novo. handleConfirm aplica a mesma régua — reusa o projeto já criado ao navegar quando o snapshot ainda bate, e só chama createProject quando não há nada pra reusar. montarPayloadDeCriacao, função pura extraída do que antes vivia só dentro de handleConfirm, é reaproveitada pelos dois caminhos.

O pré-requisito do botão é dito ANTES do clique (AT-214): neste modo a lista vem do agente local, então sem um brabo-runner rodando e conectado ao projeto recém-criado o modal só consegue mostrar a espera e o onboarding. A tela passou a dizer isso em TEXTO abaixo do campo (aviso-procurar-runner, ADR 0064 — nunca tooltip), com o comando brabo-runner --project <id> --dir <pasta> e a alternativa que não depende de nada: digitar o caminho no campo, que o runner confirma ao conectar. O mecanismo (a criação antecipada, o transporte pelo agente local) não mudou.

  • Onde: apps/web/src/routes/NewProjectWizard.tsx (handleProcurarPasta, handleConfirm, montarPayloadDeCriacao, snapshotDeIdentidade/mesmaIdentidade)
  • Teste: apps/web/src/routes/NewProjectWizard.test.tsx, describe navegação de pasta antecipada no modo Runner — cria o projeto e abre o modal com o id real; usa o placeholder quando o campo está vazio; clicar de novo sem mudar nada não cria outro; mudar o nome invalida o snapshot e cria um projeto diferente; handleConfirm reusa em vez de duplicar. O teste preexistente "'Procurar pasta...' mostra o estado declarado" (modo mounted) continua verde, sem alteração — prova que o escopo não vazou
  • ADR: 0108 — mudança estrutural (QUANDO um projeto passa a existir no fluxo de criação), com o efeito colateral aceito de projeto "não provisionado" órfão quando o wizard é fechado sem terminar (o MESMO estado que qualquer criação interrompida hoje já produz, não uma regressão nova)
  • Origem: pedido do dono do produto, depois de testar a criação de um projeto real no modo Runner e não conseguir navegar pastas antes de confirmar — a lacuna que o ADR 0107 já tinha declarado e adiado

RN-438 — fs-browser-channel.ts tinha o MESMO bug de path duplicado que a RN-433 já tinha corrigido no terminal-channel.ts irmão​

Achado ao verificar a RN-437 ponta a ponta no Chrome, contra o engine real: o modal abria com o projectId certo, mas a conexão caía na hora com "A conexão com o runner caiu — feche e reabra para tentar de novo." em vez de mostrar RunnerOnboardingPanel. apps/web/src/lib/fs-browser-channel.ts concatenava '/runner/websocket' a um engineWsUrl que a api já devolve PRONTO (ws://host:porta/runner, de engineWsUrlPublico()) — o mesmo Socket do phoenix.js ainda acrescenta /websocket sozinho no construtor, e o resultado batia no engine como GET /runner/runner/websocket/websocket, que Phoenix.Router.NoRouteError recusa. É EXATAMENTE o item 3 da RN-433, só que no módulo IRMÃO — a correção de lá nunca tinha sido replicada aqui, e nenhuma suite pegava porque este módulo não tinha teste nenhum até agora (o próprio docblock dizia, incorretamente, que terminal-channel.test.ts cobria "indiretamente"). Fix: mesma linha da RN-433 — engineWsUrl.replace(/^http/, 'ws'), sem concatenar path nenhum.

Verificado END TO END contra um projeto runner real (owner logado, teste-navegacao, criado pela RN-437): antes da correção, o modal caía direto no erro de conexão; depois, mostra RunnerOnboardingPanel com o comando brabo-runner --project <id-real> --dir <pasta> em menos de 2s — o resultado esperado sem um runner de verdade conectado.

  • Onde: apps/web/src/lib/fs-browser-channel.ts
  • Teste: apps/web/src/lib/fs-browser-channel.test.ts (novo — o módulo não tinha teste próprio nenhum até esta correção). Caminho feliz confirma socket.url === engineWsUrl sem concatenação; mais diretorioInicial/listarDiretorio roundtrip, falha ao buscar ticket, e fechar() idempotente
  • ADR: nenhum — restaura o comportamento que o ADR 0103/0107 e a RN-433 já declaravam como intenção, só que num módulo que a correção anterior não alcançou
  • Origem: achado testando manualmente a RN-437 (criação antecipada de projeto runner) contra o Chrome e o engine real — a primeira vez que FolderBrowserModal foi exercitado ponta a ponta contra uma conexão de verdade, porque antes desta entrega "Procurar pasta..." nunca alcançava um projeto real antes da confirmação

RN-439 — POST .../runner-ticket autentica E autoriza no MESMO guard; RolesGuard (global) se abstém em rota @RequirePatAuth()​

POST /projects/:projectId/runner-ticket respondia 403 "Não autenticado" para TODO PAT, mesmo válido, recém-emitido, escopado ao projeto certo — achado numa verificação AO VIVO do brabo-runner conectando a um projeto real, nunca por teste automatizado. Causa raiz: JwtAuthGuard e RolesGuard são os dois APP_GUARD — GLOBAIS — e um guard global SEMPRE roda ANTES de um guard LOCAL de rota (@UseGuards), não importa a ordem dos decorators no controller. JwtAuthGuard já sabia se abster nesta rota (o desvio de IS_PAT_ROUTE_KEY já existia, sem popular request.user, contando com PatAuthGuard pra autenticar depois); RolesGuard não sabia, e recusava toda chamada com request.user ainda vazio — PatAuthGuard nunca chegava a rodar. O comentário do controller descrevia a ordem ao contrário ("RolesGuard roda DEPOIS do PatAuthGuard"), e nunca foi assim.

A correção seguiu o MESMO padrão que JwtAuthGuard já usava para este problema: RolesGuard ganhou o desvio gêmeo (IS_PAT_ROUTE_KEY → retorna true sem checar request.user), delegando a autorização inteira para quem passa a ser o ÚNICO guard rodando depois de request.user populado nesta rota — o próprio PatAuthGuard, que agora também lê @RequireRole (via Reflector) e resolve o papel efetivo (ResolveEffectiveRoleUseCase.forProject), recusando com a MESMA mensagem que RolesGuard usaria ("Papel insuficiente para esta ação"). A lógica é duplicada entre os dois guards de PROPÓSITO — RolesGuard mora em iam, PatAuthGuard mora em auth, e uma dependência cruzada só para reusar ~10 linhas não valeria a pena.

Um SEGUNDO defeito ficou escondido atrás do primeiro, e só apareceu quando o teste de integração passou a exercitar os dois guards JUNTOS: PatAuthGuard comparava o token BRUTO direto contra personal_access_tokens.token_hash em validarEUsar — que sempre espera o HASH (hashDeToken, HMAC-SHA256+ pepper, o mesmo que TokenFactory/IssuePersonalAccessTokenUseCase usam para gravar). Corrigido para this.tokens.validarEUsar(hashDeToken(token)). Enquanto RolesGuard recusava tudo antes, este segundo bug nunca chegava a se manifestar — corrigir só o primeiro teria trocado um 403 sempre por um 401 sempre, e o runner continuaria nunca conectando de verdade.

  • Onde: apps/api/src/interfaces/http/iam/roles.guard.ts (desvio IS_PAT_ROUTE_KEY); apps/api/src/interfaces/http/auth/pat-auth.guard.ts (autorização por papel + hash do token); apps/api/src/interfaces/http/runner/runner-http.module.ts (IamUseCasesModule importado, pelo ResolveEffectiveRoleUseCase que PatAuthGuard passou a precisar); comentário corrigido em apps/api/src/interfaces/http/runner/runner-tickets.controller.ts
  • Teste: apps/api/test/interfaces/pat-auth.guard.spec.ts (autorização por papel isolada + hash do token); apps/api/test/interfaces/roles.guard.spec.ts (desvio IS_PAT_ROUTE_KEY); apps/api/test/interfaces/http/runner/runner-tickets.guards.integration.spec.ts (NOVO — sobe JwtAuthGuard+RolesGuard+PatAuthGuard num Nest real, com supertest, na MESMA ordem relativa do AppModule: é o teste que faltava, porque cada guard só era exercitado isolado antes disto). Verificado também AO VIVO: brabo-runner conectou de verdade a um projeto runner real com um PAT emitido depois da correção
  • ADR: 0105 — correção de bug na implementação da decisão já tomada lá (PAT autentica esta rota), não uma decisão nova
  • Origem: achado rodando o brabo-runner de verdade contra um projeto real e isolando com curl direto — nunca por teste automatizado, porque nenhuma suíte exercitava RolesGuard e PatAuthGuard na mesma requisição

RN-443 — Budget de área é ADITIVO ao de projeto/sessão, nunca cascata; só maintainer muda o teto​

Fecha o item "budget por área" do corte do ADR 0038, em aberto desde a FASE 8. agent_areas ganha budget_micros (nullable — null é SEM TETO, o default) e spent_micros (NOT NULL DEFAULT 0), espelhando exatamente max_parallel: mesma linha, mesmo dono da decisão, mesmo AgentAreaRepository. Não é a cascata de binding de modelo do ADR 0064 (sessão > agente > área > projeto > workspace, "o mais específico vence") — os dois mecanismos usam a palavra "área" e não têm mais nada em comum. Budget de área é um TERCEIRO teto independente ao lado dos de projeto e sessão que já existiam: CheckBudgetGateUseCase.execute agora resolve a área do agentId (via areaDo, função pura, sem tocar banco a menos que ache correspondência) e checa os três em paralelo — qualquer um bloqueado já recusa a chamada, sem hierarquia entre eles.

RecordLlmUsageUseCase incrementa spent_micros da área do ator SEMPRE que ele pertence a uma (lead ou membro — areaDo devolve área pros dois), com ou sem budget_micros configurado: o gasto real da área fica visível antes mesmo de alguém configurar um teto. Ator sem área (usuário no chat, agente fora de qualquer área) não grava nada — não é omissão silenciosa, é o comportamento correto quando não há o que incrementar.

SetAreaBudgetUseCase exige maintainer (mesma régua de SetAreaMaxParallelUseCase — mudar quanto o produto pode gastar sem perguntar), converte dólar→micro-USD no controller (mesma convenção de BudgetsController) e não emite evento de domínio (config de projeto, sem sessão pra gravar — mesmo raciocínio já registrado em SetAreaMaxParallelUseCase). null LIMPA o teto — campo obrigatório que aceita null como valor válido (ValidateIf, não IsOptional), mesmo padrão de RenameSessionDto.

  • Onde: apps/api/src/db/schema/agents.ts (agentAreas, colunas budgetMicros/spentMicros e os dois CHECK); apps/api/src/domain/llm/area-budget.ts (isAreaBudgetExceeded); apps/api/src/application/use-cases/llm/check-budget-gate.use-case.ts; apps/api/src/application/use-cases/llm/record-llm-usage.use-case.ts; apps/api/src/application/use-cases/execution/set-area-budget.use-case.ts; apps/api/src/infrastructure/persistence/drizzle/agent-area.repository.ts (setBudget/incrementSpent); apps/api/src/interfaces/http/execution/execution.controller.ts (PUT agent-areas/:key/budget); apps/web/src/routes/settings/BudgetSection.tsx
  • Teste: apps/api/test/application/use-cases/llm/check-budget-gate.use-case.spec.ts (describe budget de área — aditivo, não cascata: área excedida bloqueia com projeto/sessão OK e vice-versa, sem teto nunca bloqueia, agente sem área não é afetado); apps/api/test/application/use-cases/llm/record-llm-usage.use-case.spec.ts (describe gasto por área: incrementa pro membro e pro lead, não faz nada nocivo sem área, soma sem teto configurado); apps/api/test/application/use-cases/execution/set-area-budget.use-case.spec.ts (grava, limpa com null, recusa negativo/NaN/Infinity, zero é válido); apps/api/test/infrastructure/persistence/drizzle/agent-area.repository.spec.ts (setBudget/incrementSpent, atomicidade sob concorrência)
  • ADR: 0110
  • Origem: item de backlog aprovado pelo dono do produto numa sessão de planejamento explícita — docs/explanation/backlog.md, tabela "Older backlog"

RN-440 — addressableAgents() é o catálogo FECHADO do handoff manual, mais estrito que assertHandoffTargetAllowed​

Handoff manual a agente à escolha (backlog, ADR 0109): assertHandoffTargetAllowed (ADR 0038) só recusa um SUBAGENTE de área — um agente citando "abc" como alvo passaria por ela sem erro, porque uma AGENTE só cita alvos que ele já conhece pela própria instrução. Isso deixa de ser verdade quando quem escolhe é um HUMANO, num <select> alimentado por um cliente que pode divergir do backend. addressableAgents() (apps/api/src/domain/agents/agent-areas.ts) é o catálogo FECHADO — leads de área (AGENT_AREAS.map(a => a.lead)) ∪ SOLO_CONVERSATIONAL_AGENTS (criativo, po, arquiteto, ux-designer, staff) — e RequestManualHandoffUseCase recusa com 400 qualquer toAgent fora dele, ANTES de chamar CreateHandoffUseCase.

SOLO_CONVERSATIONAL_AGENTS é uma lista PRÓPRIA, não derivada do roster apps/web/src/lib/agents.ts (que também lista agentes de gate e o Psicólogo/Anamnese, nenhum endereçável por handoff). A direção da derivação é a da api para o web: desde a AT-046 a cópia do web deixou de ser mirror MANUAL e sai do MESMO gerador das áreas (pnpm --filter api gerar:areas, Fase 18), em apps/web/src/lib/agent-areas.generated.ts, que agents.ts só reexporta. agent-areas.spec.ts reprova quando o disco diverge do que o gerador produz, e a lista gerada sai as const satisfies readonly AgentKey[] — um nome que o roster do web não conhece quebra o build do web. O engine NÃO recebe a lista (não há consumidor lá). Mesmo com as duas cópias travadas, quem decide o que é aceito continua sendo o RequestManualHandoffUseCase.

  • Onde: apps/api/src/domain/agents/agent-areas.ts (addressableAgents, SOLO_CONVERSATIONAL_AGENTS); apps/api/src/application/use-cases/agents/request-manual-handoff.use-case.ts; apps/api/scripts/gerar-areas.ts (renderWeb, que escreve a lista no web); apps/web/src/lib/agent-areas.generated.ts (cópia GERADA, reexportada por apps/web/src/lib/agents.ts)
  • Teste: apps/api/test/domain/agents/agent-areas.spec.ts (describe('addressableAgents (ADR 0109)') e describe('a lista solo sai do gerador para o web (AT-046)'), mais o aferidor de frescor do arquivo do web); apps/api/test/application/use-cases/agents/request-manual-handoff.use-case.spec.ts (recusa subagente E recusa agente desconhecido); apps/web/src/lib/agents.test.ts
  • ADR: 0109
  • Origem: backlog do modelo de time — item aberto desde a FASE 13c, fechado pelo caso real do Staff (ADR 0088) e do UX Designer (ADR 0087), os dois com plumbing de engine pronto e nenhum caminho humano até eles

RN-441 — POST .../sessions/:sessionId/handoffs exige developer; handoff manual nasce offered, sem estado novo​

O único caminho para gravar toAgent continua sendo CreateHandoffUseCase (ADR 0038) — não um segundo mecanismo. RequestManualHandoffUseCase resolve fromAgent sozinho (o agent.activated mais RECENTE da sessão, via SessionEventRepository.listByTypeInSession — mesmo critério de activeAgent em SessionPage.tsx, achado 9-fix; sessão sem nenhum agente ativado ainda cai no sentinela "usuario", nunca um nome adivinhado) e chama CreateHandoffUseCase.execute passando actor: {kind: 'user', id: userId} — CreateHandoffInput ganhou o campo opcional actor?: Actor para isso, com default {kind:'agent', id: fromAgent} preservando o comportamento de sempre para o chamador interno (o engine). O handoff nasce offered, do MESMO jeito que um automático, e o card de aceite já existente (offeredHandoff/handleAcceptHandoff em SessionPage.tsx) o pega sozinho no próximo poll de useHandoffs — sem NENHUMA mudança no caminho de aceite. (Não pegava, e ninguém viu até a AT-253: o card casava a oferta pelo ATOR, e o ator do manual é a pessoa. Corrigido na RN-631, que casa pelo handoffId.)

A rota (POST projects/:projectId/sessions/:sessionId/handoffs) exige papel developer, o mesmo de handoffs/:handoffId/accept (RN-136: quem CONVERSA nesta tela). ux-designer e staff entraram em AGENTES_DE_CHAT (SessionPage.tsx) na mesma mudança — as duas cláusulas de message/2 já existiam em agent_command_controller.ex (ADR 0087/0088), verificado por leitura ANTES de escrever qualquer linha de web, e nenhuma delas tinha caminho humano até si.

  • Onde: apps/api/src/interfaces/http/agents/agents.controller.ts (requestManual); apps/api/src/interfaces/http/agents/dto/request-manual-handoff.dto.ts; apps/api/src/application/use-cases/agents/create-handoff.use-case.ts (actor?: Actor); apps/web/src/routes/SessionPage.tsx (AGENTES_DE_CHAT, .manualHandoffRow, handleRequestManualHandoff); apps/web/src/lib/api-client.ts (requestManualHandoff)
  • Teste: apps/api/test/interfaces/http/agents/agents.controller.spec.ts (papel exigido); apps/api/test/application/use-cases/agents/request-manual-handoff.use-case.spec.ts (fromAgent derivado, sentinela "usuario", actor: user); docs/security-surface.md + apps/api/test/interfaces/route-surface.spec.ts (classificação role:developer em runtime)
  • ADR: 0109
  • Origem: backlog do modelo de time (ver RN-440)

RN-447 — Converter execution_mode de projeto EXISTENTE recusa (409) enquanto qualquer dev agent do projeto não está idle​

ConvertProjectExecutionModeUseCase (PUT .../execution-mode, papel maintainer) lê engine.dev_agent_states DIRETO (cross-schema, mesmo banco físico — mesmo caminho da RN-409) por project_id, em TODAS as sessões do projeto, não só a mais recente. Qualquer status diferente de idle (working/blocked/idle_tripped/awaiting_gate/ awaiting_approval) recusa a conversão: Engine.Dev.DevAgentServer NÃO re-resolve o worktree sozinho — workspace_root é capturado UMA vez, na criação do worktree, e trocar a coluna por baixo dele deixaria o agente escrevendo num escopo que o permissions.json e a política de terminal (ADR 0055) já abandonaram. A decisão é RECUSAR e explicar (mesmo padrão "recusa e ensina" da RN-088/RN-422) — nunca drenar ou forçar a migração de um agente vivo.

idle_tripped conta como ATIVO aqui, diferente da RN-409 (que o exclui de onlineAgentCount): as duas perguntas são diferentes — "alguém está olhando este agente trabalhar agora" (RN-409) contra "existe um ponteiro de escopo que ficaria obsoleto" (aqui). Um agente com o circuit breaker disparado ainda tem workspace_root capturado, esperando desbloqueio humano — não é "ocioso" para efeito de conversão, mesmo não sendo "online" para efeito de contagem.

Mesmo (modo, caminho) de hoje é NO-OP: a checagem de dev agent nem roda — reenviar o formulário sem mudar nada não deveria custar uma varredura de agentes ativos.

  • Onde: apps/api/src/application/use-cases/iam/convert-project-execution-mode.use-case.ts; apps/api/src/application/ports/dev-agent-activity.port.ts; apps/api/src/infrastructure/persistence/drizzle/dev-agent-activity.repository.ts
  • Teste: apps/api/test/application/use-cases/iam/convert-project-execution-mode.use-case.spec.ts (recusa com dev agent ativo, sem gravar nada; no-op não checa); apps/api/test/infrastructure/persistence/drizzle/dev-agent-activity.repository.spec.ts (idle não conta, todo o resto conta, isolamento entre projetos)
  • ADR: 0111
  • Origem: correção registrada em docs/explanation/backlog.md durante a implementação da Onda 1 do runner (ADR 0104), fechada nesta Onda 2

RN-448 — permissions.json é RELOCALIZADO na conversão — o conteúdo nunca muda, só o caminho​

PermissionsFileStore.move(from, to) lê o arquivo na localização ANTIGA (projectScopeRoot(localAntiga)), grava o MESMO conteúdo na localização NOVA e apaga o antigo (best-effort — se já não existir, não há o que apagar). O conteúdo (padrões allow/deny/ask) não carrega caminho nem modo dentro de si, então não há nada para REESCREVER, só para mover. from === to (nenhuma raiz efetiva mudou) é no-op.

Confirmado por leitura de projectScopeRoot (project-workspaces-root.ts): para mounted/runner a raiz É o workspacePath, tratado como caminho DENTRO DO CONTAINER DA API — em runner, isso já era verdade ANTES desta entrega (sem bind-mount, o arquivo mora numa pasta que só coincide em STRING com a pasta real do usuário, desconectada dela). A conversão não muda essa propriedade existente, só a relocaliza de forma consistente — sem caso especial para runner além de usar move() como qualquer outro par.

  • Onde: apps/api/src/application/ports/permissions-file-store.port.ts (move); apps/api/src/infrastructure/filesystem/fs-permissions-file-store.ts
  • Teste: apps/api/test/infrastructure/filesystem/fs-permissions-file-store.spec.ts (conteúdo sobrevive e é apagado da origem; origem sem arquivo grava vazio sem lançar; from === to é no-op)
  • ADR: 0111

RN-449 — Saindo de container, o ciclo de vida do container é encerrado (removed) ANTES da coluna mudar​

ConvertProjectExecutionModeUseCase chama RegistrarTransicaoDeContainerUseCase (ADR 0081) para levar a linha de project_containers a removed — via stopped primeiro quando está running (container-lifecycle.ts não tem aresta direta running -> removed) — ANTES de gravar o novo execution_mode. A ordem é obrigatória: RegistrarTransicaoDeContainerUseCase recusa (400) qualquer transição num projeto que não esteja em execution_mode = 'container' NO MOMENTO da chamada, então chamá-lo depois de trocar a coluna sempre falharia. Sem linha de container (projeto que nunca provisionou), nenhuma transição é disparada.

Entrar em container a partir de mounted/runner NÃO auto-provisiona nada — o portão da imagem do Arquiteto (RN-105) e o ciclo de vida normal valem a partir daí, como para qualquer projeto container — nunca um atalho que pula o portão porque o projeto "já tinha código em algum lugar".

  • Onde: apps/api/src/application/use-cases/iam/convert-project-execution-mode.use-case.ts (removerContainerSeExistir)
  • Teste: apps/api/test/application/use-cases/iam/convert-project-execution-mode.use-case.spec.ts (running passa por stopped; provisioning vai direto; sem linha, nenhuma transição; entrar em container nunca dispara transição)
  • ADR: 0111

RN-450 — workspaceVerifiedAt zera em TODA conversão real, mesmo voltando para runner com o "mesmo" caminho​

Só faz sentido em execution_mode: 'runner' (RN-423): um timestamp provando que um runner CONECTADO confirmou o caminho no host real. Qualquer conversão que muda de fato o par (modo, caminho) — incluindo uma que pousa de novo em runner com um caminho de aparência igual — zera o campo, forçando confirmação NOVA: o timestamp antigo atesta uma verificação que aconteceu sob um par DIFERENTE, e carregá-lo adiante afirmaria uma confirmação que nunca aconteceu para o estado novo. O no-op (RN-447) é a única exceção — nada muda, então nada zera.

  • Onde: apps/api/src/application/use-cases/iam/convert-project-execution-mode.use-case.ts
  • Teste: apps/api/test/application/use-cases/iam/convert-project-execution-mode.use-case.spec.ts (runner -> container zera; no-op preserva)
  • ADR: 0111

RN-451 — Smoke do binário standalone roda o SUBPROCESSO real, nunca mock​

O mesmo padrão de disciplina que smoke-dist.mjs (ADR 0106) já aplica ao dist/index.cjs publicado no npm passa a valer também para dist-bin/brabo-runner-<platform>-<arch>[.exe] (ADR 0112): nenhuma suíte prova que um artefato EMPACOTADO funciona lendo o código-fonte dele — só executando o artefato de verdade, como subprocesso, e observando saída real. scripts/smoke-bin.mjs roda o binário COMPILADO (nunca node src/index.ts nem qualquer forma não-compilada) com --self-test-pty — uma flag interna, não documentada em uso() — que resolve node-pty exatamente como produção e spawna um PTY real via GerenciadorDePty, escrevendo e lendo dele. Só a linha node-pty carregado com sucesso + SELF_TEST_PTY_OK: no stdout do processo real conta como prova; nenhum mock de node-pty nem de child_process é aceitável para este teste específico, porque o que ele existe para provar é justamente que o .node nativo embutido (with { type: 'file' }, native-pty-embed.generated.ts) carrega e funciona DENTRO do binário — mockar qualquer peça do caminho apagaria a única coisa que o teste precisa provar.

  • Onde: apps/runner/scripts/smoke-bin.mjs; apps/runner/src/index.ts (rodarAutoTestePty, a implementação de --self-test-pty)
  • Teste: o próprio smoke-bin.mjs — não há teste unitário que o substitua, de propósito (é o mesmo desenho de smoke-dist.mjs, que também não tem par unitário)
  • ADR: 0112
  • Origem: requisito explícito do dono do produto para este item de backlog ("no mocking")

RN-452 — node-pty resolvido por injeção, não import estático — e a lacuna que isso abriu no smoke do npm foi fechada no mesmo commit​

pty.ts deixou de fazer import * as nodePty from 'node-pty' estático no topo do módulo — passou a receber o módulo já resolvido por injeção no construtor de GerenciadorDePty, resolvido uma vez em main() (src/index.ts) via native-pty-loader.ts#carregarNodePty(). A mudança existe para o binário standalone (ADR 0112): o caminho compilado precisa extrair os arquivos embutidos pra um diretório real ANTES de resolver node-pty, o que exige uma chamada assíncrona — incompatível com um import estático hoisted. Fora do binário compilado, o comportamento é idêntico a antes (await import('node-pty'), resolvido do node_modules de quem instalou o pacote).

A lacuna que a mudança abriu, fechada no MESMO commit: a ADR 0106 já registrava que o import estático de node-pty, por ser hoisted antes de qualquer parsing de argumento, fazia smoke-dist.mjs provar que o binding nativo carregava só por IMPORTAR dist/index.cjs — mesmo no caminho de uso() (zero argumentos), que nunca chega a main(). Mover a resolução pra dentro de main() quebrou essa garantia em silêncio, porque smoke-dist.mjs só exercitava o caminho de uso(). smoke-dist.mjs ganhou uma terceira checagem — roda o CLI com argumentos válidos (mas sem api/engine reais do outro lado) e espera a linha node-pty carregado com sucesso no stdout antes de matar o processo — fechando a lacuna que a própria mudança abriu, em vez de deixá-la como perda de cobertura silenciosa.

  • Onde: apps/runner/src/pty.ts (GerenciadorDePty, injeção de NodePtyModule); apps/runner/src/native-pty-loader.ts (carregarNodePty); apps/runner/src/index.ts (main, resolve uma vez); apps/runner/scripts/smoke-dist.mjs (terceira checagem, verificarNodePtyCarrega)
  • Teste: apps/runner/scripts/smoke-dist.mjs (caminho npm/tsup) e apps/runner/scripts/smoke-bin.mjs (caminho binário, RN-451) — os dois únicos lugares que exercitam o artefato empacotado de verdade, nenhum teste unitário substitui
  • ADR: 0112
  • Origem: necessidade técnica do binário standalone; a lacuna no smoke do npm foi achada por auditoria própria da mudança, não por execução real reportando falha

RN-454 — A aba Insights sabe que o Psicólogo está pausado ANTES de o usuário esbarrar no 503​

Achado por USO: a aba Insights, com zero hipóteses, mostrava "Sem hipóteses ainda — o Psicólogo analisa cada sessão encerrada" mesmo com PSYCHOLOGIST_ENABLED=false — a mesma frase que aparece quando o Psicólogo está ATIVO e só ainda não rodou. As duas situações são indistinguíveis pelo texto, o que é a mesma classe de defeito que a RN-088/RN-107 já fecharam para outras telas: um estado que existe e o produto sabe, mas não mostra.

A RN-117 já cobria a descoberta da pausa, mas só no CLIQUE de "Reanalisar" (503 → PsychologistDisabledError) — e esse botão só existe na faixa de análises, que só aparece quando runs.length > 0. Uma sessão sem hipótese nenhuma nunca chega perto dele, então a pausa era invisível justamente na tela vazia.

GET /internal/psychologist/status (engine, PsychologistCommandController.status/2) é leitura pura de PsychologistWorker.enabled?/0 — SEM efeito colateral, diferente de /reanalyze, que cria um job quando ativado. A api expõe GET /projects/:projectId/psychologist/status (GetPsychologistStatusUseCase, role:viewer) por cima disso — projeto na URL só por consistência com as rotas irmãs (hypotheses, psychologist/analyses); a flag em si é GLOBAL, como a RN-117 já registra. ProjectInsightsTab.tsx consome essa leitura (usePsychologistStatus) e escolhe a frase do estado vazio por ela: pausado mostra "O Psicólogo está pausado — nenhuma sessão é analisada até ser reativado" (insights.projectInsightsTab.emptyPaused); do contrário, mantém a frase original, que É honesta quando a feature está de fato ativa. O aviso persistente (pausedNotice) e os botões de "Reanalisar" também passaram a refletir essa leitura proativa, e não só o useState descoberto pelo 503 — que continua existindo, como reforço, para o caso raro de a flag mudar EM VOO entre a leitura de status e o clique.

  • Onde: apps/engine/lib/engine_web/controllers/psychologist_command_controller.ex (status/2), apps/engine/lib/engine_web/router.ex (GET /internal/psychologist/status), apps/api/src/application/ports/api-to-engine-client.port.ts (getPsychologistStatus), apps/api/src/infrastructure/http-clients/api-to-engine-client.ts, apps/api/src/application/use-cases/execution/get-psychologist-status.use-case.ts, apps/api/src/interfaces/http/psychologist/psychologist.controller.ts (status), apps/web/src/lib/api-client.ts (getPsychologistStatus), apps/web/src/lib/hooks.ts (usePsychologistStatus), apps/web/src/routes/ProjectInsightsTab.tsx
  • Teste: apps/engine/test/engine_web/controllers/psychologist_command_controller_test.exs (status/2, enabled: true/false), apps/api/test/infrastructure/http-clients/api-to-engine-client.spec.ts (getPsychologistStatus), apps/api/test/application/use-cases/execution/get-psychologist-status.use-case.spec.ts, apps/web/src/routes/ProjectInsightsTab.test.tsx
  • Borda: a flag continua GLOBAL (ver RN-117) — a rota da api aceita projectId na URL só para bater com o padrão das rotas irmãs do controller, e não porque a resposta varia por projeto.
  • Origem: achado por USO real navegando a aba Insights com PSYCHOLOGIST_ENABLED=false, não roteiro. Sem ADR — extensão pontual do mecanismo de leitura já existente da RN-117, mesmo padrão da RN-088/ RN-107 para o resto do produto.

Pasta local anexada vira o quarto escopo do índice RAG, lido pelo NAVEGADOR (RN-455..457, ADR 0113)​

Pedido do dono do produto: anexar uma pasta da PRÓPRIA máquina do usuário a um projeto como referência de leitura para os agentes — sem exigir o CLI brabo-runner (ADR 0103), diferente de execution_mode: runner (ADR 0104), que precisa de um caminho de HOST real porque roteia comando para lá. O navegador lê o CONTEÚDO dos arquivos (File.text()) e o caminho RELATIVO dentro da pasta escolhida (File.webkitRelativePath) — nunca um caminho absoluto de máquina, porque a API de File do navegador não expõe um, para nenhum site. O que atravessa a rede é texto que o navegador já tinha o direito de ler, o mesmo modelo de confiança de qualquer upload de arquivo comum.

RN-455 — chunks.scope ganha 'local', reusando o pipeline de RAG inteiro​

ChunkScope passa de 'docs' | 'adr' | 'session' para incluir 'local' (migração 0052, ALTER TYPE ... ADD VALUE) — aditivo, sem migração de dado, sem CHECK novo: os dois CHECK de chunks (migração 0045) já são escritos como "é session ou não é", então local cai do mesmo lado de docs/adr (tem source_path, não tem session_id) sem mudança nenhuma além do valor do enum. origemDoChunk (domain/rag/rag-citation.ts) não precisou de nenhum ramo novo: um chunk local carrega sourcePath exatamente como docs/adr, então cai no kind: 'file' que a citação já sabia renderizar.

  • Onde: apps/api/src/db/schema/rag.ts (chunkScopeEnum), apps/api/src/db/migrations/0052_chunks_local_scope.sql, apps/api/src/application/ports/chunk-repository.port.ts (ChunkScope), apps/api/src/application/use-cases/rag/index-local-folder.use-case.ts
  • Teste: apps/api/test/application/use-cases/rag/index-local-folder.use-case.spec.ts (caminho feliz e full rebuild), apps/api/test/interfaces/http/rag/rag.controller.spec.ts
  • ADR: 0113
  • Origem: pedido do dono do produto

RN-456 — Teto agregado REJEITA (400) o upload inteiro; arquivo individual grande/binário só é PULADO​

Diferente de docs/adr (uma varredura em background, sem ninguém olhando), anexar uma pasta é um gesto ÚNICO com um seletor de pasta na tela. IndexLocalFolderUseCase recusa (400) o lote inteiro quando a quantidade de arquivos (RAG_LOCAL_FILE_COUNT_LIMIT, 500) ou os bytes somados (RAG_LOCAL_TOTAL_BYTES_LIMIT, 8 MiB) estouram — nunca trunca em silêncio, porque quem clicou "Anexar" pode escolher uma pasta menor. Um arquivo individual grande demais (RAG_LOCAL_FILE_BYTES_LIMIT, 512 KiB) ou de extensão não reconhecida (RAG_LOCAL_ALLOWED_EXTENSIONS, allowlist) é só PULADO (filesSkipped), nunca derruba o lote — a mesma distinção que IndexProjectDocsUseCase já faz implicitamente ao filtrar só .md. Caminho com .. ou barra inicial é RECUSADO (400), nunca aceito silenciosamente, mesma disciplina de nunca confiar em caminho vindo do cliente (RN-092/095) mesmo quando ele "não deveria" conter isso. apps/web/src/lib/rag-local-limits.ts espelha os mesmos números no cliente, só como conveniência de UX (resumo antes de enviar) — quem garante de verdade é o servidor.

  • Onde: apps/api/src/domain/rag/rag-search-limits.ts (RAG_LOCAL_*), apps/api/src/application/use-cases/rag/index-local-folder.use-case.ts
  • Teste: apps/api/test/application/use-cases/rag/index-local-folder.use-case.spec.ts (tetos de quantidade e bytes somados rejeitam; arquivo grande/binário é pulado; caminho com ../barra inicial é recusado)
  • ADR: 0113
  • Origem: pedido do dono do produto

RN-457 — maintainer, e reanexar é o MECANISMO de resincronizar — nunca o "Reindexar agora" genérico​

POST .../rag/local exige maintainer, mesma régua de POST .../rag/reindex (RN-238): as duas chamam o provider de embedding e substituem o que o projeto já tinha indexado. ReindexProjectUseCase NÃO foi estendido para cobrir local — ele reindexa lendo de uma fonte que o SERVIDOR consegue revisitar (o repositório do projeto, o event log), e local não tem fonte nenhuma para revisitar: o texto só existe no navegador de quem anexou, e o servidor nunca guardou caminho de host nenhum (não há um). Chamar deleteByScope(projectId, 'local') no botão genérico apagaria a referência anexada sem ter como recriá-la — reanexar a pasta (novo upload) É o mecanismo de resincronizar, e é um botão deliberadamente separado. Os dois casos de uso carregam comentário cruzado explicando o porquê, para uma "correção" futura não religar os dois e apagar silenciosamente o material do usuário.

  • Onde: apps/api/src/interfaces/http/rag/rag.controller.ts (anexarPastaLocal, @RequireRole('maintainer')), apps/api/src/application/use-cases/rag/reindex-project.use-case.ts (comentário "Por que local NÃO entra aqui")
  • Teste: apps/api/test/interfaces/http/rag/rag.controller.spec.ts
  • ADR: 0113
  • Origem: pedido do dono do produto

RN-458 — Cobertura de local é forma PRÓPRIA, e lastAttachedAt é a ÚNICA exceção real ao "nunca Xmin" (RN-237)​

RagCoverage.local não reusa RagFileCoverage (RN-237, ADR 0080): não há "total no repositório" pra comparar — uma pasta anexada não tem um total que o servidor possa recontar. RagLocalCoverage mostra o que está indexado AGORA (filesIndexed, folderName) e lastAttachedAt, um MAX(chunks.created_at) REAL sobre o escopo — a única exceção declarada à regra de nunca mostrar um "reindexado há Xmin" chutado, porque aqui o valor real EXISTE e é barato de calcular (mesmo todosOsChunks que GetRagCoverageUseCase já busca).

  • Onde: apps/api/src/application/use-cases/rag/get-rag-coverage.use-case.ts (RagLocalCoverage), apps/web/src/components/rag/RagCoveragePanel.tsx
  • Teste: apps/api/test/application/use-cases/rag/get-rag-coverage.use-case.spec.ts, apps/web/src/components/rag/RagCoveragePanel.test.tsx
  • ADR: 0113
  • Origem: pedido do dono do produto

RN-459 — Teto de iterações não termina mais calado em quatro dos seis agentes conversacionais​

Achado durante a investigação da faixa de atividade do turno (RN-460): arquiteto_server.ex, dev_lead_server.ex, ux_designer_server.ex e staff_server.ex terminavam CALADOS quando o teto de iterações estourava (defp run_turn(state, remaining) when remaining <= 0, do: state, sem evento nenhum) — contradizendo a regra permanente de que laço de agente não termina calado. Só o PO já emitia toolloop.limit_reached (RN-166); o Criativo diverge deliberadamente com agent.error (reason: "limite_de_iteracoes", decisão documentada no próprio código — reusar o nome do ToolLoop mentiria sobre quem produziu o evento) e não foi tocado. Os quatro corrigidos passam a emitir toolloop.limit_reached (mesmo evento do PO, mesmo payload iteration/max_iterations) antes de retornar — nenhuma mudança na estrutura de dispatch_tool de cada um.

  • Onde: apps/engine/lib/engine/agents/arquiteto_server.ex:195, dev_lead_server.ex:244, ux_designer_server.ex:121, staff_server.ex:113
  • Teste: apps/engine/test/engine/agents/{arquiteto,dev_lead, ux_designer,staff}_server_test.exs ("teto de iterações emite toolloop.limit_reached")
  • Origem: achado por leitura completa dos seis servers, durante a implementação da RN-460; escopo estendido por decisão do dono do produto

RN-460 — A faixa de atividade do turno narra em tempo real, e o fio só recebe a resposta no fim​

A tela de Sessão mostra, ACIMA do composer, uma faixa que narra em linguagem humana o que um agente conversacional (Criativo, PO, Arquiteto, Dev Lead, UX Designer, Staff) está fazendo DURANTE o turno — referência visual: a linha de status do Claude Code. O fio só recebe a bolha de resposta DEPOIS que o turno termina; a regra é do CHAT (canal Phoenix) e vale só para os seis agentes conversacionais — o chat consultivo sem agente ativo (SSE, streamChatMessage) continua com a bolha de streaming de sempre, intocada. (Desde a RN-682 a tela não chama mais esse SSE: a consultiva sem agente pede um agente antes de enviar.)

Mecanismo (engine): os seis servers já emitem tool.call DURÁVEL no event log; passam a também fazer broadcast(state, "tool.call", %{tool: name, agent: @agent}) — EFÊMERO, sem args (nunca payload cru, mesma régua da RN-096/RN-412) — logo depois, pro canal session:<id> entregar em tempo real. Não há behaviour/macro compartilhado entre os seis dispatch_tool (quatro formas estruturais distintas confirmadas por leitura completa): a mudança é seis edições adaptadas à forma local de cada um.

Mecanismo (web): um reducer PURO (reduzirAtividadeDoTurno/lib/atividade-do-turno.ts) acumula o texto do agent.delta como "corrente"; ao chegar um tool.call do canal, arquiva o corrente não-vazio como linha de NARRAÇÃO, zera, e SEMPRE adiciona uma linha de FERRAMENTA com fraseDaFerramenta(tool) (dicionário das 19 ferramentas dos seis agentes, lib/narracao-de-ferramentas.ts, sem RN própria — só existe pra servir esta regra) — duas chamadas consecutivas sem delta entre elas viram duas linhas separadas, nunca uma. Fim de turno reseta (finalizarTurnoDoAgente, ÚNICO ponto de reset). turnoViaCanal é uma flag PRÓPRIA (nunca derivada de statusAgent/streamingAgent, que passam por janelas legitimamente null no meio de um turno) que decide se a faixa aparece OU a bolha antiga — nunca as duas.

Regra de apresentação (histórico): agruparNarracoesDoTurno, nova passada pura que roda DEPOIS de afundarDesfechos (RN-172) no mesmo useMemo — dentro de um mesmo turno+autor, agent.response consecutivas viram um Disclosure compacto ("Passos do turno · N"), deixando só a última intacta e fora dele. turno === 0 (o prólogo sentinela de turnoDoSeq) fica de FORA do agrupamento, de propósito — não é um turno de verdade, e fixtures antigas empilham agent.response sem fronteira de turno pra testar outro mecanismo (RN-138/RN-177). Função agnóstica a agente (só lê turno/autor), afundarDesfechos em si não muda.

  • Onde: apps/engine/lib/engine/agents/{po,criativo,arquiteto, dev_lead,ux_designer,staff}_server.ex (broadcast de tool.call); apps/web/src/lib/atividade-do-turno.ts (reducer); apps/web/src/components/TurnActivityStrip.tsx (componente); apps/web/src/lib/session-channel.ts:54 (onToolCall); apps/web/src/routes/session-fio.tsx:48 (agruparNarracoesDoTurno), apps/web/src/routes/SessionPage.tsx:197 (turnoViaCanal)
  • Teste: apps/web/src/lib/atividade-do-turno.test.ts, apps/web/src/components/TurnActivityStrip.test.tsx, apps/web/src/lib/session-channel.test.ts, apps/web/src/routes/SessionPage.ordenacao-e-avisos.test.tsx (agruparNarracoesDoTurno), suite de apps/engine/test/engine/agents/ *_server_test.exs (broadcast de tool.call sem args crus)
  • Origem: pedido do dono do produto

Ollama nativo no bootstrap dev e pull de modelo Hugging Face (RN-461..463, ADR 0114/0115)​

RN-461 — Ollama nativo: pergunta uma vez, a resposta persiste em .env, nunca pergunta de novo​

scripts/dev/preflight.mjs detecta um Ollama nativo já escutando em OLLAMA_PORT (mesmo default, 11434, de uma instalação nativa) confirmando por HTTP (GET /api/tags) que é mesmo um Ollama, não qualquer processo na porta. Confirmado, PERGUNTA uma única vez se é para usar essa instância — default "Sim" quando não há TTY (o bootstrap.sh roda o comando com stdin de /dev/null de propósito, para não roubar as setas do usuário) — e grava a resposta em .env (OLLAMA_MODE=host|container). A partir daí a detecção nem roda de novo: com OLLAMA_MODE já presente, a função retorna cedo. ollama/ollama-model-loader entram em profiles: ["local-llm"] no compose para não subirem quando o modo é host. A ÚNICA forma de reabrir a pergunta é o item de menu dedicado "Docker › Reconfigurar Ollama", que apaga as chaves gravadas — nunca uma pergunta espontânea de novo enquanto elas existirem.

  • Onde: scripts/dev/env-file.mjs:41 (escreverEnv), scripts/dev/preflight.mjs:157 (ehOllama), scripts/dev/preflight.mjs:202 (perguntarUsoDoOllama), scripts/dev/preflight.mjs:238 (detectarOllamaNativo), scripts/dev/reconfigurar-ollama.sh, scripts/dev/perfil-ollama.sh, docker/docker-compose.yml:82,119 (profiles: ["local-llm"])
  • Teste: scripts/dev/bootstrap.spec.ts (cobre a fiação do menu — o perfil condicional em Deploy › All/Create e o item dedicado "Reconfigurar Ollama"). A lógica de detecção/persistência em si (detectarOllamaNativo/escreverEnv) foi validada ponta a ponta manualmente contra um servidor HTTP fake e contra o Ollama real desta máquina (ver ADR 0114) — lacuna declarada: não existe suíte automatizada própria para preflight.mjs hoje.
  • ADR: 0114
  • Origem: pedido do dono do produto, achado real durante a execução (colisão de porta com Ollama nativo)

RN-462 — Pull de modelo Hugging Face exige confirmação explícita em duas etapas; nunca roda sozinho​

POST .../huggingface/pull-requests só CRIA o pedido, em pending_confirmation — nenhum download começa. Somente uma chamada SEPARADA, POST .../pull-requests/:id/confirm, move o pedido para confirmed → pulling e de fato chama OllamaProvider.pullModel. ConfirmModelPullUseCase RECUSA (409) confirmar um pedido que já não esteja em pending_confirmation — a transição de estado É a segunda confirmação, não uma flag à parte, e por isso não é reexecutável. Falha do Ollama durante o pull marca o pedido failed com failedReason prefixado pela origem (infra/modelo/código, vocabulário do ADR 0020) — nunca falha silenciosa, e nada é ativado no catálogo quando falha.

  • Onde: apps/api/src/application/use-cases/llm/huggingface/request-model-pull.use-case.ts:23 (RequestModelPullUseCase), apps/api/src/application/use-cases/llm/huggingface/confirm-model-pull.use-case.ts:45 (ConfirmModelPullUseCase), apps/api/src/interfaces/http/llm/huggingface-models.controller.ts:109-165 (as duas rotas, role:maintainer)
  • Teste: apps/api/test/application/use-cases/llm/huggingface/request-model-pull.use-case.spec.ts (cria em pending_confirmation, nada além disso), apps/api/test/application/use-cases/llm/huggingface/confirm-model-pull.use-case.spec.ts (caminho feliz ativa no catálogo; falha do Ollama marca failed com a origem certa — modelo/infra; recusa reconfirmar um pedido que já saiu de pending_confirmation; 404 para pedido inexistente no workspace)
  • ADR: 0115
  • Origem: pedido do dono do produto

RN-463 — Allowlist de publishers oficiais; comunidade exige opt-in explícito com aviso de segurança​

Busca no Hugging Face Hub filtra para publishers OFICIAIS por padrão — HUGGINGFACE_OFFICIAL_PUBLISHERS, vocabulário FECHADO e curado à mão (meta-llama, google, mistralai, microsoft, Qwen, deepseek-ai, openai, nvidia), comparado por igualdade EXATA de caixa — um reupload minúsculo (qwen/...) não herda o selo da org oficial (Qwen/...). includeCommunity=true traz todo o resto, cada resultado marcado official: true|false, nunca ocultando a distinção. Na tela, o toggle de comunidade nasce DESLIGADO e, enquanto ligado, mostra um aviso de segurança (Alert tone="danger") — o opt-in e o aviso são o mesmo gesto, nunca uma preferência que o usuário liga uma vez e esquece que está ligada.

  • Onde: apps/api/src/domain/llm/huggingface-official-publishers.ts:17-41 (HUGGINGFACE_OFFICIAL_PUBLISHERS/isOfficialPublisher), apps/api/src/application/use-cases/llm/huggingface/search-huggingface-models.use-case.ts:28-39 (filtro por padrão), apps/web/src/components/HuggingFaceModelBrowser.tsx:190-203 (toggle + Alert de aviso)
  • Teste: apps/api/test/domain/huggingface/huggingface-official-publishers.spec.ts (reconhece publisher do allowlist; recusa reupload de terceiro; sensível a caixa; repoId sem / nunca casa), apps/api/test/application/use-cases/llm/huggingface/search-huggingface-models.use-case.spec.ts (só oficiais por padrão; includeCommunity traz todos marcados), apps/web/src/components/HuggingFaceModelBrowser.test.tsx (toggle nasce desligado e só mostra o aviso quando ligado; busca respeita includeCommunity)
  • ADR: 0115
  • Origem: pedido do dono do produto

Configuração do runner pelo navegador (RN-464..466, ADR 0118)​

RN-464 — Chave de dispositivo do runner: registro/revogação self-service; SEM visão de maintainer nesta rodada (corte declarado)​

Revisada pela RN-687 (ADR 0203, 01/10): o fluxo do navegador foi APOSENTADO, e o POST .../runner-device-keys saiu com ele — junto com RegisterRunnerDeviceKeyUseCase e o teste dele, citados abaixo como eram. Ficam GET/DELETE e a revogação self-service; a chave nova nasce no terminal (RN-551) e quem a registra é o instalador (RN-552). As chaves de projeto já registradas continuam valendo e revogáveis.

runner_device_keys guarda só a chave PÚBLICA Ed25519 de um dispositivo do runner — gerada no navegador, nunca a privada. POST/DELETE .../runner-device-keys exigem papel mínimo developer e são autenticadas pelo JWT DE SESSÃO normal (diferente de runner-ticket: quem chama aqui é o navegador já logado registrando o próprio dispositivo, não o runner rodando sem sessão). RegisterRunnerDeviceKeyUseCase valida só a FORMA mínima da JWK (kty:"OKP", crv:"Ed25519", x presente) — a validação profunda (é mesmo um ponto Ed25519 válido) fica pro jose.importJWK no momento de usar, na RN-465. RevokeRunnerDeviceKeyUseCase é IDEMPOTENTE e escopado ao userId chamador — mesmo desenho de revogar() da RN-426 — e devolve 404 tanto para chave inexistente quanto para chave de outro usuário, mesma disciplina de não vazar existência.

Assimetria declarada com o PAT: ao contrário da RN-427 (maintainer revoga o PAT de QUALQUER usuário do projeto, resposta a incidente), a chave de dispositivo NÃO tem hoje uma rota equivalente de maintainer — só autorevogação. O docblock de RunnerDeviceKeysController declara isto explicitamente como corte desta rodada, não esquecimento: estender o mesmo padrão da RN-427 para chaves de dispositivo é trabalho futuro direto, se vier a ser pedido.

  • Onde: apps/api/src/db/schema/auth.ts:338 (runnerDeviceKeys), apps/api/src/application/use-cases/auth/register-runner-device-key.use-case.ts, apps/api/src/application/use-cases/auth/revoke-runner-device-key.use-case.ts, apps/api/src/interfaces/http/runner/runner-device-keys.controller.ts
  • Teste: apps/api/test/application/use-cases/auth/register-runner-device-key.use-case.spec.ts, apps/api/test/application/use-cases/auth/revoke-runner-device-key.use-case.spec.ts
  • ADR: 0118
  • Origem: pedido do dono do produto

RN-465 — POST .../runner-ticket aceita PAT OU chave de dispositivo (Ed25519, TTL ≤60s) — segunda forma de credencial de DISPOSITIVO, nunca dual-auth com JWT de sessão (distinção da RN-439)​

Revisada pela RN-687 (ADR 0203, 01/10): nada desta RN mudou no guard — o PatAuthGuard continua aceitando o JWT de uma chave de PROJETO, e é por isso que um runner configurado pelo navegador antes do ADR 0203 não quebra. O que deixou de existir é criar uma chave de projeto NOVA: o navegador não gera mais o par.

PatAuthGuard ganhou um segundo caminho, ao lado do PAT (brb_...) inalterado: um bearer no formato de JWT compacto (três segmentos) é tratado como chave de dispositivo. O guard lê o kid do header (sem verificar assinatura ainda), busca a chave pública ATIVA correspondente em runner_device_keys, e só então verifica a assinatura EdDSA com jose.importJWK/jwtVerify. TTL curto e OBRIGATÓRIO: exp - iat não pode passar de 60s — checado contra a vida ASSINADA do token, não contra "agora", fechando a janela de replay de um JWT vazado a partir do momento em que foi assinado. userId/projectId usados para autorizar vêm sempre do REGISTRO salvo no banco, nunca de claim do JWT — o token só precisa provar posse da privada, nunca afirmar quem é o dono. projectId é conferido DUAS vezes (claim do JWT contra a rota, e projeto registrado da chave contra a rota) — mesma disciplina 401 vs 403 que o caminho PAT já usa: token/chave válida para o projeto ERRADO é categoria diferente de token/chave inválida. O runner assina esse JWT em assinarTicketComChaveDeDispositivo (apps/runner/src/auth.ts), com a JWK privada já CARREGADA em memória — nunca lendo arquivo ali, preservando a garantia "sem I/O de arquivo" de auth.ts (quem lê o arquivo é o módulo separado da RN-466).

Isto NÃO reabre a RN-439: a RN-439 fechou a garantia de que runner-ticket nunca aceita o JWT de LOGIN como credencial — ela continua de pé. A chave de dispositivo é um JWT DIFERENTE, autoassinado pelo próprio runner com uma chave que a api nunca viu a privada, sem sub de usuário nenhum — só uma segunda forma de provar "sou o dispositivo de tal usuário neste projeto", tão escopada quanto o PAT que ela complementa. Aceitar o JWT de sessão aqui faria RolesGuard/@RequireRole autorizar esse usuário pra tudo que o papel dele permite no resto da api, estourando o escopo runner:project:<id> — exatamente o que a RN-439 impediu.

  • Onde: apps/api/src/interfaces/http/auth/pat-auth.guard.ts:104-216 (autenticarChaveDeDispositivo), apps/runner/src/auth.ts:143-188 (assinarTicketComChaveDeDispositivo, obterTicketDoRunnerComCredencial)
  • Teste: apps/api/test/interfaces/pat-auth.guard.spec.ts (describe "chave de dispositivo (JWT EdDSA autoassinado, ao lado do PAT)" — caminho feliz, assinatura inválida, chave revogada, projectId do claim não bate com a rota, chave registrada em outro projeto, TTL longo demais, kid sem chave correspondente), apps/runner/src/auth.spec.ts (describe "assinarTicketComChaveDeDispositivo"/"obterTicketDoRunnerComCredencial")
  • ADR: 0118
  • Origem: pedido do dono do produto

RN-466 — brabo-runner roda sem --project/--dir/--token quando a pasta tem config local gravada pelo navegador​

Revisada pela RN-687 (ADR 0203, 01/10): o navegador não grava mais esses arquivos — o fluxo foi aposentado. A LEITURA continua inteira: uma pasta configurada antes do ADR 0203 segue funcionando, e é também por --dir apontando para $XDG_CONFIG_HOME/brabo/ que a unit de máquina acha a chave criada no terminal (RN-551).

apps/runner/src/device-key.ts (módulo NOVO, separado de propósito de auth.ts) lê — nunca escreve — brabo-runner.config.json ({projectId, apiUrl}) e brabo-runner-device-key.jwk.json do cwd() atual; ausência de qualquer um dos dois devolve null, nunca lança, porque essa ausência é o caso NORMAL de quem ainda usa flags explícitas. Em lerArgumentos (apps/runner/src/index.ts), a regra é a MESMA três vezes — flag explícita sempre vence o arquivo local: --project vence configLocal.projectId; --dir ausente cai para . (a própria pasta onde o comando roda) quando havia config local, em vez do erro de uso de antes; --token/BRABO_ACCOUNT_TOKEN vence a chave de dispositivo local quando ambos existem. Sem NENHUMA credencial (nem token, nem chave local), o CLI continua recusando com a mensagem de uso — nada aqui torna a autenticação opcional, só qual das duas formas é usada.

  • Onde: apps/runner/src/device-key.ts (lerConfigLocal, lerChaveDeDispositivo), apps/runner/src/index.ts:95-206 (lerArgumentos)
  • Teste: apps/runner/src/device-key.spec.ts (caminho feliz de cada leitura; null sem lançar para arquivo ausente/JSON inválido/campo faltando; respeita o cwd recebido; módulo não importa writeFileSync/mkdirSync)
  • ADR: 0118
  • Origem: pedido do dono do produto

Painel "precisa de você" (RN-467)​

RN-467 — O painel das cinco filas de decisão NUNCA soma e NUNCA executa: separa por fila e encaminha para a decisão​

O chip "Precisa de você", no topo do projeto, abre um painel com as CINCO filas de decisão do projeto — aprovações de ação, merges de PR, promoções de história, pendências de arquitetura e hipóteses do Psicólogo. Duas garantias o definem, e nenhuma das duas é estética:

Nunca soma. Cada fila tem cabeçalho e contagem PRÓPRIA, e não existe total do projeto em lugar nenhum: nem no painel, nem no chip que o abre — o chip anuncia PRESENÇA (um ponto, temAlgoEsperando, booleano por assinatura) e nunca quantidade. É a mesma decisão de produto que já mantém os cinco contadores do trilho separados (ADR 0126): somar apaga QUAL fila está pedindo atenção, que é justamente a pergunta de quem abre o painel. montarFilas devolve as cinco na ordem de urgência declarada (ORDEM_DAS_FILAS) e não exporta função de total — a ausência é o mecanismo, não um esquecimento.

A única dedupe que existe é por IDENTIDADE, nunca entre filas: a MESMA proposed_action de git_merge chega pelos dois hooks (usePendingActions é da sessão e não filtra por tipo; useProjectPendingActions é project-wide) e apareceria duas vezes na mesma lista, sob dois títulos. Fica no grupo mais específico (prs).

Nunca executa. As duas filas acionáveis no painel (aprovacoes, prs) renderizam o MESMO ApprovalCard da aba de Aprovações (uma variante só desde a AT-322, com o detalhe recolhido), e os botões chamam os mesmos endpoints de decisão — o painel é um ATALHO para a decisão, nunca um substituto dela. Isso importa em especial para git_merge: merge em branch protegida é rebaixado a require_approval INCONDICIONALMENTE (apps/api/src/domain/actions/decide.ts), teto que nem agent_autonomy nem permissions.json levantam, e nada no painel toca nesse caminho. onActivateAutoMode é OMITIDO nos dois cards de propósito: ligar "auto mode" (RN-153) é mudar POLÍTICA do agente, não decidir a ação que está na frente — quem quer isso decide na aba de Aprovações, onde o papel de workspace já é checado. As outras três filas não têm card de decisão fora do contexto delas e por isso LEVAM à aba onde a decisão mora.

A pendência de arquitetura não tem data própria — nenhuma, em campo nenhum (ArchitecturePendency): ela é visão DERIVADA do cruzamento entre história e module_map, recalculada a cada leitura e nunca gravada. O painel EMPRESTA a data da história relacionada e DIZ que emprestou ("história atualizada há 18 min", dataEmprestada); sem a história no backlog carregado, mostra "sem data" e ordena o item no FIM da fila. Em nenhum caso um instante inventado: renderizar "agora" faria a linha mais urgente da tela ser a que menos se sabe, sem nada denunciando a mentira. Acrescentar coluna e migração na api resolveria de verdade e é decisão à parte, fora do escopo desta.

  • Onde: apps/web/src/lib/precisa-de-voce.ts:108 (ORDEM_DAS_FILAS), :117 (FILAS_ACIONAVEIS), :139 (montarFilas — a dedupe por identidade e o empréstimo de data), :245 (temAlgoEsperando); apps/web/src/components/PainelPrecisaDeVoce.tsx:246-248 (role="dialog", aria-modal, rótulo) e :279-284 (grupo por fila, com a contagem dela); apps/web/src/routes/ProjectPage.tsx:120 (monta as filas a partir dos cinco hooks que os contadores do trilho já usam)
  • Teste: apps/web/src/lib/precisa-de-voce.test.ts (as cinco filas separadas com os itens de cada uma; nada somado e nenhuma função de total; o git_merge duplicado aparece uma vez, na fila de PRs; ordem por espera mais longa; data emprestada marcada, ausente vira null e vai para o fim), apps/web/src/components/PainelPrecisaDeVoce.test.tsx (cinco cabeçalhos com a contagem de cada fila; o total não aparece em lugar nenhum; frase própria do vazio; ApprovalCard decide pela sessão da PRÓPRIA ação; "Modo automático" não é oferecido; linha de arquitetura sem data renderiza sem quebrar; Esc, clique-fora, foco e aria-expanded)
  • Origem: revisão de design do dono do produto (item #3 do canvas de melhorias de UI)

Estado de ambiente na tela (RN-468)​

RN-468 — Sinal de ambiente diz o que SABE: workspaceVerifiedAt é registro de confirmação, nunca garantia de que o runner está vivo​

O produto mostra estado de ambiente em dois lugares, e o recorte de cada um é a regra: a tela de login mostra só o que é verdade SEM identidade, e a Visão geral do projeto mostra o que só é verdade COM ela.

Pré-login, o recorte é imposto pelo escopo do dado, não por escolha estética. Sobram os dois /health — públicos nos dois serviços de propósito (@Public() na api; "Sem auth de propósito" no router.ex), os mesmos que StatusPage já consome sem sessão. Presença de runner é chaveada por {user_id, project_id} (runner_device_keys) e a lista de modelos é projects/:projectId/models com papel viewer: antes do login não existe nenhum dos dois sujeitos, então a tela declara a ausência ("Runner e modelos locais dependem da sua conta e de um projeto") em vez de omiti-la — omitir faria a plataforma parecer não ter o que ela tem. Os dois /health mantêm os TRÊS estados separados (RN-088 aplicada a um sinal de ambiente): verificando… não é sem resposta, e nenhum dos dois é respondendo. A sonda tem TETO (6s): uma api que aceita a conexão e nunca responde deixaria a linha em "verificando…" para sempre, o que é honesto por meio segundo e mentira por omissão depois de dez.

E o formulário nunca espera pela sonda. O estado é local ao bloco, que é IRMÃO do card — api fora do ar muda um texto e não atrasa nem esconde um pixel do login. Isso importa exatamente no momento em que a api cai, que é quando alguém mais precisa que a tela ao menos ABRA.

Pós-login, a regra é sobre o que o dado NÃO afirma. O sinal de runner sai de projects.workspace_verified_at — o carimbo que ConfirmProjectWorkspaceUseCase grava quando um runner conecta e confirma a pasta (RN-423), e o MESMO campo que o engine usa como portão (TerminalExecutor recusa comando em projeto runner com o campo nulo). Ele não é batimento, por duas razões independentes: não há processo sendo observado, e reconectar reportando o mesmo caminho não regrava o carimbo (decisão explícita do caso de uso), de modo que nem a data é "a última vez que o runner apareceu". A tela diz "pasta confirmada em <data>" com a ressalva do que isso não é, e nunca "de pé", "online" nem bolinha verde — o tom é neutro, e verde ali leria como uma garantia de liveness que o dado não sustenta. Quem sabe do AGORA é o socket do terminal, na aba Código (TerminalPanel/RunnerOnboardingPanel), e a ressalva aponta para lá. Mesma disciplina que a RN-467 usou ao tomar emprestado o updatedAt da história.

A linha do runner só existe em projeto no modo runner: nos outros dois o campo é nulo por definição (a conversão de modo o zera, RN-450) e uma linha "nunca confirmada" ali seria uma ausência inventada.

  • Onde: apps/web/src/components/SinaisDoAmbiente.tsx:72 (useSaude — a sonda com teto e o .catch da rejeição de conexão), :133 (SinaisDoAmbiente, o bloco pré-login); apps/web/src/components/AmbienteDoProjeto.tsx:92 (AmbienteDoProjeto), :102 (linha do runner, só no modo runner, com tom neutro e ressalva); apps/web/src/routes/AuthLayout.tsx:46 (colunaDeIdentidade — nada focável ali, ou a primeira parada de Tab deixa de ser o campo de e-mail); apps/api/src/application/use-cases/iam/confirm-project-workspace.use-case.ts:93 (o carimbo NÃO é regravado quando o caminho não muda — a razão de a data não ser recência)
  • Teste: apps/web/src/components/SinaisDoAmbiente.test.tsx (os três estados distintos; rejeição de conexão vira "sem resposta" e não silêncio; resposta não-OK idem; teto da sonda pendurada; nenhum elemento focável; a ausência de runner/modelos é declarada), apps/web/src/components/AmbienteDoProjeto.test.tsx (sem linha de runner fora do modo runner; confirmado exige a ressalva e o bloco não contém de pé|online|conectado agora; nunca confirmado ensina o caminho; contagem de modelos locais no singular, no plural e no zero; chaves de consulta reusadas), apps/web/src/routes/LoginPage.ambiente.test.tsx (o formulário renderiza e submete com a sonda rejeitando E com a sonda pendurada; um <h1> só; a versão continua com uma fonte)
  • Origem: revisão de design do dono do produto (item #6 do canvas de melhorias de UI)

RN-469 — Salvar uma seção são N chamadas, e a tela nunca afirma o desfecho que não obteve​

Nas seções de Configurações cujas linhas são um ajuste ESCALAR por chave (Paralelismo por área e Teto de gasto por área, as duas por agent_areas), o botão de salvar é UM, da seção, e não um por linha. Salvar dispara uma chamada por linha suja — PUT .../areas/:key/max-parallel e .../budget são endpoints por ÁREA, não existe endpoint transacional para "grave estes N tetos", e este produto não inventou um. A consequência é que o desfecho de um clique pode ser parcial, e a regra é sobre o que a tela pode dizer então.

As chamadas são em SÉRIE, na ordem da tela, e uma falha não interrompe as seguintes. Em série porque o relatório de falha nomeia linhas e precisa sair na ordem em que a pessoa as vê. Sem interromper porque quem clicou pediu as N: abortar na primeira recusa deixaria linhas sem tentativa nenhuma, e a tela não teria como distinguir "a api recusou" de "nem chegou a tentar".

O desfecho é por LINHA, e é ele que a UI mostra. Só o rascunho que a api CONFIRMOU é descartado; o que falhou permanece no campo, com o que a pessoa digitou, e a seção continua marcada como não salva por exatamente essas linhas — clicar Salvar de novo tenta só elas. Os três desfechos são distintos e nenhum se disfarça de outro: todas passaram → sucesso; nenhuma passou → a mensagem que a API deu, nunca uma contagem; algumas passaram → aviso que diz quantas de quantas e nomeia as que ficaram. "Salvo" e "não salvo" seriam as duas mentira no terceiro caso, e é ele que esta RN existe para proteger.

A seção declara QUANTAS linhas estão pendentes antes do clique. Um botão por seção diz "Salvar" igual com uma linha suja e com cinco; a contagem ("2 alterações não salvas nesta seção") é a contrapartida de ter trocado N botões por um. Sujo é comparação por valor interpretado, não por texto: 20 e 20.0 são o mesmo teto, e comparar string mandaria uma chamada que a api trata como no-op. Rascunho inválido conta como sujo e substitui a contagem pela mensagem que explica o bloqueio — dois números sobre o mesmo conjunto seriam ruído, e quem tem valor inválido precisa do que trava o botão.

Onde a regra NÃO vale, e por quê. Seção cujo controle é escolha de valor NOMEADO (Promoção de história, Modelos por agente (com a caixa da área do lead, RN-912), papel em Membros) salva no onChange, sem botão, e continua assim: a confirmação existe para campo DIGITADO, onde salvar a cada tecla mandaria 1 a caminho de 12. O que essa frase mede é de quem é o valor: ela vale para o controle que grava o PRÓPRIO valor, e não alcança um seletor cujo valor é o ARGUMENTO de uma ação sobre outras linhas — ver RN-476, que é a exceção e diz por quê. E Credenciais mantém botão por linha apesar de também ter drafts por chave: a credencial é write-only (ADR 0050) e nunca volta do servidor, então não há valor com que comparar para decidir "sujo"; o botão da linha alterna entre "Salvar" e "Trocar" conforme aquele provider já tenha chave; e ele divide o card com "Testar" e "Remover", que são irredutivelmente da linha.

  • Onde: apps/web/src/routes/settings/secao-salvavel.tsx:105 (useSecaoSalvavel — o laço em série, o desfecho por linha e os três toasts), :130 (sujo por valor interpretado), :241 (MarcaDeNaoSalvo — a contagem, e o inválido substituindo-a), apps/web/src/routes/settings/ParallelismSection.tsx:31, apps/web/src/routes/settings/BudgetSection.tsx:37 (os dois consumidores), apps/web/src/routes/settings/CredentialsSection.tsx:57 (por que esta seção ficou de fora)
  • Teste: apps/web/src/routes/settings/secao-salvavel.test.tsx (a contagem de linhas sujas; voltar ao valor do servidor limpa a marca; inválido substitui a contagem e trava o botão; um clique persiste todas as linhas sujas; número e null na mesma leva; falha parcial diz quantas de quantas e nomeia a que ficou; a seção continua marcada pelas que falharam; o segundo clique tenta só elas; uma falha não interrompe as seguintes; nenhuma passando mostra a mensagem da API e não a contagem)
  • Origem: revisão de design do dono do produto (item #7 do canvas de melhorias de UI — "salvar por seção em vez de por linha")

RN-476 — Aplicar um modelo a TODOS os agentes: um valor, N chamadas, e um botão apesar do valor ser nomeado​

A tabela Modelos por agente tem uma linha por agente e um seletor em cada uma. Escolher o mesmo modelo para os 17 era percorrer as 17 linhas, e o custo disso não é o tempo: é que ninguém confere 17 dropdowns e a tela não tem como dizer se sobrou um para trás. A seção ganha uma barra ACIMA da tabela — um seletor e um botão — que aplica UM modelo a todos os agentes de uma vez.

A ação grava no nível do AGENTE, nas 17 linhas — não no projeto. As duas leituras de "um modelo para todos" existem e produzem estados diferentes: gravar no projeto e apagar os bindings de agente e de área faria os 17 HERDAREM, e é o idioma da própria cascata. Não foi o escolhido, por dois preços concretos: PUT projects/:id/model-binding e os endpoints de área exigem maintainer, enquanto o binding de agente exige developer — a mesma pessoa que pode trocar linha a linha passaria a não poder fazê-lo de uma vez —, e o binding de projeto é também o default da SESSÃO, que a RN-040 deixa livre de propósito. A escolha tem preço declarado: as 17 linhas passam a divergir, com origem agent na coluna Origem, e voltar a herdar continua sendo linha a linha.

Há botão, apesar de o valor ser NOMEADO. A régua da RN-469 diz que escolha de valor nomeado salva no onChange, e ela continua valendo para o seletor de CADA LINHA, que grava o próprio valor. O seletor da barra não é configuração de nada: ele é o argumento da ação ao lado. Aplicar no onChange faria um clique exploratório num dropdown reescrever 17 linhas que a pessoa não estava editando, e desfazer isso são 17 cliques. O botão NOMEIA quantas linhas vai alcançar, porque esse número é a consequência.

O desfecho segue a RN-469 inteira, e é a razão de a barra não inventar nada. Uma chamada por agente, em SÉRIE, na ordem da tela, sem abortar na primeira recusa — abortar deixaria 16 linhas sem tentativa e a tela não distinguiria "recusou" de "nem tentou". Os três desfechos não se disfarçam: todas passaram → sucesso nomeando o modelo; nenhuma passou → a mensagem da api, nunca uma contagem; algumas passaram → aviso com quantas de quantas, nomeando as que ficaram pelo NOME do agente, que é o que a pessoa lê na primeira coluna. Só as linhas que a api CONFIRMOU são relidas: invalidar as 17 apagaria da tela a diferença que o relatório parcial acabou de contar.

O gate é o do ENDPOINT, e o endpoint é o mesmo dos controles de linha. developer, por PUT projects/:projectId/agent-bindings/:agentSlug — não o maintainer da seção vizinha de área (RN-102). O botão fica inerte para quem não alcança, e o motivo é dito UMA vez, em TEXTO, na legenda da seção, junto com as outras duas ações que ficam inertes: title em elemento disabled não abre no Chromium (ADR 0064). O filtro "aptos para agentes" do seletor abre MARCADO, como no picker de linha e pelo mesmo motivo (RN-040) — com mais força aqui, porque um modelo chat-only escolhido na barra não produziria um 422, e sim 17, e o relatório parcial contaria a mesma recusa dezessete vezes.

  • Onde: apps/web/src/routes/settings/aplicar-a-todos.tsx:66 (useAplicacaoEmLote — o laço em série e os três desfechos), apps/web/src/routes/settings/ModelsSection.tsx (a barra, o estado local do modelo escolhido e a invalidação só das linhas confirmadas)
  • Teste: apps/web/src/routes/settings/aplicar-a-todos.test.tsx (grava o mesmo modelo em todos os agentes, na ordem da tela; nenhuma passando mostra a mensagem da api e NÃO a contagem, e ainda assim tenta todas; falha parcial diz quantas de quantas e nomeia a que ficou; papel abaixo de developer deixa o botão inerte e o motivo continua em texto)
  • Origem: pedido do dono do produto ao configurar um ambiente zerado com um provider só — todos os agentes precisavam do mesmo modelo

A seção de Membros respeita o papel, e o papel é o EFETIVO do projeto (RN-471)​

RN-471 — Papel na seção de Membros: maintainer, e derivado da linha do projeto sobre a do workspace​

MembersSection não checava papel nenhum. As TRÊS ações da seção — convidar, trocar o papel de alguém e remover — apareciam ativas para todo mundo, viewer incluído, e a api recusava com 403. Duas delas nem chegavam a dizer isso: handleRoleChange e handleRemove não tinham try/catch e eram chamadas de um onChange/onClick, então toda recusa virava unhandled promise rejection — silêncio na tela e ruído no console, a mesma classe de defeito que a RN-102 fechou na tabela de modelos. Remover um membro em silêncio é o pior dos três: é ação consequente e sem volta pela tela, porque repor exige o UUID que a linha removida levava junto.

O mínimo é maintainer, e é do ENDPOINT. Os três caminhos pedem o mesmo (projects.controller.ts), e GET pede viewer — por isso quem não edita continua vendo a tabela inteira:

açãoendpointpapel
convidarPOST :projectId/membersmaintainer
trocar papelPOST :projectId/members (upsert)maintainer
removerDELETE :projectId/members/:userIdmaintainer
ver a tabelaGET :projectId/membersviewer

Copiar o gate da seção de modelos (developer, e correto lá) ofereceria aqui os três controles a quem a api recusa. É a SEGUNDA seção seguida cujo mínimo difere da vizinha: a régua continua sendo o endpoint, nunca a tela ao lado.

O papel é o EFETIVO do PROJETO — e isto FECHA a lacuna que a RN-102 declarou, não a repete. Lá a tela lê o papel de WORKSPACE enquanto quem autoriza é ResolveEffectiveRoleUseCase.forProject, e fechar isso exigiria uma consulta de papel por projeto que o web não tem. É a MESMA lacuna aqui, vista do outro lado: esta seção JÁ faz essa consulta. listProjectMembers é findMemberRole para todo mundo de uma vez, e userIdDaSessao() (o sub do access token) diz qual linha é a de quem olha. A composição é literalmente a do caso de uso — projectRole ?? workspaceRole —, não uma segunda fonte de papel inventada só nesta tela. Enquanto a lista não chegou o papel é AUSENTE, não o de workspace: sem ela não há como saber se existe linha própria, e errar para o lado de desabilitar se conserta recarregando.

A sobreposição vale nos DOIS sentidos, e QUATRO lugares diziam o contrário. forProject devolve a linha de project_members quando ela existe, sem comparar com o workspace. Então definir viewer aqui rebaixa mesmo — até um owner de workspace, que depois só é restaurado por quem tenha maintainer. O código nunca fez o contrário, e o próprio resolve-effective-role.use-case.spec.ts fixa isso ("papel de projeto sobrepõe o de workspace"). Onde a promessa falsa estava:

lugaro que diziasituação
descrição de POST :projectId/members"the EFFECTIVE role is the higher of this one and what the person already has in the workspace"; "associating someone as viewer here doesn't downgrade a workspace owner"não corrigida — é mudança de api
descrição de AddMemberDto.rolea mesma frase do "higher of"não corrigida — é mudança de api
resumo de GET :projectId/members"with their effective role"; "includes whoever inherits access from the workspace"não corrigida — é mudança de api
apps/web/src/lib/roles.ts:3"Papel efetivo nunca é rebaixado"corrigida aqui

As três da api ficam declaradas e não são tocadas de passagem: as descrições propagam para api-types.generated.ts. A do web é PIOR que as outras três e por isso foi corrigida junto — as da api ao menos moram numa descrição de OpenAPI, enquanto essa estava no módulo que roleAtLeast e ROLE_ORDER exportam, ou seja, no código que a próxima pessoa desta família de correções vai abrir e editar, lendo a regra errada antes de qualquer outra coisa. A hierarquia linear declarada na mesma linha continua válida — é a ordem que roleAtLeast compara, e não mudou. A tela, por sua vez, DIZ o que vale, em vez de deixar o Select ser lido como sugestão inofensiva.

Some o CONTROLE, nunca a INFORMAÇÃO (ADR 0064). Quem não tem maintainer continua lendo o papel de cada membro no próprio Select apagado — é a informação central da tabela, e trocá-la por texto esconderia o estado junto com a ação. O motivo é dito UMA vez, em texto, na legenda: title em elemento disabled não abre no Chromium, e uma linha por membro repetiria um fato sobre QUEM OLHA em cima de cada pessoa da lista.

A tabela é um RECORTE, e declara (RN-180). listMembers é um innerJoin em project_members: quem alcança o projeto só pelo workspace não aparece em linha nenhuma, embora o resumo do GET prometa "includes whoever inherits access from the workspace". Esse dado NÃO está ao alcance do cliente — nenhuma consulta do web lista membros de workspace com papel —, então a legenda diz que a lista omite, em vez de deixá-la ser lida como "todo mundo que tem acesso". É a lacuna que sobra, e ela é declarada, não tratada.

Nada disto é fronteira de segurança: quem recusa continua sendo o RolesGuard.

Convidar mantém a dica fixa, e é decisão. As outras duas ações usam mensagemDaApi porque o userId delas veio da lista e existe — o que sobra é 403 e rede, e a frase da api é a informação mais útil que há. No convite o userId é DIGITADO, e o erro alcançável é apontar para um usuário inexistente: um UUID bem formado passa pelo @IsUUID(), estoura a FK project_members.user_id → users.id, nenhum dos filtros globais trata isso e o Nest responde o 500 padrão. mensagemDaApi devolveria "Internal server error", pior que a dica — o padrao dela só vale para erro que não é ApiError. Uniformizar a forma pioraria o conteúdo no caminho mais provável da caixa.

  • Onde: apps/api/src/interfaces/http/iam/projects.controller.ts:121, :135, :147 (os três papéis — estas linhas NÃO mudaram), apps/api/src/application/use-cases/iam/resolve-effective-role.use-case.ts:14 (projectRole ?? workspaceRole, a sobreposição nos dois sentidos), apps/api/src/infrastructure/persistence/drizzle/project.repository.ts:194 (listMembers — o innerJoin que faz a tabela ser recorte), apps/web/src/routes/settings/MembersSection.tsx:62 (o papel efetivo e por que maintainer), :109 (por que convidar não usa mensagemDaApi), :142 e :162 (os dois try/catch que faltavam), apps/web/src/lib/roles.ts:49 (roleAtLeast), :3 (o comentário de ROLE_ORDER, que afirmava "papel efetivo nunca é rebaixado" — o quarto lugar da tabela acima, e o único corrigido)
  • Teste: apps/web/src/routes/settings/papel-na-secao-de-membros.test.tsx (viewer e developer não editam e o clique não chega na api; maintainer e owner editam e o POST/DELETE chegam; papel ausente é inerte; o papel da linha sobrevive ao controle apagado; o PAR que prova a sobreposição nos dois sentidos — viewer de workspace com linha maintainer EDITA, owner de workspace com linha viewer NÃO edita; as duas recusas viram toast com a frase da api; a tela não passa a exibir o papel que a api negou; e convidar mantendo a dica fixa contra um 500)
  • Origem: revisão da #443 — a lacuna que ela declarou, vista da seção que tem os dados para fechá-la
  • Adendo (aditivo, o texto acima não mudou): as três descrições de OpenAPI que a tabela marca como "não corrigida" FORAM corrigidas depois, pela RN-472 / ADR 0127, na mesma mudança que pôs os dois tetos de rebaixamento na api. Não reabra essa linha da tabela.

Os dois tetos de rebaixamento em project_members (RN-472)​

RN-472 — Ninguém rebaixa o owner do workspace, e ninguém rebaixa a si mesmo​

A RN-471 registrou que ResolveEffectiveRoleUseCase.forProject é projectRole ?? workspaceRole — a linha de projeto SOBREPÕE a de workspace, nos dois sentidos. O que ela não guardou é a consequência: AddProjectMemberUseCase era um passthrough de uma linha para o upsert, e nenhum dos dois caminhos de escrita olhava para QUEM é o alvo. Qualquer maintainer podia (a) rebaixar o owner do workspace a viewer num projeto, tirando o dono do próprio projeto, e (b) se rebaixar sem poder desfazer, porque desfazer é a mesma rota, que pede maintainer.

A sobreposição FICA nos dois sentidos. forProject não muda. Restringir alguém num projeto sensível (workspace developer → viewer no projeto X) é capacidade deliberada, e "o maior dos dois" a eliminaria — o resolve-effective-role.use-case.spec.ts fixa a metade de subir desde a Fase 1. O que entra são DOIS tetos, e só eles:

tetoo que recusastatus
1papel abaixo de owner para quem é owner do WORKSPACE403
2papel abaixo do que o CHAMADOR tem hoje, quando o alvo é ele mesmo403

O owner é workspace_members.role, nunca workspaces.created_by. Os dois existem em db/schema/iam.ts e não são a mesma coisa: created_by é fato histórico, e é o role que a autorização usa em todo o resto do sistema (forWorkspace é workspaces.findMemberRole; created_by não aparece em caminho de autorização nenhum). Ler created_by blindaria o criador que já transferiu a propriedade e deixaria descoberto o owner corrente que não criou nada — o buraco que o teto existe para fechar.

O teto 2 é "a si mesmo", sem limiar — não "abaixo de maintainer". A versão com limiar copiaria um número do @RequireRole do controller para dentro do domínio e envelheceria calada se a rota mudasse de mínimo. O preço, declarado: o owner que quisesse se pôr como maintainer no próprio projeto (movimento reversível) também é recusado, e passa a precisar de outro maintainer. Subir o próprio papel não é rebaixamento e continua passando.

Os tetos moram no CASO DE USO, com a regra no domínio — não no RolesGuard. O guard autoriza o CHAMADOR contra o @RequireRole da rota e não vê corpo (dto.role) nem alvo (dto.userId); os dois tetos são sobre o ALVO e sobre a relação ator↔alvo. A FORMA é a de domain/actions/decide.ts (RN-154/RN-418): função pura, mensagem ao lado da condição, sem chave de configuração. O que não se transporta é o desfecho — lá o teto vira require_approval sobre uma proposed_action de agente, aqui a chamada já é humana e síncrona e não há fila para onde mandá-la.

403, não 409 nem 400: é recusa de autorização — o chamador tem o papel da rota e não tem autoridade para este movimento. Esperar não muda nada (não é conflito de estado) e o corpo é válido (o mesmo corpo com outro alvo passaria).

O que os tetos NÃO cobrem, e segue possível: rebaixar outro maintainer que não é owner de workspace; auto-rebaixamento pela REMOÇÃO — RemoveProjectMemberUseCase não ganhou teto, e remover a própria linha derruba o efetivo para o papel de workspace, que é benigno quando o workspace segura e irreversível quando não segura (maintainer só pela linha de projeto, viewer no workspace); auto-PROMOÇÃO, que já era possível antes; e rebaixar o owner NO WORKSPACE (POST workspaces/:workspaceId/members segue sem teto). Os dois casos da remoção estão FIXADOS em teste, inclusive o aberto.

A tela fica para depois, e a recusa aparece. MembersSection calcula o teto 2 sozinha, mas não o teto 1: listProjectMembers devolve o papel da LINHA DE PROJETO, e o papel de workspace do alvo não está ao alcance do cliente. Meio gate seria um Select honesto sobre uma recusa e calado sobre a outra — a segunda fonte de papel contra a qual a RN-471 escreve. Enquanto isso, handleRoleChange já mostra mensagemDaApi(erro, …) num toast (RN-471), e é a frase da api que diz qual teto bateu.

  • Onde: apps/api/src/domain/iam/tetos-de-rebaixamento.ts (rebaixaOwnerDoWorkspace, ehAutoRebaixamento e as duas mensagens), apps/api/src/application/use-cases/iam/add-project-member.use-case.ts (onde os dois são aplicados, antes do upsert), apps/api/src/interfaces/http/iam/projects.controller.ts (o @CurrentUser() que dá o ator, e as duas descrições corrigidas), apps/api/src/interfaces/http/iam/dto/add-member.dto.ts (a terceira), apps/api/src/application/use-cases/iam/resolve-effective-role.use-case.ts (INTOCADO — projectRole ?? workspaceRole segue como está), apps/api/src/db/schema/iam.ts (workspaces.created_by × workspace_members.role)
  • Teste: apps/api/test/application/use-cases/iam/tetos-de-rebaixamento.use-case.spec.ts (os dois tetos recusando; owner de workspace recebendo owner de projeto passa; auto-rebaixamento também quando o papel do ator vem da linha de projeto; auto-promoção passa; as DUAS capacidades legítimas preservadas — rebaixar outra pessoa, e viewer de workspace virando maintainer no projeto; auto-remoção permitida; e a remoção que ainda rebaixa quem a chamou, fixada como lacuna declarada)
  • Origem: ADR 0127, sobre o achado da revisão da #444 registrado na RN-471

A pasta vem primeiro, e o binário não é bloqueio (RN-473/474)​

RN-473 — A configuração do runner pelo navegador começa pela PASTA, e a falha do binário nunca descarta o que já foi configurado​

Revisada pela RN-687 (ADR 0203, 01/10): o fluxo que esta RN ordena SAIU — runner-bootstrap.ts e o teste dele foram removidos com o fluxo do navegador. O que sobrevive dela é a régua do passo humano dito ANTES, que o painel aplica ao comando do instalador.

O fluxo da RN-464..466 rodava na ordem chave → registro → binário → pasta (configurarPastaAutomaticamente, apps/web/src/lib/runner-bootstrap.ts). Os quatro passos não têm o mesmo risco: o download do binário é o ÚNICO que depende de uma release publicada no GitHub (RunnerReleasesController, proxy de releases/latest), e com a release corrente sem asset para plataforma nenhuma ele devolve 502. A exceção subia antes de showDirectoryPicker, então o seletor de pastas nunca chegava a abrir — o botão parecia inerte e a pasta ficava inalcançável, com a configuração inteira perdida por causa do passo mais frágil.

A ordem passa a ser pasta → config → chave → registro → chave privada → binário, e o último passo é best-effort:

passofalha derruba o fluxo?por quê
1. showDirectoryPickersimsem pasta não há onde gravar nada. Cancelar (AbortError/NotAllowedError) NÃO é falha — volta ao estado inicial, sem alerta
2. brabo-runner.config.jsonsimé metade do par que a RN-466 lê
3. par Ed25519 + registerRunnerDeviceKey + …device-key.jwk.jsonsimprivada em disco sem contraparte no servidor não autentica; por isso o registro vem ANTES da gravação, e as duas são adjacentes
4. binárionãodevolve falhaDoBinario preenchido; a instrução final vira npm install -g @brabo/runner && brabo-runner

O caminho alternativo funciona sem flag nenhuma exatamente porque os passos 2 e 3 já rodaram: o CLI lê os dois arquivos do cwd (RN-466). É um dos TRÊS caminhos de distribuição que o CLAUDE.md declara, e o único que não depende nem da release nem de um checkout do monorepo. O baixarKitManual (fora do Chromium) segue a mesma régua invertendo a ordem dos dois downloads: o kit sai primeiro, o binário por último.

O passo do terminal é humano, e a tela nunca finge o contrário. Uma página web não executa binário na máquina de ninguém, e a File System Access API não preserva o bit de execução — daí o chmod +x continuar no comando. O que o produto faz é encolher esse passo a UMA linha copiável em um clique; nenhum texto de UI diz "instalação automática".

E ele é anunciado ANTES do clique, não só depois. A frase que explica o passo existia desde o início, mas era renderizada apenas no estado de SUCESSO — depois de a pessoa escolher a pasta, esperar o registro da chave e o download do binário. Quem clica num botão chamado "Configurar pasta automaticamente" e só então descobre que ainda precisa abrir um terminal foi surpreendido, mesmo sem nenhuma frase ter mentido: anunciar no fim é o mais tarde possível para ainda não ser fingimento. Agora há um aviso no estado inicial, ao lado do botão, e o do fim CONTINUA — as duas dizem coisas diferentes (uma avisa que o passo virá, a outra explica por que ele existe). O rótulo do botão não mudou: ele fala da PASTA, que é de fato configurada automaticamente, e trocá-lo descreveria pior o que ele faz.

O comando final diz em que pasta rodar, quando dá para afirmar qual é. A instrução dizia "dentro da pasta escolhida, rode: …" sem nunca dizer onde essa pasta fica — a File System Access API expõe só o basename (dirHandle.name), nunca o caminho absoluto. O caminho existe do outro lado: é o projects.workspace_path que a pessoa digitou ao criar o projeto. Quando o basename dele bate com a pasta escolhida, a instrução ganha um prefixo cd <caminho> && ; quando NÃO bate, sai sem prefixo — nada obriga a pessoa a escolher no seletor a mesma pasta que digitou, e um cd para o lugar errado seria a tela afirmando o que não sabe. O basename é o máximo que se prova daqui, e o caso raro em que ele coincide para pastas diferentes falha ALTO (cd não acha o caminho, ou ./brabo-runner não está lá), nunca em silêncio — que é a régua que decide se uma heurística pode entrar. O kit manual (fallback fora do Chromium) não recebe o prefixo, e não é esquecimento: lá os arquivos caem na pasta de downloads, e cd mandaria para onde eles ainda não estão.

Lacuna declarada: fechar a aba entre o passo 3 e o fim deixa uma chave de dispositivo órfã no projeto. Ela é inerte — a privada correspondente só existiu na memória daquela aba, e sem ela a chave não autentica nada — mas hoje é invisível: RunnerDeviceKeysController tem POST e DELETE, e nenhuma rota de LISTAGEM, então não há tela onde revogá-la. Refazer o fluxo registra uma chave nova, que funciona. Uma listagem é trabalho direto sobre o DELETE que já existe, quando for pedido.

  • Onde: apps/web/src/lib/runner-bootstrap.ts (configurarPastaAutomaticamente, baixarKitManual, COMANDO_VIA_NPM), apps/web/src/components/RunnerOnboardingPanel.tsx (ehCancelamentoDoSeletor, o bloco de desfecho)
  • Teste: apps/web/src/lib/runner-bootstrap.test.ts (a ordem dos quatro passos afirmada como lista; binário 502 e falha de GRAVAÇÃO do binário gravando os outros dois arquivos e devolvendo o comando alternativo; cancelamento do seletor sem registrar chave nem baixar nada; o kit saindo mesmo com o binário em 502), apps/web/src/components/RunnerOnboardingPanel.test.tsx (a pasta é anunciada com o motivo da falha ao lado; cancelar não vira alerta)
  • ADR: 0118 — esta RN revisa a ORDEM do fluxo que ele estabeleceu, não o mecanismo
  • Origem: pedido do dono do produto

RN-474 — Depois da instrução, a tela ESPERA o runner: três estados, teto, e o caminho que vale é o que o runner reportou​

Configurar a pasta e mostrar o comando deixava a pessoa sem saber se tinha dado certo. O quarto passo é uma espera que resolve sozinha (apps/web/src/components/EsperaDoRunner.tsx), montada pelo RunnerOnboardingPanel assim que a configuração termina — nos dois caminhos (pasta gravada e kit baixado).

O mecanismo é o que já existia. O sinal é project.workspaceVerifiedAt, o carimbo que ConfirmProjectWorkspaceUseCase grava quando o runner conecta (RN-423) e que o engine usa como PORTÃO (terminal_executor.ex recusa executar em projeto runner com workspace_verified_at nulo) — a definição do próprio produto de "este projeto tem runner". É o mesmo dado que AmbienteDoProjeto já lê (RN-468), na mesma chave de cache ['project', id] que a página inteira mantém. A alternativa considerada foi sondar connectFsBrowserChannel (cujo erro 'Nenhum runner conectado' o FolderBrowserModal detecta): ela sabe do AGORA, que é mais forte, mas o canal grava erroDeConexao de forma permanente por instância, então cada sondagem custaria ticket + socket NOVOS — dezenas ao longo da espera — e ela ainda não responderia a segunda metade do que a tela deve dizer: QUAL caminho o runner reportou.

Confirmado é o carimbo MUDAR, nunca "existir". A espera tira uma linha de base no primeiro GET e compara contra ela. Sem isso, um projeto já confirmado antes seria anunciado como recém-conectado no instante em que a tela abrisse.

Os três estados não colapsam, e nenhum é eterno (RN-088, RN-468):

estadoo que a tela diz
esperando"procurando o runner", com o teto DECLARADO ("paramos depois de 3 minutos")
confirmadoa data do carimbo E o workspacePath que o runner reportou, dizendo que ele substitui o que foi digitado — a tela não compete com quem roda no host de verdade
semRespostao teto estourou, e a tela declara o que NÃO sabe: reconectar com uma pasta já confirmada não regrava o carimbo, então ausência aqui não é prova de ausência, e quem sabe do agora é a aba Código. Um botão recomeça a busca sem refazer a configuração
  • Onde: apps/web/src/components/EsperaDoRunner.tsx (INTERVALO_MS, TETO_MS, a linha de base em base), apps/web/src/components/RunnerOnboardingPanel.tsx (quem a monta)
  • Teste: apps/web/src/components/EsperaDoRunner.test.tsx (os três estados, um a um, cada um afirmando que os outros dois NÃO estão na tela; o carimbo preexistente que não conta como conexão nova; o teto estourando com temporizador falso e o botão que recomeça)
  • ADR: 0118
  • Origem: pedido do dono do produto

O kid é o vínculo, e a recusa tem nome (RN-475)​

RN-475 — A JWK privada gravada pelo navegador carrega o kid do registro; e o CLI distingue "não há chave" de "há chave e ela não serve"​

Revisada pela RN-687 (ADR 0203, 01/10): a metade do NAVEGADOR saiu com o fluxo (e o e2e que a provava num Chromium); o kid carimbado continua valendo para a chave criada no terminal (device-key finish --id, RN-551), e a metade do CLI — ausente × recusada — continua inteira.

O modo automático do ADR 0118 (RN-464..466) nunca autenticou nenhuma vez desde que nasceu. As duas metades desta RN são o defeito e o que o escondeu.

O kid não é decoração — é o único vínculo entre os dois lados. O id que POST projects/:projectId/runner-device-keys devolve identifica o registro runner_device_keys que guarda a chave PÚBLICA. Ele precisa chegar ao disco DENTRO da JWK privada, no campo kid (RFC 7517), porque é assim que a cadeia inteira o carrega: lerChaveDeDispositivo só REPASSA jwk.kid (nunca inventa nem deriva um id), assinarTicketComChaveDeDispositivo o põe no header protegido do JWT de ticket, e o PatAuthGuard usa exatamente esse kid para achar a pública e verificar a assinatura (RN-465).

O navegador descartava o retorno de registerRunnerDeviceKey nos DOIS caminhos (configurarPastaAutomaticamente e baixarKitManual), e crypto.subtle.exportKey exporta a JWK CRUA, sem kid nenhum. O produtor gravava um arquivo que o consumidor recusa sempre — e o defeito era invisível dos dois lados: cada função estava certa sozinha. A prova está no banco: as chaves registradas pelo fluxo tinham last_used_at nulo, todas.

A correção é estrutural, não uma linha: o passo 3 da RN-473 (exportar a pública, registrar, exportar a privada) vira UMA função (registrarChaveEExportarPrivada), com o id fluindo dentro dela. Descartá-lo de novo passa a exigir apagar código, não esquecer uma atribuição. O kid vai só na PRIVADA: a pública não o leva, porque é o registro dela que o produz.

A recusa tem nome. lerChaveDeDispositivo devolve null sem lançar — de propósito, e isso não muda: a ausência dos arquivos é o caminho NORMAL de quem roda com --project/--dir/--token. Mas null respondia a duas perguntas diferentes, e o CLI imprimia o MESMO bloco de uso() — que fala de flags e não menciona o arquivo — tanto para "você não configurou nada" quanto para "sua pasta está configurada e a chave não serve". Uma pessoa com brabo-runner.config.json perfeito era mandada investigar justamente o lado que estava certo.

estadoquem respondesaída do CLI
ausentecaminho normalo bloco de uso(), inalterado
json-invalidorecusaexplicacaoDaChaveRecusada — nomeia o arquivo, o motivo, e as duas saídas (regravar a pasta pelo navegador, ou --token enquanto isso)
sem-kidrecusaidem, nomeando o campo que falta e para que ele serve
validasegue o fluxo—

estadoDaChaveDeDispositivo e explicacaoDaChaveRecusada vivem em device-key.ts porque falam do ARQUIVO; o que FAZER com a recusa (sair, com qual código, com ou sem o bloco de uso) continua sendo do index.ts, como o docblock do módulo já dizia. O tipo de explicacaoDaChaveRecusada exclui ausente e valida: não existe explicação para o caso normal nem para o sucesso, e um texto vago cobrindo os quatro estados seria o mesmo defeito de novo.

O que deixou passar: o teste do web afirmava que o arquivo tinha sido ABERTO (getFileHandle chamado com o nome certo), nunca o que havia DENTRO dele. Um teste que prova a criação de um arquivo sem provar seu conteúdo deixa gravar qualquer coisa. O dublê passou a amarrar nome → conteúdo, e as duas asserções antigas ganharam a metade que faltava.

  • Onde: apps/web/src/lib/runner-bootstrap.ts (registrarChaveEExportarPrivada, exportarJwkPrivada), apps/runner/src/device-key.ts (estadoDaChaveDeDispositivo, explicacaoDaChaveRecusada, EstadoDaChaveLocal), apps/runner/src/index.ts (o ramo de credencial em lerArgumentos)
  • Teste: apps/web/src/lib/runner-bootstrap.test.ts (o kid gravado É o id devolvido, provado com um id diferente do padrão; os dois caminhos — pasta e kit manual; a pública SEM kid; a chave útil sobrevivendo ao binário em 502), apps/runner/src/device-key.spec.ts (os quatro estados, um a um; ausente ≠ sem-kid enquanto lerChaveDeDispositivo colapsa os dois em null; as duas frases de recusa diferentes entre si), apps/runner/src/index.spec.ts (o CLI DE VERDADE, em processo separado: a pasta sem chave cai no bloco de uso, a pasta com chave sem kid cai na recusa nomeada, e as duas saídas não são iguais — o defeito era elas serem)
  • ADR: 0118 — esta RN não revisa o mecanismo dele, fecha o contrato que ele deixou implícito entre as duas pontas
  • Origem: uso real do fluxo contra o ambiente local pelo dono do produto

RN-477 — Provisionamento que falha DIZ que falhou, e a espera por ele tem teto​

O provisionamento de repositório roda inteiro dentro do POST — não há worker nem fila atrás dele (a descrição de OpenAPI que prometia "continues in the background" estava errada e foi corrigida). Uma falha, portanto, já aconteceu quando a resposta chega. Esta RN é sobre a tela nunca afirmar "provisionando" sobre um trabalho que já terminou mal.

Toda falha vira estado DURÁVEL, e há dois caminhos que não viravam. O primeiro é step.check(), que ficava fora de todo try/catch no BootstrapRunner e faz IO de rede: um 401 de token expirado, um 403 ou um timeout subiam sem tocar a linha, que continuava pending com lastError: NULL. O segundo é a recusa em createRepo, que acontece antes de a linha existir — repo_bootstraps só nasce depois de o provider confirmar o repositório, então o projeto ficava com ZERO linha. Agora o primeiro grava status: 'failed' + lastError num lugar só (o catch da etapa) e emite bootstrap.step_failed; e o segundo é lido pelo endpoint de status a partir da proposed_action de git_repo_create que falhou.

A falha de criação NÃO inventa um passo. failedStep fica null: o repositório não chegou a existir, então nenhum dos seis passos do Gitflow foi tentado, e nomear um para preencher a frase seria trocar um silêncio por uma informação errada. A tela tem um título próprio para esse caso, sem {{step}}.

A tela mostra o motivo e oferece saída. O .catch(() => {}) de corpo vazio da ProvisioningPage — cujo comentário afirmava que "a falha aparece via bootstrapQuery", o que era falso nos dois caminhos acima — passa a guardar a mensagem da api e exibi-la. O botão "Tentar novamente" deixa de depender de status === 'provision_failed': ele aparece também quando o POST recusou, que é justamente quando não há status de falha para depender.

A espera tem TETO e três estados que não colapsam, exatamente como a RN-474 e pelo mesmo motivo: o poll de 1s era infinito. Passados 3 minutos sem convergir, a tela diz que parou de acompanhar, declara que isso não prova fracasso (o provisionamento roda dentro da requisição, e um provider lento pode ainda estar trabalhando) e oferece "procurar de novo" — que rearma a espera sem disparar um segundo POST, porque um segundo POST criaria mais uma sessão de bootstrap.

O card do dashboard sabe. O read model consulta as ações git_repo_create falhadas junto com as linhas de bootstrap, então um projeto que falhou antes de existir cursor deixa de aparecer sem badge nenhum — e o clique volta a levar para a tela de provisionamento, que só desviava em provision_failed.

  • Onde: apps/api/src/application/use-cases/git/bootstrap-runner.ts:181 (o catch da etapa, único lugar que declara o fracasso), apps/api/src/domain/git/repo-bootstrap-status.ts:29 (o segundo argumento), apps/api/src/application/use-cases/git/get-repo-bootstrap-status.use-case.ts:59 (a falha de criação lida da proposed_action), apps/api/src/infrastructure/persistence/drizzle/projects-summary.repository.ts (o badge do card), apps/web/src/routes/ProvisioningPage.tsx (motivo, retry e teto)
  • Teste: apps/api/test/application/use-cases/git/provision-repository.use-case.spec.ts (falha no CHECK vira failed com motivo e evento; falha ao CRIAR não deixa linha mas o status reporta, com failedStep nulo), apps/api/test/domain/git/repo-bootstrap-status.spec.ts (sem linha com falha de criação; com linha a falha antiga é ignorada; pending puro), apps/web/src/routes/ProvisioningPage.test.tsx (o motivo aparece; o retry existe sem provision_failed; a espera para e diz o que não sabe; título sem passo não inventa um)
  • Origem: uso real do dono do produto — o provisionamento ficou "Pendente" para sempre, e a causa (permissão negada: /data/git-repos/exp001.git, um volume Docker de desenvolvimento que nascia root) estava gravada só em proposed_actions.execution_result, que nenhuma tela lê

A busca do RAG deixa rastro, e o rastro congela os pesos (RN-479..481)​

RN-478 — O permissions.json mora onde a api ALCANÇA, o escopo do terminal aponta para o HOST​

A raiz do projeto tinha uma derivação (projectScopeRoot) e dois consumidores com necessidades opostas. Isso estava certo enquanto os dois modos com pasta de usuário eram bind-mount; deixou de estar quando o modo runner nasceu (RN-423, ADR 0104), porque ele é deliberadamente sem bind-mount.

consumidorprecisa depor quê
escopo de terminal (ADR 0055)o caminho do hosté lá que o comando roda — na máquina do usuário, pelo runner
permissions.json (a api lê e escreve)um caminho que a api alcanceela o escreve de dentro do container dela

São duas derivações, e as duas moram no mesmo arquivo — a fonte continua única, o que se separou foi a pergunta. projectScopeRoot fica inalterada; permissionsFilePath é nova e diverge dela em um único ponto: no modo runner o arquivo vai para <PROJECT_WORKSPACES_ROOT>/<workspace_dir_name>/permissions.json, a raiz gerenciada, chaveada pelo nome que a RN-109 congela na criação. container e mounted não mudam — em mounted a pasta é bind-mount, e mover o arquivo quebraria projetos que já o têm em disco sem ganhar nada.

Por que a raiz gerenciada e não o disco do usuário, já que o código está lá: permissions.json é política, não código do projeto. Quem a lê é a api; o runner nunca a lê (recebe comando já aprovado) e o engine não a toca em ponto nenhum — todas as menções a ela em apps/engine/lib são comentário. Guardá-la na máquina do usuário a tornaria editável por quem ela restringe, e ilegível justamente quando o runner está desconectado, que é quando a decisão precisa continuar valendo.

O que isso corrigiu. A ativação da execução é a primeira escrita do arquivo, e ela fazia mkdir -p de um caminho do host dentro do container da api: EACCES: permission denied, mkdir '/home/<usuario>' → 500. A leitura degradava calada (ENOENT → EMPTY_PERMISSIONS_FILE), e é por isso que o efeito maior atravessou sem ser visto: em projeto runner o permissions.json nunca existiu, e decide() sempre caiu em require_approval por um arquivo que não estava lá.

Custo declarado: para projeto runner, o arquivo de política deixa de morar ao lado do código — quem o procurar na pasta do projeto não vai achar.

Recusa tipada, e 400 em vez de 500. Os dois throw new Error(...) da derivação viram LocalizacaoDeProjetoInvalidaError, no molde de CaminhoLocalInvalidoError: motivo legível em pt-BR, e ActivateExecutionUseCase o mapeia para 400. Uma linha de projeto incoerente (o par (modo, caminho) só se torna inválido sendo gravado por fora da criação) passa a dizer o que corrigir em vez de virar 500 sem corpo. A tela da Visão geral passa a mostrar essa mensagem — ela imprimia a constante "Não foi possível ativar a execução" enquanto o botão gêmeo do chat da sessão já usava mensagemDaApi: mesmo botão, dois diagnósticos.

A lacuna que FICA, declarada: o engine tem o mesmo defeito e ele não foi corrigido aqui. Engine.Actions.Workspace.ensure!/4 faz File.mkdir_p!(workspace_dir(project_id)), que em projeto runner é o caminho do host — o working tree do dev agent não tem onde nascer. Não vira 500 (o rescue de ensure_remoto/2 devolve {:error, …}), vira dev agent que não trabalha. Corrigi-lo isolado seria materializar worktree no host por um caminho que a execução em container substitui; o que muda agora é só a mensagem, que nomeia a causa em vez de repassar "permissão negada" cru.

  • Onde: apps/api/src/infrastructure/filesystem/project-workspaces-root.ts:231 (projectScopeRoot, inalterada) e :319 (permissionsFilePath, a segunda derivação), com LocalizacaoDeProjetoInvalidaError em :219, apps/api/src/infrastructure/filesystem/fs-permissions-file-store.ts:77 (o único chamador do caminho do arquivo), apps/api/src/application/use-cases/execution/activate-execution.use-case.ts:176 (o 400), apps/api/src/interfaces/http/execution/execution.controller.ts:74 (a anotação de OpenAPI, que prometia 409 para dois casos que nunca foram 409), apps/web/src/routes/ProjectOverviewTab.tsx:481 (mensagemDaApi), apps/engine/lib/engine/actions/workspace.ex:61 (a mensagem da lacuna que fica)
  • Teste: apps/api/test/infrastructure/filesystem/project-workspaces-root.spec.ts (permissionsFilePath nos três modos; e a não-regressão de projectScopeRoot continuar devolvendo o caminho do host em runner/mounted — unificar as duas de volta quebraria o ADR 0055), apps/api/test/application/use-cases/execution/activate-execution.use-case.spec.ts (com o FsPermissionsFileStore DE VERDADE: o arquivo cai na raiz gerenciada e a pasta do host fica intocada; linha incoerente vira 400 que ensina), apps/web/src/routes/ProjectOverviewTab.test.tsx (a mensagem da api na tela), apps/engine/test/engine/actions/workspace_runner_test.exs (a falha nomeia a causa em projeto runner, e passa intacta nos outros modos)
  • Origem: uso real do dono do produto — "Ativar execução" respondendo 500 no projeto exp002

RN-485 — O broker de container não aceita especificação: ele recebe um projeto e COMPÕE​

O broker (apps/broker) é o único processo do produto com acesso a um daemon Docker no servidor. A regra que o torna não-arbitrário não é um allowlist: é a forma da entrada.

Ele recebe projectId e uma das cinco operações — nada mais. As cinco são as da DockerPort do ADR 0128 (start, stop, remove, inspect, exec), e uma sexta é decisão de produto com ADR, nunca um parâmetro a mais. start, stop e remove têm corpo VAZIO; exec leva comando e cwd, e mais nada.

A especificação é lida da api e COMPOSTA aqui. O broker chama GET /internal/projects/:projectId/container-spec, que devolve identidade do projeto, modo de execução e a decisão de imagem vigente do Arquiteto. Imagem, rede, recursos e o único mount saem daí. Não existe campo em que se escreva privileged, cap_add, network: host ou um -v livre — porque não existe campo. Se a especificação viajasse no corpo, a contenção de um processo root-equivalente no host dependeria de o CHAMADOR estar correto, e contenção que depende do chamador não é contenção.

Ele revalida o que a api devolveu, e as duas validações não são a mesma. validarDecisaoDeImagem (api) pergunta "esta decisão de arquitetura é revisável?" — exige rationale, recusa latest, aplica RECURSOS_MAXIMOS e devolve a recusa ao MODELO pelo tool-result (RN-061). especificacaoValidada (broker) pergunta "posso entregar isto ao daemon?" — é o PARSE de um JSON não confiável para dentro do tipo fechado. Os tetos daqui são os do BROKER, o último recurso que ele nunca ultrapassa venha o pedido de onde vier; hoje os números coincidem com os da api de propósito, e se um dia divergirem o menor vence sem nada quebrar, porque nenhum dos dois afirma ser o outro. Uma checagem existe só deste lado: referência de imagem que começa com - seria lida pelo CLI como FLAG, e execFile sem shell resolve injeção de COMANDO, não de ARGUMENTO.

A api não manda CAMINHO nenhum. O -v de um docker run é resolvido pelo DAEMON, contra o filesystem do HOST — um caminho de dentro do container da api faria o daemon criar e montar uma pasta VAZIA, com o dev agent trabalhando num diretório sem código e nada indicando por quê. O broker compõe o caminho com PROJECT_WORKSPACES_HOST_ROOT, configuração DELE, mais o workspaceDirName congelado na criação (RN-109). Sem essa variável, start RECUSA nomeando-a; as outras quatro operações continuam funcionando. Recusar é a regra: adivinhar produziria o mount vazio em silêncio.

Projeto mounted/runner é recusado com 409, porque a pasta deles mora na máquina do usuário e o host do broker não a enxerga — lá quem sobe container é o runner. É a mesma política que RegistrarTransicaoDeContainerUseCase já aplica na api, dita onde o broker consegue dizê-la.

Nada dispara subida. Não há laço, não há fila, não há proposed_action de container_start: o broker age quando chamado, e o único chamador que existe hoje faz LEITURA. Efeito externo continua exigindo aprovação.

  • Onde: apps/broker/src/operacoes.ts (as cinco, e a composição em especificacaoDoProjeto), apps/broker/src/servidor.ts (a lista fechada de rotas e a tabela de status), packages/docker-port/src/spec-de-container.ts (o parse e os tetos), apps/api/src/application/use-cases/containers/obter-spec-de-container.use-case.ts (o que a api devolve, e o caminho que ela não devolve), apps/api/src/interfaces/http/internal/internal-containers.controller.ts
  • Teste: apps/broker/src/servidor.spec.ts (imagem, rede, recursos e mount mandados no corpo são IGNORADOS; artefato com imagem que começa com - é 422 e nada é tocado; uma sexta operação é 404; cwd fora de /work é recusado, inclusive /workspace; sem PROJECT_WORKSPACES_HOST_ROOT o start recusa dizendo qual variável falta), packages/docker-port/src/spec-de-container.spec.ts (o parse campo a campo), apps/api/test/application/use-cases/containers/spec-e-observacao-de-container.use-case.spec.ts (a api não devolve caminho nenhum, nem rationale), apps/api/test/infrastructure/http-clients/container-broker.client.spec.ts (start manda corpo vazio)
  • ADR: 0130

RN-486 — Estado REGISTRADO e estado OBSERVADO nunca se fundem, e "não olhei" tem motivo próprio​

project_containers guarda o que foi REGISTRADO. O daemon responde o que é OBSERVADO. Antes do broker a tabela não tinha como mentir (container_id era sempre NULL); agora tem, e a leitura diz isso em vez de escondê-lo.

A rota de ciclo de vida devolve os dois, separados. Container morto por fora aparece como registrado running e observado exited, e é assim que tem de aparecer. A reconciliação é NA LEITURA, não um daemon de fundo.

observado: null sozinho não é resposta. Ele significa duas coisas diferentes, e naoObservado é o que as separa: null ali quer dizer que a observação ACONTECEU e voltou vazia — a afirmação positiva "olhei e não há container" —, enquanto broker-nao-configurado, broker-sem-resposta e broker-recusou querem dizer que não deu para olhar, cada um com um conserto diferente. Herdar o estado registrado nesses três casos é exatamente o que a RN-468 proíbe: sinal de ambiente diz o que SABE, e proxy não vira garantia.

Nenhuma recusa do broker derruba a leitura. O ciclo de vida registrado é informação legítima por si só e existia antes do broker; perdê-lo porque o broker está fora trocaria um dado que temos por um que não temos.

Lacuna declarada: container órfão de projeto que nunca teve linha de ciclo de vida não aparece nessa rota — ela lê o registrado primeiro. Quem acha órfão é a varredura por brabo.managed=true, e a página que a consome ainda não existe.

  • Onde: apps/api/src/application/use-cases/containers/obter-estado-observado-do-container.use-case.ts (os três motivos e o null que não é motivo), apps/api/src/interfaces/http/containers/containers.controller.ts (a rota que devolve os dois), apps/api/src/application/ports/container-broker.port.ts (o null de inspect é ausência; a falha LANÇA)
  • Teste: apps/api/test/application/use-cases/containers/spec-e-observacao-de-container.use-case.spec.ts ("olhei e não há" e "não consegui olhar" não colapsam; sem BROKER_URL o broker nem é chamado; recusa vira motivo com detalhe, nunca exceção)
  • ADR: 0130

RN-479 — Toda busca híbrida grava uma linha em rag_searches, com os pesos CONGELADOS; e a telemetria nunca derruba a busca​

apps/api/src/domain/rag/rag-search-limits.ts declara, no próprio comentário, que nenhum dos quatro números da busca híbrida (os dois pesos, o limiar e o número de candidatos) vem de calibração com dado real. Não vinha porque não havia como: a busca não deixava rastro nenhum — nem linha de tabela, nem evento. Calibrar sem medir seria trocar um chute por outro.

HybridSearchUseCase passa a gravar, a cada busca, o que ela devolveu (hits, com o rank 1-based de cada trecho), sob que condições (degraded/vector_available), quanto demorou (latency_ms), quem buscou (actor_kind/actor_id) e em que sessão — ou em NENHUMA.

Por que TABELA e não só evento de sessão. session_events.session_id é NOT NULL, e uma busca vinda da aba de RAG é de PROJETO: não tem sessão. Registrar só como evento perderia exatamente as buscas em que um humano olhou os scores e julgou, que são as que carregam o sinal de verdade. É a mesma classe de problema que forçou o corte do metering de embedding (ADR 0075, token_usage.session_id NOT NULL), e a saída é a mesma: tabela própria, com session_id NULLABLE.

pesos congelado na linha é o ponto. pesosVigentes() copia os valores do momento para dentro do registro — mesma disciplina do preço congelado no metering (ADR 0042) e da image_version em project_containers. Sem a cópia, a primeira calibração que mexer em RAG_SEARCH_WEIGHT_VECTOR faria toda a medição anterior passar a significar outra coisa, calada — e "melhorou depois da mudança?", a única pergunta que a telemetria existe para responder, ficaria impossível de fazer.

Gravar telemetria NUNCA derruba a busca, e também não falha CALADA. Quem pergunta não deveria perder a resposta porque o instrumento de medição caiu; e o repositório é explícito sobre falha que vira silêncio (RN-059). Então o INSERT que falha vira log com a origem classificada (infra, RN-023) e searchId: null na resposta — que não é o mesmo que "não houve resultado": é "não há a que anexar voto", e a tela precisa dos dois separados para não oferecer um controle que a api recusaria (RN-088).

degraded e vector_available são duas colunas de propósito, e a redundância de hoje (degraded = !vector_available) está declarada no schema em vez de escondida: vector_available é um fato sobre o PROVIDER — é ele que faz medir:rag reprovar —, e degraded é a palavra do CONTRATO com o engine, cuja definição pode crescer.

  • Onde: apps/api/src/domain/rag/rag-telemetry.ts (pesosVigentes, RagSearchHitTelemetry), apps/api/src/db/schema/rag.ts (ragSearches), apps/api/src/application/use-cases/rag/hybrid-search.use-case.ts (registrarBusca), apps/api/src/infrastructure/persistence/drizzle/rag-telemetry.repository.ts
  • Teste: apps/api/test/application/use-cases/rag/hybrid-search.use-case.spec.ts (a linha gravada com rank e pesos; os pesos serem CÓPIA e não o mesmo objeto; a busca da aba sem sessão; e o CASO DE FALHA — insert que falha não derruba a busca e devolve searchId: null)
  • ADR: 0129 — por que é TABELA e não evento, e por que os pesos vão congelados; 0080, cujos números esta RN não revisa: ela dá o instrumento para revisá-los depois
  • Origem: plano do dono do produto, Parte 2 / Etapa 1

RN-480 — O voto sobre um trecho é o único sinal de VERDADE da medição, e ele exige a referência que a busca devolveu​

Latência e taxa de degradação dizem se a busca RODOU; só o voto diz se ela ACERTOU. rag_feedback guarda esse voto — dois valores (util/irrelevante) e não uma escala de 1 a 5, porque escala fina convida a diferenças de régua entre quem vota que nenhuma agregação recupera depois.

A referência é obrigatória, e as duas recusas são 400 que ENSINAM. searchId que não existe naquele projeto, e chunkId que não estava entre os hits daquela busca, são recusados antes de qualquer escrita. Não é rigor decorativo: o rank do trecho votado é o que separa dois diagnósticos que a precision@1 confunde — índice pobre não devolve o trecho certo em posição nenhuma, peso errado devolve o trecho certo em rank 7. Voto sem rank produz número sem significado, que é pior que número nenhum.

A recusa vale nos dois caminhos: pela aba vira 400 na tela; pelo agente vira tool-result de erro que o modelo pode corrigir na iteração seguinte (RN-061/RN-163), nunca crash.

Um voto por ator por trecho por busca (unique (search_id, chunk_id, actor_id), com onConflictDoUpdate): mudar de ideia sobrescreve o próprio voto. Sem a trava, quem clicasse duas vezes pesaria o dobro na precision@k e a métrica passaria a medir entusiasmo.

O papel é viewer, o MESMO de search — quem pode ler o resultado é quem pode julgá-lo (RN-102: o mínimo é do endpoint, e votar não gasta nem configura nada). A ferramenta do agente é :direct, nunca proposed_action: dar nota a um trecho não é efeito externo, e transformá-la em ação a aprovar encheria a fila de ruído.

  • Onde: apps/api/src/db/schema/rag.ts (ragFeedback, ragVerdictEnum), apps/api/src/application/use-cases/rag/record-rag-feedback.use-case.ts, apps/engine/lib/engine/harness/tools/rag_feedback.ex, apps/web/src/components/rag/RagCitationCard.tsx (os dois controles, onde o score já está)
  • Teste: apps/api/test/application/use-cases/rag/record-rag-feedback.use-case.spec.ts (o rank devolvido; e os três CASOS DE FALHA — searchId desconhecido, chunkId fora dos hits, busca de outro projeto), apps/engine/test/engine/harness/tools/rag_feedback_test.exs (a recusa voltando como tool-result com a mensagem da api, nunca crash; :direct; e o registro nos seis agentes que já tinham rag_search), apps/web/src/components/rag/RagCitationCard.test.tsx (os três estados do controle, e a ausência dele sem searchId), apps/api/test/scripts/medir-rag.spec.ts (precision@k sobre o julgado)
  • ADR: 0129
  • Origem: plano do dono do produto, Parte 2 / Etapa 1

RN-481 — rag.search/rag.feedback são NARRAÇÃO da timeline, e só existem quando há sessão​

Os dois eventos entram no event log apenas no caminho do agente — a busca da aba é de projeto e não tem sessão onde narrar. Isso é assimetria declarada, não lacuna: a tabela é a fonte da medição, o evento é narração. Medir pelo event log perderia metade das buscas, e justamente a metade com julgamento humano.

Falha de narração não derruba nem a busca nem o voto: a linha da tabela já está gravada, e é ela que a medição lê. A falha vira log com origem infra, nunca silêncio.

'rag' entra em PREFIXOS_DE_EVENTO (scripts/docs/generate.mjs) no mesmo commit — sem isso o inventário gerado de docs/reference/events.md não enxerga os dois tipos, e pnpm docs:check ficaria verde sobre uma lista incompleta: uma doc que passa mentindo é pior que uma doc que reprova.

  • Onde: apps/api/src/domain/rag/rag-telemetry.ts (EVENTO_RAG_SEARCH/EVENTO_RAG_FEEDBACK), apps/api/src/application/use-cases/rag/hybrid-search.use-case.ts, apps/api/src/application/use-cases/rag/record-rag-feedback.use-case.ts, scripts/docs/generate.mjs (PREFIXOS_DE_EVENTO)
  • Teste: apps/api/test/application/use-cases/rag/hybrid-search.use-case.spec.ts (narra com sessão; NÃO narra sem sessão), apps/api/test/application/use-cases/rag/record-rag-feedback.use-case.spec.ts (o mesmo par, para o voto)
  • ADR: 0129
  • Origem: plano do dono do produto, Parte 2 / Etapa 1

RN-487 — O Arquiteto roteia módulo por módulo, mas só CANDIDATA — quem elege é a Infra​

route_modules_to_infra produz UMA lista — um item {modulo, imagemCandidata, porque} por módulo do module_map vigente — e ela vira o evento artifact.module_routing, sem tabela: mesmo desenho de artifact.module_map/ artifact.project_image/artifact.c4_diagram, o evento É o artefato.

A imagem candidata passa pela MESMA regra de choose_project_image, aplicada por item. validarDecisaoDeImagem já recusa imagem sem tag/digest explícito, latest, e rationale com menos de 10 caracteres — reimplementar essa regra por módulo criaria uma segunda versão dela para divergir da primeira cedo ou tarde. validarRoteamento (domain/architecture/module-routing.ts) delega, e só acrescenta o que é PRÓPRIO da lista: vazia é recusada (não é uma decisão), e módulo repetido também (duas imagens para o mesmo módulo são ambíguas — qual vale?).

Módulo fora do module_map vigente é recusado nomeando os módulos VÁLIDOS, mesma régua de AssignStoryModulesUseCase (missingModules): listar só o que está errado obrigaria o modelo a adivinhar contra um mapa que ele não pode reler; listar os nomes certos encerra a busca na primeira recusa.

Sem module_map vigente, não há módulo — recusado com 400, nunca inventado. É a mesma recusa de create_c4_diagram quando falta module_map, e pelo mesmo motivo: não há módulo, não há infra para rotear. O build_kickoff/1 do ArquitetoServer — o ÚNICO lugar de onde o modelo aprende a ORDEM das ferramentas — lista route_modules_to_infra como o passo que segue create_module_map.

:direct, nunca proposed_action. Rotear não tem efeito externo — não sobe container, não muda nada fora do event log — e é decisão INTERNA de arquitetura, do mesmo calibre de choose_project_image/create_c4_diagram. Colocar isto na fila de aprovação misturaria um rascunho que a Infra ainda vai revisar com decisões que já têm efeito real.

Arquiteto candidata, Infra elege — e esta entrega é só a metade que candidata. A metade que ELEGE entre as candidaturas (ou recusa todas) é do Infra Lead, com proposed_action própria, num PR à parte. O que existe aqui é a lista auditável no event log, antes de a Infra ter ferramenta para agir sobre ela — mesmo sequenciamento que já valeu para o module_map existir antes de create_c4_diagram precisar dele.

  • Onde: apps/api/src/domain/architecture/module-routing.ts (validarRoteamento, EVENTO_MODULE_ROUTING), apps/api/src/application/use-cases/architecture/route-modules-to-infra.use-case.ts, apps/api/src/application/use-cases/architecture/get-module-routing.use-case.ts, apps/api/src/interfaces/http/internal/internal-sessions.controller.ts (POST .../module-routing), apps/engine/lib/engine/harness/tools/route_modules_to_infra.ex, apps/engine/lib/engine/agents/arquiteto_server.ex (build_kickoff/1, passo 4)
  • Teste: apps/api/test/domain/architecture/module-routing.spec.ts (lista vazia, módulo repetido, e a delegação a validarDecisaoDeImagem — latest e rationale curto), apps/api/test/application/use-cases/architecture/route-modules-to-infra.use-case.spec.ts (caminho feliz com dois módulos; sem module_map vigente; módulo fora do mapa; versionamento ao rotear de novo), apps/engine/test/engine/harness/tools/route_modules_to_infra_test.exs (:direct; normalização das chaves do tool call; recusa da api virando tool-result de erro, nunca crash — RN-061)
  • ADR: 0131
  • Origem: plano do dono do produto, Parte 1 / PR 1.4

RN-490 — O golden-set de acerto do RAG mede por CAMINHO DE ARQUIVO, no TOP-5, nunca por chunk exato ou rank 1​

O gate rag-acertivo (docs/gates.yml) mede se a busca híbrida devolve o arquivo certo para 17 perguntas compostas a partir de RNs/ADRs reais deste repositório (golden-set, molde do ADR 0123 aplicado ao RAG). O critério de "acertou" tem duas decisões deliberadas, e as duas evitam medir a pergunta errada:

  • Caminho de arquivo, nunca chunk exato. O chunking (1200/150) é justamente um dos parâmetros que este programa existe para poder revisar (ADR 0080) — travar o golden-set no chunk faria QUALQUER ajuste de chunking quebrar os 17 casos, mesmo quando o arquivo certo continuasse sendo recuperado.
  • TOP-5, nunca só a primeira posição. É assim que o produto usa o resultado: o Chat RAG cita VÁRIOS trechos por resposta (RagCitationCard, plural), não só o primeiro. Medir contra rank 1 mediria uma pergunta que a UI não faz. 5, e não o RAG_SEARCH_RESULT_LIMIT (10) da rota real, para não testar um k mais folgado que a maioria das buscas reais usa.

O critério é função PURA (acertouCaminhoEsperado/rankDoCaminhoEsperado), consumida pelo seed (seed-golden-set-rag.ts) e testada isolada — a mesma disciplina de gate-registry.ts: a decisão de "isto bateu" não depende de como o resultado chegou.

O golden-set roda manual (mix golden_set.rag), nunca em mix test comum — mesma decisão já registrada para o golden-set do QA (ADR 0123): sem segredo de LLM de API ou infra nova, não havia como rodar Ollama de verdade em CI. Excluído por tag PERMANENTE (:golden_set_rag) em test_helper.exs, nunca por detecção de "Ollama alcançável" — mesmo motivo do lado QA: esta máquina já tem Ollama de pé o tempo todo. O piso é RATCHET (>=, nunca >), contagem e não porcentagem, chaveado pelo modelo de EMBEDDING (nomic-embed-text, hoje o único que o produto suporta) e escrito só por humano.

Revisada pela RN-498: "não há como rodar Ollama de verdade em CI" era verdade na Etapa 2 e deixou de ser na Etapa 3 (ADR 0138) — um workflow dedicado, agendado, agora roda mix golden_set.rag de verdade contra um Ollama real em CI. A exclusão permanente em test_helper.exs acima CONTINUA valendo como está: ela protege o mix test comum (e a máquina de qualquer desenvolvedor com Ollama de pé), nunca o novo workflow, que invoca a tag explicitamente, do mesmo jeito que sempre foi preciso.

  • Onde: apps/api/src/domain/rag/golden-set-criterio.ts, apps/api/scripts/seed-golden-set-rag.ts, apps/engine/test/engine/rag/rag_golden_test.exs, apps/engine/test/fixtures/golden_set_rag/floor.json
  • Teste: apps/api/test/domain/rag/golden-set-criterio.spec.ts
  • ADR: 0132
  • Origem: plano do dono do produto, Parte 2 / Etapa 2

RN-491 — A Infra elege entre as candidatas do Arquiteto, e a eleição vira uma NOVA versão de artifact.project_image​

propose_container_start fecha a metade que a RN-487 deixou declarada em aberto: a Infra elege UMA das imagemCandidata do roteamento vigente do Arquiteto (artifact.module_routing) e propõe subir o container REAL do projeto. A eleição só tem efeito porque ExecuteContainerStartUseCase REUSA DecidirImagemDoProjetoUseCase para emitir uma nova versão de artifact.project_image com decidedBy: 'infra-lead' — sem isso a eleição seria auditável e INERTE: o broker compõe o container lendo GET .../container-spec, que lê artifact.project_image (RN-105), nunca artifact.module_routing.

A imagem eleita precisa estar na lista de candidatas, sempre — validado ANTES de tocar no artefato. Fora da lista, a ação falha nomeando a imagem recusada e listando as candidatas válidas, e nem DecidirImagemDoProjetoUseCase nem o broker chegam a ser chamados: a Infra elege, nunca inventa.

Novo container_start (maintainer, mesmo calibre de open_infra_pr/ parallelize), deliberadamente FORA do bloco de tetos absolutos de decide.ts — mesmo raciocínio já registrado ali para propose_execution_plan/assess_implementability: é a PRIMEIRA vez que este container sobe de verdade para esta eleição, não uma ultrapassagem de um teto já autorizado. Diferente de open_infra_pr, esta ação NÃO é seedada em INFRA_AUTONOMY_SEEDS — o Infra Lead nunca aplica nada com open_infra_pr (só propõe uma PR que um humano ainda mergeia), mas subir um container é efeito externo real, então fica require_approval por padrão, decidido caso a caso pelo ApprovalCard. Um maintainer PODE configurar auto-aprovação depois, e por isso ProposeActionUseCase também executa no caminho auto_approved, mesma lição do comentário sobre parallelize nesse arquivo ("sem isto a ação nascia, era aprovada — e nada subia").

Revisado pela RN-671 (ADR 0190, 01/10): container_start passa a ser SEMEADO auto_approve no aceite do handoff da Infra, e o servidor do Infra Lead propõe a primeira subida sozinho quando há roteamento. O resto desta regra — a lista de candidatas, maintainer, fora dos tetos absolutos — segue.

Depois do broker confirmar, a transição de ciclo de vida segue a máquina de estados do ADR 0081, nunca reprovisiona à toa. Sem linha ainda, ou linha em failed/removed: provisioning (a que lê a imagem recém-decidida e congela imageVersion) e só então running. Linha em stopped: direto para running — a máquina de estados nem permite stopped -> provisioning, e reprovisionar reemitiria uma imagem que já está gravada na linha. Linha já provisioning/running: completa a transição pendente ou não faz nada, apoiado no start idempotente do broker (jaEstavaDePe).

Revisa a RN-485. "Nada dispara subida... o único chamador que existe hoje faz LEITURA" deixa de ser verdade: container_start é o primeiro chamador real de ContainerBrokerPort.start, das cinco operações que o ADR 0128/0130 declarou. RegistrarTransicaoDeContainerUseCase ganha o primeiro chamador fora de teste também. Continua valendo que NADA dispara sozinho — é sempre uma proposed_action, decidida por um humano ou por política explícita.

Dev agents NÃO passam a trabalhar dentro do container que sobe aqui. Essa etapa é uma PR posterior, declarada em aberto no CLAUDE.md — a frase que a tela de aprovação mostra (apps/web/src/lib/aprovacoes.ts) para de propósito antes de prometer isso.

  • Onde: apps/api/src/application/use-cases/actions/execute-container-start.use-case.ts, apps/api/src/domain/actions/decide.ts (ACTION_TYPES, MIN_ROLE_FOR_ACTION_TYPE), apps/api/src/domain/containers/container-start-execution-result.ts, apps/api/src/application/use-cases/actions/approve-action.use-case.ts, apps/api/src/application/use-cases/actions/propose-action.use-case.ts, apps/api/src/application/use-cases/execution/get-infra-context.use-case.ts (moduleRouting, primeiro consumidor HTTP de GetModuleRoutingUseCase), apps/web/src/lib/aprovacoes.ts, apps/engine/lib/engine/infra/tools/propose_container_start.ex, apps/engine/lib/engine/infra/infra_lead_server.ex
  • Teste: apps/api/test/application/use-cases/actions/execute-container-start.use-case.spec.ts (caminho feliz elegendo candidata; imagem fora das candidatas recusa sem chamar DecidirImagemDoProjetoUseCase nem o broker; BrokerRecusouError/ BrokerIndisponivelError viram failed, nunca propagam; stopped pula direto para running), apps/api/test/domain/actions/decide.spec.ts (container_start exige maintainer; CONSEGUE chegar a auto_approve quando configurado — ao contrário de parallelize/merge protegido/instruction_patch), apps/api/test/application/use-cases/execution/get-infra-context.use-case.spec.ts (moduleRouting presente e ausente), apps/web/src/lib/aprovacoes.test.ts (frase não promete dev agent dentro do container), apps/engine/test/engine/infra/infra_lead_server_test.exs
  • ADR: 0130, 0133
  • Origem: plano do dono do produto, Parte 1 / PR 1.5

RN-492 — O comando de terminal do dev agent roda DENTRO do container real, quando há um​

Engine.Actions.TerminalExecutor.decisao_de_execucao/1 ganha a QUINTA saída (:executar_no_container): projeto em execution_mode: container com uma linha REGISTRADA running em project_containers (Engine.Containers.ProjectContainerLifecycle.running?/1, leitura read-only direta da tabela, mesmo padrão de Engine.Projects.Project). Antes desta regra, mesmo com um container de pé (RN-491), TODO comando de terminal rodava via System.cmd no processo do engine, contra a pasta compartilhada — o container ficava ocioso.

O comando atravessa engine → api → broker (POST internal/projects/:projectId/container-exec, ExecutarComandoNoContainerUseCase, ContainerBrokerPort.exec) e roda via docker exec. cwd, quando presente, é TRADUZIDO do caminho de HOST (dentro de PROJECT_WORKSPACES_ROOT) para dentro de /work — o único diretório que o container enxerga — trocando o prefixo <project_workspaces_root>/ <workspace_dir_name> por /work e preservando o sufixo (o worktree individual de um dev agent, .worktrees/<agent_id>, incluso). O worktree em si não muda de lugar nem de mecanismo de criação: Workspace.ensure!/4 continua escrevendo no MESMO diretório físico que o broker monta em /work (verificado em código: raizDoProjeto do broker e PROJECT_WORKSPACES_ROOT do engine resolvem a mesma pasta, via workspace_dir_name, RN-109) — o container é só mais um observador dela.

running REGISTRADO nunca confirma que o container está de pé DE VERDADE agora (RN-486). Se ele morreu ou foi removido por fora entre o registro e esta chamada, broker.exec falha e vira failed_result NORMAL — exit_code nil, mensagem nomeando o motivo — nunca crash, nunca fallback silencioso de volta pro System.cmd fora do container (isso reabriria o vetor de isolamento que esta regra existe para fechar). mounted nunca cai nesta saída: o broker recusa subir container pra esse modo (ModoDeExecucaoNaoSuportadoError).

  • Onde: apps/engine/lib/engine/actions/terminal_executor.ex (decisao_de_execucao/1, run_no_container/4, cwd_para_container/2), apps/engine/lib/engine/containers/project_container_lifecycle.ex, apps/engine/lib/engine/sessions/engine_api_client.ex (executar_comando_no_container/4), apps/api/src/interfaces/http/internal/internal-projects.controller.ts (POST :projectId/container-exec), apps/api/src/application/use-cases/containers/executar-comando-no-container.use-case.ts, apps/api/src/application/ports/container-broker.port.ts (exec, timeoutMs)
  • Teste: apps/engine/test/engine/actions/terminal_executor_test.exs (as cinco saídas de decisao_de_execucao/1; tradução de cwd na raiz e num worktree; sucesso: false e falha de transporte viram failed_result; container sem linha running/em stopped cai no caminho de sempre), apps/api/test/application/use-cases/containers/executar-comando-no-container.use-case.spec.ts (BrokerRecusouError/BrokerIndisponivelError nunca propagam; outro erro propaga), apps/api/test/infrastructure/http-clients/container-broker.client.spec.ts
  • ADR: 0130, 0134
  • Origem: plano do dono do produto, Parte 1 / PR 1.6

RN-493 — Terminal dentro do container real ganha PISO de auto-aprovação, sem tocar nos tetos absolutos​

Quando containerExecutionActive é verdadeiro (o mesmo running REGISTRADO da RN-492, consultado por ProposeActionUseCase só para actionType === 'terminal' em projeto execution_mode: container), o valor INICIAL de current dentro de decide() deixa de ser require_approval e passa a ser auto_approve — um PISO, não um teto novo: os estágios que seguem (agent_autonomy, permissions.json) continuam podendo REBAIXAR esse piso exatamente como já rebaixavam o require_approval default (um deny explícito ou um ask casando o comando vencem do mesmo jeito), e os CINCO tetos absolutos de decide.ts (escopo, git push/comando privilegiado, merge protegido, instruction_patch, paralelismo) continuam rodando por CIMA, byte a byte como estavam — inclusive quando o auto_approve veio do piso, e não de uma regra explícita.

A justificativa é uma SEGUNDA fronteira, não a ausência da primeira. O escopo léxico de terminal (terminalNoEscopo, ADR 0055) continua rodando sobre os MESMOS caminhos de HOST de sempre — o cwd/command que chegam em decide() NUNCA são traduzidos pra /work (essa tradução acontece só depois, no engine — RN-492). Dentro do container real, some uma fronteira MAIS forte por cima da léxica: o mount namespace do Docker, que o processo fisicamente não atravessa, somado à validação de /work que o broker já faz (DiretorioForaDoEscopoError). É defesa em profundidade — o escopo léxico não é substituído, é redundante.

  • Onde: apps/api/src/domain/actions/decide.ts (DecideContext.containerExecutionActive, o current inicial condicional), apps/api/src/application/use-cases/actions/propose-action.use-case.ts
  • Teste: apps/api/test/domain/actions/decide.spec.ts (describe "decide — piso do container ativo do projeto": auto-aprova sem regra nenhuma; inalterado sem containerExecutionActive; não afeta ação não-terminal; agent_autonomy/permissions.json explícitos rebaixam o piso; IAM insuficiente nega antes do piso; escopo continua vencendo por cima; git push/sudo continuam require_approval), apps/api/test/application/use-cases/actions/propose-action.use-case.spec.ts (describe "piso do container ativo": terminal auto-aprova e EXECUTA com container running; não afeta container_start; sem linha running, inalterado; deny embutido/git push/sudo continuam vencendo)
  • ADR: 0134
  • Origem: plano do dono do produto, Parte 1 / PR 1.6

RN-494 — O portão da imagem (RN-105) vale nos TRÊS modos de execução​

REVISA a RN-169 (item 1) e a RN-421: a dispensa do portão RN-105 para projeto mounted/ runner — "esses modos não sobem container próprio, então a decisão do Arquiteto nunca vai acontecer" — está REVOGADA. ReadProjectCodeUseCase .portaoDoContainer deixa de checar executionMode: os TRÊS modos agora exigem artifact.project_image decidido (pelo Arquiteto, choose_project_image, ou pela Infra elegendo uma candidata, container_start — ADR 0133/RN-491) antes de liberar as sete rotas de leitura da aba Code.

A dispensa original confundia duas perguntas: "este projeto sobe container no SERVIDOR?" (não, em mounted/runner, e isso não muda aqui) e "faz sentido exigir que ALGUÉM tenha decidido a imagem do projeto antes de abrir a leitura de código?" (sim, nos três modos — a decisão é sobre o que o projeto EXECUTA, não sobre onde o container físico sobe). Manter a dispensa deixava a aba Code de mounted/runner abrir sem que ninguém tivesse pensado na imagem do projeto; a regra uniforme fecha essa lacuna.

Custo aceito, com todas as letras: todo projeto mounted/runner EXISTENTE sem artifact.project_image decidido — inclusive projetos reais de dogfooding como exp001/exp002 — PERDE acesso à aba Code no instante em que este PR é deployado, até que o Arquiteto (ou a Infra) decida uma imagem para ele. Essa é uma AÇÃO DO OPERADOR exigida depois do deploy, não uma correção transparente — por isso a branch nasce breaking/, e a versão sobe MAJOR.

O ciclo de vida (project_containers) também para de recusar por modo: RegistrarTransicaoDeContainerUseCase não responde mais 400 para mounted/runner — a tabela pode registrar linha para os três modos, o mesmo funil de provisioning (que já lia a decisão de imagem, agora exigida também para eles). O que continua IMPOSSÍVEL para mounted/runner é chegar em running DE VERDADE: isso é aplicado num lugar só, ContainerBrokerPort.start(), que recusa (ModoDeExecucaoNaoSuportadoError, política deliberada — o broker, no servidor, não enxerga a pasta do usuário onde o código de mounted/ runner mora) — e ExecuteContainerStartUseCase já trata essa recusa como falha normal (container.start_failed, motivo nomeado), nunca crash, nunca silêncio. Na prática, pelo caminho normal (aprovação de container_start), NENHUMA linha chega a nascer em project_containers para esses dois modos, porque o broker recusa ANTES de qualquer transição ser chamada. Duplicar essa checagem de modo em propose-action.use-case.ts ou em ExecuteContainerStartUseCase foi considerado e DESCARTADO: o broker já falha alto e nomeado, e a checagem duplicada só criaria um segundo lugar para divergir do primeiro — mesmo raciocínio do comentário de ModoDeExecucaoNaoSuportadoError (apps/broker/src/operacoes.ts), que já apontava para este PR.

O lado web deixa de tratar mounted/runner como caso à parte: ProjectCodeTab perguntava o modo do projeto e abria o shell direto, sem sequer chamar GET /projects/:id/container, quando o modo não era container. Isso SIMPLIFICOU o componente — os três modos agora seguem o mesmo caminho de useQuery/estados (carregando, erro, sem_decisao, decidido), sem modoLocal nem enabled condicional por modo.

  • Onde: apps/api/src/application/use-cases/git/read-project-code.use-case.ts (portaoDoContainer, dispensa removida), apps/api/src/application/use-cases/containers/registrar-transicao-de-container.use-case.ts (400 por modo removido), apps/api/src/application/use-cases/iam/convert-project-execution-mode.use-case.ts (comentário de ordenação corrigido), apps/web/src/routes/ProjectCodeTab.tsx (gate uniforme, modoLocal removido)
  • Teste: apps/api/test/application/use-cases/git/read-project-code.use-case.spec.ts (bloco "o portão do container": mounted/runner respondem 409 sem decisão e leem normalmente decididos), apps/api/test/application/use-cases/containers/ciclo-de-vida-do-container.use-case.spec.ts (mounted/runner registram provisioning em vez de 400), apps/web/src/routes/ProjectCodeTab.test.tsx (os três modos tratados igual)
  • Decisão arquitetural: ADR 0135
  • Origem: plano do dono do produto, Parte 1 / PR 1.7 — decisão #5 do plano original ("Portão RN-105 passa a valer nos TRÊS modos"), já aceita antes deste PR existir

RN-495 — container_stop/container_remove nascem como proposed_action, e só container_remove entra no teto absoluto​

Fecha a lacuna que o comentário de ContainerBrokerPort (ADR 0130) e o CLAUDE.md declaravam desde o PR 1.5: stop/remove tinham CLIENTE HTTP pronto (HttpContainerBrokerClient.stop/.remove) e ZERO chamador — a página global de containers (/containers, RN-496) é o primeiro. Os dois NUNCA são ação de agente: só um humano clicando "Parar"/"Remover" numa linha da tela propõe.

container_stop segue o calibre EXATO de container_start (RN-491): maintainer, require_approval por padrão, PODE ser configurado auto_approve (nunca semeado) — ExecuteContainerStopUseCase pede ao broker para parar e, só quando o registrado ainda dizia running, registra a transição running -> stopped (container-lifecycle.ts). Registrado já stopped/provisioning/failed: o broker é chamado mesmo assim (é idempotente — parar o que já não está rodando é no-op do lado do Docker), mas NENHUMA transição é gravada — inventar uma que a máquina de estados não descreveu de verdade seria o mesmo defeito que a RN-486 já nomeou para o observado.

container_remove é o MAIS destrutivo dos três — descarta o container e exige reprovisionar do zero (container-lifecycle.ts: removed só sai provisionando de novo, nunca "voltando à vida") — e por isso entra no MESMO teto absoluto de decide.ts que git push/comando privilegiado (RN-418): nunca auto-aprovável, nem por agent_autonomy nem por permissions.json, mesmo com "modo automático" ligado. A fresta de "sempre permitir" é fechada NA FONTE, pelo mesmo mecanismo de RN-418: ApproveAlwaysActionUseCase recusa (400) gravar o padrão para container_remove — o clique inteiro é recusado, e quem quer remover aprova a instância pelo fluxo normal (POST .../approve). No web, ApprovalCard.podeSemprePermitir esconde o botão "sempre permitir" para container_remove, mesma régua que já vale para instruction_patch.

ContainerBrokerPort.remove é docker rm --force — remove mesmo um container running, numa chamada só. A máquina de estados NÃO tem running -> removed direto (só running -> stopped/failed, e só DAÍ para removed): ExecuteContainerRemoveUseCase registra os DOIS hops quando o registrado ainda dizia running (stopped, depois removed), refletindo o que aconteceu de verdade do lado do Docker sem alargar a máquina de estados por um atalho que só existiria aqui.

Nenhum dos dois decide imagem — ao contrário de container_start, que reusa DecidirImagemDoProjetoUseCase para emitir uma nova versão de artifact.project_image. stop/remove só pedem ao broker para agir sobre o container que já existe.

  • Onde: apps/api/src/domain/actions/decide.ts (ACTION_TYPES, MIN_ROLE_FOR_ACTION_TYPE, teto absoluto de container_remove), apps/api/src/domain/actions/command-matcher.ts (ACTION_TYPE_LABELS), apps/api/src/domain/containers/container-stop-remove-execution-result.ts, apps/api/src/application/use-cases/actions/execute-container-stop.use-case.ts, apps/api/src/application/use-cases/actions/execute-container-remove.use-case.ts, apps/api/src/application/use-cases/actions/approve-action.use-case.ts, apps/api/src/application/use-cases/actions/propose-action.use-case.ts, apps/api/src/application/use-cases/actions/approve-always-action.use-case.ts, apps/web/src/lib/aprovacoes.ts, apps/web/src/components/ApprovalCard.tsx (podeSemprePermitir)
  • Teste: apps/api/test/domain/actions/decide.spec.ts ("container_stop, a página global de containers" — CONSEGUE auto_approve; "teto de container_remove" — NUNCA consegue), apps/api/test/application/use-cases/actions/execute-container-stop.use-case.spec.ts, apps/api/test/application/use-cases/actions/execute-container-remove.use-case.spec.ts (os dois hops a partir de running; um hop só a partir de stopped/ failed; idempotência; BrokerRecusouError/BrokerIndisponivelError nunca propagam), apps/api/test/application/use-cases/actions/approve-always-action.use-case.spec.ts, apps/web/src/lib/aprovacoes.test.ts, apps/web/src/routes/ContainersPage.test.tsx
  • Decisão arquitetural: ADR 0136
  • Origem: plano do dono do produto, Parte 1 / PR 1.8

RN-496 — A página global de containers pergunta ao broker com TETO, e nunca funde registrado com observado​

Mesma família da RN-486/RN-468, aplicada a uma tela NOVA: GET workspaces/:workspaceId/containers (/containers, cross-projeto) devolve uma linha por projeto do workspace que já tem project_containers (ContainersOverviewRepository, TRÊS consultas em lote, nunca uma por projeto — mesmo espírito de ProjectsSummaryRepository), mas perguntar ao broker o estado OBSERVADO é uma chamada de REDE por projeto, e não cabe numa consulta SQL.

O teto (ADR 0060) é por DUAS réguas, não uma. Primeiro, só linhas provisioning/running são ELEGÍVEIS — um container stopped/failed/ removed não precisa de confirmação do daemon para a tela fazer sentido (ninguém espera que ele esteja de pé). Segundo, entre as elegíveis, no máximo TETO_DE_VERIFICACOES_POR_CARGA (20, revisável) são perguntadas ao broker POR CARREGAMENTO — em paralelo, nunca em série. O que sobra de fora de qualquer uma das duas réguas carrega naoVerificado (fora_do_escopo_da_verificacao | teto_de_verificacoes_atingido), um campo PRÓPRIO da tela — nunca confundido com naoObservado (broker-nao-configurado/broker-sem-resposta/broker-recusou), que é sobre o broker TER SIDO perguntado e não ter respondido. Uma linha fora do teto tem observado/naoObservado/detalheDaObservacao todos null: a tela nunca inventa uma resposta que o broker não deu.

A leitura da imagem é a CONGELADA, não a vigente. imageVersion em project_containers aponta para a versão de artifact.project_image que estava vigente quando a linha nasceu — o Arquiteto pode ter revisado a decisão DEPOIS. A tela resolve a imagem-texto buscando o evento artifact.project_image cuja versão bate com imageVersion (decisaoNaVersao, domain/containers/project-container.ts), nunca a decisão mais recente — mostrar a mais recente mentiria sobre qual imagem o container que subiu de verdade usa. null quando o evento daquela versão não é encontrado (nunca inventada).

A proposed_action pendente de container (se houver) viaja na MESMA leitura em lote, batida por projectId IN (...) como as outras duas — é o que permite a tela trocar os três botões de ação pelo ApprovalCard inline sem uma quarta consulta por projeto.

  • Onde: apps/api/src/application/ports/containers-overview-repository.port.ts, apps/api/src/infrastructure/persistence/drizzle/containers-overview.repository.ts, apps/api/src/application/use-cases/containers/obter-visao-geral-de-containers.use-case.ts, apps/api/src/domain/containers/project-container.ts (decisaoNaVersao, versaoDoPayload), apps/api/src/interfaces/http/containers/containers-overview.controller.ts, apps/web/src/routes/ContainersPage.tsx, apps/web/src/lib/hooks.ts (useContainersOverview)
  • Teste: apps/api/test/infrastructure/persistence/drizzle/containers-overview.repository.spec.ts (só entra projeto com project_containers; imagem CONGELADA, não a vigente; acaoPendente cross-sessão; número de consultas não cresce com a quantidade de projetos), apps/api/test/application/use-cases/containers/obter-visao-geral-de-containers.use-case.spec.ts (elegibilidade por status; teto por carga; naoObservado nunca confundido com naoVerificado), apps/api/test/interfaces/http/containers/containers-overview.controller.spec.ts, apps/web/src/routes/ContainersPage.test.tsx
  • Decisão arquitetural: ADR 0136
  • Origem: plano do dono do produto, Parte 1 / PR 1.8

RN-497 — O brabo-runner sobe o container do projeto NA MÁQUINA DO USUÁRIO, com o Docker DELE​

Fecha a metade que a RN-494 (ADR 0135) deixou declarada: mounted/runner passaram a EXIGIR imagem decidida (o portão da RN-105 vale nos três modos), mas continuavam sem subir container NENHUM — só execution_mode: container tinha alguém (o broker, apps/broker) capaz de chamar Docker de verdade. container_start/container_stop/container_remove (RN-491/495) agora têm um SEGUNDO caminho de execução, ramificado pelo executionMode do projeto dentro de ExecuteContainerStartUseCase/ExecuteContainerStopUseCase/ ExecuteContainerRemoveUseCase — nunca na PROPOSTA nem na APROVAÇÃO da ação, que continuam agnósticas de modo (decide.ts não muda).

container segue pelo broker, sem mudança nenhuma. mounted/runner pedem ao ENGINE (ApiToEngineClient.startContainerViaRunner/ stopContainerViaRunner/removeContainerViaRunner, síncrono como executeTerminalAction) para repassar ao RUNNER conectado — o CLI que já mantém o canal terminal:<projectId> (ADR 0103/0104) ganha TRÊS pares de evento novos, container_start/_result, container_stop/_result, container_remove/_result (apps/runner/src/channel.ts), no MESMO molde de exec/exec_result: Engine.Runners.RunnerRouter despacha pro canal correlacionado por ref e fica bloqueado em receive, o runner chama DockerViaCli.start/stop/remove (@brabo/docker-port, ADR 0128) com o DOCKER DO USUÁRIO — não o do servidor — e responde. EngineWeb. ContainerCommandController (rotas POST internal/projects/:projectId/ containers/{start,stop,remove}) é o único chamador; nenhum outro caminho alcança essas três operações do lado engine.

A imagem que sobe não é ELEITA de novo — ao contrário do caminho container, que reusa DecidirImagemDoProjetoUseCase para gravar uma nova versão de artifact.project_image. O caminho mounted/runner só LÊ a decisão VIGENTE (ObterSpecDeContainerUseCase, o MESMO caso de uso que já compõe GET .../container-spec para o broker — chamado direto, sem HTTP, porque os dois rodam no processo da api) e a manda ao runner; sem imagem decidida (RN-105), falha ANTES de perguntar ao engine.

"Sem runner conectado" é FALHA NORMAL, nunca exceção genérica — mesma disciplina de BrokerIndisponivelError/BrokerRecusouError do caminho container. RunnerNaoConectadoError (sem runner, ou timeout) e RunnerRecusouContainerError (o runner respondeu e RECUSOU — Docker indisponível na máquina do usuário, especificação inválida) são as duas classes que HttpApiToEngineClient lança; os três casos de uso as capturam e gravam failed com o motivo, nunca deixam propagar. Do lado engine, a resposta é SEMPRE 200 — sucesso: false no corpo, com motivoCodigo ("not_connected"/"timeout") só quando o engine NEM CHEGOU a perguntar ao runner (RunnerRouter devolveu {:error, _}), nunca um status HTTP de erro para o que é falha normal do comando.

Engine.Actions.TerminalExecutor NÃO ganhou saída nova. A decisão "rodar o comando no host ou dentro do container que este runner subiu" é INTERNA ao runner — decisao_de_execucao/1 já roteia INCONDICIONALMENTE todo comando de projeto runner conectado para RunnerRouter.exec/4 (RN-423). O que muda é só apps/runner/src/index.ts: EstadoDoRunner ganha containerAtivo (o NOME do container que ESTE runner subiu, ou null), e tratarExec passa a rotear pra DockerViaCli.exec (via docker exec) quando há container ativo, ou pro caminho de sempre (executarComando direto no host) quando não há — o cwd, já validado por validarCwdDentroDaRaiz contra a raiz do projeto, é traduzido pra dentro de /work por troca de PREFIXO (cwdParaContainer, guard.ts), mesmo raciocínio de cwd_para_container/2 do lado engine (RN-492).

guard.ts (contenção em $HOME no Linux, RN-434) passa a cobrir também o caminho montado, sem NENHUMA validação nova. O bind-mount do container É estado.dir — a mesma raiz que validarDirDentroDoHomeNoLinux já validou no startup da CLI, antes de qualquer container subir. Não existe uma segunda checagem de "caminho de mount válido" porque não precisa existir: o mount É a raiz confirmada, ponto.

  • Onde: apps/runner/src/channel.ts, apps/runner/src/index.ts (EstadoDoRunner.containerAtivo, tratarContainerStart/Stop/Remove, tratarExec), apps/runner/src/guard.ts (cwdParaContainer), apps/engine/lib/engine/runners/runner_router.ex (start_container/stop_container/remove_container), apps/engine/lib/engine_web/channels/terminal_channel.ex, apps/engine/lib/engine_web/controllers/container_command_controller.ex, apps/engine/lib/engine_web/router.ex, apps/api/src/application/ports/api-to-engine-client.port.ts (RunnerNaoConectadoError, RunnerRecusouContainerError), apps/api/src/infrastructure/http-clients/api-to-engine-client.ts, apps/api/src/application/use-cases/actions/execute-container-start.use-case.ts, apps/api/src/application/use-cases/actions/execute-container-stop.use-case.ts, apps/api/src/application/use-cases/actions/execute-container-remove.use-case.ts
  • Teste: apps/runner/src/channel.spec.ts (roundtrip dos três pares novos), apps/runner/src/index-handlers.spec.ts (tratarExec roteia host vs. container; tratarContainerStart/Stop/Remove nunca lançam), apps/runner/src/guard.spec.ts (cwdParaContainer), apps/engine/test/engine/runners/runner_router_test.exs, apps/engine/test/engine_web/channels/terminal_channel_test.exs, apps/api/test/infrastructure/http-clients/api-to-engine-client.spec.ts, apps/api/test/application/use-cases/actions/execute-container-start.use-case.spec.ts, apps/api/test/application/use-cases/actions/execute-container-stop.use-case.spec.ts, apps/api/test/application/use-cases/actions/execute-container-remove.use-case.spec.ts (os três com describe "mounted/runner (ADR 0137)")
  • Decisão arquitetural: ADR 0137
  • Origem: plano do dono do produto, Parte 1 / PR 1.3

RN-498 — O golden-set do RAG roda em CI de verdade, AGENDADO — e o gate continua warn​

A RN-490 registrou o golden-set do RAG (ADR 0132) nascendo mix golden_set.rag manual, "nunca em CI", pelo mesmo motivo do golden-set do QA (ADR 0123): sem segredo de LLM de API ou infra nova, não havia como rodar Ollama de verdade em um runner de CI. Esta RN fecha essa metade — só para o RAG, nunca para o QA — com um workflow dedicado (.github/workflows/golden-set-rag.yml, schedule + workflow_dispatch) que puxa nomic-embed-text num serviço Ollama real (mesma versão pinada do resto do produto, hoje 0.34.4) e roda mix golden_set.rag de verdade.

A diferença que torna isto tratável para o RAG e não para o QA: o golden-set do RAG só chama o modelo de EMBEDDING — CPU, determinístico, sem amostragem — nunca um modelo de CHAT fazendo julgamento, que é o que o golden-set do QA exige (caro, mede algo não-determinístico por natureza, como o próprio ADR 0123 registra com rodadas reais variando 1 a 5 acertos em 6). ubuntu-latest, sem GPU nenhuma, basta aqui — não bastaria para o QA. O golden-set do QA não muda nada com esta RN: continua inteiramente manual, TODO(humano) no CLAUDE.md.

severidade: warn (docs/gates.yml) CONTINUA warn, e o motivo mudou. Não é mais "não há CI com LLM" — passa a ser cadência: o workflow é AGENDADO (noturno + disparo manual), nunca roda por pull_request. block prometeria um travamento de merge que não existe — o mesmo raciocínio que já valia na Etapa 2, aplicado à razão certa agora. Uma regressão de acerto de busca vira sinal de tendência visível em até 24h (ou na hora, via workflow_dispatch), nunca um gate que bloqueia PR — trade-off aceito explicitamente pelo dono do produto, dado o custo de puxar o modelo e rodar 17 buscas (alguns minutos) em toda janela.

Nenhum segredo precisa ser gerado no workflow. seed-golden-set-rag.ts nunca define NODE_ENV, e os quatro segredos que a api recusaria com literal de exemplo em produção (RN-114) só são checados quando NODE_ENV === 'production' — o script cai no ramo de desenvolvimento e usa os literais de dev sem reclamar, diferente de docker/smoke.sh, que sobe com NODE_ENV=production de propósito e por isso gera os seus com openssl rand.

A exclusão permanente da tag :golden_set_rag em test_helper.exs (RN-490) não muda. Ela protege o mix test comum — inclusive na máquina de qualquer desenvolvedor com Ollama de pé — nunca o novo workflow, que invoca mix golden_set.rag explicitamente, do mesmo jeito que sempre foi preciso rodar manual.

Corrigido depois de medir: o workflow acima nunca rodou verde uma vez. Cinco execuções agendadas seguidas (05 a 09/09/2026) falharam, e havia três defeitos empilhados, cada um escondendo o seguinte:

  1. mix golden_set.rag é um alias que chama test, e o Mix recusa isso fora do ambiente :test — ** (Mix) "mix test" is running in the "dev" environment —, antes de rodar coisa alguma. def cli em apps/engine/mix.exs passa a declarar preferred_envs para as DUAS tasks de golden-set: é o que faz o comando curto, o único documentado em toda parte, funcionar sem prefixo de ambiente. Exigir MIX_ENV=test de quem chama seria transferir a correção para toda a prosa que ensina o comando — e para quem esquecesse, de novo.
  2. O job nunca subiu a api, e o teste PULA quando API_URL não responde (pré-condição de ambiente declarada no ADR 0132). Corrigido o item 1, o job ficaria verde sem medir nada. Passa a subir a api de verdade (build, node dist/main.js, espera do /health) — satisfazer a pré-condição, e nunca afrouxar o teste, que seria mudar o julgamento para caber no CI. Quem faz o trabalho continua sendo seed-golden-set-rag.ts, que sobe o próprio contexto Nest; a instância existe para responder ao /health.
  3. O seed morria em 409 no portão da imagem. Ele criava o projeto como runner justamente para ESCAPAR do portão (RN-105), apoiado na isenção que a RN-169/RN-421 davam a mounted/runner — isenção revogada pela RN-494 (ADR 0135), com o comentário do script continuando a citá-la. Agora o seed ATRAVESSA o portão pelo caminho real: decide a imagem por DecidirImagemDoProjetoUseCase, o mesmo caso de uso do choose_project_image do Arquiteto, numa sessão consultiva que ele abre (o evento de decisão exige session_id, que é NOT NULL) — nunca um INSERT de artefato à mão, que faria o golden-set medir contra um estado que a produção não sabe produzir.

E o job passa a exigir que o golden-set tenha MEDIDO. A guarda é a linha de resultado (N/N casos acertaram), que só é impressa depois de os 17 casos rodarem; sem ela o passo reprova nomeando o motivo. Ela é necessária porque o {:skip, motivo} que o teste devolve do CORPO é inerte — não existe skip em runtime no ExUnit, e o que se vê é Result: 1 passed. Foi exatamente o que aconteceu ao medir: com o seed morrendo em 409, o teste passou. excluded NÃO serve de sinal aqui, ao contrário do que a guarda irmã de ci.yml verifica: a saída legítima traz mais de mil excluídos, que são todos os outros testes do engine, fora pela tag.

Medido depois das três correções, nesta máquina, pelo caminho que o job passa a percorrer: 17/17 no top-5, igual às duas rodadas manuais que gravaram o piso.

  • Onde: .github/workflows/golden-set-rag.yml, docs/gates.yml (rag-acertivo), apps/engine/mix.exs, apps/api/scripts/seed-golden-set-rag.ts
  • ADR: 0138
  • Origem: plano do dono do produto, Parte 2 / Etapa 3

RN-499 — O handoff da Infra tem card acionável PRÓPRIO, fora do fio — sem alargar AGENTES_DE_CHAT​

OfferInfraHandoffUseCase oferece o handoff pro Infra Lead (offer-infra-handoff.use-case.ts:43), e até aqui tela nenhuma podia aceitá-lo. offeredHandoff (SessionPage.tsx) restringe o card do fio a AGENTES_DE_CHAT (RN-136), que não inclui infra, e acceptHandoff (api-client.ts) tinha um único consumidor — esse card, atrás desse filtro. O comentário do próprio código registrava a consequência como aceita: "como Infra nunca é aceito por AQUI … na prática, nunca".

O efeito não era cosmético: o handoff ficava offered para sempre, o Infra Lead nunca era ativado, propose_container_start nunca era chamado (RN-491) e nenhum projeto de nenhum modo chegava a ter container de pé. A cadeia "Infra aceita → propõe container_start → aprovado → running" era inalcançável por qualquer caminho de tela.

O filtro NÃO foi alargado, e isso é a regra. AGENTES_DE_CHAT está certo em excluir infra: o agent_command_controller.ex do engine não tem cláusula de message pro Infra Lead, e a última cláusula (sem guarda) trataria "infra" como se fosse o Criativo — pôr infra na lista faria a tela oferecer um fio de conversa que não existe e o composer mandar mensagem pro agente errado. (Desde a RN-584 o engine recusa com nome em vez de entregar ao Criativo; o filtro continua certo pelo mesmo motivo.) Aceitar a Infra é uma ação propositiva, não conversacional.

O card acionável mora, então, fora do fio: na faixa fixa entre a área que rola e o composer, a mesma que hospeda o handoff manual (RN-440) e que já se declara o lugar das ações de handoff que "não são conversa, são redirecionamento". Três consequências que a escolha compra: o handoff.offered da Infra continua narrado no fio como divisor mudo (nada muda na timeline); nenhum handoff conversacional muda de forma; e o botão não some quando a oferta sai da janela de 200 eventos numa sessão longa — o card do fio dependeria do evento estar visível, este não depende.

O card diz a consequência do clique, porque ela não é óbvia: aceitar ativa o Infra Lead, que assume o provisionamento e vai propor a subida do container; a proposta ainda passa pelo pipeline de aprovação de sempre (container_start, maintainer, nunca auto-aprovável por seed — RN-491). Aceitar não sobe container nenhum.

Fechamento: o mesmo activeFor(h.toAgent) do card do fio — handoff já aceito, ou Infra já ativa nesta sessão por qualquer outro caminho, não reabre convite. E o clique chama o mesmo handleAcceptHandoff, sem segundo caminho de aceite.

  • Onde: apps/web/src/routes/SessionPage.tsx (handoffDaInfraOferecido, e o bloco styles.infraHandoffRow acima do manualHandoffRow)
  • Teste: apps/web/src/routes/SessionPage.handoff-da-infra.test.tsx — card aparece e aceita com os ids certos; some com a Infra já ativa; some em sessão não-ativa; o divisor mudo do fio continua lá sem botão de fio; e a não-regressão de RN-136: Infra (mais antigo) + Dev Lead (mais novo) pendentes mostram os DOIS cards, cada um aceitando o próprio handoff
  • Origem: exploração do plano "Nome e local na mesma tela, e container antes dos dev agents" (D0) — o bloqueador que tornava o requisito "dev agent só depois do container de pé" equivalente a "dev agent nunca começa"

RN-502 — Dev agent só reivindica task com container running, e o terminal não cai mais fora dele​

Duas metades da mesma regra: sem ambiente de execução, o dev agent não começa, e sem ambiente de execução, o comando não roda em lugar nenhum.

A guarda do claim. Engine.Dev.AgentIo.try_claim/2 — o ponto ÚNICO de claim — consulta Engine.Containers.ProjectContainerLifecycle.running?/1 ANTES de chamar claim_task/1. Sem uma linha REGISTRADA running em project_containers (RN-243/ADR 0081), o agente cai em :idle, persiste, emite dev.blocked_by_container e não chama a api. A guarda vem antes do claim de propósito: reivindicar para devolver logo em seguida deixaria a task marcada e sem dono vivo, que é o estado que block_task/4 existe para nunca produzir.

:idle, e não um status novo. É o único estado do qual um wake ainda resgata — os guards de handle_info/2 são todos casados com ele ({:wake, :became_claimable} exige :idle, :rearm exige :idle_tripped, gate_resolved exige task_id batendo, RN-047). Um :blocked_by_container inventado seria um estado do qual nada resgata.

No ENGINE e não só no activate-execution da api, porque o claim tem um caminho que nenhuma rota cobre: a REIDRATAÇÃO. Engine.Dev.DevRehydrator não faz cast :work; quem claima depois de um restart é DevAgentServer.init/1 → finish_restart_recovery/1 → try_claim/2. Um gate só na fronteira HTTP deixaria todo agente reidratado voltar a trabalhar sem container.

O wake. Leitura não avisa ninguém: um agente já parado continuaria parado até um evento não relacionado passar por perto. Então a chegada em running PUBLICA — RegistrarTransicaoDeContainerUseCase grava a linha e o evento na MESMA transação, aggregateType: 'container' (o terceiro agregado que Engine.Outbox.Drain passou a drenar, ao lado de session e task), aggregateId = o PROJETO. Engine.Workers.DevAgentWakeWorker entrega {:wake, :became_claimable} a TODOS os agentes do projeto (DevAgentState.list_by_project/1 — o container é do projeto, não de um módulo). É a MESMA mensagem que já existia, e não uma nova: a semântica dela já é "pode haver trabalho agora", e uma mensagem própria exigiria cláusula nova de handle_info/2 nos dois servers com guard idêntico ao que já existe. Só running publica; provisioning/stopped/failed/removed não soltam ninguém.

A segunda metade: o terminal. Engine.Actions.TerminalExecutor degradava calado — container sem container running caía em :caminho_de_sempre, isto é, System.cmd DENTRO do processo do engine, o mesmo que fala com o banco, com a api e com todos os outros projetos. O ADR 0134 (RN-492) tinha fechado o isolamento só no caminho feliz. Agora recusa (:recusar_container_ausente), espelhando o :recusar_nao_verificado/:recusar_runner_desconectado que o modo runner já tinha (RN-423), e como failed_result normal — nunca crash. mounted entra no MESMO ramo: com container running atravessa pro broker igual a container; sem ele, recusa. O catch-all :caminho_de_sempre encolhe para o que sempre deveria ter sido sozinho — projeto inexistente ou project_id malformado. Nenhum modo de execução cai nele.

Consequência declarada: projeto sem container de pé para de trabalhar, e diz por quê. É deliberado — é o que a regra existe para fazer — e é por isso que ela só pode entrar DEPOIS das mudanças que dão container ao modo mounted.

  • Onde: apps/engine/lib/engine/dev/agent_io.ex (try_claim/2), apps/engine/lib/engine/actions/terminal_executor.ex (decisao_de_execucao/1), apps/engine/lib/engine/outbox/drain.ex, apps/engine/lib/engine/workers/dev_agent_wake_worker.ex, apps/api/src/application/use-cases/containers/registrar-transicao-de-container.use-case.ts
  • Teste: apps/engine/test/engine/dev/claim_com_container_test.exs — :work inicial sem container cai em :idle, persiste, emite e não chama a api; o Noop passa pela mesma guarda; agente REIDRATADO para em :idle sem claimar; container.running na outbox percorre drain → worker → wake → agente e ele re-claima; e o caminho de sempre segue intacto com container running. apps/engine/test/engine/actions/terminal_executor_test.exs — recusa para container (sem linha e com stopped) e para mounted; mounted com running atravessa pro broker; só projeto inexistente cai em :caminho_de_sempre. apps/api/test/application/use-cases/containers/ciclo-de-vida-do-container.use-case.spec.ts — running publica container.running no agregado container; os outros destinos e a criação da linha não publicam nada
  • ADR: 0143
  • Origem: plano "Nome e local na mesma tela, e container antes dos dev agents" (PR 7) — dez tasks do exp001 travaram de uma vez porque nada ordenava container antes de dev agent

A base única dos projetos montados (RN-500)​

RN-500 — Existe UMA base para os projetos montados, ela é montada por identidade, e ausente quer dizer "não ofereça o modo"​

BRABO_PROJECTS_BASE é a única pasta do computador do operador que os containers do Brabo enxergam. Ela é montada por identidade ($X:$X) nos serviços api e engine, o que significa que o caminho é o MESMO no host e dentro dos dois containers.

A identidade não é preferência de estilo. A string de projects.workspace_path é digitada pelo usuário e mostrada de volta a ele; com host ≠ container o produto teria que escolher qual das duas guardar, e qualquer escolha faz a tela mentir para alguém. E é ela que faz projectScopeRoot (api) e Engine.Actions.Workspace.workspace_dir/2 (engine) continuarem corretos sem uma linha de código nova — o discriminador da barra inicial segue valendo, e o escopo de terminal do ADR 0055 continua autorizando exatamente a pasta que o usuário vê.

Ausente é estado NORMAL, não erro. baseDeProjetos() devolve null quando a variável não está definida ou está vazia, e NUNCA lança. null viaja até o cliente por GET /workspaces/:workspaceId/projects-base (maintainer, o mesmo mínimo de POST .../projects, porque é para decidir o que aquela rota oferece que o valor existe) e é assim que a criação de projeto aprende a não oferecer o modo Pasta montada. Oferecer um modo que a instalação não honra produz um projeto que trava depois, na primeira ferramenta do primeiro agente, longe da tela onde a decisão foi tomada — é a mesma lição da RN-170.

dentroDaBaseDeProjetos reusa dentroDoEscopo, a mesma função do escopo de terminal, e não uma comparação de prefixo escrita de novo: a armadilha é exatamente a que ela já resolve — /home/voce/brabo2 não está dentro de /home/voce/brabo, embora a string comece igual. A própria base conta como dentro; com base null, nada está dentro.

A base é variável PRÓPRIA, nunca PROJECT_WORKSPACES_HOST_DIR. Os três motivos estão no ADR, e o primeiro tem consequência de dados: workspace gerenciado é nomeado por workspace_dir_name (UNIQUE) e projeto montado é nomeado pelo usuário, então <base>/loja e um projeto container com workspace_dir_name = loja cairiam na MESMA pasta física, com init_from_bare! dando git init dentro do projeto do outro.

A guarda do checkout mora no preflight, e é o único lugar onde ela é possível. pnpm dev recusa subir quando a base se sobrepõe ao checkout do Brabo, nos dois sentidos. A api não consegue fazer essa checagem: ela compara contra process.cwd(), que dentro do container dela é /workspace, e nunca vê o caminho real no disco. Sem essa guarda, quem clona o Brabo em $HOME/brabo e aponta a base para lá passa por toda validação existente e faz os dev agents executarem dentro da árvore do próprio produto — a falha do ADR 0055 entrando por uma porta que ele não vigia.

O que esta RN NÃO faz: ela não exige que um projeto mounted esteja dentro da base. caminhoDeWorkspaceLocalValido e projectScopeRoot ficam intactos — aquele predicado roda em toda LEITURA, e projeto montado legado fora da base passaria a explodir ao ser lido. A base é regra de CRIAÇÃO e CONVERSÃO, e entra na RN seguinte.

  • Onde: apps/api/src/infrastructure/filesystem/project-workspaces-root.ts:67 (baseDeProjetos) e :92 (dentroDaBaseDeProjetos); apps/api/src/interfaces/http/iam/workspaces.controller.ts:197 (GET :workspaceId/projects-base); scripts/dev/base-de-projetos.mjs e a chamada em scripts/dev/preflight.mjs; docker/docker-compose.yml e docker/docker-compose.prod.yml (serviços api, engine, broker)
  • Teste: apps/api/test/infrastructure/filesystem/project-workspaces-root.spec.ts (describe('baseDeProjetos / dentroDaBaseDeProjetos')) e scripts/dev/base-de-projetos.spec.ts
  • ADR: 0141
  • Origem: plano do dono do produto, PR 1

O consentimento da base, no host (RN-511)​

RN-511 — A base é CONSENTIDA uma vez no host, o compartilhamento é provado MONTANDO, e sem terminal o passo relata em vez de consentir​

A RN-500 construiu a base e disse o que fazer quando ela falta — projectsBase: null, e o modo "Pasta montada" não é oferecido. O que faltava era alguém pedir que ela fosse configurada: nada no produto o fazia, e o resultado é que um modo pronto ficou inalcançável para quem instala.

O passo de consentimento vive em pnpm bootstrap (Docker › Base de projetos), propõe $HOME/projetos-brabo e aceita outro caminho digitado. O nome não é preferência: .env.example já usa ~/brabo-projetos como exemplo de PROJECT_WORKSPACES_HOST_DIR, a raiz que o PRODUTO gerencia, e propor o mesmo nome para as duas variáveis seria andar para dentro da colisão que o ADR 0141 recusou — <base>/loja cairia na mesma pasta física de um projeto container cujo workspace_dir_name também fosse loja, e o bootstrap daria git init dentro do projeto do outro.

O nome é só a esquiva; a proteção é mecanismo. validarBase recusa qualquer candidata que se sobreponha — nos DOIS sentidos — ao checkout do Brabo (a guarda que a RN-500 já tinha) ou a PROJECT_WORKSPACES_HOST_DIR (a regra nova), além de recusar ~, caminho relativo e a raiz. A recusa vale para o caminho DIGITADO, não só para o default; um default bem escolhido só evita que a recusa seja a primeira coisa que o usuário veja.

O compartilhamento do Docker Desktop é provado MONTANDO, nunca lido de settings.json. Aquele arquivo não é documentado, muda entre versões e entre macOS e Windows, e descreve o que o usuário CONFIGUROU — não o que o daemon fará. É a mesma régua de "capability só é declarada quando provada por execução" dos ADRs 0041/0042, aplicada ao host. A prova é uma sentinela, e não um ls seco: pasta vazia e pasta não compartilhada produzem a mesma listagem vazia, então o script escreve um arquivo e pergunta se o container o enxerga. Só em macOS e Windows (process.platform); no Linux não há lista de compartilhamento, e a checagem é existência e escrita. É a SEGUNDA guarda de plataforma do produto, simétrica à primeira (validarDirDentroDoHomeNoLinux, no runner): cada uma restringe apenas onde a restrição significa alguma coisa.

A prova tem três desfechos, e eles não colapsam: provado; reprovado (a variável não é gravada, e a recusa nomeia o caminho e onde adicioná-lo); e não consegui provar — sem imagem Docker local para montar —, que grava a variável e diz em voz alta que a prova não rodou, com o comando para rodá-la depois. "Não consegui olhar" virar "está tudo bem" é exatamente como uma base não compartilhada chegaria ao usuário na forma de um container que sobe com a pasta vazia, longe da tela onde a escolha foi feita.

Sem TTY, o passo RELATA e não faz nada. O item de menu do bootstrap.sh roda com stdin vindo de /dev/null — de propósito, senão qualquer coisa que leia stdin rouba as setas do usuário — então de lá o script só pode relatar, e a saída diz o comando que pergunta. É o mesmo desenho de perguntarUsoDoOllama, que sem terminal aplica o default e avisa em vez de travar. Um script de consentimento que "consente" sozinho num pipe de CI é a negação da palavra. Pelo mesmo motivo preflight.mjs relata o estado da base e nunca pergunta: a resposta é um caminho no disco de alguém, e não existe default que se possa aplicar em silêncio.

mkdir -p só acontece depois do aceite e da validação — criar pasta a partir de um caminho que ainda não passou pelas recusas é criar pasta em qualquer lugar que o processo alcance. E valor já presente nunca é sobrescrito sem confirmação.

  • Onde: scripts/dev/consentir-base.mjs (o I/O: perguntar, provar, criar, gravar); scripts/dev/base-de-projetos.mjs:143 (basePadrao), :177 (validarBase) e :208 (exigeProvaDeCompartilhamento) — a decisão pura; scripts/dev/env-file.mjs (lerEnv/escreverEnv, extraídas de preflight.mjs, que roda await main() no topo e por isso não pode ser importado); scripts/dev/bootstrap.sh (item 1.6); scripts/dev/preflight.mjs (relatarBaseDeProjetos)
  • Teste: scripts/dev/base-de-projetos.spec.ts (describe('basePadrao'), describe('validarBase') — caminho feliz e as seis recusas — e describe('exigeProvaDeCompartilhamento')); scripts/dev/bootstrap.spec.ts (a árvore do menu, 25 folhas)
  • ADR: 0146
  • Origem: FASE 28, sessão 2

O broker sobe por padrão no compose local (RN-512)​

RN-512 — O broker sai do profiles no compose LOCAL e só nele, e o DOCKER_GID passa a ser relatado no preflight​

O broker nasceu sob profiles: ["container-broker"] com uma justificativa que o próprio ADR 0130 escreveu: "dar acesso ao Docker do host a um processo que nada chama ainda seria uma mudança de postura para toda máquina de desenvolvimento, em troca de nada". As duas metades morreram. "Nada o chama" é falso desde os ADRs 0133/0134/0136 — o broker tem quatro chamadores reais. E "em troca de nada" deixou de descrever a troca quando o ADR 0144 fez mounted subir container PELO BROKER e o ADR 0146 tornou mounted o padrão local: com o profile desligado, o modo padrão termina em BrokerIndisponivelError.

Então ele sai do profiles em docker/docker-compose.yml — e só ali. Em docker/docker-compose.prod.yml o profile FICA, e os dois arquivos passam a divergir de propósito.

A assimetria é o que o socket significa de cada lado. Quem roda pnpm dev já tem o socket do Docker — está rodando docker compose, contra o daemon do próprio host —, então o broker recebê-lo não concede nada que o operador já não possua. A superfície marginal é o HTTP do próprio broker, que não publica porta, vive numa rede internal: true que só a api alcança, e compara BRABO_SERVICE_TOKEN em tempo constante. Em produção o cálculo é o oposto: o socket é fronteira de privilégio de verdade, o operador não é o desenvolvedor, e nada nesta fase muda o modo padrão de uma instalação de produção.

As cinco camadas de contenção não são tocadas. Isto muda quando o broker roda, nunca o que ele aceita: ele continua recebendo projectId e uma das cinco operações, continua indo à api ler a decisão do Arquiteto, e continua sem parâmetro nenhum onde se escreva privileged, cap_add, network: host ou um -v livre. A contenção nunca foi o profile.

A consequência ambiental, e por que ela ganhou relato. DOCKER_GID deixa de importar só para quem ligava o profile de propósito — alguém que estava, por definição, prestando atenção nele — e passa a valer para qualquer pessoa que rode pnpm dev. O socket é root:docker no host e o processo do broker roda non-root; o group_add do compose é o que lhe dá o grupo, e o default 999 é o mais comum e está errado em várias distribuições. Errar não quebra o boot — o broker sobe normalmente —, quebra o USO: toda operação morre com permission denied no socket, e o sintoma aparece muito depois, quando alguém propõe container_start. Por isso preflight.mjs passa a relatar o estado a cada pnpm dev.

O relato tem três desfechos, e eles não colapsam: bate; diverge (com a origem do valor efetivo — configurado × default — porque as duas pedem instruções diferentes); e não se aplica, quando a máquina não tem grupo docker. O terceiro não é erro nem "não consegui": em macOS e Windows o Docker Desktop não usa grupo unix, e num Docker rootless o socket é do próprio usuário. Tratar a ausência como divergência acusaria metade das máquinas de um defeito que elas não têm — o mesmo cuidado de baseSobrepoeOCheckout, que devolve false para checkout desconhecido. E ele relata, nunca bloqueia: pnpm dev sobe igual.

Sair do profiles não bastava, e o defeito só apareceu na primeira subida por padrão. A imagem de desenvolvimento preparava o pnpm com corepack como root no build e rodava como uid 1000, que não lê aquele cache; sem cache o corepack tenta baixar o pnpm no start, e a única rede do broker é internal: true — a tentativa morria com getaddrinfo EAI_AGAIN registry.npmjs.org e o container saía com 1, cinco segundos depois de subir. O serviço também não tinha healthcheck, então up --wait imprimia Healthy e o reset total anunciava "reset completo" com o processo morto. Enquanto o serviço esteve sob profile isso nunca apareceu: quem o ligava de propósito era quem já estava olhando. A correção tira o registry do caminho de runtime — o pnpm install migra para um passo de BUILD e COREPACK_HOME passa a um diretório com o dono do uid de runtime — e não abre a rede, que é a primeira das cinco camadas acima. Como os três node_modules são volumes nomeados montados por cima do que a imagem instalou, e o Docker só semeia volume vazio, docker/broker/entrypoint.sh reconcilia os três contra uma cópia guardada fora de /workspace, carimbada com o sha256 do pnpm-lock.yaml do build — o mesmo carimbo que, comparado com o lockfile da árvore, avisa que a imagem está velha sem recusar subir. O compose de PRODUÇÃO não sofria disto: Dockerfile.prod já instalava em estágio de build, removia corepack da imagem final e já tinha HEALTHCHECK. Desde a AT-360 nenhuma imagem usa corepack (o Node 25+ não o traz): o pnpm é instalado do tarball do registry, conferido contra um sha512 gravado no Dockerfile, em /usr/local/bin, e COREPACK_HOME saiu do broker de dev — com a versão igual à do packageManager não há cache a ler nem download a tentar no start (medido sob --network none).

  • Onde: docker/docker-compose.yml (serviço broker, sem profiles, com healthcheck) e docker/docker-compose.prod.yml (com profiles, e a nota que impede uniformizar os dois); docker/broker/Dockerfile (o pnpm pinado por sha512, o pnpm install de build e a cópia em /opt/broker-deps); docker/broker/entrypoint.sh (a reconciliação dos três volumes e o aviso de lockfile); scripts/dev/docker-gid.mjs (avaliarDockerGid, mensagemDoDockerGid, GID_PADRAO_DO_COMPOSE — a decisão pura); scripts/dev/preflight.mjs (relatarDockerGid, gidDoGrupoDocker)
  • Teste: scripts/dev/docker-gid.spec.ts — o caminho feliz, as duas divergências (default ausente e valor errado), o "não se aplica" sem grupo docker, e o default que por acaso ACERTA
  • ADR: 0146, ponto 3
  • Origem: FASE 28, sessão 4

A base decide o que a criação de projeto oferece (RN-513)​

RN-513 — Com base consentida, mounted é o pré-selecionado com <base>/<slug> sugerido; sem base, ou sem saber, ele não é oferecido​

Revisada pela RN-573 (ADR 0161): a pré-seleção de mounted passou a exigir TAMBÉM o broker de container confirmado. Com base e sem broker (a instalação por Release), runner é o pré-selecionado e container/mounted ficam inertes com o motivo em texto; com o broker desconhecido, container segue selecionado. O resto desta regra vale como está.

Duas metades desta regra já eram regra e nunca tinham chegado ao cliente. "Não oferecer o modo sem base" é a RN-500; "sugerir <base>/<slug>" é a RN-501, e o ADR 0142 adiou a validação de disco justamente para que o assistente pudesse propor uma pasta que ainda não existe. Até aqui, GET /workspaces/:workspaceId/projects-base não tinha chamador nenhum no web, e o card "Pasta montada" era oferecido incondicionalmente — numa instalação sem BRABO_PROJECTS_BASE, escolher o modo terminava numa recusa 400 da api depois de a pessoa ter escolhido, digitado o caminho e chegado ao fim do assistente.

O que é novo é a pré-seleção: com base configurada, mounted nasce selecionado. Ela revisa o default do ADR 0072 (onde container é "a normal" e o modo local é escape hatch) e o revisa só para a instalação local, onde a base foi consentida por um humano no host (RN-511). O enum project_execution_mode não muda, nenhum modo novo nasce, e a conversão de modo (ADR 0111) não é tocada.

A consulta nasce com o assistente, não com o passo. O passo "Onde o código vai morar" é o quinto; um useQuery montado só ali faria o card aparecer piscando depois que a pessoa já estivesse olhando a tela.

Desconhecido não vira oferta. Enquanto a resposta não chegou, e também quando a consulta falha (403, rede, api fora), o card não aparece e container segue selecionado. Falha não é permissão para oferecer um modo cuja pré-condição não se conseguiu confirmar: é a mesma régua da RN-088/RN-468 — o produto não colapsa "não sei" com "não tem" — e a dos ADRs 0041/0042, onde capability só é declarada quando provada.

A pré-seleção nunca sobrescreve escolha humana. A base chega por rede, sempre DEPOIS do primeiro render. A tela guarda a escolha do usuário separada do modo vigente (modoDeWorkspaceEscolhido × modoDeWorkspace), e a pré-seleção só vale enquanto ninguém tocou nos cards — trocar o modo debaixo da mão de quem já clicou é defeito, não conveniência.

A sugestão nunca clobbera o que foi digitado. <base>/<slug> entra em dois casos e só neles: campo vazio com nada sugerido ainda, ou campo contendo exatamente a sugestão anterior desta tela (o nome mudou no passo de detalhes, e a sugestão acompanha). Qualquer outra coisa — inclusive o campo vazio depois de o usuário ter apagado a sugestão — fica como está. Slug vazio (a adoção, onde o nome vem do provider) não vira segmento inventado: o campo continua vazio, e canAdvanceFromWorkspace segura o passo. O campo é editável o tempo todo; a pré-seleção não relaxou nenhuma régua de avanço.

E o aviso do modo parou de mentir. O texto anterior era anterior ao ADR 0141: dizia que o caminho era livre e que só funcionaria se o usuário o montasse. Hoje são dois estados, e são os dois que o backend tem — sob a base, nota neutra (a criação passa, e a pasta nem precisa existir: a Infra a cria ao subir o container, RN-501); fora da base, aviso dizendo que a api recusa a criação (400, em resolverWorkspacePath) e nomeando a base, em vez de mandar procurar a variável. Campo vazio não é "fora da base": não há caminho para a api recusar ainda, e alarmar antes de a pessoa digitar seria a tela afirmando sobre o que não tem. A comparação é por segmento, nunca startsWith cru — /base-outra não está dentro de /base —, espelhando dentroDoEscopo da api.

  • Onde: apps/web/src/routes/NewProjectWizard.tsx:220 (a consulta), :238 (podeOferecerMounted/baseDeProjetos), :283 (o modo vigente), :333 (caminhoForaDaBase), :360 (a sugestão); apps/web/src/lib/wizard.ts:143 (caminhoSugeridoNaBase) e :171 (caminhoDentroDaBase) — as funções puras; apps/web/src/lib/api-client.ts:305 (getProjectsBase)
  • Teste: apps/web/src/routes/NewProjectWizard.test.tsx (describe('NewProjectWizard — a base de projetos decide o que é oferecido') — base presente, base null, consulta recusada com 403, a escolha humana antes de a base chegar, e os dois estados do aviso); apps/web/src/lib/wizard.test.ts (describe('caminhoSugeridoNaBase') e describe('caminhoDentroDaBase'))
  • ADR: 0146, ponto 4
  • Origem: FASE 28, sessão 3

A pasta montada nasce quando o container sobe (RN-501)​

RN-501 — mounted valida só o LÉXICO e a base na criação; a pasta é MATERIALIZADA depois, por quem tem autoridade sobre o disco​

A criação de um projeto mounted deixou de tocar disco. Ela exige duas coisas, e as duas são léxicas:

  1. o mesmo predicado que runner já usava (caminhoDeWorkspaceLocalValido) — absoluto, sem .., fora da raiz e das pastas de sistema, sem se sobrepor ao checkout do Brabo (RN-422/RN-423);
  2. estar dentro de BRABO_PROJECTS_BASE (RN-500), que é a única pasta do computador que os containers da api e do engine enxergam.

O projeto nasce com workspaceVerifiedAt: null, exatamente como um runner.

Por que adiar. O requisito é do dono do produto e é literal: "se for Pasta montada, o bind-mount deve ser criado APÓS a decisão do arquiteto". A validação de disco rodava na CRIAÇÃO, que é a primeira tela do fluxo, e a decisão do Arquiteto acontece muitas sessões depois — exigir a pasta pronta na criação é exigi-la antes de existir decisão nenhuma. E é o que impedia mounted de ser escolha de primeira classe: um caminho SUGERIDO pelo assistente (<base>/<slug>) é, por construção, um caminho que ainda não existe.

A diferença entre mounted e runner nunca foi o que conta como caminho válido — é quando e quem confirma o disco. No runner é o CLI conectando; no mounted é a materialização.

Sem base configurada, o MODO não está disponível. A recusa diz isso, com o nome da variável e o que o operador precisa fazer — nunca finge que o caminho é que estava errado. Fora da base, a recusa nomeia a base e sugere <base>/<nome que a pessoa pediu>, nunca a base pelada, que ensinaria a colocar o projeto na raiz de todos eles. As duas mensagens saem de UMA fonte (motivoDeForaDaBaseDeProjetos), porque as duas portas que aplicam a regra — criação/conversão e materialização — recusam pelo mesmo motivo.

A materialização (materializarWorkspaceMontado) é mkdir -p mais as três perguntas de disco de sempre (existe? é pasta? dá para escrever?), com a recusa por estar fora da base antes do mkdir — senão um caminho gravado por fora do produto faria a api criar pasta em qualquer lugar que ela alcança. Dois chamadores:

  • ExecuteContainerStartUseCase — o normal. Quando a Infra sobe o container, a pasta é criada, provada gravável, e workspace_verified_at é carimbado pelo MESMO caminho que ConfirmProjectWorkspaceUseCase usa. Falhar é failed NOMEADO, nunca throw nem 500 — mesma disciplina de BrokerIndisponivelError/RunnerNaoConectadoError —, e o ciclo de vida não chega a ser marcado provisioning: marcá-lo e só então descobrir que não dá para escrever deixaria project_containers afirmando um estado que nunca existiu. A mensagem nomeia a variável, o caminho, a causa provável (dono da pasta no host; as imagens rodam non-root, ADR 0024) e o próximo passo ("aprove container_start de novo").
  • ConvertProjectExecutionModeUseCase — a exceção, declarada. A conversão não tem passo de container onde pendurar o trabalho, e logo em seguida ela MOVE o permissions.json para permissionsFilePath(localNova), que em mounted é a pasta do usuário. Mover arquivo para dentro de pasta inexistente falha, então aqui mkdir-na-decisão é o certo — antes da transação, para que a recusa não deixe transação aberta, e virando 400.

O que esta RN NÃO faz, e é a regressão mais fácil de causar. A regra da base não entra em caminhoDeWorkspaceLocalValido. Esse predicado roda em TODA LEITURA, por projectScopeRoot (escopo de terminal, permissions.json, aba Code), e um projeto mounted LEGADO — criado quando o bind-mount era uma linha de compose por projeto, portanto fora da base — passaria a explodir com LocalizacaoDeProjetoInvalidaError ao ser simplesmente lido. A base é regra de criação e conversão; o léxico é para sempre.

Sem migration, e o CHECK do banco fica intacto. mounted continua gravando workspace_path NÃO-nulo, então (execution_mode <> 'container') = (workspace_path IS NOT NULL) segue satisfeito. Adiar a verificação nunca toca o invariante de pareamento.

Consequência declarada: entre criar o projeto e subir o container, workspace_path aponta para uma pasta que pode não existir. Nada quebra (projectScopeRoot é léxico; permissions.json degrada para EMPTY_PERMISSIONS_FILE, que é require_approval em tudo), mas a tela precisa DIZER — uma tela que mostra um caminho sem dizer que ele ainda não existe é uma tela afirmando o que não sabe.

  • Onde: apps/api/src/application/services/workspace-location.ts:93 (léxico) e :108 (base); apps/api/src/infrastructure/filesystem/project-workspaces-root.ts:575 (motivoDeForaDaBaseDeProjetos), :624 (validarWorkspaceMontadoEmDisco, o antigo validarCaminhoDeWorkspaceLocal) e :705 (materializarWorkspaceMontado); apps/api/src/application/use-cases/iam/convert-project-execution-mode.use-case.ts:139; apps/api/src/application/use-cases/actions/execute-container-start.use-case.ts:133 e :334
  • Teste: apps/api/test/infrastructure/filesystem/project-workspaces-root.spec.ts (describe('materializarWorkspaceMontado') e a não-regressão "projeto mounted LEGADO, FORA da base, continua resolvendo sem lançar"); apps/api/test/application/use-cases/iam/create-project-modo-de-workspace.spec.ts (describe('mounted valida só o léxico + a base na criação')); apps/api/test/application/use-cases/iam/convert-project-execution-mode.use-case.spec.ts; apps/api/test/application/use-cases/actions/execute-container-start.use-case.spec.ts (describe "ExecuteContainerStartUseCase — materialização do mounted")
  • ADR: 0142, que referencia 0141
  • Origem: plano do dono do produto, PR 2

Projeto montado sobe container pelo BROKER (RN-503)​

RN-503 — Projeto mounted sobe container pelo BROKER, e o que atravessa a rede é um localizador discriminado — nunca um caminho absoluto​

Um projeto no modo Pasta montada não conseguia container nenhum até aqui, e por dois bloqueios independentes: ExecuteContainerStartUseCase mandava todo modo diferente de container para o RUNNER (que exige um brabo-runner conectado), e o broker recusava na fonte qualquer modo que não fosse container, com ModoDeExecucaoNaoSuportadoError.

Os dois existiam pela mesma razão de GEOMETRIA, não pelo nome do modo: a pasta de um projeto montado ficava num lugar arbitrário do disco do operador, que o daemon Docker do servidor não tinha por que enxergar. O ADR 0141 (RN-500) mudou essa geometria — todo projeto montado passa a morar sob uma base montada por identidade —, e é isso, e só isso, que esta regra colhe.

A ramificação passa a ser por DESTINO, não por modo. container e mounted vão ao BROKER; só runner vai ao runner, porque a pasta dele continua numa máquina que este servidor não alcança (ADR 0137). Vale igual para as três ações de ciclo de vida — container_start, container_stop e container_remove: elas TÊM de mudar juntas, senão o container de um projeto montado sobe no servidor e o pedido de parar vai procurá-lo na máquina do usuário, deixando de pé, sem forma de parar, o que está de pé.

O invariante do ADR 0130 não se mexe: nenhum caminho absoluto atravessa a rede. O broker é root-equivalente no host e COMPÕE o -v a partir das raízes DELE; se a api mandasse /home/voce/brabo/loja, a contenção do bind-mount passaria a depender de a api estar correta, que é exatamente a dependência que o broker existe para não ter. O que muda é que agora existem DUAS raízes do lado de lá, então a spec precisa DIZER contra qual delas o pedaço relativo vale — e diz, num localizador discriminado:

localizacao.tiposegmentoraiz do broker
gerenciadaworkspace_dir_name (RN-109)PROJECT_WORKSPACES_HOST_ROOT
montadao caminho RELATIVO sob a baseBRABO_PROJECTS_HOST_BASE
indisponivel— (há motivo)nenhuma

Três estados e não dois, porque o terceiro existe de verdade e tem dois consertos diferentes: projeto runner (a pasta está noutra máquina — o conserto é o runner, do lado de lá) e projeto mounted LEGADO criado fora da base (o conserto é mover a pasta). Colapsá-los num null faria a mesma ausência mandar quem opera para o lugar errado. A pasta que É a própria base também cai aqui, e não vira segmento vazio: <raiz>/ montaria a base inteira — a pasta de TODOS os projetos montados — dentro do container de um só.

A falta de uma raiz nunca é suprida pela outra. Sem BRABO_PROJECTS_HOST_BASE, um start de projeto montado recusa nomeando a variável (BaseDeProjetosNaoConfiguradaError, origem infra, 503) e não toca container nenhum — mesmo molde de RaizDeWorkspacesNaoConfiguradaError. Cair na outra raiz por omissão seria o pior desfecho possível: a raiz gerenciada é nomeada por workspace_dir_name e a base é nomeada pelo usuário, então o mesmo nome aponta para pastas diferentes e o container subiria com a pasta de OUTRO projeto, sem nada indicando por quê. Pelo mesmo motivo, a lista de modos que o broker atende é de PERMITIDOS: um modo novo no enum nasce recusado, com mensagem, em vez de aceito por omissão.

A composição continua passando por três barreiras, não uma. A api recusa o que não está sob a base (segmentoSobABaseDeProjetos); o broker recusa o segmento que não é relativo (segmentoDeProjetoValidado — .., absoluto, vazio, barra dupla, NUL); e o resultado da concatenação ainda passa por raizDeProjetoValidada antes de virar -v. Validar o segmento e não validar a concatenação seria confiar na aritmética de strings.

mounted ELEGE a imagem, como container — não lê a vigente como o runner. Não é simetria estética: é o único desenho que funciona. O broker compõe a partir de artifact.project_image, indo BUSCÁ-LO na api. Uma eleição da Infra que não fosse gravada nesse artefato seria inerte — o container subiria com a imagem que o Arquiteto decidiu, o payload que o humano aprovou diria outra, e nada no registro denunciaria a diferença. O caminho do runner pode ler a vigente justamente porque ali a api MANDA os campos da spec pelo canal; lá o artefato não é a fonte que o outro lado consulta.

O que esta regra NÃO faz: não materializa pasta nenhuma — quem cria a pasta do projeto montado é a RN-501, no MESMO caso de uso e antes desta ramificação, justamente porque o daemon do servidor precisa da pasta existindo para montá-la. Também não toca o portão da imagem (RN-105 já vale para os três modos desde a RN-494) e não muda quem PROPÕE container_start — o Infra Lead segue podendo propor para um projeto sem runner conectado e sem imagem decidida, a lacuna declarada desde a RN-494.

  • Onde: apps/api/src/application/use-cases/containers/obter-spec-de-container.use-case.ts (LocalizacaoDoProjeto, localizacaoDoProjeto); apps/api/src/infrastructure/filesystem/project-workspaces-root.ts (segmentoSobABaseDeProjetos); apps/api/src/interfaces/http/internal/dto/container-spec-internal.response.dto.ts; apps/api/src/application/use-cases/actions/execute-container-start.use-case.ts, …/execute-container-stop.use-case.ts, …/execute-container-remove.use-case.ts (a ramificação !== 'runner'); packages/docker-port/src/docker-port.ts (segmentoDeProjetoValidado); apps/broker/src/config.ts (baseDeProjetosNoHost) e apps/broker/src/operacoes.ts (raizDoProjetoNoHost, garantirModoSuportado, BaseDeProjetosNaoConfiguradaError, LocalizacaoIndisponivelError)
  • Teste: apps/api/test/application/use-cases/containers/spec-e-observacao-de-container.use-case.spec.ts (describe('… o localizador discriminado (RN-503)')); apps/api/test/application/use-cases/actions/execute-container-{start,stop,remove}.use-case.spec.ts; packages/docker-port/src/docker-port.spec.ts (describe('segmentoDeProjetoValidado')); apps/broker/src/operacoes.spec.ts e apps/broker/src/config.spec.ts
  • ADR: 0130, 0141
  • Origem: plano do dono do produto, PR 3

O navegador de pastas servido pela api (RN-504)​

RN-504 — O navegador de pastas é escopado à base de projetos e NUNCA sai dela​

GET /workspaces/:workspaceId/project-folders lista as subpastas de um caminho, e existe porque o assistente de criação de projeto perde, de uma vez, os DOIS mecanismos de "procurar pasta" que tinha: FolderBrowserModal navegava pelo websocket do runner (fs_list_dir/fs_home_dir) e o RunnerOnboardingPanel usava showDirectoryPicker, que devolve um handle do navegador e nunca um caminho absoluto. Sem runner, o navegador não tem como listar filesystem nenhum — e é caminho absoluto que projects.workspace_path guarda.

A contenção é UMA, e é dura. path é opcional e omitido quer dizer a base (baseDeProjetos()); todo path fornecido tem que satisfazer dentroDaBaseDeProjetos (RN-500), que reusa dentroDoEscopo — a mesma função do escopo de terminal do ADR 0055 — e por isso pega a armadilha de prefixo: /home/voce/brabo2 não está dentro de /home/voce/brabo, embora a string comece igual. .. e . são recusados em vez de resolvidos, pela mesma razão de caminhoDeWorkspaceLocalValido: resolver aceitaria que o caminho lido não é o caminho pedido.

Sair da base é 400, e não 403. 403 diria "você não tem permissão para ver isto" e sugeriria que outro papel veria — não é o caso. Não existe papel nenhum que navegue fora da base, porque fora da base não é uma área mais privilegiada, é uma área que esta rota simplesmente não endereça. O pedido está MALFORMADO.

Os tetos são contrato, não detalhe de implementação: só diretório em entries; no máximo 500, ordenados antes do corte (senão "as 500 primeiras" seria a ordem que o filesystem devolveu, que muda entre máquinas); sem recursão; entradas começadas com . fora; e symlink reportado, nunca descido — readdirSync(withFileTypes) tem semântica de lstat, então um link apontando para fora da base não é porta de saída. O que fica de fora é CONTADO (arquivos, simbolicos, truncado): sem isso uma pasta cheia de código voltaria como lista vazia e a tela diria "pasta vazia", afirmando sobre o que não leu (RN-180).

Não há POST. Criar pasta é da materialização do workspace montado, no momento em que o container sobe — nunca do seletor.

O mínimo é maintainer, o mesmo de POST .../projects e de .../projects-base, e pelo mesmo raciocínio um passo adiante: projects-base revela UM caminho da máquina do operador, esta rota revela a TOPOLOGIA abaixo dele. Herdar o viewer das rotas vizinhas por elas serem vizinhas é o defeito que a RN-102 nomeia — o mínimo é do ENDPOINT, nunca da seção. workspaceId não entra no cálculo: a base é da INSTALAÇÃO, e ele está na rota porque é o que dá escopo ao RolesGuard.

No cliente, o transporte vira uma das DUAS implementações da interface FsBrowser (apps/web/src/lib/fs-browser.ts): criarFsBrowserViaApi (nova) e connectFsBrowserChannel (o canal do runner, re-tipada). FolderBrowserModal escolhe por origem: { tipo: 'api'; workspaceId } | { tipo: 'runner'; projectId } — união discriminada, e não duas props opcionais, porque "nenhuma das duas" e "as duas" seriam estados representáveis que o componente teria de tratar em runtime. O transporte via runner fica sem chamador no web a partir daqui, e continua no repositório por decisão declarada do dono do produto (o runner sai da interface, o binário segue sendo refinado); o protocolo em apps/runner/src/channel.ts não é tocado de qualquer forma.

  • Onde: apps/api/src/infrastructure/filesystem/project-folders-browser.ts (listarPastasDeProjeto, TETO_DE_ENTRADAS, PastaForaDaBaseError, PastaNaoLegivelError); apps/api/src/interfaces/http/iam/workspaces.controller.ts (listProjectFolders); apps/web/src/lib/fs-browser.ts; apps/web/src/components/FolderBrowserModal.tsx
  • Teste: apps/api/test/infrastructure/filesystem/project-folders-browser.spec.ts, apps/api/test/interfaces/http/iam/workspaces-project-folders.controller.spec.ts, apps/web/src/lib/fs-browser.test.ts, apps/web/src/components/FolderBrowserModal.test.tsx
  • ADR: 0141 (a base que esta rota escopa)
  • Origem: plano do dono do produto, PR 4

RN-509 — "Sempre permitir" de um Dev Agent de módulo escopa a agent_autonomy, POR AGENTE — não mais o permissions.json de projeto inteiro​

ApproveAlwaysActionUseCase sempre gravou em permissions.json/allow — escopo de PROJETO INTEIRO, compartilhado por qualquer ator. Isso significava que "sempre permitir" um comando pro dev-checkout liberava o MESMO comando pro dev-auth, pro dev-lead, e pra qualquer agente futuro do projeto: a intenção de quem clicou ("confio NESTE agente com ISTO") não tinha como ser expressa — só existia "confio em qualquer um com isto".

A gravação passa a se ramificar pelo ATOR, dentro do MESMO use case (não um novo — duplicar as duas guardas de teto absoluto de terminal fora de escopo/git_push/sudo e de container_remove em dois lugares seria o mesmo defeito que RN-418/RN-495 já existem para evitar):

  • Ator agent cujo id é dev-<modulo> (ADR 0053/FASE 14d — a área DINÂMICA de dev, um agente por módulo do module_map): grava em agent_autonomy(projectId, agentId, actionType) = auto_approve, pelo MESMO AgentAutonomyRepository.upsert que activate-execution.use-case.ts já chama pra semear as três ações git por módulo. permissions.json fica intocado.
  • Qualquer outro ator — user, system, agente agent que não é dev-de-módulo, e o dev-lead — continua indo pro permissions.json/allow de sempre, escopo de projeto inteiro, exatamente como antes.

dev-lead é a exceção que precisa de nome próprio. ehDevDeModulo (agent-areas.ts) é agentId.startsWith('dev-') PURO, de propósito — e isso classifica dev-lead como true. Quem exclui o lead da própria área é ehMembroDe, não ehDevDeModulo sozinho ("o lead não é membro da própria área" vale pra qualquer área — duplicar a exclusão dentro do predicado deixava as duas cópias inalcançáveis por teste, achado registrado no comentário do próprio agent-areas.ts). Por isso o branch usa ehDevDeModulo(actor.id) && actor.id !== DEV_LEAD: sem o segundo termo, "sempre permitir" clicado numa ação do dev-lead gravaria autonomia de módulo sob o agentId do LEAD por acidente — um agente que não é membro de dev, que não tem módulo, e que a área nunca olha via agent_area_members.

Os dois tetos absolutos de decide.ts continuam rodando ANTES do branch, sem mudar de ordem. Terminal com efeito externo git (git push, gh pr create) ou privilegiado (sudo/doas) — RN-106/RN-418 — e container_remove — RN-495 — seguem recusando o clique INTEIRO (400, ação nem aprovada), pra QUALQUER ator, antes mesmo do projeto ser buscado. Escopar a gravação por agente não é uma segunda porta pro mesmo teto: os dois if de recusa ficam onde estavam, e o branch novo entra depois.

O evento permission.granted carrega um payload OU outro, nunca um fingindo ser o outro. { pattern } continua sendo o formato do caminho de permissions.json (o padrão de texto gravado no arquivo); o caminho novo emite { agentId, actionType } — não há "padrão" nenhum pra mostrar quando a gravação é uma linha de tabela chaveada por agente.

Sem migração de dados. Entradas antigas de "sempre permitir" gravadas para um dev-de-módulo em permissions.json, de antes desta regra existir, continuam lá exatamente como estavam — só não recebem MAIS entradas desse tipo dali pra frente. Decisão consciente do dono do produto, não lacuna esquecida.

  • Onde: apps/api/src/application/use-cases/actions/approve-always-action.use-case.ts (o branch ehAgenteDeModulo), apps/api/src/domain/agents/agent-areas.ts (ehDevDeModulo, DEV_LEAD — consumidos, não alterados), apps/api/src/application/ports/agent-autonomy-repository.port.ts (upsert, já existente, sem método novo)
  • Teste: apps/api/test/application/use-cases/actions/approve-always-action.use-case.spec.ts (descreve escopo por Dev Agent de módulo (RN-509): caminho feliz dev-checkout grava agent_autonomy e não vaza pra dev-auth; os DOIS tetos absolutos recusando o clique inteiro pra um ator dev-de-módulo; dev-lead e ator user — inclusive com id começando em dev- — continuam no caminho antigo)
  • Origem: plano do dono do produto, Frente 2

O artefato de decisão dos conversacionais (RN-505)​

RN-505 — decision_record reusa o padrão GENÉRICO de emit_artifact, não o dedicado​

Um agente conversacional que toma uma decisão relevante numa conversa (por que escolheu X e não Y, o que considerou, o que aceitou perder) registra isso com emit_artifact e type: decision_record — payload com context, options (lista), choice e consequences, as quatro chaves obrigatórias em Engine.Harness.ArtifactSchemas (artifact_schemas.ex:82), tool-emittable desde que entrou em @tool_emittable (artifact_schemas.ex:98). Sem validação cruzada de propósito: choice não precisa bater com um item de options — texto livre é mais robusto, e o schema genérico só valida presença de chave (a mesma régua de note/business_rule).

Por que reusar o padrão GENÉRICO em vez do dedicado. artifact.project_image e artifact.c4_diagram (RN vizinhas desta) são artefatos VERSIONADOS — cada emissão substitui a anterior como "vigente", com rota HTTP própria de leitura e schema TypeScript dedicado na api. Uma decisão não é assim: é um registro append-only por natureza, não um "vigente" que se substitui — o mesmo raciocínio que já vale para note/business_rule. Pagar o preço do padrão dedicado (schema TS, versionamento, rota nova) para um log que só precisa existir e ser pesquisável no event log da sessão custaria estrutura que a decisão não pede.

Distinção declarada com open_adr_pr (só o Arquiteto tem). Os dois COEXISTEM, com propósitos diferentes: decision_record é para TODA decisão relevante de qualquer um dos seis conversacionais, sem fricção nenhuma — nunca commita arquivo, nunca abre PR, nunca espera aprovação humana. open_adr_pr é para quando a decisão é grande o bastante para virar DOCUMENTO real em docs/adr/*.md, com PR de verdade e aprovação humana obrigatória. Um projeto pode ter dez decision_record e zero ADR numa sessão — o segundo não é a versão "séria" do primeiro, é outra ferramenta para outra escala de decisão.

Wiring nos cinco conversacionais que ainda não tinham emit_artifact — PO (po_server.ex:115), Arquiteto (arquiteto_server.ex:103), Dev Lead (dev_lead_server.ex:153), UX Designer (ux_designer_server.ex:94) e Staff (staff_server.ex:94); o Criativo já tinha a ferramenta desde a Fase 3b, só ganhou o tipo novo (é o known/0 compartilhado quem decide, sem mudança no CriativoServer). Em cada um, o run_tool/3 comum ganhou a cláusula "emit_artifact" -> EmitArtifact.run/2 (ex.: po_server.ex:279) — sem tratamento especial: diferente de propose_execution_plan/ assess_implementability no Dev Lead (que suspendem o turno esperando aprovação, ADR 0086), emit_artifact nunca devolve {:pending, _}, então entra no dispatch comum sem precisar do reduce_while que aquelas duas ferramentas exigem. E os cinco ganharam a MESMA frase no system_prompt/1 (ex.: po_server.ex:463) explicando quando usar type: decision_record — fora do texto de identidade (Engine.Harness.Agents) porque não é sobre QUEM o agente é, é uma instrução operacional sobre uma ferramenta.

Fora de escopo, declarado. Agentes de execução (QA, SecOps, Dev Agent de módulo) rodam sobre ToolLoop, sem harness comum aos seis conversacionais — ficam de fora desta fatia, investigação própria por módulo antes de estender. A amarração com a Frente 1 do mesmo plano (o dispatch do Infra Lead emitindo um decision_record server-emitted quando propõe subir container via runner) também fica de fora: depende de uma tool (propose_container_start_via_runner) que outro PR está criando em paralelo — ver TODO no PR desta mudança.

  • Onde: apps/engine/lib/engine/harness/artifact_schemas.ex (schema + @tool_emittable); apps/engine/lib/engine/agents/po_server.ex, arquiteto_server.ex, dev_lead_server.ex, ux_designer_server.ex, staff_server.ex (alias + tool_specs + run_tool/3 + system_prompt/1)
  • Teste: apps/engine/test/engine/harness/artifact_schemas_test.exs (describe("decision_record …")); apps/engine/test/engine/agents/po_server_test.exs e arquiteto_server_test.exs (describe("Frente 3 do plano de decision_record — emit_artifact"))
  • Origem: plano do dono do produto, Frente 3 ("Artefato de decisão")

RN-507 — Docker vira pré-requisito real do modo runner: os TRÊS modos de execução exigem o MESMO predicado​

Até aqui, Engine.Actions.TerminalExecutor.decisao_de_execucao/1 checava só DUAS pré-condições para rotear terminal a um projeto execution_mode: runner — workspace CONFIRMADO e runner CONECTADO agora — e roteava incondicionalmente assim que as duas batiam, sem checar se havia container running REGISTRADO (Engine.Containers.ProjectContainerLifecycle.running?/1), o mesmo predicado que container/mounted já exigiam desde a RN-502 (ADR 0143). O runner do lado de lá decidia sozinho se o comando rodava dentro do container que ele subiu (ADR 0137) ou caía no HOST puro — e sem Docker de pé na máquina do usuário, caía sempre no host, silenciosamente: o isolamento que o ADR 0134 buscou criar para container nunca valeu para runner.

A correção unifica os três modos sob a MESMA pergunta. runner ganha uma TERCEIRA pré-condição, na mesma ordem que as outras duas: verificado → conectado → container running. As três recusas nomeadas (workspace não verificado, runner desconectado, sem container) vivem hoje em Engine.Runners.RunnerReadiness — extraído porque um SEGUNDO consumidor nasceu no mesmo PR (Engine.Actions.Workspace.RunnerGit, abaixo): "não são duas derivações, é esta função com dois consumidores", o mesmo raciocínio do moduledoc de ProjectContainerLifecycle.

A materialização do worktree do dev agent muda de mecanismo, não de resultado. Engine.Actions.Workspace.ensure!/4 tentava File.mkdir_p!/ System.cmd("git", ...) LOCAL contra o caminho do HOST, mesmo em modo runner — a lacuna que a RN-478 registrou e deixou ABERTA de propósito ("o working tree do dev agent não tem onde nascer", já que o processo do engine não tem bind-mount nenhum para uma pasta que mora na máquina do usuário). A correção não é "melhorar a mensagem de erro" de novo: ensure!/4 passa a bifurcar por execution_mode — LOCAL para container/mounted (inalterado: mounted já enxerga sua pasta via BRABO_PROJECTS_BASE, bind-mount por identidade no engine desde o ADR 0141), via Engine.Actions.Workspace. RunnerGit para runner. RunnerGit faz git init/remote add/fetch/ checkout (e os equivalentes de File.dir?/ls/rm_rf que Engine.Dev.WorktreeManager precisa) como comandos de shell NORMAIS, entregues pelo MESMO canal Phoenix que já executa terminal aprovado (Engine.Runners.RunnerRouter.exec/5) — nunca um mecanismo novo, e sempre depois de RunnerReadiness.verificar/1 confirmar as três pré-condições (levanta com mensagem nomeada antes de tentar qualquer I/O, nunca um exec às cegas). Engine.Dev.WorktreeManager (create/3, remove/2, list/1, cleanup_orphans/2) bifurca pelo mesmo critério.

O job periódico de limpeza PULA em silêncio, nunca falha. Engine.Dev.WorktreeCleanup.run/0 varria File.dir?(work_dir) para decidir se podava — sempre false para runner (o caminho é do HOST, o engine não o enxerga), então runner nunca era podado de verdade nem testado por essa via. O gate vira por MODO: File.dir? continua para container/mounted; para runner, RunnerReadiness.pronto?/1 decide — sem runner conectado ou sem container running AGORA, o projeto é pulado NESTA rodada ("não dá pra saber agora", nunca um erro, nunca um worktree tratado como órfão por engano), e Enum.each/2 continua para os projetos seguintes sem exceção nenhuma atravessar.

O protocolo exec/exec_result ganha um campo NOVO: env (ADR 0056). O git fetch autenticado de RunnerGit precisa da credencial do provider — que só pode viajar no AMBIENTE do processo filho (nunca argv, nunca .git/config, mesma disciplina de Engine.Actions.GitAuth local). Como o processo filho agora nasce na máquina do usuário, o campo env (opcional, Record<string,string>) entra no payload que RunnerRouter.exec/5 despacha — presente só quando há credencial de verdade (provider local nunca o carrega). Do lado do runner: apps/runner/src/exec.ts MESCLA (nunca substitui) opts.env em cima de process.env antes do spawn — env definido sem merge descartaria PATH e quebraria a resolução do próprio binário git/sh. apps/runner/src/index.ts repassa msg.env só para o caminho HOST (executarComando) — o container (docker exec, via packages/docker-port) não ganhou suporte a env (fora de escopo desta entrega: a operação exec da porta continua sem esse campo, de propósito, ADR 0130). Nenhum log do runner imprime env em ponto nenhum — auditado por grep, console.log/console.warn/console.error seguem citando só ref/command/cwd/exitCode.

  • Onde: apps/engine/lib/engine/actions/terminal_executor.ex (decisao_de_execucao/1); apps/engine/lib/engine/runners/ runner_readiness.ex (novo); apps/engine/lib/engine/actions/workspace.ex (ensure!/4); apps/engine/lib/engine/actions/workspace/runner_git.ex (novo); apps/engine/lib/engine/dev/worktree_manager.ex; apps/engine/lib/engine/dev/worktree_cleanup.ex; apps/engine/lib/engine/projects/project.ex (all_workspace_dirs/0 ganha execution_mode); apps/engine/lib/engine/runners/runner_router.ex (exec/5); apps/engine/lib/engine_web/channels/terminal_channel.ex; apps/runner/src/channel.ts (ExecMessage.env); apps/runner/src/exec.ts; apps/runner/src/index.ts
  • Teste: apps/engine/test/engine/actions/terminal_executor_test.exs, apps/engine/test/engine/actions/workspace_runner_test.exs, apps/engine/test/engine/dev/worktree_manager_test.exs, apps/engine/test/engine/dev/worktree_cleanup_test.exs, apps/runner/src/channel.spec.ts
  • ADR: 0145
  • Origem: decisão do dono do produto — Docker vira pré-requisito real do modo runner, sem fallback pro host

RN-508 — container_start_via_runner: segundo tipo de ação para subir o container, exclusivo de runner, e o Infra Lead recusa às cegas ANTES de propor​

container_start (ADR 0130/0133) exige imagem/network/resources/ rationale — a Infra ELEGE uma candidata do roteamento do Arquiteto. Até esta entrega, o mesmo tipo de ação também atendia execution_mode: runner (ExecuteContainerStartUseCase.executeViaRunner), mas o caminho do runner NUNCA lia nenhum desses quatro campos: a imagem que sobe é a que já estiver DECIDIDA (não há roteamento contra o qual eleger, porque o broker nunca alcança a pasta de um projeto runner). Pedir 4 campos que o dispatch sempre descartava é o defeito que motiva nascer uma AÇÃO separada em vez de uma container_start "inteligente" ramificando por modo.

container_start_via_runner só tem rationale (opcional). Mesmo calibre de container_start (maintainer, require_approval por padrão, nunca semeado em auto-aprovação, configurável em permissions.json como as demais três ações de container — não entra no teto absoluto de container_remove). ExecuteContainerStartUseCase perde o ramo runner inteiro (executeViaRunner, ApiToEngineClient/ObterSpecDeContainerUseCase saem do construtor) — fica exclusivo de container/mounted, os dois pelo BROKER (RN-503). O código extraído vira ExecuteContainerStartViaRunnerUseCase, mesmo arquivo-irmão. A dança de ciclo de vida (provisioning -> running) que os dois caminhos compartilhavam por cópia vira SubirCicloDeVidaDoContainerUseCase (apps/api/.../containers/) — extraída para não duplicar o racional da máquina de estados nos dois arquivos.

O Infra Lead consulta LOCALMENTE, sem HTTP, antes de propor. A tool nova (Engine.Infra.Tools.ProposeContainerStartViaRunner) é interceptada por Engine.Infra.InfraLeadServer.dispatch_calls/2, que lê Project.get/1 (execution_mode) e Engine.Runners.Registry.connected?/1 (runner conectado) — as DUAS leituras já são locais, no mesmo processo BEAM — ANTES de chamar propose_action. Recusa com motivo NOMEADO (nunca propõe às cegas) quando o projeto está em container (usa propose_container_start), em mounted (idem, desde a RN-503) ou em runner sem runner conectado agora. Esta lacuna — "propor sem saber se dá certo" — é a MESMA que propose_container_start tem desde a RN-494, declarada e aceita para aquela tool (tocar o prompt/ instrução do Infra Lead ficaria fora do escopo de API/domínio); a tool NOVA nasce sem ela, porque nasce sabendo negar.

A especificação que a api compõe é a que o runner VALIDA, e isso passou a ser provado. EspecificacaoDeContainerParaRunner são os campos de EntradaDeEspecificacao (packages/docker-port) menos raizDoProjeto, que só o runner conhece — DEZ, não nove. Ela nasceu com nove, sem projectId, e a consequência foi que este caminho NUNCA subiu container nenhum desde que existe: o engine repassa o mapa opacamente, o runner o entrega a especificacaoValidada, que exige projectId como texto não vazio, e a ação terminava failed com "especificação de container recusada em projectId: esperava texto não vazio, recebi undefined" — e daí o bloqueio CORRETO da RN-507/RN-502 fazia o resto, sem nenhum dev agent reivindicando tarefa. O dado sempre existiu (SpecDeContainer.projectId, o mesmo que o BROKER recebe por GET .../container-spec, e é por isso que só o modo runner quebrou); só não era copiado. Três suítes verdes não pegaram porque cada uma montava o próprio fixture: o teste do caso de uso afirmava o que ele mesmo esperava, o do runner montava um fixture COM projectId, e o engine não olha o mapa. O que passa a ser garantido é a CORRENTE — o payload que ExecuteContainerStartViaRunnerUseCase compõe, atravessando o especificacaoValidada de verdade —, e o teste também trava o conjunto de campos: raizDoProjeto é o único que a api não manda, então campo novo de um lado sem o outro reprova.

  • Onde: apps/api/src/domain/actions/decide.ts (container_start_via_runner); apps/api/src/application/use-cases/actions/ execute-container-start-via-runner.use-case.ts (novo); apps/api/src/application/use-cases/containers/ subir-ciclo-de-vida-do-container.use-case.ts (novo); apps/api/src/application/use-cases/actions/propose-action.use-case.ts; apps/api/src/application/use-cases/actions/approve-action.use-case.ts; apps/api/src/application/ports/api-to-engine-client.port.ts (EspecificacaoDeContainerParaRunner, os dez campos); apps/web/src/lib/aprovacoes.ts; apps/engine/lib/engine/infra/tools/propose_container_start_via_runner.ex (novo); apps/engine/lib/engine/infra/infra_lead_server.ex
  • Teste: apps/api/test/application/use-cases/actions/execute-container-start.use-case.spec.ts, apps/api/test/application/use-cases/actions/ execute-container-start-via-runner.use-case.spec.ts (novo), apps/api/test/contract/ especificacao-de-container-para-runner.contract.spec.ts (a CORRENTE — o payload composto pela api contra o especificacaoValidada de verdade), apps/web/src/lib/aprovacoes.test.ts, apps/engine/test/engine/infra/tools/ propose_container_start_via_runner_test.exs (novo), apps/engine/test/engine/infra/infra_lead_server_test.exs
  • ADR: 0145
  • Origem: decisão do dono do produto — Docker vira pré-requisito real do modo runner, sem fallback pro host

O join do agente local deixa de ser mudo (RN-514)​

RN-514 — O runner DECLARA o que sabe fazer no join, o servidor CONCEDE a interseção em socket.assigns, e mensagem sem capacidade é RECUSADA em vez de sumir​

Até aqui o join do canal terminal:<projectId> era mudo dos dois lados: o runner mandava socket.channel(\terminal:${projectId}`, {}) e o servidor os ignorava literalmente (def join("terminal:" <> project_id, _params, socket)). Toda a identidade vinha do ticket — o papel (:runner/:web) sai do kind` —, e o servidor não tinha como saber se o binário do outro lado era de uma versão que entende uma mensagem nova.

O defeito que isso produz é o pior possível, e é o que esta regra fecha: a mensagem chega, o handler não existe do outro lado, e nada acontece — sem erro, sem log, sem ninguém saber que a função nunca rodou.

Três capacidades, e só o que o runner sabe fazer é declarado. O vocabulário do servidor é exec (comando aprovado, ADR 0104), pty (terminal interativo, ADR 0103) e espelho (copiar o trabalho para uma pasta fora da base montada). O runner declara exec e pty, e espelho fica de fora de propósito: declarar o que não se implementa é exatamente o defeito que a negociação existe para impedir — o servidor concederia, entregaria a mensagem, e ela sumiria do mesmo jeito. espelho nasce só como nome do vocabulário, sem comportamento nenhum; quem o implementa é a sessão 6 da FASE 28.

Ausência de params é o LEGADO, e isso é fato — não benevolência. Todo binário anterior a esta mudança manda params vazios, e todo binário anterior a esta mudança sabe fazer exec e pty: são as duas funções com que o runner nasceu. Conceder {exec, pty} a quem não declara nada é ler corretamente a única coisa que aquele binário poderia ter dito. O contrário faria todo runner instalado parar de conectar, que é mudança exigindo ação do operador antes do deploy — breaking/ e MAJOR, pela convenção do repositório, e não é isto que esta entrega é. Params ausentes, capacidades ausente, lista vazia e valor que não é lista caem todos no legado: são as formas que "este binário não sabe declarar nada" assume no caminho de rede.

Desconhecido é IGNORADO, nunca motivo de recusa. O conjunto concedido é a interseção do declarado com o vocabulário que este servidor conhece — runner mais NOVO que o engine tem que conseguir conectar. Já declarar apenas nomes desconhecidos é declaração vazia, não legado: quem sabe declarar disse o que sabe fazer.

O que cada modo EXIGE. runner exige exec — é literalmente o que faz o modo funcionar, e sem ele o TerminalExecutor rotearia comando aprovado para um binário que não o executa, com a falha aparecendo como timeout em vez de recusa. container e mounted não exigem nada (o runner nem é o caminho de execução deles). Nada exige espelho ainda — o mecanismo de recusa nasce implementado e testado, e não dispara na prática hoje. Isso é o desenho, não pendência. Modo que não se conseguiu ler (projeto inexistente, id malformado, consulta que falhou) exige nada: recusar por pré-condição não confirmada seria colapsar "não sei" com "não tem" (RN-088).

O conjunto concedido vive em socket.assigns, nunca em tabela. Capacidade é propriedade daquela conexão — uma tabela poderia afirmar que um runner sabe algo que o processo conectado agora não sabe. É o mesmo raciocínio que faz a entrada :global do Engine.Runners.Registry morrer junto com o pid. Só o papel :runner recebe conjunto: o socket :web não ganha :capacidades e nenhuma checagem de capacidade se aplica a ele — a aba não executa nada, ela pede.

Recusa do join nomeia a que falta. {:error, %{reason: ...}} dizendo qual capacidade o projeto exige e que o binário precisa ser atualizado. A recusa é fatal do lado do runner, sem retry (JoinRecusadoError, comportamento que já existia e não mudou) — por isso ela é a única chance de explicar, e "recusado" sem o nome do que falta obrigaria a adivinhar. A checagem acontece antes de Registry.register/2: registrar e recusar em seguida deixaria o Registry afirmando um runner que não entrou.

E a metade que entrega valor hoje: mensagem sem a capacidade concedida é RECUSADA com resposta nomeada, nunca engolida. exec para um runner sem exec responde ao chamador no mesmo formato de exec_result que o runner mandaria (exitCode 126 — "encontrado, mas não executável" —, a causa no output), em vez de deixar o RunnerRouter bloqueado até o receive ... after e o usuário ver um timeout no lugar da causa. pty_* para um runner sem pty não é relayado, e a :web que originou recebe pty_error nomeado, pelo mesmo broadcast filtrado que o runner usaria para reportar erro de PTY — sem isso a aba ficaria em "carregando" para sempre, o defeito que o whereis de handle_in("pty_open", ...) já tinha fechado para o caso "sem runner". Como o legado tem as duas capacidades, nada muda para ninguém hoje.

Isto não é fronteira de segurança. Capacidade é negociação de protocolo; quem autoriza continua sendo o ticket de uso único (RN-108) e o pipeline de proposed_action. O que o runner ganha declarando é que mensagens daquele tipo lhe sejam entregues, nunca permissão para nada.

  • Onde: apps/engine/lib/engine/runners/capacidades.ex (novo — o vocabulário, o legado, o que cada modo exige e as duas mensagens); apps/engine/lib/engine_web/channels/terminal_channel.ex:208 (join/3 passa a receber params), :215 (autorizar_por_papel/3), :251 (entrar_como_runner/3, a concessão antes do Registry), :305 (exigencias_do_projeto/1, que era modo_de_execucao/1 até a RN-516 fazer a mesma consulta responder também o destino do espelho), :595 (o gate de exec em handle_info({:dispatch_exec, ...})) e :762 (o de pty em handle_info({:relay, "pty_" <> _, ...})); apps/runner/src/channel.ts (CAPACIDADES_DO_RUNNER e os params do socket.channel/2)
  • Teste: apps/engine/test/engine/runners/capacidades_test.exs (novo — o vocabulário puro: legado, lista vazia, desconhecido ignorado, o que cada modo exige, e que nada exige espelho); apps/engine/test/engine_web/channels/terminal_channel_test.exs (describe "capacidades declaradas no join (ADR 0147 ponto 1, RN-514)" — concessão em assigns, join sem params, desconhecido ignorado, :web sem conjunto, a recusa nomeando exec num projeto runner, e as duas mensagens recusadas com resposta em vez de silêncio); apps/runner/src/channel.spec.ts (o CONTEÚDO dos params do join, espelho fora da lista, e a recusa virando JoinRecusadoError com a mensagem do servidor legível)
  • ADR: 0147, ponto 1
  • Origem: FASE 28, sessão 5

O destino do espelho é por projeto (RN-515)​

RN-515 — O destino do espelho é declarado POR PROJETO e nunca é global; null é o estado normal; e a api valida SÓ o léxico, dizendo que é só o léxico​

A capacidade espelho (RN-514, ADR 0147) copia o trabalho dos agentes para uma pasta do usuário fora da base montada — que é a razão de ela existir, porque bind mount não atravessa rede nem alcança caminho fora do que foi montado. Esta regra decide onde esse destino mora e como o usuário o declara; ela não copia arquivo nenhum.

Por PROJETO, nunca global. O destino é a coluna projects.mirror_path e o campo mirrorPath do projeto, escrito por PUT /projects/:projectId/mirror-path (maintainer, o mesmo mínimo de execution-mode e de projects-base: a rota fala de um caminho do computador do operador). Nunca configuração global no runner, nunca variável de ambiente: um destino global faria o artefato do projeto B aterrissar na pasta do projeto A, e o usuário descobriria isso pelo conteúdo, não por um erro (ADR 0147, ponto 4).

null é o estado NORMAL, não uma pendência. A coluna é nullable e nasce nula; projeto sem espelho é a maioria, e o produto nunca escolhe um destino sozinho. Limpar é {"mirrorPath": null} — a chave é obrigatória, e omiti-la é 400: um corpo que omite o campo seria indistinguível de "limpe", e limpar em silêncio é justamente o defeito. Limpar funciona sempre, inclusive num projeto container, senão uma linha convertida ficaria presa no estado que a regra proíbe.

Só o LÉXICO, e a api DIZ que é só o léxico. A api não enxerga a máquina onde o destino vai existir — é a situação do modo runner (RN-423), não a do mounted, onde a pasta acaba dentro da base que a api alcança de verdade. Ela reusa caminhoDeWorkspaceLocalValido, o mesmo predicado da criação e da conversão (absoluto, sem ../., nunca a raiz nem pasta de sistema, nunca sobreposto ao checkout do Brabo), em vez de uma quarta cópia da mesma régua — e não exige BRABO_PROJECTS_BASE, porque o destino do espelho é por definição a pasta que a base não cobre. A mensagem de recusa declara que o disco não foi verificado e quem o verifica.

Os dois sentidos do mesmo laço. Destino dentro do workspacePath e destino que contém o workspacePath são a mesma recusa vista de dois lados (ADR 0147, ponto 2): escrever o espelho dentro da origem faz o espelho copiar o próprio espelho, e uma origem dentro do destino aninha a cópia a cada rodada. Recusar um e permitir o outro seria fechar a porta e deixar a janela. A comparação é por SEGMENTO (dentroDoEscopo), nunca startsWith cru: /base-outra não está dentro de /base, e recusá-lo taxaria uma pasta irmã perfeitamente legítima.

Esta é a metade LÉXICA da guarda, e só ela. A outra é realpath, e é do RUNNER (espelho-guard.ts, ADR 0147 ponto 2), na máquina onde os dois caminhos existem de verdade. Um symlink em qualquer segmento do destino apontando de volta para a origem passa por aqui sem ser visto — a api não tem disco onde resolvê-lo. A api não garantiu o laço; ela impediu o laço ESCRITO.

container é recusado, e a recusa é NOMEADA. A origem de um projeto container é um volume gerenciado no servidor, e quem copiaria é um processo na máquina do usuário, que não a enxerga. Aceitar e nunca copiar seria pior que recusar, porque pareceria configurado. mounted e runner podem. Pelo mesmo motivo, converter o modo ZERA o destino (RN-447..450): em container ele não poderia existir, e mesmo entre mounted e runner a conversão MOVE a origem contra a qual os dois sentidos do laço foram validados.

O valor volta em toda leitura de projeto (ProjectResponseDto.mirrorPath), para a implementação do espelho e a tela de estado não precisarem de rota nova. Não há endpoint de leitura dedicado.

E ele NÃO é proposed_action. A escrita do espelho é configuração que o usuário declarou, não um agente pedindo para agir — o ADR 0147 é explícito disso, e sincronizar por comando de terminal cairia no escopo do ADR 0055 e viraria fila de aprovações rotineiras.

  • Onde: apps/api/src/db/schema/iam.ts (coluna mirror_path, nullable, sem CHECK — a recusa de container mora no caso de uso porque o modo é CONVERSÍVEL); apps/api/src/db/migrations/0056_lovely_firelord.sql; apps/api/src/domain/iam/project.entity.ts (mirrorPath); apps/api/src/application/services/workspace-location.ts (validarDestinoDeEspelho — o léxico reusado, a recusa de container e os dois sentidos do laço); apps/api/src/application/use-cases/iam/set-project-mirror-path.use-case.ts; apps/api/src/application/use-cases/iam/convert-project-execution-mode.use-case.ts (a conversão zerando o destino); apps/api/src/interfaces/http/iam/dto/set-mirror-path.dto.ts (@IsDefined
    • @ValidateIf, nunca @IsOptional, que colapsaria null e undefined); apps/api/src/interfaces/http/iam/projects.controller.ts (PUT :projectId/mirror-path, maintainer); apps/api/src/interfaces/http/iam/dto/iam.response.dto.ts
  • Teste: apps/api/test/application/use-cases/iam/set-project-mirror-path.use-case.spec.ts (grava, normaliza, limpa, limpa mesmo em container, recusa não-absoluto/../raiz/pasta de sistema/checkout do Brabo, os dois sentidos do laço, /base-outra vs /base NÃO sendo dentro, container recusado, 404); apps/api/test/interfaces/http/iam/projects-execution-mode.controller.spec.ts (describe "destino do espelho (RN-515)" — maintainer e o repasse de null); apps/api/test/application/use-cases/iam/convert-project-execution-mode.use-case.spec.ts (a conversão zerando o destino); apps/api/test/interfaces/route-surface.spec.ts (a rota classificada em docs/security-surface.md)
  • ADR: 0147, ponto 4
  • Origem: FASE 28, sessão 6 (metade A — onde o destino mora; a cópia, o mirror_sync e a segunda passada de realpath são a metade B, no engine e no runner)

O espelho copia o trabalho para a pasta do usuário (RN-516)​

RN-516 — O destino do espelho viaja na CONCESSÃO do join, o espelho copia a lista do git numa direção só e nunca apaga, e o gatilho é um momento nomeado — nunca um watcher​

A metade B da sessão 6 da FASE 28: a capacidade espelho (RN-514, ADR 0147) passa a EXISTIR. A RN-515 decidiu onde o destino mora e não copiou arquivo nenhum; esta decide como ele chega ao agente local, o que é copiado e quando.

O destino viaja na concessão do join, nunca em configuração do runner. A resposta do join do canal terminal:<projectId> deixou de ser vazia para o papel :runner: quando o projeto tem destino declarado (projects.mirror_path) e a capacidade espelho foi concedida, ela carrega %{espelho: %{destino: "<caminho>"}}. O runner guarda isso em estado.destinoDoEspelho, que morre junto com a conexão, e recusa mirror_sync para destino que não lhe foi concedido naquela conexão (ADR 0147, ponto 4). Nunca variável de ambiente, nunca arquivo local: um destino global faria o artefato do projeto B aterrissar na pasta do projeto A, e o usuário descobriria isso pelo conteúdo, não por um erro.

Consequência declarada, e ela é o preço: trocar o destino com o runner conectado exige reconectá-lo. A concessão é do join, e um destino novo chegando por outra porta seria exatamente a configuração global que a regra recusa.

mirror_path não-nulo passa a EXIGIR a capacidade. Ela não entra no mapa por execution_mode — quem a exige é um DADO do projeto, não o modo (e mounted e runner podem ter destino enquanto container não pode). Este é o primeiro caso REAL do mecanismo de recusa que a RN-514 deixou implementado e sem nenhum disparo: um brabo-runner anterior a esta versão conectado a um projeto com destino deixa de conectar, com recusa nomeada (JoinRecusadoError, fatal e sem retry). É opt-in — só acontece onde alguém configurou um destino — e o custo está declarado no próprio ADR 0147.

As TRÊS recusas da guarda, e a passada de realpath não é redundante. apps/runner/src/espelho-guard.ts é irmão de guard.ts, não uma extensão dele: reusa semBarraFinal/dentroDoEscopo/realpathMaisProximo e a dupla passada léxica-depois-realpath de validarCwdDentroDaRaiz, e herda por escrito a mesma ressalva de TOCTOU — é best-effort, não fronteira de segurança. Ele recusa (1) symlink que escape do destino, (2) destino que contenha o workspace e (3) destino dentro do workspace; (2) e (3) são o mesmo laço em sentidos opostos, e com o bind por identidade do ADR 0141 escrever o espelho dentro da origem faz o espelho copiar o próprio espelho. A api já recusou a metade léxica (RN-515) e diz, no próprio código, que é só o léxico — ela não tem disco onde resolver um symlink do computador do usuário. destino/atalho -> /caminho/do/projeto passa por lá sem ser visto, e é aqui que ele é resolvido.

Uma diferença de mecanismo, achada implementando: realpathMaisProximo cai no primeiro ancestral que existe, e o destino do espelho quase nunca existe (é o mkdir -p desta rodada que o cria). Colapsar os dois lados no ancestral faria /base-outra e /base virarem ambos /, e a guarda recusaria toda pasta irmã. Por isso a segunda passada resolve o ancestral e recoloca o sufixo — symlink só pode morar num segmento que existe, então o que sobra nunca esconde link nenhum. A recusa (1) tem ainda uma segunda metade, por arquivo (caminhoNoDestino): um destino/sub que é link para /etc faria a cópia de sub/passwd escrever fora, com o destino em si perfeitamente válido.

Uma direção, e NUNCA apaga. Workspace → destino. Nunca --delete, nunca unlink, nunca rm. Arquivo apagado no workspace permanece no destino, e isso é decisão, não descuido: a pasta do usuário não é réplica, é acúmulo — um agente que apaga arquivo na pasta pessoal de alguém por causa de um git clean do outro lado é a surpresa que o produto existe para não produzir. Sobrescrever arquivo que mudou é esperado; remover nunca é. O preço, declarado no ADR: o destino só cresce, e nenhum mecanismo automático o limpa.

O que se copia é a LISTA DO GIT, e nada mais. Os arquivos rastreados (git ls-files) mais os não-rastreados que não são ignorados (git ls-files --others --exclude-standard), com -z (o git aspeia e escapa caminho com espaço ou acento sem ele). Isso é exatamente "o trabalho": pula node_modules, dist, saída de build e o próprio .git sem manter lista de exclusão nenhuma, que envelheceria a cada stack nova — o .gitignore do projeto já é a lista, mantida por quem mantém o projeto. O workspace é repositório git por construção (o produto provisiona Gitflow nele e o materializa com git init/fetch/checkout), então a pré-condição é do PRODUTO, não uma suposição sobre a máquina. Git que falha ou pasta que não é repositório falham com motivo nomeado — nunca um cp -r de tudo como plano B, que é como node_modules inteiro acabaria na pasta pessoal de alguém.

Duas propriedades verificadas e declaradas, não escondidas: repositório git aninhado volta como UMA entrada de diretório e não é descido — o worktree de cada dev agent (<workspace>/.worktrees/<agent_id>) é um desses, então o espelho mostra o checkout principal, nunca o trabalho ainda isolado num worktree; e symlink na origem é pulado, porque seguir o link traria conteúdo de fora do projeto para dentro da pasta pessoal. O que fica de fora é CONTADO (pulados/recusados), nunca engolido. E não há heurística de "mudou?": copiar sempre custa bytes já iguais, enquanto pular por tamanho+mtime faria o espelho, em silêncio, não copiar um arquivo que mudou.

Predicado PRÓPRIO, e o espelho não exige Docker. Engine.Runners.RunnerReadiness fica byte a byte como está (ADR 0147, ponto 3) — Engine.Runners.Espelho não o chama, não o estende e não recebe nada dele. O espelho tem DUAS pré-condições, workspace confirmado e runner conectado, e não a terceira que a RN-507 acrescentou (container REGISTRADO running). Um RunnerReadiness com flag "pula container" é precisamente o mecanismo pelo qual essa terceira cai por acidente para o exec numa refatoração futura, e o ADR 0145 existe para ela não cair. E o espelho legitimamente não precisa dela: a terceira existe porque havia duas execuções possíveis e a errada era invisível; cópia de arquivo na máquina do usuário não tem container de onde cair. Ele também não pergunta o execution_mode — quem já decidiu isso foi a api ao gravar o destino (RN-515 recusa container), e repetir a decisão seria a segunda fonte da mesma regra.

Gatilho por MOMENTO NOMEADO, nunca watcher. O engine empurra mirror_sync de um ponto do código que tem nome — hoje o commit bem-sucedido (EngineWeb.ActionCommandController, git_commit). Nunca fs.watch: watcher é trabalho ilimitado disparado por qualquer coisa — uma instalação de dependências, um build, e pior, pelas escritas do próprio espelho, que é laço — e rodaria continuamente na máquina do usuário sem ninguém ter pedido, que é o oposto do que "agente local" deve significar. O empurrão é fire-and-forget: sem from, sem pending_execs e sem mirror_sync_result, porque quem disparou está no meio de outra coisa e a telemetria da sincronização é o ponto 7 do ADR, de outra sessão. Falha do espelho vira log local, nunca exceção que derruba o runner e nunca silêncio.

Ele NÃO é proposed_action, e não deve virar uma. A escrita do espelho é configuração que o usuário declarou, não um agente pedindo para agir — o ADR 0147 é explícito disso, e sincronizar por comando de terminal cairia no escopo do ADR 0055 e viraria fila de aprovações rotineiras, corroendo o teto que dá sentido ao clique.

  • Onde: apps/runner/src/espelho-guard.ts (as três recusas, a dupla passada com sufixo preservado, a ressalva de TOCTOU herdada); apps/runner/src/espelho.ts (a lista do git, a cópia numa direção, o mkdir -p só DEPOIS da guarda); apps/runner/src/guard.ts (os três helpers passam a ser exportados — uma fonte só de comparação de caminho); apps/runner/src/channel.ts (CAPACIDADES_DO_RUNNER ganha espelho, MirrorSyncMessage, EspelhoConcedido, espelhoConcedidoDaResposta); apps/runner/src/index.ts (EstadoDoRunner.destinoDoEspelho, tratarMirrorSync — a recusa do destino não concedido); apps/engine/lib/engine/runners/capacidades.ex (exigidas/2/conceder/3 e destino?/1 — o destino, não o modo, é quem exige); apps/engine/lib/engine/runners/espelho.ex (o predicado próprio, o destino/1 do join e o sincronizar/2 fire-and-forget); apps/engine/lib/engine/projects/project.ex (mirror_path no schema de leitura); apps/engine/lib/engine_web/channels/terminal_channel.ex (a concessão na resposta do join e o dispatch_mirror_sync); apps/engine/lib/engine_web/controllers/action_command_controller.ex (o gatilho do commit)
  • Teste: apps/runner/src/espelho-guard.spec.ts (destino dentro, destino contendo, symlink de verdade em tmp escapando pelo próprio destino e por um segmento do meio, /base-outra vs /base, o alvo por arquivo, lstat vs stat); apps/runner/src/espelho.spec.ts (repositório git de verdade: rastreado + não-rastreado-não-ignorado, ignorado de fora, arquivo apagado na origem permanece no destino, repositório aninhado pulado, symlink da origem pulado, symlink do destino recusado, guarda antes do mkdir, pasta sem git falhando nomeada); apps/runner/src/channel.spec.ts (o destino vindo na resposta do join, as três capacidades declaradas, mirror_sync chegando ao handler); apps/runner/src/index-handlers.spec.ts (describe "tratarMirrorSync" — o destino não concedido recusado, a conexão sem concessão, a falha que não lança, e o canal sem push nenhum de volta); apps/engine/test/engine/runners/capacidades_test.exs (o destino exigindo a capacidade, o binário legado recusado, destino?/1); apps/engine/test/engine/runners/espelho_test.exs (as DUAS pré-condições e a AUSÊNCIA da terceira — :pronto sem container nenhum registrado); apps/engine/test/engine_web/channels/terminal_channel_test.exs (describe "o destino do espelho na concessão do join" — a concessão, as duas recusas e o push de mirror_sync); apps/engine/test/engine_web/controllers/action_command_controller_test.exs (o gatilho: commit bem-sucedido dispara, commit que falha não dispara)
  • ADR: 0147, pontos 2, 3, 4 e 8
  • Origem: FASE 28, sessão 6 (metade B). Ficou declarado e não ligado o segundo momento nomeado do ponto 8, "fim de turno de agente": o engine tem duas coisas disjuntas com esse nome e nenhuma é um enganche não-ambíguo — Engine.Agents.TurnoAssincrono.finalizar/1 fecha o turno dos seis conversacionais, que não têm ferramenta de escrita no workspace, e também dispara em crash e em cancelamento; Engine.Dev.AgentIo.finish_task/3 não é fronteira de ociosidade (reivindica a próxima task na mesma chamada) e o que o dev agent escreve mora no worktree, que o espelho deliberadamente não desce. Escolher um seria inventar produto onde o ADR nomeou um momento sem nomear uma função

O estado do espelho é visível, e os três estados não colapsam (RN-517)​

RN-517 — O desfecho de cada rodada do espelho volta pelo canal e vira TELEMETRIA numa tabela própria; "nunca sincronizou", "sincronizou e não copiou nada" e "falhou" são TRÊS respostas, e sucesso e erro convivem​

A sessão 8 da FASE 28 e o ponto 7 do ADR 0147. A RN-516 fez o espelho copiar de verdade e deixou o mirror_sync fire-and-forget — sem mirror_sync_result no protocolo, de propósito, porque o formato da telemetria era decisão desta entrega. É ela que fecha o laço.

O caminho é o de workspace_confirm, e nenhum outro. O runner empurra mirror_sync_result no canal terminal:<projectId> depois de a rodada terminar; o engine trata em handle_in/3 e REPASSA à api pelo HTTP interno (POST /internal/projects/:projectId/mirror-sync-result), que grava pelo caso de uso. O engine não escreve a tabela — um segundo caminho de escrita seria a segunda fonte da mesma verdade, e a primeira coisa a divergir. É o único precedente de o runner contar algo sobre si mesmo, e foi por isso que o ADR o nomeou.

Nunca um "ok" otimista. O desfecho reportado é o REAL: as três contagens que sincronizarEspelho devolveu (copiados, pulados, recusados — nunca recontadas) ou o erro nomeado. As TRÊS saídas de tratarMirrorSync reportam, e com mensagens diferentes: a recusa por destino não concedido, a falha da cópia e o sucesso. ok NUNCA é deduzido de haver contagem ou mensagem — nem no runner, nem na tradução do engine, nem no caso de uso da api: deduzir tornaria uma rodada que copiou 0 arquivos (perfeitamente normal) indistinguível de uma que nem chegou a rodar.

TABELA, e não event log. project_mirror_states, uma linha por projeto (project_id único, mesmo desenho de project_containers). O motivo é o mesmo que fez rag_searches virar tabela (RN-479): session_events.session_id é NOT NULL e uma rodada de espelho não tem sessão — o event log perderia a coisa inteira, não uma parte dela. E não é proposed_action em hipótese nenhuma: a escrita do espelho é configuração que o usuário declarou, não um agente pedindo para agir.

Os TRÊS estados da RN-088, e eles não colapsam. "Nunca sincronizou" é a linha ausente. "Sincronizou e não copiou nada" é linha presente com files_copied = 0 — um desfecho, não um vazio. "Falhou" é last_error_at mais recente que last_synced_at. São três respostas diferentes, a pessoa age diferente em cada uma, e um traço servindo às três seria a tela recusando nomear o que sabe (RN-470).

Sucesso e erro CONVIVEM na mesma linha. As duas escritas mencionam colunas disjuntas: a de sucesso não toca last_error/last_error_at, a de falha não toca nenhuma coluna de sucesso, e nenhuma das duas apaga a outra. Quem decide qual está VIGENTE é a comparação dos dois carimbos (deriveMirrorSyncStatus, função pura do domínio), e é isso que permite a tela dizer "a última rodada falhou — última cópia boa em ontem, com 412 arquivos". Uma escrita destrutiva de um lado sobre o outro jogaria fora justamente a informação mais útil que a tela tem enquanto o espelho está quebrado. Duas escolhas menores na mesma direção: falha SEM destino não apaga o destino da última cópia que funcionou, e empate exato de carimbo resolve para failed — entre afirmar que está tudo bem e afirmar que algo falhou, a afirmação segura é a que faz a pessoa olhar.

destination é CONGELADO, como image_version em project_containers e os pesos em rag_searches (RN-479): é o destino REAL daquela rodada, resolvido por realpath na máquina do usuário depois do mkdir -p. Ler projects.mirror_path na hora de mostrar diria o destino de AGORA sobre uma cópia de ontem — e os dois divergem de verdade, porque a concessão viaja no join e só muda quando o runner reconecta (RN-516). Quando divergem, a tela DIZ isso.

Gravar telemetria jamais derruba o que ela mede. A cópia já terminou quando o reporte sai; o handle_in do engine só LOGA a recusa da api e devolve {:noreply, socket} nos dois ramos, e o canal do runner segue vivo. Sem canal (a conexão caiu entre o pedido e o fim da cópia) o runner não reporta e não lança — a rodada que aconteceu não é desfeita por isso. E um projeto cujo destino foi LIMPO no meio ainda registra: a rodada aconteceu, e o erro dela é o que costuma explicar o que houve.

Na tela, a linha entra em AmbienteDoProjeto (a coluna lateral da Visão geral, ao lado das de modo de execução e runner) e só existe em projeto que tem mirrorPath — num projeto sem espelho, "nunca sincronizou" seria uma ausência inventada, o mesmo argumento que já valia para a linha do runner. Data ABSOLUTA com ressalva e nunca bolinha verde de "está de pé", pelo mesmo precedente de workspaceVerifiedAt: o carimbo diz que uma rodada aconteceu, não que a pasta esteja em dia com o que mudou depois (o gatilho é um momento nomeado, o commit — nunca um watcher). O status chega RESOLVIDO da api: a regra que decide qual carimbo vale tem uma fonte só, e é ela que decide qual das três frases a pessoa lê.

Papel viewer para LER, maintainer para escrever o destino — a régua da RN-102 aplicada: o mínimo é do ENDPOINT, e este só lê. O destino já viaja em toda leitura de projeto (GET /projects/:projectId, viewer), então exigir maintainer aqui trancaria informação para quem já a vê no mesmo projeto, que é o pior dos dois defeitos porque é invisível para quem perdeu a capacidade.

  • Onde: apps/api/src/db/schema/iam.ts (project_mirror_states, no agregado iam porque é lá que o destino mora — não há domain/runners); apps/api/src/db/migrations/0057_brave_silver_centurion.sql; apps/api/src/domain/iam/mirror-state.ts (deriveMirrorSyncStatus, a regra dos três estados, pura); apps/api/src/application/ports/mirror-state-repository.port.ts (DOIS métodos de escrita, não um upsert com tudo opcional — é o que torna a disjunção um invariante do tipo, não uma convenção do chamador); apps/api/src/infrastructure/persistence/drizzle/mirror-state.repository.ts; apps/api/src/application/use-cases/iam/record-mirror-sync.use-case.ts e .../get-project-mirror-state.use-case.ts; apps/api/src/interfaces/http/internal/internal-projects.controller.ts (POST .../mirror-sync-result); apps/api/src/interfaces/http/iam/projects.controller.ts (GET .../mirror-state, viewer); apps/engine/lib/engine/sessions/engine_api_client.ex (report_mirror_sync/2); apps/engine/lib/engine_web/channels/terminal_channel.ex (handle_in("mirror_sync_result", …) e a tradução do vocabulário); apps/runner/src/channel.ts (MirrorSyncResultMessage, enviarMirrorSyncResult); apps/runner/src/index.ts (tratarMirrorSync reporta as três saídas); apps/web/src/components/AmbienteDoProjeto.tsx
  • Teste: apps/api/test/application/use-cases/iam/mirror-sync-telemetry.use-case.spec.ts (os três estados na função pura e na leitura; erro que não apaga o sucesso; destino trocado; contagem 0; mensagem sem texto e mensagem longa; projeto sem destino que ainda registra); apps/api/test/infrastructure/persistence/drizzle/mirror-state.repository.spec.ts (o invariante das colunas disjuntas contra o banco de verdade — um fake provaria o fake); apps/engine/test/engine_web/channels/terminal_channel_test.exs (describe "mirror_sync_result" — o repasse com tradução, o filesCopied: 0 que não some, campo ausente que NÃO vira nil, sucesso faltando que vira ok: false, :web ignorado, e a api que recusa sem derrubar o canal); apps/runner/src/index-handlers.spec.ts (describe "o desfecho é reportado em mirror_sync_result" — contagem real, repositório vazio como sucesso, falha nomeada, recusa sem destino, e a conexão caída que não reporta nem lança); apps/web/src/components/AmbienteDoProjeto.test.tsx (describe "linha do espelho" — sem mirrorPath a linha some e nem consulta; os três estados com três frases; nenhum vocabulário de liveness; destino trocado dito)
  • ADR: 0147, ponto 7
  • Origem: FASE 28, sessão 8. Fica declarado e NÃO feito: nenhum alarme, nenhuma notificação e nenhuma retentativa automática de rodada que falhou — a próxima rodada acontece no próximo momento nomeado, como sempre, e a tela é o único lugar onde a falha aparece hoje

O agente local vira serviço de USUÁRIO, e status não colapsa estado (RN-518)​

RN-518 — brabo-runner service install|uninstall|status instala nível de USUÁRIO e nunca root; uninstall limpa a pasta que a UNIT registra; status responde quatro estados sem colapsar nenhum; Windows é recusa nomeada​

A sessão 7 da FASE 28 e o ponto 5 do ADR 0147. Até aqui o agente local era só primeiro plano: um processo encerrado por SIGINT/SIGTERM, que morre com o terminal e não volta com a sessão do usuário. Não havia precedente nenhum — nenhuma menção a systemd, launchd ou LaunchAgent em apps/runner/.

Nível de usuário, SEMPRE — e root é RECUSA, não aviso. systemd --user no Linux, LaunchAgent no macOS. Isso não é preferência de empacotamento: o docblock de apps/runner/src/guard.ts:9-31 declara "o runner roda NA máquina do usuário, com os privilégios DELE" como a premissa em que as três fronteiras reais do produto se apoiam (autenticação, pipeline de aprovação, consentimento) — e a guarda de cwd é best-effort por escrito, justamente porque não é ela que segura nada. Um serviço de sistema rodando como root quebraria o invariante e deixaria a guarda best-effort como única coisa entre um comando aprovado e o disco da máquina. Por isso install com uid 0 recusa nomeando o motivo, sem escrever arquivo nenhum e sem rodar comando nenhum: instalar-e-avisar deixaria a instalação errada de pé, e o aviso seria lido uma vez só.

Windows fica FORA DE ESCOPO, e a recusa é nomeada. A matriz de build já produz o binário de Windows (ADR 0112), mas serviço de usuário ali é um terceiro mecanismo (Serviços do SO com conta de usuário, ou Agendador de Tarefas no logon) — não uma variação dos dois. Os três subcomandos respondem nomeando a plataforma, dizendo que a decisão é declarada, e apontando o caminho que continua funcionando (rodar em primeiro plano). Nunca falha muda, nunca um "instalado" que não instalou nada. Qualquer outra plataforma cai na mesma recusa.

Uma unit POR PROJETO, nomeada pelo projectId — brabo-runner-<id>.service / dev.brabo.runner.<id> — porque o servidor já impõe um runner por projeto: um segundo join no mesmo terminal:<projectId> é recusado pelo Engine.Runners.Registry. Nomear pela PASTA permitiria instalar duas units que nunca podem estar de pé ao mesmo tempo, e a segunda apareceria como falha de conexão em vez de erro de instalação. Instalar de novo, de outra pasta, sobrescreve. uninstall e status alcançam exatamente a mesma unit porque derivam o nome do MESMO projectId, resolvido pelas MESMAS duas fontes na mesma ordem do CLI de sempre (--project, senão brabo-runner.config.json), e a pasta sai de resolverDir de guard.ts — nunca uma segunda régua. No Linux o arquivo vai para onde o systemd procura, com a precedência dele: $XDG_CONFIG_HOME/systemd/user quando a variável está posta, ~/.config/systemd/user quando não — escolher só o segundo faria a unit nascer onde o gerenciador nunca olha, e a falha apareceria como "unit file does not exist" no enable, visível mas pelo motivo errado.

O projectId vira NOME DE ARQUIVO, então ele é validado aqui. Ele chega de duas fontes que não são o servidor (uma flag digitada e um arquivo da pasta), e um --project ../../etc/systemd escreveria fora do diretório de units. Só letras, dígitos, ., - e _, até 64 caracteres — UUID, o formato real de todo projeto do produto, passa inteiro. Caminho do runner e pasta com aspa dupla ou quebra de linha também são recusa nomeada: quebra de linha num arquivo de unit é injeção de diretiva, não caso de borda.

Um serviço autentica por CHAVE DE DISPOSITIVO, nunca por token. install exige brabo-runner-device-key.jwk.json válido na pasta e recusa sem ele, ignorando --token/BRABO_ACCOUNT_TOKEN de propósito: gravar um token de conta dentro de um arquivo de unit o deixaria em disco, legível por qualquer processo do usuário e sobrevivendo ao shell que o exportou — e este CLI nunca gravou credencial em disco (o docblock de auth.ts tem uma trava de teste contra exatamente isso). A RN-434 roda igual, pela MESMA função: serviço apontando para fora do $HOME no Linux é recusado, porque fazer por unit o que a CLI recusa fazer por flag seria contornar a regra pela porta dos fundos.

Restart=on-abnormal, e nunca on-failure (no plist, KeepAlive com Crashed, e nunca SuccessfulExit). O runner sai com 1 quando o join foi RECUSADO — ticket inválido, outro runner no projeto, capacidade que falta (RN-514) — ou quando esgotou o próprio teto de tentativas, e as duas coisas são "pare e chame um humano". on-failure reiniciaria uma recusa fatal em laço, que é exatamente o que o CLI recusa fazer sozinho. Sinal, OOM e watchdog — o que on-abnormal cobre — são a única falha que reiniciar conserta. O PATH vai congelado na unit, com o custo declarado no próprio arquivo: os dois gerenciadores dão ao serviço um PATH mínimo, e o runner chama git (o espelho, RN-516) e docker (ADR 0137); sem isso o serviço subiria perfeitamente e as duas funções falhariam com "não encontrado". É um retrato, e muda só reinstalando.

As DUAS metades do arquivo de unit NÃO têm a mesma sintaxe, e o WorkingDirectory= vai SEM aspas. ExecStart= é parseado com unquoting e separação em palavras — por isso cada argumento vai entre aspas —, e WorkingDirectory= não é: o systemd toma o resto da linha inteiro como caminho. Tratar as duas como se fossem iguais custou uma release inteira: com aspas o valor deixa de começar com /, a unit é recusada na carga (Loaded: bad-setting, WorkingDirectory= path is not absolute) e nunca inicia — nas duas espécies, em instalação nenhuma. A suíte estava verde o tempo todo, porque cada asserção pedia de volta exatamente a forma errada: por isso a prova agora é feita contra systemd-analyze --user verify, o validador de verdade, e não contra texto. O que SOBRA para escapar é um caractere, e não é o espaço (a linha vai inteira, então /home/eu/pasta com espaço funciona literal): é o %, porque essa diretiva passa por expansão de especificador — /home/dan/50%off vira /home/dan/50<id-do-os-release>ff sem erro nenhum, e o serviço sobe na pasta errada. %% é a forma de dizer % literal. A leitura de volta aceita as duas formas, e isso é dívida deliberada com quem já tem uma unit quebrada no disco: status e uninstall precisam continuar alcançando a pasta dela. Quem está nesse estado conserta reinstalando — install sobrescreve o arquivo inteiro.

E há uma TERCEIRA sintaxe no mesmo arquivo: Environment= (AT-095). Ela é uma LISTA de atribuições separadas por espaço, com unquoting e escape estilo C, e também expande especificador. Escrita crua — como saía o XDG_CONFIG_HOME da unit de MÁQUINA e o PATH das duas espécies —, Environment=XDG_CONFIG_HOME=/home/dan/50%off com espaco chegava ao serviço como /home/dan/50popff: %o expandido e com/espaco descartados pela separação em palavras. A unit carrega, verify aprova, o serviço sobe — e o agente procura a base e a chave numa pasta que não existe, calado. Por isso a atribuição INTEIRA vai entre aspas (Environment="VAR=valor"), com \ e " escapados por barra e % como %%; $ não se escapa ali (Environment= não expande variável). O --dir do ExecStart= tinha a mesma classe e foi junto: cada argumento continua entre aspas, e agora \ vira \\, % vira %% e $ vira $$ (a substituição de variável do ExecStart=). Quebra de linha em PATH/XDG_CONFIG_HOME é RECUSADA antes de escrever — num arquivo de unit ela é diretiva nova, não valor. Nada disso muda a LEITURA de volta: ela só lê o WorkingDirectory=, e nenhum dos dois valores de ambiente é lido do disco. A prova é o VALOR EFETIVO, não o verify (que aprova a forma quebrada): o próprio systemd --test --user --unit=<u>, sobre uma pasta de units temporária, despeja a configuração resolvida, e o teste compara o que o systemd ENTENDEU com o que foi gravado.

Ativação que falha NÃO apaga o arquivo, e não diz "instalado". Escrever a unit e ativá-la são dois passos, e o gerenciador pode não estar lá (systemctl fora do PATH, launchd recusando). O arquivo fica — é o que a pessoa precisa para terminar à mão — e a resposta é um terceiro desfecho, que nomeia o comando pendente e o motivo. Apagar o arquivo transformaria "não consegui ativar" em "não aconteceu nada", que é falso.

uninstall remove os TRÊS, e a pasta sai da UNIT — nunca do cwd. O ADR é explícito: o arquivo de unit, o brabo-runner.config.json e o brabo-runner-device-key.jwk.json. Os dois últimos moram na pasta do runner, e a pasta que interessa é a que foi INSTALADA, não a de onde a pessoa por acaso digitou o comando — por isso o WorkingDirectory é lido de volta do próprio arquivo da unit, que é o registro do que foi instalado. Sem unit não há registro: os dois arquivos só são removidos com --dir explícito, e a mensagem diz por quê. Adivinhar pelo cwd apagaria a chave de um projeto que ninguém pediu para remover. Desativar que falha vira aviso e o arquivo é removido assim mesmo — uma unit que o gerenciador não reconhece mais não é motivo para deixar o arquivo para trás.

E uninstall DIZ o que ele não fez: a chave saiu do disco, não do servidor. A pública correspondente continua em runner_device_keys, e revogá-la é outra coisa (DELETE /projects/:projectId/runner-device-keys/:deviceKeyId, ADR 0147 ponto 6). Um "removido" que deixasse a pessoa achar que revogou seria pior que não remover nada. O log do LaunchAgent também não é apagado: ele é o registro do usuário sobre o que aconteceu, e apagá-lo não foi pedido.

status responde QUATRO estados, e eles não colapsam (RN-088): não instalado, instalado e rodando, instalado e parado, e instalado e não consegui perguntar. A primeira resposta vem do DISCO — há arquivo de unit? —, e é por isso que ela continua certa numa máquina onde o gerenciador não existe; só depois se pergunta ao gerenciador. spawn que falhou (binário ausente do PATH) nunca vira "o gerenciador disse não": são respostas diferentes, separadas na fonte, e uma palavra que este CLI não sabe interpretar também cai em "não consegui perguntar" em vez de virar "parado" por chute. Cada estado tem frase própria e código de saída próprio (0 / 3 / 4 / 5, com 1 e 2 fora porque já significam falha genérica e erro de uso neste CLI): um exit(1) único para os três "não está rodando" colapsaria pelo código exatamente o que a frase separa.

Nada aqui é proposed_action. É o usuário rodando um comando na própria máquina, sobre o próprio serviço — não um agente pedindo para agir. service é despachado antes de lerArgumentos, porque nenhum dos três subcomandos conecta a nada e o que mais importa (status) precisa funcionar numa pasta cuja configuração está quebrada, que é justamente quando alguém pergunta.

  • Onde: apps/runner/src/servico.ts (novo — o vocabulário, as duas plataformas como respostas às MESMAS cinco perguntas, as recusas, os quatro estados e CODIGO_POR_ESTADO); apps/runner/src/servico-sistema.ts (novo — o adaptador real de disco e de systemctl/launchctl, separado para que a garantia "servico.ts não toca node:fs/node:child_process" continue verificável); apps/runner/src/index.ts (comandoDoRunnerParaServico, rodarSubcomandoDeServico, o despacho de service em main() antes de lerArgumentos, e a linha nova em uso())
  • Teste: apps/runner/src/servico.spec.ts (novo — caminho feliz nas duas plataformas com o conteúdo real da unit/plist; root recusado sem escrever nada; Windows nomeado; sem chave de dispositivo; projectId que viraria caminho; systemctl ausente deixando o arquivo e dizendo que não ativou; a RN-434 repassada; uninstall lendo a pasta da unit e não do cwd, sem unit e sem --dir não tocando em arquivo nenhum, e o disable que falha virando aviso; os QUATRO estados de status mais a palavra desconhecida e os três desfechos do launchctl; e os quatro códigos de saída distintos); apps/runner/src/index.spec.ts (describe "brabo-runner service é despachado antes de exigir credencial" — processo de VERDADE numa pasta sem credencial nenhuma: status responde 4 em vez do bloco de uso, e subcomando desconhecido cai no uso de service); apps/runner/src/servico-systemd.spec.ts (a unit GERADA perguntada ao systemd-analyze --user verify de verdade, nas duas espécies, com pasta que tem espaço e pasta que tem % — mais a forma ANTIGA, entre aspas, sendo REPROVADA pelo mesmo validador; sem systemd na máquina o arquivo PULA nomeando o motivo, nunca passa em silêncio nem reprova por ambiente) — e, desde a AT-095, describe "o valor EFETIVO de Environment= é o valor gravado" e describe "o --dir do ExecStart= e o WorkingDirectory= também chegam intactos", que perguntam ao despejo de systemd --test --user o valor RESOLVIDO de XDG_CONFIG_HOME, PATH, WorkingDirectory e do --dir com %, espaço, aspas, barra invertida e $, e fixam a forma antiga do Environment= como entregando OUTRO valor; servico.spec.ts ganhou as duas recusas de quebra de linha e a unit de projeto sem XDG_CONFIG_HOME
  • ADR: 0147, ponto 5
  • Origem: FASE 28, sessão 7. Fica declarado e NÃO feito: BRB-031 (o chmod +x manual do fluxo do ADR 0118) não morre aqui — quem roda brabo-runner service install já precisou tornar o binário executável para chegar até o comando, e o ADR 0147 amarra esse efeito colateral à instalação vir do bootstrap versionado do ADR 0146, que esta entrega não toca. Também fora: qualquer subcomando de start/stop/restart (quem gerencia o ciclo de vida é o gerenciador de serviços do SO, e duplicá-lo no CLI criaria uma segunda fonte de verdade sobre o estado) e Windows, acima

A revogação de chave de dispositivo deixa de ser cega, e alcança a conexão viva (RN-519..520)​

RN-519 — Ninguém revoga o que não consegue ver: a chave de dispositivo ganha listagem do PRÓPRIO dono, e a visão de maintainer fica fora por DECISÃO​

A sessão 7 da FASE 28 e o ponto 6 do ADR 0147. A revogação existia desde o ADR 0118 (DELETE /projects/:projectId/runner-device-keys/:deviceKeyId, developer, 204, idempotente) — e não havia como enxergar o que se revoga.

O corte real era maior do que o docblock dizia. Ele declarava a ausência como "sem a visão de maintainer (listar/revogar de qualquer usuário) que o PAT tem". Mas o PersonalAccessTokensController tem cinco rotas — @Post(), @Get() e @Delete(':tokenId') para o próprio usuário, mais @Get('all') e @Delete(':tokenId/admin') para maintainer (RN-427) — e o de chave de dispositivo tinha duas. Não faltava só a visão de maintainer: faltava a listagem do PRÓPRIO dono. Uma chave órfã (aba fechada no meio do fluxo da RN-473) era inerte, invisível e permanente, sem tela nenhuma onde revogá-la — a lacuna que o CLAUDE.md já declarava.

Entra @Get(), developer, no mesmo formato que o PAT já tem e escopado ao próprio usuário no WHERE do repositório, nunca num filtro do caso de uso.

A revogada CONTINUA na lista. Mesma escolha de listarDoUsuarioNoProjeto do PAT: quem revogou precisa ver que revogou, e sumir com a linha faria a tela afirmar que a chave nunca existiu. revokedAt é o que separa as duas, e lastUsedAt nulo é o sinal da ÓRFÃ — registrada e nunca usada por runner nenhum.

Nunca a JWK pública. Ela não é segredo, mas não serve a nada aqui: a lista existe para revogar, e para isso bastam id, nome e as três datas. A metade privada a api nunca viu.

A visão de maintainer continua FORA — agora por decisão, não por omissão. As duas rotas de admin do PAT nasceram de resposta a incidente (RN-427): dev desligado com um segredo COMPARTILHADO circulando. Chave de dispositivo não é esse bicho — a privada nunca sai do navegador que a gerou, então não há segredo na mão de outro a conter. O que a DELETE daqui ganhou em troca foi ALCANCE (RN-520), não amplitude.

  • Onde: apps/api/src/interfaces/http/runner/runner-device-keys.controller.ts:99 (@Get(), listDeviceKeys, developer); apps/api/src/application/use-cases/auth/list-runner-device-keys.use-case.ts:24; apps/api/src/application/ports/runner-device-key-repository.port.ts:90 (listarDoUsuarioNoProjeto); apps/api/src/infrastructure/persistence/drizzle/runner-device-key.repository.ts:69; apps/api/src/interfaces/http/runner/dto/runner-device-key-list.response.dto.ts:19 (as DUAS travas de tipo, Wire/MesmasChaves, ao contrário do DTO do registro)
  • Teste: apps/api/test/application/use-cases/auth/list-runner-device-keys.use-case.spec.ts (delegação com userId/projectId na ordem certa; a revogada que fica na lista; a órfã com lastUsedAt nulo; repositório que rejeita e propaga); apps/api/test/interfaces/http/runner/runner-device-keys.controller.spec.ts (as TRÊS rotas em developer; a delegação; a rota que não devolve lista vazia por engano)
  • ADR: 0147, ponto 6
  • Origem: FASE 28, sessão 7. Fica declarado e NÃO feito: tela nenhuma. Esta entrega é a metade server-side; a lacuna do CLAUDE.md fecha pela metade — a api passa a poder listar e revogar, e apps/web ainda não tem onde fazê-lo
  • A outra metade FECHOU na RN-561: a aba Configurações ganhou a seção que lista e revoga, marcando a ESPÉCIE de cada chave. O que segue aberto daqui é a visão de maintainer (acima, por decisão) e o alvo {projeto, usuário} da desconexão (RN-520)

RN-520 — Revogar deixa de só impedir ticket NOVO e passa a derrubar a conexão VIVA; o alvo é {projeto, usuário} e nunca {chave}, e derrubar nunca derruba a revogação​

Revisada pela RN-685 (ADR 0201): o ticket do socket passou a guardar QUAL credencial o pediu, e a revogação de chave (e a de PAT) derruba só as conexões abertas COM ELA — outro runner do mesmo usuário, com outra chave ou com PAT, fica de pé. O "custo declarado" abaixo deixou de valer. O par {projeto, usuário} sobrevive como o alvo da remoção de membro (RN-615), como o da conexão LEGADA (ticket sem credencial) durante o rollout, e como o plano B quando o engine não conhece a rota por credencial.

A outra metade do ponto 6 do ADR 0147. Revogar uma chave de dispositivo impedia o ticket SEGUINTE e nada mais: um brabo-runner já conectado mantinha o canal terminal:<projectId> de pé — executando comando aprovado, atendendo PTY, subindo container — com a chave revogada, até cair sozinho.

Quem manda o comando é a api; quem alcança o pid é o engine. A api chama POST /internal/projects/:projectId/runner/disconnect (o mesmo HTTP interno com service token de sempre, nunca um segundo mecanismo), e o engine acha o canal por Engine.Runners.Registry.whereis/1 — o pid que ele já entregava. O engine não consulta a tabela de chaves e a api não fala com o canal; nenhum dos dois faz o trabalho do outro, a mesma divisão de EngineWeb.ContainerCommandController (RN-497).

A precisão que existe é {projeto, usuário}, e a que não existe é {chave} — por construção, não por gosto. Engine.Runners.SocketTicket guarda project_id, user_id e kind, e só; a api pede o ticket com {userId, kind}. A identidade da CREDENCIAL que originou o ticket (o PAT, ou o kid da chave de dispositivo — RN-475) morre no PatAuthGuard e nunca chega ao socket. Levá-la até lá exigiria coluna nova em runner_socket_tickets, campo novo no pedido interno e um assign novo no connect/3: mudança de contrato de auth, fora desta entrega.

Custo DECLARADO, e nomeado no código, não só aqui: um runner do MESMO usuário conectado com PAT, ou com outra chave do mesmo projeto, também cai. Ele reconecta sozinho — a rodada seguinte pede um ticket novo, e a credencial que ainda vale ganha um. Quem foi revogado não ganha, e é aí que a revogação morde. Runner de OUTRO usuário no mesmo projeto não cai: a comparação é socket.assigns.user_id, dentro do processo do canal, que é o único que pode lê-lo — e o desfecho de_outro_dono volta ao pedinte em vez de um :ok que não descreve o que aconteceu.

Derruba o TRANSPORTE, não só o processo do canal. Parar apenas o canal deixaria o socket vivo e o cliente Phoenix reentrando no tópico para sempre com um ticket já consumido — degradação silenciosa, exatamente o defeito que o ADR 0147 existe para não repetir. EngineWeb.RunnerSocket.id/1 deixa de ser nil e passa a nomear a conexão (runner_socket:<kind>:<projectId>:<userId>), e o canal usa o mecanismo documentado do Phoenix, Endpoint.broadcast(id, "disconnect", %{}). É por ele que o runner PERCEBE a queda: onDisconnected resolve a rodada, o laço pede ticket novo e a api recusa.

Efeito colateral NUNCA derruba o efeito principal. O DELETE continua 204 e idempotente: engine fora do ar, nenhum runner conectado ou timeout não podem fazer a revogação falhar nem virar 5xx. A chamada ao engine mora num try/catch que só LOGA, dentro do caso de uso — a MESMA régua de rag_searches (RN-479) e do mirror_sync_result (RN-517). E os quatro desfechos são informação, não erro: sem_runner é o caso normal de quem revoga uma chave órfã (RN-519), e desfecho DESCONHECIDO vindo do engine vira timeout e nunca derrubado — entre afirmar que a conexão caiu e afirmar que talvez não, a afirmação segura é a que não promete o que não se sabe.

Revoga PRIMEIRO, derruba depois, e o projeto sai da LINHA revogada, nunca da URL: o projectId da rota já era ignorado no DELETE, e lê-lo aqui faria um projectId divergente derrubar o runner de um projeto que não tem nada com esta chave.

  • Onde: apps/api/src/application/use-cases/auth/revoke-runner-device-key.use-case.ts:63 (a ordem, o projeto da linha e o try/catch que só loga); apps/api/src/application/ports/api-to-engine-client.port.ts:421 (disconnectRunnerOfUser, DesfechoDeDesconexaoDeRunner); apps/api/src/infrastructure/http-clients/api-to-engine-client.ts:477; apps/engine/lib/engine/runners/revogacao.ex:92 (derrubar/3); apps/engine/lib/engine_web/channels/terminal_channel.ex:697 (handle_info({:derrubar_por_revogacao, …})); apps/engine/lib/engine_web/channels/runner_socket.ex:79 (id/1, que deixou de ser nil); apps/engine/lib/engine_web/controllers/runner_connection_command_controller.ex:27; apps/engine/lib/engine_web/router.ex:86
  • Teste: apps/api/test/application/use-cases/auth/revoke-runner-device-key.use-case.spec.ts (describe "a revogação alcança a conexão viva" — o projeto da linha; a ordem revoga→derruba; engine fora do ar que não derruba a revogação; 404 que não derruba ninguém); apps/api/test/infrastructure/http-clients/api-to-engine-client.spec.ts (a rota e o corpo; desfecho desconhecido virando timeout; 500 que LANÇA, para o caso de uso tratar); apps/engine/test/engine/runners/revogacao_test.exs (sem runner; o dono; o outro dono; o teto; argumento fora de forma que não levanta); apps/engine/test/engine_web/channels/terminal_channel_test.exs (describe "revogação alcança a conexão viva" — o canal REAL que para e libera a presença no Registry, o de outro dono que fica de pé, o :web que nunca é alvo); apps/engine/test/engine_web/controllers/runner_connection_command_controller_test.exs (os 200 com desfecho, e o 400 de pedido malformado)
  • ADR: 0147, ponto 6
  • Origem: FASE 28, sessão 7. Fica declarado e NÃO feito: a revogação não alcança um runner conectado em OUTRO projeto com a mesma chave — não existe esse caso, porque runner_device_keys.project_id amarra a chave a um projeto só; e nenhuma tela dispara isto ainda (a metade web da RN-519)

RN-521 — A página /containers lista TODO projeto do workspace e vira o caminho HUMANO de subir o container, ramificado por execution_mode​

Estendida pela RN-574 (ADR 0161): um quarto motivo de recusa local, sem_broker_na_instalacao, para container/mounted numa instalação sem broker de container — depois de "já está de pé" e antes de "sem imagem decidida".

Estendida pela RN-590 (AT-105): a mesma leitura vale na conversão de modo, em parar/remover e na proposta do Infra Lead (recusa 409 da api ao propor).

Subir o container de um projeto tinha um caminho: o Infra Lead, dentro de uma sessão, chamando a ferramenta dele. Numa execução real do exp004 (modo runner) a container_start_via_runner falhou pelo defeito que a RN-508 fechou — e não havia como TENTAR DE NOVO. Cinco dev agents ficaram horas emitindo dev.blocked_by_container (RN-502), e a única saída era abrir sessão nova e conversar com um agente para reexecutar uma operação de máquina.

A página global de containers (ADR 0136, RN-495) já era o lugar onde um HUMANO decide ciclo de vida de container — mas ela nem mostrava o projeto: a régua era "uma linha por projeto que já tem project_containers", e uma subida que falha ANTES de registrar não deixa linha nenhuma. A tela escondia exatamente o projeto para o qual ela precisava existir.

O TERCEIRO estado tem nome próprio, e não é stopped. O read model passa a LEFT JOIN (era INNER) e registrado vira null para quem nunca provisionou — não é um valor novo de ContainerLifecycleStatus (stopped é um container que EXISTIU e parou), e não é "não observado" (que é sobre o daemon ter sido perguntado). A tela imprime "nunca provisionado" e a coluna Observado diz sem_container_registrado, o terceiro motivo de naoVerificado — os três não colapsam (RN-088/RN-486).

A lista alargou; o ORÇAMENTO não. Perguntar o observado ao broker é chamada de rede por projeto, com teto de 20 por carregamento (TETO_DE_VERIFICACOES_POR_CARGA). Projeto sem project_containers não tem o que observar, então nunca é elegível e nunca ocupa uma vaga: num workspace com centenas de projetos vazios, quem tem container de verdade continua verificado exatamente como antes. E continuam sendo TRÊS consultas em lote, quantos projetos forem — nenhuma dentro de laço.

A ação ramifica por execution_mode, com a mesma régua do backend (RN-497/RN-503): container e mounted propõem container_start (broker, no servidor) e runner propõe container_start_via_runner (agente local, na máquina do usuário). Os DOIS payloads são diferentes de propósito e não se copiam: container_start carrega a ELEIÇÃO de imagem (imagem, rede, recursos), lida da decisão vigente do projeto no clique; container_start_via_runner tem schema só com rationale (RN-508), porque ela sobe a imagem JÁ decidida e não elege nada.

A tela NÃO propõe o que já se sabe que vai falhar — e diz o motivo no lugar do botão. Três recusas, cada uma com texto próprio, na ordem "estado do MUNDO antes da capacidade de quem olha" (assim um viewer ainda lê "falta decidir a imagem", e não só "você não pode"):

  1. sem imagem decidida — o portão da RN-105, que vale nos TRÊS modos desde a RN-494; sem artifact.project_image não há o que subir;
  2. runner que nunca teve pasta confirmada (workspaceVerifiedAt nulo, RN-423) — nenhum agente local jamais conectou, então a ação falharia com certeza. Mesma honestidade que a ferramenta do Infra Lead já pratica ao recusar LOCALMENTE antes de propor (RN-508);
  3. papel abaixo de maintainer — o mínimo é o do ENDPOINT (RN-102): os dois tipos pedem maintainer em decide.ts, e a comparação sai de roleAtLeast, nunca de uma lista de papéis à mão. O que se tira é o CONTROLE, nunca a INFORMAÇÃO (ADR 0064): quem não pode continua vendo estado, imagem e recursos, e o motivo é dito UMA vez em TEXTO — title em elemento disabled não abre no Chromium.

E o que a tela NÃO sabe, ela declara em vez de prometer. workspaceVerifiedAt é registro de UMA confirmação e nunca batimento (RN-468): não-nulo não prova agente local conectado AGORA, e a api não tem sinal de presença (quem sabe do agora é o canal Phoenix, no engine). Então a linha do runner oferece o botão com ressalva escrita — "se ele não estiver rodando agora, a ação falha ao ser aprovada" — em vez de uma bolinha verde que mentiria.

Nada de teto se mexeu. Toda proposta desta tela é proposed_action, sempre com um HUMANO clicando, nunca um agente; container_remove continua no teto absoluto (RN-418) e decide.ts fica byte a byte. O papel lido é o do WORKSPACE, e a lacuna é declarada: o efetivo é projectRole ?? workspaceRole (RN-471), e buscar project_members por linha seria o N+1 de rede pelo qual esta página existe para não pagar — quem foi REBAIXADO num projeto ainda vê o botão e recebe 403, que é o defeito reparável (termina em toast), não o invisível.

  • Onde: apps/api/src/infrastructure/persistence/drizzle/containers-overview.repository.ts:96 (o LEFT JOIN, o ORDER BY e temImagemDecidida); apps/api/src/application/ports/containers-overview-repository.port.ts:20 (executionMode/lifecycle nulo/temImagemDecidida/workspaceVerifiedAt); apps/api/src/application/use-cases/containers/obter-visao-geral-de-containers.use-case.ts:33 (sem_container_registrado) e :101 (elegivelParaVerificacao, que é o que mantém o orçamento intacto); apps/api/src/interfaces/http/containers/containers-overview.controller.ts:60; apps/web/src/routes/containers-subida.ts:114 (decidirSubida, a regra inteira, pura); apps/web/src/routes/ContainersPage.tsx:119 (os dois payloads)
  • Teste: apps/api/test/application/use-cases/containers/obter-visao-geral-de-containers.use-case.spec.ts (o terceiro estado com motivo próprio; 25 projetos vazios que NÃO empurram para fora da verificação os 20 com container real); apps/api/test/infrastructure/persistence/drizzle/containers-overview.repository.spec.ts (projeto sem project_containers na lista; temImagemDecidida; container_start_via_runner pendente; as TRÊS consultas que não crescem); apps/api/test/interfaces/http/containers/containers-overview.controller.spec.ts (o registrado aninhado e o null que sobrevive à conversão); apps/web/src/routes/containers-subida.test.ts (a ramificação por modo, as três recusas, a ressalva, a hierarquia de papel); apps/web/src/routes/ContainersPage.test.tsx (o cenário REAL do exp004: projeto runner, sem linha, imagem decidida, pasta confirmada → a ação proposta é container_start_via_runner com payload só de rationale, e GET .../container nem é chamado)
  • ADR: 0136 (a página), 0145 (o tipo de ação), 0133 (o outro)
  • Origem: execução real do exp004, decisão do dono do produto. Fica declarado e NÃO feito: a tela não sabe se há agente local conectado AGORA (não existe sinal de presença na api), e o papel lido é o do workspace, não o efetivo do projeto

RN-539 — assess_implementability dispara o threat model de DESIGN em PARALELO, e uma vez só por story​

O appsec (Engine.Gates.SecOpsAgentServer.run_design/2, RN-360) nasceu implementado, testado e sem chamador de produção nenhum: a única menção fora de teste era um comentário em artifact_schemas.ex. A própria RN-360 declarava a lacuna, e docs/fluxo.yml (papel appsec, campo acionamento) já NOMEAVA o gatilho que faltava — assess_implementability do Dev Lead, gate implementavel, o mesmo ADR 0090. Era ele. run_assessment/2 passa a chamá-lo por Engine.Gates.Dispatcher.run_appsec_design/2 — sexto callback do behaviour, CÓPIA estrutural de run_qa_estrategia/3 e trocável em teste pelo mesmo motivo dos cinco vizinhos (o chamador é exercitado por um teste leve, sem sandbox Ecto, e não deveria subir um GenServer real só para provar que PEDIU).

Em paralelo, e o parecer não espera. O disparo acontece nas DUAS saídas de run_assessment/2 — com plano de teste e sem — e o desfecho de cada uma fica byte a byte como era. Fazer o parecer depender do threat model faria o gate implementavel depender de DUAS produções assíncronas em vez de uma, e queimaria dois turnos do Dev Lead onde hoje se queima um: o ramo :sem_plano já devolve {:error, ...} pedindo ao modelo que chame de novo (RN-163), e uma segunda espera empilhada nele multiplicaria a janela. O threat model chega a quem precisa pelo caminho que a RN-361 já definiu — handoff para arquiteto, dev-lead e infra —, nunca por este retorno; a mensagem do :sem_plano sequer menciona o appsec, porque prometer ao modelo algo que ele não vai receber ali é o defeito que a RN-163 fecha.

Desde a RN-674 não há mais :sem_plano: o parecer sai na primeira chamada, o disparo do appsec acompanha a proposta, e a leitura do histórico serve só à guarda abaixo. O appsec segue no DESIGN — a decisão do dono que tirou o plano de teste do pre-dev foi sobre o QA.

E uma vez só por story. O modelo é INSTRUÍDO a chamar assess_implementability de novo enquanto o plano de teste não existe: sem guarda, cada rechamada custaria outra rodada de LLM do appsec e mais três handoffs sobre a MESMA story. disparar_appsec_se_preciso/3 só dispara quando não há artifact.threat_model daquela storyId no histórico. A pergunta aqui é EXISTE, não QUAL — o oposto do plano de teste, onde o mais recente vence porque uma story pode ser reavaliada.

A leitura do event log é uma por chamada: as duas perguntas ("já há plano de teste?", "já há threat model?") são respondidas sobre a mesma lista. Dois list_events/2 na mesma invocação seriam o amplificador de tráfego que o ADR 0060 recusa, e este caminho é quente justamente por causa da retentativa. Histórico ILEGÍVEL não dispara nada: ausência de resposta não é prova de ausência de artefato, e disparar ali reabriria a rechamada por outra porta. A cláusula de args inválidos também não dispara — ela não sabe qual é a story.

  • Onde: apps/engine/lib/engine/agents/dev_lead_tools.ex:457 (run_assessment/2, a leitura única do histórico) e :519 (disparar_appsec_se_preciso/3, a guarda de idempotência); apps/engine/lib/engine/gates/dispatcher.ex:29 (o callback) e :86 (Engine.Gates.Dispatcher.Live.run_appsec_design/2); apps/engine/test/support/fake_gate_dispatcher.ex:23
  • Teste: apps/engine/test/engine/agents/dev_lead_tools_test.exs, describe "assess_implementability dispara o appsec (RN-539)" — story sem threat model dispara, story com threat model NÃO dispara (a idempotência), threat model de outra story não conta, o desfecho de :sem_plano não muda, o parecer é proposto normalmente ao lado do disparo, args inválidos e histórico ilegível não disparam
  • ADR: 0090 (que declarava o gatilho automático como fora de escopo)
  • Origem: docs/fluxo.yml, papel appsec. Fica declarado e NÃO feito: artifact.threat_model é emitido na sessão da STORY (emit_threat_model/3 lê story["sessionId"]), enquanto a guarda lê o histórico da sessão do DEV LEAD. No caminho comum são a mesma sessão — o Dev Lead avalia stories que a própria sessão de execução criou. Não sendo, a guarda não enxerga o threat model e o appsec roda outra vez: custo de uma rodada de LLM repetida, nunca dado errado (o artefato é append-only e quem o consome é o handoff). Fechar isso exigiria a api aceitar busca de artefato por story ATRAVÉS de sessões, que hoje não existe

A flag que liga um agente tem de CHEGAR ao processo (RN-540)​

RN-540 — Flag booleana lida pelo runtime.exs é mapeada no environment: do serviço engine dos dois composes, com o default do código​

Uma pausa só é reversível se existir o caminho de ligar. A da Anamnese e a do Psicólogo (RN-115, RN-117) foram declaradas reversíveis em TRÊS lugares — o docblock de AnamneseSchedulerWorker.enabled?/0 ("Ligar de volta é ANAMNESE_ENABLED=true e reiniciar o engine"), o de PsychologistWorker.enabled?/0 e a tabela de configuração — e o caminho não existia em ambiente nenhum.

Medido, não hipótese. Com o compose de dev de pé:

$ docker compose … exec -T engine sh -lc \
'echo "[$START_ANAMNESE] [$ANAMNESE_ENABLED] [$PSYCHOLOGIST_ENABLED]"'
[true] [] []

A causa é a camada que só o Compose tem: ele não repassa o ambiente do host ao container. Uma variável chega ao processo se, e só se, estiver escrita no environment: (ou num env_file) daquele serviço. O engine mapeava TREZE variáveis de Anamnese/Psicólogo — todas TETOS de custo (ANAMNESE_INTERVAL_SECONDS, ANAMNESE_BUDGET_MICROS, PSYCHOLOGIST_TRIAGE_THRESHOLD, …) — e nenhuma das duas que decidem se os agentes rodam. ANAMNESE_ENABLED=true no .env era inerte: apps/engine/config/runtime.exs caía no default "false" e os dois seguiam desligados em silêncio, sem erro.

Origem provável do defeito: quando o default do código virou false (2026-08-10), ninguém precisou da variável para DESLIGAR. Só quem tenta LIGAR descobre que o fio não existe — e essa pessoa, por construção, aparece meses depois.

A regra. Toda flag BOOLEANA que o runtime.exs lê — a forma System.get_env("X", "true"|"false") == "true" — tem de estar mapeada no environment: do serviço engine dos dois composes. O recorte é o das booleanas e não o das 58 variáveis do arquivo porque uma flag existe exatamente para ser virada, e a virada acontece pelo ambiente: flag sem linha no compose é interruptor sem fio. Teto numérico com default bom (READ_FILE_MAX_BYTES) e variável de release (SECRET_KEY_BASE, POOL_SIZE) ficam legitimamente de fora, e é por isso que a lista é DERIVADA do runtime.exs em vez de mantida à mão.

O default difere por arquivo, e a diferença é a decisão:

  • em docker/docker-compose.yml (dev) a linha repete o default do CÓDIGO. Dev é onde a promessa dos docblocks é exercida, e um default divergente ali faria o compose decidir produto por baixo do runtime.exs.
  • em docker/docker-compose.prod.yml basta estar mapeada. Aquele arquivo desliga os agentes de fundo de propósito (START_ANAMNESE:-false contra o true do código), com o motivo escrito ao lado — cobrar igualdade reprovaria uma decisão consciente.

Isto NÃO muda decisão de produto nenhuma. As duas flags continuam false por default nos dois composes: a pausa de 2026-08-10 segue valendo, byte a byte. O que passou a existir é o interruptor.

E ANAMNESE_ENABLED=true sozinho não basta. START_ANAMNESE (chave de BOOT, apps/engine/lib/engine/application.ex:82,106-108) decide se o kickoff/0 que AGENDA o tick periódico é chamado; ANAMNESE_ENABLED (flag de PRODUTO) decide se uma rodada NOVA pode acontecer. São perguntas diferentes, e a Anamnese periódica exige as duas em true. O Psicólogo tem só a segunda — o gatilho automático dele é o fechamento de sessão, não um tick.

deploy/k8s/ fica de fora, e por decisão. Lá não existe a camada que quebra: um Deployment/ConfigMap não intercepta nada, e a variável é escrita onde toda variável é escrita. O brabo-config tem QUATRO literais (endereços de serviço e nível de log do web), sem nenhum dos 13 tetos nem START_ANAMNESE — o engine roda com os defaults do código, coerentemente. Não há interruptor sem fio a consertar ali.

  • Onde: docker/docker-compose.yml:406-425 (as quatro chaves de boot e as quatro flags de produto, no bloco de cada agente); docker/docker-compose.prod.yml:308-315; apps/engine/config/runtime.exs:157,158,162,168,185,229,238,264 (as oito flags); .env.example (as duas perguntas, lado a lado)
  • Teste: scripts/ci/flags-do-engine-no-compose.spec.ts — deriva as flags do runtime.exs e reprova nomeando a que faltar em qualquer dos dois composes, e a que divergir do default do código no de dev. warn nenhum: reprova mesmo, no job test-packages
  • Origem: achado por medição direta no container do engine, 2026-09-08. Fica declarado: a lista de exceções FLAGS_FORA_DO_COMPOSE nasce VAZIA — ela existe para que "fora por decisão" tenha onde ser dito, e não vire indistinguível de "fora por esquecimento"

RN-529 — O brabo-runner nasce com uma BASE local, cada projeto é uma subpasta dela, e a base NUNCA invalida --dir​

O agente local não tinha noção de base. A única raiz era --dir, resolvida por resolverDir contra INIT_CWD ?? process.cwd(); sem a flag, a raiz era o próprio cwd. estado.dir é um caminho, singular — o do projeto daquela conexão —, e não existia constante, variável nem campo que dissesse onde os projetos moram naquela máquina. A palavra "base" só aparecia em comentário.

A base é LOCAL, e nunca chega pela rede. É o desenho do broker (ADR 0144) aplicado ao agente local: quem tem a raiz é quem executa, e o que atravessa a rede é o segmento relativo. Não existe campo de base vindo do servidor, e resolverPastaDoProjetoNaBase recusa um segmento ABSOLUTO por léxico — nunca o "aceita e reinterpreta".

Duas fontes, e a flag vence. --base <caminho> na linha de comando, e $XDG_CONFIG_HOME/brabo/runner.json (senão ~/.config/brabo/runner.json), que é onde o instalador (ADR 0150) a grava. O arquivo é de USUÁRIO e não de projeto de propósito: a base é uma para N projetos, e guardá-la no brabo-runner.config.json — que é por projeto, vive DENTRO da pasta do projeto e é escrito pelo NAVEGADOR — daria N cópias do mesmo valor com N chances de divergir. Variável de ambiente foi recusada por duas razões: BRABO_PROJECTS_BASE já existe e é a base dos projetos mounted do lado SERVIDOR (RN-500), a colisão de namespace que o ADR 0141 recusou por escrito; e sob systemd --user/LaunchAgent (RN-518) o ambiente é o da unit, não o do shell de quem instalou. Só a flag também não bastaria: a base entraria no ExecStart de cada unit, e trocá-la exigiria reescrever todas.

--dir continua significando exatamente o que sempre significou, e a base NÃO entra na validação dele. Isto é a proibição da api transposta: project-workspaces-root.ts proíbe por escrito que a regra da base desça para caminhoDeWorkspaceLocalValido, porque aquele predicado roda em toda LEITURA e um projeto legado fora da base explodiria ao ser lido — "a base é regra de CRIAÇÃO e CONVERSÃO; o léxico é para sempre" (RN-501). Deste lado vale igual: --dir segue validado por validarDirDentroDoHomeNoLinux + garantirDiretorio (RN-434/RN-435), e estar FORA da base não o invalida. Sem isso a base seria breaking/ sem ganho, contra o binário legado da RN-514. A base decide só onde uma pasta de projeto NOVA nasce.

A guarda reusa a régua; não nasce uma quarta cópia. base-guard.ts é o TERCEIRO irmão de guard.ts, ao lado de espelho-guard.ts: importa semBarraFinal, dentroDoEscopo e realpathMaisProximo — os três helpers que deixaram de ser privados justamente para isso (RN-516) —, repete a dupla passada léxica-depois-realpath de validarCwdDentroDaRaiz, e reusa validarDirDentroDoHomeNoLinux INTEIRA para exigir a base dentro do $HOME no Linux (base dentro do $HOME implica toda subpasta dentro dele, então as duas checagens continuam concordando). Da recusa de laço de espelho-guard.ts reusa o que é régua — dentroDoEscopo, comparação por SEGMENTO e nunca startsWith cru, para /base-outra não contar como dentro de /base — e não a função recusarLaco, que lança DestinoDeEspelhoInvalidoError e reportaria uma base malformada como problema de espelho. Mesma lição da RN-515: reusa-se a régua, não a mensagem.

O laço aqui é ASSIMÉTRICO, e esse é o achado. No espelho os dois sentidos são defeito. Aqui a pasta do projeto DENTRO da base é o arranjo NORMAL — é literalmente o que "cada projeto é uma subpasta dela" quer dizer. O sentido contrário é que é laço: base dentro da raiz deste projeto (ou igual a ela) faria todo projeto novo nascer dentro deste, com o git e o espelho deste passando a varrer os outros. Só esse é recusado.

Recusa tem disposição diferente por FONTE, e nenhuma é silenciosa. Base ausente é o estado NORMAL (null, o legado intacto). Base pela FLAG que não passa na guarda sai com código 2 — é um pedido explícito digitado agora e impossível de honrar, a mesma disposição de --dir recusado. Base pelo ARQUIVO que não passa é dita em stderr, nomeia o que se perde ("não terá onde criar a pasta de um projeto novo") e o runner segue sem base: derrubar um agente local que atende um projeto por causa de uma configuração que ele ainda nem usa seria desproporcional. Um arquivo que existe e não declara base é ausência, não recusa — é configuração SEM base consentida, não configuração quebrada. E a linha de startup diz a base nos DOIS estados, porque omitir "nenhuma" deixaria alguém procurando por que a pasta não apareceu.

Isto é BEST-EFFORT, e a ressalva de TOCTOU é herdada por escrito de guard.ts e espelho-guard.ts: symlink criado DEPOIS da checagem e ANTES do mkdir não é coberto, nem link cujo alvo ainda não existe. A fronteira de segurança continua sendo autenticação + pipeline de aprovação + o consentimento de quem rodou o CLI.

Nada aqui CRIA pasta: a validação da base não exige que ela exista (quem a cria é o instalador, e é o mesmo raciocínio do ADR 0142 — validar disco onde só o léxico é conhecível impediria consentir uma base antes de ela existir). A única pergunta de disco é a que nunca é ambígua: um caminho que JÁ existe e não é pasta nunca virará uma.

  • Onde: apps/runner/src/base.ts:69 (caminhoDoArquivoDeBase, a precedência XDG de servico.ts), :106 (lerBaseConsentida, os quatro desfechos) e :181 (resolverBaseConsentida, a ordem das fontes e a origem da recusa); apps/runner/src/base-guard.ts:128 (validarBaseDeProjetos), :209 (recusarLacoComARaiz, o laço assimétrico) e :296 (resolverPastaDoProjetoNaBase, a subpasta e a dupla passada); apps/runner/src/index.ts:321 (a resolução no lerArgumentos, DEPOIS de garantirDiretorio e sem tocar na validação de --dir) e :452 (EstadoDoRunner.base)
  • Teste: apps/runner/src/base-guard.spec.ts (caminho feliz com base que ainda não existe; a irmã com prefixo comum que NÃO é confundida; .., /, arquivo, $HOME no Linux; o laço nos dois sentidos, incluindo o construído por symlink REAL; o segmento absoluto, com .., o que aponta para a própria base e o que escapa por link no meio); apps/runner/src/base.spec.ts (a precedência XDG, os quatro desfechos da leitura, a flag vencendo o arquivo, e a recusa carregando a ORIGEM)
  • ADR: 0151 pontos 1 e 2
  • Origem: FASE 29. O par workspace_create/workspace_create_result e a capacidade workspace (pontos 3 a 6 do ADR), declarados aqui como não feitos, FECHARAM na RN-532 — a base passou a ter consumidor. Segue declarado e NÃO feito: o install.sh, que é quem vai gravar o arquivo

A base do agente local ganha um consumidor: a pasta do projeto nasce pelo canal (RN-532)​

RN-532 — workspace_create cria a pasta sob a base LOCAL, e quem GRAVA continua sendo o workspace_confirm que já existia​

A RN-529 deu ao brabo-runner uma BASE e a validou, e declarou por escrito que nada a consumia. Este é o consumidor: o par workspace_create/workspace_create_result, a capacidade workspace e um predicado próprio no engine.

O par é de PEDIDO COM RESPOSTA, e não podia ser modelado no workspace_confirm. Aquele é UNIDIRECIONAL: handle_in("workspace_confirm", …) responde {:noreply, socket} e não empurra nada de volta. Quem pede a criação de uma pasta precisa saber se ela apareceu, e com que caminho — então o molde é o de exec/exec_result (despachar_pedido + pending_execs + responder_pedido_pendente/3), o mesmo dos três pares de container. Registro de precisão, porque o nome circulou: workspace.verified não existe neste repositório — o que existe é o evento project.workspace_verified e a coluna workspace_verified_at.

Só o SEGMENTO atravessa a rede. A base é local e nunca vem do servidor (RN-529); o engine manda projectId e o segmento RELATIVO, e é resolverPastaDoProjetoNaBase — do lado do runner, que tem a raiz — quem os junta. É o invariante do ADR 0130/ ADR 0144 aplicado ao agente local: quem tem a raiz é quem executa. Um segmento ABSOLUTO é recusado por LÉXICO, nunca aceito e reinterpretado.

A confirmação REUSA o workspace_confirm, e é o que economiza uma frente inteira. Tendo criado a pasta, o runner empurra o workspace_confirm que existe desde a RN-423 — e é ele que grava, pelo caminho já construído: canal → engine → HTTP interno → ConfirmProjectWorkspaceUseCase, que revalida o léxico, é idempotente e carimba workspace_verified_at. Nenhuma rota nova de gravação nasce, o engine continua não escrevendo a tabela, e o único caminho que carimba continua sendo um só. A ORDEM é o mecanismo, não estética: os dois pushes chegam ao MESMO processo de canal, em ordem, e o handler do workspace_confirm é síncrono — mandá-lo ANTES do resultado é o que garante que o carimbo já existe quando o pedinte destrava do receive.

A capacidade workspace entra no vocabulário na sessão em que o código entra, dos dois lados (ADR 0151 ponto 4). A RN-514 tinha feito a aposta oposta com espelho — nome no servidor sem código do outro lado — e ela só se pagou porque alguém a cobrou; repetir seria repetir o defeito silencioso que a negociação existe para impedir.

Ela é a única das quatro cuja declaração depende do ESTADO daquela execução, e não da versão do binário. O runner só a declara quando tem base consentida (capacidadesDoRunner), e é por essa linha — e por mais nenhuma — que o servidor SABE que existe base: o engine não lê o disco do usuário e não tem tabela de bases. Ninguém a EXIGE no join, e isso é decisão: exigi-la em runner faria todo binário sem base deixar de conectar, o que é breaking/ e MAJOR, por uma função que aquele projeto talvez nunca use. Ela é opt-in pela outra ponta — quem tem base declara, e só quem declarou recebe a mensagem.

Predicado PRÓPRIO, e RunnerReadiness fica byte a byte. Engine.Runners.PastaDoProjeto tem DUAS pré-condições — runner conectado, base consentida — e o ADR adota literalmente a recusa que Engine.Runners.Espelho já tinha escrito: uma função compartilhada com flag "pula container" é precisamente o caminho pelo qual a terceira pré-condição cai POR ACIDENTE para o exec numa refatoração futura (RN-507/ADR 0145 existe para ela não cair). E aqui a terceira seria pior que desnecessária: é circular — um projeto runner só chega a ter container running REGISTRADO depois de o próprio runner o subir, e o container sobe sobre a pasta que esta mensagem existe para criar.

Ele também NÃO pergunta workspace_verified_at (ao contrário do espelho: aqui ele é o RESULTADO, e exigi-lo tornaria a mensagem inalcançável exatamente no caso para o qual foi feita) nem o execution_mode (a segunda fonte da mesma regra — quem tem base consentida é o agente local, e só existe agente local onde ele existe).

Onde cada pré-condição é respondida, e por que não são as duas no mesmo lugar. "Runner conectado" é do Registry, e o módulo a responde sozinho. "Base consentida" é propriedade daquela CONEXÃO: ela vive em socket.assigns e nunca em tabela (a régua do ADR 0147), então quem a responde é o despacho — o canal recusa ANTES de empurrar e devolve motivo: "sem-base" pelo MESMO ref, no formato que o módulo já espera, exatamente como dispatch_exec faz com o 126. Nunca um timeout, que é como o defeito silencioso apareceria. Os dois motivos, as duas mensagens e a ordem em que se aplicam moram no módulo, e em nenhum outro lugar.

Erro NOMEADO, e cinco motivos que não colapsam. sem-base, segmento, nao-e-pasta, mkdir e git viajam como motivo ao lado do texto de erro — separados de propósito: o texto é para um humano ler, o motivo é para o outro lado DECIDIR, e colapsá-los obrigaria o engine a casar substring de pt-BR. Só sem-base vira átomo próprio no engine (:sem_base), porque só ele tem conserto próprio; os outros quatro são :recusado com a mensagem do runner. O init/clone que falha é erro nomeado e nunca um plano B: uma pasta sem .git quebraria o worktree do dev agent, o espelho e o próprio versionamento do usuário num lugar longe da causa.

Idempotente, e os três desfechos não colapsam. mkdir -p já é idempotente; o que precisou de decisão foi o repositório — uma pasta que JÁ é repositório volta como SUCESSO, com o modo ja-era-repositorio, nunca um init por cima (que mentiria sobre o que aconteceu) nem um clone (que falharia por pasta não-vazia e transformaria "já estava pronto" em erro). É a mesma disposição de ConfirmProjectWorkspaceUseCase, e pelo mesmo motivo: a mensagem pode chegar duas vezes.

estado.dir NÃO muda. A pasta criada é a do projeto do ponto de vista do SERVIDOR — e é o workspace_confirm que a grava lá. A raiz que guard.ts usa para conter comando aprovado continua sendo a desta execução: trocá-la em runtime moveria uma fronteira de contenção por causa de uma mensagem de rede, e este CLI nunca faz isso.

Isto NÃO é proposed_action, e nenhum teto ganha exceção. Criar a pasta do projeto que o usuário acabou de pedir é configuração CONSENTIDA, não um agente pedindo para agir — a mesma linha que o espelho já estabeleceu (RN-516). Fazê-la por comando de terminal cairia no escopo do ADR 0055 e viraria fila de aprovações rotineiras, corroendo o teto que dá sentido ao clique.

  • Onde: apps/runner/src/pasta-do-projeto.ts:132 (criarPastaDoProjeto — a ordem guarda→disco→versionamento e os cinco motivos); apps/runner/src/channel.ts:173 (WorkspaceCreateMessage), :194 (WorkspaceCreateResultMessage), :476 (CAPACIDADE_DE_WORKSPACE e capacidadesDoRunner); apps/runner/src/index.ts:1034 (tratarWorkspaceCreate — a ordem workspace_confirm antes do resultado); apps/engine/lib/engine/runners/pasta_do_projeto.ex:107 (verificar/1) e :134 (criar/3); apps/engine/lib/engine/runners/runner_router.ex:181 (create_workspace/3); apps/engine/lib/engine/runners/capacidades.ex:95 (workspace no vocabulário, e fora do legado); apps/engine/lib/engine_web/channels/terminal_channel.ex:666 (handle_info({:dispatch_workspace_create, …}), a recusa nomeada) e :380 (handle_in("workspace_create_result", …))
  • Teste: apps/runner/src/pasta-do-projeto.spec.ts (caminho feliz com init e com clone; a idempotência provada com o binário de versionamento de VERDADE; os cinco motivos, com textos distintos, e a pasta que NÃO nasce fora da base); apps/runner/src/index-handlers.spec.ts (a ORDEM workspace_confirm → workspace_create_result; sem base, nenhum confirm; conexão caída não empurra nada); apps/runner/src/channel.spec.ts (workspace só declarada com base; payload sem segmento/projectId descartado; env malformado virando undefined); apps/engine/test/engine/runners/pasta_do_projeto_test.exs (as duas pré-condições SEM banco — a asserção é o ExUnit.Case puro —, o payload exato, os quatro motivos e o sucesso sem caminho que não vira sucesso); apps/engine/test/engine/runners/runner_router_test.exs (o roundtrip e o timeout); apps/engine/test/engine/runners/capacidades_test.exs (workspace declarável, nunca no legado, nunca exigida); apps/engine/test/engine_web/channels/terminal_channel_test.exs (a recusa nomeada pelo mesmo ref, e nada empurrado ao runner)
  • ADR: 0151 pontos 3 a 6
  • Origem: FASE 29, sessão 8. Fica declarado e NÃO feito: quem CHAMA PastaDoProjeto.criar/3 — o caminho web→api→engine é da sessão 9 —, e o install.sh que grava o arquivo da base, que é de outra frente

O picker do modo Runner volta a ler o disco de QUEM ESCOLHE (RN-533)​

RN-533 — Em modo runner o navegador de pastas pergunta ao agente local, e a tela diz de quem é o disco​

O assistente de criação tinha um navegador de pastas para dois modos que leem discos diferentes. A RN-504 apontou os dois para a api — certo para mounted, cuja pasta mora sob BRABO_PROJECTS_BASE, no SERVIDOR — e declarou o preço por escrito: "para runner a lista deixa de ser o disco da máquina do usuário e passa a ser a base". Aquilo foi aceito porque "o modo runner sai da criação de projeto no PR seguinte", o que não aconteceu: o modo continua sendo oferecido, e a direção do ADR 0151 (ponto 7) é a oposta. Enquanto durou, escolher runner e clicar "Procurar pasta…" navegava um disco que não é o daquele projeto — e nada na tela dizia isso, então o erro só apareceria pelo CONTEÚDO da lista.

O MODO decide o transporte, e são duas perguntas diferentes. mounted segue byte a byte pela api (GET workspaces/:workspaceId/project-folders, escopado duro à base — RN-504 intacta); runner volta a fs_list_dir, que sempre existiu dos dois lados e cujo transporte no web nunca saiu do repositório. Não é um transporte sobrando que ganhou uso: é a pergunta "o que há na SUA máquina?", que a api não tem como responder, voltando a ser feita a quem tem como.

A âncora do modo runner é a criação ANTECIPADA da RN-437. O ticket do canal é escopado a um projectId real, então o clique cria o projeto antes de abrir o modal — o propósito original daquela regra, que a RN-504 tinha deixado sem consumidor. E falhar essa criação não abre o modal em transporte nenhum: cair na api mostraria a base do SERVIDOR sob o rótulo de "sua máquina", exatamente o defeito que esta regra fecha.

Sem agente local conectado, o modo continua oferecido — e quem declara é o PICKER, não o passo de modo. A RN-513 esconde mounted quando a base é desconhecida ou ausente, e está certa: ali a api TEM sinal (GET .../projects-base), e sem base a criação termina em 400 no fim do assistente, sem conserto na tela. Aqui nenhuma das duas coisas vale. Não existe sinal de presença de runner na api — a RN-521 já declara isso por escrito —, então esconder o modo ou apagar o botão seria decidir "não tem" a partir de "não sei", o colapso que a RN-088/RN-468 proíbem; e o modo não está quebrado sem runner, porque o caminho é confirmado depois, pelo CLI (RN-423) — o que falta é só a NAVEGAÇÃO. Então a declaração vai para o único lugar que de fato pergunta: o picker, no instante em que pergunta.

O motivo é DISCRIMINADO, e os dois desfechos não colapsam. MotivoDeFalhaDoAgente é sem-agente (o engine AFIRMA que não há runner conectado — um fato do servidor) ou sem-resposta (teto da requisição, socket caído, ticket recusado — ignorância, e dita como ignorância na tela). Antes desta regra quem decidia era erro?.includes('Nenhum runner conectado') no COMPONENTE — uma tela casando substring de uma frase em pt-BR que mora num .ex do engine, exatamente o que a RN-532 recusa por escrito do outro lado ("o texto é para um humano ler, o motivo é para o outro lado DECIDIR"). O casamento sobrou em um lugar, o módulo que é dono do protocolo do canal, e a degradação é nomeada: frase reescrita no engine cai em sem-resposta — o estado "não sei" —, nunca em "há agente". O transporte de api nunca preenche motivo, e isso é decisão: recusa da api é recusa da api, tem mensagem própria que ENSINA e não há agente local a instalar para consertá-la.

A espera é a da RN-474, REUSADA e não reescrita. Os dois motivos levam à EsperaDoRunner — mesmos três estados (esperando/confirmado/ semResposta, que nunca colapsam), mesmo TETO de 3 minutos, mesma redação, mesmo sinal (workspaceVerifiedAt MUDANDO, nunca "existindo"). Ela ganhou uma callback opcional porque aqui a confirmação tem CONSEQUÊNCIA: a listagem que falhou é refeita sozinha, em vez de a pessoa ter de descobrir que precisa clicar em algo. A callback dispara UMA vez por instância — sem o ref, cada rodada da sonda a redispararia e o chamador entraria em laço — e ela não afirma "está de pé agora" (workspaceVerifiedAt nunca disse isso, RN-468): por isso quem a recebe REPETE o pedido, e se o agente já caiu de novo a tela volta ao mesmo estado, honesta das duas vezes. Espera é UMA por tela: RunnerOnboardingPanel ganhou mostrarEspera, false só onde quem o monta já mostra a sua — duas seriam duas sondas, dois tetos e, no pior caso, duas frases discordando sobre o mesmo carimbo.

E o picker diz de QUEM é o disco, nas DUAS origens. O único sinal antes disso era o rótulo de um atalho ("Base de projetos" × "Pasta pessoal"), que nomeia um lugar e não uma máquina. A frase da api não é decoração de simetria: sem ela a do runner leria como aviso excepcional, quando é informação que as duas devem.

  • Onde: apps/web/src/lib/fs-browser.ts:60 (MotivoDeFalhaDoAgente, e por que só o transporte do runner o preenche); apps/web/src/lib/fs-browser-channel.ts:66 (FRASE_DO_ENGINE_SEM_RUNNER, o único casamento por substring que sobrou) e :68 (motivoDaMensagem); apps/web/src/components/FolderBrowserModal.tsx:227 (agenteNaoRespondeu, a decisão pelo motivo) e a linha de origem nas duas origens; apps/web/src/components/EsperaDoRunner.tsx:91 (onConfirmado) e :136 (o ref que a dispara uma vez); apps/web/src/components/RunnerOnboardingPanel.tsx:131 (mostrarEspera); apps/web/src/routes/NewProjectWizard.tsx:304 (origemDoNavegador, o transporte escolhido pelo modo, undefined quando não há projeto a ancorar)
  • Teste: apps/web/src/components/FolderBrowserModal.test.tsx (a lista declarando o disco da máquina do usuário; sem-agente caindo no painel de onboarding; a decisão pelo MOTIVO e não pelo texto, com uma redação que ninguém prometeu manter; sem-resposta com a ressalva de que isso não prova ausência, UMA espera só e o teto anunciado; a listagem refeita sozinha quando o carimbo muda); apps/web/src/routes/NewProjectWizard.test.tsx (o modo decidindo o transporte nos dois sentidos; a criação antecipada ancorando o canal; a criação que FALHA não abrindo navegador nenhum)
  • ADR: 0151 ponto 7
  • Origem: FASE 29, sessão 9. Fica declarado e NÃO feito: o picker não dispara workspace_create — escolher uma pasta é escolher, e criar é outra coisa. Engine.Runners.PastaDoProjeto.criar/3 (RN-532) segue sem chamador, e ligá-lo exigiria uma rota HTTP nova na api, fora do escopo desta sessão. O conserto definitivo do casamento por substring — o engine mandar um código ao lado do texto, como o runner já faz com os cinco motivo da RN-532 — também é do lado do engine, e fica declarado no código

O backup cobre os DOIS volumes que são fonte de verdade, e roda sem cluster (RN-528)​

RN-528 — git_local_repos entra no backup, o destino pode ser disco, e o restore de compose reusa as MESMAS três validações​

O backup cobria um banco e rodava num lugar. Três consequências, e a terceira é a que custa código: make test-restore exige kubectl, um cluster e o CronJob brabo-backup no namespace, e docker-compose.prod.yml não tinha serviço backup nenhum — a imagem era construída pelo bake e usada só no k8s. Migrar uma instalação por compose não tinha caminho provado.

Classificar antes de copiar. O movimento fácil seria "fazer backup de todos os volumes", e ele é errado: dos sete, neo4j_data e project_workspaces são DERIVADOS, ollama_data é re-obtenível e brabo_projects_base é do usuário. Copiá-los dá impressão de cobertura sem acrescentar recuperação. Fonte de verdade são DOIS: pgdata — que já vinha, como dump lógico e nunca como cópia do diretório de dados de um Postgres em execução — e git_local_repos, que nenhum backup cobria. Um projeto com provider local guarda o repositório bare ali dentro, e ele NÃO é reconstruível do Postgres: o event log guarda a narrativa, não os objetos do git. A prosa do runbook dizia o contrário ("the real repositories live in GitHub/GitLab") e é essa frase que explica como o volume passou anos sem cobertura — ela vale para github/gitlab, e para local não há upstream nenhum.

A garantia do arquivo dos repos é declarada, e é menor do que "snapshot". Nada é quiesced. O que se garante é consistência POR REFERÊNCIA — o git escreve os objetos antes de mover a ref, e troca a ref por rename atômico, então um tar que cruza um git push pega o valor velho ou o novo, nunca metade de um. O que NÃO se garante é instante global (o tar percorre a árvore ao longo de um período) nem conectividade dos objetos (a imagem não tem git, de propósito). E um gc concorrente apagando packfile vira FALHA e não arquivo parcial: o status do tar é capturado num arquivo, porque em POSIX sh o status de um cano é o do último comando — sem isso o arquivo incompleto se anunciaria como bom, e backup que mente é pior do que backup que falha, porque só o segundo vira alerta. *.lock fica de fora: restaurar um lock órfão produz repositório em que todo git recusa operar.

Destino em disco, S3 opcional. As cinco variáveis de S3 deixam de ser obrigatórias e passam a ser o que já eram na prática — a configuração de UM dos destinos. BACKUP_DIR definida escolhe disco; ausente, S3, que é como o CronJob do k8s continua rodando sem uma linha de mudança. A inferência (em vez de uma variável de destino obrigatória) é o que mantém aquele caminho intacto: um destino explícito quebraria o CronJob no dia do deploy, não no do commit. Quatro prefixos e não dois (daily/, weekly/, git-daily/, git-weekly/) porque a retenção é por CONTAGEM: dois tipos de arquivo sob um prefixo fariam "manter 7" significar três backups e meio.

O restore de compose muda o invólucro, nunca o julgamento. test-restore-compose.sh dispara um backup REAL pela mesma imagem e o mesmo comando de produção, e chama o MESMO brabo-restore, com as MESMAS três validações (lista de tabelas contra a origem, contagem das críticas na janela do backup, continuidade densa da seq de session_events). deploy/k8s/test-restore.sh fica intacto — unificá-los faria o caminho de compose depender de kubectl, que é a dependência que ele existe para não ter. E restore.sh continua sem tocar a database de ORIGEM, propriedade que não se negocia por conveniência de migração. O passo que só o compose pode cobrar é o terceiro: verificar o arquivo dos repos, porque no k8s aquele volume nem é montado no pod (pular é correto) e sob compose ele é (pular seria falso verde).

Restaurar os repos é comando IRMÃO, não fase do outro — são dois artefatos com dois julgamentos, e juntá-los faria falha de git reprovar a validação do event log. Ele verifica por default e só escreve com --restaurar; recusa extrair por cima de repos existentes (a mistura de dois estados não levanta erro nenhum e só aparece num fetch com história errada); e — achado da execução real — recusa ANTES do tar quando o volume não aceita escrita, nomeando --user 0:0. git_local_repos é compartilhado por três imagens com uids diferentes e carrega o dono de quem o montou primeiro (medido: 1000:1000, modo 0755) enquanto esta imagem roda como uid 70. Quando a extração roda como root, o que sai é devolvido ao dono do DIRETÓRIO e nunca deixado como root: senão o volume voltaria íntegro e inútil, com api e engine sem conseguir escrever no que acabou de ser restaurado.

Neo4j recebe reprojeção, não backup. Restaurar uma projeção possivelmente velha ao lado de um Postgres restaurado noutro instante dá dois estados derivados de momentos diferentes, sem nada que os concilie. A reprojeção não foi construída aqui (é o BRB-018): até existir, instalação migrada nasce com o grafo VAZIO — degradação conhecida e nomeada, não perda de dado, porque o RAG vive no pgvector e vem no dump. Desde a RN-569 ela existe (grafo:reprojetar), e o grafo vazio passa a durar até alguém rodá-la.

  • Onde: docker/backup/lib.sh:28 (destino_tipo, a inferência que mantém o CronJob intacto), :65 (destino_esperar perguntando por ESCRITA e não por existência), :105 (destino_enviar, escrita em .parcial e rename), :181 (a garantia dos bare repos, escrita junto do código que a produz), :221 (git_arquivar, o status do tar num arquivo), :259 (git_restaurar, a devolução do dono) e :273 (git_raiz_gravavel); docker/backup/backup.sh:31 (destino_preparar — as cinco variáveis de S3 deixando de ser obrigatórias), :149 (os três desfechos do volume de repos, sem colapsar "não montado" com "vazio"), :170 (a conferência do status do tar) e :220 (a poda dos quatro prefixos); docker/backup/restore.sh:28 (o mesmo destino do backup) e :120, :184, :215 (as três validações e o veredito, sem uma linha de mudança); docker/backup/restore-git.sh:43 (verificar como default), :109 (a recusa de extrair por cima) e :123 (a recusa por escrita, com o conserto nomeado); docker/backup/test-restore-compose.sh:60 (o --profile no config e não no run); docker/docker-compose.prod.yml:392 (o serviço backup, sob profiles e restart: "no") e :550 (o volume que NÃO entra na classificação dos sete); docker/backup/Dockerfile.prod:80 (o /backups criado e chown-ado ANTES do USER); Makefile:54 (test-restore-compose)
  • Teste: scripts/ci/backup-lib.spec.ts — as funções num sh de verdade, contra um diretório de verdade: as cinco operações do destino em disco; o objeto ausente medindo 0; a escrita atômica e o .parcial que a listagem ignora; os prefixos irmãos que não se misturam; o destino que existe e NÃO aceita escrita (caso de falha); "não montado" × "montado e vazio"; o arquivo dos repos com status 0 e conteúdo relido; *.lock e objects/tmp_* fora; o ciclo arquivar→restaurar devolvendo byte a byte; a recusa de restaurar onde não se pode escrever; e o tar truncado que reprova ao ser LIDO, nunca pelo tamanho
  • ADR: 0152
  • Origem: FASE 29, sessão 5. Exercitado de verdade contra o compose de desenvolvimento com dados reais (55 tabelas, 422 eventos, os bare repos de exp001 e exp004), incluindo a restauração num volume novo conferida com git fsck — o registro está no docs/runbook.md, seção "Last verified run — compose, disk destination". Fica declarado e NÃO feito: o caminho S3 do adaptador não tem cobertura automatizada (exigiria um endpoint), e continua sendo o mesmo comando aws de antes; a reprojeção do Neo4j é o BRB-018; e nada aqui agenda o backup num compose — quem o dispara é um humano ou o instalador (sessão 6)

Quando dá errado​

situaçãoo que o sistema faz
Transição de estado inválidaerro tipado (InvalidSessionTransitionError etc.), traduzido para 4xx — nunca grava estado inconsistente
Agente estoura o teto de iterações ou tokensencerra e registra artefato de bloqueio com a origem da falha
Task esgota o ciclo Kbloqueada com motivo, não gira para sempre (RN-015)
Provider de LLM cai no meioregistrado como falha de infra, nunca "o modelo parou" (RN-023)
Duas decisões concorrentes na mesma hipóteseconflito explícito (RN-022)
Réplica do engine caisessão é adotada por outra ou encerra como closed_abnormally / node_shutdown — nunca fica órfã
QaLeadServer/SecOpsAgentServer cai no meio de um ciclo de gateEngine.Gates.GateRescuer retoma sozinho — reinicia a área (nenhum veredito gravado) ou reenvia a chamada perdida (veredito já gravado) — sem intervenção manual (RN-140)
Rate limit indisponívela requisição passa: o guard protege contra abuso, não contra acesso indevido
Rate limit estourado (429)a tela diz o que a api respondeu e o poll para; a app nunca responde ao limite com mais tráfego (RN-088)
Credencial errada, conta inexistente ou conta bloqueadaa mesma resposta 401, com o mesmo custo de argon2 (RN-032)
Refresh já usado reapresentadofamília revogada e evento de segurança; o usuário legítimo também é deslogado (RN-030)
Tráfego interno sem o segredo de serviço403 na api, 401 no engine — nunca alcança o controller (RN-035)
Provider recusa a chave durante o sync de catálogoprovider pulado com a origem da falha; nenhum modelo é marcado como sumido (RN-041)
Modelo do binding some do providera cascata cai para o nível de baixo e AVISA qual escopo pulou — nunca troca o modelo em silêncio (RN-041)
Preço do modelo mudavale daqui em diante; o custo gravado e o preço que o produziu ficam intocados (RN-042)
Criar o handoff falha (Criativo→PO, Arquiteto→Infra/Dev Lead)agent.error durável, o processo do agente CONTINUA vivo; o que já foi gravado antes (product_brief, regras) não se perde (RN-116)
Caminho de projeto Pasta montada fora de BRABO_PROJECTS_BASEa criação é recusada (400) nomeando a base e sugerindo <base>/<nome> — o projeto não nasce para travar depois (RN-170/RN-501)
Pasta de projeto Pasta montada inalcançável quando a Infra sobe o containercontainer_start termina failed NOMEADO (variável, caminho, dono da pasta, próximo passo) e o ciclo de vida não chega a provisioning (RN-501)
BRABO_PROJECTS_BASE ausentea api responde projectsBase: null e a criação de projeto não oferece o modo Pasta montada — nunca oferecer um modo que a instalação não honra (RN-500)
Navegador de pastas recebe um path fora da base (inclusive a armadilha de prefixo <base>2)400 que nomeia a base — malformado, e não 403: nenhum papel navega fora dela (RN-504)
Navegador de pastas recebe um path dentro da base que a api não consegue abrir404 dizendo QUAL dos dois é (não existe / existe e não dá para ler) — nunca 500 com mensagem de fs (RN-504)
Pasta navegada só tem arquivos, symlinks, ou mais de 500 subpastasa listagem volta com arquivos/simbolicos/truncado e a tela DIZ o que ficou de fora — pasta cheia de código nunca se apresenta como vazia (RN-504)
BRABO_PROJECTS_BASE sobreposta ao checkout do Brabo (nos dois sentidos)pnpm dev recusa subir, nomeando os dois caminhos. Nenhuma validação da api pega isso: ela compara contra process.cwd(), que dentro do container dela é /workspace (RN-500)
BRABO_PROJECTS_HOST_BASE ausente no broker e container_start de projeto mountedrecusa nomeando a variável (503, origem infra), sem tocar container nenhum — nunca cai na outra raiz, que apontaria para a pasta de outro projeto (RN-503)
Projeto mounted LEGADO, com a pasta fora da basea spec devolve localizacao.tipo: 'indisponivel' com o motivo nomeando a base e o caminho; o broker recusa com 409 e o conserto é mover a pasta, não trocar de modo (RN-503)
Broker fora do ar (ele sobe sob profiles e NÃO sobe por padrão) e container_start de projeto mounteda ação termina failed com o motivo do BrokerIndisponivelError, nunca exceção — e nada transiciona no ciclo de vida (RN-503)
Localização de projeto incoerente no banco (par modo/caminho gravado por fora da criação)a ativação da execução recusa com 400 e o motivo em pt-BR, nunca 500 sem corpo (RN-478)
Login social: e-mail do provider bate com conta existente mas NÃO verificadorecusado com 403, nenhum vínculo gravado — e-mail não verificado não é prova de identidade (RN-274)
Login social: state inválido/expirado, ou de outro PROPÓSITO (fluxo de conexão de git)recusado, nenhuma chamada ao provider nem escrita no banco (RN-273)
Validar a necessidade sem product_brief nenhum na sessãorecusado (400) ANTES de gravar qualquer evento — não há o que validar ainda (RN-406)
Converter execution_mode com dev agent trabalhando ou travadorecusado (409) ANTES de mexer no permissions.json ou no ciclo de vida do container — nunca migra um agente vivo (RN-447)
Pasta local anexada estoura o teto de arquivos ou de bytes somadosrecusado (400), o lote inteiro — nunca trunca em silêncio (RN-456)
Arquivo individual da pasta local é grande demais ou de extensão não reconhecidasó PULADO (filesSkipped), nunca derruba o upload inteiro (RN-456)
Confirmar um pedido de pull de modelo que já não está pending_confirmationrecusado (409) — a confirmação não é reexecutável (RN-462)
Pull de modelo Hugging Face falha no Ollamapedido termina failed com a origem declarada (infra/modelo), nada é ativado no catálogo (RN-462)
Binário do runner indisponível (release sem asset, GitHub fora) durante a configuração pelo navegadora pasta escolhida e os dois arquivos de configuração FICAM; a tela diz o motivo e troca a instrução pelo caminho npm install -g @brabo/runner (RN-473)
Runner não conecta dentro do teto da esperaa tela diz que não viu, declara que isso não é prova de ausência e aponta a aba Código — nunca "verificando" para sempre (RN-474)
Pasta do runner com chave de dispositivo presente e inválida (JSON quebrado, ou sem kid)o CLI recusa NOMEANDO o arquivo e o motivo, e oferece as duas saídas — nunca o bloco de uso, que é a resposta de quem não configurou nada (RN-475)
Criar o repositório falha antes de existir linha de bootstrapo endpoint de status reporta provision_failed com o motivo lido da proposed_action, e failedStep fica NULO — nenhum passo do Gitflow foi tentado, e nomear um seria inventar (RN-477)
step.check do bootstrap falha (token expirado, 403, timeout)vira status: 'failed' + lastError na linha e bootstrap.step_failed no event log — antes subia sem tocar em nada e a tela pollava para sempre (RN-477)
Provisionamento não converge dentro do teto da telaa espera PARA em 3 minutos, declara que isso não prova fracasso e oferece procurar de novo — sem disparar um segundo POST (RN-477)
Aplicar um modelo a todos os agentes e a api recusar PARTE delesas linhas que passaram ficam gravadas e são relidas; o aviso diz quantas de quantas e NOMEIA as que ficaram — nunca "salvo" nem "não salvo", que seriam as duas mentira (RN-476)
INSERT da telemetria de busca do RAG falhaa busca responde assim mesmo, com searchId: null, e a falha vira log com origem infra — o instrumento de medição não derruba o que ele mede, e também não some calado (RN-479)
Voto num searchId/chunkId que aquela busca não devolveu400 que ensina, nada gravado — voto sem rank não distingue "índice pobre" de "pesos errados", e número sem significado é pior que número nenhum (RN-480)
medir:rag numa janela em que vector_available foi false o tempo todoreprova (exit 1): o que foi medido não é a busca híbrida, é a metade léxica dela, e calibrar peso de vetor contra isso seria calibrar contra outro sistema (RN-479)
route_modules_to_infra chamado sem module_map vigente, com lista vazia, módulo repetido, módulo fora do mapa, ou imagem inválida (latest/sem tag/rationale curto)400 nomeando o que falta ou o que está errado — pelo agente, tool-result de erro que o modelo corrige, nunca crash (RN-487)
container_start elege uma imagem fora das candidatas do roteamento vigente do Arquitetoação vira failed nomeando a imagem recusada e listando as candidatas válidas — nem a imagem é decidida nem o broker é chamado (RN-491)
Broker recusa ou está indisponível ao subir o container (BrokerRecusouError/BrokerIndisponivelError)ação vira failed com a mensagem do broker — nunca propaga, nunca fica pendente (RN-491)
emit_artifact com type: decision_record faltando context/options/choice/consequencestool-result de erro nomeando a(s) chave(s) que faltam — o modelo corrige na próxima volta do laço, nunca crash (RN-505)
Terminal/worktree num projeto runner verificado e conectado, mas SEM container runningrecusa NOMEADA (RunnerReadiness), nunca cai no host puro — Docker é pré-requisito real, sem fallback (RN-507)
RunnerGit tenta materializar o worktree sem as três pré-condições da RN-507levanta com mensagem nomeada ANTES de qualquer exec — nunca tenta às cegas e deixa o timeout do canal explicar (RN-507)
Job periódico de limpeza de worktree encontra um projeto runner sem runner conectado ou sem container runningPULA o projeto nesta rodada, em silêncio — nunca um erro, nunca deixa de podar os demais projetos (RN-507)
container_start_via_runner proposto para projeto container/mounted, ou runner sem runner conectadoo Infra Lead RECUSA localmente, nomeando o motivo, sem chamar propose_action nenhuma vez (RN-508)
container_start_via_runner aprovado sem imagem decidida (RN-105)ação vira failed nomeando a ausência de decisão — nunca chama o engine (RN-508)
Runner conectado a um projeto cujo execution_mode exige uma capacidade que ele não declarou no joinjoin recusado nomeando a capacidade e mandando atualizar o binário — recusa fatal, sem retry, nunca degradação silenciosa (RN-514)
Runner que declara uma capacidade que ESTE servidor não conheceo nome é ignorado e o join passa — runner mais novo que o engine conecta (RN-514)
exec despachado a um runner que não declarou a capacidade execresponde ao chamador no mesmo formato de exec_result, com exitCode 126 e a causa no output — nunca deixa o RunnerRouter esperar até o timeout (RN-514)
pty_* relayado a um runner que não declarou a capacidade ptya mensagem NÃO é entregue e a :web recebe pty_error nomeado — nunca "carregando" para sempre (RN-514)
Destino de espelho dentro do workspacePath do projeto, ou contendo ele400 nomeando o sentido do laço — os dois são a mesma recusa, e a comparação é por SEGMENTO, então /base-outra NÃO conta como dentro de /base (RN-515)
Destino de espelho declarado num projeto execution_mode: container400 NOMEANDO o motivo (a origem é um volume do servidor, o agente local não a enxerga) — nunca aceitar e nunca copiar (RN-515)
PUT .../mirror-path sem a chave mirrorPath no corpo400 — omitir seria indistinguível de pedir para LIMPAR, e limpar em silêncio é o defeito; limpar é null explícito (RN-515)
brabo-runner service install rodado como rootrecusa nomeada, sem escrever arquivo nem rodar comando — o serviço é de usuário por desenho, e um root quebraria a premissa das três fronteiras do produto (RN-518)
brabo-runner service <qualquer> no Windows (ou em qualquer plataforma fora de Linux/macOS)recusa nomeando a plataforma, dizendo que a decisão é declarada e apontando o primeiro plano — nunca falha muda, nunca um "instalado" que não instalou (RN-518)
service install numa pasta sem chave de dispositivo válidarecusa nomeando o arquivo — um serviço autentica por chave, nunca por token, que ficaria em disco dentro da unit (RN-518)
systemctl/launchctl indisponível durante o service installo arquivo de unit FICA, e a resposta diz que não ativou, nomeando o comando pendente — apagá-lo viraria "não aconteceu nada", que é falso (RN-518)
service uninstall sem unit instalada e sem --dirresponde NÃO INSTALADO e não toca em arquivo nenhum: sem o registro da unit, adivinhar pelo cwd apagaria a chave de outro projeto (RN-518)
service status numa máquina sem o gerenciador de serviços no PATHresponde "instalado, e NÃO consegui perguntar" com código próprio — "não sei" nunca vira "parado" (RN-518)
Projeto do workspace que NUNCA provisionou container, na página /containersaparece na lista com registrado: null e o texto "nunca provisionado" — nunca stopped, nunca "não observado", e nunca gasta uma das 20 verificações do broker (RN-521)
"Subir" clicado num projeto sem artifact.project_image decididoo botão nem fica ativo: a tela DIZ que falta decidir a imagem, em texto, em vez de propor uma ação que já se sabe que termina em falha (RN-521/RN-105)
"Subir" num projeto runner cujo workspaceVerifiedAt é nulobotão inerte com motivo PRÓPRIO ("nenhum agente local jamais confirmou a pasta") — nunca colapsado com "sem imagem decidida" (RN-521)
"Subir" num projeto runner com pasta confirmada mas runner possivelmente desligadoa tela OFERECE e escreve a ressalva: confirmação de pasta não é presença de agente (RN-468), e a ação falha ao ser aprovada se ele não estiver de pé (RN-521)
Papel abaixo de maintainer na página /containerscontroles inertes com o motivo dito UMA vez em texto; estado, imagem e recursos continuam VISÍVEIS (RN-521/RN-102, ADR 0064)
brabo-runner --base <caminho> com caminho relativo, com .., igual a /, apontando para um arquivo, ou (no Linux) fora do $HOMErecusa nomeando o motivo e sai com código 2 — pedido explícito digitado agora que não dá para honrar, a mesma disposição de --dir recusado (RN-529)
--base DENTRO da pasta deste projeto, ou igual a elarecusa base-dentro-da-raiz: todo projeto novo nasceria dentro deste. O sentido CONTRÁRIO (a pasta do projeto dentro da base) é o arranjo normal e passa (RN-529)
$XDG_CONFIG_HOME/brabo/runner.json corrompido, ou com uma base que a guarda recusaa recusa é DITA em stderr, nomeia o que se perde, e o runner segue sem base — derrubar um agente que atende um projeto por uma configuração que ele ainda não usa seria desproporcional (RN-529)
Arquivo de base que existe e não declara baseé ausência, não recusa: configuração sem base consentida, não configuração quebrada — o runner roda como sempre (RN-529)
Projeto cuja pasta (--dir) está FORA da base configuradasegue válido, sem aviso: a base é regra de criação, e o léxico de --dir é para sempre — a mesma proibição escrita em project-workspaces-root.ts (RN-529/RN-501)
Segmento de projeto ABSOLUTO, com .., apontando para a própria base, ou escapando dela por symlinkrecusa nomeada por motivo próprio, na dupla passada (léxica e por realpath) — nunca aceito e reinterpretado, porque o que atravessa a rede é o SEGMENTO (RN-529, ADR 0130)

FASE 29 — a instalação de uma linha: assinar o que se publica​

A FASE 28 recusou por escrito o instalador de uma linha enquanto os artefatos não fossem assinados (BRB-005), e é por isso que a FASE 29 assina antes de instalar. Sessão 2, ADR 0149.

RN-524 — Artefato publicado é assinado por identidade de workflow, verificado no mesmo run, e a verificação recusa em vez de avisar​

Toda tag final assina o que publica, e verifica o que assinou antes de publicar a Release:

  • as cinco imagens são assinadas por DIGEST (eram quatro quando esta regra foi escrita; a quinta, o broker, entrou com o ADR 0162), lido de .release/images.json — nunca por tag. Tag é ponteiro móvel: assinar :5.0.0 atestaria o que aquela tag apontava no instante da assinatura, e não o que ela aponta quando alguém baixa. O digest é o que o manifesto já registra e o que o overlay de produção já aplica (ADR 0119);
  • os binários do runner (cinco alvos quando esta regra nasceu, quatro desde o ADR 0174, que tirou o darwin-x64) são cobertos por UM checksums.txt assinado, e não por uma assinatura por binário — quem verifica três e esquece o quarto não tem como saber que esqueceu;
  • a assinatura é keyless (OIDC do GitHub Actions): a identidade que assina é o próprio workflow, e não existe chave privada em custódia. Um par de chaves próprio significaria mais um segredo de CI, e este repositório já viu um PAT expirar em silêncio e reprovar duas runs do tag-release (ADR 0139);
  • falha de verificação é recusa nomeada, nunca aviso. Um aviso que se aceita clicando é uma verificação que não existe.

O manifesto declara o que cobre. A matriz é fail-fast: false de propósito, então um alvo que não construiu não impede os outros quatro de terem manifesto — mas o job nomeia no log os alvos que ficaram de fora, porque um checksums.txt que lista quatro e cala sobre o quinto é pior que nenhum: quem verifica os quatro conclui que verificou a release. Esta metade era intenção escrita e mecanismo que não a cumpria: com needs: build, um alvo que não constrói não impedia, ele ATRASAVA um dia — ver RN-565, que a torna verdadeira e mede o número.

Onde: .github/workflows/release.yml (passos "Instalar o cosign" e "Assinar as imagens publicadas", com id-token: write no bloco de permissions); .github/workflows/build-runner-binaries.yml (job checksums, sem needs: desde a RN-565 — a espera é pelos assets, com teto).

Teste: scripts/ci/actions-pinadas.spec.ts cobre o pin por SHA do sigstore/cosign-installer (com o comentário de versão ao lado, que é o que diz a um humano e ao Dependabot qual versão é aquele hash). A assinatura em si só se prova numa tag real — declarado, como o resto da esteira de release.

Origem: FASE 29, sessão 2. Fecha BRB-005 para imagens e binários. NÃO cobre o code-signing de sistema operacional dos binários (notarização do macOS, Authenticode do Windows), que exige identidade paga e segue no backlog.

RN-526 — O instalador verifica a própria origem, NOMEIA o que achou antes de perguntar, e nunca apaga pasta de usuário​

install.sh (raiz do repositório, versionado e publicado como asset da Release) é a porta única de instalação. Quatro garantias, e cada uma existe contra um defeito concreto:

A invocação preserva o stdin e deixa um ARQUIVO. A forma documentada é curl -fsSLO …/install.sh && bash install.sh — baixar e rodar um arquivo com bash —, e nunca curl … | sh: com o script chegando pelo pipe, o stdin do processo É o download, e qualquer read lê bytes do próprio script ou encontra EOF. Um instalador que não consegue perguntar escolheria sozinho onde criar pastas no computador de alguém — o oposto da régua que a RN-511 já aplica ao consentimento da base. Até a AT-083 a forma documentada era sh -c "$(curl -fsSL …)", e ela nunca funcionou: a verificação da própria origem (o parágrafo seguinte) calcula o hash de $0, e em X -c "…" o $0 é o nome do shell, não um arquivo; com dash como sh o script morria antes, no set -o pipefail. A decisão do mantenedor (18/09) foi mudar a DOC e manter a verificação por $0, sem porta de pular (ADR 0150). As formas erradas viraram recusas nomeadas, antes do primeiro download, que imprimem a forma certa: rodar sob um shell que não é bash (bloco POSIX no topo, antes do set -o pipefail) e rodar sem arquivo (exigir_o_proprio_arquivo, que exige o BASH_SOURCE do nível de cima e um $0 legível — /bin/bash -c deixa $0=/bin/bash, que existe, e sem a primeira metade o hash dele iria ao manifesto e a pessoa leria a frase de adulteração). A forma é UMA constante (COMO_RODAR), usada pelas recusas e pelo relato sem TTY.

Ele verifica a própria origem antes de agir, e a cadeia tem dois elos: a assinatura do checksums.txt da Release (RN-524), e o hash deste arquivo dentro do manifesto verificado. O cosign que faz a verificação é ele mesmo pinado por versão e conferido por hash contra o valor escrito no script — quem confia no install.sh o bastante para executá-lo confia no hash que ele carrega, e a cadeia não fica mais frágil que o elo que a inicia. Falha em qualquer elo é recusa nomeada, nunca aviso.

Calcular hash tem UMA função e DUAS ferramentas aceitas — sha256sum ou shasum -a 256 —, resolvidas antes do primeiro download e nunca no meio de uma verificação: a máquina que não tem com que conferir não deve chegar a ter o que conferir. Faltar as duas é recusa PRÓPRIA, que nomeia as duas ferramentas, diz onde cada uma mora e afirma que nada foi baixado — nunca a recusa de hash divergente, que é a de incidente ("pare e investigue"). A distinção não é estilo: o script chamava sha256sum direto nos cinco pontos de verificação, o macOS não o traz, e o command not found chegava a quem instalava como acusação de adulteração — bloqueando toda instalação na plataforma que o próprio script suporta (há cosign darwin-* pinado nele) e ensinando a ignorar a frase no dia em que ela for verdade. --print-plan roda sem nenhuma das duas e declara qual o script aceita (conferir-hash). E há uma TERCEIRA recusa, desde a AT-083: a ferramenta existe mas não consegue ler o arquivo. Sem ela, hash_sha256 devolvia vazio de dentro da substituição de comando (o errexit não chega ao subshell) e a comparação seguinte falhava com o texto de incidente do chamador — a acusação de adulteração da AT-091 por outro caminho. Agora é falha_ao_calcular_hash, que nomeia o arquivo e diz que não houve o que comparar.

A detecção nomeia o que achou. Pelo marcador ($XDG_STATE_HOME/brabo/install-state.json) quando ele existe; por sinais (projeto de compose brabo*, units brabo-runner-*, binário no PATH) quando não — e a lista é impressa antes de qualquer pergunta. Não existe o caminho "achei algo e decido sozinho". Sem TTY o script relata e sai 0, como consentir-base.mjs já faz.

Ele nunca apaga pasta de usuário. A base de projetos e a pasta de espelho estão fora do alcance de qualquer confirmação — a mesma régua do espelho (RN-516): pasta de usuário é acúmulo, não estado do produto. Volume nomeado só sai com confirmação, e listado um a um antes de perguntar.

O marcador mora no $XDG_STATE_HOME e não em ~/.brabo/, que é onde o runner guarda coisa por PROJETO: estado de instalação e configuração de projeto são vidas diferentes, e juntá-los faria a remoção de um apagar o outro.

Onde: install.sh (raiz). Teste: scripts/dev/install.spec.ts — os modos --print-state e --print-plan provam a DECISÃO sem TTY, sem rede e sem efeito, no molde de bootstrap.spec.ts; e o bloco "o hash num PATH sem sha256sum" roda as funções de hash no shell de verdade, com o PATH montado ferramenta por ferramenta, conferindo as DUAS recusas (ferramenta ausente × hash divergente) e que a primeira nunca usa o vocabulário da segunda; scripts/dev/install-invocacao.spec.ts roda o script pelas formas erradas (bash -c, /bin/bash -c, pipe, dash -c, dash install.sh) e cobra a recusa nomeada com a forma certa antes de qualquer download, a terceira recusa de hash, e que runbook, bootstrap e cabeçalho ensinam a MESMA forma. Origem: FASE 29, sessão 3 (ADR 0150). Nesta sessão o script não instala nada — subir o compose, gerar segredos e instalar o runner são as sessões 4 e 7, e ele diz isso na saída em vez de terminar em silêncio.

RN-527 — A instalação sobe de um compose PRÓPRIO, por imagem escolhida, e confere a saúde antes de dizer que instalou​

Revisada pela RN-575 (ADR 0162): o serviço broker passou a existir neste compose, desligado sob o profile container-broker e ligado pelo instalador só com consentimento; as imagens obrigatórias são CINCO. O resto desta regra vale como está.

docker/docker-compose.install.yml é o que roda na máquina de quem instalou, e ele não é o docker-compose.prod.yml. Três diferenças, cada uma com um motivo:

  • as cinco imagens vêm de variável obrigatória (${BRABO_API_IMAGE:?…}), sem default. Com default, uma variável ausente subiria metade da stack com uma imagem que ninguém escolheu, e o erro apareceria como comportamento estranho em vez de recusa;
  • não há bloco build: em lugar nenhum: este arquivo não constrói, e é o ponto dele;
  • o serviço broker não existe aqui, porque a imagem dele não é publicada (docker-bake.hcl tem quatro alvos). Consequência declarada: com --source=ghcr, projeto mounted não sobe container (ADR 0150, decisão 7).

O docker-compose.prod.yml fica intacto — ele se declara compose de validação, é o que docker/smoke.sh usa construindo local de propósito, e promovê-lo quebraria esse uso. O custo dos dois arquivos é declarado, e o teto contra a divergência é mecânico: scripts/dev/composes-em-conformidade.spec.ts reprova se o de instalação ganhar serviço, volume ou porta que o de validação não tenha, se algum build: sobreviver, ou se uma imagem de terceiro afrouxar para uma tag diferente.

A fonte da imagem é escolha, e "oficial" é resultado de verificação. --source=ghcr (default) resolve por digest a partir do images.json da Release; --source=local roda docker buildx bake e exige árvore limpa e em tag — imagem construída de árvore suja não é a versão que ela diz ser, e o marcador registraria uma mentira.

Os segredos nascem uma vez e persistem. Os cinco de RN-114 mais o NEO4J_PASSWORD, gerados com openssl rand no mesmo molde de docker/smoke.sh:31-57 — inclusive o detalhe de o NEO4J_PASSWORD ser -hex e não -base64, porque uma / quebra o parse de NEO4J_AUTH. O .env é criado vazio e com modo 600 ANTES de receber conteúdo: criar com o umask do usuário e apertar depois deixaria os segredos legíveis por uma janela, e é justamente o arquivo que não pode ter essa janela.

E o .env é um arquivo que o COMPOSE parseia, e se prova contra ele (AT-083). O SECRET_KEY_BASE saía de openssl rand -base64 64, que QUEBRA a linha aos 64 caracteres: os 88 do segredo iam em duas linhas, e a segunda ficava solta no .env. Medido em 100 gerações, o Compose recusou 49 ("unexpected character in variable name") e ACEITOU 51 — com o segredo cortado em 64 caracteres, que ainda é o mínimo do Phoenix e por isso subia sem aviso. Todo base64 passa agora por segredo_base64 (| tr -d '\n'), e o arquivo é escrito por escrever_env — função, e não corpo de main, para que o spec grave o MESMO arquivo que a instalação grava.

Não há passo de migrate, e não deve haver: o compose encadeia api → migrate-api com service_completed_successfully, e up --wait espera. Um segundo lugar mandando migrar seria a segunda fonte da mesma verdade.

E o instalador pergunta antes de afirmar. Depois do up --wait — que já espera o healthcheck — ele bate no /health da api e do engine, e só então diz que instalou. É a régua que o reset-total.sh custou a aprender: ele anunciava "reset completo" com a api em Exited (1).

Onde: install.sh (raiz), docker/docker-compose.install.yml. Teste: scripts/dev/install.spec.ts (o plano, sem TTY nem efeito) e scripts/dev/composes-em-conformidade.spec.ts (a divergência entre os dois composes); scripts/dev/install-env.spec.ts gera os segredos e o .env pelas funções de verdade, passa o arquivo por docker compose config contra o compose de instalação e cobra que CADA segredo chegue INTEIRO aos serviços (pula nomeando o motivo sem docker compose), com a geração antiga fixada como reprovada. Origem: FASE 29, sessão 4 (ADR 0150). Instalar o runner (sessão 7) e migrar uma instalação anterior (sessão 6) seguem fora, e o --print-plan os declara nao-nesta-versao.

RN-525 — O proxy do binário confere o hash contra o manifesto da mesma release, recusa quando não pode conferir, e DIZ que isso é integridade e não procedência​

GET /runner-releases/binary é @Public(), sem autenticação, e é o passo 4 do fluxo de configuração pelo navegador (RN-473, ADR 0118). Até a sessão 3 da FASE 29 ele validava a ENTRADA (platform contra uma allowlist fechada) e transmitia os bytes do GitHub direto para o cliente, sem verificar nada — é o consumidor que o ADR 0149 nomeia e a RN-524 deixou pendente.

Agora ele confere o sha256 dos bytes contra o checksums.txt da MESMA release antes de responder qualquer coisa, e o header Content-Digest (RFC 9530) repete o hash conferido, para quem receber poder refazer a conta.

O que esta verificação NÃO faz, e está escrito onde ela é feita. Ela não verifica a assinatura do manifesto (checksums.txt.bundle). Isto é integridade contra o manifesto, não procedência: quem consiga reescrever a Release reescreve os dois arquivos e passa. As duas formas de fechar isso foram MEDIDAS nesta sessão, e as duas foram recusadas:

caminhomedidopor que não
cosign na imagem da api155 MB (cosign-linux-amd64 v2.6.1)quase dobra a imagem de runtime (Alpine + Node), publicada por digest no GHCR (ADR 0119), para verificar um download opcional
@sigstore/verify + @sigstore/tuf2,5 MB, 12 pacoteso tamanho não é o custo. getTrustedRoot() refresca metadados TUF contra tuf-repo-cdn.sigstore.dev (o seeds.json do pacote só semeia o root.json), então uma rota pública que hoje depende de UM host de terceiro passaria a depender de dois. E, pela régua dos ADRs 0041/0042 — capability só é declarada quando provada —, não há como provar o caminho hoje: nenhuma Release tem checksums.txt.bundle, porque o job checksums nasceu na sessão 2 e só roda numa tag final, lacuna que a própria RN-524 já declara do lado de quem assina

Quem verifica a ASSINATURA é o install.sh (sessão 4/7), que tem cosign de verdade e o baixa pinado com sha256sum -c (decisão 4 do ADR 0149). BRB-005 segue aberto para a metade de procedência desta rota, e o registro diz isso — não é rodapé, é o limite do que a rota prova.

Release sem checksums.txt é RECUSA, nunca "sirvo avisando". Um aviso que se aceita clicando é uma verificação que não existe (ADR 0149, decisão 3) — e seria o downgrade mais barato possível: apagar 400 bytes da Release desligaria a conferência de todo mundo. O custo está medido e é pequeno: na v5.0.0 há dois binários (linux-x64, linux-arm64) e nenhum manifesto, e o passo do binário é best-effort no fluxo do navegador (RN-473, passo 4) — ele cai em npm install -g @brabo/runner, que não depende de release nenhuma. Toda release anterior à primeira tag depois do ADR 0149 cai nesse caminho, por construção.

Os seis desfechos de 502 não colapsam. O status sozinho não os separa e eles pedem ações diferentes de quem chamou, então a recusa carrega um motivo no corpo — plataforma_nao_publicada, release_sem_manifesto, manifesto_nao_cobre_a_plataforma, manifesto_ilegivel, download_falhou, hash_divergente. É a régua da RN-470 numa resposta de api: "essa plataforma não saiu ainda" manda esperar a próxima release, "release sem manifesto" manda usar o npm, e "hash divergente" é incidente. Em especial, "manifesto não listado na release" e "manifesto listado que falhou ao baixar" são desfechos DIFERENTES — confundi-los mandaria a pessoa instalar pelo npm por causa de uma falha de rede momentânea.

Conferir hash exige ler todos os bytes, e isso passa pelo disco. Bufferizar os ~79 MB do binário em memória seria exaustão trivial numa rota pública (o pod da api tem limits.memory: 512Mi), e cachear os bytes seria pior — cinco plataformas × 79 MB. Os bytes descem em stream para um arquivo temporário em /tmp (um emptyDir montado justamente porque o rootfs da api é read-only), com o hash calculado no caminho, e o arquivo é relido em stream para o cliente só depois de o hash bater: memória constante, e nenhum byte não conferido chega ao cliente. Um teto de 256 MiB aborta resposta absurda antes de encher o /tmp. O cache existente (URLs, 5 min) fica, e passa a guardar também o manifesto já parseado — centenas de bytes de TEXTO, nunca bytes de binário —, na MESMA entrada e nunca num cache próprio: em janelas diferentes, uma release publicada no meio faria o hash novo ser conferido contra o binário velho, e o desfecho seria um hash_divergente que pareceria adulteração.

  • Onde: apps/api/src/interfaces/http/runner/runner-releases.controller.ts (a verificação, o teto de bytes, o arquivo temporário e o Content-Digest), apps/api/src/interfaces/http/runner/checksums.ts (a leitura pura do manifesto e a união de MotivoDaRecusa), apps/web/src/lib/runner-bootstrap.ts (baixarBinario passa a ler message e motivo do corpo, e a frase chega em falhaDoBinario)
  • Teste: apps/api/test/interfaces/runner-releases.controller.spec.ts (caminho feliz com Content-Digest; hash divergente sem NENHUM byte nem header no corpo; release sem manifesto recusando antes de baixar o binário; manifesto que não cobre a plataforma; manifesto ilegível; download falhado; e o cache guardando release e manifesto juntos) e o describe('parsearChecksums') do mesmo arquivo; apps/web/src/lib/runner-bootstrap.test.ts (a frase da api e o motivo chegando ao chamador, e corpo não-JSON não virando segunda exceção)
  • Origem: FASE 29, sessão 3. ADR 0149, o terceiro consumidor da tabela da decisão 3 — pela METADE que a api pode provar hoje, com a outra metade nomeada em vez de fingida | BACKUP_DIR aponta para um diretório que existe e NÃO aceita escrita | o backup recusa ANTES do pg_dump, dizendo que é ESCRITA — um destino read-only passaria num teste de existência e falharia depois, com o diagnóstico apontando para o lugar errado (RN-528) | | git gc concorrente apaga um packfile no meio do tar dos bare repos | a execução vira failed em backup_runs, com o status do tar nomeado — nunca um arquivo parcial que se anuncia como bom, porque só a falha vira alerta (RN-528) | | Volume git_local_repos não montado no container de backup (o CronJob do k8s) | o passo é PULADO e dito no log — e "não montado" nunca é colapsado com "montado e vazio", que é estado normal de instalação sem projeto local (RN-528) | | Há bare repos no volume e NENHUM arquivo no prefixo git-daily/ | brabo-restore-git reprova dizendo que este volume está sem cobertura — ausente-e-vazio é outro desfecho, e sai com sucesso (RN-528) | | Arquivo dos bare repos truncado | reprova ao ser LIDO (tar -tzf), nunca pelo tamanho: um tar cortado tem bytes de sobra. É o pg_restore --list do lado do git (RN-528) | | brabo-restore-git --restaurar num volume que já tem bare repos | recusado: sobrepor dois estados de repositório não levanta erro nenhum e só aparece num fetch com história errada. RESTORE_GIT_FORCE=1 para quem quer mesmo (RN-528) | | brabo-restore-git --restaurar sem poder escrever no volume (uid 70 × dono da api) | recusa ANTES do tar, nomeando --user 0:0 — descobrir no meio da extração deixaria o volume pela metade. E o que é extraído como root volta para o dono do DIRETÓRIO (RN-528) |

RN-530 — Migrar exige backup PROVADO: o instalador só ganha o direito de apagar depois de restaurar​

Instalar por cima de uma instalação existente não é oferecido: ou se migra, ou se para. Um up sobre volumes de outra versão é o tipo de estrago que não avisa.

A migração tem uma ordem, e o passo do meio é o que dá sentido a ela: backup → PROVAR que restaura → perguntar → apagar → instalar → restaurar.

A prova não é zelo. Um backup que ninguém tentou restaurar é um arquivo, e a hora de descobrir isso não é depois do down -v — por isso o instalador roda docker/backup/test-restore-compose.sh (as MESMAS três validações de restore.sh, ADR 0152) antes de qualquer deleção. Backup que falha, ou prova que falha, interrompem a migração sem apagar nada, e a mensagem diz onde o backup ficou.

O destino do backup é uma pasta do HOST, e isso é o detalhe que faz a diferença entre migrar e perder: o default do compose é o volume nomeado backup_local, que o down -v apagaria junto com o que se quer preservar.

O que a migração apaga: os volumes nomeados da instalação (pgdata inclusive), os containers e a rede. O que ela nunca apaga: a base de projetos — quando BRABO_PROJECTS_BASE aponta para uma pasta do host, a linha do compose é bind-mount, e down -v não toca bind —, a pasta de espelho (RN-516) e o backup recém-provado.

O dump do Postgres não é restaurado automaticamente sobre o banco novo: brabo-restore valida contra uma database de teste e nunca sobrescreve a origem (ADR 0152), e restaurar sobre um banco já populado é operação destrutiva que o instalador não toma sozinho. Os repositórios git locais, sim — eles são fonte de verdade que nasceria vazia.

Onde: install.sh, migrar_instalacao_anterior e restaurar_apos_migrar. Teste: scripts/dev/install.spec.ts — o plano declara apagar-sem-backup-provado: nunca, e um teste afere que a chamada da prova vem antes da chamada de deleção no código. Origem: FASE 29, sessão 6 (ADR 0150, ADR 0152).

RN-531 — O instalador consente UMA base para os dois lados e instala o agente local já executável​

Uma base, consentida uma vez. O install.sh pergunta onde os projetos vão morar e grava o mesmo caminho nos DOIS lugares: BRABO_PROJECTS_BASE no .env (o servidor, ADR 0141) e o campo base do runner.json (o agente local, RN-529). O que difere entre mounted e runner é quem executa, não onde o código mora — duas bases diferentes para a mesma pasta seriam a colisão de namespace que o ADR 0141 recusou por escrito.

A base é recusada quando não é absoluta, quando fica dentro do checkout do Brabo e quando contém o checkout — os dois sentidos, como o preflight.mjs já faz do lado do servidor. O ~ não é expandido de propósito: o valor vai para um arquivo de configuração, e um ~ gravado ali é lido literalmente.

O agente local é instalado já executável. O binário é baixado, conferido contra o mesmo checksums.txt assinado que o script já verificou para conferir a si mesmo (RN-524) — baixá-lo duas vezes seriam duas chances de pegar manifestos diferentes, e a segunda não seria verificada — e instalado com install -m 0755, que põe o bit de execução no mesmo ato em que copia.

Isso fecha o BRB-031. O chmod +x manual sobreviveu à FASE 28 porque a instalação como serviço não vinha de um artefato versionado: quem chegava a rodar brabo-runner service install já precisara tornar o binário executável para chegar ao comando. Vindo do instalador, o passo desaparece — a File System Access API do navegador não preserva o bit, um script preserva.

Release sem o binário da plataforma não interrompe a instalação: o resto já está de pé, e a mensagem aponta npm install -g @brabo/runner. É a mesma disposição do passo do binário na configuração pelo navegador (RN-473), e a razão é a mesma — o agente local é a última etapa, não um pré-requisito.

Onde: install.sh, consentir_base e instalar_o_runner. Teste: scripts/dev/install.spec.ts — install -m 0755 presente, a instrução de chmod dirigida ao usuário ausente, e a conferência contra o manifesto assinada nomeada. Origem: FASE 29, sessão 7 (ADR 0150, ADR 0151).

RN-534 — O instalador entra no manifesto assinado, e é exercitado numa máquina limpa​

Duas metades da mesma garantia.

O install.sh entra no checksums.txt e na Release. Sem isso a verificação que ele faz de si mesmo (RN-526) seria letra morta: ele confere o próprio hash contra o manifesto assinado, e o manifesto não o cobria. O arquivo vem do checkout da tag, não de download — é o mesmo commit que produziu tudo o mais que está sendo assinado no mesmo job.

E ele é exercitado numa máquina limpa, que é o que o runner efêmero do Actions é: host recém-criado, sem Brabo, sem .env, sem marcador. O E2E prova, nessa ordem: o plano não deixou de prometer o que nunca apaga; o estado numa máquina limpa não inventa marcador nem sinais; sem TTY o instalador relata, sai 0 e não grava; e, com um TTY de verdade (não um pipe — o pipe é justamente o que mataria o consentimento), a instalação completa verifica assinatura, confere o próprio hash, grava o marcador e deixa o .env em modo 600.

Correção (AT-083): o "TTY simulado por script -qec" que este parágrafo afirmava nunca foi TTY para o instalador. O passo rodava script -qec "… ./install.sh … < respostas", com o < DENTRO do comando do script: o stdin do instalador era o arquivo, [ -t 0 ] era sempre falso, e o fluxo interativo nunca foi exercitado. Hoje quem roda o instalador é um driver de PTY em Python (python3 -I, sem dependência nova), que o põe no lado escravo do pty e ESPERA cada pergunta antes de responder — e, para as duas de senha, espera o eco estar DESLIGADO, o que prova o "sem eco" da RN-547 e impede a senha de aparecer na saída. O E2E também baixa e roda o instalador pela MESMA forma que o runbook manda (curl -fsSLO + bash install.sh, RN-526), e reprova de cara se o instalador disser "Sem terminal interativo". Como o workflow segue sem rodar em PR, o driver é extraído dele e exercitado em scripts/dev/install-e2e.spec.ts contra um instalador de mentira (terminal visto, respostas em ordem, senha fora da saída, e as duas paradas nomeadas), com a forma antiga fixada como a que NÃO dava terminal.

Ele não roda em pull_request, e o motivo é o mesmo da assinatura: o manifesto assinado só existe depois de uma tag final. Fazer o script rodar em PR exigiria dar-lhe uma porta para PULAR a verificação — a porta que o ADR 0150 recusa, e que uma vez aberta valeria para qualquer um, não só para o CI. É a decisão que o golden-set do RAG já tinha tomado pelo mesmo raciocínio (ADR 0138): o que não é exercitável em PR roda em cadência própria, e o workflow diz por quê em vez de fingir cobertura.

Onde: .github/workflows/install-e2e.yml; .github/workflows/build-runner-binaries.yml, job checksums. Teste: o próprio workflow — e scripts/dev/install.spec.ts continua cobrindo a decisão sem rede; scripts/dev/install-e2e.spec.ts (bloco "o TTY do E2E é um TTY para o INSTALADOR") exercita o driver de PTY em PR. Origem: FASE 29, sessão 10 (ADR 0149, ADR 0150).

TODO(humano): as RNs acima foram extraídas do código e dos testes. Falta confirmar se existe regra de negócio não implementada que deveria estar aqui — algo combinado e ainda não codificado não aparece nesta varredura.


Criar projeto não provisiona repositório (RN-541)​

RN-541 — Criar um projeto NÃO cria repositório git; o bootstrap é adiado até o handoff do Arquiteto​

Criar um projeto e provisionar o git eram, na prática, o mesmo gesto — e o que os unia era uma TELA. ProvisioningPage dispara provisionRepository no efeito de montagem e é o único chamador dele no web; o assistente navegava para lá ao confirmar, então todo projeto nascia com repositório e Gitflow completos, sem que ninguém tivesse pedido.

O dono do produto quer o contrário: o que se gera por ora segue sem git, e o repositório só nasce quando o desenvolvimento começa de verdade — o momento em que o Arquiteto passa o handoff ao Dev Lead.

O mecanismo do adiamento é não navegar para aquela tela. Ao criar, o assistente vai direto para a página do PROJETO. Nada mais precisou ser desligado: como o disparo mora no useEffect de uma rota que deixou de ser visitada, deixar de visitá-la É o adiamento. A rota continua existindo e continua alcançável — o Dashboard leva a ela quando o provisionamento falhou e precisa ser retomado.

Provider e credencial saem da criação, e ficam na adoção. Sem repositório para criar agora, perguntar "onde hospedar" seria colher uma decisão que a tela não usa, e o passo de credencial cobraria um PAT para um repositório que ninguém vai criar. Adotar é o oposto por construção — aponta para um repositório que já existe, e sem provider não há o que apontar. O assistente passa a ter três passos ao criar e quatro ou cinco ao adotar.

A tela de Confirmar deixa de prometer o Gitflow. Ela anunciava "N passos de Gitflow" ao criar, contando BOOTSTRAP_STEPS; isso passaria a ser mentira no instante em que o bootstrap deixou de rodar ali. Passa a dizer QUANDO o repositório nasce, em vez de afirmar que já está nascendo.

Consequência declarada: entre a criação e o handoff, o projeto existe sem repositório. Nada quebra — GET .../repository responde null e não 404, a página do projeto já tem o estado "repositório não provisionado" e a seção de Repositório em Configurações simplesmente não aparece —, mas todo caminho que precise de working tree falha NOMEADO até lá (remoto_de_trabalho devolve :not_found, e open_adr_pr recusa com "Projeto sem repositório provisionado"). Isso deixa de ser borda e passa a ser o estado normal do começo de um projeto.

O gatilho é a próxima entrega, e sem ele um projeto criado hoje ficaria sem repositório para sempre: o aceite do handoff Arquiteto → Dev Lead passa a provisionar local, o único provider que não pede credencial. Desde a RN-582, esse gatilho é o aceite do handoff ao Arquiteto (o do Dev Lead ficou como segunda porta), e ativar a execução sem repositório é recusado.

O aviso "no plano gratuito do GitHub, repositório privado não aceita proteção de branch" saiu da tela junto — não por ter deixado de ser verdade, mas por ter ficado inalcançável: a condição era provider === 'github', e ao criar não há mais provider escolhido.

  • Onde: apps/web/src/routes/NewProjectWizard.tsx:379 (stepKeys, agora dependente de adotando), :540 (a guarda de provider restrita à adoção) e :590 (a navegação para /projects/$projectId)
  • Teste: apps/web/src/routes/NewProjectWizard.test.tsx, describe('NewProjectWizard — o git é adiado') — "criar não passa mais pela página de provisionamento — vai direto ao projeto", "criar tem TRÊS passos: provider e credencial saíram", "adotar mantém provider e credencial" e "a tela de Confirmar não promete mais o Gitflow agora"
  • Origem: pedido do dono do produto — "o que for gerado por hora segue sem git; git mesmo só será iniciado após iniciar desenvolvimento em projetos novos, neste momento que deve rodar o bootstrap"

O repositório nasce no handoff para o Dev Lead (RN-522)​

RN-522 — O repositório é provisionado ao ACEITAR o handoff Arquiteto → Dev Lead, sempre local, e a falha nunca derruba o aceite​

Revisada pela RN-582 (ADR 0165): o GATILHO passou a ser o aceite do handoff ao Arquiteto. O aceite ao Dev Lead descrito abaixo continua provisionando, como segunda porta idempotente, e o resto desta regra (sempre local, falha como evento, adotado não é falha) segue valendo. O texto abaixo é o da decisão original.

A RN-541 tirou o provisionamento da criação e declarou que o gatilho viria em seguida. Este é o gatilho — e sem ele um projeto criado ficaria sem repositório para sempre, porque ProvisioningPage era o único chamador de provisionRepository no web e a criação deixou de navegar para lá.

O momento é o aceite do handoff do Arquiteto para o Dev Lead, e não a ativação da execução. São instantes DISTINTOS, nesta ordem: aceitar o handoff ativa o Dev Lead; ativar a execução é quando os dev agents começam a reivindicar task. É no primeiro que "o desenvolvimento começou" passa a ser verdade, e é o que o dono do produto pediu — "quando o arquiteto passar o handoff para o desenvolvedor iniciar o desenvolvimento".

O ramo mora em AcceptHandoffUseCase, ao lado do ramo 'infra' que já existia e pela mesma razão de ordem: o efeito acontece antes de activateAgent, de modo que o agente acorde num projeto que já tem onde trabalhar.

Sempre local. É o único provider que não pede credencial nenhuma — o if (providerName !== 'local') de ProvisionRepositoryUseCase nunca chega a resolver segredo, e o LocalGitProvider.createRepo sequer lê o accessToken que a assinatura aceita. É isso que torna um provisionamento automático possível: ninguém escolheu hospedagem no meio do aceite, então não há credencial a buscar nem pergunta a fazer. Publicar num provider remoto continua sendo caminho separado (adoção, ou conversão em Configurações) — declarado, não esquecido. Efeito colateral aceito: protectBranch: false no provider local faz o último passo do bootstrap degradar (step_degraded), nunca falhar.

A falha vira EVENTO, nunca exceção. Este caso de uso não tem transação: quando o provisionamento roda, updateStatus('accepted') e o evento handoff.accepted já estão COMMITADOS. Um throw devolveria 500 ao usuário sobre um handoff que, no banco, foi aceito — e ainda impediria o activateAgent logo abaixo, deixando o Dev Lead sem acordar por causa de uma falha de git. Então o desfecho vai para o event log como repository.provision_failed, com origem e a mensagem real, e o aceite segue. É a régua de sempre: falha registra a origem e nunca vira resposta vazia.

O que NÃO é falha: projeto que ADOTOU um repositório. ProvisionRepository recusa esse caso com ConflictException de propósito — bootstrap de repositório de terceiro só roda por aprovação de plano (RN-045) —, mas o projeto TEM repositório, então não há o que provisionar e registrar isso como falha mentiria sobre o estado dele. O ramo consulta findByProjectId e sai antes. Repositório created que já existe não é pulado: cai no caso de uso normal, que é idempotente por desenho ("rodar de novo não falha"), e é assim que um bootstrap interrompido se completa.

  • Onde: apps/api/src/application/use-cases/agents/accept-handoff.use-case.ts — o ramo dev-lead no execute e o método privado provisionarRepositorio, com o try/catch e a saída antecipada do repositório adotado; apps/api/src/application/use-cases/agents/agents-use-cases.module.ts (o import de GitUseCasesModule — a seta é agents → git, e git não importa agents, então não há ciclo)
  • Teste: apps/api/test/application/use-cases/agents/accept-handoff.use-case.spec.ts — caminho feliz ("aceitar para o Dev Lead provisiona local com o slug do projeto"), os dois casos de falha ("provisionamento que falha NÃO derruba o aceite nem a ativação" e "a falha vira evento nomeado, com origem e a mensagem real"), e os limites ("aceitar para OUTRO agente não provisiona nada", "projeto que ADOTOU repositório não provisiona, e isso não é falha", "repositório já CRIADO cai no caso de uso, que é idempotente por desenho")
  • Origem: pedido do dono do produto — "o gatilho deve vir quando o arquiteto passar o handoff para o desenvolvedor iniciar o desenvolvimento"

Os artefatos dos agentes viram arquivo, em docs/ (RN-523)​

RN-523 — A pasta docs/ é PROJEÇÃO do event log: derivada, por agente, e nunca fonte de verdade​

Todo artefato que os agentes produzem vive exclusivamente no event log — apps/engine/lib/engine/harness/tools/emit_artifact.ex:3-5 diz literalmente que "não há tabela de artefatos". Isso continua certo para a FONTE, e esta RN não mexe nisso. O que ela fecha é outro buraco: essa memória não chegava a lugar nenhum que uma pessoa abrisse. Quem abre a pasta do projeto no editor não encontrava nada do raciocínio que produziu o código. (O provisionamento adiado — criar projeto deixar de criar repositório, e o git nascer no aceite do handoff ao Dev Lead — abre um intervalo inteiro em que o projeto não tem nem repositório, e torna esta pasta ainda mais necessária; ele vem em PR próprio, e esta RN não depende dele.)

Cada evento artifact.* de um tipo permitido vira um Markdown em docs/<agente>/ dentro do workspace do projeto. É projeção derivada, no mesmo sentido estrito que o ADR 0101 deu ao grafo Neo4j: a fonte é o evento, a pasta pode ser apagada inteira e reconstruída — desde a RN-590 por artefatos:reprojetar —, e nada no produto lê dela para decidir coisa alguma.

O mecanismo é o do GraphProjector, copiado peça por peça — poller próprio, drainOnce() público, flag contra ciclos sobrepostos, lote de 50, e markProcessed só após sucesso. A linha de outbox é uma TERCEIRA, gravada na mesma transação do evento, sob um aggregate_type PRÓPRIO (artifact_projection): Engine.Outbox.Drain só drena session, task e container, e disputar a linha 'session' seria perder a corrida contra ele quase sempre. Uma diferença deliberada em relação ao grafo: o aggregateId é o projeto, não a sessão — a pasta é do projeto, e gravá-lo ali poupa o projetor de uma consulta por artefato.

A lista é de PERMITIDOS. Tipo de artefato novo nasce fora da projeção e entra quando alguém decidir que ele é documento, em vez de aparecer sozinho numa pasta que o usuário lê. Ficam de fora qa_verdict, secops_verdict, task_blocked e infra_delegation_files: são desfechos OPERACIONAIS, e um veredito de gate por task encheria docs/qa-lead/ de arquivos que ninguém abre, custando à pasta a propriedade que a torna útil — caber numa olhada.

Versionado sobrescreve; append-only leva o seq. Os quatro tipos lidos por redução ao maior version (module_map, module_routing, project_image, c4_diagram) escrevem sempre o mesmo arquivo, e a pasta mostra o vigente — o histórico continua inteiro no event log. Os demais viram arquivo próprio com o seq do evento no nome, e sem ele dois artefatos do mesmo tipo com o mesmo título gerariam o mesmo arquivo, com o segundo apagando o primeiro: a única forma de esta projeção perder informação que a fonte tem.

Duas barreiras contra o filesystem, e a segunda não é redundante. O nome sai do título que o MODELO escreveu. slugDeArquivo deriva um nome de um alfabeto fechado (nenhum caminho de retorno deixa separador sobreviver; devolve null quando não sobra nada), e FsArtifactFileStore confere de novo com relative() antes de abrir. As duas respondem perguntas diferentes — "que nome eu derivo disto" e "o que eu vou de fato abrir" —, e confiar só na primeira faria a segurança da escrita depender de nenhuma mudança futura de slug jamais deixar passar um separador, que é a contenção que o ADR 0130 recusa por princípio.

A projeção nunca derruba a fonte. Nenhum throw escapa do ciclo: cada item falha isolado, fica logado e permanece processed_at IS NULL para o ciclo seguinte. Diferente do grafo, não há aqui um erro que pare o lote inteiro — falha de escrita costuma ser do ITEM, e parar o lote bloquearia todos atrás dele. Evento que sumiu do event log é marcado processado, não retentado para sempre.

Em modo runner a pasta desvia para a raiz gerenciada, pelo mesmo motivo físico do permissions.json (RN-478): quem escreve é a api, de dentro do container dela, e o canal do runner só transporta comando, sem primitiva de escrever conteúdo. Custo declarado: ali docs/ não fica ao lado do código.

A escrita NÃO passa por proposed_action, e não é omissão: aquele pipeline é a origem de todo efeito externo de um AGENTE PEDINDO PARA AGIR, e aqui o artefato já foi emitido e já está no event log. Passar a projeção pela fila encheria as aprovações de decisões rotineiras — uma por artefato — e corroeria o teto que dá sentido ao clique nas ações que são efeito externo de verdade.

  • Onde: apps/api/src/domain/artifacts/artifact-projection-events.ts (o vocabulário e as regras de nome, puros); apps/api/src/application/artifact-projection/artifact-projector.ts (o poller); apps/api/src/application/ports/artifact-file-store.port.ts e apps/api/src/infrastructure/filesystem/fs-artifact-file-store.ts (a escrita, com a segunda barreira); apps/api/src/infrastructure/filesystem/project-workspaces-root.ts (pastaDeArtefatosDoProjeto, irmã de permissionsFilePath); apps/api/src/application/use-cases/sessions/append-session-event.use-case.ts (a terceira linha de outbox)
  • Teste: apps/api/test/domain/artifacts/artifact-projection-events.spec.ts (as regras de nome, o traversal e a colisão por seq) e apps/api/test/application/artifact-projection/artifact-projector.spec.ts (caminho feliz, versionado sobrescrevendo, e a degradação: falha deixa a linha não processada, o ciclo seguinte retenta, uma falha não bloqueia o lote, evento sumido não é retentado para sempre, reprocessar é idempotente)
  • ADR: 0148
  • Origem: pedido do dono do produto — "criar apenas uma pasta por ora chamada docs, onde os artefatos estarão de cada um dos agentes"

RN-542 — O instalador compara a versão instalada com a que vai instalar, e a oferta é específica por relação​

O install.sh detectava instalação anterior desde a RN-530 e oferecia migrar com backup provado. O que ele não sabia era qual versão estava lá: o marcador ($XDG_STATE_HOME/brabo/install-state.json) gravava schemaVersion, instaladoEm, plataforma, fonte, digests e caminhos — e nada que dissesse 5.0.0. A pergunta "migrar esta instalação?" era cega nos dois lados, e quem respondia não tinha como saber se aquilo subia, descia ou repetia.

A informação sempre esteve à mão e era ignorada: images.json da Release traz "versao" ao lado de commit e publicadoEm, e resolver_imagens_do_ghcr lia só repositorio e digest. Pela outra ponta, --source=local já exige árvore limpa e em tag — então git describe é uma versão confiável ali, pelas duas recusas que já aconteceram antes.

A ordem é a decisão. As imagens passam a ser resolvidas antes da detecção: saber o que entra é pré-requisito de perguntar se o que existe sai. O marcador sobe para schemaVersion: 2 e grava versao e commit — os dois juntos, porque um marcador novo sem o campo passaria por antigo.

Com as duas versões em mãos, a oferta deixa de ser uma pergunta e vira quatro, uma por relação:

relaçãoofertadefault
maiorAtualizar 5.0.0 → 5.1.0?sim — é o que a pessoa veio fazer, e o backup provado torna reversível
igualJá é a 5.0.0. Reinstalar do zero mesmo assim?não — não há ganho a oferecer
menornomeia rebaixamento, avisando que migração de banco não anda para trásnão — pode não ter volta
desconhecidamarcador de schema 1 não tem versão: diz isso, e não adivinhanão

O ramo maior é o único com default sim, e é o único que pode tê-lo: o passo é reversível pelo backup que acabou de ser provado. Os outros três exigem um s digitado.

Em qualquer ramo a instalação é apagada e recriada do zero, nunca subida por cima — decisão do dono do produto, e a mesma razão da RN-530: um up sobre volumes de outra versão é estrago que não avisa, e meia migração é pior que nenhuma. A cadeia backup → PROVAR → perguntar → apagar → instalar → restaurar não muda em nenhum deles.

comparar_versoes é implementada à mão e não com sort -V: o BSD sort do macOS ganhou -V tarde, e este script roda na máquina dos outros — a mesma disciplina que já fez sha_do_cosign recusar array associativo por causa do bash 3.2. Ela ecoa o veredito em vez de devolver código de saída, porque sob set -e um return 1 legítimo mataria o script.

No menu do pnpm bootstrap, Docker › Instalar (install.sh) relata o plano e não instala — mecânica, não preferência: item de menu roda com stdin em /dev/null para o menu seguir lendo teclas do mesmo terminal, e este instalador existe para PERGUNTAR (é o motivo de o ADR 0150 recusar curl | sh). É o mesmo desenho do item Base de projetos (RN-511), e a nota do item carrega o comando que instala de verdade.

  • Código: install.sh (comparar_versoes, campo_do_marcador, resolver_imagens_do_ghcr, resolver_imagens_locais, main); scripts/dev/bootstrap.sh (item 1.7)
  • Teste: scripts/dev/install.spec.ts — "a comparação de versões" e "o marcador registra a versão"; a função é extraída do script e rodada por bash, nunca reimplementada em TS (uma cópia continua passando depois que o shell quebra)
  • Achado rodando, não lendo: cut -d. sobre uma string sem ponto devolve a linha inteira, não vazio — então 5.0.0 vs 5 respondia menor. Normalizado com .0.0 no fim, com teste de regressão.
  • ADR: 0150
  • Origem: pedido do dono do produto — "o sh deve ser inteligente de entender que já há uma versão e verificar se será uma nova versão a ser executada ou não... caso seja uma nova ou escolhido isso pelo usuário deverá deletar a anterior e instalar do zero para evitar possíveis erros"

A chave de dispositivo passa a poder ser da MÁQUINA (RN-543, FASE 30)​

RN-543 — runner_device_keys.project_id nulo é a chave de MÁQUINA: vale para os projetos do DONO, resolvida contra o projeto pedido​

A identidade do agente local era por PROJETO. Uma máquina com três projetos em modo runner tinha três chaves, três pastas com brabo-runner-device-key.jwk.json e três units de serviço — e a pessoa passava três vezes pelo mesmo fluxo de navegador (RN-464..466, RN-475) para descrever uma máquina. É o item 4 dos cinco acoplamentos que o ADR 0154 mediu; os itens 1, 2 e 3 (tópico, socket id, ticket) descrevem uma conexão e N conexões os satisfazem byte a byte, então nada no engine muda aqui.

Uma tabela, duas espécies. project_id preenchido = a chave de PROJETO de sempre. project_id NULL = a chave de MÁQUINA. Tabela irmã foi considerada e recusada no ADR: PatAuthGuard acha a pública pelo kid e não precisa saber a espécie, e duas tabelas fariam duas buscas — a segunda esquecida exatamente uma vez. NULL e não sentinela: a FK é real, e project_id = '00000000-…' exigiria uma linha falsa em projects. user_id continua NOT NULL — o ADR recusa chave que atravesse usuários, porque a credencial descreve quem age.

O ponto que decide tudo é um só. PatAuthGuard.autorizarPapel recusava quando o projeto da credencial diferia do projeto da rota. Essa comparação não foi apagada: ela é o que impede a chave do projeto A servir o projeto B, e ela some só para project_id nulo, porque aí não há o que comparar. O papel passa a se resolver contra o projeto PEDIDO, que é o ADR 0154 ponto 2 ao pé da letra — "a autorização continua sendo a de sempre, resolvida contra o projeto pedido". Nenhum teto novo e nenhum afrouxado: a chave de máquina não dá ao runner nada que o DONO dela já não tivesse, e papel insuficiente no projeto pedido continua 403. O ramo do PAT não muda — personal_access_tokens.project_id continua NOT NULL.

O agente PERGUNTA quais projetos atende. GET runner/projects devolve os projetos em execution_mode: 'runner' nos quais o dono da credencial alcança pelo menos developer — o MESMO mínimo de runner-ticket, porque listar projeto cujo ticket seria recusado é prometer o que a rota seguinte nega. Ele pergunta em vez de varrer o disco: a base da máquina é do usuário e pode ter pasta que não é projeto nenhum, e adivinhar por nome de pasta é a classe de erro que o ADR 0141 recusou. O que viaja é o SEGMENTO (workspaceDirName, RN-109) e nunca um caminho absoluto — o mesmo invariante do broker.

É a primeira rota @RequirePatAuth() sem :projectId no caminho, e as duas consequências são travadas por teste: só credencial de MÁQUINA entra (uma de projeto descreve um projeto só e não tem o que descobrir — 403 com mensagem PRÓPRIA, nunca a de "projeto errado", que mentiria sobre o motivo), e não há @RequireRole, porque não há projeto contra o que resolvê-lo. O mínimo é aplicado por LINHA, com a régua ÚNICA do produto (ResolveEffectiveRoleUseCase.forProject, projectRole ?? workspaceRole — RN-471); a consulta de candidatos em SQL é alcança, nunca tem papel, e reescrever o ?? ali seria uma segunda régua. Por isso route-surface.spec.ts a classifica como jwt — o classificador lê @RequireRole e não enxerga mecanismo — e docs/security-surface.md corrige a leitura em prosa, exatamente como já faz para runner-ticket.

A listagem da RN-519 passa a dizer a ESPÉCIE. Uma chave de máquina serve todo projeto do dono, então ela entra na listagem de todos — marcada (especie: 'maquina', projectId: null). As duas metades são obrigatórias: sem entrar na lista ela seria invisível e permanente em toda tela, que é o defeito que a RN-519 fechou renascido na espécie nova; sem a marca, a mesma linha apareceria em N projetos parecendo N chaves diferentes. A espécie é DERIVADA de project_id num lugar só — não há coluna para ela, e não deve haver.

E a revogação passa a ter alvo PLURAL. RN-520 derruba a conexão viva de {projeto, usuário}, e uma chave de máquina não nomeia projeto nenhum. Não derrubar reabriria exatamente o que a RN-520 fechou — a chave morre para ticket NOVO e as conexões vivas seguem executando comando aprovado —, agora em N conexões em vez de uma. Então revogar uma chave de máquina pede um disconnectRunnerOfUser por projeto em modo runner que o dono alcança. É a consequência que o ADR declarou por antecipação ("revogar passará a derrubar todos os projetos daquela máquina"), e ela cabe sem tocar o engine: a assinatura {projeto, usuário} fica byte a byte, só é chamada N vezes. A lista vem dos CANDIDATOS, sem filtro de papel, e isso é deliberado — desconectar não é decisão de autorização, e faltar um projeto deixaria de pé o que a revogação existe para matar.

  • Código: apps/api/src/db/schema/auth.ts (runnerDeviceKeys.projectId, sem .notNull()); apps/api/src/db/migrations/0058_public_war_machine.sql; apps/api/src/interfaces/http/auth/pat-auth.guard.ts (autorizarPapel); apps/api/src/interfaces/http/runner/runner-projects.controller.ts; apps/api/src/application/use-cases/runner/list-runner-projects.use-case.ts; apps/api/src/infrastructure/persistence/drizzle/project.repository.ts (listRunnerModeReachableBy); apps/api/src/infrastructure/persistence/drizzle/runner-device-key.repository.ts; apps/api/src/application/use-cases/auth/revoke-runner-device-key.use-case.ts
  • Teste: apps/api/test/interfaces/pat-auth.guard.spec.ts — "chave de MÁQUINA", com os DOIS sentidos travados (a de máquina vale em qualquer projeto; a de PROJETO continua recusada em projeto diferente) e a rota sem :projectId; apps/api/test/application/use-cases/runner/list-runner-projects.use-case.spec.ts (papel abaixo de developer fica de fora); apps/api/test/infrastructure/persistence/drizzle/runner-device-key.repository.spec.ts e .../project-runner-mode.repository.spec.ts (contra o Postgres — o que mudou é um NOT NULL e um WHERE, e fake nenhum prova qualquer um dos dois); apps/api/test/application/use-cases/auth/revoke-runner-device-key.use-case.spec.ts ("o alvo da desconexão vira PLURAL"); apps/api/test/interfaces/http/runner/runner-projects.controller.spec.ts
  • Lacuna DECLARADA, e FECHADA pela RN-552: quando esta RN fechou, nenhuma rota da api criava chave de máquina — a rota nasceria no PR que tivesse o primeiro chamador real, o install.sh (ADR 0155 ponto 4), nunca antes. Foi o que aconteceu: POST /internal/machine-device-keys. O tipo da porta já é o da COLUNA (projectId: string | null), para o consumidor futuro não ter o que mudar ali. E o web continua sem tela onde listar ou revogar chave de dispositivo (a metade aberta da RN-519): a espécie nova torna a lacuna maior, e ela fica declarada, não fechada aqui. FECHADA depois pela RN-561, que é a tela — e a marca de espécie criada aqui é o que ela usa para não mentir sobre o alcance de revogar.
  • ADR: 0154
  • Origem: FASE 30, sessão 2 — o recorte da fase

O agente local abre N conexões, uma por projeto (RN-544, FASE 30)​

RN-544 — O agente local abre N conexões, uma por projeto descoberto pela rota — e o teto de tentativas e a recusa de join deixam de ser do PROCESSO​

A RN-543 abriu GET runner/projects e ficou sem chamador. O brabo-runner continuava exigindo --project: index.ts tinha if (!projectId) uso(), e não existia execução sem projeto. Esta RN é o consumidor — e é a metade do ADR 0154 que prova a descoberta dele: "um único processo que abra N conexões — uma por projeto que ele atende — satisfaz os itens 1, 2 e 3 byte a byte". Nada no engine muda: o tópico terminal:<projectId>, o socket id e o ticket descrevem uma CONEXÃO, e N conexões continuam sendo N conexões. A recusa de segundo runner no mesmo projeto fica intacta, e é garantia real, não acidente.

Dois modos, e o antigo fica byte a byte. --project/--dir com credencial de projeto — inclusive o fluxo do navegador (ADR 0118, RN-464) — é o caminho de sempre: UMA conexão, e o desfecho dela continua sendo o do processo. O modo novo exige as DUAS coisas, e por motivos diferentes: credencial de MÁQUINA, porque é ela que a rota aceita; e base consentida (RN-529), porque sem ela não há de onde derivar a pasta de cada projeto, e inventar uma seria escrever no disco do usuário um caminho que ele não consentiu. Sem base, rodar sem --project segue caindo em uso(), dizendo qual das duas faltou.

O runner não sabe de que espécie é a própria chave, e isso é decisão. Em disco, chave de máquina e chave de projeto são o MESMO arquivo: uma JWK privada com um kid (RN-475). Quem sabe é o SERVIDOR, que acha a pública por esse kid. Então o CLI pergunta, e o 403 nomeado do PatAuthGuard vira CredencialNaoEDeMaquinaError, com as palavras da api e o conserto (--project) — nunca um "falha na conexão" genérico. Um palpite local seria uma segunda fonte de verdade sobre a mesma coisa, e a local seria a errada. O JWT dessa chamada vai sem o claim projectId, porque o guard compara payload.projectId !== request.params.projectId e numa rota sem projeto os dois precisam ser undefined.

N conexões exigem N estados. Quatro campos de EstadoDoRunner são POR PROJETO — dir, canalAtual, containerAtivo, destinoDoEspelho — mais o gerenciadorPty, que nasce de dir. Um estado compartilhado faria o docker exec de um projeto rodar no container de OUTRO, e o PTY de um abrir na pasta de outro. docker e base são da MÁQUINA e entram como o MESMO valor em todos, nunca cópias que possam divergir. O tipo não mudou, e os handlers (tratarExec, tratarMirrorSync, tratarWorkspaceCreate…) não mudaram uma linha: o espelho (RN-516) e o workspace_create (RN-532) já eram por projeto e já viajavam na concessão do join daquela conexão, que é exatamente o que os torna corretos com N.

O teto e a recusa viram do PROJETO. Eram do processo: recusa de join dava process.exit(1), e TETO_DE_TENTATIVAS_SEGUIDAS esgotado também. Com N conexões, matar tudo por causa de uma derrubaria o agente de todos os projetos que estão funcionando. Então cada projeto tem seu laço e seu contador; um projeto que recusa ou esgota encerra sozinho, NOMEADO, e os demais seguem — e só quando NENHUM sobra o processo sai com 1, com um resumo dizendo o desfecho de cada um. Todo log do laço é prefixado pelo nome do projeto: com N laços intercalados, "falha na conexão" sem dono é a forma que o silêncio toma quando há N de algo. No modo projeto a disposição antiga fica idêntica, e pelo mesmo raciocínio invertido: com UMA conexão, não sobra nada a atender.

A lista é consultada UMA vez, no start. Repesquisar periodicamente foi considerado e recusado: custa chamada recorrente por um evento raro, e o preço real não é tráfego — é que uma lista que volta MENOR é ambígua (projeto apagado, convertido de modo, papel revogado, ou um 500 transitório se disfarçando dos três), e derrubar conexão VIVA por causa dessa ambiguidade trocaria um estado certo por um palpite. O processo DIZ isso ao subir, e o gesto para pegar projeto novo é reconectar o agente — um comando só, com o serviço de usuário da RN-518.

Lista VAZIA é estado NORMAL, e a saída é 0. É o estado de toda máquina recém-instalada: o instalador sobe o agente antes de existir projeto nenhum (ADR 0155). Não é erro, então não é código de erro — e Restart=on-abnormal (RN-518) não o reergue, que é o certo: não há o que reerguer. Ficar de pé com zero conexões seria um serviço "ativo" que não faz nada, e o status da unit passaria a mentir.

A pasta de cada projeto é <base>/<workspaceDirName>, pelas guardas que já existem. resolverPastaDoProjetoNaBase (a MESMA que workspace_create usa, e pelo mesmo motivo: o nome veio pela rede), mais validarDirDentroDoHomeNoLinux (RN-434) e garantirDiretorio (RN-435) — nenhuma quarta cópia de régua nasce. Criar a pasta é a RN-435 aplicada a um caminho DERIVADO em vez de digitado; o que NÃO acontece aqui é git init/clone, que continua sendo workspace_create (RN-532). Um projeto cuja pasta é recusada sai do plano nomeado e não derruba os outros. OpcoesDaBase.raizDoProjeto aceita null no modo de máquina: sem --project não há --dir, a recusa de laço fica sem sujeito (toda raiz é derivada da base, então "a base dentro da raiz de um projeto" não tem como ser construída), e passar a própria base ali seria pior — dentroDoEscopo(base, base) é verdadeiro e toda base seria recusada.

  • Código: apps/runner/src/index.ts (o portão em lerArgumentos, lerArgumentosDeMaquina, manterConexaoDoProjeto, criarEstado, rodarComoAgenteDeMaquina); apps/runner/src/projetos.ts (listarProjetosDoRunner, planejarConexoes, CredencialNaoEDeMaquinaError); apps/runner/src/auth.ts (assinarDescobertaComChaveDeDispositivo — o JWT sem o claim projectId); apps/runner/src/base-guard.ts (OpcoesDaBase.raizDoProjeto nulo)
  • Teste: apps/runner/src/projetos.spec.ts — caminho feliz da rota e do plano, o 403 virando erro PRÓPRIO, resposta fora do contrato, e o segmento que escapa da base sendo recusado SEM derrubar o vizinho (inclusive por symlink, na segunda passada); apps/runner/src/index.spec.ts — o PORTÃO no processo de verdade (sem base, uso() com o motivo; com base, a recusa passa a ser da credencial); apps/runner/src/base-guard.spec.ts — raizDoProjeto nulo não afrouxa nenhuma outra recusa
  • Lacuna DECLARADA, FECHADA depois pela RN-552: quando esta RN fechou, ninguém CRIAVA chave de máquina (era o install.sh, ADR 0155 ponto 4), então o modo novo só era exercitável com uma chave registrada à mão — a metade que a RN-543 já declarava. A apiUrl no modo de máquina vem de --api-url/BRABO_API_URL/default, e não do brabo-runner.config.json, que é por PROJETO: uma instalação em porta ou host diferentes precisa da flag no ExecStart, e quem a escreve é a unit por máquina, que é a sessão 4 desta fase. E a unit continua sendo POR PROJETO — este PR muda o PROCESSO, não o serviço
  • ADR: 0154
  • Origem: FASE 30, sessão 3 — o recorte da fase

O teto de auto-rebaixamento também na REMOÇÃO (RN-556)​

RN-556 — Remover a própria linha de project_members é recusado com 403 quando o efeito líquido é rebaixamento​

Sobre o número. O salto de RN-542 para 556 é deliberado: a faixa 543–555 está reservada para a FASE 30, que já a alocou em branches em voo. Alocar aqui produziria a colisão que este repositório já observou três vezes no mesmo instante (ADR 0148 ×2, RN-522 ×2, RN-523 ×2), resolvida depois pela DATA. Nada foi pulado por engano.

O ADR 0127 pôs os dois tetos da RN-472 só no caminho de ESCRITA e declarou o terceiro movimento aberto, por escrito e com o custo estimado: "RemoveProjectMemberUseCase NÃO ganhou teto… A premissa de que 'a remoção é sempre benigna' é falsa, e fica escrita aqui para não ser redescoberta". Esta RN fecha essa porta — é o BRB-001, P1 de segurança.

Remover não apaga um papel, TROCA o papel efetivo. ResolveEffectiveRoleUseCase.forProject é projectRole ?? workspaceRole (RN-471), e a sobreposição vale nos DOIS sentidos. Some a linha de projeto, o efetivo passa a resolver pelo segundo termo — que pode ser MENOR. Um maintainer cuja autoridade vinha da linha de projeto, viewer no workspace, removia a própria linha e caía para viewer sem volta pela tela: repor é POST :projectId/members, que pede o maintainer recém-abandonado. Sem papel de workspace nenhum, a queda é para acesso NENHUM.

É o teto 2, não um teto novo. ehAutoRebaixamento compara o papel efetivo de HOJE com o de DEPOIS, e nunca precisou saber por qual rota o segundo chegou: no add o papel-depois vem do corpo, na remoção vem do workspace. remocaoEhAutoRebaixamento é essa aplicação, e DELEGA — não reimplementa comparação nenhuma. Ela existe como função própria por UM caso que a assinatura da outra não sabe enunciar: ator sem papel de workspace, onde o papel-depois é "nenhum", que não é um Role. Esse caso é o rebaixamento máximo e recusa.

movimentodesfecho
remover a própria linha, workspace com o MESMO papelpassa — é como alguém sai da lista
remover a própria linha, workspace com papel MENOR403
remover a própria linha, SEM papel de workspace403 — a queda é para acesso nenhum
remover a linha de OUTRA pessoa, mesmo que ela caiapassa — capacidade legítima de sempre
remover a linha que restringia o owner do WORKSPACEpassa — só pode ELEVAR

O teto 1 não ganha par, e a ausência é decisão. owner é o topo do ROLE_ORDER: remover a linha de projeto de quem é owner no workspace só pode elevar o efetivo dele, ou mantê-lo. Mais que inofensivo, é a ÚNICA forma de desfazer uma restrição que o teto 1 impede de criar. Um teto ali recusaria só movimentos benignos e tornaria permanente o estado que o ADR 0127 nasceu para eliminar. Está escrito ao lado da função e fixado em teste.

Sem limiar, como o teto 2 — e o preço vem junto. A recusa é "a si mesmo", não "abaixo de maintainer", pelo mesmo motivo da RN-472: um limiar copiaria um número do @RequireRole do controller para dentro do domínio e envelheceria calado se a rota mudasse de mínimo. O preço declarado é a auto-remoção de quem tem owner no projeto e maintainer no workspace: reversível, e recusada também. Era o único movimento benigno que passava antes e passa a recusar; segue alcançável por outro maintainer.

A mensagem é PRÓPRIA. Quem clicou "remover" não pediu para mudar papel nenhum, então MENSAGEM_TETO_AUTO_REBAIXAMENTO_POR_REMOCAO diz o MECANISMO — sem a linha de projeto o papel cai para o do workspace, e voltar exige o papel abandonado — em vez de reusar a frase do add, que falaria de um movimento que a pessoa não fez.

A tela não muda, e a recusa aparece. MembersSection.handleRemove já mostra mensagemDaApi(erro, …) num toast (RN-471), então a frase da api chega sem uma linha de web. Diferente do teto 1, este gate É calculável no cliente — mas calcular só ele produziria o meio-gate que o ADR 0127 recusou.

  • Código: apps/api/src/domain/iam/tetos-de-rebaixamento.ts (remocaoEhAutoRebaixamento e MENSAGEM_TETO_AUTO_REBAIXAMENTO_POR_REMOCAO, mais o comentário que diz por que o teto 1 não tem par), apps/api/src/application/use-cases/iam/remove-project-member.use-case.ts (a assinatura com atorId e as duas leituras de papel), apps/api/src/interfaces/http/iam/projects.controller.ts (@CurrentUser() no @Delete(':projectId/members/:userId'), a descrição corrigida e o @ApiForbiddenResponse), apps/api/src/application/use-cases/iam/resolve-effective-role.use-case.ts (INTOCADO — projectRole ?? workspaceRole segue como está)
  • Teste: apps/api/test/application/use-cases/iam/tetos-de-rebaixamento.use-case.spec.ts, bloco "Teto 2 pela outra porta — a remoção da própria linha" (as três recusas; a auto-remoção benigna; remover outra pessoa; e a remoção que devolve o projeto ao owner do workspace). O primeiro deles é o teste de lacuna do ADR 0127 INVERTIDO, com o nome guardando a origem
  • ADR: 0156, que referencia o 0127 e fecha a lacuna que ele declarou
  • Origem: BRB-001, P1 de Segurança do registro do mantenedor — a lacuna que o ADR 0127 escreveu com a frase "quem for fechá-lo vê o teste falhar"

O teto de auto-movimento no upsert de WORKSPACE, e a auto-promoção que fecha (RN-557)​

RN-557 — Mudar o PRÓPRIO papel é recusado com 403 nas duas rotas de associação, nos dois sentidos​

Estendida pela RN-615 (ADR 0173): a rota de remoção de membro de workspace que abaixo se diz inexistente passou a existir, com o MESMO teto (remover a si mesmo é 403) — e é essa cláusula que segura o último owner, sem contar. O raciocínio abaixo sobre o teto 1 continua valendo.

Terceira e última porta da linha aberta pela RN-472 e continuada pela RN-556. O ADR 0127 declarou esta por escrito — "POST workspaces/:workspaceId/members continua um upsert sem teto nenhum — mesma classe de defeito, escopo acima" — e o ADR 0156 a reafirmou como "escopo diferente, decisão separada". É o BRB-002, P1 de segurança.

O defeito, medido. AddWorkspaceMemberUseCase.execute recebia (workspaceId, userId, role) e chamava workspaces.addMember — não recebia o ator —, e o controller (@Post(':workspaceId/members'), @RequireRole('owner')) chamava com o corpo puro, sem @CurrentUser(). Não havia com o que aplicar teto nenhum.

Por que é mais grave um escopo acima. Duas coisas que existem no projeto não existem aqui: (a) não há nível acima para segurar a queda — no projeto, rebaixar-se derruba o efetivo para o papel de workspace, que muitas vezes o segura; no workspace o papel é o papel, e um owner que se grava viewer perde o workspace inteiro, com todos os projetos e a própria capacidade de convidar quem o reponha; (b) não existe rota de remoção de membro — WorkspacesController não tem @Delete de membro (medido, e o ADR 0156 já tinha usado esse fato). Desfazer é esta mesma rota, que pede o owner recém abandonado.

A auto-PROMOÇÃO era brecha, e fecha. O ADR 0127 registrou nas Consequences que ela ficava: "Um maintainer pode se gravar como owner do projeto (…) os tetos são sobre descer". Esta RN revisa essa frase. As duas metades são o mesmo movimento — uma pessoa decidindo sozinha qual autoridade tem — e a de cima é a única que escala privilégio: o ADR 0127 pôde dizer que os tetos dele não eram sobre escalação ("ninguém ganha papel que não tinha"), e sobre a promoção isso é falso.

movimentodesfecho
mudar o próprio papel no PROJETO, para baixo403 — RN-472, inalterada
mudar o próprio papel no PROJETO, para cima403 — era permitido, e é o que muda
mudar o próprio papel no WORKSPACE, em qualquer sentido403 — a porta nova
reescrever o próprio papel com o MESMO valorpassa — upsert idempotente não é movimento
um owner rebaixando OUTRO owner no workspacepassa — é a única forma de revogar propriedade
associar/rebaixar OUTRA pessoa, em qualquer escopopassa — capacidade legítima de sempre
auto-promoção pela REMOÇÃO da linha de projetopassa — protegida pelo ADR 0156, ponto 3

O teto NÃO conta owners. Recusar só quando o chamador for o último dono foi considerado e recusado pelo critério literal do ADR 0127: "se enuncia numa cláusula, não tem número para envelhecer". Uma contagem acrescenta uma leitura que pode sair de fase com a escrita, tem de julgar se um segundo dono inativo conta, e faz a mesma chamada com o mesmo corpo passar hoje e recusar amanhã sem que nada do que o chamador controla tenha mudado. E a cláusula já produz o invariante que a contagem existiria para garantir: um workspace nunca fica sem owner, porque tirar o último exigiria que ele mesmo o fizesse.

O teto 1 não tem par neste escopo, e foi considerado, não espelhado. Diferente da remoção (RN-556), aqui o movimento EXISTE — um owner pode rebaixar outro. A recusa vem de o teto 1 ser regra sobre inversão de hierarquia: no projeto a linha sobrepõe a de workspace (RN-471) e um maintainer alcança quem está ACIMA dele; no workspace o @RequireRole('owner') já garante que ninguém alcança alguém maior que si — a precondição está ausente. Pôr o par, somado à ausência de rota de remoção, faria de owner um estado absorvente: ninguém sairia dele por HTTP, e offboarding, rotação de propriedade e correção de convite errado passariam a exigir escrita direta no banco — a classe de estado que o ADR 0127 nasceu para eliminar, com o sinal trocado.

A régua não é copiada: a comparação vira UM classificador. autoMovimentoDoProprioPapel devolve o SENTIDO ('rebaixamento' | 'promocao' | null) em vez de um booleano, porque quem chama precisa dele para escolher a mensagem. ehAutoRebaixamento continua existindo como uma LEITURA do classificador, e por motivo de comportamento e não de compatibilidade: remocaoEhAutoRebaixamento delega a ela, e alargá-la faria a REMOÇÃO recusar também a auto-promoção — tirar a própria linha que restringia alguém é justamente como se desfaz a restrição que o teto 1 impede de criar, movimento que o ADR 0156 protegeu por escrito.

Quatro mensagens, uma por SENTIDO e por ESCOPO. Quem tentou se promover não recebe a frase de rebaixamento (não foi o que fez), e quem esbarra no teto do workspace não é mandado falar com um maintainer — lá o papel que desfaz é owner. Mesma disciplina do ponto 5 da RN-556 e da RN-470.

O papel do ator vem do repositório, não do ResolveEffectiveRoleUseCase — o inverso da escolha da RN-556, pela mesma razão que ela deu: nos casos de uso de projeto o outro entra para o papel do ator não virar uma segunda composição de projectRole ?? workspaceRole escrita à mão; no workspace não há composição a proteger (forWorkspace É workspaces.findMemberRole), e este caso de uso já tem o repositório na mão.

A metade de PROMOÇÃO no workspace é inalcançável por HTTP, e é aplicada mesmo assim. Com @RequireRole('owner') o ator já está no topo e não há para onde subir. Ela entra e é testada pelo motivo que o teto 2 sempre teve para não ter limiar: o caso de uso não presume o guard, e é ele que fica certo no dia em que a rota mudar de mínimo. Mesmo desenho da recusa por capacidade da RN-514, implementada antes de ter disparo.

Nenhuma linha de web muda, e não é omissão: POST /workspaces/:workspaceId/members não tem chamador em apps/web — medido; a única ocorrência é o tipo gerado em api-types.generated.ts. Não há toast para ajustar nem Select para fechar.

  • Código: apps/api/src/domain/iam/tetos-de-rebaixamento.ts (autoMovimentoDoProprioPapel, o tipo SentidoDoAutoMovimento, as três mensagens novas e o ehAutoRebaixamento que passou a ser leitura do classificador), apps/api/src/application/use-cases/iam/add-workspace-member.use-case.ts (a assinatura com atorId, a leitura do papel do ator e os dois 403), apps/api/src/application/use-cases/iam/add-project-member.use-case.ts (a troca de ehAutoRebaixamento pelo classificador, que é o que fecha a auto-promoção lá), apps/api/src/interfaces/http/iam/workspaces.controller.ts (@CurrentUser(), o @ApiForbiddenResponse e a descrição), apps/api/src/interfaces/http/iam/dto/add-member.dto.ts e apps/api/src/interfaces/http/iam/projects.controller.ts (as descrições que prometiam "sem teto nenhum" e "downgrading yourself"), apps/api/src/db/seed.ts (o owner.id como ator), apps/api/src/application/use-cases/iam/resolve-effective-role.use-case.ts (INTOCADO, terceiro ADR seguido)
  • Teste: apps/api/test/application/use-cases/iam/tetos-de-rebaixamento.use-case.spec.ts, bloco "Teto de auto-movimento no WORKSPACE — a quarta porta" (a recusa com outro dono presente, a recusa do dono único, a promoção inalcançável, a mensagem por escopo, o upsert idempotente, o owner rebaixando outro owner e a associação de terceiros), mais os três casos no bloco do teto 2 — o teste da auto-promoção INVERTIDO, com o nome guardando a origem, a asserção de que cada sentido tem sua frase, e o upsert idempotente no projeto
  • ADR: 0157, que referencia o 0127 e o 0156, e revisa a frase do primeiro sobre a auto-promoção
  • Origem: BRB-002, P1 de Segurança do registro do mantenedor — o último movimento que os dois ADRs anteriores deixaram declarado

A credencial que não atravessa o container do agente local (RN-558)​

RN-558 — Credencial no exec com container ativo é RECUSADA pelo runner, com desfecho nomeado e origem politica​

Lacuna declarada por escrito ao fechar a RN-507/RN-508 (ADR 0145) e medida de novo aqui, elo por elo, antes de qualquer linha ser escrita.

A cadeia, medida. Engine.Runners.RunnerReadiness.verificar/1 (runner_readiness.ex:52) exige container running REGISTRADO antes de QUALQUER operação de Engine.Actions.Workspace.RunnerGit — inclusive o git fetch autenticado inicial, que sai de fetch!/3 (runner_git.ex:226, via exec/4 em :288). A ÚNICA forma de esse registro existir num projeto runner é o MESMO runner ter subido o próprio container (ADR 0137), e é esse mesmo sucesso que marca estado.containerAtivo nele (apps/runner/src/index.ts:822, em tratarContainerStart). Com containerAtivo setado, tratarExec rota o comando para dentro do container (index.ts:728–730, e a recusa nova em :693), e a operação exec de packages/docker-port não tem campo de env — de propósito (ADR 0130: sem -e livre nenhum). O campo env que a RN-507 acrescentou ao par exec/exec_result (RunnerRouter.exec/5, runner_router.ex:50; ExecMessage.env, apps/runner/src/channel.ts:62) só é aplicado no caminho HOST, onde apps/runner/src/exec.ts:94 o MESCLA sobre process.env.

Resultado: no instante em que a RN-507 deixa o fetch autenticado rodar, o container quase sempre já está de pé no MESMO runner — e era aí que a credencial (ADR 0056, GitAuth.env_de_auth/1) era descartada, em silêncio, sem erro. O git rodava com o helper efêmero instalado por -c e as duas variáveis VAZIAS, e saía com falha de autenticação — indistinguível de token inválido, permissão errada ou rede fora. A pessoa caçava a coisa errada, e o produto não dizia nada.

A correção é o desfecho NOMEADO, não a entrega da credencial. O docker exec continua sem campo de env — mexer nisso é mudar a porta de contenção, e é decisão de ADR, não recorte (ver a lacuna declarada abaixo). O que muda é que o par (env presente, container ativo) deixa de ser executado e passa a ser recusado, com marca, mensagem e origem.

Quem recusa é o RUNNER, e só ele pode. É o único processo que sabe as duas metades ao mesmo tempo: que o comando carrega credencial e que ele vai para dentro do container. O engine não sabe a segunda, e isso foi MEDIDO: estado.containerAtivo nasce null a cada execução do runner (index.ts:1449) e só é setado por tratarContainerStart, então um container running REGISTRADO no banco não implica container ativo naquele processo — um runner reiniciado com o container de pé roteia pro HOST, e ali a credencial chega normalmente. Subir a checagem para RunnerReadiness ou para RunnerGit recusaria um caminho que funciona. E RunnerReadiness fica BYTE A BYTE como está: ela não ganha flag nem quarta pré-condição — é a mesma regra que a RN-516 já enunciou sobre não lhe pendurar uma flag "pula container".

A MARCA é constante de PROTOCOLO, não detalhe de mensagem. MARCA_DE_CREDENCIAL_NAO_ENTREGUE (index.ts) e @marca (credencial_de_git.ex) carregam o MESMO token, e é por ele — não pela frase — que o engine reconhece a recusa depois de ela ter atravessado um rescue e virado string solta. Mudar um lado só não quebra compilação de nada: a recusa continua acontecendo e só a CLASSIFICAÇÃO volta a errar, que é a forma exata do defeito que esta RN fecha. Nenhuma das duas suítes alcança a outra linguagem (ExUnit não lê TypeScript, o vitest do runner não lê Elixir), então a guarda mora em scripts/ci/, com os outros testes que leem o repositório inteiro como texto.

A origem é politica, e a escolha aponta AÇÃO. As quatro do ADR 0020 não distribuem culpa, dizem o que fazer. Isto não é cláusula que ninguém escreveu (codigo) nem rede que caiu (infra): é a contenção do ADR 0130 funcionando como desenhada, contra um caso que ela não previu. Quem lê politica sabe que não há bug para caçar — há uma decisão de produto pendente. Engine.Runners.CredencialDeGit.desfecho/1 acrescenta UM caso e não reclassifica nenhum outro: qualquer outra falha de worktree mantém byte a byte {"falha ao preparar o worktree", "codigo"}, nos DOIS dev agents (o real e o Noop, que existe para exercitar ESTE caminho e não uma cópia dele).

A recusa de CONTENÇÃO vence, e a ordem é decisão. Ela entra DEPOIS de validarCwdDentroDaRaiz (guard.ts): um comando apontado para fora da raiz precisa ouvir isso, porque ali há uma fronteira de contenção sendo recusada, enquanto aqui há uma capacidade que falta. Colapsar as duas faria um comando fora do escopo parecer problema de credencial — o mesmo defeito de diagnóstico, com outro sinal.

O env nunca aparece na recusa — só a CONTAGEM. A invariante da RN-507 é que msg.env não vai para log nenhum, e esta saída vai para o event log do produto: a recusa diz "2 variável(is) de ambiente", jamais um nome ou um valor. O número já diz o que se precisa saber ali.

O que NÃO é afetado, confirmado por leitura: repositório local (provider sem token) — GitAuth.env_de_auth/1 devolve [], fetch!/3 converte para nil, o campo nem entra no payload, e a recusa não dispara; os modos container e mounted — nunca passam por RunnerGit (Workspace.ensure!/4 bifurca por execution_mode e só runner vai para lá), e o container deles sobe pelo broker, do outro lado da rede; e comando de terminal comum, que nunca carregou env. workspace_create também carrega env, e também não é afetado: ele roda no HOST (criarPastaDoProjeto), não no container.

  • Código: apps/runner/src/index.ts (MARCA_DE_CREDENCIAL_NAO_ENTREGUE e a recusa em tratarExec, depois da validação de cwd), apps/runner/src/channel.ts (o docblock de ExecMessage.env, que prometia "roda só sem a credencial"), apps/engine/lib/engine/runners/credencial_de_git.ex (novo — a marca, recusada?/1, mensagem/2 e desfecho/1), apps/engine/lib/engine/actions/workspace/runner_git.ex (a cláusula nova em fetch!/3, antes da genérica, e o moduledoc), apps/engine/lib/engine/dev/dev_agent_server.ex e apps/engine/lib/engine/dev/noop_dev_agent_server.ex (o desfecho de {:error, reason} na criação do worktree)
  • Teste: apps/runner/src/index-handlers.spec.ts, bloco "a credencial não atravessa o docker exec (RN-558)" — o caminho feliz (sem container, o env chega ao host e nada é recusado), o caso de falha (com container, recusa nomeada e docker.exec NÃO chamado), a prova negativa de que nome e valor das variáveis não vazam na saída, env vazio não virando recusa, e a recusa de contenção de guard.ts vencendo esta quando o cwd está fora da raiz; apps/engine/test/engine/actions/workspace_runner_test.exs (os dois testes RN-558: a recusa que vira mensagem nomeada com origem politica, e a falha REAL do fetch que mantém a mensagem de sempre com origem codigo — o par negativo que prova que a cláusula nova não sequestrou a antiga); apps/engine/test/engine_web/channels/credencial_no_runner_test.exs (AT-111, a CORRENTE inteira pelo TerminalChannel real: o env chega ao canal só no fetch, a resposta do runner é a fixture apps/runner/fixtures/exec-result-recusa-de-credencial.json — que o spec do runner compara byte a byte com a saída real de tratarExec —, RunnerGit devolve a mensagem nomeada, e o DevAgentServer grava dev.blocked com origem politica; mais o ramo sem container ativo, em que o fetch responde 0, e a falha real, que segue codigo. NÃO prova a ponte entre linguagens por execução: nenhum processo node sobe no ExUnit, e o par que a fecha é a fixture comum. Verificado por mutação: cortar o env no canal, no RunnerGit, o reconhecimento da recusa, o desfecho/1 ou o uso dele no dev agent reprova o teste; desligar a recusa em tratarExec reprova o spec do runner); scripts/ci/marca-de-credencial-do-runner.spec.ts (a marca idêntica nos dois lados). Verificado por mutação: neutralizar a cláusula de fetch!/3 reprova o teste do engine com a mensagem antiga à vista
  • Lacuna DECLARADA, que é a METADE que continua aberta — FECHADA pela RN-676 (ADR 0193, decisão do dono de 01/10): o fetch credenciado vai MARCADO pelo engine e roda no HOST do runner mesmo com container ativo; esta recusa encolheu para o env SEM a marca (na prática, um runner anterior ao ADR 0193). O texto abaixo é o de quando ela fechou a metade do silêncio: a credencial continua não atravessando o docker exec. Clone/fetch de repositório remoto AUTENTICADO em modo runner segue impossível com o container de pé — o que mudou é que agora ele falha DIZENDO isso, em vez de parecer erro de token. Fechar exige decidir COMO uma operação credenciada fala com um docker exec sem campo de env, e as opções conhecidas mexem todas na porta de contenção do ADR 0130 (dar env à operação; um arquivo de credencial montado e apagado; rodar o fetch no host mesmo com container ativo, o que quebraria o invariante de que o trabalho acontece dentro do container). É ADR, nunca correção de passagem. A ADJACÊNCIA que esta entrega também declarou — a recusa acontecia DEPOIS de init_from_bare!/5 já ter feito git init, e o git_dir?/2 de ensure!/5 marcava o workspace pronto na tentativa seguinte por encontrar o .git, de modo que a segunda tentativa falhava adiante no worktree add em vez de repetir a recusa — FECHOU no AT-112: a inicialização que falha (qualquer passo, não só o fetch credenciado) DESFAZ o .git que ela mesma criou (inicializar_ou_desfazer! em Engine.Actions.Workspace e em RunnerGit) e relança a exceção original, então a tentativa seguinte repete a MESMA causa. O desfazer só roda no ramo em que NÃO havia .git — o de "workspace de antes da marca" (que tem .git e não se re-inicializa) segue intacto, então não apaga trabalho de ninguém. Do lado runner o rm -rf é best-effort (o runner pode ter caído, uma das causas da falha). Sinais considerados e recusados: marcador de "em andamento" (segundo arquivo a manter, e órfão se o processo morrer entre os dois passos) e checar a ref esperada (o repositório vazio, sem origin/<branch>, é um estado LEGÍTIMO e cairia como falha)
  • ADR: nenhum novo — a entrega não move fronteira nenhuma. Ela CONSOME o 0130 (a porta sem env), o 0137 (o container na máquina do usuário), o 0145 (a pré-condição e o campo env) e o 0020 (as quatro origens)
  • Origem: AT-053 — a lacuna que o ADR 0145 deixou declarada por escrito

A primeira conta de uma instalação nasce no terminal (RN-546)​

RN-546 — Rota interna cria a PRIMEIRA conta, já verificada, e recusa quando existe qualquer usuário​

Sessão 5 da FASE 30, decidida pelo ADR 0155. É a metade de api do passo que fecha a instalação de uma linha; o install.sh que consome esta rota é a sessão 6, e ele chegou na RN-547.

O buraco, medido. Numa instalação nova ninguém consegue entrar. O .env que o instalador gera não tem nenhuma variável de e-mail, MAIL_TRANSPORT cai no default log (docker/docker-compose.install.yml), e o registro normal exige verificar e-mail — que por um canal desligado não fecha. A instalação termina dizendo "Pronto" e a única saída é pescar o link em docker compose logs api.

A rota. POST /internal/first-account, engine-service — autenticada pelo BRABO_SERVICE_TOKEN, no mesmo cabeçalho e com a mesma comparação em tempo constante das outras rotas internas. Corpo { email, senha, nome? }, resposta { userId, email, workspaceId }, 201.

Por que interna, e nunca pública. Uma rota pública de "criar o primeiro owner" é uma corrida entre quem instalou e quem escaneou a porta, e quem perde a corrida perde a instalação: owner do primeiro workspace não é papel que se recupere por HTTP. O service token fecha a corrida no lugar certo — ele é gerado pelo próprio install.sh e escrito no .env com modo 600, então apresentá-lo prova controle da máquina, que é a credencial que este passo realmente quer.

A conta nasce VERIFICADA, e é a única coisa que esta rota afrouxa. O que a verificação de e-mail prova é "esta pessoa controla esta caixa"; quem roda o instalador já provou algo mais forte — a máquina, o .env com os cinco segredos e o daemon do Docker. Exigir dela a prova mais fraca, por um canal que a instalação sabe estar desligado, é teatro. O registro normal fica byte a byte: o ADR cria um caminho para um caso nomeado, não mexe no outro.

A recusa é sobre a INSTALAÇÃO, não sobre o e-mail. UserRepository.existeAlgumUsuario() (SELECT 1 … LIMIT 1, nunca count(*)), e 409 havendo qualquer usuário. A pergunta é sobre users e não sobre auth_credentials, porque conta provisionada por login social (RN-278) nasce sem credencial e perguntar pela credencial diria "não há ninguém" numa instalação povoada. Herdar a idempotência por e-mail de provisionarUsuario transformaria a rota num criador de contas com nome enganoso — bastaria variar o endereço. Numa migração (RN-530) os usuários vêm no restore e o passo se cala pelo mesmo critério.

Workspace pessoal na MESMA transação (RN-410). Este é o TERCEIRO ponto de criação de conta, e ele não ganha regra de nome/slug própria: chama a mesma nomeESlugDoWorkspacePessoal dos outros dois. Sem isso a instalação fecharia com um login que atravessa e um dashboard onde "Novo projeto" não tem onde criar — a metade do buraco que esta fase veio tapar, não a outra.

A extração, e por que não BRABO_FORCE_SEED. provisionarUsuario recusa rodar com NODE_ENV=production, e o instalador roda exatamente em produção. O que a recusa protege está escrito no docblock dela: senha conhecida criada sem interação humana. A rota é outra categoria — a senha é escolhida por um humano no TTY, lida sem eco e confirmada, atrás do service token, e só quando não há usuário nenhum. Então o núcleo (o trio de escritas que faz a conta nascer verificada) virou ProvisionarUsuarioUseCase, e a recusa ficou no script, que é quem ela protege. Mandar o instalador se declarar "seed forçado" o faria atravessar uma regra que nunca falou dele, e afrouxaria a recusa justamente onde ela vale.

Uma régua de senha, não duas. exigirSenhaValida do domínio, chamada pelo MESMO código que o registro chama — e por isso o DTO desta rota não repete um @MinLength: a chamada cobre as CINCO recusas da política, não só o comprimento. O @MaxLength fica, porque não é política: é proteção de argon2id, que copia a entrada antes de derivar. PoliticaDeSenhaError é Error de domínio e não tem filtro global, então o caso de uso traduz para 400 — sem isso o instalador leria "erro do servidor" onde a resposta certa é "escolha outra senha". Traduzir ali não toca o registro normal.

Kind próprio na trilha de auth. first_account_created, e não register_created: a conta nasce verificada sem e-mail nenhum ter sido enviado, e quem audita depois precisa ver isso escrito, não deduzir pela ausência de um email_verified ao lado. União fechada no TypeScript, coluna text no banco — kind novo não custa migração.

Sem GET. "Existe algum usuário?" é pergunta que ninguém precisa fazer de fora: o POST responde 201 ou 409, e o 409 já é a resposta. Uma rota de leitura publicaria o mesmo fato com uma superfície a mais, e a resposta dela seria o sinal exato que um scanner quer.

A superfície nova está declarada, não escondida. Passa a existir um segundo caminho de criação de usuário que funciona em PRODUÇÃO. Ele é estreito em três contagens independentes (primeira conta, rota interna, service token) e some assim que a instalação tem gente. Está no security-surface.md, nas Consequences do ADR e aqui.

Uma janela declarada, não fechada. A checagem de "existe algum usuário" roda DENTRO da transação, mas READ COMMITTED não impede duas chamadas concorrentes com e-mails diferentes de passarem juntas. Fechá-la exigiria lock consultivo sobre a ausência de linhas, e não é onde a contenção mora: quem alcança a rota já tem o service token, ou seja, já controla a instalação inteira. O que a condição impede é a rota VIRAR superfície permanente de criação de conta, e isso ela impede.

O que NÃO entrou, por decisão do ADR: rota pública de primeiro owner; senha gerada pelo código ou default (a senha nunca é gravada — nem no .env, nem no marcador, nem em log); SMTP ligado sozinho (MAIL_TRANSPORT=log continua o default declarado); credencial de LLM (é de quem vai gastar, RN-058); e qualquer mudança no registro normal.

  • Código: apps/api/src/application/use-cases/auth/criar-primeira-conta.use-case.ts (a recusa por instalação povoada, a régua de senha, o workspace pessoal e o evento), apps/api/src/application/use-cases/auth/provisionar-usuario.use-case.ts (o núcleo extraído, sem a recusa de produção), apps/api/src/scripts/provisionar-usuario.ts (só a recusa, mais a delegação), apps/api/src/interfaces/http/internal/internal-first-account.controller.ts, apps/api/src/interfaces/http/internal/dto/first-account-internal.dto.ts e …/first-account-internal.response.dto.ts, apps/api/src/application/ports/user-repository.port.ts e apps/api/src/infrastructure/persistence/drizzle/user.repository.ts (existeAlgumUsuario), apps/api/src/domain/auth/auth-event.ts (first_account_created), apps/api/src/domain/auth/password-policy.ts (INTOCADO — é a régua reusada)
  • Teste: apps/api/test/application/use-cases/auth/criar-primeira-conta.use-case.spec.ts (caminho feliz com conta verificada e workspace pessoal, o kind próprio, o 409 com instalação povoada, o 400 por comprimento e o 400 por motivo que NÃO é comprimento, e o fallback de nome); apps/api/test/interfaces/http/internal/internal-first-account.controller.spec.ts (o repasse ao caso de uso, o nome ausente virando null, e as três recusas do guard: cabeçalho ausente, token errado, token certo); apps/api/test/scripts/provisionar-usuario.spec.ts (a recusa de produção FICOU no script, e a delegação); apps/api/test/interfaces/route-surface.spec.ts (a rota classificada)
  • ADR: 0155, que depende do 0154

A chave de MÁQUINA que o instalador registra (RN-552)​

RN-552 — Rota interna registra a chave de dispositivo de MÁQUINA, para o usuário ÚNICO da instalação, e registrar SUBSTITUI​

Sessão 7 da FASE 30, fechando o ponto 4 do ADR 0155 sobre a espécie de chave que o ADR 0154 criou. É a lacuna que a RN-543 declarou por escrito e deixou aberta de propósito, pela régua do DEPLOY_ENABLED: "nenhuma rota da api cria chave de máquina ainda; isso é o install.sh, e nasce com o primeiro chamador". O chamador chegou — é o instalador da sessão 6 —, então a rota nasce.

A rota. POST /internal/machine-device-keys, engine-service. Corpo { name, publicKeyJwk }, resposta 201 com { id, userId, name, createdAt, replacedKeyIds }. Sem GET e sem DELETE.

A credencial é o SERVICE TOKEN, e a alternativa foi recusada por motivo escrito. É o mesmo instalador, no mesmo minuto, no passo seguinte ao da RN-546: o BRABO_SERVICE_TOKEN foi gerado pelo próprio install.sh e escrito no .env com modo 600, então apresentá-lo prova controle da máquina — que é exatamente o que este passo quer provar. A candidata real do outro lado era a credencial do usuário recém-criado, e ela contraria o próprio ADR 0155: a senha é lida no TTY, "usada e descartada", e o ponto 5 declara que "o instalador não faz login por ninguém", razão pela qual a resposta da primeira conta não devolve token de sessão. Exigi-la aqui obrigaria o instalador a criar, na máquina, a sessão viva que aquele ADR recusou criar.

O que essa escolha custa, declarado e não escondido. Um BRABO_SERVICE_TOKEN vazado passa a poder fabricar uma credencial de acesso duradoura de um usuário — não só falar com as rotas internas. Está dito aqui, no security-surface.md e no docblock do controller, ao lado da declaração irmã da primeira conta. O que a chave concede não aumentou: ela dá ao agente local exatamente os projetos que o dono dela já alcança em developer, resolvidos contra o projeto pedido (RN-543).

A rota não sabe escolher de quem é a chave. Não há userId no corpo. O dono é o usuário ÚNICO da instalação, resolvido por UserRepository.usuarioUnicoDaInstalacao() (LIMIT 2, nunca LIMIT 1: com uma linha seria impossível distinguir "há exatamente um" de "há muitos"); com zero ou mais de um, 409 e nada é escrito. É o análogo exato da condição da RN-546 — sobre a INSTALAÇÃO, nunca sobre o argumento pedido —, e é o que impede que quem tenha o token escolha a vítima. Consequência declarada: numa instalação com duas pessoas a rota não funciona mais, para ninguém. Ela se cala como a da primeira conta se cala, e pelo mesmo motivo — pertence ao MOMENTO da instalação, não à vida do produto.

Registrar SUBSTITUI, nunca acumula. Toda chave de MÁQUINA ativa do dono é revogada na MESMA transação, e os ids voltam em replacedKeyIds. Uma máquina reinstalada é caso legítimo e continua passando; mil chaves de máquina viram impossíveis — e não por um número que envelhece, mas porque o conjunto de chaves de máquina vivas de um usuário nunca passa de uma. A revogação vem ANTES do registro: revogar depois deixaria uma janela com as duas vivas, e falhar no meio deixaria a instalação com duas credenciais de máquina e nenhuma forma de saber qual é a nova. Só as de MÁQUINA caem (project_id IS NULL): as de PROJETO vieram do navegador (ADR 0118), num fluxo que esta rota não conhece, e derrubá-las apagaria o pareamento de quem já usa o produto — o erro que a RN-545 nomeou ao recusar converter instalação alheia em silêncio.

A resposta devolve o id porque ele vira o kid. É a lição da RN-475, a mais cara desta área: o id do registro vai gravado DENTRO da JWK privada, no kid, e a cadeia inteira só o REPASSA — o agente local lê jwk.kid, o JWT de ticket o leva no header, o PatAuthGuard acha a pública por ele. Ninguém deriva esse id de outra coisa, e foi por ele faltar que o modo automático do navegador nunca autenticou. O userId também volta, RESOLVIDO pela api: quem chama não o manda, e devolvê-lo deixa o instalador conferir contra a conta que acabou de criar em vez de supor.

A privada nunca viaja, e uma que chegue é RECUSADA. O par é gerado na máquina e só a metade pública sobe — mesmo desenho do navegador, pelo mesmo motivo, mantendo verdadeira a frase que a RN-519 usa. A validação de forma virou régua ÚNICA no domínio (exigirJwkPublicaEd25519), chamada pelos DOIS registradores: duas cópias divergiriam no primeiro dia em que uma delas mudasse, como a política de senha da RN-546 já resolve. E ela ganhou um caso que nenhuma das duas tinha: JWK com d responde 400 dizendo o que chegou. d é a metade privada de uma JWK OKP (RFC 8037 §2) e só chega por engano de quem serializou o par inteiro — antes, ela passava na checagem (tem kty, crv e x como qualquer pública) e era gravada. Recusar é a resposta; a mensagem nomeia o que veio, porque um "JWK inválida" genérico faria a pessoa tentar de novo com o mesmo arquivo.

Kind próprio na trilha de auth. machine_device_key_registered: esta é a única credencial duradoura de um usuário que nasce sem esse usuário autenticar nada — quem prova é o controle da máquina. Numa suspeita de vazamento do service token, é essa linha que diz quando e para quem ela foi criada, e o metadata carrega os ids que ela substituiu, nunca a JWK nem o nome.

Sem GET e sem DELETE, e o que isso custa. Listar por aqui publicaria, para quem só tem o token da máquina, o inventário de credenciais de uma pessoa; revogar já existe onde tem dono humano (DELETE /projects/:projectId/runner-device-keys/:deviceKeyId, que casa por {id, usuário} e nunca por projeto). A assimetria tem preço, e ele está declarado: numa instalação que ainda não tem projeto, tela nenhuma alcança uma chave de máquina — e é justamente por isso que registrar SUBSTITUI em vez de deixar órfãs para trás, que seriam vivas e inalcançáveis.

O que NÃO entrou: rota pública (mesma corrida da RN-546); userId no corpo; chave de máquina para instalação com time (declarado acima como lacuna, não como acaso); tabela nova (uma chave de máquina continua sendo uma linha de runner_device_keys com project_id NULL, ADR 0154 ponto 1) — e nenhuma migration, porque a coluna já é nullable desde a RN-543.

  • Código: apps/api/src/interfaces/http/internal/internal-machine-device-keys.controller.ts:87 (o @Post() da rota, e o docblock com a escolha de credencial), apps/api/src/application/use-cases/auth/registrar-chave-de-maquina.use-case.ts:122 (o dono resolvido), :124 (o 409), :136 (a substituição antes do registro) e :146 (projectId: null, o que faz dela uma chave de máquina), apps/api/src/domain/auth/jwk-de-dispositivo.ts:33 (a régua única) e :59 (a recusa da privada), apps/api/src/infrastructure/persistence/drizzle/user.repository.ts:31 (usuarioUnicoDaInstalacao, LIMIT 2), apps/api/src/infrastructure/persistence/drizzle/runner-device-key.repository.ts:148 (revogarChavesDeMaquina, os dois IS NULL), apps/api/src/domain/auth/auth-event.ts (machine_device_key_registered), apps/api/src/interfaces/http/internal/dto/machine-device-key-internal.dto.ts e …/machine-device-key-internal.response.dto.ts
  • Teste: apps/api/test/application/use-cases/auth/registrar-chave-de-maquina.use-case.spec.ts (caminho feliz com projectId nulo e dono resolvido; o 409 sem usuário único, sem escrever nada; a substituição ANTES do registro; a privada recusada antes de abrir transação; a trilha sem a JWK); apps/api/test/domain/auth/jwk-de-dispositivo.spec.ts (a régua única, com o d e a precedência da curva); apps/api/test/interfaces/http/internal/internal-machine-device-keys.controller.spec.ts (o contrato da resposta, o corpo SEM dono, e as três do guard); apps/api/test/infrastructure/persistence/drizzle/user.repository.spec.ts (um usuário devolve; dois devolvem null, nunca "o primeiro"); apps/api/test/infrastructure/persistence/drizzle/runner-device-key.repository.spec.ts (a substituição contra o Postgres de verdade, e o que ela NÃO derruba); apps/api/test/interfaces/route-surface.spec.ts (a rota classificada)
  • ADR: 0155 ponto 4, sobre a espécie criada pelo 0154

A unit de MÁQUINA, convivendo com as por projeto (RN-545)​

RN-545 — service install --machine instala UMA unit para a máquina, e as duas espécies não se sobrepõem​

A RN-544 fez o agente de máquina existir como PROCESSO — N conexões, uma por projeto descoberto pela rota — e declarou por escrito o que faltava: "a unit continua sendo POR PROJETO; este PR muda o PROCESSO, não o serviço". Esta RN é a outra metade, e é o quinto e último dos cinco acoplamentos que o ADR 0154 mediu (apps/runner/src/servico.ts:264,391). Os outros quatro já estão fechados ou declarados intocáveis: os itens 1, 2 e 3 descrevem uma CONEXÃO e N conexões os satisfazem byte a byte, e o item 4 fechou na RN-543.

O nome da unit de máquina não pode colidir com projectId nenhum, e não colide por construção. brabo-runner.service / dev.brabo.runner, sem sufixo, contra brabo-runner-<projectId>.service / dev.brabo.runner.<projectId>. PROJECT_ID_VALIDO exige ao menos um caractere, então o nome de projeto sempre tem um - (ou um . de separação, no launchd) onde o de máquina termina — os dois nunca produzem a mesma string. É travado por teste, e não por leitura da regex.

A camada de plataforma passa a ser chaveada por ESPÉCIE, não por projectId. Seis dos oito membros de PlataformaDeServico recebiam projectId: string; eles passam a receber AlvoDaUnidade, um tipo discriminado ({especie:'projeto', projectId} | {especie:'maquina'}) — e nunca projectId: string | null, porque com o nulo cada membro decidiria sozinho o que fazer com ele e o compilador não cobraria nenhum. PlanoDeInstalacao vira união pelo mesmo motivo: os três campos do plano de projeto perdem sentido na máquina, onde não há projeto, não há --dir (cada raiz sai da base) e a api não pode vir de um arquivo que é por projeto. caminhoDaUnidade DESCEU de membro para função, porque as duas plataformas respondiam a mesma coisa — pasta mais nome — e manter duas implementações idênticas era a chance de elas divergirem quando a segunda espécie chegasse.

O discriminador é a flag --machine, e NUNCA a ausência de --project. O ADR 0154 ponto 5 escreveu "service install sem --project instala a unit de máquina", e isso foi medido nesta sessão e não pôde ser implementado ao pé da letra: resolverProjeto tem DUAS fontes, e o caminho normal de hoje é rodar install sem flag nenhuma de dentro da pasta que o navegador configurou — é o brabo-runner.config.json que responde. Tratar "sem --project" como "máquina" converteria em silêncio a instalação de quem já usa o produto, que é exatamente o que o ponto 5 do mesmo ADR promete não fazer. Então a espécie nova é opt-in explícito, as duas fontes de projeto ficam byte a byte, e --machine junto de --project é recusa nomeada em vez de precedência inventada.

install --machine recusa mais que a metade de projeto, e as duas recusas próprias são medidas. A primeira: a pasta não pode ter brabo-runner.config.json. lerArgumentos resolve o projectId por esse arquivo quando --project falta, e o modo de máquina é justamente "sem projectId" — uma unit de máquina cujo WorkingDirectory tenha esse arquivo subiria o processo em modo de PROJETO, em silêncio, atendendo UM projeto e chamando-se agente da máquina. A segunda: sem base consentida (RN-529) o agente de máquina sai em uso() no primeiro boot (RN-544), e gravar um serviço que nunca sobe é a "instalação errada de pé" que este módulo recusa em toda parte — a base entra INJETADA (DependenciasDeInstalacao.resolverBase), como as outras quatro dependências, para que servico.ts continue sem ler disco nem ambiente por conta própria.

A unit congela XDG_CONFIG_HOME, e nunca o VALOR da base. É a mesma decisão do PATH da RN-518, com uma consequência maior: é essa variável que decide ONDE o arquivo da base mora (base.ts), e systemd --user não repassa o ambiente do shell de quem instalou — sem ela, um usuário com a variável apontada para fora de ~/.config teria o serviço lendo a base errada, ou nenhuma. O valor da base fica fora de propósito: trocá-la continua sendo editar o arquivo e reiniciar, nunca reinstalar, que é o que o docblock de base.ts já defendia ao rejeitar "só a flag --base".

A apiUrl da unit de máquina lê BRABO_API_URL, e a de projeto não. A precedência do plano de PROJETO fica byte a byte (--api-url › config daquela pasta › default), porque lá existe um arquivo respondendo. No de máquina esse arquivo é por projeto e não serve, então sem a variável a única alternativa a --api-url seria o default localhost — instalar de um shell com BRABO_API_URL posta geraria em silêncio uma unit apontando para o lugar errado. Ela entra no ContextoDoServico (apiUrlDoAmbiente), nunca lida diretamente do process.env.

install RECUSA quando a outra espécie já está instalada, e nada é removido. O agente de máquina atende TODO projeto em modo runner do dono da chave; somar a ele uma unit por projeto (ou o contrário) põe dois processos disputando o mesmo terminal:<projectId>, e o servidor nega um dos dois — provavelmente o que a pessoa acabou de instalar, que é a pior ordem possível para descobrir o problema. É recusa pelo MESMO critério da recusa de root que já mora nesse arquivo: "instalar e avisar deixaria a instalação errada de pé, e a mensagem seria lida uma vez só". A unit que já existe fica intacta, a mensagem nomeia o uninstall de cada uma, e não há --force — uma flag que derruba uma guarda cujo sintoma é silencioso devolve o sintoma silencioso. Isto NÃO contradiz o ponto 5 do ADR: o que ele garante é que unit por projeto já instalada continua funcionando, e ela continua, byte a byte; o que se recusa é criar a sobreposição AGORA.

status responde sobre a espécie perguntada, e os quatro estados não viram oito. O código de saída continua sendo o da unit PERGUNTADA (RN-088) — somar as duas apagaria qual delas está pedindo atenção, do mesmo jeito que somar as cinco filas de decisão apagaria qual fila é qual. A coexistência aparece em TEXTO, e é resposta do DISCO: as units da outra espécie são listadas pelo nome, sem perguntar o estado de cada uma ao gerenciador, e a saída DIZ que não perguntou. É o ponto 5 do ADR 0154 cumprido — "deixar seis processos disputando três projetos em silêncio" é o defeito que esse bloco existe para impedir. Sem --machine e sem projeto resolvível, o principal é a unit de máquina (a única cujo nome não precisa de argumento) e a saída nomeia --project para quem queria a outra; antes desta RN a mesma entrada respondia "precisa saber QUAL projeto", que é uma pergunta e não uma resposta para quem tem exatamente a instalação que o instalador produz.

uninstall NÃO herda esse default, e a assimetria é deliberada. Ler a espécie errada custa uma linha errada; remover a espécie errada custa um serviço e uma chave de dispositivo. Sem --machine e sem projeto resolvível ele RECUSA, listando o que existe no disco com o comando exato de cada um — e nunca remove as duas, que seria remover mais do que foi pedido.

Restart=on-abnormal fica byte a byte, nas duas espécies. Exit 1 é recusa fatal de join ou teto de tentativas esgotado, e reiniciar seria o laço que o CLI recusa fazer sozinho (RN-514). Mais: a RN-544 fez lista vazia sair com 0 justamente para que on-abnormal não reerga o serviço numa instalação nova, onde não há o que atender — trocar por on-failure desfaria as duas decisões de uma vez.

  • Código: apps/runner/src/servico.ts (AlvoDaUnidade, ALVO_DE_MAQUINA, alvoDeProjeto, resolverEspecie, PlanoDeInstalacao como união, caminhoDaUnidade/projetosInstalados/maquinaInstalada, prepararPlanoDeProjeto/prepararPlanoDeMaquina, recusaDeSobreposicao, linhasDoQueExiste/linhasDeCoexistencia, ContextoDoServico.apiUrlDoAmbiente, DependenciasDeInstalacao.resolverBase, SistemaDeServico.listarPasta); apps/runner/src/servico-sistema.ts (listarPasta sobre readdirSync); apps/runner/src/index.ts (o adaptador de resolverBase sobre resolverBaseConsentida, apiUrlDoAmbiente, o texto de uso)
  • Teste: apps/runner/src/servico.spec.ts — service install --machine (a unit sem --project/--dir, a ausência de colisão de nome, o congelamento de XDG_CONFIG_HOME sem o valor da base, o Label e o log do launchd, o BRABO_API_URL) e as cinco recusas próprias (config de projeto na pasta, sem base, base inválida, sem chave, --machine com --project); e as duas espécies não se sobrepondo (recusa nos DOIS sentidos sem remover nada, o status de cada lado dizendo da outra sem somar código nem perguntar ao gerenciador, o status sem argumento caindo na máquina, o uninstall --machine deixando a de projeto intacta, e o uninstall sem espécie recusando e listando as duas)
  • Lacuna DECLARADA: o install --machine não sabe se a chave daquela pasta é mesmo de máquina — em disco as duas espécies são o mesmo arquivo, uma JWK com kid (RN-475), e quem sabe é o SERVIDOR (RN-544). Uma pasta com chave de PROJETO instala a unit sem erro, e a recusa aparece só no primeiro boot, como CredencialNaoEDeMaquinaError. Verificar no install exigiria uma chamada de rede num subcomando que hoje não faz nenhuma, e inventar um palpite local produziria a segunda fonte de verdade que a RN-544 recusou. E, à época desta RN, ninguém CRIAVA chave de máquina: quem registra é o install.sh (ADR 0155 ponto 4), na sessão 6 — esta sessão entrega o subcomando que ele vai chamar, e a rota que ele chama chegou na RN-552
  • ADR: 0154
  • Origem: FASE 30, sessão 4 —

A tela reconhece um agente local de MÁQUINA já pareado (RN-548, FASE 30)​

RN-548 — Com chave de MÁQUINA ativa, o painel do runner para de mandar parear e passa a dizer o que falta — sem nunca afirmar que o agente está de pé​

Sessão 7 da FASE 30, decidida pelo ADR 0154. É a metade de WEB da identidade de máquina: a RN-543 fez a listagem de chaves marcar a ESPÉCIE (projeto | maquina) "para que uma chave de máquina não ficasse invisível em toda tela", e ficou sem consumidor. Esta RN é o consumidor.

O defeito, medido. RunnerOnboardingPanel é o painel que os TRÊS pontos de onboarding montam (TerminalPanel, FolderBrowserModal e o passo workspace do NewProjectWizard), e ele tinha uma pergunta só: "configure a pasta". Com a chave de máquina do ADR 0154, essa pergunta passa a estar errada no caso mais comum — a máquina já foi pareada, o que falta é o agente estar rodando. Mandar a pessoa repetir o pareamento que a máquina já tem é o painel respondendo à pergunta errada, com um fluxo que termina no terminal dela.

Sete estados, e nenhum vira o outro (RN-088/RN-470). semPapel (não perguntamos), verificando (perguntamos e não voltou), naoSei (a pergunta falhou), semChaveDeMaquina (voltou e não há), revogada (houve e foi revogada), pareadaNuncaUsada (há chave ativa que agente nenhum usou — a ÓRFÃ da RN-519 renascida na espécie nova) e pareada. "Não sei" nunca vira "não tem", e os dois vazios têm textos diferentes porque o gesto é diferente: um pede parear, o outro explica por que o agente parou de conectar. A falha é derivada de isError e não de haver status — erro de rede não tem status, e derivá-la do status deixaria um fetch recusado girando em "verificando…" para sempre, que é mentira por omissão depois de dez segundos. semChaveDeMaquina é o único que renderiza NADA, e de propósito: o painel inteiro já É a resposta para "nenhuma máquina pareada".

Reconhecer NÃO é dizer que o agente está de pé, e a régua é a de sempre. workspaceVerifiedAt é registro de uma confirmação e não batimento (RN-468); chave registrada é a mesma coisa um passo antes — ela prova que a máquina foi pareada um dia, nunca que há processo vivo agora. Uma tela que confundisse as duas seria PIOR que a de hoje, porque a de hoje ao menos não mente. Por isso o tom do alerta é accent e nunca success (verde leria como "está de pé" — a mesma aritmética que AmbienteDoProjeto faz na linha do runner), a ressalva é dita em texto ao lado, e quem responde pelo AGORA continua sendo a EsperaDoRunner (RN-474), REUSADA e não reescrita: reconhecida a máquina, o que falta é o agente CONECTAR, que é exatamente a pergunta dela.

E há um segundo limite, que o dado impõe e nenhuma redação apaga: a lista é da CONTA, não deste navegador. runner_device_keys não sabe de que máquina o navegador está falando, então o mais forte que a tela pode afirmar é "sua conta tem uma máquina pareada" — nunca "esta máquina está pareada". É por isso que o fluxo do navegador (ADR 0118) não é removido: ele muda de LUGAR, para um <details> cujo rótulo nomeia o único caso em que ainda é a resposta ("estou em outra máquina"). Aposentá-lo é o BRB-031, decisão do mantenedor, e não consequência desta entrega.

A ESPÉCIE aparece, e o que aparece é o CUSTO dela. O painel não lista chaves nem oferece revogação — isso é a tela que o web nunca ganhou, a metade aberta da RN-519, e é frente própria. O que ele diz é o que a espécie significa para quem lê: uma chave de máquina atende TODOS os projetos do dono em modo runner, e revogá-la derruba o agente local em todos eles (RN-520, cujo alcance {projeto, usuário} não muda aqui). Sem essa frase, a marca da RN-543 seria decoração.

O papel é o do ENDPOINT, e a api é a autoridade. O mínimo sai de roleAtLeast (RN-102) contra developer, o que RunnerDeviceKeysController exige nas três rotas — a tela deixa de perguntar o que a api negaria, e diz por quê UMA vez em TEXTO (title em elemento disabled não abre no Chromium). Isso não é fronteira de segurança: quem recusa é o RolesGuard. O 403 de verdade cai no MESMO estado, e não é redundância — o papel lido é o de WORKSPACE e quem autoriza é o EFETIVO do projeto (RN-471), uma SOBREPOSIÇÃO nos dois sentidos: o proxy evita a pergunta obviamente perdida, a api corrige o proxy.

Onde aparece, e por que não é igual nos três montadores. O reconhecimento é por PROJETO, porque a rota é GET /projects/:projectId/runner-device-keys — existe em TerminalPanel e FolderBrowserModal, que sempre têm projeto, e no NewProjectWizard só DEPOIS da criação antecipada (RN-437). Sem projectId não há a quem perguntar e o painel fica byte a byte como era. A diferença não é escolha de desenho: é a forma do endpoint.

  • Código: apps/web/src/lib/agente-de-maquina.ts (a derivação inteira — reconhecerAgenteDeMaquina, podeLerChavesDeDispositivo, maquinaJaPareada), apps/web/src/components/RunnerOnboardingPanel.tsx:173 (as duas consultas e o ReconhecimentoDeMaquina), apps/web/src/components/RunnerOnboardingPanel.module.css (.reconhecimento, .gesto), apps/web/src/lib/api-client.ts:442 (listRunnerDeviceKeys), apps/web/src/lib/api-types.ts:791 (RunnerDeviceKeyListItem, RunnerDeviceKeyEspecie), apps/web/src/locales/{en,pt-BR}/terminal.json (agenteDeMaquina.*)
  • Teste: apps/web/src/lib/agente-de-maquina.test.ts — o caminho feliz (ativa e usada, com o uso MAIS RECENTE entre N) e os sete estados sem colapso, inclusive a falha SEM status e a chave de PROJETO que NÃO conta; apps/web/src/components/RunnerOnboardingPanel.test.tsx — o painel anunciando o pareamento com o gesto do serviço e o fluxo do ADR 0118 recolhido e ainda alcançável, as duas ressalvas ("não é agente rodando", "é da sua CONTA") mais a do alcance, a chave REVOGADA voltando a mandar parear, a consulta falhada dizendo que não sabe, o papel abaixo de developer sem chamar a rota, e o wizard sem projectId sem a quem perguntar
  • Lacuna DECLARADA, FECHADA depois pela RN-552: quando esta RN fechou, ninguém CRIAVA chave de máquina (era o install.sh, ADR 0155 ponto 4, sessão 6), então esta tela só era exercitável com uma chave registrada à mão — a mesma metade que as RN-543 e RN-544 já declaravam. O comando que o painel oferece é brabo-runner service status --project <id>, a forma que existe HOJE; a forma por máquina chega com a unit da sessão 4 — FECHADO depois (AT-106): reconhecida a chave de MÁQUINA, o painel oferece brabo-runner service status --machine (a unit de máquina da RN-545), e o bloco da chave de PROJETO segue oferecendo --project <id>; as duas espécies ativas mostram cada uma o seu. O comando continua sendo para a pessoa PERGUNTAR — a tela não afirma a resposta (chaves agenteDeMaquina.comandoDeServico e chaveDeProjeto.comandoDeServico, testes em RunnerOnboardingPanel.test.tsx nos dois ramos e no par). Uma chave de PROJETO ativa NÃO muda o painel: é o defeito irmão, um escopo abaixo, e fechá-lo é decisão à parte — FECHADO depois (AT-107): o painel reconhece chave de PROJETO ativa DESTE projeto (reconhecerChaveDeProjeto, lib/agente-de-maquina.ts, mesma listagem e mesma queryKey, nenhuma rota nova), com o mesmo vocabulário — tom accent, chave registrada não é agente de pé, o gatilho é conferir o serviço — e o custo da ESPÉCIE dito: revogar derruba o agente NESTE projeto, não em todos. Chave de outro projeto e revogada não contam, e o <details> do ADR 0118 continua alcançável. E o web continua sem TELA onde listar ou revogar chave de dispositivo (a metade aberta da RN-519) — esta entrega consome a listagem e não a constrói. FECHADA depois pela RN-561, que a constrói: a seção de Configurações reusa podeLerChavesDeDispositivo daqui e a MESMA queryKey, e por isso revogar por lá invalida o reconhecimento deste painel
  • ADR: 0154
  • Origem: FASE 30, sessão 7 — o recorte da fase

O agente de máquina espera o primeiro projeto, e a chave nasce no terminal (RN-550/551, FASE 30)​

RN-550 — Com ZERO conexões o agente local NÃO sai: ele espera e reconsulta a lista; com conexão viva a consulta continua sendo só no start​

Sessão 8 da FASE 30. A RN-544 fez lista vazia ser estado NORMAL e o processo sair com 0, dizendo "crie um projeto em modo Runner e suba o agente de novo — a lista é consultada só no start". Isso contradizia, por escrito, o ADR 0155 ponto 5 — "ele espera… quando a pessoa criar o primeiro projeto na web, o agente já está lá" — e quebrava exatamente o caso que a fase persegue: a instalação nova, que é a instalação com ZERO projetos. O instalador subia o agente, o agente descobria que não havia nada e ia embora, e a pessoa que criasse o primeiro projeto minutos depois voltaria ao terminal sem nunca ter sido avisada disso.

A regra é ASSIMÉTRICA, e a assimetria é o desenho:

conexões > 0 → a lista é consultada SÓ no start (a RN-544 fica byte a byte)
conexões = 0 → reconsulta, com cadência declarada

E ela NÃO desfaz a decisão da RN-544. O argumento daquela sessão continua inteiro: uma lista que volta MENOR é ambígua — projeto apagado, convertido de modo, papel revogado, ou um 500 transitório se disfarçando dos três —, e derrubar uma conexão VIVA por causa dessa ambiguidade trocaria um estado certo por um palpite. Com zero conexões não há nada a derrubar e não há ambiguidade nenhuma: qualquer projeto que apareça é ganho puro, e nenhuma decisão é tomada sobre o que sumiu. A reconsulta PARA no instante em que a primeira lista não-vazia chega, e daí em diante o comportamento é byte a byte o de hoje — o módulo da espera não é chamado de novo enquanto o processo viver.

O argumento do exit 0 caiu porque a premissa dele caiu. A RN-544 escreveu que "ficar de pé com zero conexões seria um serviço 'ativo' que não faz nada, e o status da unit passaria a mentir" — e isso dependia de o processo não fazer nada. É essa premissa que esta RN remove: ele reconsulta, com cadência declarada, e DIZ que está fazendo isso. active (running) passa a ser verdadeiro, e quem mentia era a saída com 0, que deixava a instalação nova sem agente justamente no minuto em que a pessoa ia criar o primeiro projeto.

A cadência escalona e ESTABILIZA: 15s, 30s, 60s, e o último se repete para sempre. Os dois degraus curtos existem porque o caso a atender é o de alguém criando o primeiro projeto AGORA; um teto de minutos faria o agente aparecer depois de a pessoa desistir de olhar. O platô de 60s é o preço em regime — UMA requisição por minuto, de uma instalação, contra uma rota que devolve a lista do próprio dono da credencial. Crescer além disso economizaria tráfego que ninguém está pagando e pioraria a única coisa que a espera entrega.

Há teto, e ele é de FALHAS, nunca de espera. Esperar não tem limite — esperar é o estado normal de uma instalação nova, e pode durar dias. O que tem teto são as falhas SEGUIDAS da consulta: dez, e então o processo sai com 1 NOMEANDO o número, como o laço de conexão já faz com TETO_DE_TENTATIVAS_SEGUIDAS e pelo mesmo motivo (não martelar a api para sempre quando o problema não é transitório). Uma consulta que RESPONDE zera o contador — inclusive quando responde VAZIO, que é sucesso, porque a lista vazia é a resposta certa. A constante é SEPARADA de propósito, com o mesmo valor: uma conta falhas de CONECTAR e a outra, falhas de LISTAR, e amarrá-las faria uma mudança de cadência de conexão mexer, sem ninguém pedir, no tempo que uma instalação nova espera antes de desistir. CredencialNaoEDeMaquinaError (o 403 por espécie de chave, RN-544) não entra no teto: ela é fatal na hora, porque uma chave de projeto não vira chave de máquina por esperar mais um minuto.

Ficar de pé em silêncio seria pior que sair. "Esperando" e "travado" são a mesma imagem para quem lê journalctl, então o processo diz a cadência ao entrar na espera, diz quando o primeiro projeto aparece, e bate um BATIMENTO a cada 30 consultas (meia hora, no platô) informando quantas consultas já houve. Nem toda consulta vira linha, também de propósito: um log por minuto, para sempre, é o ruído que enterra as linhas que importam.

Restart=on-abnormal fica byte a byte, nas DUAS espécies de unit (RN-545), e nada nelas muda. Medido: on-abnormal reergue por SINAL, watchdog ou timeout — nunca por código de saída, nem 0 nem 1. A saída com 0 nunca foi o que impedia o serviço de ser reerguido; o que impedia é que on-abnormal não olha código nenhum. O efeito real da mudança é o oposto do temido: a unit de MÁQUINA, que é a que roda este modo, passa a ficar active (running) de verdade em vez de terminar em inactive (dead) segundos depois de subir — e service status --machine passa a responder rodando (0) onde antes respondia parado (3) numa instalação nova. A unit por PROJETO não é afetada em linha nenhuma: ela roda o modo projeto, que nunca chega aqui.

Uma exceção, declarada: lista NÃO-vazia cujos projetos são todos recusados continua saindo com 1. Também são zero conexões, e mesmo assim não espera — porque ali cada recusa nomeia um defeito local concreto (segmento que escapa da base, pasta que é um arquivo) com conserto próprio, e reconsultar repetiria as mesmas recusas indefinidamente, enterrando no log a linha que diz o que consertar. A espera é para "ainda não existe projeto", nunca para "existe e eu não consigo atendê-lo".

  • Código: apps/runner/src/espera-de-projetos.ts:127 (esperarPrimeiroProjeto), :60 (CADENCIA_DA_ESPERA_MS), :75 (TETO_DE_FALHAS_DE_CONSULTA), :88 (CONSULTAS_POR_BATIMENTO); apps/runner/src/index.ts:1465 (a espera dentro de rodarComoAgenteDeMaquina, e a exceção dos recusados logo abaixo)
  • Teste: apps/runner/src/espera-de-projetos.spec.ts — o caminho feliz (espera, reconsulta, devolve a primeira lista não-vazia e PARA ali), a cadência que estabiliza, o batimento a cada N consultas, o teto de falhas seguidas com o número na mensagem, a resposta no meio ZERANDO o contador, o 403 de espécie subindo intacto e o SIGTERM devolvendo parado; apps/runner/src/index.spec.ts — a JUNÇÃO no PROCESSO de verdade, contra uma api de mentira que devolve []: o processo DIZ que fica de pé e não termina, que era o defeito
  • ADR: 0155 ponto 5
  • Origem: FASE 30, sessão 8 — o recorte da fase

RN-551 — brabo-runner device-key create/finish: o par Ed25519 nasce NA MÁQUINA, a privada nunca viaja, e o arquivo que o runner lê nunca existe sem kid​

Sessão 8 da FASE 30, a outra metade. Até aqui, só o navegador gerava par de chaves de dispositivo (apps/web/src/lib/runner-bootstrap.ts, ADR 0118, RN-464); o CLI só sabia ler o que alguém tinha posto na pasta (device-key.ts). O ADR 0155 ponto 4 pede o outro lado: "o par é gerado na máquina; a privada nunca viaja… mantém verdadeira a frase que a RN-519 usa para recusar a visão de maintainer: 'a privada de uma chave de dispositivo nunca sai do navegador' — passa a ser 'nunca sai da máquina'". Sem isso, a RN-543, a RN-544 e a RN-545 continuavam todas com a mesma lacuna declarada: ninguém cria chave de máquina.

São DOIS passos, e o motivo é o kid. O kid gravado dentro da JWK privada É o id do registro no servidor (RN-475) — a cadeia inteira só o REPASSA (o runner lê jwk.kid, o JWT o leva no header, o PatAuthGuard acha a pública por ele), e ninguém o deriva de outra coisa. Ele só existe depois de a pública ser registrada, e uma privada gravada antes disso nasce inútil, que é literalmente o defeito que a RN-475 custou uma caçada para achar. O navegador resolve mantendo o par em MEMÓRIA entre o registro e a gravação; o CLI resolve com um arquivo PARCIAL:

PUB=$(brabo-runner device-key create) # gera aqui, imprime a PÚBLICA
ID=$(… registra PUB na api … | jq -r .id) # quem registra é o instalador
brabo-runner device-key finish --id "$ID" # carimba o kid e grava

Em nenhum instante existe um brabo-runner-device-key.jwk.json sem kid. O create grava em …jwk.json.parcial, um nome que lerChaveDeDispositivo não procura; o finish carimba e grava o nome de verdade. O arquivo que o runner LÊ ou está completo ou não existe, e uma interrupção entre os dois passos deixa um .parcial que o runner IGNORA e que o próximo create NOMEIA — dizendo que o registro daquela tentativa pode ter ficado ÓRFÃO, que é inerte e aparece na listagem da RN-519.

O CLI NÃO fala com a api, e a razão é de SEGREDO, não técnica. Quem registra a chave de máquina é o instalador, autenticado pelo BRABO_SERVICE_TOKEN que ele acabou de gerar (ADR 0155 ponto 1). Fazer o brabo-runner chamar a rota exigiria pôr esse token na mão dele — um segredo de INSTALAÇÃO, que abre rotas internas, entregue a um processo que hoje só vê credencial de dispositivo e que fica de pé para sempre na máquina do usuário. O corte é o oposto: cada lado guarda exatamente um segredo, e nenhum vê o do outro. O que atravessa a fronteira entre eles é a JWK PÚBLICA e o id — nada que precise ser protegido.

O destino padrão é a pasta de configuração da máquina, e a precedência é REUSADA. $XDG_CONFIG_HOME/brabo/ (senão ~/.config/brabo/), ao lado do runner.json da RN-529 e pelo mesmo motivo — é configuração da MÁQUINA, e uma chave de máquina não tem pasta de projeto onde morar. pastaDeConfiguracaoDoBrabo foi EXTRAÍDA de caminhoDoArquivoDeBase quando ganhou o segundo consumidor, nunca copiada. Isso não reabre a porta que a Onda 2 do ADR 0104 fechou (credencial em caminho global e IMPLÍCITO): device-key.ts continua lendo só do cwd que o chamador passar, e quem aponta para esta pasta é o --dir da unit de máquina (RN-545), explicitamente, escrito no serviço. --dir existe e serve também a uma pasta de projeto — em disco as duas espécies são o mesmo arquivo, e quem sabe a diferença é o SERVIDOR (RN-544): este comando não afirma espécie nenhuma.

A privada é gravada com modo 600, explícito, e nunca deixado ao umask. A pasta nasce 700 quando é ESTE comando que a cria; uma pasta que já existia é do usuário (pode ser a que o navegador configurou) e não é reapertada. O --dir passa pela MESMA validarDirDentroDoHomeNoLinux da RN-434, reusada inteira: o que se grava é um segredo do usuário, e uma pasta fora do $HOME dele não é onde o systemd --user dele vai procurar.

O stdout carrega UM valor, numa linha, e todo o resto é stderr. É esse o contrato de que o install.sh depende (PUB=$(…)): misturar as duas coisas obrigaria o script a filtrar saída de humano, que é como um contrato se quebra sem ninguém perceber. No create o stdout é a JWK pública; no finish, o CAMINHO do arquivo — o valor que o passo seguinte (service install --machine --dir <pasta>) precisa.

Sobrescrever chave completa é RECUSADO, e não há --force. Trocar a chave de uma máquina já pareada trocaria a identidade de um agente que pode estar de pé AGORA, em silêncio: a pública continuaria registrada e o processo passaria a assinar com uma chave que ninguém conhece. A recusa nomeia o gesto (revogar, apagar, recriar), e uma flag que derruba uma guarda cujo sintoma é silencioso devolveria o sintoma silencioso.

  • Código: apps/runner/src/criar-chave-de-dispositivo.ts:202 (criarChaveDeDispositivo), :275 (finalizarChaveDeDispositivo), :82 (SUFIXO_PARCIAL), :177 (resolverDestino); apps/runner/src/base.ts:85 (pastaDeConfiguracaoDoBrabo, extraída); apps/runner/src/index.ts:1280 (o despacho antes de lerArgumentos, e a separação stdout/stderr)
  • Teste: apps/runner/src/criar-chave-de-dispositivo.spec.ts — o caminho feliz ponta a ponta (a pública no stdout, o parcial em 600, o finish carimbando, e a chave resultante lida por lerChaveDeDispositivo e ASSINANDO um JWT de ticket de verdade), o --dir, e as recusas: finish sem create, sem --id, com --id colado do JSON inteiro, chave completa já no lugar (nos dois comandos), parcial que não é a JWK, subcomando desconhecido e o aviso de órfão; apps/runner/src/index.spec.ts — a JUNÇÃO no processo de verdade: device-key create roda numa pasta SEM credencial nenhuma, e o stdout contém só a JWK pública
  • Lacuna DECLARADA, e FECHADA nas duas metades: quem REGISTRA a pública não existia neste repositório quando esta RN fechou — a rota chegou na RN-552 e o install.sh que encadeia os dois comandos, na RN-547. O contrato que este comando assume é o que a rota de chave de PROJETO já tem ({ name, publicKeyJwk }, com publicKeyJwk sendo o JSON da JWK pública Ed25519 — kty "OKP", crv "Ed25519", x — e resposta com id), e é isso que o stdout do create produz: uma linha nesse formato. O comando também não escolhe o name do dispositivo, de propósito — ele é campo do registro, e quem nomeia é quem registra
  • ADR: 0155 ponto 4
  • Origem: FASE 30, sessão 8 — o recorte da fase

O install.sh fecha a instalação (RN-547, FASE 30)​

RN-547 — O instalador cria a primeira conta, registra a chave desta máquina e sobe o agente como serviço — e o que falha no meio é RELATADO, nunca desfeito nem engolido​

Sessão 6 da FASE 30, decidida pelo ADR 0155. A sessão tocaria três workspaces de uma vez e foi dividida em três: as duas peças do agente e da api já fecharam (RN-546, RN-551, RN-552, sobre a unit da RN-545), e o que sobrou aqui é o install.sh como orquestrador e nada mais — nenhuma linha de apps/ foi tocada, e nenhuma rota nasceu.

O buraco que fecha. Uma instalação nova terminava dizendo "Pronto" com um login que NINGUÉM atravessava: o .env gerado não tem variável de e-mail nenhuma, MAIL_TRANSPORT cai no default log, e o registro normal exige verificar e-mail. A única saída era pescar o link em docker compose logs api. E o agente local ficava instalado sem credencial, esperando que alguém voltasse ao navegador para parear uma pasta.

A cadeia, e cada elo já existia. POST /internal/first-account (RN-546) → brabo-runner device-key create (RN-551) → POST /internal/machine-device-keys (RN-552) → device-key finish --id <id> → brabo-runner service install --machine (RN-545). O id que a api devolve no registro é o que vira o kid da privada, e a cadeia inteira só o REPASSA — ninguém o deriva de outra coisa (RN-475). O --dir do serviço sai do CAMINHO que o finish imprimiu, não de uma segunda conta do script sobre onde a pasta de configuração fica.

O que acontece quando um elo do meio falha: nada é desfeito, tudo é RELATADO, e o script sempre sai 0. É a decisão desta entrega, e o argumento é que os elos já cumpridos são úteis por si — com a conta criada a pessoa entra, com a chave registrada a máquina está pareada, e o serviço é o único passo que ela repete à mão com um comando que o script imprime. Desfazer exigiria apagar conta e revogar chave, e não há rota para isso nem deveria haver uma que o instalador chame sozinho. O preço é que uma instalação pode terminar pela metade; o que ela nunca faz é terminar pela metade em SILÊNCIO — cada falha vira uma linha nomeada num bloco "O que ficou pendente", no fim, onde quem instalou ainda está olhando. Derrubar a instalação inteira por causa do último passo trocaria meia instalação por nenhuma.

Idempotência é o CÓDIGO HTTP, e 409 não é falha. As duas rotas respondem 409 quando a instalação já tem gente — uma segunda execução, ou uma migração (RN-530) cujo restore trouxe os usuários. Esse é o desfecho ESPERADO: o passo se cala dizendo por quê, e não entra nas pendências. Qualquer outro código é falha, e aparece com o código e a resposta da api ao lado. 000 — o curl que não chegou a falar com a api — tem tratamento próprio pelo mesmo motivo: colapsá-lo com um código de resposta faria "a api não subiu" ser lida como "a api recusou". E o passo do agente só roda quando ESTA execução criou a conta: com usuário preexistente, registrar uma chave de máquina cunharia credencial duradoura para alguém que não pediu nada nesta rodada.

A senha não toca disco nem argv. Lida no TTY sem eco (stty -echo, com o modo do terminal restaurado no fim), confirmada, e enviada à api pelo STDIN do curl — nunca por --data em linha de comando, que /proc/<pid>/cmdline publica para qualquer usuário da máquina, e nunca por arquivo. O cabeçalho com o BRABO_SERVICE_TOKEN vai num --config de modo 600, apagado em seguida: ele é segredo também, e esse segredo já mora em disco, no .env, com o mesmo modo. Sem stty na máquina o passo é recusado, nunca degradado para perguntar com eco — prometer sem eco e entregar com eco é pior que não perguntar. A senha não vai para o .env, não vai para o marcador e não vai para log nenhum; o marcador ganha o e-mail, que identifica e não é segredo, e o MARCADOR_SCHEMA sobe para 3 junto com o campo, pela mesma régua que o fez subir para 2 ao ganhar a versao.

A recusa de senha é repetível, e o teto é 3. A política é a do domínio (RN-546), a mesma do registro normal, e só é conhecida DEPOIS do POST. Abortar a instalação ali deixaria alguém com o compose de pé e a segunda execução caindo no caminho de migração — então o laço é do par pergunta+resposta, e esgotar o teto vira pendência nomeada em vez de recusa. Senha digitada diferente da confirmação não chega a sair da máquina. Tabulação no que foi digitado é recusada e nunca removida em silêncio: um instalador que reescreve a senha produz uma conta cuja senha não é a que a pessoa digitou.

A recusa do service install --machine é o caso COMUM numa máquina de desenvolvedor, e ela chega com as palavras dela. install --machine recusa quando há unit por PROJETO instalada, sem --force (RN-545: "instalar e avisar deixaria a instalação errada de pé"). O instalador repassa a saída do CLI INTEIRA em vez de resumi-la — é ela que nomeia o uninstall de cada unit encontrada, que é o gesto — e a pendência carrega o comando exato para repetir depois.

O name da chave é o hostname, e a escolha é deste script. A RN-551 declara por escrito que o CLI não nomeia: o name é campo do registro, e quem nomeia é quem registra. Quem abrir a lista de chaves de dispositivo precisa saber a QUAL máquina ir, e o hostname é o nome que a pessoa já usa para falar das máquinas dela — é o que o prompt do shell mostra. Não é segredo, não é único, e a api não deriva nada dele. Aspa e barra saem (sairiam do lugar dentro do JSON), o corte é em 80 (o @MaxLength(80) do DTO, aplicado aqui para a recusa não vir da api) e a ausência total de hostname e uname devolve um nome fixo — vazio viraria 400 pelo @MinLength(1), depois de a conta já existir.

O .parcial que sobra quando o registro não fecha FICA onde está, e é nomeado. O create grava a privada num arquivo com sufixo .parcial que o runner ignora por construção (RN-551), e se o registro falhar ele sobra. Apagá-lo seria o instalador decidir que sabe algo que não sabe: um timeout depois de a api gravar é indistinguível de um timeout antes, e o arquivo pode ser a única cópia da metade privada de uma chave já registrada. Ele é inerte, o próximo device-key create o nomeia e o substitui, e a mensagem diz as duas coisas.

O que este script NÃO faz, e agora diz certo. A última linha da saída anunciava, meses depois de as funções existirem, que ele "não pareia o agente local" e que a chave "continua vindo da tela do projeto". O texto foi reescrito: SMTP continua desligado por decisão (MAIL_TRANSPORT=log, aqui como em produção — e a conta criada nasce verificada justamente por isso), credencial de LLM continua sendo de quem vai gastar (RN-058), e o pareamento pela tela do projeto (ADR 0118) continua existindo para quem quiser — o que mudou é que ele deixou de ser obrigatório. O --help sofria do mesmo defeito num lugar que nenhum teste alcançava (ele imprime o CABEÇALHO, que é comentário, e a regra que proíbe falar de "sessões de fase" só lia linhas de código): ele dizia "ESTA VERSÃO NÃO INSTALA NADA". Corrigido, e o intervalo fixo de linhas que o cortava em silêncio virou "até a primeira linha que não é comentário".

O que NÃO entrou: rota nova de qualquer espécie (a sessão é orquestração — se faltasse algo do outro lado, o gesto era parar e relatar); SMTP; credencial de LLM; senha gravada em qualquer lugar; e qualquer caminho que crie conta sem TTY — sem terminal interativo o script relata e sai 0, no passo que já existia, antes de qualquer escrita.

  • Código: install.sh:1664 (fechar_a_instalacao, o encadeamento e o "sempre 0"), :1463 (perguntar_e_criar_a_conta, o consentimento, o laço e o teto), :1563 (parear_esta_maquina, o id virando --id), :1640 (subir_o_agente_como_servico, a recusa repassada inteira), :1366 (post_interno, o corpo pelo stdin e o cabeçalho pelo --config 600), :1385 (criar_primeira_conta) e :1426 (registrar_chave_de_maquina) — os vereditos das duas rotas —, :1441 (ler_sem_eco), :1345 (nome_da_maquina), :1586 (avisar_chave_parcial), :1208/:1215 (escapar_json/sem_controle), :108 (MARCADOR_SCHEMA=3), :1871 (a chamada, depois do runner e antes do marcador), :1882 (o ownerEmail). Números relidos pelo símbolo em 18/09 (AT-096), em 27/09 (AT-065, que acrescentou o ramo do Mac Intel em instalar_o_runner) e em 01/10 (AT-122); os de :966/:682 já não batiam em 17/09
  • Teste: scripts/dev/install-fechamento.spec.ts — as funções de shell rodadas DE VERDADE (o script inteiro carregado por source, menos a chamada de main) contra um servidor node:http real e um brabo-runner dublê: o caminho feliz da conta (corpo, service token e senha chegando), 409/400/ 500/sem-resposta com vereditos DIFERENTES, o laço de senha e o teto de 3, a ordem create → registrar → finish → service install com o --id sendo o id da api, a recusa do service install repassada com as palavras dela, o .parcial que fica quando o registro falha, o create que recusa sem nada ser registrado, e o fechamento sem binário do agente; scripts/dev/install.spec.ts (o plano imprimível, e o --help que deixou de mentir)
  • Lacuna DECLARADA: a instalação de ponta a ponta numa máquina limpa — compose de verdade, api de verdade, binário do runner de verdade, unit escrita e agente esperando o primeiro projeto — é o E2E da sessão 8 da fase, e só ele pode provar. Aqui o brabo-runner é um dublê de shell e a api é um servidor de teste; o que se prova é o ENCADEAMENTO, nunca a integração. E o fluxo principal do install.sh (verificar a própria origem contra o checksums.txt assinado) continua sem cobertura, pelo motivo que install.spec.ts já registrava: nenhuma Release tem esse asset até a próxima tag final
  • ADR: 0155
  • Origem: FASE 30, sessão 6 — o recorte da fase

RN-549 — O E2E em máquina limpa prova a instalação INTEIRA: os cinco elos, o agente de pé esperando, e o primeiro projeto pego sem ninguém voltar ao terminal​

Sessão 8 da FASE 30, a última — e a única que podia responder a pergunta que a fase inteira persegue: numa máquina limpa, o install.sh termina com o agente local de pé e pareado, e a pessoa cria o primeiro projeto em modo Runner sem voltar ao terminal? Até aqui a resposta era uma intenção escrita: a RN-547 encadeou os cinco comandos e declarou, por escrito, que nos testes dela o brabo-runner é um dublê de shell e a api é um node:http — "o que se prova é o ENCADEAMENTO, nunca a integração".

O harness não nasceu aqui: ele foi ESTENDIDO. .github/workflows/install-e2e.yml existe desde a FASE 29 (RN-534) e já roda o instalador publicado num runner efêmero do Actions — que é literalmente uma máquina limpa, sem Brabo, sem .env, sem marcador. O que ele provava parava no .env: plano, estado, as três promessas de nunca apagar, o caminho sem TTY, e a subida com TTY simulado. Um segundo workflow duplicaria o lado caro (esperar a Release, baixar, verificar assinatura) para medir o lado barato.

Ele NÃO roda em pull_request, e a consequência é declarada em vez de escondida. O instalador verifica a própria origem contra o checksums.txt assinado de uma Release (RN-526), que só existe depois de uma tag final; a única forma de fazê-lo rodar em PR seria dar-lhe uma porta para PULAR a verificação — a porta que o ADR 0150 recusa, e que uma vez aberta valeria para qualquer um, não só para o CI. Logo: o PR que escreveu este E2E não o executou, e os checks dele passaram sem exercitar uma linha do que ele acrescentou. É a mesma decisão do golden-set do RAG (ADR 0138), pelo mesmo motivo.

Daí a metade que RODA em PR, e é a razão de ela existir. Workflow que só roda em tag é workflow cujo erro aparece na tag, quando o release já está saindo e ninguém olha mais o PR que o quebrou. scripts/dev/install-e2e.spec.ts mede as duas maneiras conhecidas de este E2E apodrecer calado: alguém afrouxar o gate (pôr pull_request no gatilho, ou passar uma flag de pular verificação), e alguém reescrever uma frase do install.sh — com o que todo grep do workflow passa a não casar nada, e as asserções não falham, elas SOMEM. O canário de prompts é derivado, não decorado: as respostas do TTY vão por arquivo, em ordem, e um read -r novo no instalador desalinha todas as seguintes — a base viraria a senha —, então o teste conta os prompts e manda olhar o arquivo de respostas.

O que o E2E passou a provar, e cada asserção tem um dono. Os cinco elos da RN-547, um a um, contra api e binário de VERDADE — a conta (RN-546), o par gerado na máquina e o kid carimbado (RN-551), o registro da pública (RN-552) e a unit por máquina (RN-545). Mais o invariante da RN-475 medido no DISCO e não na saída (o arquivo que o runner lê tem kid, tem d, e o .parcial sumiu quando o finish fechou), a senha ausente do .env, do marcador e do log, e o ownerEmail presente no marcador.

A prova NEGATIVA é a que mais vale, e é a ausência do bloco de pendências. A RN-547 manda relatar cada elo que falha num bloco final nomeado — e sair 0 do mesmo jeito. Um E2E que olhasse o código de saída daria verde com quatro dos cinco elos quebrados. "Nenhuma pendência" é a única forma de afirmar que os cinco fecharam, inclusive os que o workflow não sabe nomear.

O sinal de que o agente está DE PÉ esperando é a LINHA, nunca o estado do gerenciador — e essa escolha é a decisão central deste E2E. perguntarEstado mapeia activating para rodando (RN-088, com razão: "subindo" não é "parado"), então tanto systemctl is-active quanto brabo-runner service status --machine respondem de pé para um processo que vai morrer dois segundos depois. É exatamente a corrida que um sleep fixo esconde, e medir o artefato errado aqui — um binário no disco, uma unit escrita — é o jeito mais fácil de um E2E dar verde sem provar nada. O sinal escolhido é a linha que o processo escreve ao ENTRAR na espera (RN-550): ela só existe depois de o agente ter lido a chave de máquina do disco, tirado ticket, falado com a api de verdade e recebido uma lista VAZIA — prova pareamento e espera de uma vez. E não é amostragem de estado: é um fato já gravado no journal, que não se desfaz. Por isso esperar por ela com teto não é corrida — ou ela aparece, ou o teto estoura e o passo reprova mostrando o journal inteiro. service status --machine entra depois dela, como confirmação de que o código de saída responde o que o operador vai ver, nunca no lugar dela.

O desfecho da fase é medido pela API, e não pelo NAVEGADOR. O sujeito da afirmação é o AGENTE, não a página: o que ele observa é GET /runner/projects (RN-543), um fato da api. Um navegador criaria o projeto pela MESMA rota com uma camada a mais que falha por motivos alheios à promessa — e não tornaria a asserção mais forte, porque ela continua sendo a linha do journal do agente. A camada de navegador existe (e2e/, ADR 0120), responde outra pergunta (três origens, cookie httpOnly, CSRF), tem lockfile próprio e assume o compose de produção de pé: arrastá-la para cá faria o E2E do instalador depender de um segundo harness instalado numa máquina que deveria estar limpa. Provar o wizard é trabalho da suíte do web, e ele já é coberto lá. O que o passo mede é a cadeia inteira: a conta nascida no terminal atravessa o login sem link de verificação nenhum, o workspace pessoal da RN-410 existe, o projeto nasce runner, e o agente o pega sozinho dentro da cadência de 15s/30s/60s da RN-550.

A idempotência é medida pela metade que o E2E alcança sem reinstalar. A api responde 409 de verdade a uma segunda first-account — com senha GERADA, nunca constante, porque exigirSenhaValida roda ANTES do conflito e uma senha fraca daria 400, medindo a coisa errada. O que o instalador FAZ com esse 409 (calar-se, e não virar pendência) é classificação de código HTTP, coberta em install-fechamento.spec.ts contra um servidor de teste: repeti-la aqui exigiria uma segunda instalação inteira, que passa pelo caminho de MIGRAÇÃO (RN-530) — backup provado, apagar, recriar — e é entrega própria.

A única divergência entre este runner e uma máquina limpa é do AMBIENTE, e está nomeada. Numa máquina de verdade quem instala tem sessão de login e o systemd --user já está de pé; o job do Actions roda não-interativo, sem sessão, e sem ela systemctl --user não tem barramento a que falar — o service install --machine viraria pendência por um motivo que não é do produto, e o E2E "provaria" uma falha de laboratório. loginctl enable-linger põe o gerenciador de usuário de pé; ele não é flag do instalador, não pula verificação nenhuma e não muda o que a unit faz.

Dois defeitos apareceram ao escrever isto, e os dois eram invisíveis porque o workflow nunca rodou. O primeiro estava no próprio harness: o arquivo de respostas começava por nao-migrar, que numa máquina limpa cai na pergunta do MARCADOR — ela não casa s|S|sim|SIM, o script dizia "Nada foi gravado" e saía 0, e a asserção do marcador logo abaixo teria reprovado. A pergunta de migração está sob if [ -n "$marcador" ] || [ -n "$sinais" ], e numa máquina limpa os dois são vazios: ela não acontece. Corrigido para as SETE respostas reais.

O segundo é ACHADO e não foi corrigido aqui, de propósito. O install.sh publicado não consegue subir nada sozinho numa máquina limpa: ele sobe a pilha com docker compose -f docker/docker-compose.install.yml, um caminho RELATIVO ao diretório de onde roda — e esse arquivo não é asset da Release, não entra no checksums.txt assinado (RN-524), e o script não o baixa em lugar nenhum (as únicas descargas dele são o cosign, o manifesto, o próprio hash e o binário do runner). O compose ainda bind-monta ./postgres/init.sql, então são TRÊS arquivos e não um. Quem segue o sh -c "$(curl … install.sh)" do runbook morre em "no such file or directory" depois de já ter verificado assinatura, escolhido a base e gravado o .env. Consertar é decidir se o compose vira asset assinado ou se o instalador passa a clonar — entrega própria, não algo a fazer de passagem dentro do E2E que descobriu o buraco. O workflow traz os três à mão, pela tag, num passo que DIZ que é achado; o teste em PR cobra as duas metades (o passo existe, e o instalador continua usando o compose sem baixá-lo), e o dia em que o instalador os buscar sozinho o teste reprova — e o certo então é apagar o passo, não relaxar o teste.

O que NÃO entrou: porta nenhuma para pular a verificação de origem; apps/ intocado (o achado virou relato, não correção de passagem); nenhum segundo workflow de E2E de instalador; e nenhum gate afrouxado — em particular, o E2E continua fora de pull_request.

  • Código: .github/workflows/install-e2e.yml:16 (o gatilho que segue sem pull_request), :111 (o passo do achado — os três arquivos que o instalador não baixa), :172 (a sessão de usuário do systemd, a divergência declarada), :204 (a instalação completa com as SETE respostas), :241–:245 (os cinco elos da RN-547, um por linha), :253 (a prova negativa — nenhuma pendência), :267–:274 (o kid, o d e o .parcial, medidos no DISCO e não na saída), :278 (a senha que não vazou), :307 (o agente de pé, pela linha do journal e não pelo gerenciador), :354 (o primeiro projeto pela api, e o agente pegando-o), :415 (o 409 da segunda conta)
  • Teste: scripts/dev/install-e2e.spec.ts — a metade que roda em PR: o gatilho lido do DOCUMENTO (não do texto, que casaria com o comentário que explica por que pull_request não está lá), a ausência de flag de pular verificação nos dois arquivos, o contrato de frases entre workflow e install.sh (os cinco elos mais as quatro de sempre), as duas linhas do apps/runner de que as asserções dependem, o canário de prompts interativos, a ORDEM entre a linha do journal e o service status --machine, e as duas metades do achado do compose. Verificado por mutação: reescrever uma frase do instalador e pôr pull_request no gatilho reprovam
  • Lacuna DECLARADA: este E2E não roda no PR que o escreveu, e nenhum check dele prova uma linha do que foi acrescentado — a primeira execução real é a próxima tag final. Fica também fora, e não por esquecimento: a segunda execução do instalador pelo caminho de MIGRAÇÃO (RN-530) — backup, prova de restauração, apagar e recriar —, o macOS (o passo completo é Linux: launchd, e o Docker que o runner hospedado não tem), e o navegador
  • ADR: 0155
  • Origem: FASE 30, sessão 8 — o recorte da fase

A conversão de modo deixa de ser um salto no escuro (RN-559, RN-560)​

RN-559 — A conversão para mounted abre o MESMO navegador de pastas do wizard; o ramo runner continua digitado, e a tela diz por quê​

ExecutionModeSection era o único dos cinco lugares do produto em que se escolhe uma pasta e se digitava o caminho no escuro — os outros quatro já abriam o FolderBrowserModal desde a RN-473/RN-504. A lacuna estava declarada no CLAUDE.md e o motivo declarado era do outro ramo: onboardar um runner ANTES de a conversão salvar registra chave num projeto que ainda não é runner, e ConfirmProjectWorkspaceUseCase recusa com 400. Nada disso vale para mounted, e é isso que esta regra fecha.

Mesmo componente, mesmo transporte, nenhuma régua nova. O ramo mounted monta o FolderBrowserModal com origem: { tipo: 'api', workspaceId } — a mesma união discriminada que NewProjectWizard usa para esse modo (RN-533), porque a pasta mora dentro da base do SERVIDOR (ADR 0141) e é o servidor quem a enxerga. Não nasce navegador novo nem endpoint novo, e o navegador não garante caminho válido: quem valida continua sendo validarExecutionModeEWorkspacePath e o CHECK do banco.

O ramo runner NÃO ganha navegador, e isso é decisão, não esquecimento. O transporte dele ({ tipo: 'runner', projectId }) exige um runner conectado a ESTE projeto, e um projeto que ainda não é runner não tem nenhum — a espera terminaria num erro com cara de bug. A ordem "converte, depois onboarda" segue sendo decisão de produto à parte. O que muda é a tela passar a dizer isso em texto em vez de só não oferecer botão nenhum, que é a régua do ADR 0064: tira-se o controle, nunca a informação.

A base é um estado de QUATRO valores, e eles não colapsam. A rota GET workspaces/:workspaceId/projects-base (RN-500) pode estar em voo, ter falhado, ter respondido null ou ter respondido um caminho — e cada um desses tem texto PRÓPRIO, pela régua da RN-088/RN-468: "não sei" nunca vira "não tem". O botão fica na tela apagado nos três primeiros, com o motivo dito em TEXTO logo abaixo, e não por title (que em elemento disabled não abre no Chromium).

estado da baseo botãoo que a tela diz
em vooapagado"consultando a base de projetos desta instalação"
a consulta falhouapagadonão deu para SABER se ela existe; recarregue
respondeu nullapagadoesta instalação não declarou base
respondeu um caminhovivoa base, NOMEADA, e que é nela que o navegador abre

O modo mounted não sai do seletor quando a base falta, e essa é a diferença deliberada em relação ao wizard (RN-513): um projeto que JÁ é mounted não pode ver o próprio modo sumir do controle por causa de uma consulta em voo. O que some é o NAVEGADOR, não a conversão.

O mínimo é o do ENDPOINT. PUT projects/:projectId/execution-mode e GET workspaces/:workspaceId/projects-base pedem os dois maintainer, lido por roleAtLeast sobre a ROLE_ORDER e nunca por uma lista de papéis à mão (RN-102) — a seção fazia role === 'owner' || role === 'maintainer', que acerta por acidente enquanto o mínimo é alto. Quem não alcança o mínimo não dispara a consulta da base: ela terminaria num 403 certo, e o "não sei" que ela produziria seria sobre a autorização, não sobre a base.

O valor escolhido no navegador é tratado como valor DIGITADO: ele preenche o campo e só vai à api quando alguém confirma no botão da seção. Esta seção não vira autosave (RN-469).

  • Código: apps/web/src/routes/settings/ExecutionModeSection.tsx:68 (os quatro estados da base, como união), :211 (o mínimo por roleAtLeast), :220 (a consulta que não sai para quem não alcança maintainer), :260 (a derivação dos quatro), :280 (o que o navegador exige para existir), :414 (o botão, apagado e não escondido), :423 (o texto do ramo runner), :475 (o FolderBrowserModal com origem: { tipo: 'api', workspaceId }); apps/web/src/locales/pt-BR/settings.json:231 e apps/web/src/locales/en/settings.json:231 (executionMode.path.*, os cinco textos nos dois idiomas)
  • Teste: apps/web/src/routes/settings/conversao-de-modo.test.tsx — o caminho feliz (abrir o navegador, escolher loja, o campo preencher e a conversão enviar o que foi ESCOLHIDO, só depois do botão), a consulta da base FALHANDO (botão inerte, texto próprio, o modal não montando nem à força, e o campo continuando editável), a base AUSENTE com texto DIFERENTE do de falha e do de carregamento, o ramo runner sem botão e com o motivo em texto, e o papel abaixo de maintainer (controles apagados, valor vigente ainda visível, consulta da base não disparada)
  • Lacuna DECLARADA: o ramo runner desta seção continua digitado no escuro — a lacuna do CLAUDE.md encolheu para ele e não fechou. Fechá-la exige decidir a ordem "converte, depois onboarda", que é decisão de produto à parte e segue sem dono. Atualização: a ordem foi decidida e medida na RN-612 — o ramo runner ganha o onboarding DEPOIS de salvar, e o navegador segue fora por outro motivo, dito lá
  • ADR: 0111, 0141
  • Origem: AT-049 (EP-021/HS-033)

RN-560 — O aviso da conversão diz o que ela recusa, o que ela leva e o que ela NÃO leva — nomeando o caminho antigo​

O aviso fixo da seção afirmava "isto migra a pasta de trabalho do agente", nos dois idiomas. ConvertProjectExecutionModeUseCase não tem uma linha que copie ou mova conteúdo de pasta: ele move o permissions.json (a POLÍTICA, com allow/deny/ask intactos — RN-448), zera workspaceVerifiedAt (RN-450) e mirrorPath (RN-515), desprovisiona o container ao SAIR de container (RN-449) e grava o novo workspacePath. materializarWorkspaceMontado apenas CRIA a pasta nova (RN-501). O que estiver na pasta antiga — trabalho não commitado incluído — fica lá, órfão.

O órfão já era lacuna declarada no CLAUDE.md desde a RN-447..450. O que esta regra fecha não é o órfão: é a tela afirmando o CONTRÁRIO do que o servidor faz. Isto é correção de afirmação, não feature — migrar conteúdo entre modos segue fora, sem dono.

Três fatos, e não um parágrafo, porque respondem perguntas diferentes:

  1. o que a conversão RECUSA — dev agent trabalhando ou travado agora vira 409, e a frase da api é a que aparece no toast (o texto que já existia e continua valendo);
  2. o que ela LEVA — a política do projeto muda de escopo com o conteúdo intacto;
  3. o que ela NÃO leva — o conteúdo da pasta, trabalho não commitado incluído, nomeando o caminho antigo.

O aviso NOMEIA o caminho porque "fica no disco antigo" sem dizer QUAL disco manda a pessoa procurar. O valor está a uma linha de distância (project.workspacePath) e some da tela no instante em que a conversão salva. Quando não há caminho a nomear — o projeto é container, e a pasta antiga é um volume do SERVIDOR — a frase é OUTRA, apontando a pasta gerenciada: uma variante com {{caminho}} vazio diria "o que estiver em ", que é a tela recusando nomear o que sabe. É o caso em que a pessoa tem MENOS como adivinhar.

Ele não promete detecção. Perguntar ao disco "há trabalho não commitado?" é I/O por modo e impossível de responder para runner do lado da api: a pasta mora numa máquina que o servidor não enxerga. Não se mede, não se afirma (ADRs 0041/0042). E ele não bloqueia nada: a conversão continua sendo a mesma chamada, com as mesmas recusas.

O tom do Alert passou de accent para warning, porque o terceiro fato é uma PERDA de alcance e não uma informação neutra. Segue sem role="alert": é texto que já estava na tela quando ela abriu, e uma live region assertiva ali viraria interrupção sem causa.

  • Código: apps/web/src/routes/settings/ExecutionModeSection.tsx:333 (os três fatos, um por linha), :337 (a variante que nomeia o caminho antigo × a da pasta gerenciada); apps/web/src/locales/pt-BR/settings.json:220 e apps/web/src/locales/en/settings.json:220 (executionMode.warning.*, os quatro textos nos dois idiomas — en é o idioma default do app (RN-425), e um aviso honesto só em português é a mesma mentira com sotaque)

  • Teste: apps/web/src/routes/settings/conversao-de-modo.test.tsx — o caminho feliz (o aviso contendo o caminho antigo E as palavras "trabalho não commitado"), a variante sem caminho antigo (projeto container apontando a pasta gerenciada, e a variante com caminho NÃO renderizada), e a prova negativa nos DOIS bundles de idioma: a promessa de migração não sobrevive em nenhum deles, e as quatro chaves existem em ambos

  • As consequências CONDICIONAIS (AT-144). A pergunta que esta regra deixou em aberto — listar TODAS as consequências ou só a que enganava — foi decidida pelo mantenedor: só as que valem para ESTE projeto. O aviso acrescenta, depois dos três fatos, uma linha por consequência que se aplica, e NENHUMA para o que não se aplica:

    linhaquando aparecea mesma condição do caso de uso
    o destino de espelho é zerado, NOMEADOmirrorPath preenchidotoda conversão zera (RN-515)
    o container registrado é parado e removidoo projeto é container HOJE e o ciclo de vida existe fora de removedremoverContainerSeExistir só roda ao SAIR de container (RN-449)
    "não consegui ler se há container"o projeto é container e a leitura do ciclo de vida FALHOU— ("não sei" nunca vira "não tem", RN-468)
    a pasta confirmada em <data> deixa de valerworkspaceVerifiedAt preenchidotoda conversão zera (RN-450)

    A linha do container não aparece em mounted/runner mesmo com container registrado, porque o caso de uso não o remove ali — o aviso diria o contrário do servidor, que é o defeito que esta regra existe para fechar. Os dados são os que a seção já tem (Project: mirrorPath, workspaceVerifiedAt, executionMode) mais o ciclo de vida do container, que a seção NÃO carregava: ele vem da rota que JÁ existe, GET projects/:projectId/container/lifecycle (viewer), consultada só quando o projeto é container — nenhum endpoint novo. Enquanto a leitura está em voo, a linha simplesmente não aparece.

    • Código: apps/web/src/routes/settings/ExecutionModeSection.tsx:102 (consequenciasCondicionais), :118 (containerParaOAviso, os três estados), :236 (cicloQuery, só em container), :344 (as quatro linhas no Alert)
    • Teste: o describe AT-144 em apps/web/src/routes/settings/conversao-de-modo.test.tsx — nenhuma condição (nenhuma linha a mais), container registrado, linha removed que não conta, leitura FALHANDO com texto próprio, mounted com espelho (a linha do espelho sim, a do container não, e a consulta nem sai), runner com pasta confirmada (com a data), e as três condicionais em en
  • ADR: 0111

  • Origem: AT-050 (EP-021/HS-034); as condicionais, AT-144 (EP-021/HS-034)

RN-612 — Converter para runner é converte, DEPOIS onboarda: salvar monta o painel de onboarding do projeto agora runner, e o navegador de pastas fica fora por construção​

O ramo runner da conversão era digitado no escuro (RN-559) porque a ordem estava indecidida: onboardar ANTES de salvar registra chave num projeto que ainda não é runner, e ConfirmProjectWorkspaceUseCase recusa a confirmação com 400. A decisão do mantenedor foi medir a outra ordem com a chave de MÁQUINA (RN-543/RN-548) e implementar se o atrito fosse aceitável. A medição foi feita no CÓDIGO (sem subir o ambiente) e deu isto:

quem vai atender o projetoo que acontece depois de salvargestos
agente de MÁQUINA esperando (zero projetos runner, RN-550)a próxima consulta de GET /runner/projects o lista e o agente conecta sozinho — cadência 15/30/60 s de espera-de-projetos.tszero, em até 60 s
agente de MÁQUINA já atendendo OUTRO projetoa lista é lida UMA vez, no start, com conexão viva (RN-550) — o projeto novo não entraum: reiniciar o serviço
nenhuma chave de máquinao mesmo pareamento da criação de projeto (RN-473)parear + um comando

Nos três casos o atrito é o da criação de projeto ou menor, então a conversão passa a ser dois passos na mesma seção: salvar para runner monta, logo abaixo, o MESMO RunnerOnboardingPanel da aba Código e do wizard, com o projectId de um projeto que agora É runner — o reconhecimento da máquina pareada, a EsperaDoRunner e o pareamento do ADR 0118 vêm junto, sem régua nova. Acima do painel a seção DIZ a assimetria do agente de máquina (espera sozinho × reinício), com o comando de reinício das duas plataformas (systemctl --user restart brabo-runner.service, launchctl kickstart -k gui/$(id -u)/dev.brabo.runner): a tela não sabe se o agente tem outra conexão viva, então nomeia os dois casos em vez de afirmar um.

O painel aparece quando o projeto SALVO é runner, sem troca de modo pendente no seletor, e com a pasta ainda não confirmada — ou até o fim da visita em que a conversão salvou, para a EsperaDoRunner mostrar a confirmação em vez de sumir no instante em que ela chega. Um projeto runner já confirmado não o ganha.

O caminho digitado é PROVISÓRIO, e a tela diz isso. Quem decide a pasta de um projeto runner é o runner: a confirmação SOBRESCREVE workspacePath (RN-423), e o agente de máquina nem olha o valor digitado — ele usa <base>/<workspaceDirName> (RN-544). O campo continua obrigatório porque o CHECK do banco pede caminho não-nulo fora de container; a seção não inventa placeholder.

O navegador de pastas continua FORA do ramo runner, e agora pelo motivo certo. Não é mais a ordem: é que o transporte { tipo: 'runner', projectId } exige um runner conectado a ESTE projeto, e um runner conectado já confirmou a PRÓPRIA pasta (--dir, ou a derivada da base). Escolher outra ali seria uma segunda conversão runner → runner que a próxima reconexão desfaria. Oferecer o botão seria um controle que não controla nada; a seção diz isso em texto (ADR 0064).

  • Código: apps/web/src/routes/settings/ExecutionModeSection.tsx:300 (mostrarProximoPasso), :313 (setConvertidoParaRunner, a visita em que a conversão salvou), :452 (o bloco do próximo passo), :483 (RunnerOnboardingPanel, o mesmo da aba Código); apps/web/src/locales/pt-BR/settings.json:249 e apps/web/src/locales/en/settings.json:249 (executionMode.nextStep.*) e :234 (executionMode.path.runnerTyped, reescrito); a cadência medida em apps/runner/src/espera-de-projetos.ts:60 (CADENCIA_DA_ESPERA_MS) e a lista lida uma vez em apps/runner/src/index.ts (rodarComoAgenteDeMaquina)
  • Teste: o describe RN-612 em apps/web/src/routes/settings/conversao-de-modo.test.tsx — o caminho feliz (antes de salvar não há painel; salvar monta o próximo passo, com o comando de reinício, e o painel pergunta pelas chaves DESTE projeto), a chave de máquina reconhecida, o caso de falha (projeto runner já confirmado não ganha o passo), o rascunho de outro modo escondendo o passo, e o texto em en
  • Lacuna DECLARADA: o reinício do agente de máquina que já atende outro projeto continua sendo um gesto no terminal — repesquisar a lista com conexão viva foi recusado pela RN-550, e esta regra não reabre isso. E a medição é de CÓDIGO, não de tela: o ambiente não foi subido
  • ADR: 0111, 0154
  • Origem: AT-143 (EP-021/HS-033)

A rotação da chave mestra deixa de ser tentativa e erro (RN-562, RN-563)​

RN-562 — A rotação da chave mestra é provada ponta a ponta, nas duas tabelas​

O procedimento com o pior desfecho do Runbook passa a ter verificação NOMEADA: um teste percorre a rotação inteira — cifra com K1, publica K2, reenvelopa, descarta K1 e ainda decifra — contra user_credentials e project_git_connections, com Postgres de verdade.

Por que de integração, e não mais um caso unitário. O que já existia (envelope-encryption.service.spec.ts, o describe rotação da chave mestra) exercita encrypt/decrypt/rewrap em memória, e isso não prova a única coisa que o passo 3 do runbook depende: que o script percorre as duas tabelas. Convertendo metade do acervo e reportando sucesso, descartar a chave velha tornaria a outra metade ilegível para sempre. A prova disso é a asserção sobre as duas linhas — e ela reprova quando a lista de tabelas do script encolhe.

A idempotência entra no mesmo teste, porque é a propriedade que o runbook promete a quem interrompe o script: a segunda rodada reporta re-embrulhados=0 e já na chave atual=1 nas duas tabelas.

Uma linha ilegível não aborta as outras. Registro embrulhado por uma TERCEIRA chave — o caso que o runbook chama de "veio de outro ambiente" — é contado, identificado por tabela e id, e deixa as demais seguirem; o envelope dele fica intocado.

O que o teste exigiu do código: rewrap-deks.ts era um main() disparado na CARGA do módulo, então importá-lo de um spec rodaria a rotação. O núcleo virou reenvelopar(db, cofre, reportarFalha), exportado, e main() — que lê ambiente, abre o pool, imprime e escolhe o código de saída — passou a rodar só sob require.main === module. O nome do script e a invocação do runbook (node scripts/rewrap-deks.js) NÃO mudaram.

Nenhuma chave e nenhum segredo é literal no teste: os dois nascem de randomBytes a cada rodada. Passphrase constante em fixture é string com cara de segredo indo para o histórico, e o gitleaks varre branches.

  • Código: apps/api/src/scripts/rewrap-deks.ts:93 (reenvelopar, a função exportada), :100 (a lista das duas tabelas, que é o que o teste fixa), :209 (o require.main === module que impede a importação de rotacionar)
  • Teste: apps/api/test/scripts/rewrap-deks.spec.ts — o caminho feliz (a sequência K1 → K1+K2 → reenvelopa → só K2), a idempotência, e o caso de falha da linha de outro ambiente, que também assere que o relato NÃO carrega segredo nenhum
  • Origem: AT-033 (EP-015/HS-022), finding BRB-010

RN-563 — O envelope diz qual chave o embrulhou, e esse rótulo nunca decide se ele abre​

user_credentials e project_git_connections ganham key_id text anulável — a impressão digital da chave mestra que embrulhou aquele DEK. Com ela, a pergunta que decide o passo 3 da rotação ("ainda há credencial na chave velha?") vira uma consulta:

select count(*) from user_credentials where key_id is distinct from '<atual>'
union all
select count(*) from project_git_connections where key_id is distinct from '<atual>';

IS DISTINCT FROM, e nunca <>: com <>, a linha de key_id NULO — o acervo anterior a esta coluna — sumiria da contagem, e "não sei qual chave" viraria "já está na atual", que é a leitura que faz alguém descartar a chave velha cedo demais.

O valor é HMAC-SHA256(chave derivada, 'brabo-master-key-id') truncado a 8 bytes, e não um contador no .env nem um hash da passphrase. Não há oráculo novo, e isso foi MEDIDO, não suposto: quem lê o banco já tem um oráculo de verificação perfeito — o próprio envelope é AES-256-GCM, que autentica, então testar uma passphrase candidata já custava um scrypt e um decipher. Testá-la contra a impressão custa o MESMO scrypt. E não há variável nova para o operador errar: o valor é função pura da chave que ele já publicou.

O rótulo é OBSERVABILIDADE, nunca AUTORIDADE. decrypt fica byte a byte como estava — tenta a atual, cai para a anterior, o GCM decide — e rewrap continua decidindo "já está na chave atual" pela TENTATIVA. Confiar no rótulo faria uma linha que MENTE (rótulo "atual", envelope na chave velha) ser pulada em silêncio, ficar fora da contagem de re-embrulhados, e o passo 3 a tornaria ilegível para sempre. Metadado incoerente vira perda de segredo; é por isso que o teste que fixa esta regra é o de uma linha mentirosa sendo re-embrulhada assim mesmo.

O que o rótulo compra na falha: quando NENHUMA das duas chaves abre, a mensagem deixa de ser genérica e nomeia o caso — "veio de outro ambiente" (rótulo desconhecido), "rótulo incoerente ou registro adulterado" (rótulo diz atual e não abre) e "não tem key_id" (linha anterior a esta coluna) são diagnósticos diferentes com ações diferentes. Nenhuma dessas mensagens carrega passphrase, chave derivada ou conteúdo do segredo — só impressões digitais.

Sem migração do acervo. O key_id nasce na próxima escrita (encrypt e rewrap o gravam). As linhas de hoje ficam NULL, e numa instalação existente a consulta só passa a valer depois da primeira rotação. Forçar re-embrulho de tudo só para preencher a coluna transformaria observabilidade em operação de risco sobre todas as credenciais.

O operador precisa da impressão corrente, senão a coluna não se compara contra nada: a api a registra no boot em uma linha, e a advertência de rotação que já existia passa a nomear as DUAS.

  • Código: apps/api/src/infrastructure/security/envelope-encryption.service.ts:130 (impressaoDigital), :107 (a linha de boot), :113 (a advertência com as duas), :226 (o diagnóstico de rewrap), :251 (diagnosticoDaChave); apps/api/src/db/schema/llm.ts:259 e apps/api/src/db/schema/git.ts:76 (a coluna nas duas tabelas); migration apps/api/src/db/migrations/0059_key_id_no_envelope.sql
  • Teste: o describe key_id da chave mestra em apps/api/test/infrastructure/security/envelope-encryption.service.spec.ts — o caminho feliz (impressão estável e determinística, gravada por encrypt e por rewrap), a prova de que o rótulo não decide (linha mentirosa é re-embrulhada), a linha legada sem rótulo, e os dois casos de falha (mensagem que nomeia a chave sem vazar segredo; "sem rótulo" dito como tal). A consulta em SQL de verdade está em apps/api/test/scripts/rewrap-deks.spec.ts, incluindo o caso da linha NULL contando como pendente
  • ADR: 0158
  • Origem: AT-034 (EP-015/HS-022), finding BRB-016

A chave de dispositivo ganha tela, e a tela diz o alcance de revogar (RN-561)​

RN-561 — A aba Configurações lista e revoga chave de dispositivo, marcando a ESPÉCIE — porque revogar a de MÁQUINA derruba o agente local em TODOS os projetos do dono​

A metade que faltava da RN-519, declarada por escrito desde então no CLAUDE.md: "o que segue aberto é só a TELA: apps/web não tem onde listar nem revogar, então quem quiser revogar chama a rota". As duas rotas existem — o DELETE desde o ADR 0118 e o GET desde a RN-519 —, e tela nenhuma as usava. Esta RN é a tela, e ela não abre rota nova: o que a listagem já devolve basta para desenhar tudo o que segue.

Onde ela mora, e por que de PROJETO. Ela leva a aba Configurações de 17 para 18 seções e é a 13ª na ordem de render (device-keys, grupo pessoas, logo depois de access-tokens — a vizinha que faz o mesmo com PAT); a ordem do registro é a MESMA do barrel, porque é ela que o scroll-spy usa. De projeto porque o endpoint é /projects/:projectId/runner-device-keys: uma tela de CONTA exigiria rota nova, e o recorte é sobre a que existe.

A ESPÉCIE aparece, e não é enfeite — é o alcance da única ação irreversível da tela. Desde a RN-543 a listagem marca projeto | maquina, e uma chave de MÁQUINA (project_id nulo) serve todos os projetos do dono e por isso aparece na lista de TODOS eles. Mostrá-la sem a marca faria a tela afirmar que é "a chave deste projeto", e quem clicasse em revogar acharia que derruba o agente local aqui quando derruba em todos — a tela mentiria justamente sobre o que menos pode errar. A marca é warning para a de máquina e muted para a de projeto, e nunca success: verde leria como "está de pé", que é a aritmética que a RN-548 já recusou.

A confirmação DEPENDE da espécie, e diz também o custo colateral. Revogar abre um diálogo com TRÊS frases, e a do meio é a que muda: a de máquina nomeia todos os seus projetos em modo runner (o laço de RevokeRunnerDeviceKeyUseCase sobre listRunnerModeReachableBy), a de projeto nomeia o projeto dela. Um texto só para as duas mentiria numa das metades. A terceira frase é o custo que a RN-520 declarou e que esta entrega não muda: o alvo da desconexão é {projeto, usuário} e nunca {chave}, então outro runner do mesmo usuário no mesmo projeto também cai — mesmo autenticado por PAT ou por outra chave — e reconecta sozinho se a credencial dele ainda valer.

Uso registrado NÃO é agente de pé, e o vocabulário é o MESMO da RN-548. lastUsedAt é a classe de dado do workspaceVerifiedAt (RN-468): registro de uma confirmação, nunca batimento. A ressalva é dita em TEXTO na legenda, e a coluna de status fala de REVOGAÇÃO ("ativa"/"revogada", um fato sobre a linha), nunca de conexão — quem sabe do AGORA continua sendo o canal do terminal. Inventar um segundo vocabulário para a mesma distinção seria a tela dizendo a mesma coisa de dois jeitos, e um dos dois envelheceria.

Cinco estados, e nenhum vira o outro (RN-088/RN-470). Nenhuma chave ("Nenhuma chave de dispositivo sua serve este projeto"), chave REVOGADA (que continua na lista por decisão da RN-519 — sumir com a linha faria a tela afirmar que nunca existiu, e ela não ganha botão porque revogar de novo, ainda que idempotente na api, sugeriria efeito que não sobrou), lastUsedAt NULO (a chave ÓRFÃ da RN-473: registrada por uma aba que fechou no meio do fluxo e nunca usada por runner nenhum — texto próprio, e uma explicação abaixo da tabela que só aparece quando existe uma, porque explicar o que não ocorre é ruído), "ainda não carregou" e "não consegui ler". "Não sei" nunca vira "não tem".

O papel é o do ENDPOINT, e é o EFETIVO do projeto. O mínimo é developer — o que RunnerDeviceKeysController exige nas TRÊS rotas (RN-102) — e a comparação sai da MESMA função que a RN-548 criou (podeLerChavesDeDispositivo, sobre roleAtLeast), nunca uma segunda régua e nunca role === 'x' || role === 'y' à mão. O que muda em relação à RN-548 é o INSUMO: lá o painel lia o papel de WORKSPACE e declarava a lacuna, porque monta em lugares que não buscam project_members; aqui os dados estão à mão — a seção mora na mesma aba que MembersSection, com a MESMA queryKey, e o react-query deduplica —, então o papel é composto como no caso de uso (projectRole ?? workspaceRole, uma SOBREPOSIÇÃO nos dois sentidos, RN-471) e a lacuna fecha em vez de se repetir. Papel AUSENTE não é papel INSUFICIENTE: enquanto a lista de membros está em voo a tela diz que está verificando, e não acusa quem lê de não alcançar developer. Quem não alcança continua vendo a seção inteira, o que uma chave de dispositivo é e o que revogar a de máquina custa — some o CONTROLE, nunca a INFORMAÇÃO (ADR 0064) —, e o motivo é dito UMA vez em TEXTO (title em elemento disabled não abre no Chromium). Como as três rotas pedem o MESMO papel, quem não alcança também não vê a LISTA: a tela deixa de perguntar o que a api negaria, em vez de transformar um 403 previsível em "não consegui ler". Isto não é fronteira de segurança — quem recusa é o RolesGuard.

Uma por vez, e por isso não há desfecho de lote. A tela não oferece "revogar todas", então a régua da RN-469 — ação de UI que vira N chamadas não é transação, e a tela diz isso — não se aplica: oferecer o lote criaria um desfecho parcial a narrar para uma ação que ninguém pediu. Falha na revogação mostra a frase da PRÓPRIA api e a linha continua na lista.

  • Código: apps/web/src/routes/settings/RunnerDeviceKeysSection.tsx:76 (a seção inteira; o papel efetivo em :107, a consulta em :130, a confirmação por espécie em :326); apps/web/src/routes/settings/sumario.ts:62 (a entrada nova, 13ª na ordem de render, das 18); apps/web/src/routes/ProjectSettingsTab.tsx:86 (a composição, na MESMA ordem); apps/web/src/locales/{en,pt-BR}/settings.json (runnerDeviceKeys.*). Consome listRunnerDeviceKeys/ revokeRunnerDeviceKey (apps/web/src/lib/api-client.ts:431) e podeLerChavesDeDispositivo (apps/web/src/lib/agente-de-maquina.ts:68) sem alterar nenhum dos dois
  • Teste: apps/web/src/routes/settings/chaves-de-dispositivo.test.tsx — as duas espécies marcadas na mesma lista; a revogada que fica, marcada e sem botão; a órfã com texto próprio e a explicação que NÃO aparece sem ela; vazio e falha com textos diferentes; a ressalva de que uso registrado não é agente de pé; o aviso de alcance nas DUAS espécies, com a chamada saindo só depois de confirmar; cancelar sem revogar; a falha trazendo a frase da api com a chave ainda na lista; viewer sem chamar a rota e com o motivo em texto; a sobreposição do papel de projeto nos DOIS sentidos; e o papel ausente que diz "verificando" em vez de acusar
  • Fica declarado e NÃO fecha: (1) o alvo da revogação continua sendo {projeto, usuário} e nunca {chave} — a tela DIZ isso e não muda, porque mudar exige coluna nova em runner_socket_tickets e contrato novo de auth (RN-520), frente própria com ADR; (2) numa instalação sem PROJETO, a chave de máquina recém-criada pela RN-552 segue inalcançável: as duas rotas são de projeto, e sem projeto não há a quem perguntar — FECHOU na RN-611, com rota e seção por CONTA; (3) a visão de maintainer (listar/revogar de qualquer usuário) continua FORA por decisão da RN-519, não por omissão desta tela; (4) sem E2E de navegador — o que se prova aqui é o que a tela AFIRMA, e isso a suíte de componente prova
  • Origem: AT-012 (EP-003/HS-007)

RN-566 — O Infra Lead recusa container_start por MODO antes de propor: a lacuna da RN-494 fecha na tool que a declarava​

propose_container_start propunha às cegas. dispatch_container_start/2 montava o payload e chamava EngineApiClient.propose_action(...) direto — sem uma leitura de projeto entre uma coisa e outra, e com zero ocorrências de execution_mode na tool inteira. Num projeto runner a proposta só podia terminar em falha: desde a RN-507/ADR 0145 container_start não atende esse modo, porque o payload dela ELEGE uma candidata do roteamento do Arquiteto (ADR 0131) e em runner não há roteamento contra o qual eleger — o tipo daquele modo é container_start_via_runner.

A régua não é nova, e não nasceu uma segunda. A tool irmã já recusava localmente (RN-508) e a página /containers já ramificava por DESTINO (RN-521, acaoDeSubidaDoModo): container e mounted sobem pelo BROKER, runner sobe pelo agente local (ADR 0144/RN-503). O que esta regra faz é dar à tool que faltava a MESMA régua — uma leitura de projeto (recusa_local_de_subida/2, que devolve nil ou motivo) e uma CLÁUSULA por tool (recusa_por_modo/3). Duas réguas paralelas divergiriam no primeiro modo novo do enum.

A recusa de container_start é lista de PERMITIDOS, como a do próprio broker: container/mounted passam, runner é recusado NOMEANDO a tool irmã, e modo novo no enum nasce recusado com mensagem em vez de proposto por omissão. Projeto inexistente também recusa — mesma cláusula das duas tools.

A recusa é ENTRADA do laço (RN-163): texto de RESULTADO de ferramenta, que o modelo lê e usa para chamar a tool certa. Nunca agent.error, nunca fim de turno, nunca proposed_action — a api não é chamada. E não é silêncio: o emit do tool.call passou a acontecer ANTES da recusa nas DUAS tools (na irmã ele estava dentro do ramo que propõe), como no dispatch_tool/2 genérico do mesmo módulo — recusa sem rastro no event log é a chamada sumindo do timeline do humano.

As leituras são LOCAIS, e isso é a decisão. Project.get/1 e Engine.Runners.Registry.connected?/1 rodam no MESMO processo BEAM do Infra Lead; um HTTP à api aqui poria uma chamada de rede dentro do laço do agente. O custo declarado no CLAUDE.md — "corrigir exigiria tocar o prompt/instrução do Infra Lead" — não se confirmou: nenhuma linha de prompt mudou, e o texto da recusa é o que o modelo lê como resultado.

O que esta regra NÃO fecha, e é metade da lacuna: a proposta cega por AUSÊNCIA DE IMAGEM continua possível em container/mounted. A recusa aqui é sobre MODO, e exigir imagem decidida inverteria a ordem — eleger a imagem é justamente o que essa proposta FAZ (RN-491). A /containers checa as TRÊS coisas (imagem decidida, modo, pasta confirmada) porque tem um humano clicando; o agente passa a checar UMA. GetInfraContextUseCase segue sem executionMode — enriquecer o contexto do Infra Lead é frente à parte. container_stop/container_remove não entram: são propostas pela tela, nunca por agente (RN-495).

Nenhum teto muda: container_start segue proposed_action de verdade, maintainer, nunca semeada em auto-aprovação (até a RN-671, que a semeia no aceite do handoff da Infra), e container_remove segue no teto absoluto de git push/comando privilegiado (RN-418).

  • Código: apps/engine/lib/engine/infra/infra_lead_server.ex:599 (o dispatch de container_start consultando antes de propor), :895 (recusa_local_de_subida/2 — a leitura ÚNICA do projeto), :474 (a cláusula de container_start: lista de permitidos), :477 (a recusa nomeando container_start_via_runner), :492 (a cláusula da irmã, com a segunda pergunta — runner conectado), :409 (o emit do tool.call movido para antes da recusa); apps/engine/lib/engine/infra/tools/propose_container_start.ex (moduledoc — a tool deixou de propor às cegas)
  • Teste: apps/engine/test/engine/infra/infra_lead_server_test.exs:422 (caminho feliz em mounted — o broker atende os dois), :448 (caso de falha: runner recusa NOMEADO, propose_action nunca chamada, o tool.call narrado mesmo assim), :538 (projeto inexistente), :380 (o caminho feliz de container, que passou a exigir a linha no banco)
  • ADR: 0144, 0145
  • Origem: AT-048 (EP-020/HS-032) — a lacuna declarada no CLAUDE.md desde a RN-494

RN-565 — O manifesto assinado sai com o que a Release TEM, e não espera a plataforma que nunca constrói​

O checksums.txt assinado (RN-524) é a pré-condição de duas outras regras: sem ele, GET /runner-releases/binary recusa com release_sem_manifesto (RN-525) e o install.sh recusa a si mesmo (RN-526) — em todas as plataformas, inclusive as que anexaram binário direito. Um manifesto que atrasa não é um manifesto atrasado: para quem instala nesse intervalo, é um manifesto ausente.

A medição, contra as três tags que existem. Em v4.0.0, v4.0.1 e v5.0.0, o alvo darwin-x64 (macos-13) ficou 24h00m01s na fila e foi cancelado pelo teto do Actions — o MESMO número nas três, o que diz que ele nunca chega a ser agendado. Os outros quatro jobs terminam em no máximo 4m18s (linux-x64 na v4.0.1, 17:38:28Z → 17:42:44Z). O job checksums tinha needs: build: o always() garantia que ele RODASSE (dependência cancelada está coberta), nunca QUANDO.

A regra: o job do manifesto não depende da matriz. Ele espera os ASSETS da Release, não os JOBS — com teto —, e publica o que existir, nomeando no log o que não cobre. A ausência de um alvo é desfecho previsto (::notice::), nunca erro; a ausência da Release continua sendo erro nomeado, porque este workflow só ANEXA e nunca cria; e nenhum binário anexado continua sendo erro, porque um checksums.txt vazio afirmaria que a release não tem binários quando o que houve foi a matriz inteira falhar.

O teto dos binários é 1200s, e o número não é arbitrário: é o timeout-minutes: 20 do próprio job build — o máximo que um alvo com runner pode demorar depois de começar. O que ele deliberadamente não cobre é o tempo de fila, e não cobrir tempo de fila é o ponto inteiro. O laço sai cedo quando todos os alvos chegam, que é o caminho feliz.

Nada do que o job FAZ muda. Ele continua montando o manifesto a partir dos assets (e não de dist-bin/, vazio ali), assinando com cosign keyless e verificando antes de anexar — RN-524 intacta, um manifesto e não cinco assinaturas. E um alvo que anexe DEPOIS do teto não fica órfão: o --clobber já existia para isso, e refazer o manifesto é um workflow_dispatch com a mesma tag.

O que esta regra NÃO faz: darwin-x64 continua não construindo. Trocar o label, tirar a plataforma ou pagar runner é decisão de dono, e o manifesto não pode depender dela — é justamente por depender dela que ele não saía.

Medido depois, na AT-065 (2026-09-13, por workflow_dispatch em ensaio): macos-13 é label sem runner (imagem aposentada pelo GitHub em dez/2025; 30min na fila, cancelado, run 34769280227). macos-15-intel agenda em 3–22s e constrói, mas reprova no --self-test-pty porque, sob o Bun, o onData do node-pty não entrega a saída do filho (oven-sh/bun#25822, aberto); a mesma prova passa sob Node no mesmo runner (run 34770476634). A decisão pendente deixou de ser de runner e passou a ser esperar o Bun ou tirar a plataforma. A regra acima não muda com isso.

Decidido em 2026-09-27 (ADR 0174, AT-065): a plataforma saiu. A matriz passou a QUATRO alvos, e ALVOS_ESPERADOS acompanhou — é contra ele que a ausência é declarada, e deixar darwin-x64 ali faria todo manifesto anunciar, para sempre, uma falta que é decisão. O Mac Intel fica com npm install -g @brabo/runner (sob Node, onde a mesma prova passa): o install.sh diz isso sem tentar baixar, o proxy GET /runner-releases/binary recusa darwin-x64 com 400 próprio que aponta o npm, e o painel do navegador nem pede o download. A regra desta RN não muda: ela continua valendo para qualquer alvo que fique sem runner, e scripts/ci/alvos-do-runner.spec.ts reprova quando a matriz, a lista da api, o case do instalador e a do navegador divergem.

  • Código: .github/workflows/build-runner-binaries.yml:283 (o job, agora sem needs:), :318 (timeout-minutes: 40, que cabe as duas esperas), :335 (ALVOS_ESPERADOS no JOB, para os dois passos lerem a mesma lista), :397 (o passo que espera), :401/:402 (os dois tetos), :410 (a Release ausente, que é erro), :433 (o teto dos binários, que é notice), :499 (o manifesto nomeando o que não cobre)
  • Teste: scripts/ci/checksums-nao-espera-a-matriz.spec.ts — que o job não tem needs:, que os dois tetos existem, que o teto dos binários é derivado do timeout-minutes do build (e não copiado), que ele cabe no timeout-minutes do próprio job, que ausência de alvo é notice e ausência de Release é error, que ALVOS_ESPERADOS bate com a matriz, e as quatro garantias do ADR 0149 que esta mudança não toca (uma assinatura, verificar antes de anexar, id-token: write, o install.sh no manifesto). É ESTÁTICO, e o limite é declarado no topo do arquivo: o job só se prova numa tag final, e nunca rodou em nenhuma
  • Lacuna DECLARADA: a matriz é de TRÊS alvos desde o ADR 0187 (o win32-x64 saiu pelo mesmo molde do ADR 0174, e ALVOS_ESPERADOS acompanhou) e a Release continua recebendo dois — o darwin-arm64 está provado em ensaio (RN-688) e só falta uma tag final exercitá-lo. O manifesto vai nascer cobrindo dois e vai dizer isso, que é a diferença entre parcial e silencioso. E, com a matriz falhando inteira, o job gasta as duas esperas antes de recusar
  • ADR: 0149
  • Origem: AT-051 (EP-009/HS-035)

RN-567 — proposed_action.created diz QUAL regra decidiu: o reason de decide() passa a sobreviver quando a ação passa​

decide() sempre devolveu um par — policy e reason —, e a razão é escrita em todos os pontos de retorno: o IAM insuficiente, o piso do container (RN-492), agent_autonomy, as linhas do permissions.json, o comando composto, e cada teto absoluto. Mas ProposeActionUseCase só a guardava num lugar: rejectionReason, que initialStatusFor preenche só quando a política nega. Numa auto-aprovação — o desfecho que mais precisa de auditoria — o event log dizia "a política decidiu" (RN-049) e nunca qual regra. status tornou a auto-aprovação distinguível de um clique; o motivo é o degrau seguinte.

A regra: o payload do evento de SESSÃO proposed_action.created ganha reason, com a string de decide() como está, nos TRÊS desfechos — auto_approved, pending e denied. Não nasce segunda régua "estruturada": a string já é a fonte, e um enum paralelo divergiria dela no primeiro teto novo. No denied o valor é o MESMO que vira rejectionReason.

O outbox NÃO ganha o campo, e isso é decisão. A linha de outbox proposed_action.created é contrato api↔engine, e nenhum consumidor do engine lê o motivo; alargar contrato sem consumidor é o corte que o ADR 0153 nomeia. A linha session_event.appended que AppendSessionEventUseCase grava para o mesmo evento carrega só eventId/seq/type, então o motivo também não vaza por ela.

Ausente não é "sem motivo". Evento gravado antes desta regra não tem o campo, e não há reprocessamento nem coluna nova: quem lê trata reason ausente como "não registrado". Todo evento novo tem o campo, porque Decision.reason é string obrigatória no tipo.

Nada em decide() muda — nenhum teto, nenhum veredito, nenhuma string. A tela também não muda nesta regra: o motivo passa a EXISTIR no log, e mostrá-lo é outra entrega. (Desde a RN-614 a tela o mostra na linha do evento no painel de log e no ApprovalCard, pela mesma frase.)

O que o motivo NÃO diz, declarado: ele nomeia a regra, não a RAIZ do escopo — o ponto 7 do ADR 0055 pedia qual raiz autorizou, e escopo: cd dentro da pasta do projeto não carrega o caminho. (A raiz passou a ir no evento em campo PRÓPRIO desde a RN-609, relativa e nunca absoluta.) No comando composto, a razão diz que todos os segmentos bateram em allow, sem dizer qual padrão casou qual segmento. E as linhas do permissions.json repetem os TOKENS do comando no texto (o label de matchAgainstFile) — o mesmo comando que já mora em proposed_actions.payload e no card de aprovação.

  • Código: apps/api/src/application/use-cases/actions/propose-action.use-case.ts:264 (reason no payload do evento de sessão), :223 (o comentário do outbox sem o campo), :227 (o payload do outbox, intacto), :356 (o rejectionReason, que continua só no deny); apps/api/src/domain/actions/decide.ts:292 (Decision, a fonte da string)
  • Teste: apps/api/test/application/use-cases/actions/propose-action.use-case.spec.ts:902 (caminho feliz: auto-aprovação grava agent_autonomy: auto_approve), :948 (require_approval pelo default e pelo teto da trava de merge), :1047 (deny com o mesmo texto do rejectionReason), :999 (o outbox sem reason)
  • ADR: 0048, 0055 (ponto 7, parcialmente)
  • Origem: AT-066

RN-568 — A presença de QA, SecOps e dos membros de área no painel do time é decidida pela sessão INTEIRA, não pela janela de 200 eventos​

A Visão geral e a aba Executores montam a roster com deriveAgentRoster sobre os ÚLTIMOS 200 eventos da sessão (useSessionEvents, latest: true). Dois dos fatos de presença saíam dessa janela: gatesEverOpened (qa e secops entram quando algum pr.gate_changed/infra.gate_changed já aconteceu) e delegatedSubagents (membro de área entra com delegação registrada). Os dois eventos são dos primeiros de uma execução — numa sessão longa eles saem da janela, e QA, SecOps e qa-automacao sumiam do painel de uma execução que continuava rodando. É a mesma classe de defeito que já tinha sido fechada para executionActivated, e o resumo do workspace (RN-090) já agregava os dois fatos sobre a sessão inteira (bool_or e selectDistinct em projects-summary.repository.ts) — as duas telas o carregavam e ignoravam esses dois campos.

A regra: deriveAgentRoster/rosterFactsFromEvents aceitam um agregado OPCIONAL (AgregadoDaSessao), e as duas telas o preenchem com o resumo. Quatro decisões, cada uma com motivo:

  1. Ausente é "não li", nunca "não houve". Sem resumo carregado (primeiro paint, erro), o fato sai só da janela, exatamente como antes. Leitura ausente nunca vira "QA não está aqui".
  2. O agregado SOMA à janela, não a substitui (OU lógico no gate, união nas delegações). Os dois fatos são MONÓTONOS dentro de uma sessão — gate que abriu não desabre, delegação registrada não some —, então a janela só produz falso NEGATIVO, nunca falso positivo. Um false agregado que discorde de um gate VISTO na janela é resumo atrasado (as duas leituras têm cadências próprias de poll), e não pode esconder QA.
  3. Só vale quando o resumo é da MESMA sessão que a tela lê (latestSessionId === sessionId). O resumo agrega a sessão mais RECENTE do projeto; a Visão geral lê essa mesma sessão, mas a aba Executores lê a sessão de EXECUÇÃO vigente (RN-139). Uma ideação aberta depois faz as duas divergirem, e aí o agregado seria de OUTRA sessão — a tela volta a decidir só pela janela.
  4. Só a PRESENÇA muda. rosterFromFacts (a regra única, compartilhada com o card do dashboard) fica intacto, e o STATUS de cada agente (gateStatus, subagentStatus) continua lido da janela. Nenhuma requisição nova e o teto de 200 não muda.

delegatedSubagents entrou pelo MESMO molde porque cabe sem mudar regra: a tabela delegations que o resumo lê e os eventos delegation.* que a janela lê nascem juntos em RecordDelegationUseCase (uma linha, um evento, os três desfechos), então os dois conjuntos descrevem a mesma coisa.

executionActivated (AT-130). É o terceiro fato do mesmo molde: a aba Executores o lia do resumo SEM a guarda do item 3, então com uma sessão mais nova que a de execução o resumo (dela) dizia false e os dev agents sumiam — ou true, e apareciam sem prova. Agora ele vai em agregado.executionActivated só quando latestSessionId === sessionId, e deriveAgentRoster o SOMA (OU lógico, monótono) à janela e ao parâmetro, que também passa a olhar execution.activated nos eventos. Sem campo novo na API. Testes: ProjectExecutorsTab.test.tsx (resumo de OUTRA sessão, nos dois sentidos). Segue aberto: com a sessão de execução longa E o resumo de outra sessão, o evento pode ter saído da janela e a aba volta a decidir só por ela — é o custo da guarda, o mesmo dos outros dois fatos.

  • Código: apps/web/src/lib/agent-status.ts:324 (AgregadoDaSessao),

  • Código: apps/web/src/lib/agent-status.ts:324 (AgregadoDaSessao), :296 (o parâmetro opcional de rosterFactsFromEvents), :304 (a união das delegações), :312 (o OU do gate), :410 (o parâmetro repassado por deriveAgentRoster); apps/web/src/routes/ProjectOverviewTab.tsx:97 e apps/web/src/routes/ProjectExecutorsTab.tsx:111 (o agregado com a guarda de sessão)

  • Teste: apps/web/src/lib/agent-status.test.ts:628 (o bloco inteiro: :633 gate fora da janela traz QA/SecOps com status da janela, :658 sem agregado a janela decide sozinha, :676 false agregado não esconde gate visto na janela); apps/web/src/routes/ProjectExecutorsTab.test.tsx:385 (QA e membro de área voltam pelo resumo), :397 (resumo de outra sessão não decide); apps/web/src/routes/ProjectOverviewTab.test.tsx:301 (SecOps volta pelo resumo)

  • Origem: AT-047 — a lacuna declarada no CLAUDE.md e nos comentários das duas telas

RN-569 — O grafo é reconstruído do event log pelo MESMO tradutor do projetor vivo, sem tocar a outbox, e nunca sai como sucesso calado​

O ADR 0152 (decisão 4) recusou backup de neo4j_data apoiado numa premissa — o grafo pode ser descartado porque pode ser reconstruído — cujo mecanismo não existia: grep por reprojet/reproject em apps/ e scripts/ voltava vazio, e application/graph-projection/ só tinha o projetor PARA FRENTE. Enquanto não existisse, o grafo não era descartável: era perdido.

A regra: apps/api/src/scripts/reprojetar-grafo.ts (pnpm --filter api grafo:reprojetar; node scripts/reprojetar-grafo.js na imagem) reconstrói o grafo varrendo a fonte. Cinco decisões, cada uma com motivo:

  1. Um tradutor só. A tradução evento → nó/aresta SAIU do GraphProjector para GraphEventTranslator, e os dois caminhos a chamam — o projetor chega à fonte por uma linha de outbox, a reprojeção por cursor. Dois tradutores divergiriam, e o divergente seria o que roda uma vez por ano. A superfície de injeção do projetor NÃO mudou (ele monta o tradutor com as dependências que já recebia). Um teste reprova tipo novo em GRAPH_PROJECTABLE_EVENT_TYPES sem tradução.
  2. As fontes são duas, e são as de verdade. handoff.offered, psychologist.hypothesis_proposed e anamnese.profile_updated saem de session_events; a Interacao sai de sessions em closed/closed_abnormally, porque fechar sessão é transição pura e não deixa evento no log. PromptTemplate/PromptVersion NÃO saem do event log e NÃO são reprojetados — voltam por scripts/dev/seed-prompts.ts.
  3. Idempotente, em lotes, e nunca apaga. Toda escrita é MERGE em chave natural; rodar de novo é o jeito de retomar. session_events é varrida em ordem de id (ULID, a chave primária), 200 por lote, e --after-event retoma depois do cursor impresso. Com --project, só as sessões daquele projeto.
  4. Não toca a outbox. O projetor vivo guarda o progresso em outbox_events.processed_at; a reprojeção nem lê nem escreve essa tabela, e por isso roda com a api de pé sem roubar, reabrir ou marcar linha.
  5. Nunca sucesso calado. Grafo desligado é recusado ANTES de ler o log (um escopo vazio "passaria" com zero projetados); Neo4j que cai no meio para com o último evento concluído; projeto inexistente é recusado sem gravar nada; e item com payload incoerente é contado, nomeado, e o processo sai com 1.

O que esta regra NÃO fecha: PerfilAnamnese é snapshot por usuário + dimensão, SEM projeto — o último a escrever vence —, então uma rodada com --project pode deixá-lo no valor daquele projeto quando outro escreveu depois; a rodada total restaura o último global. Não há medição de tempo em event log grande (a única rodada real foi o compose de dev, 98 eventos, 2,5 s), e a prova no cluster local que o BRB-018 pede não foi feita — ele segue aberto.

  • Código: apps/api/src/application/graph-projection/graph-event-translator.ts:15 (EVENTOS_DO_LOG_PROJETAVEIS), :31 (FECHAMENTOS_DE_SESSAO), :56 (o tradutor), :75 (projetarEvento), :99 (projetarFechamentoDeSessao); apps/api/src/application/graph-projection/graph-projector.ts:77 (o projetor monta o MESMO tradutor), :141 (chega à fonte pela outbox e delega); apps/api/src/scripts/reprojetar-grafo.ts:124 (montarTradutor), :145 (reprojetarGrafo), :154 (a recusa antes de ler), :163 e :169 (projeto inexistente), :187 (a fase dos eventos, por cursor), :213 (a interrupção nomeada), :218 (a falha contada sem abortar o resto), :230 (a fase das sessões fechadas), :301 (lerArgumentos), :370 (só a invocação direta reprojeta); apps/api/package.json:44 (grafo:reprojetar); Makefile:61 (test-reprojecao)
  • Teste: apps/api/test/scripts/reprojetar-grafo.spec.ts:349 (projetor para frente → apaga o subgrafo → reprojeta o log inteiro → mesma contagem de nós e arestas e mesma lista de chaves; a segunda rodada idêntica; a outbox intacta), :394 (por projeto), :417 (projeto inexistente, nada gravado — caso de falha), :432 (grafo indisponível é erro nomeado — caso de falha), :441 (argumento desconhecido recusado), :452 (o tradutor cobre exatamente os tipos projetáveis); apps/api/test/application/graph-projection/graph-projector.spec.ts (o projetor para frente, inalterado, sobre o tradutor extraído). Os quatro primeiros exigem Neo4j de pé e PULAM sem ele — a CI do api não sobe Neo4j.
  • Origem: AT-032 (BRB-018) — a premissa do ADR 0152 sem mecanismo

RN-570 — O instalador sobe a instalação só com arquivos que a Release assinou, conferidos antes de perguntar ou gravar qualquer coisa​

A RN-549 mediu, na primeira vez que o instalador rodou numa máquina limpa, que o install.sh publicado não subia nada sozinho: ele chamava docker compose -f docker/docker-compose.install.yml, um caminho RELATIVO ao diretório de onde rodava, e esse arquivo não era asset da Release, não entrava no checksums.txt assinado e não era baixado em lugar nenhum. Quem seguia o one-liner do runbook morria em "no such file or directory" DEPOIS de o script ter gravado o .env com os segredos. O mantenedor decidiu a saída em 2026-09-13 — asset assinado, e não clone (ADR 0160).

A regra:

  1. Quatro arquivos viajam com o instalador, no MESMO manifesto. O compose de instalação e os três que a instalação usa por caminho relativo — o postgres/init.sql e o ollama/pull-models.sh que o compose bind-monta, e o backup/test-restore-compose.sh que a migração (RN-530) executa — são publicados como assets brabo-install-* pelo job checksums e entram no sha256sum que gera o checksums.txt assinado. O quarto não estava no contorno do E2E e foi achado ao implementar: numa pasta sem checkout a migração morreria ali. O job confere cada linha do manifesto contra o arquivo (sha256sum -c --strict) antes de anexar.
  2. A tabela tem dois lados e um guarda. Quem publica lê scripts/ci/assets-do-instalador.ts; quem baixa lê o case de destino_do_asset_do_instalador no install.sh (bash 3.2, sem Node). O spec reprova a divergência entre os dois E deriva do compose todo bind-mount ./… que não estiver na tabela — que é exatamente como o defeito nasceu.
  3. Verificar vem logo depois da própria origem, e antes de tudo. O instalador baixa os quatro e compara cada sha256 contra o checksums.txt que verificar_a_si_mesmo já verificou (nunca um segundo download), por igualdade EXATA de nome. As três recusas são distintas — asset não publicado, manifesto que não o cobre, hash que não bate — e todas acontecem com a máquina intocada.
  4. Nunca cai no relativo. COMPOSE_DE_INSTALACAO nasce VAZIO e só ganha um caminho absoluto quando as cópias verificadas são postas sob a pasta da instalação, no primeiro instante em que são precisas (a migração ou a subida). O que já estiver lá é SUBSTITUÍDO, nunca lido — com rm antes do cp, para um symlink não levar a escrita para fora.

O que esta regra NÃO fecha: a prova ponta a ponta só acontece na primeira tag final depois do merge — o install-e2e.yml não roda em PR (ADR 0150) —, e nenhuma Release já publicada ganha os assets: elas seguem com o instalador antigo e o contorno de rodá-lo de dentro de um checkout na tag. A adjacência que o ADR 0160 declarou — test-restore-compose.sh chamava o Compose sem --env-file, e o Compose procura o .env na pasta do compose — fechou na AT-102: o instalador passa BRABO_ENV_FILE (--env-file, e não --project-directory, que também mudaria o nome do projeto e a base dos caminhos relativos) e o compose de instalação declara o volume backup_local que o serviço backup referencia, cuja falta recusava o arquivo inteiro com o profile ligado. Coberto por scripts/dev/prova-de-restauracao-env.spec.ts.

  • Código: install.sh:128 (COMPOSE_DE_INSTALACAO vazio até materializar), :587 (ASSETS_DO_INSTALADOR), :589 (destino_do_asset_do_instalador, a tabela do lado que baixa), :614 (baixar_e_verificar_os_arquivos_da_instalacao), :607/:615/:617 (as três recusas), :648 (materializar_os_arquivos_da_instalacao), :1573 (a migração materializa antes do backup), :1677 (a verificação logo depois da própria origem), :1831 (a subida materializa antes do up); scripts/ci/assets-do-instalador.ts:55 (a tabela do lado que publica), :93 (problemasDoMapeamento), :135 (prepararAssets); .github/workflows/build-runner-binaries.yml:468 (os assets preparados do checkout da tag), :494 (no mesmo sha256sum), :520 (a conferência --strict), :527 (anexados junto do manifesto)
  • Teste: scripts/dev/install-arquivos-da-instalacao.spec.ts:144 (caminho feliz contra uma Release de mentira), :162 (asset ausente, .env nunca gravado — caso de falha), :179 (hash que não bate — caso de falha), :191 (manifesto que não cobre), :202 (nome parecido não cobre), :217 (materializar sem verificar é recusa), :225 (o que já estava é substituído, e o symlink não leva a escrita para fora), :258/:273/:280 (a ordem em main e na migração); scripts/ci/assets-do-instalador.spec.ts:89 (todo bind-mount relativo é asset), :114 (o case do shell é a tabela do TypeScript), :148/:152/:161 (a esteira assina, confere e anexa); scripts/dev/install-e2e.spec.ts:247 (o contorno manual não volta, e o E2E afirma que o instalador os baixou), :295 (o token que baixa o manifesto não está no ambiente do instalador)
  • Origem: AT-026 — o achado da AT-008 (RN-549), decidido pelo mantenedor em 2026-09-13

RN-572 — A árvore do time traduz todo tipo dev.* que o engine emite, e só diz trabalho em curso onde o painel diz trabalhando​

Numa instalação real (14/09) os dev agents "não saíram do começou a task". O event log tinha dev.started seguido de dev.blocked_by_container, com o reason por extenso (RN-502). A árvore do time (timeline-tree.ts) não tinha tradução para o bloqueio — o que não está em TRADUCAO é descartado —, então o ramo parava no marco anterior e a frase do presente afirmava trabalho sobre um agente parado. O painel do time, na mesma tela de execução, já dizia aguardando para o mesmo evento (DEV_STATUS_EVENTS, RN-411): duas telas, duas tabelas, e só uma delas estava coberta pela comparação com o vocabulário do engine.

A regra, em três partes:

  1. Todo tipo dev.* que o engine emite tem decisão na árvore — traduzido em TRADUCAO ou declarado em TRADUCAO_FORA com o motivo (vazia hoje). Omitir deixou de ser possível: scripts/ci/vocabulario-de-eventos-dev.spec.ts deriva o vocabulário do engine pelo mesmo extrator do inventário de eventos e reprova, nomeando o tipo, o que faltar; e reprova também o tipo dev.* traduzido que o engine não emite. Na primeira rodada ele achou DOIS faltando (dev.blocked_by_container e dev.error) e UM sobrando (dev.rearmed, que a api grava com ator user e que por isso nunca chegou a ramo nenhum — a árvore só pendura marco de ator agent).
  2. Se o último marco de um dev agent é trabalho em curso não se decide na árvore. Para marco dev.*, o ramo só fica ATIVO quando o painel dá trabalhando ao mesmo evento (statusDoEventoDev, lido de DEV_STATUS_EVENTS). aguardando, ocioso, falhou e travado deixam o ramo parado — e com eles dev.idle, dev.blocked, dev.awaiting_gate, dev.awaiting_approval e dev.idle_tripped, que antes também contavam como ativos. Tipo dev.* que o painel não decidiu NÃO vira ativo: na dúvida a tela não afirma trabalho.
  3. O bloqueio por container é marco próprio (espera, cor de warning, a mesma do gate), com o reason do engine como detalhe — é ele que diz o que fazer. E dev.started deixa de dizer "começou a task": medido no engine, ele sai ao RECEBER a ordem de trabalhar, antes de try_claim (DevAgentServer.handle_cast(:work)); passa a dizer "procurando task", e quem afirma a task é dev.working, que agora mostra o título dela.

O que esta regra NÃO faz, e por quê:

  • container.start_failed não entra na árvore. É a razão do bloqueio, mas o ator é system (action-executor): pendurá-lo no ramo de um dev agent seria inventar autoria, e o único outro lugar da estrutura, o tronco, NÃO É RENDERIZADO por consumidor nenhum (medido: AgentTimelineTree e a seção Atividades do Shell leem só ramos). O evento continua no log cronológico, a um clique, e o ramo bloqueado já diz, pelo reason, que falta subir o container.

  • Os rótulos da árvore ficaram em pt-BR fixo nesta correção — migrá-los era mudança da tela inteira. A AT-134 fez essa migração depois, e ela NÃO mexeu na regra acima: a DECISÃO por tipo (se vira nó, que MarcoTipo, que detalhe) continua em TRADUCAO, no código, e é ela que o spec compara com o engine; só o TEXTO saiu, como CHAVE (ChaveDeRotulo) resolvida em executors:timelineTree.label.*, com a frase do presente em timelineTree.now.* e o detalhe de origem em timelineTree.detail.origin, nos dois idiomas (en é o default, RN-425). montarArvore recebe o idioma como argumento (getFixedT), e os dois consumidores (AgentTimelineTree e a seção Atividades do Shell) o passam do useTranslation — trocar de idioma refaz a árvore. Uma chave que a tabela usa sem texto em um dos dois locales reprova em timeline-tree.test.ts.

  • Código: apps/web/src/lib/timeline-tree.ts:161 (TRADUCAO), :205 (dev.started), :206 (dev.working com o título), :222 (dev.blocked_by_container), :227 (dev.error), :246 (TRADUCAO_FORA), :307 (o ativo lido do painel), :96 (ChaveDeRotulo, AT-134), :135 (tradutorDaArvore); apps/web/src/locales/{en,pt-BR}/executors.json (timelineTree.label, timelineTree.now, timelineTree.detail); apps/web/src/lib/agent-status.ts:134 (statusDoEventoDev); apps/web/src/components/AgentTimelineTree.module.css (.espera)

  • Teste: scripts/ci/vocabulario-de-eventos-dev.spec.ts:300 (o bloco da árvore: :308 todo tipo do engine decidido, :324 nada traduzido que o engine não emite, :342 nada nos dois lados); apps/web/src/lib/timeline-tree.test.ts:130 (a sequência medida: dev.started + dev.blocked_by_container deixa o ramo parado com o motivo — caso de falha), :149 (dev.started não afirma task), :156 (estados que o painel não chama de trabalho deixam o ramo parado; dev.working segue ativo com o título), :168 (dev.error encerra com o motivo), :367 (AT-134: toda chave usada tem texto nos dois locales, as subárvores têm as mesmas chaves, e o idioma passado vence o ativo)

  • Origem: AT-087 — instalação real de 14/09; o i18n dos rótulos, AT-134

RN-573 — O assistente de criação só pré-seleciona o modo que a instalação executa: sem broker, runner é o sugerido e container/mounted ficam inertes com o motivo em texto​

Numa instalação real da v6.1.0 (AT-085), o assistente pré-selecionou mounted — o install.sh sempre grava BRABO_PROJECTS_BASE, e com base conhecida a RN-513 pré-selecionava o modo. Mas a instalação por Release não tem broker de container (a imagem não é publicada, decisão 7 do ADR 0150), e container/mounted só sobem container pelo broker (RN-503). Resultado: seis dev.blocked_by_container e duas container.start_failed — ninguém trabalha no projeto, para sempre (RN-502).

A regra (ADR 0161):

  1. A api diz se há broker, na rota que o assistente já lê. GET workspaces/:workspaceId/projects-base passa a devolver brokerConfigurado ao lado de projectsBase — mesma pergunta ("que modo esta INSTALAÇÃO executa?"), mesmo mínimo (maintainer), nenhuma rota nova. A fonte é ContainerBrokerPort.configurado(), a mesma da leitura do estado observado. Diz que BROKER_URL existe, nunca que o broker responde.
  2. Três estados, que não colapsam. Broker confirmado: a RN-513 intacta. Ausência confirmada: container e mounted continuam na tela, inertes (o controle sai, a informação fica — RN-102), o motivo é dito UMA vez em texto abaixo dos cards, e runner é o pré-selecionado. Não sei (carregando, consulta que falha, campo ausente): não vira "tem" — mounted NÃO é pré-selecionado, a revisão da RN-513 — e também não vira "não tem": nenhum card trava e nada se afirma em texto; container segue selecionado, como antes.
  3. A escolha humana não aponta para o que não roda. Quem clicou num modo antes de a resposta chegar, e a resposta confirma que ele não executa aqui, cai para runner — a mesma regra que já derrubava mounted quando a base some.

O que esta regra NÃO fecha: a conversão de modo (RN-559) continua oferecendo container/mounted numa instalação sem broker; o instalador não pergunta se liga o broker, porque a imagem dele não é obtível por uma instalação de Release (medido — ver o ADR 0161); e no compose de desenvolvimento, que sobe o broker por padrão (RN-512) mas deixa BROKER_URL vazia até alguém a pôr no .env, o aviso aparece — e está certo, porque sem a variável a api nunca chama o broker.

  • Código: apps/api/src/interfaces/http/iam/workspaces.controller.ts:404 (getProjectsBase, :256 o campo novo), apps/api/src/interfaces/http/iam/dto/iam.response.dto.ts:554, apps/api/src/interfaces/http/iam/iam-http.module.ts (o módulo do broker importado só pela pergunta configurado()); apps/web/src/routes/NewProjectWizard.tsx:257 (brokerConfirmado), :260 (semBrokerConfirmado), :270 (modoExecutavel), :283 (o modo vigente), :716 (o card inerte), :728 (o motivo em texto); apps/web/src/locales/{pt-BR,en}/newProject.json (workspace.noBroker)
  • Teste: apps/api/test/interfaces/http/iam/workspaces-projects-base.controller.spec.ts:59 (com BROKER_URL, true), :69 (sem, false mesmo com base — caso de falha), :79 (só espaço conta como ausente), :52 (o mínimo continua maintainer); apps/web/src/routes/NewProjectWizard.test.tsx:504 (sem broker: runner pré-selecionado, os dois cards inertes, o motivo em texto), :523 (broker desconhecido: não pré-seleciona mounted nem trava nada), :534 (clique em container antes da resposta cai para runner), :479 (consulta que falha não afirma ausência)
  • Origem: AT-085 — instalação real da v6.1.0 em 2026-09-14, decidido pelo mantenedor em 2026-09-18

RN-574 — A /containers recusa antes do clique a subida de container/mounted numa instalação sem broker, com motivo próprio em texto​

A mesma instalação do AT-085 deixou aprovar, pela página de containers (RN-521), duas container_start que só podiam terminar failed com BrokerIndisponivelError. A página já recusava localmente o que sabia que ia falhar — sem imagem, runner sem pasta confirmada, papel abaixo de maintainer — e não sabia desta.

A regra (ADR 0161):

  1. A lista diz se há broker, em toda linha. GET workspaces/:workspaceId/containers ganha brokerConfigurado por linha, com o MESMO valor em todas, lido UMA vez por carga — é configuração da instalação, e trocar a lista por um envelope quebraria o contrato.
  2. Um quarto motivo de recusa, sem_broker_na_instalacao, para container e mounted quando brokerConfigurado !== true ("não sei" não vira "tem"). Vem logo depois de "já está de pé" e ANTES de "sem imagem decidida": numa instalação sem quem suba o container, mandar decidir a imagem seria apontar a porta errada. O texto diz o quê (esta instalação não sobe container para esses dois modos), por quê (sem broker, BROKER_URL vazia) e o que não é afetado (runner).
  3. runner nunca cai nele. Quem depende do broker é decidido pela MESMA ramificação por destino de acaoDeSubidaDoModo (usaBroker), não por uma segunda régua.

O que esta regra NÃO fecha: parar e remover não ganharam a recusa (sem broker não há container de container/mounted registrado de pé para parar, e o caso não foi medido), e o Infra Lead continua sem saber de broker ausente — ele recusa por MODO (RN-566), e propor container_start numa instalação sem broker continua possível pelo agente.

  • Código: apps/api/src/application/use-cases/containers/obter-visao-geral-de-containers.use-case.ts:71 (o campo), :150 (lido uma vez por carga); apps/api/src/interfaces/http/containers/dto/containers.response.dto.ts:408; apps/web/src/routes/containers-subida.ts:33 (o motivo), :94 (usaBroker), :122 (a recusa); apps/web/src/locales/{pt-BR,en}/containers.json (actions.bloqueio.sem_broker_na_instalacao)
  • Teste: apps/api/test/application/use-cases/containers/obter-visao-geral-de-containers.use-case.spec.ts:252 (sem broker, false em toda linha — caso de falha), :264 (com broker, true); apps/web/src/routes/containers-subida.test.ts:54 (container e mounted recusam antes da imagem), :75 (runner não é afetado), :84 (campo ausente recusa), :93 (já de pé continua dizendo isso); apps/web/src/routes/ContainersPage.test.tsx:562 (botão inerte e o motivo em texto, nunca propõe), :672 (runner segue subindo)
  • Origem: AT-085 — instalação real da v6.1.0 em 2026-09-14, decidido pelo mantenedor em 2026-09-18

O broker de container na instalação, com consentimento (RN-575)​

RN-575 — O instalador só liga o broker de container com um "s" digitado, mede o grupo do socket de dentro de um container, e grava as quatro linhas juntas ou nenhuma​

Numa instalação por Release, projeto container ou mounted nunca executava: quem sobe o container desses dois modos é o broker (ADR 0144), e a instalação não tinha o serviço porque a imagem não era publicada (ADR 0150, decisão 7). O ADR 0162 publica a imagem como a quinta e põe o serviço no compose de instalação, DESLIGADO; esta regra é o que decide quando ele liga, porque ligar entrega o socket do Docker da máquina a um serviço da instalação.

A regra:

  1. Só um "s"/"sim" digitado liga. A pergunta vem depois da base (a segunda raiz do broker deriva dela) e antes do .env, e DIZ em texto o que concede — o socket do Docker desta máquina, e que quem comanda o broker comanda o Docker — e o que o contém. Enter, qualquer outra resposta e a falta de terminal deixam desligado, e a falta de terminal é DITA, nunca decidida em silêncio. Enquanto a resposta não é sim, o Docker não é tocado.
  2. O grupo do socket é MEDIDO, de dentro de um container, com a própria imagem do broker, sem rede, rootfs read-only e o socket montado por --mount type=bind — que recusa uma origem inexistente em vez de criar uma pasta no host, como -v faria. É o gid visto de dentro que o group_add precisa (no Docker Desktop o do host não diz nada). Medição que falha, caminho que não é socket ou gid que não é número são RECUSA nomeada, que ensina a rodar de novo respondendo não — nunca o 999 do default do compose.
  3. As quatro linhas vão juntas ou nenhuma vai: COMPOSE_PROFILES=container-broker, BROKER_URL=http://broker:8090, DOCKER_GID e PROJECT_WORKSPACES_HOST_ROOT. Profile sem URL sobe um broker que ninguém chama; URL sem profile aponta a api para um serviço que não sobe. escrever_env recusa gravar o bloco sem o gid medido — defeito do script, não da máquina. BRABO_BROKER_IMAGE é gravada SEMPRE, ligado ou não: o Compose interpola o arquivo inteiro antes de filtrar por profile.
  4. A raiz da pasta gerenciada é CALCULADA e depois CONFERIDA. Antes da subida, <DockerRootDir>/volumes/brabo_project_workspaces/_data; depois, comparada com o Mountpoint do volume. Divergir, ou a api não alcançar o broker pela rede interna, vira PENDÊNCIA nomeada no fim — nunca recusa, que derrubaria uma instalação de pé por um passo que se conserta editando o .env.
  5. A decisão aparece no resumo final, nos dois sentidos, e a frase do que o instalador NÃO faz deixa de dizer que só a Pasta montada fica sem container: container também fica.

O que esta regra NÃO fecha: o DOCKER_GID e a raiz calculada não foram medidos no Docker Desktop do macOS, e o E2E da tag roda a metade interativa só no Linux. Docker rootless ou remoto não tem o socket em /var/run/docker.sock, que é o caminho que o compose monta, e cai na recusa do item 2 — ligar o broker ali não é oferecido por outro caminho.

  • Código: install.sh:1050 (consentir_broker), :1002 (medir_gid_do_socket), :1037 (calcular_raiz_gerenciada), :1034 (conferir_o_broker), :792 (escrever_env_do_broker), :855 (o broker no manifesto), :1812 (a ordem: depois da base, antes do .env); docker/docker-compose.install.yml (o serviço broker e a rede broker)
  • Teste: scripts/dev/install-broker.spec.ts:158 (sem terminal: desligado, dito, Docker intocado — caso de falha), :169 (Enter), :179 (n), :187 (s liga, com --mount e sem rede), :208/:221/:229 (as três recusas), :237 (a raiz como pendência), :248 (a frase final); scripts/dev/install-env.spec.ts:250/:259 (as quatro linhas juntas ou nenhuma), :270 (a guarda do gid), :291/:307 (o .env contra o parser do Compose: o broker sobe sem flag, com o token, as duas raízes e o gid, e a api aponta para ele — ou nada disso); scripts/dev/composes-em-conformidade.spec.ts:114 (as camadas do ADR 0130 no arquivo que viaja)
  • ADR: 0162
  • Origem: AT-097 — decidido pelo mantenedor em 2026-09-18

RN-577 — open_adr_pr e open_infra_pr são recusadas pelo agente, antes de propor, quando o projeto não tem repositório​

Numa instalação real (AT-088, 2026-09-14), um projeto sem repositório — project_repositories, project_git_connections e repo_bootstraps vazias — recebeu três open_adr_pr do Arquiteto e uma open_infra_pr do Infra Lead. O humano aprovou as quatro, e as quatro terminaram failed com "Projeto sem repositório provisionado" (ExecuteAdrPrUseCase, ExecuteInfraPrUseCase). A pessoa aprovou quatro ações que só podiam falhar.

Não é borda, é o caso comum. Desde a RN-541 criar um projeto não cria repositório, e o gatilho do repositório é o aceite do handoff do Arquiteto para o Dev Lead (RN-522). O Arquiteto trabalha ANTES desse handoff — então num projeto novo todo open_adr_pr falhava por construção.

A regra: as duas tools perguntam, ANTES de propor, se o projeto tem repositório, e sem ele RECUSAM com motivo NOMEADO — o que falta, e quando passa a existir. A proposed_action não nasce, e a fila de aprovação não recebe o que não pode dar certo.

O molde é o da RN-566, sem régua nova e sem tocar prompt. A recusa é resultado de ferramenta (RN-163), entrada do laço — nunca agent.error, nunca fim de turno —, e o modelo segue o turno lendo o motivo.

A leitura é LOCAL, e o predicado é o MESMO da execução. O engine já lia project_repositories direto do Postgres (Engine.Projects.ProjectRepository, os consumidores de default_branch/1 e remoto_de_trabalho/1), então não houve HTTP no laço do agente a medir nem a justificar: a pergunta nova, recusa_de_pr_sem_repositorio/2, é a MESMA que ProvisionedRepositoryRepository.findByProjectId faz nos dois casos de uso — existe linha para o projeto. Se os dois divergissem, a tool deixaria passar o que a execução recusa, ou recusaria o que ela aceitaria. As duas tools usam a MESMA função, com o mesmo texto.

No Infra Lead a pergunta vem antes do HALT. propose_infra_pr não propõe na hora: ela interrompe o turno para finalize/3 rodar o WorkflowsAgent e consolidar numa PR só. Recusar depois disso (em abrir_pr/3) teria gastado um laço de LLM inteiro e registrado duas delegações completed para uma PR que não pode existir. A recusa acontece na interceptação, e o turno CONTINUA.

Rastro durável nas duas. A AT-048 achou que recusa local não deixava rastro no event log; a RN-566 resolveu emitindo o tool.call antes da recusa, e aqui vale o mesmo — no Arquiteto o dispatch_tool/2 já o emitia; no Infra Lead a interceptação de propose_infra_pr não emitia nada e passa a emitir, no ramo da recusa, com o título e os CAMINHOS (nunca o conteúdo dos arquivos). E o PORQUÊ vai junto: um tool.result com ok: false e erro com o motivo — a forma que o Criativo já usa —, porque só o tool.call diria que a chamada existiu, não por que ela não virou proposta. No caminho que propõe nada muda: o rastro dele continua sendo a própria proposed_action.

Os casos de uso de execução continuam recusando como antes — defesa em profundidade: a recusa do agente é uma camada ANTES, nunca no lugar.

O que esta regra NÃO decide: ONDE o repositório deveria nascer. O texto da recusa descreve o gatilho vigente, e ele MUDOU: a AT-092 foi decidida na RN-582 (ADR 0165) — o repositório nasce no aceite do handoff AO Arquiteto, antes do primeiro turno dele, com o aceite ao Dev Lead como segunda porta. O texto mudou junto, como esta regra prometia: ele nomeia o gatilho novo e, como o Arquiteto só trabalha depois desse aceite, diz que a recusa significa provisionamento FALHADO (o repository.provision_failed) ou projeto anterior à regra, com as duas saídas. Deixou de ser o caso comum; a recusa continua, porque essas duas situações existem. A open_infra_pr também é afetada no projeto novo por outro caminho (o Infra Lead é acionado pelo handoff do Arquiteto, que pode acontecer antes do handoff ao Dev Lead); esta regra só faz a proposta não nascer, não reordena os handoffs.

  • Código: apps/engine/lib/engine/projects/project_repository.ex:74 (recusa_de_pr_sem_repositorio/2 — o predicado e o texto, únicos para as duas tools); apps/engine/lib/engine/harness/tools/propose_adr.ex:54 (a recusa antes de propor), :61 (o tool.result com o motivo); apps/engine/lib/engine/infra/infra_lead_server.ex:251 (a interceptação de propose_infra_pr perguntando antes do HALT), :638 (recusa_de_infra_pr/4), :309 (o tool.call com os caminhos), :314 (o tool.result)
  • Teste: apps/engine/test/engine/agents/arquiteto_server_test.exs:107 (caso de falha: sem repositório, open_adr_pr nunca proposta, motivo como resultado de ferramenta, tool.call e tool.result no event log), :80 (caminho feliz: com repositório, propõe); apps/engine/test/engine/infra/infra_lead_server_test.exs:491 (caso de falha: sem repositório, nem propose_action nem delegação do Workflows, o turno segue, rastro durável), :89 (caminho feliz: com repositório, a PR consolidada é proposta)
  • Origem: AT-088 — instalação real em 2026-09-14

RN-580 — O agente conversacional que sobe sobre uma conversa recebe o FIM dela, com perguntas e ferramentas, e o começo que não coube é resumido com o número escrito​

Os seis agentes conversacionais (Criativo, PO, Arquiteto, Dev Lead, UX Designer e Staff) reconstroem o histórico do event log no init/1 — no restart e, também, quando sobem numa sessão que já tem conversa (o PO no handoff do Criativo). Medido em 13/09 (AT-073, contra a b61bc49cd), os seis tinham a MESMA cópia de rehydrate/2, com três defeitos:

  1. Liam o começo. EngineApiClient.list_events/2 pede limit=200 sem latest, e a rota interna — que nem aceitava latest — devolve os PRIMEIROS 200. Numa conversa de 201 eventos o agente acordava sem a mensagem que estava respondendo. A tela já tinha resolvido isso com latest: true e aviso de recorte (RN-180); o engine não tinha herdado.
  2. Reconstruíam só chat.message e agent.response. A pergunta feita por formulário (chat.structured_question) e as ferramentas chamadas (tool.call/tool.result) sumiam.
  3. Cortavam calados. E as leituras dos kickoffs (PO, Arquiteto, Dev Lead, UX Designer) e das regras do product_brief (Criativo) filtravam em memória os mesmos PRIMEIROS 200 eventos de todos os tipos: numa conversa longa com o Criativo, o brief nascia depois do evento 200 e o PO recebia "(sem product brief disponível)".

A regra:

  1. Um caminho só. Engine.Agents.Reidratacao substitui as seis cópias; os seis servidores chamam historico/3 no init/1.
  2. A cauda, com o teto do ADR 0060. A leitura é latest=true&limit=200 — o MAX_LIMIT da rota, nem mais (a api cortaria) nem menos (deixaria de fora o que ela entrega de graça). O teto NÃO ficou ilimitado.
  3. O que entra. chat.message e agent.response como sempre (de qualquer agente da sessão — é o que o PO herda do Criativo); chat.structured_question como fala do agente, com rótulos e opções; tool.call/tool.result do PRÓPRIO agente como nota de texto com a ferramenta, os argumentos (cortados em 1.500 caracteres) e o desfecho — nunca como mensagem role: "tool", porque o evento não guarda o id da chamada e um tool_result sem o tool_use correspondente é recusado pelo provider. chat.structured_question_answered fica DE FORA de propósito: a api grava as mesmas respostas num chat.message logo depois (AnswerStructuredQuestionUseCase reusa SendAgentMessageUseCase), e reidratar os dois duplicaria a resposta.
  4. O começo que não coube é dito, com o número. O seq é gapless e começa em 1, então o número de eventos omitidos é o seq do primeiro da cauda menos um — SUBTRAÇÃO, sem requisição a mais. Quando ele é maior que zero, o histórico abre com UMA mensagem de sistema que escreve esse número e traz o resumo da compactação de contexto mais recente do agente (quando foi gravado) e a abertura da conversa (as seis primeiras mensagens dos primeiros 40 eventos, cortadas em 500 caracteres). São mais duas leituras com teto — três no total, e uma só quando a conversa cabe.
  5. context.compacted grava o resumo daqui em diante — summary, agent e messagesSummarized, ao lado das contagens de sempre. Conversa compactada antes disto fica DECLARADA ("compactada N vez(es) antes de o resumo passar a ser gravado; aquele resumo se perdeu"), nunca reconstruída; os eventos antigos não mudam.
  6. Histórico ilegível não vira conversa vazia. Se a leitura falha, o agente recebe uma mensagem de sistema dizendo que não conseguiu ler o histórico.
  7. Kickoffs e refs do brief leem POR TIPO, pela cauda. A rota interna ganhou types (até 20, validados; malformado é 400) — mudança de CONTRATO, aditiva: sem latest/types a resposta é a de antes. Quando a leitura por tipo bate no teto, o kickoff diz que pode haver itens mais antigos.

Medido por agente: os seis mudaram na reidratação (init/1). Nos kickoffs, PO, Arquiteto, Dev Lead e UX Designer mudaram; o Criativo mudou nas refs do product_brief e no guardrail de zero regra; o Staff não tem kickoff (ADR 0088).

O que esta regra NÃO fecha: o texto que a ferramenta devolveu não estava no log — fechado pela RN-589, daqui em diante (sessão antiga continua sem). InfraLeadServer (que reidrata por Reidratacao.historico/3 desde a AT-150, sem cópia própria) e DevLeadTools.run_assessment/2 (que lê a CAUDA, latest com o mesmo teto de 200) também passam pelo caminho comum; nenhum leitor de list_events/2 sem latest sobra em apps/engine/lib. A reidratação pode trazer MAIS do que o contexto vivo tinha (a cauda inteira, mesmo o que já tinha sido compactado); o ContextManager compacta de novo no próximo turno.

  • Código: apps/engine/lib/engine/agents/reidratacao.ex:71 (historico/3), :46 (o teto), :104 (eventos_do_tipo/3), :152 (mensagens/2), :167 (pergunta estruturada), :170 (ferramenta), :271 (omitidos por subtração), :274 (o resumo do começo), :294 (a compactação), :328 (a abertura); apps/engine/lib/engine/harness/context_manager.ex:138 (o resumo gravado); apps/engine/lib/engine/sessions/engine_api_client.ex:1066

    gravado); apps/engine/lib/engine/sessions/engine_api_client.ex:1185 (list_events/3); os seis init/1 — apps/engine/lib/engine/agents/criativo_server.ex:89, po_server.ex:93, arquiteto_server.ex:87, dev_lead_server.ex:132, ux_designer_server.ex:85, staff_server.ex:86; as leituras por tipo — criativo_server.ex:435, po_server.ex:352, arquiteto_server.ex:313, dev_lead_server.ex:431, ux_designer_server.ex:246; apps/api/src/interfaces/http/internal/leitura-interna-de-eventos.ts:24, apps/api/src/interfaces/http/internal/internal-sessions.controller.ts:306, apps/api/src/infrastructure/persistence/drizzle/session-event.repository.ts:56

  • Teste: apps/engine/test/engine/agents/reidratacao_test.exs:57 (pergunta estruturada), :92 (a resposta entra UMA vez), :108 (ferramenta com desfecho), :128 (sem tool.result), :136 (ferramenta de outro agente fica de fora), :157 (pede a cauda com teto 200), :176 (o fim entra, o começo vira resumo com o número), :194 (o resumo da compactação), :233 (compactação antiga declarada), :248 (histórico ilegível — caso de falha), :255 (abertura falhando não apaga o número), :270 (leitura por tipo e o aviso de teto); apps/engine/test/engine/agents/reidratacao_dos_seis_test.exs:59 (os seis init/1), :84 (o brief depois do evento 200 chega ao PO), :118 (a regra depois do evento 200 entra no brief do Criativo), :135 (Arquiteto, Dev Lead e UX leem por tipo); apps/engine/test/engine/harness/context_manager_test.exs:74 (o resumo gravado), :101 (fallback do sumarizador); apps/api/test/interfaces/http/internal/leitura-interna-de-eventos.spec.ts:13 (sem os parâmetros, as opções de antes), :40 (tipo malformado é 400); apps/api/test/infrastructure/persistence/session-event-latest.repository.spec.ts:129 (types filtra e o limit conta só eles), :143 (types com latest)

  • Origem: AT-073, levantada em 2026-09-13

RN-578 — O clique que dispara turno de agente responde ao ACEITAR, e a recusa deixa de ser calada​

Numa instalação real da v6.1.0 (2026-09-14), responder o formulário de perguntas do Criativo levou 97,3 s, confirmar a prontidão 97,3 s e confirmar a arquitetura pronta 51,8 s — cada clique segurado pelo turno INTEIRO do agente, porque o handle_call do conversacional só respondia ao GenServer.call do controller do engine quando a Task do turno terminava. Turno acima do teto (120 s no Criativo, 180 s nos outros) virava 500 num comando que tinha funcionado. E a recusa de uma segunda mensagem com turno em curso ({:error, :turno_em_andamento}) era descartada pelo controller, que respondia 202: um "Continue" digitado durante o kickoff do Arquiteto foi aceito e nunca lido, sem rastro nenhum.

A regra (ADR 0163, que generaliza o ADR 0086):

  1. O aceite sai na hora. TurnoAssincrono.iniciar/3 responde :ok ao from assim que a Task sobe — depois de persistir agent.status: working, nunca antes. Vale para os seis conversacionais e para as quatro rotas de usuário que disparam turno (…/agents/:agent/message e, por ela, …/structured-question/:id/answer; …/readiness; …/agents/arquiteto/handoff-infra) e para a devolução de história (ReturnStoryUseCase). O status e o corpo da api NÃO mudam (201 { ok: true }): sempre significaram "aceito"; o que mudou foi quando chegam.
  2. A recusa ANTES de o turno subir é síncrona e nomeada. O engine responde 409 (turno_em_andamento, aguardando_aprovacao) ou 422 (sem_regra_de_negocio) com {error, motivo}, e a api repassa o MESMO status com a MESMA frase. turno_em_andamento ganha agent.error durável (origem politica); os outros dois já tinham.
  3. O desfecho do turno segue pelo canal e pelo log, nunca pelo HTTP. Falha continua agent.error durável, com a mesma origem de antes.
  4. A tela deixa de tratar "a chamada resolveu" como fim de turno. Depois do aceite, ela acompanha o fim pelo canal (agent.done) e, como rede de segurança, pela cauda do log a cada 4 s: fecha quando o agent.status persistido mais recente daquele agente não é working. Sem nenhum agent.status do agente na janela, não fecha — não saber não é "acabou".
  5. O handoff ao Dev Lead continua nascendo DEPOIS do de Infra. O :offer_dev_handoff que chega com o turno de fechamento do Arquiteto em curso fica pendente e roda quando o turno fecha — sucesso, falha, crash ou cancelamento. (Superado pela RN-672: o Arquiteto não oferece mais ao Dev Lead, e a pendência deixou de existir.)

O que esta regra NÃO fecha: o chat.message que a api grava antes de perguntar ao engine continua no log quando o engine recusa (agora explicado pelo agent.error, antes órfão e mudo); engine reiniciado no meio do turno não grava idle, e a faixa fica até o "Parar" ou um recarregamento; e mensagem ao infra pelo compositor caía no Criativo pela cláusula final de AgentCommandController.message/2 — pré-existente, medido aqui e fechado depois pela RN-584, que tirou o destinatário padrão.

Revisada para a MENSAGEM pela RN-673 (ADR 0191): a mensagem do usuário que chega com turno em curso não recebe mais turno_em_andamento — entra na fila do agente e é lida no fim do turno. A recusa do item 2 segue valendo para os comandos que não são fala (revisão, prontidão, oferta de handoff).

  • Código: apps/engine/lib/engine/agents/turno_assincrono.ex:120 (o aceite), :130 e :314 (a recusa durável); apps/engine/lib/engine_web/controllers/agent_command_controller.ex:241 (202), :243/:253/:263 (409/409/422); apps/engine/lib/engine/agents/dev_lead_server.ex:211; apps/engine/lib/engine/agents/arquiteto_server.ex:160 (adiar), :202 (drenar); apps/api/src/infrastructure/http-clients/api-to-engine-client.ts:591, :612; apps/web/src/lib/session-turno.ts:33 (turnoTerminouNoLog), :349 (acompanharTurnoPeloLog), :316; apps/web/src/routes/SessionPage.tsx:491, :517, :578; apps/web/src/lib/session-promocao.ts:141; apps/web/src/lib/recusa-do-agente.ts:17
  • Teste: apps/engine/test/engine/agents/turno_assincrono_test.exs:66 (aceite com a task viva), :84 (working gravado antes do aceite), :178 (recusa durável — caso de falha); apps/engine/test/engine_web/controllers/agent_command_controller_test.exs:35, :66 (409), :107 (422); apps/engine/test/engine/agents/arquiteto_server_test.exs:209 (ordem dos handoffs), :237 (cancelamento); apps/api/test/infrastructure/http-clients/api-to-engine-client.spec.ts:520, :538, :580 (500 continua genérico); apps/web/src/routes/SessionPage.turno-preso.test.tsx:181, :252 (409); apps/web/src/routes/SessionPage.readiness-turno-preso.test.tsx:172, :210 (422); apps/web/src/lib/fim-do-turno-pelo-log.test.ts:29, :76
  • Origem: AT-089 — instalação real da v6.1.0 em 2026-09-14

RN-579 — Com o canal da sessão vivo, a tela de Sessão troca o poll curto por invalidação e um fallback longo; resposta de corpo vazio tem ETag​

A instalação medida na AT-093 (v6.1.0, 14/09) mostrou UM navegador fazendo 7.088 requisições em 56 minutos — mediana de 118/min, pico de 263/min — contra os 300/min do RATE_LIMIT_USER. Duas abas visíveis no pico passam do teto, e o 429 cai no navegador do dono da conta. Quase tudo era poll de 3–5s da tela de Sessão sobre dados que mudam raro (ações, handoffs e backlog voltavam 304 em 99% das vezes), enquanto a tela já mantinha aberto o canal session:<id> com o engine. O teto NÃO muda: o que cai é o pedido.

A regra:

  1. O engine avisa toda escrita confirmada. A fachada Engine.Sessions.EngineApiClient emite event.appended (só type e actorId, nunca o payload) depois que a api CONFIRMA append_event, append_event_returning, propose_action (proposed_action.created), create_handoff (handoff.offered) e create_epic/story/task (backlog.*_created). Escrita recusada não avisa. Antes, só ArtifactEmitter e o Infra Lead avisavam, à mão — e avisavam mesmo quando a api recusava o append. Desde a AT-157, a escrita que a API faz por conta própria também avisa — a decisão de um humano noutra aba, uma transição de sessão, o chat respondido pelo modelo da própria api. AppendSessionEventUseCase e o SendChatMessageUseCase (os dois únicos pontos que gravam em session_events sem passar pelo engine) chamam SessionChannelNotifier.eventAppended, que pede ao engine POST /internal/sessions/:id/event-appended; o engine faz o MESMO event.appended (só type e actorId). O aviso sai DEPOIS do commit da transação mais externa (aposCommit no DrizzleUnitOfWork): transação desfeita não avisa, e avisar de dentro dela faria a web buscar antes de o evento existir. Escrita que veio do engine (rota /internal/*, marcada pelo OrigemEngineMiddleware) NÃO é avisada de novo pela api — a fachada já avisou. É melhor esforço e não é aguardado: o aviso perdido nunca falha a escrita, e o fallback de 15s o cobre. São só os dois pontos: outro caso de uso que passe a gravar em session_events sem AppendSessionEventUseCase precisa chamar o notifier também.
  2. "Canal vivo" é join CONFIRMADO. connectSessionHeartbeat marca a sessão viva no ok do join e a desmarca em erro/fechamento do canal ou do socket e no cleanup. O estado é por sessão e por aba, fora do React, porque o intervalo é de cada OBSERVADOR e três observadores (Shell, SessionPage, ContextAside) leem a mesma chave de eventos.
  3. Com o canal vivo, o poll vira fallback. Eventos, ações, handoffs, backlog e a própria sessão passam de 3–5s para 15s; o orçamento, de 5s para 30s. intervaloDaSessao é Math.max: quem já pollava mais devagar não acelera. Canal caído devolve o intervalo curto NA HORA — a tela nunca fica pior do que era.
  4. O TIPO do aviso decide o que invalidar, com janela. Todo aviso invalida os eventos e o orçamento; proposed_action.*/action.* também as ações, handoff.* os handoffs, backlog.* o backlog. Cada alvo tem janela mínima entre duas buscas (eventos 3s, ações/handoffs/backlog 2s, orçamento 10s): a primeira sai na hora, as seguintes dentro da janela viram UMA no fim dela. Sem isso, uma rajada de tool.result viraria uma busca por evento. A regra do achado C segue, e só para os EVENTOS: durante um turno em streaming eles não são antecipados; as ações são.
  5. Aba oculta não polla. refetchIntervalInBackground: false (o default do TanStack v5, agora escrito em OPCOES_PADRAO_DAS_QUERIES) — medido por teste, não suposto.
  6. Corpo vazio tem validador. Handler que devolve null saía com corpo vazio e SEM ETag (o res.send do Express só gera ETag com corpo), então o 304 era impossível: sessions/:id/budget voltou 304 em 0% das 483 chamadas medidas e execution/session em 22% (o tempo com execução ativa). A api grava o ETag do corpo vazio quando send sai sem corpo — decidido sobre o que vai ao fio, não sobre o valor do handler, para que um handler com @Res() que escreve depois nunca herde o validador do vazio. 304 NÃO reduz a contagem do rate limit (o guard conta antes do handler); reduz banda.
  7. Compõe com o acompanhamento do turno pela cauda do log (RN-578). O turno ACEITO continua sendo acompanhado pela leitura da cauda a cada 4s (acompanharTurnoPeloLog) — é a rede contra o agent.done perdido, e o aviso do canal pode se perder junto com ele, então o tique NÃO vira fallback de 15s. O que muda: o event.appended do agent.status do agente ACOMPANHADO roda a leitura NA HORA (avisoPedeVerificacaoDoTurno), e o turno fecha assim que o log diz que fechou, sem esperar o tique. Aviso de outro agente, de outro tipo, ou sem turno acompanhado não lê nada a mais. E o poll de 3s dos eventos continua PAUSADO durante o streaming (achados 2/7): intervaloDaSessao só vale quando a pausa não vale, e a invalidação pelo canal respeita a mesma regra do achado C.

Números (teste de orçamento, uma aba na tela de Sessão, por minuto): 123 com o canal caído (o comportamento de antes, intacto como fallback) e 46 com ele vivo — dos quais 24 são do Shell (resumo dos projetos e sessão de execução, que não são da sessão e seguem em 5s). Duas abas: 246 → 92. Desde a RN-632 (AT-278) as leituras de PROJETO pollam no ritmo de projeto (15s) e as abas Executores e Visão geral passam pelo MESMO criarInvalidadorDoCanal — a medição com as telas de verdade e duas abas mora lá.

O que esta regra NÃO fecha: o aviso da escrita que a api faz por conta própria (item 1, AT-157) é melhor esforço — se o engine estiver fora ou o aviso se perder, a tela chega pelo fallback, em até 15s (eram 3s); o aviso vai ao nó do engine que recebeu a chamada, e o broadcast do Phoenix PubSub o entrega aos inscritos de qualquer nó do cluster (não medido em cluster). O status working do roster continua dependendo do event.appended do agent.status, que sai depois de a api gravá-lo. projects-summary e execution/session (Shell) seguem em 5s. Durante um turno aceito, a leitura da cauda de 4s da RN-578 soma até 15 req/min, só enquanto o turno dura. Não é mudança de modelo de consistência (sem ADR): a fonte continua sendo o GET, o canal continua sendo só gatilho — como já era desde a Fase 4a —, e o que muda é a latência máxima das escritas sem aviso.

  • Código: apps/engine/lib/engine/sessions/engine_api_client.ex:581, :607, :630, :1073 (avisar_canal);

    :607, :630, :953 (avisar_canal); apps/engine/lib/engine/sessions/live_broadcast.ex (event_appended/3); apps/engine/lib/engine_web/controllers/session_command_controller.ex:46 (event_appended, a rota do aviso da api); apps/api/src/application/use-cases/sessions/append-session-event.use-case.ts:225 (eventAppended); apps/api/src/infrastructure/persistence/drizzle/drizzle-context.ts:39 (aposCommit), :53 (veioDoEngine); apps/web/src/lib/canal-vivo.ts:35 (fallback), :66 (estado), :108 (intervaloDaSessao), :134 (alvosDoEvento), :150 (janelas), :207 (criarInvalidadorDoCanal); apps/web/src/lib/session-channel.ts:161, :165, :131, :229; apps/web/src/lib/session-turno.ts:414 (o aviso: invalida e, do acompanhado, antecipa a leitura), :86 (avisoPedeVerificacaoDoTurno), :344 (a leitura imediata); apps/web/src/lib/hooks.ts (useSessionEvents, usePendingActions, useHandoffs, useBacklog); apps/web/src/lib/query-policy.ts:86; apps/api/src/interfaces/http/shared/etag-do-corpo-vazio.ts:40; apps/api/src/main.ts:80

  • Teste: apps/web/src/lib/canal-vivo.orcamento.test.tsx:138 (antes, 123), :155 (depois, 46), :176 (canal cai — caso de falha), :186 (rajada), :214 (aba oculta); apps/web/src/lib/session-channel.test.ts:237, :253 (queda — caso de falha), :267; apps/engine/test/engine/sessions/engine_api_client_aviso_no_canal_test.exs:29, :57 (append recusado não avisa — caso de falha), :71; apps/api/test/application/use-cases/sessions/aviso-ao-canal-da-sessao.spec.ts (AT-157: avisa com tipo e ator; recusada não avisa e transação desfeita não avisa — casos de falha; engine fora do ar não falha a escrita; escrita vinda do engine não é avisada em dobro), apps/api/test/infrastructure/http-clients/origem-engine.middleware.spec.ts, apps/engine/test/engine_web/controllers/session_command_controller_event_appended_test.exs; apps/api/test/interfaces/http/shared/etag-do-corpo-vazio.spec.ts:56 (a causa), :69, :98 (@Res() tardio — caso de falha); apps/api/test/interfaces/rate-limit.guard.spec.ts:183 (2 × 263 estoura, 2 × 118 não); apps/web/src/routes/SessionPage.canal-e-acompanhamento.test.tsx:166 (o predicado), :203 (o aviso do acompanhado fecha o turno sem esperar o tique), :221 (aviso de outro agente não lê, e o tique de 4s segue sendo a rede — caso de falha), :244 (turno fechado: nem aviso nem tique leem)

  • Origem: AT-093 — logs da api instalada (v6.1.0), 2026-09-14; AT-157 — o aviso da escrita da própria api

RN-581 — A conversa em curso segura a sessão por até 8h, e a sessão encerrada recusa conversa​

No exp001 o heartbeat fechou a sessão 30 segundos depois de a aba parar, com o Criativo tendo acabado de perguntar — e o event log continuou recebendo eventos por quatro minutos depois do closed_at. Dois defeitos, um de cada lado do fechamento: nenhum dos sinais de trabalho pendente da RN-064 via uma conversa esperando o usuário (um Criativo que terminou o turno está idle, sem handoff, sem ação, sem dev.*), e nada — nem o funil de eventos, nem os casos de uso da conversa, nem o engine — olhava o estado da sessão antes de gravar.

A regra, pelo lado do heartbeat:

  1. Conversa esperando o usuário é trabalho pendente. É o QUINTO sinal do pending-work: o evento de fim de turno mais recente da sessão (agent.response, chat.structured_question ou agent.error do lado do agente; chat.message ou chat.structured_question_answered do lado do usuário) é do AGENTE, e o ator é um conversacional. agent.error conta como fim de turno de propósito: pela RN-059 o agente diz no fio o que houve, e a próxima jogada é do usuário. Fala de quem não conversa (Psicólogo, dev agent) não segura.
  2. É o único sinal com TETO: 8 horas, contadas do FIM do turno do agente (o created_at daquele evento), não do último heartbeat. A api devolve o instante (aguardandoUsuarioDesde) e o engine aplica o teto (SESSION_CONVERSATION_IDLE_TIMEOUT_MS, default 28800000). Passado ele, a sessão fecha closed com causa PRÓPRIA, conversation_idle_timeout — não heartbeat_timeout, porque quem lê termination_reason (o Psicólogo, uma métrica por sessão) precisa separar a aba que sumiu da conversa que ninguém retomou. O TerminationClassifier a lê como :timeout: a api conhece seis causas nas hipóteses, e abrir uma sétima seria mudar aquele contrato. O teto vale com a aba ABERTA também (AT-152): o SessionServer tem um relógio próprio (:conversation_idle_check, a cada 5 min — sem variável de ambiente, só o hook de teste :session_conversation_idle_check_ms), independente do heartbeat — o ping da aba não o reseta nem o adia. A cada volta ele lê a pendência e só FECHA por teto (mesma causa, conversation_idle_timeout); api fora do ar, sem pendência ou pendência sem instante apenas reagendam, porque quem encerra por api fora do ar continua sendo o heartbeat. Antes, o teto só era checado quando o heartbeat expirava, e uma aba aberta (ping a cada ~10s) o tornava inalcançável: sessão imortal.
  3. Sinal sem teto vence. Com handoff offered, ação pending, agente em turno ou dev agent trabalhando, o instante volta null e a sessão fica sem teto, como sempre ficou. É por isso que o sinal é o ÚLTIMO da lista.
  4. Instante presente e ilegível é ERRO, nunca nil. nil com pending: true é pendência sem teto; um formato quebrado viraria sessão imortal. Como erro, cai no caminho da api fora do ar — encerra por heartbeat, dizendo por quê.

A regra, pelo lado da sessão encerrada:

  1. Sessão closed/closed_abnormally recusa evento de CONVERSA com 409 NOMEADO: { message, reason: "sessao_encerrada", status, type }. closing ainda aceita — a trava é de estado TERMINAL.
  2. "Conversa" tem duas cláusulas, e as duas são necessárias. O TIPO só existe como conversa (chat.message, chat.structured_question[_answered], agent.status, agent.activated, handoff.offered/accepted, readiness.confirmed, necessity.validated, architecture.readiness_confirmed), OU o ATOR é um agente conversacional (os seis mais dev-lead e infra). Uma lista de PERMITIDOS por tipo foi medida e recusada: quem escreve legitimamente numa sessão fechada são os CONSUMIDORES do fechamento — o Psicólogo roda um ToolLoop contra ela e grava tool.call, agent.response, agent.error; a Anamnese grava os próprios desfechos —, e eles falam o MESMO vocabulário genérico do Criativo. O tipo é igual; quem difere é o ator.
  3. A decisão humana sobre ação continua entrando. action.approved/ action.denied e os desfechos de execução não são conversa: a ação é item de uma fila DURÁVEL que sobrevive à sessão, e recusar a decisão deixaria uma pendência impossível de resolver. O caso normal nem chega aqui (ação pending segura a sessão, sinal 2 da RN-064); ele só existe quando a sessão morreu por outra porta.
  4. A recusa não deixa rastro. O estado vem do MESMO UPDATE que reserva o seq (sem consulta a mais no caminho mais quente, sob o mesmo lock de linha da transição), e a recusa lança dentro da transação: o incremento volta, o seq segue sem buraco. O chat humano, que grava chat.message sem o funil, tem a mesma trava. Os casos de uso com efeito ANTES do evento recusam antes do efeito: CreateHandoff antes de criar a linha, AcceptHandoff antes de marcar accepted (que ativaria o agente seguinte), ActivateAgent antes de chamar o engine.
  5. O fechamento PARA os conversacionais. SessionLifecycleWorker, por onde todo fechamento passa (heartbeat, conversa ociosa, humano, crash), para os conversacionais da sessão em TODOS os nós (Engine.Agents.Conversacionais, por :erpc — o registro é local e o job cai em qualquer réplica). O turno em curso é ABANDONADO no terminate/2 dos seis servidores (TurnoAssincrono.abandonar/1): a task morre sem gravar nem transmitir nada, porque gravar seria pedir à api o que ela recusa. Não há resposta a dar: desde a RN-578 (ADR 0163) quem disparou o turno já recebeu :ok no aceite. Pelo mesmo motivo o offer_dev_handoff que o Arquiteto guardou para o fecho do turno NÃO roda quando o fecho é o da sessão — o handoff seria recusado (desde a RN-672 não há mais o que guardar: o handoff ao Dev Lead sai da Infra). O Infra Lead, que roda o turno dentro do handle_call, é morto se não sair em 5s.
  6. A recusa nunca é calada no engine. Quase todo chamador de append_event/3 descarta o retorno; o cliente registra o 409 sessao_encerrada como aviso no log, e o retorno segue o mesmo.
  7. A tela reconhece o 409 pelo CÓDIGO e diz que a sessão fechou (AT-154). ehRecusaDeSessaoEncerrada confere status === 409 E body.reason === "sessao_encerrada" — nunca o texto, e nunca só o 409, que também é "turno em andamento". Nas quatro ações de conversa (enviar mensagem, responder pergunta estruturada, confirmar prontidão do Criativo e do Arquiteto) a tela mostra uma frase própria em vez do erro genérico, refaz a leitura da sessão (é o status novo que faz o composer sumir) e NÃO perde o que foi digitado: a mensagem volta ao rascunho e aparece somente leitura no lugar do composer, e as respostas do formulário continuam nos campos. A frase não promete "Reabrir" — a ação não existe. E o botão "Encerrar" fica inerte nos DOIS estados terminais (sessaoEhTerminal, o espelho de isTerminal), não só em closed (AT-155).

O que esta regra NÃO fecha: o teto só é aferido quando o HEARTBEAT expira — com a aba aberta e mandando heartbeat, a sessão continua viva sem teto, como sempre (quem está olhando não é conversa ociosa). A resposta do chat humano stateless a um turno que COMEÇOU com a sessão aberta entra mesmo que ela feche no meio (o ator é o modelo, e o gasto já foi medido). deploy/k8s/ não carrega SESSION_CONVERSATION_IDLE_TIMEOUT_MS, e desde a AT-153 isso é DECISÃO escrita no Deployment do engine, não pendência: lá nada intercepta o ambiente, a ausência é o default do runtime.exs, e escrever o número faria uma segunda cópia sem guarda contra divergir — mudar o teto ali é um item env: no patch do engine do overlay. Os TRÊS composes o mapeiam com o default do código (o de instalação só desde a AT-153, e até ali a linha no .env de uma instalação era inerte), guardados por scripts/ci/teto-ocioso-nos-composes.spec.ts. Desde a AT-154 a tela trata o 409 com frase própria (item 11); as demais ações da tela que gravam em sessão fechada seguem mostrando a mensagem da api.

  • Código: apps/web/src/lib/sessao-encerrada.ts (ehRecusaDeSessaoEncerrada, sessaoEhTerminal); apps/api/src/domain/sessions/conversa-em-sessao-encerrada.ts:56 (AGENTES_CONVERSACIONAIS), :67 (TIPOS_DA_CONVERSA), :95 (ehEventoDeConversa), :117 (garantirQueSessaoAceitaEvento); apps/api/src/application/use-cases/sessions/append-session-event.use-case.ts:44 (conflitoDeSessaoEncerrada), :114 (o estado no mesmo UPDATE do seq), :241 (garantirQueAceita); apps/api/src/infrastructure/persistence/drizzle/session.repository.ts:117 (incrementSeq); apps/api/src/application/use-cases/llm/send-chat-message.use-case.ts:78 (o chat humano); apps/api/src/application/use-cases/agents/create-handoff.use-case.ts:67, accept-handoff.use-case.ts:90, activate-agent.use-case.ts:52; apps/api/src/application/use-cases/sessions/get-session-pending-work.use-case.ts:30 (FALA_DO_AGENTE), :238 (o quinto sinal); apps/api/src/infrastructure/persistence/drizzle/session-event.repository.ts:124 (findLatestOfTypesInSession); apps/engine/lib/engine/sessions/session_server.ex:115 (a pendência com instante), :141 (handle_info do relógio próprio, AT-152), :168 (conversa_ociosa), :193 (encerrar), :222 (conversation_idle_timeout_ms); apps/engine/lib/engine/sessions/monitor.ex:185 (classify); apps/engine/lib/engine/psychologist/termination_classifier.ex:46; apps/engine/lib/engine/sessions/engine_api_client.ex:1151 (narrar_recusa_de_sessao_encerrada), :1503 (pendencia_da_resposta);

    apps/engine/lib/engine/sessions/engine_api_client.ex:1151 (narrar_recusa_de_sessao_encerrada), :1503 (pendencia_da_resposta); apps/engine/lib/engine/agents/conversacionais.ex:49 (parar_da_sessao), :69 (parar_da_sessao_no_cluster); apps/engine/lib/engine/agents/turno_assincrono.ex:459 (abandonar); apps/engine/lib/engine/workers/session_lifecycle_worker.ex:88 (parar_conversacionais); apps/engine/config/runtime.exs:79

  • Teste: apps/api/test/application/use-cases/sessions/conversa-em-sessao-encerrada.spec.ts:144 (sessão encerrada recusa chat.message com 409 nomeado — caso de falha), :167 (sem evento e sem seq consumido), :183 (Psicólogo e Anamnese continuam entrando), :211 (decisão sobre ação continua entrando), :223 (sessão ativa aceita — caminho feliz), :244 (closing aceita), :258 (SendAgentMessage não chama o engine), :263 (nenhum handoff órfão), :276 (o aceite não ativa ninguém), :309 (a ativação não sobe o agente); apps/api/test/application/use-cases/sessions/get-session-pending-work.use-case.spec.ts:478 (o Criativo respondeu: pendente com o instante), :519 (o usuário falou por último), :546 (quem não conversa não segura), :563 (sinal sem teto vence); apps/engine/test/engine/sessions/session_lifecycle_test.exs:87 (dentro do teto, reagenda), :106 (acima do teto, conversation_idle_timeout, closed), :124 (o default é 8h), :151 (pendência sem instante segue sem teto), :152 e :194 (AT-152: com a aba aberta — pings — a conversa ociosa além do teto fecha, e dentro do teto não fecha); apps/engine/test/engine/sessions/pendencia_de_conversa_test.exs:28 (instante inválido vira erro); apps/engine/test/engine/agents/conversacionais_test.exs:60 (para só os da sessão), :85 (AT-204: pid já morto no Registry não entra na lista de parados), :126 (session.closed para o Criativo vivo); apps/engine/test/engine/agents/turno_assincrono_test.exs:161 (abandonar não grava nem transmite, e não responde de novo), :194 (o terminate/2 abandona); apps/engine/test/engine/psychologist/termination_classifier_test.exs:20

  • Origem: AT-072 — exp001; decisões do mantenedor em 2026-09-18 (teto de 8h e causa própria; sessão encerrada recusa conversa, nomeado)

A sessão encerrada pode ser reaberta (RN-649/RN-650)​

RN-649 — Sessão closed/closed_abnormally reabre para active com o log intacto, e o fechamento anterior vira evento novo​

A RN-001 dizia que os estados terminais não têm saída. Desde o ADR 0183 há UMA, e só uma: a reabertura, de closed ou closed_abnormally para active, por caminho próprio. A transição GENÉRICA continua recusando closed → active com 409, e closing → active segue proibido por qualquer caminho.

  1. O que volta é a sessão inteira. O event log não é tocado — a conversa, as regras de negócio, as perguntas respondidas, as ofertas e aceites de handoff. Um agente conversacional parado no fechamento volta pelo start_agent idempotente na primeira mensagem e reconstrói o histórico (RN-580). A conversa volta a entrar porque a recusa da RN-581 lê o estado da sessão, que agora é active.
  2. O fechamento anterior não se apaga. A linha de sessions perde closed_at e termination_reason; o evento NOVO session.reopened, no mesmo seq (RN-002) e com o ator humano que reabriu, carrega {from, to, closedAt, terminationReason}. Nenhum evento é editado nem renumerado, e o kind não muda (RN-097).
  3. Sessão com execução não reabre — 409 sessao_com_execucao. Reabrir uma sessão com execution.activated a devolveria a findActiveExecutionSession (RN-139). Decisão do dono, confirmada no ADR 0184.
  4. Sem prazo. Toda sessão encerrada é reabrível (decisão do dono, ADR 0184).
  5. O engine é chamado antes da transação, como na ativação: falha dele deixa a sessão encerrada, sem evento. Outro estado que não terminal é 409 sessao_nao_encerrada.
  6. O fechamento que a reabertura desfez não para a sessão viva. O SessionLifecycleWorker lê o status da sessão e ignora, com log, o job de session.closed/session.closed_abnormally de uma sessão que a api já diz não-terminal.
  7. A segunda análise automática do Psicólogo fica bloqueada pelo alreadyAnalyzed que já existia; o reprocessamento manual segue. A segunda consolidação do grafo estende a janela da mesma Interacao.
  • Onde: apps/api/src/domain/sessions/session-state-machine.ts:66 (canReopen), :70 (assertReopen); apps/api/src/domain/sessions/reabertura-de-sessao.ts:24 (SessaoComExecucaoNaoReabreError), :44 (garantirQueSessaoSemExecucaoReabre), :58 (payloadDaReabertura); apps/api/src/application/use-cases/sessions/reopen-session.use-case.ts:53 (execute), :96 (garantirQuePodeReabrir); apps/engine/lib/engine/workers/session_lifecycle_worker.ex:59 (encerrar_se_ainda_encerrada); apps/engine/lib/engine/sessions/project_session.ex:48 (status)
  • Teste: apps/api/test/domain/sessions/session-state-machine.spec.ts:56 (só terminais reabrem; closing nunca volta); apps/api/test/application/use-cases/sessions/reopen-session.use-case.spec.ts:121 (closed reabre, log preservado, session.reopened com o fechamento anterior — caminho feliz), :151 (closed_abnormally), :163 (a conversa volta a entrar), :186 (closing é 409 sem engine nem evento — caso de falha), :208 (sessão com execução é 409 sessao_com_execucao), :239 (engine fora do ar: nada muda); apps/engine/test/engine/workers/session_lifecycle_worker_test.exs:66 (fechamento de sessão já active não para o processo), :82 (fechamento de sessão closed para, como sempre)
  • Origem: AT-071 (HS-048), sobre o exp001

RN-650 — Reabrir é developer, por rota própria, e a tela só oferece o botão a quem alcança o papel​

POST projects/:projectId/sessions/:sessionId/reopen exige developer — o MESMO papel da transição genérica que encerra a sessão: quem pode dá-la por terminada pode trazê-la de volta. Responde 200 com a sessão. viewer é 403. O ADR 0183 tinha fixado maintainer como padrão provisório; o dono decidiu developer em 2026-09-30 (ADR 0184), confirmando junto as outras duas decisões — sem prazo, e sessão com execution.activated recusada.

Na tela de Sessão, a faixa da sessão encerrada mostra "Reabrir sessão" com uma frase do que volta. Abaixo de developer (roleAtLeast, papel de WORKSPACE — a lacuna da RN-471 já declarada na tela) o botão fica inerte e o motivo é dito em TEXTO. A recusa da api (409 de sessão com execução, 403) vira toast com a frase dela. O fio narra session.reopened como "sessão reaberta", com a causa do fechamento anterior quando ela foi gravada.

  • Onde: apps/api/src/interfaces/http/sessions/sessions.controller.ts:210 (reopen); apps/web/src/lib/api-client.ts:634 (reopenSession); apps/web/src/routes/SessionPage.tsx:153 (podeReabrir); apps/web/src/lib/session-acoes-da-sessao.ts:77 (handleReopen); apps/web/src/routes/SessionComposer.tsx:383 (o botão); apps/web/src/lib/activity.ts:633 (a frase do fio)
  • Teste: apps/api/test/interfaces/http/sessions/sessions-reopen.controller.spec.ts:22 (developer, o mesmo da transição genérica), :36 (viewer não alcança o papel da rota; developer, maintainer e owner alcançam), :49 (200); apps/web/src/routes/SessionPage.reabrir-sessao.test.tsx:143 (developer reabre pela rota própria — caminho feliz), :167 (maintainer e owner também), :178 (viewer/sem papel: inerte com o motivo em texto — caso de falha), :191 (recusa da api vira toast), :213 (sessão ativa não oferece); apps/web/src/lib/activity.test.ts ("reabertura de sessão")
  • Origem: AT-071; papel decidido na AT-337

O repositório nasce no aceite do handoff ao Arquiteto, e a execução não começa sem ele (RN-582)​

RN-582 — O aceite do handoff ao Arquiteto provisiona o repositório; o aceite ao Dev Lead é a segunda porta, idempotente; e execution/activate sem repositório é 409 nomeado​

Revisa o GATILHO da RN-522, que cumpria a promessa da RN-541. O mecanismo não muda — o mesmo ProvisionRepositoryUseCase, chamado pelo mesmo AcceptHandoffUseCase, sempre local, com a falha virando repository.provision_failed e nunca derrubando o aceite. Decisão estrutural no ADR 0165.

O que a instalação real mostrou (AT-092, v6.1.0, 2026-09-14). O Arquiteto propôs open_adr_pr três vezes antes de o repositório existir; os handoffs a infra e dev-lead foram oferecidos juntos, só o de infra foi aceito, e POST /projects/:id/execution/activate respondeu 201. As tabelas project_repositories, project_git_connections e repo_bootstraps ficaram vazias — o projeto sem repositório para sempre. Dois defeitos: o gatilho vinha DEPOIS de quem precisa do repositório (Arquiteto e Infra Lead escrevem nele antes do Dev Lead), e o único gatilho era PULÁVEL (ativar a execução não exige o aceite ao Dev Lead).

1. O gatilho é o aceite do handoff ao arquiteto. O Arquiteto só é ativado por handoff accepted endereçado a ele (canActivateAgent), e esse handoff nasce do PO (offer_handoff(to_agent: "arquiteto")) ou do usuário (handoff manual) — as duas portas passam pelo mesmo aceite. O provisionamento roda ANTES de activateAgent: o Arquiteto acorda num projeto que já tem onde escrever. Consequência: o Infra Lead, ativado por handoff DO Arquiteto, nunca mais acorda sem repositório.

2. O aceite ao dev-lead continua provisionando, como SEGUNDA PORTA. É a saída do projeto que passou pelo Arquiteto antes desta regra (o exp001 da AT-092: Arquiteto aceito, Dev Lead offered) e a retomada natural de um provisionamento que falhou no aceite ao Arquiteto. Com o repositório created já de pé, a segunda chamada não cria nada — nem repositório, nem sessão, nem linha de bootstrap, nem branch, nem commit —, e isso está provado pelas DUAS portas em sequência contra o Postgres e o LocalGitProvider de verdade.

3. execution/activate sem repositório é RECUSADO com 409, logo depois da checagem de module_map e ANTES de qualquer efeito (orçamento persistido, permissions.json, sessão, engine, evento). A frase é escolhida pelo estado dos handoffs do projeto, lidos por HandoffRepository.findByProject (de todas as sessões — o handoff ao Arquiteto mora na do PO): handoff ao Arquiteto offered → aceite-o; senão ao Dev Lead offered → aceite-o; senão algum dos dois já accepted → o provisionamento não deixou repositório, veja o repository.provision_failed e use a página de provisionamento (/projects/:id/provisioning?provider=local); senão → nenhum handoff ao Arquiteto foi aceito. Um handoff oferecido a OUTRO agente nunca vira instrução de aceite (aceitar a Infra não provisiona nada). Repositório ADOTADO conta como repositório. Recusar e não provisionar aqui é decisão: provisionar dentro da ativação esconderia um efeito de git, e os agentes anteriores a ela seguiriam sem repositório.

4. A tela tira o controle e diz o motivo uma vez, em texto (a régua da RN-102 e do ADR 0064). Na seção de Execução da Visão Geral, com o GET .../git/repository respondendo null CONFIRMADO, "Ativar execução" fica inerte no lugar, o texto diz onde o repositório nasce, e há um link para provisionar. No card do handoff ao Dev Lead, dentro da sessão, o atalho "Ativar execução" sai e o card diz que aceitar aquele handoff provisiona o repositório. Carregando ou com erro, nada tranca — "não sei" não vira "não tem", e o backend recusa de qualquer forma. Aceitar um handoff invalida a consulta do repositório, porque o aceite pode tê-lo criado.

A sessão técnica não desloca a de trabalho (AT-131). A sessão que o provisionamento abre (repo_bootstraps.session_id, nome git-bootstrap) nasce no MEIO da fase do Arquiteto, e escolher "a mais recente" só por createdAt a punha no lugar da sessão de trabalho até a próxima nascer (ADR 0165 deixou isso declarado). Agora as duas leituras por createdAt do resumo do workspace (latestSessionId e o corte das não lidas) ordenam por "é sessão de bootstrap" ANTES de createdAt, e a Visão Geral (useLatestSession) faz o mesmo. A técnica só é a mais recente quando é a ÚNICA — no fluxo manual de provisionamento é nela que o projeto começa. A api lê o marcador por repo_bootstraps; a web só tem o NOME da sessão, e renomeá-la faz as duas divergirem (a Visão Geral então trata o resumo como de outra sessão e não o usa). O critério não muda o que o ADR 0165 decidiu (onde o repositório nasce), por isso não há ADR novo.

Revista pela RN-592 (AT-183): a web deixou de ler o NOME — a listagem de sessões devolve o marcador technical, pelo mesmo vínculo em repo_bootstraps, e renomear a sessão não faz mais as duas divergirem.

A recusa da ativação não manda aceitar handoff de sessão encerrada (RN-581, AT-131). Aceitar handoff numa sessão terminal é 409 sessao_encerrada, checado ANTES de o handoff virar accepted — então nada é provisionado pela metade. A frase da recusa agora consulta o estado da sessão de cada handoff offered: só o que mora em sessão ABERTA vira "aceite-o"; se o único offered está em sessão encerrada, a frase diz isso e aponta a página de provisionamento. Sessão não encontrada conta como aberta ("não sei" não vira "encerrada"). Os eventos que o provisionamento grava não caem na RN-581: o ator é system e o destino é a sessão git-bootstrap, recém-criada. O que segue NÃO coberto: a Visão Geral e o card do handoff na sessão lêem o estado do repositório, não o de cada sessão, e não mudaram.

  • Onde: apps/api/src/application/use-cases/agents/accept-handoff.use-case.ts:66 (AGENTES_QUE_PROVISIONAM_O_REPOSITORIO, gatilho e segunda porta) e :141 (o ramo, antes de activateAgent); apps/api/src/application/use-cases/execution/activate-execution.use-case.ts:143 (a recusa, antes de qualquer efeito); apps/api/src/domain/execution/repositorio-para-executar.ts:50 (motivoDeExecucaoSemRepositorio, e os dois nomes de agente que o aceite também usa); apps/api/src/infrastructure/persistence/drizzle/handoff.repository.ts:55 (findByProject); apps/web/src/routes/ProjectOverviewTab.tsx:369 e :511; apps/web/src/routes/SessionPage.tsx:147; apps/web/src/routes/session-timeline-montagem.tsx:495; apps/web/src/lib/session-acoes-de-handoff.ts:122; apps/engine/lib/engine/projects/project_repository.ex:74 (o texto da recusa da RN-577, que nomeava o gatilho antigo e muda junto)
  • Teste: apps/api/test/application/use-cases/agents/accept-handoff.use-case.spec.ts ("aceitar para o Arquiteto provisiona local com o slug do projeto", "o repositório nasce ANTES de o Arquiteto acordar", "aceitar para o Dev Lead continua provisionando local (segunda porta)"); apps/api/test/application/use-cases/agents/accept-handoff-provisiona-uma-vez.spec.ts (as duas portas contra o banco: "UM createRepo, UMA linha, nenhuma falha", e "o exp001: Arquiteto aceito SEM repositório, e a segunda porta provisiona"); apps/api/test/application/use-cases/execution/activate-execution.use-case.spec.ts:725 ("sem repositório: 409, e NENHUM efeito antes da recusa", "a frase nomeia o handoff ao Dev Lead pendente — a saída do exp001", "repositório ADOTADO conta como repositório"); apps/api/test/domain/execution/repositorio-para-executar.spec.ts; apps/api/test/infrastructure/persistence/handoff-por-projeto.repository.spec.ts; apps/web/src/routes/ProjectOverviewTab.test.tsx:409, :427 e :440; apps/web/src/routes/SessionPage.handoff-devlead-e-colapso.test.tsx:575; apps/engine/test/engine/agents/arquiteto_server_test.exs:134 e apps/engine/test/engine/infra/infra_lead_server_test.exs:520 (a recusa nomeia o gatilho novo)
  • Origem: AT-092 — instalação real da v6.1.0 em 2026-09-14, decidido pelo mantenedor em 2026-09-18

RN-584 — A mensagem de chat não tem destinatário padrão: cada agente que conversa tem cláusula própria, e o resto é recusa nomeada​

A última cláusula de EngineWeb.AgentCommandController.message/2 não olhava o agente: casava QUALQUER nome que as cláusulas de cima não tivessem pegado e entregava a mensagem ao Criativo. Uma mensagem escrita para o infra era lida por outro agente, o clique respondia 202, e o Criativo respondia no fio — nada quebrava, e ninguém via. Achado como adjacência da RN-578 (AT-089) e corrigido aqui (AT-098).

A medição, antes do conserto (os três lados, na dev de 72ee8cdcf):

DestinoA tela oferece?A api aceita?O engine tinha cláusula?
criativosim (composer: fallback da sessão criativa e AGENTES_DE_CHAT)simNÃO — era a cláusula final, sem guarda
po, arquiteto, dev-lead, ux-designer, staffsim (AGENTES_DE_CHAT, o agent.activated mais recente)simsim, uma cada
infranão no composer (fora de AGENTES_DE_CHAT desde o achado 9-fix e a RN-499)simnão — caía no Criativo
qualquer outro nome do roster (qa, subagentes, secops, psicologo…)só pelo formulário de perguntas estruturadas, que responde ao ATOR que perguntou (agent={event.actor.id}); hoje só Criativo e PO perguntamsimnão — caía no Criativo
nome desconhecido, ou nenhumnãosim (a api não valida o agent; só o sessionId vira segmento de URL checado, RN-128)não — caía no Criativo

A regra:

  1. Cada agente que conversa tem cláusula PRÓPRIA, o Criativo inclusive ("agent" => "criativo"). A cláusula final deixou de existir como entrega: ela é RECUSA.
  2. Nome sem cláusula é 422 NOMEADO, {error, motivo} no molde da RN-578: agente_sem_conversa, com uma frase que nomeia o agente e diz que a mensagem ficou registrada e nenhum agente a leu. A api repassa 422 como UnprocessableEntityException com a MESMA frase (o postComandoDeTurno da RN-578, sem código novo), e a tela a mostra pelo mensagemDaRecusaDoAgente que já existia. Sem "agent" no corpo é agente_ausente, e agente que conversa sem texto é mensagem_sem_texto — três motivos, porque "não conversa" dito a quem conversa seria mentira.
  3. O infra tem recusa PRÓPRIA, com frase que diz o que ele faz: trabalha por proposta (a PR de infra, a subida do container), e o que pede decisão aparece em Aprovações. Não é falta de código, é a mesma leitura da RN-499 — o Infra Lead é propositivo. E o user_message/2 que o InfraLeadServer ainda exporta não serve para uma cláusula: ele roda o turno INTEIRO dentro do handle_call (até 180 s), sem o aceite do ADR 0163 e sem "Parar" (via_for/2 não o conhece). Ligá-lo devolveria ao clique a espera que a RN-578 tirou.
  4. O "Parar" também não adivinha. cancel/2 sem "agent" parava o turno do Criativo; passa a ser 422 agente_ausente. A api sempre manda o agente nas duas rotas, então essas duas recusas só alcançam chamador quebrado.
  5. A tela continua não oferecendo o infra no composer — nenhuma mudança de UI: AGENTES_DE_CHAT já o excluía, e o que mudou é que, se um dia alguém o puser lá, o engine recusa com nome em vez de entregar a outro, e a guarda do CI reprova antes.

Desde a RN-617 (ADR 0175), os itens 3 e 5 não valem mais: o turno do Infra Lead migrou para o TurnoAssincrono, e só então ele ganhou cláusula própria de message/2, o via_for/2 do "Parar" e o lugar em AGENTES_DE_CHAT. A recusa agente_sem_conversa dele saiu; a regra de fundo desta RN — nenhum destinatário padrão, todo nome sem cláusula recusado com nome — é a mesma.

A guarda que fecha a classe mora em scripts/ci/destinos-do-composer.spec.ts, porque o destino nasce em TypeScript e a cláusula mora em Elixir — nenhuma das duas suítes lê a outra linguagem. Ela DERIVA os destinos da fonte (AGENTES_DE_CHAT, os literais agentParaEnviar = '…' do SessionPage.tsx e o AgentKey do roster) e exige: cláusula própria que conversa e via_for/2 do "Parar" para cada destino do composer; nenhuma cláusula sem agente fixo na cabeça que entregue a um agente; a última cláusula de message/2 sendo recusa; o infra fora do composer e com recusa própria; e @agentes_de_conversa igual ao conjunto das cláusulas que conversam. Cinco mutações medidas, todas mortas (o catch-all antigo de volta, infra em AGENTES_DE_CHAT, a cláusula do Criativo renomeada, o via_for do Staff removido, a cláusula do infra renomeada).

O que esta regra NÃO fecha, declarado:

  • Se o Infra Lead passa a conversar pelo composer era decisão de produto, sem dono — tomada na RN-617 (AT-141): sim. Dar a ele uma cláusula exige, antes, migrar o turno dele para o TurnoAssincrono (o aceite e a recusa turno_em_andamento da RN-578) e registrá-lo no "Parar"; e exige decidir o que uma conversa faz com um agente cujo contrato é propor. O user_message/2 dele segue exportado e sem chamador.

  • O chat.message gravado pela api antes da recusa continua no log — e desde a RN-587 o engine grava o agent.error da recusa ao lado, então o fio explica a si mesmo.

  • A api não valida o agent. Recusar lá, antes de gravar, exigiria uma segunda lista de quem conversa do lado TypeScript — e divergir dela das cláusulas é o defeito que esta regra fecha. Quem decide é quem tem as cláusulas (decisão mantida na RN-587).

  • Código: apps/engine/lib/engine_web/controllers/agent_command_controller.ex:42 (@agentes_de_conversa), :177 (a cláusula do Criativo), :192 (a recusa do infra), :283 (mensagem_sem_texto), :249 (agente_sem_conversa), :312 (agente_ausente), :310 (o "Parar" sem agente); apps/api/src/interfaces/http/agents/agents.controller.ts:115 e :174 (a resposta 422 documentada); apps/web/src/lib/session-readiness.ts:37 (AGENTES_DE_CHAT, a fonte que a guarda lê)

  • Teste: scripts/ci/destinos-do-composer.spec.ts (a guarda da classe); apps/engine/test/engine_web/controllers/agent_command_controller_test.exs:124 (caminho feliz: o Criativo pela cláusula própria), :231 (o infra: 422 e nenhum dos dois sobe — caso de falha), :258 (todo nome do catálogo de áreas e do roster fora de conversa), :286, :302, :317; apps/api/test/infrastructure/http-clients/api-to-engine-client.spec.ts:575 (o destino viaja como escolhido, e o 422 chega com a frase)

  • Origem: AT-098 — adjacência medida no PR #594 (AT-089)

RN-585 — O fim do turno só fica visível depois de o turno fechar no agente​

Desde o ADR 0163 (RN-578) o turno dos seis conversacionais roda numa Task, e o desfecho que a Task grava — agent.response, ou agent.error quando a api narra orçamento, credencial ou binding no frame final — sai ANTES de o resultado chegar ao GenServer do agente e zerar turno_assincrono. Um teste que lia o state logo depois do agent.error reprovava em ~1 de 4 rodadas (AT-099: 8 de 30 medidas). A pergunta era se a MESMA janela existia para o usuário: a tela via o turno acabar e a mensagem seguinte ouvia 409 turno_em_andamento?

Não existe, e a regra é o que faz ela não existir:

  1. Os dois sinais de fim de turno saem só de finalizar/1, que roda no processo do GenServer, dentro do handle_info que JÁ zerou turno_assincrono: agent.done no canal e agent.status: idle persistido. São os dois, e só eles, que a tela usa para fechar o turno (onAgentDone e turnoTerminouNoLog, RN-578 item 4).
  2. A mensagem de quem viu um desses sinais é atendida depois desse handle_info — o GenServer atende uma mensagem por vez —, então encontra o agente livre e é aceita.
  3. O que a Task grava não é sinal de fim. agent.response e agent.error aparecem no fio com o turno ainda aberto no agente. Tela ou cliente que passasse a fechar o turno por eles reabriria a janela; emitir agent.done ou idle de dentro da Task também.

Não houve mudança de comportamento: a ordem já era esta desde a RN-578. O que esta RN acrescenta é a ordem ESCRITA e travada por teste nos seis conversacionais — o teste para o GenServer com a Task terminada e prova que nenhum dos dois sinais saiu, sem timeout — e a correção do teste do PO, que passa a esperar o idle persistido em vez do agent.error.

  • Código: apps/engine/lib/engine/agents/turno_assincrono.ex:158 (zera o turno no handle_info), :482 (finalizar/1, único emissor dos dois sinais); apps/web/src/lib/session-turno.ts:30 (turnoTerminouNoLog)
  • Teste: apps/engine/test/engine/agents/turno_assincrono_test.exs:434 (os seis conversacionais: com o GenServer suspenso e a Task terminada, o agent.response já está no log e nem agent.done nem idle saíram; depois do idle, a mensagem seguinte é aceita — o caso de falha é a recusa turno_em_andamento, que o teste nega); apps/engine/test/engine/agents/po_server_test.exs:478 (espera o idle antes de ler o state)
  • Origem: AT-099 — teste intermitente do PoServerTest, medido em 2026-09-19

RN-586 — O turno que o reinício do engine interrompeu fecha com desfecho durável, e nunca é refeito​

Desde a RN-578 o agent.status: working é gravado ANTES do aceite, e o fim do turno (agent.status: idle e agent.done) só sai de finalizar/1, dentro do processo do agente (RN-585). Se o engine cai no meio do turno, o processo e a Task morrem juntos e ninguém grava o fim: o último agent.status do agente fica working para sempre (AT-156, reproduzido — subir o servidor de novo sobre esse log não gravava nada, nos seis). Os dois leitores desse sinal o tomam por turno em curso: a tela (turnoTerminouNoLog, RN-578 item 4) mantém a faixa de atividade, e o GetSessionPendingWorkUseCase conta trabalho pendente SEM teto (o terceiro sinal da RN-064), então a sessão não fecha por heartbeat. Só a próxima mensagem do usuário destravava, e só porque ela sobe o agente.

  1. O working que ficou sem turno vivo é fechado por evento NOVO, nunca reescrito (evento é imutável): um agent.error com origem infra e reason: turno_interrompido_por_reinicio — a frase diz que o engine foi reiniciado, que o turno não foi concluído e que quem decide tentar de novo é o usuário (RN-059: falha nunca vira resposta vazia) — seguido de agent.status: idle persistido. O canal recebe agent.error e agent.done, para a faixa de quem está com a tela aberta sair sem recarregar. Vale para os que compartilham TurnoAssincrono — seis na origem, sete desde a RN-617.
  2. O turno NUNCA é reexecutado. Refazê-lo gastaria token em nome de uma mensagem que o usuário talvez já tenha esquecido, sobre um histórico reconstruído; nenhuma chamada ao modelo acontece no fechamento.
  3. Só o ÚLTIMO agent.status de cada agente conta, e só working: idle já está fechado, e awaiting_approval (Dev Lead suspenso, RN-284) é decisão pendente, não turno morto.
  4. "Sem turno vivo" olha o cluster, e na dúvida não fecha. O registro dos agentes é local; num rollout o pod antigo pode estar rodando o turno, e fechá-lo dali mataria um turno saudável. Nó que não responde conta como vivo. O repasse de sessão do drain (RN-588, Monitor.expect_handoff/1) é do SessionServer, não dos conversacionais: enquanto o pod antigo está de pé o agente dele segue registrado lá, e a varredura do pod novo o enxerga; o Adopter não varre — quem chega ao working órfão depois que o pod antigo morre é o init/1 do agente.
  5. Dois pontos de entrada, uma função (Engine.Agents.TurnoOrfao): a varredura de boot em cada sessão reidratada (Rehydrator, fora do caminho do readiness — o boot não espera a api), que fecha o turno mesmo que ninguém mande mensagem, e o init/1 de cada servidor, rede de segurança para quando o boot não conseguiu escrever (api ainda fora do ar). Falha de leitura ou de escrita é logada e o agente sobe.

Declarado e não fechado: o Infra Lead (infra) roda o turno dentro do handle_call, sem TurnoAssincrono, e fica de fora — fechado na RN-617, que o pôs no TurnoAssincrono e em TurnoOrfao; a janela de leitura é a das 200 mais recentes agent.status da sessão; se a api estiver fora no boot E ninguém acordar o agente, o working fica até a próxima subida dele; e o fechamento não recupera o que o turno já tinha gravado antes da queda.

  • Código: apps/engine/lib/engine/agents/turno_orfao.ex:89 (varrer/2), :99 (fechar_ao_subir/3), :122 (encerrar/3); apps/engine/lib/engine/sessions/rehydrator.ex:75 (varredura no boot); o init/1 dos seis *_server.ex chama TurnoOrfao.fechar_ao_subir/3
  • Teste: apps/engine/test/engine/agents/turno_orfao_test.exs:72 (nos seis: o working sem turno vivo vira agent.error infra + idle, e o LLM não é chamado), :108 (awaiting_approval não é órfão; idle e o working de outro agente não são fechados), :124 (varrer/2 pula o agente com processo vivo em ESTE nó ou num nó :peer de verdade do cluster; leitura e escrita que falham não derrubam a subida); apps/engine/test/engine/sessions/rehydration_test.exs:46 (o boot dispara a varredura)
  • Origem: AT-156, herdada da AT-089 (RN-578) — reproduzida em 2026-09-19

Revisada pela RN-920 (ADR 0226). O item 2 ("o turno NUNCA é reexecutado") deixou de valer para o turno recente: a varredura de boot o retoma UMA vez, se a última atividade do agente tem até 900 s, com a mensagem efêmera do diário; o turno velho ou já retomado continua só fechado, e o botão "Retomar" (RN-921) é o caminho humano. Os itens 1, 3, 4 e 5 seguem.

RN-587 — A mensagem que nenhum agente leu fica no fio dizendo isso: o engine grava o agent.error da recusa​

A api grava o chat.message ANTES de perguntar ao engine (o engine reconstrói a conversa lendo o log, e a mensagem precisa ser durável mesmo com ele fora do ar). Quando o engine recusava, a mensagem ficava no fio do usuário com cara de entregue e o motivo morava só no toast — a tela afirmando o que a fonte negou. As duas RNs de origem deixaram isso declarado (RN-578, RN-584); esta o fecha (AT-132).

A medição, antes do conserto, por recusa:

RecusaQuem a produzagent.error durável ao lado do chat.message?
409 turno_em_andamentoTurnoAssincrono (RN-578)JÁ SIM — emitir_recusa_por_turno_em_andamento/1, com teste
409 aguardando_aprovacaoDevLeadServer.handle_call (RN-578)JÁ SIM — reason: "aguardando_aprovacao_de_plano"
422 agente_sem_conversa, mensagem_sem_texto, agente_ausenteo controller (RN-584), sem *ServerNÃO — a explicação vivia só no toast

A regra:

  1. O engine é a fonte da recusa E do registro. As três recusas 422 gravam agent.error (origem politica, mensagem igual à frase do 422, reason igual ao motivo) e o transmitem no canal da sessão — o mesmo par durável/efêmero das duas 409. O fio já desenha agent.error (SessionPage.tsx, lerFalhaDeTurno), então nenhuma tela mudou.
  2. Só há o que gravar com sessão nomeada. Sem projectId/sessionId no corpo (chamador quebrado) a recusa segue só como resposta.
  3. O ator não vem da rota. infra e os seis que conversam entram como agent; qualquer outro nome (que a rota pública deixa passar) é o system engine — um nome digitado nunca vira ator no log.
  4. Não se apaga nem se adia o chat.message. Evento é imutável, e gravá-lo só depois do aceite reordenaria o log: o turno roda numa Task e o agent.response poderia preceder a mensagem que responde (e uma falha entre o aceite e o append rodaria um turno sobre mensagem não gravada).

A api continua sem validar agent, e agora isso é decisão e não lacuna. A validação que a AT-098 pedia é a recusa 422 do engine, e ela é o mecanismo ÚNICO: a api a repassa (postComandoDeTurno) e o engine a registra. Uma lista do lado TypeScript seria a segunda cópia que diverge no primeiro agente novo, e uma lista GERADA a partir das cláusulas exigiria parsear Elixir — sem ganho para o usuário, que já vê a mensagem e a explicação no fio.

  • Código: apps/engine/lib/engine_web/controllers/agent_command_controller.ex:559 (recusar_mensagem/5) e :560 (registrar_recusa_de_mensagem/3)
  • Teste: apps/engine/test/engine_web/controllers/agent_command_controller_test.exs:207 (qa, o agente do roster que segue sem conversa — até a RN-617 o caso era o infra, que passou a conversar: o agent.error com a frase e o motivo), :178 (nome desconhecido não vira ator), :194, :209, :224 (sem sessão: nada gravado — caso de falha); mutação medida: com o registro removido, 4 dos 5 falham; scripts/ci/destinos-do-composer.spec.ts (a recusa de message/2 é recusar_mensagem/5 e ela grava o agent.error; seis mutações mortas)
  • Origem: AT-132 — declarado aberto nas RN-578 (AT-089) e RN-584 (AT-098)

RN-588 — Repassar uma sessão não deixa o pod antigo apagar a linha do par​

No rollout, o drain do pod antigo para o SessionServer e pede a um par que assuma a sessão (Engine.Shutdown); o init do par regrava a linha de engine.session_states. O :DOWN do processo parado, porém, chega ao Engine.Sessions.Monitor do pod antigo por mensagem — e o Monitor é um GenServer único, que serializa o :DOWN de cada sessão com um DELETE por ida ao banco, sem esperar pelo repasse seguinte. Quando o :DOWN era processado DEPOIS do upsert do par, o DELETE ... WHERE session_id — que não olha dono nem geração — levava a linha DO PAR: a sessão ficava com dono e sem linha, invisível ao Adopter, ao Rehydrator e ao local_sessions/0 do drain do par. Se o par era OUTRO pod antigo (.33 → .28 → novo), ele não a via ao drenar, e o SIGTERM dele a matava: active na api e sem dono (AT-078, o rollout-test do propriedades.yml, ~3 órfãs em 13 rodadas).

A regra: o drain marca o stop com Monitor.expect_handoff/1 (o expect_stop/1 de sempre MAIS "a linha não é minha"), e o Monitor NÃO apaga a linha de uma sessão repassada — registra em Logger.info que a manteve. Sem adoção, quem apaga é o próprio drain (terminate_unadopted), como já era; o Monitor continua apagando a linha de toda sessão que morre por outra causa e a do expect_stop/1 do SessionLifecycleWorker (a api já encerrou a sessão). Sem migration: a linha não ganhou coluna de dono, e a marca vive no estado do Monitor, por sessão. Toda apagada do Monitor passou a deixar UMA linha de log com sessão, nó e linhas removidas — o log da drenagem nunca chega ao log do pod (o preStop o descarta), o do Monitor chega.

Inferência que fica: o instante em que a linha sumiu nunca foi medido no k3d; a leitura vem do artefato da rodada 35452845830 (sem linha + segundo pod antigo) e do código, e a corrida foi provada em ExUnit, não observada. A próxima falha do k3d, se houver, traz a linha do Monitor.

  • Código: apps/engine/lib/engine/sessions/monitor.ex:45 (expect_handoff/1), :119 (apagar_linha/1); apps/engine/lib/engine/shutdown.ex:130 (release/1, o drain marca o repasse)
  • Teste: apps/engine/test/engine/sessions/monitor_repasse_test.exs:52 (Monitor suspenso: o :DOWN tardio não apaga a linha regravada pelo par), :67 (cadeia antigo → antigo → novo: o segundo drain ainda vê a sessão), :87 (sem adoção a linha continua saindo pelo drain), :100 (Shutdown.release/1 marca o repasse — trocar a chamada de volta por expect_stop/1 reprova SÓ este teste)
  • Origem: AT-078 — sessão órfã intermitente do rollout do engine

RN-589 — O texto que a ferramenta devolveu entra no tool.result, cortado com o total dito​

A RN-580 declarou aberto: só o Criativo gravava tool.result, e sem o texto — nos outros cinco a nota da reidratação dizia que o log não registra o desfecho, e o agente reidratado sabia QUE chamou create_epic, não o id do épico que ela respondeu.

A regra:

  1. Os seis gravam. Criativo, PO, Arquiteto, Dev Lead, UX Designer e Staff emitem tool.result depois de TODA chamada que devolve texto. O payload sai de UM lugar, Engine.Agents.ResultadoDeFerramenta.payload/2: tool, ok e — no sucesso — resultado; na falha, erro (como o Criativo já fazia).
  2. Com teto, e o teto diz o total. O texto é cortado em 2.000 caracteres; quando corta, resultadoTotal traz o tamanho REAL (ADR 0060). O motivo do teto: o log é lido por todo membro do projeto e entra no backup, e uma listagem (listar_backlog) pode ser grande.
  3. Aditivo. Campo novo em tipo existente, sem migration; tool/ok/erro seguem como eram. Evento é imutável: sessão anterior continua sem o texto, e a nota da reidratação segue dizendo isso para a chamada sem tool.result.
  4. A reidratação usa o texto. desfecho: ok, devolveu: <texto> (com aviso [cortado; o total real tinha N caracteres] quando houver resultadoTotal). Continua nota de texto, nunca role: tool — o evento não guarda o id da chamada.
  5. propose_adr deixou de gravar o próprio tool.result de recusa: o servidor do Arquiteto o grava para toda ferramenta, e os dois duplicariam o evento.

O que fica aberto: (a) e (b) fecharam na RN-593 — (a) o Dev Lead que SUSPENDE ({:pending, _}, propose_execution_plan/assess_implementability) não gravava tool.result na suspensão nem no action_settled; (b) o InfraLeadServer tinha o próprio tool.result e não passava por este módulo; (c) o teto de 2.000 é ESCOLHA, não medida sobre o acervo — nenhum resultado real foi amostrado; (d) decisão de produto pendente (TODO humano da AT-151): as ferramentas dos seis devolvem ids, listas de regras/backlog/métricas e mensagens de erro — nenhuma lê arquivo do repositório nem saída de comando —, mas se uma ferramenta assim nascer, ela gravaria conteúdo dele no log; o corte é a única contenção.

  • Código: apps/engine/lib/engine/agents/resultado_de_ferramenta.ex:24 (payload/2), criativo_server.ex:382, po_server.ex:259, arquiteto_server.ex:336, dev_lead_server.ex:407, ux_designer_server.ex:232, staff_server.ex:198; apps/engine/lib/engine/agents/reidratacao.ex:224 (desfecho/1)
  • Teste: apps/engine/test/engine/agents/reidratacao_test.exs:128 (texto e aviso de corte na nota; :145 sessão antiga sem tool.result), apps/engine/test/engine/agents/po_server_test.exs:252 (o evento gravado, cortado, com resultadoTotal)
  • Origem: AT-151 — declarado aberto na RN-580 (AT-073)

RN-590 — A pasta docs/ dos artefatos é reconstruída do event log pelo MESMO tradutor do projetor vivo, sem tocar a outbox, e nunca sai como sucesso calado​

O ADR 0148 chamou a pasta docs/ de reconstruível e declarou, no custo 3, que não existia comando de reprojeção: apagar a pasta não a reconstruía, porque as linhas de outbox já estavam marcadas. É a mesma lacuna que a RN-569 fechou para o grafo, num segundo consumidor. Medido por leitura do código: um restore do Postgres, a perda do volume project_workspaces e a conversão de execution_mode (que não toca docs/) deixam a pasta ausente ou no lugar antigo, e nada a reescreve.

A regra: apps/api/src/scripts/reprojetar-artefatos.ts (pnpm --filter api artefatos:reprojetar; node scripts/reprojetar-artefatos.js na imagem) varre session_events e reescreve a pasta. Como na RN-569:

  1. Um tradutor só. A tradução evento → pasta do agente, nome do arquivo e Markdown SAIU do ArtifactProjector para ArtifactEventTranslator; o projetor chega ao evento por uma linha de outbox, a reprojeção por cursor, e os dois o chamam. A superfície do projetor (construtor, drainOnce) não mudou. A lista do que é projetado continua sendo ARTIFACT_PROJECTABLE_EVENT_TYPES — tipo novo entra ali e é traduzido no mesmo lugar, nunca num segundo tradutor.
  2. Idempotente e nunca apaga. O nome é função do evento (versionado: <tipo>.md; append-only: com o seq), então rodar de novo regrava os mesmos arquivos; retomar é rodar de novo, ou --after-event a partir do cursor impresso. Arquivo que o usuário pôs em docs/ fica.
  3. Não toca a outbox (aggregate_type = 'artifact_projection'): roda com a api de pé sem roubar, reabrir ou marcar linha.
  4. Nunca sucesso calado. Diferente do grafo, não há destino que pare tudo (falha de escrita é do ITEM, ADR 0148): cada falha é contada e nomeada com o id do evento, o resto é gravado, e o processo sai com 1. --project inexistente é recusado sem gravar.

O que esta regra NÃO fecha: (a) os versionados (module_map, module_routing, project_image, c4_diagram) mostram o ÚLTIMO evento em ordem de id; --after-event começando entre duas versões pode deixar uma mais antiga — só a rodada completa garante o vigente; (b) escreve na localização ATUAL do projeto, e precisa enxergar o mesmo disco que a api; (c) não recria qa_verdict/secops_verdict/task_blocked/infra_delegation_files, que nunca foram arquivo; (d) sem medição de tempo em event log grande; (e) NÃO entra em backup — é derivada, o argumento do ADR 0152.

  • Código: apps/api/src/application/artifact-projection/artifact-event-translator.ts:21 (ArtifactEventTranslator), :24 (projetarEvento); apps/api/src/application/artifact-projection/artifact-projector.ts:64 (o projetor monta o MESMO tradutor), :131 (delega); apps/api/src/scripts/reprojetar-artefatos.ts:96 (reprojetarArtefatos), :115 (o tradutor), :137 (varredura por cursor sobre session_events), :163 (a falha contada sem abortar), :185 (lerArgumentos), :241 (só a invocação direta reprojeta); apps/api/package.json:45 (artefatos:reprojetar)
  • Teste: apps/api/test/scripts/reprojetar-artefatos.spec.ts:215 (projetor para frente → apaga a pasta → reprojeta → mesmos caminhos e conteúdos; segunda rodada idêntica; outbox intacta), :254 (nunca apaga), :265 (por projeto), :275 (projeto inexistente — caso de falha), :289 (falha de escrita contada sem abortar o resto — caso de falha), :314 (argumento desconhecido); apps/api/test/application/artifact-projection/artifact-projector.spec.ts (o projetor para frente, inalterado, sobre o tradutor extraído)
  • Origem: AT-128 — a lacuna que o ADR 0148 declarou (custo 3)

RN-591 — A instalação sem broker é dita na conversão de modo, em parar/remover e na proposta do Infra Lead, pela mesma fonte da RN-574​

A RN-573/RN-574 (ADR 0161) ensinaram a criação de projeto e a subida na /containers a saber que a instalação não tem broker (BROKER_URL vazia). O fechamento declarou fora o resto: a conversão de modo, parar/remover e o Infra Lead. Esta regra leva as três portas, com a MESMA fonte — ContainerBrokerPort.configurado() — e nenhuma rota nova. Ela diz que a variável EXISTE, nunca que o broker responde (isso segue sendo naoObservado, RN-486).

  1. Conversão de modo (ExecutionModeSection). O campo brokerConfigurado já vinha em GET .../projects-base, que a seção já lê. Converter para container/mounted com a ausência CONFIRMADA (false) deixa o botão inerte e diz o motivo em texto; "consultando", consulta que falha e campo ausente NÃO afirmam nada — "não sei" não vira "tem" nem "não tem" — e não bloqueiam. Converter para runner, ou para o modo que o projeto já tem, nunca é bloqueado. É regra de TELA, como a da criação: o PUT projects/:projectId/execution-mode não a aplica.
  2. Parar/remover na /containers. container_stop/container_remove em container/mounted também passam pelo broker (RN-495), então sem ele só podem terminar em falha: a tela deixa "Parar"/"Remover" inertes com texto próprio (sem_broker_para_parar), !== true como na subida. runner nunca cai aqui.
  3. A api recusa ao propor. ProposeActionUseCase responde 409 sem_broker_na_instalacao, ANTES de criar a proposta, para container_start/container_stop/container_remove em projeto que não é runner quando configurado() é false. É a decisão de COMO o Infra Lead sabe sem HTTP no laço: o engine não lê BROKER_URL (uma segunda fonte divergiria da configurado()), e a chamada que a tool JÁ fazia devolve a recusa. dispatch_container_start/2 passou a entregar ao modelo o TEXTO da mensagem da api (container_start recusado: …), como resultado de ferramenta (RN-163), e não a tupla crua. A RN-566 segue como estava: recusa por MODO, local.

O que NÃO fecha: a proposta às cegas por ausência de IMAGEM continua possível (a RN-566 já declarava); a recusa da api vale também para quem chama sem ser o agente, o que é o desejado — a aprovação só terminaria failed; e o PUT execution-mode continua aceitando a conversão por chamada direta.

  • Código: apps/api/src/application/use-cases/actions/propose-action.use-case.ts:90 (recusa 409), apps/web/src/routes/containers-subida.ts:181 (semBrokerParaCicloDeVida) e :192 (conversaoSemBroker), apps/web/src/routes/settings/ExecutionModeSection.tsx (botão inerte), apps/engine/lib/engine/infra/infra_lead_server.ex:886 (motivo_da_recusa_da_api)
  • Teste: apps/api/test/application/use-cases/actions/propose-action.use-case.spec.ts:207 (409 nas três ações, e container_stop segue pending com broker), apps/web/src/routes/containers-subida.test.ts (as duas funções, três estados), apps/web/src/routes/settings/conversao-de-modo.test.tsx, apps/web/src/routes/ContainersPage.test.tsx, apps/engine/test/engine/infra/infra_lead_server_test.exs (o texto do 409 chega ao modelo)
  • Origem: AT-105 — declarado fora do recorte pelo ADR 0161

RN-592 — A listagem de sessões diz qual é a técnica, pelo vínculo em repo_bootstraps, e a tela lê o marcador em vez do nome​

A RN-582 (AT-131) fez a sessão técnica do provisionamento — a de repo_bootstraps.session_id — deixar de deslocar a sessão de trabalho como "a mais recente", mas com DOIS critérios: a api a reconhecia pelo vínculo em repo_bootstraps, e a web pelo NOME (git-bootstrap). O nome é rótulo que o usuário renomeia (RN-098), então renomear a sessão fazia a Visão Geral e o resumo do workspace discordarem de qual era a sessão de trabalho.

  1. A api devolve o marcador. Cada item de GET /projects/:projectId/sessions carrega technical: boolean (SessionListItemResponseDto), true só para a sessão de repo_bootstraps.session_id DAQUELE projeto (uma por projeto, unique(project_id)). É o MESMO critério do resumo do workspace (sessaoTecnicaDeBootstrap), nunca o nome: renomear não muda o marcador, e uma sessão de trabalho chamada git-bootstrap não vira técnica.
  2. Só a listagem o carrega. O marcador não é coluna de sessions nem campo da entidade Session: o GET de uma sessão e as respostas de criar/renomear/transicionar seguem com SessionResponseDto, sem ele. É a listagem que a tela ordena para achar "a mais recente".
  3. A tela lê o marcador. sessaoMaisRecente (useLatestSession) pula a sessão com technical: true e só a devolve quando é a ÚNICA — a regra da RN-582 inalterada, com a fonte trocada. O tipo SessaoListada do web tira o campo do tipo GERADO do OpenAPI.

O que NÃO fecha: a Visão Geral e o card do handoff na sessão continuam sem consultar o estado da sessão de cada handoff (a lacuna que a RN-582 declarou); decidir se passam a consultá-lo é decisão à parte, fora desta regra.

  • Código: apps/api/src/application/use-cases/sessions/list-sessions-for-project.use-case.ts:30 (execute), apps/api/src/interfaces/http/sessions/dto/sessions.response.dto.ts:110 (SessionListItemResponseDto), apps/web/src/lib/hooks.ts:147 (sessaoMaisRecente)
  • Teste: apps/api/test/application/use-cases/sessions/list-sessions-for-project.use-case.spec.ts:81 (vínculo e não nome; técnica de outro projeto não marca), apps/web/src/lib/sessao-mais-recente.test.ts:28 (renomeada continua fora) e :36 (trabalho chamada git-bootstrap não é técnica)
  • Origem: AT-183 — achado da AT-131

RN-593 — O Dev Lead suspenso e o Infra Lead gravam o tool.result pelo módulo comum​

A RN-589 declarou dois abertos: o Dev Lead que suspende numa aprovação não gravava tool.result em momento nenhum, e o InfraLeadServer só gravava o de recusa de propose_infra_pr, com payload montado à mão. Nos dois casos o agente reidratado (RN-580) lia "o log não registra o desfecho desta chamada" sobre um plano que o usuário já tinha decidido, ou sobre toda chamada de validate_infra_file e de subida de container.

A regra:

  1. Dev Lead: o tool.result nasce na RETOMADA, nunca na suspensão. Quando propose_execution_plan/assess_implementability suspende ({:pending, _}), o log fica só com o tool.call: não há desfecho, e gravar "pending" ali seria a mesma mentira que seria para o modelo. Quando o {:action_settled, …} da MESMA ação chega, o servidor grava o tool.result com o MESMO texto que entra na mensagem role: "tool". ok segue a partição de texto_do_desfecho/1: executed/auto_approved/approved são true; recusa, falha e status que o módulo não conhece são false — ok: true sobre desfecho que ninguém classificou seria afirmar sucesso sem saber. Desfecho de OUTRA ação continua ignorado e não grava nada.
  2. Infra Lead: toda ferramenta despachada inline grava. validate_infra_file, propose_container_start e container_start_via_runner gravam tool.result por Engine.Agents.ResultadoDeFerramenta.payload/2 — proposta aceita pela api é ok: true; recusa local por modo (RN-566), recusa da api (RN-591) e runner ausente são ok: false com o motivo. A recusa de propose_infra_pr sem repositório (RN-577) passa pelo mesmo módulo, com o payload de antes (tool, ok: false, erro) e o corte.
  3. propose_infra_pr aceita segue SEM tool.result, de propósito: ela não emite tool.call (o rastro dela é a proposed_action), e um resultado sem chamada viraria nota órfã na reidratação.

O que segue aberto: a suspensão atravessada por um restart do engine continua perdendo a inscrição no Wake (ADR 0086) — ali o tool.result nunca nasce, e a reidratação diz que o log não tem o desfecho, o que é verdade; o teto de 2.000 caracteres continua ESCOLHA, não medida: nenhum acervo real foi amostrado nesta mudança (o item (c) da RN-589); a saída do validate_infra_file (hadolint/actionlint) pode citar trechos do arquivo que o PRÓPRIO agente escreveu, e o corte é a única contenção — é o item (d) da RN-589 com o primeiro caso concreto.

  • Código: apps/engine/lib/engine/agents/dev_lead_server.ex:330 (handle_info/2 do action_settled, o tool.result em :271), :746 (sentido_do_desfecho/1); apps/engine/lib/engine/infra/infra_lead_server.ex:1111 (registrar_resultado/4), :317 (a recusa de propose_infra_pr)
  • Teste: apps/engine/test/engine/agents/dev_lead_server_test.exs:292 (a suspensão não grava), :364 (a retomada grava o texto que o modelo leu), :488 (recusado é ok: false com o motivo); apps/engine/test/engine/infra/infra_lead_server_test.exs:253 (o desfecho de validate_infra_file), :409 (proposta aceita), :485 (recusa por modo), :863 e :910 (container_start_via_runner com e sem runner)
  • Origem: AT-190 — declarado aberto na RN-589 (AT-151)

RN-599 — No compose de dev a api encontra o broker sozinha, e a falta da pasta gerenciada é dita a cada subida​

O broker sobe por padrão no compose de DEV desde a RN-512, mas a api recebia BROKER_URL vazia (${BROKER_URL:-}, e a linha vinha comentada no .env.example). O resultado, medido no teste do dono em 26/09: com o broker de pé ao lado, brokerConfigurado vinha false e a criação de projeto só liberava runner (RN-573). Duas regras fecham isso:

  1. BROKER_URL tem default no compose de dev, e só nele. O default é http://broker:8090 — o serviço e a porta (BROKER_PORT) que o MESMO arquivo declara, e o teste trava os dois juntos: um default que apontasse para outro lugar trocaria "sem broker" por "broker inalcançável". O valor do .env continua vencendo. Os composes de PRODUÇÃO e de INSTALAÇÃO seguem ${BROKER_URL:-}, de propósito: lá o broker está sob profiles: ["container-broker"], e quem o liga grava a URL (o install.sh, perguntando — RN-575). Um default ali faria a api de uma instalação sem broker afirmar que tem um.
  2. O preflight.mjs RELATA a pasta gerenciada, no molde do DOCKER_GID. Sem PROJECT_WORKSPACES_HOST_DIR, api e engine usam o volume gerenciado project_workspaces (sem caminho de host), o broker fica sem PROJECT_WORKSPACES_HOST_ROOT — o compose de dev a DERIVA daquela —, o stack sobe saudável e o container_start do modo container, o default, termina recusado. O relato tem quatro desfechos que não colapsam: ausente, ok, nao-absoluta (o ~: o Compose o expande na origem do bind-mount de api/engine, mas NÃO na variável que o broker recebe — medido com docker compose config) e divergente (PROJECT_WORKSPACES_HOST_ROOT explícita diferente da pasta que api e engine montam, inclusive quando elas montam o volume). Cada mensagem diz o sintoma e o conserto. Ele NUNCA recusa a subida e NUNCA grava o .env: a resposta é um caminho no disco de alguém, e trocar o volume pela pasta esconde o que já está no volume (os dados ficam lá, só deixam de ser vistos) — a mensagem diz isso também.

O que fica aberto: o pnpm bootstrap NÃO oferece a pasta. Fazê-lo seria um segundo consentir-base.mjs (perguntar num TTY, criar a pasta com o dono certo, gravar as DUAS variáveis juntas, decidir o que fazer com um volume project_workspaces que já tem dados), e isso é frente própria.

  • Código: docker/docker-compose.yml:266 (o default de BROKER_URL), :571 (a derivação de PROJECT_WORKSPACES_HOST_ROOT); scripts/dev/pasta-gerenciada.mjs:63 (avaliarPastaGerenciada), :89 (mensagemDaPastaGerenciada); scripts/dev/preflight.mjs:424 (relatarPastaGerenciada)
  • Teste: scripts/dev/broker-url-no-dev.spec.ts:42 (o default existe), :47 (aponta para o serviço e a porta do arquivo), :64 (produção e instalação sem default); scripts/dev/pasta-gerenciada.spec.ts:31 (ausente), :51 (o ~), :67 (divergente com o volume), :75 (a mensagem diz sintoma, conserto e o que não migra), :113 (o preflight chama o relato sem sair por ele)
  • Origem: AT-213 — teste do dono em 2026-09-26

RN-600 — A criação de projeto pela tela não manda o corpo que a api já se sabe que recusa, uma criação por vez, e a recusa aparece na tela​

No teste do dono em 26/09 a api registrou 11 POST /workspaces/:id/projects → 400 em ~4 s, e o mesmo projeto runner, criado pela api com caminho, saiu 201. Medido por leitura e reproduzido no vitest do assistente: não há retry automático (createProject é chamada direto, sem useMutation), então cada POST foi um CLIQUE. A causa provável é o "Procurar pasta..." do modo runner, que CRIA o projeto antecipadamente (RN-437): no passo de detalhes o campo de caminho fica ACIMA do nome, e clicar antes de digitar o nome mandava { name: '', slug: '', executionMode: 'runner', workspacePath: '/workspace-a-confirmar' } — o CreateProjectDto recusa name com menos de 2 caracteres e slug fora do kebab-case —, e a falha virava só o toast "Não deu para preparar a navegação de pasta agora", sem motivo; clicar de novo era a única resposta que a tela sugeria. Um nome de UMA letra passava no Continuar e voltava 400 no "Provisionar" pelo mesmo MinLength(2).

Três metades, todas na tela (a api está certa em recusar):

  1. Não mandar o que já se sabe recusado. nomeAceitoPelaApi espelha o DTO (2+ caracteres, recortado, e slug não vazio) e passa a ser o gate de nome do passo de detalhes. No modo runner, "Procurar pasta..." fica INERTE enquanto o nome não passa, ou enquanto um caminho DIGITADO não é plausível (caminhoLocalParecePlausivel), e o motivo é dito em texto abaixo do campo (procurar-bloqueado, ADR 0064). Caminho VAZIO continua não bloqueando — o placeholder provisório da RN-437 cobre. É a revisão da frase "nunca bloqueando o clique" da RN-437, que falava do CAMINHO e não previu o nome vazio. O nome vai recortado no corpo.
  2. Uma criação por vez. criacaoEmVoo (um useRef) trava os DOIS caminhos que criam — "Procurar pasta..." e "Provisionar" —, porque o disabled só chega ao botão no render seguinte. No jsdom o disabled já segura o segundo clique; a ref é o que segura dois eventos no mesmo quadro do navegador.
  3. A recusa na tela. A falha 400 da criação antecipada passa a pôr a frase da api (mensagemDaApi, lista do class-validator juntada) no MESMO Alert do passo que o "Provisionar" já usava (RN-170); outros erros seguem só com o toast.

O que não foi medido: o log da api do teste do dono não sobreviveu ao reinício do container, então o CORPO de cada um dos 11 POSTs não foi visto — a causa acima é a única que a leitura do código e o vitest reproduzem, e não prova que foi ela. A adjacência (RN-437) segue como era: o projeto criado antecipadamente com o caminho provisório não recebe o caminho refinado depois no "Provisionar" (ele é REUSADO, não atualizado) — quem o corrige é o runner ao conectar (RN-423).

  • Código: apps/web/src/lib/wizard.ts:108 (nomeAceitoPelaApi); apps/web/src/routes/NewProjectWizard.tsx:112 (montarPayloadDeCriacao), :202 (criacaoEmVoo), :321 (bloqueioDoProcurarNoRunner), :486 (handleProcurarPasta), :536 (handleConfirm)
  • Teste: apps/web/src/routes/NewProjectWizard.test.tsx:850 (describe criar projeto sem rajada de 400 — sem nome, 11 cliques e nenhum POST; caminho relativo; nome de uma letra; nome recortado; três cliques no mesmo quadro criam um; a recusa da api na tela); apps/web/src/lib/wizard.test.ts:210 (nomeAceitoPelaApi)
  • Origem: AT-215 (teste do dono, 26/09), irmã da AT-214

RN-604 — A chamada ao broker tem um teto POR OPERAÇÃO, e o teto estourado diz que foi o teto​

O cliente HTTP do broker aplicava UM teto, 5 s, às cinco operações. O docblock o justificava pelo caminho de LEITURA de tela (RN-486), e ele valia também para exec — cujo pedido CARREGA o próprio timeoutMs — e para start, que espera o docker run e o pull de imagem que ele faz. Reproduzido na AT-233: um broker falso que responde um exec em 6 s, com timeoutMs de 8 s, voltava como BrokerIndisponivelError('sem-resposta') — "o broker de container não respondeu … The operation was aborted due to timeout". Ou seja, todo comando de terminal com mais de 5 s num projeto container/mounted com container running (RN-492) falhava dizendo que o broker estava fora do ar, enquanto o comando seguia rodando.

E o engine cortava também: a chamada container-exec não passava receive_timeout e caía no default do Req, 15 s — o MESMO default de TERMINAL_ACTION_TIMEOUT_MS. Reproduzido com uma api falsa (Bandit) que responde em 16 s: {:error, %Req.TransportError{reason: :timeout}}. Subir o teto só na api não consertaria nada.

A regra:

  1. inspect segue curto (5 s) — é a leitura da /containers e da rota de ciclo de vida, e uma tela que espera o pior caso do broker é pior do que uma que declara "não observado".
  2. exec DERIVA do timeoutMs (ou do default de 15 s do broker, quando vem sem ele): contexto do broker (10 s) + ps (30 s) + o maior entre o comando e o docker version de diagnóstico (30 s) + 5 s de margem. O broker corta o comando em timeoutMs e RESPONDE timedOut: true; a api tem de estar esperando quando a resposta chega.
  3. start/stop/remove esperam o pior caso do PRÓPRIO broker: contexto + ps + a operação (o run com o pull; o stop com os 10 s de graça; rm --force) + o docker version que ele roda quando ela falha, mais a margem. Esperar isso é esperar a RESPOSTA do broker, inclusive a recusa nomeada dele. A conta nasceu com 105 s e esquecia o SEGUNDO ps do resolver (o gerenciado e o homônimo): o pior caso real era 130 s. Desde a RN-605 ela conta as seis chamadas de controle do start e dá 195 s.
  4. O engine espera mais que a api: timeoutMs + 90 s. A cadeia é broker < api < engine, e os testes dos dois lados a conferem (a api espelha a folga do engine como FOLGA_DO_EXEC_NO_ENGINE_MS, e o teste do engine lê esse número do fonte da api).
  5. Teto estourado tem motivo próprio: BrokerIndisponivelError ganha teto-excedido, distinto de sem-resposta (conexão recusada, DNS), e a mensagem nomeia a operação e o número — e, nas longas, a consequência: o comando pode seguir rodando no container; o run/pull pode ter acontecido, então confira /containers antes de repetir. Na leitura da tela teto-excedido cai no MESMO naoObservado: 'broker-sem-resposta' de sempre, com o texto no detalhe — nenhum valor novo no DTO.

Os números do outro lado (TIMEOUT_DE_CONTROLE_MS, TIMEOUT_DE_EXEC_PADRAO_MS, o TIMEOUT_MS do cliente de contexto do broker) são ESPELHADOS em constantes nomeadas, não importados: a api não consome o pacote da porta de Docker. Nada muda na contenção do broker (ADR 0130), na DockerPort nem na RN-502.

Declarado, não fechado:

  • Pull de mais de 30 s continua falhando — no broker. O pull roda sob o TIMEOUT_DE_CONTROLE_MS (30 s) da porta de Docker, e uma imagem grande não sobe na primeira tentativa. Desde a RN-605 o estouro tem NOME (PullExcedeuTetoError, origem infra) em vez de "código -1" com origem: null; o teto em si não subiu, por decisão do mantenedor.

  • O fetch do Node espera cabeçalhos por no máximo 300 s. Um exec com timeoutMs acima de ~255 s é cortado pelo undici antes do teto desta regra; o erro sai teto-excedido nomeando os 300 s. O salto de fora (/internal/actions/execute, api → engine) também não tem teto explícito e cai nos mesmos 300 s, então TERMINAL_ACTION_TIMEOUT_MS acima de ~210 s não chega inteiro.

  • Broker doente no ps (o docker ps levando os 30 s inteiros e o diagnóstico outros 30) pode exceder o teto do exec com comando curto; sai teto-excedido, com o nome certo.

  • Código: apps/api/src/infrastructure/http-clients/container-broker.client.ts:276 (tetoDaOperacao), :241 (TETO_DE_MUTACAO_MS), :253 (FOLGA_DO_EXEC_NO_ENGINE_MS), :306 (erroDeTransporte); apps/api/src/application/ports/container-broker.port.ts:96 (MotivoDeBrokerIndisponivel); apps/engine/lib/engine/sessions/engine_api_client.ex:2034 (teto_do_container_exec_ms)

    apps/engine/lib/engine/sessions/engine_api_client.ex:2034 (teto_do_container_exec_ms)

  • Teste: apps/api/test/infrastructure/http-clients/container-broker.client.spec.ts:286 (a reprodução, contra um broker node:http que demora 6 s), :182 (o teto de cada operação no AbortSignal), :204 e :229 (teto-excedido nomeando operação e número), :245 (o teto do undici), :330 (describe tetoDaOperacao, incluindo a ordem broker < api < engine); apps/api/test/application/use-cases/containers/spec-e-observacao-de-container.use-case.spec.ts:298 (a tela continua dizendo broker-sem-resposta); apps/api/test/application/use-cases/containers/executar-comando-no-container.use-case.spec.ts:98; apps/engine/test/engine/sessions/engine_api_client_container_exec_test.exs:72 (a reprodução do lado do engine, HTTP real com Bandit), :91 (a folga espelhada na api)

  • Origem: AT-233

RN-605 — O pull de imagem é um passo NOMEADO do start, sob o teto de controle, e o estouro diz que imagem grande não sobe​

O start da porta de Docker não fazia pull: o docker run fazia, por dentro, sob o TIMEOUT_DE_CONTROLE_MS (30 s). Reproduzido na AT-234 com um duplo do daemon (imagem ausente, pull de 45 s, sem dormir e sem rede): o estouro saía como ComandoDeDockerFalhouError — "docker run … terminou com código -1: (sem saída de erro)" —, virava 502 com origem: null no broker e chegava à api sem nome. E matar o CLI CANCELA o pull (medido no Docker 29.8.0, containerd): a imagem continuou ausente nos 60 s seguintes, então a frase "o pull pode seguir no daemon" não valia.

Das quatro saídas medidas (teto próprio de ~300 s, start assíncrono, pull antecipado na decisão de imagem, só dar nome), o mantenedor decidiu a D — só dar nome (2026-09-27). A regra:

  1. O pull é explícito. Antes do run, image inspect; imagem presente não é baixada de novo (o mesmo --pull missing do run). Ausente, docker pull sob o MESMO teto de controle de 30 s — nenhum teto maior.
  2. O estouro tem nome. PullExcedeuTetoError, origem infra, com a imagem, o teto e a consequência inteira: o pull foi cancelado, a imagem continua ausente, imagem grande não sobe por este caminho, e o conserto é baixá-la antes no host. Antes de afirmar isso, o adaptador confere o daemon (docker version): daemon fora continua sendo DockerIndisponivelError. Pull que falha por outro motivo segue o caminho de sempre (ComandoDeDockerFalhouError, sem origem, ADR 0128).
  3. O nome atravessa. O broker responde 504 com origem: 'infra', e a api o repassa em BrokerRecusouError — origem preenchida, nunca null. No modo runner o mesmo adaptador roda na máquina do usuário e a mensagem chega no container_start_result.
  4. Os tetos de quem espera o start contam o pior caso inteiro: os dois ps de resolver, image inspect, pull, run e o docker version de diagnóstico — seis chamadas de controle (CHAMADAS_DE_CONTROLE_NO_START). A api espera 10 + 6 × 30 + 5 = 195 s (TETO_DE_MUTACAO_MS); o engine, no RunnerRouter, 6 × 30 + 5 = 185 s no start e 4 × 30 + 5 = 125 s no stop/remove (eram 60 s, menos que o resolver mais a operação).
  5. O propose_action do engine espera a execução que a api faz dentro dele. container_start, container_start_via_runner e container_stop auto-aprovados são EXECUTADOS na mesma requisição (ProposeActionUseCase), e a chamada caía no default de 15 s do Req — um start longo voltava como timeout de transporte mesmo sem pull. Agora ela leva receive_timeout de 225 s (o teto da api + 30 s), espelhado na api como TETO_DO_PROPOSE_ACTION_DE_CONTAINER_NO_ENGINE_MS; os testes dos dois lados conferem broker < api < engine e runner < engine. container_remove fica fora: nunca nasce auto-aprovado.

Nenhuma dependência nova (ADR 0128), nenhum campo que o chamador escreva (ADR 0130): a imagem continua vindo da decisão do Arquiteto, e pull não é operação nova do broker — é um passo do start.

Declarado, não fechado:

  • Imagem grande não sobe na primeira tentativa, pelo broker nem pelo runner — decisão D do mantenedor. O desfecho agora é nomeado; o conserto é baixar a imagem antes no host.

  • No pior caso de falha, a aprovação fica presa por até ~195 s: ExecuteContainerStartUseCase roda dentro do POST de aprovação, e nem a web nem o servidor HTTP têm teto próprio.

  • Não foi medido se as camadas baixadas antes do cancelamento são reaproveitadas na tentativa seguinte.

  • Código: packages/docker-port/src/docker-cli.ts:202 (PullExcedeuTetoError), :586 (garantirImagem), :125 (CHAMADAS_DE_CONTROLE_NO_START), :116 (TIMEOUT_DE_CONTROLE_MS); apps/broker/src/servidor.ts:231 (PullExcedeuTetoError); apps/api/src/infrastructure/http-clients/container-broker.client.ts:241 (TETO_DE_MUTACAO_MS), :205 (CHAMADAS_DE_CONTROLE_NO_START), :264 (TETO_DO_PROPOSE_ACTION_DE_CONTAINER_NO_ENGINE_MS); apps/engine/lib/engine/sessions/engine_api_client.ex:1911 (opcoes_do_propose_action),

    apps/engine/lib/engine/sessions/engine_api_client.ex:1911 (opcoes_do_propose_action), :1649 (@teto_do_propose_action_de_container_ms); apps/engine/lib/engine/runners/runner_router.ex:52 (@timeout_do_start_ms)

  • Teste: packages/docker-port/src/docker-cli.spec.ts:425 (describe da reprodução, "(AT-234)": o pull de 45 s termina NOMEADO, :457; daemon fora continua daemon fora, :481; o pull que cabe sobe, :493; imagem presente não é baixada, :508; as seis chamadas, :520); apps/broker/src/servidor.spec.ts:317 (504 com origem infra); apps/api/test/infrastructure/http-clients/container-broker.client.spec.ts:144 (chega com origem), :362 e :377 (a conta e a ordem); apps/runner/src/index-handlers.spec.ts:420 (o nome no container_start_result); apps/engine/test/engine/sessions/engine_api_client_propose_action_test.exs:67 (HTTP real: a api que demora 16 s é esperada), :82 e :95 (as ações e a ordem, lendo o espelho da api); apps/engine/test/engine/runners/runner_router_test.exs:183

  • Origem: AT-234

RN-609 — proposed_action.created diz QUAL raiz o escopo comparou, relativa e nunca absoluta​

A RN-567 pôs no evento a REGRA que decidiu (reason) e declarou o que ficava de fora: o ponto 7 do ADR 0055 pede qual escopo autorizou, e escopo: cd dentro da pasta do projeto não diz qual pasta era o escopo naquele dia. A raiz muda — conversão de modo, base nova —, e o evento é o único lugar que guarda o que valia no instante da decisão.

A regra: o payload do evento de SESSÃO proposed_action.created de uma ação terminal ganha scopeRoot, com três campos — executionMode, ancora e segmento:

modoancorasegmento
containerraiz_gerenciadao workspace_dir_name — a raiz É <PROJECT_WORKSPACES_ROOT>/<segmento>
mountedbase_de_projetoso pedaço sob BRABO_PROJECTS_BASE, pela MESMA segmentoSobABaseDeProjetos que o broker usa
mounted (sem base, fora dela, ou a própria base)indisponivelnull
runnernome_da_pastao workspace_dir_name — identifica a pasta sem ser relativo a nada que o servidor conheça

Nunca o caminho absoluto, e isso é a decisão (AT-147, do mantenedor). O event log é lido por todo membro do projeto e evento não se reescreve; em mounted/runner a raiz é uma pasta da máquina do usuário, com o $HOME dele no caminho. Por isso indisponivel existe: cair no caminho absoluto "para não perder informação" seria exatamente o vazamento. No runner a base local do agente nunca atravessa a rede, então não há segmento relativo que o servidor possa calcular — a ancora diz que o valor é um NOME e não um caminho.

Só em terminal. É o único tipo em que decide() consulta o escopo (terminalNoEscopo); nos outros o campo não existe, em vez de existir dizendo algo que a decisão não usou. A derivação é a de projectScopeRoot — raizDoEscopoNoEvento mora ao lado dela, no mesmo arquivo, e não é segunda régua: o que ela faz é EXPRIMIR a mesma raiz sem a parte absoluta.

Aditivo, e ausente não é "sem raiz". Evento gravado antes desta regra, ou de outro tipo, não tem o campo; não há reprocessamento nem coluna nova. O outbox não o carrega, pelo mesmo motivo da RN-567 (sem consumidor no engine). Nada em decide() muda, nenhum teto, e a tela não muda — onde ele aparece é outra entrega (AT-148). (Desde a RN-614 a raiz aparece junto do motivo, na linha do evento no painel de log e no ApprovalCard, sempre relativa e com texto próprio para indisponivel.) A web lê o payload como registro genérico, então evento antigo e novo passam pela mesma leitura.

Declarado: em mounted, o segmento é relativo à base configurada NO INSTANTE da decisão — se a base mudar depois, o segmento antigo continua dizendo o que era verdade então, que é o ponto. E a separação entre escopo e permissions.json (RN-478) não entra no campo: ele descreve a raiz do ESCOPO, nunca onde o arquivo de política mora.

  • Código: apps/api/src/infrastructure/filesystem/project-workspaces-root.ts:907 (raizDoEscopoNoEvento), :901 (RaizDoEscopoNoEvento), :117 (segmentoSobABaseDeProjetos, reusada); apps/api/src/application/use-cases/actions/propose-action.use-case.ts:302 (scopeRoot só em terminal); apps/api/src/domain/actions/decide.ts:636 (terminalNoEscopo, o único consumidor do escopo)
  • Teste: apps/api/test/application/use-cases/actions/propose-action.use-case.spec.ts:1143 (container), :1123 (mounted), :1123 (runner), :1123 (o caminho absoluto sob /home/usuario/… NUNCA vaza, nos três casos, inclusive fora da base), :1213 (indisponivel), :1163 (outro tipo e o outbox sem o campo), :1183 (evento anterior, sem o campo, pela mesma leitura); apps/api/test/infrastructure/filesystem/project-workspaces-root.spec.ts:772 (a função pura, nos três modos e nos três casos de indisponivel)
  • ADR: 0055 (ponto 7)
  • Origem: AT-147 (EP-025/HS-043)

RN-610 — O Infra Lead recusa a subida por ESTADO, na ordem da /containers: já de pé, sem imagem, pasta nunca confirmada​

A RN-566 deu às duas tools de subida do Infra Lead a recusa por MODO e declarou o resto: a /containers checa três coisas antes de oferecer o botão (imagem decidida, modo, pasta confirmada) e o agente checava uma. A decisão do mantenedor (AT-142, 2026-09-27) foi fechar essa distância por recusas locais no despacho, no molde da RN-566/RN-577, e NÃO por contexto enriquecido — GetInfraContextUseCase não muda.

recusa_local_de_subida/2 continua lendo o projeto UMA vez. Primeiro o MODO (recusa_por_modo/2, a régua da RN-566 sem mudança de texto) — é ele que diz qual tool usar, e responder "falta imagem" a quem chamou a tool errada apontaria a porta errada. Depois o ESTADO (recusa_por_estado/3), uma cláusula por motivo, na MESMA ordem de decidirSubida (apps/web/src/routes/containers-subida.ts), avaliadas só até a primeira recusa:

Cláusulapropose_container_startcontainer_start_via_runnerLeitura local
container já REGISTRADO running/provisioning (ja_esta_de_pe)recusarecusaproject_containers.status
nenhuma imagem decidida (sem_imagem_decidida)não recusarecusaexiste artifact.project_image em alguma sessão do projeto
pasta nunca confirmada (runner_nunca_confirmou)—recusaprojects.workspace_verified_at nulo
nenhum runner conectado agora—recusa (já existia, RN-508)Engine.Runners.Registry

Por que propose_container_start não recusa por imagem. Eleger a imagem é o que essa proposta FAZ (RN-491): exigir decisão antes inverteria a ordem. A tela checa imagem nos dois modos porque o botão dela não elege nada. container_start_via_runner não elege — sobe a imagem JÁ decidida, e ExecuteContainerStartViaRunnerUseCase falha sem ela —, então ali a cláusula vale. O predicado é o MESMO da api (ObterContainerDoProjetoUseCase): EXISTÊNCIA do evento, nunca validade do payload, porque a api degrada payload ilegível para o default em vez de tratá-lo como ausência.

Por que "já de pé" recusa, se a execução não falharia. SubirCicloDeVidaDoContainerUseCase é idempotente sobre provisioning/ running, então a proposta aprovada seria um nada que gastou uma decisão humana — e em container/mounted pior que nada: elegeria uma versão nova de artifact.project_image que o container de pé, com a versão congelada na linha (RN-245), não usaria. Registrado não é observado (RN-486): o texto não afirma que o container está vivo, diz o que fazer quando ele morreu por fora. stopped, failed e removed não recusam. Erro de consulta devolve nil e cai no caminho de sempre — "não consegui olhar" nunca vira recusa.

Os textos, que são o resultado de ferramenta que o modelo lê (RN-163) — nunca agent.error, nunca fim de turno, com tool.call/tool.result (ok: false) no event log pelo mesmo registrar_resultado/4 da RN-593:

  • já de pé: "o container deste projeto já está REGISTRADO como <status> — subir não é a próxima ação, e <tool> não foi proposta. O registro não é observação (RN-486): se o container morreu por fora, ou se a imagem precisa mudar, diga ao usuário que parar ou remover é pela página /containers; só depois disso uma nova subida faz sentido. Não repita a chamada agora."
  • sem imagem: "nenhuma imagem de container foi decidida para este projeto (artifact.project_image, RN-105) — container_start_via_runner sobe a imagem JÁ decidida e não elege nenhuma, então aprovada ela só poderia falhar, e não foi proposta. Quem decide a imagem é o Arquiteto (choose_project_image): diga isso ao usuário e não repita a chamada até haver decisão."
  • pasta nunca confirmada: "a pasta deste projeto nunca foi confirmada por um agente local (workspace_verified_at vazio, RN-423) — nenhum brabo-runner jamais conectou a ele, e container_start_via_runner não foi proposta. Peça ao usuário para rodar brabo-runner --project <id> --dir <pasta> na máquina dele: a confirmação acontece quando o runner conecta."

Nenhuma linha de prompt mudou, e nenhuma medição com modelo real foi feita nesta regra: a premissa de que o modelo entende a recusa é a da RN-566.

O que NENHUMA cláusula checa, declarado: instalação sem broker (o engine não lê BROKER_URL; quem recusa é a api ao propor, com 409 sem_broker_na_instalacao, RN-591) e papel/sessão (o agente não é quem clica). A cláusula de pasta usa o carimbo, que não é batimento (RN-468) — por isso a de runner conectado continua depois dela.

  • Código: apps/engine/lib/engine/infra/infra_lead_server.ex:895 (recusa_local_de_subida), :953 (recusa_por_estado), :1025 (recusa_ja_de_pe), :1033 (recusa_sem_imagem_decidida), :1034 (recusa_pasta_nunca_confirmada), :1035 (recusa_runner_desconectado); apps/engine/lib/engine/containers/project_container_lifecycle.ex:83 (status_registrado); apps/engine/lib/engine/session_events/event.ex:120 (imagem_decidida?)
  • Teste: apps/engine/test/engine/infra/infra_lead_server_test.exs:742 (via runner, running recusa), :804 (provisioning recusa), :814 (stopped não recusa), :779 (sem imagem), :790 (pasta nunca confirmada), :850 (propose_container_start com container running), :850 (propose_container_start sem imagem PROPÕE), :863 (caminho feliz do via runner), :901 (sem runner conectado)
  • Origem: AT-142

RN-611 — A chave de MÁQUINA ganha listagem e revogação por CONTA, sem :projectId: só as de máquina, só as do próprio usuário, e a revogação é a MESMA de sempre​

A lacuna que a RN-552 e a RN-561 declararam por escrito: "numa instalação que ainda não tem PROJETO, a listagem e a revogação — as duas por /projects/:projectId/runner-device-keys — não têm projeto contra o que responder". A instalação de uma linha (RN-547) cria EXATAMENTE essa situação: a conta e a chave de máquina nascem antes de qualquer projeto, e a chave ficava viva e inalcançável. Decisão do mantenedor (AT-118): sim, na Conta.

Duas rotas novas, e só duas. GET /users/me/machine-device-keys lista e DELETE /users/me/machine-device-keys/:deviceKeyId revoga (204, idempotente). Sem @RequireRole, como users/me/credentials e users/me/preferences: o escopo é a PRÓPRIA pessoa, e não há projeto nem workspace contra o que resolver um papel. O userId vem do JWT de sessão e vai para o WHERE; nenhum parâmetro nomeia outra pessoa, e a visão de maintainer sobre a chave alheia continua fora por decisão da RN-519 — esta rota não a reabre por outra porta.

Só a espécie de MÁQUINA. A listagem devolve SÓ project_id IS NULL (especie: "maquina"), revogadas INCLUÍDAS (a régua da RN-519: sumir com a linha faria a tela afirmar que a chave nunca existiu). As de PROJETO têm casa — a seção device-keys do projeto delas, onde o alcance de revogá-las é dito — e trazê-las para a Conta faria a Conta derrubar pareamento de projeto sem nomear o projeto. Pela mesma razão, a revogação por conta recusa chave de PROJETO com 404, a MESMA resposta de chave que não existe ou que é de outra pessoa: a pergunta vai à MESMA listagem da tela, filtrada por userId, então não há como perguntar pela chave alheia e aprender que ela existe.

O que revogar derruba de fato, e o alvo NÃO muda. A revogação por conta não é uma revogação nova: RevokeMachineDeviceKeyUseCase confere que a chave está entre as de máquina do chamador e DELEGA a RevokeRunnerDeviceKeyUseCase, byte a byte. Ele grava a linha e, para a chave de máquina, pede ao engine um disconnectRunnerOfUser por projeto em modo runner que o dono alcança (RN-520/RN-543). O alvo continua {projeto, usuário} e nunca {chave}: outro runner do mesmo usuário nesses projetos cai junto, mesmo com PAT ou outra chave, e reconecta se a credencial dele ainda valer. Numa instalação sem projeto, a lista de projetos é vazia e a revogação só grava a linha — e é o que basta: o agente de máquina que espera o primeiro projeto (RN-550) não tem conexão nenhuma a derrubar, e o próximo ticket que ele pedir é recusado. Mudar o alvo continua sendo frente própria, com ADR (AT-013).

"Registrar substitui a anterior" FICA. A RN-552 tinha duas razões para revogar a chave de máquina anterior a cada registro: (a) o token de serviço não pode fabricar credenciais duradouras em série, e a máquina reinstalada não pode deixar para trás uma chave viva que ninguém lembra de revogar; (b) a órfã seria INALCANÇÁVEL. Esta RN derruba a (b) e deixa a (a) intacta — ela não depende de tela. O que muda é que a substituída passa a APARECER, revogada, na Conta; e como ninguém a revogou pela tela, a seção diz em TEXTO que reinstalar revoga a anterior, para a linha não ser lida como incidente.

A tela mora na Conta (/account), a página que já existia para o que segue a pessoa entre projetos. Ela lista nome, data de criação, último uso e status, e herda o vocabulário INTEIRO da RN-561/RN-548: lastUsedAt é uso REGISTRADO, nunca conexão, e nulo tem texto próprio ("nunca usada"); "ativa"/"revogada" fala da LINHA e nunca de agente de pé — e "ativa" não é verde, porque a espécie de máquina nunca ganha verde e ali só há dela. Não há coluna de espécie: com uma espécie só ela repetiria a mesma palavra em toda linha, e o que ela carregava na seção de projeto — o ALCANCE — é dito na legenda e na confirmação ("derruba o agente local em TODOS os seus projetos em modo runner; sem projeto nenhum, só impede a chave de ser usada de novo"), junto com o custo colateral da RN-520. Carregando, falhou, vazio, revogada e nunca usada são cinco estados com cinco textos (RN-088/RN-470). Sem papel mínimo no endpoint, a tela não inventa um: roleAtLeast aqui trancaria quem pode, o defeito pior dos dois.

Revogar por uma lista invalida TODAS. A chave de máquina aparece na lista da Conta (['machine-device-keys']) e na de TODO projeto do dono (['runner-device-keys', projectId], lida pelo RunnerOnboardingPanel e pela seção device-keys). invalidarChavesDeDispositivo invalida a por projeto por PREFIXO (todo projeto no cache) e a da Conta sempre, e as DUAS telas de revogação a chamam — antes, revogar pela seção do projeto invalidava só a do projeto aberto, e as outras listas seguiam anunciando a revogada.

  • Código: apps/api/src/interfaces/http/runner/machine-device-keys.controller.ts:48 (MachineDeviceKeysController), :69 (listMachineDeviceKeys), :93 (revokeMachineDeviceKey); apps/api/src/application/use-cases/auth/list-machine-device-keys.use-case.ts:33 (ListMachineDeviceKeysUseCase); apps/api/src/application/use-cases/auth/revoke-machine-device-key.use-case.ts:33 (RevokeMachineDeviceKeyUseCase); apps/api/src/infrastructure/persistence/drizzle/runner-device-key.repository.ts:97 (listarDeMaquinaDoUsuario); apps/web/src/routes/MachineDeviceKeysSection.tsx:69 (MachineDeviceKeysSection); apps/web/src/lib/chaves-de-dispositivo-queries.ts:31 (invalidarChavesDeDispositivo); apps/web/src/lib/api-client.ts:455 (listMachineDeviceKeys); apps/web/src/locales/{en,pt-BR}/machineKeys.json
  • Teste: apps/api/test/application/use-cases/auth/machine-device-keys.use-case.spec.ts (a lista só pelo usuário, a revogada incluída; a revogação REAL derrubando {projeto, usuário} em cada projeto; sem projeto, só grava; a chave fora das do chamador é 404 sem revogar nada); apps/api/test/interfaces/http/runner/machine-device-keys.controller.spec.ts (sem @RequireRole, 204, userId do JWT, o 404 propagado); apps/api/test/infrastructure/persistence/drizzle/runner-device-key.repository.spec.ts (contra o Postgres: só as de máquina do usuário, a substituída revogada na lista, a de outro usuário fora, instalação sem projeto); apps/web/src/routes/MachineDeviceKeysSection.test.tsx (lista, os cinco estados, a confirmação com o alcance, a invalidação da Conta E de todo projeto — provada também sobre uma entrada de projeto no cache —, a falha com a frase da api, e os dois idiomas); apps/api/test/interfaces/route-surface.spec.ts (as duas rotas classificadas jwt em docs/security-surface.md)
  • Fica declarado e NÃO fecha: (1) o alvo da revogação continua {projeto, usuário} (RN-520, AT-013); (2) a visão de maintainer continua fora (RN-519); (3) a rota que CRIA chave de máquina continua servindo só instalação de UMA pessoa (RN-552) — a Conta lista e revoga, não cria.
  • Origem: AT-118 (EP-003/HS-007)

RN-614 — A tela mostra o motivo da política e a raiz do escopo, numa frase só, nos dois lugares​

A RN-567 pôs no proposed_action.created a regra que decidiu (reason) e a RN-609 pôs a raiz do escopo (scopeRoot, só em terminal) — e as duas declararam que a tela não mudava. Quem auditava pela tela uma ação que PASSOU continuava sem saber qual regra a deixou passar.

A regra: o dado aparece em DOIS lugares (decisão do mantenedor), e os dois dizem a MESMA frase, montada por UMA função, fraseDaDecisaoDaPolitica:

  • a linha do evento no painel de log — classifyEvent ganha o ramo de proposed_action.created (tipo permission, bad quando negada), com o desfecho da política e, depois do travessão, a frase;
  • o ApprovalCard, numa linha própria logo abaixo da frase do que acontece (RN-096) — nunca dentro do payload colapsado.

A ação (proposed_actions) não guarda nenhum dos dois campos: a tela os lê do EVENTO, que ela já carrega (decisaoDaPoliticaDaAcao, pelo actionId). Por isso o card tem TRÊS estados, que não colapsam: tela que não lê o event log (o painel "precisa de você", PRs, containers) não passa o dado e o card CALA; tela que lê (o chat da sessão e a fila da aba Aprovações, ambos sobre a sessão mais recente) e não achou o evento na janela carregada diz "fora dos eventos carregados nesta tela"; e o evento achado dá a frase. A aba Insights não tem ApprovalCard — hipótese do Psicólogo não é proposed_action.

Desde a AT-336 (RN-656), cada card da aba Aprovações tem o PRÓPRIO motivo, lido pela ação quando o log carregado não o cobre; o parágrafo seguinte é o estado da AT-333, que ela substituiu — a nota única sobra só para a leitura que FALHA. Desde a AT-340 o mesmo vale para os cards das pendências de OUTRAS sessões no chat (RN-626), que antes calavam: o log do fio nunca cobre ação de outra sessão.

Na AT-333, a fila da aba Aprovações passou a dizer a lacuna UMA vez. A fila é a do PROJETO (RN-638) e os eventos são os de UMA sessão, então ali o "fora dos eventos carregados" saía em quase todo card — verdade repetida por card, a ferramenta se explicando. A api não expõe o motivo por ação (a ação não o guarda, e a leitura de eventos não filtra por actionId: medido), e buscar sem janela exigiria endpoint novo. A aba passa então a CONTAR as pendentes sem o evento e a dizer, numa nota acima da fila, quantas são e por quê ("destas N ações", ou "de N das M ações abaixo"), no molde da RN-180; o card dessas cala (o estado "tela que não lê"), e o card cujo evento está carregado continua mostrando a frase. A nota só conta com o log em mãos: antes de os eventos chegarem ela não afirma nada. O chat da sessão segue com os três estados por card — ali o recorte é o do próprio fio.

Ausente não é "sem motivo", e o absoluto nunca aparece. Evento anterior à RN-567 diz "motivo da política não registrado"; terminal anterior à RN-609 diz "raiz do escopo não registrada"; outro tipo, sem scopeRoot, não fala de raiz (o escopo só é consultado em terminal). As quatro âncoras têm texto próprio — pasta gerenciada, segmento dentro da base de projetos, nome da pasta na máquina do runner, e indisponivel ("sem forma relativa", sem segmento). Âncora que a tela não conhece diz que não conhece, e não imprime o segmento: a tela não reconstrói caminho nenhum. As frases estão em en e pt-BR (approvals.json, chave politica).

  • Código: apps/web/src/lib/decisao-da-politica.ts:111 (fraseDaDecisaoDaPolitica), :65 (lerDecisaoDaPolitica), :124 (decisaoDaPoliticaDaAcao), :145 (linhaDoEventoDePolitica); apps/web/src/lib/activity.ts:721 (o ramo de proposed_action.created); apps/web/src/components/ApprovalCard.tsx:272 (a linha do card); apps/web/src/routes/session-timeline-montagem.tsx:825 e apps/web/src/routes/ProjectApprovalsTab.tsx:311 (decisoesDaPolitica, desde a AT-336 por useDecisoesDaPolitica) e :499 (quem passa o dado)
  • Teste: apps/web/src/lib/decisao-da-politica.test.ts:43 (as âncoras, :43/:54/:65/:76), :87 (evento antigo), :102 (âncora desconhecida sem vazar caminho), :114 (os dois idiomas), :144 (a linha do log É a frase do card); apps/web/src/components/ApprovalCard.test.tsx:733 (o card usa a mesma função, e os três estados); apps/web/src/routes/ProjectApprovalsTab.test.tsx:304 (desde a AT-336, a nota conta só a leitura que falha)
  • Origem: AT-148 (EP-025/HS-043); a nota da fila, AT-333; o motivo por cartão, AT-336

RN-615 — A remoção de membro de WORKSPACE nasce com o teto 2 herdado: remover a si mesmo é 403, e é essa cláusula que segura o último owner, sem contar​

A quinta porta da linha dos tetos (RN-472, RN-556, RN-557), e a rota que o ADR 0157 recusou abrir de passagem. Até aqui WorkspacesController não tinha @Delete de membro, e offboarding era escrita no banco. Decisão do mantenedor (AT-115/HS-006): a rota nasce, protegida por CLÁUSULA.

A rota. DELETE /workspaces/:workspaceId/members/:userId, owner (o MESMO mínimo do upsert), 204, idempotente.

O teto é herdado, sem régua nova. remocaoEhAutoRebaixamento recebe o papel do ator no WORKSPACE como o efetivo de hoje e null como o papel de DEPOIS — no workspace não há nível acima para segurar a queda, então remover a própria linha é sempre queda para "nenhum acesso", o ramo que a função já enunciava para o projeto sem linha de workspace. Remover a SI MESMO é sempre 403, com frase própria: "Você não pode remover a si mesmo deste workspace: aqui não há papel acima para segurar a queda, e sem a linha você perde o workspace e todos os projetos dele. Voltar exige o owner que você estaria abandonando. Peça a outro owner."

O último owner, pela MESMA cláusula. Só owner chama a rota; ninguém remove a si mesmo; logo toda remoção bem-sucedida deixa ao menos o CHAMADOR como owner. Tirar o último exigiria que ele se removesse — o movimento recusado. A contagem segue recusada (ADR 0157, ponto 2). O mínimo owner da rota é METADE da prova, e por isso é asserido em teste.

movimentodesfecho
remover a SI MESMO do workspace, havendo ou não outro owner403
remover OUTRO ownerpassa — é como se revoga propriedade por inteiro
remover o TITULAR (created_by), por outro owner409 criador_do_workspace — RN-616
remover qualquer outra pessoapassa
remover quem não tem linha de workspace204 — as linhas de projeto dele no workspace saem igual

A cascata, numa transação. A linha de workspace_members; as linhas de project_members do removido nos projetos DESTE workspace (sem elas a sobreposição projectRole ?? workspaceRole da RN-471 o manteria dentro de todo projeto em que tivesse linha própria); e as credenciais dele presas a esses projetos — chaves de dispositivo de PROJETO e PATs —, revogadas com o motivo workspace_member_removed. Depois do commit, a conexão viva do runner dele cai em CADA projeto do workspace pelo caminho da RN-520, em try/catch que só loga: engine fora do ar não derruba a remoção.

Declarado e NÃO feito: a chave de MÁQUINA (é da conta e serve outros workspaces; aqui ela deixa de alcançar porque o papel resolve para nenhum); o socket de SESSÃO já conectado e o terminal :web do removido (seguem até cair; o ticket novo é recusado); tela (não existe seção de membros de workspace no apps/web). O TITULAR (workspaces.created_by) não sai por esta rota — é a RN-616.

  • Código: apps/api/src/interfaces/http/iam/workspaces.controller.ts:262 (removeMember); apps/api/src/application/use-cases/iam/remove-workspace-member.use-case.ts:79 (RemoveWorkspaceMemberUseCase); apps/api/src/domain/iam/tetos-de-rebaixamento.ts:88 (MENSAGEM_TETO_AUTO_REMOCAO_DO_WORKSPACE), :248 (remocaoEhAutoRebaixamento); apps/api/src/infrastructure/persistence/drizzle/workspace.repository.ts:84 (removeMember); apps/api/src/infrastructure/persistence/drizzle/project.repository.ts:167 (removeMemberFromWorkspaceProjects); apps/api/src/infrastructure/persistence/drizzle/runner-device-key.repository.ts:169 (revogarChavesDeProjetoNoWorkspace); apps/api/src/infrastructure/persistence/drizzle/personal-access-token.repository.ts:187 (revogarDoUsuarioNoWorkspace)
  • Teste: apps/api/test/application/use-cases/iam/remove-workspace-member.use-case.spec.ts (contra o Postgres: remove outro membro com a cascata inteira e nada fora do workspace; remove outro owner; engine fora do ar não derruba; idempotente; recusa a si mesmo sem escrever nada; frase própria; o owner único não sai; com dois owners, quem remove fica); apps/api/test/interfaces/http/iam/workspaces-members.controller.spec.ts (owner nas duas rotas de membro, 204, a ordem ator/alvo, o 403 propagado); apps/api/test/interfaces/route-surface.spec.ts (a rota classificada em docs/security-surface.md)
  • ADR: 0173
  • Origem: AT-115 (EP-002/HS-006)

RN-616 — O TITULAR do workspace não sai pela remoção de membro: 409 criador_do_workspace até transferir, e a titularidade só vai para outro owner​

Decisão do mantenedor sobre a questão que a RN-615 deixou aberta (AT-115, ADR 0173, ponto 7). workspaces.created_by não autoriza nada — quem autoriza é o papel (RN-472) —, mas é por ele que se resolve a credencial de LLM que os agentes gastam e a de git das ações de agente (RN-058) e o dono do relatório de gasto (RN-060). Remover o criador deixaria os agentes gastando a credencial de quem já não está no workspace. A coluna passa a ser chamada, na regra, de titular.

A recusa. DELETE /workspaces/:workspaceId/members/:userId com alvo = titular é 409 com code: "criador_do_workspace": "Não é possível remover o titular do workspace: a credencial de LLM que os agentes gastam, a de git das ações de agente e o relatório de gasto são dele (RN-058/RN-060). Transfira antes a titularidade para outro owner (PUT /workspaces/:workspaceId/owner-of-record) e remova depois." O teto da RN-615 vem antes: o titular tentando se remover recebe o 403 de auto-remoção.

A transferência. PUT /workspaces/:workspaceId/owner-of-record, owner, corpo { userId }, responde o workspace (200). O destino precisa JÁ ser owner do workspace, senão 409 titular_precisa_ser_owner ("A titularidade só vai para quem já é owner deste workspace: o titular paga o que os agentes gastam (RN-058). Promova a pessoa a owner antes, ou escolha outro owner."). Grava created_by numa transação com a leitura do papel; transferir para o titular atual é idempotente. Qualquer owner pode transferir, inclusive para OUTRO owner que não pediu — declarado, é a mesma autoridade que remove donos.

O gasto, a partir do commit. ResolveCredentialOwnerUseCase devolve o novo titular: o turno de agente procura a credencial DELE para o provider do modelo, e as ações de git a credencial de git dele. Sem credencial, o desfecho é o que já existia — o turno termina com "Nenhuma credencial cadastrada para " e a leitura do remoto git falha nomeando de quem é a credencial que falta. A transferência NÃO confere isso antes (declarado no ADR). O gasto registrado não se move: token_usage não guarda titular, e o relatório inteiro, histórico incluído, passa a ser do novo titular. Sem evento de domínio: fica uma linha de log com de/para/quem.

  • Código: apps/api/src/domain/iam/titularidade-do-workspace.ts:22 (CODIGO_CRIADOR_DO_WORKSPACE), :38 (removeOTitular); apps/api/src/application/use-cases/iam/remove-workspace-member.use-case.ts:120 (removeOTitular); apps/api/src/application/use-cases/iam/transfer-workspace-ownership.use-case.ts:39 (TransferWorkspaceOwnershipUseCase); apps/api/src/infrastructure/persistence/drizzle/workspace.repository.ts:98 (transferirTitularidade); apps/api/src/interfaces/http/iam/workspaces.controller.ts:324 (transferOwnership)
  • Teste: apps/api/test/application/use-cases/iam/remove-workspace-member.use-case.spec.ts (describe "o TITULAR não sai": o 409 com código e nada escrito; o titular removendo a si mesmo recebe o 403; depois de transferir, remover o antigo criador passa e o novo titular vira o protegido — e describe "TransferWorkspaceOwnershipUseCase": para owner grava created_by; para maintainer e para não-membro é 409 titular_precisa_ser_owner; para o titular atual é idempotente); apps/api/test/interfaces/http/iam/workspaces-members.controller.spec.ts (owner e a delegação da transferência)
  • ADR: 0173, ponto 7
  • Origem: AT-115 (EP-002/HS-006)

RN-617 — O Infra Lead conversa pelo composer, e o turno dele passa pelo TurnoAssincrono: aceite imediato, "Parar", turno órfão fechado​

A decisão que a RN-584 deixou aberta — "se o Infra Lead passa a conversar pelo composer" — foi tomada pelo mantenedor (AT-141, 2026-09-27): sim, e ele vira o SÉTIMO conversacional (ADR 0175). A condição que a RN-584 escreveu veio primeiro: o turno dele deixou de rodar DENTRO do handle_call/handle_cast e passou pelo MESMO Engine.Agents.TurnoAssincrono dos outros seis; só depois a cláusula de message/2 nasceu.

Etapa 1 — o turno.

  1. Os três turnos do Infra Lead sobem numa Task — o kickoff (ainda cast, só no start FRESCO do handoff aceito), a correção de gate ({:correct, _}) e a mensagem do composer. A mensagem responde ao ACEITE, com o agent.status: working gravado ANTES (RN-578); o desfecho vai pelo canal e, quando é falha, pelo agent.error durável. O user_message/2 que rodava o turno inline SAIU: o nome ficou e é o aceite.
  2. Segunda mensagem com turno em curso é 409 turno_em_andamento, com o agent.error de origem politica no fio — inclusive durante o kickoff, que antes prendia a mensagem na fila do processo pelo turno inteiro.
  3. "Parar" alcança o Infra Lead (via_for("infra") no cancel/2): mata a Task — e com ela o WorkflowsAgent, que roda dentro da consolidação —, grava agent.error cancelado_pelo_usuario e devolve idle.
  4. A consolidação com o Workflows é parte da função de turno e devolve o state; os sinais de fim (agent.done, agent.status: idle) saem só de TurnoAssincrono, no processo do servidor (RN-585).
  5. A correção de gate que chega com turno em curso entra numa FILA (correcoes_pendentes) e sobe no fecho do turno, na ordem de chegada — o TurnoAssincrono sem from DESCARTA o pedido que encontra turno em curso, e o gate pediria mudança a ninguém.
  6. O turno órfão do reinício fecha pela RN-586: infra entrou em TurnoOrfao (@agentes), no boot e no init/1.
  7. O histórico vem de Engine.Agents.Reidratacao (RN-580), como já vinha, e o tool.result de ResultadoDeFerramenta (RN-593).
  8. O teto de iterações é o dele, 14 (o mesmo de Arquiteto, Dev Lead, UX Designer e Staff), e esgotá-lo passa a emitir toolloop.limit_reached (RN-166) — antes o 14º passo terminava calado.

Etapa 2 — o composer.

  1. message/2 tem cláusula PRÓPRIA para infra, e a recusa agente_sem_conversa dele saiu — um nome, um caminho. @agentes_de_conversa o inclui; a mensagem a um Infra Lead que não está de pé (engine reiniciado) o reergue SEM kickoff, e ele reidrata no init/1.
  2. A tela o oferece pela regra de sempre: infra entrou em AGENTES_DE_CHAT, então ele é o destinatário quando é o agent.activated mais recente — sem destinatário padrão (RN-584). O aceite do handoff DELE continua no card próprio da RN-499; o card do fio o exclui por NOME (offeredHandoff), senão o convite aparecia duas vezes e, sendo o mais antigo, voltava a esconder o do Dev Lead (RN-136). A faixa de atividade ganhou frase para as quatro ferramentas dele.
  3. Conversar não abre efeito externo. As ferramentas são as mesmas quatro; a PR de infra e as duas subidas de container continuam nascendo proposed_action, com as recusas locais das RN-566, RN-577 e RN-610 intactas.

O teto de 180 s deixou de importar para o turno. Ele era menor que os 225 s do propose_action de container (RN-605), e uma mensagem que levasse o modelo a propor container_start podia estourar o GenServer.call com a proposta de pé. Com o aceite imediato, o GenServer.call só espera o aceite; o propose_action corre dentro da Task, sem ninguém esperando síncrono. O 180 000 ficou, igual ao dos outros seis, como teto do ACEITE.

Não muda, declarado: SOLO_CONVERSATIONAL_AGENTS (gerada por pnpm --filter api gerar:areas) é a lista dos agentes SEM área que o handoff manual endereça; o Infra Lead é lead de área e continua fora dela, e AGENTES_DE_CHAT não é gerada. O Infra Lead não ganhou perguntas estruturadas nem leitura de backlog — conversar com ele é conversar com o que o kickoff lhe deu e com as quatro ferramentas.

  • Código: apps/engine/lib/engine/infra/infra_lead_server.ex:187 (user_message), :211 (TurnoOrfao.fechar_ao_subir), :248 (handle_cast(:kickoff), :270 (handle_cast({:correct, a fila), :280 (handle_cast(:cancel), :296 (handle_call({:user_message), :368 (handle_info), :379 (drenar_correcao_pendente), :462 (toolloop.limit_reached), :1158 (concluir);

    (toolloop.limit_reached), :1057 (concluir); apps/engine/lib/engine_web/controllers/agent_command_controller.ex:44 (@agentes_de_conversa), :192 (a cláusula do infra), :604 (via_for); apps/engine/lib/engine/agents/turno_orfao.ex:57 (@agentes); apps/web/src/lib/session-readiness.ts:37 (AGENTES_DE_CHAT); apps/web/src/lib/session-handoffs.ts:120 (ofertasAcionaveis)

  • Teste: apps/engine/test/engine/infra/infra_lead_server_test.exs:1033 (aceite imediato, working antes), :1048 (409 com turno em curso — caso de falha), :1077 ("Parar"), :1103 ("Parar" sem turno), :1107 (a correção de gate na fila), :1788 (toolloop.limit_reached); apps/engine/test/engine/agents/turno_orfao_test.exs:76 (o órfão, agora nos sete); apps/engine/test/engine/agents/reidratacao_dos_seis_test.exs:62 (a reidratação, nos sete); apps/engine/test/engine_web/controllers/agent_command_controller_test.exs:236 (202, 409 e "Parar" pela rota), :283 (reergue sem kickoff); apps/web/src/routes/SessionPage.cancelar-turno.test.tsx:256 (o composer envia ao Infra Lead e o "Parar" é dele); apps/web/src/lib/session-readiness.test.ts:123; scripts/ci/destinos-do-composer.spec.ts:174 (a guarda: infra é destino, com UMA cláusula que conversa)

  • Origem: AT-141 (decisão do mantenedor), aberta pela AT-098

Revisada pela RN-924 (ADR 0226). A fila correcoes_pendentes, só em memória, passou a ser durável (infra.correction_queued/_delivered), no molde da fila de mensagens; o turno órfão do Infra Lead segue a retomada da RN-920.

RN-621 — O resumo da compactação mantém o idioma original de cada turno, e não traduz citação nem código​

Quando o contexto de um agente passa do limiar, o ContextManager troca os turnos antigos por UM resumo, escrito por outra chamada de LLM (o ator context-manager) e reinserido como mensagem system. O prompt dessa chamada é pt-BR e não dizia nada sobre idioma: numa conversa em inglês, ou com dois idiomas, o resumo saía traduzido, e o modelo seguinte lia a própria memória na língua do prompt de resumo — o desvio de idioma que o épico de idioma persegue, nascido fora do turno do usuário. A orientação de idioma do turno não protege isto: ela nunca está em messages, então nunca é resumida, mas também nunca é vista pelo sumarizador.

A regra é uma frase no prompt de resumo, nas DUAS trilhas (o texto inline e o template versionado prompts/context-manager-summarize.md, que sobe para a versão "2"):

Mantenha cada turno no idioma original; não traduza citações nem código.

  1. O idioma é POR TURNO, nunca um idioma da sessão: numa sessão com dois autores de línguas diferentes, os dois sobrevivem ao resumo.
  2. Citação e código são texto literal e não se traduzem — um identificador ou uma mensagem de erro traduzida no resumo deixa de ser encontrável.
  3. A frase vem ANTES dos turnos, para que o texto de um turno no fim do prompt não seja lido como continuação dela.
  4. Custo: o acréscimo é de 76 bytes — 19 tokens pela heurística do engine (bytes/4), no máximo 38 por uma régua pessimista de 1 token a cada 2 bytes —, dentro do teto de 50 tokens de entrada por chamada decidido pelo mantenedor para a orientação de idioma (AT-169, resposta 8). O teste reprova a frase que passar dele pelas duas réguas.
  5. Não é agent_instructions: é texto de código e de template versionado, fora do teto de instruction_patch (RN-007), porque o sumarizador não é agente de projeto e não tem instrução por projeto.

Decidido sem linha de base, declarado: o mantenedor decidiu mudar o prompt já, sem esperar a validação paga mostrar que o resumo em pt-BR desvia (AT-169, resposta 4). A consequência é que a validação da AT-167 mede o CONJUNTO (orientação + resumo novo), sem medir o resumo antigo isolado.

O que esta regra NÃO fecha, declarado:

  • Com graph_templates_enabled? ligada, vale a versão ATIVA no grafo. Uma instalação que semeou a versão "1" continua mandando o prompt antigo até rodar scripts/dev/seed-prompts.ts de novo — o hash do corpo mudou, então o seeder cria a versão nova. Com a flag desligada (o default) vale o inline, que já tem a frase. Semear de novo também tira do corpo a seção de documentação que fazia o sumarizador receber os turnos em dobro (AT-244, ver RN-413); o template está na versão "3".

  • O fallback de quando o sumarizador falha ("(N turnos anteriores omitidos)") não é resumo e não ganha a frase: não há texto de turno nele.

  • A frase é pt-BR. Ela pede o idioma de cada turno, não o do prompt; se um modelo a obedece em conversa de outra língua é o que a validação da AT-167 mede.

  • Código: apps/engine/lib/engine/harness/context_manager.ex:214 (@instrucao_de_idioma), :276 (prompt_inline), :233 (prompt, a escolha entre template e inline); prompts/context-manager-summarize.md

  • Teste: apps/engine/test/engine/harness/context_manager_test.exs:417 (caminho feliz: o prompt enviado ao sumarizador tem a frase, antes dos turnos), :433 (o teto de 50 tokens, pelas duas réguas), :443 (o template versionado carrega a MESMA frase), :453 (falha do sumarizador: o fallback não finge resumo nem ganha a frase)

  • Origem: AT-166, especificada na AT-081 e destravada pela AT-169 (decisões do mantenedor de 2026-09-28)

O idioma das respostas dos agentes (RN-618..620, RN-622..624, ADR 0177)​

O épico EP-028 nasceu de uma conversa em português respondida em espanhol. As decisões do mantenedor de 2026-09-28 (AT-168, AT-169) fixaram onde o idioma das RESPOSTAS mora — conta, sessão e projeto — e a precedência entre as fontes. Nenhuma destas regras toca o idioma da INTERFACE (RN-432).

RN-618 — O idioma das respostas é da PESSOA: escolha na conta, override por sessão, e a precedência que termina sempre num idioma​

  1. Lista aberta, forma canônica. O idioma das respostas é qualquer código BCP-47 cuja subtag de idioma o ICU da api reconheça, gravado canônico (pt-br vira pt-BR) — nunca o enum pt-BR/en da interface, que continua fechado. und, uso privado e texto livre são recusados com 400 que nomeia a regra.
  2. Conta nova nasce no AUTOMÁTICO. users.response_language é NULL ("automático") até a pessoa escolher; a api mostra e aceita automatico como o nome desse estado.
  3. Interface e respostas são independentes. PATCH /users/me/preferences aceita locale e responseLanguage como OPCIONAIS; mandar um não toca o outro, um corpo sem nenhum é 400, e um responseLanguage inválido recusa o corpo INTEIRO antes de gravar qualquer campo.
  4. O detectado só vale confirmado. O idioma detectado pelas mensagens da pessoa (a detecção é a RN-624) só entra em users.detected_language junto com detected_language_confirmed_at — um CHECK recusa um sem o outro. A detecção sozinha não troca nada.
  5. Override por sessão é só de quem o fixou. PUT projects/:projectId/sessions/:sessionId/response-language (developer, o papel de mandar mensagem a um agente — o override só muda as respostas às mensagens da própria pessoa) fixa um idioma naquela sessão para QUEM CHAMA; language: null (chave obrigatória) solta e volta a herdar da conta. O GET (viewer) devolve o efetivo de quem chama. A chave é o par sessão×usuário: outro participante da mesma sessão nunca é afetado, e não há rota para ler ou mexer no de outra pessoa.
  6. A precedência, resolvida por pessoa e nunca por sessão: override da sessão > escolha da conta > detectado confirmado > users.locale. Como users.locale é NOT NULL, a cadeia sempre termina num idioma, e a origem (sessao/conta/detectado/interface) viaja com ele. O pedido pontual dentro de uma mensagem ("traduza para o inglês") não é degrau: quem o atende é o modelo.

Quem consome: desde a RN-622 o efetivo chega ao modelo — a api chama ResolverIdiomaDaRespostaUseCase com o AUTOR de cada mensagem a um agente e a sessão, e o engine põe a orientação no fim de cada chamada de LLM daquele turno. A Conta deixou de dizer que o valor "ainda não chega".

  • Código: apps/api/src/domain/iam/idioma-de-resposta.ts:56 (normalizarIdiomaBcp47), :118 (resolverIdiomaDaResposta); apps/api/src/db/schema/iam.ts:104 (responseLanguage), :111 (detectedLanguage), :128 (users_idioma_detectado_so_confirmado); apps/api/src/db/schema/sessions.ts:120 (sessionLanguageOverrides); apps/api/src/application/use-cases/iam/resolver-idioma-da-resposta.use-case.ts:44 (ResolverIdiomaDaRespostaUseCase); apps/api/src/application/use-cases/iam/update-user-preferences.use-case.ts:25 (idiomaDaRespostaOuRecusa), :47 (UpdateUserPreferencesUseCase); apps/api/src/application/use-cases/iam/idioma-da-resposta-na-sessao.use-case.ts:19 (IdiomaDaRespostaNaSessaoUseCase); apps/web/src/routes/ResponseLanguageSection.tsx
  • Teste: apps/api/test/domain/iam/idioma-de-resposta.spec.ts:12 (a régua, e o que ela recusa — caso de falha), :40 (a precedência); apps/api/test/application/use-cases/iam/idioma-da-resposta.use-case.spec.ts:96 (conta nova no automático), :124 (grava canônico, não toca a interface), :158 (inválido não grava nada — caso de falha), :188 (o override não alcança o outro participante), :209 (404 de sessão de outro projeto); apps/api/test/infrastructure/persistence/session-language-override.repository.spec.ts:73 (o CHECK no banco), :93 (upsert pelo par); apps/web/src/routes/ResponseLanguageSection.test.tsx:76 (efetivo com origem, sem o aviso antigo de "ainda não chega"), :115 (código digitado só salva no botão)
  • Origem: AT-162, sobre as decisões das AT-168 (respostas 1, 2, 3, 4 e 6) e AT-169 (resposta 2); especificação da AT-079

RN-619 — O projeto tem um idioma, o de tudo que não tem autor humano, e ele nasce do idioma de quem cria​

  1. O que ele governa (decisões do mantenedor: AT-168 resposta 8, AT-169 resposta 1): o idioma dos artefatos COMPARTILHADOS (product brief, regras de negócio, ADRs) e dos turnos SEM autor humano (abertura por kickoff, dev agents, gates, mensagem de commit, corpo de PR). O que um agente responde a uma PESSOA continua sendo o idioma das respostas dela (RN-618).
  2. Sempre um idioma concreto. projects.language é NOT NULL, código BCP-47 canônico pela MESMA régua da RN-618 — mas sem "automático", que é resolver pelas mensagens de uma pessoa e não cabe no que não tem pessoa. automatico e código não reconhecido são 400, e o projeto não nasce (ou não muda).
  3. Projeto novo: o language pedido na criação, canonicalizado; sem pedido, o idioma EFETIVO das respostas de QUEM CRIA (ResolverIdiomaDaRespostaUseCase, sem sessão: escolha da conta > detectado confirmado > idioma da interface). A proposta da nota era o users.locale de quem cria; a cadeia inteira foi escolhida porque quem já disse em que idioma quer trabalhar não deveria ver o projeto nascer em outro — e, sem escolha nem detecção, ela É o users.locale.
  4. Projetos que já existiam: a migração 0062_idioma_do_projeto grava o idioma do TITULAR do workspace (workspaces.created_by, de quem é a credencial que os agentes gastam — RN-058/RN-616), pela mesma cadeia. Na migração ninguém tinha escolha nem detecção ainda, então o valor é o users.locale dele. O titular, e não "um owner" de workspace_members, porque ele é UM só e a migração não pode escolher entre dois.
  5. Quem troca: PATCH /projects/:projectId com language (maintainer, o mínimo da rota), e a seção "Idioma do projeto" em Configurações, com o controle inerte e o motivo em texto para quem não alcança (RN-102). O default pt-BR da coluna é só a rede de quem insere por fora do caso de uso (seed, script, fixture).

Quem consome, e o que sobra: desde a RN-622 o engine lê a coluna (Engine.Projects.Project, direto do Postgres) e a manda ao modelo em toda chamada de LLM de turno SEM autor humano, e desde a RN-623 também como idioma do artefato compartilhado que um agente grava DURANTE um turno com autor humano — a divergência que esta regra declarava fechou.

  • Código: apps/api/src/db/schema/iam.ts:262 (language); apps/api/src/db/migrations/0062_idioma_do_projeto.sql (o backfill); apps/api/src/application/use-cases/iam/idioma-do-projeto.ts:13 (idiomaDoProjetoOuRecusa); apps/api/src/application/use-cases/iam/create-project.use-case.ts:72 (language); apps/api/src/application/use-cases/iam/update-project.use-case.ts:18 (idiomaDoProjetoOuRecusa); apps/api/src/interfaces/http/iam/dto/iam.response.dto.ts:233 (language); apps/web/src/routes/settings/ProjectLanguageSection.tsx
  • Teste: apps/api/test/application/use-cases/iam/idioma-do-projeto.use-case.spec.ts:63 (nasce com o idioma de quem cria), :74 (a escolha da conta vence a interface), :100 (inválido e automatico — caso de falha, o projeto não nasce), :132 (PATCH inválido não muda nada), :152 (a migração grava o do titular); apps/web/src/routes/settings/idioma-do-projeto.test.tsx:84 (vigente e a declaração do que ainda não segue ele), :125 (abaixo de maintainer: o valor fica, o controle fica inerte)
  • Origem: AT-243, sobre as decisões das AT-168 (resposta 8) e AT-169 (resposta 1)

RN-620 — A barra da sessão mostra o idioma das respostas de QUEM VÊ, com a origem, e trocar ali é o override só daquela pessoa naquela sessão​

  1. Ao lado do seletor de modelo, em outro escopo. O modelo da barra é da SESSÃO e vale para todos; o idioma é da PESSOA (GET .../sessions/:sessionId/response-language, resolvido para quem chama, RN-618). Trocar ali é o override por sessão — decisão do mantenedor, AT-169 resposta 2 — e nunca mexe no de outro participante nem na Conta.
  2. Nunca o valor sem a origem (a régua da RN-470): "fixado por você nesta sessão", "escolhido por você na Conta", "detectado pelas suas mensagens e confirmado por você" e "o idioma da interface" são quatro textos, e nenhum colapsa em outro. Carregando e falhou também têm texto próprio — a falha não finge um idioma. O detectado nunca vira afirmação sobre a pessoa ("você é brasileiro"), só sobre as mensagens (AT-080).
  3. "Seguir a Conta" solta o override (language: null) e diz em QUE idioma isso dá — a mesma cadeia sem o degrau da sessão, derivada no cliente da cadeia que a rota já devolve, sem endpoint novo. "Outro código…" abre um campo que só fixa no botão (RN-469), e a recusa da api chega no toast com a frase dela. Um link leva à Conta, onde mora a escolha que vale em todas as sessões.
  4. Quem troca: o mínimo do PUT (developer), por roleAtLeast sobre o papel de WORKSPACE (a lacuna declarada das telas que não buscam project_members). Abaixo dele o valor e a origem continuam na barra, o seletor fica inerte e o motivo vem em texto.
  5. O que a barra NÃO afirma: nunca "os agentes respondem em X" como fato sobre a pessoa — só o efetivo e a origem. Desde a RN-622 esse efetivo chega ao modelo nas mensagens de quem vê, e o aviso "ainda não chega aos agentes" saiu da linha de origem. A linha trunca nos 60px da barra e leva o texto inteiro no title — o texto está na tela, o title só devolve o que as reticências cortaram.
  6. Na barra estreita, o idioma MUDA de lugar e não some (AT-317). A barra se arruma pela PRÓPRIA largura (modoDaBarra, medida por ResizeObserver; largura desconhecida é a barra completa): a partir de 1720px tudo fica em linha; abaixo disso modelo, idioma e orçamento viram UM controle cujo gatilho resume o modelo e o código do idioma (abaixo de 920px só o ícone, com nome acessível), e o painel dele traz o indicador inteiro com a origem QUEBRANDO em vez de cortar. A pergunta da detecção (RN-624) mora no painel, e enquanto existe o gatilho ganha uma marca e o nome acessível a anuncia. O título da sessão é o único item que encolhe (com piso e o nome inteiro no title), e os botões não quebram linha — "Encerrar" vira ícone com nome acessível só na barra mínima.
  • Código: apps/web/src/routes/SessionLanguageIndicator.tsx:79 (SessionLanguageIndicator), :35 (idiomaSemOverride), :170 (origemPorExtenso), :219 (podeTrocar); apps/web/src/routes/SessionTopbar.tsx:260 (SessionLanguageIndicator), :128 (modoDaBarra), :377 (AjustesAgrupados); apps/web/src/lib/modo-da-barra-da-sessao.ts:35 (modoDaBarra)
  • Teste: apps/web/src/routes/SessionTopbar.test.tsx:174 (a barra de 1440px não é completa), :191 (agrupa, e o painel traz idioma com origem), :236 (barra mínima com nome acessível), :256 (pergunta pendente marca o controle), :273 (leitura do idioma falhada — caso de falha); apps/web/src/routes/SessionLanguageIndicator.test.tsx:99 (efetivo com origem e o link, sem o aviso antigo), :112 (o detectado não vira "escolhido"), :126 (trocar fixa só nesta sessão), :151 ("Seguir a Conta" manda null), :180 (recusa da api — caso de falha), :202 (abaixo de developer), :213 (leitura falhada), :225 (a cadeia sem a sessão)
  • Origem: AT-165, sobre as decisões das AT-169 (resposta 2) e AT-168 (resposta 7)

RN-622 — O idioma chega ao modelo como a ÚLTIMA mensagem de cada chamada do turno, efêmera: o do AUTOR quando há um, o do PROJETO quando não há​

As RN-618..620 decidiram EM QUE idioma o agente responde e mostraram isso na tela; nada disso chegava ao modelo. Esta regra é o transporte (AT-164, sobre a especificação da AT-081 e o mecanismo verificado por provider na AT-161).

  1. Uma mensagem role: "system" no FIM da lista, a cada chamada de LLM do turno — inclusive as iterações de ferramenta —, e NUNCA no histórico: ela não entra em state.messages, então não se acumula de turno em turno, não é resumida pela compactação (RN-621) e some com o turno. No OpenAI-compatível ela vai no fim da conversa; no Anthropic é içada para o fim do system do topo (AT-161). Não é agent_instructions: é texto de código, fora do teto de instruction_patch (RN-007).
  2. Um lugar só. Quem acrescenta é a fachada EngineApiClient (llm_turn/5 e llm_turn_stream/6), por onde passa TODA chamada de LLM do engine — os sete conversacionais, o ToolLoop (dev agents e gates) e o Infra Lead. Nenhum servidor monta a orientação.
  3. Turno COM autor humano (a mensagem que alguém digitou a um agente): a api resolve o idioma DAQUELA pessoa naquela sessão (ResolverIdiomaDaRespostaUseCase, RN-618 — override da sessão

    conta > detectado confirmado > interface) e o manda no comando agent/message como idiomaDaResposta, campo OPCIONAL. O servidor do agente o põe no estado do TURNO durante o handle_call, e a Task do turno o herda do dicionário de processo (TurnoAssincrono); o GenServer não o guarda para o turno seguinte. Dois autores na mesma sessão recebem cada um o seu.

  4. Turno SEM autor humano (kickoff, dev agents, gates, commit, corpo de PR, a retomada do Dev Lead depois da aprovação): o idioma do PROJETO (RN-619), lido de projects.language a cada chamada (decisão do mantenedor, AT-169 resposta 1).
  5. O sumarizador da compactação (context-manager) fica fora: "responda em X" traduziria o resumo, e preservar o idioma de cada turno nele é a RN-621.
  6. Falha nunca derruba o turno. A resolução que falha na api vira log e a mensagem segue SEM idiomaDaResposta; turno com autor sem idioma segue SEM orientação — nunca o do projeto no lugar do da pessoa. A leitura do idioma do projeto que falha no engine vira warning e a chamada segue sem orientação. Valor que não tem forma de código BCP-47 também não vira orientação: nada que não seja um código é interpolado no prompt.
  7. Os textos são constantes de código, um por idioma: pt-BR ("Responda em português brasileiro (pt-BR), salvo pedido explícito do usuário por outro idioma nesta mensagem."), en, e um genérico em inglês que nomeia o código BCP-47 para qualquer outro idioma da lista aberta (AT-168 resposta 2). O texto não depende do modelo: trocar de modelo no meio da sessão não muda nada, e não há cascata por modelo nem caso especial de DeepSeek.
  8. Custo — teto decidido de 50 tokens de ENTRADA por chamada (AT-169 resposta 8). Medido com gpt-tokenizer, só o conteúdo, cl100k / o200k: pt-BR 29 / 23, en 18 / 18, genérico es-MX 26 / 26 e o PIOR caso (o genérico com o código mais longo que a forma aceita) 42 / 42; a moldura de mensagem soma ~4, então o pior caso fica em ~46. O teste trava o texto em 160 caracteres como vigia do teto.
  9. Medição: o chat.message gravado pela api carrega idiomaAlvo e origem quando a resolução deu certo — o evento é imutável e diz, para sempre, o que foi pedido ao modelo naquele turno (AT-082).

O que esta regra NÃO fecha, declarado:

  • Chat humano sem agente (SendChatMessageUseCase) fica fora, por decisão do mantenedor (AT-169 resposta 3).

  • Só a MENSAGEM a um agente carrega autor (e a resposta ao formulário de pergunta estruturada, que a reusa). Os outros comandos que uma pessoa dispara — confirmar prontidão ao Criativo, aceitar handoff — sobem turno sem autor e caem no idioma do PROJETO: o product brief do confirmReadiness sai no idioma do projeto.

  • Artefato de turno com autor: esta regra o declarava seguindo o idioma de quem escreveu; desde a RN-623 a mesma mensagem ganha a cláusula do artefato, no idioma do projeto.

  • Não medido: o tokenizador do DeepSeek e o da Anthropic; se a orientação basta contra o prompt de sistema em pt-BR é o que a validação paga da AT-167 mede. No Anthropic, a orientação içada ao topo muda o prefixo do cache a cada troca de autor ou de idioma — cache não é observável hoje.

  • Código: apps/engine/lib/engine/harness/idioma_da_resposta.ex:294 (anexar), :253 (com_idioma_do_autor), :161 (orientacao), :94 (@sem_orientacao), :301 (texto_do_turno), :326 (idioma_do_projeto); apps/engine/lib/engine/sessions/engine_api_client.ex:714 (IdiomaDaResposta), :822 (IdiomaDaResposta);

    apps/engine/lib/engine/sessions/engine_api_client.ex:714 (IdiomaDaResposta), :822 (IdiomaDaResposta); apps/engine/lib/engine/projects/project.ex:64 (idioma); apps/engine/lib/engine_web/controllers/agent_command_controller.ex:527 (idioma_da_resposta); apps/engine/lib/engine/agents/criativo_server.ex:136 (handle_call, e a MESMA cláusula nos outros seis); apps/engine/lib/engine/agents/turno_assincrono.ex:134 (copiar_dicionario); apps/api/src/application/use-cases/agents/send-agent-message.use-case.ts:168 (resolverIdioma), :74 (idiomaAlvo); apps/api/src/infrastructure/http-clients/api-to-engine-client.ts:215 (idiomaDaResposta)

  • Teste: apps/engine/test/engine/harness/idioma_da_resposta_test.exs:78 (sem autor: o idioma do projeto, no fim), :123 (com autor: o do autor vence o do projeto), :134 (autor sem idioma resolvido: sem orientação), :98 (a consulta que falha — caso de falha: lista intacta e log), :93 (o sumarizador fica fora), :67 (o vigia do teto de 50 tokens), :340 (ponta a ponta: o idioma do autor no fim de cada chamada e fora do histórico), :370 (a fachada também anexa no llm_turn/5); apps/engine/test/engine_web/controllers/agent_command_controller_test.exs:402 (o comando leva o idioma até o modelo), :414 (campo inválido: aceito, sem orientação); apps/api/test/application/use-cases/agents/send-agent-message.use-case.spec.ts:109 (resolve pelo autor e pela sessão — o override da sessão vence), :125 (a resolução que falha não derruba a mensagem — caso de falha); apps/api/test/infrastructure/http-clients/api-to-engine-client.spec.ts:528 (o campo viaja só quando resolvido)

  • Origem: AT-164, especificada na AT-081 e destravada pelas decisões das AT-168 e AT-169 (mantenedor, 2026-09-28); mecanismo verificado na AT-161

RN-623 — Num turno COM autor, a resposta é no idioma da pessoa e o ARTEFATO compartilhado é no idioma do PROJETO​

A RN-619 prometeu o idioma do projeto para o artefato compartilhado, e a RN-622 pôs o idioma do AUTOR em todo turno com autor — então o que um agente gravava DURANTE a conversa com uma pessoa saía no idioma dela. Decisão do mantenedor (AT-168 resposta 8, AT-245): o artefato segue o PROJETO; a resposta de chat continua no idioma do autor.

  1. O mecanismo é a mesma mensagem da RN-622, com uma segunda cláusula. Num turno com autor, quando a chamada leva ao menos uma ferramenta que grava artefato compartilhado e o idioma do projeto (projects.language) DIFERE do do autor, a orientação vira "Responda em português brasileiro (pt-BR), salvo pedido… Artefatos do projeto: em en." — no idioma do texto base: a cláusula do pt-BR é em português, a dos outros em inglês ("Write project artifacts in X."). Continua UMA mensagem system efêmera no fim, montada pela fachada; quem decide é o conjunto de ferramentas que a MESMA chamada leva ao modelo, que a fachada já tem.
  2. Por que a orientação, e não a descrição da ferramenta. A descrição viaja em TODA chamada, em cada ferramenta, com os idiomas iguais ou não; a cláusula existe só quando eles diferem, e mora no único lugar que já decide o idioma. Sem acréscimo nenhum quando os idiomas são iguais, quando a chamada não tem ferramenta de artefato, e em turno SEM autor (que já recebe o idioma do projeto).
  3. O que é artefato compartilhado — o que a ferramenta PERSISTE é lido por outras pessoas e pelos próximos agentes:
    • emit_artifact (business_rule, note, decision_record) — Criativo, PO, Arquiteto, Dev Lead, UX Designer, Staff;
    • create_epic, create_story, create_task — PO;
    • create_module_map, assign_story_modules, choose_project_image, create_c4_diagram, route_modules_to_infra, propose_adr (o ADR de open_adr_pr), emit_insight — Arquiteto;
    • propose_execution_plan, assess_implementability — Dev Lead;
    • propose_prototype — UX Designer; propose_rfc — Staff;
    • propose_infra_pr — Infra Lead. O product brief NÃO passa por aqui: ele é emitido pelo SERVIDOR no confirmReadiness, que é turno sem autor e já sai no idioma do projeto (como o handoff e o revise, que esta regra não muda).
  4. O que NÃO é: ask_structured_questions (é pergunta à PESSOA do turno), offer_handoff (não leva texto), as de leitura (listar_*, rag_*, read_file, search_workspace), validate_infra_file (só valida) e as propostas de subir container (pedem decisão, não gravam artefato). Os dev agents e os gates não têm turno com autor.
  5. Falha nunca derruba o turno. Ler o idioma do projeto que falha vira warning e segue SÓ a orientação do autor — nunca sem orientação, nunca a do projeto no lugar da da pessoa. Autor sem idioma resolvido segue sem orientação, como na RN-622.
  6. Custo — o teto de 50 tokens de entrada da AT-169 vale para a mensagem inteira. Medido com gpt-tokenizer, só o conteúdo, cl100k / o200k, sobre todos os pares de pt-BR, en, es, es-MX e zh-Hant-TW: pior caso pt-BR → zh-Hant-TW 42 / 36; en → pt-BR 26 / 26; genérico es-MX → zh-Hant-TW 36 / 36. A cláusula só entra com os DOIS códigos na forma canônica curta (idioma[-Escrita][-Região], ex.: pt-BR, zh-Hant-TW, es-419), porque é a FORMA que segura o teto: o pior dela (pt-BR → código de 12 caracteres) mede 44 / 37, ~48 com a moldura da mensagem. Código fora dessa forma fica só com a orientação do autor, e o log diz. O teste trava todo par em 165 caracteres como vigia.

O que esta regra NÃO fecha, declarado: a lista é por NOME de ferramenta — ferramenta nova que grave artefato entra nela no mesmo PR (o teste amarra cada nome a uma spec que existe, mas não descobre ferramenta nova). Se o modelo SEGUE as duas cláusulas na mesma mensagem é o que a validação paga da AT-167 mede; os tokenizadores do DeepSeek e da Anthropic seguem não medidos.

  • Código: apps/engine/lib/engine/harness/idioma_da_resposta.ex:174 (orientacao), :127 (@ferramentas_de_artefato), :118 (@forma_curta), :301 (texto_do_turno), :319 (grava_artefato?), :320 (idioma_do_projeto_para_o_artefato); apps/engine/lib/engine/sessions/engine_api_client.ex:714 (IdiomaDaResposta), :822 (IdiomaDaResposta);

    apps/engine/lib/engine/sessions/engine_api_client.ex:714 (IdiomaDaResposta), :822 (IdiomaDaResposta); apps/web/src/routes/settings/ProjectLanguageSection.tsx

  • Teste: apps/engine/test/engine/harness/idioma_da_resposta_test.exs:157 (idiomas diferentes: a orientação diz os dois), :168 (iguais: só um), :175 (código fora da forma curta: só o do autor, com log), :190 (o vigia do teto), :204 (cada nome da lista é uma spec que existe), :235 (autor pt-BR, projeto en, numa mensagem só), :259 (sem ferramenta de artefato: só o autor), :290 (idioma do projeto ilegível — caso de falha: só o do autor, com log), :316 (sem autor: nada muda), :340 (ponta a ponta pelo Criativo); apps/web/src/routes/settings/idioma-do-projeto.test.tsx:84

  • Origem: AT-245, sobre a decisão da AT-168 (resposta 8) e as do mantenedor de 2026-09-29

RN-667 — O formulário estruturado segue o idioma da resposta: a descrição da ferramenta não fixa idioma nenhum​

A validação paga da AT-167 achou, para autor com preferência en (C06/C12), a chamada ask_structured_questions com a prosa em inglês e as perguntas do formulário em português — no DeepSeek tratado, 8 das 15 respostas de C06+C12. A causa é do produto, e mora num lugar só: a descrição da ferramenta dizia que label era "o texto da pergunta, em pt-BR". Era uma SEGUNDA orientação de idioma, fixa, fora da fachada da RN-622, competindo com ela em toda chamada do Criativo e do PO (as duas usam a MESMA spec).

  1. O mecanismo é tirar a orientação concorrente, não acrescentar outra. A descrição passou a dizer "o texto da pergunta, no idioma da sua resposta (vale também para options)", e o exemplo, que segue em português, é marcado "os textos seguem o idioma da sua resposta". Ela não nomeia idioma NENHUM: QUAL é o idioma continua decidido só pela mensagem efêmera de Engine.Harness.IdiomaDaResposta, e a fachada EngineApiClient não muda.
  2. A orientação da RN-622/623 fica byte a byte. Zero token a mais nos 50 de teto. ask_structured_questions segue FORA de @ferramentas_de_artefato (é pergunta à pessoa do turno, RN-623 item 4), então autor en num projeto pt-BR recebe as perguntas em inglês — e é o que se quer.
  3. Custo, onde ele cai. A descrição viaja em toda chamada que leva a ferramenta, com qualquer autor: medido com gpt-tokenizer, só o texto que mudou, +23 tokens em cl100k e +19 em o200k (a linha de label 18/17 → 29/27; a do exemplo 8/6 → 20/15). Não é a orientação indo para a descrição — a frase aponta para ela sem dizer idioma, e o que foi embora era justamente a frase que dizia um.
  4. Guarda. Nenhuma descrição de ferramenta do harness (as 22 de Engine.Harness.Tools.*) pode citar pt-BR, (en), "in English", "em português" ou "em inglês" — a do emit_artifact, que exige as CHAVES do payload "em INGLÊS", fica de fora porque fala de contrato, não de texto para a pessoa.

O que esta regra NÃO fecha, declarado: a medição paga antes/depois não rodou (sem chave do OpenRouter na máquina da mudança) — o "antes" é o da AT-167 (DeepSeek tratado: 8 de 15 respostas de C06+C12 com o formulário em português), e o "depois" é pendência do dono: pnpm --filter @brabo/scripts idioma:validar --arquivo-de-chave ~/.config/brabo/openrouter-test.env --bracos tratamento --casos C06,C12 --rodadas 5 --teto-usd 0.5 --saida ~/.cache/brabo/validacao-idioma/at-282, seguido de idioma:validar --relatorio --saida na mesma pasta e da revisão do formulário (rótulo formulario do idioma:revisar). Teto sugerido US$ 0,50: o braço tratado inteiro custou US$ 0,21 (DeepSeek) e US$ 0,94 (Haiku) em 17 casos, e estes são 2. O script lê a descrição do arquivo do produto, então mede o texto novo sem cópia. Se o formulário continuar em português com a descrição neutra, o passo seguinte é uma cláusula condicional na orientação (só quando a chamada leva ask_structured_questions), e ela tem de caber no pior caso da RN-623 (44 / 37), que já está a ~48 com a moldura.

  • Código: apps/engine/lib/engine/harness/tools/ask_structured_questions.ex:193 (descricao), :44 (spec)
  • Teste: apps/engine/test/engine/harness/tools/ask_structured_questions_test.exs:199 (a descrição manda seguir o idioma da resposta), :207 (não nomeia idioma — caso de falha: a forma antiga "em pt-BR" é reprovada pela mesma régua), :214 (nenhuma das ferramentas do harness fixa idioma); scripts/idioma/validacao.spec.ts:60 (a medição lê a descrição nova)
  • Origem: AT-282, da revisão humana da AT-167 (PR #750, 29/09); entrada na rodada aprovada pelo mantenedor em 2026-09-30

RN-624 — A api detecta o idioma do autor pelas próprias mensagens e PERGUNTA; só o confirmado vira preferência​

A RN-618 deixou o degrau "detectado confirmado" pronto e vazio. Decisão do mantenedor (AT-168 respostas 4, 6 e 7): detectar, PERGUNTAR se o idioma está certo, e o confirmado vira o padrão — por pessoa, global, com a pergunta na tela onde o idioma já é mostrado e corrigido.

  1. A régua é UMA, e mora na api. A heurística da AT-080, medida pela AT-160 (marcadores contrastivos de pt/es/en, a limpeza de código, log, citação, URL e identificador), saiu de scripts/idioma/ para apps/api/src/domain/iam/heuristica-de-idioma.ts, e o instrumento a REEXPORTA de lá — o que o produto roda é o que o instrumento mede. A regra de O QUE é evidência (só ator user; no formulário, só as respostas, e o eco com os rótulos do agente pulado) também é uma só, evidenciasDoAutor, usada pela extração do corpus real e pela detecção. Local, sem LLM, sem dependência nova.
  2. A amostra é do event log, a histerese é refeita. A detecção lê as 11 evidências mais recentes da pessoa em TODAS as sessões (a janela de 10 mais a que a histerese refaz) e decide quando as DUAS últimas avaliações da amostra apontam o mesmo idioma (idiomaConcordante). Não há estado de detecção guardado: a avaliação da mensagem anterior é refeita com a janela que terminava nela. A leitura é servida por um índice PARCIAL (session_events_evidencia_de_idioma_idx, ator user e os dois tipos de evidência): medido com 200 mil eventos, 0,056 ms pelo índice contra 13,5 ms da varredura sem ele.
  3. Os limiares são PROVISÓRIOS. PARAMETROS_PROVISORIOS são os candidatos da AT-080 com UMA diferença, a evidência mínima de 10 palavras e não 20 — no corpus SINTÉTICO a 10 a cobertura dos decidíveis sobe de 42,2% para 58,9% com os mesmos três erros (todos em língua fora das três). Lista at080; limiar 0,8, margem 0,3, amostra 10 mensagens / 2.000 caracteres, histerese 2. O corpus sintético foi escrito pela mesma mão que escreveu a heurística: nenhum destes números está calibrado, e o dono os recalibra com o corpus real (pnpm --filter @brabo/scripts idioma:medir --real), mudando o objeto, que é o único lugar de onde a api os lê.
  4. Quando pergunta. GET .../sessions/:sessionId/response-language devolve detectionQuestion com o idioma a perguntar, ou null. Não pergunta quando: a detecção não concorda (texto curto, "ok", código, log, misto); o efetivo vem de escolha EXPLÍCITA — override da sessão ou Conta —, porque confirmar gravaria um degrau ABAIXO delas e a resposta não mudaria; o detectado já é o efetivo, comparado pela SUBTAG (pt não diverge de pt-BR — a heurística só sabe a língua); ou a pessoa já recusou esse idioma.
  5. A detecção nunca troca nada sozinha. Só POST /users/me/preferences/detected-language grava: confirm escreve o par detected_language + detected_language_confirmed_at (o CHECK da RN-618) e apaga a recusa antiga do mesmo idioma — e é 409 deteccao_mudou, sem gravar, quando as mensagens já não apontam o idioma (a coluna diz "detectado" e não pode guardar o que a heurística não detectou); decline grava a recusa em detected_language_declines (uma linha por pessoa e idioma, com a data), e ESSE idioma não é perguntado de novo. O que se guarda é o idioma e a decisão da pessoa — nunca país, nacionalidade ou proficiência.
  6. Onde a pergunta aparece. Num cartão pequeno ancorado logo abaixo do indicador de idioma da barra da sessão (RN-620): "Detectamos que você escreve em espanhol (es) — usar espanhol (es) nas respostas?", com "Usar", "Não" e "Agora não". Sem modal, sem cobrir o composer; "Agora não" só esconde nesta tela, sem gravar, e a pergunta volta na próxima leitura. Enviar mensagem (ou responder formulário) relê o indicador, porque a mensagem nova é a evidência que pode fazer a pergunta aparecer.
  7. Falha nunca bloqueia nada. A detecção roda na LEITURA do idioma, e não dentro de SendAgentMessageUseCase: a evidência já está no event log, a pergunta sobrevive a recarregar a página e aparece em qualquer sessão, e o envio da mensagem não passa por ela — não ganha latência e não tem como falhar por ela. Na leitura, qualquer falha (banco, heurística) vira warning e detectionQuestion: null; o idioma continua sendo mostrado.
  8. Custo: CPU medido no corpus sintético, nesta máquina — 11,6 µs para limpar e classificar uma mensagem, 110 µs por detecção (11 mensagens, duas avaliações), mais a leitura indexada do item 2. Zero token.

O que esta regra NÃO fecha, declarado: a heurística conhece três línguas, e a lista de idiomas é aberta — galego e francês podem virar es/pt (os erros do sintético), e sob esta regra o custo é uma pergunta errada, que a pessoa recusa. Uma mensagem em espanhol numa janela portuguesa deixa a amostra indeterminada enquanto estiver nela (sem pergunta, sem troca). E o arquivo da heurística não pode ter import: o instrumento o executa por type stripping, e scripts/idioma/ tem tsconfig próprio sem verbatimModuleSyntax para aceitar os export de um módulo CommonJS.

  • Código: apps/api/src/domain/iam/heuristica-de-idioma.ts:175 (PARAMETROS_PROVISORIOS), :457 (evidenciasDoAutor), :500 (mensagensNecessarias), :520 (idiomaConcordante); apps/api/src/domain/iam/deteccao-de-idioma.ts:43 (idiomaAPerguntar); apps/api/src/application/use-cases/iam/detectar-idioma-do-autor.use-case.ts:57 (DetectarIdiomaDoAutorUseCase), :66 (detectar), :88 (pergunta), :123 (responder); apps/api/src/infrastructure/persistence/drizzle/deteccao-de-idioma.repository.ts:27 (ultimasEvidencias), :75 (confirmar); apps/api/src/db/schema/iam.ts:144 (detectedLanguageDeclines); apps/api/src/db/schema/sessions.ts:164 (session_events_evidencia_de_idioma_idx); apps/api/src/interfaces/http/iam/session-response-language.controller.ts:66 (comPergunta); apps/api/src/interfaces/http/iam/user-preferences.controller.ts:94 (answerDetected); apps/web/src/routes/SessionLanguageIndicator.tsx; scripts/idioma/heuristica.ts (a reexportação)
  • Teste: apps/api/test/domain/iam/deteccao-de-idioma.spec.ts:29 (os provisórios), :42 (duas avaliações concordam), :48 (uma só não basta), :52 (indeterminado não aponta — caso de falha), :77 (o eco do formulário pulado), :105..:158 (quando pergunta); apps/api/test/application/use-cases/iam/detectar-idioma-do-autor.use-case.spec.ts:109 (pergunta sem gravar), :116 (confirmar), :131 (recusar não pergunta de novo), :142 (indeterminado não pergunta — caso de falha), :148 (Conta explícita), :159 (erro na detecção vira "sem pergunta" — caso de falha), :169 (409 deteccao_mudou); apps/api/test/infrastructure/persistence/deteccao-de-idioma.repository.spec.ts:87 (só o autor, todas as sessões), :166 (o índice parcial), :200 (o par e a recusa apagada); apps/web/src/routes/SessionLanguageIndicator.test.tsx:117 ("Usar"), :148 ("Não"), :162 ("Agora não" não grava), :172 (409 vira toast)
  • Origem: AT-163, sobre as decisões da AT-168 (respostas 4, 6 e 7), a especificação da AT-080 e a medição da AT-160

RN-625 — O laço pergunta ao Jev qual ferramenta, e o modelo do usuário vê só o menu que sobra; o Jev restringe e nunca decide​

Decisão do mantenedor (AT-236, 2026-09-28) e, sobre o critério, de 2026-09-29: antes de cada chamada de chat de um agente, a api pergunta ao Jev (typesafe/jev-1.13, Decisions API do OpenRouter) qual ferramenta cabe no passo, e o provider de chat recebe só o menu que a política P3 tira dessa resposta. O mecanismo está no ADR 0179; a decisão de adoção, com os números, também.

  1. Quando. Só com o provider do modelo do turno igual a openrouter, com duas ou mais ferramentas no pedido, com agente no turno (fora a Anamnese e o sumarizador da compactação), sem catalogoCompleto pedido pelo engine e com o workspace ligado (workspaces.tool_router_enabled, padrão ligado, PUT workspaces/:workspaceId/tool-router, owner). Qualquer condição falha: nenhuma chamada, nenhum evento. Com provider ≠ openrouter nem o workspace é lido. A chave é a do titular do workspace, a mesma do chat (RN-058).
  2. A política é a P3 e só restringe. Menu = {escolha do Jev, ferramenta anterior da MESMA execução}. responder_sem_ferramenta do Jev, ou nenhuma ferramenta anterior no catálogo do passo, deixa o catálogo INTEIRO. Não há tool_choice (o modelo ainda pode responder em texto) e não há limiar de confiança na v1: a confiança é gravada e é da AT-239 propor um. Critério de adoção: 80%, decisão do dono de 2026-09-29 — top-1 do Jev 73% (66–79), cobertura de menu da P3 91% (85,1–94), top-2 90%; cobertura é o TETO da acurácia ponta a ponta (62%–91% em replay), e a validação real é a AT-239.
  3. O state é o que o produto tem antes do passo, recortado das messages que o engine já manda, sem leitura de banco: agente = o agentId; contexto = o começo (1.500 caracteres) da mensagem system que o ToolLoop põe na frente (apps/engine/lib/engine/harness/tool_loop.ex:98, system_msg, montada por system_prompt/1 em :316); pedido = a última mensagem user — nos agentes de execução a mensagem inicial do laço (apps/engine/lib/engine/dev/dev_agent_server.ex:484, initial_message/2, ou a de correção em :523), nos conversacionais a fala da pessoa; e passos_recentes = as 6 últimas chamadas de ferramenta depois dessa mensagem, uma entrada por chamada, argumento e resultado cortados em 500 caracteres — o resultado vem da mensagem tool que o laço anexa (tool_loop.ex:293, concluir_despacho/5, com toolCallId). A execução corrente é "depois da última user" porque ctx.messages recomeça a cada ToolLoop.run. Teto de 8 mil tokens; estourou, queda estado_grande.
  4. O turno nunca falha por causa do Jev. Erro HTTP, timeout (2.000 ms, TOOL_ROUTER_TIMEOUT_MS), erro de rede, JSON inesperado, escolha fora das opções, estado grande e colisão com o nome reservado: o provider recebe o catálogo inteiro e o evento leva motivoDaQueda e origemDaQueda (infra, modelo ou codigo — RN-059), nunca calado.
  5. A política não muda. O Jev não aprova, não nega e não escolhe modelo. A ferramenta escolhida que exige aprovação continua exigindo; a chamada a uma ferramenta fora do cardápio é despachada como sempre e o evento só registra foraDoCardapio. O teto de iteração é o de antes (a chamada ao Jev não é uma iteração).
  6. Registro por passo. O engine grava tool_router.decided, na fachada EngineApiClient para todos os agentes, com menu antes e depois, escolha, confiança, segunda opção, aplicado, queda, latência, custo real e foraDoCardapio. A api grava UMA linha de token_usage do Jev: ator = o próprio agente (o gasto entra no orçamento de área, ADR 0110), modelName = typesafe/jev-1.13, modelId nulo, estimated = false, costMicros = o usage.cost da resposta, preço por milhão IMPLÍCITO (custo ÷ tokens) com price_implicit = true — mantém tokens × preço = custo da RN-044 sem inventar preço de catálogo. Sem usage.cost na resposta não há linha. O gate de orçamento não é reconsultado entre o Jev e o chat.
  7. Sair do beco. Menu restringido e o modelo responde sem chamar ferramenta (nem em texto recuperável), sem erro do provider: a fachada repete o passo UMA vez com catalogoCompleto: true, que pula o Jev. No stream, só se nada foi escrito para a pessoa. Pede mais ferramentas, nunca uma que o agente não tinha.

O que esta regra NÃO fecha, declarado: a tela (mostrar que o passo foi roteado, com a confiança, e a faixa "escolhendo ferramenta" pelo frame tool_routing_started) é de apps/web; o ganho é da AT-239; um timeout que o OpenRouter cobrou sem devolver usage.cost não entra em token_usage; o endpoint é ALPHA e o smoke manual (jev-tool-router.smoke.spec.ts, teto US$ 0,05) é quem acusa a mudança.

  • Código: apps/api/src/domain/llm/tool-router.ts:27 (MODELO_DO_JEV), :109 (montarPedidoAoJev), :166 (lerRespostaDoJev), :216 (recortarEstado), :268 (menuP3); apps/api/src/application/use-cases/llm/decidir-ferramenta-do-passo.use-case.ts:118 (preparar), :143 (executar), :305 (registrarGasto); apps/api/src/application/use-cases/llm/run-llm-turn.use-case.ts:170 (decisaoDoJev); apps/api/src/application/use-cases/llm/stream-llm-turn.use-case.ts:187 (preparar); apps/api/src/infrastructure/llm/jev-tool-router.ts:32 (decidir); apps/api/src/db/schema/iam.ts:169 (toolRouterEnabled); apps/api/src/db/schema/llm.ts:375 (priceImplicit); apps/engine/lib/engine/harness/roteamento_de_ferramenta.ex:60 (registrar), :112 (custo_micros), :123 (repetir_com_catalogo_inteiro?), :146 (mesclar_repeticao); apps/engine/lib/engine/sessions/engine_api_client.ex:730 (llm_turn), :814 (llm_turn_stream);

    (mesclar_repeticao); apps/engine/lib/engine/sessions/engine_api_client.ex:730 (llm_turn), :814 (llm_turn_stream); apps/engine/lib/engine/harness/tool_loop.ex:132 (o custo do Jev no orçamento local)

  • Teste: apps/api/test/domain/llm/tool-router.spec.ts:25 (questions é objeto), :86 (forma inesperada vira queda — caso de falha), :138 (o state), :210 (a P3); apps/api/test/infrastructure/llm/jev-tool-router.contract.spec.ts:60 (a resposta gravada), :121 (erro HTTP), :130 (timeout), :142 (rede), :149 e :154 (JSON inesperado), :163 (escolha fora das opções); apps/api/test/application/use-cases/llm/decidir-ferramenta-do-passo.use-case.spec.ts:186 (provider ≠ openrouter não chama), :222 (workspace desligado), :255 (estado grande), :277 (a P3), :318 (sem ferramenta = catálogo inteiro), :331 (sem anterior = inteiro), :352 (a política não muda), :369 (as quedas), :456 (o metering, no orçamento de área); apps/engine/test/engine/harness/roteamento_de_ferramenta_test.exs:67 (o evento), :103 (a queda no evento), :180 (sair do beco), :288 (o custo no laço), :299 (a aprovação continua exigida), :325 (o roteamento não conhece a política)

  • Origem: AT-238, sobre a especificação da AT-235, as dezesseis decisões da AT-236, a medição da AT-237/AT-280 e a decisão do dono de 2026-09-29 (critério de 80%)

Decidir no chat, onde o dono está (RN-626)​

RN-626 — O chat da sessão decide o que os agentes propuseram noutra sessão, mostra o turno que estava em curso e propõe o merge — sem mudar teto nenhum​

O uso real de 2026-09-29 mediu o custo de o dono conversar numa sessão e os dev agents proporem noutra: as aprovações deles nunca apareciam onde ele estava, 45 de 173 cliques em "Sempre permitir" voltaram 409 (o card continuava clicável depois de a ação já ter saído de pending), e reabrir a sessão com um turno em curso devolvia o composer livre e o 409. As quatro correções são de TELA — a api, o engine e todo teto ficam como estão.

  1. O card fica inerte e diz a frase da api (AT-256). ApprovalCard segura os botões enquanto a decisão está em voo (o duplo clique era um 409 certo) e, quando a chamada devolve promessa que rejeita, mostra a frase que a api mandou NO card — nunca um toast genérico. Um 409 (a ação já não está pending) deixa os botões inertes com o motivo em texto até a lista trocar o card pela linha de desfecho. Quem chama devolve a promessa e refaz a lista MESMO na recusa (finally): é UMA invalidação por clique, sem poll novo, e a invalidação usa o canal e o orçamento da RN-579.
  2. Reabrir a sessão retoma o turno do log (AT-268). O estado do turno da tela é useState local e se perdia ao sair. Na primeira leitura dos eventos de uma sessão active, turnoEmCursoNoLog olha o agent.status persistido mais recente de cada agente (a mesma leitura de turnoTerminouNoLog, da RN-578); o que estiver working reabre a faixa e trava o composer pelas mesmas entradas de um turno aceito (iniciarTurnoDoAgente + acompanharTurnoPeloLog), e a leitura da cauda do log o fecha. Roda UMA vez por sessão montada (um efeito recorrente rearmaria a faixa com o working antigo ainda no cache). O working que o reinício do engine deixou é fechado por evento novo no boot (RN-586), então o que sobra é turno vivo. Não enfileira mensagem: mensagem com turno em curso segue recusada com 409 nomeado — a fila é decisão pendente do dono (AT-267).
  3. As pendências de OUTRAS sessões do projeto aparecem no chat (AT-265). PendenciasDeOutrasSessoes lê GET /projects/:id/actions?status=pending (o mesmo endpoint da aba PRs e do painel "precisa de você") e desenha as que NÃO nasceram nesta sessão — as desta o fio já decide. É ATALHO e nunca substituto (RN-467): o MESMO ApprovalCard, os mesmos endpoints, com o sessionId que a própria ação carrega; onActivateAutoMode fica de fora (é política, não decisão). As duas filas — aprovações e merges de PR — têm título e contagem PRÓPRIOS e nunca são somadas, e cada linha diz a sessão de origem. O desenho tem teto de 20 cards, e o excedente é dito ("Mostrando N de M"): recorte declarado, como na RN-180. O poll é o da sessão (5 s com o canal caído, o fallback de 15 s com ele vivo — intervaloDaSessao), porque o canal da sessão não avisa escrita de outra.
  4. "Mergear" no card da PR aberta (AT-266). Sob o card de um pr_open executado, e só quando o evento action.pr_open traz o id da PR e não há git_merge vivo (pendente, aprovado ou auto-aprovado) para ela, o botão PROPÕE o git_merge pelo mesmo endpoint da aba PRs (ator user, nesta sessão). A proposta aparece no fio como o card de sempre, com Aprovar/Negar: a confirmação é a mesma e é do humano — o merge em branch protegida continua require_approval incondicional (RN-418), sem auto-aprovar, sem "sempre permitir", sem o modo automático. O dedupe e as recusas da api (PR já mergeada, merge já proposto — RN-663) chegam ao dono como a frase da api; gate pendente não é recusa, é aviso em texto ao lado do botão.
  5. O papel é o do ENDPOINT, e a lacuna é declarada. Decidir ação e propor ação pedem developer (actions.controller.ts); a tela usa roleAtLeast sobre o papel de WORKSPACE, porque a Sessão não busca project_members — a mesma lacuna única da RN-471, aqui declarada e não fechada. Quem não alcança VÊ a pendência e o botão, com os controles inertes e o motivo em texto (bloqueio do ApprovalCard): o que se tira é o controle, nunca a informação.

O que esta regra NÃO fecha, declarado: (a) o "Mergear" não propõe E aprova num clique só — são dois cliques, a proposta e a confirmação, e é de propósito; (b) a retomada do turno lê a janela dos eventos já carregada, e um agent.status fora dela não é visto (não saber não é "está trabalhando"); (c) a lista de pendências de outras sessões tem o teto de 20 cards, e o resto continua na aba Aprovações; (d) as pendências de arquitetura e as hipóteses do Psicólogo continuam SEM decisão inline (não há ApprovalCard para elas).

  • Código: apps/web/src/components/ApprovalCard.tsx:232 (decidir, a decisão em voo e a recusa no card); apps/web/src/lib/session-turno.ts:59 (turnoEmCursoNoLog), :532 (useRetomarTurnoDoLog); apps/web/src/lib/pendencias-do-projeto.ts:24 (usePendenciasDoProjeto), :56 (separarPendenciasDeOutrasSessoes); apps/web/src/components/PendenciasDeOutrasSessoes.tsx:65 (PendenciasDeOutrasSessoes); apps/web/src/routes/MergearNoChat.tsx:28 (prAbertaDaAcao), :56 (jaHaMergeDaPr), :89 (MergearNoChat); apps/web/src/routes/SessionPage.tsx:150 (podeDecidir), :272 (useRetomarTurnoDoLog), :767 (PendenciasDeOutrasSessoes)
  • Teste: apps/web/src/components/ApprovalCard.decisao-em-voo.test.tsx:37 (duplo clique), :52 (409 no card e botões inertes — caso de falha), :67 (erro que não é 409 devolve os botões); apps/web/src/lib/turno-em-curso-no-log.test.ts:18 (working), :22 (idle e awaiting_approval fecham), :38 (sem status na janela — caso de falha); apps/web/src/routes/SessionPage.retomar-turno.test.tsx:159 (a tela retoma e o log destrava), :182 (idle não inventa turno — caso de falha); apps/web/src/components/PendenciasDeOutrasSessoes.test.tsx:71 (filas separadas, sessionId da ação), :99 (papel abaixo de developer — caso de falha), :109 (409 no card), :126 (recorte); apps/web/src/routes/MergearNoChat.test.tsx:62 (PR do evento), :75 (dedupe), :100 (propõe como usuário, sem aprovar), :121 (recusa da api — caso de falha), :134 (papel — caso de falha)
  • Origem: AT-256, AT-268, AT-265 e AT-266, da análise do uso real de 2026-09-29 (itens 4, 5 e 10a), rodada 27, lane web-sessao

RN-629 — O QA Lead trata o subagente que SUSPENDE de novo na retomada do mesmo jeito que na primeira vez, e resultado que não conhece vira bloqueio nomeado​

O qa-automacao roda vários comandos e cada um pode pedir aprovação humana. O QaLeadServer já parava a área quando run/5 devolvia {:awaiting, _} (achado AB, ADR 0052), mas a RETOMADA (retomar/3, quando a decisão chega) devolvia o resultado de um laço que podia suspender OUTRA vez — e esse segundo {:awaiting, _} entrava na lista de resultados colhidos como se fosse parecer. Ao consolidar, registrar_resultado/5 não tinha cláusula para ele e o GenServer caía (FunctionClauseError, medido em 29/09/2026: 06:55:01 e 07:00:10), deixando as três tasks entregues em awaiting_qa para sempre, sem veredito e sem desfecho nomeado.

  1. Um só lugar decide o que fazer com o resultado de um subagente (tratar_resultado/6), venha ele de run/5 ou de retomar/3. {:awaiting, _} guarda o estado em voo (chaveado pela nova ação), mantém a linha de gate_states em in_progress com o subagente e PARA a área: sem veredito, sem bloquear a task, sem registrar a delegação. Quem retoma é o mesmo {:action_settled, _} do Engine.Dev.Wake (ADR 0086), quantas vezes forem necessárias.
  2. Resultado que o lead não conhece nunca derruba nem some (RN-059): vira {:blocked, …} com origem codigo (falta uma cláusula aqui, não é infra, modelo nem política), que registrar_resultado/5 grava como delegação failed e QaLead.consolidar/1 transforma em bloqueio da task com a origem.

Declarado, não resolvido: o restart do engine com o lead suspenso perde a inscrição no Wake (lacuna aceita do ADR 0086); o GateRescuer reinicia a área inteira (ADR 0067), não retoma o ctx. Esta regra não muda o ADR 0090 (o momento do QA-estratégia) nem o teto de iterações do subagente.

  • Código: apps/engine/lib/engine/gates/qa_lead_server.ex:127 (handle_info), :237 (tratar_resultado), :230 (resultado desconhecido)
  • Testes: apps/engine/test/engine/gates/qa_lead_server_test.exs:325 (segunda suspensão na retomada: fica suspenso, não decide nada, e a segunda decisão conclui a área); :278 (uma suspensão, o caminho feliz)
  • Origem: AT-248, sobre o achado E2 da análise de uso real de 2026-09-29

RN-630 — Quem está ativo na sessão se lê da sessão INTEIRA, não dos últimos 200 eventos​

"Quem está ativo" e "quem recebe o composer" saíam do agent.activated visto na janela de 200 eventos (latest: true). Numa sessão longa as ativações do Arquiteto e da Infra saíam da janela; as ofertas de handoff endereçadas a eles voltavam a parecer aceitáveis e o "oi" ia para o último agente que ainda aparecia. É a classe de defeito da RN-180 (teto silencioso faz a tela afirmar sobre o que não leu), já fechada para a presença de QA/SecOps na RN-568.

A regra: o resumo do projeto (RN-090) devolve roster.activatedAgents — os agentes com ao menos um agent.activated na sessão mais recente, um por agente, o de ativação mais recente (por seq) primeiro. Quatro decisões:

  1. A fonte continua sendo o event log. Nenhuma coluna derivada, nenhum índice e nenhuma migration: o campo entra na MESMA varredura por sessão que já calcula executionActivated/gatesEverOpened (array_agg ... filter), então o número de idas ao banco do resumo não muda (o spec de contagem constante segue valendo). A migration 0066 reservada não foi usada.
  2. Sem HTTP novo por poll. O campo viaja no resumo que as telas já leem (RN-579).
  3. O cliente SOMA à janela e só confia com o resumo da MESMA sessão (latestSessionId === sessionId), como na RN-568: ativação é monótona (agente ativado não se desativa), então a janela só dá falso negativo. Resumo de outra sessão não vale; sem resumo, a janela decide sozinha.
  4. A api diz o que aconteceu, não quem recebe. O destinatário explícito e visível no chat (AT-251) e o aceite por handoffId (AT-253) consomem este campo na web e NÃO fazem parte desta regra.

Lacuna declarada: esta rodada só entrega o CONTRATO da api. apps/web (session-readiness.ts, session-handoffs.ts) segue lendo a janela até a AT-251/AT-253 passarem a consumir roster.activatedAgents.

Fechada na RN-631 (AT-251): o destinatário do composer e as ofertas de handoff da tela de Sessão SOMAM roster.activatedAgents à janela, com a guarda da mesma sessão. criativoActive/arquitetoActive (session-readiness.ts) seguem lendo só a janela.

  • Onde vive: apps/api/src/infrastructure/persistence/drizzle/projects-summary.repository.ts:212 (activatedAgents); apps/api/src/application/ports/projects-summary-repository.port.ts:32 (activatedAgents)
  • Testes: apps/api/test/infrastructure/persistence/drizzle/projects-summary.repository.spec.ts ("activatedAgents cobre ativações anteriores à janela de 200 eventos" e "activatedAgents é da sessão MAIS RECENTE: ativação de sessão antiga não vale")
  • Origem: AT-252

RN-627 — O container do projeto roda com o dono da pasta, medido pela api​

Sob --cap-drop ALL o root do container não tem CAP_DAC_OVERRIDE e não escreve na pasta do projeto, que é do uid do operador: npm install dava EACCES para todo dev agent (AT-247, ADR 0180).

  1. A api mede, o broker compõe. container-spec devolve usuarioDaPasta (stat da pasta dos modos container/mounted; null se a pasta não é alcançada, se o dono é root ou se o projeto é runner). O broker o copia para spec.usuario e o adaptador emite --user uid:gid e --env HOME=/tmp.
  2. Nunca vem de quem pede. start não tem corpo e o corpo de exec ignora campo extra; exec herda o usuário do container.
  3. O broker revalida. Inteiros em 1..2^31-1; 0 é recusado nomeando usuario.uid/usuario.gid (root é o ausente, "como sempre").
  4. Não afrouxa contenção: --cap-drop ALL, um bind, rede de dois valores e cinco operações continuam. Container já criado só muda ao ser recriado.
  • Onde: packages/docker-port/src/docker-cli.ts:785 (argsDeCriacao), packages/docker-port/src/spec-de-container.ts:136 (usuarioValidado), apps/broker/src/operacoes.ts:427 (especificacaoDoProjeto), apps/api/src/application/use-cases/containers/obter-spec-de-container.use-case.ts:124 (ObterSpecDeContainerUseCase)
  • Teste: packages/docker-port/src/docker-cli.spec.ts (o run leva --user e continua sem campo livre), packages/docker-port/src/spec-de-container.spec.ts (usuario), apps/broker/src/operacoes.spec.ts ("o usuário do container"), apps/api/test/application/use-cases/containers/spec-e-observacao-de-container.use-case.spec.ts
  • Origem: AT-247

RN-628 — O merge executado fecha a tarefa da PR: done, uma vez só​

O gate de QA/SecOps termina em awaiting_user e nada mais mexia no status da tarefa: no uso real de 29/09 o pr-6 foi mergeado três vezes e as tarefas seguiram em in_review (AT-275).

  1. Depois do merge, nunca no lugar dele. git_merge continua sendo require_approval incondicional (RN-418); a regra roda DENTRO de ExecuteGitActionUseCase, na transação que grava o resultado do merge, e só quando o provider devolveu state: 'merged' (PR aberta, ou merge que falhou, não toca a tarefa).
  2. A tarefa é achada pela PR que a abriu. Toda pr_open do projeto cujo pullRequestId é o da PR mergeada e que carrega storyTaskId no payload. PR sem tarefa (infra, ADR) não muda nada.
  3. Idempotente. TaskRepository.markDoneIfNotDone é um UPDATE … WHERE status <> 'done': merge repetido da mesma PR devolve null, não move de novo e NÃO grava o evento. Quando move, grava UM evento imutável backlog.task_status_changed (cause: 'pr_merged', pullRequestId, actionId), append-only, sem UPDATE em tabela de eventos. Sem migration.
  4. Fronteira declarada (AT-249). Esta regra NÃO recusa merge de PR já mergeada nem consulta gate_status. A recusa da PR já mergeada (e da proposta repetida) veio depois, na RN-663, antes de o merge chegar aqui; o gate pendente, por decisão do dono, é só aviso na tela.
  • Onde: apps/api/src/application/use-cases/actions/execute-git-action.use-case.ts:295 (settleMerge), apps/api/src/infrastructure/persistence/drizzle/backlog.repository.ts:480 (markDoneIfNotDone)
  • Teste: apps/api/test/application/use-cases/actions/execute-git-action.use-case.spec.ts ("git_merge marca a tarefa como done": feliz, repetido, PR aberta/merge falho, PR sem tarefa)
  • Origem: AT-275

RN-640 — A paleta neutra fecha a dívida de contraste, e cor da UI só vem de token​

O tema escuro deixa de ser azul-petróleo e vira preto neutro com o acento terracota suave; o claro vira o neutro da mesma família (ADR 0181). O mecanismo da RN-184 não muda — os pares são medidos nos dois temas, com o mesmo piso —; o que muda é o que se cobra:

  1. A dívida do escuro virou piso. Os cinco pares que a RN-184 deixava travados por número (3,89 / 3,10 / 3,88 / 3,88 / 4,41) passam 4,5:1 nos dois temas, e o teste reprova quem os devolver para baixo — não "registra" mais. O valor final saiu da MEDIÇÃO: o --text-muted do rascunho (#71717a) repetia a dívida e foi recusado (#86868f no escuro, #696972 no claro), e o muted continua ABAIXO do --text-secondary nas três superfícies, o que o teste também cobra.
  2. O texto sobre fundo sólido de marca/estado passa AA. --on-accent do escuro é o preto neutro (5,61:1 sobre o acento, 7,25:1 no hover, 7,65:1 sobre --success, 5,24:1 sobre --danger); a "exceção conhecida" do botão primário deixa de existir, e o matiz do acento continua terracota (10°–30°) nos dois temas.
  3. Cor da UI só vem de token. Quem pinta por biblioteca de runtime (Mermaid, xterm, minimapa) lê o token do tema ativo e, sem ele, cai no padrão de TOKENS_PADRAO — uma cópia do :root que o teste confere contra design/tokens.css. Cor de agente é sempre var(--token); os três agentes sem semântico têm --agent-* por tema, medidos a 3:1. E nenhum módulo CSS usa var(--x) que ninguém declara.
  • Onde: design/tokens.css:118, design/tokens.css:126, apps/web/src/lib/tokens-padrao.ts:50 (lerTokenDoTema), apps/web/src/lib/agents.ts:90
  • Teste: apps/web/src/lib/contraste.test.ts ("a antiga dívida de contraste agora é piso"), apps/web/src/design-contraste.test.ts, apps/web/test/design-contraste.test.ts ("o botão primário — a exceção que deixou de existir"), apps/web/src/lib/tokens-padrao.test.ts, apps/web/src/lib/agents.test.ts ("cor de agente vem do design system"), apps/web/src/design-tokens-existentes.test.ts
  • ADR: 0181
  • Origem: AT-283, AT-284

RN-642 — "Sempre permitir" aprova e grava o padrão na MESMA transação; clique em ação já aprovada é sucesso nomeado​

No uso real de 29/09, 45 de 173 cliques em "Sempre permitir" devolveram 409 (AT-310): o padrão era gravado ANTES de aprovar, e uma ação que já tinha saído de pending (clique duplo, a mesma pendência em dois painéis, outra aba) lançava 409 com o padrão JÁ gravado e SEM o evento permission.granted.

  1. Mesma transação, depois da transição. ApproveActionUseCase aceita um aoAprovar que roda DENTRO da transação da decisão, depois de assertTransition passar e antes da execução; é por ele que o padrão (permissions.json/allow ou agent_autonomy, RN-509) e o permission.granted são gravados. "Aprovar antes, gravar depois" em transações separadas foi recusado: trocaria o defeito por ação aprovada (e até executada) com o padrão falhando depois. agent_autonomy e o evento são atômicos com a decisão; o permissions.json é arquivo e não entra em transação — ele só é escrito depois de a transição ser válida, e falhar ao escrevê-lo desfaz a aprovação. Sobra, declarada, a janela de o COMMIT falhar depois do arquivo escrito.
  2. O evento acompanha a gravação, e só ela. Padrão que já existe não é regravado nem narrado de novo (padraoGravado: false).
  3. Ação já aprovada é sucesso nomeado. Se a transição é recusada porque a ação já está approved/auto_approved/executed/failed, a resposta é 201 com desfecho: 'ja_aprovada' (o caminho normal devolve 'aprovada'), sem executar de novo, gravando o padrão só se ele faltar — com o evento. Transição inválida vinda da EXECUÇÃO, depois de a decisão ter sido confirmada, NÃO vira ja_aprovada: propaga como sempre.
  4. Ação RECUSADA continua 409, nomeado (reason: 'acao_ja_recusada'), sem gravar padrão nem evento: liberar para sempre o que alguém acabou de recusar não é idempotência.
  5. Tetos intactos. git push/PR/deploy, sudo/doas (motivoDeRecusaSempreAprovar, RN-418) e container_remove continuam recusados com 400 ANTES de qualquer leitura de estado — inclusive com a ação já aprovada. O padrão continua o EXATO da ação (a generalização por verbo é a AT-257, fora daqui).
  6. Os TIPOS do teto também (AT-320). Medido na auditoria de 30/09: a api só recusava o git push DIGITADO no terminal; o git_push TIPADO passava e gravava GitPush() em allow (ou autonomia do módulo), e o ApprovalCard oferecia o botão. TIPOS_SEM_SEMPRE_PERMITIR é a lista ÚNICA — git_push, pr_open, git_merge (inteiro: o padrão GitMerge() valeria para todo destino), container_remove, instruction_patch, parallelize, raise_max_parallel — e motivoDeRecusaDoSempreAprovar julga terminal pelo comando e o resto pelo tipo. A recusa é 400 com reason: 'teto_do_sempre_permitir' e actionType. A web esconde o botão pela cópia em apps/web/src/lib/sempre-permitir.ts, conferida contra a da api por teste; comando de terminal segue com o botão (repetir o parser na tela seria segunda régua) e recebe a recusa da api. Quando nasceu, NÃO mudava decide() nem a semeadura; desde a RN-689 mudam os dois — git_push/pr_open estão no teto de decide() e a ativação semeia só git_commit —, e o padrão que o clique gravaria para eles seria inerte.
  • Onde: apps/api/src/application/use-cases/actions/approve-always-action.use-case.ts:109 (execute), :190 (cliqueSobreAcaoJaDecidida), :240 (gravarPadraoSeFaltar), apps/api/src/application/use-cases/actions/approve-action.use-case.ts:279 (approve), apps/api/src/domain/actions/sempre-permitir.ts:30 (TIPOS_SEM_SEMPRE_PERMITIR), :65 (motivoDeRecusaDoSempreAprovar), apps/web/src/lib/sempre-permitir.ts:15 (TIPOS_SEM_SEMPRE_PERMITIR)
  • Teste: apps/api/test/application/use-cases/actions/approve-always-action.use-case.spec.ts ("ordem e idempotência (RN-642)": clique duplo, cliques concorrentes, ação aprovada por outro caminho, recusada → 409 nomeado sem padrão, padrão que falha desfaz a aprovação, tetos com a ação já aprovada; "tipos do teto (AT-320)"), apps/api/test/domain/actions/sempre-permitir.spec.ts, apps/web/src/lib/sempre-permitir.test.ts (a cópia do web igual à da api), apps/web/src/components/ApprovalCard.test.tsx (git tipado sem o botão)
  • Origem: AT-310, AT-320

RN-631 — O destinatário do chat é escolhido e visível, e a oferta de handoff é casada pelo handoffId​

O destinatário da mensagem do composer era DERIVADO: "o agent.activated mais recente dentro da janela de 200 eventos" (achado 9-fix). Era um destinatário padrão do lado da TELA — a classe de defeito que a RN-584 fechou no engine — e era INVISÍVEL: o composer não dizia a quem a mensagem ia, e aceitar um handoff nesta tela não mudava nada além do log. No uso real de 29/09 a sessão teve handoff.accepted para a Infra e depois para o Arquiteto, e o "oi" do usuário foi respondido pelo Arquiteto sem que a tela avisasse. E o card de aceite casava a oferta com o evento pelo par ATOR/toAgent (e.actor.id === offeredHandoff.fromAgent), embora o handoff.offered carregue o handoffId desde sempre (AT-251).

A regra:

  1. As OPÇÕES são os agentes que conversam (AGENTES_DE_CHAT) e que já estão na sessão: agent.activated na janela, roster.activatedAgents do resumo do projeto (RN-630, sessão INTEIRA, só com o resumo da MESMA sessão, no molde da RN-568) ou handoff ACEITO (a lista de handoffs não tem janela). Os dois últimos termos são a RN-180: o agente não some do seletor porque a ativação saiu dos 200 eventos. Na sessão criativa o Criativo é opção antes de ativado, porque a primeira mensagem o ativa (achado 3).

  2. O destinatário é o que a pessoa ESCOLHEU — no seletor "Para" do composer, ou aceitando um handoff NESTA tela (o gesto de chamar aquele agente; aceite que falha não troca nada). Nada do log escolhe por ela: um aceite feito em outra aba ou por outro caminho só acrescenta uma opção.

  3. Opção ÚNICA é o destinatário, e o composer a nomeia antes do envio. Não é um padrão no sentido da RN-584 — não há outro agente a quem a mensagem pudesse ir.

  4. Duas ou mais opções e nenhuma escolha válida: sem destinatário. O "Enviar" (e o Enter) fica travado e a tela diz em TEXTO que é preciso escolher — nunca "a mais recente". Zero opções é o chat da sessão sem agente (SSE), e a tela também o diz. Revisado pela RN-682: zero opções também trava o envio — a consultiva sem agente pede um agente, e o SSE ao modelo cru deixou de ser chamado pela tela.

  5. A escolha é lembrada por sessão no localStorage — conforto de quem vê (sem armazenamento a tela só volta a pedir a escolha), nunca estado do produto.

  6. "Parar" para o agente DO TURNO (turnoAgentRef, streamingAgent, statusAgent), e só na falta dele o destinatário: trocar o seletor no meio do turno de outro agente não pode parar o errado. O modelo exibido na topbar segue o destinatário.

  7. A oferta é casada pelo handoffId do payload, nunca pelo ator: cada handoff.offered é o card da oferta dele, sem par para confundir nem "mais recente" para escolher. As ofertas a agente já ativado (janela OU roster.activatedAgents) não voltam a ser aceitáveis.

  8. Toda oferta pendente é acionável, e nenhuma esconde outra (AT-253). O card elegia UMA oferta (handoffs.find, a pendente mais antiga), e o handoff MANUAL (ADR 0109, RN-441) — gravado com a PESSOA como ator, e por isso nunca casado pelo ator — ficava sem "Aceitar" e escondia as ofertas seguintes. Agora cada oferta pendente a um agente que conversa (fora a da Infra, que tem card próprio, RN-499) tem o botão no evento dela; duas pendentes ao MESMO agente dão um botão só, o da mais recente, e aceitá-la tira a outra pelo activeFor. A pílula do handoff manual diz "Handoff manual" em vez do id da pessoa.

  9. "Já está na sessão" se lê de fontes SEM janela escopadas à sessão PEDIDA (revisão do PR #759). O roster.activatedAgents do resumo é da sessão MAIS RECENTE do projeto, e ativar a execução CRIA uma sessão nova que passa a ser ela: a sessão do chat perdia a fonte inteira e voltava a depender da janela de 200. A soma agora tem TRÊS fontes — o resumo, só quando é desta sessão; os handoffs DESTA sessão (quem recebeu um aceito e quem OFERECEU um, que estava nela); e o gasto por agente DESTA sessão (GET .../token-usage, agregado sem janela: agente com linha ali rodou turno, e só roda turno quem foi ativado). Cada fonte só corrige falso negativo, e o gasto é prova de PRESENÇA, nunca de ausência. Lacuna declarada: agente ativado por start direto, sem handoff, que nunca rodou turno, numa sessão que já não é a mais recente, continua dependendo da janela — e quem tem papel abaixo de developer (o mínimo da rota de gasto) fica sem a terceira fonte. Fechar pede o agregado de ativação POR SESSÃO na api; não foi feito aqui.

  10. Oferta pendente cujo evento saiu da janela ganha botão na faixa fixa acima do composer (revisão do PR #759). O card acionável pertence ao handoff.offered, e a lista de handoffs não tem janela: numa sessão longa a oferta seguia offered e sem botão em lugar nenhum. O critério é o CORTE, não a ausência — a oferta é mais antiga que o evento mais antigo da janela (uma oferta recém-criada cujo evento ainda não chegou não pisca na faixa), e janela vazia não afirma corte. A faixa tem só o aceite; o atalho "Ativar execução" do card do Dev Lead fica no fio.

A guarda da RN-584 (scripts/ci/destinos-do-composer.spec.ts) passa a ler DESTINATARIO_DA_SESSAO_CRIATIVA em vez do literal de SessionPage.tsx: todo destino que o composer pode dar continua tendo cláusula própria no engine. Nenhuma mudança de api nem de engine.

  • Onde: apps/web/src/lib/session-destinatario.ts:228 (agentesEmConversa), :255 (resolverDestinatario), :290 (useDestinatarioDoChat), :116 (useAtivadosNaSessaoInteira), :141 (ativadosSemJanela); apps/web/src/routes/SessionComposer.tsx:287 (destinatarioRow), :193 (ofertasForaDaJanela); apps/web/src/routes/SessionPage.tsx:350 (aceitarHandoff); apps/web/src/routes/session-timeline-montagem.tsx:555 (handoffIdDoEvento), :563 (origem); apps/web/src/lib/session-handoffs.ts:68 (activeFor), :120 (ofertasAcionaveis), :142 (ofertasForaDaJanela)
  • Teste: apps/web/src/routes/SessionPage.destinatario-do-chat.test.tsx (escolha → envio ao escolhido; dois agentes sem escolha não enviam e dizem por quê; opção única; handoff aceito fora da janela; resumo da mesma sessão e de outra; aceite que troca e aceite que falha; escolha lembrada; sessão sem agente; sessão de execução mais recente com a ativação lida dos handoffs e do gasto desta sessão, e o gasto recusado; oferta fora da janela na faixa, e oferta na janela ou mais nova que ela fora dela), apps/web/src/lib/session-destinatario.test.ts (ativadosSemJanela), apps/web/src/routes/SessionPage.handoff-inline-e-links.test.tsx (oferta antiga de OUTRO handoffId fica muda), apps/web/src/routes/SessionPage.handoff-manual.test.tsx (o manual ganha "Aceitar"; o manual pendente não esconde a oferta seguinte; duas ao mesmo agente viram um botão)
  • Origem: AT-251, AT-253

RN-633 — O handoff manual não declara prontidão, e "já ativo" para a oferta é do projeto​

Duas leituras da tela de Sessão deixavam o handoff obsoleto (AT-293, AT-294):

  1. O handoff MANUAL não esconde "Estou pronto para produzir" nem "Confirmar arquitetura pronta" (AT-293). prontidaoJaDeclarada e arquiteturaJaDeclarada perguntavam só "existe handoff saindo do Criativo/Arquiteto?", e o handoff manual (ADR 0109, RN-441) grava como fromAgent o ÚLTIMO agente ativado (request-manual-handoff.use-case.ts): pedir um handoff manual ao Staff com o Criativo ativo escondia o botão de prontidão sem prontidão nenhuma declarada. O manual se reconhece pelo ATOR humano do handoff.offered (casado pelo handoffId, RN-631) — o Handoff da api não carrega o ator. Fora da janela de 200 o ator é desconhecido, e aí: para o Criativo vale o artifactId (o handoff da prontidão leva o product_brief, criativo_server.ex; o manual nunca leva artefato); para o Arquiteto os dois automáticos não levam artefato, então o de origem desconhecida segue contando, como antes. Lacuna declarada: a marca durável seria o ator (ou uma marca de manual) no próprio Handoff da api; não foi feita aqui (a api é da lane do ciclo de vida, ADR 0182).
  2. Oferta a agente já ATIVO em outra sessão do projeto não é acionável (AT-294). "Ativo" era da SESSÃO: depois de "Ativar execução", o Dev Lead roda na sessão de execução, e a oferta a ele na sessão do chat seguia com "Aceitar". Agora a oferta (o card do fio, a faixa fora da janela e o card da Infra) soma ao "já entrou nesta sessão" o roster.activatedAgents do resumo, que é a sessão MAIS RECENTE do projeto — a de execução, depois de ativada. activeFor continua sendo DESTA sessão, porque é ele que o seletor do handoff manual usa. Lacuna declarada: sessões que não são nem esta nem a mais recente não são vistas; fechar pede um agregado de ativação POR PROJETO na api, que não existe.

Nenhuma mudança de api nem de engine.

  • Onde: apps/web/src/lib/session-handoffs.ts:41 (origemDoHandoff), :108 (jaAtivo), :180 (prontidaoJaDeclarada), :192 (arquiteturaJaDeclarada); apps/web/src/lib/session-destinatario.ts:156 (useAtivosNoProjeto)
  • Teste: apps/web/src/routes/SessionPage.handoff-obsoleto.test.tsx (manual com o Criativo ativo não esconde "Estou pronto"; manual com o Arquiteto ativo não esconde "Confirmar arquitetura pronta"; o handoff do agente continua escondendo — caso de falha; fora da janela o do Criativo com brief conta; Dev Lead ativo na sessão de execução deixa a oferta muda; sem ele, a oferta segue acionável — caso de falha)
  • Origem: AT-293, AT-294

RN-634 — Depois de "Ativar execução", a tela vai à sessão de execução​

A execução roda numa sessão NOVA (RN-135, RN-139), e a de chat de onde o clique partiu pode até fechar. O atalho "Ativar execução" do card do handoff ao Dev Lead (RN-137) — e a fusão com o aceite (RN-161) — deixava a pessoa na sessão de chat, olhando o lugar de onde o trabalho saiu (AT-295). Agora handleActivateExecution lê o sessionId que POST .../execution/activate devolve e leva a tela até ele, com o aviso "Levando você à sessão de execução.". Sem sessionId na resposta, com o mesmo id da sessão atual, ou sem quem navegue (a tela montada fora da rota), só o aviso de sempre, e a tela fica. Ativação recusada não navega e mostra a frase da api. Quem navega é a ROTA (irParaSessao, o navigate do router): a tela não depende do router.

  • Onde: apps/web/src/lib/session-acoes-de-handoff.ts:147 (handleActivateExecution); apps/web/src/router.tsx:366 (irParaSessao)
  • Teste: apps/web/src/routes/SessionPage.handoff-obsoleto.test.tsx (navega para o sessionId devolvido, com aviso; recusa não navega — caso de falha; sem quem navegue, só avisa)
  • Origem: AT-295

RN-632 — Leitura de PROJETO polla no ritmo de projeto, e toda tela que ouve o canal da sessão invalida pela MESMA janela​

O uso real de 29/09 bateu o teto de 300 req/min do usuário (RN-579) com duas abas abertas: 192 × 429 às 07:14, 106 deles em GET /projects/:id/sessions e 20 em GET /projects/:id, e três cliques de approve_always recusados no meio (AT-278). A RN-579 tinha posto sob o canal as leituras da tela de SESSÃO; as de PROJETO e as outras telas que ouvem o canal ficaram de fora. O teto NÃO muda: o que cai é o pedido.

A regra:

  1. Leitura de projeto que nenhum canal avisa polla a 15s (INTERVALO_DO_PROJETO_MS), INCONDICIONAL. O canal session:<id> só avisa escritas da própria sessão; a lista de sessões do projeto e os contadores do trilho (aprovações da sessão mais recente, merges pendentes do projeto, histórias a promover, pendências de arquitetura, hipóteses) mudam por escrita de QUALQUER sessão — não há aviso para esperar, então o mecanismo é poll longo, no mesmo fallback que a RN-579 já aceita para a sessão. Vale para useProjectSessions/useLatestSession (eram 5s), para os cinco contadores da moldura ProjectPage e para a arquitetura da aba Executores. A aba que mostra o dado como ASSUNTO (Backlog, Arquitetura, PRs) mantém o poll dela, e o observador mais rápido dita o ritmo da chave. Quem escreve nesta aba continua invalidando a chave na hora.
  2. Quem só precisa do dado no clique não polla. Cada linha de /containers lê a sessão mais recente do seu projeto para PROPOR parar/remover/subir; em poll de 5s eram 12 req/min POR PROJETO — nove projetos, 108/min só dessa página, o número que casa com os 106 do log. useLatestSession(projectId, false): montagem e foco da janela bastam.
  3. Toda tela que ouve o canal passa por criarInvalidadorDoCanal. As abas Executores e Visão geral invalidavam os eventos a CADA event.appended, sem janela: um dev agent em rajada (10 avisos/s) fazia 630 GET de eventos por minuto só na aba Executores. Agora as duas usam o MESMO invalidador da tela de Sessão (janela por alvo, o TIPO decide o alvo), e ganham de brinde o que faltava: a proposta de ação do dev agent invalida as ações na hora, em vez de esperar o fallback de 15s. A Visão geral pede o backlog a cada aviso (as tasks bloqueadas vêm dele) pelo parâmetro extras, com a mesma janela.
  4. A espera pelo runner para quando acaba. EsperaDoRunner só parava a sonda de GET /projects/:id (3s) no TETO; confirmada a conexão, o efeito do teto saía cedo e a sonda seguia para sempre — 20 req/min enquanto o painel estivesse na tela, e, sendo o observador mais rápido de ['project', id], arrastando a chave inteira da página. Agora para no teto OU na confirmação.

Números (duas-abas.orcamento.test.tsx, as telas de verdade — Shell, SessionPage, ProjectPage na aba Executores, ContainersPage — sobre um fetch que conta por rota; cada aba medida sozinha, porque duas abas são dois contextos JS, e somadas; req/min, dev → esta regra):

CenárioAntesDepois
chat da sessão + Executores, canal vivo70 + 138 = 20870 + 72 = 142
idem, dev agent em rajada (10 avisos/s)70 + 764 = 83470 + 97 = 167
idem, canal que nunca conecta155 + 186 = 341155 + 120 = 275
/containers com 9 projetos132 (108 de /sessions)24

O que esta regra NÃO fecha: com o canal CAÍDO nas duas abas, 275/min ainda é perto do teto — a tela de Sessão volta aos 3s de sempre (RN-579: nunca pior que era), e 25 req/min de folga somem com uma rajada de cliques. O Shell segue pollando projects-summary e execution/session a 5s e, na aba do chat, os eventos da sessão de EXECUÇÃO a 3s (a aba não tem o canal dela) — 44 das 70 req/min da aba do chat. Uma lista de sessões criada noutra aba aparece aqui em até 15s (eram 5s). O número do incidente foi CASADO, não reproduzido: o log não dizia quais abas estavam abertas, e a leitura de que /containers estava numa delas é inferência pelos 106 ≈ 9 × 12.

  • Código: apps/web/src/lib/canal-vivo.ts:54 (INTERVALO_DO_PROJETO_MS), :255 (aoEvento, os extras); apps/web/src/lib/hooks.ts:127 (useProjectSessions), :158 (useLatestSession); apps/web/src/routes/ProjectPage.tsx:77 (os contadores do trilho); apps/web/src/components/AcoesDoContainer.tsx:52 (useLatestSession, sem poll); apps/web/src/routes/ProjectExecutorsTab.tsx:203 (invalidador), apps/web/src/routes/ProjectOverviewTab.tsx:202 (invalidador); apps/web/src/components/EsperaDoRunner.tsx:73 (confirmadoPor)
  • Teste: apps/web/src/routes/duas-abas.orcamento.test.tsx (as duas abas com canal vivo, com rajada, com o canal que nunca conecta, e /containers; os quatro reprovam na dev); apps/web/src/components/EsperaDoRunner.test.tsx ("confirmada a espera, a sonda PARA" e o caso de falha: sem confirmação ela segue); apps/web/src/routes/ContainersPage.test.tsx ("a linha lê a sessão mais recente SEM poll")
  • Origem: AT-278 (extensão da RN-579; não muda a RN-108 — o ticket de uso único e a reconexão manual seguem como estão)

RN-635 — Uma oferta de handoff pendente por destino no projeto; a que deixa de valer vira superseded​

handoffs só gravava offered/accepted, sem expiração, supersessão nem dedupe: duplo clique em "arquitetura pronta" dava 2× Infra e 2× Dev Lead, e oferta a agente já ativo ficava acionável para sempre (AT-291, ADR 0182).

  1. No máximo UMA offered por (projeto, destino). decidirOferta decide: sem pendente, cria; pendente na MESMA sessão e sem artefato novo (o novo é null ou igual), devolve a existente (desfecho: ja_oferecido, sem linha nem evento); qualquer outro caso cria a nova e substitui a pendente (substituiu_oferta) — devolver a antiga perderia o artefato novo, ou deixaria quem pediu sem nada para aceitar na sessão em que está.
  2. Quem substitui grava. A linha vira superseded e nasce um evento handoff.superseded (handoffId, toAgent, motivo, substitutaId) na sessão da oferta VELHA, ator system, mesmo com a sessão encerrada. O handoff.offered original não é tocado.
  3. Serializado por destino. CreateHandoffUseCase roda numa transação com pg_advisory_xact_lock do par (projeto, destino): duas abas chegando juntas produzem UMA linha. Duplicatas de antes convergem na próxima oferta ou ativação, com evento; a migration não reescreve linha nenhuma.
  4. Agente já ativo não recebe oferta: 409 reason: agente_ja_ativo, sem linha nem evento. Ativo = agent.activated numa sessão NÃO terminal do projeto. A message é o texto que o modelo lê: offer_handoff a repassa literal, e FalhaDeTurno.origem/1 a classifica politica.
  5. Ativar substitui. ActivateAgentUseCase (aceite e ativação direta) e ActivateExecutionUseCase (por dev-<modulo>) substituem, depois de ativar, toda oferta pendente ao agente em qualquer sessão do projeto; a aceita não é tocada, e ativação recusada não substitui nada.
  6. "Arquitetura pronta" é idempotente. OfferInfraHandoffUseCase não aciona o destino (infra, dev-lead) que já tem oferta pendente ou está ativo; com os dois assim, não grava nem chama o engine (desfecho: ja_oferecido, jaAtendidos com o motivo de cada um).
  • Onde: apps/api/src/domain/sessions/ciclo-de-vida-do-handoff.ts:71 (decidirOferta), :104 (mensagemDeAgenteJaAtivo); apps/api/src/application/use-cases/agents/ciclo-de-vida-do-handoff.service.ts:43 (sessaoOndeEstaAtivo), :71 (substituir), :97 (substituirOfertasAoAtivar); apps/api/src/application/use-cases/agents/create-handoff.use-case.ts:109 (travarOfertasDoDestino); apps/api/src/infrastructure/persistence/drizzle/handoff.repository.ts:94 (travarOfertasDoDestino); apps/api/src/application/use-cases/agents/activate-agent.use-case.ts:88 (substituirOfertasAoAtivar); apps/api/src/application/use-cases/execution/activate-execution.use-case.ts:327 (substituirOfertasAoAtivar); apps/api/src/application/use-cases/agents/offer-infra-handoff.use-case.ts:96 (jaAtendido); apps/engine/lib/engine/harness/tools/offer_handoff.ex:132 (agente_ja_ativo); apps/engine/lib/engine/agents/falha_de_turno.ex:67 (agente_ja_ativo); apps/api/src/db/migrations/0064_ciclo_de_vida_do_handoff.sql
  • Teste: apps/api/test/application/use-cases/agents/ciclo-de-vida-do-handoff.spec.ts (contra Postgres: repetida na mesma sessão, duplo clique concorrente, artefato novo, outra sessão encerrada, dado de antes do ADR, agente ativo e ativo em sessão encerrada, ativação que substitui e ativação recusada); apps/api/test/application/use-cases/agents/offer-infra-handoff.use-case.spec.ts ("ADR 0182: segundo clique…", "ADR 0182: só aciona o destino que falta…"); apps/api/test/application/use-cases/agents/activate-agent.use-case.spec.ts; apps/api/test/application/use-cases/execution/activate-execution.use-case.spec.ts ("ciclo de vida do handoff"); apps/engine/test/engine/harness/tools/offer_handoff_test.exs; apps/engine/test/engine/agents/falha_de_turno_test.exs; apps/web/src/lib/session-handoffs.test.ts (a tela: superseded não vira card acionável no fio, na faixa fora da janela nem no card da Infra)
  • Origem: AT-291

RN-636 — O AppSec só oferece o threat model a quem ainda não recebeu oferta nem está ativo​

O AppSec criava três ofertas por história com run_design, aos mesmos três destinos — N histórias, 3N ofertas pendentes (AT-292, que funde a AT-273 do vault por decisão do dono; ADR 0182).

  1. Pergunta no modo seAusente. criar_handoffs_appsec/3 chama EngineApiClient.create_handoff_if_absent/5, que manda seAusente: true à MESMA rota interna. A api decide sob o lock do destino (RN-635): com oferta pendente em QUALQUER sessão do projeto, devolve a existente (desfecho: ja_oferecido) e não a substitui; com o destino ativo, 409 agente_ja_ativo. Perguntar antes, pelo engine, seria corrida com outra oferta.
  2. "Já atendido" não é falha. As duas respostas acima seguem sem agent.error. Falha de verdade (5xx, rede) continua narrada por alvo, com origem (RN-116, RN-361).
  3. O artefato fica. O artifact.threat_model é gravado ANTES e de qualquer jeito; o que deixa de nascer é a oferta repetida.
  • Onde: apps/engine/lib/engine/gates/secops_agent_server.ex:377 (criar_handoffs_appsec/3); apps/engine/lib/engine/sessions/engine_api_client.ex:1131

    apps/engine/lib/engine/sessions/engine_api_client.ex:1250 (create_handoff_if_absent); apps/api/src/domain/sessions/ciclo-de-vida-do-handoff.ts:71 (decidirOferta)

  • Teste: apps/engine/test/engine/gates/secops_agent_server_test.exs ("run_design (RN-636): destino com oferta pendente ou já ativo não recebe outra, e isso não é erro", "run_design (RN-636): falha de verdade ao oferecer segue narrada, só para aquele destino"); apps/api/test/application/use-cases/agents/ciclo-de-vida-do-handoff.spec.ts ("seAusente (AppSec, RN-636)…")

  • Origem: AT-292, AT-273

RN-644 — O fio corta por MENSAGEM, nunca parte uma troca e recolhe o histórico na ordem em que aconteceu​

O levantamento visual da Rodada 29 (achado X3, AT-319) mediu três defeitos no corte do fio da RN-177, todos na mesma função: (1) as "últimas 5" contavam entradas, e os cards de handoff, de aprovação e de história contavam como uma mensagem cada — numa conversa curta, a primeira pergunta e a primeira resposta iam para o histórico recolhido; (2) o corte caía onde a contagem mandava, podendo deixar a pergunta recolhida e a resposta aberta; (3) o histórico era agrupado por origem, na ordem das origens, e o grupo "LLM" vinha antes de "Usuário" — expandido, a resposta do Criativo aparecia ACIMA da pergunta que a gerou.

A regra, no fio (o painel de log NÃO muda — lá o eixo é a camada):

  1. O corte conta MENSAGENS: chat.message, agent.response e chat.structured_question (TimelineEntry.mensagem). Card e divisor não contam; os que vêm depois do corte ficam abertos com as mensagens em volta. Um colapso por agente (RN-138) e um "Passos do turno" continuam sendo UMA entrada, e contam como uma mensagem.
  2. O corte nunca parte um turno (RN-172): ele recua até a abertura do turno da mensagem que o marcou, então a pergunta fica sempre junto da resposta. Por isso as abertas podem passar de cinco — nunca uma troca pela metade. O prólogo (turno 0) não tem abertura e não recua; se recuar chegar ao início, não há histórico.
  3. O histórico é UM bloco, em ordem cronológica, e DECLARA o recorte (RN-180): o título diz o que o corte conta ("Antes das últimas 5 mensagens") e o trailing separa o que ficou dentro ("2 mensagens · 2 outras entradas"). O rótulo "LLM" sai do fio junto com os grupos por origem.

O que se perdeu, declarado: reabrir só o que o usuário disse, sem reabrir o resto, era a razão dos grupos por origem no fio. Ela não sobrevive à ordem cronológica — o painel de log segue oferecendo esse recorte.

  • Código: apps/web/src/routes/session-fio.tsx:119 (FIO_RECENTES_ABERTAS), :291 (dividirFio), :141 (entradaSimples); apps/web/src/lib/session-timeline.ts:67 (mensagem); apps/web/src/routes/session-timeline-montagem.tsx:389, :426, :673 (as três mensagens); apps/web/src/routes/SessionFio.tsx:139 (o bloco único)
  • Teste: apps/web/src/routes/session-fio.test.ts (cards não contam, o corte recua até a abertura, ordem do fio, e o caso de falha do turno maior que o corte); apps/web/src/routes/SessionPage.painel-e-agrupamento.test.tsx (describe "RN-177/RN-644": o caso medido com cards intercalados, e o histórico expandido na ordem)
  • Origem: AT-319, achado X3 do levantamento visual "antes" da Rodada 29

RN-637 — A tela lê a CAUDA das ações da sessão, e a pendente antiga volta junto​

GET /projects/:projectId/sessions/:sessionId/actions ordenava por seq crescente com teto de 200, e usePendingActions nunca paginava: a partir da ação 201 de uma sessão — o que uma execução real alcança fácil, um dev agent propõe dezenas de comandos por tarefa — a pendente NOVA ficava fora da única página lida e sumia do fio, dos Executores e de Aprovações (AT-296), justamente quando havia alguém esperando por ela.

A regra:

  1. A rota aceita latest=true e status=pending. latest traz as limit ações de seq mais alto, devolvidas em ordem CRESCENTE e sem nextCursor, ignorando afterSeq — o mesmo contrato do latest dos eventos (ADR 0021). status só aceita pending (outro valor é 400, sem consultar); combinado com latest, traz as pendentes mais novas sem que as decididas ocupem a janela. Sem os dois parâmetros o contrato é o de antes.
  2. A tela faz duas perguntas, e a segunda só quando precisa. buscarAcoesDaSessao lê a cauda (200); só se ela vier CHEIA pede também as pendentes e une por id, em seq crescente. Uma pendente antiga que a cauda empurrou para fora continua sendo decisão a tomar e não some por ter esperado demais. A sessão curta — o caso comum — continua custando UMA requisição, e a leitura de pendentes que falha derruba a query (a tela diz que falhou, nunca finge fila vazia — RN-088).

O que esta regra NÃO fecha: com mais de 200 PENDENTES numa sessão, as mais antigas além delas ficam fora (o teto é o mesmo limit); e o que o fio mostra de DECIDIDO continua sendo a cauda — ação decidida antiga, fora das 200 mais novas, não aparece no fio. Pendentes do PROJETO inteiro são outra leitura (RN-638).

  • Código: apps/api/src/infrastructure/persistence/drizzle/proposed-action.repository.ts:96 (listPaginated), :113 (o ramo latest); apps/api/src/interfaces/http/actions/actions.controller.ts:105 (list), :114 (a recusa de status); apps/api/src/application/ports/proposed-action-repository.port.ts (ListProposedActionsOptions); apps/web/src/lib/acoes-da-sessao.ts:48 (buscarAcoesDaSessao), :30 (juntarCaudaEPendentes); apps/web/src/lib/hooks.ts:432 (usePendingActions)
  • Teste: apps/api/test/infrastructure/persistence/proposed-action-latest.repository.spec.ts (sem latest a pendente nova fica de fora — o defeito; com latest ela está na cauda; status=pending devolve a antiga; e não traz outra sessão); apps/api/test/interfaces/http/actions/actions-list.controller.spec.ts (repasse dos parâmetros e o 400 — caso de falha); apps/web/src/lib/acoes-da-sessao.test.ts (sessão curta numa requisição; cauda cheia com 250 ações devolve a pendente 3 e a 250; leitura de pendentes que falha derruba a query)
  • Origem: AT-296 (HS-076)

RN-638 — Quem pergunta "o que espera decisão" lê a fila do PROJETO, nunca a sessão mais recente​

O contador de Aprovações do trilho, o painel "precisa de você" (RN-467), a aba Aprovações, o roster da Visão geral e a aba Código liam as pendentes de useLatestSession — a sessão CRIADA por último —, que só é a de execução por coincidência. Uma ideação ou um chat aberto depois da execução zerava o contador e escondia do roster os dev agents esperando decisão (AT-297). E o bloco de pendências de outras sessões do chat (RN-626) só existia em sessão ativa, enquanto o container_start que o Infra Lead propõe no CHAT destrava o dev.blocked_by_container que aparece na EXECUÇÃO (AT-298).

A regra:

  1. A fila é a do projeto. Essas telas leem useProjectPendingActions (GET /projects/:projectId/actions?status=pending, em qualquer sessão), todas pela MESMA chave ['project-pending-actions', projectId, undefined] — no ritmo de projeto da RN-632, e a 3s só na aba Aprovações, que a tem como assunto. A moldura ProjectPage tira o contador de PRs (git_merge) da MESMA leitura, em vez de uma segunda consulta. As filas continuam separadas, com o selo próprio — nada se soma (RN-467).
  2. Cada card decide pela sessão que a PRÓPRIA ação carrega. Na aba Aprovações, aprovar, negar, "sempre permitir" e o lote chamam o endpoint com action.sessionId, e invalidam a fila do projeto por prefixo e as ações da sessão da ação. Os blocos de PR e gate da aba leem eventos e ações da sessão de EXECUÇÃO vigente (RN-139), e só sem ela da mais recente.
  3. As pendências de outras sessões aparecem em qualquer estado. O bloco do chat sai também em sessão encerrada e técnica — decidir é sobre a ação da OUTRA sessão, não conversa nesta (a RN-581 segue recusando conversa) —, e a aba Executores ganha o MESMO bloco para a sessão de execução: o container_start do chat fica visível onde o bloqueio aparece.
  4. A proposta chega pelo canal, sem esperar o poll. Todo aviso proposed_action.*/action.* de qualquer canal de sessão que uma tela ouve invalida também a fila do projeto (pendenciasDoProjeto em criarInvalidadorDoCanal, janela de 2s), e o dev.blocked_by_container também — a proposta que o destrava nasce noutra sessão, cujo canal ninguém ali ouve (AT-299). Nenhum poll subiu.
  5. O bloco cabe na coluna e mostra todos (AT-318). Na medida do fio e do composer (780px), recolhível, com a PRESENÇA de cada fila no cabeçalho — "Aprovações 3 · Merges de PR 1", cada uma com o próprio número, nunca a soma (RN-467) — visível mesmo recolhido. Os cards nascem com o detalhe FECHADO (detalheRecolhido no ApprovalCard): abertos, cobriam ~60% do fio e deixavam um card à vista de três. O rótulo de origem sai UMA vez por sessão, com o tempo da mais antiga — por card, ele se repetia entre um card e o seguinte. O teto de 20 cards e o recorte declarado (RN-180) seguem.

O que esta regra NÃO fecha: o aviso só chega onde há uma tela ouvindo um canal de sessão (Sessão; Visão geral, a sessão mais recente; Executores, a de execução). Numa aba sem canal (Aprovações, PRs, Backlog…) ou para uma proposta feita numa sessão cujo canal nenhuma tela aberta ouve, o contador chega pelo poll de projeto, em até 15s; abrir um canal de sessão na moldura mudaria o ciclo de vida da sessão (o canal é o heartbeat da RN-064) e ficou de fora. O contador de Aprovações continua contando o git_merge pendente, como antes — o painel o deduplica para a fila de PRs. A lacuna do papel de WORKSPACE no lugar do efetivo (RN-471) vale para o bloco novo dos Executores como vale para o do chat.

  • Código: apps/web/src/routes/ProjectPage.tsx:82 (o contador), :121 (os merges da mesma leitura); apps/web/src/routes/ProjectApprovalsTab.tsx:115 (pendentesQuery), :119 (sessaoDeTrabalho), :326 (handleApprove); apps/web/src/routes/ProjectOverviewTab.tsx:125 (pendentesDoProjeto); apps/web/src/routes/ProjectExecutorsTab.tsx:107 (pendentesQuery), :290 (o bloco); apps/web/src/routes/code/CodeShell.tsx:101 (pendentesQuery); apps/web/src/routes/SessionPage.tsx:782 (o bloco sem isActive); apps/web/src/lib/canal-vivo.ts:134 (alvosDoEvento), :180 (a chave por prefixo); apps/web/src/components/PendenciasDeOutrasSessoes.tsx (porSessao, presenca); apps/web/src/components/ApprovalCard.tsx (detalheRecolhido); apps/web/src/lib/hooks.ts:455 (useProjectPendingActions); apps/web/src/lib/precisa-de-voce.ts (acoesPendentes)
  • Teste: apps/web/src/routes/ProjectApprovalsTab.test.tsx (a pendente da execução com uma ideação mais nova, decidida pela sessão dela; decidida não entra — contraste); apps/web/src/routes/project-tabs.test.tsx (os selos pela fila do projeto); apps/web/src/routes/ProjectExecutorsTab.test.tsx (o container_start do chat na aba da execução; a pendente da própria execução não vira bloco — contraste); apps/web/src/routes/SessionPage.sessao-encerrada.test.tsx (o bloco em closed e closed_abnormally; a da própria sessão não entra); apps/web/src/lib/acoes-da-sessao.test.ts (o canal invalida a fila do projeto por prefixo e com janela; tool.call não); apps/web/src/components/PendenciasDeOutrasSessoes.test.tsx (três cards à vista com o detalhe fechado e a presença por fila sem soma; recolhido, a presença fica no cabeçalho; um rótulo de origem por sessão); apps/web/src/routes/duas-abas.orcamento.test.tsx (a rajada invalida a fila do projeto na aba Executores, dentro do orçamento da RN-632)
  • Origem: AT-297, AT-298, AT-299 (HS-076), AT-318 (auditoria visual de 30/09)

RN-646 — Bloqueio e controle inerte dizem o motivo de QUEM está na tela: a aba PRs fala da lista de PRs, e Parar/Remover sem container dizem que não há container​

Duas telas mostravam um estado certo com o texto de outro lugar (AT-323, AT-324 — achados P1 e K1/K2 do levantamento visual da Rodada 29).

A aba PRs e o portão da imagem. O bloqueio é REAL e fica: a lista de PRs e o diff passam pelo MESMO funil da aba Código (ReadProjectCodeUseCase.alvo → portaoDoContainer), então sem imagem decidida (RN-105, nos três modos pela RN-494) a api responde 409 também ali — medido, não suposto. O que estava errado era o texto: a aba PRs mostrava "A aba Code ainda não está liberada", falando de OUTRA aba e com um nome ("Code") que não é o do trilho ("Código"). Agora ContainerImageGateNotice recebe a SUPERFÍCIE bloqueada ('code' | 'prs'), a aba PRs diz "A lista de PRs ainda não está liberada" e explica que lê o repositório pelo mesmo caminho da aba Código; e o nome das abas no texto vem de nav:tabs.*.label, a MESMA fonte do trilho — nunca um literal, que é como "Code" e "Código" divergiram. A nota da aba PRs NÃO promete liberar sozinha: a aba Código pergunta o estado e revalida, a lista de PRs só descobre o 409 quando a query falha.

/containers sem container. Parar e Remover já eram disabled com registrado: null (RN-521, "nunca provisionado") e com removed, mas nada dizia por quê, e a variante danger do Button não tinha estado desabilitado — "Remover" saía em vermelho cheio, igual ao ativo. Agora a linha diz o motivo UMA vez em texto (Nunca provisionado: não há container para parar nem remover. / Container removido: …), e .danger:disabled ganha a mesma opacidade das outras variantes. Linha com container de pé não ganha texto nenhum.

O motivo longo da falta de broker. O texto que nomeia BROKER_URL tinha nove linhas e triplicava a altura da linha da tabela. A célula mostra agora UMA frase curta num <summary>, e o texto longo (que continua nomeando a variável, para o operador) abre sob demanda no <details> — nativo e acessível por teclado, nunca title num botão desabilitado (ADR 0064). Quando a MESMA falta de broker trava subir E parar/remover, é UMA linha só ("subir, parar e remover só terminariam em falha"), com os dois detalhes atrás do mesmo <details>.

  • Código: apps/web/src/components/ContainerImageGate.tsx:33 (ContainerImageGateNotice), :42 (bloco); apps/web/src/routes/ProjectPrsTab.tsx:157 (superficie="prs"); apps/web/src/components/AcoesDoContainer.tsx:250 (semContainer), :268 (chaveCurtaDoBroker); apps/web/src/components/ui/Button.module.css:101 (.danger:disabled)
  • Teste: apps/web/src/routes/ProjectPrsTab.test.tsx ("409 do portão vira o estado dedicado, com o texto da aba PRs e não o da aba Código", "409 no diff aberto por id também usa o texto da aba PRs", e o caso de falha: 500 continua banner com "Tentar de novo"); apps/web/src/routes/ProjectCodeTab.test.tsx e apps/web/src/routes/code/CodeDiffPanel.test.tsx (a aba Código diz "Código"); apps/web/src/routes/ContainersPage.test.tsx, bloco "Parar/Remover sem container e o motivo curto (AT-324)" (nunca provisionado, removido, o caso de falha "container de pé: nenhum motivo", o <details> fechado com BROKER_URL só dentro dele, e a linha única quando o broker trava as três)
  • Origem: AT-323, AT-324. Não muda o portão da RN-105/RN-494 (a lista de PRs segue bloqueada sem imagem decidida — se ela DEVE escapar do portão é decisão do dono), nem a régua de subida da RN-521/RN-574.

RN-648 — A sidebar, o card do Dashboard e o painel de artefatos não se contradizem: cada estado vazio diz QUAL sessão leu, e contador e lista contam na mesma unidade​

Três telas afirmavam "nada" ao lado de uma tela que mostrava algo (AT-325 — achados S2/D2/X6 do levantamento visual da AT-290). A reprodução com o engine real não foi possível neste ambiente (o egress bloqueia repo.hex.pm e o Alpine, e nenhuma imagem compila); as três contradições foram MEDIDAS no código e provadas por testes que falham antes da correção. A do Dashboard nasceu, no levantamento, de um 500 do stub do engine no resumo do workspace — mas o texto que a transformou em "sem atividade" é do web, e com o engine real qualquer falha do resumo produziria o mesmo.

A sidebar lia a sessão de EXECUÇÃO, e só ela. O bloco Atividades (RN-198) pedia os eventos da sessão de execução vigente (useActiveExecutionSession). Num projeto só com sessões de conversa ela é null, a lista vinha vazia, e a sidebar dizia "Nenhum agente entrou em ação ainda" ao lado de uma Visão geral com o Criativo aguardando. Agora: com execução vigente, a sidebar continua lendo ELA (os dev agents e as instâncias -2 são o que o agrupamento da RN-198 existe para mostrar, e a queryKey é a da aba Executores); SEM execução, lê a MESMA sessão da Visão geral e do card do Dashboard — a mais recente de trabalho (useLatestSession, a regra de sessaoMaisRecente). Os estados que colapsavam no mesmo texto se separam (RN-470): carregando (nada é afirmado), erro (texto próprio), projeto sem sessão ("Este projeto ainda não tem sessão."), e a sessão lida sem agente, com o texto NOMEANDO qual ("…na sessão de execução…" / "…na sessão mais recente…"). A lista de sessões só é pedida sem execução, e com o frescor de um ciclo de projeto (frescorMs em useProjectSessions): quando a sidebar a habilita a moldura do projeto já a trouxe, e o orçamento de requisições da RN-632/RN-645 não cresce.

O card do Dashboard tinha um texto para quatro estados. "Sem atividade ainda" saía com o resumo do workspace CARREGANDO, com ele FALHADO (o caso do levantamento: o resumo respondia 500 e o card de um projeto com dezesseis eventos dizia que não havia nenhum), com o projeto SEM sessão e com a sessão mais recente vazia. Agora são quatro textos: "carregando atividade…", "atividade indisponível", "Nenhuma sessão ainda" e "Sem atividade na sessão mais recente ainda". A linha continua lendo a sessão mais recente de trabalho (latestSessionId), a mesma da Visão geral e da sidebar sem execução — a api não mudou.

"Artefatos gerados 3" sobre um único grupo "PO 1". O cabeçalho da seção contava a ÁRVORE do backlog (épico, história e tarefa: 3) e o grupo por agente contava só as RAÍZES (1). Os dois contam agora na mesma unidade — cada item carrega quantos (1, mais os descendentes quando é raiz do backlog), o cabeçalho é a soma dos itens e cada grupo é a soma dos seus, então a soma dos grupos É o cabeçalho, por construção. E o vazio da seção diz o que ela conta ("Nenhuma PR nem item de backlog nesta sessão ainda.") em vez do "Nada ainda." genérico, que ao lado de um brief ou de uma regra da mesma sessão parecia negar que eles existissem — o escopo da seção (PR e backlog, RN-159) não muda.

  • Código: apps/web/src/routes/Shell.tsx:616 (execucao), :624 (origemDaAtividade), :631 (estadoDaAtividade); apps/web/src/lib/hooks.ts:127 (useProjectSessions, frescorMs); apps/web/src/routes/Dashboard.tsx:75 (lastActivityText); apps/web/src/routes/ContextAside.tsx:46 (quantos), :340 (totalDeArtefatos)
  • Teste: apps/web/src/routes/Shell.test.tsx ("Shell — Atividades": sem execução lê a sessão da Visão geral e o Criativo aparece; com execução lê a de execução e o vazio diz qual; projeto sem sessão; e o caso de falha, a lista de sessões que falha vira erro nomeado e nunca "nenhum agente"); apps/web/src/routes/Dashboard.test.tsx ("Dashboard — linha de atividade do card (RN-648)": o resumo que falhou diz "indisponível", carregando não afirma, sem sessão × sessão vazia têm textos diferentes, e com evento nenhum vazio aparece); apps/web/src/routes/SessionPage.artefatos-gerados.test.tsx (a soma dos grupos é o cabeçalho; o vazio diz o que conta); apps/web/src/routes/configuracoes.orcamento.test.tsx e apps/web/src/routes/duas-abas.orcamento.test.tsx seguem nos tetos
  • Origem: AT-325 (achados S2/D2/X6 da AT-290). Não muda o escopo da RN-159 nem o resumo do workspace (RN-090); estende a RN-198 (qual sessão a sidebar lê) e aplica a RN-470

Acompanhar ao vivo sem martelar (RN-639)​

RN-639 — O que muda por token não re-renderiza a tela, e o que acompanha o bootstrap polla a 3 s e para no fim​

Três telas faziam trabalho que ninguém via (AT-301/AT-302). A tela de Sessão re-renderizava INTEIRA a cada token de um turno — o texto em curso morava em estado do hook chamado por ela. As telas de provisionamento e de adoção pollavam o status do bootstrap e os eventos da sessão técnica a 1 s, e os eventos nunca paravam, mesmo com o repositório pronto. E o /status batia para sempre num serviço que não respondia.

  1. O texto em curso mora num store externo. O texto do chat consultivo e a faixa de atividade do turno ficam num store por sessão (criarStoreDoStreaming), assinado por useSyncExternalStore só pelos dois componentes que os desenham — a bolha do fio e a faixa. A página assina UMA pergunta booleana (o turno já mostrou conteúdo?), que é o que o timer de "pensando" (RN-131) precisa e que muda uma vez por turno. O reducer da faixa é o mesmo; mudou quem guarda o estado.
  2. O bootstrap é acompanhado a 3 s. Status e eventos, nas duas telas.
  3. E para no fim. Status e eventos param quando o bootstrap converge (provisioned) e quando a query erra (pollQueParaNoErro). A FALHA (provision_failed) é terminal na adoção e NÃO no provisionamento: ali o "Tentar novamente" dispara um bootstrap novo e a tela precisa ver o progresso dele (RN-477).
  4. O /status para no erro. Serviço que responde — inclusive com 5xx, que vira status: 'error' num corpo resolvido — segue a cada 5 s. Serviço que não responde deixa de ser perguntado por timer; o foco da janela e a remontagem perguntam de novo, e o primeiro sucesso retoma o ritmo.
  • Onde: apps/web/src/lib/streaming-do-turno.ts:48 (criarStoreDoStreaming), apps/web/src/lib/streaming-do-turno.ts:98 (useSemConteudoNoTurno), apps/web/src/lib/poll-do-bootstrap.ts:33 (pollDoBootstrap), apps/web/src/lib/poll-do-bootstrap.ts:47 (bootstrapTerminou), apps/web/src/routes/ProvisioningPage.tsx:41 (acompanharBootstrap), apps/web/src/routes/StatusPage.tsx:78 (pollQueParaNoErro)
  • Teste: apps/web/src/routes/SessionPage.streaming-isolado.test.tsx (vinte tokens depois do primeiro não re-renderizam a página; o fim do turno re-renderiza), apps/web/src/lib/streaming-do-turno.test.ts, apps/web/src/lib/poll-do-bootstrap.test.ts, apps/web/src/routes/StatusPage.test.tsx
  • Origem: AT-301, AT-302

RN-643 — Abaixo de 768px a sidebar vira gaveta e o trilho do projeto vira barra horizontal; a página nunca rola de lado​

A auditoria visual da Rodada 29 (AT-290, achado S1) mediu que o produto não tinha layout móvel depois do login: a 390px a sidebar (264px) e o trilho do projeto (180px) continuavam FIXOS, somando 444px de moldura, e o conteúdo ficava com 0 a 126px ou estourava para a direita. Configurações chegou a 114 textos cortados (validacao-visual.js).

A regra:

  1. Um corte, uma fonte. O breakpoint é CONSULTA_MOVEL ((max-width: 767px)), lido por useLayoutMovel. Quem troca de layout lê esse hook e aplica o desenho móvel por uma CLASSE; os módulos CSS não têm @media próprio para isso. Assim o que o JS decide e o que o CSS desenha não divergem num pixel de fronteira, e o teste de componente (jsdom, sem CSS) prova o mesmo corte que o navegador aplica. Sem matchMedia o layout é o de DESKTOP: o móvel só vale quando a consulta o afirma.
  2. A sidebar vira gaveta. Uma barra no topo traz o botão de menu (aria-expanded, aria-controls) e a marca. A gaveta é um diálogo modal (role="dialog", aria-modal): ao abrir, o foco entra nela; o Tab não sai; o Esc, o X e o fundo a fecham e devolvem o foco ao botão de menu. Ela também fecha ao seguir qualquer link de dentro dela (as abas do projeto mudam só o ?tab=) e quando a rota muda por fora. Na gaveta a sidebar aparece sempre EXPANDIDA, e o botão "Recolher menu" some: o colapso manual (RN-195) é preferência do desktop, fica gravado e volta a valer quando a janela cruza o corte de novo.
  3. O trilho do projeto vira barra horizontal. São as mesmas 12 folhas, na mesma ordem, com os cabeçalhos de grupo inline e aria-orientation="horizontal". As setas que andam são esquerda e direita, e a aba ativa é rolada para dentro da faixa visível. A barra rola por DENTRO; a página não rola de lado. O corpo do projeto vira coluna (a barra acima do painel), e a Visão geral empilha o time e a atividade em vez de manter o trilho de 360px ao lado.

Medido (390×844, tema escuro, seed da api, validacao-visual.js injetado): scrollWidth do documento = 390 em todas as telas autenticadas (dashboard, as 12 abas, /containers, /account), e <main> com os 390px inteiros. Nas Configurações, texto-cortado caiu de 114 para 33. O resto é texto de componente (tabelas e rótulos das seções), assunto de tipografia e espaço, não de moldura. O fora-da-viewport que sobra na Visão geral e em Aprovações são abas da barra horizontal além da borda, onde a rolagem interna as alcança.

Revisto pelo ADR 0211 (AT-404): o item 3 deixou de valer — o trilho do projeto saiu, e com ele a barra horizontal. No telefone as abas do projeto moram na GAVETA (item 2), com os grupos, a ativa e os contadores; escolher uma fecha a gaveta. O corpo do projeto não vira mais coluna: só os respiros encolhem.

O que esta regra NÃO fecha: a aba Código mantém o trilho de 48px do CodeShell e o editor em colunas; ela não foi desenhada para telefone. A tela de Sessão herda a moldura nova, mas a barra superior e o fio seguem com os achados X1–X3 da auditoria. A duplicação de navegação por abas (gaveta e barra) fechou no ADR 0211.

  • Código: apps/web/src/lib/layout-movel.ts:18 (CONSULTA_MOVEL), :39 (useLayoutMovel); apps/web/src/routes/Shell.tsx:438 (movel), :479 (prenderFoco), :498 (fecharAoSeguirLink); apps/web/src/routes/ProjectPage.tsx:49 (movel); apps/web/src/routes/ProjectOverviewTab.tsx:76 (movel)
  • Teste: apps/web/src/routes/Shell.test.tsx ("Shell — layout móvel (RN-643)": a gaveta abre com o foco dentro e o Esc a fecha devolvendo o foco; fecha pelo link, pelo X, pelo fundo e pela troca de rota; ignora o colapso gravado; cruzar o corte volta à sidebar fixa; e o caso de falha, sem matchMedia não há menu nem diálogo); apps/web/src/routes/Shell.test.tsx ("no telefone as abas estão na gaveta, e escolher uma fecha a gaveta (RN-643)") e apps/web/src/routes/AbasDoProjeto.test.tsx ("no telefone continua VERTICAL"), desde o ADR 0211, sobre o mock apps/web/src/test/match-media.ts
  • Origem: AT-316 (achado S1 da AT-290); não muda o ADR 0126, só dá ao trilho uma forma para telas estreitas

RN-645 — Configuração não se busca de novo a cada montagem, não polla, e a aba Configurações tem três faixas de moldura, não quatro​

O levantamento visual da Rodada 29 (achado G1, AT-321) percorreu as seções de Configurações em ~40s e a página inteira do projeto virou "Limite de requisições excedido" — o teto de 300 req/min é do USUÁRIO (RN-579). O roteiro RECARREGAVA a página por seção, e uma carga da aba custava 55 requisições. Medido com as telas de verdade, o defeito tinha três partes, e nenhuma delas pede subir o teto:

A regra:

  1. Dado de CONFIGURAÇÃO vale um minuto (FRESCOR_DA_CONFIGURACAO_MS). Com o staleTime: 0 do TanStack, toda observadora que MONTA depois da primeira refaz a busca. As seções só montam depois que o projeto chega, então GET /projects/:id, GET /projects/:id/git/repository e GET /workspaces saíam DUAS vezes em toda carga; e trocar de aba e voltar remontava as 19 seções e refazia 31 buscas. Configuração só muda por MUTAÇÃO, e toda mutação da aba invalida a própria chave — invalidar ignora staleTime, então quem salva vê o valor novo na hora. O que o minuto atrasa é a mudança feita por OUTRA pessoa, que segue chegando no foco da janela e na próxima montagem depois do minuto. Estado operacional fica fora (ciclo de vida do container, gasto por agente e por chave, chaves de dispositivo com lastUsedAt, plano de bootstrap): ali a pergunta é "o que está de pé agora".
  2. Configuração não polla. O perfil de proficiência e o histórico de versões de instrução pollavam a 15s — 8 req/min contra o teto para duas listas que só mudam pela própria seção (que invalida) ou por patch aprovado.
  3. O sumário DEITA acima das seções, em qualquer largura. Como coluna sticky ao lado delas ele era a quarta faixa vertical (sidebar 264 + trilho 180 + sumário 208): a 1440px sobravam ~740px de conteúdo e a tabela de modelos cortava o nome do agente em 79px e o do modelo em 91px (achado G2). A coluna não tinha como pagar o próprio preço nem num monitor largo, porque .body tem teto de 1040px (ADR 0078). Deitado, ele devolve os 208px às seções; o preço é o que a janela estreita já pagava — perde-se o "onde estou" GRUDADO na tela, não a navegação. Nenhum token mudou.

Números (configuracoes.orcamento.test.tsx, Shell + ProjectPage na aba Configurações sobre um fetch que conta por rota; dev → esta regra):

CenárioAntesDepois
carga da aba (2,5s)5350
um minuto parado na aba7668
Configurações → Visão geral → volta (buscas de configuração)310
carga da aba, com os bindings resolvidos em LOTE (RN-654, AT-334)4930

Os "Depois" das três primeiras linhas foram medidos sobre a mesma base do "Antes"; integrada a dev com a RN-638 (a moldura lê a fila do projeto uma vez só), a carga ficou em 49 e o minuto em 64. A quarta linha é a RN-654, medida sobre a dev em 94e5dc721d: os 20 bindings resolvidos por chave viraram UMA leitura, e o teste guarda a carga em 30 e o minuto em 64.

O que esta regra NÃO fecha: até a RN-654, 20 das requisições da carga eram o binding RESOLVIDO de cada agente (17) e de cada área (3), uma rota por chave (GET projects/:projectId/agent-bindings/:agentSlug) — essa parte fechou com a rota de LOTE. RECARREGAR a página por seção, como o roteiro do levantamento fazia, ainda custa 30 por carga (~10 cargas por minuto antes do teto); navegar pelo sumário DENTRO da aba não remonta nada e custa zero. Os 68 do minuto são todos da MOLDURA (o Shell polla projects-summary e execution/session a 5s e os eventos da sessão de execução a 3s), comuns a toda aba — a mesma lacuna que a RN-632 declara.

  • Código: apps/web/src/lib/query-policy.ts:111 (FRESCOR_DA_CONFIGURACAO_MS); apps/web/src/lib/hooks.ts:29 (useCurrentWorkspaceWithRole), :535 (useProficiency); apps/web/src/routes/settings/InstructionVersionsSection.tsx:31 (instruction-versions, sem poll); as seções de apps/web/src/routes/settings/ e apps/web/src/components/ModelCatalogSection.tsx; apps/web/src/routes/settings/sumario.module.css:36 (.layout, a faixa)
  • Teste: apps/web/src/routes/configuracoes.orcamento.test.tsx (a carga busca cada recurso uma vez; um minuto sem poll de configuração; trocar de aba e voltar sem buscas de configuração — os três reprovam na dev — e o caso de falha: invalidar busca na hora, e passado o minuto a volta busca de novo)
  • Origem: AT-321 (achados G1 e G2 do levantamento visual da Rodada 29; extensão da RN-579 e da RN-632)

RN-654 — Os bindings resolvidos de todos os agentes e áreas vêm numa leitura só​

A RN-645 mediu 49 requisições na carga da aba Configurações, e 20 delas eram o binding RESOLVIDO de cada agente (17) e de cada área (3), uma rota por chave. Três seções liam essas chaves — modelos por agente, modelos por área e melhores modelos por capacidade —, e o teto de 300 req/min é do USUÁRIO (RN-579).

A regra:

  1. GET projects/:projectId/model-bindings/resolved?agents=…&areas=… devolve { agents: [{ key, binding }], areas: [{ key, binding }] }, na ordem pedida e sem repetição. Cada binding é EXATAMENTE o que a rota individual responderia para aquela chave (GET .../agent-bindings/:slug, GET .../area-bindings/:key): a mesma cascata workspace › projeto › área › agente sem sessão, as mesmas origens que a tela transforma em cadeia (RN-470), a herança do Criativo, e null quando nenhum nível tem modelo. Não há cascata nova: o caso de uso chama ResolveModelBindingUseCase chave a chave, com a entrada que a rota individual passaria.
  2. O papel mínimo é o das rotas individuais de leitura, viewer. Quem não alcança o projeto recebe o MESMO 403.
  3. A leitura é contida (ADR 0060): as chaves vêm em lista separada por vírgula, com o alfabeto dos slugs (minúsculas, dígitos, -, _), e são no máximo 64 somadas (TETO_DE_CHAVES_NO_LOTE). Chave malformada ou lote acima do teto é 400 nomeando o motivo, nunca chave descartada calada — a resposta afirmaria sobre menos chaves do que a tela pediu.
  4. A web lê o lote por UMA queryKey (['model-bindings-resolved', projectId]), servida por uma requisição às três seções de Configurações e, desde a AT-339, também à Visão geral e à aba Executores (useBindingsDosAgentes): os agentes do roster que o catálogo conhece saem dessa MESMA chave — o cache que uma aba aqueceu serve a outra sem requisição —, e os que o catálogo não conhece (os dev-<modulo>) saem de UMA leitura em lote a mais, sob o mesmo prefixo e em fatias do mesmo teto. Toda escrita de binding de agente ou de área relê o lote inteiro por esse PREFIXO, que alcança as duas leituras; não sobra chave por agente para invalidar. A falha dessas leituras deixa o cartão do agente sem modelo — "não sei", nunca um modelo afirmado — e não cai de volta na rota por agente.
  5. A falha do lote não vira "sem modelo". Com uma leitura só, a falha é de todas as chaves de uma vez: a seção diz o motivo UMA vez, com a frase da api e a ação de reler, e cada linha diz "não lido" no lugar da cadeia. Enquanto o lote não responde, a linha diz "lendo…". "Sem modelo em nenhum nível" fica reservado ao null que a api afirmou.

Números: a carga da aba caiu de 49 para 30 requisições (configuracoes.orcamento.test.tsx, sobre a dev em 94e5dc721d); o minuto parado segue em 64, todo da moldura. Na AT-339, a carga da aba Executores caiu de 29 para 27 e a da Visão geral de 33 para 31 (duas-abas.orcamento.test.tsx, com o roster base de três agentes — com time e módulos, a queda é de um por cartão), e ir de Configurações para a Visão geral deixou de buscar binding nenhum (antes, 3).

O que esta regra NÃO fecha: as rotas por chave continuam existindo na api (nenhuma tela as lê mais). Um dev-<modulo> cuja chave passe de 64 caracteres seria recusado pelo formato do lote, e a leitura dos extras falharia inteira — não medido em projeto real, e declarado.

  • Código: apps/api/src/application/use-cases/llm/resolve-model-bindings-em-lote.use-case.ts:12 (TETO_DE_CHAVES_NO_LOTE), :37 (lerListaDeChaves), :77 (ResolveModelBindingsEmLoteUseCase); apps/api/src/interfaces/http/llm/model-bindings.controller.ts:233 (getResolvedBindings); apps/web/src/lib/api-client.ts:1185 (getResolvedModelBindings); apps/web/src/lib/bindings-resolvidos.ts:43 (invalidarBindingsResolvidos), :69 (useBindingsResolvidos), :111 (useBindingsDosAgentes); apps/web/src/routes/ProjectOverviewTab.tsx:144 e apps/web/src/routes/ProjectExecutorsTab.tsx:155 (quem o chama); apps/web/src/components/AgentTeamGrid.tsx:46 (bindingDoAgente); apps/web/src/routes/settings/LeituraDosBindings.tsx:19 (AvisoDeBindingsNaoLidos), :40 (MarcaDeBindingNaoLido)
  • Teste: apps/api/test/interfaces/http/llm/model-bindings-em-lote.integration.spec.ts:182 (caminho feliz contra Postgres: viewer lê o lote, e cada chave é igual à rota individual), :256 (recusa por papel, o mesmo 403 da rota individual), :275 (chave malformada), :287 (lote acima do teto); apps/web/src/routes/settings/bindings-em-lote.test.tsx:150 (as duas seções renderizam de UMA chamada, sem rota por chave), :182 (a falha do lote é dita e nenhuma linha vira "sem modelo"), :217 (lendo, não "sem modelo"); apps/web/src/routes/configuracoes.orcamento.test.tsx:233 (a carga lê o lote uma vez e cabe em 30), :279 (ir para a Visão geral não busca binding); apps/web/src/routes/ProjectOverviewTab.test.tsx:472 (o modelo do cartão sai de UMA leitura em lote, sem rota por agente), :493 (o lote recusado não inventa modelo nem cai na rota por agente); apps/web/src/routes/ProjectExecutorsTab.test.tsx:524 (o dev-<modulo> fora do catálogo vem da leitura dos extras), :547 (os extras recusados deixam o cartão sem modelo); apps/web/src/routes/duas-abas.orcamento.test.tsx:334 (a carga das duas abas lê o lote uma vez e cabe em 27 e 31)
  • Origem: AT-334 (decisão do dono sobre a lacuna declarada na RN-645); AT-339 (a Visão geral e a aba Executores no lote)

RN-651 — No telefone, o painel da Sessão é gaveta, a barra quebra linha, e a tabela vira cartões​

A auditoria visual "depois" da Rodada 29 (docs/explanation/auditoria-visual-rodada-29.md, achados N1 e N3 a N8) mediu, a 390px, o que a RN-643 não tinha alcançado: na Sessão o painel "Contexto da sessão" mantinha ~320px e deixava ~70px ao fio (uma palavra por linha), e a barra jogava "Iniciar ideação" e "Encerrar" para fora da borda; em /containers sete colunas em fração sobrepunham os cabeçalhos e cortavam as ações; em "Modelos por agente" o nome do agente tinha 6px e o seletor 0px; o /status rolava de lado (413px); a busca do Dashboard virava "Bus"; o nome da sessão na lista do Criativo tinha 0px; e o trilho móvel deixava a aba ativa cortada, sem sinal de que rolava.

A regra (o corte é o mesmo useLayoutMovel da RN-643, e o desenho vem por CLASSE ou atributo que o componente põe, nunca por @media):

  1. O painel de contexto da Sessão nasce FECHADO no telefone e abre como GAVETA sobre o fio, pela direita: diálogo modal com o nome do painel, foco no X ao abrir, Esc, X e fundo fecham e devolvem o foco a quem abriu. No desktop ele nasce aberto ao lado do fio, como sempre. Cruzar o corte volta ao padrão do lado novo — um painel aberto no desktop não reaparece espremendo o fio no telefone. A exceção é a navegação que VEM ao painel (highlightEvent, o chip de evidência do Psicólogo): aí ele nasce aberto também no telefone.
  2. A barra da Sessão quebra linha, nunca corta. Saída, estado, título e o alternador do painel ficam na primeira linha; tipo, ajustes, "Iniciar ideação" e "Encerrar" descem, e descem de novo se precisar. O que a barra mostra continua decidido pela largura dela (RN-620 item 6).
  3. Toda Table vira pilha de cartões no telefone. Cada linha é um cartão e cada célula leva o rótulo da coluna ao lado do valor; o cabeçalho some. Coluna sem rótulo, ou marcada largaNoMovel (um seletor, um bloco de ações), ocupa a largura do cartão com o rótulo em cima.
  4. A página não rola de lado, e o que identifica a linha não some. O /status rola a tabela por dentro de um invólucro próprio; a busca do Dashboard ganha linha inteira; a linha da lista de sessões quebra com o nome em cima, inteiro.
  5. O trilho móvel acompanha a aba ativa e diz que rola. A ativa é trazida para a faixa ao montar, ao trocar, quando o CONTEÚDO do trilho muda (os contadores chegam depois e alargam as abas anteriores) e quando as fontes terminam de carregar — nunca a cada render, senão quem rolou a faixa à mão seria puxado de volta. A borda que esconde abas esmaece (data-rola-inicio/data-rola-fim).

Medido (390×844, os dois temas, api nativa com o seed, validacao-visual.js injetado): scrollWidth 390 em todas as sete telas; zero texto-cortado no Dashboard, na lista do Criativo, na Sessão (com e sem a gaveta), em /containers e no /status; nas Configurações os 33 caíram para zero, e a aba ativa do trilho ficou inteira dentro da faixa nas três abas medidas.

O que esta regra NÃO fecha: na barra mínima "Encerrar" continua só com o ícone e o nome acessível (a RN-620 decide isso pela largura da barra). O alternador do painel sobe para a linha do título só na PINTURA (order): a ordem do Tab segue a do DOM. O fora-da-viewport que o verificador ainda relata nas Configurações e em Aprovações são ícones decorativos com position: absolute dentro de controles abaixo da dobra, não algo cortado.

  • Código: apps/web/src/lib/painel-de-contexto.ts:16 (usePainelDeContexto); apps/web/src/routes/GavetaDoContexto.tsx:14 (GavetaDoContexto); apps/web/src/routes/SessionTopbar.tsx:134 (movel); apps/web/src/components/ui/Table.tsx:24 (largaNoMovel), :39 (movel); apps/web/src/routes/Dashboard.tsx:165 (movel); apps/web/src/routes/ProjectSessionsTab.tsx:149 (movel)
  • Teste: apps/web/src/routes/SessionPage.layout-movel.test.tsx (a gaveta nasce fechada, abre com o foco no X e o Esc fecha devolvendo o foco; X e fundo fecham; a barra quebra antes das ações; e o caso de falha, no desktop o painel fica ao lado do fio sem gaveta nem quebra; cruzar o corte fecha o painel); apps/web/src/components/ui/Table.test.tsx ("layout estreito"); apps/web/src/routes/ContainersPage.test.tsx, apps/web/src/routes/settings/modelos-por-agente-no-movel.test.tsx, apps/web/src/routes/StatusPage.test.tsx, apps/web/src/routes/Dashboard.test.tsx, apps/web/src/routes/ProjectSessionsTab.test.tsx (cada um com o móvel e o caso de falha do desktop ou da faixa que cabe). A faixa de abas do projeto (ProjectRail, achado N8) saiu no ADR 0211: as abas moram na gaveta
  • Origem: AT-328 (achado N1) e AT-330 (achados N3 a N8) da auditoria visual da Rodada 29; completa a RN-643

O autor de cada fala do fio é o ator do evento (RN-652)​

RN-652 — chat.message é desenhado sob o ATOR do evento — pessoa, agente ou desconhecido —, nunca sob quem vê a tela​

Até aqui todo chat.message saía com o nome e o avatar de quem estava logado: numa sessão compartilhada, a fala de outra pessoa aparecia com o SEU nome, e a de um agente com o seu e-mail (achado N2 da auditoria visual da Rodada 29, escondido enquanto o fio agrupava o histórico por origem). O autor sai agora do actor do evento, que a api grava com o id de quem mandou (SendAgentMessageUseCase, SendChatMessageUseCase) e que a rota genérica de anexar evento aceita de qualquer espécie. Os desfechos não se colapsam:

  1. Quem vê (actor.id igual ao sub do token; sem ele, pelo e-mail da linha de membro) — o nome ou e-mail da linha de membro, senão o e-mail da sessão, e só na falta dos dois "Você".
  2. Outra pessoa que a tela sabe nomear — o nome dela, senão o e-mail, de DUAS leituras compostas numa lista só (comporMembros), a de PROJETO antes da de WORKSPACE: GET projects/:id/members (mínimo viewer), sob a MESMA queryKey da aba de Configurações, e desde a AT-335 GET workspaces/:id/members (RN-655), do workspace DO PROJETO. Quem tem as duas linhas aparece uma vez, pela de projeto — a mesma ordem da sobreposição de papel (RN-471).
  3. Outra pessoa que a tela NÃO sabe nomear — "Outro membro": sem linha de projeto nem de workspace (quem saiu do workspace e ainda tem fala no log), ou as duas leituras falharam ou ainda não chegaram. A leitura que falha não apaga a outra, e nenhuma delas quebra o fio. Até a AT-335 esta era a LACUNA declarada de quem entra só pelo papel de workspace, que não tinha rota de leitura; a rota nasceu por decisão do dono.
  4. Agente — nomeDoAgente e o avatar/cor do agente, como agent.response.
  5. Ator desconhecido (ausente, sem id, system ou espécie fora de pessoa/agente) — "Autor desconhecido", nunca "Você".

A narração backlog.story_promotion_returned, que tinha o mesmo defeito (o nome de quem vê sobre quem devolveu a história), passa pela mesma função. A mensagem OTIMISTA do composer continua com o e-mail de quem vê — ela é sempre dele.

  • Código: apps/web/src/lib/autor-da-mensagem.ts:52 (autorDaMensagem); apps/web/src/lib/autoria-da-sessao.ts:45 (useAutoriaDaSessao), :18 (comporMembros); apps/web/src/routes/session-timeline-montagem.tsx:162 (rotuloDoAutor), :405 (o chat.message), :687 (a devolução de história); apps/web/src/routes/SessionPage.tsx:133 (autoria)
  • Teste: apps/web/src/routes/SessionPage.autor-da-mensagem.test.tsx:147 (duas pessoas e um agente, cada mensagem sob o próprio autor — caminho feliz), :168 (ator desconhecido e pessoa fora dos membros com texto próprio — caso de falha), :186 (quem entra só pelo papel de workspace é nomeado — AT-335), :205 (a leitura do workspace recusada não apaga a do projeto), :219 (leitura de membros recusada); apps/web/src/lib/autoria-da-sessao.test.ts:15 (a linha de projeto antes da de workspace), :22 (falha de uma leitura não apaga a outra); apps/web/src/lib/autor-da-mensagem.test.ts:58 (ator ausente, sem id ou de espécie desconhecida nunca vira "você")
  • Origem: AT-329 (achado N2 de docs/explanation/auditoria-visual-rodada-29.md); a lista do workspace, AT-335

A leitura dos membros do workspace (RN-655)​

RN-655 — GET workspaces/:id/members é viewer, e devolve só id, nome, e-mail e papel​

O fio da sessão (RN-652) nomeava só quem tinha linha em project_members; quem entra no projeto só pelo papel de WORKSPACE virava "Outro membro", porque não havia rota que lesse os membros do workspace (decisão do dono, AT-335). A rota nasce ao lado das três de ESCRITA de membro (POST/DELETE de membro e PUT owner-of-record, todas owner), e o mínimo dela NÃO é o delas: o mínimo é do ENDPOINT (RN-102), e ler a lista não é mantê-la. É viewer, o das leituras vizinhas (GET workspaces/:id, .../projects) e o de GET projects/:id/members, que ela completa — quem abre uma sessão precisa saber quem falou nela, e um viewer de workspace já enxerga todo projeto do workspace.

A resposta tem a MESMA forma da leitura de projeto — userId, name, email, role (o papel de WORKSPACE) — e nada além: sem createdAt, sem estado de conta. Workspace inexistente é 404, e quem não é membro é recusado pelo RolesGuard (403). Continua não havendo TELA de membros de workspace: a leitura existe para nomear, e as rotas de escrita seguem sendo de API.

  • Código: apps/api/src/interfaces/http/iam/workspaces.controller.ts:216 (listMembers); apps/api/src/application/use-cases/iam/list-workspace-members.use-case.ts:16 (execute); apps/api/src/infrastructure/persistence/drizzle/workspace.repository.ts:127 (listMembers)
  • Teste: apps/api/test/application/use-cases/iam/list-workspace-members.use-case.spec.ts:75 (a forma da resposta, contra Postgres — caminho feliz), :96 (workspace inexistente é 404), :104 (o mínimo é viewer), :112 (viewer passa pelo guard), :125 (quem não é membro recebe 403)
  • Origem: AT-335

O motivo da política em cada cartão de Aprovações (RN-656)​

RN-656 — A leitura de eventos da sessão filtra por actionId, e cada cartão da aba Aprovações lê o próprio motivo por ela​

A ação não guarda o motivo da política: ele mora no proposed_action.created (RN-567, RN-609). A aba Aprovações lê a fila do PROJETO (RN-638) e o log de UMA sessão, então desde a AT-333 ela dizia numa nota única quantas ações ficavam sem motivo (RN-614). Decisão do dono (AT-336): cada cartão mostra o PRÓPRIO.

O que se mediu. As duas saídas do card eram um filtro actionId na leitura de eventos ou uma rota que lesse o evento pela ação. O filtro é o menor: reusa a rota (GET .../sessions/:sessionId/events, viewer), o DTO e o caso de uso, e a ação já carrega o sessionId da sessão que a propôs. Não nasce índice nem migration: o predicado sobre payload->>'actionId' roda DENTRO da sessão, que o índice único (session_id, seq) já recorta. Uma leitura de PROJETO por actionId exigiria rota nova para achar a sessão que o cartão já conhece.

A regra na api. actionId devolve só os eventos cujo payload.actionId é aquela ação, na sessão pedida — nunca atravessa sessões. Combina com os outros parâmetros e o limit conta só os que casam; o seq deixa de ser contíguo na página, então quem filtra NÃO deriva omitidos por subtração (RN-180). Vazio é 400: um filtro que some com a string errada devolveria o log inteiro a quem pediu o motivo de uma ação.

A regra na tela. useDecisoesDaPolitica: a ação cujo evento está no log que a aba JÁ carregou usa esse, sem requisição; as outras leem pela ação, na sessão dela, com staleTime: Infinity — evento é imutável, então cada ação custa UMA leitura enquanto o cache viver e a fila polla sem repetir nenhuma. Nada é pedido enquanto a aba ainda carrega o próprio log. A leitura que responde SEM o evento dá a frase de "motivo não registrado" (nunca cala); só a leitura que FALHA deixa o cartão calado, e é essa, e só essa, que a nota do topo conta ("de N das M ações abaixo"). Desde a AT-340 os cartões das pendências de OUTRAS sessões no chat (RN-626, PendenciasDeOutrasSessoes, também na aba Executores) passam pelo MESMO hook e pelo MESMO cache: toda ação ali é de outra sessão, então nenhuma é coberta pelo log da tela e cada uma lê pela ação — só as DESENHADAS, dentro do teto de cards do bloco, e a que a aba Aprovações já leu não custa nada ali (e vice-versa). A leitura que falha também cala o cartão e é dita uma vez, no topo do bloco. O painel "precisa de você" NÃO usa este caminho: ele não lê log nenhum e abrir não dispara consulta, então o cartão ali segue calado, como a RN-614 já dizia — decisão, não lacuna. Nenhum teto muda.

  • Código: apps/api/src/interfaces/http/sessions/sessions.controller.ts:256 (listEvents); apps/api/src/infrastructure/persistence/drizzle/session-event.repository.ts:61 (o filtro); apps/web/src/lib/decisao-da-politica-queries.ts:35 (useDecisoesDaPolitica); apps/web/src/routes/ProjectApprovalsTab.tsx:307 (quem o chama), :481 (a nota da falha), :499 (o motivo em cada cartão); apps/web/src/components/PendenciasDeOutrasSessoes.tsx:74 (o chat chama o mesmo hook), :145 (o motivo em cada cartão), :181 (a nota da falha)

  • Teste: apps/api/test/application/use-cases/sessions/list-session-events-por-acao.use-case.spec.ts:80 (só os eventos da ação, com o motivo — caminho feliz, contra Postgres), :109 (não atravessa sessões), :123 (sessão de outro projeto é 404), :134 (actionId vazio é 400); apps/web/src/routes/ProjectApprovalsTab.test.tsx:267 (ação de outra sessão lê o motivo pela ação, e a do log carregado não vira requisição), :294 (evento ausente diz "não registrado"), :304 (a leitura que falha é dita uma vez, contando só ela); apps/web/src/components/PendenciasDeOutrasSessoes.test.tsx:204 (o cartão do chat lê o motivo pela ação, na sessão dela, e remontar não relê), :228 (a leitura que falha cala o cartão e é dita uma vez)

  • Origem: AT-336; AT-340 (as pendências de outras sessões no chat)

  • Origem: AT-329 (achado N2 de docs/explanation/auditoria-visual-rodada-29.md)

RN-659 — Projeto novo nasce com promoção de histórias automática; projeto existente fica no modo que tinha​

O default de projects.story_promotion passa de manual para auto (decisão do dono em 30/09, AT-313): cada história completa do PO nasce ready, sem o clique de promoção por história que o levantamento da AT-309 contou como +N passos. A validação é a MESMA nos dois modos (RN-048); muda só quem dispara.

A mudança é do DEFAULT da coluna, pela migration 0065 (ALTER COLUMN … SET DEFAULT 'auto'), e nada mais: nenhuma linha é reescrita, então projeto que nasceu manual continua manual até alguém trocar em Configurações. A criação NÃO manda o campo — o valor é o default da coluna, uma fonte só —, e as duas telas DIZEM o valor: o passo Confirmar da criação mostra "Promoção de histórias: automática — o PO promove", com onde mudar, e a seção de Configurações diz que projeto novo nasce em Automática e que mudar ali vale só para aquele projeto. Os dois scripts de validação que exercitam o fluxo manual (validacao-fase-12.ts, validacao-real.ts) passam a pedir manual explícito.

  • Código: apps/api/src/db/schema/iam.ts:287 (storyPromotion); apps/api/src/db/migrations/0065_default_de_promocao_auto.sql; apps/web/src/routes/NewProjectWizard.tsx:978 (a linha do Confirmar); apps/web/src/routes/settings/PromotionSection.tsx:75 (a nota do default)
  • Teste: apps/api/test/db/story-promotion-migration.spec.ts:29 (projeto novo nasce auto — caminho feliz), :40 (projeto existente em manual não muda — caso de falha); apps/web/src/routes/NewProjectWizard.test.tsx:254 (o Confirmar diz o valor); apps/web/src/routes/ProjectSettingsTab.test.tsx:625 (projeto antigo segue manual e a tela diz o default), :646 (a api recusa a troca e a tela diz que não salvou)
  • Origem: AT-313 (levantamento da AT-309, passo 5); revisa o default da RN-048

RN-660 — O handoff do PO ao Arquiteto é aceito pelo SISTEMA quando o backlog está coberto e o repositório é local e sem credencial​

A oferta po → arquiteto que o engine cria (POST /internal/sessions/:id/handoffs) é aceita sem clique, na mesma requisição e DEPOIS da transação da oferta, quando as quatro condições valem (decidirAceiteAutomatico, puro):

  1. a oferta está offered;
  2. o backlog está coberto: o projeto tem ao menos UMA regra de negócio (artifact.business_rule) e NENHUMA sem história que a cite em businessRuleIds — a MESMA computeCoverage da aba Backlog e da leitura do PO (RN-164). Zero regras não é coberto;
  3. o repositório é local e sem credencial: o projeto não tem repositório (o aceite provisiona local) ou tem um local, e não tem conexão de git (OAuth) em project_git_connections;
  4. quem abriu a sessão ainda tem developer no projeto (papel efetivo, RN-471) — o mínimo da rota humana de aceite.

Quem aceita continua sendo AcceptHandoffUseCase: o repositório nasce ali, antes de activateAgent (RN-582, ADR 0165), em nome de quem abriu a sessão. O aceite é auditável: handoff.accepted e agent.activated saem com o ator de SISTEMA handoff-auto-accept, e o handoff.accepted leva automatico: true, emNomeDe e o criterio (regras, cobertas, repositorio). Condição que falha não grava nada — a oferta fica com o botão de sempre, e a resposta ao engine diz o motivo (regras_sem_historia, repositorio_nao_local, credencial_de_git_no_projeto…). Falha DEPOIS de decidir "sim" não sobe: vira o evento handoff.auto_accept_failed (origem infra) e o fio o mostra. Com o aceite feito, a ferramenta offer_handoff diz ao PO que o Arquiteto já foi ativado. Repositório remoto ou com credencial continua pedindo o clique, e o handoff manual (ADR 0109) também.

  • Código: apps/api/src/domain/sessions/aceite-automatico-do-handoff.ts:81 (decidirAceiteAutomatico), :74 (backlogCoberto); apps/api/src/application/use-cases/agents/aceitar-handoff-automaticamente.use-case.ts:44 (AceitarHandoffAutomaticamenteUseCase); apps/api/src/application/use-cases/agents/accept-handoff.use-case.ts:91 (AceitePeloSistema); apps/api/src/interfaces/http/internal/internal-sessions.controller.ts:414 (handoff); apps/engine/lib/engine/harness/tools/offer_handoff.ex:59; apps/web/src/routes/session-timeline-montagem.tsx:604 (o aviso no fio)
  • Teste: apps/api/test/application/use-cases/agents/aceitar-handoff-automaticamente.use-case.spec.ts:73 (backlog coberto e sem repositório: aceita pelo sistema — caminho feliz), :89 (regra sem história: não aceita e não grava nada — caso de falha), :99 (repositório remoto), :109 (conexão de git), :128 (falha vira handoff.auto_accept_failed); apps/api/test/domain/sessions/aceite-automatico-do-handoff.spec.ts:37, :49 (cada motivo de recusa); apps/api/test/application/use-cases/agents/accept-handoff.use-case.spec.ts:323 (ator de sistema, critério e provisionamento antes de ativar); apps/web/src/routes/SessionPage.handoff-aceite-automatico.test.tsx:174, :196; apps/engine/test/engine/harness/tools/offer_handoff_test.exs:38 (não rodado nesta sessão: sem Elixir no ambiente)
  • Origem: AT-314, ADR 0186

RN-661 — O modo automático é OFERECIDO em lote para o time no início da execução, e a oferta diz o que ele não libera​

Na aba Executores, com a execução ativa e o time de execução na tela, um cartão oferece ligar o modo automático (RN-153) para os agentes do time de uma vez (AT-315). É UM controle, e é oferta: os agentes que ainda não estão em automático vêm marcados, a pessoa desmarca quem não quer, e nada é gravado sem o clique em "Ligar". O clique grava a MESMA curinga (agent_autonomy, actionType: "*", auto_approve) pelo MESMO endpoint do toggle por agente (PUT .../agent-autonomy, maintainer) — um PUT por agente, em série, sem abortar na primeira recusa, com desfecho POR agente nos três casos da RN-469 (todos, nenhum com a frase da api, alguns com quem ficou em manual). Desligar continua no toggle manual/auto do card de cada agente; não há "desligar em lote". Quando todos já estão em automático, o cartão some.

O cartão DIZ, antes do clique, o que o modo automático NÃO libera — os tetos absolutos seguem pedindo aprovação (RN-154, RN-418): merge em branch protegida; git push, abertura de PR e deploy; sudo/doas; container_remove; instruction_patch; paralelizar ou subir o teto de paralelismo. Sem maintainer o controle fica inerte e o motivo é dito em texto; o papel lido é o de WORKSPACE, com a mesma lacuna declarada das outras telas de modo automático (RN-471).

  • Código: apps/web/src/components/ModoAutomaticoDoTime.tsx:73 (ModoAutomaticoDoTime), :109 (ligar), :44 (agentesEmModoAutomatico); apps/web/src/routes/ProjectExecutorsTab.tsx:89 (podeLigarModoAutomatico), :308 (onde a oferta monta)
  • Teste: apps/web/src/components/ModoAutomaticoDoTime.test.tsx:54 (nada gravado ao montar; o clique grava a curinga para cada escolhido — caminho feliz), :85 (a api recusa um agente e o desfecho nomeia quem ficou em manual — caso de falha), :74 (o que não libera), :102 (sem maintainer), :108 (todos em automático, nada a oferecer)
  • Origem: AT-315 (levantamento da AT-309, linha "Modo automático")

Um clique fecha a prontidão, a necessidade e o aceite do PO (RN-657/RN-658)​

RN-657 — "Estou pronto — a necessidade está validada" fecha os DOIS gates num POST, com o product_brief produzido depois do clique​

Revisa a RN-406 por decisão do dono (30/09, AT-311): o botão do Criativo passou a se chamar "Estou pronto — a necessidade está validada" (en: "I'm ready — the need is validated"), e é o rótulo que faz do clique o julgamento de MÉRITO que o ADR 0095 exigia separado — a pessoa declara as duas coisas, com as palavras das duas. POST .../sessions/:id/readiness grava, nesta ordem:

  1. readiness.confirmed, ator a pessoa, com a marca { necessidadeValidada: true, aceiteImplicitoDoPo: true } (a marca é o que a RN-658 lê; evento antigo, com payload {}, não aceita nada);
  2. o comando ao engine (o turno que consolida o product_brief, RN-142);
  3. só se o engine ACEITOU o turno, necessity.validated, ator a mesma pessoa, com productBriefId: null (o brief ainda não existe — nasce do turno que o clique dispara), via: 'readiness.confirmed' e o readinessEventId do passo 1.

A recusa do engine (409 turno em curso, 422 sem regra de negócio) sobe ANTES do passo 3: sem turno aceito não há brief a caminho, e a necessidade NÃO fica validada. O gate segue warn (docs/gates.yml) — nada a jusante o consulta. A tela não tem mais o botão "Confirmar necessidade validada"; o título do botão habilitado diz o que o clique fecha, e o aviso de sucesso diz que o PO entra quando o resumo ficar pronto.

  • Código: apps/api/src/application/use-cases/agents/confirm-readiness.use-case.ts:26 (ConfirmReadinessUseCase), :42 (necessity.validated); apps/api/src/domain/sessions/estou-pronto.ts:27 (MARCA_DO_ESTOU_PRONTO); apps/web/src/routes/SessionComposer.tsx:315 (o título do botão); apps/web/src/lib/session-acoes-da-sessao.ts:143 (handleReadiness)
  • Teste: apps/api/test/application/use-cases/agents/confirm-readiness.use-case.spec.ts:49 (os dois eventos, com a marca e o brief por vir — caminho feliz), :72 (422 do engine: só a prontidão fica gravada — caso de falha); apps/web/src/routes/SessionPage.estou-pronto.test.tsx:150 (um clique, aviso de necessidade validada, sem botão separado), :169 (recusa: nada é dito como validado)
  • ADR: 0185, que revisa o 0095
  • Origem: AT-311 (levantamento da AT-309, achado 1)

RN-658 — O aceite do handoff Criativo→PO é implícito no "Estou pronto": os MESMOS eventos, com a pessoa como ator e a marca implicito​

Decisão do dono (30/09, AT-312): o clique separado de aceitar o PO some. O handoff Criativo→PO nasce DEPOIS do clique (é o turno que ele dispara que o oferece, CriativoServer.executar_confirm_readiness/1), então o aceite não cabe no POST do clique: ele acontece na rota interna que CRIA a oferta (POST /internal/sessions/:id/handoffs), logo depois de CreateHandoffUseCase, e passa pelo MESMO AcceptHandoffUseCase do card.

Medido antes de mudar, o aceite pelo card grava: a transição da linha para accepted, handoff.accepted (ator a pessoa, {handoffId, toAgent}), agent.activated (ator a pessoa, {agent}) e, pela RN-635, o handoff.superseded das outras ofertas ao PO no projeto. O aceite implícito grava exatamente esses, com o MESMO ator humano — quem clicou, lido do readiness.confirmed marcado —, e acrescenta a marca implicito: { via: 'readiness.confirmed', readinessEventId } no payload de handoff.accepted e de agent.activated. Nenhum evento é editado, nenhum ator de sistema entra no lugar da pessoa, e o aceite pelo card segue sem a marca.

Aceita-se só quando TODAS valem (decidirAceiteImplicitoDoPo): a oferta é criativo → po, offered, DESTA sessão, e leva como artefato o product_brief nascido DEPOIS do readiness.confirmed marcado mais recente. Fora disso a oferta fica offered e o card aparece como sempre: sessão anterior ao ADR 0185 (sem marca), brief anterior ao clique, e o handoff MANUAL, que nunca leva artefato (RN-633 — o "Estou pronto" segue na tela depois dele). A falha do aceite NÃO sobe para o engine (a oferta foi criada, e o Criativo diria "não consegui oferecer" — falso): vira agent.error durável, ator system/aceite-implicito, com origem (infra ou politica) e reason: aceite_implicito_falhou. O 400 de "não está offered" não é falha: alguém decidiu a oferta por outro caminho no intervalo, e isso já está no log. A tela passa o destinatário do composer ao PO no clique (o gesto de chamá-lo, RN-631); enquanto ele não entra, a escolha não vale e o Criativo segue recebendo. Nenhum teto se move: aceitar o PO não provisiona repositório (só o Arquiteto e o Dev Lead, RN-582), não faz merge nem push.

  • Código: apps/api/src/domain/sessions/estou-pronto.ts:62 (decidirAceiteImplicitoDoPo); apps/api/src/application/use-cases/agents/aceite-implicito-do-po.use-case.ts:37 (AceiteImplicitoDoPoUseCase), :45 (seCouber); apps/api/src/interfaces/http/internal/internal-sessions.controller.ts:414 (handoff); apps/api/src/application/use-cases/agents/accept-handoff.use-case.ts:135 (implicito no handoff.accepted); apps/api/src/application/use-cases/agents/activate-agent.use-case.ts:80 (implicito no agent.activated)
  • Teste: apps/api/test/application/use-cases/agents/aceite-implicito-do-po.use-case.spec.ts:105 (aceita em nome de quem clicou — caminho feliz), :129 (falha do aceite vira agent.error e não sobe — caso de falha), :120, :146; apps/api/test/domain/sessions/estou-pronto.spec.ts:58, :72, :83, :94; apps/api/test/application/use-cases/agents/accept-handoff.use-case.spec.ts:318 (mesmos eventos, ator humano e marca), :338 (sem marca pelo card)
  • ADR: 0185
  • Origem: AT-312 (levantamento da AT-309, passo 4)

A PR do dev agent mira dev, e o gate julga o diff dessa PR (RN-664)​

RN-664 — O trabalho dos agentes nasce de dev, a PR deles mira dev, e o gate julga o diff contra dev — os três juntos​

A política de branches do produto manda o trabalho nascer de dev (o bootstrap cria dev e qa a partir de main, RN-582), mas até aqui ninguém apontava os agentes para ela: o pr_open do dev agent não levava targetBranch e a api completava com repo.defaultBranch (main); a PR de ADR do Arquiteto nascia de main e mirava main; o working tree do projeto abria a default_branch; o gate calculava o diff contra ela; e o contexto do QA dizia "Repositório · branch main" (AT-250, item A4 do uso real de 29/09). Toda PR de agente pulava a esteira dev → qa → main.

Decisão do dono (30/09): os TRÊS passam a usar dev, juntos — mudar só um deixaria o gate julgando um diff que não é o da PR. A branch de TRABALHO é dev e NÃO é a default_branch do provider (que segue sendo a que a promoção alcança por último e continua lida onde ela é a pergunta):

  • o working tree do projeto abre dev (ensure_remoto/2 ignora o default_branch do remoto para escolher a base), e o worktree de cada dev agent nasce de dev EXPLICITAMENTE, não do HEAD — workspace inicializado antes desta regra está parado na default_branch (a marca de pronto impede re-inicializar) e ganha a dev local a partir de origin/dev, sem fetch novo;
  • o pr_open do dev agent leva targetBranch: "dev" no payload, e a api, sem o campo (ação proposta antes), completa com dev e não mais com a defaultBranch; a PR de ADR nasce de dev e mira dev;
  • o diff do gate é dev...HEAD, e a linha de contexto do projeto diz "Repositório · branch de trabalho dev".

Repositório SEM dev (adotado sem bootstrap, ou bootstrap que não chegou a create_dev_branch): NÃO há queda para a default_branch — seria o agente trabalhando e o gate julgando sobre uma base que não é a da PR. Cada ponto falha NOMEADO, pelo comportamento que já tinha para branch ausente, com uma exceção nova: o working tree recusava nada e criava uma dev LOCAL VAZIA (o caminho do bare que nunca recebeu push), marcava pronto e nunca mais re-inicializava. Agora esse caminho só vale para o remoto SEM branch nenhuma; com branches e sem origin/dev, a inicialização levanta com a mensagem nomeada (mensagem_sem_branch_de_trabalho/1, "o repositório do projeto não tem a branch dev… crie a branch… e tente de novo") e o .git é desfeito (AT-112), então a próxima tentativa, com a dev criada, inicializa normal. O worktree recusa com a mesma mensagem quando não há dev nem origin/dev; o diff falha com o erro do git nomeando a revisão; o provider recusa a PR (e a criação da branch do ADR) nomeando a ref. O infra (open_infra_pr) NÃO mudou: segue nascendo e mirando a defaultBranch.

O valor mora em DOIS lugares, de propósito, um por linguagem, e mudam juntos: BRANCH_DE_TRABALHO na api e branch_de_trabalho/0 no engine.

  • Código: apps/engine/lib/engine/projects/project_repository.ex:165 (branch_de_trabalho), :176 (branch_de_trabalho), :190 (mensagem_sem_branch_de_trabalho); apps/engine/lib/engine/actions/workspace.ex:70 (ensure_remoto), :224 (init_from_bare!), :272 (remoto_vazio?); apps/engine/lib/engine/actions/workspace/runner_git.ex:158 (add_worktree), :183 (garantir_base), :308 (remoto_vazio?); apps/engine/lib/engine/dev/worktree_manager.ex:36 (create), :138 (add_worktree), :201 (garantir_base); apps/engine/lib/engine/dev/agent_io.ex:427 (propose_pr); apps/engine/lib/engine/gates/diff.ex:29 (compute); apps/engine/lib/engine/harness/project_context.ex:29 (repo_line); apps/api/src/domain/actions/protected-branches.ts:26 (BRANCH_DE_TRABALHO); apps/api/src/application/use-cases/actions/execute-git-action.use-case.ts:208 (targetBranch); apps/api/src/application/use-cases/actions/execute-adr-pr.use-case.ts:86 (fromRef), :99 (targetBranch)
  • Teste: apps/engine/test/engine/gates/diff_test.exs:64 (o diff é só a mudança do agente, nada da main — caminho feliz), :73 (sem dev o diff falha nomeando a revisão — caso de falha), :91 (o contexto diz a branch de trabalho); apps/engine/test/engine/actions/workspace_test.exs:87, :98, :110 (repositório adotado sem dev: recusa nomeada, .git desfeito, e passa depois de criada a branch — caso de falha), :130; apps/engine/test/engine/dev/worktree_manager_test.exs:119, :127 (workspace de antes da regra), :147 (sem dev nem origin/dev); apps/engine/test/engine/dev/dev_agent_server_test.exs:114; apps/api/test/application/use-cases/actions/execute-git-action.use-case.spec.ts:184, :213; apps/api/test/application/use-cases/actions/execute-adr-pr.use-case.spec.ts:143, :151 (sem dev, failed com o motivo do provider)
  • Origem: AT-250 (item A4 da análise do uso real de 29/09), decisão do dono de 30/09

O Infra Lead não anuncia subida que não fez (RN-668)​

RN-668 — O que o turno do Infra Lead anuncia sobre a subida do container é decidido pelo código, não pelo texto do modelo​

É a RN-163 ("nada se anuncia que o código não vá executar") aplicada ao Infra Lead. No uso real de 29/09 (sessão be70, seq 282) ele escreveu que subiria o container "em paralelo" à PR de infra, e nenhum tool.call de subida veio depois (AT-264). Havia duas causas no código, e as duas fecham aqui:

  1. O lote inteiro da resposta é despachado antes do encerramento. propose_infra_pr aceita encerra o turno sem nova ida ao modelo — é o único ponto em que o CÓDIGO corta o laço com o modelo querendo continuar —, mas ela parava o despacho no MEIO do lote: uma propose_container_start (ou container_start_via_runner) pedida na MESMA resposta, depois da PR, sumia sem tool.call, sem tool.result e sem aviso. Agora é a forma do Dev Lead (propose_execution_plan): todas as chamadas da resposta rodam, e só depois a PR aceita encerra o turno. Uma segunda propose_infra_pr na mesma resposta é recusada com motivo (tool.call + tool.result com ok: false) — só a primeira consolida com o Workflows.
  2. O fecho do turno diz, com frase do SERVIDOR, quando a subida NÃO foi proposta — a forma do desfecho consolidado do Criativo (encerrar/2). A decisão é pelo que o laço executou (alguma proposta de subida ACEITA pela api neste turno?), nunca pelo que o modelo escreveu; não há leitura do texto dele. Fala-se em dois casos: o turno terminou pela PR e nenhuma subida foi proposta; ou uma subida foi tentada, recusada, e nenhuma proposta aceita veio depois. Turno que nunca tocou na subida (uma pergunta, uma correção de gate) não ganha frase nenhuma, e container já REGISTRADO running/provisioning também não (a mesma leitura local de recusa_ja_de_pe/2, sem HTTP).

O kickoff e a descrição de propose_infra_pr passam a dizer o que o código faz: a subida vem ANTES da PR ou na MESMA resposta, porque a PR encerra o turno. Isso é ajuda ao modelo, não a garantia — a garantia é o fecho do item 2.

Nada aqui sobe container nem propõe subida, com as recusas por modo e estado intactas (RN-566/RN-610). Desde a RN-671 (AT-260) a primeira subida é passo do SERVIDOR no kickoff, marcada pelo MESMO registrar_subida: no caso comum ela foi proposta e o fecho do item 2 não tem o que dizer; ele segue falando quando a subida do servidor foi recusada (ou NEGADA pela política, que desde então conta como recusa) e quando ela não cabia ao servidor (sem roteamento, runner) e o modelo não a propôs.

  • Código: apps/engine/lib/engine/infra/infra_lead_server.ex:511 (dispatch_calls), :547 (recusa_pr_repetida_no_lote), :1246 (registrar_subida), :1256 (fechar_subida), :1155

    (dispatch_calls), :539 (recusa_pr_repetida_no_lote), :1140 (registrar_subida), :1150 (fechar_subida), :1154 (desfecho_da_subida); apps/engine/lib/engine/infra/tools/propose_infra_pr.ex (a descrição da tool)

  • Teste: apps/engine/test/engine/infra/infra_lead_server_test.exs:1075 (a subida pedida depois da PR na mesma resposta é despachada — caminho feliz), :1107 (a PR encerra o turno sem subida e o fio diz — caso de falha), :1147 (subida recusada e prometida, sem proposta: o fecho diz), :1132 (container já de pé: nada a dizer), :1169 (recusa corrigida no turno: sem frase), :1190 (turno sem subida: sem frase), :1199 (segunda propose_infra_pr no lote é recusada)

  • Origem: AT-264 (uso real de 2026-09-29, item A18 da análise)

RN-669 — O escopo de caminho compara com a pasta REAL de execução: /work e o /tmp do container quando ele está de pé​

O teto de escopo do ADR 0055 comparava todo comando com a pasta do projeto no HOST, inclusive quando o comando roda DENTRO do container (RN-492/RN-502). No uso real de 29/09, os 37 pedidos "escopo" eram /work/... e /tmp comparados com /home/<usuario>/projetos-brabo/<projeto> (AT-258).

Com um container running REGISTRADO num projeto container ou mounted, a raiz do escopo é /work — o ponto de montagem, onde moram a pasta e os .worktrees do projeto — mais o /tmp do PRÓPRIO container. O cwd chega como caminho de host e é traduzido para /work pela mesma regra do engine (cwd_para_container/2): a raiz vira /work, o que está sob ela vira /work/..., o que está fora segue como veio e fica fora. Em mounted a tradução é bijetiva. Sem container, a raiz continua a pasta do projeto no host, e o /tmp do host fica FORA.

Vale para quem não está no piloto (a regra específica terminal): o piloto não passa pelo escopo (RN-603, mantida pela RN-670). projectScopeRoot não muda nem se une a permissionsFilePath (RN-478); o PISO de auto-aprovação continua só do modo container (RN-493). runner fica fora, de propósito: a api não sabe se o runner vai rotear para o container ou para o host (RN-558). Argumento de comando com caminho de HOST passa a contar como fora quando há container — o engine traduz só o cwd, e dentro do container esse caminho não existe.

  • Código: apps/api/src/domain/actions/path-scope.ts:153 (cwdNoContainer), :174 (comandoNoEscopoDoContainer), :112 (comandoNoEscopo, agora com várias raízes), :137 (PONTO_DE_MONTAGEM_DO_CONTAINER); apps/api/src/domain/actions/decide.ts:289 (execucaoNoContainer), :648 (execucaoNoContainer, em terminalNoEscopo); apps/api/src/application/use-cases/actions/propose-action.use-case.ts:174 (containerExecutionActive, separado de execucaoNoContainer)
  • Teste: apps/api/test/domain/actions/decide.spec.ts:1274 (com container /work, .worktrees, /tmp e .. dentro — caminho feliz; /etc, outro projeto, /workspace, /tmp/../etc fora; sem container /tmp e /work fora — casos de falha); apps/api/test/domain/actions/path-scope.spec.ts:216 (e a cópia de /work travada contra packages/docker-port); apps/api/test/application/use-cases/actions/propose-action.use-case.spec.ts:1141 (o modo mounted com e sem container, e sem o piso)
  • Origem: AT-258 (item A12 da análise do uso real de 29/09), decisão do dono de 01/10, ADR 0189

RN-670 — O modo automático é o PILOTO AUTOMÁTICO, e "Sempre permitir" não o desliga​

Decisão do dono de 01/10 (ADR 0189): a RN-603 NÃO é revogada. A curinga *: auto_approve aprova TUDO, inclusive o composto sintetizado do permissions.json, caminho fora da pasta, git commit e criação de branch LOCAL. Seguem fora, nunca auto-aprováveis: push/PR/merge/deploy pelo terminal e sudo/doas (RN-418), merge em branch protegida, container_remove, instruction_patch e paralelismo. deny explícito vence, e ask escrito no arquivo pede. A garantia de que isso não alcança o código do Brabo é a contenção do container — uma pasta só, em /work, estritamente abaixo da raiz do broker, sem socket do Docker nem privilégio —, provada em apps/broker/src/contencao-do-brabo.spec.ts; o que a prova NÃO cobre está declarado no ADR 0189.

"Sempre permitir" não desliga o piloto (AT-255). O clique de dev agent grava terminal: auto_approve (RN-509), e o repositório devolvia essa específica ANTES da curinga, com origem especifica — decide() deixava de reconhecer o modo automático (97 + 37 pedidos no uso real de 29/09, com o modo aceso na tela). Agora a específica auto_approve sob a curinga auto_approve resolve como a CURINGA. Específica que diz outra coisa (require_approval, deny) continua vencendo, e com o toggle de volta em manual a específica gravada pelo clique volta a valer sozinha, com o escopo. O clique continua gravando a específica; quem decide se ela já existe é o campo especifica da resolução, nunca o modo resolvido.

A tela diz o que o piloto libera e o que não libera, com UMA lista nos três lugares onde o modo é ligado: o cartão de lote dos Executores (RN-661, aberta), o ApprovalCard e o toggle do card do agente (recolhida). O "não libera" é o texto que a RN-661 já tinha, reusado.

  • Código: apps/api/src/infrastructure/persistence/drizzle/agent-autonomy.repository.ts:25 (resolve), :67 (modoEspecifico, a exceção do piloto); apps/api/src/application/ports/agent-autonomy-repository.port.ts:5 (AutonomiaResolvida, com especifica); apps/api/src/application/use-cases/actions/approve-always-action.use-case.ts:263 (especifica); apps/api/src/domain/actions/decide.ts:309 (modoAutomaticoDoAgente); apps/web/src/components/OQueOPilotoLibera.tsx:16 (OQueOPilotoLibera); apps/web/src/components/ApprovalCard.tsx:434, apps/web/src/components/AgentCard.tsx:164, apps/web/src/components/ModoAutomaticoDoTime.tsx:134
  • Teste: apps/api/test/application/use-cases/actions/approve-always-action.use-case.spec.ts:850 (o cenário medido: curinga + "Sempre permitir" segue sem pedido — caminho feliz; toggle em manual volta ao escopo — caso de falha); apps/api/test/infrastructure/persistence/drizzle/agent-autonomy.repository.spec.ts:115; apps/api/test/domain/actions/decide.spec.ts:1210 (commit e branch local livres; push, merge, PR, deploy e doas seguem pedindo); apps/broker/src/contencao-do-brabo.spec.ts:105 (a contenção); apps/web/src/components/ModoAutomaticoDoTime.test.tsx:86, apps/web/src/components/ApprovalCard.test.tsx:145, apps/web/src/components/AgentCard.test.tsx:81
  • Origem: AT-259 e AT-255 (itens A9 e A13 da análise do uso real de 29/09), decisão do dono de 01/10

RN-675 — "Sempre permitir" grava VERBO + SUBCOMANDO, um padrão por segmento do comando​

Decisão do dono de 01/10 (AT-257, que fecha a AT-170 — o ponto 6 do ADR 0055, a unidade da generalização; ADR 0189). Até aqui o clique gravava o comando INTEIRO, byte a byte (Terminal(<comando>)): o próximo comando quase nunca era igual — 173 cliques no uso real de 29/09 —, e o composto virava UM padrão cujo conteúdo só casava o PRIMEIRO segmento, então cd src && npm test liberava só cd src.

Agora cada SEGMENTO do comando (o mesmo corte de decide(): &&, ||, ;, |, &) vira um padrão, e a unidade é:

  • o verbo sozinho (ls) → o verbo;
  • verbo + palavra (npm test, git status, mix test, npx vitest) → os dois. "Palavra" é letra no começo, sem /, ., =, glob nem flag; um argumento que parece palavra (cd src) fica como subcomando — o lado ESTREITO da dúvida, porque distinguir os dois exigiria uma lista de verbos, que é o espaço que os achados Z/AD dizem não convergir;
  • verbo + argumento que não é palavra (cat src/x.ts, cd ../lib) → o verbo;
  • verbo + FLAG (ls -la src, git -C /x status) → o segmento EXATO: a forma com flag é onde verbo e invocação divergem, e generalizar ali seria escolher uma forma de dentro do código;
  • unidade que é PREFIXO de um teto da RN-418 (git remote, gh pr) → o segmento EXATO; e se até o exato é prefixo (git sozinho), nenhum padrão para aquele segmento.

O casamento continua o de sempre (prefixo de tokens), o clique sobre comando com efeito externo ou privilegiado continua recusado inteiro antes (motivoDeRecusaDoSempreAprovar, 400 teto_do_sempre_permitir), o teto de decide() roda depois do arquivo, e o escopo de caminho segue limitando ONDE fora do piloto (RN-669). Quem pode gravar é quem já podia (o papel do endpoint, inalterado); a precedência com o modo automático é a da RN-670. O "Sempre permitir" de dev agent de módulo continua indo para agent_autonomy (RN-509), sem padrão. Só os padrões que FALTAM são gravados e narrados; o permission.granted leva patterns (a lista gravada) e pattern (a mesma lista unida por vírgula, para a timeline).

  • Código: apps/api/src/domain/actions/pattern-for-action.ts:22 (patternsForAction), :62 (unidadeDoSegmento), :43 (SUBCOMANDO); apps/api/src/domain/actions/external-effect.ts:237 (padraoAlcancaTeto); apps/api/src/application/use-cases/actions/approve-always-action.use-case.ts:277 (faltam), :315 (patternsForAction)
  • Teste: apps/api/test/domain/actions/pattern-for-action.spec.ts:18 (a unidade, caminho feliz), :103 (segmento que é prefixo de teto não grava nada — caso de falha); apps/api/test/domain/actions/external-effect.spec.ts:11 (padraoAlcancaTeto); apps/api/test/application/use-cases/actions/approve-always-action.use-case.spec.ts:936 (o clique num composto libera o próximo com os mesmos verbos; outro subcomando continua pedindo; composto que empurra segue recusado); apps/web/src/components/ApprovalCard.test.tsx:253 (a nota diz a unidade)
  • Origem: AT-257 (item A11 da análise do uso real de 29/09) e AT-170 (EP-025), decisão do dono de 01/10

RN-663 — O merge de PR recusa a PR já mergeada e a proposta repetida; gate pendente é só aviso​

O uso real de 29/09 mediu os dois buracos: a pr-6 teve TRÊS git_merge executed, e a pr-4 foi mergeada com a tarefa em awaiting_qa. A api não deduplicava git_merge por PR (só a tela, e só na mesma lista), e o LocalGitProvider devolvia a PR já mergeada sem erro — o executor gravava um segundo executed para o mesmo merge.

  1. Proposta. git_merge de uma PR que uma execução anterior já mergeou (executed com state: 'merged') é 409 pr_ja_mergeado; com uma proposta VIVA da mesma PR (pending, approved ou auto_approved), 409 merge_ja_proposto. Nos dois, nada é criado. Negada ou falha não contam: quem negou ou viu falhar pode tentar de novo. A comparação é pelo pullRequestId do payload, no projeto inteiro (qualquer sessão).
  2. Aprovação. Aprovar um git_merge pendente cuja PR OUTRA proposta já mergeou é 409 pr_ja_mergeado, antes de qualquer efeito, e a ação continua pending para quem a vê negar. A irmã apenas VIVA não impede decidir esta.
  3. Provider. O LocalGitProvider recusa mergear PR já mergeada com GitPullRequestAlreadyMergedError, e o target não se move — alinhado ao GitHub, que recusa o merge repetido.
  4. Gate pendente é AVISO, nunca recusa (decisão do dono, 30/09). A aba PRs e o "Mergear" do chat dizem em TEXTO qual gate falta (qa-verificada com awaiting_qa ou gate ainda não aberto, secops-segura com awaiting_secops), e o botão e o card seguem ativos. awaiting_user, tarefa done e PR sem tarefa não avisam. O "gate bloqueado" (tarefa blocked) continua como era.
  5. Nenhum teto se move. O merge em branch protegida segue require_approval incondicional e humano (RN-418, decide.ts): esta regra só impede propor ou aprovar o que não há o que mergear. Declarado: duas propostas simultâneas da mesma PR não são serializadas (não há trava por PR), e é a regra 2 que segura a segunda.
  • Código: apps/api/src/domain/actions/merge-de-pr.ts:56 (recusaDeMerge), :40 (mergeouAPr); apps/api/src/application/use-cases/actions/propose-action.use-case.ts:124 (recusaDeMerge); apps/api/src/application/use-cases/actions/approve-action.use-case.ts:306 (recusaDeMerge); apps/api/src/infrastructure/git/local-git-provider.ts:339 (GitPullRequestAlreadyMergedError); apps/web/src/lib/gate-do-merge.ts:19 (gatePendenteNoMerge); apps/web/src/routes/ProjectPrsTab.tsx:232 (gatePendenteNoMerge); apps/web/src/routes/session-timeline-montagem.tsx:1148 (gatePendenteNoMerge) apps/web/src/routes/ProjectPrsTab.tsx:232 (gatePendenteNoMerge); apps/web/src/routes/session-timeline-montagem.tsx:1148 (gatePendenteNoMerge)

  • Teste: apps/api/test/application/use-cases/actions/propose-action.use-case.spec.ts:664 (merge_ja_proposto), :687 (pr_ja_mergeado — caso de falha), :696

    apps/web/src/routes/session-timeline-montagem.tsx:1148 (gatePendenteNoMerge)

  • Teste: apps/api/test/application/use-cases/actions/propose-action.use-case.spec.ts:664 (merge_ja_proposto), :687 (pr_ja_mergeado — caso de falha), :696 (negada não bloqueia, outra PR não colide — caminho feliz); apps/api/test/application/use-cases/actions/approve-deny-action.use-case.spec.ts:473; apps/api/test/infrastructure/git/local-git-provider.contract.spec.ts:150; apps/web/src/routes/ProjectPrsTab.test.tsx:266, :300, :315; apps/web/src/routes/MergearNoChat.test.tsx:135, :148; apps/web/src/lib/gate-do-merge.test.ts:5, :18

  • Origem: AT-249 (item A3/extra E3 da análise do uso real de 29/09)

RN-671 — A Infra sobe o container no aceite do handoff: o servidor propõe, e a autonomia semeada aprova​

Decisão do dono em 01/10 (ADR 0190), revisando o "nunca semeado" da RN-491. No uso real de 29/09 a Infra anunciou a subida "em paralelo" e não a propôs; o container só subiu pela /containers (AT-260).

  1. A semente. Aceitar o handoff endereçado a infra grava, além de open_infra_pr: auto_approve e terminal: deny, a autonomia container_start: auto_approve. Só ela entre as de container: container_start_via_runner e container_stop seguem nunca semeados, e container_remove segue no teto absoluto (RN-495).
  2. O disparo é do SERVIDOR. No kickoff do Infra Lead, ANTES da primeira ida ao modelo, havendo roteamento vigente (artifact.module_routing roteado, com candidata) e projeto container/mounted, o servidor propõe container_start pelo MESMO caminho da tool propose_container_start: as recusas locais por modo e estado (RN-566, RN-610), depois propose_action, onde a api recusa sem broker (RN-591), resolve a autonomia e, auto-aprovada, executa a subida, elegendo a imagem por DecidirImagemDoProjetoUseCase com decidedBy: 'infra-lead' (RN-491). runner não tem passo do servidor: segue pelo container_start_via_runner, proposto pelo modelo e decidido por humano.
  3. A eleição é determinística. A candidata do maior número de módulos; no empate, a primeira do roteamento. Rede none, recursos padrão, e um rationale que diz que foi o servidor. (Revisado pela RN-683: os recursos vão omitidos e a api sobe com o mínimo derivado do module_map, não com o padrão.)
  4. O rastro diz quem chamou. tool.call (com origem: "servidor") e tool.result duráveis, e nenhuma mensagem de ferramenta no histórico do modelo; o kickoff diz em texto o que JÁ aconteceu (status devolvido, ou o motivo da recusa).
  5. O fecho da RN-668 segue verdadeiro. A subida do servidor marca o turno como a da tool: proposta, o fecho não fala; recusada, ele diz que foi recusada. Proposta NEGADA pela política (status denied, como uma sessão aberta por quem não é maintainer) conta como recusa, nas duas origens.

Nenhum teto se move: o papel mínimo segue maintainer (o efetivo de quem abriu a sessão), deny de permissions.json segue vencendo, e a /containers não muda (ela propõe como humano, e a autonomia de agente não vale para ela). Declarado: o toggle manual/auto do card escreve a curinga, e a regra específica vence — pôr a Infra em "manual" não desliga esta semente.

  • Código: apps/api/src/application/use-cases/agents/accept-handoff.use-case.ts:38 (INFRA_AUTONOMY_SEEDS); apps/engine/lib/engine/infra/infra_lead_server.ex:265 (handle_cast), :716 (propor_container_start), :797 (subir_no_aceite), :800 (eleger_candidata), :1429 (passo_da_subida)

    :626 (propor_container_start), :707 (subir_no_aceite), :759 (eleger_candidata), :1319 (passo_da_subida)

  • Teste: apps/api/test/application/use-cases/agents/accept-handoff.use-case.spec.ts:250 (a semente, e só ela — caminho feliz), :267 (outro agente não semeia — caso de falha); apps/engine/test/engine/infra/infra_lead_server_test.exs:1263 (o servidor elege e propõe antes do modelo — caminho feliz), :1454 (mounted), :1464 (runner não sobe pelo servidor), :1377 (sem roteamento), :1387 (a api recusa sem broker — caso de falha), :1427 (container já running), :1439 (negada pela política), :1576 (eleger_candidata/1)

  • Origem: AT-260 (item A14 da análise do uso real de 29/09)

RN-672 — O handoff ao Dev Lead sai da Infra, e só com o container do projeto running​

Decisão do dono em 01/10 (ADR 0190, AT-262). Até aqui "Confirmar arquitetura pronta" oferecia DOIS handoffs — à Infra e ao Dev Lead (FASE 14d, handoff-duplo em docs/fluxo.yml) —, e o Dev Lead podia ser aceito antes de existir o container em que os dev agents trabalham (RN-502). No uso real de 29/09 os dois nasceram da mesma confirmação (sessão be70, seq 252/254).

  1. A confirmação de arquitetura oferece só à Infra. OfferInfraHandoffUseCase tem um destino (infra); o offerDevHandoff da porta do engine, a rota POST /internal/sessions/:id/agent/offer-dev-handoff e o :offer_dev_handoff/handoff_dev_pendente do Arquiteto deixaram de existir. ja_oferecido passa a significar "a Infra já tinha oferta ou estava ativa".
  2. Quem oferece o Dev Lead é o servidor do Infra Lead, por create_handoff_if_absent/5 (infra → dev-lead, ADR 0182): oferta pendente em qualquer sessão do projeto volta como está, e Dev Lead já ativo (409 agente_ja_ativo) não é falha. Não é ferramenta: o modelo não decide quando o Dev Lead entra.
  3. Só com o container REGISTRADO running (a leitura local de ProjectContainerLifecycle). A pergunta é feita em dois momentos: no fim de todo turno da Infra (concluir) — o caso comum, porque a subida do servidor no aceite (RN-671) executa dentro do kickoff — e quando o container.running do outbox chega fora de um turno (aprovação posterior, /containers, container_start_via_runner): o Engine.Outbox.Drain passa a enfileirar também o Engine.Workers.InfraOfereceDevLeadWorker, que avisa por PubSub o Infra Lead do projeto.
  4. Falha que não é "já atendido" é agent.error durável com origem (RN-116), sem derrubar o turno; o fim do próximo turno da Infra tenta de novo.

Fica como estava: a segunda porta do repositório no aceite ao Dev Lead (RN-582) — o aceite é o mesmo, só mudou quem oferece — e a ativação de execução encadeada no aceite (RN-161). Declarado: o aviso do outbox é at-most-once, como o Engine.Dev.Wake; sem Infra Lead de pé naquele instante, a oferta espera o fim do próximo turno dele.

  • Código: apps/api/src/application/use-cases/agents/offer-infra-handoff.use-case.ts:15 (ALVOS_DA_CONFIRMACAO); apps/engine/lib/engine/infra/infra_lead_server.ex:1163 (oferecer_ao_dev_lead), :1158 (concluir), :368 (handle_info), :1075

    :1057 (concluir), :345 (handle_info), :1074 (topico_do_container); apps/engine/lib/engine/workers/infra_oferece_dev_lead_worker.ex:29 (perform); apps/engine/lib/engine/outbox/drain.ex:107 (handlers_for)

  • Teste: apps/engine/test/engine/infra/infra_lead_server_test.exs:1490 (container running no fim do turno: oferece — caminho feliz), :1504 (sem running: não oferece — caso de falha), :1515 (o aviso fora de turno), :1532 (o aviso com turno em curso), :1544 (409 não é falha; 500 vira agent.error); apps/engine/test/engine/agents/arquiteto_server_test.exs:256 (o Arquiteto não oferece mais ao Dev Lead); apps/engine/test/engine/workers/infra_oferece_dev_lead_worker_test.exs:9; apps/engine/test/engine/outbox/drain_test.exs:266; apps/api/test/application/use-cases/agents/offer-infra-handoff.use-case.spec.ts:104, :147

  • Origem: AT-262 (item A16 da análise do uso real de 29/09)


O plano de teste nasce depois da entrega do dev (RN-674, ADR 0192)​

RN-683 — A Infra sobe o container com o MENOR recurso elegível, derivado do que o Arquiteto declara por módulo no module_map​

Decisão do dono em 01/10 (ADR 0199, AT-261). Até aqui resources omitido virava RECURSOS_PADRAO (2 cpus, 4096 MiB, 512 pids) e a subida do servidor no aceite (RN-671) o omitia sempre: todo container nascia no padrão, sem noção de mínimo.

  1. Cada módulo declara o seu. O module_map ganha resources (cpus, memoryMb, pidsLimit) por módulo — o que AQUELE módulo precisa sozinho no container. A ferramenta create_module_map o pede; a api o aceita ausente e recusa com 400, na criação do mapa: declaração pela METADE (os três ou nenhum), valor não positivo ou acima do teto, e SOMA entre módulos acima do teto. O campo mora no JSON do artefato, sem migration.
  2. O mínimo é a SOMA, campo a campo. Um projeto tem UM container (project_id UNIQUE em project_containers) e os módulos rodam nele ao mesmo tempo; o máximo entre módulos subdimensionaria o container sempre que dois estivessem de pé.
  3. Módulo sem declaração: piso no padrão de hoje. Não se inventa número. Havendo módulo sem declaração, o mínimo é o maior entre a soma dos declarados e RECURSOS_PADRAO, campo a campo, e o rationale NOMEIA quem não declarou. Sem declaração nenhuma (todo mapa anterior a esta regra), e sem module_map vigente, o mínimo é exatamente o padrão de hoje.
  4. A execução de container_start resolve campo a campo. Omitido vira o mínimo (a subida do servidor manda tudo omitido); abaixo do mínimo é failed nomeado, sem gravar decisão de imagem nem subir; acima vale até o teto, que não muda. O rationale do artifact.project_image gravado ganha "Recursos mínimos (RN-683): …" com os módulos somados e a versão do mapa.
  5. Uma régua só. Quem deriva é a api; o engine não soma nada e a subida do servidor diz no rationale que os recursos são o mínimo derivado.

Fora, declarado: choose_project_image (o Arquiteto decide a imagem e os recursos dele) e container_start_via_runner (sobe a imagem já decidida) não mudam; a recusa abaixo do mínimo acontece na execução, não ao propor.

  • Código: apps/api/src/domain/containers/recursos-minimos.ts:70 (validarRecursosDoModulo), :108 (derivarRecursosMinimos), :150 (validarSomaNoTeto), :171 (resolverRecursosDaSubida), :206 (explicarRecursosMinimos); apps/api/src/application/use-cases/architecture/create-module-map.use-case.ts:134 (normalizarModulo); apps/api/src/application/use-cases/actions/execute-container-start.use-case.ts:175 (resolverRecursosDaSubida); apps/engine/lib/engine/harness/tools/create_module_map.ex:103 (normalize); apps/engine/lib/engine/infra/infra_lead_server.ex:811 (rationale_do_servidor)
  • Teste: apps/api/test/domain/containers/recursos-minimos.spec.ts:18 (soma — caminho feliz), :35 (mapa antigo = padrão), :41 (piso e nomeado), :54 (soma acima do teto — caso de falha), :94 (abaixo do mínimo); apps/api/test/application/use-cases/architecture/create-module-map.use-case.spec.ts:190 (grava), :208 (pela metade — caso de falha), :217 (soma no teto); apps/api/test/application/use-cases/actions/execute-container-start.use-case.spec.ts:530 (subida do servidor com a soma), :556 (sem declaração), :575 (acima), :588 (abaixo — caso de falha), :608 (sem mapa); apps/engine/test/engine/harness/tools/create_module_map_test.exs:94, :109; apps/engine/test/engine/infra/infra_lead_server_test.exs:1307
  • Origem: AT-261 (item A15 da análise do uso real de 29/09)

RN-674 — A QA-estratégia escreve o plano sobre o código ENTREGUE, no começo do ciclo do qa-verificada, e o implementavel se julga sem ele​

No uso real de 29/09 o plano de teste PRE-DEV (RN-341) terminou duas vezes em toolloop.limit_reached 8/8 sem emit_plano_de_teste: o laço leu docs, RAG vazio e pastas até concluir que "o código ainda não existe". O dono decidiu (01/10) pela saída (b): o plano nasce DEPOIS da entrega. O ADR 0192 revisa o 0090 nesse ponto, e a regra tem quatro partes:

  1. O plano é o primeiro passo do ciclo de revisão. run_area/3 do QaLeadServer obtém o plano da entrega ANTES das subespecialidades (plano_de_teste_da_entrega/5): reencontra na cauda da sessão (teto da reidratação, RN-580) o artifact.plano_de_teste da MESMA taskId — a rodada de correção não paga outro laço —, e senão roda QaEstrategiaAgent.run/6 com o worktree do dev como workspace_root, as regras de negócio da task e a lista de arquivos que a entrega tocou (git diff dev...HEAD, até 40 nomes). Diff que falha vira texto ("não consegui listar: motivo"), nunca lista vazia.
  2. O plano é INSUMO, nunca veredito. Ele chega à Automação no FIM da mensagem dela, para o passo de achar qual teste cobre cada regra; a régua do veredito não muda (uma linha de coverageMatrix por regra da story, approved só com suite verde e toda regra coberta) e o contrato externo segue sendo UM qa_verdict por ciclo. Plano que FALHA não segura a revisão: a QA-estratégia grava agent.error com origem (RN-059) e a Automação recebe a mensagem de sempre, byte a byte.
  3. O implementavel se julga sobre a história e o module_map. run_assessment/2 não lê nem dispara plano: propõe o parecer na PRIMEIRA chamada, sobre o que o kickoff do Dev Lead já lhe dá, com payload storyId/parecer/justificativa. Gate, dono, severidade warn e aprovação humana não mudam; só a entrada ([story-ready, module_map]). O appsec (RN-539) segue disparando ali, no design.
  4. O teto continua 8. "qa-estrategia" não ganha cláusula em Iteracoes.tipo/1; o que mudou foi o insumo. O agente passa a rodar sob o task_budget_micros que as subespecialidades compartilham (RN-036).

run_design/3, Dispatcher.run_qa_estrategia/3 e QaEstrategiaContext saíram, e artifact.plano_de_teste passa a exigir taskId. Declarado e não decidido aqui: se os critérios executáveis do plano devem virar linhas OBRIGATÓRIAS da coverageMatrix (e portanto reprovar entrega).

  • Onde: apps/engine/lib/engine/gates/qa_lead_server.ex:190 (plano_de_teste_da_entrega), :719 (plano_de_teste_da_entrega), :720 (plano_ja_emitido), :725 (arquivos_alterados); apps/engine/lib/engine/gates/qa_estrategia_agent.ex:86 (run), :104 (token_budget_micros), :188 (descrever_arquivos); apps/engine/lib/engine/gates/qa_automacao_agent.ex:226 (com_o_plano); apps/engine/lib/engine/agents/dev_lead_tools.ex:457 (run_assessment), :538 (propor_parecer); apps/engine/lib/engine/harness/artifact_schemas.ex:59 (taskId); docs/gates.yml (qa-verificada, implementavel); docs/fluxo.yml (qa-estrategia, area-qa, dev-lead)
  • Teste: apps/engine/test/engine/gates/qa_lead_server_test.exs, describe "plano de teste da entrega (ADR 0192)" — o plano antes da Automação e dentro da mensagem dela (caminho feliz), o reuso por taskId, o plano de outra task que não conta, e o plano que falha sem segurar a revisão (caso de falha); apps/engine/test/engine/gates/qa_estrategia_agent_test.exs (os arquivos da entrega na mensagem, o diff que falhou, taskId no artefato); apps/engine/test/engine/agents/dev_lead_tools_test.exs, describe "assess_implementability (ADR 0090, insumo do ADR 0192)" (parecer na primeira chamada, payload sem plano); dev_lead_server_test.exs ("assess_implementability pending TAMBÉM suspende"); apps/engine/test/engine/harness/artifact_schemas_test.exs ("sem taskId é rejeitado"). ExUnit: provado só no CI (o repo.hex.pm deu 403 no ambiente da rodada).
  • Origem: AT-269 (item A23 da análise do uso real de 29/09); decisão do dono em 01/10

O git credenciado roda no host do runner (RN-676)​

RN-676 — Com container ativo, o git fetch credenciado de RunnerGit roda no HOST do runner, marcado pelo engine; o código continua no container​

Decisão do dono (01/10), registrada no ADR 0193: operação de git CREDENCIADA roda no HOST do runner, e o código roda no container. Fecha a metade que a RN-558 deixou aberta — o fetch autenticado em modo runner com o container de pé, que é o caminho comum, terminava sempre na recusa nomeada.

  1. Quem marca é o engine, e só num ponto. RunnerGit.fetch!/3 despacha o fetch com credencial por RunnerRouter.exec_git_credenciado/5, que põe gitCredenciado: true no payload exec e exige env não vazio por guarda. O comando de terminal do dev agent passa por RunnerRouter.exec/4 e nunca leva a marca. Sem credencial (provider local), o fetch vai sem marca.
  2. O runner roda no host só com a CONJUNÇÃO: a marca true literal e env não vazio. Aí o comando vai a executarComando (o env mesclado sobre process.env), mesmo com container ativo. A pasta é a mesma: estado.dir é o bind-mount de /work.
  3. env sem a marca não ganha o host. Com container ativo, esse par segue RECUSADO com a marca da RN-558 e origem politica. Hoje nenhum chamador do engine o produz; na prática ele chega de um brabo-runner anterior ao ADR 0193, que não lê a marca — e a mensagem do engine diz para atualizá-lo. A marca sem env também não muda nada: o comando vai ao container.
  4. Nenhuma contenção muda: DockerPort.exec segue sem env (ADR 0130), e RunnerReadiness (RN-507) fica byte a byte — a marca decide ONDE o runner executa, nunca SE o engine despacha.
  5. O log nunca traz nome nem valor de variável do env: a recusa dá a contagem, e o log do caminho host acrescenta só [git credenciado, no host].
  • Código: apps/runner/src/index.ts:701 (gitCredenciadoNoHost), :666 (tratarExec); apps/runner/src/channel.ts:71 (gitCredenciado), :627 (gitCredenciado); apps/engine/lib/engine/runners/runner_router.ex:93 (exec_git_credenciado), :98 (despachar_exec); apps/engine/lib/engine/actions/workspace/runner_git.ex:338 (exec), :426 (despachar); apps/engine/lib/engine_web/channels/terminal_channel.ex:609 (git_credenciado); apps/engine/lib/engine/runners/credencial_de_git.ex:93 (mensagem)
  • Teste: apps/runner/src/index-handlers.spec.ts:366 (o helper real do git recebe a credencial no host com container ativo — caminho feliz), :391 (um git fetch origin real sucede no host), :430 (env sem a marca segue recusado — caso de falha); apps/runner/src/channel.spec.ts:208; apps/engine/test/engine/actions/workspace_runner_test.exs:451, :466, :476; apps/engine/test/engine_web/channels/credencial_no_runner_test.exs:149 (AT-111: a corrente pelo TerminalChannel real termina em {:ok, _}), :177. Os testes ExUnit não rodaram no ambiente desta entrega (repo.hex.pm 403); o CI os prova.
  • Origem: AT-116 (prova: AT-111), decisão do dono de 01/10

RN-673 — A mensagem que chega com turno em curso entra numa fila persistida no log e é lida no fim do turno, N mensagens num turno só​

Até aqui a mensagem que chegava a um agente conversacional no meio de um turno era 409 turno_em_andamento (RN-578): gravada como chat.message (a api grava antes de falar com o engine) e nunca lida. O uso real de 29/09 mediu três numa sessão só — PO, Infra Lead e Dev Lead. Decisão do dono (01/10), para os SETE conversacionais pelo mesmo caminho:

  1. Fila, não recusa. Com turno em curso, a mensagem entra na fila do agente (TurnoAssincrono.receber_mensagem/4), o agente grava chat.message_queued (mensagemId, texto, idioma, posicao) e o clique recebe 202 com {entrega: "enfileirada", posicao}. Sem turno, o turno sobe na hora, como sempre (entrega: "lida"). A rota da api devolve também o mensagemId — o id do chat.message.
  2. N mensagens = 1 turno. No FIM do turno (finalizar/1, nunca suspender/1) as pendentes viram UM turno, na ordem de chegada, num texto só (FilaDeMensagens.texto_do_turno/1: uma é ela mesma; várias, numeradas), e o agente grava chat.message_delivered com os ids. A entrega é uma mensagem a si mesmo, depois do fecho do servidor (a correção de gate do Infra Lead e o handoff do Arquiteto vêm primeiro). A mensagem que chega entre o fim do turno e a entrega entra no FIM da fila, e a fila é entregue na hora.
  3. Teto de 10 pendentes por agente e sessão. A 11ª é 409 fila_de_mensagens_cheia, com agent.error durável de origem politica — ela está gravada e não será lida.
  4. Cancelar. POST …/agents/:agent/messages/:messageId/cancel (developer, o mínimo da rota de mensagem): só quem ENVIOU (403 mensagem_de_outra_pessoa), só em sessão que aceita conversa (RN-581). Quem decide se ela ainda está na fila é o processo do agente, que serializa a corrida com a entrega: 409 mensagem_fora_da_fila quando já foi lida ou cancelada. O engine grava chat.message_cancelled em nome de quem cancelou; com o agente fora do ar, grava direto (a fila é o log).
  5. Persistida no log, nunca em tabela. Pendente = enfileirada e nem entregue nem cancelada — a mesma derivação no engine e na tela. O init/1 dos sete reconstrói a fila e agenda a entrega; a RN-580 tira do histórico a cancelada e a pendente do próprio agente; no boot, depois de fechar o turno órfão (RN-586), o agente com fila pendente é acordado. O turno interrompido continua nunca refeito.
  6. Turno suspenso em aprovação (Dev Lead, RN-284) segue recusando com aguardando_aprovacao — sem fim previsto, enfileirar prometeria leitura sem data. A fila que já existia espera a retomada terminar.
  7. A tela. Com turno em curso o composer não trava mais para mensagem a agente: o botão vira "Pôr na fila" e a mensagem não arma turno novo. No fio, "na fila de " com Cancelar para o autor, e "cancelada" riscada. O chat sem agente segue travando.

A recusa turno_em_andamento continua para o que não é fala: revisão de história do PO, prontidão do Criativo, oferta de handoff do Arquiteto.

  • Código: apps/engine/lib/engine/agents/turno_assincrono.ex:322 (receber_mensagem), :245 (tratar_resultado), :506 (entregar_fila), :367 (cancelar_mensagem), :412 (fila_ao_subir), :542 (emitir_recusa_por_fila_cheia); apps/engine/lib/engine/agents/fila_de_mensagens.ex:71 (pendentes), :97 (fora_do_historico), :143 (texto_do_turno), :164 (acordar_pendentes); apps/engine/lib/engine/agents/reidratacao.ex:160 (fora_do_historico); apps/engine/lib/engine/agents/dev_lead_server.ex:254 (_mensagem_id); apps/engine/lib/engine_web/controllers/agent_command_controller.ex:405 (cancel_queued_message), :468 (responder_ao_aceite); apps/api/src/application/use-cases/agents/cancel-queued-agent-message.use-case.ts:35 (CancelQueuedAgentMessageUseCase); apps/api/src/infrastructure/http-clients/api-to-engine-client.ts:69 (entregaDaResposta); apps/api/src/domain/sessions/conversa-em-sessao-encerrada.ts:72; apps/web/src/lib/fila-de-mensagens.ts:27 (estadosNaFila); apps/web/src/lib/enfileirar-mensagem.ts:25 (useEnfileirarMensagem); apps/web/src/routes/SeloDaFila.tsx:22 (SeloDaFila)
  • Teste: apps/engine/test/engine/agents/fila_de_mensagens_test.exs:156 (entra na fila — caminho feliz), :181 (N mensagens = 1 turno), :223 (a que chega na janela vai ao fim), :246 (teto de 10 — caso de falha), :276 e :306 (cancelar e cancelar fora da fila), :322 (restart), :349 (Dev Lead suspenso), :78/:120 (derivação e reidratação); apps/engine/test/engine_web/controllers/agent_command_controller_test.exs:70; apps/api/test/application/use-cases/agents/cancel-queued-agent-message.use-case.spec.ts:40, :61 (outra pessoa — caso de falha), :95, :106; apps/api/test/application/use-cases/agents/send-agent-message.use-case.spec.ts:74; apps/api/test/infrastructure/http-clients/api-to-engine-client.spec.ts:531; apps/web/src/routes/SessionPage.fila-de-mensagens.test.tsx:163, :189 (fila cheia), :207, :233; apps/web/src/lib/fila-de-mensagens.test.ts:17
  • Origem: AT-267 (item A21 da análise do uso real de 2026-09-29), decisão do dono de 01/10; ADR 0191

Complementada pela RN-924. A mesma ideia (estado da fila no log, reconstruído no init/1) vale para a correção de gate do Infra Lead, e a fila de mensagens espera o fim do turno retomado (RN-920).

RN-677 — Aceitar o Dev Lead só o traz para planejar; APROVAR o plano dele é o que ativa a execução​

Revisa a RN-161 (decisão do dono, 01/10): aceitar o handoff ao Dev Lead deixou de encadear POST .../execution/activate, para TODO papel. O atalho explícito "Ativar execução" do card (RN-137) e o da Visão Geral continuam — são um clique próprio, não o aceite.

O uso real de 29/09 mediu a inversão: a execução foi ativada no aceite do handoff às 06:45:01 — o web encadeava a ativação para maintainer/owner (session-acoes-de-handoff.ts, RN-161) —, e o plano do Dev Lead, proposto às 06:47:47, ficou pending e nunca foi usado. O plano não tinha consumidor: propose_execution_plan não tinha pipeline de execução e, aprovado, ficava approved para sempre. Medido em dev antes de mudar: o backend do aceite (AcceptHandoffUseCase) NUNCA emitiu execution.activated; quem o emite é só ActivateExecutionUseCase, chamado só pela rota execution/activate.

A regra:

  1. O aceite do Dev Lead só o ativa para planejar. handleAcceptHandoff aceita e mais nada; o AcceptHandoffUseCase não muda — segue provisionando o repositório como segunda porta idempotente (RN-582).
  2. Aprovar o propose_execution_plan ATIVA a execução. A ação ganhou executor na api, ExecuteExecutionPlanUseCase, chamado pela aprovação manual (ApproveActionUseCase) e pela auto-aprovação (ProposeActionUseCase — o tipo não está nos tetos absolutos de decide.ts, então PODE ser configurado auto-aprovável). Ele grava o módulo das tarefas (RN-678) e chama o MESMO ActivateExecutionUseCase do botão — nenhuma segunda régua: o 409 sem repositório (RN-582), o 409 de execution.activated em sessão consultiva (RN-097) e a reativação idempotente por findActiveExecutionSession continuam lá, intocados.
  3. Quem ativa é quem APROVOU (decidedBy); na auto-aprovação, quem abriu a sessão — contra quem decide() resolveu o papel. Os dois exigem maintainer, o mesmo mínimo de execution/activate, então a resolução do papel efetivo dos git_* dos dev agents (o createdBy da sessão de execução) não muda de dono.
  4. A sessão do Dev Lead NÃO fecha (não é passada como originSessionId, RN-135): é nela que ele retoma o turno suspenso e narra o desfecho.
  5. O desfecho é nomeado e durável. A ação termina executed com { sessaoDeExecucao, modulos, tarefasAtribuidas } ou failed com o motivo (a frase da api, ex.: o 409 sem repositório), mais o evento execution.plan_applied/execution.plan_failed na timeline. O Dev Lead lê o desfecho real ao retomar ("execução ATIVADA na sessão …" ou "a ativação falhou: …"), nunca "plano registrado". Falhar não desfaz a aprovação: a decisão do humano já está gravada.
  • Onde: apps/api/src/application/use-cases/execution/execute-execution-plan.use-case.ts:89 (execute); apps/api/src/application/use-cases/actions/approve-action.use-case.ts:216 (executeExecutionPlan); apps/api/src/application/use-cases/actions/propose-action.use-case.ts:401 (executeExecutionPlan); apps/web/src/lib/session-acoes-de-handoff.ts:91 (handleAcceptHandoff); apps/engine/lib/engine/agents/dev_lead_tools.ex:220 (classificar); apps/engine/lib/engine/agents/dev_lead_server.ex:699 (texto_do_desfecho); apps/web/src/lib/aprovacoes.ts:327 (propose_execution_plan)
  • Teste: apps/api/test/application/use-cases/execution/execute-execution-plan.use-case.spec.ts:106 (aprovado ativa como quem aprovou — caminho feliz), :129 (auto-aprovado: quem abriu a sessão), :151 (ativação recusada por falta de repositório vira failed com a frase — caso de falha); apps/api/test/application/use-cases/actions/approve-deny-action.use-case.spec.ts:399 (aprovar chama o executor e avisa o Dev Lead); apps/web/src/routes/SessionPage.handoff-devlead-e-colapso.test.tsx:396 (aceitar não ativa, nos três papéis), :416 (o atalho explícito continua); apps/web/src/lib/aprovacoes.test.ts:118 (a frase diz que aprovar ativa); apps/engine/test/engine/agents/dev_lead_tools_test.exs:134, :141; apps/engine/test/engine/agents/dev_lead_server_test.exs:426, :457
  • Decisão arquitetural: ADR 0194
  • Origem: AT-263 (item A17 da análise do uso real de 29/09)

RN-678 — A tarefa pertence a um módulo, atribuído pelo Dev Lead no plano, e só o dev daquele módulo a pega​

No uso real de 29/09 dev-input-keyboard pegou "Implementar loop de queda automática da peça" e dev-board-engine pegou "Mapear teclas…": a tarefa não tinha módulo, a história tinha vários, e claimNext deixava qualquer dev de qualquer módulo da história pegar qualquer tarefa dela (s.module_ids ? $1).

A regra:

  1. A tarefa tem módulo — tasks.module, text nulo (migração 0068_modulo_da_tarefa). Quem o atribui é o Dev Lead, no plano de execução: propose_execution_plan ganhou tarefas: [{ taskId, modulo }], e o kickoff dele lista as tarefas pendentes do backlog do PROJETO com o task_id e os módulos da história.
  2. O conjunto válido é o module_map vigente. A api lê o plano (lerPlanoDeExecucao, função pura) na PROPOSTA e recusa com 400 plano_de_execucao_invalido, sem criar a ação, quando: o plano não tem tarefas; uma tarefa não tem taskId ou está sem módulo; o módulo (da tarefa ou do plano) está fora do module_map; a tarefa aparece duas vezes; ou não existe neste projeto. A frase nomeia a tarefa, o módulo e os módulos válidos, e volta ao Dev Lead como erro da ferramenta (entrada do laço, RN-163), para ele corrigir.
  3. A aprovação relê o plano contra o module_map daquele momento (ele pode ter mudado): recusa vira failed sem gravar nada e sem ativar. Passando, os módulos são gravados ANTES da ativação, para nenhum dev agent recém-subido procurar tarefa sem dono.
  4. Só o dev daquele módulo pega. claimNext e countClaimableByModule usam o MESMO predicado (daTarefaDoModulo): tarefa com módulo é de quem tem aquele módulo, e de mais ninguém — os module_ids da história deixam de decidir. Tarefa sem módulo só é pegável quando a história tem UM módulo só, e ele é o do dev: é o único caso em que "o dev daquele módulo" não é ambíguo, e é o caminho de quem ativou pela Visão Geral sem plano. Tarefa sem módulo de história com vários espera o próximo plano.

Fica de fora, declarado: tarefa criada DEPOIS do plano nasce sem módulo (o PO não atribui); o aviso task.became_claimable segue roteado pelos módulos da HISTÓRIA (acordar um dev a mais não pega nada, só gasta uma consulta); e o agentes por módulo do plano segue informativo — a ativação sobe um dev por módulo do module_map, como antes, e o paralelismo extra continua pelo parallelize (RN-083).

  • Onde: apps/api/src/domain/execution/plano-de-execucao.ts:33 (lerPlanoDeExecucao); apps/api/src/infrastructure/persistence/drizzle/backlog.repository.ts:680 (daTarefaDoModulo), :403 (claimNext), :391 (assignModules); apps/api/src/application/use-cases/actions/propose-action.use-case.ts:141 (recusaNaProposta); apps/api/src/application/use-cases/execution/execute-execution-plan.use-case.ts:67 (recusaNaProposta); apps/api/src/db/schema/backlog.ts:187 (module); apps/engine/lib/engine/agents/dev_lead_tools.ex:372 (tarefas_do_backlog); apps/engine/lib/engine/agents/dev_lead_tools.ex:204 (erro_da_proposta)
  • Teste: apps/api/test/domain/execution/plano-de-execucao.spec.ts:22 (caminho feliz), :41 (tarefa sem módulo — caso de falha), :49 (módulo fora do module_map), :80 (repetida e de outro projeto); apps/api/test/application/use-cases/execution/claim-next-task.use-case.spec.ts:127 (história com dois módulos: só o dev do módulo da tarefa), :152 (sem módulo e vários módulos: ninguém), :169 (o módulo da tarefa vence); apps/api/test/application/use-cases/actions/propose-action.use-case.spec.ts:289 (400 nomeado, sem proposta); apps/api/test/application/use-cases/execution/execute-execution-plan.use-case.spec.ts:135 (módulo fora do mapa na aprovação: nada gravado, nada ativado); apps/engine/test/engine/agents/dev_lead_tools_test.exs:110, :118; apps/engine/test/engine/agents/dev_lead_server_test.exs:131
  • Decisão arquitetural: ADR 0194
  • Origem: AT-274 (item A28 / extra E5 da análise do uso real de 29/09)

A Anamnese religada: sujeito elegível e fato do perfil (RN-680)​

RN-680 — A Anamnese não roda sem sujeito elegível, e a hipótese que a própria pessoa aceitou vira fato do perfil dela no grafo e entra no contexto dos agentes que conversam com ela​

Decisão do dono em 01/10 (AT-277): religar a Anamnese com correções. No uso real de 29/09 ela rodou seis vezes e as seis terminaram em anamnese.run_skipped ("nenhum membro elegível"), escritos pelo MODELO depois de uma chamada paga (8 933 micros), e as três hipóteses aceitas ficaram na fila sem destino. Ver o ADR 0196.

  1. Sem sujeito elegível, a rodada não roda. Sujeito é o membro EFETIVO do projeto (projectRole ?? workspaceRole, a régua da RN-471), fora do opt-out, com interação PRÓPRIA no que a rodada mostra ao modelo — evento dele com actor_kind: "user" na janela, ou decisão dele. A guarda vem ANTES da triagem e nem a hipótese na fila a atravessa. Sem sujeito: nenhuma chamada ao LLM e nenhuma busca no RAG; o motivo vai NOMEADO para o log do engine (sem_sujeito_elegivel, com nenhum_membro ou nenhuma_interacao_propria e os números) e, na rodada pedida à mão, também para o event log (anamnese.run_skipped com causa: "sem_sujeito_elegivel"). Pelo tick é só log — seria um aviso a cada 15 minutos por projeto. O prompt passa a listar só os sujeitos.
  2. Membro do projeto é o EFETIVO. Criar projeto não grava project_members, e a Anamnese lia só essa tabela — o dono do workspace nunca era membro. Os três casos de uso da Anamnese passam a ler listEffectiveMembers; listMembers continua só project_members.
  3. A hipótese ACEITA pela PRÓPRIA pessoa vira fato do perfil. O psychologist.hypothesis_accepted carrega projectId, sujeito (o autor da sessão analisada), hipotese, sugestao e fatoDoPerfil, e o GraphEventTranslator — o mesmo do projetor e de grafo:reprojetar — o traduz para (:FatoDoPerfil {hypothesisId}), ligado à pessoa (SOBRE) e ao projeto (NO_PROJETO). fatoDoPerfil só é true quando quem aceitou é o sujeito: um fato sobre a pessoa nunca nasce do clique de outra, e o payload diz por quê (motivoSemFato). A recusada fica só registrada, como antes. Os aceites gravados antes desta RN não têm os campos e não viram fato.
  4. O fato entra no contexto dos agentes que conversam com a pessoa. A mensagem do autor a um agente conversacional leva os fatos DELE neste projeto — lidos por QueryUserContextUseCase, escopados ao projeto, os 5 mais recentes, com o total ao lado e o recorte dito —, e o engine os põe no turno como mensagem system EFÊMERA, pelo mesmo caminho do idioma (RN-622): nunca no histórico, antes da orientação de idioma, com teto de 2 000 caracteres. Grafo fora do ar vira log, e o turno segue sem os fatos. Turno sem autor não recebe fatos.
  5. Religada. ANAMNESE_ENABLED volta ao default true no runtime.exs e nos três composes; START_ANAMNESE não muda (o de produção segue false, divergência já declarada). O Psicólogo segue pausado.
  • Código: apps/engine/lib/engine/anamnese/elegibilidade.ex:59 (avaliar); apps/engine/lib/engine/workers/anamnese_worker.ex:63 (maybe_analyze), :97 (narrar_sem_sujeito); apps/api/src/domain/iam/membros-efetivos.ts:16 (membrosEfetivos); apps/api/src/infrastructure/persistence/drizzle/project.repository.ts:209 (listEffectiveMembers); apps/api/src/application/use-cases/execution/accept-hypothesis.use-case.ts:75 (fatoDoPerfil); apps/api/src/application/graph-projection/graph-event-translator.ts:171 (projectProfileFact); apps/api/src/application/use-cases/graph/query-user-context.use-case.ts:68 (fatos); apps/api/src/domain/graph/perfil-do-autor.ts:30 (textoDoPerfilDoAutor); apps/api/src/application/use-cases/agents/send-agent-message.use-case.ts:111 (resolverPerfil); apps/engine/lib/engine/harness/perfil_do_autor.ex:68 (anexar); apps/engine/lib/engine/harness/idioma_da_resposta.ex:253 (com_idioma_do_autor); apps/engine/config/runtime.exs:315 (anamnese_enabled?)
  • Teste: apps/engine/test/engine/anamnese/elegibilidade_test.exs:17 (caminho feliz), :35 e :46 (sem sujeito — casos de falha); apps/engine/test/engine/workers/anamnese_worker_test.exs:305 (sem membro, nem com fila, não chama o LLM), :324, :343 (rodada à mão narra), :360; apps/engine/test/engine/anamnese/context_builder_test.exs:82; apps/api/test/infrastructure/persistence/drizzle/project-effective-members.repository.spec.ts:69; apps/api/test/application/use-cases/execution/accept-dismiss-hypothesis.use-case.spec.ts:136, :156 (aceite de terceiro não vira fato); apps/api/test/application/graph-projection/graph-projector.spec.ts:361, :386; apps/api/test/application/use-cases/agents/send-agent-message.use-case.spec.ts:181, :207 (grafo fora do ar); apps/engine/test/engine/harness/perfil_do_autor_test.exs:14; apps/engine/test/engine_web/controllers/agent_command_controller_test.exs:428
  • Origem: AT-277 (item A31 da análise do uso real de 29/09; decisão do dono em 01/10)

RN-682 — A consultiva sem agente pede um agente: o composer não envia sem destinatário, e a rota de chat recusa com nome​

Até aqui, a mensagem de uma sessão consultiva sem agente ia ao SSE de POST .../chat (SendChatMessageUseCase), que manda ao modelo vinculado só o texto atual — sem histórico, sem prompt de sistema, sem ferramenta — e grava a resposta com o NOME DO MODELO como ator. Medido no uso real de 29/09: a sessão 5d66 respondeu "não tenho acesso a conversas anteriores". A decisão do dono (01/10) é que, sem agente ativo, a consultiva pede para escolher um agente — a mesma régua da RN-584 (nenhum destinatário padrão) chegando ao último caso que ainda tinha um: o modelo cru.

A medição, antes do conserto: a rota de chat tinha UM cliente, a tela (handleSend, só quando não havia destinatário — o que, desde a RN-631, só acontecia na consultiva sem agente: a criativa sempre tem o Criativo como opção). O curl que o seed imprime é dica de desenvolvimento, numa sessão criativa. O caso de uso tem um SEGUNDO consumidor legítimo: os smokes de provider (*-provider.smoke.spec.ts) o usam direto como instrumento de ponta a ponta (credencial → chamada → metering) numa sessão sem agente. Por isso a recusa da api mora na ROTA, e o caso de uso não muda.

A regra:

  1. A tela não envia sem destinatário. O "Enviar" e o Enter ficam travados com zero opções, como já ficavam com duas sem escolha; o ramo do SSE saiu de handleSend.
  2. A linha do destinatário MOSTRA quem pode ser chamado. Sem opção, o seletor do handoff manual (RN-440) muda de lugar para dentro dela — "Para", a lista, e "Chamar" —, e o seletor de cima some para não haver a mesma escolha em dois lugares. A lista são os agentes que conversam (AGENTES_DE_CHAT, os que o engine casa com cláusula própria) e que o handoff manual alcança, menos quem já entrou e menos o Criativo: a consultiva promete no convite que ele não entra, porque abrir a ideação é o que a criativa faz (RN-097). Chamar oferece o handoff; aceitá-lo no fio faz do agente o destinatário (RN-631).
  3. A api recusa o mesmo caso para qualquer cliente. POST .../chat numa sessão consultiva em que nenhum agent.activated foi gravado é 422 {reason: "destinatario_ausente"}, com a frase que aponta .../agents/:agent/message, ANTES de qualquer efeito: nada é gravado e nenhum modelo é chamado. A recusa sai como resposta HTTP e não como quadro error dentro de um stream 200 — o handler devolve Promise<Observable>, e a rejeição antes do cabeçalho do SSE vira a resposta normal.

O que esta regra NÃO fecha, declarado:

  • A rota segue respondendo pelo modelo cru fora desse caso: numa criativa sem agente (o curl do seed) e numa consultiva COM agente, para quem a chamar à mão. A tela não a chama em nenhum dos dois; a decisão do dono foi sobre a consultiva sem agente, e fechar a rota inteira é decisão à parte.

  • O chat livre com histórico, a outra opção da AT-254, não foi feito.

  • Código: apps/api/src/domain/sessions/chat-sem-destinatario.ts:24 (chatSemDestinatarioRecusado), :17 (MOTIVO_CHAT_SEM_DESTINATARIO); apps/api/src/application/use-cases/llm/garantir-destinatario-do-chat.use-case.ts:27 (GarantirDestinatarioDoChatUseCase); apps/api/src/interfaces/http/llm/chat.controller.ts:82 (chat); apps/web/src/lib/session-destinatario.ts:347 (agentesParaChamar); apps/web/src/routes/SessionPage.tsx:678 (handleSend); apps/web/src/routes/SessionComposer.tsx:276

  • Teste: apps/api/test/application/use-cases/llm/garantir-destinatario-do-chat.use-case.spec.ts:82 (consultiva sem agente — caso de falha), :96, :113, :120; apps/api/test/interfaces/http/llm/chat.controller.spec.ts:56 (422 como resposta HTTP), :79 (caminho feliz); apps/web/src/routes/SessionPage.destinatario-do-chat.test.tsx:458 (não envia — caso de falha), :471 (lista e Chamar — caminho feliz); apps/web/src/lib/session-destinatario.test.ts:93; apps/web/src/routes/SessionPage.ideacao-automatica.test.tsx:177

  • Origem: AT-254 (item A8 da análise do uso real de 2026-09-29), decisão do dono de 01/10

RN-684 — O contrato entre módulos é artefato versionado do Arquiteto, e o dev agent o lê pela ferramenta, nunca no worktree de outro dev​

Decisão do dono (01/10), registrada no ADR 0200. No uso real de 29/09, dev-board-engine gastou 5 dos seus 24 passos com ferramenta lendo o worktree de outros módulos para descobrir a interface deles: o module_map diz quem depende de quem, nunca o que um módulo expõe. Aplica a RN-164 aos dev agents.

  1. O artefato é artifact.module_contracts, sem tabela, como o module_routing e o c4_diagram: o vigente é o de maior version, com desempate por seq. Cada declaração leva a lista INTEIRA e SUBSTITUI a anterior; mudar contrato é versão nova do Arquiteto.
  2. Por módulo, só o que ele EXPÕE: {modulo, expoe: [{tipo, assinatura, descricao}]}, tipo em funcao | rota | evento | dado, assinatura obrigatória (até 300 caracteres), de 1 a 40 itens. O que ele CONSOME é o dependsOn do module_map vigente, derivado na leitura — nunca redigitado.
  3. A api recusa com 400 e o motivo inteiro (que volta ao modelo, RN-061): lista vazia, módulo repetido, módulo fora do module_map vigente (listando os válidos) ou sem module_map, expoe vazio ou acima de 40, tipo desconhecido, assinatura vazia ou acima de 300. Nada é gravado.
  4. Artefato SEPARADO do module_map, nunca um campo dele: revisar um não reemite o outro.
  5. O dev lê por listar_contratos_de_modulos, :direct, no registro de Engine.Dev.Tools: escopo do projeto pela rota, NENHUM parâmetro, o módulo do dev vindo de ctx.module. Mostra por inteiro o dele e os que ele consome; quem o consome e os demais, só pelo nome; teto de 120 itens com o corte dito. Contrato de módulo que saiu do mapa é DITO, nunca atribuído.
  6. O kickoff do dev diz onde ler a interface de outro módulo, e que não é no worktree de outro dev agent. Sem contrato para um módulo mostrado, a ferramenta manda implementar pelo que a story e a task dizem e, se faltar interface, report_blocked nomeando o módulo — nunca reler o worktree.
  • Código: apps/api/src/domain/architecture/module-contracts.ts:110 (validarContratos); apps/api/src/application/use-cases/architecture/declare-module-contracts.use-case.ts:55 (execute), :80 (missingModules); apps/api/src/application/use-cases/architecture/get-module-contracts.use-case.ts:23 (execute); apps/api/src/application/use-cases/architecture/list-module-contracts.use-case.ts:42 (execute); apps/api/src/interfaces/http/internal/internal-sessions.controller.ts:779 (moduleContracts); apps/api/src/interfaces/http/internal/internal-projects.controller.ts:178 (moduleContracts); apps/engine/lib/engine/harness/tools/declare_module_contracts.ex:91 (run); apps/engine/lib/engine/harness/tools/listar_contratos_de_modulos.ex:43 (run), :66 (renderizar); apps/engine/lib/engine/dev/tools.ex:36 (ListarContratosDeModulos); apps/engine/lib/engine/dev/dev_agent_server.ex:497 (module), :501; apps/engine/lib/engine/agents/arquiteto_server.ex:112, :335, :399
  • Teste: apps/api/test/application/use-cases/architecture/module-contracts.use-case.spec.ts:173 (grava a versão 1 — caminho feliz), :185 (a vigente substitui), :210 (módulo fora do mapa — caso de falha), :222 (sem module_map), :123 (as recusas de domínio), :239 e :257 (a leitura do dev); apps/engine/test/engine/harness/tools/declare_module_contracts_test.exs:26, :65 (recusa da api); apps/engine/test/engine/harness/tools/listar_contratos_de_modulos_test.exs:71, :76, :97, :109, :128; scripts/jev/kickoff.spec.ts:19. Os testes ExUnit não rodaram pelo mix neste ambiente (repo.hex.pm 403; rodaram num harness avulso com os módulos compilados à mão); o CI os prova.
  • Origem: AT-276 (item A30 da análise do uso real de 2026-09-29), decisão do dono de 01/10

RN-685 — Revogar uma credencial derruba só as conexões abertas COM ELA: o ticket do socket guarda a credencial, e o alvo deixa de ser {projeto, usuário}​

Revisa a RN-520. Até aqui, revogar uma chave de dispositivo derrubava a conexão viva do runner pelo par {projeto, usuário}, porque a identidade da credencial morria no PatAuthGuard: um runner do MESMO usuário conectado com PAT ou com outra chave caía junto, e a chave de MÁQUINA (RN-543) multiplicava isso por projeto. Revogar um PAT não derrubava nada.

O ticket guarda QUAL credencial o pediu. runner_socket_tickets (tabela do engine) ganha credential_kind (device_key | pat) e credential_id (o kid, que é o id do registro — RN-475 —, ou o id do PAT), as duas NULÁVEIS: o ticket de terminal é da aba da web e não tem credencial de dispositivo, e o de runner emitido por uma api anterior chega sem ela. O PatAuthGuard anota a credencial DEPOIS de autorizar (request.credencialDeDispositivo), o ticket de runner a leva ao engine e o de terminal nunca leva; par incompleto ou espécie desconhecida vira AUSENTE, nunca uma credencial inventada.

O socket guarda a credencial, e o id dele também. connect/3 põe assigns.credencial, e o id passa a ser runner_socket:<kind>:<projectId>:<userId>:<espécie>:<id> quando ela existe — o disconnect que o canal difunde para o próprio id alcança só aquela credencial.

A revogação de UMA credencial:

  1. anula os tickets PENDENTES dela — o pedido feito segundos antes da revogação não entra depois dela (consumed_at, o mesmo estado de "já usado", nenhum estado novo);
  2. pergunta a TODO runner do cluster se ele nasceu desta credencial — só o canal sabe —, e só quem nasceu dela cai. A chave de máquina é UM pedido, e cai em todo projeto em que abriu conexão, inclusive num que a api não listaria;
  3. responde um BALANÇO (derrubados, legados, intocados, semResposta, ticketsAnulados) — zero derrubados é o caso normal da chave órfã (RN-519).

Vale para as três revogações: a de chave (por projeto e pela Conta, RN-611) e as duas de PAT (a do dono, RN-426, e a do maintainer, RN-427). Revoga PRIMEIRO, derruba depois, e derrubar continua sendo efeito colateral que só LOGA — o DELETE segue 204 e idempotente.

O que já caía continua caindo (a proibição da AT-013). A conexão LEGADA — ticket sem credencial — do mesmo dono, num dos projetos que a revogação de CHAVE manda como alcance (o da linha, ou os candidatos da máquina), cai pelo par como antes; o PAT não ganha alcance legado, porque não derrubava nada. E se o engine não conhece a rota por credencial (404 de um engine anterior), a revogação de chave volta ao par, disconnectRunnerOfUser, projeto a projeto.

O par {projeto, usuário} continua, com outro dono: é o alvo da remoção de membro (RN-615), que tira a PESSOA, com a credencial que for.

A tela diz a precisão (RN-561): a confirmação de revogar — chaves de dispositivo em Configurações e chaves de máquina na Conta — troca "outro runner seu também cai" por "só cai o que se conectou com ESTA chave". Muda o rótulo, não o comportamento.

  • Onde: apps/api/src/interfaces/http/auth/pat-auth.guard.ts:151 (credencialDeDispositivo, o PAT), :236 (credencialDeDispositivo, a chave); apps/api/src/interfaces/http/runner/runner-tickets.controller.ts:67 (runnerTicket); apps/api/src/application/use-cases/runner/request-runner-ticket.use-case.ts:55 (execute); apps/api/src/application/use-cases/auth/revoke-runner-device-key.use-case.ts:71 (execute), :109 (derrubarConexoesDaChave); apps/api/src/application/use-cases/auth/derrubar-conexoes-do-pat.ts:21 (derrubarConexoesDoPat); apps/api/src/infrastructure/http-clients/api-to-engine-client.ts:674 (disconnectRunnerCredential); apps/engine/lib/engine/runners/socket_ticket.ex:107 (emitir), :207 (anular_pendentes_da_credencial); apps/engine/lib/engine/runners/revogacao.ex:131 (derrubar_credencial); apps/engine/lib/engine/runners/registry.ex:64 (todos); apps/engine/lib/engine_web/channels/runner_socket.ex:81 (socket_id); apps/engine/lib/engine_web/channels/terminal_channel.ex:772 (handle_info), :879 (desfecho_da_revogacao_por_credencial); apps/engine/lib/engine_web/controllers/runner_connection_command_controller.ex:58 (disconnect_credential); apps/engine/priv/repo/migrations/20261001120000_add_credential_to_runner_socket_tickets.exs; apps/web/src/routes/settings/RunnerDeviceKeysSection.tsx:346, apps/web/src/routes/MachineDeviceKeysSection.tsx:202
  • Teste: apps/api/test/application/use-cases/auth/revoke-runner-device-key.use-case.spec.ts (describe "o alvo da queda é a CHAVE" — caminho feliz; describe "CASO DE FALHA: o engine não atende o pedido por chave" — o plano B pelo par; a máquina num pedido só); apps/api/test/application/use-cases/auth/machine-device-keys.use-case.spec.ts; apps/api/test/application/use-cases/auth/revoke-personal-access-token.use-case.spec.ts e revoke-personal-access-token-as-maintainer.use-case.spec.ts; apps/api/test/interfaces/pat-auth.guard.spec.ts (a credencial anotada nos dois caminhos, e não anotada na recusa); apps/api/test/interfaces/http/runner/runner-tickets.guards.integration.spec.ts (o PAT real até o pedido ao engine); apps/api/test/application/use-cases/runner/request-runner-ticket.use-case.spec.ts (o terminal nunca leva credencial); apps/api/test/infrastructure/http-clients/api-to-engine-client.spec.ts (describe "o alvo é a CREDENCIAL", inclusive o 404 que LANÇA); apps/engine/test/engine_web/channels/terminal_channel_test.exs (describe "revogação por CREDENCIAL" — o canal real que cai, o PAT do mesmo usuário que fica de pé, a máquina em dois projetos sem lista, o legado pelo par, o ticket pendente anulado); apps/engine/test/engine/runners/socket_ticket_test.exs, apps/engine/test/engine_web/channels/runner_socket_test.exs, apps/engine/test/engine_web/controllers/runner_connection_command_controller_test.exs (200 com balanço, 400 de credencial fora de forma); apps/web/src/routes/settings/chaves-de-dispositivo.test.tsx, apps/web/src/routes/MachineDeviceKeysSection.test.tsx (a frase da precisão, e a do alvo antigo que não volta)
  • ADR: 0201
  • Origem: AT-013 (HS-008, EP-003), decisão do dono em 01/10. Fica declarado e NÃO feito: o ticket SEM credencial (de api anterior) não é anulado na revogação — não há o que comparar, e a janela é de 30 s, só no rollout; e os testes ExUnit só rodam no CI (repo.hex.pm dá 403 no ambiente em que foram escritos)

RN-688 — O binário do runner lê o PTY com leitor próprio sob o Bun, e o auto-teste prova a SEGUNDA leitura​

Sob o Bun (o binário do bun build --compile, ADR 0112), o tty.ReadStream com que o node-pty lê o lado mestre do PTY é um fs.ReadStream, e a primeira leitura sem dados do fd não-bloqueante SOBE como EAGAIN: o stream se destrói, fecha o fd, e nenhum onData chega depois disso (oven-sh/bun#25822). O terminal interativo do binário parava depois do primeiro pedaço de saída nas TRÊS plataformas Unix; o Linux passava na prova só porque a primeira leitura já trazia o eco e a resposta juntos.

A regra: sob o Bun e fora do Windows, o spawn do node-pty roda com o tty.ReadStream trocado por um leitor próprio que lê o MESMO fd com fs.read e, no EAGAIN, ESPERA e tenta de novo (de 4 ms, dobrando até 32 ms, voltando ao mínimo a cada dado); EIO ou zero bytes é fim. A troca vale só DURANTE o spawn e é restaurada mesmo quando ele lança; sob o Node o módulo volta INTACTO. O fd e o processo filho continuam os de verdade — nada simula saída. E o --self-test-pty faz DUAS voltas, a segunda depois de uma pausa sem nada a ler: é a pausa que força a leitura a passar por um EAGAIN, e é por ela que o leitor antigo reprova também no Linux. Quando reprova, o auto-teste diz por quê (pedaços, saída do filho, uma sonda num segundo PTY e os eventos do stream) — o veredito não muda.

Foi a sonda que mediu o Windows: lá o lado de leitura é o pipe nomeado do ConPTY, aberto pelo node-pty como net.Socket, sem fd que o leitor possa assumir, e o pipe termina depois do primeiro pedaço — por isso o win32-x64 saiu da matriz (ADR 0187) em vez de ganhar contorno.

  • Onde: apps/runner/src/leitor-de-pty.ts:60 (precisaDoLeitorProprio), :70 (LeitorDeFdNaoBloqueante), :115 (agendar), :152 (comLeitorDePtyProprio); apps/runner/src/native-pty-loader.ts:114 (comLeitorDePtyProprio); apps/runner/src/index.ts:1263 (PAUSA_ENTRE_VOLTAS_MS), :1298 (sondarPty), :1353 (rodarAutoTestePty)
  • Teste: apps/runner/src/leitor-de-pty.spec.ts:14 (só sob o Bun e fora do Windows), :23 (fora dele, o MESMO módulo), :28 (troca só durante o spawn, restaura quando ele lança — caso de falha), :48 (as duas voltas de um cat real, com EAGAIN entre elas); a prova de ponta a ponta é o smoke:bin da matriz, verde em darwin-arm64, linux-x64 e linux-arm64 no ensaio 36779817686
  • ADR: 0112, 0187
  • Origem: AT-342, AT-343

O fluxo do runner pelo navegador é aposentado (RN-687, ADR 0203)​

RN-687 — O painel do runner manda para o instalador; o navegador não gera chave, não baixa binário e não grava pasta​

O fluxo do ADR 0118 (RN-464..466) foi APOSENTADO por decisão do dono (01/10, BRB-031): era o único caminho de instalação que terminava em chmod +x manual, e o install.sh já faz as três coisas dele do lado certo — baixa o binário conferido contra o checksums.txt assinado e o instala com install -m 0755 (RN-531), e cria a chave NA MÁQUINA e a registra (RN-547, RN-551, RN-552).

A regra:

  1. O RunnerOnboardingPanel mostra o comando do instalador (curl -fsSLO …/install.sh && bash install.sh, a MESMA frase do runbook e do install.sh, RN-526), copiável, com o que ele faz dito em texto, a ressalva de que ele serve a máquina da instalação, e a EsperaDoRunner (RN-474) embaixo. O comando no web é uma CÓPIA, e a cópia é conferida contra a constante do instalador.
  2. Reconhecida uma chave ativa que serve o projeto — de máquina (RN-548) ou de projeto (AT-107) —, o bloco do instalador SAI e o gesto é conferir o serviço. O comando manual com PAT (--token) fica sempre no <details>, nomeado como o caminho de OUTRA máquina.
  3. Não há botão de configurar pasta, de baixar arquivos, nem seletor de plataforma, e o web não chama mais Web Crypto, File System Access nem o proxy do binário. runner-bootstrap.ts saiu inteiro.
  4. A api não tem mais POST /projects/:projectId/runner-device-keys. Ele ficou sem chamador. GET e DELETE ficam (listar e revogar); a rota interna de máquina (POST /internal/machine-device-keys) fica.
  5. Chave de projeto JÁ registrada continua valendo e revogável: nada no banco muda, o PatAuthGuard segue aceitando-a (RN-465) e o CLI segue lendo a pasta configurada (RN-466). O runner configurado pelo navegador antes do ADR 0203 não quebra.

Fica de fora, declarado: instalação com TIME perde o caminho sem PAT para parear uma segunda máquina (a rota de chave de máquina é 409 com duas ou mais pessoas), e passa a usar o comando manual; GET /runner-releases/binary fica sem consumidor de produção e NÃO foi removido — é decisão à parte.

  • Onde: apps/web/src/components/RunnerOnboardingPanel.tsx:68 (COMANDO_DO_INSTALADOR), :124 (RunnerOnboardingPanel); apps/api/src/interfaces/http/runner/runner-device-keys.controller.ts:75 (RunnerDeviceKeysController), :96 (listDeviceKeys), :119 (revokeDeviceKey); apps/api/src/interfaces/http/auth/pat-auth.guard.ts:152 (autenticarChaveDeDispositivo)
  • Teste: apps/web/src/components/RunnerOnboardingPanel.test.tsx:91 (caminho feliz: o comando do instalador, copiado, e a espera), :119 (o fluxo do navegador saiu: nenhum botão dele, nenhum chmod), :134 (caso de falha: clipboard recusado não quebra a tela), :211 (chave de máquina ativa: o instalador sai); apps/api/test/interfaces/http/runner/runner-device-keys.controller.spec.ts:43 (não há handler de POST); apps/api/test/interfaces/pat-auth.guard.spec.ts:345 (chave de projeto segue aceita); scripts/dev/install-invocacao.spec.ts:162 (o comando do painel é a frase do instalador); scripts/ci/alvos-do-runner.spec.ts:70 (a lista de alvos do navegador não volta)
  • Decisão arquitetural: ADR 0203
  • Origem: AT-014 (BRB-031), decisão do dono em 01/10

O teto da RN-418 vale também pela porta tipada (RN-689)​

RN-689 — git_push e pr_open tipados nunca são auto-aprováveis: o teto da RN-418 vale para o comando E para a ação​

Revisada pela RN-713 (AT-385, 02/10): o modo automático (a curinga em auto_approve) passa a liberar git_push e pr_open.

Revisa a RN-418, por decisão do dono (01/10). Até aqui o teto de efeito externo de decide() só olhava actionType === 'terminal': o git push DIGITADO virava require_approval, e a mensagem mandava usar a ação tipada git_push — que, com o papel mínimo (maintainer) cumprido, nascia auto_approved por QUALQUER um dos três estágios: o curinga "*" do modo automático (RN-153, o piloto da RN-670), uma regra específica de agent_autonomy (a ativação da execução semeava git_push/pr_open em auto_approve para cada dev-<modulo>) ou um allow em permissions.json. Medido em decide.spec.ts antes da correção: os seis casos (dois tipos × três estágios) saíam auto_approve.

A regra: git_push e pr_open entram no bloco de tetos absolutos — se o veredito até ali é auto_approve, vira require_approval incondicional, com o motivo nomeado ("teto de efeito externo (RN-418): … nunca é auto-aprovável"), que vai no proposed_action.created. deny já retornou antes e continua vencendo; a ORIGEM da autonomia não importa. O "sempre permitir" dos dois já era recusado na fonte (RN-642, TIPOS_SEM_SEMPRE_PERMITIR), e é essa metade que impede o teto de ser decorativo.

A semeadura acompanha: DEV_AUTO_GIT_ACTIONS passa a ser só git_commit — na ativação e no aceite de paralelização. Semear push e PR seria gravar uma autonomia que decide() nunca honra, a tela prometendo o que não acontece (o argumento da RN-642). Linhas já gravadas ficam no banco, inertes. O dev agent segue o caminho da RN-050: commit executado, push e PR pending, agente em awaiting_approval retendo o worktree até o task.pr_settled; só então o gate abre.

Ficam FORA, de propósito: git_merge (teto próprio de branch protegida); git_commit (fica na máquina, RN-670); open_adr_pr e open_infra_pr (abrem PR real no provider, mas a decisão do dono não os cobre — open_infra_pr é semeado auto_approve no aceite da Infra, RN-671); as ações de bootstrap (git_repo_create/git_branch_create/git_branch_protect, que não passam por decide()); e deploy, que não é tipo de proposed_action — só existe como destino da mensagem do comando de terminal.

  • Onde: apps/api/src/domain/actions/decide.ts:427 (ehAcaoTipadaComEfeitoExterno); apps/api/src/domain/actions/external-effect.ts:258 (ACOES_TIPADAS_COM_EFEITO_EXTERNO), :266 (ehAcaoTipadaComEfeitoExterno), :280 (mensagemDoTetoDaAcaoTipada); apps/api/src/application/use-cases/execution/activate-execution.use-case.ts:71 (DEV_AUTO_GIT_ACTIONS)
  • Teste: apps/api/test/domain/actions/decide.spec.ts:1375 (os dois tipos com curinga, regra específica e allow saem require_approval; deny continua vencendo — caso de falha; git_commit e git_merge para branch não protegida ficam fora); apps/api/test/application/use-cases/actions/propose-action.use-case.spec.ts:953 (com Postgres: nascem pending com o motivo no evento); apps/api/test/application/use-cases/execution/activate-execution.use-case.spec.ts:473 e accept-parallelization.use-case.spec.ts:141 (semeiam só git_commit); apps/api/test/application/use-cases/actions/approve-always-action.use-case.spec.ts:795 ("sempre permitir" recusado, 400 teto_do_sempre_permitir)
  • Origem: AT-347, decisão do dono em 01/10

RN-695 — A análise da Anamnese não entra no fio da sessão​

Medido no uso real da v7.0.0: o texto que o modelo da Anamnese escreve durante a rodada ("Analisando a janela do log… usuário (09824667-…)… Decisão de proficiência") aparecia como BOLHA no fio da sessão de ideação, entre o Criativo e o PO, com o UUID cru do usuário. A Anamnese não conversa com ninguém: ela analisa a PESSOA, e o que ela produz é o perfil (RN-680).

A regra: o texto da rodada é gravado como anamnese.analysis, nunca como agent.response. O ToolLoop aceita response_event no contexto (padrão agent.response, inalterado para todo outro consumidor) e a Anamnese o põe. O fio da sessão não desenha NENHUM evento cujo ator é o agente anamnese — inclusive o agent.response gravado antes da correção. O painel de log da sessão continua mostrando os eventos dela; o resultado mora em Configurações › Perfil de proficiência, sem id cru. Nenhum marcador "perfil atualizado" entra no fio: fica de fora até alguém pedir.

  • Onde: apps/engine/lib/engine/harness/tool_loop.ex:128 (loop); apps/engine/lib/engine/workers/anamnese_worker.ex:157 (build_ctx); apps/web/src/routes/session-timeline-montagem.tsx:123 (ehDaAnamnese)
  • Teste: apps/engine/test/engine/workers/anamnese_worker_test.exs:253 (o texto sai anamnese.analysis; agent.response nunca é gravado — caso de falha); apps/web/src/routes/session-timeline-montagem.falha-de-turno.test.tsx:86 (a resposta da Anamnese some do fio, a do Criativo fica)
  • Origem: AT-355

RN-696 — O nível de proficiência por competência chega aos agentes que conversam com a pessoa​

Medido no código: até aqui o proficiency_profiles que a Anamnese grava não chegava a agente nenhum — só os FATOS do Psicólogo aceitos pela pessoa (RN-680) iam no turno. O perfil era só tela.

A regra: a api lê o nível por competência do AUTOR da mensagem neste projeto (listByUser) e o acrescenta ao MESMO texto perfilDoAutor que o engine põe como mensagem system efêmera no turno (Engine.Harness.PerfilDoAutor) — nenhum servidor de agente muda. Vai só competência: nível, com a orientação de calibrar quantas perguntas fazer; o rationale e as evidências NÃO vão, e nível fora da escala é descartado. Teto de 12 competências, e o texto inteiro segue sob o TETO_DO_TEXTO de 2 000 caracteres. Falha na leitura é log, e o turno segue sem ela, independente da leitura do grafo. fatosDoPerfil no chat.message continua contando só os fatos do Psicólogo.

Fica FORA, e é decisão de produto pendente: o que a Anamnese observa sobre o comportamento do AGENTE (o laço, o usuário cancelando o turno) virar sinal de melhoria do produto, separado do perfil da pessoa.

TODO(humano): o sinal de melhoria do produto vindo da Anamnese é evento (anamnese.agent_signal, sem sujeito pessoa) ou tabela própria, quem o lê (Staff? uma tela de melhorias?) e por quanto tempo vive? É fronteira nova de dado e pede ADR (0205 reservado).

  • Onde: apps/api/src/domain/anamnese/proficiencia-do-autor.ts:22 (textoDaProficienciaDoAutor); apps/api/src/application/use-cases/agents/send-agent-message.use-case.ts:111 (resolverPerfil), :134 (resolverProficiencia)
  • Teste: apps/api/test/application/use-cases/agents/send-agent-message.use-case.spec.ts:254 (o nível vai, o rationale não), :274 (leitura falha, a mensagem segue — caso de falha); apps/api/test/domain/anamnese/proficiencia-do-autor.spec.ts
  • Origem: AT-356 (metade 1)

RN-693 — Ativar o agente não é conversa: a dica de abertura fica no fio até a primeira mensagem, e os campos de texto da sessão não convidam o autofill​

Medido no uso real da v7.0.0: "Iniciar ideação" grava agent.activated (e agent.status), o Criativo só fala depois da primeira mensagem, e o convite — com a dica "Comece contando o que você quer construir…" — sumia no clique, porque a pergunta "a conversa começou?" era events.length > 0. O fio ficava vazio por ~25 s.

A regra: eventos de CICLO DE VIDA do agente (agent.activated, agent.status) não contam como conversa. Qualquer outro evento continua contando, para o convite não cobrir a sessão do git-bootstrap nem a de execução (RN-131). Com o Criativo ativo e sem mensagem, o convite fica sem o botão "Iniciar ideação" e com a dica; a primeira mensagem o tira.

E o composer e os campos de texto das perguntas estruturadas levam autocomplete="off" e os atributos que os gerenciadores de senha respeitam (data-1p-ignore, data-lpignore="true", data-bwignore, data-form-type="other"): cada foco abria o popup de autofill do navegador.

  • Onde: apps/web/src/lib/conversa-comecou.ts:15 (conversaComecou), :29 (SEM_AUTOFILL); apps/web/src/routes/SessionPage.tsx:436 (conversaComecou); apps/web/src/routes/SessionComposer.tsx:389 (SEM_AUTOFILL); apps/web/src/routes/StructuredQuestionCard.tsx:242 (SEM_AUTOFILL)
  • Teste: apps/web/src/routes/SessionPage.sessao.test.tsx:316 (Criativo ativado sem mensagem mantém a dica; com mensagem ela some — caso de falha; atributos do composer); apps/web/src/routes/SessionPage.perguntas-estruturadas.test.tsx:201 (atributos dos campos das perguntas)
  • Origem: AT-353

RN-690 — O assistente guarda os toolCalls da resposta: o resultado da ferramenta não chega órfão ao provider​

Os agentes conversacionais com laço próprio (Criativo, PO, Arquiteto, Dev Lead, UX Designer, Staff e Infra Lead) guardavam no histórico a mensagem do assistente SEM os toolCalls da resposta e, em seguida, a mensagem role: "tool" com o toolCallId. No fio, o provider recebia um resultado que respondia a chamada nenhuma e o descartava: o modelo nunca via o que a ferramenta devolveu e repetia a mesma chamada até o teto do laço (medido na v7.0.0: o PO chamou listar_regras_de_negocio 12 de 12 vezes em dois turnos).

A mensagem do assistente passa a levar os toolCalls NATIVOS da resposta quando há algum — o mesmo formato que o ToolLoop já guardava e que o ContextManager agrupa na compactação —, montada num lugar só. Resposta sem toolCalls não ganha a chave. Chamada recuperada do texto (ToolCallRecovery, id nulo) fica de fora: não há id para casar. A api já repassava o campo aos providers (toWireMessage no OpenAI-compatível, tool_use no Anthropic); a reidratação continua trazendo ferramentas como texto.

  • Onde: apps/engine/lib/engine/agents/mensagem_do_assistente.ex:17 (de); apps/engine/lib/engine/agents/po_server.ex:280, e o mesmo ponto nos outros seis servidores
  • Teste: apps/engine/test/engine/agents/po_server_test.exs:93 (a segunda chamada leva o assistant com toolCalls logo antes do tool que o responde; a resposta final, sem ferramenta, não ganha a chave — caso de falha)
  • Origem: AT-350

RN-694 — O catálogo de modelos tem busca, a credencial oferece o sync, e o primeiro modelo vai ao time num clique​

Três atalhos sobre ações que já existiam, nenhum endpoint novo (AT-357):

  1. Busca. O catálogo tem um campo de busca que casa o nome de exibição, o name ou o id do modelo, sem diferenciar maiúsculas. Com termo, todo grupo e subgrupo de hub com resultado abre sozinho; busca sem resultado DIZ que nada casou, com o termo — nunca uma lista vazia muda.
  2. Sync ao lado da credencial. O card de credencial cujo provider tem chave e NENHUM modelo no catálogo diz isso e oferece "Atualizar catálogo" — a MESMA ação do catálogo (POST .../models/sync). O botão só aparece para owner (o mínimo do endpoint, RN-102); ler o catálogo pede maintainer. O predicado é UM só, providersSemCatalogo, também usado pelo aviso do catálogo.
  3. Ativar e aplicar ao time. Retirado pela RN-911 (AT-597): o catálogo saiu das Configurações do projeto e mora em /providers, onde não há projeto para receber o binding. Ativar é curadoria de provider; a escolha por projeto segue pelo "aplicar a todos" da RN-476.
  • Onde: apps/web/src/lib/models.ts:244 (casaComBusca), apps/web/src/lib/models.ts:491 (providersSemCatalogo), apps/web/src/lib/models.ts:507 (algumModeloAtivo), apps/web/src/routes/settings/CredentialsSection.tsx:119 (handleSync)
  • Teste: apps/web/src/components/catalogo-com-busca.test.tsx (busca por id e sem resultado; sem atalho "ativar e aplicar"; sync no card, com e sem catálogo, e a falha)
  • Origem: AT-357

RN-697 — O turno da prontidão do Criativo só sintetiza: sem ferramentas​

O turno que confirm_readiness dispara consolida as regras num resumo e grava o product_brief. No uso real (v7.0.0, Haiku 4.5) ele levou 25 s e gravou mais quatro artifact.business_rule e um chat.structured_question que ninguém responderia: o PO já tinha entrado pelo aceite implícito (RN-658). O turno passa a ir ao modelo SEM catálogo de ferramentas, e nenhuma chamada é atendida nele — nem a que o modelo copie do histórico. O catálogo volta intacto no turno seguinte; o turno normal continua com emit_artifact e ask_structured_questions.

  • Onde: apps/engine/lib/engine/agents/criativo_server.ex:228 (executar_confirm_readiness); :273 (ferramentas_da_prontidao)
  • Teste: apps/engine/test/engine/agents/criativo_server_test.exs:172 (o turno vai com tools: [] e não grava regra nem pergunta) e :192 (regressão: o turno normal mantém as duas ferramentas)
  • Origem: AT-352

RN-698 — O texto do turno vira UMA resposta, e o teto de iterações aparece no fio​

Com ferramentas, o modelo escreve em pedaços — um trecho antes da chamada, outro na volta seguinte, muitas vezes continuando a mesma frase. Cada volta virava a sua agent.response, e o fio recolhe as anteriores em "Passos do turno": à mostra ficava só o último pedaço (" histórias e tarefas que cubram tudo.", começando com espaço). O transporte não perdia nada — o frame final da api leva o texto inteiro da VOLTA —; o que se partia era o TURNO. Nos sete conversacionais, o texto de uma volta com ferramenta é acumulado no processo do turno e gravado numa agent.response só quando o turno fecha (sem ferramenta, teto, falha, suspensão do Dev Lead, PR da Infra). Pedaço que começa ou termina com espaço entra como veio; dois pedaços colados viram parágrafos.

O toolloop.limit_reached dos conversacionais (RN-166) passa a ser uma linha no fio, na bolha de falha, origem modelo, dizendo o teto ("parou no teto de N passos sem terminar o turno"); sem o número no payload a linha diz ?.

A agent.response do turno leva o modelName (RN-146) em TODO desfecho, não só no fecho sem ferramenta: quando o turno fecha sem uma chamada final que o traga (entregou um formulário ask_structured_questions, esgotou o teto, falhou), vale o modelo da última volta acumulada. Sem chamada de LLM que tenha devolvido modelo, o campo fica nil — nunca um modelo inventado (AT-384; o Infra Lead não grava modelName em desfecho nenhum e segue fora).

Revisão (decisão do dono, 03/10, AT-395): só o fecho do turno na bolha. No uso real (loja-teste-2, 02/10) a resposta única juntava o texto de TODAS as voltas ("Vou registrar… Excelente! Registrei…", dois resumos seguidos no PO). A agent.response do turno continua UMA, mas o content passa a ser só o FECHO — o texto da última volta que escreveu algo (o turno que termina num formulário, sem texto na última volta, fica com a anterior) — e o texto das voltas anteriores vai, na ordem, no campo passos, ausente quando não há. Nada some: o fio desenha cada passo como narração do mesmo turno+autor antes do fecho, e o colapso de "Passos do turno" que já existia os recolhe, fechado por padrão; os passos não contam no corte de mensagens do fio (RN-644). A reidratação (RN-580) devolve passos ANTES do content, no mesmo assistant, para o modelo não perder o que disse no meio do turno. Vale nos sete conversacionais (o Infra Lead também, sem modelName). Sem teto próprio para passos: o acúmulo já era gravado inteiro antes da revisão.

  • Onde: apps/engine/lib/engine/agents/texto_do_turno.ex:88 (descarregar); :116 (juntar); :51 (descarregar_com_modelo); :65 (payload_do_turno); apps/engine/lib/engine/agents/reidratacao.ex:187 (acumular); apps/web/src/routes/session-timeline-montagem.tsx:817 (passos)
  • Teste: apps/engine/test/engine/agents/texto_do_turno_test.exs:43 (três voltas: fecho e dois passos), :56 (uma volta, sem passos), :60 (termina em formulário); apps/engine/test/engine/agents/reidratacao_test.exs (passos antes do fecho); apps/web/src/routes/SessionPage.ordenacao-e-avisos.test.tsx:502 (só o fecho à mostra, passos ao expandir) e :524 (sem passos, nada extra); apps/engine/test/engine/agents/criativo_server_test.exs:209 (duas voltas, uma resposta inteira; nenhuma segunda), apps/engine/test/engine/agents/texto_do_turno_test.exs e apps/web/src/routes/session-timeline-montagem.falha-de-turno.test.tsx:85 (a linha do teto; sem número, não inventa um); o modelo no formulário em apps/engine/test/engine/agents/criativo_server_test.exs:703 (com modelo) e :724 (sem modelo, nil)
  • Origem: AT-354, AT-384, AT-395

RN-700 — O PO não anuncia o handoff ao Arquiteto sem fazê-lo, e história sem tarefa não é entregue​

No uso real o PO escreveu "Handoff ao Arquiteto" e encerrou o turno sem chamar offer_handoff (fere a RN-163), e entregou cinco histórias ready com zero tarefas — o Dev Lead não tinha o que distribuir. No fim de todo turno do PO, se o servidor ainda não registrou oferta ao Arquiteto naquele processo, ele lê as regras do projeto (uncoveredCount, a mesma cobertura do aceite automático da RN-660) e o backlog: com toda regra coberta e toda história com ≥ 1 tarefa, o SERVIDOR oferece o handoff (molde da Infra que propõe sozinha, ADR 0190, RN-671/672), e a RN-660 segue decidindo o aceite. Com regra coberta e história sem tarefa, não oferece e grava backlog.handoff_not_offered (origem modelo) nomeando as histórias; leitura ou oferta que falha grava o mesmo evento com origem infra. Regra descoberta, ou backlog sem regra/história, não é julgado. A instrução do PO passa a exigir tarefa por história. Quem cria as tarefas é o PO (decisão do agente principal na AT-364: ele já tem create_task).

  • Onde: apps/engine/lib/engine/agents/po_server.ex:528 (fechar_handoff); :555 (oferecer_handoff)
  • Teste: apps/engine/test/engine/agents/po_server_test.exs:592 (oferece e não repete), :610 (história sem tarefa: não oferece, nomeia)
  • Origem: AT-364

RN-699 — O Dev Lead lê o module_map vigente e o backlog do PROJETO, e relê os dois quando o plano é recusado​

Medido no uso real (02/10): aceito o Dev Lead, ele respondeu que faltava o module_map — e o mapa existia. O kickoff procurava o evento architecture.module_map_created, que NENHUM código emite (o Arquiteto grava o mapa pela api), e caía sempre em "(sem module_map)". O mapa agora vem da MESMA leitura de projeto que o AppSec usa (get_infra_context, o vigente da api), nunca de evento da sessão; sem mapa, o kickoff diz que não há.

No mesmo uso, as tarefas nasceram DEPOIS do kickoff, o Dev Lead inventou um task_id, a api recusou o plano e o turno acabou sem nova tentativa. Ele ganha read_backlog: leitura CONTIDA, sem parâmetro, escopada ao projeto (as tarefas pendentes pela mesma rota da listar_backlog do PO, teto de 200 linhas dizendo o total real, e o module_map vigente). A recusa da api volta ao laço dizendo para chamar read_backlog e corrigir; e só o propose_execution_plan bem-sucedido encerra o turno — antes, qualquer ferramenta bem-sucedida na última chamada (uma leitura, um emit_artifact) encerrava. A instrução do kickoff proíbe inventar task_id e pede que ele só diga ter avaliado as histórias em que chamou assess_implementability.

  • Onde: apps/engine/lib/engine/agents/dev_lead_tools.ex:339 (module_map_vigente); :324 (spec_read_backlog); :340 (tarefas_do_backlog); apps/engine/lib/engine/agents/dev_lead_server.ex:584 (kickoff_instruction);

  • Onde: apps/engine/lib/engine/agents/dev_lead_tools.ex:339 (module_map_vigente); :324 (spec_read_backlog); :372 (tarefas_do_backlog); apps/engine/lib/engine/agents/dev_lead_server.ex:584 (kickoff_instruction); :397 (run_turn)

  • Teste: apps/engine/test/engine/agents/dev_lead_server_test.exs:164 (o mapa vigente chega ao kickoff e o tipo antigo não é lido), o teste seguinte (sem mapa, diz que não há — caso de falha) e :202 (plano recusado pela api → read_backlog no mesmo turno, com as tarefas reais)

  • Origem: AT-362, AT-363

O instalador oferece os modelos locais, e não confunde o dev com uma instalação (RN-691, RN-692)​

RN-691 — O install.sh PERGUNTA se liga os modelos locais (profile llm), mede a porta do Ollama antes de gravar, e compõe UMA linha de COMPOSE_PROFILES​

Medido numa instalação real da v7.0.0 (AT-351): toda regra de negócio gravava backlog.semantic_duplicate_check_skipped com getaddrinfo EAI_AGAIN ollama, porque a instalação por Release sobe sem o profile llm e o instalador nunca perguntava; ligá-lo à mão esbarrava em address already in use na 11434, a porta de um Ollama NATIVO no host.

A regra: depois da pergunta do broker (RN-575), o instalador diz o CUSTO (disco da imagem e dos modelos que o ollama-model-loader baixa, RAM com modelo carregado) e o que se PERDE sem ele (a checagem semântica de duplicata da RN-681 e modelo local para os agentes), e só um "s" digitado liga; sem terminal fica desligado DIZENDO isso. Ligar MEDE a porta do host antes de gravar qualquer coisa — a 11434, ou a primeira livre até a 11443, pelo /dev/tcp do próprio bash (3.2 incluso) — e a faixa inteira ocupada é recusa NOMEADA, sem nada gravado, nunca um address already in use na subida. O .env ganha OLLAMA_PORT e o profile llm; os profiles consentidos saem numa ÚNICA linha COMPOSE_PROFILES (container-broker,llm), porque duas linhas não somam — a última venceria e o broker sumiria calado.

Fica FORA, declarado: a sessão dizer UMA vez que a checagem semântica foi pulada (lado da api, outra frente); e o E2E da tag responde NÃO (ligar baixaria GBs de modelo), então a subida com o profile llm não é provada ponta a ponta.

  • Onde: install.sh:1128 (consentir_llm), :1113 (escolher_porta_do_ollama), :1103 (porta_em_uso), :800 (escrever_env_do_llm), :821 (escrever_env_dos_profiles)
  • Teste: scripts/dev/install-llm.spec.ts (sem terminal fica desligado dizendo o custo; 11434 ocupada escolhe a próxima; faixa inteira ocupada falha — caso de falha; um servidor escutando é medido como em uso; broker e modelos numa linha só); scripts/dev/install-e2e.spec.ts (a resposta da pergunta nova está na lista do driver de TTY, na ordem)
  • Origem: AT-351

RN-692 — Só o projeto compose brabo é instalação anterior, e a migração oferece reinstalar DO ZERO depois do backup provado​

detectar_por_sinais casava "Name":"brabo[^"]*", e o compose de DEV (brabo-dev, ADR 0170) contava como instalação anterior — o instalador oferecia migrar o ambiente de desenvolvimento. Agora casa só o nome EXATO brabo.

E a confirmação da migração ganha uma terceira resposta: z reinstala DO ZERO. A ordem do ADR 0150 não muda — backup, PROVA, pergunta, apaga, instala —, mas os dados antigos NÃO são restaurados: o backup provado fica na pasta, e restaurá-lo depois é o #restore do runbook. s segue restaurando, e Enter continua não apagando nada.

Junto, o fechamento deixa de dizer que parear uma pasta pela tela do projeto "continua existindo (ADR 0118)": esse fluxo saiu no ADR 0203 (RN-687).

  • Onde: install.sh:370 (detectar_por_sinais), :1694 (migrar_instalacao_anterior)
  • Teste: scripts/dev/install-llm.spec.ts (brabo-dev não conta e brabo conta; z apaga depois da prova e deixa MIGRAR_DE vazio; Enter não apaga — caso de falha; a frase do ADR 0118 não volta)
  • Origem: AT-358

RN-711 — O conversacional sabe onde está no fluxo, e a restrição técnica do usuário vira decision_record​

  • Regra: os sete agentes conversacionais (Criativo, PO, Arquiteto, Infra, Dev Lead, UX Designer, Staff) recebem em toda chamada de LLM uma mensagem system EFÊMERA com o fluxo de entrega — as sete etapas na ordem (Criativo → PO → Arquiteto → Infra → Dev Lead → dev agents → QA/SecOps → merge manual), ONDE o agente está, o PRÓXIMO passo real e QUEM o dispara (humano × sistema) —, pela fachada EngineApiClient, depois do ambiente (RN-706) e antes do perfil (RN-680) e do idioma (RN-622, sempre o último). A fonte é UMA, Engine.Harness.FluxoDoTime, conferida por teste contra docs/fluxo.yml: todo papel citado existe ali como active, e o texto não cita etapa fora dele (deploy, pipeline, Dockerfile, InfraAgent). O Criativo registra restrição técnica EXPLÍCITA do usuário (linguagem, "nenhuma dependência", "sem rede", "um módulo só") como decision_record (emit_artifact, RN-505), sem sermão; o kickoff do Arquiteto lê os artifact.decision_record da sessão (a mesma leitura por tipo, RN-580) e manda respeitá-los, dizendo QUAL e POR QUÊ quando contrariar. O fechamento do Arquiteto não pede de novo a confirmação já dada, e a Infra não cola os arquivos no chat nem promete deploy.

  • Medição (AT-369/AT-370, 02/10, loja-teste): o Criativo anunciou o Arquiteto como próximo (é o PO); o PO disse que o Arquiteto ia "implementar"; o Arquiteto, que "dev agents começam" após os ADRs, e pediu "Confirme: Arquitetura pronta" depois da confirmação; a Infra despejou ~80 linhas de compose no fio. O pedido "Node puro, nenhuma dependência, sem rede, um módulo só" não virou artefato e o Arquiteto criou três módulos.

  • Onde: apps/engine/lib/engine/harness/fluxo_do_time.ex:107 (texto), :97 (anexar), apps/engine/lib/engine/sessions/engine_api_client.ex:647

    :97 (anexar), apps/engine/lib/engine/sessions/engine_api_client.ex:730 (llm_turn), apps/engine/lib/engine/harness/agents.ex:23 (identity), apps/engine/lib/engine/agents/arquiteto_server.ex:415 (build_kickoff)

  • Teste: apps/engine/test/engine/harness/fluxo_do_time_test.exs ("todo papel citado existe no fluxo.yml como ativo"; caso de falha: "nenhum texto cita etapa/agente fora do fluxo"), apps/engine/test/engine/agents/arquiteto_server_test.exs ("kickoff lê os decision_record e manda respeitar "um módulo só""), apps/engine/test/engine/harness/agents_test.exs

  • Lacuna declarada: a leitura de decision_record do Arquiteto é da SESSÃO (como as regras e as histórias do kickoff), não do projeto inteiro; e nada RECUSA um create_module_map que contrarie a restrição — a instrução pede a justificativa, não a impõe.

  • Origem: AT-369, AT-370

RN-706 — O agente de execução sabe onde está: pasta, imagem, rede, git tipado e o próprio módulo​

  • Regra: os dev agents (dev-*) e os subagentes de QA (qa-*) recebem em toda chamada de LLM uma mensagem system EFÊMERA de ambiente, pelo mesmo caminho do idioma (RN-622) e do perfil do autor (RN-680) — nunca em state.messages, nunca por servidor. Ela diz a pasta real em que o terminal roda (o workspace_root do laço, traduzido para /work pela mesma regra do TerminalExecutor quando há container running REGISTRADO), a imagem e a rede do último artifact.project_image, que git não se roda no terminal (use git_commit/git_push/pr_open) e, para o dev agent, o MÓDULO dele: o que consome de outro módulo vem do contrato (listar_contratos_de_modulos) e se usa por stub/mock, nunca reimplementado, e arquivos de raiz que já existem na dev (package.json, lockfile) não se recriam. Sem container running, a mensagem DIZ isso e não afirma /work. Teto de 1 200 caracteres; falha de leitura vira mensagem ausente, nunca turno derrubado.
  • Medição (AT-379/AT-378, 02/10, loja-teste): dev-api e qa-automacao rodaram cd /workspace (nenhum prompt cita essa pasta); git status deu exit 127 numa imagem sem git; o dev-api (módulo api) criou src/catalog.js e package.json, os mesmos do dev-catalog, e o merge conflitou.
  • Onde: apps/engine/lib/engine/harness/ambiente_do_agente.ex:102 (montar), :48 (com_ambiente), :65 (anexar), apps/engine/lib/engine/harness/tool_loop.ex:87 (com_ambiente)
  • Teste: apps/engine/test/engine/harness/ambiente_do_agente_test.exs ("caminho feliz: com container running, a pasta é /work/.worktrees/"; caso de falha: "sem container running, não afirma /work")
  • Lacuna declarada: nenhum aviso no resultado do git_commit para arquivo de outro módulo (o module_map não diz quais caminhos são de cada módulo, então o aviso não seria determinístico) e nenhum bloqueio de commit, por decisão do dono.
  • Origem: AT-379, AT-378

RN-715 — O conflito de merge devolve a tarefa ao dev agent dono, e os gates julgam de novo​

  • Regra: quando o merge aprovado de uma PR é recusado por conflito com a dev (GitMergeConflictError, RN-704), cada tarefa da PR que está in_review volta a in_progress com gate_status zerado (os vereditos antigos de QA/SecOps não valem para a branch que vai mudar), grava backlog.task_status_changed com cause: merge_conflict e põe task.merge_conflict na outbox, com o agente que propôs o pr_open (o DONO), a PR e os arquivos em conflito. O engine entrega ao dono pelo Engine.Dev.Wake; livre, ele READOTA a branch da PR (WorktreeManager.adopt/3, a mesma garantir_base, sem perder os commits) e roda o laço de CORREÇÃO com o motivo no texto: trazer origin/dev por git fetch/git merge, resolver os arquivos, rodar a suite. No report_done propõe commit e push pelo caminho de sempre (a política de aprovação vigente decide) e o gate RECOMEÇA do QA (open_gate + QA), sem pr_open novo — a PR é a mesma branch. Nenhum estado novo de tarefa: in_progress com a causa no evento.
  • Desfechos nomeados: dono sem linha em dev_agent_states — a tarefa é BLOQUEADA (conflito de merge sem dev agent para resolvê-lo, origem infra); dono com o disjuntor aberto (idle_tripped) — bloqueada, origem politica; a branch não volta (adopt falha) — bloqueada, origem infra. Dono ocupado (working/awaiting_gate/awaiting_approval) recebe o conflito de novo em 30 s. A recusa do merge (RN-705) não depende de nada disso: a ação já está failed, com o motivo, antes de o engine agir. Tarefa que já não está in_review (merge repetido) não é reaberta nem acorda ninguém, e PR aberta por humano não tem dono a acordar.
  • Medição (AT-383, 02/10, loja-teste): a #6 terminou failed por conflito em package.json, e a tarefa ficou in_review sem ninguém para resolvê-lo.
  • Onde: apps/api/src/application/use-cases/actions/execute-git-action.use-case.ts:392 (devolverAoDono), apps/api/src/infrastructure/persistence/drizzle/backlog.repository.ts:490 (reabrirPorConflitoDeMerge), apps/engine/lib/engine/workers/dev_agent_wake_worker.ex:151 (task.merge_conflict), apps/engine/lib/engine/dev/dev_agent_server.ex:350 (handle_info), apps/engine/lib/engine/dev/dev_agent_server.ex:831 (trigger_gate_recheck), apps/engine/lib/engine/dev/worktree_manager.ex:47 (adopt)
  • Teste: apps/api/test/application/use-cases/actions/execute-git-action.use-case.spec.ts ("conflito de merge → kind git_merge, arquivos no resultado e evento na tarefa"; falha: "RN-715: tarefa que já não está em revisão não é reaberta nem acorda ninguém"), apps/engine/test/engine/dev/dev_agent_server_test.exs (describe "conflito de merge (RN-715)": readota, motivo no turno, commit+push sem PR nova, gate reabre do QA; falhas: branch que não volta e disjuntor aberto), apps/engine/test/engine/workers/dev_agent_wake_worker_test.exs ("dono que não existe: bloqueia a task com motivo nomeado, nada calado"), apps/engine/test/engine/dev/worktree_manager_test.exs ("readotar a branch da PR preserva os commits dela")
  • Lacuna declarada: a reentrega ao dono ocupado mora em memória — restart do engine nessa janela a perde, e a tarefa fica in_progress até intervenção (mesma família da lacuna do Engine.Dev.Wake, ADR 0045). O agente integra a dev por MERGE, não rebase: o push forçado que o rebase exigiria não existe no executor de git. O NoopDevAgentServer não trata o conflito.
  • Origem: AT-383

RN-705 — A recusa de merge aparece na aba PRs, e a falha grava o tipo da ação​

  • Regra: a ação git que falha grava execution_result com o kind da PRÓPRIA ação (git_merge, pr_open, git_commit, git_push), failed: true, o error e, no git_merge, o pullRequestId; antes toda falha gravava {kind: 'git_push', branch: ''}. O conflito de merge (GitMergeConflictError, RN-704) grava também conflictingFiles e um evento backlog.task_merge_conflict em cada tarefa da PR. GET projects/:projectId/actions aceita status=failed (só com actionType), e a aba PRs lê dali a última recusa de merge de cada PR aberta: o motivo, os arquivos e a frase de que é preciso resolver o conflito antes. É AVISO em texto, como o do gate pendente (RN-663): o botão "Merge" segue ativo e a decisão continua humana. Aprovar o merge invalida a lista de PRs, e o aviso action.git_merge/action.failed do canal também; a lista "PRs em revisão" de Aprovações deixa de mostrar tarefa done. O card do merge nomeia o ator humano pela leitura de membros (RN-655) e mostra o NÚMERO da PR (#3, não #pr-3), e o provider local grava o autor da PR (<agente>[bot]).
  • Medição (AT-383/AT-382, 02/10, loja-teste): a #6 terminou failed com o conflito em package.json e a aba PRs seguiu mostrando "ABERTA" com o botão "Merge", sem texto nenhum; a ação gravou kind: git_push. Depois de um merge executado, a PR seguia em "Abertas" até recarregar, o card mostrava o UUID do usuário e #pr-3, e toda PR local era "autor desconhecido".
  • Onde: apps/api/src/application/use-cases/actions/execute-git-action.use-case.ts:72 (resultadoDaFalha), apps/api/src/application/use-cases/actions/list-project-pending-actions.use-case.ts:22 (execute), apps/web/src/routes/ProjectPrsTab.tsx:65 (ultimaRecusaDeMerge), apps/web/src/lib/canal-vivo.ts:134 (alvosDoEvento)
  • Teste: apps/api/test/application/use-cases/actions/execute-git-action.use-case.spec.ts ("conflito de merge → kind git_merge, arquivos no resultado e evento na tarefa"; caso de falha: "falha que não é conflito não grava arquivos nem evento de conflito"), apps/web/src/routes/ProjectPrsTab.test.tsx ("conflito de merge: mostra motivo e arquivos..."; "recusa de OUTRA PR não aparece nesta"), apps/api/test/infrastructure/git/local-git-provider.contract.spec.ts ("grava o autor ao abrir...")
  • Lacuna declarada (fechada na RN-715): o conflito NÃO acorda o dev agent da tarefa para rebasear e reenviar. O caminho que o QA usa (DevAgentServer.correct/3) só age com o agente em :awaiting_gate NA MESMA tarefa, e no merge o gate já terminou (task.gate_resolved com done leva o agente a finish_task, que zera tarefa, worktree e branch e reivindica outra). Reaproveitá-lo exige decidir como o agente readota uma tarefa in_review na branch existente e se o gate roda de novo depois do rebase — decisão de desenho, não tomada aqui. A tarefa segue in_review, com o backlog.task_merge_conflict durável.
  • Origem: AT-383, AT-382

RN-704 — O merge do repositório local é merge de verdade, e conflito é recusa nomeada​

  • Regra: mergePullRequest do provider local faz fast-forward só quando o alvo é ancestral da fonte; com os dois divergidos, monta um commit de merge com os dois pais (git merge-tree --write-tree + commit-tree, sem worktree, mensagem Merge pull request #N from <branch>, identidade Brabo Bot). A ref do alvo é movida por update-ref com o valor antigo esperado, então um merge concorrente falha em vez de sobrescrever. Conflito lança GitMergeConflictError com os arquivos em conflito: a ref fica intacta, a PR continua open, a ação git_merge termina failed com o motivo no action.failed, e pela HTTP o erro é 409 merge_conflict.
  • Medição (AT-377): duas PRs do mesmo commit base (#3 e #4) mergeadas em sequência: a dev ficou só com a #4, e os commits da #3 deixaram de ser ancestrais dela. O update-ref antigo era um reset forçado.
  • Onde: apps/api/src/infrastructure/git/local-git-provider.ts:783 (mergeShas), apps/api/src/domain/git/git-errors.ts:89 (GitMergeConflictError), apps/api/src/interfaces/http/shared/git-provider-error.filter.ts
  • Teste: apps/api/test/infrastructure/git/local-git-provider.contract.spec.ts ("duas PRs divergentes sem conflito: os dois commits ficam no alvo, por commit de merge"; caso de falha: "conflito: recusa nomeada com os arquivos, alvo intacto e PR aberta")
  • Lacuna declarada: GitHub e GitLab recusam o merge com conflito pela própria API (405/406), sem tradução para GitMergeConflictError.
  • Origem: AT-377

RN-707 — O semgrep do gate de SecOps tem $HOME gravável, e a falha sem saída é nomeada​

  • Regra: nos composes de produção e de instalação, o tmpfs /home/engine do engine nasce com o dono do usuário da imagem (/home/engine:uid=1000,gid=1000,mode=0700). O SemgrepDetector.Live distingue o exit 1 com JSON (achados) do exit 0/1 com stdout vazio, que vira {:error, {:sem_saida, exit_code}} em vez de :invalid_output. O veredito do gate NÃO muda: scanner que não rodou continua "pulado" no resumo do parecer.
  • Medição (AT-380): dentro de brabo-engine-1 (imagem de produção, read_only: true), o tmpfs /home/engine nascia root 2755; o semgrep scan do gate saía 1 com stdout vazio e PermissionError: '/home/engine/.semgrep' no stderr, e o parser chamava isso de :invalid_output. Com o tmpfs uid=1000 o mesmo comando devolve o JSON (exit 0). O Kubernetes não tinha o defeito (fsGroup: 1000 no emptyDir).
  • Onde: apps/engine/lib/engine/actions/semgrep_detector.ex:73 (interpretar), docker/docker-compose.prod.yml, docker/docker-compose.install.yml
  • Teste: apps/engine/test/engine/actions/semgrep_detector_test.exs ("JSON com results vira findings, inclusive com exit 1 (achou algo)"; caso de falha: "exit 1 com stdout vazio é falha de execução nomeada, não :invalid_output")
  • Decisão (revista pela RN-714): a frase "o veredito do gate NÃO muda" deixou de valer: o dono decidiu (02/10) que SAST que não rodou deixa o gate PENDENTE e bloqueando.
  • Origem: AT-380

RN-714 — SAST que não rodou deixa o gate de SecOps pendente, nunca aprovado​

  • Regra: quando o semgrep do gate de SecOps não roda — binário ausente, saída inválida (:invalid_output), :sem_saida, exceção ou teto de tempo —, nenhum veredito é gravado: a PR continua em awaiting_secops, o gate fica PENDENTE e bloqueia, e a PR não chega ao usuário como aprovada pela SecOps. O motivo vai ao fio como agent.error de origem infra ("SAST não rodou: — … reexecute o gate depois de corrigir"). O contrato externo não muda: continuam os dois vereditos de sempre (approved/changes_requested), um por gate. O gitleaks ausente continua só "pulado" no resumo.
  • Reexecução: o ciclo fica in_progress em gate_states, e o GateRescuer o reexecuta no boot do engine e depois do limiar de staleness (gate_rescue_stale_after_seconds, 15 min). Não há botão de reexecutar gate na tela.
  • Sem repetição (AT-386): o resgate que reencontra o MESMO motivo não grava outro agent.error: se o último evento do SecOps daquela task (agent.error ou artifact.secops_verdict, lidos por tipo, cauda com teto) já é este erro com o mesmo reason, fica só a linha de Logger do servidor. Motivo diferente, um veredito no meio ou leitura que falha gravam de novo; o agent.error passa a levar taskId.
  • Onde: apps/engine/lib/engine/gates/secops_agent_server.ex:116 (run_secops), apps/engine/lib/engine/gates/secops_agent_server.ex:144 (sast_nao_rodou), apps/engine/lib/engine/gates/secops_agent_server.ex:209 (erro_ja_registrado?)
  • Teste: apps/engine/test/engine/gates/secops_agent_server_test.exs ("sem achados (gitleaks e semgrep limpos): approved"; casos de falha: "semgrep com saída inválida: gate pendente, sem veredito, motivo nomeado" e "semgrep com binário ausente: gate pendente, sem veredito, motivo nomeado"; AT-386: "primeiro resgate grava; o segundo, com o mesmo motivo, não", "motivo diferente, ou veredito depois do erro, grava de novo", "o SAST que volta a rodar aprova como sempre")
  • Revisa: RN-707 ("o veredito do gate NÃO muda")
  • Origem: AT-380

RN-702 — A compactação de contexto respeita a janela padrão de 128k e nunca troca turnos por um marcador vazio​

  • Regra: sem context_window no contexto (os agentes conversacionais não o declaram), a janela de compactação é DEFAULT_CONTEXT_WINDOW, cujo padrão passou de 8 192 para 128 000, o mesmo valor que os agentes de gate e de dev declaram. Quando o sumarizador (context-manager) falha ou devolve texto vazio, o resumo é DETERMINÍSTICO (um trecho de até 300 caracteres de cada turno substituído) e o context.compacted grava summaryOrigin: "deterministico" e falha com origem e motivo (RN-059). Com o sumarizador respondendo, summaryOrigin: "modelo" e falha: null.
  • Medição (AT-366): sessão 17d41c20, Arquiteto em anthropic/claude-haiku-4.5 (janela de 200k): 6 799 tokens passavam de 0,7 × 8 192 = 5 734 e compactavam 35 turnos; o sumarizador falhou e o resumo gravado foi o marcador "(35 turnos anteriores omitidos)". No turno seguinte o Arquiteto pediu de novo uma confirmação que já tinha recebido.
  • Onde: apps/engine/lib/engine/harness/context_manager.ex:157 (summarize), apps/engine/lib/engine/harness/context_manager.ex:188 (resumo_deterministico), apps/engine/config/runtime.exs (default_context_window)
  • Teste: apps/engine/test/engine/harness/context_manager_test.exs ("sem context_window declarado: 6,8k tokens NÃO compactam"; "sumarizador respondendo: summaryOrigin modelo, sem falha"; caso de falha: "sumarizador falhando: falha NOMEADA com origem e resumo determinístico com o conteúdo")
  • Lacuna declarada: a janela continua não sendo lida do catálogo por modelo (o engine não recebe o context_window do binding); 128k é o padrão da instalação, ajustável por DEFAULT_CONTEXT_WINDOW.
  • Origem: AT-366

RN-701 — O painel de contexto não afirma "0" sobre regras que saíram da janela​

O painel "Contexto da sessão" lê os eventos já baixados (RN-180). Numa sessão com mais eventos do que a janela, as regras de negócio emitidas no começo ficam ANTES dela, e o contador dizia 0 com "Nada ainda." — afirmando sobre o que não leu (medido na sessão de 268 eventos de 02/10: 5 regras viraram 0). Com eventos anteriores à janela, o contador passa a dizer "N na janela · há mais antes" e a seção vazia diz que não há nenhuma NOS EVENTOS CARREGADOS. Sem leitura nova: o número de anteriores continua saindo da subtração sobre o seq.

A outra metade do AT-365 — o cartão de aprovação já executado voltando a pedir decisão — NÃO foi corrigida aqui: o card do fio já decide pelo status da AÇÃO (ApprovalCard, isPending), e a leitura de ações é a cauda mais as pendentes (RN-637); o defeito não reproduz pelo código do web.

Medição (02/10, no ambiente da AT-015): GET .../actions?latest=true da sessão 17d41c20 devolve as duas open_adr_pr como executed, e recarregar a sessão (com os blocos recolhidos abertos) não mostra nenhum "Aprovar". O cartão pendente apareceu só DURANTE a sessão ao vivo — estado transitório da tela, não da api nem da janela. A próxima rodada da AT-015 captura a rede no momento em que ele aparecer, antes de qualquer correção.

  • Onde: apps/web/src/routes/ContextAside.tsx:393 (trailing), :415 (nadaNaJanela)
  • Teste: apps/web/src/routes/SessionPage.painel-e-agrupamento.test.tsx ("RN-701 (AT-365): com as regras fora da janela, o contador não afirma 0"; o caso de falha evitado é o "alcançando o começo da sessão" ao lado)
  • Origem: AT-365

RN-708 — O projeto novo diz o próximo passo, derivado do que já se lê​

A Visão geral de um projeto recém-criado não dizia o que fazer: sem chave de provedor, sem modelo e sem sessão, nada na tela apontava o caminho, e a credencial e o catálogo moravam no fim de Configurações (medido no teste real de 02/10, instalação nova). Desde a AT-372 a Visão geral abre com o cartão "Primeiros passos", com TRÊS passos, cada um com o link para o lugar exato:

  • credencial — a conta tem ao menos uma credencial de LLM (a mesma leitura ['credentials'] da seção de Credenciais, com o frescor de configuração e sem poll, RN-645). A Visão geral não fazia essa leitura, e a carga dela tem orçamento: ela só roda quando o time ainda não tem modelo; com modelo resolvido, o passo conta como coberto;
  • modelo — o binding resolvido do Criativo não é nulo (o lote da RN-654, a mesma queryKey de Configurações);
  • ideação — o projeto tem ao menos uma sessão (a lista que a página já polla).

Nenhuma rota nova. Insumo ainda não lido (ou com erro) é "não sei" (RN-470), e o cartão NÃO aparece enquanto houver um — afirmar "falta credencial" sem ter lido seria a tela mentindo. Com os três feitos, ele some. Junto, a aba Configurações passou a abrir pelo grupo de modelos, com a credencial à frente, e o sumário acompanha a ordem.

Destinos (revisão da RN-911, AT-597): o passo da credencial leva a /providers (a chave é do usuário e deixou as Configurações do projeto) e o do modelo leva a ?tab=settings&section=models; o catálogo, que o passo antigo abria, também é de /providers.

  • Onde: apps/web/src/lib/primeiros-passos.ts:37 (derivarPrimeirosPassos), apps/web/src/components/PrimeirosPassos.tsx:22 (PrimeirosPassos)
  • Teste: apps/web/src/lib/primeiros-passos.test.ts (caminho feliz e "insumo não lido não vira pendente") e apps/web/src/routes/ProjectOverviewTab.test.tsx ("primeiros passos (RN-708, AT-372)")
  • Origem: AT-372

RN-710 — O Workflows não é delegado sem CI que rode, e não pergunta a ninguém​

No aceite da Infra, o infra-workflows respondia com uma pergunta ("qual é o provider do repositório do projeto?") que nenhum humano recebe — subagente não conversa —, e a delegação terminava failed com origem modelo e um dev.error genérico no fio (medido no loja-teste, 02/10). O projeto era local: repositório bare no servidor, sem runner de CI.

  • Provider local ou ausente: a delegação ao Workflows é dispensada (delegation dispensed, com a justificativa nomeada — "repositório local … não tem CI que rode"), e a PR de infra sai só com o que o Lead produziu. Só github e gitlab delegam.

  • Quando delega, o prompt diz que o provider recebido JÁ é o do projeto e proíbe perguntar.

  • O subagente que para com TEXTO em vez de chamar a ferramenta continua falha nomeada reportada ao lead, mas o diagnóstico diz o que ele pediu: "pediu informação que não recebeu, em vez de chamar emit_infra_delegation_result: " (até 300 caracteres).

  • O título da PR de infra leva o prefixo infra: só quando o resumo do modelo ainda não começa com ele (sem diferenciar maiúsculas) — antes saía "infra: infra: Dockerfile…" (AT-396).

  • Onde: apps/engine/lib/engine/infra/workflows_agent.ex:61 (dispensa), apps/engine/lib/engine/infra/infra_lead.ex:69 (com_prefixo), apps/engine/lib/engine/infra/workflows_agent.ex:184 (parada_sem_ferramenta), apps/engine/lib/engine/infra/infra_lead_server.ex:1290 (finalize)

    apps/engine/lib/engine/infra/infra_lead_server.ex:1290 (finalize)

  • Teste: apps/engine/test/engine/infra/workflows_agent_test.exs ("RN-710: …") e apps/engine/test/engine/infra/infra_lead_server_test.exs ("RN-710: repositório local dispensa o Workflows …") e apps/engine/test/engine/infra/infra_lead_test.exs ("com_prefixo/1 (AT-396)")

  • Origem: AT-368, AT-396

RN-712 — O fio da sessão mostra o que acabou de acontecer, uma vez e legível​

Medido no teste real de 02/10 (loja-teste): a faixa do turno mostrava Markdown cru, a resposta do formulário estruturado aparecia duas vezes, o seletor de handoff manual oferecia Dev Lead, QA e Infra desde o primeiro minuto da ideação, o "Para" ficava no PO com o Arquiteto já trabalhando, o botão "Iniciar ideação" reaparecia no meio da sessão e um docker-compose de 80 linhas empurrava a conversa para fora da tela. Desde a AT-375:

  • faixa do turno — a prévia e as linhas expandidas passam por textoPlanoDoMarkdown: **, ##, réguas, tabelas e crases viram texto corrido. A faixa é prévia; a resposta renderizada continua sendo a bolha do fim do turno (RN-460), e o store do streaming não muda (RN-639);
  • formulário respondido — a api reenvia as respostas ao agente como chat.message do usuário; a bolha cujo texto é EXATAMENTE o que a api monta ("<n>. <rótulo>: <resposta>"), primeira mensagem de usuário depois da resposta, sai do fio, porque o card respondido já as mostra. Mensagem digitada que só pareça uma lista fica;
  • destinatário — a escolha feita no SELETOR do composer é manual e nunca é sobrescrita. A que veio de um gesto (aceitar handoff nesta tela, "Estou pronto") segue o destino do último handoff ACEITO, inclusive o aceito pelo sistema (RN-660). Revisa a cláusula da RN-631 de que um aceite por outro caminho "só acrescenta a opção" — só para escolha que não foi manual. Sem escolha nenhuma e com duas opções o envio continua travado;
  • handoff manual — o seletor nasce recolhido atrás de um botão; a lista de agentes não mudou;
  • "Iniciar ideação" — some do cabeçalho assim que algum agente entrou na sessão (evento de agente ou handoff);
  • cabeçalho — o horário vira "iniciada às HH:MM:SS", e a origem do idioma quebra em até duas linhas antes de elipsar, com o texto inteiro no title;
  • bloco de código — acima de 20 linhas nasce recolhido nas 8 primeiras, com "Mostrar as N linhas".

Fica de fora, medido: a primeira bolha do Criativo "modelo não registrado" vem do ENGINE — criativo_server.ex grava o texto acumulado com modelName nulo quando o turno termina entregando um formulário (gravar_texto_do_turno(state, "", nil)), não da tela.

  • Onde: apps/web/src/lib/texto-plano.ts:7 (textoPlanoDoMarkdown), apps/web/src/lib/eco-de-formulario.ts:17 (ecosDeRespostaEstruturada), apps/web/src/lib/session-destinatario.ts:255 (resolverDestinatario), apps/web/src/components/ui/MarkdownMessage.tsx:83 (CodeFence)
  • Teste: apps/web/src/lib/texto-plano.test.ts, apps/web/src/lib/eco-de-formulario.test.ts, apps/web/src/lib/session-destinatario.test.ts ("RN-712"), apps/web/src/components/ui/MarkdownMessage.test.tsx ("RN-712")
  • Origem: AT-375

RN-709 — O cartão diz o tempo certo do verbo, o teto não oferece "sempre permitir", e o plano diz quanto custa e só sobe agente com tarefa​

Medido no projeto de teste de 02/10. Quatro defeitos de um mesmo cartão de decisão, fechados juntos:

  • O verbo segue o estado da ação (AT-371). O cartão pendente de PR dizia "abriu pull request (de ADR)" antes de abrir. VERBO_DA_ACAO fala da PROPOSTA ("quer abrir pull request…") e VERBO_DA_ACAO_EXECUTADA dá o verbo do que já aconteceu, só para os tipos cujo tempo muda; verboDaAcao recebe o status e é a fonte única.

  • PR de ADR e de infra são teto da RN-418 (AT-371). open_adr_pr e open_infra_pr entram em TIPOS_SEM_SEMPRE_PERMITIR (api e a cópia do web, conferida por teste), como pr_open — "sempre permitir" recusa gravar o padrão e o cartão não oferece o botão nem o "Modo automático". Fora do terminal, as notas não falam mais de comando ("npm test", "roda qualquer comando"): o tipo tipado tem nota própria.

  • O plano mostra a estimativa (AT-381). Ao propor propose_execution_plan a api acrescenta ao payload orcamentoPorTarefaMicros — o orçamento por tarefa VIGENTE, o mesmo que a ativação usa (setting do projeto ou o default de US$ 0,50) —, e a frase do cartão diz "até US$ X (N tarefas × US$ Y) — é o teto por tarefa, não o preço". Sem rota nova.

  • Módulo sem tarefa não ganha agente (AT-381). Aplicar o plano passa à ativação os módulos com ≥ 1 tarefa (modulosDoPlano); os do module_map sem tarefa ficam de fora e são ditos no resultado (modulosSemTarefa), ao lado de tarefasPorModulo. O botão "Ativar execução" (sem plano) e o plano sem tarefas seguem subindo o mapa inteiro. O resumo calculado chega ao Dev Lead no texto do resultado da ferramenta (resumo_do_plano/1), para ele citar os números em vez de recontar.

  • Onde: apps/web/src/lib/aprovacoes.ts:450 (verboDaAcao), apps/api/src/domain/actions/sempre-permitir.ts:33 (TIPOS_SEM_SEMPRE_PERMITIR), apps/api/src/application/use-cases/actions/propose-action.use-case.ts:84 (execute), apps/api/src/application/use-cases/execution/execute-execution-plan.use-case.ts:89 (execute), apps/api/src/application/use-cases/execution/activate-execution.use-case.ts:123 (execute), apps/engine/lib/engine/agents/dev_lead_tools.ex:587 (resumo_do_plano)

  • Teste: apps/web/src/lib/aprovacoes.test.ts ("AT-371 — o verbo segue o estado da ação", "AT-381 — o plano mostra a estimativa de gasto"), apps/web/src/components/ApprovalCard.test.tsx ("open_adr_pr pendente"), apps/api/test/domain/actions/sempre-permitir.spec.ts, apps/api/test/application/use-cases/execution/activate-execution.use-case.spec.ts ("módulos do plano"), apps/api/test/application/use-cases/execution/execute-execution-plan.use-case.spec.ts, apps/engine/test/engine/agents/dev_lead_tools_test.exs (resumo_do_plano/1)

  • Origem: AT-371, AT-381

RN-716 — A Anamnese não grava nível sobre o que não observou, e aprovar não é dominar​

  • Regra: um perfil de proficiência só é gravado quando a evidência inclui ao menos um evento escrito pela PRÓPRIA pessoa perfilada que não seja clique de aprovação (proposed_action.approved, handoff.accepted, readiness.confirmed). Evidência só de outros atores é "não observado" — ausência de oportunidade, nunca pouco conhecimento —, e "não observado" é estado próprio: o lote é recusado com motivo nomeado, nada é gravado e nenhum anamnese.profile_updated nasce. Aprovação ao lado de uma interação da pessoa continua aceita. A mensagem da pessoa que repara falha de agente é desvio do AGENTE e não traço dela: o prompt diz isso, e o registro mora na RN-717.
  • Medição (AT-376): na loja-teste de 02/10, "Git → iniciante" e "Infra → iniciante" sem a pessoa ter interagido com nenhum dos dois, e "aprova 2 ADRs sem hesitar" como arquitetura avançada.
  • Onde: apps/api/src/domain/anamnese/proficiency-validation.ts:53 (ehEvidenciaObservadaDaPessoa), apps/api/src/domain/anamnese/proficiency-validation.ts:74 (validateProficiencyBatch), apps/api/src/application/use-cases/anamnese/record-proficiency.use-case.ts:189 (resolveKnownEventIds), regras no kickoff (prompts/anamnese-kickoff.md v3 e o fallback inline de anamnese_worker.ex)
  • Teste: apps/api/test/domain/anamnese/proficiency-validation.spec.ts ("RN-716: aprovação ao lado de interação da pessoa passa"; falhas: "só evidência de outro ator é "não observado"", "aprovação sem leitura não é evidência"), apps/api/test/application/use-cases/anamnese/record-proficiency.use-case.spec.ts ("evidência só de aprovação não grava perfil nem profile_updated")
  • Lacuna declarada: reconhecer a mensagem de REPARO no lote de perfil é do modelo (prompt); o código não classifica texto de mensagem.
  • Origem: AT-376

RN-717 — O desvio do agente que a Anamnese observa vira sinal do produto, sem dado pessoal​

  • Regra: laço de agente, turno cancelado pelo usuário e mensagem do usuário que repara falha de agente são registrados pela Anamnese como anamnese.agent_deviation (ferramenta report_agent_deviation), nunca como perfil da pessoa. O payload é montado no engine por lista de permitidos — agente, tipo (laco | cancelamento_pelo_usuario | reparo_pelo_usuario) e evidenceEventIds (deduplicados, teto de 20) —, sem texto livre e sem id, nome, e-mail ou traço do usuário; campo extra é descartado. O tipo não é projetado no grafo do usuário. Tipo fora do vocabulário ou sem evidência é recusado, sem gravar.
  • Onde: apps/engine/lib/engine/anamnese/tools/report_agent_deviation.ex:73 (payload), apps/engine/lib/engine/anamnese/tools/report_agent_deviation.ex:52 (run), apps/engine/lib/engine/anamnese/tools.ex, regra no kickoff (prompts/anamnese-kickoff.md v4)
  • Teste: apps/engine/test/engine/anamnese/report_agent_deviation_test.exs ("reparo do usuário vira anamnese.agent_deviation do AGENTE, só com agente, tipo e evidência"; falhas: "tipo fora do vocabulário é recusado e nada é gravado", "sem evidência é recusado"), apps/api/test/application/graph-projection/desvio-do-agente-fora-do-grafo.spec.ts
  • Lacuna declarada: a api não confere que a evidência existe; a leitura é por consulta ao event log, sem tela (ADR 0205).
  • Origem: AT-356 (parte 2), decisão do dono de 02/10

O modo automático libera push e PR (RN-713)​

RN-713 — Com o modo automático do agente, git_push e pr_open são auto-aprovados; só a curinga libera​

Revisa a RN-418 e a RN-689, só na metade de push e PR, por decisão do dono (02/10, AT-385). Com a curinga agent_autonomy actionType: "*" em auto_approve (o modo automático, RN-153, o piloto da RN-670), git_push e pr_open tipados e o mesmo efeito pelo comando de terminal (git push, git remote add/set-url, gh pr create, glab mr create) saem auto_approve, com o motivo "modo automático (RN-713)" no proposed_action.created (RN-567).

Só a curinga libera: regra específica auto_approve sem ela, "Sempre permitir" (recusado na fonte, RN-642) e allow no permissions.json seguem no teto da RN-418, e a ativação continua semeando só git_commit. Seguem teto absoluto mesmo em modo automático: merge (tipado em branch protegida, e git merge/gh pr merge pelo terminal), deploy, sudo/doas, container_remove, instruction_patch e paralelismo. O toggle em manual restaura a aprovação.

  • Onde: apps/api/src/domain/actions/decide.ts:309 (modoAutomaticoDoAgente), :324 (decide); apps/api/src/domain/actions/external-effect.ts:294 (mensagemDoModoAutomaticoNoEfeitoExterno); apps/web/src/components/OQueOPilotoLibera.tsx
  • Teste: apps/api/test/domain/actions/decide.spec.ts (o piloto libera git push/gh pr create e os tipados; git merge, gh pr merge e sudo seguem require_approval; específica e allow sem curinga seguem no teto; curinga em require_approval pede aprovação — casos de falha); apps/api/test/application/use-cases/actions/propose-action.use-case.spec.ts (com Postgres: auto-aprovados e executados, motivo no evento; específica sozinha nasce pending); apps/web/src/components/ModoAutomaticoDoTime.test.tsx, AgentCard.test.tsx, ApprovalCard.test.tsx (a tela diz que push e PR são liberados)
  • Decisão arquitetural: ADR 0204
  • Origem: AT-385, decisão do dono em 02/10

Uma chave de cache, uma pergunta (RN-718)​

RN-718 — Quem grava as ações de uma sessão no cache faz a MESMA leitura do fio​

['session-actions', projectId, sessionId] é UMA entrada do cache do TanStack Query, e a aba Sessões gravava nela o resultado de outra pergunta: a PRIMEIRA página crescente (listActions({ limit: 200 }), sem latest), enquanto o fio da Sessão lê a cauda das 200 mais novas mais as pendentes que ela empurrou (RN-637). As duas telas trocavam o recorte uma da outra — depois de abrir a aba, o fio podia desenhar as 200 ações mais ANTIGAS da sessão, o defeito que a RN-637 fechou, reaberto pela porta do cache (AT-365).

A regra: a chave e a função andam juntas, em consultaDasAcoesDaSessao (apps/web/src/lib/acoes-da-sessao.ts). Quem LÊ as ações de uma sessão para pôr no cache usa esse par — usePendingActions e o resumo por sessão da aba Sessões. Chave própria para a aba foi considerada e descartada: pararia a troca de cache, mas manteria a aba resumindo o recorte errado. Com a mesma leitura, o resumo ganha — a pendente nova de uma sessão longa passa a contar em "aguardando". Quem só INVALIDA a chave (decisão nas telas de aprovação, o canal vivo) continua escrevendo a lista à mão: invalidar não grava dado.

  • Onde: apps/web/src/lib/acoes-da-sessao.ts:76 (chaveDasAcoesDaSessao), :80 (consultaDasAcoesDaSessao); apps/web/src/lib/hooks.ts:432 (usePendingActions); apps/web/src/routes/ProjectSessionsTab.tsx:270 (acoesPorSessao)
  • Teste: apps/web/src/routes/ProjectSessionsTab.test.tsx (describe "a leitura de ações é a mesma do fio": com o fio tendo gravado a cauda, montar a aba deixa a cauda + pendente no cache e nenhuma chamada sem latest — falha no código antigo, que gravava as 200 mais antigas; e a pendente empurrada para fora da cauda conta em "aguardando")
  • Lacuna declarada: o cartão de uma ação cujo proposed_action.created saiu da janela de 200 eventos ainda SALTA para o topo do fio (ordemDaAcaoNaTimeline degrada para createdAt e ancora em 0.5) — fixado como comportamento atual em apps/web/src/routes/SessionPage.acao-fora-da-janela.test.tsx, cuja correção é decisão separada. O mesmo teste prova que o cartão decidido não volta a pedir decisão: o que pareceu "voltar" no AT-365 era OUTRA ação.
  • Origem: AT-365

RN-719 — Ferramenta de agente aceita lista enviada como texto JSON, e exceção dentro dela volta ao laço​

Contexto: medido ao vivo (02/10, Haiku 4.5 via OpenRouter), o modelo manda lista e objeto como STRING JSON ("business_rule_ids": "[\"01M…\"]"). Três desfechos: create_story descartava os ids calado (história ficava draft), outras ferramentas recusavam e custavam voltas, e create_c4_diagram fazia Enum.map numa string e derrubava o turno do Arquiteto com agent.error.

A regra: toda ferramenta dos sete conversacionais e do ToolLoop roda por UM ponto, Engine.Harness.ArgumentosDeFerramenta.executar/4. Argumento cujo schema declara array/object e chega como string JSON válida do tipo vira o valor; string que não é recusa NOMEADA ("o campo X precisa ser uma lista"), antes de a ferramenta rodar. Campo declarado string nunca é tocado. Exceção levantada dentro da ferramenta vira tool.result ok: false com origem: "codigo", e o erro entra no laço como qualquer outro (RN-163): o turno continua.

  • Onde: apps/engine/lib/engine/harness/argumentos_de_ferramenta.ex:26 (executar), :73 (coagir), :56 (falha_interna?); apps/engine/lib/engine/agents/resultado_de_ferramenta.ex:29 (payload); apps/engine/lib/engine/harness/tool_loop.ex:368 (run_direct); apps/engine/lib/engine/agents/arquiteto_server.ex:357 (executar_ferramenta, o mesmo helper nos outros seis servidores)
  • Teste: apps/engine/test/engine/harness/argumentos_de_ferramenta_test.exs (create_story com ids em string JSON grava os ids; create_c4_diagram com actors em string JSON funciona; string não-JSON num campo array é recusa nomeada; ferramenta que lança não encerra o turno do ToolLoop e grava tool.result ok: false)
  • Origem: AT-387

RN-721 — O id encurtado de história ou tarefa é resolvido dentro do projeto​

O Arquiteto passava a assign_story_modules os 8 primeiros caracteres do id da história, a api respondia 400 "storyId must be a UUID" e, depois de oito tentativas, nenhuma história ganhava módulo (loja-teste-2, 02/10). Toda ferramenta de agente que recebe id de história ou tarefa — assign_story_modules e create_task (story_id), assess_implementability (storyId) e propose_execution_plan (tarefas[].taskId) — passa o id por Engine.Harness.IdDoBacklog antes de chamar a api:

  • UUID completo segue sem consulta.

  • Prefixo de no mínimo 8 caracteres hexadecimais é resolvido contra o backlog do PROJETO (list_backlog): um só id que começa com ele vira o UUID; nenhum é recusa nomeada ("nenhuma história do projeto tem id começando com …"); mais de um é recusa nomeada ("o prefixo … é ambíguo"). A recusa volta ao modelo como resultado da ferramenta (RN-163).

  • O que não é nem UUID nem prefixo segue como veio, e quem recusa é a api.

  • Onde: apps/engine/lib/engine/harness/id_do_backlog.ex:21 (resolver), :43 (resolver_em); apps/engine/lib/engine/agents/dev_lead_tools.ex:176 (resolver_tarefas)

  • Teste: apps/engine/test/engine/harness/id_do_backlog_test.exs (prefixo único resolve; ambíguo e inexistente recusam nomeados)

  • Origem: AT-390

RN-723 — A Infra herda a rede que o Arquiteto declarou para o projeto​

Decisão do dono em 02/10 (ADR 0208), revisando a rede literal none da subida no aceite da RN-671. A eleição da Infra usa o network da artifact.project_image mais recente emitida pelo Arquiteto (actor_id: "arquiteto", qualquer sessão do projeto), lido localmente pelo engine; sem decisão dele, ou com valor fora de none/egress, segue none. Vale para a subida do servidor no aceite e para a tool propose_container_start sem network. A autonomia semeada não muda: egress sobe sem clique, e o preço (a rede egress alcança as portas publicadas da máquina, ADR 0189) está declarado no ADR.

  • Código: apps/engine/lib/engine/session_events/event.ex:139 (rede_do_arquiteto), apps/engine/lib/engine/infra/infra_lead_server.ex:809 (rede_herdada)
  • Testes: apps/engine/test/engine/infra/infra_lead_server_test.exs — "RN-723: a subida herda a rede egress que o Arquiteto declarou" e "RN-723: rede do Arquiteto fora do vocabulário — a subida segue none"
  • Origem: AT-392

RN-720 — O PO completa a história que já existe, e não oferece o handoff ao Arquiteto com regra descoberta​

Contexto: no uso real da loja-teste-2 (02/10) o PO criou doze histórias sem regra ligada, leu em listar_regras_de_negocio que sete das oito regras estavam SEM cobertura, escreveu que não tinha ferramenta para editar história e chamou offer_handoff ao Arquiteto assim mesmo — afirmando no fio que a cobertura estava completa e que o sistema aceitaria sozinho. O aceite automático da RN-660 não aconteceu (o critério não fechava), e a história que ele recriou disparou o aviso de duplicata semântica (RN-681).

A regra:

  1. O PO tem complete_story: completa uma história draft EXISTENTE do projeto — os business_rule_ids informados são SOMADOS aos já ligados (completar nunca desliga regra; id que não é artifact.business_rule é 400), listas informadas e não vazias substituem as atuais e as omitidas ficam. História que não é draft é 409; de outro projeto, 404. Depois de gravar, a promoção segue o MESMO critério de create_story (isPromotable; auto vai a ready, manual liga proposedReady), e a escrita grava backlog.story_completed.
  2. offer_handoff do PO ao Arquiteto é RECUSADO, sem criar oferta, enquanto houver regra sem história ou história draft incompleta (sem proposedReady) — e o resultado lista as duas, com id e título, e manda completar com complete_story. A cobertura é a da api (computeCoverage, a mesma do aceite automático), nunca uma segunda conta no engine; falha de leitura não vira recusa. Os outros agentes e destinos não passam pela checagem.
  3. Quando o emit_artifact de business_rule do PO volta com o aviso de semelhança (status: warned), o resultado acrescenta que a regra parece ALTERAR uma existente e que ele deve perguntar ao usuário (ask_structured_questions) qual vale antes de seguir (AT-389: o usuário disse "taxa fixa de R$ 10", o Criativo registrou "R$ 10 por pedido" e o PO emitiu "R$ 10 por torrefador"). A regra já gravada não é desfeita; o aviso continua aviso.

O kickoff do PO diz as três coisas.

  • Onde: apps/api/src/application/use-cases/backlog/complete-story.use-case.ts:52 (CompleteStoryUseCase); apps/api/src/interfaces/http/internal/internal-sessions.controller.ts:541 (completeStoryRoute); apps/engine/lib/engine/harness/tools/complete_story.ex:47 (run); apps/engine/lib/engine/harness/tools/offer_handoff.ex:52 (recusa_por_cobertura); apps/engine/lib/engine/harness/tools/emit_artifact.ex:188 (pergunte_antes)
  • Teste: apps/api/test/application/use-cases/backlog/complete-story.use-case.spec.ts (liga a regra e propõe a promoção; auto vai a ready; regra inexistente, outro projeto e história ready recusados); apps/engine/test/engine/harness/tools/offer_handoff_test.exs (describe "cobertura (RN-720)": recusa com a lista, cobertura completa oferta e o aceite automático segue); apps/engine/test/engine/harness/tools/complete_story_test.exs; apps/engine/test/engine/harness/duplicata_semantica_test.exs (o aviso no PO manda perguntar; fora do PO ou sem aviso, não)
  • Lacuna declarada: o servidor do PO continua oferecendo sozinho no fim do turno pela RN-700, que já exigia cobertura; a recusa aqui é da FERRAMENTA. Se a duplicata semântica avisou no caso real da loja-teste-2 não foi medido nesta mudança (o banco daquela execução não foi lido).
  • Origem: AT-388, AT-389

A Anamnese não paga duas vezes pela mesma janela, e o resgate de gate não reinicia ciclo que espera alguém (RN-722)​

RN-722 — A Anamnese fecha a janela que avaliou e só roda com interação nova; o resgate de gate pergunta pela task, respeita a decisão humana pendente e estaciona o ciclo velho​

  • Regra (Anamnese): (1) a rodada que AVALIOU a janela e encerrou sem perfil (skip_proficiency) grava anamnese_runs com zero perfis, e a próxima começa em window_to — revisa a cláusula da RN-680/do moduledoc "rodada que não conclui não grava": rodada que FALHA (teto, orçamento, provider) continua sem gravar, e a que nem chega ao LLM (sem sujeito, triagem, teto diário) não fecha nada; (2) os eventos que a própria Anamnese escreve (anamnese.* e todo evento de ator agent/anamnese) e os de ator system ficam fora da janela e da contagem da triagem; (3) decisão do dono de 02/10: sujeito é quem tem ao menos 5 interações próprias NOVAS (eventos de actor_kind: "user" mais decisões, na janela que já começa no fim da última tentativa) — revisa o "ao menos UMA" da RN-680 —, e o projeto tem no máximo 4 rodadas pagas por dia UTC (concluída, falha ou run_skipped escrito pelo modelo; o pulo sem sujeito não conta). Abaixo de qualquer dos dois, nenhuma chamada ao LLM nem ao RAG; o motivo vai ao log e, só na rodada pedida à mão, a anamnese.run_skipped (causa: "teto_diario" ou "sem_sujeito_elegivel").
  • Regra (resgate de gate, revisa a RN-140): o GateRescuer (a) considera vivo o ciclo cuja TASK está em voo no lead, perguntando a ele ({:em_voo?, task_id}), e não mais o processo do projeto — lead ocioso não segura o resgate; lead ocupado demais para responder conta como vivo; (b) não reinicia ciclo cuja sessão tem proposed_action PENDENTE de ator do gate (qa*, secops*, appsec) — só registra no log; (c) decisão do dono de 02/10: ciclo parado há mais de 2 h desde a ÚLTIMA ATIVIDADE (updated_at) é ESTACIONADO (parked_at) com gate.rescue_parked no log da sessão (task, gate, idade, motivo), nem no boot nem no tick é retomado, e retomar exige gesto humano (GateRescuer.retomar_estacionado/3).
  • Onde: apps/engine/lib/engine/workers/anamnese_worker.ex:189 (fechar_janela), apps/engine/lib/engine/workers/anamnese_worker.ex:104 (dentro_do_teto_diario?), apps/engine/lib/engine/session_events/event.ex:194 (project_window_query), apps/engine/lib/engine/session_events/event.ex:202 (count_anamnese_rounds_since), apps/engine/lib/engine/anamnese/elegibilidade.ex:67 (min_interacoes_proprias), apps/api/src/application/use-cases/anamnese/record-proficiency.use-case.ts:160 (fecharJanelaSemPerfil), apps/engine/lib/engine/gates/gate_rescuer.ex:379 (task_em_voo?), apps/engine/lib/engine/gates/gate_rescuer.ex:403 (aguardando_humano?), apps/engine/lib/engine/gates/gate_rescuer.ex:321 (estacionar), apps/engine/lib/engine/gates/gate_rescuer.ex:359 (retomar_estacionado), apps/engine/lib/engine/gates/qa_lead_server.ex:95 (handle_call)
  • Teste: apps/engine/test/engine/workers/anamnese_worker_test.exs (describe "a janela avaliada fecha (RN-722)": "skip_proficiency grava a rodada vazia; a segunda começa em window_to e, sem interação nova, não chama o LLM"; falhas: "falha ao fechar a janela não vira falha da rodada", "rodada que FALHA não fecha a janela"; describe "piso de interações e teto diário (RN-722)"), apps/engine/test/engine/anamnese/elegibilidade_test.exs, apps/engine/test/engine/session_events/event_window_test.exs ("dez eventos da Anamnese sozinhos não passam a triagem"), apps/engine/test/engine/gates/gate_rescuer_test.exs ("lead do projeto vivo e OCIOSO não segura o resgate de uma task perdida", "ciclo com ação PENDENTE de ator do gate espera o humano", "ação pendente de OUTRO ator não segura o resgate", "ciclo parado há mais de 2 h é ESTACIONADO", "ciclo parado há MENOS de 2 h segue sendo resgatado"), apps/api/test/application/use-cases/anamnese/record-proficiency.use-case.spec.ts (describe "fecharJanelaSemPerfil (RN-722)")
  • Lacuna declarada: retomar um ciclo estacionado não tinha tela nem rota — era chamada de operador (bin/engine rpc); fechou na RN-724 (rota e botão no evento gate.rescue_parked). A pendência humana é lida pela SESSÃO do ciclo (a proposed_action não tem task_id) (ADR 0207).
  • Origem: AT-391 (uso real de 02/10: ~US$ 8,90 de Anamnese num dia sem ninguém pedir; ciclo de gate reiniciado com ação pendente e outro 8 h sem resgate), decisões do dono de 02/10

Revisada pela RN-923 (ADR 0226). O resgate AUTOMÁTICO (tick) de um ciclo vale uma vez: o segundo estaciona com motivo: "retomada_automatica_ja_usada" e espera o "Retomar gate", que zera o contador. O estacionamento de 2 h segue como está.

RN-724 — O ciclo de gate estacionado volta por um botão no fio, não só pelo terminal do operador​

  • Regra: o ciclo de gate ESTACIONADO pelo resgate (RN-722, ADR 0207) é retomado por POST /projects/:projectId/tasks/:taskId/gates/:gate/resume, com o MESMO papel mínimo de quem decide a PR (developer). A api só autoriza e pede ao engine (POST /internal/projects/:id/tasks/:taskId/gates/:gate/resume), que delega a GateRescuer.retomar_estacionado/3: ciclo que não existe, não está estacionado ou gate fora de qa/secops é 409 gate_nao_estacionado e nada é despachado. No fio da sessão, o evento gate.rescue_parked vira um aviso com o botão "Retomar gate", inerte com o motivo em texto quando o papel não alcança developer; a recusa mostra a frase da api. Nenhum poll novo: o andamento do gate retomado chega pelo log da sessão.
  • Onde: apps/engine/lib/engine_web/controllers/gate_command_controller.ex:15 (resume), apps/api/src/application/use-cases/gates/resume-parked-gate.use-case.ts:18 (execute), apps/api/src/infrastructure/http-clients/api-to-engine-client.ts:360 (resumeParkedGate), apps/api/src/interfaces/http/gates/gate-resume.controller.ts:40 (resume), apps/web/src/routes/RetomarGateEstacionado.tsx:59 (RetomarGateEstacionado)
  • Teste: apps/engine/test/engine_web/controllers/gate_command_controller_test.exs ("ciclo estacionado: 202 e despacha o gate de novo"; falha: "ciclo NÃO estacionado: 409 nomeado e nada despachado"), apps/api/test/interfaces/http/gates/gate-resume.controller.spec.ts (feliz, 409 gate_nao_estacionado, papel abaixo de developer), apps/web/src/routes/RetomarGateEstacionado.test.tsx (o clique chama a rota; a recusa vira toast com a frase da api; papel insuficiente deixa o botão inerte)
  • Origem: AT-397, fechando a lacuna declarada na RN-722

RN-728 — A mensagem do usuário tem uma bolha só, e o card do Dev Lead tem um primário só​

  • Regra: (1) a bolha OTIMISTA da mensagem enviada a um agente sai do fio assim que o chat.message com o mensagemId que a api devolveu no aceite está no log — casamento por id, nunca por texto, e sem esperar o fim do turno; o nome dela sai do MESMO autorDaMensagem da bolha do log (RN-652), com o e-mail do token só como fallback. Antes as duas conviviam durante o turno, uma com o nome e outra com o e-mail. (2) No card "passou o bastão ao Dev Lead", aceitar o handoff é o gesto primário e "Ativar execução" é SECUNDÁRIO, com o aviso do que pula: sem plano, os devs pegam as tarefas como estão (RN-677).
  • Onde: apps/web/src/lib/mensagem-otimista.ts:14 (otimistaJaNoLog), apps/web/src/lib/mensagem-otimista.ts:22 (nomeDaMensagemOtimista), apps/web/src/routes/SessionPage.tsx:790 (otimistaJaNoLog), apps/web/src/routes/session-timeline-montagem.tsx:617 (handleActivateExecution)
  • Teste: apps/web/src/lib/mensagem-otimista.test.ts (feliz: o id no log tira a otimista; falha: id ausente ou de outro tipo a mantém; o nome é o da linha de membro), apps/web/src/routes/SessionPage.cancelar-turno.test.tsx ("com o turno em curso, a otimista sai quando o chat.message do aceite chega ao log"), apps/web/src/routes/SessionPage.handoff-devlead-e-colapso.test.tsx ("o aceite é o primário; "Ativar execução" é secundário e diz o que pula")
  • Origem: AT-409 e AT-410 (TP-01 de 03/10); a forma do card é decisão do dono ("Secundário com aviso")

RN-725 — Texto com escape literal é decodificado, e a recusa de argumento diz o que chegou​

Contexto: no TP-01 (03/10, Claude Haiku 4.5) o PO gravou títulos de história com o escape unicode LITERAL (código): a normalização da RN-719 só decodificava campo array/object. Recriadas com o título certo, as histórias passaram pela recusa de duplicata (RN-720/RN-081), que não casou o escapado com o normal. E as recusas "exige X" não diziam o que tinha chegado: o PO gastou 28+ passos (48% do custo do teste) até o teto, e complete_story respondia "ainda faltam RF, DoD, DoR ou business_rule_ids" sem dizer qual.

A regra:

  1. No mesmo ponto da RN-719, string com \uXXXX LITERAL (par surrogate incluído) é decodificada, e com ela \n, \t, \" e \\ da MESMA string. String sem \uXXXX volta byte a byte — código num content com "\n" literal não muda. Isso revisa o "campo string nunca é tocado" da RN-719 só nesse caso.
  2. A duplicata EXATA de história (api) e de regra (engine) compara o título com o escape decodificado, antes da caixa e do NFD de sempre.
  3. Recusa de ferramenta que diz "exige X" ganha, no mesmo ponto, as CHAVES que chegaram (nomes, nunca valores), ou que chegou string/lista em vez de objeto. emit_artifact com chave faltando no payload diz as chaves faltantes e as recebidas, em vez de {:missing_keys, …}.
  4. complete_story nomeia exatamente o que falta (falta: business_rule_ids), pela mesma régua de missingForReady da api, lida da história devolvida.
  • Onde: apps/engine/lib/engine/harness/argumentos_de_ferramenta.ex:102 (decodificar_escapes), :152 (chaves_recebidas), :166 (com_chaves_recebidas); apps/engine/lib/engine/harness/tools/complete_story.ex:80 (faltam); apps/api/src/domain/backlog/story-overlap.ts:39 (decodificarEscapesLiterais), :25 (normalizarTitulo); apps/engine/lib/engine/harness/artifact_dedupe.ex (normalizar)
  • Teste: apps/engine/test/engine/harness/argumentos_de_ferramenta_test.exs (describe "RN-725": escape decodificado, texto sem \u e lista intactos; recusa com as chaves e sem os valores; complete_story nomeia o campo); apps/api/test/domain/backlog/story-overlap.spec.ts (título escapado casa com o normal; título diferente segue diferente)
  • Origem: AT-405, AT-408

RN-726 — Crédito do provedor esgotado pausa o dev agent, com origem infra, sem queimar as tarefas seguintes​

  • Regra: quando o provedor do modelo recusa por falta de CRÉDITO (HTTP 402, ou o texto "available credits"/"add credits" do OpenRouter), o turno do dev agent não é "parou sem concluir": a tarefa é bloqueada com o motivo "crédito do provider esgotado" e origem infra (nunca codigo), o contador da parada automática (RN-047) NÃO avança, e o agente NÃO reivindica a próxima tarefa — fica em idle_tripped e emite dev.credit_exhausted (motivo: credito_esgotado), nunca dev.idle_tripped. A retomada é o Rearmar que já existia no painel do time, depois de recarregar o crédito; a tarefa bloqueada volta pelo desbloqueio de sempre. A tela de Executores mostra o agente travado com "crédito do provedor do modelo esgotado — recarregue e rearme para retomar", e o sino diz o mesmo. O mesmo reconhecimento faz FalhaDeTurno.origem/1 devolver infra para o frame final de crédito (vale para os agentes de gate, como o QA). A sessão conta dev.credit_exhausted como trabalho pendente, como dev.idle_tripped. Desde a RN-730 o 402 chega com code próprio (insufficient_credit) e é ele que decide; o reconhecimento pelo texto ficou só como rede.
  • Onde: apps/engine/lib/engine/agents/falha_de_turno.ex:142 (credito_esgotado?), apps/engine/lib/engine/dev/dev_agent_server.ex:902 (handle_outcome), apps/engine/lib/engine/dev/agent_io.ex:271 (pausar_por_credito), apps/web/src/lib/agent-status.ts:198 (breakerReasonFor), apps/api/src/application/use-cases/sessions/get-session-pending-work.use-case.ts
  • Teste: apps/engine/test/engine/dev/dev_agent_server_test.exs ("402 do provider: origem infra, pausa sem idle_tripped e sem queimar a próxima task"; falha: "erro que não é de crédito segue como antes: parou sem concluir, conta no disjuntor"), apps/engine/test/engine/agents/falha_de_turno_test.exs ("402 do provider é infra e é reconhecido"), apps/web/src/lib/agent-status.test.ts ("RN-726: dev.credit_exhausted é travado com motivo de crédito…")
  • Origem: AT-407 (TP-01 de 03/10: 402 do OpenRouter virou três tarefas bloqueadas com origem codigo e a parada automática)

RN-730 — O 402 do provider tem code próprio, e é ele que decide o crédito esgotado​

  • Regra: o HTTP 402 do provider de LLM é normalizado como insufficient_credit (antes caía em upstream), na mesma tabela de status do ADR 0041 — os demais status continuam como estavam. O code viaja ao engine como errorCode ao lado do error, no corpo de llm-turn e no frame final de llm-turn/stream (null sem erro ou com erro que não é do provider, como orçamento e binding ausente). No engine, FalhaDeTurno.credito_esgotado?/1 decide primeiro pelo code (insufficient_credit é crédito; outro code não é, mesmo com a frase), e o frame {:final, texto, code} dos conversacionais dá origem infra pelo código. O reconhecimento por texto da RN-726 fica só como REDE: api anterior ao campo, e caminho que só leva a mensagem.
  • Onde: apps/api/src/domain/llm/llm-provider-errors.ts:38 (LLMInsufficientCreditError), :86 (normalizeHttpStatus), apps/api/src/application/use-cases/llm/run-llm-turn.use-case.ts:244 (streamErrorCode), apps/api/src/application/use-cases/llm/stream-llm-turn.use-case.ts:273 (streamErrorCode), apps/engine/lib/engine/agents/falha_de_turno.ex:91 (credito_esgotado?), :71 (origem), apps/engine/lib/engine/harness/tool_loop.ex:168 (last_error_code), apps/engine/lib/engine/dev/dev_agent_server.ex:902 (handle_outcome)
  • Teste: apps/api/test/contract/llm-provider.contract.ts (erro_402 vira chunk de erro com code "insufficient_credit"; falha: erro_500 segue upstream), rodado por todas as suítes de contrato de provider; apps/engine/test/engine/agents/falha_de_turno_test.exs ("insufficient_credit é crédito esgotado e origem infra, sem frase nenhuma"; falha: "outro code não é crédito, mesmo com a frase; sem code, o texto é a rede")
  • Origem: AT-416 (a lacuna declarada da RN-726)

RN-729 — A PR de infra mira dev e diz onde mergear; a sidebar abre só o projeto aberto​

  • Regra: quatro cláusulas medidas no TP-01 (03/10). (1) A PR de infra (open_infra_pr) segue a RN-664: a branch feature/infra-setup nasce de dev e a PR mira dev — o worktree do dev agent nasce de dev, então o Dockerfile mergeado em main nunca chegava a ele. Repositório sem dev falha nomeado pelo provider, sem queda para a default. A PR leva o autor <agente>[bot] (infra[bot] quando o ator não é agente), a mesma identidade da RN-705. Em Aprovações, cada PR de infra em revisão ganha "Mergear na aba PRs", que troca a moldura para a aba PRs (onde o merge mora). (2) Abrir um projeto RECOLHE os outros na sidebar e rola a lista até ele; no Dashboard nenhum fica forçado expandido; o chevron segue alternando à mão. (3) Os campos de texto do assistente "Novo projeto" (nome, caminho, repositório adotado) levam SEM_AUTOFILL, o mesmo conjunto da RN-693. (4) Decidir um merge na aba PRs invalida a fila inteira de pendentes do projeto, então o contador "PRs" da sidebar não segue mostrando o merge já executado até o próximo poll; e o lead de QA (ou SecOps) deixa de dizer "trabalhando" quando, depois do último parecer, um subagente da área registra delegation.failed — passa a falhou. Nenhuma query nem poll novo.
  • Onde: apps/api/src/application/use-cases/actions/execute-infra-pr.use-case.ts:71 (ExecuteInfraPrUseCase), apps/web/src/routes/ProjectApprovalsTab.tsx:106 (ProjectApprovalsTab), apps/web/src/routes/Shell.tsx:582 (idDoProjetoAtual), apps/web/src/routes/NewProjectWizard.tsx:155 (NewProjectWizard), apps/web/src/routes/ProjectPrsTab.tsx:141 (invalidateMergeActions), apps/web/src/lib/agent-status.ts:222 (gateStatus)
  • Teste: apps/api/test/application/use-cases/actions/execute-infra-pr.use-case.spec.ts ("a branch nasce de dev, a PR mira dev e leva infra[bot]"; falha: "repositório sem dev falha nomeado, sem cair na default"), apps/web/src/routes/Shell.test.tsx ("abrir um projeto recolhe o visitado antes e rola até o aberto", "no Dashboard nenhum projeto fica forçado expandido"), apps/web/src/routes/NewProjectWizard.test.tsx ("o campo de nome não convida o autofill"), apps/web/src/lib/agent-status.test.ts ("delegação falhada depois do parecer encerra o trabalhando")
  • Origem: AT-411, AT-412, AT-413, AT-415

RN-727 — A história draft se corrige e se arquiva, pelo PO e pelo usuário, sem apagar nada​

  • Regra: uma história draft SEM tarefa em execução (in_progress ou in_review) pode ter o título corrigido e pode ser ARQUIVADA — pelo PO (ferramentas update_story, que também corrige a descrição, e archive_story) e pelo usuário na aba Backlog ("Editar título" e "Arquivar", com confirmação). As duas portas passam pela MESMA régua (recusaDeCorrecaoDeHistoria), e a recusa é 409 NOMEADO: historia_arquivada, historia_nao_draft ou historia_com_tarefa_em_execucao, com a frase que o modelo e o toast mostram. Arquivar NÃO apaga: a linha de stories ganha archived_at/archived_reason (migration 0069, ADR 0212), as tarefas ficam, e o evento NOVO backlog.story_archived guarda quem, por quê e quais tarefas; a edição grava backlog.story_updated com o título anterior. A história arquivada sai do backlog (listar_backlog incluído — o PO não a vê para não completá-la de novo), da cobertura (computeCoverage lê só as não arquivadas), da fila de promoção, do plano do Dev Lead e do claim, e as tarefas dela saem junto, pela história; transição, complete_story e create_task recusam história arquivada. O papel mínimo do usuário é o das outras escritas de backlog (promover/devolver, RN-048): developer; abaixo dele os botões ficam inertes com o motivo em texto, e o mesmo vale para história fora de draft ou com tarefa em execução. Cada história mostra quantas tarefas tem.
  • Onde: apps/api/src/domain/backlog/correcao-de-historia.ts:33 (recusaDeCorrecaoDeHistoria), apps/api/src/application/use-cases/backlog/corrigir-historia.use-case.ts:47 (editar), :95 (arquivar), apps/api/src/infrastructure/persistence/drizzle/backlog.repository.ts:97 (findByProject), :403 (claimNext), apps/api/src/interfaces/http/backlog/backlog.controller.ts:205 (updateTitle), :219 (archive), apps/api/src/interfaces/http/internal/internal-sessions.controller.ts:576 (updateStoryRoute), :609 (archiveStoryRoute), apps/engine/lib/engine/harness/tools/update_story.ex:45 (run), apps/engine/lib/engine/harness/tools/archive_story.ex:38 (run), apps/web/src/routes/ProjectBacklogTab.tsx:76 (motivoDeCorrecaoInerte)
  • Teste: apps/api/test/application/use-cases/backlog/corrigir-historia.use-case.spec.ts (feliz: título e arquivo com evento; falha: tarefa in_progress/in_review é 409 historia_com_tarefa_em_execucao sem escrita), apps/api/test/application/use-cases/execution/claim-next-task.use-case.spec.ts ("não pega task de story ARQUIVADA, nem a conta, nem a lista", contra Postgres), apps/api/test/interfaces/http/backlog/backlog-corrigir-historia.controller.spec.ts (o papel das duas rotas é o de promover; viewer não alcança), apps/engine/test/engine/harness/tools/corrigir_historia_test.exs (a recusa nomeada chega ao modelo), apps/web/src/routes/ProjectBacklogTab.test.tsx ("corrigir história (RN-727)": feliz, tarefa em execução e papel abaixo de developer deixam inerte com o motivo)
  • Origem: AT-406 (TP-01 de 03/10: o PO criou duas histórias duplicadas, chamou delete_story e recebeu "ferramenta desconhecida"; as duplicatas ganharam tarefa e entraram no plano), decisão do dono de 03/10 ("PO e usuário")

RN-731 — O fecho do turno diz o que foi gravado com o número do servidor​

  • Regra: o número do que um agente conversacional gravou num turno é FATO do servidor, nunca texto do modelo (RN-163). O Criativo, o PO e o Arquiteto contam, no processo do turno, cada ferramenta de escrita que voltou ok (emit_artifact por tipo — regra de negócio, decisão, nota, outro —, create_epic, create_story, update_story, archive_story, create_task), e o fecho da agent.response ganha, DEPOIS do texto do modelo e sem reescrevê-lo, a linha "Gravado neste turno: N …". Ferramenta recusada e leitura não contam; turno sem escrita não ganha linha; a contagem morre com o turno. A linha sai no idioma do turno (RN-622): o do autor, senão o do projeto; pt* em português ("Gravado neste turno: 1 regra de negócio"), qualquer outro idioma — ou nenhum resolvido — em inglês ("Recorded this turn: 2 business rules"), com singular e plural nos dois.
  • Onde: apps/engine/lib/engine/agents/gravado_no_turno.ex:62 (anotar), :95 (descarregar), apps/engine/lib/engine/harness/idioma_da_resposta.ex:272 (idioma_do_turno), apps/engine/lib/engine/agents/texto_do_turno.ex:65 (payload_do_turno)
  • Teste: apps/engine/test/engine/agents/gravado_no_turno_test.exs (feliz: seis regras e uma decisão gravadas, uma recusada — o fecho "8 regras" do modelo fica e ganha "6 regras de negócio, 1 decisão" ao lado; sem escrita: nada acrescentado; pt-BR e en, singular e plural; idioma fora dos dois cai em en), apps/engine/test/engine/agents/criativo_server_test.exs
  • Origem: AT-414 (TP-01 de 03/10: o Criativo fechou com "Registrei as 8 regras de negócio" e o painel tinha 6)

RN-732 — O texto do turno passa pela mesma régua de escape dos argumentos​

  • Regra: o texto que o modelo escreve no turno de um agente conversacional — o content (fecho) e cada passos da agent.response — passa pela MESMA decodificar_escapes/1 da RN-725 antes de ser gravado: \u00e3 literal vira ã. Uma régua só, sem cópia, e com o mesmo limite: só age quando há \uXXXX literal, então texto sem escape — inclusive código com "\n" literal num bloco cercado — volta byte a byte. O agent.delta do streaming NÃO é decodificado: um escape pode chegar partido entre dois deltas, e a bolha gravada substitui o texto em curso no fim do turno. Regra própria, e não cláusula da RN-725, porque a superfície é outra (resposta do modelo, não argumento de ferramenta) e a decisão sobre o delta é desta.
  • Onde: apps/engine/lib/engine/agents/texto_do_turno.ex:65 (payload_do_turno), apps/engine/lib/engine/harness/argumentos_de_ferramenta.ex:102 (decodificar_escapes)
  • Teste: apps/engine/test/engine/agents/texto_do_turno_test.exs (feliz: os cinco escapes medidos, no fecho e num passo; intacto: texto sem escape e bloco de código com \n literal)
  • Origem: AT-417 (TP-01 de 03/10, 2ª rodada: a resposta final do Criativo gravada com "colis\u00e3o", "usu\u00e1rio" e mais três escapes na bolha)

RN-735 — A rede none da imagem do Arquiteto diz que não instala dependências​

  • Regra: a descrição de choose_project_image e o RESULTADO dela dizem que, com rede none, nenhum gerenciador de pacotes (npm, pnpm, pip, mix, cargo, go mod...) instala dependência no container, e que a Infra herda essa rede (RN-723). Com none, o resultado pede ao modelo que chame a ferramenta de novo com egress se a stack instala dependências. É AVISO: a api não adivinha a stack, e a contenção do broker (ADR 0130) não muda. (Desde a RN-906 a api RECUSA none quando a stack declarada de um módulo baixa dependências; fora desse caso, segue só o aviso.)
  • Código: apps/engine/lib/engine/harness/tools/choose_project_image.ex:96 (mensagem_de_fixada), apps/engine/lib/engine/harness/tools/choose_project_image.ex:23 (spec)
  • Teste: apps/engine/test/engine/harness/tools/choose_project_image_test.exs
  • Origem: AT-420 (TP-01 de 03/10: node:22-bookworm-slim com rede none, e o dev agent não conseguiu npm install)

RN-736 — O campo "Nome do projeto" não ganha foco sozinho nem se chama "name"​

  • Regra: no passo de detalhes do assistente de projeto, o campo "Nome do projeto" NÃO recebe autoFocus e usa id/name project-title (desde a RN-834, projeto-slug), nunca "name" puro; os atributos que desligam gerenciadores de senha (data-1p-ignore, data-lpignore, data-bwignore, data-form-type="other") continuam vindo da fonte única SEM_AUTOFILL (RN-729). Decisão: sem foco automático em caso nenhum (nem por teclado) — o menu "Contact Info" dessas extensões abre no FOCO, ignora autocomplete="off" e cobria a página; um clique ou Tab a mais é o preço. Os nomes dos atributos são os documentados publicamente por 1Password, LastPass, Bitwarden e Dashlane, não medidos contra cada extensão aqui.
  • Onde: apps/web/src/routes/NewProjectWizard.tsx:746 (projeto-slug), apps/web/src/lib/conversa-comecou.ts:29 (SEM_AUTOFILL)
  • Teste: apps/web/src/routes/NewProjectWizard.test.tsx ("RN-736: o campo de nome não ganha foco sozinho nem se chama "name"" e o de RN-729, que confere os quatro atributos)
  • Origem: AT-418 (TP-01, 2ª rodada de 03/10)

RN-740 — O campo "Nome do projeto" declara que não sugere nada, e o resto da lacuna é da extensão​

  • Regra: SEM_AUTOFILL acrescenta aria-autocomplete="none" (a árvore acessível diz que o campo não oferece sugestão) e data-protonpass-ignore (o atributo que o Proton Pass documenta). O rótulo visível e o nome acessível continuam "Nome do projeto": trocar só o aria-label quebraria o "rótulo no nome" (WCAG 2.5.3), e trocar o rótulo visível é decisão de produto, não tomada. Medido no TP-01 de 03/10: o menu "Contact Info" abre ao focar/digitar mesmo com os atributos da RN-736 — a extensão provavelmente classifica pelo TEXTO do rótulo. Se o próximo TP-01 reproduzir com estes atributos, a lacuna é da EXTENSÃO e fica declarada aqui.
  • Onde: apps/web/src/lib/conversa-comecou.ts:29 (SEM_AUTOFILL)
  • Teste: apps/web/src/routes/NewProjectWizard.test.tsx ("RN-740: …")
  • Origem: AT-425 (TP-01, 3ª rodada de 03/10)

RN-737 — A resposta cortada pelo teto de saída é sinalizada, não executada e dita​

  • Regra: a api lê o motivo de parada nos dois dialetos — finish_reason: "length" no OpenAI-compatível (OpenRouter incluído) e stop_reason: "max_tokens" no Anthropic — e, só quando a resposta foi cortada pelo teto de saída (RN-734), devolve truncated: true na resposta de llm-turn e no frame final de llm-turn-stream (campo aditivo; ausente no caso normal). O engine anota o sinal no processo do turno, a cada chamada de LLM: (a) a chamada de ferramenta de resposta cortada NÃO é executada — volta ao laço como erro nomeado ("a resposta foi cortada pelo limite de tokens… Reenvie o argumento em partes menores"), que o modelo lê e corrige (RN-163); (b) o fecho de texto cortado ganha, DEPOIS do texto do modelo e sem reescrevê-lo, a linha no idioma do turno (RN-622, molde da RN-731): pt* "Resposta cortada pelo limite de tamanho.", qualquer outro — ou nenhum — "Response cut off by the length limit.". O Ollama não lê o motivo de parada (fora do escopo, declarado).
  • Onde: apps/api/src/infrastructure/llm/openai-compatible-provider.ts:314 (cortadaPeloTeto), apps/api/src/infrastructure/llm/anthropic-provider.ts:198 (finalMessage), apps/api/src/application/use-cases/llm/run-llm-turn.use-case.ts:240 (truncated), apps/api/src/application/use-cases/llm/stream-llm-turn.use-case.ts:269 (truncated), apps/engine/lib/engine/harness/resposta_cortada.ex:43 (registrar), :89 (descarregar), apps/engine/lib/engine/harness/argumentos_de_ferramenta.ex:26 (executar), apps/engine/lib/engine/agents/texto_do_turno.ex:65 (payload_do_turno)
  • Teste: apps/api/test/contract/llm-provider.contract.ts ("resposta cortada pelo teto de saída emite o sinal truncated" e "resposta normal não traz o sinal de corte", rodados com servidor falso pelos harnesses OpenAI-compatível e Anthropic), apps/engine/test/engine/harness/resposta_cortada_test.exs (ferramenta de resposta cortada não roda e volta erro nomeado; resposta normal roda; fecho cortado ganha a linha em pt-BR sem reescrita; fecho normal intacto; pt/en)
  • Origem: AT-423 (lacuna declarada na RN-734)

RN-733 — A falha de crédito diz o que fazer, e o reason nunca é a tupla​

  • Regra: o reason gravado no agent.error dos conversacionais, do Infra Lead e do ToolLoop (dev agent) é o TEXTO do frame final quando há um — nunca a tupla {:final, …} inspecionada; outras formas seguem com inspect/1. Com crédito do provider esgotado (errorCode: insufficient_credit, RN-730, ou o reconhecimento por texto da RN-726), a mensagem da bolha é curta e acionável no idioma do turno (RN-622): pt "Crédito do provedor do modelo esgotado — recarregue a chave e tente de novo.", qualquer outro idioma "The model provider's credit is exhausted — top up the key and try again."; sem idioma resolvido, pt-BR. O JSON do provider fica só no reason (diagnóstico). Os demais erros mantêm a frase de sempre.
  • Onde: apps/engine/lib/engine/agents/falha_de_turno.ex:166 (mensagem), :189 (diagnostico)
  • Teste: apps/engine/test/engine/agents/dev_lead_server_test.exs (402 em pt-BR e en: frase curta, reason sem {:final; erro upstream: frase de sempre), apps/engine/test/engine/agents/falha_de_turno_test.exs
  • Origem: AT-421 (TP-01 de 03/10, 2ª rodada: o Dev Lead gravou reason: "{:final, …" e a bolha despejou o JSON do 402)

RN-734 — O caminho OpenAI-compatível manda o teto de saída SEMPRE​

  • Regra: toda chamada de chat pelo dialeto /chat/completions (os providers que nascem de OpenAICompatibleProvider, OpenRouter incluído) leva o campo de teto de saída — max_tokens ou max_completion_tokens, conforme maxTokensField — mesmo quando quem chama não define maxTokens. Sem definição, o valor é MAX_TOKENS_PADRAO (4096), o MESMO DEFAULT_MAX_TOKENS do anthropic-provider.ts, para o agente ter o mesmo teto nos dois dialetos. O motivo é a RESERVA de crédito: sem o campo, o OpenRouter reserva por chamada a saída MÁXIMA do modelo e recusa com 402 ("would exceed your available credits given your current in-flight requests") com saldo que cobriria a chamada real. Hoje nenhum chamador (engine → llm-turn) define maxTokens, então o padrão vale para todos os agentes. A resposta cortada pelo teto (finish_reason: "length") é lida e narrada desde a RN-737 (a RN-698 narra o teto de ITERAÇÕES, não o de tokens). Hipótese NÃO medida com chave real: que a reserva explica o 402 do TP-01.
  • Onde: apps/api/src/infrastructure/llm/openai-compatible-provider.ts:38 (MAX_TOKENS_PADRAO), :618 (flags)
  • Teste: apps/api/test/infrastructure/llm/openai-compatible-provider.contract.spec.ts (sem maxTokens o corpo leva max_tokens: 4096; com maxTokens: 123 leva 123; com maxTokensField: 'max_completion_tokens' o campo muda e max_tokens não vai)
  • Origem: AT-419 (TP-01 de 03/10: US$ 1,79 livres e 402 na chamada do Dev Lead e na do dev agent)

RN-738 — Parar container de projeto é imediato, e a linha da /containers relê ao decidir​

  • Regra: todo container de projeto sobe com --init — CONSTANTE da composição do docker run em DockerViaCli, nunca campo da spec (a contenção é o tipo, ADR 0130); vale para o broker e para o runner, que usam a mesma porta. O comando de espera (sleep infinity) era PID 1 sem handler de SIGTERM, e o kernel não entrega sinal de ação padrão a PID 1: docker stop esperava os 10 s e matava (Exited (137)). Medido em container descartável: 10,25 s sem --init, 0,20 s com. Na página /containers, decidir a ação pendente (aprovar, negar, sempre permitir) invalida ['containers-overview'] e ['container-lifecycle', projectId] no finally — também quando a chamada falha ou cai —, e devolve a promessa ao card. Nenhum poll novo (RN-579/632).
  • Onde: packages/docker-port/src/docker-cli.ts:785 (argsDeCriacao), apps/web/src/components/AcoesDoContainer.tsx:61 (invalidateContainers), :172 (aprovar)
  • Teste: packages/docker-port/src/docker-cli.spec.ts (run leva --init antes do comando), apps/broker/src/contencao-do-brabo.spec.ts (os dois modos sobem com --init), apps/web/src/routes/ContainersPage.test.tsx (aprovar relê visão geral e ciclo de vida; aprovação recusada ainda relê)
  • Origem: AT-422 (instalação local, 03/10: três paradas executed com a linha em "rodando" e Exited (137))

RN-739 — O diagnóstico leva o corpo de erro do provider inteiro, e a bolha segue curta​

  • Regra: a mensagem do erro normalizado de provider (ADR 0041) leva o corpo de erro até LIMITE_DO_DETALHE = 4 000 caracteres (era 200, e cortava o JSON do OpenRouter antes do metadata.reason, que diz a natureza do erro). Essa mensagem é o texto do frame final e vira, verbatim, o reason do agent.error (FalhaDeTurno.diagnostico/1, RN-733). A frase da bolha NÃO cresce com ela: fora do caso de crédito (que já tem frase curta), o texto narrado entra na bolha cortado em 300 caracteres com "…".
  • Onde: apps/api/src/domain/llm/llm-provider-errors.ts:164 (LIMITE_DO_DETALHE), apps/engine/lib/engine/agents/falha_de_turno.ex:284 (@teto_da_bolha), :289 (curto)
  • Teste: apps/api/test/domain/llm/llm-provider-errors.spec.ts (o 402 leva "metadata":{"reason":"insufficient_credits" inteiro; corpo acima do teto corta), apps/engine/test/engine/agents/falha_de_turno_test.exs (RN-739: o reason contém o metadata; erro longo corta na bolha e não no diagnóstico)
  • Origem: AT-424 (TP-01, 3ª rodada, 03/10: reason cortado em …"code":402,"metadata":{"reason":"in…)

RN-741 — O raciocínio tem orçamento próprio e não come a saída visível​

  • Regra: quando o modelo do binding raciocina (models.supports_reasoning, do catálogo), os casos de uso de LLM mandam reasoning: true ao provider. Um provider com campoDeRaciocinio configurado (hoje só o OpenRouter) manda um orçamento EXPLÍCITO de raciocínio — reasoning: { max_tokens: ORCAMENTO_DE_RACIOCINIO }, 4096 — e o max_tokens da chamada passa a ser a saída visível (o teto da RN-734) MAIS esse orçamento, para o texto e a chamada de ferramenta continuarem com o teto deles. O raciocínio nunca é desligado (exclude/enabled: false não são enviados). Modelo sem raciocínio, e provider sem a configuração, mandam o corpo de antes, byte a byte. O anthropic-provider direto não envia thinking, então o modelo não raciocina por ali e o buraco não existe — declarado, sem mudança. A forma do campo foi lida da doc do OpenRouter (Reasoning Tokens: reasoning.max_tokens; o max_tokens da chamada tem de ser maior que ele). Revisada pela RN-782/RN-783 (AT-467): a medição com credencial de 10/10 mostrou que o Haiku 5.5 IGNORA reasoning.max_tokens; o campo deixou de ser enviado e a folga no max_tokens ficou.
  • Onde: apps/api/src/infrastructure/llm/openai-compatible-provider.ts:48 (ORCAMENTO_DE_RACIOCINIO), :624 (controla)
  • Teste: apps/api/test/infrastructure/llm/openai-compatible-provider.contract.spec.ts (raciocínio com orçamento próprio (RN-741): orçamento e teto somado; sem raciocínio o corpo não muda; provider sem campo não inventa orçamento)
  • Origem: AT-427 (TP-01, 08/10: anthropic/claude-haiku-5.5 com output_tokens = reasoning_tokens = 4096 em duas chamadas seguidas do PO)

RN-756 — A Anamnese só roda com interação de conteúdo técnico do sujeito; aprovar não conta​

  • Regra: revisa a contagem de "interação própria" da RN-680/RN-722. Só contam para fazer alguém sujeito da rodada (a) evento actor_kind: "user" do próprio membro cujo tipo está em tipos_de_evento_com_conteudo/0 — chat.message (mensagem escrita por ele) e chat.structured_question_answered — e (b) decisão de ação com motivo de recusa escrito (rejectionReason não vazio, uma correção). Aprovação, aceite de handoff, clique e qualquer outro evento de usuário NÃO contam: não são evidência de competência. O piso de 5 (RN-722), o teto diário e o desfecho sem sujeito (motivo no log; anamnese.run_skipped só na rodada pedida à mão) não mudam, e a triagem segue LOCAL, antes do LLM e do RAG.
  • Código: apps/engine/lib/engine/anamnese/elegibilidade.ex:61 (avaliar), :102 (recusa_com_motivo?), :108 (tipos_de_evento_com_conteudo)
  • Teste: apps/engine/test/engine/anamnese/elegibilidade_test.exs (só interação com conteúdo técnico conta (RN-756, AT-439): mensagem e resposta estruturada fazem sujeito; só aprovações, handoff e recusa sem motivo não fazem)
  • Origem: AT-439 (TP-01, 08/10, projeto c1dc053d: duas rodadas durante a execução, US$ 0,20, ambas skip_proficiency com "o usuário apenas aprovou propostas")

RN-742 — O dev agent não morre calado, e a resposta HTTP tardia não fica na caixa dele​

  • Regra: toda chamada HTTP não-streamada do engine à api (post_returning/3 e os GETs de EngineApiClient.Live) roda numa Task descartável; a resposta que chega depois do receive_timeout cai num processo que já acabou, nunca na caixa de mensagens de quem chamou, e exceção dentro do pedido volta como {:error, _}. E o dev agent que CAI (saída que não é :normal nem :shutdown) não é tratado como terminado: o Engine.Dev.Monitor grava agent.error DURÁVEL com origem infra (reason: processo_do_dev_caiu, religado, taskId) e o RELIGA a partir da própria linha de dev_agent_states, até 3 vezes por agente; o resume passa pelo handle_continue({:restart_recovery, _}) que já existia, que bloqueia a task interrompida com diagnóstico (origem infra) e tenta o próximo claim. Sem linha, ou no teto, a linha sai e o erro diz que não houve religamento. O llm_turn_stream/6 fica fora do isolamento (o into: grava no dicionário do chamador); os conversacionais já o chamam de dentro da Task do turno (TurnoAssincrono), então o padrão "HTTP dentro do GenServer" não os alcança — declarado. A web não mudou: a aba Executores passa a ver o agent.error e o dev.blocked da recuperação.
  • Onde: apps/engine/lib/engine/sessions/engine_api_client.ex:2137 (isolado), apps/engine/lib/engine/dev/monitor.ex:69 (ao_terminar), :107 (registrar_e_religar)
  • Teste: apps/engine/test/engine/sessions/engine_api_client_isolado_test.exs (mensagem tardia não chega ao chamador; exceção vira {:error, _}), apps/engine/test/engine/dev/monitor_test.exs (crash grava agent.error infra e religa; :normal apaga a linha; sem linha não religa)
  • Origem: AT-428 (08/10: CaseClauseError em Finch.HTTP1.Conn.receive_response/8 depois do timeout do npm install)

Revisada pela RN-922 (ADR 0226). O dev que cai com working recente e sem retomada anterior na tarefa deixa de ser bloqueado: preserva o worktree como commit e retoma a mesma tarefa uma vez. Fora disso o bloqueio descrito acima segue.

RN-743 — Bloquear a task preserva o trabalho do worktree, e a próxima parte dele​

  • Regra: antes de bloquear uma task (qualquer origem, inclusive o teto de orçamento taskBudgetMicros, cujo VALOR não muda aqui), o que o dev agent deixou no worktree vira commit na branch da própria task, com a identidade <agente>[bot] (git add -A + commit --no-verify). O dev.blocked leva trabalhoPreservado ({branch, commit} ou null) e o diagnóstico DIZ onde o trabalho ficou — ou que não foi possível preservá-lo, com o motivo; a falha ao preservar nunca impede o bloqueio. A próxima task do MESMO agente (logo, do mesmo módulo) nasce dessa branch (create_from/4) em vez de dev, uma vez só. Worktree limpo não gera commit. Declarado: o ponteiro mora só em memória (restart do engine perde o ponteiro, não o commit), e no modo runner o worktree está na máquina do usuário e o engine não preserva nada.
  • Onde: apps/engine/lib/engine/dev/worktree_manager.ex:72 (preservar_em), :55 (create_from), apps/engine/lib/engine/dev/agent_io.ex:491 (preservar_trabalho), apps/engine/lib/engine/dev/agent_io.ex:321 (base_preservada)
  • Teste: apps/engine/test/engine/dev/worktree_manager_test.exs (preservar_em/3 e create_from/4: commit com dev-api[bot] e a próxima task enxerga o arquivo; limpo é :nada; pasta sem repositório é erro), apps/engine/test/engine/dev/bloqueio_preserva_trabalho_test.exs (o dev.blocked diz a branch; falha ao preservar não impede o bloqueio)
  • Origem: AT-429 (08/10: feature/task-86dade03 sem commit, package.json e src perdidos depois do bloqueio por orçamento)

RN-744 — O dev sabe que o shell é sh e começa vendo o próprio worktree​

  • Regra: a descrição da ferramenta terminal diz que o shell do container é sh (dash), não bash — sem brace expansion, sem [[ ]], sem arrays, sem source — e dá a forma que funciona. O kickoff de cada task do dev agent ganha uma segunda mensagem fixada com o RETRATO do worktree: a branch e os arquivos que já existem (rastreados + não rastreados não ignorados), com teto de 80 linhas e o total real quando corta (ADR 0060); é o servidor que monta, sem parâmetro do modelo. Pasta que o engine não alcança (modo runner) diz que o retrato está indisponível e manda listar pelo terminal. Declarado e NÃO corrigido: o worktree do dev pode nascer antes do merge da PR de infra, então o que a Infra entregou por PR pode não estar na dev de onde ele parte.
  • Onde: apps/engine/lib/engine/harness/tools/terminal.ex:12 (spec), apps/engine/lib/engine/dev/worktree_manager.ex:108 (retrato), apps/engine/lib/engine/dev/dev_agent_server.ex:595 (retrato_do_worktree)
  • Teste: apps/engine/test/engine/dev/worktree_manager_test.exs (retrato/2: branch e arquivos; pasta inalcançável diz indisponível; a descrição do terminal diz sh)
  • Origem: AT-444 (TP-01 de 08/10: mkdir -p src/{a,b} no dash e o dev recriando o que já existia)

RN-745 — Na resposta cortada, só a chamada incompleta é recusada​

  • Regra: quando a resposta do modelo vem cortada pelo teto de saída (truncated: true, RN-737), só a ÚLTIMA chamada de ferramenta da resposta é tratada como incompleta e recusada com o erro nomeado (que agora diz "a última da resposta", que as anteriores foram executadas e pede "uma por chamada"). As chamadas anteriores da mesma resposta executam normalmente — medido no código: o stream OpenAI-compatível monta as chamadas em ordem, o corte cai na última, e o parseArgumentos da api devolve {} para o JSON partido. A chamada é identificada por nome e argumentos. Resposta cortada sem chamada conhecida continua recusando toda ferramenta (régua da RN-737). A descrição de propose_adr orienta um ADR por resposta.
  • Onde: apps/engine/lib/engine/harness/resposta_cortada.ex:44 (registrar), :64 (incompleta?), apps/engine/lib/engine/harness/argumentos_de_ferramenta.ex:26 (executar), apps/engine/lib/engine/harness/tools/propose_adr.ex:27 (spec)
  • Teste: apps/engine/test/engine/harness/resposta_cortada_test.exs ("só a última chamada da resposta cortada é recusada; as completas executam"; "ferramenta de resposta cortada NÃO executa", sem chamada conhecida)
  • Origem: AT-430 (08/10: o Arquiteto mandou 5 propose_adr numa resposta cortada e as cinco foram recusadas; 0 ADRs)

RN-746 — O fecho do Arquiteto diz o que gravou, e as escritas recusadas são contadas​

  • Regra: a linha factual "Gravado neste turno: …" da RN-731 passa a contar as escritas do Arquiteto (ADR proposto, mapa de módulos, atribuição de módulos, imagem decidida, diagrama C4, roteamento para a infra, contrato de módulos, insight), a RFC do Staff e o protótipo do UX Designer, e conta à parte as chamadas de ESCRITA recusadas ({:error, _}) do turno: "…; N chamadas de escrita recusadas". Turno que tentou escrever e não gravou nada fecha com "Gravado neste turno: nada; N chamadas de escrita recusadas." (en: "Recorded this turn: nothing; N write calls refused."). O Criativo passa a anotar a recusa também; Staff e UX Designer passam a anotar. Leitura nunca conta. O texto do modelo não é reescrito (RN-163: o agente não anuncia como feito o que o servidor recusou — quem lê vê o fato ao lado). Fora, declarado: Dev Lead (o plano suspende o turno, RN-284) e Infra Lead.
  • Onde: apps/engine/lib/engine/agents/gravado_no_turno.ex:77 (anotar), :96 (descarregar), :141 (classe), apps/engine/lib/engine/agents/arquiteto_server.ex:334 (dispatch_tool)
  • Teste: apps/engine/test/engine/agents/gravado_no_turno_test.exs ("Arquiteto com as cinco propose_adr recusadas fecha com nada gravado"; "escritas do Arquiteto contadas, leitura e ferramenta desconhecida não")
  • Origem: AT-431 (08/10: com os 5 propose_adr ok: false, o Arquiteto fechou com "✅ ADRs Propostas (5 decisões críticas)" e nenhuma linha factual)

RN-747 — O plano do Dev Lead diz quantos dev agents sobem, não quantos pede​

  • Regra: aprovar propose_execution_plan ativa a execução (RN-677), e a ativação sobe UM dev agent por módulo; o agentes por módulo do plano é recomendação. O cartão de aprovação diz "sobe N dev agent(s), um por módulo" e, quando o plano pede mais que isso, que os agentes a mais só sobem se o usuário aceitar paralelizar depois — paralelizar é teto da RN-154 e nunca é auto-aprovado nem embutido na aprovação do plano. O resultado da ferramenta que volta ao Dev Lead diz o mesmo, e a descrição de agentes pede para não prometer ganho de paralelismo. A aspa do modelo nas pontas do resumo é removida antes de a frase citá-lo, para não sobrar coberto.".
  • Onde: apps/web/src/lib/aprovacoes.ts:327 (propose_execution_plan), apps/engine/lib/engine/agents/dev_lead_tools.ex:254 (sobem), :245 (classificar), :97 (spec)
  • Teste: apps/web/src/lib/aprovacoes.test.ts ("plano com 2 agentes num módulo único diz que sobe 1…"; "plano sem agente a mais não fala de paralelizar"), apps/engine/test/engine/agents/dev_lead_tools_test.exs ("agente a mais no plano é dito como NÃO subindo")
  • Origem: AT-437 (08/10: o plano aprovado prometia "2 agentes no módulo único… ~40% de ganho" e a execução subiu 1 dev)

RN-748 — A espera por aprovação fecha a faixa do turno e leva o cartão à vista​

  • Regra: quando o agente suspende o turno esperando aprovação humana (agent.status: awaiting_approval, hoje o Dev Lead no propose_execution_plan, RN-284), a tela de Sessão trata isso como FIM de turno também pelo CANAL — não só pela leitura do log (turnoTerminouNoLog, que já o fazia): a faixa de atividade sai, e com ela "Pôr na fila" e "Parar". E, quando uma espera NOVA aparece no log (o seq da espera mais recente muda depois da abertura), o fio rola até o fim SEM a guarda dos 120px da RN-173, porque o cartão de aprovação é o que a pessoa precisa ver. Reabrir a sessão com uma espera antiga não rola nada além da abertura de sempre.
  • Onde: apps/web/src/lib/session-channel.ts:232 (awaiting_approval), apps/web/src/lib/session-turno.ts:418 (awaiting_approval), seqDaEsperaPorAprovacao (removido na RN-951, apps/web/src/lib/session-rolagem.ts), esperaPorAprovacao (removido)
  • Teste: apps/web/src/lib/session-channel.test.ts (onAgentStatus repassa awaiting_approval (RN-748)), apps/web/src/lib/session-rolagem.test.ts (seqDaEsperaPorAprovacao (RN-748): espera mais recente; sem espera, null)
  • Origem: AT-432 (TP-01: o Dev Lead em awaiting_approval com a faixa "Montando o plano de execução" ativa e o cartão fora da vista)

RN-749 — A resposta cortada pelo teto oferece a continuação num clique​

  • Regra: a bolha do agente cujo fecho termina na linha que o engine acrescenta à resposta cortada pelo teto de saída (RN-737, nos dois idiomas) mostra, com a sessão ativa, o botão "Continuar de onde parou". O clique manda ao MESMO agente o texto "Continue de onde parou." (no idioma da interface) pelo mesmo envio do composer — com turno em curso, entra na fila dele (RN-673) —, e o rascunho que a pessoa está escrevendo não é tocado. Só o fecho tem o botão; os passos do turno não. A tela reconhece a linha pelo texto, e as duas frases são o contrato com Engine.Harness.RespostaCortada.
  • Onde: apps/web/src/lib/resposta-cortada.ts:14 (respostaCortada), apps/web/src/routes/session-timeline-montagem.tsx:879 (respostaCortada), apps/web/src/routes/SessionPage.tsx:447 (continuarRespostaCortada), :686 (enviarMensagem)
  • Teste: apps/web/src/lib/resposta-cortada.test.ts (respostaCortada (RN-749): as duas linhas reconhecidas; resposta inteira e a frase no meio do texto não)
  • Origem: AT-434 (TP-01: a resposta cortada não oferecia ação, e o usuário teve de adivinhar "continue")

RN-750 — O colapso por agente deixa o fecho de cada agente aberto​

  • Regra: o colapso de mensagens por agente depois que ele passa o bastão (RN-138) nunca recolhe a ÚLTIMA mensagem daquele agente no fio — o fecho, que no handoff é o resumo que a pessoa acabou de ler. Recolhe-se o que veio antes dele na mesma sequência, e só com duas ou mais entradas (uma sozinha não vira cabeçalho, a régua de sempre da RN-138). Sequências anteriores do mesmo agente, separadas por outra fala, colapsam como antes. O corte do fio (RN-644) não muda: ele roda sobre a lista já agrupada.
  • Onde: apps/web/src/routes/session-fio.tsx:155 (fechoDeCadaAgente), :181 (fechoPorAgente), :190 (indiceDoFecho)
  • Teste: apps/web/src/routes/SessionPage.handoff-devlead-e-colapso.test.tsx (duas mensagens consecutivas do PO colapsam...: duas recolhidas e o fecho aberto; RN-750: com duas mensagens, o fecho fica aberto e a outra, sozinha, não vira grupo)
  • Origem: AT-435 (TP-01: no handoff o fio recolhia a resposta inteira do Criativo, "Criativo · 2 mensagens", escondendo o resumo)

RN-751 — A Sessão e o catálogo não saltam: composer, aba ativa, barra de lote e rolagem​

  • Regra: quatro correções de layout, cada uma uma regra de tela. (1) O composer não espreme o campo durante o turno: os botões não encolhem nem quebram texto, e descem de linha quando não cabem. (2) Sem a moldura do projeto montada, a aba que a sidebar marca é a que a ROTA implica — na tela de Sessão, o Chat —, e só fora disso a padrão. (3) A barra de ação em lote do catálogo de modelos SOBREPÕE a lista, presa ao pé da janela, e a seção ganha um respiro no fim enquanto ela está aberta; inserida no fluxo, ela empurrava a lista e o checkbox fugia do cursor. (4) O fio "acompanha o fim" (RN-173) também quando a pessoa ESTAVA no fim antes de a altura mudar — a intenção é medida no gesto de rolar, e a rolagem grampeada com conteúdo menor que a janela não a muda. A guarda dos 120px continua: quem rolou para cima não é arrastado. A causa do topo no aceite do handoff da Infra não foi reproduzida no navegador; a correção cobre a classe (conteúdo que encolhe e volta).
  • Onde: apps/web/src/routes/project-tabs.ts:383 (abaDaRota), apps/web/src/routes/Shell.tsx:547 (abaDaRota), estaPertoDoFim (removido na RN-951, apps/web/src/lib/session-rolagem.ts), estavaNoFimRef, aoRolarOFio (removidos), apps/web/src/components/ModelCatalogSection.module.css (.barraDeLote), apps/web/src/routes/SessionPage.module.css (.composer)
  • Teste: apps/web/src/lib/session-rolagem-fim.test.ts (estaPertoDoFim e abaDaRota: caminho feliz e falha), apps/web/src/routes/Shell.test.tsx (sem a moldura montada (ex.: tela de Sessão): Chat marcado), apps/web/src/routes/SessionPage.ordenacao-e-avisos.test.tsx (não arrasta quem rolou pra cima)
  • Origem: AT-442 (TP-01, levantamento visual)

RN-752 — PR de infra mergeada sai de "PRs de infra em revisão"​

  • Regra: a leitura GET .../infra-artifacts (seção "PRs de infra em revisão" da aba Aprovações) não devolve o artefato de infra cuja PR já foi mergeada. O gate do artefato termina em awaiting_user e nada o move depois do merge; em vez de coluna nova, a régua é DERIVADA: a PR do artefato é o pullRequestId do resultado do open_infra_pr que a abriu (prActionId), e ela está mergeada quando um git_merge do projeto com status executed devolveu state: 'merged' para esse id — o mesmo casamento por pullRequestId que fecha as tarefas (RN-628). Merge que falhou ou ainda pende não tira nada da lista. O aviso de "PR de infra pronta para merge" no fio ou no painel "precisa de você" NÃO foi feito aqui.
  • Onde: apps/api/src/application/use-cases/execution/list-infra-artifacts.use-case.ts:26 (execute)
  • Teste: apps/api/test/application/use-cases/execution/list-infra-artifacts.use-case.spec.ts (merge executado tira a PR; merge falho ou pendente a mantém)
  • Origem: AT-436 (TP-01: depois de "Merge" → "Aprovar" na aba PRs, a PR seguia com Dev ✓ QA ✓ SecOps ✓ e "Você" pendente)

RN-755 — Ligar o modo automático não aprova o cartão onde foi ligado​

  • Regra: no ApprovalCard (o mesmo nas quatro superfícies), o botão "Modo automático" grava a curinga do agente (RN-153) e NÃO decide a ação do próprio cartão: ela segue pendente, com "Aprovar" como ação principal. Onde o botão sai, sai junto uma nota dizendo isso antes do clique ("Ligar o modo automático não aprova esta ação: ela continua esperando o seu Aprovar."). Aprovar o cartão junto com a ligação do modo seria decisão do dono e NÃO foi feito.
  • Onde: apps/web/src/components/ApprovalCard.tsx:207 (ApprovalCard)
  • Teste: apps/web/src/components/ApprovalCard.test.tsx (ligar o "Modo automático" não aprova o card, e o card diz isso; sem o botão, a nota não sai)
  • Origem: AT-443 (TP-01: o modo ligado num cartão pendente não o aprovava, e nada na tela dizia por quê)

RN-757 — O terminal auto-aprovado espera o comando, e o teto do comando é 2 min​

  • Regra: a ação terminal que a api auto-aprova é EXECUTADA dentro da mesma requisição do propose_action (api → engine /internal/actions/execute → api container-exec → broker), e o engine espera essa requisição por TERMINAL_ACTION_TIMEOUT_MS + 120 s — não pelo default de 15 s do Req. O teto do comando (TERMINAL_ACTION_TIMEOUT_MS) passa de 15 s para 120 s nos três composes e no runtime.exs. A cadeia fica broker (T) < api → broker (T + até 45 s, RN-604) < engine → container-exec (T + 90 s) < engine → propose_action (T + 120 s), e com T = 120 s o salto de fora (240 s) segue abaixo dos 300 s de cabeçalhos do fetch do Node. Se a espera ainda estourar, o modelo recebe texto NOMEADO — o teto em segundos, que o processo pode seguir rodando e a sugestão de nohup … & com log —, nunca %Req.TransportError{reason: :timeout} cru. Exec assíncrono/streaming foi considerado e não feito: a menor mudança que faz npm install comum caber é o teto.
  • Onde: apps/engine/lib/engine/sessions/engine_api_client.ex:1911 (opcoes_do_propose_action) e apps/engine/lib/engine/harness/hooks/action_pipeline.ex:85 (resultado_de_teto_do_terminal)
  • Teste: apps/engine/test/engine/sessions/engine_api_client_propose_terminal_test.exs (api falsa que demora 16 s ainda é esperada; ordem dos tetos abaixo de 300 s; o texto de estouro nomeia o teto)
  • Origem: AT-440 (TP-01 de 08/10: npm install 2>&1 | tail -50 num container node:22-bookworm-slim voltou "falha no pipeline: %Req.TransportError{reason: :timeout}"; medido nesta máquina, o mesmo npm install de bcrypt+sqlite3+express levou 5,3 s com a imagem em cache e prebuilds disponíveis — a cadeia, não o comando, cortava em 15 s)

RN-758 — O recorte do Jev nunca tira a ferramenta de obrigação do agente​

  • Regra: quando o Jev recorta o menu do passo (RN-625, P3), o menu recebe de volta o PISO do agente: para o PO, create_story, create_task e, desde a RN-942, declare_open_question; para o Arquiteto, create_module_map, route_modules_to_infra, declare_module_contracts, propose_adr e create_c4_diagram. Só entra o que está no catálogo daquele passo — o piso nunca acrescenta ferramenta que o agente não tinha, e em toda queda o catálogo inteiro segue como antes. O piso é ESTÁTICO por agente, e isso é declarado: a api não tem sinal barato de qual obrigação está pendente naquele passo, então ele vale sempre que há recorte. Os dev agents ficam sem piso, de propósito (a obrigação deles muda a cada passo; para eles vale só a RN-759) — salvo report_done/report_blocked, que entram no piso deles desde a RN-839.
  • Onde: apps/api/src/domain/llm/tool-router.ts:392 (menuComPiso) e apps/api/src/application/use-cases/llm/decidir-ferramenta-do-passo.use-case.ts:248 (menuComPiso)
  • Teste: apps/api/test/application/use-cases/llm/decidir-ferramenta-do-passo.use-case.spec.ts (o PO com o menu ["listar_backlog","create_story"] mantém create_task; o Arquiteto com 2 de 9 mantém as cinco de artefato)
  • Origem: AT-445 (TP-01 de 09/10: o PO sem create_task respondeu "Não tenho uma ferramenta para criar tarefas" e fechou com 8 histórias sem tarefa; o Arquiteto com 2 de 9 fechou sem roteamento, contratos, ADR nem C4)

RN-759 — O passo recortado avisa ao modelo que o menu é um recorte​

  • Regra: quando o recorte do Jev é aplicado (aplicado: true), a chamada ao provider DAQUELE passo leva, no fim, uma mensagem system EFÊMERA que diz quantas e quais ferramentas o passo recebeu, que as demais seguem disponíveis nas próximas voltas e que o agente não deve concluir que não tem uma ferramenta nem encerrar o turno esperando por ela. Ela nunca entra no histórico do agente nem no event log, e não existe sem recorte nem em queda. É montada na api, e não na fachada do engine como a RN-622, porque só a api sabe o menu depois do Jev. Não aplicar o recorte na volta que fecha o turno foi avaliado e não feito: a api não sabe antes da resposta que a volta será a última.
  • Onde: apps/api/src/domain/llm/tool-router.ts:411 (avisoDeRecorte) e apps/api/src/application/use-cases/llm/decidir-ferramenta-do-passo.use-case.ts:263 (avisoDeRecorte)
  • Teste: apps/api/test/application/use-cases/llm/decidir-ferramenta-do-passo.use-case.spec.ts (o aviso chega como última mensagem com recorte; sem recorte e em queda, as mensagens são as do pedido)
  • Origem: AT-445 (o dev agent bloqueou uma tarefa "sem ferramenta para editar arquivos" tendo write_file)

RN-766 — O gate parado esperando clique é dito, e a oferta em lote cobre os subagentes de QA​

  • Regra: no painel "precisa de você" (RN-467), a ação pendente proposta por agente de GATE (área de QA — qa, qa-* — ou SecOps) ganha, acima do MESMO ApprovalCard, a frase de que aquele gate está PARADO até a decisão; é uma frase por card, nunca uma soma de filas. E a oferta em lote do modo automático na aba Executores (RN-661) lista os agentes da área de QA (AREAS.qa) desde a ativação, sem esperar que eles apareçam no roster — antes o qa-automacao só entrava na oferta depois do primeiro evento dele, quando a primeira ação já esperava clique. O allow padrão do permissions.json NÃO muda (decisão do dono).
  • Onde: apps/web/src/lib/precisa-de-voce.ts:256 (gateEsperandoClique) e apps/web/src/components/ModoAutomaticoDoTime.tsx:63 (agentesDaOfertaEmLote)
  • Teste: apps/web/src/lib/precisa-de-voce.test.ts (gate de QA e SecOps nomeados; dev agent e humano não) e apps/web/src/components/ModoAutomaticoDoTime.test.tsx (a oferta cobre os subagentes de QA antes do roster, sem repetir)
  • Origem: AT-449 (TP-01 de 09/10: o qa-automacao propôs ls; cat; for f in test/*.js…, um segmento fora do allow, sem modo automático, e ficou 25 min pendente com só o contador de Aprovações como sinal)

RN-765 — O gate de QA julga a TAREFA, e RF de tarefa irmã é observação​

  • Regra: a QA-estratégia (plano de teste, RN-674) e a QA-automação recebem, além da história, o RECORTE da tarefa: título e descrição, as regras de negócio da história e as OUTRAS tarefas da mesma história com o status (siblingTasks em GET .../dev-context). O veredito é sobre o que é DESTA tarefa: requisito que pertence a uma tarefa irmã (feita ou pendente) não reprova — vai ao resumo como observação, fora de itens, e não impede approved. A régua é de PROMPT, não de código: o emit_qa_verdict continua exigindo só a suite verde para aprovar. Desde a AT-479, quando a descrição da tarefa ou uma regra quantifica POR item ("por X", "cada", "de todos os" — "por exemplo"/"por padrão" não contam), o recorte ganha a seção "Forma da resposta": conferir que a resposta traz o valor de CADA item, não só o total (o painel aprovado com só {totalCliques} sobre "a contagem… de todos os links"), e que a recusa de entrada nomeia o campo — esta última é OBSERVAÇÃO no resumo, fora de itens, salvo regra que a exija. Continua régua de prompt: nenhuma reprovação nova no código.

  • Onde: apps/engine/lib/engine/gates/recorte_da_tarefa.ex:18 (texto), apps/engine/lib/engine/gates/recorte_da_tarefa.ex:82 (secao_da_forma), apps/api/src/application/use-cases/execution/get-dev-task-context.use-case.ts:195 (tarefasIrmas) e apps/engine/lib/engine/gates/qa_automacao_agent.ex:170

    apps/engine/lib/engine/gates/recorte_da_tarefa.ex:82 (secao_da_forma), apps/api/src/application/use-cases/execution/get-dev-task-context.use-case.ts:195 (tarefasIrmas) e apps/engine/lib/engine/gates/qa_automacao_agent.ex:225 (com_o_recorte)

  • Teste: apps/engine/test/engine/gates/recorte_da_tarefa_test.exs (desde a AT-479 também "regra que quantifica por item pede a conferência da forma da resposta" e o caso sem quantificador) e apps/api/test/application/use-cases/execution/get-dev-task-context.use-case.spec.ts (as irmãs sem a própria tarefa). O golden-set do QA (ADR 0168) NÃO ganhou caso: ele roda o modelo de verdade, e provar o veredito sem chamada paga não é possível — declarado.

  • Origem: AT-448 (TP-01 de 09/10: o gate de QA da tarefa "Modelar tabela de usuários e hash bcrypt" reprovou três vezes por "RF01: login HTTP com JWT não existe nesta entrega", que é de outra tarefa, e a tarefa bloqueou; artifact.qa_verdict das tasks 42bea901 e 762833bd)

RN-768 — Na aba PRs, o texto ao lado do Merge diz a verdade do botão​

  • Regra: gate pendente continua AVISO e não trava o merge (RN-663). Tarefa BLOQUEADA (task.blocked) é outra coisa e desabilita o botão; nesse caso a linha da PR NÃO mostra "o merge segue disponível": diz, em texto, que o merge está indisponível porque a tarefa está bloqueada (nomeando o gate pendente, se houver, ou o motivo do bloqueio). Sem sessão no projeto, o mesmo: o motivo vai em texto, nunca só no title do botão disabled, que não abre no Chromium. Se tarefa bloqueada deve ou não travar o merge é pergunta do dono, não decidida aqui.
  • Onde: apps/web/src/routes/ProjectPrsTab.tsx:276 (avisoDaLinha)
  • Teste: apps/web/src/routes/ProjectPrsTab.test.tsx ("AT-451: tarefa bloqueada com gate pendente NÃO diz que o merge segue disponível")
  • Origem: AT-451 (TP-01 de 09/10: "O merge segue disponível — a decisão é sua" ao lado de um "Merge" desabilitado)

RN-769 — Encerrar sessão com execução ativa pergunta antes, e o estado aparece traduzido​

  • Regra: "Encerrar" numa sessão com execução ativa (evento execution.activated na janela, ou um dev-<modulo> entre os ativados da sessão inteira — o Dev Lead não conta, ele planeja antes da execução) abre uma confirmação que diz o que para: os agentes da sessão param na hora, o comando em andamento é cortado, o turno em curso não é gravado, e sessão com execução não reabre (RN-650). Sem execução, o clique encerra direto, como antes. A frase de sessão encerrada no composer interpola o estado TRADUZIDO (pontoDaSessao(...).rotuloKey), nunca o enum cru (closed). E isso é guardado no CÓDIGO, não só no locale: a régua de vocabulário lê os JSON, e o enum entrava pela interpolação em runtime; i18n-enum-cru.test.ts reprova t(…, { status: x.status }).
  • Onde: apps/web/src/routes/ConfirmarEncerramento.tsx:13 (useEncerrarComConfirmacao) e apps/web/src/routes/SessionComposer.tsx:479 (statusGenerico)
  • Teste: apps/web/src/routes/ConfirmarEncerramento.test.tsx (sem execução encerra direto; com execução só encerra ao confirmar; cancelar não encerra) e apps/web/src/lib/i18n-enum-cru.test.ts (reprovava o SessionComposer.tsx antes da correção)
  • Origem: AT-452 (TP-01 de 09/10: "Encerrar" fechou a sessão de execução em 3 s com o dev agent no meio de um npm install, e a barra disse "Sessão closed")

RN-770 — Miudezas da tela de projeto: autor da PR de ADR, modelo da Infra, título da PR do dev, selo da execução, caminho e tabela​

  • Regra: (1) a PR de ADR leva a identidade do agente (<agente>[bot], senão arquiteto[bot]), como a de infra (RN-729) — a aba PRs deixa de dizer "autor desconhecido"; (2) a agent.response do Infra Lead carrega o modelName do frame final, como os outros conversacionais — a bolha deixa de dizer "modelo não registrado"; (3) o título da PR do dev agent abre pela TAREFA e depois a história (<tarefa> — <história>), para três PRs da mesma história não parecerem a mesma com o fim cortado; (4) com execução ativa (o mesmo predicado da RN-769), a barra da sessão diz "Execução" em vez do kind gravado (criativa, que não muda — RN-097) e o composer não oferece o Criativo antes de ativado; (5) na confirmação da criação, cada segmento do caminho é nowrap: a quebra só acontece depois da barra, nunca no hífen; (6) a tabela Markdown no balão tem piso de 12rem POR COLUNA e cresce por fração, com o contêiner em min-content — com max-content ela ficava da largura do texto sem quebra (medido no Chromium: 2 236px num balão de 708px) e a última coluna sumia.
  • Onde: apps/api/src/application/use-cases/actions/execute-adr-pr.use-case.ts:96 (openPullRequest); apps/engine/lib/engine/infra/infra_lead_server.ex:1601 (gravar_texto_do_turno); apps/engine/lib/engine/dev/dev_agent_server.ex:1188 (propose_pr); apps/web/src/lib/session-destinatario.ts:228 (agentesEmConversa) e apps/web/src/lib/session-kind.ts:69 (tipoDaSessao); apps/web/src/routes/NewProjectWizard.tsx:1122 (quebraPorSegmento); apps/web/src/components/ui/MarkdownMessage.tsx:162 (TabelaMarkdown)
  • Teste: apps/api/test/application/use-cases/actions/execute-adr-pr.use-case.spec.ts, apps/engine/test/engine/infra/infra_lead_server_test.exs ("agent.response carrega o nome do modelo"), apps/engine/test/engine/dev/dev_agent_server_test.exs (fluxo feliz), apps/web/src/lib/session-destinatario.test.ts, apps/web/src/lib/session-kind.test.ts, apps/web/src/routes/NewProjectWizard.test.tsx, apps/web/src/components/ui/MarkdownMessage.test.tsx
  • Origem: AT-455 (TP-01 de 09/10, achados 2, 3, 8, 9, 16 e 18)

RN-760 — A próxima task do dev agent parte da última branch dele ainda não mergeada​

  • Regra: generaliza a RN-743. Ao terminar uma task — aprovada nos gates (nextAction: "done") ou bloqueada por QUALQUER motivo, inclusive o "ciclo de correção esgotado (gate)" que a api decide e chega ao engine só como gate_resolved —, o dev agent guarda a branch dela e a próxima task do MESMO agente (logo, do mesmo módulo) nasce dessa branch, e não de dev. O que sobrou sem commit no worktree é preservado antes, como na RN-743; o ponteiro gravado pelo block_task/4 não é sobrescrito. Quando a branch anterior já é ancestral da dev local (mergeada), a próxima parte da dev, que já a contém. QUANDO a fila anda não muda: a próxima task continua sendo reivindicada ao fim dos gates, antes do merge do usuário (decisão do dono). Declarado: merge por squash num remoto não deixa a branch ancestral da dev, então a próxima segue partindo dela; o ponteiro continua só em memória, e no modo runner a checagem de merge não roda.
  • Onde: apps/engine/lib/engine/dev/agent_io.ex:296 (levar_branch_para_a_proxima), :324 (criar_worktree), apps/engine/lib/engine/dev/worktree_manager.ex:199 (ja_mergeada_volta_ao_trabalho)
  • Teste: apps/engine/test/engine/dev/bloqueio_preserva_trabalho_test.exs (levar_branch_para_a_proxima/1 e criar_worktree/2: aprovada leva a branch para a próxima; o ponteiro do bloqueio não é sobrescrito), apps/engine/test/engine/dev/worktree_manager_test.exs (add_worktree/4 com a branch da task anterior: não mergeada parte dela, mergeada parte da dev)
  • Origem: AT-447 (TP-01 de 09/10: tasks 762833bd e 5ebad64b — a segunda nasceu de dev sem a rota de login da primeira, e o QA reprovou)

RN-761 — Dependência instalada não entra no repositório: .gitignore base, commit que a exclui e gate que a acusa​

  • Regra: três camadas, com a MESMA lista de diretórios (node_modules, deps, _build, .venv, venv, __pycache__, vendor, target):
    1. o bootstrap do repositório (os três providers e a adoção) ganha o passo commit_gitignore, que commita um .gitignore base em main ANTES de dev nascer, então toda branch de trabalho o herda. É o ponto único porque é por ele que TODO repositório criado ou adotado passa, antes de qualquer agente escrever; a Infra pelo module_map chegaria depois do primeiro commit do dev e só nos projetos que passam por ela. O passo só CRIA o arquivo: um .gitignore já existente, mesmo diferente, é do usuário e não entra no plano;
    2. o commit do dev agent (git_commit) e o commit que preserva o worktree ao bloquear (RN-743) estagiam tudo MENOS esses diretórios, em qualquer profundidade (pathspec :(exclude,glob)), mesmo sem .gitignore;
    3. o gate SecOps acusa, como achado (changes_requested), diretório da lista que apareça no diff da PR, com a contagem de arquivos. As duas cópias da lista (api e engine) são conferidas por teste. Declarado: arquivo de dependência JÁ rastreado antes desta regra não é removido pelo commit (a exclusão não estagia remoção); o projeto já bootstrapado só ganha o .gitignore se o bootstrap rodar de novo; no modo runner o commit roda pelo mesmo GitExecutor e a mesma exclusão vale onde o engine executa git.
  • Onde: apps/api/src/application/use-cases/git/bootstrap-templates.ts:38 (gitignoreContent), apps/api/src/application/use-cases/git/bootstrap-steps.ts:164 (soSeAusente), apps/engine/lib/engine/actions/diretorios_de_dependencia.ex:37 (argumentos_do_add), :47 (commitados), apps/engine/lib/engine/gates/secops_agent_server.ex:162 (dependencias_commitadas)
  • Teste: apps/api/test/application/use-cases/git/bootstrap-plan.spec.ts (o repositório vazio planeja os 3 arquivos; .gitignore do usuário não entra no plano), apps/api/test/application/use-cases/git/provision-repository.use-case.spec.ts (o local converge os 6 passos), apps/engine/test/engine/actions/git_executor_test.exs (commit exclui diretórios de dependência mesmo sem .gitignore), apps/engine/test/engine/actions/diretorios_de_dependencia_test.exs (a lista é a mesma da api; o SecOps acusa node_modules)
  • Origem: AT-446 (TP-01 de 09/10: PR com 1.453 arquivos, 1.437 em node_modules/, aprovada por QA e SecOps)

RN-762 — O bloqueio por ciclo de correção esgotado grava a origem politica​

  • Regra: quando o gate esgota o teto de correções (maxGateCorrections) e a task é bloqueada com "ciclo de correção esgotado (gate)", o backlog.task_blocked e a linha da task levam origin: "politica" — o teto é decisão de configuração, não falha real (vocabulário do ADR 0020, regra da RN-059). Antes saía origin: null. O bloqueio de artefato de infra pelo mesmo motivo (infra.artifact_blocked) não tem campo de origem e fica como está, declarado.
  • Onde: apps/api/src/application/use-cases/execution/record-gate-verdict.use-case.ts:215 (politica)
  • Teste: apps/api/test/application/use-cases/execution/record-gate-verdict.use-case.spec.ts (o ciclo esgotado chama MarkTaskBlockedUseCase com 'politica'; o veredito aprovado não bloqueia)
  • Origem: AT-450 (TP-01 de 09/10: backlog.task_blocked com origin: null)

RN-763 — Encerrar a sessão de execução para os dev agents e os gates, e "Parar execução" na aba Executores​

  • Regra: o fechamento da sessão (session.closed/closed_abnormally, pelo SessionLifecycleWorker, o ponto por onde todo fechamento passa) PARA os dev agents dela em todos os nós e os gates (QA lead e SecOps) do projeto — a régua que já valia para os conversacionais (RN-581). O processo é derrubado na hora, sem gravar o turno em curso, e o Engine.Dev.Monitor é avisado antes (esquecer/1): a linha durável sai e o agente NÃO é religado (RN-742). Para cada dev com task em curso (working, awaiting_approval ou awaiting_gate), o trabalho do worktree vira commit na branch da task (RN-743) e a task é BLOQUEADA com motivo "sessão de execução encerrada", origem politica, e o diagnóstico dizendo onde ficou o trabalho — volta à fila quando um humano a libera, nunca perdida. As linhas de gate_states da sessão saem, para o GateRescuer não reabrir ciclo da sessão encerrada. A aba Executores ganha "Parar execução" (papel developer, o de encerrar sessão), cujo mecanismo é o MESMO fechamento (closing → closed), sem estado novo; a confirmação diz antes do clique o que fica: as tarefas em curso (com a contagem), as PRs abertas, as aprovações pendentes e que a sessão não reabre. Declarado: ação pendente de dev agent continua pendente; PR aberta continua aberta; sessão com execution.activated não reabre (RN-650), então retomar é ativar a execução numa sessão nova; a contagem de tarefas da confirmação vem da janela de eventos da aba. A confirmação no "Encerrar" da barra da sessão é de outra atividade (AT-452).
  • Onde: apps/engine/lib/engine/dev/encerramento_da_sessao.ex:40 (parar_da_sessao), :74 (parar_processos), :104 (devolver_task), apps/engine/lib/engine/dev/monitor.ex:34 (esquecer), apps/engine/lib/engine/workers/session_lifecycle_worker.ex:94 (parar_execucao), apps/web/src/components/PararExecucao.tsx
  • Teste: apps/engine/test/engine/dev/encerramento_da_sessao_test.exs (o dev morre sem ser religado, a linha sai, a task é bloqueada com origem politica; sessão sem dev é no-op e o dev de outra sessão fica de pé), apps/web/src/components/PararExecucao.test.tsx (a confirmação diz o que fica e fecha closing → closed; a recusa da api fica na confirmação; sem papel o botão fica inerte com o motivo)
  • Origem: AT-456 (TP-01 de 09/10: com a sessão de execução encerrada, o dev-encurtador-api bloqueou a tarefa 3, pegou a 4 e abriu PR; o único freio foi parar o container)

RN-772 — O Arquiteto lê o status ATUAL das ADRs que propôs​

  • Regra: o Arquiteto tem a leitura listar_adrs_propostas: as ações open_adr_pr do PROJETO, lidas agora de proposed_actions (mesmo Postgres, sem HTTP no laço), com o status de cada uma — pendente, aprovada, aprovada automaticamente, executada (com a URL da PR), recusada (com o motivo) ou falhada. Contida (ADR 0060, RN-164): sem parâmetro, escopo fechado no projeto do contexto, uma contagem e uma consulta por chamada, as 20 mais recentes, e o total real dito quando corta. Falha de leitura é tool.result ok: false com o motivo, nunca texto vazio. Desde a RN-784 ela ESTÁ no piso do Jev.
  • Onde: apps/engine/lib/engine/harness/tools/listar_adrs_propostas.ex:37 (run), :40 (ler), apps/engine/lib/engine/agents/arquiteto_server.ex:124 (ListarAdrsPropostas)
  • Teste: apps/engine/test/engine/harness/tools/listar_adrs_propostas_test.exs (aprovada aparece aprovada, executada com a PR, recusada com o motivo, só as do projeto; teto com o total real; sem proposta; leitura que falha vira erro nomeado)
  • Origem: AT-454 (TP-01 de 09/10, sessão 771131f4: com as 3 ADRs aprovadas, o fecho seguinte disse "As 3 ADRs seguem pendentes de aprovação")

RN-773 — O Arquiteto lê o backlog, e consulta antes de afirmar estado​

  • Regra: o Arquiteto ganha a mesma listar_backlog do PO (RN-164), com a contagem de tarefas por história. O kickoff dele e o resultado do propose_adr dizem que o status das ADRs e as tarefas mudam fora da conversa e mandam consultar as duas leituras antes de afirmá-los, em vez do retrato de um resultado anterior. Nada de fluxo do time é narrado aqui (RN-711).
  • Onde: apps/engine/lib/engine/agents/arquiteto_server.ex:125 (ListarBacklog), apps/engine/lib/engine/harness/tools/propose_adr.ex:94 (propor)
  • Teste: apps/engine/test/engine/harness/tools/listar_backlog_test.exs (a contagem de tarefas por história), apps/engine/test/engine/agents/arquiteto_server_test.exs (as duas leituras estão no catálogo do Arquiteto)
  • Origem: AT-454 (TP-01 de 09/10: com 17 tarefas no backlog, o Arquiteto disse "faltam as tarefas" e "não consigo verificá-las daqui")

RN-788 — Decisões e linha do tempo do time não afirmam sobre o que não leram​

  • Regra: no painel "Contexto da sessão", "Decisões registradas" segue o mesmo recorte declarado das regras (RN-701): com eventos antes da janela, o contador diz "N na janela · há mais antes" e o vazio diz que pode haver decisões nos anteriores, nunca "Nenhuma decisão registrada nesta sessão". Na Visão geral, a "Linha do tempo do time" lê a sessão de EXECUÇÃO quando ela existe (a mesma da sidebar) e diz isso no subtítulo quando ela não é a mais recente. Nenhuma requisição a mais: as chaves são as que a sidebar já assina, e useActiveExecutionSession ganha staleTime igual ao intervalo, para o segundo assinante não refazer a busca (RN-632).
  • Onde: apps/web/src/routes/ContextAside.tsx:393 (trailing), apps/web/src/routes/ProjectOverviewTab.tsx:97 (linhaDoTempoNaExecucao)
  • Teste: apps/web/src/routes/SessionPage.painel-e-agrupamento.test.tsx (decisão fora da janela não vira 0; sessão inteira na janela diz vazio), apps/web/src/routes/ProjectOverviewTab.test.tsx (lê a sessão de execução e diz; sem execução, lê a mais recente), apps/web/src/routes/duas-abas.orcamento.test.tsx (a carga da Visão geral segue no teto)
  • Origem: AT-463 (TP-01 de 09/10)

RN-789 — Na tela de Sessão, a sidebar marca a aba onde a sessão está listada​

  • Regra: a tela de Sessão publica a aba que o TIPO da sessão implica — criativa em "Criativo" (a lista de ideações), consultiva em "Chat" —, e a sidebar a marca em vez de sempre "Chat" (RN-751); o clique leva à lista onde a sessão aparece, que é o caminho de volta a ela. Sessão ainda carregando, ou aba publicada de outro projeto, cai em "Chat". Sem leitura nova: o tipo já veio com a sessão.
  • Onde: apps/web/src/routes/project-tabs.ts:383 (abaDaRota), apps/web/src/lib/session-kind.ts:94 (usePublicarAbaDaSessao)
  • Teste: apps/web/src/lib/session-rolagem-fim.test.ts (criativa marca "Criativo"; aba de outro projeto ou inválida não vale)
  • Origem: AT-463 (TP-01 de 09/10)

RN-782 — Aceitar o parâmetro de raciocínio não é pedir raciocínio​

  • Regra: revisa a RN-741. O modelo com supportsReasoning (no OpenRouter, reasoning em supported_parameters — 341 de 478 modelos, ou seja, "aceita o parâmetro", não "raciocina") continua com a FOLGA no teto: max_tokens = saída visível (RN-734) + ORCAMENTO_DE_RACIOCINIO (4096). O que sai é o campo reasoning: { max_tokens }: ele NÃO é mais enviado a modelo nenhum. Medição paga de 10/10 (AT-467): no anthropic/claude-haiku-4.5, que sem o campo não raciocina, ele LIGAVA o raciocínio em 74% das chamadas a 2,8× o custo; no anthropic/claude-haiku-5.5 ele era IGNORADO (pedido 1024, gastou 8192 só de raciocínio). A decisão do que pedir mora num ponto só, opcoesDeRaciocinio, chamado pelos três casos de uso de turno. O TODO(humano) da RN-741 fecha com essa medição.
  • Onde: apps/api/src/domain/llm/raciocinio-do-agente.ts:18 (opcoesDeRaciocinio), apps/api/src/infrastructure/llm/openai-compatible-provider.ts:624 (controla), apps/api/src/application/use-cases/llm/run-llm-turn.use-case.ts:180 (opcoesDeRaciocinio)
  • Teste: apps/api/test/infrastructure/llm/openai-compatible-provider.contract.spec.ts (modelo que raciocina: folga no teto e NENHUM campo de raciocínio (RN-782), corpo byte a byte), apps/api/test/domain/llm/raciocinio-do-agente.spec.ts
  • Origem: AT-467 (medição paga de 10/10)

RN-783 — Os dev agents de execução rodam com o raciocínio desligado​

  • Regra: decisão do dono de 10/10. Para agente dev-<modulo> (o Dev Lead, dev-lead, é conversacional e fica de fora) com modelo de supportsReasoning, o provider que sabe dizer "desligado" (hoje só o OpenRouter, campoDeRaciocinioDesligado) manda reasoning: { enabled: false }, mantendo a folga da RN-782. Medido no Haiku 5.5: 0 de 8 respostas cortadas e −42% de custo. Conversacionais e gates não mudam (sem campo), e modelo sem supportsReasoning não manda nada. Não é silencioso: a ajuda da faceta "thinking" do catálogo de modelos diz que nos dev agents de execução e, desde a RN-838, no QA de Automação o raciocínio vai desligado (o mínimo escolhido; o seletor e a bolha não mudam).
  • Onde: apps/api/src/domain/llm/raciocinio-do-agente.ts:23 (raciocinioDesligadoParaOAgente), apps/api/src/infrastructure/llm/openrouter-provider.ts:251 (campoDeRaciocinioDesligadoOpenRouter)
  • Teste: apps/api/test/infrastructure/llm/openai-compatible-provider.contract.spec.ts (dev agent: raciocínio desligado explícito, com a folga (RN-783)), apps/api/test/domain/llm/raciocinio-do-agente.spec.ts (dev-* desliga; criativo e dev-lead só a folga; sem supportsReasoning, nada)
  • Origem: AT-467 (decisão do dono de 10/10)

RN-784 — As leituras de estado do Arquiteto entram no piso do Jev​

  • Regra: revisa a RN-758 e a RN-772 (decisão do dono). O piso do Arquiteto ganha listar_adrs_propostas e listar_backlog: o recorte do Jev nunca as tira, para o Arquiteto não afirmar status de ADR nem tarefas do backlog sem poder consultá-los (RN-773). Continua valendo que o piso só devolve o que está no catálogo do passo.
  • Onde: apps/api/src/domain/llm/tool-router.ts:311 (PISO_DO_MENU)
  • Teste: apps/api/test/application/use-cases/llm/decidir-ferramenta-do-passo.use-case.spec.ts (Arquiteto recortado pelo Jev: as leituras de estado (ADRs propostas, backlog) continuam no menu (RN-784))
  • Origem: AT-460 (TP-01 de 09/10: o Jev tirou do menu do Arquiteto as leituras da AT-454)

RN-779 — O worktree da task (re)pegada parte da dev ATUAL e a integra​

  • Regra: antes de criar o worktree de uma task (primeira vez ou retomada), o engine faz git fetch origin no working tree do projeto (serializado por projeto) — é no remoto que as PRs são mergeadas, e a dev local só andava no checkout da inicialização. A ponta da dev é origin/dev quando a local ficou para trás. A task nasce: (1) da branch da task anterior não mergeada (RN-760), (2) da PRÓPRIA branch quando ela tem trabalho que a dev não contém — a task retomada num processo novo, que perdeu o ponteiro em memória (RN-743) —, ou (3) da ponta atual da dev. Nos casos (1) e (2) o worktree INTEGRA a ponta da dev (merge --no-ff, identidade <agente>[bot]) antes do primeiro passo. Conflito nessa integração é recusa NOMEADA ("conflito ao integrar a dev atual", com os arquivos), o merge é abortado, a branch de origem fica intacta e a task bloqueia com motivo próprio e origem codigo. fetch que falha bloqueia a task com o motivo do git, nunca cai na dev velha. A readoção depois de conflito de merge (RN-715) não integra sozinha. Medido no TP-01: a AT-447 DISPAROU no 1º claim (reflog: Created from feature/task-b3cc92b8); quem perdeu a base foi o reclaim, que redefiniu a branch para a dev LOCAL parada no merge da infra (Reset to dev). Declarado: no modo runner nada disso roda (o git é da máquina do usuário).
  • Onde: apps/engine/lib/engine/dev/worktree_manager.ex:151 (atualizar_remoto), :195 (add_worktree), :227 (ponta_do_trabalho), :225 (retomada), :300 (integrar_trabalho)
  • Teste: apps/engine/test/engine/dev/worktree_manager_test.exs (integração da dev atual (RN-779): retomada com branch antiga parte da dev nova do remoto; trabalho preservado integra a dev nova; anterior em awaiting_user é a base com a dev integrada; conflito é recusa nomeada)
  • Origem: AT-458 (TP-01 de 09/10: feature/task-c80daf6c nasceu de 1fa2241 sem as 12 tarefas mergeadas depois, e o merge conflitou)

RN-780 — Resposta só de raciocínio cortada pelo teto é corte, não desistência​

  • Regra: no ToolLoop (caminho de todo agente de execução e de gate), a resposta sem texto e sem chamada de ferramenta que a api marca truncated: true (o finish_reason: length da RN-737) é o raciocínio que comeu o teto de saída. A volta é retentada UMA vez, sem a resposta vazia no histórico e contando iteração — o teto de iterações e o orçamento da tarefa continuam valendo. Se repetir, o laço para marcando corte_pelo_teto, e o dev agent bloqueia a task com o motivo "resposta só de raciocínio, cortada pelo teto" e origem modelo, em vez de "parou sem concluir nem reportar bloqueio". Uma chamada de ferramenta no meio zera a retentativa. Declarado: o engine recebe da api truncated e usage.outputTokens, mas NÃO os reasoningTokens (ficam só em token_usage), então a resposta vazia que bate no teto sem finish_reason: length não é reconhecida.
  • Onde: apps/engine/lib/engine/harness/tool_loop.ex:239 (parada_sem_chamada), apps/engine/lib/engine/dev/dev_agent_server.ex:1124 (motivo_da_parada), :1143 (stop_diagnosis)
  • Teste: apps/engine/test/engine/harness/tool_loop_test.exs (corte só de raciocínio: retentada uma vez e segue; repetido para com corte_pelo_teto), apps/engine/test/engine/dev/dev_agent_server_test.exs (resposta só de raciocínio cortada pelo teto bloqueia com motivo nomeado)
  • Origem: AT-459 (TP-01 de 09/10: a tarefa c80daf6c bloqueou em 40 s com 8.192 tokens de saída, todos de raciocínio)

RN-949 — Chamada de ferramenta escrita como texto não vira resposta no fio​

  • Regra: quando a chamada ao LLM leva tools, vem sem toolCalls e o texto traz a chamada escrita como marcação (<function_calls>, <invoke name="X"> com X ferramenta do passo, <tool_call>{json} com name de ferramenta; com ou sem prefixo antml:), a api repete a MESMA chamada UMA vez (flag própria, independente das RN-916/917; as duas chamadas entram no metering). Se repetir, o desfecho é falha NOMEADA: final com content: '', error nomeando o formato e o modelo e errorCode: 'tool_call_as_text' (erro do MODELO, RN-059/163), nunca resposta. No stream, a api para de repassar deltas a partir do primeiro marcador e retém a cauda que pode ser início dele: o texto cru não sai da api. Bloco cercado, código inline, prosa, nome que não é ferramenta e chamada sem tools não contam; o JSON solto segue com o ToolCallRecovery (ADR 0020). Decisão do dono: NÃO se parseia nem se executa o XML (RN-040: resgate não é licença). Limite declarado: o prefixo legítimo da 1ª tentativa fica na bolha em curso até o turno fechar.
  • Onde: apps/api/src/domain/llm/chamada-em-texto.ts:10 (marcacaoDeChamadaEmTexto), apps/api/src/application/use-cases/llm/run-llm-turn.use-case.ts:321 (semChamadaEmTexto), apps/api/src/application/use-cases/llm/stream-llm-turn.use-case.ts:354 (semChamadaEmTexto)
  • Teste: apps/api/test/domain/llm/chamada-em-texto.spec.ts, apps/api/test/application/use-cases/llm/run-llm-turn.use-case.spec.ts e stream-llm-turn.use-case.spec.ts (chamada escrita como texto: duas vezes falha nomeada com 2 linhas de uso; segunda limpa responde)
  • Origem: AT-633 (sessão 2b5161d0, seq 4: <invoke name="emit_artifact"> apareceu como resposta no fio)

RN-781 — A mensagem de commit do dev não grava pendência que é do sistema​

  • Regra: o resumo do report_done é a mensagem de commit do dev agent, e commit, push e PR são feitos pelo sistema depois dele. Antes de propor o git_commit, o engine corta do marcador de pendência ("Pendente", "Pendência", "Pending") ao fim quando o resto fala de commit, push ou PR, e tira o parêntese "(ainda sem commit…)"; pendência que não fala disso fica. A descrição do campo summary diz que ele vira a mensagem de commit e que não se narra commit, push nem PR. Resumo que fica vazio cai na mensagem padrão <agente>: <task>.
  • Onde: apps/engine/lib/engine/dev/agent_io.ex:374 (mensagem_de_commit), :373 (propose_commit), apps/engine/lib/engine/dev/tools/report_done.ex:27 (summary)
  • Teste: apps/engine/test/engine/dev/mensagem_de_commit_test.exs (corta a narração; tira o parêntese; pendência alheia ao sistema fica)
  • Origem: AT-464 item 2 (TP-01 de 09/10: git log feature/task-9d6dcd53 com "Pendente: commit… as ações git_commit, git_push e pr_open não estavam disponíveis neste passo")

RN-785 — Requisito que depende de outra história ainda não feita é observação no gate de QA​

  • Regra: além das tarefas irmãs (RN-765), o recorte que a QA-estratégia e a QA-automação recebem traz as tarefas NÃO concluídas das OUTRAS histórias do mesmo módulo, com o título da história (moduleOpenTasks em GET .../dev-context). O módulo é o da tarefa, senão o pedido, senão os da história; história arquivada fica fora. Leitura contida (ADR 0060): no máximo 20, com o total real em moduleOpenTasksTotal e o corte dito no recorte. Requisito da entrega que depende do que outra história ainda vai entregar (uma rota, uma tela) vai ao resumo como observação e não reprova. A régua é de PROMPT, como na RN-765.
  • Onde: apps/api/src/application/use-cases/execution/get-dev-task-context.use-case.ts:210 (tarefasAbertasDoModulo), apps/engine/lib/engine/gates/recorte_da_tarefa.ex:20 (texto)
  • Teste: apps/api/test/application/use-cases/execution/get-dev-task-context.use-case.spec.ts (só a tarefa aberta da outra história do mesmo módulo; arquivada fora; teto com o total real) e apps/engine/test/engine/gates/recorte_da_tarefa_test.exs (as tarefas abertas e o corte no texto). O golden-set do QA não ganhou caso (o veredito exige chamada paga) — declarado.
  • Origem: AT-461 (TP-01 de 09/10, sessão 80e3ce09: a tarefa "middleware JWT" foi reprovada porque "não há rotas de criar, listar, apagar links nem de painel no diff", rotas de outras histórias ainda não feitas)

RN-786 — O gate de QA lê o contrato do módulo, e divergência com a história é observação​

  • Regra: o recorte que a QA-estratégia e a QA-automação recebem traz o contrato VIGENTE do módulo da tarefa (artifact.module_contracts, RN-684), em moduleContract de GET .../dev-context, como a fonte da INTERFACE. O módulo é o da tarefa, senão o pedido, senão o único da história; história com mais de um módulo e tarefa sem módulo é ambígua e vai sem contrato. Quando a história e o contrato divergem num nome de interface (rota, função, evento), a entrega que segue o contrato não é reprovada por isso: o parecer nomeia a DIVERGÊNCIA no resumo (história diz X, contrato diz Y), para o usuário e o Arquiteto. Régua de PROMPT, como a RN-765. Desde a RN-792 o Arquiteto confere o contrato contra as rotas citadas nas histórias ao declará-lo.

  • Onde: apps/api/src/application/use-cases/execution/get-dev-task-context.use-case.ts:255 (moduloDoContrato), apps/engine/lib/engine/gates/recorte_da_tarefa.ex:66

  • Onde: apps/api/src/application/use-cases/execution/get-dev-task-context.use-case.ts:255 (moduloDoContrato), apps/engine/lib/engine/gates/recorte_da_tarefa.ex:70 (secao_do_contrato)

  • Teste: apps/api/test/application/use-cases/execution/get-dev-task-context.use-case.spec.ts (o contrato do módulo da história; sem contrato ou módulo ambíguo, null) e apps/engine/test/engine/gates/recorte_da_tarefa_test.exs (a seção do contrato com a régua da divergência; sem contrato, sem seção)

  • Origem: AT-462 (TP-01 de 09/10, sessão 80e3ce09: a história pedia GET /painel, o contrato declarou GET /panel, o dev seguiu o contrato e o QA reprovou pela história)

RN-787 — O gate conta o diff da PR contra a dev da origem, e o parecer de QA grava lista​

  • Regra: (1) o diff que o QA e o SecOps julgam (dev...HEAD, RN-664) usa a dev MAIS NOVA que o working tree enxerga: com origem local (caminho no disco) o gate faz antes git fetch origin dev, melhor esforço, e entre origin/dev e a dev local vale a que contém a outra. A dev local do clone não anda quando a PR é mergeada na origem, e o parecer do SecOps contava o acumulado das tarefas já mergeadas. Com origem remota não há fetch no gate (a credencial é do Workspace) — declarado. O semgrep e o gitleaks seguem varrendo o worktree inteiro, não só o diff: estreitar o que o scanner vê muda o que o gate reprova, e isso fica para o dono. (2) itens e coverageMatrix do parecer de QA chegam ao artifact.qa_verdict sempre como LISTA: string JSON de lista é decodificada (o molde da RN-719), e string que não é lista vira [].
  • Onde: apps/engine/lib/engine/gates/diff.ex:44 (base_mais_nova), apps/engine/lib/engine/gates/hooks/termination.ex:46 (lista)
  • Teste: apps/engine/test/engine/gates/diff_test.exs (a tarefa B nascida da A, com a A mergeada na origem: o diff é só B) e apps/engine/test/engine/gates/hooks/termination_test.exs (string JSON vira lista; lista intacta; string inválida vira [])
  • Origem: AT-464 itens 1 e 3 (TP-01 de 09/10, sessão 80e3ce09: "17 arquivo(s) alterado(s) nesta PR" numa PR de 3 arquivos contra a dev, e coverageMatrix/itens gravados como "[]" nos últimos pareceres)

RN-776 — Tarefa da execução esperando alguém segura a sessão de execução​

  • Regra: o heartbeat não fecha a sessão de execução VIGENTE do projeto (a active mais recente com execution.activated) enquanto houver tarefa de história não arquivada BLOQUEADA, com PR em awaiting_user, ou devolvida por conflito de merge (RN-715, backlog.task_merge_conflict nesta sessão) e ainda in_progress. É o sexto sinal da RN-064, sem teto, como os outros sinais que esperam uma pessoa; vem antes do sinal da conversa ociosa (RN-581), que continua o único com teto. Sessão de execução ANTIGA do mesmo projeto não é segurada pela tarefa: a tarefa é do projeto, e sem esse recorte ela ficaria imortal.
  • Onde: apps/api/src/application/use-cases/sessions/get-session-pending-work.use-case.ts:242 (findPendenteDaExecucao), apps/api/src/infrastructure/persistence/drizzle/backlog.repository.ts:252 (findPendenteDaExecucao)
  • Teste: apps/api/test/application/use-cases/sessions/get-session-pending-work.use-case.spec.ts (bloqueada, PR esperando merge e conflito seguram; sem nada, fecha; sessão de execução antiga não é segurada)
  • Origem: AT-457 (TP-01 de 09/10: as duas sessões de execução fecharam 2–8 s depois do dev.idle, com tarefa bloqueada e PR esperando o merge)

RN-777 — Desbloquear a tarefa acorda o dev do módulo DELA​

  • Regra: task.became_claimable com causa task_unblocked leva como modules o módulo da TAREFA (tasks.module, RN-678) quando ele existe, e os da história só quando a tarefa não tem módulo — o claim é pelo módulo da tarefa, então é o dev dele que tem de acordar. O conflito de merge já acorda o dono (RN-715); com a sessão de execução de pé (RN-776), o dono continua com linha em dev_agent_states para ser acordado.
  • Onde: apps/api/src/application/use-cases/execution/unblock-task.use-case.ts:68 (modules)
  • Teste: apps/api/test/application/use-cases/execution/unblock-task.use-case.spec.ts (tarefa com módulo acorda o dev desse módulo)
  • Origem: AT-457

RN-778 — O dev agent órfão fecha por evento novo no boot​

  • Regra: no boot, para cada sessão não terminal, o dev agent cujo último dev.* na sessão é de espera (dev.working, dev.blocked, dev.idle_tripped e os demais que o GetSessionPendingWorkUseCase conta) e que NÃO tem linha em dev_agent_states NESTA sessão ganha dev.error com origem infra (reason: dev_agent_sem_processo) seguido de dev.idle, que o tira da régua do trabalho pendente (RN-064). É o molde da RN-586 para o vocabulário dev.*: nunca reexecuta, nunca reescreve evento, e a linha durável é a prova de dono — com ela o DevRehydrator religa o agente, e fechá-lo seria matar um agente saudável. Não toca a tarefa: tarefa bloqueada ou com PR esperando o merge continua segurando a sessão de execução vigente pela RN-776 (AT-457), que tem dono humano.
  • Onde: apps/engine/lib/engine/dev/dev_orfao.ex:49 (varrer), apps/engine/lib/engine/sessions/rehydrator.ex:81 (varrer)
  • Teste: apps/engine/test/engine/dev/dev_orfao_test.exs (o órfão fecha com dev.error infra + dev.idle; o que tem linha durável e o que já está idle não; leitura que falha não fecha nada)
  • Origem: AT-465 (TP-01 de 09/10: a sessão 63a4e1aa reagendada pelo heartbeat a cada 30 s por um dev.working sem processo)

Citada pela RN-922 (ADR 0226), sem mudar: o dev sem linha durável em dev_agent_states continua só fechado, porque não há tarefa a retomar.

RN-790 — O README de como subir o entregável é critério do DoD que o PO escreve​

  • Regra: a instrução de kickoff do PO manda incluir, no DoD da história que cria a entrada da aplicação (servidor, CLI, configuração ou persistência) — ou numa história própria —, o critério "README diz como instalar, configurar (variáveis de ambiente obrigatórias e opcionais, com o padrão) e subir a aplicação, e como criar o primeiro acesso, quando houver". O dev agent cumpre o DoD da história, e o gate o julga por ele. Régua de PROMPT, como a RN-765: nenhuma ferramenta recusa história sem esse critério. A outra variante — o dev agent atualizar o README em toda tarefa que mexe em configuração/entrada — NÃO foi feita: exigiria classificar a tarefa no AmbienteDoAgente ou no report_done, regra nova de código — declarado.
  • Onde: apps/engine/lib/engine/agents/po_server.ex:613 (kickoff_instruction)
  • Teste: apps/engine/test/engine/agents/po_server_test.exs:438 (a instrução de kickoff traz o critério do README)
  • Origem: AT-466 (TP-01 de 09/10: o encurtador-api saiu com um README só com npm test, sem JWT_SECRET obrigatório, DB_PATH/PORT, npm start nem como criar o primeiro membro)

RN-792 — Ao declarar o contrato, o Arquiteto recebe as rotas das histórias que o contrato não declara​

  • Regra: depois de declare_module_contracts gravar a versão (RN-684), a ferramenta lê o backlog do projeto e extrai, de forma determinística e sem LLM, todo MÉTODO /caminho (GET, POST, PUT, PATCH, DELETE) do título, descrição, RF e DoD das histórias não arquivadas, e o mesmo padrão das assinaturas do contrato. Rota citada por história e ausente da UNIÃO das rotas do contrato (a história do front cita a rota do back) volta no resultado como DIVERGÊNCIA, com o que o contrato declara no mesmo método — entrada do laço (RN-163), para o Arquiteto corrigir o contrato ou registrar a decisão. Parâmetro (:id, {id}), barra final e query string são normalizados; teto de 10 linhas, com o total. A leitura do backlog falhar NÃO recusa a declaração (já gravada): o resultado diz que não conferiu. Fica de fora, declarado: rota que só o contrato declara (não é divergência), história editada DEPOIS da declaração (só é conferida na próxima), e o contrato acompanhar a rota que o DEV renomeou (o QA nomeia a divergência no parecer, RN-786, mas nada a leva de volta ao Arquiteto).
  • Onde: apps/engine/lib/engine/harness/rotas_do_contrato.ex:37 (divergencias), apps/engine/lib/engine/harness/tools/declare_module_contracts.ex:119 (conferencia_com_historias)
  • Teste: apps/engine/test/engine/harness/tools/declare_module_contracts_test.exs (GET /painel na história × GET /panel no contrato acusa; rota igual com parâmetro e barra normalizados não acusa; backlog ilegível diz que não conferiu)
  • Origem: AT-468 (TP-01 de 09/10: a história pedia GET /painel, o contrato declarou GET /panel, e ninguém acusou na arquitetura)

RN-794 — Sem execução vigente, a tela diz o que ficou pendente e religa pelo mesmo caminho​

  • Regra: quando a leitura da sessão de execução vigente (RN-139) CONFIRMA que não há nenhuma e o backlog tem tarefa que não está done (todo — inclusive a devolvida pelo gate —, in_progress, in_review, bloqueada), a aba Executores e a seção Execução da Visão geral dizem quantas são e oferecem "Religar execução", que chama o MESMO POST .../execution/activate (ActivateExecutionUseCase) do botão "Ativar execução", sem originSessionId (como a Visão geral sempre chamou: não há sessão de chat para a api fechar, RN-135). A recusa (409 sem repositório, sessão consultiva, papel) aparece com a frase da api. "Desbloquear" sem execução vigente avisa que a tarefa só roda quando a execução for religada — o botão está na mesma seção. Na aba Executores o backlog só é lido com a ausência confirmada (query desligada com execução de pé, ritmo de projeto quando ligada, RN-632).
  • Onde: apps/web/src/components/ReligarExecucao.tsx:23 (ReligarExecucao), apps/web/src/lib/execution.ts:21 (contarTarefasPendentes), apps/web/src/routes/ProjectExecutorsTab.tsx:99 (semExecucaoVigente), apps/web/src/routes/ProjectOverviewTab.tsx:514 (semExecucaoVigente)
  • Teste: apps/web/src/routes/ProjectExecutorsTab.test.tsx (religar a execução), apps/web/src/routes/ProjectOverviewTab.test.tsx (religar a execução encerrada)
  • Origem: AT-469

RN-800 — A linha do tempo do time filtra por família, não pela roster da sessão mais recente​

  • Regra: a árvore "Linha do tempo do time" da Visão geral lê a sessão de EXECUÇÃO quando há uma vigente (AT-463), e tira dela os eventos de dev agent e de QA pela FAMÍLIA do ator (isExecutorAgentId, a mesma régua da aba Executores, RN-121) — nunca pela roster, que é derivada da sessão mais recente. Filtrar pela roster apagava todo agente que só agiu na execução (o Infra Lead cujo handoff foi aceito lá), e com uma ideação aberta depois a árvore dizia "Nenhum agente entrou em ação nesta sessão ainda" sobre um time trabalhando. Sem leitura nova.
  • Onde: apps/web/src/routes/ProjectOverviewTab.tsx:275 (overviewEvents)
  • Teste: apps/web/src/routes/ProjectOverviewTab.test.tsx ("AT-471: agente que só agiu na execução aparece na árvore…"; o caso sem execução segue lendo a mais recente)
  • Origem: AT-471 (TP-01 de 10/10)

RN-801 — O nome da nova sessão não convida o gerenciador de senhas​

  • Regra: o campo "Nome (opcional)" do formulário de nova sessão (aba Criativo e aba Chat) recebe o MESMO SEM_AUTOFILL do composer e do assistente (RN-736/RN-740): autocomplete="off", aria-autocomplete="none" e os ignores de 1Password, LastPass, Bitwarden e Proton Pass. O composer da Sessão já o tinha. A constante NÃO tem atributo de ignore do NordPass, e nenhum foi inventado: se o NordPass segue se anexando, o resto da lacuna é da extensão, como na RN-740.
  • Onde: apps/web/src/routes/ProjectSessionsTab.tsx:319 (SEM_AUTOFILL), apps/web/src/lib/conversa-comecou.ts:29 (SEM_AUTOFILL)
  • Teste: apps/web/src/routes/ProjectSessionsTab.test.tsx ("AT-472: o nome da nova ideação não convida o gerenciador de senhas")
  • Origem: AT-472 (TP-01 de 10/10)

RN-796 — O item do gitleaks diz a regra e o trecho com o segredo mascarado​

  • Regra: o item que o gitleaks põe no parecer do SecOps leva, além do caminho e da linha, a regra que acusou (RuleID) e o trecho acusado (Match) com o segredo (Secret) trocado por *** — nunca o segredo em claro. Sem o Secret para mascarar, o trecho não entra. E o ambiente do agente de execução (RN-706) diz em uma linha que senha, token ou chave de teste são gerados em tempo de execução, nunca literais. Sem allowlist nova.
  • Onde: apps/engine/lib/engine/actions/gitleaks_detector.ex:121 (mensagem), apps/engine/lib/engine/harness/ambiente_do_agente.ex:102 (montar)
  • Teste: apps/engine/test/engine/actions/gitleaks_relatorio_test.exs, apps/engine/test/engine/harness/ambiente_do_agente_test.exs
  • Origem: AT-470

RN-797 — A task cuja branch não tem diff contra a dev fecha como "já na dev"​

  • Regra: no report_done, antes de propor commit, push e PR, o dev agent pergunta se a branch do worktree traz algo que a dev não tem: árvore limpa e git diff --quiet <ponta da dev>...HEAD sem diferença. É o caso da task reintegrada (RN-779) cujo código já entrou por outra PR. Sem diff, a task fecha como done com o evento dev.task_already_in_dev (agente, task, branch, motivo) — sem commit, sem PR, sem gates e sem clique. Qualquer dúvida (pasta que o engine não alcança, como no modo runner; git que falha; ref da dev ausente) é "tem diff": a task segue para os gates, nunca fecha sozinha. Mede DIFF e não ancestralidade, então cobre também o merge por squash que a RN-760 declara não ver. Declarado: a PR antiga da branch, se existir, não é fechada no provider.
  • Onde: apps/engine/lib/engine/dev/worktree_manager.ex:246 (sem_diff_contra_a_dev?), apps/engine/lib/engine/dev/dev_agent_server.ex:1056 (ja_na_dev)
  • Teste: apps/engine/test/engine/dev/tarefa_ja_na_dev_test.exs
  • Origem: AT-474

RN-798 — O QA confere os comandos do README tocado pela entrega contra os scripts​

  • Regra: quando o diff da entrega contra a dev toca um README, o QA confere, sem LLM, que todo npm run <s>, npm test, npm start, pnpm [run] <s> e yarn [run] <s> citado nele existe em scripts do package.json da mesma pasta (senão o da raiz do worktree). A conferência entra na consolidação como mais um parecer ("Conferência do README"): divergência é changes_requested com um item por comando, nomeando o script que falta; sem divergência, aprova. Sem README no diff, diff que falhou ou package.json inalcançável, nada é afirmado. De FORA, declarado: subcomando embutido do gerenciador (pnpm install, pnpm dlx…), comando com flag antes do script (pnpm --filter x test), flags de CLI do próprio projeto e comando que não é de Node.
  • Onde: apps/engine/lib/engine/gates/conferencia_do_readme.ex:28 (conferir), apps/engine/lib/engine/gates/qa_lead_server.ex:796 (conferencia_do_readme)
  • Teste: apps/engine/test/engine/gates/conferencia_do_readme_test.exs
  • Origem: AT-475

RN-803 — A oferta em lote do modo automático fica inerte até saber quem já está ligado​

  • Regra: enquanto a leitura de agent_autonomy do projeto não chegou, o "Ligar para N agentes" da aba Executores (RN-661) e as caixas dos agentes ficam disabled, com o motivo em texto ("Lendo quais agentes já estão em modo automático…"); o clique nesse intervalo não grava nada. Antes, a lista nascia com TODOS os agentes como palpite e encolhia quando a leitura chegava, e o primeiro clique depois de navegar caía em outro elemento sem gravar nem dizer nada. O desfecho do lote (todos, nenhum com a frase da api, alguns) é dito mesmo que a releitura depois das gravações falhe. Declarado: o mesmo sintoma no "Merge" da aba PRs, em "Nova ideação" e no "Aplicar a todos os agentes" NÃO foi reproduzido nem corrigido aqui.
  • Onde: apps/web/src/components/ModoAutomaticoDoTime.tsx:98 (carregando)
  • Teste: apps/web/src/components/ModoAutomaticoDoTime.test.tsx ("AT-477 (RN-803): antes de a leitura de agent_autonomy chegar, o botão fica inerte e diz por quê")
  • Origem: AT-477 (TP-01 de 10/10)

RN-804 — O cartão do plano do Dev Lead oferece o modo automático em lote antes de aprovar​

  • Regra: o ApprovalCard de propose_execution_plan pendente mostra, acima de "Aprovar", o MESMO controle de oferta em lote da aba Executores (RN-661): os dev-<modulo> que o plano sobe (mesma derivação de devAgentId da api) mais os subagentes de gate, todos marcados, e nada gravado sem o clique em "Ligar". Mesmo endpoint (PUT .../agent-autonomy, curinga *), um PUT por agente, desfecho por agente e a mesma lista do que o modo automático não libera. Aprovar ativa a execução (RN-677) e o dev pede o primeiro comando logo depois; ligar depois não aprova o pendente (RN-755). Só aparece onde quem chama passou o callback do modo automático (papel maintainer) e há QueryClient. O cartão segue único (AT-322): é um bloco a mais do tipo, não variante de aparência.
  • Onde: apps/web/src/components/ApprovalCard.tsx:359 (ModoAutomaticoDoPlano), apps/web/src/components/ModoAutomaticoDoPlano.tsx:17 (agentesDoPlano)
  • Teste: apps/web/src/components/ApprovalCard.modo-automatico-do-plano.test.tsx
  • Origem: AT-476 (TP-01 de 10/10)

RN-805 — O plano de teste gravado é o que a ferramenta aceitou​

  • Regra: emit_plano_de_teste valida os argumentos JÁ normalizados (RN-719: criteriosExecutaveis em string JSON vira lista), e o hook que encerra o laço da QA-estratégia lê criteriosExecutaveis pela MESMA normalização (Termination.lista/1, a da RN-787). Antes, a tool dizia "plano registrado: 9 critérios", o hook levava a string crua, o artefato era recusado (:criterios_vazios), o agente devolvia {:ok, plano} mesmo assim e a Automação caía no Enum da string — o QA Lead morria e o gate ficava parado até o resgate (18 min no uso real). Artefato recusado agora é falha NOMEADA da QA-estratégia (agent.error, origem codigo) e a revisão segue sem plano, como já seguia quando o plano falha; a Automação também normaliza os critérios que recebe.
  • Onde: apps/engine/lib/engine/gates/hooks/termination_plano_de_teste.ex:23 (criterios_executaveis), apps/engine/lib/engine/gates/qa_estrategia_agent.ex:231 (emit_returning)
  • Teste: apps/engine/test/engine/gates/qa_estrategia_agent_test.exs ("critérios em string JSON viram lista: o plano é gravado e devolvido como lista")
  • Origem: AT-478 (TP-01 de 10/10)

RN-807 — O exec que estoura o teto volta como estouro, e o comando morre dentro do container​

  • Regra: o estouro do teto do exec da DockerPort é decidido pelo RELÓGIO (decorrido ≥ teto) além do killed, porque o cliente docker exec captura o SIGTERM do timeout do execFile e sai com código 0 — medido: sleep 30; echo FIM sob teto de 3s voltava erro: null, stdout: "", e o adaptador devolvia exitCode: 0. Comando de agente que estourava os 120s voltava "exit 0", e o QA aprovou suíte que trava. O estouro agora devolve exitCode: -1, timedOut: true e a marca de timeout. E matar o cliente não mata o comando: o exec embrulha o comando (que segue inteiro e não interpretado; o canal é -e BRABO_EXEC_CMD, RN-947) num sh que grava o próprio PID — líder de grupo e de sessão, porque o runtime faz setsid em todo docker exec — em /tmp do container; no estouro o adaptador mata o GRUPO por esse PID (SIGKILL), numa chamada interna, sem operação nova na porta (ADR 0130). Medido e recusado: timeout do busybox dentro do sh -c — o vigia morre com o cliente e o sleep sobrevive. Se a morte falhar (/tmp sem escrita, por exemplo), a marca diz que o processo pode ter sobrevivido.
  • Onde: packages/docker-port/src/docker-cli.ts:279 (estourouOTeto), packages/docker-port/src/docker-cli.ts:506 (matarExecEstourado)
  • Teste: packages/docker-port/src/docker-cli.spec.ts ("erro nulo depois do teto É estouro"; "no estouro mata o grupo do comando DENTRO do container"; "o comando que estoura volta timedOut e MORRE dentro do container", contra Docker real, pulado sem daemon)
  • Origem: AT-482

RN-806 — Um ciclo de QA por tarefa: pedido anterior ao veredito é descartado​

  • Regra: o QA Lead roda UM ciclo por task. Pedido {:run, task} para a task cujo ciclo está suspenso esperando decisão é ignorado, e ao fim de um ciclo (veredito ou bloqueio) todo {:run, task} que esperava na caixa do processo é descartado — foi pedido antes de o veredito existir, e a correção legítima do dev só nasce depois dele. No uso real o resgate (RN-722) disparou o ciclo duas vezes no mesmo instante, e o segundo, rodando depois do aprovado, gravou um parecer contrário sobre a tarefa já mergeada. De FORA, declarado: duas réplicas do engine (a caixa é por processo, a mesma ressalva do GateRescuer) e a causa da dupla varredura do resgate, que não foi corrigida aqui.
  • Onde: apps/engine/lib/engine/gates/qa_lead_server.ex:106 (handle_cast), apps/engine/lib/engine/gates/qa_lead_server.ex:157 (descartar_pedidos_obsoletos)
  • Teste: apps/engine/test/engine/gates/qa_lead_server_test.exs ("pedido repetido que esperava na caixa é descartado ao fim do ciclo"; "pedido para a task com ciclo suspenso não abre um segundo")
  • Origem: AT-478 (TP-01 de 10/10)

RN-809 — As obrigações de módulo e imagem do Arquiteto entram no piso do Jev​

  • Regra: estende a RN-758 e a RN-784. O piso do Arquiteto ganha assign_story_modules e choose_project_image, os passos 2 e 3 do kickoff dele (arquiteto_server.ex): o recorte do Jev nunca as tira, e o Arquiteto não encerra dizendo que a ferramenta "não está disponível nesta etapa" com histórias sem módulo e imagem não decidida. emit_insight (passo 8, registro de tensão) e emit_artifact ficam fora do piso: não são obrigação da etapa. Continua valendo que o piso só devolve o que está no catálogo do passo.
  • Onde: apps/api/src/domain/llm/tool-router.ts:311 (PISO_DO_MENU)
  • Teste: apps/api/test/application/use-cases/llm/decidir-ferramenta-do-passo.use-case.spec.ts (Arquiteto recortado pelo Jev sem assign_story_modules/choose_project_image: as duas continuam no menu (RN-809))
  • Origem: AT-481 (TP-01: o recorte deixou 7 de 11 ferramentas e o Arquiteto não distribuiu as histórias por módulo)

RN-810 — Falha de rede passageira do provider é retentada, e o bloqueio dela é infra sem contar no disjuntor​

  • Regra: quando a chamada ao modelo volta com erro de REDE do provider — o code connection/timeout que a api normaliza (ADR 0041) ou, sem código, o texto do transporte (EAI_AGAIN, ECONNRESET, ETIMEDOUT, ECONNREFUSED, ENOTFOUND) —, o ToolLoop a retenta até duas vezes, com espera de 1 s e 3 s, gravando toolloop.network_retry a cada uma. Vale para todo consumidor do ToolLoop (dev agents e subagentes de gate). Esgotado o teto, a origem do desfecho é infra, nunca codigo, e o bloqueio da task do dev agent por essa causa NÃO incrementa o contador de bloqueios seguidos (RN-047): o agente segue reivindicando. No uso real um getaddrinfo EAI_AGAIN openrouter.ai de 17 s bloqueou duas tarefas como codigo e desarmou o dev. A correção pós-gate segue a mesma régua (AT-621, RN-864).
  • Onde: apps/engine/lib/engine/harness/tool_loop.ex:266 (llm_turn_com_retentativa), apps/engine/lib/engine/agents/falha_de_turno.ex:119 (falha_de_rede?), apps/engine/lib/engine/dev/dev_agent_server.ex:952 (bloquear_parada), apps/engine/lib/engine/dev/agent_io.ex:228 (finish_task)
  • Teste: apps/engine/test/engine/harness/tool_loop_test.exs ("falha de rede do provider: retenta e segue quando a rede volta"; "falha de rede que persiste esgota o teto"), apps/engine/test/engine/dev/dev_agent_server_test.exs ("erro de rede do provider: origem infra e o disjuntor não conta"), apps/engine/test/engine/agents/falha_de_turno_test.exs
  • Origem: AT-484 (TP-01 de 10/10)

RN-811 — O "já na dev" compara com a dev do remoto atualizada​

  • Regra: antes da checagem "já na dev" (RN-797) no report_done, o dev agent atualiza a dev do remoto pelo MESMO fetch que a RN-779 faz no claim, serializado por projeto. Sem ele a comparação usava a dev do dia em que o worktree nasceu: a branch da task cujos commits já tinham entrado na dev remota pela PR de outra task passou por QA e SecOps com 0 arquivos. fetch que falha não decide nada — compara com o que há, e na dúvida a task segue para os gates.
  • Onde: apps/engine/lib/engine/dev/worktree_manager.ex:267 (sem_diff_contra_a_dev?), apps/engine/lib/engine/dev/worktree_manager.ex:277 (sem_diff_atualizado?), apps/engine/lib/engine/dev/dev_agent_server.ex:892 (handle_outcome)
  • Teste: apps/engine/test/engine/dev/tarefa_ja_na_dev_test.exs ("caminho feliz: com a dev atualizada, a branch não tem diff"; "falha: remoto inalcançável não fecha a task sozinha")
  • Origem: AT-486 (TP-01 de 10/10)

RN-812 — A PR aberta da mesma branch é reaproveitada, nunca duplicada​

  • Regra: antes de abrir a PR de um pr_open, o executor pergunta ao provider (listPullRequests, só as abertas) se já há PR ABERTA com a mesma origem e o mesmo destino. Havendo, ela é reaproveitada: a ação fecha executed com a URL e o id dela e reaproveitada: true, sem abrir outra — o push da task já a atualizou. No uso real a task reaberta depois de bloqueio abriu a pr-9 da mesma branch com a pr-8 aberta, e a aba PRs mostrou as duas. Provider sem a listagem, ou listagem que falha, não impede abrir: o caminho de sempre segue. Fica como estava a PR da branch que a RN-797 fecha como "já na dev": essa não é fechada no provider.
  • Onde: apps/api/src/application/use-cases/actions/execute-git-action.use-case.ts:32 (prAbertaDaBranch), apps/api/src/application/use-cases/actions/execute-git-action.use-case.ts:204 (prAbertaDaBranch)
  • Teste: apps/api/test/application/use-cases/actions/execute-git-action.use-case.spec.ts ("pr_open com PR aberta da mesma branch → reaproveita, sem abrir outra"; "pr_open: listar PRs falha → abre a PR como sempre")
  • Origem: AT-485 (TP-01 de 10/10)

RN-815 — O "Parar execução" conta as tarefas em curso E em revisão, lidas do backlog​

  • Regra: a confirmação de "Parar execução" (RN-763) conta as tarefas pelo BACKLOG, lido só com a confirmação aberta, e não pela janela de eventos: in_progress e in_review (gate de QA rodando) em frases separadas, cada uma dizendo o que acontece com ela ao parar — a em curso é bloqueada com o trabalho preservado; a em revisão tem o gate parado e é bloqueada com a PR aberta; as duas voltam à fila quando liberadas. Antes a contagem saía dos dev agents com task na janela, e a tarefa em revisão (sem dev trabalhando nela) fazia a tela afirmar "Nenhuma tarefa está em curso". Backlog carregando ou com erro é dito em texto próprio, nunca como "nenhuma". Junto, o cartão do plano do Dev Lead (RN-804) abre a oferta em lote com texto próprio ("Ao aprovar o plano, a execução começa"), porque ali a execução ainda não começou (RN-677).
  • Onde: apps/web/src/lib/execution.ts:37 (contarTarefasAfetadasAoParar), apps/web/src/components/PararExecucao.tsx:26 (PararExecucao), apps/web/src/components/ModoAutomaticoDoTime.tsx:73 (ModoAutomaticoDoTime)
  • Teste: apps/web/src/components/PararExecucao.test.tsx ("RN-815 (AT-488): a tarefa em revisão de QA conta…"; "RN-815: sem backlog lido não afirma…"), apps/web/src/components/ModoAutomaticoDoTime.test.tsx ("RN-816 (AT-488): no cartão do plano não afirma que a execução começou")
  • Origem: AT-488 (TP-01 de 10/10)

RN-822 — O primeiro clique em Merge abre o cartão sem esperar a fila​

  • Regra: extensão da RN-816. Na aba PRs, o clique em "Merge" que cria a proposed_action de git_merge mostra o cartão de decisão com a ação que a api DEVOLVEU, na hora, em vez de esperar a fila do projeto (useProjectPendingActions) voltar. Antes, entre a resposta da proposta e o refetch da fila, o botão voltava sem cartão nenhum e a pessoa lia "o clique não pegou". A ação local vale por id da PR só enquanto a fila não tiver leitura MAIS NOVA que a proposta (dataUpdatedAt); com leitura mais nova, quem diz se ela segue pendente é a fila. Decidir (aprovar, negar, sempre permitir) a esquece. Declarado: a causa foi medida no CÓDIGO (o cartão dependia do refetch), não no navegador; que o refetch demore o bastante para o clique parecer perdido é inferência do TP-01.
  • Onde: apps/web/src/routes/ProjectPrsTab.tsx:125 (acaoDaPr)
  • Teste: apps/web/src/routes/ProjectPrsTab.test.tsx ("RN-822 (AT-493): o 1º clique em Merge abre o cartão sem esperar a fila do projeto voltar" e "RN-822: a fila lida DEPOIS da proposta, sem ela, vence o cartão local")
  • Origem: AT-493 (TP-01 de 10/10)

RN-816 — O botão não muda de lugar quando um bloco tardio chega​

  • Regra: bloco que chega DEPOIS do primeiro paint não fica acima do botão que a pessoa vai clicar, e o que precisa ficar acima tem o espaço reservado enquanto carrega. Na Sessão as pendências de outras sessões (RN-626) moram ACIMA do fio — entre o fio e o composer elas o encolhiam por baixo e, com o "acompanha o fim" (RN-751), o card final ("Aceitar handoff e iniciar…") subia sob o cursor. Na aba Executores elas vêm DEPOIS de "Parar execução" e da oferta em lote. Na aba PRs o Merge vem primeiro na coluna da linha, e a esteira do gate, que depende do backlog, tem altura reservada (esqueleto) enquanto ele carrega. Declarado: a reserva da esteira é a altura de uma esteira de uma linha de etapas, não medida no navegador, e jsdom não faz layout — o teste prova a ORDEM e a reserva, não o deslocamento em pixels.
  • Onde: apps/web/src/routes/SessionPage.tsx (PendenciasDeOutrasSessoes antes de styles.messages), apps/web/src/routes/ProjectExecutorsTab.tsx, apps/web/src/routes/ProjectPrsTab.tsx:103 (ProjectPrsTab), apps/web/src/routes/ProjectPrsTab.module.css (.reservaDaEsteira)
  • Teste: apps/web/src/routes/salto-de-layout.test.ts, apps/web/src/routes/ProjectPrsTab.test.tsx ("RN-816 (AT-480): o Merge não muda de lugar quando o backlog chega")
  • Origem: AT-480 (TP-01 de 10/10)

RN-813 — Tarefa que toca o ponto de entrada exige evidência de que ele sobe​

  • Regra: extensão do recorte da RN-765. Quando o título ou a descrição da tarefa toca o ponto de entrada, a fiação (rotas, migração, registro) ou o README de execução, o recorte do gate de QA ganha a seção "Ponto de entrada": exigir evidência de que o entrypoint REAL sobe (o start/main do projeto rodado, ou um teste que importe o entrypoint real, não um app montado no próprio teste) e responde numa rota ou comando. Sem essa evidência, a lacuna vai para itens. É régua de PROMPT: o contrato externo dos gates (docs/gates.yml) e o código do emit_qa_verdict não mudam, e nenhum gate novo nasce. Tarefa que não toca nada disso não ganha a seção.
  • Onde: apps/engine/lib/engine/gates/recorte_da_tarefa.ex:121 (secao_do_ponto_de_entrada)
  • Teste: apps/engine/test/engine/gates/recorte_da_tarefa_test.exs ("tarefa que toca a fiação exige evidência do ponto de entrada real" e "tarefa que não toca entrypoint nem fiação não ganha a seção")
  • Origem: AT-483 (TP-01 de 10/10: o server.ts nunca chamava a migração da tabela links e /:code sombreava GET /links; os 53 testes passavam porque cada um montava o próprio banco e app)

RN-814 — O parecer do QA lista todas as lacunas numa volta​

  • Regra: extensão do recorte da RN-765: o gate de QA lista em itens TODAS as lacunas da tarefa que achar na volta, de uma vez, nunca uma por volta — cada volta é uma correção paga e um passo do teto do ciclo. O prompt anterior só mandava listar "quais regras faltam", e lacuna que não era regra descoberta ficava para a volta seguinte. Régua de prompt.
  • Onde: apps/engine/lib/engine/gates/recorte_da_tarefa.ex:20 (texto)
  • Teste: apps/engine/test/engine/gates/recorte_da_tarefa_test.exs ("o recorte pede todas as lacunas numa volta só")
  • Origem: AT-487 (TP-01 de 10/10, tarefa c7ced5a2: duas reprovações por duas lacunas que já existiam na entrega original)

RN-817 — O QA roda a suíte em estado limpo, e teste pulado não é verde​

  • Regra: extensão do recorte da RN-765, para toda tarefa. Antes de rodar a suíte, o gate de QA encerra o que o PRÓPRIO projeto deixou de pé (servidor do start escutando numa porta do projeto), só processos daquela pasta, nunca de fora. Teste que sobe processo deve encerrar o GRUPO (ou subir node direto, não via npm); o que não encerra trava a suíte num clone limpo e é lacuna em itens. Teste que se PULOU por ambiente (porta ocupada, ferramenta ausente) não conta como verde: vai ao resumo como observação. É instrução do recorte, não passo do harness: quem roda a suíte é o próprio QA pelo terminal, que já passa pela política e pelo roteamento de container dos três modos — um pkill no harness precisaria de um segundo caminho de execução por modo. Régua de prompt; o contrato dos gates não muda.
  • Onde: apps/engine/lib/engine/gates/recorte_da_tarefa.ex:178 (secao_do_ambiente_limpo)
  • Teste: apps/engine/test/engine/gates/recorte_da_tarefa_test.exs ("RN-817: pede ambiente limpo, encerrar o grupo e pulado como observação")
  • Origem: AT-496 (TP-01 de 10/10: npm test travava num clone limpo — o teste matava o npm e o node neto ficava na 3000 —, e no container o QA viu exit 0 porque o teste se pulava com a porta ocupada por um servidor órfão)

RN-818 — A evidência do ponto de entrada é a que o ambiente do dev produz​

  • Regra: extensão da RN-813. A seção "Ponto de entrada" diz o que CONTA como evidência no ambiente do dev — subir o processo do entrypoint na pasta do projeto, rodar o script no container do projeto, bater numa rota do processo de pé — e o que NÃO cabe ali: construir imagem Docker, docker compose, rede externa. Exigência desse segundo tipo nunca vai para itens; vai ao resumo como observação. Régua de prompt.
  • Onde: apps/engine/lib/engine/gates/recorte_da_tarefa.ex:121 (secao_do_ponto_de_entrada)
  • Teste: apps/engine/test/engine/gates/recorte_da_tarefa_test.exs ("RN-818: com ponto de entrada diz o que conta e o que não cabe" e "RN-818: sem ponto de entrada não traz a régua de evidência")
  • Origem: AT-492 (TP-01 de 10/10, task c708f8b2: a 4ª volta exigiu construir a imagem a partir do Dockerfile, o container do projeto não tem Docker, e o ciclo esgotou em bloqueio politica)

RN-819 — O QA varre regra por regra antes de emitir​

  • Regra: extensão da RN-814. Além de listar todas as lacunas, o recorte pede uma VARREDURA POR REGRA antes de emitir: percorrer cada regra de negócio e cada critério da tarefa, um por um, apontando onde está a prova ou que ela falta, e só emitir depois de passar por todas. "Liste todas" sozinho não segurou lacuna pré-existente aparecendo na volta seguinte. Régua de prompt.
  • Onde: apps/engine/lib/engine/gates/recorte_da_tarefa.ex:20 (texto)
  • Teste: apps/engine/test/engine/gates/recorte_da_tarefa_test.exs ("RN-819: pede varredura por regra antes de emitir")
  • Origem: AT-490 (TP-01 de 10/10: tarefas 2 e 11 trouxeram na 2ª volta lacuna que já estava na entrega)

RN-846 — A rota HTTP vai ao README e o erro de entrada é do projeto​

  • Regra: extensão da RN-819. O kickoff do dev agent de execução pede que rota HTTP criada ou mudada entre no README (método, caminho, corpo, respostas e códigos) e que erro de entrada (JSON malformado, campo inválido) responda com mensagem própria no formato do projeto, nunca a mensagem crua da biblioteca. O recorte do QA confere as duas coisas na varredura por regra, e o que faltar vai a itens. Régua de prompt.
  • Onde: apps/engine/lib/engine/dev/dev_agent_server.ex:604 (initial_message) e apps/engine/lib/engine/gates/recorte_da_tarefa.ex:20 (texto)
  • Teste: apps/engine/test/engine/dev/report_done_instrucao_test.exs ("RN-846: kickoff pede a rota no README e erro de entrada próprio") e apps/engine/test/engine/gates/recorte_da_tarefa_test.exs ("RN-846: confere rota no README e erro de entrada próprio")
  • Origem: AT-515 (TP-01, 12ª: o README não documentava a API, e JSON malformado respondia 400 com "Expected property name…" do parser)

RN-850 — A rota entra na seção "Rotas HTTP" e o README não leva nota de processo​

  • Regra: extensão da RN-846. O kickoff do dev agent pede que a rota entre DENTRO da seção "Rotas HTTP" do README (criada antes de "Testes" se faltar), nunca no fim do arquivo, e que o README, que é do usuário, não ganhe nota de processo nem instrução para agentes. O recorte do QA confere as duas coisas. Régua de prompt.
  • Onde: apps/engine/lib/engine/dev/dev_agent_server.ex:604 (initial_message) e apps/engine/lib/engine/gates/recorte_da_tarefa.ex:20 (texto)
  • Teste: apps/engine/test/engine/dev/report_done_instrucao_test.exs ("RN-850: a rota vai dentro de Rotas HTTP e o README não leva nota de processo") e apps/engine/test/engine/gates/recorte_da_tarefa_test.exs ("RN-850: confere a seção Rotas HTTP e a ausência de nota de processo")
  • Origem: AT-520 (TP-01: cada tarefa acrescentou rotas no fim do README, depois de "Testes", e sobrou um parágrafo de processo para agentes)

RN-851 — O compose da Infra sobe o APP na máquina do usuário, e a PR declara como rodá-lo​

  • Regra: o kickoff do Infra Lead pede o compose que SOBE O APP (não só ambiente de dev): build do Dockerfile de cada módulo com FROM = a imagem decidida (projectImage do contexto da Infra; sem ela, a candidata do roteamento, dito no README), porta ${APP_PORT:-8080}:<porta interna>, volume para dados persistentes, .env.example commitado (nunca .env) e a seção "Rodar na sua máquina" no README. O container de TRABALHO dos agentes segue subindo pelo broker, e não há deploy. propose_infra_pr ganha o campo OBRIGATÓRIO run (command até 300 caracteres; urls de 1 a 5, label até 60, kind em api_docs | web, url começando com http://localhost: ou http://127.0.0.1:; envExample booleano), validado no engine ANTES do HALT com recusa nomeada que entra no laço (RN-163) e repassado no payload de open_infra_pr. Decisão no ADR 0213.
  • Onde: apps/engine/lib/engine/infra/instrucoes_de_execucao.ex:27 (validar), apps/engine/lib/engine/infra/infra_lead_server.ex:1416 (build_kickoff) e :1317 (abrir_pr), e apps/engine/lib/engine/infra/tools/propose_infra_pr.ex:24 (spec)
  • Teste: apps/engine/test/engine/infra/infra_lead_server_test.exs (describe "o compose sobe o app e a PR leva run (RN-851)")
  • Origem: AT-517 (o entregável não tinha como ser rodado pelo usuário)

RN-823 — O ciclo de gate que o reinício do engine matou é retomado no boot, e o fio diz isso​

  • Regra: no BOOT do engine, a linha in_progress de gate_states RECENTE (abaixo de gate_rescue_stale_after_seconds, 15 min, e por isso invisível para o tick) sem processo vivo NESTE nó nem nos OUTROS nós do cluster é órfã do reinício, não um ciclo lento: o boot a retoma na hora (Dispatcher.run_qa/run_secops), sem esperar o limiar. "Vivo" é a régua da RN-722 (a task em voo no lead), perguntada também aos outros nós por :erpc.multicall — nó que não responde conta como VIVO, a régua do TurnoOrfao (RN-586). Valem as guardas de sempre: ação pendente de ator do gate não reinicia, e dev dono ausente só apaga a linha. Antes de redespachar, o boot grava gate.interrupted no log da sessão (taskId, gate, ultimaAtividadeEm, retomadoEm), e o fio o mostra como aviso, nos dois idiomas. O tick periódico NÃO muda: fora do boot, linha recente é ciclo em voo. Resíduo declarado: nó que ainda não entrou no cluster no instante do boot não é perguntado, e o pior desfecho é o de sempre (trabalho duplicado e barato; o segundo veredito é recusado pela api).
  • Onde: apps/engine/lib/engine/gates/gate_rescuer.ex:231 (run_boot), :240 (interrompido_no_boot), apps/web/src/routes/RetomarGateEstacionado.tsx:33 (cicloInterrompido)
  • Teste: apps/engine/test/engine/gates/gate_rescuer_test.exs ("boot com ciclo RECENTE órfão do reinício: grava gate.interrupted e retoma já (RN-823)"; falhas: "boot com ciclo recente esperando ação PENDENTE do gate: não reinicia nem grava (RN-823)" e o processo local vivo intocado), apps/web/src/routes/RetomarGateEstacionado.test.tsx ("cicloInterrompido (RN-823)")
  • Origem: AT-495 (TP-01 de 10/10: o reinício das 18:51 matou o ciclo de QA da tarefa 10, e o dev ficou awaiting_gate 15 min sem tela nenhuma dizer)

Revisada pela RN-923 (ADR 0226). A retomada do boot conta como a automática do ciclo (auto_resumes): se o ciclo já usou a sua, o boot o estaciona em vez de redespachá-lo, e o subagente retomado leva o plano do último agent.turn_resumed.

RN-820 — O container de projeto volta sozinho quando o daemon reinicia​

  • Regra: o docker run composto pelo DockerViaCli leva --restart unless-stopped como CONSTANTE, ao lado do --init da RN-738 — a spec não ganha campo, e a contenção do ADR 0130 não muda. Um daemon reiniciado (atualização do sistema) religa o container de projeto; um docker stop explícito (o container_stop do ciclo de vida) NÃO é desfeito, porque unless-stopped não religa o que foi parado à mão — nunca always. Medido: container unless-stopped parado com docker stop segue exited com RestartCount=0.
  • Onde: packages/docker-port/src/docker-cli.ts:785 (argsDeCriacao)
  • Teste: packages/docker-port/src/docker-cli.spec.ts ("cria o container com rótulo gerenciado, UM bind e as capabilities derrubadas")
  • Origem: AT-494 (o daemon reiniciado deixou todo container de projeto Exited (143) com o registro running)

RN-821 — Registrado rodando e observado parado oferece "Subir de novo"​

  • Regra: na /containers, a linha com registro running e estado OBSERVADO exited deixa de ser "já está de pé" e oferece a ação de subida do modo (acaoDeSubidaDoModo), sem exigir Parar antes, com a ressalva registrado_rodando_observado_parado em texto. Só o observado destrava: sem observação, ou observado running, o registro continua valendo. A api já aceitava: ExecuteContainerStartUseCase com linha running não transiciona nada (a máquina de estados do ADR 0081 não muda) e o broker faz docker start no container parado.
  • Onde: apps/web/src/routes/containers-subida.ts:125 (caiuPorFora)
  • Teste: apps/web/src/routes/containers-subida.test.ts ("AT-494: registrado running e observado exited oferece subir de novo, com a ressalva" e "AT-494: registrado running e observado running (ou não observado) continua já de pé")
  • Origem: AT-494

RN-824 — Depois do mapa de módulos, o composer diz que o próximo passo é confirmar a arquitetura​

  • Regra: a oferta da Infra continua sendo um clique HUMANO ("Confirmar arquitetura pronta"); o que muda é que a tela AVISA. Com o Arquiteto ativo, sem turno em curso, sem o handoff dele já oferecido e com um artifact.module_map gravado na sessão, o composer mostra em texto visível que a arquitetura pode ser confirmada para o sistema oferecer a Infra. Sem história promovida, o mesmo lugar diz o motivo de o botão estar inerte, uma vez, em texto (o title de botão desabilitado não abre no Chromium, RN-102); o botão habilitado ganha a explicação no title. A derivação lê os events que a tela já tem — nenhuma query nova (RN-579).
  • Onde: apps/web/src/lib/session-readiness.ts:149 (temMapaDeModulos), apps/web/src/routes/SessionComposer.tsx:365 (hasModuleMap)
  • Teste: apps/web/src/routes/SessionPage.proximo-passo-arquitetura.test.tsx ("mapa gravado + história promovida + Arquiteto parado: o texto aparece"; falhas: "com o handoff do Arquiteto já oferecido, o texto some", "com turno em curso (streaming), o texto não aparece", "sem história promovida, o motivo vem em texto visível")
  • Origem: AT-491 (opção a: o gesto humano é desenho, o defeito era a tela não avisar)

RN-832 — O dev agent sabe que report_done faz commit, push e PR, e que teste novo entra no script test​

  • Regra: o dev agent não tem nem precisa de git_commit, git_push ou pr_open: commit, push e PR acontecem quando ele chama report_done. A descrição de report_done, a de report_blocked ("falta ferramenta de git" não é motivo de bloqueio), o kickoff do dev agent e a mensagem de ambiente (RN-706) dizem isso — a de ambiente mandava, até aqui, usar as ações tipadas de git, que o dev não tem. O kickoff e o ambiente dizem também que, se o script test do package.json lista arquivos um a um, todo arquivo de teste novo entra nele. Só texto: nenhuma regra nova de código, e o Jev segue sem piso para dev agents (RN-758) — até a RN-839, que pôs report_done/report_blocked no piso deles.
  • Onde: apps/engine/lib/engine/dev/tools/report_done.ex:16 (spec), apps/engine/lib/engine/dev/tools/report_blocked.ex:12 (spec), apps/engine/lib/engine/dev/dev_agent_server.ex:604 (initial_message), apps/engine/lib/engine/harness/ambiente_do_agente.ex:102 (montar)
  • Teste: apps/engine/test/engine/dev/report_done_instrucao_test.exs ("caminho feliz: report_done, kickoff e ambiente carregam as duas instruções"; falha: "o ambiente não manda mais usar as ações tipadas de git")
  • Origem: AT-499 (TP-01, 11ª: tarefa bloqueada com a suíte verde por "git_commit não existe"; quatro voltas de QA por teste fora do script test)

RN-825 — O chat mostra as PRs prontas para merge e propõe o merge ali​

  • Regra: acima do fio da sessão, ao lado das pendências de outras sessões (RN-626), a fila "PRs prontas para merge" lista as PRs ABERTAS do projeto cuja tarefa teve os gates aprovados (gateStatus: awaiting_user) ou que não têm tarefa (ADR, infra), e que não têm git_merge pendente de OUTRA sessão (essa já está em "Merges de PR"). "Mergear" PROPÕE o merge nesta sessão, pelo mesmo endpoint da aba PRs, e o cartão de aprovação aparece na linha da PR com a ação devolvida pela api (RN-822); o merge segue manual (RN-418). Cada fila tem título e contagem próprios, nunca somados (RN-467), e as leituras são as que a aba PRs e o backlog já fazem (mesmas queryKeys, backlog a 15 s), sem poll novo apertado (RN-579).
  • Onde: apps/web/src/lib/prontas-e-bloqueadas.ts:30 (prsProntasParaMerge), apps/web/src/components/PendenciasDeOutrasSessoes.tsx:112 (prontas)
  • Teste: apps/web/src/components/PendenciasDeOutrasSessoes.test.tsx ("a PR com os gates aprovados aparece, a com gate pendente não, e Mergear mostra o cartão ali")
  • Origem: AT-497 (TP-01 conduzido pelo chat do Criativo)

RN-826 — O chat lista as tarefas bloqueadas e desbloqueia ali​

  • Regra: na mesma área, a fila "Tarefas bloqueadas" lista as tarefas blocked do backlog com título e motivo (blockedReason, ou "sem motivo registrado"), e "Desbloquear" chama o mesmo unblock da Visão geral (RN-794) com a sessão aberta. O retorno é dito na linha da tarefa: sucesso em texto, e a recusa com a frase da api. Papel abaixo de developer vê a fila com o botão inerte e o motivo em texto.
  • Onde: apps/web/src/lib/prontas-e-bloqueadas.ts:53 (tarefasBloqueadas), apps/web/src/components/PendenciasDeOutrasSessoes.tsx:180 (desbloquear)
  • Teste: apps/web/src/components/PendenciasDeOutrasSessoes.test.tsx ("CASO DE FALHA: desbloquear com erro diz o erro da api na linha da tarefa")
  • Origem: AT-497

RN-829 — O avatar de quem fala no fio é o do agente, no streaming e depois dele​

  • Regra: a bolha do agente em streaming e a bolha persistida da resposta (agent.response) desenham o MESMO avatar, AvatarDoAgente, pelo id do agente. Antes a bolha em curso mostrava o ícone do agente e a persistida o ícone de modelo, e o avatar trocava na cara da pessoa quando o turno fechava. O modelo que gerou a resposta continua só no chip ao lado do nome. Agente fora do roster (ou sem id) degrada para o ícone genérico, nunca para caixa vazia.
  • Onde: apps/web/src/components/ui/AvatarDoAgente.tsx:22 (AvatarDoAgente), apps/web/src/routes/SessionFio.tsx:273 (BolhaDoStreaming), apps/web/src/routes/session-timeline-montagem.tsx:127 (montarTimeline)
  • Teste: apps/web/src/routes/SessionFio.at503.test.tsx ("a bolha persistida usa o avatar do agente, não o do modelo; o modelo segue no chip", "a bolha em streaming usa o MESMO avatar"; degradação: "sem agente identificado, degrada para o avatar genérico")
  • Origem: AT-503 (TP-01 11ª, nota de UX do chat §2.1)

RN-830 — O composer respeita a composição de acento, e o fio fala com o leitor de tela​

  • Regra: Enter só envia quando não há composição em curso (nativeEvent.isComposing) — no meio de um acento por IME ou tecla morta, ele confirma o caractere. O contêiner do fio é role="log" (com nome), e a faixa de atividade do turno (RN-460) é role="status" com aria-live="polite": o leitor de tela anuncia a resposta nova e o que o agente está fazendo sem interromper quem está lendo. A checagem mora no composer, não na página, que está no teto de linhas (ADR 0176).
  • Onde: apps/web/src/routes/SessionComposer.tsx:81 (ignorarComposicaoIme), apps/web/src/components/TurnActivityStrip.tsx:47 (TurnActivityStrip)
  • Teste: apps/web/src/routes/SessionFio.at503.test.tsx ("Enter sem composição chega ao handler de envio", "a faixa do turno é uma região de status educada"; falha: "Enter no meio da composição de acento NÃO envia")
  • Origem: AT-503 (nota de UX do chat §3)

RN-831 — A mensagem que não foi enviada fica no fio, com "tentar de novo"​

  • Regra: quando o envio da mensagem ao agente falha (exceto a recusa por sessão encerrada, que devolve o texto ao campo — AT-154), a bolha otimista não some: o texto fica no fio com o selo "não enviada" e o botão "Tentar de novo", que reenvia o MESMO texto ao MESMO destinatário pelo caminho de sempre. Há uma só por vez; um envio novo (ou o próprio reenvio) a substitui. O toast com o motivo continua.
  • Onde: apps/web/src/lib/mensagem-nao-enviada.ts:29 (marcarMensagemNaoEnviada), apps/web/src/routes/SessionFio.tsx:52 (SessionFio)
  • Teste: apps/web/src/routes/SessionFio.at503.test.tsx ("mostra o texto com "não enviada" e reenvia ao clicar em tentar de novo", "um envio novo (otimista acesa) substitui a não enviada"); apps/web/src/routes/SessionPage.turno-preso.test.tsx (falha: "CASO DE FALHA: erro no envio desarma o turno e a mensagem fica "não enviada" (RN-831)")
  • Origem: AT-503 (nota de UX do chat §3)

RN-833 — Todo socket Phoenix do web se descarta, e o substituído não age​

  • Regra: todo socket Phoenix do web nasce por criarSocket e morre por descartar(), que faz disconnect() e remove os três ouvintes que o construtor do phoenix@1.8.9 registra em window (pagehide, pageshow, visibilitychange) — disconnect() sozinho os deixava, e o visibilitychange reabria o socket velho. No canal da sessão cada conectar() é uma GERAÇÃO: a anterior é descartada (leave, descartar() e o ping DELA) antes de a nova nascer, e todo callback de geração antiga é no-op — o onClose de um socket substituído não agenda reconexão, não limpa o ping do atual e não marca morto o canal vivo. O canal vivo (RN-579) é CONTAGEM de conexões por sessão, não booleano. A neutralização do auto-reconnect (RN-108) continua obrigatória no tipo de criarSocket.
  • Onde: apps/web/src/lib/socket-phoenix.ts:31 (criarSocket), apps/web/src/lib/session-channel.ts:92 (descartarGeracao), apps/web/src/lib/session-channel.ts:136 (ehAtual), apps/web/src/lib/canal-vivo.ts:70 (marcarCanalDaSessao)
  • Teste: apps/web/src/lib/socket-phoenix.test.ts (Phoenix real: "50× criar + descartar deixa saldo líquido 0 nos três ouvintes de window"; falha: "socket só desconectado (sem descartar) deixa os três ouvintes"), apps/web/src/lib/session-channel.test.ts ("gerações (AT-500, RN-833)"), apps/web/src/lib/canal-vivo.test.ts
  • Origem: AT-500

RN-834 — O nome do projeto no assistente não se chama "title"​

  • Regra: o campo "Nome do projeto" do assistente de criação usa id/name projeto-slug, que não casam com nenhum token de dado de contato (name, nome, title, first, last, company, contact). O project-title da RN-736 tirou o "name", mas "title" é pronome de tratamento (Sr./Sra.) no preenchimento de contato, e o NordPass seguiu abrindo a lista de contatos sobre o campo. O rótulo visível "Nome do projeto" (ligado por htmlFor) e o SEM_AUTOFILL da RN-740 ficam. É HEURÍSTICA, não medida: o NordPass não documenta atributo de ignore, e nenhum foi acrescentado ao SEM_AUTOFILL; a prova é o TP-01.
  • Onde: apps/web/src/routes/NewProjectWizard.tsx:746 (projeto-slug)
  • Teste: apps/web/src/routes/NewProjectWizard.test.tsx ("RN-834: id e name do nome do projeto não parecem dado de contato…")
  • Origem: AT-501 (TP-01 de 10/10)

RN-827 — O painel de contexto conta a sessão inteira, e a janela só dá o detalhe​

  • Regra: os NÚMEROS do painel "Contexto da sessão" — regras de negócio, decisões registradas e artefatos por agente — vêm de um agregado da sessão INTEIRA contado no servidor (GET .../sessions/:sessionId/context-summary, viewer, leitura sem tabela nova), SOMADO à janela de eventos que a tela já carregou pelo molde da RN-568: as contagens são monótonas, então vale o maior dos dois. O DETALHE (o cartão da regra, a decisão, a árvore do backlog) continua sendo o da janela, e o aviso de recorte ("Mostrando N de M") aparece só na seção cujo detalhe é parcial; o agente cujos artefatos ficaram todos antes da janela ganha o grupo com o total. Agregado AUSENTE (carregando ou falhou) é "não li": o painel cai no recorte declarado da RN-180/RN-701, e "Arquivos tocados" vazio diz que não leu, nunca "Nada ainda". O agregado NÃO polla (RN-579, RN-632): é relido quando a janela vê um artefato ou uma ação de commit/PR nova (a chave da query), no foco e na remontagem.
  • Onde: apps/api/src/infrastructure/persistence/drizzle/session-context-summary.repository.ts:22 (resumir), apps/web/src/lib/contexto-da-sessao.ts:15 (useContextoDaSessaoInteira), apps/web/src/lib/contexto-da-sessao.ts:34 (totalDaSessao), apps/web/src/routes/ContextAside.tsx:174 (resumo)
  • Teste: apps/api/test/infrastructure/persistence/drizzle/session-context-summary.repository.spec.ts ("conta a sessão INTEIRA, além de qualquer cauda de 200…"), apps/api/test/application/use-cases/sessions/get-session-context-summary.use-case.spec.ts (falha: "sessão de outro projeto é 404 e não lê nada"), apps/web/src/routes/ContextAside.sessao-inteira.test.tsx ("com o agregado maior que a janela, mostra o total da sessão…"; falha: "sem o agregado, os arquivos dizem "não li"…")
  • Origem: AT-498 (TP-01 de 10/10: sessão de 301 eventos mostrou 0 regras sobre 11 e 2 decisões sobre 6)

RN-828 — "Arquivos tocados" inclui a sessão de execução ligada, com teto e total real​

  • Regra: a lista de arquivos tocados do agregado (RN-827) junta os git_commit/git_push da sessão pedida e da sessão de EXECUÇÃO ligada a ela — a que o plano do Dev Lead aprovado NESTA sessão ativou (executionResult.sessaoDeExecucao de propose_execution_plan executado), do mesmo projeto. Sessão sem essa ligação não entra. A lista vem do mais recente para o mais antigo, cortada em 100 (TETO_DE_ARQUIVOS_TOCADOS), e o total diz o número real; a tela declara o recorte quando ele corta.
  • Onde: apps/api/src/infrastructure/persistence/drizzle/session-context-summary.repository.ts:31 (ligadas), apps/web/src/routes/ContextAside.tsx:366 (filesTouched)
  • Teste: apps/api/test/infrastructure/persistence/drizzle/session-context-summary.repository.spec.ts ("…e soma os arquivos da execução ligada"; falha: "corta a lista de arquivos no teto e devolve o total real; não lê sessão sem ligação")
  • Origem: AT-498

RN-842 — O rótulo do nome do projeto não diz "Nome"​

  • Regra: o rótulo visível do campo de nome do assistente de criação é "Projeto" (en: "Project"), não "Nome do projeto"/"Project name": com o id/name projeto-slug da RN-834 o NordPass seguiu abrindo "Contact info" sobre o campo (TP-01, 12ª), e o texto do <label> é o sinal que sobrava. O rótulo segue visível e ligado por htmlFor, o campo segue fora de <form> e com o SEM_AUTOFILL da RN-740. É HEURÍSTICA: a busca (central de ajuda do NordPass, loja da extensão, fóruns) não achou atributo de ignore documentado pelo NordPass (nada como data-np-ignore), então nenhum foi acrescentado ao SEM_AUTOFILL. Já tentados antes: tirar autoFocus (RN-736), aria-autocomplete/data-protonpass-ignore (RN-740), id/name sem token de contato (RN-834). A prova é o TP-01.
  • Onde: apps/web/src/routes/NewProjectWizard.tsx:749 (projeto-slug), apps/web/src/locales/pt-BR/newProject.json (nameLabel)
  • Teste: apps/web/src/routes/NewProjectWizard.test.tsx ("RN-842: o rótulo visível do campo não traz "nome"…")
  • Origem: AT-511 (TP-01 de 11/10)

RN-835 — O "Desbloquear" da Visão geral não se move e diz o que aconteceu​

  • Regra: extensão da RN-816 e da RN-794. Na seção de execução da Visão geral, a lista de tarefas bloqueadas vem PRIMEIRO, antes do "Religar execução" (que depende da leitura da execução vigente) e do grid dos dev agents (que depende dos eventos) — blocos que chegavam depois do primeiro paint e empurravam o botão sob o cursor. O desbloqueio tem retorno POR TAREFA: em voo o botão fica inerte e diz "Desbloqueando…" (um clique, uma chamada), o sucesso diz "Tarefa desbloqueada" até o backlog relido tirar a linha, e a recusa mostra a frase da api na própria linha. Sem sessão lida, o botão fica inerte com o motivo em texto — antes o clique retornava calado. Declarado: o que fica ACIMA da seção (o time e a árvore de atividade) não tem altura reservada, e jsdom não faz layout — o teste prova a ORDEM, não o deslocamento em pixels; qual das duas causas (salto ou sessão não lida) fez o 1º clique do TP-01 se perder não foi medido no navegador.
  • Onde: apps/web/src/routes/ProjectOverviewTab.tsx:494 (desbloqueio), apps/web/src/routes/ProjectOverviewTab.tsx:623 (blockedTasks)
  • Teste: apps/web/src/routes/ProjectOverviewTab.test.tsx ("RN-835 (AT-502): o desbloqueio chama uma vez, fica em voo e diz quando terminou"; falha: "RN-835: a recusa do desbloqueio mostra a frase da api na linha"), apps/web/src/routes/salto-de-layout.test.ts ("RN-835 (AT-502): Visão geral — as tarefas bloqueadas vêm antes…")
  • Origem: AT-502 (TP-01 de 10/10)

RN-836 — O fio oferece "ir ao fim" quando chega conteúdo para quem subiu​

  • Regra: com a pessoa longe do fim do fio (a guarda dos 120px da RN-173) e o fio tendo ganho mensagem ou cartão de aprovação PENDENTE desde que ela saiu do fim, um botão flutuante no rodapé da área que rola oferece o salto ao fim, contando as mensagens novas ("3 mensagens novas", plural do i18next com _zero/_one/_other). Cartão de aprovação PENDENTE novo não soma ao número: REALÇA o botão e troca o rótulo por "Há uma aprovação esperando" (só o fio marca ação pending, comparada por CONJUNTO de ids ao sair do fim; ação já decidida não realça e a pendente decidida apaga o realce). O botão tem rótulo acessível próprio, some ao chegar ao fim e nunca aparece para quem está no fim — ali a rolagem automática da RN-173 continua mandando, e o botão só observa.
  • Onde: estadoDoIrAoFim (removido na RN-951, apps/web/src/lib/session-rolagem.ts), useIrAoFimDoFio (removido), IrAoFimDoFio (componente removido)
  • Teste: apps/web/src/routes/IrAoFimDoFio.test.tsx ("caminho feliz: fora do fim e com mensagens novas, mostra o contador…", "aprovação nova realça o botão", "pendente decidida apaga o realce", "troca de uma pendente por outra realça"; falha: "no fim, conteúdo novo não mostra botão")
  • Origem: AT-506 (Melhorias de UX do Chat, item 1.3)

RN-837 — Markdown do fio: blocos novos e prefixo de stream sem marcador cru​

  • Regra: o Markdown leve do chat (parser próprio, sem dependência) também reconhece régua (---, ***, ___; logo depois de texto de parágrafo continua texto), citação (linhas > seguidas, miolo reprocessado pelo mesmo parser), cabeçalho ####, lista aninhada de UM nível (item recuado em 2+ espaços vai para o item anterior; mais fundo achata) e checklist (- [ ]/- [x], caixa só visual, desabilitada). No modo stream (MarkdownMessage com emCurso), o texto passa antes por fecharMarcadoresAbertos, que fecha fence, crase e ** pendentes e tira um marcador solto na ponta — nenhum prefixo lança nem mostra marcador cru. O texto final nunca passa por ele. Continua sem dangerouslySetInnerHTML.
  • Onde: apps/web/src/lib/markdown.ts:122 (HR_RE), apps/web/src/lib/markdown.ts:123 (CITACAO_RE), apps/web/src/lib/markdown.ts:124 (TAREFA_RE), apps/web/src/lib/markdown.ts:374 (fecharMarcadoresAbertos), apps/web/src/components/ui/MarkdownMessage.tsx:320 (MarkdownMessage)
  • Teste: apps/web/src/lib/markdown.blocos-e-stream.test.ts ("checklist marca cada item…"; falha: "--- logo depois de texto continua texto"), apps/web/src/components/ui/MarkdownMessage.stream.test.tsx ("nenhum prefixo lança nem mostra marcador cru"; falha: "o texto final (sem emCurso) não é alterado")
  • Origem: AT-507 (Melhorias de UX do Chat, 10/10, item 1.2-a)

RN-838 — O QA de Automação roda com o raciocínio desligado​

  • Regra: estende a RN-783 (decisão do dono de 10/10). O gate qa-automacao passa a ter o mesmo tratamento dos dev agents de execução: com modelo de supportsReasoning, o provider que sabe dizer "desligado" manda reasoning: { enabled: false }, mantendo a folga da RN-782. O QA-estratégia (qa-estrategia) e os demais gates não mudam. Medição que motivou: o raciocínio dos gates subiu de 44 mil para 211 mil tokens por rodada, e o custo deles de US$ 0,15 para US$ 0,55.
  • Onde: apps/api/src/domain/llm/raciocinio-do-agente.ts:39 (raciocinioDesligadoParaOAgente)
  • Teste: apps/api/test/domain/llm/raciocinio-do-agente.spec.ts (QA de Automação desliga; QA-estratégia e outros gates não (RN-838); falha: QA de Automação com modelo sem supportsReasoning não manda nada)
  • Origem: AT-504 (decisão do dono de 10/10)

RN-839 — report_done e report_blocked entram no piso do Jev dos dev agents​

  • Regra: revisa a RN-758/RN-759 (decisão do dono de 10/10). Os dev agents de execução (dev-<modulo>, nunca o dev-lead) deixam de ficar sem piso SÓ para as duas ferramentas de desfecho da task: o recorte do Jev nunca tira report_done nem report_blocked. O resto do passo deles segue recortável, com o aviso de recorte da RN-759. Como todo piso, ele só devolve o que está no catálogo do passo.
  • Onde: apps/api/src/domain/llm/tool-router.ts:351 (pisoDoMenu)
  • Teste: apps/api/test/domain/llm/piso-dos-dev-agents.spec.ts (recorte que deixa só \terminal` para dev-api: report_done e report_blocked continuam; falha: dev-lead e outro agente não ganham as ferramentas de desfecho`)
  • Origem: AT-499 (sequência, decisão do dono de 10/10)

RN-840 — a ferramenta de parecer de cada agente de gate entra no piso do Jev​

  • Regra: estende a RN-758 e a RN-839 aos agentes de GATE. O recorte do Jev nunca tira de cada um a ferramenta de PARECER dele, e só a dele: qa-automacao → emit_qa_verdict, qa-performance-seguranca → emit_perf_seguranca_verdict, qa-estrategia → emit_plano_de_teste, appsec → emit_threat_model. Sem ela o gate rodava a suíte e parava sem veredito, com a delegação falhando por origem modelo e a tarefa pronta bloqueada. Agente fora dos gates não ganha ferramenta de parecer, e o piso só devolve o que está no catálogo do passo.
  • Onde: apps/api/src/domain/llm/tool-router.ts:311 (PISO_DO_MENU)
  • Teste: apps/api/test/domain/llm/tool-router-piso-dos-gates.spec.ts (ids e ferramentas conferidos contra os .ex do engine; recorte que deixa só terminal/read_file termina com o parecer; falha: criativo, dev-api e dev-lead não ganham ferramenta de parecer; o piso não acrescenta ferramenta fora do catálogo)
  • Origem: AT-509 (TP-01, 12ª execução)

RN-841 — A fila "PRs prontas" se relê sem recarregar a página​

  • Regra: extensão da RN-825. A lista de PRs abertas continua sem poll próprio, e é relida em dois momentos: (1) quando a leitura do backlog que o chat já faz a 15 s traz alguma tarefa com status ou gateStatus diferente da leitura anterior (a PR do dev nasce na sessão de EXECUÇÃO, cujo canal o chat não ouve, e os gates aprovando aparecem ali); e (2) quando o canal desta sessão avisa adr.pr_opened, infra.pr_opened, action.pr_open, action.git_merge ou action.failed, pelo invalidador com janela de 2 s (RN-579, RN-632). A primeira leitura e a leitura igual não releem nada: o custo é zero de poll novo. Fica de fora, declarado: PR de infra aberta noutra sessão sem mudar tarefa nem passar por este canal só aparece na próxima leitura disparada por outro motivo.
  • Onde: apps/web/src/lib/prontas-e-bloqueadas.ts:66 (assinaturaDoBacklog), apps/web/src/components/PendenciasDeOutrasSessoes.tsx:91 (assinaturaAnterior), apps/web/src/lib/canal-vivo.ts:134 (alvosDoEvento)
  • Teste: apps/web/src/components/PendenciasDeOutrasSessoes.test.tsx ("RN-841: a tarefa que entra em awaiting_user relê as PRs…" e "CASO DE FALHA RN-841…"), apps/web/src/lib/acoes-da-sessao.test.ts
  • Origem: AT-510

RN-845 — A leitura das ADRs propostas diz se a PR já foi mergeada​

  • Regra: na linha de uma ADR executed, listar_adrs_propostas (RN-772) não afirma mais "PR aberta" pelo status da AÇÃO: cruza o pullRequestId do resultado com o git_merge EXECUTADO do mesmo projeto (a ação pela qual todo merge passa, RN-418) e diz "PR MERGEADA em <data da decisão>" ou "PR aberta (nenhum merge dela registrado no Brabo)". Resultado sem id de PR, ou consulta de merge que falha, vira "estado da PR: NÃO CONSEGUI LER" — nunca "aberta". Contida (ADR 0060): uma consulta a mais, só sobre os ids listados, no mesmo Postgres. Limite declarado: PR fechada sem merge FORA do Brabo (no provider remoto) não é vista, e segue lida como aberta — perguntar ao provider seria HTTP no laço do agente.
  • Onde: apps/engine/lib/engine/harness/tools/listar_adrs_propostas.ex:88 (cruzar_com_merges), :102 (merges_por_pr), :155 (estado_da_pr).
  • Teste: apps/engine/test/engine/harness/tools/listar_adrs_propostas_test.exs ("PR mergeada aparece como mergeada…", "estado da PR ilegível diz que não leu, nunca aberta").
  • Origem: AT-514 (TP-01, 11/10: o Arquiteto disse "aberta como PR" 30 s depois do merge).

RN-849 — O turno de prontidão da arquitetura abre com o estado atual das ADRs​

  • Regra: extensão da RN-845. Quando o usuário confirma que a arquitetura está pronta, o servidor do Arquiteto LÊ o estado das ADRs pelo mesmo código de listar_adrs_propostas (Postgres local, sem HTTP, mesmo teto de 20 linhas) e o põe na mensagem de abertura desse turno, com a instrução de não afirmar estado de ADR diferente. Leitura que falha diz "NÃO CONSEGUI LER" e manda dizer que não sabe — nunca "pendente".
  • Onde: apps/engine/lib/engine/harness/tools/listar_adrs_propostas.ex:50 (contexto_de_prontidao), apps/engine/lib/engine/agents/arquiteto_server.ex:242 (contexto_de_prontidao).
  • Teste: apps/engine/test/engine/harness/tools/listar_adrs_propostas_test.exs ("contexto_de_prontidao/2 (RN-849)").
  • Origem: AT-519 (TP-01: no turno de prontidão o Arquiteto disse "pendente de aprovação" sobre ADR já mergeada, repetindo a leitura do turno anterior).

RN-843 — Um cartão de decisão por ação no chat​

  • Regra: extensão da RN-822. O git_merge PENDENTE que o bloco de pendências desenha na linha da PR ("PRs prontas para merge") é o ÚNICO ApprovalCard dele na tela: o bloco registra os ids que desenha num registro leve do cliente, e o fio da sessão, enquanto a ação estiver pendente e registrada, mostra só uma linha apontando para a fila. Decidida, o fio volta ao cartão (histórico); sem o bloco montado (outra tela, PR fora da fila), o fio mostra o cartão completo. O registro vale só enquanto o cartão está MONTADO: com o bloco recolhido (o Disclosure desmonta o conteúdo), o fio mostra o cartão. Nenhum endpoint nem teto muda.
  • Onde: apps/web/src/lib/acoes-no-bloco.ts:48 (useAcaoEstaNoBloco), apps/web/src/components/PendenciasDeOutrasSessoes.tsx:460 (useRegistrarAcoesNoBloco), apps/web/src/components/CartaoOuReferenciaNoFio.tsx:11 (CartaoOuReferenciaNoFio), apps/web/src/routes/session-timeline-montagem.tsx:1185 (CartaoOuReferenciaNoFio)
  • Teste: apps/web/src/components/CartaoOuReferenciaNoFio.test.tsx, apps/web/src/components/PendenciasDeOutrasSessoes.test.tsx ("a PR com os gates aprovados aparece…")
  • Origem: AT-512

RN-844 — O painel de contexto lista as regras, as decisões e os arquivos das PRs da sessão inteira​

  • Regra: extensão da RN-827 e da RN-828. O agregado GET .../sessions/:sessionId/context-summary devolve, além dos números, os ITENS: as regras de negócio (itensDeRegras: título, descrição e quantas origens cita) e as decisões registradas (itensDeDecisoes: escolha e contexto) da sessão, as mais recentes primeiro, até 50 de cada (TETO_DE_ITENS_DO_CONTEXTO); o total segue em regras/decisoes. Com o agregado, o painel lista esses itens em vez da janela de 200 eventos — a regra que ficou antes da janela continua aparecendo — e o recorte diz "Mostrando as N mais recentes de M". "Arquivos tocados" passa a incluir os arquivos das PRs (pr_open/open_adr_pr/open_infra_pr executadas, com pullRequestId) da sessão e da sessão de execução ligada, lidos do MESMO diff da aba PRs (ReadProjectCodeUseCase.pullRequestDiff), antes dos arquivos de payload de commit, no teto de 100 com o total real. Leitura contida (ADR 0060): sem parâmetro livre, no máximo 20 diffs por chamada (TETO_DE_PRS_LIDAS); PR além do teto ou cujo diff falhou entra em prsNaoLidas, e a tela diz quantas ficaram de fora. Com o agregado e nada a listar, o vazio diz o que não houve (nenhum arquivo em PR nem commit desta sessão ou da execução ligada), nunca "Nada ainda". O caso de uso mora no módulo de git porque é ele que lê o repositório; sessão de outro projeto continua 404 sem ler PR nenhuma.
  • Onde: apps/api/src/infrastructure/persistence/drizzle/session-context-summary.repository.ts:140 (itensDeRegras), apps/api/src/infrastructure/persistence/drizzle/session-context-summary.repository.ts:184 (prsDasSessoes), apps/api/src/application/use-cases/git/resumir-contexto-da-sessao.use-case.ts:37 (prs), apps/web/src/routes/ContextAside.tsx:197 (businessRules), apps/web/src/routes/ContextAside.tsx:222 (decisoes)
  • Teste: apps/api/test/infrastructure/persistence/drizzle/session-context-summary.repository.spec.ts ("RN-844: devolve os ITENS de regra e decisão fora de qualquer cauda, com teto de 50"; "RN-844: lista as PRs executadas da sessão e da execução ligada…"), apps/api/test/application/use-cases/git/resumir-contexto-da-sessao.use-case.spec.ts ("lê os arquivos das PRs das sessões lidas…"; falhas: "corta no teto de arquivos, conta PR fora do teto e diff que falhou em prsNaoLidas" e "sessão de outro projeto é 404 e não lê PR nenhuma"), apps/web/src/routes/ContextAside.sessao-inteira.test.tsx ("…lista os itens do servidor (RN-844)"; "agregado sem arquivo diz O QUE não houve…")
  • Origem: AT-513 (TP-01 de 11/10: 9 regras sem lista, "mostrando 1 de 4" decisões e "Arquivos tocados: Nada ainda" com 12 PRs mergeadas)

RN-847 — A PR mergeada continua dizendo os arquivos que mudou​

  • Regra: extensão da RN-844. No provider git LOCAL, o diff de uma PR era target...source também depois do merge — e aí a merge base é a própria ponta da origem, então a PR mergeada devolvia ZERO arquivos (medido em teste: [] para fast-forward e para commit de merge), e o painel "Contexto da sessão" dizia "Nenhum arquivo nas PRs" com 14 PRs mergeadas, sem nada em prsNaoLidas (o diff não falhava, vinha vazio). Agora o merge grava no store da PR a merge base e a ponta da origem do instante do merge (mergedBaseSha/mergedHeadSha), e o diff da PR mergeada é mergedBaseSha...mergedHeadSha. PR mergeada antes desses campos cai no commit de merge do alvo (primeiro pai) cujo segundo pai é a ponta da origem; a mergeada por fast-forward sem os campos não tem como ser reconstruída e segue vazia (lacuna declarada). GitHub e GitLab já devolvem o diff da PR mergeada pela própria plataforma. Na tela, o resumo do contexto é relido nos MESMOS gatilhos que releem a lista de PRs (RN-841): o alvo prs do canal (com a janela de 2 s dele, sem busca a mais) e a mudança da assinatura do backlog, por prefixo ['session-context-summary', projectId].
  • Onde: apps/api/src/infrastructure/git/local-git-provider.ts:374 (mergedBaseSha), apps/api/src/infrastructure/git/local-git-provider.ts:862 (rangeDoDiff), apps/web/src/lib/canal-vivo.ts:221 (prs), apps/web/src/components/PendenciasDeOutrasSessoes.tsx:101 (invalidateQueries)
  • Teste: apps/api/test/infrastructure/git/local-git-provider.contract.spec.ts ("PR mergeada por fast-forward devolve os arquivos que mudou"; "PR mergeada por commit de merge devolve só os arquivos dela"; "PR mergeada antes de os SHAs serem gravados acha o commit de merge"), apps/web/src/lib/contexto-da-sessao.test.tsx ("o merge que chega pelo canal relê o resumo"; "CASO DE FALHA: evento que não mexe em PR não relê o resumo"), apps/web/src/components/PendenciasDeOutrasSessoes.test.tsx ("RN-841: a tarefa que entra em awaiting_user relê as PRs…"). A falha de leitura somando em prsNaoLidas segue coberta por apps/api/test/application/use-cases/git/resumir-contexto-da-sessao.use-case.spec.ts ("corta no teto de arquivos, conta PR fora do teto e diff que falhou em prsNaoLidas").
  • Origem: AT-518 (TP-01, 13ª: "Nenhum arquivo nas PRs nem nos commits" com 14 PRs mergeadas, e a aba sem reler o resumo)

RN-848 — O efeito de ação executada dentro de um pedido interno avisa o canal da sessão​

  • Regra: extensão da RN-579 (AT-157). A api não avisa o canal da escrita que veio do engine (/internal/*), porque a fachada dele avisa o evento que ELE pediu para gravar. O que a api grava por conta própria como EFEITO de uma ação auto-aprovada executada dentro desse pedido (infra.pr_opened, adr.pr_opened, action.*) nenhuma fachada avisa, e passa a ser avisado: ProposeActionUseCase executa a ação sob a marca runComoEfeitoDeAcao, e o notifier só fica calado com origem engine SEM essa marca. O evento que o engine pediu (inclusive o proposed_action.created) continua sem aviso da api, para não sair em dobro.
  • Onde: apps/api/src/application/efeito-de-acao.ts:18 (ehEfeitoDeAcao), apps/api/src/application/use-cases/actions/propose-action.use-case.ts:319 (runComoEfeitoDeAcao), apps/api/src/infrastructure/http-clients/session-channel-notifier.ts:73 (ehEfeitoDeAcao)
  • Teste: apps/api/test/application/use-cases/sessions/aviso-ao-canal-da-sessao.spec.ts ("AT-516: efeito de ação executada dentro de /internal avisa o canal, uma vez"; falha/regressão: "escrita vinda do engine (/internal) não é avisada de novo pela api")
  • Origem: AT-516 (a PR de infra auto-aprovada só entrou em "PRs prontas" do chat ~5 min depois, pelo poll de fallback)

RN-853 — O dev expõe OpenAPI e Swagger quando a tarefa toca rotas​

  • Regra: parte do ADR 0213. Quando a tarefa toca ponto de entrada ou rotas (a MESMA régua do recorte do QA, toca_ponto_de_entrada?, nunca uma cópia), o kickoff do dev ganha a cláusula: módulo que expõe HTTP serve /openapi.json (OpenAPI 3) e /docs (UI Swagger); sem rede ou sem pacote, /docs é página HTML que carrega o swagger-ui de CDN; módulo web serve a página inicial; a URL entra na seção "Rodar na sua máquina" do README. Tarefa que não toca rotas não paga a cláusula (a mensagem é fixada e volta a cada iteração).
  • Onde: apps/engine/lib/engine/dev/dev_agent_server.ex:636 (clausula_de_openapi), apps/engine/lib/engine/gates/recorte_da_tarefa.ex:113 (toca_ponto_de_entrada?)
  • Teste: apps/engine/test/engine/dev/report_done_instrucao_test.exs ("RN-853: tarefa que toca rotas pede /openapi.json e /docs"; falha: "RN-853 falha: tarefa sem rota nem entrypoint não paga a cláusula")
  • Origem: AT-517

RN-854 — O QA prova /openapi.json e /docs dentro do container​

  • Regra: parte do ADR 0213. Na seção de ponto de entrada do recorte (RN-813), o QA sobe o app pelo comando do README em background DENTRO do container, bate em localhost:<porta interna>/openapi.json e /docs (curl, wget ou node -e fetch) e encerra o grupo de processos (RN-817). Falha vai a itens; o docker compose up real fica no resumo como "provado só na máquina do usuário".
  • Onde: apps/engine/lib/engine/gates/recorte_da_tarefa.ex:121 (secao_do_ponto_de_entrada)
  • Teste: apps/engine/test/engine/gates/recorte_da_tarefa_test.exs ("tarefa que toca rotas pede /openapi.json e /docs, compose em resumo"; falha: "falha: tarefa sem rota não recebe a seção")
  • Origem: AT-517

RN-852 — A PR de infra declara como rodar o entregável, e isso vira artefato​

  • Regra: open_infra_pr EXIGE run = {command, urls, envExample} (ADR 0213): command texto não vazio até 300 caracteres; urls de 1 a 5 itens {label (até 60), url, kind}, com url começando com http://localhost: ou http://127.0.0.1: e kind ∈ api_docs | web; envExample booleano. A api recusa na PROPOSTA com 400 run_invalido (texto que volta ao Infra Lead) e revalida na EXECUÇÃO, que falha nomeada sem commitar. Com a PR aberta (ou corrigida), a api emite artifact.run_instructions {command, urls, envExample, prUrl, version} — versionado, sem tabela, o de maior version é o vigente (o desenho de artifact.project_image), e projetado em docs/ como os outros versionados. GET /projects/:projectId/run-instructions (viewer) devolve o vigente (+ version, eventId, declaredAt) ou null. É DECLARADO pela Infra, nunca verificado na máquina de quem lê (RN-468). E o contexto da Infra (/internal/.../infra-context) passa a levar projectImage, a decisão vigente de imagem, rede e recursos, null sem decisão.
  • Onde: apps/api/src/domain/infra/run-instructions.ts:77 (recusaDoRun), apps/api/src/application/use-cases/actions/propose-action.use-case.ts:155 (recusaDoRun), apps/api/src/application/use-cases/actions/execute-infra-pr.use-case.ts:111 (recusaDoRun), :328 (EVENTO_INSTRUCOES_DE_EXECUCAO), apps/api/src/application/use-cases/execution/get-run-instructions.use-case.ts:26 (execute), apps/api/src/application/use-cases/execution/get-infra-context.use-case.ts:89 (projectImage)
  • Teste: apps/api/test/application/use-cases/actions/propose-action.use-case.spec.ts ("sem run: 400 run_invalido, sem proposta"; "URL fora de localhost: 400 nomeando o campo"; "run válido: a proposta nasce"), apps/api/test/application/use-cases/actions/execute-infra-pr.use-case.spec.ts ("PR aberta emite artifact.run_instructions com prUrl e versão seguinte"; falha: "run inválido na execução falha sem commitar nem emitir"), apps/api/test/application/use-cases/execution/get-run-instructions.use-case.spec.ts ("devolve o vigente — o de maior version"; "null sem artefato, e o de outro projeto não vaza"), apps/api/test/application/use-cases/execution/get-infra-context.use-case.spec.ts ("RN-852: projectImage é a decisão vigente…"; "…null sem decisão de imagem")
  • Origem: AT-517 (o entregável do TP-01 não dizia como rodar na máquina do usuário)

RN-855 — A Visão geral mostra como rodar o entregável, declarado e não verificado​

  • Regra: a Visão geral ganha o cartão "Rodar na sua máquina", logo abaixo do de primeiros passos, com o que a Infra declarou na PR dela (artifact.run_instructions, RN-852): o comando num bloco de código com botão "Copiar", as URLs como links que abrem em aba nova (target="_blank", rel="noopener noreferrer") — só http://localhost:<porta> e http://127.0.0.1:<porta>; outra URL não vira link, mesmo a api já a recusando —, a lembrança de cp .env.example .env quando envExample e a nota "Declarado pela Infra na PR, não verificado na sua máquina" (RN-468: nunca verde, nunca "rodando"). Sem declaração (null) o cartão não aparece, como o de primeiros passos some quando não há o que dizer. A leitura é de configuração (FRESCOR_DA_CONFIGURACAO_MS, sem poll) e o aviso infra.pr_opened do canal da sessão a relê.
  • Onde: apps/web/src/components/RodarNaSuaMaquina.tsx:32 (RodarNaSuaMaquina), :20 (urlLocalSegura), :13 (releSeInfraAbriuPr), apps/web/src/routes/ProjectOverviewTab.tsx:210 (releSeInfraAbriuPr), apps/web/src/lib/api-client.ts:850 (getRunInstructions)
  • Teste: apps/web/src/components/RodarNaSuaMaquina.test.tsx ("mostra comando, copiar, link local e a nota de não verificado"; "sem declaração (null) o cartão não aparece"; "só localhost/127.0.0.1 com porta vira link"; "infra.pr_opened relê o cartão; outro evento não")
  • Origem: AT-517 (ADR 0213)

RN-856 — Desbloquear não encerra a execução, e a tarefa em curso órfã tem volta​

  • Regra: três metades de um defeito só (o TP-01 mediu 1 s entre o "Desbloquear" e o claim do dev, e o heartbeat encerrou a sessão nele). (1) O sexto sinal da RN-064 (RN-776) conta também, na sessão de execução vigente, a tarefa todo de história ready cujo módulo é de um dev agent DESTA execução (ator de evento dev.* na sessão) e a tarefa in_progress atribuída a um deles — sem teto, como os outros. (2) O claim retoma, ANTES da fila todo, a tarefa in_progress que já é do agente (mesmo assigned_to, fora de gate, não bloqueada); a branch dela, se existir e não estiver mergeada, é o ponto de partida do worktree (a retomada do WorktreeManager). (3) Encerrar a sessão de execução (RN-763) bloqueia também a tarefa in_progress de dono dev-* que nenhuma linha de dev_agent_states segura, com o mesmo motivo e origem politica; a lista só vem para sessão de EXECUÇÃO, então encerrar uma consultiva do mesmo projeto não bloqueia nada.
  • Onde: apps/api/src/infrastructure/persistence/drizzle/backlog.repository.ts:252 (findPendenteDaExecucao), :403 (claimNext), :221 (listEmCursoDeDev), apps/api/src/application/use-cases/sessions/get-session-pending-work.use-case.ts:49 (em_curso_de_dev), apps/engine/lib/engine/dev/encerramento_da_sessao.ex:133 (bloquear_orfas)
  • Teste: apps/api/test/application/use-cases/sessions/get-session-pending-work.use-case.spec.ts ("tarefa recém-desbloqueada (todo) do módulo de um dev da execução segura a sessão"; "tarefa todo de módulo SEM dev na execução não segura"; "tarefa in_progress atribuída a dev da execução segura a sessão"; "listEmCursoDeDev lista a in_progress de dev só para sessão de EXECUÇÃO"), apps/api/test/application/use-cases/execution/claim-next-task.use-case.spec.ts ("retoma a in_progress que já é DELE antes da fila todo"; "não retoma a in_progress dele que está em gate"), apps/engine/test/engine/dev/encerramento_da_sessao_test.exs ("bloqueia a task em curso ÓRFÃ…"; "sem nenhuma linha, a órfã também é bloqueada…")
  • Origem: AT-522 (TP-01, 14ª: sessão 538a0ba5 encerrada 1 s depois do desbloqueio, tarefa in_progress presa em dev-api sem processo)

RN-857 — O passo de correção do dev agent tem escrita e terminal no recorte do Jev​

  • Regra: quando o recorte do Jev (RN-625) decide o menu de um dev agent de execução (dev-<modulo>, nunca o dev-lead) e a ÚLTIMA mensagem user do passo é o parecer de correção que o engine monta — gate pedindo correção ("pediu correção nesta PR") ou merge recusado por conflito (RN-715) —, o menu ganha write_file e terminal além do piso de desfecho (RN-839). O critério é o texto que a api já recebe, sem campo novo; o piso fixo de todo passo não alarga (RN-759), outro agente não ganha, e o piso só acrescenta o que está no catálogo do passo (P3).
  • Onde: apps/api/src/domain/llm/tool-router.ts:368 (PISO_DA_CORRECAO_DO_DEV), :379 (ehPassoDeCorrecao), :392 (menuComPiso), apps/api/src/application/use-cases/llm/decidir-ferramenta-do-passo.use-case.ts:248 (menuComPiso)
  • Teste: apps/api/test/domain/llm/tool-router-correcao-do-dev.spec.ts ("dev agent em correção recebe write_file e terminal"; "passo comum de leitura do dev não alarga"; "outro agente com a mesma mensagem não ganha"; "as marcas existem no texto que o engine monta")
  • Origem: AT-523

RN-858 — A pergunta em aberto de agente é registro durável, e só o autor da sessão a responde​

  • Regra: a dúvida que um agente deixa para o usuário vira o evento artifact.open_question (append-only, no log da sessão onde nasce) com {questionId (uuid), pergunta (até 500), sugestao (até 300, opcional), afeta: {regras, historias, tarefas}, autor, sessionId} — o funil de eventos recusa com 400 o payload fora desse formato, venha do engine ou da api. Ela fecha com open_question.answered {questionId, resposta, aceitouSugestao, decisionEventId, respondidaPor}; o STATUS sai do log (aberta = sem answered), nunca de tabela. GET /projects/:projectId/open-questions?status=open|answered|all (viewer, padrão open) lista as mais recentes primeiro, com teto de 50 e o total (RN-180), e diz em autorDaSessao quem pode responder. POST .../open-questions/:questionId/answer com {resposta} ou {aceitarSugestao: true} só é aceito do AUTOR da sessão de origem (403 so_o_autor_da_sessao); já respondida é 409 pergunta_ja_respondida, pergunta de outro projeto é 404 e resposta vazia (ou sugestão que não existe) é 400. Responder grava, numa transação, um artifact.decision_record com o usuário como ator (context = a pergunta, choice = a resposta, consequences = o que afeta, mais questionId e afeta) e o open_question.answered, avisa o canal da sessão (pelo funil, RN-579) e pede ao engine para retomar o gate de QA ESTACIONADO de cada tarefa afetada (caminho da RN-724) — melhor esforço: gate que não estava estacionado ou engine fora do ar não desfaz a resposta. ADR 0214.
  • Onde: apps/api/src/domain/open-questions/pergunta-em-aberto.ts:44 (validarPerguntaEmAberto), :112 (respostaEscolhida), apps/api/src/application/use-cases/sessions/append-session-event.use-case.ts:101 (EVENTO_PERGUNTA_EM_ABERTO), apps/api/src/application/use-cases/backlog/perguntas-em-aberto.use-case.ts:72 (listar), :106 (responder), :189 (resumeParkedGate), apps/api/src/interfaces/http/backlog/open-questions.controller.ts:51 (list), :81 (answer)
  • Teste: apps/api/test/application/use-cases/backlog/perguntas-em-aberto.use-case.spec.ts ("o autor responde: decision_record (ator usuário, com questionId) e answered, e o gate é religado"; "aceitar a sugestão grava a sugestão como escolha"; falhas: "quem não é o autor da sessão recebe 403 so_o_autor_da_sessao, sem escrever"; "já respondida é 409 pergunta_ja_respondida"; "pergunta de outro projeto é 404"; "o funil de eventos recusa com 400 antes de qualquer escrita")
  • Origem: AT-521 (TP-01: quatro perguntas do Criativo sumiram do campo de visão e o fluxo seguiu sem respostas)

RN-859 — A decisão do usuário chega a quem trabalha, e destrava a correção da história ready​

  • Regra: GET /internal/sessions/:sessionId/dev-context devolve decisoes: os artifact.decision_record do projeto cujo afeta toca a tarefa, a história dela ou uma regra que a história cita — mais recentes primeiro, no máximo 10, com a pergunta e a decisão cortadas em 1 500 caracteres cada ({id, questionId, pergunta, decisao, decididaEm}). Decisão sem afeta não entra. Elas vencem RF/DoR quando conflitam (o kickoff do dev e o recorte do QA as mostram — engine). E a régua de correção de história (RN-727, recusaDeCorrecaoDeHistoria) é revista (ADR 0214, sobre o ADR 0212): história ready também é corrigível quando o pedido cita um questionId que o usuário JÁ respondeu — pergunta aberta ou inexistente não destrava nada, e a trava de tarefa in_progress/in_review continua valendo. As duas portas do PO aceitam o questionId: update_story (título/descrição, o evento o leva) e complete_story (RF/DoD/DoR; a história segue ready, sem nova promoção, e backlog.story_completed leva o questionId).
  • Onde: apps/api/src/application/use-cases/execution/get-dev-task-context.use-case.ts:122 (decisoesDaTarefa), :374 (decisoesDoContexto), apps/api/src/domain/backlog/correcao-de-historia.ts:33 (recusaDeCorrecaoDeHistoria), apps/api/src/application/use-cases/backlog/corrigir-historia.use-case.ts:126 (respostaDoUsuario), apps/api/src/application/use-cases/backlog/complete-story.use-case.ts:82 (corrigindoReady)
  • Teste: apps/api/test/application/use-cases/execution/get-dev-task-context.use-case.spec.ts ("devolve só as que afetam a tarefa, a história ou uma regra dela, mais recentes primeiro"; "decisão sem afeta não entra; teto de 10 e texto cortado em 1 500"), apps/api/test/application/use-cases/backlog/corrigir-historia.use-case.spec.ts ("história ready citando pergunta RESPONDIDA é corrigida, e o evento a cita"; falhas: "história ready citando pergunta AINDA ABERTA segue recusada"; "história ready com tarefa em revisão segue recusada mesmo com a resposta"), apps/api/test/application/use-cases/backlog/complete-story.use-case.spec.ts ("história ready com pergunta respondida tem RF/DoR corrigidos e segue ready"; "história ready com pergunta ainda aberta é recusada")
  • Origem: AT-521 (o QA reprovou três vezes lendo a DoR de uma história ready que ninguém podia corrigir, depois de o usuário já ter decidido)

RN-860 — O gate de QA espera o usuário sem gastar o ciclo de correção​

  • Regra: o parecer de gate (POST /internal/sessions/:sessionId/gates/verdict) aceita o veredito needs_user_decision, só do gate qa, com pergunta (até 500) e sugestao (até 300) ou um openQuestionId que já existe — sem nenhum dos dois é 400. A máquina de estados mantém o MESMO gate e NÃO incrementa o contador de correção, nem no teto, então o ciclo não chega ao bloqueio politica da RN-762. Sem openQuestionId, a api grava o artifact.open_question (ator e autor qa-agent, na sessão de execução, afetando a tarefa e a história dela), o pr.gate_changed sai com esperandoUsuario: true e o openQuestionId (é por ele que a tela mostra a tarefa como "esperando você"), e a resposta devolve nextAction: "await_user" com o openQuestionId — o engine estaciona o ciclo (ADR 0207) e a resposta à pergunta o religa (RN-858). Nenhuma linha de outbox nasce nesse desfecho. ADR 0214.
  • Onde: apps/api/src/domain/execution/pr-gate-state-machine.ts:63 (nextGateStatus), apps/api/src/application/use-cases/execution/record-gate-verdict.use-case.ts:135 (esperaUsuario), :171 (nextAction)
  • Teste: apps/api/test/domain/execution/pr-gate-state-machine.spec.ts ("needs_user_decision mantém o gate e NÃO incrementa o contador, nem no teto"), apps/api/test/application/use-cases/execution/record-gate-verdict.use-case.spec.ts ("needs_user_decision no teto: grava a pergunta, não conta correção, não bloqueia"; falha: "needs_user_decision sem pergunta nem openQuestionId é recusado")
  • Origem: AT-521 (o QA de Automação reprovou quatro vezes "não confirmado pelo usuário" até o ciclo esgotar)

RN-862 — A pergunta em aberto do lado do engine: ferramenta, veredito e decisão no contexto​

  • Regra: o Criativo, o PO, o Arquiteto e o Dev Lead declaram toda dúvida deixada para o usuário com declare_open_question (pergunta até 500, sugestao até 300 opcional, afeta: {regras, historias, tarefas}), que grava artifact.open_question com questionId (uuid), autor e sessionId; payload inválido é recusa nomeada e não grava. A instrução é UMA frase central, no texto do fluxo dos quatro (RN-711), nunca por servidor. No gate de QA, requisito "não confirmado pelo usuário" (ou contradito por decisão vigente) é needs_user_decision, nunca changes_requested: emit_qa_verdict o aceita só com pergunta ou openQuestionId, o QA Lead o consolida acima de approved, o veredito vai à api com a pergunta e o ciclo fica ESTACIONADO (ADR 0207, gate.rescue_parked, sem resgate automático) até a api pedir a retomada pela rota da RN-724. As decisoes do dev-context entram no kickoff do dev e no recorte do QA como "Decisões do usuário — vencem RF/DoR quando conflitam" (pergunta → decisao); vazio não escreve nada. A pergunta gravada conta no fecho do turno (RN-731), e o PO passa question_id em update_story/complete_story quando corrige a história a partir de uma resposta (ADR 0214).
  • Onde: apps/engine/lib/engine/harness/tools/declare_open_question.ex:54 (run), apps/engine/lib/engine/harness/fluxo_do_time.ex:113 (frase_da_pergunta), apps/engine/lib/engine/gates/tools/emit_qa_verdict.ex:77 (run), apps/engine/lib/engine/gates/qa_lead.ex:89 (consolidar_decisao), apps/engine/lib/engine/gates/qa_lead_server.ex:573 (estacionar_por_decisao), apps/engine/lib/engine/dev/context_builder.ex:121 (secao_de_decisoes)
  • Teste: apps/engine/test/engine/harness/perguntas_em_aberto_test.exs ("válida grava artifact.open_question…"; "inválida é recusa nomeada…"; "consolidação: needs_user_decision vence approved"; "o recorte diz a régua…"; "ContextBuilder: a história leva as decisões…"), apps/engine/test/engine/gates/qa_lead_server_test.exs ("grava a pergunta na api, estaciona o ciclo e só o gesto humano o retoma")
  • Origem: AT-521, ADR 0214

RN-863 — api_docs é sempre /docs, toda rota entra no OpenAPI, e o QA reprova a que falta​

  • Regra: módulo HTTP declara em run.urls SEMPRE /docs (a UI Swagger, kind: "api_docs") mesmo antes de existir, e módulo web a página inicial — nunca uma rota de negócio; o kickoff da Infra diz isso no passo do README (estende a RN-851). A validação do run recusa, nomeada e com a MESMA régua nos dois lados, api_docs cuja URL não termina em /docs (estende a RN-852). A cláusula do dev diz que TODA rota HTTP criada ou mudada na tarefa entra no /openapi.json — path com método, corpo e respostas (estende a RN-853). No recorte do QA, cada rota da seção "Rotas HTTP" do README precisa estar em paths do /openapi.json servido; rota faltando é item que reprova, nunca observação (estende a RN-854).
  • Onde: apps/engine/lib/engine/infra/instrucoes_de_execucao.ex:60 (url), apps/api/src/domain/infra/run-instructions.ts:77 (recusaDoRun), apps/engine/lib/engine/infra/infra_lead_server.ex:1416 (build_kickoff), apps/engine/lib/engine/dev/dev_agent_server.ex:636 (clausula_de_openapi), apps/engine/lib/engine/gates/recorte_da_tarefa.ex:20 (texto)
  • Teste: apps/engine/test/engine/infra/infra_lead_server_test.exs ("run api_docs em rota de negócio é recusado com motivo, sem propor"), apps/api/test/application/use-cases/execution/get-run-instructions.use-case.spec.ts ("recusaDoRun (RN-852)"), apps/engine/test/engine/dev/report_done_instrucao_test.exs ("RN-853: …"), apps/engine/test/engine/gates/recorte_da_tarefa_test.exs ("RN-854: …")
  • Origem: AT-524, ADR 0213

RN-861 — As perguntas em aberto são fila própria no chat, e o aviso de prontidão não bloqueia​

  • Regra: o bloco de pendências do chat ganha a fila "Perguntas em aberto", com título e contagem PRÓPRIOS (o total da api, nunca somado às outras filas — RN-467) e recorte declarado quando a lista vem com teto (RN-180). Cada cartão diz a pergunta, o agente que perguntou, o que ela afeta, a sugestão com "Aceitar sugestão" e um campo curto com "Responder"; a tarefa que o gate de QA estacionou pela pergunta (RN-860) aparece no próprio cartão como "esperando sua resposta". Só o autor da sessão de origem (autorDaSessao) tem os controles; os outros veem o cartão com o controle inerte e o motivo em texto (ADR 0064). A recusa mostra a frase da api; o sucesso relê a fila e a pergunta sai. A leitura NÃO polla: o canal relê a fila em artifact.open_question, open_question.answered e pr.gate_changed (alvo perguntas, janela de 2 s). Junto de "Estou pronto" e de "Confirmar arquitetura pronta", o composer avisa "N perguntas em aberto" com atalho até a fila, lendo a MESMA query — só avisa, os botões seguem habilitados (ADR 0214).
  • Onde: apps/web/src/components/PerguntasEmAberto.tsx:47 (PerguntasEmAberto), :117 (eAutor), :282 (AvisoDePerguntasEmAberto), apps/web/src/lib/canal-vivo.ts:135 (alvosDoEvento)
  • Teste: apps/web/src/components/PerguntasEmAberto.test.tsx ("fila própria com contagem própria; o autor responde e aceita a sugestão", "o aviso do composer diz N e não bloqueia nada; some sem perguntas"; falha: "a recusa 409 mostra a frase da api", "quem não é o autor da sessão vê o cartão com o controle inerte e o motivo em texto")
  • Origem: AT-521

RN-864 — Falha passageira de rede volta sozinha em até 3 tentativas, e o dev pausa​

  • Regra: a task bloqueada pelo dev agent com origem infra por falha PASSAGEIRA de rede/provedor — lista de permitidos explícita: DNS (EAI_AGAIN, ENOTFOUND, getaddrinfo), ECONNRESET, ETIMEDOUT, aborted, HTTP 5xx/429 do provedor (códigos rate_limit/upstream, connection/timeout) — é RETOMADA sozinha até 3 vezes, depois de 1, 3 e 10 min (decisão do dono), por um job Oban que chama o MESMO desbloqueio do humano (UnblockTaskUseCase, pela rota interna .../tasks/:taskId/unblock, ator system, no-op se a task já não está bloqueada). O motivo do bloqueio diz qual retomada foi agendada; depois da 3ª retomada a falha seguinte fica bloqueada como antes, e o motivo diz que as 3 já foram tentadas. E o agente PAUSA 1 min em vez de pegar a próxima task (o wake é ignorado durante a pausa). Crédito esgotado (402, RN-726/730) e erro de código NÃO entram: bloqueiam na hora. O contador de retomadas é só em memória: reiniciar o engine o zera. Vale também para a correção pós-gate (AT-621): o 503 do provider numa correção segue o mesmo caminho do laço principal (crédito RN-726 + retomada), com o motivo marcado "(correção)".
  • Onde: apps/engine/lib/engine/dev/dev_agent_server.ex:822 (handle_correction_outcome), apps/engine/lib/engine/agents/falha_de_turno.ex:140 (passageira?), apps/engine/lib/engine/dev/dev_agent_server.ex:978 (bloquear_passageira), apps/engine/lib/engine/dev/agent_io.ex:293 (pausar_por_rede), apps/engine/lib/engine/workers/retomada_de_tarefa_worker.ex:33 (agendar), apps/api/src/application/use-cases/execution/unblock-task.use-case.ts:33 (ator)
  • Teste: apps/engine/test/engine/dev/dev_agent_server_test.exs ("falha passageira: agenda a retomada, pausa e não queima a fila", "fim da pausa volta a reivindicar"; falha: "falha passageira depois de 3 retomadas fica bloqueada pedindo o clique", "402 não agenda retomada"; correção: "correção com 503: infra, retomada agendada, pausa e disjuntor intacto", "correção sem crédito: pausa por crédito, sem retomada", "correção com erro não passageiro: bloqueia na hora, sem retomada"), apps/engine/test/engine/workers/retomada_de_tarefa_worker_test.exs
  • Origem: AT-525

RN-870 — O veredito só espera pergunta que existe e está aberta​

  • Regra: POST /internal/sessions/:sessionId/gates/verdict com needs_user_decision e openQuestionId confere, ANTES de qualquer escrita (comentário na PR, estado do gate, evento), que a pergunta existe no projeto e está aberta. Inexistente ou de outro projeto: 404 reason: "pergunta_nao_encontrada". Já respondida: 409 reason: "pergunta_ja_respondida" com a decisão do usuário em decisao — sem escrita e sem pr.gate_changed. O estado sai do log (RN-858).
  • Onde: apps/api/src/application/use-cases/execution/record-gate-verdict.use-case.ts:85 (exigirPerguntaAberta), apps/api/src/domain/open-questions/pergunta-em-aberto.ts:154 (estadoDaPergunta)
  • Teste: apps/api/test/application/use-cases/execution/record-gate-verdict.use-case.spec.ts ("pergunta aberta: estaciona sem gravar outra"; falha: "inexistente ou de outro projeto: 404 nomeado, sem escrita", "já respondida: 409 com a decisão, sem escrita nem pr.gate_changed")
  • Origem: AT-526, ADR 0215

RN-871 — afeta.regras aceita o título exato da regra e guarda o id​

  • Regra: no funil de eventos, o artifact.open_question cujo afeta.regras traz o TÍTULO exato de um artifact.business_rule do projeto é gravado com o id da regra (a mais recente, se o título se repete); o que não é id nem título exato é recusado com 400 nomeando os valores. No contexto do dev, decisoes casa também pelo título das regras da história, para as decisões gravadas antes desta regra.
  • Onde: apps/api/src/domain/open-questions/pergunta-em-aberto.ts:179 (resolverRegrasDoAfeta), apps/api/src/application/use-cases/sessions/append-session-event.use-case.ts:117 (resolverRegrasDoAfeta), apps/api/src/application/use-cases/execution/get-dev-task-context.use-case.ts:134 (decisoesDoContexto)
  • Teste: apps/api/test/application/use-cases/backlog/perguntas-em-aberto.use-case.spec.ts ("título exato é convertido para o id da regra"; falha: "texto que não é id nem título de regra: 400 sem escrita"), apps/api/test/application/use-cases/execution/get-dev-task-context.use-case.spec.ts ("decisão legada com TÍTULO de regra em afeta.regras casa pelo título")
  • Origem: AT-526, ADR 0215

RN-872 — As outras decisões do projeto chegam ao dev e ao QA, em seção própria​

  • Regra: GET /internal/sessions/:sessionId/dev-context devolve, além de decisoes (as que tocam a tarefa, RN-859), decisoesDoProjeto: as demais artifact.decision_record do projeto, mais recentes primeiro, no mesmo formato. O teto de 10 é COMPARTILHADO — as da tarefa vêm primeiro e consomem o teto antes. Revisa a frase "decisão sem afeta não entra" do ADR 0214 para esta lista. O campo é aditivo (o engine que não o conhece o ignora).
  • Onde: apps/api/src/application/use-cases/execution/get-dev-task-context.use-case.ts:134 (decisoesDoContexto), apps/api/src/interfaces/http/internal/dto/internal.response.dto.ts:237 (decisoesDoProjeto)
  • Teste: apps/api/test/application/use-cases/execution/get-dev-task-context.use-case.spec.ts ("as que não tocam a tarefa vêm em decisoesDoProjeto; teto de 10 somado, as da tarefa primeiro")
  • Origem: AT-526, ADR 0215

RN-865 — Endpoint e verbo HTTP com caminho também ligam a conferência do OpenAPI, e ela cobre as rotas do código​

  • Regra: a régua única de "a tarefa toca ponto de entrada/rotas" (toca_ponto_de_entrada?, usada pelo recorte do QA e pela cláusula do dev da RN-853) casa também "endpoint" e verbo HTTP seguido de caminho (POST /links). Sem isso, tarefa descrita como "Endpoint protegido…" não recebia nem a cláusula do OpenAPI (dev) nem a conferência do /openapi.json (QA) — foi assim que POST /links e GET /panel ficaram fora do OpenAPI com as duas tarefas aprovadas no TP-01. A conferência do QA passa a exigir em paths do /openapi.json servido, além das rotas da seção "Rotas HTTP" do README, cada rota que o CÓDIGO registra; a que falta é item que reprova (estende a RN-863).
  • Onde: apps/engine/lib/engine/gates/recorte_da_tarefa.ex:103 (@ponto_de_entrada), apps/engine/lib/engine/gates/recorte_da_tarefa.ex:114 (toca_ponto_de_entrada?)
  • Teste: apps/engine/test/engine/gates/recorte_da_tarefa_test.exs ("RN-865: tarefa que diz só endpoint ou verbo com caminho recebe a seção"; falha: "falha: tarefa sem rota não recebe a seção")
  • Origem: AT-527

RN-867 — O aviso do gate estacionado diz o motivo gravado, e a pergunta em aberto aponta o cartão dela​

  • Regra: o gate.rescue_parked tem dois emissores: o resgate por tempo (GateRescuer, RN-722), que grava idadeSegundos, e o QA estacionado esperando a resposta a uma pergunta em aberto, que não o grava. O fio distingue os dois pela PRESENÇA de idadeSegundos (o motivo é texto pt-BR fixo e não é lido como chave): "parado mais de 2 h" só no resgate por tempo; no outro, o aviso diz que o gate espera a resposta e aponta o cartão "Perguntas em aberto" (RN-861). O botão "Retomar gate" (RN-724) segue nos dois.
  • Onde: apps/web/src/routes/RetomarGateEstacionado.tsx:18 (cicloEstacionado), apps/web/src/routes/session-timeline-montagem.tsx (gateEstacionado.avisoPergunta)
  • Teste: apps/web/src/routes/RetomarGateEstacionado.test.tsx ("o resgate por tempo grava idadeSegundos e lê como tempo"; falha: "a pergunta em aberto não tem idadeSegundos e não fala em 2 h")
  • Origem: AT-528

RN-868 — O tick do resgate de gates é uma corrente só​

  • Regra: o GateRescueSchedulerWorker reagenda o próprio sucessor com a MESMA unicidade do kickoff/0 (estados available/scheduled/retryable, sem executing, janela de dois intervalos). Antes só o kickoff/0 era único: o job em execução não conta, então o kickoff do boot com o tick anterior em voo, ou um retry que re-roda o perform/1, abria uma SEGUNDA corrente, e elas nunca se fundiam. Medido no uso real (14/10): 11 jobs agendados ao mesmo tempo e a linha "espera decisão humana" da RN-722 11 vezes por tick para UMA linha de gate_states. Não era defeito de dado. Com a unicidade no reagendamento as correntes duplicadas colapsam numa só no tick seguinte.
  • Onde: apps/engine/lib/engine/workers/gate_rescue_scheduler_worker.ex:42 (unicidade)
  • Teste: apps/engine/test/engine/workers/gate_rescue_scheduler_worker_test.exs ("o tick se reagenda e mantém a corrente viva"; falha: "com um sucessor já agendado, o tick não abre uma segunda corrente")
  • Origem: AT-529

RN-874 — O PO recebe no turno as decisões do usuário e as perguntas abertas do projeto​

  • Regra: todo turno do PO leva, como mensagem system EFÊMERA acrescentada pela fachada EngineApiClient logo depois do fluxo do time (RN-711) — nunca em state.messages nem no prompt do servidor —, as decisões do usuário (artifact.decision_record, choice e context) e as perguntas AINDA abertas do projeto (artifact.open_question sem open_question.answered com o mesmo questionId), de TODAS as sessões. O texto manda escrever a RESPOSTA decidida na história, regra, DoR, DoD e tarefa, nunca "pergunta em aberto N"; pergunta ainda aberta é citada pelo questionId, sem inventar resposta. A leitura é LOCAL (Postgres direto, sem HTTP no laço), dos 200 eventos mais recentes desses tipos, com teto de 10 decisões, 10 perguntas, 240 caracteres por item e 3 000 no texto. Sem decisão nem pergunta aberta, ou com a leitura falhando, o turno segue sem a mensagem, nunca cai. Só o po recebe.
  • Onde: apps/engine/lib/engine/harness/decisoes_do_projeto.ex:34 (anexar), :49 (texto), apps/engine/lib/engine/session_events/event.ex:224 (list_types_for_project), apps/engine/lib/engine/sessions/engine_api_client.ex:729 (DecisoesDoProjeto)
  • Teste: apps/engine/test/engine/harness/decisoes_do_projeto_test.exs ("o turno do PO leva a decisão e só a pergunta AINDA aberta", "custo de entrada tem teto"; falha: "sem decisões nem perguntas não acrescenta nada", "leitura que falha deixa o turno sem a mensagem, sem queda")

RN-876 — O needs_user_decision recusado pela api volta ao QA, e só o aceite estaciona o gate​

  • Regra: o gate de QA só ESTACIONA (gate.rescue_parked) quando a api aceita o veredito needs_user_decision com nextAction: "await_user". Se a api RECUSA (400, 404, ou o 409 pergunta_ja_respondida), a recusa volta ao laço da QA de Automação como mensagem nova (RN-163: erro é entrada do laço), retomando o MESMO contexto em que ela emitiu — no 409, com a decisão já tomada para o QA aplicar em vez de perguntar de novo. As outras delegações não rodam de novo. A volta conta no teto de iterações do próprio laço; esgotado, a tarefa é bloqueada com o motivo e a origem modelo (RN-059), nunca estacionada em silêncio. Antes, o retorno da api era descartado e o gate estacionava sem pergunta no fio (medido: openQuestionId: "1", 400 do @IsUUID). A ferramenta emit_qa_verdict recusa, DENTRO do laço e antes da api, openQuestionId que não é uuid.
  • Onde: apps/engine/lib/engine/gates/qa_lead_server.ex:579 (devolver_ao_qa), apps/engine/lib/engine/gates/qa_lead_server.ex:312 (devolver_ao_qa), apps/engine/lib/engine/gates/qa_lead_server.ex:675 (texto_da_recusa), apps/engine/lib/engine/gates/qa_automacao_agent.ex:186 (devolver), apps/engine/lib/engine/gates/tools/emit_qa_verdict.ex:75 (uuid)
  • Teste: apps/engine/test/engine/gates/qa_lead_server_test.exs ("400: nada estaciona, o QA recebe o motivo e uma nova volta acontece"; "409 no laço: a volta seguinte do QA lê a decisão"; falha: "teto esgotado depois da recusa: bloqueio narrado, nunca estacionamento"), apps/engine/test/engine/harness/perguntas_em_aberto_test.exs ("AT-526 (RN-876): openQuestionId que não é uuid é recusa nomeada; uuid passa")
  • Origem: AT-526

RN-877 — O recorte do QA manda conferir as decisões antes de pedir decisão​

  • Regra: a instrução do recorte do QA diz que, antes de needs_user_decision, ele confere as decisões listadas; requisito que uma delas já responde não é pergunta e a decisão se aplica. openQuestionId só com o uuid de uma pergunta ABERTA; sem ele, a pergunta nova vai em pergunta/sugestao. É lembrete de leitura: não muda a régua do veredito.
  • Onde: apps/engine/lib/engine/gates/recorte_da_tarefa.ex:20 (texto)
  • Teste: apps/engine/test/engine/gates/recorte_da_tarefa_test.exs ("AT-526 (RN-877): manda conferir as decisões antes de needs_user_decision"; a falha da instrução é coberta pelo teste da RN-876 da ferramenta)
  • Origem: AT-526

RN-879 — URL absoluta do entregável com a porta, e o mesmo code para o mesmo erro​

  • Regra: quando a tarefa toca ponto de entrada ou rotas, a cláusula do dev manda que URL absoluta devolvida pela API do entregável (ex.: o link curto) venha de PUBLIC_BASE_URL ou do Host da requisição COM a porta — nunca http://localhost fixo nem o hostname sem porta —, e que o mesmo caso de erro responda o mesmo code em todas as rotas. O recorte do QA confere as duas coisas e trata a divergência (ex.: not_found × link_not_found) como ITEM. Decisão do dono de 14/10: regra geral.
  • Onde: apps/engine/lib/engine/dev/dev_agent_server.ex:644 (clausula_de_openapi); apps/engine/lib/engine/gates/recorte_da_tarefa.ex:122 (secao_do_ponto_de_entrada)
  • Teste: apps/engine/test/engine/dev/report_done_instrucao_test.exs ("RN-879/880: tarefa que toca rotas leva URL com porta, code único e CORS" e a falha "RN-879/880 falha: tarefa sem rota não recebe CORS nem PUBLIC_BASE_URL"); apps/engine/test/engine/gates/recorte_da_tarefa_test.exs ("RN-879/880: URL, code de erro e CORS")
  • Origem: AT-531

RN-880 — O entregável responde CORS configurável, com servers relativo e swagger-ui pinado​

  • Regra: na mesma cláusula, a API do entregável responde CORS com origens de CORS_ORIGINS (padrão http://localhost:<porta> e http://127.0.0.1:<porta>), OPTIONS 204 e Access-Control-Allow-* com Authorization e Content-Type; o /openapi.json declara servers só relativo (/) — URL absoluta criaria o erro de CORS quando a página é aberta por outro host —, e o swagger-ui de CDN vai com versão exata, nunca @5. O QA prova com um preflight de outra origem (curl -X OPTIONS) e reprova sem os cabeçalhos, com servers absoluto ou com o CDN flutuante.
  • Onde: apps/engine/lib/engine/dev/dev_agent_server.ex:644 (clausula_de_openapi); apps/engine/lib/engine/gates/recorte_da_tarefa.ex:122 (secao_do_ponto_de_entrada)
  • Teste: os mesmos da RN-879 (caminho feliz e falha nos dois arquivos)
  • Origem: AT-532

RN-881 — O engine imprime as outras decisões do projeto para o dev e o QA​

  • Regra: o decisoesDoProjeto que a api devolve no dev-context (RN-872, ADR 0215, já com teto) vira a seção "Outras decisões do projeto", separada e DEPOIS das decisões da tarefa, na unidade da story do dev e no recorte do QA. Vazio ou ausente não escreve nada; o engine não aplica teto próprio.
  • Onde: apps/engine/lib/engine/dev/context_builder.ex:133 (secao_de_decisoes_do_projeto); apps/engine/lib/engine/gates/recorte_da_tarefa.ex:20 (texto)
  • Teste: apps/engine/test/engine/dev/context_builder_test.exs ("RN-881: decisoesDoProjeto vira a seção..." e "RN-881 falha: sem decisoesDoProjeto a seção some"); apps/engine/test/engine/gates/recorte_da_tarefa_test.exs ("RN-881: outras decisões do projeto")
  • Origem: AT-526 (fatia F4, lado do engine)

RN-885 — O CORS do entregável usa a porta PUBLICADA, e o QA confere o header nas duas origens​

  • Regra: revisa a RN-880 (que continua valendo no resto). O padrão de CORS_ORIGINS do entregável usa a porta PUBLICADA — APP_PORT, que o compose passa ao app —, nunca a interna PORT: no TP-01 (16ª) o padrão saiu com PORT=3000 e, com o compose publicando 8080, nenhuma origem real recebia Access-Control-Allow-Origin (medido com curl: :3000 recebe, :8080 não; no Chrome, Failed to fetch). O QA faz o preflight contra a porta PUBLICADA, uma vez para http://localhost:<APP_PORT> e outra para http://127.0.0.1:<APP_PORT>, e reprova sem o header Access-Control-Allow-Origin — 204 sozinho não basta, e CORS só na porta interna é ITEM.
  • Onde: apps/engine/lib/engine/dev/dev_agent_server.ex:644 (clausula_de_openapi); apps/engine/lib/engine/gates/recorte_da_tarefa.ex:122 (secao_do_ponto_de_entrada)
  • Teste: apps/engine/test/engine/dev/report_done_instrucao_test.exs ("RN-885: o padrão do CORS usa a porta PUBLICADA, nunca a interna"; falha: "RN-879/880 falha: tarefa sem rota não recebe CORS nem PUBLIC_BASE_URL"); apps/engine/test/engine/gates/recorte_da_tarefa_test.exs ("RN-885: o preflight é na porta PUBLICADA, nas duas origens, e confere o header")
  • Origem: AT-535

RN-886 — O 403 de orçamento do OpenRouter é crédito esgotado, com limite, gasto e reset​

  • Regra: o 403 do OpenRouter com Budget limit exceeded / limit_source: openrouter_guardrail_budget (guardrail mensal da chave) NÃO é credencial rejeitada: a api o normaliza no MESMO insufficient_credit do 402 (RN-730) — sem código novo no contrato — e o resto do caminho do crédito vale igual: origem infra (RN-726), pausa sem retomada automática (nunca passageira, RN-864). A frase curta, no idioma do turno (RN-733), diz o limite, o gasto e o reset lidos do corpo (limit_usd, spend_usd, resets_at, o que vier) e pede ao admin do OpenRouter para subir o orçamento; o dev bloqueia a tarefa com essa frase no motivo, em vez de "parou sem concluir nem reportar bloqueio" com origem codigo. 403 sem o marcador segue auth.
  • Onde: apps/api/src/domain/llm/llm-provider-errors.ts:86 (normalizeHttpStatus), :140 (pareceOrcamento); apps/engine/lib/engine/agents/falha_de_turno.ex:212 (orcamento_esgotado), :226 (frase_de_orcamento), :163 (credito_esgotado?); apps/engine/lib/engine/dev/dev_agent_server.ex:957 (motivo_de_credito)
  • Teste: apps/api/test/domain/llm/llm-provider-errors.spec.ts ("o 403 de orçamento vira insufficient_credit com o corpo inteiro"; falha: "403 sem marcador de orçamento continua credencial rejeitada"); apps/engine/test/engine/agents/falha_de_turno_test.exs ("RN-886: o 403 de orçamento do OpenRouter")
  • Origem: AT-534

RN-878 — O idioma do agente de execução vale desde a primeira frase​

  • Regra: nos turnos SEM autor humano dos agentes de execução (dev-*, qa-*), a orientação de idioma da RN-622 diz que vale desde a PRIMEIRA frase, inclusive a narração antes de cada ferramenta, e perde a ressalva "salvo pedido do usuário" (não há usuário no turno). Medido na sessão 6c68fbc0 (projeto pt-BR, anthropic/claude-haiku-5.5): a orientação chegava, e mesmo assim a iteração 0 do qa-automacao e do dev-encurtador-api abria em inglês ("I'll start by running…"). O caminho continua UM (a fachada, RN-622): nada em prompt de servidor, agent_instructions ou descrição de ferramenta (RN-667). O teto de 50 tokens (AT-169) segue: medido com gpt-tokenizer, pt-BR 32/26, en 19/19 e o genérico com código na forma curta no pior caso 30/30 (cl100k/o200k); código fora da forma curta cai na orientação de sempre, que já cabia. Turno com autor e os conversacionais não mudam. Sem idioma resolvido, nada é acrescentado. A eficácia se mede no próximo TP-01.
  • Onde: apps/engine/lib/engine/harness/idioma_da_resposta.ex:164 (orientacao_de_execucao), :154 (agente_de_execucao?)
  • Teste: apps/engine/test/engine/harness/idioma_da_resposta_test.exs ("agente de execução: vale desde a primeira frase, dentro do teto"; falha: "agente de execução sem idioma resolvido: nada é acrescentado")
  • Origem: AT-530

RN-882 — O composer da Sessão não perde tecla na digitação rápida​

  • Regra: o campo do composer é NÃO controlado (AT-539, revisão): defaultValue
    • ref, e o onChange só emite o valor ao draft da página (envio, fila e botões seguem lendo dele). Re-render da página nunca reescreve o DOM do campo; a prop só vira atribuição imperativa no nó (esvaziar depois do envio, o texto devolvido na falha, o exemplo do convite) quando difere do DOM E não é eco de um valor que o campo emitiu. Antes o <textarea> era controlado (primeiro pelo draft, depois por um estado local) e um commit com valor mais velho que o do DOM o reescrevia: 991 de 1 010 caracteres no TP-01 (AT-533) e 1 tecla a cada 53 numa rajada de CDP (AT-539, causa não isolada; aceite no Chrome real). Enter, Shift+Enter e a composição de acento (RN-830) não mudam.
  • Onde: apps/web/src/components/CampoDoComposer.tsx:22 (CampoDoComposer); apps/web/src/routes/SessionComposer.tsx
  • Teste: apps/web/src/components/CampoDoComposer.test.tsx ("digitação rápida com a página atrasada não perde tecla nenhuma"; falha/borda: "a escrita de fora (esvaziar depois do envio) é adotada pelo campo"; "um re-render da página nunca reescreve o que já está no DOM do campo"; "rajada de 300 teclas com a página atrasada chega inteira")
  • Origem: AT-533, AT-539

RN-883 — A oferta em lote do modo automático também no chat da Sessão​

  • Regra: com a sessão ativa e com execução (sessaoTemExecucao), o chat mostra a MESMA oferta em lote da aba Executores (RN-661) — o mesmo componente, o mesmo endpoint (um PUT por agente), o mesmo texto do que o modo automático não libera, e nada é gravado sem o clique. Os agentes são os dev-<modulo> que entraram na sessão inteira mais os subagentes de gate (agentesDaOfertaEmLote); papel maintainer (o do endpoint). Sem execução, nada aparece e agent_autonomy nem é lido. Some quando todos já estão em automático.
  • Onde: apps/web/src/components/ModoAutomaticoNoChat.tsx:41 (ModoAutomaticoNoChat), :18 (agentesDoChat)
  • Teste: apps/web/src/components/ModoAutomaticoNoChat.test.tsx ("caminho feliz: com execução, a oferta aparece e só o clique grava"; falha: "sem execução, nada aparece e nem a leitura acontece")
  • Origem: AT-537

RN-884 — A pergunta respondida oferece "Corrigir a história"​

  • Regra: a pergunta respondida pelo painel "Perguntas em aberto" do chat (RN-861) fica como cartão respondido até ser dispensada, e cada história de afeta.historias que o backlog traz ganha "Corrigir a história" quando ainda é corrigível pela régua da RN-727 (draft, sem tarefa in_progress/in_review, papel developer) — o link leva à aba Histórias com ela aberta (?historia=). Não aciona o PO (decisão do dono, 14/10). Não corrigível: o link não existe e o motivo é dito em texto (RN-102). A régua é a MESMA função da aba Histórias, movida para lib/.
  • Onde: apps/web/src/components/PerguntasEmAberto.tsx:222 (CartaoRespondido); apps/web/src/lib/correcao-de-historia.ts (motivoDeCorrecaoInerte); apps/web/src/routes/ProjectBacklogTab.tsx:398 (useHistoriaInicial)
  • Teste: apps/web/src/components/PerguntasRespondidas.test.tsx ("caminho feliz: história draft sem tarefa em execução ganha o link para a aba Histórias"; falha: "com tarefa em execução o botão não existe e o motivo é dito em texto")
  • Origem: AT-538

RN-887 — O bloqueio da tarefa é ortogonal ao status, e quem mostra lê os dois​

  • Regra: tarefa bloqueada continua todo/in_progress com tasks.blocked = true (e blocked_reason/blocked_origin) — não há status blocked no enum, por decisão do dono (14/10). Quem CONTA ou MOSTRA tarefa lê os dois campos, e blocked vence status: a aba Histórias mostra o selo "Bloqueada" (tom de perigo) com o motivo em texto, e os demais pelo status (a fazer, em curso, em revisão, concluída); a lista de tarefas do backlog que o Dev Lead lê marca a linha com | BLOQUEADA: <motivo, até 120 caracteres>, e a tarefa segue na lista. Claim, desbloqueio e retomada não mudam.
  • Onde: apps/web/src/lib/execution.ts:10 (estadoDaTarefa); apps/web/src/routes/ProjectBacklogTab.tsx (o selo da lista de tarefas); apps/engine/lib/engine/agents/dev_lead_tools.ex:410 (bloqueio)
  • Teste: apps/web/src/lib/execution.test.ts ("blocked vence o status"; falha: "sem bloqueio, o status"); apps/web/src/routes/ProjectBacklogTab.test.tsx ("a bloqueada mostra "Bloqueada" e o motivo, sem o selo "a fazer""); apps/engine/test/engine/agents/dev_lead_tools_test.exs ("a tarefa bloqueada diz BLOQUEADA e o motivo; a todo normal não")
  • Origem: AT-536

RN-889 — A oferta em lote do modo automático aparece no chat de qualquer sessão ativa do projeto em execução​

  • Regra: estende a RN-883. Aprovar o plano do Dev Lead cria a sessão de EXECUÇÃO, separada da do Criativo, e é no chat do Criativo que o usuário conduz. A oferta em lote (RN-661) aparece no chat de toda sessão ATIVA quando o PROJETO tem execução — roster.executionActivated do resumo de projetos (a sessão mais recente, a de execução), a MESMA leitura e queryKey que o chat já fazia (useAtivosNoProjeto, RN-633), sem query nova nem poll novo. Os dev agents de lá (roster.activatedAgents) somam aos desta sessão. Mesmo componente, mesmo endpoint, mesmo texto, nada gravado sem o clique, espera saber quem já está ligado (RN-803) e some com o time todo em automático. Sem execução no projeto, nada aparece e agent_autonomy nem é lido. Lacuna declarada: o sinal é roster.executionActivated da sessão mais recente, que diz que ela TEVE execution.activated, não que segue aberta; com a sessão de execução já encerrada, a oferta ainda aparece no chat do Criativo — declarado, não corrigido, porque distinguir exigiria uma leitura nova (o orçamento de requisições).
  • Onde: apps/web/src/components/ModoAutomaticoNoChat.tsx:29 (ofertaDoChat); apps/web/src/lib/session-destinatario.ts:174 (useExecucaoNoProjeto)
  • Teste: apps/web/src/components/ModoAutomaticoNoChat.test.tsx ("caminho feliz: execução ativa noutra sessão do projeto, time em manual — a oferta aparece no chat do Criativo"; falhas: "time todo já em automático: nada aparece", "sem execução no projeto: nada aparece e nem a leitura acontece")
  • Origem: AT-540

RN-890 — O QA cobra o código de saída do comando de linha, na recusa também​

  • Regra: a instrução do QA de Automação manda conferir, no teste que prova um comando de linha da entrega (CLI, script do package.json), o código de saída 0 no caminho feliz e diferente de 0 quando o comando recusa a entrada; teste sem essa conferência na recusa é regra sem cobertura e vai para itens. A instrução da QA-estratégia pede, em criteriosExecutaveis, esses dois critérios para todo comando que recusa entrada. É instrução de agente: nenhum gate novo, nenhuma mudança de veredito ou de estrutura.
  • Onde: apps/engine/lib/engine/gates/qa_automacao_agent.ex:250 (instrucao_de_codigo_de_saida), apps/engine/lib/engine/gates/qa_estrategia_agent.ex:129 (instrucao_de_codigo_de_saida)
  • Teste: apps/engine/test/engine/gates/qa_automacao_agent_test.exs ("a instrução cobra código de saída 0 no feliz e diferente de 0 na recusa"); apps/engine/test/engine/gates/qa_estrategia_agent_test.exs ("os arquivos da entrega entram na mensagem")
  • Origem: AT-542 (TP-01, 17ª: npm run user:create recusava a senha e saía com 0)

RN-891 — A interface tem quatro idiomas, com pt-BR como padrão e fallback​

  • Regra: o idioma da interface (users.locale) aceita pt-BR, en, ru e zh-Hans, e recusa qualquer outro; o padrão da conta continua pt-BR. No web, pt-BR é o padrão e o fallbackLng (revisa a RN-432, que fazia do en o padrão do web). A escolha gravada na conta vence sempre; sem ela, o navegador sugere: pt* → pt-BR; zh, zh-CN, zh-SG, zh-Hans* → zh-Hans; ru* → ru; en* → en; o resto (alemão, chinês tradicional…) → pt-BR. Locale sem catálogo em locales/ não quebra o boot: a tela cai no pt-BR e o seletor da Conta, com os rótulos nativos, diz "tradução em andamento". <html lang> acompanha o idioma ativo. A paridade de chaves compara cada locale existente com a fonte pt-BR.
  • Onde: apps/api/src/domain/iam/user.entity.ts:8 (USER_LOCALES), apps/web/src/lib/idioma.ts:65 (resolverIdiomaDoNavegador), apps/web/src/lib/i18n.ts:72 (fallbackLng)
  • Teste: apps/api/test/interfaces/http/iam/user-preferences-locale.dto.spec.ts ("aceita pt-BR, en, ru e zh-Hans"; "recusa idioma fora da lista"); apps/web/src/lib/idioma.test.ts ("idioma sem catálogo cai no pt-BR, nunca no inglês"); apps/web/src/lib/i18n-paridade.test.ts
  • Origem: AT-548, ADR 0216 (itens 1–4)

RN-893 — O PO declara a borda do valor informado no RF e no DoD​

  • Regra: a instrução de kickoff do PO (com ou sem event log) traz uma terceira obrigação: todo RF que recebe um valor informado pelo usuário (data, número, texto, lista, limite) declara, no RF e no DoD, se a borda — passado/futuro, zero, vazio, negativo, o próprio limite — é ACEITA ou RECUSADA (com o status HTTP/erro); o exemplo é a expiração no passado. Sem saber, o PO chama declare_open_question com afeta.historias e uma sugestão, nunca inventa nem omite. Régua de PROMPT, no molde da RN-790: nenhuma ferramenta recusa história sem borda. O PO NÃO passa a reescrever business_rule; Criativo, QA, schema e gate não mudam.
  • Onde: apps/engine/lib/engine/agents/po_server.ex:681 (obrigacoes)
  • Teste: apps/engine/test/engine/agents/po_server_test.exs:438 (a instrução de kickoff traz a obrigação da borda) e :460 (sem event log, o kickoff ainda a carrega)
  • Origem: AT-541 (TP-01 de 14/10: o entregável aceitou expiresAt no passado e o link nasceu expirado, 410; o RF da história só dizia que a expiração é opcional), decisão do dono de 15/10

RN-894 — Épico com o mesmo título de outro do projeto é recusado​

  • Regra: create_epic recusa (400) o épico cujo título, normalizado, é igual ao de outro épico do MESMO projeto, e a recusa devolve ao modelo o id do existente: "Já existe o épico «título» (id …) neste projeto — use este id em epic_id". A régua é a MESMA da história (achado R, tituloDuplicado), sem segunda cópia. A duplicata SEMÂNTICA (RN-681) não vale para épico: só título igual. Nada é criado nem gravado no event log quando recusa.
  • Onde: apps/api/src/application/use-cases/backlog/create-epic.use-case.ts:25 (tituloDuplicado)
  • Teste: apps/api/test/application/use-cases/backlog/create-epic.use-case.spec.ts:41 (cria sem título igual) e :49 (recusa o título igual com o id do existente)
  • Origem: AT-581

RN-895 — A recusa de argumento diz o que chegou e o formato esperado​

  • Regra: revisa a RN-719 e a RN-725. Campo declarado array/object que chega como texto fora de JSON, ou como número/booleano/tipo trocado, é recusado antes da ferramenta com "o campo X chegou como ; envie ", com exemplo (["item 1","item 2"], {"chave":"valor"}), nunca com o valor. A frase do emit_plano_de_teste passou a pôr as chaves entre crases, e por isso a recusa leva as chaves recebidas.
  • Onde: apps/engine/lib/engine/harness/argumentos_de_ferramenta.ex:84 (coagir); apps/engine/lib/engine/gates/tools/emit_plano_de_teste.ex:73 (run)
  • Teste: apps/engine/test/engine/harness/argumentos_de_ferramenta_test.exs:193 e :201 (o formato e o tipo recebido) e :210 (as chaves recebidas)
  • Origem: AT-583 (TP-01 18ª e 19ª: o dod chegou como checklist markdown e o plano de teste foi recusado 4 vezes sem dizer o que veio)

RN-896 — O epic_id é validado antes da api​

  • Regra: create_story recusa epic_id que não é UUID ("epic_id inválido; …"). Quando o PO já criou épicos neste processo (epicos_conhecidos) e o id não é um deles, relê o backlog UMA vez (list_backlog): se o id está lá, aceita; senão recusa listando id=título do backlog (teto de 20 linhas, dizendo o total se truncar, ADR 0060). Mapa vazio ou leitura do backlog que falha é "não sei" e não recusa — a api decide.
  • Onde: apps/engine/lib/engine/harness/tools/create_story.ex:57 (epico_valido)
  • Teste: apps/engine/test/engine/harness/argumentos_de_ferramenta_test.exs:224 e :233
  • Origem: AT-584 (o qwen mandou EPIC_ID_PLACEHOLDER_NAO_USAR_AQUI e a api respondeu com o nome do DTO)

RN-897 — O corte do provider por max_tokens num gate é origem modelo​

  • Regra: o agent.error dos gates qa-estrategia, qa-automacao, qa-performance-seguranca e appsec classifica "Tool calls cutoff by max_tokens" como modelo (o raciocínio comeu o teto, RN-059); qualquer outro erro de LLM segue infra.
  • Onde: apps/engine/lib/engine/gates/origem_do_erro.ex:10 (de_llm)
  • Teste: apps/engine/test/engine/gates/origem_do_erro_test.exs:7
  • Origem: AT-586 (BaseTen 502 gravado como infra)

RN-898 — Resposta cortada pelo teto, sem texto algum, fecha o turno dita​

  • Regra: o turno de agente conversacional cuja última resposta de LLM foi cortada pelo teto de saída (RN-737) e que não escreveu texto em volta nenhuma NÃO fecha calado: a linha "Resposta cortada pelo limite de tamanho." (en: "Response cut off by the length limit.") vira o fecho de uma agent.response com modelName, e a bolha mostra "Continuar de onde parou" (RN-749). Vale para os sete conversacionais, que gravam o fecho por payload_do_turno/2. Fere a RN-059 e a RN-163 quando falta.
  • Onde: apps/engine/lib/engine/agents/texto_do_turno.ex:65 (payload_do_turno)
  • Teste: apps/engine/test/engine/harness/resposta_cortada_test.exs:104
  • Origem: AT-570 (TP-01 18ª: a 3ª chamada do PO saiu 8192/8192 e o turno fechou em idle sem agent.response)

RN-899 — Resposta vazia sem corte vira agent.error de origem modelo​

  • Regra: o fecho do laço conversacional sem texto, sem ferramenta e sem corte grava agent.error com origem modelo e a frase "o modelo não devolveu resposta" (FalhaDeTurno), nos sete servidores. É fala do agente para o heartbeat (RN-581): a sessão com turno calado fechava por heartbeat_timeout 26 s depois de o usuário sair da tela. Presença por aba do projeto NÃO entra (RN-064).
  • Onde: apps/engine/lib/engine/agents/falha_de_turno.ex:63 (origem), apps/engine/lib/engine/agents/po_server.ex:320 (gravar_texto_do_turno)
  • Teste: apps/engine/test/engine/agents/po_server_test.exs:194 e apps/engine/test/engine/agents/texto_do_turno_test.exs:96
  • Origem: AT-572 (TP-01 18ª, achado 4)

RN-900 — write_file recusa reescrever o README e apagar a seção de uso da Infra​

  • Regra: write_file substitui o arquivo inteiro. Quando o path é um README (README*), o arquivo atual tem a seção ## Rodar na sua máquina e o conteúdo novo não a traz, a ferramenta recusa com erro nomeado e não escreve nada. Trocar um trecho exige reescrever o README completo, com a seção. O título é uma constante só (titulo_secao_de_uso/0), usada também pelos prompts da Infra e do dev, para que a recusa e a instrução não divirjam. Não é gate: a recusa é da própria ferramenta, e não cobre escrita pelo terminal (cat >, sed -i).
  • Onde: apps/engine/lib/engine/harness/tools/write_file.ex:62 (recusa_de_secao_perdida)
  • Teste: apps/engine/test/engine/harness/tools/write_file_test.exs:41 (a reescrita sem a seção é recusada e o arquivo fica intacto); o caminho feliz (README com a seção reescrito com a seção, e arquivo que não é README) está nas primeiras duas provas do mesmo arquivo
  • Origem: AT-571 (TP-01 18ª de 19/10: o dev agent reescreveu o README ao corrigir um parecer do QA e a seção de uso que a Infra escrevera sumiu; nenhum gate notou)

RN-901 — A oferta de modo automático do chat inclui os dev agents do plano aprovado​

  • Regra: estende a RN-889. roster.activatedAgents do resumo de projetos NUNCA traz dev agent: o dev não grava agent.activated, só dev.started. A oferta em lote do chat (RN-883/889) lia só essa lista e não listava o dev-<modulo> da execução ativa, que o usuário precisava ligar pela aba Executores. A execução do projeto (execucaoDoRoster) passa a somar, só com roster.executionActivated, os dev-<modulo> dos roster.moduleNames (o module_map vigente), pela MESMA devAgentId que a aba Executores usa (RN-661, rosterFromFacts), sem derivação nova. Sem execução ativa, nenhum dev entra. Lacuna declarada: a lista sai do module_map VIGENTE; se o plano aprovado usou um subconjunto dos módulos, a oferta herda essa divergência. Nenhum teto se move e nada é gravado sem o clique.
  • Onde: apps/web/src/lib/session-destinatario.ts:194 (execucaoDoRoster), chamado por useExecucaoNoProjeto (apps/web/src/lib/session-destinatario.ts:175)
  • Teste: apps/web/src/lib/session-destinatario.test.ts ("CAMINHO FELIZ: execução ativa soma o dev- do module_map aos ativados"; falha: "sem execução ativa, o módulo do plano NÃO vira dev agent"), e apps/web/src/components/ModoAutomaticoNoChat.test.tsx (fixture derivada do roster real, "caminho feliz")
  • Origem: AT-573 (TP-01 18ª, 19/10: a 1ª tarefa parou em dev.awaiting_approval porque o dev não estava no modo automático)

RN-902 — Recriar o container congela a decisão de imagem vigente, e a imagem desatualizada é avisada​

  • Regra: a transição removed → provisioning e failed → provisioning RELÊ a decisão de imagem vigente e regrava imageVersion e resources da linha de project_containers (antes só a criação da linha os congelava, e a coluna Imagem seguia na versão antiga depois de Remover → Subir). Sem decisão vigente é 409 (RN-105). stopped → running NÃO muda a versão: o container reaproveitado é o antigo. A página /containers ganha decisaoVigente e imagemDesatualizada por linha — a divergência compara CONTEÚDO (imagem, rede, recursos), nunca o número da versão, porque a eleição da Infra grava v+1 com o mesmo conteúdo a cada subida — e, com running/stopped/provisioning divergente, a linha diz em texto "Parar → Remover → Subir de novo" e passa a dizer por que "Subir de novo" não aplica a imagem nova. O resultado de choose_project_image traz containerDePe e o texto avisa que a decisão só vale ao recriar o container. "Recriar" numa só ação NÃO existe; nenhum teto de aprovação muda (RN-495).
  • Onde: apps/api/src/application/use-cases/containers/registrar-transicao-de-container.use-case.ts:131 (recongelar), apps/api/src/domain/containers/project-container.ts:373 (containerDivergeDaDecisao), apps/engine/lib/engine/harness/tools/choose_project_image.ex:99 (mensagem_de_fixada)
  • Teste: apps/api/test/application/use-cases/containers/ciclo-de-vida-do-container.use-case.spec.ts ("recongela versão e recursos", "sem decisão vigente é 409", "mantém a versão congelada"); apps/api/test/domain/containers/project-container.spec.ts (containerDivergeDaDecisao); apps/api/test/infrastructure/persistence/drizzle/containers-overview.repository.spec.ts ("imagemDesatualizada compara CONTEÚDO"); apps/web/src/routes/ContainersPage.test.tsx (aviso e motivo); apps/engine/test/engine/harness/tools/choose_project_image_test.exs
  • Origem: AT-595 (TP-01 20ª: a decisão v3 egress não alcançou o container v2 none e a coluna Imagem seguiu em v2)

RN-903 — O 429 do provider é limite de uso do modelo, não falha de rede​

  • Regra: a task que o dev agent bloqueia porque o modelo respondeu 429 (código rate_limit, ou texto com (429) ou "limite de uso") diz no motivo "limite de uso do modelo atingido (429)", e a retomada automática diz que, se repetir, é para trocar o modelo do dev agent. Nunca diz "falha passageira de rede". A origem segue infra e a retomada da RN-864 não muda (até 3, depois de 1, 3 e 10 min; pausa de 60 s, que o dev.idle nomeia como "depois de limite de uso do modelo"). A falha de rede segue com o texto de sempre.
  • Onde: apps/engine/lib/engine/dev/dev_agent_server.ex:1136 (limite_de_uso?), apps/engine/lib/engine/dev/dev_agent_server.ex:1127 (motivo_da_parada), apps/engine/lib/engine/dev/dev_agent_server.ex:993 (bloquear_passageira), apps/engine/lib/engine/dev/agent_io.ex:293 (pausar_por_rede)
  • Teste: apps/engine/test/engine/dev/dev_agent_server_test.exs ("429 do provider: o motivo diz limite de uso do modelo, não rede"; falha: "falha de rede continua com o texto de rede, não de limite")
  • Origem: AT-594 (TP-01 20ª, 19/10: o 429 do laguna-s-2.1:free bloqueou as tarefas com "falha passageira de rede", e o diagnóstico dizia limite de uso)

RN-904 — O corte do teto de saída não repete a chamada com o catálogo inteiro​

  • Regra: quando o Jev restringe o menu e o modelo responde SEM ferramenta, o passo é repetido UMA vez com o catálogo inteiro (RN-625) — exceto quando a resposta veio CORTADA pelo teto de saída (truncated). O corte não é erro de menu: a volta com o catálogo inteiro só aumentaria o prompt e gastaria outro teto de saída. Vale nas duas fachadas (llm_turn e llm_turn_stream, esta só sem texto já entregue). Quem trata o corte é a retentativa da RN-780 no ToolLoop, que não muda.
  • Onde: apps/engine/lib/engine/harness/roteamento_de_ferramenta.ex:126 (repetir_com_catalogo_inteiro?)
  • Teste: apps/engine/test/engine/harness/roteamento_de_ferramenta_test.exs:274 (resposta vazia cortada não repete, nos dois caminhos; sem corte, repete)
  • Origem: AT-578 (EP-026, levantamento de 21/10): o 1º turno do PO na 18ª rodada gastou duas voltas de 8192 tokens de saída.

RN-905 — O teto de passos diz o que foi gravado e o que falta, e oferece continuar​

  • Regra: quando um agente conversacional para no limite de passos, o evento do teto (toolloop.limit_reached; no Criativo, o agent.error com reason: "limite_de_iteracoes") leva gravado, a linha da RN-731 lida ANTES de o texto do turno ser gravado (ela sai ali e não na agent.response, nunca nas duas). O PO leva também regrasSemHistoria, o uncoveredCount que fechar_handoff já lê; a leitura que falha deixa a chave ausente, nunca erro. O fio mostra o motivo sem jargão, a linha gravada, as regras sem história e o botão "Continuar de onde parou" (RN-749, mensagem do usuário, que a fila da RN-673 absorve com turno em curso) só para os sete conversacionais com a sessão ativa; dev-*/qa-* ganham o texto, sem botão. O botão nunca continua sozinho.
  • Onde: apps/engine/lib/engine/agents/po_server.ex:254 (run_turn); apps/web/src/routes/session-timeline-montagem.tsx:991 (conversacional)
  • Teste: apps/engine/test/engine/agents/po_server_test.exs:548 (gravado e regrasSemHistoria; sem a leitura, sem a chave); apps/web/src/routes/session-timeline-montagem.falha-de-turno.test.tsx:108 (bolha, botão para o mesmo agente; sem botão em dev-* e sessão inativa)
  • Origem: AT-590 (EP-031).

RN-906 — A api recusa a rede none quando a stack de um módulo baixa dependências​

  • Regra: ao decidir a imagem do projeto, se a rede é none e algum módulo do module_map vigente tem stack que casa, por PALAVRA inteira, com a lista fechada de ecossistemas que baixam dependências (node, npm, pnpm, yarn, python, pip, elixir, mix, go, rust, cargo, ruby, java, maven, gradle, php, dotnet...), a api responde 400 nomeando o módulo, a stack e o termo, e pede egress. Nada é gravado e a rede nunca é trocada sozinha: egress é autorização do usuário. Vale para o Arquiteto (choose_project_image) e para a eleição da Infra, que passam pelo mesmo caso de uso (na Infra, o container_start termina failed com o motivo, sem chamar o broker). Sem module_map ou com stack que não casa, segue só o aviso da RN-735. Sem exceção configurável. Revisa a RN-735 (aviso → recusa nesse caso).
  • Onde: apps/api/src/application/use-cases/containers/decidir-imagem-do-projeto.use-case.ts:80 (stackQueBaixaDependencias)
  • Teste: apps/api/test/application/use-cases/containers/container-do-projeto.use-case.spec.ts (recusa nos dois chamadores; egress, stack que não casa e sem mapa passam); apps/api/test/domain/containers/project-container.spec.ts (casamento por palavra); apps/api/test/application/use-cases/actions/execute-container-start.use-case.spec.ts
  • Origem: AT-593 (EP-031).

RN-908 — O kickoff do PO pede o backlog em lote, depois de ler, em camadas​

  • Regra: o kickoff do PO manda, nesta ordem: ler listar_regras_de_negocio e listar_backlog na mesma resposta, sem escrita junto; depois todos os épicos numa resposta só; depois todas as histórias, com o epic_id devolvido; depois todas as tarefas, com o story_id devolvido. Nunca recria épico que já existe, e no máximo cerca de 10 chamadas de ferramenta por resposta. Motivo medido: sem o pedido de lote, o nemotron faz 1 ferramenta por resposta e o qwen faz 3; com ele, 9 em cada (parallel_tool_calls não mudou nada). Ler primeiro é o que impede o qwen de criar épicos sem ler o backlog. Não altera o teto @max_iterations (RN-166).
  • Onde: apps/engine/lib/engine/agents/po_server.ex:625 (build_kickoff)
  • Teste: apps/engine/test/engine/agents/po_server_test.exs:518 (leitura antes da escrita; lote em camadas épicos → histórias → tarefas)
  • Origem: sonda da AT-591 (21/10), implementada na AT-596 (EP-026).

RN-909 — O 9Router é o 10º provider: URL por instalação, credencial obrigatória, preço público de curadoria​

  • Regra: o provider 9router fala com um gateway na máquina do dono. A URL base é NINE_ROUTER_BASE_URL (padrão http://host.docker.internal:20128/v1), lida a cada uso pelo provider e pelo teste de conexão (GET /models com a chave). A credencial é obrigatória. As capabilities de provider são false (só listModels, streaming e, desde a RN-950, toolCalling, provado por smoke com chave) até um smoke com chave provar o resto (ADR 0041). O preço dos modelos não é descoberto: entra por curadoria manual e, por decisão do dono, modelo de assinatura registra o preço público da API equivalente, não zero. O provider manda x-9router-token-saver: off em toda chamada, para o 9Router não comprimir o tool_result.
  • Onde: apps/api/src/infrastructure/llm/nine-router-provider.ts:171 (nineRouterConfig)
  • Teste: apps/api/test/infrastructure/llm/nine-router-provider.contract.spec.ts:109 (capabilities, URL do ambiente, cabeçalho, teste de conexão, parse da fixture sintética); smoke manual em nine-router-provider.smoke.spec.ts.
  • Origem: AT-576a (EP-026), ADR 0222, decisão do dono de 22/10.

RN-911 — Provider é global; Configurações do projeto só escolhem modelo; a matriz mora na Conta​

  • Regra: credencial de LLM, catálogo de modelos (curadoria, preços, sync e o navegador do Hugging Face) e o relatório de gasto por chave saíram das Configurações do projeto. Moram em /providers (as três peças) e o gasto por chave também na aba Gastos, só para owner (RN-060). O projeto guarda só a ESCOLHA: modelo por agente e por área, o "aplicar a todos" (RN-476), o critério de roteamento e "melhores modelos". A matriz "Quem pode aprovar o quê" saiu das Configurações e é uma seção da Conta (/account), para qualquer pessoa logada. O atalho "Ativar e aplicar aos N agentes" (RN-694 item 3) deixou de existir. Link antigo ?section=credentials, model-catalog, key-spend ou approval-matrix abre a aba no topo, sem erro. O vazio de "Modelos por agente", o subtítulo de "melhores modelos" e os Primeiros passos (RN-708) apontam para /providers. Revisa a RN-694 (item 3) e a RN-708 (destinos). Sem ADR: é fronteira de tela.
  • Onde: apps/web/src/routes/settings/sumario.ts:48 (SECOES_DE_CONFIGURACOES), apps/web/src/routes/ProjectSettingsTab.tsx:62 (ProjectSettingsTab), apps/web/src/routes/AccountPage.tsx:36 (AccountPage)
  • Teste: apps/web/src/routes/settings/sumario.test.ts (as quatro chaves não existem); apps/web/src/routes/settings/SumarioDeConfiguracoes.test.tsx (a aba não monta as quatro seções, nem para owner); apps/web/src/routes/AccountPage.test.tsx (a matriz, e o caso de falha no idioma); apps/web/src/routes/configuracoes.orcamento.test.tsx (a volta à aba não busca credencial); apps/web/src/components/catalogo-com-busca.test.tsx (sem atalho)
  • Origem: AT-597 e AT-598 (EP-031), pedidos do dono de 22/10.

RN-912 — O modelo da área é uma caixa na linha do lead, em "Modelos por agente"​

  • Regra: a seção "Modelo por área" deixou de existir. O padrão da área (RN-102, escopo area da RN-470) liga-se por uma caixa só na linha do lead de cada área (dev-lead, qa, infra): "Valer para a área X inteira (lead + subagentes)". O binding de área é um valor por área e não guarda o agente de origem, então a caixa em toda linha deixaria duas disputarem o valor. Marcada ⇔ o vigente do lead vem da área (origin: 'area'), lido do lote da RN-654, sem consulta nova. Marcar grava a área com o vigente do lead e o critério de roteamento dele (PUT .../area-bindings/:area) e DEPOIS apaga o binding próprio do lead (DELETE .../agent-bindings/:lead; copiar o deixaria para trás, RN-102). São duas chamadas e o desfecho é por passo (RN-469): se a área recusa (inclusive o 422 da RN-040) nada mudou e o toast traz a frase da api; se a área gravou e o DELETE falha, a tela diz que o padrão da área foi gravado e o modelo próprio do lead não foi removido (404 do DELETE conta como já removido). Desmarcar apaga a área (DELETE .../area-bindings/:area) e não grava nada no lead; 404 vira "já herdava". Com a caixa marcada, o seletor e o critério de roteamento do lead gravam a ÁREA. O agente com binding próprio vence a área (RN-470) e NÃO segue: a tela lista, com plural, os subagentes conhecidos nessa situação (recorte declarado, RN-180; os dev-<modulo> dinâmicos não entram e a tela diz isso) e não apaga a decisão deles. O "aplicar a todos" (RN-476) grava binding próprio nos 17 e, por isso, tira os subagentes do padrão da área; o aviso dele diz isso. Papel: a caixa e, enquanto marcada, o seletor do lead pedem maintainer (o mínimo do endpoint de área, RN-102), comparado por roleAtLeast; abaixo disso ficam inertes e o motivo é dito UMA vez em texto na legenda da seção (ADR 0064). As demais linhas seguem em developer. A caixa nasce desabilitada se o lead não tem vigente para copiar, e diz o motivo em texto na própria caixa (AT-601, chave modelsSection.areaBox.needsModel). Revisa a UI das RN-040, RN-102 e RN-470; a cascata, o escopo e os endpoints não mudam (sem ADR).
  • Onde: apps/web/src/routes/settings/ModelsSection.tsx:94 (podeEditarArea), :428 (areaDoLead), :446 (handleMarcarArea), :486 (handleDesmarcarArea)
  • Teste: apps/web/src/routes/settings/ModelsSection.caixa-da-area.test.tsx (AT-601: motivo sem vigente no lead); apps/web/src/routes/ProjectSettingsTab.test.tsx (marcar, a área recusando, FALHA PARCIAL, desmarcar com e sem 404, seletor do lead na área, não-seguidores, developer inerte); apps/web/src/routes/settings/papel-na-tabela-de-agentes.test.tsx (os dois mínimos); bindings-em-lote.test.tsx (lote único); cascata.test.tsx; heranca.test.tsx; filtro-de-aptos.test.tsx (RN-040); preferencia-de-roteamento.test.tsx (ADR 0166)
  • Origem: AT-600 (EP-031), pedido do dono de 22/10.

RN-913 — Falha igual repetida de ferramenta no turno corrige na 2ª e para na 3ª​

  • Regra: em cada turno, o harness conta as falhas IGUAIS de ferramenta (mesma ferramenta e mesmos argumentos, tool.result ok: false). Na 2ª, o resultado devolvido ao modelo ganha a correção "esta chamada já falhou igual… mude os argumentos ou use outra abordagem". Na 3ª, o laço PARA e grava agent.error com origem modelo (reason: falha_repetida_de_ferramenta), nunca em silêncio (RN-059, RN-163). Falhas diferentes não somam, e o contador zera a cada turno. Vale para os sete conversacionais e para o ToolLoop, pelo despacho único da RN-719; cada laço para pelo desfecho que já tinha (os conversacionais pelo caminho do teto de iterações, com o agent.error no lugar do toolloop.limit_reached; o ToolLoop devolvendo {:ok, ctx} com last_error).
  • Onde: apps/engine/lib/engine/harness/argumentos_de_ferramenta.ex:26 (executar), apps/engine/lib/engine/harness/falha_repetida.ex:30 (registrar), :51 (parou?), :56 (narrar), apps/engine/lib/engine/harness/tool_loop.ex:123 (parou_ou_loop)
  • Teste: apps/engine/test/engine/harness/falha_repetida_test.exs (2ª corrige, 3ª para no ToolLoop, falhas diferentes não somam, zera entre turnos)
  • Origem: AT-588 (EP-026), decisão do dono de 22/10 (K=2 corrige, K=3 para).

RN-914 — O orçamento de raciocínio é por modelo, só para quem o respeita​

  • Regra: revisa a RN-782 (ADR 0223). Modelo com models.reasoning_budget preenchido (curado à mão, nunca pelo sync) e supportsReasoning manda reasoning: { max_tokens: N } e max_tokens = saída visível + N (RN-741); só no provider que sabe controlar o raciocínio (hoje o OpenRouter) e nunca no agente com raciocínio desligado (RN-783). Hoje só qwen/qwen3.7-flash (N = 2048), provado em 22/10: 1024 pedidos, 1024 gastos. Modelo sem orçamento segue a RN-782, corpo byte a byte. deepseek/deepseek-v4.1-flash fica sem orçamento: reasoning.max_tokens, effort e reasoning_effort foram ignorados (8192 de raciocínio). effort nunca é enviado. O limite contém o raciocínio mas não garante a tool call.
  • Onde: apps/api/src/domain/llm/raciocinio-do-agente.ts:18 (opcoesDeRaciocinio), apps/api/src/infrastructure/llm/openai-compatible-provider.ts:624 (controla), apps/api/src/db/schema/llm.ts:159 (reasoningBudget), apps/api/src/db/migrations/0074_orcamento_de_raciocinio.sql
  • Teste: apps/api/test/infrastructure/llm/openai-compatible-provider.contract.spec.ts (modelo com orçamento de raciocínio (RN-914)), apps/api/test/domain/llm/raciocinio-do-agente.spec.ts
  • Origem: AT-569 (medição paga de 22/10, ~/brabo-gravacoes/raciocinio-smoke-2026-10-22/resultados.jsonl)

RN-918 — O agente escreve o próprio plano com registrar_plano, e o último agent.plan é o vigente​

  • Regra: registrar_plano grava agent.plan com o objetivo (até 200 caracteres) e de 1 a 12 passos de até 160 caracteres, cada um pendente, em_curso, feito ou descartado. Cada chamada manda o plano INTEIRO e substitui o anterior (o vigente é o último agent.plan do ator). Acima do teto a ferramenta RECUSA nomeando o limite, nunca corta. Não é proposed_action (escrever o plano não é efeito externo) nem artefato (fora de ferramentas_de_artefato/0) e a descrição não nomeia idioma (RN-667). Têm a ferramenta os seis conversacionais com o Infra Lead, o dev e os subagentes de QA e AppSec; ficam fora o subagente de validação de arquivo da Infra e o context-manager. A Reidratacao mostra só o último plano de cada ator, como uma nota, e pula o tool.call/tool.result da ferramenta.
  • Onde: apps/engine/lib/engine/harness/tools/registrar_plano.ex:84 (run), apps/engine/lib/engine/agents/reidratacao.ex:172 (so_o_ultimo_plano_de_cada_ator)
  • Teste: apps/engine/test/engine/agents/diario_vivo_test.exs (registrar_plano: grava, substitui, recusa acima do teto; Reidratacao: só o último plano)
  • Origem: AT-582 (EP-031), decisão do dono de 27/10; ADR 0226.

RN-919 — O diário é a projeção do log mais o plano, sem tabela, e diz o que está confirmado e o que ficou sem desfecho​

  • Regra: Engine.Agents.Diario lê a cauda do log do agente, a partir do marco do turno (agent.status: working, o dev.working da tarefa ou o agent.turn_resumed do subagente de gate), e devolve o plano vigente, os efeitos confirmados (tool.result ok, proposed_action.executed, artefatos, commit preservado), as chamadas sem desfecho, as ações pendentes (proposed_action.created sem decisão), a última atividade e se a retomada automática já foi usada. A fonte de verdade continua o event log. RetomadaDoTurno transforma o diário numa mensagem system EFÊMERA (teto de 2 000 caracteres, depois de DecisoesDoProjeto e antes de PerfilDoAutor; no ToolLoop, por ctx[:retomada]): onde parou, o plano, o que não repetir, o que conferir antes e as ações pendentes que não se propõem de novo. Nunca é mensagem falsa do usuário. Push e PR são repetíveis (a PR aberta é reaproveitada, RN-812); merge NUNCA na retomada (RN-418); o engine não reexecuta chamada sem desfecho, quem decide é o modelo, depois de conferir o estado.
  • Onde: apps/engine/lib/engine/agents/diario.ex:85 (de_eventos), :255 (retomada_usada?), apps/engine/lib/engine/harness/retomada_do_turno.ex:69 (texto)
  • Teste: apps/engine/test/engine/agents/diario_vivo_test.exs (Diario.de_eventos/2, RetomadaDoTurno, marco por opção e retomada usada)
  • Origem: AT-582 (EP-031); ADR 0226.

RN-920 — O turno conversacional que o reinício matou é retomado UMA vez no boot, se recente​

  • Regra: revisa a RN-586. A varredura de boot (TurnoOrfao.varrer/2) retoma o turno órfão cujo agente teve atividade há no máximo 900 s (:retomada_automatica_janela_segundos, a mesma régua da RN-823, medida pela última atividade do agente e não pelo working) e que ainda não foi retomado: grava agent.error (turno_interrompido_por_reinicio, retomada: "automatica"), agent.status: idle e agent.turn_resumed (automatica: true) e acorda o agente, cujo init/1 agenda :retomar_turno_interrompido ANTES da entrega da fila (RN-673). O teto é durável no log: um agent.turn_resumed do agente entre o último status não-working e o working órfão. Turno velho ou já retomado é só fechado, com a frase apontando o botão (RN-921). O fechar_ao_subir/3 do init/1 continua só fechando, e nó mudo conta como vivo (na dúvida nem fecha nem retoma). Resíduo declarado: dois nós subindo juntos, sem se enxergarem, podem retomar o mesmo turno duas vezes.
  • Onde: apps/engine/lib/engine/agents/turno_orfao.ex:90 (varrer), :181 (retomar), apps/engine/lib/engine/agents/turno_assincrono.ex:418 (retomada_ao_subir), :253 (tratar_resultado)
  • Teste: apps/engine/test/engine/agents/diario_vivo_test.exs (TurnoOrfao.varrer/2: retomada automática 1× no boot, TurnoAssincrono: retomada no init); apps/engine/test/engine/agents/reidratacao_dos_seis_test.exs
  • Origem: AT-582 (EP-031), decisão do dono de 27/10; ADR 0226.

RN-921 — O botão "Retomar" do fio é gesto humano, sem teto, e só vale para o turno interrompido​

  • Regra: o agent.error com reason: turno_interrompido_por_reinicio ganha no fio o cartão RetomarTurnoInterrompido (objetivo e passos do último plano, "Parou em", botão). O clique chama POST /projects/:projectId/sessions/:sessionId/agents/:agent/resume (developer; 200 {ok:true}; 404 sessão alheia; 409 turno_nao_interrompido quando o último desfecho do agente não é a interrupção ou há turno em curso; 409 agente_sem_retomada), que pede ao engine POST /internal/sessions/:sessionId/agent/resume: ele grava agent.turn_resumed com automatica: false e manda :retomar_turno_interrompido à MESMA TurnoAssincrono. Sem teto: cada clique é uma retomada. O cartão some quando algo segue o erro, e a retomada automática vira uma linha discreta. O "sem desfecho" do diário NÃO aparece na tela (o evento não o traz).
  • Onde: apps/api/src/interfaces/http/sessions/agent-resume.controller.ts:23 (AgentResumeController), apps/api/src/application/use-cases/sessions/resume-agent-turn.use-case.ts:29 (execute), apps/engine/lib/engine_web/controllers/agent_command_controller.ex:624 (resume), apps/web/src/routes/RetomarTurnoInterrompido.tsx:23 (ehTurnoInterrompido)
  • Teste: apps/api/test/interfaces/http/sessions/agent-resume.controller.spec.ts (feliz, duas recusas 409, 404, papel); apps/web/src/routes/RetomarTurnoInterrompido.test.tsx; ExUnit do agent_command_controller
  • Origem: AT-582 (EP-031); ADR 0226.

RN-922 — O dev agent que cai dentro da janela retoma do commit da MESMA tarefa, uma vez​

  • Regra: revisa a RN-742 (e cita a RN-778, que continua só fechando o dev sem linha durável). O dev working reidratado, com a última atividade há no máximo 900 s e sem dev.working cause: "retomada" nesta tarefa, preserva o worktree como commit (RN-743), readota a MESMA tarefa (a branch dela, RN-779), grava dev.working com cause: "retomada" e o commit, e roda o laço com a mensagem do diário. A tarefa nunca sai de in_progress. A 2ª queda, a queda fora da janela, o awaiting_approval e qualquer falha da retomada bloqueiam como antes, e o "desbloquear" manual vira o "retomar do commit". Merge nunca ocorre aqui (RN-418). O contador de falha passageira da RN-864 é outra régua e não muda.
  • Onde: apps/engine/lib/engine/dev/dev_agent_server.ex:1224 (retomada_do_commit), :1250 (retomar_do_commit)
  • Teste: apps/engine/test/engine/dev/retomada_do_commit_test.exs (queda recente retoma; 2ª queda, fora da janela e awaiting_approval bloqueiam)
  • Origem: AT-582 (EP-031), decisão do dono de 27/10; ADR 0226.

RN-923 — O resgate automático de gate vale uma vez por ciclo, no boot e no tick, e o "Retomar gate" zera o contador​

  • Regra: revisa a RN-823 e a RN-722. engine.gate_states.auto_resumes conta os redespachos automáticos do ciclo (no boot e no tick); com 1 já usada, o segundo resgate ESTACIONA (gate.rescue_parked, motivo: "retomada_automatica_ja_usada") e espera o "Retomar gate" (RN-724), que não tem teto e ZERA o contador. O contador fica fora do on_conflict do upsert (sobrevive aos passos do ciclo) e some com a linha quando o ciclo termina. O estacionamento de 2 h não muda. Os subagentes de gate ganham registrar_plano; no redespacho o ToolLoop recebe ctx[:retomada] com o plano do último agent.turn_resumed do subagente, e a área continua sendo redespachada inteira. Resíduo declarado: um ciclo novo pode pegar um marco pendente com menos de 15 min, e o subagente de gate não abre working, então a retomada dele leva só o plano.
  • Onde: apps/engine/lib/engine/gates/gate_rescuer.ex:212 (retomar_ou_estacionar), :359 (retomar_estacionado), apps/engine/lib/engine/gates/gate_state.ex:37 (auto_resumes)
  • Teste: apps/engine/test/engine/gates/gate_rescuer_test.exs:501 (teto de 1 retomada automática no tick e no boot, e o manual zerando)
  • Origem: AT-582 (EP-031), decisões do dono de 27 e 28/10; ADR 0226.

RN-924 — A correção de gate que chega ao Infra Lead com turno em curso é durável​

  • Regra: revisa a RN-617, no molde da fila da RN-673. InfraLeadServer.correct/3 grava infra.correction_queued (id, gate, reason, diagnóstico até 2 000 caracteres) ANTES do cast e, sem processo, acorda o agente; iniciar_correcao/2 grava infra.correction_delivered (id). O init/1 reconstrói as pendentes (queued menos delivered) e as entrega depois do turno retomado e da fila de mensagens, e o Rehydrator acorda o Infra Lead que tem correção pendente no boot. Entregue não é reentregue. O Infra Lead também tem registrar_plano e a retomada da RN-920.
  • Onde: apps/engine/lib/engine/infra/infra_lead_server.ex:1651 (correct), :414 (iniciar_correcao), :200 (init)
  • Teste: apps/engine/test/engine/infra/infra_lead_server_test.exs:1980 (correct/3 grava infra.correction_queued antes do cast; a pendente sobrevive ao reinício e a entregue não é reentregue)
  • Origem: AT-582 (EP-031); ADR 0226.

RN-925 — Cada container tem página de detalhe com o estado registrado e o observado separados e as URLs declaradas​

  • Regra: /containers/$projectId (carregada sob demanda) abre a partir de cada linha de /containers. Só lê o que já existe: a linha da visão geral (a RN-486: registrado e observado nunca se fundem; projeto "nunca provisionado" tem texto próprio, a imagem/versão, os recursos, a postura de rede da decisão vigente e o modo de execução) e as URLs de GET projects/:projectId/run-instructions, que a tela diz serem declaradas pela Infra e não verificadas, nunca verdes (RN-855); sem declaração, texto próprio. A leitura da página polla no ritmo de projeto (15 s, RN-632), nunca nos 5 s da lista. Subir, parar e remover são o MESMO componente da lista (AcoesDoContainer), com as mesmas recusas locais em texto (RN-521, RN-574, RN-610) e o papel do endpoint por roleAtLeast.
  • Onde: apps/web/src/routes/ContainerDetalhePage.tsx:20 (ContainerDetalhePage), apps/web/src/components/AcoesDoContainer.tsx:40 (AcoesDoContainer)
  • Teste: apps/web/src/routes/ContainerDetalhePage.test.tsx:92 (caminho feliz com ritmo de 15 s, nunca provisionado com URLs vazias, projeto fora da lista); apps/web/src/routes/ContainersPage.test.tsx cobre as ações extraídas
  • Origem: AT-609 (EP-030), fatia da AT-608.

RN-926 — A rota de providers com chave devolve só os nomes, e a chave é a do TITULAR​

  • Regra: GET workspaces/:workspaceId/llm-providers/credentialed (viewer, o mínimo da leitura de membros, RN-655) devolve { providers: LLMProviderName[] } com os providers de LLM em que o TITULAR do workspace (workspaces.created_by, a pessoa cuja credencial os agentes gastam, RN-058) tem credencial cadastrada. Só nomes, na ordem da lista canônica: nunca segredo, id, data nem key_id. ollama não tem credencial e nunca aparece; token de git (github/gitlab) também não. Credencial de outro membro não conta. É a fonte de "tem chave" das telas de providers e do catálogo (AT-607); a tela vem depois.
  • Onde: apps/api/src/application/use-cases/llm/list-credentialed-providers.use-case.ts:20 (execute), apps/api/src/interfaces/http/llm/models.controller.ts:80 (credentialed)
  • Teste: apps/api/test/application/use-cases/llm/list-credentialed-providers.use-case.spec.ts:44 (titular com duas chaves: só os dois nomes; a chave de outro membro não conta)
  • Origem: AT-607 (EP-026).

RN-927 — O runner grava a chave de API de LLM do usuário por POST /runner/llm-credentials, só com chave de máquina, sem sobrescrever sem confirmação​

  • Regra: POST /runner/llm-credentials (@RequirePatAuth(), PatAuthGuard) recebe { provider, apiKey, origem, substituir? } e delega ao mesmo UpsertUserCredentialUseCase (envelope com key_id, ADR 0158) para o usuário dono da credencial. Rota sem :projectId: o guard recusa com 403 credencial amarrada a projeto, e todo PAT é de projeto, então só a chave de dispositivo de MÁQUINA entra (a primeira rota em que ela escreve segredo de usuário). Provider fora de LLM_PROVIDER_NAMES_COM_CREDENCIAL é 400; credencial existente do provider sem substituir: true é 409 credencial_existente, sem tocar a existente. A resposta é { provider, substituida }. O valor nunca vai a log (a auditoria leva provider e origem), URL, evento, exceção nem resposta, e a rota não testa a conexão (ADR 0050). Só chave de API é importável; token de login por assinatura não (ADR 0230).
  • Onde: apps/api/src/application/use-cases/llm/import-runner-llm-credential.use-case.ts:22 (execute), apps/api/src/interfaces/http/runner/runner-llm-credentials.controller.ts:33 (RunnerLlmCredentialsController)
  • Teste: apps/api/test/application/use-cases/llm/import-runner-llm-credential.use-case.spec.ts (grava cifrado com key_id sem o valor em log/resposta; 409 sem substituir; substituir; provider inválido); 401/403 do guard em apps/api/test/interfaces/pat-auth.guard.spec.ts; route-surface.spec.ts
  • Origem: AT-611 (EP-026), ADR 0230.

RN-928 — O comando do app é argumento de um supervisor constante, e sem ele o container é o de sempre​

  • Regra: EspecificacaoDeContainer.comandoDoApp (opcional, até 300 caracteres, sem \n, \r nem NUL) troca o sleep infinity pelo supervisor CONSTANTE SUPERVISOR_DO_APP, que recebe o comando como $1 (molde da RN-807) e nunca o concatena: aspas, ; e $() do comando não escapam. O app sobe em background (log em /tmp/brabo-app.log, código em /tmp/brabo-app.exit) e o PID 1 segue sleep infinity. Ausente ou null, o docker run é byte a byte o anterior. O broker lê o campo SÓ da container-spec da api. (ADR 0228.)
  • Onde: packages/docker-port/src/spec-de-container.ts:351 (comandoDoAppValidado), packages/docker-port/src/docker-cli.ts:380 (SUPERVISOR_DO_APP), packages/docker-port/src/docker-cli.ts:785 (argsDeCriacao)
  • Teste: packages/docker-port/src/docker-cli.spec.ts:657 (NÃO-REGRESSÃO), packages/docker-port/src/docker-cli.spec.ts:676 (comando com ', ; e $() fica como argumento)
  • Origem: AT-610 (EP-030).

RN-929 — Porta publicada vai só ao loopback, e DNS e extra_hosts são IP literal e nome de lista fechada​

  • Regra: portas vira sempre -p 127.0.0.1:H:C (TCP; interface constante, sem opção), host de 1024 a 65535 fora de 3000, 4000, 5173, 5432, 7474, 7687, 8088, 8090 e 11434, no máximo 10 e sem duplicata. dns é IP literal v4/v6 (até 3). hostsExtras é { nome, ip } (até 10) com nome em [a-z0-9.-] de até 253 caracteres, nunca localhost, host.docker.internal nem nome de serviço da instalação, e IPv4 literal. Os três são recusados com rede none. inspect devolve portasPublicadas lidas de NetworkSettings.Ports, separadas do declarado (RN-468). (ADR 0228.)
  • Onde: packages/docker-port/src/spec-de-container.ts:384 (portasValidadas), packages/docker-port/src/spec-de-container.ts:405 (dnsValidado), packages/docker-port/src/spec-de-container.ts:415 (hostsExtrasValidados), packages/docker-port/src/docker-cli.ts:358 (portasPublicadasDe)
  • Teste: packages/docker-port/src/spec-de-container.spec.ts:237 (recusas), packages/docker-port/src/docker-cli.spec.ts:688 (-p 127.0.0.1, --dns, --add-host), apps/broker/src/operacoes.spec.ts:289 (vem da api)
  • Origem: AT-608 (EP-030), fatia do contrato.

RN-930 — A instância de QA é efêmera, só mexe no que tem brabo.papel=qa e sobe os serviços que a Infra declarou por digest​

  • Regra: a spec com qa: { chave, servicos } sobe brabo-qa-<workspace_dir_name>-<chave> sem --restart, com brabo.papel=qa e brabo.qa.instancia, sem app nem porta. stop, remove, inspect e exec com alvo de QA filtram brabo.papel=qa; homônimo sem o papel é "não gerenciado" e nada é removido. Cada serviço (até 6, imagem por digest @sha256: obrigatório, env de chaves [A-Z_][A-Z0-9_]*) sobe com --network container:<qa>, --cap-drop ALL, sem privilégio nem porta publicada, tetos constantes e --tmpfs; serviço compartilhado vive na rede privada --internal do projeto. O broker expõe /containers/:projectId/qa/:chave/{start,remove,exec} e lê os serviços da api, conferindo a chave. (ADR 0229.)
  • Onde: packages/docker-port/src/docker-cli.ts:572 (iniciarQa), packages/docker-port/src/docker-cli.ts:880 (argsDeServicoDeQa), packages/docker-port/src/spec-de-container.ts:439 (instanciaDeQaValidada), apps/broker/src/operacoes.ts:212 (startQa), apps/broker/src/servidor.ts:135 (rotaDeContainer)
  • Teste: packages/docker-port/src/docker-cli.spec.ts:756 (remove só alcança o papel qa), packages/docker-port/src/spec-de-container.spec.ts:256 (digest, chave, env), apps/broker/src/operacoes.spec.ts:321 (lista da api e chave da rota), apps/broker/src/servidor.spec.ts:405 (roteador fechado)
  • Origem: AT-612 (EP-030).

RN-931 — As telas de providers e do catálogo mostram só o que tem chave do TITULAR, com a cor do provider​

  • Regra: "ativo" é o provider LOCAL ou o que tem chave do titular do workspace (RN-058), lida da rota da RN-926. A tabela "O que cada provider sabe fazer" lista só os ativos, com o nome humano e a mesma cor do card de credencial (COR_DO_PROVIDER, fonte única) e uma linha dizendo quantos ficaram de fora e quais. O catálogo de modelos vira um painel com UMA ABA por provider ativo (o hub continua repartido por fabricante); busca, facetas e a barra de lote são globais e a seleção sobrevive à troca de aba. Provider SEM chave que ainda tem modelo ATIVO aparece como aba marcada "sem chave do titular", para dar para desativar o modelo (RN-043); os demais sem chave entram na linha "de fora" (RN-180). Carregando ou falhando a rota, mostra TODOS: "não sei" não é "não tem". Revisa o item 1 da RN-694: o catálogo não tem mais os grupos Local, APIs diretas e Hubs, e a busca que não casa na aba nomeia as abas que casam. O relatório do sync segue com todos os providers.
  • Onde: apps/web/src/lib/models.ts:363 (providerAtivo), apps/web/src/lib/models.ts:371 (catalogoDoProvider), apps/web/src/components/ModelCatalogSection.tsx:132 (useMemo), apps/web/src/routes/ProvidersPage.tsx:33 (deFora)
  • Teste: apps/web/src/routes/ProvidersPage.test.tsx:102 (só os com chave do titular, e a falha que mostra todos), apps/web/src/components/ModelCatalogSection.test.tsx:399 (aba "sem chave" com modelo ativo; seleção entre abas; busca nomeia a aba que casa), apps/web/src/lib/models.test.ts:126 (providerAtivo)
  • Origem: AT-607 (EP-026), tela; a rota é a RN-926.

RN-932 — A rede do container é artefato declarado por humano e só vale no container recriado​

  • Regra: artifact.container_network ({ ports[{host,container,protocol:'tcp'}], dns[], extraHosts[{name,ip}] }) é versionado, sem tabela, e o vigente é o de maior version. Só um humano maintainer o escreve (PUT /projects/:projectId/container/network; GET é viewer), com a MESMA régua do docker-port (host 1024-65535 fora das portas da instalação, até 10 portas sem duplicata, até 3 DNS por IP literal, até 10 hosts com IPv4 e nome fora dos reservados); recusa é 400 rede_invalida nomeando o campo. Escrever NÃO é efeito externo. A container-spec devolve ports, dns e extraHosts (ou null; runner sempre null, runner depois) e a mudança só vale em container recriado (RN-934). A api não importa o docker-port, então a régua é espelhada e a paridade é provada por teste.
  • Onde: apps/api/src/domain/containers/rede-do-container.ts:86 (validarRedeDoContainer), apps/api/src/interfaces/http/containers/containers.controller.ts:91 (declarar), apps/api/src/application/use-cases/containers/obter-spec-de-container.use-case.ts:135 (execute).
  • Teste: apps/api/test/contract/rede-do-container-paridade.spec.ts (paridade com o docker-port), apps/api/test/application/use-cases/containers/spec-e-observacao-de-container.use-case.spec.ts (spec devolve a rede; runner tudo null).
  • Origem: AT-608 (EP-030), ADR 0228.

RN-933 — run.app declara o comando do app e só o piloto automático dispensa a aprovação da PR de infra​

  • Regra: run de open_infra_pr aceita app: { command, port } opcional (comando de até 300 code points, uma linha, sem NUL; porta 1024-65535; null equivale a ausente, como no engine) validado na proposta e na execução; sem app, tudo é byte a byte o de antes. Com run.app, a regra ESPECÍFICA open_infra_pr: auto_approve (a semente do aceite) NÃO basta e a decisão vira require_approval; só a CURINGA auto_approve do agente (piloto automático, RN-603/RN-670) auto-aprova. A container-spec devolve comandoDoApp do run.app.command vigente, ou null.
  • Onde: apps/api/src/domain/infra/run-instructions.ts:77 (recusaDoRun), apps/api/src/domain/actions/decide.ts:614 (comAppDeclarado), apps/api/src/application/use-cases/actions/propose-action.use-case.ts:208 (comAppDeclarado), apps/api/src/application/use-cases/containers/obter-spec-de-container.use-case.ts:171 (comandoDoApp).
  • Teste: apps/api/test/domain/infra/run-app.spec.ts, apps/api/test/domain/actions/decide.spec.ts (sem app + específica = auto; com app + específica = aprovação; com app + curinga = auto).
  • Origem: AT-610 (EP-030), ADR 0228.

RN-934 — container_recreate remove e sobe de novo, no teto absoluto de container_remove​

  • Regra: container_recreate (maintainer) é proposed_action nunca auto-aprovável, nem com a curinga, e "sempre permitir" é recusado na fonte (mesma lista de container_remove). A execução faz remove e start pelo broker passando por running -> stopped -> removed -> provisioning -> running; recusa ou indisponibilidade do broker vira failed nomeado (se a subida falha, o motivo diz que o container já foi removido). runner é recusado nomeando "runner depois". A frase em aprovacoes.ts existe e o teste do web reprova tipo sem frase.
  • Onde: apps/api/src/domain/actions/decide.ts:597 (container_recreate), apps/api/src/application/use-cases/actions/execute-container-recreate.use-case.ts:44 (execute), apps/web/src/lib/aprovacoes.ts:117 (container_recreate).
  • Teste: apps/api/test/application/use-cases/actions/execute-container-recreate.use-case.spec.ts, apps/api/test/domain/actions/decide.spec.ts (teto), apps/web/src/lib/aprovacoes.test.ts.
  • Origem: AT-608 (EP-030), ADR 0228.

RN-935 — A Infra declara os serviços da instância de QA (run.qa), e a api guarda uma instância por tarefa com teto de 1 por projeto​

  • Regra: open_infra_pr aceita run.qa = { services: [...] } (opcional, null = ausente; até 6 serviços de nome único [a-z][a-z0-9-]{0,31}; image COM digest @sha256:<64 hex>; env até 20 variáveis [A-Z_][A-Z0-9_]* de uma linha; tmpfs até 3 caminhos absolutos; shared booleano). A mesma régua vale na proposta (400 run_invalido) e na execução, e quem declara é a Infra, não um catálogo do produto (decisão do dono, 29/10). A instância de uma tarefa é uma linha de project_qa_environments (provisioning|running|failed|removed, único (projeto, tarefa)), e um índice parcial único garante no banco UMA ativa por projeto: subir a de outra tarefa com uma ativa é 409 instancia_de_qa_ocupada. runner é recusado (409 qa_nao_suporta_runner, "runner depois"). A chave do container é o chaveDeQa(taskId) (8 primeiros [a-z0-9] do id, sem hífen), calculada só pela api.
  • Onde: apps/api/src/domain/infra/run-instructions.ts:160 (recusaDoQa), apps/api/src/domain/containers/instancia-de-qa.ts:15 (chaveDeQa), apps/api/src/db/schema/containers.ts:87 (projectQaEnvironments), apps/api/src/application/use-cases/containers/instancia-de-qa.use-case.ts:80 (start).
  • Teste: apps/api/test/domain/infra/run-qa.spec.ts, apps/api/test/application/use-cases/containers/instancia-de-qa.use-case.spec.ts.
  • Origem: AT-612 (EP-030), ADR 0229.

RN-936 — Subir a instância de QA é a ação qa_environment_start, semeada auto_approve para o QA; remover é automático e fora do teto do container_remove​

  • Regra: qa_environment_start (developer, payload { taskId }) é proposed_action semeada auto_approve para qa-automacao na ativação da execução; ao ser auto-aprovada ou aprovada, a api sobe a instância pelo broker (failed nomeado se o teto está ocupado, se o projeto é runner ou se o broker recusa). A REMOÇÃO não é proposta: POST /internal/projects/:projectId/qa-environments/:taskId/remove é idempotente e só alcança o que o broker rotula brabo.papel=qa, então não entra no teto absoluto de container_remove/container_recreate. O container-exec aceita alvo: { qa: true, taskId } vindo do engine (nunca do modelo) para rodar na instância; sem instância running devolve sucesso: false. A frase do tipo existe em aprovacoes.ts e o teste do web reprova tipo sem frase.
  • Onde: apps/api/src/domain/actions/decide.ts:196 (qa_environment_start), apps/api/src/application/use-cases/actions/execute-qa-environment-start.use-case.ts:25 (execute), apps/api/src/application/use-cases/containers/instancia-de-qa.use-case.ts:120 (remove), apps/web/src/lib/aprovacoes.ts:438 (qa_environment_start).
  • Teste: apps/api/test/application/use-cases/containers/instancia-de-qa.use-case.spec.ts, apps/api/test/domain/actions/decide.spec.ts, apps/web/src/lib/aprovacoes.test.ts.
  • Origem: AT-612 (EP-030), ADR 0229.

RN-937 — A container-spec de QA e a limpeza de órfãs​

  • Regra: GET /internal/projects/:id/container-spec?papel=qa&task=<ref> devolve o contexto normal com comandoDoApp: null, ports: null e qa: { chave, services } (os de run.qa, [] se ausentes). task é o id completo OU a chave de 8 (o broker da dev manda a chave), resolvido DENTRO do projeto: tarefa inexistente ou de outro projeto é 404, chave ambígua é 409 tarefa_ambigua. POST /internal/qa-environments/sweep recebe do engine os taskIdsEmVoo e remove toda instância ativa ou failed fora dessa lista. Lacuna declarada: o broker ainda não tem operação para remover o serviço compartilhado nem a rede brabo-qa-<wd>, que ficam até essa operação existir.
  • Onde: apps/api/src/application/use-cases/containers/obter-spec-de-container.use-case.ts:135 (execute), apps/api/src/application/use-cases/containers/instancia-de-qa.use-case.ts:140 (limparOrfas), apps/api/src/interfaces/http/internal/internal-qa-environments.controller.ts:90 (sweep).
  • Teste: apps/api/test/application/use-cases/containers/instancia-de-qa.use-case.spec.ts.
  • Origem: AT-612 (EP-030), ADR 0229.

RN-938 — O terminal só vai para a instância de QA quando o ator é o qa-automacao e a tarefa é do projeto​

  • Regra: a api repassa payload.alvo = { qa: true, taskId } de uma proposed_action terminal ao /internal/actions/execute do engine SOMENTE se o ator da ação é qa-automacao e o taskId pertence ao projeto da ação. Em qualquer outro caso (outro agente, alvo malformado, tarefa de outro projeto) o alvo é descartado com o aviso terminal.alvo_de_qa_descartado no log e o comando segue pelo caminho de sempre: nenhum outro agente desvia comando para a instância de QA.
  • Onde: apps/api/src/application/use-cases/actions/execute-terminal-action.use-case.ts:40 (alvoDeQa), apps/api/src/infrastructure/http-clients/api-to-engine-client.ts:166 (executeTerminalAction).
  • Teste: apps/api/test/application/use-cases/actions/alvo-de-qa-no-terminal.spec.ts.
  • Origem: AT-612 (EP-030), ADR 0229.

RN-939 — A página do container edita a rede só para maintainer, valida antes e recria por container_recreate​

  • Regra: a seção "Rede" da página de detalhe lê GET projects/:projectId/container/network (sem poll, staleTime de configuração; null tem texto próprio) e salva num PUT só, por botão. O controle fica inerte abaixo de maintainer (papel efetivo, roleAtLeast) com o motivo dito uma vez em texto. O cliente espelha a régua da api (RN-932) só para poupar a ida e volta; a recusa 400 rede_invalida aparece com o campo e a mensagem da api e 409 sem_sessao tem texto próprio. A tela diz que a porta é publicada só em 127.0.0.1 e que a configuração só vale com o container recriado. "Aplicar e subir de novo" PROPÕE container_recreate (RN-934: teto absoluto, sempre pede aprovação humana) e fica inerte em runner ("runner depois"), sem container registrado, sem sessão e com rede editada e não salva. As portas publicadas que o daemon observa (portasPublicadas) saem como http://127.0.0.1:<porta> com a ressalva de que nada garante que algo escute (RN-468): sem sonda e nunca verde.
  • Onde: apps/web/src/components/RedeDoContainer.tsx:25 (RedeDoContainer), apps/web/src/lib/rede-do-container.ts:30 (validarRedeEmTexto).
  • Teste: apps/web/src/components/RedeDoContainer.test.tsx (rede nula, salvar, recusa no cliente, 400 com campo, 409, papel, container_recreate, runner, portas observadas).
  • Origem: AT-608 (EP-030), ADR 0228.

RN-940 — O gate de QA-automação sobe uma instância de QA efêmera, testa nela e a remove​

  • Regra: em container/mounted, antes do laço o QaAutomacaoAgent propõe qa_environment_start (semeada auto_approve); a subida que falha termina o gate com desfecho nomeado de origem infra (RN-059). O terminal do QA leva alvo: {qa: true, taskId} vindo do ctx do agente, nunca do modelo, e roda na instância; os demais agentes não mudam. A instância é removida em sucesso, falha, exceção e teto esgotado, salvo laço parado esperando decisão. runner segue sem instância. O boot e o tick do GateRescuer chamam o sweep com as tarefas de gate_states qa não estacionadas; falha só loga. run.qa é validado no engine com a mesma régua da api (RN-935).
  • Onde: apps/engine/lib/engine/gates/qa_automacao_agent.ex:78 (subir_instancia), apps/engine/lib/engine/gates/qa_automacao_agent.ex:120 (com_remocao), apps/engine/lib/engine/harness/hooks/action_pipeline.ex:46 (maybe_put_alvo), apps/engine/lib/engine/gates/gate_rescuer.ex:102 (varrer_instancias_de_qa), apps/engine/lib/engine/infra/instrucoes_de_execucao.ex:133 (qa).
  • Teste: apps/engine/test/engine/gates/qa_automacao_agent_instancia_test.exs, apps/engine/test/engine/infra/instrucoes_de_execucao_qa_test.exs.
  • Origem: AT-612 (EP-030), ADR 0229; depende da api (#1146).

RN-941 — O serviço de QA roda com usuário e comando declarados, nunca como root, e leva o nome da instância mais o do serviço​

  • Regra: revisa a RN-930 e a RN-935 depois da prova com Docker real de 29/10/2026 (AT-612), que achou três defeitos. (1) O serviço PRÓPRIO era criado com o nome da própria instância (Conflict): agora é <instância>-<serviço>, o que removerServicosDeQa e o rótulo brabo.qa.dono já esperavam. (2) Postgres sob --cap-drop ALL sai com chown/chmod negados: o serviço ganha user ("<uid>:<gid>", inteiros diferentes de 0, sem zero à esquerda, nunca root) que vira --user, e os caminhos tmpfs montam com uid=, gid= e mode=0700 do mesmo dono. (3) Imagens que não sobem sozinhas: o serviço ganha command (1 a 16 argumentos de até 200 caracteres, sem \0, \n ou \r), passado como argumentos DEPOIS da imagem, nunca por shell. null equivale a ausente. A contenção não muda (--cap-drop ALL, sem --privileged/--cap-add, sem porta publicada, tetos constantes). A régua mora nos três lugares (docker-port, api, engine) e nenhum deles aceita 0.
  • Onde: packages/docker-port/src/spec-de-container.ts:453 (usuarioDeServicoValidado), packages/docker-port/src/docker-cli.ts:880 (argsDeServicoDeQa), apps/api/src/domain/infra/run-instructions.ts:160 (recusaDoQa), apps/engine/lib/engine/infra/instrucoes_de_execucao.ex:133 (qa).
  • Teste: packages/docker-port/src/docker-cli.spec.ts (nome, --user, dono do tmpfs, comando), packages/docker-port/src/spec-de-container.spec.ts (recusa de 0:0, 70:0, 70, comando grande/com quebra), apps/broker/src/operacoes.spec.ts, apps/api/test/domain/infra/run-qa.spec.ts, apps/engine/test/engine/infra/instrucoes_de_execucao_test.exs; prova com Docker real: postgres:17-alpine com 70:70 aceita conexão em localhost:5432 e busybox httpd responde em localhost:9000 de dentro da instância.
  • Origem: AT-612 (EP-030), ADR 0229.

RN-942 — O recorte do Jev nunca tira do PO a ferramenta de declarar dúvida​

  • Regra: estende a RN-758. O piso do PO ganha declare_open_question, porque a regra 3 das obrigações do PO manda declarar a dúvida em vez de inventar ou omitir, e o recorte do Jev (RN-625) a tirava de todo passo depois do primeiro: o PO punha a lacuna na borda da história e nenhuma artifact.open_question nascia. Continua valendo que o piso só devolve o que está no catálogo do passo.
  • Onde: apps/api/src/domain/llm/tool-router.ts:311 (PISO_DO_MENU).
  • Teste: apps/api/test/application/use-cases/llm/decidir-ferramenta-do-passo.use-case.spec.ts (PO recortado mantém declare_open_question; catálogo sem ela não a ganha).
  • Origem: AT-615 (EP-030).

RN-943 — O bloco de pendências do chat se chama "Pendências deste projeto"; só a aba Executores diz "de outras sessões"​

  • Regra: refina a RN-626, a RN-825, a RN-826 e a RN-861. Dentro do bloco do chat moram cinco filas, e só aprovações e merges são filtradas por "outra sessão"; perguntas em aberto, PRs prontas e tarefas bloqueadas são do projeto inteiro (a pergunta da PRÓPRIA sessão aparece ali de propósito, porque o aviso do composer lê a mesma query). Por isso, com filasDoChat (o chat) o título é "Pendências deste projeto" (pendencias.tituloDoProjeto, nos quatro locales); com filasDoChat={false} (aba Executores, só aprovações e merges) o título segue "Pendências de outras sessões deste projeto". Nenhuma fila é filtrada, nenhum teto muda e o data-testid não muda.
  • Onde: apps/web/src/components/PendenciasDeOutrasSessoes.tsx:316 (tituloDoProjeto).
  • Teste: apps/web/src/components/PendenciasDeOutrasSessoes.test.tsx (pergunta da própria sessão: cabeçalho "Pendências deste projeto", sem "outras sessões"; filasDoChat={false} mantém o título antigo).
  • Origem: AT-619 (EP-031).

RN-944 — O QA de automação recebe o pedido da volta anterior, o plano é lido por tipo e o dev sabe onde o write_file escreve​

  • Regra: (a) o changes_requested mais recente do ator qa para a MESMA task entra no dev_context como :pedido_anterior, e o QaAutomacaoAgent acrescenta à mensagem a lista de itens e a instrução de dizer no resumo se cada um foi atendido; sem pedido anterior (primeira volta, approved, outra task, lista vazia) a mensagem é a de sempre. É insumo, nunca régua nova de veredito (molde do ADR 0192): o teto de correções (RN-015) e o qa_verdict único por ciclo não mudam. (b) plano_ja_emitido/3 lê por tipo (Reidratacao.eventos_do_tipo/3), não a janela dos 200 mais recentes: o plano da task não sai da janela nem é pago de novo (cumpre a RN-674). (c) O ambiente de dev-*/qa-* diz que write_file/read_file usam caminho relativo à raiz do repositório e que cd no terminal não persiste (cada chamada é sh -c novo em System.cmd ... cd: dir, terminal_executor.ex).
  • Onde: apps/engine/lib/engine/gates/qa_lead_server.ex:762 (pedido_anterior), apps/engine/lib/engine/gates/qa_lead_server.ex:743 (plano_ja_emitido), apps/engine/lib/engine/gates/qa_automacao_agent.ex:317 (com_o_pedido_anterior), apps/engine/lib/engine/harness/ambiente_do_agente.ex:102 (montar).
  • Teste: apps/engine/test/engine/gates/qa_automacao_agent_test.exs, apps/engine/test/engine/gates/qa_lead_server_test.exs, apps/engine/test/engine/harness/ambiente_do_agente_test.exs.
  • Origem: AT-616 (EP-030), TP-01 (21ª) de 30/10/2026.

RN-945 — Os agentes de QA nomeiam o corte pelo teto de saída em vez de "o modelo parou"​

  • Regra: estende a RN-780 aos dois agentes de QA: quando o ToolLoop devolve corte_pelo_teto: true, QaEstrategiaAgent e QaAutomacaoAgent narram, com origem modelo, que a resposta foi só raciocínio e o teto de saída a cortou duas vezes (texto único em Engine.Gates.MotivoDoCorte). Sem o marcador, o diagnóstico antigo ("parou sem chamar ...") continua. A falha do plano segue sem segurar a revisão (RN-674) e o teto de iterações não muda.
  • Onde: apps/engine/lib/engine/gates/qa_estrategia_agent.ex:265 (diagnostico_de_parada), apps/engine/lib/engine/gates/qa_automacao_agent.ex:421 (diagnostico_de_parada), apps/engine/lib/engine/gates/motivo_do_corte.ex:8 (texto).
  • Teste: apps/engine/test/engine/gates/qa_estrategia_agent_test.exs, apps/engine/test/engine/gates/qa_automacao_agent_test.exs.
  • Origem: AT-618 parte 1 (EP-030), TP-01 (21ª) de 30/10/2026.

RN-946 — O corte pelo teto no meio do texto também é nomeado pelos agentes de QA​

  • Regra: estende a RN-945/RN-780: resposta truncated COM texto e sem chamada (o raciocínio comeu o teto e o texto saiu cortado) não é "o modelo parou": o ToolLoop devolve corte_com_texto: true, chave PRÓPRIA (não corte_pelo_teto, que o dev agent e a RN-780 casam), e QaEstrategiaAgent e QaAutomacaoAgent narram, com origem modelo, que o teto cortou a resposta no meio do texto antes da ferramenta de término (MotivoDoCorte.texto_com_texto). Não há retentativa nova (mudaria laço e custo); resposta não cortada com texto segue "parou sem chamar ...". A mesma lacuna no dev agent fica fora.
  • Onde: apps/engine/lib/engine/harness/tool_loop.ex:239 (parada_sem_chamada), apps/engine/lib/engine/gates/qa_estrategia_agent.ex:265 (diagnostico_de_parada), apps/engine/lib/engine/gates/qa_automacao_agent.ex:421 (diagnostico_de_parada), apps/engine/lib/engine/gates/motivo_do_corte.ex:9 (texto_com_texto).
  • Teste: apps/engine/test/engine/harness/tool_loop_test.exs, apps/engine/test/engine/gates/qa_estrategia_agent_test.exs, apps/engine/test/engine/gates/qa_automacao_agent_test.exs.
  • Origem: AT-620 (EP-030), TP-01 (22ª) de 31/10/2026.

RN-947 — O comando do agente no exec não aparece na linha de comando de nenhum processo do container​

  • Regra: o comando do exec da DockerPort viaja por -e BRABO_EXEC_CMD=<comando> e não mais no argv: o embrulho da RN-807 o lê, faz unset e set --, e o roda por (eval "$c") em subshell (mesmo grupo, mesmo PID gravado, exit e set -e contidos, exit code preservado). O argv do container fica só /bin/sh -c <embrulho> brabo-exec <marcador>. Medido no uso real: os 36 comandos do QA de automação com pkill -f morriam com 143, porque o padrão casava o argv do próprio sh. Fora, declarado: o runner no host (apps/runner/src/exec.ts), que a RN-507 já condiciona a container running.
  • Onde: packages/docker-port/src/docker-cli.ts:299 (EMBRULHO_DO_EXEC).
  • Teste: packages/docker-port/src/docker-cli.spec.ts ("o comando que estoura volta timedOut e MORRE dentro do container", contra Docker real: auto-pkill -f sobrevive, exit 3 volta 3, o comando não aparece no ps).
  • Origem: AT-622 (EP-030).

RN-948 — Higiene do entregável: banco SQLite fora do commit, pasta dos comandos dita, rotas do README preservadas​

  • Regra: estende a RN-761, a RN-900 e as RN-706/RN-850. Três partes, sem gate novo. (1) Segunda lista, ARQUIVOS_DE_DADOS (*.db, *.db-*, *.sqlite, *.sqlite-*, *.sqlite3, *.sqlite3-*), nas MESMAS três camadas da RN-761: o .gitignore base do bootstrap, a exclusão :(exclude,glob)**/<padrão> no git add do dev agent e um achado próprio do SecOps ("arquivo de dados em tempo de execução commitado", que nomeia o caminho). O teste que cruza as listas da api e do engine cruza as duas. (2) A mensagem de ambiente do agente com módulo diz que npm/pip/mix rodam na pasta do módulo na MESMA chamada, nunca cd … 2>/dev/null; (o cd falho caía na raiz e deixou um package-lock.json vazio), dentro do teto de 1.500 caracteres. (3) O kickoff do dev nomeia o README.md da RAIZ, sem repetir a seção em README de pasta, e write_file recusa reescrever um README que perde cabeçalho de rota (### GET /links) presente no antigo, no molde da RN-900. Declarado: repositório já bootstrapado não ganha o .gitignore novo; fixture .db intencional fica fora do commit (renomeie para versioná-la); a guarda não cobre escrita pelo terminal. Fora desta RN: expires_at × expiresAt.
  • Onde: apps/api/src/application/use-cases/git/bootstrap-templates.ts:29 (ARQUIVOS_DE_DADOS), apps/engine/lib/engine/actions/diretorios_de_dependencia.ex:37 (argumentos_do_add), apps/engine/lib/engine/gates/secops_agent_server.ex:162 (dependencias_commitadas), apps/engine/lib/engine/harness/ambiente_do_agente.ex:178 (modulo), apps/engine/lib/engine/harness/tools/write_file.ex:62 (recusa_de_secao_perdida), apps/engine/lib/engine/dev/dev_agent_server.ex:604 (initial_message).
  • Teste: apps/engine/test/engine/actions/diretorios_de_dependencia_test.exs (lista cruzada com a api, add real que estagia só src/a.js, SecOps nomeia o .db), apps/api/test/application/use-cases/git/bootstrap-plan.spec.ts (os seis padrões no .gitignore), apps/engine/test/engine/harness/ambiente_do_agente_test.exs, apps/engine/test/engine/harness/tools/write_file_test.exs, apps/engine/test/engine/dev/report_done_instrucao_test.exs.
  • Origem: AT-623 (EP-031), entregável entregavel3 (commits 956fa5c, 8e593b0).

RN-950 — O 9Router recebe as ferramentas, o vínculo confere a capability do provider e ferramenta sem capability lança​

  • Regra: revisa a RN-909 e completa a RN-040 com a SEGUNDA camada do ADR 0041. (1) nineRouterConfig declara toolCalling: true, provado por smoke com chave real em 31/10: 3/3 chamadas em cc/claude-haiku-5-5 voltaram com tool_calls estruturado, 10/10 consistente a temperatura 0 e laço de 3 voltas íntegro. Antes, a capability false fazia o buildBody descartar tools em silêncio e o Criativo escrevia a chamada como XML no fio (AT-637). (2) assertModelFitsBindingScope confere também o PROVIDER nos escopos agent e area: vínculo cujo provider declara toolCalling: false é recusado com 422 (ModelNotFitForAgentScopeError, causa provider), com frase própria. workspace, project e session seguem livres. (3) buildBody com ferramentas pedidas e capability false LANÇA erro nomeado em vez de descartar. Providers afetados: só o opencode-zen tem toolCalling: false além do 9Router de antes; vínculos novos de agente/área a ele passam a ser 422 e o chat sem ferramenta segue igual. Vínculo antigo a ele continua gravado e o turno do agente falha nomeado.
  • Onde: apps/api/src/infrastructure/llm/nine-router-provider.ts:171 (nineRouterConfig), apps/api/src/domain/llm/model-capabilities.ts:95 (assertModelFitsBindingScope), apps/api/src/application/use-cases/llm/set-model-binding.use-case.ts:56 (toolCalling), apps/api/src/infrastructure/llm/openai-compatible-provider.ts:617 (buildBody)
  • Teste: apps/api/test/domain/llm/model-capabilities.spec.ts (recusa por provider em agent/area; escopos livres), apps/api/test/infrastructure/llm/nine-router-provider.contract.spec.ts (capability true), apps/api/test/contract/llm-provider.contract.ts (provider sem capability lança nomeado); smoke manual em nine-router-provider.smoke.spec.ts.
  • Origem: AT-637 (EP-026), TP-01 de 31/10 e medição da AT-631.

RN-966 — O 9Router 0.5.112 faz três coisas por trás: off desliga RTK/Headroom/Caveman/Ponytail e não o Custom Prompt; o OAuth do Claude redige o raciocínio; e camufla as ferramentas​

  • Regra: completa a RN-909 com o que o build 0.5.112 do 9Router faz por trás do gateway, medido no bundle e declarado sem código novo. (1) O cabeçalho x-9router-token-saver com valor off (sem caixa) desliga, juntos, o RTK, o Headroom, o Caveman e o Ponytail; o Custom Prompt NÃO passa por ele e segue como o painel o deixou. O provider manda o cabeçalho em toda chamada, e o efeito é confirmado pela medição da AT-631 (ok sem ele = 1 262 tokens). (2) Para a conta OAuth do Claude (cc/*) o 9Router manda redact-thinking-2026-02-12 em Anthropic-Beta: o pensamento volta redigido e sem reasoning_tokens, então a contagem de raciocínio do stream (RN-915) e a retentativa sem raciocínio (RN-916) ficam cegas nesse caminho, e reasoning_tokens grava ausente ("não disse", RN-666). (3) Com token sk-ant-oat o 9Router renomeia as ferramentas, acrescenta cerca de 20 ferramentas falsas do Claude Code e envia cabeçalhos de Claude Code; não há configuração que desligue isso. O Brabo não corrige nem desliga a camuflagem: o risco é o modelo chamar uma ferramenta falsa e os termos de uso, e por isso o TP-01 voltou ao OpenRouter.
  • Onde: apps/api/src/infrastructure/llm/nine-router-provider.ts:38 (NINE_ROUTER_TOKEN_SAVER_HEADER), apps/api/src/infrastructure/llm/nine-router-provider.ts:172 (nineRouterConfig)
  • Teste: apps/api/test/infrastructure/llm/nine-router-provider.contract.spec.ts:140 (cabeçalho off fixado no contrato). Os efeitos (2) e (3) vivem no bundle do 9Router, não no repositório: não há teste deles, e o smoke com chave é o único que os exercitaria.
  • Origem: AT-641 (EP-026), levantamento de 31/10 sobre o 9Router 0.5.112 (chunks/8895.js, chunks/8499.js, chunks/2380.js, chunks/2598.js), derivado da AT-631.

RN-951 — O fio da Sessão acompanha SEMPRE a última mensagem​

  • Regra: revê a RN-173, o item 4 da RN-751 e a RN-836 (decisão do dono de 31/10). Cada evento ou ação nova no fio o leva à última mensagem, mesmo que a pessoa tenha rolado para cima: a guarda dos 120 px e a "intenção de estar no fim" saíram, e o botão "↓ N novas" (com o realce de aprovação) foi aposentado. O acompanhamento dispara por IDENTIDADE (seq do último evento e ids das ações, não pelo tamanho das listas, que não muda no teto de 200), pelo texto do streaming e por ResizeObserver no CONTEÚDO e no CONTAINER (faixa de atividade, composer e pendências mudam a altura). ?highlightEvent segue levando ao evento citado na abertura, mas não desliga o acompanhamento: a primeira mudança depois o devolve ao fim. A rolagem é instantânea (block: 'end') e o foco não se move. Entre duas mensagens a pessoa pode rolar e ler; a próxima a traz de volta.
  • Onde: apps/web/src/lib/session-rolagem.ts:20 (identidadeDoFio), apps/web/src/lib/session-rolagem.ts:36 (useRolagemDoFio).
  • Teste: apps/web/src/lib/session-rolagem.test.tsx (mensagem nova com a pessoa acima, length igual com seq novo, ação nova, container que encolhe, highlightEvent, rajada, streaming, sem conteúdo).
  • Origem: AT-635 (EP-031).

RN-955 — GET interno com conexão envenenada do pool é repetido UMA vez​

  • Regra: complementa a RN-742 (AT-428). A resposta atrasada de um pedido que estourou o receive_timeout fica no socket de uma conexão do pool do Finch; o GET seguinte que a pega lê a resposta ANTIGA e lança CaseClauseError {:status, ref, _}, que a checagem de trabalho pendente da RN-064 lia como "api fora do ar" e encerrava a sessão ativa. O req_get repete o MESMO GET uma vez quando o erro é esse (o Finch já descartou a conexão); só GET, porque POST pode ter tido efeito. Erro de transporte verdadeiro NÃO é repetido, e a RN-064 não muda.
  • Onde: apps/engine/lib/engine/sessions/engine_api_client.ex:2155 (get_repetindo).
  • Teste: apps/engine/test/engine/sessions/engine_api_client_isolado_test.exs (repete e devolve; falha na segunda não tem terceira; timeout verdadeiro não repete).
  • Origem: AT-642 (EP-030).

RN-956 — readiness e offer-infra-handoff sobem o agente antes de chamar, como message​

  • Regra: depois do fechamento e da reabertura (RN-649) os conversacionais não estão de pé, e as duas rotas chamavam o servidor sem start_agent: o :noproc virava 500 mascarado por "Stale conn". Agora recebem projectId e sobem o Criativo/Arquiteto antes (idempotente); sem projectId, 400 nomeado.
  • Onde: apps/engine/lib/engine_web/controllers/agent_command_controller.ex:348 (readiness), :355 (offer_infra_handoff).
  • Teste: apps/engine/test/engine_web/controllers/agent_command_controller_test.exs (agente parado sobe; sem projectId é 400).
  • Origem: AT-642 (EP-030).

RN-957 — "aborted" é falha de origem infra​

  • Regra: complementa a RN-059 e a RN-810. "aborted" é o socket da resposta do provider morrendo no meio do stream; o frame final sem errorCode caía em codigo. FalhaDeTurno.origem/1 o classifica infra (sem tocar falha_de_rede?; a retentativa segue pela RN-864).
  • Onde: apps/engine/lib/engine/agents/falha_de_turno.ex:87 (origem), :110.
  • Teste: apps/engine/test/engine/agents/falha_de_turno_test.exs ("RN-957").
  • Origem: AT-642 (EP-030).

RN-960 — O GraphStore reconecta depois de boot sem Neo4j, e o aviso diz a causa​

  • Regra: refina o ADR 0099. Boot em que o Neo4j ainda não respondia deixava o driver null para a vida do processo. Agora, com config presente, a operação seguinte tenta conectar de novo, no máximo uma vez a cada 30 s; sem config nunca tenta, e onModuleInit segue sem lançar. O aviso do GraphProjector inclui a causa do erro do driver.
  • Onde: apps/api/src/infrastructure/graph/graph-store.ts:105 (reconectarSeNecessario), apps/api/src/application/graph-projection/graph-projector.ts:106 (drainOnce).
  • Teste: apps/api/test/infrastructure/graph/graph-store.spec.ts ("RN-960"), apps/api/test/application/graph-projection/graph-projector.spec.ts ("RN-960").
  • Origem: AT-642 (EP-030).

RN-962 — O compose do entregável usa volume nomeado, nunca bind-mount relativo​

  • Regra: o Infra Lead pede VOLUME NOMEADO para dados persistentes e .env.example só com as variáveis obrigatórias, sem valor. propose_infra_pr recusa, ANTES de abrir a PR, arquivo de compose com bind-mount relativo (- ./pasta:/destino ou source: ./pasta): a pasta nasce root na primeira subida e o app non-root não grava nela. A recusa é resultado de ferramenta (entra no laço, RN-163) e registra tool.result com ok: false.
  • Onde: apps/engine/lib/engine/infra/infra_lead_server.ex:675 (recusa_de_bind_mount_relativo).
  • Teste: apps/engine/test/engine/infra/infra_lead_server_test.exs (volume nomeado passa; formas curta, com aspas e longa são recusadas).
  • Origem: AT-644 (EP-031).

RN-961 — O aviso de recorte diz que as ferramentas fora dele podem ser pedidas neste passo​

  • Regra: revisa a RN-759 (decisão do dono de 31/10, AT-643). O aviso system efêmero do passo recortado não diz mais que as ferramentas fora do recorte "voltam nas próximas voltas" nem que ela "volta no seguinte": o recorte é do PASSO, e a RN-759 mandava o agente esperar por um passo que, para o modelo, pode não vir. O texto novo diz a verdade do ADR 0179: as demais seguem disponíveis e podem ser pedidas neste mesmo passo, e o agente não deve concluir que não tem a ferramenta nem encerrar o turno esperando por ela; se precisar de uma delas e responder sem ferramenta, o passo é refeito com o catálogo completo (a repetição da regra 10 do ADR 0179, pelo mecanismo que a fachada já tem). Não muda o piso, a política do Jev nem a montagem do menu: só o texto do aviso.
  • Onde: apps/api/src/domain/llm/tool-router.ts:412 (avisoDeRecorte).
  • Teste: apps/api/test/domain/llm/aviso-de-recorte.spec.ts (fixa o texto novo e reprova o antigo, "voltam nas próximas voltas" e "volta no seguinte").
  • Origem: AT-643 (EP-030): o write_file e o terminal saíam do menu do dev no Jev, e o aviso mandava esperar por uma volta que o modelo não tinha como pedir.

RN-959 — O 503 do 9Router que embrulha um 4xx do upstream é classificado pelo status de dentro​

  • Regra: completa a RN-864 e a RN-909. Quando todas as contas do 9Router estão em cooldown por um 4xx do upstream, ele responde 503 com [<status>]: <msg> (reset after Ns). O provider lê o status de dentro: 404 vira model_not_found, 401/403 auth, 402 insufficient_credit (RN-730), 429 rate_limit. 5xx de dentro, ausente ou corpo sem reset after seguem upstream passageiro. A forma do corpo vem da leitura do bundle do 9Router 0.5.112; o smoke com chave a confirma.
  • Onde: apps/api/src/infrastructure/llm/nine-router-provider.ts:153 (classificarErro9Router), aplicado por apps/api/src/infrastructure/llm/openai-compatible-provider.ts:305 (classificarErroHttp).
  • Teste: apps/api/test/infrastructure/llm/nine-router-provider.contract.spec.ts (503 com cada status interno e os três casos que seguem upstream); apps/engine/test/engine/agents/falha_de_turno_test.exs (model_not_found não é passageira).
  • Origem: AT-639 (EP-026).

RN-967 — A faixa do turno mostra o plano que o agente registrou no turno em curso​

  • Regra: a faixa de atividade do turno (RN-460) mostra, acima dos passos, o último agent.plan do agente do turno com seq posterior à abertura dele (o último agent.status: working), derivado dos eventos JÁ carregados: nenhuma requisição nova (RN-632) e nenhum estado por token (RN-639). Ferramentas e plano seguem os padrões Tool e Plan; a fila de uma mensagem segue o Queue, com o cancelar inerte enquanto cancela (RN-673, não otimista).
  • Onde: apps/web/src/lib/plano-do-turno.ts:9 (planoDoTurno), apps/web/src/components/TurnActivityStrip.tsx:47 (TurnActivityStrip).
  • Teste: apps/web/src/lib/plano-do-turno.test.ts, apps/web/src/components/ai-elements/passos.test.tsx.
  • Origem: AT-652 (EP-031).

RN-968 — O formulário de perguntas estruturadas é um questionário passo a passo​

  • Regra: o formulário aberto de chat.structured_question (RN-162) é o Questionnaire do shadcn: uma pergunta por vez, com "Pergunta N de T", Anterior/Próxima e "Enviar respostas" no último passo (desabilitado até TODAS estarem respondidas, a régua da api). A lista (select) vira escolhas (radio) e a saída por texto livre (RN-171) é um campo AO LADO delas, sem sentinela: escrever no campo é a resposta e escolher uma opção descarta o texto; allowOther: false não tem o campo. textarea usa o QuestionnaireInput com render de textarea. O estado segue controlado pelo card, então o que viaja para a api é o mesmo Record<id, texto> (RN-712), a ordem do envio é a de sempre (RN-174, RN-578) e as respostas ficam na tela no 409 sessao_encerrada (RN-581). Enter que confirma o IME não avança nem envia (RN-830). O cartão respondida e o painel de pendências (artifact.open_question) não mudam. Revisa a apresentação da RN-162/RN-171, não a regra.
  • Onde: apps/web/src/routes/StructuredQuestionCard.tsx:73 (StructuredQuestionCard), apps/web/src/components/shadcn/questionnaire.tsx:8 (Questionnaire).
  • Teste: apps/web/src/routes/SessionPage.perguntas-estruturadas.test.tsx, apps/web/src/components/shadcn/registro.test.tsx.
  • Origem: AT-653 (EP-031).

RN-971 — A paleta de comandos Ctrl+K/⌘K busca projetos, sessões e ações só do que já está em memória​

  • Regra: Ctrl+K (⌘K no macOS) abre a paleta de qualquer tela autenticada, menos com o foco no terminal xterm, durante composição de IME (RN-830) ou com o evento já tratado. Ela lista projetos, abas do projeto aberto, a sessão mais recente de cada projeto, as sessões do projeto aberto que já estão no cache e as ações da sidebar (novo projeto, tema, gastos, containers, providers, conta, sair), lendo SÓ o que o Shell já tem e o cache do TanStack Query: abrir, filtrar e fechar a paleta não faz requisição (RN-632). O grupo de sessões diz que é recorte (RN-180). Sem dependência nova: Autocomplete do Base UI dentro do Dialog, carregada por React.lazy.
  • Onde: apps/web/src/lib/paleta-de-comandos.ts:33 (itensDaPaleta), apps/web/src/lib/paleta-de-comandos.ts:70 (deveAbrirPaleta), apps/web/src/components/PaletaDeComandos.tsx:38 (PaletaDeComandos).
  • Teste: apps/web/src/lib/paleta-de-comandos.test.ts, apps/web/src/components/PaletaDeComandos.test.tsx (zero fetch).
  • Origem: AT-656 (EP-031).

RN-972 — O código no chat e na aba Código é realçado pelo shiki​

  • Regra: o bloco de código do chat (CodeFence, agora o Code Block do shadcn com botão Copiar) e o arquivo da aba Código são realçados pelo shiki com o tema próprio dos --syntax-* (sem hex; os dois temas saem do data-theme e o contraste dos oito papéis segue medido, RN-185). O shiki é carregado sob demanda (import() dinâmico, motor de regex em JS, gramática por linguagem) e fica fora do JS inicial; enquanto carrega, e para linguagem sem gramática, vale o realce por regex da RN-158. Só o tokenizador troca: o recolher de 20/8 linhas (RN-712), a virtualização e o minimapa da aba Código (RN-239..242) não mudam. Copiar diz "Copiado" por 2 s e a falha da área de transferência vira texto, nunca silêncio.
  • Onde: apps/web/src/lib/realce-shiki.ts:61 (carregarRealce), apps/web/src/lib/tema-shiki-brabo.ts:19 (temaShikiBrabo).
  • Teste: apps/web/src/lib/realce-shiki.test.ts.
  • Origem: AT-657 (EP-031).

RN-970 — Na árvore do time, cada delegação de um lead aparece como subagente​

  • Regra: na árvore do time (Visão geral/Executores), cada delegação gravada por um lead (delegation.*) aparece no ramo dele como subagente — nome, desfecho (efetivada/falhou/dispensada, falha com a origem) e se o subagente está trabalhando agora —, derivada dos eventos já carregados, sem requisição nova; delegação fora da janela não aparece. O ramo próprio do subagente continua.
  • Onde: apps/web/src/lib/timeline-tree.ts:287 (subagenteDoMarco), apps/web/src/components/AgentTimelineTree.tsx:51 (AgentTimelineTree).
  • Teste: apps/web/src/lib/timeline-tree.test.ts, apps/web/src/components/ai-elements/passos.test.tsx.
  • Origem: AT-655 (EP-031).