From 20bdc7d44a1b4df756e0bfb2d03813c5f4e37abb Mon Sep 17 00:00:00 2001 From: Codex Bot Date: Thu, 12 Nov 2026 12:51:30 +0800 Subject: [PATCH] docs(runtime): clarify elision and reconciliation evidence --- src/lib/canvas-runtime/elision.ts | 21 +++++++++++++++------ src/lib/canvas-runtime/history-replay.ts | 8 ++++++-- src/lib/canvas-runtime/reconcile.ts | 15 +++++++++++---- 3 files changed, 32 insertions(+), 12 deletions(-) diff --git a/src/lib/canvas-runtime/elision.ts b/src/lib/canvas-runtime/elision.ts index b42436f..6d0e445 100644 --- a/src/lib/canvas-runtime/elision.ts +++ b/src/lib/canvas-runtime/elision.ts @@ -13,7 +13,8 @@ export type ElisionProof = { readonly version: 1; readonly operationIds: readonl /** Reconstruct a closed lifetime from an empty fragment. Existing objects cannot enter this proof. */ /** 在空记录片段上重建至多 64 意图/10000 changes,验证每个对象首触及都是创建且最终全删。 - * 墓碑变化是允许保留的本地效果,不等于服务器执行过这些创建/删除。 */ + * 本函数不读 ledger 阶段、basePresence、请求或依赖,不独自证明这些操作从未发送/实际基线 absent。 + * 只做条件变化与生命周期闭合检查,不调用 registry/整图校验;临时片段可无根,最终只保留本地墓碑。 */ export function proveElision(intents: readonly OperationIntent[], baseTombstones: Readonly>): ElisionProof { if (!intents.length || intents.length > 64 || new Set(intents.map(intent => intent.id)).size !== intents.length) throw fault('invalid_elision'); const created = new Set(), canvases = new Set(); @@ -37,6 +38,7 @@ export function proveElision(intents: readonly OperationIntent[], baseTombstones } } } + // 起始墓碑只能描述本段确实创建过的元素,不能趁抵消夹带无关 ID 的生命周期水位。 if (Object.keys(baseTombstones).some(id => !created.has(id))) throw fault('invalid_elision'); const fragment: Document = { projectId: '', elements: new Map(), canvases: new Map(), tombstones: new Map(Object.entries(baseTombstones)) }; // 条件执行仍使用真实 applyChanges;before 不匹配不能因最终想删除就被忽略。 @@ -46,10 +48,12 @@ export function proveElision(intents: readonly OperationIntent[], baseTombstones } /** Longest closed prefix before an existing-object write or another unsafe precondition. */ -/** 寻找最长安全闭合前缀;遇 frozen/blocker/外部未解依赖/已有对象首写即停止扩展。 - * preceding 只允许前面已选独立真实段作为依赖屏障,不把它当作已经 applied 的回执。 */ +/** 在传入候选顺序内寻找最长闭合前缀,检查 pending/未绑请求、基线和依赖;不扫描全账本证明中间无漏项。 + * 全账本连续性还由 ledger.elide/validateElisions 复核;preceding 只给批次边界规划暂时满足前段依赖。 + * undefined 表示没找到;条件回放失败或闭合证明非法仍会抛错,不统一吞成 undefined。 */ export function findElision(intents: readonly OperationIntent[], acknowledged: Document, ledger: LedgerSnapshot, preceding: ReadonlySet = new Set()): ElisionProof | undefined { const selected: OperationIntent[] = [], seen = new Set(), canvases = new Set(), bases: Record = Object.create(null); + // 候选超过 64 直接不寻找,不自动截取前 64;真正闭合后的 changes 上限由 proveElision 检查。 if (intents.length > 64 || !intents.some(intent => intent.changes.some(change => change.kind !== 'field' && change.after === null))) return undefined; // 每次片段归空时保存最佳前缀;后续又创建未删对象,不影响之前已找到的闭合边界。 let bestLength = 0, bestBases: Record = {}; @@ -57,6 +61,7 @@ export function findElision(intents: readonly OperationIntent[], acknowledged: D for (const intent of intents) { const record = ledger.operations.get(intent.id); if (!record || record.phase !== 'pending' || record.requestId || record.blockers.length || !equal(record.intent, intent) || intent.operationType !== 'document.change' || intent.operationSchemaVersion !== 1) break; + // resolved 只表示依赖已被本地解除,不在此区分 applied/elided/retired 的业务理由;preceding 不是远端执行证明。 if (intent.dependencies.some(id => !selected.some(intent => intent.id === id) && !preceding.has(id) && ledger.operations.get(id)?.phase !== 'resolved')) break; let safe = true; for (const change of intent.changes) { @@ -78,7 +83,8 @@ export function findElision(intents: readonly OperationIntent[], acknowledged: D } /** Keep a later closed segment mutable while earlier real edits obtain their own receipt. */ -/** 若后面存在可抵消段,限制前面的真实请求前缀,避免一次冻结把可抵消段也锁死。 */ +/** 若后面存在可抵消候选段,缩短前面的真实请求前缀,避免本次 freeze 锁住后段。 + * 这里只返回前缀长度,不标 ledger.elided、不证明前段已成功;原前缀可直接抵消时交其它钩子处理。 */ export function elisionBatchLimit(intents: readonly OperationIntent[], acknowledged: Document, ledger: LedgerSnapshot): number { if (!intents.some(intent => intent.changes.some(change => change.kind !== 'field' && change.after === null)) || findElision(intents, acknowledged, ledger)) return intents.length; const preceding = new Set(); @@ -99,7 +105,8 @@ export function assertElisionLineage(proof: ElisionProof, intents: readonly Oper /** Validate persisted local facts independently of the current (possibly newer) server baseline. */ /** 恢复时按逻辑顺序复验持久抵消组:成员连续、依赖闭合、anchor 唯一、证明与水位一致。 - * 不依赖当前可能更新过的 ACK 基线来重新猜历史 absent 事实。 */ + * 不依赖当前可能更新过的 ACK 基线来重新猜历史 absent 事实,而是复核保存的 basePresence/proof。 + * 它不向服务器查询当年是否接收过请求;此前的持久记录捕获与身份校验仍由恢复边界负责。 */ export function validateElisions(ledger: Pick): void { const ordered = [...ledger.operations.values()].sort((a, b) => a.intent.sequence - b.intent.sequence), positions = new Map(ordered.map((record, index) => [record.intent.id, index])); const previous = new Map(); @@ -128,7 +135,9 @@ export function validateElisions(ledger: Pick): vo } /** Local high-water marks belong to working state, never to the server ACK document. */ -/** 只给 working 候选补本地墓碑最大值,不修改源文档,也不向服务器 ACK 事实表灌入本地证明。 */ +/** 只为候选复制墓碑表并补本地最大值,其余文档表沿用;不改传入文档。 + * 调用方只能将结果当 working,不应覆写 acknowledged;这里不自动区分两种变量的用途。 + * 直接读取保存的 elision 字段,不在此复验证据;ledger 应先经可信生成或 validateElisions。 */ export function withLocalLifecycles(document: Document, ledger: Pick): Document { const tombstones = new Map(document.tombstones); for (const record of ledger.operations.values()) if (record.elision) for (const [id, incarnation] of Object.entries(record.elision.tombstones)) tombstones.set(id, Math.max(tombstones.get(id) ?? 0, incarnation)); diff --git a/src/lib/canvas-runtime/history-replay.ts b/src/lib/canvas-runtime/history-replay.ts index 3d34009..07c9866 100644 --- a/src/lib/canvas-runtime/history-replay.ts +++ b/src/lib/canvas-runtime/history-replay.ts @@ -11,13 +11,16 @@ export type HistoryReplayEvent={readonly kind:'operation';readonly operationId:I /** version=1 的 anchor+有序事件;所有 transition 绑定同一次 adoption Root revision。 */ export type HistoryReplay={readonly version:1;readonly anchor:HistoryTransition;readonly events:readonly HistoryReplayEvent[]}; /** Local evidence only. The anchor and event deltas explain one atomic adoption, not new service writes. */ -/** 检查证据版本/JSON、anchor无entry、事件 revision 一致和 operation ID 唯一;未知版本拒绝解释。 */ +/** 检查证据版本/JSON、anchor 无 entry、事件 revision 一致及 operation ID 唯一;未知版本拒绝解释。 + * revision 由调用方提供;不查询真实发布日志,checkChanges 缺省时不完整解释各条领域 Change。 */ export function validateHistoryReplay(value:unknown,revision:number,checkChanges?:(value:unknown)=>unknown):asserts value is HistoryReplay{ if(!value||typeof value!=='object'||Array.isArray(value))throw fault('invalid_history_replay');const raw=value as Record; if(raw.version!==1)throw fault('history_replay_interpreter_unavailable');if(!validJson(value))throw fault('invalid_history_replay');validateHistoryTransition(raw.anchor,checkChanges);if(raw.anchor.rootRevision!==revision||raw.anchor.entryId!==undefined||!Array.isArray(raw.events))throw fault('invalid_history_replay');const ids=new Set(); for(const value of raw.events){if(!value||typeof value!=='object'||Array.isArray(value))throw fault('invalid_history_replay');const event=value as Record;validateHistoryTransition(event.transition,checkChanges);if(event.transition.rootRevision!==revision)throw fault('invalid_history_replay');if(event.kind==='operation'){if(typeof event.operationId!=='string'||!event.operationId||ids.has(event.operationId))throw fault('invalid_history_replay');ids.add(event.operationId);}else if(event.kind!=='adjustment'||event.operationId!==undefined)throw fault('invalid_history_replay');} } -/** 纯条件回放 anchor/events;可选校验逻辑 ID 存在,带 entry 的事件必须在该项关联同 operation。 */ +/** 条件回放历史元数据:先正向 anchor,再按 events 顺序更新两栈/entries,不执行文档 changes 或网络。 + * 用 anchor 自身 revision 调结构校验,不独自核对外部 adoption 的 Root;调用方另需绑定那次发布。 + * hasOperation 可选检查 ID 存在,带 entry 的操作事件需关联同 ID;不在此比较逻辑意图内容与 transition 是否语义等价。 */ export function replayHistoryEvidence(before:HistoryState,replay:HistoryReplay,hasOperation?:(id:Id)=>boolean):HistoryState{ validateHistoryReplay(replay,replay.anchor.rootRevision);let history=applyHistoryTransition(before,replay.anchor,'forward'); for(const event of replay.events){if(event.kind==='operation'&&hasOperation&&!hasOperation(event.operationId))throw fault('history_replay_operation_missing',[event.operationId]);history=applyHistoryTransition(history,event.transition,'forward');if(event.kind==='operation'&&event.transition.entryId&&!history.entries.get(event.transition.entryId)?.operationIds.includes(event.operationId))throw fault('history_replay_operation_mismatch',[event.operationId]);} @@ -25,5 +28,6 @@ export function replayHistoryEvidence(before:HistoryState,replay:HistoryReplay,h } /** 比较两栈和全部 entry;entry 表按 ID 排序,避免 Map 插入顺序差异被误认作历史不一致。 */ export function sameHistory(left:HistoryState,right:HistoryState):boolean{ + // 仅规范化 entries 表的顺序;undo/redo 数组顺序仍按栈语义精确比较,不能排序后当集合。 const value=(history:HistoryState)=>({undo:history.undo,redo:history.redo,entries:[...history.entries].sort(([a],[b])=>a.localeCompare(b))});return equal(value(left),value(right)); } diff --git a/src/lib/canvas-runtime/reconcile.ts b/src/lib/canvas-runtime/reconcile.ts index 6168ddb..0570ae7 100644 --- a/src/lib/canvas-runtime/reconcile.ts +++ b/src/lib/canvas-runtime/reconcile.ts @@ -12,11 +12,13 @@ import type { LedgerSnapshot } from './ledger.ts'; /** A complete read cannot rewind known scopes or change an unchanged-version canvas. */ /** 校验完整 fresh read:同项目、合法文档、活 canvas 版本齐全、已知版本和墓碑均不可倒退。 - * 某 canvas 版本没变时,其记录/直属元素必须相同;全向量没变时墓碑集合也不能偷偷改变。 */ + * 某 canvas 版本没变时,其记录/直属元素必须相同;全向量没变时墓碑集合也不能偷偷改变。 + * 只校验模型一致性/单调性,不证明响应身份或读取来自何服务;传输认证与实际完整覆盖由端口保证。 */ export function validateCanonicalRead(previous: Document, previousVersions: ReadonlyMap, next: Document, versions: ReadonlyMap, registry: Registry) { if(next.projectId!==previous.projectId)throw fault('project_mismatch'); validateDocument(next,registry); for(const [id,version]of versions)if(!Number.isSafeInteger(version)||version<1)throw fault('invalid_canonical_version',[id]); + // 活 canvas 必须有版本,但不要求版本表只含活项;已移除 scope 的已知水位也可继续保留。 for(const canvas of next.canvases.keys())if(!versions.has(canvas))throw fault('missing_server_version',[canvas]); // 记录服务声明“版本未变”的 owner,随后双向检查元素,既防遗漏删除也防夹带新增。 const unchanged=new Set(); @@ -34,10 +36,12 @@ export function validateCanonicalRead(previous: Document, previousVersions: Read } /** The footprint follows ownership; geometry does not decide which canvas owns an object. */ -/** 计算跨 canvas 操作 footprint:有已冻结意图范围时沿用,否则收集新旧 owner/父级/端点归属。 - * 上传和 run.cancel 不占文档参与者;空间位置或画框相交不决定 owner。 */ +/** 计算跨 canvas footprint:声明了非空 intent.canvasIds 就直接沿用,不以是否已冻结为条件。 + * 否则从变化收集新旧 owner/父级/端点归属;上传和 run.cancel 总是返回空文档参与者。 + * 不根据画框相交或 run 的 boardId/action 自动猜参与者;ID 有效性与编译覆盖另由边界核对。 */ export function operationCanvases(intent: OperationIntent, document: Document, after:Document=document): readonly Id[] { if(intent.operationType==='asset.upload'||intent.operationType==='run.cancel')return[]; + // 已声明范围按原引用/顺序返回,不在此排序去重;仅下方推导路径用 Set 并排序。 if (intent.canvasIds?.length) return intent.canvasIds; // 字段变化不能只查 after;重挂/改端点时旧 owner 也参与版本条件和占用。 const ids = new Set(); @@ -64,18 +68,21 @@ export function operationCanvases(intent: OperationIntent, document: Document, a /** Replay once, by logical sequence. A receipt can already contain a desired field value. */ /** 将本地生命周期水位叠到 ACK 副本,再按 sequence 回放 document.change/run.apply。 - * 只有已满足的 after 可安全跳过,条件冲突仍拒绝;不会自动改写原意图或冻结请求。 */ + * 底层按同 incarnation/字段存在性判断已满足 after,并检查 field owner 范围;条件冲突仍拒绝。 + * 不改写意图/冻结请求,不移动 history;historyEffect 只在此验证形状,不重新执行尺寸历史规划。 */ export function replayPending(base: Document, ledger: LedgerSnapshot, registry: Registry): Document { /** 已 resolved 项不重放;未冻结、冻结或 blocked 的未解决文档意图仍按原事实参与重建。 */ function* pending(){ for(const record of [...ledger.operations.values()].sort((a,b)=>a.intent.sequence-b.intent.sequence)){ if(record.phase==='resolved'||!['document.change','run.apply'].includes(record.intent.operationType))continue; + // 只筛 document.change/run.apply 的变化;上传、run.start/cancel 不因存在 changes 字段就在这里执行。 if(record.intent.historyEffect)validateCreationSizeEffect(record.intent.historyEffect); yield {changes:record.intent.changes,canvasIds:record.intent.canvasIds}; } } // 本地抵消可能从未进入服务器,水位只加到 working 候选,不能冒充服务器已确认墓碑。 const document=replayChanges(withLocalLifecycles(base,ledger),pending()); + // 全部回放成功后检查最终工作文档;任何异常让调用者丢弃这份候选,不在此处理冲突 UI 或覆盖 ACK。 validateDocument(document,registry); return document; }