1
0
Эх сурвалжийг харах

fix(feedback): address backend review gaps

ZiyaZhang 2 сар өмнө
parent
commit
c61be60737

+ 2 - 2
.agents/notes/implemented/architecture/2026-08-10-message-feedback-sidecar.i18n.yaml

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-08-10-message-feedback-sidecar.md
-2026-08-10-message-feedback-sidecar.md: a741a7e6b9fe422c9aafb68d9b099466399e1ee9
-2026-08-10-message-feedback-sidecar.zh.md: da176e13586bb93ebadcefc0afb44c21ce386e08
+2026-08-10-message-feedback-sidecar.md: 780cbaa840fcac7bcfa799468bfd61f5b08715cb
+2026-08-10-message-feedback-sidecar.zh.md: 72ecc82717010f65d013418a45c3b38c6084aaf9

+ 1 - 1
.agents/notes/implemented/architecture/2026-08-10-message-feedback-sidecar.md

@@ -22,7 +22,7 @@ Before `put` commits a sidecar row, it puts the target log behind a durability b
 
 Each message item carries its own opaque version plus Host-assigned `createdAt` and `updatedAt` timestamps. `put` compares the caller's `ifVersion` only with the addressed item, so editing one message does not invalidate another. The comparison is strict even when the desired value already matches, preventing a stale request from crossing an ABA value cycle; a conflict returns the authoritative current item so callers can reconcile without a second read. A matching-version no-op preserves the version and timestamps, while a material update preserves `createdAt`, replaces the version, and keeps `updatedAt` from moving backward. An already-absent delete is likewise successful. Versions are tokens for equality, not counters callers may order or synthesize.
 
-A per-Session mutation queue encloses lifecycle inspection, sidecar read, conflict evaluation, and whole-row write. This makes one service instance's mutations serial and preserves the per-message compare-and-swap contract inside one Host process. The underlying storage-domain API provides no cross-process conditional write, so the implementation claims no cross-process linearizability or lost-update protection.
+A per-Session mutation queue encloses lifecycle inspection, sidecar read, conflict evaluation, and whole-row write. This makes one service instance's mutations serial and preserves the per-message compare-and-swap contract inside one Host process. Plugin disposal closes admission, drains accepted queue work, and then closes the storage domain. The underlying storage-domain API provides no cross-process conditional write, so the implementation claims no cross-process linearizability or lost-update protection.
 
 `maxNoteBytes` is a required deployment choice and bounds the UTF-8 byte length of an optional note; the Web Host bundle sets it explicitly to `8192`. The package publishes the Host `messageFeedback.list`, `messageFeedback.put`, and `messageFeedback.delete` contract directly through `GatewayService` and `@Remote`. Client Remote aggregate mounting and UI remain separately owned and deferred; their later adapter stays a thin consumer of this Host contract.
 

+ 1 - 1
.agents/notes/implemented/architecture/2026-08-10-message-feedback-sidecar.zh.md

@@ -22,7 +22,7 @@ Status: implemented
 
 每个消息条目都携带自己的 opaque version,以及 Host 分配的 `createdAt` 和 `updatedAt` 时间戳。`put` 只把调用方的 `ifVersion` 与目标条目比较,因此编辑一条消息不会使另一条消息失效。即使目标值已经相同,比较仍然严格执行,从而防止陈旧请求穿过 ABA 值循环;冲突会返回权威当前条目,调用方无需二次读取即可协调。携带匹配 version 的无变化请求会保留 version 与时间戳;实质更新保留 `createdAt`、替换 version,并保证 `updatedAt` 不倒退。删除已经不存在的条目也同样成功。version 是只能做相等比较的 token,不是调用方可以排序或自行合成的计数器。
 
-按 Session 划分的变更队列覆盖生命周期检查、伴随记录读取、冲突判断与整行写入。这使同一个服务实例的变更串行化,并在单个 Host 进程内保持逐消息 compare-and-swap 契约。底层 storage-domain API 不提供跨进程条件写,因此实现不承诺跨进程线性一致性或防止丢失更新。
+按 Session 划分的变更队列覆盖生命周期检查、伴随记录读取、冲突判断与整行写入。这使同一个服务实例的变更串行化,并在单个 Host 进程内保持逐消息 compare-and-swap 契约。Plugin disposal 会关闭接纳、排空已进入队列的工作,然后关闭 storage domain。底层 storage-domain API 不提供跨进程条件写,因此实现不承诺跨进程线性一致性或防止丢失更新。
 
 `maxNoteBytes` 是必填的部署选择,用于限制可选备注的 UTF-8 字节长度;Web Host bundle 将其显式设为 `8192`。该包通过 `GatewayService` 与 `@Remote` 直接发布 Host `messageFeedback.list`、`messageFeedback.put` 与 `messageFeedback.delete` 契约。客户端 Remote 聚合挂载与 UI 由各自边界负责并保持延后;后续适配层只是该 Host 契约的薄消费者。
 

+ 115 - 0
apps/web/tests/message-feedback-protocol.snapshot.ts

