Преглед изворни кода

OTel uploads explicit feedback for all users by default

Tianyi Cui пре 2 дана
родитељ
комит
9ffe85a512
65 измењених фајлова са 1158 додато и 377 уклоњено
  1. 2 2
      .agents/notes/implemented/architecture/2026-09-05-canonical-feedback-log.i18n.yaml
  2. 4 4
      .agents/notes/implemented/architecture/2026-09-05-canonical-feedback-log.md
  3. 4 4
      .agents/notes/implemented/architecture/2026-09-05-canonical-feedback-log.zh.md
  4. 6 0
      .agents/notes/implemented/architecture/2026-09-05-nonofficial-feedback-otel.i18n.yaml
  5. 33 0
      .agents/notes/implemented/architecture/2026-09-05-nonofficial-feedback-otel.md
  6. 33 0
      .agents/notes/implemented/architecture/2026-09-05-nonofficial-feedback-otel.zh.md
  7. 2 2
      .agents/notes/implemented/feature/2026-07-23-session-telemetry-otel-revival.i18n.yaml
  8. 3 3
      .agents/notes/implemented/feature/2026-07-23-session-telemetry-otel-revival.md
  9. 3 4
      .agents/notes/implemented/feature/2026-07-23-session-telemetry-otel-revival.zh.md
  10. 2 2
      .agents/notes/implemented/feature/2026-08-25-feedback-gated-telemetry-default.i18n.yaml
  11. 6 6
      .agents/notes/implemented/feature/2026-08-25-feedback-gated-telemetry-default.md
  12. 6 6
      .agents/notes/implemented/feature/2026-08-25-feedback-gated-telemetry-default.zh.md
  13. 2 2
      apps/cli/reference/README.i18n.yaml
  14. 2 2
      apps/cli/reference/README.md
  15. 2 2
      apps/cli/reference/README.zh.md
  16. 163 14
      apps/web/tests/feedback-release.e2e.ts
  17. 14 8
      apps/web/tests/scaffold.ts
  18. 2 2
      docs/config-catalog.i18n.yaml
  19. 2 3
      docs/config-catalog.md
  20. 2 3
      docs/config-catalog.zh.md
  21. 2 2
      docs/event-producer-consumer.i18n.yaml
  22. 1 0
      docs/event-producer-consumer.md
  23. 2 1
      docs/event-producer-consumer.zh.md
  24. 2 2
      docs/module-graph.i18n.yaml
  25. 2 1
      docs/module-graph.md
  26. 2 1
      docs/module-graph.zh.md
  27. 2 2
      docs/subsystems/feedback.i18n.yaml
  28. 27 1
      docs/subsystems/feedback.md
  29. 27 1
      docs/subsystems/feedback.zh.md
  30. 2 2
      docs/subsystems/session-telemetry.i18n.yaml
  31. 22 6
      docs/subsystems/session-telemetry.md
  32. 22 6
      docs/subsystems/session-telemetry.zh.md
  33. 2 2
      packages/bundle/base/README.i18n.yaml
  34. 1 1
      packages/bundle/base/README.md
  35. 1 1
      packages/bundle/base/README.zh.md
  36. 2 3
      packages/bundle/base/cordis.patch.yml
  37. 1 1
      packages/bundle/base/tests/base.spec.ts
  38. 9 1
      packages/extensions/tool-cordis/src/api-catalog.ts
  39. 2 2
      packages/feedback/message-feedback/README.i18n.yaml
  40. 3 1
      packages/feedback/message-feedback/README.md
  41. 3 1
      packages/feedback/message-feedback/README.zh.md
  42. 26 5
      packages/feedback/message-feedback/src/index.ts
  43. 2 2
      packages/session/README.i18n.yaml
  44. 1 1
      packages/session/README.md
  45. 1 1
      packages/session/README.zh.md
  46. 2 2
      packages/session/session-telemetry-otel/README.i18n.yaml
  47. 15 12
      packages/session/session-telemetry-otel/README.md
  48. 15 12
      packages/session/session-telemetry-otel/README.zh.md
  49. 2 0
      packages/session/session-telemetry-otel/package.json
  50. 44 35
      packages/session/session-telemetry-otel/src/index.ts
  51. 105 55
      packages/session/session-telemetry-otel/tests/egress.spec.ts
  52. 24 20
      packages/session/session-telemetry-otel/tests/fixtures/driver.ts
  53. 40 24
      packages/session/session-telemetry-otel/tests/loader-composition.e2e.ts
  54. 306 66
      packages/session/session-telemetry-otel/tests/otel.spec.ts
  55. 3 0
      packages/session/session-telemetry-otel/tsconfig.json
  56. 2 2
      packages/session/session-telemetry/README.i18n.yaml
  57. 5 5
      packages/session/session-telemetry/README.md
  58. 5 5
      packages/session/session-telemetry/README.zh.md
  59. 18 11
      packages/session/session-telemetry/src/coordinator.ts
  60. 3 10
      packages/session/session-telemetry/src/index.ts
  61. 51 2
      packages/session/session-telemetry/tests/telemetry.spec.ts
  62. 3 0
      pnpm-lock.yaml
  63. 1 0
      scripts/gen-cordis-catalog.ts
  64. 10 0
      scripts/type-equiv.manifest.json
  65. 44 0
      snapshots/web/feedback-release/feedback-release.expected.json

+ 2 - 2
.agents/notes/implemented/architecture/2026-09-05-canonical-feedback-log.i18n.yaml

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-09-05-canonical-feedback-log.md
-2026-09-05-canonical-feedback-log.md: e30e21bc89c8fd21177ef1f0be98de506cc67a61
-2026-09-05-canonical-feedback-log.zh.md: b1b1623fc16768a15a45554e41e6856364fb91a5
+2026-09-05-canonical-feedback-log.md: c890064817ac0604fa4cc2b4073850174195e511
+2026-09-05-canonical-feedback-log.zh.md: e7e8563df8ad37d7aba641e58da4fc8872ec10b9

+ 4 - 4
.agents/notes/implemented/architecture/2026-09-05-canonical-feedback-log.md

