feat(platform): complete the Gelios external data loop
This commit is contained in:
@@ -48,14 +48,18 @@ read-capability передаёт все entities, которые provider воз
|
||||
boundary запрещены; сортировка и видимость принадлежат Data Product consumer и
|
||||
Foundry.
|
||||
|
||||
Первый production-shaped package — `providers/gelios/v1`. Он фиксирует реальный
|
||||
Gelios REST `GET /api/v1/units` transport contract и точный output
|
||||
`fleet.positions.current.v1@1.0.0` с revision
|
||||
`ontology.map.moving_object.v1`. Все Data Product fields используют snake_case.
|
||||
Его optional `tokenLifecycle` точно описывает два provider-issued artifacts —
|
||||
`access` и `refresh`: request использует `access`, а refresh пока имеет режим
|
||||
`operator_managed`. Это metadata без secret values и без заявления о
|
||||
реализованном автоматическом refresh.
|
||||
Текущий production-shaped package — `providers/gelios/v5`. Он сохраняет два
|
||||
официальных Gelios REST safe-read: `GET /api/v1/users/me/monitoring-config` и
|
||||
`GET /api/v1/units?incltrip=true`, а также точный immutable output
|
||||
`fleet.positions.current.v4@4.0.0` с revision
|
||||
`ontology.map.moving_object.v3`. Единственные monitoring-state fields —
|
||||
`signal_state` (`active|inactive`) и `movement_state` (`moving|stopped`),
|
||||
полученные из Ontology Gelios `v1.1.0`. Все Data Product fields используют
|
||||
snake_case. `fieldContracts` фиксирует тип и обязательность каждого поля, а
|
||||
закрытые state values проверяются и в provider mapping, и на каждом publish в
|
||||
External Data Plane. Native rotating credential использует `access`, а
|
||||
`refresh` остаётся внутри NDC L2 Credentials. Предыдущие Data Product versions
|
||||
не переписываются и остаются legacy-compatible.
|
||||
|
||||
## Проверяемые v1 contracts
|
||||
|
||||
@@ -64,8 +68,11 @@ Gelios REST `GET /api/v1/units` transport contract и точный output
|
||||
запрещены; publisher представлен system-managed declaration/status без ref.
|
||||
- `Collection Profile` — явная policy сбора. `manual` не может скрыто содержать
|
||||
polling interval; `realtime` требует interval не чаще одного раза в секунду.
|
||||
- `Data Product` — нормализованный versioned output с semantic types, полями и
|
||||
внутренней аудиторией.
|
||||
- `Data Product` — нормализованный versioned output с semantic types, полями,
|
||||
optional `fieldContracts` и внутренней аудиторией. Если `fieldContracts`
|
||||
задан, он обязан покрывать точный набор fields; каждый contract задаёт
|
||||
`type`, `required`, optional closed `enum` и числовые bounds. Отсутствие
|
||||
contracts допустимо только для опубликованных legacy versions.
|
||||
- `Intake Batch` — canonical **scoped** record, который External Data Plane
|
||||
валидирует и сохраняет: source, contract revision, idempotency, restricted
|
||||
raw envelope и canonical facts. Его `source` содержит `providerId`,
|
||||
@@ -153,10 +160,11 @@ Provider auth и внутренние workload capabilities используют
|
||||
credential domains. После сохранения ядро владеет secret, а graph и MCP
|
||||
используют только opaque reference.
|
||||
|
||||
Gelios выдаёт ровно access token и refresh token. Текущий credential type
|
||||
`httpBearerAuth` использует access token для HTTP request. Автоматический обмен
|
||||
refresh → access в текущем runtime не доказан и поэтому не заявлен: package
|
||||
фиксирует `refreshMode: operator_managed`. Название credential с текстом вроде
|
||||
Gelios выдаёт ровно access token и refresh token. Production credential type
|
||||
`ndcProviderRotatingAccessApi` использует access token для HTTP request и выполняет
|
||||
refresh → access в native n8n `preAuthentication`; provider package v3 фиксирует
|
||||
`refreshMode: runtime_managed`. Оба secret artifact остаются в native Credentials и не
|
||||
попадают в graph, package или trace. Название credential с текстом вроде
|
||||
`read access` является лишь локальной меткой; read-классификацию задают
|
||||
разрешённые endpoint/method в capability catalog и workflow policy, а не scope
|
||||
самого access token. В deployed Engine exact method/path policy ещё не
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
# Gelios provider package v3
|
||||
|
||||
Version 3 describes the production REST transport used by the L2 workflow:
|
||||
`GET https://api.geliospro.com/api/v1/units`. The provider credential keeps the
|
||||
Gelios access/refresh pair inside native NDC L2 Credentials; n8n refreshes the
|
||||
access token at request time and exposes neither secret to the graph. The
|
||||
normalized output is the immutable provider-neutral Data Product
|
||||
`fleet.positions.current.v2@2.0.0`.
|
||||
|
||||
The product adds four orthogonal state facets owned by L2 semantic mapping:
|
||||
|
||||
- `availability_state`: `online`, `offline`, `unknown`;
|
||||
- `motion_state`: `moving`, `stationary`, `unknown`;
|
||||
- `position_state`: `valid`, `low_quality`, `missing`;
|
||||
- `freshness_state`: `fresh`, `stale`.
|
||||
|
||||
`state_policy_version` identifies the mapping policy that produced the facets.
|
||||
`operational_status` remains in the product as a backwards-compatible coarse
|
||||
state; presentation classes, colours, pin proportions and label geometry remain
|
||||
Foundry-owned and are never part of this provider package.
|
||||
@@ -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,
|
||||
geliosProviderPackageV3,
|
||||
} from "./package.mjs";
|
||||
@@ -0,0 +1,185 @@
|
||||
import { geliosProviderPackageV1 } from "../v1/package.mjs";
|
||||
|
||||
export const GELIOS_PROVIDER_PACKAGE_ID = "gelios.provider.v3";
|
||||
export const GELIOS_PROVIDER_PACKAGE_VERSION = "3.0.0";
|
||||
export const GELIOS_POSITIONS_DATA_PRODUCT_ID = "fleet.positions.current.v2";
|
||||
export const GELIOS_POSITIONS_DATA_PRODUCT_VERSION = "2.0.0";
|
||||
export const GELIOS_POSITIONS_ONTOLOGY_REVISION = "ontology.map.moving_object.v2";
|
||||
export const GELIOS_UNIT_SOURCE_ID_PREFIX = "gelios-unit-";
|
||||
|
||||
const FIELD_POLICY_ID = "gelios.positions.current.fields.v2";
|
||||
const AUTH_MODE_ID = "gelios.rest-rotating-bearer.v3";
|
||||
const REALTIME_PROFILE_ID = "gelios.positions.current.realtime.v3";
|
||||
const MANUAL_PROFILE_ID = "gelios.positions.current.manual.v3";
|
||||
const MAPPING_ID = "gelios.units.to.fleet.positions.current.v3";
|
||||
const TEMPLATE_ID = "gelios.positions.current.l2.v3";
|
||||
const STATE_POLICY_VERSION = "map-moving-object-state/v1";
|
||||
|
||||
const targetFields = Object.freeze([
|
||||
"availability_state",
|
||||
"course_degrees",
|
||||
"display_name",
|
||||
"elevation_meters",
|
||||
"freshness_state",
|
||||
"geometry",
|
||||
"hdop",
|
||||
"horizontal_accuracy_meters",
|
||||
"motion_state",
|
||||
"object_kind",
|
||||
"operational_status",
|
||||
"position_source",
|
||||
"position_state",
|
||||
"position_valid",
|
||||
"quality_flags",
|
||||
"satellite_count",
|
||||
"speed_kph",
|
||||
"state_policy_version",
|
||||
]);
|
||||
|
||||
const value = structuredClone(geliosProviderPackageV1);
|
||||
value.id = GELIOS_PROVIDER_PACKAGE_ID;
|
||||
value.version = GELIOS_PROVIDER_PACKAGE_VERSION;
|
||||
value.manifest = {
|
||||
...value.manifest,
|
||||
id: "gelios.provider.manifest.v3",
|
||||
version: GELIOS_PROVIDER_PACKAGE_VERSION,
|
||||
authModeIds: [AUTH_MODE_ID],
|
||||
fieldPolicyIds: [FIELD_POLICY_ID],
|
||||
collectionProfileIds: [REALTIME_PROFILE_ID, MANUAL_PROFILE_ID],
|
||||
dataProductIds: [GELIOS_POSITIONS_DATA_PRODUCT_ID],
|
||||
mappingContractIds: [MAPPING_ID],
|
||||
l2TemplateIds: [TEMPLATE_ID],
|
||||
};
|
||||
value.authModes = [{
|
||||
...value.authModes[0],
|
||||
id: AUTH_MODE_ID,
|
||||
tokenLifecycle: {
|
||||
artifacts: ["access", "refresh"],
|
||||
requestArtifact: "access",
|
||||
refreshMode: "runtime_managed",
|
||||
},
|
||||
}];
|
||||
value.capabilities = [{
|
||||
...value.capabilities[0],
|
||||
authModeId: AUTH_MODE_ID,
|
||||
}];
|
||||
value.fieldPolicies = [{
|
||||
...value.fieldPolicies[0],
|
||||
id: FIELD_POLICY_ID,
|
||||
version: GELIOS_POSITIONS_DATA_PRODUCT_VERSION,
|
||||
dataProductId: GELIOS_POSITIONS_DATA_PRODUCT_ID,
|
||||
targetFields: [...targetFields],
|
||||
}];
|
||||
value.collectionProfiles = value.collectionProfiles.map((profile, index) => ({
|
||||
...profile,
|
||||
id: index === 0 ? 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 = [{
|
||||
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: [...targetFields],
|
||||
history: {
|
||||
mode: "sampled",
|
||||
intervalMs: 60000,
|
||||
strategy: "latest-per-entity-per-bucket",
|
||||
retentionDays: 90,
|
||||
},
|
||||
}];
|
||||
value.mappingContracts = [{
|
||||
...value.mappingContracts[0],
|
||||
id: MAPPING_ID,
|
||||
version: GELIOS_PROVIDER_PACKAGE_VERSION,
|
||||
fieldPolicyId: FIELD_POLICY_ID,
|
||||
target: {
|
||||
...value.mappingContracts[0].target,
|
||||
dataProductId: GELIOS_POSITIONS_DATA_PRODUCT_ID,
|
||||
version: GELIOS_POSITIONS_DATA_PRODUCT_VERSION,
|
||||
ontologyRevision: GELIOS_POSITIONS_ONTOLOGY_REVISION,
|
||||
},
|
||||
derivations: {
|
||||
...value.mappingContracts[0].derivations,
|
||||
availability_state: {
|
||||
kind: "ordered_rules",
|
||||
rules: [
|
||||
"observed_age_gt_limit.offline",
|
||||
"otherwise.online",
|
||||
],
|
||||
default: "unknown",
|
||||
parameters: { staleAfterMs: 60000 },
|
||||
},
|
||||
freshness_state: {
|
||||
kind: "ordered_rules",
|
||||
rules: [
|
||||
"observed_age_gt_limit.stale",
|
||||
"otherwise.fresh",
|
||||
],
|
||||
default: "stale",
|
||||
parameters: { staleAfterMs: 60000 },
|
||||
},
|
||||
motion_state: {
|
||||
kind: "ordered_rules",
|
||||
rules: [
|
||||
"speed_kph_gte_threshold.moving",
|
||||
"speed_kph_lt_threshold.stationary",
|
||||
"otherwise.unknown",
|
||||
],
|
||||
default: "unknown",
|
||||
parameters: { movingThresholdKph: 1 },
|
||||
},
|
||||
position_state: {
|
||||
kind: "ordered_rules",
|
||||
rules: [
|
||||
"position_invalid.missing",
|
||||
"position_quality_below_threshold.low_quality",
|
||||
"otherwise.valid",
|
||||
],
|
||||
default: "missing",
|
||||
parameters: {
|
||||
minSatelliteCount: 4,
|
||||
maxHdop: 5,
|
||||
maxHorizontalAccuracyMeters: 100,
|
||||
},
|
||||
},
|
||||
},
|
||||
fact: {
|
||||
...value.mappingContracts[0].fact,
|
||||
attributes: {
|
||||
...value.mappingContracts[0].fact.attributes,
|
||||
availability_state: { derive: "availability_state" },
|
||||
freshness_state: { derive: "freshness_state" },
|
||||
motion_state: { derive: "motion_state" },
|
||||
position_state: { derive: "position_state" },
|
||||
state_policy_version: { constant: STATE_POLICY_VERSION },
|
||||
},
|
||||
},
|
||||
}];
|
||||
value.l2Templates = [{
|
||||
...value.l2Templates[0],
|
||||
id: TEMPLATE_ID,
|
||||
version: GELIOS_PROVIDER_PACKAGE_VERSION,
|
||||
credentialBindings: value.l2Templates[0].credentialBindings.map((binding) => (
|
||||
binding.role === "provider" ? { ...binding, authModeId: AUTH_MODE_ID } : binding
|
||||
)),
|
||||
steps: value.l2Templates[0].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 geliosProviderPackageV3 = deepFreeze(value);
|
||||
|
||||
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;
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
# Gelios provider package v4
|
||||
|
||||
Version 4 is the first provider package whose monitoring states are derived
|
||||
strictly from the Gelios Ontology `v1.1.0` value contracts.
|
||||
|
||||
The collection uses two official safe-read capabilities with the same native
|
||||
rotating Gelios credential:
|
||||
|
||||
- `GET /api/v1/users/me/monitoring-config` supplies the account/user
|
||||
`signalActiveDuration` and `signalSomewhatInactiveDuration` values used by
|
||||
the official monitoring client;
|
||||
- `GET /api/v1/units?incltrip=true` supplies the complete credential-visible
|
||||
unit set, last-message time, speed and position facts.
|
||||
|
||||
The immutable provider-neutral output is
|
||||
`fleet.positions.current.v3@3.0.0`. Its only monitoring-state fields are:
|
||||
|
||||
- `signal_state`: `active` or `inactive`;
|
||||
- `movement_state`: `moving` or `stopped`.
|
||||
|
||||
The realtime collection profile resolves the signal threshold in one explicit
|
||||
order: a positive `signalSomewhatInactiveDuration`, then a positive
|
||||
`signalActiveDuration`, then the Robot2B profile fallback of `120` seconds.
|
||||
The fallback is profile metadata with observable provenance; it is never a
|
||||
silent mapper default. If no live threshold and no positive profile fallback
|
||||
exist, publication fails closed instead of manufacturing `inactive` facts.
|
||||
|
||||
There is no unknown, freshness, GPS-quality, position-quality, parked,
|
||||
no-position or aggregate operational-status state. Missing geometry remains an
|
||||
absent geometry fact and never becomes a status. Labels, counters, colours and
|
||||
layout remain Foundry presentation metadata, but Foundry may only use the exact
|
||||
values declared by Ontology.
|
||||
@@ -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,
|
||||
geliosProviderPackageV4,
|
||||
} from "./package.mjs";
|
||||
@@ -0,0 +1,214 @@
|
||||
import { geliosProviderPackageV3 } from "../v3/package.mjs";
|
||||
|
||||
export const GELIOS_PROVIDER_PACKAGE_ID = "gelios.provider.v4";
|
||||
export const GELIOS_PROVIDER_PACKAGE_VERSION = "4.0.0";
|
||||
export const GELIOS_POSITIONS_DATA_PRODUCT_ID = "fleet.positions.current.v3";
|
||||
export const GELIOS_POSITIONS_DATA_PRODUCT_VERSION = "3.0.0";
|
||||
export const GELIOS_POSITIONS_ONTOLOGY_REVISION = "ontology.map.moving_object.v3";
|
||||
export const GELIOS_UNIT_SOURCE_ID_PREFIX = "gelios-unit-";
|
||||
|
||||
const UNIT_CAPABILITY_ID = "gelios.units.current.read";
|
||||
const MONITORING_CONFIG_CAPABILITY_ID = "gelios.monitoring_config.current.read";
|
||||
const FIELD_POLICY_ID = "gelios.positions.current.fields.v3";
|
||||
const REALTIME_PROFILE_ID = "gelios.positions.current.realtime.v4";
|
||||
const MANUAL_PROFILE_ID = "gelios.positions.current.manual.v4";
|
||||
const MAPPING_ID = "gelios.units.to.fleet.positions.current.v4";
|
||||
const TEMPLATE_ID = "gelios.positions.current.l2.v4";
|
||||
|
||||
const targetFields = Object.freeze([
|
||||
"course_degrees",
|
||||
"display_name",
|
||||
"elevation_meters",
|
||||
"geometry",
|
||||
"hdop",
|
||||
"horizontal_accuracy_meters",
|
||||
"movement_state",
|
||||
"object_kind",
|
||||
"position_source",
|
||||
"satellite_count",
|
||||
"signal_state",
|
||||
"speed_kph",
|
||||
]);
|
||||
|
||||
const value = structuredClone(geliosProviderPackageV3);
|
||||
const unitCapability = value.capabilities.find((capability) => capability.id === UNIT_CAPABILITY_ID);
|
||||
const baseMapping = value.mappingContracts[0];
|
||||
const baseAttributes = baseMapping.fact.attributes;
|
||||
const authModeId = value.authModes[0].id;
|
||||
|
||||
value.id = GELIOS_PROVIDER_PACKAGE_ID;
|
||||
value.version = GELIOS_PROVIDER_PACKAGE_VERSION;
|
||||
value.manifest = {
|
||||
...value.manifest,
|
||||
id: "gelios.provider.manifest.v4",
|
||||
version: GELIOS_PROVIDER_PACKAGE_VERSION,
|
||||
ontology: {
|
||||
packageId: "gelios",
|
||||
revision: "ontology.gelios.v1_1",
|
||||
},
|
||||
capabilityIds: [MONITORING_CONFIG_CAPABILITY_ID, UNIT_CAPABILITY_ID],
|
||||
fieldPolicyIds: [FIELD_POLICY_ID],
|
||||
collectionProfileIds: [REALTIME_PROFILE_ID, MANUAL_PROFILE_ID],
|
||||
dataProductIds: [GELIOS_POSITIONS_DATA_PRODUCT_ID],
|
||||
mappingContractIds: [MAPPING_ID],
|
||||
l2TemplateIds: [TEMPLATE_ID],
|
||||
};
|
||||
value.capabilities = [{
|
||||
id: MONITORING_CONFIG_CAPABILITY_ID,
|
||||
classification: "read",
|
||||
status: "implemented",
|
||||
authModeId,
|
||||
request: {
|
||||
method: "GET",
|
||||
baseUrl: "https://api.geliospro.com",
|
||||
path: "/api/v1/users/me/monitoring-config",
|
||||
query: {},
|
||||
response: {
|
||||
collectionPaths: ["$", "data"],
|
||||
pagination: "single_bounded_response",
|
||||
},
|
||||
},
|
||||
entityScope: {
|
||||
mode: "all_visible_to_credential",
|
||||
refresh: "each_collection_run",
|
||||
businessEntityFilter: "forbidden",
|
||||
},
|
||||
}, {
|
||||
...unitCapability,
|
||||
request: {
|
||||
...unitCapability.request,
|
||||
query: { incltrip: "true" },
|
||||
},
|
||||
}];
|
||||
value.fieldPolicies = [{
|
||||
...value.fieldPolicies[0],
|
||||
id: FIELD_POLICY_ID,
|
||||
version: GELIOS_POSITIONS_DATA_PRODUCT_VERSION,
|
||||
dataProductId: GELIOS_POSITIONS_DATA_PRODUCT_ID,
|
||||
targetFields: [...targetFields],
|
||||
}];
|
||||
value.collectionProfiles = value.collectionProfiles.map((profile, index) => ({
|
||||
...profile,
|
||||
id: index === 0 ? REALTIME_PROFILE_ID : MANUAL_PROFILE_ID,
|
||||
version: GELIOS_PROVIDER_PACKAGE_VERSION,
|
||||
capabilityIds: [MONITORING_CONFIG_CAPABILITY_ID, UNIT_CAPABILITY_ID],
|
||||
dataProductId: GELIOS_POSITIONS_DATA_PRODUCT_ID,
|
||||
mappingContractId: MAPPING_ID,
|
||||
fieldPolicyId: FIELD_POLICY_ID,
|
||||
l2TemplateId: TEMPLATE_ID,
|
||||
}));
|
||||
value.dataProducts = [{
|
||||
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: [...targetFields],
|
||||
history: {
|
||||
mode: "sampled",
|
||||
intervalMs: 60000,
|
||||
strategy: "latest-per-entity-per-bucket",
|
||||
retentionDays: 90,
|
||||
},
|
||||
}];
|
||||
value.mappingContracts = [{
|
||||
...baseMapping,
|
||||
id: MAPPING_ID,
|
||||
version: GELIOS_PROVIDER_PACKAGE_VERSION,
|
||||
sourceCapabilityId: UNIT_CAPABILITY_ID,
|
||||
fieldPolicyId: FIELD_POLICY_ID,
|
||||
target: {
|
||||
...baseMapping.target,
|
||||
dataProductId: GELIOS_POSITIONS_DATA_PRODUCT_ID,
|
||||
version: GELIOS_POSITIONS_DATA_PRODUCT_VERSION,
|
||||
ontologyRevision: GELIOS_POSITIONS_ONTOLOGY_REVISION,
|
||||
},
|
||||
derivations: {
|
||||
signal_state: {
|
||||
kind: "ordered_rules",
|
||||
rules: [
|
||||
"last_message_missing.inactive",
|
||||
"last_message_age_lt_resolved_monitoring_limit.active",
|
||||
"otherwise.inactive",
|
||||
],
|
||||
default: "inactive",
|
||||
parameters: {
|
||||
monitoringConfigCapability: MONITORING_CONFIG_CAPABILITY_ID,
|
||||
activeDurationPath: "signalActiveDuration",
|
||||
somewhatInactiveDurationPath: "signalSomewhatInactiveDuration",
|
||||
inactiveDurationPath: "signalSomewhatInactiveDuration",
|
||||
collectionProfileFallbackSeconds: 120,
|
||||
thresholdResolution: "somewhatInactive_then_active_then_profileFallback",
|
||||
lastMessageTimePath: "lastMsg.time",
|
||||
},
|
||||
},
|
||||
movement_state: {
|
||||
kind: "ordered_rules",
|
||||
rules: [
|
||||
"integer_speed_gt_threshold.moving",
|
||||
"otherwise.stopped",
|
||||
],
|
||||
default: "stopped",
|
||||
parameters: {
|
||||
speedPath: "lastMsg.speed",
|
||||
movingThresholdKph: 2,
|
||||
},
|
||||
},
|
||||
},
|
||||
fact: {
|
||||
...baseMapping.fact,
|
||||
attributes: {
|
||||
course_degrees: baseAttributes.course_degrees,
|
||||
display_name: baseAttributes.display_name,
|
||||
elevation_meters: baseAttributes.elevation_meters,
|
||||
hdop: baseAttributes.hdop,
|
||||
horizontal_accuracy_meters: baseAttributes.horizontal_accuracy_meters,
|
||||
movement_state: { derive: "movement_state" },
|
||||
object_kind: baseAttributes.object_kind,
|
||||
position_source: baseAttributes.position_source,
|
||||
satellite_count: baseAttributes.satellite_count,
|
||||
signal_state: { derive: "signal_state" },
|
||||
speed_kph: baseAttributes.speed_kph,
|
||||
},
|
||||
},
|
||||
}];
|
||||
value.l2Templates = [{
|
||||
...value.l2Templates[0],
|
||||
id: TEMPLATE_ID,
|
||||
version: GELIOS_PROVIDER_PACKAGE_VERSION,
|
||||
steps: [{
|
||||
id: "collection.trigger",
|
||||
kind: "collection_trigger",
|
||||
collectionProfileDriven: true,
|
||||
}, {
|
||||
id: "provider.fetch-monitoring-config",
|
||||
kind: "provider_request",
|
||||
capabilityId: MONITORING_CONFIG_CAPABILITY_ID,
|
||||
}, {
|
||||
id: "provider.fetch-units",
|
||||
kind: "provider_request",
|
||||
capabilityId: UNIT_CAPABILITY_ID,
|
||||
}, {
|
||||
id: "provider.extract-units",
|
||||
kind: "extract_items",
|
||||
capabilityId: UNIT_CAPABILITY_ID,
|
||||
}, {
|
||||
id: "ontology.map",
|
||||
kind: "semantic_mapping",
|
||||
mappingContractId: MAPPING_ID,
|
||||
}, {
|
||||
id: "data-product.publish",
|
||||
kind: "data_product_publish",
|
||||
dataProductId: GELIOS_POSITIONS_DATA_PRODUCT_ID,
|
||||
nodeType: "n8n-nodes-ndc.ndcDataProductPublish",
|
||||
}],
|
||||
}];
|
||||
|
||||
export const geliosProviderPackageV4 = deepFreeze(value);
|
||||
|
||||
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;
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
# Gelios provider package v5
|
||||
|
||||
Version 5 preserves the Ontology-exact monitoring logic from provider package
|
||||
v4 and publishes it through the immutable `fleet.positions.current.v4@4.0.0`
|
||||
contract.
|
||||
|
||||
The Data Product now declares a machine-enforced contract for every published
|
||||
field. In particular:
|
||||
|
||||
- `signal_state` is required and accepts only `active` or `inactive`;
|
||||
- `movement_state` is required and accepts only `moving` or `stopped`;
|
||||
- identity/presentation attributes are typed and required where the mapping
|
||||
must always produce them;
|
||||
- optional telemetry is type-checked and bounded where the physical domain has
|
||||
a stable lower or upper limit;
|
||||
- geometry remains optional, but when present it must be a valid GeoJSON Point.
|
||||
|
||||
The provider mapping is checked against the same contract before it can be
|
||||
packaged, and External Data Plane checks every publish at runtime. Existing
|
||||
v1-v3 products remain immutable and backward compatible.
|
||||
@@ -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,
|
||||
geliosProviderPackageV5,
|
||||
} from "./package.mjs";
|
||||
@@ -0,0 +1,105 @@
|
||||
import { geliosProviderPackageV4 } from "../v4/package.mjs";
|
||||
|
||||
export const GELIOS_PROVIDER_PACKAGE_ID = "gelios.provider.v5";
|
||||
export const GELIOS_PROVIDER_PACKAGE_VERSION = "5.0.0";
|
||||
export const GELIOS_POSITIONS_DATA_PRODUCT_ID = "fleet.positions.current.v4";
|
||||
export const GELIOS_POSITIONS_DATA_PRODUCT_VERSION = "4.0.0";
|
||||
export const GELIOS_POSITIONS_ONTOLOGY_REVISION = "ontology.map.moving_object.v3";
|
||||
export const GELIOS_UNIT_SOURCE_ID_PREFIX = "gelios-unit-";
|
||||
|
||||
const FIELD_POLICY_ID = "gelios.positions.current.fields.v4";
|
||||
const REALTIME_PROFILE_ID = "gelios.positions.current.realtime.v5";
|
||||
const MANUAL_PROFILE_ID = "gelios.positions.current.manual.v5";
|
||||
const MAPPING_ID = "gelios.units.to.fleet.positions.current.v5";
|
||||
const TEMPLATE_ID = "gelios.positions.current.l2.v5";
|
||||
|
||||
const value = structuredClone(geliosProviderPackageV4);
|
||||
const dataProduct = {
|
||||
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: [...value.dataProducts[0].fields],
|
||||
fieldContracts: {
|
||||
course_degrees: { type: "number", required: false, minimum: 0, maximum: 360 },
|
||||
display_name: { type: "string", required: true },
|
||||
elevation_meters: { type: "number", required: false },
|
||||
geometry: { type: "point", required: false },
|
||||
hdop: { type: "number", required: false, minimum: 0 },
|
||||
horizontal_accuracy_meters: { 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 },
|
||||
signal_state: { type: "string", required: true, enum: ["active", "inactive"] },
|
||||
speed_kph: { type: "number", required: false, minimum: 0 },
|
||||
},
|
||||
history: { ...value.dataProducts[0].history },
|
||||
};
|
||||
|
||||
value.id = GELIOS_PROVIDER_PACKAGE_ID;
|
||||
value.version = GELIOS_PROVIDER_PACKAGE_VERSION;
|
||||
value.manifest = {
|
||||
...value.manifest,
|
||||
id: "gelios.provider.manifest.v5",
|
||||
version: GELIOS_PROVIDER_PACKAGE_VERSION,
|
||||
fieldPolicyIds: [FIELD_POLICY_ID],
|
||||
collectionProfileIds: [REALTIME_PROFILE_ID, MANUAL_PROFILE_ID],
|
||||
dataProductIds: [GELIOS_POSITIONS_DATA_PRODUCT_ID],
|
||||
mappingContractIds: [MAPPING_ID],
|
||||
l2TemplateIds: [TEMPLATE_ID],
|
||||
};
|
||||
value.fieldPolicies = value.fieldPolicies.map((policy) => ({
|
||||
...policy,
|
||||
id: FIELD_POLICY_ID,
|
||||
version: GELIOS_POSITIONS_DATA_PRODUCT_VERSION,
|
||||
dataProductId: GELIOS_POSITIONS_DATA_PRODUCT_ID,
|
||||
}));
|
||||
value.collectionProfiles = value.collectionProfiles.map((profile, index) => ({
|
||||
...profile,
|
||||
id: index === 0 ? 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 = [dataProduct];
|
||||
value.mappingContracts = value.mappingContracts.map((mapping) => ({
|
||||
...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,
|
||||
},
|
||||
}));
|
||||
const attributes = value.mappingContracts[0].fact.attributes;
|
||||
Object.assign(attributes.course_degrees, { minimum: 0, maximum: 360, omitIfInvalid: true });
|
||||
Object.assign(attributes.hdop, { minimum: 0, omitIfInvalid: true });
|
||||
Object.assign(attributes.horizontal_accuracy_meters, { minimum: 0, omitIfInvalid: true });
|
||||
Object.assign(attributes.satellite_count, { minimum: 0, omitIfInvalid: true });
|
||||
Object.assign(attributes.speed_kph, { minimum: 0, omitIfInvalid: true });
|
||||
value.l2Templates = value.l2Templates.map((template) => ({
|
||||
...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 geliosProviderPackageV5 = deepFreeze(value);
|
||||
|
||||
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;
|
||||
}
|
||||
@@ -20,6 +20,7 @@ const TOKEN_REFRESH_MODES = new Set(["not_applicable", "operator_managed", "runt
|
||||
const COLLECTION_MODES = new Set(["realtime", "manual", "history", "weekly"]);
|
||||
const DELIVERY_MODES = new Set(["snapshot", "snapshot+patch", "query"]);
|
||||
const HISTORY_MODES = new Set(["none", "all", "sampled"]);
|
||||
const FIELD_CONTRACT_TYPES = new Set(["string", "number", "boolean", "string_array", "point"]);
|
||||
const L2_STEP_KINDS = new Set([
|
||||
"collection_trigger",
|
||||
"provider_request",
|
||||
@@ -115,8 +116,10 @@ const DATA_PRODUCT_KEYS = new Set([
|
||||
"deliveryMode",
|
||||
"semanticTypes",
|
||||
"fields",
|
||||
"fieldContracts",
|
||||
"history",
|
||||
]);
|
||||
const FIELD_CONTRACT_KEYS = new Set(["type", "required", "enum", "minimum", "maximum"]);
|
||||
const HISTORY_KEYS = new Set(["mode", "intervalMs", "strategy", "retentionDays"]);
|
||||
const MAPPING_KEYS = new Set([
|
||||
"schemaVersion",
|
||||
@@ -130,7 +133,10 @@ const MAPPING_KEYS = new Set([
|
||||
]);
|
||||
const MAPPING_TARGET_KEYS = new Set(["dataProductId", "version", "ontologyRevision", "semanticType"]);
|
||||
const FACT_KEYS = new Set(["sourceId", "semanticType", "observedAt", "geometry", "attributes"]);
|
||||
const EXPRESSION_KEYS = new Set(["strategy", "paths", "coerce", "prefix", "fallback", "constant", "derive", "omitIfMissing"]);
|
||||
const EXPRESSION_KEYS = new Set([
|
||||
"strategy", "paths", "coerce", "prefix", "fallback", "constant", "derive",
|
||||
"omitIfMissing", "omitIfInvalid", "minimum", "maximum",
|
||||
]);
|
||||
const GEOMETRY_KEYS = new Set(["type", "longitude", "latitude", "omitIfInvalid"]);
|
||||
const DERIVATION_KEYS = new Set(["kind", "rules", "default", "parameters"]);
|
||||
const TEMPLATE_KEYS = new Set([
|
||||
@@ -226,9 +232,6 @@ export function validateProviderPackage(value) {
|
||||
}
|
||||
for (const profile of collectionProfiles) {
|
||||
const selectedCapabilityIds = Array.isArray(profile.capabilityIds) ? profile.capabilityIds : [];
|
||||
if (selectedCapabilityIds.length !== 1) {
|
||||
errors.push(`collectionProfile.${profile.id}.capabilityIds_must_select_one_v1_read`);
|
||||
}
|
||||
selectedCapabilityIds.forEach((id) => {
|
||||
assertReference(id, capabilityIds, `collectionProfile.${profile.id}.capabilityIds`, errors);
|
||||
const capability = capabilities.find((item) => item.id === id);
|
||||
@@ -247,10 +250,6 @@ export function validateProviderPackage(value) {
|
||||
if (!selectedCapabilityIds.includes(mapping.sourceCapabilityId)) {
|
||||
errors.push(`collectionProfile.${profile.id}.mapping_source_capability_must_be_selected`);
|
||||
}
|
||||
if (selectedCapabilityIds.length !== 1
|
||||
|| selectedCapabilityIds[0] !== mapping.sourceCapabilityId) {
|
||||
errors.push(`collectionProfile.${profile.id}.capability_chain_must_be_exact_singleton`);
|
||||
}
|
||||
if (mapping.fieldPolicyId !== profile.fieldPolicyId) errors.push(`collectionProfile.${profile.id}.mapping_field_policy_mismatch`);
|
||||
if (mapping.target?.dataProductId !== profile.dataProductId) errors.push(`collectionProfile.${profile.id}.mapping_data_product_mismatch`);
|
||||
}
|
||||
@@ -258,12 +257,19 @@ export function validateProviderPackage(value) {
|
||||
errors.push(`collectionProfile.${profile.id}.field_policy_data_product_mismatch`);
|
||||
}
|
||||
if (template) {
|
||||
const [, requestStep, extractStep, mappingStep, publishStep] = Array.isArray(template.steps) ? template.steps : [];
|
||||
if (!selectedCapabilityIds.includes(requestStep?.capabilityId) || extractStep?.capabilityId !== requestStep?.capabilityId) {
|
||||
const steps = Array.isArray(template.steps) ? template.steps : [];
|
||||
const requestSteps = steps.filter((step) => step?.kind === "provider_request");
|
||||
const extractSteps = steps.filter((step) => step?.kind === "extract_items");
|
||||
const mappingSteps = steps.filter((step) => step?.kind === "semantic_mapping");
|
||||
const publishSteps = steps.filter((step) => step?.kind === "data_product_publish");
|
||||
const executedCapabilityIds = requestSteps.map((step) => step.capabilityId);
|
||||
if (!sameSet(selectedCapabilityIds, executedCapabilityIds)
|
||||
|| extractSteps.length !== 1
|
||||
|| extractSteps[0]?.capabilityId !== mapping?.sourceCapabilityId) {
|
||||
errors.push(`collectionProfile.${profile.id}.template_capability_chain_mismatch`);
|
||||
}
|
||||
if (mappingStep?.mappingContractId !== profile.mappingContractId) errors.push(`collectionProfile.${profile.id}.template_mapping_mismatch`);
|
||||
if (publishStep?.dataProductId !== profile.dataProductId) errors.push(`collectionProfile.${profile.id}.template_data_product_mismatch`);
|
||||
if (mappingSteps.length !== 1 || mappingSteps[0]?.mappingContractId !== profile.mappingContractId) errors.push(`collectionProfile.${profile.id}.template_mapping_mismatch`);
|
||||
if (publishSteps.length !== 1 || publishSteps[0]?.dataProductId !== profile.dataProductId) errors.push(`collectionProfile.${profile.id}.template_data_product_mismatch`);
|
||||
}
|
||||
}
|
||||
for (const mapping of mappingContracts) {
|
||||
@@ -315,13 +321,15 @@ export function validateProviderPackage(value) {
|
||||
const providerBinding = Array.isArray(template.credentialBindings)
|
||||
? template.credentialBindings.find((binding) => binding?.role === "provider")
|
||||
: undefined;
|
||||
const providerRequest = Array.isArray(template.steps)
|
||||
? template.steps.find((step) => step?.kind === "provider_request")
|
||||
: undefined;
|
||||
const requestCapability = capabilities.find((capability) => capability.id === providerRequest?.capabilityId);
|
||||
if (providerBinding && requestCapability
|
||||
&& providerBinding.authModeId !== requestCapability.authModeId) {
|
||||
errors.push(`l2Template.${template.id}.provider_credential_auth_mode_mismatch`);
|
||||
const providerRequests = Array.isArray(template.steps)
|
||||
? template.steps.filter((step) => step?.kind === "provider_request")
|
||||
: [];
|
||||
for (const providerRequest of providerRequests) {
|
||||
const requestCapability = capabilities.find((capability) => capability.id === providerRequest?.capabilityId);
|
||||
if (providerBinding && requestCapability
|
||||
&& providerBinding.authModeId !== requestCapability.authModeId) {
|
||||
errors.push(`l2Template.${template.id}.provider_credential_auth_mode_mismatch`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -557,6 +565,7 @@ function validateDataProductDefinition(value, path, errors) {
|
||||
if (Array.isArray(value.fields) && value.fields.some((field) => SECRET_FIELD_NAME.test(String(field)))) {
|
||||
errors.push(`${path}.fields_must_not_contain_secret_fields`);
|
||||
}
|
||||
validateFieldContracts(value.fieldContracts, value.fields, `${path}.fieldContracts`, errors);
|
||||
if (!isPlainObject(value.history)) {
|
||||
errors.push(`${path}.history_must_be_object`);
|
||||
} else {
|
||||
@@ -688,8 +697,8 @@ function validateL2Template(value, path, errors) {
|
||||
});
|
||||
if (!roles.has("provider") || !roles.has("publisher")) errors.push(`${path}.credentialBindings_roles_invalid`);
|
||||
}
|
||||
if (!Array.isArray(value.steps) || value.steps.length !== 5) {
|
||||
errors.push(`${path}.steps_must_define_five_boundary_steps`);
|
||||
if (!Array.isArray(value.steps) || value.steps.length < 5) {
|
||||
errors.push(`${path}.steps_must_define_complete_boundary`);
|
||||
} else {
|
||||
const stepIds = new Set();
|
||||
value.steps.forEach((step, index) => {
|
||||
@@ -701,26 +710,41 @@ function validateL2Template(value, path, errors) {
|
||||
stepIds.add(step.id);
|
||||
if (!L2_STEP_KINDS.has(step.kind)) errors.push(`${stepPath}.kind_invalid`);
|
||||
});
|
||||
const expectedKinds = [
|
||||
"collection_trigger",
|
||||
"provider_request",
|
||||
"extract_items",
|
||||
"semantic_mapping",
|
||||
"data_product_publish",
|
||||
];
|
||||
if (!value.steps.every((step, index) => step?.kind === expectedKinds[index])) errors.push(`${path}.steps_sequence_invalid`);
|
||||
const requestSteps = value.steps.filter((step) => step?.kind === "provider_request");
|
||||
const extractSteps = value.steps.filter((step) => step?.kind === "extract_items");
|
||||
const mappingSteps = value.steps.filter((step) => step?.kind === "semantic_mapping");
|
||||
const publishSteps = value.steps.filter((step) => step?.kind === "data_product_publish");
|
||||
const requestStart = 1;
|
||||
const extractIndex = value.steps.findIndex((step) => step?.kind === "extract_items");
|
||||
const expectedSequence = (
|
||||
value.steps[0]?.kind === "collection_trigger"
|
||||
&& requestSteps.length >= 1
|
||||
&& value.steps.slice(requestStart, extractIndex).every((step) => step?.kind === "provider_request")
|
||||
&& extractSteps.length === 1
|
||||
&& mappingSteps.length === 1
|
||||
&& publishSteps.length === 1
|
||||
&& value.steps.at(-3)?.kind === "extract_items"
|
||||
&& value.steps.at(-2)?.kind === "semantic_mapping"
|
||||
&& value.steps.at(-1)?.kind === "data_product_publish"
|
||||
);
|
||||
if (!expectedSequence) errors.push(`${path}.steps_sequence_invalid`);
|
||||
if (value.steps[0]?.collectionProfileDriven !== true) errors.push(`${path}.collection_trigger_must_be_profile_driven`);
|
||||
if (!value.steps[1]?.capabilityId || value.steps[1]?.mappingContractId !== undefined || value.steps[1]?.dataProductId !== undefined) {
|
||||
errors.push(`${path}.provider_request_shape_invalid`);
|
||||
for (const step of requestSteps) {
|
||||
if (!step?.capabilityId || step?.mappingContractId !== undefined || step?.dataProductId !== undefined) {
|
||||
errors.push(`${path}.provider_request_shape_invalid`);
|
||||
}
|
||||
}
|
||||
if (!value.steps[2]?.capabilityId || value.steps[2]?.mappingContractId !== undefined || value.steps[2]?.dataProductId !== undefined) {
|
||||
const extractStep = extractSteps[0];
|
||||
if (!extractStep?.capabilityId || extractStep?.mappingContractId !== undefined || extractStep?.dataProductId !== undefined) {
|
||||
errors.push(`${path}.extract_items_shape_invalid`);
|
||||
}
|
||||
if (value.steps[1]?.capabilityId !== value.steps[2]?.capabilityId) errors.push(`${path}.request_extract_capability_mismatch`);
|
||||
if (!value.steps[3]?.mappingContractId || value.steps[3]?.capabilityId !== undefined || value.steps[3]?.dataProductId !== undefined) {
|
||||
if (!requestSteps.some((step) => step.capabilityId === extractStep?.capabilityId)) errors.push(`${path}.request_extract_capability_mismatch`);
|
||||
const mappingStep = mappingSteps[0];
|
||||
if (!mappingStep?.mappingContractId || mappingStep?.capabilityId !== undefined || mappingStep?.dataProductId !== undefined) {
|
||||
errors.push(`${path}.semantic_mapping_shape_invalid`);
|
||||
}
|
||||
if (!value.steps[4]?.dataProductId || value.steps[4]?.nodeType !== "n8n-nodes-ndc.ndcDataProductPublish") {
|
||||
const publishStep = publishSteps[0];
|
||||
if (!publishStep?.dataProductId || publishStep?.nodeType !== "n8n-nodes-ndc.ndcDataProductPublish") {
|
||||
errors.push(`${path}.data_product_publish_shape_invalid`);
|
||||
}
|
||||
}
|
||||
@@ -745,6 +769,122 @@ function validateMappingAgainstProduct(mapping, product, errors) {
|
||||
}
|
||||
const mappedFields = [...Object.keys(mapping.fact?.attributes || {}), ...(mapping.fact?.geometry ? ["geometry"] : [])];
|
||||
if (!sameSet(mappedFields, product.fields)) errors.push(`${path}.fact_fields_must_match_data_product_fields`);
|
||||
const contracts = isPlainObject(product.fieldContracts) ? product.fieldContracts : {};
|
||||
for (const [field, contract] of Object.entries(contracts)) {
|
||||
if (!isPlainObject(contract)) continue;
|
||||
if (field === "geometry") {
|
||||
if (contract.type !== "point") errors.push(`${path}.fact.geometry_contract_must_be_point`);
|
||||
if (contract.required === true && mapping.fact?.geometry?.omitIfInvalid === true) {
|
||||
errors.push(`${path}.fact.geometry_required_but_mapping_can_omit`);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
const expression = mapping.fact?.attributes?.[field];
|
||||
if (!isPlainObject(expression)) continue;
|
||||
validateExpressionAgainstFieldContract(expression, contract, mapping.derivations, `${path}.fact.attributes.${field}`, errors);
|
||||
}
|
||||
}
|
||||
|
||||
function validateFieldContracts(value, fields, path, errors) {
|
||||
if (value === undefined) return;
|
||||
if (!isPlainObject(value)) return errors.push(`${path}_must_be_object`);
|
||||
const names = Object.keys(value);
|
||||
if (names.length && !sameSet(names, fields)) errors.push(`${path}_must_exactly_match_fields`);
|
||||
for (const [field, contract] of Object.entries(value)) {
|
||||
requiredIdentifier(field, `${path}.${field}`, errors);
|
||||
if (SECRET_FIELD_NAME.test(field)) errors.push(`${path}.${field}_secret_field_not_allowed`);
|
||||
validateFieldContract(contract, `${path}.${field}`, errors);
|
||||
}
|
||||
}
|
||||
|
||||
function validateFieldContract(value, path, errors) {
|
||||
if (!isPlainObject(value)) return errors.push(`${path}_must_be_object`);
|
||||
rejectUnknownKeys(value, FIELD_CONTRACT_KEYS, path, errors);
|
||||
if (!FIELD_CONTRACT_TYPES.has(value.type)) errors.push(`${path}.type_invalid`);
|
||||
if (typeof value.required !== "boolean") errors.push(`${path}.required_must_be_boolean`);
|
||||
if (value.enum !== undefined) {
|
||||
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`);
|
||||
} else if (new Set(["point", "string_array"]).has(value.type)
|
||||
|| value.enum.some((item) => !fieldContractValueMatchesType(item, value.type))) {
|
||||
errors.push(`${path}.enum_value_type_invalid`);
|
||||
}
|
||||
}
|
||||
if (value.minimum !== undefined || value.maximum !== undefined) {
|
||||
if (value.type !== "number") errors.push(`${path}.range_only_allowed_for_number`);
|
||||
if (value.minimum !== undefined && !Number.isFinite(value.minimum)) errors.push(`${path}.minimum_invalid`);
|
||||
if (value.maximum !== undefined && !Number.isFinite(value.maximum)) errors.push(`${path}.maximum_invalid`);
|
||||
if (Number.isFinite(value.minimum) && Number.isFinite(value.maximum) && value.minimum > value.maximum) {
|
||||
errors.push(`${path}.range_invalid`);
|
||||
}
|
||||
if (Array.isArray(value.enum) && value.enum.some((item) => (
|
||||
typeof item === "number"
|
||||
&& ((Number.isFinite(value.minimum) && item < value.minimum)
|
||||
|| (Number.isFinite(value.maximum) && item > value.maximum))
|
||||
))) errors.push(`${path}.enum_value_out_of_range`);
|
||||
}
|
||||
}
|
||||
|
||||
function validateExpressionAgainstFieldContract(expression, contract, derivations, path, errors) {
|
||||
if (contract.required === true && expression.omitIfMissing === true) {
|
||||
errors.push(`${path}_required_but_mapping_can_omit`);
|
||||
}
|
||||
if (contract.required === true && expression.omitIfInvalid === true) {
|
||||
errors.push(`${path}_required_but_mapping_can_omit_invalid`);
|
||||
}
|
||||
if (expression.paths !== undefined) {
|
||||
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`);
|
||||
if (contract.type === "number") {
|
||||
if (!Object.is(expression.minimum, contract.minimum)) errors.push(`${path}.minimum_must_match_field_contract`);
|
||||
if (!Object.is(expression.maximum, contract.maximum)) errors.push(`${path}.maximum_must_match_field_contract`);
|
||||
if ((contract.minimum !== undefined || contract.maximum !== undefined) && expression.omitIfInvalid !== true) {
|
||||
errors.push(`${path}.bounded_number_must_omit_invalid`);
|
||||
}
|
||||
}
|
||||
}
|
||||
if (expression.constant !== undefined) {
|
||||
validateMappedValueAgainstFieldContract(expression.constant, contract, `${path}.constant`, errors);
|
||||
}
|
||||
if (typeof expression.derive === "string") {
|
||||
const derivation = isPlainObject(derivations) ? derivations[expression.derive] : undefined;
|
||||
if (!isPlainObject(derivation)) return;
|
||||
validateMappedValueAgainstFieldContract(derivation.default, contract, `${path}.derivation.default`, errors);
|
||||
if (Array.isArray(contract.enum) && Array.isArray(derivation.rules)) {
|
||||
for (const [index, rule] of derivation.rules.entries()) {
|
||||
const output = typeof rule === "string" ? rule.slice(rule.lastIndexOf(".") + 1) : undefined;
|
||||
validateMappedValueAgainstFieldContract(output, contract, `${path}.derivation.rules[${index}]`, errors);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function validateMappedValueAgainstFieldContract(value, contract, path, errors) {
|
||||
if (!fieldContractValueMatchesType(value, contract.type)) errors.push(`${path}_type_mismatch`);
|
||||
if (Array.isArray(contract.enum) && !contract.enum.some((allowed) => Object.is(allowed, value))) {
|
||||
errors.push(`${path}_not_in_field_contract_enum`);
|
||||
}
|
||||
if (typeof value === "number") {
|
||||
if (Number.isFinite(contract.minimum) && value < contract.minimum) errors.push(`${path}_below_field_contract_minimum`);
|
||||
if (Number.isFinite(contract.maximum) && value > contract.maximum) errors.push(`${path}_above_field_contract_maximum`);
|
||||
}
|
||||
}
|
||||
|
||||
function fieldContractValueMatchesType(value, type) {
|
||||
if (type === "string_array") return Array.isArray(value) && value.every((item) => typeof item === "string");
|
||||
if (type === "point") {
|
||||
return isPlainObject(value)
|
||||
&& value.type === "Point"
|
||||
&& Array.isArray(value.coordinates)
|
||||
&& value.coordinates.length === 2
|
||||
&& value.coordinates.every(Number.isFinite);
|
||||
}
|
||||
return typeof value === type && (type !== "number" || Number.isFinite(value));
|
||||
}
|
||||
|
||||
function stableLiteral(value) {
|
||||
return `${typeof value}:${JSON.stringify(value)}`;
|
||||
}
|
||||
|
||||
function validateExpression(value, path, errors) {
|
||||
@@ -769,6 +909,18 @@ function validateExpression(value, path, errors) {
|
||||
if (value.fallback !== undefined && value.fallback !== "collection_received_at") errors.push(`${path}.fallback_invalid`);
|
||||
if (value.derive !== undefined) requiredIdentifier(value.derive, `${path}.derive`, errors);
|
||||
if (value.omitIfMissing !== undefined && typeof value.omitIfMissing !== "boolean") errors.push(`${path}.omitIfMissing_must_be_boolean`);
|
||||
if (value.omitIfInvalid !== undefined && typeof value.omitIfInvalid !== "boolean") errors.push(`${path}.omitIfInvalid_must_be_boolean`);
|
||||
if (value.minimum !== undefined || value.maximum !== undefined) {
|
||||
if (value.coerce !== "number") errors.push(`${path}.range_requires_number_coercion`);
|
||||
if (value.minimum !== undefined && !Number.isFinite(value.minimum)) errors.push(`${path}.minimum_invalid`);
|
||||
if (value.maximum !== undefined && !Number.isFinite(value.maximum)) errors.push(`${path}.maximum_invalid`);
|
||||
if (Number.isFinite(value.minimum) && Number.isFinite(value.maximum) && value.minimum > value.maximum) {
|
||||
errors.push(`${path}.range_invalid`);
|
||||
}
|
||||
if (value.omitIfInvalid !== true) errors.push(`${path}.range_requires_omitIfInvalid`);
|
||||
} else if (value.omitIfInvalid !== undefined) {
|
||||
errors.push(`${path}.omitIfInvalid_requires_range`);
|
||||
}
|
||||
}
|
||||
|
||||
function validateDerivation(value, path, errors) {
|
||||
|
||||
@@ -15,6 +15,9 @@ import {
|
||||
geliosUnitsCurrentFixtureV1,
|
||||
} from "../providers/gelios/v1/index.mjs";
|
||||
import { geliosProviderPackageV2 } from "../providers/gelios/v2/index.mjs";
|
||||
import { geliosProviderPackageV3 } from "../providers/gelios/v3/index.mjs";
|
||||
import { geliosProviderPackageV4 } from "../providers/gelios/v4/index.mjs";
|
||||
import { geliosProviderPackageV5 } from "../providers/gelios/v5/index.mjs";
|
||||
import { normalizeDataProductDefinition } from "../../../services/external-data-plane/src/data-product-policy.mjs";
|
||||
|
||||
const expectedFields = [
|
||||
@@ -35,6 +38,197 @@ const expectedFields = [
|
||||
|
||||
assert.deepEqual(validateProviderPackage(geliosProviderPackageV1), { ok: true, errors: [] });
|
||||
assert.deepEqual(validateProviderPackage(geliosProviderPackageV2), { ok: true, errors: [] });
|
||||
assert.deepEqual(validateProviderPackage(geliosProviderPackageV3), { ok: true, errors: [] });
|
||||
assert.deepEqual(validateProviderPackage(geliosProviderPackageV4), { ok: true, errors: [] });
|
||||
assert.deepEqual(validateProviderPackage(geliosProviderPackageV5), { ok: true, errors: [] });
|
||||
|
||||
const strictProduct = geliosProviderPackageV5.dataProducts[0];
|
||||
const registeredStrictProduct = JSON.parse(await readFile(new URL(
|
||||
"../../../services/external-data-plane/definitions/fleet.positions.current.v4.json",
|
||||
import.meta.url,
|
||||
), "utf8"));
|
||||
const geliosOntologyEntities = JSON.parse(await readFile(new URL(
|
||||
"../../../services/ontology-core/catalog/domain-packages/gelios/entities.json",
|
||||
import.meta.url,
|
||||
), "utf8"));
|
||||
assert.equal(geliosProviderPackageV5.id, "gelios.provider.v5");
|
||||
assert.equal(strictProduct.id, "fleet.positions.current.v4");
|
||||
assert.deepEqual(strictProduct, registeredStrictProduct);
|
||||
assert.deepEqual(strictProduct.fieldContracts.signal_state.enum, ["active", "inactive"]);
|
||||
assert.deepEqual(strictProduct.fieldContracts.movement_state.enum, ["moving", "stopped"]);
|
||||
for (const ontologyEntityId of ["gelios.signal_state", "gelios.movement_state"]) {
|
||||
const valueContract = geliosOntologyEntities.entities.find((entity) => entity.id === ontologyEntityId)?.valueContract;
|
||||
assert.ok(valueContract, `${ontologyEntityId} must expose an Ontology valueContract`);
|
||||
assert.deepEqual(
|
||||
strictProduct.fieldContracts[valueContract.field].enum,
|
||||
valueContract.values.map(({ value: state }) => state),
|
||||
`${valueContract.field} Data Product enum must equal the Ontology value contract`,
|
||||
);
|
||||
}
|
||||
|
||||
const withInvalidClosedStateDefault = structuredClone(geliosProviderPackageV5);
|
||||
withInvalidClosedStateDefault.mappingContracts[0].derivations.signal_state.default = "invented";
|
||||
assert.equal(
|
||||
validateProviderPackage(withInvalidClosedStateDefault).errors.includes(
|
||||
"mappingContract.gelios.units.to.fleet.positions.current.v5.fact.attributes.signal_state.derivation.default_not_in_field_contract_enum",
|
||||
),
|
||||
true,
|
||||
);
|
||||
|
||||
const withInvalidClosedStateRule = structuredClone(geliosProviderPackageV5);
|
||||
withInvalidClosedStateRule.mappingContracts[0].derivations.movement_state.rules[0] = "integer_speed_gt_threshold.teleporting";
|
||||
assert.equal(
|
||||
validateProviderPackage(withInvalidClosedStateRule).errors.includes(
|
||||
"mappingContract.gelios.units.to.fleet.positions.current.v5.fact.attributes.movement_state.derivation.rules[0]_not_in_field_contract_enum",
|
||||
),
|
||||
true,
|
||||
);
|
||||
|
||||
const withOptionalRequiredState = structuredClone(geliosProviderPackageV5);
|
||||
withOptionalRequiredState.mappingContracts[0].fact.attributes.signal_state.omitIfMissing = true;
|
||||
assert.equal(
|
||||
validateProviderPackage(withOptionalRequiredState).errors.includes(
|
||||
"mappingContract.gelios.units.to.fleet.positions.current.v5.fact.attributes.signal_state_required_but_mapping_can_omit",
|
||||
),
|
||||
true,
|
||||
);
|
||||
|
||||
const withMalformedFieldContract = structuredClone(geliosProviderPackageV5);
|
||||
withMalformedFieldContract.dataProducts[0].fieldContracts.signal_state = null;
|
||||
assert.doesNotThrow(() => validateProviderPackage(withMalformedFieldContract));
|
||||
assert.equal(validateProviderPackage(withMalformedFieldContract).ok, false);
|
||||
|
||||
const withUnboundedNumericMapping = structuredClone(geliosProviderPackageV5);
|
||||
delete withUnboundedNumericMapping.mappingContracts[0].fact.attributes.speed_kph.minimum;
|
||||
assert.equal(
|
||||
validateProviderPackage(withUnboundedNumericMapping).errors.includes(
|
||||
"mappingContract.gelios.units.to.fleet.positions.current.v5.fact.attributes.speed_kph.minimum_must_match_field_contract",
|
||||
),
|
||||
true,
|
||||
);
|
||||
|
||||
const withNonOmittingBoundedMapping = structuredClone(geliosProviderPackageV5);
|
||||
withNonOmittingBoundedMapping.mappingContracts[0].fact.attributes.course_degrees.omitIfInvalid = false;
|
||||
assert.equal(
|
||||
validateProviderPackage(withNonOmittingBoundedMapping).errors.includes(
|
||||
"mappingContract.gelios.units.to.fleet.positions.current.v5.fact.attributes.course_degrees.bounded_number_must_omit_invalid",
|
||||
),
|
||||
true,
|
||||
);
|
||||
|
||||
const canonicalMonitoringProduct = geliosProviderPackageV4.dataProducts[0];
|
||||
const canonicalMonitoringMapping = geliosProviderPackageV4.mappingContracts[0];
|
||||
const registeredCanonicalMonitoringProduct = JSON.parse(await readFile(new URL(
|
||||
"../../../services/external-data-plane/definitions/fleet.positions.current.v3.json",
|
||||
import.meta.url,
|
||||
), "utf8"));
|
||||
assert.equal(geliosProviderPackageV4.id, "gelios.provider.v4");
|
||||
assert.equal(geliosProviderPackageV4.version, "4.0.0");
|
||||
assert.deepEqual(geliosProviderPackageV4.manifest.ontology, {
|
||||
packageId: "gelios",
|
||||
revision: "ontology.gelios.v1_1",
|
||||
});
|
||||
assert.deepEqual(geliosProviderPackageV4.collectionProfiles[0].capabilityIds, [
|
||||
"gelios.monitoring_config.current.read",
|
||||
"gelios.units.current.read",
|
||||
]);
|
||||
assert.equal(
|
||||
geliosProviderPackageV4.capabilities.find((item) => item.id === "gelios.monitoring_config.current.read").request.path,
|
||||
"/api/v1/users/me/monitoring-config",
|
||||
);
|
||||
assert.equal(
|
||||
geliosProviderPackageV4.capabilities.find((item) => item.id === "gelios.units.current.read").request.query.incltrip,
|
||||
"true",
|
||||
);
|
||||
assert.deepEqual(canonicalMonitoringProduct, registeredCanonicalMonitoringProduct);
|
||||
assert.deepEqual(canonicalMonitoringProduct.fields.filter((field) => field.endsWith("_state")), [
|
||||
"movement_state",
|
||||
"signal_state",
|
||||
]);
|
||||
for (const forbiddenField of [
|
||||
"availability_state",
|
||||
"freshness_state",
|
||||
"operational_status",
|
||||
"position_state",
|
||||
"state_policy_version",
|
||||
]) {
|
||||
assert.equal(canonicalMonitoringProduct.fields.includes(forbiddenField), false);
|
||||
}
|
||||
assert.deepEqual(canonicalMonitoringMapping.derivations.signal_state.rules, [
|
||||
"last_message_missing.inactive",
|
||||
"last_message_age_lt_resolved_monitoring_limit.active",
|
||||
"otherwise.inactive",
|
||||
]);
|
||||
assert.equal(canonicalMonitoringMapping.derivations.signal_state.default, "inactive");
|
||||
assert.equal(canonicalMonitoringMapping.derivations.signal_state.parameters.activeDurationPath, "signalActiveDuration");
|
||||
assert.equal(
|
||||
canonicalMonitoringMapping.derivations.signal_state.parameters.somewhatInactiveDurationPath,
|
||||
"signalSomewhatInactiveDuration",
|
||||
);
|
||||
assert.equal(canonicalMonitoringMapping.derivations.signal_state.parameters.collectionProfileFallbackSeconds, 120);
|
||||
assert.equal(
|
||||
canonicalMonitoringMapping.derivations.signal_state.parameters.thresholdResolution,
|
||||
"somewhatInactive_then_active_then_profileFallback",
|
||||
);
|
||||
assert.equal(canonicalMonitoringMapping.derivations.movement_state.parameters.movingThresholdKph, 2);
|
||||
assert.equal(canonicalMonitoringMapping.derivations.movement_state.default, "stopped");
|
||||
assert.deepEqual(
|
||||
geliosProviderPackageV4.l2Templates[0].steps.map((step) => step.kind),
|
||||
[
|
||||
"collection_trigger",
|
||||
"provider_request",
|
||||
"provider_request",
|
||||
"extract_items",
|
||||
"semantic_mapping",
|
||||
"data_product_publish",
|
||||
],
|
||||
);
|
||||
|
||||
const stateAwareProduct = geliosProviderPackageV3.dataProducts[0];
|
||||
const stateAwareMapping = geliosProviderPackageV3.mappingContracts[0];
|
||||
const registeredStateAwareProduct = JSON.parse(await readFile(new URL(
|
||||
"../../../services/external-data-plane/definitions/fleet.positions.current.v2.json",
|
||||
import.meta.url,
|
||||
), "utf8"));
|
||||
assert.equal(geliosProviderPackageV3.id, "gelios.provider.v3");
|
||||
assert.equal(geliosProviderPackageV3.version, "3.0.0");
|
||||
assert.equal(geliosProviderPackageV3.authModes[0].id, "gelios.rest-rotating-bearer.v3");
|
||||
assert.deepEqual(geliosProviderPackageV3.authModes[0].transport, {
|
||||
placement: "header",
|
||||
name: "Authorization",
|
||||
});
|
||||
assert.deepEqual(geliosProviderPackageV3.authModes[0].tokenLifecycle, {
|
||||
artifacts: ["access", "refresh"],
|
||||
requestArtifact: "access",
|
||||
refreshMode: "runtime_managed",
|
||||
});
|
||||
assert.equal(geliosProviderPackageV3.capabilities[0].request.baseUrl, "https://api.geliospro.com");
|
||||
assert.equal(geliosProviderPackageV3.capabilities[0].request.path, "/api/v1/units");
|
||||
assert.deepEqual(geliosProviderPackageV3.capabilities[0].request.query, {});
|
||||
assert.equal(
|
||||
geliosProviderPackageV3.l2Templates[0].credentialBindings.find((binding) => binding.role === "provider").authModeId,
|
||||
"gelios.rest-rotating-bearer.v3",
|
||||
);
|
||||
assert.equal(stateAwareProduct.id, "fleet.positions.current.v2");
|
||||
assert.equal(stateAwareProduct.version, "2.0.0");
|
||||
assert.equal(stateAwareProduct.ontologyRevision, "ontology.map.moving_object.v2");
|
||||
assert.deepEqual(stateAwareProduct, registeredStateAwareProduct);
|
||||
assert.deepEqual(
|
||||
["availability_state", "freshness_state", "motion_state", "position_state"].map((field) => (
|
||||
stateAwareMapping.fact.attributes[field]
|
||||
)),
|
||||
[
|
||||
{ derive: "availability_state" },
|
||||
{ derive: "freshness_state" },
|
||||
{ derive: "motion_state" },
|
||||
{ derive: "position_state" },
|
||||
],
|
||||
);
|
||||
assert.equal(stateAwareMapping.fact.attributes.state_policy_version.constant, "map-moving-object-state/v1");
|
||||
assert.equal(stateAwareMapping.derivations.motion_state.parameters.movingThresholdKph, 1);
|
||||
assert.equal(stateAwareMapping.derivations.position_state.parameters.minSatelliteCount, 4);
|
||||
assert.equal(stateAwareMapping.derivations.position_state.parameters.maxHdop, 5);
|
||||
assert.equal(stateAwareMapping.derivations.position_state.parameters.maxHorizontalAccuracyMeters, 100);
|
||||
|
||||
const geliosSdkAuth = geliosProviderPackageV2.authModes[0];
|
||||
const geliosSdkUnitsRead = geliosProviderPackageV2.capabilities[0];
|
||||
@@ -318,7 +512,7 @@ for (const profile of withUnexecutedSecondCapability.collectionProfiles) {
|
||||
}
|
||||
assert.equal(
|
||||
validateProviderPackage(withUnexecutedSecondCapability).errors.includes(
|
||||
"collectionProfile.gelios.positions.current.realtime.v1.capabilityIds_must_select_one_v1_read",
|
||||
"collectionProfile.gelios.positions.current.realtime.v1.template_capability_chain_mismatch",
|
||||
),
|
||||
true,
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user