@plasius/asset-contracts 0.5.0 → 0.6.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,61 @@
1
+ # ADR 0010: Public Conversion Diagnostics
2
+
3
+ - Status: Accepted
4
+ - Date: 2026-10-24
5
+ - Tracking: asset-contracts Task49; site Feature1153 / Epic1147
6
+
7
+ ## Context and decision
8
+
9
+ Existing ModelConverterDiagnostic/ModelConversionLoss and signed processing,
10
+ fidelity, rights, human review and promotion contracts already cover their
11
+ boundaries. Model-core0.7.0 owns canonical strict/tolerant/forensic policy,
12
+ applied repairs, unsupported data and loss consent. Its private diagnostics may
13
+ contain original values, rawSource, private paths and free-form adapter text.
14
+ They cannot be forwarded as public contract evidence.
15
+
16
+ Add qualifyModelConversionDiagnostics as an opt-in async boundary. Lazy import
17
+ the existing exact published core dependency and reuse its evaluator; do not
18
+ implement another acceptance policy or format parser. Inert bounded snapshots
19
+ precede the first await. Project fixed category codes/messages, exact public
20
+ resource bindings and existing ModelProvenance. Root diagnostics use source
21
+ identity; unmatched non-root paths and contradictory URI identities reject.
22
+ Only recognized completed repairs expose core-owned rule IDs. Discard adapter
23
+ codes/text/private paths, original values and forensic snapshots. Unknown
24
+ adapter codes become fixed category codes; unknown repair rules still block.
25
+
26
+ Explicit failure takes precedence, then unsupported, then canonical rejection,
27
+ then warning/clean success. Denied loss remains blocking in the legacy converter
28
+ loss array. Detailed output and existing-shape converter arrays are frozen.
29
+ Technical acceptance is not approval. No existing gate or factory is weakened.
30
+
31
+ ## Alternatives, costs and limits
32
+
33
+ Regex redaction cannot reliably scrub arbitrary provider text. Tightening
34
+ legacy factories would break staged consumers. Copying the canonical evaluator
35
+ would create policy drift. Requiring eager core loading would add avoidable
36
+ first-load work. Lazy import is asynchronous and may load a bundler chunk; module
37
+ loading cannot be cancelled, but the wait is bounded. Bounded synchronous work
38
+ checks deadlines before/after and cannot be preempted. Resource/provenance
39
+ binding is a trusted-caller assertion; byte evidence and promotion remain
40
+ separate. No dependency versions change. Tasks12/23/50/51 retain animation,
41
+ shader ABI, role and promotion-closure scope respectively.
42
+
43
+ ## Rollout, NFR and release
44
+
45
+ Backend evaluator gpu.model.conversion.enabled controls consumer adoption;
46
+ disabled/rollback callers retain legacy contracts. No service lookup or flag
47
+ mutation in this library, no UI entitlement capability. Test both branches and
48
+ rollback, malformed metadata, stable codes/serialization, privacy canaries,
49
+ resource identity, mutation, limits, deadline/cancellation and real processing
50
+ manifest composition. Existing independent gate tests remain active.
51
+
52
+ Security/privacy use allowlisted output and value-free errors. Fixed work and
53
+ input budgets, no retries/I/O/global state address reliability/performance and
54
+ scalability; immutable typed JSON and reused factories address maintenance and
55
+ testability. Fixed codes support safe observability without logging. UI/SEO and
56
+ durable storage controls are N/A. Require Node24 frozen install, full tests and
57
+ changed-source LCOV>=80%, types/lint/build, full/runtime audits, tree/privacy/pack
58
+ and packed/public ESM/CJS/types/browser consumers. Update README/Unreleased/TDR.
59
+ Release additive minor through PR, exact-main push CI, existing hosted
60
+ cd.yml/main/production OIDC, registry bytes/provenance and consumer checks.
61
+ No site deployment is authorized or required by this package-only Task.
@@ -9,3 +9,4 @@
9
9
  - [ADR 0007: Hosted OIDC Package Publication](./adr-0007-hosted-oidc-package-publication.md)
10
10
  - [ADR 0008: Package-Owned Issue Tracking](./adr-0008-package-owned-issue-tracking.md)
11
11
  - [ADR 0009: Additive Model Resource Manifest Qualification](./adr-0009-model-resource-manifest-qualification.md)
12
+ - [ADR 0010: Public Conversion Diagnostics](./adr-0010-public-conversion-diagnostics.md)
@@ -4,3 +4,4 @@
4
4
  - [TDR 0002: Exact-Version Validation Routing](./tdr-0002-exact-version-validation-routing.md)
