Retain ignorable session events for external plugins
为外部插件保留可忽略会话事件
The session event envelope carries `ignorable?: true` so a reader can accept an unrecognized informational event without treating every vocabulary addition as a new session format. PR #3087 removed the field after finding no first-party producer and made every unknown event required-on-read. That producer inventory did not cover a third-party plugin that currently depends on the field. Without `ignorable`, a first-pa
English
Problem
The session event envelope carries ignorable?: true so a reader can accept an unrecognized informational event without treating every vocabulary addition as a new session format. PR #3087 removed the field after finding no first-party producer and made every unknown event required-on-read.
That producer inventory did not cover a third-party plugin that currently depends on the field. Without ignorable, a first-party reader rejects a stored session containing the plugin's informational event because the event is outside the repository-generated KNOWN_SESSION_EVENT_TYPES. The plugin has no replacement registration or versioning mechanism, so deleting the field before a replacement exists breaks a current external consumer.
Decision
The canonical SessionEvent envelope retains ignorable?: true, and every representation preserves it: seed validation, JSONL, API transport, generated catalogs, and test fixtures. The persistence seam's stored-event validation (validateStoredEvents) continues to refuse an unknown event unless its stored envelope explicitly carries ignorable: true; absent remains required-on-read.
The field is removable only after a replacement supports the current third-party plugin across event production, persistence, reload, and transport, with an explicit cutover for sessions already containing the marker. The session log versioning decision continues to own the default-required safety rule and format-version policy.
appendPluginRecord() in @deepseek-ai/dsh-session is the one first-party writer of the marker. It appends ignorable records whose type is in the plugin: namespace, which the V3-to-V4 edge already assigns to unknown ignorable V3 events, and only production source under packages/experimental/ may call it; the verify-plugin-record-callers gate enforces that, and the persistence catalog generator rejects a SessionEventMap member in the namespace. Experimental packages keep state there that their owners can lose: a format migration retains records on a best-effort basis, so a release package declares its events instead.
Experimental packages declare record names and payload types in the separate PluginRecordMap. The map types writes and supplies the dedicated experimental persistence catalog, making plugin-owned persistence discoverable from current source without expanding its payload schemas or recording released type history. It does not add names to KNOWN_SESSION_EVENT_TYPES or change stored-event admission. The reader returns unknown payloads for owner validation, including records whose declarations have changed or disappeared.
Historical format migration is deliberately stricter in the alpha implementation. The v0-to-v1 edge refuses every unknown v0 type, including an ignorable one, because an opaque payload may contain references that a format edge cannot validate. The alpha historical-event decision owns that bounded exception; equal-version append and reload continue to follow this note.
Alternatives considered
Require every unknown event on read. Rejected because the current third-party plugin emits an informational event outside the repository-generated vocabulary. A first-party reload would reject that session even though omitting the event is safe.
Delete the field and design a replacement later. Rejected because that ordering creates an immediate compatibility gap with no migration or cutover path for the plugin or its stored sessions.
Treat every repository-external event as ignorable. Rejected because a reader cannot infer that an unknown durable event is informational. An external event may change later reconstruction or plugin-owned state.
Declare a SessionEventMap member for each experimental package's state. Rejected because each member is required-on-read and enters the released-schema inventory and type history: a build without the experimental package refuses the Session, and removing the package leaves a released type behind.
Keep no catalog of plugin records. Rejected because maintainers need to find in-repository persistence writers. The dedicated experimental catalog provides that inventory from current-source declarations while their payloads retain best-effort compatibility; a full schema history would impose the released-event obligations that these records deliberately avoid.
Let any package call the record writer. Rejected because records survive a format migration only on a best-effort basis, and a release package must not keep state that an upgrade can drop.
Restrict the writer with a runtime capability. Not adopted because the writer runs in its caller's process, where a runtime check cannot tell experimental code from release code; the repository gate checks every reference instead.
Register mounted plugin event names as known. Not adopted as the removal mechanism because event-name registration alone does not classify whether absence is safe, and acceptance would depend on the reader's current composition rather than the stored record.
Consequences
Third-party informational events can remain reloadable when their stored records carry the explicit marker, while unknown required events still fail loudly. The field remains part of the public event envelope, JSONL representation, transport types, generated references, and their tests until a replacement satisfies the cutover condition, which now also covers plugin records written by experimental packages.
中文
问题
会话事件信封包含 ignorable?: true,读取器因此可以接受不认识的信息性事件,而不必把每次词汇增加都视为新的会话格式。PR #3087 在没有发现第一方生产方后删除了该字段,并把每个未知事件都改为读取必需项。
该生产方清单没有覆盖当前依赖此字段的一个第三方插件。没有 ignorable 时,第一方读取器会拒绝包含该插件信息性事件的已存会话,因为该事件不在仓库生成的 KNOWN_SESSION_EVENT_TYPES 中。插件没有可替代的注册或版本机制,因此在替代机制存在前删除该字段会破坏当前外部消费方。
决定
标准 SessionEvent 信封保留 ignorable?: true,每种表示都保留它:seed 校验、JSONL、API 传输、生成目录与测试 fixture。持久化 seam 的已存事件校验(validateStoredEvents)继续拒绝未知事件,除非已存信封显式带有 ignorable: true;字段不存在时仍表示读取必需。
只有替代机制在事件生产、持久化、重新加载与传输中都支持当前第三方插件,并为已包含该标记的会话提供显式切换方案后,才能删除此字段。Session log 版本决策继续定义默认读取必需的安全规则与格式版本策略。
@deepseek-ai/dsh-session 中的 appendPluginRecord() 是该标记唯一的第一方写入方。它追加类型位于 plugin: 命名空间的 ignorable 记录(V3-to-V4 迁移边已把未知的 ignorable V3 事件放入该命名空间),且只有 packages/experimental/ 下的生产源码可以调用它;verify-plugin-record-callers 门禁强制这一点,持久化目录生成器也拒绝该命名空间中的 SessionEventMap 成员。实验性包在这里保存其所有者可以丢失的状态:格式迁移以尽力而为的方式保留记录,因此正式发布的包改为声明自己的事件。
实验性包在独立的 PluginRecordMap 中声明记录名称与 payload 类型。该映射约束写入类型,并生成独立的实验性持久化目录,让维护者能够从当前源码找到插件自有的持久化数据,而无须展开其 payload schema 或记录已发布类型历史。它不向 KNOWN_SESSION_EVENT_TYPES 添加名称,也不改变已存事件的准入。读取方返回未知类型的 payload,由所属包校验,其中也包括声明已变更或消失的记录。
Alpha 实现中的历史格式迁移有意更严格。v0-to-v1 迁移边会拒绝每个未知 v0 类型,包括 ignorable 类型,因为不透明 payload 可能包含格式迁移边无法校验的引用。Alpha 历史事件决策定义该有限例外;同版本 append 与 reload 继续遵循本记录。
曾考虑的替代方案
要求读取所有未知事件。 不予采用,因为当前第三方插件会发出仓库生成词汇之外的信息性事件。即使省略该事件是安全的,第一方重新加载仍会拒绝该会话。
先删除字段,以后再设计替代机制。 不予采用,因为该顺序会立刻产生兼容缺口,而且插件及其已存会话都没有迁移或切换路径。
把所有仓库外事件都视为可忽略。 不予采用,因为读取器无法推断未知持久事件是否属于信息性事件。外部事件可能改变后续重建或插件自有状态。
为每个实验性包的状态声明 SessionEventMap 成员。 不予采用,因为每个成员都是读取必需的,并会进入已发布 schema 清单及类型历史:没有该实验性包的构建会拒绝该会话,移除该包后还会留下一个已发布的类型。
不维护插件记录目录。 不予采用,因为维护者需要找到仓库内的持久化写入方。独立的实验性目录根据当前源码声明提供这份清单,而 payload 仍然只有尽力而为的兼容性;完整的 schema 历史会施加这些记录刻意不承担的已发布事件义务。
允许任何包调用记录写入方。 不予采用,因为记录只能以尽力而为的方式经受格式迁移,而正式发布的包不得保存升级可能丢弃的状态。
用运行时能力限制写入方。 不予采用,因为写入方运行在调用方的进程中,运行时检查无法区分实验性代码与正式发布的代码;仓库门禁改为检查每一处引用。
把已挂载插件的事件名称注册为已知。 不作为删除机制采用,因为只注册事件名称无法判定缺失该事件是否安全,而且接受结果会依赖读取器的当前组合,而不是已存记录。
影响
第三方信息性事件的已存记录带有显式标记时可以继续重新加载,未知必需事件则仍会明确失败。在替代机制满足切换条件前,该字段继续属于公开事件信封、JSONL 表示、传输类型、生成引用及其测试;该切换条件现在也涵盖实验性包写入的插件记录。