测试全绿,同一订单却扣了 100 次:AI 代码上线前的 6 场对账实验

7 阅读19分钟

cover-v2.png

一笔 19.99 元的订单,正常调用一次,测试通过。

把同一份请求重放 100 次,账上却出现了 100 笔记录,总计 1,999 元。

再给代码加上幂等键,重复请求的问题消失了。看起来可以上线了?把进程停在“记完账、还没保存回执”的瞬间,再启动重试,账又错了。

一段代码能把一件事做成,和它能在重试、并发、中断之后仍然把账对上,是两种验收。

这篇文章把它们放在同一张表里。

先交代实验口径:下文的“扣款”均为本地 SQLite 的模拟记账,没有发生真实资金交易。三个版本是为对照而刻意构造的教学实现,不是抽样某些模型后得到的测评结果。我们要检查的是验收方法,同样适用于人工代码和 AI 代码。

先看账单:加了幂等键,仍然有四个坑

我准备了三个实现,让它们处理同一份请求:

用户:alice
订单:order-001
金额:1999 分
幂等键:request-001

金额用整数“分”,避免让浮点误差干扰这次实验。重放时,订单、金额、用户、幂等键都保持不变。

三个版本的区别是:

版本做法
V0:能跑版收到请求,直接记账
V1:加键版先查幂等回执;没有就记账,再保存回执;每一步单独提交
V2:对账版用用户和操作隔离幂等键,校验业务参数;本地记账与回执在同一事务中提交,事务内再次检查

三个版本都通过了一次正常路径检查:返回正确回执,账上恰好增加 19.99 元。到这里,3 个检查全部是绿的。

随后更换验收场景。本机 Python 3.13.3、SQLite 3.49.1 的实际运行结果如下。每个版本、每个场景都使用独立数据库,不会互相污染。

验收场景V0:能跑版V1:加键版V2:对账版
同一请求重放 100 次失败:100 笔通过:1 笔通过:1 笔
提交后丢失响应,再重试失败:2 笔通过:1 笔通过:1 笔
记账后、存回执前进程退出失败:2 笔失败:2 笔通过:1 笔
两个进程同时首次请求失败:2 笔失败:2 笔通过:1 笔
同一个键,把金额改成 29.99 元失败:再次记账失败:接受请求,返回旧回执通过:拒绝参数冲突
Alice 和 Bob 使用相同的键通过:分别记账失败:Bob 收到 Alice 的回执通过:分别记账

V0 通过 1 项,V1 通过 2 项,V2 通过 6 项。

这里没有“AI 错误率 83%”之类的结论。这是一个有意保留缺陷的对照实验:用它证明,正常路径检查识别不出的差异,可以通过明确的业务断言暴露出来。

真正值得注意的是中间那列:它已经有幂等键、有查重、有缓存结果,却仍然会出错。

第一场:重放 100 次,先查“到底执行了几次”

V0 的思路很直白:收到请求,写一笔账,返回成功。

如果测试只调用一次,它没有问题。

但请求次数和业务意图数量并不是一回事。重复点击、客户端重试、任务补偿,都可能让同一个意图被多次送达。

这次重放的断言不是“100 次都返回成功”,而是:

账单数量 = 1
记账总额 = 1999 分
每次返回的回执,都指向同一笔记账

V0 产生 100 笔记录,总额 199900 分。V1、V2 都只记了 1 笔。

加键确实解决了这一关,但键的含义要写清楚:它标识同一次业务尝试,这次尝试的重试必须复用原键。如果每次重试都生成新 UUID,防重协议就失去了识别依据。

还有一个容易被标题掩盖的边界:本实验验证的是“同一用户、同一操作、同一个键”的重复请求。如果同一订单换了新键,V2 不会自动把它识别为重试。业务若规定每张订单只能成功扣款一次,还需要订单状态或业务唯一约束来表达这个规则。

请求防重,不能代替业务规则。

第二场:返回失败的时候,事情可能已经办成了

这次把故障放在提交之后、响应返回之前:

服务端完成记账
服务端提交结果
响应未被调用方收到
调用方使用原键重试

脚本在提交后抛出 ResponseLost,模拟调用方没有拿到响应;它没有实际搭建 HTTP 服务或切断网络。

此时调用方看到失败,数据库里却已经有一笔成功记录。

V0 重试后多出第二笔。V1、V2 找到了已保存的回执,返回原结果,账上仍是一笔。

这揭示了一个接口设计问题:调用方没有观察到成功,不能据此断言服务端没有执行。