5
5
  - [TDR 0003: PVOX Validation and Confirmation Binding](./tdr-0003-pvox-validation-and-confirmation-binding.md)
6
6
  - [TDR 0004: Model Resource Manifest Qualification](./tdr-0004-model-resource-manifest-qualification.md)
7
+ - [TDR 0005: Public Conversion Diagnostics](./tdr-0005-public-conversion-diagnostics.md)
@@ -0,0 +1,51 @@
1
+ # TDR 0005: Public Conversion Diagnostics
2
+
3
+ - Status: Accepted
4
+ - Date: 2026-10-24
5
+ - Tracking: asset-contracts Task49 / site Feature1153
6
+ - Decision: ADR0010
7
+
8
+ ## Input and evaluation
9
+
10
+ Accept outcome completed/unsupported/failed, existing public source provenance,
11
+ canonical GpuModelDiagnosticInput and exact private path/public resource bindings.
12
+ Snapshot inert JSON (no getters, symbols, sparse arrays, exotic prototypes or
13
+ cycles) with depth12/16,384values/2,048-characterstrings/262,144aggregatecharacters;
14
+ records/arrays have hard1024member limits. Limit issues/resources to100. Reserve
15
+ one issue slot for non-completed outcomes. Reject duplicate paths and conflicting
16
+ resource declarations for a URI. Every non-root diagnostic needs a binding;
17
+ root uses source. Reuse existing provenance/resource factories and fixed errors.
18
+
19
+ After snapshot, lazy-import published exact model-core0.7.0 and evaluate canonical
20
+ policy. Do not trust a caller-supplied accepted flag or re-evaluate repairs here.
21
+ Project core categories into fixed public codes/text; only accepted repairs expose
22
+ core rule IDs. Drop arbitrary source code/message/path, originalValue/rawSource.
23
+ Explicit failed outcome wins, then unsupported kind/outcome, rejected policy,
24
+ warning/clean success. Canonical consent controls loss severity; denied losses
25
+ remain blocking for existing fidelity derivation. Return frozen version1 JSON
26
+ with technicalAccepted, source, standard/mode, detailed diagnostics and exact
27
+ legacy converter diagnostic/loss arrays. No approval/receipt is synthesized.
28
+
29
+ Native AbortSignal and deadline1–60,000ms(default10,000) cover the operation.
30
+ Race lazy import against bounded timer/abort; clean timer/listener in finally.
31
+ Check deadlines around bounded synchronous snapshot/evaluation; neither loader
32
+ nor synchronous JS can be cancelled mid-execution. No network/filesystem/logging,
33
+ retry or callback dependency is added. Input references are caller assertions,
34
+ not byte verification; Task48/51 retain manifest/promotion responsibilities.
35
+
36
+ ## Requirements-based validation and delivery
37
+
38
+ Tests first cover status precedence, strict/tolerant/applied and unknown repairs,
39
+ allowed/denied dropped+approximated loss, fixed messages/codes, forensic redaction,
40
+ source/resource binding, JSON serialization and mutation isolation. Test invalid
41
+ codes/kinds/shapes, accessors/prototypes/symbols/sparse/cyclic inputs, limits and
42
+ options, cancellation/deadline and legacy array ceilings. Compose actual
43
+ ModelProcessingManifest evidence and verify independent fidelity/hard/rights
44
+ gates still reject missing or contradictory evidence. Preserve existing tests.
45
+
46
+ Backend gpu.model.conversion.enabled adoption enabled/disabled/rollback branches
47
+ are tested without live changes; no capability. No dependency versions change.
48
+ Run Node24 frozen full gates, >=80%changed-source LCOV, pack/consumer/audit,
49
+ then PR CI, merge, exact-main CI and approved hosted cd.yml/main/production OIDC.
50
+ Verify registry immutable bytes/provenance/signatures and actual public consumers
51
+ before Done. README/Unreleased and ADR/TDR indices ship with additive minor.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@plasius/asset-contracts",
3
- "version": "0.5.0",
3
+ "version": "0.6.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
@@ -39,6 +39,7 @@ export * from "./pvox-hash-preimage.js";
39
39
  export * from "./pvox-model-resolution.js";
40
40
  export * from "./pvox-model-resolution-schema.js";
41
41
  export * from "./model-resource-manifest.js";
