HOOP PulseKit(脉诊)—— 诊断系统图纸 v1.7

HOOP 系统设计图纸 · 正文唯一真相是 docs/PULSEKIT.md;改 md 再跑 ./tools/deploy_docs.sh,这页是生成的

版本 6b185d18f
2026-09-16 14:53

出处:Jeff 2026-09-13 14:52 / 15:03 两条语音(Hoop Master 房)· Gode Master 14:54–15:05 三条 · Nova Master 16:25 一条 · Jeff 2026-09-16 05:15「你们开工」。 写这份的人:Vega(通话线)。审:Gode Master(审核卡已落 09-16 05:16)。出包和真机开销实测:NOVA / Nova Master。 基线:origin/master b0eda8f44(2026-09-16)。这份是图纸,不是现状:§2 那张盘点表才是现状,其余全是待建。 v1.1(09-16 05:24 Gode 首审六条):v1 里六处约束互相打架 —— 脱敏正则会打掉编号、mono 跨进程不能排、自动上传与「网络 0」矛盾、上传只限压缩后大小、账本只限条数、三档量法尺子不同。v1.1 逐条闭合,对照在 §11.2。 v1.2(09-16 05:33 Gode 复审五组):receiving 没有恢复出口、R2 存原始 gzip 会留下未知字段、开关优先级会误清手动提交、L1 内存超预算且会话容量无解、首包尺子不是接收端。v1.2 改成直接写状态转换和失败结果,对照在 §11.3。 v1.3(09-16 05:40 Gode 三审四处):锁行串行裁决与孤儿对象、track 联合类型(现有 local:mic / local:cam 会被 v1.2 拒掉)、「一份未提交」的定义与流式压缩、裁剪证明不能靠 pubspec.lock 不含 vm_service(它今天就在)。对照在 §11.4。Gode 三审放行:本地类型与账本可按已认可部分开码,上传设计仍待放行。 v1.4(09-16 05:45 Gode 定向复审两组,只动 §6.2):对象键必须先提交再 put(v1.3 把 INSERT 和 put 写在一个事务里,put 后进程死会一起回滚)、DELETE 等锁只留一种、清理不许抹掉未清完的责任记录、req_sha 的字节定义与流式 gzip 不衔接。对照在 §11.5。 v1.5(09-16,随第 ②a 步代码同一笔):§2 标出已迁入账本的三处;§4.3 说清 v1 类型闸是运行时校验;§6.2 补 Gode 定向复审通过时要的三点(DELETE / 读 / 下载验 user_id、事务 B 超时与取消、验收 E 措辞);§10 第 ② 步拆成 ②a 本地(已交)/ ②b 上传。对照在 §11.6。 v1.6(09-16,随 ②a 修补同笔):Gode 审 60d713ed0 抓出五条真缺陷 + 一处交付出入(§11.7)。账本改成唯一串行写手 + 账号代次绑定 + 退出屏障,envelope 全过闸,限额在首个 await 前预留,关了不记不计不写,文件上限含本批与归档、7 天按事件 ts,boot 之前 setAccount 只存身份。 v1.7(09-16,随 ②a 修补二同笔):Gode 复审 c7bbc7b3e 四条边界(§11.8):账号切换本身排进串行链、flush 按队列剩余切批且丢弃计数不进环、到期段落删除不归档 + 读回按保留期过滤、真实前后台状态统一守门起钟。

0. 一句话

把「一次真实操作」从手机到服务器串成一条脉络,平时只记不传、追问题时限时加深、正式包里裁得掉重的那半。 不是再造一个浮窗。浮窗只有客户端那一半,分不清「没发出去 / 服务器慢 / 网络断 / 音轨没了」;PulseKit 两半都有,所以分得清。

Jeff 定的四件事(09-13 语音,Nova 归纳):轻量核心 · 主动加深 · 模块可裁剪 · 可扩展。外加他最在意的一条:它自己不许拖慢电话

1. 三档(和第零档)

谁开 记什么 传不传 进哪个包
L0 关 编译期 --dart-define=HOOP_PULSE=off 什么都不记,代码被 tree-shake 掉 只用于开销对照包
L1 基础 装好就开(待 Jeff 拍板的默认值,Nova 提议) 崩溃 / 卡死 / 启动耗时 / 请求失败率 / 连接重连 / 通话脉络 / 系统日报(MetricKit) 只记不传;用户在「设置 → 诊断」按一下才上传 正式包
L2 诊断会话 用户(或测试员)主动开,限时 用户勾选的模块(通话 / 消息 / 网络 / 资源 / 启动)按更高采样率记 到期后用户看过摘要再提交 正式包
L3 深度 只在测试包 帧时间线 · 内存分配采样 · 线程 CPU · 音频单元统计 · 请求体大小 同 L2 只进测试包;正式包裁掉(§7 列清单)

「软件里关掉」和「安装包完全不带」是两件事(Gode 14:54):L1/L2 可以在设置里关;L3 是编译期不带,要用得换包。

1.1 唯一的例外:通话脉络现在就是自动上报的

call_diag(POST /v1/calls/diag,迁移 000294)从 2026-08-25 起自动把通话看门狗的事件送到线上,Jeff 当晚点头、Gode 当晚要的。09-12 Kian 那通单向无声就是靠它 + LiveKit 日志对上的。

图纸口径: - 迁进来:call_diagdiagSink 改成写 PulseKit 的账本(§3.4),它不再自己发。全 App 只有一个写手、一个上传口,旁路就不存在。 - 通话这一路保留自动上传,写成账本里一条 channel 级策略(call = auto,其余 = on_demand)。它是产品决定(Gode 09-16:可单独留给 Jeff),不挡基础档开工。 - 自动通道发什么、手动报告发什么(§6.5 一张表列死):自动通道只发 call channel 的 §4.1 事件(现有 CallDiag.* 那一组),每通 ≤ 30 条、每批 ≤ 50 条、一通 ≤ 60 KB;手动报告发 L1 账本全部 channel + L2 会话文件。同一条事件两路都可能带(为了脉络完整),服务器按 (inst, seq) 去重。 - 给用户一个开关:「设置 → 诊断 → 通话诊断自动上报」,默认开(待 Jeff 拍板 §9-1)。 - 网络预算(§8.1)据此改写:L1 网络 = 「0,通话自动通道(上限如上)」,不再写「0」。

1.2 开关、退出账号与两条上传队列(Gode 首审 3 / 复审 3)

先分清有几样东西:采集(记不记)· auto 队列(通话自动批次)· manual 队列(用户在提交页按过「提交」的报告)· 账本文件。四样各自受哪些开关管,一张表列死,不再用「以上全部为开」这种总条件:

事件 采集 auto 队列 manual 队列 账本文件
总开关关 清空 不动(用户确认过的提交照发完) 留到用户「清除」或 7 天滚动
通话自动上报关 call 退回 on_demand 清空 不动 不动
L2 会话到期 / 用户停止 回 L1 不动 不动(到期后用户再提交走正常路) 会话文件留给用户看 / 提交 / 删
退出账号 / 换账号 停(新账号重新起) 清空 清空 删该账号目录
编译期 L0
  • 退出账号的清理独立执行:它是 Pulse.onAccountChanged(uid) 一条路,不经过总开关判断,总开关关着照样跑。
  • 账号代次:每次登录 / 退出 gen++;每条发送和它的回调都带发起时的 (uid, gen),回来时 gen 不等于当前就丢弃结果、不写盘、不改状态 —— 退出账号后迟到的 200 不会把上个账号的报告标成「已发送」写进新账号目录。
  • 重试(§6.2)只看自己那条队列还在不在,不看别的开关。

