多人实战总结:合约类文件(OpenAPI/DB migration/工具 schema)被 AI 自由派分支污染后,生产事故排查成本极高。提出 6 条「拒绝合并」红线。
下面的场景是一起复合故障的舞台化呈现。夜班事故往往始于未签署的 schema diff。Checkout webhook 开始拒绝每一个带签名的订单 payload。
Staging 分支合并了一个来自 agent 分支的晚间清理。一个 free-lane 草案将 tax_id 字段标记为可选。而生产环境的客户端仍然将该字段视为必填。
计费管道陷入停滞,运维人员在追踪那个未签署的 diff。Agent 的自检报告了本地解析通过。同一个模型写了这个 patch,然后又给它打了分。
本文是一份「何时不该用」的实战指南。面向的是 contract 级别的修改,而非注释更新。Free-lane 草案可以存在,但不能拥有锁。
当其他系统必须遵守某个文件时,称这个文件为 contract。共享 schema 比提出它的对话存活得更久。静默删除字段会演变成多服务事故。
以下制品应视为 contract 级别的变更:
README 调整不是 contract 级别的变更。单个服务内部的日志格式实验也不是。删除必填字段永远是 contract 级别的变更。
眼下业界讨论中 vibe coding 和实际工程工作混为一谈。这种混合在 scratch 分支内是无害的。一旦外部调用方依赖这些字节,就变得代价高昂。
Free-tier 草案通常看起来本地正确。Agent 在 diff 旁打印绿色自检。那项检查通常用的是写出该 diff 的同一个模型。
循环评分掩盖了缺失的消费者约束。仓库外的调用方仍然发送旧的数据结构。那些调用方在没有版本或过渡窗口的情况下就会崩溃。
然后支持侧看到的 payload 错误找不到负责人。Schema lock 从未分配给人工审查员。Rollback 变成了又一次生成,而不是一个固定的回滚。
当以下任意标志为真时,拒绝晋升。
Diff 涉及 contracts、migrations 或工具 schema 路径
起源标签等于 free-tier 或 scratch-server
同一个模型写 patch 并写测试
必填键在未版本升级的情况下变成可选
Enum 值在无文档化过渡窗口的情况下消失
Apply 步骤没有前一个 lock 的校验和
Rollback 意味着生成另一个 patch 而不是回滚
变更改变了认证、金钱或删除语义
测试只断言解析成功,跳过消费者 fixture
Free server 进程也运行 apply 作业
Free-lane patch 递增版本号以自我批准
任意一个标志就足以阻止晋升。两个标志意味着分支应回到 scratch。
把这张表放在 merge box 旁边。
Draft 意味着字节永远不会离开 scratch 分支。Lock 意味着人或可信作业签署摘要。Refused 意味着 apply gate 必须以非零退出。
下面的 Node.js gate 是一个未执行的提案。运维人员应先在 staging 分支上试用。它拒绝来自 free 或未知起源的 contract 路径。
它还将文件摘要与已提交的 lockfile 进行比较。新的摘要必须已经存在于 pending map 中。未签署的字节永远不会到达 apply 作业。
#!/usr/bin/env node
'use strict';
const fs = require('fs');
const path = require('path');
const crypto = require('crypto');
const CONTRACT_PREFIXES = [
'contracts/',
'migrations/',
'tools/schema/',
'openapi.yaml',
'openapi.yml'
];
const FREE_ORIGINS = {
'free-tier': true,
'scratch-server': true,
'best-effort-model': true
};
function sha256(filePath) {
const buf = fs.readFileSync(filePath);
return crypto.createHash('sha256').update(buf).digest('hex');
}
function isContractPath(rel) {
const normalized = rel.split(path.sep).join('/');
return CONTRACT_PREFIXES.some(function (g) {
return normalized === g || normalized.indexOf(g) === 0;
});
}
function loadChangedFiles() {
const raw = process.env.CHANGED_FILES || '';
return raw.split('|').map(function (s) {
return s.trim();
}).filter(Boolean);
}
function main() {
const origin = process.env.PATCH_ORIGIN || 'unknown';
const lockPath = process.env.SCHEMA_LOCK || 'schema.lock.json';
const changed = loadChangedFiles();
if (!fs.existsSync(lockPath)) {
console.error('missing schema.lock.json');
process.exit(2);
}
const lock = JSON.parse(fs.readFileSync(lockPath, 'utf8'));
const contractHits = changed.filter(isContractPath);
if (contractHits.length === 0) {
console.log('no contract-class paths; apply gate skipped');
process.exit(0);
}
if (FREE_ORIGINS[origin] || origin === 'unknown') {
console.error('refuse: free or unknown origin on contract paths');
console.error(contractHits.join('\n'));
process.exit(3);
}
for (let i = 0; i < contractHits.length; i += 1) {
const rel = contractHits[i];
if (!fs.existsSync(rel)) {
continue;
}
const digest = sha256(rel);
const expected = lock.files && lock.files[rel];
if (!expected) {
console.error('refuse: contract file missing from lock: ' + rel);
process.exit(4);
}
const pending = lock.pending && lock.pending[rel];
if (digest !== expected && digest !== pending) {
console.error('refuse: unsigned digest for ' + rel);
console.error('got ' + digest);
process.exit(5);
}
}
console.log('contract gate passed');
process.exit(0);
}
main();
将该文件保存为 contract_gate.js,放在 lockfile 旁边。将脚本与 schema 一起纳入版本控制。对 gate 的修改也视为 contract 级别。
示例 lockfile,标注为 stub:
{
"version": 1,
"files": {
"contracts/order.schema.json": "REPLACE_WITH_SHA256"
},
"pending": {}
}
在任何真实运行前替换 stub 摘要。不要在生产仓库中提交空哈希。
一个小 signer 在人工审查后写入 pending 摘要:
#!/usr/bin/env node
'use strict';
const fs = require('fs');
const crypto = require('crypto');
const lockPath = process.env.SCHEMA_LOCK || 'schema.lock.json';
const file = process.argv[2];
if (!file) {
console.error('usage: node sign_pending.js <contract-file>');
process.exit(1);
}
const lock = JSON.parse(fs.readFileSync(lockPath, 'utf8'));
const digest = crypto
.createHash('sha256')
.update(fs.readFileSync(file))
.digest('hex');
lock.pending = lock.pending || {};
lock.pending[file] = digest;
fs.writeFileSync(lockPath, JSON.stringify(lock, null, 2) + '\n');
console.log('pending ' + file + ' ' + digest);
在创建时标记每个 patch,而不是事后推断。文件已经变更后不要再去推断起源。
export PATCH_ORIGIN=free-tier
export SCHEMA_LOCK=schema.lock.json
export CHANGED_FILES="$(git diff --name-only origin/main | tr '\n' '|')"
node contract_gate.js
echo $?
Free-lane contract diff 应以退出码 3 退出。那次拒绝是 gate 的全部目的。
只有在 lock owner 写入 pending 摘要后才能晋升。
node sign_pending.js contracts/order.schema.json
export PATCH_ORIGIN=human-lock
node contract_gate.js
echo $?
将消费者 fixture 放在 schema 文件旁边。对每个 pending 摘要运行那些 fixture。
mkdir -p tests/fixtures/orders
npx --yes ajv-cli validate -s contracts/order.schema.json -d tests/fixtures/orders/*.json
如果 fixture 失败,立即删除 pending 摘要。不要用同一个 free 模型修复 fixture。
一个最小的 order fixture 可能长这样:
{
"order_id": "ord_example_not_real",
"customer_id": "cus_example_not_real",
"tax_id": "XX-0000000",
"total_cents": 1099
}
保留多个 fixture,包括一个必须失败的。接受所有 blob 的 schema 不是 lock。
在一次性克隆上运行这些步骤。不要将 gate 指向实时 apply runner。
分支审查期间跳过第 10 步。只在合并后将摘要写入 files。
该计划不需要生产流量,不需要 vendor 配额声明。它只证明 origin 和摘要策略。
将 free lane 用于探索。将 signed lock 放在可信作业或人上面。
人类在任何必填字段变更前写一份简短的 RFC。
可信的 renderer 只从 signed schema 发出代码。
消费者发布 fixture,供起草 agent 无法修改。
Ship v2 文档,而不是静默删除字段。
通过校验和 pin 回滚,而不是让模型撤销。
添加型可选字段仍然可以从 scratch draft 开始。删除和类型变更必须从 locked 路径开始。重命名是删除加添加,不是清理。
版本号属于 lock owner,从不属于 draft。Free-lane 将 v1 升级到 v2 仍然是未签署的 contract。将 v2 作为新文件发出,然后在日历上废弃 v1。
Scratch server 可以安全地托管一次性 agent。该主机不应运行 migration apply 或 cluster apply。只在 signed、non-free runner 上排队不可逆作业。
当任意项目触发时,离开 free lane 用于 contract 工作。
退出后,在 CODEOWNERS 中冻结 contract 路径。Merge 前要求 lock 签名。
/contracts/ @schema-lock-owners
/migrations/ @schema-lock-owners
/tools/schema/ @schema-lock-owners
保持 owners 列表精简且有人值守。空的 CODEOWNERS 文件不是 lock。
Drafts 仍然需要一个低成本失败的地方。这种分离是 free-tier access 的有用部分。
披露:本文是 MonkeyCode 产品推广的一部分。
MonkeyCode 包括免费模型访问和免费 server 选项。它们适合 scratch diff、prompt 试用和一次性 agent 循环。它们不是 lockfile 或生产 apply 的归宿。
团队可以在 scratch server 上发出候选 schema。然后人工将字节复制到 locked 分支。上面的 gate 忽略 vendor 名称,只读取 origin 标签。
在少数狭义情况下跳过此 gate。
不要将 gate 用作备份的替代品。被拒绝的 apply 不会恢复昨天的数据。
不要将 gate 视为秘密扫描器。它只检查 origin 标签和文件摘要。
脚本信任 PATCH_ORIGIN 环境变量。标签错误的作业将绕过整个策略。
前缀列表是有意不完整的。每个团队必须为本地布局扩展它。
校验和相等不能证明语义安全。它只证明 lock owner 看过那些字节。
Fixture 验证会遗漏许多行为破坏。金钱舍入和时区转换仍然需要领域测试。
本文不发布负载数字或模型名称。它不声称有配额、硬件或正常运行时间。
Free-tier 行为会在无通知的情况下漂移。Gate 假设那种漂移是正常的且不可信的。
把这张表放在 merge box 旁边。
如果第 2 步或第 3 步失败,停止 merge。不要与 free-lane draft 谈判。
Contract 级别的编辑应该每次都 fail closed。Free-lane drafts 可以保持 draft,无需道歉。