在真实系统里,超时之后可能需要查询结果、按同一键重试,或者进入待确认状态。具体选择取决于接口协议。把未知结果直接解释成“没执行”,再发一笔新请求,会把观察上的不确定变成真实的重复副作用。

AWS 在关于安全重试的设计文章中讨论了这一困境,以及通过调用方请求标识表达重试意图的方法。AWS:Making retries safe with idempotent APIs

第三场:最危险的地方,藏在两个“成功”之间

V1 的控制流可以概括为:

if receipt_exists(key):
    return saved_receipt(key)
​
debit()                  # 单独提交:账已经记了
save_receipt(key)        # 另一笔提交:证明刚才记过账
return success

看起来只差两行,现实却可以在两行之间终止进程。

实验使用一个真正的子进程,在记账之后、保存回执之前执行 os._exit(73)。这会立即退出,不运行应用层的 finally。

然后由另一个进程打开同一数据库,使用原请求重试。

观察点V1:分别提交V2:同一事务
退出后、重试前已有 1 笔账,没有回执未提交的账不保留
重试完成后2 笔账1 笔账

V1 不是没做查重,而是查重依据与副作用没有一起保存。进程留下了一笔“确实发生,却没有防重回执”的账。

V2 将两者放进同一个 SQLite 事务:要么一起提交,要么这次未提交的修改不生效。本次测的是进程突然退出后的恢复,不是整机断电或存储硬件故障。

可以把需要证明的关系写成一句话:

在本例中,一笔成功提交的业务记账,必须同时具有可查询、能核对意图的幂等回执。

只检查“用了幂等键吗”,看不到这个关系。

第四场:两个请求都查到“没有”,并不矛盾

现在先不终止进程,让两个进程同时处理首次请求。

为了让问题稳定复现,两个进程都在第一次无锁查询结束后停到屏障,再一起继续。V2 也经过相同的初次查询与屏障。

于是 V1 会经历:

进程 A:查不到回执
进程 B:查不到回执
进程 A:记账,提交
进程 B:记账,提交
进程 A:保存回执
进程 B:重复回执被忽略

回执表可能只有一行,账却已经记了两笔。

回执表的唯一约束,不能撤销另一张表里已经提交的副作用。 如果只是把最后的忽略改成抛错,也只是让第二个调用失败,已经提交的账仍然存在。

V2 在 BEGIN IMMEDIATE 之后再次查回执。第一个写事务完成后,第二个请求进入事务时能看到回执,于是直接返回原结果。

下面是控制流程示意,完整可运行实现见文末:

BEGIN_IMMEDIATE()
try:
    old = find_receipt(scope, key)  # 事务内再查,不能依赖刚才的结果
    if old:
        check_same_intent(old, request)
        result = old.response
    else:
        result = write_local_ledger(request)
        insert_receipt(scope, key, fingerprint(request), result)
    COMMIT()
except BaseException:
    ROLLBACK_IF_ACTIVE()
    raise

这是适合演示正确性的小实现,不是并发性能方案。SQLite 同时只允许一个写事务;这里用串行写入换取容易核对的事务边界,不能把结果直接推广成高吞吐支付架构。SQLite 事务文档

第五场:同一个键,不应该替另一件事背书

第一次请求是 19.99 元。

第二次仍使用同一个键,却把金额改成 29.99 元。

V1 发现键存在,直接返回第一次的成功回执。它没有再次记账,但仍然接受了一个业务意图已经改变的请求。

这不是我们定义的成功:调用方提交了新金额,系统既没有执行新意图,也没有明确告诉它发生冲突。

V2 对经过验证、规范化的业务参数计算摘要。这个实验中的意图由订单号与整数金额组成;同作用域、同键、不同摘要,会抛出 Conflict,账上保持原来的一笔。

如果封装成 HTTP API,可以在协议中把这种异常映射为明确的冲突响应,例如 409。实验本身没有 HTTP 层。

真实项目还需要决定哪些字段属于业务意图:收款方、币种、操作类型等可能必须包含,追踪时间戳则未必应该包含。直接对未经规范化的原始 JSON 字符串算哈希,可能把字段顺序差异误判为意图差异。

另外,摘要只是用来比较意图,不能代替鉴权或签名。Stripe 的 API v1 文档也描述了比较相同键下的请求参数、拒绝参数不一致的处理,但这不是所有 API 的统一规则。Stripe:Idempotent requests

第六场:你的“同一个键”,是谁的同一个键?

最后一场让 Alice 和 Bob 都使用 request-001。

