APSer-Atelier

APSer-Atelier 的技术架构与选型决策机制

APSer-Atelier 是一个单机部署的控制面加活动账本。本文按真实代码与账本数据,讲它的系统边界、数据模型与两套状态机、受管派发与交互式会话两条写入链路各自的机制和覆盖面、并发与一致性、失败恢复、安全边界、技术选型,以及技术决定怎样登记、拍板和复议。

发布于 更新于 青岛艾普仕

先说结论

  • APSer-Atelier 是我们做制造业深度定制交付时使用的可持续交付底座,工程上是一个单机部署的控制面加一本活动账本:一个 Python 后端进程、一个 SQLite 库、一个 React 操作台。它登记每一件事的状态、谁持有哪个仓库的写入权、待人拍板的事项和每一步的验证记录;代码、文档和决策记录仍以各项目自己的 Git 仓库为准。
  • 代码进入项目仓库有两条链路。受管派发链路:Atelier 自己启动无人值守的 Codex 或 Claude 进程,在仓库外的隔离目录里写,由父进程验证和复审后直接提交到主干。交互式会话链路:我们本人在场的 Claude Code 或 Codex 会话,直接在项目工作目录里修改和提交。
  • 两条链路的覆盖面相差很大。从 8 月 10 日受管链路第一次运行到 9 月 30 日,我们在 12 个主要代码仓库里共提交 2651 次,经受管链路落地的是 119 次,约 4.5%;其余绝大部分来自交互式会话。
  • 两条链路的约束强度也不同。受管链路的每道检查失败即拒绝(fail-closed:检查本身出错或结果不确定,也按不通过处理),执行状态迁移由数据库触发器把关。交互式链路靠钩子:规则命中时拦下,钩子自身出错时放行(fail-open),约束的是流程纪律;其中只有发布前检查失败即拒绝。
  • 两条链路在同一个仓库、同一条主干上汇合。一致性靠两件事:活动账本里「同一仓库同一时间只有一个写入者」的互斥,以及落地时对主干引用做比较并交换(compare-and-swap:只有引用仍指向预期的旧值时才更新)。
  • 技术决定先登记为待拍板事项,每个选项写明代价;影响架构的再整理成决策记录,写明否掉的备选和复议条件。238 条关键约束以标记注释写在代码旁,由脚本逐条核对。

《APSer-Atelier 的几条原则》讲做事的原则。本文从工程角度讲系统本身:边界与组件、数据模型与状态机、两条写入链路、并发与一致性、失败与恢复、安全边界、选型,以及技术决定怎样做和复议。读者设定为做软件工程的同行和技术负责人。

文中的机制、默认开关和数字,都按本文更新时(2026 年 9 月 30 日)的代码、配置和账本核对过;默认关闭或只在某条链路生效的,写明条件。出于安全考虑,不写主机、地址、端口和凭据。

一、Atelier 在交付里管哪一段?

我们的交付里有三个角色。我们提需求、拍板、在真实环境验收;Claude Code、Codex 等 AI 编程工具作为执行器写代码;APSer-Atelier 作为主控负责组织:接住意图、排队、分配写入权、调度执行器、核对结果、留下证据。APSer-Aps、APSer-One 与客户定制项目的开发,都在这套组织方式下进行。

系统上下文如下。项目仓库是唯一的事实来源,Atelier 只记录活动;两条写入链路在第二节展开:

受管链路
  操作台 → 预检 → 执行器进程(隔离目录)
        → 封存 · 验证 · 复审 → 比较并交换落地 → 项目仓库主干
交互链路
  会话(人在场)→ 写前钩子向 Atelier 查询写入权
        → git commit → 项目仓库主干
汇合点
  Atelier 账本:同一仓库同一时间只有一个写入者
  项目仓库:同一条主干
组件职责实现
活动账本一件事、运行、待拍板、写入者、验证与交付记录SQLite,WAL 模式;库内 81 张表,含只读保留的旧表
接口与操作台新建任务、预检并启动、答复待拍板、守卫与发布检查总览FastAPI,路由层 320 个接口声明;React + TypeScript 操作台
受管派发引擎预检、隔离写入、封存、验证、复审、落地、交付同一进程内的服务模块
执行器适配Codex、Claude 的固定启动参数、选择与熔断同上
守卫交互式会话钩子、git 钩子、发布检查共享脚本,各仓库只写一行转调
模型网关本地模型的知识问答与本地判断统一网关,限并发、有界重试、统一记账
表格可左右滑动

