From 82e9213804359d2fe387a42e935e17e44cdb6142 Mon Sep 17 00:00:00 2001 From: AskaEth Date: Mon, 26 Oct 2026 19:29:16 +0800 Subject: [PATCH] =?UTF-8?q?test(memory):=20M45=20=E5=A4=8D=E7=9C=8B=20P2?= =?UTF-8?q?=20=E2=80=94=E2=80=94=20=E8=A1=A5=E3=80=8Ctemporal=20=E4=B8=8D?= =?UTF-8?q?=E8=AE=B8=E8=A2=AB=E5=88=A4=E5=AE=9A=E8=AF=BB=E3=80=8D=E7=9A=84?= =?UTF-8?q?=E6=BA=90=E7=A0=81=E7=BA=A7=E6=96=AD=E8=A8=80=20+=20=E6=B3=A8?= =?UTF-8?q?=E9=87=8A=E7=BA=A0=E6=AD=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit P2-1(真缺口):r1 复看的变异 C 证明「在异步审查侧拿 item.Temporal 改判」当时 **整个 memory 包全绿** —— 已落库的行在异步侧被改判这条口径没有守卫,而异步侧 (applyVerdict / setIngestRetention)恰恰最容易被人随手接线。 新增 TestTemporalIsNotWiredIntoJudgement:扫生产代码(非 _test.go、非注释行)里 所有含 `.Temporal` 的行,逐行要求命中 temporalFieldSites 白名单(校验 3 条 / 产出 1 条, **片段级**而不是文件级 —— 按文件放行会让同一文件里新写的接线路过),违规时报 「文件:行号: 表达式片段」;同时校验白名单每一条确实命中过,防止它老化成空头许可证。 变异实测(复看给的写法,插在 preinsert.go 的 applyVerdict 入口): --- FAIL: TestTemporalIsNotWiredIntoJudgement temporal 被接进了判定(裁决 D:它只许被记录,不许被判定读)…: preinsert.go:476: if item.Temporal == TemporalOneOff { 删掉接线后全绿。 P2-2(文案与 D 矛盾):store_temporal.go 的归属清单原写「判定在 resolveRetention (唯一入口,第 4 步)」—— D 之后第 4 步是 N1~N7、没有 temporal 步,那句等于给后来者 指了接线入口。改为:temporal 只被记录、不参与判定;要把 temporal 接进判定必须先拿到 模型真实标签分布,并只能通过 temporalFieldSites 白名单(有意的接线决定)进来。 另按复看提醒:temporal.go 头注里那两条统计 SQL 标明「**运维分析用,不属于读侧代码 路径**」,并写明读侧(timegate 与任何读侧 SQL)明令不许对 created_at/expires_at 用 datetime() —— 那是定宽文本比较,抄进查询层会静默判错。 回归:go build ./... + go vet ./... 全绿;memory/db 包测试全绿;memory -race 38.0s 全绿。 --- internal/runtime/memory/store_temporal.go | 10 ++- internal/runtime/memory/temporal.go | 7 ++ internal/runtime/memory/temporal_test.go | 84 +++++++++++++++++++++++ 3 files changed, 99 insertions(+), 2 deletions(-) diff --git a/internal/runtime/memory/store_temporal.go b/internal/runtime/memory/store_temporal.go index 3ccfcce..32b9633 100644 --- a/internal/runtime/memory/store_temporal.go +++ b/internal/runtime/memory/store_temporal.go @@ -8,12 +8,18 @@ import ( // 本文件是 `memory_items.temporal`(迁移 0027)在**写入路径**上的那一半:哨兵错误、 // 字段校验、以及落库时的列值编码。 // -// 其余三处的归属(别在这条链上另开一份实现): +// 🔴 其余三处的归属(别在这条链上另开一份实现): // - **字段与语义**在 store.go 的 MemoryItem 上(结构体的字段不能跨文件声明), // 闭集与归一化在 temporal.go; -// - **判定**在 retention.go 的 resolveRetention(唯一入口,第 4 步); +// - **判定**仍是 `resolveRetention`(retention.go)里的 N1~N7 —— `temporal` +// **只被记录,不参与判定**(r1 复审裁决 D),因此 `resolveRetention` 里 +// **没有、也不许有**「第 4 步看 temporal」这种东西; // - **DDL** 在 migrations/0027 + store_schema.go + temporal_schema.go。 // +// 要把 `temporal` 接进判定,前置条件写在 temporal.go 头注:先拿到模型真实标签分布 +// (那里给了两条只读 SQL),再改 `temporal_test.go` 的 temporalFieldSites 白名单 —— +// 那张表是**有意的接线决定**的唯一入口,不许绕过它。 +// // 拆出本文件只为守 AGENTS §10 的文件行数上限(store.go 已到 500 行)与「模块按职责 // 拆源文件」那条规范 —— 口径逐字未变。 diff --git a/internal/runtime/memory/temporal.go b/internal/runtime/memory/temporal.go index 3184ed3..a45154d 100644 --- a/internal/runtime/memory/temporal.go +++ b/internal/runtime/memory/temporal.go @@ -34,6 +34,13 @@ import "strings" // SELECT created_at, retention_tier, content FROM memory_items // WHERE temporal = 'one_off' ORDER BY created_at DESC LIMIT 10; // +// 🔴 上面两条是**运维分析用的 SQL,不属于读侧代码路径**,别把它们抄进查询层: +// timegate.go(以及任何读侧 SQL)明令**不许**对 `created_at` / `expires_at` 用 +// `datetime()` 之类的 SQLite 时间函数 —— 读侧闸是**定宽文本比较**,一旦有人按这两条 +// 的写法去写查询,时间格式一变就会静默判错(timegate_test.go 钉着那条反证)。 +// 这里出现 `datetime(created_at)` 只是因为「人工一次性分析、离线跑、不需要精确到 +// 纳秒」——**恰好**是读侧不许用的那个形态。 +// // 看 `one_off` 那 10 条里有多少是长期约定 —— 那才是「这一位能不能用」的判据。 // // 闭集(与迁移 0027 的列值口径逐字一致): diff --git a/internal/runtime/memory/temporal_test.go b/internal/runtime/memory/temporal_test.go index 9d775c4..dc490f7 100644 --- a/internal/runtime/memory/temporal_test.go +++ b/internal/runtime/memory/temporal_test.go @@ -2,6 +2,8 @@ package memory import ( "context" + "fmt" + "os" "strings" "testing" @@ -185,6 +187,88 @@ func readTemporalColumn(t *testing.T, store *Store, id string) TemporalKind { return TemporalKind(raw) } +// temporalFieldSites 是**允许**读取 `.Temporal` 的全部生产代码位置(文件 + 行内片段)。 +// +// 只有四类,缺一不可、多一不许(多出来的那条就是「接线」): +// +// ① 写入侧的字段校验(store_temporal.go 的 validateTemporal,两行); +// ② 写入侧的列值编码(store_temporal.go 的 temporalColumn); +// ③ 产出侧的构造(llmextractor.go 把模型输出归一后放进候选条目)。 +// +// 为什么用「片段」而不是「文件」做粒度:按文件放行会让**同一文件里**新写的接线悄悄 +// 溜过去(例如在 store_temporal.go 里加一句 `if item.Temporal == …`)。按片段放行则 +// 任何新表达式都必须先改这张表 —— 而改这张表就是一次自觉的接线决定。 +func temporalFieldSites() []struct{ file, snippet string } { + return []struct{ file, snippet string }{ + {"store_temporal.go", "it.Temporal.valid()"}, + {"store_temporal.go", "ErrInvalidTemporal, it.Temporal"}, + {"store_temporal.go", "string(it.Temporal)"}, + {"llmextractor.go", "normalizeTemporal(c.Temporal)"}, + } +} + +// TestTemporalIsNotWiredIntoJudgement 是**源码级断言**:`temporal` 只许被记录, +// **不许被判定读**。它回答的是「零影响」能不能被后来人无声破坏 —— +// retention_test.go 的同类断言只钉住 `judgeExtractionRetention` 的调用点,管不到 +// 「在异步审查侧拿 `item.Temporal` 改判」这种接线方式。 +// +// 复现的缺口(r1 复审变异 C 实测):在 `preinsert.go` 的 `applyVerdict` 入口插一句 +// `if item.Temporal == TemporalOneOff { …setIngestRetention(temp)… }`,**整个 memory +// 包全绿** —— 那时没有任何用例察觉。异步侧恰恰是最容易被随手接线的地方(它拿到的 +// 是**已落库**的行),因此这条断言必须存在。 +// +// 判据:扫生产代码(非 `_test.go`、非注释行)里所有含 `.Temporal` 的行,逐行要求 +// 命中 temporalFieldSites 的某一条;否则报**文件 + 行号 + 表达式片段**(不只报个数)。 +// 同时校验 allowlist 的每一条**确实命中过**,防止它老化成一张空头许可证。 +func TestTemporalIsNotWiredIntoJudgement(t *testing.T) { + t.Parallel() + files, err := os.ReadDir(".") + if err != nil { + t.Fatalf("read package dir: %v", err) + } + allowed := temporalFieldSites() + hit := make([]bool, len(allowed)) + var violations []string + for _, f := range files { + name := f.Name() + if f.IsDir() || !strings.HasSuffix(name, ".go") || strings.HasSuffix(name, "_test.go") { + continue + } + src, err := os.ReadFile(name) + if err != nil { + t.Fatalf("read %s: %v", name, err) + } + for i, line := range strings.Split(string(src), "\n") { + trimmed := strings.TrimSpace(line) + if strings.HasPrefix(trimmed, "//") || !strings.Contains(trimmed, ".Temporal") { + continue + } + ok := false + for j, site := range allowed { + if name == site.file && strings.Contains(trimmed, site.snippet) { + hit[j] = true + ok = true + break + } + } + if !ok { + violations = append(violations, fmt.Sprintf("%s:%d: %s", name, i+1, trimmed)) + } + } + } + if len(violations) > 0 { + t.Fatalf("temporal 被接进了判定(裁决 D:它只许被记录,不许被判定读)。"+ + "若这是有意的接线,先拿到模型真实标签分布并改 temporal.go 头注、再改本断言的 allowlist:\n%s", + strings.Join(violations, "\n")) + } + for j, site := range allowed { + if !hit[j] { + t.Fatalf("allowlist 项 %s: %q 已不存在 —— 这张表老化了,请同步更新"+ + "(否则它只是一张空头许可证)", site.file, site.snippet) + } + } +} + // TestNormalizeTemporal 钉住归一化口径:**只认四个字面值**,其余(空 / 中文 / 自造 / // 拼写变体)一律 unknown。 //