feat(data-plane): add bounded Gelios telemetry contract

This commit is contained in:
Codex 2026-07-22 11:47:24 +03:00
parent a9b8d71968
commit 4402c9ed25
16 changed files with 432 additions and 13 deletions

View File

@ -27,6 +27,7 @@ const files = [
["packages/external-provider-contract/src/index.mjs", "platform/packages/external-provider-contract/src/index.mjs"], ["packages/external-provider-contract/src/index.mjs", "platform/packages/external-provider-contract/src/index.mjs"],
["packages/external-provider-contract/src/provider-package.mjs", "platform/packages/external-provider-contract/src/provider-package.mjs"], ["packages/external-provider-contract/src/provider-package.mjs", "platform/packages/external-provider-contract/src/provider-package.mjs"],
["packages/external-provider-contract/src/sensitive-field-policy.mjs", "platform/packages/external-provider-contract/src/sensitive-field-policy.mjs"], ["packages/external-provider-contract/src/sensitive-field-policy.mjs", "platform/packages/external-provider-contract/src/sensitive-field-policy.mjs"],
["packages/external-provider-contract/src/telemetry-readings.mjs", "platform/packages/external-provider-contract/src/telemetry-readings.mjs"],
["packages/external-provider-contract/src/zone-source.mjs", "platform/packages/external-provider-contract/src/zone-source.mjs"], ["packages/external-provider-contract/src/zone-source.mjs", "platform/packages/external-provider-contract/src/zone-source.mjs"],
["packages/external-provider-contract/providers/gelios", "platform/packages/external-provider-contract/providers/gelios"], ["packages/external-provider-contract/providers/gelios", "platform/packages/external-provider-contract/providers/gelios"],
]; ];

View File

@ -0,0 +1,11 @@
# Gelios provider package v7
Version 7 preserves the v6 geozone capability and introduces the immutable
`fleet.positions.current.v5@5.0.0` telemetry contract.
The current-units request now asks Gelios for counters, sensor definitions and
latest sensor values. The mapping publishes only provider-neutral, bounded
telemetry: mileage, engine hours and at most 128 scalar sensor readings with a
stable id, label, optional unit and observation time. IMEI, phones, decrypt
keys, addresses, raw message params, raw sensor definitions and secret-like
material remain outside the product.

View File

@ -0,0 +1,9 @@
export {
GELIOS_POSITIONS_DATA_PRODUCT_ID,
GELIOS_POSITIONS_DATA_PRODUCT_VERSION,
GELIOS_POSITIONS_ONTOLOGY_REVISION,
GELIOS_PROVIDER_PACKAGE_ID,
GELIOS_PROVIDER_PACKAGE_VERSION,
GELIOS_UNIT_SOURCE_ID_PREFIX,
geliosProviderPackageV7,
} from "./package.mjs";

View File