部署形态是一个模块化单体:后端服务、核心、路由三层共 193 个 Python 模块,约 12.6 万行;后端测试文件 288 个。进程由 PM2 托管,只监听本机回环地址,经私有组网的 HTTPS 入口访问。停机预留 45 秒,其中 HTTP 请求排空上限 5 秒,其余时间留给在跑的受管执行收尾。

它不运行交付给您的系统,您的系统不依赖它就能独立运行和维护;它不以保存客户资料为目的;代码与运行服务只供我们自己交付使用,不开源,也不作为多租户服务提供。

二、两条写入链路,各覆盖多少?

先把两条链路的差别列清楚。后文把受管派发链路简称「受管链路」,把交互式会话链路简称「交互链路」。

项受管链路交互链路
写入者Atelier 启动的 Codex / Claude 进程,无人值守我们在场的 Claude Code / Codex 会话
写在哪里仓库外的私有隔离目录,目录里没有 .git项目自己的工作目录
怎样提交父进程用 git commit-tree 造提交,git update-ref 带旧值更新主干引用普通的 git commit
谁来核对父进程重跑检查;中高风险候选另由另一家执行器只读复审会话内自测,我们本人验收
约束方式失败即拒绝;关键状态由数据库触发器把关钩子,异常放行;发布前检查失败即拒绝
实际占比2651 次提交中的 119 次,约 4.5%其余绝大部分
表格可左右滑动

占比按 Git 历史统计:在 12 个主要代码仓库里,取 8 月 10 日以来进入主干的提交,逐个核对受管链路账本里记录的提交号。

分项目看差别更明显。APSer-Aps 是我们的主战场,同期 537 次提交里经受管链路的只有 2 次;Atelier 自身的仓库 1243 次里有 61 次。

交互链路占主体,原因在两边的成本结构:

  • 受管链路每一轮都要先规划、冻结计划,再写、再由父进程跑测试和复审。单次计划声明超过 3 个文件、触及契约路径或含删除,就判为高风险,只能人工启动。
  • 9 月下旬的一次独立评估列出了它的吞吐瓶颈:预检规则的词表、声明范围逐字比对、无关提交触发重新规划、同一仓库串行加全量验证、执行器用量额度。
  • 交互链路里人在场,可以当场判断业务口径,一次改动可以跨很多文件,方向可以随时调整。制造业定制交付里,需要当场判断业务口径的工作占多数。

9 月 29 日我们为此拍过一次板:产品仓库的任务可以进受管队列,是否走受管链路按任务决定,不强制;交互式会话照常使用,同仓单写入者对两边同样生效。

两条链路最终写的是同一个仓库、同一条主干。这一点决定了后面讲的一致性设计:协调点在活动账本的写入者互斥和主干引用的比较并交换,不在任何一条链路内部。

三、数据模型:一件事和两套状态机

因果链

对象按「一件事怎样被做完」组织:

意图(人提出)
 └ 一件事:从提出到验收的完整记录,调度与交接的单位
    └ 运行:一次执行尝试,带单调递增的轮次号
       └ 执行轮:某个执行器的一次具体进程,可失败、可换执行器
          └ 候选改动 · 验证记录 · 复审记录 · 交付记录 · 交接点

运行和执行轮都可以失败、重来、换执行器,一件事本身不变。交互链路里我们本人「持有」一件事时,同样登记在这张表里,只是没有运行和执行轮。

一件事的表结构节选:

CREATE TABLE work_threads (
  id          TEXT PRIMARY KEY,
  status      TEXT,   -- 8 个状态,见下表
  wip_slot    TEXT,   -- deep / shallow:占哪一类执行名额
  projects    TEXT,   -- 涉及的仓库,JSON 数组,可以跨仓
  next_action TEXT,   -- 交接点:下一步做什么
  next_action_confirmed INTEGER,  -- 下一步是否已由人确认
  trigger_cond TEXT,  -- 交接点:什么条件下接着做
  blocked_by  TEXT,   -- 正在等哪一条跨项目请求
  completion_summary TEXT,  -- 完成摘要
  verification TEXT,        -- 验证结果
  requested_delivery_stage TEXT  -- landed / pushed / activated / live_accepted
);

一件事的状态机:规则写在应用层的唯一写入口

