feat(memory): retention_tier 档位列 + defer 与档位合并为同一判定(M37)

主人 2026-10-26 对「记忆分型」裁定「两列」:kind(关于什么)保留,新增
retention_tier(留多久 / 干什么用)。本片把这条轴从列、判定、写入、读侧过滤
一路打通,并把「判第五态 defer」与「判 retention_tier=temp」合并成一个判定。

结构(迁移 0026 + 自举前滚):
- memory_items 新增 retention_tier TEXT NOT NULL DEFAULT ''(空串 = 尚未分型,
  历史行与受限来源行),索引 idx_memory_items_retention_tier;up/down 均实跑过
  (在隔离的生产库副本上 migrate → 建 Store → down → 再 up,5073 行零丢失)。
- NewStore 幂等前滚该列 + 建索引(ensureRetentionSchema),与迁移同形由测试钉住。

判定(retention.go,全仓唯一入口):
- RetentionTier 闭集 temp/daily/work/long/knowledge + 空串未分型;与 kind 正交。
- resolveRetention 的优先级:调用方显式档位 > 显式有效期(⇒ temp)> 受限来源
  不分型 > 留存规则 N1~N7 ⇒ temp > 默认 long。判据靠元数据,规则不许推翻显式档位。
- tempDeadline 是「档位 → expires_at」的唯一换算:temp ⇔ 有效期非空,两列同一笔
  INSERT 落下(M2 登记过的「INSERT 与补写之间的短窗口」就此关闭)。

合并(defer ⇔ temp):
- judgeIngest 不再跑留存规则,只读 Add 落定的 item.RetentionTier;规则调用点因此
  全仓唯一(源码级断言钉住)。
- 判定不再依赖同步闸是否开启:闸未接/关着时档位与有效期照样落库。
- 异步层对已是 temp 的行不重复改写(避免 TTL 随审查延迟漂移)。
- IngestRuleVersion d3.3 → d3.4(判据作用范围变了,按既有硬规矩升版)。

读侧:
- 四条列表读路径的 SELECT 与 scanItem 读出 retention_tier(14 列);Get 同样读回。
- 新增 ListByPersonaTiers / ListScopeTiers / ListTiers / ListProjectTiers(可选档位
  过滤,缺省不过滤 ⇒ 历史行照常返回;显式传空串只取未分型;非法档位报错)。
  原方法签名逐字不变 —— 网关 MemoryStore 接口钉着它们,可变参数会编译不过。
- store.go 的读路径拆到 store_list.go,守 500 行上限。

回归:go build ./... + go vet ./... + go test ./... 全绿(内存包另有 -race)。
This commit is contained in:
AskaEth committed 2026-10-26 13:09:30 +08:00
1 parent 240b59708d
commit cc6904b1b4
23 files changed
+1592 -181

No files matched your search

