diff --git a/internal/runtime/memory/rules.go b/internal/runtime/memory/rules.go index 47ccb11..3669cbb 100644 --- a/internal/runtime/memory/rules.go +++ b/internal/runtime/memory/rules.go @@ -73,6 +73,11 @@ func (s *Store) AddScoped(ctx context.Context, actorID string, item MemoryItem) // 作用域上也始终声明身份边界。是否允许受限上下文(群聊 / 陌生人,design §5) // 接收这些内容仍由更高层策略决定;本方法只施加 // 基础读规则。 +// +// 与 ListByPersona/List 同口径:只返回 status='active' 且过**时效闸** +// (expires_at / valid_to 均未到期)的行 —— 闸只有这里与 ListByPersona / +// ListProject / List 四处,检索路径经 Retriever.visibleCandidates 组合调用 +// 本方法,因此自动获得同一判据(timegate.go 头注、design/03 §11.2)。 func (s *Store) ListScope(ctx context.Context, scope Scope, personaID string) ([]MemoryItem, error) { if strings.TrimSpace(personaID) == "" { return nil, ErrMissingPersona @@ -80,10 +85,12 @@ func (s *Store) ListScope(ctx context.Context, scope Scope, personaID string) ([ if !scope.valid() { return nil, ErrInvalidScope } + now := timeGateNow() rows, err := s.db.QueryContext(ctx, `SELECT id, persona_id, role_id, project_id, scope, kind, content, tags, confidence, source_conversation, created_at, updated_at, - decay_weight FROM memory_items WHERE scope = ? AND status = ? - ORDER BY created_at DESC, rowid DESC`, string(scope), string(tidyStatusActive)) + decay_weight FROM memory_items WHERE scope = ? AND status = ?`+timeGateClause+` + ORDER BY created_at DESC, rowid DESC`, + string(scope), string(tidyStatusActive), now, now) if err != nil { return nil, fmt.Errorf("memory: list scope items: %w", err) } @@ -96,15 +103,17 @@ func (s *Store) ListScope(ctx context.Context, scope Scope, personaID string) ([ // 层级(design §3);这是深检索入口 —— 调用方把它与 // projectindex 存储结合,沿 项目索引 → 项目描述 → 章节内容 遍历。项目 // 记忆所有角色可读,因此不施加角色过滤。projectID 为空 -// 返回 ErrMissingProjectID。 +// 返回 ErrMissingProjectID。与其余三条列表读路径同口径:只返回在册且过 +// **时效闸**的行(timegate.go、design/03 §9.6/§11.2)。 func (s *Store) ListProject(ctx context.Context, projectID string) ([]MemoryItem, error) { if strings.TrimSpace(projectID) == "" { return nil, ErrMissingProjectID } + now := timeGateNow() rows, err := s.db.QueryContext(ctx, `SELECT id, persona_id, role_id, project_id, scope, kind, content, tags, confidence, source_conversation, created_at, updated_at, - decay_weight FROM memory_items WHERE project_id = ? AND status = ? - ORDER BY created_at DESC, rowid DESC`, projectID, string(tidyStatusActive)) + decay_weight FROM memory_items WHERE project_id = ? AND status = ?`+timeGateClause+` + ORDER BY created_at DESC, rowid DESC`, projectID, string(tidyStatusActive), now, now) if err != nil { return nil, fmt.Errorf("memory: list project items: %w", err) } diff --git a/internal/runtime/memory/store.go b/internal/runtime/memory/store.go index b58ef68..2a646d3 100644 --- a/internal/runtime/memory/store.go +++ b/internal/runtime/memory/store.go @@ -108,6 +108,16 @@ type MemoryItem struct { CreatedAt time.Time UpdatedAt time.Time DecayWeight float64 + + // ExpiresAt 是**临时记忆**的有效期末(design/03 §9.6、design/23 §18.1)。 + // 零值 = 不设过期,落库为空串,四条列表读路径恒可见;非零值落库为定宽 UTC + // 文本,到期后该行即从读路径消失,直到后台清扫物理删除(design/23 §19)。 + // + // ⚠️ **本字段目前只写不读**:Add 会把它写进 expires_at 列,但读路径一律不 + // SELECT 该列(闸是纯 SQL,见 timegate.go),故 Get/List* 回来的条目 + // ExpiresAt 恒为零值。要读回时效需同步 13 列 SELECT 与 scanItem,并连带 + // tidy/preinsert 的内部召回 SQL —— 那是改扫描口径,不在本切片授权范围。 + ExpiresAt time.Time } // 区分 memory 包各类失败的哨兵错误。每个都在 @@ -231,6 +241,11 @@ func NewStore(ctx context.Context, db *sql.DB, log *slog.Logger) (*Store, error) // 关系表里,语义增强的失败绝不使记忆写入失败,检索侧也会回退到 // 内存余弦(见 Retriever.semanticScores)。被闸判为重复的行**不建索引**: // 它不进召回、也不该占掉语义检索 top-K 的一个位置。 +// +// `item.ExpiresAt`(临时记忆的时效,design/03 §9.6)同在这一处落库为 +// `expires_at` 列:Add 是全仓唯一 INSERT 路径(design/23 §15.1 D3-1), +// 因此时效列的真生产者**只能**在这里 —— 读侧时效闸(timegate.go)比对的 +// 就是这一列,若写入侧不写出它,闸就退化成恒真的空壳。 func (s *Store) Add(ctx context.Context, item MemoryItem) (MemoryItem, error) { if err := item.normalize(); err != nil { return MemoryItem{}, err @@ -247,12 +262,13 @@ func (s *Store) Add(ctx context.Context, item MemoryItem) (MemoryItem, error) { plan := s.planIngest(ctx, item) _, err = s.db.ExecContext(ctx, `INSERT INTO memory_items (id, persona_id, role_id, project_id, scope, kind, content, tags, confidence, - source_conversation, created_at, updated_at, decay_weight, status, merged_into) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + source_conversation, created_at, updated_at, decay_weight, status, merged_into, + expires_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, item.ID, item.PersonaID, item.RoleID, item.ProjectID, string(item.Scope), string(item.Kind), item.Content, string(tagsJSON), item.Confidence, item.SourceConversation, formatTime(item.CreatedAt), formatTime(item.UpdatedAt), item.DecayWeight, - plan.insertStatus(), plan.absorbedID()) + plan.insertStatus(), plan.absorbedID(), expiryText(item.ExpiresAt)) if err != nil { return MemoryItem{}, fmt.Errorf("memory: insert item: %w", err) } @@ -276,6 +292,11 @@ func (s *Store) Add(ctx context.Context, item MemoryItem) (MemoryItem, error) { // Get 返回给定 id 的条目。id 为空或 // 不存在时返回 ErrNotFound。 +// +// 🔴 **刻意不过任何读侧闸**(status 与时效都不过;design/03 §11.2 第 3 条): +// 撤销软合并必须先读得到被吸收的行,人工核查 / 清算也要先读得到已过期与 +// 已失效的行 —— 加闸等于把审计与撤销的抓手一并抹掉。要「面向用户的可见集」 +// 请走 ListByPersona / ListScope / ListProject / List 或 Retriever(那些都过闸)。 func (s *Store) Get(ctx context.Context, id string) (MemoryItem, error) { if strings.TrimSpace(id) == "" { return MemoryItem{}, ErrNotFound @@ -304,14 +325,21 @@ func (s *Store) Get(ctx context.Context, id string) (MemoryItem, error) { // 只返回**在册**(status='active')条目:整理与入库前审查都只做软动作, // 被合并/被归档的行内容原样留在表里,因此「能看到什么记忆」的每条读路径 // 都必须过 status 闸,否则清理过的行照样会被检索到、照样占满面板。 +// +// 同时过**时效闸**(timeGateClause):已过期(expires_at ≤ now)与已失效 +// (valid_to ≤ now)的行一并不再返回 —— 判据细节与「为什么必须下沉到这里」 +// 见 timegate.go 与 design/03 §9.6/§11.2。 func (s *Store) ListByPersona(ctx context.Context, personaID string) ([]MemoryItem, error) { if strings.TrimSpace(personaID) == "" { return nil, ErrMissingPersona } + // 闸的左右操作数同源:两边都由 formatTime 产出定宽 UTC 文本。 + now := timeGateNow() rows, err := s.db.QueryContext(ctx, `SELECT id, persona_id, role_id, project_id, scope, kind, content, tags, confidence, source_conversation, created_at, updated_at, - decay_weight FROM memory_items WHERE persona_id = ? AND status = ? - ORDER BY created_at DESC, rowid DESC`, personaID, string(tidyStatusActive)) + decay_weight FROM memory_items WHERE persona_id = ? AND status = ?`+timeGateClause+` + ORDER BY created_at DESC, rowid DESC`, + personaID, string(tidyStatusActive), now, now) if err != nil { return nil, fmt.Errorf("memory: list persona items: %w", err) } @@ -329,11 +357,15 @@ func (s *Store) ListByPersona(ctx context.Context, personaID string) ([]MemoryIt // 管理面板的记忆列表里(design/23 §8/§10 的软动作口径)。要看它们走 // 审查留痕 `GET /admin/memories/reviews` 或按 id 用 Get —— 后者刻意 // 不过 status 闸,因为撤销软合并必须先读得到被吸收的行。 +// +// 同样过**时效闸**:临时记忆到期(expires_at ≤ now)与事实失效 +// (valid_to ≤ now)的行一并从这里消失(timegate.go、design/03 §9.6)。 func (s *Store) List(ctx context.Context) ([]MemoryItem, error) { + now := timeGateNow() rows, err := s.db.QueryContext(ctx, `SELECT id, persona_id, role_id, project_id, scope, kind, content, tags, confidence, source_conversation, created_at, updated_at, - decay_weight FROM memory_items WHERE status = ? - ORDER BY created_at DESC, rowid DESC`, string(tidyStatusActive)) + decay_weight FROM memory_items WHERE status = ?`+timeGateClause+` + ORDER BY created_at DESC, rowid DESC`, string(tidyStatusActive), now, now) if err != nil { return nil, fmt.Errorf("memory: list items: %w", err) } diff --git a/internal/runtime/memory/tidy_store.go b/internal/runtime/memory/tidy_store.go index ea95c26..8c0f532 100644 --- a/internal/runtime/memory/tidy_store.go +++ b/internal/runtime/memory/tidy_store.go @@ -41,6 +41,13 @@ const tidyStatusActive = "active" // 最新在前;rowid 打破 created_at 并列,使同一刻写入的条目顺序稳定 // (与 ListByPersona 同一口径)。分区过滤写在 SQL 里,而不是读全表再在内存 // 里筛 —— design/23 §6 要求在分区切分层就隔断。 +// +// 🔴 **刻意不过时效闸**(design/03 §11.2「主会话已裁定:保持不过时效闸」, +// 同一取舍的先例见 design/23 §10.1 ①):整理比对的是**正文**,与「这条对外 +// 可不可见」无关;已过期的临时记忆随后由后台清扫物理删除(design/23 §19), +// 绝不会被整理「复活」。可见性判据只有一处 —— 四条列表读路径共用 timegate.go +// 的 timeGateClause。**不要再给这条 SQL 加时效闸。** 本文件另两条分区口径 +// 查询(listTidyPartitionsSQL / countTidyPartitionItemsSQL)是同一裁定的实例。 const listTidyPartitionItemsSQL = `SELECT id, persona_id, role_id, project_id, scope, kind, content, tags, confidence, source_conversation, created_at, updated_at, decay_weight FROM memory_items diff --git a/internal/runtime/memory/timegate.go b/internal/runtime/memory/timegate.go new file mode 100644 index 0000000..47c2a99 --- /dev/null +++ b/internal/runtime/memory/timegate.go @@ -0,0 +1,69 @@ +package memory + +import "time" + +// 本文件是**读侧时效闸**的唯一实现(design/03 §9.6 / §11.2),也是「临时记忆到期 +// 即从读面消失」链路里的第 ① 步(隐藏):判据就是列值本身,零写入、即时生效。 +// 第 ② 步(后台物理删除)与第 ③ 步(删除前审计)是另一条显式、有界、可停的 +// 后台路径,**绝不混进读路径**(design/23 §19.1)。 +// +// 🔴 为什么必须下沉到共享底层、不许写在调用点(design/03 §11.2 死角分析): +// 记忆读入口有 8 个(对话注入 owner / 群聊陌生人、`knowledge` 工具、capacity L4 +// 习惯召回、子代理以 B 身份、admin 面板列表 / 搜索 / RAG),但它们**只走同一批 +// 底层** —— Store.ListByPersona / ListScope / ListProject / List(面板与直接调用方) +// 以及 Retriever.visibleCandidates(它组合调用前两条,因此所有复用检索器的入口 +// 自动获得同一判据)。闸一旦写在某个调用点上,立刻出现「对话看不见、`knowledge` +// 工具看得见」的死角。**因此判据只有这一处,四条列表读路径共用。** +// +// 🔴 同时登记**两处刻意不过闸**(design/03 §11.2 第 3 条;与 design/23 §10.1 ① +// 已立的先例同构): +// +// 1. 按 id 的 Store.Get(store.go)—— 撤销软合并 / 查看已软删行必须先读得到, +// 加闸等于把撤销与审计的抓手抹掉。过期行同理:清算与人工核查要读得到。 +// 2. 整理与审查的**内部**召回 SQL —— listTidyPartitionItemsSQL(tidy_store.go) +// 与 listReviewCandidatesSQL(preinsert_store.go)刻意**只过 status 闸**: +// 它们比对的是正文,与「这条对外可不可见」无关;过期行随后由后台清扫删除, +// 不会复活(design/03 §11.2「主会话已裁定:保持不过时效闸」)。两处都在 +// SQL 旁落了原位注释:前者在 tidy_store.go(切片 M1),后者的原位注释由 +// 切片 M2 落在 preinsert_store.go —— 此处登记,免得分头实现时漏掉。 +// +// **新增 / 变更任何记忆读路径时,先问这一句**:它要不要看见已过期(临时层)与 +// 已软删行?答案写进注释(design/03 §11.2)。 + +// timeGateClause 是时效闸的 WHERE 片段,拼在四条列表读路径既有的 status 闸之后、 +// ORDER BY 之前。判据与 design/03 §9.6 逐字一致: +// +// (expires_at = '' OR expires_at > ?) AND (valid_to = '' OR valid_to > ?) +// +// 空串是「从未设过期 / 从未失效」(长期记忆与日常记忆的默认值,见 +// store_schema.go:27-29 的 DEFAULT 空串),因此空串**必须可见**;只有非空才比较。 +// 两个 ? 都绑定 timeGateNow() 的返回值。 +const timeGateClause = ` AND (expires_at = '' OR expires_at > ?) AND (valid_to = '' OR valid_to > ?)` + +// timeGateNow 返回时效闸比较用的「当下」。 +// +// 🔴 口径必须与**写入格式同源**(本仓历史事故,DevDocs/开发习惯与约定.md §7): +// 列里存的是 formatTime 产出的定宽 30 字节 UTC 文本(timefmt.go:17-21),所以右边 +// 的「当下」也必须由同一个 formatTime 产出**同形文本**,两边才落在同一个字母表里、 +// 文本序才等于时间序。反例是拿 SQLite 的 datetime('now')("YYYY-MM-DD HH:MM:SS", +// 空格分隔、无小数秒)去比这些列值:'T'(0x54) 恒大于 ' '(0x20),于是**任何当日行 +// 都「大于当下」**,过期行反而被判为未过期 —— 时间判断静默错,不报错、不 panic。 +// 同理不许改写成 datetime(expires_at) 之类的函数比较:那是另一套口径, +// 一旦写入格式变化就再次静默分叉。timegate_test.go 里有这条反证的实证。 +func timeGateNow() string { + return formatTime(time.Now()) +} + +// expiryText 把 MemoryItem.ExpiresAt 编码为要落库的 expires_at 文本,是 +// 「读侧口径与写入格式同源」在**写入侧**的那一半:零值 = 不设过期 +// (长期 / 日常记忆),写空串(与 DDL 的默认值一致);非零值走 formatTime。 +// +// 它不做任何判据(过去值不在这里被拒、也不被改写):写入侧不决定可见性, +// 一条「出生即过期」的行照常落库,四条列表读路径立刻看不见它,直到后台清扫 +// 把它删掉;Get 仍读得到(见 store.go 的 Get 注释)。 +func expiryText(t time.Time) string { + if t.IsZero() { + return "" + } + return formatTime(t) +} diff --git a/internal/runtime/memory/timegate_test.go b/internal/runtime/memory/timegate_test.go new file mode 100644 index 0000000..08374f4 --- /dev/null +++ b/internal/runtime/memory/timegate_test.go @@ -0,0 +1,357 @@ +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 +}