feat(memory): 读侧时效闸(四条列表读路径)+ Add 落 expires_at 列

主人裁定「临时记忆到期删除 / 任何调用记忆的地方都要正常调用到」的读侧
第 ① 步(隐藏):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 交叉构建通过。
This commit is contained in:
AskaEth committed 2026-10-13 19:54:19 +08:00
1 parent ae68bb0241
commit 9a3b3cff8b
5 files changed
+486 -12

No files matched your search

+14 -5
View File
@@ -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)
}
+39 -7
View File
@@ -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)
}
+7
View File
@@ -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
+69
View File
@@ -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)
}
+357
View File
@@ -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
}