当前状态可以迁到
排队进行中、停放、完成、取消
停放排队、取消
进行中等拍板、验证中、受阻、排队、取消
等拍板进行中、受阻、取消
验证中进行中、受阻、完成、取消
受阻排队、进行中、取消
完成、取消终态,无出边
表格可左右滑动

终态集合由这张表派生,全系统判断终态复用同一个常量。唯一的例外是受管返修:一件事已完成、它绑定的那次运行被复审打回时,只允许这次运行把它精确地重新打开,完成摘要与验证结果同时清空,须重新取得。

进入「进行中」前要过四项检查:下一步非空且已由人确认;没有在等的跨项目请求;没有相关的未答复待拍板;名额与同仓写入者(见第六节)。标记完成必须同时写完成摘要和验证结果。

这些规则写在应用层,原因是它们要跨表判断:名额要数别的一件事,写入者要查同仓的其他记录,待拍板要查另一张表。它们放在唯一的写入函数里,在同一个立即写事务(SQLite 的 BEGIN IMMEDIATE,事务开始即取得写锁)内完成。这条约束只对经这个入口写入的数据成立,早期的 31 条旧记录(多数在 7 月)没有完成摘要,不追溯。

执行轮的状态机:规则写在数据库触发器里

执行轮有 14 个状态,合法迁移由一个 BEFORE UPDATE 触发器强制,任何写入路径都绕不过:

reserved → prepared → spawning → executing → validating → patch_ready
        → commit_prepared → applying → committed
validating 还可以到 no_changes / waiting_decision
各阶段可到 blocked / cancelled;spawning 之后的阶段可到 ambiguous
ambiguous 只能到 blocked 或 cancelled

放在触发器里,是因为这张表有多个写入方:执行引擎、重启恢复、交付对账。状态写错一次的后果很具体:一次已经改过主干的执行被当成「还没开始」,重启后再跑一遍。ambiguous 表示「项目主干可能已被改动、结果又无法确认」,它只能被收口,不能被重试。

旁路对象

对象作用关键约束
待拍板需要人决定的有界选择选项是 {key, label, cost} 数组,每个选项写明代价;答复连同时间、作答者、途径入账
停车场暂不处理的事项、跨项目请求跨项目请求带来源项目、来源运行和「是否要等」;要等时来源的一件事写入 blocked_by,启动检查据此拒绝
诉求账一个会话里人提出的每条要求标记完成必须挂结果引用,否则接口返回 422
线程事件一件事的追加式事件流与状态迁移在同一事务里写入
表格可左右滑动

待拍板答复后,续跑要交还给原来那次运行。这里要防的是重复作答和重复启动:

BEGIN IMMEDIATE
  待拍板 open → answered(比较并交换,第二次作答失败)
  绑定了运行的:续跑状态写 pending;选择跳过的写 blocked
  追加一条线程事件
COMMIT

之后只有一个专用函数能把 pending 改为 starting,同时签发只能用一次的内部凭证。只有能证明「启动还没尝试、回滚全部成功」时才写 retryable;其余不确定的情形保留 starting 并阻断新的受管启动,交给人处理,不自动重放。

四、受管链路:一次改动怎样从预检走到主干

受管链路要解决的问题是:让一个无人值守的进程改代码,同时保证它改不到声明以外的东西、它的「测过了」不被直接采信、落地时不覆盖别人的改动。下面按顺序讲每一步防什么、怎么做、代价是什么。

第一步:预检,先冻结一份声明计划

要防的场景:执行器顺手改了依赖清单或部署配置,事后很难从一大段改动里分辨哪些是任务要求的。

做法:每次派出(首次派出、追加指令、待拍板后的续跑)都先启动一个新的规划进程。它以只读沙箱运行、不读用户配置、不续接会话,按固定 JSON Schema 输出计划:改哪些文件、每个文件是新建、修改还是删除、会跑哪些检查、有哪些仓库外效果、还有哪些不确定项,超时 180 秒。计划连同请求和仓库快照的哈希写入预检表,之后不可修改;同一个幂等键(重复提交只生效一次的请求标识)重试时复用原记录,同一个键换了请求内容则拒绝。

风险分级是确定性的,由路径规则和计划内容判定,不调用模型:

