主人裁定「临时记忆到期删除 / 任何调用记忆的地方都要正常调用到」的读侧 第 ① 步(隐藏):design/03 §9.6 / §11.2 的时效闸下沉到共享底层,不写在 任何调用点。 - timegate.go(新):timeGateClause = (expires_at = '' OR expires_at > ?) AND (valid_to = '' OR valid_to > ?);timeGateNow() 与列值同源(同一个 formatTime 的定宽 30 字节 UTC 文本),expiryText() 负责写入侧编码。 - Store.ListByPersona / List / ListScope / ListProject 四条列表读路径 的 WHERE 在既有 status 闸之后拼上时效闸;Retriever.visibleCandidates 组合前两条,检索入口因此自动继承同一判据(无第二处判据)。 - Store.Get 与整理/审查的内部召回 SQL 刻意不过闸,理由写在各自注释里 (listTidyPartitionItemsSQL 原位注释在本切片;listReviewCandidatesSQL 的原位注释按塔裁定改派 M2,timegate.go 头注登记了这条拆分)。 - Store.Add 的 INSERT 补上 expires_at 列(MemoryItem.ExpiresAt):Add 是 全仓唯一 INSERT 路径,时效列的真生产者只能在这里,否则闸退化成空壳。 - timegate_test.go(新):过期/失效行在四条路径与 visibleCandidates 上 都不可见、空值与未来值可见、Get 仍能读过期行、Add 真的落库该列; 外加时间口径同源钉(文本序==时间序 + 跨格式 'T' vs ' ' 陷阱实证)。 验收:go build ./internal/runtime/... 通过;go vet 通过;go test ./internal/runtime/memory/... 全绿;internal 全包 62 个 ok / 0 FAIL; GOOS=darwin GOARCH=arm64 交叉构建通过。
358 lines
14 KiB
Go
358 lines
14 KiB
Go
package memory
|
||
|
||
import (
|
||
"context"
|
||
"sort"
|
||
"strings"
|
||
"testing"
|
||
"time"
|
||
)
|
||
|
||
// 本文件是**读侧时效闸**的回归(design/03 §9.6 / §11.2)。
|
||
//
|
||
// 判据:status='active' ∧ (expires_at = '' ∨ expires_at > now) ∧ (valid_to = '' ∨ valid_to > now),
|
||
// 它必须同时出现在**四条列表读路径**上(Store.ListByPersona / ListScope / ListProject / List),
|
||
// 因为记忆读入口有 8 个而底层只有这四个;Retriever.visibleCandidates 组合调用其中两条,
|
||
// 因此检索入口自动继承同一判据(本条亦有测试)。任何一条漏闸 = 过期记忆照样进上下文、
|
||
// 照样出现在 admin 面板(design §11.2 的死角分析)。
|
||
|
||
// setItemExpiryForTest 直接改一行的时效列,模拟整理 / D3 第五态落下的时效裁定,
|
||
// 或「事实已失效」的 valid_to 标记。走 SQL 而不是走写入路径,是为了让本条只验证
|
||
// **读侧**:写入侧(Add 真的把 ExpiresAt 落成 expires_at 列)由本文件末条测试单独钉。
|
||
func setItemExpiryForTest(t *testing.T, s *Store, id, expiresAt, validTo string) {
|
||
t.Helper()
|
||
if _, err := s.db.ExecContext(context.Background(),
|
||
`UPDATE memory_items SET expires_at = ?, valid_to = ? WHERE id = ?`,
|
||
expiresAt, validTo, id); err != nil {
|
||
t.Fatalf("set expiry (expires_at=%q valid_to=%q) on %s: %v", expiresAt, validTo, id, err)
|
||
}
|
||
}
|
||
|
||
// assertReadSet 断言读路径返回的条目集合**恰好**是 want(按 id 比对),
|
||
// 且 hidden 里的条目一条都没漏进来。按 id 而不是按正文断言:正文可能重复,
|
||
// id 才是行的身份。
|
||
func assertReadSet(t *testing.T, name string, got, want, hidden []MemoryItem) {
|
||
t.Helper()
|
||
gotIDs := idsOfItems(got)
|
||
wantIDs := idsOfItems(want)
|
||
if !sameIDs(gotIDs, wantIDs) {
|
||
t.Fatalf("%s = %v %v, want %v", name, gotIDs, contentsOf(got), wantIDs)
|
||
}
|
||
for _, h := range hidden {
|
||
for _, id := range gotIDs {
|
||
if id == h.ID {
|
||
t.Errorf("%s 返回了已过期 / 已失效行 %q(时效闸失效)", name, h.Content)
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
// TestStore_读路径_过滤已过期行 逐条走四条列表读路径与检索路径,证明「临时记忆到期」
|
||
// (expires_at ≤ now)与「事实已失效」(valid_to ≤ now)两种行在每一处都不可见,
|
||
// 而空 expires_at(长期 / 日常记忆的默认值)与未来的 expires_at / valid_to 照常可见。
|
||
func TestStore_读路径_过滤已过期行(t *testing.T) {
|
||
t.Parallel()
|
||
ctx := context.Background()
|
||
s := newTestStore(t)
|
||
now := time.Now().UTC()
|
||
|
||
// 可见组:长期(expires_at 空)、临时未到期(Add 直接给 ExpiresAt)、未失效(valid_to 未来)。
|
||
longTerm := addItem(t, s, MemoryItem{
|
||
PersonaID: "persona.a", Content: "长期-角色", Scope: ScopePersona, Kind: KindRelationship,
|
||
})
|
||
tempAlive := addItem(t, s, MemoryItem{
|
||
PersonaID: "persona.a", Content: "临时-未到期-角色", Scope: ScopePersona, Kind: KindRelationship,
|
||
ExpiresAt: now.Add(time.Hour),
|
||
})
|
||
tempAliveUser := addItem(t, s, MemoryItem{
|
||
PersonaID: "persona.a", Content: "临时-未到期-用户", Scope: ScopeUser, Kind: KindWorldEvent,
|
||
ExpiresAt: now.Add(time.Hour),
|
||
})
|
||
validToFuture := addItem(t, s, MemoryItem{
|
||
PersonaID: "persona.a", Content: "未失效-项目", Scope: ScopeProject,
|
||
Kind: KindWorldEvent, ProjectID: "proj.1",
|
||
})
|
||
|
||
// 不可见组:临时已到期(写入侧落库的过去值)、valid_to 已过、项目域的临时已到期。
|
||
tempExpired := addItem(t, s, MemoryItem{
|
||
PersonaID: "persona.a", Content: "临时-已过期-角色", Scope: ScopePersona, Kind: KindRelationship,
|
||
ExpiresAt: now.Add(-time.Hour),
|
||
})
|
||
validToPast := addItem(t, s, MemoryItem{
|
||
PersonaID: "persona.a", Content: "已失效-用户", Scope: ScopeUser, Kind: KindWorldEvent,
|
||
})
|
||
tempExpiredProject := addItem(t, s, MemoryItem{
|
||
PersonaID: "persona.a", Content: "临时-已过期-项目", Scope: ScopeProject,
|
||
Kind: KindWorldEvent, ProjectID: "proj.1",
|
||
})
|
||
|
||
setItemExpiryForTest(t, s, validToPast.ID, "", formatTime(now.Add(-time.Hour)))
|
||
setItemExpiryForTest(t, s, tempExpiredProject.ID, formatTime(now.Add(-time.Hour)), "")
|
||
setItemExpiryForTest(t, s, validToFuture.ID, "", formatTime(now.Add(time.Hour)))
|
||
|
||
ownVisible := []MemoryItem{longTerm, tempAlive, tempAliveUser, validToFuture}
|
||
hidden := []MemoryItem{tempExpired, validToPast, tempExpiredProject}
|
||
|
||
for _, tc := range []struct {
|
||
name string
|
||
read func() ([]MemoryItem, error)
|
||
want []MemoryItem
|
||
}{
|
||
{
|
||
name: "ListByPersona",
|
||
read: func() ([]MemoryItem, error) { return s.ListByPersona(ctx, "persona.a") },
|
||
want: ownVisible,
|
||
},
|
||
{
|
||
name: "List",
|
||
read: func() ([]MemoryItem, error) { return s.List(ctx) },
|
||
want: ownVisible,
|
||
},
|
||
{
|
||
name: "ListScope/user",
|
||
read: func() ([]MemoryItem, error) { return s.ListScope(ctx, ScopeUser, "persona.a") },
|
||
want: []MemoryItem{tempAliveUser},
|
||
},
|
||
{
|
||
name: "ListScope/project",
|
||
read: func() ([]MemoryItem, error) { return s.ListScope(ctx, ScopeProject, "persona.a") },
|
||
want: []MemoryItem{validToFuture},
|
||
},
|
||
{
|
||
name: "ListProject",
|
||
read: func() ([]MemoryItem, error) { return s.ListProject(ctx, "proj.1") },
|
||
want: []MemoryItem{validToFuture},
|
||
},
|
||
} {
|
||
t.Run(tc.name, func(t *testing.T) {
|
||
got, err := tc.read()
|
||
if err != nil {
|
||
t.Fatalf("%s: %v", tc.name, err)
|
||
}
|
||
assertReadSet(t, tc.name, got, tc.want, hidden)
|
||
})
|
||
}
|
||
|
||
// 检索路径:visibleCandidates 是候选集本身(Retrieve/Inject 与所有复用 Retriever 的
|
||
// 入口都只从它取候选),闸加在它组合调用的 ListByPersona + ListScope 上,
|
||
// 因此这里必须一条过期行都没有。
|
||
r := NewRetriever(s)
|
||
cands, err := r.visibleCandidates(ctx, "persona.a")
|
||
if err != nil {
|
||
t.Fatalf("visibleCandidates: %v", err)
|
||
}
|
||
assertReadSet(t, "visibleCandidates", cands, ownVisible, hidden)
|
||
|
||
results, err := r.Retrieve(ctx, "persona.a", "", 20)
|
||
if err != nil {
|
||
t.Fatalf("Retrieve: %v", err)
|
||
}
|
||
if len(results) != len(ownVisible) {
|
||
t.Fatalf("Retrieve = %d 条 (%v), want %d", len(results), idsOf(results), len(ownVisible))
|
||
}
|
||
found := make([]MemoryItem, 0, len(results))
|
||
for _, res := range results {
|
||
found = append(found, res.Item)
|
||
}
|
||
assertReadSet(t, "Retrieve", found, ownVisible, hidden)
|
||
|
||
injected, err := r.Inject(ctx, "persona.a", "", 20)
|
||
if err != nil {
|
||
t.Fatalf("Inject: %v", err)
|
||
}
|
||
for _, h := range hidden {
|
||
if strings.Contains(injected, h.Content) {
|
||
t.Errorf("Inject 文本里出现了已过期 / 已失效行 %q(这就是「任何地方都调得到」的反面)", h.Content)
|
||
}
|
||
}
|
||
for _, v := range ownVisible {
|
||
if !strings.Contains(injected, v.Content) {
|
||
t.Errorf("Inject 文本缺少在有效期内的 %q", v.Content)
|
||
}
|
||
}
|
||
}
|
||
|
||
// TestStore_Get_不过时效闸 是反向护栏:按 id 的 Get 刻意**不过** status 闸与时效闸。
|
||
// 撤销软合并要读得到被吸收的行(design/23 §8 的「无损可逆」),清算与人工核查要读得到
|
||
// 已过期 / 已失效的行(design/03 §11.2 第 3 条)。若有人「顺手」给 Get 也加闸,本条会红。
|
||
func TestStore_Get_不过时效闸(t *testing.T) {
|
||
t.Parallel()
|
||
ctx := context.Background()
|
||
s := newTestStore(t)
|
||
now := time.Now().UTC()
|
||
|
||
tempExpired := addItem(t, s, MemoryItem{
|
||
PersonaID: "persona.a", Content: "临时-已过期-角色", Scope: ScopePersona, Kind: KindRelationship,
|
||
ExpiresAt: now.Add(-time.Hour),
|
||
})
|
||
validToPast := addItem(t, s, MemoryItem{
|
||
PersonaID: "persona.a", Content: "已失效-用户", Scope: ScopeUser, Kind: KindWorldEvent,
|
||
})
|
||
setItemExpiryForTest(t, s, validToPast.ID, "", formatTime(now.Add(-time.Hour)))
|
||
|
||
for _, want := range []MemoryItem{tempExpired, validToPast} {
|
||
got, err := s.Get(ctx, want.ID)
|
||
if err != nil {
|
||
t.Errorf("Get(%s) err = %v, want readable(撤销 / 审计的抓手)", want.ID, err)
|
||
continue
|
||
}
|
||
if got.Content != want.Content {
|
||
t.Errorf("Get(%s).Content = %q, want %q", want.ID, got.Content, want.Content)
|
||
}
|
||
}
|
||
}
|
||
|
||
// TestStore_Add_落库expires_at列 钉住写入侧:Add 是全仓唯一 INSERT 路径,
|
||
// 时效列的**真生产者**只能是它 —— 若 Add 不写这一列,读侧时效闸就退化成恒真空壳
|
||
// (expires_at 永远为空 ⇒ 过期行永远可见)。零值必须落空串(= 不设过期),
|
||
// 非零值必须走与读侧闸同一个 formatTime。
|
||
func TestStore_Add_落库expires_at列(t *testing.T) {
|
||
t.Parallel()
|
||
ctx := context.Background()
|
||
s := newTestStore(t)
|
||
|
||
expiry := time.Date(2026, 10, 20, 3, 20, 5, 830978900, time.UTC)
|
||
withExpiry := addItem(t, s, MemoryItem{
|
||
PersonaID: "persona.a", Content: "临时-带时效", Scope: ScopePersona,
|
||
Kind: KindRelationship, ExpiresAt: expiry,
|
||
})
|
||
withoutExpiry := addItem(t, s, MemoryItem{
|
||
PersonaID: "persona.a", Content: "长期-无时效", Scope: ScopePersona, Kind: KindRelationship,
|
||
})
|
||
|
||
for _, tc := range []struct {
|
||
name string
|
||
id string
|
||
want string
|
||
}{
|
||
{"非零 ExpiresAt", withExpiry.ID, formatTime(expiry)},
|
||
{"零值 ExpiresAt", withoutExpiry.ID, ""},
|
||
} {
|
||
var raw string
|
||
if err := s.db.QueryRowContext(ctx,
|
||
`SELECT expires_at FROM memory_items WHERE id = ?`, tc.id).Scan(&raw); err != nil {
|
||
t.Fatalf("%s: read raw expires_at: %v", tc.name, err)
|
||
}
|
||
if raw != tc.want {
|
||
t.Errorf("%s: expires_at = %q, want %q", tc.name, raw, tc.want)
|
||
}
|
||
}
|
||
}
|
||
|
||
// TestTimeGate_比较口径与写入格式同源 是本仓历史事故的回归(DevDocs/开发习惯与约定.md §7):
|
||
// 「SQLite 时间比较用错格式(带 T/Z 的串比 datetime('now'))会把『今天全部』当成『最近一小时』」。
|
||
//
|
||
// 闸的处理方式写死为「两边都由 formatTime 产出同形文本,文本序即时间序」,本条把这件事钉三下:
|
||
// 1. 闸的比较操作数与列值同源(同一个 formatTime,定宽 30 字节 UTC,可被 parseTime 解回);
|
||
// 2. 同源文本的**文本序 == 时间序**(这是整个闸成立的前提);
|
||
// 3. 跨格式比较会**系统性判错**(用 SQLite 字面量实证 'T' > ' ' 的陷阱),
|
||
// 所以任何把闸改成 datetime('now') 之类的第二套口径的改动,都会先在这条上露馅。
|
||
func TestTimeGate_比较口径与写入格式同源(t *testing.T) {
|
||
t.Parallel()
|
||
ctx := context.Background()
|
||
s := newTestStore(t)
|
||
|
||
gateNow := timeGateNow()
|
||
if len(gateNow) != storedTimeLen {
|
||
t.Fatalf("timeGateNow() = %q (%d 字节), want %d 字节(必须与列值同宽)",
|
||
gateNow, len(gateNow), storedTimeLen)
|
||
}
|
||
back, err := parseTime(gateNow)
|
||
if err != nil {
|
||
t.Fatalf("timeGateNow() 不能被 parseTime 解回: %v", err)
|
||
}
|
||
if reformatted := formatTime(back); reformatted != gateNow {
|
||
t.Fatalf("timeGateNow() 与列值格式不同源:再格式化得 %q,不等于 %q", reformatted, gateNow)
|
||
}
|
||
|
||
// 文本序 == 时间序(两个都由 formatTime 产出的值)。
|
||
earlier := time.Date(2026, 10, 13, 11, 0, 0, 0, time.UTC)
|
||
later := earlier.Add(time.Nanosecond)
|
||
if !(formatTime(earlier) < formatTime(later)) {
|
||
t.Fatalf("formatTime(%v) = %q 不早于 formatTime(%v) = %q —— 文本序已不等于时间序",
|
||
earlier, formatTime(earlier), later, formatTime(later))
|
||
}
|
||
|
||
// 同源比较:11:00 的列值小于 12:00 的当下 ⇒ 判为「已过期」。
|
||
var sameSource bool
|
||
if err := s.db.QueryRowContext(ctx,
|
||
`SELECT '2026-10-13T11:00:00.000000000Z' < ?`, formatTime(earlier.Add(time.Hour))).
|
||
Scan(&sameSource); err != nil {
|
||
t.Fatalf("same-source comparison: %v", err)
|
||
}
|
||
if !sameSource {
|
||
t.Fatal("同源文本比较失效:11:00 的列值未被判为早于 12:00 的当下")
|
||
}
|
||
|
||
// 跨格式比较(历史事故的机制):SQLite 的 datetime('now') 形如
|
||
// "YYYY-MM-DD HH:MM:SS",第 11 个字节是空格(0x20),而列值第 11 个字节是 'T'(0x54)。
|
||
// 于是**同一 UTC 日**内,任何列值都「大于当下」—— 早一小时的过期行反而被判为未过期。
|
||
var crossFormat bool
|
||
if err := s.db.QueryRowContext(ctx,
|
||
`SELECT '2026-10-13T11:00:00.000000000Z' > datetime('2026-10-13 12:00:00')`).
|
||
Scan(&crossFormat); err != nil {
|
||
t.Fatalf("cross-format comparison: %v", err)
|
||
}
|
||
if !crossFormat {
|
||
t.Fatalf("陷阱实证失效:跨格式比较不再系统性判错('T' vs ' ')," +
|
||
"请重新核对 timegate.go 的口径与 DevDocs/开发习惯与约定.md §7")
|
||
}
|
||
}
|
||
|
||
// TestTimeGate_闸不接受第二套时间口径 是结构性护栏:闸的 WHERE 片段里只允许出现
|
||
// 参数化比较(?),不得出现 SQLite 的时间函数 —— 一旦出现 datetime(...)/julianday(...)
|
||
// 之类,就有第二套口径,写入格式或 SQLite 行为一变就再次静默分叉(历史事故正是如此)。
|
||
func TestTimeGate_闸不接受第二套时间口径(t *testing.T) {
|
||
t.Parallel()
|
||
for _, fn := range []string{"datetime(", "julianday(", "strftime(", "unixepoch(", "date("} {
|
||
if strings.Contains(timeGateClause, fn) {
|
||
t.Errorf("timeGateClause 含 SQLite 时间函数 %q:闸必须只做「同源定宽文本」的参数化比较(%s)",
|
||
fn, timeGateClause)
|
||
}
|
||
}
|
||
if got := strings.Count(timeGateClause, "?"); got != 2 {
|
||
t.Errorf("timeGateClause 的占位符 = %d, want 2(expires_at 与 valid_to 各一个)", got)
|
||
}
|
||
}
|
||
|
||
// TestExpiryText_零值落空串 钉住写入侧编码:零值 = 不设过期,必须落空串(列默认值),
|
||
// 而不是 formatTime(零值)(那会写成 "0001-01-01T00:00:00.000000000Z",反而把长期记忆
|
||
// 变成「早已过期」—— 长期记忆会整体从读面消失)。
|
||
func TestExpiryText_零值落空串(t *testing.T) {
|
||
t.Parallel()
|
||
if got := expiryText(time.Time{}); got != "" {
|
||
t.Errorf("expiryText(零值) = %q, want 空串(不设过期)", got)
|
||
}
|
||
at := time.Date(2026, 10, 20, 3, 20, 5, 0, time.UTC)
|
||
got := expiryText(at)
|
||
if want := formatTime(at); got != want {
|
||
t.Errorf("expiryText(%v) = %q, want %q", at, got, want)
|
||
}
|
||
if len(got) != storedTimeLen {
|
||
t.Errorf("expiryText(%v) 长度 = %d, want %d", at, len(got), storedTimeLen)
|
||
}
|
||
}
|
||
|
||
// idsOfItems 取条目 id,仅用于失败信息与集合比对。
|
||
func idsOfItems(items []MemoryItem) []string {
|
||
out := make([]string, 0, len(items))
|
||
for _, it := range items {
|
||
out = append(out, it.ID)
|
||
}
|
||
sort.Strings(out)
|
||
return out
|
||
}
|
||
|
||
// sameIDs 报告两个**已排序**的 id 切片是否逐项相等。
|
||
func sameIDs(a, b []string) bool {
|
||
if len(a) != len(b) {
|
||
return false
|
||
}
|
||
for i := range a {
|
||
if a[i] != b[i] {
|
||
return false
|
||
}
|
||
}
|
||
return true
|
||
}
|