Allow durable imports and deep copies in certified loading scopes

This commit is contained in:
2026-11-09 13:39:39 +08:00
parent 945aaea504
commit a66933775e
23 changed files with 234 additions and 62 deletions
+22 -18
View File
@@ -1,6 +1,7 @@
import {validateLoadingImportAdmissions} from './loading-import.ts';
import {validateStructuralIntent,structuralCommand,assertStructuralChanges,appliedSource,ownerReady} from './loading-structure.ts';
import {validateElementOwners,type ElementOwners,validateCanvasElementCounts,type CanvasElementCounts} from './scope-counts.ts';
import type {Availability,Change,Command,Document,Id,Registry} from '../canvas-core/types.ts';
import {validateCanvasTopology,validateElementOwners,type ElementOwners,validateCanvasElementCounts,type CanvasElementCounts} from './scope-counts.ts';
import type {Availability,Change,Command,Document,Id,Registry,CanvasRecord} from '../canvas-core/types.ts';
import {createDocument,validateDocument} from '../canvas-core/document.ts';
import {equal,fault,readSet} from '../canvas-core/values.ts';
import type {CanonicalProposal,RuntimeState} from './contracts.ts';
@@ -8,14 +9,16 @@ import type {CanonicalProposal,RuntimeState} from './contracts.ts';
/** Durable marker for a verified read prefix. Versions 1/2 hold all writes;
* version 3 admits certified scopes with a retained read baseline. No HTTP cursor. */
type LoadingBase={readonly sourceToken:string;readonly declaredCanvasIds:readonly Id[];readonly receivedElements:number;readonly totalElements:number};
export type LoadingState=LoadingBase&({readonly version:1;readonly canvasElementCounts?:never;readonly elementOwners?:never}|{readonly version:2|3;readonly canvasElementCounts:CanvasElementCounts;readonly elementOwners?:never}|{readonly version:4;readonly canvasElementCounts:CanvasElementCounts;readonly elementOwners:ElementOwners});
export type LoadingState=LoadingBase&({readonly version:1;readonly canvasElementCounts?:never;readonly elementOwners?:never;readonly sourceCanvases?:never}|{readonly version:2|3;readonly canvasElementCounts:CanvasElementCounts;readonly elementOwners?:never;readonly sourceCanvases?:never}|{readonly version:4;readonly canvasElementCounts:CanvasElementCounts;readonly elementOwners:ElementOwners;readonly sourceCanvases?:never}|{readonly version:5;readonly canvasElementCounts:CanvasElementCounts;readonly elementOwners:ElementOwners;readonly sourceCanvases:readonly CanvasRecord[]});
export function validateLoadingState(value:LoadingState,document:Document,versions:ReadonlyMap<Id,number>):void{
if(!value||![1,2,3,4].includes(value.version))throw fault('loading_interpreter_unavailable');
if(!value||![1,2,3,4,5].includes(value.version))throw fault('loading_interpreter_unavailable');
for(const [id,version]of versions)if(!Number.isSafeInteger(version)||version<1)throw fault('invalid_canonical_version',[id]);
if(typeof value.sourceToken!=='string'||!value.sourceToken||value.sourceToken.length>256||!Array.isArray(value.declaredCanvasIds)||!value.declaredCanvasIds.length||value.declaredCanvasIds.some(id=>typeof id!=='string'||!id||!versions.has(id))||new Set(value.declaredCanvasIds).size!==value.declaredCanvasIds.length||!value.declaredCanvasIds.includes(document.projectId)||[...document.canvases.keys()].some(id=>!value.declaredCanvasIds.includes(id))||![value.receivedElements,value.totalElements].every(value=>Number.isSafeInteger(value)&&value>=0)||value.receivedElements>value.totalElements||document.elements.size>value.receivedElements)throw fault('invalid_loading_state');
if(value.version===1&&value.canvasElementCounts!==undefined)throw fault('invalid_loading_state');
if(value.version!==4&&value.elementOwners!==undefined)throw fault('invalid_loading_state');
if(value.version===4){validateElementOwners(value.elementOwners,value.canvasElementCounts,value.totalElements);for(const element of document.elements.values())if(!Object.hasOwn(value.elementOwners,element.id)||value.elementOwners[element.id]!==element.ownerCanvasId)throw fault('invalid_model_identity');}
if(value.version<4&&value.elementOwners!==undefined)throw fault('invalid_loading_state');
if(value.version===4||value.version===5){validateElementOwners(value.elementOwners,value.canvasElementCounts,value.totalElements);for(const element of document.elements.values())if(!Object.hasOwn(value.elementOwners,element.id)||value.elementOwners[element.id]!==element.ownerCanvasId)throw fault('invalid_model_identity');}
if(value.version!==5&&value.sourceCanvases!==undefined)throw fault('invalid_loading_state');
if(value.version===5){validateCanvasTopology(value.sourceCanvases,value.elementOwners,document.projectId);if(!equal(value.sourceCanvases.map(canvas=>canvas.id).sort(),[...value.declaredCanvasIds].sort())||[...document.canvases].some(([id,record])=>!equal(record,value.sourceCanvases.find(canvas=>canvas.id===id))))throw fault('invalid_model_topology');}
if(value.version!==1){validateCanvasElementCounts(value.canvasElementCounts,value.declaredCanvasIds,value.totalElements);const counts=new Map<Id,number>();for(const element of document.elements.values()){const count=(counts.get(element.ownerCanvasId)??0)+1;if(count>value.canvasElementCounts[element.ownerCanvasId])throw fault('invalid_model_coverage');counts.set(element.ownerCanvasId,count);}}
}
const denied:Availability=Object.freeze({enabled:false,code:'model_loading',targets:[]});
@@ -28,7 +31,7 @@ function pathAllowed(id:Id,document:Document,registry:Registry,loading?:LoadingS
return (directCounts(base).get(element.ownerCanvasId)??0)===loading.canvasElementCounts[element.ownerCanvasId];
}
export function loadingCommand(command:Command,document:Document,registry:Registry,loading?:LoadingState,base=document,source?:CanonicalProposal):Availability{
if(loading?.version===4&&source){const scoped=structuralCommand(command,document,registry,loading,source);if(scoped)return scoped;}
if(loading&&(loading.version===4||loading.version===5)&&source){const scoped=structuralCommand(command,document,registry,loading,source);if(scoped)return scoped;}
if(command.type==='connection.path')return pathAllowed(command.target.id,document,registry,loading,base)?{enabled:true}:denied;
if(command.type!=='element.data'&&command.type!=='element.geometry')return denied;
const element=document.elements.get(command.target.id);if(!element||registry.resolve(element)!=='known')return denied;
@@ -38,7 +41,7 @@ export function loadingCommand(command:Command,document:Document,registry:Regist
/** Check actual changes too: a feature handler cannot evade command admission by
* modifying a protected reference field as a side effect of an allowed edit. */
export function assertLoadingChanges(changes:readonly Change[],document:Document,registry:Registry,loading?:LoadingState,source?:CanonicalProposal):void{
if(loading?.version===4&&source){assertStructuralChanges(changes,document,registry,loading,source);return;}
if(loading&&(loading.version===4||loading.version===5)&&source){assertStructuralChanges(changes,document,registry,loading,source);return;}
for(const change of changes){
if(change.kind!=='field')throw fault('model_loading');
const element=document.elements.get(change.target.id);
@@ -51,16 +54,17 @@ export function validateLoadingRuntime(runtime:RuntimeState,registry:Registry):v
if(runtime.loading.version>=3&&!source||runtime.loading.version<3&&runtime.loadingRead)throw fault('invalid_loading_state');
validateLoadingState(runtime.loading,source?.document??runtime.acknowledged,source?.versions??runtime.versions);
if(source)validateLoadingRead(runtime,registry);
if(runtime.loading.version<3&&runtime.ledger.requests.size||runtime.resources.size||runtime.runs.size||runtime.imports?.size||runtime.orphanFiles?.size||runtime.recoveries.size)throw fault('invalid_loading_state');
if(runtime.loading.version<3&&runtime.ledger.requests.size||runtime.resources.size||runtime.runs.size||runtime.loading.version<5&&runtime.imports?.size||runtime.orphanFiles?.size||runtime.recoveries.size)throw fault('invalid_loading_state');
validateLoadingImportAdmissions(runtime);
let logical=source?.document??runtime.acknowledged;
for(const record of [...runtime.ledger.operations.values()].sort((a,b)=>a.intent.sequence-b.intent.sequence)){
if(record.intent.operationType!=='document.change'||runtime.loading.version<3&&(record.requestId||record.resolution||!['pending','blocked'].includes(record.phase)))throw fault('invalid_loading_state');
if(runtime.loading.version===4){logical=validateStructuralIntent(record.intent,logical,registry,runtime.loading,source!);}else assertLoadingChanges(record.intent.changes,source?.document??runtime.acknowledged,registry,runtime.loading);
if(runtime.loading.version>=4){logical=validateStructuralIntent(record.intent,logical,registry,runtime.loading,source!);}else assertLoadingChanges(record.intent.changes,source?.document??runtime.acknowledged,registry,runtime.loading);
}
}
export function validatePrefixExtension(previous:Document,previousVersions:ReadonlyMap<Id,number>,state:LoadingState,next:Document,versions:ReadonlyMap<Id,number>,nextState:LoadingState,registry:Registry):void{
validateDocument(next,registry);validateLoadingState(nextState,next,versions);
if(next.projectId!==previous.projectId||state.sourceToken!==nextState.sourceToken||state.version!==nextState.version||!equal(state.canvasElementCounts,nextState.canvasElementCounts)||!equal(state.elementOwners,nextState.elementOwners)||state.totalElements!==nextState.totalElements||nextState.receivedElements<state.receivedElements||!equal(state.declaredCanvasIds,nextState.declaredCanvasIds)||!equal(Object.fromEntries(previousVersions),Object.fromEntries(versions))||!equal(Object.fromEntries(previous.tombstones),Object.fromEntries(next.tombstones))||[...previous.canvases].some(([id,record])=>!equal(record,next.canvases.get(id)))||[...previous.elements].some(([id,record])=>!equal(record,next.elements.get(id))))throw fault('loading_source_mismatch');
if(next.projectId!==previous.projectId||state.sourceToken!==nextState.sourceToken||state.version!==nextState.version||!equal(state.canvasElementCounts,nextState.canvasElementCounts)||!equal(state.elementOwners,nextState.elementOwners)||!equal(state.sourceCanvases,nextState.sourceCanvases)||state.totalElements!==nextState.totalElements||nextState.receivedElements<state.receivedElements||!equal(state.declaredCanvasIds,nextState.declaredCanvasIds)||!equal(Object.fromEntries(previousVersions),Object.fromEntries(versions))||!equal(Object.fromEntries(previous.tombstones),Object.fromEntries(next.tombstones))||[...previous.canvases].some(([id,record])=>!equal(record,next.canvases.get(id)))||[...previous.elements].some(([id,record])=>!equal(record,next.elements.get(id))))throw fault('loading_source_mismatch');
}
/** Full fresh reads may include new records in an unchanged-version scope. Known
* records still cannot change without an owner version advancing. */
@@ -72,9 +76,9 @@ export function validateLoadingCompletion(previous:Document,previousVersions:Rea
for(const [id,record]of previous.elements)if(previousVersions.get(record.ownerCanvasId)===versions.get(record.ownerCanvasId)&&!equal(record,next.elements.get(id)))throw fault('canonical_version_mismatch',[record.ownerCanvasId]);
for(const [id,dead]of previous.tombstones)if((next.tombstones.get(id)??0)<dead)throw fault('stale_canonical_lifecycle',[id]);
const same=equal(Object.fromEntries(previousVersions),Object.fromEntries(versions));
if(state.version!==1&&!(state.version===4&&fresh)&&(!fresh||same)){const counts=new Map<Id,number>();for(const element of next.elements.values())counts.set(element.ownerCanvasId,(counts.get(element.ownerCanvasId)??0)+1);if(state.declaredCanvasIds.some(id=>(counts.get(id)??0)!==state.canvasElementCounts[id]))throw fault('invalid_model_coverage');}
if(state.version!==1&&!(state.version>=4&&fresh)&&(!fresh||same)){const counts=new Map<Id,number>();for(const element of next.elements.values())counts.set(element.ownerCanvasId,(counts.get(element.ownerCanvasId)??0)+1);if(state.declaredCanvasIds.some(id=>(counts.get(id)??0)!==state.canvasElementCounts[id]))throw fault('invalid_model_coverage');}
if(!fresh&&(!same||sourceToken!==state.sourceToken))throw fault('loading_source_mismatch');
if(same&&!(state.version===4&&fresh)&&(next.elements.size!==state.totalElements||next.canvases.size!==state.declaredCanvasIds.length||state.declaredCanvasIds.some(id=>!next.canvases.has(id))||!equal(Object.fromEntries(previous.tombstones),Object.fromEntries(next.tombstones))))throw fault('incomplete_loading_model');
if(same&&!(state.version>=4&&fresh)&&(next.elements.size!==state.totalElements||next.canvases.size!==state.declaredCanvasIds.length||state.declaredCanvasIds.some(id=>!next.canvases.has(id))||!equal(Object.fromEntries(previous.tombstones),Object.fromEntries(next.tombstones))))throw fault('incomplete_loading_model');
}
/** Counts refer to the read baseline, never to optimistic working membership. */
@@ -88,7 +92,7 @@ export function loadingQueryCoverage(snapshot:import('./contracts.ts').EngineSna
const loading=snapshot.runtime.loading,base=snapshot.runtime.loadingRead?.document??snapshot.runtime.acknowledged;
if(!loading)return{projectComplete:true,topologyComplete:true,completeCanvasIds:[...snapshot.document.canvases.keys()]};
const counts=directCounts(base);
return{...(loading.version===4?{elementIds:namespace(loading.elementOwners),loadedElementIds:loadedNamespace(base)}:{}),projectComplete:false,topologyComplete:base.canvases.size===loading.declaredCanvasIds.length,completeCanvasIds:loading.version===4?[...snapshot.document.canvases.keys()].filter(id=>ownerReady(loading,snapshot.runtime.loadingRead!,snapshot.document,id)):loading.version!==1?[...base.canvases.keys()].filter(id=>snapshot.document.canvases.has(id)&&(counts.get(id)??0)===loading.canvasElementCounts[id]):[]};
return{...((loading.version===4||loading.version===5)?{elementIds:namespace(loading.elementOwners),loadedElementIds:loadedNamespace(base)}:{}),projectComplete:false,topologyComplete:base.canvases.size===loading.declaredCanvasIds.length,completeCanvasIds:(loading.version===4||loading.version===5)?[...snapshot.document.canvases.keys()].filter(id=>ownerReady(loading,snapshot.runtime.loadingRead!,snapshot.document,id)):loading.version!==1?[...base.canvases.keys()].filter(id=>snapshot.document.canvases.has(id)&&(counts.get(id)??0)===loading.canvasElementCounts[id]):[]};
}
/** Validate the retained source separately from the moving ACK baseline. Only
@@ -96,7 +100,7 @@ export function loadingQueryCoverage(snapshot:import('./contracts.ts').EngineSna
function validateLoadingRead(runtime:RuntimeState,registry:Registry):void {
const source=runtime.loadingRead!,loading=runtime.loading!,base=source.document,ack=runtime.acknowledged;
validateDocument(base,registry);
if(loading.version===4){validateStructuralRead(runtime,registry);return;}
if(loading.version>=4){validateStructuralRead(runtime,registry);return;}
if(base.projectId!==ack.projectId||!equal(Object.fromEntries(base.canvases),Object.fromEntries(ack.canvases))||!equal(Object.fromEntries(base.tombstones),Object.fromEntries(ack.tombstones))||base.elements.size!==ack.elements.size||source.versions.size!==runtime.versions.size)throw fault('invalid_loading_read');
const ready=new Set(loadingQueryCoverage({runtime,document:ack} as import('./contracts.ts').EngineSnapshot).completeCanvasIds);
for(const [id,version]of source.versions){const current=runtime.versions.get(id);if(current===undefined||!Number.isSafeInteger(current)||current<version||current!==version&&!ready.has(id))throw fault('invalid_loading_read',[id]);}
@@ -112,7 +116,7 @@ function validateLoadingRead(runtime:RuntimeState,registry:Registry):void {
/** Overlay only scopes whose ACK version advanced since this immutable capture. */
export function mergeLoadingRead(runtime:RuntimeState,read:CanonicalProposal,registry:Registry):CanonicalProposal {
const source=runtime.loadingRead;if((!runtime.loading||runtime.loading.version<3)||!source)return read;
if(runtime.loading.version===4){const projected=appliedSource(runtime,read);validateDocument(projected.document,registry);return projected;}
if(runtime.loading.version>=4){const projected=appliedSource(runtime,read);validateDocument(projected.document,registry);return projected;}
const advanced=new Set([...runtime.versions].filter(([id,version])=>version>(source.versions.get(id)??Infinity)).map(([id])=>id));
const elements=[...read.document.elements.values()].filter(record=>!advanced.has(record.ownerCanvasId));
for(const element of runtime.acknowledged.elements.values())if(advanced.has(element.ownerCanvasId))elements.push(element);
@@ -121,7 +125,7 @@ export function mergeLoadingRead(runtime:RuntimeState,read:CanonicalProposal,reg
}
function importDocument(document:Document,elements:readonly import('../canvas-core/types.ts').ElementRecord[]):Document{return createDocument(document.projectId,[...document.canvases.values()],elements,document.tombstones);}
export function loadingCanSend(runtime:RuntimeState,canvasIds:readonly Id[],document=runtime.acknowledged):boolean {
if(!runtime.loading)return true;if(runtime.loading.version<3)return false;if(runtime.loading.version===4)return canvasIds.length>0&&canvasIds.every(id=>structuralRequestScope(runtime,runtime.loadingRead!,id));
if(!runtime.loading)return true;if(runtime.loading.version<3)return false;if(runtime.loading.version>=4)return canvasIds.length>0&&canvasIds.every(id=>structuralRequestScope(runtime,runtime.loadingRead!,id));
const ready=new Set(loadingQueryCoverage({runtime,document} as import('./contracts.ts').EngineSnapshot).completeCanvasIds);return canvasIds.length>0&&canvasIds.every(id=>ready.has(id));
}
@@ -132,7 +136,7 @@ function validateStructuralRead(runtime:RuntimeState,registry:Registry){
for(const request of runtime.ledger.requests.values())if(request.protocolVersion!==2||!request.canvasIds.length||Object.keys(request.expectedVersions).length!==request.canvasIds.length||request.canvasIds.some(id=>!structuralRequestScope(runtime,source,id)||!Number.isSafeInteger(request.expectedVersions[id])||request.expectedVersions[id]<(source.versions.get(id)??0)||request.expectedVersions[id]>(runtime.versions.get(id)??0)))throw fault('invalid_loading_request');
}
function structuralRequestScope(runtime:RuntimeState,source:CanonicalProposal,id:Id):boolean {
export function structuralRequestScope(runtime:RuntimeState,source:CanonicalProposal,id:Id):boolean {
if(source.versions.has(id))return ownerReady(runtime.loading!,source,source.document,id);
return [...runtime.ledger.operations.values()].some(record=>record.intent.changes.some(change=>change.kind==='canvas'&&change.id===id&&!change.before&&!!change.after));
}