@@ -0,0 +1,115 @@
+import { readFile } from 'node:fs/promises'
+import { join } from 'node:path'
+import { fileURLToPath } from 'node:url'
+import { afterAll, beforeAll, describe, expect, it } from 'vitest'
+import {
+  assertFixtureInventory,
+  compareOrRefreshGolden,
+  launchWebScaffold,
+  seedSession,
+  type WebScaffold,
+} from './scaffold.ts'
+
+const SNAPSHOT_DIR = fileURLToPath(new URL('./snapshots/message-feedback-protocol', import.meta.url))
+const SESSION_FIXTURE = join(SNAPSHOT_DIR, 'session.jsonl')
+const PROTOCOL_EXPECTED = join(SNAPSHOT_DIR, 'protocol.expected.json')
+const SESSION_ID = 'message-feedback-protocol'
+const MESSAGE_ID = '11111111-1111-4111-8111-111111111111'
+
+interface ProtocolExchange {
+  readonly endpoint: string
+  readonly request: unknown
+  readonly status: number
+  readonly response: unknown
+}
+
+function isRecord(value: unknown): value is Record<string, unknown> {
+  return typeof value === 'object' && value !== null
+}
+
+/** Extract the opaque item version while keeping every surrounding wire field snapshot-owned. */
+function createdVersion(response: unknown): string {
+  if (!isRecord(response) || !isRecord(response.result) || response.result.ok !== true
+    || !isRecord(response.result.value) || response.result.value.ok !== true
+    || !isRecord(response.result.value.value)
+    || typeof response.result.value.value.version !== 'string') {
+    throw new Error('messageFeedback.put did not return a successful versioned item')
+  }
+  return response.result.value.value.version
+}
+
+/** Replace only run-owned UUID/time values; all protocol names and business fields stay exact. */
+function normalizeProtocol(exchanges: readonly ProtocolExchange[], version: string): string {
+  return JSON.stringify(exchanges, (key, value: unknown) => {
+    if ((key === 'version' || key === 'ifVersion') && value === version) return '{{version}}'
+    if ((key === 'createdAt' || key === 'updatedAt') && typeof value === 'number') return '{{timestamp}}'
+    return value
+  }, 2)
+}
+
+describe('message feedback Host Remote protocol', () => {
+  let scaffold: WebScaffold
+
+  beforeAll(async () => {
+    scaffold = await launchWebScaffold()
+    await seedSession(scaffold, await readFile(SESSION_FIXTURE, 'utf8'), SESSION_ID)
+  })
+
+  afterAll(async () => {
+    await scaffold?.close()
+  })
+
+  it('snapshots strict list, put, conflict, and delete calls through the shipped Web Host', async () => {
+    const exchanges: ProtocolExchange[] = []
+    const invoke = async (rpcId: string, endpoint: string, request: unknown): Promise<unknown> => {
+      const payload = { args: { request } }
+      const response = await fetch(`${scaffold.baseUrl}/api/${endpoint}`, {
+        method: 'POST',
+        headers: { 'content-type': 'application/json' },
+        body: JSON.stringify({
+          type: 'client-request',
+          rpcId,
+          method: endpoint,
+          payload,
+        }),
+      })
+      const body: unknown = await response.json()
+      exchanges.push({ endpoint: `/api/${endpoint}`, request: payload, status: response.status, response: body })
+      return body
+    }
+
+    await invoke('feedback-invalid', 'messageFeedback/put', {
+      sessionId: SESSION_ID,
+      messageId: MESSAGE_ID,
+      rating: 'invalid-rating',
+      ifVersion: null,
+    })
+    await invoke('feedback-list-empty', 'messageFeedback/list', { sessionId: SESSION_ID })
+    const created = await invoke('feedback-put', 'messageFeedback/put', {
+      sessionId: SESSION_ID,
+      messageId: MESSAGE_ID,
+      rating: 'positive',
+      note: 'Useful answer',
+      ifVersion: null,
+    })
+    const version = createdVersion(created)
+    expect(version).toMatch(/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/)
+    await invoke('feedback-list-created', 'messageFeedback/list', { sessionId: SESSION_ID })
+    await invoke('feedback-conflict', 'messageFeedback/put', {
+      sessionId: SESSION_ID,
+      messageId: MESSAGE_ID,
+      rating: 'negative',
+      ifVersion: null,
+    })
+    await invoke('feedback-delete', 'messageFeedback/delete', {
+      sessionId: SESSION_ID,
+      messageId: MESSAGE_ID,
+      ifVersion: version,
+    })
+    await invoke('feedback-list-deleted', 'messageFeedback/list', { sessionId: SESSION_ID })
+
+    expect(exchanges.every(exchange => exchange.status === 200)).toBe(true)
+    await compareOrRefreshGolden(PROTOCOL_EXPECTED, normalizeProtocol(exchanges, version), scaffold.mode)
+    await assertFixtureInventory(SNAPSHOT_DIR, ['protocol.expected.json', 'session.jsonl'])
+  })
+})

+ 203 - 0
apps/web/tests/snapshots/message-feedback-protocol/protocol.expected.json

@@ -0,0 +1,203 @@
+[
+  {
+    "endpoint": "/api/messageFeedback/put",
+    "request": {
+      "args": {
+        "request": {
+          "sessionId": "message-feedback-protocol",
+          "messageId": "11111111-1111-4111-8111-111111111111",
+          "rating": "invalid-rating",
+          "ifVersion": null
+        }
+      }
+    },
+    "status": 200,
+    "response": {
+      "type": "server-response",
+      "rpcId": "feedback-invalid",
+      "result": {
+        "ok": false,
+        "error": {
+          "code": "internal",
+          "message": "typert gateway: messageFeedback/put: wire field \"request\" failed boundary validation",
+          "details": {}
+        }
+      }
+    }
+  },
+  {
+    "endpoint": "/api/messageFeedback/list",
+    "request": {
+      "args": {
+        "request": {
+          "sessionId": "message-feedback-protocol"
+        }
+      }
+    },
+    "status": 200,
+    "response": {
+      "type": "server-response",
+      "rpcId": "feedback-list-empty",
+      "result": {
+        "ok": true,
+        "value": {
+          "ok": true,
+          "value": {
+            "items": []
+          }
+        }
+      }
+    }
+  },
+  {
+    "endpoint": "/api/messageFeedback/put",
+    "request": {
+      "args": {
+        "request": {
+          "sessionId": "message-feedback-protocol",
+          "messageId": "11111111-1111-4111-8111-111111111111",
+          "rating": "positive",
+          "note": "Useful answer",
+          "ifVersion": null
+        }
+      }
+    },
+    "status": 200,
+    "response": {
+      "type": "server-response",
+      "rpcId": "feedback-put",
+      "result": {
+        "ok": true,
+        "value": {
+          "ok": true,
+          "value": {
+            "messageId": "11111111-1111-4111-8111-111111111111",
+            "rating": "positive",
+            "note": "Useful answer",
+            "version": "{{version}}",
+            "createdAt": "{{timestamp}}",
+            "updatedAt": "{{timestamp}}"
+          }
+        }
+      }
+    }
+  },
+  {
+    "endpoint": "/api/messageFeedback/list",
+    "request": {
+      "args": {
+        "request": {
+          "sessionId": "message-feedback-protocol"
+        }
+      }
+    },
+    "status": 200,
+    "response": {
+      "type": "server-response",
+      "rpcId": "feedback-list-created",
+      "result": {
+        "ok": true,
+        "value": {
+          "ok": true,
+          "value": {
+            "items": [
+              {
+                "messageId": "11111111-1111-4111-8111-111111111111",
+                "rating": "positive",
+                "note": "Useful answer",
+                "version": "{{version}}",
+                "createdAt": "{{timestamp}}",
+                "updatedAt": "{{timestamp}}"
+              }
+            ]
+          }
+        }
+      }
+    }
+  },
+  {
+    "endpoint": "/api/messageFeedback/put",
+    "request": {
+      "args": {
+        "request": {
+          "sessionId": "message-feedback-protocol",
+          "messageId": "11111111-1111-4111-8111-111111111111",
+          "rating": "negative",
+          "ifVersion": null
+        }
+      }
+    },
+    "status": 200,
+    "response": {
+      "type": "server-response",
+      "rpcId": "feedback-conflict",
+      "result": {
+        "ok": true,
+        "value": {
+          "ok": false,
+          "error": {
+            "code": "version-conflict",
+            "current": {
+              "messageId": "11111111-1111-4111-8111-111111111111",
+              "rating": "positive",
+              "note": "Useful answer",
+              "version": "{{version}}",
+              "createdAt": "{{timestamp}}",
+              "updatedAt": "{{timestamp}}"
+            }
+          }
+        }
+      }
+    }
+  },
+  {
+    "endpoint": "/api/messageFeedback/delete",
+    "request": {
+      "args": {
+        "request": {
+          "sessionId": "message-feedback-protocol",
+          "messageId": "11111111-1111-4111-8111-111111111111",
+          "ifVersion": "{{version}}"
+        }
+      }
+    },
+    "status": 200,
+    "response": {
+      "type": "server-response",
+      "rpcId": "feedback-delete",
+      "result": {
+        "ok": true,
+        "value": {
+          "ok": true,
+          "value": {
+            "absent": true
+          }
+        }
+      }
+    }
+  },
+  {
+    "endpoint": "/api/messageFeedback/list",
+    "request": {
+      "args": {
+        "request": {
+          "sessionId": "message-feedback-protocol"
+        }
+      }
+    },
+    "status": 200,
+    "response": {
+      "type": "server-response",
+      "rpcId": "feedback-list-deleted",
+      "result": {
+        "ok": true,
+        "value": {
+          "ok": true,
+          "value": {
+            "items": []
+          }
+        }
+      }
+    }
+  }
+]