V1 的回执键是全局的。Alice 先完成操作,Bob 随后命中 Alice 的回执,自己的账没有记,返回结果里的用户却是 Alice。

这时,仅检查“有没有重复记账”甚至会得出错误的安心感:总共只有一笔,防重似乎非常成功。

V2 的键空间是:

经过认证的用户 + 操作类型 + 幂等键

两位用户的请求互不冲突,各自记账一次,并拿到各自的回执。

这里的“经过认证”不能省略。脚本通过固定数据模拟身份,未实现鉴权;真实服务应从验证后的身份上下文中取用户或租户,不能相信请求体里随便传来的 user_id。

这一场的验收必须同时看三件事:账记给谁、金额是什么、回执返回给谁。只看接口成功率,三件事都可能漏掉。

为什么值得用这六场实验验收 AI 代码

使用 AI 时,交付形式很容易变得完整:代码、测试、说明,最后附上一句“已完成”。完整的形式会让人忽略一个更具体的问题:测试究竟约束了哪些业务结果?

Stack Overflow 的 2025 年调查中,相关问题的受访者有 66% 选择“AI 方案接近正确但并不完全正确”这一困扰。这是一份历史调查,不代表每个项目,更不能代替对当前代码的检查。调查原页

本次三个正常路径检查也不是假的:它们的确验证了单次操作。然而,重复送达、半途退出、两人同时到达、意图改变和身份边界,并没有被这些检查覆盖。

问题在于从已验证的命题,跳到了更大的结论。

你可以让 AI 先交付一份验收对账单:

验收项必须提供的证据
同一意图重复到达最终副作用次数和回执一致性
提交之后拿不到响应调用方观察与持久化状态的对应关系
两次持久化之间终止进程恢复后与重试后的最终状态
两个请求同时到达可复现时序,以及锁或约束的具体边界
原键携带不同业务参数明确冲突,原状态不被悄悄替换
不同身份使用相同键状态和返回数据均正确隔离

不要只让它写“已考虑边界情况”。让每个格子都有输入、故障位置、预期结果和实际结果。

可以直接复制这段提示词:

请先检查这段代码的业务不变量,再修改实现。
​
对每一个会产生持久副作用的操作,列出:
1. 什么代表同一次业务意图,重试时哪些字段必须保持不变;
2. 哪些数据必须一起提交;
3. 在每次写入前后终止进程,重试后应该留下什么状态;
4. 两个独立执行者同时到达时,哪个机制决定只能执行一次;
5. 同一个请求键携带不同参数时如何拒绝;
6. 不同用户或租户使用相同请求键时如何隔离。
​
先交付失败用例和可运行命令,说明修改前应该在哪里失败。
保留业务断言,做最小修复,再报告实际运行结果。
同时列出尚未测试的边界,以及本地事务无法覆盖的外部副作用。

这段提示词不是正确性的保证。它的作用是把“再仔细检查一下”变成能被逐条验证的工作。

最后一道边界:本地事务管不到第三方的账

如果读到这里,你准备把真实支付 API 调用塞进数据库事务,再宣称解决重复扣款,请先停一下。

本实验里的记账与回执都在同一个 SQLite 文件里。真实支付平台有自己的状态;本地数据库回滚,不会让对方已经完成的扣款自动消失。

跨系统场景通常需要先定义状态与恢复协议。例如:本地事务保存待处理意图和 outbox 消息;执行者调用下游时始终使用稳定的下游幂等标识;响应不确定时查询、重试或对账,再推进本地状态。

Outbox 解决的是本地状态与待发送消息之间的双写问题,它本身不保证下游只执行一次。重复投递仍然需要接收方防重;无法确认的结果需要保留并处理。AWS:Transactional outbox pattern

此外,本实验没有覆盖余额不足、部分退款、回执过期、主备切换、跨地域复制或数据库不可用。V2 的“6/6”只表示通过这里明确列出的六场实验。

当代码变得容易生成,验收就更需要准确:什么发生了,发生了几次,发生在谁身上,失败之后能否查清。

下次代码交付时,除了问“测试通过了吗”,再要一张能对得上业务结果的账单。

你也可以把这六场实验换成自己的业务:发券、加积分、创建云资源、发送通知。先写清楚不变量,再判断哪些场景适用,不必把“一次且仅一次”套到所有动作上。


附录:完整实验脚本,可直接复制运行

把下面代码保存为 order_lab.py,运行:

python3 order_lab.py