2. 现有采集点盘点(现状,不是设计)

采集点 在哪 记什么 留多久 / 上限 传不传 PulseKit 怎么接
资源浮窗 FdMonitor app/lib/store/fd_monitor.dart · 原生 ios/Runner/HoopFdMonitor.swift(getrlimit + fcntl) 每 10 s 采一次进程打开的文件描述符数、上限、iOS 版本、App 版本;WS 重连原因那一行 留 10 分钟;后台暂停;默认关(设置 → 通用 → 资源监视器) 不传;「复制诊断信息」经 sanitize() 打码(网址 / Bearer / 长串 / 邮箱) 变成 L2「资源」模块的一个采样器;sanitize() 只管「复制诊断信息」那段人读文字,结构化事件不经它(§4.3)
启动面包屑 BootCrumb app/lib/data/boot_crumb.dart 启动每一步落记号,活着出来抹掉;留下记号 = 上次死在那一步;同一步连死 2 次下次跳过 一个文件,零插件、全同步 不传 L1 的「崩溃 / 死在哪一步」就从它读:启动时把上次的记号变成一条 app.crash_at_boot{step} 事件
通话看门狗 + call_diag app/lib/services/call_engine_watchdog.dart diagReportApiClient.callDiagbackend/internal/domain/calls/diag.go → 表 call_diag audio_session · local_publish · remote_first_packets · ringback · callkit_action · flow_stall · flow_blind · reconnected · track_restore · resub · call_summary · rating 每通 ≤ 30 条(kDiagCap);后端一批 ≤ 50 条、detail ≤ 2000 字节、每人每分钟 ≤ 60 批;超了照回 200 不收;表没有清理(§6 补) 自动,不 await、不重试、不弹提示;门禁 CanJoin 已迁入(v2.271):diagSink / reportCallKit / 评分表都经 recordCallDiag → 适配器(call_diag_adapter.dart,翻掉 err / 拼接 ended_by / 空 perm)→ 账本;老自动上报路照旧,等 ②b 并成一个口
WS 客户端诊断行 WsClient.diagnostics 重握手 / 重试 / 断了 / 出错 那几类一行字 内存里一小串 不传(浮窗报告会摘最近一条) 改成结构化事件 net.ws_reconnect{reason_code},不再是一行中文
通话后评分 通话结束页 → call_diagrating 事件 1–5 星 + 原因 同 call_diag 自动 保留;是 L1 里唯一「用户主动给的」信号
服务器这一半 LiveKit hoop-livekit-1(INFO)· 后端 slog · callStats expvar ICE 换对 / resume / SyncState / 轨发布 · 请求日志 · 计数器 docker 日志按容器轮转 请求日志加 trace_id 字段(§3.3),LiveKit 日志靠房号对

盘点结论(和 Gode 14:55 一致):线索不少,缺的是把它们串成同一次故障;而且除通话外全是「只活在那台手机上」。

3. 事件模型

3.1 一条事件长什么样

{ v:1, inst:"7c1e…(进程实例号,16 hex)", seq: 41, mono: 12345, ts: 1789190486951,
  ch:"call", ev:"remote_first_packets", op:"3f9c…(脉络号)", pop:null,
  f:{ t_ms: 30562, packets: 57, track:"TR_AM…" } }
  • inst 进程实例号:每次进程启动随机生成;seqmono 的作用域都是它。不同 inst 之间不能用 mono
  • seq 单进程内单调递增,丢事件时会跳号(跳号本身就是「这里丢过」的证据)。
  • mono 单调毫秒(进程起算);ts 墙钟毫秒。
  • ch / ev 固定枚举(§4.1)。op 脉络号;pop 父脉络号(子操作用,如 media.upload 挂在 msg.send 下)。
  • f 只允许 §4.1 的类型。序列化后单条 ≤ 2 KB,超了整条丢并计入 sys.dropped{why:"oversize"}
  • 单条 ≤ 2 KB、id 类字段 ≤ 64 字符、枚举码 ≤ 32 字符 —— 这三个数是后端校验和客户端类型闸共用的常量(一份 JSON 生成两端)。

3.2 脉络(operation)—— HOOP 自己的那一招

一次真实操作从起点到终点串起来,手机和服务器共用同一个脉络号。第一批七条:

脉络 起点 中间站(每站一条事件) 终点
call.out 点拨号 offer 发出 → CallKit 登记 → 会话交出(audio_session)→ 开麦(local_publish)→ 回铃(ringback)→ 对方接 → 远端首包(remote_first_packets)→ 重连 / 补订(reconnected / resub) 挂断(call_summary)
call.in 系统来电响 accept → 会话交出 → 开麦 → 远端首包 → … 挂断
msg.send 点发送 本地落库 → 附件上传(若有)→ HTTP 发出 → 服务器回执 → WS 回声 送达 / 失败(错误码)
media.upload 选中文件 压缩 → 分片 → 每片结果 完成 / 失败
app.start 进程起 BootCrumb 每一步 → 首帧 → 首页数据到 可交互
auth.refresh 令牌到期 请求发出 → 结果 成功 / 失败(只记耗时和结果码,绝不记令牌,Gode 14:55)
net.reconnect WS 断 退避 N → 重握手 → 首条消息 恢复

通话那两条已经在 call_diag 里活着(缺的只是脉络号和几个起点站);其余五条是新的。

3.3 脉络号怎么到服务器,三边的时间怎么对(Gode 首审 2)

  • HTTP:请求头 X-Hoop-Trace: <op>;后端 httpx 中间件读进 slog 的每一行(trace=),同时写进 pulse_reports.trace_ids
  • LiveKit:房号本身就是关联键(call_diag.room),脉络号作为 call_diag.detail.op 一起存。
  • WS:握手 query 不带(避免进网关日志),连上后第一帧 hello 里带。

排序规则(写进 Admin 时间线视图,也写进查案手册): 1. 同一 inst 内:按 mono;seq 跳号处画一个「此处丢过 n 条」的记号。 2. 同一设备不同 inst(重启、L2 跨重启续记):按段落排 —— 每段用报告头里的 inst_started_at(墙钟)排段,段内仍按 mono;段与段之间不画顺序箭头。 3. 手机 ↔ 服务器 ↔ 对端手机:只能用 ts,而每台钟不一样。客户端每次收到后端响应就用 Date 头估一次偏差,报告头里带 clock_skew_ms(估值)和 clock_skew_rtt_ms(那次往返,当不确定度);时间线上跨端事件画成带误差带的点(± rtt/2 + 50 ms),两端事件差在误差带内就不排先后。 4. 邻接 ≠ 因果:时间线只展示先后,因果关系只承认脉络号 / 父子脉络号(op / pop)串起来的;后台文案固定写「相邻」不写「导致」。

