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:
1 parent
ae68bb0241
commit
9a3b3cff8b
5 files changed
+486
-12
No files matched your search
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
Reference in new issue
Block a user