等级条件去向
LOW只改文档和测试可以自动启动
MEDIUM有界的一般代码改动可以自动启动;候选须经复审
HIGH契约路径、超过 3 个文件、含删除、受限的构建依赖下载只能人工启动;候选须经复审
UNKNOWN计划声明范围不完整或有未决项阻断,改清任务描述后重新规划
表格可左右滑动

计划或指令里声明了推送、部署、生产环境、凭据等外部效果的,直接以策略拒绝。

代价在判定层。外部效果的识别靠文本规则,偏保守,任务描述需要写清限定语。「自动启动」由一个功能开关控制,代码默认关闭,由我们按阶段打开或关闭,本文更新时处于关闭状态。

第二步:在隔离目录里写

要防的场景:执行器进程如果看得到 .git,就能改钩子、改主干引用、改仓库配置;如果和人在同一个工作目录里写,两边会互相覆盖。

做法:父进程在仓库外建一个权限 0700 的私有目录,按当前 HEAD 的原始对象导出一份文件目录,它只是一个普通目录,不挂在 Git 仓库上,里面没有任何 Git 元数据。计划内的文件在项目工作目录里有未提交改动,直接拒绝。Git 过滤器与属性、特殊文件、未知扩展属性、大小写与 Unicode 规范化冲突,任何一项命中即拒绝。

执行器的启动参数是固定契约。Codex 以关闭钩子、忽略用户配置、忽略规则、禁用网页搜索、禁用子代理的参数启动,环境变量只继承最小集合,网络默认关闭,只对计划里声明的 npm 源开放;Claude 以受限模式、空的设置来源、严格的工具配置启动。这几个参数由测试用例钉住,删掉任何一个,对应用例失败,父进程验证就会拒绝这次候选。

第三步:封存与比对

要防的场景:执行器主进程退出后,逃逸的子进程还可能继续写;父进程如果边读边比,比对的是一棵还在变的树。

做法:执行器绑定在一个进程组里。进程组退出后,父进程以目录描述符加 O_NOFOLLOW 把整棵树完整读两遍,两遍一致才写出一份不可变的封存快照,此后生成补丁、候选和提交都只读这份快照。读取时不采信 .gitignore,也不采信执行器自报的改动清单。

随后比对「实际改了什么」和「声明要改什么」。多改了没声明的文件、改了文件权限位、内容无法校验,一律拒绝;实际风险比计划高,也拒绝。比对通过后生成候选:补丁、候选提交对象和一份权限 0600 的候选清单。

少做声明里的某一项在当前实现里是合法的。这个取舍已经出过问题,写在第十一节。

第四步:父进程验证

要防的场景:执行器报告「测试全部通过」,实际只跑了一部分,或者改动测试配置让一部分用例不再被收集。

做法:验证由父进程在一份物化的候选树上执行,执行器的自述不算数。检查项包括:git diff --check,按提交范围的密钥扫描,候选改到的测试文件先跑、失败即短路,通过后再跑全套,按项目登记的构建命令。Python 项目的候选改到测试收集配置时,父进程先在基线树上只收集不执行、数出用例数,候选实际执行的用例比基线少即判失败。目标仓库有变更记录文件的,候选里必须真的改了它;改到项目入口说明文件时,用平台正在运行的检查脚本复核体量预算,不采信候选树里自带的那一份,防止候选连同检查一起放宽。

验证记录只保存命令、退出码、产物哈希、执行者和时间,从不保存输出正文:

CREATE TABLE dispatch_verification_receipts (
  execution_id         TEXT NOT NULL UNIQUE,
  run_id               TEXT NOT NULL,
  candidate_sha256     TEXT NOT NULL,
  candidate_commit_sha TEXT NOT NULL,
  base_head            TEXT NOT NULL,
  target_ref           TEXT NOT NULL,
  artifact_sha256      TEXT NOT NULL,
  policy_sha256        TEXT NOT NULL,  -- 验证策略实现的哈希
  status TEXT NOT NULL CHECK(status IN ('passed','invalidated'))
);
-- 触发器:绑定字段写入后不可改,invalidated 不能改回;
-- 每项检查的明细行不可更新、不可删除。

第五步:跨家族只读复审

要防的场景:同一家模型写、同一家模型审,容易共享同一类盲点。

