跳到主要内容
译文日期 2026-10-16;原文此后已有更改。

架构决策

ADR 记录的是某件事在被决定的那一刻为什么这样决定。 它们是不可变的:任何 ADR 在被接受之后都不再编辑。当一项 决策改变时,会产生一份新的 ADR 来取代旧的——这正是 0018 的情况,它记录了对 0011 中所决定的 NoopDevAgent 的取代。

实际后果:一份 ADR 可能描述的是一个已不再是当前的状态。 要了解当前状态,请看业务规则、 架构和运维手册。要了解 推理过程,请来这里。

在这 61 份中,除了仍在等待实现的之外都已接受——目前是 0053、0054 和 0056,它们在各自文件中标为提议。0055 在 Fase F 实现时被接受。0154 和 0155 走了同样的路:在 Fase 30 实际运行过它们之后,于 2026-09-27 被接受,并附有一节"接受说明",指出文本与代码在哪些地方不一致。没有任何检查会核对这句话:它在已经是 54 份时还说着 48,在已经是 59 份时又说着 58,这正是 ADR 0029(gerar > verificar > lembrar)的论点在反驳自身时得到印证。

决策按主题分组——即它所决定的产品前线——每份 ADR 只出现一次,位于其主要主题之下。主题存放在 docs/adr/temas.yml 中,与本页并列,而不放在 ADR 里,因为已接受的 ADR 永远不会被编辑(ADR 0202);pnpm docs:check 会拒绝没有主题的 ADR 或放错章节的行。逐阶段的历史见阶段历史。

Git、仓库与分支策略​

git provider 的契约、仓库的创建与采纳、分支的保护与策略。