@ -0,0 +1,214 @@
import { geliosProviderPackageV6 } from "../v6/package.mjs";
export const GELIOS_PROVIDER_PACKAGE_ID = "gelios.provider.v7";
export const GELIOS_PROVIDER_PACKAGE_VERSION = "7.0.0";
export const GELIOS_POSITIONS_DATA_PRODUCT_ID = "fleet.positions.current.v5";
export const GELIOS_POSITIONS_DATA_PRODUCT_VERSION = "5.0.0";
export const GELIOS_POSITIONS_ONTOLOGY_REVISION = "ontology.map.moving_object.v3";
export const GELIOS_UNIT_SOURCE_ID_PREFIX = "gelios-unit-";
const PREVIOUS_PRODUCT_ID = "fleet.positions.current.v4";
const PREVIOUS_FIELD_POLICY_ID = "gelios.positions.current.fields.v4";
const PREVIOUS_REALTIME_PROFILE_ID = "gelios.positions.current.realtime.v5";
const PREVIOUS_MANUAL_PROFILE_ID = "gelios.positions.current.manual.v5";
const PREVIOUS_MAPPING_ID = "gelios.units.to.fleet.positions.current.v5";
const PREVIOUS_TEMPLATE_ID = "gelios.positions.current.l2.v5";
const FIELD_POLICY_ID = "gelios.positions.current.fields.v5";
const REALTIME_PROFILE_ID = "gelios.positions.current.realtime.v7";
const MANUAL_PROFILE_ID = "gelios.positions.current.manual.v7";
const MAPPING_ID = "gelios.units.to.fleet.positions.current.v7";
const TEMPLATE_ID = "gelios.positions.current.l2.v7";
const positionFields = Object.freeze([
"course_degrees",
"display_name",
"elevation_meters",
"engine_hours",
"geometry",
"hdop",
"horizontal_accuracy_meters",
"mileage_km",
"movement_state",
"object_kind",
"position_source",
"satellite_count",
"sensor_readings",
"signal_state",
"speed_kph",
]);
const value = structuredClone(geliosProviderPackageV6);
const previousMapping = value.mappingContracts.find((mapping) => mapping.id === PREVIOUS_MAPPING_ID);
if (!previousMapping) throw new Error("gelios_v7_previous_mapping_missing");
value.id = GELIOS_PROVIDER_PACKAGE_ID;
value.version = GELIOS_PROVIDER_PACKAGE_VERSION;
value.manifest = {
...value.manifest,
id: "gelios.provider.manifest.v7",
version: GELIOS_PROVIDER_PACKAGE_VERSION,
fieldPolicyIds: replaceOne(value.manifest.fieldPolicyIds, PREVIOUS_FIELD_POLICY_ID, FIELD_POLICY_ID),
collectionProfileIds: replaceOne(
replaceOne(value.manifest.collectionProfileIds, PREVIOUS_REALTIME_PROFILE_ID, REALTIME_PROFILE_ID),
PREVIOUS_MANUAL_PROFILE_ID,
MANUAL_PROFILE_ID,
),
dataProductIds: replaceOne(value.manifest.dataProductIds, PREVIOUS_PRODUCT_ID, GELIOS_POSITIONS_DATA_PRODUCT_ID),
mappingContractIds: replaceOne(value.manifest.mappingContractIds, PREVIOUS_MAPPING_ID, MAPPING_ID),
l2TemplateIds: replaceOne(value.manifest.l2TemplateIds, PREVIOUS_TEMPLATE_ID, TEMPLATE_ID),
};
value.capabilities = value.capabilities.map((capability) => (
capability.id === "gelios.units.current.read"
? {
...capability,
request: {
...capability.request,
query: {
...capability.request.query,
incltrip: "true",
inclcntrs: "true",
inclsnsrs: "true",
incllsv: "true",
},
},
}
: capability
));
value.fieldPolicies = value.fieldPolicies.map((policy) => (
policy.id === PREVIOUS_FIELD_POLICY_ID
? {
...policy,
id: FIELD_POLICY_ID,
version: GELIOS_POSITIONS_DATA_PRODUCT_VERSION,
dataProductId: GELIOS_POSITIONS_DATA_PRODUCT_ID,
targetFields: [...positionFields],
restrictedSourcePaths: policy.restrictedSourcePaths
.filter((path) => path !== "sensors")
.concat(["sensors.conversionTable"]),
}
: policy
));
value.collectionProfiles = value.collectionProfiles.map((profile) => {
if (profile.dataProductId !== PREVIOUS_PRODUCT_ID) return profile;
return {
...profile,
id: profile.mode === "realtime" ? REALTIME_PROFILE_ID : MANUAL_PROFILE_ID,
version: GELIOS_PROVIDER_PACKAGE_VERSION,
dataProductId: GELIOS_POSITIONS_DATA_PRODUCT_ID,
mappingContractId: MAPPING_ID,
fieldPolicyId: FIELD_POLICY_ID,
l2TemplateId: TEMPLATE_ID,
};
});
value.dataProducts = value.dataProducts.map((product) => (
product.id === PREVIOUS_PRODUCT_ID
? {
id: GELIOS_POSITIONS_DATA_PRODUCT_ID,
version: GELIOS_POSITIONS_DATA_PRODUCT_VERSION,
ontologyRevision: GELIOS_POSITIONS_ONTOLOGY_REVISION,
deliveryMode: "snapshot+patch",
semanticTypes: ["map.moving_object"],
fields: [...positionFields],
fieldContracts: {
course_degrees: { type: "number", required: false, minimum: 0, maximum: 360 },
display_name: { type: "string", required: true },
elevation_meters: { type: "number", required: false },
engine_hours: { type: "number", required: false, minimum: 0 },
geometry: { type: "point", required: false },
hdop: { type: "number", required: false, minimum: 0 },
horizontal_accuracy_meters: { type: "number", required: false, minimum: 0 },
mileage_km: { type: "number", required: false, minimum: 0 },
movement_state: { type: "string", required: true, enum: ["moving", "stopped"] },
object_kind: { type: "string", required: true },
position_source: { type: "string", required: true },
satellite_count: { type: "number", required: false, minimum: 0 },
sensor_readings: { type: "telemetry_readings", required: false },
signal_state: { type: "string", required: true, enum: ["active", "inactive"] },
speed_kph: { type: "number", required: false, minimum: 0 },
},
history: { mode: "sampled", intervalMs: 60_000, strategy: "latest-per-entity-per-bucket", retentionDays: 90 },
}
: product
));
value.mappingContracts = value.mappingContracts.map((mapping) => {
if (mapping.id !== PREVIOUS_MAPPING_ID) return mapping;
return {
...mapping,
id: MAPPING_ID,
version: GELIOS_PROVIDER_PACKAGE_VERSION,
fieldPolicyId: FIELD_POLICY_ID,
target: {
...mapping.target,
dataProductId: GELIOS_POSITIONS_DATA_PRODUCT_ID,
version: GELIOS_POSITIONS_DATA_PRODUCT_VERSION,
ontologyRevision: GELIOS_POSITIONS_ONTOLOGY_REVISION,
},
derivations: {
...mapping.derivations,
sensor_readings: {
kind: "bounded_readings",
rules: ["last_sensor_values.normalized"],
default: [],
parameters: { maxReadings: 128 },
},
},
fact: {
...mapping.fact,
attributes: {
...mapping.fact.attributes,
engine_hours: {
strategy: "first_non_empty",
paths: ["counters.engineHours.value"],
coerce: "number",
minimum: 0,
omitIfMissing: true,
omitIfInvalid: true,
},
mileage_km: {
strategy: "first_non_empty",
paths: ["counters.mileage.value"],
coerce: "number",
minimum: 0,
omitIfMissing: true,
omitIfInvalid: true,
},
sensor_readings: { derive: "sensor_readings" },
},
},
};
});
value.l2Templates = value.l2Templates.map((template) => {
if (template.id !== PREVIOUS_TEMPLATE_ID) return template;
return {
...template,
id: TEMPLATE_ID,
version: GELIOS_PROVIDER_PACKAGE_VERSION,
steps: template.steps.map((step) => {
if (step.kind === "semantic_mapping") return { ...step, mappingContractId: MAPPING_ID };
if (step.kind === "data_product_publish") return { ...step, dataProductId: GELIOS_POSITIONS_DATA_PRODUCT_ID };
return step;
}),
};
});
export const geliosProviderPackageV7 = deepFreeze(value);
function replaceOne(items, previous, next) {
if (!Array.isArray(items) || items.filter((item) => item === previous).length !== 1) {
throw new Error(`gelios_v7_manifest_predecessor_invalid:${previous}`);
}
return items.map((item) => item === previous ? next : item);
}
function deepFreeze(input) {
if (!input || typeof input !== "object" || Object.isFrozen(input)) return input;
Object.freeze(input);
for (const child of Object.values(input)) deepFreeze(child);
return input;
}

