fix(归档): 两表判据分叉的成因收口 —— TouchSession 不再写 status,建邮件一律拒归档会话

pi 2026-09-28 裁定 §1/§2 认可「判据分叉」这个定性,§4 要求做 1+2,
并把 permission/request 点为第三个复活入口。本轮做 1+2,并补上第四个。

# 缺陷:归档后不可见,判据挂在两张表上

  unreadFor / readStateFor   判邮件行   (repo.go:unreadFor)
  ListInbox / UnreadWorkspaces 判会话行   (repo.go:ListInbox)

两边对同一条已归档线索给出不同答案,而每一边单独看都「是对的」。
分叉由 `TouchSession` 的 `SET status='active'` 与建邮件 INSERT 只写
邮件行共同造成 ⇒ 只要有一个写路径碰会话行而不碰邮件行,半活会话就能被造出来。

# 改法:让不变量由构造保证,而不是逐个入口堵

  · TouchSession 只剩 updated_at —— 它是全库唯一能解除归档的入口
  · EnsureSessionOpen 是 CreateMail / CreatePermissionMail / CreateDecisionMail
    的共同前置(集中一处,新增建邮件函数必须经过它)
  · ErrSessionArchived 与 ErrSessionNotFound 分列:调用方要能分开回话
  · resolveTarget 的 reply_to 分支恢复归档契约(此前绕过别名路径的 404)
  · permission/request 补 SessionOpenFor:存在 + 未归档 + 参与方
  · FindSessionByPlatformID 补 s.status(adopt 路径,pi 未列的第四个入口)

# 判据:写成不变量而不是单点

session_status_invariant_test.go:对任意 session_id,
sessions.status='archived' ⟹ 该会话全部邮件 archived。入口级回归单测仍在,
但它们是说明。已实测把 TouchSession 改回旧实现后该判据转红
(不是「改完就绿」的装饰)。

# 读侧清册

mail_status_readers_test.go 的清册仍为 repo.go=13 / thread.go=1 / migrate.go=4:
本轮新增的 5 处命中全在注释里(散文里拼了列名字面量),已改写措辞而不改数字
—— 让数字变化会给未来新增读取凭空送出 5 格余量,正是那张表要防的事。

# 遗留(pi 裁定本轮不做,已登记)

FindSessionByAddress 无 status 条件:补上会把重复归档从 200 变成 404,
属行为变更,不在 bugfix 里夹带。
This commit is contained in:
2026-09-28 11:01:38 +08:00
parent de6fa59cbb
commit e78888b756
5 changed files with 390 additions and 1 deletions

View File

