From f78618be2b2d6d72049d033ef4abdf6144feb00e Mon Sep 17 00:00:00 2001 From: Codex Bot Date: Thu, 12 Nov 2026 15:54:01 +0800 Subject: [PATCH] docs(service): clarify Foundry protocol validation boundaries --- src/lib/canvas-service/foundry.ts | 22 +++++++++++++++++----- 1 file changed, 17 insertions(+), 5 deletions(-) diff --git a/src/lib/canvas-service/foundry.ts b/src/lib/canvas-service/foundry.ts index c9eb650..77084da 100644 --- a/src/lib/canvas-service/foundry.ts +++ b/src/lib/canvas-service/foundry.ts @@ -38,7 +38,8 @@ const pathFor = (workspace: Id, canvas: Id) => `workspaces/${encodeURIComponent( /** Credentials stay in the host/session. Every write additionally binds the expected account. */ /** 默认将请求发送到同源代理,凭证留在宿主会话,不塞进请求描述或恢复镜像。 - * 发出前与收到后均检查当前身份,带 X-Expected-Account 供服务端复查账户;跨身份响应不能交给旧调用者。 */ + * 发出前与收到后均检查当前身份,带 X-Expected-Account 供服务端复查账户;跨身份响应不能交给旧调用者。 + * fetch 在创建时选定;其余 options 在请求时读取,宿主应维持此适配器的账户/路径绑定稳定。 */ export function createFoundryTransport(options: { accountId: Id; isCurrentIdentity(): boolean; fetch?: typeof fetch; basePath?: string }): ServiceTransport { if (!uuid.test(options.accountId)) throw fault('invalid_account_identity'); const requestFetch = options.fetch ?? globalThis.fetch; @@ -51,6 +52,7 @@ export function createFoundryTransport(options: { accountId: Id; isCurrentIdenti } }); if (!options.isCurrentIdentity()) { await response.body?.cancel(); throw fault('identity_changed'); } // 成功的资源字节读取同时约束 Content-Length 提示和实际累计20MiB,不能只信响应头。 + // 分块仍全部保留至 Blob 构造;这是载荷预算,不是总堆内存上限,也不验证摘要或图片解码。 if(input.responseFormat==='asset-bytes-v1'&&response.status===200){ const maximum=20*1024*1024,length=response.headers.get('content-length'); if(length!==null&&(!/^\d+$/.test(length)||Number(length)>maximum)){await response.body?.cancel();throw fault('asset_byte_budget_exceeded');} @@ -70,7 +72,8 @@ export function createFoundryTransport(options: { accountId: Id; isCurrentIdenti if(!response.body)throw fault('incomplete_model_stream'); const body=response.body; /** 惰性读取字节,每个 await 两侧复查身份;迭代结束、抛错或消费者提前退出都归还 reader 锁。 - * NDJSON 内容和字节预算由 model-stream 解释器执行,此处只提供有生命周期边界的字节源。 */ + * NDJSON 内容和字节预算由 model-stream 解释器执行,此处只提供有生命周期边界的字节源。 + * reader 在首次迭代时才取得;从不消费此生成器时,不会进入这里的 finally 清理。 */ async function* stream(){ const reader=body.getReader(),abort=()=>{void reader.cancel().catch(()=>{});};input.signal?.addEventListener('abort',abort,{once:true}); try{while(true){input.signal?.throwIfAborted();if(!options.isCurrentIdentity())throw fault('identity_changed');const next=await reader.read();input.signal?.throwIfAborted();if(!options.isCurrentIdentity())throw fault('identity_changed');if(next.done)break;yield next.value;}} @@ -147,6 +150,7 @@ function statusFor(request: FrozenRequest, response: ServiceResponse, query: boo return freeze({ outcome: 'applied', canonical: JSON.parse(JSON.stringify(body)) as Json }); } if (matching && body.status === 'accepted' && response.status >= 200 && response.status < 300) return { outcome: 'accepted', retryAfterMs: response.retryAfterMs }; + // 终结未生效分支只要求协议/请求匹配和 terminal;不复用 applied 的 HTTP 与操作覆盖校验。 if (matching && body.status === 'not-applied' && body.terminal === true) return { outcome: 'not-applied', error: error ?? 'not_applied' }; // 查询不到回执不等于从未执行,继续使用原请求身份查证;普通4xx未提供终结证据时阻塞。 if (query && response.status === 404 && body.status === 'unknown' && body.request_id === request.id) return { outcome: 'unresolved', error: 'operation_receipt_unknown', retryAfterMs: response.retryAfterMs }; @@ -154,7 +158,8 @@ function statusFor(request: FrozenRequest, response: ServiceResponse, query: boo return { outcome: 'unresolved', error: error ?? 'transport_unknown', retryAfterMs: response.retryAfterMs }; } /** 发送/查询/解释前交叉核对冻结元数据与请求体:协议、request_id、预期版本、画布集合和操作覆盖一致。 - * 不在这里重新编译 payload,重试必须沿用已经保存的精确 body。 */ + * 不在这里重新编译 payload,重试必须沿用已经保存的精确 body。 + * 这是冻结描述的一致性检查,不重新执行每项 change 的 schema、条件或依赖验证。 */ function validateFrozen(request: FrozenRequest) { let body: Record; try { body = record(JSON.parse(request.body)); } catch { throw fault('invalid_frozen_request'); } @@ -207,15 +212,17 @@ export function createFoundryPersistence(options: { workspaceId: Id; rootId: Id; } // The body is the durable coverage mapping. Never replace logical IDs with a synthetic batch ID. // projectBatch 基于 ACK 与 ledger 合并条件字段,同时保留逻辑覆盖;JSON复制使描述脱离调用方引用。 + // 返回值尚不是持久冻结的 body 字符串;保存请求、调度和重试由 runtime 完成。 const payload = JSON.parse(JSON.stringify({ protocol_version: 2, request_id: requestId, expected_versions: expectedVersions, operations: projectBatch(intents, snapshot.runtime.acknowledged, snapshot.runtime.ledger, canvasIds) })) as Json; if (!validJson(payload)) throw fault('invalid_operation_payload'); return freeze({ protocolVersion: 2, canvasIds, expectedVersions, payload }); } return Object.freeze({ - /** 此适配器仅解释远端协议v2,恢复的其它版本不能猜测着继续发送。 */ + /** 协议版本能力门,只声明可解释 v2;不是对冻结 body 完整合法性的认证。 */ supportsRequest: (request: FrozenRequest) => request.protocolVersion === 2, /** 仅在调用方已协调成功且请求resolved/applied后尝试摘要。 - * 检查参与版本与已采用结果一致,未知字段/未知组件/额外格式信息一律保留原始回执。 */ + * 检查参与版本与已采用结果一致,未知字段/未知组件/额外格式信息一律保留原始回执。 + * 此处信任 applied 已经协调采用,不再次逐元素比较 applied.document;摘要不能独立重建文档。 */ ...(options.resolvedReceiptPolicy==='summary'?{compactResolvedReceipt(request:FrozenRequest,receipt:Json,applied:import('../canvas-runtime/contracts.ts').CanonicalProposal):Json{ if(request.kind!=='document.change'||request.protocolVersion!==2||request.phase!=='resolved'||request.outcome!=='applied')return receipt; validateFrozen(request);const body=record(receipt); @@ -257,6 +264,7 @@ export function createFoundryPersistence(options: { workspaceId: Id; rootId: Id; query: (request: FrozenRequest, signal: AbortSignal) => execute(request, true, signal), /** 从原始回执提取参与者模型,合成新的 ACK 候选;不接受本地已压缩摘要代替原模型。 */ canonical(request: FrozenRequest, status: OperationStatus, snapshot: EngineSnapshot) { + // 按已确认的 applied 结果调用;此处核对身份并组装模型候选,不代替结果解释或版本推进检查。 assertRawReceipt(status.canonical);const body = record(status.canonical); if (body.request_id !== request.id || body.protocol_version !== 2) throw fault('receipt_identity_mismatch'); return mergeFoundryModel(body.model, request.canvasIds, snapshot, options.registry, options.rootId); @@ -280,6 +288,7 @@ export function verifyFoundryModelProof(value:unknown,participants:readonly Id[] if(!equal(canvases.get(canvas),proposal.document.canvases.get(canvas)))throw fault('canonical_version_mismatch',[canvas]); unchanged.add(canvas); } + // 更高版本跳过逐字段相等检查,允许后续合法修改;同版本(包括删除记录)必须保持既有内容。 // 对仍处于同版本的 canvas 逐元素比较,既不能缺元素也不能多元素;墓碑只能单调推进。 const expected=new Map(chunk.elements.filter(element=>unchanged.has(element.ownerCanvasId)).map(element=>[element.id,element])); let count=0;for(const element of proposal.document.elements.values())if(unchanged.has(element.ownerCanvasId)){count++;if(!equal(expected.get(element.id),element))throw fault('canonical_version_mismatch',[element.ownerCanvasId]);} @@ -294,7 +303,9 @@ export function verifyFoundryModelProof(value:unknown,participants:readonly Id[] export function mergeFoundryModel(value: unknown, participants: readonly Id[], snapshot: EngineSnapshot, registry: Registry, rootId: Id) { const chunk = modelChunk(value); if (chunk.projectId !== rootId || chunk.complete || chunk.versions.size !== participants.length || participants.some(id => !chunk.versions.has(id))) throw fault('receipt_coverage_mismatch'); + // 必须从 ACK 构造,不把 working 中尚未确认的本地内容冒充服务端已接纳内容。 const previous = snapshot.runtime.acknowledged, canvases = new Map(previous.canvases), elements = new Map(previous.elements), tombstones = new Map(previous.tombstones), serverVersions = new Map(snapshot.runtime.versions); + // 先全部删除参与 canvas;removed 参与者不在返回列表内,因此保留版本但不重新放回画布记录。 for (const canvas of participants) canvases.delete(canvas); for (const canvas of chunk.canvases) canvases.set(canvas.id, canvas); // 先移除旧参与者成员,再加入回执成员;与非参与者现有ID碰撞则拒绝,不能跨范围覆盖。 @@ -326,6 +337,7 @@ export function mergeFoundryRunContext(value: unknown, snapshot: EngineSnapshot, for(const canvas of chunk.canvases)if(replace.has(canvas.id))canvases.set(canvas.id,canvas); for(const [id,element] of elements)if(replace.has(element.ownerCanvasId))elements.delete(id); for(const element of chunk.elements)if(replace.has(element.ownerCanvasId)){if(elements.has(element.id))throw fault('canonical_scope_mismatch',[element.id]);elements.set(element.id,element);} + // 墓碑取全上下文的逐 ID 最大值,即使对应 canvas 因版本较旧未替换,也不能让生命周期倒退。 for(const [id,generation] of chunk.tombstones)tombstones.set(id,Math.max(tombstones.get(id)??0,generation)); const document=createDocument(rootId,[...canvases.values()],[...elements.values()],tombstones);validateDocument(document,registry); return Object.freeze({document,versions:readMap(serverVersions)});