需要 Python 3.10+,无第三方依赖。程序创建并自动清理临时数据库,不连接支付系统。实际验证环境为 Python 3.13.3、SQLite 3.49.1、macOS;其他环境未在本次写作中实测。

输出中 V0、V1 的“失败”是有意保留的反例。程序会验证预期差异;若子进程运行异常或结果不符合预期,会报错退出。

希望保存原始记录时,运行:

python3 order_lab.py --json results.json --markdown results.md

原始记录包含每场实验的账单、金额、故障观察、运行环境以及脚本的 SHA-256,方便复核文章里的数字。

#!/usr/bin/env python3
"""Six deterministic acceptance experiments; Python standard library only.
​
All debits are rows in a temporary, local SQLite database. No money moves.
These are deliberately constructed implementations, not samples from model APIs.
"""
​
import argparse
from collections import Counter
from dataclasses import asdict, dataclass, replace
from datetime import datetime, timezone
import hashlib
import json
import multiprocessing as mp
import os
from pathlib import Path
import platform
import sqlite3
import sys
import tempfile
​
​
VERSIONS = ("v0", "v1", "v2")
SCENARIOS = (
    ("replay_100", "同一请求重放 100 次"),
    ("response_lost", "提交后响应丢失,再重试"),
    ("process_exit", "记账后、存回执前进程退出"),
    ("two_processes", "两个进程同时首次请求"),
    ("changed_payload", "同一键改金额"),
    ("tenant_scope", "不同用户复用相同键"),
)
​
​
@dataclass(frozen=True)
class Request:
    # In a real service, tenant comes from verified authentication context.
    tenant: str = "alice"
    order_id: str = "order-001"
    amount_fen: int = 1999
    key: str = "request-001"
​
​
class Conflict(Exception):
    pass
​
​
class ResponseLost(Exception):
    pass
​
​
def connect(path):
    conn = sqlite3.connect(path, timeout=15, isolation_level=None)
    conn.execute("PRAGMA synchronous=FULL")
    conn.row_factory = sqlite3.Row
    return conn
​
​
def initialize(path):
    conn = connect(path)
    try:
        conn.executescript("""
            CREATE TABLE ledger (
                id INTEGER PRIMARY KEY,
                tenant TEXT NOT NULL,
                order_id TEXT NOT NULL,
                amount_fen INTEGER NOT NULL CHECK(amount_fen > 0)
            );
            CREATE TABLE receipts (
                scope TEXT NOT NULL,
                request_key TEXT NOT NULL,
                fingerprint TEXT NOT NULL,
                response TEXT NOT NULL,
                PRIMARY KEY(scope, request_key)
            );
        """)
    finally:
        conn.close()
​
​
def fingerprint(request):
    payload = json.dumps(
        {"order_id": request.order_id, "amount_fen": request.amount_fen},
        sort_keys=True, separators=(",", ":"), ensure_ascii=False,
    )
    return hashlib.sha256(payload.encode("utf-8")).hexdigest()
​
​
def lookup(conn, scope, key):
    return conn.execute(
        "SELECT fingerprint, response FROM receipts "
        "WHERE scope=? AND request_key=?", (scope, key),
    ).fetchone()
​
​
def pay(path, version, request, fault=None, gate=None):
    """v0: no dedupe. v1: check then act. v2: scoped ACID receipt.
​
    The optional gate puts BOTH contenders after an initial unlocked lookup.
    v2 must recheck inside its transaction; v1 deliberately does not.
    """
    if type(request.amount_fen) is not int or request.amount_fen <= 0:
        raise ValueError("amount_fen must be a positive integer")
    if not request.key or not request.tenant or not request.order_id:
        raise ValueError("key, authenticated tenant and order_id are required")
    if version not in VERSIONS:
        raise ValueError(version)
    conn = connect(path)
    scope = f"{request.tenant}:debit" if version == "v2" else "global"
    digest = fingerprint(request)
    try:
        # This lookup is intentionally OUTSIDE the write transaction.
        previous = lookup(conn, scope, request.key) if version != "v0" else None
        if previous:
            if version == "v2" and previous["fingerprint"] != digest:
                raise Conflict("same key, different business intent")
            return json.loads(previous["response"])
        if gate is not None:
            gate.wait(timeout=20)
        if version == "v2":
            conn.execute("BEGIN IMMEDIATE")
            # A competing process may have committed since the first lookup.
            previous = lookup(conn, scope, request.key)
            if previous:
                if previous["fingerprint"] != digest:
                    raise Conflict("same key, different business intent")
                conn.execute("COMMIT")
                return json.loads(previous["response"])
        cursor = conn.execute(
            "INSERT INTO ledger(tenant, order_id, amount_fen) VALUES (?, ?, ?)",
            (request.tenant, request.order_id, request.amount_fen),
        )
        # Abrupt child exit: no finally block, no application-level rollback.
        if fault == "after_ledger_exit":
            os._exit(73)
        response = {
            "debit_id": cursor.lastrowid,
            "tenant": request.tenant,
            "order_id": request.order_id,
            "amount_fen": request.amount_fen,
        }
        if version != "v0":
            # v1 can suppress a duplicate receipt but cannot undo its debit.
            insert = "INSERT OR IGNORE" if version == "v1" else "INSERT"
            conn.execute(
                f"{insert} INTO receipts VALUES (?, ?, ?, ?)",
                (scope, request.key, digest, json.dumps(response, sort_keys=True)),
            )
        if version == "v2":
            conn.execute("COMMIT")
        if fault == "after_commit_response_lost":
            raise ResponseLost("committed, but caller received no response")
        return response
    except BaseException:
        if conn.in_transaction:
            conn.execute("ROLLBACK")
        raise
    finally:
        conn.close()
