deliver: 交付收口(引擎代为提交)

This commit is contained in:
agent.develop 2026-09-23 03:34:09 +08:00
parent 9c76329a56
commit dad69c41bf

View File

@ -1,17 +1,34 @@
#!/usr/bin/env python3 #!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""S3 取证日志装配器:把 chain/runA/runB/replay 原始输出逐字嵌入 markdown。 """S3 取证日志装配器:把 chain/runA/runB/replay 原始输出逐字嵌入 markdown。
存在意义:交付日志中的「原样输出」必须由程序从磁盘原始日志读出拼接, 存在意义:交付日志中的「原样输出」必须由程序从磁盘原始日志读出拼接,
人工转抄会产生差异,反而削弱真实性证据。本脚本只做读取+排版, 人工转抄会产生差异,反而削弱真实性证据。本脚本只做读取+排版,
不产生任何判定结论(结论段是显式常量,与实际输出分离便于核对)。 不产生任何判定结论(结论段是显式常量,与实际输出分离便于核对)。
去硬编码原则(QC#15):文档头采集时点、§4 主键对比表、§6 产物字节数/哈希/
mtime、断言条数、时钟偏移统计、count/created/skipped、chain 末行 CHAIN_RC、
§7 的 git SHA 与文件清单,一律由本程序从 evidence/s3/ 磁盘日志实读
(正则切片 / os.stat / sha256sum / grep -c / git show --stat 子进程)。
确属人工填写的常量(任务 key、父任务 key、角色与迭代名、结论文字)在模板中
就地标注「人工填写段,非切片」。
""" """
import argparse import argparse
import hashlib import hashlib
import os import os
import re
import subprocess import subprocess
import sys
import time import time
# ---- 人工填写段,非切片(任务归属标识,QC 可 grep 正文首行核对) ----
TASK_KEY = "OqAv27u3w8DE9nirTwPp2" TASK_KEY = "OqAv27u3w8DE9nirTwPp2"
PARENT_KEY = "N_Ppka4GtWo3176UxFTuR"
ROLE_LINE = "agent.develop | **迭代**:pbls-初始迭代"
# -------------------------------------------------------------------
PK_RE = re.compile(r"'(ev[0-9a-f]{8,})'")
TS_RE = re.compile(r"(20\d{2}-\d{2}-\d{2} \d{2}:\d{2}:\d{2} [+\-]\d{4})")
def sha256(path): def sha256(path):
@ -23,6 +40,8 @@ def sha256(path):
def read(path): def read(path):
if not os.path.exists(path):
return ""
with open(path, "r", encoding="utf-8", errors="replace") as f: with open(path, "r", encoding="utf-8", errors="replace") as f:
return f.read().rstrip("\n") return f.read().rstrip("\n")
@ -31,15 +50,46 @@ def size(path):
return os.path.getsize(path) if os.path.exists(path) else -1 return os.path.getsize(path) if os.path.exists(path) else -1
def git(*args): def git(repo, *args):
return subprocess.run(["git"] + list(args), capture_output=True, return subprocess.run(["git", "-C", repo] + list(args),
text=True).stdout.strip() capture_output=True, text=True).stdout.strip()
def fenced(text, lang="text"): def fenced(text, lang="text"):
return "```%s\n%s\n```" % (lang, text) return "```%s\n%s\n```" % (lang, text)
def line_with(text, needle, default=""):
"""返回首行包含 needle 的原文行(strip 后);找不到返回 default。"""
for ln in text.splitlines():
if needle in ln:
return ln.strip()
return default
def rx1(text, pattern, default="-"):
m = re.search(pattern, text)
return m.group(1) if m else default
def pks_in(text, needle):
"""从含 needle 的那一行里实读出全部 ev 前缀主键。"""
return PK_RE.findall(line_with(text, needle))
def count_pass(path):
"""实读 [PASS] 断言条数(grep -c 等价)+ 按出现顺序去重的断言标签。"""
txt = read(path)
n = sum(1 for ln in txt.splitlines() if "[PASS]" in ln)
labels, seen = [], set()
for m in re.finditer(r"\[PASS\]\s+([A-Za-z]+[\w.\-]*)", txt):
lab = m.group(1).rstrip(".")
if lab not in seen:
seen.add(lab)
labels.append(lab)
return n, labels
def main(): def main():
ap = argparse.ArgumentParser() ap = argparse.ArgumentParser()
ap.add_argument("--out", required=True, help="markdown 落盘路径") ap.add_argument("--out", required=True, help="markdown 落盘路径")
@ -48,14 +98,19 @@ def main():
a = ap.parse_args() a = ap.parse_args()
d = a.dir d = a.dir
chain = read(os.path.join(d, "s3_chain.log")) chain_p = os.path.join(d, "s3_chain.log")
run_a = read(os.path.join(d, "runA.log")) runa_p = os.path.join(d, "runA.log")
run_b = read(os.path.join(d, "runB.log")) runb_p = os.path.join(d, "runB.log")
replay = read(os.path.join(d, "replay.log")) replay_p = os.path.join(d, "replay.log")
chain = read(chain_p)
run_a = read(runa_p)
run_b = read(runb_p)
replay = read(replay_p)
if not chain or not run_a or not run_b:
sys.stderr.write("[gen] FAIL: 磁盘日志缺失,拒绝生成文档(不伪造数值)\n")
return 2
# 从 chain log 中切出证据段落(按标题行定位,不硬编码行号)。 # 从 chain log 中切出证据段落(按标题行定位,不硬编码行号)。
# stop 找不到时切到文件末尾:本脚本由 chain 的 [7] 步调用,此刻 chain log
# 仍在同一进程写入、末行 CHAIN_RC=0 尚未刷盘,属预期的自引用边界。
def cut(marker, stop): def cut(marker, stop):
i = chain.index(marker) i = chain.index(marker)
try: try:
@ -70,18 +125,152 @@ def main():
seg_3 = cut("########## [3-3]", "########## [3-4]") seg_3 = cut("########## [3-3]", "########## [3-4]")
seg_4 = cut("########## [3-4]", "########## [3-5]") seg_4 = cut("########## [3-4]", "########## [3-5]")
seg_clock = cut("########## [1]", "########## [2]") seg_clock = cut("########## [1]", "########## [2]")
seg_tail = cut("########## [6]", "CHAIN_RC=0") seg_4b = cut("########## [4b]", "########## [5]")
seg_5 = cut("########## [5]", "########## [6]")
seg_6 = cut("########## [6]", "########## [7]")
runa_hdr = cut("########## [2] RUN A", "M5a live-DB harness")
runb_hdr = cut("########## [3-5] RUN B", "M5a live-DB harness")
seg_after_b = cut("########## RUN B 结束后再次确认 trigger",
"########## [4] collector")
# [7] 段:由 chain 内调用时此刻尚未写入,切片自然为空(自引用边界)
try:
seg_7 = cut("########## [7]", "########## S3 EVIDENCE CHAIN END")
except ValueError:
seg_7 = "(本文档由 chain 的 [7] 步生成,生成时该段尚未落盘," \
"可 tail -3 s3_chain.log 实测)"
diff_ab = subprocess.run(["diff", os.path.join(d, "runA.log"), env_line = line_with(chain, "沙箱连接串(脱敏)= ")
os.path.join(d, "runB.log")], if "沙箱连接串(脱敏)= " in env_line:
capture_output=True, text=True).stdout.strip() \ env_line = env_line.split("沙箱连接串(脱敏)= ", 1)[1].strip()
or "(runA.log 与 runB.log 逐字节完全一致)"
head_commit = git("-C", a.repo, "rev-parse", "--short", "HEAD") # ---- 采集时点:从 chain log 首/末标题行正则实读 ----
head_full = git("-C", a.repo, "rev-parse", "HEAD") start_line = line_with(chain, "S3 EVIDENCE CHAIN START")
branch = git("-C", a.repo, "rev-parse", "--abbrev-ref", "HEAD") end_line = line_with(chain, "S3 EVIDENCE CHAIN END")
porcelain = git("-C", a.repo, "status", "--porcelain") or "(工作区干净,无未提交变更)" start_ts = rx1(start_line, TS_RE)
end_ts = rx1(end_line, TS_RE)
start_epoch = rx1(start_line, r"epoch=([\d.]+)")
end_epoch = rx1(end_line, r"epoch=([\d.]+)")
rc_line = line_with(chain, "CHAIN_RC=")
chain_rc = rc_line if rc_line else \
"(CHAIN_RC 行在文档生成后刷盘,请 tail -1 s3_chain.log 实测)"
chain_last_line = chain.splitlines()[-1] if chain else "-"
# ---- 时钟偏移统计:实读 chain 内统计行 ----
offset_line = line_with(chain, "offset 统计:")
offset_min = rx1(offset_line, r"min=([\d.]+)")
offset_max = rx1(offset_line, r"max=([\d.]+)")
offset_mean = rx1(offset_line, r"mean=([\d.]+)")
offset_range = rx1(offset_line, r"极差=([\d.]+)")
# ---- 断言条数:grep -c 等价实读 ----
n_pass_a, labels_a = count_pass(runa_p)
n_pass_b, labels_b = count_pass(runb_p)
allpass_a = "LIVE DB HARNESS ALL PASS" in run_a
allpass_b = "LIVE DB HARNESS ALL PASS" in run_b
rc_a = rx1(line_with(chain, "########## [2] RUN A") + "\n" +
chain[chain.index("########## [2] RUN A"):
chain.index("########## [3-0]")], r"RC=(\d+)")
rc_b = rx1(chain[chain.index("########## [3-5] RUN B"):
chain.index("########## [4] collector")], r"RC=(\d+)")
# ---- 主键:从 runA/runB/replay 原样回显行实读 ----
pks_a = pks_in(run_a, "证据行主键回显")
pks_b = pks_in(run_b, "证据行主键回显")
pks_rp_before = pks_in(replay, "重放前主键回显")
pks_rp_after = pks_in(replay, "重放后主键回显")
def short(pk):
return pk[:8] + "…" + pk[-4:] if pk else "-"
def pk_list_str(pks):
return " / ".join(pks) if pks else "(未从日志解析到主键)"
def pk_short_str(pks):
return " / ".join(short(p) for p in pks) if pks else "-"
# ---- replay 幂等数值:逐行正则实读 ----
cnt_before = rx1(replay, r"重放前 count\(\*\)\s*=\s*(\d+)")
cnt_final = rx1(replay, r"count\(\*\)\s*最终\s*=\s*(\d+)")
dedup_n = rx1(replay, r"ids 去重后数量\s*=\s*(\d+)")
total_n = rx1(replay, r"去重后数量\s*=\s*\d+\s*/\s*总数\s*=\s*(\d+)")
id_lens = rx1(replay, r"id 长度集合\s*=\s*(\[[^\]]*\])")
trg_cnt = rx1(replay, r"当前 trigger 数(应为 0,证明 id 非 trigger 产物)\s*=\s*(\d+)")
dup_err = line_with(replay, "Duplicate entry")
replay_ok = "REPLAY IDEMPOTENT OK" in replay
rp_rows = []
for ln in replay.splitlines():
if "次重放 collector stats" in ln:
rp_rows.append({
"idx": rx1(ln, r"第 (\d+) 次重放"),
"created": rx1(ln, r"'created':\s*(\d+)"),
"skipped": rx1(ln, r"'skipped':\s*(\d+)"),
"failed": rx1(ln, r"'failed':\s*(\d+)"),
"count": rx1(ln, r"重放后 count\(\*\)\s*=\s*(\d+)"),
})
rp_rows.sort(key=lambda r: r["idx"])
n_rp = len(rp_rows)
def row(label, before, key):
# 每次重放单独一列,保证 markdown 列数与表头一致
cells = "".join(" | %s" % r[key] for r in rp_rows)
return "| %s | %s%s |" % (label, before, cells)
pk_same = (sorted(pks_rp_before) == sorted(pks_rp_after))
rp_pk_cell = "同前(无变化)" if pk_same else pk_list_str(pks_rp_after)
replay_table = "\n".join([
row("`pbl_evidence` count(*)", cnt_before, "count"),
row("collector `created`", "—", "created"),
row("collector `skipped`", "—", "skipped"),
row("collector `failed`", "—", "failed"),
"| 主键集合(完整值,实读 replay.log) | %s%s |"
% (pk_list_str(pks_rp_before),
"".join(" | %s" % rp_pk_cell for _ in rp_rows)),
"| 主键集合(缩写便于排版) | %s%s |"
% (pk_short_str(pks_rp_before),
"".join(" | %s" % rp_pk_cell for _ in rp_rows)),
])
# ---- [4b] DB 侧回查数值实读 ----
ev_rows = rx1(seg_4b, r'"evidence_rows":\s*"(\d+)"')
evt_rows = rx1(seg_4b, r'"event_rows":\s*"(\d+)"')
db_id_lens = sorted(set(re.findall(r'"id_len":\s*"(\d+)"', seg_4b)))
db_trg_rows = rx1(seg_4b, r"SHOW TRIGGERS\s*\n\s*->\s*(\d+) 行")
# ---- runA vs runB 差异:diff 原样 + 差异行是否只落在主键上 ----
diff_ab = subprocess.run(["diff", runa_p, runb_p],
capture_output=True, text=True).stdout.strip()
diff_lines = [ln for ln in diff_ab.splitlines()
if ln.startswith("<") or ln.startswith(">")]
n_diff = len(diff_lines)
n_diff_non_pk = sum(1 for ln in diff_lines if not PK_RE.search(ln))
diff_display = diff_ab or "(runA.log 与 runB.log 逐字节完全一致)"
only_pk_diff = (n_diff > 0 and n_diff_non_pk == 0)
# ---- git 事实(§7 全部原样嵌入,不写死 SHA) ----
head_commit = git(a.repo, "rev-parse", "--short", "HEAD")
head_full = git(a.repo, "rev-parse", "HEAD")
head1 = git(a.repo, "rev-parse", "--short", "HEAD~1")
head1_subject = git(a.repo, "log", "-1", "--format=%s", "HEAD~1")
branch = git(a.repo, "rev-parse", "--abbrev-ref", "HEAD")
porcelain = git(a.repo, "status", "--porcelain") or \
"(工作区干净,无未提交变更)"
git_log = git(a.repo, "log", "--oneline", "-3")
stat_head = git(a.repo, "show", "--stat", "--oneline", "HEAD")
stat_head1 = git(a.repo, "show", "--stat", "--oneline", "HEAD~1")
probes = ["tests/s3_trigger_probe.py", "tests/s3_sql_probe.py",
"tests/s3_replay_idempotency.py", "tests/s3_clock_skew_probe.py",
"tests/s3_db_url.py", "tests/s3_clock_compare.py"]
hits = []
for p in probes:
fp = os.path.join(a.repo, p)
n = 0
if os.path.exists(fp):
with open(fp, "r", encoding="utf-8", errors="replace") as f:
n = sum(1 for ln in f if TASK_KEY in ln)
hits.append("%s=%d" % (os.path.basename(p), n))
probe_hits = ", ".join(hits)
# ---- 产物文件系统实测(os.stat + sha256 实读) ----
files = ["s3_chain.log", "runA.log", "runB.log", "replay.log"] files = ["s3_chain.log", "runA.log", "runB.log", "replay.log"]
rows = [] rows = []
for fn in files: for fn in files:
@ -91,20 +280,51 @@ def main():
fn, st.st_size, time.strftime("%Y-%m-%d %H:%M:%S %z", fn, st.st_size, time.strftime("%Y-%m-%d %H:%M:%S %z",
time.localtime(st.st_mtime)), time.localtime(st.st_mtime)),
sha256(p)[:16])) sha256(p)[:16]))
table = "\n".join(rows) # 表头写在 markdown 模板里,此处只给数据行 table = "\n".join(rows)
chain_bytes = size(chain_p)
chain_sha = sha256(chain_p)[:16]
runa_bytes, runb_bytes = size(runa_p), size(runb_p)
replay_bytes = size(replay_p)
# chain 内 [6] 步自述的 s3_chain.log 大小是「边写边统计」的瞬时值,实读差值
inst_bytes = rx1(seg_6, r"s3_chain\.log size=(\d+)")
try:
chain_delta = chain_bytes - int(inst_bytes)
except ValueError:
chain_delta = "-"
# ---- QC#17:根目录陈旧副本处置(实读 evidence/ 目录) ----
parent = os.path.dirname(os.path.abspath(d))
names = sorted(os.listdir(parent)) if os.path.isdir(parent) else []
root_dup = "s3_chain.log" in names
stale = [n for n in names if n.startswith("s3_chain.log") and n != "s3_chain.log"]
stale_desc = "(无)"
if stale:
parts = []
for n in stale:
sp = os.path.join(parent, n)
sz = size(sp)
st_start = rx1(read(sp).splitlines()[0]
if os.path.exists(sp) else "", TS_RE)
parts.append("`%s`(实测 %s 字节,其内部 START 时点 %s,早于本轮 %s;"
"重命名保留、未删除)" % (n, sz, st_start, start_ts))
stale_desc = ";".join(parts)
md = """# TASK_KEY: %(key)s | S3 真实链路 live DB harness 端到端 + 删 trigger 复验 + 重放幂等 md = """# TASK_KEY: %(key)s | S3 真实链路 live DB harness 端到端 + 删 trigger 复验 + 重放幂等
- **本任务 key**:`%(key)s`(正文首行标注以证明归属,QC 可 grep `TASK_KEY: %(key)s`) - **本任务 key**:`%(key)s`(正文首行标注以证明归属,QC 可 grep `TASK_KEY: %(key)s`)
- **父任务**:`N_Ppka4GtWo3176UxFTuR`(split_batch=1,split_review_child=true) - **父任务**:`%(parent)s`(split_batch=1,split_review_child=true)〔人工填写段,非切片〕
- **角色**:agent.develop | **迭代**:pbls-初始迭代 - **角色**:%(role)s〔人工填写段,非切片〕
- **采集时点**:取证链单进程原子执行 START `2026-09-23 01:48:10 +0800` → END `2026-09-23 01:48:12 +0800`(见 §7 chain log 首尾行) - **采集时点**:取证链单进程原子执行 START `%(start_ts)s`(epoch=%(start_epoch)s)→ END `%(end_ts)s`(epoch=%(end_epoch)s)——两值由本脚本从 `s3_chain.log` 首/末标题行正则实读
- **本文档生成时点**:%(gen)s - **本文档生成时点**:%(gen)s(本脚本 `time.strftime` 实读)
- **执行方式**:`bash tests/s3_evidence_chain.sh`,stdout+stderr 整体重定向到 `s3_chain.log`, - **执行方式**:`bash tests/s3_evidence_chain.sh`,stdout+stderr 整体重定向到 `s3_chain.log`,全链 `set -euo pipefail` + 显式 grep 门禁,任一步失败立即终止;末行 `%(chain_rc)s`
全链 `set -euo pipefail` + 显式 grep 门禁,任一步失败立即终止(末行 `CHAIN_RC=0` 即全链通过)
> 本文所有「原样输出」由 `tests/gen_s3_log.py` 从磁盘原始日志**程序化切片**嵌入, > **数值口径声明(QC#15)**:本文所有时点、字节数、sha256、mtime、断言条数、主键、
> 未做任何人工转抄/删改;切片锚点是 chain log 内的段落标题,非硬编码行号。 > count/created/skipped/failed、trigger 数、git SHA 与文件清单,均由 `tests/gen_s3_log.py`
> 从 `evidence/s3/` 磁盘日志**程序化切片**或 `os.stat`/`sha256sum`/`grep -c`/`git show --stat`
> **实读**得到,脚本内不存在与日志打架的写死常量;确属人工填写的只有任务 key、
> 父任务 key、角色/迭代名与结论文字,且已就地标注「人工填写段,非切片」。
> 全部「原样输出」未做任何人工转抄/删改,切片锚点是日志内的段落标题,非硬编码行号。
> 每个数值的取值来源逐条列在 §9 溯源表。
--- ---
@ -112,8 +332,7 @@ def main():
%(env)s %(env)s
- 凭据唯一事实源:`projects/pbls/env/test.json` → `db.sandbox`(`scope=sandbox_only`, - 凭据唯一事实源:`projects/pbls/env/test.json` → `db.sandbox`(`scope=sandbox_only`,grants=CREATE/DROP DATABASE + 沙箱库内全权限)。**口令不打印、不入日志、不入 git。**
grants=CREATE/DROP DATABASE + 沙箱库内全权限)。**口令不打印、不入日志、不入 git。**
- 沙箱 schema:`pbl_m5a_u7rb`(harness 每次 DROP/CREATE 重建,不触碰业务库 `pbls`) - 沙箱 schema:`pbl_m5a_u7rb`(harness 每次 DROP/CREATE 重建,不触碰业务库 `pbls`)
- 引擎 mariadb 127.0.0.1:3306,DDL 方言与 `env/test.json` 的 `db.engine=mariadb` 一致。 - 引擎 mariadb 127.0.0.1:3306,DDL 方言与 `env/test.json` 的 `db.engine=mariadb` 一致。
@ -123,13 +342,15 @@ def main():
%(clock)s %(clock)s
``` ```
容器内 `date`/`time()`(CLOCK_REALTIME)与文件系统 `st_mtime` 是两个时钟源, 两时钟源偏移统计行(chain log 原样实读):`%(offset_line)s`
实测偏移稳定在 12.832~12.833s(极差 0.001s)——系统性固定偏移,非随机跳变。 → 容器 `date`/`time()`(CLOCK_REALTIME)比文件系统 `st_mtime` 恒快
故日志内打印的时间会「晚于」同一时刻写入的文件 mtime,属正常现象,不是改写痕迹。 %(offset_min)s~%(offset_max)s(mean %(offset_mean)s,极差 %(offset_range)s),
属**系统性固定偏移**而非随机跳变。故日志内打印的时间会「晚于」同一时刻写入文件的
mtime,是正常现象,不是改写痕迹(呼应 §6 末尾的瞬时值说明)。
--- ---
## 1. 必做项2:harness 端到端 RUN A(ALL PASS + RC=0) ## 1. 必做项2:harness 端到端 RUN A(ALL PASS + RC=%(rc_a)s)
### 命令与退出码(chain log 原样切片) ### 命令与退出码(chain log 原样切片)
@ -137,13 +358,15 @@ def main():
%(runa_hdr)s %(runa_hdr)s
``` ```
### runA.log 完整输出(%(runa_bytes)s 字节,逐字) ### runA.log 完整输出(实测 %(runa_bytes)d 字节,逐字)
```text ```text
%(runa)s %(runa)s
``` ```
**判定**:`RC=0` 且末行 `LIVE DB HARNESS ALL PASS`,12 条 `[PASS]`(C-3.1~4 + IDM.1~12)全命中。 **判定**:`RC=%(rc_a)s`(chain 内 `RC=` 行实读)且 `LIVE DB HARNESS ALL PASS` 命中
= %(allpass_a)s;`grep -c '\\[PASS\\]' runA.log` 实读 **%(n_pass_a)d 条**断言,
标签(按出现顺序去重):`%(labels_a)s`。
--- ---
@ -179,9 +402,11 @@ def main():
%(seg4)s %(seg4)s
``` ```
trigger 计数轨迹(各段 `SHOW TRIGGERS ... -> N 条` 原样切片所得):**0 → 1 → DROP → 0**。
--- ---
## 3. 必做项3续:无该 trigger 状态下重跑 harness(RUN B,ALL PASS + RC=0) ## 3. 必做项3续:无该 trigger 状态下重跑 harness(RUN B,ALL PASS + RC=%(rc_b)s)
### 命令与退出码 ### 命令与退出码
@ -189,7 +414,7 @@ def main():
%(runb_hdr)s %(runb_hdr)s
``` ```
### runB.log 完整输出(%(runb_bytes)s 字节,逐字) ### runB.log 完整输出(实测 %(runb_bytes)d 字节,逐字)
```text ```text
%(runb)s %(runb)s
@ -207,142 +432,266 @@ def main():
%(diff)s %(diff)s
``` ```
**结论**:两次运行唯一差异是 2 处**每次随机生成的应用层主键**(`gen_pk` 产物, **结论**:`diff` 实读差异行数 **%(n_diff)d**,其中不含主键 token 的差异行 **%(n_diff_non_pk)d**
前缀 `ev`、长度 32、两次互不相同),断言集合与 `ALL PASS` 完全一致。 → 差异**全部**落在 %(n_pk_diff)d 处每次随机生成的应用层主键(`gen_pk` 产物,前缀 `ev`、
长度 32、两次互不相同)上;断言条数(runA %(n_pass_a)d / runB %(n_pass_b)d)与
`ALL PASS`(runA=%(allpass_a)s / runB=%(allpass_b)s)完全一致。
这恰好反证 id 不是 trigger 产物——trigger 已被 DROP,若 id 依赖 DB 侧兜底, 这恰好反证 id 不是 trigger 产物——trigger 已被 DROP,若 id 依赖 DB 侧兜底,
RUN B 会以 `1364 Field "id" does not have a default value` 失败而非 RC=0。 RUN B 会以 `1364 Field "id" does not have a default value` 失败而非 RC=%(rc_b)s。
- RUN A 主键(runA.log 原样回显行实读):`%(pks_a)s`
- RUN B 主键(runB.log 原样回显行实读):`%(pks_b)s`
--- ---
## 4. 必做项4:collector 重放幂等(同一批数据重放 3 次) ## 4. 必做项4:collector 重放幂等(同一批数据重放 %(n_rp)d 次)
### replay.log 完整输出(%(replay_bytes)s 字节,逐字) ### replay.log 完整输出(实测 %(replay_bytes)d 字节,逐字)
```text ```text
%(replay)s %(replay)s
``` ```
### 重放前后 count(*) 与主键对比(关键数据一览) ### 重放前后 count(*) 与主键对比(全部数值实读 replay.log,非人工填写)
| 指标 | 重放前 | 第1次重放 | 第2次重放 | 第3次重放 | | 指标 | 重放前 | %(rp_hdr)s |
|---|---|---|---|---| %(rp_sep)s
| `pbl_evidence` count(*) | 2 | 2 | 2 | 2 | %(replay_table)s
| collector `created` | — | 0 | 0 | 0 |
| collector `skipped` | — | 2 | 2 | 2 |
| collector `failed` | — | 0 | 0 | 0 |
| 主键集合 | evf1abd3…cd8e / evf5e85f…2191 | 同前 | 同前 | 同前(无变化) |
- **行数不增长**:3 次重放后 `count(*) = 2`(与重放前一致)。 - **行数不增长**:%(n_rp)d 次重放后 `count(*) = %(cnt_final)s`(重放前 `%(cnt_before)s`,一致)。
- **无重复主键冲突**:ids 去重后 2 / 总数 2;`LENGTH(id)` 集合 = `[32]`, - **无重复主键冲突**:ids 去重后 %(dedup_n)s / 总数 %(total_n)s;`LENGTH(id)` 集合 = %(id_lens)s,
符合 `models/pbl_evidence.json` 的 `id: str(32)` 定义。 符合 `models/pbl_evidence.json` 的 `id: str(32)` 定义。
- **主键来源为应用层**:全部 id 以 `ev` 前缀(`gen_pk('ev')` 产物), - **主键来源为应用层**:全部 id 以 `ev` 前缀(`gen_pk` 产物),长度 32 而非 trigger 会产生的
长度 32 而非 trigger 会产生的 `ev+32hex=34`;且此刻沙箱库 `SHOW TRIGGERS` = 0 条。 `ev+32hex=34`;且此刻沙箱库 trigger 数实读 = %(trg_cnt)s(见 replay.log 与 §2 第 4 段)。
- **DB 层真防重佐证**:探针直插重复三元组得到原始报错 - **DB 层真防重佐证**(replay.log 原样行):`%(dup_err)s`,写入路径另含
`IntegrityError: (1062, "Duplicate entry 't1-evt_u7rb_0001-artifact' for key 'uk_ev_dedup'")`, `ON DUPLICATE KEY UPDATE` 作第二层防重(见 §1 runA.log 的 IDM.7)。
写入路径另含 `ON DUPLICATE KEY UPDATE` 作第二层防重。 - 判定行 `REPLAY IDEMPOTENT OK` 命中 = %(replay_ok)s(脚本自带 exit 1 判定,chain 内
- 判定行:`REPLAY IDEMPOTENT OK`(脚本自带 exit 1 判定,chain 内 `[GATE] PASS` 命中)。 `[GATE] PASS` 命中,见 §6 `[6]` 段前的门禁行)。
--- ---
## 5. 清理与终态 ## 5. 清理与终态(chain `[4b]`/`[5]` 段原样切片)
- `[5]` 段执行 `DROP DATABASE IF EXISTS pbl_m5a_u7rb`(仅沙箱 schema),凭据不落盘。 ```text
- `[4b]` 段复核:`pbl_evidence` 2 行 / `pbl_runtime_event` 2 行 / `id_len=32` / %(seg_4b)s
`SHOW TRIGGERS -> 0 行`。 ```
```text
%(seg_5)s
```
`[4b]` 段数值实读:`pbl_evidence` %(ev_rows)s 行 / `pbl_runtime_event` %(evt_rows)s 行 /
`id_len` 集合 = %(db_id_lens)s / `SHOW TRIGGERS` -> %(db_trg_rows)s 行。
`[5]` 段执行 `DROP DATABASE IF EXISTS pbl_m5a_u7rb`(仅沙箱 schema,凭据不落盘)。
--- ---
## 6. 产物文件系统实测(字节数可查,供 QC 复核) ## 6. 产物文件系统实测(`os.stat` + `sha256sum`,供 QC 复核)
| 文件 | 字节 | mtime | sha256(前16) | | 文件 | 字节 | mtime | sha256(前16) |
|---|---|---|---| |---|---|---|---|
%(table)s %(table)s
### 唯一有效 chain log(呼应 QC#17)
- **唯一有效 chain log = `projects/pbls/docs/02-develop/evidence/s3/s3_chain.log`**,
实测 %(chain_bytes)d 字节,sha256 前 16 = `%(chain_sha)s`,末行 `%(chain_last_line)s`。
本文档全部切片仅引用该文件。
- `evidence/` 根目录**与本轮同名**的陈旧副本 `s3_chain.log` 实测存在 = **%(root_dup)s**
(即陈旧同名文件已不存在,不会再与 `evidence/s3/s3_chain.log` 打架)。
- 历史陈旧副本已按 QC#17 重命名保留为可追溯证据、未删除:%(stale_desc)s。
### chain log `[6]` 段原样输出 ### chain log `[6]` 段原样输出
> 说明:本文档由 chain 的 `[7]` 步调用 `gen_s3_log.py` 生成,此刻 chain log 末行
> `CHAIN_RC=0` 尚未刷盘(自引用边界),故切片止于 `[6]` 段末尾;
> 末行可 `tail -1 s3_chain.log` 实测,实测结果紧跟在下方代码块后。
```text ```text
%(tail)s %(seg6)s
``` ```
### chain log `[7]` 段(装配步自身)原样输出
> 口径说明(与磁盘事实一致):`[7]` 段是 **s1 取证链重跑**时写下的,当时按 s1 任务边界
> 用 `S3_DOC` 把装配结果指到 `.s3_scratch/live-harness-s3a-scratch.md`(故该段内路径是
> scratch,不是本交付文档)。本次 S3-b 子任务**不重跑取证链**,只改本装配器后直接
> `python3 tests/gen_s3_log.py --out <本交付文档> --dir evidence/s3 --repo <模块仓>`
> 重生成文档;因此本文档内嵌的日志仍是 s1 那一轮 chain log 的原始字节
> (sha256 前 16 = `%(chain_sha)s`,见上表),未新增/改写任何日志内容。
```text ```text
%(chainrc)s # tail -1 s3_chain.log 实测 %(seg7)s
``` ```
chain log 末行(本脚本 `tail` 实读):`%(chainrc_verify)s`
> **瞬时值说明(防误判为自相矛盾)**:`[6]` 段自述 `s3_chain.log size=%(inst_bytes)s`,
> 而本文 §6 表格实读 %(chain_bytes)d 字节——差值 %(chain_delta)s 字节正是 `[6]` 之后
> (`[7]` 段 + END 行 + CHAIN_RC 行)才写入的内容。chain log 是**边跑边被统计**的自身文件,
> 属预期现象,非拼接、非事后改写。
--- ---
## 7. git 收口(本轮由 develop 自行提交,非引擎代收口) ## 7. git 收口(如实描述,措辞与 `git show --stat` 一致)
- 仓库:`modules/pbl_evidence`(分支 `%(branch)s`) - 仓库:`modules/pbl_evidence`(分支 `%(branch)s`)
- 本文档生成时点 HEAD:`%(head)s`(full `%(headfull)s`) - 本文档生成时点 HEAD:`%(head)s`(full `%(headfull)s`)
- 该时点 `git status --porcelain`:`%(porcelain)s` - 该时点 `git status --porcelain`:
> **时序说明**:本文档由取证链 `[7]` 步生成,故生成时点的 HEAD 仍是上一轮提交、 ```text
> 工作区显示本任务改动为待提交状态——这是正常顺序(先取证、后收口)。 %(porcelain)s
> develop 的收口 commit 在取证链跑完、本文档落盘**之后**立即执行: ```
> 选择性 `git add tests/s3_trigger_probe.py tests/s3_sql_probe.py tests/s3_replay_idempotency.py`
> `tests/s3_clock_skew_probe.py tests/s3_db_url.py tests/s3_clock_compare.py` ### 7a. 最近 3 次提交(`git log --oneline -3` 原样)
> `tests/s3_evidence_chain.sh tests/gen_s3_log.py`
> (**未使用** `git add -A`,未纳入 `tests/__pycache__`/`logs/`),随后 `git commit`。 ```text
> 上一轮「引擎代为收口」的 7 项变更,本轮由 develop 自身 commit 重新落地为可追溯提交; %(gitlog)s
> 收口后的短 SHA 与 `git status` 终态见交付摘要(引擎会在交付件回填「git 收口核验」段可交叉核对)。 ```
### 7b. HEAD 的 `git show --stat`(本轮 develop 自有提交)
```text
%(stathead)s
```
### 7c. HEAD~1 的 `git show --stat`(`%(head1)s` `%(head1subj)s`)
```text
%(stathead1)s
```
**事实陈述(不夸大覆盖范围)**:
1. 6 个探针文件(`s3_trigger_probe.py` / `s3_sql_probe.py` / `s3_replay_idempotency.py` /
`s3_clock_skew_probe.py` / `s3_db_url.py` / `s3_clock_compare.py`)与
`s3_evidence_chain.sh` 初版**已在 HEAD~1(`%(head1)s`,引擎代为收口)入库**——
文件清单以 §7c 的 `git show --stat` 原样输出为准,它们**不是**本轮 develop 新增。
2. 本轮 develop 自有提交是 HEAD `%(head)s`:新增 `tests/gen_s3_log.py`(本装配器)
并修改 `tests/s3_evidence_chain.sh` 的 `[7]` 装配步——具体文件与行数以 §7b 的
`git show --stat` 原样输出为准。
3. 本文档(S3-b 子任务:去硬编码 + 文档重生成)对 `tests/gen_s3_log.py` 的改动,
在本文档生成**之后**由 develop 选择性 `git add tests/gen_s3_log.py && git commit`
收口(生成时点该文件仍是工作区待提交状态,见上方 porcelain,属「先取证、后收口」
的正常顺序);**未使用** `git add -A`,未纳入 `tests/__pycache__`/`logs/`。
收口后的短 SHA 与终态以交付摘要中引擎回填的「git 收口核验」段为准,本文不预先声称。
4. 探针脚本内**未内嵌 task key**(实测逐文件 grep 计数:%(probe_hits)s)。
任务归属仅由本文档首行 `TASK_KEY: %(key)s` 与 chain log 的 `[path] OUT=` 取证目录标识;
本文不宣称探针带 task-key docstring 头(与磁盘事实一致)。
5. 本文档与 `evidence/s3/*` 属 `projects/pbls`(过程文档仓库),按产线规范由 PM
审核通过后统一提交,不在模块仓库 `modules/pbl_evidence` 的提交范围内。
--- ---
## 8. 结论(对照验收标准逐条) ## 8. 结论(对照验收标准逐条)
| # | 验收标准 | 结果 | 证据位置 | | # | 验收标准 | 结果 | 证据位置(数值均为实读) |
|---|---|---|---| |---|---|---|---|
| 1 | 沙箱库环境就绪、连接串脱敏记录 | ✅ | §0 | | 1 | 沙箱库环境就绪、连接串脱敏记录 | ✅ | §0(chain `[env]` 行原样切片) |
| 2 | `python3 tests/m5a_live_db_harness.py` ALL PASS 且 RC=0,含命令+完整输出+RC | ✅ | §1(runA.log 全文 6482B) | | 2 | `python3 tests/m5a_live_db_harness.py` ALL PASS 且 RC=0,含命令+完整输出+RC | ✅ | §1(runA.log 全文实测 %(runa_bytes)d 字节,%(n_pass_a)d 条 `[PASS]`,RC=%(rc_a)s) |
| 3 | **删 trigger 复验四段完整**:drop 前 SHOW TRIGGERS / DROP 语句 / drop 后 SHOW TRIGGERS / 重跑输出+RC | ✅ | §2 + §3(RC=0 + ALL PASS) | | 3 | **删 trigger 复验四段完整**:drop 前 SHOW TRIGGERS / DROP 语句 / drop 后 SHOW TRIGGERS / 重跑输出+RC | ✅ | §2 + §3(RUN B RC=%(rc_b)s + ALL PASS=%(allpass_b)s,%(n_pass_b)d 条 `[PASS]`;差异 %(n_diff)d 行全部只落在主键) |
| 4 | 重放幂等:前后 count(*) 对比 + 主键 id=str(32) + 无重复主键冲突 | ✅ | §4(2→2→2→2,去重 2/2,len=32) | | 4 | 重放幂等:前后 count(*) 对比 + 主键 id=str(32) + 无重复主键冲突 | ✅ | §4(count %(cnt_before)s→%(cnt_final)s,去重 %(dedup_n)s/%(total_n)s,len 集合 %(id_lens)s,trigger 数 %(trg_cnt)s) |
| 5 | 日志真实落盘、路径+字节数可查、归属本任务 | ✅ | §6 + 本文首行 `TASK_KEY` | | 5 | 日志真实落盘、路径+字节数可查、归属本任务 | ✅ | §6(os.stat + sha256 实读)+ 首行 `TASK_KEY: %(key)s` |
| — | 不改业务逻辑、不写业务代码 | ✅ 仅新增 tests/ 下取证脚本与日志装配器,`pbl_evidence/*.py` 零改动 | §7 porcelain | | 6 | 文档与实测零自相矛盾(QC#15/#16) | ✅ | §9 溯源表 + §6 瞬时值说明 + §7 事实陈述 |
| — | 不改业务逻辑、不写业务代码 | ✅ 仅 tests/ 下取证脚本与日志装配器,`pbl_evidence/*.py` 零改动 | §7b/§7c stat 清单 |
**总判定:全部必做项通过,取证链末行 `%(chain_rc)s`。**
〔结论段为人工填写的判断文字,其中每个数字均由上文实读值代入〕
**总判定:全部必做项通过,取证链 CHAIN_RC=0。**
链路真实性关键论据:BEFORE INSERT 适配 trigger 在 RUN B 之前被显式 DROP 链路真实性关键论据:BEFORE INSERT 适配 trigger 在 RUN B 之前被显式 DROP
(drop 后 `SHOW TRIGGERS -> 0 条`),RUN B 仍 `ALL PASS + RC=0`, (drop 后 `SHOW TRIGGERS -> 0 条`),RUN B 仍 `ALL PASS + RC=%(rc_b)s`,
且落库主键为 32 位 `ev` 前缀(应用层 `gen_pk` 形态,非 trigger 的 34 位形态)—— 且落库主键为 32 位 `ev` 前缀(应用层 `gen_pk` 形态,非 trigger 的 34 位形态)——
证明走的是真实链路(应用层主键 + `uk_ev_dedup` 唯一键 + `ON DUPLICATE KEY UPDATE`), 证明走的是真实链路(应用层主键 + `uk_ev_dedup` 唯一键 + `ON DUPLICATE KEY UPDATE`),
而非补丁生效。 而非补丁生效。
---
## 9. 数值溯源表(每个数字取自哪里,QC 可逐条复算)
| 文档中的值 | 实读值 | 取值方式(gen_s3_log.py 内对应代码) |
|---|---|---|
| 采集 START / END | %(start_ts)s / %(end_ts)s | chain log 首/末标题行 `TS_RE` 正则 |
| epoch START / END | %(start_epoch)s / %(end_epoch)s | 同上 `epoch=([\\d.]+)` |
| CHAIN_RC | %(chain_last_line)s | chain log 末行 tail 实读 |
| 时钟偏移 min/max/mean/极差 | %(offset_min)s / %(offset_max)s / %(offset_mean)s / %(offset_range)s | chain `[1]` 段 `offset 统计:` 行正则 |
| runA.log 字节 / sha16 | %(runa_bytes)d / `%(runa_sha)s` | `os.stat` + `sha256sum` |
| runB.log 字节 / sha16 | %(runb_bytes)d / `%(runb_sha)s` | 同上 |
| replay.log 字节 / sha16 | %(replay_bytes)d / `%(replay_sha)s` | 同上 |
| s3_chain.log 字节 / sha16 | %(chain_bytes)d / `%(chain_sha)s` | 同上 |
| `[6]` 段自述 chain 瞬时值 | %(inst_bytes)s(差值 %(chain_delta)s) | chain `[6]` 段 `s3_chain.log size=(\\d+)` |
| 断言条数 runA / runB | %(n_pass_a)d / %(n_pass_b)d | 逐行 `'[PASS]' in line` 计数(grep -c 等价) |
| RUN A / RUN B 的 RC | %(rc_a)s / %(rc_b)s | chain 对应段首个 `RC=(\\d+)` |
| RUN A 主键 | %(pks_a)s | runA.log「证据行主键回显」行 `PK_RE` |
| RUN B 主键 | %(pks_b)s | runB.log 同上 |
| replay 前/后主键 | %(pks_rp_before)s / %(pks_rp_after)s | replay.log「重放前/后主键回显」行 |
| count 前/最终 | %(cnt_before)s / %(cnt_final)s | replay.log 正则 |
| 去重/总数 / id 长度集合 | %(dedup_n)s / %(total_n)s / %(id_lens)s | replay.log 正则 |
| trigger 数(replay 时点) | %(trg_cnt)s | replay.log 正则 |
| `[4b]` evidence/event 行数 | %(ev_rows)s / %(evt_rows)s | chain `[4b]` 段 JSON 正则 |
| diff 差异行 / 非主键差异行 | %(n_diff)d / %(n_diff_non_pk)d | `subprocess diff` 输出逐行统计 |
| git HEAD / HEAD~1 / 分支 | %(head)s / %(head1)s / %(branch)s | `git rev-parse` 子进程 |
| git 文件清单 | §7b / §7c 原样块 | `git show --stat --oneline` 子进程 |
| 探针 task-key 计数 | %(probe_hits)s | 逐文件读入统计 |
| 根目录同名陈旧副本存在? | %(root_dup)s | `os.listdir(evidence/)` |
| 陈旧重命名副本 | %(stale_names)s | 同上(前缀匹配 `s3_chain.log*`) |
""" """
# 从 chain log 中切 RUN A / RUN B 的「命令+RC」头部(含 $ 命令行与 RC= 行)
runa_hdr = cut("########## [2] RUN A", "M5a live-DB harness")
runb_hdr = cut("########## [3-5] RUN B", "M5a live-DB harness")
seg_after_b = cut("########## RUN B 结束后再次确认 trigger",
"########## [4] collector")
env_line = chain.split("沙箱连接串(脱敏)= ")[1].split("\n")[0].strip()
body = md % dict( body = md % dict(
key=TASK_KEY, key=TASK_KEY, parent=PARENT_KEY, role=ROLE_LINE,
gen=time.strftime("%Y-%m-%d %H:%M:%S %z"), gen=time.strftime("%Y-%m-%d %H:%M:%S %z"),
env=fenced(env_line, "text"), start_ts=start_ts, end_ts=end_ts,
clock=seg_clock.split("\n", 1)[1].strip(), start_epoch=start_epoch, end_epoch=end_epoch,
runa_hdr=runa_hdr.split("\n", 1)[1].strip(), chain_rc=chain_rc, chain_last_line=chain_last_line,
env=fenced(env_line, "text") if env_line else "(chain log 未含脱敏连接串行)",
clock=seg_clock.split("\n", 1)[1].strip() if "\n" in seg_clock else seg_clock,
offset_line=offset_line or "(未实读到 offset 统计行)",
offset_min=offset_min, offset_max=offset_max,
offset_mean=offset_mean, offset_range=offset_range,
runa_hdr=runa_hdr.split("\n", 1)[1].strip() if "\n" in runa_hdr else runa_hdr,
runb_hdr=runb_hdr.split("\n", 1)[1].strip() if "\n" in runb_hdr else runb_hdr,
runa=run_a, runb=run_b, replay=replay, runa=run_a, runb=run_b, replay=replay,
runb_hdr=runb_hdr.split("\n", 1)[1].strip(), runa_bytes=runa_bytes, runb_bytes=runb_bytes, replay_bytes=replay_bytes,
runa_bytes=size(os.path.join(d, "runA.log")), runa_sha=sha256(runa_p)[:16], runb_sha=sha256(runb_p)[:16],
runb_bytes=size(os.path.join(d, "runB.log")), replay_sha=sha256(replay_p)[:16],
replay_bytes=size(os.path.join(d, "replay.log")), chain_bytes=chain_bytes, chain_sha=chain_sha,
inst_bytes=inst_bytes, chain_delta=chain_delta,
rc_a=rc_a, rc_b=rc_b,
allpass_a=allpass_a, allpass_b=allpass_b,
n_pass_a=n_pass_a, n_pass_b=n_pass_b,
labels_a=", ".join(labels_a), labels_b=", ".join(labels_b),
pks_a=pk_list_str(pks_a), pks_b=pk_list_str(pks_b),
pks_rp_before=pk_short_str(pks_rp_before),
pks_rp_after=pk_short_str(pks_rp_after),
n_pk_diff=max(n_diff // 2, 0),
seg0=seg_0, seg1=seg_1, seg2=seg_2, seg3=seg_3, seg4=seg_4, seg0=seg_0, seg1=seg_1, seg2=seg_2, seg3=seg_3, seg4=seg_4,
seg_after_b=seg_after_b, seg_after_b=seg_after_b, seg_4b=seg_4b, seg_5=seg_5,
diff=diff_ab, table=table, tail=seg_tail, seg6=seg_6, seg7=seg_7,
chainrc="(CHAIN_RC 行在文档生成后刷盘,请 tail -1 实测)", diff=diff_display, n_diff=n_diff, n_diff_non_pk=n_diff_non_pk,
head=head_commit, headfull=head_full, branch=branch, only_pk_diff=only_pk_diff,
porcelain=porcelain, n_rp=n_rp,
rp_hdr=" | ".join("第%s次重放" % r["idx"] for r in rp_rows),
rp_sep="|" + "|".join([" --- "] * (2 + n_rp)) + "|",
replay_table=replay_table,
cnt_before=cnt_before, cnt_final=cnt_final,
dedup_n=dedup_n, total_n=total_n, id_lens=id_lens, trg_cnt=trg_cnt,
dup_err=dup_err or "(未实读到 Duplicate entry 行)", replay_ok=replay_ok,
ev_rows=ev_rows, evt_rows=evt_rows,
db_id_lens=("[" + ", ".join(db_id_lens) + "]") if db_id_lens else "-",
db_trg_rows=db_trg_rows,
table=table,
root_dup=root_dup, stale_desc=stale_desc,
stale_names=", ".join(stale) if stale else "(无)",
chainrc_verify=chain_last_line,
head=head_commit, headfull=head_full, head1=head1,
head1subj=head1_subject, branch=branch, porcelain=porcelain,
gitlog=git_log, stathead=stat_head, stathead1=stat_head1,
probe_hits=probe_hits,
) )
os.makedirs(os.path.dirname(a.out), exist_ok=True) out_dir = os.path.dirname(os.path.abspath(a.out))
os.makedirs(out_dir, exist_ok=True)
with open(a.out, "w", encoding="utf-8") as f: with open(a.out, "w", encoding="utf-8") as f:
f.write(body) f.write(body)
print("[gen] 写出 %s (%d bytes)" % (a.out, os.path.getsize(a.out))) print("[gen] 写出 %s (%d bytes)" % (a.out, os.path.getsize(a.out)))
print("[gen] sha256=%s" % sha256(a.out)[:16]) print("[gen] sha256=%s" % sha256(a.out)[:16])
return 0
if __name__ == "__main__": if __name__ == "__main__":
main() sys.exit(main())