Files
Cyrene_v2/internal/runtime/memory/rules.go
T
AskaEth 9a3b3cff8b 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 交叉构建通过。
2026-10-13 19:54:19 +08:00

160 lines
6.6 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package memory
import (
"context"
"database/sql"
"errors"
"fmt"
"strings"
)
// 作用域访问控制哨兵错误。当角色试图执行 design §3 规则
// 不允许的写入时返回。
var (
// ErrReadOnly 在角色试图写入或删除
// 不属于它的角色作用域记忆条目时返回。按 design §3,其他角色
// 对角色日常记忆只读。
ErrReadOnly = errors.New("memory: read-only: persona does not own this item")
// ErrMissingProjectID 在项目作用域查询的 id 为空时返回。
ErrMissingProjectID = errors.New("memory: project_id is required")
)
// canWrite 报告 actorID 按 design §3 作用域规则是否可以写入 item:
//
// - ScopeUser:任何角色都可写(用户习惯全局共享)。
// - ScopePersona:只有归属角色可写(其他角色只读)。
// - ScopeProject:任何角色都可写(项目记忆人人可写)。
//
// 调用方必须已校验 item.Scope;非法作用域对任何人
// 都不可写。
func canWrite(actorID string, it MemoryItem) bool {
switch it.Scope {
case ScopeUser, ScopeProject:
return true
case ScopePersona:
return it.PersonaID == actorID
default:
return false
}
}
// AddScoped 以 actorID 的权限持久化 item,按 persona_id + scope 施加
// design §3 写规则。actorID 为空返回 ErrMissingPersona。
// item.PersonaID 为空时用 actorID 填充它,因此角色总是
// 记录自身的来源;归属其他角色的角色作用域条目
// 返回 ErrReadOnly。用户与项目作用域的条目任何角色都可写。
func (s *Store) AddScoped(ctx context.Context, actorID string, item MemoryItem) (MemoryItem, error) {
if strings.TrimSpace(actorID) == "" {
return MemoryItem{}, ErrMissingPersona
}
if item.PersonaID == "" {
item.PersonaID = actorID
}
if !item.Scope.valid() {
return MemoryItem{}, ErrInvalidScope
}
if !canWrite(actorID, item) {
return MemoryItem{}, ErrReadOnly
}
return s.Add(ctx, item)
}
// ListScope 返回 personaID 可见的该作用域全部条目,最新的在前,
// 施加 design §3 读规则(rowid 打破 created_at 并列,使
// 同一时钟刻内写入的条目保持稳定的最新优先顺序):
//
// - ScopeUser:全部用户作用域条目(跨角色全局共享)。
// - ScopePersona:全部角色作用域条目。其他角色只读但
// 仍可读,因此角色在这里可以读到另一个角色的角色日常记忆。
// - ScopeProject:全部项目作用域条目(人人可读)。
//
// personaID 指明读取方角色,且必填,以便调用方即使在共享
// 作用域上也始终声明身份边界。是否允许受限上下文(群聊 / 陌生人,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
}
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 = ?`+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)
}
return scanItems(rows, "scope item")
}
// ListProject 返回链接到 projectID 的全部记忆条目,最新的在前,
// rowid 打破 created_at 并列,使同一刻的写入保持稳定顺序。项目
// 作用域记忆携带一个 ProjectID,链到 projectindex
// 层级(design §3);这是深检索入口 —— 调用方把它与
// projectindex 存储结合,沿 项目索引 → 项目描述 → 章节内容 遍历。项目
// 记忆所有角色可读,因此不施加角色过滤。projectID 为空
// 返回 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 = ?`+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)
}
return scanItems(rows, "project item")
}
// DeleteScoped 删除给定 id 的条目,但仅当按 design §3 规则 actorID
// 可以写它。这在删除上施加角色只读边界:
// 角色可以删自己的角色作用域条目,以及任何用户或
// 项目作用域条目,但不能删另一个角色的角色日常记忆。id 为空返回
// ErrNotFound;改动他人的角色作用域条目返回 ErrReadOnly。
func (s *Store) DeleteScoped(ctx context.Context, actorID, id string) error {
if strings.TrimSpace(actorID) == "" {
return ErrMissingPersona
}
item, err := s.Get(ctx, id)
if err != nil {
return fmt.Errorf("memory: delete scoped load: %w", err)
}
if !canWrite(actorID, item) {
return ErrReadOnly
}
return s.Delete(ctx, id)
}
// ensureProjectIDColumn 在 memory_items 表创建于 M4 项目索引
// (design §3)存在之前时把它前滚:缺失时补加 project_id 列,
// 已存在时忽略重复列错误。它是
// SQLite 特有的(记忆存储基于 SQLite)且幂等,因此 NewStore
// 可在全新 schema 或既有 schema 上调用。
func ensureProjectIDColumn(ctx context.Context, db *sql.DB) error {
_, err := db.ExecContext(ctx, `ALTER TABLE memory_items
ADD COLUMN project_id TEXT NOT NULL DEFAULT ''`)
if err == nil {
return nil
}
// 当该列已存在(例如由 NewStore 中的 CREATE TABLE 创建,
// 或由上一次运行创建)时,SQLite 报 "duplicate column name: project_id"。
if strings.Contains(err.Error(), "duplicate column name") {
return nil
}
return fmt.Errorf("memory: roll forward project_id column: %w", err)
}