3.4 本地账本(所有档共用的那一个写手)(Gode 首审 5 / 复审 4)

  • 入队:通话路径里调 Pulse.record(...) 是一句同步入队,无 await,无异常外泄(和 BootCrumb 同一条规矩:这个东西自己绝不能成为新的卡点)。入队前整个 envelope 过闸:ch / ev / track / op / pop / f(v1.6:op / pop 也按脉络号格式核,不合格整条丢)。
  • 唯一串行写手(v1.6):所有批次排进一条队列,一次只写一批,轮转和追加都在这条队列里做;每个写任务绑入队时的账号代次,代次 / 开关 / 账号任一变了就作废这批。限额(批数字节)在 flush()第一个 await 之前同步预留,满了只累计 queue_full、不派、不等。
  • 退出账号屏障:换代 → 清没开始的批次 → 账号置空(谁都不许写)→ 等在途那一批写完 → 删旧目录 → 才发布新账号。账号切换本身排进单一串行链(v1.7):后一次 setAccount 等前一次整段做完才开始,退出还没删完目录时重登不会抢先恢复账号。
  • 切批(v1.7):flush 按队列剩余字节 / 批数把环切成若干批(每批 ≤ 队列字节 ÷ 批数),切得下多少排多少,剩的留环里等下一拍;丢弃统计只是几个整数计数器,合成的 sys.dropped 进批次不进环,永远挤不爆环。
  • 保留期(v1.7):到期的段落删掉,不归档;归档只因大小产生、到期也删;readBack 按保留期过滤 —— 文件还没轮到删,读回也不把过期事件交出去。
  • 起钟只有一道门(v1.7):boot / 切账号 / 开关 / 慢盘退避 / 回前台都走 _ensureTimer,它看真实前后台状态,后台一律不起。boot 之前 setAccount 只存待同步身份,不起 timer / observer、不解析 schema、不建目录;boot() 收尾取最新身份,异步返回不会盖掉中途换的人。
  • 总开关关:record 不记也不计;flush 不写(连 sys.dropped 也不写);没开始的批次取消,在途那一批写完即止;文件留着。
  • 内存限额按档分开,预算(§8.1)含对象开销: | 档 | 内存环 | isolate 待写队列 | 合计上限(含对象开销 ×2 估)| |---|---|---|---| | L1 | 512 条 ≤ 512 KB | 16 批 ≤ 256 KB | < 2 MB | | L2 | 2048 条 ≤ 2 MB | 64 批 ≤ 1 MB | < 8 MB | 单条 ≤ 2 KB 封顶保证「条数 × 2 KB」是硬上界;实测(§8)超了就下调条数,不调预算。
  • 满了丢最旧。丢弃计数聚合:只加一个内存计数器,下一次 flush 时合成一条 sys.dropped{n, why},不为每次丢弃再入队一条。
  • 写盘 isolate:主 isolate 每 5 s(前台)把环里的批送过去;队列满了背压 = 主 isolate 不再送、只累计丢弃计数,直到队列腾出;慢盘(一次写 > 2 s)记 sys.slow_disk 一次并把 flush 间隔退避到 30 s。
  • 文件与容量(三审 3):按账号隔离,目录 pulse/<sha256(uid) 前 16 位>/;L1 账本 ledger.jsonl + 归档 ledger.1.jsonl 合计 ≤ 1 MB(单文件封顶取一半,轮转按「当前大小 + 本批字节」裁决,归档算进总量);7 天按段落首条事件的 ts 判,不看 mtime(持续追加也会到期;归档首条超 7 天就删);L1 报告冻结件 l1-<id>.frozen.jsonl ≤ 1 MB(冻结后账本从零续写,两份都计入);L2 会话 session-<id>.jsonl ≤ 4 MB(冻结 = rename,不复制)。 「一份未提交的会话」的定义:状态在 draft / frozen / uploading / retry_wait / rejected 任一个 —— 也就是服务器还没回 stored、用户也没点「丢弃」的,都算未提交;按过「提交」但断网卡在 retry_wait 的 4 MB 也算,期间不许开新会话磁盘上不留压缩文件:上传时从冻结件流式 gzip 直接进请求体,重试再流一次;所以「压缩触顶」不存在 —— 4 MB 明文经 gzip 最坏也远小于服务端 8 MB 的上限(gzip 不可压缩数据只膨胀几十字节 / 块),不可压缩报告照样收。 总容量 ≤ 6 MB = L1 账本 1 + L1 冻结件 1 + L2 会话 4,按实际字节核:开会话前先核 目录实际字节 + 4 MB ≤ 6 MB,不够就拒。 验收:慢网上传中(uploading / retry_wait)开新会话 → 拒;符合 schema 但压缩率低的 4 MB 会话(每条事件的 id / 数值都随机、不重复)→ 上传成功、服务端存下;L1 冻结件 + 新账本各 1 MB 同时在 → 目录 2 MB,此时开会话仍允许(2 + 4 = 6)。
  • 没有空间时的失败结果(磁盘满、总量到顶):开新会话 → 拒绝,提交页写「先提交或丢弃上一份诊断」或「手机存储不足」;L1 追加失败 → 停记,落一行 storage_full 标记文件,设置页显示「诊断已暂停:存储不足」,下次成功写入后自动清标记;上传永远不因「压不下」失败(见上)。
  • 持久化的保证只到「追加写返回」:每行一条 JSON,读取时容忍最后一行截断。不承诺任何「最多丢 N 秒」:丢的是「自上次成功 flush 以来」的一切 —— 前台正常是一个 flush 间隔(5 s),慢盘退避时可到 30 s,isolate 队列积压时加上队列里那几批,中途被杀时再加上正在写的那半行。查案手册按 seq 跳号和 sys.dropped 读缺口,不按秒数估。
  • 后台:不起定时器;只在三个时刻各 flush 一次:App 进后台、通话结束(call_summary 后)、L2 会话到期。CallKit 从后台叫醒接电话时进程活着,通话事件照常入环、通话结束 flush。
  • 守卫:单条超 2 KB 被丢且计数 · 环满丢最旧且只有一条聚合 sys.dropped · isolate 队列满时主 isolate 不阻塞 · 第二份会话被拒 · 总量到顶时 L1 停记并落标记 · 退出账号后目录空 · 截断的最后一行能读过去。

4. 隐私:靠字段白名单,不靠承诺(Gode 09-16)

4.1 允许进 f 的类型(其余一律编译不过 / 后端拒收)

类型 约束
整数 / 布尔 / 毫秒 t_ms, packets, ok
枚举码 reason:"timeout", kind:"denied" 每个 ev 一张枚举表,在 pulse_schema.dart 和后端 pulse/schema.go 各一份、由同一份 JSON 生成(不许手抄)
id(每个字段各自格式) 房号 / 轨 sid / 脉络号 / 报告 id 按 §4.4 那张表逐字段核格式和来源,没有通用的「任意字符串 id」
版本 / 机型 / 系统版本 v2.271, iPhone16,2, 17.5 只在报告头,不在每条事件里重复
路由模板 POST /v1/messages/{cid} 不是完整 URL;参数替换成 {},查询串整段不记
错误码 HTTP 状态 / 我们自己的 code / 平台错误码数字 不记错误消息原文(原文可能夹着用户数据)
对端角色 peer:"callee" 不记对方是谁(uid / 名字都不记)

