docs(service): clarify Foundry protocol validation boundaries
This commit is contained in:
@@ -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<string, unknown>;
|
||||
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)});
|
||||
|
||||
Reference in New Issue
Block a user