diff --git a/CHANGELOG.md b/CHANGELOG.md index 7a99f8c..04634a9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,7 @@ The format follows [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). Thi ## [Unreleased] ### Added +- DeviceService contract v0.1 (FOG & CITIZEN PLANE W8.7 — the estate's first southbound device abstraction): `DeviceProfile` (what a device IS — protocol, typed protocol binding, and the exact metric set it produces with units, value types, operating ranges and protocol-native source addresses; digest-pinned by a `definitionDigest` the validator RECOMPUTES) and `DeviceReading` (one observation — deviceRef, metric, value, unit, quality, observedAt, carried on the MPCC `ConversationEvent` envelope with byte-identical sub-schemas, parity machine-enforced). EdgeX Foundry's lesson landed as a contract: ONE southbound interface, N protocol drivers — a driver speaks a protocol, it does not invent a vocabulary. **Normative invariant — a reading is ATTRIBUTABLE OR IT IS NOTHING**: `deviceRef`, `deviceProfileRef`, `profileDigest`, `metric`, `sourceAddress` and `unit` are all required, and the validator resolves every one of them across the example set (profile exists, digest matches the recomputed declaration, metric is declared, unit and source address agree, value is of the declared type and inside the declared range, `provenanceLinks` independently name both device and profile). Digest-pinning closes the retroactive-legalisation path — widening a range after the fact produces a new digest and orphans the readings it was meant to legalise, the `UpdateHealthProbe` construct applied to metrology. Quality is closed at five values with `unavailable` schema-bound to a null value plus a `NullAbsenceRecord` reference (the existing 12-kind MPCC absence taxonomy is reused, not re-invented); `stale` and `substituted` are normatively not `ok`. Simulated devices are a first-class labelled member of the protocol taxonomy (`protocol: "virtual"` ⇒ `synthetic:simulated-device`, enforced in both directions) — the `model-generated` admissibility rule applied to sensors. `observedAt`/`receivedAt`/`wallTime` stay distinct because `observedAt` → `receivedAt` is the southbound latency a twin's sync budget is spent on. Read-only at v0.1 (`access` closed to `"read"`): actuation must travel the `EffectRequest` → `EffectDecision` lifecycle. Distinct from `DeviceIdentity` (operator-workstation trust, bound via `identityRef`) and `TelemetryEvent` (agent-session diagnostics). Includes a BLE-GATT and a virtual profile plus measured and typed-absence reading examples, twenty-one negative conformance vectors (`fixtures/device-service/conformance.json`), a `validate-device-service-examples` target, and a normative spec note (`specs/device-service-contract.md`). Reference implementation: `device-service` in `SocioProphet/prophet-platform`. - A/B fallback update contract v0.1 (TRUST FABRIC W9.2): `UpdateSlot` (one of exactly two slots, carrying the GPT priority-boot attribute triple `bootPriority`/`triesRemaining`/`successful`, the installed payload digest, and a deliberate `role` vs `currentlyRunning` split so the fallback slot stays describable during a trial boot), `UpdateTransaction` (one apply attempt: write target, pinned probe, per-attempt boot record naming where each failure fell back to, terminal `promoted`/`rolled-back`/`refused` outcome with a closed `rollbackReason` set), and `UpdateHealthProbe` (the digest-pinned promotion gate: non-empty check set with at least one blocking check, mandatory hardware+software watchdog pair enforced by two `contains` clauses, `evaluatedIn` closed to post-boot userspace, `onProbeUnavailable` closed to `fail`). **Normative invariant — the currently-good slot is never overwritten by the update being applied** — enforced three ways: by schema within a document (a top-level `not`/`anyOf` enumerating the two illegal `(fromSlot, toSlot)` pairs, plus `UpdateSlot`'s `state: writing` ⇒ `role: candidate`), by schema on the settle path (four if/then clauses pinning `settledOnSlot` to `fromSlot` on rollback/refusal and to `toSlot` on promotion), and across documents by the validator (a settled transaction's `preservedPayloadDigest` still equals the active slot's `payloadDigest`). The attempt budget follows the GPT attribute — decremented by the bootloader *before* control transfers, so a payload that hangs before userspace still consumes an attempt — and `refused` is terminal, which is what ends the boot loop. Includes a refused-update and a promoted-update example set telling two coherent end-to-end stories, fourteen negative conformance vectors (`fixtures/ab-update/conformance.json`), a `validate-ab-update-examples` target whose probe-digest check is *recomputed* rather than read back, and a normative spec note (`specs/ab-fallback-update-contract.md`). Reference implementation: `AbUpdateMachine` in `sourceos-boot`. - `make validate` now also runs the duplicate schema `$id` guardrail, which previously ran only in CI — a local run could not reproduce the check that gates the PR. - KnowledgeNugget contract v0.1 (the estate's L2 content grain): warrant-typed knowledge fragments generalizing the production IFM warrant-typed extraction — content-addressed source spans (`docRef` + span + `sha256-` content hash), a closed four-kind warrant taxonomy (`direct-quote`/`computed`/`inferred`/`model-generated`) with evidence refs and confidence, ontology type refs (`kkoTypeRefs`), normalized `canonicalPayload`, typed provenance chain links, and `wallTime`/`logicalTime` carried verbatim from the MPCC `ConversationEvent` envelope (parity machine-enforced). Normative: `model-generated` nuggets MUST stay visibly distinguishable downstream (admissibility discounting), and `computed`/`inferred` warrants must cite evidence (schema-enforced). Includes direct-quote and model-generated examples, negative conformance vectors (`fixtures/knowledge-nugget/conformance.json`), a `validate-knowledge-nugget-examples` target, and a normative spec note (`specs/knowledge-nugget-contract.md`). diff --git a/Makefile b/Makefile index 8522658..65560f1 100644 --- a/Makefile +++ b/Makefile @@ -1,6 +1,6 @@ -.PHONY: validate validate-value-type validate-source-locator validate-sourceos-repo-manifest validate-control-plane-examples validate-nlboot-examples validate-lattice-data-governai-examples validate-ops-history-examples validate-runtime-observability-examples validate-interpretability-examples validate-lifecycle-boundary-examples validate-svf-contracts validate-sync-cycle-receipts validate-onboarding-examples validate-runtime-causality-examples validate-agentic-os-examples validate-triparty-examples validate-labor-market-examples validate-supply-chain-risk-examples validate-reasoning-examples validate-mpcc-event-examples validate-knowledge-nugget-examples validate-semantic-action-examples validate-epistemic-kernel-examples validate-ab-update-examples validate-duplicate-schema-ids +.PHONY: validate validate-value-type validate-source-locator validate-sourceos-repo-manifest validate-control-plane-examples validate-nlboot-examples validate-lattice-data-governai-examples validate-ops-history-examples validate-runtime-observability-examples validate-interpretability-examples validate-lifecycle-boundary-examples validate-svf-contracts validate-sync-cycle-receipts validate-onboarding-examples validate-runtime-causality-examples validate-agentic-os-examples validate-triparty-examples validate-labor-market-examples validate-supply-chain-risk-examples validate-reasoning-examples validate-mpcc-event-examples validate-knowledge-nugget-examples validate-semantic-action-examples validate-epistemic-kernel-examples validate-ab-update-examples validate-device-service-examples validate-duplicate-schema-ids -validate: validate-control-plane-examples validate-nlboot-examples validate-lattice-data-governai-examples validate-ops-history-examples validate-runtime-observability-examples validate-interpretability-examples validate-lifecycle-boundary-examples validate-svf-contracts validate-sync-cycle-receipts validate-onboarding-examples validate-runtime-causality-examples validate-agentic-os-examples validate-triparty-examples validate-labor-market-examples validate-supply-chain-risk-examples validate-reasoning-examples validate-mpcc-event-examples validate-knowledge-nugget-examples validate-semantic-action-examples validate-epistemic-kernel-examples validate-ab-update-examples validate-duplicate-schema-ids validate-value-type validate-source-locator validate-sourceos-repo-manifest +validate: validate-control-plane-examples validate-nlboot-examples validate-lattice-data-governai-examples validate-ops-history-examples validate-runtime-observability-examples validate-interpretability-examples validate-lifecycle-boundary-examples validate-svf-contracts validate-sync-cycle-receipts validate-onboarding-examples validate-runtime-causality-examples validate-agentic-os-examples validate-triparty-examples validate-labor-market-examples validate-supply-chain-risk-examples validate-reasoning-examples validate-mpcc-event-examples validate-knowledge-nugget-examples validate-semantic-action-examples validate-epistemic-kernel-examples validate-ab-update-examples validate-device-service-examples validate-duplicate-schema-ids validate-value-type validate-source-locator validate-sourceos-repo-manifest @echo "OK: validate" validate-source-locator: @@ -17,6 +17,10 @@ validate-ab-update-examples: # but was not reachable from `make validate`, so a local run could not reproduce # the check that gates the PR. Wiring it in costs nothing and removes a way for # local and CI verdicts to disagree. +validate-device-service-examples: + python3 -m pip install --user jsonschema >/dev/null + python3 tools/validate_device_service_examples.py + validate-duplicate-schema-ids: python3 scripts/check_duplicate_schema_ids.py diff --git a/examples/device_profile.json b/examples/device_profile.json new file mode 100644 index 0000000..66e91e4 --- /dev/null +++ b/examples/device_profile.json @@ -0,0 +1,81 @@ +{ + "id": "urn:srcos:device-profile:acme_th100_room_sensor_v1", + "type": "DeviceProfile", + "specVersion": "0.1.0", + "actorRef": "urn:srcos:agent:device_service", + "workspaceRef": "urn:srcos:workspace:citizen_home_demo", + "visibilityScope": [ + "private" + ], + "provenanceLinks": [ + { + "rel": "declared_from", + "ref": "datasheet:acme-th-100-rev-c" + }, + { + "rel": "conforms_to", + "ref": "org.bluetooth.service.environmental_sensing" + } + ], + "policyLabels": [ + "residence:interior", + "pii:none" + ], + "riskLabels": [], + "deviceClass": "acme-th-100-room-sensor", + "manufacturer": "Acme Instruments", + "model": "TH-100", + "protocol": "ble-gatt", + "protocolBinding": { + "endpoint": "ble://C4:2F:90:11:8A:3D", + "securityMode": "pairing-bonded", + "parameters": { + "connectionIntervalMs": 1000, + "notifyOnly": true, + "gattService": "0000181a-0000-1000-8000-00805f9b34fb" + } + }, + "metrics": [ + { + "metric": "temperature", + "kkoTypeRef": "http://kbpedia.org/ontologies/kko#Quantity", + "valueType": "number", + "unit": "Cel", + "minimum": -40.0, + "maximum": 85.0, + "resolution": 0.01, + "samplePeriodMs": 1000, + "sourceAddress": "gatt://0000181a-0000-1000-8000-00805f9b34fb/00002a6e-0000-1000-8000-00805f9b34fb", + "access": "read" + }, + { + "metric": "humidity.relative", + "kkoTypeRef": "http://kbpedia.org/ontologies/kko#Quantity", + "valueType": "number", + "unit": "%", + "minimum": 0.0, + "maximum": 100.0, + "resolution": 0.01, + "samplePeriodMs": 1000, + "sourceAddress": "gatt://0000181a-0000-1000-8000-00805f9b34fb/00002a6f-0000-1000-8000-00805f9b34fb", + "access": "read" + }, + { + "metric": "battery.level", + "kkoTypeRef": "http://kbpedia.org/ontologies/kko#Quantity", + "valueType": "integer", + "unit": "%", + "minimum": 0, + "maximum": 100, + "resolution": 1, + "samplePeriodMs": 300000, + "sourceAddress": "gatt://0000180f-0000-1000-8000-00805f9b34fb/00002a19-0000-1000-8000-00805f9b34fb", + "access": "read" + } + ], + "declaredAt": "2026-07-29T08:00:00.000Z", + "identityRef": "urn:srcos:device-identity:home_edge_node_01", + "adminState": "enabled", + "supersedesRef": null, + "definitionDigest": "sha256:24dfe44e340bc684c2c5618cfad534717c6b1f218b6bf7e94aa809afecab2813" +} diff --git a/examples/device_profile.virtual.json b/examples/device_profile.virtual.json new file mode 100644 index 0000000..aa4c41d --- /dev/null +++ b/examples/device_profile.virtual.json @@ -0,0 +1,77 @@ +{ + "id": "urn:srcos:device-profile:srcos_virtual_room_sensor_v1", + "type": "DeviceProfile", + "specVersion": "0.1.0", + "actorRef": "urn:srcos:agent:device_service", + "workspaceRef": "urn:srcos:workspace:citizen_home_demo", + "visibilityScope": [ + "private" + ], + "provenanceLinks": [ + { + "rel": "declared_from", + "ref": "prophet-platform:apps/device-service/profiles/virtual-room-sensor.json" + } + ], + "policyLabels": [ + "synthetic:simulated-device" + ], + "riskLabels": [ + "not-a-measurement" + ], + "deviceClass": "srcos-virtual-room-sensor", + "manufacturer": "SourceOS", + "model": "virtual-room-sensor", + "protocol": "virtual", + "protocolBinding": { + "endpoint": "virtual://room-sensor", + "securityMode": "none", + "parameters": { + "seed": 20260729, + "samplePeriodMs": 1000 + } + }, + "metrics": [ + { + "metric": "temperature", + "kkoTypeRef": "http://kbpedia.org/ontologies/kko#Quantity", + "valueType": "number", + "unit": "Cel", + "minimum": 5.0, + "maximum": 40.0, + "resolution": 0.01, + "samplePeriodMs": 1000, + "sourceAddress": "virtual://room-sensor/temperature", + "access": "read" + }, + { + "metric": "humidity.relative", + "kkoTypeRef": "http://kbpedia.org/ontologies/kko#Quantity", + "valueType": "number", + "unit": "%", + "minimum": 10.0, + "maximum": 90.0, + "resolution": 0.01, + "samplePeriodMs": 1000, + "sourceAddress": "virtual://room-sensor/humidity.relative", + "access": "read" + }, + { + "metric": "occupancy.detected", + "kkoTypeRef": "http://kbpedia.org/ontologies/kko#States", + "valueType": "boolean", + "unit": "{bool}", + "minimum": null, + "maximum": null, + "resolution": null, + "samplePeriodMs": 1000, + "sourceAddress": "virtual://room-sensor/occupancy.detected", + "access": "read" + } + ], + "declaredAt": "2026-07-29T08:00:00.000Z", + "identityRef": "urn:srcos:device-identity:home_edge_node_01", + "adminState": "enabled", + "supersedesRef": null, + "definitionDigest": "sha256:55d51880fb7d938c7d87f0a8190b6d1499ba2c00291dbe55b710be5fc93e6666" +} diff --git a/examples/device_reading.json b/examples/device_reading.json new file mode 100644 index 0000000..95acbe6 --- /dev/null +++ b/examples/device_reading.json @@ -0,0 +1,57 @@ +{ + "id": "urn:srcos:device-reading:room_sensor_01_temperature_000117", + "type": "DeviceReading", + "specVersion": "0.1.0", + "actorRef": "urn:srcos:agent:device_service", + "workspaceRef": "urn:srcos:workspace:citizen_home_demo", + "branchRef": "urn:srcos:branch:home_main", + "visibilityScope": [ + "private" + ], + "wallTime": "2026-07-29T09:15:00.042Z", + "logicalTime": 117, + "causalParents": [], + "traceContext": { + "traceId": "4bf92f3577b34da6a3ce929d0e0e4736", + "spanId": "00f067aa0ba902b7" + }, + "provenanceLinks": [ + { + "rel": "declared_by", + "ref": "urn:srcos:device-profile:srcos_virtual_room_sensor_v1" + }, + { + "rel": "produced_by", + "ref": "urn:srcos:device:room_sensor_01" + }, + { + "rel": "ingested_by", + "ref": "prophet-platform:apps/device-service" + } + ], + "policyLabels": [ + "synthetic:simulated-device" + ], + "riskLabels": [ + "not-a-measurement" + ], + "deviceRef": "urn:srcos:device:room_sensor_01", + "deviceProfileRef": "urn:srcos:device-profile:srcos_virtual_room_sensor_v1", + "profileDigest": "sha256:55d51880fb7d938c7d87f0a8190b6d1499ba2c00291dbe55b710be5fc93e6666", + "metric": "temperature", + "sourceAddress": "virtual://room-sensor/temperature", + "value": 21.37, + "unit": "Cel", + "quality": "ok", + "observedAt": "2026-07-29T09:15:00.000Z", + "receivedAt": "2026-07-29T09:15:00.042Z", + "sequenceRef": 117, + "nullAbsenceRef": null, + "qualityFlags": [], + "kkoTypeRef": "http://kbpedia.org/ontologies/kko#Quantity", + "rawPayload": { + "driver": "virtual", + "raw": 2137, + "scale": -2 + } +} diff --git a/examples/device_reading.unavailable.json b/examples/device_reading.unavailable.json new file mode 100644 index 0000000..843f714 --- /dev/null +++ b/examples/device_reading.unavailable.json @@ -0,0 +1,56 @@ +{ + "id": "urn:srcos:device-reading:th100_bed2_humidity_000418", + "type": "DeviceReading", + "specVersion": "0.1.0", + "actorRef": "urn:srcos:agent:device_service", + "workspaceRef": "urn:srcos:workspace:citizen_home_demo", + "branchRef": "urn:srcos:branch:home_main", + "visibilityScope": [ + "private" + ], + "wallTime": "2026-07-29T09:15:03.500Z", + "logicalTime": 418, + "causalParents": [ + "urn:srcos:device-reading:th100_bed2_humidity_000417" + ], + "traceContext": { + "traceId": "4bf92f3577b34da6a3ce929d0e0e4736", + "spanId": "1a2b3c4d5e6f7081" + }, + "provenanceLinks": [ + { + "rel": "declared_by", + "ref": "urn:srcos:device-profile:acme_th100_room_sensor_v1" + }, + { + "rel": "produced_by", + "ref": "urn:srcos:device:th100_bed2" + }, + { + "rel": "ingested_by", + "ref": "prophet-platform:apps/device-service" + } + ], + "policyLabels": [ + "residence:interior", + "pii:none" + ], + "riskLabels": [], + "deviceRef": "urn:srcos:device:th100_bed2", + "deviceProfileRef": "urn:srcos:device-profile:acme_th100_room_sensor_v1", + "profileDigest": "sha256:24dfe44e340bc684c2c5618cfad534717c6b1f218b6bf7e94aa809afecab2813", + "metric": "humidity.relative", + "sourceAddress": "gatt://0000181a-0000-1000-8000-00805f9b34fb/00002a6f-0000-1000-8000-00805f9b34fb", + "value": null, + "unit": "%", + "quality": "unavailable", + "observedAt": "2026-07-29T09:15:03.000Z", + "receivedAt": "2026-07-29T09:15:03.500Z", + "sequenceRef": 418, + "nullAbsenceRef": "urn:srcos:null-absence:th100_bed2_notify_timeout_000418", + "qualityFlags": [ + "notify_window_missed" + ], + "kkoTypeRef": "http://kbpedia.org/ontologies/kko#Quantity", + "rawPayload": null +} diff --git a/examples/null_absence_record.device_timeout.json b/examples/null_absence_record.device_timeout.json new file mode 100644 index 0000000..0c04a5d --- /dev/null +++ b/examples/null_absence_record.device_timeout.json @@ -0,0 +1,24 @@ +{ + "id": "urn:srcos:null-absence:th100_bed2_notify_timeout_000418", + "type": "NullAbsenceRecord", + "specVersion": "0.1.0", + "kind": "timeout", + "observedAt": "2026-07-29T09:15:03.000Z", + "relatedEventRef": "urn:srcos:device-reading:th100_bed2_humidity_000418", + "relatedBranchRef": "urn:srcos:branch:home_main", + "relatedWorkspaceRef": "urn:srcos:workspace:citizen_home_demo", + "causalNotes": "GATT notification window for 00002a6f elapsed with no indication; the peripheral stayed connected, so this is a timeout and not a transport_failure. Recorded as a typed absence rather than re-reporting the prior humidity value, which would have been substituted data wearing an ok quality.", + "policyLabels": [ + "residence:interior" + ], + "provenanceLinks": [ + { + "rel": "detected_by", + "ref": "prophet-platform:apps/device-service#ble-gatt" + } + ], + "details": { + "expectedNextSequence": 419, + "notifyWindowMs": 3000 + } +} diff --git a/fixtures/device-service/conformance.json b/fixtures/device-service/conformance.json new file mode 100644 index 0000000..1bf4643 --- /dev/null +++ b/fixtures/device-service/conformance.json @@ -0,0 +1,1436 @@ +{ + "description": "Negative conformance vectors for the DeviceService contract v0.1 (W8.7). Each document MUST FAIL validation against its schema for the stated reason; tools/validate_device_service_examples.py enforces this and fails the build if any of them validates. Every case is derived from a complete canonical example and broken in exactly one way, so a failure is attributable to the named defect rather than to a missing required field. The vectors encode the failure modes that make a reading meaningless: a value that cannot name its device, its profile, or the exact profile revision it was admitted against; an absence that is not typed; a quality vocabulary widened by a producer; a profile that declares no metric, no range, or no source address; and actuation smuggled in through an access enum.", + "cases": [ + { + "schema": "DeviceReading.json", + "reason": "deviceRef is required. A reading that cannot name the physical thing that produced it is unattributable — the exact silent-wrong this contract exists to make impossible.", + "document": { + "id": "urn:srcos:device-reading:neg_no_device_ref", + "type": "DeviceReading", + "specVersion": "0.1.0", + "actorRef": "urn:srcos:agent:device_service", + "workspaceRef": "urn:srcos:workspace:citizen_home_demo", + "branchRef": "urn:srcos:branch:home_main", + "visibilityScope": [ + "private" + ], + "wallTime": "2026-07-29T09:15:00.042Z", + "logicalTime": 117, + "causalParents": [], + "traceContext": { + "traceId": "4bf92f3577b34da6a3ce929d0e0e4736", + "spanId": "00f067aa0ba902b7" + }, + "provenanceLinks": [ + { + "rel": "declared_by", + "ref": "urn:srcos:device-profile:srcos_virtual_room_sensor_v1" + }, + { + "rel": "produced_by", + "ref": "urn:srcos:device:room_sensor_01" + }, + { + "rel": "ingested_by", + "ref": "prophet-platform:apps/device-service" + } + ], + "policyLabels": [ + "synthetic:simulated-device" + ], + "riskLabels": [ + "not-a-measurement" + ], + "deviceProfileRef": "urn:srcos:device-profile:srcos_virtual_room_sensor_v1", + "profileDigest": "sha256:55d51880fb7d938c7d87f0a8190b6d1499ba2c00291dbe55b710be5fc93e6666", + "metric": "temperature", + "sourceAddress": "virtual://room-sensor/temperature", + "value": 21.37, + "unit": "Cel", + "quality": "ok", + "observedAt": "2026-07-29T09:15:00.000Z", + "receivedAt": "2026-07-29T09:15:00.042Z", + "sequenceRef": 117, + "nullAbsenceRef": null, + "qualityFlags": [], + "kkoTypeRef": "http://kbpedia.org/ontologies/kko#Quantity", + "rawPayload": { + "driver": "virtual", + "raw": 2137, + "scale": -2 + } + } + }, + { + "schema": "DeviceReading.json", + "reason": "deviceProfileRef is required. Without the profile a value is a bare number: no declared unit, no declared range, no declared source channel.", + "document": { + "id": "urn:srcos:device-reading:neg_no_profile_ref", + "type": "DeviceReading", + "specVersion": "0.1.0", + "actorRef": "urn:srcos:agent:device_service", + "workspaceRef": "urn:srcos:workspace:citizen_home_demo", + "branchRef": "urn:srcos:branch:home_main", + "visibilityScope": [ + "private" + ], + "wallTime": "2026-07-29T09:15:00.042Z", + "logicalTime": 117, + "causalParents": [], + "traceContext": { + "traceId": "4bf92f3577b34da6a3ce929d0e0e4736", + "spanId": "00f067aa0ba902b7" + }, + "provenanceLinks": [ + { + "rel": "declared_by", + "ref": "urn:srcos:device-profile:srcos_virtual_room_sensor_v1" + }, + { + "rel": "produced_by", + "ref": "urn:srcos:device:room_sensor_01" + }, + { + "rel": "ingested_by", + "ref": "prophet-platform:apps/device-service" + } + ], + "policyLabels": [ + "synthetic:simulated-device" + ], + "riskLabels": [ + "not-a-measurement" + ], + "deviceRef": "urn:srcos:device:room_sensor_01", + "profileDigest": "sha256:55d51880fb7d938c7d87f0a8190b6d1499ba2c00291dbe55b710be5fc93e6666", + "metric": "temperature", + "sourceAddress": "virtual://room-sensor/temperature", + "value": 21.37, + "unit": "Cel", + "quality": "ok", + "observedAt": "2026-07-29T09:15:00.000Z", + "receivedAt": "2026-07-29T09:15:00.042Z", + "sequenceRef": 117, + "nullAbsenceRef": null, + "qualityFlags": [], + "kkoTypeRef": "http://kbpedia.org/ontologies/kko#Quantity", + "rawPayload": { + "driver": "virtual", + "raw": 2137, + "scale": -2 + } + } + }, + { + "schema": "DeviceReading.json", + "reason": "profileDigest is required. A reading that pins no contract revision can be retroactively legalised by widening the profile it failed.", + "document": { + "id": "urn:srcos:device-reading:neg_no_profile_digest", + "type": "DeviceReading", + "specVersion": "0.1.0", + "actorRef": "urn:srcos:agent:device_service", + "workspaceRef": "urn:srcos:workspace:citizen_home_demo", + "branchRef": "urn:srcos:branch:home_main", + "visibilityScope": [ + "private" + ], + "wallTime": "2026-07-29T09:15:00.042Z", + "logicalTime": 117, + "causalParents": [], + "traceContext": { + "traceId": "4bf92f3577b34da6a3ce929d0e0e4736", + "spanId": "00f067aa0ba902b7" + }, + "provenanceLinks": [ + { + "rel": "declared_by", + "ref": "urn:srcos:device-profile:srcos_virtual_room_sensor_v1" + }, + { + "rel": "produced_by", + "ref": "urn:srcos:device:room_sensor_01" + }, + { + "rel": "ingested_by", + "ref": "prophet-platform:apps/device-service" + } + ], + "policyLabels": [ + "synthetic:simulated-device" + ], + "riskLabels": [ + "not-a-measurement" + ], + "deviceRef": "urn:srcos:device:room_sensor_01", + "deviceProfileRef": "urn:srcos:device-profile:srcos_virtual_room_sensor_v1", + "metric": "temperature", + "sourceAddress": "virtual://room-sensor/temperature", + "value": 21.37, + "unit": "Cel", + "quality": "ok", + "observedAt": "2026-07-29T09:15:00.000Z", + "receivedAt": "2026-07-29T09:15:00.042Z", + "sequenceRef": 117, + "nullAbsenceRef": null, + "qualityFlags": [], + "kkoTypeRef": "http://kbpedia.org/ontologies/kko#Quantity", + "rawPayload": { + "driver": "virtual", + "raw": 2137, + "scale": -2 + } + } + }, + { + "schema": "DeviceReading.json", + "reason": "profileDigest must be a full lowercase sha256 hex digest. A truncated digest is a pin that cannot actually distinguish two profile revisions.", + "document": { + "id": "urn:srcos:device-reading:neg_short_profile_digest", + "type": "DeviceReading", + "specVersion": "0.1.0", + "actorRef": "urn:srcos:agent:device_service", + "workspaceRef": "urn:srcos:workspace:citizen_home_demo", + "branchRef": "urn:srcos:branch:home_main", + "visibilityScope": [ + "private" + ], + "wallTime": "2026-07-29T09:15:00.042Z", + "logicalTime": 117, + "causalParents": [], + "traceContext": { + "traceId": "4bf92f3577b34da6a3ce929d0e0e4736", + "spanId": "00f067aa0ba902b7" + }, + "provenanceLinks": [ + { + "rel": "declared_by", + "ref": "urn:srcos:device-profile:srcos_virtual_room_sensor_v1" + }, + { + "rel": "produced_by", + "ref": "urn:srcos:device:room_sensor_01" + }, + { + "rel": "ingested_by", + "ref": "prophet-platform:apps/device-service" + } + ], + "policyLabels": [ + "synthetic:simulated-device" + ], + "riskLabels": [ + "not-a-measurement" + ], + "deviceRef": "urn:srcos:device:room_sensor_01", + "deviceProfileRef": "urn:srcos:device-profile:srcos_virtual_room_sensor_v1", + "profileDigest": "sha256:deadbeef", + "metric": "temperature", + "sourceAddress": "virtual://room-sensor/temperature", + "value": 21.37, + "unit": "Cel", + "quality": "ok", + "observedAt": "2026-07-29T09:15:00.000Z", + "receivedAt": "2026-07-29T09:15:00.042Z", + "sequenceRef": 117, + "nullAbsenceRef": null, + "qualityFlags": [], + "kkoTypeRef": "http://kbpedia.org/ontologies/kko#Quantity", + "rawPayload": { + "driver": "virtual", + "raw": 2137, + "scale": -2 + } + } + }, + { + "schema": "DeviceReading.json", + "reason": "deviceRef must be a urn:srcos:device: instance URN. A DeviceIdentity is an admitted operator workstation, not a sensor; attributing a measurement to the gateway that relayed it loses the device.", + "document": { + "id": "urn:srcos:device-reading:neg_device_ref_is_a_workstation", + "type": "DeviceReading", + "specVersion": "0.1.0", + "actorRef": "urn:srcos:agent:device_service", + "workspaceRef": "urn:srcos:workspace:citizen_home_demo", + "branchRef": "urn:srcos:branch:home_main", + "visibilityScope": [ + "private" + ], + "wallTime": "2026-07-29T09:15:00.042Z", + "logicalTime": 117, + "causalParents": [], + "traceContext": { + "traceId": "4bf92f3577b34da6a3ce929d0e0e4736", + "spanId": "00f067aa0ba902b7" + }, + "provenanceLinks": [ + { + "rel": "declared_by", + "ref": "urn:srcos:device-profile:srcos_virtual_room_sensor_v1" + }, + { + "rel": "produced_by", + "ref": "urn:srcos:device:room_sensor_01" + }, + { + "rel": "ingested_by", + "ref": "prophet-platform:apps/device-service" + } + ], + "policyLabels": [ + "synthetic:simulated-device" + ], + "riskLabels": [ + "not-a-measurement" + ], + "deviceRef": "urn:srcos:device-identity:home_edge_node_01", + "deviceProfileRef": "urn:srcos:device-profile:srcos_virtual_room_sensor_v1", + "profileDigest": "sha256:55d51880fb7d938c7d87f0a8190b6d1499ba2c00291dbe55b710be5fc93e6666", + "metric": "temperature", + "sourceAddress": "virtual://room-sensor/temperature", + "value": 21.37, + "unit": "Cel", + "quality": "ok", + "observedAt": "2026-07-29T09:15:00.000Z", + "receivedAt": "2026-07-29T09:15:00.042Z", + "sequenceRef": 117, + "nullAbsenceRef": null, + "qualityFlags": [], + "kkoTypeRef": "http://kbpedia.org/ontologies/kko#Quantity", + "rawPayload": { + "driver": "virtual", + "raw": 2137, + "scale": -2 + } + } + }, + { + "schema": "DeviceReading.json", + "reason": "sourceAddress is required. It is what proves the value came off the declared physical channel for this metric rather than an adjacent characteristic, register, or topic.", + "document": { + "id": "urn:srcos:device-reading:neg_no_source_address", + "type": "DeviceReading", + "specVersion": "0.1.0", + "actorRef": "urn:srcos:agent:device_service", + "workspaceRef": "urn:srcos:workspace:citizen_home_demo", + "branchRef": "urn:srcos:branch:home_main", + "visibilityScope": [ + "private" + ], + "wallTime": "2026-07-29T09:15:00.042Z", + "logicalTime": 117, + "causalParents": [], + "traceContext": { + "traceId": "4bf92f3577b34da6a3ce929d0e0e4736", + "spanId": "00f067aa0ba902b7" + }, + "provenanceLinks": [ + { + "rel": "declared_by", + "ref": "urn:srcos:device-profile:srcos_virtual_room_sensor_v1" + }, + { + "rel": "produced_by", + "ref": "urn:srcos:device:room_sensor_01" + }, + { + "rel": "ingested_by", + "ref": "prophet-platform:apps/device-service" + } + ], + "policyLabels": [ + "synthetic:simulated-device" + ], + "riskLabels": [ + "not-a-measurement" + ], + "deviceRef": "urn:srcos:device:room_sensor_01", + "deviceProfileRef": "urn:srcos:device-profile:srcos_virtual_room_sensor_v1", + "profileDigest": "sha256:55d51880fb7d938c7d87f0a8190b6d1499ba2c00291dbe55b710be5fc93e6666", + "metric": "temperature", + "value": 21.37, + "unit": "Cel", + "quality": "ok", + "observedAt": "2026-07-29T09:15:00.000Z", + "receivedAt": "2026-07-29T09:15:00.042Z", + "sequenceRef": 117, + "nullAbsenceRef": null, + "qualityFlags": [], + "kkoTypeRef": "http://kbpedia.org/ontologies/kko#Quantity", + "rawPayload": { + "driver": "virtual", + "raw": 2137, + "scale": -2 + } + } + }, + { + "schema": "DeviceReading.json", + "reason": "quality \"ok\" with a null value is an untyped hole wearing a measured quality. Non-unavailable qualities must carry a real value.", + "document": { + "id": "urn:srcos:device-reading:neg_ok_with_null_value", + "type": "DeviceReading", + "specVersion": "0.1.0", + "actorRef": "urn:srcos:agent:device_service", + "workspaceRef": "urn:srcos:workspace:citizen_home_demo", + "branchRef": "urn:srcos:branch:home_main", + "visibilityScope": [ + "private" + ], + "wallTime": "2026-07-29T09:15:00.042Z", + "logicalTime": 117, + "causalParents": [], + "traceContext": { + "traceId": "4bf92f3577b34da6a3ce929d0e0e4736", + "spanId": "00f067aa0ba902b7" + }, + "provenanceLinks": [ + { + "rel": "declared_by", + "ref": "urn:srcos:device-profile:srcos_virtual_room_sensor_v1" + }, + { + "rel": "produced_by", + "ref": "urn:srcos:device:room_sensor_01" + }, + { + "rel": "ingested_by", + "ref": "prophet-platform:apps/device-service" + } + ], + "policyLabels": [ + "synthetic:simulated-device" + ], + "riskLabels": [ + "not-a-measurement" + ], + "deviceRef": "urn:srcos:device:room_sensor_01", + "deviceProfileRef": "urn:srcos:device-profile:srcos_virtual_room_sensor_v1", + "profileDigest": "sha256:55d51880fb7d938c7d87f0a8190b6d1499ba2c00291dbe55b710be5fc93e6666", + "metric": "temperature", + "sourceAddress": "virtual://room-sensor/temperature", + "value": null, + "unit": "Cel", + "quality": "ok", + "observedAt": "2026-07-29T09:15:00.000Z", + "receivedAt": "2026-07-29T09:15:00.042Z", + "sequenceRef": 117, + "nullAbsenceRef": null, + "qualityFlags": [], + "kkoTypeRef": "http://kbpedia.org/ontologies/kko#Quantity", + "rawPayload": { + "driver": "virtual", + "raw": 2137, + "scale": -2 + } + } + }, + { + "schema": "DeviceReading.json", + "reason": "quality \"unavailable\" means the device produced nothing; a value alongside it is a contradiction the schema must reject, not a curiosity for a consumer to resolve.", + "document": { + "id": "urn:srcos:device-reading:neg_unavailable_with_a_value", + "type": "DeviceReading", + "specVersion": "0.1.0", + "actorRef": "urn:srcos:agent:device_service", + "workspaceRef": "urn:srcos:workspace:citizen_home_demo", + "branchRef": "urn:srcos:branch:home_main", + "visibilityScope": [ + "private" + ], + "wallTime": "2026-07-29T09:15:03.500Z", + "logicalTime": 418, + "causalParents": [ + "urn:srcos:device-reading:th100_bed2_humidity_000417" + ], + "traceContext": { + "traceId": "4bf92f3577b34da6a3ce929d0e0e4736", + "spanId": "1a2b3c4d5e6f7081" + }, + "provenanceLinks": [ + { + "rel": "declared_by", + "ref": "urn:srcos:device-profile:acme_th100_room_sensor_v1" + }, + { + "rel": "produced_by", + "ref": "urn:srcos:device:th100_bed2" + }, + { + "rel": "ingested_by", + "ref": "prophet-platform:apps/device-service" + } + ], + "policyLabels": [ + "residence:interior", + "pii:none" + ], + "riskLabels": [], + "deviceRef": "urn:srcos:device:th100_bed2", + "deviceProfileRef": "urn:srcos:device-profile:acme_th100_room_sensor_v1", + "profileDigest": "sha256:24dfe44e340bc684c2c5618cfad534717c6b1f218b6bf7e94aa809afecab2813", + "metric": "humidity.relative", + "sourceAddress": "gatt://0000181a-0000-1000-8000-00805f9b34fb/00002a6f-0000-1000-8000-00805f9b34fb", + "value": 44.1, + "unit": "%", + "quality": "unavailable", + "observedAt": "2026-07-29T09:15:03.000Z", + "receivedAt": "2026-07-29T09:15:03.500Z", + "sequenceRef": 418, + "nullAbsenceRef": "urn:srcos:null-absence:th100_bed2_notify_timeout_000418", + "qualityFlags": [ + "notify_window_missed" + ], + "kkoTypeRef": "http://kbpedia.org/ontologies/kko#Quantity", + "rawPayload": null + } + }, + { + "schema": "DeviceReading.json", + "reason": "quality \"unavailable\" requires a NullAbsenceRecord reference. An untyped absence conflates a timeout with an intentional silence with a transport failure — the exact conflation the 12-kind taxonomy exists to prevent.", + "document": { + "id": "urn:srcos:device-reading:neg_unavailable_untyped", + "type": "DeviceReading", + "specVersion": "0.1.0", + "actorRef": "urn:srcos:agent:device_service", + "workspaceRef": "urn:srcos:workspace:citizen_home_demo", + "branchRef": "urn:srcos:branch:home_main", + "visibilityScope": [ + "private" + ], + "wallTime": "2026-07-29T09:15:03.500Z", + "logicalTime": 418, + "causalParents": [ + "urn:srcos:device-reading:th100_bed2_humidity_000417" + ], + "traceContext": { + "traceId": "4bf92f3577b34da6a3ce929d0e0e4736", + "spanId": "1a2b3c4d5e6f7081" + }, + "provenanceLinks": [ + { + "rel": "declared_by", + "ref": "urn:srcos:device-profile:acme_th100_room_sensor_v1" + }, + { + "rel": "produced_by", + "ref": "urn:srcos:device:th100_bed2" + }, + { + "rel": "ingested_by", + "ref": "prophet-platform:apps/device-service" + } + ], + "policyLabels": [ + "residence:interior", + "pii:none" + ], + "riskLabels": [], + "deviceRef": "urn:srcos:device:th100_bed2", + "deviceProfileRef": "urn:srcos:device-profile:acme_th100_room_sensor_v1", + "profileDigest": "sha256:24dfe44e340bc684c2c5618cfad534717c6b1f218b6bf7e94aa809afecab2813", + "metric": "humidity.relative", + "sourceAddress": "gatt://0000181a-0000-1000-8000-00805f9b34fb/00002a6f-0000-1000-8000-00805f9b34fb", + "value": null, + "unit": "%", + "quality": "unavailable", + "observedAt": "2026-07-29T09:15:03.000Z", + "receivedAt": "2026-07-29T09:15:03.500Z", + "sequenceRef": 418, + "nullAbsenceRef": null, + "qualityFlags": [ + "notify_window_missed" + ], + "kkoTypeRef": "http://kbpedia.org/ontologies/kko#Quantity", + "rawPayload": null + } + }, + { + "schema": "DeviceReading.json", + "reason": "the quality vocabulary is closed at v0.1. A producer inventing \"estimated\" widens the contract unilaterally; substituted already types a value that did not come from the device.", + "document": { + "id": "urn:srcos:device-reading:neg_invented_quality", + "type": "DeviceReading", + "specVersion": "0.1.0", + "actorRef": "urn:srcos:agent:device_service", + "workspaceRef": "urn:srcos:workspace:citizen_home_demo", + "branchRef": "urn:srcos:branch:home_main", + "visibilityScope": [ + "private" + ], + "wallTime": "2026-07-29T09:15:00.042Z", + "logicalTime": 117, + "causalParents": [], + "traceContext": { + "traceId": "4bf92f3577b34da6a3ce929d0e0e4736", + "spanId": "00f067aa0ba902b7" + }, + "provenanceLinks": [ + { + "rel": "declared_by", + "ref": "urn:srcos:device-profile:srcos_virtual_room_sensor_v1" + }, + { + "rel": "produced_by", + "ref": "urn:srcos:device:room_sensor_01" + }, + { + "rel": "ingested_by", + "ref": "prophet-platform:apps/device-service" + } + ], + "policyLabels": [ + "synthetic:simulated-device" + ], + "riskLabels": [ + "not-a-measurement" + ], + "deviceRef": "urn:srcos:device:room_sensor_01", + "deviceProfileRef": "urn:srcos:device-profile:srcos_virtual_room_sensor_v1", + "profileDigest": "sha256:55d51880fb7d938c7d87f0a8190b6d1499ba2c00291dbe55b710be5fc93e6666", + "metric": "temperature", + "sourceAddress": "virtual://room-sensor/temperature", + "value": 21.37, + "unit": "Cel", + "quality": "estimated", + "observedAt": "2026-07-29T09:15:00.000Z", + "receivedAt": "2026-07-29T09:15:00.042Z", + "sequenceRef": 117, + "nullAbsenceRef": null, + "qualityFlags": [], + "kkoTypeRef": "http://kbpedia.org/ontologies/kko#Quantity", + "rawPayload": { + "driver": "virtual", + "raw": 2137, + "scale": -2 + } + } + }, + { + "schema": "DeviceReading.json", + "reason": "Unknown top-level property must be rejected (additionalProperties: false). Side-channel fields are how an untyped vocabulary grows inside a typed one.", + "document": { + "id": "urn:srcos:device-reading:neg_unknown_prop", + "type": "DeviceReading", + "specVersion": "0.1.0", + "actorRef": "urn:srcos:agent:device_service", + "workspaceRef": "urn:srcos:workspace:citizen_home_demo", + "branchRef": "urn:srcos:branch:home_main", + "visibilityScope": [ + "private" + ], + "wallTime": "2026-07-29T09:15:00.042Z", + "logicalTime": 117, + "causalParents": [], + "traceContext": { + "traceId": "4bf92f3577b34da6a3ce929d0e0e4736", + "spanId": "00f067aa0ba902b7" + }, + "provenanceLinks": [ + { + "rel": "declared_by", + "ref": "urn:srcos:device-profile:srcos_virtual_room_sensor_v1" + }, + { + "rel": "produced_by", + "ref": "urn:srcos:device:room_sensor_01" + }, + { + "rel": "ingested_by", + "ref": "prophet-platform:apps/device-service" + } + ], + "policyLabels": [ + "synthetic:simulated-device" + ], + "riskLabels": [ + "not-a-measurement" + ], + "deviceRef": "urn:srcos:device:room_sensor_01", + "deviceProfileRef": "urn:srcos:device-profile:srcos_virtual_room_sensor_v1", + "profileDigest": "sha256:55d51880fb7d938c7d87f0a8190b6d1499ba2c00291dbe55b710be5fc93e6666", + "metric": "temperature", + "sourceAddress": "virtual://room-sensor/temperature", + "value": 21.37, + "unit": "Cel", + "quality": "ok", + "observedAt": "2026-07-29T09:15:00.000Z", + "receivedAt": "2026-07-29T09:15:00.042Z", + "sequenceRef": 117, + "nullAbsenceRef": null, + "qualityFlags": [], + "kkoTypeRef": "http://kbpedia.org/ontologies/kko#Quantity", + "rawPayload": { + "driver": "virtual", + "raw": 2137, + "scale": -2 + }, + "calibrationHint": "trust me" + } + }, + { + "schema": "DeviceReading.json", + "reason": "specVersion is pinned: any value other than the 0.1.0 const must fail.", + "document": { + "id": "urn:srcos:device-reading:neg_wrong_spec_version", + "type": "DeviceReading", + "specVersion": "0.2.0", + "actorRef": "urn:srcos:agent:device_service", + "workspaceRef": "urn:srcos:workspace:citizen_home_demo", + "branchRef": "urn:srcos:branch:home_main", + "visibilityScope": [ + "private" + ], + "wallTime": "2026-07-29T09:15:00.042Z", + "logicalTime": 117, + "causalParents": [], + "traceContext": { + "traceId": "4bf92f3577b34da6a3ce929d0e0e4736", + "spanId": "00f067aa0ba902b7" + }, + "provenanceLinks": [ + { + "rel": "declared_by", + "ref": "urn:srcos:device-profile:srcos_virtual_room_sensor_v1" + }, + { + "rel": "produced_by", + "ref": "urn:srcos:device:room_sensor_01" + }, + { + "rel": "ingested_by", + "ref": "prophet-platform:apps/device-service" + } + ], + "policyLabels": [ + "synthetic:simulated-device" + ], + "riskLabels": [ + "not-a-measurement" + ], + "deviceRef": "urn:srcos:device:room_sensor_01", + "deviceProfileRef": "urn:srcos:device-profile:srcos_virtual_room_sensor_v1", + "profileDigest": "sha256:55d51880fb7d938c7d87f0a8190b6d1499ba2c00291dbe55b710be5fc93e6666", + "metric": "temperature", + "sourceAddress": "virtual://room-sensor/temperature", + "value": 21.37, + "unit": "Cel", + "quality": "ok", + "observedAt": "2026-07-29T09:15:00.000Z", + "receivedAt": "2026-07-29T09:15:00.042Z", + "sequenceRef": 117, + "nullAbsenceRef": null, + "qualityFlags": [], + "kkoTypeRef": "http://kbpedia.org/ontologies/kko#Quantity", + "rawPayload": { + "driver": "virtual", + "raw": 2137, + "scale": -2 + } + } + }, + { + "schema": "DeviceReading.json", + "reason": "metric names are lowercase dot-separated slugs. Free-form display strings make the profile lookup a fuzzy match instead of an equality.", + "document": { + "id": "urn:srcos:device-reading:neg_metric_name_shape", + "type": "DeviceReading", + "specVersion": "0.1.0", + "actorRef": "urn:srcos:agent:device_service", + "workspaceRef": "urn:srcos:workspace:citizen_home_demo", + "branchRef": "urn:srcos:branch:home_main", + "visibilityScope": [ + "private" + ], + "wallTime": "2026-07-29T09:15:00.042Z", + "logicalTime": 117, + "causalParents": [], + "traceContext": { + "traceId": "4bf92f3577b34da6a3ce929d0e0e4736", + "spanId": "00f067aa0ba902b7" + }, + "provenanceLinks": [ + { + "rel": "declared_by", + "ref": "urn:srcos:device-profile:srcos_virtual_room_sensor_v1" + }, + { + "rel": "produced_by", + "ref": "urn:srcos:device:room_sensor_01" + }, + { + "rel": "ingested_by", + "ref": "prophet-platform:apps/device-service" + } + ], + "policyLabels": [ + "synthetic:simulated-device" + ], + "riskLabels": [ + "not-a-measurement" + ], + "deviceRef": "urn:srcos:device:room_sensor_01", + "deviceProfileRef": "urn:srcos:device-profile:srcos_virtual_room_sensor_v1", + "profileDigest": "sha256:55d51880fb7d938c7d87f0a8190b6d1499ba2c00291dbe55b710be5fc93e6666", + "metric": "Temperature (indoor)", + "sourceAddress": "virtual://room-sensor/temperature", + "value": 21.37, + "unit": "Cel", + "quality": "ok", + "observedAt": "2026-07-29T09:15:00.000Z", + "receivedAt": "2026-07-29T09:15:00.042Z", + "sequenceRef": 117, + "nullAbsenceRef": null, + "qualityFlags": [], + "kkoTypeRef": "http://kbpedia.org/ontologies/kko#Quantity", + "rawPayload": { + "driver": "virtual", + "raw": 2137, + "scale": -2 + } + } + }, + { + "schema": "DeviceProfile.json", + "reason": "A profile with no metrics describes nothing, yet would still be citable by readings. minItems: 1.", + "document": { + "id": "urn:srcos:device-profile:neg_declares_nothing", + "type": "DeviceProfile", + "specVersion": "0.1.0", + "actorRef": "urn:srcos:agent:device_service", + "workspaceRef": "urn:srcos:workspace:citizen_home_demo", + "visibilityScope": [ + "private" + ], + "provenanceLinks": [ + { + "rel": "declared_from", + "ref": "datasheet:acme-th-100-rev-c" + }, + { + "rel": "conforms_to", + "ref": "org.bluetooth.service.environmental_sensing" + } + ], + "policyLabels": [ + "residence:interior", + "pii:none" + ], + "riskLabels": [], + "deviceClass": "acme-th-100-room-sensor", + "manufacturer": "Acme Instruments", + "model": "TH-100", + "protocol": "ble-gatt", + "protocolBinding": { + "endpoint": "ble://C4:2F:90:11:8A:3D", + "securityMode": "pairing-bonded", + "parameters": { + "connectionIntervalMs": 1000, + "notifyOnly": true, + "gattService": "0000181a-0000-1000-8000-00805f9b34fb" + } + }, + "metrics": [], + "declaredAt": "2026-07-29T08:00:00.000Z", + "identityRef": "urn:srcos:device-identity:home_edge_node_01", + "adminState": "enabled", + "supersedesRef": null, + "definitionDigest": "sha256:24dfe44e340bc684c2c5618cfad534717c6b1f218b6bf7e94aa809afecab2813" + } + }, + { + "schema": "DeviceProfile.json", + "reason": "Every declared metric must carry a protocol-native sourceAddress; without it a reading's sourceAddress has nothing to be checked against and the attribution chain breaks at the profile.", + "document": { + "id": "urn:srcos:device-profile:neg_metric_without_address", + "type": "DeviceProfile", + "specVersion": "0.1.0", + "actorRef": "urn:srcos:agent:device_service", + "workspaceRef": "urn:srcos:workspace:citizen_home_demo", + "visibilityScope": [ + "private" + ], + "provenanceLinks": [ + { + "rel": "declared_from", + "ref": "datasheet:acme-th-100-rev-c" + }, + { + "rel": "conforms_to", + "ref": "org.bluetooth.service.environmental_sensing" + } + ], + "policyLabels": [ + "residence:interior", + "pii:none" + ], + "riskLabels": [], + "deviceClass": "acme-th-100-room-sensor", + "manufacturer": "Acme Instruments", + "model": "TH-100", + "protocol": "ble-gatt", + "protocolBinding": { + "endpoint": "ble://C4:2F:90:11:8A:3D", + "securityMode": "pairing-bonded", + "parameters": { + "connectionIntervalMs": 1000, + "notifyOnly": true, + "gattService": "0000181a-0000-1000-8000-00805f9b34fb" + } + }, + "metrics": [ + { + "metric": "temperature", + "kkoTypeRef": "http://kbpedia.org/ontologies/kko#Quantity", + "valueType": "number", + "unit": "Cel", + "minimum": -40.0, + "maximum": 85.0, + "resolution": 0.01, + "samplePeriodMs": 1000, + "access": "read" + }, + { + "metric": "humidity.relative", + "kkoTypeRef": "http://kbpedia.org/ontologies/kko#Quantity", + "valueType": "number", + "unit": "%", + "minimum": 0.0, + "maximum": 100.0, + "resolution": 0.01, + "samplePeriodMs": 1000, + "sourceAddress": "gatt://0000181a-0000-1000-8000-00805f9b34fb/00002a6f-0000-1000-8000-00805f9b34fb", + "access": "read" + }, + { + "metric": "battery.level", + "kkoTypeRef": "http://kbpedia.org/ontologies/kko#Quantity", + "valueType": "integer", + "unit": "%", + "minimum": 0, + "maximum": 100, + "resolution": 1, + "samplePeriodMs": 300000, + "sourceAddress": "gatt://0000180f-0000-1000-8000-00805f9b34fb/00002a19-0000-1000-8000-00805f9b34fb", + "access": "read" + } + ], + "declaredAt": "2026-07-29T08:00:00.000Z", + "identityRef": "urn:srcos:device-identity:home_edge_node_01", + "adminState": "enabled", + "supersedesRef": null, + "definitionDigest": "sha256:24dfe44e340bc684c2c5618cfad534717c6b1f218b6bf7e94aa809afecab2813" + } + }, + { + "schema": "DeviceProfile.json", + "reason": "A number/integer metric must declare a real operating range (if/then). With a null range there is no way to distinguish a genuine excursion from a decode error, and every out-of-range value becomes admissible by default.", + "document": { + "id": "urn:srcos:device-profile:neg_numeric_without_range", + "type": "DeviceProfile", + "specVersion": "0.1.0", + "actorRef": "urn:srcos:agent:device_service", + "workspaceRef": "urn:srcos:workspace:citizen_home_demo", + "visibilityScope": [ + "private" + ], + "provenanceLinks": [ + { + "rel": "declared_from", + "ref": "datasheet:acme-th-100-rev-c" + }, + { + "rel": "conforms_to", + "ref": "org.bluetooth.service.environmental_sensing" + } + ], + "policyLabels": [ + "residence:interior", + "pii:none" + ], + "riskLabels": [], + "deviceClass": "acme-th-100-room-sensor", + "manufacturer": "Acme Instruments", + "model": "TH-100", + "protocol": "ble-gatt", + "protocolBinding": { + "endpoint": "ble://C4:2F:90:11:8A:3D", + "securityMode": "pairing-bonded", + "parameters": { + "connectionIntervalMs": 1000, + "notifyOnly": true, + "gattService": "0000181a-0000-1000-8000-00805f9b34fb" + } + }, + "metrics": [ + { + "metric": "temperature", + "kkoTypeRef": "http://kbpedia.org/ontologies/kko#Quantity", + "valueType": "number", + "unit": "Cel", + "minimum": null, + "maximum": null, + "resolution": 0.01, + "samplePeriodMs": 1000, + "sourceAddress": "gatt://0000181a-0000-1000-8000-00805f9b34fb/00002a6e-0000-1000-8000-00805f9b34fb", + "access": "read" + }, + { + "metric": "humidity.relative", + "kkoTypeRef": "http://kbpedia.org/ontologies/kko#Quantity", + "valueType": "number", + "unit": "%", + "minimum": 0.0, + "maximum": 100.0, + "resolution": 0.01, + "samplePeriodMs": 1000, + "sourceAddress": "gatt://0000181a-0000-1000-8000-00805f9b34fb/00002a6f-0000-1000-8000-00805f9b34fb", + "access": "read" + }, + { + "metric": "battery.level", + "kkoTypeRef": "http://kbpedia.org/ontologies/kko#Quantity", + "valueType": "integer", + "unit": "%", + "minimum": 0, + "maximum": 100, + "resolution": 1, + "samplePeriodMs": 300000, + "sourceAddress": "gatt://0000180f-0000-1000-8000-00805f9b34fb/00002a19-0000-1000-8000-00805f9b34fb", + "access": "read" + } + ], + "declaredAt": "2026-07-29T08:00:00.000Z", + "identityRef": "urn:srcos:device-identity:home_edge_node_01", + "adminState": "enabled", + "supersedesRef": null, + "definitionDigest": "sha256:24dfe44e340bc684c2c5618cfad534717c6b1f218b6bf7e94aa809afecab2813" + } + }, + { + "schema": "DeviceProfile.json", + "reason": "access is closed to \"read\" at v0.1. Commanding a device is a world-changing effect and must travel the MPCC EffectRequest -> EffectDecision lifecycle; letting it in through a widened enum would give drivers unreviewed actuation.", + "document": { + "id": "urn:srcos:device-profile:neg_write_access", + "type": "DeviceProfile", + "specVersion": "0.1.0", + "actorRef": "urn:srcos:agent:device_service", + "workspaceRef": "urn:srcos:workspace:citizen_home_demo", + "visibilityScope": [ + "private" + ], + "provenanceLinks": [ + { + "rel": "declared_from", + "ref": "datasheet:acme-th-100-rev-c" + }, + { + "rel": "conforms_to", + "ref": "org.bluetooth.service.environmental_sensing" + } + ], + "policyLabels": [ + "residence:interior", + "pii:none" + ], + "riskLabels": [], + "deviceClass": "acme-th-100-room-sensor", + "manufacturer": "Acme Instruments", + "model": "TH-100", + "protocol": "ble-gatt", + "protocolBinding": { + "endpoint": "ble://C4:2F:90:11:8A:3D", + "securityMode": "pairing-bonded", + "parameters": { + "connectionIntervalMs": 1000, + "notifyOnly": true, + "gattService": "0000181a-0000-1000-8000-00805f9b34fb" + } + }, + "metrics": [ + { + "metric": "temperature", + "kkoTypeRef": "http://kbpedia.org/ontologies/kko#Quantity", + "valueType": "number", + "unit": "Cel", + "minimum": -40.0, + "maximum": 85.0, + "resolution": 0.01, + "samplePeriodMs": 1000, + "sourceAddress": "gatt://0000181a-0000-1000-8000-00805f9b34fb/00002a6e-0000-1000-8000-00805f9b34fb", + "access": "write" + }, + { + "metric": "humidity.relative", + "kkoTypeRef": "http://kbpedia.org/ontologies/kko#Quantity", + "valueType": "number", + "unit": "%", + "minimum": 0.0, + "maximum": 100.0, + "resolution": 0.01, + "samplePeriodMs": 1000, + "sourceAddress": "gatt://0000181a-0000-1000-8000-00805f9b34fb/00002a6f-0000-1000-8000-00805f9b34fb", + "access": "read" + }, + { + "metric": "battery.level", + "kkoTypeRef": "http://kbpedia.org/ontologies/kko#Quantity", + "valueType": "integer", + "unit": "%", + "minimum": 0, + "maximum": 100, + "resolution": 1, + "samplePeriodMs": 300000, + "sourceAddress": "gatt://0000180f-0000-1000-8000-00805f9b34fb/00002a19-0000-1000-8000-00805f9b34fb", + "access": "read" + } + ], + "declaredAt": "2026-07-29T08:00:00.000Z", + "identityRef": "urn:srcos:device-identity:home_edge_node_01", + "adminState": "enabled", + "supersedesRef": null, + "definitionDigest": "sha256:24dfe44e340bc684c2c5618cfad534717c6b1f218b6bf7e94aa809afecab2813" + } + }, + { + "schema": "DeviceProfile.json", + "reason": "protocolBinding.parameters holds scalars only. A nested blob is an untyped escape hatch through which unreviewed configuration — here, credentials — enters a typed contract.", + "document": { + "id": "urn:srcos:device-profile:neg_nested_binding_parameter", + "type": "DeviceProfile", + "specVersion": "0.1.0", + "actorRef": "urn:srcos:agent:device_service", + "workspaceRef": "urn:srcos:workspace:citizen_home_demo", + "visibilityScope": [ + "private" + ], + "provenanceLinks": [ + { + "rel": "declared_from", + "ref": "datasheet:acme-th-100-rev-c" + }, + { + "rel": "conforms_to", + "ref": "org.bluetooth.service.environmental_sensing" + } + ], + "policyLabels": [ + "residence:interior", + "pii:none" + ], + "riskLabels": [], + "deviceClass": "acme-th-100-room-sensor", + "manufacturer": "Acme Instruments", + "model": "TH-100", + "protocol": "ble-gatt", + "protocolBinding": { + "endpoint": "ble://C4:2F:90:11:8A:3D", + "securityMode": "pairing-bonded", + "parameters": { + "auth": { + "user": "admin", + "pass": "admin" + } + } + }, + "metrics": [ + { + "metric": "temperature", + "kkoTypeRef": "http://kbpedia.org/ontologies/kko#Quantity", + "valueType": "number", + "unit": "Cel", + "minimum": -40.0, + "maximum": 85.0, + "resolution": 0.01, + "samplePeriodMs": 1000, + "sourceAddress": "gatt://0000181a-0000-1000-8000-00805f9b34fb/00002a6e-0000-1000-8000-00805f9b34fb", + "access": "read" + }, + { + "metric": "humidity.relative", + "kkoTypeRef": "http://kbpedia.org/ontologies/kko#Quantity", + "valueType": "number", + "unit": "%", + "minimum": 0.0, + "maximum": 100.0, + "resolution": 0.01, + "samplePeriodMs": 1000, + "sourceAddress": "gatt://0000181a-0000-1000-8000-00805f9b34fb/00002a6f-0000-1000-8000-00805f9b34fb", + "access": "read" + }, + { + "metric": "battery.level", + "kkoTypeRef": "http://kbpedia.org/ontologies/kko#Quantity", + "valueType": "integer", + "unit": "%", + "minimum": 0, + "maximum": 100, + "resolution": 1, + "samplePeriodMs": 300000, + "sourceAddress": "gatt://0000180f-0000-1000-8000-00805f9b34fb/00002a19-0000-1000-8000-00805f9b34fb", + "access": "read" + } + ], + "declaredAt": "2026-07-29T08:00:00.000Z", + "identityRef": "urn:srcos:device-identity:home_edge_node_01", + "adminState": "enabled", + "supersedesRef": null, + "definitionDigest": "sha256:24dfe44e340bc684c2c5618cfad534717c6b1f218b6bf7e94aa809afecab2813" + } + }, + { + "schema": "DeviceProfile.json", + "reason": "definitionDigest is required. Without it there is nothing for a reading to pin, and a declaration can be edited in place with no reading ever noticing.", + "document": { + "id": "urn:srcos:device-profile:neg_no_definition_digest", + "type": "DeviceProfile", + "specVersion": "0.1.0", + "actorRef": "urn:srcos:agent:device_service", + "workspaceRef": "urn:srcos:workspace:citizen_home_demo", + "visibilityScope": [ + "private" + ], + "provenanceLinks": [ + { + "rel": "declared_from", + "ref": "datasheet:acme-th-100-rev-c" + }, + { + "rel": "conforms_to", + "ref": "org.bluetooth.service.environmental_sensing" + } + ], + "policyLabels": [ + "residence:interior", + "pii:none" + ], + "riskLabels": [], + "deviceClass": "acme-th-100-room-sensor", + "manufacturer": "Acme Instruments", + "model": "TH-100", + "protocol": "ble-gatt", + "protocolBinding": { + "endpoint": "ble://C4:2F:90:11:8A:3D", + "securityMode": "pairing-bonded", + "parameters": { + "connectionIntervalMs": 1000, + "notifyOnly": true, + "gattService": "0000181a-0000-1000-8000-00805f9b34fb" + } + }, + "metrics": [ + { + "metric": "temperature", + "kkoTypeRef": "http://kbpedia.org/ontologies/kko#Quantity", + "valueType": "number", + "unit": "Cel", + "minimum": -40.0, + "maximum": 85.0, + "resolution": 0.01, + "samplePeriodMs": 1000, + "sourceAddress": "gatt://0000181a-0000-1000-8000-00805f9b34fb/00002a6e-0000-1000-8000-00805f9b34fb", + "access": "read" + }, + { + "metric": "humidity.relative", + "kkoTypeRef": "http://kbpedia.org/ontologies/kko#Quantity", + "valueType": "number", + "unit": "%", + "minimum": 0.0, + "maximum": 100.0, + "resolution": 0.01, + "samplePeriodMs": 1000, + "sourceAddress": "gatt://0000181a-0000-1000-8000-00805f9b34fb/00002a6f-0000-1000-8000-00805f9b34fb", + "access": "read" + }, + { + "metric": "battery.level", + "kkoTypeRef": "http://kbpedia.org/ontologies/kko#Quantity", + "valueType": "integer", + "unit": "%", + "minimum": 0, + "maximum": 100, + "resolution": 1, + "samplePeriodMs": 300000, + "sourceAddress": "gatt://0000180f-0000-1000-8000-00805f9b34fb/00002a19-0000-1000-8000-00805f9b34fb", + "access": "read" + } + ], + "declaredAt": "2026-07-29T08:00:00.000Z", + "identityRef": "urn:srcos:device-identity:home_edge_node_01", + "adminState": "enabled", + "supersedesRef": null + } + }, + { + "schema": "DeviceProfile.json", + "reason": "Unknown top-level property must be rejected (additionalProperties: false).", + "document": { + "id": "urn:srcos:device-profile:neg_unknown_prop", + "type": "DeviceProfile", + "specVersion": "0.1.0", + "actorRef": "urn:srcos:agent:device_service", + "workspaceRef": "urn:srcos:workspace:citizen_home_demo", + "visibilityScope": [ + "private" + ], + "provenanceLinks": [ + { + "rel": "declared_from", + "ref": "datasheet:acme-th-100-rev-c" + }, + { + "rel": "conforms_to", + "ref": "org.bluetooth.service.environmental_sensing" + } + ], + "policyLabels": [ + "residence:interior", + "pii:none" + ], + "riskLabels": [], + "deviceClass": "acme-th-100-room-sensor", + "manufacturer": "Acme Instruments", + "model": "TH-100", + "protocol": "ble-gatt", + "protocolBinding": { + "endpoint": "ble://C4:2F:90:11:8A:3D", + "securityMode": "pairing-bonded", + "parameters": { + "connectionIntervalMs": 1000, + "notifyOnly": true, + "gattService": "0000181a-0000-1000-8000-00805f9b34fb" + } + }, + "metrics": [ + { + "metric": "temperature", + "kkoTypeRef": "http://kbpedia.org/ontologies/kko#Quantity", + "valueType": "number", + "unit": "Cel", + "minimum": -40.0, + "maximum": 85.0, + "resolution": 0.01, + "samplePeriodMs": 1000, + "sourceAddress": "gatt://0000181a-0000-1000-8000-00805f9b34fb/00002a6e-0000-1000-8000-00805f9b34fb", + "access": "read" + }, + { + "metric": "humidity.relative", + "kkoTypeRef": "http://kbpedia.org/ontologies/kko#Quantity", + "valueType": "number", + "unit": "%", + "minimum": 0.0, + "maximum": 100.0, + "resolution": 0.01, + "samplePeriodMs": 1000, + "sourceAddress": "gatt://0000181a-0000-1000-8000-00805f9b34fb/00002a6f-0000-1000-8000-00805f9b34fb", + "access": "read" + }, + { + "metric": "battery.level", + "kkoTypeRef": "http://kbpedia.org/ontologies/kko#Quantity", + "valueType": "integer", + "unit": "%", + "minimum": 0, + "maximum": 100, + "resolution": 1, + "samplePeriodMs": 300000, + "sourceAddress": "gatt://0000180f-0000-1000-8000-00805f9b34fb/00002a19-0000-1000-8000-00805f9b34fb", + "access": "read" + } + ], + "declaredAt": "2026-07-29T08:00:00.000Z", + "identityRef": "urn:srcos:device-identity:home_edge_node_01", + "adminState": "enabled", + "supersedesRef": null, + "definitionDigest": "sha256:24dfe44e340bc684c2c5618cfad534717c6b1f218b6bf7e94aa809afecab2813", + "calibrationTable": "see wiki" + } + }, + { + "schema": "DeviceProfile.json", + "reason": "The protocol taxonomy is closed. An unlisted protocol means no driver implements it: admitting the profile would produce a device nothing can read, described as if it could.", + "document": { + "id": "urn:srcos:device-profile:neg_unknown_protocol", + "type": "DeviceProfile", + "specVersion": "0.1.0", + "actorRef": "urn:srcos:agent:device_service", + "workspaceRef": "urn:srcos:workspace:citizen_home_demo", + "visibilityScope": [ + "private" + ], + "provenanceLinks": [ + { + "rel": "declared_from", + "ref": "datasheet:acme-th-100-rev-c" + }, + { + "rel": "conforms_to", + "ref": "org.bluetooth.service.environmental_sensing" + } + ], + "policyLabels": [ + "residence:interior", + "pii:none" + ], + "riskLabels": [], + "deviceClass": "acme-th-100-room-sensor", + "manufacturer": "Acme Instruments", + "model": "TH-100", + "protocol": "proprietary-serial", + "protocolBinding": { + "endpoint": "ble://C4:2F:90:11:8A:3D", + "securityMode": "pairing-bonded", + "parameters": { + "connectionIntervalMs": 1000, + "notifyOnly": true, + "gattService": "0000181a-0000-1000-8000-00805f9b34fb" + } + }, + "metrics": [ + { + "metric": "temperature", + "kkoTypeRef": "http://kbpedia.org/ontologies/kko#Quantity", + "valueType": "number", + "unit": "Cel", + "minimum": -40.0, + "maximum": 85.0, + "resolution": 0.01, + "samplePeriodMs": 1000, + "sourceAddress": "gatt://0000181a-0000-1000-8000-00805f9b34fb/00002a6e-0000-1000-8000-00805f9b34fb", + "access": "read" + }, + { + "metric": "humidity.relative", + "kkoTypeRef": "http://kbpedia.org/ontologies/kko#Quantity", + "valueType": "number", + "unit": "%", + "minimum": 0.0, + "maximum": 100.0, + "resolution": 0.01, + "samplePeriodMs": 1000, + "sourceAddress": "gatt://0000181a-0000-1000-8000-00805f9b34fb/00002a6f-0000-1000-8000-00805f9b34fb", + "access": "read" + }, + { + "metric": "battery.level", + "kkoTypeRef": "http://kbpedia.org/ontologies/kko#Quantity", + "valueType": "integer", + "unit": "%", + "minimum": 0, + "maximum": 100, + "resolution": 1, + "samplePeriodMs": 300000, + "sourceAddress": "gatt://0000180f-0000-1000-8000-00805f9b34fb/00002a19-0000-1000-8000-00805f9b34fb", + "access": "read" + } + ], + "declaredAt": "2026-07-29T08:00:00.000Z", + "identityRef": "urn:srcos:device-identity:home_edge_node_01", + "adminState": "enabled", + "supersedesRef": null, + "definitionDigest": "sha256:24dfe44e340bc684c2c5618cfad534717c6b1f218b6bf7e94aa809afecab2813" + } + } + ] +} diff --git a/schemas/DeviceProfile.json b/schemas/DeviceProfile.json new file mode 100644 index 0000000..a190fa9 --- /dev/null +++ b/schemas/DeviceProfile.json @@ -0,0 +1,328 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://schemas.srcos.ai/v2/DeviceProfile.json", + "title": "DeviceProfile", + "description": "What a southbound device IS: the typed, digest-pinned declaration of one class of physical device — its protocol, its protocol binding, and the exact set of readings it produces with units, value types, specified ranges, and protocol-native source addresses. This is the single southbound abstraction the fog-and-citizen plane reads devices through (EdgeX Foundry's lesson: ONE southbound interface, N protocol drivers); a driver implements a protocol, it does not invent a vocabulary. A DeviceReading is admissible only against a profile, and definitionDigest pins the exact revision it was admitted against so a range cannot be widened after the fact to legalise a reading that already failed. Distinct from DeviceIdentity, which governs admission and trust for a SourceOS operator workstation, not the metrology of a sensor; the two bind via identityRef rather than competing.", + "type": "object", + "additionalProperties": false, + "required": [ + "id", + "type", + "specVersion", + "deviceClass", + "protocol", + "protocolBinding", + "metrics", + "definitionDigest", + "declaredAt" + ], + "properties": { + "id": { + "type": "string", + "pattern": "^urn:srcos:device-profile:[A-Za-z0-9._~-]+$", + "description": "Stable URN identifier. Pattern: urn:srcos:device-profile:. Invariant: profile identity is stable and never reused; a changed declaration is a new profile that names its predecessor via supersedesRef." + }, + "type": { + "const": "DeviceProfile", + "description": "Discriminator constant — always \"DeviceProfile\"." + }, + "specVersion": { + "const": "0.1.0", + "description": "DeviceService contract version, pinned per the policy-integrity tranche-0001 discipline. This family is versioned independently of the v2 metadata-plane schemas; changing this const is a contract change." + }, + "actorRef": { + "type": "string", + "minLength": 1, + "description": "Authority reference of the actor that emitted this event (human subject, agent, service, or system). Subject or Agent Registry URNs are recommended; free-form identifiers are permitted in v0.1." + }, + "workspaceRef": { + "type": "string", + "minLength": 1, + "description": "Workspace scope this event belongs to. Free-form stable reference in v0.1; a typed workspace URN may narrow this in a later minor version." + }, + "visibilityScope": { + "type": "array", + "items": { + "type": "string", + "minLength": 1, + "description": "A visibility scope label such as private, shared, public, or an audience URN." + }, + "uniqueItems": true, + "default": [], + "description": "Visibility scopes governing who may observe this event (e.g. private, shared, public, or audience URNs)." + }, + "provenanceLinks": { + "type": "array", + "items": { + "type": "object", + "additionalProperties": false, + "required": [ + "rel", + "ref" + ], + "properties": { + "rel": { + "type": "string", + "minLength": 1, + "description": "Relation of the linked artifact to this event (e.g. derived_from, quotes, source_feed)." + }, + "ref": { + "type": "string", + "minLength": 1, + "description": "URN or stable reference of the linked artifact." + } + }, + "description": "A single provenance link (relation + reference)." + }, + "uniqueItems": true, + "default": [], + "description": "Typed provenance links from this event to upstream artifacts, sources, or records." + }, + "policyLabels": { + "type": "array", + "items": { + "type": "string", + "minLength": 1, + "description": "A policy label." + }, + "uniqueItems": true, + "default": [], + "description": "Policy labels attached to this event by producers or the policy fabric." + }, + "riskLabels": { + "type": "array", + "items": { + "type": "string", + "minLength": 1, + "description": "A risk label." + }, + "uniqueItems": true, + "default": [], + "description": "Risk labels attached to this event by producers or risk controls." + }, + "deviceClass": { + "type": "string", + "minLength": 1, + "description": "Stable slug for the class of device this profile describes (e.g. acme-th-100-thermostat). Profiles describe a device MODEL; instances cite the profile by URN in every reading." + }, + "manufacturer": { + "type": "string", + "minLength": 1, + "description": "Device manufacturer, as printed on the device or its datasheet." + }, + "model": { + "type": "string", + "minLength": 1, + "description": "Manufacturer's model designation." + }, + "protocol": { + "type": "string", + "enum": [ + "ble-gatt", + "zigbee", + "z-wave", + "matter", + "modbus-tcp", + "mqtt", + "coap", + "snmp", + "http-poll", + "virtual" + ], + "description": "Southbound protocol a driver must speak to reach this device. \"virtual\" denotes a simulated device with no physical counterpart — it is a first-class, visibly distinguishable member of the taxonomy precisely so that simulated readings can never be mistaken for measured ones downstream." + }, + "protocolBinding": { + "type": "object", + "additionalProperties": false, + "required": [ + "endpoint", + "securityMode" + ], + "properties": { + "endpoint": { + "type": "string", + "minLength": 1, + "description": "Protocol-native endpoint of the device as a whole: BLE peripheral address, Modbus host:port, MQTT broker URL, or virtual:// authority. Per-metric addressing lives in metrics[].sourceAddress." + }, + "securityMode": { + "type": "string", + "enum": [ + "none", + "psk", + "certificate", + "pairing-bonded", + "token" + ], + "description": "Transport security posture of the southbound link. \"none\" is legal and must stay visible: an unauthenticated sensor link is a stated risk, not an omission." + }, + "parameters": { + "type": "object", + "additionalProperties": { + "type": [ + "string", + "number", + "integer", + "boolean" + ], + "description": "A scalar binding parameter value." + }, + "description": "Scalar protocol parameters the driver needs (e.g. unitId for Modbus, connectionIntervalMs for BLE). Scalars only: a nested blob here would be an untyped escape hatch." + } + }, + "description": "How a driver reaches this device: endpoint, transport security posture, and scalar protocol parameters." + }, + "metrics": { + "type": "array", + "minItems": 1, + "items": { + "type": "object", + "additionalProperties": false, + "required": [ + "metric", + "valueType", + "unit", + "sourceAddress" + ], + "properties": { + "metric": { + "type": "string", + "pattern": "^[a-z][a-z0-9_]*(\\.[a-z][a-z0-9_]*)*$", + "description": "Metric name, unique within this profile. Lowercase dot-separated segments (e.g. temperature, air.pm2_5). This is the name a DeviceReading cites." + }, + "kkoTypeRef": { + "type": "string", + "format": "uri", + "description": "Ontology class URI of the observed quantity. Shares the URI vocabulary of KnowledgeNugget.kkoTypeRefs and SemanticAction slot types, so a reading binds as a typed graph value without a second ontology mapping. Producers SHOULD cite a class they can verify exists in the ontology the estate actually vendors: the KKO upper ontology (http://kbpedia.org/ontologies/kko#, 169 terms) is vendored; the ~58k KBpedia reference-concept layer that would carry a concept as specific as \"temperature\" is NOT, and citing an unvendored URI is an unresolvable type ref wearing the appearance of one." + }, + "valueType": { + "type": "string", + "enum": [ + "number", + "integer", + "boolean", + "string" + ], + "description": "JSON type of DeviceReading.value for this metric. A reading whose value type disagrees with this declaration is inadmissible." + }, + "unit": { + "type": "string", + "minLength": 1, + "description": "Unit symbol of the reading value (UCUM symbol recommended, e.g. Cel, %, ug/m3). Use \"1\" for a dimensionless ratio and \"{bool}\" for a boolean state. A reading MUST repeat this unit verbatim so it is self-describing at the point of consumption." + }, + "minimum": { + "type": [ + "number", + "null" + ], + "description": "Inclusive lower bound of the device's specified operating range for this metric. Required for number/integer metrics: a numeric metric with no declared range cannot distinguish a real excursion from a decode error." + }, + "maximum": { + "type": [ + "number", + "null" + ], + "description": "Inclusive upper bound of the device's specified operating range for this metric. Required for number/integer metrics." + }, + "resolution": { + "type": [ + "number", + "null" + ], + "exclusiveMinimum": 0, + "description": "Smallest reportable increment of this metric in declared units, when the device specifies one." + }, + "samplePeriodMs": { + "type": [ + "integer", + "null" + ], + "minimum": 1, + "description": "Nominal sampling period in milliseconds. Consumers use it to distinguish an overdue reading from an absent device; it is a declaration, not a guarantee." + }, + "sourceAddress": { + "type": "string", + "minLength": 1, + "description": "Protocol-native address this metric is read from — the BLE GATT characteristic UUID, Modbus register, MQTT topic, or virtual:// path. A DeviceReading MUST repeat it verbatim, which is what proves the value came from the declared physical channel of the declared metric rather than from somewhere else." + }, + "access": { + "type": "string", + "enum": [ + "read" + ], + "default": "read", + "description": "Access mode. Closed to \"read\" at v0.1: this contract is a southbound READ abstraction. Commanding a device is a world-changing effect and must travel the MPCC EffectRequest -> EffectDecision lifecycle, not a widened enum here." + } + }, + "allOf": [ + { + "if": { + "properties": { + "valueType": { + "enum": [ + "number", + "integer" + ] + } + }, + "required": [ + "valueType" + ] + }, + "then": { + "required": [ + "minimum", + "maximum" + ], + "properties": { + "minimum": { + "type": "number" + }, + "maximum": { + "type": "number" + } + }, + "description": "A numeric metric MUST declare a real (non-null) operating range. Without one there is no way to tell a genuine excursion from a decode error, and every out-of-range value becomes admissible by default." + } + } + ], + "description": "One metric this device produces: its name, ontology type, value type, unit, specified range, and the protocol-native address it is read from." + }, + "description": "The complete set of readings this device produces. A profile that declares no metric describes nothing; a reading citing a metric absent from this set is inadmissible." + }, + "definitionDigest": { + "type": "string", + "pattern": "^sha256:[0-9a-f]{64}$", + "description": "sha256 over the canonical JSON (sorted keys, no whitespace) of the declared-capability projection {deviceClass, protocol, metrics}. Recomputed — never merely read back — by tools/validate_device_service_examples.py. Every DeviceReading pins this value, so widening a range after the fact produces a new digest and orphans the readings it was meant to legalise instead of silently admitting them." + }, + "declaredAt": { + "type": "string", + "format": "date-time", + "description": "ISO 8601 time this profile revision was declared." + }, + "identityRef": { + "type": [ + "string", + "null" + ], + "pattern": "^urn:srcos:device-identity:[A-Za-z0-9._~-]+$", + "description": "DeviceIdentity URN of the SourceOS host that owns the southbound link to this device (the gateway workstation or edge node), when one is registered. Binds sensor metrology to the existing admission/attestation family instead of duplicating trust vocabulary." + }, + "adminState": { + "type": "string", + "enum": [ + "enabled", + "disabled" + ], + "default": "enabled", + "description": "Operator intent for devices of this class. \"disabled\" means readings are not to be ingested; it is an authorisation state, not a liveness observation." + }, + "supersedesRef": { + "type": [ + "string", + "null" + ], + "pattern": "^urn:srcos:device-profile:[A-Za-z0-9._~-]+$", + "description": "DeviceProfile URN this revision supersedes, so a declaration change is an auditable chain rather than an in-place edit." + } + } +} diff --git a/schemas/DeviceReading.json b/schemas/DeviceReading.json new file mode 100644 index 0000000..b6353d0 --- /dev/null +++ b/schemas/DeviceReading.json @@ -0,0 +1,312 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://schemas.srcos.ai/v2/DeviceReading.json", + "title": "DeviceReading", + "description": "One observation from one southbound device: a single metric's value, in a declared unit, at a declared instant, with a declared quality. Profile of the ConversationEvent envelope: shares the single MPCC identity/causality/governance vocabulary (actorRef, workspaceRef, branchRef, visibilityScope, wallTime, logicalTime, causalParents, traceContext, provenanceLinks, policyLabels, riskLabels) with byte-identical sub-schemas — parity is enforced by tools/validate_device_service_examples.py. Normative: a reading is ATTRIBUTABLE OR IT IS NOTHING. deviceRef, deviceProfileRef, profileDigest, metric, sourceAddress and unit are all required, so every value resolves to the physical thing that produced it, the exact declared-capability revision it was admitted against, and the protocol-native channel it came off. An unattributable reading is the silent-wrong this contract exists to make impossible.", + "type": "object", + "additionalProperties": false, + "required": [ + "id", + "type", + "specVersion", + "deviceRef", + "deviceProfileRef", + "profileDigest", + "metric", + "sourceAddress", + "value", + "unit", + "quality", + "observedAt", + "wallTime" + ], + "properties": { + "id": { + "type": "string", + "pattern": "^urn:srcos:device-reading:[A-Za-z0-9._~-]+$", + "description": "Stable URN identifier. Pattern: urn:srcos:device-reading:. Invariant: reading identity is stable and never reused." + }, + "type": { + "const": "DeviceReading", + "description": "Discriminator constant — always \"DeviceReading\"." + }, + "specVersion": { + "const": "0.1.0", + "description": "DeviceService contract version, pinned per the policy-integrity tranche-0001 discipline. This family is versioned independently of the v2 metadata-plane schemas; changing this const is a contract change." + }, + "actorRef": { + "type": "string", + "minLength": 1, + "description": "Authority reference of the actor that emitted this event (human subject, agent, service, or system). Subject or Agent Registry URNs are recommended; free-form identifiers are permitted in v0.1." + }, + "workspaceRef": { + "type": "string", + "minLength": 1, + "description": "Workspace scope this event belongs to. Free-form stable reference in v0.1; a typed workspace URN may narrow this in a later minor version." + }, + "branchRef": { + "type": "string", + "minLength": 1, + "description": "Conversation-fabric branch this event was appended to. Free-form stable reference in v0.1, pending adoption of the profit-mpcc branch-id contract." + }, + "visibilityScope": { + "type": "array", + "items": { + "type": "string", + "minLength": 1, + "description": "A visibility scope label such as private, shared, public, or an audience URN." + }, + "uniqueItems": true, + "default": [], + "description": "Visibility scopes governing who may observe this event (e.g. private, shared, public, or audience URNs)." + }, + "wallTime": { + "type": "string", + "format": "date-time", + "description": "ISO 8601 wall-clock time of the event as observed by the producer. Family profiles map their primary domain timestamp onto this field." + }, + "logicalTime": { + "type": [ + "integer", + "string" + ], + "minimum": 0, + "minLength": 1, + "description": "Producer-scoped logical clock: a non-negative integer for scalar (Lamport-style) clocks, or a non-empty string for encoded vector/hybrid clocks. Invariant: causalParents must never point forward in logical time." + }, + "causalParents": { + "type": "array", + "items": { + "type": "string", + "pattern": "^urn:srcos:[a-z0-9-]+:[A-Za-z0-9._~-]+$", + "description": "URN of a causally preceding event in any MPCC event family." + }, + "uniqueItems": true, + "default": [], + "description": "Event URNs this event causally depends on. Cross-family parents are permitted (e.g. an OrderIntent caused by a MarketDataEvent). Invariant: parents must never point forward in logical time." + }, + "traceContext": { + "type": "object", + "additionalProperties": false, + "required": [ + "traceId" + ], + "properties": { + "traceId": { + "type": "string", + "minLength": 1, + "description": "Distributed trace identifier." + }, + "spanId": { + "type": "string", + "minLength": 1, + "description": "Span identifier within the trace." + }, + "baggage": { + "type": "object", + "additionalProperties": { + "type": "string" + }, + "description": "String-valued trace baggage entries." + } + }, + "description": "Optional distributed-tracing correlation context." + }, + "provenanceLinks": { + "type": "array", + "items": { + "type": "object", + "additionalProperties": false, + "required": [ + "rel", + "ref" + ], + "properties": { + "rel": { + "type": "string", + "minLength": 1, + "description": "Relation of the linked artifact to this event (e.g. derived_from, quotes, source_feed)." + }, + "ref": { + "type": "string", + "minLength": 1, + "description": "URN or stable reference of the linked artifact." + } + }, + "description": "A single provenance link (relation + reference)." + }, + "uniqueItems": true, + "default": [], + "description": "Typed provenance links from this event to upstream artifacts, sources, or records." + }, + "policyLabels": { + "type": "array", + "items": { + "type": "string", + "minLength": 1, + "description": "A policy label." + }, + "uniqueItems": true, + "default": [], + "description": "Policy labels attached to this event by producers or the policy fabric." + }, + "riskLabels": { + "type": "array", + "items": { + "type": "string", + "minLength": 1, + "description": "A risk label." + }, + "uniqueItems": true, + "default": [], + "description": "Risk labels attached to this event by producers or risk controls." + }, + "deviceRef": { + "type": "string", + "pattern": "^urn:srcos:device:[A-Za-z0-9._~-]+$", + "description": "URN of the device INSTANCE that produced this reading — the individual physical thing, stable across profile revisions and across its own re-commissioning. Required: a reading that cannot name its device is unattributable." + }, + "deviceProfileRef": { + "type": "string", + "pattern": "^urn:srcos:device-profile:[A-Za-z0-9._~-]+$", + "description": "DeviceProfile URN this reading is admissible against. Required: the profile is what makes a bare number mean something." + }, + "profileDigest": { + "type": "string", + "pattern": "^sha256:[0-9a-f]{64}$", + "description": "The referenced profile's definitionDigest at the instant this reading was admitted. Pins the exact declared-capability revision: if the profile is later widened, this reading still records which contract it actually passed, and a mismatch is detectable rather than silently forgiven." + }, + "metric": { + "type": "string", + "pattern": "^[a-z][a-z0-9_]*(\\.[a-z][a-z0-9_]*)*$", + "description": "Name of the metric observed. Must be declared in the referenced profile's metrics set." + }, + "sourceAddress": { + "type": "string", + "minLength": 1, + "description": "Protocol-native address this value was actually read from. Must equal the sourceAddress the profile declares for this metric — that equality is what proves the value came off the declared physical channel and not from an adjacent characteristic, register, or topic." + }, + "value": { + "type": [ + "number", + "boolean", + "string", + "null" + ], + "description": "The observed value, in the declared unit, of the type the profile declares for this metric. null is legal ONLY for quality \"unavailable\", where it must be accompanied by nullAbsenceRef — a typed absence, never an untyped hole." + }, + "unit": { + "type": "string", + "minLength": 1, + "description": "Unit symbol of value, repeated verbatim from the profile's declaration for this metric. Deliberately redundant: consumers read readings, not profiles, and a value whose unit must be looked up elsewhere is a unit-confusion incident waiting to happen. The validator enforces the equality." + }, + "quality": { + "type": "string", + "enum": [ + "ok", + "degraded", + "stale", + "substituted", + "unavailable" + ], + "description": "Observation quality, closed at v0.1: ok (measured, within specification); degraded (measured, but the driver has cause to doubt it); stale (a cached prior value re-reported because the device did not answer in time); substituted (a value supplied by something other than the device, e.g. a default or an interpolation); unavailable (no value — the device produced nothing). Invariant: stale and substituted are NOT ok, and substituted is NOT measured. Conflating them is how a smart-home twin comes to believe a default is a measurement." + }, + "observedAt": { + "type": "string", + "format": "date-time", + "description": "ISO 8601 instant the device observed the value, as close to the sensor as the protocol permits. Distinct from wallTime (the producer's event time) and receivedAt (arrival at the DeviceService): observedAt -> receivedAt is the southbound latency a twin's sync budget is actually spent on, and collapsing them hides it." + }, + "receivedAt": { + "type": "string", + "format": "date-time", + "description": "ISO 8601 instant the DeviceService received this value from the driver. Invariant: never earlier than observedAt." + }, + "sequenceRef": { + "type": [ + "integer", + "string" + ], + "minimum": 0, + "minLength": 1, + "description": "Driver-scoped monotonic sequence number (non-negative integer) or sequence token (non-empty string) for this device+metric stream, used for gap detection. A gap is reported as a typed absence, not inferred from silence." + }, + "nullAbsenceRef": { + "type": [ + "string", + "null" + ], + "pattern": "^urn:srcos:null-absence:[A-Za-z0-9._~-]+$", + "description": "NullAbsenceRecord URN typing the absence when quality is \"unavailable\". Reuses the existing 12-kind MPCC absence taxonomy rather than inventing a device-local one, so a transport failure is never conflated with a device that intentionally reported nothing." + }, + "qualityFlags": { + "type": "array", + "items": { + "type": "string", + "minLength": 1, + "description": "A data-quality flag." + }, + "uniqueItems": true, + "default": [], + "description": "Driver-attached data-quality flags (e.g. out_of_order, recovered_after_gap, low_battery). Advisory detail beneath the closed quality enum, never a substitute for it." + }, + "kkoTypeRef": { + "type": "string", + "format": "uri", + "description": "Ontology class URI of the observed quantity, carried verbatim from the profile's metric declaration so a reading is graph-typed on arrival without a profile lookup. Kept at the granularity the estate can actually resolve (see DeviceProfile.metrics[].kkoTypeRef); the metric name and unit carry the specificity the vendored ontology does not." + }, + "rawPayload": { + "type": [ + "object", + "array", + "string", + "number", + "boolean", + "null" + ], + "description": "The driver payload exactly as received from the device, before decoding or normalization (e.g. the BLE characteristic notification as a hex string). Keeping the pre-decode form is what makes a decode bug provable after the fact." + } + }, + "allOf": [ + { + "if": { + "properties": { + "quality": { + "const": "unavailable" + } + }, + "required": [ + "quality" + ] + }, + "then": { + "properties": { + "value": { + "type": "null" + } + }, + "required": [ + "nullAbsenceRef" + ], + "not": { + "properties": { + "nullAbsenceRef": { + "type": "null" + } + }, + "required": [ + "nullAbsenceRef" + ] + } + }, + "else": { + "properties": { + "value": { + "not": { + "type": "null" + } + } + } + } + } + ] +} diff --git a/schemas/README.md b/schemas/README.md index a1e0a33..b1609a7 100644 --- a/schemas/README.md +++ b/schemas/README.md @@ -5,6 +5,31 @@ This directory contains the JSON Schema (draft 2020-12) files that make up the S --- +## Recent additions — DeviceService Contract v0.1 (the southbound device plane, W8.7) + +The southbound device abstraction — the estate's first — adds the following top-level schemas: + +| File | Type | URN prefix | +|------|------|-----------| +| `DeviceProfile.json` | DeviceProfile | `urn:srcos:device-profile:` | +| `DeviceReading.json` | DeviceReading | `urn:srcos:device-reading:` | + +These types support: +- **ONE southbound interface, N protocol drivers** (EdgeX Foundry's lesson): a driver speaks a protocol, it does not invent a vocabulary. `DeviceProfile` declares what a device IS — protocol, protocol binding, and the exact readings it produces with units, value types, operating ranges and protocol-native source addresses; `DeviceReading` is one observation against that declaration +- **the invariant the family exists for — a reading is ATTRIBUTABLE OR IT IS NOTHING.** `deviceRef`, `deviceProfileRef`, `profileDigest`, `metric`, `sourceAddress` and `unit` are all required, so every value resolves to the physical thing that produced it, the exact declared-capability revision it was admitted against, and the protocol-native channel it came off. The validator resolves all of them across the example set and additionally requires `provenanceLinks` to name the device and profile independently, so the attribution survives being read without the validator +- **digest-pinning against retroactive legalisation**: `DeviceProfile.definitionDigest` is recomputed by the validator from the profile's own `{deviceClass, protocol, metrics}` projection, and every reading pins it. Widening a range after the fact produces a new digest and orphans the readings it was meant to legalise instead of silently admitting them (the `UpdateHealthProbe` digest-pinned-gate construct, applied to metrology) +- **a closed quality vocabulary with typed absence**: `ok` / `degraded` / `stale` / `substituted` / `unavailable`, where `unavailable` is schema-bound to a null value plus a `NullAbsenceRecord` reference — the device plane reuses the existing 12-kind MPCC absence taxonomy rather than inventing a device-local one. Normative: `stale` and `substituted` are not `ok`, and `substituted` is not measured +- **simulated devices as a first-class, visibly-labelled member of the protocol taxonomy** (`protocol: "virtual"` ⇒ the `synthetic:simulated-device` label, enforced in both directions) — the `model-generated` admissibility rule applied to sensors +- `observedAt` → `receivedAt` → `wallTime` kept distinct, because `observedAt` → `receivedAt` is the southbound latency a twin's sync budget is actually spent on +- ontology typing at the granularity the estate can resolve: `kkoTypeRef` cites the vendored KKO upper ontology (169 verified terms), not the unvendored ~58k KBpedia reference-concept layer — a more specific-looking URI that resolves to nothing is the same silent-wrong in different clothing +- read-only at v0.1 (`access` closed to `"read"`): commanding a device is a world-changing effect and must travel the MPCC `EffectRequest` → `EffectDecision` lifecycle, not a widened enum + +Distinct from `DeviceIdentity` (admission/attestation/trust for a SourceOS operator workstation — no metrology) and `TelemetryEvent` (an `AgentSession` diagnostic log event — no unit, range, quality or device); `DeviceProfile.identityRef` binds to the former rather than competing with it. + +Validation: `make validate-device-service-examples` (schema conformance, tranche strictness bar, envelope parity against `ConversationEvent`, recomputed profile digests, cross-document attribution soundness, simulated-visibility, and twenty-one negative vectors under `fixtures/device-service/`). Normative notes: `specs/device-service-contract.md`. Reference implementation: `device-service` in `SocioProphet/prophet-platform` (`apps/device-service`). + +--- + ## Recent additions — A/B Fallback Update Contract v0.1 The dual-slot update contract adds the following top-level schemas: diff --git a/specs/device-service-contract.md b/specs/device-service-contract.md new file mode 100644 index 0000000..61d973f --- /dev/null +++ b/specs/device-service-contract.md @@ -0,0 +1,213 @@ +# DeviceService Contract v0.1 — the southbound device plane (W8.7) + +Status: v0.1.0 (normative for the schemas listed below) +Validation: `make validate-device-service-examples` (tools/validate_device_service_examples.py). +Reference implementation: `device-service` in `SocioProphet/prophet-platform` (`apps/device-service`). + +## 1. Scope — the gap this closes + +The estate had **no southbound device abstraction anywhere**. The health twin +ingests batch corpora; the smart home has nothing; a Δt<50ms twin sync is +aspirational for the simple reason that no sensor stream exists to be late. + +EdgeX Foundry's lesson is the fix, and it is a lesson about *shape*, not about +adopting EdgeX: **ONE southbound interface, N protocol drivers.** A driver's job +is to speak a protocol. It is emphatically not to invent a vocabulary — the +moment each driver defines its own event shape, every consumer downstream grows +a per-driver special case and the "device plane" is a directory of adapters +rather than a plane. + +This contract lands that one interface as two types: + +| Schema | What it is | +|--------|-----------| +| `DeviceProfile` | What a device **is**: identity, protocol, protocol binding, and the exact set of readings it produces with units, value types, operating ranges, and protocol-native source addresses. | +| `DeviceReading` | One **observation**: a metric's value, in a declared unit, at a declared instant, with a declared quality — and the provenance that makes those words mean something. | + +## 2. The normative invariant: a reading is attributable or it is nothing + +> An unattributable reading is the silent-wrong we keep paying for. + +A bare number with a timestamp is not data. It is a number with a timestamp. +`DeviceReading` therefore **requires** the whole attribution set — there is no +optional path to an anonymous value: + +| Field | What it answers | Why required | +|-------|-----------------|--------------| +| `deviceRef` | *which physical thing* | Two identical sensors in two rooms are not interchangeable. | +| `deviceProfileRef` | *under what declaration* | The profile is what makes the number mean a temperature. | +| `profileDigest` | *which revision of that declaration* | §3. | +| `metric` | *which of the device's readings* | A device produces several. | +| `sourceAddress` | *off which physical channel* | Proves the value came from the declared characteristic/register/topic, not an adjacent one. | +| `unit` | *in what units* | §4. | +| `quality` | *how much to believe it* | §5. | +| `observedAt` | *when the sensor saw it* | §6. | + +`tools/validate_device_service_examples.py` resolves every one of these across +the example set: the profile must exist, the digest must match, the metric must +be declared, the unit and source address must agree with the declaration, the +value must be of the declared type and inside the declared range, and +`provenanceLinks` must independently name both the device and the profile — so +the attribution survives being read by something that is not this validator. + +## 3. Digest-pinning: a range cannot be widened after the fact + +`DeviceProfile.definitionDigest` is `sha256` over the canonical JSON (sorted +keys, no whitespace, UTF-8) of the declared-capability projection +`{deviceClass, protocol, metrics}` of the document **as written** — schema +defaults are not materialised, so an omitted field and an explicitly-defaulted +one are different declarations and hash differently. + +Every `DeviceReading` carries the digest of the profile revision it was actually +admitted against. This closes a specific attack that a plain `profileRef` leaves +wide open: a reading fails the declared range, so somebody widens the range. +With digest-pinning, widening the profile produces a new digest and **orphans** +the readings it was meant to legalise instead of silently admitting them — the +mismatch is detectable forever after. + +This deliberately mirrors `UpdateHealthProbe.definitionDigest` from the A/B +fallback update family (W9.2), where the same construct stops a failing +candidate from being promoted by weakening the gate it failed. The validator +**recomputes** the digest rather than reading it back: a digest that is merely +stored is an assertion about pinning; a digest that is recomputed is pinning. + +## 4. One envelope, not two — and one unit, stated twice + +`DeviceReading` is a structural profile of the `ConversationEvent` envelope, +exactly as the MPCC trading families are: `actorRef`, `workspaceRef`, +`branchRef`, `visibilityScope`, `wallTime`, `logicalTime`, `causalParents`, +`traceContext`, `provenanceLinks`, `policyLabels`, `riskLabels` are carried with +**byte-identical sub-schemas**, and the validator fails the build on any drift. +`DeviceProfile` carries the registry subset of the same vocabulary (no event +time, no causality — a profile is declared, not observed). + +`specVersion` is deliberately **excluded** from the parity set: it is the +per-family contract version by construction, and the strictness bar pins it to +the `0.1.0` const instead. Rationale for structural profiles over `allOf` +composition is unchanged from `specs/mpcc-event-contract.md` §2 — this repo +requires `additionalProperties: false` everywhere, which `allOf` envelope reuse +cannot express in draft 2020-12 without `unevaluatedProperties`. + +**`unit` is stated on both the profile and every reading, on purpose.** The +redundancy is the point: consumers read readings, not profiles, and a value +whose unit must be looked up elsewhere is a unit-confusion incident waiting for +a deployment. The validator enforces the equality, so the redundancy cannot +become a disagreement. + +## 5. Quality is closed, and absence is typed + +`quality` is a closed five-value enum: + +| Value | Meaning | +|-------|---------| +| `ok` | Measured, within specification. | +| `degraded` | Measured, but the driver has cause to doubt it. | +| `stale` | A cached prior value re-reported because the device did not answer in time. | +| `substituted` | A value supplied by something other than the device (a default, an interpolation). | +| `unavailable` | No value. The device produced nothing. | + +Normative: **`stale` and `substituted` are not `ok`, and `substituted` is not +measured.** Conflating them is how a smart-home twin comes to believe a default +is a measurement, and then how a health twin does. + +`unavailable` is schema-bound (`if`/`then`/`else`) to `value: null` **and** a +non-null `nullAbsenceRef` pointing at a `NullAbsenceRecord`. The device plane +does not get its own absence taxonomy: it reuses the existing 12-kind MPCC one, +so a `timeout` is never conflated with a `transport_failure` or an +`intentional_silence`. Symmetrically, a non-`unavailable` reading may not carry +a null value — an untyped hole wearing a measured quality is exactly the defect +the taxonomy exists to prevent. + +## 6. Three timestamps, because they measure three different things + +`observedAt` (the instant at the sensor) → `receivedAt` (arrival at the +DeviceService) → `wallTime` (the producer's event time on the envelope). +`observedAt` → `receivedAt` **is** the southbound latency a twin's sync budget is +actually spent on. Collapsing them into one field hides the only number that +would tell you whether a Δt target is met, which is why the family carries all +three and the validator enforces `receivedAt >= observedAt`. + +Normative: that ordering is over **instants, not strings**. `format: date-time` +admits the same instant written several ways (`...T09:15:00Z` == +`...T11:15:00+02:00`), and lexicographically `...:00.500Z` sorts *before* +`...:00Z` while being 500ms later. A conformance tool that compares the two +timestamps as text is a check that cannot fail for precisely the documents most +likely to be wrong, so implementations MUST parse both to an absolute instant +and MUST refuse a pair that cannot be parsed to one (an offset is required — +a naive stamp is not an instant). + +## 7. Simulated devices are first-class and must stay visible + +`protocol: "virtual"` denotes a simulated device with no physical counterpart. +It is a **named member of the taxonomy**, not an omission, and the validator +enforces the labelling in both directions: a virtual profile must carry the +`synthetic:simulated-device` policy label, a physical one may not, and every +reading must agree with its profile. + +This is the `KnowledgeNugget` `model-generated` rule applied to sensors: +generated data that is indistinguishable downstream is worse than no data, +because it is *believed*. A simulated reading that reaches a twin unmarked is a +fabricated measurement. + +## 8. Reading only, at v0.1 + +`metrics[].access` is closed to `"read"`. Commanding a device is a +world-changing effect and must travel the MPCC `EffectRequest` → +`EffectDecision` → `EffectRecord` lifecycle. Admitting actuation through a +widened enum here would hand every protocol driver unreviewed physical +authority over a citizen's home — the southbound *read* plane is deliberately +the only thing this contract grants. + +## 9. Overlap decisions (spec-first conformance) + +| Existing contract | Decision | +|-------------------|----------| +| `DeviceIdentity` | **Not duplicated, and not the same thing.** `DeviceIdentity` governs admission, attestation and trust for a SourceOS operator workstation; it says nothing about metrology. `DeviceProfile.identityRef` points at the registered host that owns the southbound link, binding the two families instead of competing. A negative vector pins this: a reading may not attribute itself to a `urn:srcos:device-identity:` URN. | +| `TelemetryEvent` | Distinct concern: a diagnostic/log event emitted during an `AgentSession` (`sessionRef` is required). It is software telemetry, not a physical observation, and has no unit, range, quality or device. Untouched. | +| `ConversationEvent` | The envelope authority. `DeviceReading` profiles it (§4). | +| `NullAbsenceRecord` | Reused verbatim for `unavailable` readings (§5). No device-local absence vocabulary exists. | +| `EffectRequest` / `EffectDecision` | The path any future device *command* must travel (§8). Not duplicated here. | +| `KnowledgeNugget` | Shares the `kkoTypeRef` ontology-URI vocabulary, so a reading is graph-typed on the same terms as a content grain. A reading is not a nugget: it is an observation, not a warrant-typed assertion about a document. | +| Fog plane (`Topic`/`Offer`/`WorkOrder`/`Receipt`/`Settlement`) | No overlap despite the shared word. That family is the FogCompute *marketplace*; this is sensor fog. | +| `EventEnvelope` | Unchanged wire wrapper; `DeviceReading` is a payload-plane domain object that rides inside it. | + +## 10. Versioning + +The family versions as one contract, pinned by the `specVersion` const `0.1.0`. +Additive optional fields or widened enums bump the minor; anything that can +invalidate an existing document bumps the major, with CHANGELOG + ADR per +CONTRIBUTING.md. + +## 11. Known gaps (deliberate, v0.1) + +- **No `Device` instance registry.** `DeviceProfile` describes a device *model*; + `DeviceReading.deviceRef` names an *instance* by a `urn:srcos:device:` URN + that has no schema of its own yet. In v0.1 the DeviceService owns the + instance→profile binding in its commissioned-device table, and the reading + makes that binding auditable by carrying both refs plus the digest. A typed + `Device` registry (admin/operating state, last-seen, commissioning receipt, + instance→profile binding checkable *outside* the producing service) is the + first follow-on. This is the same house convention as `ConversationEvent`'s + free-form `actorRef`/`workspaceRef`/`branchRef` — a stable reference now, a + typed contract when the names have settled. +- **No command/actuation path** (§8), by design. +- **No device discovery or provisioning contract** — how a device comes to have + a profile is out of scope; the profile is an input at v0.1. +- **No OpenAPI/AsyncAPI operations or semantic-context mappings yet**, matching + how the MPCC, KnowledgeNugget and A/B-update families landed; wiring follows + once names have settled. +- **Ontology typing is coarser than the metric.** `kkoTypeRef` cites the KKO + upper ontology (`http://kbpedia.org/ontologies/kko#`), which is the ontology + this estate actually vendors — 169 terms, verified. The ~58k KBpedia + reference-concept layer that would carry a concept as specific as + *temperature* is **not** vendored, so a numeric metric types as `Quantity` + and a boolean state as `States`, with the metric name and unit carrying the + specificity. Citing `.../kko/rc/Temperature` would look more precise and be + an unresolvable reference — the same class of silent-wrong this contract + exists to prevent. Nothing currently resolves these IRIs against a loaded + KKO either; the platform-wide TBox binding is a separate tracked gap + (`nugget-extractor` states the same). +- **No aggregate/derived-reading type.** Downsampling, windowing and unit + conversion produce new observations whose provenance rules are not yet stated; + emitting them as `DeviceReading`s would launder a computation into a + measurement. diff --git a/tools/validate_device_service_examples.py b/tools/validate_device_service_examples.py new file mode 100644 index 0000000..0dbc54a --- /dev/null +++ b/tools/validate_device_service_examples.py @@ -0,0 +1,416 @@ +#!/usr/bin/env python3 +"""Validate the DeviceService contract family (W8.7 — the southbound device plane). + +Seven checks, not one: + 1. schema conformance — both schemas are valid draft-2020-12 documents and + every canonical example validates against its schema; + 2. strictness bar — both schemas hold the tranche-0001 bar: top-level + "additionalProperties": false, specVersion pinned to the 0.1.0 const, an + anchored urn:srcos: id pattern, and a type const equal to the title; + 3. envelope parity — DeviceReading carries the ConversationEvent envelope + vocabulary with byte-identical sub-schemas, and DeviceProfile carries the + registry subset of it, so the device plane can never drift into a second + identity/causality/governance vocabulary. specVersion is deliberately + excluded: it is per-family by construction (check 2 pins it instead); + 4. recomputed profile digest — DeviceProfile.definitionDigest is RECOMPUTED + from the profile's own declared-capability projection, never read back. A + digest that is merely stored is an assertion about pinning; a digest that + is recomputed is pinning; + 5. attribution soundness — the check this family exists for. Every reading + must resolve to a real profile, to the exact profile REVISION it claims + (digest equality), to a metric that profile declares, and to that metric's + declared unit, source address, value type, ontology type and operating + range. A reading that survives all five is attributable; one that does not + is the silent-wrong the contract is meant to make impossible; + 6. simulated-visibility — any reading produced under a "virtual" profile must + carry a label marking it as not a measurement, and no reading produced + under a physical profile may carry one. Simulated data that is + indistinguishable downstream is worse than no data; + 7. negative vectors — every case in fixtures/device-service/conformance.json + FAILS for its stated reason. +""" +from __future__ import annotations + +import hashlib +import json +import sys +from datetime import datetime, timezone +from pathlib import Path + +import jsonschema + +ROOT = Path(__file__).resolve().parents[1] + +PROFILE_SCHEMA = "DeviceProfile.json" +READING_SCHEMA = "DeviceReading.json" +ABSENCE_SCHEMA = "NullAbsenceRecord.json" +ENVELOPE_AUTHORITY = "ConversationEvent.json" + +SCHEMA_NAMES = [PROFILE_SCHEMA, READING_SCHEMA] + +PAIRS = [ + (PROFILE_SCHEMA, "device_profile.json"), + (PROFILE_SCHEMA, "device_profile.virtual.json"), + (READING_SCHEMA, "device_reading.json"), + (READING_SCHEMA, "device_reading.unavailable.json"), + (ABSENCE_SCHEMA, "null_absence_record.device_timeout.json"), +] + +PROFILE_EXAMPLES = ["device_profile.json", "device_profile.virtual.json"] +READING_EXAMPLES = ["device_reading.json", "device_reading.unavailable.json"] +ABSENCE_EXAMPLES = ["null_absence_record.device_timeout.json"] + +# The shared envelope vocabulary. ConversationEvent is the authority. +# specVersion is excluded on purpose: it is the per-family contract version and +# is pinned by the strictness bar instead. +READING_ENVELOPE_KEYS = [ + "actorRef", + "workspaceRef", + "branchRef", + "visibilityScope", + "wallTime", + "logicalTime", + "causalParents", + "traceContext", + "provenanceLinks", + "policyLabels", + "riskLabels", +] + +# A profile is declared, not observed: it carries the registry subset (no event +# time, no causality) with the same byte-identical sub-schemas. +PROFILE_ENVELOPE_KEYS = [ + "actorRef", + "workspaceRef", + "visibilityScope", + "provenanceLinks", + "policyLabels", + "riskLabels", +] + +# Normative digest projection. Exactly the fields that decide which readings are +# admissible; prose, labels and timestamps are excluded so documentation edits do +# not orphan live readings. +DIGEST_FIELDS = ["deviceClass", "protocol", "metrics"] + +# The label that keeps simulated data visibly distinguishable downstream. +SIMULATED_LABEL = "synthetic:simulated-device" + +FAILURES: list[str] = [] +CHECKS: dict[str, bool] = {} + + +def fail(msg: str) -> None: + FAILURES.append(msg) + + +def load(path: Path) -> dict: + return json.loads(path.read_text(encoding="utf-8")) + + +def instant(value: str) -> datetime | None: + """Parse an RFC3339 `date-time` to an absolute instant, or None if it is not + one. Comparing these as STRINGS is wrong and silently so: the schema pins + `format: date-time` with no `pattern`, so `2026-07-29T09:15:00Z` and + `2026-07-29T11:15:00+02:00` are the same instant written two ways, and + `...:00.500Z` sorts BEFORE `...:00Z` lexicographically ('.' < 'Z') while + being 500ms later. An ordering check built on string compare is a check that + cannot fail for exactly the documents most likely to be wrong.""" + text = value.strip() + if len(text) > 10 and text[10] in "tT": # RFC3339 permits a lowercase separator + text = text[:10] + "T" + text[11:] + if text.endswith(("Z", "z")): + text = text[:-1] + "+00:00" + try: + parsed = datetime.fromisoformat(text) + except ValueError: + return None + if parsed.tzinfo is None: + # RFC3339 requires an offset. A naive stamp is not an instant; refuse it + # rather than assume UTC and compare two different clocks. + return None + return parsed.astimezone(timezone.utc) + + +def definition_digest(profile: dict) -> str: + """Recompute DeviceProfile.definitionDigest. Normative: sha256 over the + canonical JSON (sorted keys, no whitespace, UTF-8) of the DIGEST_FIELDS + projection of the document AS WRITTEN — schema defaults are not + materialised, so an omitted field and an explicitly-defaulted one are + different declarations and hash differently.""" + core = {field: profile[field] for field in DIGEST_FIELDS} + canonical = json.dumps(core, sort_keys=True, separators=(",", ":"), ensure_ascii=False) + return "sha256:" + hashlib.sha256(canonical.encode("utf-8")).hexdigest() + + +# --------------------------------------------------------------- 1. conformance +def check_conformance(schemas: dict[str, dict]) -> None: + for name, schema in schemas.items(): + try: + jsonschema.Draft202012Validator.check_schema(schema) + CHECKS[f"schema-valid:{name}"] = True + except jsonschema.SchemaError as exc: + fail(f"schema invalid: {name}: {exc.message}") + + for schema_name, example_name in PAIRS: + schema = schemas[schema_name] + example = load(ROOT / "examples" / example_name) + errors = sorted(jsonschema.Draft202012Validator(schema).iter_errors(example), key=str) + if errors: + for err in errors: + fail(f"example {example_name} vs {schema_name}: {err.message}") + else: + CHECKS[f"example:{example_name}"] = True + + +# ---------------------------------------------------------------- 2. strictness +def check_strictness(schemas: dict[str, dict]) -> None: + for name in SCHEMA_NAMES: + schema = schemas[name] + if schema.get("additionalProperties") is not False: + fail(f"{name}: top-level additionalProperties must be false") + if schema["properties"]["specVersion"].get("const") != "0.1.0": + fail(f"{name}: specVersion must be pinned to const 0.1.0") + pattern = schema["properties"]["id"].get("pattern", "") + if not (pattern.startswith("^urn:srcos:") and pattern.endswith("$")): + fail(f"{name}: id pattern must be an anchored urn:srcos: pattern") + if schema["properties"]["type"].get("const") != schema["title"]: + fail(f"{name}: type const must equal title") + CHECKS[f"strictness:{name}"] = True + + +# ------------------------------------------------------------ 3. envelope parity +def check_envelope_parity(schemas: dict[str, dict]) -> None: + authority = load(ROOT / "schemas" / ENVELOPE_AUTHORITY)["properties"] + for name, keys in ((READING_SCHEMA, READING_ENVELOPE_KEYS), (PROFILE_SCHEMA, PROFILE_ENVELOPE_KEYS)): + props = schemas[name]["properties"] + for key in keys: + if key not in props: + fail(f"{name}: missing shared envelope property {key!r}") + elif props[key] != authority[key]: + fail( + f"{name}: envelope property {key!r} drifted from ConversationEvent " + f"— one envelope, not a second vocabulary" + ) + else: + CHECKS[f"envelope-parity:{name}:{key}"] = True + + +# ------------------------------------------------------------- 4. digest recompute +def check_profile_digests(profiles: dict[str, dict]) -> None: + for name, profile in profiles.items(): + recomputed = definition_digest(profile) + if profile["definitionDigest"] != recomputed: + fail( + f"{name}: definitionDigest is not the digest of its own declared capability " + f"(stored {profile['definitionDigest']}, recomputed {recomputed})" + ) + else: + CHECKS[f"digest-recomputed:{name}"] = True + + # Two different declarations must not share a digest, or pinning proves nothing. + seen: dict[str, str] = {} + for name, profile in profiles.items(): + digest = profile["definitionDigest"] + if digest in seen: + fail(f"{name}: shares definitionDigest with {seen[digest]} — a pin that cannot distinguish revisions") + seen[digest] = name + CHECKS["digest-distinct"] = True + + +# -------------------------------------------------------- 5. attribution soundness +def check_attribution(profiles: dict[str, dict], readings: dict[str, dict], absences: dict[str, dict]) -> None: + by_urn = {p["id"]: p for p in profiles.values()} + absence_by_urn = {a["id"]: a for a in absences.values()} + + for name, reading in readings.items(): + profile = by_urn.get(reading["deviceProfileRef"]) + if profile is None: + fail(f"{name}: deviceProfileRef {reading['deviceProfileRef']} resolves to no profile in the example set") + continue + CHECKS[f"attribution:{name}:profile-resolves"] = True + + if reading["profileDigest"] != definition_digest(profile): + fail( + f"{name}: profileDigest does not match the recomputed digest of " + f"{profile['id']} — the reading was admitted against a different revision " + f"than the one it names" + ) + else: + CHECKS[f"attribution:{name}:digest-pins-revision"] = True + + declared = {m["metric"]: m for m in profile["metrics"]} + metric = declared.get(reading["metric"]) + if metric is None: + fail(f"{name}: metric {reading['metric']!r} is not declared by {profile['id']}") + continue + CHECKS[f"attribution:{name}:metric-declared"] = True + + if reading["unit"] != metric["unit"]: + fail( + f"{name}: unit {reading['unit']!r} contradicts the profile's declared " + f"{metric['unit']!r} for {reading['metric']} — a unit-confusion incident in waiting" + ) + else: + CHECKS[f"attribution:{name}:unit-agrees"] = True + + if reading["sourceAddress"] != metric["sourceAddress"]: + fail( + f"{name}: sourceAddress {reading['sourceAddress']!r} is not the channel the " + f"profile declares for {reading['metric']} ({metric['sourceAddress']!r})" + ) + else: + CHECKS[f"attribution:{name}:channel-agrees"] = True + + # Matches the reference implementation (prophet-platform device-service + # contract.py): if the PROFILE declares an ontology type, the reading must + # carry that same type. A reading that simply omits kkoTypeRef does not get + # to skip the check — a laxer conformance tool than the implementation it + # certifies would green-light documents the implementation rejects. + if metric.get("kkoTypeRef") and reading.get("kkoTypeRef") != metric["kkoTypeRef"]: + fail( + f"{name}: kkoTypeRef {reading.get('kkoTypeRef')!r} disagrees with the ontology type " + f"{metric['kkoTypeRef']!r} the profile declares for {reading['metric']}" + ) + else: + CHECKS[f"attribution:{name}:ontology-agrees"] = True + + # value typing + range, against the declaration rather than a runtime guess + value = reading["value"] + quality = reading["quality"] + if quality == "unavailable": + if value is not None: + fail(f"{name}: unavailable reading carries a value") + ref = reading.get("nullAbsenceRef") + if not ref: + fail(f"{name}: unavailable reading carries no nullAbsenceRef") + elif ref not in absence_by_urn: + fail(f"{name}: nullAbsenceRef {ref} resolves to no NullAbsenceRecord in the example set") + elif absence_by_urn[ref].get("relatedEventRef") != reading["id"]: + fail(f"{name}: the referenced NullAbsenceRecord does not point back at this reading") + else: + CHECKS[f"attribution:{name}:absence-typed"] = True + else: + expected = metric["valueType"] + ok_type = { + "number": isinstance(value, (int, float)) and not isinstance(value, bool), + "integer": isinstance(value, int) and not isinstance(value, bool), + "boolean": isinstance(value, bool), + "string": isinstance(value, str), + }[expected] + if not ok_type: + fail(f"{name}: value {value!r} is not the declared {expected} for {reading['metric']}") + else: + CHECKS[f"attribution:{name}:value-type"] = True + if expected in ("number", "integer"): + lo, hi = metric["minimum"], metric["maximum"] + if not (lo <= value <= hi): + fail(f"{name}: value {value!r} is outside the declared range [{lo}, {hi}]") + else: + CHECKS[f"attribution:{name}:value-in-range"] = True + + # Compare INSTANTS, never strings (see instant()). Only record the check + # as performed when it actually was — a green "latency-ordered" on a + # reading that carries no receivedAt is a check reporting on nothing. + received = reading.get("receivedAt") + if received: + observed_at, received_at = instant(reading["observedAt"]), instant(received) + if observed_at is None or received_at is None: + fail( + f"{name}: observedAt/receivedAt must be RFC3339 instants with an offset " + f"({reading['observedAt']!r} -> {received!r}); an unorderable pair cannot be checked" + ) + elif received_at < observed_at: + fail( + f"{name}: receivedAt precedes observedAt by " + f"{(observed_at - received_at).total_seconds()}s — the reading arrived " + f"before it was observed" + ) + else: + CHECKS[f"attribution:{name}:latency-ordered"] = True + + # A reading must also *say* where it came from, not only be checkable. + rels = {link["ref"] for link in reading.get("provenanceLinks", [])} + if profile["id"] not in rels or reading["deviceRef"] not in rels: + fail( + f"{name}: provenanceLinks must name both the device and the profile, so the " + f"attribution survives being read without this validator" + ) + else: + CHECKS[f"attribution:{name}:provenance-stated"] = True + + # The example set must exercise both poles, or the absence path is untested prose. + qualities = {r["quality"] for r in readings.values()} + if "ok" not in qualities or "unavailable" not in qualities: + fail("example set must contain at least one measured (ok) and one typed-absence (unavailable) reading") + else: + CHECKS["attribution:both-poles-exercised"] = True + + +# ---------------------------------------------------- 6. simulated visibility +def check_simulated_visibility(profiles: dict[str, dict], readings: dict[str, dict]) -> None: + by_urn = {p["id"]: p for p in profiles.values()} + for name, profile in profiles.items(): + simulated = profile["protocol"] == "virtual" + labelled = SIMULATED_LABEL in profile.get("policyLabels", []) + if simulated and not labelled: + fail(f"{name}: a virtual profile must carry the {SIMULATED_LABEL!r} policy label") + elif labelled and not simulated: + fail(f"{name}: only a virtual profile may claim the {SIMULATED_LABEL!r} label") + else: + CHECKS[f"simulated-visibility:{name}"] = True + + for name, reading in readings.items(): + profile = by_urn.get(reading["deviceProfileRef"]) + if profile is None: + continue + simulated = profile["protocol"] == "virtual" + labels = set(reading.get("policyLabels", [])) | set(reading.get("riskLabels", [])) + if simulated and SIMULATED_LABEL not in labels: + fail( + f"{name}: produced under a virtual profile but carries no {SIMULATED_LABEL!r} " + f"label — simulated data must stay visibly distinguishable downstream" + ) + elif not simulated and SIMULATED_LABEL in labels: + fail(f"{name}: a physically-measured reading must not be labelled simulated") + else: + CHECKS[f"simulated-visibility:{name}"] = True + + +# ------------------------------------------------------------ 7. negative vectors +def check_negative_vectors(schemas: dict[str, dict]) -> None: + fixture = load(ROOT / "fixtures" / "device-service" / "conformance.json") + for i, case in enumerate(fixture["cases"]): + schema = schemas[case["schema"]] + try: + jsonschema.validate(case["document"], schema) + except jsonschema.ValidationError: + CHECKS[f"negative:{i}:{case['schema']}"] = True + continue + fail(f"negative vector {i} ({case['schema']}) unexpectedly PASSED: {case['reason']}") + + +def main() -> int: + schemas = {name: load(ROOT / "schemas" / name) for name in SCHEMA_NAMES + [ABSENCE_SCHEMA]} + profiles = {n: load(ROOT / "examples" / n) for n in PROFILE_EXAMPLES} + readings = {n: load(ROOT / "examples" / n) for n in READING_EXAMPLES} + absences = {n: load(ROOT / "examples" / n) for n in ABSENCE_EXAMPLES} + + check_conformance(schemas) + check_strictness(schemas) + check_envelope_parity(schemas) + check_profile_digests(profiles) + check_attribution(profiles, readings, absences) + check_simulated_visibility(profiles, readings) + check_negative_vectors(schemas) + + for msg in FAILURES: + print(f"FAIL: {msg}", file=sys.stderr) + + ok = not FAILURES and all(CHECKS.values()) + print(json.dumps({"ok": ok, "checks": CHECKS}, indent=2, sort_keys=True)) + return 0 if ok else 1 + + +if __name__ == "__main__": + raise SystemExit(main())