4.2 永远不进去的

聊天正文 · 语音 / 视频内容 · 密码 / 令牌 / Bearer · 完整 URL · 文件名 · 联系人 · 邮箱 / 手机号 · 别人的 uid · 剪贴板 · 手机上其它 App 的任何东西(Gode 14:55:「不能承诺检查手机上所有应用」)。

4.3 三道闸(Gode 首审 1:按字段类型校验,不套自由文本脱敏器)

  1. 类型闸:事件只能用 PulseEvent.xxx(...) 这类有名构造器造;每个字段的类型就是 §4.1 的七种之一,各自校验:整数 / 布尔 / 毫秒直接过;枚举码查表,不在表里 → 整条丢 + sys.dropped{why:"enum"};id 类字段每个字段各自一条格式(§4.4),不存在「任意字符串当编号」的入口;路由模板必须是编译期常量表里的一条;错误码只许数字或我们自己的短码;版本 / 机型 / 系统只在报告头。没有「自由文本」这种字段类型,所以没有可以夹带正文的口。
  2. 正则闸只给旧的「复制诊断信息」:FdMonitor.sanitize() 继续管那段人读的文字;结构化事件不经过它。
  3. 后端闸:POST /v1/pulse/reportsPOST /v1/calls/diag 按同一份 schema 校验;一条事件的任一字段不合格 → 那一条丢并计数;一份报告里被丢的事件 > 5% 或出现任何未知键 → 整份 422 拒绝并回 {dropped:{n, why}}(一个客户端版本的 bug 不许借「丢一条」漏东西);通过的报告只存规范化后的版本(§6.3)。

验收:① 合法脉络号 / 报告 UUIDv7 / TR_… sid 经两端闸后逐字节不变;② 往 call_summarytext:"你好"、往 id 字段塞 secretpassword / 1234567890 / 邮箱、往枚举塞 Bearer xxx → 客户端整条丢并计数、后端整份 422;③ 旧「复制诊断信息」里的网址 / Bearer / 邮箱仍被打码。三条各配坏刀。

v1 实现口径(②a 已交):类型闸是运行时校验 —— Pulse.record() 对每条事件跑 PulseSchema.check,不合格整条丢并计入 sys.dropped{why:invalid};入口是 PulseEvent.call(...) 有名构造器,字段是 Map<String, Object?>。「不合格编译不过」那一档要靠 schema 生成的静态字段类(第 ③ 步随新脉络一起做),v1 没有,别把它说成已有。schema 的唯一出处是 app/lib/pulse/pulse_schema.json,tools/pulse_schema_gen.py 内嵌成 Dart 常量,守卫比对两者一致。

4.4 id 字段各自的格式(复审 2:1–64 位字符集挡不住伪装)

