主人裁定「临时记忆到期删除 / 任何调用记忆的地方都要正常调用到」的读侧 第 ① 步(隐藏):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 交叉构建通过。
70 lines
4.5 KiB
Go
70 lines
4.5 KiB
Go
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)
|
||
}
|