会话持久化
把会话的事件日志落到磁盘,并在进程崩溃后把"半截的最后一个 turn"补成可重放的形状。 稳定契约、通用 Coordinator 与物理 Provider 分属独立程序集;Consumer 不需要知道事件最终写进 JSONL 还是 SQLite。
一个 Coordinator,两个可替换 Provider
Tether.Session.Persistence 只保留稳定接口、header/result DTO、格式版本与异常;
Tether.Session.Persistence.Coordinator 统一拥有 live capture、write-behind、repair 与 writer 生命周期;
.Jsonl 和 .Sqlite 只实现物理 event store。base/headless/CLI 默认选 JSONL,
SQLite patch 只替换 session-store 这一行,不替换 Coordinator 或 Consumer。
三层职责不能互相渗透
| 层 | 稳定入口 | 拥有的职责 |
|---|---|---|
| Service Definition | ISessionPersistence、ISessionEventStore | provider-neutral DTO、格式版本、稳定异常 |
| Coordinator | SessionPersistenceCoordinator | Session event capture、per-session write-behind、flush/final drain、header/seq/prefix 校验、inspect/load/prepare、synthetic closers、writer ownership 与 publication fence |
| Provider | JsonlSessionEventStore(唯一 first-party 实现) | 原子 batch、物理 codec、range/list、tail revision fence、进程级 lease 与 durability |
Provider 不订阅 Session event,也不构造 Session 或 Agent。Coordinator 不读取文件路径、SQLite transaction 或 zstd frame。这个分界让 composition 只换 store,而所有调用方继续依赖同一个 ISessionPersistence。
Consumer-facing ISessionPersistence
public interface ISessionPersistence : IAsyncDisposable
{
ValueTask CreateAsync(SessionHeader header, CancellationToken ct = default);
ValueTask AppendAsync(SessionId id, IReadOnlyList<SessionEvent> events, CancellationToken ct = default);
ValueTask FlushAsync(SessionId? id = null, CancellationToken ct = default);
ValueTask AttachAsync(ISession session, CancellationToken ct = default);
ValueTask AttachAsync(ISession session, SessionHeader header, CancellationToken ct = default);
ValueTask RetireAsync(ISession session, CancellationToken ct = default);
ValueTask<SessionInspection> InspectAsync(SessionId id, CancellationToken ct = default);
ValueTask<SessionInspection> LoadAsync(SessionId id, CancellationToken ct = default);
ValueTask<SessionPreparation> PrepareAsync(SessionId id, CancellationToken ct = default);
ValueTask<SessionInspection> ReadFromAsync(SessionId id, long fromSeq, CancellationToken ct = default);
ValueTask<IReadOnlyList<SessionHeader>> ListAsync(CancellationToken ct = default);
}
ISessionPersistence 是 Consumer 看到的协调面。它不发布 Agent;冷恢复时 PrepareAsync 只返回尚未发布的 SessionPreparation,最终 publication 仍由 Agent 注册表拥有。
不带 header 的 AttachAsync(session) 用于正常 composition:它先读取 durable store,只有确实收到 SessionPersistenceMissingException 才为新 Session 采用生命周期内唯一的 pending header。带完整 header 的 overload 用于 prepare/resume 与显式 identity fence;两条路径都不会把 I/O、corruption、unsupported format 或 writer conflict 当作 missing。
Provider-facing ISessionEventStore
public interface ISessionEventStore : IAsyncDisposable
{
ValueTask AppendAsync(
SessionHeader header,
SessionLogOffset expectedNextOffset,
IReadOnlyList<SessionEvent> events,
CancellationToken ct = default);
ValueTask<SessionStoreReadResult> ReadAsync(
SessionId id,
SessionLogOffset fromOffset = default,
CancellationToken ct = default);
ValueTask RepairTailAsync(
SessionId id,
SessionStoreRevision expectedRevision,
SessionLogOffset expectedNextOffset,
CancellationToken ct = default);
ValueTask<IReadOnlyList<SessionHeader>> ListAsync(CancellationToken ct = default);
ValueTask<bool> DeleteAsync(SessionId id, CancellationToken ct = default); // 默认 NotSupported
}
一次 append 的 expectedNextOffset、header identity 与连续 seq 在同一个原子 commit 中核对:整个 batch durable,或整个 batch 完全不可见。JSONL provider 在持有会话写锁期间为每个会话保留 append 游标(dsh appendLines 按游标写入、不回读):上一次成功提交后 artifact 的路径、exact 文件 identity、字节长度、逻辑下一位置与解码字节数。后续追加只核对尾部——同一文件对象、长度恰为游标记录的长度——再按游标里的 durable header、expectedNextOffset 与解码上限校验并写入一帧,代价与 artifact 大小无关;文件对象被替换,或长度与游标不符(截断、撕裂尾部、追加的外来字节)时退回完整扫描,沿用其失败关闭语义。同长度的原地改写不在追加时检测——与 dsh appendLines 一样,这类损坏留给下一次读取的帧校验和与格式校验发现,属于 owner-only 威胁模型之外。首次写打开、写入失败与 RepairTailAsync 之后都没有游标,下一次追加完整扫描一次。SessionStoreRevision 是 provider-qualified 的 opaque fence;Coordinator 只能原样带回 RepairTailAsync,不能解释其内容。读后若 artifact 或 next seq 已变化,repair 立即以 conflict 失败。
空 batch 是 no-op,不会单独 materialize header。选中的 Provider 一旦失败就向上失败;没有 fallback、mirror 或双写路径。
DeleteAsync 只服务用户显式删除(Web 侧栏批量删除经 Tether 宿主扩展 tetherSessions/delete 调用):provider 在删除期间持有与写路径同一把跨进程会话锁,另一 owner 正在写时以 SessionPersistenceLockedException 拒绝;稳定缺席返回 false;删除不解析 header,任何格式代都能删。未声明删除能力的 provider 走默认实现(NotSupportedException),不会被误当成已删除。provider 不观察 live Session/Agent,所以宿主先检查 activity(与归档同一 workspace/session-activity 询问,运行中的会话拒绝删除),再经 IAgentRegistry.RetireAsync 退役该 id 的 live Agent(委托持有 handle 的 factory,等 teardown 与 final drain 完成),并在物理删除期间占住 registry 的 cold-start flight——并发 resolve/follow 只会等到它以 not-found 失败,删除窗口里不会把会话重新唤醒。根会话连同全部 subagent 后代删除(后代先删);日志删除之后才清理 workspace 归属、置顶/归档集合与检索索引,并经 api-session/removed 让每个已连接浏览器摘掉该行。
会话头是日志之外的身份
public sealed class SessionHeader
{
public int Version { get; } // 事件词汇表版本,当前 SessionFormat.Version = 15
public SessionId Id { get; }
public long CreatedAt { get; } // Unix 毫秒
public string? Cwd { get; } // 工作区
public string? Composition { get; } // 组合文档身份
public string? Profile { get; }
public string? Preset { get; }
public SessionLogOffset? InheritedEventCount { get; }
public SessionSubagentOrigin? SubagentOrigin { get; }
}
Version 只在事件词汇表发生破坏性变更时才递增,并且刻意与 SQLite 的 schema 版本、application id 区分开——三者变更的理由不同,混在一起会导致”改了存储细节却要求所有旧会话不可读”。
版本 9 采用强类型的 SessionSeq、SessionLogOffset 与 SessionSeqCursor 位置词汇,并把 fork cut 明确为 InheritedEventCount;随后版本随 agent-teams 消息词汇、复刻客户端事件面、权限初始化事实与审批最终词汇的收敛递增;版本 14 把 system prompt 迁为 system/message surface 节点并让 request/header 去 system 化;版本 15(issue #332,上游 V4 目标形状对齐)把 user/message/inbox/spliced 的 source 从封闭枚举换成 producer-owned 对象(kind 为非空字符串、plugin 值退役、未知 kind 的全部字段逐字节保留,form ∈ {instructions,catalog,snapshot,notice,relay,recall} 带各自的必填字段),给 turn/end reason 词汇补 forked,让 subagent/catalog 的 version 域拓宽到 {0,1} 并在 v1 引入 mode:'unknown',并新增可选 session/end-seed.inherited 标签。按 pre-release 规则不为旧事件格式增加兼容层。
因此 load/append 对任何其它格式代都 fail closed,而 listing 仍然宽容:一条异代会话不会让整个列表失败,Query 语料也照常带着它的 Version。上游在打开时沿迁移链升级旧格式,所以它的会话列表照常展示旧会话;Tether 没有迁移链,浏览器面(侧栏 session/list、session/search 的可见集、会话引用候选)只露出 Version == SessionFormat.Version 的会话——展示一条点开即报 session format N is unsupported 的会话没有意义。异代日志仍原样留在磁盘上,不会被隐藏动作改写或删除。冷会话 projection cache 缺失时,session/list 对小日志(≤ 64 条事件)做有界只读探测以判定 blank(若无 turn/start 且已至末尾则为 blank),大日志或探测失败保持上游”未知即可见”(blank: false)。
词汇变更按固定分类规则登记(与 dsh persistence-changes 决策同形,但 tether 版本线自有、与 dsh V3 无对应):
| 分类 | 变更 | SessionFormat.Version |
|---|---|---|
| same-version | 新增可选 event-body 属性;required→optional;新增普通事件类型 | 不递增 |
| version-bump | optional→required;新增 required 属性;payload 类型变更;删除或重命名事件;任何 header/envelope 结构变化 | 递增 |
新事件类型默认 required-on-read:SessionEventType<T> 的 requiredOnRead 缺省 true,旧读者遇到未知 required 事件即拒绝该日志;显式 requiredOnRead: false 的类型以 ignorable envelope 写入、允许旧读者跳过。envelope 的 ignorable 由类型声明派生,SessionEventMetadata.Ignorable 只能确认、不能反言;可选事件不得是 surface 事件。tests/Tether.Bundles.Tests/session-event-vocabulary.txt 快照门禁比对全部 Tether*.dll 声明的描述符,词汇的任何结构差异都必须同步登记快照并在提交中声明分类。
same-version 扩展登记(版本内新增可选字段、不递增 Version):
| 事件 | same-version 变更 |
|---|---|
| hook/result | 2026-09-27:新增可选 evolutionArtifactId、evolutionGeneration、operationalFailure,由可信 Hook 配置与运行器填充。旧事件缺省不归因、不推断故障;主动 deny/block 不等于运行故障。无新事件名,SessionFormat.Version 不递增。 |
tool/result | 版本 13 内新增可选顶层 error{name,code,reason?}(对应上游 persistence-changes/2026-09-12-auto-review-error-metadata):无该字段的旧数据照常读取;读取时整个 error 从 typed message 中丢弃、只留在 durable data 里,reason 永不进入模型可见内容。error 只允许与 tether.tool.failure isError 标记同现,写入与读取都校验该标记。 |
subagent/catalog | 版本 14 内新增普通事件类型(required-on-read):parent 日志记录 direct-child 发现事实 {version,childId,childCreatedAt,mode,label?}(上游 subagent/catalog v0,label 缺席不写出)。one-shot 在 provider 返回本地 child 后追加,continuable 在初始 prompt 准入后追加;subagentCatalog 投影是唯一读取端。 |
tool/ptc-dispatch-start / tool/ptc-dispatch | 版本 14 内新增两个 required-on-read 事件类型(对应上游 tool/ptc-dispatch*):记录 run_code 嵌套 sub-call 的 start 与 settle;两者都是 log 类普通事件,旧读者遇到会拒绝该日志。 |
user/message | 版本 14 内新增可选 files[{attachmentId,name,bytes,order}](对应上游 generic file upload,FileAttachmentRef refs-only 形态):无该字段的旧数据照常读取;空数组不写出(files 缺席语义等于无文件)。 |
inbox/spliced | 版本 14 内 inserted[] 各项新增可选 files[{attachmentId,name,bytes,order}]:与 user/message.data.files 同形,排队中的同源 user 消息携带相同的 refs-only 文件块;缺席语义同上。 |
image/offload | 版本 14 内新增普通事件类型(required-on-read、requiresMessageProjection,对应上游 compaction-image-offload 的 durable 修复事件):{targets:[{seq,imageIndexes[]}]} 把当前 surface 节点上的图片 occurrence 标记为已卸载;事件自身不进 surface,语义由注册的 message projection 解释,无投影时 fold 失败关闭,旧读者必须拒绝以免已卸载图片在回放中复活。 |
deliverables/presented | 版本 14 内新增普通事件类型(required-on-read、log-only,对应上游 deliverables/presented):{turn,callId,files[{path,description?}]} 记录 present 工具成功结算后声明的交付文件;只在非错误 outcome 后追加,永不进模型可见内容。 |
workspace/changes | 版本 14 内新增普通事件类型(required-on-read、log-only,对应上游 workspace/changes):{turn} 标记一次 turn 工作区变更记录已完成,summary/diff 由 IWorkspaceChanges 服务按事件 seq 读回(非 durable 负载);在 agent/turn-stopping 追加,turn/end 前仍有迟到的 tool result 结算时再追加一次。 |
user/message | 版本 14 内 data.source 的 webhook 形态新增可选 {provider,source,deliveryId,ruleId}(对应上游 webhook delivery provenance,source.kind: 'webhook' + InboxMessageProvenance.Webhook):无该字段的旧数据照常读取;四字段为 webhook source 的闭合标识组,缺席语义等于非 webhook 来源。 |
session/end-seed | 版本 15 内新增可选 inherited 布尔标签(issue #332):只写出 true,标记该行是 fork 种子构造的 end-seed;无该字段的旧数据照常读取,false 写入被拒。 |
file-trigger/observed | 版本 15 内翻转为只读可选(required→optional,issue #329):随仓库发布的树外插件包运行在可回收 ALC 下,事件声明为 requiredOnRead: false,写入 ignorable envelope;旧读者遇到未知该事件可跳过,卸载插件后既有会话日志保持可读。 |
git-checkpoint/disabled / git-checkpoint/created / git-checkpoint/failed / git-checkpoint/dirty-repo / git-checkpoint/rolled-back | 版本 15 内翻转为只读可选(required→optional,issue #329):git-checkpoint 全部 5 个 log-only 事件声明为 requiredOnRead: false,写入 ignorable envelope;旧读者遇到可跳过。 |
notify/failed / notify/suppressed | 版本 15 内翻转为只读可选(required→optional,issue #329):notify 全部 2 个 log-only 事件声明为 requiredOnRead: false,写入 ignorable envelope;旧读者遇到可跳过。 |
tool-workflow/run-start / tool-workflow/agent-start / tool-workflow/agent-end / tool-workflow/run-end | 版本 15 内新增四个普通事件类型(required-on-read,issue #341):workflow 顶层工具调用(exec.parent 缺席)的 durable 记录面——run-start {runId,name}、agent-start {runId,seq,label,phase?,childId}、agent-end {runId,seq,outcome}、run-end {runId,stopReason};旧读者遇到会拒绝该日志。 |
request/failed | 版本 15 内新增普通事件类型(optional、log-only,对应上游 assistant/attempt):{turn,step,failure{message,code,offloadImages?}} 结算一次已写出 request/start 却没有提交 assistant/message 的失败模型尝试——流建立前或流中途的非取消失败,重试与终态失败都写,由 Agent 在 request-error waterfall 之前追加,因此总在 llm/retry 之前。上游把 attempt 流内嵌为 stream;Tether 的流是一等 assistant/chunk 行,本尝试的流就是同 (turn, step) 最近一条 request/start 之后的 chunk(非 surface 事件不带 sourceEventSeqs)。取消不是失败,不写本事件。web wire 把它映射成上游 assistant/attempt {turn,step,stream},流末尾补 adapter 形状的 error finish。名字刻意不复用 assistant/attempt:不认识它的旧宿主把可选事件原样透传给浏览器,上游已知名字配上没有 stream 的载荷会让客户端崩溃,Tether 自有名字只会被当作未知可选事件忽略(页脚照旧不披露)。 |
v0.25 架构与适配器持久化核对结论:v0.25 MEAI adapter 经核对无 session 事件/header/envelope 差异,无需 same-version/version-bump 条目(
FunctionCallContent的tether.llm.raw-tool-arguments与TextReasoningContent原生复用已存在的AssistantMessageEventData与tool/result载体,物理存储与读取完全向后兼容)。
CreateAsync 的物理行可以推迟到首次追加才写,因此 ListAsync 不会列出从未追加过的会话:没写过事件的会话在存储层不算存在。
一旦 session 已经 materialize,durable header 的 CreatedAt/version/id/cwd/composition/profile 就是唯一 authority。persistence 插件卸载再重挂时会先读回并复用这六个字段;首次 append 前尚未 materialize 的 exact Session 也由 weak lifetime slot 保住第一次生成的 header。发行 Boot 把绝对根 composition source(与 ICompositionInfo.ConfigSource 同义)和已解析 profile 写入后两项,因此换 composition 或 profile 恢复同一 durable Session 会在 publication 前失败。故意改动 identity 会在写入前失败,不能通过忽略 CreatedAt、放松比较或换 Provider 绕过。
write-behind 与 flush
AttachAsync 把一个 exact live session 接上 Coordinator 的 write-behind:之后提交的事件进入该 session 的有序队列,已经 durable 的 prefix 不会被重写。
这条 lifecycle 不只覆盖 composition root。AgentLoop 创建或恢复 Session 后,必须在 setup、session/created、registry entry 与任何对外 publication 之前 awaited attach;attach 失败属于同一个 publication transaction,保留 primary failure 并回滚 writer、listener、reservation、Agent 与 Session。Coordinator 重激活时先订阅 agent/created 再枚举 registry snapshot,并按 exact Session reference 合并两条路径,因此 snapshot 窗口内发布的 Agent 不会漏接。
FlushAsync 排空写队列——传 id 只排空一个会话,省略则排空全部活会话。需要”确保已落盘”的场合(退出前、快照前)必须显式 flush,因为 write-behind 本身不保证时点。
RetireAsync 是 writer ownership 的最终交接:只有 final drain 成功后才释放 exact session 的 writer;失败则保留 ownership,让 Coordinator disposal 重试,避免 publication rollback 后留下半退休 writer。
Agent teardown、publication rollback 与 HMR disposal 共享同一个 awaited retirement boundary。persistence-only HMR 重挂时,同一 service key 下的新 exact service generation 会在 snapshot attach 后原子替换 stale finalizer,而不会执行旧 callback;same-id replacement 只有在旧 exact Session 的 final drain/retire 完成后才能取得 writer,provider 行重挂也不会把仍在飞行中的 writer 转交给另一个 Session。
语义持久化 checkpoint
write-behind 决定事件何时落盘,但不保证副作用之前落盘。base 组合默认挂载的 session-checkpoint policy(Tether.Session.Checkpoint)补上这道边界:在三个既有 seam 上,先 awaited 复用 Coordinator 的 FlushAsync,完成前不放行下游——
agent/pre-step:上一步的响应/结果批次 durable 之后,本 step 才开始装配;llm/stream:request facts(request/header、request/context)durable 之后,下游适配器才被构造、调用、枚举;retry 的每一次 attempt 同样先过这道 fence;- 顶层
tools/execute:call 声明 durable 之后 body 才执行;nested dispatch(在 outer body 内发起的重入)复用外层已持久化的上下文,不递归 flush。
阻塞或失败的 checkpoint 只会向上失败:不产生模型请求、不执行工具 body、不伪造成功。checkpoint 完成后立即复查 cancellation,被取消的调用返回 aborted-before-dispatch。插件只是 checkpoint 消费者——事件捕获、写入、flush 调度、finalizer 与 writer ownership 全部留在 Coordinator,不创建第二个 store。
诚实的 crash window:该策略保证的是「意图先于效果 durable」。进程在外部效果已经发生之后、result 落盘之前崩溃,该效果的 outcome 是 unknown——恢复方可以证明调用曾被声明且 durable,但不能证明效果没有发生。这里不承诺通用 exactly-once,也不会自动重试 outcome unknown 的外部效果;此类恢复必须由领域层或人来判定。Goal/Schedule/Teams 各自的领域专用 checkpoint 不受此通用边界代替。
撕裂的尾部:检视与修复是两回事
进程在一个 turn 中途崩溃时,磁盘上的日志末尾是不平衡的——turn 开着、step 可能开着、tool/call 可能没有对应的 tool/result。两个方法处理这种情况,区别很关键:
| 方法 | 磁盘 | 返回 |
|---|---|---|
InspectAsync | 完全不动。撕裂的尾部留在盘上 | 校验过的逻辑视图,合成闭合事件只存在于内存 |
LoadAsync | 持久地截断撕裂尾部并追加合成闭合事件 | 修复后的视图 |
也就是说 InspectAsync 是只读诊断,LoadAsync 才会改盘。想看看某个会话什么状态而不想改它,用前者。
PrepareAsync 在 LoadAsync 的基础上再往前一步:返回一个尚未发布的修复后会话(SessionPreparation),供冷恢复流程接管。它实现 IAsyncDisposable,释放时归还内部持有的资源;DisposeAsync 用 Interlocked.Exchange 保证只释放一次。
中断 turn 的合成闭合
修复的核心规则是:绝不改写已提交的事件,只追加。InterruptedTurnRepair.Closers 扫一遍日志,算出需要补哪些事件。
两种”未完成的工具调用”要区别对待
合成的 tool/result 分两种情况,给模型的指引完全不同:
| 常量 | 情形 | 给模型的指引 |
|---|---|---|
TOOL_NOT_STARTED | 助手声明了调用,但没有 tool/call 落盘 | 工具还没开始执行,仍需要就重试 |
TOOL_OUTCOME_UNKNOWN | tool/call 已落盘,但没有持久结果 | 结果未知,不要盲目重试 |
第二种的文本明确要求模型按工具语义判断:只有只读或幂等操作才可以重试;可能有副作用的,先核实外部状态或询问用户。
这个区分是正确性问题,不是措辞讲究
tool/call 已落盘意味着工具本体可能已经执行完了——文件可能已经写了,命令可能已经跑了。
把这种情况和"根本没开始"混为一谈,会让恢复后的模型重复执行有副作用的操作。
合成的 tool/result 会通过 sourceEventSeqs 引用原始 tool/call 的 seq(仅在确实已落盘时),因此事后能精确对应到是哪一次调用被中断。
失败分类是稳定契约
| 异常 | 含义 |
|---|---|
SessionPersistenceMissingException | 指定 session 尚未 materialize |
SessionPersistenceCorruptionException | 已提交的前缀读不出来,或 seq 有空洞 |
SessionFormatUnsupportedException | schema 或事件格式版本是本构建不读的 |
SessionPersistenceConflictException | expected seq、revision、header identity 或 exact writer owner 已变化 |
SessionPersistenceLockedException | 另一个进程已持有写者租约 |
SessionPersistenceIOException | 已选 Provider 的本地 I/O 失败 |
这些类别分开是有意的:损坏需要人工介入,版本不支持需要升级或降级,conflict 要重新读取后再决定,租约冲突表示另一个 writer 正在使用同一 authority。任何一类都不会触发自动 Provider fallback。
本地 artifact 的 owner-only 边界
两个 Provider、projection checkpoint、用户设置、凭据和附件都复用 Tether.LocalStorage,不各自维护 permission helper。POSIX 创建 syscall 请求 0700/0600,且不修改进程级 umask;restrictive umask 移除目录权限时,helper 会在创建 descendant、database/log/checkpoint、settings、attachment、credential 或 lease 前,通过稳定 parent 与 no-follow exact-entry handle 把刚创建的同一目录归一为 0700,并复核 owner/type/identity/parent chain/mode。敏感 regular file 在首次写入前通过 exact handle 归一并复核为 0600,因此 permissive 与 restrictive umask 都不会产生公开窗口。Windows 由 helper 创建的 entry 从创建时就是 exact current-user owner 与 protected private DACL,native SQLite sidecar 从继承时就只有 current-user DACL,并立即在原 handle 上归一 owner/DACL。重开时,归当前 owner 但过宽的权限会在读取或写入前收紧;wrong owner、symlink/reparse point、错误 entry type 或不可验证 ACL 直接失败,不能触发 Provider fallback。
| 范围 | 受保护的本地产物 |
|---|---|
| JSONL | root、project、session directories;session.jsonl.zstd、.session.jsonl.zstd.staging-*、root .writer |
| SQLite | database、-wal、-shm、-journal、.writer |
| Projection | .projcache database 及其 WAL/SHM/journal |
| Settings | profile cordis.patch.yml 与 profile 目录 writer lock |
| Credentials | 托管 credential 文件及其 private parent |
| Attachment | object root、content-addressed objects 与 publication staging |
JSONL 首次发布在同一 private session directory 中写 staging,flush 后以 no-overwrite 原子 rename 发布,并在前后复核 parent/entry identity。SQLite 主 store 持有独占 writer lease,所以 database、lease 与已捕获 sidecar generation 都能跨 open 保持 exact fence。Projection cache 允许其它合法 SQLite connection 并发,没有独占 lease;它对主 database 与 parent 保持 exact fence,并在 open 前后分别收编当时存在且通过 owner/DACL/type 验证的 native sidecar generation。
POSIX 以 effective UID 作为 owner-only 安全主体。cleanup 在 stable parent fd 下以 atomic no-replace quarantine rename 线性化 target path 删除:线性化前的 replacement 会 fail closed,线性化后的 target winner 不会被删除,restore 也绝不覆盖 winner。Linux/macOS 的最终 unlinkat 只绑定 parent/name,并非 expected-inode conditional unlink;线性化后恶意 same-euid actor 对随机 quarantine namespace 的篡改不在 #68 威胁模型内。cleanup 前已观察到的 identity 变化仍 fail closed。Windows 则持有 exact child handle 并按 handle 删除,不使用这项 POSIX 收窄。
Windows SQLite 原生创建 WAL/SHM 时,提升权限的 token 可能先把 token default owner 写入 metadata。唯一的窄例外是:sidecar 位于已捕获的 private parent 中,DACL 只给 exact current user 有效 FullControl;helper 随即在同一个 no-follow handle 上把 owner 与 protected DACL 归一。通用文件验证仍只接受 exact current-user owner,不把 Administrators 或其它 principal 当作敏感文件 owner。
JSONL/Zstandard Provider
权威 artifact 路径是:
<root>/<project-key>/<session-id>/session.jsonl.zstd
没有 cwd 的 header 使用 _no-cwd;project key 与 session segment 都经过 bounded encoding,不能通过 ..、分隔符或超长输入逃出 root。Boot 默认把 root 解析为 <home>/<profile>/sessions,也可由 --session-root 显式覆盖。
删除只移除 session.jsonl.zstd:删除前捕获 identity,经 Tether.LocalStorage 的 stable-parent no-replace quarantine rename 线性化,替换一律 fail closed;session.lock 与会话目录保留(锁文件永不删除),列表与读路径以 artifact 缺席判定会话不存在,删除成功后释放本 store 持有的该会话锁句柄。
首个独立 zstd frame 只含 header;每个 durable batch 再占一个独立 frame。完整 frame 就是 commit boundary:只有文件末尾不完整的 frame 属于 torn tail;任何完整 frame 的解压、UTF-8/JSON、shape 或 seq 错误都属于 committed corruption。崩溃撕裂只会留下最后一次写入的前缀,所以被判 torn 的区域里若还能找到完整、可解压且 content checksum 通过的 frame,或任何 block 声明的 Block_Size 超过 128 KiB,一律按 committed corruption fail closed,不得 repair 截断。Provider 可以物理 packing assistant/chunk,但读出时必须逐事件恢复原始 seq、time、turn、step、type 与 payload。
Coordinator 对 logical prefix 的 envelope、sequence、surface 与 source-seq 字段继续逐项严格比较,payload 则按 JSON 结构比较;对象键序与等价字符转义不会把 dsh 式 packed decode 误判成另一条事件。这个语义不参与 provider revision、expected-next-seq 或 repair CAS,也不会把完整 frame/row 的解压、JSON、shape 或 seq 错误降级为可接受输入。
SQLite Session Provider 已下线(#150)
first-party SQLite Session provider 与 session-sqlite composition patch 已按 #150 的 owner 决策(FOLLOW)移除:JSONL 是唯一 first-party ISessionEventStore 实现,ISessionEventStore 与 Coordinator 保持 provider-neutral。out-of-tree provider 仍可通过替换 stable id 为 session-store 的 row 接入,Coordinator 与 projection/resume/title 等 Consumer rows 保持不变;每个 composition 始终恰好一个 ISessionEventStore。
既有 .sqlite session 库不再被打开或迁移:需要保留会话的用户应在升级前用 provider-neutral logical export 导出。Tether.Storage.Sqlite(domain-KV)与 Session Query 的 FTS 观察索引不受影响。
与会话模型的关系
这条 seam存储的就是会话与事件溯源里描述的那套不可变事件。持久化不改变任何投影语义:FoldSurface 与 DeriveMessages 仍然是对日志的纯函数重放,只是日志现在可以来自磁盘而不只是内存。
冷恢复的完整流程(PrepareAsync 之后如何发布 Agent)见 Agent 生命周期的注册表一节。