字段 格式(正则) 来源
op / pop 脉络号 ^[0-9a-f]{32}$ 客户端 Random.secure() 16 字节
inst 进程实例号 ^[0-9a-f]{16}$ 同上 8 字节
report_id / session_id UUIDv7 ^[0-9a-f]{8}-[0-9a-f]{4}-7[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$ 客户端生成
room 房号 UUID(任意版本) 后端发的房号
track(联合类型,三审 2) ^(local:(mic|cam)|TR_[A-Za-z0-9]{12,16})$ —— 本地轨是枚举(CallEngine.kLocalAudio = local:mickLocalCamera = local:cam),远端轨是 LiveKit sid(音频 / 视频都是 TR_ 前缀) 本地:代码常量;远端:LiveKit
participant ^PA_[A-Za-z0-9]{12,16}$ LiveKit
schema JSON 里每个 id 字段带 format 名,两端生成器按名取正则;新加一种 id = 先在 schema 加 format,否则编译不过。

哪些事件带 track、哪些不带(按 call_engine*.dartdiagReport 的调用点逐个定,不是「可选」): | 带 track(必填,联合类型)| 不带 track(schema 里没有这个键;带了算未知键)| |---|---| | local_publish(local:mictrack_restore(local:mic / local:camflow_stall / flow_blind / remote_first_packets / resub(本地或 TR_)| audio_session · ringback · callkit_action · reconnected · disconnected · connect_fail · callee_timeout · glare_merge · vm_send_fail · tick_stuck · call_summary · rating | 今天 call_diag 那一路无轨事件传的是 track_key: '';迁进账本时改成不带键,后端 call_diag 老表照旧存 ''(表不改),schema 校验只对新口生效。 验收:把 call_engine*.dart 里每一处 diagReport(...) 的真实调用(开麦成功 / 开麦失败四种 kind / 修复 / 补订 found·pending·ok·gone / 评分 / 会话等待五种 reason / 无轨那一组)做成样本文件,两端 schema 各跑一遍全绿;守卫:样本数 = 调用点数(少一处就红);坏刀:把 local:mic 改成 local:microphone → 红。

5. L2 诊断会话的边界

问题 答案
怎么开 设置 → 诊断 → 「开始一次诊断」:勾模块(通话 / 消息 / 网络 / 资源 / 启动),选时长 15 min / 1 h / 6 h / 24 h(上限 24 h),屏幕上写明会记录什么(§4.1 的白话版);已有一份未提交、未删的会话时不许开新的(先提交或删除,§3.4)
采样 每模块 ≤ 1 条 / s;资源模块沿用 10 s;通话模块沿用看门狗 4 s
容量 ≤ 4 MB;满了停记并标 sys.session_full,不覆盖
到期 自动关,回 L1;通知一条「诊断结束,看看 / 提交 / 删除」
跨重启 会话记录(id / 模块 / 到期时刻)落盘;重启后未到期就继续,到期就按到期处理
退出账号 / 换账号 立刻结束并删掉未提交的数据(数据属于那个账号,不跟到下一个人)
低电 / 过热 电量 < 20% 或 thermal ≥ serious → 降回 L1 采样率,记 sys.degraded{why}
随时停 同一页一颗「停止」,停了照样能看 / 提交 / 删

6. 提交与服务器

6.1 用户看得见的提交范围

提交前一页摘要:时间段 · 每个模块几条 · 涉及几条脉络 · 报告大小 · 一行「不含聊天内容、通话声音、密码或完整网址」,下面可展开原始事件列表(就是要发出去的那份,不做「展示一份、发另一份」)。

6.2 上传:两边的状态机(Gode 首审 4 / 复审 1 / 三审 1 / 定向复审 v1.4)

客户端一份报告的状态:draft →(用户按提交)frozenuploadingstored | retry_wait | rejected | deleted冻结:rename<id>.frozen.jsonl,同时对这个文件的未压缩准确字节算一次 sha256 = req_sha,写进会话索引;之后重试 / 重启都读同一个文件、同一个值,不重算。上传时从冻结件流式 gzip 进请求体(磁盘不留压缩文件);gzip 参数不参与任何哈希,所以「两次压缩字节是否相同」不需要成立。

服务端一行 pulse_reports(id, user_id, state, req_sha, stored_sha, r2_key, r2_cleared_at, cleanup_attempts, dropped, deleted_at …),state ∈ {receiving, stored, rejected, expired, deleted};不存在的 id = absentr2_key 永远等于派生键 pulse/<uid>/<id>.json.gz,建行时写死。

请求限制:JWT;请求体(压缩后)≤ 8 MB 按 Content-Length / MaxBytesReader 限;解压后 ≤ 32 MB、≤ 50 000 条、单条 ≤ 2 KB 用限长解压流边解边数,超限当场 413;每人每小时 ≤ 5 份、每天 ≤ 20 份 → 429。

POST /v1/pulse/reports(头:X-Hoop-Report-IdX-Hoop-Req-Sha256 = 冻结件未压缩字节的 sha256):

两个提交边界(定向复审 1): - 事务 A(短,先提交):INSERT INTO pulse_reports (id, user_id, state='receiving', req_sha, r2_key=派生键) ON CONFLICT (id) DO NOTHINGCOMMIT。这一刻起,无论后面哪一步死掉,行和它该负责的对象键都已经在库里。 - 事务 B(裁决 + 落地):SELECT … FOR UPDATE WHERE id=$1,持锁到本请求结束(POST 完成、DELETE、第二个 POST、清理任务全在这把锁上串行);拿到锁后先核 —— 归属 user_id 不同 → 404;state: | 行状态 | 同 req_sha | 不同 req_sha | |---|---|---| | receiving | 继续本次流水线(第一次或续做)| 409 | | stored | 200 + 原结果(stored_shadropped)| 409 | | rejected | 422 + 原因(不重跑;要改就换新 id)| 409 | | expired / deleted | 410;若对象还在(r2_cleared_at 为空)→ 本次顺手按派生键删并核 | 410 | 然后:① 限长解压流 + 流式校验 / 规范化(§4.3),同时对解压后的字节自算 sha256 与头比对,不符 → 400、行留 receiving(不 put);校验不合格 → 行改 rejected、COMMIT、422;② 算 stored_sha,R2 put 到派生键(规范化后的);put 失败 → 回滚 B(行仍是 A 提交的 receiving)、503;③ 插 pulse_events、行改 stored(stored_sha / dropped)→ COMMIT B;④ 200 {state:"stored", stored_sha, dropped}。 - put 之后、B 提交之前进程死:B 回滚,行仍是 A 提交的 receiving + 派生键,对象在 R2;客户端重试同 req_sha → 拿锁、续做、put 幂等覆盖、③ 补上 → stored;不重试 → 24 h 后清理任务按 §6.2.1 处理。

DELETE /v1/pulse/reports/{id}(一律等锁,只此一种;v1.3 验收 B 里「持锁时改 deleted」那句作废): | 行状态 | 结果 | |---|---| | absent | 事务 A 同款短事务:建墓碑行 deleted(user_iddeleted_at、派生键、r2_cleared_at 为空)→ COMMIT → 204;之后的 POST 撞墓碑 → 410 | | receiving / stored / rejected / expired | FOR UPDATE 等锁;拿到后:删 pulse_events、行改 deleted → COMMIT;按派生键删 R2 并核(§6.2.1 判据),成功才写 r2_cleared_at → 204;R2 删失败 → 行仍 deletedr2_cleared_at 空,交给清理任务,仍回 204(用户视角已删,责任在我们)| | deleted | 204(幂等)|

6.2.1 清理任务(每夜;定向复审 1:清成功前不抹责任记录)

  • 对象 = 派生键;「清干净」的判据 = R2 删除接口成功响应(2xx / 204)并且随后 HEAD 回 404;两者都成立才写 r2_cleared_at
  • 逐行 SELECT … FOR UPDATE SKIP LOCKED:正在被 POST 续做或 DELETE 处理的行被跳过,永远不会拿着过期快照去删正在恢复的对象。
  • receivingupdated_at 早于 24 h:先删对象并核;清干净后行改 expired(行不删,它是这个 id 曾经存在过、对象已清的记录),之后同 id POST → 410;删不掉 → 行留 receiving,cleanup_attempts+1,明夜再来,不封顶
  • deletedr2_cleared_at 为空:删对象并核;成功写 r2_cleared_at;失败 cleanup_attempts+1 明夜再来。
  • 行的最终清除(墓碑 / expired 到期 30 天):只清 r2_cleared_at 非空的;对象没清完的行永远留着。
  • 清理任务只碰 receiving / deleted / expired 三种状态,stored 行连锁都不拿。

归属与超时(Gode 09-16 05:49 定向复审通过时要的):user_id 不只在 POST 核 —— DELETE、GET、下载全部在锁内或 WHERE user_id = $uid 里核,不同人一律 404;事务 B 的时限见验收 K,诊断服务慢不许耗尽共享连接池(单独的 pgxpool 上限 4 条连接,满了直接 503,不排队)。

实现口径(②b-1 已交):请求 = 头 X-Hoop-Report-Id(UUIDv7)· X-Hoop-Req-Sha256(冻结件未压缩字节)· X-Hoop-Report-Meta(≤ 4 KB JSON:tier / app_version / build / os / device_model / session_id / modules / segments / started_at / ended_at),体 = gzip(冻结件,一行一条事件的 JSONL)。服务端对解压后的字节算 sha 与头比对;规范化 = 每条事件按 schema 重新序列化(键序固定、未知键在闸那儿已拒),写成 gzip 存 R2;stored_sha 是那份的哈希。同一份报告里 (inst, seq) 重复 → 逐条 ON CONFLICT DO NOTHING(自动通道和手动报告带同一条的去重键)。连接用量用容量 4 的信号量封顶,满了直接 503 不排队。哈希不符 → 400,行留 receiving(不 put)。

客户端对响应:200 stored → 完成;503 / 超时 / 断网 → retry_wait(1 / 4 / 16 分钟,最多 3 次,再失败标「未发送」留手动;重试永远用同一个冻结文件和同一个 req_sha);400 哈希不符 → rejected + sys.upload_hash_mismatch(冻结件被改了才会发生);409 → rejected(客户端 bug,记 sys.upload_conflict);410 → deleted;413 / 422 / 429 → rejected 并在提交页说原因(429 允许手动重发)。

验收(每条配坏刀): - A. DELETE 先于首次 POST:DELETE → 204、墓碑行;POST 同 id → 410;R2 无对象、pulse_events 无行。 - B. put 后强制终止(定向复审 1 故障测试一):在 ② 之后杀进程 → 库里查得到 A 提交的 receiving 行、r2_key = 派生键;R2 对象在;重试同 sha → stored;不重试 → 24 h 后清理把对象清掉、行改 expired。 - C. 崩在事务 B 提交前重试不同 sha → 409,对象仍是第一次那份。 - D. 双 POST 并发(同 id 同 sha):只有一次事件入库、一个对象、两个 200 同一 stored_sha。 - E. DELETE 与 POST 抢锁:POST 先拿 → DELETE 等到 stored 提交后再删事件、改 deleted、删对象;DELETE 先拿 → POST 拿锁见 deleted,此时尚未 put,直接 410;若派生键下有以前遗留的对象,归 §6.2.1 清理责任,不由这次 POST 处理。只有这一种规则。 - J. 跨账号:另一个账号对同 id 的 DELETE / GET / 下载 → 404,行与对象不动;DELETEabsent 建墓碑用 INSERT … ON CONFLICT DO NOTHING 一次原子处理,与并发的首次 POST 只能有一个赢,输的一方拿锁后按行状态走。 - K. 慢请求 / R2 卡住:事务 B 整体有请求总时限(context 30 s)、SET LOCAL lock_timeout = '5s'、R2 put/delete 各 10 s 超时;任一超时 → 取消 context、回滚 B、释放行锁和连接池连接,回 503;测试:R2 假卡 60 s 时,10 s 内锁释放、连接归还、行仍是 A 提交的 receiving。 - F. 墓碑后删对象失败:行 deletedr2_cleared_at 空;模拟 R2 删除报错 → 清理任务每夜重试直到删除响应成功且 HEAD 404;期间同 id POST 一律 410;墓碑 30 天到期不清这一行。 - G. req_sha 与解压字节不符 → 400、行留 receiving、R2 无 put。 - H. 清理与恢复并行(定向复审 1 故障测试二):一份 receiving 超 24 h 的报告正被客户端续做(POST 持锁)时清理任务跑 → SKIP LOCKED 跳过它;POST 提交 stored 后清理任务再跑 → 不碰 stored 行,对象仍在。 - I. 冻结件的 req_sha 跨重试、跨重启逐字节不变(重新计算 = 同值);gzip 参数改变不影响。

报告头:report_id · app_version · build · os · device_model · tier · session_id(L2)· modules · segments:[{inst, inst_started_at, clock_skew_ms, clock_skew_rtt_ms}] · started_at / ended_at;req_sha 只在请求头。

6.3 存哪(复审 2:只存规范化后的)

  • R2 只存服务端校验、规范化后的报告(未知键已去、不合格事件已丢并计数);客户端发来的原始 gzip 在流水线结束后丢弃,不落盘。两个哈希分开:req_sha = 客户端发的原文(只用于幂等),stored_sha = 规范化后存的那份(用于事后核对 R2 对象没被改)。
  • pulse_reports(§6.2 那些列 + 计数 + trace_ids text[])· 表 pulse_events(每条事件一行,op 建索引)· R2 pulse/<uid>/<report_id>.json.gz
  • 保留:pulse_reports / pulse_events / R2 90 天;墓碑 30 天;call_diag 补 30 天清理(现在没有)。

6.4 谁能看

  • 用户:自己的报告(设置 → 诊断 → 历史),能删。
  • 管理员:ADMIN_UIDS(和 Admin dashboard 同一把尺子,不新造角色)看全部;Admin → Data 新页「脉诊」:① 通话质量日报(从 call_diag 聚合:按版本 / 机型的 stall / reconnect / mic_failed / rating 分布)—— 就是 Jeff 备忘录里「电话沟通质量收集 status」那行;② 报告列表 + 单份报告的脉络时间线视图(按 §3.3 的排序规则画:段落分开、跨端带误差带、只按 op/pop 连线)。
  • 后台展示的是「可复现线索 + 候选原因」,不把相关性写成根因;文案固定用「相邻」不用「导致」。

6.5 自动通道 vs 手动报告:各发什么(Gode 首审 3)

自动通道(call) 手动报告(L1 / L2)
触发 通话中按批、通话结束 用户在提交页按「提交」
事件 只有 ch:"call" 的 §4.1 事件(CallDiag.* 那一组) 账本里全部 channel(含 call,服务器按 (inst, seq) 去重)+ L2 会话文件
上限 每通 ≤ 30 条、每批 ≤ 50、一通 ≤ 60 KB §6.2
room + app_version + inst §6.2 完整报告头
关掉后 待发批次丢弃 未提交文件留给用户,不自动发
MetricKit 不发 只发白名单摘要(§6.6),原始 payload 收到当场解析后丢弃,不落盘

6.6 MetricKit:不存原始载荷

MXMetricPayload / MXDiagnosticPayload 到达时(系统按日交付)当场转成固定的一组 sys.* 事件:启动耗时直方图的分位数、卡顿比例、前后台 CPU 秒、峰值内存、崩溃 / 卡死 / 磁盘写超额的计数和信号类型 —— 全是 §4.1 的整数 / 枚举,进 L1 账本、按账号隔离、算进 1 MB;原始 payload 不落盘、不上传。崩溃调用栈不进(它可能带路径 / 符号;真要看用 TestFlight 自带的崩溃报告)。

7. 编译期裁剪:正式包里实际没有的(Gode 首审 6 后半:一个标记不够,要正反对照 + 能力不可达)

能力 归属 裁法
帧时间线记录(SchedulerBinding.addTimingsCallback 全量) L3 const bool kPulseDeep = bool.fromEnvironment('HOOP_PULSE_DEEP') 包住,AOT tree-shake
内存 / 线程采样(Dart 侧) L3 只用 SDK 自带的 dart:developer,不引入任何新 pub 依赖;代码在 kPulseDeep 常量下,AOT 树摇
原生线程 CPU 采样 / 音频单元统计(Swift) L3 Xcode 只在 Debug / TestFlight-Diag 两个 configuration 定义 -DHOOP_PULSE_DEEP,源码 #if HOOP_PULSE_DEEP
LiveKit verbose 日志 L3 同上
请求体大小统计 L3 同上(大小,不是内容)
插件加载器 L2/L3 正式包只留「能读诊断配方 JSON」;不留任何动态代码加载(AOT 本来也做不到;苹果那关也不许)

证明裁掉了,四件一起,缺一不算: 1. 构建配置:发布流水线打印 --dart-define 清单和 Xcode configuration 名,守卫脚本断言正式包那次没有 HOOP_PULSE_DEEP=true、configuration 是 Release。 2. 直接依赖清单差异(三审 4:vm_service 今天就在 pubspec.lock:2330 里,是 Flutter 工具链的传递依赖,不为守卫绿灯去删它):L3 不给 pubspec.yaml直接依赖加任何包。守卫 = 把 pubspec.yaml 的直接依赖名单(dependencies: 段)和开工前那份快照(随第 ② 步提交,app/test/pulsekit_deps_baseline.txt)逐项比对,新增即红;pubspec.lock 不作判据。L3 用到的 Dart 侧接口只来自 SDK 自带的 dart:developer;L3 真能采到什么,另出原型证据(第 ④ 步前先交一份用测试包在真机上采样 60 s 的原始输出)。 3. 正反对照:同一份源码出两个产物 —— 测试包里 strings 找得到 pulse_deep_markernm 找得到 HoopPulseDeep* 符号;正式包两者都为 0。只有反例为 0 不算,正例必须同时为正(不然可能是标记名写错)。 4. 能力不可达:正式包里 UI 没有「深度」入口(golden 断言),而且直接调 method channel hoop/pulse_deepMissingPluginException(集成测试断言);测试包同样的调用必须回数据。

正式包保留的:L1 账本 · 脉络号 · MetricKit 摘要接收器 · L2 会话 · 上传口 · 配方 JSON 读取。

8. 开销预算与可重复的量法(Nova 那三行数字;Gode 首审 6:三档同一把尺子)

8.1 预算(超了就不配默认开)

CPU(前台均值) 内存常驻 磁盘 网络 后台
L1 < 0.5% < 2 MB ≤ 1 MB 0,通话自动通道(每通 ≤ 60 KB) 零定时器、零唤醒;只在进后台 / 通话结束 / 会话到期各 flush 一次
L2 < 3% < 8 MB ≤ 4 MB 同 L1 同上
L3 不进正式包,不设预算但要量(测试包里的数字只作参考,不作验收)

8.2 一把尺子量四种组合

组合:关(L0 包)· 基础(默认包,L1)· 基础 + 诊断会话(默认包,开 L2 全模块)· 深度(测试包,L1+L2+L3)。NOVA 出三个 TF:HOOP_PULSE=off · 默认 · TestFlight-Diag(含 L3),build 号各不同(Nova 出包计划要把第三个也排进去)。

每项指标四种组合用同一种外部量法、同一起终点;PulseKit 自己记的内部时间只作旁证,不作验收: | 指标 | 外部尺子(四组合相同)| 起点 → 终点 | |---|---|---| | 冷启动 | 240 fps 录屏抽帧(手机夹具固定,同一根手指同一位置)| 手指离开图标 → 首页第一帧可点(列表出现)| | 服务端首包(改名,复审 5)| LiveKit 服务器日志:对端 mediaTrack published → SFU 转发给本机那条 sub 流的 rtpStats.firstTime | 对端轨发布 → SFU 送出第一个 RTP(这是服务端指标,不是本机收到)| | 可听首声 | 第二台手机 240 fps 录像 + 录音,对着本机扬声器;对端固定播一段 1 kHz 单音;帧上「已接通」计时器出现 = 起点,录音里 1 kHz 第一次出现 = 终点 | 本机显示接通 → 本机扬声器真的响 | | 耗电 | 满电起,30 分钟脚本通话(对端固定播一段音),UIDevice.batteryLevel 前后差 | 通话开始 → 结束 | | 卡顿 | Xcode Instruments Core Animation(> 32 ms 帧数),2 分钟固定手势滚聊天列表(用 XCUITest 脚本滚,不用手)| 脚本起 → 脚本止 | | CPU / 内存 | Instruments Time Profiler + Allocations,同一脚本场景,取 60 s 均值 / 峰值 | 同上 | | 后台唤醒 | Instruments Energy Log + os_signpost 计 PulseKit 的 flush 次数;放后台 30 分钟 | 进后台 → 30 分钟 | | 包体 | 三个 IPA 大小 | — |

每个数:同一台真机(Jeff 的 16 Pro Max)、同一天、每组合 3 次,取中位数;三次极差 > 20% 的那格标「不稳」再补 3 次。Jeff 装机、Nova 记数、我出录屏抽帧和 LiveKit 日志的脚本。结果表进 §8.3,不进 CHANGELOG

8.3 实测结果

(待填。填之前 L1 的默认值都算「待定」,不算「已证明不拖慢」。)

9. 等 Jeff 拍板的

  1. 通话脉络继续自动上报(§1.1 例外)—— 是 / 否。
  2. L1 默认开、只记不传(Nova 提议)—— 是 / 否。
  3. 名字 HOOP PulseKit(脉诊) —— 用 / 换。
  4. 报告只有 ADMIN_UIDS 看 —— 是 / 要加别的角色。
  5. 保留期 90 天(报告)/ 30 天(call_diag)—— 可以 / 改。

10. 分四步交,每步一笔

交什么 验收
① 图纸 这份 + §2 盘点 Gode 逐条审 §11
②a 本地(已交 v2.271;v1.6 修补 v2.272:Gode 五条 + 交付出入,§11.7) schema JSON + 生成器 · PulseSchema.check 类型闸 · Pulse 账本(L1/L2 限额、写盘 isolate 背压、按账号目录、滚动、storage_full、进后台 flush)· call_diag 经适配器迁入 · 账号钩子(load / saveSession / clear)· 设置页总开关 守卫 4 份 20 条绿;坏刀 6 把各红(看门狗不入账 / 不过类型闸 / 环无上限 / 不背压 / 退出不删目录 / 不拆 ended_by);分析器零新增
②b-1 后端(已交,已上生产) 迁移 000410(pulse_reports / pulse_eventsbackend/internal/domain/pulse:schema go:embed 同一份 JSON(守卫逐字节比对 App 那份)· POST /v1/pulse/reports 按 §6.2 两个提交边界(事务 A 短提交 → 事务 B SET LOCAL lock_timeout + FOR UPDATE → 限长解压 + 校验 + 规范化 + 自算 sha → R2 put 带超时 → COPY 事件 + stored 同提交)· DELETE 墓碑 ON CONFLICT 原子 · GET 自己 / 管理员 · GET /v1/admin/pulse/trace/{op} · 清理任务 SKIP LOCKED · 信号量 4 条连接 · Redis 限流 5/h 20/d 验收 A–K 在真 Postgres(testdb 内嵌)上 14 条绿;坏刀 7 把各红
②b-2 App 冻结 + req_sha + 流式 gzip 上传 + 提交页(摘要 + 原始列表)+ 历史 / 删除 + 老 call_diag 自动通道并入唯一上传口 + 脉络号(通话两条 + app.start)+ MetricKit 摘要 验收 I + 客户端状态机;NOVA 两个包 → §8 三行数字
③ L2 会话(§5)+ 五条新脉络 + Admin → Data「脉诊」页 跨重启 / 退出账号两条守卫;真机一次限时会话提交成功
④ L3 + 配方 测试包深度模块 + 诊断配方 JSON + 裁剪守卫 release 产物无符号 / 无标记

11. 审核对照表

11.1 Gode 09-16 05:16 十条 → 节

Gode 的点
基础只记不传是否涵盖并迁移旧通道、防旁路 §1.1、§3.4(唯一写手 / 唯一上传口)
敏感信息靠字段白名单 §4
本地容量与保留上限 §3.4、§5
用户可见的提交范围 §6.1
限时会话跨重启 / 退出账号 §5、§1.2
上传鉴权 / 去重 / 限流 §6.2
管理员访问权限 §6.4
采集失败不阻塞通话 §3.4
三档可重复的开销测量与预算 §8
正式包实际裁掉哪些深度能力 §7

11.3 Gode 09-16 05:33 复审五组 → v1.2 改在哪

复审 问题 v1.2
1 receiving 没有恢复出口;墓碑 §6.2 两边状态机 + POST/DELETE 按状态的响应表 + 四步流水线各自失败结果 + 事件入库与 stored 同事务 + R2 后核墓碑 + 迟到对象清理 + 并发验收 A/B/C
2 R2 存原始 gzip;id 字符集挡不住伪装 §4.3 / §6.3 只存规范化报告,req_sha / stored_sha 分开;未知键或丢弃 > 5% 整份 422;§4.4 每个 id 字段各自格式与来源
3 「以上全部为开」误清手动提交;退出账号被总开关短路 §1.2 四样东西 × 五种事件的表;manual 队列只受退出账号影响;退出账号清理独立跑;发送与回调绑定 (uid, gen)
4 L1 内存超预算;两份会话无处可收;5 秒承诺 §3.4 L1 / L2 分开限额(条数且字节,含对象开销);同一时刻只许一份未提交会话;冻结 rename 不复制;总量 1+4+1;没空间的失败结果;持久化只到追加写返回,不承诺秒数
5 首包不是接收端;flavor 不改依赖图 §8.2 / §7 改名「服务端首包」四组合统一 + 新增「可听首声」外部录音量法;L3 零新 pub 依赖,证明改为 grep pubspec

11.8 Gode 09-16 06:26 复审 c7bbc7b3e 四边界 → ②a 修补二(随代码同笔,v2.273)

Gode 缺陷 修法 守卫(pulse_ledger_test)
1 P1 退出屏障被第二次切换绕过(relogin 不等旧目录删完就恢复账号) _switchAccount 排进单一串行链;删完目录才发布 Gode 复审 1
2 P2 512 条合法大事件 > 队列字节,整环当一批永远 queue_full;丢弃行挤爆环 按剩余容量切批;丢弃计数独立聚合、合成行进批次不进环 Gode 复审 2
3 P2 到期段落只改名成归档,读回仍有 到期删除不归档;readBack 按保留期过滤 [Gode 复审 3] + 改写的 [Gode 5]
4 P2 后台慢盘退避把钟又起回来 _foreground 真实状态;所有起钟入口过同一道门 [Gode 复审 4]
坏刀 5 把各红(切换不串行 / 整环当一批 / 到期改名不删 / 读回不过滤 / 后台照起钟)。「旧脚本变红」不再作修好证据,验收全是正向断言。

11.7 Gode 09-16 06:12 审 60d713ed0 五缺陷 + 交付出入 → ②a 修补(随代码同笔,v2.272)

Gode 缺陷 修法 守卫(pulse_ledger_test)
1 P1 类型闸漏 envelope,op / pop 原样落盘 PulseSchema.check 加 op / pop 脉络号格式核 [Gode 1]
2 P1 账号代次只挡回调不挡写盘,退出后旧事件落回目录 唯一串行写手 + 任务绑代次 + 退出屏障(等在途、删目录、再重建) Gode 2
3 P2 背压计数在 await 之后,40 并发进 40 限额在第一个 await 前同步预留;串行写手任何时刻只写一批 [Gode 3]
4 P2 关了仍计数、flush 仍写 record 不记不计;flush / drain 都有开关门;关时清未开始批次 [Gode 4]
5 P2 文件上限不算本批、归档不算、7 天看 mtime 按「当前 + 本批」裁决;单文件 = 总量一半;按首条 ts 到期 [Gode 5] ×2
交付出入 AuthStore.load 在 runApp 前就起了 timer / schema setAccount 在 boot 前只存身份;schema 懒加载;boot 取最新身份 [交付出入] ×2
Gode 的 artifacts/pulsekit-audit-2026-09-16/60d713ed0/review_test.dart 五条复现移植后全部不再成立(5/5 红 = 缺陷消失);坏刀 7 把各红。

11.6 Gode 09-16 05:49 定向复审通过时的三点 → v1.5(随 ②a 代码同笔)

v1.5
DELETE / 读 / 下载验 user_id;墓碑原子插入;跨账号测试 §6.2「归属与超时」段 + 验收 J
事务 B 请求总时限 / 锁等待上限 / R2 超时与取消释放;慢请求故障测试 §6.2「归属与超时」段 + 验收 K
验收 E 措辞 §6.2 验收 E 已改

11.5 Gode 09-16 05:45 定向复审两组 → v1.4 改在哪(只动 §6.2)

定向复审 问题 v1.4
1 对象键要先提交;DELETE 两种规则打架;清理会抹责任记录 §6.2 事务 A 短提交 receiving + 派生键;事务 B 锁已提交行、核归属状态后才 put,事件与 stored 同提交;DELETE 一律等锁(B 旧句作废,只留 E);§6.2.1 清理 FOR UPDATE SKIP LOCKED,只碰 receiving / deleted / expired,清干净判据 = 删除接口成功 + HEAD 404,清成功前不抹行、墓碑到期只清已清完的;故障测试 B(put 后强杀)/ H(清理与恢复并行)
2 req_sha 与流式 gzip 不衔接 §6.2 req_sha = 冻结件未压缩准确字节的 sha256,冻结时算一次存索引、不重算;服务端限长解压时对解压字节自算比对、不符 400 在 put 前;压缩大小按请求体长度另限;验收 I
验收措辞 §3.4 / §6.2 「全随机 4 MB」改为「符合 schema 但压缩率低」;R2 清理判据改为删除接口成功响应 + HEAD 不存在

11.4 Gode 09-16 05:40 三审四处 → v1.3 改在哪

三审 问题 v1.3
1 事务再读行没锁;孤儿对象;req_sha 谁算 §6.2 第 0 步服务端自算字节哈希不符 400;第 1 步 INSERT … ON CONFLICT + SELECT … FOR UPDATE 串行裁决,双 POST 只提交一次;派生键在 put 前写进行,夜间按派生键补删到 R2 回 404 为止、失败不封顶;验收 D / E / F / G
2 §4.4 会拒掉 local:mic / local:cam §4.4 track 改成联合类型正则;逐事件列「带 / 不带 track」;现有全部 diagReport 调用做样本过两端 schema,样本数 = 调用点数
3 「一份未提交」定义;冻结件与新账本;压缩触顶 §3.4 未提交 = draft/frozen/uploading/retry_wait/rejected 直到 stored 或丢弃;L1 冻结件与账本都计入;上传流式 gzip 进请求体不留文件,总量 1+1+4 按实际字节预留;验收慢网 / 不可压缩
4 pubspec.lock 已含 vm_service §7 改为直接依赖名单与基线快照比对;lock 不作判据;L3 采样能力另出原型证据

11.2 Gode 09-16 05:24 首审六条 → v1.1 改在哪

首审 打架的地方 v1.1
1 脱敏正则打掉编号 §3.2 / §4.3 §4.3:按类型各自校验,合法 id 原样过;正则只留给「复制报告」;三条验收 + 坏刀
2 mono 跨进程 / 跨端不能排 §3.1 / §5 / §6.4 §3.1 加 inst / seq 作用域 / pop;§3.3 四条排序规则、时钟偏差与误差带、邻接 ≠ 因果;§6.4 视图照此画
3 自动上传 vs 网络 0;状态优先级;MetricKit 原始载荷 §1.1 / §6 / §8 §1.1 改口;§1.2 六级优先级;§6.5 两路各发什么;§6.6 只存摘要;§8.1 网络预算改写
4 只限压缩后大小;唯一索引 ≠ 幂等 §6.2 §6.2:解压后 / 条数 / 单条上限 + 限长解压流;冻结快照 + sha 幂等回原结果;receiving/stored 两态与夜间清理;删除并发 410
5 2048 条 ≠ 内存上限;后台;丢弃计数 §3.4 §3.4:单条 2 KB、环 4 MB、isolate 队列 64 批 / 1 MB 背压、总容量 6 MB、三个 flush 时刻、保什么丢什么、聚合计数
6 三档尺子不同;L3 无预算不出包;裁剪证明不够 §7 / §8 §8.2 四组合一把外部尺子 + CPU / 内存 / 后台唤醒量法 + 第三个 TF;§7 四件证明(配置 / 依赖图 / 正反对照 / 能力不可达)

12. 行业地基(Gode 14:55 给的,照收)

iOS MetricKit(MXMetricManager,按日交付)· Android vitals(以后)· OpenTelemetry 的 trace / 脱敏思路(脉络号就是它的 trace id,不引它的 SDK)· Flutter 性能判断只认真机 + profile 模式。