View File

@ -7,6 +7,11 @@ export {
isBoundedGeoJsonGeometry, isBoundedGeoJsonGeometry,
validateGeoJsonGeometry, validateGeoJsonGeometry,
} from "./geometry.mjs"; } from "./geometry.mjs";
export {
TELEMETRY_READINGS_MAX_BYTES,
TELEMETRY_READINGS_MAX_ITEMS,
isBoundedTelemetryReadings,
} from "./telemetry-readings.mjs";
export { export {
DATA_PRODUCT_HISTORY_SCHEMA_VERSION, DATA_PRODUCT_HISTORY_SCHEMA_VERSION,
DATA_PRODUCT_PATCH_SCHEMA_VERSION, DATA_PRODUCT_PATCH_SCHEMA_VERSION,

View File

@ -9,6 +9,11 @@ export {
isBoundedGeoJsonGeometry, isBoundedGeoJsonGeometry,
validateGeoJsonGeometry, validateGeoJsonGeometry,
} from "./geometry.mjs"; } from "./geometry.mjs";
export {
TELEMETRY_READINGS_MAX_BYTES,
TELEMETRY_READINGS_MAX_ITEMS,
isBoundedTelemetryReadings,
} from "./telemetry-readings.mjs";
export { export {
ZONE_SOURCE_ADAPTERS, ZONE_SOURCE_ADAPTERS,
ZONE_SOURCE_GENERATION_SCHEMA_VERSION, ZONE_SOURCE_GENERATION_SCHEMA_VERSION,

View File

@ -4,6 +4,7 @@ export const L2_CONNECTION_INSTANCE_SCHEMA_VERSION = "nodedc.l2-connection-insta
export const SEMANTIC_MAPPING_SCHEMA_VERSION = "nodedc.semantic-mapping/v1"; export const SEMANTIC_MAPPING_SCHEMA_VERSION = "nodedc.semantic-mapping/v1";
import { SECRET_LIKE_VALUE as SECRET_VALUE } from "./sensitive-field-policy.mjs"; import { SECRET_LIKE_VALUE as SECRET_VALUE } from "./sensitive-field-policy.mjs";
import { isBoundedTelemetryReadings } from "./telemetry-readings.mjs";
const IDENTIFIER = /^[a-z][a-z0-9._:-]{2,127}$/; const IDENTIFIER = /^[a-z][a-z0-9._:-]{2,127}$/;
const SEMVER = /^\d+\.\d+\.\d+(?:[-+][a-z0-9.-]+)?$/i; const SEMVER = /^\d+\.\d+\.\d+(?:[-+][a-z0-9.-]+)?$/i;
@ -20,7 +21,7 @@ const TOKEN_REFRESH_MODES = new Set(["not_applicable", "operator_managed", "runt
const COLLECTION_MODES = new Set(["realtime", "manual", "history", "weekly"]); const COLLECTION_MODES = new Set(["realtime", "manual", "history", "weekly"]);
const DELIVERY_MODES = new Set(["snapshot", "snapshot+patch", "query"]); const DELIVERY_MODES = new Set(["snapshot", "snapshot+patch", "query"]);
const HISTORY_MODES = new Set(["none", "all", "sampled"]); const HISTORY_MODES = new Set(["none", "all", "sampled"]);
const FIELD_CONTRACT_TYPES = new Set(["string", "number", "boolean", "string_array", "point", "geometry"]); const FIELD_CONTRACT_TYPES = new Set(["string", "number", "boolean", "string_array", "telemetry_readings", "point", "geometry"]);
const L2_STEP_KINDS = new Set([ const L2_STEP_KINDS = new Set([
"collection_trigger", "collection_trigger",
"provider_request", "provider_request",
@ -871,7 +872,7 @@ function validateFieldContract(value, path, errors) {
if (value.enum !== undefined) { if (value.enum !== undefined) {
if (!Array.isArray(value.enum) || value.enum.length === 0 || new Set(value.enum.map(stableLiteral)).size !== value.enum.length) { if (!Array.isArray(value.enum) || value.enum.length === 0 || new Set(value.enum.map(stableLiteral)).size !== value.enum.length) {
errors.push(`${path}.enum_must_be_nonempty_unique_array`); errors.push(`${path}.enum_must_be_nonempty_unique_array`);
} else if (new Set(["point", "geometry", "string_array"]).has(value.type) } else if (new Set(["point", "geometry", "string_array", "telemetry_readings"]).has(value.type)
|| value.enum.some((item) => !fieldContractValueMatchesType(item, value.type))) { || value.enum.some((item) => !fieldContractValueMatchesType(item, value.type))) {
errors.push(`${path}.enum_value_type_invalid`); errors.push(`${path}.enum_value_type_invalid`);
} }
@ -939,6 +940,7 @@ function validateMappedValueAgainstFieldContract(value, contract, path, errors)
function fieldContractValueMatchesType(value, type) { function fieldContractValueMatchesType(value, type) {
if (type === "string_array") return Array.isArray(value) && value.every((item) => typeof item === "string"); if (type === "string_array") return Array.isArray(value) && value.every((item) => typeof item === "string");
if (type === "telemetry_readings") return isBoundedTelemetryReadings(value);
if (type === "point") { if (type === "point") {
return isPlainObject(value) return isPlainObject(value)
&& value.type === "Point" && value.type === "Point"
@ -997,7 +999,7 @@ function validateExpression(value, path, errors) {
function validateDerivation(value, path, errors) { function validateDerivation(value, path, errors) {
if (!isPlainObject(value)) return errors.push(`${path}_must_be_object`); if (!isPlainObject(value)) return errors.push(`${path}_must_be_object`);
rejectUnknownKeys(value, DERIVATION_KEYS, path, errors); rejectUnknownKeys(value, DERIVATION_KEYS, path, errors);
if (!new Set(["boolean_rule", "ordered_rules", "flag_set"]).has(value.kind)) errors.push(`${path}.kind_invalid`); if (!new Set(["boolean_rule", "ordered_rules", "flag_set", "bounded_readings"]).has(value.kind)) errors.push(`${path}.kind_invalid`);
requiredUniqueIdentifierArray(value.rules, `${path}.rules`, errors); requiredUniqueIdentifierArray(value.rules, `${path}.rules`, errors);
if (value.default === undefined) { if (value.default === undefined) {
errors.push(`${path}.default_required`); errors.push(`${path}.default_required`);

View File

@ -0,0 +1,48 @@
export const TELEMETRY_READINGS_MAX_ITEMS = 128;
export const TELEMETRY_READINGS_MAX_BYTES = 128 * 1024;
const READING_KEYS = new Set(["id", "label", "value", "unit", "observedAt"]);
const READING_ID = /^[a-z][a-z0-9._:-]{1,127}$/;
/**
* Validate the provider-neutral bounded telemetry collection accepted inside
* one current-position fact. Provider-specific payloads, raw sensor objects
* and arbitrary recursive JSON are deliberately excluded.
*/
export function isBoundedTelemetryReadings(value) {
if (!Array.isArray(value) || value.length > TELEMETRY_READINGS_MAX_ITEMS) return false;
let encoded;
try {
encoded = new TextEncoder().encode(JSON.stringify(value));
} catch {
return false;
}
if (encoded.byteLength > TELEMETRY_READINGS_MAX_BYTES) return false;
const ids = new Set();
for (const reading of value) {
if (!isPlainObject(reading) || Object.keys(reading).some((key) => !READING_KEYS.has(key))) return false;
if (typeof reading.id !== "string" || !READING_ID.test(reading.id) || ids.has(reading.id)) return false;
ids.add(reading.id);
if (typeof reading.label !== "string" || !reading.label.trim() || reading.label.length > 160) return false;
if (!isScalar(reading.value)) return false;
if (typeof reading.value === "string" && reading.value.length > 512) return false;
if (reading.unit !== undefined && (typeof reading.unit !== "string" || reading.unit.length > 32)) return false;
if (reading.observedAt !== undefined && !isIsoTimestamp(reading.observedAt)) return false;
}
return true;
}
function isScalar(value) {
return typeof value === "string"
|| typeof value === "boolean"
|| (typeof value === "number" && Number.isFinite(value));
}
function isIsoTimestamp(value) {
return typeof value === "string" && !Number.isNaN(Date.parse(value));
}
function isPlainObject(value) {
return Boolean(value) && typeof value === "object" && !Array.isArray(value);
}

View File

@ -19,6 +19,7 @@ import { geliosProviderPackageV3 } from "../providers/gelios/v3/index.mjs";
import { geliosProviderPackageV4 } from "../providers/gelios/v4/index.mjs"; import { geliosProviderPackageV4 } from "../providers/gelios/v4/index.mjs";
import { geliosProviderPackageV5 } from "../providers/gelios/v5/index.mjs"; import { geliosProviderPackageV5 } from "../providers/gelios/v5/index.mjs";
import { geliosProviderPackageV6 } from "../providers/gelios/v6/index.mjs"; import { geliosProviderPackageV6 } from "../providers/gelios/v6/index.mjs";
import { geliosProviderPackageV7 } from "../providers/gelios/v7/index.mjs";
import { normalizeDataProductDefinition } from "../../../services/external-data-plane/src/data-product-policy.mjs"; import { normalizeDataProductDefinition } from "../../../services/external-data-plane/src/data-product-policy.mjs";
const expectedFields = [ const expectedFields = [
@ -43,6 +44,28 @@ assert.deepEqual(validateProviderPackage(geliosProviderPackageV3), { ok: true, e
assert.deepEqual(validateProviderPackage(geliosProviderPackageV4), { ok: true, errors: [] }); assert.deepEqual(validateProviderPackage(geliosProviderPackageV4), { ok: true, errors: [] });
assert.deepEqual(validateProviderPackage(geliosProviderPackageV5), { ok: true, errors: [] }); assert.deepEqual(validateProviderPackage(geliosProviderPackageV5), { ok: true, errors: [] });
assert.deepEqual(validateProviderPackage(geliosProviderPackageV6), { ok: true, errors: [] }); assert.deepEqual(validateProviderPackage(geliosProviderPackageV6), { ok: true, errors: [] });
assert.deepEqual(validateProviderPackage(geliosProviderPackageV7), { ok: true, errors: [] });
const telemetryProduct = geliosProviderPackageV7.dataProducts.find((product) => product.id === "fleet.positions.current.v5");
const registeredTelemetryProduct = JSON.parse(await readFile(new URL(
"../../../services/external-data-plane/definitions/fleet.positions.current.v5.json",
import.meta.url,
), "utf8"));
assert.deepEqual(telemetryProduct, registeredTelemetryProduct);
assert.equal(telemetryProduct.fieldContracts.sensor_readings.type, "telemetry_readings");
const telemetryCapability = geliosProviderPackageV7.capabilities.find((capability) => capability.id === "gelios.units.current.read");
assert.deepEqual(telemetryCapability.request.query, {
incltrip: "true",
inclcntrs: "true",
inclsnsrs: "true",
incllsv: "true",
});
const telemetryMapping = geliosProviderPackageV7.mappingContracts.find((mapping) => mapping.id === "gelios.units.to.fleet.positions.current.v7");
assert.equal(telemetryMapping.derivations.sensor_readings.kind, "bounded_readings");
assert.equal(telemetryMapping.derivations.sensor_readings.parameters.maxReadings, 128);
assert.equal(telemetryMapping.fact.attributes.mileage_km.paths[0], "counters.mileage.value");
assert.equal(telemetryMapping.fact.attributes.engine_hours.paths[0], "counters.engineHours.value");
assert.equal(geliosProviderPackageV7.dataProducts.some((product) => product.id === "map.gelios-geofences.current.v1"), true);
const zonesProduct = geliosProviderPackageV6.dataProducts.find((product) => product.id === "map.gelios-geofences.current.v1"); const zonesProduct = geliosProviderPackageV6.dataProducts.find((product) => product.id === "map.gelios-geofences.current.v1");
const registeredZonesProduct = JSON.parse(await readFile(new URL( const registeredZonesProduct = JSON.parse(await readFile(new URL(

View File

@ -9,6 +9,7 @@ COPY packages/external-provider-contract/src/data-product.mjs ./packages/externa
COPY packages/external-provider-contract/src/geometry.mjs ./packages/external-provider-contract/src/geometry.mjs COPY packages/external-provider-contract/src/geometry.mjs ./packages/external-provider-contract/src/geometry.mjs
COPY packages/external-provider-contract/src/intake-batch.mjs ./packages/external-provider-contract/src/intake-batch.mjs COPY packages/external-provider-contract/src/intake-batch.mjs ./packages/external-provider-contract/src/intake-batch.mjs
COPY packages/external-provider-contract/src/sensitive-field-policy.mjs ./packages/external-provider-contract/src/sensitive-field-policy.mjs COPY packages/external-provider-contract/src/sensitive-field-policy.mjs ./packages/external-provider-contract/src/sensitive-field-policy.mjs
COPY packages/external-provider-contract/src/telemetry-readings.mjs ./packages/external-provider-contract/src/telemetry-readings.mjs
COPY services/external-data-plane/package.json services/external-data-plane/package-lock.json ./services/external-data-plane/ COPY services/external-data-plane/package.json services/external-data-plane/package-lock.json ./services/external-data-plane/
WORKDIR /workspace/services/external-data-plane WORKDIR /workspace/services/external-data-plane

View File

@ -0,0 +1,49 @@
{
"id": "fleet.positions.current.v5",
"version": "5.0.0",
"ontologyRevision": "ontology.map.moving_object.v3",
"deliveryMode": "snapshot+patch",
"semanticTypes": [
"map.moving_object"
],
"fields": [
"course_degrees",
"display_name",
"elevation_meters",
"engine_hours",
"geometry",
"hdop",
"horizontal_accuracy_meters",
"mileage_km",
"movement_state",
"object_kind",
"position_source",
"satellite_count",
"sensor_readings",
"signal_state",
"speed_kph"
],
"fieldContracts": {
"course_degrees": { "type": "number", "required": false, "minimum": 0, "maximum": 360 },
"display_name": { "type": "string", "required": true },
"elevation_meters": { "type": "number", "required": false },
"engine_hours": { "type": "number", "required": false, "minimum": 0 },
"geometry": { "type": "point", "required": false },
"hdop": { "type": "number", "required": false, "minimum": 0 },
"horizontal_accuracy_meters": { "type": "number", "required": false, "minimum": 0 },
"mileage_km": { "type": "number", "required": false, "minimum": 0 },
"movement_state": { "type": "string", "required": true, "enum": ["moving", "stopped"] },
"object_kind": { "type": "string", "required": true },
"position_source": { "type": "string", "required": true },
"satellite_count": { "type": "number", "required": false, "minimum": 0 },
"sensor_readings": { "type": "telemetry_readings", "required": false },
"signal_state": { "type": "string", "required": true, "enum": ["active", "inactive"] },
"speed_kph": { "type": "number", "required": false, "minimum": 0 }
},
"history": {
"mode": "sampled",
"intervalMs": 60000,
"strategy": "latest-per-entity-per-bucket",
"retentionDays": 90
}
}

View File

@ -4,6 +4,7 @@ import {
DATA_PRODUCT_PATCH_SCHEMA_VERSION, DATA_PRODUCT_PATCH_SCHEMA_VERSION,
DATA_PRODUCT_SNAPSHOT_SCHEMA_VERSION, DATA_PRODUCT_SNAPSHOT_SCHEMA_VERSION,
isBoundedGeoJsonGeometry, isBoundedGeoJsonGeometry,
isBoundedTelemetryReadings,
} from "@nodedc/external-provider-contract/data-plane"; } from "@nodedc/external-provider-contract/data-plane";
const IDENTIFIER = /^[a-z][a-z0-9._:-]{2,127}$/; const IDENTIFIER = /^[a-z][a-z0-9._:-]{2,127}$/;
@ -854,6 +855,7 @@ function assertFieldContractValue(value, contract) {
function fieldContractValueMatchesType(value, type) { function fieldContractValueMatchesType(value, type) {
if (type === "string_array") return Array.isArray(value) && value.every((item) => typeof item === "string"); if (type === "string_array") return Array.isArray(value) && value.every((item) => typeof item === "string");
if (type === "telemetry_readings") return isBoundedTelemetryReadings(value);
if (type === "point") return isBoundedGeoJsonGeometry(value, new Set(["Point"])); if (type === "point") return isBoundedGeoJsonGeometry(value, new Set(["Point"]));
if (type === "geometry") return isBoundedGeoJsonGeometry(value); if (type === "geometry") return isBoundedGeoJsonGeometry(value);
return typeof value === type && (type !== "number" || Number.isFinite(value)); return typeof value === type && (type !== "number" || Number.isFinite(value));

View File

@ -1,10 +1,10 @@
import { isBoundedGeoJsonGeometry } from "@nodedc/external-provider-contract/data-plane"; import { isBoundedGeoJsonGeometry, isBoundedTelemetryReadings } from "@nodedc/external-provider-contract/data-plane";
const IDENTIFIER = /^[a-z][a-z0-9._:-]{2,127}$/; const IDENTIFIER = /^[a-z][a-z0-9._:-]{2,127}$/;
const SEMVER = /^\d+\.\d+\.\d+(?:[-+][a-z0-9.-]+)?$/i; const SEMVER = /^\d+\.\d+\.\d+(?:[-+][a-z0-9.-]+)?$/i;
const DELIVERY_MODES = new Set(["snapshot", "snapshot+patch", "query"]); const DELIVERY_MODES = new Set(["snapshot", "snapshot+patch", "query"]);
const HISTORY_MODES = new Set(["none", "all", "sampled"]); const HISTORY_MODES = new Set(["none", "all", "sampled"]);
const FIELD_CONTRACT_TYPES = new Set(["string", "number", "boolean", "string_array", "point", "geometry"]); const FIELD_CONTRACT_TYPES = new Set(["string", "number", "boolean", "string_array", "telemetry_readings", "point", "geometry"]);
const DEFINITION_KEYS = new Set([ const DEFINITION_KEYS = new Set([
"id", "version", "ontologyRevision", "deliveryMode", "semanticTypes", "fields", "fieldContracts", "history", "id", "version", "ontologyRevision", "deliveryMode", "semanticTypes", "fields", "fieldContracts", "history",
]); ]);
@ -65,7 +65,7 @@ function normalizeFieldContract(value) {
if (value.enum !== undefined) { if (value.enum !== undefined) {
if (!Array.isArray(value.enum) || value.enum.length === 0 if (!Array.isArray(value.enum) || value.enum.length === 0
|| new Set(value.enum.map(stableLiteral)).size !== value.enum.length || new Set(value.enum.map(stableLiteral)).size !== value.enum.length
|| new Set(["point", "geometry", "string_array"]).has(type) || new Set(["point", "geometry", "string_array", "telemetry_readings"]).has(type)
|| value.enum.some((item) => !fieldContractValueMatchesType(item, type))) { || value.enum.some((item) => !fieldContractValueMatchesType(item, type))) {
throw policyError("data_product_field_contract_enum_invalid"); throw policyError("data_product_field_contract_enum_invalid");
} }
@ -174,6 +174,7 @@ function hasOnlyKeys(value, allowed) {
function fieldContractValueMatchesType(value, type) { function fieldContractValueMatchesType(value, type) {
if (type === "string_array") return Array.isArray(value) && value.every((item) => typeof item === "string"); if (type === "string_array") return Array.isArray(value) && value.every((item) => typeof item === "string");
if (type === "telemetry_readings") return isBoundedTelemetryReadings(value);
if (type === "point") return isBoundedGeoJsonGeometry(value, new Set(["Point"])); if (type === "point") return isBoundedGeoJsonGeometry(value, new Set(["Point"]));
if (type === "geometry") return isBoundedGeoJsonGeometry(value); if (type === "geometry") return isBoundedGeoJsonGeometry(value);
return typeof value === type && (type !== "number" || Number.isFinite(value)); return typeof value === type && (type !== "number" || Number.isFinite(value));

View File

@ -65,6 +65,40 @@ assert.throws(
}, definition), }, definition),
(error) => error?.status === 422 && error?.code === "data_product_field_type_invalid", (error) => error?.status === 422 && error?.code === "data_product_field_type_invalid",
); );
const telemetryDefinition = normalizeDataProductDefinition({
id: "test.telemetry.current.v1",
version: "1.0.0",
ontologyRevision: "ontology.test.telemetry.v1",
deliveryMode: "snapshot+patch",
semanticTypes: ["map.moving_object"],
fields: ["sensor_readings"],
fieldContracts: {
sensor_readings: { type: "telemetry_readings", required: false },
},
history: { mode: "none", retentionDays: 1 },
});
const validReadings = [{
id: "battery.voltage",
label: "Battery voltage",
value: 51.7,
unit: "V",
observedAt: "2026-07-22T08:00:00.000Z",
}];
assert.doesNotThrow(() => assertPublishMatchesDefinition({ facts: [{
sourceId: "unit-telemetry",
semanticType: "map.moving_object",
observedAt: "2026-07-22T08:00:00.000Z",
attributes: { sensor_readings: validReadings },
}] }, telemetryDefinition));
assert.throws(
() => assertPublishMatchesDefinition({ facts: [{
sourceId: "unit-telemetry",
semanticType: "map.moving_object",
attributes: { sensor_readings: [{ ...validReadings[0], value: { raw: 51.7 } }] },
}] }, telemetryDefinition),
(error) => error?.status === 422 && error?.code === "data_product_field_type_invalid",
);
assert.throws( assert.throws(
() => assertPublishMatchesDefinition({ () => assertPublishMatchesDefinition({
facts: [{ ...fact, attributes: { ...fact.attributes, speed_kph: -1 } }], facts: [{ ...fact, attributes: { ...fact.attributes, speed_kph: -1 } }],

View File

@ -95,6 +95,15 @@ assert.equal(Object.isFrozen(strict.fieldContracts), true);
assert.equal(Object.isFrozen(strict.fieldContracts.signal_state), true); assert.equal(Object.isFrozen(strict.fieldContracts.signal_state), true);
assert.equal(Object.isFrozen(strict.fieldContracts.signal_state.enum), true); assert.equal(Object.isFrozen(strict.fieldContracts.signal_state.enum), true);
const telemetry = normalizeDataProductDefinition({
...input,
fields: ["sensor_readings"],
fieldContracts: {
sensor_readings: { type: "telemetry_readings", required: false },
},
});
assert.equal(telemetry.fieldContracts.sensor_readings.type, "telemetry_readings");
assert.throws( assert.throws(
() => normalizeDataProductDefinition({ () => normalizeDataProductDefinition({
...input, ...input,

View File

@ -11,6 +11,7 @@ assert.deepEqual(bundled.map((definition) => definition.id), [
"fleet.positions.current.v2", "fleet.positions.current.v2",
"fleet.positions.current.v3", "fleet.positions.current.v3",
"fleet.positions.current.v4", "fleet.positions.current.v4",
"fleet.positions.current.v5",
"map.zones.current.v1", "map.zones.current.v1",
"map.zones.current.v2", "map.zones.current.v2",
]); ]);
@ -47,19 +48,23 @@ assert.deepEqual(bundled[3].fieldContracts.movement_state, {
required: true, required: true,
enum: ["moving", "stopped"], enum: ["moving", "stopped"],
}); });
assert.equal(bundled[4].version, "1.0.0"); assert.equal(bundled[4].version, "5.0.0");
assert.equal(bundled[4].ontologyRevision, "ontology.map.zone.v1"); assert.equal(bundled[4].ontologyRevision, "ontology.map.moving_object.v3");
assert.deepEqual(bundled[4].semanticTypes, ["map.zone"]); assert.equal(bundled[4].fieldContracts.sensor_readings.type, "telemetry_readings");
assert.deepEqual(bundled[4].fieldContracts.geometry, { type: "geometry", required: true }); assert.equal(bundled[5].version, "1.0.0");
assert.equal(bundled[4].fields.includes("dataset_authority"), false);
assert.equal(bundled[5].version, "2.0.0");
assert.equal(bundled[5].ontologyRevision, "ontology.map.zone.v1"); assert.equal(bundled[5].ontologyRevision, "ontology.map.zone.v1");
assert.deepEqual(bundled[5].semanticTypes, ["map.zone"]); assert.deepEqual(bundled[5].semanticTypes, ["map.zone"]);
assert.deepEqual(bundled[5].fieldContracts.geometry, { type: "geometry", required: true }); assert.deepEqual(bundled[5].fieldContracts.geometry, { type: "geometry", required: true });
assert.deepEqual(bundled[5].fieldContracts.dataset_authority.enum, ["moscow-department-of-transport"]); assert.equal(bundled[5].fields.includes("dataset_authority"), false);
assert.equal(bundled[6].version, "2.0.0");
assert.equal(bundled[6].ontologyRevision, "ontology.map.zone.v1");
assert.deepEqual(bundled[6].semanticTypes, ["map.zone"]);
assert.deepEqual(bundled[6].fieldContracts.geometry, { type: "geometry", required: true });
assert.deepEqual(bundled[6].fieldContracts.dataset_authority.enum, ["moscow-department-of-transport"]);
assert.equal(Object.keys(bundled[3].fieldContracts).length, bundled[3].fields.length); assert.equal(Object.keys(bundled[3].fieldContracts).length, bundled[3].fields.length);
assert.equal(Object.keys(bundled[4].fieldContracts).length, bundled[4].fields.length); assert.equal(Object.keys(bundled[4].fieldContracts).length, bundled[4].fields.length);
assert.equal(Object.keys(bundled[5].fieldContracts).length, bundled[5].fields.length); assert.equal(Object.keys(bundled[5].fieldContracts).length, bundled[5].fields.length);
assert.equal(Object.keys(bundled[6].fieldContracts).length, bundled[6].fields.length);
const directory = await mkdtemp(join(tmpdir(), "nodedc-edp-definitions-")); const directory = await mkdtemp(join(tmpdir(), "nodedc-edp-definitions-"));
try { try {