跨文件数据漂移是审计的盲区——单文件类型检查通过但整体事实矛盾。解决方案是把审计写成CI构建脚本,每次构建前自动运行,而非依赖定期人工审计。
我的旅行网站的法律状态数据集做过两次审计。第一次审计发现同一个事实被存了四个地方,各自独立漂移。第二次发现漂移有了第五个归宿——正文 prose——最后我写下一句话:一个半派生页面自相矛盾,"不是风险,是排期。"
六周后,排期再次交付。某个国家的状态在一个文件里是错的,在另一个文件里是对的。另一个国家在数据库里被放错了大洲,悄悄地把一个组件构建出来的链接弄坏了——两个死链出现在线上页面,而数据是所有人都审过的。
两次审计都是真实工作,交付那天都是对的。这就是问题所在。审计认证的是一个时刻。数据集有四个写入方——管理后台、一个编辑文件的 agent、我用的代码编辑器、偶尔运行的迁移脚本——系统里没有任何机制在审计员离开后维持一致性。每个写入方产生的输出单独看都是合法的。类型检查器高兴,构建是绿的,网站却自相矛盾了,因为合法性的检查是按文件来的,而真相横跨文件存在。
所以第三次审计不是一个会话,而是一个脚本,它在任何其他构建步骤之前运行。
网站的法律事实存在 Postgres 数据库和四个静态 TypeScript 文件里——一个给美国各州用,一个给国家详情页用,一个给地图用,一个给搜索自动补全用。检查脚本通过公开的只读 API 拉取实时表,加载四个静态文件,然后比较每个在多处出现的值。
有两个设计决策比 diff 算法本身重要得多。
它加载的是应用的真实模块,而不是正则抓取。检查脚本打包实际的 TypeScript 文件并 import 它们,然后复用应用自己的 slug 转换、名称规范化、合并逻辑。听起来像过度设计,但试过另一种方案就知道了:一个用自己的解析和 slug 规则的抓取器验证的是一个不存在的网站。早些时候,我的临时名称规范化器和应用的规范化器对cedilla的处理不同,导致一个国家明明在数据库里却显示"数据库缺失"。唯一值得自动化的比较,是渲染代码会做的那个比较。
不是所有不匹配都是失败。两种读者能访问到的来源之间的矛盾——数据库说一个状态,搜索下拉说另一个——是错误,会中止构建。一个国家存在于静态文件但没有数据库行是警告:目前没有不一致,但那个页面在无人管理下运行,在管理后台做的编辑永远不会到达它。错误是谎言;警告是盲区。把两者混为一谈要么让检查变得可忽略,要么让它变成骗子。
两条小规则很快证明了自己的价值:
运行失败即失败。如果数据库不可达,检查脚本非零退出并打印"这不是通过。"一个在无法验证时静默通过验证步骤,比没有这个步骤更糟——它把故障洗成绿色构建。
基线必须缩减。第一天产生 28 个已知冲突,如果在所有冲突解决前阻止每次部署,一周内这个检查就会被删掉。所以已知差异放在基线文件里——但每个不再符合现实的基线条目也会被报告。只做抑制的基线会成为谎言的第二份副本。我的基线三天内从 28 → 26 → 0,空基线就是契约:未来任何地方有任何不一致,都算构建失败。
构建这个检查迫使我去读实际的数据流,而数据流和我以为的完全不同。很长一段时间我以为数据库只是管理后台的记账工作——我还把它白纸黑字写下来了。错了。我忘记了一个合并函数会在每个国家详情页用数据库行覆盖静态文件。数据库一直在网站访问量最大的页面上占上风,而地图和搜索一直在渲染静态值。
这就是四路分裂的本质:同一个事实在同一个网站的不同页面上有不同的权威来源。不可能靠审计值来解决这个问题;我必须知道"不一致"这个词到底意味着什么之前,哪个副本在哪个页面赢。检查脚本现在编码了这些知识——这是唯一一个把真相的完整路由写下来并执行的地方。
第一天的错误分裂得很清楚。两个是死链——数据库里错误的大洲值,五分钟修好。另外 26 个看起来像是 26 个国家各自的独立事实争议,但完全不是这么回事。一个数据源在另一个说非法或去罪化的地方说了 51 次"医疗",因为两个数据源对"医疗"这个词的定义不同。一个把任何含有"医疗"一词的法规都算进去——包括那些整个项目只是给出口许可的项目,在那个国家没有任何居民能开到药。另一个描述的是一个人在那里实际能做什么。
一个一个仲裁会产出 26 个永远可以重新争辩的判断。替代方案是在数据文件顶部写一个定义,下一个编辑者——人还是 agent——不可能看不到:
// STATUS FIELD — what the value means (settled, do not re-litigate)
//
// `status` describes WHAT AN ORDINARY VISITOR FACES, not what the
// statute book contains. Export-only licensing is NOT "Medical".
// "Decriminalized" means no CUSTODIAL penalty. When two statuses
// both apply, the one describing possession by an ordinary
// person wins.
一个定义解决了全部 26 个。而且它用证据而不是感觉决定了信任哪一边:唯一两个反方向不一致的国家,都在现实世界中早在几年前就改了状态——数据库追踪了变化,静态文件没有。唯一在这些hard case上正确的那个数据源,被相信去判断其余的情况。
这个定义持续产生价值。后来在播种新行时,我遇到两个纸上法律几乎一模一样的国家——小量持有在一个国家不受监禁惩罚,在另一个国家受"行政"惩罚。第一个是罚款或社区服务:去罪化。第二个允许十五天拘留:非法,因为牢里十五天是监禁,不管法规叫什么。没有书面规则,这两行会是看那天心情决定的。
比较检查有一个结构性限制:它只能检查存在于两个存储中的事实。63 个国家有页面但没有数据库行——不可能有矛盾,也不可能有保护,对这些国家的任何管理后台编辑都会静默地无处可去。所以最后一个阶段是逐区域播种每个缺失的行,每个值在放入前都对照当前一手来源验证。
那次播种本身就是一次审计——最后一次手动的——它发现了网站好几个月来一直带着错误的三个状态:一个巴尔干国家,其医疗项目今年一月生效(排在搜索结果首位的"反驳"这条的是 2021 年的文章——它没有反驳这个变化,它比变化还早),一个印度洋岛国 2022 年悄悄修订了毒品法,还有一个南美国家早在 2022 年就把小量持有改成了咨询辅导代替监禁。又有三个纠正,是我自己来源之间的 diff 永远不可能发现的,因为每个内部副本都一致同意那个过时值。
盲区现在关闭了:网站上每个国家都有一行,每行每次构建都比对,检查脚本第一次完全绿色运行时报告两个存储在网站历史上首次完全一致。
审计是快照;构建门禁是契约。如果一个事实有多个写入方,副本之间的一致性需要一个在创建它的会话之外仍然存活的执行者。
用应用自己的逻辑比较,否则别费心。检查器用自己的解析验证的是一个不存在的网站。
错误是矛盾;警告是盲区。读者可及的分歧让构建失败。空白要被计数,大声地计数,直到被关闭。
无法运行的检查必须失败。"无法验证"报为成功是验证步骤能有的最昂贵的 bug。
基线必须缩减。把过时的例外和新失败同等严肃地报告,把文件减到零。空基线是唯一有意义的版本。
当两个副本长期不一致时,在仲裁个案之前找缺失的定义。26 个有共同形状的争议是一个争议。把规则写在数据所在的地方,下一百次编辑都会继承它。
两次审计教会了我副本在哪里。检查才是最终让它们一致的东西——下一次它们不一致时,我不会在审计中发现。我会在红色构建中发现,发生在几分钟之后。