@@ -14,7 +14,7 @@ The canonical Session log owns feedback. Session-level remarks use `feedback/rec
 
 Live message-feedback mutations append through the owning Session and await its durability checkpoint; cold mutations hold a persistence write handle across read, comparison, append, and flush without creating a Session or Agent. A matching no-op appends nothing but still awaits persistence. Failures propagate, and a failed live flush can leave an observable in-memory item for retry. Per-item versions prevent unrelated message edits from conflicting; strict stale-write rejection prevents ABA overwrites even when the desired value matches. Target validation binds a judgment to a sent assistant message, and forks keep independent judgments. These choices retain rationale recorded in the [archived sidecar decision](../../archived/architecture/2026-08-10-message-feedback-sidecar.md), whose storage and commit mechanism is superseded.
 
-The existing opt-in [session-log-deepseek contribution](../../../../packages/session/session-log-deepseek/README.md) includes feedback in the ordinary `dsh_session_log` suffix on a subsequent eligible request. It uses the existing DeepSeek destination selection and acceptance watermark. There is no separate `dsh_feedback` uploader, feedback-triggered request, or model-input field. The shipped base disables its OTel row; this supersedes the default composition in the [feedback-gated default decision](../feature/2026-08-25-feedback-gated-telemetry-default.md), not the optional backend's modes.
+The existing opt-in [session-log-deepseek contribution](../../../../packages/session/session-log-deepseek/README.md) includes feedback in the ordinary `dsh_session_log` suffix on a subsequent eligible request. It uses the existing DeepSeek destination selection and acceptance watermark. There is no separate `dsh_feedback` uploader, feedback-triggered LLM request, or model-input field. The [explicit-feedback OTel decision](2026-09-05-nonofficial-feedback-otel.md) owns the independent feedback-triggered upload for all users and providers.
 
 The command confirms recording with the Session and anonymous user ids, without depending on telemetry or disclosing its policy. Its append remains unflushed. This supersedes the command-copy decision in the [archived sharing disclosure note](../../archived/feature/2026-08-07-feedback-acknowledgement-sharing-disclosure.md). The [telemetry service's policy API](../../../../packages/session/session-telemetry/README.md#the-sharing-disclosure) remains independently available: a backend discloses its policy, not delivery or retention, and the optional OTel package does not own that vocabulary.
 
@@ -22,12 +22,12 @@ The command confirms recording with the Session and anonymous user ids, without
 
 **Keep the sidecar.** It supports destructive local edits, but cannot make feedback part of ordinary canonical-log export and delivery without another join and durability relationship.
 
-**Reuse `feedback/record` for message edits.** A free-text Session remark does not identify an item mutation, and its optional OTel consumer treats it as a release trigger. Distinct events preserve message identity and deletion semantics without that coupling.
+**Reuse `feedback/record` for message edits.** A free-text Session remark does not identify an item mutation. Distinct events preserve message identity and deletion semantics; upload policy remains consumer-owned.
 
-**Add a dedicated uploader or immediate request.** The existing opt-in log contribution already carries canonical events and records acceptance. A second path would add delivery ownership and deduplication policy; this design accepts delayed delivery instead.
+**Add a dedicated feedback uploader or immediate LLM request.** The opt-in log contribution carries canonical events on eligible requests. The existing OTel pipeline independently handles explicit-feedback uploads for all providers, without a custom feedback uploader or another model request.
 
 ## Consequences
 
-Feedback survives ordinary log export and replay without consuming model-input tokens or changing KV Cache. Current-item deletion is not erasure, and feedback recorded after the last eligible request can remain local indefinitely. The Web controller remains a unary Remote consumer and does not consume feedback log events for cross-tab updates.
+Feedback survives ordinary log export and replay without consuming model-input tokens or changing KV Cache. Current-item deletion is not erasure. The DeepSeek request contribution can leave final feedback local until another eligible request; OTel sends an authorized batch independently under its own policy. The Web controller remains a unary Remote consumer and does not consume feedback log events for cross-tab updates.
 
 [Message-feedback tests](../../../../packages/feedback/message-feedback/tests/message-feedback.spec.ts) cover material events, no-ops, strict versions, fork isolation, and persistence failures. The [request contribution tests](../../../../packages/session/session-log-deepseek/tests) own suffix acceptance and retry; the [command tests](../../../../packages/feedback/command-feedback/tests/command-feedback.spec.ts) pin the plain confirmation.

+ 4 - 4
.agents/notes/implemented/architecture/2026-09-05-canonical-feedback-log.zh.md

@@ -14,7 +14,7 @@ Status: implemented
 
 live 消息反馈变更通过所属 Session 追加,并等待其持久化检查点;cold 变更在读取、比较、追加和 flush 期间持有持久化写句柄,不创建 Session 或 Agent。匹配版本的无变更操作不追加事件,但仍等待持久化。故障会原样传播,live flush 失败可能留下可观测的内存条目以供重试。逐条版本避免不同消息的编辑互相冲突;严格拒绝陈旧写入避免 ABA 覆盖,即使期望值已经匹配也不例外。目标校验把判断绑定到已发送的 assistant 消息,fork 保持独立判断。这些选择保留[已归档伴随记录决策](../../archived/architecture/2026-08-10-message-feedback-sidecar.md)记载的理由,但其存储与提交机制已被取代。
 
-现有需显式启用的 [session-log-deepseek 贡献](../../../../packages/session/session-log-deepseek/README.zh.md)会在后续符合条件的请求中,把反馈纳入普通 `dsh_session_log` 后缀。它使用现有的 DeepSeek 目标选择和接受水位。没有独立的 `dsh_feedback` 上传器、反馈触发的请求或模型输入字段。随附基础配置禁用 OTel 配置行;这取代[反馈门控默认值决策](../feature/2026-08-25-feedback-gated-telemetry-default.zh.md)中的默认组合,不改变可选后端的模式
+现有需显式启用的 [session-log-deepseek 贡献](../../../../packages/session/session-log-deepseek/README.zh.md)会在后续符合条件的请求中,把反馈纳入普通 `dsh_session_log` 后缀。它使用现有的 DeepSeek 目标选择和接受水位。没有独立的 `dsh_feedback` 上传器、反馈触发的 LLM 请求或模型输入字段。[显式反馈 OTel 决策](2026-09-05-nonofficial-feedback-otel.zh.md)负责面向所有用户和提供方的独立反馈触发上传
 
 命令用 Session 与匿名用户 id 确认记录,不依赖遥测,也不披露其策略。其追加仍不执行 flush。这取代[已归档共享披露记录](../../archived/feature/2026-08-07-feedback-acknowledgement-sharing-disclosure.md)中的命令文案决策。[遥测服务的策略 API](../../../../packages/session/session-telemetry/README.zh.md#the-sharing-disclosure) 仍可独立使用:后端披露策略,而不保证投递或保留,可选 OTel 包不拥有这套词汇。
 
@@ -22,12 +22,12 @@ live 消息反馈变更通过所属 Session 追加,并等待其持久化检查
 
 **保留伴随记录。** 它支持破坏性的本地编辑,但若不增加关联读取及持久化关系,就无法让反馈参与普通权威日志导出与投递。
 
-**对消息编辑复用 `feedback/record`。** 自由文本的 Session 备注不能标识条目变更,其可选 OTel 消费方还会将它当作释放触发器。独立事件保留消息身份和删除语义,不引入该耦合
+**对消息编辑复用 `feedback/record`。** 自由文本的 Session 备注不能标识条目变更。独立事件保留消息身份和删除语义;上传策略仍由消费方负责
 
-**增加专用上传器或立即发起请求。** 现有需显式启用的日志贡献已经传送权威事件并记录接受结果。第二条路径会增加投递归属与去重策略;本设计接受延迟投递
+**增加专用反馈上传器或立即发起 LLM 请求。** 需显式启用的日志贡献在符合条件的请求上传送权威事件。现有 OTel 流水线独立处理所有提供方的显式反馈上传,无需自定义反馈上传器或另一个模型请求
 
 ## 后果
 
-反馈随普通日志导出与回放保留,不消耗模型输入 token,也不改变 KV Cache。删除当前条目不等于抹除历史,最后一次符合条件的请求之后记录的反馈可能无限期保留在本地。Web 控制器仍消费一元 Remote,不消费反馈日志事件来更新其他标签页。
+反馈随普通日志导出与回放保留,不消耗模型输入 token,也不改变 KV Cache。删除当前条目不等于抹除历史。DeepSeek 请求贡献可能让最终反馈留在本地,直到下次符合条件的请求;OTel 按自身策略独立发送已授权批次。Web 控制器仍消费一元 Remote,不消费反馈日志事件来更新其他标签页。
 
 [消息反馈测试](../../../../packages/feedback/message-feedback/tests/message-feedback.spec.ts)覆盖实质事件、无变更操作、严格版本、fork 隔离与持久化故障。[请求贡献测试](../../../../packages/session/session-log-deepseek/tests)负责后缀接受与重试;[命令测试](../../../../packages/feedback/command-feedback/tests/command-feedback.spec.ts)固定纯确认文本。

+ 6 - 0
.agents/notes/implemented/architecture/2026-09-05-nonofficial-feedback-otel.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-09-05-nonofficial-feedback-otel.md
+2026-09-05-nonofficial-feedback-otel.md: bbc947455f7a84a41af6651223eb62129fa8d452
+2026-09-05-nonofficial-feedback-otel.zh.md: de9b0322861118cdc1163a0faefb172fc1062776

+ 33 - 0
.agents/notes/implemented/architecture/2026-09-05-nonofficial-feedback-otel.md

@@ -0,0 +1,33 @@
+# Agent Note: Explicit-feedback-only upload through OpenTelemetry
+
+Status: implemented
+
+English | [中文](2026-09-05-nonofficial-feedback-otel.zh.md)
+
+## Problem
+
+Feedback needs the session context it describes and a delivery path independent of the model provider or a later model request. Ordinary activity must not authorize uploads. Inherited feedback must not count as a child Session's consent.
+
+## Decision
+
+The base mounts OTel in `FEEDBACK_ONLY` for all users and providers, including `deepseek-official` and Sessions without a request header. Only new own `feedback/record`, `feedback/message-put`, and `feedback/message-delete` events authorize capture through that exact canonical event. Text feedback, ratings, material note edits, and withdrawals count. Cold `feedback/committed` notifications supply a committed snapshot without publishing a live Session or Agent.
+
+An authorized prefix includes all unhanded canonical context from seq 0 through the feedback, not just its payload. A child needs its own new feedback; its prefix then includes inherited history. Later records wait for the next explicit feedback. Request activity, request headers, Session creation or adoption, restoration, plugin mount, and HMR never authorize capture; stored feedback alone triggers nothing.
+
+The backend uses on-demand capture with complete history and the existing redaction waterfall. `DISABLED` constructs no transport. `FULL` is rejected rather than aliased. Direct `ctx.sessionTelemetry.emit()` calls are no-ops, so callers cannot bypass feedback authorization. SDK scheduled flush and shutdown may finish previously authorized batches but never capture new records. Sending after submission needs no further user interaction or model call.
+
+The [canonical-feedback decision](2026-09-05-canonical-feedback-log.md) owns storage, versions, deletion, and plain command confirmation. The [opt-in DeepSeek contribution](../../../../packages/session/session-log-deepseek/README.md) remains independent, with its existing destination and acceptance behavior.
+
+## Alternatives considered
+
+**Filter by provider or endpoint hostname.** Feedback authorizes the same bounded context for every user; a provider choice, gateway, or missing header does not change that authorization.
+
+**Use only later DeepSeek requests.** Other providers do not carry `dsh_session_log`, and final feedback may have no subsequent request. The existing OTel pipeline sends independently without a custom uploader or model call.
+
+**Keep continuous capture or replay stored feedback on lifecycle events.** Deployment configuration and old feedback do not authorize new capture. Only a new explicit submission does. Parent feedback likewise cannot authorize a child upload.
+
+## Consequences
+
+Handoff is best-effort, not collector acceptance. Same-object cursors suppress repeated capture, but fresh cold snapshots and new feedback after restart can repeat prefixes; receivers deduplicate on `(session.id, session.format_version, event.seq)`. There is no durable OTel outbox, delivery watermark, or harness HTTP retry promise. SDK batching and loss behavior apply after enqueue. OTel and the opt-in DeepSeek path can overlap. Withdrawal exports a deletion event, not remote erasure.
+
+[OTel tests](../../../../packages/session/session-telemetry-otel/tests/otel.spec.ts) cover explicit-feedback capture, provider-independent behavior, lifecycle silence, fork consent, cold commits, and direct-call denial. [Coordinator tests](../../../../packages/session/session-telemetry/tests/telemetry.spec.ts) cover history capture; [base tests](../../../../packages/bundle/base/tests/base.spec.ts) pin the mounted default.

+ 33 - 0
.agents/notes/implemented/architecture/2026-09-05-nonofficial-feedback-otel.zh.md

@@ -0,0 +1,33 @@
+# Agent Note: 仅在显式反馈后通过 OpenTelemetry 上传
+
+Status: implemented
+
+[English](2026-09-05-nonofficial-feedback-otel.md) | 中文
+
+## 问题
+
+反馈需要它所描述的会话上下文,以及不依赖模型提供方或后续模型请求的投递路径。普通活动不得授权上传。继承的反馈不得视为子 Session 的同意。
+
+## 决策
+
+基础配置为所有用户和提供方以 `FEEDBACK_ONLY` 挂载 OTel,包括 `deepseek-official` 和没有请求头的 Session。只有新的自身 `feedback/record`、`feedback/message-put` 和 `feedback/message-delete` 事件授权捕获,且截止该确切的权威事件。文本反馈、评分、实质备注编辑与撤回均算作反馈。冷会话 `feedback/committed` 通知提供已提交快照,不发布存活 Session 或 Agent。
+
+授权前缀包含从 seq 0 到该反馈的所有尚未交接的权威上下文,而非只有反馈载荷。子会话需要新的自身反馈;之后其前缀包含继承历史。后续记录等待下一次显式反馈。请求活动、请求头、Session 创建或接纳、恢复、插件挂载和 HMR(热模块替换)绝不授权捕获;仅有存储的反馈不会触发任何上传。
+
+后端使用包含完整历史的按需捕获与现有脱敏 waterfall(瀑布式事件)。`DISABLED` 不构造传输。`FULL` 被拒绝,不作为别名。直接调用 `ctx.sessionTelemetry.emit()` 是空操作,因此调用方不能绕过反馈授权。SDK 定时刷新和关闭可以完成先前已授权的批次,但绝不捕获新记录。提交后的发送无需进一步用户交互或模型调用。
+
+[权威反馈决策](2026-09-05-canonical-feedback-log.zh.md)负责存储、版本、删除与纯命令确认。[需主动开启的 DeepSeek 贡献](../../../../packages/session/session-log-deepseek/README.zh.md)保持独立,保留现有目标与接受行为。
+
+## 考虑过的替代方案
+
+**按提供方或端点主机名过滤。** 反馈为每位用户授权相同的有界上下文;提供方选择、网关或缺失请求头不改变该授权。
+
+**仅使用后续 DeepSeek 请求。** 其他提供方不携带 `dsh_session_log`,而最终反馈之后可能没有请求。现有 OTel 流水线可独立发送,无需自定义上传器或模型调用。
+
+**保留持续捕获或在生命周期事件上回放存储的反馈。** 部署配置和旧反馈不授权新捕获。只有新的显式提交才授权。父会话反馈同样不能授权子会话上传。
+
+## 后果
+
+交接尽力而为,不代表采集端接受。同对象游标抑制重复捕获,但新冷快照和重启后的新反馈可能重复前缀;接收方按 `(session.id, session.format_version, event.seq)` 去重。没有持久化 OTel outbox、投递水位或 harness HTTP 重试承诺。入队后适用 SDK 批处理与丢失行为。OTel 与需主动开启的 DeepSeek 路径可能重叠。撤回导出删除事件,不是远端擦除。
+
+[OTel 测试](../../../../packages/session/session-telemetry-otel/tests/otel.spec.ts)覆盖显式反馈捕获、提供方无关行为、生命周期静默、fork 同意、冷会话提交与直接调用拒绝。[协调器测试](../../../../packages/session/session-telemetry/tests/telemetry.spec.ts)覆盖历史捕获;[基础配置测试](../../../../packages/bundle/base/tests/base.spec.ts)固定挂载默认值。

+ 2 - 2
.agents/notes/implemented/feature/2026-07-23-session-telemetry-otel-revival.i18n.yaml

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-07-23-session-telemetry-otel-revival.md
-2026-07-23-session-telemetry-otel-revival.md: f0dca55a3bf705f650a7a8fc998e81bba04649d4
-2026-07-23-session-telemetry-otel-revival.zh.md: 488716f9723238f1841f79d2cf55f52e269bb3db
+2026-07-23-session-telemetry-otel-revival.md: dc52ee87ef78107d674e49af3c20c255266f0414
+2026-07-23-session-telemetry-otel-revival.zh.md: f647ea289684eca648fe797011daad858612546f

+ 3 - 3
.agents/notes/implemented/feature/2026-07-23-session-telemetry-otel-revival.md

@@ -14,7 +14,7 @@ Every deployment that wants harness sessions in an observability stack must hand
 
 - **`@deepseek-ai/dsh-session-telemetry`** — the seam. `SessionTelemetrySink` (`emit`/`flush?`/`shutdown`), the service-registered `SessionTelemetryBackend` form, and `SessionTelemetryCoordinator` own lifecycle-local capture: each new Session object starts immediately before `firstLiveSeq`, then the per-append firehose deep-copies, redacts, and hands off every event with zero I/O; re-adopting the same object resumes after its module-scope cursor. Buffer-free on-demand capture uses the same one-record-per-event mapping through an optional inclusive boundary. Ledger identity includes `session.id`, `session.format_version`, and `event.seq`; live capture also relays `agent/error` and creates dispose-time `shutdown` records.
 - **The `session-telemetry/record` waterfall** — the delta over the branch version and the seam's redaction extension point. Every record passes it before reaching any backend; the seam ships NO rules of its own — the innermost `next()` is a pass-through, deployments mount their rules as listeners (stacking by transforming `next()`'s return value), and a throwing rule withholds the record fail-closed. Redaction applies to the exported copy only; the canonical log is never rewritten.
-- **`@deepseek-ai/dsh-session-telemetry-otel`** — the reference backend: OTel JS SDK log pipeline (`LoggerProvider` → `BatchLogRecordProcessor` → OTLP/HTTP exporter), configured verbatim through `exporter`/`processor` passthroughs. `DISABLED` is the default and constructs no transport; the [feedback-gated telemetry decision](../../archived/feature/2026-08-05-feedback-gated-session-telemetry.md) defines the explicit `FULL` and `FEEDBACK_ONLY` delivery modes, which require `exporter.url`, without moving the redaction or backend boundary. [Buffer-free feedback replay](../../archived/simplification/2026-08-06-buffer-free-feedback-telemetry.md) avoids a second in-memory copy of the session prefix.
+- **`@deepseek-ai/dsh-session-telemetry-otel`** — the reference backend: OTel JS SDK log pipeline (`LoggerProvider` → `BatchLogRecordProcessor` → OTLP/HTTP exporter), configured verbatim through `exporter`/`processor` passthroughs. `DISABLED` is the plugin default and constructs no transport. `FEEDBACK_ONLY` requires `exporter.url`; the [explicit-feedback policy](../architecture/2026-09-05-nonofficial-feedback-otel.md) requires a new submission for every bounded capture, for all providers. [Buffer-free feedback replay](../../archived/simplification/2026-08-06-buffer-free-feedback-telemetry.md) avoids a second in-memory copy of the session prefix.
 
 The boundary axiom holds: the harness's aspect ends at `emit()`. Batching, retry, queueing, and loss policy are the reporting SDK's, configured through passthroughs. Delivery is best-effort: a crash can lose queued records. Backend retry or losing the same live object's module-scope cursor can duplicate lifecycle-local rows, so receivers deduplicate on `(session.id, session.format_version, event.seq)`.
 
@@ -28,10 +28,10 @@ The boundary axiom holds: the harness's aspect ends at `emit()`. Batching, retry
 
 **Map onto OTel spans (GenAI semantic conventions) instead of logs.** Rejected for this revival: the branch implementation's log mapping is reviewed and shipped-shaped; the span model is lossy for forkable, interruptible sessions and belongs to a future consumer with real span queries to serve.
 
-**Replay the complete constructor seed for every new Session object.** Rejected because a fork's inherited events and a resumed or migrated log belong to another lifecycle and may predate the current sharing act. Replaying them under the new object can re-release already shared data, attribute inherited events to a child Session, and make feedback acknowledgement text understate what is handed off. A new object therefore starts immediately before `firstLiveSeq`: fresh Sessions still begin at seq 0, while seeded Sessions begin with their lifecycle boundary. Re-adopting the same object resumes after its cursor, so HMR does not duplicate the settled lifecycle suffix. This rule does not provide crash backfill; a deployment requiring guaranteed historical delivery needs the deferred durable outbox and an explicit disclosure for that broader scope.
+**Replay every constructor seed automatically.** A fork's inherited events and a resumed log may predate the current sharing act. The coordinator defaults to `firstLiveSeq` for a new object; `includeHistory` explicitly permits a backend to capture stored context. OTel selects complete history only through new own feedback, never on construction, restoration, or HMR. Same-object cursors reduce duplicate handoff but do not guarantee historical delivery or replace the deferred durable outbox.
 
 **Forwarding the seam's turn-boundary `flush()` hint to the OTel provider's `forceFlush()`.** Shipped in the first revival round, then removed: three distinct silent-loss paths shared the wrapper state — a dispose racing an in-flight flush (the SDK's concurrent-flush guard makes shutdown's internal drain skip), overlapping hints displacing the retained promise, and the provider's fixed 30-second flush timeout rejecting while the processor still drains. Every path exists only because the forwarding made this backend the process's second flusher against undocumented SDK internals from the upstream experimental tree; with no `flush()` implemented, the batch processor is the only flusher, its `scheduledDelayMillis` (already deployment-tunable through the `processor` passthrough) governs export cadence, and `shutdown()`'s drain is complete by construction. Reinstate only if a deployment states a turn-boundary latency requirement `scheduledDelayMillis` cannot meet — and then by calling the retained `BatchLogRecordProcessor`'s own `forceFlush()`, never the provider's timeout-wrapped one.
 
 ## Consequences
 
-A deployment adds one `cordis.yml` entry with an OTLP endpoint and explicitly selects `FULL` to stream lifecycle-local canonical events into an OTel-compatible stack or `FEEDBACK_ONLY` to replay a lifecycle-local canonical-log prefix when feedback is recorded. `DISABLED` is the default ([`dsh-session-telemetry-otel` README](../../../../packages/session/session-telemetry-otel/README.md)) and constructs no reporting pipeline; removing the entry remains a silent opt-out, while the disabled mode keeps the local feedback warning. A rule-free deployment exports each event body exactly as captured — including every assistant chunk and any credentials embedded in file contents or command output — so a deployment crossing a trust boundary must mount `session-telemetry/record` listeners, and both READMEs state this plainly. Where rules are mounted, exported bodies can differ from canonical log bytes, so receivers must not treat telemetry as a byte-exact replica; the log remains the source of truth. Same-object replay after lost cursor state can duplicate lifecycle-local ledger rows, and crash durability remains out of scope until the outbox decision above is revisited.
+A deployment configures an OTLP endpoint and selects `FEEDBACK_ONLY` to release a complete canonical prefix at new explicit feedback. `DISABLED` is the plugin default ([`dsh-session-telemetry-otel` README](../../../../packages/session/session-telemetry-otel/README.md)) and constructs no reporting pipeline; removing the entry is a silent opt-out, while disabled mode keeps the local feedback warning. SDK scheduled flush and shutdown only drain authorized batches, without capturing later records. A rule-free deployment exports each captured event body, including compact assistant streams and credentials embedded in file contents or command output, so deployments crossing a trust boundary must mount `session-telemetry/record` listeners. Redacted bodies can differ from canonical log bytes; the log remains the source of truth. Lost handoff cursors can cause duplicates on a later authorized capture, and crash durability remains out of scope until the outbox decision above is revisited.

+ 3 - 4
.agents/notes/implemented/feature/2026-07-23-session-telemetry-otel-revival.zh.md

@@ -14,8 +14,7 @@ Status: implemented
 
 - **`@deepseek-ai/dsh-session-telemetry`** —— seam 本体。`SessionTelemetrySink`(`emit`/`flush?`/`shutdown`)、服务注册形态的 `SessionTelemetryBackend` 与 `SessionTelemetryCoordinator` 共同拥有生命周期本地捕获:每个新的 Session 对象从 `firstLiveSeq` 之前开始,随后逐 append firehose 以零 I/O 深拷贝、脱敏并交接每个事件;重新收养同一对象时从模块作用域游标之后继续。无缓冲按需捕获使用同样的一事件一记录映射,直到可选的包含式边界。Ledger 身份包含 `session.id`、`session.format_version` 与 `event.seq`;实时捕获还会转发 `agent/error`,并创建 dispose(资源释放)时的 `shutdown` 记录。
 - **`session-telemetry/record` waterfall(瀑布式事件)** —— 相对分支版本的增量,也是该 seam 的脱敏扩展点。每条记录抵达任何后端前必经此处;seam 自身不带任何规则——最内层 `next()` 原样透传,部署方以监听器挂载自己的规则(通过变换 `next()` 的返回值堆叠),抛异常的规则将该记录 fail-closed 扣下。脱敏只作用于导出副本;canonical log 永不改写。
-- **`@deepseek-ai/dsh-session-telemetry-otel`** —— 参考后端:OTel JS SDK 日志流水线(`LoggerProvider` → `BatchLogRecordProcessor` → OTLP/HTTP exporter),经 `exporter`/`processor` passthrough 原样配置。`DISABLED` 是默认值,且不构造任何传输;[反馈门控遥测决策](../../archived/feature/2026-08-05-feedback-gated-session-telemetry.md)定义了需显式启用的 `FULL` 与 `FEEDBACK_ONLY` 投递模式,这两种模式要求 `exporter.url`,且不移动脱敏或后端边界。[无缓冲反馈回放](../../archived/simplification/2026-08-06-buffer-free-feedback-telemetry.md)避免在内存中创建会话前缀的第二份副本。
-
+- **`@deepseek-ai/dsh-session-telemetry-otel`** —— 参考后端:OTel JS SDK 日志流水线(`LoggerProvider` → `BatchLogRecordProcessor` → OTLP/HTTP exporter),经 `exporter`/`processor` passthrough 原样配置。`DISABLED` 是插件默认值,且不构造任何传输。`FEEDBACK_ONLY` 要求 `exporter.url`;[显式反馈策略](../architecture/2026-09-05-nonofficial-feedback-otel.zh.md)要求每次有界捕获都由新提交触发,适用于所有提供方。[无缓冲反馈回放](../../archived/simplification/2026-08-06-buffer-free-feedback-telemetry.md)避免在内存中创建会话前缀的第二份副本。
 
 边界公理保持不变:harness 的职责止于 `emit()`。批处理、重试、排队与丢失策略属于 reporting SDK,并经 passthrough 配置。投递是尽力而为:崩溃可能丢失已排队记录。后端重试或丢失同一 live 对象的模块作用域游标可能重复生命周期本地 row,因此接收端基于 `(session.id, session.format_version, event.seq)` 去重。
 
@@ -29,10 +28,10 @@ Status: implemented
 
 **映射到 OTel span(GenAI 语义约定)而非日志。** 本次复活否决:分支实现的日志映射已经过评审、形态可交付;span 模型对可 fork、可中断的会话有损,留给将来真正有 span 查询需求的消费方。
 
-**为每个新 Session 对象回放完整 constructor seed。** 不予采用,因为 fork 的继承事件以及 resume 或迁移日志属于其他生命周期,可能早于当前共享动作。在新对象下回放这些内容会再次释放已共享数据、把继承事件归因给 child Session,并使反馈确认文本低估实际交接范围。因此,新对象从 `firstLiveSeq` 之前开始:全新 Session 仍从 seq 0 开始,seeded Session 则从其生命周期边界开始。重新收养同一对象时仍从游标之后继续,因此 HMR 不会重复稳定的生命周期后缀。该规则不提供崩溃回填;要求保证历史投递的部署需要延后的 durable outbox,并对更广范围作出显式披露
+**自动回放每个 constructor seed。** fork 的继承事件和恢复的日志可能早于当前共享动作。协调器对新对象默认从 `firstLiveSeq` 开始;`includeHistory` 显式允许后端捕获存储的上下文。OTel 仅在新的自身反馈时选择完整历史,绝不在构造、恢复或 HMR 时捕获。同对象游标减少重复交接,但不保证历史投递,也不替代延后的持久化 outbox
 
 **将 seam 的轮次边界 `flush()` 提示转发到 OTel 提供方的 `forceFlush()`。** 首轮复活曾交付此转发,其后移除:三条不同的静默丢失路径共用同一份包装层状态——dispose 与进行中的 flush 之间的竞态(SDK 的并发 flush 防护会令 shutdown 的内部排空被跳过)、相互重叠的提示顶掉留存的 promise、以及提供方固定的 30 秒 flush 超时在批处理器仍在排空时便 reject。这些路径存在的唯一原因,是该转发让这个后端成为进程内第二个执行 flush 的组件,面对的还是上游实验性(experimental)源码树中未见诸文档的 SDK 内部行为;不实现 `flush()` 时,批处理器就是唯一执行 flush 的组件,其 `scheduledDelayMillis`(已可由部署方经 `processor` passthrough 调优)决定导出节奏,`shutdown()` 的排空从构造上就是完整的。仅当某个部署提出 `scheduledDelayMillis` 无法满足的轮次边界延迟要求时才恢复此转发——且届时应调用留存的 `BatchLogRecordProcessor` 自身的 `forceFlush()`,绝不调用提供方那个带超时包装的版本。
 
 ## 后果
 
-部署方在 `cordis.yml` 加一个带 OTLP endpoint 的 Cordis 配置项,并显式选择 `FULL`,即可把生命周期本地权威事件流接入任何 OTel 兼容体系;选择 `FEEDBACK_ONLY` 则会在记录反馈时回放生命周期本地权威日志前缀。`DISABLED` 是默认值([`dsh-session-telemetry-otel` README](../../../../packages/session/session-telemetry-otel/README.zh.md)),且不构造上报流水线;删除该配置项仍是静默退出方式,而禁用模式会保留本地反馈警告。未挂载规则的部署会按捕获原样导出每个事件 body,包括每条 assistant chunk,以及文件内容与命令输出中内嵌的任何凭据。因此,跨信任边界的部署必须挂载 `session-telemetry/record` 监听器,两份 README 对此如实陈述。挂载规则后,导出的 body 可能与 canonical log 字节不同,接收端不得把遥测当作字节精确副本;日志仍是真源。同一对象丢失游标状态后的回放可能重复生命周期本地 ledger 行,崩溃持久性则在上述 outbox 决定重新审议前继续不在范围内。
+部署方配置 OTLP endpoint 并选择 `FEEDBACK_ONLY`,即可在新的显式反馈时释放完整权威日志前缀。`DISABLED` 是插件默认值([`dsh-session-telemetry-otel` README](../../../../packages/session/session-telemetry-otel/README.zh.md)),且不构造上报流水线;删除配置项是静默退出方式,而禁用模式保留本地反馈警告。SDK 定时刷新和关闭只排空已授权批次,不捕获后续记录。未挂载规则的部署会导出每条已捕获事件的正文,包括紧凑 assistant stream 以及文件内容或命令输出中内嵌的凭据,因此跨信任边界的部署必须挂载 `session-telemetry/record` 监听器。脱敏后的正文可能与权威日志字节不同;日志仍是真源。丢失交接游标可能使后续已授权捕获产生重复,崩溃持久性则在上述 outbox 决定重新审议前继续不在范围内。

+ 2 - 2
.agents/notes/implemented/feature/2026-08-25-feedback-gated-telemetry-default.i18n.yaml

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-08-25-feedback-gated-telemetry-default.md
-2026-08-25-feedback-gated-telemetry-default.md: f3de87e8aec62b5fe6ddc71a008ac0fd82aef78d
-2026-08-25-feedback-gated-telemetry-default.zh.md: be3a0d6819a0074a2d30a200cde19e0d3f260363
+2026-08-25-feedback-gated-telemetry-default.md: 1a5ecd6b34415650db5e5068d96f9d2dc5f30e42
+2026-08-25-feedback-gated-telemetry-default.zh.md: 55936aa4045214b627ecaad39d898b1898a50fc9

+ 6 - 6
.agents/notes/implemented/feature/2026-08-25-feedback-gated-telemetry-default.md

@@ -10,22 +10,22 @@ Diagnosing a `/feedback` report needs the session data the report describes. Wit
 
 ## Decision
 
-The [canonical feedback decision](../architecture/2026-09-05-canonical-feedback-log.md) supersedes this default composition: the shipped base disables its OTel row. The optional backend retains its modes; the release-boundary rationale below does not authorize upload in a default installation.
+The [explicit-feedback OTel decision](../architecture/2026-09-05-nonofficial-feedback-otel.md) owns upload authorization for all users, including `deepseek-official`. This note retains the rationale for the base default: feedback-gated release rather than continuous export.
 
-When explicitly enabled without replacing its config, the shared base's OTel row resolves an unset or empty `DSH_TELEMETRY_MODE` to `FEEDBACK_ONLY`. The plugin's own omitted-`mode` default remains `DISABLED`; `FULL` and `DISABLED` are explicit environment overrides, and non-empty `DSH_TELEMETRY_DISABLED` remains the pre-load hard opt-out. In `FEEDBACK_ONLY`, each `feedback/record` releases the canonical suffix after the same Session object's handoff cursor through that event. A new object starts at its constructor boundary: a fresh Session begins at seq 0, while a forked, resumed, or migrated Session excludes its constructor seed and begins with this lifecycle's `session/end-seed`.
+The shared base resolves an unset or empty `DSH_TELEMETRY_MODE` to `FEEDBACK_ONLY`. The plugin's own omitted-`mode` default is `DISABLED`; `FULL` rejects, and non-empty `DSH_TELEMETRY_DISABLED` is the pre-load hard opt-out. New own text feedback, message-rating edits, and withdrawals release the unhanded canonical prefix through that event, including stored context. Inherited parent feedback does not authorize a child export.
 
-Feedback-gated release lets a reporter share the lifecycle that exhibited the problem without reproducing it. It trades continuous export for an explicit feedback trigger, but a deployment must establish consent before enabling that policy. The [archived default-off](../../archived/feature/2026-08-10-telemetry-default-off.md) and [default-mount](../../archived/feature/2026-07-31-web-telemetry-default-mount.md) notes record the earlier composition; the [base patch](../../../../packages/bundle/base/cordis.patch.yml) and [OTel README](../../../../packages/session/session-telemetry-otel/README.md) own current configuration.
+Feedback-gated release lets a reporter share the Session that exhibited the problem without reproducing it. It trades continuous export for an explicit feedback trigger. The [archived default-off](../../archived/feature/2026-08-10-telemetry-default-off.md) and [default-mount](../../archived/feature/2026-07-31-web-telemetry-default-mount.md) notes record the earlier composition; the [base patch](../../../../packages/bundle/base/cordis.patch.yml) and [OTel README](../../../../packages/session/session-telemetry-otel/README.md) own current configuration.
 
 ## Alternatives considered
 
 **Require reporters to re-run after enabling telemetry.** Rejected as the feedback-gated workflow: the Session that exhibited the problem is the useful evidence, and re-running loses it.
 
-**Default to `FULL`.** Rejected: a fresh installation does not authorize continuous export without user action.
+**Permit continuous export.** Rejected: deployment configuration does not authorize capture without explicit feedback.
 
-**Use only subsequent DeepSeek requests for delivery.** The canonical feedback decision accepts this for the shipped default, including the risk that a final feedback entry remains local. The optional feedback-gated OTel mode retains its event-time release trigger; request-time delivery cannot provide that timing without initiating another request.
+**Use only subsequent DeepSeek requests for delivery.** The independent opt-in contribution can carry canonical feedback, but a final feedback entry may have no later request. OTel releases it for every provider without initiating another LLM request.
 
 ## Consequences
 
-- The shipped base uploads nothing through OTel. An explicitly enabled `FEEDBACK_ONLY` backend hands off the unreleased lifecycle-local prefix only on `feedback/record`; message-rating events do not themselves trigger release.
+- The shipped base releases a bounded prefix only at new explicit feedback. Ordinary requests, lifecycle events, and stored feedback do not trigger capture. Later records wait for the next explicit feedback.
 - On-demand capture copies and redacts the canonical log at feedback time. Without a deployment redaction rule, exported data can include message text, tool arguments and results, and workspace paths.
 - The command acknowledgement confirms recording, not sharing or delivery. A deployment requiring prior informed consent must provide it before enabling uploads; OTel handoff remains subject to the SDK's batching, retry, and loss policy.

+ 6 - 6
.agents/notes/implemented/feature/2026-08-25-feedback-gated-telemetry-default.zh.md

@@ -10,22 +10,22 @@ Status: implemented
 
 ## 决定
 
-[权威反馈决策](../architecture/2026-09-05-canonical-feedback-log.zh.md)取代此默认组合:随附基础配置禁用 OTel 配置行。可选后端保留其模式;下述释放边界的理由不构成默认安装的上传授权
+[显式反馈 OTel 决策](../architecture/2026-09-05-nonofficial-feedback-otel.zh.md)负责所有用户的上传授权,包括 `deepseek-official`。本记录保留基础默认值的理由:选择反馈门控释放而非持续导出
 
-共享基础配置的 OTel 行被显式启用且其 config 未被替换时,会把未设置或为空的 `DSH_TELEMETRY_MODE` 解析为 `FEEDBACK_ONLY`。插件自身省略 `mode` 的默认值仍是 `DISABLED`;`FULL` 和 `DISABLED` 是显式环境覆盖值,非空 `DSH_TELEMETRY_DISABLED` 仍是加载前的强制关闭开关。在 `FEEDBACK_ONLY` 下,每个 `feedback/record` 会释放同一 Session 对象的 handoff 游标之后至该事件的权威后缀。新对象从 constructor boundary 开始:全新 Session 从 seq 0 开始,而 fork、resume 或迁移 Session 排除 constructor seed,从本生命周期的 `session/end-seed` 开始
+共享基础配置把未设置或为空的 `DSH_TELEMETRY_MODE` 解析为 `FEEDBACK_ONLY`。插件自身省略 `mode` 的默认值是 `DISABLED`;`FULL` 被拒绝,非空 `DSH_TELEMETRY_DISABLED` 是加载前的强制关闭开关。新的自身文本反馈、消息评分编辑和撤回释放尚未交接的权威前缀,截止该事件,包含存储的上下文。继承的父会话反馈不授权子会话导出
 
-反馈门控释放让报告者无需复现问题就能共享出问题的生命周期。它用显式反馈触发取代持续导出,但部署必须在启用该策略前取得同意。[已归档默认关闭](../../archived/feature/2026-08-10-telemetry-default-off.md)与[默认挂载](../../archived/feature/2026-07-31-web-telemetry-default-mount.md)记录记载早期组合;当前配置由[基础补丁](../../../../packages/bundle/base/cordis.patch.yml)与 [OTel README](../../../../packages/session/session-telemetry-otel/README.zh.md) 持有。
+反馈门控释放让报告者无需复现问题就能共享出问题的 Session。它用显式反馈触发取代持续导出。[已归档默认关闭](../../archived/feature/2026-08-10-telemetry-default-off.md)与[默认挂载](../../archived/feature/2026-07-31-web-telemetry-default-mount.md)记录记载早期组合;当前配置由[基础补丁](../../../../packages/bundle/base/cordis.patch.yml)与 [OTel README](../../../../packages/session/session-telemetry-otel/README.zh.md) 持有。
 
 ## 考虑过的替代方案
 
 **要求报告者启用遥测后重跑。** 不作为反馈门控工作流:值得保留的证据是出问题的那个 Session,重跑会丢掉它。
 
-**默认 `FULL`。** 否决:全新安装不授权没有用户动作的持续导出
+**允许持续导出。** 否决:部署配置不授权没有显式反馈的捕获
 
-**仅通过后续 DeepSeek 请求投递。** 权威反馈决策为随附默认配置接受此方案,包括最后一条反馈可能留在本地的风险。可选反馈门控 OTel 模式保留事件发生时的释放触发;请求时投递若不发起另一个请求,就无法提供这种时机
+**仅通过后续 DeepSeek 请求投递。** 独立的需显式启用的贡献可传送权威反馈,但最终反馈之后可能没有请求。OTel 为每个提供方释放反馈,无需发起另一个 LLM 请求
 
 ## 后果
 
-- 随附基础配置不通过 OTel 上传任何内容。显式启用的 `FEEDBACK_ONLY` 后端只在 `feedback/record` 时交接未释放的生命周期本地前缀;消息评分事件本身不触发释放
+- 随附基础配置仅在新的显式反馈时释放有界前缀。普通请求、生命周期事件和已存储反馈不触发捕获。后续记录等待下一次显式反馈
 - 按需捕获在反馈时复制权威日志并脱敏。部署未挂载脱敏规则时,导出数据可能包含消息文本、工具参数和结果,以及 workspace 路径。
 - 命令确认文本确认记录,而非共享或投递。要求事先知情同意的部署必须在启用上传前提供该步骤;OTel 交接仍受 SDK 的批处理、重试与丢失策略约束。

+ 2 - 2
apps/cli/reference/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 apps/cli/reference/README.md
-README.md: fe2b5bab5a41095d4184f49de36dfec40cc87bc0
-README.zh.md: 01e0d06fe57f9364d7411d2e2dae7892327d803e
+README.md: 5a276a3fa5bd0b96fcecb1e209146b17bcb4a95e
+README.zh.md: d5263652b909f13c8fc283b694eea1b18a0c037f

+ 2 - 2
apps/cli/reference/README.md

@@ -89,9 +89,9 @@ New sessions in base-backed profiles default to the `workspace-write` permission
 
 ## Shared deployment behavior
 
-The base bundle mounts the native DeepSeek adapter, settings and credential providers, stable `web_search` and `web_fetch`, the public-only HTTP fetch provider, and opt-in DeepSeek session-log upload. Provider credentials resolve from the inherited environment, `$DSH_HOME/.credentials.yaml`, the invoking directory's `.env`, then `$DSH_HOME/.env`; the managed document is never materialized into `process.env`, while both `.env` files are ordinary launch environment layers. Search uses `DEEPSEEK_API_KEY` and accepts `DEEPSEEK_SEARCH_BASE_URL`. Enabled fetch calls run in every sandbox and approval mode without per-call confirmation; the provider rejects non-public destinations before connecting. The Web app disables the base tool row and exposes the same tools through its `cordis`, `ptc`, and `standard` agent presets.
+The base bundle mounts the native DeepSeek adapter, settings and credential providers, stable `web_search` and `web_fetch`, the public-only HTTP fetch provider, opt-in DeepSeek session-log upload, and feedback-gated OTel upload for all users. Provider credentials resolve from the inherited environment, `$DSH_HOME/.credentials.yaml`, the invoking directory's `.env`, then `$DSH_HOME/.env`; the managed document is never materialized into `process.env`, while both `.env` files are ordinary launch environment layers. Search uses `DEEPSEEK_API_KEY` and accepts `DEEPSEEK_SEARCH_BASE_URL`. Enabled fetch calls run in every sandbox and approval mode without per-call confirmation; the provider rejects non-public destinations before connecting. The Web app disables the base tool row and exposes the same tools through its `cordis`, `ptc`, and `standard` agent presets.
 
-Feedback is recorded in the Session log without starting model work. Enable the [DeepSeek session-log contributor](../../../packages/session/session-log-deepseek/README.md) to send complete unaccepted log suffixes with subsequent DeepSeek requests, including configured gateways. The shipped OTel session-upload row is disabled; changing `DSH_TELEMETRY_MODE` alone does not enable it. Session-log upload can include message text, tool arguments and results, and workspace paths.
+Feedback is recorded in the Session log without starting model work. Enable the [DeepSeek session-log contributor](../../../packages/session/session-log-deepseek/README.md) to send complete unaccepted log suffixes with subsequent DeepSeek requests, including configured gateways. [OTel session upload](../../../packages/session/session-telemetry-otel/README.md) applies to all users and providers, including `deepseek-official`, without requiring a request header. The base defaults to `FEEDBACK_ONLY`: new own text feedback, message ratings, edits, and withdrawals release the complete canonical prefix through that event, including stored context; later records wait for the next explicit feedback. Inherited parent feedback does not authorize a fork. Requests, restoration, mount, and HMR do not trigger capture. SDK batching may finish an authorized upload without further interaction or model work. `DSH_TELEMETRY_MODE=DISABLED` disables OTel delivery; `FULL` is rejected, and any non-empty `DSH_TELEMETRY_DISABLED` disables its row. `DSH_TELEMETRY_OTLP_URL` selects the collector. Handoff is best-effort, not collector acceptance; no durable outbox or retry guarantee is provided. These OTel settings do not enable or disable the DeepSeek contribution. Neither path changes model input, but exports can include message text, tool arguments and results, and workspace paths.
 
 Install external plugin bundles through `dsh plugin --profile <name> add <package-or-git-spec>`. The installed package owns its dependencies and contributes its declared `cordis.patch.yml` layer. The CLI also ships `@deepseek-ai/dsh-mcp-client` as a dependency for patch layers, but no MCP server is enabled by default because each server command is trusted executable code outside the agent sandbox.
 

+ 2 - 2
apps/cli/reference/README.zh.md

@@ -89,9 +89,9 @@ dsh web --help
 
 ## 共享部署行为
 
-基础组合包挂载原生 DeepSeek 适配器、settings 与凭据提供方、稳定的 `web_search` 和 `web_fetch`、仅限公网的 HTTP fetch 提供方,以及需主动开启的 DeepSeek 会话日志上传。提供方凭据依次从继承环境、`$DSH_HOME/.credentials.yaml`、调用目录的 `.env` 和 `$DSH_HOME/.env` 解析;受管文档从不物化进 `process.env`,而两个 `.env` 文件都是普通启动环境层。搜索使用 `DEEPSEEK_API_KEY` 并接受 `DEEPSEEK_SEARCH_BASE_URL`。已启用的抓取调用会在所有 sandbox 与审批模式下执行,无需逐次确认;提供方会在连接前拒绝非公开目的地址。Web app 会禁用 base 工具配置项,再通过 `cordis`、`ptc` 与 `standard` agent preset 暴露相同工具。
+基础组合包挂载原生 DeepSeek 适配器、settings 与凭据提供方、稳定的 `web_search` 和 `web_fetch`、仅限公网的 HTTP fetch 提供方,需主动开启的 DeepSeek 会话日志上传,以及面向所有用户的反馈门控 OTel 上传。提供方凭据依次从继承环境、`$DSH_HOME/.credentials.yaml`、调用目录的 `.env` 和 `$DSH_HOME/.env` 解析;受管文档从不物化进 `process.env`,而两个 `.env` 文件都是普通启动环境层。搜索使用 `DEEPSEEK_API_KEY` 并接受 `DEEPSEEK_SEARCH_BASE_URL`。已启用的抓取调用会在所有 sandbox 与审批模式下执行,无需逐次确认;提供方会在连接前拒绝非公开目的地址。Web app 会禁用 base 工具配置项,再通过 `cordis`、`ptc` 与 `standard` agent preset 暴露相同工具。
 
-反馈记录在会话日志中,不会启动模型工作。开启 [DeepSeek 会话日志贡献器](../../../packages/session/session-log-deepseek/README.zh.md)后,后续 DeepSeek 请求会发送尚未确认的完整日志后缀,包括发往配置网关的请求。随附 OTel 会话上传行处于禁用状态;仅修改 `DSH_TELEMETRY_MODE` 不会启用该行。会话日志上传可能包含消息文本、工具参数和结果,以及工作区路径。
+反馈记录在会话日志中,不会启动模型工作。开启 [DeepSeek 会话日志贡献器](../../../packages/session/session-log-deepseek/README.zh.md)后,后续 DeepSeek 请求会发送尚未确认的完整日志后缀,包括发往配置网关的请求。[OTel 会话上传](../../../packages/session/session-telemetry-otel/README.zh.md)适用于所有用户和提供方,包括 `deepseek-official`,无需请求头。基础配置默认使用 `FEEDBACK_ONLY`:新的自身文本反馈、消息评分、编辑与撤回会释放截至该事件的完整权威日志前缀,包含存储的上下文;后续记录等待下一次显式反馈。继承的父级反馈不构成 fork 的授权。请求、恢复、挂载和 HMR 不触发捕获。SDK 批处理可完成已授权上传,无需进一步交互或模型工作。`DSH_TELEMETRY_MODE=DISABLED` 禁止 OTel 投递;`FULL` 被拒绝,任何非空的 `DSH_TELEMETRY_DISABLED` 都会禁用其配置行。`DSH_TELEMETRY_OTLP_URL` 选择采集端。交接尽力而为,不代表采集端接受;不提供持久化 outbox 或重试保证。这些 OTel 设置不会开启或关闭 DeepSeek 贡献。两条路径都不改变模型输入,但导出可能包含消息文本、工具参数和结果,以及工作区路径。
 
 通过 `dsh plugin --profile <name> add <package-or-git-spec>` 安装外部插件组合包。安装的包拥有其依赖,并贡献其声明的 `cordis.patch.yml` 层。CLI 还随附 `@deepseek-ai/dsh-mcp-client` 作为供 patch 层使用的依赖,但默认不启用 MCP 服务器,因为每条服务器命令都是 agent(智能体)沙箱之外的受信任可执行代码。
 

+ 163 - 14
apps/web/tests/feedback-release.e2e.ts

@@ -1,6 +1,5 @@
-// The shipped disabled OTel row keeps feedback local. A loopback collector
-// observes the complete browser session through scaffold shutdown, while a
-// separate persistence reader verifies both remarks reach the session log.
+// Recorded model replay drives the shipped feedback UI and canonical log.
+// The loopback collector must receive each authorized suffix before the next UI action.
 import { readFile } from 'node:fs/promises'
 import { fileURLToPath } from 'node:url'
 import { join } from 'node:path'
@@ -9,34 +8,81 @@ import { once } from 'node:events'
 import { gunzipSync } from 'node:zlib'
 import type { Browser, Page } from 'playwright'
 import { chromium } from 'playwright'
-import type { SessionId } from '@deepseek-ai/dsh-session'
+import type { SessionEvent, SessionId } from '@deepseek-ai/dsh-session'
 import { afterAll, beforeAll, describe, expect, it, onTestFailed } from 'vitest'
 import {
-  assertFixtureInventory, captureExpandedTurnProcessAria, captureStableAria,
+  acknowledgeReloadConnectionLoss, assertFixtureInventory, captureExpandedTurnProcessAria, captureStableAria,
   compareOrRefreshGolden, fixtureUserPrompts,
   launchWebScaffold, readPersistedEvents, watchConsole, webSnapshotMode, type WebScaffold,
 } from './scaffold.ts'
 import { connectFreshWorkspace, newEnglishPage, saveFailureShot } from './support.ts'
 
 const SNAPSHOT_DIR = fileURLToPath(new URL('../../../snapshots/web/feedback-release', import.meta.url))
-// The local-only path needs only a settled ordinary turn, so this lane replays
-// the feedback-command scenario's recorded session (declared as this
-// manifest's `session.source`) instead of recording a duplicate.
+// Both routes borrow the same settled turn; this manifest references its owner.
 const FIXTURE = fileURLToPath(new URL('../../../snapshots/web/feedback-command/session.v2.jsonl', import.meta.url))
 const ACK_EXPECTED = join(SNAPSHOT_DIR, 'ack.expected.md')
 const ACK_EXPANDED_EXPECTED = join(SNAPSHOT_DIR, 'ack-expanded.expected.md')
+const RELEASE_EXPECTED = join(SNAPSHOT_DIR, 'feedback-release.expected.json')
 const MODE = webSnapshotMode()
 
+interface OtlpCapture {
+  resourceLogs: { scopeLogs: { logRecords: {
+    attributes: { key: string; value: { stringValue?: string; intValue?: number | string } }[]
+  }[] }[] }[]
+}
+
 const PROMPT = 'Reply with the single word LIGHTHOUSE and stop.'
 
-describe('web e2e: feedback stays local with shipped OTel disabled', () => {
+describe.each(MODE === 'record' ? ['deepseek-official'] : ['deepseek-official', 'feedback-mock'])('web e2e: feedback release for %s', (provider) => {
+  const official = provider === 'deepseek-official'
   let scaffold: WebScaffold
   let browser: Browser
   let page: Page
   let tripwire: ReturnType<typeof watchConsole>
   let collector: Server
   let sessionId: SessionId
+  let authorized: readonly SessionEvent[] = []
+  let releasedCount = 0
+  const suffixes: string[][] = []
   const uploads: string[] = []
+  let headerlessExpected: [string, number, string][] = []
+
+  function captured(): [string | undefined, number, string | undefined][] {
+    return uploads.flatMap((upload) => {
+      const capture = JSON.parse(upload) as OtlpCapture
+      return capture.resourceLogs.flatMap(resource => resource.scopeLogs.flatMap(scope =>
+        scope.logRecords.map((record) => {
+          const attribute = (key: string) => record.attributes.find(value => value.key === key)?.value
+          return [attribute('session.id')?.stringValue, Number(attribute('event.seq')?.intValue),
+            attribute('event.type')?.stringValue] as [string | undefined, number, string | undefined]
+        })))
+    })
+  }
+
+  async function expectFeedbackRelease(type: SessionEvent['type'], count: number): Promise<void> {
+    const agent = scaffold.ctx.agents.get(sessionId)
+    if (agent === undefined) throw new Error('feedback session has no active agent')
+    await scaffold.ctx.sessions.flush(agent.session)
+    const events = await readPersistedEvents(scaffold, sessionId)
+    const feedback = events.filter(event => event.type === type)
+    expect(feedback).toHaveLength(count)
+    const boundary = feedback.at(-1)!
+    authorized = events.filter(event => event.seq <= boundary.seq)
+    const expected = authorized.map(event => [sessionId, event.seq, event.type])
+    // No teardown, flush hook, or subsequent interaction may cause this delivery.
+    await expect.poll(captured, { timeout: 10_000 }).toEqual(expected)
+    suffixes.push(authorized.slice(releasedCount).map(event => event.type))
+    releasedCount = authorized.length
+    expect(scaffold.ctx.agents.get(sessionId)).toBe(agent)
+  }
+
+  async function selectModel(name: string): Promise<void> {
+    const trigger = page.getByRole('button', { name: /^Select model, current/ })
+    await trigger.click()
+    await page.getByRole('menuitem', { name: /^Model\b/ }).click()
+    await page.getByRole('menuitemradio', { name, exact: true }).click()
+    await expect.poll(() => trigger.getAttribute('aria-label')).toContain(name)
+  }
 
   beforeAll(async () => {
     collector = createServer((request, response) => {
@@ -54,6 +100,16 @@ describe('web e2e: feedback stays local with shipped OTel disabled', () => {
     if (address === null || typeof address === 'string') throw new Error('collector has no port')
     scaffold = await launchWebScaffold({
       telemetryUrl: `http://127.0.0.1:${address.port}/v1/logs`,