做法:MEDIUM 和 HIGH 候选在落地前交给另一家执行器做只读复审:Codex 写的由 Claude 审,Claude 写的由 Codex 审,同家自审被禁止。复审者只拿到冻结候选的交接包:哈希、基线、相对路径、检查项编号,拿不到写权限、仓库所有权和凭据。结论是 go 或 no_go,发现项一次性写入,结算后数据库拒绝追加。这道门目前处于强制模式,no_go 的候选不能落地。

第六步:落地到主干

要防的场景:候选生成期间,有人在交互链路里往同一条主干提交了新改动;直接覆盖会丢掉那次提交。

做法:只有验证记录与这一次的一件事、运行、执行轮、基线、主干、候选全部对得上,才进入 applying。候选提交的父提交就是主干当时的 HEAD,落地就是把主干指针前移一格,用两条 Git 底层命令完成:

git commit-tree <候选树> -p <基线提交>
    # 用候选树直接造提交对象,不经过工作区
git update-ref -m "Atelier controlled local commit" <主干> <候选提交> <基线提交>
    # 第三个参数是旧值:主干此刻仍指向基线才移动,否则失败

第二条命令就是一次比较并交换。落地时禁用钩子、提交签名和全局与系统级 Git 配置;前后都核对 HEAD、主干引用和树对象;工作区里与本次无关的已暂存、未暂存和未跟踪改动原样保留。主干已被移动,就以 target_stale 拒绝,任务回到队列按新的 HEAD 重新规划。

第七步:交付阶段

受管链路本身不推送、不部署。推送、激活和线上真实验收由按项目登记的交付流程接着做,每个阶段单独留一条记录:landed、pushed、activated、live_accepted,前一个不能冒充后一个。只有在代码里登记了发布与回滚步骤的仓库才会走完四个阶段,目前是 5 个真实仓库(含 Atelier 自身);其余仓库停在本地主干提交。发布后检查不通过,走登记好的回滚步骤。

验证或复审被拒的候选,在原来的一件事上返修;同一件事的候选连续 15 次不收敛即停止,交给人处理。

五、交互链路:人在场时靠什么约束?

交互链路是主力,但它的进程不归 Atelier 启动:会话跑在我们的终端或桌面应用里,直接写工作目录、直接执行 git commit。Atelier 能介入的只有两类挂载点:执行器提供的钩子(收到消息时、调用工具前、一轮结束时),以及 Git 的钩子(提交前、写提交说明时、推送前)。

机制挂在哪里防什么出错时
写前持有调用写文件工具、执行 git commit 前两个写入者同时改一个仓库放行
诉求账一轮结束时;接口侧人提的要求没登记、没做完就被标完成钩子放行;接口拒绝
提交检查Git pre-commit、commit-msg漏写变更记录、不在主干上提交、暂存区含密钥、缺署名放行
发布门Git pre-push、打上线标签前未经检查就推送或标记上线拒绝
表格可左右滑动

写前持有。 会话第一次写某个仓库前,钩子向 Atelier 查询:本会话是否持有一件包含该仓库的「进行中」的事。没有就拦下,并给出挂上一件事的命令。持有走的是第三节同一个状态迁移入口,因此和受管链路共用同仓单写入者的判定:交互会话持有了某个仓库,受管链路进不去;受管执行在跑,交互会话也持有不上。交互会话的持有不占受管执行名额,这是 9 月 28 日拍板的口径。这道门对 Claude Code 的交互式会话和 Codex 的交互式会话都已挂上。

它的边界要说清楚:持有者标签由会话自报;Atelier 不可达、令牌缺失或钩子异常时放行;另有写明原因的豁免。所以它是流程纪律,挡得住疏忽,挡不住有意绕过。

提交检查。 所有仓库转调同一份共享钩子:暂存区必须包含变更记录;只在主干上提交;项目入口说明文件的体量不超预算;暂存的前端新增行不含硬编码颜色;暂存区密钥扫描;提交说明必须带署名行。这些检查都有写在命令里的逃生门,用于确需跳过的场合。

发布门。 这是交互链路里唯一失败即拒绝的一道。仓库声明了发布检查清单,推送前和打上线标签前就必须跑通:没跑完、报错、超时、结果不合格式都算不通过。检查结果登记回 Atelier。目前在代码里列为必检的只有官网一个仓库,推送前检查已记录 82 次。