@ -371,12 +371,89 @@ func GetSessionByID(ctx context.Context, id uuid.UUID) (*models.Session, error)
return &s, nil
}
// TouchSession 刷一条会话的 updated_at("这条线索刚刚有新活动")。
//
// ★★ 它**只**更新 updated_at,**绝不碰 status**(2026-09-28 bugfix)。
//
// 原实现是 `SET updated_at = NOW(), status = 'active'`。那一行 `status='active'`
// 让**每一个**调用点都成了"复活入口":6 个调用点里 5 个的 id 都是服务端刚查出来的
// (收件人确实参与过的那条会话),只有 reply_to 与 permission/request 是从请求里拿的
// id。于是"归档后不可见"被这样绕过:
//
// sessions.status: archived → active(只被这一列改)
// mails 那张表: archived(原地不动)⇒ **半活会话**
//
// 半活会话在库里有两处可观察的裂口:
// - `ListInbox` 整体按 `s.status <> 'archived'` 过滤 ⇒ 归档邮件**重新出现在列表里**,
// 只是标着"已归档"(判据读会话表);
// - `unreadFor` 判的是邮件行上的 `<> 'archived'` ⇒ 收件箱 unread 一封都不给
// (判据读邮件表)。同一个"归档"在两张表上给出**两个不同答案**。
//
// 而"不可见"的判据本来就不该由**任何**单点写入来恢复 —— 归档是全局的、
// 单向的(前端只有归档入口,没有恢复入口)。所以正确的形状不是"在 reply_to 里
// 拦一道"(那只是把一个入口堵上),而是让这个函数**没有能力**去改 status。
// 判据见 session_status_invariant_test.go:不变量写在**两个写入口**(CreateMail 系
// 与 ArchiveSession)上,而不是写成"某条路径返回 404"。
func TouchSession(ctx context.Context, id uuid.UUID) error {
_, err := db.DB.ExecContext(ctx,
`UPDATE sessions SET updated_at = NOW(), status = 'active' WHERE session_id = $1`, id)
`UPDATE sessions SET updated_at = NOW() WHERE session_id = $1`, id)
return err
}
// ErrSessionArchived 表示目标会话已归档。
//
// 它与 ErrSessionNotFound **刻意分列**,不是同一件事的两份写法:前者是"这条线索
// 你知道存在、我也知道,只是不再接受邮件",后者是"没有这条线索"。
// 两者对调用方是不同的可恢复动作(前者可以 `.new` 另起一条,后者要先确认地址),
// 所以调用方要能 errors.Is 分开判。
var ErrSessionArchived = errors.New("session is archived")
// SessionOpenFor 报告 agent 是否可以**往这条会话里发新邮件**。
//
// 三个判据,来自三张表,缺一不可:
// - 会话存在(ErrSessionNotFound);
// - 会话未归档(ErrSessionArchived)—— 归档是全局、单向的;
// - 发起方是发起方:`s.from_agent = $1` **或**该 Agent 参与过这条线索
// (from/to/cc 的 EXISTS 子句,与别名寻址 `FindNamedSessionFor` 同一形状)。
//
// 第三个判据补的是 `AgentCanAccessSession`(repo.go:AgentCanAccessSession)那条
// 只在读路径被调用的判据:写路径此前**一个都不查**。permission/request 的
// session_id 完全来自请求体,携带别人的 session_id 就能往那条线索里投一封邮件 ——
// 而 mail.go 的 reply_to 分支同样不查参与方,于是"知道 id 就能往别人的线索里发信"。
//
// 为什么不复用 AgentCanAccessSession:那个函数判的是"能否改这条线索的**别名**"
// (别名是人记住的寻址入口),范围比"能否发信"窄(Agent 可以发信到一条自己
// 只是被抄送的线索,别名却不该由它改)。两者是不同的能力,不能互相顶替。
func SessionOpenFor(ctx context.Context, agentName string, sessionID uuid.UUID) error {
var status string
err := db.DB.QueryRowContext(ctx,
`SELECT s.status FROM sessions s WHERE s.session_id = $1`, sessionID).Scan(&status)
if errors.Is(err, sql.ErrNoRows) {
return ErrSessionNotFound
}
if err != nil {
return err
}
if status == "archived" {
return ErrSessionArchived
}
var n int
if err := db.DB.QueryRowContext(ctx, `
SELECT COUNT(*) FROM sessions s
WHERE s.session_id = $1
AND (s.from_agent = $2 OR EXISTS (
SELECT 1 FROM mails m
WHERE m.session_id = s.session_id
AND (m.from_name = $2 OR m.to_name = $2 OR `+db.CCHas("m.cc_list", 2)+`)))`,
sessionID, agentName).Scan(&n); err != nil {
return err
}
if n == 0 {
return ErrSessionNotFound
}
return nil
}
// UpdateSessionAlias 手工改名(人显式指定)。
//
// 同时把 alias_source 标为 'manual':人的选择优先于平台自动命名。
@ -394,6 +471,9 @@ func UpdateSessionAlias(ctx context.Context, id uuid.UUID, alias string) error {
func CreateMail(ctx context.Context, sessionID uuid.UUID, parentMailID *uuid.UUID,
fromName, fromWorkspace, toName, toWorkspace, subject, body string, ccList []models.Address) (uuid.UUID, error) {
if err := EnsureSessionOpen(ctx, sessionID); err != nil {
return uuid.Nil, err
}
if ccList == nil {
ccList = []models.Address{}
}
@ -424,7 +504,42 @@ func CreateMail(ctx context.Context, sessionID uuid.UUID, parentMailID *uuid.UUI
//
// 取会话的 workspace 而不是传参:会话的工作目录在它建立时就定下了,
// 而询问发起于那条会话里。
//
// ★ 这里的 EnsureSessionOpen 是本文件**所有**建邮件函数
// (CreateMail / CreatePermissionMail / CreateDecisionMail)的共同前置。
//
// 为什么不把 WHERE 塞进每个 INSERT:分叉判据的成因就是「每个写路径各判一次,
// 而它们判的列不同」(会话表 vs 邮件表)。守卫集中一处,
// 新增一种建邮件的函数就必须经过它 —— 否则下一个新函数又会漏。
//
// 目的是让**不变量由构造保证**:会话一旦 archived,就再也长不出非归档的邮件行,
// 而两表分叉正是「半活会话」的全部成因
// (判据:session_status_invariant_test.go)。
// EnsureSessionOpen 判「会话存在且未归档」,不判参与方。
//
// 需要它的调用点各自已经判过权限(人类的决策回执只允许收件人本人或管理员),
// 所以这里**刻意不**叠参与方那一格 —— 叠上会让管理员被误挡,
// 而管理员本来就可以给任何线索做决策,那不是越权。
func EnsureSessionOpen(ctx context.Context, sessionID uuid.UUID) error {
var status string
err := db.DB.QueryRowContext(ctx,
`SELECT status FROM sessions WHERE session_id = $1`, sessionID).Scan(&status)
if errors.Is(err, sql.ErrNoRows) {
return ErrSessionNotFound
}
if err != nil {
return err
}
if status == "archived" {
return ErrSessionArchived
}
return nil
}
func CreatePermissionMail(ctx context.Context, sessionID uuid.UUID, fromName, toUser, question, body string, options []string, kind string, multiSelect bool) (uuid.UUID, error) {
if err := EnsureSessionOpen(ctx, sessionID); err != nil {
return uuid.Nil, err
}
optsJSON, _ := json.Marshal(options)
var multiSelectInt int
if multiSelect {
@ -447,6 +562,11 @@ func CreatePermissionMail(ctx context.Context, sessionID uuid.UUID, fromName, to
// CreateDecisionMail 创建人类决策邮件(fromUser → toAgent)
func CreateDecisionMail(ctx context.Context, sessionID uuid.UUID, parentMailID uuid.UUID, fromUser, toAgent, decision, note string) (uuid.UUID, error) {
// 守卫对这一条尤其要紧:决策是人在授权页上点的,而被决策的权限邮件可能
// 在等这一轮的过程中被归档。静默丢弃决策会让 Agent 那边永远等不到回执。
if err := EnsureSessionOpen(ctx, sessionID); err != nil {
return uuid.Nil, err
}
var id uuid.UUID
body := decision
if note != "" {