@plasius/asset-contracts 0.7.0 → 0.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,92 @@
1
+ # ADR 0012: Model Resource Promotion Closure
2
+
3
+ - Status: Accepted
4
+ - Date: 2026-10-24
5
+ - Tracking: asset-contracts Task 51; site Feature 1153 / Epic 1147
6
+
7
+ ## Context
8
+
9
+ Released resource and texture qualifiers verify complete inventories and bytes,
10
+ retain original resource identities and distinguish derived output. Conversion
11
+ qualification evaluates raw canonical diagnostics. Typed GPU promotion already
12
+ checks bytes, exact versions and GPU compatibility envelopes. ModelCandidate
13
+ and its confirmation already enforce rights, security, fidelity, render and
14
+ human gates. Published asset-pipeline owns orchestration. Missing composition
15
+ must not create an alternate approval route or duplicate those contracts.
16
+
17
+ ## Decision
18
+
19
+ Add opt-in createModelResourcePromotionRecord. Supply a typed model promotion,
20
+ complete source inventory/bytes, existing output texture qualification inputs,
21
+ raw diagnostic input, exact resource-to-package/path bindings and the existing
22
+ candidate/confirmation. Reuse all existing factories and qualifiers. Snapshot
23
+ inert metadata and both byte sets before awaiting; retain only frozen metadata
24
+ in output, never bytes, Blobs or GPU objects. No dependency update is required.
25
+
26
+ Qualify both complete packages, exact immutable distinct source/output
27
+ identities, graph and candidate converter identity/version/source format, source
28
+ entrypoint digest and converted output digest. Require re-evaluated diagnostic
29
+ and loss arrays to equal candidate evidence. Bind every projected diagnostic
30
+ resource and candidate LOD/collision/fidelity resource to a qualified file's
31
+ path/hash/length/MIME; runtime resources use the output package and LOD0 names
32
+ its entrypoint. Existing catalog candidates also match target asset/version.
33
+ Explicit bindings avoid deriving trust from opaque URI strings.
34
+
35
+ Require a passed review without blocking findings; use the existing candidate
36
+ confirmation factory for rights/security/fidelity/render/human policy. Reuse
37
+ createGpuAssetPromotionRecord for the exact copied output bytes and typed GPU
38
+ envelope. Bind the HTTPS runtime URI suffix to assetId/version/entrypoint;
39
+ existing credential/query/fragment restrictions still apply. These are local
40
+ contract checks. Hosted services must authenticate approvers, verify signed
41
+ tokens and validate evidence issuers; this library grants no approval authority.
42
+
43
+ The separate package closureHash is SHA-256 over existing canonical GPU JSON
44
+ serialization of version, qualified source/output, evaluated diagnostics and
45
+ sorted resource bindings. It includes all file descriptors, original/derived
46
+ lineage and authored reload metadata. It excludes promotion events and
47
+ candidate/confirmation tokens so renewed approval does not change package
48
+ identity. Preserve existing leaf/assembly processing closureHash semantics.
49
+
50
+ Rollback requires locally qualified prior/current results through a private
51
+ WeakSet, then requalifies target bytes and all independent gates. Compare exact
52
+ prior package closure, manifest and runtime URI; enforce same asset/channel,
53
+ distinct current/target versions and rollbackOfVersion=current.version. Emit
54
+ both exact closure identities. No version, historical record or runtime pointer
55
+ is mutated. Forward calls reject rollback metadata. JSON clones are not local
56
+ qualification: reconstruct and requalify persisted inputs before use. The
57
+ WeakSet is process/module-instance evidence only, not a durable signature or
58
+ trusted history database. Hosted storage owns create-only immutable writes,
59
+ verified historical records, concurrency control and pointer-last transactions.
60
+
61
+ ## NFRs, alternatives and limits
62
+
63
+ Fixed inert metadata/count/character/depth and byte ceilings, sequential package
64
+ qualification, native abort/default 10s deadline (1–60s), bounded underlying
65
+ hashing, no network/retries/logging. A digest cannot be preempted; phase checks
66
+ prevent late successful return. Normalize even hostile thrown proxies to fixed
67
+ value-free errors. Authored package metadata and candidate content remain
68
+ trusted data requiring privacy minimization; only diagnostic projections are
69
+ public-safe. No raw forensic diagnostics or provider strings are retained.
70
+
71
+ Tightening legacy factories would break staged construction. Copying pipeline
72
+ or candidate policy would drift from released owners. Accepting structural or
73
+ serialized history as qualified would lose the local byte-check evidence.
74
+ Persisting bytes in a mutable qualification registry would add memory and
75
+ lifecycle ownership; use bounded ephemeral copies and weak membership instead.
76
+
77
+ This boundary does not parse internal model references, prove embedding or
78
+ atlas algorithms, revalidate shader ABI policy (Task 23), supply animation
79
+ schemas (Task 12), verify external signatures, or perform storage/promotion.
80
+ Existing exact immutable assembly child refs stay in candidate evidence; their
81
+ separate catalog packages remain hosted resolver responsibilities.
82
+
83
+ Backend evaluator gpu.model.conversion.enabled controls adoption. Enabled
84
+ callers use the opt-in composition; disabled/rollback adoption uses legacy
85
+ factories. No flag service I/O/mutation or UI capability belongs in this library.
86
+ Requirements-first tests cover complete real glTF/GLB fixtures, byte drift,
87
+ evidence/resource/gate drift, exact rollback, deterministic hashes, immutable
88
+ snapshots and malformed/limit/cancel/deadline cases. Qualify packed and public
89
+ ESM/CJS/types/browser consumers and released asset-pipeline orchestration.
90
+ Release additive minor through existing source PR/exact-main push CI and
91
+ two-phase cd.yml/main/production hosted OIDC path, then verify registry
92
+ integrity/provenance before Done. No site deployment is authorized by Task 51.
@@ -11,3 +11,4 @@
11
11
  - [ADR 0009: Additive Model Resource Manifest Qualification](./adr-0009-model-resource-manifest-qualification.md)