为什么大部分钩子选择放行。 钩子依赖外部进程:Atelier 服务、Python 解释器、执行器的钩子加载机制。如果全部设成失败即拒绝,Atelier 自身的一次故障就会放大成所有项目停工。代价是可能静默失效,所以要配上可观测性:现在守卫页按实际挂载列出全部守卫(Claude Code 钩子 20 项、Git 钩子 6 项、Codex 侧 8 项),与登记清单逐条比对漂移,并直接显示近 7 天的拦截和报错次数。

两条链路的守卫是分开的。受管进程以固定参数启动,不加载交互会话的任何钩子;受管落地走底层命令,Git 也不在这条路径上运行钩子。所以受管链路在父进程验证里重新实现了对应检查,第四节写到的变更记录和入口说明文件复核就是这样来的。

交互会话里还可以派出子代理,它们与主会话共用同一个工作目录和同一组钩子,Atelier 不单独隔离;同一仓库内的多个子代理,靠我们按文件划分、串行派出来避免冲突。

六、并发与一致性怎样保证?

写入者互斥(single-writer)。 同一个仓库在任一时刻只允许一个写入者,受管执行和交互会话的持有都算。判定在进入「进行中」的迁移里完成:查询其他占用名额的一件事,与本次涉及的仓库有交集即以 repo_writer_busy 拒绝。

立即写事务。 SQLite 的 BEGIN IMMEDIATE 在事务开始时就取得写锁,保证「检查」和「写入」之间不会插入另一个写事务。所有让一件事进入「进行中」的路径,都在一个这样的事务里同时检查四件事:总名额、分类名额、同仓写入者、相关的未答复待拍板。每一次真正启动执行器进程,也在这样一个事务之内完成:事务先证明「此刻没有与本次运行相关的待拍板」,再启动进程。

名额。 受管执行的名额写在配置里:总数 5,深度工作 4,轻量 1,每个仓库最多 1 个写入者。只有受管的一件事占名额。

主干引用的比较并交换。 这是两条链路汇合处的最后一道。典型场景:受管候选以提交 A 为基线生成,期间交互会话在主干上提交了 B;落地时 update-ref 的旧值 A 与当前值 B 不符,命令失败,候选作废并按 B 重新规划。

单实例。 后端先绑定监听端口,再对数据库对应的锁文件取得操作系统级独占锁,然后建立唯一的状态库连接,做一次只读的完整性检查和对象一致性扫描,恢复能证明已经退出的执行,最后才开放派发入口。锁文件里的进程号只用于诊断,从不据此判断锁是否有效,也不据此结束进程。

持久化。 状态库用 WAL 模式(Write-Ahead Logging,先写日志再合并回主文件,读不阻塞写),同步级别为 NORMAL:掉电或系统崩溃时,最后几个已提交的事务可能回滚,但数据库保持一致 [2]。

幂等。 预检按幂等键去重;待拍板答复、续跑领取都是比较并交换;仓库写入者账本的领取与释放按代次比较并交换;交付记录在数据库层按「执行轮 + 阶段」唯一。

七、失败与恢复

进程崩溃。 每个受管执行轮记录后端的代际号、进程号、启动时间,每 5 秒一次心跳,所有状态迁移都要求代际号与心跳序号一致。后端重启后,只有能证明原进程组已经不存在、且项目主干没被改动的执行,才会被收口为「受阻」;心跳过期、进程号被系统复用、正处于 applying 或 ambiguous 的,一律保留为阻断项,交给人处理。

验证或复审失败。 留在原来的一件事、原来的运行里返修,下一轮可以在上一轮被拒的候选之上继续改,基线仍锚定原 HEAD。连续 15 次仍不收敛即停止。

交付失败。 对登记了交付流程的仓库,发布后检查不通过就执行登记好的回滚步骤;推送失败停在 landed,不记 pushed。

数据备份。 状态库每天备份一次:用 SQLite 的在线备份接口生成包含已提交 WAL 的一致快照,在临时区跑完整性检查后原子发布,记录 MD5,保留 30 天。较大的表结构迁移前,另存一份整库快照。恢复演练把备份还原到一个 0700 的临时目录重新检查,结束后清除,不碰现场库。凭据类文件从不进备份。

八、安全边界在哪里?

入口。 后端只监听本机回环地址,操作台经私有组网的 HTTPS 入口访问。浏览器会话用 __Host- 前缀、HttpOnly、SameSite=Strict 的 Cookie,库里只存令牌的哈希;会写数据的请求必须带防跨站请求头,并校验来源与 Sec-Fetch-Site。

