From 5953685ffb1ca88496a975bff648061ed6a1e4ab Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Fri, 17 Apr 2026 12:35:27 -0400 Subject: [PATCH] =?UTF-8?q?Seed=20the=20canonical=20OT=20schemas=20content?= =?UTF-8?q?=20under=203yearplan/schemas/=20as=20a=20temporary=20location?= =?UTF-8?q?=20until=20a=20dedicated=20`schemas`=20repo=20is=20created=20(G?= =?UTF-8?q?itea=20push-to-create=20is=20disabled,=20the=20dedicated=20repo?= =?UTF-8?q?=20needs=20a=20manual=20UI=20step).=20Initial=20seed=20contribu?= =?UTF-8?q?ted=20by=20the=20OtOpcUa=20team=20to=20unblock=20the=20Equipmen?= =?UTF-8?q?tClassRef=20integration=20timeline=20(lmxopcua=20decision=20#11?= =?UTF-8?q?2)=20and=20to=20provide=20the=20future=20cross-team=20owner=20w?= =?UTF-8?q?ith=20a=20concrete=20starting=20point=20rather=20than=20a=20bla?= =?UTF-8?q?nk=20slate.=20Marked=20DRAFT=20throughout=20with=20prominent=20?= =?UTF-8?q?"ownership=20TBD"=20framing=20in=20README=20and=20CONTRIBUTING?= =?UTF-8?q?=20=E2=80=94=20the=20future=20owner=20team=20should=20treat=20t?= =?UTF-8?q?his=20seed=20as=20a=20starting=20point=20and=20revise=20format?= =?UTF-8?q?=20/=20structure=20/=20naming=20as=20the=20open=20questions=20i?= =?UTF-8?q?n=20README=20"Open=20Questions"=20get=20resolved.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Includes: README explaining purpose / scope / temporary-location framing / format decision, CONTRIBUTING.md with proposed workflow + per-class semver versioning policy + validation commands, format/equipment-class.schema.json defining the shape of a class template (classId, version, displayName, applicability, signals, alarms, optional stateModel), format/tag-definition.schema.json defining the shape of a single canonical signal (name, dataType, category, unit, isArray, accessLevel, writeIdempotent, isHistorized, scaling), format/uns-subtree.schema.json defining the shape of a per-site UNS subtree (enterprise + site + areas + lines), classes/fanuc-cnc.json as the worked pilot class with 16 signals + 3 alarms + suggested state-derivation notes (per OtOpcUa corrections doc D1), uns/example-warsaw-west.json as a worked UNS subtree example, docs/overview.md (what / why / lifecycle / what's NOT in this repo), docs/format-decisions.md (8 numbered decisions covering JSON Schema choice per corrections D2, per-class semver, additive-only minor bumps, _default placeholder reservation, signal-name vs UNS-segment regex distinction, stateModel-as-informational, no per-equipment overrides at this layer, applicability.drivers as OtOpcUa driver enumeration), docs/consumer-integration.md (how OtOpcUa / Redpanda / dbt each integrate). $id URLs in the JSON schemas resolve at the actual current path so validators don't 404. Top-level README adds a row to the Component Detail Files table pointing to schemas/. Corrections doc B2 (schemas-repo dependencies) marked partially RESOLVED with the seed location and a list of what still needs the plan team or cross-team owner to decide (owner team naming, dedicated repo migration, format-decision ratification, FANUC CNC pilot confirmation, CI gate setup, Redpanda + dbt consumer integration plumbing). Co-Authored-By: Claude Opus 4.7 (1M context) --- README.md | 1 + handoffs/otopcua-corrections-2026-04-17.md | 16 +++- schemas/CONTRIBUTING.md | 55 +++++++++++++ schemas/README.md | 81 +++++++++++++++++++ schemas/classes/fanuc-cnc.json | 87 ++++++++++++++++++++ schemas/docs/consumer-integration.md | 50 ++++++++++++ schemas/docs/format-decisions.md | 56 +++++++++++++ schemas/docs/overview.md | 42 ++++++++++ schemas/format/equipment-class.schema.json | 93 ++++++++++++++++++++++ schemas/format/tag-definition.schema.json | 83 +++++++++++++++++++ schemas/format/uns-subtree.schema.json | 64 +++++++++++++++ schemas/uns/example-warsaw-west.json | 24 ++++++ 12 files changed, 651 insertions(+), 1 deletion(-) create mode 100644 schemas/CONTRIBUTING.md create mode 100644 schemas/README.md create mode 100644 schemas/classes/fanuc-cnc.json create mode 100644 schemas/docs/consumer-integration.md create mode 100644 schemas/docs/format-decisions.md create mode 100644 schemas/docs/overview.md create mode 100644 schemas/format/equipment-class.schema.json create mode 100644 schemas/format/tag-definition.schema.json create mode 100644 schemas/format/uns-subtree.schema.json create mode 100644 schemas/uns/example-warsaw-west.json diff --git a/README.md b/README.md index a2dcb04..f162b99 100644 --- a/README.md +++ b/README.md @@ -41,6 +41,7 @@ The plan also declares a **Unified Namespace (UNS)** composed of OtOpcUa + Redpa | [`current-state/legacy-integrations.md`](current-state/legacy-integrations.md) | Pillar 3 denominator: 3 legacy IT/OT integrations to retire | | ~~`current-state/equipment-protocol-survey.md`~~ | Removed — protocol survey no longer needed; OtOpcUa v2 team committed driver list directly | | [`goal-state/digital-twin-management-brief.md`](goal-state/digital-twin-management-brief.md) | Digital twin management conversation brief (completed) | +| [`schemas/`](schemas/) | Canonical OT equipment definitions (DRAFT seed contributed by OtOpcUa team — UNS hierarchy + equipment-class templates + format JSON Schemas + worked FANUC CNC pilot). Temporary location until a dedicated `schemas` repo is created and an owner team is named — see `schemas/README.md` | ### Output Generation diff --git a/handoffs/otopcua-corrections-2026-04-17.md b/handoffs/otopcua-corrections-2026-04-17.md index 5c495b7..60490d4 100644 --- a/handoffs/otopcua-corrections-2026-04-17.md +++ b/handoffs/otopcua-corrections-2026-04-17.md @@ -103,9 +103,23 @@ The v2 design ships `Equipment.EquipmentClassRef` as a nullable hook column (per **Plan should say instead:** The plan should make the schemas-repo dependency explicit on the OtOpcUa critical path: - Schemas repo creation should be a Year 1 deliverable (its own handoff doc, distinct from OtOpcUa's) - Until it exists, OtOpcUa equipment configurations are hand-curated and prone to drift -- The Equipment Protocol Survey output should feed both: (a) OtOpcUa core driver scope, and (b) the initial schemas repo equipment-class list - A pilot equipment class (proposed: FANUC CNC, see D1 below) should land in the schemas repo before tier 1 cutover begins, to validate the template-consumer contract end-to-end +**Resolution — partial** (2026-04-17): the OtOpcUa team contributed an initial seed at `3yearplan/schemas/` (temporary location until the dedicated `schemas` repo is created — Gitea push-to-create is disabled and the dedicated repo needs a manual UI step). The seed includes: +- README + CONTRIBUTING explaining purpose, scope, ownership-TBD framing, format decision, and proposed workflow +- JSON Schemas defining the format (`format/equipment-class.schema.json`, `format/tag-definition.schema.json`, `format/uns-subtree.schema.json`) +- The pilot equipment class as a worked example (`classes/fanuc-cnc.json` — 16 signals + 3 alarm definitions + suggested state-derivation notes, per D1) +- A worked UNS subtree (`uns/example-warsaw-west.json`) +- Documentation: `docs/overview.md`, `docs/format-decisions.md` (8 numbered decisions), `docs/consumer-integration.md` + +What still needs the plan team / cross-team owner: +- Name an owner team for the schemas content +- Decide whether to move it to a dedicated `gitea.dohertylan.com/dohertj2/schemas` repo (proposed; would be cleaner than living under `3yearplan/`) or keep it as a 3-year-plan sub-tree +- Ratify or revise the format decisions in `schemas/docs/format-decisions.md` (8 items including JSON Schema choice, per-class semver, additive-only minor bumps, `_default` placeholder, signal-name vs UNS-segment regex distinction, stateModel-as-informational, no per-equipment overrides at this layer, applicability.drivers as OtOpcUa driver enumeration) +- Confirm the FANUC CNC pilot is the right starting point (D1 recommendation) +- Establish the CI gate for JSON Schema validation +- Decide on consumer-integration plumbing for Redpanda Protobuf code-gen and dbt macro generation per `schemas/docs/consumer-integration.md` + --- ### B3. Per-node `ApplicationUri` uniqueness and trust-pinning is an OPC UA spec constraint with operational implications diff --git a/schemas/CONTRIBUTING.md b/schemas/CONTRIBUTING.md new file mode 100644 index 0000000..5c1e883 --- /dev/null +++ b/schemas/CONTRIBUTING.md @@ -0,0 +1,55 @@ +# Contributing + +> Initial seed by the OtOpcUa team. Future ownership TBD — the contribution process below is a starting suggestion the schemas-repo owner team should ratify or revise. + +## Workflow (proposed) + +1. **Open an issue first** for any new equipment class, UNS subtree, or format change. Describe the use case + the consumer(s) that need it. +2. **Branch + PR** — work on a feature branch, open a PR against `main`. +3. **CI gate** validates every JSON file against the schema in `format/`. +4. **Review** — at least one schemas-repo maintainer + one consumer-team representative (OtOpcUa, Redpanda, or dbt depending on what changed). +5. **Merge + tag** — merge to `main` and create a semver tag. Consumers pin to tags. + +## Adding a new equipment class + +1. Create `classes/{class-id}.json` (lowercase hyphenated). +2. Reference the class schema: `"$schema": "../format/equipment-class.schema.json"`. +3. Fill in: `classId`, `version` (start at `0.1.0`), `displayName`, `vendor`, `applicability`, `signals`, optionally `alarms` and `stateModel`. +4. Validate locally — the CI gate will reject malformed JSON or schema violations. +5. Open the PR with a brief description of which equipment populations are covered. + +## Adding a new UNS subtree + +1. Create `uns/{site-name}.json`. +2. Reference the schema: `"$schema": "../format/uns-subtree.schema.json"`. +3. Fill in: `enterprise` (must match the org-wide value), `site`, `displayName`, `areas` with `lines`. +4. Coordinate with the OtOpcUa team — every cluster at the site must declare its `Enterprise` and `Site` matching this file. + +## Format changes + +Editing files in `format/` is a breaking change for downstream consumers. Process: + +1. Open an issue with the proposed change + rationale. +2. Notify all consumer teams (OtOpcUa, Redpanda, dbt, anyone else listed in `docs/consumer-integration.md`). +3. Get explicit signoff from each before merging. +4. Bump the major version of every affected class file simultaneously (consumers use this to detect breaking changes). + +## Validation + +```bash +# Per-file validation +ajv validate -s format/equipment-class.schema.json -d classes/fanuc-cnc.json +ajv validate -s format/uns-subtree.schema.json -d uns/example-warsaw-west.json + +# Bulk validate everything +for f in classes/*.json; do ajv validate -s format/equipment-class.schema.json -d "$f"; done +for f in uns/*.json; do ajv validate -s format/uns-subtree.schema.json -d "$f"; done +``` + +## Versioning policy (proposed) + +- **Major bump** (`0.x.y` → `1.x.y`) — breaking change: signal removed, signal renamed, dataType changed, isRequired changed false→true, alarm removed +- **Minor bump** (`x.0.y` → `x.1.y`) — additive non-breaking: new optional signal, new alarm, new state in stateModel, displayName/description updates +- **Patch bump** (`x.y.0` → `x.y.1`) — documentation/clarification only: description text updates, examples added, no semantic change + +Consumers pin to a major.minor in production; staging environments can track `main`. diff --git a/schemas/README.md b/schemas/README.md new file mode 100644 index 0000000..bf73d8c --- /dev/null +++ b/schemas/README.md @@ -0,0 +1,81 @@ +# schemas — Canonical OT Equipment Definitions + +> **Status**: DRAFT — initial seed contributed by the OtOpcUa team (`lmxopcua/docs/v2/acl-design.md`, `lmxopcua/docs/v2/plan.md` decisions #112, #115, corrections doc D1+D2). **Ownership of this content is TBD** — should be assigned to a cross-team authority since it's consumed by OT and IT systems alike. The future owner team should treat this seed as a starting point, not a finished spec. +> +> **Temporary location**: this seed lives at `3yearplan/schemas/` as a sub-tree of the 3-year-plan repo because Gitea's push-to-create is disabled and creating the dedicated `schemas` repo requires a manual UI step. Once the dedicated repo is created (proposed: `gitea.dohertylan.com/dohertj2/schemas`) and an owner team is named, this content should migrate over and references update accordingly. Until then, treat this directory as the canonical location for the seed. + +## Purpose + +Single source of truth for the org's canonical OT equipment definitions: + +1. **UNS hierarchy** — the per-site Unified Namespace subtree (Enterprise / Site / Area / Line / Equipment) per the 3-year-plan handoff §UNS Naming Hierarchy +2. **Equipment-class templates** — per-class declarations of which raw signals each equipment type exposes (FANUC CNCs surface these; Modbus PLCs surface those; etc.) +3. **Type vocabulary** — the canonical machine-state values (`Running` / `Idle` / `Faulted` / `Starved` / `Blocked`) and any other shared enumerations consumers need + +## Who consumes this + +Three surfaces per the 3-year-plan handoff §"Canonical Model Integration": + +| Consumer | How | +|----------|-----| +| **OtOpcUa equipment namespace** | At deploy/config time, OtOpcUa nodes fetch the equipment-class template referenced by `Equipment.EquipmentClassRef` and use it to validate the operator-configured tag set. Drift = config validation error | +| **Redpanda topics + Protobuf schemas** | Equipment-class templates derive Protobuf message definitions for canonical events (`equipment.state.transitioned`, etc.) | +| **dbt curated layer in Snowflake** | Same templates derive column definitions and dimension tables for the curated analytics model | + +OtOpcUa is one consumer of three. Decisions about format, structure, and naming live with the schemas-repo owner team (TBD), not with any one consumer. + +## Format + +**JSON Schema (Draft 2020-12) authored in this repo; Protobuf code-generated for wire serialization where needed** (per OtOpcUa corrections doc D2 recommendation, blessed by the OtOpcUa team but pending schemas-repo team review). + +Why JSON Schema as the authoring format: +- Idiomatic for .NET (System.Text.Json + JsonSchema.Net) — OtOpcUa reads templates with no extra dependencies +- Idiomatic for CI tooling (every CI runner can `jq` / validate JSON Schema without extra toolchain) +- Supports validation at multiple layers: operator-visible Admin UI errors in OtOpcUa, schemas-repo CI gates, downstream consumer runtime validation +- Better authoring experience than Protobuf (binary format, .proto compiler, poor merge story) +- Where wire-format efficiency matters (Redpanda events), code-generate Protobuf from the JSON Schema source. One-way derivation is simpler than bidirectional sync. + +## Structure + +``` +schemas/ +├── README.md This file +├── CONTRIBUTING.md How to add a new class, validate, PR process +├── format/ JSON Schemas defining the format of everything below +│ ├── equipment-class.schema.json Shape of an equipment-class template +│ ├── uns-subtree.schema.json Shape of a per-site UNS subtree definition +│ └── tag-definition.schema.json Shape of an individual tag declaration inside a template +├── classes/ Equipment-class templates +│ └── fanuc-cnc.json Pilot class per OtOpcUa corrections D1 +├── uns/ Per-site UNS subtree definitions +│ └── example-warsaw-west.json Worked example +└── docs/ + ├── overview.md What this repo is, who uses it, lifecycle + ├── format-decisions.md Why JSON Schema, structure rationale, Protobuf derivation + └── consumer-integration.md How each of the 3 consumers integrates +``` + +## Lifecycle + +1. **Author** — humans edit JSON files in this repo, validated against the schemas in `format/` by the CI gate +2. **Publish** — merging to `main` makes the new content the authoritative version; semver tag on each release +3. **Consume** — each consumer pulls a specific tagged version (or `main` for staging environments) and integrates per `docs/consumer-integration.md` + +The current state has no consumers actively reading from this repo — content is seed-only. First wiring happens in OtOpcUa Phase 1+ when `Equipment.EquipmentClassRef` validation lands (see `lmxopcua/docs/v2/plan.md` decision #112). + +## Open Questions + +Owner-team-decisions (not for OtOpcUa or any single consumer to make alone): + +- **Repo ownership and review process**: who owns this repo? PR review SLA? Format changes need who to sign off? +- **Versioning policy**: semver per release? Per-class versioning vs whole-repo versioning? How do consumers pin versions? +- **Backward-compatibility policy**: when a class adds a new required signal, do all existing equipment in OtOpcUa's central config DB need to be updated atomically? Or graceful degradation? +- **Cross-class shared types**: how does a `Position` measurement (used by both FANUC CNC and a Beckhoff axis) avoid duplicate definitions? +- **Pilot class scope**: FANUC CNC chosen per OtOpcUa corrections D1 because FOCAS already has a fixed pre-defined hierarchy. Confirm with the schemas-repo team that this is the right starting class. +- **Enterprise shortname** (currently `ent` placeholder) — the UNS subtree definitions reference an Enterprise segment; the canonical value comes from organizational naming authority. + +## References + +- 3-year-plan handoff: `gitea.dohertylan.com/dohertj2/3yearplan` → `handoffs/otopcua-handoff.md` §"UNS Naming Hierarchy" + §"Canonical Model Integration" + §"Digital Twin Touchpoints" +- OtOpcUa corrections doc: `gitea.dohertylan.com/dohertj2/3yearplan` → `handoffs/otopcua-corrections-2026-04-17.md` §B2 (schemas-repo dependencies) + §D1 (pilot class) + §D2 (format) +- OtOpcUa v2 plan: `gitea.dohertylan.com/dohertj2/lmxopcua` → `docs/v2/plan.md` decisions #108–115 + §"UNS naming hierarchy" diff --git a/schemas/classes/fanuc-cnc.json b/schemas/classes/fanuc-cnc.json new file mode 100644 index 0000000..57303d0 --- /dev/null +++ b/schemas/classes/fanuc-cnc.json @@ -0,0 +1,87 @@ +{ + "$schema": "../format/equipment-class.schema.json", + "classId": "fanuc-cnc", + "version": "0.1.0", + "displayName": "FANUC CNC", + "description": "FANUC CNC machine tools (mills, lathes, machining centers) accessed via FOCAS2. Pilot equipment class per OtOpcUa corrections doc D1 — chosen because the FOCAS driver already exposes a fixed pre-defined node hierarchy (lmxopcua/docs/v2/driver-specs.md §7) which is essentially a class template already; this file formalizes it.", + "vendor": "FANUC", + "applicability": { + "drivers": ["Focas"], + "models": ["0i-F", "0i-D", "30i-B", "31i-B", "32i-B"] + }, + "signals": [ + { "name": "SeriesNumber", "category": "Identity", "dataType": "String", "description": "CNC series (e.g. '0i-F'). From cnc_sysinfo()." }, + { "name": "Version", "category": "Identity", "dataType": "String", "description": "CNC software version. From cnc_sysinfo()." }, + { "name": "MaxAxes", "category": "Identity", "dataType": "Int32", "description": "Maximum axes supported by this CNC. From cnc_sysinfo()." }, + { "name": "AxisCount", "category": "Identity", "dataType": "Int32", "description": "Active axis count. From cnc_rdaxisname()." }, + + { "name": "RunState", "category": "Status", "dataType": "Int32", "description": "0=STOP, 1=HOLD, 2=START, 3=MSTR. From cnc_statinfo()." }, + { "name": "Mode", "category": "Status", "dataType": "Int32", "description": "0=MDI, 1=AUTO, 3=EDIT, 4=HANDLE, 5=JOG, 7=REF. From cnc_statinfo()." }, + { "name": "MotionState", "category": "Status", "dataType": "Int32", "description": "0=idle, 1=MOTION, 2=DWELL, 3=WAIT. From cnc_statinfo()." }, + { "name": "EmergencyStop", "category": "Status", "dataType": "Boolean", "description": "From cnc_statinfo()." }, + { "name": "AlarmActive", "category": "Status", "dataType": "Int32", "description": "Bitmask of active alarm categories. From cnc_statinfo()." }, + + { + "name": "AxisAbsolutePosition", + "category": "Position", + "dataType": "Float64", + "unit": "mm", + "isArray": true, + "arrayDimension": 32, + "description": "Per-axis absolute position. Indexed by axis number. From cnc_absolute(). FANUC scaled-integer source converted via cnc_getfigure().", + "scaling": { "method": "DecimalPlaces", "decimalPlaces": 3 } + }, + { + "name": "AxisMachinePosition", + "category": "Position", + "dataType": "Float64", + "unit": "mm", + "isArray": true, + "arrayDimension": 32, + "description": "Per-axis machine position. From cnc_machine().", + "scaling": { "method": "DecimalPlaces", "decimalPlaces": 3 } + }, + { + "name": "ActualFeedRate", + "category": "Velocity", + "dataType": "Float64", + "unit": "mm/min", + "description": "Actual feed rate. From cnc_actf()." + }, + { + "name": "SpindleActualSpeed", + "category": "Velocity", + "dataType": "Float64", + "unit": "rpm", + "isArray": true, + "arrayDimension": 8, + "description": "Per-spindle actual speed. From cnc_acts() / cnc_acts2()." + }, + { + "name": "SpindleLoad", + "category": "Process", + "dataType": "Float64", + "unit": "%", + "isArray": true, + "arrayDimension": 8, + "description": "Per-spindle load percentage. From cnc_rdspmeter(). FOCAS2." + }, + + { "name": "MainProgramNumber", "category": "Process", "dataType": "Int32", "description": "From cnc_rdprgnum()." }, + { "name": "RunningProgramNumber", "category": "Process", "dataType": "Int32", "description": "From cnc_rdprgnum()." }, + { "name": "RunningProgramName", "category": "Process", "dataType": "String", "description": "Full program path/name. From cnc_exeprgname(). FOCAS2." }, + { "name": "SequenceNumber", "category": "Process", "dataType": "Int32", "description": "Current N-number. From cnc_rdseqnum()." }, + { "name": "PartsCount", "category": "Counter", "dataType": "Int64", "description": "Parts produced. From cnc_rdparam(6711/6712)." }, + + { "name": "ActiveAlarmCount", "category": "Alarm", "dataType": "Int32", "description": "Number of active alarms. From cnc_rdalmmsg(). FOCAS2." } + ], + "alarms": [ + { "alarmId": "ps-alarm", "displayName": "Program/Setup Alarm", "severity": "High", "description": "P/S alarm category." }, + { "alarmId": "ot-alarm", "displayName": "Overtravel Alarm", "severity": "Critical", "description": "OT alarm category — axis overtravel." }, + { "alarmId": "sv-alarm", "displayName": "Servo Alarm", "severity": "Critical", "description": "Servo subsystem alarm." } + ], + "stateModel": { + "states": ["Running", "Idle", "Faulted", "Starved", "Blocked"], + "derivationNotes": "Derivation lives at Layer 3 (System Platform / Ignition), not in OtOpcUa. Suggested mapping (informational): RunState=2 (START) AND MotionState=1 (MOTION) AND AlarmActive=0 → Running; RunState=0 (STOP) OR RunState=1 (HOLD) → Idle; AlarmActive≠0 OR EmergencyStop=true → Faulted; Starved/Blocked are inferred from upstream/downstream signals not exposed by FOCAS itself." + } +} diff --git a/schemas/docs/consumer-integration.md b/schemas/docs/consumer-integration.md new file mode 100644 index 0000000..9827a4a --- /dev/null +++ b/schemas/docs/consumer-integration.md @@ -0,0 +1,50 @@ +# Consumer Integration + +How each of the three canonical-model consumers integrates with this repo. + +## OtOpcUa equipment namespace + +**Reference**: `lmxopcua/docs/v2/plan.md` decisions #112, #115; `lmxopcua/docs/v2/config-db-schema.md` Equipment table. + +**What it pulls**: equipment-class templates from `classes/` keyed by `Equipment.EquipmentClassRef` in the central config DB. + +**Integration points**: +- At Admin UI draft-publish time: `sp_ValidateDraft` checks that every Equipment row's tag set satisfies the referenced class's required-signal list. Missing required signals = draft validation error. +- At cluster runtime: each OtOpcUa node fetches the relevant class templates at startup (cached locally per the LiteDB cache pattern) and uses them to validate the applied generation. + +**Versioning**: each `EquipmentClassRef` value carries a `classId@version` form (e.g. `fanuc-cnc@0.1.0`). Pinning to a specific version protects against breaking changes in the schemas repo. + +**Status (2026-04-17)**: `Equipment.EquipmentClassRef` ships as a nullable hook column in OtOpcUa Phase 1 with no validation enforcement. Enforcement lands when the schemas repo is publishable and the OtOpcUa team wires the validator into `sp_ValidateDraft`. + +## Redpanda topics + Protobuf schemas + +**What it pulls**: equipment-class templates derive Protobuf message definitions for canonical equipment events (`equipment.state.transitioned`, `equipment.signal.changed`, etc.). + +**Integration points**: +- A code-generation step in the Redpanda team's CI reads `classes/*.json` and emits `.proto` files for each class. +- The generated `.proto` files publish to a Schema Registry (or equivalent) for runtime consumers. +- Versioning: Protobuf schema versions track this repo's tag versions one-for-one. + +**Status (2026-04-17)**: not wired. Redpanda team to design the codegen step when the schemas repo has a stable initial class set. + +## dbt curated layer in Snowflake + +**What it pulls**: equipment-class templates derive column definitions for the curated equipment-state and equipment-signal models in dbt. + +**Integration points**: +- A dbt macro reads `classes/*.json` and generates per-class staging models with the canonical signal columns. +- UNS subtree definitions (`uns/*.json`) drive the dim_site / dim_area / dim_line dimension tables. +- Versioning: dbt project pins to a specific schemas-repo tag; updates require an explicit dbt deploy. + +**Status (2026-04-17)**: not wired. dbt team to design the macro when the schemas repo has a stable initial class set. + +## Cross-consumer compatibility + +A breaking change in a class template (major version bump per CONTRIBUTING.md) requires: + +1. Schemas repo PR with the change + rationale +2. Each consumer team confirms the impact on their integration +3. All three consumers either upgrade simultaneously OR pin to the prior major version until they can upgrade +4. OtOpcUa specifically: a breaking change to a class with existing equipment in production requires a config-generation publish that updates the equipment's `EquipmentClassRef` to the new version + reconciles tag changes + +Breaking changes should be rare. Preferred pattern: add new optional signals (minor bump) + deprecate old ones across multiple minor releases before removing in a major. diff --git a/schemas/docs/format-decisions.md b/schemas/docs/format-decisions.md new file mode 100644 index 0000000..c7b0329 --- /dev/null +++ b/schemas/docs/format-decisions.md @@ -0,0 +1,56 @@ +# Format Decisions + +Why the schemas repo looks the way it does. Each decision is open for the schemas-repo owner team to revisit. + +## D1 — JSON Schema (Draft 2020-12) as the authoring format + +**Alternatives considered**: Protobuf (`.proto`), YAML, custom DSL. + +**Choice**: JSON Schema. + +**Why**: +- Idiomatic for .NET 10 (System.Text.Json + JsonSchema.Net) — OtOpcUa reads templates with no extra dependencies +- Idiomatic for CI tooling — every CI runner can `jq` and validate JSON Schema without extra toolchain (`ajv`, `jsonschema`, etc.) +- Best authoring experience: text format, mergeable in git, structured diffing, IDE autocomplete via the `$schema` reference +- Validation at multiple layers: operator-visible Admin UI errors in OtOpcUa, schemas-repo CI gates, downstream consumer runtime validation +- Protobuf is better for *wire* serialization (size, speed, generated code) but worse for *authoring* (binary, requires `.proto` compiler, poor merge story in git) +- Where wire-format efficiency matters (Redpanda events), we code-generate Protobuf from the JSON Schema source. One-way derivation is simpler than bidirectional sync. + +## D2 — Per-class versioning, semver + +**Alternatives considered**: whole-repo versioning, no versioning. + +**Choice**: each class file has its own `version` field (semver); the repo also tags overall releases. + +**Why**: +- Different classes evolve at different rates (FANUC CNC may stabilize while Modbus PLC catalog grows) +- Consumers can pin per-class for fine-grained compatibility (e.g. `fanuc-cnc@0.1.0` + `modbus-plc@0.3.2`) +- Repo-level tags exist to bundle a known-good combination for consumers that want one anchor + +## D3 — Strict additive policy on minor bumps + +**Why**: removes ambiguity. If I see `class@1.3.0`, I know its signal set is a strict superset of `class@1.0.0` (and `class@1.x.y` for any earlier x.y). Breaking changes only happen at major-version boundaries. + +## D4 — `_default` reserved as placeholder for unused UNS levels + +Imported from `lmxopcua/docs/v2/plan.md` decision #108. + +**Why**: some sites have no Area-level distinction (single-building sites). Rather than letting the UNS path have inconsistent depth across sites, we mandate 5 levels always with `_default` as the placeholder. Downstream consumers can rely on path depth. + +## D5 — Tag names use PascalCase or snake_case (class's choice), NOT UNS-segment regex + +**Why**: UNS path segments (Enterprise/Site/Area/Line/Equipment) are infrastructure-level identifiers — they go on the wire of every browse, every URI, every dashboard filter. The regex (`^[a-z0-9-]{1,32}$`) reflects that constraint. + +Signal names (level 6) are vocabulary-level identifiers — they live inside an equipment node. Keeping them in PascalCase or snake_case (e.g. `RunState`, `actual_feedrate`) is more readable for operators looking at OPC UA browse output, and matches OPC UA SDK conventions which expect identifier-style names rather than URL-safe slugs. + +## D6 — `stateModel` is informational, not authoritative + +**Why**: state derivation lives at Layer 3 (System Platform / Ignition). Placing the derivation rules in the schemas repo would create dual sources of truth (and the schemas-repo version would inevitably drift). Instead, the class template lists which states the class supports + an informational note about what the rough mapping looks like; Layer 3 owns the actual derivation logic. + +## D7 — No per-equipment overrides at this layer + +**Why**: per-equipment config (which specific CNC has which program, etc.) is OtOpcUa's central config DB concern. Mixing per-instance config with per-class definitions in this repo would muddy the separation and cause the repo to grow with deployment-specific data instead of staying small + reusable. + +## D8 — `applicability.drivers` lists OtOpcUa drivers explicitly + +**Why**: the schemas repo is OT-side-focused. The OtOpcUa driver enumeration is the closest thing to a canonical "how do you get raw data from this equipment" vocabulary that exists across the org. If a future class is populated by a non-OtOpcUa source, the field becomes optional or extends. For now, listing OtOpcUa driver IDs makes the consumer-side validation (per `lmxopcua/docs/v2/plan.md` decision #111 — driver type ↔ namespace kind) trivial. diff --git a/schemas/docs/overview.md b/schemas/docs/overview.md new file mode 100644 index 0000000..9c49950 --- /dev/null +++ b/schemas/docs/overview.md @@ -0,0 +1,42 @@ +# Overview + +The `schemas` repo is the org's single source of truth for OT equipment definitions. Three things live here: + +1. **UNS hierarchy** — per-site declarations of which Areas and Lines exist (`uns/`). +2. **Equipment-class templates** — per-class declarations of which raw signals each equipment type exposes (`classes/`). +3. **Format definitions** — JSON Schemas defining what UNS and class files must look like (`format/`). + +## Why a central repo + +Three OT/IT systems consume the same canonical model: + +- **OtOpcUa** equipment namespace — exposes raw signals over OPC UA +- **Redpanda + Protobuf** event topics — canonical event shape on the wire +- **dbt curated layer in Snowflake** — analytics model + +Without a central source, they would drift. With one repo, every consumer pulls a versioned snapshot and validates against it. Drift becomes a CI failure, not a production incident. + +## Lifecycle + +``` +[author edits JSON in this repo] + │ + ▼ +[CI validates against format/*.schema.json] + │ + ▼ +[PR review by maintainer + consumer reps] + │ + ▼ +[merge to main → tag a semver release] + │ + ▼ +[each consumer pulls the tag and integrates per docs/consumer-integration.md] +``` + +## What's NOT in this repo + +- **Per-equipment configuration** — which specific CNC at Warsaw West runs which program. That's per-instance config in OtOpcUa's central config DB (`lmxopcua/docs/v2/config-db-schema.md` Equipment table), not template-level material. +- **State derivation rules** — how raw signals derive into `Running` / `Idle` / `Faulted` / `Starved` / `Blocked`. That's Layer 3 logic in Aveva System Platform / Ignition. Equipment-class templates can declare which states the class supports (`stateModel.states`) but not the derivation itself. +- **Wire-format Protobuf schemas** — those are code-generated from this repo into a separate output artifact for Redpanda. The authoring source is here; the binary wire format is derived. +- **Authoritative LDAP groups, ACL grants, OPC UA security policies** — those live in the consuming systems (OtOpcUa central config DB, identity provider). diff --git a/schemas/format/equipment-class.schema.json b/schemas/format/equipment-class.schema.json new file mode 100644 index 0000000..b8b8aa4 --- /dev/null +++ b/schemas/format/equipment-class.schema.json @@ -0,0 +1,93 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://gitea.dohertylan.com/dohertj2/3yearplan/raw/branch/main/schemas/format/equipment-class.schema.json", + "title": "Equipment-Class Template", + "description": "Declares the canonical raw signal vocabulary for an equipment class. Consumed by OtOpcUa (validates per-equipment tag config), Redpanda (derives Protobuf event schemas), and dbt (derives column definitions for the curated layer).", + "type": "object", + "required": ["classId", "version", "displayName", "signals"], + "additionalProperties": false, + "properties": { + "classId": { + "type": "string", + "pattern": "^[a-z0-9-]{1,64}$", + "description": "Stable logical ID for the class. Lowercase, hyphens allowed. Once published, never changes — clients pin to it." + }, + "version": { + "type": "string", + "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$", + "description": "Semver. Major bump = breaking change; minor = additive non-breaking; patch = doc/clarification only." + }, + "displayName": { + "type": "string", + "minLength": 1, + "maxLength": 128, + "description": "Human-readable name (e.g. 'FANUC CNC')." + }, + "description": { + "type": "string", + "maxLength": 1024, + "description": "Optional longer description." + }, + "vendor": { + "type": "string", + "maxLength": 64, + "description": "Vendor or family (e.g. 'FANUC', 'Allen-Bradley', 'Siemens')." + }, + "applicability": { + "type": "object", + "description": "When this class applies. Optional; omit for vendor-agnostic classes.", + "properties": { + "drivers": { + "type": "array", + "items": { + "type": "string", + "enum": ["Galaxy", "ModbusTcp", "AbCip", "AbLegacy", "S7", "TwinCat", "Focas", "OpcUaClient"] + }, + "description": "Which OtOpcUa drivers can populate equipment of this class." + }, + "models": { + "type": "array", + "items": { "type": "string" }, + "description": "Free-text model identifiers (e.g. '0i-F', '30i-B' for FANUC; '1756-L84E' for Logix)." + } + }, + "additionalProperties": false + }, + "signals": { + "type": "array", + "minItems": 1, + "items": { "$ref": "tag-definition.schema.json" }, + "description": "Required signals every equipment of this class must expose. Operator-configured tags in OtOpcUa are validated against this list — missing required signals = config error; extra signals are allowed (operator-extensible)." + }, + "alarms": { + "type": "array", + "items": { + "type": "object", + "required": ["alarmId", "displayName", "severity"], + "additionalProperties": false, + "properties": { + "alarmId": { "type": "string", "pattern": "^[a-z0-9-]{1,64}$" }, + "displayName": { "type": "string", "minLength": 1, "maxLength": 128 }, + "severity": { "type": "string", "enum": ["Low", "Medium", "High", "Critical"] }, + "description": { "type": "string", "maxLength": 1024 } + } + }, + "description": "Optional canonical alarm definitions for this class." + }, + "stateModel": { + "type": "object", + "description": "Optional declaration of how this class's raw signals derive into the canonical machine-state vocabulary at Layer 3 (System Platform / Ignition). Informational — derivation lives at Layer 3, not in OtOpcUa.", + "properties": { + "states": { + "type": "array", + "items": { + "type": "string", + "enum": ["Running", "Idle", "Faulted", "Starved", "Blocked", "Changeover", "Maintenance", "Setup"] + } + }, + "derivationNotes": { "type": "string", "maxLength": 2048 } + }, + "additionalProperties": false + } + } +} diff --git a/schemas/format/tag-definition.schema.json b/schemas/format/tag-definition.schema.json new file mode 100644 index 0000000..a976565 --- /dev/null +++ b/schemas/format/tag-definition.schema.json @@ -0,0 +1,83 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://gitea.dohertylan.com/dohertj2/3yearplan/raw/branch/main/schemas/format/tag-definition.schema.json", + "title": "Tag Definition (Signal)", + "description": "Declares a single canonical signal an equipment class exposes. Used inside equipment-class templates.", + "type": "object", + "required": ["name", "dataType", "category"], + "additionalProperties": false, + "properties": { + "name": { + "type": "string", + "pattern": "^[A-Za-z][A-Za-z0-9_]{0,63}$", + "description": "Canonical signal name. PascalCase or snake_case at the equipment class's discretion; must be unique within the class. This is the OPC UA browse-name children of the equipment node." + }, + "displayName": { + "type": "string", + "minLength": 1, + "maxLength": 128, + "description": "Human-readable name for dashboards and Admin UI. Defaults to `name` if omitted." + }, + "description": { + "type": "string", + "maxLength": 1024 + }, + "category": { + "type": "string", + "enum": ["Identity", "Status", "Process", "Position", "Velocity", "Acceleration", "Temperature", "Pressure", "Flow", "Counter", "Setpoint", "Command", "Alarm", "Diagnostic", "Other"], + "description": "Coarse-grained category for filtering and dashboarding. Adds taxonomy without forcing a deep type hierarchy." + }, + "dataType": { + "type": "string", + "enum": ["Boolean", "Int16", "Int32", "Int64", "UInt16", "UInt32", "UInt64", "Float32", "Float64", "String", "DateTime", "Reference"], + "description": "OPC UA built-in type. Mirrors the values OtOpcUa drivers map to (per lmxopcua/docs/v2/driver-specs.md DataType column)." + }, + "unit": { + "type": "string", + "maxLength": 32, + "description": "Optional engineering unit (e.g. 'mm', 'mm/min', 'rpm', 'degC', 'bar'). Use UCUM where possible (https://ucum.org/)." + }, + "isArray": { + "type": "boolean", + "default": false, + "description": "True if this signal is an array (e.g. per-axis position vector)." + }, + "arrayDimension": { + "type": "integer", + "minimum": 1, + "description": "Required when isArray=true. Maximum array length." + }, + "accessLevel": { + "type": "string", + "enum": ["Read", "ReadWrite"], + "default": "Read", + "description": "Default access level when this signal is exposed via OPC UA. Per-equipment ACL grants (see lmxopcua/docs/v2/acl-design.md) can further restrict write access." + }, + "writeIdempotent": { + "type": "boolean", + "default": false, + "description": "True if writing the same value twice is safe (setpoint overwrite, mode selection). Drives OtOpcUa's Polly write-retry policy (lmxopcua decision #44–45)." + }, + "isRequired": { + "type": "boolean", + "default": true, + "description": "True (default) means every equipment of this class MUST expose this signal. False means it's class-supported but optional per equipment." + }, + "isHistorized": { + "type": "boolean", + "default": false, + "description": "True if this signal is expected to feed historian. Drives HistoryRead permission and OtOpcUa's IHistorianDataSource binding." + }, + "scaling": { + "type": "object", + "description": "Optional scaling for raw integer values that represent decimal quantities (e.g. FANUC scaled-integer positions).", + "additionalProperties": false, + "properties": { + "method": { "type": "string", "enum": ["Linear", "DecimalPlaces", "DriverDefined"] }, + "multiplier": { "type": "number" }, + "offset": { "type": "number" }, + "decimalPlaces": { "type": "integer" } + } + } + } +} diff --git a/schemas/format/uns-subtree.schema.json b/schemas/format/uns-subtree.schema.json new file mode 100644 index 0000000..2f2237f --- /dev/null +++ b/schemas/format/uns-subtree.schema.json @@ -0,0 +1,64 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://gitea.dohertylan.com/dohertj2/3yearplan/raw/branch/main/schemas/format/uns-subtree.schema.json", + "title": "UNS Subtree (per-site)", + "description": "Declares the canonical UNS subtree for a site: Enterprise (level 1) + Site (level 2) + the Areas / Lines that exist at that site. Equipment (level 5) is configured per-cluster in OtOpcUa, not declared here — this file defines the higher-level structure operators are required to use.", + "type": "object", + "required": ["enterprise", "site", "areas"], + "additionalProperties": false, + "properties": { + "enterprise": { + "type": "string", + "pattern": "^[a-z0-9-]{1,32}$", + "description": "UNS level 1. Must match `ServerCluster.Enterprise` in every OtOpcUa cluster at this site." + }, + "site": { + "type": "string", + "pattern": "^[a-z0-9-]{1,32}$", + "description": "UNS level 2. Must match `ServerCluster.Site` in every OtOpcUa cluster at this site." + }, + "displayName": { + "type": "string", + "minLength": 1, + "maxLength": 128, + "description": "Human-readable site name (e.g. 'Warsaw West')." + }, + "areas": { + "type": "array", + "minItems": 1, + "items": { + "type": "object", + "required": ["name", "displayName"], + "additionalProperties": false, + "properties": { + "name": { + "anyOf": [ + { "type": "string", "pattern": "^[a-z0-9-]{1,32}$" }, + { "const": "_default" } + ], + "description": "UNS level 3 segment. `_default` is the reserved placeholder for sites where Area doesn't apply." + }, + "displayName": { "type": "string", "minLength": 1, "maxLength": 128 }, + "lines": { + "type": "array", + "items": { + "type": "object", + "required": ["name", "displayName"], + "additionalProperties": false, + "properties": { + "name": { + "anyOf": [ + { "type": "string", "pattern": "^[a-z0-9-]{1,32}$" }, + { "const": "_default" } + ], + "description": "UNS level 4 segment." + }, + "displayName": { "type": "string", "minLength": 1, "maxLength": 128 } + } + } + } + } + } + } + } +} diff --git a/schemas/uns/example-warsaw-west.json b/schemas/uns/example-warsaw-west.json new file mode 100644 index 0000000..549b284 --- /dev/null +++ b/schemas/uns/example-warsaw-west.json @@ -0,0 +1,24 @@ +{ + "$schema": "../format/uns-subtree.schema.json", + "enterprise": "ent", + "site": "warsaw-west", + "displayName": "Warsaw West (example)", + "areas": [ + { + "name": "bldg-3", + "displayName": "Building 3", + "lines": [ + { "name": "line-1", "displayName": "Line 1 — Assembly" }, + { "name": "line-2", "displayName": "Line 2 — Machining" }, + { "name": "line-3", "displayName": "Line 3 — Inspection" } + ] + }, + { + "name": "bldg-4", + "displayName": "Building 4", + "lines": [ + { "name": "line-1", "displayName": "Line 1 — Injection Molding" } + ] + } + ] +}