12
12
  - [ADR 0010: Public Conversion Diagnostics](./adr-0010-public-conversion-diagnostics.md)
13
13
  - [ADR 0011: Reloadable Texture and Resource Roles](./adr-0011-reloadable-texture-resource-roles.md)
14
+ - [ADR 0012: Model Resource Promotion Closure](./adr-0012-model-resource-promotion-closure.md)
@@ -6,3 +6,4 @@
6
6
  - [TDR 0004: Model Resource Manifest Qualification](./tdr-0004-model-resource-manifest-qualification.md)
7
7
  - [TDR 0005: Public Conversion Diagnostics](./tdr-0005-public-conversion-diagnostics.md)
8
8
  - [TDR 0006: Reloadable Texture and Resource Qualification](./tdr-0006-reloadable-texture-resource-roles.md)
9
+ - [TDR 0007: Model Resource Promotion Closure](./tdr-0007-model-resource-promotion-closure.md)
@@ -0,0 +1,63 @@
1
+ # TDR 0007: Model Resource Promotion Qualification
2
+
3
+ - Status: Accepted
4
+ - Date: 2026-10-24
5
+ - Tracking: asset-contracts Task 51; site Feature 1153
6
+ - Decision: ADR 0012
7
+
8
+ 1. Read inert root/options records; reject getters, symbols, exotic/sparse
9
+ arrays, prototype keys and unknown fields. Snapshot all metadata, validate
10
+ existing candidate and confirmation, and copy both bounded byte inventories
11
+ before awaiting. Keep the original privately verified core graph.
12
+ 2. Qualify source inventory/bytes, then output graph/resources/derived metadata,
13
+ then re-evaluate raw canonical diagnostics with the released evaluator.
14
+ 3. Match immutable distinct asset/version identities; typed output manifest and
15
+ promotion manifest are equal with files sorted by path. Match graph and
16
+ candidate converter ID/version/source format, exact source and output hashes,
17
+ source provenance and diagnostics/losses. A warning is allowed only when the
18
+ candidate carries the same freshly evaluated warning; unsupported/failed or
19
+ unconsented loss never grants promotion.
20
+ 4. Validate unique explicit URI bindings to source/output file descriptors.
21
+ Compare every runtime LOD/collision/fidelity resource and projected diagnostic
22
+ resource exactly. LOD0 must bind the output entrypoint. Core still owns
23
+ embedded resources and original/derived graph relationships; no parser here.
24
+ 5. Require review passed with no blocking findings, existing candidate gates
25
+ and exact confirmation. Check typed GPU manifest equality, ABI envelopes and
26
+ canonical JSON file bytes through createGpuAssetPromotionRecord. Runtime URI
27
+ must end in /assetId/version/entrypoint and satisfy existing immutable HTTPS
28
+ policy. Hosted verifiers still authenticate signatures and approvers.
29
+ 6. SHA-256 canonicalize version/source/output/diagnostics/sorted bindings. Leave
30
+ events, candidate and confirmation out of package identity, allowing fresh
31
+ approval for the exact prior package. Retain candidate/confirmation alongside
32
+ the closure for callers' independent evidence checks.
33
+ 7. For rolled-back outcome require private local membership for both prior and
34
+ current; exact prior closure/manifest/runtime URI, same asset/channel, target
35
+ prior version and rollbackOfVersion=current version. Emit from/to exact
36
+ closure identities and never mutate history. Forward calls reject history.
37
+ Persisted JSON must be reconstructed and qualified again; storage owns
38
+ authenticated historical admission, immutable create-only writes and CAS.
39
+
40
+ Limits: 1,024 public resource bindings; 262,144 metadata values; 2,097,152 UTF-16
41
+ characters; depth 24; 2,048 characters/string and 128/key. Existing byte ceilings
42
+ apply independently to source and output (16,384 files, 64 MiB/file,128 MiB per
43
+ package). Copies are bounded and ephemeral; upstream qualifiers also copy
44
+ payloads. No retained byte cache or mutable durable registry. Package phases
45
+ are sequential; the final existing typed factory retains its bounded file-set
46
+ hashing behavior. Default deadline 10s, configured integer1–60,000ms; cancellation
47
+ and timer race cover async work, phase checks cover bounded synchronous work.
48
+ No retry/I/O or logging. Value-free errors normalize hostile upstream proxies.
49
+
50
+ NFR evidence: requirements-based real GLB header/JSON and source fixtures,
51
+ exact hashes/bytes, positive warnings, stale diagnostics, missing/tampered
52
+ resources, alias/identity/URI drift, review/rights/security/GPU/human gates,
53
+ brand and rollback mismatch, fresh confirmation, deterministic inventory order,
54
+ copy/freeze isolation, malformed/limits/deadlines/cancel and flag adoption tests.
55
+ Run full Node24 frozen tests/privacy/coverage>=80% with all changed sources in
56
+ LCOV, types/lint/build/tree/full/runtime audits/privacy/pack/drypublish. Isolated
57
+ packed/public ESM/CJS/types/browser consumers use released asset-pipeline to
58
+ verify state transitions still require review and approval. Accessibility/SEO
59
+ N/A library; trusted metadata requires caller minimization; observability uses
60
+ stable codes without logging. Existing package boundaries/API remain stable.
61
+ Remote flag gpu.model.conversion.enabled uses backend evaluator; capability N/A.
62
+ Release via approved cd.yml/main/production/OIDC with exact CI and public
63
+ registry/provenance/consumer verification. No site/backend secretref change.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@plasius/asset-contracts",
3
- "version": "0.7.0",
3
+ "version": "0.8.0",
4
4
  "description": "Canonical contracts for Plasius asset jobs, model resolution, immutable WGSL shader assets, reviews, and promotion records.",