#决策
0001GitProvider 规范化契约的形态:九个操作和声明的 capabilities,由唯一的契约测试套件验证
0002错误规范化:六个具体类,没有公共基类——这种抽象曾被考虑并被拒绝,因为它目前还抵不上自身的成本
0003自动重试只用于读取,从不用于变更,采用 Full Jitter 和 4 次尝试
0004git 凭据的登记:数据库中专用的 enum,与 LLM 的 providers 只在类型上合并
0005Gitflow 的 bootstrap 分为六个幂等且可恢复的步骤;skip 即成功
0028分支保护在 GitHub 和 GitLab 之间存在差异——因此它不是闸门;闸门是领域
0030机械化的分支策略:规则 → 机制,逻辑放在可测试的脚本中,回传 gate,以及重新引入每一项裁剪的代价
0044采纳已有仓库,两张表中都有 origin(created|adopted),并以计划作为闸门:dry-run 就是 ADR 0005 的 check() 而不执行 run(),计划存放在游标中(快照,不是日志),在决策为空期间什么都不改变——Fase 2 的 runner 原样交给一个协作者,从不被过滤,只是不被调用;"分歧的保护"停留在布尔层面,因为契约只暴露了这么多(ADR 0028)
0056engine 在远程仓库上工作。get_local_repo_path/1 对所有不是 local 的情况返回 unsupported_provider,四个使用方会一起停下——dev 的 worktree、终端执行器、QA 和 SecOps 评判的 diff,以及项目上下文。不对称是根源:api 通过 HTTP 与 GitHub 对话(创建了仓库、提交、创建了分支),而 engine 在文件系统上工作,只认识本地 bare repo;远程项目完成了对话那一半,停在构建那一半。这正是阻止 FASE 13b 以写入的形式存在的原因。git 的管道已经是通用的——init + remote add + fetch + checkout 不在乎来源是路径还是 URL——所以缺的是凭据。engine 不接收主密钥:它在需要的时刻,通过已经存在的 /internal/* 通道向 api 请求工作用的远程地址。而且 token 永远不进入 .git/config,这一决定是 0055 的直接后果:把 https://x-access-token:TOKEN@… 写进 origin,就会把秘密以明文放进 RN-075 刚刚给 dev agent 开放了自动批准读取的文件夹里——一个 cat .git/config 就会返回 token,它会在下一个回合被送到 LLM 的 provider。范围防的是向外读取,而不是产品自己放进来的秘密。origin 保留干净的 URL,认证在每次调用时注入。凭据是 OWNER 的(RN-058),local 保持不变,凭据失败以来源 infra 拒绝
0139晋升是 --no-ff,而这条规则被打破了三次(#367、#394、#464)——第三次正是修复前两次的那个 PR。调查发现的恰恰不是检测上的漏洞:这个检查自 2026-07-27 起就存在,并且在两次 squash 晋升中都正确触发了;2026-08-25 的日志一字不差地写着 o merge em qa não é merge commit (1 pai)。缺的是一个接收者——由 push 触发、在永久分支上失败的 workflow 没有可以变红的 PR(merge 已经发生了),所以它只在 Actions 标签页留下一次没有任何东西指向的执行,而仓库在整个历史中 issue 数为零。因此 tag-release 增加了一个 avisar job 来开一个 issue(如果同一分支已有 issue,就在其中评论而不是再开第二个,因为一大堆相同的 issue 是教会人忽略它们的最快方式),认证使用 github.token 而从不用 PAT——一个无效的 BRABO_BOT_TOKEN 本身就是告警需要报告的故障之一,依赖于可能已损坏之物的告警恰恰会在需要时沉默。这条规则也延伸到 dev,但方式是狭窄的:dev 有意通过 squash 接收工作,因此"一个父提交就是缺陷"会在每个 PR 上触发;那里的缺陷只是带入了一条新边的 PR(它的 head 是一个 merge,其第二个父提交尚不在基线中)——即 #464,它的全部交付就是这条边。良性的孪生情况,即一个把 dev 拉进自身的工作 PR,head 同样是 merge,但它的第二个父提交已经在基线中。识别产生该提交的 PR 时按 merge_commit_sha 过滤,而不是按 .[0]:commits/{sha}/pulls 返回所有关联的 PR,而由于 dev 是整个周期都保持打开的晋升 PR 的 head,.[0].head.ref 会在每次工作 PR 的 squash 时读到 dev,从而让整个仓库不通过——这个错误曾被犯下,并在进入之前针对真实数据被发现。在整个仓库关闭 squash 经过测量后被拒绝:dev 上最近的六次 merge 都是单父提交的 squash,而 changelog.mjs 运行 git log --no-merges,所以这种修正会向每个工作 PR 收取代价,并改变 changelog 的生成。预防仍然悬而未决,属于产品决策:由 promote.yml 自己执行 --no-ff merge 可以在机械化方法的同时保留权限,但需要决定一次人工触发是否算作"手动 merge"
0165项目的仓库在发给Arquiteto的 handoff 被接受时创建,没有仓库时执行拒绝开始。v6.1.0 的一次真实安装(AT-092)显示,在任何仓库存在之前,Arquiteto 三次提议 open_adr_pr,发给 Dev Lead 的 handoff 停留在 offered,而 POST .../execution/activate 在一个三张 git 表永远为空的项目上回应 201。RN-522 把触发点放在发给 Dev Lead 的 handoff 上——在两个会往仓库写入的智能体(Arquiteto 和它激活的 Infra Lead)之后——而且这个触发点可以被跳过,因为激活执行从来不要求它。Arquiteto 只能被发给它的已接受 handoff 激活(来自 PO 或手动),所以触发点移到同一个 AcceptHandoffUseCase 中,在 activateAgent 之前,使用同样的机制(local,失败作为事件)。Dev Lead 的接受保留为第二道门,幂等——这是在旧规则下经过 Arquiteto 的项目的出路,也是一次失败的创建的自然重试——幂等性通过两道门针对 Postgres 和真实的本地 provider 得到证明。没有仓库就激活会变成一个在任何效果之前抛出的 409,其消息根据项目的 handoffs 选择(HandoffRepository.findByProject);概览让按钮失效并以文字说明一次原因,发给 Dev Lead 的 handoff 卡片失去激活的捷径。已声明:在 Arquiteto 的整个阶段,git-bootstrap 会话会成为项目最近的会话
0217当 QA 处于测试阶段(QA_FASE_DE_TESTE=true)时,每次合并到 dev 都会在所有检查都通过后,自动开启并合并 dev → qa 的晋升;审批阶梯只豁免这一对由 bot 开启的晋升,qa → main 仍然是手动的

智能体的 harness、回合与会话​

一个智能体如何运行——上下文、工具循环、对话回合、会话及其状态。

#决策
0006确定性的上下文组装:有序的层次,每层有预算,可预测的截断
0007ToolLoop、工具目录和 ContextManager——与真实 LLM 交互的循环
0061会话的类型是创建时的数据,而执行仍然是事件。过去每个会话诞生时都一样,要找到 Criativo 必须先打开会话,然后再在顶栏里发现一个按钮——用户正是这样报告问题的。风险不在于新字段:而在于产品已经能够通过推导区分一个执行中的会话,findActiveExecutionSession 查找带有 execution.activated 的 active 会话(正是它让重新激活落在 dev agents 已经所在的会话上,第一次 dogfooding 的发现 #11)。记录一个类型会产生第二个来源,而两个来源会出现分歧——在这里,分歧关乎 dev agents 往哪里写。全部从日志推导行不通,原因在于时间:意图在任何事件之前就存在,在打开会话的那次点击里,而一个没人激活的创意会话根本没有可推导的事件。决策是让两者共存,回答不同的问题——kind 是创建时的意图且不可变,事件是执行的状态,findActiveExecutionSession 不开始查看 kind——并受一条防止它们冲突的规则约束:在 consultiva 会话中出现 execution.activated 是 409,永远不是静默转换。这道锁位于漏斗(AppendSessionEventUseCase)中,因为用户的路由和 engine 的 /internal/* 都经过它,在用例里加锁会留下另一个口子;并且它在 incrementSeq 之前运行,使被拒绝的尝试不消耗 seq。该字段在每个创建点都是必填的,包括四个内部创建点:可选会让调用方默默继承默认值。列的 DEFAULT 是 consultiva(能力最少的类型),而 BACKFILL 是 criativa,方向相反,依据是 0046 的论点——每一条现有记录都早于这种区分,其中一些正是 dev agents 现在工作所在的会话。随之而来的还有友好名称,它叠加在 hashtag 上而从不替代它(hashtag 是用来粘贴到 URL 里的;人名不唯一),以及会话页面的退出入口,此前它一个都没有。接受的代价:问题"这个会话执行吗?"今后有两个地方可查
0098PR 上的 413 request entity too large 根源在 Brabo 自己的 api,从来不在 LLM 的 provider(把责任归给 provider 的注释是错的)。两端一起修复(RN-412):apps/api/src/main.ts 从未配置 Express 的 body 限制(沿用 100 KB 的默认值,而 Phoenix 在最重的 engine→api 方向 POST .../llm-turn 上接受 8 MB)——它新增了 API_JSON_BODY_LIMIT(默认 10 MB);Engine.Harness.ContextManager.Default 的压缩在溢出之前结构上不可达——estimate/1 不计算 toolCalls(它们几乎不消耗 token),窗口只用了 context_window(128k,压缩前约 350 KB)。有效窗口变为 min(context_window, teto_de_transporte),截断总是落在 ToolLoop 的迭代边界上,绝不把带 toolCalls 的 assistant 与回应它的 tool 分开
0163启动智能体回合的点击在接受时就响应,而不是在回合结束时,拒绝也不再是沉默的。在 v6.1.0 的一次真实安装中测得(AT-089):回答 Criativo 的问题表单用了 97.3 s,确认就绪 97.3 s,确认架构 51.8 s——每次点击都被整个回合卡住,因为对话智能体的 handle_call 只有在回合的 Task 结束时才回应 engine 控制器的同步 GenServer.call,而超过 120/180 s 上限的回合会在一个本已成功的命令上变成 500。同一次安装显示了另一半:在 Arquiteto 的 kickoff 期间输入的一句 "Continue" 收到了 {:error, :turno_em_andamento},控制器在回应 202 之前把它丢掉了——被接受、从未被读取、不留痕迹。决策:TurnoAssincrono.iniciar/3 一旦 Task 启动就回应 :ok(在持久化 agent.status: working 之后,永远不在之前),把 0086 只为挂起的 Dev Lead 所做的事推广开来——六个对话智能体一个机制,没有第二条路径。智能体在开始之前拒绝的内容仍然是同步的,并获得自己的状态码(409 turno_em_andamento/aguardando_aprovacao,422 sem_regra_de_negocio),由 api 带着 engine 的原话转发。api 的成功契约不改变(201 { ok: true } 一直意味着"已接受")。界面不再把"调用已返回"理解为"回合已结束",而是通过通道加上日志尾部来跟踪结束。发给 Dev Lead 的 handoff 在 Arquiteto 的收尾回合运行期间被扣住,以便继续在 Infra 的之后到达。已声明:在 engine 拒绝之前记录的 chat.message 保留在日志中(现在有了解释),而回合中途 engine 重启会让进度条一直显示,直到"Parar"。
0175Infra Lead 通过 composer 对话,成为第七个对话智能体(AT-141,维护者的决定)。RN-584 曾把这个问题留作开放并为"是"定了价:Infra Lead 的整个回合在 handle_call/handle_cast 内部运行——接受时没有响应,没有"Parar",没有孤儿回合的关闭,调用上限 180 s 低于容器 propose_action 的 225 s(RN-605),而在 kickoff 期间发送的消息要在进程队列里等待整个回合。所以回合先改变:kickoff、gate 修正和消息都通过 Engine.Agents.TurnoAssincrono 开始,与其他六个是同一个模块——在其之前持久化 working 的接受、409 turno_em_andamento、用于"Parar"的 via_for("infra")、TurnoOrfao 以及自身上限 14 的 toolloop.limit_reached。Workflows 的汇总在 Task 内运行;回合进行中到达的 gate 修正会进入一个队列,并在回合结束时开始,因为没有 from 的 TurnoAssincrono 会丢弃它。之后 message/2 才获得了 infra 自己的子句(具名拒绝被移除),web 把 infra 放进 AGENTES_DE_CHAT,使它在是最近被激活的智能体时成为接收者;它的 handoff 保留自己的卡片(RN-499),并按卡片名称从对话流中排除。没有新的外部效果:同样的四个工具,一切有效果的东西仍然是 proposed_action。SOLO_CONVERSATIONAL_AGENTS 不变——Infra Lead 是领域的 lead
0177智能体回应所用的语言存放在三个地方——账户、会话和项目——作为 BCP-47 代码的开放列表,永远不放在 users.locale 中(AT-162,维护者在 AT-168/AT-169 中的决定)。没有 enum:每个位置都保存由 normalizarIdiomaBcp47 校验的规范 text(形式由 Intl.getCanonicalLocales 决定,语言子标签须为 api 的 ICU 所知,永远不是 und 或私有用途,最多 35 个字符),因为开放回应语言不需要资源文件,而 enum 每种语言都需要一次 migration。账户:users.response_language(NULL 表示自动,所有账户都从这里开始),以及由 CHECK 绑定的一对 detected_language/detected_language_confirmed_at——检测只有在确认后才算数(由 AT-163 写入)。会话:session_language_overrides,以 {session, user} 对为键,是配置而不是事件。项目:projects.language(AT-243,RN-619)。对有人类作者的回合,按个人的优先级:会话覆盖 > 账户的选择 > 已确认的检测 > users.locale,因此链条总是以一种语言结束;ResolverIdiomaDaRespostaUseCase 返回胜出者、来源和链条。已声明:目前还没有任何回合改变——传达给模型是 AT-164 的工作
0179智能体的循环在每一步询问 Jev 哪个工具合适,用户的聊天模型只收到剩下的菜单(AT-238,HS-063,维护者在 AT-236 和 2026-09-29 的决定)。一个自有的路由器 ToolRouter,位于 ADR 0041 的 provider 契约之外,也位于 models 目录之外:只在 OpenRouter 的聊天模型且有两个或更多工具时起作用,并且只做限制(P3 策略:{Jev 的选择,同一次执行中的上一个工具};responder_sem_ferramenta 或没有上一个工具时保留整个目录)。采用标准由所有者在面对数字时从 90% 降到 80%(菜单覆盖率 91%,85.1–94%;Jev 的 top-1 73%);真实验证是 AT-239。任何失败——HTTP、超时(2,000 ms)、意外的 JSON、选项之外的选择——都会回退到整个目录并给出具名原因,回合永远不会因为 Jev 而失败。Jev 不批准、不拒绝、也不选择模型。它的成本以真实的 usage.cost 和隐含价格(cost ÷ tokens,price_implicit)记入 token_usage,计在智能体自身的执行者名下,从而进入领域的预算;关闭开关按 workspace 设置(migration 0066);错误的菜单可以通过用整个目录重复该步骤一次来逃脱。
0183一个已结束的会话可以被重新打开(AT-071):一个从终止状态出去的出口,只到 active,通过自己的路由(POST .../sessions/:id/reopen)和状态机自己的检查(canReopen)——通用的转换仍然拒绝 closed → active,closing → active 依旧被禁止。该行回到 active,closed_at/termination_reason 被清空,同一个 seq 中的一个新事件 session.reopened 保存之前的关闭;没有事件被编辑,kind 不被触碰。三个临时默认值等待所有者决定:最低 maintainer、没有期限,以及带有 execution.activated 的会话以 409 sessao_com_execucao 被拒绝,以免 findActiveExecutionSession 被引偏。engine 的 SessionLifecycleWorker 会忽略已被重新打开撤销的关闭;Psicólogo 的第二次自动分析仍被 alreadyAnalyzed 阻止
0184所有者回应了 ADR 0183 的三个临时默认值(AT-337):重新打开已结束的会话需要 developer——与通过通用转换结束它的角色相同——而不是 maintainer,viewer 仍是 403;没有期限,以及对带有 execution.activated 的会话的 409 sessao_com_execucao 拒绝,均被确认。ADR 0183 不被编辑
0191在对话智能体处于回合中途时到达的消息不再以 409 turno_em_andamento 被拒绝(AT-267,所有者 10月1日的决定,只针对消息修订 ADR 0163):它进入 TurnoAssincrono 中该智能体的队列——七个智能体一个队列,服务器只说明一条消息如何变成回合——回合结束时,待处理的消息按顺序一起在一个回合中被读取。状态存放在事件日志中(chat.message_queued/_delivered/_cancelled,都带有 chat.message 的 id),从不放在表里:init/1 重建队列,启动时唤醒有待处理消息的智能体。每个智能体和会话上限 10 条(409 fila_de_mensagens_cheia);发送待处理消息的人可以取消它。为审批而挂起的 Dev Lead 仍然拒绝;不是消息的命令保留 turno_em_andamento

智能体团队——角色、领域与 handoffs​

有哪些智能体,每个向谁交付什么,领域内部的委派,以及它们之间的 handoff。

#决策
0008对话式的 Criativo(聊天不再是无状态的 echo)以及 handoffs 的基础
0009PO、三层 backlog 以及 规则 → 故事 → task 的可追溯性;就绪变成领域校验
0010Arquiteto:通过真实 PR 提交的 ADR、module_map 和交叉校验
0014提议式的 InfraAgent(通过 PR 提议,从不直接应用)以及通过 Phoenix 通道实时显示的团队面板
0015真正的 Psicólogo:假设附带指向真实事件的证据,并按成本进行轻/重分诊
0016Anamnese:封闭的能力目录、带回滚的版本化指令补丁,以及补丁永远不可自动批准这一上限
0022到达的证据、显现的成本、在 kill 后存活的分析;终止原因作为确定性的分类
0023能运作的目录、能区分谁拒绝的去重,以及在一轮之后存活的队列
0038领域、lead 与委派:外部 handoff 只寻址 lead 或没有领域的智能体,合并为单一工件而不改变 gates 的外部契约,级联预算并对每次委派设子上限,失败来源有类型,并回填到整个系统中原本以非正式方式分类失败的地方
0046故事的晋升重新归用户所有:proposed_ready 是布尔值,而不是 enum 中的新值,因为 enum 就是 claimNext 的闸门;晋升复用 TransitionStoryUseCase(发现 #13 中的死代码),并免费继承 12b 的 task.became_claimable;校验在触发方式变为可配置之前统一到 assertPromotable 中,否则两种模式会是同名的不同规则;定向的 backfill(已有项目进入 auto),因为该行为正在使用中;拒绝在与 engine 对话之前写入——与 rearm 相反,因为事件断言的是关于用户的事,而不是关于 engine 的
0053Dev Lead 作为领域,以及由用户授权的并行。目前每个模块启动一个 dev,每次一键接受再多启动一个,没有任何会话上限:N 个模块 × 2 个智能体,花费上升而无人授权。14d 决定由 LEAD 评估值得用多少个智能体,并且会话中超过两个就变成 proposed_action,走与所有有外部效果的动作相同的管道。Dev Lead 原本不存在且被禁止,连同通过 module_map 的动态领域和通用的领域机制——三项裁剪一起取消,因为 dev 领域的成员是 module_map 中每个模块一个,由 Arquiteto 决定,且每个项目都不同:无法像 qa 和 infra 那样硬编码。agent_areas/agent_area_members 按项目引入,max_parallel 默认 2,可按 lead 配置;gates 的外部契约不变,Fase 4 的测试套件不加修改保持绿色是验收标准。当授权变得反复出现时,Anamnese 提议提高上限,但从不自行提高——自动化就等于产品自己抬高自己的花费上限
0068Arquiteto 的 C4 图(Context + Container,Simon Brown),在概览中渲染。artifact.c4_diagram 没有表,与 artifact.project_image(ADR 0065)设计相同:Container 层由用例从当前的 module_map 推导(从不由模型重新输入——避免图与真实映射之间的静默分歧),只有 Context(系统名称 + 外部参与者)来自工具调用 create_c4_diagram。mermaid 作为 apps/web 新的运行时依赖加入(用户的决定)——这类依赖的第一个;它被隔离在 lib/mermaid-render.ts 之后,使用动态 import(),经 vite build 确认只有入口点被急切加载,而 Mermaid 的重量级 chunk(cytoscape.esm 435 KB,katex 258 KB,只被其他类型的图使用)保持按需加载。确认 ADR 0058 的封闭 CSP 保持完好:Mermaid 的 chunk 都不使用 eval/new Function,而 style-src 'unsafe-inline' 已经覆盖了 SVG 可能内嵌的 <style>
0069有条件地把 handoff 的接受与执行的激活合并。Dev Lead 的卡片有两次点击,因为这是两个有意区分的授权(RN-137):接受需要 developer,激活需要 maintainer,因为激活的人会成为 session.createdBy,并决定 dev agents 之后开启的 PR 的有效角色。这个请求不降低这项要求——对于已经是 maintainer/owner 的人(从已经授权"Auto mode"和 审批/设置 的同一个 useCurrentWorkspaceWithRole() 读取),第二次点击并不保护任何东西,只是重复一个角色已经授权了的决定;handleAcceptHandoff 改为串联"Ativar execução"按钮已经调用的同一个 handleActivateExecution(RN-137),复用它的错误处理,以免把"接受失败"与"成功接受后激活失败"混为一谈。后端未改动——RequireRole('maintainer') 保持不变,这只是 apps/web 的决定;developer 保持今天的流程,两个按钮分开
0085docs/fluxo.yml 记录智能体团队各角色之间的关系——谁向谁交付什么——这是与目录(agent-areas.ts,FASE 18)和控制(docs/gates.yml,ADR 0054)并列的第三块,此前它只隐含地存在于 prompts 和用例中。按角色的契约(使命、有类型的输入/输出、引用 gates.yml 的出口 gate),状态为 active/planned/proposto/em-refinamento;proposto 角色声明今天由谁吸收它以及分离的客观标准,使目标组织结构图成为激活的顺序而不是愿景。docs/.docmap.yml 增加了 warn 级别的 modelo-de-time 规则——要升级为 block,还缺 fluxo.yml × gates.yml × agent-areas.ts 的交叉测试(backlog)
0086Dev Lead 的执行计划真正变成 proposed_action,修订了 0053 的一部分——对 docs/fluxo.yml × 代码的审计发现,流程已经为这个输出声明了 via: proposed_action,而代码从未调整。直接先例:0052 和 0057,但这两者都由 cast 触发,从不等待同步响应——Dev Lead 是第一个挂起的对话智能体(最长 180s 的 GenServer.call)。TurnoAssincrono.tratar_resultado/2 增加了一个分支:带有 :aguardando_aprovacao 键的 state(值非空,而不只是存在——Dev Lead 从 init/1 起就带着它)以同样的方式、在同一时刻回应 from,但只发出 agent.status: awaiting_approval 而不是 agent.done。LiveBroadcast.agent_status/4 扩大了守卫以接受新状态。DevLeadServer 以自己的 id 订阅 Engine.Dev.Wake(QaLeadServer 已为 QA 子智能体复用的同一模块),并通过 {:action_settled, ...} 恢复;等待期间的第二条 user_message 会以 agent.error(来源 politica)被拒绝,不开新回合。propose_execution_plan 曾被考虑过,并被排除在 decide.ts 的绝对上限块之外——与 parallelize/raise_max_parallel 不同,它可以被配置为自动批准。接受并声明的缺口:没有状态表,挂起期间重启会丢失在 Wake 中的订阅——决策仍被记录并在 审批 中可见,但 Dev Lead 不会自己讲述结果
0087UX Designer 作为第五个对话智能体加入(Criativo、PO、Arquiteto、Dev Lead,现在加上它),单独且没有领域,提前于 docs/fluxo.yml(ADR 0085)一直为该角色声明的分离触发条件("当被管理的项目有自己的界面需要设计时")——这是产品所有者有意识的决定,而不是对标准的误读。UxDesignerServer 镜像 DevLeadServer(上限 14,有界循环);kickoff 读取 Criativo 的 artifact.product_brief,设计系统(design/tokens.css/COMPONENTS.md)在身份中被描述——对话智能体没有读取仓库的工具。唯一的工具 propose_prototype 写入 artifact.prototipo_navegavel,在 api 中没有专门的用例(不同于 project_image/c4_diagram,它们需要用例是因为有推导内容或共享的领域拒绝——这里两者都不适用):形状校验在 engine 中(ArtifactSchemas),通过通用的 append_event_returning 写入。只有一个工件,基于它的两个 handoffs(发给 "po" 和 "dev-lead")——永远不为 "spec-visual" 建第二个工件,它就是同一个原型。复用 ADR 0086 设计中在没有其挂起机制时仍然成立的那一半:成功即结束回合,以免重复提议
0088Staff(docs/fluxo.yml,camada_decisao_tecnica,自 0085 起为 status: planned)在其自动触发条件存在之前就获得了代码——这是产品所有者有意识的决定,他知道 Anamnese(本会因"反复出现的系统性问题"触发 handoff)处于暂停状态(ANAMNESE_ENABLED=false)。第六个单独的对话智能体,镜像 Arquiteto(每个会话一个 GenServer,有界循环上限 14),有两点不同:没有 kickoff/1(没有可总结的会话工件——它在第一条 user_message 之前保持空闲),以及通过 canActivateAgent 的通用路径激活(发给 "staff" 的 accepted handoff),不进入 USER_STARTED_AGENTS——调查确认 api 中不需要任何领域改动,因为 assertHandoffTargetAllowed 只拒绝领域的子智能体,而 Staff 没有领域。唯一的工具 propose_rfc(问题、带权衡的选项、建议、一次性 PoC)通过 append_event_returning 直接写入 artifact.rfc_staff——与 emit_insight 相同的无表模式,而不是 artifact.c4_diagram(0068)的模式,因为 RFC 不需要从 api 一侧推导任何东西——并在同一个工具调用中把 handoff 交还给 Arquiteto,不经过 proposed_action(记录一份文档不是外部效果)。已声明而非隐藏:SessionPage.tsx/AGENTES_DE_CHAT 没有被触碰——与已接受的 infra 模式相同,它是一个真正的 lead,同样不在这个列表里
0089analytics 和 delivery-metricas(docs/fluxo.yml,status: proposto)作为报告脚本变为 status: active——产品所有者决定提前启用这两个角色,而不等待流程本身已声明的自然触发条件。apps/api/scripts/analise-funil.ts 是唯一的新工件,与 medir-execucao.ts(Fase 13b)格式相同:通过 Drizzle 纯读取,零写入,没有 GenServer,没有 LLM 智能体——这正是 docs/fluxo.yml 已经规定的形式(对 delivery-metricas 来说是"永远不变成智能体")。它测量真实的漏斗(会话 → commit → PR → merge,按阶段统计会话,只计 executed 的动作)、真实的 lead time(第一次 commit 到第一次 merge,按执行的 updated_at,而不是提议的)以及真实的部署频率(合并到 PROTECTED_BRANCHES 分支,通过引用与 backmerge gate 交叉)。三项指标被有意声明为缺失:完整的产品漏斗 构思 → commit(sessions 没有 storyId,RN-230)、按功能的采用证据(Brabo 不对自己构建的东西做埋点——今天不只是没有数据,而是没有途径)以及 MTTR/change failure rate(依赖真实的事故信号,与 secops-runtime/platform 的依赖相同)
0092platform(docs/fluxo.yml,status: planned,激活与 DEPLOY_ENABLED 同步——而它不存在)以按需报告脚本的形式诞生,而不是 LLM 智能体或 GenServer:pnpm --filter api relatorio:telemetria [--projeto <uuid>] 读取 DomainGaugesCollector 每隔 METRICS_GAUGE_INTERVAL_MS 为 Prometheus 抓取所收集的同样问题——每个项目的活跃/closing 会话、每个项目被阻塞的 tasks、最后一次备份的状态(总是全局,从不按项目)——但作为一次性的、独立的读取,打印完即结束。SQL 查询是复制的,而不是从收集器导入的:它的方法是私有的,并以写入一个 Prometheus gauge 结束,没有可复用的纯"只是查询"那一半,若复用就会把一个 CLI 脚本耦合到 @Injectable 的生命周期上。输出总是链接到已有版本化的内容(仪表盘、告警、运维手册、pnpm dev:obs)而不是重复它们,并声明——从不编造——它不测量的东西:正式的数值 SLO(一个都没定义)、事后复盘(没有真实事故)以及闭环回馈到产品的遥测(这正是会让 platform 变成 active 的东西,而其触发条件仍然缺失)。该角色的 status: planned 和 gate_saida: operavel 不变;docs/fluxo.yml 只在 telemetria-consolidada 输出上增加一条 nota,说明手动版本已经是真实的
0093dbre 角色(docs/fluxo.yml,ADR 0085)变成两个机械脚本,由产品所有者决定提前启用,而不等待触发条件("真实的数据量")——既不是 LLM 智能体(没有需要做的自然语言判断),也不是 GenServer(没有循环也没有长期状态),因为两项交付物都不需要这些:lint-migracao.ts 是对 SQL 文本的静态分析(没有 DEFAULT 的 DROP TABLE/TRUNCATE/DROP COLUMN/ALTER COLUMN ... TYPE/ADD COLUMN ... NOT NULL,纯逻辑与 I/O 适配器分离,与 pr-police.ts 设计相同),而 relatorio-backup.ts 只读取 backup_runs,逻辑与 DomainGaugesCollector.collectBackup() 相同,格式化为按需报告,引用已经测试过的恢复(docs/runbook.md#restore)而不是重新执行它。linter 扫描整个仓库(而不是一个 PR 的 diff),因此没有进入 CI——今天运行它会在已合并并接受的 migrations(0006、0007、0034)中发现三处,而一个让整个仓库不通过的 gate 会永远让每个 PR 不通过;要真正成为 gate,需要像 pr-police.ts 已经做的那样把范围限定在 diff 上,留待 dbre 需要阻止而不只是发表意见的时候。plano-de-capacidade/tuning 仍然是缺口,已声明:它们依赖真实的数据量,在没有负载的情况下模拟就是编造数字(与 0042/0077 同一把尺子)
0094Dev Lead → dev-<modulo> 的委派在 delegations 中变成数据(area: 'dev'),关闭了 fluxo.yml × 代码审计的发现 B1,并撤销了 0053 第 5 项中声明的裁剪。在 API 一侧、AcceptParallelizationUseCase.execute 内部写入(免费覆盖两条路径——直接的和经批准的——因为两者已经在那里汇合),不同于 QA/Infra 在 engine 一侧写入,因为子智能体是在那里产出意见的。status: 'completed' 对这个领域被重新定义:它表示"委派已生效"(智能体已启动),而不是"意见已发出"——parecerArtifactId 指向项目最近一个 artifact.module_map 事件的 id(通过 SessionEventRepository.listByTypeForProject,已有方法,原样复用不做泛化),即证明委派决策合理的那个工件。项目中没有 module_map:不写入假的 id,只记录意外的状态(RN-059)并继续——RecordDelegationUseCase 中的失败从不拖垮激活,激活在尝试写入时就已经成功
0109发给任选智能体的手动 handoff,关闭了 ADR 0088 精确点名的 backlog 项("通用 UI 留在 backlog")。没有第三种 handoff 状态:CreateHandoffUseCase(toAgent 唯一的写入者,ADR 0038)增加一个可选的 actor?: Actor,使由人类发起的 handoff.offered 记录 {kind:'user'} 而不是默认的 {kind:'agent'}——现有的接受/激活路径无需改动即可处理它。addressableAgents()(leads ∪ SOLO_CONVERSATIONAL_AGENTS)比 assertHandoffTargetAllowed 更严格:在下拉框中做选择的人得到的是一个封闭目录,而不只是"不是子智能体"。编码之前的调查发现 ux-designer(ADR 0087)与 Staff 处于完全相同的境地,只能通过内部路由到达——两者一起进入了 AGENTES_DE_CHAT,对两者都没有改动 engine
0182handoff 的提议获得生命周期:状态 superseded(migration 0064),伴随不可变事件 handoff.superseded,并且每个(项目,目标)最多一个 offered——同一会话且没有新工件时返回现有提议,新工件或另一个会话会取代它(AT-291/292)。并发通过按目标、以事务为范围的 advisory lock 控制,而不是部分唯一索引,使表中已存在的重复项伴随事件收敛,而不是被 migration 改写。通过任何途径激活一个智能体都会取代发给它的提议;发给项目中已激活智能体的提议是 409 agente_ja_ativo,其消息就是模型读到的内容;"架构就绪"和 AppSec(seAusente,RN-636)不再重复提议
0185一次点击——"Estou pronto — a necessidade está validada"——同时关闭就绪、necessidade-validada gate 和 PO 的接受(AT-311/312),按所有者的决定修订了 ADR 0095 的单独点击。同一个 POST 写入 readiness.confirmed(带标记),并在 engine 接受回合时写入 necessity.validated,其 productBriefId: null(brief 来自点击所启动的回合);该 gate 仍是 warn。携带在这次点击之后产生的 brief 的 Criativo→PO 提议,在创建它的内部路由中由同一个 AcceptHandoffUseCase 接受:同样的事件,该人员为执行者,并在 handoff.accepted/agent.activated 上带 implicito 标记。失败的隐式接受会变成持久的 agent.error;engine 不变
0186当 backlog 已被覆盖(至少一条业务规则,且没有一条缺少引用它的故事——与 Backlog 标签页相同的 computeCoverage)、接受所触及的仓库是 local 且项目中没有 git 凭据、并且打开会话的人仍然具有 developer 时(AT-314),PO 发给 Arquiteto 的 handoff 由系统接受,无需点击。它依托 ADR 0165 而不编辑它:创建仍在 AcceptHandoffUseCase 中、activateAgent 之前进行;新的用例只读取和决定,并以系统执行者 handoff-auto-accept 和 payload 中的标准写入 handoff.accepted。决定之后的失败会变成 handoff.auto_accept_failed;远程仓库或 git 凭据仍然需要点击
0194批准 Dev Lead 的 propose_execution_plan 才是激活执行的动作,而接受发给 Dev Lead 的 handoff 只是让它来规划(AT-263,所有者 10月1日的决定,修订了 ADR 0069 的合并,RN-161)。计划在 api 中获得一个执行器,它根据当前的 module_map 重新读取计划,写入每个任务的模块,并调用按钮所用的同一个 ActivateExecutionUseCase,因此无仓库的 409、咨询会话的 409 和 findActiveExecutionSession 保持原样。任务新增 module 列(migration 0068),由 Dev Lead 在计划中分配(tarefas: [{ taskId, modulo }],AT-274);没有模块或模块不在 module_map 中的任务会在提议时被以具名的 400 拒绝。只有该模块的 dev 能领取它;没有模块的任务只有在其故事恰好只有一个模块时才可领取
0212一个故事可以被归档,这是一个列——stories.archived_at/archived_reason(migration 0069)——而不是 story_status 的新值,因为状态是 claim 的闸门,而归档不是进度中的一步(AT-406,所有者 10月3日的决定"PO e usuário")。每个做出决定的读取(backlog、覆盖率、晋升队列、Dev Lead 的计划、claim)都过滤 archived_at IS NULL,任务随故事一起退出;什么都不删除,新的不可变事件 backlog.story_archived/backlog.story_updated 记录这一点。PO 的工具和 Backlog 标签页用同一把尺子:只有 draft 且没有执行中的任务,其余情况为具名的 409
0213交付物通过 Infra 的 compose 在用户的机器上运行,从不通过 broker 发布的端口(AT-517,所有者 10月11日的决定;ADR 0130 未改动)。compose 以 FROM = 已决定的镜像,从模块的 Dockerfile 构建应用,使用主机端口 ${APP_PORT:-8080},并提交一个 .env.example;propose_infra_pr 增加一个必填的 run payload(command、1–5 个 localhost urls、envExample),api 把它转换为版本化的 artifact.run_instructions。devs 提供 /openapi.json 和 /docs(在 none 网络下来自 CDN 的 Swagger),QA 在容器内部证明它们,概览的卡片写着"declarado pela Infra, não verificado na sua máquina"——真实的 docker compose up 留作已声明的缺口

Gates、QA 与安全审查​

gates 的注册表、QA 和 SecOps 的裁决,以及衡量它们的 golden-sets。

#决策
0013QA 和 SecOps 的 gates,带有顺序不可变的状态机
0020解锁 gates:在工作树上运行 gitleaks(不只是在 diff 上),意见作为工件校验,从文本中恢复工具调用
00214a 的收尾:真正做校验的 infra gate,以及说真话的面板
0054gates 作为 docs/gates.yml 中的声明式注册表。流程中的 gates 早已存在并经过真实执行验证,但它们是隐式的——散落在智能体的 prompt、纯规则、用例、测试和 CI 之间——而 Fase 10/13 的教训(指标由脚本提取,从不手工标注)无法应用到不可枚举的东西上。这份 ADR 是 0048 的上半部分:那份让决策在事件日志中可观察,这份让集合可枚举。每个 gate 都声明 `evidencia: event_log
0057gate 智能体等待批准,就像 dev 等待一样。ADR 0052 为 dev agent 做了这件事;gate 智能体被遗漏了,没人发现,因为没有任何执行能走到它们——FASE 13b 走到了。在第 6 次真实执行中,qa-automacao 运行了 `ls -la && find …
0067gate 在重启后存活。0057 已经把"等待中途重启会丢失循环"声明为已知限制,但重新调查发现了第二个窗口,不同于 {:awaiting, ...} 中的挂起:DevAgentServer.correct/3 和 Dispatcher.run_secops/2 是内存中的直接调用,发生在 record_gate_verdict 已经在 api 中持久地推进了 gate_status 之后——两者之间的崩溃会让 PR 永远卡住,而这是最容易发生的窗口,不是最少见的。gate_states(engine schema,键 {project_id, task_id, gate})记录两个 step:in_progress(尚未记录任何裁决——不对 ctx 做精确恢复,它在重启后不存活,但从零重启该领域是安全的,因为 api 会拒绝对不再拥有 gate_status 的 gate 执行 record_gate_verdict)和 dispatch_pending(裁决已记录,只差进程内调用)。Engine.Gates.GateRescuer 扫描停滞超过 15 分钟的行(有意放宽——QA 的 ToolLoop 合法地最多运行 60 次迭代,过早救援会复制一个只是慢的周期),在启动时和由自我重新调度的 Oban tick 调用,复用 engine 其余部分已在使用的同一队列机制。两道防止重复工作的守卫:任何救援之前的本地 Registry.lookup,以及 staleness 阈值本身作为第一道未覆盖情况(远程副本、Registry 只在本节点,与 ADR 0045 起 Engine.Dev.Wake 的同一保留)的第二道防线——残余竞争的最坏结果是重复且廉价的工作,绝不是不一致的数据
0090同一概念设计的两个"第二时刻",每个都由产品所有者决定,以提前启用 docs/fluxo.yml 预见的触发条件。implementavel gate 从 planned 变为 active:QA-策略不再是 proposto 角色,而成为同一个 qa-lead 的第二个时刻,同一进程(Engine.Gates.QaLeadServer.run_design/3,叠加在一贯的 run/2 之上),交付物分开——Engine.Gates.QaEstrategiaAgent(无状态,没有 Terminal,轻量上下文,复用 list_backlog/get_infra_context 而没有新路由)在 dev 之前产出 artifact.plano_de_teste。Dev Lead 获得 assess_implementability:还没有计划时,它触发评估并返回要求重试的错误(RN-163);有计划时,它把意见作为 proposed_action 提出,与 propose_execution_plan(0086)同一模式。appsec(RN-360/361)在 secops 之上遵循同一模式:Engine.Gates.SecOpsAgentServer.run_design/2,与 PR 的 secops 在同一进程中,产出 artifact.threat_model(针对 story 设计的 STRIDE-lite 检查表),并创建发给 arquiteto、dev-lead 和 infra 的 handoff。两个智能体从不挂起——没有工具经过 ActionPipeline——迭代上限有意保持在 8(对话级),与 infra-workflows 理由相同:底下没有 token_budget_micros,提高上限会放大最坏情况而没有东西加以约束。两者都可触发,但自动触发(assess_implementability 调用 appsec 的 run_design)尚不存在——不在本次交付范围内(RN-340/341/360/361)
0091secops-runtime(docs/fluxo.yml,安全层的 proposto 角色)作为报告脚本提前启用,而不是 LLM 智能体或 GenServer——产品所有者有意识的决定,即使声明的触发条件("有真实流量的生产环境,在 DEPLOY_ENABLED + platform 激活之后")尚未触发。pnpm --filter api relatorio:seguranca-runtime 读取 rate_limit_hits(ADR 0027 的 RateLimitGuard 已经在写的数据,包括在 dev/CI 流量下),并产出桶的排名(user:<uuid>/ip:<endereço>,仅有的两种写入格式——没有路由也没有原因,表里从未保存过)以及按固定切片的时间分布。自动事故检测、事故响应和事后复盘仍然在范围外——它们依赖同一个未触发的条件——报告在一个永久的"未测量"章节中列出它们,不模拟示例事故,也不编造数字(与 ADR 0041/0042/0077 同一原则)。保留窗口很短(DomainGaugesCollector.pruneRateLimit 删除超过 2 × RATE_LIMIT_WINDOW_MS、默认 240s 的 hits),报告声明两个窗口——配置的和观察到的——从不让第二个冒充比实际更长的历史
0095necessidade-validada gate(docs/fluxo.yml,criativo 角色,自 ADR 0085 起为 proposto)关闭了 fluxo.yml × 代码审计的最后一波(发现 B2),采用单独的人类确认——绝不是 Criativo 自我验证,那是 modelo-de-time.md 中已记录的反模式。SessionPage.tsx 中的专用按钮(与"Confirmar arquitetura pronta"同一模式,RN-160),路由 POST .../agents/criativo/validate-necessity,ValidateNecessityUseCase 写入 necessity.validated——只有在 confirm_readiness 已经汇总了 artifact.product_brief 之后才启用(无法验证尚不存在的东西),并且不通知 engine:Criativo→PO 的 handoff 已在 confirm_readiness 内部发生,所以这个事件只是对价值的记录,而不是激活的触发。docs/gates.yml 增加了这个 active/warn gate(目前没有任何东西在让 PO 继续之前查询它是否通过)
0123关闭了 ADR 0020 自身留下的开放发现——QA gate 的语义步骤(把业务规则与测试匹配)在本地模型下是非确定性的,在 11 次执行中的第 10 次才关闭——用一个包含六个回归用例的 golden-set,有意地(mix golden_set.qa)针对真实的本地 LLM(Ollama)运行,从不在 CI 中(已确认的阻碍:workflows 中没有 ollama,没有 LLM 秘密,而且 CI 本身的文化拒绝静默的 allow-failure)。Engine.Gates.QaAutomacaoAgent.run/5 被直接调用,就像在 qa_automacao_agent_test.exs 中一样,但针对的是真实的 EngineApiClient.Live——它需要 api 和 engine 真正在运行,还需要一个新的跨语言 System.cmd(Elixir 调用 pnpm/ts-node)来写入真实的项目/会话/模型 binding 行,engine 测试隔离的 engine_test 数据库触及不到它们。apps/api/scripts/seed-golden-set-qa.ts 自己做 git checkout(worktree 必须位于 projectScopeRoot 之内,decide() 的路径范围上限才有朝一日能自动批准 npm test——经验上发现的)并写入 agent_areas(没有它,第一个 LLM 回合的强制计量会返回 404——同样是经验上发现的)。ExUnit 标签 :golden_set_qa 被永久排除,从不依据二进制/服务的检测,因为这台机器一直在运行 Ollama,自动包含会让这套测试在任何 mix test 中触发。一个 floor.json 棘轮(镜像 coverage-floor.ts,>= 从不 >,只由人类编辑)取代了二元的通过/不通过:测得的真实命中率为 qwen2.5-coder:latest 在四次执行中 1–4/6(主要失败:terminal 命令参数为空——ADR 0020 在这一模型家族中已经发现的工具调用不可靠),对比 gpt-oss:20b 在两次执行中 4–5/6(每个用例慢 4 倍)——对任何依赖这一判断可靠的流程,推荐默认使用 gpt-oss:20b。与 CI 的连接作为已声明的 TODO(humano) 保留
0168QA Automação 的 golden-set(ADR 0123)开始在 CI 中运行,位于 .github/workflows/golden-set-qa.yml,仿照 ADR 0138——定时且每周(周六 01:17 UTC,外加 workflow_dispatch),从不在 pull_request 上。两次测量使之成为可能:AT-067 显示时间在没有 GPU 的 ubuntu-latest 上放得下(驳倒了 ADR 0123 那句话中"新基础设施"的一半),但在 RN-502 之下按构造得分 0/6;AT-076 让 seed 通过生产路径经 broker 启动每个用例的容器,两次得分 3/6,对比 1/6 的下限,耗时 52 和 86 分钟。是每周而不是每晚,因为一次执行要 52–86 分钟,而得分在不同执行之间会变化(总数相同,用例不同)——每周一次执行可以读出棘轮下限所守护的趋势;timeout-minutes: 150 覆盖测得的 86 分钟加上模型的波动。裁决是测试自身的下限;workflow 只增加两道工具层面的守卫(得分行必须存在,事件日志中不能出现 RN-502 的任何拒绝),并在失败时完全像 RAG 的 job 那样打印日志——不开 issue。api、engine 和 broker 在 runner 上原生运行,只有 broker 与 Docker 对话;要预先拉取的镜像从 seed 自身的常量读取,使 job 永远不会在 broker 在 30 s 控制上限内请求另一个镜像时去拉取一个镜像(RN-604)。已声明:CI 的 broker 没有 compose 的五层隔离(剩下的是它所接受的东西),而编辑该 workflow 的 PR 不会运行它
0192测试计划在 dev agent 交付之后、根据交付的代码编写,而不是在设计时(AT-269,所有者 10月1日的决定,即与 ADR 0090 相矛盾的出路"(b)")。它成为 qa-verificada 周期的第一步:QaLeadServer.run_area/3 在 dev 的 worktree 中用交付所触及的文件(git diff dev...HEAD)运行 QaEstrategiaAgent.run/6,每个任务一次(修正轮按 taskId 复用计划),计划作为输入到达 Automação——裁决规则和裁决的契约不变,失败的计划从不阻挡审查。implementavel gate 保留(同一所有者,warn,人类批准),由 Dev Lead 根据已在其上下文中的故事和 module_map 评判:裁决在第一次调用时就给出,没有计划字段。run_design/3、run_qa_estrategia/3 和 QaEstrategiaContext 被移除;artifact.plano_de_teste 要求 taskId;迭代上限保持为 8,现在底下有任务的预算;appsec 仍在设计时进行(RN-674)
0207自最后一次活动以来停滞超过 2 小时的 gate 周期会以 gate.rescue_parked 事件停放,只有人类的动作才能恢复它;救援器向 lead 询问的是任务而不是项目,并且不去打扰有待处理人类决策的周期(AT-391,修订 0067)。
0214向用户提出的开放问题变成一个在日志中有状态的待办(artifact.open_question + open_question.answered + 一个 decision_record),只能通过 declare_open_question 工具声明,只能由会话的作者回答;QA 获得 needs_user_decision 裁决,它不消耗修正周期,并通过 ADR 0207 的机制停放 gate 直到得到回答;决策到达 dev-context 和 QA 的截取,PO 可以根据已回答的问题修正 ready 的故事(AT-521,扩展 0207,修订 0212 的一部分)。
0215gate 裁决中的 openQuestionId 必须在项目中存在且处于开放状态(未知为 404,已回答为带决策的 409),afeta.regras 接受规则的确切标题并保存其 id,dev-context 还在同样 10 条的上限内以 decisoesDoProjeto 返回项目的其他决策(AT-526,修订 0214 的一部分)。

验收标准仍未关闭。 0020 明确记录 gates 的演示是 "FECHADA, mas NÃO DETERMINÍSTICA"(已关闭,但非确定性)——它依赖一个 7B 本地模型的判断,不能 用作回归测试。0021 有 一节"Estado do critério de aceite: NÃO FECHADO"(验收标准状态:未关闭)。两者都是有意 诚实的;不要把它们读作已完成。

执行、终端策略与审批​

工作中的 dev agent、proposed_action、终端策略、路径范围、自动模式和绝对上限。

#决策
0011每个 dev agent 一个 worktree、git 执行器以及合并锁:受保护分支永远不可自动批准
0012真正的 DevAgent:worktree 中的 ToolLoop、按 task 的预算、要求测试套件全绿的 ReportDone、带诊断的阻塞
0017workspace 的初始化锁、dev agents 的 Monitor,以及子智能体对修正次数上限的继承
0018NoopDevAgent 不再是脚手架,而成为永久的执行模式
0019解锁真正的 DevAgent:写入、终端、按模块的 ADR 和按项目的预算
0045由事件驱动的 dev agent 重新调度:使用 outbox(而不是进程内调用),因为它能在 gate 裁决与 wake 之间的重启后存活;awaiting_gate 按智能体(而不是按 task)保留 worktree,直到 gate 结束;通过 PubSub(而不是只在本节点的 Registry)投递,有意识地接受 at-most-once 的限制;circuit breaker(RN-047)放在 engine 中,因为 backlog.task_blocked 的 actor.id 无法识别一个智能体的序列
0047Fase 12 的收尾:验证是一个以非 0 退出的脚本,而不是一份散文式的脚本说明,证据从数据库中提取而不是转录;它在本地运行且不使用 LLM,出于两个经过核实的原因(fork 从未被命名;自 ADR 0020 起本地模型下 gates 的判断就不是确定性的),这一点在第一段而不是脚注中声明;Noop 进入了 12b 的状态机——发现 #10 曾存活在测量工具本身之内——把它移到 AgentIo 中而不是复制它;Fase 10 的收集记录把空缺写成了 não medido
0048重新审视两项发现:一个动作的决策(created/approved/denied)变成带真实执行者的领域事件——outbox 是传输,不是记忆,而 events.md 一直记载着这三个事件,它们却从不存在;created 的 payload 中的 status 用来区分人类点击与按策略的自动批准,这是 Fase 10 未能收集到的指标。还有 D5:原因不是 worktree 被回收,而是 propose/3 丢弃了状态,gate 在没有 PR 的情况下开启——QA 扫描 worktree、批准,task 在没有一行提交的情况下关闭。顺序修正之后,D5 随之消亡,按 task 的 worktree(ADR 0045 所预见)也不再必要
0052dev agent 在循环中途等待批准,而不是消耗迭代次数。待处理的 :pipeline 工具把字符串 proposed_action <id> status pending 作为结果返回,ToolLoop 继续:智能体对命令一无所知,尝试别的东西,最后死于 toolloop.limit_reached {iteration: 8, max_iterations: 8},一行也没写——批准在循环耗尽之后才到达,毫无用处。终端白名单解决不了,因为它是预期命令的列表,而模型会发明命令:放开读取类命令后,同一个智能体卡在了 git branch -a。:awaiting_approval 早已存在(Fase 12e,用于 task 结束时的 pr_open),所有管道都已就绪——outbox 事件、worker、Wake.deliver、worktree 保留——所以缺的是把它应用到循环中途。不以阻塞的方式等待:ToolLoop 在 GenServer 的回调中运行,在那里阻塞会锁住本应带来决策本身的消息邮箱
0055终端策略中的路径范围。terminal 的审批阶梯经测量不可行:15 个回合,209,031 个输入 token 对 4,546 个输出 token,14 个动作,为 task 写下的代码为零行,因为几乎每个回合都以等待点击结束。两个独立的缺陷解释了"总是在问":智能体发出 cd <caminho> && <verbo>,而 cd 不在任何 allow 中,复合命令要求所有片段都匹配(decide.ts:201),所以种下的 allow 几乎从未被触及;而"Sempre permitir"记录的是整个字面命令,它永远不会再匹配,因为匹配是按 token 前缀——逃生口逃不出去。用户要的"只要在项目文件夹里就总是允许"今天无法表达:只有 write_file 有路径的概念,terminal 没有。执行暴露了加重因素:执行器内的 /workspace 是 Brabo 自身的 monorepo,hello-limpo 的 dev agent 甚至提议读取 git_executor.ex 和 context_builder.ex——不存在项目边界,可及范围大于项目。范围按 REALPATH 而不是字符串前缀判断(一个 .. 就能逃出),它允许但不豁免(网络和安装仍然要询问;在文件夹里并不让管道到 shell 的 curl 变得安全),deny 仍然最先生效,范围之外是 require_approval 而不是 deny。这是策略,不是隔离:只要 monorepo 挂载在执行的容器中,边界就依赖于策略判断正确
0102对 ADR 0065 的修订,产品所有者的全局决定(在自动安全警告之后明确确认):git push/PR/deploy(RN-106)和 sudo/doas(新增)不再是无条件的 deny,而成为绝对上限——无条件的 require_approval,与其他上限相同的代码模式,永远不能通过 auto mode 或 permissions.json 自动批准。"sempre permitir"的缝隙在源头被堵上(ApproveAlwaysActionUseCase 拒绝为这些命令记录模式)——否则上限只是装饰
0167自动模式解除路径范围上限,也只有它能。在所有者的安装上测得(AT-226,项目 exp001,mounted 且容器在运行):开启通配 "*": auto_approve 后,仍有 51 条终端命令请求批准,其中 47 条只是因为提到了 /work 或 /tmp——dev agent 在容器内运行,而 ADR 0055 的范围是与主机根目录比较。产品所有者决定:处于自动模式的智能体无需批准即可运行任何终端命令,即使在项目文件夹之外。decide() 开始从仓库接收自主权的来源(具体规则或通配),仓库仍然负责具体规则优先于通配的规则;只有解析为 auto_approve 的通配才是自动模式。在自动模式下,路径范围上限以及复合命令为未覆盖片段合成的 require_approval 不再生效;写在 permissions.json 中的 ask、所有 deny、外部效果/特权上限(RN-418)、受保护分支上的 merge、instruction_patch、并行和 container_remove 完全保持原样。审批卡片和智能体卡片在点击之前就说明自动模式解除了什么、仍然会询问什么。拒绝的方案:在范围检查之前转换 /work,以及为任何具体的 auto_approve 解除范围
0189自动模式变成自动驾驶,路径范围与命令运行所在的文件夹比较(AT-259/255/258,所有者 10月1日的决定)。ADR 0167 不被撤销:通配 *: auto_approve 批准一切——合成的复合命令、文件夹外的路径、git commit 和本地分支的创建——除了绝对上限(push/PR/merge/deploy、sudo/doas、受保护分支上的 merge、container_remove、instruction_patch、并行);deny 仍然优先。在通配之下由"Sempre permitir"记录的具体 auto_approve 按通配解析,因此不再关闭自动驾驶。有已登记的 running 容器时(container/mounted),ADR 0055 的范围根变为 /work 加上容器的 /tmp,主机的 cwd 按 engine 的方式转换;没有时,保留主机文件夹,/tmp 不在内。界面在开启它的三个地方显示自动驾驶解除和不解除的内容清单。"Sempre permitir"记录动词 + 子命令,每个命令片段一个模式(AT-257,关闭 AT-170 和 ADR 0055 第 6 点):动词 + flag 保持精确,会成为 RN-418 上限前缀的单元(git remote、gh pr)也保持精确。支撑该决策的隔离在 broker 中被端到端证明,它不覆盖的部分已声明
0204自动模式放开 git_push 和 pr_open(有类型的,以及通过终端命令进行的同样的 push/PR),按所有者 10月2日的决定(AT-385,RN-713),只针对 push/PR 修订 ADR 0102 和 RN-689。只有通配 *: auto_approve 放开它们——具体规则、"Sempre permitir"和 permissions.json 仍然不放开,激活仍然只种下 git_commit。受保护分支上的 merge、deploy、sudo/doas、container_remove、instruction_patch 和并行仍然是绝对上限;把开关切回手动会恢复审批

代码住在哪里——执行模式​

三种执行模式、用户的文件夹、项目的基目录,以及项目在模式之间的转换。

#决策
0066workspace 的可读文件夹名。修订 0055:projectScopeRoot 改为接收 workspace_dir_name,而不再是原始的 projectId,保留 api 和 engine 对项目根目录达成一致的保证。用户的请求:可读的物理文件夹(<slug>-<8 chars do id>),带唯一 id——PROJECT_WORKSPACES_ROOT 是整个实例的一个根,仅凭 slug 并非全局唯一。在提出的两个选项(风险较小的 symlink 与真正重命名文件夹)之间,用户明确选择了第二个。id 在代码中产生(CreateProjectUseCase 中的 crypto.randomUUID(),不再是 Postgres 的 defaultRandom()),因为文件夹名需要在 INSERT 之前就有它。在此 migration 之前创建的项目保留原有的物理文件夹——backfill 写入 workspace_dir_name = id,从不重命名任何目录,并且名称在创建时冻结:之后编辑 slug 不会重新计算文件夹。一个 BEFORE INSERT 触发器(产品 migration 历史上的第一个)对缺少该字段的插入应用同样的 id::text 回退——是安全网,不是主路径,它避免了重写五十多个不了解这一概念就插入 projects 的测试 spec。engine 读取同一列(Engine.Projects.Project.workspace_dir_name/1)来解析 Workspace.workspace_dir/1,从不重新计算名称——两个推导实际上是针对同一数据库的同一次读取。不在工具循环的热路径上(它已经在创建智能体的 worktree 时一次性接收解析好的 ctx[:workspace_root]);WorktreeCleanup 从扫描磁盘改为在一次查询中查询 Project.all_workspace_dirs/0,因为文件夹不再是 project_id,不查询就无法从一个推回另一个
0072项目选择代码住在哪里:用户的文件夹(Local)或被管理的文件夹(Container)。修订 0065 的一部分,并踏入 0055 的领地——两者都曾决定了相反的方向,朝向容器之墙,这正是为什么后果一节是这份 ADR 最重要的部分。用户的请求,明确选择的变体:自由输入路径,并知晓只有挂载在容器中才能工作的警告。projects 获得一对(workspace_mode, workspace_path),由 CHECK 绑定——没有路径的 local 会是指向无处的终端范围,有路径的 container 会是第二个事实来源——这道锁位于数据库中,因为该列被两个进程读取,并被不经过用例的脚本写入。container 是默认值:已有项目不会搬家。projectScopeRoot 改为接收 LOCAL 而不是文件夹名,并仍然是唯一的推导——没有调用方获得自己的校验,与该函数存在的理由相同。创建时的守卫(RN-170)以 400 拒绝并给出指导:绝对路径、没有 ..、存在、是文件夹、且进程可写(`W_OK
0104ADR 0072 和 0103 从未对话过:RN-170 在创建时要求 bind-mount,RN-420 在同一个 workspace_mode == 'local' 标志下路由到 runner,却没有任何 bind-mount——真正使用 runner 仍然必须通过挂载文件夹的校验。workspace_mode(2 个值)变成 execution_mode(3 个:container、mounted——原来的 local,RN-170 现在以它为条件——以及没有 bind-mount 的 runner)。runner 项目以声明的路径和 workspace: unverified 状态诞生,只有当一个 runner 连接并在主机上确认时才晋升为 verified——与 docs/gates.yml 已经使用的、以事件为证据的 gate 精神相同。三个值之间的任何转换都被允许,无需重建项目。产品所有者排好优先级的 backlog,按此顺序记录:通过 npm publish 分发 runner(@brabo/runner,tsup),用长期账户 token(PAT)取代 apps/runner/src/auth.ts 中的登录重放——在分发之前——,按 {project_id, machine_id} 的独占性推迟到真正出现第二个同时工作的 dev 时,并重申 guard.ts 是 best-effort——真正的安全边界是认证 + 审批 + 同意,从来不是沙箱
0107本地文件夹浏览,修订(而不编辑)ADR 0072:0072 曾因要求 api 枚举容器文件系统而拒绝的文件夹选择器,通过 RUNNER(ADR 0103)得以存在,它已经拥有正确的视角——用户的真实机器。同一个 terminal:<projectId> 通道上的两个新事件(fs_list_dir/fs_home_dir),与 PTY 相同的纯中继设计,通过 ref 关联。api 仍然不枚举任何文件系统——没有新路由。安全边界仍是 0103 的(认证 + 无效果的读取 + 同意),而不是路径白名单——guard.ts(限制已批准命令的 cwd)有意不适用于此,因为目标是自由浏览。声明的缺口:在项目创建界面上,ticket 是按项目的,而项目只在确认时诞生,所以那里的"Procurar pasta..."显示声明的状态,而不是假装一个不可能的连接——在项目已存在的地方它端到端可用
0108关闭 ADR 0107 自己声明的缺口:在 runner 模式下,NewProjectWizard.tsx 在点击"Navegar pasta..."时就创建项目,而不只在确认时——使用已输入的路径,或者在字段仍为空时使用一个词法上有效的临时占位符(/workspace-a-confirmar,在真正的 runner 连接时被覆盖,RN-423)。通过一个身份快照复用(name/externalId/adotando——从不包括路径,细化路径正是浏览的原因):身份不变则复用同一项目,身份改变则创建另一个。mounted 模式不变——路径校验在创建时触及磁盘,所以仍然等待确认。接受的副作用:如果向导在完成前被关闭,会留下一个"未创建"的孤儿项目,与今天任何中断的创建已经产生的状态相同
0111转换已有项目的 execution_mode,纠正 ADR 0104 第 4 项中"无需重建项目"这已经可行的说法——并不可行;UpdateProjectDto 一直有意排除这两个字段。一条专用路由(PUT :projectId/execution-mode,role:maintainer)编排了对该列的原始写入做不到的事:只要项目中有任何 dev agent 在任何会话中不是空闲的就拒绝(409)(engine.dev_agent_states,在这里 idle_tripped 算作活跃,与 RN-409 的在线计数相反,因为智能体仍持有一个已捕获的 workspace_root)——从不排空或迁移一个活着的智能体;通过新的 PermissionsFileStore.move() 把 permissions.json 重新放到新的范围根目录(内容从不改变,只有物理路径);离开 container 时,在列改变之前把 project_containers 的生命周期行退役为 removed(如果是 running 则先经过 stopped),进入它时从不自行创建;每次真正的转换都清空 workspaceVerifiedAt。已声明而非隐藏:转换从不在范围根目录之间复制代码——engine 按需从项目的 bare 仓库重新推导 worktree(Engine.Actions.Workspace.ensure!/4),所以已提交的工作是安全的,但被遗弃的旧 worktree 中未提交的 diff 会就地成为孤儿,既不迁移也不删除
0141创建一个 mounted 项目曾需要在 docker-compose.yml 中为 api 和 engine 手工编辑一行 bind-mount 并重启它们——这会杀死整个安装中所有进行中的智能体回合、终端 socket 和 LLM 调用,只为接纳一个项目。在 mounted 还是逃生通道时可以容忍;如今它成为带建议路径的一等选择,就不可接受了。于是:一个基目录 BRABO_PROJECTS_BASE,由运维者配置一次,以同一性($X:$X)挂载到 api 和 engine,每个挂载的项目都住在它下面,永远不按项目编辑任何东西。是同一性而不是固定挂载点,因为 projects.workspace_path 是用户输入、界面回显的字符串:如果主机 ≠ 容器,产品就得选择保存两者中的哪一个,而任何选择都会让界面对某人撒谎——而同一性恰恰让 projectScopeRoot(api)和 Engine.Actions.Workspace.workspace_dir/2(engine)在零新代码的情况下继续正确,仍由开头的斜杠来决定。一个新变量,永远不是 PROJECT_WORKSPACES_HOST_DIR,有三个理由,第一个会对数据造成后果:被管理的 workspace 由 workspace_dir_name(UNIQUE)命名,而挂载的项目由用户命名,所以 <base>/loja 和一个文件夹名为 loja 的 container 项目会落在同一个物理文件夹中,init_from_bare! 会在另一个人的项目里执行 git init——schema 中没有任何东西阻止这一点,因为唯一性是在 workspace_dir_name 的值之间,从不针对 workspace_path 的最终名称。缺失是正常状态:baseDeProjetos() 返回 null 且从不抛出,GET /workspaces/:workspaceId/projects-base(maintainer,与 POST .../projects 相同的最低要求,因为决定这条路由提供什么是该值唯一的用处)把 null 带到客户端,向导就干脆不提供 Montado 模式——永远不要提供安装无法兑现的模式。而 /workspace 的盲点终于在可以设守卫的地方得到一个:scripts/dev/preflight.mjs 在基目录与 Brabo 的 checkout 在任一方向上重叠时拒绝启动整套服务,因为它在主机上运行——api 比较的是 process.cwd(),即它自己容器内的 /workspace,所以把 Brabo 克隆到 $HOME/brabo 并把基目录指向那里的人能通过现有的所有校验,并让 dev agents 在产品自己的目录树中执行,这是 ADR 0055 的失败从一扇它没看守的门进来。声明的两项代价:基目录下的 symlink 在两侧解析结果不同(文件夹浏览器拒绝进入 symlink 并统计它们;物化时的磁盘检查才是捕获分歧的地方,作为具名失败),基目录之外的代码需要移动文件夹——v1 支持一个基目录,拒绝消息如实说明这一点,而不是暗示有隐藏的配置
0142ADR 0141 决定了 mounted 项目住在哪里,并明确把另一半留给下一个 PR:这个文件夹何时开始存在。产品所有者的要求是字面的——"se for Pasta montada, o bind-mount deve ser criado DEPOIS da decisão do arquiteto"(如果是挂载文件夹,bind-mount 应在 Arquiteto 做出决定之后创建)——而今天的代码让它不可能:validarCaminhoDeWorkspaceLocal 在项目创建时运行,提出三个磁盘问题(存在吗?是目录吗?本进程可写吗?),任何一个失败就以 400 拒绝。创建是流程的第一个界面;Arquiteto 的决定在许多个会话之后才发生,所以在创建时要求文件夹就绪,就是要求它在任何决定存在之前就存在——这正是阻止 mounted 成为一等选择的原因,因为向导建议的路径按构造就是一个尚不存在的路径。于是 mounted 采用 runner 已有的纪律(RN-423):创建时只做词法谓词,再加上一条自己的新规则——路径必须位于 BRABO_PROJECTS_BASE 之内,这是 api 和 engine 容器唯一能看到的机器文件夹,没有配置基目录时,该模式在此安装中不可用,拒绝会如实说明。推迟的验证在 materializarWorkspaceMontado 中回归(mkdir -p 加上同样的三个磁盘问题,在 mkdir 之前拒绝基目录之外的路径,以免产品之外写下的路径让 api 在它能触及的任何地方创建文件夹),从两个地方调用:ExecuteContainerStartUseCase,常规路径,Infra 启动容器时创建文件夹并盖上 workspace_verified_at——那里的失败是具名的 failed,从不 throw,生命周期不标记为 provisioning,因为先标记再发现不能写入会让这一行断言一个从未存在的状态;以及 ConvertProjectExecutionModeUseCase,声明的例外,它没有可以挂靠这项工作的容器步骤,并紧接着把 permissions.json 移进这个文件夹。有意不改变的是 caminhoDeWorkspaceLocalValido:基目录规则不放进它,因为这个谓词通过 projectScopeRoot 在每次读取时运行,一个基目录之外的遗留 mounted 项目会仅仅因为被读取就开始崩溃。没有 migration,CHECK (execution_mode <> 'container') = (workspace_path IS NOT NULL) 保持不动——推迟验证从不触及配对不变量
0146Montado 模式已经就绪却无法到达:ADR 0141 构建了单一基目录,ADR 0142 使建议一个尚不存在的路径成为可能,但产品中没有任何地方真正请运维者配置 BRABO_PROJECTS_BASE——所以 GET /workspaces/:workspaceId/projects-base 在 web 中没有调用方,该模式干脆从未被提供。因此 pnpm bootstrap 增加一个同意步骤:它提议 $HOME/projetos-brabo(在 $HOME 下,因为这是 Docker Desktop 默认共享的——一个需要先打开 Docker 设置的默认值是一个伪装的问题),而不是 $HOME/brabo-projetos,因为 .env.example 已经把这个确切名称用作 PROJECT_WORKSPACES_HOST_DIR 的注释示例,即产品管理的根目录——这正是 ADR 0141 拒绝的混淆,因为 <base>/loja 会与一个 workspace_dir_name 也是 loja 的 container 项目冲突,bootstrap 会在另一个人的项目里执行 git init。名称只是绕开;保护是机制:脚本拒绝任何等于该根目录、包含它或被它包含的基目录,复用已经支撑 baseSobrepoeOCheckout 的 dentroDe。由 scripts/dev/consentir-base.mjs 用 Node 编写而从不用 bash,出于两个测量过的原因:bootstrap.sh 没有写变量的 helper(garantir_env 只复制示例,唯一的就地写入者是 preflight.mjs 中的 escreverEnv),以及——决定性的——菜单项无法提问,因为每个菜单命令都在后台运行、stdin 为 /dev/null,以免有东西抢走用户的方向键;写在那里的同意步骤按构造就是一个无法征得同意的步骤。Docker Desktop 的文件共享通过挂载来证明(docker run --rm -v <base>:/sonda ... ls /sonda),从不读取 settings.json:该文件没有文档,在 macOS 和 Windows 之间不同,随版本变化,描述的是用户配置了什么而不是守护进程将会做什么——与产品对 LLM providers 已经应用的规则相同,只有经执行证明的 capability 才被声明(ADR 0041/0042)。这成为产品的第二个平台守卫,与 validarDirDentroDoHomeNoLinux 并列,两者在原则上对称:每个只在限制有意义的地方施加限制。broker 只在本地 compose 中离开 profiles: ["container-broker"]——ADR 0130 选择那个 profile 是因为*"nada o chama para escrever ainda... em troca de nada"*(还没有东西调用它来写入……换来的什么也没有),而这个前提已经死了:ADR 0133/0136 给了它四个真实调用方,ADR 0144 让 mounted 通过 broker 启动容器,所以当 mounted 成为本地默认值时,broker 就是区分默认模式与 BrokerIndisponivelError 的那个东西。不对称性是支撑这一点的:运行 pnpm dev 的人已经拥有 Docker socket——他在运行 docker compose——所以 broker 获得它并没有授予运维者所缺的任何东西,而在生产中 socket 是真正的特权边界,运维者也不是开发者。五层隔离保持不动:这改变的是 broker 何时运行,从不改变它接受什么

项目容器与 broker​

项目容器的生命周期、镜像的决定、Docker 端口,以及与守护进程对话的 broker。

#决策
0065每个项目一个容器:边界不再是策略。修订 0055,它曾自己声明了这项待办:路径范围是策略,不是隔离——只要 Brabo 的 monorepo 挂载在执行命令的同一个容器中,边界就依赖于策略判断正确,而从内部指向外部的 symlink 不会被发现。发现 Z 和 AD(动词白名单不收敛——动词、形式和调用是不同的空间)强化了 FASE 13 的同一结论:通往自主的道路不经过放松策略,而经过更换边界。用户的决定,原文如下:每个项目获得自己的容器,智能体在其中是自由的(读、写、安装、构建、测试、运行),而 Code 只有在 ARQUITETO 决定启动哪个镜像之后才开放——他是知道技术栈需要哪种运行时的人。镜像作为工件诞生(事件日志中的 artifact.project_image,版本化,无表——修订就是重新发出),带有显式的 OCI tag(拒绝 latest)和必填的 rationale;闸门(RN-105)让 Code 标签页在 sem_decisao 期间回应 409,位于已经包含路径的同一个函数中(RN-092),从不在四条路由中重复。剩下的边界在外面:git push、PR 和 deploy 不通过终端出去——即使在项目范围之内——规则是 deny,而不是 require_approval,因为"sempre permitir"会把模式写入 allow,一次点击就会永远重新打开这扇门;消息把人引导到有类型的动作(git_push/git_merge/pr_open),那本来就是 dev agent 的真实路径。网络在工件中一次性决定(默认 network: none,egress 由用户授权),而不是逐条命令——为出站重复白名单就是重复 Z 和 AD 证明不收敛的错误;开销(cpus/内存/pids)有上限,超出时拒绝而不是静默降级。诚实且已声明的裁剪:容器的生命周期(创建、回收、清理、重启后留存什么)留给有 migration 名额的阶段——容器状态是可变的,需要表,而一个启动后不回收的容器比没有更糟。后果:终端策略中"内部自由"的那一半尚未改变;在墙真正立起来之前,0055 原样有效
0081容器的生命周期:状态表,没有编排器。关闭了 0065 自己声明的裁剪("O que este ADR NÃO faz")——project_containers(migration 0046)记录可变的状态(status、指向 Arquiteto 决定而不复制它的 image_version、container_id、声明的资源上限、failure_reason),每个项目一行,就像 engine 中的 dev_agent_states(0045),带有纯状态机(provisioning → running ⇄ stopped,三者都可到达 failed,removed 只能通过重新创建离开),格式与 session-state-machine.ts 相同。事先调查确认产品中没有服务挂载 /var/run/docker.sock 或以 privileged 运行——因此两个用例(Registrar/ObterCicloDeVidaDoContainer)只写和读,没有对 Docker 的真实调用:为了"真正能用"而挂载 socket 就等于做出一个没人在这一切片中要求的安全决定(升级到 root 的向量)。第一次转换(provisioning)要求镜像已经决定(RN-105,与 Code 标签页同一个闸门),并在这一行中冻结版本/资源;处于 workspace_mode: 'local'(0072)的项目被拒绝,因为它不启动自己的容器。没有新的 HTTP 路由——还没有真正的使用方,暴露一个就是猜测契约
0083Terminal 标签页获得生命周期的真实使用方,而不是终端。修订 0081 中把 HTTP 路由推迟到出现真实使用方的那部分——这条前线(PROGRAMA 28,Onda 5/F2)就是那个使用方。原计划是完整的交互式终端,但 FASE 25b 仍被裁剪:没有服务挂载 /var/run/docker.sock,生产中也没有任何东西转换 project_containers(ADR 0081 创建的是表,不是编排器)。实现一个假的终端——或者在 Brabo monorepo 的同一个容器中运行,即 0055 已经描述为策略的那笔债——就是编造能力,与 ADR 0041/0042 对 provider 和模型所拒绝的错误相同。真正的交付:GET /projects/:projectId/container/lifecycle(RN-267),忠实映射 ObterCicloDeVidaDoContainerUseCase,以及 CodeBottomPanel.tsx 在已有的说明文字之下——从不取代它——显示这个状态(翻译后的徽章、失败原因),只在 Terminal 标签页打开期间获取(RN-268)。null(从未创建)是今天诚实且最常见的结果
0128runner 中对 Docker 的访问被放到一个恰好有五个操作(start/stop/remove/inspect/exec)的端口之后,实现是由一次失败的证明选定的。dockerode 被安装、导入、实例化,并通过针对真实工件运行的 --self-test-docker 标志进行了实际调用(ping())——从不是打包器可能通过 tree-shaking 消除的 import。tsup 通过了(它把 dependencies 保留为外部 require,所以 dockerode 从未进入 bundle);bun build --compile(ADR 0112,独立二进制)失败了,错误按原文记录:Could not resolve: "../build/Release/cpufeatures.node"。这条链是必然的,不是偶然的——docker-modem/lib/modem.js 在模块顶部 require ./ssh,ssh2 require cpu-features,而这个可选的原生 binding 被包在一个打包器所没有的运行时 try/catch 中。与 ADR 0112 为 node-pty 记录的同一类问题,差别在于决定性的一点:那里 binding 是必需的,这里它加速的是这个 runner 从不使用的 SSH 传输(它与本地 unix socket 对话)。已测量且未采用,免得有人重做调查:--external cpu-features 可以编译(280 个模块对 79 个,每个二进制 +1.7 MB × 5 个平台),dockerode 在二进制内能工作——这恰恰是产品所有者事先排除的那种巧妙绕法。于是 runner 使用 execFile('docker', …),dockerode 离开 lockfile,而 BROKER(PR 1.2)不继承这个决定:它以我们构建的镜像交付,从不变成独立二进制。隔离靠的是类型,而不是调用方的自律:没有 privileged/cap_add 的字段,RedeDoContainer 是 `'none'
0130容器 BROKER 诞生——一个新的 Node/TS 服务(apps/broker),是产品中唯一在服务器上与 Docker 守护进程对话的进程,api 永远拿不到 socket。三个不可分割的决定。第一,端口移动位置:docker-port.ts 和 docker-cli.ts(以及两个 spec)离开 apps/runner/src/,进入一个新的 workspace 包 packages/docker-port,由 runner 和 broker 使用——正是 ADR 0128 写下的会做的事,从不复制,因为"一个带着同样五个操作外加第六个'仅 broker'操作的第二份文件,就是隔离终结的开始"。packages/shared 因其声明、并由 packages-shared-so-tipos.spec.ts 保持诚实的不变量被拒绝(100% 类型,如果有运行时值在 tsc 之后留存,api 的生产镜像会以 ERR_UNSUPPORTED_NODE_MODULES_TYPE_STRIPPING 死掉);新包没有构建步骤,由两个使用方打包(tsup、bun build --compile),这就是为什么 api 不能使用它,以及为什么 validarDecisaoDeImagem 不移动位置。独立二进制在这次移动中存活,已测量:build、build:bin、smoke 和 smoke:bin 全绿,Bun 的 bundle 从 79 → 83 个模块,npm pack --dry-run 不变。第二,也是该服务的目的:broker 不接受容器 spec。它接收一个 projectId 加上五个操作之一,在 api 本身读取 Arquiteto 的决定(GET /internal/projects/:projectId/container-spec,RN-485),并组合镜像、网络、资源和唯一的挂载——没有可以写 privileged、cap_add、network: host 或自由 -v 的字段,因为根本没有字段。它重新校验 api 返回的内容,因为依赖调用方正确的隔离不是隔离,而两次校验回答不同的问题(api 的那次是关于工件是否可修订;这次是把不可信的 JSON 解析为封闭类型)。有一项检查只存在于此且是新的:以 - 开头的镜像引用会被 CLI 读作 FLAG——没有 shell 的 execFile 解决的是命令注入,不是参数注入。第三,api 不发送任何路径:-v 由守护进程针对主机的文件系统解析,所以 api 容器内部的路径会静默地挂载一个空文件夹;broker 把 workspaceDirName(RN-109)与自己的 PROJECT_WORKSPACES_HOST_ROOT 拼接,缺少它时拒绝 start 而不是去猜。pidsLimit 进入端口,理由值得一提——工件一直带着三个数字,丢掉一个就会让它承诺一个容器得不到的上限。由五个独立层隔离:没有发布的端口,一个只有 api 进入的 internal: true 网络,以常数时间比较的共享 service token,五个操作,以及计算出来的 spec。它在两个 compose 中都以 profiles: ["container-broker"] 交付,因此默认关闭,其镜像不发布到 GHCR——docker-bake.hcl 和 ALVOS 保持 ADR 0119 的四个镜像。api 获得一个端口和一个 HTTP 客户端,其唯一的调用方是 inspect:容器生命周期路由开始在登记的状态旁边返回观察到的状态,从不合并,当没有办法查看时用 naoObservado 说明原因(RN-486)——所以表现在可能说谎,而读取会说出这一点。没有任何东西启动容器:没有循环、没有队列、没有 container_start 的 proposed_action,RegistrarTransicaoDeContainerUseCase 逐字节保持不变,包括按模式的守卫
0131Arquiteto 获得第七个工具 route_modules_to_infra:当前 module_map 的每个模块一个条目 {modulo, imagemCandidata, porque},记录为 artifact.module_routing——没有表,与 artifact.module_map/artifact.project_image/artifact.c4_diagram 设计相同,事件就是工件。Arquiteto 只提名候选;在候选之间选出(或全部拒绝)是 Infra 的工作,在之后的一个 PR 中——这个只产出可审计的列表。每个条目的镜像都经过 choose_project_image 已经应用的同一规则(validarDecisaoDeImagem:显式的 tag/digest,拒绝 latest,一个真正的 rationale),按条目复用而不是重新实现;新的领域(module-routing.ts)只添加列表特有的东西——空列表被拒绝(这不是决定),重复的模块被拒绝(有歧义:两个镜像哪个胜出?),每个 modulo 必须指向当前 module_map 中存在的模块,拒绝时列出有效的模块,与 assign_story_modules 规则相同。:direct,从不是 proposed_action:路由没有外部效果。build_kickoff/1——Arquiteto 工具顺序的唯一来源——把新工具列在 create_module_map 之后
0133ADR 0131 留下了半句悬而未决的话——"o Arquiteto só candidata, a Infra elege, num PR separado"——这就是那个 PR,连接到另一句悬而未决的话,在 broker 端口自身的注释中(ADR 0130):"subir é efeito externo e não acontece sem uma proposed_action; a Infra a propõe, com a autoridade final do usuário — outro PR."两者的结合暴露了一个两份 ADR 都没回答的问题:broker 不接收 spec,它读取 GET .../container-spec,后者读取 artifact.project_image(RN-105)——而不是 artifact.module_routing。因此一次没有变成 artifact.project_image 新版本的选择是可审计的,却是惰性的——启动的容器是 choose_project_image 所说的那个,与 Infra 刚刚选出的无关。决定:选择复用整个 DecidirImagemDoProjetoUseCase,现在以 decidedBy: 'infra-lead' 而不是默认的 'arquiteto' 调用——同样的校验(validarDecisaoDeImagem,同样的资源上限)、同一个唯一的"当前"工件、与 module-routing.ts 自身的注释早已确立的复用而非重新实现的同一先例。被选出的镜像必须是路由中的某个 imagemCandidata——从不编造——在触碰工件之前检查,不是时按名称拒绝并列出有效的候选。新的 proposed_action container_start(maintainer,有意放在 decide.ts 的绝对上限块之外,与那里已为 propose_execution_plan/assess_implementability 记录的理由相同——首次选择,不是提高上限)是 ContainerBrokerPort.start 的第一个真实调用方——ADR 0128/0130 声明的五个操作中,之前只有 inspect 有调用方。RegistrarTransicaoDeContainerUseCase 也获得测试之外的第一个调用方,只有在 broker 确认之后才转换 provisioning → running(或从 stopped 直接到 running,因为 ADR 0081 的状态机拒绝 stopped → provisioning)。dev agents 还没有在启动的容器内运行——那是之后的一个 PR
0134关闭 ADR 0133 声明留下的事:"os dev agents ainda não rodam dentro do container que sobe — isso é um PR posterior."Engine.Actions.TerminalExecutor.decisao_de_execucao/1 获得第五种结果 :executar_no_container——execution_mode: container 且有一行登记为 running 的 project_containers(Engine.Containers.ProjectContainerLifecycle.running?/1,按 Engine.Projects.Project 模式的新只读 Ecto schema)——终端命令穿过 engine → api(POST internal/projects/:projectId/container-exec,InternalProjectsController 中的新路由,不放在面向 broker 的 InternalContainersController 中,因为这里的调用方是 engine,而且生命周期表中什么都不写)→ broker(ContainerBrokerPort.exec,ADR 0128/0130 五个操作中最后一个获得真实调用方的),通过 docker exec 运行,而不是在 engine 进程内的 System.cmd。cwd 存在时,由 engine 在调用之前从主机路径(在 PROJECT_WORKSPACES_ROOT 之内)转换为 /work——在代码中核实过,不是假定:broker 的 raizDoProjeto(PROJECT_WORKSPACES_HOST_ROOT + workspaceDirName)和 engine 的 PROJECT_WORKSPACES_ROOT + workspace_dir_name 解析到同一个物理目录,所以 Workspace.ensure!/4 需要零改动——容器只是 dev agent 已有 worktree 的第二个观察者。一行 running 永远只是登记的状态(RN-486:登记的和观察到的从不合并);被外部杀死或移除的容器会让对 broker 的调用变成普通的 failed_result——从不是崩溃,从不静默回落到容器之外,那会重新打开这个 PR 所关闭的隔离缺口。在项目真实容器范围内的终端动作还获得一个自动批准的下限——DecideContext 中的 containerExecutionActive,只由 ProposeActionUseCase 为 execution_mode: container 中的 terminal 设置——它把 decide() 的初始值从 require_approval 改为 auto_approve;这是下限,不是新上限:agent_autonomy/permissions.json 仍然可以完全像它们降低旧默认值那样降低它,五个绝对上限(路径范围、有外部效果的 git/特权命令、受保护分支上的 merge、instruction_patch、并行)继续在其上运行,不变。安全论证是第二道更强的边界,而不是第一道的缺席:decide() 从不看到 /work 路径(这个转换发生在之后、在 engine 中),所以路径范围的词法上限继续在一贯的主机路径上运作,与 Docker 的挂载命名空间和 broker 自己对 /work 的重新校验是冗余的——而不是被它们取代
0135执行"在容器中真实执行"计划的第 5 号决定,在这个 PR 存在之前就已接受:RN-105 的镜像闸门——在有人决定 artifact.project_image 之前 Código 标签页返回 409——不再对 mounted/runner 豁免(ADR 0072/RN-169,经 ADR 0104/RN-421 修订)。ReadProjectCodeUseCase.portaoDoContainer 失去了提前返回 executionMode !== 'container';闸门在三种模式下都变成无条件的,ProjectCodeTab(web)不再把 mounted/runner 当作特例——同样的 useQuery,同样的四个状态,modoLocal 分支被移除,这简化了组件而不是增加一种情况。接受的代价,明明白白地说:每个已有的、没有决定镜像的 mounted/runner 项目——包括本仓库的 dogfooding 项目 exp001/exp002——在这一改动进入的那一刻就失去对 Código 标签页的访问,直到 Arquiteto(或 Infra 的选择,ADR 0133)决定一个;这是部署之后需要运维者采取的动作,所以分支是 breaking/ 而不是 bugfix/,即使这个改动看起来像修正,升到 MAJOR 版本是机械的,不是手动的。RegistrarTransicaoDeContainerUseCase 也失去了按模式的 400 拒绝——project_containers 现在可以为三种模式登记行——但经调查确认不变:对 mounted/runner 来说真正到达 running 仍然不可能,在唯一一处强制,即 ContainerBrokerPort.start() 的 ModoDeExecucaoNaoSuportadoError(broker 在服务器上运行,看不到用户的文件夹),ExecuteContainerStartUseCase 早已把它变成普通的 container.start_failed——从不崩溃,从不静默回落——在甚至尝试任何生命周期转换之前,所以尽管 400 被移除,通过真实的审批路径也不会为这些模式产生任何行。有意没有在 api/提议层添加第二次模式检查:复制本属于 broker 的闸门会制造第二个与第一个产生分歧的地方,这正是 broker 自身注释早已记录并指向这份 ADR 的教训。同样经过调查,并在此声明而非修正(超出本 PR 的 API/领域范围):目前 propose_container_start(Infra Lead 的工具)和 GetInfraContextUseCase 都不按 executionMode 限制,所以 Infra Lead 仍然可以为 mounted/runner 项目提议 container_start——这是先前存在的缺口,会消耗一个审批周期,以响亮且具名的方式失败而不是沉默
0136全局容器页面(/containers,跨项目)关闭了 ADR 0130 端口注释中声明的另一个缺口——"stop/remove ainda não têm chamador"——带有计划未规定的两个决定。第一:页面以三次常数级查询读取登记的状态(ContainersOverviewRepository,与 ProjectsSummaryRepository 相同的无 N+1 形式),但向 broker 询问观察到的状态是每个项目一次网络调用——所以只有 provisioning/running 行有资格,每次加载以 TETO_DE_VERIFICACOES_POR_CARGA(20)为上限,并行进行;落在任一规则之外的条目带有自己的原因 naoVerificado,从不与 broker 的 naoObservado 混淆(RN-468/486 的纪律延伸到一个新界面)。第二:container_stop 获得与 container_start 完全相同的规格(maintainer,可配置的自动批准,从不预置);container_remove——三者中唯一丢弃容器并迫使一切重新创建的——进入有外部效果的 git/特权命令的同一个绝对上限(RN-418):永远不可自动批准,"sempre permitir"在源头被拒绝。ContainerBrokerPort.remove 本来就是 docker rm --force(一次调用就能移除甚至 running 的容器);ExecuteContainerRemoveUseCase 没有用 running -> removed 捷径来扩大状态机,而是登记两次真实的跳转(stopped,然后 removed)。"Subir de novo"字面地复用 container_start——后端零改动——从项目当前的镜像决定组装 payload,而不是第四种动作类型
0137关闭 ADR 0135 声明留下的另一半:mounted/runner 现在要求已决定的镜像(RN-105 的闸门,三种模式都是),但还没有办法启动容器——服务器的 broker 从一开始就拒绝这两种模式,因为它们的代码住在只有用户机器能看到的文件夹中。container_start/container_stop/container_remove 获得第二条执行路径,只在执行时按 executionMode 分支(从不在提议或批准时,这两者仍然与模式无关):container 继续不变地经过 broker;mounted/runner 现在请 ENGINE 转交给已连接的 RUNNER,通过同一 Phoenix 通道上的三对新事件——container_start/_result、container_stop/_result、container_remove/_result——完全复制 exec/exec_result 模式而不是发明新的。runner 用 DockerViaCli(@brabo/docker-port)回应,即 broker 在服务器上使用的同一个端口,此前只被 --self-test-docker 证明过,现在有了真实调用方;它启动的镜像是从当前决定中读取的(ObterSpecDeContainerUseCase,broker 的 spec 路由已经调用的同一个用例,由于两者在 api 进程中运行而被直接调用),从不重新选择。Engine.Actions.TerminalExecutor 没有获得新结果:它本来就无条件地把已连接 runner 项目的每条命令路由到通道(RN-423)——主机还是容器是 RUNNER 在内部做出的决定(EstadoDoRunner.containerAtivo,tratarExec 切换为 docker exec,cwd 通过前缀替换转换,cwdParaContainer)。guard.ts 在 $HOME 中的约束(RN-434)需要零新代码来覆盖新的挂载——通过通读整个文件核实:bind 就是 estado.dir,即在任何通道连接之前、CLI 启动时已经校验的同一根目录。"没有已连接的 runner"/超时(RunnerNaoConectadoError)和"runner 尝试过并拒绝了"(RunnerRecusouContainerError)都是普通的 failed 结果,从不是未处理的异常——与 BrokerIndisponivelError/BrokerRecusouError 同样的纪律
0143exp001 的一次真实执行中有十个 dev 任务同时卡住,而发现的不是具体哪个文件夹失败——而是没有任何东西安排容器先于 dev agent:没有任何 container_start 被提议过,十个 dev agents 依然开始了。于是 Engine.Dev.AgentIo.try_claim/2——唯一的 claim 点——在调用 api 之前询问 ProjectContainerLifecycle.running?/1(ADR 0134 已引入的谓词,没有新机制),没有登记的 running 行时,智能体落入 :idle,持久化并发出 dev.blocked_by_container。放在 ENGINE 中而不只放在 api 的 activate-execution 闸门,因为 claim 有一条任何路由都覆盖不到的路径:再水合不发 :work 的 cast——DevAgentServer.init/1 -> finish_restart_recovery/1 -> try_claim/2 自己完成 claim,所以只有 HTTP 的闸门会让每个再水合的智能体在没有容器的情况下回去工作,恰恰是在没人看着的情况下。用 :idle 而不是新状态,出于 try_claim/2 自身 docblock 早在 Fase 12b 就记录的理由:它是 wake 仍能救回的唯一状态,handle_info/2 的每个守卫都以它为键——一个 :blocked_by_container 会是一个没有东西能救回的状态。区分"队列空了"与"没有容器"的是事件,从不是状态。唤醒通过 outbox:RegistrarTransicaoDeContainerUseCase 在记录到达的同一个事务中添加 container.running,归在一个 container 聚合下(Engine.Outbox.Drain 读取的第三个,与 session/task 并列),而不是借用 task 以省一行查询,以项目作为 aggregateId;投递的消息是现有的 {:wake, :became_claimable},其语义本就是"现在可能有工作了"——新消息需要在两个服务器中各加一个守卫相同、没有不同决策要做的 handle_info/2 子句。第二半,从另一侧看的同一规则:TerminalExecutor.decisao_de_execucao/1 不再静默降级——没有运行中容器的 container 曾落入 engine 进程内部的 System.cmd,所以 ADR 0134 的隔离只在顺利路径上有效。现在它拒绝(:recusar_container_ausente),镜像 runner 模式已有的两种拒绝,mounted 在同一分支中,catch-all 缩小到它本应单独覆盖的情况:不存在的项目或格式错误的 id。声明的代价,也是这只能在 mounted 获得容器之后进入的原因:没有容器的项目会停止工作——响亮地,这正是目标
0144Montado 模式的项目不能有任何容器,被阻止了两次:ExecuteContainerStartUseCase 把每个不是 container 的模式都发给 RUNNER(它需要一个已连接的 brabo-runner),而 broker 在源头拒绝每个不是 container 的模式。两次拒绝都是关于几何的,而不是关于模式的名字——挂载项目的文件夹位于运维者磁盘上某个任意位置,服务器的 Docker 守护进程没有理由能看到——而 ADR 0141 恰恰改变了这种几何,把每个挂载项目放在一个以同一性挂载的基目录之下。于是 broker 获得第二个根 BRABO_PROJECTS_HOST_BASE,api 开始发送一个区分式定位符,说明一个片段属于哪个根:gerenciada(被管理的文件夹,由 workspace_dir_name 命名)、montada(基目录下的相对路径)或 indisponivel(这里没有根能到达它,并给出原因)。ADR 0130 的不变量保持不动,也是这种形式的理由:穿过线路的仍然只是 broker 的根未覆盖的那一半,api 从不知道这些根在主机上的位置,broker 从不知道项目的任何事——任何一方单独都写不出任意路径,而发送绝对路径就等于把一个相当于 root 的进程的隔离交回调用方手中。三个变体而不是两个,因为 indisponivel 有两种不同的修正——runner 项目(修正:另一侧的 runner)和在基目录之外创建的遗留 mounted 项目(修正:移动文件夹)——一个 null 会有一半的时候把运维者带到错误的地方;就是基目录本身的文件夹也落在这里,因为 <root>/ 会把每个挂载项目都挂进一个项目的容器。没有一个根取代另一个:缺少的那个在拒绝中被点名(BaseDeProjetosNaoConfiguradaError,镜像 RaizDeWorkspacesNaoConfiguradaError),因为被管理的根由 workspace_dir_name 命名而基目录由用户命名,从一个落到另一个会挂载另一个人的代码而没有任何迹象说明原因。拼接之上有三道屏障,而不是一道——api 拒绝基目录之外的东西,packages/docker-port 拒绝不是相对路径的片段(..、绝对路径、空、NUL),raizDeProjetoValidada 仍在 root + segment 变成 -v 之前对其运行。在 api 一侧,分支变成按目标:container 和 mounted 去 broker,只有 runner 去 runner,container_stop/container_remove 必然随 container_start 一起改变——在服务器上启动容器,然后请用户的机器停止它,会让已经运行的东西继续运行而没有办法停止。并且 mounted 像 container 那样选择镜像,而不是像 runner 的路径那样读取当前的:broker 从 artifact.project_image 组合,它在 api 中获取该工件,所以没有写入这个工件的选择会是惰性的——ADR 0133 的论点应用于第二种模式
0180项目的容器以文件夹所有者的身份运行(--user uid:gid,由 api 测量,由 broker 重新校验,从不来自请求体),使 dev agents 能在 --cap-drop ALL 下写入 /work(AT-247)。它为 spec 添加一个字段,不打开任何隔离的口子
0190当发给 Infra 的 handoff 被接受时,Infra 自己启动容器(AT-260,所有者 10月1日的决定),修订 ADR 0133 的"nunca semeado"而不编辑它:接受时为 Infra 智能体预置 container_start: auto_approve,Infra Lead 的服务器——不是模型,不是新工具——选出路由中覆盖最多模块的候选(平局时取第一个),并在第一次调用模型之前,通过与工具相同的路径提议 container_start,按模式/状态的拒绝(RN-566/610)和无 broker 时的 409 保持不变。runner 保留需要审批的 container_start_via_runner,container_remove 仍在绝对上限中,RN-668 的闭合仍然成立。同一所有者决定(AT-262):Dev Lead 由 INFRA 提供,只在项目容器 running 时——Arquiteto 的双重 handoff 被移除,ADR 0053/0085 中"o Dev Lead vem do Arquiteto"的说法改变而不被编辑
0199容器的最低资源由 module_map 推导(AT-261,所有者 10月1日的决定):每个模块可以声明 resources(cpus、memoryMb、pidsLimit——三个都有或都没有),省略资源的 container_start——在 handoff 被接受时由服务器启动总是这种情况(ADR 0190)——以模块的总和启动,因为所有模块共享一个容器。没有声明的模块把容器的下限设为今天的默认值,而不是编造一个数字;完全没有声明的映射正好得到今天的默认值。总和超过上限时,在创建映射时对 Arquiteto 返回 400;请求低于最低值是一个具名的 failed
0208Infra 继承 Arquiteto 在 artifact.project_image 中声明的网络(AT-392,所有者 10月2日的决定):handoff 被接受时由服务器启动,以及不带 network 的 propose_container_start 工具,都使用 Arquiteto 的 egress/none 而不是字面的 none,并继续无需点击即可启动;修订 ADR 0190 的第 3 点,偏离 ADR 0065 的 §4,并声明代价(egress 能到达主机发布的端口,ADR 0189)。

本地 runner​

用户机器上的 CLI——通道、凭据、设备密钥、分发、基目录和服务。

#决策
0103apps/runner(新的 workspace,Node/TS):在用户机器上运行的 CLI,通过新的 Phoenix 通道(/runner,由 ENGINE 签发的一次性 ticket)连接到 engine,在用户的 $SHELL 中执行已批准的智能体命令,外加供 Code 标签页使用的交互式 PTY 终端(engine 纯粹中继字节)。路由总是在批准之后——从不是第二条执行路径。没有连接的 runner 时,回落到一贯的 System.cmd(ADR 0072 变成回退)。安全边界不是沙箱:而是认证 + 审批管道 + 用户同意——guard.ts 是 best-effort,已声明。真实发现:产品中没有长期账户 token 的机制,runner 会复制浏览器的登录,直到真正的自动化 token 出现
0105Personal Access Token(brb_…)关闭了 ADR 0104 的 backlog 项:apps/runner/src/auth.ts 把复制的登录(磁盘上的密码+cookie)换成一个长期 token,作用域限定于一个项目。新表 personal_access_tokens,通过 hashDeToken() 做 HMAC-SHA256+pepper 哈希(对 backlog 的纠正,它说的是 argon2——对高熵秘密来说是错的)。核心决定:PAT 在构造上永远不在 POST .../runner-ticket 之外认证——IS_PAT_ROUTE_KEY/@RequirePatAuth()(JwtAuthGuard 中的第三个分支,与 IS_SERVICE_ROUTE_KEY 相同的模式)+ 路由级的 PatAuthGuard,从不是全局守卫中的一个分支,那样会让 RolesGuard 为用户角色的任何路由授权 PAT。last_used_at 是同一个校验查询中的无条件 UPDATE——没有节流,节流会让 5 分钟内的合法重连以 401 被拒绝
0106关闭 ADR 0104 backlog 的最后一项:tsup 把 apps/runner 打包成单个 dist/index.cjs(node-pty 必须作为 external——原生 binding,不能内嵌),由一个自有的 workflow 发布为 @brabo/runner,由与 release.yml 相同的最终 tag 触发。经实证测试的真实发现:自动运行守卫的显而易见的修正(import.meta.url === pathToFileURL(argv[1]).href)在这一波正要启用的情况下是坏的——process.argv[1] 从不经过 realpath 解析,import.meta.url 总是经过,而 npm install -g 恰恰通过 symlink 造成这种不对称;最终的修正在比较之前对 argv[1] 应用 realpathSync。版本只在 CI 的一次性 checkout 中注入(npm pkg set),从不提交。缓解了在重新发布仓库中已有的六个孤儿 tag 之一时 npm 的 latest 回退的问题——当目标版本早于当前 latest 时,用自己的 dist-tag 发布。breaking/——发布要端到端工作,需要运维者先采取动作(创建 npm 的 Automation Token)
0112通过 bun build --compile 构建 @brabo/runner 的独立二进制——ADR 0106 的伙伴,关闭 ADR 0104 中明确推迟的 backlog 项。node-pty 通过运行时计算出的路径解析自己的原生 .node 插件,Bun 无法静态内嵌它(经实证证明:朴素的构建能干净地编译,却在运行时抛出);修正通过 with { type: 'file' }(Bun 唯一接受字面说明符的机制)内嵌 node-pty 需要的每个文件,并在启动时把它们重新提取到一个真实的临时目录,保留原始的相对布局,使 node-pty 自己的相对 require() 无需修改即可解析。两个只有运行编译后的二进制才发现的 bug:--external node-pty 在 bun build 命令本身中是必需的(否则 Bun 会静默地打包未编译回退中的 await import('node-pty'),在运行时抛出误导性的错误而不是让构建失败),ADR 0106 的自动执行守卫需要第二个分支,以处理 process.argv[1] 在编译后的二进制中是虚拟路径 /$bunfs/root/... 的情况。五个目标的矩阵(linux-x64/linux-arm64/darwin-x64/darwin-arm64/win32-x64),每个都在自己的原生 runner 上构建;在这次调查中只有 linux-x64 经过真实执行验证——其他四个,尤其是 Windows,是根据 node-pty 的源代码推理的,它们的第一次真实执行是下一个版本 tag
0118通过浏览器配置 runner:在浏览器中(Web Crypto)生成的一对 Ed25519 密钥登记为 runner_device_keys 的新一行,POST .../runner-ticket 接受由它自签名、生命周期短(≤60s)的 EdDSA JWT,作为与 PAT 并列的第二种凭据——叠加,从不替代,并且明确不是 RN-439 已经禁止的与会话 JWT 的双重认证。一条新的 @Public() 路由代理已经发布在 GitHub Releases 上的二进制(对资源解析做 5 分钟的内存缓存,从不缓存字节),而不是构建或保存第二份副本。File System Access API 直接在 Chromium 中写入配置好的文件夹(二进制 + 配置 + 设备私钥,三个文件,一次点击);在其他任何地方,两次普通下载是备选方案。接受的限制:浏览器无法设置 Unix 的可执行位,所以 chmod +x 仍然是手动的。有本地配置时,--project/--dir/--token 在 CLI 中变为可选。在同一会话中明确拒绝:runner 自行发现本地的 Docker/Kubernetes 容器并通过 SSH 连接——这会重新打开有意裁剪的 Fase 25b
0145runner 曾在 workspace 已验证且 runner 已连接时,就把每条已批准的终端命令路由到已连接的 CLI——从不检查登记的 running 容器,而 container/mounted 自 ADR 0143 起就要求这个谓词。runner 在内部决定主机还是容器(ADR 0137),没有运行中的容器时就静默地落到纯主机上,恰恰是 ADR 0143 为另外两种模式关闭的那种回落。第二个缺陷,自 RN-478 起声明为开放:Workspace.ensure!/4 曾在 runner 项目只存在于主机的路径上尝试本地的 File.mkdir_p!/git init,而 engine 进程从来没有对它的 bind-mount——之前的修正只改善了失败的消息,dev agent 依然无处工作。于是三个前提条件(已验证、已连接、容器 running)进入 Engine.Runners.RunnerReadiness,由 TerminalExecutor 和 worktree 的物化共享,后者现在在用户的真实机器上运行,通过已经运行已批准终端命令的同一个 exec/exec_result 通道(Engine.Actions.Workspace.RunnerGit,新增),而不是在 engine 进程内——Engine.Dev.WorktreeManager 的四个操作以同样的方式分支,周期性清理任务(Engine.Dev.WorktreeCleanup)会跳过当前未就绪的 runner 项目,而不是崩溃或把它当作孤儿。exec 协议获得一个可选的 env 字段(RN-507),使 git fetch 的凭据(ADR 0056)可以在用户机器上随子进程的环境传递——合并在 process.env 之上,从不替换,从不发送给 docker exec(packages/docker-port 中按设计不支持 env),也从不记录日志。第三个发现:container_start 也服务于 runner,但它的 payload(imagem/network/resources/rationale——Infra 选择一个候选)在那里从不适用——没有可选择的对象,因为 broker 从来到不了 runner 项目的文件夹。于是 container_start 变成 container/mounted 专属(两者都经过 broker,RN-503),一个新动作 container_start_via_runner(schema:只有可选的 rationale)覆盖 runner——它在 Infra Lead 中的工具在提议之前在本地(不经 HTTP)检查 execution_mode 和 runner 的连接性,按名称拒绝而不是盲目提议,与 propose_container_start 先前存在、已声明且仍然开放的缺口相反(RN-494)
0147向用户在主机任何位置选择的文件夹写入,恰好有三种可能的机制——原生运行 api/engine、在主机上保持一个存活的进程,或者让浏览器通过 File System Access API 写入——而只有第二种是持续的。brabo-runner 已经是那个进程,所以它不再交付第二个"镜像"二进制,而是成为一个本地智能体,在连接时声明能力(espelho、exec、pty),服务器只授予项目的 execution_mode 所需要的:一次安装、一次认证、一次撤销。在 join 的参数中声明,因为今天 join 在两侧都是哑的——runner 发送 {},通道的签名字面上忽略 _params,角色来自 ticket 的 kind——并通过交集授予,作为连接的状态保存在 socket.assigns 中,从不放进表里,与在客户端保留 containerAtivo、并已经让 Registry 的 :global 条目随 pid 一起消亡的推理相同。未声明的必需能力会拒绝 join,这是正确的形式,因为 runner 已经把 JoinRecusadoError 当作致命错误而不重试:一个无能力的 runner 不应陷入退避循环。能力放在 join 中而不是像 workspace_confirm 那样在 join 之后推送,因为后者描述的是会变化的 WORKSPACE,而能力描述的是二进制,在连接期间固定不变。镜像获得自己的范围守卫,与 guard.ts 是姐妹,复用已经能捕获路径中间 symlink 的 词法+realpath 双重检查,并拒绝三件事:逃出目标的 symlink、包含 workspace 的目标,以及位于其内部的目标——后两者是与 ADR 0141 的 bind 方向相反的同一个循环。单向,并且永远不 --delete:在 workspace 中删除的文件留在目标中,累积是已声明的代价。不要求 Docker,并获得一个单独的谓词,而不是带标志的 RunnerReadiness——一个带有"跳过容器"参数的共享函数,正是第三个前提条件之后意外地对 exec 失效的方式;镜像不需要它,因为这个前提条件的存在是为了防止命令落到纯主机上(ADR 0145),而在用户机器上复制文件没有可以落到的主机。撤销暴露出一种代码自身 docblock 都低估了的盲目:它把裁剪声明为"sem visão de maintainer",但 PAT 有五条路由而设备密钥只有两条——一个 developer 连自己的密钥都无法列出,而无法撤销看不到的东西。于是加入了列表,撤销不再只意味着"不再签发新 ticket":现在它会断开活着的通道,而 Registry.whereis/1 早已使它可达。同步由事件触发(回合结束、commit),从不通过 fs.watch,后者会是由任何东西触发的无限工作——包括镜像自身的写入,那就是一个循环。而塑造这一切的那句话:**同意一项配置不等于批准一个动作。**镜像的写入不经过 proposed_action,因为它不是智能体请求行动;它是系统在用户声明的路径中写入。把它作为终端命令来做会适得其反——它会落入 ADR 0055 的路径范围,变成一队例行审批,并侵蚀赋予点击意义的那道上限本身
0151runner 获得一个经同意的基目录,文件夹改由通道创建——一个有意狭窄的切片,按机器的 runner 推到下一阶段。今天 runner 没有基目录:唯一的根是 --dir,没有它时根就是 cwd(index.ts:242-250);estado.dir 是一个单一路径,供 cwd 守卫、pty、cwdParaContainer 和 workspace_confirm 使用。基目录只存在于 api 中,而同一个文件以书面形式禁止(project-workspaces-root.ts:421-438)把基目录规则放进在每次读取时运行的 caminhoDeWorkspaceLocalValido。决定:安装程序同意一个基目录,保存在本地;每个项目是一个子文件夹;基目录永远不从线路上接收——与 broker(ADR 0144)同样的形式,根属于执行者,只有片段传递。守卫复用 dentroDoEscopo 和 espelho-guard.ts:208-233 中按片段的循环拒绝(/base-outra 不在 /base 之内),从不是规则的第四份副本——先例是 RN-515。新的一对 workspace_create/workspace_create_result,仿照 exec/exec_result,之后 runner 发送已经存在的 workspace_confirm,所以不产生新的写入路径,engine 仍然不写表,只有一条路由仍然盖上 workspace_verified_at。记录一个名称上的精确之处:workspace.verified 不存在于任何地方——事件是 project.workspace_verified——而 workspace_confirm 是单向的(terminal_channel.ex:356-373 回应 {:noreply, socket}),所以一对需要回应的消息不能在它之上建模。能力只在代码进入的那一次会话中才进入词汇表(RN-514)。有两个前提条件的自有谓词,字面采纳 espelho.ex:19-24 中关于为什么 RunnerReadiness 永远不获得"跳过容器"标志的说明。不是 proposed_action。声明了按机器的 runner 需要跨越的五个独立耦合,其中一个是 migration,一个是 breaking/
0154本地智能体的身份变成机器的,而不是项目的。安装程序已经很接近了——自 RN-531 起它下载二进制,对照签名的 checksums.txt 核对,并以 install -m 0755 安装,无需手动 chmod——然后就停下,在自己的最后一行这样说:它不配对智能体,设备密钥仍然来自项目页面。阻碍的不是二进制,而是身份:service install 需要一个 projectId 和一个密钥,而在安装时还没有项目——没有账户,也没有 workspace。FASE 29 把"每个项目一个 runner"测量为在五个独立地方被强制;带着 arquivo:linha 重新测量后显示其中三个根本不需要改变,这是这份 ADR 的发现。RunnerSocket.connect/3 通过 TICKET 认证,ticket 按项目请求,原子消费发生在针对 topic 的 project_id 的 join 时——所以 topic(terminal_channel.ex:204)、socket 的 id(runner_socket.ex:73)和 ticket 描述的是一个连接,而不是一个进程。一个进程打开 N 个连接、每个它服务的项目一个,逐字节满足这三者,并保留对同一项目第二个 runner 的拒绝,这是一项真实的保证而不是巧合。剩下的是真正属于进程的东西:请求这 N 个 ticket 的凭据,以及让它保持存活的 unit。于是 runner_device_keys.project_id 变为可空,NULL 表示机器——同一张表,私有 JWK 中同样的 kid(RN-475),同样的撤销;考虑过并拒绝了一张姐妹表,因为 PatAuthGuard 通过 kid 找到公钥,不需要知道它的种类,而两张表就意味着两次查找,第二次恰好会被忘记一次。机器密钥不授予其所有者原本没有的任何东西:runner-ticket 仍然按项目,授权仍然针对所请求的项目解析。runner 通过新的 GET runner/projects 发现它服务哪些项目,而不是扫描基目录——通过文件夹名去猜正是 ADR 0141 拒绝的那类错误。unit 变成每台机器一个,项目的 unit 继续工作,status 说明它们存在,而不是让六个进程默默地争抢三个项目。拒绝:把 topic 合并成 runner:<userId>、跨用户的密钥、从磁盘发现项目,以及 decide.ts 中的任何例外——这份 ADR 移动的是身份,从不是权限。声明了 FASE 29 预见的代价:撤销现在会为那台机器的每个项目断开智能体
0174runner 的独立二进制不再承诺Mac Intel:四个目标而不是 ADR 0112 的五个。经测量(AT-065):macos-13 没有 runner(镜像已被 GitHub 退役,在队列中等了 24h00m01s 后在三个 tag 上都被取消),替代者 macos-15-intel 能调度并构建,但 --self-test-pty 失败,因为在 Bun 之下 node-pty 的 onData 从不触发(oven-sh/bun#25822,开放中)——同样的证明在 Node 之下通过。由维护者决定:darwin-x64 离开矩阵和 ALVOS_ESPERADOS,代理 GET /runner-releases/binary 以自己的 400 拒绝它并指向 npm 包,darwin-amd64 上的 install.sh 不下载并让人运行 npm install -g @brabo/runner,浏览器仍然检测到它但跳过下载。scripts/ci/alvos-do-runner.spec.ts 把枚举目标的四个地方绑定在一起。回归之路:当 Bun 交付修正时写一份新的 ADR
0187runner 的独立二进制不再承诺 Windows:三个目标而不是 ADR 0174 的四个,同样的模式。在四次 workflow_dispatch 演练中测得(AT-343,执行 36775746724、36779817686、36780804339、36781729045):二进制能构建,usage 能运行,当 Windows 的虚拟路径被识别时 node-pty 能加载,但在 Bun 之下,ConPTY 输出 named pipe 上的 net.Socket 在第一块数据之后就结束——连 cmd.exe /c echo 都以 0xC000013A 死掉(oven-sh/bun#25822 一族)。由维护者决定:win32-x64 离开矩阵和 ALVOS_ESPERADOS,代理 GET /runner-releases/binary 以自己的 400 拒绝它并指向 npm install -g @brabo/runner,install.sh 对 Windows 的拒绝点出 npm 的路径,浏览器仍然检测到它但跳过下载。回归之路:当 Bun 交付修正时写一份新的 ADR
0193带凭据的 git 在 runner 的主机上运行,代码在容器中运行(AT-116,所有者 10月1日的决定):RunnerGit 的认证 git fetch 带上标记(gitCredenciado: true,只由 RunnerRouter.exec_git_credenciado/5 设置),即使容器处于活动状态,runner 也在主机上执行它,env 在那里到达子进程——同一个文件夹,因为 estado.dir 就是 /work 的 bind-mount。ADR 0130 的端口都不变(DockerPort.exec 仍然没有 env),RunnerReadiness 逐字节不变,RN-558 的拒绝缩小为没有标记的 env(实际上就是早于这份 ADR 的 runner)
0201撤销针对的是密钥,而不是 {project, user} 对(AT-013,所有者 10月1日的决定,修订了 RN-520,该规则针对的是 ADR 0154 和 ADR 0155)。runner socket 的 ticket 记录是哪个凭据请求了它(credential_kind/credential_id,可空,engine 自身 schema 中的 Ecto migration),PatAuthGuard 转交它,socket 把它保存在 assigns 和自己的 id 中,POST /internal/runner/disconnect-credential 作废该凭据待处理的 tickets,并询问每个已登记的 runner 是否由它产生——因此同一用户的另一个 runner,持有 PAT 或另一把密钥,保持运行,而机器密钥是一次请求,而不是把那对应用 N 次。PAT 的撤销(RN-426/427)现在也会断开它的连接。两个过渡部件让原来会断开的仍然断开:遗留连接(没有凭据的 ticket)在 api 发送的项目中按那对断开,如果 engine 不认识这条路由,密钥撤销就退回到 disconnectRunnerOfUser。按对的路由保留给成员移除(RN-615)。确认对话框只改变文字(RN-561);runner 不变
0203ADR 0118 中通过浏览器配置 runner 的方式被退役(AT-014,所有者 10月1日的决定,关闭 BRB-031):浏览器不再生成密钥、不再下载二进制、也不再写入文件夹,RunnerOnboardingPanel 改为显示 install.sh 的命令——安装程序已经下载经过验证的二进制,以可执行方式安装它(install -m 0755,所以任何地方都不再需要手动 chmod +x),并通过终端配对机器。apps/web/src/lib/runner-bootstrap.ts 和 POST /projects/:projectId/runner-device-keys 被移除(没有其他调用方;BREAKING),而 GET/DELETE 以及机器密钥的内部路由保留。之前登记的项目密钥继续工作且可撤销。已声明:有团队的安装失去了无需 PAT 就能配对第二台机器的方式,GET /runner-releases/binary 失去了生产环境的使用方,按另一项决定保留

认证、访问与秘密​

first-party 认证、角色及其上限、workspace 成员关系、跨源加固以及秘密的信封。

#决策
0031api 领域内的认证:参数固定的 argon2id、派生密钥的 EdDSA access token、refresh 轮换并在重用时撤销整个家族、以 e-mail(而不是 id)为键的锁定,以及杜绝枚举的那条唯一不变量
0032Keycloak 的原子切换:守卫中的自有签发者而不触碰 RBAC,/internal/* 不经 JWT 而用共享秘密,web 会话放在带 CSRF 的 httpOnly cookie 中,迁移过来的用户与一个不存在的 e-mail 无法区分
0037engine 原本没有的 CORS(/health 返回 200 而浏览器丢弃了它),自有的 plug 而不是 Corsica,/internal 和 /metrics 不在白名单内,WEB_ORIGIN 只在一处读取,以及 strictPort,因为端口是 CORS 契约的一部分
0050凭据总是加密并保存;验证成为单独的动作。ADR 0004 的闸门(在持久化之前测试)看似审慎,却产生了最坏的结果:只写字段 + 拒绝 = 用户既没有凭据也没有可供修正的文本,因为界面从不重新显示他粘贴的内容。保存和验证是不同的问题,把它们绑在一起会让第二个依赖于第一个现在就有答案。这同时适用于 LLM 和 git——同一张表,同样的承诺,区别对待会为同一对象产生两个规则不同的界面。裁决有三种状态,因为 tester 在 ollama/anthropic/openai 上是 NO-OP:在二元结果中它们会返回 ok,界面会断言一次没人做过的验证(这里应用了 ADR 0041 的 capability 规则)。坏的密钥不再是 HTTP 异常而变成 200 结果,所以映射它的两个 @Catch 被移除——无法触发的过滤器就是死规则
0058api 上封闭的 CSP,以及被限制在根目录中的项目范围。修订 0027 第 7 项,它曾决定 contentSecurityPolicy: false,理由是"api 提供 JSON,CSP 属于 web"——这是事实但不是重点:通用 CSP 的替代不是不要头部,而是一个具体的 CSP,对只提供 JSON 的服务来说,具体的就是 default-src 'none',零行为代价。有两条路径会把 api 的响应置于 web 的 CSP 范围之外:直接导航到某条路由(粘贴的链接、重定向),在 api 的源上渲染,以及 frame-ancestors,它只在被嵌入的文档中生效。在生产之外,该配置只放宽 /docs 的 Swagger UI 所需要的,条件与挂载 Swagger 的相同——如果有一天它在生产中启动,CSP 会跟着一起变,而不是默默地阻挡它。选项从 main.ts 的字面量移到 security-headers.ts,理由与之前移动 cors-origins.ts 相同:在 boot 中它们不可测试,而证明必须是一次真实的请求,因为 false 和一个指令对象都是"有效配置",只在 HTTP 响应上不同。随之而来的是对 projectId 的限制:它从路由参数到达,Express 已经解码了百分号编码,所以 ..%2F..%2Fetc 会变成 ../../etc 并逃出根目录——不仅影响 permissions.json 的读取和写入,还影响根据 0055 授权终端的范围,也就是说,策略指向了错误的地方。校验放在推导根目录的地方,而不是每个调用方,理由与该函数存在的理由相同:重复的检查总有一天会分歧。还记录了 CodeQL 的七项豁免及各自的理由——其中包括针对 OAuth state 的 HMAC 的 js/insufficient-password-hash,那里没有密码,慢哈希会是错误
0059签名 git OAuth state 的密钥没有默认值。源于 0058 的一项豁免:豁免 js/insufficient-password-hash 是对的——那里没有密码,"密码"是 GIT_OAUTH_STATE_SECRET,一个服务器的 HMAC 密钥,而 HMAC-SHA256 是正确的原语。该论证没有考虑到的是,带有已知默认值的服务器密钥什么都签不了,而情况正是如此:两个读取点都做的是 process.env.GIT_OAUTH_STATE_SECRET ?? 'dev-oauth-state-secret-change-me'。与 WEB_ORIGIN 已在 cors-origins.ts 中关闭的同一失败模式,而且更糟,原因有二——该值是公开的(它在一个开源仓库的 .env.example 中;这不是弱默认值,而是一个已公开的秘密),而它保护的是 state,即伪造一条必须公开的路由的回调的唯一障碍:有了已知的密钥,任何人都能签署任意 {projectId, userId, provider},让回调把从 provider 获得的 git token 写入该 payload 的项目。决定修正形式的细节是,docker-compose.prod.yml 把该字面量作为回退提供,所以在真实的错误路径中变量是已定义的——只要求"非空"什么也抓不到,因此示例中的字面量被明确拒绝,并加上 16 个字符的下限,把配置表中一直存在的"弱 = CSRF"变成真正的检查。检查在启动时运行,而不是首次使用时:只在有人尝试连接 git 时才失败的 api 会运行数周,接受用公开密钥签名的 state。解析放在一个唯一的函数中,理由同 RN-092——同一个字面量曾有两份副本,它们分歧会让回调拒绝每个合法的 state。有意的破坏:没有定义该变量的生产环境将无法启动,这是目标而不是副作用。同一 compose 中的四个姊妹秘密(AUTH_JWT_SECRET、BRABO_SERVICE_TOKEN、CREDENTIALS_MASTER_KEY、SECRET_KEY_BASE)被明确留在范围之外,放进 backlog——之后按同一规则关闭,没有新的 ADR,见 RN-114
0084社交登录(GitHub/GitLab),修订 ADR 0031/0032 的 backlog——禁令只对这条前线撤销,出于产品所有者的明确决定。SocialLoginCallbackUseCase 最终调用与密码登录相同的 EmitirSessaoUseCase——没有第二种会话格式。OAuth 客户端被复用(GithubOauthClient/GitlabOauthClient 获得 buildLoginAuthorizeUrl/fetchIdentity),但 state 不复用:domain/auth/social-oauth-state.ts 用同一个密钥 GIT_OAUTH_STATE_SECRET 签名,只是因为 payload 在结构上不兼容——在任何其他字段之前检查的判别字段 purpose: 'social_login' 阻止了 git 连接流程的 state 在这里被接受(RN-273),否则就是权限提升。新表 social_identities(migration 0047),键为 (provider, provider_user_id)——从不是 e-mail/登录名,它们可能易主(RN-276)。回调分三步决定:已知身份 → 登录;e-mail 与现有账户匹配且经 provider 验证 → 关联,并顺带验证该账户尚未验证的 e-mail(RN-279);e-mail 匹配但未验证 → 拒绝,账户接管(RN-274);没有对应账户 → 创建一个没有密码的新用户,复用 Keycloak 迁移时同样的"待定"状态(RN-278)——这里 e-mail 不需要已验证,因为没有要保护的账户(RN-275)。复用 git 连接流程的同一个 OAuth 应用——零新环境变量——只按请求改变 redirect_uri 和 scope(最小,没有 repo/api);运维者需要在 provider 中登记第二个回调,这一动作是使用 breaking/ 分支的理由。access token 从不在 URL 中传递:会话 cookie 在回调中写入,web 的启动过程已经能自行重建会话
0096MailSender 中的真实 SMTP,关闭自 0032 起开放的 backlog 项("SMTP real continua sendo config futura")。基于纯 SMTP 传输的 nodemailer——带状态/MIME/STARTTLS/AUTH 的行协议,不同于产品其余部分集成的 HTTP JSON API——位于 MAIL_TRANSPORT(log,包括在生产中也是默认值,或 smtp)之后,通过 AuthUseCasesModule 中的 useFactory 选择,不触碰任何用例。模式为 smtp 时,SMTP_HOST/SMTP_USER/SMTP_PASSWORD/SMTP_FROM 在生产中遵循 RN-114 的模式,但没有最初四个 RN-114 秘密拥有的公开默认值——这把尺子只在运维者选择了 smtp 时才生效,在 SmtpMailSender 的构造函数中校验(在组装 provider 图时由 useFactory 调用,与 CREDENTIALS_MASTER_KEY 相同的设计),而不是在 main.ts 中急切调用。正文是纯文本,从不是 HTML——端口不携带富文本正文的结构。调查在实现之前发现了一个真实的缺口:email_verification 没有任何 web 路由(verificarEmail 存在于 apps/web/src/lib/auth.ts 中却没有调用方)——/verificar-email(VerifyEmailPage.tsx)补上了它,镜像 SetPasswordPage.tsx
0127ResolveEffectiveRoleUseCase.forProject 仍然是 projectRole ?? workspaceRole——项目的那一行在两个方向上覆盖 workspace 的那一行,而在一个敏感项目中限制某人(workspace 中的 developer → 项目中的 viewer)是一项有意的能力,"两者中较大者"会抹去它。取而代之的是写入路径上的两个上限,以 403 拒绝,且在任何地方都不可配置:没有人能降级 workspace 的 owner(所以 maintainer 再也不能把所有者锁在其自己的项目之外),也没有人能降级自己(撤销需要自己所放弃的那个角色)。owner 从 workspace_members.role 读取,从不从 workspaces.created_by 读取——created_by 是一个历史事实,不出现在任何授权路径中,读取它的上限会保护一个已经转让所有权的创建者,却让当前的所有者暴露在外。上限 2 的表述没有阈值("你自己",而不是"低于 maintainer"):带阈值的版本把一个数字从控制器的 @RequireRole 复制到领域中,如果路由的最低要求改变,它就会默默地过时;声明的代价是一个 owner 把自己降级为 maintainer——可逆的——也会被拒绝。它们位于用例中,规则在 domain/iam/tetos-de-rebaixamento.ts,而不在 RolesGuard 中,后者针对路由授权调用方,既看不到请求体(dto.role)也看不到目标;形式遵循 domain/actions/decide.ts(RN-154/RN-418),但没有它的结局,因为同步的人类调用没有可以落入的审批队列。RN-471 声明为错误的三段 OpenAPI 描述("o maior entre este e o que a pessoa já tem"、"inclui quem herda acesso do workspace")在这里被修正。声明为仍然可能:降级另一个 maintainer、自我晋升(不变,这里不开放)、workspace 层面的 POST,它没有任何上限,以及通过移除进行的自我降级——RemoveProjectMemberUseCase 没有获得上限,而"移除总是良性的"这一前提是错误的(当 workspace 的角色接住下降时它是良性的;接不住时它就是不可逆的自我降级)。两种移除情况都固定在测试中,包括开放的那种。web 有意不参与,原因是结构性的:界面能计算上限 2,却不能计算上限 1,因为 listProjectMembers 返回项目行的角色,而目标的 workspace 角色在客户端的触及范围之外——半个闸门会成为 RN-471 所警告的第二个角色来源;与此同时,拒绝会通过 #444 加入的 mensagemDaApi toast 显示出来
0156自我降级的上限延伸到另一扇门:移除自己在 project_members 中的那一行。ADR 0127 只在写入路径上设了上限,并书面把这个动作排除在外,把它固定在一个名字就记录了这个漏洞的测试中——"a remoção AINDA consegue rebaixar quem a chamou"——并预见了关闭它的代价(RemoveProjectMemberUseCase.execute 的签名)。这就是那个下一个人。移除不会抹去一个角色,而是替换有效角色:projectRole ?? workspaceRole(RN-471)改为由第二项解析,所以一个权限来自项目行、在 workspace 中是 viewer 的 maintainer 会不可挽回地降为 viewer——恢复需要 POST :projectId/members,而它要求刚刚放弃的 maintainer 本身;如果没有任何 workspace 角色,下降就是降到没有访问权限。规则是上限 2,不是新上限:ehAutoRebaixamento 比较今天的有效角色与之后的有效角色,从来不需要知道第二个是经由哪条路由到达的,所以 remocaoEhAutoRebaixamento 把 workspace 角色作为请求的角色委托给它,而不重新实现任何比较。它作为独立函数存在,只为另一个函数的签名无法表述的唯一情况——没有 workspace 角色的执行者,那里的"之后"是无,而这不是一个 Role。上限 1 有意不获得配对:owner 是 ROLE_ORDER 的顶端,所以移除 workspace 所有者的项目行只可能提升其有效角色——这实际上是撤销上限 1 所禁止创建的限制的唯一方式,在那里设上限只会拒绝良性的动作,并使 ADR 0127 生来要消除的状态永久化。表述没有阈值,与上限 2 一样,声明的代价也随之而来:从项目的 owner 自我移除为 workspace 的 maintainer 是可逆的,也会被拒绝——这是唯一一个原本通过、现在被拒绝的良性动作。消息是自有的,因为点击"移除"的人没有要求改变角色,应该得到机制的说明,而不是 add 的那句话。拒绝的方案:移除上的所有者上限、第二条比较规则、触碰 forProject、扩展到 workspace 范围(其 POST 仍然没有上限,其成员 @Delete 不存在)、阻止移除其他会下降的人,以及客户端闸门——MembersSection.handleRemove 已经在 toast 中显示 mensagemDaApi,只计算界面能算的那一半就是 ADR 0127 拒绝的半个闸门。关闭 BRB-001
0157自我变动的上限延伸到 WORKSPACE 的 upsert,而 ADR 0127 书面保留的自我晋升被视为漏洞并关闭。AddWorkspaceMemberUseCase 是一个十二行的转发,从不接收执行者,位于一条要求 owner 的路由上:与 project_members 同类的缺陷,高一个范围且更糟,因为这里没有更高的层级来接住下降,而且根本不存在成员的 @Delete(经测量;ADR 0156 曾用同一事实来推迟这件事)。一个把自己写成 viewer 的 owner 会失去整个 workspace——每个项目、按 RN-060 属于他的 LLM 开销,以及邀请能恢复他的人的能力本身——而撤销就是这同一条路由,要求刚刚放弃的 owner。上限不计算 owner 数量,依据 ADR 0127 的字面标准("enunciado como cláusula, não tem número para envelhecer"):计数会增加一次可能与写入不同步的读取,需要判断一个已经离开公司的第二个 owner 是否还算数,并让同样请求体的同一请求今天通过、明天被拒绝。最有力的论点是,这条子句已经产生了计数本应保证的不变量——workspace 永远不会剩下零个 owner,因为移除最后一个需要那个 owner 自己去做,而那正是被拒绝的动作。上限 1 在这里不获得配对,这经过了考虑而没有镜像:与移除(ADR 0156)不同,这个动作存在——一个 owner 可以降级另一个 owner。它被拒绝是因为上限 1 是关于层级倒置的规则,而 @RequireRole('owner') 已经让这个范围内的倒置不可能:能调用的人永远不会低于他所触及的人。加上它,再加上缺失的移除路由,会让 owner 成为一个无法通过 HTTP 离开的吸收态——离职、所有权轮换和错误邀请的修正都需要直接写数据库,这正是 ADR 0127 生来要消除的那类状态,只是符号相反地被重建。所以降级另一个 owner 保留,任何剩下的 owner 都可以通过同一条路由撤销。自我晋升在两条成员关系路由上都关闭。ADR 0127 曾把它记录为保留的能力("os tetos são sobre descer");在这里重新打开后,答案是两半是同一个动作——一个人独自决定自己拥有什么权限——而上面那一半是唯一会提升特权的,恰恰是那份 ADR 能断言它的上限从不会做的事。重写相同的角色仍然通过:幂等的 upsert 不是变动,拒绝它会破坏 seed.ts。比较不被复制:它变成一个分类器 autoMovimentoDoProprioPapel,返回方向而不是布尔值,因为调用方需要它来选择消息。ehAutoRebaixamento 作为这个分类器的一种读取方式保留下来——是有意的,而不是为了兼容:remocaoEhAutoRebaixamento 委托给它,扩大它会让移除也拒绝自我晋升,破坏 ADR 0156 点名保护的良性动作。四条消息,每个方向和每个范围各一条——尝试晋升自己的人不会读到降级的那句话,在 workspace 被拦下的人也不会被叫去找 maintainer。拒绝的方案:计算 owner 数量、这个范围内的所有者上限、创建 workspace 成员的 @Delete(一扇新门需要同样的上限外加对最后一个 owner 的判断)、扩大移除之门、连续第三份 ADR 触碰 forProject,以及客户端闸门——没有什么可构建的,因为 apps/web 中没有这条路由的调用方。声明的代价:一个合法需要 owner 的 maintainer 现在要依赖另一个人。关闭 BRB-002
0158信封开始说明是哪把主密钥封装了它,而这个标签从不决定它能否打开。运维手册的那一页自己就陈述了这个漏洞("O wrapped_dek guardado no banco não identifica qual chave o embrulhou"),由此产生两个缺陷:轮换的中间一步没有进度——中断它的人不知道停在哪里,"旧密钥上还有东西吗?"在 SQL 中没有答案,只能按打印出的计数器再跑一遍脚本——而且错误的密钥和损坏的 blob 落入同一个 catch,两个行动相反的诊断穿着一件通用失败的外衣。于是两张信封表都获得一个可空的 key_id,总是一起,因为它们共享信封和脚本,只改其中一张会留下一个查询只能回答一半的存量。值是密钥的指纹,HMAC-SHA256(derived key, label) 截断——它通过测量而非假定回答了活动留下的开放问题("não um hash da chave, isso dá a quem lê o banco um oráculo de verificação offline"):信封是 AES-256-GCM,它会认证,所以针对 wrapped_dek 测试候选口令本来就是一个完美的预言机,代价是一次 scrypt;针对指纹测试它代价相同。没有制造任何预言机,也没有增加任何变量——运维者不需要在任何地方输入指纹,它是已发布密钥的纯函数,这正是让"又一个要保持同步的元数据"这一旧异议对任意 id 成立、而对指纹不成立的原因。标签是可观测性,从不是权威,这正是此决定偏离显而易见方案的地方:decrypt 逐字节保持不变,rewrap 仍然通过尝试来判断"已经在当前密钥上"。用标签来跳过尝试曾被考虑,并因其特有的失败模式被拒绝——一行标为"当前"、而信封实际在旧密钥上的记录会被静默跳过,不计入重新封装的数量,第 3 步会让它永远不可读,把元数据的不一致变成秘密的丢失。所以标签在密码学决策之外服务于三件事:进度查询(IS DISTINCT FROM,从不是 <>,以便 NULL 计为待处理)、没有任何密钥能打开时的诊断,以及 rewrap 在新信封旁写下的戳记。没有回填:key_id 在下一次写入时产生,已有的行保持 NULL,表示*"在这一列之前写入",从不表示"在当前密钥上"*——声明的代价是,在已有安装上,这个查询只有在第一次轮换之后才开始有意义。api 在启动时打印当前的指纹,因为一个没人能与任何东西比较的列是无用的。拒绝的方案:KMS/HSM/按租户的密钥(ADR 0027 仍然有效)、key_id 上的索引,以及把诊断带进热读取路径
0173workspace 成员的移除诞生,由同一条子句设上限。DELETE /workspaces/:workspaceId/members/:userId(owner,204)终结了通过写数据库来办理离职,那是 ADR 0157 拒绝顺手打开的。上限 2 被继承而没有新规则——remocaoEhAutoRebaixamento,以 workspace 角色作为今天的有效角色,以无作为之后的角色,因为上面没有东西接住下降——所以移除自己总是 403,而同一条子句不计数地保护最后一个 owner:只有 owner 调用这条路由,而没人能移除自己,所以每次移除至少留下调用者。移除另一个 owner 是允许的;上限 1 仍然没有配对。移除在一个事务中级联(目标在该 workspace 中的 project_members 行、项目设备密钥及其在那里的 PAT),然后通过 RN-520 的路径按项目断开他的活跃 runner。声明为开放:workspaces.created_by 不变,所以移除创建者会让智能体继续花费创建者的 LLM 密钥(RN-058)。

LLM providers、模型与开销​

provider 的契约、模型目录与人工筛选、模型的 bindings、计量和预算。

#决策
0040仪表盘的卡片和摘要行只显示 USD(价格的原始货币,不涉及任何换算)——差异只限于这个界面,TokenMeter 的 default/live 继续是 R$+US$;按 workspace 的货币偏好加手动汇率被记录下来并推迟
0041基于 node:http 的 OpenAI 兼容基础层(不基于 SDK,以便有不活动超时),chunk 中带必填 code 的规范化错误,两层 capabilities,没有原生工具调用的智能体 binding 被拒绝,以及契约测试套件——契约拥有断言,harness 拥有方言
0042按 capability 同步目录,新发现的模型以停用状态进入,消失的模型被标记而不是删除;两个相互独立的可用性维度(owner 的人工筛选 × 远端的现实);降级时重新校验 capability 的级联;以及在 token_usage 中冻结价格,使旧成本可复现,并在 outbox 之外做只追加的审计
00439b 的六个 providers(NVIDIA NIM、Together、DeepInfra、Bitdeer、Vultr、OpenRouter)作为基础层之上的配置加入,每个都对照官方文档从零调查——capability 只有在证明后才声明,实现过程中有两个真实的决定被当场撤回;基础层唯一新增的钩子是只有 OpenRouter(hub)需要的 parseErrorFrame
0049按 workspace 的模型人工筛选:models.is_active 曾是整个安装的一列——workspace A 的 owner 开启一个模型,就会为 B 开启它,开销落到没有做决定的人的预算上。目录保持全局,因为名称、价格和 capabilities 是 provider 的事实,按 workspace 复制会对同一个模型产生 N 个事实,还会拆散 token_usage.model_id;只有决定迁移到 workspace_models。没有行就是关闭——没有第三种状态,RN-043 就是这样在没有任何同步可能覆盖的列的情况下存活下来;关闭是 UPDATE 而不是 DELETE,以免连同做决定的人一起删掉。选择器的路由挂在项目下(使用它的三个界面都已经有 projectId,没有一个有 workspace),isActive 离开 Model 实体:需要它的人使用一个只有手里有 workspace 时才存在的类型
0051从 provider 读取的 capability 侧面,以及作为 workspace 人工筛选的"用途"。同步从不向远端查询模态(supportsVision: local?.supportsVision ?? false),所以该列生来是 false,死时也是 false——OpenRouter 第一次真实同步的 338 个模型全都没有 vision,包括 provider 声明为多模态的 181 个。图像输入和输出变成不同的维度:读图的和画图的解决的是不同的问题。请求的另一半——"最适合代码/文档"——在任何目录中都不存在,从模型名称推导它就是穿着数据外衣的猜测(ADR 0041);所以它是运维者的意见,住在 workspace_models 中,与 ADR 0049 以来所有的人工筛选一样。两个维度不混合:标记用途不会开启模型(is_active 列的 DEFAULT 是 true,新行以显式的 false 诞生),界面从不写"不读图像",因为那里的 false 意味着"provider 没有声明"。视频被排除在外:九个 providers 中零个模型,一个永远不会匹配任何东西的过滤器
0063同一笔开销的两类受众:owner 的账单和成员的消耗。请求是一个按 OpenRouter activity 精神做的开销摘要标签页,而数据从来不是问题——自 0042 起 token_usage 就有 provider、模型、执行者、tokens 和冻结的价格;缺的是聚合(没有按模型、按 workspace 内项目、按会话或按人的任何聚合)。难点在于两条现有规则的冲突:按 RN-058,智能体花费的密钥是 OWNER 的,按 RN-060,这把密钥的账单属于他且只属于他——所以"我花了多少"字面上就是要另一个人账单的一部分。两个简单的出路都是错的:开放按凭据过滤的报告是从后门撤销 RN-060(回答仍然会谈论 provider 和密钥,把行加起来就重建了账单),而什么都不显示,恰恰让被禁止看到消耗的人就是消耗的人。决定把问题分开:owner 的问题按凭据(provider、密钥、账单),仍然要求 owner,整体复用而不是重写;成员的问题按执行者,以 tokens 和估算成本表示,不按 provider 或凭据拆分。执行者来自已认证的 token,没有可以写别人 id 的参数——"成员看不到另一个执行者的行"成为签名的属性,而不是有人可能忘记的检查;由测试证明,去掉过滤器的变异会杀死它。provider 维度在新聚合中不存在(sumGroupedBy 有 model/project/actor/session/day,没有别的):缺失正是阻止成员视图意外收到它的东西,同样的原因,两个提供同名模型的 providers 会落在一行。智能体不计入成员的账目,因为 token_usage 记录的是谁花了钱,而不是谁让花钱。没有图表库——两种形式,各一个序列,一个内联 <rect> 就够了——而每日序列从 api 稠密地返回,否则分布在三周内的三次调用会变成三根挤在一起的柱子。没有 migration:在 52.5 万行上测得,查询通过 seq scan 分别耗时 55 ms 和 38 ms,created_at 上的索引会把它们降到 32 ms 和 19 ms——收益被记录下来,索引以后再加
0064模型级联中的领域范围,以及全局的智能体 binding。请求:领域的 leads 及其子组共享一个当前模型,智能体可以偏离——可继承的默认值,用户已经做出的决定。级联(sessão > agente > projeto > workspace)在智能体和项目之间加入了 area,而回答放在哪里暴露了一个需要在编码前做决定的不一致:agent 的 binding 是全局的(scope_id = 纯 slug,自 Fase 9a 起有意忽略路由中的 :projectId),而 area 自 ADR 0053 起是按项目的——一个按项目的范围位于一个全局范围之上,会让同一个智能体只在存在领域的地方解析出不同的模型,行为取决于数据的偶然。两个互斥的出路:智能体 binding 变成按项目(改变现有行为),或领域放在智能体之下(与"可继承的默认值"相矛盾,因为今天几乎所有 binding 都是智能体的)。选择了第一个:agent 和 area 的 scope_id 变成复合的——`:<slug
0075LLMProvider 契约中的 embeddings。即将到来的 Chat RAG 需要向量,而产品不会生成它们:"embedding"一词只出现在一条注释中,在 bitdeer-provider.ts 的散文里。该操作以 embed? 加入,可选,处于与 listModels 相同的双边契约下(声明 capability 的实现它;不声明的不暴露它),以批量形式,因为索引一次接收 N 个 chunk——由此产生最重要的保证:每个输入一个向量或一个错误,从不是更短的列表,因为顺序是输入与向量之间唯一的联系,而部分响应事后无法检测。错误以 code 规范化后抛出,而不是变成 chunk,0041 的分类法不受影响:chunk 的理由是保留进行中回合的开销,而这里没有什么要保留。返回值声明的 dimensions 与返回的内容核对(而不是从目录复制——向量索引有固定的维度),声明 provider 说它使用的 model(别名会解析到带日期的版本,这正是出于冻结价格的理由进入计量的东西,RN-044),以及带有 RN-041 区分的 inputTokens/estimated。capability 是两层的,如 0041 所要求,但有一个需要点名的差异:工具调用是渐变的(没有工具的模型仍然能对话),而 embedding 是排斥的——nomic-embed-text 不回答问题,llama3.2 不返回向量,两个不相交的集合,这就是为什么这个问题放不进任何现有的 supports_* 列。provider:capabilities.embeddings,必填,理由与让 code 必填的相同。模型:目录中的 supportsEmbeddings/embeddingDimensions,可选,因为缺失意味着"provider 没说"——Ollama 是九个中唯一按模型发布这一点的(/api/tags 中的 capabilities: ["embedding"])。只有 ollama 声明为 true,通过针对真实守护进程 0.32.1 的执行证明(2 个输入 → 2 个 768 维向量,prompt_eval_count: 10),这次执行还产生了聊天模型返回 501 的测试。其他八个保持 false:七个因为缺乏证明(环境中没有密钥,唯一跑过的付费 smoke 是聊天的——在 hub 中,embedding 会路由到另一个 provider),而 Anthropic 是因为没有这个操作(它的文档让人使用第三方)。基础层的方言单独证明,契约测试套件在开启 capability 的情况下针对它再运行一次,这让在密钥存在时开启一个 provider 变得廉价。两个声明的裁剪:embedding 的计量还不存在,因为 token_usage.session_id 是 NOT NULL,而为仓库建索引不在会话中发生;models.supports_embeddings 列等待了另一波的 migration 名额
0076provider 重新成为开销的维度,而约束变成由类型承担。修订 0063,它曾用一句没有余地的话排除了这个维度("a ausência é estrutural… não há argumento a passar")——这个论证仍然正确,正因如此,由产品所有者决定的重新开放附带了书面的约束。0063 没有区分而使用中需要的东西:owner 无法在滑动窗口内看到钱从哪里流出(credential-spend 按 provider 回答,但按日历月且绑定于今天存在的密钥),人和智能体出现在同一个排名中。sumGroupedBy 获得 provider,owner 的报告获得 porProvider(RN-186),成员什么字段都没获得。约束改变的是形式而不是强度:有两个重载,携带 actor 的范围——成员视图唯一拥有的——只接受 SpendDimensionDoAtor,即 Exclude<SpendDimension, 'provider'>;带执行者请求 provider 无法编译(RN-187)。有意没有针对这种组合的 if,理由与 auto mode 的解析放在仓库而不是 decide.ts 中相同(RN-153/154):动态检查暗示动态保证,而那正是下一次重构会移除却不留下红色测试的东西。用 Exclude 而不是第二个手写的列表,因为平行的受限列表会悄无声息地过时。人和智能体变成 porOwner/porAgente,是用例中 porAtor 的划分而不是两次查询(RN-188)——0063 测得成本随 token_usage 的大小增长,而不是随请求的大小。0063 测量过并推迟的 token_usage(created_at) 索引在这里加入(migration 0044):52.5 万行时 55 ms → 32 ms,38 ms → 19 ms。不加缓和地声明的后果:保证从"无法表达"变成"无法编译",后者更弱——撑住代价的是两道独立的屏障,另一道是成员的路由根本没有维度参数,而 @ts-expect-error 覆盖第一道(如果屏障倒下,tsc 会让未使用的指令不通过,而不是让测试开始什么也不测)。界面被排除在外,已声明
0077按能力的模型排名,没有编造的评分。handoff 要求一张"Melhores modelos por capacidade"表格,每项能力带一个评分(代码 9.4、图像 9.1……),以及在 ModelPicker 中"当模型覆盖智能体要求的所有能力时"显示一个 ideal 徽章。调查发现两者要的是产品没有的同一份数据:评分是 mock 中虚构的(没有 provider 发布"代码质量",产品也没有任何指标测量它),而"智能体要求的能力"在领域中不存在——ModelsSection 本身早已因此把"Agente · capacidades"列改名为只是"Agente",再次承诺已被拒绝的东西会重新打开这个缺口。第二道结构性屏障独自就关闭了徽章:智能体 binding 的 ModelPicker 读取 GET /projects/:id/models(角色 viewer),而人工筛选(uses,ADR 0051)只存在于 ModelComCuradoria 中,来自一条仅限 maintainer 的路由——把两者连起来就是扩大 RBAC,是另一项产品决策。徽章不构建(在 ModelPicker.tsx 本身中记录);排名表格构建,带有两个真实的信号:"recomendado"/"alternativa"来自 workspace 已经标记的人工筛选(uses),按本项目中已有多少个智能体通过级联解析到该模型排序(不编造评分时最诚实的信号),成本作为平局决胜;没有人工筛选覆盖的能力显示缺口,从不隐藏这一行
0110关闭自 Fase 8 起开放的 backlog 项"按领域的预算"。agent_areas 获得 budget_micros/spent_micros,在同一行中镜像 max_parallel——没有新表,也不是通用的 budgets 表(它的 CHECK 强制项目/会话恰好二选一,为双边排斥而构建,不是为第三个范围)。它是在项目/会话预算之外叠加的第三个上限,由 CheckBudgetGateUseCase/RecordLlmUsageUseCase 独立检查和递增——明确不是 ADR 0064 的模型 binding 级联,两个机制只共享名字。只要执行者属于某个领域(由纯函数 areaDo 解析,没有领域的执行者不访问数据库),无论是否设了上限,spent_micros 都会累积;SetAreaBudgetUseCase 要求 maintainer 并接受 null 来清除上限
0114在 pnpm dev 的 bootstrap 中检测原生 Ollama:preflight.mjs 通过 HTTP 确认占用 OLLAMA_PORT 的(默认 11434,与原生安装的默认值相同)确实是 Ollama 守护进程,一次询问是否复用它(没有 TTY 时默认为是,因为菜单有意以 /dev/null 作为 stdin 运行命令),并通过 escreverEnv 把答案持久化到 .env(OLLAMA_MODE=host|container)——这是仓库中第一个就地写入 .env 而不只是读取它的东西。ollama/ollama-model-loader 放到 profiles: ["local-llm"] 之后,由 scripts/dev/perfil-ollama.sh 按命令切换;带 profile 的服务默认对 docker compose config 不可见,这迫使 detectarOllamaNativo 获得自己报告冲突的路径(通用的按端口循环会看不到一个不是 Ollama 的真正占用者),并迫使移除 api 的 depends_on: ollama(当一个不在 profile 中的服务依赖于一个在非活动 profile 中的服务时,Compose 会拒绝整个文件)。新的菜单项"Docker › Reconfigurar Ollama"按需忘掉这个决定
0115Hugging Face 的模型拉取获得一张小的自有表 huggingface_model_pull_requests,从不是 proposed_actions——经过追踪后被拒绝,因为该管道的 session_id NOT NULL、decide()/agent_autonomy 的策略解析以及 ActionType 的封闭词汇,全都围绕着一个由人类监督的智能体构建,而这里执行者已经就是人类,路由上有角色闸门,没有会话,也没有智能体参与。两步确认就是 pending_confirmation → confirmed 转换,而不是额外的标志——ConfirmModelPullUseCase 拒绝(409)运行两次,只有成功的确认才会在请求内同步调用 OllamaProvider.pullModel(api 没有自己的后台任务执行器;GET .../pull-requests/:id 是为不想保持这个连接打开的人准备的)。repoId 放在请求体中而不是路由中,因为 Hub 的 <publisher>/<model> 形式会破坏单段路径匹配(与 code.controller.ts 中文件路径已经使用的修正相同)。一份手工筛选、区分大小写的 Hub 官方发布者白名单(ADR 0042 的"curadoria manual sempre",应用于一个新的徽章)过滤默认的搜索结果;社区结果需要显式的选择加入,默认关闭,并附带持久的安全警告
0166hub 的路由偏好住在模型 binding 中,随它传递,并在计量中被冻结。在 v6.1.0 的一次安装上测得(AT-090):通过 OpenRouter 的某个模型 24 次调用中有 23 次由一个慢约四倍的上游提供,因为请求体从未说明如何选择(provider.sort)。由维护者决定:model_bindings.routing_preference(`price
0188provider 返回的真实成本成为计量的数字(AT-270,所有者 9月30日的决定):当响应带有它时(OpenRouter 的 usage.cost),它就是 token_usage.cost_micros、回合返回给 engine 的值、预算累加的值和 Gasto 标签页显示的值;目录的冻结价格(ADR 0042)只在响应什么都没说的地方继续作为数字。该行的价格变为隐含的(cost ÷ tokens,标记 price_implicit,与 ADR 0179 中 Jev 的同一列和同一函数),目录本应收取的放在旁边的 catalog_cost_micros 中,解析出的模型和 gen-… id 被记录下来。estimated 仍然指的是 TOKENS;BYOK 回落到目录。同一轮还记录方言报告的组成部分——cached_input_tokens 和 reasoning_tokens(AT-272),从不加到总数中,null 表示"没说"——medir-execucao.ts 报告输入中有多少是缓存读取
0198故事或业务规则的语义重复由带阈值的 embedding 提醒,从不拒绝(AT-171,所有者 10月1日的决定):条目写入之后,用 RAG 的 RagEmbeddingService 把它的标题与项目中同类型的标题比较,余弦等于或高于 0.80——一个起点,未校准,因为环境无法记录真实向量——在工具结果中向智能体返回一条提醒,并在日志中写入 backlog.semantic_duplicate_warned。没有 embedding provider 时检查被跳过并说明原因。它的开销作为 token_usage 的单独一行计量(执行者 system/duplicata-semantica)——ADR 0075 裁剪的一个例外,该裁剪对索引和搜索仍然有效。完全相同的重复仍然被拒绝(RN-080/081)

RAG、知识图谱与工件​

chunks 和混合搜索、对它的测量、Neo4j 图谱,以及从事件日志投影出的工件。

#决策
0079供 Chat RAG 使用的 chunks 表:vector(768) 和 tsvector 在同一行(不是两张表 JOIN),HNSW 索引(vector_cosine_ops,不是 IVFFlat——表生来为空)和 GIN,三个诚实的范围(docs/adr/session),由 CHECK 互斥。migration 0045 自己带上 CREATE EXTENSION IF NOT EXISTS vector,因为 docker/postgres/init.sql 只在卷首次初始化时运行——因此它诞生于 breaking/ 分支:创建扩展可能需要应用角色在生产中没有的权限
0080混合搜索:权重、阈值以及什么是引用。docs/adr 从项目自己的 GIT 仓库建立索引(通过 ReadProjectCodeUseCase,与 Code 标签页相同的凭据/闸门/路径检查);session 只索引 chat.message/agent.response(事件日志的其余部分是机制,不是可引用的知识)。按段落/标题分块,1200 个字符,150 个字符重叠——有文档记录的起点,未用真实数据校准。当 embedding provider(固定:ollama/nomic-embed-text,与 RN-222 相同维度)不响应时,管道把 chunks 不带向量地写入,而不是整体失败——search_vector 是 GENERATED ALWAYS AS,不依赖任何 provider——并在报告中声明缺口,从不假装索引完整。混合搜索是两个独立查询(searchByVector/searchByLexicalQuery,各自用为它建的索引),通过加权求和融合(向量 0.6,词法 0.4——归一化的 ts_rank 与余弦的尺度天生不可比),并在阈值(0.2)处截断;权重和阈值是可调的起点,不是科学。引用是 chunkId/content/分开的分数(信号没找到时为 null 而不是零)/origin,后者是按 kind 区分的联合(file 带 sourcePath/headingPath,或 session 带 sessionId/eventId)。重建索引总是手动的(POST .../rag/reindex,幂等的完整重建)——没有按 push 或按会话关闭的 watcher
0082Chat RAG 成为自己的标签页(key: 'rag'),不重命名 sessions。0080 让管道和三条路由准备就绪;0078/RN-202 曾推迟"Chat RAG"标签,因为能力不存在——现在存在了,问题只是它住在哪里。sessions 是与已激活智能体的对话(每个回合花费 owner 的密钥,RN-058);RAG 是在已建好的索引上搜索,中间没有任何智能体,本质上只读——把两者合并会迫使同一个对话流表达结构上不同的东西,所以答案是一个新标签页(ordem: 28,在 Código 和 Backlog 之间),而不是重命名现有的。界面使用 0080 的三个契约而不猜测形状(searchRag/getRagCoverage/reindexRag,类型 1:1 镜像),并显示后端早已声明却没有界面读取的两种诚实降级:vectorAvailable: false 变成可见的警告(RN-252),覆盖率从不编造"X 分钟前重建索引"——响应不携带这个数据。session 来源的引用通过复用 useNavigate + highlightEvent 导航到确切的事件,这正是 Psicólogo 证据标签已经使用的同一机制(RN-253);file 来源把路径/标题显示为文本,没有链接——Código 标签页今天没有按路径的深层链接,构建它超出这条前线的范围。重建索引按钮在客户端只对 owner/maintainer 显示,镜像路由已经应用的 maintainer 标准(RN-238/RN-254)——保证者仍然是 api
0099Neo4j 作为知识图谱加入——从事件日志派生的记忆,从不是事实来源——用于版本化的 prompt 模板(PromptTemplate/PromptVersion)和关系记忆(交互、带证据的 Psicólogo 假设、Anamnese 的画像、handoffs)。pgvector 仍然是 chunks 的向量索引——图谱不保存 embedding。驱动 neo4j-driver 在 API 中(不在 engine 中):API 已经拥有所有持久化和现有的 RAG,engine 一如既往地通过带 service token 的内部 HTTP 使用它。GraphStore.onModuleInit 从不抛出——三个环境变量在生产中必填,在生产之外可选,不可用变成 GraphUnavailableError/503,从不是原始的 500。模式明确受仓库 ErickWendel/neo4j-ai-experiments 启发(图谱作为智能体的记忆 + prompts 作为与代码分离的文件)
0100为 engine 的智能体新增工具 rag_search——关闭现有 RAG(pgvector,混合搜索)最大的空缺:从没有智能体查询过它,只有 web 标签页"Chat RAG"。调用 POST /internal/rag/search(复用 HybridSearchUseCase 而不复制逻辑),把命中格式化为带引用(path + 片段)的形式,降级(degraded: true)总是在文本开头可见。自有的上限(RN-150):top_k 限制为 10,16 KiB 的字节上限。ollama-model-loader(新的一次性服务,dev 和 prod)修复了另一个真实的 bug:nomic-embed-text 从未被自动拉取,RAG 在任何干净的环境中都会静默降级为仅词法搜索
0101对图谱基础的使用:InstructionFiles 获得 :graph 来源(优先级 db > graph > dir > root,复用 ETS 缓存),不改变任何 GenServer 的契约——ux-designer、Psicólogo 和 Anamnese 开始在内联文本之前尝试从图谱解析模板,内联文本变成回退。两个独立的标志(graph_templates_enabled?、graph_instruction_templates_enabled?)是有意的——单个开关会在并行的前线之间与相反的默认值冲突。Psicólogo/Anamnese 通过 rag_search 把"最近的"/"时间窗口"与相关的片段组合(从不替代,总是叠加——没有命中时,行为与之前完全相同)。图谱自己写入:GraphProjector 消费第二条 outbox 线(aggregateType: 'graph_projection',从不被 engine 的过滤器匹配),并调用已有的写入用例——engine 从不直接写入图谱,保持事件日志为唯一的事实来源
0113把用户自己机器上的一个本地文件夹作为只读参考资料附加——不使用 CLI brabo-runner,明确区别于需要真实主机路径的 execution_mode: runner(ADR 0104)。浏览器读取文件的内容(File.text())和相对路径(File.webkitRelativePath)——从不读取主机的绝对路径,因为 File API 不向任何网站暴露它;没有要经由 Runner(ADR 0107)路由的路径,也没有要像 ADR 0072 的守卫那样校验的路径,因为根本没有路径。chunks.scope 获得第四个值 'local'(migration 0052,叠加式的 ALTER TYPE,零新 CHECK——现有约束已经按"是不是 session"划分),复用整个 RAG 管道(分块、embedding、混合搜索、引用),而不是一个平行的机制。一次性上传:聚合上限(文件数量、字节总和)拒绝(400)整批,而不是静默截断,单个过大或无法识别的文件只是被跳过;重新发送是唯一的重新同步机制,有意从不与通用的"Reindexar agora"挂钩(它从服务器可以重访的来源重新读取——这个范围没有任何来源)
0129RAG 的混合搜索终于留下痕迹——但作为表(rag_searches/rag_feedback),而不是会话事件:session_events.session_id 是 NOT NULL,而来自 RAG 标签页的搜索是没有会话的项目搜索,所以事件日志会静默地丢弃恰恰是人类评判过的那些搜索(与在 ADR 0075 中裁掉 embedding 计量的是同一类问题)。权重在每一行中冻结,就像计量中的冻结价格(ADR 0042),以免之后的校准悄悄改写之前每次测量的含义。rag.search/rag.feedback 只在有会话时存在,它们是叙述,从不是测量的来源。写入遥测从不拖垮搜索——也从不静默失败:日志中来源为 infra,以及一个显式的 searchId: null。这里什么都不校准;这一步只做埋点。
0132轮到 RAG 获得 golden-set:与 QA Automação 智能体的(ADR 0123)形式相同——一个永久排除的 ExUnit 标签(:golden_set_rag),一个以模型为键的棘轮下限,一个由 System.cmd 调用的 seed 脚本——有意复制而不是重新推导,只有领域强制的一个调整:RAG 的混合搜索完全在 api 内部运行(HybridSearchUseCase),路径上任何地方都没有聊天 LLM 的判断,所以 seed-golden-set-rag.ts 既做准备也运行搜索本身,Elixir 一侧只调用它并应用下限——用 Elixir 重新实现搜索会测试第二个实现,而从不是真正的那个。17 个问题是根据真实的 RN/ADR 组合出来的,而不是提取的——事先核实过:在这个独立开发的仓库中 gh issue list 返回"No Issues",所以没有真正的问题语料可以挖掘,这一点被如实说出而不是伪造。但被索引的语料是真实的:本仓库 docs/ 中精选的 22 个文件,逐字复制,并由任何真实项目都使用的同一个 IndexProjectDocsUseCase 索引——是精选的,而不是 130+ 份 ADR 的整棵树,出于手动执行时 embedding 的成本。命中标准——文件路径,从不是确切的 chunk,在前 5 名内,从不是第 1 位——是一个经过测试的纯函数(golden-set-criterio.ts),因为分块恰恰是整个计划存在就是为了能自由修订的参数,而 Chat RAG 每个回答引用多个 chunk,而不只是第一个。docs/gates.yml 中新的 gate rag-acertivo,warn(没有运行真实 LLM 的 CI),证据 tipo: teste——证明这个 gate 的是 golden-set 本身,而不是一个事件。真实测量,两次,确定性的:17/17 个问题都在前 5 名内找到了文件,两次执行都是,针对带 nomic-embed-text 的真实 Ollama 和一个隔离的 Postgres
0138可测量 RAG 计划的第 3 步:RAG 的 golden-set(ADR 0132)开始真正在 CI 中运行,在一个新的 .github/workflows/golden-set-rag.yml 中——但是定时的(每晚 cron + workflow_dispatch),从不在 pull_request 上,这是产品所有者根据一次执行约 3-6 分钟的代价(下载 nomic-embed-text 加上索引和 17 次搜索)选择的节奏。rag-acertivo 的 severidade 仍然是 warn——是有意的,现在是出于节奏而不是技术原因,因为定时任务不会阻挡合并。使它在 QA 的 golden-set(ADR 0123)仍不可行之处变得可行的原因:它只调用 EMBEDDING 模型(CPU,确定性,没有采样),从不调用做判断的聊天模型——没有 GPU 的 ubuntu-latest 就够了。一个简化了整个 job 的发现:seed-golden-set-rag.ts 从不设置 NODE_ENV,所以 api 在生产中会拒绝的四个秘密(RN-114)毫无抱怨地采用 dev 的字面回退值——与 docker/smoke.sh 不同,这里什么都不需要 openssl rand。Ollama 服务的健康检查运行 ollama list 而不是 curl(镜像保证有前者,不保证后者),完全复用 docker/ollama/pull-models.sh 已经通过一次真实下载验证过的等待模式。QA 的 golden-set 从这份 ADR 得到零改动——仍然完全手动,仍然是一个已声明的 TODO(humano)
0148智能体产生的每一个工件——Criativo 的 brief、PO 的业务规则、Arquiteto 的 C4 图和模块路由、六个对话智能体的决策——都只存在于事件日志中;emit_artifact.ex 白纸黑字地说,"não há tabela de artefatos"。对来源来说这是正确的决定,这里不触碰它。问题在于这份记忆到不了任何一个人会打开的地方:它只能在产品的会话时间线中一行一行地读,而在编辑器中打开项目文件夹的人——代码将会在那里——找不到任何产生它的推理。推迟 git(推迟的创建(自己的 PR))扩大了这个洞:从创建到发给 Arquiteto 的 handoff 之间,项目没有任何仓库,所以产品积累的一切都留在数据库行中,而用户的文件夹是空的。于是 docs/ 在 ADR 0101 赋予图谱的严格意义上成为事件日志的投影——来源仍然是事件,文件夹可以整个删除并重建,产品中没有任何东西从它读取来做任何决定;它的存在是为了让人打开,其余一切都由此而来。机制逐件复制 GraphProjector(带 .unref() 的自有 poller、供测试用的公开 drainOnce()、draining 标志、50 条一批、成功之后才 markProcessed)——不是代码复用,而是设计复用,因为两者写入的目标毫无共同之处,复制形式是让懂一个的人不用重读就能理解另一个的原因。自有的 aggregate_type(artifact_projection),理由与图谱的那个已经记录的相同:Engine.Outbox.Drain 只消费 session/task/container,并每约 2s 标记 'session' 行,所以共用它就是与 engine 赛跑并且几乎总是输——有一个有意的差别,aggregateId 是项目而不是会话,为投影器省去每个工件一次查询,而用例手里本来就有这个值。一份允许列表而不是禁止列表,以便新类型的工件诞生在投影之外,而不是不加提醒地出现在用户阅读的文件夹中;qa_verdict、secops_verdict、task_blocked 和 infra_delegation_files 作为运营结果而不是产品文档被排除在外——每个任务一个的 gate 裁决会让 docs/qa-lead/ 塞满没人打开的文件,并让文件夹失去使它有用的那个属性:一眼就能看完。布局是 docs/<agent>/,所以浏览就能回答"Arquiteto 产出了什么?"而不用打开任何东西;四种版本化的类型覆盖一个文件(文件夹显示当前的,历史完整保留在事件日志中——按版本投影会产生 c4-diagram-v1.md、c4-diagram-v2.md,让读者去找哪个有效,这正是按 version 归约要省掉的),而只追加的类型各自获得自己的文件,带着事件的 seq,因为没有它,两个同类型同标题的工件会冲突,第二个会抹掉第一个——这是这个投影丢失来源所拥有信息的唯一方式。在 LLM 写的标题与文件系统之间有两道屏障,第二道并不冗余:slugDeArquivo 从一个封闭的字母表派生名称(没有任何返回路径能让分隔符存活,空则得到 null,因为从无派生的名称会与所有其他名称冲突),FsArtifactFileStore 在打开之前用 relative() 再检查一次——两者回答不同的问题,"我派生什么名称"和"我即将打开什么",只信任第一道会让这次写入的安全依赖于 slug 的任何未来改动都永不放过分隔符,恰恰是 ADR 0130 原则上拒绝的那种约束。在 runner 模式下,文件夹被转向被管理的根目录,与 permissions.json 完全一样,出于同样的物理原因(api 从自己的容器内部写入,runner 项目有意没有 bind-mount,而 runner 的通道传送的是命令,没有写入文件内容的原语)——声明的代价:在那里,docs/ 不在代码旁边。写入不经过 proposed_action,这不是遗漏:那个管道是智能体请求行动的每个外部效果的来源,而这里没有智能体请求任何东西——工件已经发出,已经在事件日志中;让它经过审批会让队列塞满每个工件一个的例行决定,并侵蚀赋予那些真正是外部效果的动作上的点击以意义的那道上限本身
0196Anamnese 被重新开启,带有修正(AT-277,所有者 10月1日的决定)。一轮在没有合格对象时不运行——项目的有效成员(projectRole ?? workspaceRole,RN-471;创建项目不会在 project_members 中写入行,这就是为什么 9月29日真实使用中的每一轮都付费让模型得出"nenhum membro elegível"的结论)、不在 opt-out 中、在窗口内有自己的交互:没有 LLM 调用,没有 RAG 搜索,原因在 engine 日志中点名,anamnese.run_skipped 只在手动请求的那一轮出现。被会话所属的人接受的 Psicólogo 假设成为图谱中的一个画像事实——由同一个 GraphEventTranslator 从 psychologist.hypothesis_accepted 翻译而来,所以 grafo:reprojetar 能重建它——并到达与这个人对话的智能体:api 通过 QueryUserContextUseCase 读取它(按项目限定范围,最近 5 条,旁边附总数),engine 在作者的回合中把它作为临时的系统消息加入,走与回应语言相同的路径。第三方的接受和拒绝只被记录。ANAMNESE_ENABLED 回到默认值 true;Psicólogo 仍然暂停
0200模块之间的契约成为 Arquiteto 的版本化工件,dev agents 读取它而不是另一个模块的 worktree(AT-276,所有者 10月1日的决定)。artifact.module_contracts,无表,当前版本取代之前的:按模块,只包含它暴露的(funcao/rota/evento/dado + 签名,1–40 项);它消费的内容在读取时从当前 module_map 的 dependsOn 推导。一个单独的工件,从不是 module_map 的字段。Arquiteto 获得 declare_module_contracts;dev agents 获得 listar_contratos_de_modulos(无参数,按项目限定范围,自身模块及其依赖完整列出,上限 120 项),它们的 kickoff 告诉它们在那里读取接口;没有契约时,report_blocked 点名缺少什么
0205Anamnese 视为智能体偏差的东西——一个循环、一个被用户取消的回合、一条用户修复智能体失败的消息——成为产品改进的信号(AT-356 第 2 部分,所有者 10月2日的决定):持久事件 anamnese.agent_deviation,由新工具 report_agent_deviation 写入,与用户画像分开,并且不含个人数据(只有智能体、封闭词汇的偏差类型和按 eventId 的证据;没有自由文本)。它不投影到用户的图谱中;它从事件日志中读取,没有新界面

Web 界面与设计系统​

界面、导航、主题,以及 web 应用中最大文件的结构。

#决策
0036忠实于已批准 mock 的认证界面:设计系统的三种字体自托管(CSP 阻止了 CDN,生产中字体回落到系统字体)、烘焙进工件的 tag 版本、由 token 计算的对比度而不是由 axe 测量,以及与 mock 的六处有意差异
0060Código 标签页的代码读取界面,受约束且有预算。26a 把 listTree 和 getPullRequestDiff 放进契约,并安装了让没有使用方的操作不通过的检查;这份 ADR 就是使用方,而决定写它迫使人解决此前任何路由都没有面对过的三件事,因为没有一条路由从客户端接收文件路径。搜索放在哪里:契约之外。三个 providers 都没有它——GitHub 和 GitLab 有平台级的代码搜索,语义各自不同,LocalGitProvider 是一个 bare 仓库——所以声明它要么是第 13 个操作而在 local 上 capability 为 false(一个在某个 provider 上消失的标签页),要么就是把某个平台的词汇泄漏到恰恰为阻止这一点而存在的规范化契约中;它在应用层面组合,基于 listTree + getFileContent。它能花多少:三项预算(遍历的目录、打开的文件、返回的匹配)加上一个短 TTL 缓存,因为它是唯一一个成本随仓库大小而不是随请求大小增长的读取,而付钱的是 OWNER 的凭据(RN-058/RN-082)——没有上限,它就是仪表盘每分钟 3,824 次请求的同一类失败,429 变成空白页。广度优先遍历:中途被截断时,它交付浅层的文件,那正是人们通常寻找的地方。路径如何被约束:一个唯一的函数,在 RN-092 的文件中,复用 0055 的范围原语——并返回规范化后的路径,因为返回原始路径会允许检查一个字符串却发送另一个。向量不是读错文件:在远程 provider 中,路径会变成 API URL 的一个片段,一个 .. 会拿着 owner 的 token 切换端点。CodeQL 会继续指出它,代价是 PÓS-FASE 15 中已经接受的那个——复制检查会让告警沉默,却制造四份总有一天会分歧的副本。四条路由都是 GET/role:viewer,控制器没有写动词:读取不是外部效果,不会变成 proposed_action(会让队列塞满噪音),当写入到来时它将以 proposed_action 的形式诞生——没有动词正是让这条边界可验证而不只是一个意图的原因
0074可达的主题,以及 CSP 下的 boot。浅色主题存在于 design/tokens.css 中,由一致性测试测量,在 design/README.md 中被引用——而 apps/web 中没有任何东西写 data-theme:看到它的唯一方式是在 DevTools 中输入这个属性。两个对比度测试知道这一点,并且在持续期间是对的(其中一个用 expect 断言浅色主题的三对不通过,"registro do que se herda, não garantia do que se renderiza");有了主题按钮,只测量主主题就会隐藏一半的可见界面。boot 是一个文件(public/theme-boot.js),而不是内联脚本:镜像在 script-src 'self' 下提供(ADR 0058),内联在 pnpm dev:web 中能工作,在生产中会被阻止——这是 0036 的失败换了个主语,因为 handoff 恰恰要求内联。同步且在 bundle 之前,否则浅色用户每次加载都会看到深色闪烁;纯 ES5,因为 vite 的 public/ 不经过构建,由测试锁定;抛出异常的 localStorage 降级为"绘制但不持久化",未知值回落到默认值,而不是变成 CSS 不认识的 data-theme。浅色主题的六个 token 改变了值,因为主题最苛刻的背景是纸色的 --code-bg:--accent 3.56 → 4.81,--warning 3.15 → 4.98,--success 3.89 → 5.12,--violet 4.16 → 4.95,--text-muted 2.76 → 5.17(在 --surface-2 上为 2.40:1 时它连 3:1 的下限都过不了——那是缺陷,不是债务),--accent-hover 跟着低一档。记录的副作用:在深色中是已知债务的五对在浅色中通过 4.5:1,而"设计系统的已知例外"(--accent 上的 --on-accent,3.20:1)在浅色中消失,因为测试注释描述的修正——加深到 --terracota-500——正是浅色开始做的事。深色没有改变一个值。语法调色板变成 handoff 的八个角色,前缀 --syntax-*,handoff 的八个值中有五个因测量被拒绝(--syn-cm 在浅色中 2.32:1,--syn-kw 4.34,--syn-str 4.20,--syn-fn 4.14,--syn-op 4.00):handoff 不通过的地方以测得的数字为准,与 0036 同一把尺子。--font-display/--shadow-modal 和 --r-md/--r-lg/--r-pill 作为别名加入,从不重命名——而 --r-sm(7px)不是 --radius-sm(4px)的同义词
0078界面框架,以及有意偏离 handoff 的标签页注册表。handoff 的"界面框架"检查清单(60px 的 header、带 box-shadow: inset 0 -2px 0 var(--accent) 的标签栏、横向滚动、最大宽度 960–1040px 的内容容器)在 ProjectPage.tsx 中有四个缺陷,四个都被修正——--header-h 变成 min-height 而不是 height,因为项目的头部(身份 + 紧凑的 TokenMeter)比 handoff 中 6 个内部界面的通用头部更丰富,强制 60px 会切掉预算警告,这是 RN-088 禁止的同一类错误。需要决定而不只是 CSS 的部分:handoff 列出 7 个标签页,而注册表(project-tabs.ts)有 10 个。executores(RN-121)、backlog(RN-048)和 insights 诞生于 handoff 之后,有真实数据和自己的 RN,它们保留——handoff 是视觉忠实度的参考,不是产品标签页数量的上限(RN-203)。sessions 键继续标为"Chat",从不像最新的 handoff 要求的那样标为"Chat RAG"(RN-202):"Chat RAG"描述的是一个不存在的功能——没有索引管道,没有引用 UI,只有契约的基础(embeddings,0075)——今天重命名标签页会在能力存在之前宣布它,这正是 0042 对目录模型已经拒绝的错误。原本作为 ProjectPage.module.css 中后代 CSS 覆盖而存在的标签栏间距值——自 FASE 16 起已声明的待办——迁移到 Tabs.module.css,它现在有同一个所有者。导出的形式都没有改变(AbaDoProjeto、ABAS_DO_PROJETO);只有 code 的标签从"Code"变成了"Código"
0097仪表盘的"N 个智能体在线"(RN-409)直接读取 engine.dev_agent_states——按 workspace 批量的原始 SQL,在已经汇总 RN-090 读模型十一次查询的同一个 Promise.all 中——而不是在 engine 中暴露一条新的内部 HTTP 路由。api 和 engine 共享同一个物理数据库,只以 Postgres schema 区分("engine" 对 public),并且已有跨 schema 读取的先例(apps/api/scripts/medir-execucao.ts 已经读取 engine.oban_peers)——这一决定把该模式从手动脚本提升为经过测试的生产代码,并在 test/support/global-setup.ts 中带有 engine 表的最小且已声明的 fixture。接受的后果:查询假定运维产品的人同时迁移两侧(db:migrate 和 engine:migrate),否则会响亮/可见地失败——没有隐藏错误的 try/catch
0116apps/web 的 ActionType 不再手工复制——现在由 openapi-typescript 从 docs/reference/openapi.json(已经是带漂移检查的工件)生成,复用同一道新鲜度闸门而不是复制它。修正了一个在生产中已经与 apps/api/src/domain/actions/decide.ts 分歧过两次的副本(Gitflow bootstrap 的三种类型弄坏了 ApprovalCard;然后是 parallelize/raise_max_parallel),两次编译器都没注意到,因为 apps/api 不是 apps/web 的依赖。只有 ActionType 迁移——api-types.ts 其余约 1600 行继续手写,一次一个类型,而不是大规模重新生成。aprovacoes.test.ts(FASE 19)保留:穷尽性现在也由编译器通过 Record<ActionType, ...> 核对,但测试中关于内容的断言(句子的质量,而不只是存在)不是类型的属性
0122apps/web/src/routes/SessionPage.tsx——3 807 行,169 KiB,25 个测试文件导入它,处于活跃变动中(50 次提交,此前零次提取尝试)——事先声明分成 5 个 PR 的完整拆分,使每个 PR 执行它而不是重新决定它:时间线/回合的纯 helpers → lib/session-timeline.ts(PR 1),StorySlide(PR 2),StructuredQuestionCard + permiteOutra(PR 3),backlog 树的 helpers + ItemDeBacklog + ContextAside(PR 4),一个 hook useSessionReadiness——唯一不是文件移动的,排在最后(PR 5)。SessionPage.module.css 今天恰好只有一个导入者,所以 PR 2–4 的新兄弟文件可以安全地直接导入它——不需要强行拆分 CSS。明确不在五个 PR 之内的:回合通道的状态集合(turnoViaCanal/statusAgent/pensandoVisivel/atividadeDoTurno,由四个不同的回合入口 handler 读写,即代码注释中的"UM lugar que termina um turno")以及 ProjectSettingsTab.tsx(单独的范围)。每个 PR 的验收标准都是同样的 25 个 SessionPage.*.test.tsx 文件不经编辑而通过
0124ADR 0122 推迟的回合通道状态集合("um ADR próprio, depois, com numeração separada")进入 useTurnoDoAgente(apps/web/src/lib/session-turno.ts):streaming/streamingText/streamingAgent/turnoViaCanal/statusAgent/pensandoVisivel/atividadeDoTurno/optimisticUser、通道效果 connectSessionHeartbeat(核实为 100% 回合生命周期机制),以及 iniciarTurnoDoAgente/finalizarTurnoDoAgente/cancelarTurnoOtimista。逐行重读发现 ADR 0122 自己的计数已经过时——点名了四个回合入口 handler,实际存在七个写入入口点,包括 handleReturnStory/RN-174,那里从未提到——并依托于一个之前的机械去重 PR(它自己也没有 ADR),该 PR 先让三个重复的内联分支调用已有的 iniciarTurnoDoAgente(它在那里获得了 { comStatus }),并引入了 cancelarTurnoOtimista。这个函数被构建为只覆盖文件中发现的五个形状不同的失败重置块中的两个(handleReadiness/handleArchitectureReadiness 相同的 4 字段块)——不覆盖 handleAcceptHandoff 的 2 字段块(它的 kickoff 是异步的 GenServer.cast,所以它从不设置另外两个字段;把它与 cancelarTurnoOtimista 耦合会重复 ADR 0122 已经点名的那种静默耦合陷阱),也不覆盖 handleSend 的两种不同形式(optimisticUser 属于 handleSend 自己的生命周期,而不属于设置)。SessionPage.tsx 从 2 681 行变为 2 479 行;hook 以对象形式返回(13 个字段,按位置解构因不可读被拒绝),保持每个调用点的变量名不变。专门关闭 ADR 0122"Explicitamente fora de escopo"一节中的回合通道项——ProjectSettingsTab.tsx,这条债务线上的另一个文件,保持不动且范围分开
0125apps/web/src/routes/ProjectSettingsTab.tsx——2 532 行,89.9 KiB,ADR 0122 和 ADR 0124 点名并有意推迟的技术债务线上最后开放的一半——拆成 apps/web/src/routes/settings/ 下每节一个文件:17 个节加上一个含两个符号的 settings/shared.ts。一个 PR,而不是像 0122 那样五个,原因是结构性的而不是品味:父组件什么都不持有(17 个 JSX 子元素,没有 hook,没有 query,没有 t,没有 queryClient,没有角色检查),没有任何一节接收多于 {projectId} 的东西(MatrixSection/CredentialsSection 不接收任何 prop),17 个中有 11 个已经导出——所以每个文件都是一个连续的块,逐字节相同,迫使 0122 采用五个 PR 形式的活跃变动论点不适用。ProjectSettingsTab.tsx 保留在原路径作为入口 + barrel(2 532 → 77 行),这是承重的,不是装饰:ProjectSettingsTab.test.tsx 从该路径导入 11 个名称,ProjectPage.test.tsx 和 project-tabs.test.tsx 按路径用 vi.mock 模拟它,ProficiencySection.test.tsx 渲染 barrel 本身——唯一一次挂载全部 17 个的测试。与 ADR 0121 相同的 barrel 形式,但使用具名导出而不是 export *:一个节是带有某种 prop 形状的组件,显式列表能让编译器发现某个节不再与调用点匹配。helpers 的位置是机械的并经 grep 核实——一个调用方意味着调用方的文件(十二个中的十个),两个或以上意味着 shared.ts(ORIGIN_TONE、formatarCustoMicros)——这纠正了一个顺带的预期:iniciaisDe/gradienteDe 只属于 MembersSection,不属于 ProficiencySection。6 个私有节在自己的模块中获得 export(机械的;barrel 需要导入它们),但不由 barrel 重新导出,所以公共界面的形式逐字节相同——扩大它会是一个声称没有任何可观察变化的 PR 中的可观察变化。ProjectSettingsTab.module.css 仍然是一个共享样式表,现在有 15 个导入者,与 0122 对 SessionPage.module.css 给出的答案相同。有意保留:useTranslation('settings') 约 14 次、useQueryClient() 11 次、useToast() 10 次、useQuery(['project', projectId]) 7 次——React Query 会对它们去重,把它们提到父组件或 context 中会是伪装成清理的设计改动。关闭 docs/architecture.md 中声明的最后一项开放债务;落在这些同样文件中的四项 UI 改进是同一计划的后续 PR,明确不在本 PR 之内
0126项目页面的两级横向标签栏(components/ui/GroupedTabs.tsx)变成一个垂直导轨(apps/web/src/routes/ProjectRail.tsx),三个分组同时展开。设计评审的三个论点中只有一个经核实后成立,也是唯一记录的理由:为约 6 个设计的栏中放 12 个标签页无法扩展。另外两个是错的,写下来以免有人再争论——第二级是内联的一行,从不是覆盖层(GroupedTabs.tsx:143),而关闭的分组确实会累加子项的计数(somarContagens,:33,在 :141 中测试)。几何借自 routes/code/CodeShell.module.css——仓库中唯一画出了活动状态的导轨(--surface-1 条带、右侧分隔线、--radius-sm 的 32px 条目、着色的活动项 color-mix(… 12%))——从不借自侧边栏的 .trilha,它没有活动项也没有键盘支持。一处声明的偏离:宽度 180px 而不是 48px,因为这个导轨带有标签和分组标题,这是保持三个分组展开的机制。五个计数器保持分开,没有分组累加它们;徽章仍然是实心的 --accent/--on-accent,而不是 handoff 的 18% 着色,原因是 ADR 0074 的对比度债务。onKeyDownDaLinha 经过测试的键盘契约被移植,换了轴(ArrowDown/ArrowUp/Home/End,循环)并得到改进——按键的 refs 而不是对 DOM 的按位置 [role="tab"] 查询,后者之所以存在只是因为 Tabs 原语不暴露任何 ref。GroupedTabs 与其测试一起被删除;Tabs 保留,尽管失去了唯一的调用方,因为 ds-bundle/_ds_sync.json 把它发布为 components/primitivas/Tabs,一个没有使用方的 DS 原语是库存,不是死代码。修订 RN-201:Código 标签页的自动收起(AutoCollapseContext/useAutoCollapseSidebar)被移除,因为在 Shell 图标导轨旁边放一个永久的项目导轨,会在最重的标签页上出现两个相邻的垂直导轨。接受的代价,经过测量:第一个代码字符之前有 492px 的框架(264 侧边栏 + 180 导轨 + 48 代码导轨),之前约 110px。换来的是收起重新成为用户的决定——仍然是手动的,仍然持久化;双导轨在手动收起下依然存在,这一点被大声说出,而不是假装不存在
0176apps/web/src/routes/SessionPage.tsx 通过十个堆叠且机械的 PR 降到 1,000 行以下,然后才在 CI 中获得上限(AT-138,BRB-015,维护者的决定:"sim, e trava depois")。由 ADR 0122/0124 在 2,479 行关闭的债务线,是靠一个没有人去量的数字关闭的:在 018b8cd24c 的 origin/dev 上,该文件有 2,637 行,因合法的功能又回到了原来的大小。该计划在不编辑 28 个 SessionPage.*.test.tsx 文件中任何一个的情况下移动了:对话流的片段(session-fio.tsx)、时间线 useMemo 的 762 行主体(session-timeline-montagem.tsx,在同一个 useMemo 内部以同样的依赖列表调用,逐字节相同)、三条 JSX 带(SessionTopbar、SessionFio、SessionComposer)、handoff 的推导(lib/session-handoffs.ts)、滚动的 refs 和效果(useRolagemDoFio)、故事的晋升(usePromocaoDeHistorias + DevolverHistoriaModal),以及不属于回合的 handoff/执行动作(useAcoesDeHandoff)——2,637 → 834。回合入口 handler 保留:它们是 ADR 0124 那个集合的门。最后一个 PR 加入 SessionPage.teto.test.ts,在文件达到 1,000 行或更多时让它不通过;提高上限需要新的 ADR
0181深色主题变成带柔和赤陶色强调的中性黑,浅色主题变成同一家族的中性色(AT-283/AT-284)。语义值改变,名称不变;加入原始色阶 --neutro-*。最终数字来自测量而不是草稿(--text-muted 深色为 #86868f / 浅色为 #696972,深色的 --on-accent 变成中性黑),0074 按数字锁定的深色主题五对债务现在通过 4.5:1,并作为下限被断言;主按钮的例外在不移动品牌色相的情况下被移除。token 之外的颜色进入 token:供 Mermaid/xterm/小地图使用的回退值的一份经过核对的副本、每个主题三个智能体 token、--overlay,以及一个让任何无人声明的 var(--x) 不通过的测试
0210用 motion 做文字动画(AT-401):AI Elements 的 Shimmer,适配为 CSS Modules 和 token(基色 --text-secondary,扫光 --text-primary),位于 components/ui/Shimmer.tsx——motion 的唯一导入者,通过 LazyMotion + domAnimation 加载到自己的 chunk vendor-motion 中(gzip 后 26.5 kB,不在初始加载中);减少动态效果时保持文字静止。
0211只有一种导航(AT-404):项目的垂直导轨(ADR 0126,ProjectRail.tsx)被移除,侧边栏的按项目列表(AbasDoProjeto)吸收了它原有的一切——分组、活动标签页、五个分开的计数器(框架的同一个 hook useContagensDoProjeto,没有新请求)、键盘、收起时的弹出菜单以及手机上的抽屉。Código 标签页的框架从 492px 降到 312px(收起时 110px)。

运维、部署与安装​

生产镜像、Kubernetes、可观测性、备份、dev 的 compose 以及一行安装程序。

#决策
0024多阶段、非 root 的生产镜像,compose.prod,带扫描和冒烟测试的 CI
0025Kustomize base+overlays(不是 Helm),engine 按 Oban 队列深度的 HPA,本地 overlay
0026带排空的优雅关闭、在 :global 中登记会话、按会话 trace 的 OpenTelemetry、指标和仪表盘即代码
0027经过测试的备份/恢复、Postgres 中的速率限制(不用 Redis)、分级的 HTTP 界面以及版本化的发布
0035埋点与导出分离(没有收集器也有真实 trace_id 的 trace),通过 AsyncLocalStorage + @Traced 跨层的路径,dev 中可读的日志和生产中一行 JSON,以及原本已经失效的三个关联漏洞
0070本地 Compose 中的可观测性:指标和日志,没有 trace。0026 只为 Kubernetes 搭建了 Prometheus、Loki、Tempo、Alloy、Collector 和 Grafana——验收标准写的是"no Grafana local",而那里的"local"是 k3d 集群。实际后果是,用 pnpm dev 开发的人拥有完整运作的埋点(api 暴露 102 个序列,engine 暴露 oban_queue_depth,三者都在日志中打上 trace_id),而这些都无法观察,因为看一个面板需要启动一个按设计不能与 pnpm dev 共存的集群(相同端口,ADR 0025)。overlay docker/docker-compose.observability.yml 是可选加入的,不属于 pnpm dev:在一台已经运行 Postgres、Ollama 和三个应用的机器上多四个容器,没在排查问题的人不应为它们买单。它交付不需要收集器的两种遥测——指标是抓取,日志从 stdout 读取,正是 0026 第 9 项决定的那种分离——并把 trace 排除在外,不设置 OTEL_EXPORTER_OTLP_ENDPOINT,因为 0035 已经确立,不导出是开发中的正常状态,而指向一个不存在的地址只会在每个回合产生导出错误。工件与集群的相同,而不是副本:Compose 直接挂载 deploy/k8s/observability/dashboards/,数据源的 UID 完全一样,因为仪表盘通过 UID 引用数据源,而仪表盘的副本会在只修正一侧的第一个面板上产生分歧;出于同样的理由,抓取输出面板据以分组的 app/pod 标签,收集器输出集群中 Alloy 从 app.kubernetes.io/name 推导出的 app。日志解析用正则而不是 stage.json,因为在 dev 中 pino-pretty 画出可读的树,engine 使用 PrettyLogFormatter——不会为了迁就收集器而更换的好决定;是收集器去适应。只有 api、engine 和 web 进入 Loki:Postgres 和 Ollama 是噪音,可观测性栈本身被排除在外,因为它运行在同一个 Compose 项目中,会在自我喂养的循环中摄入自己的日志。trace_id 是结构化元数据,从不是标签(基数),这需要在 Loki 自己的配置中开启 allow_structured_metadata 和 schema v13。记录了三种耗费了时间的静默失败模式,签名都一样——配置有效,日志零错误,面板上数据错误:stage.replace 替换的是捕获组,没有括号就什么都不替换;River 字符串中 \x1b 的转义在正则编译之前就被处理,而 [[:cntrl:]] 不依赖这一层;缺少键的 Go 模板会渲染字面的 <NO VALUE>,它变成了选择器中的一个级别值
0150一行安装程序,以及塑造它的三个磁盘事实。docker-compose.prod.yml 不是安装清单:每个服务都指向带本地 build: 块的 brabo-*:prod,docker/ 下没有一处对 ghcr.io 的引用,该文件自称是*"compose de VALIDAÇÃO"以及"o que este arquivo NÃO é: um manifesto de produção endurecido"*。.release/images.json 唯一的使用方是 kustomize 的 overlay。决定:调用方式是 sh -c "$(curl -fsSL …)",**从不是 `curl
0152针对 compose 运行的备份和恢复——诞生的原因是 FASE 29 需要迁移现有安装,而记录在案的路径不适用:make test-restore 是 Kubernetes 的路径(需要 kubectl、一个集群和 CronJob brabo-backup),docker-compose.prod.yml 没有 backup 服务,backup.sh 只覆盖 Postgres,并要求五个 S3 变量。决定首先拒绝简单的做法("备份所有卷"),并对七个卷分类:pgdata 和 git_local_repos 是事实来源;neo4j_data 和 project_workspaces 是派生的;ollama_data 可重新获取;brabo_projects_base 属于用户。git_local_repos 是真正的漏洞,也是这份 ADR 的发现——local provider 的项目把 bare 仓库存在那里,丢失这个卷就丢失了智能体产出的代码,今天没有任何备份覆盖它,而且它无法从 Postgres 重建:事件日志保存的是叙述,不是 git 的对象。Neo4j 获得重新投影,而不是备份——把一个可能过时的投影与在另一个时刻恢复的 Postgres 一起恢复,会产生两个来自不同时刻的派生状态,没有任何东西去调和它们;这一阶段声明了这条路径,并接受迁移后的安装以空图谱开始,这是已知的降级(RAG 住在 pgvector 中,在 dump 之内),不是数据丢失。磁盘成为有效的目标,使迁移不需要 bucket。compose 的恢复复用 restore.sh:139-224 的同样三项校验——外壳改变,判断不变——deploy/k8s/test-restore.sh 保持不动。安装程序之所以获得删除卷的权利,只是因为它证明了自己能把它们恢复回来
0153DEPLOY_ENABLED 被十份文档引用为激活的触发条件——ADR 0089/0091/0092 在 Status 字段本身中,fluxo.yml 中三处(platform.ativacao、area-infra.gate_saida,以及 secops-runtime 的两项缺口交付),gates.yml 中的 operavel gate,再加上 business-rules.md、backlog.md、modelo-de-time.md、auditoria-fluxo-vs-codigo.md、historico-de-fases.md 和 CLAUDE.md——却在代码的任何地方都不存在:仅有的两处出现是注释(external-effect.ts:144、listar_metricas_de_produto.ex:185)。十份文档指向一个背后没有墙的开关。一个与部署无关的发现:deployavel gate 在 fluxo.yml:247 中被声明为 area-infra 的 gate_saida,却不在 gates.yml 注册表中,这正是三块团队模型之间的交叉检查(仍在 backlog 中)会抓到的不一致——这类发现中的第二个。决定被一条硬边界分成两部分:今天就能决定的阶段 A,把 deployavel 作为 planned/warn 加入注册表(RN-070/071 禁止在没有验证脚本的情况下升级为 block),保持两个提前启用的脚本不动,并让文字不再把 DEPLOY_ENABLED 当作变量,转而点名它一直想表达的里程碑;阶段 B 被运维者而不是代码阻塞——一个有真实流量的生产环境,在那之后 platform 离开 planned,缺口才变得可能。基础设施不是瓶颈(overlays、自 0119 起按 digest 发布在 GHCR 的镜像、smoke/rollout/restore 脚本,全都存在)——缺的是有人去运行和维护。考虑过并拒绝的替代方案是现在就创建默认为 false 的变量:一个没有代码读取的变量,就是这份 ADR 所谴责的同一个空洞承诺,只是多了一行配置撑着它;这个标志在拥有第一个真实使用方的 PR 中诞生。明确拒绝:在 engine 中创建 platform/secops-runtime 智能体(重复 0091/0092 的拒绝,仍然有效),以及在 MTTR/change failure rate 只依赖于一个环境时把它们标为永久不可测量
0155安装程序在终端中创建第一个账户,并启动已经配对的本地智能体。迫使这一点的发现:一次全新的一行安装不让任何人进入。生成的 .env 不带任何 e-mail 变量,MAIL_TRANSPORT 落到 log(docker-compose.install.yml:200,其自身注释说该行为"continua só-log até aqui"),而注册需要验证 e-mail——正如 provisionar-usuario.ts 的 docblock 早已记录的,"com o MailSender só-log não fecha sozinho"。于是安装以"Pronto"结束,而前进的唯一路径是在 docker compose logs api 中捞出验证链接。provisionarUsuario 恰恰是为这个漏洞而存在的——它创建一个密码已验证的用户——而它的两个调用方是演示用的 seed 和 smoke,都不是真实安装。决定:这一步只在没有任何用户时运行,通过一条由安装程序刚刚生成并以 600 模式写入的 BRABO_SERVICE_TOKEN 认证的内部路由;不产生"创建第一个 owner"的公开路由,因为一条创建 owner 的公开路由就是安装者与扫描端口者之间的赛跑。账户以已验证状态诞生,理由正是授权它的东西:e-mail 验证证明的是*"这个人控制这个邮箱"*,而运行安装程序的人已经证明了更强的东西——他控制这台机器、带有五个秘密的 .env 和 Docker 守护进程。通过一个安装已知是关闭的通道要求较弱的证明,是走过场——安装程序在输出中说明这一点,而不是让人自己发现他的账户与其他人的不同。同意是显式的,没有 TTY 时这一步不发生(RN-511 的规则,consentir-base.mjs 的形式):不存在由脚本选择密码而创建账户的路径,无论是生成的还是默认的。密码在无回显的情况下读取、确认、使用并丢弃——从不进入 .env、从不进入标记、从不进入日志;标记获得 owner 的 e-mail,那是身份标识,不是秘密。账户创建后,登记一把机器设备密钥并安装按机器的 unit;密钥对在本机生成,私钥那一半从不传输,使 RN-519 拒绝 maintainer 视图时的那句话依然成立。然后智能体在没有项目的情况下启动,这是正常状态——它等待,不发出失败,status 说"de pé, nenhum projeto em modo runner",这是 RN-518 已经区分的四种状态之一,而不是第五种。拒绝的方案:已有账户时再创建账户、第一个 owner 的公开路由、由脚本选择的密码、放宽正常注册、自行开启 SMTP,以及索要 LLM 凭据
0160安装程序的 compose 与它一起传送,作为Release 的签名资源。安装程序在一台干净机器上的首次执行(RN-549)发现,发布的 install.sh 自己什么都启动不了:它运行 docker compose -f docker/docker-compose.install.yml,一个相对于它运行所在位置的路径,而这个文件不是 Release 的资源,不在签名的 checksums.txt 中,也不在任何地方被下载——所以遵循运维手册那一行的人会在带有五个秘密的 .env 已经写好之后,死于 "no such file or directory"。由维护者决定:四个文件成为同一个签名清单中的资源——compose、它通过 bind-mount 挂载的两个文件(postgres/init.sql、ollama/pull-models.sh),以及在实现过程中测得的、迁移路径会执行的 backup/test-restore-compose.sh,E2E 的手动绕法从未带上它,因为干净的机器没有要迁移的东西。名称带有 brabo-install- 前缀,因为 Release 是扁平的,通用名称会冲突。这张表住在两侧——发布方的 scripts/ci/assets-do-instalador.ts,下载方 install.sh 中的一个 case(bash 3.2,没有 Node)——一个 spec 在它们分歧时不通过,并推导出 compose 的每个相对 bind-mount,缺陷正是这样产生的。checksums job 从 tag 的 checkout 发布它们,现在在附加之前对清单运行 sha256sum -c --strict。安装程序在核对自己之后立即下载并核对这四个文件,针对同一个清单、按确切名称,带有三种不同的具名拒绝,机器全都保持不动;COMPOSE_DE_INSTALACAO 生来为空,只有当核对过的副本放到安装文件夹下时才变成绝对路径,替换——从不读取——原来在那里的东西。E2E 去掉了手动步骤并断言它的缺席。拒绝的方案:在 tag 上克隆仓库(用对 git 传输的信任替换签名链,外加一个干净机器可能没有的依赖)、一个 tarball(一行清单覆盖四个无名文件)、把它们嵌入 install.sh、一个指向另一个 Release 的 URL 的标志,以及在 pull request 上运行 E2E。已声明:只在下一个最终 tag 上端到端证明;已经发布的 Release 都不会获得这些资源;并且,经测量但未修正,test-restore-compose.sh 调用 Compose 时不带 --env-file,所以迁移的恢复证明倾向于以安全的方式失败
0161界面只提供这个安装能运行的执行模式,而从 Release 安装的没有容器 broker。v6.1.0 的一次真实安装(AT-085)以 mounted 创建了一个项目——预先选中,因为 install.sh 总是写入 BRABO_PROJECTS_BASE,而 RN-513 在基目录已知时预选这个模式——并收到六个 dev.blocked_by_container 外加两个 container.start_failed:container 和 mounted 只通过 broker 启动容器(ADR 0144),安装用的 compose 根本没有 broker 服务(不是一个关闭的 profile——ADR 0150 第 7 项决定),而没有运行中的容器,dev agent 都不工作(ADR 0143)。维护者决定"os dois":安装程序询问是否启用 broker,界面不再提供不能运行的东西。安装程序那一半经过测量后停下了:从 Release 安装无法获得 broker 的镜像——四个 bake 目标,images-manifest.ts 和 v6.1.0 的 images.json 中都没有 broker,docker manifest inspect ghcr.io/daneiel/brabo-broker:6.1.0 回应 denied,而对 brabo-api 的同样匿名调用能够解析,并且那一行安装在一个没有源代码可供构建的文件夹中运行。一个"是"的回答没有镜像的问题比没有问题更糟;把 broker 作为第五个镜像发布要先做,作为自己的一条前线。界面那一半进入:brokerConfigurado(来自 ContainerBrokerPort.configurado(),与观察状态的读取使用的同一来源)进入 GET .../projects-base 和 GET .../containers 的每一行——没有新路由。向导有三种从不合并的状态:broker 已确认保留 RN-513;确认缺失时把 Container 和 Montado 留在界面上但设为不可用,以文字说明一次原因,并预选 Runner;未知时既不预选 Montado 也不锁定任何东西。/containers 获得第四个本地拒绝 sem_broker_na_instalacao,针对 container/mounted,在镜像检查之前。RN-512 和五层隔离保持不动。范围之外,已声明:模式转换、停止/移除、Infra Lead 不知道 broker 缺失,以及忽略被阻塞智能体的并行建议
0162容器 broker 成为第五个发布的镜像,安装程序询问是否开启它。这是 ADR 0161"os dois"中安装程序的那一半:在从 Release 的安装中,container 和 mounted 项目从未运行过,因为启动它们容器的是 broker(ADR 0144),而安装没有 broker 服务——经测量,ghcr.io/daneiel/brabo-broker 回应 denied,bake 有四个目标。Dockerfile.prod 早已存在并通过 hadolint;只是没有东西构建它。现在 bake 目标 broker 经过其他四个经过的每一道闸门——在每个 PR 中构建、由 Trivy 扫描并作为 root 被拒绝,被证明在只读且无网络的情况下健康,发布,写入 images.json,在每个 tag 上按 digest 签名并验证。安装用的 compose 获得这个服务,处于关闭状态,位于 profile container-broker 之下,ADR 0130 的五层隔离保持完整;Kubernetes 的 overlay 不认识它。安装程序在基目录之后提问,以文字说明开启它会把机器的 Docker socket 交给该服务,只有在输入"s"时才开启,从容器内部测量 socket 的 gid(否则具名拒绝,从不猜一个 999),一起写入 COMPOSE_PROFILES/BROKER_URL/DOCKER_GID/被管理的根,并在启动之后核对 api 能到达 broker,以及计算出的根就是卷的根。RN-512 的论点(在自己机器上安装的人已经拥有 socket)是它可以被提供的原因;安装程序的读者从未打开过这个仓库,是它被询问而不是被假定的原因。代价,已声明:一个相当于 root 的镜像是公开的,用户的安装在同意时会挂载 Docker socket
0169本地 overlay 的备份 S3 目标不再是 MinIO,而是 SeaweedFS(docker.io/chrislusf/seaweedfs 4.47,按索引 digest 固定,server -s3 -s3.port=9000)。MinIO 停止发布社区镜像——固定的 digest 在 quay.io 上回应 401,Docker Hub 仓库回应 404——所以 bootstrap 死在 rollout status 上,没有任何属性证明能够运行(issue #633)。按维护者的标准依次测量:SeaweedFS 四项全部通过(官方、匿名、amd64+arm64 索引;lib.sh 通过 aws-cli 的真实流程——认证、mb、通过 stdin 的 30 MiB 分段上传、head-object、经 sha256 核对的下载、服务端复制、list-objects-v2、rm;只有服务器改变;Apache-2.0);Garage 在第三项被淘汰(配置文件加上启动后的布局和密钥导入);Bitnami、它冻结的旧镜像以及 Chainguard 只有 :latest 的 MinIO 在第一项被淘汰。资源获得一个中性的名称(s3-local);客户端、备份脚本、Secret 和端口不变。已声明:digest 保证不可变性,不保证可用性;S3 兼容性是按操作的,所以 lib.sh 中的新操作要先针对它测量
0170dev 的 compose 成为 Docker 项目 brabo-dev,dev 拒绝在一个安装旁边启动。2026-09-19 测得:dev 的 compose 和安装的 compose 都是 name: brabo——在同时有两者的机器上是一个 Docker 项目,共享容器、网络和卷(brabo_pgdata……);一个安装的数据库收到了 dev 的 migration,而 reset-total.sh 本会删除它的 schema。安装仍然是 brabo(重命名它会把每个现有安装与自己的卷断开);改名的是 dev,可观测性 overlay 重复这个名称,因为最后一个 -f 胜出。只要存在任何容器的 config_files 标签指向安装的 compose(任何状态、任何 -p 下),或者 dev 的 compose 会解析为 brabo,preflight.mjs 和 reset-total.sh 就拒绝,并点名每个文件的容器和 down(不带 -v);reset 的守卫在一切之前运行。dev 的旧 brabo_* 卷只产生警告——只有 dev 拥有、从两个 compose 文件推导出的键证明了它们,共享的键仍然有歧义,警告如实说明——运维手册带有复制或重新开始的步骤。声明的代价:在一台有安装的机器上开发,需要先把安装停掉
0209engine 在 dev、生产和 CI 中改用 Alpine 3.24.2 上的 Elixir 1.20.4 / OTP 29.1.1(AT-398):在带 AMX 的主机上,OTP 27 在 Alpine 3.24 的 musl 1.2.6 之下启动时以 sys_sigaltstack(): Internal error 中止(erlang/otp#11349),只有从 OTP 29.1 起才修正;代价是重建 dev 的 _build/deps 卷。

CI、发布与供应链​

管道、发布的镜像、签名、扫描、固定版本、测试层次以及仓库自身的结构。

#决策
0039在 engine 的 Dockerfile 中固定 actionlint(与 gitleaks/hadolint 相同的模式),在生成时校验 CI 管道,而不是用一个新的 PR 后 gate,并记录 .gitlab-ci.yml 没有同等的离线静态 linter 这一缺口
0119release.yml 不再只为证明 tag 可构建而构建四个生产镜像,而是在每个最终 tag 上把它们发布到 GHCR,公开,用 job 自己的 GITHUB_TOKEN(packages: write)登录——没有新的秘密。registry 前缀和构建输出成为 bake 的变量(REGISTRY/OUTPUT),两者默认为空,使 ci.yml 继续构建它扫描的本地短名称;type=registry 不是优化,而是 bake 返回 containerimage.digest 的唯一模式,因为 digest 产生于 push。每个 tag 发布的内容按 digest 记录在 .release/images.json 中——一个 Release 资源,在 CHANGELOG 的 PR 中进入仓库,没有第三个 push 例外。overlay 保留 REPLACE_WITH_DIGEST 作为标记,make imagens-do-release 用 kustomize edit set image 应用 digest;仓库从不声明哪个 release 在生产中。途中的发现:overlays 列出的是三个镜像,而不是四个——备份 CronJob 继承了 brabo-backup:prod,一个在任何 registry 中都无法解析的名称
0120一层浏览器 E2E(e2e/,Playwright,只有 chromium)关闭了金字塔顶端仅剩的漏洞:没有任何东西运行真正的浏览器,而承载会话的机制正是浏览器机制——与可读的 brabo_csrf 配对的 httpOnly refresh cookie、从 :8088 到 :3000 的跨源调用(preflight、SameSite、credentials: 'include'),以及 RN-108 的一次性 socket ticket 在第三个源上与 engine 握手。jsdom 不强制其中任何一个,所以在那里断言它们的测试会通过却什么也证明不了(main.ts 已经写着:"teste não faz preflight")。它针对 docker/smoke.sh 已经启动的生产 compose 运行(SMOKE_KEEP_UP=1),并在同一个 images job 中,因为四个镜像占了这个 job 的大部分时间,一个单独的 job 会为了得到同样的栈而重新构建它们。e2e/ 不是 workspace 的成员——自己的 pnpm-workspace.yaml 和 lockfile,与 website/(ADR 0117)相同的形式,出于同样的理由:Playwright 的依赖树不会进入任何镜像,在产品的 pnpm audit 中无事可做。specs 用结构化的选择器断言机制(浏览器报告的 httpOnly 标志、页面看到的 document.cookie、在重新加载后的存活、WebSocket URL 中的 ticket=),从不用文本——界面语言是服务器的决定。seed 通过 HTTP 进行,镜像 smoke。声明为未覆盖:浏览器之间的差异、内联审批和流式输出
0121apps/api/src/db/schema.ts——2 452 行,51 张表,34 个 enum,仓库中改动最多的文件——拆分为 db/schema/ 下每个领域聚合一个文件,镜像 src/domain/*,而不是发明一种该文件已经用 // --- section --- 注释手工维护的分类法。旧路径变成一个 25 行 export * 的 barrel,所以导入 …/db/schema 的 144 个模块什么都不用改,drizzle-client.ts 的 import * as schema 看到完全相同的导出集合,drizzle.config.ts 继续指向它(已核实:drizzle-kit 跟随 export * 链并报告"No schema changes, nothing to migrate")。纯机械的——没有任何表、列、enum 值、索引名或约束表达式改变,验收标准是零 diff 的 migration,比测试套件更严格,因为 Drizzle 比较推导出的 SQL。enum 与调用它的表放在一起,而不是按主题:跨文件的 FK 在循环中是安全的(.references() 是惰性回调),跨文件的 ENUM 则不是(它在模块求值时运行),所以 project_execution_mode/story_promotion 留在 iam.ts 中与唯一的使用方在一起,failure_origin 在 backlog.ts 中,保持导入图是一个 DAG。backup.ts 是唯一在 domain/ 中没有对应的文件——没有业务规则的基础设施,而错误的边界比一个小文件代价更高。docs/.docmap.yml 在 barrel 旁边加入 schema/**:否则规则会看不到约束,而那正是它存在要监视的东西
01402026-09-04,npm 的 advisories 端点(POST .../security/advisories/bulk)在数小时内时断时续,以相同的签名(先是 error (503),然后是 TimeoutError)让四个不同 PR 的依赖审计 job 失败——在 CI 之外、从另一台机器复现,那里对 registry 的简单 GET 在 130ms 内响应,而批量 POST 从不响应。代价不是浪费的分钟,而是它教会的习惯:每一次这类红色都靠重跑直到变绿来清除,而学会这一点的人也会对真正的红色重跑。产品所有者的决定:三次尝试 registry 都无响应是承担的风险——job 变绿,并附带一条响亮的警告,说明依赖树没有被审计,从不说它是干净的。这个决定不放松的,正是让它站得住脚的东西:报告的漏洞仍然在第一次尝试时就失败而不重试,未识别的失败同样失败(fail closed)。优先级是核心——classificarAudit 先测试发现的签名,再测试网络的签名,所以一份恰好提到 timeout 的报告(一个叫这个名字的包、一个关于 timeout 的 CVE)永远不会被当作不可用而宽恕,并且有一个测试正是为此而设。逻辑放在一个可测试的脚本中(11 个用例,使用执行 33838507158 的真实输出而不是转述),而不是 YAML 中的 bash。只有 pnpm 一侧改变;mix hex.audit/mix deps.audit 保持原样,因为把例外扩大到没有要求它的地方会把一次性的决定变成通用策略。已声明而未解决:风险被反复承担时没有任何告警——一个 PR 中三次超时是第三方的噪音,一周内每个 PR 都三次就是事实上关掉了闸门,而今天没有任何东西能区分两者
0149产品对它发布的东西什么都不签名:在 .github/、scripts/、docker/ 和 Makefile 中搜索 cosign、sigstore、slsa 和 attestation,只返回一处真实出现,而它既不覆盖镜像也不覆盖二进制(npm 包的 npm publish --provenance,publish-runner.yml:188)。release.yml 构建并推送四个镜像(:286-303),把它们的 digest 写入 .release/images.json(:308-319),只声明了三项权限(:40-43)——没有 id-token: write,而无密钥签名需要它。build-runner-binaries.yml 用 gh release upload 上传五个二进制(:227),没有 checksums.txt——而存在的 sha256sum -c 调用(ci.yml:201、Dockerfile.prod:95)校验的是第三方的二进制,正是仓库熟知却从未应用于自身的模式。GET /runner-releases/binary 是 @Public(),直接转发 GitHub 的字节,没有任何完整性检查。这就是 BRB-005,它在 FASE 28 中阻塞了一行安装程序。决定:无密钥 cosign(Actions 的 OIDC)按 digest 签名四个镜像——从不按 tag,那是一个移动的指针——外加一个签名的 checksums.txt 覆盖五个二进制,而不是五个单独的签名(五个要校验的工件就是五种让校验只做一半的方式)。三个具名的使用方,每个都是拒绝,从不是警告:安装程序、下载代理和本地构建。选择无密钥而不是一对受管理的密钥,原因是仓库已经吃过苦头学到的:私钥意味着托管、轮换和又一个 CI 秘密,而一个无人注意到过期的 PAT 已经让 tag-release 两次执行失败(0139)。做校验的 cosign 二进制本身被固定,并用 sha256sum -c 对照写在版本化脚本中的哈希核对。拒绝的方案:受管理的密钥、"现在签名,以后校验"、按 tag 签名,以及作为单独维度的完整 SLSA/SBOM 证明。声明 broker 仍然没有签名,因为它仍然没有发布——bake 有四个目标。在第 3 次会话中修订,把下载代理那一行一分为二:它校验完整性(对照 release 的 checksums.txt 校验 sha256,清单缺失时拒绝——从不警告),而不校验来源,因为 api 核对签名的两种方式都经过测量并被拒绝——镜像中的 cosign 有 155 MB,而运行时是 Alpine+Node;@sigstore/verify 很便宜(2.5 MB),却会让一条 @Public() 路由依赖于第二个第三方主机来校验目前还没有任何 Release 带有的东西,而 ADR 0041/0042 禁止没有证明就这样声明。于是 BRB-005 就产生签名而言关闭,对代理的这一半仍然开放(RN-525)
0159第三方镜像按 digest 固定,tag 放在末尾的注释中,一个 lint 会让下一次退步失败。这撤销了本仓库曾写下的一项决定:供应链页面曾把按 tag 固定称为*"uma parada deliberada, não um descuido",在日常中换取可复现性,"sem o custo de manutenção de digests em imagens que não publicamos"。这句话的两半都在测量面前站不住。第三方引用有 37 处,它们运行的地方中有三处不是日常:十三处是 Dockerfile 的 FROM 行,而那是我们发布到 GHCR 并用 cosign 签名的四个镜像的基础(RN-524);四处在 docker-compose.install.yml 中,它运行在安装了产品的人的 Postgres 旁边,就在同一个文件中,而产品自己的四个镜像已经通过变量按 digest 到达;四处是 ci.yml/golden-set-rag.yml 中的 services:,那字面上就是 actions 规则要保护的 runner,从另一扇门到达——这三处都不在 BRB-004 自己的证据中。维护成本是真实的,并且与已经为 actions 的 SHA 接受的相同。这个检查是 actions-pinadas.ts 的姐妹,而不是扩展:uses: 是 workflow YAML 中的一种语法,镜像则分布在 compose、kustomize 清单和 Dockerfile 中的另外三种语法里,一个回答两个问题的函数会把两个都答坏。它让可变引用、没有 tag 注释的 digest,以及同一 tag 带两个不同 digest 的情况失败——第三种把写在注释中的承诺(golden-set-rag.yml 声称运行与 dev compose"MESMA versão pinada",因为 golden-set 的下限按模型而不是按环境为键)变成了可核验的东西。digest 是索引的,从不是某个平台的:固定 linux/amd64 会在没有警告的情况下弄坏 linux-arm64。在 Dockerfile 中,tag 注释放在上一行*,这是犯错后才学到的:Docker 的解析器只接受行首的 #,所以末尾的 # 会成为一个带三个参数的 FROM,构建会死——而 hadolint 却让同一个文件通过,因为 linter 同意不等于构建同意。检查识别末尾的形式只是为了拒绝它,因为一个干脆不匹配的模式会让引用从扫描中消失而不是变红——用沉默换掉错误,而那正是它存在要防止的缺陷。注释在两种形式中都是单个 token,这正是区分 tag 与几乎每个 FROM 上方已有散文的东西。检查有意不覆盖产品发布的四个镜像,出于三个独立的理由——没有第三方能移动它们;brabo-api:prod 是本地 tag,其 digest 在构建之前不存在且每次构建都变;而在它们经过 registry 的地方,ADR 0119 已经按 digest 解析它们,overlay 有意保存标记而不是冻结的 release,所以在那里要求字面量会让正确的状态失败。对插值引用和多阶段构建的阶段同样如此。例外列表按名称且失败关闭。可观测性 compose 曾是一个开放的 TODO(humano),它被纳入:排除它需要在 docker/ 内有一个具名豁免,这正是姐妹检查的 docblock 所诊断的那种漏洞("um workflow novo é escrito copiando um vizinho, e o vizinho tinha uma tag")。已声明而未修正:digest 在有人手动提升之前不会获得安全更新——又是 action SHA 的那笔债——Dependabot 的 docker 生态没有启用,那是另一项决定;dev 的基础镜像(node:24-alpine)比生产的冻结得更多,这是最不舒服的后果,也被接受;otel-collector-values.yaml 中 image: 开启一个由 chart 选择 tag 的映射,没有被覆盖。没有产生 RN:姐妹规则也没有,两者都住在 CLAUDE.md 和供应链页面中。BRB-004 不再复现
0172release 对它发布的东西运行 Trivy,按 digest 并在签名之前(AT-179):此前只有 ci.yml 做扫描,而它扫描的是 PR 的本地构建,而 tag 在另一次执行中冷构建(AT-110)——安装程序下载的镜像是没人扫描过的那个。闸门位于写入 .release/images.json 和 cosign sign 之间,所以不通过的镜像永远不会被签名;它逐字节使用 ci.yml 的标志(--severity HIGH,CRITICAL --ignore-unfixed --exit-code 1,同一个 .trivyignore.yaml,没有新的白名单),Trivy 的退出码就是裁决。没有修正的 HIGH/CRITICAL 会被报告——job 摘要和一个由 scripts/ci/trivy-do-release.ts 组装的 Release 资源 trivy-sem-correcao.md——不阻塞。声明的代价:push 已经发生,所以一个不通过的 tag 会在 GHCR 中留下没有签名、没有 Release 的镜像;版本、哈希和标志住在两个 workflow 中,由一个 spec 守护。针对 v6.1.0 的 digest 测得:api 和 web 今天会因 CVE-2026-45447(openssl,在 3.3.7-r1 中修正)而不通过。
0178第三方镜像引用变成 image:tag@sha256:<index>——tag 从注释中移出,放进引用中,位于 digest 之前(AT-139,关于 ADR 0159;维护者的决定称之为"ADR 0171",一个从未写下、保持空缺的编号)。2026-09-27 测得:Dependabot 不读取任何形式的 tag 注释,会把一个只有 digest 的固定移到 latest 的 digest,这会把 pgvector、neo4j 和 node 带到另一个主版本,而注释仍在撒谎,lint 仍是绿色。39 处引用以同样的 digest 迁移(29 处在 docker/,4 处在 deploy/k8s/,6 处在 workflows),注释被移除;imagens-pinadas.ts 从引用推导 tag,并让没有内联 tag 的 digest(包括 ADR 0159 的形式)、不一致的 tag 注释、FROM 末尾的注释,以及同一内联 tag 带两个 digest 的情况失败——每条规则都由变异证明。未做,已声明:启用 Dependabot 的 docker/docker-compose,因为已决定的、在 bot 的 PR 中对齐 workflows 的 services: 的那一步需要推送到 .github/workflows/,而 GITHUB_TOKEN 永远做不到——出路在维护者手中。CNPG 的 imageName 和 QA golden-set 的镜像无论如何仍是手动的
0197workflows 的 services: 从 dev 的 compose 读取镜像,Dependabot 的 docker/docker-compose 生态被开启(AT-246,所有者 10月1日的决定,关于 ADR 0159 和 ADR 0178)。六个字面量(ci.yml 和两个 golden-set 中的 pgvector 和 ollama)让位于 ${{ needs.imagens.outputs.<image> }},来自一个运行 scripts/ci/imagens-do-compose.ts 的可复用 imagens-do-compose.yml——一个 digest 来源,所以 bot 的 PR 永远不触碰 .github/workflows/,golden-set-rag.yml 的"mesmo Ollama do dev"从承诺变成了构造。imagens-pinadas.ts 现在在 workflow 中让任何镜像字面量、任何不完全是这个表达式的东西(字面量、`

文档​

文档如何生成、核验和发布。

#决策
0029文档的持续同步:生成 > 核验 > 记住,在 CI 中校验的责任映射,以及必需的逃生口
0033API 参考来自代码:按类型镜像实体的响应 DTO,要求真实 summary 和响应的表格测试(Swagger 合成的不算数),以及在 --check 中用哈希清单代替重新生成
0034按阶梯发布到 Pages 子目录的文档:按变量的 baseUrl,main 之外的 noIndex 以及本地搜索中的 forceIgnoreNoIndex,还有第三个直接 push 例外
0071按阶梯的对称发布,以及一个让人选择的根。0034 让每个永久分支发布自己的文档,但让地址不对称:main 在根,qa 和 dev 在子目录。这种不对称收取三项代价——三个站点互相看不见(落在 /brabo/ 的人无法到达 qa),根的特例迫使 workflow 有一个只在三分之一的发布中运行的步骤,也没有地方声明成熟度。三者改为 /brabo/<branch>/,根变成一个生成的索引,而不是重定向:重定向会更简单,却会吞掉让人去看不止一个阶梯的那个问题("qa 已经有我需要的东西了吗?")。页面提供树中存在的东西,从不是固定列表,并显示从 tags 读取的盖章版本。根上的一个 404.html 接住每个旧的深层链接(/brabo/architecture → /brabo/main/architecture),对发生在某个阶梯内部的 404 有防循环保护。最重要的变化是不可见的:站点通过比较 BASE_URL === '/brabo/' 知道自己是否是生产,而三者都在子目录中后,这对 main 也变成假的——真正的文档上 noIndex,从 Google 中消失,悄无声息且 CI 是绿的,因为构建中没有任何东西会因为索引太少而失败;阶梯改为在 DOCS_BRANCH 中声明。树为三者组装(不是 keep_files: true,它从不移除孤儿),这免费带来了迁移:main 留在根上的垃圾不在组装好的树中,在第一次 push 时就会离开。过渡从旧根播种 /main/——否则 dev 的第一次 push 会让 /brabo/main/ 恰好在 404.html 往那里转发的时候不存在
0073发布的路径命名的是环境,而不是分支。修订 0071:它通过插值 $GITHUB_REF_NAME 把三个阶梯放在 /brabo/<branch>/,于是稳定文档落在 /brabo/main/——main 是提交者的用语,而读者在选择的是成熟度,不是分支。main 改为发布在 /brabo/prd/;qa 和 dev 的一致是偶然,不是规则。分支→路径的映射在每个进程中存在于一处(workflow 的步骤、docusaurus.config.ts 的 DEGRAUS 表和 landing.mjs 的那张表,后者把树的目录与产生版本 TAG 的分支分开)。E_PRODUCAO 仍然是 DOCS_BRANCH === 'main'——0071 的第 4 项正是为路径改变的那一天而存在,而今天就是那一天。处理了两个陷阱:/brabo/main/ 已经发布,并会随 keep_files: false 消失,所以 404.html 重写前缀(自己的情况,而不是通用的转发,后者会产生 /brabo/prd/main/…),防循环保护改为只覆盖存在的路径;过渡从 gh-pages:main 播种 /prd/,否则在 dev 的第一次 push 和下一次晋升之间,/brabo/prd/ 会有好几天回应 404
0117website/ 离开根的 pnpm-workspace.yaml,获得自己的 pnpm-lock.yaml,使根的 pnpm audit 不再报告从不交付的 Docusaurus 依赖树。用 pnpm why <pkg> -r(而不是重读注释)审计根的 13 个安全 overrides,发现大多数被归于只属于 website/ 的其实是混合的——mermaid/dompurify/uuid 也经由 apps/web 自己的运行时 mermaid(ADR 0068)解析,postcss/nanoid 经由 apps/web 的 vite,js-yaml/fast-uri/lodash 经由 apps/api 的 eslint/ajv/@nestjs/swagger 链——所以这些留在根中,并被复制到 website/package.json 中一个新的 pnpm.overrides 块;只有 serialize-javascript 和 yaml@1.x 范围是真正 website 专属的,被彻底移走;esbuild 只留在根中,website/ 的树中没有它。pnpm --filter website(只能从根运行,要求属于 workspace)在每个 docs:* 脚本以及 docs-deploy.yml/docs-check.yml 中变为 pnpm --dir website,后两者各获得第二个单独的、范围限于 website/ 的 pnpm install 步骤
0202ADR 索引按主题分组,主题在 ADR 之外(AT-137,所有者 10月1日的决定,发现 BRB-026):docs/adr/temas.yml 保存 15 个主题的封闭列表以及每个 ADR 一个主要主题,使任何已接受的 ADR 都不被编辑,即使是为了获得 frontmatter。索引是核对的,而不是生成的——一行描述仍然是人工精选的——docs:check(scripts/docs/temas-de-adr.mjs,每条规则由变异证明)让没有主题的 ADR、不在列表中的主题、没有 ADR 的主题,以及放错章节、不在任何章节或不按数字顺序的索引行失败。分类法来自产品的前线,而不是阶段或代码文件夹,是 lane 提交给所有者审阅的提议;新 ADR 的作者在同一个 PR 中选择它的主题,超过 15 个主题需要一份 ADR
0206API 参考变成在服务器端渲染的一个 Redoc 页面(redocusaurus),位于 /reference/api/,修订了 0033 中发布的那一半:OpenAPI 的插件和主题拖入了 postman-collection → @faker-js/faker@5.5.3(High 告警 #43),没有修正的途径。按操作的页面、按 tag 的侧边栏、按操作的 URL、playground 和 cURL 片段按所有者的决定移除;api-render-check.mjs 现在断言标题以及每个操作一个 SSR 章节
0216文档和界面使用四种语言——pt-BR 成为来源和默认,英语、俄语和简体中文作为分阶段的翻译,测量一致性,并对未翻译或过时的页面逐页给出提示(AT-545,所有者 10月15日的决定)
0218每个 ADR 都命名为“ADR NNNN — 标题”:H1 加上 ADR 前缀、4 位编号和破折号;文件名(即 URL)不变,规范标题标签不算编辑决策(AT-559,所有者 10月15日的决定)
0219参考文档纳入翻译:docs/reference/** 成为 pt-BR 源,被翻译并计入平价统计(包括生成块),平价报告本身和 openapi.json 除外;Redoc 页面留给 AT-565(AT-564,所有者 10/15 的决定)
0220API 页面按目录翻译:源仍是 api 装饰器文本(pt-BR 源的例外),每个语言环境一个以文本哈希为键的目录,spec 在构建时派生且不进 git,孤立键失败、缺失只警告,Redoc 固定标签保持英文(AT-565,所有者 10/16 的决定)

约定​

  • 每个决策一个文件,位于 docs/adr/NNNN-short-title.md,使用 4 位顺序编号,标题为 # ADR NNNN — <标题>(0218)。编号不复用,即使一份 ADR 被取代——下一个是 0220。
  • 每个决策一个主题,由编写 ADR 的人在同一个 PR 中选择:docs/adr/temas.yml 中的一行,以及该主题章节下的索引行, 按数字顺序。审阅者确认。新主题是 对 temas.yml 中列表的修改,在添加它的 PR 中说明理由;超过 15 个需要一份修订 0202 的 ADR。
  • 三个章节,仅此三个: 背景(促成它的问题或力量), 决策(决定了什么),后果(接受的权衡以及 留待以后的事)。
  • 不使用第三方模板——不用 Nygard、MADR 或其变体。三个 章节就够了。
  • 用葡萄牙语,与项目的其余部分一样。
  • **已接受的 ADR 不被编辑。**被后续决策取代的 ADR 保持 原样;新的 ADR 引用旧的并解释改变了什么。

让这些 ADR 有价值的,是它们记录了没有奏效的东西:0020 记录了连续九次失败的执行,0027 记录了一个 在产生它的同一次会话中就被修订的理由。被省略的坏后果 正是六个月后会缺失的那一部分。