+33 -14
View File
@@ -32,7 +32,7 @@ const ingestStatusMerged = "merged"
// (design/23 §19),**不会复活**。姊妹注释在 tidy_store.go 的 listTidyPartitionItemsSQL。
const listReviewCandidatesSQL = `SELECT id, persona_id, role_id, project_id, scope, kind,
content, tags, confidence, source_conversation, created_at, updated_at,
decay_weight FROM memory_items
decay_weight, retention_tier FROM memory_items
WHERE persona_id = ? AND scope = ? AND project_id = ? AND status = ? AND id <> ?
ORDER BY created_at DESC, rowid DESC LIMIT ?`
@@ -46,18 +46,22 @@ const updateSurvivorSQL = `UPDATE memory_items
SET merged_from = ?, merge_count = ?, confidence = ?, updated_at = ? WHERE id = ?`
const revertMergedSQL = `UPDATE memory_items
SET status = ?, merged_into = '', updated_at = ? WHERE id = ? AND status = ?`
SET status = ?, merged_into = "", updated_at = ? WHERE id = ? AND status = ?`
const selectMergedIntoSQL = `SELECT merged_into FROM memory_items
WHERE id = ? AND status = ?`
const selectItemStatusSQL = `SELECT status FROM memory_items WHERE id = ?`
// setIngestExpirySQL 是第五态(design/23 §18.1)的落点:**只写 expires_at 一列**。
// 不碰 status / merged_* / content,因此它天然是「软动作」——回滚 = 把该列清空
// (§18.4);行一行不删(临时层的物理删除是 design/23 §19 的另一条有审计的
// 清扫路径,不在这里)。
const setIngestExpirySQL = `UPDATE memory_items SET expires_at = ? WHERE id = ?`
// setIngestRetentionSQL 是「留多久」轴在**异步审查层**的落点:把一条已落库记忆
// 改成临时层(档位 + 有效期**同一条语句**写,两者因此不可能分叉),或按回滚口径
// 改回长期层(档位 long + 有效期清空)。
//
// 不碰 status / merged_* / content,因此它天然是「软动作」——回滚 = 把两列改回去
// (design/23 §18.4);行一行不删(临时层的物理删除是 design/23 §19 的另一条
// 有审计的清扫路径,不在这里)。
const setIngestRetentionSQL = `UPDATE memory_items
SET retention_tier = ?, expires_at = ? WHERE id = ?`
const insertIngestReviewSQL = `INSERT INTO memory_ingest_reviews
(id, item_id, persona_id, scope, project_id, decision, reason, veto, target_id,
@@ -146,22 +150,37 @@ func (s *Store) listReviewCandidates(ctx context.Context, p tidyPartition, exclu
return scanItems(rows, "review candidate")
}
// setIngestExpiry 把一条记忆的 `expires_at` 写成 expiryText(**定宽 30 字节 UTC**),
// 空白串表示「不设过期」。它是第五态 defer 的**唯一**写口,两处调用:
// setIngestRetention 把一条记忆改成临时层(`retention_tier='temp'` +
// `expires_at=expiryText(...)`)或改回长期层(`long` + 空串)。
//
// - 同步入库闸(preinsert_gate.go 的 finishIngest):INSERT 之后补这一列;
// - 异步审查层(preinsert.go 的 applyVerdict):T1 判 defer 时改这一列。
// 它是**档位与有效期同一条语句**的落点:两列因此不可能分叉(「temp 却没有有效期」
// 或「有有效期却标着长期」都写不出来)。三类调用:
//
// - 异步审查层(preinsert.go 的 applyVerdict):T1 在同一次调用里判 defer 时,
// 把一条已经落库的长期记忆改判成临时层;
// - 回滚(design/23 §18.4):把两列改回 `long` / `""` —— 清空有效期即升回长期;
// - 测试夹具需要「已经过期」的行时也走它(显式时刻由 expiryText 编码)。
//
// 同步入库路径**不调它**:`Store.Add` 已经把两列同一笔 INSERT 写下(见 retention.go
// 的 tempDeadline),所以那条路径上没有第二笔 UPDATE、也没有中间窗口。
//
// 🔴 **编码必须与读侧同源**:读侧时效闸是纯文本比较(timegate.go),因此这里
// 只许走 expiryText —— 写成别的布局(RFC3339 带 `+08:00`、秒精度、空格分隔)
// 会让闸**静默**判错(早八小时的列值可能被判成「大于当下」,B 仓历史事故,
// timegate_test.go 钉着那条反证)。
func (s *Store) setIngestExpiry(ctx context.Context, id, expiresAt string) error {
func (s *Store) setIngestRetention(ctx context.Context, id string, tier RetentionTier, expiresAt string) error {
if strings.TrimSpace(id) == "" {
return ErrNotFound
}
if _, err := s.db.ExecContext(ctx, setIngestExpirySQL, expiresAt, id); err != nil {
return fmt.Errorf("memory: set ingest expiry: %w", err)
if !tier.valid() {
return fmt.Errorf("%w: %q", ErrInvalidRetentionTier, tier)
}
if !tier.assigned() && expiresAt != "" {
// 未分型 + 非空有效期是自相矛盾的落点(会造出「没有档位却会到期」的行)。
return fmt.Errorf("%w: unset tier with expires_at", ErrRetentionConflict)
}
if _, err := s.db.ExecContext(ctx, setIngestRetentionSQL, string(tier), expiresAt, id); err != nil {
return fmt.Errorf("memory: set ingest retention: %w", err)
}
return nil
}