​
​
def ledger_rows(path):
    conn = connect(path)
    try:
        return [dict(row) for row in conn.execute("SELECT * FROM ledger ORDER BY id")]
    finally:
        conn.close()
​
​
def worker(path, version, request, fault, gate, output):
    try:
        response = pay(path, version, request, fault, gate)
        if output is not None:
            output.put({"response": response})
    except Exception as error:
        if output is None:
            raise
        output.put({"error": f"{type(error).__name__}: {error}"})
​
​
def join_process(process, expected=0):
    process.join(timeout=30)
    if process.is_alive():
        process.terminate()
        process.join(timeout=5)
        raise RuntimeError("worker timed out")
    if process.exitcode != expected:
        raise RuntimeError(f"worker exit={process.exitcode}; expected={expected}")
​
​
def run_case(root, version, scenario, context):
    path = str(root / f"{version}-{scenario}.sqlite")
    initialize(path)
    request = Request()
    responses = []
    details = {}
    if scenario == "replay_100":
        responses = [pay(path, version, request) for _ in range(100)]
    elif scenario == "response_lost":
        try:
            pay(path, version, request, fault="after_commit_response_lost")
        except ResponseLost:
            details["caller_observed"] = "ResponseLost"
        else:
            raise RuntimeError("fault was not injected")
        details["rows_before_retry"] = len(ledger_rows(path))
        responses = [pay(path, version, request)]
    elif scenario == "process_exit":
        process = context.Process(
            target=worker,
            args=(path, version, request, "after_ledger_exit", None, None),
        )
        process.start()
        join_process(process, expected=73)
        details["child_exitcode"] = process.exitcode
        details["rows_before_retry"] = len(ledger_rows(path))
        responses = [pay(path, version, request)]
    elif scenario == "two_processes":
        gate = context.Barrier(2)
        output = context.Queue()
        processes = [context.Process(
            target=worker, args=(path, version, request, None, gate, output),
        ) for _ in range(2)]
        try:
            for process in processes:
                process.start()
            for process in processes:
                join_process(process)
            worker_results = [output.get(timeout=5) for _ in processes]
            errors = [result["error"] for result in worker_results if "error" in result]
            if errors:
                raise RuntimeError(errors)
            responses = [result["response"] for result in worker_results]
            details["workers"] = 2
            details["barrier_position"] = "after initial unlocked receipt lookup"
        finally:
            for process in processes:
                if process.is_alive():
                    process.terminate()
                    process.join(timeout=5)
            output.close()
            output.join_thread()
    elif scenario == "changed_payload":
        responses = [pay(path, version, request)]
        try:
            responses.append(pay(path, version, replace(request, amount_fen=2999)))
            details["changed_request"] = "accepted"
        except Conflict:
            details["changed_request"] = "rejected"
    elif scenario == "tenant_scope":
        responses = [pay(path, version, request), pay(path, version, replace(request, tenant="bob"))]
    else:
        raise ValueError(scenario)
