diff --git a/packages/external-provider-contract/README.md b/packages/external-provider-contract/README.md index 6ead110..aac2128 100644 --- a/packages/external-provider-contract/README.md +++ b/packages/external-provider-contract/README.md @@ -52,11 +52,17 @@ Foundry. детерминированный `nodedc.l2-execution-plan/v1`: разрешает generic шаги collection/request/extract/mapping/publish, прикладывает полные декларативные request/mapping contracts и фиксирует SHA-256 digests package, profile, -template, mapping, field policy, Data Product и telemetry registry. В -компиляторе нет веток по provider ID. Provider-specific URL, source paths и -параметры являются данными immutable package. После материализации Engine -`attestL2ExecutionPlanMaterialization` связывает exact plan digest с exact -graph revision/digest и точным списком materialized steps. +template, mapping, field policy, Data Product и telemetry registry. Plan +содержит exact `nodedc.l2-graph-blueprint/v1`: линейный набор generic runtime +node kinds, exact edges, config digests, credential slots, trigger/retry/batch/ +cardinality policy и закрытую матрицу требуемых mapping operators. Blueprint +не содержит credential reference и фиксируется отдельным SHA-256 digest. +Runtime обязан fail-closed отклонить неизвестный operator, rule или geometry +strategy; молча подставлять provider-specific code запрещено. В компиляторе +нет веток по provider ID. Provider-specific URL, source paths и параметры +являются данными immutable package. После материализации Engine +`attestL2ExecutionPlanMaterialization` связывает exact plan/blueprint digest с +exact graph revision/digest и точным списком materialized steps. Динамическая телеметрия проходит отдельный `nodedc.telemetry-field-registry/v1`. Реестр хранит только имена source diff --git a/packages/external-provider-contract/providers/gelios/v6/package.mjs b/packages/external-provider-contract/providers/gelios/v6/package.mjs index 1299507..8acd4f0 100644 --- a/packages/external-provider-contract/providers/gelios/v6/package.mjs +++ b/packages/external-provider-contract/providers/gelios/v6/package.mjs @@ -146,18 +146,23 @@ value.mappingContracts.push({ observedAt: { strategy: "first_non_empty", paths: ["updatedAt"], coerce: "unix_or_iso_timestamp", fallback: "collection_received_at" }, geometry: { type: "GeoJSON", - strategy: "gelios_geozone_v1", - paths: ["type", "points", "line", "radius"], + strategy: "bounded_zone_geometry_v1", + kindPath: "type", + polygonPointsPath: "points", + corridorPointsPath: "line", + radiusPath: "radius", + coordinateOrder: "latitude_longitude", + circleSegments: 64, allowedTypes: ["Polygon", "MultiPolygon"], omitIfInvalid: false, }, attributes: { - area_square_meters: { strategy: "first_non_empty", paths: ["surfaceArea", "surface_area"], coerce: "number", minimum: 0, omitIfInvalid: true }, + area_square_meters: { strategy: "first_non_empty", paths: ["surfaceArea", "surface_area"], coerce: "number", minimum: 0, omitIfMissing: true, omitIfInvalid: true }, description: { strategy: "first_non_empty", paths: ["description", "descr"], coerce: "string", omitIfMissing: true }, display_name: { strategy: "first_non_empty", paths: ["name"], coerce: "string" }, geometry_kind: { derive: "geometry_kind" }, - max_speed_kph: { strategy: "first_non_empty", paths: ["maxPermissibleSpeed", "max_permissible_speed"], coerce: "number", minimum: 0, omitIfInvalid: true }, - perimeter_meters: { strategy: "first_non_empty", paths: ["perimeter"], coerce: "number", minimum: 0, omitIfInvalid: true }, + max_speed_kph: { strategy: "first_non_empty", paths: ["maxPermissibleSpeed", "max_permissible_speed"], coerce: "number", minimum: 0, omitIfMissing: true, omitIfInvalid: true }, + perimeter_meters: { strategy: "first_non_empty", paths: ["perimeter"], coerce: "number", minimum: 0, omitIfMissing: true, omitIfInvalid: true }, source_kind: { constant: "live_api" }, source_revision: { constant: "gelios-rest-v1" }, style_color: { strategy: "first_non_empty", paths: ["color"], coerce: "string", omitIfMissing: true }, diff --git a/packages/external-provider-contract/providers/gelios/v8/package.mjs b/packages/external-provider-contract/providers/gelios/v8/package.mjs index 483ea35..7a2fe75 100644 --- a/packages/external-provider-contract/providers/gelios/v8/package.mjs +++ b/packages/external-provider-contract/providers/gelios/v8/package.mjs @@ -138,7 +138,7 @@ value.collectionProfiles.push({ maxCurrentEntities: 5000, onExceed: "require_partitioned_data_product", }, - retry: { maxAttempts: 4, backoff: "exponential_with_jitter" }, + retry: { maxAttempts: 4, backoff: "fixed_delay", delayMs: 2000 }, }); value.dataProducts.push({ @@ -232,6 +232,13 @@ value.l2Templates.push({ ], }); +// The target Engine runtime materializes bounded fixed-delay retries exactly. +// v8 therefore avoids promising a backoff algorithm that n8n cannot execute. +value.collectionProfiles = value.collectionProfiles.map((profile) => ({ + ...profile, + retry: { maxAttempts: 4, backoff: "fixed_delay", delayMs: 2000 }, +})); + export const geliosProviderPackageV8 = deepFreeze(value); function optional(type) { diff --git a/packages/external-provider-contract/src/index.mjs b/packages/external-provider-contract/src/index.mjs index c2c725f..36c2054 100644 --- a/packages/external-provider-contract/src/index.mjs +++ b/packages/external-provider-contract/src/index.mjs @@ -24,9 +24,12 @@ export { export { L2_EXECUTION_PLAN_COMPILER_VERSION, L2_EXECUTION_PLAN_SCHEMA_VERSION, + L2_GRAPH_BLUEPRINT_SCHEMA_VERSION, + L2_MAPPING_RUNTIME_SCHEMA_VERSION, L2_MATERIALIZATION_RECEIPT_SCHEMA_VERSION, attestL2ExecutionPlanMaterialization, compileL2ExecutionPlan, + compileL2GraphBlueprint, validateL2ExecutionPlan, } from "./l2-execution-plan.mjs"; export { diff --git a/packages/external-provider-contract/src/l2-execution-plan.mjs b/packages/external-provider-contract/src/l2-execution-plan.mjs index e340512..9d2c4a6 100644 --- a/packages/external-provider-contract/src/l2-execution-plan.mjs +++ b/packages/external-provider-contract/src/l2-execution-plan.mjs @@ -6,14 +6,23 @@ import { } from "./telemetry-field-registry.mjs"; export const L2_EXECUTION_PLAN_SCHEMA_VERSION = "nodedc.l2-execution-plan/v1"; +export const L2_GRAPH_BLUEPRINT_SCHEMA_VERSION = "nodedc.l2-graph-blueprint/v1"; export const L2_MATERIALIZATION_RECEIPT_SCHEMA_VERSION = "nodedc.l2-materialization-receipt/v1"; -export const L2_EXECUTION_PLAN_COMPILER_VERSION = "1.0.0"; +export const L2_MAPPING_RUNTIME_SCHEMA_VERSION = "nodedc.semantic-mapping-runtime/v1"; +export const L2_EXECUTION_PLAN_COMPILER_VERSION = "1.1.0"; const HASH = /^(?:sha256:)?[a-f0-9]{64}$/; const IDENTIFIER = /^[a-z][a-z0-9._:-]{2,127}$/; const CREDENTIAL_REF = /^ndc-credref:[A-Za-z0-9._:-]{8,255}$/; const COMPILE_OPTION_KEYS = new Set(["telemetryFieldRegistry"]); const RECEIPT_OPTION_KEYS = new Set(["graphRevision", "graphDigest", "materializedStepIds"]); +const STEP_RUNTIME_KINDS = Object.freeze({ + collection_trigger: "ndc.collection-trigger", + provider_request: "ndc.provider-request", + extract_items: "ndc.extract-items", + semantic_mapping: "ndc.semantic-mapping", + data_product_publish: "ndc.data-product-publish", +}); /** * Resolve one immutable provider package plus one connection instance into a @@ -86,6 +95,12 @@ export function compileL2ExecutionPlan(providerPackage, connectionInstance, opti telemetryProjection, artifacts, })); + const graphBlueprint = compileGraphBlueprint({ + profile, + template, + mapping, + steps, + }); const planWithoutDigest = { schemaVersion: L2_EXECUTION_PLAN_SCHEMA_VERSION, @@ -108,6 +123,7 @@ export function compileL2ExecutionPlan(providerPackage, connectionInstance, opti collectionProfile: structuredClone(profile), artifacts, steps, + graphBlueprint, }; return deepFreeze({ @@ -128,6 +144,7 @@ export function validateL2ExecutionPlan(value) { if (ids.some((id) => typeof id !== "string" || !IDENTIFIER.test(id))) errors.push("executionPlan.steps_id_invalid"); if (new Set(ids).size !== ids.length) errors.push("executionPlan.steps_id_must_be_unique"); } + validateGraphBlueprint(value, errors); if (!HASH.test(String(value.executionPlanDigest || ""))) { errors.push("executionPlan.executionPlanDigest_invalid"); } else { @@ -140,6 +157,20 @@ export function validateL2ExecutionPlan(value) { return result(errors); } +/** + * Produce the exact provider-neutral graph shape required by one resolved + * execution plan. Runtime adapters may translate these generic node kinds to + * their native graph representation, but may not add provider branches or + * silently weaken the declared execution policies. + */ +export function compileL2GraphBlueprint(executionPlan) { + const validation = validateL2ExecutionPlan(executionPlan); + if (!validation.ok) { + throw new Error(`l2_execution_plan_invalid:${validation.errors.join(",")}`); + } + return executionPlan.graphBlueprint; +} + /** * Bind a successful Engine graph materialization back to the exact compiled * plan without teaching this package how Engine stores or renders its graph. @@ -245,6 +276,180 @@ function compileStep(context) { throw new Error(`l2_execution_plan_step_kind_unsupported:${String(step.kind)}`); } +function compileGraphBlueprint({ profile, template, mapping, steps }) { + const nodes = steps.flatMap((step) => { + if (step.kind !== "collection_trigger") return [compileBlueprintNode(step, step.id)]; + if (profile.mode !== "realtime") { + return [compileBlueprintNode(step, step.id, "ndc.manual-trigger", { entrypoint: "manual" })]; + } + return [ + compileBlueprintNode( + step, + `${step.id}.manual`, + "ndc.manual-trigger", + { entrypoint: "manual" }, + ), + compileBlueprintNode( + step, + `${step.id}.interval`, + "ndc.interval-trigger", + { entrypoint: "interval", intervalMs: profile.schedule.intervalMs }, + ), + ]; + }).map((node, order) => ({ ...node, order })); + const triggerNodes = nodes.filter((node) => node.stepId === steps[0].id); + const executableNodes = nodes.filter((node) => node.stepId !== steps[0].id); + const edgePairs = [ + ...triggerNodes.map((node) => ({ from: node.id, to: executableNodes[0].id })), + ...executableNodes.slice(1).map((node, index) => ({ + from: executableNodes[index].id, + to: node.id, + })), + ]; + const edges = edgePairs.map((edge, index) => ({ + id: `edge.${String(index + 1).padStart(3, "0")}`, + ...edge, + channel: "main", + order: 0, + })); + const blueprintWithoutDigest = { + schemaVersion: L2_GRAPH_BLUEPRINT_SCHEMA_VERSION, + runtime: { + target: template.runtime, + instanceMode: template.instanceMode, + executionMode: profile.mode, + entrypoints: profile.mode === "realtime" + ? [ + { kind: "manual", purpose: "managed_deploy_and_run" }, + { kind: "interval", intervalMs: profile.schedule.intervalMs, purpose: "scheduled_collection" }, + ] + : [{ kind: "manual", purpose: "managed_deploy_and_run" }], + envelope: { + schemaVersion: "nodedc.l2-runtime-envelope/v1", + providerResponses: "keyed_by_capability_id", + collectionReceivedAt: "required_iso_timestamp", + canonicalFactsOnlyAtPublish: true, + rawProviderPayloadAtPublish: "forbidden", + maxProviderResponseBytes: 16 * 1024 * 1024, + maxExtractedItems: profile.cardinality.maxCurrentEntities, + }, + retry: structuredClone(profile.retry), + batching: structuredClone(profile.batching), + cardinality: structuredClone(profile.cardinality), + failureMode: "fail_closed", + mappingRuntime: compileMappingRuntimeRequirements(mapping), + }, + nodes, + edges, + }; + return { + ...blueprintWithoutDigest, + blueprintDigest: canonicalDigest(blueprintWithoutDigest), + }; +} + +function compileBlueprintNode(step, id, runtimeKind = STEP_RUNTIME_KINDS[step.kind], adapterConfig) { + return { + id, + stepId: step.id, + runtimeKind, + configDigest: canonicalDigest({ + stepConfig: step.config, + ...(adapterConfig ? { adapterConfig } : {}), + }), + ...(adapterConfig ? { adapterConfig } : {}), + credentialSlots: step.config?.credentialBinding + ? [{ + role: step.config.credentialBinding, + slot: step.config.credentialBinding, + authority: "engine_managed_opaque_ref", + }] + : [], + }; +} + +function compileMappingRuntimeRequirements(mapping) { + const expressions = []; + collectMappingExpressions(mapping.fact, expressions); + const geometry = mapping.fact?.geometry; + return { + schemaVersion: L2_MAPPING_RUNTIME_SCHEMA_VERSION, + expressionSources: uniqueSorted(expressions.map(expressionSourceKind)), + coercions: uniqueSorted(expressions.map((expression) => expression.coerce).filter(Boolean)), + derivationKinds: uniqueSorted(Object.values(mapping.derivations || {}).map((item) => item.kind)), + ruleIds: uniqueSorted( + Object.values(mapping.derivations || {}).flatMap((item) => item.rules || []), + ), + geometryStrategies: geometry + ? [geometry.type === "Point" ? "point_from_longitude_latitude" : geometry.strategy] + : [], + }; +} + +function collectMappingExpressions(value, found) { + if (Array.isArray(value)) { + value.forEach((item) => collectMappingExpressions(item, found)); + return; + } + if (!isPlainObject(value)) return; + if (Array.isArray(value.paths) || value.constant !== undefined || typeof value.derive === "string") { + found.push(value); + } + Object.values(value).forEach((item) => collectMappingExpressions(item, found)); +} + +function expressionSourceKind(value) { + if (Array.isArray(value.paths)) return value.strategy || "first_non_empty"; + if (value.constant !== undefined) return "constant"; + return "derive"; +} + +function validateGraphBlueprint(executionPlan, errors) { + if (!isPlainObject(executionPlan.graphBlueprint)) { + errors.push("executionPlan.graphBlueprint_must_be_object"); + return; + } + const blueprint = executionPlan.graphBlueprint; + if (blueprint.schemaVersion !== L2_GRAPH_BLUEPRINT_SCHEMA_VERSION) { + errors.push("executionPlan.graphBlueprint.schemaVersion_mismatch"); + } + if (!HASH.test(String(blueprint.blueprintDigest || ""))) { + errors.push("executionPlan.graphBlueprint.blueprintDigest_invalid"); + } else { + const descriptor = structuredClone(blueprint); + delete descriptor.blueprintDigest; + if (normalizeDigest(blueprint.blueprintDigest) !== canonicalDigest(descriptor)) { + errors.push("executionPlan.graphBlueprint.blueprintDigest_mismatch"); + } + } + if (!Array.isArray(executionPlan.steps) || !isPlainObject(executionPlan.collectionProfile)) return; + try { + const expected = compileGraphBlueprintDescriptor({ + profile: executionPlan.collectionProfile, + steps: executionPlan.steps, + mapping: executionPlan.steps.find((step) => step?.kind === "semantic_mapping")?.config?.mappingContract, + }); + if (stableJson(blueprint) !== stableJson(expected)) { + errors.push("executionPlan.graphBlueprint_mismatch"); + } + } catch { + errors.push("executionPlan.graphBlueprint_unverifiable"); + } +} + +function compileGraphBlueprintDescriptor({ profile, steps, mapping }) { + if (!isPlainObject(mapping)) throw new Error("graph_blueprint_context_invalid"); + const template = { + runtime: "ndc_l2", + instanceMode: "one_connection_per_workflow", + }; + return compileGraphBlueprint({ profile, template, mapping, steps }); +} + +function uniqueSorted(values) { + return [...new Set(values)].sort(); +} + function validateConnectionInstance(providerPackage, value) { if (!isPlainObject(value)) throw new Error("l2_execution_plan_connection_must_be_object"); if (value.schemaVersion !== "nodedc.l2-connection-instance/v1") { diff --git a/packages/external-provider-contract/src/provider-package.mjs b/packages/external-provider-contract/src/provider-package.mjs index 08561c9..cd82c94 100644 --- a/packages/external-provider-contract/src/provider-package.mjs +++ b/packages/external-provider-contract/src/provider-package.mjs @@ -144,6 +144,8 @@ const EXPRESSION_KEYS = new Set([ ]); const GEOMETRY_KEYS = new Set([ "type", "longitude", "latitude", "strategy", "paths", "allowedTypes", "omitIfInvalid", + "kindPath", "polygonPointsPath", "corridorPointsPath", "radiusPath", + "coordinateOrder", "circleSegments", ]); const DERIVATION_KEYS = new Set(["kind", "rules", "default", "parameters"]); const TEMPLATE_KEYS = new Set([ @@ -593,9 +595,18 @@ function validateCollectionProfileTemplate(value, path, errors) { if (!isPlainObject(value.retry)) { errors.push(`${path}.retry_must_be_object`); } else { - rejectUnknownKeys(value.retry, new Set(["maxAttempts", "backoff"]), `${path}.retry`, errors); + rejectUnknownKeys(value.retry, new Set(["maxAttempts", "backoff", "delayMs"]), `${path}.retry`, errors); if (!Number.isInteger(value.retry.maxAttempts) || value.retry.maxAttempts < 1 || value.retry.maxAttempts > 10) errors.push(`${path}.retry.maxAttempts_invalid`); - if (value.retry.backoff !== "exponential_with_jitter") errors.push(`${path}.retry.backoff_invalid`); + if (!["exponential_with_jitter", "fixed_delay"].includes(value.retry.backoff)) { + errors.push(`${path}.retry.backoff_invalid`); + } + if (value.retry.backoff === "fixed_delay") { + if (!Number.isInteger(value.retry.delayMs) || value.retry.delayMs < 100 || value.retry.delayMs > 60_000) { + errors.push(`${path}.retry.delayMs_invalid`); + } + } else if (value.retry.delayMs !== undefined) { + errors.push(`${path}.retry.delayMs_requires_fixed_delay`); + } } } @@ -672,13 +683,29 @@ function validateMappingContract(value, path, errors) { errors.push(`${path}.fact.geometry.point_shape_invalid`); } } else if (value.fact.geometry.type === "GeoJSON") { - if (value.fact.geometry.strategy !== "gelios_geozone_v1") errors.push(`${path}.fact.geometry.strategy_invalid`); - requiredSourcePathArray(value.fact.geometry.paths, `${path}.fact.geometry.paths`, errors); + if (value.fact.geometry.strategy !== "bounded_zone_geometry_v1") errors.push(`${path}.fact.geometry.strategy_invalid`); + for (const key of ["kindPath", "polygonPointsPath", "corridorPointsPath", "radiusPath"]) { + if (!isCanonicalSourcePath(value.fact.geometry[key])) { + errors.push(`${path}.fact.geometry.${key}_invalid`); + } + } + if (!["latitude_longitude", "longitude_latitude"].includes(value.fact.geometry.coordinateOrder)) { + errors.push(`${path}.fact.geometry.coordinateOrder_invalid`); + } + if (!Number.isInteger(value.fact.geometry.circleSegments) + || value.fact.geometry.circleSegments < 16 + || value.fact.geometry.circleSegments > 256) { + errors.push(`${path}.fact.geometry.circleSegments_invalid`); + } if (!sameSet(value.fact.geometry.allowedTypes, ["Polygon", "MultiPolygon"])) { errors.push(`${path}.fact.geometry.allowedTypes_must_be_polygon_or_multipolygon`); } if (value.fact.geometry.omitIfInvalid !== false) errors.push(`${path}.fact.geometry.omitIfInvalid_must_be_false`); - if (value.fact.geometry.longitude !== undefined || value.fact.geometry.latitude !== undefined) { + if ( + value.fact.geometry.longitude !== undefined + || value.fact.geometry.latitude !== undefined + || value.fact.geometry.paths !== undefined + ) { errors.push(`${path}.fact.geometry.geojson_shape_invalid`); } } else { @@ -900,6 +927,9 @@ function validateExpressionAgainstFieldContract(expression, contract, derivation errors.push(`${path}_required_but_mapping_can_omit_invalid`); } if (expression.paths !== undefined) { + if (contract.required === false && expression.omitIfMissing !== true) { + errors.push(`${path}_optional_source_field_must_omit_missing`); + } const expectedCoerce = new Map([["string", "string"], ["number", "number"], ["boolean", "boolean"]]).get(contract.type); if (expectedCoerce && expression.coerce !== expectedCoerce) errors.push(`${path}.coerce_must_match_field_contract`); if (Array.isArray(contract.enum)) errors.push(`${path}.enum_field_must_use_constant_or_derivation`); @@ -1041,8 +1071,11 @@ function mappingSourcePaths(value, found = []) { if (isCanonicalSourcePath(path)) found.push(path); }); } + for (const key of ["kindPath", "polygonPointsPath", "corridorPointsPath", "radiusPath"]) { + if (isCanonicalSourcePath(value[key])) found.push(value[key]); + } Object.entries(value).forEach(([key, item]) => { - if (key !== "paths") mappingSourcePaths(item, found); + if (key !== "paths" && !key.endsWith("Path")) mappingSourcePaths(item, found); }); return found; } diff --git a/packages/external-provider-contract/test/l2-execution-plan.test.mjs b/packages/external-provider-contract/test/l2-execution-plan.test.mjs index 73eff0a..194d24a 100644 --- a/packages/external-provider-contract/test/l2-execution-plan.test.mjs +++ b/packages/external-provider-contract/test/l2-execution-plan.test.mjs @@ -1,9 +1,13 @@ import assert from "node:assert/strict"; +import { createHash } from "node:crypto"; import { L2_EXECUTION_PLAN_SCHEMA_VERSION, + L2_GRAPH_BLUEPRINT_SCHEMA_VERSION, + L2_MAPPING_RUNTIME_SCHEMA_VERSION, L2_MATERIALIZATION_RECEIPT_SCHEMA_VERSION, attestL2ExecutionPlanMaterialization, compileL2ExecutionPlan, + compileL2GraphBlueprint, instantiateL2Connection, validateL2ExecutionPlan, } from "../src/index.mjs"; @@ -30,6 +34,58 @@ assert.match(positionsPlan.executionPlanDigest, /^sha256:[a-f0-9]{64}$/); assert.match(positionsPlan.artifacts.packageDigest, /^sha256:[a-f0-9]{64}$/); assert.match(positionsPlan.artifacts.mappingContractDigest, /^sha256:[a-f0-9]{64}$/); assert.match(positionsPlan.artifacts.telemetryRegistryDigest, /^sha256:[a-f0-9]{64}$/); +assert.equal(positionsPlan.graphBlueprint.schemaVersion, L2_GRAPH_BLUEPRINT_SCHEMA_VERSION); +assert.equal( + positionsPlan.graphBlueprint.runtime.mappingRuntime.schemaVersion, + L2_MAPPING_RUNTIME_SCHEMA_VERSION, +); +assert.match(positionsPlan.graphBlueprint.blueprintDigest, /^sha256:[a-f0-9]{64}$/); +assert.equal(compileL2GraphBlueprint(positionsPlan), positionsPlan.graphBlueprint); +assert.deepEqual( + positionsPlan.graphBlueprint.nodes.map(({ id, stepId, runtimeKind, order }) => ({ + id, + stepId, + runtimeKind, + order, + })), + [ + { id: "collection.trigger.manual", stepId: "collection.trigger", runtimeKind: "ndc.manual-trigger", order: 0 }, + { id: "collection.trigger.interval", stepId: "collection.trigger", runtimeKind: "ndc.interval-trigger", order: 1 }, + { id: "provider.fetch-monitoring-config", stepId: "provider.fetch-monitoring-config", runtimeKind: "ndc.provider-request", order: 2 }, + { id: "provider.fetch-units", stepId: "provider.fetch-units", runtimeKind: "ndc.provider-request", order: 3 }, + { id: "provider.extract-units", stepId: "provider.extract-units", runtimeKind: "ndc.extract-items", order: 4 }, + { id: "ontology.map", stepId: "ontology.map", runtimeKind: "ndc.semantic-mapping", order: 5 }, + { id: "data-product.publish", stepId: "data-product.publish", runtimeKind: "ndc.data-product-publish", order: 6 }, + ], +); +assert.deepEqual( + positionsPlan.graphBlueprint.edges.map(({ from, to }) => ({ from, to })), + [ + { from: "collection.trigger.manual", to: "provider.fetch-monitoring-config" }, + { from: "collection.trigger.interval", to: "provider.fetch-monitoring-config" }, + { from: "provider.fetch-monitoring-config", to: "provider.fetch-units" }, + { from: "provider.fetch-units", to: "provider.extract-units" }, + { from: "provider.extract-units", to: "ontology.map" }, + { from: "ontology.map", to: "data-product.publish" }, + ], +); +assert.deepEqual(positionsPlan.graphBlueprint.runtime.mappingRuntime.derivationKinds, [ + "bounded_readings", + "ordered_rules", +]); +assert.deepEqual(positionsPlan.graphBlueprint.runtime.retry, { + maxAttempts: 4, + backoff: "fixed_delay", + delayMs: 2000, +}); +assert.equal( + JSON.stringify(positionsPlan.graphBlueprint).includes("ndc-credref:"), + false, +); +assert.equal( + positionsPlan.graphBlueprint.nodes.every((node) => !node.runtimeKind.includes("gelios")), + true, +); assert.deepEqual(positionsPlan.steps.map((step) => step.kind), [ "collection_trigger", "provider_request", @@ -54,6 +110,7 @@ assert.deepEqual( ); assert.equal(Object.isFrozen(positionsPlan), true); assert.equal(Object.isFrozen(positionsPlan.steps), true); +assert.equal(Object.isFrozen(positionsPlan.graphBlueprint), true); const clonedPlan = compileL2ExecutionPlan( structuredClone(geliosProviderPackageV8), @@ -77,6 +134,28 @@ const profilePlan = compileL2ExecutionPlan(geliosProviderPackageV8, profileConne assert.deepEqual(validateL2ExecutionPlan(profilePlan), { ok: true, errors: [] }); assert.equal(profilePlan.artifacts.telemetryRegistryDigest, undefined); assert.notEqual(profilePlan.executionPlanDigest, positionsPlan.executionPlanDigest); +assert.deepEqual(profilePlan.graphBlueprint.runtime.mappingRuntime.derivationKinds, []); + +for (const collectionProfile of geliosProviderPackageV8.collectionProfiles) { + const connection = instantiateL2Connection(geliosProviderPackageV8, { + tenantId: "tenant-all-profiles-fixture", + connectionId: `connection-${collectionProfile.id.replaceAll(".", "-")}`, + collectionProfileId: collectionProfile.id, + providerCredentialRef: "ndc-credref:provider-all-profiles-fixture", + }); + const plan = compileL2ExecutionPlan( + geliosProviderPackageV8, + connection, + collectionProfile.dataProductId === "fleet.positions.current.v5" + ? { telemetryFieldRegistry: geliosTelemetryFieldRegistryV1 } + : {}, + ); + assert.deepEqual(validateL2ExecutionPlan(plan), { ok: true, errors: [] }); + assert.equal(JSON.stringify(plan.graphBlueprint.runtime.mappingRuntime).includes("gelios"), false); + const expectedEntrypoints = collectionProfile.mode === "realtime" ? 2 : 1; + assert.equal(plan.graphBlueprint.nodes.length, plan.steps.length + expectedEntrypoints - 1); + assert.equal(plan.graphBlueprint.edges.length, plan.steps.length + expectedEntrypoints - 2); +} const secondConnection = instantiateL2Connection(geliosProviderPackageV8, { tenantId: "tenant-compiler-fixture", @@ -115,7 +194,39 @@ assert.equal( true, ); +const authorityEscalatedPlan = structuredClone(positionsPlan); +authorityEscalatedPlan.graphBlueprint.runtime.target = "provider_specific_runtime"; +authorityEscalatedPlan.graphBlueprint.blueprintDigest = digestWithout( + authorityEscalatedPlan.graphBlueprint, + "blueprintDigest", +); +authorityEscalatedPlan.executionPlanDigest = digestWithout( + authorityEscalatedPlan, + "executionPlanDigest", +); +assert.equal( + validateL2ExecutionPlan(authorityEscalatedPlan).errors.includes("executionPlan.graphBlueprint_mismatch"), + true, +); + const serialized = JSON.stringify(positionsPlan); assert.equal(serialized.includes("Bearer "), false); assert.equal(serialized.includes("access_token"), false); assert.equal(serialized.includes("refresh_token"), false); + +function digestWithout(value, key) { + const descriptor = structuredClone(value); + delete descriptor[key]; + return `sha256:${createHash("sha256").update(JSON.stringify(stableValue(descriptor)), "utf8").digest("hex")}`; +} + +function stableValue(value) { + if (Array.isArray(value)) return value.map(stableValue); + if (!value || typeof value !== "object") return value; + return Object.fromEntries( + Object.keys(value) + .filter((key) => value[key] !== undefined) + .sort() + .map((key) => [key, stableValue(value[key])]), + ); +}