42
+ export * from "./model-conversion-diagnostics.js";
42
43
  export {
43
44
  GPU_SHADER_STORE_FEATURE_FLAG,
44
45
  GPU_SHADER_STYLE_SELECTION_CAPABILITY,
@@ -0,0 +1,185 @@
1
+ import type { GpuModelDiagnosticInput, GpuModelDiagnosticMode, GpuModelDiagnosticStandard } from "@plasius/gpu-model-core";
2
+ import {
3
+ createModelProvenance, createModelResourceRef,
4
+ type ModelProvenance, type ModelResourceRef, type ModelConverterDiagnostic, type ModelConversionLoss,
5
+ } from "./model-resolution.js";
6
+
7
+ /** Hard ceilings for public conversion evidence, including private input metadata. */
8
+ export const MODEL_CONVERSION_DIAGNOSTIC_LIMITS = Object.freeze({ issues: 100, resources: 100, values: 16_384, depth: 12, string: 2048, characters: 262_144, timeoutMs: 60_000 });
9
+
10
+ /** Exact private canonical path to an opaque, credential-free public resource. */
11
+ export interface ModelConversionResourceBinding {
12
+ readonly path: string;
13
+ readonly resource: ModelResourceRef;
14
+ }
15
+ /** Canonical evaluator input; arbitrary adapter text is never projected. */
16
+ export interface ModelConversionDiagnosticsInput {
17
+ readonly outcome: "completed" | "unsupported" | "failed";
18
+ readonly source: ModelProvenance;
19
+ readonly report: GpuModelDiagnosticInput;
20
+ readonly resources: readonly ModelConversionResourceBinding[];
21
+ }
22
+ /** Cancellation and deadline cover snapshotting, lazy import and evaluation. */
23
+ export interface ModelConversionDiagnosticsOptions {
24
+ readonly signal?: AbortSignal;
25
+ readonly timeoutMs?: number;
26
+ }
27
+ /** Stable public codes; adapter-supplied codes are never treated as this vocabulary. */
28
+ export type ModelConversionDiagnosticCode = "conversion.warning" | "conversion.repair" | "conversion.loss.dropped" | "conversion.loss.approximated" | "conversion.unsupported" | "conversion.blocked" | "conversion.failed";
29
+ /** Technical evidence only; this is never human, rights, security or promotion approval. */
30
+ export interface QualifiedModelConversionDiagnostic extends ModelConverterDiagnostic {
31
+ readonly code: ModelConversionDiagnosticCode;
32
+ readonly resource?: ModelResourceRef;
33
+ readonly effect?: "dropped" | "approximated";
34
+ readonly fallback?: string;
35
+ }
36
+ /** Deterministic JSON projection plus legacy-compatible converter evidence fields. */
37
+ export interface QualifiedModelConversionDiagnostics {
38
+ readonly contractVersion: 1;
39
+ readonly status: "succeeded" | "warning" | "unsupported" | "failed";
40
+ readonly technicalAccepted: boolean;
41
+ readonly standard: GpuModelDiagnosticStandard;
42
+ readonly mode: GpuModelDiagnosticMode;
43
+ readonly source: ModelProvenance;
44
+ readonly diagnostics: readonly QualifiedModelConversionDiagnostic[];
45
+ readonly converter: {
46
+ readonly diagnostics: readonly ModelConverterDiagnostic[];
47
+ readonly losses: readonly ModelConversionLoss[];
48
+ };
49
+ }
50
+ /** Fixed, value-free error; never includes adapter/provider exception text. */
51
+ export class ModelConversionDiagnosticsError extends TypeError {
52
+ constructor(public readonly code: "invalid-input" | "limit-exceeded" | "unbound-resource" | "aborted" | "deadline-exceeded") {
53
+ super(`Invalid model conversion diagnostics: ${code}`);
54
+ this.name = "ModelConversionDiagnosticsError";
55
+ }
56
+ }
57
+ function fail(code: ModelConversionDiagnosticsError["code"] = "invalid-input"): never { throw new ModelConversionDiagnosticsError(code); }
58
+
59
+ function fields(value: unknown): Record<string, unknown> {
60
+ if (!value || typeof value !== "object" || ![Object.prototype, null].includes(Object.getPrototypeOf(value))) fail();
61
+ const result: Record<string, unknown> = Object.create(null) as Record<string, unknown>;
62
+ const keys = Reflect.ownKeys(value);
63
+ if (keys.length > 1024) fail("limit-exceeded");
64
+ for (const key of keys) {
65
+ if (typeof key !== "string" || key.length > 128 || ["__proto__", "prototype", "constructor"].includes(key)) fail();
66
+ const descriptor = Object.getOwnPropertyDescriptor(value, key)!;
67
+ if (!("value" in descriptor) || !descriptor.enumerable) fail();
68
+ result[key] = descriptor.value;
69
+ }
70
+ return result;
71
+ }
72
+ function only(record: Record<string, unknown>, keys: readonly string[]): void {
73
+ if (Object.keys(record).some((key) => !keys.includes(key))) fail();
74
+ }
75
+ function snapshot(value: unknown, budget: { values: number; characters: number }, depth = 0): unknown {
76
+ if (--budget.values < 0 || depth > MODEL_CONVERSION_DIAGNOSTIC_LIMITS.depth) fail("limit-exceeded");
77
+ if (value === null || typeof value === "boolean") return value;
78
+ if (typeof value === "string") {
79
+ budget.characters -= value.length;
80
+ if (value.length > MODEL_CONVERSION_DIAGNOSTIC_LIMITS.string || budget.characters < 0) fail("limit-exceeded");
81
+ return value;
82
+ }
83
+ if (typeof value === "number" && Number.isFinite(value)) return value;
84
+ if (Array.isArray(value)) {
85
+ if (Object.getPrototypeOf(value) !== Array.prototype || value.length > 1024) fail("limit-exceeded");
86
+ if (Reflect.ownKeys(value).length !== value.length + 1) fail();
87
+ const result = [];
88
+ for (let i = 0; i < value.length; i++) {
89
+ const descriptor = Object.getOwnPropertyDescriptor(value, String(i));
90
+ if (!descriptor || !("value" in descriptor) || !descriptor.enumerable) fail();
91
+ result.push(snapshot(descriptor.value, budget, depth + 1));
92
+ }
93
+ return Object.freeze(result);
94
+ }
95
+ const record = fields(value);
96
+ for (const key of Object.keys(record)) {
97
+ budget.characters -= key.length;
98
+ if (budget.characters < 0) fail("limit-exceeded");
99
+ record[key] = snapshot(record[key], budget, depth + 1);
100
+ }
101
+ return Object.freeze(record);
102
+ }
103
+ function legacy(value: QualifiedModelConversionDiagnostic): ModelConverterDiagnostic {
104
+ return Object.freeze({ severity: value.severity, code: value.code, message: value.message });
105
+ }
106
+
107
+ /**
108
+ * Evaluate canonical diagnostics and project fixed public text. Unknown adapter codes
109
+ * are intentionally discarded; unknown repair rules remain blocking in model-core.
110
+ * Exact path bindings are assertions by the trusted caller, not byte attestations.
111
+ */
112
+ export async function qualifyModelConversionDiagnostics(input: unknown, options: ModelConversionDiagnosticsOptions = {}): Promise<QualifiedModelConversionDiagnostics> {
113
+ let timer: ReturnType<typeof setTimeout> | undefined;
114
+ let signal: AbortSignal | undefined;
115
+ let onAbort: (() => void) | undefined;
116
+ try {
117
+ const start = performance.now();
118
+ const controls = fields(options);
119
+ only(controls, ["signal", "timeoutMs"]);
120
+ const timeoutMs = controls.timeoutMs === undefined ? 10_000 : controls.timeoutMs;
121
+ if (typeof timeoutMs !== "number" || !Number.isSafeInteger(timeoutMs) || timeoutMs < 1 || timeoutMs > MODEL_CONVERSION_DIAGNOSTIC_LIMITS.timeoutMs) fail();
122
+ signal = controls.signal as AbortSignal | undefined;
123
+ const aborted = Object.getOwnPropertyDescriptor(AbortSignal.prototype, "aborted")!.get!;
124
+ const check = () => {
125
+ if (signal !== undefined && aborted.call(signal)) fail("aborted");
126
+ if (performance.now() - start >= timeoutMs) fail("deadline-exceeded");
127
+ };
128
+ check();
129
+ const args = fields(snapshot(input, { values: MODEL_CONVERSION_DIAGNOSTIC_LIMITS.values, characters: MODEL_CONVERSION_DIAGNOSTIC_LIMITS.characters }));
130
+ only(args, ["outcome", "source", "report", "resources"]);
131
+ if (!["completed", "unsupported", "failed"].includes(args.outcome as string)) fail();
132
+ const source = createModelProvenance(args.source);
133
+ const reportInput = fields(args.report);
134
+ // Reserve room for the outcome diagnostic in legacy arrays limited to 100.
135
+ const maximumIssues = MODEL_CONVERSION_DIAGNOSTIC_LIMITS.issues - (args.outcome === "completed" ? 0 : 1);
136
+ if (!Array.isArray(reportInput.issues) || reportInput.issues.length > maximumIssues) fail("limit-exceeded");
137
+ if (!Array.isArray(args.resources) || args.resources.length > MODEL_CONVERSION_DIAGNOSTIC_LIMITS.resources) fail("limit-exceeded");
138
+ const resources = new Map<string, ModelResourceRef>();
139
+ const identities = new Map<string, string>();
140
+ for (const value of args.resources) {
141
+ const binding = fields(value);
142
+ only(binding, ["path", "resource"]);
143
+ if (typeof binding.path !== "string" || !binding.path.startsWith("/") || binding.path === "/" || resources.has(binding.path)) fail();
144
+ const resource = createModelResourceRef(binding.resource);
145
+ const identity = JSON.stringify(resource);
146
+ if (identities.has(resource.uri) && identities.get(resource.uri) !== identity) fail();
147
+ identities.set(resource.uri, identity);
148
+ resources.set(binding.path, resource);
149
+ }
150
+ check();
151
+ const interrupted = new Promise<never>((_resolve, reject) => {
152
+ timer = setTimeout(() => reject(new ModelConversionDiagnosticsError("deadline-exceeded")), Math.max(1, timeoutMs - (performance.now() - start)));
153
+ onAbort = () => reject(new ModelConversionDiagnosticsError("aborted"));
154
+ if (signal) AbortSignal.prototype.addEventListener.call(signal, "abort", onAbort, { once: true });
155
+ });
156
+ const core = await Promise.race([import("@plasius/gpu-model-core"), interrupted]);
157
+ check();
158
+ const report = core.evaluateGpuModelDiagnostics(reportInput);
159
+ const diagnostics: QualifiedModelConversionDiagnostic[] = report.diagnostics.map((diagnostic) => {
160
+ const resource = resources.get(diagnostic.path);
161
+ if (diagnostic.path !== "/" && !resource) fail("unbound-resource");
162
+ const base = resource ? { resource } : {};
163
+ switch (diagnostic.kind) {
164
+ case "warning": return Object.freeze({ ...base, severity: "warning", code: "conversion.warning", message: "Conversion completed with an advisory." });
165
+ case "repair": return Object.freeze({ ...base, severity: "warning", code: "conversion.repair", message: "Conversion applied a canonical repair fallback.", fallback: diagnostic.ruleId });
166
+ case "loss": return Object.freeze({ ...base, severity: reportInput.allowLoss === true ? "warning" : "blocking", code: `conversion.loss.${diagnostic.effect}`, message: diagnostic.effect === "dropped" ? "Conversion dropped source data." : "Conversion approximated source data.", effect: diagnostic.effect });
167
+ case "unsupported": return Object.freeze({ ...base, severity: "blocking", code: "conversion.unsupported", message: "Conversion encountered unsupported data." });
168
+ case "blocking-error": return Object.freeze({ ...base, severity: "blocking", code: "conversion.blocked", message: "Conversion did not satisfy canonical validation policy." });
169
+ }
170
+ });
171
+ if (args.outcome !== "completed") diagnostics.push(Object.freeze({ severity: "blocking", code: args.outcome === "failed" ? "conversion.failed" : "conversion.unsupported", message: args.outcome === "failed" ? "Conversion failed." : "Conversion encountered unsupported data." }));
172
+ const technicalAccepted = args.outcome === "completed" && report.accepted;
173
+ const status = args.outcome === "failed" ? "failed" : args.outcome === "unsupported" || report.diagnostics.some((item) => item.kind === "unsupported") ? "unsupported" : !technicalAccepted ? "failed" : diagnostics.length ? "warning" : "succeeded";
174
+ const losses = diagnostics.filter((item) => item.effect !== undefined).map(legacy);
175
+ check();
176
+ return Object.freeze({ contractVersion: 1, status, technicalAccepted, standard: report.standard, mode: report.mode, source,
177
+ diagnostics: Object.freeze(diagnostics), converter: Object.freeze({ diagnostics: Object.freeze(diagnostics.filter((item) => item.effect === undefined).map(legacy)), losses: Object.freeze(losses) }) });
178
+ } catch (error) {
179
+ if (error instanceof ModelConversionDiagnosticsError) throw error;
180
+ return fail();
181
+ } finally {
182
+ if (timer !== undefined) clearTimeout(timer);
183
+ if (signal && onAbort) AbortSignal.prototype.removeEventListener.call(signal, "abort", onAbort);
184
+ }
185
+ }