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。 //