+ 7 - 0
apps/web/tests/snapshots/message-feedback-protocol/session.jsonl

@@ -0,0 +1,7 @@
+{"type":"session","version":0,"id":"{{sessionId}}","createdAt":1786406400000,"cwd":"{{cwd}}"}
+{"type":"turn/start","seq":0,"time":1786406400001,"data":{"turn":1}}
+{"type":"user/message","seq":1,"time":1786406400002,"data":{"role":"user","content":[{"type":"text","text":"Give one useful answer."}],"source":{"kind":"user"},"id":"22222222-2222-4222-8222-222222222222"},"surfaceOp":"append"}
+{"type":"step/start","seq":2,"time":1786406400003,"data":{"turn":1,"step":1}}
+{"type":"assistant/message","seq":3,"time":1786406400004,"data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"text","text":"A useful answer."}],"source":{"kind":"model","provider":"fixture","model":"fixture"},"id":"11111111-1111-4111-8111-111111111111"},"usage":{"inputTokens":4,"outputTokens":4}},"surfaceOp":"append"}
+{"type":"step/end","seq":4,"time":1786406400005,"data":{"turn":1,"step":1}}
+{"type":"turn/end","seq":5,"time":1786406400006,"data":{"turn":1,"reason":{"kind":"completed"}}}

+ 1 - 0
apps/web/tsconfig.json

@@ -25,6 +25,7 @@
     "tests/scaffold.ts",
     "tests/scaffold-hermetic.e2e.ts",
     "tests/minimal-preset.snapshot.ts",
+    "tests/message-feedback-protocol.snapshot.ts",
     "tests/live-interactions.e2e.ts",
     "tests/question-composer.e2e.ts",
     "tests/approval-composer.e2e.ts",

+ 2 - 2
docs/subsystems/feedback.i18n.yaml

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write docs/subsystems/feedback.md
-feedback.md: e5c99ee639403ec3da0cf5845ec00f5ea1a5817d
-feedback.zh.md: 407d111da59af1d9a5c99c0e3d06d70df9051a13
+feedback.md: 76a29f7d6ba604fa07ed56429c9b066e22639671
+feedback.zh.md: 5a409832de68b6d0bc9688a907c0f22edd3b0a43

+ 180 - 0
docs/subsystems/feedback.md

@@ -6,6 +6,183 @@ English | [中文](feedback.zh.md)
 
 Source: [`packages/feedback/message-feedback/src/types.ts`](../../packages/feedback/message-feedback/src/types.ts)
 
+## Public types
+
+```ts type-equiv
+/** Opaque compare-and-set token for one exact feedback item revision. */
+type MessageFeedbackVersion = Branded<'MessageFeedbackVersion'>
+```
+
+```ts type-equiv
+/** The human's overall judgment of one assistant message. */
+type MessageFeedbackRating = 'positive' | 'negative'
+```
+
+```ts type-equiv
+/** One current feedback value and its opaque mutation token. */
+interface MessageFeedbackItem {
+  /** Stable identity of the assistant message inside the owning Session. */
+  readonly messageId: MessageId
+  /** Overall positive or negative judgment. */
+  readonly rating: MessageFeedbackRating
+  /** Optional explanation, preserved verbatim after validation. */
+  readonly note?: string
+  /** Equality-only token replaced by every material create or update. */
+  readonly version: MessageFeedbackVersion
+  /** Host-assigned creation time in Unix epoch milliseconds. */
+  readonly createdAt: number
+  /** Host-assigned time of the most recent material update. */
+  readonly updatedAt: number
+}
+```
+
+```ts type-equiv
+/** Read all message feedback belonging to one persisted Session lifecycle. */
+interface MessageFeedbackListRequest {
+  /** Persisted Session whose sidecar should be read. */
+  readonly sessionId: SessionId
+}
+```
+
+```ts type-equiv
+/** Current feedback values for one Session, in first-creation order. */
+interface MessageFeedbackListValue {
+  /** Fresh immutable item snapshots. */
+  readonly items: readonly MessageFeedbackItem[]
+}
+```
+
+```ts type-equiv
+/** Create or replace feedback for one assistant message. */
+interface MessageFeedbackPutRequest {
+  /** Persisted Session that owns the target message. */
+  readonly sessionId: SessionId
+  /** Target assistant-message identity. */
+  readonly messageId: MessageId
+  /** Desired overall judgment. */
+  readonly rating: MessageFeedbackRating
+  /** Optional non-blank explanation. */
+  readonly note?: string
+  /** Observed item version, or `null` to require that no item exists. */
+  readonly ifVersion: MessageFeedbackVersion | null
+}
+```
+
+```ts type-equiv
+/** Delete feedback for one message after observing its current version. */
+interface MessageFeedbackDeleteRequest {
+  /** Persisted Session that owns the sidecar. */
+  readonly sessionId: SessionId
+  /** Message whose feedback should be absent after this operation. */
+  readonly messageId: MessageId
+  /** Observed item version; ignored when the item is already absent. */
+  readonly ifVersion: MessageFeedbackVersion
+}
+```
+
+```ts type-equiv
+/** Idempotent deletion acknowledgement. */
+interface MessageFeedbackDeleteValue {
+  /** Stable postcondition shared by the first deletion and every retry. */
+  readonly absent: true
+}
+```
+
+```ts type-equiv
+/** No persisted Session header exists for the requested id. */
+interface MessageFeedbackSessionNotFound {
+  readonly code: 'session-not-found'
+  readonly sessionId: SessionId
+}
+```
+
+```ts type-equiv
+/** The id does not name a derived, append-origin assistant message. */
+interface MessageFeedbackTargetNotFound {
+  readonly code: 'target-not-found'
+  readonly sessionId: SessionId
+  readonly messageId: MessageId
+}
+```
+
+```ts type-equiv
+/** A material mutation did not match the addressed item's current version. */
+interface MessageFeedbackVersionConflict {
+  readonly code: 'version-conflict'
+  /** Authoritative current item, or `null` when it does not exist. */
+  readonly current: MessageFeedbackItem | null
+}
+```
+
+```ts type-equiv
+/** A supplied note contains no non-whitespace character. */
+interface MessageFeedbackNoteBlank {
+  readonly code: 'note-blank'
+}
+```
+
+```ts type-equiv
+/** A supplied note exceeds the configured UTF-8 byte limit. */
+interface MessageFeedbackNoteTooLarge {
+  readonly code: 'note-too-large'
+  readonly maxBytes: number
+  readonly actualBytes: number
+}
+```
+
+```ts type-equiv
+/** Failures shared by the public message-feedback operations. */
+type MessageFeedbackFailure =
+  | MessageFeedbackSessionNotFound
+  | MessageFeedbackTargetNotFound
+  | MessageFeedbackVersionConflict
+  | MessageFeedbackNoteBlank
+  | MessageFeedbackNoteTooLarge
+```
+
+```ts type-equiv
+/** Successful public operation result. */
+interface MessageFeedbackSuccess<T> {
+  readonly ok: true
+  readonly value: T
+}
+```
+
+```ts type-equiv
+/** Rejected public operation result with a stable business failure. */
+interface MessageFeedbackRejected<E extends MessageFeedbackFailure> {
+  readonly ok: false
+  readonly error: E
+}
+```
+
+```ts type-equiv
+/** Result returned by the message-feedback `list` operation. */
+type MessageFeedbackListResult =
+  | MessageFeedbackSuccess<MessageFeedbackListValue>
+  | MessageFeedbackRejected<MessageFeedbackSessionNotFound>
+```
+
+```ts type-equiv
+/** Result returned by the message-feedback `put` operation. */
+type MessageFeedbackPutResult =
+  | MessageFeedbackSuccess<MessageFeedbackItem>
+  | MessageFeedbackRejected<
+    | MessageFeedbackSessionNotFound
+    | MessageFeedbackTargetNotFound
+    | MessageFeedbackVersionConflict
+    | MessageFeedbackNoteBlank
+    | MessageFeedbackNoteTooLarge
+  >
+```
+
+```ts type-equiv
+/** Result returned by the message-feedback `delete` operation. */
+type MessageFeedbackDeleteResult =
+  | MessageFeedbackSuccess<MessageFeedbackDeleteValue>
+  | MessageFeedbackRejected<MessageFeedbackSessionNotFound | MessageFeedbackVersionConflict>
+```
+
 ## Data and concurrency
 
 One Session sidecar row contains its header identity `{createdAt, cwd}` and feedback items keyed by `MessageId`. Each item carries a positive or negative rating, an optional note, Host-assigned `createdAt`/`updatedAt` timestamps, and its own opaque version. Versions are compared only for equality and only against the addressed message; callers do not order or synthesize them.
@@ -22,12 +199,15 @@ The stored `{createdAt, cwd}` identity must match the inspected header. A mismat
 
 The service stores whole Session rows in the `message_feedback` storage domain through `ctx.storageDomain`. Before `put` commits a row that references a target message, a matching live target passes through the canonical `ctx.sessions.flush` checkpoint; both live and cold paths are then physically read from sequence zero through `SessionPersistence.readFrom`. The resulting observation is revalidated before the sidecar write, so the durable target log always precedes its sidecar commit. `maxNoteBytes` is required and bounds note text by UTF-8 bytes; the Web Host composition sets `8192`. The package publishes the Host `messageFeedback.list`, `messageFeedback.put`, and `messageFeedback.delete` unary Remote contract through `GatewayService` and `@Remote`; the generated Cordis surface below is the method-level authority.
 
+Plugin disposal closes mutation admission, drains accepted per-Session queue work, and then closes the storage domain.
+
 ## Boundaries and limitations
 
 - The client Remote aggregate mount and UI consumer are separately owned and deferred.
 - The mutation queue is process-local. Storage-domain has no cross-process conditional write, so multiple Host writers to one storage root have no compare-and-swap or lost-update guarantee.
 - Session persistence has no durable deletion surface. The service does not treat `session/disposed` or `host/session-removed` as deletion and therefore performs no fake cascade; orphan sidecar rows may remain after out-of-band log removal.
 - A request in the narrow interval after live detach but before the persistence catalog materializes the header can receive `session-not-found`; callers retry after retirement materialization.
+- Cold requests scan the complete Session snapshot catalog because persistence has no lookup-by-id metadata operation. One Session row also has no item-count or aggregate-byte cap; `maxNoteBytes` bounds only each note until a concrete consumer owns a row policy.
 - Header identity detects a reused id only when `{createdAt, cwd}` differs; a cloned log retaining the same header identity is indistinguishable by this contract.
 - The Host contract records no authenticated actor or audit identity and therefore assumes a trusted caller boundary.
 

+ 180 - 0
docs/subsystems/feedback.zh.md

@@ -6,6 +6,183 @@
 
 来源:[`packages/feedback/message-feedback/src/types.ts`](../../packages/feedback/message-feedback/src/types.ts)
 
+## 公开类型
+
+```ts type-equiv
+/** Opaque compare-and-set token for one exact feedback item revision. */
+type MessageFeedbackVersion = Branded<'MessageFeedbackVersion'>
+```
+
+```ts type-equiv
+/** The human's overall judgment of one assistant message. */
+type MessageFeedbackRating = 'positive' | 'negative'
+```
+
+```ts type-equiv
+/** One current feedback value and its opaque mutation token. */
+interface MessageFeedbackItem {
+  /** Stable identity of the assistant message inside the owning Session. */
+  readonly messageId: MessageId
+  /** Overall positive or negative judgment. */
+  readonly rating: MessageFeedbackRating
+  /** Optional explanation, preserved verbatim after validation. */
+  readonly note?: string
+  /** Equality-only token replaced by every material create or update. */
+  readonly version: MessageFeedbackVersion
+  /** Host-assigned creation time in Unix epoch milliseconds. */
+  readonly createdAt: number
+  /** Host-assigned time of the most recent material update. */
+  readonly updatedAt: number
+}
+```
+
+```ts type-equiv
+/** Read all message feedback belonging to one persisted Session lifecycle. */
+interface MessageFeedbackListRequest {
+  /** Persisted Session whose sidecar should be read. */
+  readonly sessionId: SessionId
+}
+```
+
+```ts type-equiv
+/** Current feedback values for one Session, in first-creation order. */
+interface MessageFeedbackListValue {
+  /** Fresh immutable item snapshots. */
+  readonly items: readonly MessageFeedbackItem[]
+}
+```
+
+```ts type-equiv
+/** Create or replace feedback for one assistant message. */
+interface MessageFeedbackPutRequest {
+  /** Persisted Session that owns the target message. */
+  readonly sessionId: SessionId
+  /** Target assistant-message identity. */
+  readonly messageId: MessageId
+  /** Desired overall judgment. */
+  readonly rating: MessageFeedbackRating
+  /** Optional non-blank explanation. */
+  readonly note?: string
+  /** Observed item version, or `null` to require that no item exists. */
+  readonly ifVersion: MessageFeedbackVersion | null
+}
+```
+
+```ts type-equiv
+/** Delete feedback for one message after observing its current version. */
+interface MessageFeedbackDeleteRequest {
+  /** Persisted Session that owns the sidecar. */
+  readonly sessionId: SessionId
+  /** Message whose feedback should be absent after this operation. */
+  readonly messageId: MessageId
+  /** Observed item version; ignored when the item is already absent. */
+  readonly ifVersion: MessageFeedbackVersion
+}
+```
+
+```ts type-equiv
+/** Idempotent deletion acknowledgement. */
+interface MessageFeedbackDeleteValue {
+  /** Stable postcondition shared by the first deletion and every retry. */
+  readonly absent: true
+}
+```
+
+```ts type-equiv
+/** No persisted Session header exists for the requested id. */
+interface MessageFeedbackSessionNotFound {
+  readonly code: 'session-not-found'
+  readonly sessionId: SessionId
+}
+```
+
+```ts type-equiv
+/** The id does not name a derived, append-origin assistant message. */
+interface MessageFeedbackTargetNotFound {
+  readonly code: 'target-not-found'
+  readonly sessionId: SessionId
+  readonly messageId: MessageId
+}
+```
+
+```ts type-equiv
+/** A material mutation did not match the addressed item's current version. */
+interface MessageFeedbackVersionConflict {
+  readonly code: 'version-conflict'
+  /** Authoritative current item, or `null` when it does not exist. */
+  readonly current: MessageFeedbackItem | null
+}
+```
+
+```ts type-equiv
+/** A supplied note contains no non-whitespace character. */
+interface MessageFeedbackNoteBlank {
+  readonly code: 'note-blank'
+}
+```
+
+```ts type-equiv
+/** A supplied note exceeds the configured UTF-8 byte limit. */
+interface MessageFeedbackNoteTooLarge {
+  readonly code: 'note-too-large'
+  readonly maxBytes: number
+  readonly actualBytes: number
+}
+```
+
+```ts type-equiv
+/** Failures shared by the public message-feedback operations. */
+type MessageFeedbackFailure =
+  | MessageFeedbackSessionNotFound
+  | MessageFeedbackTargetNotFound
+  | MessageFeedbackVersionConflict
+  | MessageFeedbackNoteBlank
+  | MessageFeedbackNoteTooLarge
+```
+
+```ts type-equiv
+/** Successful public operation result. */
+interface MessageFeedbackSuccess<T> {
+  readonly ok: true
+  readonly value: T
+}
+```
+
+```ts type-equiv
+/** Rejected public operation result with a stable business failure. */
+interface MessageFeedbackRejected<E extends MessageFeedbackFailure> {
+  readonly ok: false
+  readonly error: E
+}
+```
+
+```ts type-equiv
+/** Result returned by the message-feedback `list` operation. */
+type MessageFeedbackListResult =
+  | MessageFeedbackSuccess<MessageFeedbackListValue>
+  | MessageFeedbackRejected<MessageFeedbackSessionNotFound>
+```
+
+```ts type-equiv
+/** Result returned by the message-feedback `put` operation. */
+type MessageFeedbackPutResult =
+  | MessageFeedbackSuccess<MessageFeedbackItem>
+  | MessageFeedbackRejected<
+    | MessageFeedbackSessionNotFound
+    | MessageFeedbackTargetNotFound
+    | MessageFeedbackVersionConflict
+    | MessageFeedbackNoteBlank
+    | MessageFeedbackNoteTooLarge
+  >
+```
+
+```ts type-equiv
+/** Result returned by the message-feedback `delete` operation. */
+type MessageFeedbackDeleteResult =
+  | MessageFeedbackSuccess<MessageFeedbackDeleteValue>
+  | MessageFeedbackRejected<MessageFeedbackSessionNotFound | MessageFeedbackVersionConflict>
+```
+
 ## 数据与并发
 
 每个 Session 的一条伴随记录包含 header 身份 `{createdAt, cwd}` 和以 `MessageId` 为键的反馈条目。每个条目携带好评或差评、可选备注、Host 分配的 `createdAt`/`updatedAt` 时间戳及自己的 opaque version。version 只能用于相等比较,且只与目标消息比较;调用方不能排序或自行合成它。
@@ -22,12 +199,15 @@
 
 服务通过 `ctx.storageDomain` 在 `message_feedback` 存储域中保存完整 Session 行。`put` 提交引用目标消息的伴随记录前,身份匹配的 live 目标先经过权威 `ctx.sessions.flush` checkpoint;随后 live 与 cold 路径都会通过 `SessionPersistence.readFrom` 从序列零做物理复读。写入伴随记录前会再次校验所得观测,因此目标日志的持久提交始终先于其伴随记录。`maxNoteBytes` 为必填项,按 UTF-8 字节限制备注文本;Web Host 组合将其设为 `8192`。该包通过 `GatewayService` 与 `@Remote` 发布 Host `messageFeedback.list`、`messageFeedback.put` 和 `messageFeedback.delete` 一元 Remote 契约;下方生成的 Cordis surface 是方法级权威。
 
+Plugin disposal 会先关闭变更接纳,排空已进入各 Session 队列的工作,然后才关闭 storage domain。
+
 ## 边界与限制
 
 - 客户端 Remote 聚合挂载与 UI 消费方由各自边界负责并保持延后。
 - 变更队列仅在进程内生效。storage-domain 没有跨进程条件写,因此多个 Host 写入同一存储根目录时,不提供 compare-and-swap 或防止丢失更新的保证。
 - Session persistence 没有持久删除接口。服务不把 `session/disposed` 或 `host/session-removed` 当作删除,因此不伪造级联;在带外移除日志后,孤儿伴随记录可能继续存在。
 - 请求若恰好落在 live detach 之后、persistence catalog 物化 header 之前的极短窗口,可能收到 `session-not-found`;调用方应在 retirement materialization 后重试。
+- 由于 persistence 没有按 id 读取元数据的操作,cold 请求会扫描完整的 Session snapshot 目录。单个 Session 行也没有条目数或聚合字节上限;在具体消费方拥有行策略之前,`maxNoteBytes` 只限制每条备注。
 - 只有 `{createdAt, cwd}` 不同时,header 身份才能识别复用的 id;本契约无法区分保留相同 header 身份的克隆日志。
 - Host 契约不记录已认证的 actor 或审计身份,因此假设调用方边界可信。
 

+ 2 - 2
packages/feedback/message-feedback/README.i18n.yaml

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write packages/feedback/message-feedback/README.md
-README.md: 948cdcd2ef0fa2d1bcea68fdc40f16be08e963a8
-README.zh.md: 14e62eea0a182bc60be2c370653d1cb031d6b8a8
+README.md: a9ebad25907e32435c8d2a65eb4b2cd9eaa89eeb
+README.zh.md: 29cbee0c1ec2ee810948d895c762d2e6320e9b66

+ 4 - 1
packages/feedback/message-feedback/README.md

@@ -12,7 +12,7 @@ Public request, value, version, and failure types are exported from the package
 |---|---|
 | `maxNoteBytes` | Required positive safe integer: maximum UTF-8 byte length of one optional note. |
 
-Notes must contain at least one non-whitespace character, but accepted text is stored verbatim rather than trimmed. Omitting `note` means the desired value has no note, so an authorized material `put` clears an existing note.
+Notes must contain at least one non-whitespace character, but accepted text is stored verbatim rather than trimmed. Omitting `note` means the desired value has no note, so a version-matched material `put` clears an existing note. Note validation precedes Session lookup and can therefore return `note-blank` or `note-too-large` for a missing Session without touching persistence.
 
 ```yaml
 - id: message-feedback
@@ -55,6 +55,8 @@ A matching-version no-op returns the already stored item with unchanged version
 
 A per-Session promise queue encloses inspection, durability validation, sidecar read, comparison, and whole-row write. These semantics serialize concurrent mutations through one service instance; storage-domain itself has no cross-process conditional write.
 
+Plugin disposal closes mutation admission, drains every operation already accepted into the per-Session queues, and only then closes the storage domain. A mutation submitted after disposal begins rejects as a lifecycle failure instead of entering a closing domain.
+
 ## Model Experience
 
 ### Local message-feedback state
@@ -79,3 +81,4 @@ Independent. Listing or mutating message feedback does not touch a model request
 - **Detach/catalog retirement window** — a request in the narrow interval after live detach but before the persistence catalog materializes the header can receive `session-not-found`; callers retry after retirement materialization.
 - **Header identity is not a content fingerprint** — `{createdAt, cwd}` detects reuse only when those fields differ; a cloned log retaining the same header identity is indistinguishable.
 - **Trusted caller boundary** — `list`/`put`/`delete` carry no authenticated actor or audit identity. A deployment must expose the Host gateway only through its trusted or separately authenticated boundary until authorization and attribution are added.
+- **Catalog and row bounds** — a cold request scans the complete Session snapshot catalog because persistence has no lookup-by-id metadata operation. `maxNoteBytes` bounds one note, but the item count and aggregate retained bytes of one Session row are not capped; an indexed metadata read and deployment-owned row bound remain deferred until a concrete consumer defines their policy.

+ 4 - 1
packages/feedback/message-feedback/README.zh.md

@@ -12,7 +12,7 @@
 |---|---|
 | `maxNoteBytes` | 必填正 safe integer:一条可选备注的最大 UTF-8 字节长度。 |
 
-备注必须包含至少一个非空白字符,但通过校验的文本按原样存储,不会 trim。省略 `note` 表示目标值不含备注,因此通过授权的实质 `put` 会清除已有备注。
+备注必须包含至少一个非空白字符,但通过校验的文本按原样存储,不会 trim。省略 `note` 表示目标值不含备注,因此 version 匹配的实质 `put` 会清除已有备注。备注校验早于 Session 查找,因此即使 Session 不存在,也可能在不访问持久化的情况下返回 `note-blank` 或 `note-too-large`。
 
 ```yaml
 - id: message-feedback
@@ -55,6 +55,8 @@ message feedback 不是 Session 日志内容或 Session 投影。它不发出 `f
 
 按 Session 划分的 promise 队列覆盖检查、持久性校验、伴随记录读取、比较与整行写入。这些语义会串行化经由同一服务实例的并发变更;storage-domain 自身没有跨进程条件写。
 
+Plugin disposal 会先关闭变更接纳,排空已进入各个 Session 队列的所有操作,然后才关闭 storage domain。disposal 开始后提交的变更会以生命周期故障拒绝,不会进入正在关闭的 domain。
+
 ## 模型体验
 
 ### 本地消息反馈状态
@@ -79,3 +81,4 @@ message feedback 不是 Session 日志内容或 Session 投影。它不发出 `f
 - **Detach/catalog retirement 窗口**——请求若恰好落在 live detach 之后、persistence catalog 物化 header 之前的极短窗口,可能收到 `session-not-found`;调用方应在 retirement materialization 后重试。
 - **Header 身份不是内容指纹**——只有 `{createdAt, cwd}` 不同时才能识别复用;本契约无法区分保留相同 header 身份的克隆日志。
 - **调用方边界受信任**——`list`/`put`/`delete` 不携带已认证的 actor 或审计身份。在加入授权与归属信息前,部署方必须只通过受信任或另行认证的边界暴露 Host gateway。
+- **目录与行边界**——由于 persistence 没有按 id 读取元数据的操作,cold 请求会扫描完整的 Session snapshot 目录。`maxNoteBytes` 只限制单条备注,单个 Session 行的条目数和聚合保留字节尚无上限;按索引读取元数据和由部署决定的行边界,延后到具体消费方明确策略时处理。

+ 9 - 1
packages/feedback/message-feedback/src/index.ts

@@ -158,6 +158,7 @@ export class MessageFeedbackService extends GatewayService {
   private readonly maxNoteBytes: number
   private table?: KvTable<SessionId, MessageFeedbackRow>
   private readonly operationTails = new Map<SessionId, Promise<void>>()
+  private mutationAdmissionOpen = true
 
   /**
    * @param ctx - Host context carrying persistence and the storage-domain form.
@@ -171,7 +172,11 @@ export class MessageFeedbackService extends GatewayService {
   /** Open and own the one message-feedback sidecar domain. */
   protected async [Service.init](): Promise<void> {
     const domain = await this.ctx.storageDomain.open(messageFeedbackDomainSpec)
-    this.ctx.effect(() => () => domain.close(), 'message-feedback.domainClose')
+    this.ctx.effect(() => async () => {
+      this.mutationAdmissionOpen = false
+      await Promise.all(this.operationTails.values())
+      await domain.close()
+    }, 'message-feedback.domainClose')
     this.table = domain.table('sessions')
   }
 
@@ -354,6 +359,9 @@ export class MessageFeedbackService extends GatewayService {
 
   /** Queue a complete read/compare/write mutation behind this Session's prior mutation. */
   private enqueue<T>(sessionId: SessionId, operation: () => Promise<T>): Promise<T> {
+    if (!this.mutationAdmissionOpen) {
+      return Promise.reject(new Error('message-feedback: service is disposing'))
+    }
     const previous = this.operationTails.get(sessionId) ?? Promise.resolve()
     const result = previous.then(operation)
     const tail = result.then(() => undefined, () => undefined)

+ 2 - 0
packages/feedback/message-feedback/src/spec.ts

@@ -22,6 +22,8 @@ export const messageFeedbackVersionSchema = z.uuid()
   .transform(value => value as MessageFeedbackVersion)
 
 /** Runtime schema for one current feedback item. */
+// Zod infers transformed branded fields structurally, so it cannot name the
+// public interface even though every branded output is created below.
 export const messageFeedbackItemSchema = z.object({
   messageId: z.string().min(1).transform(value => value as MessageId),
   rating: messageFeedbackRatingSchema,

+ 10 - 5
packages/feedback/message-feedback/tests/helpers.ts

@@ -116,7 +116,7 @@ class TestPersistence extends SessionPersistence {
   inspectFailure: Error | undefined
   inspectCalls = 0
   readFromCalls = 0
-  onReadFrom: (() => void) | undefined
+  onReadFrom: (() => void | Promise<void>) | undefined
   onListSnapshots: (() => void | Promise<void>) | undefined
 
   locate(_meta: SessionHeader): SessionLocation | undefined { return undefined }
@@ -140,16 +140,16 @@ class TestPersistence extends SessionPersistence {
       : Promise.resolve(stored)
   }
 
-  readFrom(
+  async readFrom(
     id: SessionId,
     fromSeq: number,
   ): Promise<{ meta: SessionHeader; events: SessionEvent[] }> {
     this.readFromCalls += 1
-    this.onReadFrom?.()
+    await this.onReadFrom?.()
     const stored = this.durable.get(id)
     return stored === undefined
       ? Promise.reject(new Error(`test persistence: session '${id}' not found`))
-      : Promise.resolve({ meta: stored.meta, events: stored.events.filter(event => event.seq >= fromSeq) })
+      : { meta: stored.meta, events: stored.events.filter(event => event.seq >= fromSeq) }
   }
 
   list(): Promise<SessionHeader[]> {
@@ -177,6 +177,7 @@ export interface TestHarness {
   readonly ctx: Context
   readonly persistence: TestPersistence
   readonly root: string
+  disposeFeedback(): Promise<void>
   dispose(): Promise<void>
 }
 
@@ -184,22 +185,26 @@ export interface TestHarness {
 export async function setupHarness(maxNoteBytes = 64): Promise<TestHarness> {
   const root = await mkdtemp(join(tmpdir(), 'dsh-message-feedback-test-'))
   const ctx = new Context()
+  let disposeFeedback: (() => Promise<void>) | undefined
   try {
     await ctx.plugin(SessionStore)
     await ctx.plugin(TestPersistence)
     await ctx.plugin(Storage)
     await ctx.plugin(StorageJson, { root })
     await ctx.plugin(StorageDomain, { backend: 'json' })
-    await ctx.plugin(MessageFeedbackService, { maxNoteBytes })
+    const feedbackFiber = await ctx.plugin(MessageFeedbackService, { maxNoteBytes })
+    disposeFeedback = feedbackFiber.dispose
   } catch (error) {
     await ctx.fiber.dispose()
     await rm(root, { recursive: true, force: true })
     throw error
   }
+  if (disposeFeedback === undefined) throw new Error('message feedback test plugin did not load')
   return {
     ctx,
     persistence: ctx.sessionPersistence as unknown as TestPersistence,
     root,
+    disposeFeedback,
     async dispose() {
       await ctx.fiber.dispose()
       await rm(root, { recursive: true, force: true })

+ 23 - 0
packages/feedback/message-feedback/tests/invariant.spec.ts

@@ -0,0 +1,23 @@
+import { describe, expect, it } from 'vitest'
+import InvariantService from '@deepseek-ai/dsh-invariants'
+import * as MessageFeedbackInvariant from '../src/invariant.ts'
+import { setupHarness } from './helpers.ts'
+
+describe('message-feedback invariant companion', () => {
+  it('removes its registry contribution when its fiber is disposed (HMR safety)', async () => {
+    const harness = await setupHarness()
+    try {
+      await harness.ctx.plugin(InvariantService)
+      const fiber = await harness.ctx.plugin(MessageFeedbackInvariant)
+
+      expect(() => {
+        harness.ctx.invariants.register('@deepseek-ai/dsh-message-feedback', () => {})
+      }).toThrow(/already registered/u)
+
+      await fiber.dispose()
+      await expect(harness.ctx.plugin(MessageFeedbackInvariant).await()).resolves.toBeDefined()
+    } finally {
+      await harness.dispose()
+    }
+  })
+})

+ 51 - 0
packages/feedback/message-feedback/tests/message-feedback.spec.ts

@@ -459,6 +459,57 @@ describe('MessageFeedbackService item concurrency', () => {
     }))
     expect(newItem.version).not.toBe(oldItem.version)
   })
+
+  it('drains admitted mutations before domain close and rejects later admission', async () => {
+    const current = await harness()
+    const { ctx, persistence } = current
+    const fixture = messageFixture('dispose-quiescence')
+    persistence.persist(fixture.session)
+    const service = ctx.messageFeedback
+    const lifecycle = service as unknown as { readonly mutationAdmissionOpen: boolean }
+    const started = Promise.withResolvers<undefined>()
+    const release = Promise.withResolvers<undefined>()
+    let physicalReads = 0
+    let committed = 0
+    persistence.onReadFrom = async () => {
+      physicalReads += 1
+      if (physicalReads !== 1) return
+      started.resolve(undefined)
+      await release.promise
+    }
+    ctx.on('domain/changed', (change) => {
+      if (change.domain === 'message_feedback') committed += 1
+    })
+
+    const first = service.put({
+      sessionId: fixture.session.id,
+      messageId: fixture.assistantMessageIds[0],
+      rating: 'positive',
+      ifVersion: null,
+    })
+    await started.promise
+    const second = service.put({
+      sessionId: fixture.session.id,
+      messageId: fixture.assistantMessageIds[1],
+      rating: 'negative',
+      ifVersion: null,
+    })
+    const disposal = current.disposeFeedback()
+    await vi.waitFor(() => { expect(lifecycle.mutationAdmissionOpen).toBe(false) })
+
+    await expect(service.delete({
+      sessionId: fixture.session.id,
+      messageId: fixture.assistantMessageIds[0],
+      ifVersion: staleVersion(),
+    })).rejects.toThrow('message-feedback: service is disposing')
+    release.resolve(undefined)
+
+    expectItem(await first)
+    expectItem(await second)
+    await disposal
+    expect(physicalReads).toBe(2)
+    expect(committed).toBe(2)
+  })
 })
 
 describe('MessageFeedbackService durability ordering', () => {

+ 95 - 0
scripts/type-equiv.manifest.json

@@ -1739,6 +1739,101 @@
       "doc": "docs/subsystems/core.md",
       "symbol": "AgentOptions",
       "source": "packages/core/agent/src/runtime-types.ts"
+    },
+    {
+      "doc": "docs/subsystems/feedback.md",
+      "symbol": "MessageFeedbackVersion",
+      "source": "packages/feedback/message-feedback/src/types.ts"
+    },
+    {
+      "doc": "docs/subsystems/feedback.md",
+      "symbol": "MessageFeedbackRating",
+      "source": "packages/feedback/message-feedback/src/types.ts"
+    },
+    {
+      "doc": "docs/subsystems/feedback.md",
+      "symbol": "MessageFeedbackItem",
+      "source": "packages/feedback/message-feedback/src/types.ts"
+    },
+    {
+      "doc": "docs/subsystems/feedback.md",
+      "symbol": "MessageFeedbackListRequest",
+      "source": "packages/feedback/message-feedback/src/types.ts"
+    },
+    {
+      "doc": "docs/subsystems/feedback.md",
+      "symbol": "MessageFeedbackListValue",
+      "source": "packages/feedback/message-feedback/src/types.ts"
+    },
+    {
+      "doc": "docs/subsystems/feedback.md",
+      "symbol": "MessageFeedbackPutRequest",
+      "source": "packages/feedback/message-feedback/src/types.ts"
+    },
+    {
+      "doc": "docs/subsystems/feedback.md",
+      "symbol": "MessageFeedbackDeleteRequest",
+      "source": "packages/feedback/message-feedback/src/types.ts"
+    },
+    {
+      "doc": "docs/subsystems/feedback.md",
+      "symbol": "MessageFeedbackDeleteValue",
+      "source": "packages/feedback/message-feedback/src/types.ts"
+    },
+    {
+      "doc": "docs/subsystems/feedback.md",
+      "symbol": "MessageFeedbackSessionNotFound",
+      "source": "packages/feedback/message-feedback/src/types.ts"
+    },
+    {
+      "doc": "docs/subsystems/feedback.md",
+      "symbol": "MessageFeedbackTargetNotFound",
+      "source": "packages/feedback/message-feedback/src/types.ts"
+    },
+    {
+      "doc": "docs/subsystems/feedback.md",
+      "symbol": "MessageFeedbackVersionConflict",
+      "source": "packages/feedback/message-feedback/src/types.ts"
+    },
+    {
+      "doc": "docs/subsystems/feedback.md",
+      "symbol": "MessageFeedbackNoteBlank",
+      "source": "packages/feedback/message-feedback/src/types.ts"
+    },
+    {
+      "doc": "docs/subsystems/feedback.md",
+      "symbol": "MessageFeedbackNoteTooLarge",
+      "source": "packages/feedback/message-feedback/src/types.ts"
+    },
+    {
+      "doc": "docs/subsystems/feedback.md",
+      "symbol": "MessageFeedbackFailure",
+      "source": "packages/feedback/message-feedback/src/types.ts"
+    },
+    {
+      "doc": "docs/subsystems/feedback.md",
+      "symbol": "MessageFeedbackSuccess",
+      "source": "packages/feedback/message-feedback/src/types.ts"
+    },
+    {
+      "doc": "docs/subsystems/feedback.md",
+      "symbol": "MessageFeedbackRejected",
+      "source": "packages/feedback/message-feedback/src/types.ts"
+    },
+    {
+      "doc": "docs/subsystems/feedback.md",
+      "symbol": "MessageFeedbackListResult",
+      "source": "packages/feedback/message-feedback/src/types.ts"
+    },
+    {
+      "doc": "docs/subsystems/feedback.md",
+      "symbol": "MessageFeedbackPutResult",
+      "source": "packages/feedback/message-feedback/src/types.ts"
+    },
+    {
+      "doc": "docs/subsystems/feedback.md",
+      "symbol": "MessageFeedbackDeleteResult",
+      "source": "packages/feedback/message-feedback/src/types.ts"
     }
   ]
 }

+ 1 - 0
tsconfig.host.json

@@ -14,6 +14,7 @@
     "apps/web/tests/support.ts",
     "apps/web/tests/scaffold-hermetic.e2e.ts",
     "apps/web/tests/minimal-preset.snapshot.ts",
+    "apps/web/tests/message-feedback-protocol.snapshot.ts",
     "apps/web/tests/live-interactions.e2e.ts",
     "apps/web/tests/question-composer.e2e.ts",
     "apps/web/tests/approval-composer.e2e.ts",