观测面 Actor 边界(INV-OBS-08/09/10)

wesgine 观测子系统中的 Actor 边界设计,确保每位用户只看到自己的 Run 记录和工具调用。


核心问题

观测面是 Actor 边界的第三个面。难在分类错误——"观测"听起来是元数据,于是没人把它当数据边界看。

但 wes_tool_calls.input/output 是:

work_dir 是目录结构。这些不是元数据,是数据。


空 actor 是合法值

诊断特征:空 actor 是合法值(跨 actor 管理视图)。

所以漏传参数:

症状与"功能正常"同形。


INV-OBS-08:三层单点收敛

SQL 谓词

internal/observe/actor.go 的 actorClause 单点:

func actorClause(actor string) (string, []any) {
    if actor == "" {
        return "", nil // admin 全 Cell 视图
    }
    return "AND json_extract(tags, '$.actor') = ?", []any{actor}
}

对于无自己 tags 列的表(wes_tool_calls / wes_turn_traces),使用 runOwnedByActor 的 EXISTS 子查询借父 Run 的归属。

请求边界

一律 actorFromRequestOrContext(req):

关键约束


INV-OBS-09:证据链不过滤

/observe/evidence 与 /audit/* 反向裁决:

理由:证据链是逐 Run hash chain,切片后缺环无法校验。"只给我看我自己的审计记录"不是审计。

声明必须排在 defaultCellRequiredScope 的 /observe 泛前缀之前(Go switch 按序匹配)。


INV-OBS-10:事件环也要守

事件先进 per-Run 内存环再进 wes_run_traces,只守 SQL 那一侧等于把最新最全的一份留在门外。

重放流吐原始 engine.Event,Data 带工具输入输出与模型输出。

RunVisible 单点

func RunVisible(owner, actor string) bool {
    if actor == "" { return true }  // admin
    return owner == actor
}

闸门 60-actor-single-judgement.sh 的 run_owner_cmp 规则禁止别处手写 owner == actor。

无主 Run

没有 owner 意味着 actor 在某处没被传下去。

"我不知道这是谁的"的安全读法是隐藏,不是"大家的"。

不属于你与不存在同返空流。


聚合查询注意事项

无 GROUP BY 的聚合在零行上仍返回一行,MAX of nothing 是 NULL。

加了 actor 过滤之后"零匹配行"从边角变成新用户的第一次访问。

-- ✅ 必须 COALESCE
SELECT COALESCE(MAX(created_at), '') FROM wes_run_traces WHERE ...

-- ❌ 首访 500
SELECT MAX(created_at) FROM wes_run_traces WHERE ...

Token 用量查询

GET /cells/{id}/observe/token-usage
    ?group_by=model&tags=actor:alice&from=2026-01-01&to=2026-12-31

支持 GroupBy(model / agent / session / day / tag:actor)+ Tags / Model / Session / From / To 多维过滤。


各产品场景

产品观测面影响
wescodeactor 恒为 "local",无边界问题
wesclaw 桌面N=1,无边界问题
wescraft 团队多 actor,观测面生效
企业版部门多员工,Run/工具调用按员工隔离