From 3cffc777195813f49ded29b3efcefe35cbf1a761 Mon Sep 17 00:00:00 2001
From: ZiyaZhang <199893125+ZiyaZhang@users.noreply.github.com>
Date: Mon, 10 Aug 2026 11:28:38 -0700
Subject: [PATCH] feat(feedback): add durable message feedback backend
---
...6-08-10-message-feedback-sidecar.i18n.yaml | 6 +
.../2026-08-10-message-feedback-sidecar.md | 43 ++
.../2026-08-10-message-feedback-sidecar.zh.md | 43 ++
docs/architecture.i18n.yaml | 4 +-
docs/architecture.md | 1 +
docs/architecture.zh.md | 1 +
docs/capability-seams.i18n.yaml | 4 +-
docs/capability-seams.md | 13 +-
docs/capability-seams.zh.md | 13 +-
docs/config-catalog.i18n.yaml | 4 +-
docs/config-catalog.md | 14 +
docs/config-catalog.zh.md | 14 +
docs/module-graph.i18n.yaml | 4 +-
docs/module-graph.md | 9 +
docs/module-graph.zh.md | 9 +
docs/subsystems/README.i18n.yaml | 4 +-
docs/subsystems/README.md | 1 +
docs/subsystems/README.zh.md | 1 +
docs/subsystems/feedback.i18n.yaml | 6 +
docs/subsystems/feedback.md | 76 +++
docs/subsystems/feedback.zh.md | 76 +++
packages/bundle/web-app/cordis.patch.yml | 5 +
packages/bundle/web-app/package.json | 1 +
packages/feedback/README.i18n.yaml | 4 +-
packages/feedback/README.md | 7 +-
packages/feedback/README.zh.md | 7 +-
.../message-feedback/README.i18n.yaml | 6 +
packages/feedback/message-feedback/README.md | 81 +++
.../feedback/message-feedback/README.zh.md | 81 +++
.../feedback/message-feedback/package.json | 82 +++
.../feedback/message-feedback/src/index.ts | 380 ++++++++++++
.../message-feedback/src/invariant.ts | 27 +
.../feedback/message-feedback/src/spec.ts | 88 +++
.../feedback/message-feedback/src/types.ts | 151 +++++
.../message-feedback/tests/helpers.ts | 206 +++++++
.../tests/loader-composition.spec.ts | 115 ++++
.../tests/message-feedback.spec.ts | 546 ++++++++++++++++++
.../feedback/message-feedback/tsconfig.json | 45 ++
.../tool-cordis/src/api-catalog.ts | 94 +++
pnpm-lock.yaml | 52 ++
scripts/gen-cordis-catalog.ts | 20 +
scripts/gen-doc-graphs.ts | 13 +-
tsconfig.host.json | 1 +
43 files changed, 2333 insertions(+), 25 deletions(-)
create mode 100644 .agents/notes/implemented/architecture/2026-08-10-message-feedback-sidecar.i18n.yaml
create mode 100644 .agents/notes/implemented/architecture/2026-08-10-message-feedback-sidecar.md
create mode 100644 .agents/notes/implemented/architecture/2026-08-10-message-feedback-sidecar.zh.md
create mode 100644 docs/subsystems/feedback.i18n.yaml
create mode 100644 docs/subsystems/feedback.md
create mode 100644 docs/subsystems/feedback.zh.md
create mode 100644 packages/feedback/message-feedback/README.i18n.yaml
create mode 100644 packages/feedback/message-feedback/README.md
create mode 100644 packages/feedback/message-feedback/README.zh.md
create mode 100644 packages/feedback/message-feedback/package.json
create mode 100644 packages/feedback/message-feedback/src/index.ts
create mode 100644 packages/feedback/message-feedback/src/invariant.ts
create mode 100644 packages/feedback/message-feedback/src/spec.ts
create mode 100644 packages/feedback/message-feedback/src/types.ts
create mode 100644 packages/feedback/message-feedback/tests/helpers.ts
create mode 100644 packages/feedback/message-feedback/tests/loader-composition.spec.ts
create mode 100644 packages/feedback/message-feedback/tests/message-feedback.spec.ts
create mode 100644 packages/feedback/message-feedback/tsconfig.json
diff --git a/.agents/notes/implemented/architecture/2026-08-10-message-feedback-sidecar.i18n.yaml b/.agents/notes/implemented/architecture/2026-08-10-message-feedback-sidecar.i18n.yaml
new file mode 100644
index 0000000000..25457df431
--- /dev/null
+++ b/.agents/notes/implemented/architecture/2026-08-10-message-feedback-sidecar.i18n.yaml
@@ -0,0 +1,6 @@
+# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
+# 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: 2c6fe72174e4683793980727da672948793eb45f
+2026-08-10-message-feedback-sidecar.zh.md: ee67d0d694e0502f4f4770484d9505eaa43ad232
diff --git a/.agents/notes/implemented/architecture/2026-08-10-message-feedback-sidecar.md b/.agents/notes/implemented/architecture/2026-08-10-message-feedback-sidecar.md
new file mode 100644
index 0000000000..2c6fe72174
--- /dev/null
+++ b/.agents/notes/implemented/architecture/2026-08-10-message-feedback-sidecar.md
@@ -0,0 +1,43 @@
+# Agent Note: Lifecycle-bound message feedback sidecar
+
+Status: implemented
+
+English | [中文](2026-08-10-message-feedback-sidecar.zh.md)
+
+## Problem
+
+The existing `/feedback` command records an immutable Session-level `feedback/record` event. That event can release a pending telemetry prefix under `FEEDBACK_ONLY`, so it is the wrong authority for an editable positive/negative rating and optional note attached to one assistant message. Message feedback needs independent update and delete semantics without entering the canonical Session log, changing a projection, reaching the model surface, or implicitly consenting to telemetry.
+
+A sidecar keyed only by `SessionId` can outlive the log lifecycle it describes when an id is recreated with a different header identity. A Session-wide revision also makes unrelated message edits conflict, while plain storage-domain read/put has no cross-process compare-and-swap. Session disposal is only live-store detach, not durable deletion, and the current Session persistence seam exposes no deletion operation that could own a truthful cascade.
+
+## Decision
+
+`@deepseek-ai/dsh-message-feedback` owns the `ctx.messageFeedback` service and stores message feedback as one storage-domain sidecar row per Session. The sidecar is neither Session-log content nor a Session projection. It emits no `feedback/record` event and performs no telemetry handoff; the command-feedback and message-feedback contracts remain independent.
+
+Every usable row is bound to the inspected Session header identity `{createdAt, cwd}`, not merely its `SessionId`. A lifecycle mismatch is treated as absence: `list` returns no items, and `put` may replace the stale row with one bound to the current identity. An id reused with a different header identity therefore cannot inherit stale feedback. A fork receives its own Session identity and no sidecar copy: even when the fork seed contains the same assistant messages, feedback remains attached to the Session in which the human recorded it.
+
+`put` accepts a target only when `SessionPersistence.inspect()` observes a non-empty, append-origin `assistant/message` with that `MessageId`. Replacement-origin messages, empty usage-only assistant records, and non-assistant targets are rejected. Inspection is the cold-safe authority: it neither publishes or resumes an Agent nor commits cold-log repair merely to validate feedback. A cold `listSnapshots()` preflight classifies definite absence; inspection failure for a catalogued Session remains an infrastructure failure. A request in the narrow live-detach-to-header-materialization interval can therefore return `session-not-found`, and the caller retries after retirement materialization.
+
+Before `put` commits a sidecar row, it puts the target log behind a durability barrier. A matching live Session passes through the canonical `ctx.sessions.flush` checkpoint; a catalogued cold Session is physically re-read from sequence zero through `SessionPersistence.readFrom`. The resulting observation's header identity and target are checked again. A missing flush participant, changed identity, vanished target, or cold physical-read failure prevents the sidecar write, so a committed feedback item never precedes the durable assistant message it references.
+
+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. An exact retry of an already committed desired value returns that stored item before a stale `ifVersion` becomes a conflict, preserving its version and timestamps; 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.
+
+`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.
+
+The service performs no fake deletion cascade. `session/disposed` and `host/session-removed` describe detach from live ownership, not durable Session deletion, and Session persistence currently has no delete surface. Sidecar rows can therefore remain after out-of-band log removal; a different `{createdAt, cwd}` prevents such an orphan from becoming feedback for a later Session that reuses the id.
+
+## Alternatives considered
+
+**Append edits to the Session log and derive a projection.** Rejected because editable UI metadata would become canonical conversation-adjacent history, forks would replay and inherit it, deletion would require tombstones, and reusing `feedback/record` would silently couple a message rating to telemetry consent.
+
+**Key feedback globally by `MessageId`, copy it on fork, or use one Session revision.** Rejected because message ids are meaningful only within a Session lifecycle, forked conversations need independent human judgments, and unrelated message mutations must not create false conflicts.
+
+**Extend `KvTable` with cross-process compare-and-swap in this change.** Rejected because the shipped storage-domain backends expose no common conditional-write primitive. A process-local queue matches the supported one-Host topology; a real multi-process guarantee requires a backend-level atomic contract and is separate work.
+
+**Delete feedback on Session disposal.** Rejected because disposal includes ordinary detach and rollback paths. Treating it as durable deletion would lose feedback while the Session log still exists; cleanup waits for a real Session deletion authority.
+
+## Consequences
+
+Message feedback is locally durable and independently editable without changing model-visible history or telemetry behavior. Concurrent callers in one Host receive per-message conflict detection and retry-safe outcomes, while deployments with multiple writers to the same storage root remain unsupported. A differing header identity treats a stale row as absent but does not reclaim it; a cloned log that retains the same `{createdAt, cwd}` is indistinguishable by this contract. The Host Remote contract is available now; client assembly and UI can remain thin consumers rather than taking ownership of persistence or concurrency semantics.
diff --git a/.agents/notes/implemented/architecture/2026-08-10-message-feedback-sidecar.zh.md b/.agents/notes/implemented/architecture/2026-08-10-message-feedback-sidecar.zh.md
new file mode 100644
index 0000000000..ee67d0d694
--- /dev/null
+++ b/.agents/notes/implemented/architecture/2026-08-10-message-feedback-sidecar.zh.md
@@ -0,0 +1,43 @@
+# Agent Note: 绑定生命周期的消息反馈伴随记录
+
+Status: implemented
+
+[English](2026-08-10-message-feedback-sidecar.md) | 中文
+
+## 问题
+
+现有 `/feedback` 命令记录不可变的 Session 级 `feedback/record` 事件。在 `FEEDBACK_ONLY` 下,该事件可以释放待处理的遥测前缀,因此它不适合作为挂在单条 assistant 消息上的可编辑好评/差评与可选备注的权威来源。消息反馈需要独立的更新与删除语义,且不得进入权威 Session 日志、改变投影、到达模型接口,或隐式表示遥测同意。
+
+只按 `SessionId` 建索引的伴随记录可能在该 id 以不同 header 身份重建后,继续存活于其所描述的日志生命周期之外。Session 级 revision 还会让无关消息的编辑彼此冲突,而普通 storage-domain 读/写不提供跨进程 compare-and-swap。Session disposal 只是从 live store 脱离,并非持久删除;当前 Session 持久化 seam 也没有可拥有真实级联的删除操作。
+
+## 决策
+
+`@deepseek-ai/dsh-message-feedback` 拥有 `ctx.messageFeedback` 服务,并把消息反馈存为每个 Session 一条 storage-domain 伴随记录(sidecar)。该伴随记录既不是 Session 日志内容,也不是 Session 投影。它不发出 `feedback/record` 事件,也不执行遥测交接;command-feedback 与 message-feedback 契约保持独立。
+
+每条可用记录都绑定到经检查的 Session header 身份 `{createdAt, cwd}`,而不只是其 `SessionId`。生命周期不匹配按不存在处理:`list` 返回空条目,`put` 可以用绑定当前身份的新记录替换陈旧行。因此,以不同 header 身份复用的 id 不会继承陈旧反馈。fork 拥有自己的 Session 身份,且不复制伴随记录:即使 fork 种子包含相同的 assistant 消息,反馈仍只属于人类记录它的那个 Session。
+
+`put` 只接受由 `SessionPersistence.inspect()` 观测到的非空、append-origin `assistant/message`,且其 `MessageId` 必须与目标相同。replacement-origin 消息、仅承载 usage 的空 assistant 记录以及非 assistant 目标都会被拒绝。检查使用 cold-safe 权威路径:它不会仅为验证反馈而发布或恢复 Agent,也不会提交 cold 日志修复。cold 路径由 `listSnapshots()` 预检明确不存在;已进入目录的 Session 若检查失败,仍按基础设施故障处理。因此,请求若恰落在 live detach 到 header materialization 的极短窗口,可能返回 `session-not-found`,调用方在 retirement materialization 后重试。
+
+`put` 提交伴随记录前,会先让目标日志通过 durability barrier。身份匹配的 live Session 经过权威 `ctx.sessions.flush` checkpoint;已进入目录的 cold Session 则通过 `SessionPersistence.readFrom` 从序列零做物理复读。随后再次校验所得观测的 header 身份与目标。缺少 flush 参与方、身份变化、目标消失或 cold 物理读取失败都会阻止伴随记录写入,因此已提交反馈绝不会先于它引用的持久 assistant 消息。
+
+每个消息条目都携带自己的 opaque version,以及 Host 分配的 `createdAt` 和 `updatedAt` 时间戳。`put` 只把调用方的 `ifVersion` 与目标条目比较,因此编辑一条消息不会使另一条消息失效。对已提交目标值的完全相同重试,会在陈旧 `ifVersion` 形成冲突前返回已存条目,并保留其 version 与时间戳;实质更新保留 `createdAt`、替换 version,并保证 `updatedAt` 不倒退。删除已经不存在的条目也同样成功。version 是只能做相等比较的 token,不是调用方可以排序或自行合成的计数器。
+
+按 Session 划分的变更队列覆盖生命周期检查、伴随记录读取、冲突判断与整行写入。这使同一个服务实例的变更串行化,并在单个 Host 进程内保持逐消息 compare-and-swap 契约。底层 storage-domain API 不提供跨进程条件写,因此实现不承诺跨进程线性一致性或防止丢失更新。
+
+`maxNoteBytes` 是必填的部署选择,用于限制可选备注的 UTF-8 字节长度;Web Host bundle 将其显式设为 `8192`。该包通过 `GatewayService` 与 `@Remote` 直接发布 Host `messageFeedback.list`、`messageFeedback.put` 与 `messageFeedback.delete` 契约。客户端 Remote 聚合挂载与 UI 由各自边界负责并保持延后;后续适配层只是该 Host 契约的薄消费者。
+
+服务不伪造删除级联。`session/disposed` 与 `host/session-removed` 表示脱离 live ownership,而非持久删除,Session persistence 当前也没有删除接口。因此在带外移除日志后,伴随记录可能继续存在;不同的 `{createdAt, cwd}` 可阻止此类孤儿记录变成后来复用该 id 的 Session 反馈。
+
+## 考虑过的替代方案
+
+**把编辑追加到 Session 日志并派生投影。** 不予采纳,因为可编辑 UI 元数据会变成权威且邻近对话的历史,fork 会回放并继承它,删除需要 tombstone,而复用 `feedback/record` 会把消息评分与遥测同意静默耦合。
+
+**按全局 `MessageId` 建索引、在 fork 时复制,或使用一个 Session revision。** 不予采纳,因为消息 id 仅在某个 Session 生命周期内有意义,fork 后的对话需要独立的人类判断,而且无关消息的变更不应制造虚假冲突。
+
+**在本次变更中为 `KvTable` 扩展跨进程 compare-and-swap。** 不予采纳,因为出厂 storage-domain 后端没有共同的条件写原语。进程内队列符合受支持的单 Host 拓扑;真实的多进程保证需要后端级原子契约,属于独立工作。
+
+**在 Session disposal 时删除反馈。** 不予采纳,因为 disposal 包含普通 detach 与 rollback 路径。把它当成持久删除会在 Session 日志仍存在时丢失反馈;清理必须等待真正的 Session 删除权威。
+
+## 后果
+
+消息反馈在本地持久化并可独立编辑,且不改变模型可见历史或遥测行为。同一 Host 中的并发调用方获得逐消息冲突检测与可安全重试的结果;多个写入者共享同一存储根目录的部署仍不受支持。不同的 header 身份会让陈旧记录被视为不存在,但不会将其回收;本契约无法区分保留相同 `{createdAt, cwd}` 的克隆日志。Host Remote 契约现在可用;客户端组装与 UI 可以保持为薄消费者,而不接管持久化或并发语义。
diff --git a/docs/architecture.i18n.yaml b/docs/architecture.i18n.yaml
index 344bac8145..ea8b934c79 100644
--- a/docs/architecture.i18n.yaml
+++ b/docs/architecture.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/architecture.md
-architecture.md: 8d1c5a1be391e2455aefc69db89d96027aaf3efa
-architecture.zh.md: 53bf9f54a5503ae40aa62a348822f9d92162dda2
+architecture.md: aeda7f9674e75a1e97549f25c13d571b3b37ee8c
+architecture.zh.md: a25f20ba9babefeaab4636027e97d6f2d4ae8caf
diff --git a/docs/architecture.md b/docs/architecture.md
index 8d1c5a1be3..aeda7f9674 100644
--- a/docs/architecture.md
+++ b/docs/architecture.md
@@ -42,6 +42,7 @@ Harnesses are [Cordis](cordis-primer.md) contexts; packages contribute services,
| `ctx.tasks` | [`tasks/`](../packages/tasks/README.md) | background task registry, generic `task_*` controls |
| `ctx.workflows` | [`workflow/`](../packages/workflow/README.md) | script-driven multi-agent orchestration |
| `ctx.goals` | [`goal/`](../packages/goal/README.md) | persisted same-session goals |
+| `ctx.messageFeedback` | [`feedback/`](../packages/feedback/README.md) | lifecycle-bound editable feedback for individual assistant messages and its Host Remote contract |
| `ctx.sessionPersistence` | [`session/`](../packages/session/README.md) | durable session-log storage |
| `ctx.sessionQuery` | [`session-query/`](../packages/session-query/README.md) | live-preferred exact/filter/trace queries over SQLite FTS, workspace-authorized model tools |
| `ctx.sessionTitle` | [`session/session-title`](../packages/session/README.md) | log-backed fallbacks, one optional asynchronous provider |
diff --git a/docs/architecture.zh.md b/docs/architecture.zh.md
index 53bf9f54a5..a25f20ba9b 100644
--- a/docs/architecture.zh.md
+++ b/docs/architecture.zh.md
@@ -42,6 +42,7 @@
| `ctx.tasks` | [`tasks/`](../packages/tasks/README.md) | 后台任务注册表和通用 `task_*` 控制 |
| `ctx.workflows` | [`workflow/`](../packages/workflow/README.md) | 脚本驱动的多 agent 编排 |
| `ctx.goals` | [`goal/`](../packages/goal/README.md) | 持久化的同会话目标 |
+| `ctx.messageFeedback` | [`feedback/`](../packages/feedback/README.md) | 绑定生命周期的单条 assistant 消息可编辑反馈及其 Host Remote 契约 |
| `ctx.sessionPersistence` | [`session/`](../packages/session/README.md) | 会话日志的持久化存储 |
| `ctx.sessionQuery` | [`session-query/`](../packages/session-query/README.md) | 基于 SQLite 全文搜索的实时优先精确检索/过滤/追踪、经工作区授权的模型工具 |
| `ctx.sessionTitle` | [`session/session-title`](../packages/session/README.md) | 基于日志的回退标题和单个可选异步提供方 |
diff --git a/docs/capability-seams.i18n.yaml b/docs/capability-seams.i18n.yaml
index d6c4832447..c0091e400e 100644
--- a/docs/capability-seams.i18n.yaml
+++ b/docs/capability-seams.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/capability-seams.md
-capability-seams.md: c102167aa76b9ba613b1b434cb0aa58765d26106
-capability-seams.zh.md: 7c4eab8d5a2d890bdf4513bb9acdc81f55414642
+capability-seams.md: 64d20b1bfb609aec3acb3673e4589516bb315bfa
+capability-seams.zh.md: 10b5116d12991319df55c551d51840bb566ac898
diff --git a/docs/capability-seams.md b/docs/capability-seams.md
index c102167aa7..64d20b1bfb 100644
--- a/docs/capability-seams.md
+++ b/docs/capability-seams.md
@@ -30,6 +30,7 @@ flowchart LR
pkg_session_query_sqlite["session-query-sqlite"]
pkg_subagent_inprocess["subagent-inprocess"]
pkg_invariants["invariants"]
+ pkg_message_feedback["message-feedback"]
svc_invariants["ctx.invariants
Package-owned invariant registry"]
pkg_scope["scope"]
pkg_typert_registry["typert-registry"]
@@ -60,6 +61,7 @@ flowchart LR
pkg_storage_domain["storage-domain"]
svc_storageDomain["ctx.storageDomain
Domain data facility"]
pkg_workspace["workspace"]
+ svc_messageFeedback["ctx.messageFeedback
Lifecycle-bound message feedback"]
svc_workspace["ctx.workspace
Workspace entity registry"]
svc_sessionQuery["ctx.sessionQuery
Session reads, traces, filters, and search"]
pkg_session_reference["session-reference"]
@@ -218,6 +220,7 @@ flowchart LR
pkg_llm_deepseek --> svc_llm
pkg_llm_pi_ai --> svc_llm
pkg_llm_replay --> svc_llm
+ pkg_message_feedback --> svc_messageFeedback
pkg_modules --> svc_clientModuleHost
pkg_permission --> svc_permission
pkg_plan_mode --> svc_planMode
@@ -322,6 +325,7 @@ flowchart LR
svc_sessionPersistence --> pkg_agent_loop
svc_sessionPersistence --> pkg_hooks_claude
svc_sessionPersistence --> pkg_hooks_codex
+ svc_sessionPersistence --> pkg_message_feedback
svc_sessionPersistence --> pkg_session_query
svc_sessionPersistence --> pkg_session_query_sqlite
svc_sessionPersistence --> pkg_tool_bash
@@ -334,6 +338,7 @@ flowchart LR
svc_sessions --> pkg_agent
svc_sessions --> pkg_agent_loop
svc_sessions --> pkg_invariants
+ svc_sessions --> pkg_message_feedback
svc_sessions --> pkg_session_persistence
svc_sessions --> pkg_session_query
svc_sessions --> pkg_session_query_sqlite
@@ -344,6 +349,7 @@ flowchart LR
svc_skills --> pkg_tool_skill
svc_spillStore --> pkg_spill_policy
svc_storage --> pkg_storage_domain
+ svc_storageDomain --> pkg_message_feedback
svc_storageDomain --> pkg_workspace
svc_subagents --> pkg_tool_ralph
svc_subagents --> pkg_tool_subagent
@@ -392,16 +398,17 @@ flowchart LR
| `ctx.llm` | `seam` | [`llm`](../packages/llm/llm) | [`llm-deepseek`](../packages/llm/llm-deepseek), [`llm-pi-ai`](../packages/llm/llm-pi-ai), [`llm-replay`](../packages/support/llm-replay) | [`agent-loop`](../packages/core/agent-loop), [`compact-basic`](../packages/compact/compact-basic) | - | Adapters register provider implementations; the loop and compaction call the provider-neutral stream service. |
| `ctx.tokenMeter` | `core` | [`token-meter`](../packages/llm/token-meter) | - | [`compact-basic`](../packages/compact/compact-basic) | - | Owns isolated per-session replay folds; pressure consumers share immutable revisioned measurements. |
| `ctx.toolResultPrune` | `core` | [`compact-tool-result-prune`](../packages/compact/compact-tool-result-prune) | - | [`compact-basic`](../packages/compact/compact-basic) | - | Rewrites oversized current tool results through replayable single-node surface replacements before summary compaction. |
-| `ctx.sessions` | `core` | [`session`](../packages/core/session) | - | [`agent-loop`](../packages/core/agent-loop), [`agent`](../packages/core/agent), [`session-persistence`](../packages/session/session-persistence), [`session-query`](../packages/session-query/session-query), [`session-query-sqlite`](../packages/session-query/session-query-sqlite), [`subagent-inprocess`](../packages/subagent/subagent-inprocess), [`invariants`](../packages/support/invariants) | - | Owns append-only Session instances and emits the durable session event feed. |
+| `ctx.sessions` | `core` | [`session`](../packages/core/session) | - | [`agent-loop`](../packages/core/agent-loop), [`agent`](../packages/core/agent), [`session-persistence`](../packages/session/session-persistence), [`session-query`](../packages/session-query/session-query), [`session-query-sqlite`](../packages/session-query/session-query-sqlite), [`subagent-inprocess`](../packages/subagent/subagent-inprocess), [`invariants`](../packages/support/invariants), [`message-feedback`](../packages/feedback/message-feedback) | - | Owns append-only Session instances and emits the durable session event feed. |
| `ctx.invariants` | `core` | [`invariants`](../packages/support/invariants) | - | [`session`](../packages/core/session), [`agent`](../packages/core/agent), [`scope`](../packages/core/scope), [`agent-loop`](../packages/core/agent-loop) | - | Companion subpaths register owner-local checks; the service owns selection, uniqueness, child fibers, and package-attributed failures. |
| `ctx.typert` | `core` | [`typert-registry`](../packages/typert/registry) | - | [`typert-loader`](../packages/typert/loader), [`api-gateway`](../packages/api/gateway) | - | Plugins register live zod contributions directly or through dsh-typert-loader; the API gateway consumes invocation descriptors and providers, while other runtime consumers query schemas and reflection metadata at their own edges. |
| `ctx.typertGateway` | `core` | [`api-gateway`](../packages/api/gateway) | - | - | - | Associates generated Remote descriptors with live Cordis services, resolves registered identities, and exposes unary calls through the shared Connection RPC carrier. |
-| `ctx.sessionPersistence` | `seam` | [`session-persistence`](../packages/session/session-persistence) | [`session-persistence-jsonl`](../packages/session/session-persistence-jsonl), [`session-persistence-sqlite`](../packages/session/session-persistence-sqlite) | [`agent-loop`](../packages/core/agent-loop), [`tool-bash`](../packages/bash/tool-bash), [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex), [`session-query`](../packages/session-query/session-query), [`session-query-sqlite`](../packages/session-query/session-query-sqlite) | - | Backends persist the same SessionEvent vocabulary; apps choose a backend at composition time. |
+| `ctx.sessionPersistence` | `seam` | [`session-persistence`](../packages/session/session-persistence) | [`session-persistence-jsonl`](../packages/session/session-persistence-jsonl), [`session-persistence-sqlite`](../packages/session/session-persistence-sqlite) | [`agent-loop`](../packages/core/agent-loop), [`tool-bash`](../packages/bash/tool-bash), [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex), [`session-query`](../packages/session-query/session-query), [`session-query-sqlite`](../packages/session-query/session-query-sqlite), [`message-feedback`](../packages/feedback/message-feedback) | - | Backends persist the same SessionEvent vocabulary; apps choose a backend at composition time. |
| `ctx.settings` | `seam` | [`settings`](../packages/settings/settings) | [`settings-local`](../packages/settings/settings-local) | [`llm-deepseek`](../packages/llm/llm-deepseek), [`llm-pi-ai`](../packages/llm/llm-pi-ai), `apiproxy` | - | Plugins register namespace schemas and resolve layered values; providers store the raw document. The LLM adapters register their entry config as the composition base under the user section; the web gateway serves redacted layered descriptors and writes the user layer. |
| `ctx.credentials` | `seam` | [`credentials`](../packages/credentials/credentials) | [`credentials-local`](../packages/credentials/credentials-local) | [`llm-deepseek`](../packages/llm/llm-deepseek), [`llm-pi-ai`](../packages/llm/llm-pi-ai), `apiproxy` | - | Configuration carries references to secrets; providers own the values. Consumers resolve per operation, so a rotated credential reaches the very next request; the web gateway exposes value-free views and write-only storage. |
| `ctx.telemetry` | `seam` | [`session-telemetry`](../packages/session/session-telemetry) | [`session-telemetry-otel`](../packages/session/session-telemetry-otel) | - | - | The seam captures, redacts, and hands session records to one backend; nothing else consumes the service — its output leaves the process. |
| `ctx.storage` | `seam` | [`storage`](../packages/storage/storage) | [`storage-json`](../packages/storage/storage-json), [`storage-sqlite`](../packages/storage/storage-sqlite) | [`storage-domain`](../packages/storage/storage-domain) | - | Backends register side by side under names; data forms (domain first) mount on the hub and translate typed operations into opaque KV-unit primitives. |
-| `ctx.storageDomain` | `core` | [`storage-domain`](../packages/storage/storage-domain) | - | [`workspace`](../packages/workspace/workspace) | - | Waits for every configured backend, then publishes the domain form as one lifecycle-bound service for typed durable state. |
+| `ctx.storageDomain` | `core` | [`storage-domain`](../packages/storage/storage-domain) | - | [`workspace`](../packages/workspace/workspace), [`message-feedback`](../packages/feedback/message-feedback) | - | Waits for every configured backend, then publishes the domain form as one lifecycle-bound service for typed durable state. |
+| `ctx.messageFeedback` | `core` | [`message-feedback`](../packages/feedback/message-feedback) | - | - | - | Owns local per-assistant-message feedback, lifecycle and target validation, per-item compare-and-set, and the Host unary Remote contract without entering Session history or telemetry. |
| `ctx.workspace` | `core` | [`workspace`](../packages/workspace/workspace) | - | `apiproxy` | - | Owns WorkspaceId-branded records over the domain facility; stable sessionIds accounts drive Host RPC and GUI projections. |
| `ctx.sessionQuery` | `seam` | [`session-query`](../packages/session-query/session-query) | [`session-query-sqlite`](../packages/session-query/session-query-sqlite) | [`session-reference`](../packages/context/session-reference), [`tool-session-query`](../packages/session-query/tool-session-query) | - | The interface supplies exact reads, filters, and traces; its concrete backend adds full-text reconciliation, ranking, snippets, and cursor generations, while the model consumer owns workspace authority and cursor-free rendering. |
| `ctx.sessionReferences` | `core` | [`session-reference`](../packages/context/session-reference) | - | - | - | Projects bounded current-surface conversation snapshots into durable untrusted message context; host adapters own mention syntax. |
diff --git a/docs/capability-seams.zh.md b/docs/capability-seams.zh.md
index 7c4eab8d5a..10b5116d12 100644
--- a/docs/capability-seams.zh.md
+++ b/docs/capability-seams.zh.md
@@ -32,6 +32,7 @@ flowchart LR
pkg_session_query_sqlite["session-query-sqlite"]
pkg_subagent_inprocess["subagent-inprocess"]
pkg_invariants["invariants"]
+ pkg_message_feedback["message-feedback"]
svc_invariants["ctx.invariants
Package-owned invariant registry"]
pkg_scope["scope"]
pkg_typert_registry["typert-registry"]
@@ -62,6 +63,7 @@ flowchart LR
pkg_storage_domain["storage-domain"]
svc_storageDomain["ctx.storageDomain
Domain data facility"]
pkg_workspace["workspace"]
+ svc_messageFeedback["ctx.messageFeedback
Lifecycle-bound message feedback"]
svc_workspace["ctx.workspace
Workspace entity registry"]
svc_sessionQuery["ctx.sessionQuery
Session reads, traces, filters, and search"]
pkg_session_reference["session-reference"]
@@ -220,6 +222,7 @@ flowchart LR
pkg_llm_deepseek --> svc_llm
pkg_llm_pi_ai --> svc_llm
pkg_llm_replay --> svc_llm
+ pkg_message_feedback --> svc_messageFeedback
pkg_modules --> svc_clientModuleHost
pkg_permission --> svc_permission
pkg_plan_mode --> svc_planMode
@@ -324,6 +327,7 @@ flowchart LR
svc_sessionPersistence --> pkg_agent_loop
svc_sessionPersistence --> pkg_hooks_claude
svc_sessionPersistence --> pkg_hooks_codex
+ svc_sessionPersistence --> pkg_message_feedback
svc_sessionPersistence --> pkg_session_query
svc_sessionPersistence --> pkg_session_query_sqlite
svc_sessionPersistence --> pkg_tool_bash
@@ -336,6 +340,7 @@ flowchart LR
svc_sessions --> pkg_agent
svc_sessions --> pkg_agent_loop
svc_sessions --> pkg_invariants
+ svc_sessions --> pkg_message_feedback
svc_sessions --> pkg_session_persistence
svc_sessions --> pkg_session_query
svc_sessions --> pkg_session_query_sqlite
@@ -346,6 +351,7 @@ flowchart LR
svc_skills --> pkg_tool_skill
svc_spillStore --> pkg_spill_policy
svc_storage --> pkg_storage_domain
+ svc_storageDomain --> pkg_message_feedback
svc_storageDomain --> pkg_workspace
svc_subagents --> pkg_tool_ralph
svc_subagents --> pkg_tool_subagent
@@ -394,16 +400,17 @@ flowchart LR
| `ctx.llm` | `seam` | [`llm`](../packages/llm/llm) | [`llm-deepseek`](../packages/llm/llm-deepseek)、[`llm-pi-ai`](../packages/llm/llm-pi-ai)、[`llm-replay`](../packages/support/llm-replay) | [`agent-loop`](../packages/core/agent-loop)、[`compact-basic`](../packages/compact/compact-basic) | - | 适配器注册提供方实现;agent loop(智能体循环)与压缩功能调用提供方无关的流服务。 |
| `ctx.tokenMeter` | `core` | [`token-meter`](../packages/llm/token-meter) | - | [`compact-basic`](../packages/compact/compact-basic) | - | 拥有按会话隔离的回放折叠区;压力消费方共享不可变且带修订版本的测量结果。 |
| `ctx.toolResultPrune` | `core` | [`compact-tool-result-prune`](../packages/compact/compact-tool-result-prune) | - | [`compact-basic`](../packages/compact/compact-basic) | - | 在摘要压缩前,通过可回放的单节点表层替换来改写过大的当前工具结果。 |
-| `ctx.sessions` | `core` | [`session`](../packages/core/session) | - | [`agent-loop`](../packages/core/agent-loop)、[`agent`](../packages/core/agent)、[`session-persistence`](../packages/session/session-persistence)、[`session-query`](../packages/session-query/session-query)、[`session-query-sqlite`](../packages/session-query/session-query-sqlite)、[`subagent-inprocess`](../packages/subagent/subagent-inprocess)、[`invariants`](../packages/support/invariants) | - | 拥有仅追加的 Session 实例,并发出持久的会话事件流。 |
+| `ctx.sessions` | `core` | [`session`](../packages/core/session) | - | [`agent-loop`](../packages/core/agent-loop)、[`agent`](../packages/core/agent)、[`session-persistence`](../packages/session/session-persistence)、[`session-query`](../packages/session-query/session-query)、[`session-query-sqlite`](../packages/session-query/session-query-sqlite)、[`subagent-inprocess`](../packages/subagent/subagent-inprocess)、[`invariants`](../packages/support/invariants)、[`message-feedback`](../packages/feedback/message-feedback) | - | 拥有仅追加的 Session 实例,并发出持久的会话事件流。 |
| `ctx.invariants` | `core` | [`invariants`](../packages/support/invariants) | - | [`session`](../packages/core/session)、[`agent`](../packages/core/agent)、[`scope`](../packages/core/scope)、[`agent-loop`](../packages/core/agent-loop) | - | 配套子路径注册所属包本地的检查;该服务负责选择、唯一性、子 fiber,以及标明所属包的失败。 |
| `ctx.typert` | `core` | [`typert-registry`](../packages/typert/registry) | - | [`typert-loader`](../packages/typert/loader)、[`api-gateway`](../packages/api/gateway) | - | 插件直接或通过 dsh-typert-loader 注册实时 zod 贡献;API 网关消费调用描述符和提供方,其他运行时消费方则在各自边界查询 schema 与反射元数据。 |
| `ctx.typertGateway` | `core` | [`api-gateway`](../packages/api/gateway) | - | - | - | 将生成的 Remote 描述符与实时 Cordis 服务关联,解析已注册的身份,并通过共享的 Connection RPC 载体提供一元调用。 |
-| `ctx.sessionPersistence` | `seam` | [`session-persistence`](../packages/session/session-persistence) | [`session-persistence-jsonl`](../packages/session/session-persistence-jsonl)、[`session-persistence-sqlite`](../packages/session/session-persistence-sqlite) | [`agent-loop`](../packages/core/agent-loop)、[`tool-bash`](../packages/bash/tool-bash)、[`hooks-claude`](../packages/hooks/hooks-claude)、[`hooks-codex`](../packages/hooks/hooks-codex)、[`session-query`](../packages/session-query/session-query)、[`session-query-sqlite`](../packages/session-query/session-query-sqlite) | - | 各后端持久化同一套 SessionEvent 词汇;应用在组合时选择后端。 |
+| `ctx.sessionPersistence` | `seam` | [`session-persistence`](../packages/session/session-persistence) | [`session-persistence-jsonl`](../packages/session/session-persistence-jsonl)、[`session-persistence-sqlite`](../packages/session/session-persistence-sqlite) | [`agent-loop`](../packages/core/agent-loop)、[`tool-bash`](../packages/bash/tool-bash)、[`hooks-claude`](../packages/hooks/hooks-claude)、[`hooks-codex`](../packages/hooks/hooks-codex)、[`session-query`](../packages/session-query/session-query)、[`session-query-sqlite`](../packages/session-query/session-query-sqlite)、[`message-feedback`](../packages/feedback/message-feedback) | - | 各后端持久化同一套 SessionEvent 词汇;应用在组合时选择后端。 |
| `ctx.settings` | `seam` | [`settings`](../packages/settings/settings) | [`settings-local`](../packages/settings/settings-local) | [`llm-deepseek`](../packages/llm/llm-deepseek)、[`llm-pi-ai`](../packages/llm/llm-pi-ai)、`apiproxy` | - | 插件注册命名空间 schema 并解析分层值;提供方存储原始文档。LLM(大语言模型)适配器在用户分区下将其入口配置注册为组合基础;Web 网关提供经过脱敏的分层描述符,并写入用户层。 |
| `ctx.credentials` | `seam` | [`credentials`](../packages/credentials/credentials) | [`credentials-local`](../packages/credentials/credentials-local) | [`llm-deepseek`](../packages/llm/llm-deepseek)、[`llm-pi-ai`](../packages/llm/llm-pi-ai)、`apiproxy` | - | 配置携带对机密信息的引用;提供方拥有实际值。消费方按操作解析,因此轮换后的凭据会在紧接着的下一次请求中生效;Web 网关提供不含实际值的视图和只写存储。 |
| `ctx.telemetry` | `seam` | [`session-telemetry`](../packages/session/session-telemetry) | [`session-telemetry-otel`](../packages/session/session-telemetry-otel) | - | - | 该 seam 捕获会话记录、进行脱敏并交给一个后端;没有其他组件消费该服务,其输出会离开当前进程。 |
| `ctx.storage` | `seam` | [`storage`](../packages/storage/storage) | [`storage-json`](../packages/storage/storage-json)、[`storage-sqlite`](../packages/storage/storage-sqlite) | [`storage-domain`](../packages/storage/storage-domain) | - | 各后端以不同名称并列注册;数据形态(领域优先)挂载到枢纽上,并将类型化操作转换为不透明的 KV 单元原语。 |
-| `ctx.storageDomain` | `core` | [`storage-domain`](../packages/storage/storage-domain) | - | [`workspace`](../packages/workspace/workspace) | - | 等待所有已配置后端就绪,然后将领域形态发布为一个受生命周期约束的服务,用于类型化持久状态。 |
+| `ctx.storageDomain` | `core` | [`storage-domain`](../packages/storage/storage-domain) | - | [`workspace`](../packages/workspace/workspace)、[`message-feedback`](../packages/feedback/message-feedback) | - | 等待所有已配置后端就绪,然后将领域形态发布为一个受生命周期约束的服务,用于类型化持久状态。 |
+| `ctx.messageFeedback` | `core` | [`message-feedback`](../packages/feedback/message-feedback) | - | - | - | 拥有本地逐 assistant 消息反馈、生命周期与目标校验、逐条目 compare-and-set 及 Host 一元 Remote 契约,且不进入 Session 历史或遥测。 |
| `ctx.workspace` | `core` | [`workspace`](../packages/workspace/workspace) | - | `apiproxy` | - | 通过领域设施拥有带 WorkspaceId 品牌类型的记录;稳定的 sessionIds 账户驱动 Host RPC 与 GUI 投影。 |
| `ctx.sessionQuery` | `seam` | [`session-query`](../packages/session-query/session-query) | [`session-query-sqlite`](../packages/session-query/session-query-sqlite) | [`session-reference`](../packages/context/session-reference)、[`tool-session-query`](../packages/session-query/tool-session-query) | - | 该接口提供精确读取、过滤和追踪;具体后端还提供全文协调、排序、摘要片段和游标世代,而模型消费方负责工作区权限与不含游标的渲染。 |
| `ctx.sessionReferences` | `core` | [`session-reference`](../packages/context/session-reference) | - | - | - | 将当前表层中有界的对话快照投影为持久但不可信的消息上下文;Host 适配器负责提及语法。 |
diff --git a/docs/config-catalog.i18n.yaml b/docs/config-catalog.i18n.yaml
index a33d28a415..3693fdf977 100644
--- a/docs/config-catalog.i18n.yaml
+++ b/docs/config-catalog.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/config-catalog.md
-config-catalog.md: 4110b89d871605e4286828f229fc45b62df156d8
-config-catalog.zh.md: 3cf2035865f36f18b2395bf8f161157bc5ee9f32
+config-catalog.md: 6e7d6d6214af81cc89c7578454b0004ca0c52e1f
+config-catalog.zh.md: e3425e0ab74d2ba8a879a1cfc1acf0d79518743b
diff --git a/docs/config-catalog.md b/docs/config-catalog.md
index 4110b89d87..6e7d6d6214 100644
--- a/docs/config-catalog.md
+++ b/docs/config-catalog.md
@@ -1129,6 +1129,20 @@ export interface ReconnectConfig {
Source: [`packages/mcp/mcp-client/src/index.ts:98`](../packages/mcp/mcp-client/src/index.ts)
+## `@deepseek-ai/dsh-message-feedback`
+
+Requires: `storageDomain` · `sessionPersistence` · `sessions`
+
+```ts config-catalog
+/** Required deployment policy for optional notes. */
+export interface Config {
+ /** Maximum UTF-8 byte length accepted for one note. */
+ readonly maxNoteBytes: number
+}
+```
+
+Source: [`packages/feedback/message-feedback/src/index.ts:49`](../packages/feedback/message-feedback/src/index.ts)
+
## `@deepseek-ai/dsh-permission`
Requires: `bash` · `approval` · `sessions`
diff --git a/docs/config-catalog.zh.md b/docs/config-catalog.zh.md
index 3cf2035865..e3425e0ab7 100644
--- a/docs/config-catalog.zh.md
+++ b/docs/config-catalog.zh.md
@@ -1131,6 +1131,20 @@ export interface ReconnectConfig {
来源:[`packages/mcp/mcp-client/src/index.ts:94`](../packages/mcp/mcp-client/src/index.ts)
+## `@deepseek-ai/dsh-message-feedback`
+
+需要:`storageDomain` · `sessionPersistence` · `sessions`
+
+```ts config-catalog
+/** Required deployment policy for optional notes. */
+export interface Config {
+ /** Maximum UTF-8 byte length accepted for one note. */
+ readonly maxNoteBytes: number
+}
+```
+
+来源:[`packages/feedback/message-feedback/src/index.ts:49`](../packages/feedback/message-feedback/src/index.ts)
+
## `@deepseek-ai/dsh-permission`
需要:`bash` · `approval` · `sessions`
diff --git a/docs/module-graph.i18n.yaml b/docs/module-graph.i18n.yaml
index b7483ea865..d54af9af70 100644
--- a/docs/module-graph.i18n.yaml
+++ b/docs/module-graph.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/module-graph.md
-module-graph.md: 57577f59f3c773c51680b1e1c6bf53b0bbba4dfa
-module-graph.zh.md: 3f97c65e091f3dc0f1ccba23b6984610e445fe73
+module-graph.md: 7eac17bea2849bcc3730b4165eb55de4457ec66d
+module-graph.zh.md: e6a8758f50481e849ccf4c33794b5468d81601ee
diff --git a/docs/module-graph.md b/docs/module-graph.md
index 57577f59f3..7eac17bea2 100644
--- a/docs/module-graph.md
+++ b/docs/module-graph.md
@@ -200,6 +200,7 @@ flowchart TD
end
subgraph group_feedback["packages/feedback"]
pkg_command_feedback["command-feedback"]
+ pkg_message_feedback["message-feedback"]
end
subgraph group_guard["packages/guard"]
pkg_repeat_tool_guard["repeat-tool-guard"]
@@ -570,6 +571,13 @@ flowchart TD
pkg_time_context --> pkg_agent
pkg_time_context --> pkg_invariants
pkg_time_context --> pkg_session
+ pkg_message_feedback --> pkg_brand
+ pkg_message_feedback --> pkg_invariants
+ pkg_message_feedback --> pkg_llm
+ pkg_message_feedback --> pkg_session
+ pkg_message_feedback --> pkg_session_persistence
+ pkg_message_feedback --> pkg_storage_domain
+ pkg_message_feedback --> pkg_type_meta
pkg_host_apiproxy --> pkg_agent_presets
pkg_host_apiproxy --> pkg_invariants
pkg_host_directory_picker_auto --> pkg_host_directory_picker_browse
@@ -1342,6 +1350,7 @@ flowchart TD
| [`spill-local`](../packages/spill/spill-local) | `spill` | [`invariants`](../packages/support/invariants), [`spill`](../packages/spill/spill) |
| [`loader-smoke`](../packages/support/loader-smoke) | `support` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session) |
| [`time-context`](../packages/context/time-context) | `context` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`session`](../packages/core/session) |
+| [`message-feedback`](../packages/feedback/message-feedback) | `feedback` | [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`storage-domain`](../packages/storage/storage-domain), [`type-meta`](../packages/typert/type-meta) |
| [`host-apiproxy`](../packages/host/apiproxy) | `host` | [`agent-presets`](../packages/preset/agent-presets), [`invariants`](../packages/support/invariants) |
| [`host-directory-picker-auto`](../packages/host/directory-picker-auto) | `host` | [`host-directory-picker-browse`](../packages/host/directory-picker-browse), [`host-directory-picker-native`](../packages/host/directory-picker-native), [`host-webserver`](../packages/host/webserver), [`invariants`](../packages/support/invariants) |
| [`commands`](../packages/interaction/commands) | `interaction` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants), [`scope`](../packages/core/scope), [`session`](../packages/core/session) |
diff --git a/docs/module-graph.zh.md b/docs/module-graph.zh.md
index 3f97c65e09..e6a8758f50 100644
--- a/docs/module-graph.zh.md
+++ b/docs/module-graph.zh.md
@@ -202,6 +202,7 @@ flowchart TD
end
subgraph group_feedback["packages/feedback"]
pkg_command_feedback["command-feedback"]
+ pkg_message_feedback["message-feedback"]
end
subgraph group_guard["packages/guard"]
pkg_repeat_tool_guard["repeat-tool-guard"]
@@ -572,6 +573,13 @@ flowchart TD
pkg_time_context --> pkg_agent
pkg_time_context --> pkg_invariants
pkg_time_context --> pkg_session
+ pkg_message_feedback --> pkg_brand
+ pkg_message_feedback --> pkg_invariants
+ pkg_message_feedback --> pkg_llm
+ pkg_message_feedback --> pkg_session
+ pkg_message_feedback --> pkg_session_persistence
+ pkg_message_feedback --> pkg_storage_domain
+ pkg_message_feedback --> pkg_type_meta
pkg_host_apiproxy --> pkg_agent_presets
pkg_host_apiproxy --> pkg_invariants
pkg_host_directory_picker_auto --> pkg_host_directory_picker_browse
@@ -1344,6 +1352,7 @@ flowchart TD
| [`spill-local`](../packages/spill/spill-local) | `spill` | [`invariants`](../packages/support/invariants), [`spill`](../packages/spill/spill) |
| [`loader-smoke`](../packages/support/loader-smoke) | `support` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session) |
| [`time-context`](../packages/context/time-context) | `context` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`session`](../packages/core/session) |
+| [`message-feedback`](../packages/feedback/message-feedback) | `feedback` | [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`storage-domain`](../packages/storage/storage-domain), [`type-meta`](../packages/typert/type-meta) |
| [`host-apiproxy`](../packages/host/apiproxy) | `host` | [`agent-presets`](../packages/preset/agent-presets), [`invariants`](../packages/support/invariants) |
| [`host-directory-picker-auto`](../packages/host/directory-picker-auto) | `host` | [`host-directory-picker-browse`](../packages/host/directory-picker-browse), [`host-directory-picker-native`](../packages/host/directory-picker-native), [`host-webserver`](../packages/host/webserver), [`invariants`](../packages/support/invariants) |
| [`commands`](../packages/interaction/commands) | `interaction` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants), [`scope`](../packages/core/scope), [`session`](../packages/core/session) |
diff --git a/docs/subsystems/README.i18n.yaml b/docs/subsystems/README.i18n.yaml
index 9e578f7886..ebdccb923a 100644
--- a/docs/subsystems/README.i18n.yaml
+++ b/docs/subsystems/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 docs/subsystems/README.md
-README.md: fddbf460c8e9e7c6f9ed1d3375bdabe65947661f
-README.zh.md: febc5a97426fef5ec4b2b80d9677957369994a3b
+README.md: 560851eeda607b762456fe20c874b4497704709f
+README.zh.md: 4acba4372e995b54a3bb326bec0cf9606f997773
diff --git a/docs/subsystems/README.md b/docs/subsystems/README.md
index fddbf460c8..560851eeda 100644
--- a/docs/subsystems/README.md
+++ b/docs/subsystems/README.md
@@ -18,6 +18,7 @@ One page per subsystem of the DeepSeek Harness: what it is, the data structures
| [settings.md](settings.md) | the user-settings seam: `SettingsNamespace` registration, layered resolution (defaults → composition `base` → user document), owner scopes, hot commits |
| [credentials.md](credentials.md) | the credential seam: `CredentialRef` references (never values) in configuration, per-operation resolution, UI-safe `CredentialInfo`, provider source layers |
| [session-query.md](session-query.md) | logical records, bounded exact-event reads, relationship traces, semantic filters/documents, and full-text result pages |
+| [feedback.md](feedback.md) | lifecycle-bound per-message feedback records, optimistic versions, sidecar persistence, and the Host Remote contract |
| [session-title.md](session-title.md) | durable title snapshots, cited source-message seqs, and the asynchronous provider contract |
| [session-reference.md](session-reference.md) | structured cross-session references: `SessionReferenceInput`/`Candidate`, prepared message contexts, the stable error taxonomy |
| [system-prompt.md](system-prompt.md) | per-assembly context, tool-provider results, prompt sections, and cooperative assembly |
diff --git a/docs/subsystems/README.zh.md b/docs/subsystems/README.zh.md
index febc5a9742..4acba4372e 100644
--- a/docs/subsystems/README.zh.md
+++ b/docs/subsystems/README.zh.md
@@ -18,6 +18,7 @@
| [settings.md](settings.md) | 用户设置 seam:`SettingsNamespace` 注册、分层解析(默认值 → 组合 `base` → 用户文档)、owner scope、热提交 |
| [credentials.md](credentials.md) | 凭据 seam:配置中的 `CredentialRef` 引用(绝不含值)、按操作解析、对 UI 安全的 `CredentialInfo`、provider 来源层 |
| [session-query.md](session-query.md) | 逻辑记录、有界精确事件读取、关系追踪、语义筛选器/文档与全文检索结果页 |
+| [feedback.md](feedback.md) | 绑定生命周期的逐消息反馈记录、乐观版本、伴随记录持久化与 Host Remote 契约 |
| [session-title.md](session-title.md) | 持久标题快照、被引用的来源消息 seq 与异步提供方约定 |
| [session-reference.md](session-reference.md) | 结构化跨会话引用:`SessionReferenceInput`/`Candidate`、prepared 消息上下文、稳定错误分类 |
| [system-prompt.md](system-prompt.md) | 逐次组装的上下文、工具提供方结果、提示词段落与协作式组装 |
diff --git a/docs/subsystems/feedback.i18n.yaml b/docs/subsystems/feedback.i18n.yaml
new file mode 100644
index 0000000000..2dd07e8f8c
--- /dev/null
+++ b/docs/subsystems/feedback.i18n.yaml
@@ -0,0 +1,6 @@
+# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
+# 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: 4de02753ef179b13cae90b51ba965c42e2f519ee
+feedback.zh.md: 88e51cdb5c07e8dd7499f0ac2449b6b6ecc3388d
diff --git a/docs/subsystems/feedback.md b/docs/subsystems/feedback.md
new file mode 100644
index 0000000000..4de02753ef
--- /dev/null
+++ b/docs/subsystems/feedback.md
@@ -0,0 +1,76 @@
+# Message Feedback
+
+English | [中文](feedback.zh.md)
+
+[`@deepseek-ai/dsh-message-feedback`](../../packages/feedback/message-feedback) owns editable feedback for individual assistant messages. It is deliberately separate from the immutable Session-level `feedback/record` event: message feedback is a local storage-domain sidecar, not Session-log content or a projection, and it performs no telemetry handoff.
+
+Source: [`packages/feedback/message-feedback/src/types.ts`](../../packages/feedback/message-feedback/src/types.ts)
+
+## 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.
+
+`put` is optimistic and retry-safe. An exact retry of the already stored desired value returns that item before a stale `ifVersion` is treated as a conflict. Deleting an already absent item also succeeds. A per-Session queue encloses inspection, read, conflict evaluation, and whole-row write, so these guarantees cover concurrent calls in one Host process.
+
+## Target and lifecycle authority
+
+`SessionPersistence.inspect()` supplies the target Session observation without publishing or resuming an Agent and without committing cold repair. A cold `listSnapshots()` preflight classifies definite absence; inspection failure for a catalogued Session propagates as infrastructure failure. `put` accepts only a non-empty, append-origin `assistant/message` with the requested `MessageId`; replacement-origin, usage-only empty, and non-assistant records are not feedback targets.
+
+The stored `{createdAt, cwd}` identity must match the inspected header. A mismatch is treated as absence: `list` returns no items, while `put` may replace the stale row with one bound to the current header identity. Forks use a new Session identity and receive no sidecar copy even when their seed contains the same messages.
+
+## Persistence and Remote contract
+
+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; a catalogued cold target is physically re-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.
+
+## 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.
+- 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.
+
+
+
+
+
+## Cordis surface
+
+Generated from source by `scripts/gen-cordis-catalog.ts` (verified fresh by `pnpm run verify-cordis-catalog` in doc-sync; regenerate with `pnpm run gen-cordis-catalog`) — this section is byte-identical in both language sides of the page. Signature blocks use a `ts cordis-catalog` fence and keep the original source JSDoc; dispatch modes are defined in the [primer](../cordis-primer.md#dispatch-modes), and the framework-inherited `ctx` surface lives in [cordis-api/inherited.md](../cordis-api/inherited.md).
+
+
+
+### `ctx.messageFeedback` — `MessageFeedbackService`
+
+Storage-domain sidecar service. It inspects persisted Session history and never creates or resumes an Agent or Session.
+
+```ts cordis-catalog
+/**
+ * Read feedback belonging to the current persisted Session lifecycle.
+ * A stale row from a reused Session id is invisible.
+ * @param request - Session identity to inspect and list.
+ * @returns current immutable items or `session-not-found`.
+ */
+@Remote('list') async list(request: MessageFeedbackListRequest): Promise
+
+/**
+ * Create or replace feedback for one derived append-origin assistant
+ * message. An exact desired-value retry returns the stored item before its
+ * stale or `null` version is considered a conflict.
+ * @param request - target, desired value, and observed item version.
+ * @returns the committed item or an explicit business failure.
+ */
+@Remote('put') put(request: MessageFeedbackPutRequest): Promise
+
+/**
+ * Delete one feedback item. Absence is successful regardless of the
+ * supplied version; an existing item requires an exact version match.
+ * @param request - Session, message, and observed item version.
+ * @returns the stable absent postcondition, or an explicit failure.
+ */
+@Remote('delete') delete(request: MessageFeedbackDeleteRequest): Promise
+```
+
+Source: [`packages/feedback/message-feedback/src/index.ts:150`](../../packages/feedback/message-feedback/src/index.ts)
+
diff --git a/docs/subsystems/feedback.zh.md b/docs/subsystems/feedback.zh.md
new file mode 100644
index 0000000000..88e51cdb5c
--- /dev/null
+++ b/docs/subsystems/feedback.zh.md
@@ -0,0 +1,76 @@
+# 消息反馈
+
+[English](feedback.md) | 中文
+
+[`@deepseek-ai/dsh-message-feedback`](../../packages/feedback/message-feedback)拥有针对单条 assistant 消息的可编辑反馈。它刻意与不可变的 Session 级 `feedback/record` 事件分离:message feedback 是本地 storage-domain 伴随记录(sidecar),不是 Session 日志内容或投影,也不执行遥测交接。
+
+来源:[`packages/feedback/message-feedback/src/types.ts`](../../packages/feedback/message-feedback/src/types.ts)
+
+## 数据与并发
+
+每个 Session 的一条伴随记录包含 header 身份 `{createdAt, cwd}` 和以 `MessageId` 为键的反馈条目。每个条目携带好评或差评、可选备注、Host 分配的 `createdAt`/`updatedAt` 时间戳及自己的 opaque version。version 只能用于相等比较,且只与目标消息比较;调用方不能排序或自行合成它。
+
+`put` 采用乐观并发并可安全重试。若目标值已经存储,完全相同的重试会先返回该条目,再考虑陈旧 `ifVersion` 是否构成冲突。删除已经不存在的条目也同样成功。按 Session 划分的队列覆盖检查、读取、冲突判断与整行写入,因此这些保证适用于单个 Host 进程中的并发调用。
+
+## 目标与生命周期权威
+
+`SessionPersistence.inspect()` 提供目标 Session 的观测,且不会发布或恢复 Agent,也不会提交 cold repair。cold 路径先由 `listSnapshots()` 预检明确不存在;已进入目录的 Session 若检查失败,会按基础设施故障原样传播。`put` 只接受具有指定 `MessageId` 的非空、append-origin `assistant/message`;replacement-origin、仅承载 usage 的空记录和非 assistant 记录都不是反馈目标。
+
+存储的 `{createdAt, cwd}` 身份必须与检查所得 header 匹配。不匹配按不存在处理:`list` 返回空条目,`put` 则可用绑定当前 header 身份的新记录替换陈旧行。fork 使用新的 Session 身份,即使种子包含相同消息,也不获得伴随记录副本。
+
+## 持久化与 Remote 契约
+
+服务通过 `ctx.storageDomain` 在 `message_feedback` 存储域中保存完整 Session 行。`put` 提交引用目标消息的伴随记录前,身份匹配的 live 目标先经过权威 `ctx.sessions.flush` checkpoint;已进入目录的 cold 目标则通过 `SessionPersistence.readFrom` 从序列零做物理复读。写入伴随记录前会再次校验所得观测,因此目标日志的持久提交始终先于其伴随记录。`maxNoteBytes` 为必填项,按 UTF-8 字节限制备注文本;Web Host 组合将其设为 `8192`。该包通过 `GatewayService` 与 `@Remote` 发布 Host `messageFeedback.list`、`messageFeedback.put` 和 `messageFeedback.delete` 一元 Remote 契约;下方生成的 Cordis surface 是方法级权威。
+
+## 边界与限制
+
+- 客户端 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 后重试。
+- 只有 `{createdAt, cwd}` 不同时,header 身份才能识别复用的 id;本契约无法区分保留相同 header 身份的克隆日志。
+- Host 契约不记录已认证的 actor 或审计身份,因此假设调用方边界可信。
+
+
+
+
+
+## Cordis surface
+
+Generated from source by `scripts/gen-cordis-catalog.ts` (verified fresh by `pnpm run verify-cordis-catalog` in doc-sync; regenerate with `pnpm run gen-cordis-catalog`) — this section is byte-identical in both language sides of the page. Signature blocks use a `ts cordis-catalog` fence and keep the original source JSDoc; dispatch modes are defined in the [primer](../cordis-primer.md#dispatch-modes), and the framework-inherited `ctx` surface lives in [cordis-api/inherited.md](../cordis-api/inherited.md).
+
+
+
+### `ctx.messageFeedback` — `MessageFeedbackService`
+
+Storage-domain sidecar service. It inspects persisted Session history and never creates or resumes an Agent or Session.
+
+```ts cordis-catalog
+/**
+ * Read feedback belonging to the current persisted Session lifecycle.
+ * A stale row from a reused Session id is invisible.
+ * @param request - Session identity to inspect and list.
+ * @returns current immutable items or `session-not-found`.
+ */
+@Remote('list') async list(request: MessageFeedbackListRequest): Promise
+
+/**
+ * Create or replace feedback for one derived append-origin assistant
+ * message. An exact desired-value retry returns the stored item before its
+ * stale or `null` version is considered a conflict.
+ * @param request - target, desired value, and observed item version.
+ * @returns the committed item or an explicit business failure.
+ */
+@Remote('put') put(request: MessageFeedbackPutRequest): Promise
+
+/**
+ * Delete one feedback item. Absence is successful regardless of the
+ * supplied version; an existing item requires an exact version match.
+ * @param request - Session, message, and observed item version.
+ * @returns the stable absent postcondition, or an explicit failure.
+ */
+@Remote('delete') delete(request: MessageFeedbackDeleteRequest): Promise
+```
+
+Source: [`packages/feedback/message-feedback/src/index.ts:150`](../../packages/feedback/message-feedback/src/index.ts)
+
diff --git a/packages/bundle/web-app/cordis.patch.yml b/packages/bundle/web-app/cordis.patch.yml
index fb41923d71..1b26158b47 100644
--- a/packages/bundle/web-app/cordis.patch.yml
+++ b/packages/bundle/web-app/cordis.patch.yml
@@ -60,6 +60,11 @@
config:
backend: json
+ - id: message-feedback
+ name: '@deepseek-ai/dsh-message-feedback'
+ config:
+ maxNoteBytes: 8192
+
- id: workspace
name: '@deepseek-ai/dsh-workspace'
diff --git a/packages/bundle/web-app/package.json b/packages/bundle/web-app/package.json
index 181bb269c4..f5e5ffe4e5 100644
--- a/packages/bundle/web-app/package.json
+++ b/packages/bundle/web-app/package.json
@@ -82,6 +82,7 @@
"@deepseek-ai/dsh-host-directory-picker-browse": "workspace:^",
"@deepseek-ai/dsh-host-directory-picker-native": "workspace:^",
"@deepseek-ai/dsh-host-webserver": "workspace:^",
+ "@deepseek-ai/dsh-message-feedback": "workspace:^",
"@deepseek-ai/dsh-session-projection-cache": "workspace:^",
"@deepseek-ai/dsh-storage": "workspace:^",
"@deepseek-ai/dsh-storage-domain": "workspace:^",
diff --git a/packages/feedback/README.i18n.yaml b/packages/feedback/README.i18n.yaml
index fce2946cff..bab1cf0db2 100644
--- a/packages/feedback/README.i18n.yaml
+++ b/packages/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/README.md
-README.md: af8e9d5c4903594299284d09f880aa8929f5e051
-README.zh.md: 9156ff1c8da8ed1128488fcacaf454425468163a
+README.md: 152db65bd7ac179bb8d475d446535734b9f8238e
+README.zh.md: 64202a3c4a0258f9b41a6cd96e7bbbf011d0338d
diff --git a/packages/feedback/README.md b/packages/feedback/README.md
index af8e9d5c49..152db65bd7 100644
--- a/packages/feedback/README.md
+++ b/packages/feedback/README.md
@@ -2,10 +2,13 @@
English | [中文](README.zh.md)
-The feedback family lets a human record a remark about the session without acting on it. Feedback is durable session-log content, separate from the model conversation and from any policy that might later read it.
+The feedback family exposes two deliberately separate contracts: an immutable remark in the canonical Session log, and editable feedback attached to one assistant message in a local sidecar. Neither form enters the model conversation.
| Package | Role | ctx key |
|---|---|---|
| `command-feedback/` | Trigger-independent `feedback/record` event plus the human-facing `/feedback` producer | — |
+| `message-feedback/` | Lifecycle-bound per-message rating/note sidecar plus Host `messageFeedback.list/put/delete` Remote contract | `messageFeedback` |
-A recorded remark is log-only: it never enters the model surface or derived history. When mounted, [`dsh-session-telemetry-otel`](../session/session-telemetry-otel) observes `feedback/record` to release a pending telemetry prefix or warn that disabled telemetry leaves the feedback local; capture itself remains independent of that policy.
+A command feedback remark is log-only: it never enters the model surface or derived history. When mounted, [`dsh-session-telemetry-otel`](../session/session-telemetry-otel) observes `feedback/record` to release a pending telemetry prefix or warn that disabled telemetry leaves the feedback local; capture itself remains independent of that policy.
+
+Message feedback is not a Session event or projection. It remains in the storage-domain sidecar and causes no telemetry handoff. The Host Remote contract ships with the service; the client Remote aggregate mount and UI consumer are separately owned and deferred.
diff --git a/packages/feedback/README.zh.md b/packages/feedback/README.zh.md
index 9156ff1c8d..64202a3c4a 100644
--- a/packages/feedback/README.zh.md
+++ b/packages/feedback/README.zh.md
@@ -2,10 +2,13 @@
[English](README.md) | 中文
-反馈家族让人类记录对会话的评价,但不据此采取任何动作。反馈属于持久的会话日志内容,与模型对话以及后续可能读取它的任何策略相互独立。
+反馈家族公开两份刻意分离的契约:写入权威 Session 日志的不可变评价,以及挂在单条 assistant 消息上的可编辑本地伴随记录(sidecar)反馈。两者都不会进入模型对话。
| 包 | 职责 | ctx 键 |
|---|---|---|
| `command-feedback/` | 与触发方式无关的 `feedback/record` 事件,以及面向用户的 `/feedback` 生产方 | 无 |
+| `message-feedback/` | 绑定生命周期的逐消息评分/备注伴随记录,以及 Host `messageFeedback.list/put/delete` Remote 契约 | `messageFeedback` |
-被记录的评价仅写入日志:它绝不会进入模型接口或派生历史。挂载后,[`dsh-session-telemetry-otel`](../session/session-telemetry-otel) 会观察 `feedback/record`,以释放待处理的遥测前缀,或在遥测已禁用时警告反馈将留在本地;采集本身与该策略相互独立。
+command feedback 评价仅写入日志:它绝不会进入模型接口或派生历史。挂载后,[`dsh-session-telemetry-otel`](../session/session-telemetry-otel) 会观察 `feedback/record`,以释放待处理的遥测前缀,或在遥测已禁用时警告反馈将留在本地;采集本身与该策略相互独立。
+
+message feedback 不是 Session 事件或投影。它只保留在 storage-domain 伴随记录中,不触发任何遥测交接。服务随附 Host Remote 契约;客户端 Remote 聚合挂载与 UI 消费方由各自边界负责,并保持延后。
diff --git a/packages/feedback/message-feedback/README.i18n.yaml b/packages/feedback/message-feedback/README.i18n.yaml
new file mode 100644
index 0000000000..2ff326dc3a
--- /dev/null
+++ b/packages/feedback/message-feedback/README.i18n.yaml
@@ -0,0 +1,6 @@
+# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
+# 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: 0fda4fb535e5186c252377383520671cd574b364
+README.zh.md: 151db5c10048e6ff32d3f7a4ddbcba986d87597a
diff --git a/packages/feedback/message-feedback/README.md b/packages/feedback/message-feedback/README.md
new file mode 100644
index 0000000000..0fda4fb535
--- /dev/null
+++ b/packages/feedback/message-feedback/README.md
@@ -0,0 +1,81 @@
+# @deepseek-ai/dsh-message-feedback
+
+English | [中文](README.zh.md)
+
+Host-owned editable feedback for one finalized assistant message. The package registers `ctx.messageFeedback`, persists one lifecycle-bound sidecar row per Session in storage-domain, and publishes the Host `messageFeedback.list`, `messageFeedback.put`, and `messageFeedback.delete` unary Remote contract. It is separate from the immutable Session-level `feedback/record` event and performs no telemetry handoff. The [message-feedback sidecar Agent Note](../../../.agents/notes/implemented/architecture/2026-08-10-message-feedback-sidecar.md) owns the design boundary.
+
+Public request, value, version, and failure types are exported from the package root and `@deepseek-ai/dsh-message-feedback/types`; [`src/types.ts`](src/types.ts) is their source.
+
+## Configuration
+
+| key | meaning |
+|---|---|
+| `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.
+
+```yaml
+- id: message-feedback
+ name: '@deepseek-ai/dsh-message-feedback'
+ config:
+ maxNoteBytes: 8192
+```
+
+The service injects `storageDomain`, `sessionPersistence`, and `sessions`. Its durable domain is `message_feedback`, with one `sessions` table row per `SessionId`.
+
+## Data, lifecycle, and durability
+
+`MessageFeedbackItem` contains `messageId`, `rating: 'positive' | 'negative'`, optional `note`, an opaque equality-only `version`, and Host-assigned `createdAt`/`updatedAt` Unix-millisecond timestamps. A material update preserves `createdAt`, replaces `version`, and keeps `updatedAt` from moving backward. `list` returns fresh immutable snapshots in first-creation order; updating an item retains its place, while deleting and later recreating it appends a new item.
+
+Each stored row carries the inspected Session header identity `{createdAt, cwd}`. A mismatch is treated as absence: `list` returns an empty `items` array, `delete` returns the absent postcondition, and `put` may replace the stale row with one bound to the current identity. This fences a reused `SessionId` when its header identity differs. Forks use a distinct Session identity and receive no feedback-row copy.
+
+`SessionPersistence.inspect()` supplies a cold-safe observation without publishing or resuming an Agent and without committing cold repair. For a Session without a live owner, `listSnapshots()` first decides definite absence; an `inspect()` failure for a catalogued Session remains an infrastructure failure rather than being guessed into `session-not-found`. `put` accepts only a non-empty, append-origin `assistant/message` with the requested `MessageId`; replacement-origin messages, empty usage-only assistant records, and non-assistant records return `target-not-found`.
+
+After initial validation, `put` establishes a durability barrier before writing the sidecar. A matching live Session commits through the canonical `ctx.sessions.flush` checkpoint; a catalogued cold Session is physically re-read from sequence zero through `SessionPersistence.readFrom`. The resulting observation's header identity and target are validated again. A missing flush participant, changed identity, vanished target, or cold physical-read failure prevents the sidecar commit, so durable feedback never precedes the durable target message.
+
+Message feedback is not Session-log content or a Session projection. It emits no `feedback/record` event, does not enter model history, and does not trigger `FEEDBACK_ONLY` telemetry release.
+
+## Service and Host Remote contract
+
+The same three `MessageFeedbackService` methods are published by `GatewayService` and `@Remote`; the Host endpoint names are `messageFeedback.list`, `messageFeedback.put`, and `messageFeedback.delete`. Every method returns a discriminated business union: `{ ok: true, value }` or `{ ok: false, error }`. Operational storage, corruption, or missing-durability-listener failures reject instead of being mislabeled as business errors.
+
+| Method | Request | Success `value` | Rejected `error.code` |
+|---|---|---|---|
+| `list` | `MessageFeedbackListRequest { sessionId }` | `MessageFeedbackListValue { items }` | `session-not-found` |
+| `put` | `MessageFeedbackPutRequest { sessionId, messageId, rating, note?, ifVersion }` | committed `MessageFeedbackItem` | `session-not-found`, `target-not-found`, `version-conflict`, `note-blank`, `note-too-large` |
+| `delete` | `MessageFeedbackDeleteRequest { sessionId, messageId, ifVersion }` | `MessageFeedbackDeleteValue { absent: true }` | `session-not-found`, `version-conflict` |
+
+`MessageFeedbackVersionConflict` returns the caller's `expected` token and the current `actual` token, each nullable where absence is meaningful. `MessageFeedbackNoteTooLarge` returns both `maxBytes` and `actualBytes`. The Client Remote aggregate does not mount the generated client contribution yet; Host callers can use the service/Remote contract without that client assembly.
+
+## Compare-and-set and idempotency
+
+`ifVersion: null` requests creation only; a material update requires the exact current item version. The check is per message rather than per Session, so changing one item does not conflict with another. Every material create or update assigns a fresh opaque UUID token.
+
+An exact desired-value retry is recognized before `ifVersion` comparison. It returns the already stored item with unchanged version and timestamps, so a caller may safely retry after losing a success response even with the now-stale token or original `null`. `delete` ignores `ifVersion` when the item is already absent and always returns the stable `{ absent: true }` postcondition after success.
+
+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.
+
+## Model Experience
+
+### Local message-feedback state
+
+#### What the model sees
+
+Nothing. `ctx.messageFeedback` registers no tool, prompt section, model-facing context, or Session event; feedback stays in a Host-owned sidecar unless a separately documented Consumer explicitly exposes it.
+
+#### Token effect
+
+Zero. No request, result, rating, note, timestamp, or failure from this package enters a model request.
+
+#### KV Cache effect
+
+Independent. Listing or mutating message feedback does not touch a model request prefix and cannot invalidate an otherwise reusable provider cache entry.
+
+## Known Limitations and Deferred Work
+
+- **Client aggregate and UI are absent** — the Host Remote contract ships, but the Client Remote aggregate contribution and any UI consumer are separately owned and deferred.
+- **Compare-and-set is single-process** — the per-Session queue serializes one service instance only; multiple Host processes writing one storage root can still lose updates because storage-domain exposes no cross-process conditional write.
+- **No durable Session deletion cascade** — Session persistence has no deletion surface, and `session/disposed`/`host/session-removed` mean detach rather than durable deletion. The service therefore retains empty rows and may leave orphan rows after out-of-band log removal instead of deleting valid feedback on detach.
+- **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.
diff --git a/packages/feedback/message-feedback/README.zh.md b/packages/feedback/message-feedback/README.zh.md
new file mode 100644
index 0000000000..151db5c100
--- /dev/null
+++ b/packages/feedback/message-feedback/README.zh.md
@@ -0,0 +1,81 @@
+# @deepseek-ai/dsh-message-feedback
+
+[English](README.md) | 中文
+
+本包提供由 Host 拥有、针对单条已完成 assistant 消息的可编辑反馈。它注册 `ctx.messageFeedback`,在 storage-domain 中为每个 Session 持久化一条绑定生命周期的伴随记录(sidecar),并发布 Host `messageFeedback.list`、`messageFeedback.put` 与 `messageFeedback.delete` 一元 Remote 契约。它与不可变的 Session 级 `feedback/record` 事件相互独立,不执行遥测交接。[消息反馈伴随记录 Agent Note](../../../.agents/notes/implemented/architecture/2026-08-10-message-feedback-sidecar.md)拥有其设计边界。
+
+公开的请求、值、版本与失败类型从包根入口及 `@deepseek-ai/dsh-message-feedback/types` 导出;其源码为 [`src/types.ts`](src/types.ts)。
+
+## 配置
+
+| 键 | 含义 |
+|---|---|
+| `maxNoteBytes` | 必填正 safe integer:一条可选备注的最大 UTF-8 字节长度。 |
+
+备注必须包含至少一个非空白字符,但通过校验的文本按原样存储,不会 trim。省略 `note` 表示目标值不含备注,因此通过授权的实质 `put` 会清除已有备注。
+
+```yaml
+- id: message-feedback
+ name: '@deepseek-ai/dsh-message-feedback'
+ config:
+ maxNoteBytes: 8192
+```
+
+服务注入 `storageDomain`、`sessionPersistence` 与 `sessions`。其持久存储域为 `message_feedback`,其中 `sessions` 表按 `SessionId` 每个一行。
+
+## 数据、生命周期与持久性
+
+`MessageFeedbackItem` 包含 `messageId`、`rating: 'positive' | 'negative'`、可选 `note`、只能做相等比较的 opaque `version`,以及由 Host 分配、以 Unix 毫秒表示的 `createdAt`/`updatedAt` 时间戳。实质更新保留 `createdAt`、替换 `version`,并保证 `updatedAt` 不倒退。`list` 按首次创建顺序返回新的不可变快照;更新条目时保留其位置,删除后再创建则追加为新条目。
+
+每条存储行都携带检查所得 Session header 身份 `{createdAt, cwd}`。不匹配按不存在处理:`list` 返回空 `items` 数组,`delete` 返回已不存在的后置条件,`put` 可以用绑定当前身份的新行替换陈旧行。这会在复用的 `SessionId` 具有不同 header 身份时形成隔离。fork 使用独立的 Session 身份,不复制反馈伴随记录。
+
+`SessionPersistence.inspect()` 提供 cold-safe 观测,不发布或恢复 Agent,也不提交 cold repair。对于没有 live owner 的 Session,系统先用 `listSnapshots()` 判定明确不存在;已进入目录的 Session 若 `inspect()` 失败,仍属于基础设施故障,不会被猜测成 `session-not-found`。`put` 只接受具有指定 `MessageId` 的非空、append-origin `assistant/message`;replacement-origin 消息、仅承载 usage 的空 assistant 记录与非 assistant 记录都返回 `target-not-found`。
+
+初步校验后,`put` 在写入伴随记录前建立 durability barrier。身份匹配的 live Session 通过权威 `ctx.sessions.flush` checkpoint 提交;已进入目录的 cold Session 则通过 `SessionPersistence.readFrom` 从序列零做物理复读。随后再次校验所得观测的 header 身份与目标。缺少 flush 参与方、身份变化、目标消失或 cold 物理读取失败都会阻止伴随记录提交,因此持久反馈绝不会先于其持久目标消息。
+
+message feedback 不是 Session 日志内容或 Session 投影。它不发出 `feedback/record` 事件,不进入模型历史,也不触发 `FEEDBACK_ONLY` 遥测释放。
+
+## 服务与 Host Remote 契约
+
+`GatewayService` 与 `@Remote` 将 `MessageFeedbackService` 的同三个方法发布出去;Host endpoint 名称为 `messageFeedback.list`、`messageFeedback.put` 与 `messageFeedback.delete`。每个方法都返回判别式业务 union:`{ ok: true, value }` 或 `{ ok: false, error }`。存储、损坏或缺少 durability listener 等操作故障会产生 reject,不会被误标为业务错误。
+
+| 方法 | 请求 | 成功 `value` | 拒绝的 `error.code` |
+|---|---|---|---|
+| `list` | `MessageFeedbackListRequest { sessionId }` | `MessageFeedbackListValue { items }` | `session-not-found` |
+| `put` | `MessageFeedbackPutRequest { sessionId, messageId, rating, note?, ifVersion }` | 已提交的 `MessageFeedbackItem` | `session-not-found`、`target-not-found`、`version-conflict`、`note-blank`、`note-too-large` |
+| `delete` | `MessageFeedbackDeleteRequest { sessionId, messageId, ifVersion }` | `MessageFeedbackDeleteValue { absent: true }` | `session-not-found`、`version-conflict` |
+
+`MessageFeedbackVersionConflict` 返回调用方的 `expected` token 与当前 `actual` token;在表示不存在时,两者可以为 null。`MessageFeedbackNoteTooLarge` 同时返回 `maxBytes` 与 `actualBytes`。客户端 Remote 聚合尚未挂载生成的客户端 contribution;Host 调用方无需该客户端组装即可使用 service/Remote 契约。
+
+## Compare-and-set 与幂等性
+
+`ifVersion: null` 表示仅当条目不存在时才创建;实质更新要求与当前条目 version 完全一致。检查按消息而非按 Session 进行,因此修改一个条目不会与另一个条目冲突。每次实质创建或更新都会分配新的 opaque UUID token。
+
+系统会在比较 `ifVersion` 之前识别与目标值完全相同的重试。它返回已存条目,version 与时间戳均不变,因此调用方在成功响应丢失后,即使用当前已陈旧的 token 或原始 `null` 也可安全重试。条目已不存在时,`delete` 忽略 `ifVersion`;成功后始终返回稳定的 `{ absent: true }` 后置条件。
+
+按 Session 划分的 promise 队列覆盖检查、持久性校验、伴随记录读取、比较与整行写入。这些语义会串行化经由同一服务实例的并发变更;storage-domain 自身没有跨进程条件写。
+
+## 模型体验
+
+### 本地消息反馈状态
+
+#### 模型看到的内容
+
+无。`ctx.messageFeedback` 不注册工具、提示词段落、模型可见上下文或 Session 事件;除非另一个具有独立文档的 Consumer 显式公开反馈,否则它只留在 Host 拥有的伴随记录中。
+
+#### Token 影响
+
+为零。本包的请求、结果、评分、备注、时间戳或失败都不会进入模型请求。
+
+#### KV Cache 影响
+
+相互独立。读取或变更消息反馈不会触碰模型请求前缀,也不会使本可复用的提供方缓存条目失效。
+
+## 已知局限与延后工作
+
+- **缺少客户端聚合与 UI**——Host Remote 契约已经发布,但客户端 Remote 聚合 contribution 与任何 UI 消费方由各自边界负责并保持延后。
+- **Compare-and-set 仅限单进程**——按 Session 划分的队列只串行化一个服务实例;storage-domain 不提供跨进程条件写,因此多个 Host 进程写入同一存储根目录时仍可能丢失更新。
+- **没有持久 Session 删除级联**——Session persistence 没有删除接口,且 `session/disposed`/`host/session-removed` 表示 detach 而非持久删除。因此服务会保留空行,并可能在带外移除日志后留下孤儿行,而不会在 detach 时删除仍有效的反馈。
+- **Detach/catalog retirement 窗口**——请求若恰好落在 live detach 之后、persistence catalog 物化 header 之前的极短窗口,可能收到 `session-not-found`;调用方应在 retirement materialization 后重试。
+- **Header 身份不是内容指纹**——只有 `{createdAt, cwd}` 不同时才能识别复用;本契约无法区分保留相同 header 身份的克隆日志。
+- **调用方边界受信任**——`list`/`put`/`delete` 不携带已认证的 actor 或审计身份。在加入授权与归属信息前,部署方必须只通过受信任或另行认证的边界暴露 Host gateway。
diff --git a/packages/feedback/message-feedback/package.json b/packages/feedback/message-feedback/package.json
new file mode 100644
index 0000000000..5a07238ec9
--- /dev/null
+++ b/packages/feedback/message-feedback/package.json
@@ -0,0 +1,82 @@
+{
+ "name": "@deepseek-ai/dsh-message-feedback",
+ "description": "Lifecycle-bound per-message rating and note sidecar for the DeepSeek Harness",
+ "version": "0.0.1",
+ "publishConfig": {
+ "access": "restricted"
+ },
+ "repository": {
+ "type": "git",
+ "url": "git+https://github.com/deepseek-ai/deepseek-harness.git",
+ "directory": "packages/feedback/message-feedback"
+ },
+ "type": "module",
+ "main": "lib/index.js",
+ "types": "lib/types/index.d.ts",
+ "exports": {
+ ".": {
+ "types": "./lib/types/index.d.ts",
+ "default": "./lib/index.js"
+ },
+ "./invariant": {
+ "types": "./lib/types/invariant.d.ts",
+ "default": "./lib/invariant.js"
+ },
+ "./types": {
+ "types": "./lib/types/types.d.ts",
+ "default": "./lib/types/types.js"
+ },
+ "./typert": {
+ "types": "./lib/typert.host.d.ts",
+ "default": "./lib/typert.host.js"
+ },
+ "./remote": {
+ "types": "./lib/typert.remote-client.d.ts",
+ "default": "./lib/typert.remote-client.js"
+ },
+ "./src/*": "./src/*",
+ "./package.json": "./package.json"
+ },
+ "files": [
+ "lib/index.js",
+ "lib/invariant.js",
+ "lib/types/**/*.js",
+ "lib/types/**/*.d.ts",
+ "lib/typert.host.js",
+ "lib/typert.host.d.ts",
+ "lib/typert.remote-client.js",
+ "lib/typert.remote-client.d.ts",
+ "lib/typert.remote-client.d.ts.map",
+ "src"
+ ],
+ "license": "BSD-3-Clause",
+ "peerDependencies": {
+ "@deepseek-ai/dsh-brand": "workspace:^",
+ "@deepseek-ai/dsh-invariants": "workspace:^",
+ "@deepseek-ai/dsh-llm": "workspace:^",
+ "@deepseek-ai/dsh-session": "workspace:^",
+ "@deepseek-ai/dsh-session-persistence": "workspace:^",
+ "@deepseek-ai/dsh-storage-domain": "workspace:^",
+ "@deepseek-ai/dsh-type-meta": "workspace:^",
+ "@deepseek-ai/cordis": "workspace:^"
+ },
+ "dependencies": {
+ "@deepseek-ai/schemastery": "workspace:^",
+ "zod": "^4.4.3"
+ },
+ "devDependencies": {
+ "@deepseek-ai/cordis-plugin-include": "workspace:^",
+ "@deepseek-ai/cordis-plugin-loader": "workspace:^",
+ "@deepseek-ai/dsh-brand": "workspace:^",
+ "@deepseek-ai/dsh-invariants": "workspace:^",
+ "@deepseek-ai/dsh-llm": "workspace:^",
+ "@deepseek-ai/dsh-session": "workspace:^",
+ "@deepseek-ai/dsh-session-persistence": "workspace:^",
+ "@deepseek-ai/dsh-session-persistence-jsonl": "workspace:^",
+ "@deepseek-ai/dsh-storage": "workspace:^",
+ "@deepseek-ai/dsh-storage-domain": "workspace:^",
+ "@deepseek-ai/dsh-storage-json": "workspace:^",
+ "@deepseek-ai/dsh-type-meta": "workspace:^",
+ "@deepseek-ai/cordis": "workspace:^"
+ }
+}
diff --git a/packages/feedback/message-feedback/src/index.ts b/packages/feedback/message-feedback/src/index.ts
new file mode 100644
index 0000000000..ce2e9d06f3
--- /dev/null
+++ b/packages/feedback/message-feedback/src/index.ts
@@ -0,0 +1,380 @@
+/**
+ * Durable, lifecycle-bound feedback for finalized assistant messages.
+ * @module @deepseek-ai/dsh-message-feedback
+ */
+
+import { Buffer } from 'node:buffer'
+import { randomUUID } from 'node:crypto'
+import { Context, Service } from '@deepseek-ai/cordis'
+import s from '@deepseek-ai/schemastery'
+import { deriveEventMessage, isAppendSurfaceEvent } from '@deepseek-ai/dsh-session/surface'
+import type { SessionHeader, SessionId } from '@deepseek-ai/dsh-session/types'
+import type { SessionInspection } from '@deepseek-ai/dsh-session-persistence'
+import type { KvTable } from '@deepseek-ai/dsh-storage-domain'
+import { GatewayService, Remote } from '@deepseek-ai/dsh-type-meta'
+import { messageFeedbackDomainSpec } from './spec.ts'
+import type { MessageFeedbackRow, MessageFeedbackSessionIdentity } from './spec.ts'
+import type {
+ MessageFeedbackDeleteRequest,
+ MessageFeedbackDeleteResult,
+ MessageFeedbackDeleteValue,
+ MessageFeedbackFailure,
+ MessageFeedbackItem,
+ MessageFeedbackListRequest,
+ MessageFeedbackListResult,
+ MessageFeedbackListValue,
+ MessageFeedbackNoteBlank,
+ MessageFeedbackNoteTooLarge,
+ MessageFeedbackPutRequest,
+ MessageFeedbackPutResult,
+ MessageFeedbackRejected,
+ MessageFeedbackSessionNotFound,
+ MessageFeedbackSuccess,
+ MessageFeedbackVersion,
+ MessageFeedbackVersionConflict,
+} from './types.ts'
+
+export type * from './types.ts'
+export {
+ messageFeedbackDomainSpec,
+ messageFeedbackItemSchema,
+ messageFeedbackRatingSchema,
+ messageFeedbackRowSchema,
+ messageFeedbackSessionIdentitySchema,
+ messageFeedbackVersionSchema,
+} from './spec.ts'
+export type { MessageFeedbackRow, MessageFeedbackSessionIdentity } from './spec.ts'
+
+/** Required deployment policy for optional notes. */
+export interface Config {
+ /** Maximum UTF-8 byte length accepted for one note. */
+ readonly maxNoteBytes: number
+}
+
+declare module '@deepseek-ai/cordis' {
+ interface Context {
+ messageFeedback: MessageFeedbackService
+ }
+}
+
+/** Immutable empty list reused only as an input to caller-owned copying. */
+const EMPTY_ITEMS: readonly MessageFeedbackItem[] = Object.freeze([])
+
+/** Validate the one deployment-varying limit at the configuration boundary. */
+function resolveMaxNoteBytes(value: number): number {
+ if (!Number.isSafeInteger(value) || value < 1) {
+ throw new TypeError(
+ `message-feedback: maxNoteBytes must be a positive safe integer, got ${String(value)}`,
+ )
+ }
+ return value
+}
+
+/** Copy and freeze one item before it crosses the service boundary. */
+function snapshotItem(item: MessageFeedbackItem): MessageFeedbackItem {
+ return Object.freeze({
+ messageId: item.messageId,
+ rating: item.rating,
+ ...(item.note === undefined ? {} : { note: item.note }),
+ version: item.version,
+ createdAt: item.createdAt,
+ updatedAt: item.updatedAt,
+ })
+}
+
+/** Copy and freeze a list response. */
+function snapshotList(items: readonly MessageFeedbackItem[]): MessageFeedbackListValue {
+ return Object.freeze({ items: Object.freeze(items.map(snapshotItem)) })
+}
+
+/** Build a frozen success branch. */
+function success(value: T): MessageFeedbackSuccess {
+ return Object.freeze({ ok: true, value })
+}
+
+/** Build a frozen business-failure branch. */
+function rejected(error: E): MessageFeedbackRejected {
+ return Object.freeze({ ok: false, error: Object.freeze(error) })
+}
+
+/** Project the Session fields that distinguish one persisted log lifecycle. */
+function identityOf(header: SessionHeader): MessageFeedbackSessionIdentity {
+ return Object.freeze({
+ createdAt: header.createdAt,
+ ...(header.cwd === undefined ? {} : { cwd: header.cwd }),
+ })
+}
+
+/** Whether a stored row belongs to the inspected Session lifecycle. */
+function sameIdentity(row: MessageFeedbackRow, header: SessionHeader): boolean {
+ return row.session.createdAt === header.createdAt && row.session.cwd === header.cwd
+}
+
+/** Whether two observations name the same persisted Session lifecycle. */
+function sameHeaderIdentity(left: SessionHeader, right: SessionHeader): boolean {
+ return left.id === right.id && left.createdAt === right.createdAt && left.cwd === right.cwd
+}
+
+/** Freeze the replacement row so storage-domain never exposes mutable aliases. */
+function rowSnapshot(
+ session: MessageFeedbackSessionIdentity,
+ items: readonly MessageFeedbackItem[],
+): MessageFeedbackRow {
+ const copiedItems = items.map(snapshotItem)
+ Object.freeze(copiedItems)
+ return Object.freeze({
+ session,
+ items: copiedItems,
+ })
+}
+
+/** Generate an opaque equality token for one material mutation. */
+function nextVersion(): MessageFeedbackVersion {
+ return randomUUID() as MessageFeedbackVersion
+}
+
+/** Session inspection result that keeps absence inside the business union. */
+type KnownSession =
+ | MessageFeedbackSuccess
+ | MessageFeedbackRejected
+
+/** Validated note or one explicit request failure. */
+type ResolvedNote =
+ | MessageFeedbackSuccess
+ | MessageFeedbackRejected
+
+/**
+ * Storage-domain sidecar service. It inspects persisted Session history and
+ * never creates or resumes an Agent or Session.
+ */
+export class MessageFeedbackService extends GatewayService {
+ static inject = ['storageDomain', 'sessionPersistence', 'sessions']
+
+ /** Loader validation for the required note-size policy. */
+ static Config: s = s.object({
+ maxNoteBytes: s.number().step(1).min(1).required(),
+ })
+
+ private readonly maxNoteBytes: number
+ private table?: KvTable
+ private readonly operationTails = new Map>()
+
+ /**
+ * @param ctx - Host context carrying persistence and the storage-domain form.
+ * @param config - Required note-size policy.
+ */
+ constructor(ctx: Context, config: Config) {
+ super(ctx, 'messageFeedback')
+ this.maxNoteBytes = resolveMaxNoteBytes(config.maxNoteBytes)
+ }
+
+ /** Open and own the one message-feedback sidecar domain. */
+ protected async [Service.init](): Promise {
+ const domain = await this.ctx.storageDomain.open(messageFeedbackDomainSpec)
+ this.ctx.effect(() => () => domain.close(), 'message-feedback.domainClose')
+ this.table = domain.table('sessions')
+ }
+
+ /**
+ * Read feedback belonging to the current persisted Session lifecycle.
+ * A stale row from a reused Session id is invisible.
+ * @param request - Session identity to inspect and list.
+ * @returns current immutable items or `session-not-found`.
+ */
+ @Remote('list')
+ async list(request: MessageFeedbackListRequest): Promise {
+ const known = await this.inspectSession(request.sessionId)
+ if (!known.ok) return known
+ const row = this.requireTable().get(request.sessionId)
+ const items = row !== undefined && sameIdentity(row, known.value.meta) ? row.items : EMPTY_ITEMS
+ return success(snapshotList(items))
+ }
+
+ /**
+ * Create or replace feedback for one derived append-origin assistant
+ * message. An exact desired-value retry returns the stored item before its
+ * stale or `null` version is considered a conflict.
+ * @param request - target, desired value, and observed item version.
+ * @returns the committed item or an explicit business failure.
+ */
+ @Remote('put')
+ put(request: MessageFeedbackPutRequest): Promise {
+ const note = this.resolveNote(request.note)
+ if (!note.ok) return Promise.resolve(note)
+ return this.enqueue(request.sessionId, async () => {
+ const known = await this.inspectSession(request.sessionId)
+ if (!known.ok) return known
+ if (!this.hasFeedbackTarget(known.value, request.messageId)) {
+ return rejected({
+ code: 'target-not-found',
+ sessionId: request.sessionId,
+ messageId: request.messageId,
+ })
+ }
+
+ const durable = await this.ensureTargetDurable(known.value)
+ if (!sameHeaderIdentity(durable.meta, known.value.meta)
+ || !this.hasFeedbackTarget(durable, request.messageId)) {
+ return rejected({
+ code: 'target-not-found',
+ sessionId: request.sessionId,
+ messageId: request.messageId,
+ })
+ }
+
+ const table = this.requireTable()
+ const stored = table.get(request.sessionId)
+ const current = stored !== undefined && sameIdentity(stored, durable.meta) ? stored : undefined
+ const items = current?.items ?? EMPTY_ITEMS
+ const index = items.findIndex(item => item.messageId === request.messageId)
+ const existing = items[index]
+ if (existing !== undefined
+ && existing.rating === request.rating
+ && existing.note === note.value) {
+ return success(snapshotItem(existing))
+ }
+ if (request.ifVersion !== (existing?.version ?? null)) {
+ return rejected(this.versionConflict(request, existing?.version ?? null))
+ }
+
+ const now = Date.now()
+ const item = snapshotItem({
+ messageId: request.messageId,
+ rating: request.rating,
+ ...(note.value === undefined ? {} : { note: note.value }),
+ version: nextVersion(),
+ createdAt: existing?.createdAt ?? now,
+ updatedAt: existing === undefined ? now : Math.max(now, existing.updatedAt),
+ })
+ const nextItems = [...items]
+ if (index === -1) nextItems.push(item)
+ else nextItems[index] = item
+ await table.put(
+ request.sessionId,
+ rowSnapshot(identityOf(durable.meta), nextItems),
+ )
+ return success(snapshotItem(item))
+ })
+ }
+
+ /**
+ * Delete one feedback item. Absence is successful regardless of the
+ * supplied version; an existing item requires an exact version match.
+ * @param request - Session, message, and observed item version.
+ * @returns the stable absent postcondition, or an explicit failure.
+ */
+ @Remote('delete')
+ delete(request: MessageFeedbackDeleteRequest): Promise {
+ return this.enqueue(request.sessionId, async () => {
+ const known = await this.inspectSession(request.sessionId)
+ if (!known.ok) return known
+
+ const table = this.requireTable()
+ const stored = table.get(request.sessionId)
+ const current = stored !== undefined && sameIdentity(stored, known.value.meta) ? stored : undefined
+ const items = current?.items ?? EMPTY_ITEMS
+ const existing = items.find(item => item.messageId === request.messageId)
+ if (existing === undefined) {
+ return success(Object.freeze({ absent: true }))
+ }
+ if (request.ifVersion !== existing.version) {
+ return rejected(this.versionConflict(request, existing.version))
+ }
+
+ await table.put(
+ request.sessionId,
+ rowSnapshot(identityOf(known.value.meta), items.filter(item => item !== existing)),
+ )
+ return success(Object.freeze({ absent: true }))
+ })
+ }
+
+ /**
+ * Resolve a live owner directly; otherwise use the storage catalog as the
+ * existence authority before inspecting the log. Inspection failures for a
+ * catalogued Session remain infrastructure failures rather than being
+ * guessed into the business `session-not-found` branch.
+ */
+ private async inspectSession(sessionId: SessionId): Promise {
+ if (this.ctx.sessions.get(sessionId) === undefined) {
+ const snapshots = await this.ctx.sessionPersistence.listSnapshots()
+ if (!snapshots.some(snapshot => snapshot.header.id === sessionId)) {
+ return rejected({ code: 'session-not-found', sessionId })
+ }
+ }
+ return success(await this.ctx.sessionPersistence.inspect(sessionId))
+ }
+
+ /** Require the exact finalized append-origin assistant message projection. */
+ private hasFeedbackTarget(inspection: SessionInspection, messageId: MessageFeedbackItem['messageId']): boolean {
+ return inspection.events.some((event) => {
+ if (event.type !== 'assistant/message' || !isAppendSurfaceEvent(event)) return false
+ const message = deriveEventMessage(event)
+ return message?.role === 'assistant' && message.id === messageId
+ })
+ }
+
+ /**
+ * Put the target log prefix behind a durability barrier before its sidecar.
+ * A live owner flushes through the SessionStore's canonical checkpoint; a
+ * cold owner is re-read from the physical durable prefix.
+ */
+ private async ensureTargetDurable(inspection: SessionInspection): Promise {
+ const live = this.ctx.sessions.get(inspection.meta.id)
+ if (live !== undefined && sameHeaderIdentity(live.header, inspection.meta)) {
+ if (!(await this.ctx.sessions.flush(live))) {
+ throw new Error(
+ `message-feedback: no durability listener participated for live session '${inspection.meta.id}'`,
+ )
+ }
+ return inspection
+ }
+ return await this.ctx.sessionPersistence.readFrom(inspection.meta.id, 0)
+ }
+
+ /** Validate optional-note semantics and the configured complete UTF-8 byte bound. */
+ private resolveNote(note: string | undefined): ResolvedNote {
+ if (note === undefined) return success(undefined)
+ if (note.trim().length === 0) return rejected({ code: 'note-blank' })
+ const actualBytes = Buffer.byteLength(note, 'utf8')
+ if (actualBytes > this.maxNoteBytes) {
+ return rejected({ code: 'note-too-large', maxBytes: this.maxNoteBytes, actualBytes })
+ }
+ return success(note)
+ }
+
+ /** Build a conflict branch without exposing an orderable version. */
+ private versionConflict(
+ request: Pick,
+ actual: MessageFeedbackVersion | null,
+ ): MessageFeedbackVersionConflict {
+ return {
+ code: 'version-conflict',
+ sessionId: request.sessionId,
+ messageId: request.messageId,
+ expected: request.ifVersion,
+ actual,
+ }
+ }
+
+ /** Queue a complete read/compare/write mutation behind this Session's prior mutation. */
+ private enqueue(sessionId: SessionId, operation: () => Promise): Promise {
+ const previous = this.operationTails.get(sessionId) ?? Promise.resolve()
+ const result = previous.then(operation)
+ const tail = result.then(() => undefined, () => undefined)
+ this.operationTails.set(sessionId, tail)
+ return result.finally(() => {
+ if (this.operationTails.get(sessionId) === tail) this.operationTails.delete(sessionId)
+ })
+ }
+
+ /** Resolve the initialized durable table or fail a broken service lifecycle. */
+ private requireTable(): KvTable {
+ if (this.table === undefined) {
+ throw new Error('message-feedback: durable domain is not initialized')
+ }
+ return this.table
+ }
+}
+
+export default MessageFeedbackService
diff --git a/packages/feedback/message-feedback/src/invariant.ts b/packages/feedback/message-feedback/src/invariant.ts
new file mode 100644
index 0000000000..5433f318f1
--- /dev/null
+++ b/packages/feedback/message-feedback/src/invariant.ts
@@ -0,0 +1,27 @@
+/** Package-owned invariant companion. @module @deepseek-ai/dsh-message-feedback/invariant */
+
+/* jscpd:ignore-start */
+import type { Context } from '@deepseek-ai/cordis'
+import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants'
+
+const PACKAGE_NAME = '@deepseek-ai/dsh-message-feedback'
+
+/** Cordis companion plugin name. */
+export const name = 'message-feedback-invariant'
+/** Services required before the companion can reserve and check package ownership. */
+export const inject = ['invariants']
+
+/**
+ * No runtime invariant: the private typed writer owns current row mutations,
+ * the domain schema validates rows on reopen, and no second authority exists.
+ */
+const install: InvariantInstaller = Object.assign(() => {}, { inject: ['messageFeedback'] })
+
+/**
+ * Register this package's invariant companion.
+ * @param ctx - Cordis context carrying the invariant service.
+ * @returns the installed registration's disposer after setup succeeds.
+ */
+export const apply = (ctx: Context): Promise<() => void> =>
+ Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install))
+/* jscpd:ignore-end */
diff --git a/packages/feedback/message-feedback/src/spec.ts b/packages/feedback/message-feedback/src/spec.ts
new file mode 100644
index 0000000000..5dcefd9cfe
--- /dev/null
+++ b/packages/feedback/message-feedback/src/spec.ts
@@ -0,0 +1,88 @@
+/**
+ * Durable storage-domain declaration for lifecycle-bound message feedback.
+ * @module @deepseek-ai/dsh-message-feedback/src/spec
+ */
+
+import { z } from 'zod'
+import type { MessageId } from '@deepseek-ai/dsh-llm/brand'
+import type { SessionId } from '@deepseek-ai/dsh-session/types'
+import { defineDomain, domainTable } from '@deepseek-ai/dsh-storage-domain'
+import type { MessageFeedbackItem, MessageFeedbackRating, MessageFeedbackVersion } from './types.ts'
+
+const nonNegativeSafeInteger = z.number().int().nonnegative().max(Number.MAX_SAFE_INTEGER)
+
+/** Runtime schema for the closed rating vocabulary. */
+export const messageFeedbackRatingSchema = z.union([
+ z.literal('positive'),
+ z.literal('negative'),
+]) satisfies z.ZodType
+
+/** Runtime schema for one opaque item version stored on disk. */
+export const messageFeedbackVersionSchema = z.uuid()
+ .transform(value => value as MessageFeedbackVersion)
+
+/** Runtime schema for one current feedback item. */
+export const messageFeedbackItemSchema = z.object({
+ messageId: z.string().min(1).transform(value => value as MessageId),
+ rating: messageFeedbackRatingSchema,
+ note: z.string().refine(note => note.trim().length > 0, {
+ message: 'message feedback note must contain a non-whitespace character',
+ }).optional(),
+ version: messageFeedbackVersionSchema,
+ createdAt: nonNegativeSafeInteger,
+ updatedAt: nonNegativeSafeInteger,
+}).refine(item => item.updatedAt >= item.createdAt, {
+ path: ['updatedAt'],
+ message: 'message feedback updatedAt must not precede createdAt',
+}) as unknown as z.ZodType
+
+/** Persisted Session fields that fence a sidecar row to one log lifecycle. */
+export const messageFeedbackSessionIdentitySchema = z.object({
+ createdAt: nonNegativeSafeInteger,
+ cwd: z.string().optional(),
+})
+
+/** Persisted lifecycle identity inferred from its durable schema. */
+export type MessageFeedbackSessionIdentity = z.infer
+
+/**
+ * One whole-Session sidecar. Duplicate message ids would make item lookup
+ * ambiguous; duplicate versions would break their independent identity.
+ */
+export const messageFeedbackRowSchema = z.object({
+ session: messageFeedbackSessionIdentitySchema,
+ items: z.array(messageFeedbackItemSchema),
+}).superRefine((row, ctx) => {
+ const messageIds = new Set()
+ const versions = new Set()
+ row.items.forEach((item, index) => {
+ if (messageIds.has(item.messageId)) {
+ ctx.addIssue({
+ code: 'custom',
+ path: ['items', index, 'messageId'],
+ message: `duplicate message feedback id '${item.messageId}'`,
+ })
+ }
+ messageIds.add(item.messageId)
+ if (versions.has(item.version)) {
+ ctx.addIssue({
+ code: 'custom',
+ path: ['items', index, 'version'],
+ message: `duplicate message feedback version '${item.version}'`,
+ })
+ }
+ versions.add(item.version)
+ })
+})
+
+/** Durable sidecar row inferred from {@link messageFeedbackRowSchema}. */
+export type MessageFeedbackRow = z.infer
+
+/** One lifecycle-bound sidecar record per Session id. */
+export const messageFeedbackDomainSpec = defineDomain({
+ name: 'message_feedback',
+ version: 0,
+ tables: {
+ sessions: domainTable(messageFeedbackRowSchema),
+ },
+})
diff --git a/packages/feedback/message-feedback/src/types.ts b/packages/feedback/message-feedback/src/types.ts
new file mode 100644
index 0000000000..a802bfc05a
--- /dev/null
+++ b/packages/feedback/message-feedback/src/types.ts
@@ -0,0 +1,151 @@
+/**
+ * Public request, value, and failure vocabulary for per-message feedback.
+ * This module contains types only so generated Remote clients can consume it
+ * without importing Host runtime code.
+ * @module @deepseek-ai/dsh-message-feedback/types
+ */
+
+import type { Branded } from '@deepseek-ai/dsh-brand'
+import type { MessageId } from '@deepseek-ai/dsh-llm/brand'
+import type { SessionId } from '@deepseek-ai/dsh-session/types'
+
+/** Opaque compare-and-set token for one exact feedback item revision. */
+export type MessageFeedbackVersion = Branded<'MessageFeedbackVersion'>
+
+/** The human's overall judgment of one assistant message. */
+export type MessageFeedbackRating = 'positive' | 'negative'
+
+/** One current feedback value and its opaque mutation token. */
+export 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
+}
+
+/** Read all message feedback belonging to one persisted Session lifecycle. */
+export interface MessageFeedbackListRequest {
+ /** Persisted Session whose sidecar should be read. */
+ readonly sessionId: SessionId
+}
+
+/** Current feedback values for one Session, in first-creation order. */
+export interface MessageFeedbackListValue {
+ /** Fresh immutable item snapshots. */
+ readonly items: readonly MessageFeedbackItem[]
+}
+
+/** Create or replace feedback for one assistant message. */
+export 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
+}
+
+/** Delete feedback for one message after observing its current version. */
+export 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
+}
+
+/** Idempotent deletion acknowledgement. */
+export interface MessageFeedbackDeleteValue {
+ /** Stable postcondition shared by the first deletion and every retry. */
+ readonly absent: true
+}
+
+/** No persisted Session header exists for the requested id. */
+export interface MessageFeedbackSessionNotFound {
+ readonly code: 'session-not-found'
+ readonly sessionId: SessionId
+}
+
+/** The id does not name a derived, append-origin assistant message. */
+export interface MessageFeedbackTargetNotFound {
+ readonly code: 'target-not-found'
+ readonly sessionId: SessionId
+ readonly messageId: MessageId
+}
+
+/** A material mutation did not match the addressed item's current version. */
+export interface MessageFeedbackVersionConflict {
+ readonly code: 'version-conflict'
+ readonly sessionId: SessionId
+ readonly messageId: MessageId
+ /** Version supplied by the caller (`null` means create-only). */
+ readonly expected: MessageFeedbackVersion | null
+ /** Current version, or `null` when the item does not exist. */
+ readonly actual: MessageFeedbackVersion | null
+}
+
+/** A supplied note contains no non-whitespace character. */
+export interface MessageFeedbackNoteBlank {
+ readonly code: 'note-blank'
+}
+
+/** A supplied note exceeds the configured UTF-8 byte limit. */
+export interface MessageFeedbackNoteTooLarge {
+ readonly code: 'note-too-large'
+ readonly maxBytes: number
+ readonly actualBytes: number
+}
+
+/** Failures shared by the public message-feedback operations. */
+export type MessageFeedbackFailure =
+ | MessageFeedbackSessionNotFound
+ | MessageFeedbackTargetNotFound
+ | MessageFeedbackVersionConflict
+ | MessageFeedbackNoteBlank
+ | MessageFeedbackNoteTooLarge
+
+/** Successful public operation result. */
+export interface MessageFeedbackSuccess {
+ readonly ok: true
+ readonly value: T
+}
+
+/** Rejected public operation result with a stable business failure. */
+export interface MessageFeedbackRejected {
+ readonly ok: false
+ readonly error: E
+}
+
+/** Result returned by the message-feedback `list` operation. */
+export type MessageFeedbackListResult =
+ | MessageFeedbackSuccess
+ | MessageFeedbackRejected
+
+/** Result returned by the message-feedback `put` operation. */
+export type MessageFeedbackPutResult =
+ | MessageFeedbackSuccess
+ | MessageFeedbackRejected<
+ | MessageFeedbackSessionNotFound
+ | MessageFeedbackTargetNotFound
+ | MessageFeedbackVersionConflict
+ | MessageFeedbackNoteBlank
+ | MessageFeedbackNoteTooLarge
+ >
+
+/** Result returned by the message-feedback `delete` operation. */
+export type MessageFeedbackDeleteResult =
+ | MessageFeedbackSuccess
+ | MessageFeedbackRejected
diff --git a/packages/feedback/message-feedback/tests/helpers.ts b/packages/feedback/message-feedback/tests/helpers.ts
new file mode 100644
index 0000000000..fdb7ebe3b2
--- /dev/null
+++ b/packages/feedback/message-feedback/tests/helpers.ts
@@ -0,0 +1,206 @@
+import { mkdtemp, rm } from 'node:fs/promises'
+import { tmpdir } from 'node:os'
+import { join } from 'node:path'
+import { Context } from '@deepseek-ai/cordis'
+import { createAssistantMessage, createUserMessage } from '@deepseek-ai/dsh-llm'
+import type { MessageId } from '@deepseek-ai/dsh-llm/brand'
+import SessionStore, {
+ SESSION_FORMAT_VERSION,
+ Session,
+ SessionId,
+ type SessionEvent,
+ type SessionHeader,
+} from '@deepseek-ai/dsh-session'
+import SessionPersistence, {
+ SessionPersistenceRevision,
+ type SessionInspection,
+ type SessionLocation,
+ type SessionPersistenceSnapshot,
+} from '@deepseek-ai/dsh-session-persistence'
+import Storage from '@deepseek-ai/dsh-storage'
+import * as StorageDomain from '@deepseek-ai/dsh-storage-domain'
+import * as StorageJson from '@deepseek-ai/dsh-storage-json'
+import MessageFeedbackService from '../src/index.ts'
+
+export interface MessageFixture {
+ readonly session: Session
+ readonly userMessageId: MessageId
+ readonly assistantMessageIds: readonly [MessageId, MessageId]
+ readonly emptyAssistantMessageId: MessageId
+ readonly replacementAssistantMessageId: MessageId
+}
+
+/** Append one deterministic transcript surface used by target-validation tests. */
+export function appendMessageFixture(session: Session): Omit {
+ session.append('turn/start', { turn: 1 })
+ session.append('step/start', { turn: 1, step: 1 })
+ const user = createUserMessage({
+ content: [{ type: 'text', text: 'Question' }],
+ source: { kind: 'user' },
+ })
+ session.append('user/message', user, { surfaceOp: 'append' })
+
+ const first = createAssistantMessage({
+ content: [{ type: 'text', text: 'First answer' }],
+ source: { provider: 'test', model: 'test' },
+ })
+ const firstEvent = session.append('assistant/message', {
+ turn: 1,
+ step: 1,
+ message: first,
+ }, { surfaceOp: 'append' })
+ const second = createAssistantMessage({
+ content: [{ type: 'text', text: 'Second answer' }],
+ source: { provider: 'test', model: 'test' },
+ })
+ session.append('assistant/message', {
+ turn: 1,
+ step: 1,
+ message: second,
+ }, { surfaceOp: 'append' })
+ const empty = createAssistantMessage({
+ content: [],
+ source: { provider: 'test', model: 'test' },
+ })
+ session.append('assistant/message', {
+ turn: 1,
+ step: 1,
+ message: empty,
+ }, { surfaceOp: 'append' })
+ session.append('step/end', { turn: 1, step: 1 })
+ session.append('turn/end', { turn: 1, reason: { kind: 'completed' } })
+
+ const replacement = createAssistantMessage({
+ content: [{ type: 'text', text: 'Model-only replacement' }],
+ source: { provider: 'test', model: 'test' },
+ })
+ session.append('assistant/message', {
+ turn: 1,
+ step: 1,
+ message: replacement,
+ }, {
+ surfaceOp: { op: 'replace', start: firstEvent.seq, end: firstEvent.seq },
+ sourceEventSeqs: [firstEvent.seq],
+ })
+
+ return {
+ userMessageId: user.id,
+ assistantMessageIds: [first.id, second.id],
+ emptyAssistantMessageId: empty.id,
+ replacementAssistantMessageId: replacement.id,
+ }
+}
+
+/** Construct one cold persistence fixture without publishing a live Session. */
+export function messageFixture(
+ rawId: string,
+ options: { readonly createdAt?: number; readonly cwd?: string } = {},
+): MessageFixture {
+ const id = SessionId(rawId)
+ const header: SessionHeader = {
+ version: SESSION_FORMAT_VERSION,
+ id,
+ createdAt: options.createdAt ?? 1_700_000_000_000,
+ ...(options.cwd === undefined ? {} : { cwd: options.cwd }),
+ }
+ const session = Session.create(id, [], header)
+ return { session, ...appendMessageFixture(session) }
+}
+
+/** Minimal controllable persistence provider for service-level tests. */
+class TestPersistence extends SessionPersistence {
+ static inject = ['sessions']
+
+ readonly durable = new Map()
+ readonly logical = new Map()
+ inspectFailure: Error | undefined
+ inspectCalls = 0
+ readFromCalls = 0
+ onReadFrom: (() => void) | undefined
+
+ locate(_meta: SessionHeader): SessionLocation | undefined { return undefined }
+ create(_meta: SessionHeader): Promise { return Promise.resolve() }
+ append(_id: SessionId, _events: readonly SessionEvent[]): Promise { return Promise.resolve() }
+
+ load(id: SessionId): Promise {
+ return this.readFrom(id, 0)
+ }
+
+ inspect(id: SessionId): Promise {
+ this.inspectCalls += 1
+ if (this.inspectFailure !== undefined) return Promise.reject(this.inspectFailure)
+ const explicit = this.logical.get(id)
+ if (explicit !== undefined) return Promise.resolve(explicit)
+ const live = this.ctx.sessions.get(id)
+ if (live !== undefined) return Promise.resolve({ meta: live.header, events: live.events })
+ const stored = this.durable.get(id)
+ return stored === undefined
+ ? Promise.reject(new Error(`test persistence: session '${id}' not found`))
+ : Promise.resolve(stored)
+ }
+
+ readFrom(
+ id: SessionId,
+ fromSeq: number,
+ ): Promise<{ meta: SessionHeader; events: SessionEvent[] }> {
+ this.readFromCalls += 1
+ 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) })
+ }
+
+ list(): Promise {
+ return Promise.resolve([...this.durable.values()].map(value => value.meta))
+ }
+
+ listSnapshots(): Promise {
+ return Promise.resolve([...this.durable.values()].map((value, index) => ({
+ header: value.meta,
+ revision: SessionPersistenceRevision(`test:${index}:${value.events.length}`),
+ })))
+ }
+
+ persist(session: Session): void {
+ this.durable.set(session.id, { meta: session.header, events: session.events })
+ }
+
+ setDurable(inspection: SessionInspection): void {
+ this.durable.set(inspection.meta.id, inspection)
+ }
+}
+
+export interface TestHarness {
+ readonly ctx: Context
+ readonly persistence: TestPersistence
+ readonly root: string
+ dispose(): Promise
+}
+
+/** Compose the service over the real storage hub/domain/JSON backend. */
+export async function setupHarness(maxNoteBytes = 64): Promise {
+ const root = await mkdtemp(join(tmpdir(), 'dsh-message-feedback-test-'))
+ const ctx = new Context()
+ 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 })
+ } catch (error) {
+ await ctx.fiber.dispose()
+ await rm(root, { recursive: true, force: true })
+ throw error
+ }
+ return {
+ ctx,
+ persistence: ctx.sessionPersistence as unknown as TestPersistence,
+ root,
+ async dispose() {
+ await ctx.fiber.dispose()
+ await rm(root, { recursive: true, force: true })
+ },
+ }
+}
diff --git a/packages/feedback/message-feedback/tests/loader-composition.spec.ts b/packages/feedback/message-feedback/tests/loader-composition.spec.ts
new file mode 100644
index 0000000000..98a7f29243
--- /dev/null
+++ b/packages/feedback/message-feedback/tests/loader-composition.spec.ts
@@ -0,0 +1,115 @@
+import { mkdtemp, rm, writeFile } from 'node:fs/promises'
+import { tmpdir } from 'node:os'
+import { join } from 'node:path'
+import { pathToFileURL } from 'node:url'
+import { afterEach, describe, expect, it } from 'vitest'
+import { Context } from '@deepseek-ai/cordis'
+import Include from '@deepseek-ai/cordis-plugin-include'
+import Loader from '@deepseek-ai/cordis-plugin-loader'
+import SessionStore, { SessionId } from '@deepseek-ai/dsh-session'
+import SessionPersistenceJsonl from '@deepseek-ai/dsh-session-persistence-jsonl'
+import Storage from '@deepseek-ai/dsh-storage'
+import * as StorageDomain from '@deepseek-ai/dsh-storage-domain'
+import * as StorageJson from '@deepseek-ai/dsh-storage-json'
+import { remoteMethods } from '@deepseek-ai/dsh-type-meta'
+import MessageFeedbackService from '../src/index.ts'
+import { appendMessageFixture } from './helpers.ts'
+
+let root: string | undefined
+const contexts: Context[] = []
+
+afterEach(async () => {
+ await Promise.all(contexts.splice(0).map(ctx => ctx.fiber.dispose()))
+ if (root !== undefined) await rm(root, { recursive: true, force: true })
+ root = undefined
+})
+async function loadComposition(configPath: string): Promise {
+ const ctx = new Context()
+ contexts.push(ctx)
+ ctx.baseUrl = pathToFileURL(root as string).href + '/'
+ await ctx.plugin(Loader)
+ ctx.loader.builtins.include = Include
+ const modules = new Map([
+ ['@deepseek-ai/dsh-session', SessionStore],
+ ['@deepseek-ai/dsh-session-persistence-jsonl', SessionPersistenceJsonl],
+ ['@deepseek-ai/dsh-storage', Storage],
+ ['@deepseek-ai/dsh-storage-json', StorageJson],
+ ['@deepseek-ai/dsh-storage-domain', StorageDomain],
+ ['@deepseek-ai/dsh-message-feedback', MessageFeedbackService],
+ ])
+ ctx.loader.internal = {
+ version: 'v2',
+ async import(specifier: string) {
+ if (!modules.has(specifier)) throw new Error(`unexpected Loader import: ${specifier}`)
+ return modules.get(specifier)
+ },
+ } as unknown as NonNullable
+ await ctx.loader.create({
+ name: 'cordis:include',
+ config: { path: pathToFileURL(configPath).href },
+ })
+ await ctx.loader.await()
+ const unloaded = [...ctx.loader.entries()]
+ .filter(entry => entry.fiber === undefined && !entry.disabled)
+ .map(entry => entry.options.name)
+ expect(unloaded).toEqual([])
+ return ctx
+}
+
+describe('message feedback through a real Loader composition', () => {
+ it('persists a checkpointed target and its sidecar across a cold restart', async () => {
+ root = await mkdtemp(join(tmpdir(), 'dsh-message-feedback-loader-'))
+ const configPath = join(root, 'cordis.yml')
+ await writeFile(configPath, [
+ "- name: '@deepseek-ai/dsh-session'",
+ "- name: '@deepseek-ai/dsh-session-persistence-jsonl'",
+ ' config:',
+ ` root: ${JSON.stringify(join(root, 'sessions'))}`,
+ ' compression: none',
+ ' writeBatchMaxDelayMs: 1',
+ "- name: '@deepseek-ai/dsh-storage'",
+ "- name: '@deepseek-ai/dsh-storage-json'",
+ ' config:',
+ ` root: ${JSON.stringify(join(root, 'storage'))}`,
+ "- name: '@deepseek-ai/dsh-storage-domain'",
+ ' config:',
+ ' backend: json',
+ "- name: '@deepseek-ai/dsh-message-feedback'",
+ ' config:',
+ ' maxNoteBytes: 32',
+ '',
+ ].join('\n'))
+
+ const first = await loadComposition(configPath)
+ expect(first.messageFeedback.typertGateway.namespace).toBe('messageFeedback')
+ expect(remoteMethods(first.messageFeedback).map(marker => marker.method))
+ .toEqual(['list', 'put', 'delete'])
+
+ const session = first.sessions.create(SessionId('loader-feedback'), {
+ meta: { cwd: root },
+ })
+ const fixture = appendMessageFixture(session)
+ const put = await first.messageFeedback.put({
+ sessionId: session.id,
+ messageId: fixture.assistantMessageIds[0],
+ rating: 'positive',
+ note: 'survives restart',
+ ifVersion: null,
+ })
+ if (!put.ok) throw new Error(`expected put success, got ${put.error.code}`)
+ const durable = await first.sessionPersistence.readFrom(session.id, 0)
+ expect(durable.events.some(event =>
+ event.type === 'assistant/message'
+ && event.data.message.id === fixture.assistantMessageIds[0])).toBe(true)
+
+ await first.fiber.dispose()
+ contexts.splice(contexts.indexOf(first), 1)
+
+ const second = await loadComposition(configPath)
+ await expect(second.messageFeedback.list({ sessionId: session.id })).resolves.toEqual({
+ ok: true,
+ value: { items: [put.value] },
+ })
+ expect(second.sessions.get(session.id)).toBeUndefined()
+ })
+})
diff --git a/packages/feedback/message-feedback/tests/message-feedback.spec.ts b/packages/feedback/message-feedback/tests/message-feedback.spec.ts
new file mode 100644
index 0000000000..881bf9dab5
--- /dev/null
+++ b/packages/feedback/message-feedback/tests/message-feedback.spec.ts
@@ -0,0 +1,546 @@
+import { randomUUID } from 'node:crypto'
+import { afterEach, describe, expect, it, vi } from 'vitest'
+import { Context } from '@deepseek-ai/cordis'
+import type { MessageId } from '@deepseek-ai/dsh-llm/brand'
+import { Session, SessionId } from '@deepseek-ai/dsh-session'
+import { remoteMethods } from '@deepseek-ai/dsh-type-meta'
+import MessageFeedbackService, { messageFeedbackRowSchema } from '../src/index.ts'
+import type {
+ MessageFeedbackItem,
+ MessageFeedbackVersion,
+} from '../src/index.ts'
+import {
+ appendMessageFixture,
+ messageFixture,
+ setupHarness,
+ type TestHarness,
+} from './helpers.ts'
+
+const harnesses: TestHarness[] = []
+
+async function harness(maxNoteBytes = 64): Promise {
+ const value = await setupHarness(maxNoteBytes)
+ harnesses.push(value)
+ return value
+}
+
+afterEach(async () => {
+ vi.useRealTimers()
+ await Promise.all(harnesses.splice(0).map(value => value.dispose()))
+})
+
+function staleVersion(): MessageFeedbackVersion {
+ return randomUUID() as MessageFeedbackVersion
+}
+
+function expectItem(
+ result: Awaited>,
+): MessageFeedbackItem {
+ if (!result.ok) throw new Error(`expected feedback item, got ${result.error.code}`)
+ return result.value
+}
+
+describe('MessageFeedbackService public contract', () => {
+ it('publishes the exact Gateway namespace and Remote method names', async () => {
+ const { ctx } = await harness()
+ const binding = ctx.messageFeedback.typertGateway
+ expect(binding.serviceKey).toBe('messageFeedback')
+ expect(binding.namespace).toBe('messageFeedback')
+ expect(remoteMethods(ctx.messageFeedback)).toEqual([
+ { method: 'list', invocation: { kind: 'direct' } },
+ { method: 'put', invocation: { kind: 'direct' } },
+ { method: 'delete', invocation: { kind: 'direct' } },
+ ])
+ })
+
+ it('returns session-not-found only for a definite persistence miss', async () => {
+ const { ctx, persistence } = await harness()
+ const missing = SessionId('missing-session')
+ await expect(ctx.messageFeedback.list({ sessionId: missing })).resolves.toEqual({
+ ok: false,
+ error: { code: 'session-not-found', sessionId: missing },
+ })
+
+ const fixture = messageFixture('corrupt-session')
+ persistence.setDurable({ meta: fixture.session.header, events: fixture.session.events })
+ const corruption = new Error('stored log checksum mismatch')
+ persistence.inspectFailure = corruption
+ await expect(ctx.messageFeedback.list({ sessionId: fixture.session.id })).rejects.toBe(corruption)
+ })
+
+ it('returns session-not-found from mutations and conflicts on an observed version for an absent item', async () => {
+ const { ctx, persistence } = await harness()
+ const missing = SessionId('missing-mutations')
+ const missingMessage = 'missing-message' as MessageId
+ await expect(ctx.messageFeedback.put({
+ sessionId: missing,
+ messageId: missingMessage,
+ rating: 'positive',
+ ifVersion: null,
+ })).resolves.toEqual({
+ ok: false,
+ error: { code: 'session-not-found', sessionId: missing },
+ })
+ await expect(ctx.messageFeedback.delete({
+ sessionId: missing,
+ messageId: missingMessage,
+ ifVersion: staleVersion(),
+ })).resolves.toEqual({
+ ok: false,
+ error: { code: 'session-not-found', sessionId: missing },
+ })
+
+ const fixture = messageFixture('absent-version-conflict')
+ persistence.persist(fixture.session)
+ const expected = staleVersion()
+ await expect(ctx.messageFeedback.put({
+ sessionId: fixture.session.id,
+ messageId: fixture.assistantMessageIds[0],
+ rating: 'positive',
+ ifVersion: expected,
+ })).resolves.toEqual({
+ ok: false,
+ error: {
+ code: 'version-conflict',
+ sessionId: fixture.session.id,
+ messageId: fixture.assistantMessageIds[0],
+ expected,
+ actual: null,
+ },
+ })
+ })
+
+ it('creates, updates, and retry-reads immutable items with monotonic Host times', async () => {
+ const { ctx, persistence } = await harness()
+ const fixture = messageFixture('timestamps')
+ persistence.persist(fixture.session)
+ const messageId = fixture.assistantMessageIds[0]
+
+ vi.useFakeTimers()
+ vi.setSystemTime(1_700_000_001_000)
+ const created = expectItem(await ctx.messageFeedback.put({
+ sessionId: fixture.session.id,
+ messageId,
+ rating: 'positive',
+ note: ' exact prose ',
+ ifVersion: null,
+ }))
+ expect(created).toMatchObject({
+ messageId,
+ rating: 'positive',
+ note: ' exact prose ',
+ createdAt: 1_700_000_001_000,
+ updatedAt: 1_700_000_001_000,
+ })
+ expect(created.version).toMatch(/^[0-9a-f-]{36}$/u)
+ expect(Object.isFrozen(created)).toBe(true)
+
+ vi.setSystemTime(1_700_000_000_000)
+ const updated = expectItem(await ctx.messageFeedback.put({
+ sessionId: fixture.session.id,
+ messageId,
+ rating: 'negative',
+ ifVersion: created.version,
+ }))
+ expect(updated).toMatchObject({
+ messageId,
+ rating: 'negative',
+ createdAt: created.createdAt,
+ updatedAt: created.updatedAt,
+ })
+ expect(updated.version).not.toBe(created.version)
+
+ const retry = expectItem(await ctx.messageFeedback.put({
+ sessionId: fixture.session.id,
+ messageId,
+ rating: 'negative',
+ ifVersion: null,
+ }))
+ expect(retry).toEqual(updated)
+
+ const listed = await ctx.messageFeedback.list({ sessionId: fixture.session.id })
+ if (!listed.ok) throw new Error(`expected list success, got ${listed.error.code}`)
+ expect(listed.value.items).toEqual([updated])
+ expect(listed.value.items[0]).not.toBe(updated)
+ expect(Object.isFrozen(listed.value)).toBe(true)
+ expect(Object.isFrozen(listed.value.items)).toBe(true)
+ expect(Object.isFrozen(listed.value.items[0])).toBe(true)
+ })
+
+ it('reports non-blank and complete UTF-8 byte limits without touching persistence', async () => {
+ const { ctx, persistence } = await harness(4)
+ const fixture = messageFixture('note-limits')
+ persistence.persist(fixture.session)
+ const messageId = fixture.assistantMessageIds[0]
+ const before = persistence.inspectCalls
+
+ await expect(ctx.messageFeedback.put({
+ sessionId: fixture.session.id,
+ messageId,
+ rating: 'positive',
+ note: ' \n\t ',
+ ifVersion: null,
+ })).resolves.toEqual({ ok: false, error: { code: 'note-blank' } })
+ await expect(ctx.messageFeedback.put({
+ sessionId: fixture.session.id,
+ messageId,
+ rating: 'positive',
+ note: 'ééé',
+ ifVersion: null,
+ })).resolves.toEqual({
+ ok: false,
+ error: { code: 'note-too-large', maxBytes: 4, actualBytes: 6 },
+ })
+ expect(persistence.inspectCalls).toBe(before)
+
+ expectItem(await ctx.messageFeedback.put({
+ sessionId: fixture.session.id,
+ messageId,
+ rating: 'positive',
+ note: '😀',
+ ifVersion: null,
+ }))
+ })
+
+ it('accepts only non-empty append-origin assistant projections as targets', async () => {
+ const { ctx, persistence } = await harness()
+ const fixture = messageFixture('targets')
+ persistence.persist(fixture.session)
+ const rejectedTargets: MessageId[] = [
+ fixture.userMessageId,
+ fixture.emptyAssistantMessageId,
+ fixture.replacementAssistantMessageId,
+ ]
+ for (const messageId of rejectedTargets) {
+ await expect(ctx.messageFeedback.put({
+ sessionId: fixture.session.id,
+ messageId,
+ rating: 'positive',
+ ifVersion: null,
+ })).resolves.toEqual({
+ ok: false,
+ error: {
+ code: 'target-not-found',
+ sessionId: fixture.session.id,
+ messageId,
+ },
+ })
+ }
+ expectItem(await ctx.messageFeedback.put({
+ sessionId: fixture.session.id,
+ messageId: fixture.assistantMessageIds[0],
+ rating: 'positive',
+ ifVersion: null,
+ }))
+ })
+
+ it('fails invalid direct configuration and a read before domain initialization', async () => {
+ const invalidCtx = new Context()
+ expect(() => new MessageFeedbackService(invalidCtx, { maxNoteBytes: 0 }))
+ .toThrow(/positive safe integer/u)
+ await invalidCtx.fiber.dispose()
+
+ const fixture = messageFixture('uninitialized-domain')
+ const rawCtx = new Context()
+ rawCtx.provide('sessions', { get: () => undefined } as never)
+ rawCtx.provide('sessionPersistence', {
+ listSnapshots: () => Promise.resolve([{ header: fixture.session.header, revision: 'test' }]),
+ inspect: () => Promise.resolve({ meta: fixture.session.header, events: fixture.session.events }),
+ } as never)
+ const raw = new MessageFeedbackService(rawCtx, { maxNoteBytes: 1 })
+ await expect(raw.list({ sessionId: fixture.session.id }))
+ .rejects.toThrow(/durable domain is not initialized/u)
+ await rawCtx.fiber.dispose()
+ })
+
+ it('rejects durable rows with duplicate message ids or reused item versions', () => {
+ const version = staleVersion()
+ const duplicate = messageFeedbackRowSchema.safeParse({
+ session: { createdAt: 1 },
+ items: [
+ {
+ messageId: 'same-message',
+ rating: 'positive',
+ version,
+ createdAt: 1,
+ updatedAt: 1,
+ },
+ {
+ messageId: 'same-message',
+ rating: 'negative',
+ version,
+ createdAt: 1,
+ updatedAt: 1,
+ },
+ ],
+ })
+ expect(duplicate.success).toBe(false)
+ if (duplicate.success) throw new Error('expected duplicate row rejection')
+ expect(duplicate.error.issues.map(issue => issue.path.join('.')))
+ .toEqual(['items.1.messageId', 'items.1.version'])
+ })
+})
+
+describe('MessageFeedbackService item concurrency', () => {
+ it('serializes whole-row writes while keeping versions independent per message', async () => {
+ const { ctx, persistence } = await harness()
+ const fixture = messageFixture('concurrent-items')
+ persistence.persist(fixture.session)
+ const [firstId, secondId] = fixture.assistantMessageIds
+
+ const [firstResult, secondResult] = await Promise.all([
+ ctx.messageFeedback.put({
+ sessionId: fixture.session.id,
+ messageId: firstId,
+ rating: 'positive',
+ ifVersion: null,
+ }),
+ ctx.messageFeedback.put({
+ sessionId: fixture.session.id,
+ messageId: secondId,
+ rating: 'negative',
+ ifVersion: null,
+ }),
+ ])
+ const first = expectItem(firstResult)
+ const second = expectItem(secondResult)
+ const updated = expectItem(await ctx.messageFeedback.put({
+ sessionId: fixture.session.id,
+ messageId: firstId,
+ rating: 'negative',
+ note: 'changed',
+ ifVersion: first.version,
+ }))
+
+ await expect(ctx.messageFeedback.put({
+ sessionId: fixture.session.id,
+ messageId: firstId,
+ rating: 'positive',
+ note: 'stale change',
+ ifVersion: first.version,
+ })).resolves.toEqual({
+ ok: false,
+ error: {
+ code: 'version-conflict',
+ sessionId: fixture.session.id,
+ messageId: firstId,
+ expected: first.version,
+ actual: updated.version,
+ },
+ })
+
+ const listed = await ctx.messageFeedback.list({ sessionId: fixture.session.id })
+ if (!listed.ok) throw new Error(`expected list success, got ${listed.error.code}`)
+ expect(listed.value.items).toEqual([updated, second])
+ expect(listed.value.items[1]?.version).toBe(second.version)
+ })
+
+ it('makes delete retries stable and prevents delete/recreate ABA', async () => {
+ const { ctx, persistence } = await harness()
+ const fixture = messageFixture('delete-aba')
+ persistence.persist(fixture.session)
+ const messageId = fixture.assistantMessageIds[0]
+ const created = expectItem(await ctx.messageFeedback.put({
+ sessionId: fixture.session.id,
+ messageId,
+ rating: 'positive',
+ ifVersion: null,
+ }))
+
+ await expect(ctx.messageFeedback.delete({
+ sessionId: fixture.session.id,
+ messageId,
+ ifVersion: staleVersion(),
+ })).resolves.toMatchObject({
+ ok: false,
+ error: { code: 'version-conflict', actual: created.version },
+ })
+ const request = {
+ sessionId: fixture.session.id,
+ messageId,
+ ifVersion: created.version,
+ }
+ await expect(ctx.messageFeedback.delete(request)).resolves.toEqual({
+ ok: true,
+ value: { absent: true },
+ })
+ await expect(ctx.messageFeedback.delete(request)).resolves.toEqual({
+ ok: true,
+ value: { absent: true },
+ })
+
+ const recreated = expectItem(await ctx.messageFeedback.put({
+ sessionId: fixture.session.id,
+ messageId,
+ rating: 'negative',
+ ifVersion: null,
+ }))
+ expect(recreated.version).not.toBe(created.version)
+ await expect(ctx.messageFeedback.delete(request)).resolves.toMatchObject({
+ ok: false,
+ error: { code: 'version-conflict', actual: recreated.version },
+ })
+ })
+
+ it('fences a reused Session id and lets the new lifecycle start cleanly', async () => {
+ const { ctx, persistence } = await harness()
+ const old = messageFixture('reused-session', { createdAt: 10, cwd: '/old' })
+ persistence.persist(old.session)
+ const oldItem = expectItem(await ctx.messageFeedback.put({
+ sessionId: old.session.id,
+ messageId: old.assistantMessageIds[0],
+ rating: 'positive',
+ ifVersion: null,
+ }))
+
+ const replacement = Session.create(
+ old.session.id,
+ old.session.events,
+ { ...old.session.header, createdAt: 20, cwd: '/new' },
+ )
+ persistence.persist(replacement)
+ await expect(ctx.messageFeedback.list({ sessionId: replacement.id })).resolves.toEqual({
+ ok: true,
+ value: { items: [] },
+ })
+ await expect(ctx.messageFeedback.delete({
+ sessionId: replacement.id,
+ messageId: old.assistantMessageIds[0],
+ ifVersion: oldItem.version,
+ })).resolves.toEqual({ ok: true, value: { absent: true } })
+
+ const newItem = expectItem(await ctx.messageFeedback.put({
+ sessionId: replacement.id,
+ messageId: old.assistantMessageIds[0],
+ rating: 'negative',
+ ifVersion: null,
+ }))
+ expect(newItem.version).not.toBe(oldItem.version)
+ })
+})
+
+describe('MessageFeedbackService durability ordering', () => {
+ it('rejects a logical target missing from the cold physical durable prefix', async () => {
+ const { ctx, persistence } = await harness()
+ const fixture = messageFixture('cold-prefix')
+ persistence.logical.set(fixture.session.id, {
+ meta: fixture.session.header,
+ events: fixture.session.events,
+ })
+ persistence.setDurable({ meta: fixture.session.header, events: [] })
+
+ await expect(ctx.messageFeedback.put({
+ sessionId: fixture.session.id,
+ messageId: fixture.assistantMessageIds[0],
+ rating: 'positive',
+ ifVersion: null,
+ })).resolves.toEqual({
+ ok: false,
+ error: {
+ code: 'target-not-found',
+ sessionId: fixture.session.id,
+ messageId: fixture.assistantMessageIds[0],
+ },
+ })
+ expect(persistence.readFromCalls).toBe(1)
+ await expect(ctx.messageFeedback.list({ sessionId: fixture.session.id })).resolves.toEqual({
+ ok: true,
+ value: { items: [] },
+ })
+ })
+
+ it('commits a live target checkpoint before the sidecar write without a cold-log reread', async () => {
+ const { ctx, persistence } = await harness()
+ const session = ctx.sessions.create(SessionId('live-checkpoint'), {
+ meta: { createdAt: 30, cwd: '/live' },
+ })
+ const fixture = appendMessageFixture(session)
+ const order: string[] = []
+ ctx.on('session/flush', (current) => {
+ order.push('session:durable')
+ persistence.persist(current)
+ })
+ ctx.on('domain/changed', (change) => {
+ if (change.domain === 'message_feedback') order.push('sidecar:durable')
+ })
+ persistence.onReadFrom = () => { order.push('unexpected:cold-read') }
+
+ expectItem(await ctx.messageFeedback.put({
+ sessionId: session.id,
+ messageId: fixture.assistantMessageIds[0],
+ rating: 'positive',
+ ifVersion: null,
+ }))
+ expect(order).toEqual(['session:durable', 'sidecar:durable'])
+ expect(persistence.readFromCalls).toBe(0)
+ expect(persistence.durable.get(session.id)?.events).toContainEqual(
+ expect.objectContaining({ type: 'assistant/message' }),
+ )
+ })
+
+ it('fails closed when a live checkpoint fails or has no participant', async () => {
+ const failed = await harness()
+ const failedSession = failed.ctx.sessions.create(SessionId('live-flush-failure'))
+ const failedFixture = appendMessageFixture(failedSession)
+ const diskFailure = new Error('disk unavailable')
+ failed.ctx.on('session/flush', () => { throw diskFailure })
+ await expect(failed.ctx.messageFeedback.put({
+ sessionId: failedSession.id,
+ messageId: failedFixture.assistantMessageIds[0],
+ rating: 'positive',
+ ifVersion: null,
+ })).rejects.toBe(diskFailure)
+ await expect(failed.ctx.messageFeedback.list({ sessionId: failedSession.id })).resolves.toEqual({
+ ok: true,
+ value: { items: [] },
+ })
+
+ const absent = await harness()
+ const absentSession = absent.ctx.sessions.create(SessionId('live-no-flush'))
+ const absentFixture = appendMessageFixture(absentSession)
+ await expect(absent.ctx.messageFeedback.put({
+ sessionId: absentSession.id,
+ messageId: absentFixture.assistantMessageIds[0],
+ rating: 'positive',
+ ifVersion: null,
+ })).rejects.toThrow(/no durability listener participated/u)
+ await expect(absent.ctx.messageFeedback.list({ sessionId: absentSession.id })).resolves.toEqual({
+ ok: true,
+ value: { items: [] },
+ })
+ })
+
+ it('finishes the captured live checkpoint when the Session detaches mid-flush', async () => {
+ const { ctx, persistence } = await harness()
+ const session = ctx.sessions.prepare(SessionId('detach-during-flush'), {
+ meta: { createdAt: 40, cwd: '/detach' },
+ })
+ const detach = ctx.sessions.enter(session)
+ ctx.sessions.announce(session)
+ const fixture = appendMessageFixture(session)
+ const started = Promise.withResolvers()
+ const release = Promise.withResolvers()
+ ctx.on('session/flush', async (current) => {
+ started.resolve(undefined)
+ await release.promise
+ persistence.persist(current)
+ })
+
+ const pending = ctx.messageFeedback.put({
+ sessionId: session.id,
+ messageId: fixture.assistantMessageIds[0],
+ rating: 'positive',
+ ifVersion: null,
+ })
+ await started.promise
+ detach()
+ expect(ctx.sessions.get(session.id)).toBeUndefined()
+ release.resolve(undefined)
+ expectItem(await pending)
+ expect(persistence.readFromCalls).toBe(0)
+ await expect(ctx.messageFeedback.list({ sessionId: session.id })).resolves.toMatchObject({
+ ok: true,
+ value: { items: [{ messageId: fixture.assistantMessageIds[0] }] },
+ })
+ })
+})
diff --git a/packages/feedback/message-feedback/tsconfig.json b/packages/feedback/message-feedback/tsconfig.json
new file mode 100644
index 0000000000..e406c4c00e
--- /dev/null
+++ b/packages/feedback/message-feedback/tsconfig.json
@@ -0,0 +1,45 @@
+{
+ "extends": "../../../tsconfig.base.json",
+ "compilerOptions": {
+ "rootDir": "src",
+ "outDir": "lib/types"
+ },
+ "include": [
+ "src"
+ ],
+ "references": [
+ {
+ "path": "../../../vendor/cosmokit"
+ },
+ {
+ "path": "../../../vendor/cordis"
+ },
+ {
+ "path": "../../../vendor/schemastery"
+ },
+ {
+ "path": "../../util/brand"
+ },
+ {
+ "path": "../../llm/llm"
+ },
+ {
+ "path": "../../core/session"
+ },
+ {
+ "path": "../../session/session-persistence"
+ },
+ {
+ "path": "../../storage/storage"
+ },
+ {
+ "path": "../../storage/storage-domain"
+ },
+ {
+ "path": "../../typert/type-meta"
+ },
+ {
+ "path": "../../support/invariants"
+ }
+ ]
+}
diff --git a/packages/self-modification/tool-cordis/src/api-catalog.ts b/packages/self-modification/tool-cordis/src/api-catalog.ts
index 71e3d9a88d..0916b3e669 100644
--- a/packages/self-modification/tool-cordis/src/api-catalog.ts
+++ b/packages/self-modification/tool-cordis/src/api-catalog.ts
@@ -586,6 +586,24 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [
},
],
},
+ {
+ key: 'messageFeedback',
+ summary: 'Storage-domain sidecar service.',
+ methods: [
+ {
+ signature: '@Remote(\'list\') async list(request: MessageFeedbackListRequest): Promise',
+ jsDoc: '/**\n * Read feedback belonging to the current persisted Session lifecycle.\n * A stale row from a reused Session id is invisible.\n * @param request - Session identity to inspect and list.\n * @returns current immutable items or `session-not-found`.\n */',
+ },
+ {
+ signature: '@Remote(\'put\') put(request: MessageFeedbackPutRequest): Promise',
+ jsDoc: '/**\n * Create or replace feedback for one derived append-origin assistant\n * message. An exact desired-value retry returns the stored item before its\n * stale or `null` version is considered a conflict.\n * @param request - target, desired value, and observed item version.\n * @returns the committed item or an explicit business failure.\n */',
+ },
+ {
+ signature: '@Remote(\'delete\') delete(request: MessageFeedbackDeleteRequest): Promise',
+ jsDoc: '/**\n * Delete one feedback item. Absence is successful regardless of the\n * supplied version; an existing item requires an exact version match.\n * @param request - Session, message, and observed item version.\n * @returns the stable absent postcondition, or an explicit failure.\n */',
+ },
+ ],
+ },
{
key: 'permission',
summary: 'Owns the deployment\'s permission presets and their write path.',
@@ -2331,6 +2349,82 @@ export const TYPE_API: readonly TypeApiEntry[] = [
name: 'Message',
declaration: 'export interface Message {\n readonly id: MessageId;\n readonly role: \'system\' | \'user\' | \'assistant\';\n readonly content: ContentBlock[];\n readonly source: MessageSource;\n}',
},
+ {
+ name: 'MessageFeedbackDeleteRequest',
+ declaration: 'export interface MessageFeedbackDeleteRequest {\n readonly sessionId: SessionId;\n readonly messageId: MessageId;\n readonly ifVersion: MessageFeedbackVersion;\n}',
+ },
+ {
+ name: 'MessageFeedbackDeleteResult',
+ declaration: 'export type MessageFeedbackDeleteResult = MessageFeedbackSuccess | MessageFeedbackRejected;',
+ },
+ {
+ name: 'MessageFeedbackDeleteValue',
+ declaration: 'export interface MessageFeedbackDeleteValue {\n readonly absent: true;\n}',
+ },
+ {
+ name: 'MessageFeedbackFailure',
+ declaration: 'export type MessageFeedbackFailure = MessageFeedbackSessionNotFound | MessageFeedbackTargetNotFound | MessageFeedbackVersionConflict | MessageFeedbackNoteBlank | MessageFeedbackNoteTooLarge;',
+ },
+ {
+ name: 'MessageFeedbackItem',
+ declaration: 'export interface MessageFeedbackItem {\n readonly messageId: MessageId;\n readonly rating: MessageFeedbackRating;\n readonly note?: string;\n readonly version: MessageFeedbackVersion;\n readonly createdAt: number;\n readonly updatedAt: number;\n}',
+ },
+ {
+ name: 'MessageFeedbackListRequest',
+ declaration: 'export interface MessageFeedbackListRequest {\n readonly sessionId: SessionId;\n}',
+ },
+ {
+ name: 'MessageFeedbackListResult',
+ declaration: 'export type MessageFeedbackListResult = MessageFeedbackSuccess | MessageFeedbackRejected;',
+ },
+ {
+ name: 'MessageFeedbackListValue',
+ declaration: 'export interface MessageFeedbackListValue {\n readonly items: readonly MessageFeedbackItem[];\n}',
+ },
+ {
+ name: 'MessageFeedbackNoteBlank',
+ declaration: 'export interface MessageFeedbackNoteBlank {\n readonly code: \'note-blank\';\n}',
+ },
+ {
+ name: 'MessageFeedbackNoteTooLarge',
+ declaration: 'export interface MessageFeedbackNoteTooLarge {\n readonly code: \'note-too-large\';\n readonly maxBytes: number;\n readonly actualBytes: number;\n}',
+ },
+ {
+ name: 'MessageFeedbackPutRequest',
+ declaration: 'export interface MessageFeedbackPutRequest {\n readonly sessionId: SessionId;\n readonly messageId: MessageId;\n readonly rating: MessageFeedbackRating;\n readonly note?: string;\n readonly ifVersion: MessageFeedbackVersion | null;\n}',
+ },
+ {
+ name: 'MessageFeedbackPutResult',
+ declaration: 'export type MessageFeedbackPutResult = MessageFeedbackSuccess | MessageFeedbackRejected;',
+ },
+ {
+ name: 'MessageFeedbackRating',
+ declaration: 'export type MessageFeedbackRating = \'positive\' | \'negative\';',
+ },
+ {
+ name: 'MessageFeedbackRejected',
+ declaration: 'export interface MessageFeedbackRejected {\n readonly ok: false;\n readonly error: E;\n}',
+ },
+ {
+ name: 'MessageFeedbackSessionNotFound',
+ declaration: 'export interface MessageFeedbackSessionNotFound {\n readonly code: \'session-not-found\';\n readonly sessionId: SessionId;\n}',
+ },
+ {
+ name: 'MessageFeedbackSuccess',
+ declaration: 'export interface MessageFeedbackSuccess {\n readonly ok: true;\n readonly value: T;\n}',
+ },
+ {
+ name: 'MessageFeedbackTargetNotFound',
+ declaration: 'export interface MessageFeedbackTargetNotFound {\n readonly code: \'target-not-found\';\n readonly sessionId: SessionId;\n readonly messageId: MessageId;\n}',
+ },
+ {
+ name: 'MessageFeedbackVersion',
+ declaration: 'export type MessageFeedbackVersion = Branded<\'MessageFeedbackVersion\'>;',
+ },
+ {
+ name: 'MessageFeedbackVersionConflict',
+ declaration: 'export interface MessageFeedbackVersionConflict {\n readonly code: \'version-conflict\';\n readonly sessionId: SessionId;\n readonly messageId: MessageId;\n readonly expected: MessageFeedbackVersion | null;\n readonly actual: MessageFeedbackVersion | null;\n}',
+ },
{
name: 'MessageId',
declaration: 'export type MessageId = Branded<\'MessageId\'>;',
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 916e5b167b..46d392bc4d 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -1605,6 +1605,9 @@ importers:
'@deepseek-ai/dsh-host-webserver':
specifier: workspace:^
version: link:../../host/webserver
+ '@deepseek-ai/dsh-message-feedback':
+ specifier: workspace:^
+ version: link:../../feedback/message-feedback
'@deepseek-ai/dsh-session-projection-cache':
specifier: workspace:^
version: link:../../session/session-projection-cache
@@ -3792,6 +3795,55 @@ importers:
specifier: workspace:^
version: link:../../session/user-id
+ packages/feedback/message-feedback:
+ dependencies:
+ '@deepseek-ai/schemastery':
+ specifier: link:../../../vendor/schemastery
+ version: link:../../../vendor/schemastery
+ zod:
+ specifier: ^4.4.3
+ version: 4.4.3
+ devDependencies:
+ '@deepseek-ai/cordis':
+ specifier: workspace:^
+ version: link:../../../vendor/cordis
+ '@deepseek-ai/cordis-plugin-include':
+ specifier: workspace:^
+ version: link:../../../vendor/include
+ '@deepseek-ai/cordis-plugin-loader':
+ specifier: workspace:^
+ version: link:../../../vendor/loader
+ '@deepseek-ai/dsh-brand':
+ specifier: workspace:^
+ version: link:../../util/brand
+ '@deepseek-ai/dsh-invariants':
+ specifier: workspace:^
+ version: link:../../support/invariants
+ '@deepseek-ai/dsh-llm':
+ specifier: workspace:^
+ version: link:../../llm/llm
+ '@deepseek-ai/dsh-session':
+ specifier: workspace:^
+ version: link:../../core/session
+ '@deepseek-ai/dsh-session-persistence':
+ specifier: workspace:^
+ version: link:../../session/session-persistence
+ '@deepseek-ai/dsh-session-persistence-jsonl':
+ specifier: workspace:^
+ version: link:../../session/session-persistence-jsonl
+ '@deepseek-ai/dsh-storage':
+ specifier: workspace:^
+ version: link:../../storage/storage
+ '@deepseek-ai/dsh-storage-domain':
+ specifier: workspace:^
+ version: link:../../storage/storage-domain
+ '@deepseek-ai/dsh-storage-json':
+ specifier: workspace:^
+ version: link:../../storage/storage-json
+ '@deepseek-ai/dsh-type-meta':
+ specifier: workspace:^
+ version: link:../../typert/type-meta
+
packages/fs/fs:
devDependencies:
'@deepseek-ai/cordis':
diff --git a/scripts/gen-cordis-catalog.ts b/scripts/gen-cordis-catalog.ts
index e31ed08a77..1941e66acb 100644
--- a/scripts/gen-cordis-catalog.ts
+++ b/scripts/gen-cordis-catalog.ts
@@ -63,6 +63,7 @@ export const SERVICE_PAGE: Record = {
httpServer: 'http-server.md',
invariants: 'invariants.md',
llm: 'llm-streaming.md',
+ messageFeedback: 'feedback.md',
permission: 'permission.md',
planMode: 'plan.md',
pty: 'pty.md',
@@ -229,6 +230,25 @@ export const LINK_MAP: Readonly> = {
ResolvedRetryPolicy: 'llm-streaming.md',
Message: 'llm-streaming.md',
MessageSource: 'llm-streaming.md',
+ MessageFeedbackDeleteRequest: 'feedback.md',
+ MessageFeedbackDeleteResult: 'feedback.md',
+ MessageFeedbackDeleteValue: 'feedback.md',
+ MessageFeedbackFailure: 'feedback.md',
+ MessageFeedbackItem: 'feedback.md',
+ MessageFeedbackListRequest: 'feedback.md',
+ MessageFeedbackListResult: 'feedback.md',
+ MessageFeedbackListValue: 'feedback.md',
+ MessageFeedbackNoteBlank: 'feedback.md',
+ MessageFeedbackNoteTooLarge: 'feedback.md',
+ MessageFeedbackPutRequest: 'feedback.md',
+ MessageFeedbackPutResult: 'feedback.md',
+ MessageFeedbackRating: 'feedback.md',
+ MessageFeedbackRejected: 'feedback.md',
+ MessageFeedbackSessionNotFound: 'feedback.md',
+ MessageFeedbackSuccess: 'feedback.md',
+ MessageFeedbackTargetNotFound: 'feedback.md',
+ MessageFeedbackVersion: 'feedback.md',
+ MessageFeedbackVersionConflict: 'feedback.md',
UserMessage: 'session.md',
PreStepDecision: 'core.md',
PreStepContext: 'core.md',
diff --git a/scripts/gen-doc-graphs.ts b/scripts/gen-doc-graphs.ts
index 699bcae707..5b950782b1 100644
--- a/scripts/gen-doc-graphs.ts
+++ b/scripts/gen-doc-graphs.ts
@@ -135,7 +135,7 @@ const SERVICE_ROLES: ServiceRole[] = [
pkg: 'session',
title: 'In-memory session store',
mode: 'core',
- consumers: ['agent-loop', 'agent', 'session-persistence', 'session-query', 'session-query-sqlite', 'subagent-inprocess', 'invariants'],
+ consumers: ['agent-loop', 'agent', 'session-persistence', 'session-query', 'session-query-sqlite', 'subagent-inprocess', 'invariants', 'message-feedback'],
note: 'Owns append-only Session instances and emits the durable session event feed.',
},
{
@@ -167,7 +167,7 @@ const SERVICE_ROLES: ServiceRole[] = [
title: 'Durable session persistence seam',
mode: 'seam',
implementations: ['session-persistence-jsonl', 'session-persistence-sqlite'],
- consumers: ['agent-loop', 'tool-bash', 'hooks-claude', 'hooks-codex', 'session-query', 'session-query-sqlite'],
+ consumers: ['agent-loop', 'tool-bash', 'hooks-claude', 'hooks-codex', 'session-query', 'session-query-sqlite', 'message-feedback'],
note: 'Backends persist the same SessionEvent vocabulary; apps choose a backend at composition time.',
},
{
@@ -211,9 +211,16 @@ const SERVICE_ROLES: ServiceRole[] = [
pkg: 'storage-domain',
title: 'Domain data facility',
mode: 'core',
- consumers: ['workspace'],
+ consumers: ['workspace', 'message-feedback'],
note: 'Waits for every configured backend, then publishes the domain form as one lifecycle-bound service for typed durable state.',
},
+ {
+ key: 'messageFeedback',
+ pkg: 'message-feedback',
+ title: 'Lifecycle-bound message feedback',
+ mode: 'core',
+ note: 'Owns local per-assistant-message feedback, lifecycle and target validation, per-item compare-and-set, and the Host unary Remote contract without entering Session history or telemetry.',
+ },
{
key: 'workspace',
pkg: 'workspace',
diff --git a/tsconfig.host.json b/tsconfig.host.json
index 12c5d365d6..c616cc16aa 100644
--- a/tsconfig.host.json
+++ b/tsconfig.host.json
@@ -134,6 +134,7 @@
{ "path": "./packages/storage/storage-json" },
{ "path": "./packages/storage/storage-sqlite" },
{ "path": "./packages/storage/storage-domain" },
+ { "path": "./packages/feedback/message-feedback" },
{ "path": "./packages/workspace/workspace" },
{ "path": "./packages/session/session-title" },
{ "path": "./packages/session/session-title-llm" },