执行器权限。 规划进程在项目目录上以只读沙箱运行,不读用户配置,环境变量里的令牌、密钥类变量先被剔除;写入进程只看得到隔离目录,没有 Git 元数据,网络默认关闭;复审进程只读;写入和复审进程都不加载个人配置和钩子。推送、部署和凭据都不经执行器之手。

数据闭集。 Atelier 对持久数据采用显式闭集、默认拒绝。能存的是活动标识与关系、调度状态、人工控制记录、哈希与验证元数据、可重建的索引元数据、完成调度所需的短文本(标题、开发意图、下一步、触发条件)、项目登记信息、指向项目仓库已提交文件的知识指针、用量计数。客户资料、合同、外部项目的进度与决定副本、凭据、对话原文和工具输出默认拒绝。

实际落地的情况照实写。通用事实与记忆的写入口由应用层和数据库触发器双层强制;Codex 会话的索引表有 36 万多行,只存事件类别、角色、工具名、偏移和哈希,正文列全部为空;本地模型的判断依据写到本机一个被 Git 忽略、权限 0600 的文件里,库里只存指针和哈希。派出记录会保存本次任务说明和一行结果摘要,属于「开发意图」这一类短文本,平均一千多字。

触发器防什么。 不可改的触发器防的是应用代码写错和经接口的篡改,防不住本机有文件权限的人直接改库文件。这一层靠单用户机器、文件权限和备份哈希兜底。

九、技术栈为什么这样选?

层选择理由放弃的备选
整体结构模块化单体,按故障域预留拆分点拆服务的主要价值是团队之间解耦,一个人用不上;服务发现、链路追踪、多仓库管理的成本却是实在的微服务,4 月的第一份架构决策记录否掉
后端Python + FastAPI调模型、跑检查脚本、操作 Git 在同一种语言里完成,检查脚本和服务共用模块—
存储SQLite,WAL,操作系统级单实例锁单机单用户;事务、外键、CHECK 约束、触发器齐全,不变量可以写进库;在线备份接口现成;零运维独立数据库服务:多一个要维护的进程,并发收益用不上
约束放哪层多写入方的状态放触发器;跨表判断放唯一写入口触发器绕不过但表达力有限;跨表检查要在一个事务里读多张表全放应用层:多写入方时总有一条路径漏检
落地方式commit-tree + 带旧值的 update-ref不经过工作区、不触发钩子、失败时主干原样不动在工作区 git apply 再 git commit:会碰到人的未提交改动,还会运行交互链路的钩子
前端React + TypeScript + Vite + Tailwind + Radix前后端数据结构写错,在改代码时就能被类型检查发现—
模型调用统一网关,逐步收拢模型调用慢且不稳定,是独立的故障域,集中后可以限流、重试、记账各模块直连:限流与重试散在各处
知识问答人工审定、互相链接的概念页 + 代码检索可溯源;向量检索在因果类问题上容易自信地答错向量检索,在执行层直接禁用
配置读写往返保留注释的 YAML 解析操作台保存配置时不能把注释整段删掉普通 YAML 读写,曾把注释删光
表格可左右滑动

十、技术上的决定怎样做,又怎样复议?

从待拍板到决策记录。 需要人选择的事项先登记为待拍板:问题、有界的选项、每个选项的代价,一般标出推荐项。我们在操作台上作答,答复连同时间和途径入账。影响架构方向或工作方式的,再整理成一份决策记录。这借鉴了软件行业公开的架构决策记录(ADR)做法:用一份简短文档记下一项决定的背景、决定本身、状态和后果,被推翻时旧记录保留并标明已被取代 [1]。

我们的模板固定为:元信息(精确到分钟的时间、场合、范围、影响项目、状态)、背景、备选方案表(每个方案的杠杆位置、代价、风险)、一句话的决定、影响范围(执行项拆到一次提交的粒度)、反例预防、复议条件。目前 Atelier 自己的仓库里有 42 份决策记录、11 份提案,统一索引的状态只能取 8 个固定值。

两道机器门。

  • 决策目录只增不删:提交前和推送前,一个失败即拒绝的脚本逐个检查新文件是否带齐机器可读的元信息;删除、改名、改文件类型一律拒绝。记录里引用的拍板编号必须在库里有答复,作答时间与登记时间同一秒的也拒绝。脚本注释写明:这个时间差只是拒绝依据,证明不了作答人是谁。
  • 红线写进代码:最关键的约束以标记注释写在对应模块旁,附锚点词;确定性脚本按清单逐条核对,缺标记或缺锚点即报错。清单现有 238 条,脚本同时挂在提交钩子和受管验证里。

