docs(runtime): clarify partial loading structure and import gates

This commit is contained in:
2026-11-12 13:39:25 +08:00
parent 437a46ad36
commit 59b59c2f03
2 changed files with 36 additions and 12 deletions
+11 -3
View File
@@ -16,7 +16,10 @@ const relations=new WeakMap<Document,{members:Map<Id,Id[]>;children:Map<Id,Id[]>
/** 构造直接成员和entry→子canvas映射,供选区DFS扩张使用,不从节点可见性推断拥有范围。 */
function index(document:Document){let value=relations.get(document);if(!value){const members=new Map<Id,Id[]>(),children=new Map<Id,Id[]>();for(const element of document.elements.values()){const ids=members.get(element.ownerCanvasId)??[];ids.push(element.id);members.set(element.ownerCanvasId,ids);}for(const canvas of document.canvases.values())if(canvas.parent){const ids=children.get(canvas.parent.entryElementId)??[];ids.push(canvas.id);children.set(canvas.parent.entryElementId,ids);}value={members,children};relations.set(document,value);}return value;}
/** The caller captures this proof with the same document that becomes source bytes. */
/** target先闭合;复制还要求全部选中后代/必要祖先/上级入边owner完整,然后冻结准入范围与sequence。 */
/** 无 loading 返回 undefined;只有 v5/v6+loadingRead 能生成准入,target 必须 ownerReady。
* selection 存在时以数组栈+copied Set 深扩张,补入头部声明却未发布的 child,避免误将未读分区当叶子。
* captured 元素还检查必要祖先;上级入边的 owner 也纳入 required,最终冻结去重排序范围与账本最大 sequence。
* 只派生证据,不复制字节/分配 ID/验证 policy;没有 selection 表示普通目标导入,不检查复制子树。 */
export function loadingImportAdmission(snapshot:EngineSnapshot,target:Id,selection?:readonly Target[]):LoadingImportAdmission|undefined {
const state=snapshot.runtime.loading;if(!state)return;
if((state.version!==5&&state.version!==6)||!snapshot.runtime.loadingRead)throw fault('model_loading');
@@ -42,7 +45,9 @@ export function loadingImportAdmission(snapshot:EngineSnapshot,target:Id,selecti
let operationSequence=0;for(const record of snapshot.runtime.ledger.operations.values())operationSequence=Math.max(operationSequence,record.intent.sequence);
return freeze({version:1 as const,sourceToken:state.sourceToken,operationSequence,requiredCanvasIds:[...required].sort()});
}
/** 恢复时job须同项目/同token,证明sequence不超账本,target与全部必要canvas仍是可证明范围。 */
/** v5/v6 恢复/发布时逐个 validateImportJob,再核对项目、token、sequence 上界、target 包含与各范围准入。
* 只检查保存的 requiredCanvasIds,不在此重新展开 selection 或读取源字节;复制源重建由下一函数负责。
* 依赖外围已建立 loadingRead/账本身份,本函数不证明原 checkpoint 已落盘或获得当前服务器权限。 */
export function validateLoadingImportAdmissions(runtime:EngineSnapshot['runtime']):void {
const state=runtime.loading;if(!state||(state.version!==5&&state.version!==6))return;
let maxSequence=0;for(const record of runtime.ledger.operations.values())maxSequence=Math.max(maxSequence,record.intent.sequence);
@@ -51,7 +56,10 @@ export function validateLoadingImportAdmissions(runtime:EngineSnapshot['runtime'
/** Revalidate the frozen copy topology before planning or resuming its source.
* Later source chunks may add unrelated owners, so compare only the closed cut. */
/** 恢复规划前按准入sequence重建预期源,只比较原闭合cut的topology,不要求后到无关owner保持不增长。 */
/** 从原捕获源重放 sequence≤准入时点的全部意图 changes(不按 phase 过滤,conditional=false)重建旧逻辑切点。
* 用 preparation/plan 中 selection 重算 required 范围并逐 owner 比较 canvas 与 copyTopology,忽略无关后到范围。
* copyTopology 保留身份/owner/端点/几何及表现尺度,省略正文等组件内容和边 path;这不是完整 payload 一致性证明。
* 不计算 SHA、不执行完整文档/权限校验,也不发布重建文档;源字节完整性由快照/导入证据链负责。 */
export function validateLoadingCopySource(snapshot:EngineSnapshot,job:ImportJob,document:Document):void {
const state=snapshot.runtime.loading,proof=job.source?.loadingAdmission;if(!state||(state.version!==5&&state.version!==6)||!proof)return;
const source=snapshot.runtime.loadingRead!;let expected=source.document;
+25 -9
View File
@@ -17,11 +17,16 @@ const counts=new WeakMap<Document,ReadonlyMap<Id,number>>();
function membership(document:Document){let result=counts.get(document);if(!result){const next=new Map<Id,number>();for(const element of document.elements.values())next.set(element.ownerCanvasId,(next.get(element.ownerCanvasId)??0)+1);result=next;counts.set(document,result);}return result;}
/** 全部声明元素/canvas 均到齐才算源闭合;这里只做数量闭合判断,领域合法性由其它校验共同保证。 */
export function sourceClosed(state:LoadingState,source:Capture){return source.document.elements.size===state.totalElements&&source.document.canvases.size===state.declaredCanvasIds.length;}
/** working中owner存在,且是明确新canvas或源成员数已达头部证明,才能作为闭合操作范围。 */
/** working 中 canvas 必须存在;source.versions 不含它时按本地新 scope 处理,否则需 v2+ 直属源计数齐。
* 依赖 source/state 事先验证的完整声明,不以 working 乐观新增数补齐;也不单独证明全部后代/引用闭合。 */
export function ownerReady(state:LoadingState,source:Capture,document:Document,id:Id){return document.canvases.has(id)&&(!source.versions.has(id)||state.version!==1&&(membership(source.document).get(id)??0)===state.canvasElementCounts[id]);}
/** 只有v4+完整ID命名空间才能证明一个ID不属于原捕获;“当前还没看到”本身不够。 */
/** 仅 v4/v5/v6 用完整 elementOwners 的缺席判定不属于捕获,不能只看当前 prefix 未出现。
* 这里不查元素是否真的新建、墓碑或 ID 冲突;born 是命名空间分类,具体创建条件在其它分支核对。 */
function born(state:LoadingState,id:Id){return (state.version===4||state.version===5||state.version===6)&&!Object.hasOwn(state.elementOwners,id);}
/** 禁止凭早期payload引入新任务状态或任意资源URL;新资源须有准入表,已有资源须能在已知数据证明。 */
/** 数据门禁要求 copy 声明:ai/taskId/runId 的非空值只能与 prior 相等,允许清空而不允许改成新任务值。
* src/assetId/声明资源字段若非空,须命中受控 assets 表,或与 working/source 已知组件的同字段值一致。
* 这是可解释来源检查,不发授权请求、不验证 URL 协议,也不代替数据 schema/任务服务校验。
* 比对可扫描当前与源的元素表,不能把此函数当作常数成本字段检查。 */
function safeData(data:ElementRecord['data'],registry:Registry,component:Id,document:Document,source?:Document,prior?:ElementRecord['data'],assets?:ReadonlyMap<Id,string>){
const definition=registry.components.get(component);if(!definition?.copy)return false;
for(const key of ['ai','taskId','runId'])if(data[key]!==undefined&&data[key]!==null&&!equal(data[key],prior?.[key]))return false;
@@ -38,7 +43,10 @@ function assetPatch(patch:ElementRecord['data'],registry:Registry,component:Id){
const definition=registry.components.get(component),fields=['src','assetId',...(definition?.resourceFields??[])],keys=Object.keys(patch);
return keys.some(key=>fields.includes(key))&&keys.every(key=>fields.includes(key)||!['presentation','ports','ai','taskId','runId'].includes(key)&&!definition?.copy?.references.some(ref=>ref.path[0]===key||ref.path[0]==='*'));
}
/** 返回结构命令的预检结果,undefined交普通字段门;准入仍需后续核心policy/schema与实际changes检查。 */
/** v4+ 对结构/受控数据命令返回 enabled/deny;未处理命令或不满足专用数据入口时返回 undefined。
* undefined 留给普通字段门,不表示允许;enabled 也不证明目标代次、能力、handler 副作用与最终文档都合法。
* 原捕获对象的删除/重挂等要求源闭合,本地 born 对象可在 ready owner 操作;解散仍要求整个源到齐。
* 函数只预检,不锁范围、不生成 changes;调用方仍需核心 policy/schema 和 assertStructuralChanges。 */
export function structuralCommand(command:Command,document:Document,registry:Registry,state:LoadingState,source:Capture,assets?:ReadonlyMap<Id,string>):Availability|undefined {
if(state.version<4)return;
const deny:Availability={enabled:false,code:'model_loading',targets:[]},yes:Availability={enabled:true};
@@ -52,7 +60,10 @@ export function structuralCommand(command:Command,document:Document,registry:Reg
if(command.type==='element.data'&&(born(state,command.target.id)||sourceClosed(state,source)||state.version===6&&assetPatch(command.patch,registry,document.elements.get(command.target.id)?.componentId??'')&&ownerReady(state,source,document,document.elements.get(command.target.id)?.ownerCanvasId??''))){const element=document.elements.get(command.target.id);return element&&registry.resolve(element)==='known'&&safeData({...element.data,...command.patch},registry,element.componentId,document,source.document,element.data,assets)?yes:deny;}
}
/** Actual changes, not a command name, establish the mutation's required closure. */
/** 逐条在私有cursor验证实际变化的必要闭合范围/数据,并检查最终声明引用,防handler副作用绕过。 */
/** 将每条实际 change 按顺序作用到私有 cursor,逐项核对 owner/生命周期分类/数据门禁,最终检查声明引用。
* geometry 的局部门只要求已知 node,path/端点还要求 ready owner;owner 迁移则检查新旧两侧与 captured 分类。
* applyChanges 检查 before 条件,但这里不调用完整 validateDocument,也不执行 capability/policy/服务器授权校验。
* 返回是通过加载门禁的候选,非已发布 Root;任何抛错都不返回半份候选,原文档按不可变输入合同复用。 */
export function assertStructuralChanges(changes:readonly Change[],document:Document,registry:Registry,state:LoadingState,source:Capture,assets?:ReadonlyMap<Id,string>):Document {
if(state.version<4)throw fault('model_loading');
// 按变化顺序推进,同批先创建owner/端点可满足后续依赖,但原源命名空间不会因此被改写。
@@ -84,10 +95,13 @@ export function assertStructuralChanges(changes:readonly Change[],document:Docum
// 即使命令名字获准,最终仍不能产生悬空声明引用;整批失败不给调用方半份合法结果。
references(cursor,registry);return cursor;
}
/** Protocol-2 receipts contain exactly these applied logical changes. */
/** The caller's validated protocol-2 ledger supplies the applied logical effects. */
// 缓存同一源上的已确认结构投影,只在applied意图前缀身份未变时从缓存后缀继续。
const projectionCache=new WeakMap<Capture,{intents:readonly OperationIntent[];document:Document}>();
/** 将resolved/applied意图按sequence叠到读取候选,并按精确协议2的参与者版本推进;不混入pending。 */
/** 按 sequence 对 resolved+resolution=applied 意图叠原 changes,conditional=false,不加入 pending/elided 效果。
* 不按 operationType 额外过滤,也不再次核验协议/回执或调用 validateDocument;依赖外围已验证账本与精确协议 2 合同。
* 文档缓存仅在同 source 与 intent 引用前缀一致时续算;参与者版本另按每个 applied request 的 expected+1 取最大值。
* 返回新的投影 document/versions,不修改 runtime ACK;最终合法性由加载合并/验证入口继续检查。 */
export function appliedSource(runtime:RuntimeState,source:Capture):Capture {
let document=source.document;const versions=new Map(source.versions);
const applied=[...runtime.ledger.operations.values()].filter(record=>record.phase==='resolved'&&record.resolution==='applied').sort((a,b)=>a.intent.sequence-b.intent.sequence);
@@ -98,9 +112,11 @@ export function appliedSource(runtime:RuntimeState,source:Capture):Capture {
return{document,versions};
}
// source/registry/intent/before/assets全身份绑定校验缓存,不能把另一捕获或不同资源授权的结果拿来复用。
// 缓存键为 source/registry/intent 引用,再比较 before/assets 引用;不按内容 hash 或 LoadingState 单独建键。
const intentProofs=new WeakMap<Capture,WeakMap<Registry,WeakMap<OperationIntent,{before:Document;after:Document;assets?:ReadonlyMap<Id,string>}>>>();
/** 对同一候选上下文复用已验证结构结果,否则重新assert全部实际changes;不是跳过当前源范围验证。 */
/** 命中同一 source/registry/intent/before/assets 上下文时直接复用候选,否则重新检查 changes 并缓存。
* LoadingState 不在独立缓存键内,调用方须保证其与同源上下文一致,所有参与引用均按不可变合同使用。
* 缓存只证明这次加载结构门禁曾通过,不证明任意后来的 state/权限变化也可沿用结果。 */
export function validateStructuralIntent(intent:OperationIntent,document:Document,registry:Registry,state:LoadingState,source:Capture,assets?:ReadonlyMap<Id,string>):Document {
let registries=intentProofs.get(source);if(!registries){registries=new WeakMap();intentProofs.set(source,registries);}let intents=registries.get(registry);if(!intents){intents=new WeakMap();registries.set(registry,intents);}const previous=intents.get(intent);if(previous?.before===document&&previous.assets===assets)return previous.after;
const after=assertStructuralChanges(intent.changes,document,registry,state,source,assets);intents.set(intent,{before:document,after,assets});return after;