Seed the canonical OT schemas content under 3yearplan/schemas/ as a temporary location until a dedicated schemas repo is created (Gitea push-to-create is disabled, the dedicated repo needs a manual UI step). Initial seed contributed by the OtOpcUa team to unblock the EquipmentClassRef integration timeline (lmxopcua decision #112) and to provide the future cross-team owner with a concrete starting point rather than a blank slate. Marked DRAFT throughout with prominent "ownership TBD" framing in README and CONTRIBUTING — the future owner team should treat this seed as a starting point and revise format / structure / naming as the open questions in README "Open Questions" get resolved.

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) <noreply@anthropic.com>
This commit is contained in:
Joseph Doherty
2026-04-17 12:35:27 -04:00
parent dee56a6846
commit 5953685ffb
12 changed files with 651 additions and 1 deletions

View File

@@ -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

View File

@@ -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

55
schemas/CONTRIBUTING.md Normal file
View File

@@ -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`.

81
schemas/README.md Normal file
View File

@@ -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 #108115 + §"UNS naming hierarchy"

View File

@@ -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."
}
}

View File

@@ -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.

View File

@@ -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.

42
schemas/docs/overview.md Normal file
View File

@@ -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).

View File

@@ -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
}
}
}

View File

@@ -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 #4445)."
},
"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" }
}
}
}
}

View File

@@ -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 }
}
}
}
}
}
}
}
}

View File

@@ -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" }
]
}
]
}