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

@ -82,6 +82,17 @@ func resolveTarget(r *http.Request, addr models.Address, replyTo, fromAgent, sub
if err != nil {
return uuid.Nil, nil, false, errNotFound("Parent mail not found")
}
// 归档契约在这条路径上同样成立:别名寻址回 404(FindNamedSessionFor
// 带 s.status <> 'archived'),reply_to 是**绕过它的那条路**。
//
// 原先这里直接 TouchSession —— 而它写 `status='active'`,于是
// 「已归档 + reply_to」= 会话被复活而邮件行留在 archived ⇒ 半活会话
// (ListInbox 按会话状态把它放回来、unreadFor 按邮件行继续藏)。
// 判据见 repo.TouchSession 与 session_status_invariant_test.go。
if err := repo.EnsureSessionOpen(r.Context(), mail.SessionID); err != nil {
return uuid.Nil, nil, false, errNotFound(
"无法送达:被回复的邮件属于一条已归档的会话。归档是单向的,请用 .new 另起一条")
}
repo.TouchSession(r.Context(), mail.SessionID)
return mail.SessionID, &replyID, false, nil
}

View File

@ -128,6 +128,22 @@ func RequestPermission(w http.ResponseWriter, r *http.Request) {
Error(w, http.StatusBadRequest, "Invalid session_id")
return
}
// 三个判据:存在 / 未归档 / 发起方确实是这条线索的一方。
//
// 这一格原先**一个都不查**(只 parse UUID 就 TouchSession 然后建邮件),
// 而 session_id 完全来自请求体 ⇒ 携带别人的 id 就能往那条线索里投一封
// 权限询问。原先那格唯一挡住的是 status:TouchSession 会把归档会话
// 改成 active,于是"往已归档线索里发信"也一并做到了(两表分叉的成因)。
// 判据见 repo.SessionOpenFor 的注释。
if err := repo.SessionOpenFor(r.Context(), agentName, id); err != nil {
switch {
case errors.Is(err, repo.ErrSessionArchived):
Error(w, http.StatusNotFound, "该会话已归档,不再接受权限询问;请新建会话")
default:
Error(w, http.StatusNotFound, "会话不存在,或你未参与该会话")
}
return
}
sessionID = id
repo.TouchSession(r.Context(), sessionID)
} else {
@ -365,6 +381,18 @@ func DecidePermission(w http.ResponseWriter, r *http.Request) {
return
}
// 归档检查必须在 DecidePermission **之前**:否则决策已落库、回执邮件却因为
// 会话已归档建不出来 —— 发起方那边永远等不到回执(任务挂死),
// 而人在界面上看到的却是"已处理"。宁可不决策,让人看见失败。
if err := repo.SessionOpenFor(r.Context(), user.Username, perm.SessionID); err != nil {
if errors.Is(err, repo.ErrSessionArchived) {
Error(w, http.StatusNotFound, "该会话已归档,无法回执决策")
} else {
Error(w, http.StatusNotFound, "会话不存在")
}
return
}
// 决策人 = 当前登录用户(已读只记到他名下,不影响这条线索上其他收件人)
if _, err := repo.DecidePermission(r.Context(), mailID, user.Username, req.Decision); err != nil {
Error(w, http.StatusInternalServerError, "Failed to decide permission")

View File

@ -388,6 +388,7 @@ func FindSessionByPlatformID(ctx context.Context, agentName, platformID string)
SELECT s.session_id
FROM sessions s
WHERE s.platform_id = $1
AND s.status <> 'archived'
AND EXISTS (
SELECT 1 FROM mails m
WHERE m.session_id = s.session_id

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 != "" {

View File

@ -0,0 +1,229 @@
package repo
import (
"context"
"errors"
"testing"
"github.com/agentmail/gateway/internal/db"
"github.com/google/uuid"
)
/*
判据:`sessions.status` 与 `mails.status` **不得分叉**。
# 为什么不变量、单条路径都测不出来
「已归档 ⇒ 对所有人不可见」在读侧由两处判据保证,而它们挂在**不同的表**上:
unreadFor / readStateFor 判 `m.status` (repo.go:unreadFor)
ListInbox / UnreadWorkspaces 判 `s.status` (repo.go:ListInbox)
两边对同一条已归档线索给出**不同答案**,而每一边单独看都是"对的"
—— `unreadFor` 说"这封是归档所以不算未读",`ListInbox` 说"这条会话没归档所以列出来"。
分叉是怎么被造出来的:`TouchSession` 写的是 `sessions.status`
(`UPDATE ... SET status='active'`),而邮件那条 INSERT 写的是 `mails.status`。
**任何只碰前者而不碰后者的写路径,都会造出半活会话。**
所以判据不能写成"reply_to 应当 404"—— 那是**单点**,今天有三个入口
(reply_to / permission-request / adopt),明天可能有第四个。
真正堵住洞的是这一条:
对任意 session_id:sessions.status = 'archived' ⟹ 该会话全部 mails.status = 'archived'
它是**不可分叉**的形状:只要 CreateMail 系(唯一会写出 mails.status <>
'archived' 的入口)拒绝归档会话,TouchSession 系(唯一会解除归档的入口)
不再碰 status,那么任何**组合**调用都不可能分叉 —— 判据不必枚举入口。
入口级的回归仍单测(TestArchivedSessionRefuses*),但它们是**说明**,
这一条才是防线。
# 参照的坏结论
2026-09-28 探针期间 pi 的 `压测-限流-17` 得出「16/16 归档会话零回信、
4/4 活会话有回信」—— 那 4 条"活"会话正是被 TouchSession 复活的,
把「从未归档」和「归档后被复活」混成了一类。判据若只测「归档后回信会怎样」,
就抓不到这类混用;**分叉**是它们的共同签名。
*/
// sessionStatusOf 读一条会话的 sessions.status。
func sessionStatusOf(t *testing.T, sid uuid.UUID) string {
t.Helper()
var s string
if err := db.DB.QueryRowContext(context.Background(),
`SELECT status FROM sessions WHERE session_id = $1`, sid).Scan(&s); err != nil {
t.Fatal(err)
}
return s
}
// assertNoDivergence 是本文件所有判据的公共末尾:扫全库确认无半活会话。
//
// 扫全库而不是只看刚操作那条:分叉的读数要跨会话聚合才看得见
// (`ListInbox` 按 s.status 过滤、`unreadFor` 按 m.status 过滤,同一会话的
// 同一封邮件在两条路径上分别"在"和"不在")。
func assertNoDivergence(t *testing.T) {
t.Helper()
rows, err := db.DB.QueryContext(context.Background(), `
SELECT s.session_id, s.session_alias, s.status, m.mail_id, m.status
FROM sessions s JOIN mails m ON m.session_id = s.session_id
WHERE s.status = 'archived' AND m.status <> 'archived'`)
if err != nil {
t.Fatal(err)
}
defer rows.Close()
if rows.Next() {
var sid, alias, sstat, mid, mstat string
if err := rows.Scan(&sid, &alias, &sstat, &mid, &mstat); err != nil {
t.Fatal(err)
}
t.Fatalf("★ 半活会话:session %s(alias=%q) status=%s,但邮件 %s 的 mails.status=%s\n"+
" sessions.status='archived' 的会话里不该有非 archived 的邮件 ——\n"+
" ListInbox 按 s.status 放它出来、unreadFor 按 m.status 继续藏着,同一封两个答案。\n"+
" 查这两条写路径:TouchSession 是否又写了 status?CreateMail 系是否又漏了守卫?",
sid, alias, sstat, mid, mstat)
}
if err := rows.Err(); err != nil {
t.Fatal(err)
}
}
// seedArchivedSession 建一条含一封邮件的会话并把它整条归档,返回 (sessionID, mailID)。
func seedArchivedSession(t *testing.T) (uuid.UUID, uuid.UUID) {
t.Helper()
ctx := context.Background()
id := seedMailTo(t, "bob", "")
var sid uuid.UUID
if err := db.DB.QueryRowContext(ctx,
`SELECT session_id FROM mails WHERE mail_id = $1`, id).Scan(&sid); err != nil {
t.Fatal(err)
}
if err := ArchiveSession(ctx, sid); err != nil {
t.Fatal(err)
}
return sid, id
}
// TouchSession 不得解除归档 —— 这是半活会话的**唯一**成因。
func TestTouchSessionDoesNotUnarchive(t *testing.T) {
setupTestDB(t)
ctx := context.Background()
sid, _ := seedArchivedSession(t)
if err := TouchSession(ctx, sid); err != nil {
t.Fatal(err)
}
if got := sessionStatusOf(t, sid); got != "archived" {
t.Fatalf("★ TouchSession 把已归档会话改成了 %q —— 它只该刷 updated_at。\n"+
" 这一格是全库唯一能写 sessions.status 的地方(除 ArchiveSession),\n"+
" 它一旦能写 status',reply_to / permission / adopt 三个入口就都能复活归档会话。", got)
}
assertNoDivergence(t)
}
// 归档后不能有**任何**新邮件落进去 —— 落进去的那封必是 mails.status='unread'
// (DEFAULT),于是归档线索长出未读信,而 ListInbox 又因为 s.status 放它出来。
func TestArchivedSessionRefusesNewMail(t *testing.T) {
setupTestDB(t)
ctx := context.Background()
sid, _ := seedArchivedSession(t)
if _, err := CreateMail(ctx, sid, nil, "sender", "", "bob", "", "s", "b", nil); !errors.Is(err, ErrSessionArchived) {
t.Fatalf("CreateMail 落进已归档会话时 err=%v,期望 ErrSessionArchived", err)
}
if _, err := CreatePermissionMail(ctx, sid, "sender", "bob", "q", "b", []string{"同意"}, "permission", false); !errors.Is(err, ErrSessionArchived) {
t.Fatalf("CreatePermissionMail 落进已归档会话时 err=%v,期望 ErrSessionArchived", err)
}
if _, err := CreateDecisionMail(ctx, sid, uuid.Nil, "bob", "sender", "同意", ""); !errors.Is(err, ErrSessionArchived) {
t.Fatalf("CreateDecisionMail 落进已归档会话时 err=%v,期望 ErrSessionArchived", err)
}
assertNoDivergence(t)
}
// 守卫必须是 ErrSessionArchived 而不是 ErrSessionNotFound:调用方要能分开回话。
// 「已归档」可以 `.new` 另起一条;「不存在」要先确认地址写没写错。
func TestArchivedIsDistinctFromMissing(t *testing.T) {
setupTestDB(t)
sid, _ := seedArchivedSession(t)
if err := EnsureSessionOpen(context.Background(), sid); !errors.Is(err, ErrSessionArchived) {
t.Fatalf("已归档会话的读数是 %v,期望 ErrSessionArchived(与 NotFound 分列)", err)
}
if err := EnsureSessionOpen(context.Background(), uuid.New()); !errors.Is(err, ErrSessionNotFound) {
t.Fatalf("不存在会话的读数是 %v,期望 ErrSessionNotFound", err)
}
}
// SessionOpenFor 补上写路径此前一个都不查的**参与方**判据。
func TestSessionOpenForRejectsForeignSession(t *testing.T) {
setupTestDB(t)
ctx := context.Background()
sid, _ := seedArchivedSession(t)
// "bob" 是收件人,参与过 → 但会话已归档,先被归档这一格挡住
if err := SessionOpenFor(ctx, "bob", sid); !errors.Is(err, ErrSessionArchived) {
t.Fatalf("参与者对已归档会话的读数是 %v,期望 ErrSessionArchived", err)
}
// 解除归档后(模拟人先归档又改主意),参与者应通过
if _, err := db.DB.ExecContext(ctx,
`UPDATE sessions SET status='active' WHERE session_id = $1`, sid); err != nil {
t.Fatal(err)
}
if err := SessionOpenFor(ctx, "bob", sid); err != nil {
t.Fatalf("参与者 bob 应可向未归档会话发信,读数 %v", err)
}
// 没参与过的第三方:这条线是他没参与的线索
if err := SessionOpenFor(ctx, "stranger", sid); !errors.Is(err, ErrSessionNotFound) {
t.Fatalf("未参与者 stranger 的读数是 %v,期望 ErrSessionNotFound(携带别人的 id 不得注入邮件)", err)
}
// from_agent 也是一方:它发起的线索自己当然能继续
if err := SessionOpenFor(ctx, "sender", sid); err != nil {
t.Fatalf("会话发起方 sender 应可发信,读数 %v", err)
}
}
// 归档会话在读侧仍然对所有人不可见 —— 与 readstate_test.go 的既有判据同源,
// 这里重跑一遍是为了让「写侧堵死」不会悄悄改掉「读侧仍成立」的前提。
func TestArchivedStaysInvisibleInBothTables(t *testing.T) {
setupTestDB(t)
ctx := context.Background()
_, id := seedArchivedSession(t)
if n, _ := CountUnread(ctx, "bob", ""); n != 0 {
t.Fatalf("已归档会话的邮件不该计入未读(mails.status 判据),实际 %d", n)
}
if hasID(unreadList(t, "bob"), id) {
t.Fatal("已归档会话的邮件不该出现在 unread 收件箱")
}
mails, err := ListInbox(ctx, "bob", "all", "", 50)
if err != nil {
t.Fatal(err)
}
for _, m := range mails {
if m.ID == id {
t.Fatal("已归档会话的邮件不该出现在收件箱(all 也不该有)—— sessions.status 判据被绕过了")
}
}
assertNoDivergence(t)
}
// 归档**之后**再走一遍 TouchSession(投递路径的唯一副作用)也不该分叉。
// 单测按「入口」写,这条按「时序」写:先归档、后投递,是线上真实发生的顺序。
func TestArchiveAfterDeliveryKeepsTablesTogether(t *testing.T) {
setupTestDB(t)
ctx := context.Background()
sid, _ := seedArchivedSession(t)
// 归档后再投递三次:别名命中、TouchSession、CreateMail 三步都得被拒
for i := 0; i < 3; i++ {
_ = TouchSession(ctx, sid)
_, _ = CreateMail(ctx, sid, nil, "sender", "", "bob", "", "s", "b", nil)
}
assertNoDivergence(t)
if got := sessionStatusOf(t, sid); got != "archived" {
t.Fatalf("重复投递把会话状态推成了 %q,期望仍是 archived", got)
}
}