+      telemetryMode: 'FEEDBACK_ONLY',
+      telemetryScheduledDelayMillis: 10,
+      replayProviders: [
+        { id: 'deepseek-official', name: 'DeepSeek', models: [
+          { id: 'deepseek-v4-flash', name: 'DeepSeek-V4-Flash', contextWindow: 128_000 },
+        ] },
+        { id: 'feedback-mock', name: 'Feedback mock', models: [
+          { id: 'feedback-mock', name: 'Feedback mock', contextWindow: 128_000 },
+        ] },
+      ],
       // The replayed session.v2.jsonl belongs to the feedback-command scenario;
       // comparing (or refreshing) the persisted session here would rewrite
       // that shared source with this lane's feedback events. Persistence and
@@ -73,7 +129,8 @@ describe('web e2e: feedback stays local with shipped OTel disabled', () => {
     try {
       await browser?.close()
       await scaffold?.close()
-      expect(uploads).toEqual([])
+      // Shutdown drains any illicitly queued tail too; delivery assertions run in the tests.
+      expect(captured()).toEqual([...authorized.map(event => [sessionId, event.seq, event.type]), ...headerlessExpected])
     } finally {
       if (collector?.listening) {
         await new Promise<void>((resolve, reject) => {
@@ -95,10 +152,21 @@ describe('web e2e: feedback stays local with shipped OTel disabled', () => {
     }
     const input = page.locator('[data-composer-input]').first()
     await input.waitFor({ timeout: 10_000 })
+    if (!official) await selectModel('Feedback mock')
     const settled = scaffold.whenTurnSettled()
     await input.fill(PROMPT)
     await input.press('Enter')
     sessionId = await settled
+    const agent = scaffold.ctx.agents.get(sessionId)
+    expect(agent?.session.requestHeader()?.config.provider).toBe(provider)
+    expect(uploads).toEqual([])
+    // Both routes render the same composer without changing the actual request header.
+    if (MODE !== 'record') {
+      await selectModel('Feedback mock')
+      await selectModel('DeepSeek-V4-Flash')
+    }
+    expect(agent?.session.requestHeader()?.config.provider).toBe(provider)
+    expect(uploads).toEqual([])
   }, 60_000)
 
   it.skipIf(MODE === 'record')('records feedback locally and acknowledges its session and anonymous user ids', async () => {
@@ -111,7 +179,7 @@ describe('web e2e: feedback stays local with shipped OTel disabled', () => {
 
     await page.getByText(/Feedback recorded for session/).waitFor({ timeout: 10_000 })
     expect(await page.getByText(/Anonymous user: [0-9a-f-]+\.$/i).count()).toBe(1)
-    expect(uploads).toEqual([])
+    await expectFeedbackRelease('feedback/record', 1)
 
     const snapshot = await captureStableAria(page, '[class*="centerCol"]', scaffold.workspaceCwd)
     await compareOrRefreshGolden(ACK_EXPECTED, snapshot, MODE)
@@ -125,12 +193,41 @@ describe('web e2e: feedback stays local with shipped OTel disabled', () => {
     expect(tripwire.warnings).toEqual([])
   }, 60_000)
 
-  it.skipIf(MODE === 'record')('persists both feedback remarks without uploading session records', async () => {
+  it.skipIf(MODE === 'record')('does not release command tails, provider changes, or browser reloads', async () => {
+    const events = await readPersistedEvents(scaffold, sessionId)
+    expect(events.at(-1)?.type).toBe('command/done')
+    expect(events.at(-1)!.seq).toBeGreaterThan(authorized.at(-1)!.seq)
+    await selectModel('Feedback mock')
+    await selectModel('DeepSeek-V4-Flash')
+    const warningStart = tripwire.warnings.length
+    await page.reload({ waitUntil: 'load' })
+    acknowledgeReloadConnectionLoss(tripwire, warningStart)
+    await page.getByText('LIGHTHOUSE', { exact: true }).waitFor({ timeout: 15_000 })
+    expect(captured()).toHaveLength(releasedCount)
+  })
+
+  it.skipIf(MODE === 'record')('persists text, ratings, notes, and retractions in the canonical session', async () => {
     onTestFailed(() => saveFailureShot(page, 'web-e2e-feedback-release-suffix'))
     const input = page.locator('[data-composer-input]').first()
     await input.fill('/feedback the second remark')
     await input.press('Enter')
     await expect.poll(() => page.getByText(/Feedback recorded for session/).count()).toBe(2)
+    await expectFeedbackRelease('feedback/record', 2)
+    const like = page.getByRole('button', { name: 'Good response' })
+    await like.hover()
+    await like.click()
+    const rated = page.getByRole('button', { name: 'Remove rating' })
+    await expect.poll(() => rated.getAttribute('aria-pressed')).toBe('true')
+    await expectFeedbackRelease('feedback/message-put', 1)
+    await page.getByRole('button', { name: 'Add a note' }).click()
+    await page.getByRole('textbox', { name: 'Feedback note' }).fill('Read both files before answering.')
+    expect(captured()).toHaveLength(releasedCount)
+    await page.getByRole('button', { name: 'Save', exact: true }).click()
+    await page.getByText('Read both files before answering.', { exact: true }).waitFor()
+    await expectFeedbackRelease('feedback/message-put', 2)
+    await rated.click()
+    await expect.poll(() => like.getAttribute('aria-pressed')).toBe('false')
+    await expectFeedbackRelease('feedback/message-delete', 1)
     const agent = scaffold.ctx.agents.get(sessionId)
     if (agent === undefined) throw new Error('feedback session has no active agent')
     await scaffold.ctx.sessions.flush(agent.session)
@@ -139,11 +236,63 @@ describe('web e2e: feedback stays local with shipped OTel disabled', () => {
       { data: { text: 'the diff view is unreadable' } },
       { data: { text: 'the second remark' } },
     ])
+    expect(events.filter(event => event.type === 'feedback/message-put')).toMatchObject([
+      { data: { sessionId, item: { rating: 'positive' } } },
+      { data: { sessionId, item: { rating: 'positive', note: 'Read both files before answering.' } } },
+    ])
+    expect(events.filter(event => event.type === 'feedback/message-delete')).toMatchObject([{ data: { sessionId } }])
     expect(events.filter(event => event.type === 'turn/end')).toHaveLength(1)
-    expect(uploads).toEqual([])
+    expect(agent.session.requestHeader()?.config.provider).toBe(provider)
+    expect(captured()).toHaveLength(releasedCount)
+    const wire = uploads.join('\n')
+    for (const text of ['the diff view is unreadable', 'the second remark',
+      'Read both files before answering.']) expect(wire).toContain(text)
+    const feedback = events.flatMap<Record<string, string | undefined>>((event) => {
+      switch (event.type) {
+        case 'feedback/record': return [{ type: event.type, text: event.data.text }]
+        case 'feedback/message-put': return [{ type: event.type, rating: event.data.item.rating, note: event.data.item.note }]
+        case 'feedback/message-delete': return [{ type: event.type }]
+        default: return []
+      }
+    })
+    await compareOrRefreshGolden(RELEASE_EXPECTED, JSON.stringify({
+      mode: 'FEEDBACK_ONLY', feedback,
+      // The prefix is compared with each provider's actual canonical log above.
+      laterSubmissionSuffixes: suffixes.slice(1),
+    }, null, 2), MODE)
   }, 60_000)
 
+  it.skipIf(MODE === 'record')('releases headerless feedback without capturing another session’s provider-change tail', async () => {
+    await selectModel('Feedback mock')
+    await selectModel('DeepSeek-V4-Flash')
+    expect(captured()).toHaveLength(releasedCount)
+    await page.getByRole('button', { name: 'New session', exact: true }).last().click()
+    const input = page.locator('[data-composer-input][contenteditable="true"][data-placeholder="Describe what you want to build... / commands, @ files or sessions"]')
+    await input.waitFor({ timeout: 15_000 })
+    await input.fill('/feedback Feedback before any model request.')
+    expect(captured()).toHaveLength(releasedCount)
+    await input.press('Enter')
+    const findHeaderless = () => scaffold.ctx.sessions.list().find(session => session.id !== sessionId
+      && session.snapshotEvents().some(event => event.type === 'feedback/record'))
+    // A command-only session keeps the hero view; its durable event confirms submission.
+    await expect.poll(findHeaderless, { timeout: 10_000 }).toBeDefined()
+    const headerless = findHeaderless()
+    if (headerless === undefined) throw new Error('headerless feedback session not found')
+    expect(headerless.requestHeader()).toBeUndefined()
+    await scaffold.ctx.sessions.flush(headerless)
+    const events = await readPersistedEvents(scaffold, headerless.id)
+    const feedback = events.find(event => event.type === 'feedback/record')!
+    headerlessExpected = events.filter(event => event.seq <= feedback.seq)
+      .map(event => [headerless.id, event.seq, event.type])
+    await expect.poll(captured, { timeout: 10_000 }).toEqual([
+      ...authorized.map(event => [sessionId, event.seq, event.type]), ...headerlessExpected,
+    ])
+    expect(uploads.join('\n')).toContain('Feedback before any model request.')
+    expect(tripwire.pageErrors).toEqual([])
+    expect(tripwire.warnings).toEqual([])
+  })
+
   it.skipIf(MODE === 'record')('keeps the fixture inventory closed', async () => {
-    await assertFixtureInventory(SNAPSHOT_DIR, ['ack.expected.md', 'ack-expanded.expected.md'])
+    await assertFixtureInventory(SNAPSHOT_DIR, ['ack.expected.md', 'ack-expanded.expected.md', 'feedback-release.expected.json'])
   })
 })

+ 14 - 8
apps/web/tests/scaffold.ts

@@ -67,7 +67,7 @@ import { LlmAdapter } from '@deepseek-ai/dsh-llm'
 import type {
   LlmModelInfo, LlmProviderInfo, LlmResolvedModelInfo, RetryPolicyConfig, StreamChunk,
 } from '@deepseek-ai/dsh-llm'
-import type { ReplayHandle } from '@deepseek-ai/dsh-llm-replay'
+import type { ReplayHandle, ReplayProviderConfig } from '@deepseek-ai/dsh-llm-replay'
 import {
   installLlmReplay,
   parseSessionLog,
@@ -306,6 +306,8 @@ export interface LaunchOptions {
    * model calls (its header alone mounts the catalog).
    */
   replayFixture?: string
+  /** Explicit replay routes for scenarios exercising provider-dependent behavior; replay/refresh only. */
+  replayProviders?: ReplayProviderConfig[]
   /**
    * Mount the replay provider catalog (the model directory the UI shows)
    * without consuming any recorded script: for scenarios that never call a
@@ -383,13 +385,14 @@ export interface LaunchOptions {
     default: string
   }
   /**
-   * Patch the telemetry exporter URL while preserving the shipped disabled
-   * setting. Point it at a scenario-owned loopback collector so a regression
-   * enabling the row cannot send fixture sessions outside the test.
+   * Patch the telemetry exporter URL while preserving the shipped enabled
+   * setting. A scenario-owned loopback collector contains all fixture uploads.
    */
   telemetryUrl?: string
-  /** Mode when telemetryUrl is supplied; defaults to FULL without enabling a disabled row. */
-  telemetryMode?: 'FULL' | 'FEEDBACK_ONLY'
+  /** Mode when telemetryUrl is supplied; defaults to FEEDBACK_ONLY without enabling a disabled row. */
+  telemetryMode?: 'FEEDBACK_ONLY'
+  /** SDK batch cadence for a scenario-owned collector; omitted to retain the SDK default. */
+  telemetryScheduledDelayMillis?: number
   /**
    * Browse through a trusted non-loopback hostname that the browser resolves
    * to loopback (for example `*.localhost`). The test server stays bound to
@@ -558,8 +561,11 @@ export async function launchWebScaffold(options: LaunchOptions = {}): Promise<We
       : {
         id: 'session-telemetry-otel',
         config: {
-          mode: options.telemetryMode ?? 'FULL',
+          mode: options.telemetryMode ?? 'FEEDBACK_ONLY',
           exporter: { url: options.telemetryUrl },
+          ...(options.telemetryScheduledDelayMillis === undefined ? {} : {
+            processor: { scheduledDelayMillis: options.telemetryScheduledDelayMillis },
+          }),
           shutdownTimeoutMillis: 1_000,
         },
       },
@@ -743,7 +749,7 @@ export async function launchWebScaffold(options: LaunchOptions = {}): Promise<We
     if (mode !== 'record' && replayFixture !== undefined) {
       replayHandle = installLlmReplay(ctx, {
         file: replayFixture,
-        providers: replayProviders(options.replayContextWindow).map(provider => ({
+        providers: (options.replayProviders ?? replayProviders(options.replayContextWindow)).map(provider => ({
           ...provider,
           ...(options.replayRetryPolicy === undefined ? {} : { retryPolicy: options.replayRetryPolicy }),
         })),

+ 2 - 2
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: 28fc6e327a14f6f2f41330f01becbcddf50eeb20
-config-catalog.zh.md: 49aad4a46eecb39001ec0b3d5c880fdcd74614d3
+config-catalog.md: 72d7423f308a60454eaab62a288f3617fb5b91ae
+config-catalog.zh.md: d3025a25bd808fe493ea0ae8ed93c8611ece944e

+ 2 - 3
docs/config-catalog.md

@@ -1977,7 +1977,7 @@ Requires: `sessions`
  * and shutdown deadline at plugin load; `DISABLED` reads neither.
  */
 export interface Config {
-  /** Sharing policy; defaults to local-only `DISABLED` behavior. */
+  /** Defaults to `FEEDBACK_ONLY`: capture session history only when feedback is explicitly submitted. */
   mode?: SessionTelemetryMode
   /**
    * Passed verbatim to the SDK's OTLP/HTTP log exporter — the complete
@@ -2000,7 +2000,6 @@ export interface Config {
 
 /** Session-sharing policy selected by {@link Config.mode}. */
 export enum SessionTelemetryMode {
-  FULL = 'FULL',
   FEEDBACK_ONLY = 'FEEDBACK_ONLY',
   DISABLED = 'DISABLED',
 }
@@ -2008,7 +2007,7 @@ export enum SessionTelemetryMode {
 
 Depends on: `BatchLogRecordProcessorOptions` (`@opentelemetry/sdk-logs`) · `OTLPExporterNodeConfigBase` (`@opentelemetry/otlp-exporter-base`)
 
-Source: [`packages/session/session-telemetry-otel/src/index.ts:91`](../packages/session/session-telemetry-otel/src/index.ts)
+Source: [`packages/session/session-telemetry-otel/src/index.ts:100`](../packages/session/session-telemetry-otel/src/index.ts)
 
 <a id="deepseek-aidsh-session-title"></a>
 

+ 2 - 3
docs/config-catalog.zh.md

@@ -1979,7 +1979,7 @@ export interface Config {
  * and shutdown deadline at plugin load; `DISABLED` reads neither.
  */
 export interface Config {
-  /** Sharing policy; defaults to local-only `DISABLED` behavior. */
+  /** Defaults to `FEEDBACK_ONLY`: capture session history only when feedback is explicitly submitted. */
   mode?: SessionTelemetryMode
   /**
    * Passed verbatim to the SDK's OTLP/HTTP log exporter — the complete
@@ -2002,7 +2002,6 @@ export interface Config {
 
 /** Session-sharing policy selected by {@link Config.mode}. */
 export enum SessionTelemetryMode {
-  FULL = 'FULL',
   FEEDBACK_ONLY = 'FEEDBACK_ONLY',
   DISABLED = 'DISABLED',
 }
@@ -2010,7 +2009,7 @@ export enum SessionTelemetryMode {
 
 依赖:`BatchLogRecordProcessorOptions`(`@opentelemetry/sdk-logs`)· `OTLPExporterNodeConfigBase`(`@opentelemetry/otlp-exporter-base`)
 
-来源:[`packages/session/session-telemetry-otel/src/index.ts:91`](../packages/session/session-telemetry-otel/src/index.ts)
+来源:[`packages/session/session-telemetry-otel/src/index.ts:100`](../packages/session/session-telemetry-otel/src/index.ts)
 
 <a id="deepseek-aidsh-session-title"></a>
 

+ 2 - 2
docs/event-producer-consumer.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/event-producer-consumer.md
-event-producer-consumer.md: 0c9bccc5c6524ef165a1ac661fd2ab936e23731d
-event-producer-consumer.zh.md: 432cb55ecb5e3755ae513baad80aecfeb5695dc2
+event-producer-consumer.md: da072c8d2c12aa768bc1c357e6dea37821116d66
+event-producer-consumer.zh.md: af0a2a63c5dfeb391b2e82d2b6e972d69a9e8864

+ 1 - 0
docs/event-producer-consumer.md

@@ -39,6 +39,7 @@ This matrix shows which packages dispatch each harness-owned event and which pac
 | `credentials/record-updated` | `emit` | [`packages/credentials/credentials/src/types.ts:102`](../packages/credentials/credentials/src/types.ts) | [`credentials`](../packages/credentials/credentials) (`events.dispatch`) | [`authorization`](../packages/credentials/authorization) |
 | `credentials/reference-updated` | `emit` | [`packages/credentials/credentials/src/types.ts:90`](../packages/credentials/credentials/src/types.ts) | [`credentials`](../packages/credentials/credentials) (`events.dispatch`) | [`credentials`](../packages/credentials/credentials), `remotes` |
 | `domain/changed` | `emit` | [`packages/storage/storage-domain/src/events.ts:46`](../packages/storage/storage-domain/src/events.ts) | [`storage-domain`](../packages/storage/storage-domain) (`emit`) | [`storage-domain`](../packages/storage/storage-domain), [`workspace`](../packages/workspace/workspace), `workspace-controller` |
+| `feedback/committed` | `parallel` | [`packages/feedback/message-feedback/src/index.ts:57`](../packages/feedback/message-feedback/src/index.ts) | [`message-feedback`](../packages/feedback/message-feedback) (`parallel`) | [`session-telemetry-otel`](../packages/session/session-telemetry-otel) |
 | `fs/edit-intent` | `waterfall` | [`packages/fs/fs/src/index.ts:66`](../packages/fs/fs/src/index.ts) | [`tool-fs`](../packages/fs/tool-fs) (`waterfall`), [`tool-str-replace-editor`](../packages/fs/tool-str-replace-editor) (`waterfall`) | [`fs-observation-policy`](../packages/fs/fs-observation-policy) |
 | `fs/observed` | `emit` | [`packages/fs/fs/src/index.ts:76`](../packages/fs/fs/src/index.ts) | [`tool-fs`](../packages/fs/tool-fs) (`emit`), [`tool-str-replace-editor`](../packages/fs/tool-str-replace-editor) (`emit`) | [`fs-observation-policy`](../packages/fs/fs-observation-policy), [`skill-filesystem`](../packages/skill/skill-filesystem) |
 | `fs/write-intent` | `waterfall` | [`packages/fs/fs/src/index.ts:58`](../packages/fs/fs/src/index.ts) | [`tool-fs`](../packages/fs/tool-fs) (`waterfall`), [`tool-str-replace-editor`](../packages/fs/tool-str-replace-editor) (`waterfall`) | [`fs-observation-policy`](../packages/fs/fs-observation-policy) |

+ 2 - 1
docs/event-producer-consumer.zh.md

@@ -31,7 +31,7 @@
 | `api-session/status` | `emit` | [`packages/api/session-controller/src/types.ts:579`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` |
 | `approval/request` | `waterfall` | [`packages/interaction/user-approval/src/types.ts:85`](../packages/interaction/user-approval/src/types.ts) | [`user-approval`](../packages/interaction/user-approval) (`waterfall`) | [`acp`](../packages/acp/acp), `remotes` |
 | `authorization/settled` | `emit` | [`packages/credentials/authorization/src/index.ts:57`](../packages/credentials/authorization/src/index.ts) | [`authorization`](../packages/credentials/authorization) (`events.dispatch`) | [`authorization`](../packages/credentials/authorization) |
-| `commands/change` | `emit` | [`packages/interaction/commands/src/types.ts:81`](../packages/interaction/commands/src/types.ts) | [`commands`](../packages/interaction/commands) (`events.dispatch`) | `remotes` |
+| `commands/change` | `emit` | [`packages/interaction/commands/src/types.ts:87`](../packages/interaction/commands/src/types.ts) | [`commands`](../packages/interaction/commands) (`events.dispatch`) | `remotes` |
 | `cordis/dynamic-package` | `emit` | [`packages/extensions/cordis-host-runner/src/types.ts:380`](../packages/extensions/cordis-host-runner/src/types.ts) | [`cordis-host-runner`](../packages/extensions/cordis-host-runner) (`emit`) | `remotes` |
 | `cordis/dynamic-retract` | `emit` | [`packages/extensions/cordis-host-runner/src/types.ts:386`](../packages/extensions/cordis-host-runner/src/types.ts) | [`cordis-host-runner`](../packages/extensions/cordis-host-runner) (`emit`) | `remotes` |
 | `cordis/inspect-query` | `emit` | [`packages/extensions/cordis-host-runner/src/types.ts:392`](../packages/extensions/cordis-host-runner/src/types.ts) | [`cordis-host-runner`](../packages/extensions/cordis-host-runner) (`emit`) | `remotes` |
@@ -41,6 +41,7 @@
 | `credentials/record-updated` | `emit` | [`packages/credentials/credentials/src/types.ts:102`](../packages/credentials/credentials/src/types.ts) | [`credentials`](../packages/credentials/credentials) (`events.dispatch`) | [`authorization`](../packages/credentials/authorization) |
 | `credentials/reference-updated` | `emit` | [`packages/credentials/credentials/src/types.ts:90`](../packages/credentials/credentials/src/types.ts) | [`credentials`](../packages/credentials/credentials) (`events.dispatch`) | [`credentials`](../packages/credentials/credentials), `remotes` |
 | `domain/changed` | `emit` | [`packages/storage/storage-domain/src/events.ts:46`](../packages/storage/storage-domain/src/events.ts) | [`storage-domain`](../packages/storage/storage-domain) (`emit`) | [`storage-domain`](../packages/storage/storage-domain), [`workspace`](../packages/workspace/workspace), `workspace-controller` |
+| `feedback/committed` | `parallel` | [`packages/feedback/message-feedback/src/index.ts:57`](../packages/feedback/message-feedback/src/index.ts) | [`message-feedback`](../packages/feedback/message-feedback) (`parallel`) | [`session-telemetry-otel`](../packages/session/session-telemetry-otel) |
 | `fs/edit-intent` | `waterfall` | [`packages/fs/fs/src/index.ts:66`](../packages/fs/fs/src/index.ts) | [`tool-fs`](../packages/fs/tool-fs) (`waterfall`), [`tool-str-replace-editor`](../packages/fs/tool-str-replace-editor) (`waterfall`) | [`fs-observation-policy`](../packages/fs/fs-observation-policy) |
 | `fs/observed` | `emit` | [`packages/fs/fs/src/index.ts:76`](../packages/fs/fs/src/index.ts) | [`tool-fs`](../packages/fs/tool-fs) (`emit`), [`tool-str-replace-editor`](../packages/fs/tool-str-replace-editor) (`emit`) | [`fs-observation-policy`](../packages/fs/fs-observation-policy), [`skill-filesystem`](../packages/skill/skill-filesystem) |
 | `fs/write-intent` | `waterfall` | [`packages/fs/fs/src/index.ts:58`](../packages/fs/fs/src/index.ts) | [`tool-fs`](../packages/fs/tool-fs) (`waterfall`), [`tool-str-replace-editor`](../packages/fs/tool-str-replace-editor) (`waterfall`) | [`fs-observation-policy`](../packages/fs/fs-observation-policy) |

+ 2 - 2
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: 8128e19e32a0afdf3061c26879cc34d43d736ef5
-module-graph.zh.md: 63a056c2a1b5c41a3d3fc7b3d4ab2813334354a4
+module-graph.md: acf01f4cf7c8bb4c7356332832c92623265904f0
+module-graph.zh.md: 78dfc42cfffb1c1515197a9132fcca70d56b15e6

+ 2 - 1
docs/module-graph.md

@@ -837,6 +837,7 @@ flowchart TD
   pkg_session_telemetry_otel --> pkg_anonymous_user_id
   pkg_session_telemetry_otel --> pkg_command_feedback
   pkg_session_telemetry_otel --> pkg_llm
+  pkg_session_telemetry_otel --> pkg_message_feedback
   pkg_session_telemetry_otel --> pkg_session
   pkg_session_telemetry_otel --> pkg_session_telemetry
   pkg_session_title_all_prompts_llm --> pkg_llm
@@ -1362,7 +1363,7 @@ flowchart TD
 | [`agent-presets`](../packages/preset/agent-presets) | `preset` | [`agent`](../packages/core/agent), [`atomic-write`](../packages/util/atomic-write), [`home-paths`](../packages/util/home-paths), [`invariants`](../packages/runtime-diagnostics/invariants), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection), [`settings`](../packages/settings/settings), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`typert-protocol`](../packages/typert/protocol) |
 | [`schedule`](../packages/schedule/schedule) | `schedule` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`session-projection`](../packages/session/session-projection), [`tools`](../packages/core/tools) |
 | [`session-checkpoint-policy`](../packages/session/session-checkpoint-policy) | `session` | [`agent`](../packages/core/agent), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`tools`](../packages/core/tools) |
-| [`session-telemetry-otel`](../packages/session/session-telemetry-otel) | `session` | [`anonymous-user-id`](../packages/identity/anonymous-user-id), [`command-feedback`](../packages/feedback/command-feedback), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-telemetry`](../packages/session/session-telemetry) |
+| [`session-telemetry-otel`](../packages/session/session-telemetry-otel) | `session` | [`anonymous-user-id`](../packages/identity/anonymous-user-id), [`command-feedback`](../packages/feedback/command-feedback), [`llm`](../packages/llm/llm), [`message-feedback`](../packages/feedback/message-feedback), [`session`](../packages/core/session), [`session-telemetry`](../packages/session/session-telemetry) |
 | [`session-title-all-prompts-llm`](../packages/session/session-title-all-prompts-llm) | `session` | [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-title`](../packages/session/session-title), [`session-title-llm`](../packages/session/session-title-llm) |
 | [`session-title-first-prompt-llm`](../packages/session/session-title-first-prompt-llm) | `session` | [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-title`](../packages/session/session-title), [`session-title-llm`](../packages/session/session-title-llm) |
 | [`shell-env`](../packages/shell/shell-env) | `shell` | [`home-paths`](../packages/util/home-paths), [`shell`](../packages/shell/shell), [`tools`](../packages/core/tools) |

+ 2 - 1
docs/module-graph.zh.md

@@ -839,6 +839,7 @@ flowchart TD
   pkg_session_telemetry_otel --> pkg_anonymous_user_id
   pkg_session_telemetry_otel --> pkg_command_feedback
   pkg_session_telemetry_otel --> pkg_llm
+  pkg_session_telemetry_otel --> pkg_message_feedback
   pkg_session_telemetry_otel --> pkg_session
   pkg_session_telemetry_otel --> pkg_session_telemetry
   pkg_session_title_all_prompts_llm --> pkg_llm
@@ -1364,7 +1365,7 @@ flowchart TD
 | [`agent-presets`](../packages/preset/agent-presets) | `preset` | [`agent`](../packages/core/agent), [`atomic-write`](../packages/util/atomic-write), [`home-paths`](../packages/util/home-paths), [`invariants`](../packages/runtime-diagnostics/invariants), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection), [`settings`](../packages/settings/settings), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`typert-protocol`](../packages/typert/protocol) |
 | [`schedule`](../packages/schedule/schedule) | `schedule` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`session-projection`](../packages/session/session-projection), [`tools`](../packages/core/tools) |
 | [`session-checkpoint-policy`](../packages/session/session-checkpoint-policy) | `session` | [`agent`](../packages/core/agent), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`tools`](../packages/core/tools) |
-| [`session-telemetry-otel`](../packages/session/session-telemetry-otel) | `session` | [`anonymous-user-id`](../packages/identity/anonymous-user-id), [`command-feedback`](../packages/feedback/command-feedback), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-telemetry`](../packages/session/session-telemetry) |
+| [`session-telemetry-otel`](../packages/session/session-telemetry-otel) | `session` | [`anonymous-user-id`](../packages/identity/anonymous-user-id), [`command-feedback`](../packages/feedback/command-feedback), [`llm`](../packages/llm/llm), [`message-feedback`](../packages/feedback/message-feedback), [`session`](../packages/core/session), [`session-telemetry`](../packages/session/session-telemetry) |
 | [`session-title-all-prompts-llm`](../packages/session/session-title-all-prompts-llm) | `session` | [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-title`](../packages/session/session-title), [`session-title-llm`](../packages/session/session-title-llm) |
 | [`session-title-first-prompt-llm`](../packages/session/session-title-first-prompt-llm) | `session` | [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-title`](../packages/session/session-title), [`session-title-llm`](../packages/session/session-title-llm) |
 | [`shell-env`](../packages/shell/shell-env) | `shell` | [`home-paths`](../packages/util/home-paths), [`shell`](../packages/shell/shell), [`tools`](../packages/core/tools) |

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

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write docs/subsystems/feedback.md
-feedback.md: e1f2d05435cb5e0fe09c315d4123c4621e4c7c68
-feedback.zh.md: 9ce5b44995302687a39a655bf019f623e8c8c7e7
+feedback.md: c538fac50b0c75e43f761a3b58b385840b83e4ef
+feedback.zh.md: 4a77f9c0396f3635755c301a80ccaefe398d5ad6

+ 27 - 1
docs/subsystems/feedback.md

@@ -221,7 +221,7 @@ Successful message-feedback mutations await canonical persistence: live operatio
 
 Plugin disposal closes operation admission and drains accepted per-Session queue work.
 
-When explicitly enabled, [`session-log-deepseek`](../../packages/session/session-log-deepseek/README.md) carries feedback as part of the ordinary `dsh_session_log` suffix on subsequent eligible DeepSeek requests. Recording feedback does not trigger a request or a separate `dsh_feedback` upload. The command acknowledgement confirms recording and identifies the Session and anonymous user; it reports neither telemetry policy nor delivery.
+When explicitly enabled, [`session-log-deepseek`](../../packages/session/session-log-deepseek/README.md) carries feedback as part of the ordinary `dsh_session_log` suffix on subsequent eligible DeepSeek requests. Recording feedback does not trigger an LLM request or a separate `dsh_feedback` upload. For non-DeepSeek routes, the [OTel backend](../../packages/session/session-telemetry-otel/README.md) can release the canonical prefix through recorded feedback. The command acknowledgement confirms recording and identifies the Session and anonymous user; it reports neither telemetry policy nor delivery.
 
 ## Web surface
 
@@ -280,5 +280,31 @@ Session-log service; cold operations never construct a Session or Agent.
 @Remote('delete') delete(request: MessageFeedbackDeleteRequest): Promise<MessageFeedbackDeleteResult>
 ```
 
+Source: [`packages/feedback/message-feedback/src/index.ts`](../../packages/feedback/message-feedback/src/index.ts)
+
+<a id="feedback-events"></a>
+
+### `feedback/*` events
+
+<a id="feedbackcommitted--parallel"></a>
+
+#### `feedback/committed` — parallel
+
+Observe a durable cold feedback mutation without publishing a live Session. Observers run before write ownership is released and must not await another message-feedback operation for this Session. The payload is borrowed read-only; deep-clone it before transferring ownership (for example, to Session.fromRestore).
+
+```ts cordis-catalog
+/**
+ * Observe a durable cold feedback mutation without publishing a live Session.
+ * Observers run before write ownership is released and must not await
+ * another message-feedback operation for this Session. The payload is borrowed
+ * read-only; deep-clone it before transferring ownership (for example, to Session.fromRestore).
+ * @param inspection - committed canonical prefix, including the feedback as its last event.
+ * @mode parallel
+ */
+'feedback/committed'(inspection: SessionInspection): void
+```
+
+Types: [SessionInspection](persistence.md)
+
 Source: [`packages/feedback/message-feedback/src/index.ts`](../../packages/feedback/message-feedback/src/index.ts)
 <!-- END GENERATED cordis-surface -->

+ 27 - 1
docs/subsystems/feedback.zh.md

@@ -221,7 +221,7 @@ fork 种子可以包含父 Session 的反馈事件,但 payload 保留父级 `s
 
 插件释放会关闭操作接纳,并排空已进入各 Session 队列的工作。
 
-显式启用后,[`session-log-deepseek`](../../packages/session/session-log-deepseek/README.zh.md) 会在后续符合条件的 DeepSeek 请求中,把反馈作为普通 `dsh_session_log` 后缀的一部分传送。记录反馈不会触发请求,也不会单独上传 `dsh_feedback`。命令确认文本确认记录并标识 Session 与匿名用户,不报告遥测策略或投递结果。
+显式启用后,[`session-log-deepseek`](../../packages/session/session-log-deepseek/README.zh.md) 会在后续符合条件的 DeepSeek 请求中,把反馈作为普通 `dsh_session_log` 后缀的一部分传送。记录反馈不会触发 LLM 请求,也不会单独上传 `dsh_feedback`。对于非 DeepSeek 路由,[OTel 后端](../../packages/session/session-telemetry-otel/README.zh.md)可以将权威日志前缀释放至已记录的反馈。命令确认文本确认记录并标识 Session 与匿名用户,不报告遥测策略或投递结果。
 
 ## Web 界面
 
@@ -280,5 +280,31 @@ Session-log service; cold operations never construct a Session or Agent.
 @Remote('delete') delete(request: MessageFeedbackDeleteRequest): Promise<MessageFeedbackDeleteResult>
 ```
 
+Source: [`packages/feedback/message-feedback/src/index.ts`](../../packages/feedback/message-feedback/src/index.ts)
+
+<a id="feedback-events"></a>
+
+### `feedback/*` events
+
+<a id="feedbackcommitted--parallel"></a>
+
+#### `feedback/committed` — parallel
+
+Observe a durable cold feedback mutation without publishing a live Session. Observers run before write ownership is released and must not await another message-feedback operation for this Session. The payload is borrowed read-only; deep-clone it before transferring ownership (for example, to Session.fromRestore).
+
+```ts cordis-catalog
+/**
+ * Observe a durable cold feedback mutation without publishing a live Session.
+ * Observers run before write ownership is released and must not await
+ * another message-feedback operation for this Session. The payload is borrowed
+ * read-only; deep-clone it before transferring ownership (for example, to Session.fromRestore).
+ * @param inspection - committed canonical prefix, including the feedback as its last event.
+ * @mode parallel
+ */
+'feedback/committed'(inspection: SessionInspection): void
+```
+
+Types: [SessionInspection](persistence.zh.md)
+
 Source: [`packages/feedback/message-feedback/src/index.ts`](../../packages/feedback/message-feedback/src/index.ts)
 <!-- END GENERATED cordis-surface -->

+ 2 - 2
docs/subsystems/session-telemetry.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/session-telemetry.md
-session-telemetry.md: 83224cf03005909f9b3d600a950c2d6402ca3b2a
-session-telemetry.zh.md: a093590819d62839fb69a0e50b2d11f495171ec8
+session-telemetry.md: f452dc6fa9064689664bcaed08205d30def822e6
+session-telemetry.zh.md: 59c817cca945e3f972e30104104f44fb15006aa5

+ 22 - 6
docs/subsystems/session-telemetry.md

@@ -55,22 +55,38 @@ interface SessionTelemetryRecord {
 }
 ```
 
-Every canonical [session event](session.md), including each `assistant/message` or `assistant/attempt` with its complete compact stream and every plugin-merged type the seam never heard of, passes through whole as one ordered ledger record. Process-local `agent/assistant-stream` frames do not enter this durable feed. A new Session object replays its complete log from seq 0, including constructor seed history; re-adopting the same object resumes after its handoff cursor. Delivery is best-effort: the cursor marks handed-off, not delivered, and records can be lost (crash, reload window) or duplicated (new-object replay, SDK retries), so receivers dedupe ledger records on `(session.id, session.format_version, event.seq)`; ops records deliberately omit that identity — they are signals to alert on, not entries to sum, and tolerate duplicates instead.
+Every canonical [session event](session.md), including each `assistant/message` or `assistant/attempt` with its complete compact stream and every plugin-merged type the seam never heard of, passes through whole as one ordered ledger record. Process-local `agent/assistant-stream` frames do not enter this durable feed. A new Session object starts at its lifecycle boundary unless the backend selects `includeHistory`; re-adopting the same object resumes after its handoff cursor. Delivery is best-effort: the cursor marks handed-off, not delivered, and records can be lost (crash, reload window) or duplicated (new-object replay, SDK retries), so receivers dedupe ledger records on `(session.id, session.format_version, event.seq)`; ops records deliberately omit that identity — they are signals to alert on, not entries to sum, and tolerate duplicates instead.
 
 ## The sharing disclosure
 
-The seam's acknowledgement contract (owned by the [Service Definition README's sharing-disclosure section](../../packages/session/session-telemetry/README.md#the-sharing-disclosure)): every backend discloses its deployment-selected sharing policy through the required abstract `sharing` member on `ctx.sessionTelemetry`, and consumers render "not configured" only when no telemetry service is mounted. The disclosure states the current policy, never delivery or retention — handoff is the non-blocking enqueue, and batching, retry, and loss policy stay the reporting SDK's.
+Every backend exposes its deployment-selected mode through the required abstract `sharing` member on `ctx.sessionTelemetry` ([Service Definition README](../../packages/session/session-telemetry/README.md#the-sharing-disclosure)). This is neither a per-Session admission decision nor a delivery receipt. The `/feedback` acknowledgement does not consult it.
 
 ```ts type-equiv
 /**
- * Deployment-selected session-sharing policy disclosed by a mounted
- * {@link SessionTelemetryBackend} backend to human-facing acknowledgement surfaces (the
- * `/feedback` command's confirmation text). The Service Definition owns the
- * vocabulary so consumers and backends do not depend on a specific provider.
+ * Deployment-selected session-sharing mode, not confirmation of SDK delivery.
  */
 type SessionTelemetrySharingStatus = 'full' | 'feedback-only' | 'disabled'
 ```
 
+## Capture policy
+
+```ts type-equiv
+/** Whether capture follows live events or reads the canonical log only when requested. */
+type SessionTelemetryCapture = 'live' | 'on-demand'
+```
+
+```ts type-equiv
+/** Backend-selected capture mode and history policy. */
+interface SessionTelemetryCaptureOptions {
+  /** Follow live events, or wait for explicit capture; defaults to live. */
+  capture?: SessionTelemetryCapture
+  /** Include stored history before this lifecycle; defaults to false. */
+  includeHistory?: boolean
+}
+```
+
+`includeHistory` permits stored and inherited records but does not itself authorize capture. The [OTel backend](../../packages/session/session-telemetry-otel/README.md) uses on-demand capture and requires new own explicit feedback; it releases only the complete prefix through that feedback, for every provider.
+
 ## The backend contract
 
 ```ts type-equiv

+ 22 - 6
docs/subsystems/session-telemetry.zh.md

@@ -55,22 +55,38 @@ interface SessionTelemetryRecord {
 }
 ```
 
-每条权威[会话事件](session.zh.md)都会完整透传为一条有序 ledger 记录,包括每个携带完整紧凑 stream 的 `assistant/message` 或 `assistant/attempt`,以及该 seam 从未听说过、由插件合并进来的类型。进程本地 `agent/assistant-stream` frame 不进入该持久 feed。新 Session 对象会从 seq 0 回放完整日志,包括构造 seed 历史;重新收养同一对象时会从 handoff 游标之后继续。投递是尽力而为的:游标标记的是「已交接」而非「已送达」,记录可能丢失(崩溃、重载窗口)也可能重复(新对象回放、SDK 重试),因此接收端对 ledger 记录基于 `(session.id, session.format_version, event.seq)` 去重;ops 记录刻意省略这类标识——它们是用于告警的信号,而非用于累加的条目,重复被容忍而非被去重。
+每条权威[会话事件](session.zh.md)都会完整透传为一条有序 ledger 记录,包括每个携带完整紧凑 stream 的 `assistant/message` 或 `assistant/attempt`,以及该 seam 从未听说过、由插件合并进来的类型。进程本地 `agent/assistant-stream` frame 不进入该持久 feed。新 Session 对象从其生命周期边界开始,除非后端选择 `includeHistory`;重新收养同一对象时会从 handoff 游标之后继续。投递是尽力而为的:游标标记的是「已交接」而非「已送达」,记录可能丢失(崩溃、重载窗口)也可能重复(新对象回放、SDK 重试),因此接收端对 ledger 记录基于 `(session.id, session.format_version, event.seq)` 去重;ops 记录刻意省略这类标识——它们是用于告警的信号,而非用于累加的条目,重复被容忍而非被去重。
 
 ## 共享披露
 
-该 seam 的确认契约(归属 [Service Definition README 的共享披露段](../../packages/session/session-telemetry/README.zh.md#the-sharing-disclosure)):每个后端都通过 `ctx.sessionTelemetry` 上必需的抽象 `sharing` 成员披露其部署级共享策略,消费方只有在未挂载任何遥测服务时才渲染「未配置」。披露只陈述当前策略,绝不承诺投递或留存——交接是非阻塞入队,批处理、重试与丢失策略仍归上报 SDK
+每个后端都通过 `ctx.sessionTelemetry` 上必需的抽象 `sharing` 成员暴露其部署级模式([Service Definition README](../../packages/session/session-telemetry/README.zh.md#the-sharing-disclosure))。它既不是逐 Session 的接纳决定,也不是投递回执。`/feedback` 确认文本不查询它
 
 ```ts type-equiv
 /**
- * Deployment-selected session-sharing policy disclosed by a mounted
- * {@link SessionTelemetryBackend} backend to human-facing acknowledgement surfaces (the
- * `/feedback` command's confirmation text). The Service Definition owns the
- * vocabulary so consumers and backends do not depend on a specific provider.
+ * Deployment-selected session-sharing mode, not confirmation of SDK delivery.
  */
 type SessionTelemetrySharingStatus = 'full' | 'feedback-only' | 'disabled'
 ```
 
+## 捕获策略
+
+```ts type-equiv
+/** Whether capture follows live events or reads the canonical log only when requested. */
+type SessionTelemetryCapture = 'live' | 'on-demand'
+```
+
+```ts type-equiv
+/** Backend-selected capture mode and history policy. */
+interface SessionTelemetryCaptureOptions {
+  /** Follow live events, or wait for explicit capture; defaults to live. */
+  capture?: SessionTelemetryCapture
+  /** Include stored history before this lifecycle; defaults to false. */
+  includeHistory?: boolean
+}
+```
+
+`includeHistory` 允许捕获存储与继承的记录,但本身不授权捕获。[OTel 后端](../../packages/session/session-telemetry-otel/README.zh.md)使用按需捕获,并要求新的自身显式反馈;它只释放截至该反馈的完整前缀,适用于所有提供方。
+
 ## 后端约定
 
 ```ts type-equiv

+ 2 - 2
packages/bundle/base/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/bundle/base/README.md
-README.md: cfc5bd2b0e0951909f7616bf0011e2ffe1f3ddb8
-README.zh.md: 326ea6b122ec878a99db1cc93fd1dcb344941256
+README.md: 82f4de46682232aa1c45ed0cc76bbd08546f4332
+README.zh.md: fc398d759817f759c65e0294ff6a355e67f9ee0b

+ 1 - 1
packages/bundle/base/README.md

@@ -47,7 +47,7 @@ Run `dsh --profile my-profile "your task"` and you get a working agent with mode
 
 ### What you get
 
-Out of the box, every profile built on this core provides: a DeepSeek model connection (the provider and model are configurable, and you can enable extra providers from your settings), the full tool set — file editing, shell commands, web search, public HTTP(S) fetch, subagents, task and goal tracking — durable sessions that survive restarts, and the default permission policy that confines file writes to your workspace and asks before risky actions. Web fetch runs without per-call approval; its provider rejects non-public destinations. Feedback stays in the Session log. The opt-in [DeepSeek session-log contributor](../../session/session-log-deepseek/README.md) carries it with model requests; the OTel session-upload row is disabled.
+Out of the box, every profile built on this core provides: a DeepSeek model connection (the provider and model are configurable, and you can enable extra providers from your settings), the full tool set — file editing, shell commands, web search, public HTTP(S) fetch, subagents, task and goal tracking — durable sessions that survive restarts, and the default permission policy that confines file writes to your workspace and asks before risky actions. Web fetch runs without per-call approval; its provider rejects non-public destinations. Feedback stays in the Session log. [OTel session upload](../../session/session-telemetry-otel/README.md) defaults to `FEEDBACK_ONLY` for all users, including `deepseek-official`: new text feedback, message ratings, edits, and withdrawals release the complete canonical prefix through that event, including context. Later records wait for the next explicit feedback; sending an authorized batch needs no further interaction or model call. `DISABLED` prevents OTel capture. The opt-in [DeepSeek session-log contributor](../../session/session-log-deepseek/README.md) remains a separate request path.
 
 ### Shell tools per platform
 

+ 1 - 1
packages/bundle/base/README.zh.md

@@ -47,7 +47,7 @@ kind: "package-bundle"
 
 ### 你得到什么
 
-开箱即用,基于本核心构建的每个 profile 都提供:DeepSeek 模型连接(provider 与模型可配置,你还可以在设置中启用额外 provider)、完整工具集——文件编辑、shell 命令、web 搜索、公开 HTTP(S) 抓取、subagent、任务与目标跟踪——可跨重启存活的持久会话,以及默认权限策略:把文件写入限制在工作区内,危险操作前征询许可。Web 抓取无需逐次审批,其提供方会拒绝非公开目的地址。反馈保存在会话日志中。需主动开启的 [DeepSeek 会话日志贡献器](../../session/session-log-deepseek/README.zh.md)随模型请求发送日志;OTel 会话上传行处于禁用状态
+开箱即用,基于本核心构建的每个 profile 都提供:DeepSeek 模型连接(provider 与模型可配置,你还可以在设置中启用额外 provider)、完整工具集——文件编辑、shell 命令、web 搜索、公开 HTTP(S) 抓取、subagent、任务与目标跟踪——可跨重启存活的持久会话,以及默认权限策略:把文件写入限制在工作区内,危险操作前征询许可。Web 抓取无需逐次审批,其提供方会拒绝非公开目的地址。反馈保存在会话日志中。[OTel 会话上传](../../session/session-telemetry-otel/README.zh.md)对所有用户默认使用 `FEEDBACK_ONLY`,包括 `deepseek-official`:新的文本反馈、消息评分、编辑与撤回会释放截至该事件的完整权威日志前缀,包含上下文。后续记录等待下一次显式反馈;发送已授权批次无需进一步交互或模型调用。`DISABLED` 阻止 OTel 捕获。需主动开启的 [DeepSeek 会话日志贡献器](../../session/session-log-deepseek/README.zh.md)仍是独立的请求路径
 
 ### 各平台的 shell 工具
 

+ 2 - 3
packages/bundle/base/cordis.patch.yml

@@ -165,8 +165,8 @@
         writeEveryEvents: 200
         writeIntervalMs: 5000
 
-    # OTel session upload is disabled. Session-log sharing uses the opt-in
-    # session-log-deepseek contributor on DeepSeek requests.
+    # OTel releases a Session-log prefix only after explicit user feedback,
+    # regardless of model provider. Ordinary activity never triggers capture.
     # DSH_TELEMETRY_OTLP_URL overrides the production endpoint. A non-empty
     # DSH_TELEMETRY_DISABLED — any value, including '0'/'false' — opts the
     # process out (the launchers patch the row disabled; config cannot disable
@@ -183,7 +183,6 @@
     # on SIGINT/SIGTERM.
     - id: session-telemetry-otel
       name: '@deepseek-ai/dsh-session-telemetry-otel'
-      disabled: true
       config:
         mode: !!js process.env.DSH_TELEMETRY_MODE || 'FEEDBACK_ONLY'
         shutdownTimeoutMillis: 3000

+ 1 - 1
packages/bundle/base/tests/base.spec.ts

@@ -32,7 +32,7 @@ describe('dsh-base bundle', () => {
     )
     expect(rows.length).toBeGreaterThan(50)
     expect(rows.some(row => row.id === 'agent-loop')).toBe(true)
-    expect(rows.find(row => row.id === 'session-telemetry-otel')?.disabled).toBe(true)
+    expect(rows.find(row => row.id === 'session-telemetry-otel')?.disabled).toBeUndefined()
     expect(rows.find(row => row.id === 'session-telemetry-otel')?.config?.['mode']).toEqual({
       __jsExpr: "process.env.DSH_TELEMETRY_MODE || 'FEEDBACK_ONLY'",
     })

+ 9 - 1
packages/extensions/tool-cordis/src/api-catalog.ts

@@ -1913,7 +1913,7 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [
     methods: [
       {
         signature: 'abstract readonly sharing: SessionTelemetrySharingStatus',
-        description: 'Deployment-selected session-sharing policy, disclosed for acknowledgement surfaces that report whether recorded feedback leaves the process. Every backend must disclose its policy; a consumer renders "not configured" only when no telemetry service is mounted. The seam owns this vocabulary so the disclosure is backend-independent.',
+        description: 'Deployment-selected sharing mode, independent of SDK delivery.',
         parameters: [],
       },
       {
@@ -3193,6 +3193,14 @@ export const EVENT_API: readonly EventApiEntry[] = [
     description: 'A domain record or the global singleton changed, emitted once per write strictly after the backend acknowledged durability. Events of one domain arrive in its write-chain order.',
     parameters: [{ name: 'change', description: 'domain, table (`\'\'` for global), key (`\'\'` for global), operation discriminant, and on `put` the new snapshot.' }],
   },
+  {
+    name: 'feedback/committed',
+    mode: 'parallel',
+    signature: '\'feedback/committed\'(inspection: SessionInspection): void',
+    summary: 'Observe a durable cold feedback mutation without publishing a live Session.',
+    description: 'Observe a durable cold feedback mutation without publishing a live Session. Observers run before write ownership is released and must not await another message-feedback operation for this Session. The payload is borrowed read-only; deep-clone it before transferring ownership (for example, to Session.fromRestore).',
+    parameters: [{ name: 'inspection', description: 'committed canonical prefix, including the feedback as its last event.' }],
+  },
   {
     name: 'fs/edit-intent',
     mode: 'waterfall',

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

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write packages/feedback/message-feedback/README.md
-README.md: 2490655514ec4266100724e0645c29fdf61a3be1
-README.zh.md: 5f6f7ff1e78671426123bf6b61c703fb00a8faf4
+README.md: e1defa8d8fd6149f124267ffa0c382f4a82cf2ab
+README.zh.md: d16f8f4416288e5373e3221e8f43a8b2d84e5f80

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

@@ -55,6 +55,8 @@ Live operations append through `Session.append` and await `sessions.flush`, then
 
 A per-Session queue serializes operations within one service instance; the persistence write handle excludes competing cold writers. Disposal stops admission and drains admitted operations before releasing the service. Persistence failures reject instead of becoming business failures. A failed flush does not roll back an accepted event; callers can list and retry with its version. Successful no-op mutations also flush the current prefix.
 
+Cold material mutations notify `feedback/committed` after flush with a borrowed read-only canonical prefix; observers must deep-clone it before transferring ownership. Observers finish before write ownership is released, must not await another feedback operation for that Session, and cannot reject an already committed mutation. Live consumers observe `session/event`.
+
 ### Source map
 
 | File | Role |
@@ -91,7 +93,7 @@ Independent. Feedback does not change the model request prefix.
 - **Deletion retains history:** delete removes current feedback, not earlier ratings or notes from the append-only log; it is not a privacy-erasure operation.
 - **Writer ownership:** another process holding a Session write handle causes cold mutations to reject. The service does not wake that owner or coordinate Remote calls across processes.
 - **Trusted callers:** requests contain no authenticated actor or audit identity. Deployments must protect the Host gateway.
-- **Telemetry export:** the shipped OTel row is disabled. If enabled in `FULL`, it exports live feedback events; in `FEEDBACK_ONLY`, `/feedback` releases the pending canonical-log prefix, including message-feedback ratings and verbatim notes, not just the command text. Deployments own redaction; see the [OTel export policy](../../session/session-telemetry-otel/README.md).
+- **Telemetry export:** for all users and providers, including `deepseek-official`, the shipped OTel backend in `FEEDBACK_ONLY` releases the complete canonical prefix only after new explicit text feedback, rating/note edits, or withdrawal. The prefix includes context and verbatim notes; later records wait for the next feedback, and `DISABLED` prevents capture. Deployments own redaction; see the [OTel export policy](../../session/session-telemetry-otel/README.md).
 - **Scan cost:** each `list`, `put`, or `delete` that reaches an existing Session scans its full event log to derive current feedback; cold operations also read the full log from persistence. Work grows with total Session history, not just the number of feedback items.
 - **Retention:** `maxNoteBytes` limits one note, not aggregate log size or mutation count.
 

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

@@ -55,6 +55,8 @@ kind: "package-reference"
 
 每个 Session 的队列在同一服务实例内串行化操作;持久化写 handle 排除其他冷写入方。销毁时停止接收操作并排空已接收操作,然后释放服务。持久化故障会 reject,而非变成业务失败。flush 失败不会回滚已接受的事件;调用方可以读取并使用其版本重试。成功的无变化修改也会 flush 当前前缀。
 
+冷会话的实质修改在 flush 后通过 `feedback/committed` 通知借用的只读权威日志前缀;观察方在转移所有权前必须深拷贝。观察方在写入所有权释放前完成,不得等待同一 Session 的其他反馈操作,也不能使已提交的修改失败。活跃会话消费方观察 `session/event`。
+
 ### 源码地图
 
 | 文件 | 职责 |
@@ -91,7 +93,7 @@ kind: "package-reference"
 - **删除保留历史:**delete 移除当前反馈,不会从只追加日志中清除更早的评分或备注;它不是隐私擦除操作。
 - **写入所有权:**另一个进程持有 Session 写 handle 时,冷会话修改会 reject。服务不会唤醒该所有者,也不协调跨进程 Remote 调用。
 - **受信任调用方:**请求不包含经过认证的 actor 或审计身份。部署方必须保护 Host gateway。
-- **遥测导出:**随附的 OTel 配置行处于禁用状态。启用后,`FULL` 会实时导出反馈事件;`FEEDBACK_ONLY` 则在 `/feedback` 时释放尚未导出的权威日志前缀,包括 message-feedback 评分和原样备注,而不只是命令文本。部署方负责脱敏;见 [OTel 导出策略](../../session/session-telemetry-otel/README.zh.md)。
+- **遥测导出:**对于所有用户和提供方,包括 `deepseek-official`,随附 OTel 后端在 `FEEDBACK_ONLY` 模式下仅在新的显式文本反馈、评分或备注编辑、撤回后释放完整权威日志前缀。前缀包含上下文和原样备注;后续记录等待下一次反馈,`DISABLED` 阻止捕获。部署方负责脱敏;见 [OTel 导出策略](../../session/session-telemetry-otel/README.zh.md)。
 - **扫描成本:**每次访问已有 Session 的 `list`、`put` 或 `delete` 都会扫描完整事件日志来推导当前反馈;冷会话操作还会从持久化存储读取完整日志。工作量随 Session 历史总量增长,而不只是反馈条目数。
 - **保留量:**`maxNoteBytes` 只限制单条备注,不限制日志总大小或修改次数。
 

+ 26 - 5
packages/feedback/message-feedback/src/index.ts

@@ -13,7 +13,7 @@ import { SessionSeq } from '@deepseek-ai/dsh-session/types'
 import { deriveEventMessage, isAppendSurfaceEvent } from '@deepseek-ai/dsh-session/surface'
 import type { SessionEvent, SessionId } from '@deepseek-ai/dsh-session/types'
 import type {} from '@deepseek-ai/dsh-session'
-import type {} from '@deepseek-ai/dsh-session-persistence'
+import type { SessionInspection } from '@deepseek-ai/dsh-session-persistence'
 import { TypertRemoteService, Remote } from '@deepseek-ai/dsh-typert-protocol'
 import type {
   MessageFeedbackDeleteRequest,
@@ -45,6 +45,17 @@ declare module '@deepseek-ai/cordis' {
   interface Context {
     messageFeedback: MessageFeedbackService
   }
+  interface Events {
+    /**
+     * Observe a durable cold feedback mutation without publishing a live Session.
+     * Observers run before write ownership is released and must not await
+     * another message-feedback operation for this Session. The payload is borrowed
+     * read-only; deep-clone it before transferring ownership (for example, to Session.fromRestore).
+     * @param inspection - committed canonical prefix, including the feedback as its last event.
+     * @mode parallel
+     */
+    'feedback/committed'(inspection: SessionInspection): void
+  }
 }
 
 const timestamp = z.number().int().nonnegative().max(Number.MAX_SAFE_INTEGER)
@@ -245,11 +256,21 @@ export class MessageFeedbackService extends TypertRemoteService {
     try {
       const events = await handle.read()
       return await operation(events, async (event) => {
-        if (event !== undefined) {
-          const entry: FeedbackEvent = { ...event, seq: SessionSeq(events.length), time: Date.now() }
-          await handle.append([entry])
-        }
+        const entry: FeedbackEvent | undefined = event === undefined ? undefined
+          : { ...event, seq: SessionSeq(events.length), time: Date.now() }
+        if (entry !== undefined) await handle.append([entry])
         await handle.flush()
+        if (entry !== undefined) {
+          try {
+            await this.ctx.parallel('feedback/committed', {
+              meta: handle.header,
+              inheritedEventCount: handle.inheritedEventCount,
+              events: [...events, entry],
+            })
+          } catch (error) {
+            this.ctx.logger.warn('message-feedback: committed feedback observer failed', error)
+          }
+        }
       })
     } finally {
       await handle.close()

+ 2 - 2
packages/session/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/session/README.md
-README.md: 1afce7df69a7c87e0b27488af17c9450016fc906
-README.zh.md: 57a9b06c44339167fc692e735ed8cee0941324a2
+README.md: 3e6c7c511ec9c587ecd9ec173ebdb64e72ff0b0d
+README.zh.md: 88889fac65fa3da7217233b44a12a17d396a24dc

+ 1 - 1
packages/session/README.md

@@ -60,7 +60,7 @@ The group splits into four families: durable storage (persistence seam, backends
 | Package | Role | ctx key |
 |---|---|---|
 | [`session-telemetry/`](session-telemetry/README.md) | Captures session activity and hands records to a configured reporting backend | `ctx.sessionTelemetry` |
-| [`session-telemetry-otel/`](session-telemetry-otel/README.md) | Delivers telemetry through OpenTelemetry logs in `FULL`, `FEEDBACK_ONLY`, or `DISABLED` mode | registers on `ctx.sessionTelemetry` |
+| [`session-telemetry-otel/`](session-telemetry-otel/README.md) | Delivers telemetry through OpenTelemetry logs in `FEEDBACK_ONLY` or `DISABLED` mode | registers on `ctx.sessionTelemetry` |
 
 Only one title provider may register at a time; without one, the title service keeps its deterministic fallback. The subsystem pages below are the backend-neutral references for each family.
 

+ 1 - 1
packages/session/README.zh.md

@@ -60,7 +60,7 @@ session 组让 agent(智能体)的对话在实时 loop 之外持久可复用
 | 包 | 职责 | ctx key |
 |---|---|---|
 | [`session-telemetry/`](session-telemetry/README.zh.md) | 捕获会话活动并把记录交给配置的上报后端 | `ctx.sessionTelemetry` |
-| [`session-telemetry-otel/`](session-telemetry-otel/README.zh.md) | 通过 OpenTelemetry 日志以 `FULL`、`FEEDBACK_ONLY` 或 `DISABLED` 模式投递遥测 | 注册到 `ctx.sessionTelemetry` |
+| [`session-telemetry-otel/`](session-telemetry-otel/README.zh.md) | 通过 OpenTelemetry 日志以 `FEEDBACK_ONLY` 或 `DISABLED` 模式投递遥测 | 注册到 `ctx.sessionTelemetry` |
 
 同一时间只允许一个标题提供方注册;未注册时,标题服务保留其确定性回退。下面的子系统页面是各家族后端无关的参考资料。
 

+ 2 - 2
packages/session/session-telemetry-otel/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/session/session-telemetry-otel/README.md
-README.md: f1ce945a363cff489f9f81d32f6842e2d13c99a0
-README.zh.md: da9838e570fea036dccc5c5b4743721746283874
+README.md: 35dc351bd56fdce396eb4c89c321008eb14796ce
+README.zh.md: a771a06b0e2dab39a2b1e9460a99e8c4c2828fb3

+ 15 - 12
packages/session/session-telemetry-otel/README.md

@@ -9,7 +9,7 @@ English | [中文](README.zh.md)
 
 ## Summary
 
-`dsh-session-telemetry-otel` delivers session records through OpenTelemetry logs and is the only entry a deployment loads for the [session-telemetry seam](../session-telemetry/README.md). Its `mode` decides whether session records follow the live stream, are released only at recorded feedback, or stay local: `FULL` hands every record to the OTel SDK immediately, `FEEDBACK_ONLY` replays the canonical log when a `feedback/record` lands, and `DISABLED` (the default) constructs nothing and shares nothing. Uploading modes compose the OTel JS SDK as-is — `LoggerProvider` → `BatchLogRecordProcessor` → OTLP/HTTP log exporter — and map each record onto `logger.emit()`, so batching, retry, queueing, and loss policy follow the SDK. Records carry the complete event data as the seam's redaction waterfall returns it, so a deployment exporting beyond a trusted boundary mounts its own redaction rules. Modes, configuration, and the export surface come first; the implementation internals live in a collapsible developer section below.
+`dsh-session-telemetry-otel` exports session records through the OTel JS SDK only after new explicit feedback, for all users and providers, including `deepseek-official`. `FEEDBACK_ONLY` releases the canonical prefix through that feedback, including context; later records wait for the next explicit feedback. `DISABLED` constructs no transport. SDK batching can finish an authorized upload without another user interaction or model call. Deployments own their redaction rules.
 
 ## Table of Contents
 
@@ -31,11 +31,10 @@ Mount this plugin when a deployment should export session records through OpenTe
 
 | `mode` | Behavior |
 |---|---|
-| `FULL` | Every captured record, including every canonical event and lifecycle ops record, is handed to the OTel SDK immediately |
-| `FEEDBACK_ONLY` | Each `feedback/record` replays, copies, and redacts every canonical event after the handoff cursor through that event; later records wait for another feedback event and remain local if none arrives |
-| `DISABLED` | Default. No coordinator, provider, processor, or exporter is constructed; no telemetry record leaves the process, and a `feedback/record` logs that nothing will be shared |
+| `FEEDBACK_ONLY` | Default. Text feedback, rating creation/edit, note edit, and withdrawal release the unhanded prefix through that canonical feedback event; later records wait |
+| `DISABLED` | No coordinator, provider, processor, or exporter is constructed; no telemetry record leaves the process. Live feedback warns locally; cold mutations stay silent |
 
-Programmatic TypeScript configuration uses the exported `SessionTelemetryMode` enum; raw string literals are not assignable. The mounted service discloses the resolved mode through the seam's [`SessionTelemetrySharingStatus`](../session-telemetry/README.md#the-sharing-disclosure) `sharing` property (`full` / `feedback-only` / `disabled`), including `disabled` in `DISABLED` mode. This API reports policy independently of the `/feedback` acknowledgement, which confirms recording only.
+Programmatic TypeScript configuration uses the exported `SessionTelemetryMode` enum; raw string literals are not assignable. `FULL` is rejected, not an alias. The [`sharing` property](../session-telemetry/README.md#the-sharing-disclosure) reports `feedback-only` or `disabled`, not a delivery receipt. The `/feedback` acknowledgement confirms recording only.
 
 ### Minimal configuration
 
@@ -45,7 +44,7 @@ Uploading modes require an exporter URL and accept the SDK option blocks verbati
 - id: sessionTelemetry-otel
   name: '@deepseek-ai/dsh-session-telemetry-otel'
   config:
-    mode: FULL                # explicit opt-in; default: DISABLED
+    mode: FEEDBACK_ONLY       # optional; defaults to FEEDBACK_ONLY
     shutdownTimeoutMillis: 3000 # optional; defaults to 3000
     exporter:                # passed verbatim to the SDK's OTLP/HTTP log exporter
       url: https://collector.example.com/v1/logs
@@ -56,12 +55,14 @@ Uploading modes require an exporter URL and accept the SDK option blocks verbati
 
 | Field | Default | Meaning |
 |---|---|---|
-| `mode` | `DISABLED` | Sharing policy: `FULL`, `FEEDBACK_ONLY`, or `DISABLED` |
+| `mode` | `FEEDBACK_ONLY` | Sharing policy: `FEEDBACK_ONLY` or `DISABLED` |
 | `exporter.url` | required in uploading modes | Full OTLP logs endpoint; must parse as `http(s)` |
 | `exporter`, `processor` | — | Passed verbatim to the SDK exporter and batch processor |
 | `shutdownTimeoutMillis` | `3,000` | Outer deadline for the SDK's complete shutdown sequence |
 
-The generated [configuration catalog](../../../docs/config-catalog.md#deepseek-aidsh-session-telemetry-otel) is the exhaustive source for every accepted field. Upload authorization is positive and fail-closed: an unknown direct-construction mode fails before transport configuration is read, only `FULL` accepts direct `ctx.sessionTelemetry.emit()` calls, and `FEEDBACK_ONLY` treats only the exact `feedback/record` object already stored in the canonical log as consent.
+Direct `ctx.sessionTelemetry.emit()` calls are no-ops in every mode and cannot bypass feedback authorization. Inherited parent feedback does not authorize a child export: the child needs new feedback of its own. Its authorized prefix then includes inherited context.
+
+Model requests, request headers, Session creation or adoption, restoration, and plugin mount or HMR do not authorize capture. Stored feedback alone triggers nothing. SDK scheduled flush and shutdown may finish batches authorized earlier, but never capture new records.
 
 ### What leaves the machine
 
@@ -83,7 +84,7 @@ This section explains the backend's composition; the observable behavior is full
 
 ### Design concept
 
-The backend is a thin adapter over the OTel JS SDK: it owns capture mode, resource identity, and an outer shutdown deadline, and passes everything else through verbatim. Two instrumentation scopes separate record channels — ledger records on `@deepseek-ai/dsh-session-telemetry-otel`, operational records on `@deepseek-ai/dsh-session-telemetry-otel/ops` — so receivers can alert on ops without summing them. Resource identity carries `service.name`/`service.version` from `dsh-llm`'s `APP_IDENTITY` plus the package's anonymous `user.id` (from `$DSH_HOME/.anonymous-user-id`), once per export batch rather than per record.
+The backend is a thin adapter over the OTel JS SDK: it owns feedback authorization, resource identity, and an outer shutdown deadline. Canonical ledger records use the `@deepseek-ai/dsh-session-telemetry-otel` instrumentation scope; this backend captures no operational records. Resource identity carries `service.name`/`service.version` from `dsh-llm`'s `APP_IDENTITY` plus the anonymous `user.id` (from `$DSH_HOME/.anonymous-user-id`), once per export batch rather than per record.
 
 ### Source map
 
@@ -93,11 +94,11 @@ The backend is a thin adapter over the OTel JS SDK: it owns capture mode, resour
 
 ### Capture wiring
 
-`FULL` composes the coordinator in `live` mode and lets direct service calls through; `FEEDBACK_ONLY` composes it in `on-demand` mode, gives the coordinator a private backend capability, and triggers `captureSession(session, event.seq)` only when `session.eventAt(event.seq) === event` confirms the exact canonical feedback record; `DISABLED` registers nothing but a warning on `feedback/record`. The backend deliberately implements no `flush()`: the batch processor owns ordinary flushing, and forwarding the hint to `forceFlush()` would create the sole source of concurrent flushes whose interaction with shutdown's drain is undocumented.
+The backend uses on-demand capture with stored history included. Only new own `feedback/record`, `feedback/message-put`, or `feedback/message-delete` events trigger live capture, bounded by that event. A cold `feedback/committed` notification supplies its committed canonical snapshot without publishing a live Session or Agent. Same-object handoff cursors suppress repeated capture. The backend implements no `flush()`; the SDK owns batching and shutdown drain.
 
 ### Field mapping
 
-Each seam record maps onto one SDK log record: `time` and `severity` become the SDK timestamp and severity fields, and `body` and `attributes` carry through verbatim; the exact field mapping lives in [`src/index.ts`](src/index.ts). In `FULL`, receivers can detect crashes by `shutdown`-record absence — the marker is emitted at the session's own disposal or application teardown, and a marker followed by more events is a telemetry reload. In `FEEDBACK_ONLY`, a released prefix normally has no later `shutdown` marker, so its absence is not a crash signal.
+Each telemetry record maps to one SDK log record with captured timestamp, severity, body, and attributes. Feedback authorizes the complete unhanded prefix, not only the feedback payload.
 
 </details>
 
@@ -133,7 +134,9 @@ These limits define where SDK behavior governs and where export guarantees end.
 
 - **Upstream experimental tree** — `@opentelemetry/sdk-logs` is published from the upstream experimental tree; SDK API churn lands here and only here, while the seam contract does not move.
 - **Live-collector behavior belongs to the SDK exporter** — authentication, TLS, throttling, and other real OTLP deployment behavior follow the upstream SDK rather than a package-owned compatibility layer.
-- **Feedback-time snapshot** — `FEEDBACK_ONLY` retains no telemetry-owned copy before feedback; it reads and redacts the current canonical log when feedback is recorded, so a crash before feedback uploads nothing and policy changes before feedback affect what that replay exports.
+- **Best-effort handoff** — new cold snapshots and a new feedback submission after restart can repeat prefixes; receivers deduplicate by Session id, format version, and event seq. There is no durable outbox, delivery watermark, automatic retry promise, or collector-acceptance guarantee. OTel and the opt-in DeepSeek API path can overlap. Withdrawal exports a deletion event, not remote erasure.
+
+- **Backend availability** — feedback submitted while this plugin is disabled or unloaded is recorded locally but not automatically replayed when it returns. Capture requires the subscriber to remain mounted until it observes the submission; unloading during a pending cold write can miss its post-flush notification.
 
 <a id="dev-note"></a>
 ### Dev Note

+ 15 - 12
packages/session/session-telemetry-otel/README.zh.md

@@ -9,7 +9,7 @@ kind: "package-reference"
 
 ## 概述
 
-`dsh-session-telemetry-otel` 通过 OpenTelemetry 日志投递会话记录,是[会话遥测 seam](../session-telemetry/README.zh.md) 的后端,也是部署方唯一要加载的条目。其 `mode` 决定会话记录是跟随实时流、仅在记录反馈时释放,还是留在本地:`FULL` 把每条记录立即交给 OTel SDK,`FEEDBACK_ONLY` 在 `feedback/record` 落地时回放权威日志,`DISABLED`(默认值)不构造任何内容也不共享任何内容。上传模式会原样组合 OTel JS SDK——`LoggerProvider` → `BatchLogRecordProcessor` → OTLP/HTTP 日志导出器——并把每条记录映射到 `logger.emit()`,因此批处理、重试、排队与丢失策略都遵循 SDK。记录携带 seam 脱敏 waterfall(瀑布式事件)返回的完整事件数据,因此向可信边界之外导出的部署方要挂载自己的脱敏规则。模式、配置与导出面在前;实现内部细节放在下方可折叠的开发者章节中
+`dsh-session-telemetry-otel` 仅在新的显式反馈后通过 OTel JS SDK 导出会话记录,适用于所有用户和提供方,包括 `deepseek-official`。`FEEDBACK_ONLY` 释放截至该反馈的权威日志前缀,包含上下文;后续记录等待下一次显式反馈。`DISABLED` 不构造传输。SDK 批处理可完成已授权的上传,无需另一次用户交互或模型调用。部署方负责脱敏规则
 
 ## 目录
 
@@ -31,11 +31,10 @@ kind: "package-reference"
 
 | `mode` | 行为 |
 |---|---|
-| `FULL` | 每条已捕获记录都立即交给 OTel SDK,包括每条权威事件与生命周期运维记录 |
-| `FEEDBACK_ONLY` | 每个 `feedback/record` 都会回放、复制并脱敏 handoff 游标之后直至该事件的每条权威事件;后续记录等待下一个反馈事件;如果没有后续反馈,则留在本地 |
-| `DISABLED` | 默认值。不构造协调器、提供方、处理器或导出器;没有遥测记录会离开进程,`feedback/record` 会记录「不会共享任何内容」 |
+| `FEEDBACK_ONLY` | 默认值。文本反馈、评分创建或修改、备注修改和撤回释放尚未交接的前缀,截止该权威反馈事件;后续记录等待 |
+| `DISABLED` | 不构造协调器、提供方、处理器或导出器;没有遥测记录离开进程。活跃会话反馈在本地告警;冷会话修改保持静默 |
 
-程序化 TypeScript 配置使用导出的 `SessionTelemetryMode` 枚举;原始字符串字面量不可赋值。已挂载服务通过 seam 的 [`SessionTelemetrySharingStatus`](../session-telemetry/README.zh.md#the-sharing-disclosure) `sharing` 属性披露解析后的模式(`full` / `feedback-only` / `disabled`),包括在 `DISABLED` 模式下披露 `disabled`。此 API 独立报告策略;`/feedback` 确认文本只确认记录。
+程序化 TypeScript 配置使用导出的 `SessionTelemetryMode` 枚举;原始字符串字面量不可赋值。`FULL` 会被拒绝,不是别名。[`sharing` 属性](../session-telemetry/README.zh.md#the-sharing-disclosure)报告 `feedback-only` 或 `disabled`,不代表投递回执。`/feedback` 确认文本只确认记录。
 
 ### 最小配置
 
@@ -45,7 +44,7 @@ kind: "package-reference"
 - id: sessionTelemetry-otel
   name: '@deepseek-ai/dsh-session-telemetry-otel'
   config:
-    mode: FULL                # explicit opt-in; default: DISABLED
+    mode: FEEDBACK_ONLY       # optional; defaults to FEEDBACK_ONLY
     shutdownTimeoutMillis: 3000 # optional; defaults to 3000
     exporter:                # passed verbatim to the SDK's OTLP/HTTP log exporter
       url: https://collector.example.com/v1/logs
@@ -56,12 +55,14 @@ kind: "package-reference"
 
 | 字段 | 默认值 | 含义 |
 |---|---|---|
-| `mode` | `DISABLED` | 共享策略:`FULL`、`FEEDBACK_ONLY` 或 `DISABLED` |
+| `mode` | `FEEDBACK_ONLY` | 共享策略:`FEEDBACK_ONLY` 或 `DISABLED` |
 | `exporter.url` | 上传模式必填 | 完整 OTLP 日志端点;必须能解析为 `http(s)` |
 | `exporter`、`processor` | — | 原样传给 SDK 导出器与批处理器 |
 | `shutdownTimeoutMillis` | `3,000` | SDK 完整关闭序列的外层截止时间 |
 
-生成的[配置目录](../../../docs/config-catalog.zh.md#deepseek-aidsh-session-telemetry-otel)是每个受支持字段的穷尽式真源。上传授权采用显式许可,且为 fail-closed:通过直接构造传入未知模式时会在读取传输配置前失败,只有 `FULL` 接受对 `ctx.sessionTelemetry.emit()` 的直接调用,`FEEDBACK_ONLY` 只把权威日志中已存储的精确 `feedback/record` 对象视为同意。
+直接调用 `ctx.sessionTelemetry.emit()` 在任何模式下都是空操作,不能绕过反馈授权。继承的父会话反馈不授权子会话导出:子会话需要新的自身反馈。授权后的前缀包含继承的上下文。
+
+模型请求、请求头、Session 创建或接纳、恢复,以及插件挂载或 HMR(热模块替换) 均不授权捕获。仅有存储的反馈不会触发任何上传。SDK 定时刷新和关闭可以完成先前已授权的批次,但绝不捕获新记录。
 
 ### 哪些数据会离开本机
 
@@ -83,7 +84,7 @@ kind: "package-reference"
 
 ### 设计理念
 
-后端是对 OTel JS SDK 的薄适配层:它拥有捕获模式、资源身份与一个外层关闭截止时间,其余全部原样透传。两个插桩作用域区分记录通道——ledger 记录挂在 `@deepseek-ai/dsh-session-telemetry-otel` 下,运维记录挂在 `@deepseek-ai/dsh-session-telemetry-otel/ops` 下——使接收端可以在不累加它们的情况下对运维记录告警。资源身份携带 `service.name`/`service.version`(来自 `dsh-llm` 的 `APP_IDENTITY`)以及本包的匿名 `user.id`(来自 `$DSH_HOME/.anonymous-user-id`),按导出批次携带一次,而非逐条记录。
+后端是对 OTel JS SDK 的薄适配层:它拥有反馈授权、资源身份与外层关闭截止时间。权威 ledger 记录使用 `@deepseek-ai/dsh-session-telemetry-otel` 插桩作用域;此后端不捕获运维记录。资源身份携带 `service.name`/`service.version`(来自 `dsh-llm` 的 `APP_IDENTITY`)以及匿名 `user.id`(来自 `$DSH_HOME/.anonymous-user-id`),按导出批次携带一次,而非逐条记录。
 
 ### 源码地图
 
@@ -93,11 +94,11 @@ kind: "package-reference"
 
 ### 捕获接线
 
-`FULL` 以 `live` 模式组装协调器,并放行直接服务调用;`FEEDBACK_ONLY` 以 `on-demand` 模式组装协调器,给协调器一个私有后端能力,并且仅在 `session.eventAt(event.seq) === event` 确认精确的权威反馈记录时触发 `captureSession(session, event.seq)`;`DISABLED` 除了在 `feedback/record` 上发出警告外不注册任何内容。后端刻意不实现 `flush()`:常规 flush 由批处理器负责,把提示转发给 `forceFlush()` 会成为并发 flush 的唯一来源,而它与关闭排空的交互没有文档
+后端使用包含存储历史的按需捕获。只有新的自身 `feedback/record`、`feedback/message-put` 或 `feedback/message-delete` 事件触发活跃会话捕获,并以该事件为上限。冷会话 `feedback/committed` 通知提供已提交的权威快照,不发布存活 Session 或 Agent。同对象交接游标抑制重复捕获。后端不实现 `flush()`;SDK 负责批处理和关闭排空
 
 ### 字段映射
 
-每条 seam 记录映射为一条 SDK 日志记录:`time` 与 `severity` 变为 SDK 的时间戳与严重级别字段,`body` 与 `attributes` 原样照搬;确切字段映射见 [`src/index.ts`](src/index.ts)。在 `FULL` 中,接收端可通过缺少 `shutdown` 记录检测崩溃——该标记在会话自身 dispose(资源释放)或应用关闭时发出,标记之后出现更多事件说明遥测发生了重载。在 `FEEDBACK_ONLY` 中,已释放的前缀通常不包含随后的 `shutdown` 标记,因此缺少该标记不是崩溃信号
+每条遥测记录映射为一条 SDK 日志记录,携带捕获的时间戳、严重级别、正文和属性。反馈授权的是尚未交接的完整前缀,而非只有反馈载荷
 
 </details>
 
@@ -133,7 +134,9 @@ kind: "package-reference"
 
 - **上游实验性源码树**——`@opentelemetry/sdk-logs` 从上游实验性源码树发布;SDK API 的变动只会落在本包,也仅落在本包,而 seam 约定不动。
 - **真实 collector 行为属于 SDK 导出器**——身份验证、TLS、限流及其他真实 OTLP 部署行为遵循上游 SDK,不由本包自有兼容层处理。
-- **反馈时快照**——`FEEDBACK_ONLY` 在反馈前不保留遥测自有副本;记录反馈时读取并脱敏当前的权威日志,因此反馈前崩溃时什么都不上传,反馈前的策略变更会影响该次回放的导出内容。
+- **尽力交接**——新冷快照以及重启后的新反馈提交可能重复前缀;接收方按 Session id、格式版本和事件 seq 去重。没有持久化 outbox、投递水位、自动重试承诺或采集端接受保证。OTel 与需显式启用的 DeepSeek API 路径可能重叠。撤回导出删除事件,不是远端擦除。
+
+- **后端可用性**——本插件禁用或卸载期间提交的反馈会记录在本地,但恢复插件不会自动重放。捕获要求订阅方保持挂载直到观察到提交;在冷写入尚未完成时卸载,可能错过其 flush 后通知。
 
 <a id="dev-note"></a>
 ### 开发备注

+ 2 - 0
packages/session/session-telemetry-otel/package.json

@@ -37,6 +37,7 @@
   },
   "peerDependencies": {
     "@deepseek-ai/dsh-command-feedback": "workspace:^",
+    "@deepseek-ai/dsh-message-feedback": "workspace:^",
     "@deepseek-ai/dsh-llm": "workspace:^",
     "@deepseek-ai/dsh-session": "workspace:^",
     "@deepseek-ai/dsh-session-telemetry": "workspace:^",
@@ -49,6 +50,7 @@
     "@deepseek-ai/dsh-app-boot": "workspace:^",
     "@deepseek-ai/dsh-bash-local": "workspace:^",
     "@deepseek-ai/dsh-command-feedback": "workspace:^",
+    "@deepseek-ai/dsh-message-feedback": "workspace:^",
     "@deepseek-ai/dsh-llm": "workspace:^",
     "@deepseek-ai/dsh-loader-smoke": "workspace:^",
     "@deepseek-ai/dsh-session": "workspace:^",

+ 44 - 35
packages/session/session-telemetry-otel/src/index.ts

@@ -16,6 +16,8 @@ import { createRequire } from 'node:module'
 import z from '@deepseek-ai/schemastery'
 import type { Context } from '@deepseek-ai/cordis'
 import type {} from '@deepseek-ai/dsh-command-feedback'
+import type {} from '@deepseek-ai/dsh-message-feedback'
+import { Session, type SessionEvent } from '@deepseek-ai/dsh-session'
 import {
   SessionTelemetryBackend,
   SessionTelemetryCoordinator,
@@ -33,7 +35,7 @@ import {
 } from '@opentelemetry/sdk-logs'
 import { OTLPLogExporter } from '@opentelemetry/exporter-logs-otlp-http'
 import type { OTLPExporterNodeConfigBase } from '@opentelemetry/otlp-exporter-base'
-import { SeverityNumber, type AnyValue, type Logger } from '@opentelemetry/api-logs'
+import { SeverityNumber, type AnyValue } from '@opentelemetry/api-logs'
 import { resourceFromAttributes } from '@opentelemetry/resources'
 
 // The package's own manifest is the single source of the instrumentation-scope
@@ -42,23 +44,31 @@ const { version } = createRequire(import.meta.url)('../package.json') as { versi
 
 /** Session-sharing policy selected by {@link Config.mode}. */
 export enum SessionTelemetryMode {
-  FULL = 'FULL',
   FEEDBACK_ONLY = 'FEEDBACK_ONLY',
   DISABLED = 'DISABLED',
 }
 
 /** Default session-sharing policy for schema and direct construction. */
-export const DEFAULT_TELEMETRY_MODE = SessionTelemetryMode.DISABLED
+export const DEFAULT_TELEMETRY_MODE = SessionTelemetryMode.FEEDBACK_ONLY
 
-const DISABLED_FEEDBACK_WARNING = 'session telemetry is DISABLED; nothing will be shared and this feedback remains local'
-const NON_CANONICAL_FEEDBACK_WARNING = 'session telemetry ignored a feedback event absent from the canonical session log'
-const DROP_RECORD: SessionTelemetrySink['emit'] = () => {}
+const DISABLED_FEEDBACK_WARNING = 'OpenTelemetry session upload is DISABLED; this feedback is not uploaded through OpenTelemetry'
+const NON_CANONICAL_EVENT_WARNING = 'session telemetry ignored an event absent from the canonical session log'
+
+/** Only this Session's explicit feedback authorizes replay; fork seeds do not. */
+function isFeedback(session: Session, event: SessionEvent): boolean {
+  if (event.seq < session.inheritedEventCount) return false
+  switch (event.type) {
+    case 'feedback/record': return true
+    case 'feedback/message-put':
+    case 'feedback/message-delete': return event.data.sessionId === session.id
+    default: return false
+  }
+}
 
 /** Resolve the default and reject unknown runtime values before transport setup. */
 function resolveMode(mode: SessionTelemetryMode | undefined): SessionTelemetryMode {
   const resolved = mode ?? DEFAULT_TELEMETRY_MODE
   switch (resolved) {
-    case SessionTelemetryMode.FULL:
     case SessionTelemetryMode.FEEDBACK_ONLY:
     case SessionTelemetryMode.DISABLED:
       return resolved
@@ -75,7 +85,6 @@ function assertNever(value: never): never {
 /** Map the serialized mode onto the seam's backend-independent sharing vocabulary. */
 function sharingStatusFor(mode: SessionTelemetryMode): SessionTelemetrySharingStatus {
   switch (mode) {
-    case SessionTelemetryMode.FULL: return 'full'
     case SessionTelemetryMode.FEEDBACK_ONLY: return 'feedback-only'
     case SessionTelemetryMode.DISABLED: return 'disabled'
     /* v8 ignore next 2 -- resolveMode already rejected unknown values before this switch; the closed enum cannot reach the default. */
@@ -89,7 +98,7 @@ function sharingStatusFor(mode: SessionTelemetryMode): SessionTelemetrySharingSt
  * and shutdown deadline at plugin load; `DISABLED` reads neither.
  */
 export interface Config {
-  /** Sharing policy; defaults to local-only `DISABLED` behavior. */
+  /** Defaults to `FEEDBACK_ONLY`: capture session history only when feedback is explicitly submitted. */
   mode?: SessionTelemetryMode
   /**
    * Passed verbatim to the SDK's OTLP/HTTP log exporter — the complete
@@ -140,15 +149,14 @@ const SEVERITY: Record<SessionTelemetrySeverity, { severityNumber: SeverityNumbe
 
 /**
  * The backend plugin — the only entry a deployment loads. It always registers
- * the `telemetry` service (duplicate load throws). Uploading modes wire the SDK
- * pipeline and compose {@link SessionTelemetryCoordinator}; `DISABLED` constructs no
+ * the `sessionTelemetry` service (duplicate load throws). `FEEDBACK_ONLY` wires the SDK
+ * pipeline and on-demand {@link SessionTelemetryCoordinator}; `DISABLED` constructs no
  * SDK state and listens only to warn when recorded feedback stays local.
  */
 export class OpenTelemetrySessionBackend extends SessionTelemetryBackend {
   static inject = ['sessions']
   static Config = Config
 
-  private readonly directEmit: SessionTelemetrySink['emit']
   private readonly provider: LoggerProvider | undefined
   private readonly shutdownTimeoutMillis: number
   override readonly sharing: SessionTelemetrySharingStatus
@@ -158,11 +166,10 @@ export class OpenTelemetrySessionBackend extends SessionTelemetryBackend {
     super(ctx)
     this.sharing = sharingStatusFor(mode)
     if (mode === SessionTelemetryMode.DISABLED) {
-      this.directEmit = DROP_RECORD
       this.provider = undefined
       this.shutdownTimeoutMillis = DEFAULT_SHUTDOWN_TIMEOUT_MILLIS
-      ctx.on('session/event', (_session, event) => {
-        if (event.type === 'feedback/record') ctx.logger.warn(DISABLED_FEEDBACK_WARNING)
+      ctx.on('session/event', (session, event) => {
+        if (isFeedback(session, event)) ctx.logger.warn(DISABLED_FEEDBACK_WARNING)
       })
       return
     }
@@ -217,10 +224,8 @@ export class OpenTelemetrySessionBackend extends SessionTelemetryBackend {
       ],
     })
     const ledger = this.provider.getLogger('@deepseek-ai/dsh-session-telemetry-otel', version)
-    const ops = this.provider.getLogger('@deepseek-ai/dsh-session-telemetry-otel/ops', version)
     const enqueue: SessionTelemetrySink['emit'] = (record) => {
-      const logger: Logger = record.channel === 'ops' ? ops : ledger
-      logger.emit({
+      ledger.emit({
         timestamp: record.time,
         observedTimestamp: record.time,
         ...SEVERITY[record.severity],
@@ -234,33 +239,37 @@ export class OpenTelemetrySessionBackend extends SessionTelemetryBackend {
       emit: enqueue,
       shutdown: () => this.shutdown(),
     }
-    if (mode === SessionTelemetryMode.FULL) {
-      this.directEmit = enqueue
-      new SessionTelemetryCoordinator(ctx, backend, 'live')
-      return
-    }
-    this.directEmit = DROP_RECORD
-    const coordinator = new SessionTelemetryCoordinator(ctx, backend, 'on-demand')
+    const coordinator = new SessionTelemetryCoordinator(ctx, backend, {
+      capture: 'on-demand',
+      includeHistory: true,
+    })
     ctx.on('session/event', (session, event) => {
-      if (event.type !== 'feedback/record') return
-      // Consent is the committed record, not an independently emitted bus value.
+      if (!isFeedback(session, event)) return
+      // Only the canonical appended event authorizes this exact prefix.
       if (session.eventAt(event.seq) !== event) {
-        ctx.logger.warn(NON_CANONICAL_FEEDBACK_WARNING)
+        ctx.logger.warn(NON_CANONICAL_EVENT_WARNING)
         return
       }
       coordinator.captureSession(session, event.seq)
     })
+    ctx.on('feedback/committed', (inspection) => {
+      const snapshot = structuredClone(inspection)
+      const committed = snapshot.events.at(-1)
+      if (committed === undefined) return
+      const session = Session.fromRestore(
+        snapshot.meta.id, snapshot.events, snapshot.meta, snapshot.inheritedEventCount,
+      )
+      // fromRestore appends a lifecycle marker that this submission did not commit.
+      if (isFeedback(session, committed)) coordinator.captureSession(session, committed.seq)
+    })
   }
 
   /**
-   * Hand a direct service record to the SDK only in `FULL`. Direct calls are
-   * no-ops in `FEEDBACK_ONLY` and `DISABLED`; feedback replay uses a private
-   * backend capability created only for the canonical feedback listener.
-   * @param record - the logical record offered directly to the service.
+   * Drop direct records. Only a new canonical feedback submission can authorize
+   * capture through the private coordinator sink, for every provider.
+   * @param _record - the direct record, never uploaded.
    */
-  emit(record: SessionTelemetryRecord): void {
-    this.directEmit(record)
-  }
+  emit(_record: SessionTelemetryRecord): void {}
 
   // The Service Definition's optional flush() hint is deliberately NOT implemented. The
   // batch processor exports on its own cadence (`processor.scheduledDelayMillis`,

+ 105 - 55
packages/session/session-telemetry-otel/tests/egress.spec.ts

@@ -1,43 +1,70 @@
 import { createServer, type Server } from 'node:http'
-import type { AddressInfo } from 'node:net'
+import { once } from 'node:events'
 import { mkdtempSync, rmSync } from 'node:fs'
 import { tmpdir } from 'node:os'
 import { join } from 'node:path'
 import { afterAll, beforeAll, describe, expect, it } from 'vitest'
 import { installProxyFromEnvironment } from '@deepseek-ai/dsh-http-proxy'
+import { recordFeedback } from '@deepseek-ai/dsh-command-feedback'
 import { Context } from '@deepseek-ai/cordis'
 import SessionStore, { SessionId } from '@deepseek-ai/dsh-session'
 import OpenTelemetrySessionBackend, { SessionTelemetryMode } from '../src/index.ts'
 
-let seen: string[] = []
-let proxy: Server
+const seen: string[] = []
+const captures: string[] = []
+const servers: Server[] = []
 let proxyUrl: string
+let collectorUrl: string
+let home: string
+let previousHome: string | undefined
+
+async function listen(server: Server): Promise<string> {
+  servers.push(server)
+  server.listen(0, '127.0.0.1')
+  await once(server, 'listening')
+  const address = server.address()
+  if (address === null || typeof address === 'string') throw new Error('fixture server has no port')
+  return `http://127.0.0.1:${address.port}`
+}
 
 beforeAll(async () => {
-  proxy = createServer((request, response) => {
-    seen.push(`REQ ${request.url ?? ''}`)
-    response.writeHead(502); response.end('fake-proxy')
+  home = mkdtempSync(join(tmpdir(), 'dsh-otel-egress-'))
+  previousHome = process.env.DSH_HOME
+  process.env.DSH_HOME = home
+  const proxy = createServer((request, response) => {
+    seen.push(request.url ?? '')
+    response.writeHead(502).end('fake-proxy')
   })
   proxy.on('connect', (request, socket) => {
-    seen.push(`CONNECT ${request.url ?? ''}`)
-    socket.write('HTTP/1.1 502 Bad Gateway\r\n\r\n'); socket.end()
+    seen.push(request.url ?? '')
+    socket.end('HTTP/1.1 502 Bad Gateway\r\n\r\n')
   })
-  const a = await new Promise<AddressInfo>((r) => { proxy.listen(0, '127.0.0.1', () => { r(proxy.address() as AddressInfo) }) })
-  proxyUrl = `http://127.0.0.1:${String(a.port)}`
+  proxyUrl = await listen(proxy)
+  const loopbackUrl = await listen(createServer((request, response) => {
+    const chunks: Buffer[] = []
+    request.on('data', chunk => chunks.push(chunk as Buffer))
+    request.on('end', () => {
+      captures.push(Buffer.concat(chunks).toString())
+      response.writeHead(200, { 'content-type': 'application/json' }).end('{}')
+    })
+  }))
+  collectorUrl = loopbackUrl.replace('127.0.0.1', 'otel-direct.invalid')
 })
-afterAll(async () => { await new Promise<void>((r) => { proxy.close(() => { r() }) }) })
 
-let home: string
-let previousHome: string | undefined
-beforeAll(() => {
-  home = mkdtempSync(join(tmpdir(), 'dsh-otel-egress-'))
-  previousHome = process.env.DSH_HOME
-  process.env.DSH_HOME = home
-})
-afterAll(() => {
-  if (previousHome === undefined) delete process.env.DSH_HOME
-  else process.env.DSH_HOME = previousHome
-  rmSync(home, { recursive: true, force: true })
+afterAll(async () => {
+  try {
+    await Promise.all(servers.map(server => new Promise<void>((resolve, reject) => {
+      server.close((error) => {
+        if (error === undefined) resolve()
+        else reject(error)
+      })
+      server.closeAllConnections()
+    })))
+  } finally {
+    if (previousHome === undefined) delete process.env.DSH_HOME
+    else process.env.DSH_HOME = previousHome
+    rmSync(home, { recursive: true, force: true })
+  }
 })
 
 /** The launch environment of a user who exported one proxy for both schemes. */
@@ -45,38 +72,61 @@ function proxyEnv(): { get(name: string): { value: string } | undefined } {
   return { get: name => (name === 'HTTP_PROXY' || name === 'HTTPS_PROXY' ? { value: proxyUrl } : undefined) }
 }
 
-/** Mount the shipping backend against an unresolvable collector and let it try to export. */
-async function exportThroughBackend(host: string): Promise<void> {
-  const ctx = new Context()
-  await ctx.plugin(SessionStore)
-  const fiber = await ctx.plugin(OpenTelemetrySessionBackend, {
-    mode: SessionTelemetryMode.FULL,
-    exporter: { url: `http://${host}/v1/logs` },
-  })
-  const session = ctx.sessions.create(SessionId('egress'), { meta: { cwd: '/tmp/e' } })
-  session.append('turn/start', { turn: 1 })
-  ctx.sessionTelemetry.emit({ channel: 'ledger', time: Date.now(), severity: 'info', event: { type: 'probe' } } as never)
-  await fiber.dispose()
-}
-
 describe('session-telemetry-otel egress', () => {
-  it('exports directly, ignoring a configured proxy', async () => {
-    seen = []
-    const dispose = await installProxyFromEnvironment(proxyEnv(), () => undefined)
-    try {
-      await exportThroughBackend('otel-direct.invalid').catch(() => undefined)
-    } finally {
-      await dispose()
-    }
-    // Telemetry is the one outbound path this repository deliberately leaves direct. The SDK's OTLP
-    // exporter posts through `node:http`, which no global dispatcher reaches, and routing it would
-    // mean either an `http.Agent` whose `proxyEnv` option arrives after this project's lowest
-    // supported Node, or replacing the transport and reimplementing the compression the shipped
-    // profile enables. Neither is worth it for a channel whose loss costs the user nothing.
-    //
-    // This case exists so that stays a decision: an SDK upgrade that moved the exporter onto
-    // `fetch` would start routing telemetry through a proxy silently, and this assertion is what
-    // makes that visible instead.
-    expect(seen).toEqual([])
-  })
+  it.each(['mock', 'deepseek-official', 'unknown-provider', undefined])(
+    'exports only explicit feedback directly for provider %s, ignoring the configured proxy',
+    async (provider) => {
+      seen.length = 0
+      captures.length = 0
+      const disposeProxy = await installProxyFromEnvironment(proxyEnv(), () => undefined)
+      const ctx = new Context()
+      try {
+        // The positive control proves a fetch-based exporter would reach the proxy.
+        const response = await fetch(collectorUrl)
+        await response.text()
+        expect(response.status).toBe(502)
+        expect(seen.length).toBeGreaterThan(0)
+        seen.length = 0
+
+        await ctx.plugin(SessionStore)
+        const fiber = await ctx.plugin(OpenTelemetrySessionBackend, {
+          mode: SessionTelemetryMode.FEEDBACK_ONLY,
+          exporter: {
+            url: `${collectorUrl}/v1/logs`,
+            timeoutMillis: 1_000,
+            // Resolve only the SDK's collector connection, not the global fetch dispatcher.
+            httpAgentOptions: {
+              lookup: (_host, options, callback) => {
+                if (options.all) callback(null, [{ address: '127.0.0.1', family: 4 }])
+                else callback(null, '127.0.0.1', 4)
+              },
+            },
+          },
+          processor: { scheduledDelayMillis: 60_000 },
+        })
+        const session = ctx.sessions.create(SessionId('egress'), { meta: { cwd: home } })
+        if (provider !== undefined) {
+          session.append('request/header', { header: { config: { provider, model: 'm' } }, reason: 'initial' })
+        }
+        session.append('turn/start', { turn: 1 })
+        recordFeedback(session, 'explicit egress feedback')
+        session.append('turn/end', { turn: 1, reason: { kind: 'completed' } })
+        await fiber.dispose()
+
+        expect(captures).toHaveLength(1)
+        const wire = captures.join('')
+        expect(wire).toContain('explicit egress feedback')
+        expect(wire).toContain('turn/start')
+        expect(wire).not.toContain('turn/end')
+        expect(wire).not.toContain('telemetry.op')
+        expect(seen).toEqual([])
+      } finally {
+        try {
+          await ctx.fiber.dispose()
+        } finally {
+          await disposeProxy()
+        }
+      }
+    },
+  )
 })

+ 24 - 20
packages/session/session-telemetry-otel/tests/fixtures/driver.ts

@@ -1,8 +1,8 @@
 #!/usr/bin/env node
 /**
  * Test driver: start a mock OTLP/HTTP collector, boot the telemetry Loader
- * composition against it, run one turn whose prompt carries a fixture
- * credential, then persist everything the collector captured to
+ * composition against it, explicitly share feedback on a credential-bearing
+ * turn, then persist everything the collector captured to
  * `./otlp-captures.json` for the e2e's inspect step.
  */
 
@@ -35,29 +35,33 @@ const address = server.address()
 if (address === null || typeof address === 'string') throw new Error('collector has no port')
 process.env.DSH_TELEMETRY_E2E_URL = `http://127.0.0.1:${address.port}/v1/logs`
 process.env.DSH_TELEMETRY_OTLP_URL = process.env.DSH_TELEMETRY_E2E_URL
-process.env.DSH_TELEMETRY_MODE = process.env.DSH_TELEMETRY_E2E_MODE ?? 'FULL'
+process.env.DSH_TELEMETRY_MODE = process.env.DSH_TELEMETRY_E2E_MODE ?? 'FEEDBACK_ONLY'
 
-const ctx = await bootProductionProfile({
-  binName: 'telemetry-otel-e2e',
-  profile: 'headless',
-  overlayPaths: [resolveConfigPath(configPath, undefined)],
-})
 try {
-  // The fixture credential rides the model-visible user message; the exported
-  // copy must scrub it while the canonical log keeps the original bytes.
-  await runFixtureTurn(ctx, { task: 'prove telemetry with key sk-e2efixture1234567890' })
-  const mode = process.env.DSH_TELEMETRY_E2E_MODE ?? 'FULL'
-  if (mode !== 'FULL') {
+  const ctx = await bootProductionProfile({
+    binName: 'telemetry-otel-e2e',
+    profile: 'headless',
+    overlayPaths: [resolveConfigPath(configPath, undefined)],
+  })
+  try {
+    await runFixtureTurn(ctx, { task: 'prove telemetry with key sk-e2efixture1234567890' })
     const [agent] = ctx.get('agents')?.roots() ?? []
     if (agent === undefined) throw new Error('session-telemetry-otel driver requires one root agent')
-    recordFeedback(agent.session, 'fixture feedback')
-    if (mode === 'FEEDBACK_ONLY') {
-      await runFixtureTurn(ctx, { task: 'post-feedback private suffix' })
+    if (process.env.DSH_TELEMETRY_E2E_FEEDBACK !== 'none') {
+      recordFeedback(agent.session, 'fixture feedback')
     }
+    await runFixtureTurn(ctx, { task: 'post-feedback private suffix' })
+    ctx.emit('agent/error', { agent, turn: 2, step: 1, error: new Error('private operational error') })
+  } finally {
+    await ctx.fiber.dispose()
   }
+  await writeFile('./otlp-captures.json', JSON.stringify(captures))
 } finally {
-  await ctx.fiber.dispose()
+  await new Promise<void>((resolve, reject) => {
+    server.close((error) => {
+      if (error === undefined) resolve()
+      else reject(error)
+    })
+    server.closeAllConnections()
+  })
 }
-await writeFile('./otlp-captures.json', JSON.stringify(captures))
-server.close()
-server.closeAllConnections()

+ 40 - 24
packages/session/session-telemetry-otel/tests/loader-composition.e2e.ts

@@ -1,10 +1,7 @@
 /**
- * REAL-composition tier: boot the examples-owned telemetry Loader fixture as
- * a subprocess (per testing policy, through the same app/boot path a
- * deployment uses), run one mocked-model turn with a real bash round trip,
- * and assert against what the mock OTLP collector actually received on the
- * wire: ledger mirroring, the deployment-mounted redact rule applied to the
- * exported copy, ops markers, and the untouched canonical log.
+ * REAL-composition tier: explicit feedback through the shipped headless
+ * Loader profile with a mock model and real shell. The collector observes
+ * only the redacted authorized prefix; the canonical log keeps every event.
  */
 
 import { readFile, readdir } from 'node:fs/promises'
@@ -76,7 +73,21 @@ function eventTypes(captures: OtlpCapture[]): string[] {
 }
 
 describe('session-telemetry-otel through the production headless profile', () => {
-  it('exports redacted ledger records to the collector while the canonical log keeps the secret', async () => {
+  it('rejects FULL before any session can be uploaded', async () => {
+    const { stdout, stderr } = await runLoaderSmoke({
+      label: 'session-telemetry-otel rejected FULL loader smoke',
+      tempDirPrefix: 'telemetry-otel-full-e2e-',
+      binScript: driver,
+      libBinScript: driver,
+      configPath,
+      tsconfigPath: repoTsconfig,
+      env: { DSH_TELEMETRY_E2E_MODE: 'FULL' },
+      expectedExitCode: 1,
+    })
+    expect(stdout + stderr).toContain('FULL')
+  }, LOADER_SMOKE_TEST_TIMEOUT_MS)
+
+  it('exports the redacted feedback-authorized prefix while the canonical log keeps the secret', async () => {
     let output!: FixtureOutput
     const { stderr } = await runLoaderSmoke({
       label: 'session-telemetry-otel loader smoke',
@@ -96,41 +107,46 @@ describe('session-telemetry-otel through the production headless profile', () =>
     for (const expected of ['turn/start', 'user/message', 'tool/call', 'tool/result', 'assistant/message', 'turn/end']) {
       expect(types, expected).toContain(expected)
     }
-    expect(records.some(({ scope }) => scope.endsWith('/ops'))).toBe(true)
+    const canonicalEvents = output.logContent.trim().split('\n')
+      .map(line => JSON.parse(line) as { type: string; seq?: number })
+      .filter(event => event.seq !== undefined)
+    const feedbackIndex = canonicalEvents.findIndex(event => event.type === 'feedback/record')
+    expect(feedbackIndex).toBeGreaterThanOrEqual(0)
+    expect(types).toEqual(canonicalEvents.slice(0, feedbackIndex + 1).map(event => event.type))
+    expect(types.at(-1)).toBe('feedback/record')
+    expect(records.some(({ scope }) => scope.endsWith('/ops'))).toBe(false)
 
-    // The deployment-mounted rule on the wire: the fixture credential never
-    // leaves the process, its surrounding prose does, and the placeholder
-    // marks the spot — the seam itself ships no rules.
     const wire = JSON.stringify(output.captures)
     expect(wire).not.toContain(FIXTURE_SECRET)
     expect(wire).toContain(FIXTURE_PLACEHOLDER)
     expect(wire).toContain('prove telemetry with key')
+    expect(wire).toContain('fixture feedback')
+    expect(wire).not.toContain('post-feedback private suffix')
+    expect(wire).not.toContain('private operational error')
+    expect(wire).not.toContain('telemetry.op')
 
-    // The canonical session log is never rewritten.
+    expect(output.logContent).toContain('post-feedback private suffix')
     expect(output.logContent).toContain(FIXTURE_SECRET)
     expect(output.logContent).not.toContain(FIXTURE_PLACEHOLDER)
   }, LOADER_SMOKE_TEST_TIMEOUT_MS)
 
-  it('exports only prefixes ending in feedback under feedback-only mode', async () => {
+  it('never captures ordinary turns or shutdown without new feedback', async () => {
     let output!: FixtureOutput
     const { stderr } = await runLoaderSmoke({
-      label: 'session-telemetry-otel feedback-only loader smoke',
-      tempDirPrefix: 'telemetry-otel-feedback-e2e-',
+      label: 'session-telemetry-otel no-feedback loader smoke',
+      tempDirPrefix: 'telemetry-otel-no-feedback-e2e-',
       binScript: driver,
       libBinScript: driver,
       configPath,
       tsconfigPath: repoTsconfig,
-      env: { DSH_TELEMETRY_E2E_MODE: 'FEEDBACK_ONLY' },
+      env: { DSH_TELEMETRY_E2E_FEEDBACK: 'none' },
       inspect: async (cwd) => { output = await readFixtureOutput(cwd) },
     })
     expect(stderr).not.toContain('UNHANDLED')
-
-    const wire = JSON.stringify(output.captures)
-    expect(eventTypes(output.captures)).toContain('feedback/record')
-    expect(wire).toContain('fixture feedback')
-    expect(wire).toContain('prove telemetry with key')
-    expect(wire).not.toContain('post-feedback private suffix')
+    expect(output.captures).toEqual([])
+    expect(output.logContent).toContain('prove telemetry with key')
     expect(output.logContent).toContain('post-feedback private suffix')
+    expect(output.logContent).not.toContain('feedback/record')
   }, LOADER_SMOKE_TEST_TIMEOUT_MS)
 
   it('keeps disabled feedback local and prints the stable warning', async () => {
@@ -148,7 +164,7 @@ describe('session-telemetry-otel through the production headless profile', () =>
 
     expect(output.captures).toEqual([])
     expect(output.logContent).toContain('fixture feedback')
-    expect(stdout.match(/session telemetry is DISABLED; nothing will be shared and this feedback remains local/)?.[0])
-      .toMatchInlineSnapshot('"session telemetry is DISABLED; nothing will be shared and this feedback remains local"')
+    expect(stdout.match(/OpenTelemetry session upload is DISABLED; this feedback is not uploaded through OpenTelemetry/)?.[0])
+      .toMatchInlineSnapshot('"OpenTelemetry session upload is DISABLED; this feedback is not uploaded through OpenTelemetry"')
   }, LOADER_SMOKE_TEST_TIMEOUT_MS)
 })

+ 306 - 66
packages/session/session-telemetry-otel/tests/otel.spec.ts

@@ -17,7 +17,9 @@ import { getOrCreateAnonymousUserId } from '@deepseek-ai/dsh-anonymous-user-id'
 import Loader from '@deepseek-ai/cordis-plugin-loader'
 import { recordFeedback } from '@deepseek-ai/dsh-command-feedback'
 import { createAssistantMessage } from '@deepseek-ai/dsh-llm'
-import SessionStore, { SESSION_FORMAT_VERSION, SessionId, SessionSeq } from '@deepseek-ai/dsh-session'
+import SessionStore, { SESSION_FORMAT_VERSION, Session, SessionId, SessionLogOffset, SessionSeq } from '@deepseek-ai/dsh-session'
+import MessageFeedbackService from '@deepseek-ai/dsh-message-feedback'
+import JsonlPersistence from '@deepseek-ai/dsh-session-persistence-jsonl'
 import OpenTelemetrySessionBackend, { Config, DEFAULT_TELEMETRY_MODE, SessionTelemetryMode } from '../src/index.ts'
 
 interface Capture {
@@ -61,8 +63,10 @@ afterAll(() => {
 
 afterEach(async () => {
   for (const server of servers.splice(0)) {
+    const closed = once(server, 'close')
     server.close()
     server.closeAllConnections()
+    await closed
   }
 })
 
@@ -100,7 +104,7 @@ async function boot(url: string) {
   const ctx = new Context()
   await ctx.plugin(SessionStore)
   const fiber = await ctx.plugin(OpenTelemetrySessionBackend, {
-    mode: SessionTelemetryMode.FULL,
+    mode: SessionTelemetryMode.FEEDBACK_ONLY,
     exporter: { url, headers: { authorization: 'Bearer test-token' } },
   })
   return { ctx, fiber }
@@ -120,10 +124,11 @@ function eventTypes(captures: Capture[]): string[] {
 }
 
 describe('OpenTelemetrySessionBackend wire', () => {
-  it('ships session records and the ops shutdown marker through the real SDK pipeline', async () => {
+  it('ships only the submitted prefix through the real SDK pipeline', async () => {
     const { url, captures } = await mockCollector()
     const { ctx, fiber } = await boot(url)
     const session = ctx.sessions.create(SessionId('wire'), { meta: { cwd: '/tmp/w' } })
+    session.append('request/header', { header: { config: { provider: 'mock', model: 'mock' } }, reason: 'initial' })
     session.append('turn/start', { turn: 1 })
     session.append('assistant/message', {
       turn: 1,
@@ -151,6 +156,7 @@ describe('OpenTelemetrySessionBackend wire', () => {
       attributes: { 'session.id': 'wire', 'event.type': 'manual', 'event.seq': 99 },
       body: { direct: true },
     })
+    recordFeedback(session, 'explicit report')
     await fiber.dispose()
 
     expect(captures.length).toBeGreaterThan(0)
@@ -169,7 +175,7 @@ describe('OpenTelemetrySessionBackend wire', () => {
     const start = ledger.find(r => r.record.attributes?.some(a => a.key === 'event.type' && a.value.stringValue === 'turn/start'))
     expect(start).toBeDefined()
     expect(start?.record.severityNumber).toBe(9)
-    expect(BigInt(start!.record.timeUnixNano)).toBe(BigInt(session.snapshotEvents()[0]!.time) * 1_000_000n)
+    expect(BigInt(start!.record.timeUnixNano)).toBe(BigInt(session.snapshotEvents().find(event => event.type === 'turn/start')!.time) * 1_000_000n)
     expect(start?.record.attributes).toContainEqual({
       key: 'session.format_version',
       value: { intValue: SESSION_FORMAT_VERSION },
@@ -233,20 +239,13 @@ describe('OpenTelemetrySessionBackend wire', () => {
         ],
       },
     })
-    expect(eventTypes(captures)).toContain('manual')
-
-    expect(ops).toHaveLength(1)
-    expect(ops[0]!.record.attributes).toContainEqual({ key: 'telemetry.op', value: { stringValue: 'shutdown' } })
+    expect(eventTypes(captures)).not.toContain('manual')
+    expect(eventTypes(captures)).toEqual(session.snapshotEvents().map(event => event.type))
+    expect(ops).toHaveLength(0)
   })
 
   it('drains records enqueued after a timer export began: dispose during an in-flight batch', async () => {
-    // The backend implements NO flush() — the batch processor exports on its
-    // own cadence, and shutdown's internal drain is complete exactly because
-    // nothing in the process calls forceFlush() concurrently (the SDK's
-    // concurrent-flush guard skips draining otherwise). Pin that: hold the
-    // collector's response to the timer-triggered export open across
-    // disposal, and the dispose-time shutdown marker (enqueued after that
-    // batch's snapshot) must still arrive.
+    // Hold the first authorized batch while a second submission queues its suffix.
     const gate = Promise.withResolvers<boolean>()
     const arrived = Promise.withResolvers<boolean>()
     const { url, captures } = await mockCollector(async (index) => {
@@ -258,23 +257,27 @@ describe('OpenTelemetrySessionBackend wire', () => {
     const ctx = new Context()
     await ctx.plugin(SessionStore)
     const fiber = await ctx.plugin(OpenTelemetrySessionBackend, {
-      mode: SessionTelemetryMode.FULL,
+      mode: SessionTelemetryMode.FEEDBACK_ONLY,
       exporter: { url },
       processor: { scheduledDelayMillis: 10 },
     })
     const session = ctx.sessions.create(SessionId('drain'), { meta: {} })
+    session.append('request/header', { header: { config: { provider: 'mock', model: 'mock' } }, reason: 'initial' })
     session.append('turn/start', { turn: 1 })
+    recordFeedback(session, 'first report')
     await arrived.promise
 
+    recordFeedback(session, 'second report')
+    session.append('turn/start', { turn: 2 })
+    const shutdown = vi.spyOn(ctx.sessionTelemetry, 'shutdown')
     const disposal = fiber.dispose()
-    // Let disposal reach the backend's shutdown while the export is held open.
-    await new Promise(resolve => setTimeout(resolve, 50))
+    await expect.poll(() => shutdown.mock.calls.length).toBe(1)
     gate.resolve(true)
     await disposal
 
-    const ops = allRecords(captures).filter(r => r.scope === '@deepseek-ai/dsh-session-telemetry-otel/ops')
-    expect(ops).toHaveLength(1)
-    expect(ops[0]!.record.attributes).toContainEqual({ key: 'telemetry.op', value: { stringValue: 'shutdown' } })
+    expect(eventTypes(captures)).toEqual(['request/header', 'turn/start', 'feedback/record', 'feedback/record'])
+    expect(JSON.stringify(captures)).toContain('second report')
+    expect(allRecords(captures).some(r => r.scope.endsWith('/ops'))).toBe(false)
   })
 
   it('bounds the SDK forceFlush wait when an in-flight transport never settles', async () => {
@@ -289,15 +292,18 @@ describe('OpenTelemetrySessionBackend wire', () => {
     const ctx = new Context()
     await ctx.plugin(SessionStore)
     const fiber = await ctx.plugin(OpenTelemetrySessionBackend, {
-      mode: SessionTelemetryMode.FULL,
+      mode: SessionTelemetryMode.FEEDBACK_ONLY,
       exporter: { url, timeoutMillis: 60_000 },
       processor: { scheduledDelayMillis: 10, exportTimeoutMillis: 60_000 },
       shutdownTimeoutMillis: 50,
     })
     const session = ctx.sessions.create(SessionId('bounded-shutdown'), { meta: {} })
+    session.append('request/header', { header: { config: { provider: 'mock', model: 'mock' } }, reason: 'initial' })
     session.append('turn/start', { turn: 1 })
+    recordFeedback(session, 'first report')
     await arrived.promise
 
+    recordFeedback(session, 'second report')
     const started = performance.now()
     await fiber.dispose()
     expect(performance.now() - started).toBeLessThan(1_000)
@@ -318,11 +324,13 @@ describe('OpenTelemetrySessionBackend wire', () => {
     // verbatim passthrough must hand it (and every other field) to the
     // exporter rather than silently rebuilding url/headers only.
     const fiber = await ctx.plugin(OpenTelemetrySessionBackend, {
-      mode: SessionTelemetryMode.FULL,
+      mode: SessionTelemetryMode.FEEDBACK_ONLY,
       exporter: { url, compression: 'gzip' },
     } as Config)
     const session = ctx.sessions.create(SessionId('gzip'), { meta: {} })
+    session.append('request/header', { header: { config: { provider: 'mock', model: 'mock' } }, reason: 'initial' })
     session.append('turn/start', { turn: 1 })
+    recordFeedback(session, 'compressed report')
     await fiber.dispose()
 
     expect(captures.length).toBeGreaterThan(0)
@@ -337,10 +345,12 @@ describe('OpenTelemetrySessionBackend wire', () => {
     const { ctx, fiber } = await boot(url)
     ctx.on('session-telemetry/record', (_record, next) => ({ ...next(), severity: 'warn' }))
     const session = ctx.sessions.create(SessionId('warn'), { meta: {} })
+    session.append('request/header', { header: { config: { provider: 'mock', model: 'mock' } }, reason: 'initial' })
     session.append('turn/start', { turn: 1 })
     // No flush(): the coordinator's optional-call forwarding no-ops, and the
     // batch processor owns export cadence end to end (see the backend note).
     expect('flush' in ctx.sessionTelemetry && ctx.sessionTelemetry.flush !== undefined).toBe(false)
+    recordFeedback(session, 'warning feedback')
     await fiber.dispose()
     const start = allRecords(captures).find(r =>
       r.record.attributes?.some(a => a.key === 'event.type' && a.value.stringValue === 'turn/start'))
@@ -366,6 +376,7 @@ describe('OpenTelemetrySessionBackend wire', () => {
       return next()
     })
     const session = ctx.sessions.create(SessionId('feedback-only'), { meta: {} })
+    session.append('request/header', { header: { config: { provider: 'mock', model: 'mock' } }, reason: 'initial' })
     session.append('turn/start', { turn: 1 })
     recordFeedback(session, 'first report')
     session.append('turn/end', { turn: 1, reason: { kind: 'completed' } })
@@ -376,7 +387,7 @@ describe('OpenTelemetrySessionBackend wire', () => {
     const types = allRecords(captures).flatMap(({ record }) =>
       record.attributes?.flatMap(attribute =>
         attribute.key === 'event.type' ? [attribute.value.stringValue] : []) ?? [])
-    expect(types).toEqual(['turn/start', 'feedback/record', 'turn/end', 'feedback/record'])
+    expect(types).toEqual(['request/header', 'turn/start', 'feedback/record', 'turn/end', 'feedback/record'])
     expect(JSON.stringify(captures)).toContain('first report')
     expect(JSON.stringify(captures)).toContain('second report')
     expect(allRecords(captures).some(({ scope }) => scope.endsWith('/ops'))).toBe(false)
@@ -392,6 +403,7 @@ describe('OpenTelemetrySessionBackend wire', () => {
       exporter: { url },
     })
     const session = ctx.sessions.create(SessionId('no-feedback'), { meta: {} })
+    session.append('request/header', { header: { config: { provider: 'mock', model: 'mock' } }, reason: 'initial' })
     session.append('turn/start', { turn: 1 })
     ctx.sessionTelemetry.emit({
       channel: 'ledger',
@@ -400,16 +412,12 @@ describe('OpenTelemetrySessionBackend wire', () => {
       attributes: { 'session.id': 'no-feedback', 'event.type': 'direct', 'event.seq': 99 },
       body: { mustStayLocal: true },
     })
-    ctx.emit('session/event', session, {
-      type: 'feedback/record',
-      seq: SessionSeq(session.seq),
-      time: Date.now(),
-      data: { text: 'not committed' },
-    })
+    const envelope = { seq: SessionSeq(session.seq), time: Date.now() }
+    ctx.emit('session/event', session, { ...envelope, type: 'feedback/record', data: { text: 'not committed' } })
     await fiber.dispose()
 
     expect(warn).toHaveBeenCalledWith(
-      'session telemetry ignored a feedback event absent from the canonical session log',
+      'session telemetry ignored an event absent from the canonical session log',
     )
     expect(captures).toEqual([])
   })
@@ -425,11 +433,12 @@ describe('OpenTelemetrySessionBackend wire', () => {
       processor: { maxExportBatchSize: 0 },
     })
     const session = ctx.sessions.create(SessionId('disabled'), { meta: {} })
+    session.append('request/header', { header: { config: { provider: 'mock', model: 'mock' } }, reason: 'initial' })
     session.append('turn/start', { turn: 1 })
     recordFeedback(session, 'local report')
 
     expect(warn).toHaveBeenCalledWith(
-      'session telemetry is DISABLED; nothing will be shared and this feedback remains local',
+      'OpenTelemetry session upload is DISABLED; this feedback is not uploaded through OpenTelemetry',
     )
     ctx.sessionTelemetry.emit({
       channel: 'ledger',
@@ -448,12 +457,6 @@ describe('OpenTelemetrySessionBackend wire', () => {
   it('discloses the sharing policy for every mode', async () => {
     const { url, captures } = await mockCollector()
 
-    const fullCtx = new Context()
-    await fullCtx.plugin(SessionStore)
-    const full = await fullCtx.plugin(OpenTelemetrySessionBackend, { mode: SessionTelemetryMode.FULL, exporter: { url } })
-    expect(fullCtx.sessionTelemetry.sharing).toBe('full')
-    await full.dispose()
-
     const gatedCtx = new Context()
     await gatedCtx.plugin(SessionStore)
     const gated = await gatedCtx.plugin(OpenTelemetrySessionBackend, { mode: SessionTelemetryMode.FEEDBACK_ONLY, exporter: { url } })
@@ -466,35 +469,269 @@ describe('OpenTelemetrySessionBackend wire', () => {
     expect(disabledCtx.sessionTelemetry.sharing).toBe('disabled')
     await disabled.dispose()
 
-    // An omitted mode is DISABLED, so the default also shares nothing.
     const defaultCtx = new Context()
     await defaultCtx.plugin(SessionStore)
-    const defaulted = await defaultCtx.plugin(OpenTelemetrySessionBackend, {})
-    expect(defaultCtx.sessionTelemetry.sharing).toBe('disabled')
+    const defaulted = await defaultCtx.plugin(OpenTelemetrySessionBackend, { exporter: { url } })
+    expect(defaultCtx.sessionTelemetry.sharing).toBe('feedback-only')
     await defaulted.dispose()
 
     // No record was emitted by any mode, so nothing reached the collector.
     expect(captures).toEqual([])
   })
 
-  it('defaults direct construction to disabled delivery', async () => {
+  it('defaults direct construction to feedback-only delivery', async () => {
+    const { url, captures } = await mockCollector()
+    const ctx = new Context()
+    try {
+      await ctx.plugin(SessionStore)
+      new OpenTelemetrySessionBackend(ctx, {
+        exporter: { url },
+        processor: { scheduledDelayMillis: 1 },
+      })
+      const session = ctx.sessions.create(SessionId('direct-default'), { meta: {} })
+      session.append('request/header', { header: { config: { provider: 'deepseek-official', model: 'mock' } }, reason: 'initial' })
+      session.append('turn/start', { turn: 1 })
+      expect(captures).toEqual([])
+      recordFeedback(session, 'explicit report')
+      const submitted = session.snapshotEvents().map(event => event.type)
+      await expect.poll(() => eventTypes(captures)).toEqual(submitted)
+      session.append('turn/end', { turn: 1, reason: { kind: 'completed' } })
+      await ctx.sessionTelemetry.shutdown()
+      expect(eventTypes(captures)).toEqual(submitted)
+    } finally {
+      await ctx.fiber.dispose()
+    }
+  })
+})
+
+describe('OpenTelemetrySessionBackend route and feedback', () => {
+  it.each(['deepseek-official', 'mock', undefined])('uploads new text feedback for %s while the host stays alive', async (provider) => {
+    const { url, captures } = await mockCollector()
+    const ctx = new Context()
+    try {
+      await ctx.plugin(SessionStore)
+      await ctx.plugin(OpenTelemetrySessionBackend, {
+        mode: SessionTelemetryMode.FEEDBACK_ONLY, exporter: { url }, processor: { scheduledDelayMillis: 1 },
+      })
+      const session = ctx.sessions.create(SessionId('text-feedback'))
+      if (provider !== undefined) session.append('request/header', { header: { config: { provider, model: 'm' } }, reason: 'initial' })
+      session.append('turn/start', { turn: 1 })
+      expect(captures).toEqual([])
+      recordFeedback(session, 'explicit report')
+      const expected = session.snapshotEvents().map(event => event.type)
+      await expect.poll(() => eventTypes(captures)).toEqual(expected)
+      session.append('turn/end', { turn: 1, reason: { kind: 'completed' } })
+      session.append('request/header', { header: { config: { provider: 'deepseek-official', model: 'm' } }, reason: 'change' })
+      await ctx.sessionTelemetry.shutdown()
+      expect(eventTypes(captures)).toEqual(expected)
+    } finally {
+      await ctx.fiber.dispose()
+    }
+  })
+
+  it('does not upload ordinary events, inherited feedback, new/open/resume/fork or HMR', async () => {
     const { url, captures } = await mockCollector()
     const ctx = new Context()
     await ctx.plugin(SessionStore)
-    const warn = vi.spyOn(ctx.logger, 'warn').mockImplementation(() => {})
-    new OpenTelemetrySessionBackend(ctx, {
-      exporter: { url },
-      processor: { maxExportBatchSize: 0 },
+    const donor = Session.create(SessionId('stored-feedback'))
+    recordFeedback(donor, 'old feedback is not a submission')
+    const restored = ctx.sessions.create(donor.id, { seed: donor.snapshotEvents(), meta: donor.header })
+    try {
+      const first = await ctx.plugin(OpenTelemetrySessionBackend, { mode: SessionTelemetryMode.FEEDBACK_ONLY, exporter: { url } })
+      const session = ctx.sessions.create(SessionId('ordinary'))
+      session.append('turn/start', { turn: 1 })
+      for (const provider of ['mock', 'deepseek-official']) {
+        session.append('model/selection', { provider, model: 'm' })
+        session.append('request/header', { header: { config: { provider, model: 'm' } }, reason: 'change' })
+      }
+      const child = ctx.sessions.fork(restored, undefined, SessionId('child'))
+      const inherited = child.snapshotEvents().find(event => event.type === 'feedback/record')!
+      ctx.emit('session/event', child, inherited)
+      const opened = ctx.sessions.create(SessionId('opened'), { seed: donor.snapshotEvents() })
+      ctx.emit('session/created', opened)
+      await first.dispose()
+      await ctx.plugin(OpenTelemetrySessionBackend, { mode: SessionTelemetryMode.FEEDBACK_ONLY, exporter: { url } })
+    } finally {
+      await ctx.fiber.dispose()
+    }
+    expect(captures).toEqual([])
+  })
+
+  it.each(['deepseek-official', 'mock', undefined])('uploads live ratings, notes and withdrawal for %s without further interaction', async (provider) => {
+    const { url, captures } = await mockCollector()
+    const root = mkdtempSync(join(tmpdir(), 'dsh-otel-live-'))
+    const ctx = new Context()
+    try {
+      await ctx.plugin(SessionStore)
+      await ctx.plugin(JsonlPersistence, { root, compression: 'none' })
+      await ctx.plugin(MessageFeedbackService, { maxNoteBytes: 1024 })
+      await ctx.plugin(OpenTelemetrySessionBackend, {
+        mode: SessionTelemetryMode.FEEDBACK_ONLY, exporter: { url }, processor: { scheduledDelayMillis: 1 },
+      })
+      const session = ctx.sessions.create(SessionId('live-ratings'))
+      const handle = await ctx.sessionPersistence.create(session.header)
+      try {
+        if (provider !== undefined) session.append('request/header', { header: { config: { provider, model: 'm' } }, reason: 'initial' })
+        const message = createAssistantMessage({ content: [{ type: 'text', text: 'answer' }], source: { provider: provider ?? 'mock', model: 'm' } })
+        session.append('assistant/message', { message, stream: [], turn: 1, step: 1 }, { surfaceOp: 'append' })
+        const request = { sessionId: session.id, messageId: message.id, rating: 'positive' as const, ifVersion: null }
+        const before = session.seq
+        expect((await ctx.messageFeedback.put({ ...request, note: ' ' })).ok).toBe(false)
+        expect(session.seq).toBe(before)
+        expect(captures).toEqual([])
+        const put = await ctx.messageFeedback.put(request)
+        if (!put.ok) throw new Error('live put failed')
+        await expect.poll(() => eventTypes(captures)).toEqual(session.snapshotEvents().map(event => event.type))
+        const throughPut = session.seq
+        await ctx.messageFeedback.put({ ...request, ifVersion: put.value.version })
+        expect((await ctx.messageFeedback.put(request)).ok).toBe(false)
+        expect(session.seq).toBe(throughPut)
+        const edit = await ctx.messageFeedback.put({ ...request, ifVersion: put.value.version, note: 'edited note' })
+        if (!edit.ok) throw new Error('live note failed')
+        await expect.poll(() => eventTypes(captures)).toEqual(session.snapshotEvents().map(event => event.type))
+        await ctx.messageFeedback.delete({ sessionId: session.id, messageId: message.id, ifVersion: edit.value.version })
+        await expect.poll(() => eventTypes(captures)).toEqual(session.snapshotEvents().map(event => event.type))
+        const submitted = eventTypes(captures)
+        const throughDelete = session.seq
+        await ctx.messageFeedback.delete({ sessionId: session.id, messageId: message.id, ifVersion: edit.value.version })
+        expect(session.seq).toBe(throughDelete)
+        session.append('turn/start', { turn: 2 })
+        await ctx.sessionTelemetry.shutdown()
+        expect(eventTypes(captures)).toEqual(submitted)
+      } finally {
+        await handle.close()
+      }
+    } finally {
+      await ctx.fiber.dispose()
+      rmSync(root, { recursive: true, force: true })
+    }
+  })
+
+  it('ignores cold snapshots without a last own feedback event and foreign live ratings', async () => {
+    const { url, captures } = await mockCollector()
+    const { ctx, fiber } = await boot(url)
+    const session = Session.create(SessionId('cold-not-submitted'))
+    const notify = async () => ctx.parallel('feedback/committed', {
+      meta: session.header, events: session.snapshotEvents(), inheritedEventCount: session.inheritedEventCount,
     })
-    const session = ctx.sessions.create(SessionId('direct-default'), { meta: {} })
+    await notify()
+    recordFeedback(session, 'older feedback')
     session.append('turn/start', { turn: 1 })
-    recordFeedback(session, 'local report')
-    await ctx.fiber.dispose()
+    await notify()
+    const child = ctx.sessions.create(SessionId('foreign'))
+    const message = createAssistantMessage({ content: [], source: { provider: 'mock', model: 'm' } })
+    child.append('feedback/message-delete', { sessionId: session.id, messageId: message.id })
+    await ctx.parallel('feedback/committed', {
+      meta: { ...session.header, id: SessionId('inherited-cold'), parentSession: session.id, isSeeded: true },
+      events: session.snapshotEvents(SessionLogOffset(0), SessionLogOffset(1)), inheritedEventCount: SessionLogOffset(1),
+    })
+    await fiber.dispose()
+    expect(captures).toEqual([])
+  })
 
-    expect(warn).toHaveBeenCalledWith(
-      'session telemetry is DISABLED; nothing will be shared and this feedback remains local',
+  it.each([SessionTelemetryMode.FEEDBACK_ONLY])('restores a cold fork with its exact inherited cut in %s', async (mode) => {
+    const { url, captures } = await mockCollector()
+    const root = mkdtempSync(join(tmpdir(), 'dsh-otel-cold-fork-'))
+    const ctx = new Context()
+    try {
+      await ctx.plugin(SessionStore)
+      await ctx.plugin(JsonlPersistence, { root, compression: 'none' })
+      await ctx.plugin(MessageFeedbackService, { maxNoteBytes: 1024 })
+      await ctx.plugin(OpenTelemetrySessionBackend, { mode, exporter: { url }, processor: { scheduledDelayMillis: 1 } })
+      const parent = Session.create(SessionId('cold-parent'))
+      parent.append('request/header', { header: { config: { provider: 'mock', model: 'm' } }, reason: 'initial' })
+      const message = createAssistantMessage({ content: [{ type: 'text', text: 'inherited answer' }], source: { provider: 'mock', model: 'm' } })
+      parent.append('assistant/message', { message, stream: [], turn: 1, step: 1 }, { surfaceOp: 'append' })
+      const child = Session.create(SessionId('cold-child'), parent.snapshotEvents(), {
+        ...parent.header, id: SessionId('cold-child'), parentSession: parent.id, isSeeded: true,
+      }, parent.seq)
+      recordFeedback(child, 'child-owned stored feedback')
+      const handle = await ctx.sessionPersistence.create(child.header, { inheritedEventCount: child.inheritedEventCount })
+      try {
+        await handle.append(child.snapshotEvents())
+      } finally {
+        await handle.close()
+      }
+      expect(child.seq).toBeGreaterThan(child.inheritedEventCount)
+      const put = await ctx.messageFeedback.put({ sessionId: child.id, messageId: message.id, rating: 'positive', ifVersion: null })
+      if (!put.ok) throw new Error('cold child feedback failed')
+      expect(ctx.sessions.list()).toEqual([])
+      const read = await ctx.sessionPersistence.open(child.id, 'read')
+      try {
+        const events = await read.read()
+        expect(events.at(-1)?.type).toBe('feedback/message-put')
+        expect(events).toHaveLength(child.seq + 1)
+      } finally {
+        await read.close()
+      }
+    } finally {
+      await ctx.fiber.dispose()
+      rmSync(root, { recursive: true, force: true })
+    }
+    expect(eventTypes(captures)).toEqual([
+      'request/header', 'assistant/message', 'session/end-seed', 'feedback/record', 'feedback/message-put',
+    ])
+    expect(allRecords(captures).some(record => record.scope.endsWith('/ops'))).toBe(false)
+  })
+
+  it.each([
+    ['mock', SessionTelemetryMode.FEEDBACK_ONLY],
+    ['deepseek-official', SessionTelemetryMode.FEEDBACK_ONLY],
+    [undefined, SessionTelemetryMode.FEEDBACK_ONLY],
+    ['mock', SessionTelemetryMode.DISABLED],
+  ] as const)('captures cold put, note edit and withdrawal for %s in %s without opening a live Session', async (provider, mode) => {
+    const { url, captures } = await mockCollector()
+    const root = mkdtempSync(join(tmpdir(), 'dsh-otel-cold-'))
+    const ctx = new Context()
+    const warn = vi.spyOn(ctx.logger, 'warn').mockImplementation(() => {})
+    try {
+      await ctx.plugin(SessionStore)
+      await ctx.plugin(JsonlPersistence, { root, compression: 'none' })
+      await ctx.plugin(MessageFeedbackService, { maxNoteBytes: 1024 })
+      await ctx.plugin(OpenTelemetrySessionBackend, { mode, exporter: { url }, processor: { scheduledDelayMillis: 1 } })
+      const session = Session.create(SessionId('cold-feedback'))
+      if (provider !== undefined) session.append('request/header', { header: { config: { provider, model: 'm' } }, reason: 'initial' })
+      const message = createAssistantMessage({ content: [{ type: 'text', text: 'answer' }], source: { provider: provider ?? 'mock', model: 'm' } })
+      session.append('assistant/message', { message, stream: [], turn: 1, step: 1 }, { surfaceOp: 'append' })
+      const handle = await ctx.sessionPersistence.create(session.header)
+      try {
+        await handle.append(session.snapshotEvents())
+      } finally {
+        await handle.close()
+      }
+      const observer = vi.fn()
+      ctx.on('feedback/committed', observer)
+      ctx.on('feedback/committed', () => { throw new Error('observer failure') })
+      const request = { sessionId: session.id, messageId: message.id, rating: 'positive' as const, ifVersion: null }
+      expect(captures).toEqual([])
+      const put = await ctx.messageFeedback.put(request)
+      expect(put.ok).toBe(true)
+      if (!put.ok) throw new Error('put failed')
+      if (mode !== SessionTelemetryMode.DISABLED) await expect.poll(() => eventTypes(captures).filter(type => type === 'feedback/message-put').length).toBe(1)
+      await ctx.messageFeedback.put({ ...request, ifVersion: put.value.version })
+      const edit = await ctx.messageFeedback.put({ ...request, ifVersion: put.value.version, note: 'explanation' })
+      if (!edit.ok) throw new Error('edit failed')
+      if (mode !== SessionTelemetryMode.DISABLED) await expect.poll(() => eventTypes(captures).filter(type => type === 'feedback/message-put').length).toBe(3)
+      await ctx.messageFeedback.delete({ sessionId: session.id, messageId: message.id, ifVersion: edit.value.version })
+      if (mode !== SessionTelemetryMode.DISABLED) await expect.poll(() => eventTypes(captures)).toContain('feedback/message-delete')
+      expect(observer).toHaveBeenCalledTimes(3)
+      expect(ctx.sessions.list()).toEqual([])
+      expect(await ctx.messageFeedback.list({ sessionId: session.id })).toEqual({ ok: true, value: { items: [] } })
+    } finally {
+      await ctx.fiber.dispose()
+      rmSync(root, { recursive: true, force: true })
+    }
+    expect(warn).not.toHaveBeenCalledWith(
+      'OpenTelemetry session upload is DISABLED; this feedback is not uploaded through OpenTelemetry',
     )
-    expect(captures).toEqual([])
+    if (mode === SessionTelemetryMode.DISABLED) expect(captures).toEqual([])
+    else {
+      expect(eventTypes(captures)).toContain('feedback/message-put')
+      expect(eventTypes(captures)).toContain('feedback/message-delete')
+      expect(JSON.stringify(captures)).toContain('explanation')
+      expect(eventTypes(captures)).not.toContain('session/end-seed')
+      expect(allRecords(captures).some(record => record.scope.endsWith('/ops'))).toBe(false)
+    }
   })
 })
 
@@ -502,43 +739,46 @@ describe('OpenTelemetrySessionBackend config fails loud', () => {
   it('exposes modes through the nominal enum', () => {
     expectTypeOf<Config['mode']>().toEqualTypeOf<SessionTelemetryMode | undefined>()
     expectTypeOf<'FULL'>().not.toExtend<SessionTelemetryMode>()
-    expectTypeOf<SessionTelemetryMode.FULL>().toExtend<SessionTelemetryMode>()
-    expect(DEFAULT_TELEMETRY_MODE).toBe(SessionTelemetryMode.DISABLED)
+    expectTypeOf<SessionTelemetryMode.FEEDBACK_ONLY>().toExtend<SessionTelemetryMode>()
+    expect(Object.values(SessionTelemetryMode)).toEqual(['FEEDBACK_ONLY', 'DISABLED'])
+    expect(() => Config({ mode: 'FULL' } as unknown as Config)).toThrow()
+    expect(DEFAULT_TELEMETRY_MODE).toBe(SessionTelemetryMode.FEEDBACK_ONLY)
     expect(Config({}).mode).toBe(DEFAULT_TELEMETRY_MODE)
   })
 
   it.each([
-    [{ mode: SessionTelemetryMode.FULL }, /exporter\.url is required/],
-    [{ mode: SessionTelemetryMode.FULL, exporter: { url: '' } }, /exporter\.url is required/],
-    [{ mode: SessionTelemetryMode.FULL, exporter: { url: 'not a url' } }, /not a valid URL/],
-    [{ mode: SessionTelemetryMode.FULL, exporter: { url: 'ftp://collector' } }, /must be http\(s\)/],
+    [{}, /exporter\.url is required/],
     [{ mode: SessionTelemetryMode.FEEDBACK_ONLY }, /exporter\.url is required/],
+    [{ mode: SessionTelemetryMode.FEEDBACK_ONLY, exporter: { url: '' } }, /exporter\.url is required/],
+    [{ mode: SessionTelemetryMode.FEEDBACK_ONLY, exporter: { url: 'not a url' } }, /not a valid URL/],
+    [{ mode: SessionTelemetryMode.FEEDBACK_ONLY, exporter: { url: 'ftp://collector' } }, /must be http\(s\)/],
     [{ mode: 'INVALID' }, /INVALID/],
+    [{ mode: 'FULL' }, /FULL/],
     // The SDK accepts a non-positive batch size but its shutdown drain then
     // splices empty batches forever — dispose would hang, so reject at load.
-    [{ mode: SessionTelemetryMode.FULL, exporter: { url: 'http://c/v1/logs' }, processor: { maxExportBatchSize: 0 } }, /maxExportBatchSize/],
-    [{ mode: SessionTelemetryMode.FULL, exporter: { url: 'http://c/v1/logs' }, processor: { maxExportBatchSize: 0.5 } }, /maxExportBatchSize/],
-    [{ mode: SessionTelemetryMode.FULL, exporter: { url: 'http://c/v1/logs' }, shutdownTimeoutMillis: 0 }, /shutdownTimeoutMillis/],
-    [{ mode: SessionTelemetryMode.FULL, exporter: { url: 'http://c/v1/logs' }, shutdownTimeoutMillis: Number.POSITIVE_INFINITY }, /shutdownTimeoutMillis/],
+    [{ mode: SessionTelemetryMode.FEEDBACK_ONLY, exporter: { url: 'http://c/v1/logs' }, processor: { maxExportBatchSize: 0 } }, /maxExportBatchSize/],
+    [{ mode: SessionTelemetryMode.FEEDBACK_ONLY, exporter: { url: 'http://c/v1/logs' }, processor: { maxExportBatchSize: 0.5 } }, /maxExportBatchSize/],
+    [{ mode: SessionTelemetryMode.FEEDBACK_ONLY, exporter: { url: 'http://c/v1/logs' }, shutdownTimeoutMillis: 0 }, /shutdownTimeoutMillis/],
+    [{ mode: SessionTelemetryMode.FEEDBACK_ONLY, exporter: { url: 'http://c/v1/logs' }, shutdownTimeoutMillis: Number.POSITIVE_INFINITY }, /shutdownTimeoutMillis/],
   ])('rejects %j at plugin load', async (config, message) => {
     const ctx = new Context()
     await ctx.plugin(SessionStore)
     await expect(ctx.plugin(OpenTelemetrySessionBackend, config as Config)).rejects.toThrow(message)
   })
 
-  it('rejects an unknown direct mode before reading transport config', async () => {
+  it.each(['INVALID', 'FULL'])('rejects direct mode %s before reading transport config', async (mode) => {
     const ctx = new Context()
     await ctx.plugin(SessionStore)
     let exporterRead = false
     const config = {
-      mode: 'INVALID',
+      mode,
       get exporter() {
         exporterRead = true
         throw new Error('transport config was read')
       },
     } as unknown as Config
 
-    expect(() => new OpenTelemetrySessionBackend(ctx, config)).toThrow(/unsupported mode "INVALID"/)
+    expect(() => new OpenTelemetrySessionBackend(ctx, config)).toThrow(`unsupported mode "${mode}"`)
     expect(exporterRead).toBe(false)
   })
 
@@ -584,7 +824,7 @@ describe('dsh-session-telemetry-otel real-load-path guard', () => {
     const unwrapped = loader.unwrapExports(module) as Parameters<Context['plugin']>[0]
     const ctx = new Context()
     await ctx.plugin(SessionStore)
-    const fiber = await ctx.plugin(unwrapped, { mode: SessionTelemetryMode.FULL, exporter: { url } })
+    const fiber = await ctx.plugin(unwrapped, { mode: SessionTelemetryMode.FEEDBACK_ONLY, exporter: { url } })
     expect(ctx.sessionTelemetry).toBeInstanceOf(OpenTelemetrySessionBackend)
     await fiber.dispose()
   })

+ 3 - 0
packages/session/session-telemetry-otel/tsconfig.json

@@ -23,6 +23,9 @@
     {
       "path": "../../feedback/command-feedback"
     },
+    {
+      "path": "../../feedback/message-feedback"
+    },
     {
       "path": "../../llm/llm"
     },

+ 2 - 2
packages/session/session-telemetry/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/session/session-telemetry/README.md
-README.md: d6916d26272daed47c2a148240fab94b13fafaa2
-README.zh.md: c82afc5384deb2e6d7f950b5efe2e8d0b67599bd
+README.md: 14664e42d018a5e607c7bf58f38c19d5fb8ae4a3
+README.zh.md: ba89f0039ba963d83f341472d33c8140314630ff

+ 5 - 5
packages/session/session-telemetry/README.md

@@ -29,7 +29,7 @@ As a deployment, choose a backend, mount it, and add redaction rules when record
 
 ### Choosing and mounting a backend
 
-Load exactly one backend plugin; it registers `ctx.sessionTelemetry` with the capture coordinator and its own delivery pipeline, and a duplicate load throws. The mounted backend discloses its sharing policy through the required [`sharing` member](#the-sharing-disclosure); a consumer may report "not configured" only when no telemetry service is mounted. The `/feedback` command confirms recording without reading this policy.
+Load exactly one backend plugin; it registers `ctx.sessionTelemetry` with the capture coordinator and its delivery pipeline. A duplicate load throws. The required [`sharing` member](#the-sharing-disclosure) reports the deployment mode, not per-session admission or delivery. A consumer may report "not configured" only when no telemetry service is mounted. The `/feedback` command confirms recording without reading this policy.
 
 ### The backend contract
 
@@ -37,13 +37,13 @@ A backend implements three members: `emit(record)` must be a non-blocking enqueu
 
 ### What gets captured
 
-Capture runs in one of two modes. `live` capture follows session events as they are appended, replays already-live sessions at mount time, and records lifecycle markers; `on-demand` capture reads the canonical session log only when the backend requests a prefix through `captureSession(session, throughSeq?)`. Every canonical session event maps to one ledger record in order. An `assistant/message` or `assistant/attempt` record carries its complete embedded compact stream, including failed and retried output. Each ledger record also carries `session.id`, `session.format_version`, the numeric event identity, optional header facts, and a pre-mapped severity (`error` for `tool/result.isError`, `turn/end` error reasons, and `agent-error`; `info` otherwise).
+Capture runs in one of two modes. `live` capture follows session events as they are appended, replays already-live sessions at mount time, and records lifecycle markers; `on-demand` capture reads the canonical session log only when the backend requests a prefix through `captureSession(session, throughSeq?)`. Coordinator options select whether stored history is included. Every canonical session event maps to one ledger record in order. An `assistant/message` or `assistant/attempt` record carries its complete embedded compact stream, including failed and retried output. Each ledger record also carries `session.id`, `session.format_version`, the numeric event identity, optional header facts, and a pre-mapped severity (`error` for `tool/result.isError`, `turn/end` error reasons, and `agent-error`; `info` otherwise).
 
 ### The sharing disclosure
 
 <a id="the-sharing-disclosure"></a>
 
-Every backend discloses its deployment-selected sharing policy through the seam's `sharing` vocabulary: `full` (every event is handed over as it happens), `feedback-only` (nothing is handed over until a `feedback/record` event releases the unreleased prefix), or `disabled` (nothing is handed over at all). The policy API never claims delivery — handoff is the non-blocking enqueue, and batching, retry, and loss policy stay the backend SDK's.
+Every backend discloses its deployment mode through `sharing`: `full`, `feedback-only`, or `disabled`. A backend may additionally restrict eligible Sessions. This property is not a delivery receipt; handoff is a non-blocking enqueue, and batching, retry, and loss policy belong to the backend SDK.
 
 ### Redacting records
 
@@ -74,11 +74,11 @@ The seam is built on one boundary: the harness's aspect ends at `emit()`. Comple
 
 ### Capture flow
 
-Live capture registers, through the composing fiber's effects: `session/created` adopts the session and replays its lifecycle-local log suffix from the handoff cursor; `session/event` deep-copies, redacts, and hands off each event with zero I/O; `session/flush` forwards the optional hint and returns void so the loop's awaited parallel never waits on telemetry; `session/disposed` captures the session's `shutdown` marker and retires it; `agent/error` is the one live-bus relay, because the session-event vocabulary intentionally has no operational-error record. Disposal captures shutdown markers for still-live sessions, then awaits the backend's `shutdown()`. On-demand capture registers only the disposal effect and reads the requested lifecycle-local canonical-log prefix on request. Every synchronous handler runs inside containment so a failing backend or rule can never starve other listeners or reach the agent loop.
+Live capture registers Session events, flush hints, shutdown markers, and agent/error observers through the composing fiber’s effects. On-demand capture registers only the disposal effect and reads the requested canonical-log prefix under its history policy. Synchronous handlers contain failures so they cannot affect the agent loop or other listeners.
 
 ### The handoff cursor
 
-A module-scope `WeakMap<Session, seq>` records, per Session object, the highest seq handed off (not delivered). Live capture advances it at append time; on-demand capture advances it only while handing a requested prefix. Re-adopting the same object resumes after that cursor and does not duplicate its handed-off ledger records. A new Session object starts immediately before `firstLiveSeq`: a fresh object starts at seq 0, while a forked, resumed, or migrated object skips its constructor seed and starts with this lifecycle's `session/end-seed` boundary. This keeps inherited and previously persisted history outside a new lifecycle's sharing act. Receivers absorb SDK retries by deduplicating on `(session.id, session.format_version, event.seq)`. The object-keyed map is a narrow, documented exception to the registrations-are-effects discipline: entries die with their sessions, and losing one can replay only the current lifecycle suffix.
+A module-scope `WeakMap<Session, seq>` records the highest sequence handed off, not delivered. Re-adopting the same object resumes after that cursor. Capture normally starts at `firstLiveSeq`; explicit `includeHistory: true` starts an unhanded object at seq 0, including restored or fork history. The backend owns capture authorization. Stored history does not itself authorize capture; the OTel backend waits for new explicit feedback. Receivers deduplicate repeated records by `(session.id, session.format_version, event.seq)`.
 
 </details>
 

+ 5 - 5
packages/session/session-telemetry/README.zh.md

@@ -29,7 +29,7 @@ kind: "package-library"
 
 ### 选择并挂载后端
 
-只加载一个后端插件;它把捕获协调器与自己的投递流水线注册为 `ctx.sessionTelemetry`,重复加载会抛出异常。已挂载后端通过必需的 [`sharing` 成员](#the-sharing-disclosure) 披露共享策略;只有在未挂载任何遥测服务时,消费方才可报告「未配置」。`/feedback` 命令确认记录,不读取此策略。
+只加载一个后端插件;它把捕获协调器与投递流水线注册为 `ctx.sessionTelemetry`。重复加载会抛出异常。必需的 [`sharing` 成员](#the-sharing-disclosure) 报告部署模式,不代表会话准入或投递。只有在未挂载任何遥测服务时,消费方才可报告「未配置」。`/feedback` 命令确认记录,不读取此策略。
 
 ### 后端约定
 
@@ -37,13 +37,13 @@ kind: "package-library"
 
 ### 捕获内容
 
-捕获以两种模式之一运行。`live` 捕获在追加时跟随会话事件、在挂载时回放已存活会话并记录生命周期标记;`on-demand` 捕获只在后端通过 `captureSession(session, throughSeq?)` 请求前缀时读取权威会话日志。每条权威会话事件都按顺序映射为一条 ledger 记录。`assistant/message` 或 `assistant/attempt` 记录会携带完整的嵌入式紧凑 stream,包括失败和重试输出。每条 ledger 记录还携带 `session.id`、`session.format_version`、数值事件身份、可选 header 事实与预先映射的严重级别(`tool/result.isError`、`turn/end` 的错误原因与 `agent-error` 映射为 `error`;其余为 `info`)。
+捕获以两种模式之一运行。`live` 捕获在追加时跟随会话事件、在挂载时回放已存活会话并记录生命周期标记;`on-demand` 捕获只在后端通过 `captureSession(session, throughSeq?)` 请求前缀时读取权威会话日志。协调器选项决定是否包含存储历史。每条权威会话事件都按顺序映射为一条 ledger 记录。`assistant/message` 或 `assistant/attempt` 记录会携带完整的嵌入式紧凑 stream,包括失败和重试输出。每条 ledger 记录还携带 `session.id`、`session.format_version`、数值事件身份、可选 header 事实与预先映射的严重级别(`tool/result.isError`、`turn/end` 的错误原因与 `agent-error` 映射为 `error`;其余为 `info`)。
 
 ### 共享披露
 
 <a id="the-sharing-disclosure"></a>
 
-每个后端都通过 seam 的 `sharing` 词汇披露其部署级共享策略:`full`(每个事件在发生时立即交接)、`feedback-only`(在 `feedback/record` 事件释放其之前的未释放前缀之前,不交接任何内容)或 `disabled`(完全不交接任何内容)。策略 API 从不声称投递——交接是非阻塞入队,批处理、重试与丢失策略仍归后端 SDK。
+每个后端通过 `sharing` 披露部署模式:`full`、`feedback-only` 或 `disabled`。后端还可限制符合条件的 Session。该属性不是投递回执;交接是非阻塞入队,批处理、重试与丢失策略属于后端 SDK。
 
 ### 脱敏记录
 
@@ -74,11 +74,11 @@ seam 建立在一个边界之上:harness 的职责止于 `emit()`。完整事
 
 ### 捕获流程
 
-live 捕获通过组合方 fiber 的 effect 注册:`session/created` 收养会话并从 handoff 游标起回放其生命周期本地日志后缀;`session/event` 深拷贝、脱敏并交接每个事件,零 I/O;`session/flush` 转发可选的提示并返回 void,使循环所等待的并行任务绝不等待遥测;`session/disposed` 捕获会话的 `shutdown` 标记并退役它;`agent/error` 是唯一的实时总线转发,因为会话事件词汇有意不包含运维错误记录。dispose 会为仍存活的会话捕获 shutdown 标记,然后等待后端的 `shutdown()`。on-demand 捕获只注册 dispose effect,并在请求时读取所请求的生命周期本地权威日志前缀。每个同步处理器都运行在异常隔离之内,使失败的后端或规则永远不会饿死其他监听器,也永远不会触及 agent loop
+实时捕获通过组合 fiber 的 effect 注册 Session 事件、刷新提示、关闭标记与 agent/error 观察器。按需捕获只注册释放 effect,并按历史策略读取请求的权威日志前缀。同步处理器隔离失败,避免影响 agent loop 或其他监听器
 
 ### handoff 游标
 
-一个模块作用域的 `WeakMap<Session, seq>` 按 Session 对象记录已交接(而非已投递)的最高 seq。live 捕获在追加时推进它;on-demand 捕获只在交接所请求的前缀时推进它。重新收养同一对象时会从该游标之后继续,且不会重复交接其 ledger 记录。新 Session 对象从 `firstLiveSeq` 之前开始:全新对象从 seq 0 开始,而 fork、resume 或迁移对象会跳过 constructor seed,从本生命周期的 `session/end-seed` 边界开始。这样,继承历史与此前持久化历史不会进入新生命周期的共享动作。接收端基于 `(session.id, session.format_version, event.seq)` 对 SDK 重试去重。这个以对象为键的 map 是对「注册即 effect」纪律的一次有意且有文档说明的窄例外:条目随其 Session 消亡;丢失条目最多只会回放当前生命周期后缀
+模块作用域的 `WeakMap<Session, seq>` 记录已交接而非已投递的最高序号。重新收养同一对象时从该游标之后继续。捕获通常从 `firstLiveSeq` 开始;显式 `includeHistory: true` 从未交接对象的 seq 0 开始,包含恢复或分叉历史。后端负责捕获授权。存储的历史本身不授权捕获;OTel 后端等待新的显式反馈。接收方按 `(session.id, session.format_version, event.seq)` 对重复记录去重
 
 </details>
 

+ 18 - 11
packages/session/session-telemetry/src/coordinator.ts

@@ -17,6 +17,7 @@
 import type { Context } from '@deepseek-ai/cordis'
 import {
   SessionSeq,
+  SessionLogOffset,
   type Session,
   type SessionEvent,
   type SessionSeq as SessionSeqType,
@@ -28,6 +29,14 @@ import type { SessionTelemetrySink, SessionTelemetryRecord, SessionTelemetrySeve
 /** Whether capture follows live events or reads the canonical log only when requested. */
 export type SessionTelemetryCapture = 'live' | 'on-demand'
 
+/** Backend-selected capture mode and history policy. */
+export interface SessionTelemetryCaptureOptions {
+  /** Follow live events, or wait for explicit capture; defaults to live. */
+  capture?: SessionTelemetryCapture
+  /** Include stored history before this lifecycle; defaults to false. */
+  includeHistory?: boolean
+}
+
 /** One record ready for backend handoff. */
 interface PendingRecord {
   readonly record: SessionTelemetryRecord
@@ -73,14 +82,14 @@ export class SessionTelemetryCoordinator {
   /**
    * @param ctx - the composing backend's context; listeners bind to its fiber.
    * @param backend - the backend receiving records; owned elsewhere, never disposed here beyond `shutdown()` forwarding.
-   * @param capture - follow live events, or wait for explicit canonical-log capture.
+   * @param options - capture mode and history policy.
    */
   constructor(
     private readonly ctx: Context,
     private readonly backend: SessionTelemetrySink,
-    capture: SessionTelemetryCapture = 'live',
+    private readonly options: SessionTelemetryCaptureOptions = {},
   ) {
-    if (capture === 'live') {
+    if ((options.capture ?? 'live') === 'live') {
       ctx.on('session/created', (session) => {
         this.adopt(session)
       })
@@ -141,12 +150,11 @@ export class SessionTelemetryCoordinator {
    */
   captureSession(session: Session, throughSeq?: SessionSeqType): void {
     const cursor = handoffCursor.get(session)
-      ?? (session.firstLiveSeq === 0 ? -1 : SessionSeq(session.firstLiveSeq - 1))
+      ?? (this.options.includeHistory === true || session.firstLiveSeq === 0 ? -1 : SessionSeq(session.firstLiveSeq - 1))
     // Containment is PER EVENT: one rejected record is withheld fail-closed
     // while the rest of the historical replay proceeds.
-    for (const event of session.snapshotEvents()) {
+    for (const event of session.snapshotEvents(SessionLogOffset(cursor + 1))) {
       if (throughSeq !== undefined && event.seq > throughSeq) break
-      if (event.seq <= cursor) continue
       this.contain(() => {
         this.captureEvent(session, event)
       })
@@ -154,11 +162,10 @@ export class SessionTelemetryCoordinator {
   }
 
   /**
-   * Adopt a session: replay this lifecycle's log suffix after the same-object
-   * handoff cursor, then rely on the firehose for everything after. A newly
-   * constructed Session object starts at its constructor boundary, so inherited
-   * or restored seed history is not attributed to this lifecycle. Re-adopting
-   * the same object resumes after its cursor.
+   * Adopt a session and replay after its handoff cursor, then follow live events.
+   * New objects include inherited and restored history only with includeHistory;
+   * otherwise replay starts at the constructor boundary. Re-adopting the same
+   * object resumes after its cursor.
    * @param session - the live session to adopt; a second adoption is a no-op.
    */
   private adopt(session: Session): void {

+ 3 - 10
packages/session/session-telemetry/src/index.ts

@@ -132,10 +132,7 @@ export interface SessionTelemetrySink {
 }
 
 /**
- * Deployment-selected session-sharing policy disclosed by a mounted
- * {@link SessionTelemetryBackend} backend to human-facing acknowledgement surfaces (the
- * `/feedback` command's confirmation text). The Service Definition owns the
- * vocabulary so consumers and backends do not depend on a specific provider.
+ * Deployment-selected session-sharing mode, not confirmation of SDK delivery.
  */
 export type SessionTelemetrySharingStatus = 'full' | 'feedback-only' | 'disabled'
 
@@ -151,11 +148,7 @@ export abstract class SessionTelemetryBackend extends Service implements Session
   }
 
   /**
-   * Deployment-selected session-sharing policy, disclosed for acknowledgement
-   * surfaces that report whether recorded feedback leaves the process. Every
-   * backend must disclose its policy; a consumer renders "not configured" only
-   * when no telemetry service is mounted. The seam owns this vocabulary so the
-   * disclosure is backend-independent.
+   * Deployment-selected sharing mode, independent of SDK delivery.
    */
   abstract readonly sharing: SessionTelemetrySharingStatus
 
@@ -175,4 +168,4 @@ export abstract class SessionTelemetryBackend extends Service implements Session
   abstract shutdown(): Promise<void>
 }
 
-export { SessionTelemetryCoordinator, type SessionTelemetryCapture } from './coordinator.ts'
+export { SessionTelemetryCoordinator, type SessionTelemetryCapture, type SessionTelemetryCaptureOptions } from './coordinator.ts'

+ 51 - 2
packages/session/session-telemetry/tests/telemetry.spec.ts

@@ -76,7 +76,7 @@ async function setup(
     name: 'fake-telemetry',
     inject: ['sessions'],
     apply: (inner: Context) => {
-      coordinator = new SessionTelemetryCoordinator(inner, backend, capture)
+      coordinator = new SessionTelemetryCoordinator(inner, backend, { capture })
     },
   })
   return { ctx, backend, coordinator, fiber }
@@ -137,6 +137,24 @@ describe('SessionTelemetryCoordinator capture', () => {
     expect(logged.data.content[0]).toMatchObject({ text: 'hello' })
   })
 
+  it('captures a live request header without replaying previously withheld events', async () => {
+    const { ctx, backend } = await setup()
+    try {
+      const session = liveSession(ctx, 'live-header')
+      const disposeRule = ctx.on('session-telemetry/record', () => {
+        throw new Error('withheld')
+      })
+      session.append('turn/start', { turn: 1 })
+      disposeRule()
+      session.append('request/header', {
+        header: { config: { provider: 'mock', model: 'mock' } }, reason: 'initial',
+      })
+      expect(backend.ledger().map(record => record.attributes['event.type'])).toEqual(['request/header'])
+    } finally {
+      await ctx.fiber.dispose()
+    }
+  })
+
   it('stamps header facts on every record when present', async () => {
     const { ctx, backend } = await setup()
     const parent = SessionId('parent')
@@ -264,6 +282,37 @@ describe('SessionTelemetryCoordinator on-demand capture', () => {
     })
   })
 
+  it('includes inherited history only when explicitly requested, through the exact sequence', async () => {
+    const ctx = new Context()
+    const backend = new FakeBackend()
+    try {
+      await ctx.plugin(SessionStore)
+      let coordinator!: SessionTelemetryCoordinator
+      await ctx.plugin({
+        name: 'history-telemetry',
+        inject: ['sessions'],
+        apply: (inner: Context) => {
+          coordinator = new SessionTelemetryCoordinator(inner, backend, {
+            capture: 'on-demand', includeHistory: true,
+          })
+        },
+      })
+      const parent = liveSession(ctx, 'history-parent')
+      appendTurn(parent)
+      const child = ctx.sessions.create(SessionId('history-child'), { seed: [...parent.snapshotEvents()] })
+      const boundary = child.append('turn/end', { turn: 1, reason: { kind: 'completed' } })
+      child.append('turn/start', { turn: 2 })
+      expect(backend.records).toEqual([])
+      coordinator.captureSession(child, boundary.seq)
+      coordinator.captureSession(child, boundary.seq)
+      expect(backend.ledger().map(record => record.attributes['event.seq'])).toEqual([0, 1, 2, 3])
+      expect(backend.ledger().at(-1)?.attributes['event.seq']).toBe(boundary.seq)
+      expect(backend.ledger().every(record => record.attributes['session.id'] === child.id)).toBe(true)
+    } finally {
+      await ctx.fiber.dispose()
+    }
+  })
+
   it('runs the currently mounted redaction policy during canonical-log capture', async () => {
     const { ctx, backend, coordinator } = await setup(new FakeBackend(), 'on-demand')
     const session = liveSession(ctx, 'on-demand-redacted')
@@ -310,7 +359,7 @@ describe('SessionTelemetryCoordinator on-demand capture', () => {
       name: 'fake-telemetry-after-on-demand-reload',
       inject: ['sessions'],
       apply: (inner: Context) => {
-        coordinator = new SessionTelemetryCoordinator(inner, second, 'on-demand')
+        coordinator = new SessionTelemetryCoordinator(inner, second, { capture: 'on-demand' })
       },
     })
     coordinator.captureSession(session)

+ 3 - 0
pnpm-lock.yaml

@@ -7453,6 +7453,9 @@ importers:
       '@deepseek-ai/dsh-loader-smoke':
         specifier: workspace:^
         version: link:../../test-support/loader-smoke
+      '@deepseek-ai/dsh-message-feedback':
+        specifier: workspace:^
+        version: link:../../feedback/message-feedback
       '@deepseek-ai/dsh-session':
         specifier: workspace:^
         version: link:../../core/session

+ 1 - 0
scripts/gen-cordis-catalog.ts

@@ -204,6 +204,7 @@ export const EVENT_SCOPE_PAGE: Record<string, string> = {
   'subagent': 'subagent.md',
   'system-prompt': 'system-prompt.md',
   'session-telemetry': 'session-telemetry.md',
+  'feedback': 'feedback.md',
   'tools': 'tools.md',
   'user-questions': 'user-questions.md',
   'webserver': 'web-server.md',

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

@@ -1811,6 +1811,16 @@
       "symbol": "ClientArtifactBaseline",
       "source": "packages/client/modules/src/index.ts"
     },
+    {
+      "doc": "docs/subsystems/session-telemetry.md",
+      "symbol": "SessionTelemetryCapture",
+      "source": "packages/session/session-telemetry/src/coordinator.ts"
+    },
+    {
+      "doc": "docs/subsystems/session-telemetry.md",
+      "symbol": "SessionTelemetryCaptureOptions",
+      "source": "packages/session/session-telemetry/src/coordinator.ts"
+    },
     {
       "doc": "docs/subsystems/session-telemetry.md",
       "symbol": "SessionTelemetrySharingStatus",

+ 44 - 0
snapshots/web/feedback-release/feedback-release.expected.json

@@ -0,0 +1,44 @@
+{
+  "mode": "FEEDBACK_ONLY",
+  "feedback": [
+    {
+      "type": "feedback/record",
+      "text": "the diff view is unreadable"
+    },
+    {
+      "type": "feedback/record",
+      "text": "the second remark"
+    },
+    {
+      "type": "feedback/message-put",
+      "rating": "positive"
+    },
+    {
+      "type": "feedback/message-put",
+      "rating": "positive",
+      "note": "Read both files before answering."
+    },
+    {
+      "type": "feedback/message-delete"
+    }
+  ],
+  "laterSubmissionSuffixes": [
+    [
+      "command/done",
+      "model/selection",
+      "model/selection",
+      "command/run",
+      "feedback/record"
+    ],
+    [
+      "command/done",
+      "feedback/message-put"
+    ],
+    [
+      "feedback/message-put"
+    ],
+    [
+      "feedback/message-delete"
+    ]
+  ]
+}