记忆 Actor 边界(INV-MEM-38~47)

wesgine 记忆系统中 Actor 边界的收敛设计——确保多用户 Cell 中每位员工只看到自己的记忆。


核心问题

Cell 边界落在文件路径上,不需要任何代码记得检查。Actor 边界当时落在 Metadata["actor"] 一个字符串键上,于是它必须被收敛——否则每条读/写/删路径各自把关,漏一处就泄漏。


两个执行点

判据收敛到两个执行点:

读:memory.ActorVisible

func ActorVisible(entry MemoryEntry, actor string) bool {
    // 平台分区(ScopeSystem/ScopeGlobal 无主)→ 共享 → true
    // 私有域(ScopeAgent)→ 必须 entry.Actor == actor
}

写:RequireActor

在 saveCore 内部——覆盖构造上的每一条写入路径。

func (s *storeImpl) saveCore(entry MemoryEntry) error {
    // RequireActor:私有域 entry 必须有 actor
    if isPrivateScope(entry.Scope) && entry.Actor == "" {
        return ErrActorRequired
    }
    // ...
}

具体收敛规则

INV-MEM-38:写入单点

RequireActor 只在 saveCore 一处。它曾只被两个进程内调用方显式调用,三条 HTTP 写入路径全在外面。

INV-MEM-39:读取预筛 → 判定

actor 列化后 SQL 从"预算"升格为"判定"——不再是"先拿一页回来再 Go 里筛"。

INV-MEM-40:删除即可见

删除侧判据与读侧相同:ActorVisible。

DeleteAs(id, actor) 单点判 ActorVisible,不属于你回 403(不存在幂等成功)。

INV-MEM-41:无前缀约定

actor 是独立列,namespace 只表示话题/角色。不再用 u:<actor> 前缀编码归属。

INV-MEM-42:Digest 按认知主体分区

Digest 的跨条目认知判断按 (Namespace, CognitiveOwner) 分区,取全量用 DrainList。

INV-MEM-43:行身份带 actor 段

UNIQUE(scope, namespace, actor, key_)

两个员工的同名 key 不再是同一行。

INV-MEM-44:定时 flush 带 actor

去重键含 actor、丢弃非本 actor 的行、落读侧真正查询的命名空间。

INV-MEM-45:HTTP 非 GET 走 admin

/memory 非 GET 一律 cell:admin。

INV-MEM-46:scope 白名单

HTTP 写入端点的 scope 过白名单,system 需 admin。

INV-MEM-47:Session 态行不写死 ID

留空 ID 让 store 铸 UUID;命名空间只用 SessionNS(sessionID)。


读与删的答法差异

操作不是你的不存在
GetAs(id, actor)404(与不存在合并)404
DeleteAs(id, actor)403nil(幂等成功)

理由:记忆 id 会随导出与分享链接流出去,一次读不配得到"它确实存在"这个确认。删除点名要删一行,配得上一句拒绝。


无主条目的处理

按 scope 分流:

分区无主含义处理
平台(ScopeSystem/ScopeGlobal)正常(L1/L2 共享资产)允许
私有域(ScopeAgent)异常(缺主)写入拒绝

与 Session 边界的关系

Session 是同一条 Actor 边界的第二个面。

四个面共用闸门 60-actor-single-judgement.sh。


各产品场景

产品Actor 边界影响
wescodeactor 恒为 "local",单人无边界问题
wesclaw 桌面N=1,单人无边界问题
wesclaw SaaS多用户,Actor 边界生效
wescraft 团队多 actor 共享 Cell,四个面都生效
企业版唯一真正暴露面:部门多员工共享 Cell