5
5
  "type": "module",
6
6
  "sideEffects": false,
package/src/index.ts CHANGED
@@ -40,6 +40,7 @@ export * from "./pvox-model-resolution.js";
40
40
  export * from "./pvox-model-resolution-schema.js";
41
41
  export * from "./model-resource-manifest.js";
42
42
  export * from "./model-texture-resources.js";
43
+ export * from "./model-resource-promotion.js";
43
44
  export * from "./model-conversion-diagnostics.js";
44
45
  export {
45
46
  GPU_SHADER_STORE_FEATURE_FLAG,
@@ -0,0 +1,219 @@
1
+ import { canonicalizeGpuContract, computeSha256 } from "@plasius/gpu-shader";
2
+ import { assertImmutableAssetVersion } from "./asset-version.js";
3
+ import { qualifyModelResourceManifest, MODEL_RESOURCE_MANIFEST_LIMITS, type ModelResourceManifestQualificationInput, type ModelResourceFileInput } from "./model-resource-manifest.js";
4
+ import { qualifyModelTextureResources, type ModelTextureResourceQualificationInput, type QualifiedModelTextureResources } from "./model-texture-resources.js";
5
+ import { qualifyModelConversionDiagnostics, type ModelConversionDiagnosticsInput, type QualifiedModelConversionDiagnostics } from "./model-conversion-diagnostics.js";
6
+ import { createModelCandidate, createModelCandidateConfirmation, createModelResourceRef, type ModelCandidate, type ModelCandidateConfirmation, type ModelResourceRef } from "./model-resolution.js";
7
+ import { createModelAssetManifest, createGpuAssetPromotionRecord, type AssetManifest, type AssetPromotionRecord, type ModelAssetManifest } from "./index.js";
8
+
9
+ /** Hard ceilings supplement the existing per-package byte and graph limits. */
10
+ export const MODEL_RESOURCE_PROMOTION_LIMITS = Object.freeze({ bindings: 1024, values: 262_144, characters: 2_097_152, depth: 24, timeoutMs: 60_000 });
11
+ /** Explicit location of a public evidence resource inside one qualified package. */
12
+ export interface ModelPromotionResourceBinding {
13
+ readonly resource: ModelResourceRef;
14
+ readonly package: "source" | "output";
15
+ readonly filePath: string;
16
+ }
17
+ /** Existing contracts composed without granting durable storage or signature authority. */
18
+ export interface ModelResourcePromotionInput {
19
+ readonly promotion: AssetPromotionRecord<ModelAssetManifest>;
20
+ readonly source: Omit<ModelResourceManifestQualificationInput, "signal" | "timeoutMs">;
21
+ readonly output: ModelTextureResourceQualificationInput;
22
+ readonly diagnostics: ModelConversionDiagnosticsInput;
23
+ readonly resourceBindings: readonly ModelPromotionResourceBinding[];
24
+ readonly candidate: ModelCandidate;
25
+ readonly confirmation: ModelCandidateConfirmation;
26
+ readonly rollback?: { readonly prior: QualifiedModelResourcePromotion; readonly current: QualifiedModelResourcePromotion };
27
+ }
28
+ /** Native controls cover the entire composition, with no I/O, retries or pointer writes. */
29
+ export interface ModelResourcePromotionOptions { readonly signal?: AbortSignal; readonly timeoutMs?: number }
30
+ /** Frozen call-time evidence. Requalify persisted JSON; a clone has no local qualification brand. */
31
+ export interface QualifiedModelResourcePromotion {
32
+ readonly contractVersion: 1;
33
+ readonly promotion: AssetPromotionRecord<ModelAssetManifest>;
34
+ readonly source: AssetManifest;
35
+ readonly output: QualifiedModelTextureResources;
36
+ readonly diagnostics: QualifiedModelConversionDiagnostics;
37
+ readonly resourceBindings: readonly ModelPromotionResourceBinding[];
38
+ readonly candidate: ModelCandidate;
39
+ readonly confirmation: ModelCandidateConfirmation;
40
+ /** Separate package closure digest; preserves legacy candidate leaf/assembly hash semantics. */
41
+ readonly closureHash: string;
42
+ readonly rollback?: { readonly fromVersion: string; readonly fromClosureHash: string; readonly toVersion: string; readonly toClosureHash: string };
43
+ }
44
+ /** Stable value-free failure; upstream exceptions and authored values are never included. */
45
+ export class ModelResourcePromotionError extends TypeError {
46
+ constructor(public readonly code: "invalid-input" | "limit-exceeded" | "package-mismatch" | "evidence-mismatch" | "gate-blocked" | "rollback-mismatch" | "aborted" | "deadline-exceeded") {
47
+ super(`Invalid model resource promotion: ${code}`); this.name = "ModelResourcePromotionError";
48
+ }
49
+ }
50
+ const qualified = new WeakSet<object>();
51
+ function fail(code: ModelResourcePromotionError["code"] = "invalid-input"): never { throw new ModelResourcePromotionError(code); }
52
+ function fields(value: unknown, allowed?: readonly string[]): Record<string, unknown> {
53
+ if (!value || typeof value !== "object" || ![Object.prototype, null].includes(Object.getPrototypeOf(value))) fail();
54
+ const keys = Reflect.ownKeys(value); if (keys.length > 1024) fail("limit-exceeded");
55
+ const result: Record<string, unknown> = Object.create(null) as Record<string, unknown>;
56
+ for (const key of keys) {
57
+ if (typeof key !== "string" || key.length > 128 || ["__proto__", "constructor", "prototype"].includes(key) || (allowed && !allowed.includes(key))) fail();
58
+ const property = Object.getOwnPropertyDescriptor(value, key)!;
59
+ if (!("value" in property) || !property.enumerable) fail();
60
+ result[key] = property.value;
61
+ }
62
+ return result;
63
+ }
64
+ function array(value: unknown, maximum: number): unknown[] {
65
+ if (!Array.isArray(value) || Object.getPrototypeOf(value) !== Array.prototype) fail();
66
+ if (value.length > maximum) fail("limit-exceeded");
67
+ if (Reflect.ownKeys(value).length !== value.length + 1) fail();
68
+ return Array.from({ length: value.length }, (_, i) => {
69
+ const descriptor = Object.getOwnPropertyDescriptor(value, String(i));
70
+ if (!descriptor || !("value" in descriptor) || !descriptor.enumerable) fail();
71
+ return descriptor.value;
72
+ });
73
+ }
74
+ function snapshot(value: unknown, budget: { values: number; characters: number }, depth = 0): unknown {
75
+ if (--budget.values < 0 || depth > MODEL_RESOURCE_PROMOTION_LIMITS.depth) fail("limit-exceeded");
76
+ if (value === null || value === undefined || typeof value === "boolean") return value;
77
+ if (typeof value === "number" && Number.isFinite(value)) return value;
78
+ if (typeof value === "string") {
79
+ budget.characters -= value.length;
80
+ if (value.length > 2048 || budget.characters < 0) fail("limit-exceeded"); return value;
81
+ }
82
+ if (Array.isArray(value)) return Object.freeze(array(value, MODEL_RESOURCE_MANIFEST_LIMITS.files).map((v) => snapshot(v, budget, depth + 1)));
83
+ const result = fields(value);
84
+ for (const key of Object.keys(result)) {
85
+ budget.characters -= key.length; if (budget.characters < 0) fail("limit-exceeded");
86
+ result[key] = snapshot(result[key], budget, depth + 1);
87
+ }
88
+ return Object.freeze(result);
89
+ }
90
+ const typedArrayPrototype = Object.getPrototypeOf(Uint8Array.prototype);
91
+ const byteLengthGetter = Object.getOwnPropertyDescriptor(typedArrayPrototype, "byteLength")!.get!;
92
+ const bufferGetter = Object.getOwnPropertyDescriptor(typedArrayPrototype, "buffer")!.get!;
93
+ function copyFiles(value: unknown): readonly ModelResourceFileInput[] {
94
+ let total = 0;
95
+ const files = array(value, MODEL_RESOURCE_MANIFEST_LIMITS.files).map((v) => {
96
+ const file = fields(v, ["path", "bytes", "contentType"]); const bytes = file.bytes;
97
+ if (typeof file.path !== "string" || typeof file.contentType !== "string" || !(bytes instanceof Uint8Array)
98
+ || Object.getPrototypeOf(bytes) !== Uint8Array.prototype || !(bufferGetter.call(bytes) instanceof ArrayBuffer)) fail();
99
+ const length = byteLengthGetter.call(bytes) as number;
100
+ total += length;
101
+ if (length > MODEL_RESOURCE_MANIFEST_LIMITS.fileBytes || total > MODEL_RESOURCE_MANIFEST_LIMITS.totalBytes) fail("limit-exceeded");
102
+ return { path: file.path, bytes, contentType: file.contentType };
103
+ });
104
+ return files.map((f) => ({ ...f, bytes: new Uint8Array(f.bytes) }));
105
+ }
106
+ const same = (a: unknown, b: unknown): boolean => canonicalizeGpuContract(a) === canonicalizeGpuContract(b);
107
+ const entry = (m: AssetManifest) => m.files.find((f) => f.path === m.entrypoint)!;
108
+ const identity = (m: AssetManifest): string => `${m.assetId}/${m.version}`;
109
+
110
+ /**
111
+ * Qualifies complete source/output inventories and conversion lineage before
112
+ * composing existing typed promotion, rights, hard-gate and human confirmation
113
+ * contracts. Tokens remain trusted hosted assertions; this is not a signature
114
+ * verifier, parser, storage transaction or permission grant. Rollback rechecks
115
+ * target bytes/gates and requires locally qualified exact prior/current records.
116
+ */
117
+ export async function createModelResourcePromotionRecord(input: ModelResourcePromotionInput, options: ModelResourcePromotionOptions = {}): Promise<QualifiedModelResourcePromotion> {
118
+ let timer: ReturnType<typeof setTimeout> | undefined; let signal: AbortSignal | undefined; let onAbort: (() => void) | undefined;
119
+ try {
120
+ const started = performance.now(); const controls = fields(options, ["signal", "timeoutMs"]);
121
+ const timeoutMs = controls.timeoutMs === undefined ? 10_000 : controls.timeoutMs;
122
+ if (typeof timeoutMs !== "number" || !Number.isSafeInteger(timeoutMs) || timeoutMs < 1 || timeoutMs > MODEL_RESOURCE_PROMOTION_LIMITS.timeoutMs) fail();
123
+ signal = controls.signal as AbortSignal | undefined;
124
+ const aborted = Object.getOwnPropertyDescriptor(AbortSignal.prototype, "aborted")!.get!;
125
+ const check = () => { if (signal !== undefined && aborted.call(signal)) fail("aborted"); if (performance.now() - started >= timeoutMs) fail("deadline-exceeded"); };
126
+ check();
127
+ const root = fields(input, ["promotion", "source", "output", "diagnostics", "resourceBindings", "candidate", "confirmation", "rollback"]);
128
+ const budget = { values: MODEL_RESOURCE_PROMOTION_LIMITS.values, characters: MODEL_RESOURCE_PROMOTION_LIMITS.characters };
129
+ const snap = (v: unknown) => snapshot(v, budget);
130
+ const promotionInput = snap(root.promotion) as ModelResourcePromotionInput["promotion"];
131
+ const sourceInput = fields(root.source, ["manifest", "packageManifest", "files"]);
132
+ const outputInput = fields(root.output, ["manifest", "packageManifest", "files", "graph", "resources", "derived"]);
133
+ const sourceMetadata = { manifest: snap(sourceInput.manifest) as AssetManifest, packageManifest: snap(sourceInput.packageManifest) as ModelResourceManifestQualificationInput["packageManifest"] };
134
+ const outputMetadata = { manifest: snap(outputInput.manifest) as AssetManifest, packageManifest: snap(outputInput.packageManifest) as ModelTextureResourceQualificationInput["packageManifest"],
135
+ graph: outputInput.graph as ModelTextureResourceQualificationInput["graph"], resources: snap(outputInput.resources) as ModelTextureResourceQualificationInput["resources"], derived: snap(outputInput.derived) as ModelTextureResourceQualificationInput["derived"] };
136
+ const rawDiagnostics = snap(root.diagnostics);
137
+ const candidate = createModelCandidate(snap(root.candidate));
138
+ const confirmation = createModelCandidateConfirmation(snap(root.confirmation), candidate, candidate.resolutionId);
139
+ const bindings = array(snap(root.resourceBindings), MODEL_RESOURCE_PROMOTION_LIMITS.bindings).map((v): ModelPromotionResourceBinding => {
140
+ const b = fields(v, ["resource", "package", "filePath"]);
141
+ if ((b.package !== "source" && b.package !== "output") || typeof b.filePath !== "string") fail();
142
+ return Object.freeze({ resource: createModelResourceRef(b.resource), package: b.package, filePath: b.filePath });
143
+ }).sort((a, b) => a.resource.uri < b.resource.uri ? -1 : a.resource.uri > b.resource.uri ? 1 : 0);
144
+ const history = root.rollback === undefined ? undefined : fields(root.rollback, ["prior", "current"]);
145
+ if (history && (!qualified.has(history.prior as object) || !qualified.has(history.current as object))) fail("rollback-mismatch");
146
+ const prior = history?.prior as QualifiedModelResourcePromotion | undefined;
147
+ const current = history?.current as QualifiedModelResourcePromotion | undefined;
148
+ const sourceFiles = copyFiles(sourceInput.files); const outputFiles = copyFiles(outputInput.files);
149
+ check();
150
+ const interrupted = new Promise<never>((_resolve, reject) => {
151
+ timer = setTimeout(() => reject(new ModelResourcePromotionError("deadline-exceeded")), Math.max(1, timeoutMs - (performance.now() - started)));
152
+ onAbort = () => reject(new ModelResourcePromotionError("aborted"));
153
+ if (signal) AbortSignal.prototype.addEventListener.call(signal, "abort", onAbort, { once: true });
154
+ });
155
+ // Sequential package hashing limits digest concurrency. Every caller byte was
156
+ // copied above; only inert snapshots and the immutable core graph cross await.
157
+ const work = async () => {
158
+ const source = await qualifyModelResourceManifest({ ...sourceMetadata, files: sourceFiles, signal, timeoutMs }); check();
159
+ const output = await qualifyModelTextureResources({ ...outputMetadata, files: outputFiles }, { signal, timeoutMs }); check();
160
+ const diagnostics = await qualifyModelConversionDiagnostics(rawDiagnostics, { signal, timeoutMs }); check();
161
+ assertImmutableAssetVersion(source.version); assertImmutableAssetVersion(output.manifest.version);
162
+ if (identity(source) === identity(output.manifest) || !same(output.manifest, createModelAssetManifest({ ...promotionInput.manifest, files: [...promotionInput.manifest.files].sort((a, b) => a.path < b.path ? -1 : a.path > b.path ? 1 : 0) })) || output.manifest.assetKind !== "model") fail("package-mismatch");
163
+ const converter = candidate.processingManifest.converter;
164
+ if (!diagnostics.technicalAccepted || !same(diagnostics.source, candidate.provenance)
165
+ || diagnostics.source.contentHash !== entry(source).sha256 || output.source.sourceContentHash !== entry(source).sha256
166
+ || converter.sourceContentHash !== entry(source).sha256 || converter.outputContentHash !== entry(output.manifest).sha256
167
+ || converter.id !== output.source.converterId || converter.version !== output.source.converterVersion
168
+ || converter.sourceFormat !== output.source.sourceFormat || !same(diagnostics.converter.diagnostics, converter.diagnostics) || !same(diagnostics.converter.losses, converter.losses)) fail("evidence-mismatch");
169
+ const byUri = new Map<string, ModelPromotionResourceBinding>();
170
+ for (const binding of bindings) {
171
+ const file = (binding.package === "source" ? source : output.manifest).files.find((f) => f.path === binding.filePath);
172
+ if (!file || byUri.has(binding.resource.uri) || file.sha256 !== binding.resource.sha256 || file.byteLength !== binding.resource.byteLength || file.contentType !== binding.resource.contentType) fail("evidence-mismatch");
173
+ byUri.set(binding.resource.uri, binding);
174
+ }
175
+ const requireResource = (resource: ModelResourceRef, outputOnly = false) => {
176
+ const binding = byUri.get(resource.uri);
177
+ if (!binding || !same(binding.resource, resource) || (outputOnly && binding.package !== "output")) fail("evidence-mismatch");
178
+ return binding;
179
+ };
180
+ for (const d of diagnostics.diagnostics) if (d.resource) requireResource(d.resource);
181
+ for (const lod of candidate.processingManifest.lods) requireResource(lod.resource, true);
182
+ if (candidate.processingManifest.collision.resource) requireResource(candidate.processingManifest.collision.resource, true);
183
+ for (const f of candidate.processingManifest.fidelityEvidence) if (f.evidenceResource) requireResource(f.evidenceResource, true);
184
+ if (requireResource(candidate.processingManifest.lods[0]!.resource, true).filePath !== output.manifest.entrypoint) fail("evidence-mismatch");
185
+ if (candidate.assetRef.disposition === "existing" && (candidate.assetRef.asset.assetId !== output.manifest.assetId || candidate.assetRef.asset.version !== output.manifest.version)) fail("evidence-mismatch");
186
+ if (promotionInput.sourceAdapter !== output.manifest.sourceAdapter || promotionInput.reviewReport.passed !== true || promotionInput.reviewReport.findings.some((f) => f.severity === "blocking")) fail("gate-blocked");
187
+ const expectedSuffix = `/${output.manifest.assetId}/${output.manifest.version}/${output.manifest.entrypoint}`;
188
+ const runtimeUri = new URL(promotionInput.runtimeManifestUri);
189
+ if (!runtimeUri.pathname.endsWith(expectedSuffix)) fail("evidence-mismatch");
190
+ const promotion = await createGpuAssetPromotionRecord(promotionInput, new Map(outputFiles.map((f) => [f.path, f.bytes]))); check();
191
+ // Promotion event/time/approver are intentionally outside this digest, so
192
+ // an exact rollback can reuse the same package with a new approval event.
193
+ const closure = { contractVersion: 1 as const, source, output, diagnostics, resourceBindings: Object.freeze(bindings), candidate, confirmation };
194
+ const closureHash = await computeSha256(new TextEncoder().encode(canonicalizeGpuContract({ contractVersion: 1, source, output, diagnostics, resourceBindings: bindings }))); check();
195
+ let rollback: QualifiedModelResourcePromotion["rollback"];
196
+ if (promotion.outcome === "rolled-back") {
197
+ if (!prior || !current || prior.promotion.assetId !== promotion.assetId || current.promotion.assetId !== promotion.assetId
198
+ || prior.promotion.runtimeChannel !== promotion.runtimeChannel || current.promotion.runtimeChannel !== promotion.runtimeChannel
199
+ || promotion.version !== prior.promotion.version || promotion.version === current.promotion.version || promotion.rollbackOfVersion !== current.promotion.version
200
+ || closureHash !== prior.closureHash || !same(promotion.manifest, prior.promotion.manifest)
201
+ || promotion.runtimeManifestUri !== prior.promotion.runtimeManifestUri) fail("rollback-mismatch");
202
+ rollback = Object.freeze({ fromVersion: current.promotion.version, fromClosureHash: current.closureHash, toVersion: prior.promotion.version, toClosureHash: prior.closureHash });
203
+ } else if (history || promotion.rollbackOfVersion !== undefined) fail("rollback-mismatch");
204
+ const result = Object.freeze({ ...closure, promotion, closureHash, ...(rollback ? { rollback } : {}) });
205
+ qualified.add(result); return result;
206
+ };
207
+ return await Promise.race([work(), interrupted]);
208
+ } catch (error) {
209
+ // Even a proxy thrown by an inert-input inspection must not escape with
210
+ // authored exception text. Never invoke an upstream error's accessors.
211
+ let code: unknown;
212
+ try { code = Object.getOwnPropertyDescriptor(error, "code")?.value; } catch { return fail(); }
213
+ if (["invalid-input", "limit-exceeded", "package-mismatch", "evidence-mismatch", "gate-blocked", "rollback-mismatch", "aborted", "deadline-exceeded"].includes(code as string)) return fail(code as ModelResourcePromotionError["code"]);
214
+ return fail();
215
+ } finally {
216
+ if (timer !== undefined) clearTimeout(timer);
217
+ if (signal && onAbort) AbortSignal.prototype.removeEventListener.call(signal, "abort", onAbort);
218
+ }
219
+ }