diff --git a/.agents/notes/implemented/architecture/2026-08-25-session-observations-and-projection-owned-client-state.i18n.yaml b/.agents/notes/implemented/architecture/2026-08-25-session-observations-and-projection-owned-client-state.i18n.yaml index 0b38f31d9f..53fdc566f2 100644 --- a/.agents/notes/implemented/architecture/2026-08-25-session-observations-and-projection-owned-client-state.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-08-25-session-observations-and-projection-owned-client-state.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-08-25-session-observations-and-projection-owned-client-state.md -2026-08-25-session-observations-and-projection-owned-client-state.md: 0fa69dde28eadc860d426ea511f4aaf1356c7afa -2026-08-25-session-observations-and-projection-owned-client-state.zh.md: ab601d7e6839eba6370564a25f92aa5cef99ca40 +2026-08-25-session-observations-and-projection-owned-client-state.md: f1bb2d42d7f5d8e00c1a13a2d5297f7342bdd4c3 +2026-08-25-session-observations-and-projection-owned-client-state.zh.md: 15e55f17b7fa0e38a3c298e89beabf2605b60ddc diff --git a/.agents/notes/implemented/architecture/2026-08-25-session-observations-and-projection-owned-client-state.md b/.agents/notes/implemented/architecture/2026-08-25-session-observations-and-projection-owned-client-state.md index 0fa69dde28..f1bb2d42d7 100644 --- a/.agents/notes/implemented/architecture/2026-08-25-session-observations-and-projection-owned-client-state.md +++ b/.agents/notes/implemented/architecture/2026-08-25-session-observations-and-projection-owned-client-state.md @@ -94,7 +94,7 @@ A Client-visible fact belongs to `SessionProjectionMap` when its value is determ The three projection delivery states have different meanings: -- A Session-list hint is optional, partial, and possibly stale. A missing key means unknown, so a list consumer must not invent an empty value or deployment default. +- A Session-list hint is optional, partial, and unvalidated against the current log extent. It may be stale or claim a cut removed by crash repair. A missing key means unknown, so a list consumer must not invent an empty value or deployment default. - A follow opening baseline is the complete set of client-visible projection capabilities registered at its cursor. A missing key there means the capability is absent for that Host composition. - An explicit `null` is a domain-computed no-value result. It is distinct from a missing list hint and survives JSON transport. @@ -108,7 +108,7 @@ These distinctions prevent one overloaded `undefined` from representing cache mi | Follow opening baseline | Complete for the Host composition | Exact opening cursor | Capability absent | | Projection frame | One whole key | Event sequence carried by the frame | Not applicable | -The Client stores one row per key with its provenance and sequence number. A list hint fills or advances only a tentative row. A complete opening baseline replaces or clears tentative rows even when a cache hint claims a higher sequence, while preserving an authoritative frame newer than the opening cut. Frames use higher-sequence-wins and promote an equal-sequence hint to authoritative state. A replacement control baseline first discards rows beyond its durable cut, then installs its complete values. +The Client stores one row per key with its provenance and sequence number. A list hint fills or advances only a tentative row. The first authoritative frame replaces a tentative hint regardless of its claimed sequence; later authoritative frames use higher-sequence-wins. A complete opening baseline replaces or clears tentative rows while preserving an authoritative frame newer than the opening cut. A replacement control baseline first discards rows beyond its durable cut, then installs its complete values. The list view reads the same per-Session store as the opened Session. Hints can populate title, preset, and other list presentation before follow completes; the opening baseline then converges that state without creating a second summary-only authority. diff --git a/.agents/notes/implemented/architecture/2026-08-25-session-observations-and-projection-owned-client-state.zh.md b/.agents/notes/implemented/architecture/2026-08-25-session-observations-and-projection-owned-client-state.zh.md index ab601d7e68..15e55f17b7 100644 --- a/.agents/notes/implemented/architecture/2026-08-25-session-observations-and-projection-owned-client-state.zh.md +++ b/.agents/notes/implemented/architecture/2026-08-25-session-observations-and-projection-owned-client-state.zh.md @@ -94,7 +94,7 @@ Registry 拥有 fold state;各领域拥有自己的 `init`、`apply`、`view` Projection 的三种交付状态含义不同: -- Session-list hint 是可选、部分且可能陈旧的数据。key 缺失表示未知,因此 list 消费方不得自行补成空值或部署默认值。 +- Session-list hint 是可选、部分且未经当前日志范围校验的数据。它可能陈旧,也可能声称一个已被崩溃修复移除的 cut。key 缺失表示未知,因此 list 消费方不得自行补成空值或部署默认值。 - Follow opening baseline 是其 cursor 上所有已注册 Client 可见 projection capability 的完整集合。此处缺少 key 表示当前 Host composition 不具备该 capability。 - 显式 `null` 是领域计算出的无值结果。它不同于 list hint 缺失,并且能够完整通过 JSON transport。 @@ -108,7 +108,7 @@ Projection 的三种交付状态含义不同: | Follow opening baseline | 对当前 Host composition 完整 | 精确 opening cursor | Capability 不存在 | | Projection frame | 单个完整 key | Frame 携带的 event sequence | 不适用 | -Client 为每个 key 保存带来源与 sequence number 的一行。List hint 只能填充或推进暂定 row。完整 opening baseline 即使面对声称更高 sequence 的 cache hint,也会替换或清除暂定 row,同时保留晚于 opening cut 的权威 frame。Frame 继续使用 higher-sequence-wins,并会把相同 sequence 的 hint 提升为权威状态。Replacement control baseline 会先丢弃超出其 durable cut 的 row,再安装完整值。 +Client 为每个 key 保存带来源与 sequence number 的一行。List hint 只能填充或推进暂定 row。首个权威 frame 无论暂定 hint 声称的 sequence 多高都会替换它;后续权威 frame 之间才使用 higher-sequence-wins。完整 opening baseline 会替换或清除暂定 row,同时保留晚于 opening cut 的权威 frame。Replacement control baseline 会先丢弃超出其 durable cut 的 row,再安装完整值。 List view 与已打开 Session 读取同一个 per-Session store。Hints 可以在 follow 完成前填充 title、preset 和其他 list presentation;opening baseline 随后收敛这份状态,而不会建立第二套 summary-only authority。 diff --git a/docs/subsystems/session-projection.i18n.yaml b/docs/subsystems/session-projection.i18n.yaml index 367a2ce8cd..1fd53e148c 100644 --- a/docs/subsystems/session-projection.i18n.yaml +++ b/docs/subsystems/session-projection.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-projection.md -session-projection.md: 289e69e4f03020beed835ff6c7012a4b6934dfe3 -session-projection.zh.md: 14b0acd8a7ee3ef33e85ec122fb29625ba352399 +session-projection.md: f8cf86c17079b4fbfb528fede1815feb0b4f6918 +session-projection.zh.md: 0e61158b0ab37fe8423d2f7c38e9edddbf2cd478 diff --git a/docs/subsystems/session-projection.md b/docs/subsystems/session-projection.md index 289e69e4f0..f8cf86c170 100644 --- a/docs/subsystems/session-projection.md +++ b/docs/subsystems/session-projection.md @@ -272,10 +272,11 @@ restoreFloor(checkpoint: ProjectionCheckpoint): number | undefined /** * View a checkpoint's rows without any log read: for every registered * client-visible unit whose row's `ver` matches, serve the schema-validated - * `view` of the schema-validated stored state; mismatched, malformed, or absent rows leave their key - * absent (a cold or listing consumer treats it as not-yet-available and a - * fuller read path refolds it). The zero-I/O rung of the read ladder — - * values are as stale as their rows, never wrong. + * `view` of the schema-validated stored state; mismatched, malformed, or + * absent rows leave their key absent. The current log extent is unknown, so + * returned values are tentative hints: a row may trail the log or overreach + * a crash-repaired truncation. Exact restore validates the cut before using + * a row as authoritative state. * @param checkpoint - persisted rows for one session (possibly stale or empty). * @param keys - optional wire keys to view. * @returns whole values per key with a usable row; empty when none. diff --git a/docs/subsystems/session-projection.zh.md b/docs/subsystems/session-projection.zh.md index 14b0acd8a7..0e61158b0a 100644 --- a/docs/subsystems/session-projection.zh.md +++ b/docs/subsystems/session-projection.zh.md @@ -272,10 +272,11 @@ restoreFloor(checkpoint: ProjectionCheckpoint): number | undefined /** * View a checkpoint's rows without any log read: for every registered * client-visible unit whose row's `ver` matches, serve the schema-validated - * `view` of the schema-validated stored state; mismatched, malformed, or absent rows leave their key - * absent (a cold or listing consumer treats it as not-yet-available and a - * fuller read path refolds it). The zero-I/O rung of the read ladder — - * values are as stale as their rows, never wrong. + * `view` of the schema-validated stored state; mismatched, malformed, or + * absent rows leave their key absent. The current log extent is unknown, so + * returned values are tentative hints: a row may trail the log or overreach + * a crash-repaired truncation. Exact restore validates the cut before using + * a row as authoritative state. * @param checkpoint - persisted rows for one session (possibly stale or empty). * @param keys - optional wire keys to view. * @returns whole values per key with a usable row; empty when none. diff --git a/packages/api/session-controller/src/client/sessions/projection-store.ts b/packages/api/session-controller/src/client/sessions/projection-store.ts index e48f24679b..52baf936c8 100644 --- a/packages/api/session-controller/src/client/sessions/projection-store.ts +++ b/packages/api/session-controller/src/client/sessions/projection-store.ts @@ -70,12 +70,12 @@ interface Channel { * One session's projection values. A list hint can fill or advance only a * tentative row. A complete baseline replaces or clears every tentative row, * regardless of its claimed sequence, while preserving authoritative frames - * newer than the baseline cut. Frames use higher-sequence-wins after promoting - * an equal-sequence hint to authoritative state. A key the store has never seen - * reads `undefined` (capability absent). Faces are identity-stable per key - * (create-on-demand, cached) so the React side binds each exactly once; the - * store-level channel (`subscribeAny`) serves coarse consumers (the manager's - * list projection reads the `title` key). + * newer than the baseline cut. The first authoritative frame replaces any + * tentative hint; later authoritative frames use higher-sequence-wins. A key + * the store has never seen reads `undefined` (capability absent). Faces are + * identity-stable per key (create-on-demand, cached) so the React side binds + * each exactly once; the store-level channel (`subscribeAny`) serves coarse + * consumers (the manager's list projection reads the `title` key). */ export class ProjectionValueStore { private readonly rows = new Map() @@ -152,7 +152,7 @@ export class ProjectionValueStore { */ apply(key: string, value: unknown, seq: number): void { const row = this.rows.get(key) - if (row !== undefined && (seq < row.seq || (seq === row.seq && row.provenance === 'authoritative'))) return + if (row?.provenance === 'authoritative' && seq <= row.seq) return this.rows.set(key, { value, seq, provenance: 'authoritative' }) this.changed(key) } diff --git a/packages/api/session-controller/tests/projection-store.client.spec.ts b/packages/api/session-controller/tests/projection-store.client.spec.ts index c0231c6786..9cdc26dd3c 100644 --- a/packages/api/session-controller/tests/projection-store.client.spec.ts +++ b/packages/api/session-controller/tests/projection-store.client.spec.ts @@ -157,11 +157,13 @@ describe('Session tail-page seeding', () => { expect(session.projections.get('test/marks')).toEqual({ marks: ['authoritative'] }) }) - it('preserves an authoritative frame that lands while opening waits for its older baseline', async () => { + it('preserves an authoritative frame below a higher hint while opening waits for its older baseline', async () => { const api = new FakeApiClient() const history = deferred>>() api.onHistory = () => history.promise - const session = new Session(SID, api, fakeRemote(api)) + const projections = new ProjectionValueStore() + projections.prewarm('test/marks', { marks: ['hint-9'] }, 9) + const session = new Session(SID, api, fakeRemote(api), { projections }) const opening = session.open() session.projections.apply('test/marks', { marks: ['live-3'] }, 3) diff --git a/packages/extensions/tool-cordis/src/api-catalog.ts b/packages/extensions/tool-cordis/src/api-catalog.ts index 37d2c3468d..e83e6d7553 100644 --- a/packages/extensions/tool-cordis/src/api-catalog.ts +++ b/packages/extensions/tool-cordis/src/api-catalog.ts @@ -1479,7 +1479,7 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [ }, { signature: 'viewCheckpoint( checkpoint: ProjectionCheckpoint, keys?: readonly Extract[], ): Partial', - description: 'View a checkpoint\'s rows without any log read: for every registered client-visible unit whose row\'s `ver` matches, serve the schema-validated `view` of the schema-validated stored state; mismatched, malformed, or absent rows leave their key absent (a cold or listing consumer treats it as not-yet-available and a fuller read path refolds it). The zero-I/O rung of the read ladder — values are as stale as their rows, never wrong.', + description: 'View a checkpoint\'s rows without any log read: for every registered client-visible unit whose row\'s `ver` matches, serve the schema-validated `view` of the schema-validated stored state; mismatched, malformed, or absent rows leave their key absent. The current log extent is unknown, so returned values are tentative hints: a row may trail the log or overreach a crash-repaired truncation. Exact restore validates the cut before using a row as authoritative state.', parameters: [{ name: 'checkpoint', description: 'persisted rows for one session (possibly stale or empty).' }, { name: 'keys', description: 'optional wire keys to view.' }], returns: 'whole values per key with a usable row; empty when none.', }, diff --git a/packages/session/session-projection-cache/README.i18n.yaml b/packages/session/session-projection-cache/README.i18n.yaml index ce6d1644e2..39abe56528 100644 --- a/packages/session/session-projection-cache/README.i18n.yaml +++ b/packages/session/session-projection-cache/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-projection-cache/README.md -README.md: bddff27bf89c31049c72ed2e8027f452cd6fbae2 -README.zh.md: 3695fff30cb3c7741f448f0bb46660fd6a73a331 +README.md: 898af3feb69fcf709cdbb3d089aebabc40c278d4 +README.zh.md: 0663f6b95fa2168a9561cbda24498fefa1dfe55f diff --git a/packages/session/session-projection-cache/README.md b/packages/session/session-projection-cache/README.md index bddff27bf8..898af3feb6 100644 --- a/packages/session/session-projection-cache/README.md +++ b/packages/session/session-projection-cache/README.md @@ -4,14 +4,14 @@ English | [中文](README.zh.md) The persisted projection cache (`ctx.sessionProjectionCache`): durable checkpoints of every projection unit's state, one record per session on the domain data form (`session_projcache` domain — the shipped json backend lands it beside `workspace.json` under the configured storage root). Design authority: the [session-projection RFC](../../../.agents/notes/proposed/architecture/2026-07-27-session-projection-and-command-log.md) (persisted projection cache section). -A stored row `(key → {ver, seq, val})` is a fold shortcut, never an authority: possibly stale (`seq` says exactly how stale) but never wrong. Consequences the implementation commits to: +A stored row `(key → {ver, seq, val})` is a disposable fold shortcut, never an authority. The zero-I/O listing path can expose it only as a tentative hint: the row may lag the log, or crash repair may truncate the log below its claimed `seq`. An exact cold or opening read validates the current log extent and refolds instead of accepting a row that no longer fits. Consequences the implementation commits to: -- **Every background write is fail-soft.** A failed durable write logs a warning and keeps the cache stale; the next write or cold read self-heals. A crash between writes costs a longer tail replay, never a wrong value. +- **Every background write is fail-soft.** A failed durable write logs a warning and retains the previous row; the next write or exact cold read self-heals. A crash between writes normally costs a longer tail replay, while crash repair can turn the retained row into a tentative overreach until the exact path validates it. - **A `ver` mismatch against the live unit's `stateVersion` discards, never migrates.** A unit bump invalidates its rows at read time; the key refolds from the log. - **A row must pass the live unit's `stateSchema`.** A malformed row is omitted from the zero-I/O view and rejected by restore so the cold-read ladder refolds it from the log. - **Whole-record writes.** Each write replaces the session's full checkpoint (the registry cut is always complete), snapshotted through the lossless-JSON boundary — a unit state violating the plain-JSON contract fails loud. - **Records are bound to a log lifecycle, not just an id.** Each record stores the header identity (`createdAt`, `cwd`) it was folded from; every read validates it (the live or stored header is the witness) before accepting a row, so a deleted-then-recreated id or a persistence store swapped under a surviving cache discards the unrelated record instead of seeding phantom values. -- **The log leads, the cache follows.** A live checkpoint flushes the session's buffered events durably BEFORE the cache row lands, so a crash can leave the cache behind the log (a longer tail replay) but never ahead of it. +- **The log leads each checkpoint write.** A live checkpoint flushes the session's buffered events durably BEFORE the cache row lands, so the cache cannot lead the log when the write commits. Later crash repair may truncate the log below an existing row; exact reads detect that overreach before returning authoritative state. ## Write policy diff --git a/packages/session/session-projection-cache/README.zh.md b/packages/session/session-projection-cache/README.zh.md index 3695fff30c..0663f6b95f 100644 --- a/packages/session/session-projection-cache/README.zh.md +++ b/packages/session/session-projection-cache/README.zh.md @@ -4,14 +4,14 @@ 持久投影缓存(`ctx.sessionProjectionCache`):把每个投影单元的状态保存为检查点,基于域数据形态(domain data form)每会话一条记录(`session_projcache` 域——出厂 JSON 后端将其落在配置的存储根目录下、`workspace.json` 旁边)。设计权威:[session-projection RFC](../../../.agents/notes/proposed/architecture/2026-07-27-session-projection-and-command-log.zh.md)(persisted projection cache 一节)。 -一条存储行 `(key → {ver, seq, val})` 是折叠捷径,绝不是权威:可能陈旧(`seq` 精确说明陈旧到哪),但绝不会错。实现据此承诺: +一条存储行 `(key → {ver, seq, val})` 是可丢弃的折叠捷径,绝不是权威。零 I/O 列表路径只能把它公开为暂定 hint:该行可能落后于日志,崩溃修复也可能把日志截断到其声称的 `seq` 之前。精确冷读或打开会校验当前日志范围;若该行不再匹配,就从日志重新折叠,而不会把它当作权威值。实现据此承诺: -- **每次后台写入都 fail-soft。** 持久写失败只记一条警告并保持缓存陈旧;下一次写入或冷读自愈。两次写之间崩溃的代价是更长的尾部回放,绝不是错误的值。 +- **每次后台写入都 fail-soft。** 持久写失败只记一条警告并保留先前行;下一次写入或精确冷读会自愈。两次写之间崩溃通常只增加尾部回放,崩溃修复则可能让保留行暂时越界,直到精确路径完成校验。 - **`ver` 与当前运行单元的 `stateVersion` 不匹配即丢弃,绝不迁移。** 单元递增版本会在读取时使其行失效;该 key 从日志重新折叠。 - **存储行必须通过当前单元的 `stateSchema`。** 畸形行从零 I/O view 中省略,并被 restore 拒绝,使冷读阶梯从日志重新折叠。 - **整记录写入。** 每次写入替换该会话的完整检查点(注册表切面始终是完整的),并经无损 JSON 边界快照——违反纯 JSON 约定的单元状态会显式失败并报错。 - **记录绑定到日志生命周期,而不只是 id。** 每条记录存储其折叠来源的 header 身份(`createdAt`、`cwd`);每次读取先以活 header 或存储 header 为证验证它,再接受任何行——被删后重建的 id、或缓存幸存而持久化存储被换掉时,无关记录被整体丢弃,绝不播种幻影值。 -- **日志领先,缓存跟随。** 活会话检查点先把缓冲事件持久 flush,缓存行才落地,因此崩溃只会让缓存落后于日志(更长的尾部回放),绝不领先于它。 +- **每次检查点写入都由日志领先。** 活会话检查点先把缓冲事件持久 flush,缓存行才落地,因此写入提交时缓存不可能领先日志。后续崩溃修复可能把日志截断到现有缓存行之前;精确读取会在返回权威状态前识别这种越界。 ## 写策略 diff --git a/packages/session/session-projection-cache/src/spec.ts b/packages/session/session-projection-cache/src/spec.ts index 26de5abe73..889f99fcf2 100644 --- a/packages/session/session-projection-cache/src/spec.ts +++ b/packages/session/session-projection-cache/src/spec.ts @@ -17,9 +17,12 @@ import { defineDomain, domainTable } from '@deepseek-ai/dsh-storage-domain' * One persisted checkpoint row (the RFC's `(sessionId, key, ver, seq, val)` * minus the two record keys). `val` is the unit's internal state — plain * JSON by the unit contract; `z.json()` enforces that at the durable - * boundary. A row is never wrong, only possibly stale: `seq` says exactly - * how stale, and a `ver` mismatch against the live unit's `stateVersion` - * discards it at read time (never a migration). + * boundary. Without reading the current log extent, `seq` identifies only the + * row's original fold cut. A zero-I/O consumer treats the row as a tentative + * hint because it may trail the log or overreach a crash-repaired truncation; + * exact restore validates the cut before using it as authoritative state. A + * `ver` mismatch against the live unit's `stateVersion` discards the row at + * read time (never a migration). */ export const checkpointRow = z.object({ ver: z.number().int().nonnegative(), @@ -59,9 +62,10 @@ export const checkpointRecord = z.object({ export type CheckpointRecord = z.infer /** - * The session-projcache domain spec. Version bumps discard the whole medium - * (cache semantics: a stale or unreadable cache costs a longer tail replay, - * never a wrong value). + * The session-projcache domain spec. Version bumps discard the whole medium. + * Zero-I/O callers may use matching rows only as tentative hints; exact reads + * validate the current log extent and refold before returning authoritative + * state. */ export const projectionCacheDomainSpec = defineDomain({ name: 'session_projcache', diff --git a/packages/session/session-projection/src/index.ts b/packages/session/session-projection/src/index.ts index 24678d86ea..10a28f2432 100644 --- a/packages/session/session-projection/src/index.ts +++ b/packages/session/session-projection/src/index.ts @@ -428,10 +428,11 @@ export class SessionProjectionRegistry extends Service { /** * View a checkpoint's rows without any log read: for every registered * client-visible unit whose row's `ver` matches, serve the schema-validated - * `view` of the schema-validated stored state; mismatched, malformed, or absent rows leave their key - * absent (a cold or listing consumer treats it as not-yet-available and a - * fuller read path refolds it). The zero-I/O rung of the read ladder — - * values are as stale as their rows, never wrong. + * `view` of the schema-validated stored state; mismatched, malformed, or + * absent rows leave their key absent. The current log extent is unknown, so + * returned values are tentative hints: a row may trail the log or overreach + * a crash-repaired truncation. Exact restore validates the cut before using + * a row as authoritative state. * @param checkpoint - persisted rows for one session (possibly stale or empty). * @param keys - optional wire keys to view. * @returns whole values per key with a usable row; empty when none.