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
| actor | who it is | authority |
|---|---|---|
| User | the person. Roles: viewer, developer, maintainer, owner | final decision on any action with external effect; merge is theirs exclusively |
| Agent | long-running process with a role (Creative, PO, Architect, Dev, Infra, QA, SecOps, Psychologist, Anamnesis) | proposes; never decides alone what policy doesn't allow |
| System | api and engine | enforces 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:
closingis 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(tablesession_events) - Test: verified on restore —
docker/backup/restore.shfails ifcount(*) ≠ max(seq) − min(seq) + 1ormin(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:
kindclassifies creation INTENT. Acriativasession that never activated execution is not the current execution session, and the event-based derivation keeps not looking atkind— 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.activatedon aconsultivasession 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 samerenameSessionand the samerotuloDaSessao. - 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 recordedkind) and:98(the CTA creating in the tab'skind),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
consultivasession 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.activatedwins" 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, theagentId),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(formerlySessionPage.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.callis 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 durableagent.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 turnhandle_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?/1before 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
fromto 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.confirmedrecorded 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 stillapproved. Counting approvals bystatus = 'approved'gives the wrong number — the correct criterion isdecided_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:
dodnot empty — Definition of Donedornot empty — Definition of Ready- at least 1 functional requirement (
rf) - 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 inapplication/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_useris 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 = 3inapps/api/src/application/use-cases/execution/record-gate-verdict.use-case.ts:21; applied inactivate-execution.use-case.ts:89and, in the engine, inqa_lead_server.ex:268andsecops_agent_server.ex:142 - Test:
apps/engine/test/engine/gates/qa_automacao_agent_test.exs(the specialization returns{:blocked, ...}without calling the api) andqa_lead_server_test.exs(the Lead NEVER callsrecord_gate_verdictin 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.exandqa_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(tabledelegations, enumfailure_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.tsandrecord-infra-gate-verdict.use-case.spec.tspass 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(taskIdoptional) - 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 theExecuteInfraPrUseCase/InfraGateRunnercontract didn't change:execute-infra-pr.use-case.spec.tsandinfra_gate_runner_test.exspass 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 inqa_lead_server.ex(run_area/3,apply_gate_result/6) andsecops_agent_server.ex(run_secops/3,apply_verdict/6), andapps/engine/lib/engine/workers/gate_rescue_scheduler_worker.ex - Test:
apps/engine/test/engine/gates/gate_rescuer_test.exs— kills a realQaLeadServerwith the cycle in flight (DynamicSupervisor.terminate_child/2, the durable row surviving the process) and provesGateRescuer.run/0reconnects the area on its own up to a real outcome; the scenario in the statement (lostrun_secops) and the lostcorrectreturn, both with REAL process and dispatch, noFakeGateDispatcher; 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/:idcom 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.
rostertraz 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" é
seqguardado 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 → afterSeqdevolve 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 —
seqestritamente maior que o dele; - o teto de 50 eventos é por projeto, o mesmo que
GET .../eventsaplica semlimit. 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
latesttraz 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
seqsobre eventos imutáveis (nunca há UPDATE em tabela de evento):staleTime: Infinitye nenhumrefetchInterval. 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 comafterSeqcontí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:
latestSeqmenos 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,ramosAbertosPorPadraocom 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
undefinedno mapa de ícones que derrubava a árvore inteira do React), e o teste lê odecide.tsdo 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 porapps/web/src/components/ApprovalCard.tsx(Aprovações + chat da sessão,COM_CORPO_PROPRIO,previewConteudopara ocontentdewrite_file) eapps/web/src/components/HypothesisCard.tsx(Insights); colapso peloDisclosuredeapps/web/src/components/ui/Disclosure.tsx - Teste:
apps/web/src/lib/aprovacoes.test.ts(lêACTION_TYPESdeapps/api/src/domain/actions/decide.tse 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_filecom corpo próprio aberto por padrão e truncamento do preview,command/path/contentvazios 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 deProjectOverviewTab.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, áreaqainteira 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_changedque traz QA (Fase 4a,rosterFactsFromEvents), mas fica na Visão geral.dev-leadestá 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— blocohandoff.offered,offeredHandoffEventSeq;isActivesubiu para antes douseMemo),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ó nodev-lead);apps/web/src/routes/SessionPage.pista-e-status.test.tsxatualizado 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=executoresde 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— blocobacklog.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=backlogde 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— blocosbacklog.story_promotion_proposed/backlog.story_promotion_returned,handlePromoteStory/handleReturnStory, modal de motivo reusandoModal/Textarea) - Teste:
apps/web/src/routes/SessionPage.promocao-inline-e-volta.test.tsx(card com os botões, promover chamapromoteStoriescom ostoryIddo payload, devolver exige motivo e chamareturnStory, história resolvida porstory_transitionedposterior vira divisor mudo,story_promotion_returnednarra 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, ouseEffectdepensandoVisivellogo abaixo deagenteExibido, e o bloco de sucesso dehandleReadiness) - 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 detool.call/tool.resultesconde;eventsQuery.isPendingsegura 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), eapps/web/src/routes/SessionPage.readiness-turno-preso.test.tsx(confirmReadinessresolvendo semonAgentDonereconcilia o estado do mesmo jeito queSessionPage.turno-preso.test.tsxjá prova prahandleSend) - 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ó Infraofferednã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(activateExecutionganhouoriginSessionId) - Teste:
apps/web/src/routes/SessionPage.handoff-devlead-e-colapso.test.tsx, describe "problema 2" — clique chamaactivateExecutioncomsessionId; 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:
- o agente já passou o bastão — existe um handoff dele (
fromAgent) comstatus: 'accepted'(a mesma verdade que ohandoff.accepteddo event log grava, sem precisar reconstruir por junção de evento); e - nenhuma ação dele está
pending— a checagem é poractor.idem TODAS asactionsda 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.tscomo 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+ tabelaanamnese_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 emapps/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 | |
|---|---|
ok | o provider aceitou |
recusado | o provider rejeitou — carrega o motivo dele |
nao_suportado | nã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.tsetest/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) eapps/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" (< 150nó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 pacotecanvas), 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 modolocalnã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 emprovisioning, 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/containerIdda 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 deSocialLoginCallbackUseCase - 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, blococatch) - 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_planganhou pipeline de execução — aprovar o plano grava o módulo das tarefas (RN-678) e ATIVA a execução; a ação terminaexecutedoufailed, 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— ofromé respondido no aceite, e a suspensão aqui descrita mudou só o FECHO do turno (awaiting_approvalem vez deagent.done), não mais o momento da resposta. Auser_messagerecusada 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:
Engine.Agents.DevLeadTools.run/2(apps/engine/lib/engine/agents/dev_lead_tools.ex:81-107) chamaEngineApiClient.propose_action/5em vez deappend_event/3, e devolve{:ok, texto}(statusexecuted/auto_approved/approved— os três contam como sucesso, porquepropose_execution_plannão tem execute-* pipeline própria e a aprovação manual fica em"approved"para sempre),{:pending, action_id}ou{:error, texto}.Engine.Agents.DevLeadServer.run_turn/2(apps/engine/lib/engine/agents/dev_lead_server.ex, bloco de despacho detool_calls) usaEnum.reduce_whilepara PARAR no primeiro:pending, sem processar as chamadas seguintes nem recursar — e devolve ostatecom a chave:aguardando_aprovacaosetada (%{action_id:, tool_call_id:, tool_name:, remaining:}, oremainingjá descontando a iteração suspensa contra o teto). A mensagemrole: "tool"NÃO entra emstate.messagesnesse momento — gravar "pending" ali mentiria pro modelo que o comando já respondeu isso (mesmo raciocínio do dev agent,Engine.Harness.Hooks.ActionPipeline).Engine.Agents.TurnoAssincrono.tratar_resultado/2(apps/engine/lib/engine/agents/turno_assincrono.ex) responde aofromdo mesmo jeito e na mesma hora de sempre — é o que rompe o bloqueio síncrono no momento certo —, mas em vez definalizar/1(que emiteagent.doneeagent.status: idle) chamasuspender/1: sóagent.status: awaiting_approval, semagent.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 carregaaguardando_aprovacao: nildesde oinit/1, então a chave em si está sempre presente.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 oagent.statusdo 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 deEngine.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(tipoprototipo_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 doistoAgentna 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áusulasstart/2/message/2/via_for/2de"staff"),apps/engine/lib/engine/application.ex(Engine.Agents.StaffSupervisorna á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 poruser_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):
- Sem plano ainda — dispara
Engine.Gates.Dispatcher.run_qa_estrategia/3(mesma indireção trocável em teste querun_qa/2/run_secops/2já 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 umrun/2síncrono não pode bloquear esperando o resultado sem acoplar os dois processos. - 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(schemathreat_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 derun_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 demedir-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:
- Funil de produto completo (ideação → commit).
sessionsnão temstoryId— RN-230 já declara a lacuna na aba Criativo. Fechá-la exige schema novo, fora do escopo desta frente (nenhuma migration). - 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.
- MTTR e change failure rate. Exigem sinal de INCIDENTE de produção
real — a mesma dependência que
docs/fluxo.ymljá registra parasecops-runtime/platform(status: proposto/planned, ativação sincronizada comDEPLOY_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; espelhaapps/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 demedir-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çãoimprimir, seção "Não medido");docs/fluxo.yml(camada_plataforma › platform › saidas_alvo, artefatotelemetria-consolidada, camponota) - 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 deimprimir, citandodocs/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#restoreem 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 deStoryRepositoryno 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 umaready/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 deexecute) - 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 comarea: 'dev'apontando pro module_map mais recente; sem module_map não grava (e não lança);RecordDelegationUseCasefalhando 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.confirmedenecessity.validatedno mesmo POST, com oproduct_briefproduzido 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 seguewarn.POST .../agents/criativo/validate-necessitycontinua 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— semproduct_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 semproduct_brief, habilitado com ele, some depois denecessity.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 descripts/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, chamaresolverConfigSmtp()no construtor),apps/api/src/application/use-cases/auth/auth-use-cases.module.ts(useFactorydoMailSender) - Teste:
apps/api/test/infrastructure/mail/smtp-config.spec.ts(mesmo padrão deauth-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 porproject_id(status NOT IN ('idle', 'idle_tripped')).working,awaiting_gateeawaiting_approvalcontam (RN-047/ADR 0052 — os cinco estados da máquina do dev);idle/idle_trippednão.dev-<modulo>edev-<modulo>-2são chaves DISTINTAS (agent_idna 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.statusde cadaactor_id, na sessão MAIS RECENTE do projeto (mesmo escopo de sessão que o resto deRosterFactsusa, RN-090).agent.statussó tem três valores possíveis (Engine.Sessions.LiveBroadcast.agent_status/4recusa qualquer outro):working/awaiting_approvalcontam,idlenã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 ÚLTIMOagent.statusconta, 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 com0/undefined, aparece comonlineAgentCount > 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 agentdev.working/dev.blocked/dev.idle_tripped/dev.blocked_by_containercomo último evento →pending: true, inclusive os cinco agentes do cenárioexp004e comdev.blocked_by_containervencendo umdev.idleanterior;dev.idle→pending: false; sessão sem eventodev.*preserva o comportamento anterior; só o ÚLTIMO evento de cada agente importa;dev-<modulo>-2também segura; isolamento entre sessões),scripts/ci/vocabulario-de-eventos-dev.spec.ts(o vocabuláriodev.*do engine × as duas listas da api E × o mapa do painel, mais os três casos fabricados que provam a válvulaDEV_STATUS_EVENTS_FORA, que nenhum dado real exercita hoje) eapps/web/src/lib/agent-status.test.ts(dev.blocked_by_containeredev.awaiting_approval→aguardando,dev.error→falhou, nuncatrabalhando; 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_trippedfechada pelo heartbeat enquanto o usuário ainda desbloqueava tarefas manualmente; e, mais tarde, cinco dev agents emblocked_by_containernoexp004, 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):
apps/api/src/main.tsnunca 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-turnreenvia o histórico INTEIRO da conversa a cada iteração doToolLoop, e 3-4 tool results de 32 KiB (teto individual da RN-150) já somavam mais que 100 KB.API_JSON_BODY_LIMIT(default10mb) fecha essa ponta.Engine.Harness.ContextManager.Defaulttinha dois defeitos que deixavam a compactação inalcançável antes do corpo estourar:estimate/1contava sócontent, então mensagens deassistantcomtoolCallspesados 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 (threshold0.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 doToolLoop(group_by_iteration/1) — nunca separando uma mensagemassistantcomtoolCallsdosrole: "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;:pinnedsobrevive; teto de transporte força compactação mesmo com janela de modelo grande);get-session-pending-work.use-case.spec.ts(último eventodev.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
GraphStoremockado (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 TODOprompts/*.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), comgraph_templates_enabled?ligada o modelo recebia a documentação e o dado DUAS vezes: o sumarizador doContextManageros turnos em dobro, os kickoffs do Psicólogo e da Anamnese o log de eventos em dobro, e a identidade do UX (atrás dagraph_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-kickoffeux-designer-identitypara"2",psychologist-kickoffecontext-manager-summarizepara"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é rodarscripts/dev/seed-prompts.tsde 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.
- Origem: decisão do dono do produto, inspirada no repositório ErickWendel/neo4j-ai-experiments (ver ADR 0099)
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 detop_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 Serviceollamapra 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;GraphUnavailableErrordeixa 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 emappend-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.exsdos dois agentes (hits presentes; falha do RAG degrada sem erro;degraded: truevisível; clamp detop_k; query derivada do gatilho);psychologist_worker_test.exs/anamnese_worker_test.exs(template com sucesso e com fallback;:pinnedidê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 chamaget_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 pushcom auto mode"*"E comallowcasando nopermissions.jsonresolvem prarequire_approval;sudoidem; 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:runnerno 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-localno 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 modolocal+ runner conectado;cwdpassa adiante; cai no caminho atual sem runner conectado; nunca roteia em modocontainer);terminal_channel_test.exs(pty_opensem runner conectado devolvepty_errorpra 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étodoprovisionarContaNova) - Teste:
apps/api/test/domain/auth/personal-workspace.spec.ts(nome com/semnome, fallback para o local-part do e-mail, acento/maiúscula virando kebab-case ASCII, fallbackworkspace-<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 comoowner, 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(blocorunner),apps/api/test/interfaces/http/iam/project-dto-modo-de-workspace.spec.ts,apps/web/src/lib/wizard.test.ts(canAdvanceFromWorkspacepararunner) - 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:
- Recusa com
400se o projeto não estiver no modorunner; - 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;
- SOBRESCREVE
workspacePathcom o caminho normalizado e gravaworkspaceVerifiedAt = now()— sem exigir igualdade com o que foi digitado na criação: o runner manda; - É 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; - Sem sessão no projeto ainda (
ProjectSession.latest_id/1devolvenil): oUPDATEacontece do mesmo jeito, só o eventoproject.workspace_verified(e a evidência do gateworkspace-verificado) fica ausente — a MESMA degradação quepty_open/pty_closejá 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(enviarWorkspaceConfirmdepois do join) - Borda: o
docs/gates.ymlregistraworkspace-verificadocomoseveridade: 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 tentarverify()),apps/api/test/interfaces/http/runner/runner-tickets.controller.spec.ts(runnerTickettem o decorator,terminalTicketnã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; "tocalast_used_atsempre 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ó paraowner/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(useMemotimeline) - 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 comGitMerge()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 degit_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
ruezh-Hans, e o padrão do web passou deenparapt-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é:
ENGINE_PUBLIC_URL(RN-419) nunca tinha default nenhum nodocker/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.ymlganhouENGINE_PUBLIC_URL: ${ENGINE_PUBLIC_URL:-http://localhost:4000}no serviçoapi— o MESMO default queVITE_ENGINE_URLjá usa no serviçoweb, pelo mesmo motivo.- 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.tsnunca passavareconnectAfterMsproSocket, então ophoenix.jscaí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 desession-channel.ts/RN-108), mas nunca era cumprida na prática. SemonError/onClosechamandohandlers.onErro,TerminalPanelficava preso em'carregando'(RN-088 — toda tela distingue carregando/erro/ vazio — violada em silêncio). Corrigido comreconnectAfterMsdevolvendo um valor que praticamente nunca dispara (24h, mesmo padrão desession-channel.ts) e um timeout PRÓPRIO de 8s (TIMEOUT_CONEXAO_MS): seonOpennão disparar antes disso, o módulo chamahandlers.onErrocom mensagem acionável e desconecta. - O defeito que de fato impedia a conexão, mascarado pelos dois
acima:
terminal-channel.tsconcatenava'/runner/websocket'a umengineWsUrlque a api já devolve PRONTO —ws://host:porta/runner(engineWsUrlPublico()) —, e o PRÓPRIOSocketdophoenix.jsainda acrescenta/websocketsozinho no construtor (this.endPoint = ${endPoint}/websocket). O resultado batia no engine comoGET /runner/runner/websocket/websocket, quePhoenix.Router.NoRouteErrorrecusa — 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 — passaengineWsUrldireto proSocket, sem tocar — e o teste determinal-channel.tsnunca pegou porque o mock degetTerminalTicketusava umengineWsUrl(http://engine.local, sem/runner) que não reflete o contrato real da api; corrigido junto. Fix:terminal-channel.tspassa a usarengineWsUrl(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 chamaonErrodentro do timeout, sem reconexão automática do phoenix;engineWsUrldo 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 noSocketIGUAL aoengineWsUrldo 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/homeno 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
--dirno Linux) - Origem: pedido explícito do dono do produto, durante um teste real
de criação de projeto (tentativa de apontar
--dirpara um caminho fora do home,/home/dev/exp001, que não existia na máquina — levou à decisão de travar caminhos fora do$HOMEno 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 demkdiremNaoConsegiuCriarDiretorioError; e um caso a mais emdescribe('validarDirDentroDoHomeNoLinux', …)provando que um--dirfora 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
runnernã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/exp001inexistente), 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 contraINIT_CWDquando 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 ../exp001de dentro do checkout do monorepo — o único caminho de invocação disponível hoje, porque@brabo/runnerainda não foi publicado de verdade no npm (NPM_TOKENpendente, 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 chamarlistarDiretoriode novo; duplo clique navega; arquivo aparece na lista mas não é alcançável porgetByRole('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, describenavegaçã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;handleConfirmreusa em vez de duplicar. O teste preexistente "'Procurar pasta...' mostra o estado declarado" (modomounted) 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 confirmasocket.url === engineWsUrlsem concatenação; maisdiretorioInicial/listarDiretorioroundtrip, falha ao buscar ticket, efechar()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 queFolderBrowserModalfoi 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(desvioIS_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(IamUseCasesModuleimportado, peloResolveEffectiveRoleUseCasequePatAuthGuardpassou a precisar); comentário corrigido emapps/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(desvioIS_PAT_ROUTE_KEY);apps/api/test/interfaces/http/runner/runner-tickets.guards.integration.spec.ts(NOVO — sobeJwtAuthGuard+RolesGuard+PatAuthGuardnum Nest real, comsupertest, na MESMA ordem relativa doAppModule: é o teste que faltava, porque cada guard só era exercitado isolado antes disto). Verificado também AO VIVO:brabo-runnerconectou de verdade a um projetorunnerreal 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-runnerde verdade contra um projeto real e isolando comcurldireto — nunca por teste automatizado, porque nenhuma suíte exercitavaRolesGuardePatAuthGuardna 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, colunasbudgetMicros/spentMicrose 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(describebudget 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(describegasto 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 comnull, 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 porapps/web/src/lib/agents.ts) - Teste:
apps/api/test/domain/agents/agent-areas.spec.ts(describe('addressableAgents (ADR 0109)')edescribe('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(fromAgentderivado, sentinela"usuario",actor: user);docs/security-surface.md+apps/api/test/interfaces/route-surface.spec.ts(classificaçãorole:developerem 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(idlenão conta, todo o resto conta, isolamento entre projetos) - ADR: 0111
- Origem: correção registrada em
docs/explanation/backlog.mddurante 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(runningpassa porstopped;provisioningvai direto; sem linha, nenhuma transição; entrar emcontainernunca 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 -> containerzera; 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 desmoke-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 deNodePtyModule);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) eapps/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
projectIdna 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 quelocalNÃ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 detool.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 deapps/engine/test/engine/agents/ *_server_test.exs(broadcast detool.callsem 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 parapreflight.mjshoje. - 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 empending_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 marcafailedcom a origem certa —modelo/infra; recusa reconfirmar um pedido que já saiu depending_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 +Alertde aviso) - Teste:
apps/api/test/domain/huggingface/huggingface-official-publishers.spec.ts(reconhece publisher do allowlist; recusa reupload de terceiro; sensível a caixa;repoIdsem/nunca casa),apps/api/test/application/use-cases/llm/huggingface/search-huggingface-models.use-case.spec.ts(só oficiais por padrão;includeCommunitytraz todos marcados),apps/web/src/components/HuggingFaceModelBrowser.test.tsx(toggle nasce desligado e só mostra o aviso quando ligado; busca respeitaincludeCommunity) - 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-keyssaiu com ele — junto comRegisterRunnerDeviceKeyUseCasee o teste dele, citados abaixo como eram. FicamGET/DELETEe 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
PatAuthGuardcontinua 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,projectIddo claim não bate com a rota, chave registrada em outro projeto, TTL longo demais,kidsem 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
--dirapontando 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;nullsem lançar para arquivo ausente/JSON inválido/campo faltando; respeita ocwdrecebido; módulo não importawriteFileSync/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; ogit_mergeduplicado aparece uma vez, na fila de PRs; ordem por espera mais longa; data emprestada marcada, ausente viranulle 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;ApprovalCarddecide 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 earia-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.catchda 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 modorunner, com tomneutroe ressalva);apps/web/src/routes/AuthLayout.tsx:46(colunaDeIdentidade— nada focável ali, ou a primeira parada deTabdeixa 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 modorunner; confirmado exige a ressalva e o bloco não contémde 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 enullna 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 dedeveloperdeixa 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ção | endpoint | papel |
|---|---|---|
| convidar | POST :projectId/members | maintainer |
| trocar papel | POST :projectId/members (upsert) | maintainer |
| remover | DELETE :projectId/members/:userId | maintainer |
| ver a tabela | GET :projectId/members | viewer |
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:
| lugar | o que dizia | situaçã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.role | a 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— oinnerJoinque faz a tabela ser recorte),apps/web/src/routes/settings/MembersSection.tsx:62(o papel efetivo e por quemaintainer),:109(por que convidar não usamensagemDaApi),:142e:162(os doistry/catchque faltavam),apps/web/src/lib/roles.ts:49(roleAtLeast),:3(o comentário deROLE_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(vieweredevelopernão editam e o clique não chega na api;maintainereownereditam 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 —viewerde workspace com linhamaintainerEDITA,ownerde workspace com linhaviewerNÃ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:
| teto | o que recusa | status |
|---|---|---|
| 1 | papel abaixo de owner para quem é owner do WORKSPACE | 403 |
| 2 | papel abaixo do que o CHAMADOR tem hoje, quando o alvo é ele mesmo | 403 |
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,ehAutoRebaixamentoe 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 ?? workspaceRolesegue 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;ownerde workspace recebendoownerde 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, eviewerde workspace virandomaintainerno 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.tse 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:
| passo | falha derruba o fluxo? | por quê |
|---|---|---|
1. showDirectoryPicker | sim | sem pasta não há onde gravar nada. Cancelar (AbortError/NotAllowedError) NÃO é falha — volta ao estado inicial, sem alerta |
2. brabo-runner.config.json | sim | é metade do par que a RN-466 lê |
3. par Ed25519 + registerRunnerDeviceKey + …device-key.jwk.json | sim | privada 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ário | não | devolve 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):
| estado | o que a tela diz |
|---|---|
esperando | "procurando o runner", com o teto DECLARADO ("paramos depois de 3 minutos") |
confirmado | a 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 |
semResposta | o 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 embase),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
kidcarimbado 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.
| estado | quem responde | saída do CLI |
|---|---|---|
ausente | caminho normal | o bloco de uso(), inalterado |
json-invalido | recusa | explicacaoDaChaveRecusada — nomeia o arquivo, o motivo, e as duas saídas (regravar a pasta pelo navegador, ou --token enquanto isso) |
sem-kid | recusa | idem, nomeando o campo que falta e para que ele serve |
valida | segue 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 emlerArgumentos) - Teste:
apps/web/src/lib/runner-bootstrap.test.ts(okidgravado É oiddevolvido, provado com um id diferente do padrão; os dois caminhos — pasta e kit manual; a pública SEMkid; 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 enquantolerChaveDeDispositivocolapsa os dois emnull; 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 semkidcai 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(ocatchda 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 daproposed_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 virafailedcom motivo e evento; falha ao CRIAR não deixa linha mas o status reporta, comfailedStepnulo),apps/api/test/domain/git/repo-bootstrap-status.spec.ts(sem linha com falha de criação; com linha a falha antiga é ignorada;pendingpuro),apps/web/src/routes/ProvisioningPage.test.tsx(o motivo aparece; o retry existe semprovision_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 nasciaroot) estava gravada só emproposed_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.
| consumidor | precisa de | por 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 alcance | ela 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), comLocalizacaoDeProjetoInvalidaErrorem: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(permissionsFilePathnos três modos; e a não-regressão deprojectScopeRootcontinuar devolvendo o caminho do host emrunner/mounted— unificar as duas de volta quebraria o ADR 0055),apps/api/test/application/use-cases/execution/activate-execution.use-case.spec.ts(com oFsPermissionsFileStoreDE 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 projetorunner, 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 emespecificacaoDoProjeto),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;cwdfora de/worké recusado, inclusive/workspace; semPROJECT_WORKSPACES_HOST_ROOTostartrecusa 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, nemrationale),apps/api/test/infrastructure/http-clients/container-broker.client.spec.ts(startmanda 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 onullque 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(onulldeinspecté 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; semBROKER_URLo 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 devolvesearchId: 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 —searchIddesconhecido,chunkIdfora 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á tinhamrag_search),apps/web/src/components/rag/RagCitationCard.test.tsx(os três estados do controle, e a ausência dele semsearchId),apps/api/test/scripts/medir-rag.spec.ts(precision@ksobre 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 avalidarDecisaoDeImagem—latesterationalecurto),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 oRAG_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 deGetModuleRoutingUseCase),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 chamarDecidirImagemDoProjetoUseCasenem o broker;BrokerRecusouError/BrokerIndisponivelErrorviramfailed, nunca propagam;stoppedpula direto pararunning),apps/api/test/domain/actions/decide.spec.ts(container_startexigemaintainer; CONSEGUE chegar aauto_approvequando configurado — ao contrário deparallelize/merge protegido/instruction_patch),apps/api/test/application/use-cases/execution/get-infra-context.use-case.spec.ts(moduleRoutingpresente 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 dedecisao_de_execucao/1; tradução decwdna raiz e num worktree;sucesso: falsee falha de transporte viramfailed_result; container sem linharunning/emstoppedcai no caminho de sempre),apps/api/test/application/use-cases/containers/executar-comando-no-container.use-case.spec.ts(BrokerRecusouError/BrokerIndisponivelErrornunca 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, ocurrentinicial 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 semcontainerExecutionActive; não afeta ação não-terminal;agent_autonomy/permissions.jsonexplícitos rebaixam o piso; IAM insuficiente nega antes do piso; escopo continua vencendo por cima; git push/sudo continuamrequire_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 afetacontainer_start; sem linharunning, 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,modoLocalremovido) - Teste:
apps/api/test/application/use-cases/git/read-project-code.use-case.spec.ts(bloco "o portão do container":mounted/runnerrespondem 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/runnerregistramprovisioningem 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 decontainer_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 derunning; um hop só a partir destopped/failed; idempotência;BrokerRecusouError/BrokerIndisponivelErrornunca 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 comproject_containers; imagem CONGELADA, não a vigente;acaoPendentecross-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;naoObservadonunca confundido comnaoVerificado),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(tratarExecroteia host vs. container;tratarContainerStart/Stop/Removenunca 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:
mix golden_set.ragé um alias que chamatest, e o Mix recusa isso fora do ambiente:test—** (Mix) "mix test" is running in the "dev" environment—, antes de rodar coisa alguma.def cliemapps/engine/mix.exspassa a declararpreferred_envspara as DUAS tasks de golden-set: é o que faz o comando curto, o único documentado em toda parte, funcionar sem prefixo de ambiente. ExigirMIX_ENV=testde quem chama seria transferir a correção para toda a prosa que ensina o comando — e para quem esquecesse, de novo.- O job nunca subiu a api, e o teste PULA quando
API_URLnã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 sendoseed-golden-set-rag.ts, que sobe o próprio contexto Nest; a instância existe para responder ao/health. - O seed morria em 409 no portão da imagem. Ele criava o projeto como
runnerjustamente para ESCAPAR do portão (RN-105), apoiado na isenção que a RN-169/RN-421 davam amounted/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 porDecidirImagemDoProjetoUseCase, o mesmo caso de uso dochoose_project_imagedo Arquiteto, numa sessãoconsultivaque ele abre (o evento de decisão exigesession_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 blocostyles.infraHandoffRowacima domanualHandoffRow) - 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—:workinicial sem container cai em:idle, persiste, emite e não chama a api; o Noop passa pela mesma guarda; agente REIDRATADO para em:idlesem claimar;container.runningna outbox percorre drain → worker → wake → agente e ele re-claima; e o caminho de sempre segue intacto com containerrunning.apps/engine/test/engine/actions/terminal_executor_test.exs— recusa paracontainer(sem linha e comstopped) e paramounted;mountedcomrunningatravessa 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—runningpublicacontainer.runningno agregadocontainer; 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
exp001travaram 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.mjse a chamada emscripts/dev/preflight.mjs;docker/docker-compose.ymledocker/docker-compose.prod.yml(serviçosapi,engine,broker) - Teste:
apps/api/test/infrastructure/filesystem/project-workspaces-root.spec.ts(describe('baseDeProjetos / dentroDaBaseDeProjetos')) escripts/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 depreflight.mjs, que rodaawait main()no topo e por isso não pode ser importado);scripts/dev/bootstrap.sh(item1.6);scripts/dev/preflight.mjs(relatarBaseDeProjetos) - Teste:
scripts/dev/base-de-projetos.spec.ts(describe('basePadrao'),describe('validarBase')— caminho feliz e as seis recusas — edescribe('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çobroker, semprofiles, comhealthcheck) edocker/docker-compose.prod.yml(comprofiles, e a nota que impede uniformizar os dois);docker/broker/Dockerfile(o pnpm pinado por sha512, opnpm installde 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
mountedpassou a exigir TAMBÉM o broker de container confirmado. Com base e sem broker (a instalação por Release),runneré o pré-selecionado econtainer/mountedficam inertes com o motivo em texto; com o broker desconhecido,containersegue 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, basenull, 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')edescribe('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:
- o mesmo predicado que
runnerjá usava (caminhoDeWorkspaceLocalValido) — absoluto, sem.., fora da raiz e das pastas de sistema, sem se sobrepor ao checkout do Brabo (RN-422/RN-423); - 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, eworkspace_verified_até carimbado pelo MESMO caminho queConfirmProjectWorkspaceUseCaseusa. Falhar éfailedNOMEADO, nunca throw nem 500 — mesma disciplina deBrokerIndisponivelError/RunnerNaoConectadoError—, e o ciclo de vida não chega a ser marcadoprovisioning: marcá-lo e só então descobrir que não dá para escrever deixariaproject_containersafirmando 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 ("aprovecontainer_startde 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 opermissions.jsonparapermissionsFilePath(localNova), que emmountedé 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 antigovalidarCaminhoDeWorkspaceLocal) 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:133e: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.tipo | segmento | raiz do broker |
|---|---|---|
gerenciada | workspace_dir_name (RN-109) | PROJECT_WORKSPACES_HOST_ROOT |
montada | o caminho RELATIVO sob a base | BRABO_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) eapps/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.tseapps/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
agentcujo id édev-<modulo>(ADR 0053/FASE 14d — a área DINÂMICA dedev, um agente por módulo domodule_map): grava emagent_autonomy(projectId, agentId, actionType) = auto_approve, pelo MESMOAgentAutonomyRepository.upsertqueactivate-execution.use-case.tsjá chama pra semear as três ações git por módulo.permissions.jsonfica intocado. - Qualquer outro ator —
user,system, agenteagentque não é dev-de-módulo, e odev-lead— continua indo propermissions.json/allowde 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 branchehAgenteDeModulo),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(descreveescopo por Dev Agent de módulo (RN-509): caminho felizdev-checkoutgravaagent_autonomye não vaza pradev-auth; os DOIS tetos absolutos recusando o clique inteiro pra um ator dev-de-módulo;dev-leade atoruser— inclusive com id começando emdev-— 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.exsearquiteto_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/0ganhaexecution_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 oespecificacaoValidadade 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/3passa a receberparams),:215(autorizar_por_papel/3),:251(entrar_como_runner/3, a concessão antes doRegistry),:305(exigencias_do_projeto/1, que eramodo_de_execucao/1até a RN-516 fazer a mesma consulta responder também o destino do espelho),:595(o gate deexecemhandle_info({:dispatch_exec, ...})) e:762(o deptyemhandle_info({:relay, "pty_" <> _, ...}));apps/runner/src/channel.ts(CAPACIDADES_DO_RUNNERe os params dosocket.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 exigeespelho);apps/engine/test/engine_web/channels/terminal_channel_test.exs(describe "capacidades declaradas no join (ADR 0147 ponto 1, RN-514)"— concessão emassigns, join sem params, desconhecido ignorado,:websem conjunto, a recusa nomeandoexecnum projetorunner, 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,espelhofora da lista, e a recusa virandoJoinRecusadoErrorcom 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(colunamirror_path, nullable, sem CHECK — a recusa decontainermora 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 decontainere 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 colapsarianulleundefined);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 emcontainer, recusa não-absoluto/../raiz/pasta de sistema/checkout do Brabo, os dois sentidos do laço,/base-outravs/baseNÃO sendo dentro,containerrecusado, 404);apps/api/test/interfaces/http/iam/projects-execution-mode.controller.spec.ts(describe "destino do espelho (RN-515)"—maintainere o repasse denull);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 emdocs/security-surface.md) - ADR: 0147, ponto 4
- Origem: FASE 28, sessão 6 (metade A — onde o destino mora; a cópia, o
mirror_synce a segunda passada derealpathsã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, omkdir -psó 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_RUNNERganhaespelho,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/3edestino?/1— o destino, não o modo, é quem exige);apps/engine/lib/engine/runners/espelho.ex(o predicado próprio, odestino/1do join e osincronizar/2fire-and-forget);apps/engine/lib/engine/projects/project.ex(mirror_pathno schema de leitura);apps/engine/lib/engine_web/channels/terminal_channel.ex(a concessão na resposta do join e odispatch_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 emtmpescapando pelo próprio destino e por um segmento do meio,/base-outravs/base, o alvo por arquivo,lstatvsstat);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 domkdir, pasta sem git falhando nomeada);apps/runner/src/channel.spec.ts(o destino vindo na resposta do join, as três capacidades declaradas,mirror_syncchegando 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 —:prontosem 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 demirror_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/1fecha 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/3nã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 agregadoiamporque é 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.tse.../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(tratarMirrorSyncreporta 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, ofilesCopied: 0que não some, campo ausente que NÃO viranil,sucessofaltando que viraok: false,:webignorado, 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"— semmirrorPatha 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 eCODIGO_POR_ESTADO);apps/runner/src/servico-sistema.ts(novo — o adaptador real de disco e desystemctl/launchctl, separado para que a garantia "servico.tsnão tocanode:fs/node:child_process" continue verificável);apps/runner/src/index.ts(comandoDoRunnerParaServico,rodarSubcomandoDeServico, o despacho deserviceemmain()antes delerArgumentos, e a linha nova emuso()) - 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;projectIdque viraria caminho;systemctlausente deixando o arquivo e dizendo que não ativou; a RN-434 repassada;uninstalllendo a pasta da unit e não docwd, sem unit e sem--dirnão tocando em arquivo nenhum, e odisableque falha virando aviso; os QUATRO estados destatusmais a palavra desconhecida e os três desfechos dolaunchctl; 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:statusresponde 4 em vez do bloco de uso, e subcomando desconhecido cai no uso deservice);apps/runner/src/servico-systemd.spec.ts(a unit GERADA perguntada aosystemd-analyze --user verifyde 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"edescribe "o --dir do ExecStart= e o WorkingDirectory= também chegam intactos", que perguntam ao despejo desystemd --test --usero valor RESOLVIDO deXDG_CONFIG_HOME,PATH,WorkingDirectorye do--dircom%, espaço, aspas, barra invertida e$, e fixam a forma antiga doEnvironment=como entregando OUTRO valor;servico.spec.tsganhou as duas recusas de quebra de linha e a unit de projeto semXDG_CONFIG_HOME - ADR: 0147, ponto 5
- Origem: FASE 28, sessão 7. Fica declarado e NÃO feito: BRB-031 (o
chmod +xmanual do fluxo do ADR 0118) não morre aqui — quem rodabrabo-runner service installjá 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 destart/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 comuserId/projectIdna ordem certa; a revogada que fica na lista; a órfã comlastUsedAtnulo; repositório que rejeita e propaga);apps/api/test/interfaces/http/runner/runner-device-keys.controller.spec.ts(as TRÊS rotas emdeveloper; 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.mdfecha pela metade — a api passa a poder listar e revogar, eapps/webainda 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 otry/catchque 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 sernil);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 virandotimeout; 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 noRegistry, o de outro dono que fica de pé, o:webque 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_idamarra 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, paracontainer/mountednuma 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"):
- sem imagem decidida — o portão da RN-105, que vale nos TRÊS
modos desde a RN-494; sem
artifact.project_imagenão há o que subir; runnerque nunca teve pasta confirmada (workspaceVerifiedAtnulo, 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);- papel abaixo de
maintainer— o mínimo é o do ENDPOINT (RN-102): os dois tipos pedemmaintaineremdecide.ts, e a comparação sai deroleAtLeast, 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 —titleem elementodisablednã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, oORDER BYetemImagemDecidida);apps/api/src/application/ports/containers-overview-repository.port.ts:20(executionMode/lifecyclenulo/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 semproject_containersna lista;temImagemDecidida;container_start_via_runnerpendente; as TRÊS consultas que não crescem);apps/api/test/interfaces/http/containers/containers-overview.controller.spec.ts(oregistradoaninhado e onullque 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 doexp004: projetorunner, sem linha, imagem decidida, pasta confirmada → a ação proposta écontainer_start_via_runnercom payload só derationale, eGET .../containernem é 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_planonã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, papelappsec. Fica declarado e NÃO feito:artifact.threat_modelé emitido na sessão da STORY (emit_threat_model/3lê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 doruntime.exs. - em
docker/docker-compose.prod.ymlbasta estar mapeada. Aquele arquivo desliga os agentes de fundo de propósito (START_ANAMNESE:-falsecontra otruedo 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 doruntime.exse reprova nomeando a que faltar em qualquer dos dois composes, e a que divergir do default do código no de dev.warnnenhum: reprova mesmo, no jobtest-packages - Origem: achado por medição direta no container do engine, 2026-09-08.
Fica declarado: a lista de exceções
FLAGS_FORA_DO_COMPOSEnasce 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 deservico.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 nolerArgumentos, DEPOIS degarantirDiretorioe 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,$HOMEno 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_resulte a capacidadeworkspace(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: oinstall.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_WORKSPACEecapacidadesDoRunner);apps/runner/src/index.ts:1034(tratarWorkspaceCreate— a ordemworkspace_confirmantes 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(workspaceno 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 cominite 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 ORDEMworkspace_confirm→workspace_create_result; sem base, nenhum confirm; conexão caída não empurra nada);apps/runner/src/channel.spec.ts(workspacesó declarada com base; payload semsegmento/projectIddescartado;envmalformado virandoundefined);apps/engine/test/engine/runners/pasta_do_projeto_test.exs(as duas pré-condições SEM banco — a asserção é oExUnit.Casepuro —, 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(workspacedeclarável, nunca no legado, nunca exigida);apps/engine/test/engine_web/channels/terminal_channel_test.exs(a recusa nomeada pelo mesmoref, 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 oinstall.shque 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(orefque 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,undefinedquando 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-agentecaindo no painel de onboarding; a decisão pelo MOTIVO e não pelo texto, com uma redação que ninguém prometeu manter;sem-respostacom 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 cincomotivoda 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_esperarperguntando por ESCRITA e não por existência),:105(destino_enviar, escrita em.parciale rename),:181(a garantia dos bare repos, escrita junto do código que a produz),:221(git_arquivar, o status dotarnum 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 dotar) 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--profilenoconfige não norun);docker/docker-compose.prod.yml:392(o serviçobackup, sobprofileserestart: "no") e:550(o volume que NÃO entra na classificação dos sete);docker/backup/Dockerfile.prod:80(o/backupscriado echown-ado ANTES doUSER);Makefile:54(test-restore-compose) - Teste:
scripts/ci/backup-lib.spec.ts— as funções numshde 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.parcialque 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;*.lockeobjects/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
exp001eexp004), incluindo a restauração num volume novo conferida comgit fsck— o registro está nodocs/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 comandoawsde 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ção | o que o sistema faz |
|---|---|
| Transição de estado inválida | erro tipado (InvalidSessionTransitionError etc.), traduzido para 4xx — nunca grava estado inconsistente |
| Agente estoura o teto de iterações ou tokens | encerra e registra artefato de bloqueio com a origem da falha |
| Task esgota o ciclo K | bloqueada com motivo, não gira para sempre (RN-015) |
| Provider de LLM cai no meio | registrado como falha de infra, nunca "o modelo parou" (RN-023) |
| Duas decisões concorrentes na mesma hipótese | conflito explícito (RN-022) |
| Réplica do engine cai | sessão é adotada por outra ou encerra como closed_abnormally / node_shutdown — nunca fica órfã |
QaLeadServer/SecOpsAgentServer cai no meio de um ciclo de gate | Engine.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ível | a 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 bloqueada | a mesma resposta 401, com o mesmo custo de argon2 (RN-032) |
| Refresh já usado reapresentado | famí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ço | 403 na api, 401 no engine — nunca alcança o controller (RN-035) |
| Provider recusa a chave durante o sync de catálogo | provider pulado com a origem da falha; nenhum modelo é marcado como sumido (RN-041) |
| Modelo do binding some do provider | a 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 muda | vale 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_BASE | a 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 container | container_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 ausente | a 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 abrir | 404 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 subpastas | a 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 mounted | recusa 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 base | a 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 mounted | a 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 verificado | recusado 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ão | recusado (400) ANTES de gravar qualquer evento — não há o que validar ainda (RN-406) |
Converter execution_mode com dev agent trabalhando ou travado | recusado (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 somados | recusado (400), o lote inteiro — nunca trunca em silêncio (RN-456) |
| Arquivo individual da pasta local é grande demais ou de extensão não reconhecida | só PULADO (filesSkipped), nunca derruba o upload inteiro (RN-456) |
Confirmar um pedido de pull de modelo que já não está pending_confirmation | recusado (409) — a confirmação não é reexecutável (RN-462) |
| Pull de modelo Hugging Face falha no Ollama | pedido 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 navegador | a 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 espera | a 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 bootstrap | o 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 tela | a 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 deles | as 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 falha | a 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 devolveu | 400 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 todo | reprova (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 Arquiteto | açã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/consequences | tool-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 running | recusa 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-507 | levanta 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 running | PULA 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 conectado | o 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 join | join 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 conhece | o 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 exec | responde 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 pty | a 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 ele | 400 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: container | 400 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 corpo | 400 — 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 root | recusa 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álida | recusa 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 install | o 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 --dir | responde 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 PATH | responde "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 /containers | aparece 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 decidido | o 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 é nulo | botã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 desligado | a 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 /containers | controles 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 $HOME | recusa 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 ela | recusa 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 recusa | a 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 configurada | segue 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 symlink | recusa 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.0atestaria 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 UMchecksums.txtassinado, 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
brokerpassou a existir neste compose, desligado sob o profilecontainer-brokere 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
brokernão existe aqui, porque a imagem dele não é publicada (docker-bake.hcltem quatro alvos). Consequência declarada: com--source=ghcr, projetomountednã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:
| caminho | medido | por que não |
|---|---|---|
cosign na imagem da api | 155 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/tuf | 2,5 MB, 12 pacotes | o 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 oContent-Digest),apps/api/src/interfaces/http/runner/checksums.ts(a leitura pura do manifesto e a união deMotivoDaRecusa),apps/web/src/lib/runner-bootstrap.ts(baixarBinariopassa a lermessageemotivodo corpo, e a frase chega emfalhaDoBinario) - Teste:
apps/api/test/interfaces/runner-releases.controller.spec.ts(caminho feliz comContent-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 odescribe('parsearChecksums')do mesmo arquivo;apps/web/src/lib/runner-bootstrap.test.ts(a frase da api e omotivochegando 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_DIRaponta para um diretório que existe e NÃO aceita escrita | o backup recusa ANTES dopg_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 gcconcorrente apaga um packfile no meio dotardos bare repos | a execução virafailedembackup_runs, com o status dotarnomeado — nunca um arquivo parcial que se anuncia como bom, porque só a falha vira alerta (RN-528) | | Volumegit_local_reposnã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 projetolocal(RN-528) | | Há bare repos no volume e NENHUM arquivo no prefixogit-daily/|brabo-restore-gitreprova 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. É opg_restore --listdo lado do git (RN-528) | |brabo-restore-git --restaurarnum volume que já tem bare repos | recusado: sobrepor dois estados de repositório não levanta erro nenhum e só aparece numfetchcom história errada.RESTORE_GIT_FORCE=1para quem quer mesmo (RN-528) | |brabo-restore-git --restaurarsem poder escrever no volume (uid 70 × dono da api) | recusa ANTES dotar, 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 deadotando),:540(a guarda deproviderrestrita à 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 ramodev-leadnoexecutee o método privadoprovisionarRepositorio, com otry/catche a saída antecipada do repositório adotado;apps/api/src/application/use-cases/agents/agents-use-cases.module.ts(o import deGitUseCasesModule— a seta éagents → git, egitnão importaagents, 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 provisionalocalcom 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.tseapps/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ã depermissionsFilePath);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 porseq) eapps/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ção | oferta | default |
|---|---|---|
| maior | Atualizar 5.0.0 → 5.1.0? | sim — é o que a pessoa veio fazer, e o backup provado torna reversível |
| igual | Já é a 5.0.0. Reinstalar do zero mesmo assim? | não — não há ganho a oferecer |
| menor | nomeia rebaixamento, avisando que migração de banco não anda para trás | não — pode não ter volta |
| desconhecida | marcador de schema 1 não tem versão: diz isso, e não adivinha | nã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(item1.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 porbash, 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ão5.0.0vs5respondiamenor. Normalizado com.0.0no 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 dedeveloperfica de fora);apps/api/test/infrastructure/persistence/drizzle/runner-device-key.repository.spec.tse.../project-runner-mode.repository.spec.ts(contra o Postgres — o que mudou é umNOT NULLe umWHERE, 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 emlerArgumentos,lerArgumentosDeMaquina,manterConexaoDoProjeto,criarEstado,rodarComoAgenteDeMaquina);apps/runner/src/projetos.ts(listarProjetosDoRunner,planejarConexoes,CredencialNaoEDeMaquinaError);apps/runner/src/auth.ts(assinarDescobertaComChaveDeDispositivo— o JWT sem o claimprojectId);apps/runner/src/base-guard.ts(OpcoesDaBase.raizDoProjetonulo) - 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—raizDoProjetonulo 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. AapiUrlno modo de máquina vem de--api-url/BRABO_API_URL/default, e não dobrabo-runner.config.json, que é por PROJETO: uma instalação em porta ou host diferentes precisa da flag noExecStart, 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.
| movimento | desfecho |
|---|---|
| remover a própria linha, workspace com o MESMO papel | passa — é como alguém sai da lista |
| remover a própria linha, workspace com papel MENOR | 403 |
| remover a própria linha, SEM papel de workspace | 403 — a queda é para acesso nenhum |
| remover a linha de OUTRA pessoa, mesmo que ela caia | passa — capacidade legítima de sempre |
remover a linha que restringia o owner do WORKSPACE | passa — 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(remocaoEhAutoRebaixamentoeMENSAGEM_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 comatorIde 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 ?? workspaceRolesegue 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 aoownerdo 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.
| movimento | desfecho |
|---|---|
| mudar o próprio papel no PROJETO, para baixo | 403 — RN-472, inalterada |
| mudar o próprio papel no PROJETO, para cima | 403 — era permitido, e é o que muda |
| mudar o próprio papel no WORKSPACE, em qualquer sentido | 403 — a porta nova |
| reescrever o próprio papel com o MESMO valor | passa — upsert idempotente não é movimento |
um owner rebaixando OUTRO owner no workspace | passa — é a única forma de revogar propriedade |
| associar/rebaixar OUTRA pessoa, em qualquer escopo | passa — capacidade legítima de sempre |
| auto-promoção pela REMOÇÃO da linha de projeto | passa — 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 tipoSentidoDoAutoMovimento, as três mensagens novas e oehAutoRebaixamentoque passou a ser leitura do classificador),apps/api/src/application/use-cases/iam/add-workspace-member.use-case.ts(a assinatura comatorId, 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 deehAutoRebaixamentopelo classificador, que é o que fecha a auto-promoção lá),apps/api/src/interfaces/http/iam/workspaces.controller.ts(@CurrentUser(), o@ApiForbiddenResponsee a descrição),apps/api/src/interfaces/http/iam/dto/add-member.dto.tseapps/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(oowner.idcomo 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, oownerrebaixando outroownere 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_ENTREGUEe a recusa emtratarExec, depois da validação decwd),apps/runner/src/channel.ts(o docblock deExecMessage.env, que prometia "roda só sem a credencial"),apps/engine/lib/engine/runners/credencial_de_git.ex(novo — a marca,recusada?/1,mensagem/2edesfecho/1),apps/engine/lib/engine/actions/workspace/runner_git.ex(a cláusula nova emfetch!/3, antes da genérica, e o moduledoc),apps/engine/lib/engine/dev/dev_agent_server.exeapps/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, oenvchega ao host e nada é recusado), o caso de falha (com container, recusa nomeada edocker.execNÃO chamado), a prova negativa de que nome e valor das variáveis não vazam na saída,envvazio não virando recusa, e a recusa de contenção deguard.tsvencendo esta quando ocwdestá fora da raiz;apps/engine/test/engine/actions/workspace_runner_test.exs(os dois testesRN-558: a recusa que vira mensagem nomeada com origempolitica, e a falha REAL do fetch que mantém a mensagem de sempre com origemcodigo— 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 peloTerminalChannelreal: oenvchega ao canal só nofetch, a resposta do runner é a fixtureapps/runner/fixtures/exec-result-recusa-de-credencial.json— que o spec do runner compara byte a byte com a saída real detratarExec—,RunnerGitdevolve a mensagem nomeada, e oDevAgentServergravadev.blockedcom origempolitica; mais o ramo sem container ativo, em que ofetchresponde 0, e a falha real, que seguecodigo. NÃO prova a ponte entre linguagens por execução: nenhum processonodesobe no ExUnit, e o par que a fecha é a fixture comum. Verificado por mutação: cortar oenvno canal, noRunnerGit, o reconhecimento da recusa, odesfecho/1ou o uso dele no dev agent reprova o teste; desligar a recusa emtratarExecreprova 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 defetch!/3reprova 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
envSEM 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 odocker exec. Clone/fetch de repositório remoto AUTENTICADO em modorunnersegue 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 umdocker execsem campo deenv, e as opções conhecidas mexem todas na porta de contenção do ADR 0130 (darenvà operação; um arquivo de credencial montado e apagado; rodar ofetchno 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 deinit_from_bare!/5já ter feitogit init, e ogit_dir?/2deensure!/5marcava o workspace pronto na tentativa seguinte por encontrar o.git, de modo que a segunda tentativa falhava adiante noworktree addem vez de repetir a recusa — FECHOU no AT-112: a inicialização que falha (qualquer passo, não só ofetchcredenciado) DESFAZ o.gitque ela mesma criou (inicializar_ou_desfazer!emEngine.Actions.Workspacee emRunnerGit) 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.gite não se re-inicializa) segue intacto, então não apaga trabalho de ninguém. Do ladorunnerorm -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, semorigin/<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 campoenv) 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.tse…/first-account-internal.response.dto.ts,apps/api/src/application/ports/user-repository.port.tseapps/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, o409com instalação povoada, o400por comprimento e o400por 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, onomeausente virandonull, 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(o409),: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 doisIS 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.tse…/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 comprojectIdnulo e dono resolvido; o409sem 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 ode 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 devolvemnull, 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,PlanoDeInstalacaocomo união,caminhoDaUnidade/projetosInstalados/maquinaInstalada,prepararPlanoDeProjeto/prepararPlanoDeMaquina,recusaDeSobreposicao,linhasDoQueExiste/linhasDeCoexistencia,ContextoDoServico.apiUrlDoAmbiente,DependenciasDeInstalacao.resolverBase,SistemaDeServico.listarPasta);apps/runner/src/servico-sistema.ts(listarPastasobrereaddirSync);apps/runner/src/index.ts(o adaptador deresolverBasesobreresolverBaseConsentida,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 deXDG_CONFIG_HOMEsem o valor da base, o Label e o log do launchd, oBRABO_API_URL) e as cinco recusas próprias (config de projeto na pasta, sem base, base inválida, sem chave,--machinecom--project); e as duas espécies não se sobrepondo (recusa nos DOIS sentidos sem remover nada, ostatusde cada lado dizendo da outra sem somar código nem perguntar ao gerenciador, ostatussem argumento caindo na máquina, ouninstall --machinedeixando a de projeto intacta, e ouninstallsem espécie recusando e listando as duas) - Lacuna DECLARADA: o
install --machinenão sabe se a chave daquela pasta é mesmo de máquina — em disco as duas espécies são o mesmo arquivo, uma JWK comkid(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, comoCredencialNaoEDeMaquinaError. Verificar noinstallexigiria 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 é oinstall.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 oReconhecimentoDeMaquina),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 dedevelopersem chamar a rota, e o wizard semprojectIdsem 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 oferecebrabo-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 (chavesagenteDeMaquina.comandoDeServicoechaveDeProjeto.comandoDeServico, testes emRunnerOnboardingPanel.test.tsxnos 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 mesmaqueryKey, nenhuma rota nova), com o mesmo vocabulário — tomaccent, 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 reusapodeLerChavesDeDispositivodaqui e a MESMAqueryKey, 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 derodarComoAgenteDeMaquina, 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 devolvendoparado;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 delerArgumentos, 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, ofinishcarimbando, e a chave resultante lida porlerChaveDeDispositivoe ASSINANDO um JWT de ticket de verdade), o--dir, e as recusas:finishsemcreate, sem--id, com--idcolado 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 createroda 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.shque 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 }, compublicKeyJwksendo o JSON da JWK pública Ed25519 —kty"OKP",crv"Ed25519",x— e resposta comid), e é isso que o stdout docreateproduz: uma linha nesse formato. O comando também não escolhe onamedo 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, oidvirando--id),:1640(subir_o_agente_como_servico, a recusa repassada inteira),:1366(post_interno, o corpo pelo stdin e o cabeçalho pelo--config600),: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(oownerEmail). Números relidos pelo símbolo em 18/09 (AT-096), em 27/09 (AT-065, que acrescentou o ramo do Mac Intel eminstalar_o_runner) e em 01/10 (AT-122); os de:966/:682já 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 porsource, menos a chamada demain) contra um servidornode:httpreal e umbrabo-runnerdublê: 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 ordemcreate → registrar → finish → service installcom o--idsendo oidda api, a recusa doservice installrepassada com as palavras dela, o.parcialque fica quando o registro falha, ocreateque recusa sem nada ser registrado, e o fechamento sem binário do agente;scripts/dev/install.spec.ts(o plano imprimível, e o--helpque 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 doinstall.sh(verificar a própria origem contra ochecksums.txtassinado) continua sem cobertura, pelo motivo queinstall.spec.tsjá 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 sempull_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(okid, ode 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(o409da 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 quepull_requestnão está lá), a ausência de flag de pular verificação nos dois arquivos, o contrato de frases entre workflow einstall.sh(os cinco elos mais as quatro de sempre), as duas linhas doapps/runnerde que as asserções dependem, o canário de prompts interativos, a ORDEM entre a linha do journal e oservice status --machine, e as duas metades do achado do compose. Verificado por mutação: reescrever uma frase do instalador e pôrpull_requestno 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 base | o botão | o que a tela diz |
|---|---|---|
| em voo | apagado | "consultando a base de projetos desta instalação" |
| a consulta falhou | apagado | não deu para SABER se ela existe; recarregue |
respondeu null | apagado | esta instalação não declarou base |
| respondeu um caminho | vivo | a 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 porroleAtLeast),:220(a consulta que não sai para quem não alcançamaintainer),: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 ramorunner),:475(oFolderBrowserModalcomorigem: { tipo: 'api', workspaceId });apps/web/src/locales/pt-BR/settings.json:231eapps/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, escolherloja, 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 ramorunnersem botão e com o motivo em texto, e o papel abaixo demaintainer(controles apagados, valor vigente ainda visível, consulta da base não disparada) - Lacuna DECLARADA: o ramo
runnerdesta seção continua digitado no escuro — a lacuna doCLAUDE.mdencolheu 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 ramorunnerganha 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:
- 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);
- o que ela LEVA — a política do projeto muda de escopo com o conteúdo intacto;
- 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:220eapps/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 (projetocontainerapontando 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:
linha quando aparece a mesma condição do caso de uso o destino de espelho é zerado, NOMEADO mirrorPathpreenchidotoda conversão zera (RN-515) o container registrado é parado e removido o projeto é containerHOJE e o ciclo de vida existe fora deremovedremoverContainerSeExistirsó roda ao SAIR decontainer(RN-449)"não consegui ler se há container" o projeto é containere a leitura do ciclo de vida FALHOU— ("não sei" nunca vira "não tem", RN-468) a pasta confirmada em <data>deixa de valerworkspaceVerifiedAtpreenchidotoda conversão zera (RN-450) A linha do container não aparece em
mounted/runnermesmo 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ó emcontainer),:344(as quatro linhas noAlert) - Teste: o describe
AT-144emapps/web/src/routes/settings/conversao-de-modo.test.tsx— nenhuma condição (nenhuma linha a mais), container registrado, linharemovedque não conta, leitura FALHANDO com texto próprio,mountedcom espelho (a linha do espelho sim, a do container não, e a consulta nem sai),runnercom pasta confirmada (com a data), e as três condicionais emen
- Código:
-
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 projeto | o que acontece depois de salvar | gestos |
|---|---|---|
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.ts | zero, em até 60 s |
| agente de MÁQUINA já atendendo OUTRO projeto | a lista é lida UMA vez, no start, com conexão viva (RN-550) — o projeto novo não entra | um: reiniciar o serviço |
| nenhuma chave de máquina | o 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:249eapps/web/src/locales/en/settings.json:249(executionMode.nextStep.*) e:234(executionMode.path.runnerTyped, reescrito); a cadência medida emapps/runner/src/espera-de-projetos.ts:60(CADENCIA_DA_ESPERA_MS) e a lista lida uma vez emapps/runner/src/index.ts(rodarComoAgenteDeMaquina) - Teste: o describe
RN-612emapps/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 (projetorunnerjá confirmado não ganha o passo), o rascunho de outro modo escondendo o passo, e o texto emen - 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(orequire.main === moduleque 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 derewrap),:251(diagnosticoDaChave);apps/api/src/db/schema/llm.ts:259eapps/api/src/db/schema/git.ts:76(a coluna nas duas tabelas); migrationapps/api/src/db/migrations/0059_key_id_no_envelope.sql - Teste: o describe
key_id da chave mestraemapps/api/test/infrastructure/security/envelope-encryption.service.spec.ts— o caminho feliz (impressão estável e determinística, gravada porencrypte porrewrap), 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á emapps/api/test/scripts/rewrap-deks.spec.ts, incluindo o caso da linhaNULLcontando 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.*). ConsomelistRunnerDeviceKeys/revokeRunnerDeviceKey(apps/web/src/lib/api-client.ts:431) epodeLerChavesDeDispositivo(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;viewersem 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 emrunner_socket_ticketse 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 demaintainer(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 decontainer_startconsultando antes de propor),:895(recusa_local_de_subida/2— a leitura ÚNICA do projeto),:474(a cláusula decontainer_start: lista de permitidos),:477(a recusa nomeandocontainer_start_via_runner),:492(a cláusula da irmã, com a segunda pergunta — runner conectado),:409(oemitdotool.callmovido 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 emmounted— o broker atende os dois),:448(caso de falha:runnerrecusa NOMEADO,propose_actionnunca chamada, otool.callnarrado mesmo assim),:538(projeto inexistente),:380(o caminho feliz decontainer, que passou a exigir a linha no banco) - ADR: 0144, 0145
- Origem: AT-048 (EP-020/HS-032) — a lacuna declarada no
CLAUDE.mddesde 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_dispatchem ensaio):macos-13é label sem runner (imagem aposentada pelo GitHub em dez/2025; 30min na fila, cancelado, run34769280227).macos-15-intelagenda em 3–22s e constrói, mas reprova no--self-test-ptyporque, sob o Bun, oonDatadonode-ptynão entrega a saída do filho (oven-sh/bun#25822, aberto); a mesma prova passa sob Node no mesmo runner (run34770476634). 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_ESPERADOSacompanhou — é contra ele que a ausência é declarada, e deixardarwin-x64ali faria todo manifesto anunciar, para sempre, uma falta que é decisão. O Mac Intel fica comnpm install -g @brabo/runner(sob Node, onde a mesma prova passa): oinstall.shdiz isso sem tentar baixar, o proxyGET /runner-releases/binaryrecusadarwin-x64com 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, escripts/ci/alvos-do-runner.spec.tsreprova quando a matriz, a lista da api, ocasedo instalador e a do navegador divergem.
- Código:
.github/workflows/build-runner-binaries.yml:283(o job, agora semneeds:),:318(timeout-minutes: 40, que cabe as duas esperas),:335(ALVOS_ESPERADOSno 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 temneeds:, que os dois tetos existem, que o teto dos binários é derivado dotimeout-minutesdobuild(e não copiado), que ele cabe notimeout-minutesdo próprio job, que ausência de alvo énoticee ausência de Release éerror, queALVOS_ESPERADOSbate 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, oinstall.shno 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-x64saiu pelo mesmo molde do ADR 0174, eALVOS_ESPERADOSacompanhou) e a Release continua recebendo dois — odarwin-arm64está 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(reasonno payload do evento de sessão),:223(o comentário do outbox sem o campo),:227(o payload do outbox, intacto),:356(orejectionReason, que continua só nodeny);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 gravaagent_autonomy: auto_approve),:948(require_approvalpelo default e pelo teto da trava de merge),:1047(denycom o mesmo texto dorejectionReason),:999(o outbox semreason) - 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:
- 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".
- 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
falseagregado 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. - 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. - 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 derosterFactsFromEvents),:304(a união das delegações),:312(o OU do gate),:410(o parâmetro repassado porderiveAgentRoster);apps/web/src/routes/ProjectOverviewTab.tsx:97eapps/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::633gate fora da janela traz QA/SecOps com status da janela,:658sem agregado a janela decide sozinha,:676falseagregado 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.mde 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:
- Um tradutor só. A tradução evento → nó/aresta SAIU do
GraphProjectorparaGraphEventTranslator, 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 emGRAPH_PROJECTABLE_EVENT_TYPESsem tradução. - As fontes são duas, e são as de verdade.
handoff.offered,psychologist.hypothesis_proposedeanamnese.profile_updatedsaem desession_events; aInteracaosai desessionsemclosed/closed_abnormally, porque fechar sessão é transição pura e não deixa evento no log.PromptTemplate/PromptVersionNÃO saem do event log e NÃO são reprojetados — voltam porscripts/dev/seed-prompts.ts. - Idempotente, em lotes, e nunca apaga. Toda escrita é
MERGEem chave natural; rodar de novo é o jeito de retomar.session_eventsé varrida em ordem deid(ULID, a chave primária), 200 por lote, e--after-eventretoma depois do cursor impresso. Com--project, só as sessões daquele projeto. - 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. - 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),:163e: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:
- 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.sqle oollama/pull-models.shque o compose bind-monta, e obackup/test-restore-compose.shque a migração (RN-530) executa — são publicados como assetsbrabo-install-*pelo jobchecksumse entram nosha256sumque gera ochecksums.txtassinado. 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. - A tabela tem dois lados e um guarda. Quem publica lê
scripts/ci/assets-do-instalador.ts; quem baixa lê ocasededestino_do_asset_do_instaladornoinstall.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. - Verificar vem logo depois da própria origem, e antes de tudo. O
instalador baixa os quatro e compara cada sha256 contra o
checksums.txtqueverificar_a_si_mesmojá 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. - Nunca cai no relativo.
COMPOSE_DE_INSTALACAOnasce 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 — comrmantes docp, 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_INSTALACAOvazio 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 doup);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 mesmosha256sum),: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,.envnunca 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 emmaine na migração);scripts/ci/assets-do-instalador.spec.ts:89(todo bind-mount relativo é asset),:114(ocasedo 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:
- Todo tipo
dev.*que o engine emite tem decisão na árvore — traduzido emTRADUCAOou declarado emTRADUCAO_FORAcom o motivo (vazia hoje). Omitir deixou de ser possível:scripts/ci/vocabulario-de-eventos-dev.spec.tsderiva 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 tipodev.*traduzido que o engine não emite. Na primeira rodada ele achou DOIS faltando (dev.blocked_by_containeredev.error) e UM sobrando (dev.rearmed, que a api grava com atorusere que por isso nunca chegou a ramo nenhum — a árvore só pendura marco de atoragent). - 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átrabalhandoao mesmo evento (statusDoEventoDev, lido deDEV_STATUS_EVENTS).aguardando,ocioso,falhouetravadodeixam o ramo parado — e com elesdev.idle,dev.blocked,dev.awaiting_gate,dev.awaiting_approvaledev.idle_tripped, que antes também contavam como ativos. Tipodev.*que o painel não decidiu NÃO vira ativo: na dúvida a tela não afirma trabalho. - O bloqueio por container é marco próprio (
espera, cor dewarning, a mesma do gate), com oreasondo engine como detalhe — é ele que diz o que fazer. Edev.starteddeixa de dizer "começou a task": medido no engine, ele sai ao RECEBER a ordem de trabalhar, antes detry_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_failednã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, otronco, NÃO É RENDERIZADO por consumidor nenhum (medido:AgentTimelineTreee a seção Atividades doShellleem sóramos). O evento continua no log cronológico, a um clique, e o ramo bloqueado já diz, peloreason, 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 emTRADUCAO, no código, e é ela que o spec compara com o engine; só o TEXTO saiu, como CHAVE (ChaveDeRotulo) resolvida emexecutors:timelineTree.label.*, com a frase do presente emtimelineTree.now.*e o detalhe de origem emtimelineTree.detail.origin, nos dois idiomas (ené o default, RN-425).montarArvorerecebe o idioma como argumento (getFixedT), e os dois consumidores (AgentTimelineTreee a seção Atividades doShell) o passam douseTranslation— trocar de idioma refaz a árvore. Uma chave que a tabela usa sem texto em um dos dois locales reprova emtimeline-tree.test.ts. -
Código:
apps/web/src/lib/timeline-tree.ts:161(TRADUCAO),:205(dev.started),:206(dev.workingcom o título),:222(dev.blocked_by_container),:227(dev.error),:246(TRADUCAO_FORA),:307(oativolido 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::308todo tipo do engine decidido,:324nada traduzido que o engine não emite,:342nada nos dois lados);apps/web/src/lib/timeline-tree.test.ts:130(a sequência medida:dev.started+dev.blocked_by_containerdeixa o ramo parado com o motivo — caso de falha),:149(dev.startednão afirma task),:156(estados que o painel não chama de trabalho deixam o ramo parado;dev.workingsegue ativo com o título),:168(dev.errorencerra 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):
- A api diz se há broker, na rota que o assistente já lê.
GET workspaces/:workspaceId/projects-basepassa a devolverbrokerConfiguradoao lado deprojectsBase— 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 queBROKER_URLexiste, nunca que o broker responde. - Três estados, que não colapsam. Broker confirmado: a RN-513 intacta.
Ausência confirmada:
containeremountedcontinuam na tela, inertes (o controle sai, a informação fica — RN-102), o motivo é dito UMA vez em texto abaixo dos cards, erunneré o pré-selecionado. Não sei (carregando, consulta que falha, campo ausente): não vira "tem" —mountedNÃ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;containersegue selecionado, como antes. - 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á derrubavamountedquando 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,:256o 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 perguntaconfigurado());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(comBROKER_URL,true),:69(sem,falsemesmo com base — caso de falha),:79(só espaço conta como ausente),:52(o mínimo continuamaintainer);apps/web/src/routes/NewProjectWizard.test.tsx:504(sem broker:runnerpré-selecionado, os dois cards inertes, o motivo em texto),:523(broker desconhecido: não pré-selecionamountednem trava nada),:534(clique emcontainerantes da resposta cai pararunner),: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):
- A lista diz se há broker, em toda linha.
GET workspaces/:workspaceId/containersganhabrokerConfiguradopor 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. - Um quarto motivo de recusa,
sem_broker_na_instalacao, paracontaineremountedquandobrokerConfigurado !== 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_URLvazia) e o que não é afetado (runner). runnernunca cai nele. Quem depende do broker é decidido pela MESMA ramificação por destino deacaoDeSubidaDoModo(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,falseem toda linha — caso de falha),:264(com broker,true);apps/web/src/routes/containers-subida.test.ts:54(containeremountedrecusam antes da imagem),:75(runnernã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(runnersegue 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:
- 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. - 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-vfaria. É o gid visto de dentro que ogroup_addprecisa (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 o999do default do compose. - As quatro linhas vão juntas ou nenhuma vai:
COMPOSE_PROFILES=container-broker,BROKER_URL=http://broker:8090,DOCKER_GIDePROJECT_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_envrecusa 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. - A raiz da pasta gerenciada é CALCULADA e depois CONFERIDA. Antes da
subida,
<DockerRootDir>/volumes/brabo_project_workspaces/_data; depois, comparada com oMountpointdo 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. - 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:
containertambé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çobrokere a redebroker) - Teste:
scripts/dev/install-broker.spec.ts:158(sem terminal: desligado, dito, Docker intocado — caso de falha),:169(Enter),:179(n),:187(sliga, com--mounte 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.envcontra 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(otool.resultcom o motivo);apps/engine/lib/engine/infra/infra_lead_server.ex:251(a interceptação depropose_infra_prperguntando antes do HALT),:638(recusa_de_infra_pr/4),:309(otool.callcom os caminhos),:314(otool.result) - Teste:
apps/engine/test/engine/agents/arquiteto_server_test.exs:107(caso de falha: sem repositório,open_adr_prnunca proposta, motivo como resultado de ferramenta,tool.calletool.resultno 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, nempropose_actionnem 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:
- Liam o começo.
EngineApiClient.list_events/2pedelimit=200semlatest, e a rota interna — que nem aceitavalatest— devolve os PRIMEIROS 200. Numa conversa de 201 eventos o agente acordava sem a mensagem que estava respondendo. A tela já tinha resolvido isso comlatest: truee aviso de recorte (RN-180); o engine não tinha herdado. - Reconstruíam só
chat.messageeagent.response. A pergunta feita por formulário (chat.structured_question) e as ferramentas chamadas (tool.call/tool.result) sumiam. - 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:
- Um caminho só.
Engine.Agents.Reidratacaosubstitui as seis cópias; os seis servidores chamamhistorico/3noinit/1. - A cauda, com o teto do ADR 0060. A leitura é
latest=true&limit=200— oMAX_LIMITda rota, nem mais (a api cortaria) nem menos (deixaria de fora o que ela entrega de graça). O teto NÃO ficou ilimitado. - O que entra.
chat.messageeagent.responsecomo sempre (de qualquer agente da sessão — é o que o PO herda do Criativo);chat.structured_questioncomo fala do agente, com rótulos e opções;tool.call/tool.resultdo PRÓPRIO agente como nota de texto com a ferramenta, os argumentos (cortados em 1.500 caracteres) e o desfecho — nunca como mensagemrole: "tool", porque o evento não guarda o id da chamada e umtool_resultsem otool_usecorrespondente é recusado pelo provider.chat.structured_question_answeredfica DE FORA de propósito: a api grava as mesmas respostas numchat.messagelogo depois (AnswerStructuredQuestionUseCasereusaSendAgentMessageUseCase), e reidratar os dois duplicaria a resposta. - 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 é oseqdo 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. context.compactedgrava o resumo daqui em diante —summary,agentemessagesSummarized, 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.- 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.
- 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: semlatest/typesa 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:1066gravado);
apps/engine/lib/engine/sessions/engine_api_client.ex:1185(list_events/3); os seisinit/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(semtool.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 seisinit/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(typesfiltra e olimitconta só eles),:143(typescomlatest) -
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):
- O aceite sai na hora.
TurnoAssincrono.iniciar/3responde:okaofromassim que a Task sobe — depois de persistiragent.status: working, nunca antes. Vale para os seis conversacionais e para as quatro rotas de usuário que disparam turno (…/agents/:agent/messagee, 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. - 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_andamentoganhaagent.errordurável (origempolitica); os outros dois já tinham. - O desfecho do turno segue pelo canal e pelo log, nunca pelo HTTP. Falha
continua
agent.errordurável, com a mesma origem de antes. - 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 oagent.statuspersistido mais recente daquele agente não éworking. Sem nenhumagent.statusdo agente na janela, não fecha — não saber não é "acabou". - O handoff ao Dev Lead continua nascendo DEPOIS do de Infra. O
:offer_dev_handoffque 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),:130e: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(workinggravado 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:
- O engine avisa toda escrita confirmada. A fachada
Engine.Sessions.EngineApiClientemiteevent.appended(sótypeeactorId, nunca opayload) depois que a api CONFIRMAappend_event,append_event_returning,propose_action(proposed_action.created),create_handoff(handoff.offered) ecreate_epic/story/task(backlog.*_created). Escrita recusada não avisa. Antes, sóArtifactEmittere 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.AppendSessionEventUseCasee oSendChatMessageUseCase(os dois únicos pontos que gravam emsession_eventssem passar pelo engine) chamamSessionChannelNotifier.eventAppended, que pede ao enginePOST /internal/sessions/:id/event-appended; o engine faz o MESMOevent.appended(sótypeeactorId). O aviso sai DEPOIS do commit da transação mais externa (aposCommitnoDrizzleUnitOfWork): 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 peloOrigemEngineMiddleware) 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 emsession_eventssemAppendSessionEventUseCaseprecisa chamar o notifier também. - "Canal vivo" é join CONFIRMADO.
connectSessionHeartbeatmarca a sessão viva nookdo 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. - 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. - 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 detool.resultviraria 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. - Aba oculta não polla.
refetchIntervalInBackground: false(o default do TanStack v5, agora escrito emOPCOES_PADRAO_DAS_QUERIES) — medido por teste, não suposto. - Corpo vazio tem validador. Handler que devolve
nullsaía com corpo vazio e SEMETag(ores.senddo Express só geraETagcom corpo), então o 304 era impossível:sessions/:id/budgetvoltou 304 em 0% das 483 chamadas medidas eexecution/sessionem 22% (o tempo com execução ativa). A api grava oETagdo corpo vazio quandosendsai 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. - 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 oagent.doneperdido, e o aviso do canal pode se perder junto com ele, então o tique NÃO vira fallback de 15s. O que muda: oevent.appendeddoagent.statusdo 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):intervaloDaSessaosó 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:
- 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_questionouagent.errordo lado do agente;chat.messageouchat.structured_question_answereddo lado do usuário) é do AGENTE, e o ator é um conversacional.agent.errorconta 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. - É o único sinal com TETO: 8 horas, contadas do FIM do turno do agente
(o
created_atdaquele evento), não do último heartbeat. A api devolve o instante (aguardandoUsuarioDesde) e o engine aplica o teto (SESSION_CONVERSATION_IDLE_TIMEOUT_MS, default28800000). Passado ele, a sessão fechaclosedcom causa PRÓPRIA,conversation_idle_timeout— nãoheartbeat_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. OTerminationClassifiera 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): oSessionServertem 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. - Sinal sem teto vence. Com handoff
offered, açãopending, agente em turno ou dev agent trabalhando, o instante voltanulle a sessão fica sem teto, como sempre ficou. É por isso que o sinal é o ÚLTIMO da lista. - Instante presente e ilegível é ERRO, nunca
nil.nilcompending: 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:
- Sessão
closed/closed_abnormallyrecusa evento de CONVERSA com 409 NOMEADO:{ message, reason: "sessao_encerrada", status, type }.closingainda aceita — a trava é de estado TERMINAL. - "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 maisdev-leadeinfra). 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 umToolLoopcontra ela e gravatool.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. - A decisão humana sobre ação continua entrando.
action.approved/action.deniede 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çãopendingsegura a sessão, sinal 2 da RN-064); ele só existe quando a sessão morreu por outra porta. - A recusa não deixa rastro. O estado vem do MESMO
UPDATEque reserva oseq(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, oseqsegue sem buraco. O chat humano, que gravachat.messagesem o funil, tem a mesma trava. Os casos de uso com efeito ANTES do evento recusam antes do efeito:CreateHandoffantes de criar a linha,AcceptHandoffantes de marcaraccepted(que ativaria o agente seguinte),ActivateAgentantes de chamar o engine. - 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 noterminate/2dos 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:okno aceite. Pelo mesmo motivo ooffer_dev_handoffque 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 dohandle_call, é morto se não sair em 5s. - A recusa nunca é calada no engine. Quase todo chamador de
append_event/3descarta o retorno; o cliente registra o 409sessao_encerradacomo aviso no log, e o retorno segue o mesmo. - A tela reconhece o 409 pelo CÓDIGO e diz que a sessão fechou (AT-154).
ehRecusaDeSessaoEncerradaconferestatus === 409Ebody.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 (é ostatusnovo 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 deisTerminal), não só emclosed(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 mesmoUPDATEdoseq),: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_infodo 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 recusachat.messagecom 409 nomeado — caso de falha),:167(sem evento e semseqconsumido),:183(Psicólogo e Anamnese continuam entrando),:211(decisão sobre ação continua entrando),:223(sessão ativa aceita — caminho feliz),:244(closingaceita),:258(SendAgentMessagenã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),:152e: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.closedpara 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(oterminate/2abandona);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.
- 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_agentidempotente 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. - O fechamento anterior não se apaga. A linha de
sessionsperdeclosed_atetermination_reason; o evento NOVOsession.reopened, no mesmoseq(RN-002) e com o ator humano que reabriu, carrega{from, to, closedAt, terminationReason}. Nenhum evento é editado nem renumerado, e okindnão muda (RN-097). - Sessão com execução não reabre — 409
sessao_com_execucao. Reabrir uma sessão comexecution.activateda devolveria afindActiveExecutionSession(RN-139). Decisão do dono, confirmada no ADR 0184. - Sem prazo. Toda sessão encerrada é reabrível (decisão do dono, ADR 0184).
- 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. - O fechamento que a reabertura desfez não para a sessão viva. O
SessionLifecycleWorkerlê ostatusda sessão e ignora, com log, o job desession.closed/session.closed_abnormallyde uma sessão que a api já diz não-terminal. - A segunda análise automática do Psicólogo fica bloqueada pelo
alreadyAnalyzedque já existia; o reprocessamento manual segue. A segunda consolidação do grafo estende a janela da mesmaInteracao.
- 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;closingnunca volta);apps/api/test/application/use-cases/sessions/reopen-session.use-case.spec.ts:121(closedreabre, log preservado,session.reopenedcom 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 é 409sessao_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áactivenão para o processo),:82(fechamento de sessãoclosedpara, 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 emrepo_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 deactivateAgent);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:369e: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 provisionalocalcom o slug do projeto", "o repositório nasce ANTES de o Arquiteto acordar", "aceitar para o Dev Lead continua provisionandolocal(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,:427e:440;apps/web/src/routes/SessionPage.handoff-devlead-e-colapso.test.tsx:575;apps/engine/test/engine/agents/arquiteto_server_test.exs:134eapps/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):
| Destino | A tela oferece? | A api aceita? | O engine tinha cláusula? |
|---|---|---|---|
criativo | sim (composer: fallback da sessão criativa e AGENTES_DE_CHAT) | sim | NÃO — era a cláusula final, sem guarda |
po, arquiteto, dev-lead, ux-designer, staff | sim (AGENTES_DE_CHAT, o agent.activated mais recente) | sim | sim, uma cada |
infra | não no composer (fora de AGENTES_DE_CHAT desde o achado 9-fix e a RN-499) | sim | nã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 perguntam | sim | não — caía no Criativo |
| nome desconhecido, ou nenhum | não | sim (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:
- 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. - 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 comoUnprocessableEntityExceptioncom a MESMA frase (opostComandoDeTurnoda RN-578, sem código novo), e a tela a mostra pelomensagemDaRecusaDoAgenteque 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. - O
infratem 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 ouser_message/2que oInfraLeadServerainda exporta não serve para uma cláusula: ele roda o turno INTEIRO dentro dohandle_call(até 180 s), sem o aceite do ADR 0163 e sem "Parar" (via_for/2não o conhece). Ligá-lo devolveria ao clique a espera que a RN-578 tirou. - O "Parar" também não adivinha.
cancel/2sem"agent"parava o turno do Criativo; passa a ser 422agente_ausente. A api sempre manda o agente nas duas rotas, então essas duas recusas só alcançam chamador quebrado. - A tela continua não oferecendo o
infrano composer — nenhuma mudança de UI:AGENTES_DE_CHATjá 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 demessage/2, ovia_for/2do "Parar" e o lugar emAGENTES_DE_CHAT. A recusaagente_sem_conversadele 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 recusaturno_em_andamentoda RN-578) e registrá-lo no "Parar"; e exige decidir o que uma conversa faz com um agente cujo contrato é propor. Ouser_message/2dele segue exportado e sem chamador. -
O
chat.messagegravado pela api antes da recusa continua no log — e desde a RN-587 o engine grava oagent.errorda 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 doinfra),: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:115e: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(oinfra: 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:
- Os dois sinais de fim de turno saem só de
finalizar/1, que roda no processo do GenServer, dentro dohandle_infoque JÁ zerouturno_assincrono:agent.doneno canal eagent.status: idlepersistido. São os dois, e só eles, que a tela usa para fechar o turno (onAgentDoneeturnoTerminouNoLog, RN-578 item 4). - 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. - O que a Task grava não é sinal de fim.
agent.responseeagent.erroraparecem no fio com o turno ainda aberto no agente. Tela ou cliente que passasse a fechar o turno por eles reabriria a janela; emitiragent.doneouidlede 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 nohandle_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, oagent.responsejá está no log e nemagent.donenemidlesaíram; depois doidle, a mensagem seguinte é aceita — o caso de falha é a recusaturno_em_andamento, que o teste nega);apps/engine/test/engine/agents/po_server_test.exs:478(espera oidleantes 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.
- O
workingque ficou sem turno vivo é fechado por evento NOVO, nunca reescrito (evento é imutável): umagent.errorcom origeminfraereason: 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 deagent.status: idlepersistido. O canal recebeagent.erroreagent.done, para a faixa de quem está com a tela aberta sair sem recarregar. Vale para os que compartilhamTurnoAssincrono— seis na origem, sete desde a RN-617. - 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.
- Só o ÚLTIMO
agent.statusde cada agente conta, e sóworking:idlejá está fechado, eawaiting_approval(Dev Lead suspenso, RN-284) é decisão pendente, não turno morto. - "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) é doSessionServer, 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; oAdopternão varre — quem chega aoworkingórfão depois que o pod antigo morre é oinit/1do agente. - 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 oinit/1de 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 ( — fechado na
RN-617, que o pôs no infra) roda o turno dentro do
handle_call, sem TurnoAssincrono, e fica de foraTurnoAssincrono 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); oinit/1dos seis*_server.exchamaTurnoOrfao.fechar_ao_subir/3 - Teste:
apps/engine/test/engine/agents/turno_orfao_test.exs:72(nos seis: oworkingsem turno vivo viraagent.errorinfra+idle, e o LLM não é chamado),:108(awaiting_approvalnão é órfão;idlee oworkingde outro agente não são fechados),:124(varrer/2pula o agente com processo vivo em ESTE nó ou num nó:peerde 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:
| Recusa | Quem a produz | agent.error durável ao lado do chat.message? |
|---|---|---|
409 turno_em_andamento | TurnoAssincrono (RN-578) | JÁ SIM — emitir_recusa_por_turno_em_andamento/1, com teste |
409 aguardando_aprovacao | DevLeadServer.handle_call (RN-578) | JÁ SIM — reason: "aguardando_aprovacao_de_plano" |
422 agente_sem_conversa, mensagem_sem_texto, agente_ausente | o controller (RN-584), sem *Server | NÃO — a explicação vivia só no toast |
A regra:
- O engine é a fonte da recusa E do registro. As três recusas 422 gravam
agent.error(origempolitica,mensagemigual à frase do 422,reasonigual aomotivo) e o transmitem no canal da sessão — o mesmo par durável/efêmero das duas 409. O fio já desenhaagent.error(SessionPage.tsx,lerFalhaDeTurno), então nenhuma tela mudou. - Só há o que gravar com sessão nomeada. Sem
projectId/sessionIdno corpo (chamador quebrado) a recusa segue só como resposta. - O ator não vem da rota.
infrae os seis que conversam entram comoagent; qualquer outro nome (que a rota pública deixa passar) é osystemengine— um nome digitado nunca vira ator no log. - 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 oagent.responsepoderia 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 oinfra, que passou a conversar: oagent.errorcom 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 demessage/2érecusar_mensagem/5e ela grava oagent.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:DOWNtardio 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/1marca o repasse — trocar a chamada de volta porexpect_stop/1reprova 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:
- Os seis gravam. Criativo, PO, Arquiteto, Dev Lead, UX Designer e Staff
emitem
tool.resultdepois de TODA chamada que devolve texto. O payload sai de UM lugar,Engine.Agents.ResultadoDeFerramenta.payload/2:tool,oke — no sucesso —resultado; na falha,erro(como o Criativo já fazia). - Com teto, e o teto diz o total. O texto é cortado em 2.000 caracteres;
quando corta,
resultadoTotaltraz 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. - Aditivo. Campo novo em tipo existente, sem migration;
tool/ok/erroseguem como eram. Evento é imutável: sessão anterior continua sem o texto, e a nota da reidratação segue dizendo isso para a chamada semtool.result. - A reidratação usa o texto.
desfecho: ok, devolveu: <texto>(com aviso[cortado; o total real tinha N caracteres]quando houverresultadoTotal). Continua nota de texto, nuncarole: tool— o evento não guarda o id da chamada. propose_adrdeixou de gravar o própriotool.resultde 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;:145sessão antiga semtool.result),apps/engine/test/engine/agents/po_server_test.exs:252(o evento gravado, cortado, comresultadoTotal) - 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:
- Um tradutor só. A tradução evento → pasta do agente, nome do arquivo e
Markdown SAIU do
ArtifactProjectorparaArtifactEventTranslator; 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 sendoARTIFACT_PROJECTABLE_EVENT_TYPES— tipo novo entra ali e é traduzido no mesmo lugar, nunca num segundo tradutor. - Idempotente e nunca apaga. O nome é função do evento (versionado:
<tipo>.md; append-only: com oseq), então rodar de novo regrava os mesmos arquivos; retomar é rodar de novo, ou--after-eventa partir do cursor impresso. Arquivo que o usuário pôs emdocs/fica. - Não toca a outbox (
aggregate_type = 'artifact_projection'): roda com a api de pé sem roubar, reabrir ou marcar linha. - 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.
--projectinexistente é 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 sobresession_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).
- Conversão de modo (
ExecutionModeSection). O campobrokerConfiguradojá vinha emGET .../projects-base, que a seção já lê. Converter paracontainer/mountedcom 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 pararunner, ou para o modo que o projeto já tem, nunca é bloqueado. É regra de TELA, como a da criação: oPUT projects/:projectId/execution-modenão a aplica. - Parar/remover na
/containers.container_stop/container_removeemcontainer/mountedtambé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),!== truecomo na subida.runnernunca cai aqui. - A api recusa ao propor.
ProposeActionUseCaseresponde 409sem_broker_na_instalacao, ANTES de criar a proposta, paracontainer_start/container_stop/container_removeem projeto que não érunnerquandoconfigurado()é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 daconfigurado()), e a chamada que a tool JÁ fazia devolve a recusa.dispatch_container_start/2passou 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, econtainer_stopseguependingcom 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.
- A api devolve o marcador. Cada item de
GET /projects/:projectId/sessionscarregatechnical: boolean(SessionListItemResponseDto),truesó para a sessão derepo_bootstraps.session_idDAQUELE 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 chamadagit-bootstrapnão vira técnica. - Só a listagem o carrega. O marcador não é coluna de
sessionsnem campo da entidadeSession: o GET de uma sessão e as respostas de criar/renomear/transicionar seguem comSessionResponseDto, sem ele. É a listagem que a tela ordena para achar "a mais recente". - A tela lê o marcador.
sessaoMaisRecente(useLatestSession) pula a sessão comtechnical: truee só a devolve quando é a ÚNICA — a regra da RN-582 inalterada, com a fonte trocada. O tipoSessaoListadado 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 chamadagit-bootstrapnã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:
- Dev Lead: o
tool.resultnasce na RETOMADA, nunca na suspensão. Quandopropose_execution_plan/assess_implementabilitysuspende ({:pending, _}), o log fica só com otool.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 otool.resultcom o MESMO texto que entra na mensagemrole: "tool".oksegue a partição detexto_do_desfecho/1:executed/auto_approved/approvedsãotrue; recusa, falha e status que o módulo não conhece sãofalse—ok: truesobre desfecho que ninguém classificou seria afirmar sucesso sem saber. Desfecho de OUTRA ação continua ignorado e não grava nada. - Infra Lead: toda ferramenta despachada inline grava.
validate_infra_file,propose_container_startecontainer_start_via_runnergravamtool.resultporEngine.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ãook: falsecom o motivo. A recusa depropose_infra_prsem repositório (RN-577) passa pelo mesmo módulo, com o payload de antes (tool,ok: false,erro) e o corte. propose_infra_praceita segue SEMtool.result, de propósito: ela não emitetool.call(o rastro dela é aproposed_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/2doaction_settled, otool.resultem:271),:746(sentido_do_desfecho/1);apps/engine/lib/engine/infra/infra_lead_server.ex:1111(registrar_resultado/4),:317(a recusa depropose_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: falsecom o motivo);apps/engine/test/engine/infra/infra_lead_server_test.exs:253(o desfecho devalidate_infra_file),:409(proposta aceita),:485(recusa por modo),:863e:910(container_start_via_runnercom 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:
BROKER_URLtem 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.envcontinua vencendo. Os composes de PRODUÇÃO e de INSTALAÇÃO seguem${BROKER_URL:-}, de propósito: lá o broker está sobprofiles: ["container-broker"], e quem o liga grava a URL (oinstall.sh, perguntando — RN-575). Um default ali faria a api de uma instalação sem broker afirmar que tem um.- O
preflight.mjsRELATA a pasta gerenciada, no molde doDOCKER_GID. SemPROJECT_WORKSPACES_HOST_DIR, api e engine usam o volume gerenciadoproject_workspaces(sem caminho de host), o broker fica semPROJECT_WORKSPACES_HOST_ROOT— o compose de dev a DERIVA daquela —, o stack sobe saudável e ocontainer_startdo modocontainer, 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 comdocker compose config) edivergente(PROJECT_WORKSPACES_HOST_ROOTexplí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 deBROKER_URL),:571(a derivação dePROJECT_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):
- Não mandar o que já se sabe recusado.
nomeAceitoPelaApiespelha o DTO (2+ caracteres, recortado, e slug não vazio) e passa a ser o gate de nome do passo de detalhes. No modorunner, "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. - Uma criação por vez.
criacaoEmVoo(umuseRef) trava os DOIS caminhos que criam — "Procurar pasta..." e "Provisionar" —, porque odisabledsó chega ao botão no render seguinte. No jsdom odisabledjá segura o segundo clique; a ref é o que segura dois eventos no mesmo quadro do navegador. - A recusa na tela. A falha 400 da criação antecipada passa a pôr a frase
da api (
mensagemDaApi, lista doclass-validatorjuntada) no MESMOAlertdo 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(describecriar 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:
inspectsegue curto (5 s) — é a leitura da/containerse 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".execDERIVA dotimeoutMs(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 odocker versionde diagnóstico (30 s) + 5 s de margem. O broker corta o comando emtimeoutMse RESPONDEtimedOut: true; a api tem de estar esperando quando a resposta chega.start/stop/removeesperam o pior caso do PRÓPRIO broker: contexto +ps+ a operação (oruncom o pull; ostopcom os 10 s de graça;rm --force) + odocker versionque 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 SEGUNDOpsdoresolver(o gerenciado e o homônimo): o pior caso real era 130 s. Desde a RN-605 ela conta as seis chamadas de controle dostarte dá 195 s.- 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 comoFOLGA_DO_EXEC_NO_ENGINE_MS, e o teste do engine lê esse número do fonte da api). - Teto estourado tem motivo próprio:
BrokerIndisponivelErrorganhateto-excedido, distinto desem-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; orun/pull pode ter acontecido, então confira/containersantes de repetir. Na leitura da telateto-excedidocai no MESMOnaoObservado: 'broker-sem-resposta'de sempre, com o texto nodetalhe— 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, origeminfra) em vez de "código -1" comorigem: null; o teto em si não subiu, por decisão do mantenedor. -
O
fetchdo Node espera cabeçalhos por no máximo 300 s. UmexeccomtimeoutMsacima de ~255 s é cortado pelo undici antes do teto desta regra; o erro saiteto-excedidonomeando 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ãoTERMINAL_ACTION_TIMEOUT_MSacima de ~210 s não chega inteiro. -
Broker doente no
ps(odocker pslevando os 30 s inteiros e o diagnóstico outros 30) pode exceder o teto doexeccom comando curto; saiteto-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 brokernode:httpque demora 6 s),:182(o teto de cada operação noAbortSignal),:204e:229(teto-excedidonomeando operação e número),:245(o teto do undici),:330(describetetoDaOperacao, 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 dizendobroker-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:
- O pull é explícito. Antes do
run,image inspect; imagem presente não é baixada de novo (o mesmo--pull missingdorun). Ausente,docker pullsob o MESMO teto de controle de 30 s — nenhum teto maior. - O estouro tem nome.
PullExcedeuTetoError, origeminfra, 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 sendoDockerIndisponivelError. Pull que falha por outro motivo segue o caminho de sempre (ComandoDeDockerFalhouError, sem origem, ADR 0128). - O nome atravessa. O broker responde 504 com
origem: 'infra', e a api o repassa emBrokerRecusouError—origempreenchida, nuncanull. No modorunnero mesmo adaptador roda na máquina do usuário e a mensagem chega nocontainer_start_result. - Os tetos de quem espera o
startcontam o pior caso inteiro: os doispsderesolver,image inspect,pull,rune odocker versionde 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, noRunnerRouter, 6 × 30 + 5 = 185 s nostarte 4 × 30 + 5 = 125 s nostop/remove(eram 60 s, menos que oresolvermais a operação). - O
propose_actiondo engine espera a execução que a api faz dentro dele.container_start,container_start_via_runnerecontainer_stopauto-aprovados são EXECUTADOS na mesma requisição (ProposeActionUseCase), e a chamada caía no default de 15 s do Req — umstartlongo voltava como timeout de transporte mesmo sem pull. Agora ela levareceive_timeoutde 225 s (o teto da api + 30 s), espelhado na api comoTETO_DO_PROPOSE_ACTION_DE_CONTAINER_NO_ENGINE_MS; os testes dos dois lados conferem broker < api < engine e runner < engine.container_removefica 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:
ExecuteContainerStartUseCaseroda dentro doPOSTde 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 origeminfra);apps/api/test/infrastructure/http-clients/container-broker.client.spec.ts:144(chega comorigem),:362e:377(a conta e a ordem);apps/runner/src/index-handlers.spec.ts:420(o nome nocontainer_start_result);apps/engine/test/engine/sessions/engine_api_client_propose_action_test.exs:67(HTTP real: a api que demora 16 s é esperada),:82e: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:
| modo | ancora | segmento |
|---|---|---|
container | raiz_gerenciada | o workspace_dir_name — a raiz É <PROJECT_WORKSPACES_ROOT>/<segmento> |
mounted | base_de_projetos | o pedaço sob BRABO_PROJECTS_BASE, pela MESMA segmentoSobABaseDeProjetos que o broker usa |
mounted (sem base, fora dela, ou a própria base) | indisponivel | null |
runner | nome_da_pasta | o 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(scopeRootsó emterminal);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 deindisponivel) - 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áusula | propose_container_start | container_start_via_runner | Leitura local |
|---|---|---|---|
container já REGISTRADO running/provisioning (ja_esta_de_pe) | recusa | recusa | project_containers.status |
nenhuma imagem decidida (sem_imagem_decidida) | não recusa | recusa | existe artifact.project_image em alguma sessão do projeto |
pasta nunca confirmada (runner_nunca_confirmou) | — | recusa | projects.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_runnersobe 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_atvazio, RN-423) — nenhumbrabo-runnerjamais conectou a ele, econtainer_start_via_runnernão foi proposta. Peça ao usuário para rodarbrabo-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,runningrecusa),:804(provisioningrecusa),:814(stoppednão recusa),:779(sem imagem),:790(pasta nunca confirmada),:850(propose_container_startcom containerrunning),:850(propose_container_startsem 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,userIddo 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 classificadasjwtemdocs/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 demaintainercontinua 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 —
classifyEventganha o ramo deproposed_action.created(tipopermission,badquando 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 deproposed_action.created);apps/web/src/components/ApprovalCard.tsx:272(a linha do card);apps/web/src/routes/session-timeline-montagem.tsx:825eapps/web/src/routes/ProjectApprovalsTab.tsx:311(decisoesDaPolitica, desde a AT-336 poruseDecisoesDaPolitica) 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.
| movimento | desfecho |
|---|---|
| remover a SI MESMO do workspace, havendo ou não outro owner | 403 |
remover OUTRO owner | passa — é como se revoga propriedade por inteiro |
remover o TITULAR (created_by), por outro owner | 409 criador_do_workspace — RN-616 |
| remover qualquer outra pessoa | passa |
| remover quem não tem linha de workspace | 204 — 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(ownernas duas rotas de membro, 204, a ordem ator/alvo, o 403 propagado);apps/api/test/interfaces/route-surface.spec.ts(a rota classificada emdocs/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
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 — edescribe"TransferWorkspaceOwnershipUseCase": para owner gravacreated_by; paramaintainere para não-membro é 409titular_precisa_ser_owner; para o titular atual é idempotente);apps/api/test/interfaces/http/iam/workspaces-members.controller.spec.ts(ownere 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.
- 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 oagent.status: workinggravado ANTES (RN-578); o desfecho vai pelo canal e, quando é falha, peloagent.errordurável. Ouser_message/2que rodava o turno inline SAIU: o nome ficou e é o aceite. - Segunda mensagem com turno em curso é
409 turno_em_andamento, com oagent.errorde origempoliticano fio — inclusive durante o kickoff, que antes prendia a mensagem na fila do processo pelo turno inteiro. - "Parar" alcança o Infra Lead (
via_for("infra")nocancel/2): mata a Task — e com ela oWorkflowsAgent, que roda dentro da consolidação —, gravaagent.errorcancelado_pelo_usuarioe devolveidle. - 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ó deTurnoAssincrono, no processo do servidor (RN-585). - 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 — oTurnoAssincronosemfromDESCARTA o pedido que encontra turno em curso, e o gate pediria mudança a ninguém. - O turno órfão do reinício fecha pela RN-586:
infraentrou emTurnoOrfao(@agentes), no boot e noinit/1. - O histórico vem de
Engine.Agents.Reidratacao(RN-580), como já vinha, e otool.resultdeResultadoDeFerramenta(RN-593). - 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.
message/2tem cláusula PRÓPRIA parainfra, e a recusaagente_sem_conversadele saiu — um nome, um caminho.@agentes_de_conversao inclui; a mensagem a um Infra Lead que não está de pé (engine reiniciado) o reergue SEM kickoff, e ele reidrata noinit/1.- A tela o oferece pela regra de sempre:
infraentrou emAGENTES_DE_CHAT, então ele é o destinatário quando é oagent.activatedmais 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. - 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 doinfra),: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,workingantes),: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.
- 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.
- 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.
- 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.
- 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.
- Não é
agent_instructions: é texto de código e de template versionado, fora do teto deinstruction_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é rodarscripts/dev/seed-prompts.tsde 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
- 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-brvirapt-BR) — nunca o enumpt-BR/enda interface, que continua fechado.und, uso privado e texto livre são recusados com 400 que nomeia a regra. - Conta nova nasce no AUTOMÁTICO.
users.response_languageéNULL("automático") até a pessoa escolher; a api mostra e aceitaautomaticocomo o nome desse estado. - Interface e respostas são independentes.
PATCH /users/me/preferencesaceitalocaleeresponseLanguagecomo OPCIONAIS; mandar um não toca o outro, um corpo sem nenhum é 400, e umresponseLanguageinválido recusa o corpo INTEIRO antes de gravar qualquer campo. - O detectado só vale confirmado. O idioma detectado pelas mensagens da
pessoa (a detecção é a RN-624) só entra em
users.detected_languagejunto comdetected_language_confirmed_at— um CHECK recusa um sem o outro. A detecção sozinha não troca nada. - 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. OGET(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. - A precedência, resolvida por pessoa e nunca por sessão: override da
sessão > escolha da conta > detectado confirmado >
users.locale. Comousers.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
- 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). - 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.automaticoe código não reconhecido são 400, e o projeto não nasce (ou não muda). - Projeto novo: o
languagepedido 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 ousers.localede 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 É ousers.locale. - Projetos que já existiam: a migração
0062_idioma_do_projetograva 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 é ousers.localedele. O titular, e não "um owner" deworkspace_members, porque ele é UM só e a migração não pode escolher entre dois. - Quem troca:
PATCH /projects/:projectIdcomlanguage(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 defaultpt-BRda 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 eautomatico— 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 demaintainer: 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
- 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. - 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).
- "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. - Quem troca: o mínimo do
PUT(developer), porroleAtLeastsobre o papel de WORKSPACE (a lacuna declarada das telas que não buscamproject_members). Abaixo dele o valor e a origem continuam na barra, o seletor fica inerte e o motivo vem em texto. - 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, otitlesó devolve o que as reticências cortaram. - Na barra estreita, o idioma MUDA de lugar e não some (AT-317). A barra
se arruma pela PRÓPRIA largura (
modoDaBarra, medida porResizeObserver; 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 notitle), 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" mandanull),:180(recusa da api — caso de falha),:202(abaixo dedeveloper),: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).
- 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 emstate.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 dosystemdo topo (AT-161). Não éagent_instructions: é texto de código, fora do teto deinstruction_patch(RN-007). - Um lugar só. Quem acrescenta é a fachada
EngineApiClient(llm_turn/5ellm_turn_stream/6), por onde passa TODA chamada de LLM do engine — os sete conversacionais, oToolLoop(dev agents e gates) e o Infra Lead. Nenhum servidor monta a orientação. - 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ãoconta > detectado confirmado > interface) e o manda no comando
agent/messagecomoidiomaDaResposta, campo OPCIONAL. O servidor do agente o põe no estado do TURNO durante ohandle_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. - 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.languagea cada chamada (decisão do mantenedor, AT-169 resposta 1). - 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. - 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 virawarninge 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. - 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. - 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-BR29 / 23,en18 / 18, genéricoes-MX26 / 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. - Medição: o
chat.messagegravado pela api carregaidiomaAlvoeorigemquando 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
confirmReadinesssai 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 nollm_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.
- 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 dopt-BRé em português, a dos outros em inglês ("Write project artifacts in X."). Continua UMA mensagemsystemefêmera no fim, montada pela fachada; quem decide é o conjunto de ferramentas que a MESMA chamada leva ao modelo, que a fachada já tem. - 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).
- 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 deopen_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 noconfirmReadiness, que é turno sem autor e já sai no idioma do projeto (como o handoff e o revise, que esta regra não muda).
- 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. - Falha nunca derruba o turno. Ler o idioma do projeto que falha vira
warninge 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. - 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 dept-BR,en,es,es-MXezh-Hant-TW: pior casopt-BR→zh-Hant-TW42 / 36;en→pt-BR26 / 26; genéricoes-MX→zh-Hant-TW36 / 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).
- 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 deEngine.Harness.IdiomaDaResposta, e a fachadaEngineApiClientnão muda. - A orientação da RN-622/623 fica byte a byte. Zero token a mais nos 50
de teto.
ask_structured_questionssegue FORA de@ferramentas_de_artefato(é pergunta à pessoa do turno, RN-623 item 4), então autorennum projetopt-BRrecebe as perguntas em inglês — e é o que se quer. - 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 delabel18/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. - Guarda. Nenhuma descrição de ferramenta do harness (as 22 de
Engine.Harness.Tools.*) pode citarpt-BR,(en), "in English", "em português" ou "em inglês" — a doemit_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.
- 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 descripts/idioma/paraapps/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ó atoruser; 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. - 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, atorusere os dois tipos de evidência): medido com 200 mil eventos, 0,056 ms pelo índice contra 13,5 ms da varredura sem ele. - Os limiares são PROVISÓRIOS.
PARAMETROS_PROVISORIOSsã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). Listaat080; 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ê. - Quando pergunta.
GET .../sessions/:sessionId/response-languagedevolvedetectionQuestioncom o idioma a perguntar, ounull. 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 (ptnão diverge dept-BR— a heurística só sabe a língua); ou a pessoa já recusou esse idioma. - A detecção nunca troca nada sozinha. Só
POST /users/me/preferences/detected-languagegrava:confirmescreve o pardetected_language+detected_language_confirmed_at(o CHECK da RN-618) e apaga a recusa antiga do mesmo idioma — e é 409deteccao_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);declinegrava a recusa emdetected_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. - 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.
- 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) virawarningedetectionQuestion: null; o idioma continua sendo mostrado. - 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(409deteccao_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.
- 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), semcatalogoCompletopedido 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 ≠openrouternem o workspace é lido. A chave é a do titular do workspace, a mesma do chat (RN-058). - A política é a P3 e só restringe. Menu = {escolha do Jev, ferramenta
anterior da MESMA execução}.
responder_sem_ferramentado 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. - O
stateé o que o produto tem antes do passo, recortado dasmessagesque o engine já manda, sem leitura de banco:agente= oagentId;contexto= o começo (1.500 caracteres) da mensagemsystemque oToolLooppõe na frente (apps/engine/lib/engine/harness/tool_loop.ex:98,system_msg, montada porsystem_prompt/1em:316);pedido= a última mensagemuser— 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; epassos_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 mensagemtoolque o laço anexa (tool_loop.ex:293,concluir_despacho/5, comtoolCallId). A execução corrente é "depois da últimauser" porquectx.messagesrecomeça a cadaToolLoop.run. Teto de 8 mil tokens; estourou, quedaestado_grande. - 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 levamotivoDaQuedaeorigemDaQueda(infra,modelooucodigo— RN-059), nunca calado. - 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). - Registro por passo. O engine grava
tool_router.decided, na fachadaEngineApiClientpara todos os agentes, com menu antes e depois, escolha, confiança, segunda opção,aplicado, queda, latência, custo real eforaDoCardapio. A api grava UMA linha detoken_usagedo Jev: ator = o próprio agente (o gasto entra no orçamento de área, ADR 0110),modelName = typesafe/jev-1.13,modelIdnulo,estimated = false,costMicros= ousage.costda resposta, preço por milhão IMPLÍCITO (custo ÷ tokens) comprice_implicit = true— mantémtokens × preço = custoda RN-044 sem inventar preço de catálogo. Semusage.costna resposta não há linha. O gate de orçamento não é reconsultado entre o Jev e o chat. - 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(ostate),: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),:149e: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.
- O card fica inerte e diz a frase da api (AT-256).
ApprovalCardsegura 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. - Reabrir a sessão retoma o turno do log (AT-268). O estado do turno da tela
é
useStatelocal e se perdia ao sair. Na primeira leitura dos eventos de uma sessãoactive,turnoEmCursoNoLogolha oagent.statuspersistido mais recente de cada agente (a mesma leitura deturnoTerminouNoLog, da RN-578); o que estiverworkingreabre 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 oworkingantigo ainda no cache). Oworkingque 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). - As pendências de OUTRAS sessões do projeto aparecem no chat (AT-265).
PendenciasDeOutrasSessoeslê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 MESMOApprovalCard, os mesmos endpoints, com osessionIdque a própria ação carrega;onActivateAutoModefica 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. - "Mergear" no card da PR aberta (AT-266). Sob o card de um
pr_openexecutado, e só quando o eventoaction.pr_opentraz o id da PR e não hágit_mergevivo (pendente, aprovado ou auto-aprovado) para ela, o botão PROPÕE ogit_mergepelo mesmo endpoint da aba PRs (atoruser, 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 continuarequire_approvalincondicional (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. - O papel é o do ENDPOINT, e a lacuna é declarada. Decidir ação e propor
ação pedem
developer(actions.controller.ts); a tela usaroleAtLeastsobre o papel de WORKSPACE, porque a Sessão não buscaproject_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 (bloqueiodoApprovalCard): 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.
- Um só lugar decide o que fazer com o resultado de um subagente
(
tratar_resultado/6), venha ele derun/5ou deretomar/3.{:awaiting, _}guarda o estado em voo (chaveado pela nova ação), mantém a linha degate_statesemin_progresscom o subagente e PARA a área: sem veredito, sem bloquear a task, sem registrar a delegação. Quem retoma é o mesmo{:action_settled, _}doEngine.Dev.Wake(ADR 0086), quantas vezes forem necessárias. - Resultado que o lead não conhece nunca derruba nem some (RN-059): vira
{:blocked, …}com origemcodigo(falta uma cláusula aqui, não é infra, modelo nem política), queregistrar_resultado/5grava como delegaçãofailedeQaLead.consolidar/1transforma 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:
- 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. - Sem HTTP novo por poll. O campo viaja no resumo que as telas já leem (RN-579).
- 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. - 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).
- A api mede, o broker compõe.
container-specdevolveusuarioDaPasta(statda pasta dos modoscontainer/mounted;nullse a pasta não é alcançada, se o dono é root ou se o projeto érunner). O broker o copia paraspec.usuarioe o adaptador emite--user uid:gide--env HOME=/tmp. - Nunca vem de quem pede.
startnão tem corpo e o corpo deexecignora campo extra;execherda o usuário do container. - O broker revalida. Inteiros em 1..2^31-1;
0é recusado nomeandousuario.uid/usuario.gid(root é o ausente, "como sempre"). - 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(orunleva--usere 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).
- Depois do merge, nunca no lugar dele.
git_mergecontinua sendorequire_approvalincondicional (RN-418); a regra roda DENTRO deExecuteGitActionUseCase, na transação que grava o resultado do merge, e só quando o provider devolveustate: 'merged'(PR aberta, ou merge que falhou, não toca a tarefa). - A tarefa é achada pela PR que a abriu. Toda
pr_opendo projeto cujopullRequestIdé o da PR mergeada e que carregastoryTaskIdno payload. PR sem tarefa (infra, ADR) não muda nada. - Idempotente.
TaskRepository.markDoneIfNotDoneé umUPDATE … WHERE status <> 'done': merge repetido da mesma PR devolvenull, não move de novo e NÃO grava o evento. Quando move, grava UM evento imutávelbacklog.task_status_changed(cause: 'pr_merged',pullRequestId,actionId), append-only, sem UPDATE em tabela de eventos. Sem migration. - 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:
- 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-muteddo rascunho (#71717a) repetia a dívida e foi recusado (#86868fno escuro,#696972no claro), e o muted continua ABAIXO do--text-secondarynas três superfícies, o que o teste também cobra. - O texto sobre fundo sólido de marca/estado passa AA.
--on-accentdo 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. - 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:rootque o teste confere contradesign/tokens.css. Cor de agente é semprevar(--token); os três agentes sem semântico têm--agent-*por tema, medidos a 3:1. E nenhum módulo CSS usavar(--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.
- Mesma transação, depois da transição.
ApproveActionUseCaseaceita umaoAprovarque roda DENTRO da transação da decisão, depois deassertTransitionpassar e antes da execução; é por ele que o padrão (permissions.json/allowouagent_autonomy, RN-509) e opermission.grantedsã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_autonomye o evento são atômicos com a decisão; opermissions.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. - O evento acompanha a gravação, e só ela. Padrão que já existe não é
regravado nem narrado de novo (
padraoGravado: false). - 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 comdesfecho: '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 viraja_aprovada: propaga como sempre. - 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. - Tetos intactos.
git push/PR/deploy,sudo/doas(motivoDeRecusaSempreAprovar, RN-418) econtainer_removecontinuam 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). - Os TIPOS do teto também (AT-320). Medido na auditoria de 30/09: a api
só recusava o
git pushDIGITADO no terminal; ogit_pushTIPADO passava e gravavaGitPush()emallow(ou autonomia do módulo), e oApprovalCardoferecia o botão.TIPOS_SEM_SEMPRE_PERMITIRé a lista ÚNICA —git_push,pr_open,git_merge(inteiro: o padrãoGitMerge()valeria para todo destino),container_remove,instruction_patch,parallelize,raise_max_parallel— emotivoDeRecusaDoSempreAprovarjulga terminal pelo comando e o resto pelo tipo. A recusa é 400 comreason: 'teto_do_sempre_permitir'eactionType. A web esconde o botão pela cópia emapps/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 mudavadecide()nem a semeadura; desde a RN-689 mudam os dois —git_push/pr_openestão no teto dedecide()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:
-
As OPÇÕES são os agentes que conversam (
AGENTES_DE_CHAT) e que já estão na sessão:agent.activatedna janela,roster.activatedAgentsdo 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). -
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.
-
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.
-
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.
-
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. -
"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. -
A oferta é casada pelo
handoffIddo payload, nunca pelo ator: cadahandoff.offeredé o card da oferta dele, sem par para confundir nem "mais recente" para escolher. As ofertas a agente já ativado (janela OUroster.activatedAgents) não voltam a ser aceitáveis. -
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 peloactiveFor. A pílula do handoff manual diz "Handoff manual" em vez do id da pessoa. -
"Já está na sessão" se lê de fontes SEM janela escopadas à sessão PEDIDA (revisão do PR #759). O
roster.activatedAgentsdo 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 porstartdireto, 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 dedeveloper(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. -
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 seguiaofferede 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 OUTROhandoffIdfica 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):
- O handoff MANUAL não esconde "Estou pronto para produzir" nem "Confirmar
arquitetura pronta" (AT-293).
prontidaoJaDeclaradaearquiteturaJaDeclaradaperguntavam só "existe handoff saindo do Criativo/Arquiteto?", e o handoff manual (ADR 0109, RN-441) grava comofromAgento Ú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 dohandoff.offered(casado pelohandoffId, RN-631) — oHandoffda api não carrega o ator. Fora da janela de 200 o ator é desconhecido, e aí: para o Criativo vale oartifactId(o handoff da prontidão leva oproduct_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óprioHandoffda api; não foi feita aqui (a api é da lane do ciclo de vida, ADR 0182). - 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.activatedAgentsdo resumo, que é a sessão MAIS RECENTE do projeto — a de execução, depois de ativada.activeForcontinua 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 osessionIddevolvido, 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:
- Leitura de projeto que nenhum canal avisa polla a 15s
(
INTERVALO_DO_PROJETO_MS), INCONDICIONAL. O canalsession:<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 parauseProjectSessions/useLatestSession(eram 5s), para os cinco contadores da molduraProjectPagee 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. - Quem só precisa do dado no clique não polla. Cada linha de
/containerslê 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. - Toda tela que ouve o canal passa por
criarInvalidadorDoCanal. As abas Executores e Visão geral invalidavam os eventos a CADAevent.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âmetroextras, com a mesma janela. - A espera pelo runner para quando acaba.
EsperaDoRunnersó parava a sonda deGET /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ário | Antes | Depois |
|---|---|---|
| chat da sessão + Executores, canal vivo | 70 + 138 = 208 | 70 + 72 = 142 |
| idem, dev agent em rajada (10 avisos/s) | 70 + 764 = 834 | 70 + 97 = 167 |
| idem, canal que nunca conecta | 155 + 186 = 341 | 155 + 120 = 275 |
/containers com 9 projetos | 132 (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, osextras);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 nadev);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).
- No máximo UMA
offeredpor (projeto, destino).decidirOfertadecide: sem pendente, cria; pendente na MESMA sessão e sem artefato novo (o novo énullou 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á. - Quem substitui grava. A linha vira
supersedede nasce um eventohandoff.superseded(handoffId,toAgent,motivo,substitutaId) na sessão da oferta VELHA, atorsystem, mesmo com a sessão encerrada. Ohandoff.offeredoriginal não é tocado. - Serializado por destino.
CreateHandoffUseCaseroda numa transação compg_advisory_xact_lockdo 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. - Agente já ativo não recebe oferta: 409
reason: agente_ja_ativo, sem linha nem evento. Ativo =agent.activatednuma sessão NÃO terminal do projeto. Amessageé o texto que o modelo lê:offer_handoffa repassa literal, eFalhaDeTurno.origem/1a classificapolitica. - Ativar substitui.
ActivateAgentUseCase(aceite e ativação direta) eActivateExecutionUseCase(pordev-<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. - "Arquitetura pronta" é idempotente.
OfferInfraHandoffUseCasenã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,jaAtendidoscom 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:supersedednã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).
- Pergunta no modo
seAusente.criar_handoffs_appsec/3chamaEngineApiClient.create_handoff_if_absent/5, que mandaseAusente: 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, 409agente_ja_ativo. Perguntar antes, pelo engine, seria corrida com outra oferta. - "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). - 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:1131apps/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):
- O corte conta MENSAGENS:
chat.message,agent.responseechat.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. - 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. - 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
trailingsepara 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:
- A rota aceita
latest=trueestatus=pending.latesttraz aslimitações deseqmais alto, devolvidas em ordem CRESCENTE e semnextCursor, ignorandoafterSeq— o mesmo contrato dolatestdos eventos (ADR 0021).statussó aceitapending(outro valor é 400, sem consultar); combinado comlatest, traz as pendentes mais novas sem que as decididas ocupem a janela. Sem os dois parâmetros o contrato é o de antes. - A tela faz duas perguntas, e a segunda só quando precisa.
buscarAcoesDaSessaolê a cauda (200); só se ela vier CHEIA pede também as pendentes e une porid, emseqcrescente. 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 ramolatest);apps/api/src/interfaces/http/actions/actions.controller.ts:105(list),:114(a recusa destatus);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(semlatesta pendente nova fica de fora — o defeito; comlatestela está na cauda;status=pendingdevolve 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:
- 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 molduraProjectPagetira 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). - 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. - 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_startdo chat fica visível onde o bloqueio aparece. - 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 (pendenciasDoProjetoemcriarInvalidadorDoCanal, janela de 2s), e odev.blocked_by_containertambém — a proposta que o destrava nasce noutra sessão, cujo canal ninguém ali ouve (AT-299). Nenhum poll subiu. - 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 (
detalheRecolhidonoApprovalCard): 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 semisActive);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(ocontainer_startdo 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 emclosedeclosed_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.callnã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.tsxeapps/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 comBROKER_URLsó 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.tsxeapps/web/src/routes/duas-abas.orcamento.test.tsxseguem 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.
- 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 poruseSyncExternalStoresó 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. - O bootstrap é acompanhado a 3 s. Status e eventos, nas duas telas.
- 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). - O
/statuspara no erro. Serviço que responde — inclusive com 5xx, que virastatus: '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:
- Um corte, uma fonte. O breakpoint é
CONSULTA_MOVEL((max-width: 767px)), lido poruseLayoutMovel. Quem troca de layout lê esse hook e aplica o desenho móvel por uma CLASSE; os módulos CSS não têm@mediapró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. SemmatchMediao layout é o de DESKTOP: o móvel só vale quando a consulta o afirma. - 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. - 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, semmatchMedianã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)") eapps/web/src/routes/AbasDoProjeto.test.tsx("no telefone continua VERTICAL"), desde o ADR 0211, sobre o mockapps/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:
- Dado de CONFIGURAÇÃO vale um minuto (
FRESCOR_DA_CONFIGURACAO_MS). Com ostaleTime: 0do TanStack, toda observadora que MONTA depois da primeira refaz a busca. As seções só montam depois que o projeto chega, entãoGET /projects/:id,GET /projects/:id/git/repositoryeGET /workspacessaí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 ignorastaleTime, 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 comlastUsedAt, plano de bootstrap): ali a pergunta é "o que está de pé agora". - 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.
- O sumário DEITA acima das seções, em qualquer largura. Como coluna
stickyao 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.bodytem 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ário | Antes | Depois |
|---|---|---|
| carga da aba (2,5s) | 53 | 50 |
| um minuto parado na aba | 76 | 68 |
| Configurações → Visão geral → volta (buscas de configuração) | 31 | 0 |
| carga da aba, com os bindings resolvidos em LOTE (RN-654, AT-334) | 49 | 30 |
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 deapps/web/src/routes/settings/eapps/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 nadev— 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:
GET projects/:projectId/model-bindings/resolved?agents=…&areas=…devolve{ agents: [{ key, binding }], areas: [{ key, binding }] }, na ordem pedida e sem repetição. Cadabindingé EXATAMENTE o que a rota individual responderia para aquela chave (GET .../agent-bindings/:slug,GET .../area-bindings/:key): a mesma cascataworkspace › projeto › área › agentesem sessão, as mesmas origens que a tela transforma em cadeia (RN-470), a herança do Criativo, enullquando nenhum nível tem modelo. Não há cascata nova: o caso de uso chamaResolveModelBindingUseCasechave a chave, com a entrada que a rota individual passaria.- O papel mínimo é o das rotas individuais de leitura,
viewer. Quem não alcança o projeto recebe o MESMO 403. - 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. - 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 (osdev-<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. - 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
nullque 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:144eapps/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(odev-<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):
- 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. - 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).
- Toda
Tablevira 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 marcadalargaNoMovel(um seletor, um bloco de ações), ocupa a largura do cartão com o rótulo em cima. - A página não rola de lado, e o que identifica a linha não some. O
/statusrola 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. - 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:
- Quem vê (
actor.idigual aosubdo 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ê". - 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ínimoviewer), sob a MESMAqueryKeyda aba de Configurações, e desde a AT-335GET 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). - 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.
- Agente —
nomeDoAgentee o avatar/cor do agente, comoagent.response. - Ator desconhecido (ausente, sem id,
systemou 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(ochat.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(viewerpassa 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(actionIdvazio é 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 nasceauto— caminho feliz),:40(projeto existente emmanualnã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 seguemanuale 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):
- a oferta está
offered; - 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 embusinessRuleIds— a MESMAcomputeCoverageda aba Backlog e da leitura do PO (RN-164). Zero regras não é coberto; - o repositório é local e sem credencial: o projeto não tem repositório (o
aceite provisiona
local) ou tem umlocal, e não tem conexão de git (OAuth) emproject_git_connections; - quem abriu a sessão ainda tem
developerno 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 virahandoff.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(semmaintainer),: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:
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);- o comando ao engine (o turno que consolida o
product_brief, RN-142); - só se o engine ACEITOU o turno,
necessity.validated, ator a mesma pessoa, comproductBriefId: null(o brief ainda não existe — nasce do turno que o clique dispara),via: 'readiness.confirmed'e oreadinessEventIddo 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(implicitonohandoff.accepted);apps/api/src/application/use-cases/agents/activate-agent.use-case.ts:80(implicitonoagent.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 viraagent.errore 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/2ignora odefault_branchdo remoto para escolher a base), e o worktree de cada dev agent nasce dedevEXPLICITAMENTE, não do HEAD — workspace inicializado antes desta regra está parado nadefault_branch(a marca de pronto impede re-inicializar) e ganha adevlocal a partir deorigin/dev, semfetchnovo; - o
pr_opendo dev agent levatargetBranch: "dev"no payload, e a api, sem o campo (ação proposta antes), completa comdeve não mais com adefaultBranch; a PR de ADR nasce dedeve miradev; - 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 damain— caminho feliz),:73(semdevo 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 semdev: recusa nomeada,.gitdesfeito, 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(semdevnemorigin/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(semdev,failedcom 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:
- O lote inteiro da resposta é despachado antes do encerramento.
propose_infra_praceita 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: umapropose_container_start(oucontainer_start_via_runner) pedida na MESMA resposta, depois da PR, sumia semtool.call, semtool.resulte 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 segundapropose_infra_prna mesma resposta é recusada com motivo (tool.call+tool.resultcomok: false) — só a primeira consolida com o Workflows. - 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á REGISTRADOrunning/provisioningtambém não (a mesma leitura local derecusa_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(segundapropose_infra_prno 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, emterminalNoEscopo);apps/api/src/application/use-cases/actions/propose-action.use-case.ts:174(containerExecutionActive, separado deexecucaoNoContainer) - Teste:
apps/api/test/domain/actions/decide.spec.ts:1274(com container/work,.worktrees,/tmpe..dentro — caminho feliz;/etc, outro projeto,/workspace,/tmp/../etcfora; sem container/tmpe/workfora — casos de falha);apps/api/test/domain/actions/path-scope.spec.ts:216(e a cópia de/worktravada contrapackages/docker-port);apps/api/test/application/use-cases/actions/propose-action.use-case.spec.ts:1141(o modomountedcom 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, comespecifica);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 edoasseguem 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 (gitsozinho), 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.
- Proposta.
git_mergede uma PR que uma execução anterior já mergeou (executedcomstate: 'merged') é 409pr_ja_mergeado; com uma proposta VIVA da mesma PR (pending,approvedouauto_approved), 409merge_ja_proposto. Nos dois, nada é criado. Negada ou falha não contam: quem negou ou viu falhar pode tentar de novo. A comparação é pelopullRequestIddo payload, no projeto inteiro (qualquer sessão). - Aprovação. Aprovar um
git_mergependente cuja PR OUTRA proposta já mergeou é 409pr_ja_mergeado, antes de qualquer efeito, e a ação continuapendingpara quem a vê negar. A irmã apenas VIVA não impede decidir esta. - Provider. O
LocalGitProviderrecusa mergear PR já mergeada comGitPullRequestAlreadyMergedError, e o target não se move — alinhado ao GitHub, que recusa o merge repetido. - 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-verificadacomawaiting_qaou gate ainda não aberto,secops-seguracomawaiting_secops), e o botão e o card seguem ativos.awaiting_user, tarefadonee PR sem tarefa não avisam. O "gate bloqueado" (tarefablocked) continua como era. - Nenhum teto se move. O merge em branch protegida segue
require_approvalincondicional 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),:696apps/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).
- A semente. Aceitar o handoff endereçado a
infragrava, além deopen_infra_pr: auto_approveeterminal: deny, a autonomiacontainer_start: auto_approve. Só ela entre as de container:container_start_via_runnerecontainer_stopseguem nunca semeados, econtainer_removesegue no teto absoluto (RN-495). - O disparo é do SERVIDOR. No kickoff do Infra Lead, ANTES da primeira ida
ao modelo, havendo roteamento vigente (
artifact.module_routingroteado, com candidata) e projetocontainer/mounted, o servidor propõecontainer_startpelo MESMO caminho da toolpropose_container_start: as recusas locais por modo e estado (RN-566, RN-610), depoispropose_action, onde a api recusa sem broker (RN-591), resolve a autonomia e, auto-aprovada, executa a subida, elegendo a imagem porDecidirImagemDoProjetoUseCasecomdecidedBy: 'infra-lead'(RN-491).runnernão tem passo do servidor: segue pelocontainer_start_via_runner, proposto pelo modelo e decidido por humano. - 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 umrationaleque diz que foi o servidor. (Revisado pela RN-683: os recursos vão omitidos e a api sobe com o mínimo derivado domodule_map, não com o padrão.) - O rastro diz quem chamou.
tool.call(comorigem: "servidor") etool.resultdurá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). - 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(runnernã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).
- A confirmação de arquitetura oferece só à Infra.
OfferInfraHandoffUseCasetem um destino (infra); oofferDevHandoffda porta do engine, a rotaPOST /internal/sessions/:id/agent/offer-dev-handoffe o:offer_dev_handoff/handoff_dev_pendentedo Arquiteto deixaram de existir.ja_oferecidopassa a significar "a Infra já tinha oferta ou estava ativa". - 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 (409agente_ja_ativo) não é falha. Não é ferramenta: o modelo não decide quando o Dev Lead entra. - Só com o container REGISTRADO
running(a leitura local deProjectContainerLifecycle). 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 ocontainer.runningdo outbox chega fora de um turno (aprovação posterior,/containers,container_start_via_runner): oEngine.Outbox.Drainpassa a enfileirar também oEngine.Workers.InfraOfereceDevLeadWorker, que avisa por PubSub o Infra Lead do projeto. - Falha que não é "já atendido" é
agent.errordurá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(containerrunningno fim do turno: oferece — caminho feliz),:1504(semrunning: 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 viraagent.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.
- Cada módulo declara o seu. O
module_mapganharesources(cpus,memoryMb,pidsLimit) por módulo — o que AQUELE módulo precisa sozinho no container. A ferramentacreate_module_mapo 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. - O mínimo é a SOMA, campo a campo. Um projeto tem UM container
(
project_id UNIQUEemproject_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é. - 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 orationaleNOMEIA quem não declarou. Sem declaração nenhuma (todo mapa anterior a esta regra), e semmodule_mapvigente, o mínimo é exatamente o padrão de hoje. - A execução de
container_startresolve campo a campo. Omitido vira o mínimo (a subida do servidor manda tudo omitido); abaixo do mínimo éfailednomeado, sem gravar decisão de imagem nem subir; acima vale até o teto, que não muda. Orationaledoartifact.project_imagegravado ganha "Recursos mínimos (RN-683): …" com os módulos somados e a versão do mapa. - 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:
- O plano é o primeiro passo do ciclo de revisão.
run_area/3doQaLeadServerobté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) oartifact.plano_de_testeda MESMAtaskId— a rodada de correção não paga outro laço —, e senão rodaQaEstrategiaAgent.run/6com o worktree do dev comoworkspace_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. - 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
coverageMatrixpor regra da story,approvedsó com suite verde e toda regra coberta) e o contrato externo segue sendo UMqa_verdictpor ciclo. Plano que FALHA não segura a revisão: a QA-estratégia gravaagent.errorcom origem (RN-059) e a Automação recebe a mensagem de sempre, byte a byte. - O
implementavelse julga sobre a história e omodule_map.run_assessment/2não lê nem dispara plano: propõe o parecer na PRIMEIRA chamada, sobre o que o kickoff do Dev Lead já lhe dá, com payloadstoryId/parecer/justificativa. Gate, dono, severidadewarne aprovação humana não mudam; só aentrada([story-ready, module_map]). O appsec (RN-539) segue disparando ali, no design. - O teto continua 8.
"qa-estrategia"não ganha cláusula emIteracoes.tipo/1; o que mudou foi o insumo. O agente passa a rodar sob otask_budget_microsque 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 portaskId, 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,taskIdno 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 (orepo.hex.pmdeu 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.
- Quem marca é o engine, e só num ponto.
RunnerGit.fetch!/3despacha o fetch com credencial porRunnerRouter.exec_git_credenciado/5, que põegitCredenciado: trueno payloadexece exigeenvnão vazio por guarda. O comando de terminal do dev agent passa porRunnerRouter.exec/4e nunca leva a marca. Sem credencial (providerlocal), o fetch vai sem marca. - O runner roda no host só com a CONJUNÇÃO: a marca
trueliteral eenvnão vazio. Aí o comando vai aexecutarComando(oenvmesclado sobreprocess.env), mesmo com container ativo. A pasta é a mesma:estado.diré o bind-mount de/work. envsem a marca não ganha o host. Com container ativo, esse par segue RECUSADO com a marca da RN-558 e origempolitica. Hoje nenhum chamador do engine o produz; na prática ele chega de umbrabo-runneranterior ao ADR 0193, que não lê a marca — e a mensagem do engine diz para atualizá-lo. A marca semenvtambém não muda nada: o comando vai ao container.- Nenhuma contenção muda:
DockerPort.execsegue semenv(ADR 0130), eRunnerReadiness(RN-507) fica byte a byte — a marca decide ONDE o runner executa, nunca SE o engine despacha. - 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 dogitrecebe a credencial no host com container ativo — caminho feliz),:391(umgit fetch originreal sucede no host),:430(envsem 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 peloTerminalChannelreal termina em{:ok, _}),:177. Os testes ExUnit não rodaram no ambiente desta entrega (repo.hex.pm403); 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:
- Fila, não recusa. Com turno em curso, a mensagem entra na fila do agente
(
TurnoAssincrono.receber_mensagem/4), o agente gravachat.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 omensagemId— o id dochat.message. - N mensagens = 1 turno. No FIM do turno (
finalizar/1, nuncasuspender/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 gravachat.message_deliveredcom 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. - Teto de 10 pendentes por agente e sessão. A 11ª é 409
fila_de_mensagens_cheia, comagent.errordurável de origempolitica— ela está gravada e não será lida. - Cancelar.
POST …/agents/:agent/messages/:messageId/cancel(developer, o mínimo da rota de mensagem): só quem ENVIOU (403mensagem_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: 409mensagem_fora_da_filaquando já foi lida ou cancelada. O engine gravachat.message_cancelledem nome de quem cancelou; com o agente fora do ar, grava direto (a fila é o log). - Persistida no log, nunca em tabela. Pendente = enfileirada e nem
entregue nem cancelada — a mesma derivação no engine e na tela. O
init/1dos 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. - 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. - 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),:276e: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:
- O aceite do Dev Lead só o ativa para planejar.
handleAcceptHandoffaceita e mais nada; oAcceptHandoffUseCasenão muda — segue provisionando o repositório como segunda porta idempotente (RN-582). - Aprovar o
propose_execution_planATIVA 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 dedecide.ts, então PODE ser configurado auto-aprovável). Ele grava o módulo das tarefas (RN-678) e chama o MESMOActivateExecutionUseCasedo botão — nenhuma segunda régua: o 409 sem repositório (RN-582), o 409 deexecution.activatedem sessão consultiva (RN-097) e a reativação idempotente porfindActiveExecutionSessioncontinuam lá, intocados. - Quem ativa é quem APROVOU (
decidedBy); na auto-aprovação, quem abriu a sessão — contra quemdecide()resolveu o papel. Os dois exigemmaintainer, o mesmo mínimo deexecution/activate, então a resolução do papel efetivo dosgit_*dos dev agents (ocreatedByda sessão de execução) não muda de dono. - 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. - O desfecho é nomeado e durável. A ação termina
executedcom{ sessaoDeExecucao, modulos, tarefasAtribuidas }oufailedcom omotivo(a frase da api, ex.: o 409 sem repositório), mais o eventoexecution.plan_applied/execution.plan_failedna 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 virafailedcom 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:
- A tarefa tem módulo —
tasks.module,textnulo (migração0068_modulo_da_tarefa). Quem o atribui é o Dev Lead, no plano de execução:propose_execution_planganhoutarefas: [{ taskId, modulo }], e o kickoff dele lista as tarefas pendentes do backlog do PROJETO com otask_ide os módulos da história. - O conjunto válido é o
module_mapvigente. A api lê o plano (lerPlanoDeExecucao, função pura) na PROPOSTA e recusa com 400plano_de_execucao_invalido, sem criar a ação, quando: o plano não temtarefas; uma tarefa não temtaskIdou está sem módulo; o módulo (da tarefa ou do plano) está fora domodule_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. - A aprovação relê o plano contra o
module_mapdaquele momento (ele pode ter mudado): recusa virafailedsem 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. - Só o dev daquele módulo pega.
claimNextecountClaimableByModuleusam o MESMO predicado (daTarefaDoModulo): tarefa com módulo é de quem tem aquele módulo, e de mais ninguém — osmodule_idsda 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 domodule_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.
- 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 comactor_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, comnenhum_membroounenhuma_interacao_propriae os números) e, na rodada pedida à mão, também para o event log (anamnese.run_skippedcomcausa: "sem_sujeito_elegivel"). Pelo tick é só log — seria um aviso a cada 15 minutos por projeto. O prompt passa a listar só os sujeitos. - 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 lerlistEffectiveMembers;listMemberscontinua sóproject_members. - A hipótese ACEITA pela PRÓPRIA pessoa vira fato do perfil. O
psychologist.hypothesis_acceptedcarregaprojectId,sujeito(o autor da sessão analisada),hipotese,sugestaoefatoDoPerfil, e oGraphEventTranslator— o mesmo do projetor e degrafo:reprojetar— o traduz para(:FatoDoPerfil {hypothesisId}), ligado à pessoa (SOBRE) e ao projeto (NO_PROJETO).fatoDoPerfilsó étruequando 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. - 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 mensagemsystemEFÊ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. - Religada.
ANAMNESE_ENABLEDvolta ao defaulttruenoruntime.exse nos três composes;START_ANAMNESEnão muda (o de produção seguefalse, 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),:35e: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:
- 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. - 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). - A api recusa o mesmo caso para qualquer cliente.
POST .../chatnuma sessão consultiva em que nenhumagent.activatedfoi 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 quadroerrordentro de um stream 200 — o handler devolvePromise<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
curldo 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.
- O artefato é
artifact.module_contracts, sem tabela, como omodule_routinge oc4_diagram: o vigente é o de maiorversion, com desempate porseq. Cada declaração leva a lista INTEIRA e SUBSTITUI a anterior; mudar contrato é versão nova do Arquiteto. - Por módulo, só o que ele EXPÕE:
{modulo, expoe: [{tipo, assinatura, descricao}]},tipoemfuncao | rota | evento | dado,assinaturaobrigatória (até 300 caracteres), de 1 a 40 itens. O que ele CONSOME é odependsOndomodule_mapvigente, derivado na leitura — nunca redigitado. - 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_mapvigente (listando os válidos) ou semmodule_map,expoevazio ou acima de 40,tipodesconhecido,assinaturavazia ou acima de 300. Nada é gravado. - Artefato SEPARADO do
module_map, nunca um campo dele: revisar um não reemite o outro. - O dev lê por
listar_contratos_de_modulos,:direct, no registro deEngine.Dev.Tools: escopo do projeto pela rota, NENHUM parâmetro, o módulo do dev vindo dectx.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. - 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_blockednomeando 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(semmodule_map),:123(as recusas de domínio),:239e: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 pelomixneste ambiente (repo.hex.pm403; 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:
- 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); - 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;
- 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.tserevoke-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(oterminalnunca 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.pmdá 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 ospawn, restaura quando ele lança — caso de falha),:48(as duas voltas de umcatreal, comEAGAINentre elas); a prova de ponta a ponta é osmoke:binda matriz, verde emdarwin-arm64,linux-x64elinux-arm64no ensaio36779817686 - 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:
- O
RunnerOnboardingPanelmostra o comando do instalador (curl -fsSLO …/install.sh && bash install.sh, a MESMA frase do runbook e doinstall.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 aEsperaDoRunner(RN-474) embaixo. O comando no web é uma CÓPIA, e a cópia é conferida contra a constante do instalador. - 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. - 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.tssaiu inteiro. - A api não tem mais
POST /projects/:projectId/runner-device-keys. Ele ficou sem chamador.GETeDELETEficam (listar e revogar); a rota interna de máquina (POST /internal/machine-device-keys) fica. - Chave de projeto JÁ registrada continua valendo e revogável: nada no
banco muda, o
PatAuthGuardsegue 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, nenhumchmod),: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 dePOST);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 eallowsaemrequire_approval;denycontinua vencendo — caso de falha;git_commitegit_mergepara branch não protegida ficam fora);apps/api/test/application/use-cases/actions/propose-action.use-case.spec.ts:953(com Postgres: nascempendingcom o motivo no evento);apps/api/test/application/use-cases/execution/activate-execution.use-case.spec.ts:473eaccept-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, 400teto_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 saianamnese.analysis;agent.responsenunca é 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 oassistantcomtoolCallslogo antes dotoolque 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):
- Busca. O catálogo tem um campo de busca que casa o nome de exibição, o
nameou oiddo 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. - 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 paraowner(o mínimo do endpoint, RN-102); ler o catálogo pedemaintainer. O predicado é UM só,providersSemCatalogo, também usado pelo aviso do catálogo. 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 comtools: []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, sempassos),: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.exseapps/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 emapps/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_backlogno 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-devnão conta ebraboconta;zapaga depois da prova e deixaMIGRAR_DEvazio; 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
systemEFÊ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 fachadaEngineApiClient, 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 contradocs/fluxo.yml: todo papel citado existe ali comoactive, 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ó") comodecision_record(emit_artifact, RN-505), sem sermão; o kickoff do Arquiteto lê osartifact.decision_recordda 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_recorddo Arquiteto é da SESSÃO (como as regras e as histórias do kickoff), não do projeto inteiro; e nada RECUSA umcreate_module_mapque 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 mensagemsystemEFÊMERA de ambiente, pelo mesmo caminho do idioma (RN-622) e do perfil do autor (RN-680) — nunca emstate.messages, nunca por servidor. Ela diz a pasta real em que oterminalroda (oworkspace_rootdo laço, traduzido para/workpela mesma regra doTerminalExecutorquando há containerrunningREGISTRADO), a imagem e a rede do últimoartifact.project_image, quegitnão se roda no terminal (usegit_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 nadev(package.json, lockfile) não se recriam. Sem containerrunning, 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-apieqa-automacaorodaramcd /workspace(nenhum prompt cita essa pasta);git statusdeu exit 127 numa imagem sem git; odev-api(móduloapi) criousrc/catalog.jsepackage.json, os mesmos dodev-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_commitpara arquivo de outro módulo (omodule_mapnã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_reviewvolta ain_progresscomgate_statuszerado (os vereditos antigos de QA/SecOps não valem para a branch que vai mudar), gravabacklog.task_status_changedcomcause: merge_conflicte põetask.merge_conflictna outbox, com o agente que propôs opr_open(o DONO), a PR e os arquivos em conflito. O engine entrega ao dono peloEngine.Dev.Wake; livre, ele READOTA a branch da PR (WorktreeManager.adopt/3, a mesmagarantir_base, sem perder os commits) e roda o laço de CORREÇÃO com o motivo no texto: trazerorigin/devporgit fetch/git merge, resolver os arquivos, rodar a suite. Noreport_donepropõ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), sempr_opennovo — a PR é a mesma branch. Nenhum estado novo de tarefa:in_progresscom 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, origeminfra); dono com o disjuntor aberto (idle_tripped) — bloqueada, origempolitica; a branch não volta (adoptfalha) — bloqueada, origeminfra. 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
failedpor conflito empackage.json, e a tarefa ficouin_reviewsem 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_progressaté intervenção (mesma família da lacuna doEngine.Dev.Wake, ADR 0045). O agente integra adevpor MERGE, não rebase: o push forçado que o rebase exigiria não existe no executor de git. ONoopDevAgentServernã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_resultcom okindda PRÓPRIA ação (git_merge,pr_open,git_commit,git_push),failed: true, oerrore, nogit_merge, opullRequestId; antes toda falha gravava{kind: 'git_push', branch: ''}. O conflito de merge (GitMergeConflictError, RN-704) grava tambémconflictingFilese um eventobacklog.task_merge_conflictem cada tarefa da PR.GET projects/:projectId/actionsaceitastatus=failed(só comactionType), 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 avisoaction.git_merge/action.faileddo canal também; a lista "PRs em revisão" de Aprovações deixa de mostrar tarefadone. 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
failedcom o conflito empackage.jsone a aba PRs seguiu mostrando "ABERTA" com o botão "Merge", sem texto nenhum; a ação gravoukind: 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_gateNA MESMA tarefa, e no merge o gate já terminou (task.gate_resolvedcomdoneleva o agente afinish_task, que zera tarefa, worktree e branch e reivindica outra). Reaproveitá-lo exige decidir como o agente readota uma tarefain_reviewna branch existente e se o gate roda de novo depois do rebase — decisão de desenho, não tomada aqui. A tarefa seguein_review, com obacklog.task_merge_conflictdurável. - Origem: AT-383, AT-382
RN-704 — O merge do repositório local é merge de verdade, e conflito é recusa nomeada
- Regra:
mergePullRequestdo providerlocalfaz 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, mensagemMerge pull request #N from <branch>, identidadeBrabo Bot). A ref do alvo é movida porupdate-refcom o valor antigo esperado, então um merge concorrente falha em vez de sobrescrever. Conflito lançaGitMergeConflictErrorcom os arquivos em conflito: a ref fica intacta, a PR continuaopen, a açãogit_mergeterminafailedcom o motivo noaction.failed, e pela HTTP o erro é 409merge_conflict. - Medição (AT-377): duas PRs do mesmo commit base (#3 e #4) mergeadas em
sequência: a
devficou só com a #4, e os commits da #3 deixaram de ser ancestrais dela. Oupdate-refantigo 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/enginedo engine nasce com o dono do usuário da imagem (/home/engine:uid=1000,gid=1000,mode=0700). OSemgrepDetector.Livedistingue o exit1com JSON (achados) do exit0/1com 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/enginenasciaroot2755; osemgrep scando gate saía1com stdout vazio ePermissionError: '/home/engine/.semgrep'no stderr, e o parser chamava isso de:invalid_output. Com o tmpfsuid=1000o mesmo comando devolve o JSON (exit 0). O Kubernetes não tinha o defeito (fsGroup: 1000noemptyDir). - 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 emawaiting_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 comoagent.errorde origeminfra("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_progressemgate_states, e oGateRescuero 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.errorouartifact.secops_verdict, lidos por tipo, cauda com teto) já é este erro com o mesmoreason, fica só a linha de Logger do servidor. Motivo diferente, um veredito no meio ou leitura que falha gravam de novo; oagent.errorpassa a levartaskId. - 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_windowno 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 ocontext.compactedgravasummaryOrigin: "deterministico"efalhacomorigememotivo(RN-059). Com o sumarizador respondendo,summaryOrigin: "modelo"efalha: null. - Medição (AT-366): sessão 17d41c20, Arquiteto em
anthropic/claude-haiku-4.5(janela de 200k): 6 799 tokens passavam de0,7 × 8 192 = 5 734e 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_windowdo binding); 128k é o padrão da instalação, ajustável porDEFAULT_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
queryKeyde 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§ion=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") eapps/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
localou ausente: a delegação ao Workflows é dispensada (delegationdispensed, 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ógithubegitlabdelegam. -
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: …") eapps/engine/test/engine/infra/infra_lead_server_test.exs("RN-710: repositório local dispensa o Workflows …") eapps/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.messagedo 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_ACAOfala da PROPOSTA ("quer abrir pull request…") eVERBO_DA_ACAO_EXECUTADAdá o verbo do que já aconteceu, só para os tipos cujo tempo muda;verboDaAcaorecebe ostatuse é a fonte única. -
PR de ADR e de infra são teto da RN-418 (AT-371).
open_adr_preopen_infra_prentram emTIPOS_SEM_SEMPRE_PERMITIR(api e a cópia do web, conferida por teste), comopr_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_plana api acrescenta ao payloadorcamentoPorTarefaMicros— 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 domodule_mapsem tarefa ficam de fora e são ditos no resultado (modulosSemTarefa), ao lado detarefasPorModulo. O botão "Ativar execução" (sem plano) e o plano semtarefasseguem 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 nenhumanamnese.profile_updatednasce. 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.mdv3 e o fallback inline deanamnese_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(ferramentareport_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) eevidenceEventIds(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.mdv4) - 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 liberagit push/gh pr createe os tipados;git merge,gh pr mergeesudoseguemrequire_approval; específica eallowsem curinga seguem no teto; curinga emrequire_approvalpede 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 nascepending);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 semlatest— 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.createdsaiu da janela de 200 eventos ainda SALTA para o topo do fio (ordemDaAcaoNaTimelinedegrada paracreatedAte ancora em 0.5) — fixado como comportamento atual emapps/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_storycom ids em string JSON grava os ids;create_c4_diagramcomactorsem string JSON funciona; string não-JSON num campo array é recusa nomeada; ferramenta que lança não encerra o turno doToolLoope gravatool.resultok: 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 redeegressque o Arquiteto declarou" e "RN-723: rede do Arquiteto fora do vocabulário — a subida seguenone" - 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:
- O PO tem
complete_story: completa uma históriadraftEXISTENTE do projeto — osbusiness_rule_idsinformados 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 decreate_story(isPromotable;autovai aready,manualligaproposedReady), e a escrita gravabacklog.story_completed. offer_handoffdo PO ao Arquiteto é RECUSADO, sem criar oferta, enquanto houver regra sem história ou históriadraftincompleta (semproposedReady) — e o resultado lista as duas, com id e título, e manda completar comcomplete_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.- Quando o
emit_artifactdebusiness_ruledo 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;autovai aready; regra inexistente, outro projeto e históriareadyrecusados);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) gravaanamnese_runscom zero perfis, e a próxima começa emwindow_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 atoragent/anamnese) e os de atorsystemficam 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 deactor_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 ourun_skippedescrito 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, aanamnese.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 temproposed_actionPENDENTE 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) comgate.rescue_parkedno 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 eventogate.rescue_parked). A pendência humana é lida pela SESSÃO do ciclo (aproposed_actionnão temtask_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 aGateRescuer.retomar_estacionado/3: ciclo que não existe, não está estacionado ou gate fora deqa/secopsé 409gate_nao_estacionadoe nada é despachado. No fio da sessão, o eventogate.rescue_parkedvira um aviso com o botão "Retomar gate", inerte com o motivo em texto quando o papel não alcançadeveloper; 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, 409gate_nao_estacionado, papel abaixo dedeveloper),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.messagecom omensagemIdque 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 MESMOautorDaMensagemda 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:
- No mesmo ponto da RN-719, string com
\uXXXXLITERAL (par surrogate incluído) é decodificada, e com ela\n,\t,\"e\\da MESMA string. String sem\uXXXXvolta byte a byte — código numcontentcom"\n"literal não muda. Isso revisa o "campostringnunca é tocado" da RN-719 só nesse caso. - 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.
- 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_artifactcom chave faltando nopayloaddiz as chaves faltantes e as recebidas, em vez de{:missing_keys, …}. complete_storynomeia exatamente o que falta (falta: business_rule_ids), pela mesma régua demissingForReadyda 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\ue lista intactos; recusa com as chaves e sem os valores;complete_storynomeia 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(nuncacodigo), o contador da parada automática (RN-047) NÃO avança, e o agente NÃO reivindica a próxima tarefa — fica emidle_trippede emitedev.credit_exhausted(motivo: credito_esgotado), nuncadev.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 agentetravadocom "crédito do provedor do modelo esgotado — recarregue e rearme para retomar", e o sino diz o mesmo. O mesmo reconhecimento fazFalhaDeTurno.origem/1devolverinfrapara o frame final de crédito (vale para os agentes de gate, como o QA). A sessão contadev.credit_exhaustedcomo trabalho pendente, comodev.idle_tripped. Desde a RN-730 o 402 chega comcodepró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
codigoe 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 emupstream), na mesma tabela de status do ADR 0041 — os demais status continuam como estavam. Ocodeviaja ao engine comoerrorCodeao lado doerror, no corpo dellm-turne no framefinaldellm-turn/stream(nullsem erro ou com erro que não é do provider, como orçamento e binding ausente). No engine,FalhaDeTurno.credito_esgotado?/1decide primeiro pelocode(insufficient_credité crédito; outrocodenão é, mesmo com a frase), e o frame{:final, texto, code}dos conversacionais dá origeminfrapelo 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_500segueupstream), 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 branchfeature/infra-setupnasce dedeve a PR miradev— o worktree do dev agent nasce dedev, então o Dockerfile mergeado emmainnunca chegava a ele. Repositório semdevfalha 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) levamSEM_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 registradelegation.failed— passa afalhou. 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
draftSEM tarefa em execução (in_progressouin_review) pode ter o título corrigido e pode ser ARQUIVADA — pelo PO (ferramentasupdate_story, que também corrige a descrição, earchive_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_draftouhistoria_com_tarefa_em_execucao, com a frase que o modelo e o toast mostram. Arquivar NÃO apaga: a linha destoriesganhaarchived_at/archived_reason(migration0069, ADR 0212), as tarefas ficam, e o evento NOVObacklog.story_archivedguarda quem, por quê e quais tarefas; a edição gravabacklog.story_updatedcom o título anterior. A história arquivada sai do backlog (listar_backlogincluído — o PO não a vê para não completá-la de novo), da cobertura (computeCoveragelê 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_storyecreate_taskrecusam 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 dedraftou 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: tarefain_progress/in_reviewé 409historia_com_tarefa_em_execucaosem 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;viewernã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 dedeveloperdeixam inerte com o motivo) - Origem: AT-406 (TP-01 de 03/10: o PO criou duas histórias duplicadas,
chamou
delete_storye 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_artifactpor tipo — regra de negócio, decisão, nota, outro —,create_epic,create_story,update_story,archive_story,create_task), e o fecho daagent.responseganha, 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 cadapassosdaagent.response— passa pela MESMAdecodificar_escapes/1da RN-725 antes de ser gravado:\u00e3literal viraã. Uma régua só, sem cópia, e com o mesmo limite: só age quando há\uXXXXliteral, então texto sem escape — inclusive código com"\n"literal num bloco cercado — volta byte a byte. Oagent.deltado 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\nliteral) - 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_imagee o RESULTADO dela dizem que, com redenone, 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). Comnone, o resultado pede ao modelo que chame a ferramenta de novo comegressse 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 RECUSAnonequando 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-slimcom redenone, e o dev agent não conseguiunpm 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
autoFocuse usaid/nameproject-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 únicaSEM_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, ignoraautocomplete="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_AUTOFILLacrescentaaria-autocomplete="none"(a árvore acessível diz que o campo não oferece sugestão) edata-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ó oaria-labelquebraria 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) estop_reason: "max_tokens"no Anthropic — e, só quando a resposta foi cortada pelo teto de saída (RN-734), devolvetruncated: truena resposta dellm-turne no framefinaldellm-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
reasongravado noagent.errordos conversacionais, do Infra Lead e doToolLoop(dev agent) é o TEXTO do frame final quando há um — nunca a tupla{:final, …}inspecionada; outras formas seguem cominspect/1. Com crédito do provider esgotado (errorCode: insufficient_credit, RN-730, ou o reconhecimento por texto da RN-726), amensagemda 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ó noreason(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,reasonsem{:final; erroupstream: 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 deOpenAICompatibleProvider, OpenRouter incluído) leva o campo de teto de saída —max_tokensoumax_completion_tokens, conformemaxTokensField— mesmo quando quem chama não definemaxTokens. Sem definição, o valor éMAX_TOKENS_PADRAO(4096), o MESMODEFAULT_MAX_TOKENSdoanthropic-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) definemaxTokens, 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(semmaxTokenso corpo levamax_tokens: 4096; commaxTokens: 123leva 123; commaxTokensField: 'max_completion_tokens'o campo muda emax_tokensnã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 dodocker runemDockerViaCli, 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 stopesperava 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]nofinally— 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(runleva--initantes 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
executedcom a linha em "rodando" eExited (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 dometadata.reason, que diz a natureza do erro). Essa mensagem é o texto do frame final e vira, verbatim, oreasondoagent.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: oreasoncontém ometadata; erro longo corta na bolha e não no diagnóstico) - Origem: AT-424 (TP-01, 3ª rodada, 03/10:
reasoncortado 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 mandamreasoning: trueao provider. Um provider comcampoDeRaciocinioconfigurado (hoje só o OpenRouter) manda um orçamento EXPLÍCITO de raciocínio —reasoning: { max_tokens: ORCAMENTO_DE_RACIOCINIO }, 4096 — e omax_tokensda 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: falsenão são enviados). Modelo sem raciocínio, e provider sem a configuração, mandam o corpo de antes, byte a byte. Oanthropic-providerdireto não enviathinking, 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; omax_tokensda 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 IGNORAreasoning.max_tokens; o campo deixou de ser enviado e a folga nomax_tokensficou. - 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.5comoutput_tokens = reasoning_tokens = 4096em 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á emtipos_de_evento_com_conteudo/0—chat.message(mensagem escrita por ele) echat.structured_question_answered— e (b) decisão de ação com motivo de recusa escrito (rejectionReasonnã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_skippedsó 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, ambasskip_proficiencycom "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/3e os GETs deEngineApiClient.Live) roda numaTaskdescartável; a resposta que chega depois doreceive_timeoutcai 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 é:normalnem:shutdown) não é tratado como terminado: oEngine.Dev.Monitorgravaagent.errorDURÁVEL com origeminfra(reason: processo_do_dev_caiu,religado,taskId) e o RELIGA a partir da própria linha dedev_agent_states, até 3 vezes por agente; oresumepassa pelohandle_continue({:restart_recovery, _})que já existia, que bloqueia a task interrompida com diagnóstico (origeminfra) e tenta o próximo claim. Sem linha, ou no teto, a linha sai e o erro diz que não houve religamento. Ollm_turn_stream/6fica fora do isolamento (ointo: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 oagent.errore odev.blockedda 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 gravaagent.errorinfra e religa;:normalapaga a linha; sem linha não religa) - Origem: AT-428 (08/10:
CaseClauseErroremFinch.HTTP1.Conn.receive_response/8depois do timeout donpm 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). Odev.blockedlevatrabalhoPreservado({branch, commit}ounull) 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 dedev, 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 modorunnero 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 comdev-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(odev.blockeddiz a branch; falha ao preservar não impede o bloqueio) - Origem: AT-429 (08/10:
feature/task-86dade03sem commit,package.jsonesrcperdidos 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
terminaldiz que o shell do container ésh(dash), não bash — sem brace expansion, sem[[ ]], sem arrays, semsource— 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 (modorunner) diz que o retrato está indisponível e manda listar peloterminal. 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 nadevde 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 dizsh) - 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 oparseArgumentosda 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 depropose_adrorienta 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_adrnuma 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_adrok: 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_planativa a execução (RN-677), e a ativação sobe UM dev agent por módulo; oagentespor 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 deagentespede 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 sobrarcoberto.". - 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 nopropose_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 (oseqda 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_approvalcom 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(estaPertoDoFimeabaDaRota: 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 emawaiting_usere nada o move depois do merge; em vez de coluna nova, a régua é DERIVADA: a PR do artefato é opullRequestIddo resultado doopen_infra_prque a abriu (prActionId), e ela está mergeada quando umgit_mergedo projeto com statusexecuteddevolveustate: 'merged'para esse id — o mesmo casamento porpullRequestIdque 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
terminalque a api auto-aprova é EXECUTADA dentro da mesma requisição dopropose_action(api → engine/internal/actions/execute→ apicontainer-exec→ broker), e o engine espera essa requisição porTERMINAL_ACTION_TIMEOUT_MS+ 120 s — não pelo default de 15 s doReq. O teto do comando (TERMINAL_ACTION_TIMEOUT_MS) passa de 15 s para 120 s nos três composes e noruntime.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 dofetchdo 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 denohup … &com log —, nunca%Req.TransportError{reason: :timeout}cru. Exec assíncrono/streaming foi considerado e não feito: a menor mudança que faznpm installcomum caber é o teto. - Onde:
apps/engine/lib/engine/sessions/engine_api_client.ex:1911(opcoes_do_propose_action) eapps/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 -50num containernode:22-bookworm-slimvoltou "falha no pipeline: %Req.TransportError{reason: :timeout}"; medido nesta máquina, o mesmonpm installdebcrypt+sqlite3+expresslevou 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_taske, desde a RN-942,declare_open_question; para o Arquiteto,create_module_map,route_modules_to_infra,declare_module_contracts,propose_adrecreate_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) — salvoreport_done/report_blocked, que entram no piso deles desde a RN-839. - Onde:
apps/api/src/domain/llm/tool-router.ts:392(menuComPiso) eapps/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émcreate_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_taskrespondeu "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 mensagemsystemEFÊ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) eapps/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 MESMOApprovalCard, 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 oqa-automacaosó entrava na oferta depois do primeiro evento dele, quando a primeira ação já esperava clique. Oallowpadrão dopermissions.jsonNÃO muda (decisão do dono). - Onde:
apps/web/src/lib/precisa-de-voce.ts:256(gateEsperandoClique) eapps/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) eapps/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-automacaopropôsls; cat; for f in test/*.js…, um segmento fora doallow, 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 (
siblingTasksemGET .../dev-context). O veredito é sobre o que é DESTA tarefa: requisito que pertence a uma tarefa irmã (feita ou pendente) não reprova — vai aoresumocomo observação, fora deitens, e não impedeapproved. A régua é de PROMPT, não de código: oemit_qa_verdictcontinua 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 noresumo, fora deitens, 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) eapps/engine/lib/engine/gates/qa_automacao_agent.ex:170apps/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) eapps/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) eapps/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_verdictdas tasks42bea901e762833bd)
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ó notitledo botãodisabled, 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.activatedna janela, ou umdev-<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.tsreprovat(…, { status: x.status }). - Onde:
apps/web/src/routes/ConfirmarEncerramento.tsx:13(useEncerrarComConfirmacao) eapps/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) eapps/web/src/lib/i18n-enum-cru.test.ts(reprovava oSessionComposer.tsxantes 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ãoarquiteto[bot]), como a de infra (RN-729) — a aba PRs deixa de dizer "autor desconhecido"; (2) aagent.responsedo Infra Lead carrega omodelNamedo 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 dokindgravado (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 de12remPOR COLUNA e cresce por fração, com o contêiner emmin-content— commax-contentela 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) eapps/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ó comogate_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 dedev. O que sobrou sem commit no worktree é preservado antes, como na RN-743; o ponteiro gravado peloblock_task/4não é sobrescrito. Quando a branch anterior já é ancestral dadevlocal (mergeada), a próxima parte dadev, 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 dadev, então a próxima segue partindo dela; o ponteiro continua só em memória, e no modorunnera 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 dadev) - Origem: AT-447 (TP-01 de 09/10: tasks
762833bde5ebad64b— a segunda nasceu dedevsem 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):- o bootstrap do repositório (os três providers e a adoção) ganha o passo
commit_gitignore, que commita um.gitignorebase emmainANTES dedevnascer, 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 pelomodule_mapchegaria depois do primeiro commit do dev e só nos projetos que passam por ela. O passo só CRIA o arquivo: um.gitignorejá existente, mesmo diferente, é do usuário e não entra no plano; - 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; - 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.gitignorese o bootstrap rodar de novo; no modorunnero commit roda pelo mesmoGitExecutore a mesma exclusão vale onde o engine executa git.
- o bootstrap do repositório (os três providers e a adoção) ganha o passo
- 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;.gitignoredo 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 acusanode_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)", obacklog.task_blockede a linha da task levamorigin: "politica"— o teto é decisão de configuração, não falha real (vocabulário do ADR 0020, regra da RN-059). Antes saíaorigin: 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 chamaMarkTaskBlockedUseCasecom'politica'; o veredito aprovado não bloqueia) - Origem: AT-450 (TP-01 de 09/10:
backlog.task_blockedcomorigin: 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, peloSessionLifecycleWorker, 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 oEngine.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_approvalouawaiting_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", origempolitica, e o diagnóstico dizendo onde ficou o trabalho — volta à fila quando um humano a libera, nunca perdida. As linhas degate_statesda sessão saem, para oGateRescuernão reabrir ciclo da sessão encerrada. A aba Executores ganha "Parar execução" (papeldeveloper, 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 comexecution.activatednã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 origempolitica; 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 fechaclosing→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-apibloqueou 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çõesopen_adr_prdo PROJETO, lidas agora deproposed_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.resultok: falsecom 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_backlogdo PO (RN-164), com a contagem de tarefas por história. O kickoff dele e o resultado dopropose_adrdizem 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
useActiveExecutionSessionganhastaleTimeigual 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,reasoningemsupported_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 camporeasoning: { max_tokens }: ele NÃO é mais enviado a modelo nenhum. Medição paga de 10/10 (AT-467): noanthropic/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; noanthropic/claude-haiku-5.5ele 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 desupportsReasoning, o provider que sabe dizer "desligado" (hoje só o OpenRouter,campoDeRaciocinioDesligado) mandareasoning: { 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 semsupportsReasoningnã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; semsupportsReasoning, 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_propostaselistar_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 originno working tree do projeto (serializado por projeto) — é no remoto que as PRs são mergeadas, e adevlocal só andava no checkout da inicialização. A ponta dadevéorigin/devquando 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 adevnão contém — a task retomada num processo novo, que perdeu o ponteiro em memória (RN-743) —, ou (3) da ponta atual dadev. Nos casos (1) e (2) o worktree INTEGRA a ponta dadev(merge --no-ff, identidade<agente>[bot]) antes do primeiro passo. Conflito nessa integração é recusa NOMEADA ("conflito ao integrar adevatual", com os arquivos), o merge é abortado, a branch de origem fica intacta e a task bloqueia com motivo próprio e origemcodigo.fetchque falha bloqueia a task com o motivo do git, nunca cai nadevvelha. 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 adevLOCAL parada no merge da infra (Reset to dev). Declarado: no modorunnernada 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 emawaiting_useré a base com a dev integrada; conflito é recusa nomeada) - Origem: AT-458 (TP-01 de 09/10:
feature/task-c80daf6cnasceu de1fa2241sem 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 marcatruncated: true(ofinish_reason: lengthda 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 marcandocorte_pelo_teto, e o dev agent bloqueia a task com o motivo "resposta só de raciocínio, cortada pelo teto" e origemmodelo, em vez de "parou sem concluir nem reportar bloqueio". Uma chamada de ferramenta no meio zera a retentativa. Declarado: o engine recebe da apitruncatedeusage.outputTokens, mas NÃO osreasoningTokens(ficam só emtoken_usage), então a resposta vazia que bate no teto semfinish_reason: lengthnã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 comcorte_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
c80daf6cbloqueou 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 semtoolCallse o texto traz a chamada escrita como marcação (<function_calls>,<invoke name="X">com X ferramenta do passo,<tool_call>{json}comnamede ferramenta; com ou sem prefixoantml:), 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:finalcomcontent: '',errornomeando o formato e o modelo eerrorCode: '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 semtoolsnão contam; o JSON solto segue com oToolCallRecovery(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.tsestream-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 ogit_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 camposummarydiz 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-9d6dcd53com "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
(
moduleOpenTasksemGET .../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 emmoduleOpenTasksTotale o corte dito no recorte. Requisito da entrega que depende do que outra história ainda vai entregar (uma rota, uma tela) vai aoresumocomo 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) eapps/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), emmoduleContractdeGET .../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 noresumo(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) eapps/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 pediaGET /painel, o contrato declarouGET /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 adevMAIS NOVA que o working tree enxerga: com origem local (caminho no disco) o gate faz antesgit fetch origin dev, melhor esforço, e entreorigin/deve adevlocal vale a que contém a outra. Adevlocal 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 é doWorkspace) — 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)itensecoverageMatrixdo parecer de QA chegam aoartifact.qa_verdictsempre 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) eapps/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 adev, ecoverageMatrix/itensgravados 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
activemais recente comexecution.activated) enquanto houver tarefa de história não arquivada BLOQUEADA, com PR emawaiting_user, ou devolvida por conflito de merge (RN-715,backlog.task_merge_conflictnesta sessão) e aindain_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_claimablecom causatask_unblockedleva comomoduleso 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 emdev_agent_statespara 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_trippede os demais que oGetSessionPendingWorkUseCaseconta) e que NÃO tem linha emdev_agent_statesNESTA sessão ganhadev.errorcom origeminfra(reason: dev_agent_sem_processo) seguido dedev.idle, que o tira da régua do trabalho pendente (RN-064). É o molde da RN-586 para o vocabuláriodev.*: nunca reexecuta, nunca reescreve evento, e a linha durável é a prova de dono — com ela oDevRehydratorreliga 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 comdev.errorinfra +dev.idle; o que tem linha durável e o que já estáidlenão; leitura que falha não fecha nada) - Origem: AT-465 (TP-01 de 09/10: a sessão
63a4e1aareagendada pelo heartbeat a cada 30 s por umdev.workingsem 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
AmbienteDoAgenteou noreport_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-apisaiu com um README só comnpm test, semJWT_SECRETobrigatório,DB_PATH/PORT,npm startnem 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_contractsgravar a versão (RN-684), a ferramenta lê o backlog do projeto e extrai, de forma determinística e sem LLM, todoMÉ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 dasassinaturas 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 /painelna história ×GET /panelno 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 declarouGET /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 MESMOPOST .../execution/activate(ActivateExecutionUseCase) do botão "Ativar execução", semoriginSessionId(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_AUTOFILLdo 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
gitleakspõ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 oSecretpara 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 adevnão tem: árvore limpa egit diff --quiet <ponta da dev>...HEADsem diferença. É o caso da task reintegrada (RN-779) cujo código já entrou por outra PR. Sem diff, a task fecha comodonecom o eventodev.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 modorunner; git que falha; ref dadevausente) é "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
devtoca um README, o QA confere, sem LLM, que todonpm run <s>,npm test,npm start,pnpm [run] <s>eyarn [run] <s>citado nele existe emscriptsdopackage.jsonda 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_requestedcom um item por comando, nomeando o script que falta; sem divergência, aprova. Sem README no diff, diff que falhou oupackage.jsoninalcançá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_autonomydo projeto não chegou, o "Ligar para N agentes" da aba Executores (RN-661) e as caixas dos agentes ficamdisabled, 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
ApprovalCarddepropose_execution_planpendente mostra, acima de "Aprovar", o MESMO controle de oferta em lote da aba Executores (RN-661): osdev-<modulo>que o plano sobe (mesma derivação dedevAgentIdda 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 (papelmaintainer) 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_testevalida os argumentos JÁ normalizados (RN-719:criteriosExecutaveisem string JSON vira lista), e o hook que encerra o laço da QA-estratégia lêcriteriosExecutaveispela 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 noEnumda 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, origemcodigo) 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
execdaDockerPorté decidido pelo RELÓGIO (decorrido ≥ teto) além dokilled, porque o clientedocker execcaptura o SIGTERM dotimeoutdoexecFilee sai com código 0 — medido:sleep 30; echo FIMsob teto de 3s voltavaerro: null,stdout: "", e o adaptador devolviaexitCode: 0. Comando de agente que estourava os 120s voltava "exit 0", e o QA aprovou suíte que trava. O estouro agora devolveexitCode: -1,timedOut: truee a marca de timeout. E matar o cliente não mata o comando: oexecembrulha o comando (que segue inteiro e não interpretado; o canal é-e BRABO_EXEC_CMD, RN-947) numshque grava o próprio PID — líder de grupo e de sessão, porque o runtime fazsetsidem tododocker exec— em/tmpdo 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:timeoutdo busybox dentro dosh -c— o vigia morre com o cliente e osleepsobrevive. Se a morte falhar (/tmpsem 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 doGateRescuer) 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_modulesechoose_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) eemit_artifactficam 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
codeconnection/timeoutque a api normaliza (ADR 0041) ou, sem código, o texto do transporte (EAI_AGAIN,ECONNRESET,ETIMEDOUT,ECONNREFUSED,ENOTFOUND) —, oToolLoopa retenta até duas vezes, com espera de 1 s e 3 s, gravandotoolloop.network_retrya cada uma. Vale para todo consumidor doToolLoop(dev agents e subagentes de gate). Esgotado o teto, a origem do desfecho éinfra, nuncacodigo, 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 umgetaddrinfo EAI_AGAIN openrouter.aide 17 s bloqueou duas tarefas comocodigoe 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 adevdo remoto pelo MESMOfetchque a RN-779 faz no claim, serializado por projeto. Sem ele a comparação usava adevdo dia em que o worktree nasceu: a branch da task cujos commits já tinham entrado nadevremota pela PR de outra task passou por QA e SecOps com 0 arquivos.fetchque 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 fechaexecutedcom a URL e o id dela ereaproveitada: 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_progressein_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_actiondegit_mergemostra 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(PendenciasDeOutrasSessoesantes destyles.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/maindo 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 paraitens. É régua de PROMPT: o contrato externo dos gates (docs/gates.yml) e o código doemit_qa_verdictnã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.tsnunca chamava a migração da tabelalinkse/:codesombreavaGET /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
itensTODAS 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
startescutando numa porta do projeto), só processos daquela pasta, nunca de fora. Teste que sobe processo deve encerrar o GRUPO (ou subirnodedireto, não vianpm); o que não encerra trava a suíte num clone limpo e é lacuna emitens. Teste que se PULOU por ambiente (porta ocupada, ferramenta ausente) não conta como verde: vai aoresumocomo observação. É instrução do recorte, não passo do harness: quem roda a suíte é o próprio QA peloterminal, que já passa pela política e pelo roteamento de container dos três modos — umpkillno 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 testtravava num clone limpo — o teste matava onpme onodeneto 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 paraitens; vai aoresumocomo 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 bloqueiopolitica)
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) eapps/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") eapps/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) eapps/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") eapps/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):
builddo Dockerfile de cada módulo comFROM= a imagem decidida (projectImagedo contexto da Infra; sem ela, a candidata do roteamento, dito no README), porta${APP_PORT:-8080}:<porta interna>, volume para dados persistentes,.env.examplecommitado (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_prganha o campo OBRIGATÓRIOrun(commandaté 300 caracteres;urlsde 1 a 5,labelaté 60,kindemapi_docs | web,urlcomeçando comhttp://localhost:ouhttp://127.0.0.1:;envExamplebooleano), validado no engine ANTES do HALT com recusa nomeada que entra no laço (RN-163) e repassado no payload deopen_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), eapps/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 levarun(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_progressdegate_statesRECENTE (abaixo degate_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 doTurnoOrfao(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 gravagate.interruptedno 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_gate15 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 runcomposto peloDockerViaClileva--restart unless-stoppedcomo CONSTANTE, ao lado do--initda 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; umdocker stopexplícito (ocontainer_stopdo ciclo de vida) NÃO é desfeito, porqueunless-stoppednão religa o que foi parado à mão — nuncaalways. Medido: containerunless-stoppedparado comdocker stopsegueexitedcomRestartCount=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 registrorunning)
RN-821 — Registrado rodando e observado parado oferece "Subir de novo"
- Regra: na
/containers, a linha com registrorunninge estado OBSERVADOexiteddeixa de ser "já está de pé" e oferece a ação de subida do modo (acaoDeSubidaDoModo), sem exigir Parar antes, com a ressalvaregistrado_rodando_observado_paradoem texto. Só o observado destrava: sem observação, ou observadorunning, o registro continua valendo. A api já aceitava:ExecuteContainerStartUseCasecom linharunningnão transiciona nada (a máquina de estados do ADR 0081 não muda) e o broker fazdocker startno 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_mapgravado 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 (otitlede botão desabilitado não abre no Chromium, RN-102); o botão habilitado ganha a explicação notitle. A derivação lê oseventsque 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_pushoupr_open: commit, push e PR acontecem quando ele chamareport_done. A descrição dereport_done, a dereport_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 scripttestdopackage.jsonlista 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ôsreport_done/report_blockedno 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êmgit_mergependente 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 (mesmasqueryKeys, 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
blockeddo backlog com título e motivo (blockedReason, ou "sem motivo registrado"), e "Desbloquear" chama o mesmounblockda 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 dedevelopervê 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"comaria-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
criarSockete morre pordescartar(), que fazdisconnect()e remove os três ouvintes que o construtor dophoenix@1.8.9registra emwindow(pagehide,pageshow,visibilitychange) —disconnect()sozinho os deixava, e ovisibilitychangereabria o socket velho. No canal da sessão cadaconectar()é 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 — oonClosede 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 decriarSocket. - 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/nameprojeto-slug, que não casam com nenhum token de dado de contato (name,nome,title,first,last,company,contact). Oproject-titleda 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 porhtmlFor) e oSEM_AUTOFILLda RN-740 ficam. É HEURÍSTICA, não medida: o NordPass não documenta atributo de ignore, e nenhum foi acrescentado aoSEM_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_pushda 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.sessaoDeExecucaodepropose_execution_planexecutado), 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 ototaldiz 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/nameprojeto-slugda 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 porhtmlFor, o campo segue fora de<form>e com oSEM_AUTOFILLda 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 comodata-np-ignore), então nenhum foi acrescentado aoSEM_AUTOFILL. Já tentados antes: tirarautoFocus(RN-736),aria-autocomplete/data-protonpass-ignore(RN-740),id/namesem 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çãopending, 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 (MarkdownMessagecomemCurso), o texto passa antes porfecharMarcadoresAbertos, 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 semdangerouslySetInnerHTML. - 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-automacaopassa a ter o mesmo tratamento dos dev agents de execução: com modelo desupportsReasoning, o provider que sabe dizer "desligado" mandareasoning: { 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 odev-lead) deixam de ficar sem piso SÓ para as duas ferramentas de desfecho da task: o recorte do Jev nunca tirareport_donenemreport_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 origemmodeloe 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.exdo engine; recorte que deixa sóterminal/read_filetermina com o parecer; falha:criativo,dev-apiedev-leadnã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
statusougateStatusdiferente 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 avisaadr.pr_opened,infra.pr_opened,action.pr_open,action.git_mergeouaction.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 opullRequestIddo resultado com ogit_mergeEXECUTADO 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_mergePENDENTE que o bloco de pendências desenha na linha da PR ("PRs prontas para merge") é o ÚNICOApprovalCarddele 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 (oDisclosuredesmonta 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-summarydevolve, 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 emregras/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_prexecutadas, compullRequestId) 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 emprsNaoLidas, 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...sourcetambé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 emprsNaoLidas(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 alvoprsdo 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 emprsNaoLidassegue coberta porapps/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:ProposeActionUseCaseexecuta a ação sob a marcarunComoEfeitoDeAcao, e o notifier só fica calado com origem engine SEM essa marca. O evento que o engine pediu (inclusive oproposed_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.jsone/docs(curl,wgetounode -e fetch) e encerra o grupo de processos (RN-817). Falha vai aitens; odocker compose upreal fica noresumocomo "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_prEXIGErun={command, urls, envExample}(ADR 0213):commandtexto não vazio até 300 caracteres;urlsde 1 a 5 itens{label (até 60), url, kind}, comurlcomeçando comhttp://localhost:ouhttp://127.0.0.1:ekind∈api_docs|web;envExamplebooleano. A api recusa na PROPOSTA com 400run_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 emiteartifact.run_instructions{command, urls, envExample, prUrl, version}— versionado, sem tabela, o de maiorversioné o vigente (o desenho deartifact.project_image), e projetado emdocs/como os outros versionados.GET /projects/:projectId/run-instructions(viewer) devolve o vigente (+ version, eventId, declaredAt) ounull. É DECLARADO pela Infra, nunca verificado na máquina de quem lê (RN-468). E o contexto da Infra (/internal/.../infra-context) passa a levarprojectImage, a decisão vigente de imagem, rede e recursos,nullsem 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("semrun: 400run_invalido, sem proposta"; "URL fora de localhost: 400 nomeando o campo"; "runvá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: "runinvá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>ehttp://127.0.0.1:<porta>; outra URL não vira link, mesmo a api já a recusando —, a lembrança decp .env.example .envquandoenvExamplee 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 avisoinfra.pr_openeddo 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
todode históriareadycujo módulo é de um dev agent DESTA execução (ator de eventodev.*na sessão) e a tarefain_progressatribuída a um deles — sem teto, como os outros. (2) O claim retoma, ANTES da filatodo, a tarefain_progressque já é do agente (mesmoassigned_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 doWorktreeManager). (3) Encerrar a sessão de execução (RN-763) bloqueia também a tarefain_progressde donodev-*que nenhuma linha dedev_agent_statessegura, com o mesmo motivo e origempolitica; 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
538a0ba5encerrada 1 s depois do desbloqueio, tarefain_progresspresa emdev-apisem 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 odev-lead) e a ÚLTIMA mensagemuserdo 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 ganhawrite_fileeterminalalé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 comopen_question.answered{questionId, resposta, aceitouSugestao, decisionEventId, respondidaPor}; o STATUS sai do log (aberta = semanswered), nunca de tabela.GET /projects/:projectId/open-questions?status=open|answered|all(viewer, padrãoopen) lista as mais recentes primeiro, com teto de 50 e ototal(RN-180), e diz emautorDaSessaoquem pode responder.POST .../open-questions/:questionId/answercom{resposta}ou{aceitarSugestao: true}só é aceito do AUTOR da sessão de origem (403so_o_autor_da_sessao); já respondida é 409pergunta_ja_respondida, pergunta de outro projeto é 404 e resposta vazia (ou sugestão que não existe) é 400. Responder grava, numa transação, umartifact.decision_recordcom o usuário como ator (context= a pergunta,choice= a resposta,consequences= o que afeta, maisquestionIdeafeta) e oopen_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-contextdevolvedecisoes: osartifact.decision_recorddo projeto cujoafetatoca 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 semafetanã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óriareadytambém é corrigível quando o pedido cita umquestionIdque o usuário JÁ respondeu — pergunta aberta ou inexistente não destrava nada, e a trava de tarefain_progress/in_reviewcontinua valendo. As duas portas do PO aceitam oquestionId:update_story(título/descrição, o evento o leva) ecomplete_story(RF/DoD/DoR; a história segueready, sem nova promoção, ebacklog.story_completedleva oquestionId). - 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
readyque 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 vereditoneeds_user_decision, só do gateqa, compergunta(até 500) esugestao(até 300) ou umopenQuestionIdque 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 bloqueiopoliticada RN-762. SemopenQuestionId, a api grava oartifact.open_question(ator e autorqa-agent, na sessão de execução, afetando a tarefa e a história dela), opr.gate_changedsai comesperandoUsuario: truee oopenQuestionId(é por ele que a tela mostra a tarefa como "esperando você"), e a resposta devolvenextAction: "await_user"com oopenQuestionId— 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(perguntaaté 500,sugestaoaté 300 opcional,afeta: {regras, historias, tarefas}), que gravaartifact.open_questioncomquestionId(uuid),autoresessionId; 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, nuncachanges_requested:emit_qa_verdicto aceita só comperguntaouopenQuestionId, o QA Lead o consolida acima deapproved, 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. Asdecisoesdo 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 passaquestion_idemupdate_story/complete_storyquando 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.urlsSEMPRE/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 dorunrecusa, nomeada e com a MESMA régua nos dois lados,api_docscuja 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 empathsdo/openapi.jsonservido; 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
totalda 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 emartifact.open_question,open_question.answeredepr.gate_changed(alvoperguntas, 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
infrapor falha PASSAGEIRA de rede/provedor — lista de permitidos explícita: DNS (EAI_AGAIN,ENOTFOUND,getaddrinfo),ECONNRESET,ETIMEDOUT,aborted, HTTP 5xx/429 do provedor (códigosrate_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, atorsystem, 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/verdictcomneeds_user_decisioneopenQuestionIdconfere, 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: 404reason: "pergunta_nao_encontrada". Já respondida: 409reason: "pergunta_ja_respondida"com a decisão do usuário emdecisao— sem escrita e sempr.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_questioncujoafeta.regrastraz o TÍTULO exato de umartifact.business_ruledo 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,decisoescasa 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-contextdevolve, além dedecisoes(as que tocam a tarefa, RN-859),decisoesDoProjeto: as demaisartifact.decision_recorddo 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 semafetanã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 quePOST /linkseGET /panelficaram fora do OpenAPI com as duas tarefas aprovadas no TP-01. A conferência do QA passa a exigir empathsdo/openapi.jsonservido, 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_parkedtem dois emissores: o resgate por tempo (GateRescuer, RN-722), que gravaidadeSegundos, 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 deidadeSegundos(omotivoé 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
GateRescueSchedulerWorkerreagenda o próprio sucessor com a MESMA unicidade dokickoff/0(estadosavailable/scheduled/retryable, semexecuting, janela de dois intervalos). Antes só okickoff/0era ú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 operform/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 degate_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
systemEFÊMERA acrescentada pela fachadaEngineApiClientlogo depois do fluxo do time (RN-711) — nunca emstate.messagesnem no prompt do servidor —, as decisões do usuário (artifact.decision_record,choiceecontext) e as perguntas AINDA abertas do projeto (artifact.open_questionsemopen_question.answeredcom o mesmoquestionId), 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 peloquestionId, 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ó oporecebe. - 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 vereditoneeds_user_decisioncomnextAction: "await_user". Se a api RECUSA (400, 404, ou o 409pergunta_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 origemmodelo(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 ferramentaemit_qa_verdictrecusa, DENTRO do laço e antes da api,openQuestionIdque 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.openQuestionIdsó com o uuid de uma pergunta ABERTA; sem ele, a pergunta nova vai empergunta/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_URLou doHostda requisição COM a porta — nuncahttp://localhostfixo nem o hostname sem porta —, e que o mesmo caso de erro responda o mesmocodeem 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ãohttp://localhost:<porta>ehttp://127.0.0.1:<porta>),OPTIONS204 eAccess-Control-Allow-*comAuthorizationeContent-Type; o/openapi.jsondeclaraserverssó 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, comserversabsoluto 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
decisoesDoProjetoque a api devolve nodev-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_ORIGINSdo entregável usa a porta PUBLICADA —APP_PORT, que o compose passa ao app —, nunca a internaPORT: no TP-01 (16ª) o padrão saiu comPORT=3000e, com o compose publicando 8080, nenhuma origem real recebiaAccess-Control-Allow-Origin(medido com curl::3000recebe,:8080não; no Chrome,Failed to fetch). O QA faz o preflight contra a porta PUBLICADA, uma vez parahttp://localhost:<APP_PORT>e outra parahttp://127.0.0.1:<APP_PORT>, e reprova sem o headerAccess-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 MESMOinsufficient_creditdo 402 (RN-730) — sem código novo no contrato — e o resto do caminho do crédito vale igual: origeminfra(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 origemcodigo. 403 sem o marcador segueauth. - 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ão6c68fbc0(projeto pt-BR,anthropic/claude-haiku-5.5): a orientação chegava, e mesmo assim a iteração 0 doqa-automacaoe dodev-encurtador-apiabria em inglês ("I'll start by running…"). O caminho continua UM (a fachada, RN-622): nada em prompt de servidor,agent_instructionsou descrição de ferramenta (RN-667). O teto de 50 tokens (AT-169) segue: medido comgpt-tokenizer,pt-BR32/26,en19/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):
defaultValueref, e oonChangesó emite o valor aodraftda 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 pelodraft, 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 osdev-<modulo>que entraram na sessão inteira mais os subagentes de gate (agentesDaOfertaEmLote); papelmaintainer(o do endpoint). Sem execução, nada aparece eagent_autonomynem é 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.historiasque o backlog traz ganha "Corrigir a história" quando ainda é corrigível pela régua da RN-727 (draft, sem tarefain_progress/in_review, papeldeveloper) — 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 paralib/. - 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_progresscomtasks.blocked = true(eblocked_reason/blocked_origin) — não há statusblockedno enum, por decisão do dono (14/10). Quem CONTA ou MOSTRA tarefa lê os dois campos, eblockedvencestatus: 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.executionActivateddo resumo de projetos (a sessão mais recente, a de execução), a MESMA leitura equeryKeyque 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 eagent_autonomynem é lido. Lacuna declarada: o sinal éroster.executionActivatedda sessão mais recente, que diz que ela TEVEexecution.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 paraitens. A instrução da QA-estratégia pede, emcriteriosExecutaveis, 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:createrecusava 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) aceitapt-BR,en,ruezh-Hans, e recusa qualquer outro; o padrão da conta continuapt-BR. No web,pt-BRé o padrão e ofallbackLng(revisa a RN-432, que fazia doeno 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 emlocales/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_questioncomafeta.historiase 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 reescreverbusiness_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
expiresAtno 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_epicrecusa (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/objectque 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 doemit_plano_de_testepassou 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:193e:201(o formato e o tipo recebido) e:210(as chaves recebidas) - Origem: AT-583 (TP-01 18ª e 19ª: o
dodchegou 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_storyrecusaepic_idque 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 listandoid=títulodo 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:224e:233 - Origem: AT-584 (o qwen mandou
EPIC_ID_PLACEHOLDER_NAO_USAR_AQUIe a api respondeu com o nome do DTO)
RN-897 — O corte do provider por max_tokens num gate é origem modelo
- Regra: o
agent.errordos gatesqa-estrategia,qa-automacao,qa-performance-segurancaeappsecclassifica "Tool calls cutoff by max_tokens" comomodelo(o raciocínio comeu o teto, RN-059); qualquer outro erro de LLM segueinfra. - 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.responsecommodelName, e a bolha mostra "Continuar de onde parou" (RN-749). Vale para os sete conversacionais, que gravam o fecho porpayload_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
idlesemagent.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.errorcom origemmodeloe 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 porheartbeat_timeout26 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:194eapps/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_filesubstitui o arquivo inteiro. Quando opathé um README (README*), o arquivo atual tem a seção## Rodar na sua máquinae 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 peloterminal(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.activatedAgentsdo resumo de projetos NUNCA traz dev agent: o dev não gravaagent.activated, sódev.started. A oferta em lote do chat (RN-883/889) lia só essa lista e não listava odev-<modulo>da execução ativa, que o usuário precisava ligar pela aba Executores. A execução do projeto (execucaoDoRoster) passa a somar, só comroster.executionActivated, osdev-<modulo>dosroster.moduleNames(omodule_mapvigente), pela MESMAdevAgentIdque a aba Executores usa (RN-661,rosterFromFacts), sem derivação nova. Sem execução ativa, nenhum dev entra. Lacuna declarada: a lista sai domodule_mapVIGENTE; 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 poruseExecucaoNoProjeto(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_approvalporque 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 → provisioningefailed → provisioningRELÊ a decisão de imagem vigente e regravaimageVersioneresourcesda linha deproject_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 → runningNÃO muda a versão: o container reaproveitado é o antigo. A página/containersganhadecisaoVigenteeimagemDesatualizadapor 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, comrunning/stopped/provisioningdivergente, 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 dechoose_project_imagetrazcontainerDePee 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
egressnão alcançou o container v2nonee 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 segueinfrae a retomada da RN-864 não muda (até 3, depois de 1, 3 e 10 min; pausa de 60 s, que odev.idlenomeia 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:freebloqueou 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_turnellm_turn_stream, esta só sem texto já entregue). Quem trata o corte é a retentativa da RN-780 noToolLoop, 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, oagent.errorcomreason: "limite_de_iteracoes") levagravado, a linha da RN-731 lida ANTES de o texto do turno ser gravado (ela sai ali e não naagent.response, nunca nas duas). O PO leva tambémregrasSemHistoria, ouncoveredCountquefechar_handoffjá 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 emdev-*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 é
nonee algum módulo domodule_mapvigente temstackque 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 pedeegress. 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, ocontainer_startterminafailedcom o motivo, sem chamar o broker). Semmodule_mapou 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_negocioelistar_backlogna mesma resposta, sem escrita junto; depois todos os épicos numa resposta só; depois todas as histórias, com oepic_iddevolvido; depois todas as tarefas, com ostory_iddevolvido. 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_callsnã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
9routerfala com um gateway na máquina do dono. A URL base éNINE_ROUTER_BASE_URL(padrãohttp://host.docker.internal:20128/v1), lida a cada uso pelo provider e pelo teste de conexão (GET /modelscom a chave). A credencial é obrigatória. As capabilities de provider sãofalse(sólistModels,streaminge, 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 mandax-9router-token-saver: offem toda chamada, para o 9Router não comprimir otool_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 emnine-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ó paraowner(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-spendouapproval-matrixabre 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 paraowner);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
areada 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; osdev-<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 pedemmaintainer(o mínimo do endpoint de área, RN-102), comparado porroleAtLeast; abaixo disso ficam inertes e o motivo é dito UMA vez em texto na legenda da seção (ADR 0064). As demais linhas seguem emdeveloper. 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, chavemodelsSection.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,developerinerte);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.resultok: 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 gravaagent.errorcom origemmodelo(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 oToolLoop, 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 oagent.errorno lugar dotoolloop.limit_reached; oToolLoopdevolvendo{:ok, ctx}comlast_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 noToolLoop, 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_budgetpreenchido (curado à mão, nunca pelo sync) esupportsReasoningmandareasoning: { max_tokens: N }emax_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-flashfica sem orçamento:reasoning.max_tokens,effortereasoning_effortforam ignorados (8192 de raciocínio).effortnunca é 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_planogravaagent.plancom o objetivo (até 200 caracteres) e de 1 a 12 passos de até 160 caracteres, cada umpendente,em_curso,feitooudescartado. Cada chamada manda o plano INTEIRO e substitui o anterior (o vigente é o últimoagent.plando 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 deferramentas_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 ocontext-manager. AReidratacaomostra só o último plano de cada ator, como uma nota, e pula otool.call/tool.resultda 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.Diariolê a cauda do log do agente, a partir do marco do turno (agent.status: working, odev.workingda tarefa ou oagent.turn_resumeddo subagente de gate), e devolve o plano vigente, os efeitos confirmados (tool.resultok,proposed_action.executed, artefatos, commit preservado), as chamadas sem desfecho, as ações pendentes (proposed_action.createdsem decisão), a última atividade e se a retomada automática já foi usada. A fonte de verdade continua o event log.RetomadaDoTurnotransforma o diário numa mensagemsystemEFÊMERA (teto de 2 000 caracteres, depois deDecisoesDoProjetoe antes dePerfilDoAutor; noToolLoop, porctx[: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 peloworking) e que ainda não foi retomado: gravaagent.error(turno_interrompido_por_reinicio,retomada: "automatica"),agent.status: idleeagent.turn_resumed(automatica: true) e acorda o agente, cujoinit/1agenda:retomar_turno_interrompidoANTES da entrega da fila (RN-673). O teto é durável no log: umagent.turn_resumeddo agente entre o último status não-workinge oworkingórfão. Turno velho ou já retomado é só fechado, com a frase apontando o botão (RN-921). Ofechar_ao_subir/3doinit/1continua 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.errorcomreason: turno_interrompido_por_reinicioganha no fio o cartãoRetomarTurnoInterrompido(objetivo e passos do último plano, "Parou em", botão). O clique chamaPOST /projects/:projectId/sessions/:sessionId/agents/:agent/resume(developer; 200{ok:true}; 404 sessão alheia; 409turno_nao_interrompidoquando o último desfecho do agente não é a interrupção ou há turno em curso; 409agente_sem_retomada), que pede ao enginePOST /internal/sessions/:sessionId/agent/resume: ele gravaagent.turn_resumedcomautomatica: falsee manda:retomar_turno_interrompidoà MESMATurnoAssincrono. 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 doagent_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
workingreidratado, com a última atividade há no máximo 900 s e semdev.workingcause: "retomada"nesta tarefa, preserva o worktree como commit (RN-743), readota a MESMA tarefa (a branch dela, RN-779), gravadev.workingcomcause: "retomada"e o commit, e roda o laço com a mensagem do diário. A tarefa nunca sai dein_progress. A 2ª queda, a queda fora da janela, oawaiting_approvale 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 eawaiting_approvalbloqueiam) - 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_resumesconta 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 doon_conflictdo 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 ganhamregistrar_plano; no redespacho oToolLooprecebectx[:retomada]com o plano do últimoagent.turn_resumeddo 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 abreworking, 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áticano 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/3gravainfra.correction_queued(id,gate,reason, diagnóstico até 2 000 caracteres) ANTES do cast e, sem processo, acorda o agente;iniciar_correcao/2gravainfra.correction_delivered(id). Oinit/1reconstrói as pendentes (queued menos delivered) e as entrega depois do turno retomado e da fila de mensagens, e oRehydratoracorda o Infra Lead que tem correção pendente no boot. Entregue não é reentregue. O Infra Lead também temregistrar_planoe 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 deGET 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 porroleAtLeast. - 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.tsxcobre 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 nemkey_id.ollamanã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 mesmoUpsertUserCredentialUseCase(envelope comkey_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 deLLM_PROVIDER_NAMES_COM_CREDENCIALé 400; credencial existente do provider semsubstituir: trueé 409credencial_existente, sem tocar a existente. A resposta é{ provider, substituida }. O valor nunca vai a log (a auditoria leva provider eorigem), 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 comkey_idsem o valor em log/resposta; 409 sem substituir; substituir; provider inválido); 401/403 do guard emapps/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,\rnem NUL) troca osleep infinitypelo supervisor CONSTANTESUPERVISOR_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 seguesleep infinity. Ausente ounull, odocker runé byte a byte o anterior. O broker lê o campo SÓ dacontainer-specda 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:
portasvira 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, nuncalocalhost,host.docker.internalnem nome de serviço da instalação, e IPv4 literal. Os três são recusados com redenone.inspectdevolveportasPublicadaslidas deNetworkSettings.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 }sobebrabo-qa-<workspace_dir_name>-<chave>sem--restart, combrabo.papel=qaebrabo.qa.instancia, sem app nem porta.stop,remove,inspecteexeccomalvode QA filtrambrabo.papel=qa; homônimo sem o papel é "não gerenciado" e nada é removido. Cada serviço (até 6, imagem por digest@sha256:obrigatório,envde 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--internaldo 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 papelqa),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 maiorversion. Só um humanomaintainero 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 é 400rede_invalidanomeando o campo. Escrever NÃO é efeito externo. Acontainer-specdevolveports,dnseextraHosts(ounull;runnersemprenull, 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;runnertudonull). - 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:
rundeopen_infra_praceitaapp: { command, port }opcional (comando de até 300 code points, uma linha, sem NUL; porta 1024-65535;nullequivale a ausente, como no engine) validado na proposta e na execução; semapp, tudo é byte a byte o de antes. Comrun.app, a regra ESPECÍFICAopen_infra_pr: auto_approve(a semente do aceite) NÃO basta e a decisão virarequire_approval; só a CURINGAauto_approvedo agente (piloto automático, RN-603/RN-670) auto-aprova. Acontainer-specdevolvecomandoDoAppdorun.app.commandvigente, ounull. - 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_actionnunca auto-aprovável, nem com a curinga, e "sempre permitir" é recusado na fonte (mesma lista decontainer_remove). A execução fazremoveestartpelo broker passando porrunning -> stopped -> removed -> provisioning -> running; recusa ou indisponibilidade do broker virafailednomeado (se a subida falha, o motivo diz que o container já foi removido).runneré recusado nomeando "runner depois". A frase emaprovacoes.tsexiste 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_praceitarun.qa = { services: [...] }(opcional,null= ausente; até 6 serviços de nome único[a-z][a-z0-9-]{0,31};imageCOM digest@sha256:<64 hex>;envaté 20 variáveis[A-Z_][A-Z0-9_]*de uma linha;tmpfsaté 3 caminhos absolutos;sharedbooleano). A mesma régua vale na proposta (400run_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 deproject_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 é 409instancia_de_qa_ocupada.runneré recusado (409qa_nao_suporta_runner, "runner depois"). A chave do container é ochaveDeQa(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_actionsemeadaauto_approveparaqa-automacaona ativação da execução; ao ser auto-aprovada ou aprovada, a api sobe a instância pelo broker (failednomeado se o teto está ocupado, se o projeto érunnerou 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 rotulabrabo.papel=qa, então não entra no teto absoluto decontainer_remove/container_recreate. Ocontainer-execaceitaalvo: { qa: true, taskId }vindo do engine (nunca do modelo) para rodar na instância; sem instânciarunningdevolvesucesso: false. A frase do tipo existe emaprovacoes.tse 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 comcomandoDoApp: null,ports: nulleqa: { chave, services }(os derun.qa,[]se ausentes).taské o id completo OU a chave de 8 (o broker dadevmanda a chave), resolvido DENTRO do projeto: tarefa inexistente ou de outro projeto é 404, chave ambígua é 409tarefa_ambigua.POST /internal/qa-environments/sweeprecebe do engine ostaskIdsEmVooe remove toda instância ativa oufailedfora dessa lista. Lacuna declarada: o broker ainda não tem operação para remover o serviço compartilhado nem a redebrabo-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 umaproposed_actionterminalao/internal/actions/executedo engine SOMENTE se o ator da ação éqa-automacaoe otaskIdpertence ao projeto da ação. Em qualquer outro caso (outro agente,alvomalformado, tarefa de outro projeto) oalvoé descartado com o avisoterminal.alvo_de_qa_descartadono 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,staleTimede configuração;nulltem texto próprio) e salva num PUT só, por botão. O controle fica inerte abaixo demaintainer(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 recusa400 rede_invalidaaparece com o campo e a mensagem da api e409 sem_sessaotem texto próprio. A tela diz que a porta é publicada só em127.0.0.1e que a configuração só vale com o container recriado. "Aplicar e subir de novo" PROPÕEcontainer_recreate(RN-934: teto absoluto, sempre pede aprovação humana) e fica inerte emrunner("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 comohttp://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 oQaAutomacaoAgentpropõeqa_environment_start(semeadaauto_approve); a subida que falha termina o gate com desfecho nomeado de origeminfra(RN-059). Oterminaldo QA levaalvo: {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.runnersegue sem instância. O boot e o tick doGateRescuerchamam osweepcom as tarefas degate_statesqanã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 queremoverServicosDeQae o rótulobrabo.qa.donojá esperavam. (2) Postgres sob--cap-drop ALLsai comchown/chmodnegados: o serviço ganhauser("<uid>:<gid>", inteiros diferentes de 0, sem zero à esquerda, nunca root) que vira--user, e os caminhostmpfsmontam comuid=,gid=emode=0700do mesmo dono. (3) Imagens que não sobem sozinhas: o serviço ganhacommand(1 a 16 argumentos de até 200 caracteres, sem\0,\nou\r), passado como argumentos DEPOIS da imagem, nunca por shell.nullequivale 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 aceita0. - 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 de0: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-alpinecom70:70aceita conexão emlocalhost:5432ebusybox httpdresponde emlocalhost:9000de 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 nenhumaartifact.open_questionnascia. 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émdeclare_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); comfilasDoChat={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 odata-testidnã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_requestedmais recente do atorqapara a MESMA task entra nodev_contextcomo:pedido_anterior, e oQaAutomacaoAgentacrescenta à mensagem a lista de itens e a instrução de dizer noresumose 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 oqa_verdictúnico por ciclo não mudam. (b)plano_ja_emitido/3lê 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 dedev-*/qa-*diz quewrite_file/read_fileusam caminho relativo à raiz do repositório e quecdnoterminalnão persiste (cada chamada ésh -cnovo emSystem.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
ToolLoopdevolvecorte_pelo_teto: true,QaEstrategiaAgenteQaAutomacaoAgentnarram, com origemmodelo, que a resposta foi só raciocínio e o teto de saída a cortou duas vezes (texto único emEngine.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
truncatedCOM texto e sem chamada (o raciocínio comeu o teto e o texto saiu cortado) não é "o modelo parou": oToolLoopdevolvecorte_com_texto: true, chave PRÓPRIA (nãocorte_pelo_teto, que o dev agent e a RN-780 casam), eQaEstrategiaAgenteQaAutomacaoAgentnarram, com origemmodelo, 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
execdaDockerPortviaja por-e BRABO_EXEC_CMD=<comando>e não mais no argv: o embrulho da RN-807 o lê, fazunseteset --, e o roda por(eval "$c")em subshell (mesmo grupo, mesmo PID gravado,exiteset -econtidos, 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 compkill -fmorriam com 143, porque o padrão casava o argv do própriosh. Fora, declarado: o runner no host (apps/runner/src/exec.ts), que a RN-507 já condiciona a containerrunning. - 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 -fsobrevive,exit 3volta 3, o comando não aparece nops). - 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.gitignorebase do bootstrap, a exclusão:(exclude,glob)**/<padrão>nogit adddo 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 quenpm/pip/mixrodam na pasta do módulo na MESMA chamada, nuncacd … 2>/dev/null;(ocdfalho caía na raiz e deixou umpackage-lock.jsonvazio), dentro do teto de 1.500 caracteres. (3) O kickoff do dev nomeia oREADME.mdda RAIZ, sem repetir a seção em README de pasta, ewrite_filerecusa 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.gitignorenovo; fixture.dbintencional fica fora do commit (renomeie para versioná-la); a guarda não cobre escrita peloterminal. 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)
nineRouterConfigdeclaratoolCalling: true, provado por smoke com chave real em 31/10: 3/3 chamadas emcc/claude-haiku-5-5voltaram comtool_callsestruturado, 10/10 consistente a temperatura 0 e laço de 3 voltas íntegro. Antes, a capabilityfalsefazia obuildBodydescartartoolsem silêncio e o Criativo escrevia a chamada como XML no fio (AT-637). (2)assertModelFitsBindingScopeconfere também o PROVIDER nos escoposagentearea: vínculo cujo provider declaratoolCalling: falseé recusado com 422 (ModelNotFitForAgentScopeError, causaprovider), com frase própria.workspace,projectesessionseguem livres. (3)buildBodycom ferramentas pedidas e capabilityfalseLANÇA erro nomeado em vez de descartar. Providers afetados: só oopencode-zentemtoolCalling: falsealé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 emagent/area; escopos livres),apps/api/test/infrastructure/llm/nine-router-provider.contract.spec.ts(capabilitytrue),apps/api/test/contract/llm-provider.contract.ts(provider sem capability lança nomeado); smoke manual emnine-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-savercom valoroff(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 (oksem ele = 1 262 tokens). (2) Para a conta OAuth do Claude (cc/*) o 9Router mandaredact-thinking-2026-02-12emAnthropic-Beta: o pensamento volta redigido e semreasoning_tokens, então a contagem de raciocínio do stream (RN-915) e a retentativa sem raciocínio (RN-916) ficam cegas nesse caminho, ereasoning_tokensgrava ausente ("não disse", RN-666). (3) Com tokensk-ant-oato 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çalhoofffixado 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 (
seqdo ú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 porResizeObserverno CONTEÚDO e no CONTAINER (faixa de atividade, composer e pendências mudam a altura).?highlightEventsegue 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,lengthigual comseqnovo, 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_timeoutfica no socket de uma conexão do pool do Finch; o GET seguinte que a pega lê a resposta ANTIGA e lançaCaseClauseError {:status, ref, _}, que a checagem de trabalho pendente da RN-064 lia como "api fora do ar" e encerrava a sessão ativa. Oreq_getrepete 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:noprocvirava 500 mascarado por "Stale conn". Agora recebemprojectIde sobem o Criativo/Arquiteto antes (idempotente); semprojectId, 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; semprojectIdé 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
errorCodecaía emcodigo.FalhaDeTurno.origem/1o classificainfra(sem tocarfalha_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
nullpara 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, eonModuleInitsegue sem lançar. O aviso doGraphProjectorinclui 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.examplesó com as variáveis obrigatórias, sem valor.propose_infra_prrecusa, ANTES de abrir a PR, arquivo de compose com bind-mount relativo (- ./pasta:/destinoousource: ./pasta): a pasta nascerootna primeira subida e o app non-root não grava nela. A recusa é resultado de ferramenta (entra no laço, RN-163) e registratool.resultcomok: 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
systemefê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_filee oterminalsaí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 viramodel_not_found, 401/403auth, 402insufficient_credit(RN-730), 429rate_limit. 5xx de dentro, ausente ou corpo semreset afterseguemupstreampassageiro. 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 porapps/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 seguemupstream);apps/engine/test/engine/agents/falha_de_turno_test.exs(model_not_foundnã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.plando agente do turno comseqposterior à abertura dele (o últimoagent.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: falsenão tem o campo.textareausa oQuestionnaireInputcomrenderde textarea. O estado segue controlado pelo card, então o que viaja para a api é o mesmoRecord<id, texto>(RN-712), a ordem do envio é a de sempre (RN-174, RN-578) e as respostas ficam na tela no 409sessao_encerrada(RN-581). Enter que confirma o IME não avança nem envia (RN-830). O cartãorespondidae 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:
Autocompletedo Base UI dentro doDialog, carregada porReact.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(zerofetch). - 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 dodata-themee 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).