复议。 整体取代旧记录的,旧记录在索引里标为已取代;只推翻其中一条的,新记录写明精确覆盖哪一条,旧记录其余条款继续有效。三个例子:

  • 名额 4 改 5。 旧配置总名额 4、深度名额也是 4,四路深度工作占满以后,轻量那一路先撞上总名额被拒,轻量名额存在却永远拿不到。代码前一天已经改好;9 月 10 日的记录补上依据,只覆盖总名额这一条,并要求配置契约测试锁定 5 / 4 / 1 / 1。
  • 项目知识问答依据什么。 三个方案:由程序按规则路由到人工审定的概念页(选定);让模型自己决定查不查(否:回到靠模型自觉);每次两种都查再综合(否:慢近一倍,噪声大)。落地时在执行层禁用向量检索工具,调用即拒。复议条件写明:规则路由经常答非所问,或者引用后答案质量没有实测改善,就重新评估。
  • 产品仓库要不要进受管队列。 这是第二节那次拍板。原规则是 Atelier 对产品仓库只做观测和记录;评估数据显示产品仓库几乎全部走交互链路之后,改为「可以进、不强制」,同时写明回退办法:再拍一次恢复原条款,已在队列里的任务按普通事项搁置或取消,不影响已落地的提交。

十一、已知局限与踩过的坑

现象根因现状
受管提交新增的文档没登记,主检出随后过不了提交检查commit-tree 与 update-ref 是底层命令,不运行提交钩子落地前由父进程补齐文档登记;其余钩子检查在父进程验证里逐项对应
交互会话持有某仓库期间,自动启动仍对该仓库的排队任务做规划自动启动在规划前只查受管写入者规划前先查同仓写入者,有则不规划
改动 Codex 钩子配置后,对应钩子不再运行,也不报错Codex 只运行信任哈希与当前内容一致的钩子守卫页显示每条钩子的信任状态
轻量名额存在却永远拿不到总名额与深度名额同为 4,总名额先判总名额改为 5,契约测试锁定
本地模型服务两处配置各写一遍超时,调长超时的补丁不生效同一份配置有两个出处收口到唯一一处
表格可左右滑动

还有两条结构性的局限。第一,受管链路单次计划超过 3 个文件就要人工启动,大一点的改动天然流向交互链路,这正是它只占约 4.5% 的主要原因之一。第二,交互链路的约束以放行为默认,它能证明的是「规则挂上了、没被触发」,不能证明「没有人绕过」;需要留下可核对证据的环节,目前只有受管链路和发布门做得到。

以下是我们的看法

以下内容是我们的观点,供您参考。

第一,先弄清每条机制覆盖多大的面。 一条机制再严密,只覆盖 4.5% 的提交,就只能对这 4.5% 作保证。描述一个系统时,把「在哪条链路上、默认开没开、占多少」和机制本身一起写出来,读者才能判断它到底保证了什么。

第二,不变量尽量写在靠下的层。 状态迁移触发器、CHECK 约束、不可改的记录、带旧值的主干更新,都不依赖某段应用代码、某个模型或某个人记得。写不进底层的跨表规则,就收口到唯一的写入口。

第三,执行器的自述不算证据。 候选由父进程封存和比对,验证由父进程重跑,复审换另一家,落地用比较并交换,这几件事都不经执行器之手。

第四,放行式的守卫要配上可观测性。 选择异常放行是为了不让基础设施故障拖垮开发,代价是静默失效;报错计数和挂载漂移比对,是这个选择必须附带的部分。

第五,评估交付方时,您可以问几个问题: 改动在进入主干之前由谁核对,核对结果能否被篡改?这套核对覆盖了多大比例的改动?落地失败时主干是否保持原样?两路改动会不会互相覆盖?关键技术决定记在哪里、比较过哪些备选?

如果您关心交付方的工程做法,欢迎预约交流,我们可以对照您的项目聊聊这套方式适不适合。

参考来源

  1. Michael Nygard, "Documenting Architecture Decisions", Cognitect Blog, 2011-11-15
  2. SQLite, "Write-Ahead Logging", sqlite.org