​
    rows = ledger_rows(path)
    one_debit = (len(rows) == 1 and rows[0]["amount_fen"] == 1999
                 and rows[0]["tenant"] == "alice" and rows[0]["order_id"] == "order-001")
    if scenario == "tenant_scope":
        passed = (len(rows) == 2
                  and Counter(row["tenant"] for row in rows) == {"alice": 1, "bob": 1}
                  and all(row["amount_fen"] == 1999 for row in rows)
                  and [response["tenant"] for response in responses] == ["alice", "bob"])
    elif scenario == "changed_payload":
        passed = one_debit and details["changed_request"] == "rejected"
    else:
        # Check the receipt as well as the balance. Cached results must agree.
        passed = one_debit and all(
            response == {"debit_id": rows[0]["id"], "tenant": "alice",
                         "order_id": "order-001", "amount_fen": 1999}
            for response in responses
        )
    return {
        "version": version, "scenario": scenario, "passed": passed,
        "ledger_count": len(rows), "total_fen": sum(row["amount_fen"] for row in rows),
        "response_count": len(responses), "details": details,
        "ledger": rows,
    }
​
​
def happy_path(root, version):
    path = str(root / f"{version}-happy.sqlite")
    initialize(path)
    response = pay(path, version, Request())
    rows = ledger_rows(path)
    return (len(rows) == 1 and response == {
        "debit_id": rows[0]["id"], "tenant": "alice", "order_id": "order-001", "amount_fen": 1999,
    } and rows[0]["amount_fen"] == 1999)
​
​
def render_markdown(report):
    rows = ["# 本机实验结果", "", f"Python {report['python']};SQLite {report['sqlite']}。", "",
            "金额单位为分;全部记账均发生在本地临时数据库。", "",
            "| 场景 | V0 无防重 | V1 先查后写 | V2 同事务回执 |", "|---|---|---|---|"]
    for scenario, label in SCENARIOS:
        cells = []
        for version in VERSIONS:
            result = next(item for item in report["results"]
                          if item["version"] == version and item["scenario"] == scenario)
            outcome = "通过" if result["passed"] else "失败"
            details = result["details"]
            note = f"{result['ledger_count']} 笔 / {result['total_fen']} 分"
            if "changed_request" in details:
                note += ";" + ("拒绝变更" if details["changed_request"] == "rejected" else "接受变更")
            if scenario == "tenant_scope" and not result["passed"]:
                note += ";Bob 收到 Alice 回执"
            cells.append(f"{outcome}:{note}")
        rows.append(f"| {label} | " + " | ".join(cells) + " |")
    rows += ["", "正常路径检查:三个版本均通过。", "",
             "故障场景通过数:" + ";".join(f"{version.upper()} {report['scores'][version]}/6" for version in VERSIONS) + "。", "",
             "此表说明预先设计的实现缺陷可以被这些实验区分,不能据此推断任何 AI 模型的代码缺陷率。", ""]
    return "\n".join(rows)
​
​
def main():
    parser = argparse.ArgumentParser(description=__doc__)
    parser.add_argument("--json", type=Path, help="write machine-readable report")
    parser.add_argument("--markdown", type=Path, help="write publication table")
    args = parser.parse_args()
    context = mp.get_context("spawn")
    with tempfile.TemporaryDirectory(prefix="order-acceptance-") as directory:
        root = Path(directory)
        happy = {version: happy_path(root, version) for version in VERSIONS}
        results = [run_case(root, version, scenario, context)
                   for scenario, _ in SCENARIOS for version in VERSIONS]
    scores = {version: sum(item["passed"] for item in results if item["version"] == version)
              for version in VERSIONS}
    report = {
        "run_at_utc": datetime.now(timezone.utc).isoformat(),
        "python": platform.python_version(), "sqlite": sqlite3.sqlite_version,
        "platform": platform.platform(),
        "start_method": "spawn", "journal_mode": "delete", "synchronous": "FULL",
        "source_sha256": hashlib.sha256(Path(__file__).read_bytes()).hexdigest(),
        "provenance": "constructed examples; not model sampling; no external payment API",
        "request": asdict(Request()), "happy_path": happy, "scores": scores, "results": results,
    }
    if not all(happy.values()) or scores != {"v0": 1, "v1": 2, "v2": 6}:
        print(json.dumps(report, ensure_ascii=False, indent=2))
        raise SystemExit("unexpected result: investigate before publishing")
    markdown = render_markdown(report)
    print(markdown)
    if args.json:
        args.json.parent.mkdir(parents=True, exist_ok=True)
        args.json.write_text(json.dumps(report, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
    if args.markdown:
        args.markdown.parent.mkdir(parents=True, exist_ok=True)
        args.markdown.write_text(markdown, encoding="utf-8")
​
​
if __name__ == "__main__":
    mp.freeze_support()
    main()